2022.01 ~ 2023.03 동안 진행한 프로젝트가 드디어 끝났습니다. 중간에 코딩테스트나 면접 준비, 건강상의 문제로 중단된 것도 있었지만, 약 1년 2개월 동안 진행한 프로젝트를 마무리해서 기분이 좋습니다.
저는 학교에서 사용하는 커뮤니티 앱의 서버를 구축하는 프로젝트를 진행했습니다. 일반적인 커뮤니티와는 다르게 실명으로 진행하는 커뮤니티입니다. 코로나로 인해 선/후배 간의 인적교류가 줄어들어, 이를 해소하기 위해 실명 커뮤니티를 만들었습니다.
이 프로젝트는 기능 추가만 하고 끝나는 프로젝트가 아닌, 기능을 계속 추가하고 리팩토링하는 것을 목표로 했습니다. 일반적으로 프로젝트는 기능을 추가하고 끝나게 됩니다. 이는 일반적인 회사 업무와 다르다고 생각합니다. 기능의 구현도 많이 집중했지만, 전체적으로 리팩토링하며 코드의 품질을 향상시키도록 노력했던 기억이 있습니다. 리팩토링을 하면서 정말로 성장에 큰 도움이 된 것 같습니다.
iOS 2명 / 백엔드 2명으로 진행한 프로젝트이며, 기술 스택은 Spring MVC / Java / JPA입니다.
진행했던 프로젝트의 단계는 크게 3가지로 나눌 수 있습니다.
1. 기획문서 작성 및 UI / UX 디자인
1.1. 기획/디자이너의 부재
해당 프로젝트는 회사에서 진행하는 것과는 다릅니다. 회사에서는 기획자와 디자이너가 있어서 개발자는 주로 개발에 집중할 수 있는 환경이죠. 하지만 이 프로젝트는 친구들과 함께 진행한 프로젝트였기 때문에 기획부터 디자인까지 함께 진행하였습니다. 우리는 Notion을 이용해서 기획 내용을 문서화하고, Figma를 통해서 UI를 디자인했습니다. 제가 개발자를 위한 공부만 진행했기 때문에 Figma의 사용법이 익숙하지 않았습니다. 하지만 Figma에는 디자인 시스템을 라이브러리에서 공유할 수 있는 기능이 있었기 때문에 PPT를 만드는 것처럼 디자인을 진행할 수 있었습니다. 이 덕분에 PPT로 만들기 어려운 디테일한 부분을 만들 수 있었고, 이는 매우 좋았습니다.
2. API 구현
클라이언트에게 화면을 전달하기 위해 iOS 네이티브를 사용했고 API를 이용해서 서버의 응답을 수신했습니다. 사실 최근에는 하이브리드로 웹과 네이티브의 기능을 많이 사용합니다. 처음에는 초기 로직과 웹을 띄우는 기능은 네이티브를 이용하고, 나머지는 웹을 사용하려고 했습니다. 하지만, 같이 프로젝트하는 친구들의 학습을 위해 iOS는 100% 네이티브로 구현했습니다. 돌이켜 생각해보면, 잘한 선택이었다고 생각합니다. 신입 개발자는 여러 가지를 애매하게 아는 것보다는 한 두 가지를 깊게 파서 이해하는 것이 중요하다고 생각합니다.
최근에는 iOS를 담당하던 친구들이 React에 관심을 가지고 웹으로 개발하는 것 같습니다.
2.1. 경험치 상승 💪🏻
API 개발을 진행하면서 스프링, JPA, 그리고 테스트 코드를 익숙하게 다룰 수 있게 되었습니다. 처음으로 스프링을 이용한 프로젝트를 진행해서 처음에는 많이 어렵기도 했지만, 강의를 다시 보고 정리하면서 익숙해지기 위해 노력했습니다. 가장 좋았던 방법은 백엔드 친구와 함께 토론하면서 진행하는 것이었습니다. 조금이라도 토론할 만한 이야기가 있으면 인텔리제이의 code with me 기능을 이용하여 같이 코드를 작성하고 논의했습니다. 처음에는 내가 프로젝트 참여를 제안했지만, 지금 돌이켜보면 잘한 결정이었던 것 같습니다.
2.2. Spring과 익숙해지기
이전에는 API를 개발할 때, Node.js와 AWS Lambda를 이용해서 구현했습니다. Response를 리턴할 때, Node의 Object(== {} )를 그냥 리턴하면 해당 형식이 Json으로 바로 전달됩니다. 하지만, Spring MVC에서는 API response를 리턴하는 방식이 다양합니다. Response에 맞게 dto class를 만들어서 할 수도 있고, Map으로 만들어서 리턴할 수도 있습니다. 처음에는 너무 복잡하다고 생각했습니다. API의 Response Body를 리턴하기 위해서 클래스를 만들어야하는게 번거로웠습니다. 하지만, 지금은 Dto class를 만들어서 리턴하는 것도 나쁘지 않다고 생각합니다. API의 스펙을 클래스로 바로 볼 수 있고, 해당 스펙을 재사용할 수 있습니다. 가장 중요한 점은 여러 곳에서 쓰이는 스펙이 변경되면, 클래스에서만 변경하면 되기 때문에 유지보수에서도 좋습니다. 그래서 지금은 모두 각 API마다 대응되는 Dto class를 정의했습니다.
@Valid 어노테이션을 이용하여 Http Request Body의 키값들을 검증할 수 있어 좋았습니다. 이전에는 노드에서 검증하는 util.js를 구현하거나 전부 다 체크해야 했습니다. 이 부분은 정말로 좋았습니다.
가장 신기했던 것은 JPA였습니다. 노드에도 ORM이 있지만, 사용해본 것은 이번이 처음이었습니다. 쿼리에 의존적인 개발이 아니라 객체에 집중하면서 개발할 수 있었습니다. 객체의 연관관계를 바꾸거나 내부 속성을 변경하면 변경감지를 통해 자동으로 이에 맞게 update 쿼리가 발생합니다. 정말로 신세계였습니다.
3. 리펙토링
이번 프로젝트에서 가장 힘든 부분이었습니다. 그러나 제 성장에 가장 큰 도움이 되었다고 생각합니다. 처음 API를 구현할 때는 단순히 기능에 초점을 맞추었습니다. 구현한 것이 잘 작동하는지 여부가 가장 중요했습니다.
이전 코드와 비교하면, 지금의 코드는 정말 큰 발전을 이루었습니다.
3.1. 객체지향스러운 코드 구현
3.1.1 자율적인 객체와 응집력
가장 큰 문제는 OOP스럽지 않은 것이었습니다. 비즈니스 로직이 이곳저곳 서비스 계층에 있었으며, 응집력이 낮고 결합도는 높은 상태였습니다. 객체지향스러운 코드로 리팩토링하기 위해 친구와 3주간 고민하며 진행했습니다.
처음 프로젝트를 시작할 당시, Service 계층에서 비즈니스 로직을 구현했습니다. 객체가 자율적이지 못했습니다. OOP는 객체에게 책임을 부여하고 역할을 위임하여 서로 상호작용하는 것이라고 생각합니다. 하지만 처음 구현했던 것은 객체의 데이터를 단순히 getter로 가져오고 서비스 계층에서 로직을 수행했습니다. 이러한 부분을 전부 수정했습니다. 서비스 계층에 있는 비즈니스 로직을 전부 엔티티의 메서드로 이동했습니다. 서비스 계층은 단순히 트랜잭션 안에서 로직의 순서만 나열할 뿐입니다.
간단한 예시를 들어보겠습니다.
만약, 커뮤니티의 소개문구와 태그 정보를 변경한다고 하면 이전에는 changeIntroduce, changeTags를 각각 호출하였습니다
@Entity
public class Community {
private String introduce;
private List<String> tags;
public void changeIntroduce(String introduce){
this.introduce = introduce;
}
public void changeTags(List<String> tags){
this.tags = tags;
}
}
외부 객체는 커뮤니티 업데이트는 introduce와 tags가 변경된다는 걸 알아야 하고 이를 위해 changeXXX 메서드 들을 호출 해야합니다.
위의 코드보다 더 객체지향적인 구현 방법이 있습니다.
@Entity
public class Community {
private String introduce;
private List<String> tags;
public void update(String introduce, List<String> tags){
this.introduce = introduce;
this.tags = tags;
}
}
- update 메서드 이름을 통해서 Community 객체에게 요청할 때 직관적으로 어떤 의미인 지 알 수 있습니다.
- update로직이 변경되면 해당 로직에만 추가하면 됩니다. 변경 포인트도 잘 트래킹할 수 있습니다.
- 서비스 계층은 Community를 조회하고 그 객체에 update라는 메시지를 전달하여 역할을 위임합니다. 단순히 트랜젝션의 로직 순서만 담을 뿐입니다.
- Community는 자신의 데이터를 스스로 관리하는 자율적인 객체가 됩니다.
추가적으로, 엔티티 자체도 더욱 응집력 있게 만들기 위해 임베더블(@Embeddable) 클래스를 사용했습니다. 엔티티의 내부 속성에 비즈니스 로직이나 검증 로직이 있는 경우, 모두 임베더블 클래스 안에 포함시켰습니다. 이렇게 함으로써, 테스트 코드를 작성하면서 응집력이 크게 느껴졌습니다. 로직 및 검증이 있는 임베더블 클래스 안에서 테스트 코드를 작성할 수 있기 때문입니다.
JPA를 공부하면서 “JPA를 잘 다루는 사람은 Entity 클래스보다 Embeddable 클래스가 더 많다”는 말을 이해하지 못했었는데, 이제는 무슨 말인지 이해하게 되었습니다.
3.1.2. 가독성 좋은 코드
OOP에서는 객체의 책임 할당과 역할 위임이 중요하지만, 가독성도 중요합니다. 객체 간의 메시지 전달과 책임 위임 때문입니다.
과거에는 네이밍에 대해 크게 고려하지 않았습니다. 객체의 책임을 잘 모르고 데이터만 갖는 관점으로 생각했기 때문입니다. 이렇게 개발하면 자신의 데이터를 외부에서 조회하며 자신의 책임이 없어지고 분산되어 로직 변경시 변경되는 포인트가 많아집니다. 이로 인해 응집력이 낮아지고 결합도가 커집니다. 객체는 자신의 책임을 가지고 있으며, 자신의 역할이 아니라면 다른 객체에게 요청해야 합니다. 객체들 간의 상호작용이 OOP의 핵심입니다. 그러므로, 다른 객체에게 책임을 위임하기 위해 네이밍이 굉장히 중요합니다. 이는 객체 간의 소통을 위한 언어이기 때문입니다.
3.2. Service Layer 테스트에 대한 고민 - 상태/행위검증
--2023.04.25 수정--
[링크]해당 질문에 대한 답변을 토비님께 받았습니다.
위 내용을 정리하면, 상태검증과 행위검증을 두 가지 방법에 대해서 고정시켜서 생각하는 것 보다 작성하는 코드의 테스트 대상에 중심을 맞추는 것입니다. 어떤걸 검증하고, 어떻게, 얼마나 빠르게 검증하는 관점으로 이번 테스트를 어떻게 구현할지 생각합니다.
토비님께서는 "아래 예시에 있는 코드에서는 상태를 검증하는 것이 빠르고 충분히 검증되기 때문에 상태검증을 한다"고 표현하셨습니다.
즉, 반드시 상태검증 / 행위검증을 한다고 고정하는게 아니라 그 상황마다 정하는 것 입니다. (얼마나 빠르게 만들며, 충분히 검증할 수 있는지, 빠르게 수행할 수 있는지)
다른 계층에 비해서, Service 계층의 테스트 방법에 대해서 많은 고민이 있습니다. Repository 계층은 데이터베이스와 통합으로 테스트를 진행하여 실제 쿼리가 예상대로 진행했는지 상태검증을 하면 됩니다. Controller 계층은 Spring RestDocs를 같이 이용하여 문서화 작업과 동시에 Json으로 잘 만들어지는지 테스트하면 됩니다. Github에 공유된 프로젝트들을 보면 Repository 계층과 Controller는 이와같은 방법으로 많이 진행합니다. 하지만, Service 계층은 상태검증과 행위 검증에 대한 고민이 아직 있습니다.
우선, 우리는 Service 계층을 조금 더 세분화했습니다.
- Transaction이 readOnly로, 쿼리의 속성을 가진 QueryService입니다. 이 서비스는 대개 Dto를 반환합니다.
- Transaction 내에서 insert/update/delete 쿼리가 발생하는 CommandService입니다.
QueryService의 경우, 호출한 결과 Dto에 대한 내부 값의 검증이 필요합니다. 따라서 상태 검증을 수행하면 됩니다.
하지만, CommandService의 경우 아직 어떻게 할지 확실한 기준이 세워지지 않았습니다. 이는 객체의 책임과 테스트 범위 대한 관점 때문입니다.
(이 아래 부분은 제 고민이 담겨있습니다. 댓글로 피드백 주시면 감사드리겠습니다)
CommunityCommandService의 updateCommunity 로직은 커뮤니티를 ID로 가져와 내부 속성을 업데이트합니다.
@Service
@Transactional
public class CommunityCommandService {
private final CommunityRepository communityRepository;
// 생성자..
public void updateCommunity(Long id, String introduce, List<String> tags) {
Community community = communityRepository.findById(id);
community.update(introduce, tags);
}
}
CommunityCommandService는 community 객체에게 update를 위임합니다. 그러면 community 객체는 내부 상태값을 변경합니다.
여기서, 상태 검증인지 행위 검증인지에 따라 테스트가 달라집니다.
public class CommunityCommandServiceTest {
@Test
void 상태검증_테스트() {
Community community = new Community("dummy Intro", List.of("dummy tag"));
given(communityRepository.findById(any)).willReturn(community);
communityCommandService.updateCommunity(1L, "new intro", List.of("new tag"));
assertThat(community.getIntroduce).isEqualTo("new intro");
assertThat(community.getTags).containsExactly("new tag");
}
@Test
void 행위검증_테스트(){
Community community = mock(Community.class);
given(communityRepository.findById(any)).willReturn(community);
communityCommandService.updateCommunity(1L, "new intro", List.of("new tag"));
then(community).should(times(1)).update("new intro", List.of("new tag"))
}
}
상태검증_테스트의 검증 부분을 보면, 위임한 결과에 대해서 테스트를 진행하고 있습니다. Community 클래스의 update를 또 테스트하는 것 같은 느낌이 있습니다. 즉, 서비스 계층의 테스트 영역을 넘어서는 것인지 의문입니다.
반면, 행위검증_테스트는 community.update가 호출하면서 위임했는지에 대해서만 테스트합니다. 하지만, 내부 로직에 하드코딩 되어있는 듯 합니다.
사실 저는 상태검증을 더 선호합니다. 하지만 상태 검증이 객체지향스러운지 잘 모르겠습니다. 어느정도 감수해야하는 것 일까요?
정리하자면,
- 상태검증
- 협력한 결과, 객체의 상태 대한 테스트
- 개인적으로 선호하는 방식. 하지만, 협력한 객체의 상태를 테스트하기 때문에 서비스 계층 자체에 대한 테스트와 거리가 있다고 판단
- 테스트코드는 이 부분을 원래 감수하는 것인가?
- 행위검증
- 협력한 객체의 행위에 대한 테스트
- 객체지향의 관점에서는 객체가 다른 객체에게 위임한 지에 대해서 테스트하는게 자연스럽다고 생각
- 내부 행위에 하드코딩하여 검증
- 협력한 객체의 행위에 대한 테스트
서비스 계층이 다른 객체에게 위임한 결과를 테스트하는게 OOP에 맞다면 저는 상태검증과 통합테스트를 할 것 같습니다. (이 부분에 대해서는 선배 개발자분들의 피드백을 받아보고 싶습니다 😂)
3.3. todo
5월2일 기준, 전부 다 마무리하였습니다😄
다 끝난 줄 알았는데 더 할일이 있습니다. 좋은 코드와 소프트웨어 품질을 위해서 할 게 정말로 많네요
3.3.1. Fixture 도입하기
처음에 테스트코드를 작성할 때 Fixture를 도입하려고 했습니다. 하지만, 조금 복잡한 느낌이 있었습니다. 단위테스트를 작성하기 위해서 Fixture가 어떤게 있는지 보고 그에 맞춰서 테스트를 해야하기 때문입니다. 즉, Fixture의 맥락을 알고 있어야합니다.
초기에는 테스트코드가 적어서 각 테스트에서 Builder나 생성자를 이용해서 만드는게 편했습니다. 맥락을 알 필요가 없이 현재 테스트에서 필요한 것들만 생성하기 때문입니다. 하지만, 반복되는 생성 작업과 많은 테스트코드 때문에 Fixture의 도입이 필요해졌습니다. 이를 통해 반복되는 생성 작업을 줄이고 내부 데이터를 한 곳에서 관리하여 유지보수하기 좋고 테스트하기 편하게 될 것 입니다.
3.3.2. 인수테스트 작성하기
Repository, Service, Controller 계층에 대한 테스트코드는 어느정도 구현이 되었습니다. 하지만, 인수테스트는 아직 작성하지 않았습니다. 인수테스트를 통해서 유저스토리를 적절하게 처리하는지 검증이 이루어져야합니다. 이 부분은 Fixture가 도입되고 진행할 예정입니다
4. 소감
아직 할일이 남았지만, 큰 틀에서 어느정도 끝났습니다. 이 프로젝트를 진행하고 내가 공부할 것들이 정말로 많은 걸 느낍니다. 그래도 즐겁습니다. 제 성장을 볼 수 있기 때문입니다.
Github : https://github.com/sdcodebase/boogi-api-server
문제 해결 - https://sdcodebase.tistory.com/41
성능 튜닝 - https://sdcodebase.tistory.com/38
'프로젝트 > 부기온앤온' 카테고리의 다른 글
HikariCP가 Pending되는 현상과 해결과정 (0) | 2023.04.08 |
---|---|
인덱스를 이용한 쿼리 튜닝 (0) | 2023.04.07 |