Skip to content

[volume-5] 인덱스와 캐시를 활용한 상품 조회 읽기 성능 최적화#199

Open
madirony wants to merge 8 commits intoLoopers-dev-lab:madironyfrom
madirony:volume-5
Open

[volume-5] 인덱스와 캐시를 활용한 상품 조회 읽기 성능 최적화#199
madirony wants to merge 8 commits intoLoopers-dev-lab:madironyfrom
madirony:volume-5

Conversation

@madirony
Copy link
Contributor

@madirony madirony commented Mar 12, 2026

📌 Summary

  • 배경: 브랜드별 상품 목록 조회 시 likes 테이블 JOIN 집계(최대 500만 행) + fullscan이 발생하여 응답 지연이 심각했고, 캐시 부재로 동일 요청이 매번 DB를 타격하고 있었다.
  • 목표: 유스케이스 트래픽 분석 기반 인덱스 설계 + Redis 부분 캐싱 + 이벤트 성격별 무효화 전략 분리로, 읽기 성능을 극대화한다.
  • 결과: 실제 Docker 환경에 1,000만 건까지 스케일업하여 실측한 결과, 100만 건 기준 141배, 1,000만 건 기준 2,717배 개선. 캐시 히트 시 DB 호출 0회.

🧭 Context & Decision

문제 정의

  • 현재 동작/제약: 좋아요 수 조회 시 likes 테이블을 COUNT JOIN하여 집계. 정렬/필터에 인덱스 없이 fullscan 수행.
  • 문제(또는 리스크):
    • JOIN 기반 집계는 데이터 증가에 비례하여 O(N) 비용 증가
    • increaseLikeCount() / decreaseLikeCount() 도메인 메서드가 JPA 변경 감지 기반이라 동시 요청 시 Lost Update 발생 가능
    • 캐시 부재로 인기 브랜드 페이지에 트래픽 집중 시 DB 부하 직격
  • 성공 기준:
    • EXPLAIN에서 인덱스 사용 확인 (type: ref, Using index condition)
    • 좋아요 증감이 DB 레벨 atomic으로 동작
    • 캐시 장애 시에도 DB fallback으로 서비스 정상 동작

선택지와 결정

① 인덱스 전략 — 유스케이스 트래픽 기반 설계

정렬에 사용되는 컬럼에 인덱스가 없으면 데이터 규모가 커질수록 filesort가 슬로우 쿼리로 직결된다. 유스케이스를 분석하여 정렬 조건에 대해 복합 인덱스를 설계했다:

인덱스 커버하는 쿼리 핵심 타겟
(brand_id, deleted, like_count DESC) 브랜드별 좋아요 순 조회 브랜드관 핵심 트래픽 (EXPLAIN 실측 모델)
(deleted, like_count DESC) 전체 상품 좋아요 순 조회 메인 홈 최다 트래픽
(deleted, created_at DESC) 전체 상품 최신순 조회
(deleted, base_price ASC) 전체 상품 가격순 조회

이 중 브랜드별 좋아요 순 인덱스에 대해 Docker 환경에서 1,000만 건까지 스케일업하여 실측했다:

  • 10만 건: fullscan 22.78ms → index 0.20ms (114배 개선)
  • 100만 건: fullscan 201.17ms → index 1.43ms (141배 개선)
  • 1,000만 건: fullscan 1,440ms → index 0.53ms (2,717배 개선)
  • 인덱스 쿼리는 데이터 100배 증가에도 모두 sub-ms 범위에 수렴. LIMIT 20 조기 종료로 실제 스캔 행이 데이터 규모와 무관하게 고정되기 때문.

② 좋아요 수 집계 방식

  • A: Materialized View — DB가 자동 갱신하지만 MySQL 미지원, 수동 REFRESH 필요
  • B: 비정규화 (likeCount 컬럼) — 쓰기 시 동기화 비용 발생하지만 읽기 O(1)
  • 최종 결정: B — 읽기 비율이 압도적인 상품 목록 특성상 비정규화가 적합. @Modifying + SET likeCount = likeCount + 1로 DB 레벨 원자적 증감하여 Lost Update 원천 차단.

③ 캐시 전략 — "왜 Redis + 부분 캐싱인가"

로컬 캐시(Caffeine) vs 분산 캐시(Redis)

로컬 캐시는 단일 인스턴스에서는 빠르지만, Scale-out 시 인스턴스 간 캐시 정합성 문제가 발생한다. 인스턴스 A에서 Admin이 상품을 수정해도 인스턴스 B의 로컬 캐시는 갱신되지 않아 사용자마다 다른 데이터를 보게 된다. Redis 분산 캐시를 선택하여 다중 인스턴스 환경에서도 단일 캐시 소스를 보장, Scale-out 시 정합성 문제를 원천 차단했다.

캐시 패턴 선택

  • A: Write-Through — 쓰기 시 캐시 동시 갱신. 일관성 높지만 쓰기 비용 증가
  • B: Write-Behind — 비동기 갱신. 성능 최적이지만 데이터 유실 리스크
  • C: Cache-Aside — 읽기 시 캐시 확인 → 미스 시 DB 조회 → 캐시 저장
  • 최종 결정: C — 읽기 중심 워크로드에 가장 적합. Admin 수정/삭제 시 명시적 evict로 일관성 보장.

부분 캐싱(Partial Caching) + TTL Jitter

전체 페이지를 무차별 캐싱하면 거의 접근되지 않는 deep page까지 Redis 메모리를 점유하는 Cache Pollution이 발생한다. 실제 사용자 트래픽의 90% 이상은 1-3페이지에 집중되므로, CACHEABLE_PAGE_LIMIT = 2 (page 0~2)로 제한하여 캐시 적중률은 최대화하면서 메모리 낭비는 최소화했다.

또한 동일 TTL로 캐시가 동시에 만료되면 순간적으로 DB에 요청이 몰리는 Cache Stampede(Thundering Herd) 가 발생할 수 있다. 이를 방어하기 위해 TTL에 랜덤 Jitter를 추가했다:

  • 목록: 60초 + 1~10초 jitter
  • 상세: 300초 + 1~30초 jitter

④ 이벤트 성격별 캐시 무효화 전략 분리

모든 변경 이벤트를 동일하게 처리하지 않고, 이벤트의 빈도와 정합성 요구 수준에 따라 전략을 분리했다:

이벤트 빈도 무효화 전략 근거
Admin 상품 수정/삭제 낮음 즉시 evict (상세 1건 + 목록 page 0~2) 상품명/가격 변경은 사용자가 즉시 알아야 함
좋아요 토글 높음 (초당 수십~수백) 무효화 없이 TTL 만료에 의존 매 토글마다 캐시를 지우면 캐시 존재 의미 상실. "좋아요 42개 vs 43개"는 TTL(60초+jitter) 내 eventual consistency 허용

트레이드오프:

  • 비정규화로 인해 likeCount와 실제 likes 테이블 간 미세한 정합성 차이 가능 (eventual consistency)
  • 좋아요 토글 시 캐시 TTL 동안 최신 likeCount 미반영 (목록 최대 70초, 상세 최대 330초)

추후 개선 여지:

  • 좋아요 이벤트 기반 비동기 캐시 갱신 (Kafka CDC)
  • 인기 상품 프리워밍 배치

🏗️ Design Overview

변경 범위

  • 영향 받는 모듈: commerce-api (domain, application, infrastructure, interfaces), jpa, redis
  • 신규 추가:
    • ProductCacheManager (application 인터페이스)
    • RedisProductCacheManager (infrastructure 구현체)
    • CachedProductDetail, CachedBrandProductPage (캐시 전용 DTO)
    • GET /api/v1/brands/{brandId}/products (브랜드별 상품 목록 API)
  • 제거/대체:
    • Product.increaseLikeCount() / decreaseLikeCount() 도메인 메서드 제거 → DB @Modifying 쿼리로 대체
    • Money.subtract()IllegalArgumentExceptionCoreException으로 통일

주요 컴포넌트 책임

  • Product: 정렬 조건별 복합 인덱스 4개 선언. 비정규화된 likeCount 필드 보유.
  • ProductJpaRepository: @Modifying 원자적 증감 쿼리 + 브랜드별 페이징 쿼리 (likeCount DESC)
  • ProductCacheManager: 캐시 get/put/evict 계약 정의. application 계층에 위치하여 DIP 적용.
  • RedisProductCacheManager: Redis 기반 구현. TTL Jitter, 예외 흡수(DB fallback), ObjectMapper 캡슐화. 인프라 관심사가 인터페이스 밖으로 누출되지 않음.
  • ProductAppService: Cache-Aside 조회 로직. CACHEABLE_PAGE_LIMIT = 2로 page 0~2만 캐싱하여 Cache Pollution 방지.
  • AdminProductAppService: 상품 수정/삭제 시 evictProductCaches() 호출하여 캐시 무효화.
  • ProductFacade: 순수 오케스트레이터. Repository 직접 호출 0건. CachedProductDetail 기반으로 상세 조회 조합.
  • ProductController: 브랜드별 상품 목록 엔드포인트 추가. Deep Paging 방지 (MAX_PAGE = 50).
  • RedisConfig: Fail-Fast 타임아웃 (COMMAND 500ms, CONNECT 300ms) + REJECT_COMMANDS 설정으로 Redis 장애 시 빠른 실패 후 DB fallback 유도.

🔁 Flow Diagram

Main Flow — 브랜드별 상품 목록 조회 (캐시 히트)

sequenceDiagram
  autonumber
  participant Client
  participant Controller
  participant Facade
  participant AppService
  participant CacheManager
  participant Redis

  Client->>Controller: GET /api/v1/brands/{id}/products?page=0
  Controller->>Facade: getProductsByBrand(brandId, page, size)
  Facade->>AppService: getProductsByBrandIdCached(brandId, page, size)
  AppService->>CacheManager: getProductList(brandId, page, size)
  CacheManager->>Redis: GET product:brand:{id}:page:0:size:20
  Redis-->>CacheManager: JSON hit
  CacheManager-->>AppService: Optional.of(CachedBrandProductPage)
  AppService-->>Facade: CachedBrandProductPage
  Facade->>Facade: Brand 조회 + ProductInfo 조합
  Facade-->>Controller: Page<ProductInfo>
  Controller-->>Client: ApiResponse<BrandProductListResponse>
Loading

Cache Miss → DB Fallback

sequenceDiagram
  autonumber
  participant AppService
  participant CacheManager
  participant Redis
  participant Repository
  participant DB

  AppService->>CacheManager: getProductList(brandId, page, size)
  CacheManager->>Redis: GET (miss or error)
  Redis-->>CacheManager: null / exception
  CacheManager-->>AppService: Optional.empty()
  AppService->>Repository: findByBrandIdWithPaging(brandId, pageable)
  Repository->>DB: SELECT ... WHERE brand_id=? AND deleted=false ORDER BY like_count DESC
  Note over DB: idx_products_brand_deleted_likes 인덱스 사용
  DB-->>Repository: Page<Product>
  Repository-->>AppService: Page<Product>
  AppService->>CacheManager: putProductList(brandId, page, size, result)
  CacheManager->>Redis: SET (TTL 60+jitter초)
Loading

캐시 무효화 (Admin 수정/삭제)

sequenceDiagram
  autonumber
  participant Admin
  participant AdminAppService
  participant CacheManager
  participant Redis

  Admin->>AdminAppService: update(id, name, price) / delete(id)
  AdminAppService->>AdminAppService: 도메인 로직 수행
  AdminAppService->>CacheManager: evictProductCaches(productId, brandId)
  CacheManager->>Redis: DEL product:detail:{id}
  CacheManager->>Redis: DEL product:brand:{brandId}:page:0~2
Loading

✅ Checklist

🔖 Index

  • 상품 목록 API에서 brandId 기반 검색, 좋아요 순 정렬 등을 처리했다
    • 정렬 조건별 복합 인덱스 4개 적용: 브랜드+좋아요순, 전체 좋아요순, 최신순, 가격순
    • ProductJpaRepositoryORDER BY likeCount DESC 페이징 쿼리 추가
  • 조회 필터, 정렬 조건별 유즈케이스를 분석하여 인덱스를 적용하고 전후 성능비교를 진행했다
    • 10만 건: fullscan 22.78ms → index 0.20ms (114배 개선)
    • 100만 건(Docker 실측): fullscan 201.17ms → index 1.43ms (141배 개선)
    • 1,000만 건(Docker 실측): fullscan 1,440ms → index 0.53ms (2,717배 개선)

❤️ Structure

  • 상품 목록/상세 조회 시 좋아요 수를 조회 및 좋아요 순 정렬이 가능하도록 구조 개선을 진행했다
    • Product.likeCount 비정규화 필드로 JOIN 제거, 인덱스 정렬 활용
  • 좋아요 적용/해제 진행 시 상품 좋아요 수 또한 정상적으로 동기화되도록 진행하였다
    • @Modifying SET likeCount = likeCount ± 1 DB 원자적 증감
    • 음수 방지: CASE WHEN likeCount > 0 THEN ... ELSE 0 END

⚡ Cache

  • Redis 캐시를 적용하고 TTL 또는 무효화 전략을 적용했다
    • Cache-Aside 패턴: 목록 TTL 60+jitter초, 상세 TTL 300+jitter초
    • Admin 수정/삭제 시 evictProductCaches()로 즉시 무효화, 좋아요 토글은 TTL 만료에 의존 (이벤트 성격별 전략 분리)
    • page 0~2만 캐싱(Partial Caching)하여 Cache Pollution 방지
  • 캐시 미스 상황에서도 서비스가 정상 동작하도록 처리했다
    • Redis 예외 흡수 → Optional.empty() → DB fallback
    • Fail-Fast 타임아웃: COMMAND 500ms, CONNECT 300ms
    • DisconnectedBehavior.REJECT_COMMANDS로 장애 전파 차단

변경 목적: 브랜드별 상품 목록 조회 시 likes 집계와 정렬 인덱 부재로 발생한 full scan·응답 지연과 반복 요청으로 인한 DB 부담을 복합 인덱스와 Redis Cache-Aside 도입으로 해소하여 읽기 성능을 극대화하고 DB 원자성/캐시 장애 시 폴백을 보장함.

핵심 변경점: Product 엔티티에 4개 복합 인덱스 추가(brand_id, deleted, like_count DESC 등), likeCount 비정규화 및 도메인 증감 메서드 제거 -> @Modifying 쿼리로 DB 원자적 증감; ProductCacheManager 인터페이스 + RedisProductCacheManager 구현(목록 TTL 60s±jitter, 상세 TTL 300s±jitter, CACHEABLE_PAGE_LIMIT=2로 부분 캐싱, Redis 타임아웃 설정 및 예외 흡수); 관리자 수정/삭제 즉시 evict; GET /api/v1/brands/{brandId}/products 엔드포인트/페이징 제한(MAX_PAGE=50) 추가.

리스크/주의사항: 부분 캐싱(페이지 0~2)은 TTL 기반 일관성 지연(목록 최대 약 60s) 발생 가능, 좋아요 토글은 즉시 evict하지 않고 TTL 만료에 의존해 최대 DETAIL_TTL(약 300s) 불일치가 생길 수 있음; Redis 예외 흡수로 인한 장기 캐시 미적중 상태·모니터링 필요. 위 행위(부분 캐싱 범위·TTL·좋아요 무효화 전략)가 비즈니스 요구에 부합하는지 확인 필요.

테스트/검증 방법: 통합·E2E 테스트(ProductAppServiceIntegrationTest, BrandProductApiE2ETest)로 페이징·정렬·브랜드 필터링 및 에러(404/400) 검증, 캐시 행위는 ProductAppServiceTest.BrandProductCacheTest로 히트/미스·예외 폴백·deep page bypass 검증, 실제 트래픽 하 성능(응답시간, DB 쿼리 감소)과 캐시 일관성(좋아요 반영 지연) 측정 권장. 추가로, TTL·CACHEABLE_PAGE_LIMIT 변경 시 성능·일관성 영향 측정이 필요함.

- subtract()에서 IllegalArgumentException 대신 CoreException(BAD_REQUEST) 사용
- @JsonCreator/@JsonProperty 추가로 Redis 캐시 직렬화/역직렬화 지원
- (brand_id, deleted, like_count DESC) 복합 인덱스 추가 — fullscan 제거
- likeCount 증감 도메인 메서드 제거 — DB 원자적 @Modifying 쿼리로 대체 완료
- findByBrandIdWithPaging 페이징 쿼리 추가 (likeCount DESC 정렬)
- ddl-auto를 update로 변경하여 인덱스 자동 생성 지원
- ProductCacheManager 인터페이스를 application 계층에 정의 (DIP)
- RedisProductCacheManager를 infrastructure 계층에 구현
- TTL Jitter 적용 (목록 60+1~10초, 상세 300+1~30초) — Hot Key Stampede 방어
- 예외 흡수 후 Optional.empty() 반환 — DB fallback 보장
- CachedBrandProductPage, CachedProductDetail 캐시 전용 DTO 추가
- RedisConfig에 Fail-Fast 타임아웃 설정 (COMMAND 500ms, CONNECT 300ms)
- ProductAppService에 Cache-Aside 조회 메서드 추가 (상세/목록)
- page 0~2만 캐싱하여 Cache Pollution 방지 (CACHEABLE_PAGE_LIMIT = 2)
- AdminProductAppService update/delete 시 evictProductCaches 호출
- ProductInfo에 @Jacksonized 추가로 캐시 역직렬화 지원
- 단위 테스트: 캐시 히트/미스/딥페이지 바이패스 3건 추가
- 통합 테스트: 브랜드별 페이징 조회 검증 4건 추가
- GET /api/v1/brands/{brandId}/products 엔드포인트 신규 추가
- ProductFacade 상세 조회를 CachedProductDetail 기반으로 전환
- ProductFacade 브랜드별 목록 조회 오케스트레이션 추가
- BrandProductResponse/BrandProductListResponse DTO 추가
- Deep Paging 방지: page 0~50 범위 제한 (MAX_PAGE = 50)
- Facade 단위 테스트: 캐시 기반 상세 조회 + 브랜드별 목록 조회 검증
- E2E 테스트: 정렬/페이징/브랜드 필터링/에러 케이스 5건 추가
@coderabbitai
Copy link

coderabbitai bot commented Mar 12, 2026

📝 Walkthrough

Walkthrough

제품 목록·상세 조회에 Redis 기반 캐싱과 캐시 DTO/인터페이스 및 Redis 구현을 추가하고, ProductAppService/AdminProductAppService/ProductFacade/Controller에 캐시 사용과 무효화 로직을 통합했으며 페이징·인덱스·직렬화 및 테스트가 변경되었다다.

Changes

Cohort / File(s) Summary
캐싱 DTO 및 인터페이스
apps/commerce-api/src/main/java/com/loopers/application/product/CachedBrandProductPage.java, apps/commerce-api/src/main/java/com/loopers/application/product/CachedProductDetail.java, apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheManager.java
브랜드 목록 및 상품 상세용 불변 캐시 DTO와 ProductCacheManager 인터페이스를 추가함. 캐시 모델과 evict API가 정의되어 캐시 계약이 생김. 운영상 직렬화/버전 호환성 검증이 필요하다 — DTO 변경 시 역호환 정책 수립 및 직렬화 회귀 테스트 추가 권장이다.
Redis 캐시 구현
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCacheManager.java
Redis 기반 구현 추가, JSON 직렬화, TTL+지터, 리스트와 상세의 get/put/evict 및 에러 내성 로깅을 구현함. 운영상 Redis 장애·직렬화 실패가 서비스 영향으로 이어질 수 있으므로 실패 시 폴백 동작 문서화, 모니터링(존재/삭제 히트·미스 비율) 및 캐시 무효화 동작의 통합 테스트가 필요하다.
애플리케이션 레이어 확장
apps/commerce-api/src/main/java/com/loopers/application/product/ProductAppService.java, apps/commerce-api/src/main/java/com/loopers/application/admin/product/AdminProductAppService.java
ProductCacheManager 주입과 getProductDetailCached/getProductsByBrandIdCached 추가 및 update/delete에서 productCacheManager.evictProductCaches 호출을 추가함. 운영상 동시성으로 인한 캐시-DB 불일치 가능성이 있으므로 evict 타이밍과 트랜잭션 경계(트랜잭션 커밋 후 evict) 재검토 및 통합 테스트 추가 권장이다.
Facade 및 매핑 변경
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java, apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java
Facade가 캐시 DTO를 사용하여 ProductInfo를 구성하고 getProductsByBrand(brandId,page,size)를 추가함. ProductInfo에 @Jacksonized 및 withLikedByUser가 추가됨. 주의: 매핑 로직 변경으로 응답 스키마가 미세 변경될 수 있어 API 회귀 테스트 필요하다.
저장소·도메인 변경 (페이징·인덱스)
apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java, .../ProductRepository.java, .../ProductJpaRepository.java, .../ProductRepositoryImpl.java
Product 엔티티에 복합 인덱스 추가 및 브랜드 페이징 쿼리 API가 도입됨. likeCount 증감 public 메서드 제거로 외부 증감 경로가 삭제됨. 운용상 인덱스 추가는 마이그레이션 영향(인덱스 생성 시간, 디스크)을 유발하므로 배포 절차에 인덱스 생성 창 계획과 모니터링을 권장한다.
컨트롤러·DTO 변경
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java, apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java
브랜드별 제품 목록 엔드포인트 GET /api/v1/brands/{brandId}/products 추가 및 BrandProductResponse/BrandProductListResponse DTO 추가. 입력 유효성 검사 추가로 잘못된 페이지 요청에 대해 400 응답이 발생한다는 점을 테스트해야 한다.
직렬화·예외 변경
apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java
Money 생성자에 @JsonCreator/@JsonProperty를 추가해 Jackson 역직렬화를 지원하고 subtract에서 예외 타입을 CoreException(BAD_REQUEST)으로 변경함. 운영상 역직렬화 이전 캐시 데이터 호환성 확인과 예외 핸들링 일관성 검증 테스트 필요하다.
테스트 추가/수정
apps/commerce-api/src/test/... (여러 파일)
ProductAppService 단위/통합/이중계층 테스트 및 E2E 테스트 추가·수정: 캐시 히트·미스·예외, 페이징·정렬, 캐시 우회 시나리오 등. likeCount 관련 단위 테스트 일부 삭제. 테스트로 캐시 무효화와 DB 일관성 시나리오(동시성 포함)를 추가 검증할 필요가 있다.
설정 변경
modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java, modules/jpa/src/main/resources/jpa.yml
Lettuce 클라이언트에 connect/command timeout 옵션 추가 및 local 프로필의 Hibernate DDL 전략을 create→update로 변경함. 운영상 로컬/CI 설정 변경이 프로덕션에 영향을 주지 않는지 확인하고, 로컬 프로파일 동작 문서화를 권장한다.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Controller
    participant Facade
    participant AppService
    participant CacheManager
    participant Redis
    participant Repository
    participant Database

    rect rgba(100,150,200,0.5)
        Note over Client,Database: 제품 상세 조회 (캐시 우선)
        Client->>Controller: GET /api/v1/products/{id}
        Controller->>Facade: getProductDetail(productId)
        Facade->>AppService: getProductDetailCached(productId)
        AppService->>CacheManager: getProductDetail(productId)
        CacheManager->>Redis: GET product:detail:{id}
        alt 캐시 Hit
            Redis-->>CacheManager: CachedProductDetail
            CacheManager-->>AppService: Optional[CachedProductDetail]
        else 캐시 Miss
            CacheManager-->>AppService: Optional.empty()
            AppService->>Repository: findById(productId)
            Repository->>Database: SELECT Product
            Database-->>Repository: Product
            AppService->>AppService: assemble options & likeCount
            AppService->>CacheManager: putProductDetail(productId, detail)
            CacheManager->>Redis: SET product:detail:{id} (TTL 300s+jitter)
        end
        AppService-->>Facade: CachedProductDetail
        Facade->>Controller: ProductInfo
        Controller-->>Client: ApiResponse<ProductDto>
    end

    rect rgba(200,100,150,0.5)
        Note over Client,Database: 브랜드별 제품 목록 조회 (페이징 + 캐시)
        Client->>Controller: GET /api/v1/brands/{brandId}/products?page=0&size=20
        Controller->>Facade: getProductsByBrand(brandId,page,size)
        Facade->>AppService: getProductsByBrandIdCached(brandId,page,size)
        AppService->>CacheManager: getProductList(brandId,page,size)
        CacheManager->>Redis: GET product:list:brand:{brandId}:page:{page}
        alt 페이지 0-1 & 캐시 Hit
            Redis-->>CacheManager: CachedBrandProductPage
            CacheManager-->>AppService: Optional[CachedBrandProductPage]
        else 캐시 Miss or 깊은 페이지
            CacheManager-->>AppService: Optional.empty()
            AppService->>Repository: findByBrandIdWithPaging(brandId, pageable)
            Repository->>Database: SELECT Products ORDER BY like_count DESC
            Database-->>Repository: Page<Product>
            AppService->>AppService: map to summaries
            alt 페이지 0-1
                AppService->>CacheManager: putProductList(brandId,page,cachedPage)
                CacheManager->>Redis: SET product:list:... (TTL 60s+jitter)
            end
        end
        AppService-->>Facade: CachedBrandProductPage
        Facade-->>Controller: BrandProductListResponse
        Controller-->>Client: ApiResponse<BrandProductListResponse>
    end

    rect rgba(150,200,100,0.5)
        Note over Client,Redis: 제품 수정/삭제 시 캐시 무효화
        Client->>Controller: PUT/DELETE /api/v1/admin/products/{id}
        Controller->>AppService: update/delete(productId)
        AppService->>Repository: save/delete product
        Repository->>Database: UPDATE/DELETE Product
        AppService->>CacheManager: evictProductCaches(productId, brandId)
        CacheManager->>Redis: DEL product:detail:{id}
        CacheManager->>Redis: DEL product:list:brand:{brandId}:page:{n}
        Redis-->>CacheManager: OK
        CacheManager-->>AppService: void
        AppService-->>Controller: success
        Controller-->>Client: ApiResponse<Void>
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 주요 변경사항을 명확하게 요약한다: 인덱스와 캐시를 활용한 읽기 성능 최적화라는 핵심 목표가 직관적으로 드러난다.
Description check ✅ Passed PR 설명은 제공된 템플릿 구조를 충실히 따르고 있으며, 배경-목표-결과부터 Context & Decision, Design Overview, Flow Diagram까지 모두 포함되어 있다. 문제 정의, 선택지 대비, 트레이드오프, 컴포넌트 책임이 체계적으로 기술되어 있다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Tip

CodeRabbit can enforce grammar and style rules using `languagetool`.

Configure the reviews.tools.languagetool setting to enable/disable rules and categories. Refer to the LanguageTool Community to learn more.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
modules/jpa/src/main/resources/jpa.yml (1)

34-40: ⚠️ Potential issue | 🟠 Major

공용 JPA 설정에서 ddl-auto: update를 켜면 로컬 스키마 변경이 비결정적으로 진행된다.

modules/jpa/src/main/resources/jpa.yml 40행의 변경으로 local 프로필의 ddl-autoupdate로 설정되었다. 이 설정은 apps/commerce-api/src/main/resources/application.ymlapps/commerce-batch/src/main/resources/application.yml 양쪽에서 함께 import되므로, 여러 애플리케이션이 같은 데이터베이스에 연결될 때 어느 앱을 먼저 기동했는지, 기존 스키마 상태는 어떤지에 따라 추가 DDL이 점진적으로 누적된다. 이로 인해 성능 측정, 장애 재현, 로컬 검증 결과가 환경마다 달라져 운영 재현성이 손상된다.

수정안:

  1. 공용 설정(modules/jpa/src/main/resources/jpa.yml)은 ddl-auto: none으로 유지하고, 이번 인덱스 추가(idx_products_brand_deleted_likes)는 Flyway나 Liquibase 같은 버전드 마이그레이션으로 관리할 것.
  2. 로컬 편의성이 필수라면 local-ddl 같은 별도 프로필을 생성해 공용 local 프로필과 분리할 것.

추가 테스트:

  1. 빈 데이터베이스 상태에서 기동 후 INFORMATION_SCHEMA.STATISTICS에서 idx_products_brand_deleted_likes 인덱스의 존재 및 컬럼 구성을 검증하는 통합 테스트 작성.
  2. commerce-apicommerce-batch를 같은 로컬 DB에 기동 순서만 바꿔서 실행해도 추가 DDL diff가 발생하지 않는지 확인하는 설정 회귀 테스트 작성.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@modules/jpa/src/main/resources/jpa.yml` around lines 34 - 40, The shared JPA
profile currently sets spring.jpa.hibernate.ddl-auto: update for the local
profile — change spring.jpa.hibernate.ddl-auto to none in the shared JPA config
and remove index DDL from runtime schema auto-update; instead create a versioned
migration (Flyway/Liquibase) that adds idx_products_brand_deleted_likes and run
it in CI/deploy pipelines; if local convenience is required, add a separate
profile (e.g. local-ddl) that sets ddl-auto to update so it does not affect the
shared local profile; add integration tests that (1) start from an empty DB and
assert idx_products_brand_deleted_likes exists with the correct columns via
INFORMATION_SCHEMA.STATISTICS and (2) start commerce-api and commerce-batch in
both orders against the same DB to ensure no runtime DDL diffs occur.
modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java (1)

43-46: ⚠️ Potential issue | 🟠 Major

Redis 캐시 무효화 직후 stale read 위험이 존재한다.

운영 관점에서 evictProductCaches()가 마스터에 반영된 직후, 같은 replica-preferred 템플릿을 통한 getProductDetail()이 수행되면, 레플리카 동기화 지연 시간 동안 오래된 값이 응답될 수 있다. 현재 RedisProductCacheManager@Qualifier 없이 @Primary RedisTemplate(ReadFrom.REPLICA_PREFERRED)을 주입받으므로, 읽기 경로가 레플리카를 우선한다.

조치 사항:

  1. RedisProductCacheManager@Qualifier(REDIS_TEMPLATE_MASTER) 주입하여 읽기 템플릿을 마스터 전용으로 변경한다.
  2. 상품 삭제 후 즉시 캐시 조회 시 이전 데이터를 반환하지 않음을 검증하는 통합 테스트를 추가한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java` around
lines 43 - 46, RedisTemplate bean in RedisConfig is configured with
ReadFrom.REPLICA_PREFERRED, causing stale reads after evictProductCaches();
change injection in RedisProductCacheManager to use the master-only template by
adding `@Qualifier`(REDIS_TEMPLATE_MASTER) (reference the REDIS_TEMPLATE_MASTER
constant and the RedisProductCacheManager class) so reads after evictions go to
the master, and add an integration test that calls evictProductCaches() then
immediately calls getProductDetail() and asserts the deleted/updated value is
not returned (verify master consistency).
apps/commerce-api/src/main/java/com/loopers/application/admin/product/AdminProductAppService.java (1)

53-60: ⚠️ Potential issue | 🟠 Major

브랜드 삭제 시 캐시 무효화 누락

deleteByBrandId() 메서드에서 상품들을 삭제하면서 캐시 무효화를 수행하지 않는다. 이로 인해 삭제된 상품이 캐시에 남아 사용자에게 노출될 수 있다.

🐛 수정안
 `@Transactional`
 public void deleteByBrandId(Long brandId) {
     List<Product> products = productRepository.findByBrandId(brandId);
     for (Product product : products) {
         product.delete();
         deleteOptionsByProductId(product.getId());
+        productCacheManager.evictProductCaches(product.getId(), brandId);
     }
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/admin/product/AdminProductAppService.java`
around lines 53 - 60, deleteByBrandId currently deletes Product entities via
product.delete() and deleteOptionsByProductId(product.getId()) but does not
evict related cache entries; add cache invalidation calls for each deleted
product (e.g. invoke your product cache service such as
productCacheService.evict(product.getId()) or
cacheService.invalidateProductCache(product.getId())) immediately after
deleteOptionsByProductId(product.getId()), and also invalidate any brand-level
or collection caches (e.g. productListCache.evict(brandId) or similar) so
removed products are not served from cache; update the method
deleteByBrandId(Long brandId) to call these cache eviction methods for symbols
product.delete(), deleteOptionsByProductId(...), and the appropriate cache
service methods.
🧹 Nitpick comments (11)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java (1)

48-59: withLikedByUser()가 옵션 리스트 참조를 그대로 재사용한다.

운영 관점에서는 역직렬화된 options가 mutable list이면, 한 응답 객체의 옵션 변경이 다른 복사본이나 캐시된 객체에 전파되어 요청 간 데이터 오염이 생길 수 있다. options는 생성 시점과 복사 시점 모두 List.copyOf(...)로 방어적 복사를 적용하는 편이 안전하다. 추가로 mutable list로 생성한 뒤 원본 리스트를 변경해도 ProductInfowithLikedByUser() 결과가 변하지 않는지 테스트를 보강하는 것이 좋다.

As per coding guidelines, **/*.java: 'null 처리, 방어적 복사, 불변성, equals/hashCode/toString 구현 안정성을 점검한다.'

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java`
around lines 48 - 59, withLikedByUser currently reuses the same options list
reference, risking shared-mutable state; change ProductInfo.withLikedByUser to
defensively copy the options (e.g., use List.copyOf(this.options) or
List.copyOf(Optional.ofNullable(this.options).orElse(Collections.emptyList())))
when passing to the builder and ensure the ProductInfo builder/constructor also
applies List.copyOf to its options field and handles null safely; add/strengthen
a unit test that mutates an original mutable options list after creating
ProductInfo and after calling withLikedByUser to assert neither ProductInfo
instance is affected.
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java (1)

3-4: 도메인 저장소 계약에 Spring Data 타입이 스며든다.

운영 관점에서는 Page/Pageable가 도메인 계층까지 올라오면 테스트 대역 작성과 계층 분리가 어려워지고, 이후 페이징 정책 변경 시 도메인 계약까지 함께 흔들린다. ProductPageQuery/ProductPageResult 같은 도메인 전용 타입으로 계약을 유지하고, ProductRepositoryImpl에서 Spring Data 타입으로 변환하는 편이 안전하다. 추가로 Spring 클래스 없이도 저장소 계약을 검증할 수 있는 단위 테스트를 보강하는 것이 좋다.

As per coding guidelines, **/domain/**/*.java: '도메인 규칙과 인프라 관심사가 섞이면 분리하도록 제안한다.'

Also applies to: 16-16

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java`
around lines 3 - 4, ProductRepository currently exposes Spring Data types
(org.springframework.data.domain.Page/Pageable); change the repository contract
to use domain-specific DTOs (e.g., ProductPageQuery and ProductPageResult) and
move the Spring-specific conversions into ProductRepositoryImpl where you
translate ProductPageQuery → Pageable and Page → ProductPageResult; update or
add unit tests that validate ProductRepository behavior using these domain types
(without importing Spring classes) and keep the Spring Data dependencies
confined to ProductRepositoryImpl implementation.
modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java (1)

28-29: Redis 타임아웃이 코드에 고정되어 있다.

운영 관점에서는 네트워크 RTT와 장애 허용치가 환경마다 달라 300ms/500ms를 상수로 박아두면 장애 시 즉시 튜닝할 수 없고, 피크 시간대에 DB fallback이 급증할 수 있다. RedisProperties로 외부화해서 환경별 override가 가능하게 하고, 코드에는 기본값만 두는 편이 안전하다. 추가로 properties binding 테스트와 느린 Redis 응답 조건에서 fallback 임계값을 검증하는 통합 테스트를 보강하는 것이 좋다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java` around
lines 28 - 29, The hardcoded Redis timeouts in RedisConfig (COMMAND_TIMEOUT and
CONNECT_TIMEOUT) should be externalized into configurable RedisProperties with
sensible defaults; update RedisConfig to inject RedisProperties and use
properties.getCommandTimeout()/getConnectTimeout() (falling back to
Duration.ofMillis(500) and Duration.ofMillis(300) only as defaults), add a
properties binding unit test to verify values are read from configuration into
RedisProperties, and add/instrument an integration test that simulates slow
Redis responses to assert the fallback/threshold behavior uses the configured
timeouts rather than the hardcoded constants.
apps/commerce-api/src/test/java/com/loopers/application/product/ProductAppServiceIntegrationTest.java (1)

75-92: 전체 페이지 초과 요청 테스트 누락

totalPages가 2인 상황에서 page=5와 같이 존재하지 않는 페이지 요청 시의 동작 테스트가 누락되어 있다. 빈 리스트 반환인지 예외 발생인지 명확히 검증해야 한다.

추가 테스트:

`@Test`
`@DisplayName`("존재하지 않는 페이지 요청 시 빈 결과를 반환한다")
void getProductsByBrandId_pageOutOfRange() {
    // given
    productRepository.save(Product.create(testBrand.getId(), "상품", Money.of(BigDecimal.valueOf(1000))));

    // when
    Page<Product> result = productAppService.getProductsByBrandId(testBrand.getId(), 100, 20);

    // then
    assertThat(result.getContent()).isEmpty();
    assertThat(result.getTotalElements()).isEqualTo(1);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/application/product/ProductAppServiceIntegrationTest.java`
around lines 75 - 92, Add an integration test to verify out-of-range paging
behavior for ProductAppService#getProductsByBrandId: create at least one product
via productRepository.save(...), call
productAppService.getProductsByBrandId(testBrand.getId(), 100, 20) (or another
clearly out-of-range page index), and assert that the returned Page has an empty
content list and that totalElements still equals the actual total (e.g., 1);
place this new test in ProductAppServiceIntegrationTest with a descriptive
`@DisplayName` like "존재하지 않는 페이지 요청 시 빈 결과를 반환한다".
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (2)

33-40: 브랜드 존재 여부 검증 순서

getProductsByBrand()에서 상품 조회 후 브랜드를 조회하는데, 존재하지 않는 브랜드 ID로 요청 시 상품 조회(캐시 저장 포함)가 먼저 수행된 후 브랜드 조회에서 예외가 발생한다. 불필요한 DB/캐시 연산이 발생하므로 브랜드 존재 여부를 먼저 검증하는 것이 효율적이다.

♻️ 수정안
 public Page<ProductInfo> getProductsByBrand(Long brandId, int page, int size) {
+    Brand brand = brandAppService.getById(brandId);
     CachedBrandProductPage cached = productAppService.getProductsByBrandIdCached(brandId, page, size);
-    Brand brand = brandAppService.getById(brandId);
     List<ProductInfo> content = cached.getContent().stream()
             .map(summary -> toProductInfo(summary, brand))
             .toList();
     return new PageImpl<>(content, PageRequest.of(page, size), cached.getTotalElements());
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java`
around lines 33 - 40, getProductsByBrand currently fetches cached products
before verifying the brand exists, causing wasted DB/cache work when brandId is
invalid; change the call order in ProductFacade.getProductsByBrand to first
validate the brand via brandAppService.getById(brandId) (or a brand existence
check method) and only then call
productAppService.getProductsByBrandIdCached(brandId, page, size), then map
summaries with toProductInfo(summary, brand) as before to avoid unnecessary
cache/storage operations for non-existent brands.

84-96: 브랜드 상품 목록에서 사용자 좋아요 상태 미반영

toProductInfo(ProductSummary, Brand) 메서드에서 likedByUser가 항상 false로 설정된다. PR 목표상 캐시 성능 최적화를 위한 의도적 설계로 보이나, API 응답에서 해당 필드가 항상 false인 점을 API 문서에 명시하거나 응답 DTO에서 해당 필드를 제외하는 것이 클라이언트 혼란을 방지한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java`
around lines 84 - 96, toProductInfo currently hardcodes ProductInfo.likedByUser
to false, causing API responses to always show not-liked; change the mapping in
toProductInfo(CachedBrandProductPage.ProductSummary summary, Brand brand) to set
likedByUser from the summary (e.g. summary.isLikedByUser() or
summary.getLikedByUser()); if ProductSummary has no liked flag, add one to
CachedBrandProductPage.ProductSummary (and populate it where the cache is built)
so toProductInfo can read it, otherwise remove the likedByUser field from
ProductInfo or explicitly document its always-false behavior in the API docs.
apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandProductApiE2ETest.java (2)

60-95: 빈 브랜드 조회 테스트 누락

브랜드에 상품이 없는 경우의 테스트가 누락되어 있다. 운영 환경에서 신규 브랜드 등록 직후 조회 시 빈 목록이 정상 반환되는지 검증이 필요하다.

추가 테스트 케이스:

`@DisplayName`("상품이 없는 브랜드 조회 시 빈 목록을 반환한다.")
`@Test`
void getBrandProducts_emptyBrand() {
    // act (상품 없이 조회)
    ResponseEntity<ApiResponse<ProductDto.BrandProductListResponse>> response = testRestTemplate.exchange(
            "/api/v1/brands/" + testBrand.getId() + "/products?page=0",
            HttpMethod.GET,
            null,
            new ParameterizedTypeReference<>() {}
    );

    // assert
    assertAll(
            () -> assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK),
            () -> assertThat(response.getBody().data().products()).isEmpty(),
            () -> assertThat(response.getBody().data().totalElements()).isEqualTo(0)
    );
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandProductApiE2ETest.java`
around lines 60 - 95, Add a new test method getBrandProducts_emptyBrand in
BrandProductApiE2ETest that calls testRestTemplate.exchange on "/api/v1/brands/"
+ testBrand.getId() + "/products?page=0" without creating any Product entities,
and assert the response is HttpStatus.OK, response.getBody().data().products()
is empty, and response.getBody().data().totalElements() == 0; place it alongside
getBrandProducts_success and use the same ParameterizedTypeReference<> and
ApiResponse<ProductDto.BrandProductListResponse> types so the test verifies an
empty product list for a newly registered brand.

159-175: 경계값 테스트 누락

page=51에 대한 400 응답 테스트는 존재하나, page=50(허용 범위 최대값)에서 정상 동작하는지 확인하는 경계값 테스트가 누락되어 있다. 경계값에서의 동작 검증은 off-by-one 오류 방지에 필수적이다.

추가 테스트 케이스:

`@DisplayName`("허용 범위 최대 페이지(50) 요청 시 정상 응답한다.")
`@Test`
void getBrandProducts_maxPage_success() {
    // act
    ResponseEntity<ApiResponse<ProductDto.BrandProductListResponse>> response = testRestTemplate.exchange(
            "/api/v1/brands/" + testBrand.getId() + "/products?page=50",
            HttpMethod.GET,
            null,
            new ParameterizedTypeReference<>() {}
    );

    // assert
    assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandProductApiE2ETest.java`
around lines 159 - 175, Add a boundary test to BrandProductApiE2ETest to verify
the maximum allowed page (50) returns 200 OK: create a new test method
getBrandProducts_maxPage_success that uses testRestTemplate.exchange with
"/api/v1/brands/" + testBrand.getId() + "/products?page=50", the same
HttpMethod.GET and
ParameterizedTypeReference<ProductDto.BrandProductListResponse>, then assert
response.getStatusCode() is HttpStatus.OK (and optionally validate response body
metadata/result if desired) to ensure off-by-one errors are caught.
apps/commerce-api/src/main/java/com/loopers/application/admin/product/AdminProductAppService.java (1)

37-43: 트랜잭션 롤백 시 캐시 불일치 가능성

evictProductCaches()가 트랜잭션 내부에서 호출되므로, 이후 로직에서 예외 발생 시 DB는 롤백되나 캐시는 이미 삭제된 상태가 된다. 이로 인해 캐시 미스 후 DB에서 변경 전 데이터를 다시 캐싱하게 되어 정합성에 문제가 없으나, 불필요한 캐시 무효화가 발생한다.

권장 수정안: @TransactionalEventListener(phase = AFTER_COMMIT)를 활용하여 커밋 후 캐시 무효화를 수행하거나, 현재 구조를 유지할 경우 이 트레이드오프를 문서화할 것.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/admin/product/AdminProductAppService.java`
around lines 37 - 43, The update method in AdminProductAppService currently
calls productCacheManager.evictProductCaches(id, product.getBrandId()) inside
the `@Transactional` method, risking cache eviction before commit; refactor to
perform eviction after successful commit by publishing a transactional event
(e.g., create and publish a ProductUpdatedEvent in update) and handle it in a
separate listener annotated with `@TransactionalEventListener`(phase =
AFTER_COMMIT) which invokes productCacheManager.evictProductCaches using the
event payload; alternatively, if you keep eviction inside update, add an
explicit comment/documentation in AdminProductAppService.update explaining the
rollback vs cache-tradeoff.
apps/commerce-api/src/main/java/com/loopers/application/product/CachedBrandProductPage.java (1)

21-37: deleted 필드 캐싱의 불필요성

ProductSummary에서 deleted 필드를 캐싱하고 있으나, DB 쿼리에서 이미 deleted=false 조건으로 필터링하므로 캐시된 데이터는 항상 deleted=false이다. 캐시 용량 낭비이며, 만약 삭제된 상품이 캐시에 남아있다면 캐시 무효화 로직에 버그가 있는 것이다.

ProductFacade.toProductInfo()에서도 이 필드를 사용하므로, 제거 시 연관 코드도 함께 수정 필요하다. 현재 구조 유지 시 방어적 코딩으로 간주할 수 있으나, 캐시 크기 최적화가 필요할 경우 제거 고려.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/CachedBrandProductPage.java`
around lines 21 - 37, ProductSummary caches a redundant deleted flag (always
false due to DB filtering); remove the deleted field and its getter/builder
usage from CachedBrandProductPage.ProductSummary and its from(Product) mapper,
then update any consumers (notably ProductFacade.toProductInfo) to stop reading
ProductSummary.deleted — either derive deleted from the live Product/DB where
needed or stop passing it altogether; ensure builders, constructors, and imports
are cleaned up after removal.
apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java (1)

126-166: 브랜드 목록 테스트가 페이징 회귀를 충분히 막지 못한다.

지금은 내용과 totalElements만 확인해서 페이지 번호, 페이지 크기, 총 페이지 수 계산이 틀려도 테스트가 통과한다. 운영에서는 이 메타데이터가 깨지면 클라이언트의 페이지 이동이 바로 망가진다. 성공 케이스 assertion에 페이징 메타데이터를 포함하고, 빈 페이지와 마지막 페이지 같은 경계 케이스를 별도 테스트로 추가하는 편이 안전하다. 추가 테스트로 totalElements=21, size=20일 때 총 페이지 수가 2인지, 마지막 페이지 이후 요청에서 빈 결과가 일관되게 내려오는지 검증해야 한다. As per coding guidelines, "**/*Test*.java: 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java`
around lines 126 - 166, Update ProductFacadeTest to assert paging metadata and
add boundary-case tests: inside getProductsByBrand_success (and/or a new test),
assert that the returned page's page number, page size, and totalPages match
expectations based on the CachedBrandProductPage (use
productFacade.getProductsByBrand result to check getNumber(), getSize(),
getTotalPages(), getTotalElements()); add separate tests that mock
CachedBrandProductPage with totalElements=21 and page size 20 to assert
totalPages == 2 and that requesting page 2 returns the remaining item while
requesting page 3 returns an empty content list; reuse symbols
CachedBrandProductPage, productAppService.getProductsByBrandIdCached(...),
brandAppService.getById(...), and productFacade.getProductsByBrand(...) to
locate where to add/adjust assertions and new test methods.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/product/CachedProductDetail.java`:
- Around line 24-34: CachedProductDetail.from currently assumes the options
parameter is non-null and will NPE when options is null; update the method
(CachedProductDetail.from) to defensively handle a null options by treating it
as an empty list before mapping (e.g., coalesce options to an empty list and
then call options.stream().map(ProductInfo.OptionInfo::from).toList()), ensuring
likeCount and other fields are unchanged and the builder receives an empty
options list when null is passed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductAppService.java`:
- Around line 49-52: The service methods getProductsByBrandId and
getProductsByBrandIdCached call PageRequest.of(page, size) without validating
inputs; add defensive validation at the service entry (or a private helper like
createValidatedPageRequest) to ensure page >= 0, size > 0 and size <=
MAX_PAGE_SIZE, and convert invalid inputs to
CoreException(ErrorType.BAD_REQUEST) instead of letting IllegalArgumentException
propagate; update both methods to use the validated PageRequest helper so direct
service calls and controller calls produce consistent BAD_REQUEST behavior and
add tests for boundary cases (page=-1, size=0, size=Integer.MAX_VALUE) to assert
CoreException/ErrorType.BAD_REQUEST is thrown.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheManager.java`:
- Around line 7-15: evictProductCaches currently can't fully invalidate list
entries because getProductList/putProductList store entries by (brandId, page,
size) but evictProductCaches(Long productId, Long brandId) lacks page/size (or a
brand-level namespace) info; update the contract to either (a) include page and
size (e.g., evictProductCaches(Long productId, Long brandId, Integer page,
Integer size) or evictBySizeRange) so implementations can remove exact keys, or
(b) introduce a brand-version/namespace token used by
getProductList/putProductList and bumped by evictProductCaches(brandId) so all
page/size combos are implicitly invalidated; then add tests for
CachedBrandProductPage to assert lists cached at different sizes/pages are all
misses after a putProductDetail/evictProductCaches call.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java`:
- Around line 27-28: 쿼리 findByBrandIdAndDeletedFalseOrderByLikeCountDesc에서
p.likeCount만으로 정렬하면 동률 레코드의 페이지 경계가 비결정적이므로 ORDER BY에 고정된 tie‑breaker인 p.id를
추가하고 관련 JPA 쿼리를 "ORDER BY p.likeCount DESC, p.id DESC" (또는 원하는 고정 정렬 방향)로 변경하며,
DB 측 인덱스도 같은 순서(같은 컬럼 순서와 방향)로 맞춰 성능/일관성을 확보하고 페이징 안정성 테스트를
findByBrandIdAndDeletedFalseOrderByLikeCountDesc 호출 시 동일 likeCount 그룹에서 각 페이지의
ID 집합이 안정적으로 유지되는지 확인하도록 보강하세요.

In
`@apps/commerce-api/src/test/java/com/loopers/application/product/ProductAppServiceTest.java`:
- Around line 285-346: Add unit tests to verify Redis failures are absorbed and
DB fallback works: add a test in BrandProductCacheTest that stubs
productCacheManager.getProductList(...) to throw (e.g., new
RuntimeException("Redis connection failed")), stubs
productRepository.findByBrandIdWithPaging(...) to return a Page, calls
productAppService.getProductsByBrandIdCached(1L, page, 20), and asserts a
non-null CachedBrandProductPage and that
productRepository.findByBrandIdWithPaging(...) was invoked; similarly add a test
where productCacheManager.putProductList(...) throws to ensure the service still
returns DB results and does not propagate the exception.

In `@apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java`:
- Around line 96-97: The test currently only checks exception type for
money1.subtract(money2); update the assertion to also verify the CoreException's
error type equals ErrorType.BAD_REQUEST by using
isInstanceOfSatisfying(CoreException.class, e ->
assertThat(e.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST)). Apply the same
pattern to the negative amount creation test (the constructor/factory that
throws CoreException when creating negative Money) to assert the error type is
BAD_REQUEST as well.

---

Outside diff comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/admin/product/AdminProductAppService.java`:
- Around line 53-60: deleteByBrandId currently deletes Product entities via
product.delete() and deleteOptionsByProductId(product.getId()) but does not
evict related cache entries; add cache invalidation calls for each deleted
product (e.g. invoke your product cache service such as
productCacheService.evict(product.getId()) or
cacheService.invalidateProductCache(product.getId())) immediately after
deleteOptionsByProductId(product.getId()), and also invalidate any brand-level
or collection caches (e.g. productListCache.evict(brandId) or similar) so
removed products are not served from cache; update the method
deleteByBrandId(Long brandId) to call these cache eviction methods for symbols
product.delete(), deleteOptionsByProductId(...), and the appropriate cache
service methods.

In `@modules/jpa/src/main/resources/jpa.yml`:
- Around line 34-40: The shared JPA profile currently sets
spring.jpa.hibernate.ddl-auto: update for the local profile — change
spring.jpa.hibernate.ddl-auto to none in the shared JPA config and remove index
DDL from runtime schema auto-update; instead create a versioned migration
(Flyway/Liquibase) that adds idx_products_brand_deleted_likes and run it in
CI/deploy pipelines; if local convenience is required, add a separate profile
(e.g. local-ddl) that sets ddl-auto to update so it does not affect the shared
local profile; add integration tests that (1) start from an empty DB and assert
idx_products_brand_deleted_likes exists with the correct columns via
INFORMATION_SCHEMA.STATISTICS and (2) start commerce-api and commerce-batch in
both orders against the same DB to ensure no runtime DDL diffs occur.

In `@modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java`:
- Around line 43-46: RedisTemplate bean in RedisConfig is configured with
ReadFrom.REPLICA_PREFERRED, causing stale reads after evictProductCaches();
change injection in RedisProductCacheManager to use the master-only template by
adding `@Qualifier`(REDIS_TEMPLATE_MASTER) (reference the REDIS_TEMPLATE_MASTER
constant and the RedisProductCacheManager class) so reads after evictions go to
the master, and add an integration test that calls evictProductCaches() then
immediately calls getProductDetail() and asserts the deleted/updated value is
not returned (verify master consistency).

---

Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/admin/product/AdminProductAppService.java`:
- Around line 37-43: The update method in AdminProductAppService currently calls
productCacheManager.evictProductCaches(id, product.getBrandId()) inside the
`@Transactional` method, risking cache eviction before commit; refactor to perform
eviction after successful commit by publishing a transactional event (e.g.,
create and publish a ProductUpdatedEvent in update) and handle it in a separate
listener annotated with `@TransactionalEventListener`(phase = AFTER_COMMIT) which
invokes productCacheManager.evictProductCaches using the event payload;
alternatively, if you keep eviction inside update, add an explicit
comment/documentation in AdminProductAppService.update explaining the rollback
vs cache-tradeoff.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/CachedBrandProductPage.java`:
- Around line 21-37: ProductSummary caches a redundant deleted flag (always
false due to DB filtering); remove the deleted field and its getter/builder
usage from CachedBrandProductPage.ProductSummary and its from(Product) mapper,
then update any consumers (notably ProductFacade.toProductInfo) to stop reading
ProductSummary.deleted — either derive deleted from the live Product/DB where
needed or stop passing it altogether; ensure builders, constructors, and imports
are cleaned up after removal.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java`:
- Around line 33-40: getProductsByBrand currently fetches cached products before
verifying the brand exists, causing wasted DB/cache work when brandId is
invalid; change the call order in ProductFacade.getProductsByBrand to first
validate the brand via brandAppService.getById(brandId) (or a brand existence
check method) and only then call
productAppService.getProductsByBrandIdCached(brandId, page, size), then map
summaries with toProductInfo(summary, brand) as before to avoid unnecessary
cache/storage operations for non-existent brands.
- Around line 84-96: toProductInfo currently hardcodes ProductInfo.likedByUser
to false, causing API responses to always show not-liked; change the mapping in
toProductInfo(CachedBrandProductPage.ProductSummary summary, Brand brand) to set
likedByUser from the summary (e.g. summary.isLikedByUser() or
summary.getLikedByUser()); if ProductSummary has no liked flag, add one to
CachedBrandProductPage.ProductSummary (and populate it where the cache is built)
so toProductInfo can read it, otherwise remove the likedByUser field from
ProductInfo or explicitly document its always-false behavior in the API docs.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java`:
- Around line 48-59: withLikedByUser currently reuses the same options list
reference, risking shared-mutable state; change ProductInfo.withLikedByUser to
defensively copy the options (e.g., use List.copyOf(this.options) or
List.copyOf(Optional.ofNullable(this.options).orElse(Collections.emptyList())))
when passing to the builder and ensure the ProductInfo builder/constructor also
applies List.copyOf to its options field and handles null safely; add/strengthen
a unit test that mutates an original mutable options list after creating
ProductInfo and after calling withLikedByUser to assert neither ProductInfo
instance is affected.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java`:
- Around line 3-4: ProductRepository currently exposes Spring Data types
(org.springframework.data.domain.Page/Pageable); change the repository contract
to use domain-specific DTOs (e.g., ProductPageQuery and ProductPageResult) and
move the Spring-specific conversions into ProductRepositoryImpl where you
translate ProductPageQuery → Pageable and Page → ProductPageResult; update or
add unit tests that validate ProductRepository behavior using these domain types
(without importing Spring classes) and keep the Spring Data dependencies
confined to ProductRepositoryImpl implementation.

In
`@apps/commerce-api/src/test/java/com/loopers/application/product/ProductAppServiceIntegrationTest.java`:
- Around line 75-92: Add an integration test to verify out-of-range paging
behavior for ProductAppService#getProductsByBrandId: create at least one product
via productRepository.save(...), call
productAppService.getProductsByBrandId(testBrand.getId(), 100, 20) (or another
clearly out-of-range page index), and assert that the returned Page has an empty
content list and that totalElements still equals the actual total (e.g., 1);
place this new test in ProductAppServiceIntegrationTest with a descriptive
`@DisplayName` like "존재하지 않는 페이지 요청 시 빈 결과를 반환한다".

In
`@apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java`:
- Around line 126-166: Update ProductFacadeTest to assert paging metadata and
add boundary-case tests: inside getProductsByBrand_success (and/or a new test),
assert that the returned page's page number, page size, and totalPages match
expectations based on the CachedBrandProductPage (use
productFacade.getProductsByBrand result to check getNumber(), getSize(),
getTotalPages(), getTotalElements()); add separate tests that mock
CachedBrandProductPage with totalElements=21 and page size 20 to assert
totalPages == 2 and that requesting page 2 returns the remaining item while
requesting page 3 returns an empty content list; reuse symbols
CachedBrandProductPage, productAppService.getProductsByBrandIdCached(...),
brandAppService.getById(...), and productFacade.getProductsByBrand(...) to
locate where to add/adjust assertions and new test methods.

In
`@apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandProductApiE2ETest.java`:
- Around line 60-95: Add a new test method getBrandProducts_emptyBrand in
BrandProductApiE2ETest that calls testRestTemplate.exchange on "/api/v1/brands/"
+ testBrand.getId() + "/products?page=0" without creating any Product entities,
and assert the response is HttpStatus.OK, response.getBody().data().products()
is empty, and response.getBody().data().totalElements() == 0; place it alongside
getBrandProducts_success and use the same ParameterizedTypeReference<> and
ApiResponse<ProductDto.BrandProductListResponse> types so the test verifies an
empty product list for a newly registered brand.
- Around line 159-175: Add a boundary test to BrandProductApiE2ETest to verify
the maximum allowed page (50) returns 200 OK: create a new test method
getBrandProducts_maxPage_success that uses testRestTemplate.exchange with
"/api/v1/brands/" + testBrand.getId() + "/products?page=50", the same
HttpMethod.GET and
ParameterizedTypeReference<ProductDto.BrandProductListResponse>, then assert
response.getStatusCode() is HttpStatus.OK (and optionally validate response body
metadata/result if desired) to ensure off-by-one errors are caught.

In `@modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java`:
- Around line 28-29: The hardcoded Redis timeouts in RedisConfig
(COMMAND_TIMEOUT and CONNECT_TIMEOUT) should be externalized into configurable
RedisProperties with sensible defaults; update RedisConfig to inject
RedisProperties and use properties.getCommandTimeout()/getConnectTimeout()
(falling back to Duration.ofMillis(500) and Duration.ofMillis(300) only as
defaults), add a properties binding unit test to verify values are read from
configuration into RedisProperties, and add/instrument an integration test that
simulates slow Redis responses to assert the fallback/threshold behavior uses
the configured timeouts rather than the hardcoded constants.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 2947daca-5081-4996-bee0-d07cbf665ae3

📥 Commits

Reviewing files that changed from the base of the PR and between 971d26d and 7f2ae21.

📒 Files selected for processing (23)
  • apps/commerce-api/src/main/java/com/loopers/application/admin/product/AdminProductAppService.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/CachedBrandProductPage.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/CachedProductDetail.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductAppService.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheManager.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java
  • apps/commerce-api/src/main/java/com/loopers/domain/common/Money.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCacheManager.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductDto.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/ProductAppServiceIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/ProductAppServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/BrandProductApiE2ETest.java
  • modules/jpa/src/main/resources/jpa.yml
  • modules/redis/src/main/java/com/loopers/config/redis/RedisConfig.java
💤 Files with no reviewable changes (1)
  • apps/commerce-api/src/test/java/com/loopers/domain/product/ProductTest.java

Comment on lines +7 to +15
Optional<CachedBrandProductPage> getProductList(Long brandId, int page, int size);

void putProductList(Long brandId, int page, int size, CachedBrandProductPage value);

Optional<CachedProductDetail> getProductDetail(Long productId);

void putProductDetail(Long productId, CachedProductDetail value);

void evictProductCaches(Long productId, Long brandId);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

목록 캐시 무효화 계약이 키 차원을 모두 표현하지 못한다.

getProductListputProductList(brandId, page, size) 조합으로 엔트리를 만들지만 evictProductCachessize와 캐시 대상 페이지 범위를 전달받지 않는다. 이 계약으로는 구현체가 완전한 무효화를 할 수 없어 추정 상수에 의존하게 되고, 운영에서는 관리자 수정/삭제 직후 요청 size에 따라 일부 사용자만 오래된 목록을 TTL 동안 계속 보게 된다. 브랜드 단위 버전 키로 네임스페이스를 끊거나, 최소한 무효화 계약에 페이지/사이즈 정책을 포함해 구현체가 추측하지 않도록 바꾸는 편이 안전하다. 추가 테스트로 서로 다른 size와 캐시 가능 페이지에 저장한 목록 캐시가 수정/삭제 이후 모두 miss 되는지 검증해야 한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheManager.java`
around lines 7 - 15, evictProductCaches currently can't fully invalidate list
entries because getProductList/putProductList store entries by (brandId, page,
size) but evictProductCaches(Long productId, Long brandId) lacks page/size (or a
brand-level namespace) info; update the contract to either (a) include page and
size (e.g., evictProductCaches(Long productId, Long brandId, Integer page,
Integer size) or evictBySizeRange) so implementations can remove exact keys, or
(b) introduce a brand-version/namespace token used by
getProductList/putProductList and bumped by evictProductCaches(brandId) so all
page/size combos are implicitly invalidated; then add tests for
CachedBrandProductPage to assert lists cached at different sizes/pages are all
misses after a putProductDetail/evictProductCaches call.

- (deleted, like_count DESC): 전체 상품 좋아요 순 조회
- (deleted, created_at DESC): 전체 상품 최신순 조회
- (deleted, base_price ASC): 전체 상품 가격순 조회
@coderabbitai
Copy link

coderabbitai bot commented Mar 12, 2026

Caution

Failed to replace (edit) comment. This is likely due to insufficient permissions or the comment being deleted.

Error details
{"name":"HttpError","status":403,"request":{"method":"PATCH","url":"https://api.github.com/repos/Loopers-dev-lab/loop-pack-be-l2-vol3-java/issues/comments/4048530259","headers":{"accept":"application/vnd.github.v3+json","user-agent":"octokit.js/0.0.0-development octokit-core.js/7.0.6 Node.js/24","authorization":"token [REDACTED]","content-type":"application/json; charset=utf-8"},"body":{"body":"<!-- This is an auto-generated comment: summarize by coderabbit.ai -->\n<!-- walkthrough_start -->\n\n<details>\n<summary>📝 Walkthrough</summary>\n\n## Walkthrough\n\n제품 캐싱 인프라를 추가하고 Redis 기반 캐시 관리자를 구현했다. 제품 상세 정보 및 브랜드별 제품 목록 조회에 페이징 지원을 도입하고, 업데이트/삭제 시 캐시 무효화 로직을 통합했다. Money 클래스의 Jackson 직렬화를 활성화하고 Product의 likeCount 변경 메서드를 제거했다.\n\n## Changes\n\n|Cohort / File(s)|Summary|\n|---|---|\n|**캐싱 DTO 및 인터페이스** <br> `CachedBrandProductPage.java`, `CachedProductDetail.java`, `ProductCacheManager.java`|제품 목록과 상세 정보를 위한 불변 캐시 데이터 구조 및 캐시 관리 인터페이스 추가. get/put/evict 메서드를 통한 캐시 수명 주기 관리.|\n|**Redis 캐시 구현** <br> `RedisProductCacheManager.java`|ProductCacheManager 인터페이스 구현. TTL 및 지터를 포함한 Redis 기반 저장소. 목록 캐시 60초(±1-10초), 상세 캐시 300초(±1-30초). 직렬화 오류 및 Redis 장애에 대한 안전한 폴백 처리.|\n|**애플리케이션 레이어 확장** <br> `ProductAppService.java`, `AdminProductAppService.java`|ProductCacheManager 의존성 추가. 페이징 지원(getProductsByBrandId, getProductsByBrandIdCached, getProductDetailCached). 제품 업데이트/삭제 시 캐시 무효화 호출.|\n|**Facade 및 제품 조회 로직** <br> `ProductFacade.java`, `ProductInfo.java`|캐시된 상세 정보 및 목록 활용으로 데이터 조회 경로 변경. 브랜드별 페이징 조회 메서드 추가(getProductsByBrand). ProductInfo에 `@Jacksonized` 및 withLikedByUser() 메서드 추가.|\n|**도메인 및 인프라 계층** <br> `Product.java`, `ProductRepository.java`, `ProductJpaRepository.java`, `ProductRepositoryImpl.java`|Product에서 increaseLikeCount/decreaseLikeCount 제거. findByBrandIdWithPaging 메서드 추가로 브랜드별 페이징 조회 지원. 데이터베이스 인덱스 추가(brandId + deleted 복합 인덱스).|\n|**직렬화 지원** <br> `Money.java`|@JsonCreator 및 `@JsonProperty` 어노테이션으로 Jackson 역직렬화 활성화. 생성자에서 amount 필드 초기화. subtract 메서드 예외 타입을 IllegalArgumentException에서 CoreException(BAD_REQUEST)으로 변경.|\n|**API 계층** <br> `ProductController.java`, `ProductDto.java`|GET /api/v1/brands/{brandId}/products 엔드포인트 추가. 페이지 범위 검증(MAX_PAGE 상수). BrandProductResponse 및 BrandProductListResponse 레코드 추가.|\n|**테스트 확대** <br> `ProductAppServiceTest.java`, `ProductAppServiceIntegrationTest.java`, `ProductFacadeTest.java`, `ProductTest.java`, `MoneyTest.java`, `BrandProductApiE2ETest.java`|캐시 hit/miss/bypass 시나리오 테스트. 통합 테스트로 페이징 및 정렬 검증. likeCount 변경 메서드 제거에 따른 테스트 삭제. 예외 타입 변경 반영.|\n|**설정** <br> `RedisConfig.java`, `jpa.yml`|Lettuce 클라이언트 옵션 추가(connectTimeout, commandTimeout). Hibernate DDL 전략을 create에서 update로 변경(local 프로필).|\n\n## Sequence Diagram\n\n```mermaid\nsequenceDiagram\n    participant Client\n    participant Controller\n    participant Facade\n    participant AppService\n    participant CacheManager\n    participant Redis\n    participant Repository\n    participant Database\n\n    rect rgba(100, 150, 200, 0.5)\n        Note over Client,Database: 제품 상세 조회 (캐싱 흐름)\n        Client->>Controller: GET /api/v1/products/{id}\n        Controller->>Facade: getProductDetail(productId)\n        Facade->>AppService: getProductDetailCached(productId)\n        AppService->>CacheManager: getProductDetail(productId)\n        CacheManager->>Redis: GET product:detail:{id}\n        alt 캐시 Hit\n            Redis-->>CacheManager: CachedProductDetail\n            CacheManager-->>AppService: Optional[CachedProductDetail]\n        else 캐시 Miss\n            CacheManager-->>AppService: Optional.empty()\n            AppService->>Repository: findById(productId)\n            Repository->>Database: SELECT Product\n            Database-->>Repository: Product\n            AppService->>AppService: getOptions, getLikeCount\n            AppService->>CacheManager: putProductDetail(productId, detail)\n            CacheManager->>Redis: SET product:detail:{id} (TTL 300s+jitter)\n        end\n        AppService-->>Facade: CachedProductDetail\n        Facade->>Facade: mapToProductInfo()\n        Facade-->>Controller: ProductInfo\n        Controller-->>Client: ApiResponse<ProductDto>\n    end\n\n    rect rgba(200, 100, 150, 0.5)\n        Note over Client,Database: 브랜드별 제품 목록 조회 (페이징 + 캐싱)\n        Client->>Controller: GET /api/v1/brands/{brandId}/products?page=0\n        Controller->>Facade: getProductsByBrand(brandId, page, size)\n        Facade->>AppService: getProductsByBrandIdCached(brandId, page, size)\n        AppService->>CacheManager: getProductList(brandId, page, size)\n        CacheManager->>Redis: GET product:list:brand:{brandId}:page:{page}\n        alt 페이지 0-1 & 캐시 Hit\n            Redis-->>CacheManager: CachedBrandProductPage\n            CacheManager-->>AppService: Optional[CachedBrandProductPage]\n        else 캐시 Miss or 깊은 페이지\n            CacheManager-->>AppService: Optional.empty()\n            AppService->>Repository: findByBrandIdWithPaging(brandId, pageable)\n            Repository->>Database: SELECT Products ORDER BY likeCount DESC\n            Database-->>Repository: Page<Product>\n            AppService->>AppService: mapToProductSummaries()\n            alt 페이지 0-1\n                AppService->>CacheManager: putProductList(brandId, page, cachedPage)\n                CacheManager->>Redis: SET product:list:... (TTL 60s+jitter)\n            end\n        end\n        AppService-->>Facade: CachedBrandProductPage\n        Facade->>Facade: mapToProductInfo for each\n        Facade-->>Controller: Page<ProductInfo>\n        Controller-->>Client: ApiResponse<BrandProductListResponse>\n    end\n\n    rect rgba(150, 200, 100, 0.5)\n        Note over Client,Database: 제품 업데이트/삭제 (캐시 무효화)\n        Client->>Controller: PUT/DELETE /api/v1/admin/products/{id}\n        Controller->>AdminService: update/delete(productId)\n        AdminService->>Repository: save(product) / delete(product)\n        Repository->>Database: UPDATE/DELETE Product\n        AdminService->>CacheManager: evictProductCaches(productId, brandId)\n        CacheManager->>Redis: DEL product:detail:{id}<br/>DEL product:list:brand:{brandId}:page:*\n        Redis-->>CacheManager: Eviction complete\n        CacheManager-->>AdminService: void\n        AdminService-->>Controller: success\n        Controller-->>Client: ApiResponse<Void>\n    end\n```\n\n## Estimated code review effort\n\n🎯 4 (Complex) | ⏱️ ~50 minutes\n\n## Possibly related PRs\n\n- **#115**: ProductAppService 및 ProductFacade의 제품 조회 로직 변경과 캐싱 인프라 통합이 직접적으로 연관됨.\n- **#158**: Product 엔티티의 likeCount 변경 메서드(increaseLikeCount/decreaseLikeCount) 제거 변경이 동일함.\n- **#188**: Redis 기반 제품 캐싱 및 캐시 무효화 로직 추가가 일치함.\n\n</details>\n\n<!-- walkthrough_end -->\n\n\n<!-- pre_merge_checks_walkthrough_start -->\n\n<details>\n<summary>🚥 Pre-merge checks | ✅ 2 | ❌ 1</summary>\n\n### ❌ Failed checks (1 warning)\n\n|     Check name     | Status     | Explanation                                                                          | Resolution                                                                         |\n| :----------------: | :--------- | :----------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------- |\n| Docstring Coverage | ⚠️ Warning | Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. | Write docstrings for the functions missing them to satisfy the coverage threshold. |\n\n<details>\n<summary>✅ Passed checks (2 passed)</summary>\n\n|     Check name    | Status   | Explanation                                                                            |\n| :---------------: | :------- | :------------------------------------------------------------------------------------- |\n|    Title check    | ✅ Passed | PR 제목이 주요 변경사항을 명확히 요약했다: 인덱스와 캐시를 통한 상품 조회 읽기 성능 최적화.                                 |\n| Description check | ✅ Passed | Pull request 설명이 템플릿의 모든 필수 섹션을 포함하고 있으며, 문제 정의, 의사결정 근거, 설계 개요, 흐름도 등이 체계적으로 작성되어 있다. |\n\n</details>\n\n<sub>✏️ Tip: You can configure your own custom pre-merge checks in the settings.</sub>\n\n</details>\n\n<!-- pre_merge_checks_walkthrough_end -->\n\n<!-- finishing_touch_checkbox_start -->\n\n<details>\n<summary>✨ Finishing Touches</summary>\n\n<details>\n<summary>🧪 Generate unit tests (beta)</summary>\n\n- [ ] <!-- {\"checkboxId\": \"f47ac10b-58cc-4372-a567-0e02b2c3d479\", \"radioGroupId\": \"utg-output-choice-group-unknown_comment_id\"} -->   Create PR with unit tests\n- [ ] <!-- {\"checkboxId\": \"07f1e7d6-8a8e-4e23-9900-8731c2c87f58\", \"radioGroupId\": \"utg-output-choice-group-unknown_comment_id\"} -->   Post copyable unit tests in a comment\n\n</details>\n\n</details>\n\n<!-- finishing_touch_checkbox_end -->\n\n<!-- pr_review_plan_action_start -->\n\n<details>\n<summary>📝 Coding Plan</summary>\n\n- [ ] <!-- {\"checkboxId\": \"6ad8a4e1-0b3a-4ea2-9b5b-d82c1f47d1f2\"} --> Generate coding plan for human review comments\n\n</details>\n\n<!-- pr_review_plan_action_end -->\n\n<!-- tips_start -->\n\n---\n\nThanks for using [CodeRabbit](https://coderabbit.ai?utm_source=oss&utm_medium=github&utm_campaign=Loopers-dev-lab/loop-pack-be-l2-vol3-java&utm_content=199)! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.\n\n<details>\n<summary>❤️ Share</summary>\n\n- [X](https://twitter.com/intent/tweet?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A&url=https%3A//coderabbit.ai)\n- [Mastodon](https://mastodon.social/share?text=I%20just%20used%20%40coderabbitai%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20the%20proprietary%20code.%20Check%20it%20out%3A%20https%3A%2F%2Fcoderabbit.ai)\n- [Reddit](https://www.reddit.com/submit?title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&text=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code.%20Check%20it%20out%3A%20https%3A//coderabbit.ai)\n- [LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fcoderabbit.ai&mini=true&title=Great%20tool%20for%20code%20review%20-%20CodeRabbit&summary=I%20just%20used%20CodeRabbit%20for%20my%20code%20review%2C%20and%20it%27s%20fantastic%21%20It%27s%20free%20for%20OSS%20and%20offers%20a%20free%20trial%20for%20proprietary%20code)\n\n</details>\n\n<sub>Comment `@coderabbitai help` to get the list of available commands and usage tips.</sub>\n\n<!-- tips_end -->\n\n<!-- internal state start -->\n\n\n<!-- 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 -->\n\n<!-- internal state end -->\n<!-- usage_tips_start -->\n\n> [!TIP]\n> <details>\n> <summary>CodeRabbit can use Trivy to scan for security misconfigurations and secrets in Infrastructure as Code files.</summary>\n> \n> Add a [.trivyignore file](https://aquasecurity.github.io/trivy/latest/docs/configuration/filtering/) to your project to customize which findings Trivy reports.\n> \n> </details>\n\n<!-- usage_tips_end -->"},"request":{"retryCount":1}},"response":{"url":"https://api.github.com/repos/Loopers-dev-lab/loop-pack-be-l2-vol3-java/issues/comments/4048530259","status":403,"headers":{"access-control-allow-origin":"*","access-control-expose-headers":"ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset","content-encoding":"gzip","content-security-policy":"default-src 'none'","content-type":"application/json; charset=utf-8","date":"Thu, 12 Mar 2026 18:24:02 GMT","referrer-policy":"origin-when-cross-origin, strict-origin-when-cross-origin","server":"github.com","strict-transport-security":"max-age=31536000; includeSubdomains; preload","transfer-encoding":"chunked","vary":"Accept-Encoding, Accept, X-Requested-With","x-accepted-github-permissions":"issues=write; pull_requests=write","x-content-type-options":"nosniff","x-frame-options":"deny","x-github-api-version-selected":"2022-11-28","x-github-media-type":"github.v3; format=json","x-github-request-id":"10DE:35C1B9:1692487:5EB0727:69B304C0","x-ratelimit-limit":"7250","x-ratelimit-remaining":"7149","x-ratelimit-reset":"1773342328","x-ratelimit-resource":"core","x-ratelimit-used":"101","x-xss-protection":"0"},"data":{"message":"Resource not accessible by integration","documentation_url":"https://docs.github.com/rest/issues/comments#update-an-issue-comment","status":"403"}}}

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java (1)

18-23: 도메인 엔티티에 인덱스 전략을 직접 두면 운영 스키마 관리 경계가 약해진다.

Line [18]-Line [23]처럼 인덱스 정의를 도메인 엔티티에 직접 두면, 운영에서 마이그레이션 기반 스키마 관리와 이중 소스가 되어 환경별 스키마 드리프트 추적이 어려워진다. 수정안은 인덱스 생성/변경 책임을 버전드 마이그레이션으로 일원화하고, 엔티티는 도메인 규칙 중심으로 유지하는 방식이다. 추가 테스트로는 마이그레이션 적용 후 INFORMATION_SCHEMA 기준 인덱스명/컬럼 순서/정렬 방향을 검증하는 통합 점검을 권장한다.
As per coding guidelines '도메인 규칙과 인프라 관심사가 섞이면 분리하도록 제안'을 근거로 한 제안이다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java`
around lines 18 - 23, The Product entity currently embeds DB index definitions
in the `@Table`(indexes=...) annotation which mixes infra concerns into the domain
model; remove the indexes array from the Product class/@Table annotation and
instead create versioned DB migration scripts that create the same indexes (with
the same names, columns, and sort directions), and update deployment docs to run
those migrations; also add an integration test that, after applying migrations,
queries INFORMATION_SCHEMA (or the DB's metadata) to assert index names, column
order, and sort directions match expected values to prevent schema drift.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java`:
- Around line 19-22: The `@Index` annotations on Product.java
(idx_products_brand_deleted_likes, idx_products_deleted_likes,
idx_products_deleted_created, idx_products_deleted_price) are not created in
production because ddl-auto is none; create a SQL migration (via your chosen
migrator: Flyway or Liquibase) that executes CREATE INDEX statements for each of
those four index names on the products table (matching the column lists in the
annotations), add the migration file to the repository and ensure it runs at
deploy time, and update the deployment pipeline to run a post-deploy check
(e.g., SHOW INDEX FROM products WHERE Key_name IN (...)) to fail the deploy if
any index is missing.

---

Nitpick comments:
In `@apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java`:
- Around line 18-23: The Product entity currently embeds DB index definitions in
the `@Table`(indexes=...) annotation which mixes infra concerns into the domain
model; remove the indexes array from the Product class/@Table annotation and
instead create versioned DB migration scripts that create the same indexes (with
the same names, columns, and sort directions), and update deployment docs to run
those migrations; also add an integration test that, after applying migrations,
queries INFORMATION_SCHEMA (or the DB's metadata) to assert index names, column
order, and sort directions match expected values to prevent schema drift.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a845be3d-ac83-409c-9787-5c8e5d07857b

📥 Commits

Reviewing files that changed from the base of the PR and between 7f2ae21 and 85ae499.

📒 Files selected for processing (1)
  • apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java

Comment on lines +19 to +22
@Index(name = "idx_products_brand_deleted_likes", columnList = "brand_id, deleted, like_count DESC"),
@Index(name = "idx_products_deleted_likes", columnList = "deleted, like_count DESC"),
@Index(name = "idx_products_deleted_created", columnList = "deleted, created_at DESC"),
@Index(name = "idx_products_deleted_price", columnList = "deleted, base_price ASC")
Copy link

@coderabbitai coderabbitai bot Mar 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== 1) DDL 자동 생성 설정 확인 =="
fd -a 'application.*' | xargs -r rg -n --no-heading 'ddl-auto|hibernate\.hbm2ddl\.auto'

echo
echo "== 2) 인덱스 마이그레이션 존재 여부 확인 =="
fd -a '.*\.(sql|java|kt)$' | xargs -r rg -n --no-heading \
'idx_products_brand_deleted_likes|idx_products_deleted_likes|idx_products_deleted_created|idx_products_deleted_price|create[[:space:]]+index.*products'

echo
echo "== 3) Product 엔티티의 인덱스 선언 재확인 =="
fd -a 'Product.java' | xargs -r rg -n --no-heading '@Table|@Index|columnList'

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 111


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== 저장소 구조 탐색 =="
find . -type f -name "*.properties" -o -name "*.yml" -o -name "*.yaml" | head -20

echo
echo "== Product.java 파일 위치 =="
find . -type f -name "Product.java" | head -10

echo
echo "== 마이그레이션 디렉토리 찾기 =="
find . -type d \( -name "migration" -o -name "liquibase" -o -name "flyway" -o -name "db" \) | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 868


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== JPA 설정 확인 (jpa.yml) =="
cat ./modules/jpa/src/main/resources/jpa.yml

echo
echo "== commerce-api 설정 확인 (application.yml) =="
cat ./apps/commerce-api/src/main/resources/application.yml

echo
echo "== Product.java 파일 내용 (1-50줄) =="
head -50 ./apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java

echo
echo "== SQL 파일 검색 =="
find . -type f \( -name "*.sql" -o -name "*.ddl" \) | head -20

echo
echo "== Liquibase/Flyway 관련 파일 검색 =="
find . -type f \( -name "*.xml" -o -name "*.conf" \) | grep -i -E 'migration|liquibase|flyway' | head -20

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 5133


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== Gradle 빌드 스크립트 검색 (index 관련) =="
find . -name "*.gradle" -o -name "*.gradle.kts" | xargs -r grep -i 'index\|ddl\|migration' || echo "No matches found"

echo
echo "== Docker/초기화 스크립트 검색 =="
find . -type f \( -name "*.sh" -o -name "Dockerfile*" -o -name "docker-compose*" \) | head -20

echo
echo "== 데이터베이스 초기화 스크립트 =="
find . -type f -name "*.sh" | xargs -r grep -l 'index\|CREATE.*TABLE' || echo "No matches found"

echo
echo "== 전체 repository에서 CREATE INDEX 검색 =="
rg -i 'create\s+index' --type-list | head -5 || echo "No matches found"

echo
echo "== 전체 repository에서 ddl-auto 검색 =="
rg 'ddl-auto' --all-text

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 1096


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== 전체 repository에서 ddl-auto 검색 =="
rg 'ddl-auto' 

echo
echo "== 전체 repository에서 CREATE INDEX 검색 =="
rg 'CREATE.*INDEX' 

echo
echo "== Docker compose 파일 목록 =="
find . -name "*docker*compose*" -o -name "Dockerfile*"

echo
echo "== SQL 초기화 파일 검색 =="
find . -type f \( -name "*.sql" -o -name "init*.sh" -o -name "setup*.sh" \)

Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java

Length of output: 341


운영 환경에서 @Index 선언이 실제 DB 인덱스로 생성되지 않는 문제를 반드시 해결해야 한다.

jpa.yml 설정을 확인한 결과, 운영(prd), QA, 개발(dev) 환경의 ddl-auto가 기본값 none으로 설정되어 있다. Product.java 19-22줄에 선언된 4개의 @Index 주석(idx_products_brand_deleted_likes, idx_products_deleted_likes, idx_products_deleted_created, idx_products_deleted_price)은 Hibernate가 자동으로 생성하지 않으므로, 실제 DB에 인덱스가 없어 조회 성능이 심각하게 회귀한다.

현재 저장소에는 마이그레이션 파일, SQL 스크립트, Liquibase/Flyway 설정이 전혀 없다. 동일 인덱스를 생성하는 SQL 마이그레이션 파일을 작성하여 배포 전에 반드시 실행되도록 하고, 배포 파이프라인에 인덱스 존재 확인(SHOW INDEX FROM products) 단계를 추가해야 한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java`
around lines 19 - 22, The `@Index` annotations on Product.java
(idx_products_brand_deleted_likes, idx_products_deleted_likes,
idx_products_deleted_created, idx_products_deleted_price) are not created in
production because ddl-auto is none; create a SQL migration (via your chosen
migrator: Flyway or Liquibase) that executes CREATE INDEX statements for each of
those four index names on the products table (matching the column lists in the
annotations), add the migration file to the repository and ensure it runs at
deploy time, and update the deployment pipeline to run a post-deploy check
(e.g., SHOW INDEX FROM products WHERE Key_name IN (...)) to fail the deploy if
any index is missing.

Copy link
Contributor Author

@madirony madirony Mar 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai 해당 인덱스 생성 부분은 JPA ddl-auto에 의존하지 않고, 내부 데이터베이스 마이그레이션 파이프라인(별도 DB 스크립트)을 통해 관리 및 반영 중입니다.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@madirony, 내부 DB 마이그레이션 파이프라인을 통해 인덱스가 별도로 관리되고 있음을 확인하였다. 해당 파이프라인의 존재가 저장소 내에서 파악되지 않아 제기된 지적이므로, 내용을 기록하겠다.

(∪・ω・∪)


✏️ Learnings added
Learnt from: madirony
Repo: Loopers-dev-lab/loop-pack-be-l2-vol3-java PR: 199
File: apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java:19-22
Timestamp: 2026-03-13T18:25:54.998Z
Learning: In the Loopers-dev-lab/loop-pack-be-l2-vol3-java project, database index creation (including JPA Index annotations on Product.java) is NOT managed by JPA ddl-auto. Instead, it is handled via an internal, separate DB migration pipeline (external DB scripts). Do not flag missing Flyway/Liquibase migration files or ddl-auto settings as issues for this repository, as the DB schema changes are applied through a different internal process.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: yoon-yoo-tak
Repo: Loopers-dev-lab/loop-pack-be-l2-vol3-java PR: 189
File: apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java:52-55
Timestamp: 2026-03-13T08:29:17.394Z
Learning: In the loop-pack-be-l2-vol3-java project (commerce-api module), the `Product` entity declares `brandId` with `Column(nullable = false)` and validates it as non-null in the constructor. Therefore, `ProductReadModel.brandId()` is structurally guaranteed to be non-null, and null checks on `brandId` in controller or application layer code (e.g., `ProductV1Controller.getById`) are unnecessary defensive code.

Learnt from: ukukdin
Repo: Loopers-dev-lab/loop-pack-be-l2-vol3-java PR: 203
File: apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java:53-55
Timestamp: 2026-03-13T05:44:03.072Z
Learning: In code reviews of Java files under apps/commerce-api/src/main/java, pay attention to the caching strategy used by ProductQueryService (and related services). If you encounter a method like evictProductListAfterCommit() that uses cache.clear() (full cache invalidation) instead of selective eviction by brandId, evaluate whether this trade-off is justified by current workload: TTL of 1 minute and low admin write frequency. Only favor full invalidation when it is intentional and documented; otherwise consider implementing selective eviction by key (e.g., brandId) and instrument cache metrics (e.g., Micrometer) to determine if selective eviction would reduce unnecessary cache misses. If metrics indicate frequent admin modifications or poor hit rates, propose migrating to selective eviction. Ensure that such decisions are backed by automated validation or CI checks before rollout.

Learnt from: yoon-yoo-tak
Repo: Loopers-dev-lab/loop-pack-be-l2-vol3-java PR: 189
File: apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductQueryServiceImpl.java:18-20
Timestamp: 2026-03-13T08:29:47.665Z
Learning: In loop-pack-be-l2-vol3-java (commerce-api module), Transactional declaration is the responsibility of the Application layer (ApplicationService) only. Infrastructure layer implementations such as ProductQueryServiceImpl (in apps/commerce-api/src/main/java/com/loopers/infrastructure/product/) must NOT declare Transactional, even readOnly = true, because: (1) on cache hits there is no DB access, and (2) on cache misses, Spring Data JPA's SimpleJpaRepository already provides Transactional(readOnly = true) by default. Duplicate Transactional declarations in the Infrastructure layer are intentionally avoided to respect layer responsibilities.

Learnt from: jsj1215
Repo: Loopers-dev-lab/loop-pack-be-l2-vol3-java PR: 142
File: apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java:80-94
Timestamp: 2026-03-06T06:41:42.198Z
Learning: In `ProductRepositoryImpl.java` (Loopers-dev-lab/loop-pack-be-l2-vol3-java), `findOptionByIdWithLock()` uses `entityManager.find(ProductOption.class, optionId, LockModeType.PESSIMISTIC_WRITE)` without a lock timeout hint. The call flow is: `OrderService.prepareOrderItems()` → `findProductOnly()` (loads only Product, not ProductOption) → `deductStock()` → `findOptionByIdWithLock()`. Since ProductOption is never loaded into the 1st-level cache before this point, there is no stale read risk. However, the lock timeout hint (`jakarta.persistence.lock.timeout`) is missing, risking infinite wait. Fix: use the 4-parameter `entityManager.find()` overload with `Map.of("jakarta.persistence.lock.timeout", 3000L)`.

Learnt from: Namjin-kimm
Repo: Loopers-dev-lab/loop-pack-be-l2-vol3-java PR: 152
File: apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java:86-91
Timestamp: 2026-03-06T09:00:23.795Z
Learning: In Loopers-dev-lab/loop-pack-be-l2-vol3-java PR `#152`, OrderFacade.java's deadlock prevention for multi-product orders relies on MySQL InnoDB's implicit behavior of scanning IN-clause PK queries in ascending order (B+Tree clustered index traversal). This is a deliberate design decision acknowledged by the author as a trade-off: InnoDB generally locks rows in PK ascending order for IN queries, but this is not a formal SQL standard guarantee. If the query optimizer changes execution plans (e.g., full table scan), lock order may change and deadlocks could occur. The safe alternative is adding explicit ORDER BY id to the SELECT FOR UPDATE query in ProductJpaRepository.findAllByIdsForUpdate(). InnoDB does auto-detect and roll back deadlocked transactions, so hangs are avoided but transaction failures still occur.

Learnt from: yoon-yoo-tak
Repo: Loopers-dev-lab/loop-pack-be-l2-vol3-java PR: 6
File: apps/commerce-api/src/main/java/com/loopers/interfaces/api/user/UserV1Dto.java:9-10
Timestamp: 2026-02-04T05:52:09.963Z
Learning: In the loop-pack-be-l2-vol3-java project, enforce unified error handling by routing errors through CoreException to ApiControllerAdvice to ensure a consistent response format. Do not introduce MethodArgumentNotValidException handlers or Bean Validation handling, as that would create inconsistent error handling patterns. This guidance applies to all Java files under the commerce-api module (source code under apps/commerce-api/src/main/java/).

…aker

- CachedProductDetail.from()에 null-safe 옵션 매핑 추가
- ProductAppService에 validatedPageRequest() 헬퍼 도입 (page/size 검증)
- ProductAppService 캐시 조회/저장에 예외 흡수 try-catch 추가 (DB fallback)
- 브랜드별 좋아요순 쿼리에 p.id DESC tie-breaker 추가
- ProductAppServiceTest에 Redis 장애 fallback 테스트 추가
- MoneyTest 예외 검증을 isInstanceOfSatisfying으로 ErrorType 상세 검증
@madirony
Copy link
Contributor Author

@coderabbitai 이제 다 고침

@coderabbitai
Copy link

coderabbitai bot commented Mar 13, 2026

@madirony 수정 사항을 확인하기 위해 PR을 재검토하겠다.


🧠 Learnings used
Learnt from: ukukdin
Repo: Loopers-dev-lab/loop-pack-be-l2-vol3-java PR: 203
File: apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryService.java:53-55
Timestamp: 2026-03-13T05:44:03.072Z
Learning: In Loopers-dev-lab/loop-pack-be-l2-vol3-java PR `#203`, the PRODUCT_LIST cache eviction strategy in ProductService.evictProductListAfterCommit() intentionally uses cache.clear() (full cache invalidation) rather than selective eviction by brandId. This is a deliberate trade-off: TTL is 1 minute and admin write frequency is currently low, so full invalidation is considered acceptable. Selective eviction by brandId is deferred until admin modifications become frequent or cache hit rates are measured to be low via Micrometer metrics.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductAppService.java (1)

53-56: ⚠️ Potential issue | 🟠 Major

deep paging 상한을 서비스에서 강제하지 않아 비싼 페이지 조회가 그대로 열린다

validatedPageRequest()page < 0만 검사하고 있어 page=51 같은 요청이 그대로 저장소 호출로 이어진다. 운영 관점에서는 OFFSET 비용이 큰 조회가 다시 열려 이번 PR의 성능 보호 장치가 무력화된다. 또한 getProductsByBrandIdCached()는 검증 전에 캐시를 먼저 조회하므로 잘못된 요청도 Redis를 한 번 더 타게 되고, 비정상 키가 남아 있으면 잘못된 응답을 반환할 여지도 있다.

수정안은 두 공개 메서드 초입에서 Pageable pageable = validatedPageRequest(page, size);로 먼저 검증하고, helper에서 page > MAX_PAGECoreException(ErrorType.BAD_REQUEST)로 함께 차단한 뒤 같은 pageable을 재사용하는 것이다. 상수도 MAX_PAGEMAX_PAGE_SIZE로 분리하는 편이 안전하다.

수정 예시
-    private static final int MAX_PAGE_SIZE = 50;
+    private static final int MAX_PAGE = 50;
+    private static final int MAX_PAGE_SIZE = 50;
...
     `@Transactional`(readOnly = true)
     public Page<Product> getProductsByBrandId(Long brandId, int page, int size) {
-        return productRepository.findByBrandIdWithPaging(brandId, validatedPageRequest(page, size));
+        Pageable pageable = validatedPageRequest(page, size);
+        return productRepository.findByBrandIdWithPaging(brandId, pageable);
     }
...
     `@Transactional`(readOnly = true)
     public CachedBrandProductPage getProductsByBrandIdCached(Long brandId, int page, int size) {
+        Pageable pageable = validatedPageRequest(page, size);
+
         if (page <= CACHEABLE_PAGE_LIMIT) {
             try {
                 Optional<CachedBrandProductPage> cached = productCacheManager.getProductList(brandId, page, size);
                 if (cached.isPresent()) {
                     return cached.get();
@@
-        Page<Product> products = productRepository.findByBrandIdWithPaging(brandId, validatedPageRequest(page, size));
+        Page<Product> products = productRepository.findByBrandIdWithPaging(brandId, pageable);
...
     private Pageable validatedPageRequest(int page, int size) {
-        if (page < 0 || size <= 0 || size > MAX_PAGE_SIZE) {
-            throw new CoreException(ErrorType.BAD_REQUEST, "page는 0 이상, size는 1~" + MAX_PAGE_SIZE + " 범위여야 합니다.");
+        if (page < 0 || page > MAX_PAGE || size <= 0 || size > MAX_PAGE_SIZE) {
+            throw new CoreException(
+                    ErrorType.BAD_REQUEST,
+                    "page는 0~" + MAX_PAGE + ", size는 1~" + MAX_PAGE_SIZE + " 범위여야 합니다."
+            );
         }
         return PageRequest.of(page, size);
     }

추가 테스트로는 page=50 허용, page=51 거절, page=-1/size=0/size=51 거절, 그리고 잘못된 입력일 때 productCacheManager가 호출되지 않는 케이스를 getProductsByBrandId()getProductsByBrandIdCached() 모두에 넣어야 한다.

Also applies to: 83-109, 192-196

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductAppService.java`
around lines 53 - 56, The service currently lets deep-paging requests through
and calls the cache before validating, so fix getProductsByBrandId and
getProductsByBrandIdCached to compute Pageable pageable =
validatedPageRequest(page, size) at the very start (before any
productCacheManager calls), have validatedPageRequest check both page < 0, page
> MAX_PAGE and size < 1, size > MAX_PAGE_SIZE and throw new
CoreException(ErrorType.BAD_REQUEST) for out-of-range values, and then reuse
that same pageable when calling productRepository.findByBrandIdWithPaging;
introduce/rename constants MAX_PAGE and MAX_PAGE_SIZE and ensure no cache lookup
happens if validation fails so invalid requests never hit Redis.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@apps/commerce-api/src/test/java/com/loopers/application/product/ProductAppServiceTest.java`:
- Around line 289-366: Add explicit boundary tests for page=2 to prevent
regression of the CACHEABLE_PAGE_LIMIT behavior: create two new tests that call
productAppService.getProductsByBrandIdCached(1L, 2, 20) — one where
productCacheManager.getProductList(1L, 2, 20) returns a CachedBrandProductPage
and verify productRepository.findByBrandIdWithPaging(...) is never invoked, and
another where getProductList(...) returns Optional.empty() (and
productRepository.findByBrandIdWithPaging(...) returns a Page) and verify
productCacheManager.putProductList(1L, 2, 20, ...) is called; reference
productAppService.getProductsByBrandIdCached,
productCacheManager.getProductList, productCacheManager.putProductList, and
productRepository.findByBrandIdWithPaging to locate the code under test.

---

Duplicate comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductAppService.java`:
- Around line 53-56: The service currently lets deep-paging requests through and
calls the cache before validating, so fix getProductsByBrandId and
getProductsByBrandIdCached to compute Pageable pageable =
validatedPageRequest(page, size) at the very start (before any
productCacheManager calls), have validatedPageRequest check both page < 0, page
> MAX_PAGE and size < 1, size > MAX_PAGE_SIZE and throw new
CoreException(ErrorType.BAD_REQUEST) for out-of-range values, and then reuse
that same pageable when calling productRepository.findByBrandIdWithPaging;
introduce/rename constants MAX_PAGE and MAX_PAGE_SIZE and ensure no cache lookup
happens if validation fails so invalid requests never hit Redis.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: f6cdfb59-8cf7-4f7c-983a-6f786b573f9e

📥 Commits

Reviewing files that changed from the base of the PR and between 85ae499 and c185621.

📒 Files selected for processing (5)
  • apps/commerce-api/src/main/java/com/loopers/application/product/CachedProductDetail.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductAppService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/ProductAppServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java
🚧 Files skipped from review as they are similar to previous changes (2)
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
  • apps/commerce-api/src/test/java/com/loopers/domain/common/MoneyTest.java

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
apps/commerce-api/src/test/java/com/loopers/application/product/ProductAppServiceTest.java (1)

329-387: ⚠️ Potential issue | 🟡 Minor

캐시 저장 예외 경로 테스트를 추가해야 한다

Line 329~Line 347에서 캐시 조회 예외 fallback은 검증했지만, productCacheManager.putProductList(...) 저장 예외 시에도 서비스가 정상 응답하는지 검증이 빠져 있다. 운영 관점에서 Redis 쓰기 장애가 발생하면 읽기 트래픽이 많은 구간에서 장애 전파 가능성이 커지므로, 저장 실패를 흡수하고 DB 결과를 반환하는 회귀 테스트가 필요하다. cachePutException_returnsDbResult 같은 테스트를 추가해 putProductList를 throw로 스텁하고, 예외 전파 없이 결과 반환 및 Repository 호출을 검증하는 방식으로 보완하면 된다.

수정 예시(diff)
+        `@Test`
+        `@DisplayName`("캐시 저장에서 예외가 발생해도 DB 결과를 반환한다")
+        void cachePutException_returnsDbResult() {
+            // given
+            int page = 0;
+            given(productCacheManager.getProductList(1L, page, 20))
+                    .willReturn(Optional.empty());
+
+            Page<Product> dbPage = new PageImpl<>(List.of(), PageRequest.of(page, 20), 0);
+            given(productRepository.findByBrandIdWithPaging(eq(1L), any(PageRequest.class)))
+                    .willReturn(dbPage);
+
+            doThrow(new RuntimeException("Redis write timeout"))
+                    .when(productCacheManager)
+                    .putProductList(eq(1L), eq(page), eq(20), any(CachedBrandProductPage.class));
+
+            // when
+            CachedBrandProductPage result = productAppService.getProductsByBrandIdCached(1L, page, 20);
+
+            // then
+            assertThat(result).isNotNull();
+            verify(productRepository).findByBrandIdWithPaging(eq(1L), any(PageRequest.class));
+        }

As per coding guidelines, **/*Test*.java: 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/application/product/ProductAppServiceTest.java`
around lines 329 - 387, Add a new unit test named
cachePutException_returnsDbResult that stubs
productCacheManager.getProductList(1L, page, 20) to Optional.empty(), stubs
productRepository.findByBrandIdWithPaging(eq(1L), any(PageRequest.class)) to
return a Page<Product>, and then stubs
productCacheManager.putProductList(eq(1L), eq(page), eq(20),
any(CachedBrandProductPage.class)) to throw a RuntimeException; call
productAppService.getProductsByBrandIdCached(1L, page, 20) and assert it returns
the DB-derived CachedBrandProductPage (or non-null), verify
productRepository.findByBrandIdWithPaging was called and ensure the thrown cache
put exception does not propagate from
productAppService.getProductsByBrandIdCached.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In
`@apps/commerce-api/src/test/java/com/loopers/application/product/ProductAppServiceTest.java`:
- Around line 329-387: Add a new unit test named
cachePutException_returnsDbResult that stubs
productCacheManager.getProductList(1L, page, 20) to Optional.empty(), stubs
productRepository.findByBrandIdWithPaging(eq(1L), any(PageRequest.class)) to
return a Page<Product>, and then stubs
productCacheManager.putProductList(eq(1L), eq(page), eq(20),
any(CachedBrandProductPage.class)) to throw a RuntimeException; call
productAppService.getProductsByBrandIdCached(1L, page, 20) and assert it returns
the DB-derived CachedBrandProductPage (or non-null), verify
productRepository.findByBrandIdWithPaging was called and ensure the thrown cache
put exception does not propagate from
productAppService.getProductsByBrandIdCached.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 64552f64-d55e-435b-be23-ba6b7044873e

📥 Commits

Reviewing files that changed from the base of the PR and between c185621 and cb2f3f4.

📒 Files selected for processing (1)
  • apps/commerce-api/src/test/java/com/loopers/application/product/ProductAppServiceTest.java

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant