지난 주에 엔티티랑 Repository 레이어를 완성했습니다, 이번 주는 서비스 레이어부터 컨트롤러 레이어까지 전체 백엔드 로직을 완성하고, PDF 보고서 기능이랑 외부 서비스 연동까지 마무리한 주였어요. 코드 리뷰를 두 차례 받으면서 수십 건의 이슈를 수정했고, 최종적으로 Jacoco 테스트 커버리지 100%를 달성했습니다!!
서비스를 구현하기 전에 아키텍처 방향을 먼저 잡았어요. 조회와 명령을 분리하는 CQRS 패턴을 도입하고 패키지 구조를 전면 개편했습니다.
기존 플랫한 구조에서 아래처럼 command와 query를 명확히 분리했습니다.
activity/
├── command/
│ ├── controller/
│ ├── domain/entity/
│ ├── application/dto/
│ └── service/
└── query/
├── controller/
├── dto/
├── mapper/
└── service/
조회 쪽은 JPA 대신 MyBatis로 구현했습니다. 복잡한 JOIN 쿼리랑 동적 필터링이 많아서 XML 매퍼로 직접 SQL을 제어하는 방식이 더 맞겠다 싶어서 팀원들과 상의 후 그렇게 정했습니다. 아울러 Enum의 displayName을 한글에서 영문으로 일괄 변경하고, @DataJpaTest 중복 datasource 설정도 함께 정리했습니다.
TDD Red — command 서비스와 query 서비스 테스트를 먼저 작성했습니다. Activity, Contact, EmailLog, ActivityPackage 4개 도메인에 대한 생성/수정/삭제/조회 로직을 테스트로 먼저 명세화했습니다.
TDD Green — 테스트를 통과하는 최소 구현을 작성했습니다. command 서비스는 JPA Repository를 사용하고, query 서비스는 MyBatis 매퍼를 사용하는 구조로 구현했어요.
코드 리뷰 반영으로 서비스 레이어의 버그도 함께 수정했습니다!
TDD Red — command/query 컨트롤러 테스트를 각각 작성했습니다. @WebMvcTest 기반으로 MockMvc를 활용해 HTTP 요청/응답을 검증하는 슬라이스 테스트를 작성했습니다.
TDD Green — REST API 19개 엔드포인트를 완성했습니다.
활동 (5개): 목록 조회, 상세 조회, 생성, 수정, 삭제
연락처 (5개): 전체 조회, 거래처별 조회, 생성, 수정, 삭제
이메일 (4개): 목록 조회, 상세 조회, 발송, 재발송
패키지 (5개): 목록 조회, 상세 조회, 생성, 수정, 삭제
TDD Blue — 컨트롤러와 서비스를 함께 리팩토링했습니다. 이후 Jacoco 커버리지 100%를 달성했습니다!
상세 조회 응답에 외부 서비스 데이터를 붙이는 enrichment 로직을 구현했습니다. activity 서비스는 단독으로 동작하는 게 아니라 master 서비스, auth 서비스, document 서비스와 함께 연동되기 때문입니다.
client_id → master 서비스에서 client_name 조회
po_id → document 서비스에서 PO 정보 조회
user_id 계열 → auth 서비스에서 작성자 이름 조회
Feign Client를 활용해 각 서비스를 호출하고, 응답 DTO에 enrichment된 데이터를 합산해 반환하도록 했습니다. 또 쿼리 컨트롤러에서 JPA 엔티티를 직접 반환하던 부분을 제거하고 DTO 응답 분리도 완성했습니다.
활동 패키지를 PDF로 다운로드하는 기능을 구현했습니다. ActivityPackagePdfReportService를 새로 작성해서 iText 기반으로 PDF를 생성했습니다.
주요 로직은 이렇게 구성했습니다!
단위 테스트, 통합 테스트도 함께 작성해서 PDF 다운로드 전체 흐름을 검증했습니다.
REST API 응답에 HATEOAS 링크를 추가하고, Swagger UI를 통한 API 문서화를 적용했습니다. springdoc-openapi를 2.8.9에서 2.8.16으로 최신 안정 버전으로 올렸습니다.
코드 리뷰에서 발견된 Critical/Major 이슈 17건을 일괄 수정하고, 이후 잔여 이슈 11건을 추가로 수정했고, 주요 수정 내용은 다음과 같습니다.
버그/설계 수정:
성능 수정:
DTO 분리:
프록시 컨트롤러 신규 구현 :
트랜잭션 분리:
성능 관련 Critical 이슈 5건을 추가로 수정하며 마무리했습니다.
Feign 배치 처리: 외부 서비스 조회를 건별로 호출하던 부분을 배치 호출로 전환해서 N+1 HTTP 호출 문제를 해결했습니다.
DB 페이지네이션: 목록 조회 시 전체 데이터를 불러오던 쿼리에 페이지네이션을 적용했습니다.
SMTP 트랜잭션 분리: 메일 발송 로직이 DB 트랜잭션 안에 포함돼 있으면, SMTP 응답 대기 시간 동안 커넥션이 계속 점유돼요. @Transactional 범위 밖으로 메일 발송을 이동시켜서 이 문제를 해결했습니다.
이후 테스트 커버리지 누락 케이스 3건을 추가해서 최종적으로 커버리지를 유지했습니다.
처음엔 패키지를 나누는 게 잘 모르고 번거롭게 느껴졌는데, 조회 로직에 MyBatis를 쓰고 명령 로직에 JPA를 쓰는 게 자연스럽게 분리되니까 각 레이어를 독립적으로 수정할 수 있었고, 특히 코드 리뷰 이슈 수정할 때 command 쪽이랑 query 쪽을 각각 건드려도 서로 영향이 없어서 진짜 편했다!! 그동안은 모르고 그냥 그러려니 하고 사용했는데 알고쓰니까 왜 쓰는지 알겠고 이해됐다.
EmailLog 기본 상태를 SENT로 설정했다가 FAILED로 바꾼 게 인상적이었습니다. 발송 로직 중에 예외가 터지면 상태가 SENT로 남아 있어서 사용자는 메일이 전송됐다고 착각할 수 있거든요. 성공했을 때만 SENT로 바꾸는 방어적 설계가 훨씬 안전하다는 걸 체감했다.
PDF 생성이랑 메일 발송 모두 외부 I/O를 사용하는데, 이걸 @Transactional 안에 넣으면 외부 호출이 느릴 때 DB 커넥션을 불필요하게 오래 점유하게 되었습니다. 트랜잭션이 필요한 DB 작업이랑 외부 I/O를 분리하는 게 성능에 실제로 꽤 큰 영향을 준다는 걸 이번 이슈를 통해 직접 체감했습니다.
MyBatis에서 연관 데이터를 조회할 때 방식을 쓰면 코드가 편해 보이는데, 실제로는 각 행마다 추가 쿼리가 나갔습니다... 그래서 JOIN으로 한 번에 조회하도록 XML을 수정하니까 쿼리 수가 확 줄었습니다.