도서 리뷰 기반 커뮤니티 서비스입니다.
사용자가 도서를 등록·검색하고 리뷰/좋아요/댓글로 상호작용하며,
일별·주간·월간·전체 기간의 인기 도서·인기 리뷰·파워 유저 대시보드를 제공합니다.
| PR | 내용 |
|---|---|
| #80 | 리뷰 도메인 패키지 구조 및 Swagger 설정 |
| #84 | 리뷰 생성 API |
| #99 | 리뷰 수정 API + 본인 검증 + N+1 방지 fetch join |
| #108 | 리뷰 논리/물리 삭제 |
| #119 | 리뷰 단건 조회 |
| #124 | 리뷰 목록 조회 (jOOQ + 커서 페이지네이션) |
| #139 | 리뷰 좋아요 토글 |
| PR | 내용 |
|---|---|
| #146 | 대시보드 엔티티 및 패키지 구조 |
| #159 | 인기 도서 배치 + 조회 API |
| #165 | 인기 리뷰 배치 + 조회 API |
| #166 | 파워 유저 배치 + 조회 API |
| #272 | 점수 산정 시 기간 내 지표만 집계하도록 수정 |
| #287 | 배치 기준 날짜를 어제로 수정 |
| PR | 내용 |
|---|---|
| #68 | TestContainers 기반 통합 테스트 환경 |
| #70 | 로컬 개발환경 PostgreSQL 전환 |
| #73 | Flyway V1 초기 스키마 마이그레이션 |
| #77 | 글로벌 예외 처리 및 ErrorCode 구조 |
| #106 | jOOQ 빌드 파이프라인 + Docker Compose / README 정리 |
| #130 / #136 | @LoginUser ArgumentResolver 도입 |
| PR | 내용 |
|---|---|
| #167 | Redis 설정 |
| #172 | @DistributedLock AOP 컴포넌트 + javadoc 문서화 |
| #180 | ReviewService 분산락 적용 |
| #223 | 리뷰 이벤트 기반 BookStatistics 동기 집계 (AFTER_COMMIT) |
| #225 | 리뷰 조인 쿼리를 단건으로 분리 |
| #270 / #271 | 책/리뷰 조회 시 집계 테이블 참조로 전환 |
| #285 | 대시보드 Redis 캐시 적용 |
| #289 | 대시보드 배치에 Spring Batch 적용 |
| PR | 내용 |
|---|---|
| #232 | CloudWatch 의존성 추가 |
| #245 | ECS task definition에 taskRoleArn 추가 |
| #247 / #260 | CloudWatchMetricsConfig |
| #278 | 0.95 percentile 메트릭만 수집하도록 변경 |
LIMIT N으로 상위 결과만 저장합니다.PageResponse, RankedViewModel 인터페이스, trimToLimit / extractNextCursor / extractNextAfter 헬퍼로 도메인별 중복을 제거했습니다.@TransactionalEventListener(AFTER_COMMIT)으로 같은 스레드에서 동기 처리합니다. 리뷰 CUD와 반드시 함께 반영되어야 하는 작업이라 비동기로 떼어내지 않았고, 트랜잭션이 롤백되면 통계도 반영되지 않도록 보장됩니다.@DistributedLock(key, lockParam, waitTime, leaseTime) 어노테이션 + AOP. SpEL 기반 키 추출과 javadoc 문서화로 사용법을 명시했고, @Order로 분산락 어드바이스가 @Transactional보다 바깥에서 감싸도록 우선순위를 조정해 “락 해제 시점 = 트랜잭션 커밋 + AFTER_COMMIT 리스너까지 끝난 시점”이 되도록 했습니다.-PgenerateJooq 플래그가 있을 때만 Testcontainers PostgreSQL을 띄워 Flyway로 스키마를 적용한 뒤 코드를 생성하도록 구성했습니다.-PgenerateJooq → 평소엔 Docker 없이 compileJava / bootRun / test → 마이그레이션 추가나 gradle clean 이후에만 다시 -PgenerateJooq” 흐름을 단계별 명령어와 함께 적었습니다.@Modifying의 clearAutomatically=true로 인한 리뷰 INSERT 유실Situation: 리뷰 생성 시 도서의 reviewCount를 증가시키는 increaseReviewCount JPQL을 같은 트랜잭션에서 호출했는데, Swagger로 테스트하면 “리뷰는 무한히 생성되는데 DB에는 한 건도 없는” 상황이 발생했습니다. 로그상으로는 모두 성공이었습니다.
Task: 로그는 성공인데 데이터가 사라지는 원인을 찾고 수정합니다.
Action: 원인은 @Modifying(clearAutomatically = true, flushAutomatically = false) 조합이었습니다. 흐름은 다음과 같습니다.
save(review) → 영속성 컨텍스트에만 올라감 (DB flush 안 됨)increaseReviewCount() JPQL 실행 → DB에 직접 UPDATEclearAutomatically = true → 영속성 컨텍스트 통째로 클리어flushAutomatically = true를 추가해 JPQL 실행 직전에 영속성 컨텍스트를 먼저 flush하도록 수정했습니다.
Result: 리뷰 INSERT가 정상적으로 DB에 반영되었습니다. 옵션 한 줄이 데이터 유실로 이어진 사례였고, 이후 @Modifying을 사용하는 모든 쿼리에서 두 옵션을 함께 검토하는 습관을 들였습니다.
existsBy 메서드 이름 기반 쿼리의 불필요 JOIN 제거Situation: K6 + p6spy 분석 결과 POST /api/reviews의 p95가 122ms였습니다. 중복 체크 쿼리 existsByBookIdAndUserIdAndDeletedAtIsNull이 reviews → books, reviews → users 두 LEFT JOIN을 자동 생성하고 있었습니다. reviews에는 이미 book_id, user_id FK 컬럼과 (book_id, user_id) 부분 인덱스가 있어 JOIN 없이도 조회가 가능했습니다.
Task: 메서드 이름 기반 쿼리에서 발생하는 불필요한 JOIN을 제거합니다.
Action: JPA 메서드 이름 파서가 BookId → Review.book.id, UserId → Review.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만건) 기준 측정값입니다.
| API | p95 (전) | p95 (후) | 개선율 |
|---|---|---|---|
| POST /reviews | 122ms | 52ms | 57% |
| GET /reviews/:id | 47ms | 14ms | 70% |
| GET /notifications | 22ms | 11ms | 50% |
| GET /books | 24ms | 10ms | 58% |
실제로 수정한 쿼리는 하나뿐인데 다른 API까지 같이 빨라졌습니다. HikariCP 기본 풀 크기 10에서 느린 쿼리가 커넥션을 오래 점유해 풀 경합을 만들었고, 그 영향이 무관한 API까지 전파된 사례였습니다. 메서드 이름 기반 쿼리는 편리하지만 의도하지 않은 JOIN을 만들 수 있다는 교훈을 얻었습니다.
reviewCount, rating을 자주 사용하는 패턴이라 의도적으로 Book 엔티티에 비정규화 필드로 두고 시작했습니다. 그러다 보니 리뷰 CUD마다 도서 통계를 함께 갱신해야 했고, ReviewService에 도서 책임이 지속적으로 늘어났습니다. 이벤트 발행으로 책임 분리를 시도했지만, 동시성 제어를 위해 락을 잡으려 보니 결국 “리뷰 통계 갱신”을 위해 도서 row 자체를 잠궈야 하는 비효율이 보였습니다.BookStatistics 별도 집계 테이블을 분리했습니다(#223). 리뷰 CUD에서 Spring Event를 발행하고, 리스너는 @TransactionalEventListener(phase = AFTER_COMMIT)으로 묶었습니다. 이 리스너는 동기로, 같은 스레드에서 트랜잭션 커밋 직후에 실행됩니다. 일시적 실패는 listener 내부의 통계 UPDATE에 한해 retry로 보정했고, 도서 상세/리뷰 서비스는 BookStatistics 참조로 전환했습니다(#270, #271). 부동소수점 평점 표시 정책(소수 첫째 자리 반올림)도 이 작업에서 팀과 합의해 확정했습니다.success 1 / duplicate_blocked 40 / unexpected_error 9 (500)가 발생했습니다. 일부 요청이 unique constraint violation 등으로 500을 받고 있었습니다.@DistributedLock(key, lockParam, waitTime, leaseTime) 어노테이션과 AOP를 만들고(#172), 사용법(키 표현식 규칙, 권장 leaseTime, 락 획득 실패 시 ErrorCode 등)을 어노테이션 javadoc에 정리해 호출부에서 별도 문서를 찾지 않아도 되도록 했습니다.@Order로 분산락 어드바이스를 @Transactional보다 바깥에서 감싸도록 우선순위를 명시했습니다. 흐름은 @DistributedLock → @Transactional → 메서드 본체 → publishEvent → 트랜잭션 커밋 → AFTER_COMMIT 리스너(동기) → 락 해제 순으로 정렬되어, 락이 풀리는 시점에 4.4의 통계 반영까지 끝난 상태가 보장됩니다.leaseTime은 Redisson 디폴트를 그대로 두고, waitTime은 트랜잭션 평균 시간보다 짧을 가능성이 보여 더 길게 조정한 이력이 있습니다. 다만 정확한 측정값을 기준으로 한 튜닝이 아니라 추정에 가까운 결정이었고, 운영 트래픽 데이터 위에서 다시 검증할 필요가 있다고 봅니다.unexpected_error 0 / success 1 / duplicate_blocked 49로 50건이 모두 일관성 있게 처리되었습니다. p95는 락 적용 전 184ms → 적용 후 625ms로 늘었지만, 락 대기에 막힌 소수 요청이 끌어올린 값이고 사용 가능한 범위 안에 있었습니다.popularBooksJob, popularReviewsJob, powerUsersJob)으로 분리하고, 스케줄러를 1시간 간격으로 변경했습니다. 최근 30일 범위 내 실패 Job은 다음 회차에 자동 재실행되며, 성공한 Job은 Spring Batch가 자동으로 스킵합니다. 캐시 evict는 @CacheEvict를 Job 성공 시점에만 호출하도록 DashboardFacade로 옮겼습니다. 캐시 TTL은 5분으로 잡았습니다 — 대시보드 랭킹 데이터는 한 번 산출되면 하루 동안 거의 바뀌지 않는 특성이지만, TTL을 하루로 두면 트래픽이 없는 시간대에도 Redis 메모리를 계속 점유하고 강제 무효화가 필요한 순간에 stale 노출이 길어지기 때문에, 짧게 잡고 배치 성공 시점에 evict하는 방식으로 보완했습니다.
BookStatistics 분리(#223)는 도서 도메인 담당과 컬럼 제거 시점을 합의해야 했고, 댓글 카운트(#149)와 인기 리뷰 점수 산식도 댓글 담당과 합의 후 진행했습니다.4.6666... → 4.7)은 코드 단에서 임의로 자르지 않고 팀과 명시적으로 합의해 결정했습니다.@DistributedLock 사용법을 어노테이션 javadoc에 정리해 두었습니다. 덕분에 온보딩이 매끄러웠고, 셋업 문의로 흐름이 끊기는 일도 거의 없었습니다.*QueryBuilder + *QueryService로 분리했습니다. QueryService는 service.query 패키지로 이동(#282)해 명령/조회 책임을 패키지 레벨에서 구분했습니다.totalElements count 쿼리를 제거하고 null 반환(#159, #279).existsBy가 만들던 자동 JOIN을 JPQL로 교체해 p95를 절반 이하로 줄였고, 그 효과가 다른 API의 커넥션 풀 경합까지 풀어주었습니다 (4.3).BookStatistics로 도서 상세 조회 시 매번 리뷰 집계 쿼리를 돌리는 비용을 제거했고, AFTER_COMMIT 동기 이벤트로 정합성을 함께 잡았습니다 (4.4).@DistributedLock이 @Transactional보다 바깥에서 감싸도록 @Order로 명시해 락/트랜잭션/이벤트의 종료 순서를 일치시켰습니다 (4.5).PageResponse, RankedViewModel, @LoginUser, ErrorCode 글로벌 처리(#77)로 도메인 간 중복을 줄였습니다.JOB_INSTANCE 정리)을 정해두지 못한 채 마무리하게 되었습니다.@Order로 트랜잭션과의 순서 명시