[덕후감] 프로젝트 개발 리포트

dustle·2026년 5월 3일

코드잇

목록 보기
2/2

개발 리포트 — dstle


1. 프로젝트 개요

도서 리뷰 기반 커뮤니티 서비스입니다.
사용자가 도서를 등록·검색하고 리뷰/좋아요/댓글로 상호작용하며,
일별·주간·월간·전체 기간의 인기 도서·인기 리뷰·파워 유저 대시보드를 제공합니다.

  • 도서/사용자/리뷰/댓글 도메인 CRUD 및 커서 기반 페이지네이션
  • 대시보드(인기 도서/리뷰/파워 유저) 배치 집계 및 조회
  • 리뷰 이벤트 기반 도서 통계(BookStatistics) 동기 집계 (AFTER_COMMIT)
  • Redis 분산락으로 다중 인스턴스 동시성 제어
  • Redis 캐시 + Spring Batch로 대시보드 응답 안정화
  • AWS ECS 배포 + CloudWatch 메트릭 수집

2. 담당한 작업

2.1 리뷰 도메인

PR내용
#80리뷰 도메인 패키지 구조 및 Swagger 설정
#84리뷰 생성 API
#99리뷰 수정 API + 본인 검증 + N+1 방지 fetch join
#108리뷰 논리/물리 삭제
#119리뷰 단건 조회
#124리뷰 목록 조회 (jOOQ + 커서 페이지네이션)
#139리뷰 좋아요 토글

2.2 대시보드 도메인

PR내용
#146대시보드 엔티티 및 패키지 구조
#159인기 도서 배치 + 조회 API
#165인기 리뷰 배치 + 조회 API
#166파워 유저 배치 + 조회 API
#272점수 산정 시 기간 내 지표만 집계하도록 수정
#287배치 기준 날짜를 어제로 수정

2.3 인프라 / 공통

PR내용
#68TestContainers 기반 통합 테스트 환경
#70로컬 개발환경 PostgreSQL 전환
#73Flyway V1 초기 스키마 마이그레이션
#77글로벌 예외 처리 및 ErrorCode 구조
#106jOOQ 빌드 파이프라인 + Docker Compose / README 정리
#130 / #136@LoginUser ArgumentResolver 도입

2.4 동시성·성능 최적화

PR내용
#167Redis 설정
#172@DistributedLock AOP 컴포넌트 + javadoc 문서화
#180ReviewService 분산락 적용
#223리뷰 이벤트 기반 BookStatistics 동기 집계 (AFTER_COMMIT)
#225리뷰 조인 쿼리를 단건으로 분리
#270 / #271책/리뷰 조회 시 집계 테이블 참조로 전환
#285대시보드 Redis 캐시 적용
#289대시보드 배치에 Spring Batch 적용

2.5 모니터링 / 배포

PR내용
#232CloudWatch 의존성 추가
#245ECS task definition에 taskRoleArn 추가
#247 / #260CloudWatchMetricsConfig
#2780.95 percentile 메트릭만 수집하도록 변경

3. 기술적 성과

3.1 사용 기술 스택

  • 언어/프레임워크: Java 17, Spring Boot 3, Spring Batch, Spring Data JPA, Spring AOP, MapStruct
  • 쿼리: jOOQ (동적 쿼리 + 커서 페이지네이션), JPQL
  • DB / 마이그레이션: PostgreSQL, Flyway
  • 인프라: Redis (Redisson 분산락 / Cache), AWS ECS, CloudWatch, Docker Compose
  • 테스트/측정: JUnit 5, Mockito, Testcontainers, K6 + p6spy

3.2 구현한 기능

  • 리뷰 도메인 CRUD + 좋아요: 본인 검증, 중복 리뷰 차단, fetch join을 통한 N+1 방지, 좋아요 토글 시 카운터 UPDATE 쿼리 분리.
  • 대시보드 인기 도서/리뷰/파워 유저: jOOQ로 점수 산식 SQL을 빌드하고 LIMIT N으로 상위 결과만 저장합니다.
  • 커서 페이지네이션 공통화: PageResponse, RankedViewModel 인터페이스, trimToLimit / extractNextCursor / extractNextAfter 헬퍼로 도메인별 중복을 제거했습니다.
  • 이벤트 기반 도서 통계 (동기, AFTER_COMMIT): 리뷰 CUD 시 Spring Event를 발행하고, @TransactionalEventListener(AFTER_COMMIT)으로 같은 스레드에서 동기 처리합니다. 리뷰 CUD와 반드시 함께 반영되어야 하는 작업이라 비동기로 떼어내지 않았고, 트랜잭션이 롤백되면 통계도 반영되지 않도록 보장됩니다.
  • Redis 분산락: @DistributedLock(key, lockParam, waitTime, leaseTime) 어노테이션 + AOP. SpEL 기반 키 추출과 javadoc 문서화로 사용법을 명시했고, @Order로 분산락 어드바이스가 @Transactional보다 바깥에서 감싸도록 우선순위를 조정해 “락 해제 시점 = 트랜잭션 커밋 + AFTER_COMMIT 리스너까지 끝난 시점”이 되도록 했습니다.
  • Spring Batch 도입: 대시보드 배치를 12개 Job으로 분리(기간 * 종류)하고, 1시간 간격 스케줄러 + 최근 30일 실패 날짜 자동 재시도, 성공 Job 자동 스킵 구조로 만들었습니다.

4. 문제점 및 해결 과정 (STAR)

4.1 jOOQ + Flyway + Testcontainers + Docker Compose 빌드/온보딩 환경

  • Situation: 동적 정렬·커서 조건·복잡한 필터가 많은 리뷰/대시보드 쿼리에서 JPQL 문자열 조립이 빠르게 한계를 보였습니다. 또한 PostgreSQL과 Redis가 동시에 필요해, 팀원이 매번 로컬에 직접 설치하라고 요구하기는 곤란했습니다. jOOQ codegen 시점에는 실제 스키마가 떠 있어야 하는 추가 제약도 있었습니다.
  • Task: 타입 안전한 동적 쿼리를 도입하면서, 팀원이 추가 셋업 없이 빌드와 실행을 동시에 할 수 있도록 환경을 만듭니다.
  • Action:
    • Gradle에 jOOQ codegen task를 추가하고(#106), -PgenerateJooq 플래그가 있을 때만 Testcontainers PostgreSQL을 띄워 Flyway로 스키마를 적용한 뒤 코드를 생성하도록 구성했습니다.
    • PostgreSQL과 Redis를 Docker Compose 한 파일에 묶어두고, README에 “최초 1회 -PgenerateJooq → 평소엔 Docker 없이 compileJava / bootRun / test → 마이그레이션 추가나 gradle clean 이후에만 다시 -PgenerateJooq” 흐름을 단계별 명령어와 함께 적었습니다.
  • Result: 세팅 자체는 까다로웠지만, 한 번 만든 후로는 팀 전원이 추가 문의 없이 README만 따라 바로 빌드·실행할 수 있었습니다. 리뷰 목록(#124), 인기 도서/리뷰/파워 유저 집계가 모두 jOOQ 빌더 패턴으로 통일되어 가독성과 재사용성이 좋아졌습니다.

4.2 @ModifyingclearAutomatically=true로 인한 리뷰 INSERT 유실

  • Situation: 리뷰 생성 시 도서의 reviewCount를 증가시키는 increaseReviewCount JPQL을 같은 트랜잭션에서 호출했는데, Swagger로 테스트하면 “리뷰는 무한히 생성되는데 DB에는 한 건도 없는” 상황이 발생했습니다. 로그상으로는 모두 성공이었습니다.

  • Task: 로그는 성공인데 데이터가 사라지는 원인을 찾고 수정합니다.

  • Action: 원인은 @Modifying(clearAutomatically = true, flushAutomatically = false) 조합이었습니다. 흐름은 다음과 같습니다.

    1. save(review) → 영속성 컨텍스트에만 올라감 (DB flush 안 됨)
    2. increaseReviewCount() JPQL 실행 → DB에 직접 UPDATE
    3. clearAutomatically = true → 영속성 컨텍스트 통째로 클리어
    4. 1번에서 올린 review가 flush도 안 된 채로 사라짐

    flushAutomatically = true를 추가해 JPQL 실행 직전에 영속성 컨텍스트를 먼저 flush하도록 수정했습니다.

  • Result: 리뷰 INSERT가 정상적으로 DB에 반영되었습니다. 옵션 한 줄이 데이터 유실로 이어진 사례였고, 이후 @Modifying을 사용하는 모든 쿼리에서 두 옵션을 함께 검토하는 습관을 들였습니다.

4.3 existsBy 메서드 이름 기반 쿼리의 불필요 JOIN 제거

  • Situation: K6 + p6spy 분석 결과 POST /api/reviews의 p95가 122ms였습니다. 중복 체크 쿼리 existsByBookIdAndUserIdAndDeletedAtIsNullreviews → books, reviews → users 두 LEFT JOIN을 자동 생성하고 있었습니다. reviews에는 이미 book_id, user_id FK 컬럼과 (book_id, user_id) 부분 인덱스가 있어 JOIN 없이도 조회가 가능했습니다.

  • Task: 메서드 이름 기반 쿼리에서 발생하는 불필요한 JOIN을 제거합니다.

  • Action: JPA 메서드 이름 파서가 BookIdReview.book.id, UserIdReview.user.id 연관관계를 타고 들어가 자동 JOIN을 만드는 게 원인이었습니다. JPQL을 직접 작성해 r.book.id = :bookId AND r.user.id = :userId AND r.deletedAt IS NULL로 바꿨습니다. Hibernate는 이 형태를 FK 컬럼으로 직접 변환해 JOIN을 제거합니다.

  • Result: K6(20 VU / 30s / 데이터 5만건) 기준 측정값입니다.

    APIp95 (전)p95 (후)개선율
    POST /reviews122ms52ms57%
    GET /reviews/:id47ms14ms70%
    GET /notifications22ms11ms50%
    GET /books24ms10ms58%

    실제로 수정한 쿼리는 하나뿐인데 다른 API까지 같이 빨라졌습니다. HikariCP 기본 풀 크기 10에서 느린 쿼리가 커넥션을 오래 점유해 풀 경합을 만들었고, 그 영향이 무관한 API까지 전파된 사례였습니다. 메서드 이름 기반 쿼리는 편리하지만 의도하지 않은 JOIN을 만들 수 있다는 교훈을 얻었습니다.

4.4 도서 통계 정합성 — BookStatistics + AFTER_COMMIT 동기 이벤트

  • Situation: 도서 조회에서 reviewCount, rating을 자주 사용하는 패턴이라 의도적으로 Book 엔티티에 비정규화 필드로 두고 시작했습니다. 그러다 보니 리뷰 CUD마다 도서 통계를 함께 갱신해야 했고, ReviewService에 도서 책임이 지속적으로 늘어났습니다. 이벤트 발행으로 책임 분리를 시도했지만, 동시성 제어를 위해 락을 잡으려 보니 결국 “리뷰 통계 갱신”을 위해 도서 row 자체를 잠궈야 하는 비효율이 보였습니다.
  • Task: 리뷰 CUD가 도서 락에 묶이지 않게 하면서 통계 정합성을 보장합니다.
  • Action: BookStatistics 별도 집계 테이블을 분리했습니다(#223). 리뷰 CUD에서 Spring Event를 발행하고, 리스너는 @TransactionalEventListener(phase = AFTER_COMMIT)으로 묶었습니다. 이 리스너는 동기로, 같은 스레드에서 트랜잭션 커밋 직후에 실행됩니다. 일시적 실패는 listener 내부의 통계 UPDATE에 한해 retry로 보정했고, 도서 상세/리뷰 서비스는 BookStatistics 참조로 전환했습니다(#270, #271). 부동소수점 평점 표시 정책(소수 첫째 자리 반올림)도 이 작업에서 팀과 합의해 확정했습니다.
  • Result: 리뷰 CUD가 도서 락에서 해방되었고, 통계는 “커밋된 트랜잭션” 단위로만 반영되어 정합성도 함께 잡혔습니다. “조회 빈도 높은 컬럼은 비정규화”라는 초기 판단을 그대로 유지하지 못한 점은 아쉽지만, 동시성과 락 컨텍스트라는 축이 가세하면 정규화·비정규화의 손익 계산이 달라진다는 걸 확인했습니다.

4.5 리뷰 생성 동시성 — Redis 분산락 + K6 검증 + AOP 순서 제어

  • Situation: ECS 다중 인스턴스 환경에서 동일 사용자가 동일 도서에 동시에 리뷰를 생성할 가능성이 있었습니다. K6로 50 VU가 같은 유저+같은 책으로 동시에 생성을 시도했더니 success 1 / duplicate_blocked 40 / unexpected_error 9 (500)가 발생했습니다. 일부 요청이 unique constraint violation 등으로 500을 받고 있었습니다.
  • Task: 동시성 제어를 도입하되 호출부 코드가 더러워지지 않게 하고, 트랜잭션·이벤트 흐름과 충돌하지 않게 정확히 어디서 락을 잡을지 정합니다.
  • Action:
    • @DistributedLock(key, lockParam, waitTime, leaseTime) 어노테이션과 AOP를 만들고(#172), 사용법(키 표현식 규칙, 권장 leaseTime, 락 획득 실패 시 ErrorCode 등)을 어노테이션 javadoc에 정리해 호출부에서 별도 문서를 찾지 않아도 되도록 했습니다.
    • 초기엔 락 획득/해제 try-catch가 호출부에 노출되어 코드가 지저분했는데, AOP 안으로 묶고 호출부에서는 어노테이션 한 줄만 남기는 방향으로 모두 걷어냈습니다.
    • @Order로 분산락 어드바이스를 @Transactional보다 바깥에서 감싸도록 우선순위를 명시했습니다. 흐름은 @DistributedLock@Transactional → 메서드 본체 → publishEvent → 트랜잭션 커밋 → AFTER_COMMIT 리스너(동기) → 락 해제 순으로 정렬되어, 락이 풀리는 시점에 4.4의 통계 반영까지 끝난 상태가 보장됩니다.
    • 락 파라미터는 leaseTime은 Redisson 디폴트를 그대로 두고, waitTime은 트랜잭션 평균 시간보다 짧을 가능성이 보여 더 길게 조정한 이력이 있습니다. 다만 정확한 측정값을 기준으로 한 튜닝이 아니라 추정에 가까운 결정이었고, 운영 트래픽 데이터 위에서 다시 검증할 필요가 있다고 봅니다.
  • Result: 동일 K6 시나리오 재실행 결과 unexpected_error 0 / success 1 / duplicate_blocked 49로 50건이 모두 일관성 있게 처리되었습니다. p95는 락 적용 전 184ms → 적용 후 625ms로 늘었지만, 락 대기에 막힌 소수 요청이 끌어올린 값이고 사용 가능한 범위 안에 있었습니다.

4.6 대시보드 배치 안정성과 캐시 일관성

  • Situation: 초기 대시보드 배치는 새벽 cron 한 번이라, 실패해도 다음 날까지 인지가 어려웠습니다. 캐시(#285)를 붙인 뒤에는 배치 실패 후에도 stale 데이터가 5분 TTL 동안 노출되는 문제까지 겹쳤습니다.
  • Task: 배치 실패 시 자동 복구와 캐시 일관성을 동시에 잡습니다.
  • Action: Spring Batch를 도입했습니다(#289). 대시보드 배치를 3개 독립 Job(popularBooksJob, popularReviewsJob, powerUsersJob)으로 분리하고, 스케줄러를 1시간 간격으로 변경했습니다. 최근 30일 범위 내 실패 Job은 다음 회차에 자동 재실행되며, 성공한 Job은 Spring Batch가 자동으로 스킵합니다. 캐시 evict는 @CacheEvict를 Job 성공 시점에만 호출하도록 DashboardFacade로 옮겼습니다. 캐시 TTL은 5분으로 잡았습니다 — 대시보드 랭킹 데이터는 한 번 산출되면 하루 동안 거의 바뀌지 않는 특성이지만, TTL을 하루로 두면 트래픽이 없는 시간대에도 Redis 메모리를 계속 점유하고 강제 무효화가 필요한 순간에 stale 노출이 길어지기 때문에, 짧게 잡고 배치 성공 시점에 evict하는 방식으로 보완했습니다.
  • Result: 배치 실패가 다음 시간 회차에서 자체 복구되고, 캐시도 “성공한 배치 결과”로만 갱신됩니다.
    클라우드 워치로 확인한 결과 초반 요청에 13.9ms -> 재시도시 4.04ms 로 api 속도 개선하였습니다.

5. 협업 및 피드백

  • 도메인이 리뷰/대시보드 중심이라 도서·사용자·댓글 담당 팀원과 인터페이스 설계를 자주 맞췄습니다. BookStatistics 분리(#223)는 도서 도메인 담당과 컬럼 제거 시점을 합의해야 했고, 댓글 카운트(#149)와 인기 리뷰 점수 산식도 댓글 담당과 합의 후 진행했습니다.
  • 부동소수점 평점 표시 정책(예: 4.6666...4.7)은 코드 단에서 임의로 자르지 않고 팀과 명시적으로 합의해 결정했습니다.
  • 팀원 온보딩 비용을 줄이기 위해 PostgreSQL/Redis를 Docker Compose로 묶어 두고 README에 단계별 명령어와 흐름을 문서화 하였고, @DistributedLock 사용법을 어노테이션 javadoc에 정리해 두었습니다. 덕분에 온보딩이 매끄러웠고, 셋업 문의로 흐름이 끊기는 일도 거의 없었습니다.

6. 코드 품질 및 최적화

  • 계층 분리: 컨트롤러는 DTO만, 서비스는 도메인 로직, 쿼리는 *QueryBuilder + *QueryService로 분리했습니다. QueryServiceservice.query 패키지로 이동(#282)해 명령/조회 책임을 패키지 레벨에서 구분했습니다.
  • N+1 / 카운트 쿼리 최소화: 리뷰 수정 경로에 fetch join 적용(#99), 무한 스크롤에서는 totalElements count 쿼리를 제거하고 null 반환(#159, #279).
  • 불필요 JOIN 제거: 메서드 이름 기반 existsBy가 만들던 자동 JOIN을 JPQL로 교체해 p95를 절반 이하로 줄였고, 그 효과가 다른 API의 커넥션 풀 경합까지 풀어주었습니다 (4.3).
  • 집계 테이블 도입: BookStatistics로 도서 상세 조회 시 매번 리뷰 집계 쿼리를 돌리는 비용을 제거했고, AFTER_COMMIT 동기 이벤트로 정합성을 함께 잡았습니다 (4.4).
  • AOP 순서 제어: @DistributedLock@Transactional보다 바깥에서 감싸도록 @Order로 명시해 락/트랜잭션/이벤트의 종료 순서를 일치시켰습니다 (4.5).
  • 캐시 + Spring Batch: 대시보드 응답에 5분 TTL Redis 캐시를 두고, 캐시 evict는 배치 성공 시점에만 트리거되도록 결합했습니다(#285, #289).
  • 공통화: PageResponse, RankedViewModel, @LoginUser, ErrorCode 글로벌 처리(#77)로 도메인 간 중복을 줄였습니다.
  • 테스트/측정 커버리지: 단위 테스트 + Testcontainers 통합 테스트를 PR마다 동반(#68, #117, #118 등)했고, 분산락은 K6 동시성 시나리오로, 쿼리 최적화는 K6 + p6spy 조합으로 효과를 수치로 검증했습니다.

7. 향후 개선 사항 및 제안

  • 분산락을 좋아요 같은 짧은 atomic UPDATE 경로까지 일괄로 적용했는데, 지나고 보니 DB UPDATE만으로 충분한 경로가 있었습니다. 리뷰 생성처럼 멱등성 보장이 어려운 곳에만 적용 범위를 더 좁게 가져갔으면 좋았을 것 같습니다.
  • BookStatistics 집계를 Spring Event + AFTER_COMMIT 동기 구조까지 만들었지만, 애플리케이션 강제 종료 시점의 이벤트 유실까지 막아주는 Outbox 패턴이나 메시지 큐(SQS/Kafka)로 끌고 가지는 못했습니다. 이 부분까지 갔으면 운영 신뢰도가 한 단계 더 올라갔을 것 같습니다.
  • Spring Batch를 도입하면서 메타데이터 테이블이 무한히 누적되는 부분을 운영 관점에서 같이 챙겼으면 좋았을 것 같습니다. 보관 정책(예: 90일 이전 JOB_INSTANCE 정리)을 정해두지 못한 채 마무리하게 되었습니다.
  • CloudWatch 메트릭 수집까지는 했는데, 알람 룰(배치 실패율, 락 획득 실패율, 캐시 히트율, p95 응답 시간)을 정의해 운영 가시성을 끌어올리지는 못했습니다. 다음에는 메트릭 수집과 알람을 한 묶음으로 가져가면 좋겠습니다. (Grafana/Prometheus는 이슈는 열었지만 이번 사이클에는 도입하지 못했습니다.)
  • 4.3에서 본 커넥션 풀 경합 사례를 단발성으로 끝내지 말고, HikariCP 풀 사용률·슬로우 쿼리 임계 알람까지 같이 걸어 두었으면 좋았을 것 같습니다.

부록 — PR

0개의 댓글