Skip to content

[Volume 5] 상품 목록 조회 성능, 좋아요 수 정렬 구조 개선 및 캐시 적용#191

Open
YoHanKi wants to merge 4 commits intoLoopers-dev-lab:YoHanKifrom
YoHanKi:week5-optimization-core
Open

[Volume 5] 상품 목록 조회 성능, 좋아요 수 정렬 구조 개선 및 캐시 적용#191
YoHanKi wants to merge 4 commits intoLoopers-dev-lab:YoHanKifrom
YoHanKi:week5-optimization-core

Conversation

@YoHanKi
Copy link

@YoHanKi YoHanKi commented Mar 12, 2026

📌 Summary

  • 배경: 상품 목록 조회에 브랜드 필터 + 정렬(좋아요순/최신/가격순) 쿼리가 Full Table Scan을 수행하고 있었고, 조회 결과에 대한 캐시가 없어 트래픽 증가 시 DB 부하가 선형으로 증가하는 구조였음. 추가로, OFFSET 기반 페이징은 대량 데이터에서 Skip 비용이 선형 증가하며 데이터 변동 시 Skip/Duplicate 정합성 문제가 발생함
  • 목표: (1) Redis 캐시로 읽기 부하를 흡수하며, (2) 캐시 전략(Write-Around vs Write-Through)을 실험 기반으로 선택, (3) Keyset Pagination으로 OFFSET 한계 극복
  • 결과: 쿼리 스캔 rows -81% (99,510 → 18,962), 상품 상세 캐시 HIT 시 DB 쿼리 0건, 수정 후 스탬피드 없음(Write-Through DB 쿼리 0 vs Write-Around 10), 커서 기반 목록 API 추가(/api/v1/products/cursor)

🧭 Context & Decision

문제 정의

  • 현재 동작/제약: 상품 목록 쿼리는 ref_brand_id = ? 필터와 좋아요순/최신/가격순 정렬을 사용하지만, 인덱스가 없어 브랜드 필터링 후 Full Table Scan + filesort 수행
  • 문제: 데이터 증가에 선형 비례하는 쿼리 비용. 캐시 없이 모든 조회가 DB 직접 접근
  • 성공 기준: EXPLAIN type ALLref 전환, 캐시 HIT 시 DB 쿼리 미발생, 수정 후 첫 조회에서 스탬피드 없음

1️⃣ [Index] 정렬 조건별 인덱스 전략 — 3개 분리 vs 1개 복합

AS-IS EXPLAIN (인덱스 없음, 100,000건)

쿼리 type key rows Extra
likes_desc ALL NULL 99,510 Using where; Using filesort
latest ALL NULL 99,510 Using where; Using filesort
price_asc ALL NULL 99,510 Using where; Using filesort

3개 쿼리 모두 Full Table Scan. ref_brand_id 필터가 있어도 인덱스 없어 전체 스캔 후 필터 적용.

선택지와 결정

  • A: 정렬 조건별 분리 인덱스 3개 (brand+like, brand+latest, brand+price)

    • 각 정렬 쿼리에 최적화된 인덱스 → filesort 제거 가능
    • 인덱스 3개 → 쓰기 오버헤드 분산
  • B: 1개 복합 인덱스 (ref_brand_id, like_count, updated_at, price)

    • 인덱스 파일 1개 → 관리 단순
    • 정렬 방향 혼재(DESC/ASC) 시 인덱스 효율 저하
  • 최종 결정: A — 정렬 조건별 분리 인덱스 3개

TO-BE EXPLAIN (인덱스 적용 후)

쿼리 type key rows Extra
likes_desc ref idx_products_brand_like 18,962 Using index condition; Using filesort
latest ref idx_products_brand_latest 18,962 Using index condition
price_asc ref idx_products_brand_price 18,962 Using index condition

Full Table Scan 제거 (ALL → ref), rows -81% (99,510 → 18,962). latest/price_asc는 filesort 완전 제거.

Q1 filesort 잔존 이유: 인덱스는 (ref_brand_id, like_count DESC, deleted_at) 구성이지만 쿼리 정렬이 ORDER BY like_count DESC, updated_at DESCupdated_at이 인덱스 밖에 있어 보조 정렬 filesort 잔존. like_count 동률 상품은 실제로 드물어 영향 제한적. 인덱스에 updated_at을 추가하면 완전 제거 가능하나, 현행 Full Scan 제거가 핵심 개선이므로 현행 유지.


2️⃣ [Index] 정렬 로직 추상화 — switch vs enum Strategy

QueryDSL 도입 시 raw switch로 정렬 분기 구현. 확장성과 책임 분리를 위해 개선.

  • A: switch 유지 — 간단하지만 신규 정렬 추가 시 기존 코드 수정 필요 (OCP 위반)
  • B: interface + 구현체 — 별도 클래스 파일 다수 필요, 인스턴스 관리 복잡
  • C: enum with abstract method (Strategy via enum polymorphism) — 각 상수가 자신의 전략을 캡슐화. 추가 클래스 불필요. OCP 준수

최종 결정: C — ProductSortCondition enum. 신규 정렬 추가 = enum 상수 추가만으로 완결.


3️⃣ [Structure] 좋아요 순 정렬 — 비정규화 vs Materialized View

관점 비정규화 (products.like_count) 다차원 MV (product_like_stats)
단순 per-product 집계 O(1) 인덱스 직접 활용 stats + products JOIN
브랜드별 랭킹 products 스캔 필요 stats 단독 쿼리
시간 윈도우 트렌딩 likes 전체 스캔 불가피 stats WHERE time_window 인덱스
즉시 정합성 항상 최신 Sync: 즉시 / Batch: 지연
동시성 (50 스레드) like_count = 50 (정확) Sync stats.all = 30 (정확)
구현 복잡도 낮음 Sync: 낮음 / Async: 중간

최종 결정: 비정규화 유지. 현재 요구사항이 "단순 per-product 좋아요 순 정렬"이므로 가장 단순하고 즉시 정합성 보장.

MV 도입 트리거: 브랜드별 인기 상품, 최근 N일 트렌딩 기능 추가 시 또는 likes 테이블이 수억 건을 초과할 때.


4️⃣ [Cache] 상세 vs 목록 — Cache-Aside / Read-Through 분리

유스케이스 방식 핵심 이유
상품 상세 getProduct Cache-Aside (ProductCacheStore, 수동 RedisTemplate) Redis 장애 시 명시적 DB fallback 필요. @Cacheable은 예외 전파가 AOP 내부에 감춰져 fallback 제어 어려움
상품 목록 getProducts Read-Through (@Cacheable) Page<T> 복합 타입의 직렬화가 복잡 → Jackson MixIn + 선언적 처리가 더 적합. 복합 키(brand·sort·page)는 SpEL로 자동 분리

5️⃣ [Cache] 쓰기 전략 — Write-Around → Write-Through 전환

초기에는 @CachePut의 트랜잭션 불일치 문제(@Transactional 커밋 전에 캐시 갱신 → 롤백 시 캐시-DB 불일치)를 이유로 Write-Around(@CacheEvict)를 선택.

이후 캐시 스탬피드 실험에서 수치로 비교:

전략 수정 후 동시 조회 10건 DB 쿼리 5회 반복 수정 시 누적 DB 쿼리
Write-Through (productCacheStore.put()) 0 0
Write-Around (@CacheEvict) 10 >5 (스탬피드 반복)

결정 변경: Write-Through 전환

항목 Write-Around Write-Through (수동 put)
수정 후 스탬피드 발생 (MISS → 동시 DB 쿼리) 없음 (HIT 보장)
롤백 시 정합성 완전 보장 TTL 내 단기 stale 위험
핫키 반복 수정 수정마다 스탬피드 재발 수정마다 캐시 갱신

채택 이유: like_count는 Read-Heavy 필드로 수정보다 조회가 압도적으로 많고, stale 위험은 TTL(300s) 내 단기 불일치로 한정되며 재고·결제 핵심 정합성과 무관. @CachePut 대신 메서드 바디 내 명시적 put() 호출로 AOP 타이밍 이슈 회피.

추후 개선 여지: @TransactionalEventListener(AFTER_COMMIT) 적용 시 커밋 확정 후 캐시 갱신으로 불일치 문제 완전 해소 가능.


6️⃣ [Cursor] Keyset Pagination — OFFSET 대체 전략

문제 정의

OFFSET 기반 페이징의 구조적 한계:

문제 설명
Skip 비용 선형 증가 OFFSET N이 크면 N개를 읽고 버림 → 페이지 깊어질수록 쿼리 비용 선형 증가
Skip 문제 조회 도중 새 데이터가 삽입/삭제되면 이전 페이지와 현재 페이지 사이 항목 이동
Duplicate 문제 이전 페이지 마지막 항목이 다음 페이지 첫 번째에 중복 노출 가능

선택지와 결정

  • A: Base64 단순 인코딩 — JSON 직렬화 후 Base64 URL-safe 인코딩. 구현 단순, 디버깅 용이 (디코딩 가능), 추가 의존성 없음
  • B: AES 암호화 — 내부 구조 완전 은닉. 클라이언트 변조 방지가 강화되나 키 관리 및 성능 오버헤드 추가

최종 결정: A — Base64 인코딩. 커서 자체에 type 필드를 포함해 정렬 불일치를 서버에서 검증. 내부 구조 노출이 실제 보안 위협이 되지 않는 도메인.

Composite Cursor 설계

정렬 조건 커서 필드 Keyset WHERE
latest updatedAt, id updatedAt < c.updatedAt OR (updatedAt = c.updatedAt AND id < c.id)
likes_desc likeCount, updatedAt, id likeCount < c.likeCount OR (eq AND updatedAt < c.updatedAt) OR (eq AND eq AND id < c.id)
price_asc price, id price > c.price OR (price = c.price AND id > c.id)
  • id를 최종 tie-break으로 포함 → 결정적 정렬 보장
  • Price VO + @Convert 필드: QueryDSL Expressions.booleanOperation(Ops.GT, SimplePath<Price>, Expressions.constant(new Price(...))) 방식으로 타입 불일치 해소

hasNext 판정 전략

COUNT(*) 쿼리 없이 size + 1개를 조회한 후 실제 반환 목록에서 마지막 항목 제거:

  • COUNT(*) 제거 → 쿼리 1회로 단순화
  • race condition 없음 (동시 삽입/삭제에 무관)

🏗️ Design Overview

변경 범위

  • 영향 받는 모듈/도메인: apps/commerce-api — product, like 도메인
  • 신규 추가:
    • scripts/seed-products.sql — 성능 테스트용 시드 데이터 (10만건)
    • scripts/create-product-indexes.sql — 복합 인덱스 DDL
    • infrastructure/product/ProductSortCondition.java — 정렬 전략 enum
    • infrastructure/cache/CacheConfig.java, PageImplMixin.java, PageRequestMixin.java — Redis 캐시 설정
    • application/product/ProductCacheStore.java — 수동 Cache-Aside 구현체
    • docs/performance/index-analysis.md, sort-strategy-analysis.md, cache-strategy-analysis.md — 분석 문서
    • IndexStrategyComparisonTest.java, CacheStampedeHotKeyTest.java, SortStrategyComparisonTest.java, CacheStrategyComparisonTest.java — 전략 비교 테스트
  • 커서 기반 페이징 신규 추가:
    • domain/common/cursor/CursorPageResult.java — 커서 페이징 공통 응답 VO (items, nextCursor, hasNext, size)
    • infrastructure/common/cursor/CursorEncoder.java — Base64 URL-safe 인코딩/디코딩 + 잘못된 커서 시 BAD_REQUEST
    • infrastructure/product/ProductCursor.java — 정렬 기준별 커서 VO (type, updatedAt, likeCount, price, id)
    • infrastructure/product/ProductCursorCondition.java — enum Strategy: 정렬 조건별 toCursorPredicate() + toOrderSpecifiers()
  • 제거/대체:
    • ProductJpaRepository native query 3종 → ProductRepositoryImpl QueryDSL 동적 쿼리
    • ConcurrentMapCacheManagerRedisCacheManager (TTL 제어)
    • updateProduct @CacheEvict(product)productCacheStore.put() (Write-Through)

주요 컴포넌트 책임

  • ProductSortCondition: QueryDSL OrderSpecifier 생성 책임 캡슐화 (enum Strategy 패턴)
  • ProductCacheStore: Redis 장애 시 fallback 포함한 Cache-Aside 패턴. put()/get()/evict() 단순 인터페이스
  • CacheConfig: RedisCacheManager + TTL(product: 300s, products: 30s) + PageImplMixin Jackson 설정
  • LikeApp: 좋아요 추가/취소 시 @CacheEvict(product) 무효화로 동기화 보장

🔁 Flow Diagram

상품 상세 조회 (Cache-Aside)

sequenceDiagram
  autonumber
  participant Client
  participant ProductApp
  participant ProductCacheStore
  participant Redis
  participant DB

  Client->>ProductApp: getProduct(productId)
  ProductApp->>ProductCacheStore: get(productId)
  ProductCacheStore->>Redis: GET product::{productId}

  alt 캐시 HIT
    Redis-->>ProductCacheStore: ProductInfo
    ProductCacheStore-->>ProductApp: Optional.of(info)
    ProductApp-->>Client: ProductInfo (DB 접근 없음)
  else 캐시 MISS
    Redis-->>ProductCacheStore: null
    ProductCacheStore-->>ProductApp: Optional.empty()
    ProductApp->>DB: findByProductId
    DB-->>ProductApp: ProductModel
    ProductApp->>ProductCacheStore: put(productId, info)
    ProductCacheStore->>Redis: SET product::{productId} TTL 300s
    ProductApp-->>Client: ProductInfo
  else Redis 장애
    Redis-->>ProductCacheStore: Exception
    ProductCacheStore-->>ProductApp: Optional.empty() (fallback)
    ProductApp->>DB: findByProductId (정상 서비스 유지)
    DB-->>ProductApp: ProductModel
    ProductApp-->>Client: ProductInfo
  end
Loading

상품 수정 (Write-Through)

sequenceDiagram
  autonumber
  participant Client
  participant ProductApp
  participant ProductService
  participant ProductCacheStore
  participant Redis
  participant DB

  Client->>ProductApp: updateProduct(productId, ...)
  ProductApp->>ProductService: updateProduct
  ProductService->>DB: UPDATE products SET ...
  DB-->>ProductService: ProductModel (updated)
  ProductService-->>ProductApp: ProductModel
  ProductApp->>ProductCacheStore: put(productId, info)
  ProductCacheStore->>Redis: SET product::{productId} TTL 300s (Overwrite)
  ProductApp-->>Client: ProductInfo
  Note over Redis: 수정 직후 캐시 HIT 보장 → 스탬피드 없음
Loading

✅ Checklist

🔖 Index

  • 상품 목록 API에서 brandId 기반 검색, 좋아요 순 정렬 등을 처리했다
    • QueryDSL 동적 쿼리 + ProductSortCondition enum Strategy 도입
    • 정렬 조건별 복합 인덱스 3개 (brand+like, brand+latest, brand+price)
  • 조회 필터, 정렬 조건별 유즈케이스를 분석하여 인덱스를 적용하고 전 후 성능비교를 진행했다
    • AS-IS: type=ALL, rows=99,510 / TO-BE: type=ref, rows=18,962 (-81%)
    • IndexStrategyComparisonTest — 3분리 인덱스 vs 복합 인덱스 EXPLAIN 비교
    • SortStrategyComparisonTest — 비정규화 vs MV 동시성 측정 (50 스레드, 정확도 비교)

❤️ Structure

  • 상품 목록/상세 조회 시 좋아요 수를 조회 및 좋아요 순 정렬이 가능하도록 구조 개선을 진행했다
    • products.like_count 비정규화 컬럼 유지 (MV 대비 단순성·즉시 정합성 우위)
    • QueryDSL LIKES_DESC 정렬 조건 → ProductInfo.likeCount 노출
  • 좋아요 적용/해제 진행 시 상품 좋아요 수 또한 정상적으로 동기화되도록 진행하였다
    • 좋아요 이벤트 → LikeApp @CacheEvict(product) → 다음 조회 시 DB 최신값 반영
    • LikeCountSyncIntegrationTest — 동시 좋아요 30건 정확도 검증

⚡ Cache

  • Redis 캐시를 적용하고 TTL 또는 무효화 전략을 적용했다
    • 상품 상세: Cache-Aside (ProductCacheStore, TTL 300s), Write-Through on update
    • 상품 목록: Read-Through (@Cacheable, TTL 30s), 수정/삭제 시 allEntries=true evict
    • 단계적 도입: in-memory(ConcurrentMapCacheManager) → Redis(RedisCacheManager) 전환
  • 캐시 미스 상황에서도 서비스가 정상 동작하도록 처리했다
    • ProductCacheStore.get(): Redis 예외 시 Optional.empty() 반환 → DB fallback 자동 실행
    • RedisCacheIntegrationTest — 캐시 키 적재/TTL/fallback/예외 시나리오 검증
    • CacheStampedeHotKeyTest — 스탬피드 발생·완화(Mutex Lock), 핫키 집중도, Write-Through/Write-Around 비즈니스 전략 비교 (24개 테스트)

변경 목적

상품 목록 조회 성능 개선 및 Redis 캐싱을 통한 읽기 트래픽 흡수. OFFSET 기반 페이지네이션의 선형 비용과 일관성 문제를 커서 기반 페이지네이션으로 대체.

핵심 변경점

인덱싱: ProductModel에 3개 복합 인덱스 추가 (brand+like_count, brand+updated_at, brand+price DESC)로 쿼리 실행 계획 ALL → ref로 개선. QueryDSL 마이그레이션: 기존 3개 JPA 네이티브 쿼리 메서드를 ProductRepositoryImpl의 QueryDSL 구현으로 완전 대체. 캐싱 전략: Detail은 Cache-Aside via ProductCacheStore (TTL 60s), List는 @Cacheable Read-Through (TTL 30s)로 분리. Write-Through: updateProduct에서 DB 업데이트 후 productCacheStore.put()으로 캐시 동시 갱신. 커서 페이지네이션: /api/v1/products/cursor 엔드포인트 추가, Base64 인코딩 커서 및 정렬별 합성 커서 필드 지원.

주의사항 및 확인 필요사항

캐시 일관성 위험: (1) updateProduct에서 put() 후 예외 발생 시 DB와 캐시 불일치 가능 - 예외 처리 전략 확인 필요. (2) like 변경 시 allEntries=true로 "products" 캐시 전체 무효화하여 캐시 히트율 저하 - fine-grained eviction 검토 필요. (3) Detail 조회는 Cache-Aside, List 조회는 Read-Through로 전략이 혼용되는데, 운영 중 일관성 유지 방안 확인 필요. 커서 정렬 순서 일관성: ProductSortCondition과 ProductCursorCondition의 LIKES_DESC 정렬이 모두 (likeCount DESC, updatedAt DESC)로 일치함을 확인. 네이티브 쿼리 완전 제거: ProductJpaRepository에서 기존 쿼리 메서드 완전 제거되었으므로 마이그레이션 완료 상태.

테스트 및 검증

930줄의 4개 테스트 클래스(CacheStrategyComparisonTest, ProductCacheIntegrationTest, RedisCacheIntegrationTest, LikeCountSyncIntegrationTest)로 캐시 전략, 스탬피드 저항성, like_count 동기화, 커서 페이지네이션을 검증. 추가로 ProductServiceIntegrationTest와 ProductV1ControllerE2ETest에서 커서 기반 조회의 중복 방지, 가격 정렬 일관성, 페이지 경계 검증 포함.

Copilot AI review requested due to automatic review settings March 12, 2026 12:24
@coderabbitai
Copy link

coderabbitai bot commented Mar 12, 2026

📝 Walkthrough

Walkthrough

Redis 기반 캐싱 시스템과 커서 기반 페이지네이션을 도입하며, Like 트랜잭션 처리를 강화하고, Product 조회를 위한 복합 인덱스 및 QueryDSL 기반 정렬을 구현했다.

Changes

Cohort / File(s) Summary
Redis 캐싱 인프라
infrastructure/cache/CacheConfig, infrastructure/cache/PageImplMixin, infrastructure/cache/PageRequestMixin
Spring Redis Cache 설정과 Jackson 믹스인을 추가하여 PageImpl과 PageRequest 직렬화 지원, TTL 관리 (product: 60s, products: 30s)
캐시 저장소 및 스토어
application/product/ProductCacheStore, domain/product/ProductModel
Redis 백업 ProductInfo 캐싱 컴포넌트(TTL 60s)와 3개 복합 인덱스(ref_brand_id 기반 likes_desc, latest, price_asc) 추가
커서 기반 페이지네이션
domain/common/cursor/CursorPageResult, infrastructure/common/cursor/CursorEncoder, infrastructure/product/ProductCursor, infrastructure/product/ProductCursorCondition, infrastructure/product/ProductSortCondition
Base64 인코딩된 커서, 3가지 정렬 전략(LATEST, LIKES_DESC, PRICE_ASC)의 QueryDSL 술어 및 정렬 지정자 구현
Product 저장소 최적화
domain/product/ProductRepository, domain/product/ProductService, infrastructure/product/ProductRepositoryImpl, infrastructure/product/ProductJpaRepository
QueryDSL 기반 동적 쿼리로 pagination 병행, 커서 페이지네이션 메서드 추가, 기존 offset 기반 쿼리 제거, BrandId 해석 로직 분리
Like 기능 강화
domain/like/LikeService, application/like/LikeApp
@transactional@Caching(evict) 추가로 addLike/removeLike 시 "product"/"products" 캐시 무효화
API 계층 확장
interfaces/api/product/ProductV1ApiSpec, interfaces/api/product/ProductV1Controller, interfaces/api/product/ProductV1Dto, application/product/ProductFacade
커서 기반 조회 엔드포인트(/api/v1/products/cursor) 및 CursorListResponse DTO 추가
테스트 및 검증
test/.../CacheStrategyComparisonTest, test/.../ProductCacheIntegrationTest, test/.../RedisCacheIntegrationTest, test/.../LikeCountSyncIntegrationTest, test/.../SortStrategyComparisonTest, test/.../ProductServiceIntegrationTest, test/.../ProductV1ControllerE2ETest, test/.../LayeredArchitectureTest
캐싱 전략(Cache-Aside, Read-Through, Write-Through 등), 커서 페이지네이션 경계값, Like 카운트 동기화, 정렬 전략 비교, 아키텍처 검증
데이터베이스 및 유틸
modules/jpa/src/testFixtures/.../DatabaseCleanUp, modules/redis/src/testFixtures/.../RedisTestContainersConfig, scripts/create-product-indexes.sql, scripts/seed-products.sql
캐시 매니저 통합, Redis 테스트 컨테이너 설정, 복합 인덱스 생성 스크립트, 1270만 건 규모의 성능 테스트 데이터 생성

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant Controller as ProductV1Controller
    participant Facade as ProductFacade
    participant App as ProductApp
    participant CacheStore as ProductCacheStore
    participant Cache as Redis
    participant DB as ProductRepository

    Client->>Controller: GET /api/v1/products/{productId}
    activate Controller
    Controller->>Facade: getProduct(productId)
    activate Facade
    Facade->>App: getProduct(productId)
    activate App
    App->>CacheStore: get(productId)
    activate CacheStore
    CacheStore->>Cache: 조회
    activate Cache
    Cache-->>CacheStore: null (Cache MISS)
    deactivate Cache
    CacheStore-->>App: Optional.empty()
    deactivate CacheStore
    App->>DB: findById(productId)
    activate DB
    DB-->>App: ProductModel
    deactivate DB
    App->>CacheStore: put(productId, ProductInfo)
    activate CacheStore
    CacheStore->>Cache: SET with TTL(60s)
    activate Cache
    Cache-->>CacheStore: OK
    deactivate Cache
    deactivate CacheStore
    App-->>Facade: ProductInfo
    deactivate App
    Facade-->>Controller: ProductResponse
    deactivate Facade
    Controller-->>Client: 200 OK (ProductResponse)
    deactivate Controller
Loading
sequenceDiagram
    participant Client
    participant Controller as ProductV1Controller
    participant Facade as ProductFacade
    participant App as ProductApp
    participant Encoder as CursorEncoder
    participant QueryFactory as JPAQueryFactory
    participant DB as ProductRepositoryImpl
    participant Cache as Redis

    Client->>Controller: GET /api/v1/products/cursor?brandId=nike&sort=latest&cursor=null&size=10
    activate Controller
    Controller->>Facade: getProductsByCursor(brandId, sort, cursor, size)
    activate Facade
    Facade->>App: getProductsByCursor(brandId, sort, cursor, size)
    activate App
    App->>DB: findProductsByCursor(refBrandId, sort, cursor, size)
    activate DB
    alt cursor is null
        Note over DB: 첫 페이지 (커서 없음)
    else cursor is present
        DB->>Encoder: decode(cursor, ProductCursor.class)
        activate Encoder
        Encoder-->>DB: ProductCursor
        deactivate Encoder
        Note over DB: 커서 타입 검증
    end
    DB->>QueryFactory: QueryDSL 쿼리 구성 (sortBy 조건 + 커서 술어)
    activate QueryFactory
    QueryFactory->>DB: 결과 조회 (size + 1)
    deactivate QueryFactory
    DB->>DB: hasNext & nextCursor 계산
    Note over DB: hasNext = 조회 건수 > size
    alt hasNext is true
        DB->>Encoder: encode(ProductCursor.from(last, sortBy))
        activate Encoder
        Encoder-->>DB: Base64 encoded cursor
        deactivate Encoder
    end
    DB-->>App: CursorPageResult<ProductModel>
    deactivate DB
    App-->>Facade: CursorPageResult<ProductInfo>
    deactivate App
    Facade->>Cache: 선택적 캐싱 (products 캐시)
    Facade-->>Controller: CursorPageResult<ProductResponse>
    deactivate Facade
    Controller-->>Client: 200 OK (CursorListResponse)
    deactivate Controller
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75분

변경사항의 복잡도는 커서 기반 페이지네이션의 QueryDSL 술어 로직, 3가지 정렬 전략 구현, Redis 캐싱 일관성 보장(Like eviction), 600+ 라인의 테스트 코드 검증에서 비롯된다. 다양한 도메인(캐싱, 페이지네이션, 쿼리 최적화)에 걸친 heterogeneous 변경사항이며, 각 파일의 논리적 정확성과 인덱스 설계의 쿼리 성능 영향을 개별적으로 검토해야 한다.

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 11.11% 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 설명이 요구되는 모든 섹션(Summary, 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

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.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

상품 목록 조회의 Full Table Scan + filesort 문제를 줄이고, Redis 캐시 및 커서 기반(Keyset) 페이징을 도입해 읽기 성능/DB 부하를 개선하려는 PR입니다. 또한 QueryDSL 기반 정렬 전략(enum) 및 커서 인코딩/디코딩을 추가하여 목록 조회 확장성을 높입니다.

Changes:

  • 상품 목록 조회를 QueryDSL 기반으로 전환하고, 정렬 조건별 인덱스/정렬 전략(enum) 적용
  • RedisCacheManager(+ Page 직렬화 MixIn) 및 상품 상세 Cache-Aside용 RedisTemplate 스토어 도입
  • 커서 기반 상품 목록 API(/api/v1/products/cursor) 및 커서 인코더/정렬별 Keyset 조건 추가 + 관련 통합/E2E 테스트 및 분석 문서 추가

Reviewed changes

Copilot reviewed 37 out of 37 changed files in this pull request and generated 15 comments.

Show a summary per file
File Description
scripts/seed-products.sql 성능 테스트용 대량 시드 데이터 생성 스크립트 추가
scripts/create-product-indexes.sql 상품 목록 정렬 패턴 대응 복합 인덱스 생성 스크립트 추가
modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java Redis Testcontainers 설정(시스템 프로퍼티 세팅) 조정
modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java 테스트 DB truncate 후 캐시 클리어 로직 추가
docs/performance/sort-strategy-analysis.md 좋아요 정렬 전략 비교 분석 문서 추가
docs/performance/index-analysis.md 인덱스 적용 전/후 EXPLAIN 분석 문서 추가
docs/performance/cache-strategy-analysis.md 캐시 전략 비교 분석 문서 추가
apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java 커서 기반 목록 API E2E 테스트 추가
apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java 커서 기반 Repository 조회 통합 테스트 추가
apps/commerce-api/src/test/java/com/loopers/domain/like/SortStrategyComparisonTest.java 비정규화 vs MV 실험/비교 통합 테스트 추가
apps/commerce-api/src/test/java/com/loopers/domain/like/LikeCountSyncIntegrationTest.java like_count 동기화 정합성 통합 테스트 추가
apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java QueryDSL Q-class 의존성 예외 규칙 추가
apps/commerce-api/src/test/java/com/loopers/application/product/RedisCacheIntegrationTest.java Redis 캐시 적재/TTL/fallback 시나리오 통합 테스트 추가
apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheIntegrationTest.java 상품 캐시 히트/미스/무효화 통합 테스트 추가
apps/commerce-api/src/test/java/com/loopers/application/product/CacheStrategyComparisonTest.java 캐시 전략 비교 실험 테스트 추가
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java 커서 기반 목록 응답 DTO 추가
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java /cursor 엔드포인트 추가
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java 커서 기반 목록 API 스펙/문서 추가
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductSortCondition.java QueryDSL 정렬 전략 enum 추가
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java 목록 QueryDSL 전환 + 커서 페이징 조회 추가
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java 기존 native 정렬 쿼리 제거
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCursorCondition.java 정렬별 Keyset 조건/OrderSpecifier 전략 enum 추가
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCursor.java 커서 payload VO 추가
apps/commerce-api/src/main/java/com/loopers/infrastructure/common/cursor/CursorEncoder.java Base64 URL-safe 커서 인코더/디코더 추가
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/PageRequestMixin.java PageRequest 역직렬화 MixIn 추가
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/PageImplMixin.java PageImpl 역직렬화 MixIn 추가
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java RedisCacheManager/TTL/직렬화 설정 추가
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java brandId → refBrandId 해석 메서드 분리
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java 커서 기반 목록 조회 API 추가
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java 정렬 조건별 복합 인덱스 어노테이션 추가
apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java 좋아요 추가/삭제에 @Transactional 적용
apps/commerce-api/src/main/java/com/loopers/domain/common/cursor/CursorPageResult.java 커서 페이징 공통 결과 VO 추가
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java 커서 목록 facade 메서드 추가(브랜드 enrich 포함)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheStore.java 상품 상세 Cache-Aside RedisTemplate 스토어 추가
apps/commerce-api/src/main/java/com/loopers/application/product/ProductApp.java 상품 상세 Cache-Aside + 목록 @Cacheable + 커서 목록 로직 추가
apps/commerce-api/src/main/java/com/loopers/application/like/LikeApp.java 좋아요 변경 시 product/products 캐시 Evict 추가
CLAUDE.md 개발 프로세스/의사결정 체크포인트 문서 보강

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +80 to +85
public ResponseEntity<ApiResponse<ProductV1Dto.CursorListResponse>> getProductsByCursor(
@RequestParam(required = false) String brandId,
@RequestParam(required = false, defaultValue = "latest") String sort,
@RequestParam(required = false) String cursor,
@RequestParam(defaultValue = "10") int size
) {
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

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

커서 API의 size 파라미터는 0/음수 입력을 막지 않아 Repository에서 런타임 예외로 이어질 수 있습니다. @min(1) 등 검증을 추가하고(필요 시 @max도), 검증 실패 시 400을 반환하도록 해주세요.

Copilot uses AI. Check for mistakes.
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: 20

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

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

⚠️ Outside diff range comments (1)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductApp.java (1)

29-33: ⚠️ Potential issue | 🟠 Major

생성 후 목록 캐시가 비워지지 않아 신규 상품이 TTL 동안 누락된다

getProducts()를 캐시했는데 createProduct()products 캐시를 무효화하지 않아서, 이미 워밍된 목록이 최대 TTL 동안 새 상품을 누락한다. 운영 관점에서는 생성 직후 목록 정합성이 깨지므로, 생성도 수정/삭제와 동일하게 products 전체 invalidation에 포함해야 한다. 전체 목록과 브랜드 목록 캐시를 먼저 채운 뒤 상품 생성 직후 새 상품이 둘 다 보이는지 회귀 테스트를 추가해 달라.

변경 예시
-    `@Transactional`
+    `@CacheEvict`(value = "products", allEntries = true)
+    `@Transactional`
     public ProductInfo createProduct(String productId, String brandId, String productName, BigDecimal price, int stockQuantity) {
         ProductModel product = productService.createProduct(productId, brandId, productName, price, stockQuantity);
         return ProductInfo.from(product);
     }

Also applies to: 64-68

🤖 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/ProductApp.java`
around lines 29 - 33, The createProduct path in ProductApp (method
createProduct) does not evict the cached product lists so a newly created
product can be missing until TTL expiry; update createProduct to invalidate the
same caches used by getProducts (e.g., the global "products" cache and the
brand-specific cache such as "productsByBrand") immediately after successful
creation (use the same cache eviction mechanism you use for
update/delete—CacheEvict annotations or CacheManager/Cache#evict calls inside
ProductApp.createProduct while preserving `@Transactional` semantics). Also add a
regression test that pre-warms the global and brand caches (call getProducts and
the brand-list getter), then calls createProduct and asserts the new product
appears in both cached endpoints.
🟡 Minor comments (8)
scripts/seed-products.sql-130-156 (1)

130-156: ⚠️ Potential issue | 🟡 Minor

배가(doubling) 단계에서 트랜잭션 미사용으로 복구가 어렵다

7회 배가 과정 중 어느 한 단계에서 실패하면 부분적으로 데이터가 삽입된 상태로 남는다. 이 경우:

  1. 어느 라운드까지 완료되었는지 파악하기 어렵다.
  2. 재실행 시 product_id 중복으로 전체 실패한다.
  3. TRUNCATE 후 처음부터 재실행해야 하므로 시간이 낭비된다.

운영/테스트 환경에서 스크립트 실행 실패 시 복구 전략을 명확히 해야 한다.

개선 방안
  1. 각 라운드 완료 후 진행 상황을 로그로 출력:
-- Round A 완료 후
SELECT CONCAT('Round A 완료: ', COUNT(*), '') AS progress FROM products;
  1. 또는 라운드별 접두사 존재 여부 확인 후 스킵하는 멱등성 로직 추가:
-- Round A가 이미 존재하면 스킵
INSERT INTO products (...)
SELECT CONCAT('A', product_id), ...
FROM products
WHERE product_id NOT LIKE 'A%'
  AND NOT EXISTS (SELECT 1 FROM products p2 WHERE p2.product_id = CONCAT('A', products.product_id));

단, 위 방식은 성능이 저하되므로 진행 로그 출력 방식을 권장한다.

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

In `@scripts/seed-products.sql` around lines 130 - 156, The current repeated
INSERT INTO products ... SELECT CONCAT('X', product_id) FROM products sequence
can leave the table partially duplicated on failure; fix by making each
duplication round atomic and idempotent: wrap each INSERT INTO products ...
SELECT CONCAT('A'|...,' product_id) ... operation in a transaction
(BEGIN/COMMIT/ROLLBACK) and add an idempotency filter referencing product_id
(e.g., WHERE product_id NOT LIKE 'A%' AND NOT EXISTS(SELECT 1 FROM products p2
WHERE p2.product_id = CONCAT('A', products.product_id))) to skip
already-duplicated rows, and after each successful COMMIT emit a progress check
(SELECT COUNT(*) ... AS progress) so you can detect completed rounds and safely
resume.
scripts/seed-products.sql-111-112 (1)

111-112: ⚠️ Potential issue | 🟡 Minor

created_atupdated_at 관계가 주석 설명과 다를 수 있다

주석(Line 88)에서 "created_atupdated_at보다 항상 이전"이라고 명시했으나, 실제 계산 로직에서는 두 값이 동일한 경우가 발생한다.

  • n < 365인 모든 행: n % 730 = n, n % 365 = n → 동일한 값
  • 예: n = 100일 때 created_at = updated_at = NOW() - 100 DAY

테스트 데이터의 정합성 검증 시 혼란을 줄 수 있으므로 주석을 수정하거나, created_at이 항상 updated_at보다 이전이 되도록 로직을 조정해야 한다.

수정안 (created_at이 항상 이전이 되도록)
-    NOW() - INTERVAL (t.n % 730) DAY                               AS created_at,
-    NOW() - INTERVAL (t.n % 365) DAY                               AS updated_at,
+    NOW() - INTERVAL ((t.n % 365) + 30) DAY                        AS created_at,
+    NOW() - INTERVAL (t.n % 365) DAY                               AS updated_at,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/seed-products.sql` around lines 111 - 112, The generated created_at
can equal updated_at because both use t.n % 730 and t.n % 365 for many rows;
modify the created_at expression so it is always strictly older than updated_at
(for example, wrap the created_at calculation in a CASE that adds 1 day when
(t.n % 730) = (t.n % 365)), updating the expression that computes created_at
(referencing created_at and t.n in the seed-products.sql diff) so created_at <
updated_at for every row.
apps/commerce-api/src/test/java/com/loopers/domain/like/SortStrategyComparisonTest.java-267-275 (1)

267-275: ⚠️ Potential issue | 🟡 Minor

동시성 테스트에서 worker 예외를 삼키면 원인 분석이 막힌다

현재는 deadlock, timeout, unique constraint 예외가 나도 최종 count mismatch만 남고 stack trace가 사라진다. 운영 관점에서는 락 회귀가 생겼을 때 CI 실패 원인을 바로 특정하지 못해 대응 시간이 길어진다. Future<?>를 모아 get()으로 다시 던지거나 ConcurrentLinkedQueue<Throwable>에 저장한 뒤 테스트 끝에서 비어 있음을 assert해야 한다. 추가 테스트로는 지금 시나리오 그대로 예외 컬렉션이 비어 있는지 검증해서 간헐 실패 시 cause가 바로 드러나도록 만드는 편이 좋다.

🔧 제안 수정
+        Queue<Throwable> errors = new ConcurrentLinkedQueue<>();
         for (int i = 0; i < threadCount; i++) {
             long memberId = i + 1L;
             executor.submit(() -> {
                 ready.countDown();
                 try {
                     start.await();
                     insertLikeSync(memberId, syncDbId, nikeDbId);
                     insertLikeOnly(memberId + 100L, batchDbId);
-                } catch (Exception ignored) {
+                } catch (Exception e) {
+                    errors.add(e);
                 } finally {
                     done.countDown();
                 }
             });
         }
         ready.await();
         start.countDown();
         done.await();
         executor.shutdown();
+        assertThat(errors).isEmpty();

As per coding guidelines, "예외 처리 시 cause를 보존"하고 "통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다."

Also applies to: 545-553, 588-595

🤖 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/domain/like/SortStrategyComparisonTest.java`
around lines 267 - 275, The worker threads currently swallow exceptions inside
the executor.submit block, hiding causes; collect and rethrow them at test end
by submitting Runnables that capture thrown Throwables into a
ConcurrentLinkedQueue<Throwable> (or collect Futures and call get()) instead of
ignoring exceptions, then after done.await() assert the queue is empty (or
rethrow any collected Throwables) so failures from insertLikeSync,
insertLikeOnly, or other operations surface; update the test code that uses
executor, ready, start, done to either store Futures and call get() or push
caught Throwables into a shared ConcurrentLinkedQueue<Throwable> and
assert/throw at the end.
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java-59-71 (1)

59-71: ⚠️ Potential issue | 🟡 Minor

resolveRefBrandIdgetProducts@Transactional(readOnly = true)가 누락되었다.

두 메서드 모두 DB 조회를 수행하지만 트랜잭션 경계가 없다. 문제점:

  • 읽기 전용 트랜잭션 최적화(Replica 라우팅, Flush 생략) 미적용
  • 여러 조회가 동일 트랜잭션 컨텍스트 없이 실행되어 일관성 보장 불가

createProduct, updateProduct, deleteProduct에는 암묵적으로 상위 계층에서 트랜잭션이 관리될 수 있으나, 조회 메서드는 명시적 선언이 권장된다.

🔧 트랜잭션 어노테이션 추가
+    `@Transactional`(readOnly = true)
     public Long resolveRefBrandId(String brandId) {
         if (brandId == null || brandId.isBlank()) {
             return null;
         }
         BrandModel brand = brandRepository.findByBrandId(new BrandId(brandId))
                 .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다."));
         return brand.getId();
     }

+    `@Transactional`(readOnly = true)
     public Page<ProductModel> getProducts(String brandId, String sortBy, Pageable pageable) {
         Long refBrandId = resolveRefBrandId(brandId);
         return productRepository.findProducts(refBrandId, sortBy, pageable);
     }
🤖 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/ProductService.java`
around lines 59 - 71, Add read-only transaction boundaries to the ProductService
read methods by annotating resolveRefBrandId and getProducts with
`@Transactional`(readOnly = true) (or apply at the class level if you prefer to
cover all read methods); import
org.springframework.transaction.annotation.Transactional and ensure the
annotations are placed on the ProductService methods so repository calls
(brandRepository.findByBrandId and productRepository.findProducts) execute
within a read-only transaction for replica routing and consistent read
semantics.
apps/commerce-api/src/main/java/com/loopers/infrastructure/common/cursor/CursorEncoder.java-23-25 (1)

23-25: ⚠️ Potential issue | 🟡 Minor

예외 발생 시 원인(cause)이 보존되지 않는다.

현재 CoreException 클래스는 Throwable cause를 받는 생성자가 없다. 따라서 encode() 메서드(23-25줄)와 decode() 메서드(33-35줄)에서 원본 예외 e를 전달할 수 없으며, JSON 파싱 오류나 Base64 디코딩 오류 등 실제 실패 원인이 완전히 손실된다. 운영 환경에서 문제 발생 시 사용자 메시지만으로는 원인 파악이 불가능해진다.

CoreException을 다음과 같이 수정하여 cause를 지원하도록 한다:

public CoreException(ErrorType errorType, String customMessage, Throwable cause) {
    super(customMessage != null ? customMessage : errorType.getMessage(), cause);
    this.errorType = errorType;
    this.customMessage = customMessage;
}

그 후 CursorEncoder에서 다음과 같이 원본 예외를 전달한다:

catch (Exception e) {
    throw new CoreException(ErrorType.INTERNAL_ERROR, "커서 인코딩 중 오류가 발생했습니다.", e);
}
🤖 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/infrastructure/common/cursor/CursorEncoder.java`
around lines 23 - 25, CursorEncoder's encode/decode catch blocks discard the
original exception, losing cause information; add a Throwable-accepting
constructor to CoreException (e.g., CoreException(ErrorType, String, Throwable))
that calls super(message, cause) and preserves errorType/customMessage, then
update CursorEncoder.encode and CursorEncoder.decode to throw the new
CoreException passing the caught exception as the cause so JSON parse/Base64
errors are preserved for debugging.
apps/commerce-api/src/test/java/com/loopers/application/product/CacheStrategyComparisonTest.java-411-429 (1)

411-429: ⚠️ Potential issue | 🟡 Minor

crossStrategy_likeChange_evictsBothCaches 테스트가 실제 캐시 무효화를 검증하지 않음

likeApp = null로 선언 후 사용하지 않고, redisTemplate.delete()로 직접 키를 삭제한다. 이는 좋아요 변경 시 실제 @CacheEvict 동작을 검증하지 않으며, 테스트 이름과 실제 동작이 불일치한다. 실제 LikeApp을 주입하여 좋아요 등록/해제 후 캐시 무효화를 검증하거나, 테스트 이름을 수정하라.

// 현재: 수동 삭제로 실제 동작 검증 불가
redisTemplate.delete(PRODUCT_KEY);
redisTemplate.delete("products::null:latest:0:10");

// 권장: 실제 좋아요 동작 후 캐시 상태 검증
// likeApp.registerLike("prod1", userId);
// assertThat(redisTemplate.hasKey(PRODUCT_KEY)).isFalse();
🤖 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/CacheStrategyComparisonTest.java`
around lines 411 - 429, The test crossStrategy_likeChange_evictsBothCaches
currently bypasses the real cache-eviction logic by setting likeApp = null and
manually deleting keys with redisTemplate.delete; replace this with invoking the
real like service so the `@CacheEvict` annotations run: inject or use the existing
com.loopers.application.like.LikeApp instance (e.g., LikeApp likeApp) and call
the appropriate method that registers/removes a like for "prod1" (e.g.,
likeApp.registerLike or likeApp.toggleLike) after the
productApp.getProduct/productApp.getProducts calls, then assert
redisTemplate.hasKey(PRODUCT_KEY) and the products key are false; alternatively,
if invoking LikeApp is not possible, change the test name to reflect manual key
deletion instead of validating eviction.
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCursorCondition.java-18-21 (1)

18-21: ⚠️ Potential issue | 🟡 Minor

커서 필드 null 접근 시 NPE 가능성

toCursorPredicate 메서드들이 c.updatedAt(), c.likeCount(), c.price(), c.id() 등을 직접 호출한다. ProductCursor의 필드가 null일 경우 NullPointerException이 발생하며, 사용자에게 500 에러가 노출된다. 방어적 검증을 추가하거나, ProductCursor 생성 시점에 필수 필드 검증을 수행하라.

Also applies to: 31-37, 47-52

🤖 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/infrastructure/product/ProductCursorCondition.java`
around lines 18 - 21, The toCursorPredicate methods in ProductCursorCondition
call ProductCursor accessors (e.g., c.updatedAt(), c.id(), c.likeCount(),
c.price()) without null checks which can cause NPEs; add defensive validation at
the start of each toCursorPredicate (or a shared private validator) to require
non-null cursor fields (use Objects.requireNonNull or explicit checks) and throw
a clear IllegalArgumentException when a required field is missing, e.g.,
validate c.updatedAt() and c.id() before building the predicate in
toCursorPredicate(QProductModel p, ProductCursor c) and do the same for the
other overloaded toCursorPredicate variants that use likeCount/price.
apps/commerce-api/src/test/java/com/loopers/application/product/CacheStrategyComparisonTest.java-147-172 (1)

147-172: ⚠️ Potential issue | 🟡 Minor

Stampede 테스트에서 스레드 내 예외 무시로 인한 위음성(false negative) 가능성

catch (Exception ignored) 블록에서 모든 예외가 무시되므로, 스레드 내부에서 assertion 실패나 런타임 예외가 발생해도 테스트가 통과할 수 있다. 실제 stampede 상황에서 발생하는 오류를 감지하지 못한다.

제안: 예외를 수집하여 검증
+        List<Exception> errors = new CopyOnWriteArrayList<>();
         for (int i = 0; i < threadCount; i++) {
             new Thread(() -> {
                 ready.countDown();
                 try {
                     start.await();
                     results.add(productApp.getProduct("prod1"));
-                } catch (Exception ignored) {
+                } catch (Exception e) {
+                    errors.add(e);
                 } finally {
                     done.countDown();
                 }
             }).start();
         }
         
         ready.await();
         start.countDown();
         done.await(10, TimeUnit.SECONDS);
         
+        assertThat(errors).isEmpty();
         // 결과 정확성: 모든 스레드 동일 값
🤖 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/CacheStrategyComparisonTest.java`
around lines 147 - 172, In CacheStrategyComparisonTest, stop ignoring exceptions
inside the thread lambda; instead capture them into a thread-safe collection
(e.g., ConcurrentLinkedQueue<Throwable> exceptions) from the catch block used
around productApp.getProduct, and after done.await verify the collection is
empty (or rethrow the first exception) so assertion failures or runtime errors
in threads fail the test; reference the thread lambda that currently does catch
(Exception ignored), the results collection, and productApp.getProduct to locate
where to add exception collection and the post-wait check.
🧹 Nitpick comments (12)
apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java (1)

102-110: 예외 규칙 범위를 QueryDSL 저장소 구현체로 제한해야 한다.

현재 predicate는 com.loopers.infrastructure 전체와 도메인 패키지의 모든 Q* 타입을 한 번에 면제한다. 운영 관점에서는 아키텍처 회귀를 막아야 하는 테스트가 너무 넓게 통과해, QueryDSL과 무관한 인프라 컴포넌트가 도메인 쿼리 타입에 의존해도 놓치게 된다. 소스 범위를 RepositoryImpl 또는 QueryDSL 전용 패키지로 좁히고, 가능하면 타깃도 실제 생성 Q-type 범위로 한정하는 편이 안전하다. 추가로 RepositoryImpl -> Q*는 허용되고 Converter나 캐시 어댑터의 Q* 의존은 실패하는 케이스를 별도 ArchUnit 테스트로 넣어 회귀를 막아야 한다.

예외 범위를 좁히는 예시다
                 .ignoreDependency(
                         DescribedPredicate.describe(
-                                "Infrastructure classes using QueryDSL",
-                                javaClass -> javaClass.getPackageName().startsWith("com.loopers.infrastructure")
+                                "Infrastructure QueryDSL repository implementations",
+                                javaClass -> javaClass.getPackageName().startsWith("com.loopers.infrastructure")
+                                        && javaClass.getSimpleName().endsWith("RepositoryImpl")
                         ),
                         DescribedPredicate.describe(
                                 "QueryDSL Q-classes in domain package",
                                 javaClass -> javaClass.getSimpleName().startsWith("Q")
                                         && javaClass.getPackageName().startsWith("com.loopers.domain")
                         )
                 )
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/architecture/LayeredArchitectureTest.java`
around lines 102 - 110, 현재 LayeredArchitectureTest의
ignoreDependency/DescribedPredicate 조합이 com.loopers.infrastructure 전체와 도메인 패키지의
모든 Q* 타입을 면제해 범위가 너무 넓으니, 예외를 저장소 구현으로 한정하도록 수정하세요: LayeredArchitectureTest에서
ignoreDependency 호출 내 첫 번째 predicate는 인프라 전체가 아니라 저장소 구현 패키지 또는 구현 클래스 이름(예:
패키지명 com.loopers.infrastructure.repository.impl 또는 클래스명 끝이 RepositoryImpl인
클래스)으로 좁히고, 두 번째 DescribedPredicate는 도메인의 실제 생성 Q-타입 영역으로 제한(예:
com.loopers.domain.querydsl 또는 simpleName.startsWith("Q") &&
packageName().startsWith("com.loopers.domain.querydsl"))하여 RepositoryImpl -> Q*
만 허용되게 하세요; 추가로 Converter나 캐시 어댑터 같은 다른 인프라 컴포넌트가 Q*에 의존하는 경우를 방지하기 위해 별도의
ArchUnit 테스트를 추가해 Converter/*CacheAdapter -> Q* 의존은 실패하도록 검증하세요.
scripts/seed-products.sql (2)

178-182: 검증 쿼리에서 deleted_at 필터 누락

검증 쿼리(Line 163, 165-176, 178-182)에서 deleted_at IS NULL 조건 없이 전체 데이터를 조회한다. 시드 데이터에서는 deleted_at = NULL로 삽입하므로 현재는 문제가 없으나, 향후 soft delete 테스트 데이터가 추가되면 검증 결과가 왜곡될 수 있다.

수정안
-SELECT COUNT(*) AS total_products FROM products;
+SELECT COUNT(*) AS total_products FROM products WHERE deleted_at IS NULL;

 SELECT
     b.brand_name,
     COUNT(p.id)       AS product_count,
 ...
 FROM products p
 JOIN brands b ON b.id = p.ref_brand_id
+WHERE p.deleted_at IS NULL
 GROUP BY b.id, b.brand_name
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/seed-products.sql` around lines 178 - 182, The validation query that
computes like_range counts is missing a soft-delete filter; update each SELECT
(the expressions producing '0~49', '50~999', '1000~4999', '5000~9999' which
currently filter on like_count) to include "deleted_at IS NULL" in their WHERE
clauses (e.g., change WHERE like_count < 50 to WHERE deleted_at IS NULL AND
like_count < 50) and apply the same deleted_at IS NULL filter to the other
similar verification queries referenced (the blocks around the
like_range/like_count SELECTs) so soft-deleted rows are excluded.

4-7: 기존 데이터 존재 시 충돌 처리 전략 미흡

주석에서 "products 데이터가 없는 상태(truncate 이후)에서 실행할 것"이라고 명시했으나, 실제로 데이터가 있는 상태에서 실행하면 product_id 중복 에러가 발생한다. 스크립트 시작 부분에 안전장치를 추가하는 것이 운영 실수를 방지한다.

수정안
-- =============================================================================
-- 실행 전 안전 확인 (products 테이블이 비어 있는지 확인)
-- =============================================================================
SELECT IF(
    (SELECT COUNT(*) FROM products) > 0,
    (SELECT CONCAT('ERROR: products 테이블에 ', COUNT(*), '건의 데이터가 존재합니다. TRUNCATE 후 재실행하세요.') FROM products),
    'OK: products 테이블이 비어 있습니다. 시드 삽입을 시작합니다.'
) AS pre_check;

-- 아래 주석을 해제하면 기존 데이터를 강제로 삭제 (주의!)
-- TRUNCATE TABLE products;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/seed-products.sql` around lines 4 - 7, Add a pre-run safety check at
the top of scripts/seed-products.sql that verifies products is empty before
inserting seeds: run a SELECT COUNT(*) FROM products and if the count > 0
return/raise an error message (and avoid executing the insert statements),
otherwise proceed and print an OK message; optionally include a commented
TRUNCATE TABLE products line for intentional forced resets. Ensure this guard is
the very first statements so product_id uniqueness conflicts are prevented when
the script (seed-products.sql) is executed.
apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java (2)

353-355: 커서를 쿼리 문자열에 직접 이어 붙이지 않는 편이 안전하다.

cursor 포맷이 Base64나 JSON처럼 예약 문자를 포함하면 +, /, =가 URL 파싱 과정에서 깨져 정상 커서도 400으로 바뀔 수 있다. 이 클래스 전반의 &cursor= 결합은 UriComponentsBuilder.queryParam(...)으로 바꿔 인코딩을 보장하라. 추가 테스트로 +, /, =가 포함된 커서에서도 다음 페이지 조회가 통과하는지 검증하라.

변경 예시
+import org.springframework.web.util.UriComponentsBuilder;
+
-ResponseEntity<ApiResponse<ProductV1Dto.CursorListResponse>> secondResponse = testRestTemplate.exchange(
-        ENDPOINT_PRODUCTS_CURSOR + "?sort=latest&size=10&cursor=" + nextCursor,
+String url = UriComponentsBuilder.fromPath(ENDPOINT_PRODUCTS_CURSOR)
+        .queryParam("sort", "latest")
+        .queryParam("size", 10)
+        .queryParam("cursor", nextCursor)
+        .toUriString();
+ResponseEntity<ApiResponse<ProductV1Dto.CursorListResponse>> secondResponse = testRestTemplate.exchange(
+        url,
         HttpMethod.GET, null, cursorResponseType
 );

Also applies to: 384-385, 418-419, 448-449, 498-499

🤖 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/product/ProductV1ControllerE2ETest.java`
around lines 353 - 355, In ProductV1ControllerE2ETest replace
string-concatenated query URLs passed to testRestTemplate.exchange (using
ENDPOINT_PRODUCTS_CURSOR + "?sort=...&cursor=" + nextCursor) with a properly
encoded URI built via UriComponentsBuilder.queryParam(...) (e.g., set path to
ENDPOINT_PRODUCTS_CURSOR and add sort, size, cursor via queryParam) so the
cursor is URL-encoded; update all occurrences (around the exchanges at the
referenced blocks) and add an extra E2E test case that uses a cursor containing
reserved characters like "+", "/", "=" to assert the subsequent page request
succeeds.

463-477: 에러 E2E는 상태 코드만이 아니라 표준 응답 바디까지 고정해야 한다.

지금은 400만 맞으면 ApiControllerAdvice를 우회하거나 에러 포맷이 깨져도 테스트가 통과한다. 운영에서는 클라이언트가 ApiResponse의 실패 메타데이터와 에러 구조에 의존하므로, 두 케이스 모두 meta.result와 에러 코드/메시지까지 assert하는 편이 안전하다. 추가 테스트로 잘못된 커서와 정렬 불일치가 동일한 응답 스키마를 유지하는지 검증하라.

Based on learnings, commerce-api에서는 CoreExceptionApiControllerAdvice로 라우팅해 일관된 응답 포맷을 유지해야 한다.

Also applies to: 480-503

🤖 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/product/ProductV1ControllerE2ETest.java`
around lines 463 - 477, Update the E2E tests (ProductV1ControllerE2ETest) to
assert the full standardized error response body instead of only the HTTP
status: after the testRestTemplate.exchange calls for invalid cursor and for the
sort mismatch case, deserialize the response body as
ApiResponse<ProductV1Dto.CursorListResponse> and assert that
body.getMeta().getResult() indicates failure and that body.getError() contains
the expected error code and message (use the same error constants your
application throws). Also ensure the controller path used in these tests
triggers the application to throw CoreException (so ApiControllerAdvice formats
the error) rather than bypassing it; if needed, make the controller/service
throw the same CoreException instance or map the validation failure to
CoreException so the ApiControllerAdvice formatting is exercised.
apps/commerce-api/src/main/java/com/loopers/domain/common/cursor/CursorPageResult.java (1)

6-19: 가변 리스트를 그대로 들고 있어 불변 계약이 깨진다.

items를 그대로 저장하면 호출자가 원본 리스트나 items() 반환값을 수정해 응답 내용이 뒤늦게 바뀔 수 있다. 이 객체가 캐시되거나 여러 계층을 거칠수록 이런 변이는 추적이 어려워진다. 생성자에서 List.copyOf(items)로 방어적 복사를 하고, 추가 테스트로 원본 리스트 변경과 items().add(...) 시도가 결과 객체 상태를 바꾸지 않는지 검증하라.

변경 예시
 public record CursorPageResult<T>(
         List<T> items,
         String nextCursor,
         boolean hasNext,
         int size
 ) {
+    public CursorPageResult {
+        items = List.copyOf(items);
+    }
+
     public <R> CursorPageResult<R> map(Function<T, R> mapper) {
         return new CursorPageResult<>(
                 items.stream().map(mapper).toList(),
                 nextCursor,
                 hasNext,
                 size
         );
     }
 }
As per coding guidelines, `**/domain/**/*.java`: 엔티티/값 객체/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/domain/common/cursor/CursorPageResult.java`
around lines 6 - 19, CursorPageResult currently stores the provided mutable
List<T> directly which breaks immutability; add an explicit canonical
constructor for the CursorPageResult record that defensively copies the list
with List.copyOf(items) (and validate non-null if desired) so the record field
is an immutable copy, and update the map(Function) to ensure it constructs the
new CursorPageResult with the copied list as well; then add tests that mutate
the original source list after construction and attempt items().add(...) to
assert the CursorPageResult contents do not change.
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java (1)

20-20: Repository 계약은 문자열 정렬값보다 도메인 타입이 안전하다.

여기서 String sortBy를 그대로 받으면 오타나 미지원 정렬이 런타임까지 내려가고, cursor 인코딩/디코딩도 stringly-typed로 남는다. Repository 경계는 도메인 정렬 enum/value object를 받도록 좁히고, 문자열 파싱은 controller 또는 application 계층에서 끝내는 편이 안전하다. 추가 테스트로 잘못된 sort 값이 repository 진입 전에 400으로 차단되는지 검증하라.

🤖 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`
at line 20, Change the repository boundary to accept domain-typed sorting and
cursor values: update ProductRepository.findProductsByCursor to take a specific
Sort enum/value-object (e.g., ProductSort) and a Cursor value-object instead of
raw String sortBy and String cursor, and make CursorPageResult<ProductModel>
usage remain the same; move all String parsing/validation of sort and cursor
into the controller or application service (parse request params into
ProductSort and Cursor before calling findProductsByCursor) and throw a 400 on
invalid inputs; add a unit/integration test that submits an invalid sort value
and asserts the request is rejected with 400 before any ProductRepository
interactions.
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java (1)

31-44: 기본 TTL이 설정되지 않아 미정의 캐시는 무기한 저장된다.

defaultConfig에 TTL이 설정되지 않아, "product"와 "products" 외의 캐시 이름이 사용될 경우 데이터가 만료되지 않는다. 개발 중 실수로 새 캐시명을 사용하면 Redis 메모리가 지속적으로 증가할 수 있다.

또한, 트랜잭션 커밋 후 캐시 갱신을 보장하려면 transactionAware()를 고려해야 한다.

🔧 기본 TTL 및 트랜잭션 인식 설정
         RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
                 .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
                 .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(serializer))
-                .disableCachingNullValues();
+                .disableCachingNullValues()
+                .entryTtl(Duration.ofMinutes(5)); // 기본 TTL 설정

         Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
                 "product", defaultConfig.entryTtl(Duration.ofSeconds(60)),
                 "products", defaultConfig.entryTtl(Duration.ofSeconds(30))
         );

         return RedisCacheManager.builder(lettuceConnectionFactory)
                 .cacheDefaults(defaultConfig)
                 .withInitialCacheConfigurations(cacheConfigs)
+                .transactionAware()
                 .build();
🤖 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/infrastructure/cache/CacheConfig.java`
around lines 31 - 44, defaultConfig currently has no TTL so caches other than
"product"/"products" never expire; update the RedisCacheConfiguration named
defaultConfig to set a sensible global entryTtl (e.g., Duration.ofMinutes(...))
and enable transaction awareness on the manager by calling transactionAware() on
RedisCacheManager.builder(lettuceConnectionFactory) before build(); keep the
per-cache overrides for "product" and "products" but ensure they derive from the
new defaultConfig with entryTtl applied.
apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java (1)

306-328: 커서 페이지네이션 테스트 구조는 적절하다.

첫 페이지, 다음 페이지 연속성, 전체 순회, 중간 삽입 시 중복 방지 등 핵심 시나리오가 잘 커버되어 있다.

다만, 다음 경계 케이스 테스트 추가를 권장한다:

  • size보다 데이터가 적을 때 (hasNext=false, nextCursor=null)
  • 데이터가 0건일 때 빈 결과 반환
  • size=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/domain/product/ProductServiceIntegrationTest.java`
around lines 306 - 328, Add three additional test cases inside the
GetProductsByCursor nested class to cover edge boundaries: (1) when total items
< page size — create a brand and fewer than 10 products, call
productRepository.findProductsByCursor(null, "latest", null, 10) and assert
result.items().size() equals count, result.hasNext() is false, and
result.nextCursor() is null; (2) when there are zero products — create a brand
but no products, call findProductsByCursor(...) and assert an empty items list,
hasNext() false, and nextCursor() null; and (3) when page size = 1 — create
multiple products, call findProductsByCursor(null, "latest", null, 1) and assert
that items size is 1, hasNext() reflects remaining data, and nextCursor() is
present for the first page; use existing helpers brandService.createBrand,
productService.createProduct and assert using CursorPageResult<ProductModel>.
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductSortCondition.java (1)

36-42: 잘못된 sortBy 입력 시 silent default로 디버깅 어려움

from(String sortBy) 메서드가 알 수 없는 값에 대해 LATEST를 반환한다. 운영 환경에서 오타나 잘못된 파라미터가 전달될 경우 오류 없이 기본값이 적용되어 문제 파악이 지연될 수 있다. 최소한 로깅을 추가하거나, 허용된 값 목록을 명시적으로 검증하는 방안을 고려하라.

🤖 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/infrastructure/product/ProductSortCondition.java`
around lines 36 - 42, ProductSortCondition.from currently silently returns
LATEST for unknown inputs; change it to validate sortBy and fail fast: in
ProductSortCondition.from(String sortBy) check for null/empty, compare against
the known constants ("likes_desc", "price_asc", "latest" if applicable) and if
the value is not recognized either (a) throw an IllegalArgumentException with a
clear message that includes the received value and the allowed values
(referencing LIKES_DESC, PRICE_ASC, LATEST) or (b) log a warning using the class
logger before throwing/returning; this makes invalid parameters visible in logs
and prevents silent defaults.
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCursorCondition.java (1)

66-80: from()과 fromType()의 입력 형식 차이로 인한 혼란 가능성

from(String sortBy)는 소문자 snake_case ("likes_desc")를, fromType(String cursorType)는 대문자 enum 이름 ("LIKES_DESC")을 기대한다. 두 메서드의 역할과 호출 시점이 명확히 구분되지 않으면 잘못된 입력으로 silent default가 적용될 수 있다.

🤖 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/infrastructure/product/ProductCursorCondition.java`
around lines 66 - 80, ProductCursorCondition.from and fromType accept
differently formatted strings which can cause silent fallbacks; update them to a
single consistent parsing strategy in ProductCursorCondition: normalize the
input (e.g., sortBy -> sortBy.trim().toUpperCase(Locale.ROOT).replace('-', '_'))
and then map to the enum cases (LIKES_DESC, PRICE_ASC, LATEST) or delegate to a
single parse method; additionally handle unknown values by throwing a clear
IllegalArgumentException instead of returning LATEST silently so callers can
detect invalid inputs.
apps/commerce-api/src/test/java/com/loopers/application/product/CacheStrategyComparisonTest.java (1)

276-302: writeThrough_transactionRollback 테스트가 실제 트랜잭션 롤백을 시뮬레이션하지 않음

테스트 이름은 "트랜잭션 롤백 시 캐시-DB 불일치"를 검증한다고 하지만, 실제로는 캐시만 수동 갱신하고 DB 업데이트를 생략한다. 실제 @Transactional 롤백 시나리오(예: 예외 발생으로 인한 롤백)를 재현하지 않아, Write-Through의 실제 취약점을 증명하지 못한다. 테스트 목적을 명확히 하거나, 실제 롤백 시나리오를 구현하라.

🤖 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/CacheStrategyComparisonTest.java`
around lines 276 - 302, The test
writeThrough_transactionRollback_cacheAndDbDiverge only manually mutates
productCacheStore and never triggers a real transactional rollback; change it to
either (A) rename the test to reflect it only simulates cache mutation, or
preferably (B) reproduce a real rollback by invoking a transactional operation
that attempts the update and then throws to force rollback: add or use a method
like productService.updatePriceTransactional(productId, newPrice) or execute via
TransactionTemplate/TestTransaction inside the test so the DB update is executed
within a `@Transactional` boundary and an exception is thrown (caught by the test)
to cause rollback; after the rollback assert that
productRepository.findByProductId(...) still returns the original DB price while
productCacheStore (or productApp.getProduct(...)) shows the pre-updated cached
value, keeping references to productApp.getProduct,
productRepository.findByProductId, productCacheStore.put and the test method
writeThrough_transactionRollback_cacheAndDbDiverge.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 846e86a8-24a9-4cd9-97b1-95dee09daa2b

📥 Commits

Reviewing files that changed from the base of the PR and between 8b18150 and 4aec8cf.

⛔ Files ignored due to path filters (4)
  • CLAUDE.md is excluded by !**/*.md and included by **
  • docs/performance/cache-strategy-analysis.md is excluded by !**/*.md and included by **
  • docs/performance/index-analysis.md is excluded by !**/*.md and included by **
  • docs/performance/sort-strategy-analysis.md is excluded by !**/*.md and included by **
📒 Files selected for processing (33)
  • apps/commerce-api/src/main/java/com/loopers/application/like/LikeApp.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductApp.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheStore.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
  • apps/commerce-api/src/main/java/com/loopers/domain/common/cursor/CursorPageResult.java
  • apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/PageImplMixin.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/PageRequestMixin.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/common/cursor/CursorEncoder.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCursor.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCursorCondition.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/ProductSortCondition.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/CacheStrategyComparisonTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/RedisCacheIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/like/LikeCountSyncIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/like/SortStrategyComparisonTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java
  • modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java
  • modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java
  • scripts/create-product-indexes.sql
  • scripts/seed-products.sql
💤 Files with no reviewable changes (1)
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java

Comment on lines +14 to +17
public class ProductCacheStore {

private static final String KEY_PREFIX = "product::";
private static final long TTL_SECONDS = 60;
Copy link

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
# ProductCacheStore 사용처 확인
rg -n "ProductCacheStore" --type java -A2

# `@Cacheable`("product") 사용처 확인
rg -n '@Cacheable.*"product"' --type java

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

Length of output: 1633


🏁 Script executed:

# CacheConfig 클래스 전체 내용 확인
fd "CacheConfig.java" --type f | xargs cat -n

# ProductApp에서 productCacheStore 사용 패턴 확인
rg -n "productCacheStore\." --type java -B2 -A3

# ProductCacheStore 전체 구현 확인
cat -n apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheStore.java

# `@Cacheable` 사용처 전체 확인
rg -n "@Cacheable" --type java -B1 -A2

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

Length of output: 14400


🏁 Script executed:

# 최종 확인: CacheConfig에서 "product" 캐시가 실제로 사용되는지 확인
rg -n "\"product\"" --type java -B2 -A2 apps/commerce-api/src/

# ProductCacheStore 단독 사용 확인
rg -n "productCacheStore\.(get|put)" --type java

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

Length of output: 5006


ProductCacheStore와 CacheConfig의 "product" 캐시 무효화가 불일치한다.

ProductCacheStore는 RedisTemplate을 사용해 product::{productId} 형태로 데이터를 저장하고, CacheConfig의 "product" 캐시는 Spring CacheManager를 통해 별도 네임스페이스로 관리된다. 업데이트 시 문제가 발생한다:

  • ProductApp.updateProduct()productCacheStore.put() 실행
  • 동시에 @CacheEvict(value="product") → CacheManager 캐시만 무효화
  • ProductCacheStore의 캐시는 무효화되지 않음 → 만료까지 stale 데이터 반환

ProductCacheStore를 CacheManager로 통합하거나, @CacheEvict 시 ProductCacheStore도 명시적으로 무효화하도록 수정해야 한다. 캐시 일관성 검증 테스트를 추가하여 무효화 동작을 확인한다.

🤖 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/ProductCacheStore.java`
around lines 14 - 17, ProductCacheStore uses RedisTemplate with keys prefixed
"product::" but CacheConfig's "product" cache is managed by Spring CacheManager,
causing `@CacheEvict`(value="product") in ProductApp.updateProduct() to not clear
ProductCacheStore entries; fix by either 1) integrating ProductCacheStore into
the Spring Cache abstraction (implement Cache and register it in CacheConfig
under name "product"), or 2) keep ProductCacheStore as-is but update
ProductApp.updateProduct() (or the method annotated with `@CacheEvict`) to call
productCacheStore.evict(productId) after the `@CacheEvict` runs (or add a custom
cache eviction listener that calls ProductCacheStore.evict(key)), and add a test
that updates a product, asserts both the CacheManager "product" cache and
ProductCacheStore (methods: ProductCacheStore.put/get/evict) reflect the
eviction to prevent stale reads.

Comment on lines +27 to +38
public Optional<ProductInfo> get(String productId) {
byte[] bytes = redisTemplate.opsForValue().get(KEY_PREFIX + productId);
if (bytes == null) {
return Optional.empty();
}
return Optional.of((ProductInfo) serializer.deserialize(bytes));
}

public void put(String productId, ProductInfo productInfo) {
byte[] bytes = serializer.serialize(productInfo);
redisTemplate.opsForValue().set(KEY_PREFIX + productId, bytes, TTL_SECONDS, TimeUnit.SECONDS);
}
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

Redis 장애 시 예외 처리가 누락되었다.

PR 설명에서 "Redis failure 시 DB fallback"을 언급했으나, 이 클래스에서는 Redis 연결 실패나 타임아웃 시 예외가 그대로 전파된다. Redis 장애가 전체 상품 조회 기능 장애로 이어질 수 있다.

운영 환경에서 Redis 불안정 시 서비스 가용성을 보장하려면 다음 처리가 필요하다:

  • get(): 예외 발생 시 Optional.empty() 반환 후 로그 기록
  • put(): 예외 발생 시 무시하고 로그 기록 (캐시 갱신 실패는 치명적이지 않음)
🛡️ Redis 장애 대응 코드 제안
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 `@Component`
 public class ProductCacheStore {
+    private static final Logger log = LoggerFactory.getLogger(ProductCacheStore.class);
 
     public Optional<ProductInfo> get(String productId) {
+        try {
             byte[] bytes = redisTemplate.opsForValue().get(KEY_PREFIX + productId);
             if (bytes == null) {
                 return Optional.empty();
             }
             return Optional.of((ProductInfo) serializer.deserialize(bytes));
+        } catch (Exception e) {
+            log.warn("Redis 캐시 조회 실패, DB fallback 진행: productId={}", productId, e);
+            return Optional.empty();
+        }
     }
 
     public void put(String productId, ProductInfo productInfo) {
+        try {
             byte[] bytes = serializer.serialize(productInfo);
             redisTemplate.opsForValue().set(KEY_PREFIX + productId, bytes, TTL_SECONDS, TimeUnit.SECONDS);
+        } catch (Exception e) {
+            log.warn("Redis 캐시 저장 실패: productId={}", productId, e);
+        }
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public Optional<ProductInfo> get(String productId) {
byte[] bytes = redisTemplate.opsForValue().get(KEY_PREFIX + productId);
if (bytes == null) {
return Optional.empty();
}
return Optional.of((ProductInfo) serializer.deserialize(bytes));
}
public void put(String productId, ProductInfo productInfo) {
byte[] bytes = serializer.serialize(productInfo);
redisTemplate.opsForValue().set(KEY_PREFIX + productId, bytes, TTL_SECONDS, TimeUnit.SECONDS);
}
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
`@Component`
public class ProductCacheStore {
private static final Logger log = LoggerFactory.getLogger(ProductCacheStore.class);
public Optional<ProductInfo> get(String productId) {
try {
byte[] bytes = redisTemplate.opsForValue().get(KEY_PREFIX + productId);
if (bytes == null) {
return Optional.empty();
}
return Optional.of((ProductInfo) serializer.deserialize(bytes));
} catch (Exception e) {
log.warn("Redis 캐시 조회 실패, DB fallback 진행: productId={}", productId, e);
return Optional.empty();
}
}
public void put(String productId, ProductInfo productInfo) {
try {
byte[] bytes = serializer.serialize(productInfo);
redisTemplate.opsForValue().set(KEY_PREFIX + productId, bytes, TTL_SECONDS, TimeUnit.SECONDS);
} catch (Exception e) {
log.warn("Redis 캐시 저장 실패: productId={}", productId, e);
}
}
🤖 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/ProductCacheStore.java`
around lines 27 - 38, ProductCacheStore currently lets Redis exceptions
propagate from get(...) and put(...); wrap the redisTemplate calls inside
try-catch blocks so Redis failures don't break product read path: in get(String
productId) catch Redis/DataAccess/RuntimeException around
redisTemplate.opsForValue().get(...) and on exception log the error (include
productId and exception) and return Optional.empty(); in put(String productId,
ProductInfo productInfo) catch Redis/DataAccess/RuntimeException around
serializer.serialize(...) and redisTemplate.opsForValue().set(...) and on
exception log the failure (include productId, TTL_SECONDS, KEY_PREFIX and
exception) and swallow the exception so cache write failures are non-fatal. Use
the existing class logger (or add a private static final Logger) and preserve
serializer usage when no error occurs.

Comment on lines +44 to +47
public CursorPageResult<ProductInfo> getProductsByCursor(String brandId, String sortBy, String cursor, int size) {
CursorPageResult<ProductInfo> result = productApp.getProductsByCursor(brandId, sortBy, cursor, size);
return result.map(this::enrichProductInfo);
}
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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# BrandApp.getBrandByRefId 캐시 적용 여부 확인
rg -n "getBrandByRefId" --type java -B2 -A5

# Brand 조회에 캐시가 있는지 확인
rg -n '@Cacheable.*brand' --type java

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

Length of output: 3034


🏁 Script executed:

#!/bin/bash
# CursorPageResult.map() 구현 확인
rg -n "class CursorPageResult" --type java -A30

# getProducts 메서드 확인 (N+1 패턴 비교)
rg -n "public.*getProducts" --type java -A10 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java

# 브랜드 정보를 한번에 조회하는 메서드 확인
rg -n "getBrand.*List\|getBrands\|getBrandsByIds" --type java

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

Length of output: 893


🏁 Script executed:

#!/bin/bash
# Find CursorPageResult to understand map() behavior
fd -e java | xargs rg -l "class CursorPageResult|interface CursorPageResult" --type java

# Look for batch loading or multi-get methods for brands
rg -n "public.*BrandInfo.*List|public.*List.*Brand" --type java apps/commerce-api/src/main/java/com/loopers/application/brand/

# Check if any caching exists on brand queries
rg -n "@Cacheable|@Cache" --type java apps/commerce-api/src/main/java/com/loopers/application/brand/

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Broader search for CursorPageResult definition
rg -n "CursorPageResult" --type java -B2 -A20 | head -100

# Check all methods in BrandApp
cat apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApp.java

# Check for batch/collection methods in brand repository
rg -n "findAllById|findByIdIn" --type java apps/commerce-api/src/main/java/com/loopers/

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

Length of output: 15894


🏁 Script executed:

#!/bin/bash
# Find CursorPageResult class definition
find . -type f -name "*.java" | xargs grep -l "class CursorPageResult"

# Look at the file
fd "CursorPageResult.java" -x cat {}

# Check BrandRepository methods
rg -n "public.*Brand.*find\|public.*List.*Brand" --type java apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/

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

Length of output: 567


🏁 Script executed:

#!/bin/bash
# Check BrandRepository interface/class
find . -path "*/infrastructure/brand/*Repository*.java" -type f | xargs cat

# Search for any batch loading patterns with brands in the codebase
rg -n "findAllById|findByIdIn|IN\(" --type java apps/commerce-api/src/main/java/com/loopers/ | head -30

# Check if LikeFacade has similar issue and any solutions
cat apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java

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

Length of output: 2743


enrichProductInfo에서 브랜드 조회 시 N+1 쿼리 문제가 발생한다.

CursorPageResult.map(this::enrichProductInfo) 실행 시 각 상품마다 brandApp.getBrandByRefId() 가 호출되며, getBrandByRefId는 캐싱이 적용되지 않아 동일 브랜드의 상품이 다수 포함된 경우 중복 쿼리가 발생한다. 커서 페이지네이션에서 한 번에 조회하는 상품 수만큼 추가 데이터베이스 호출이 증가하므로 성능 저하를 초래한다.

이 문제는 getProducts() (39-42줄)와 LikeFacade.getMyLikedProducts() 에서도 동일한 패턴으로 발생한다.

개선 방안:

  • 브랜드 정보를 일괄 조회 후 메모리에서 매핑 (IN 쿼리로 한 번에 조회)
  • 상품 조회 시 브랜드 정보를 JOIN으로 함께 가져오기
  • getBrandByRefId@Cacheable 적용하여 중복 조회 방지

검증:

  • 동일 브랜드 다수 상품 조회 시 쿼리 수 측정 (현재 상품 수 + 1 → 1로 감소 확인)
  • 캐시 또는 배치 로딩 적용 후 부하 테스트 진행
🤖 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 44 - 47, enrichProductInfo causes an N+1 by calling
brandApp.getBrandByRefId per product when
CursorPageResult.map(this::enrichProductInfo) (used by getProductsByCursor,
getProducts and LikeFacade.getMyLikedProducts) runs; fix by batching brand
fetches: collect all distinct brandRefIds from the page, call brandApp to load
brands in one IN query (or add a join in product query) and build an in-memory
Map<brandRefId, Brand> to pass into enrichProductInfo (or refactor
enrichProductInfo to accept the map), alternatively annotate getBrandByRefId
with `@Cacheable` if caching is acceptable — verify by ensuring queries drop from
N+1 to 1 for the page.

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.

2 participants