한화시스템 SW BEYOND CAMP 22기 23 주차 회고록

chanjin·2026년 4월 5일

[회고] Spring Boot Activity 서비스 — 서비스/컨트롤러/PDF 생성 (3/29 ~ 4/5)

한 주 요약

지난 주에 엔티티랑 Repository 레이어를 완성했습니다, 이번 주는 서비스 레이어부터 컨트롤러 레이어까지 전체 백엔드 로직을 완성하고, PDF 보고서 기능이랑 외부 서비스 연동까지 마무리한 주였어요. 코드 리뷰를 두 차례 받으면서 수십 건의 이슈를 수정했고, 최종적으로 Jacoco 테스트 커버리지 100%를 달성했습니다!!


작업 흐름

1. CQRS 패턴 도입 및 패키지 구조 전면 개편

서비스를 구현하기 전에 아키텍처 방향을 먼저 잡았어요. 조회와 명령을 분리하는 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 설정도 함께 정리했습니다.


2. 서비스 레이어 TDD

TDD Red — command 서비스와 query 서비스 테스트를 먼저 작성했습니다. Activity, Contact, EmailLog, ActivityPackage 4개 도메인에 대한 생성/수정/삭제/조회 로직을 테스트로 먼저 명세화했습니다.
TDD Green — 테스트를 통과하는 최소 구현을 작성했습니다. command 서비스는 JPA Repository를 사용하고, query 서비스는 MyBatis 매퍼를 사용하는 구조로 구현했어요.
코드 리뷰 반영으로 서비스 레이어의 버그도 함께 수정했습니다!


3. 컨트롤러 레이어 TDD + REST API 19개 완성

TDD Red — command/query 컨트롤러 테스트를 각각 작성했습니다. @WebMvcTest 기반으로 MockMvc를 활용해 HTTP 요청/응답을 검증하는 슬라이스 테스트를 작성했습니다.
TDD Green — REST API 19개 엔드포인트를 완성했습니다.

활동 (5개): 목록 조회, 상세 조회, 생성, 수정, 삭제
연락처 (5개): 전체 조회, 거래처별 조회, 생성, 수정, 삭제
이메일 (4개): 목록 조회, 상세 조회, 발송, 재발송
패키지 (5개): 목록 조회, 상세 조회, 생성, 수정, 삭제

TDD Blue — 컨트롤러와 서비스를 함께 리팩토링했습니다. 이후 Jacoco 커버리지 100%를 달성했습니다!


4. 외부 서비스 Enrichment 구현

상세 조회 응답에 외부 서비스 데이터를 붙이는 enrichment 로직을 구현했습니다. activity 서비스는 단독으로 동작하는 게 아니라 master 서비스, auth 서비스, document 서비스와 함께 연동되기 때문입니다.

client_id → master 서비스에서 client_name 조회
po_id → document 서비스에서 PO 정보 조회
user_id 계열 → auth 서비스에서 작성자 이름 조회

Feign Client를 활용해 각 서비스를 호출하고, 응답 DTO에 enrichment된 데이터를 합산해 반환하도록 했습니다. 또 쿼리 컨트롤러에서 JPA 엔티티를 직접 반환하던 부분을 제거하고 DTO 응답 분리도 완성했습니다.


5. PDF 보고서 생성 기능 구현

활동 패키지를 PDF로 다운로드하는 기능을 구현했습니다. ActivityPackagePdfReportService를 새로 작성해서 iText 기반으로 PDF를 생성했습니다.

주요 로직은 이렇게 구성했습니다!

  • 패키지에 제목이 있으면 이를 우선적으로 문서 제목 및 파일명으로 사용
  • X-User-Id 헤더로 요청자 이름을 auth 서비스에서 조회해 보고서 작성자로 출력
  • 파일명 sanitize 로직으로 특수문자 제거

단위 테스트, 통합 테스트도 함께 작성해서 PDF 다운로드 전체 흐름을 검증했습니다.


6. HATEOAS + Swagger UI 적용

REST API 응답에 HATEOAS 링크를 추가하고, Swagger UI를 통한 API 문서화를 적용했습니다. springdoc-openapi를 2.8.9에서 2.8.16으로 최신 안정 버전으로 올렸습니다.


7. 코드 리뷰 이슈 대량 수정

코드 리뷰에서 발견된 Critical/Major 이슈 17건을 일괄 수정하고, 이후 잔여 이슈 11건을 추가로 수정했고, 주요 수정 내용은 다음과 같습니다.

버그/설계 수정:

  • EmailLog 기본 상태를 SENT에서 FAILED로 변경 (발송 성공 시에만 SENT로 바꾸는 방어적 설계)
  • EmailLogAttachment에서 @Setter 제거
  • GlobalExceptionHandler에서 PdfGenerationException을 500으로 분리 처리
  • updateAll()에서 activityIds null 처리를 viewerIds와 동일하게 통일

성능 수정:

  • MyBatis N+1 문제 해결 — nested select 방식을 JOIN으로 변경
  • Activity, EmailLog 동적 필터 쿼리 추가 (프론트엔드 검색 조건 매칭)
  • 패키지 viewer 권한 기반 필터 및 목록 enrichment 구현

DTO 분리:

  • EmailLogResponse에서 JPA 엔티티 의존 제거, EmailLogTypeResponse DTO 신규 추가

프록시 컨트롤러 신규 구현 :

  • GET /api/clients, GET /api/pos — 프론트엔드가 activity 서비스를 통해 외부 서비스에 접근할 수 있도록 프록시 엔드포인트 추가

트랜잭션 분리:

  • ActivityPackagePdfReportService에서 @Transactional 제거 — Feign 호출 시 DB 커넥션 풀을 점유하지 않도록 분리
  • sendMail() catch 블록에 log.error() 추가

8. 성능 이슈 및 배포 환경 보완

성능 관련 Critical 이슈 5건을 추가로 수정하며 마무리했습니다.

  • Feign 배치 처리: 외부 서비스 조회를 건별로 호출하던 부분을 배치 호출로 전환해서 N+1 HTTP 호출 문제를 해결했습니다.

  • DB 페이지네이션: 목록 조회 시 전체 데이터를 불러오던 쿼리에 페이지네이션을 적용했습니다.

  • SMTP 트랜잭션 분리: 메일 발송 로직이 DB 트랜잭션 안에 포함돼 있으면, SMTP 응답 대기 시간 동안 커넥션이 계속 점유돼요. @Transactional 범위 밖으로 메일 발송을 이동시켜서 이 문제를 해결했습니다.
    이후 테스트 커버리지 누락 케이스 3건을 추가해서 최종적으로 커버리지를 유지했습니다.


배운 점

CQRS는 유지보수에 실제로 도움이 됐다!

처음엔 패키지를 나누는 게 잘 모르고 번거롭게 느껴졌는데, 조회 로직에 MyBatis를 쓰고 명령 로직에 JPA를 쓰는 게 자연스럽게 분리되니까 각 레이어를 독립적으로 수정할 수 있었고, 특히 코드 리뷰 이슈 수정할 때 command 쪽이랑 query 쪽을 각각 건드려도 서로 영향이 없어서 진짜 편했다!! 그동안은 모르고 그냥 그러려니 하고 사용했는데 알고쓰니까 왜 쓰는지 알겠고 이해됐다.

"방어적 기본값"의 중요성

EmailLog 기본 상태를 SENT로 설정했다가 FAILED로 바꾼 게 인상적이었습니다. 발송 로직 중에 예외가 터지면 상태가 SENT로 남아 있어서 사용자는 메일이 전송됐다고 착각할 수 있거든요. 성공했을 때만 SENT로 바꾸는 방어적 설계가 훨씬 안전하다는 걸 체감했다.

트랜잭션 범위는 최소로

PDF 생성이랑 메일 발송 모두 외부 I/O를 사용하는데, 이걸 @Transactional 안에 넣으면 외부 호출이 느릴 때 DB 커넥션을 불필요하게 오래 점유하게 되었습니다. 트랜잭션이 필요한 DB 작업이랑 외부 I/O를 분리하는 게 성능에 실제로 꽤 큰 영향을 준다는 걸 이번 이슈를 통해 직접 체감했습니다.

MyBatis N+1은 nested select가 아니라 JOIN으로

MyBatis에서 연관 데이터를 조회할 때 방식을 쓰면 코드가 편해 보이는데, 실제로는 각 행마다 추가 쿼리가 나갔습니다... 그래서 JOIN으로 한 번에 조회하도록 XML을 수정하니까 쿼리 수가 확 줄었습니다.

profile
찬지니에오

0개의 댓글