Skip to content

[volume-5] 인덱스와 캐시를 사용한 성능 최적화#221

Open
shAn-kor wants to merge 45 commits intoLoopers-dev-lab:shAn-korfrom
shAn-kor:shAn-kor
Open

[volume-5] 인덱스와 캐시를 사용한 성능 최적화#221
shAn-kor wants to merge 45 commits intoLoopers-dev-lab:shAn-korfrom
shAn-kor:shAn-kor

Conversation

@shAn-kor
Copy link

@shAn-kor shAn-kor commented Mar 13, 2026

[volume-5] 상품 조회 캐시 전략 정리 및 로컬 실험 환경 보강

📌 Summary

  • 배경: 상품 조회 성능 개선 과제를 진행하면서 상품 상세 캐시, 브랜드/카테고리 캐시 전략, 로컬 실험 인프라 구성이 각각 흩어져 있어 리뷰 포인트가 분산되어 있었습니다.
  • 목표: 상품 상세 Redis 캐시를 실제 런타임 경로에 적용하고, 브랜드/카테고리 캐시는 별도 전략으로 정리하며, docker/infra-compose.yml만으로 seed 포함 실험이 가능하도록 맞추는 것이 목표였습니다.
  • 결과: 상품 상세 조회는 Redis 캐시 + TTL jitter + 개별 key 무효화 구조로 정리했고, 브랜드/카테고리 캐시는 별도 ADR로 분리했으며, 로컬 인프라는 seed 데이터(categories 5 / brands 30 / products 300000)까지 재현 가능하게 보강했습니다.

🧭 Context & Decision

문제 정의

  • 현재 동작/제약: 상품 목록 캐시 실험 이력, 상품 상세 캐시 요구사항, 브랜드/카테고리 캐시 전략, 로컬 시드 구성 방식이 한 흐름으로 정리되어 있지 않았습니다.
  • 문제(또는 리스크): 상세 캐시 요구사항을 직접 설명하기 어렵고, 브랜드/카테고리 캐시와 상품 캐시의 운영 목적이 섞여 보이며, 로컬 성능 실험 재현 경로도 불안정했습니다.
  • 성공 기준(완료 정의): 상품 상세 캐시 경로/TTL/무효화가 명확해야 하고, 브랜드/카테고리 캐시는 별도 전략으로 분리 설명되어야 하며, infra-compose + mysql-seed만으로 실험 데이터가 재현되어야 합니다.

선택지와 결정

  • 고려한 대안:
    • A: 상품 목록 캐시 실험 결과를 유지하면서 문서만 보강한다.
    • B: 현재 런타임 기준을 상품 상세 캐시로 명확히 전환하고, 브랜드/카테고리 캐시는 별도 전략 문서로 분리한다.
  • 최종 결정: B를 선택했습니다.
  • 트레이드오프: 목록 캐시 실험 이력은 남기되, 현재 런타임 전략은 상세 캐시 기준으로 다시 설명해야 해서 문서가 2갈래로 나뉩니다.
  • 추후 개선 여지(있다면): 상세 캐시 실측 결과를 추가하고, 브랜드/카테고리 캐시도 필요하면 운영 지표와 함께 별도 비교 문서로 확장할 수 있습니다.

🏗️ Design Overview

변경 범위

  • 영향 받는 모듈/도메인: commerce-api 상품 조회 캐시, 브랜드/카테고리 캐시 문서화, Docker infra seed 구성
  • 신규 추가:
    • 상품 상세 캐시 AOP/Repository/Properties/TtlPolicy
    • 브랜드/카테고리 캐시 ADR
  • 제거/대체:
    • 기존 목록 캐시 런타임 코드는 제거하고, 상세 캐시 중심으로 정리

주요 컴포넌트 책임

  • ProductQueryFacade: 공개 상품 상세 조회 진입점, 상세 캐시 AOP 적용 경계
  • PublicProductDetailCacheApplicationService: 상세 캐시 key 생성, 조회/저장/삭제 위임, TTL policy 연결
  • PublicProductDetailCacheAspect: 상세 조회 cache hit/miss fallback 처리
  • PublicProductDetailCacheEvictionAspect: 상품 수정/삭제/좋아요 변경 후 상세 캐시 무효화
  • BrandCacheRepositoryImpl / CategoryCacheRepositoryImpl: TTL 없는 read-through + 목록 키 삭제 전략 유지
  • docker/infra-compose.yml: seed 포함 로컬 실험 환경 재현 경로 제공

주요 코드 링크

🔁 Flow Diagram

Main Flow

sequenceDiagram
  autonumber
  participant Client
  participant API as ProductController
  participant Facade as ProductQueryFacade
  participant Cache as Redis Detail Cache
  participant Service as ProductApplicationService
  participant DB

  Client->>API: GET /api/v1/products/{productId}
  API->>Facade: get(productId)
  Facade->>Cache: find(productId)

  alt Cache Hit
    Cache-->>Facade: ProductView
    Facade-->>API: cached response
  else Cache Miss
    Cache-->>Facade: empty
    Facade->>Service: get(productId)
    Service->>DB: select product
    DB-->>Service: product
    Service-->>Facade: Product
    Facade->>Cache: save(productId, ProductView, ttl+jitter)
    Facade-->>API: fresh response
  end

  API-->>Client: ApiResponse<ProductResponse>
Loading

Invalidation Flow

sequenceDiagram
  autonumber
  participant App as ProductApplicationService / ProductLikeAplicationService
  participant Aspect as PublicProductDetailCacheEvictionAspect
  participant Cache as Redis Detail Cache

  App->>App: update/deleteSoft/increaseLikeCount/decreaseLikeCount
  App-->>Aspect: @EvictPublicProductDetailCache
  Aspect->>Cache: evict(productId)
Loading

검증

  • 상품 상세 캐시 관련 테스트 통과
  • 상품 상세 캐시 eviction 관련 테스트 통과
  • 상품 상세 캐시 TTL jitter 정책 테스트 통과
  • :apps:commerce-api:build -x test 통과

테스트 코드 링크

참고 문서


리뷰포인트

1. 브랜드/카테고리 캐시 전략

카테고리, 브랜드는 변경 추가가 많지 않을거 같아서 ttl을 따로 두지 않고 조회시 캐시를 먼저 바라보게 했습니다. 처음에는 브랜드/카테고리 두 테이블을 인메모리 디비로 올려서 변경을 레디스에서만 반영하고 특정 시간 혹은 수동으로 배치로 디비에 백업을 하는 형식으로 하려고 했습니다. 그러나 배치 관련 학습 부족등으로 수정은 일딘 디비에 먼저 반영하고 커밋 후 레디스에 동기화하는 방식으로 진행했습니다. 브랜드/카테고리 등 변경이 많지 않다고 생각되는 데이터들의 경우 어떤 전략을 사용하시는지 여쭤보고 싶습니다.

2. 페이지네이션 방식

일반 사용자의 클라이언트를 웹앱이라 가정하여 커서 기반 페이지네이션 방식을 선택했습니다. 그러나 커서 방식은 목록 특정 페이지 캐시가 오프셋 방식에 비해 불리하여 적용을 못했습니다. 실무에선 어떻게 처리하시는지 여쭤보고 싶습니다.

3. 캐쉬 인터페이스의 위치

처음에는 일반 레포지토리 인터페이스처럼 도메인 계층에 두었으나, 캐쉬는 디비 부하 완화 / 조회 속도 개선 이 목적이어서 도메인과는 거리가 멀다고 생각하여 어플리케이션 계층에 두었습니다. 이 방식이 적절한지 궁금합니다.

개인질문

집 근처 추천 검색 관련 스타트업의 채용 공고를 봤는데 저랑 맞을거 같아서 대표님께 연락드렸는데 이력서 및 커피챗 제안을 주셨습니다.
주 사업 관련 키워드로 Search Engine Optimization, Generative Engine Optimization 가 있는 회사인데 커피챗 준비시 참고할만한 것들이 있을까요? 면접은 경험이 있는데 커피챗은 처음이라 준비를 어떻게 해야할지 모르겠습니다.

PR 요약

변경 목적

상품 상세 조회에 Redis 캐시를 런타임 경로에 적용하고, 브랜드/카테고리 캐시를 application 계층으로 이동하며, 커서 기반 페이징을 도입하고, UUID 기반 식별자 체계로 마이그레이션하며, 로컬 개발 환경의 재현 가능성을 보강합니다.

핵심 변경점

  • 상품 상세 캐싱: @CachedPublicProductDetail 어노테이션 + PublicProductDetailCacheAspect를 통한 AOP 기반 Redis 캐싱, TTL(30s) + jitter 정책, @EvictPublicProductDetailCache로 update/delete/like 변경 시 개별 키 무효화
  • 브랜드/카테고리: BrandCacheRepository, CategoryCacheRepository를 application 계층에 도입, registerUpsert/registerDelete를 통한 트랜잭션 후 동기화, 실패 시 CacheSyncTask에 기록
  • 커서 기반 페이징: listByCursor(ProductListCriteria) 신규 추가, 레거시 Pageable 기반 list() 메서드 제거, ProductCursor 및 ProductCursorPage로 정렬(LIKES/PRICE/NAME)과 커서 상태 관리
  • 식별자 체계: referenceId(BINARY(16)) 추가, AutoIncrementBaseEntity 도입으로 자동증분 PK와 UUID 기반 도메인 ID 분리, 모든 릴레이션을 referenceId 기반으로 변경
  • 로컬 인프라: docker-compose.yml 추가, docker/infra-compose.yml에 MySQL seed 서비스 추가, categories 5개/brands 30개/products 300000개 시드 데이터 재현 가능

리스크/주의사항

  • CacheSyncTask 재시도 배치 로직이 명시되지 않음: 실패 케이스는 DB 기록되나, pending 상태의 동기화 작업을 주기적으로 재시도하는 스케줄러/배치 구현 여부 확인 필요
  • 캐시 스탬피드 가능성: 브랜드/카테고리는 TTL 없는 read-through 방식으로, 쓰기 발생 시 전체 목록 캐시 무효화 후 대량 조회 시 동시 DB 부하 가능성
  • 커서 페이징 인덱스: LIKES, PRICE, NAME 정렬 시 해당 컬럼과 deletedAt, brandReferenceId 등의 복합 인덱스 존재 확인 필요
  • ProductService 제거: 기존 toCriteria 의존성이 모두 ProductListCriteria.fromPublic()으로 교체되었으나, 레거시 클라이언트 코드 호환성 검토 필요

테스트/검증

  • PublicProductDetailCache* 관련 단위 테스트 3개(ApplicationService, Aspect, Aspect eviction), BrandCacheSyncer 및 CategoryCacheSyncer 통합/단위 테스트로 동기화 및 실패 기록 검증
  • 브랜드/카테고리 캐시 토글 테스트(Redis 활성화/비활성화 시 구현 선택 검증), 커서 페이징 E2E 테스트(nextCursor 전달 및 재조회 검증)
  • :apps:commerce-api:build 및 관련 테스트 통과 확인 필요

shAn-kor and others added 30 commits March 13, 2026 17:32
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
shAn-kor and others added 14 commits March 13, 2026 17:32
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
@coderabbitai
Copy link

coderabbitai bot commented Mar 13, 2026

📝 Walkthrough

Walkthrough

Spring AOP 의존성을 추가하고, Brand/Category 엔티티에 UUID 기반 참조 ID를 도입하며, DB 기반 및 Redis 기반 캐시 레이어를 구현한다. 공개 상품 상세 조회 캐싱 및 자동화된 캐시 동기화 인프라를 추가하고, 상품 목록 페이징을 offset 기반에서 커서 기반으로 변경한다. 광범위한 통합 테스트와 Docker 구성을 추가한다.

Changes

Cohort / File(s) Summary
Brand/Category 캐시 레포지토리 계약
BrandCacheRepository, CategoryCacheRepository
캐시 조회/저장/삭제 인터페이스 정의.
Brand 캐시 구현
infrastructure/brand/db/BrandDbPassThroughCacheRepositoryImpl, infrastructure/brand/redis/BrandCacheDocument, infrastructure/brand/redis/BrandCacheRepositoryImpl, infrastructure/brand/redis/BrandCacheSyncer
DB 패스스루 및 Redis 기반 캐시 구현, 캐시 동기화 메커니즘.
Category 캐시 구현
infrastructure/category/db/CategoryDbPassThroughCacheRepositoryImpl, infrastructure/category/redis/CategoryCacheDocument, infrastructure/category/redis/CategoryCacheRepositoryImpl, infrastructure/category/redis/CategoryCacheSyncer
카테고리 캐시 계층 구현, 동기화 및 시드 초기화 연동.
Brand/Category 애플리케이션 서비스 캐시 통합
application/brand/BrandApplicationService, application/coupon/category/CategoryApplicationService, infrastructure/category/CategorySeedInitializer
캐시 레포지토리 주입 및 쓰기 작업 후 동기화 호출.
공개 상품 상세 캐시 정의
application/product/cache/CachedPublicProductDetail, application/product/cache/EvictPublicProductDetailCache, application/product/cache/PublicProductDetailCacheRepository, application/product/cache/PublicProductDetailCacheProperties
캐시 어노테이션, 저장소 계약, 설정 프로퍼티 정의.
공개 상품 상세 캐시 구현
application/product/PublicProductDetailCacheApplicationService, application/product/cache/PublicProductDetailCacheAspect, application/product/cache/PublicProductDetailCacheEvictionAspect, application/product/cache/PublicProductDetailCacheTtlPolicy, infrastructure/product/redis/PublicProductDetailCacheRepositoryImpl
AOP 기반 캐시 저장/조회/제거, TTL 정책 및 Redis 구현.
캐시 동기화 도메인 모델
domain/cachesync/CacheSyncTask, domain/cachesync/CacheSyncTaskStatus, domain/cachesync/CacheSyncAggregateType, domain/cachesync/CacheSyncOperationType, domain/cachesync/CacheSyncTaskRepository
캐시 동기화 작업 엔티티, 상태, 저장소 계약.
캐시 동기화 인프라
infrastructure/cachesync/CacheSyncTaskEntity, infrastructure/cachesync/CacheSyncTaskJpaRepository, infrastructure/cachesync/CacheSyncTaskRepositoryImpl, infrastructure/cachesync/CacheSyncFailurePersistence
캐시 동기화 작업 영속화, 실패 기록 메커니즘.
Brand 엔티티 및 저장소 변경
infrastructure/brand/BrandEntity, infrastructure/brand/BrandJpaRepository, infrastructure/brand/BrandRepositoryImpl
referenceId 추가, JPA 저장소 ID 타입 변경 (UUID → Long), referenceId 기반 조회.
Category 엔티티 및 저장소 변경
infrastructure/category/CategoryEntity, infrastructure/category/CategoryJpaRepository, infrastructure/category/CategoryRepositoryImpl
referenceId 추가, 기본 엔티티 변경 (BaseEntity → AutoIncrementBaseEntity).
상품 엔티티 및 저장소 변경
infrastructure/product/ProductEntity, infrastructure/product/ProductJpaRepository, infrastructure/product/ProductRepositoryImpl
referenceId, categoryReferenceId, brandReferenceId 추가. JpaSpecificationExecutor 통합.
상품 커서 페이징
domain/product/query/ProductCursor, domain/product/query/ProductCursorPage, infrastructure/product/ProductSpecifications
Base64 커서 인코딩/디코딩, 커서 기반 페이징 결과 타입, 동적 Specification 빌더.
상품 목록 쿼리 확장
domain/product/query/ProductListQuery, domain/product/query/ProductListCriteria, domain/product/query/ProductSortOption
카테고리, 가격 범위, 삭제 필터, 커서 지원. fromPublic/fromAdmin 팩토리 추가.
상품 애플리케이션 서비스 변경
application/product/ProductApplicationService
캐시 레포지토리 주입, 커서 기반 listByCursor 메서드 추가, 캐시 제거 어노테이션 적용.
상품 공개 목록 조회 서비스
application/product/PublicProductListQueryApplicationService, application/product/view/PublicProductListItemView, application/product/view/PublicProductListView
공개 목록 조회 오케스트레이션, View 객체 정의.
상품 쿼리 및 컨트롤러 변경
application/product/ProductQueryFacade, application/product/ProductLikeAplicationService, interfaces/api/product/ProductController, interfaces/api/product/ProductDto
공개 목록 쿼리 서비스 통합, 캐시 제거 어노테이션 추가, 응답 DTO 확장.
기본 엔티티 및 JPA 설정
domain/AutoIncrementBaseEntity, jpa.yml
Long 기반 자동 증가 PK, 감사 필드, DDL 설정 변경.
데이터베이스 마이그레이션
docker/mysql/init/01-schema.sql
카테고리, 브랜드, 상품, 멤버, 라이크, 읽기 모델 동기화 테이블 생성 (referenceId, 복합 인덱스).
데이터 시드 및 초기화
docker/mysql/seed/02-reset.sql, docker/mysql/seed/03-load-csv.sql, docker/infra-compose.yml
데이터 초기화, CSV 로드, UUID 변환, 온 중복 키 업데이트 전략.
통합 테스트 - Brand/Category
test/*/BrandApplicationServiceIntegrationTest, test/*/CategoryApplicationServiceIntegrationTest
Redis 캐시 동기화 및 제거 검증.
통합 테스트 - Product
test/*/ProductApplicationServiceIntegrationTest, test/*/ProductApiE2ETest, test/*/ProductControllerTest
커서 페이징, 카테고리/가격 필터, Redis 캐시 어설션.
단위 테스트 - 공개 상세 캐시
test/*/PublicProductDetailCacheApplicationServiceTest, test/*/PublicProductDetailCacheAspectTest, test/*/PublicProductDetailCacheEvictionAspectTest, test/*/PublicProductDetailCacheTtlPolicyTest
캐시 저장소 위임, AOP 어드바이스 동작, TTL 정책 검증.
단위 테스트 - Brand 동기화
test/*/BrandCacheSyncerTest
Redis 쓰기 실패 시 재시도 작업 기록.
캐시 토글 테스트
test/*/BrandCategoryCacheToggleTest
Redis 인에이블/디세이블 시 저장소 구현 선택 검증.
API E2E 및 라이크 테스트
test/*/LikeApiE2ETest, test/*/LikeControllerTest, test/*/ProductQueryTest
referenceId 기반 조회 업데이트, ProductListCriteria 팩토리 사용.
설정 및 Docker
build.gradle.kts, application.yml, docker-compose.yml
AOP 의존성, 캐시 설정 프로퍼티, 멀티 서비스 Docker 환경.
클린업 및 기타
DatabaseCleanUp, ApiControllerAdvice
외래 키 체크 비활성화 제거, CoreException 커스텀 메시지 전달.

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant ProductController
    participant PublicProductListQueryApplicationService
    participant ProductApplicationService
    participant BrandApplicationService
    participant CacheRepository as Brand/Category<br/>CacheRepository
    participant Database

    Client->>ProductController: getProducts(query)
    ProductController->>PublicProductListQueryApplicationService: list(criteria)
    PublicProductListQueryApplicationService->>ProductApplicationService: listByCursor(criteria)
    ProductApplicationService->>Database: searchByCursor (with Specifications)
    Database-->>ProductApplicationService: ProductCursorPage
    PublicProductListQueryApplicationService->>BrandApplicationService: findNamesByIds(brandIds)
    BrandApplicationService->>CacheRepository: findNamesByIds
    CacheRepository-->>BrandApplicationService: Map<UUID, String>
    BrandApplicationService-->>PublicProductListQueryApplicationService: brandNames
    PublicProductListQueryApplicationService->>PublicProductListQueryApplicationService: map to PublicProductListView
    PublicProductListQueryApplicationService-->>ProductController: PublicProductListView
    ProductController-->>Client: ApiResponse<PublicProductListResponse>
Loading
sequenceDiagram
    actor Mutation as Write Operation<br/>(create/update/delete)
    participant ApplicationService
    participant Repository
    participant TransactionSync as Transaction<br/>Synchronization
    participant CacheSyncer
    participant CacheRepository as Redis<br/>CacheRepository
    participant FailurePersistence

    Mutation->>ApplicationService: create/update/delete
    ApplicationService->>Repository: save/delete
    Repository->>Repository: database operation
    ApplicationService->>CacheSyncer: registerUpsert/registerDelete
    CacheSyncer->>TransactionSync: schedule afterCommit
    TransactionSync-->>TransactionSync: commit
    TransactionSync->>CacheSyncer: invoke syncUpsert/syncDelete
    CacheSyncer->>CacheRepository: save/delete
    alt Redis Success
        CacheRepository-->>CacheSyncer: success
    else Redis Failure
        CacheRepository-->>CacheSyncer: RuntimeException
        CacheSyncer->>FailurePersistence: recordFailure
        FailurePersistence->>FailurePersistence: persist retry task
    end
Loading
sequenceDiagram
    actor Client
    participant ProductQueryFacade
    participant PublicProductDetailCacheAspect as `@CachedPublicProductDetail`<br/>Aspect
    participant PublicProductDetailCacheApplicationService
    participant PublicProductDetailCacheRepository as Redis<br/>Cache
    participant ProductRepository

    Client->>ProductQueryFacade: get(productId)
    ProductQueryFacade->>PublicProductDetailCacheAspect: get(productId)
    PublicProductDetailCacheAspect->>PublicProductDetailCacheApplicationService: enabled()
    alt Cache Enabled
        PublicProductDetailCacheApplicationService-->>PublicProductDetailCacheAspect: true
        PublicProductDetailCacheAspect->>PublicProductDetailCacheApplicationService: find(productId)
        alt Cache Hit
            PublicProductDetailCacheApplicationService->>PublicProductDetailCacheRepository: findByKey
            PublicProductDetailCacheRepository-->>PublicProductDetailCacheApplicationService: ProductView
            PublicProductDetailCacheApplicationService-->>PublicProductDetailCacheAspect: Optional<ProductView>
            PublicProductDetailCacheAspect-->>ProductQueryFacade: ProductView (cached)
        else Cache Miss
            PublicProductDetailCacheAspect->>PublicProductDetailCacheAspect: proceed (original method)
            PublicProductDetailCacheAspect->>ProductRepository: query
            ProductRepository-->>PublicProductDetailCacheAspect: ProductView
            PublicProductDetailCacheAspect->>PublicProductDetailCacheApplicationService: save(productId, view)
            PublicProductDetailCacheApplicationService->>PublicProductDetailCacheRepository: save(key, view, ttl)
            PublicProductDetailCacheRepository-->>PublicProductDetailCacheApplicationService: success
            PublicProductDetailCacheAspect-->>ProductQueryFacade: ProductView (cached)
        end
    else Cache Disabled
        PublicProductDetailCacheApplicationService-->>PublicProductDetailCacheAspect: false
        PublicProductDetailCacheAspect->>PublicProductDetailCacheAspect: proceed (original method)
        PublicProductDetailCacheAspect-->>ProductQueryFacade: ProductView
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs


추가 검토 사항

운영 관점 주요 점검 항목

  1. 캐시 일관성 보장 문제

    • 문제: BrandCacheSyncerCategoryCacheSyncer는 트랜잭션 커밋 후 비동기적으로 캐시 업데이트를 수행하는데, Redis 쓰기 실패 시 CacheSyncFailurePersistence에만 기록하고 재시도 메커니즘이 보이지 않는다.
    • 수정안: 별도의 배치 작업 또는 이벤트 처리기를 통해 read_model_sync_task 테이블의 PENDING 작업을 주기적으로 처리하는 로직을 추가해야 한다.
    • 추가 테스트: 캐시 동기화 실패 후 재시도 시나리오, 최대 재시도 초과 시 DEAD 상태 전환 검증.
  2. 커서 기반 페이징 일관성

    • 문제: ProductCursor.from()에서 Base64 디코딩 실패 시 BAD_REQUEST 예외를 던지는데, 정렬 옵션이 변경된 경우 기존 커서가 유효하지 않을 수 있다.
    • 수정안: 커서에 정렬 옵션 버전 정보를 포함시키거나, 정렬 옵션 변경 시 클라이언트에 명확한 문서화를 제공한다.
    • 추가 테스트: 정렬 옵션 변경 후 기존 커서 사용 시 예외 처리 검증.
  3. TTL 적용 및 모니터링

    • 문제: PublicProductDetailCacheTtlPolicy의 지터(jitter) 로직이 동작하지만, Redis 캐시 모니터링 및 만료 통계가 없다.
    • 수정안: 캐시 히트율, 미스율, TTL 만료 통계를 메트릭으로 노출하고, 알림 임계값을 설정한다.
    • 추가 테스트: 지터 범위 검증, 캐시 만료 후 재로딩 시간 측정.
  4. 데이터베이스 마이그레이션 안정성

    • 문제: 03-load-csv.sql에서 UUID를 UUID_TO_BIN으로 변환하는데, 기존 데이터가 있는 경우 마이그레이션 경로가 불명확하다.
    • 수정안: 마이그레이션 스크립트를 여러 단계로 분리하고(스키마 추가 → 데이터 복사 → 인덱스 재구성), 각 단계 후 무결성 검증을 추가한다.
    • 추가 테스트: 롤백 시나리오, 부분 마이그레이션 실패 시 복구 절차.
  5. 외래 키 제약 조건 제거

    • 문제: DatabaseCleanUp에서 SET FOREIGN_KEY_CHECKS = 0/1을 제거했는데, 테스트 실행 중 외래 키 위반으로 인한 실패 가능성이 있다.
    • 수정안: 테이블 삭제 순서를 명시적으로 정의하거나(read_model_sync_tasklikesproductscategories/brandsmembers), 또는 트랜잭션 격리 레벨을 조정한다.
    • 추가 테스트: 동시성 높은 테스트 환경에서 외래 키 위반 오류 모니터링.

보안 및 성능 관점

  1. 캐시 주입 공격

    • 우려: Redis 캐시에 저장되는 JSON이 신뢰할 수 있는 출처인지 검증이 부족하다.
    • 수정안: ObjectMapper 역직렬화 시 화이트리스트 기반 타입 검증을 추가한다.
  2. 대량 조회 성능

    • 우려: findNamesByIds가 캐시 미스 시 SQL IN 절로 대량 조회하는데, 너무 많은 ID가 전달될 경우 쿼리 성능 저하.
    • 수정안: ID 개수 제한(예: 1000개)을 설정하고, 배치 처리하도록 변경.
    • 추가 테스트: 대량(10,000+) ID에 대한 쿼리 성능 측정.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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

Note

Due to the large number of review comments, Critical 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 (2)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductLikeAplicationService.java (1)

44-63: ⚠️ Potential issue | 🟠 Major

캐시 무효화를 AFTER_COMMIT으로 보장하도록 수정해야 한다.

@AfterReturning 사용으로 인해 메서드 반환 후 트랜잭션 커밋 전에 캐시가 무효화된다. 이 시점에서 예외 발생 시 DB 업데이트는 롤백되지만 캐시는 이미 삭제되어 일시적 일관성 저하와 불필요한 캐시 미스가 발생한다.

동일한 목적으로 BrandCacheSyncer, CategoryCacheSyncer에서는 TransactionSynchronizationManager.registerSynchronization() 패턴으로 afterCommit() 콜백을 사용하고 있다. 같은 방식으로 PublicProductDetailCacheEvictionAspect도 개선해야 한다.

다음을 추가할 것:

  • PublicProductDetailCacheApplicationServiceevict() 메서드를 호출하는 대신, 트랜잭션 커밋 후에만 실행되도록 TransactionSynchronization 콜백 등록
  • 롤백 시 eviction이 실행되지 않음을 검증하는 통합 테스트 추가 (@Transactional(rollbackFor=Exception.class) 사용)
🤖 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/ProductLikeAplicationService.java`
around lines 44 - 63, The current cache eviction (in
PublicProductDetailCacheEvictionAspect) runs via `@AfterReturning` and may execute
before transaction commit; change it to register a TransactionSynchronization
via TransactionSynchronizationManager.registerSynchronization() and invoke
PublicProductDetailCacheApplicationService.evict(productId) only from the
afterCommit() callback so eviction happens after successful commit (mirror
BrandCacheSyncer/CategoryCacheSyncer pattern); update the aspect to stop using
`@AfterReturning` and use TransactionSynchronizationManager inside the advice that
currently calls evict, and add an integration test that performs an operation
annotated with `@Transactional`(rollbackFor=Exception.class) which throws to
verify PublicProductDetailCacheApplicationService.evict is NOT called on
rollback.
apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductQueryTest.java (1)

14-107: ⚠️ Potential issue | 🟡 Minor

ProductListCriteria.fromAdmin() 경로에 대한 테스트가 없어 테스트 커버리지가 불완전하다.

fromAdmin() 메서드는 공개 API이나 현재 테스트 파일에서 테스트되지 않는다. fromPublic()과는 다른 검증 규칙(예: 삭제 상품 필터링 허용)을 가지므로, 다음 시나리오에 대한 테스트를 추가해야 한다:

  • 삭제 상품 조건(deleted=true)을 수락하는지
  • 지원되는 정렬 옵션 및 범위 검증
  • 페이지/크기 검증
  • 가격 범위 검증

이는 운영 관점에서 관리자 기능의 안정성을 보장하고, 공개 API와 관리자 API의 검증 규칙 차이를 명확히 하기 위해 필요하다.

🤖 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/ProductQueryTest.java`
around lines 14 - 107, Add tests covering the admin path by creating a new test
suite or adding test methods that call
ProductListCriteria.fromAdmin(ProductListQuery ...) and assert admin-specific
behavior: verify deleted=true is accepted (no CoreException and
criteria.deleted() == true), ensure supported sort mappings and legacy sort
values map to ProductSortOption similarly to fromPublic, assert invalid sort
throws BAD_REQUEST, validate page/size bounds produce BAD_REQUEST (using
negative page or size<1), and validate minPrice>maxPrice produces BAD_REQUEST;
use the same helper types ProductListQuery, ProductListCriteria,
ProductSortOption, and CoreException/ErrorType to mirror existing fromPublic
tests.
🟠 Major comments (20)
docker/infra-compose.yml-22-26 (1)

22-26: ⚠️ Potential issue | 🟠 Major

헬스체크에 start_period가 없어 초기 기동에서 오탐 가능성이 있다

운영 관점에서 초기 DB 부트스트랩이 느린 환경에서는 Line 24-26 기준으로 조기 unhealthy가 발생하고, 그 결과 mysql-seed가 시작되지 않아 재현성이 깨질 수 있다. 수정안은 start_period를 추가하고 timeout/retries를 완화해 초기화 구간을 흡수하는 것이다. 추가 테스트로 빈 볼륨 상태에서 docker compose upmysqlhealthy에 도달하고 이후 mysql-seed가 정상 실행되는지 검증해야 한다.

수정 예시
     healthcheck:
       test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uapplication", "-papplication"]
       interval: 5s
-      timeout: 3s
-      retries: 20
+      timeout: 5s
+      retries: 30
+      start_period: 60s
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docker/infra-compose.yml` around lines 22 - 26, 헬스체크 블록 (healthcheck with
test)에 start_period가 없어 초기 부팅 시 오탐(unhealthy) 가능성이 있으니 healthcheck에
start_period를 추가하고 timeout/retries를 완화해 초기 DB 부트스트랩 여유를 주십시오; 예를 들어
start_period를 60s 이상으로 설정하고 timeout을 10s, retries를 30 수준으로 늘려 mysql의 느린 초기화 구간을
흡수하도록 변경하고, 변경 후 빈 볼륨 상태에서 docker compose up 실행해 mysql이 healthy에 도달하고
mysql-seed가 정상 시작되는지 검증하세요.
docker/infra-compose.yml-5-7 (1)

5-7: ⚠️ Potential issue | 🟠 Major

mysql_native_password 전역 강제는 향후 버전 업그레이드 시 인증 장애를 초래한다

Line 6의 --default-authentication-plugin=mysql_native_password 설정은 운영상 큰 위험을 안고 있다. MySQL 8.0.4부터는 caching_sha2_password가 기본 인증 플러그인이며, mysql_native_password는 8.0.34부터 deprecated 상태이고 MySQL 8.4에서는 기본 비활성화되어 있으며 9.0.0에서 완전히 제거될 예정이다. 현재 설정을 유지하면 컨테이너 업그레이드 시 인증 실패로 이어질 가능성이 높으며, 개발/운영 환경의 MySQL 버전이 달라질 경우 버전 간 동작 불일치가 발생한다.

기본 인증 플러그인을 제거하고 불가피한 레거시 클라이언트가 필요한 경우에만 해당 계정의 인증 방식을 mysql_native_password로 명시적으로 지정하는 방식으로 수정해야 한다. 수정 후에는 SELECT user, plugin FROM mysql.user; 쿼리를 통해 실제로 레거시 계정에만 해당 플러그인이 적용되었는지 검증해야 한다.

수정 예시
     command:
-      - --default-authentication-plugin=mysql_native_password
       - --local-infile=1
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docker/infra-compose.yml` around lines 5 - 7, The docker compose sets a
global flag --default-authentication-plugin=mysql_native_password which will
break future MySQL upgrades; remove that flag from the command list and rely on
the server default (caching_sha2_password), and if you must support legacy
clients convert only specific accounts using ALTER USER ... IDENTIFIED WITH
'mysql_native_password' (apply during initialization or migration scripts) and
then verify with SELECT user, plugin FROM mysql.user; to ensure only intended
accounts use mysql_native_password; keep --local-infile=1 as needed.
docker-compose.yml-9-12 (1)

9-12: ⚠️ Potential issue | 🟠 Major

정적 DB 자격 증명 하드코딩은 로컬 환경에서도 사고 반경을 키운다.

  • 왜 문제인지(운영 관점): Line 9-12, Line 111-113의 고정 계정/비밀번호는 재사용·유출 시 다른 환경까지 동일 자격 증명이 전파될 수 있다.
  • 수정안: .env 기반 주입으로 전환하고, 저장소에는 안전한 기본값 또는 필수값 검증만 남긴다.
  • 추가 테스트: .env 누락 시 기동이 실패하는지, 올바른 값 주입 시 정상 기동하는지를 CI에서 검증한다.

Also applies to: 111-113

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

In `@docker-compose.yml` around lines 9 - 12, Replace the hardcoded MySQL
credentials (MYSQL_DATABASE, MYSQL_USER, MYSQL_PASSWORD, MYSQL_ROOT_PASSWORD) in
docker-compose.yml with environment-variable references sourced from a .env
file; update docker-compose service env entries to use ${MYSQL_DATABASE},
${MYSQL_USER}, ${MYSQL_PASSWORD}, ${MYSQL_ROOT_PASSWORD} and add
documentation/default .env.example with no secrets, and add startup validation
(or docker-compose override) to fail fast when any of those variables are
missing so CI can assert that launching without a .env fails and that providing
valid values starts correctly.
docker-compose.yml-49-50 (1)

49-50: ⚠️ Potential issue | 🟠 Major

Redis 무인증 + 외부 포트 노출 조합은 보안 리스크가 크다.

  • 왜 문제인지(운영 관점): 49-50, 73-74줄에서 호스트 포트를 모든 네트워크 인터페이스(0.0.0.0)로 공개하면서 53-60, 77-89줄에 인증 설정이 없으면 로컬 네트워크 내 비인가 접근으로 데이터 변조/삭제가 가능하다. 특히 109-117줄 애플리케이션 설정에도 Redis 비밀번호가 없어 누구든 Redis에 접근할 수 있다.
  • 수정안: Redis master에 requirepass 비밀번호를 설정하고, replica에 masterauth를 동일하게 적용한다. 포트 바인딩을 127.0.0.1:6379127.0.0.1:6380으로 제한하고(외부 접근 차단), 애플리케이션 환경변수에 동일 비밀번호를 추가한다.
  • 추가 테스트: 비밀번호 없이 redis-cli PING이 거부되는지, 인증 후 읽기/쓰기 및 master-replica 복제 동작이 정상인지 확인한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docker-compose.yml` around lines 49 - 50, The docker-compose Redis service
currently exposes ports with "6379:6379" (and similarly for the replica) and has
no authentication; update the Redis master service to set a strong requirepass
and the replica service to set masterauth to the same password, restrict host
port bindings to 127.0.0.1:6379 (and 127.0.0.1:6380 for the replica) instead of
0.0.0.0, and add the same password to the application environment (the app's
REDIS_PASSWORD/related env var) so the app authenticates when connecting; then
verify redis-cli PING requires AUTH and that authenticated read/write and
master-replica replication work.
docker-compose.yml-5-7 (1)

5-7: ⚠️ Potential issue | 🟠 Major

mysql_native_password 강제 설정은 MySQL 버전 업그레이드 시 호환성 문제를 야기한다.

Line 6의 --default-authentication-plugin=mysql_native_password 설정은 다음과 같은 운영상 문제를 초래한다:

  • MySQL 8.4 이상에서는 mysql_native_password가 기본적으로 비활성화되어 인증 실패가 발생한다.
  • MySQL 9.0 이상에서는 플러그인 자체가 제거되어 업그레이드가 불가능해진다.
  • 보안 측면에서도 mysql_native_password는 레거시 방식이며, caching_sha2_password(MySQL 8.0의 기본값)가 더 강력한 인증 메커니즘을 제공한다.

해결 방안: 해당 플러그인 강제 설정을 제거하여 MySQL 8.0의 기본 인증 플러그인(caching_sha2_password)을 사용하도록 변경한다. 이는 향후 버전 업그레이드 시 마이그레이션 경로를 차단하지 않으면서 보안 수준을 향상시킨다.

추가 테스트: MySQL 컨테이너 기동 후 Spring JDBC 드라이버를 통한 DB 연결 성공 여부와 마이그레이션 스크립트(schema, reset, data load) 실행 완료를 검증한다.

수정 예시 diff
     command:
-      - --default-authentication-plugin=mysql_native_password
       - --local-infile=1
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docker-compose.yml` around lines 5 - 7, Remove the forced authentication flag
--default-authentication-plugin=mysql_native_password from the docker-compose
command block (leave other flags like --local-infile=1 intact) so the container
uses MySQL's default caching_sha2_password; then restart the MySQL container and
verify connectivity from the Spring JDBC client and that all migration scripts
(schema, reset, data load) run successfully to ensure no auth regressions.
docker-compose.yml-90-94 (1)

90-94: ⚠️ Potential issue | 🟠 Major

Replica 헬스체크는 PING만으로 충분하지 않다.

  • 왜 문제인지(운영 관점): Line 91의 PING 기반 체크는 프로세스 생존만 확인하므로 복제 단절 상태(master_link_status:down)에서도 healthy로 판정되어 읽기 지연 및 데이터 정합성 문제를 숨긴다. 특히 마스터와의 연결이 끊겼더라도 이전 데이터는 여전히 조회 가능하므로 stale data를 반환할 수 있다.
  • 수정안: INFO replication에서 role:slavemaster_link_status:up을 모두 확인하도록 헬스체크를 강화한다. 추가로 master_last_io_seconds_ago 값도 모니터링하여 복제 지연을 감지한다.
    test:
      [
        "CMD-SHELL",
        "redis-cli -p 6379 INFO replication | grep -q 'role:slave' && redis-cli -p 6379 INFO replication | grep -q 'master_link_status:up'"
      ]
    timeout을 최소 3초 이상으로 설정하여 두 번의 redis-cli 호출이 완료될 충분한 시간을 확보한다.
  • 추가 테스트: (1) 마스터 일시 중단/재개 시 replica 헬스 상태가 fail/recover 되는지 검증한다. (2) 네트워크 단절 시뮬레이션으로 master_link_status가 down으로 변경되고 헬스체크가 실패하는지 확인한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docker-compose.yml` around lines 90 - 94, Update the docker-compose service
healthcheck block (the healthcheck and test fields) to stop using a simple PING
and instead run a CMD-SHELL that calls redis-cli INFO replication and verifies
role:slave and master_link_status:up (and optionally parses
master_last_io_seconds_ago to detect large replication lag); increase the
timeout to at least 3s so both redis-cli calls can complete, and keep
retries/interval sensible; ensure the healthcheck uses redis-cli on port 6379
and returns non-zero on failure so Docker marks the replica unhealthy.
docker/mysql/seed/02-reset.sql-1-5 (1)

1-5: ⚠️ Potential issue | 🟠 Major

read_model_sync_task 초기화 누락으로 재시드 시 캐시 동기화 오염 위험이 있다

운영 관점에서 재시드를 여러 번 수행하면 이전 실행의 동기화 태스크가 남아 비정상 재처리, 캐시 덮어쓰기, 디버깅 난이도 증가로 이어진다. read_model_sync_task도 함께 비워 재현성을 보장해야 한다. 추가 테스트로 reset -> load를 2회 연속 수행한 뒤 read_model_sync_task가 항상 0건인지와 불필요한 재시도 로그가 없는지 검증해야 한다.

수정안 예시
 TRUNCATE TABLE likes;
 TRUNCATE TABLE members;
 TRUNCATE TABLE products;
 TRUNCATE TABLE brands;
 TRUNCATE TABLE categories;
+TRUNCATE TABLE read_model_sync_task;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docker/mysql/seed/02-reset.sql` around lines 1 - 5, 현재 재시드
스크립트(02-reset.sql)는 likes, members, products, brands, categories만 비우고 있어 이전 실행의
동기화 태스크가 남아 재처리/캐시 오염을 초래할 수 있으니, 같은 스크립트에 read_model_sync_task 테이블을 함께 비우도록
TRUNCATE TABLE read_model_sync_task; 문을 추가하고, 변경 후에는 reset -> load를 2회 연속 실행해
read_model_sync_task의 건수가 항상 0인지와 불필요한 재시도 로그가 없는지(재시도/동기화 관련 로그가 발생하지 않는지)
확인하세요.
apps/commerce-api/src/main/java/com/loopers/domain/cachesync/CacheSyncTaskStatus.java-3-7 (1)

3-7: ⚠️ Potential issue | 🟠 Major

캐시 동기화 실패 작업의 재시도 및 DEAD 상태 처리 로직 구현 필요.

PENDING, COMPLETED, DEAD 상태는 정의되어 있으나, 실제 상태 전이와 재시도 메커니즘이 없다. 현재 구조는 실패를 기록하기만 하고 처리하지 않는다:

  1. 재시도 스케줄러 구현 필수: PENDING 상태 작업을 주기적으로 처리할 @Scheduled 메서드 또는 배치 서비스 부재. nextAttemptAt 필드가 있으나 활용되지 않음.

  2. 최대 재시도 횟수 및 DEAD 상태 전이 규칙 부재: attemptCount 필드가 있으나 제한이 없고, DEAD 상태로 전이하는 로직이 전혀 없음. 무한 PENDING 상태에 빠질 수 있음.

  3. DEAD 상태 모니터링 및 알람 전략 부재: DEAD 작업 발생 시 운영팀 알림, 대시보드 노출, 수동 복구 절차가 없음. 데이터 유실 감지 불가.

  4. 중간 상태(FAILED, RETRYING) 부재로 인한 상태 추적 불가: 세 가지 상태만으로는 재시도 중인 작업과 최종 실패 작업을 구분할 수 없음.

상태 전이 로직과 함께 재시도 정책(지수 백오프, 최대 시도 횟수), 모니터링/알람 연동, 실패 작업 수동 재처리 메커니즘을 포함한 완성된 구현이 필요하다.

🤖 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/cachesync/CacheSyncTaskStatus.java`
around lines 3 - 7, Extend CacheSyncTaskStatus to include intermediate states
(e.g., RETRYING, FAILED) and update any consumers to use them; implement a retry
scheduler (a `@Scheduled` method in your CacheSyncTaskService or a batch component
that queries tasks with status PENDING or RETRYING and nextAttemptAt <= now)
that invokes the sync logic, increments attemptCount, computes exponential
backoff to set nextAttemptAt, transitions to RETRYING while retrying and to
COMPLETED on success, and transitions to DEAD when attemptCount >= MAX_ATTEMPTS;
add a helper method (e.g., computeNextAttemptAt(task) or
backoffMillis(attemptCount)) to centralize backoff logic and a constant
MAX_ATTEMPTS; on transition to DEAD call a NotificationService/AlertService and
set a dashboard flag / expose an endpoint to list DEAD tasks for manual
reprocessing, and add a manualRetry(taskId) method that resets status to
PENDING/RETRYING and clears/updates nextAttemptAt to trigger reprocessing.
apps/commerce-api/src/main/java/com/loopers/application/product/cache/PublicProductDetailCacheProperties.java-9-14 (1)

9-14: ⚠️ Potential issue | 🟠 Major

캐시 설정값 경계 검증이 없어 잘못된 배포값을 런타임까지 끌고 간다.

ttl <= 0 또는 jitterRatio가 0~1 범위를 벗어나면 운영에서 캐시가 즉시 만료되거나 TTL 계산이 비정상화될 수 있다. 설정 바인딩 시점에 즉시 실패하도록 검증을 넣어야 한다. 또한 잘못된 프로퍼티로 애플리케이션 컨텍스트가 실패하는 테스트를 추가해야 한다.

수정 예시
 `@ConfigurationProperties`("cache.public-product-detail")
 public record PublicProductDetailCacheProperties(
         `@DefaultValue`("true") boolean enabled,
         `@DefaultValue`("30s") Duration ttl,
         `@DefaultValue`("false") boolean jitterEnabled,
         `@DefaultValue`("0.0") double jitterRatio
 ) {
+    public PublicProductDetailCacheProperties {
+        if (ttl == null || ttl.isZero() || ttl.isNegative()) {
+            throw new IllegalArgumentException("cache.public-product-detail.ttl must be > 0");
+        }
+        if (jitterRatio < 0.0 || jitterRatio > 1.0) {
+            throw new IllegalArgumentException("cache.public-product-detail.jitter-ratio must be between 0.0 and 1.0");
+        }
+    }
 }
🤖 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/cache/PublicProductDetailCacheProperties.java`
around lines 9 - 14, PublicProductDetailCacheProperties currently lacks
validation so invalid config can reach runtime; add checks in the record's
canonical constructor (or a static factory) to throw IllegalArgumentException
when ttl == null or ttl.toMillis() <= 0 and when jitterRatio is outside [0.0,
1.0], keep fields enabled and jitterEnabled as-is, and include descriptive
messages referencing ttl and jitterRatio; also add a unit/integration test that
binds invalid properties (e.g., ttl=0s or jitterRatio=-0.1 or >1.0) and asserts
the application context fails to start or the binding throws the expected
exception.
apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryRepositoryImpl.java-20-24 (1)

20-24: ⚠️ Potential issue | 🟠 Major

save() 메서드에서 기존 엔티티의 필드 업데이트 로직이 누락되어 있다.

category.id()가 non-null일 때, 기존 엔티티를 조회한 후 새로운 데이터를 반영하지 않고 그대로 재저장한다. 즉, category 파라미터의 변경된 필드(예: 카테고리명 변경)가 완전히 무시되고, 기존 엔티티가 그대로 저장되어 업데이트가 발생하지 않는다.

운영 관점에서 카테고리 수정 요청 시 변경사항이 반영되지 않아 데이터 정합성 문제가 발생한다. 또한 CategoryEntity에는 update() 메서드가 존재하지 않으므로 이를 추가해야 한다.

🐛 수정안
        CategoryEntity saved = category.id() == null
                ? categoryJpaRepository.save(CategoryEntity.from(category))
                : categoryJpaRepository.findByReferenceIdAndDeletedAtIsNull(category.id())
-                       .map(existing -> categoryJpaRepository.save(existing))
+                       .map(existing -> {
+                           existing.update(category);
+                           return categoryJpaRepository.save(existing);
+                       })
                        .orElseGet(() -> categoryJpaRepository.save(CategoryEntity.from(category)));

CategoryEntity에 아래의 update() 메서드를 추가하고, 업데이트 동작을 테스트하는 단위 테스트를 작성해야 한다.

public void update(Category category) {
    this.name = category.name();
}
🤖 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/category/CategoryRepositoryImpl.java`
around lines 20 - 24, The save() path currently re-saves the existing entity
without applying changes from the incoming Category; update the logic so when
category.id() is non-null and findByReferenceIdAndDeletedAtIsNull(...) returns
an existing CategoryEntity you call an update method on it (implement
CategoryEntity.update(Category) to set mutable fields like name) before saving,
instead of saving the entity as-is or replacing it via
CategoryEntity.from(category); also add unit tests for
CategoryRepositoryImpl.save() that verify changed fields (e.g., name) are
persisted when updating an existing entity.
apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryJpaRepository.java-11-18 (1)

11-18: ⚠️ Potential issue | 🟠 Major

deleted_at 컬럼에 대한 인덱스 추가가 필수다.

reference_id 컬럼에는 이미 UNIQUE KEY가 정의되어 있으나, deleted_at 컬럼에는 인덱스가 없다. findAllByDeletedAtIsNull(Pageable)findAllByDeletedAtIsNullOrderByIdAsc() 메서드는 대량 데이터에서 전체 테이블 스캔을 수행하며, 특히 캐시 재구성 및 페이지네이션 조회 시 성능 병목이 발생한다. 다음과 같이 복합 인덱스를 추가해야 한다:

CREATE INDEX idx_categories_deleted_at_id ON categories(deleted_at, id);

이를 통해 소프트 삭제 기반의 조회가 효율적으로 동작한다.

🤖 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/category/CategoryJpaRepository.java`
around lines 11 - 18, Repository currently queries by deletedAt (methods
findAllByDeletedAtIsNull, findAllByDeletedAtIsNullOrderByIdAsc,
findByReferenceIdAndDeletedAtIsNull, existsByReferenceIdAndDeletedAtIsNull) but
there is no DB index on deleted_at, causing full table scans; add a composite
index on (deleted_at, id) to the categories table (e.g., CREATE INDEX
idx_categories_deleted_at_id ON categories(deleted_at, id)) and deploy the
migration so these JPA queries use the index for efficient pagination and
soft-delete lookups.
apps/commerce-api/src/main/java/com/loopers/application/product/cache/PublicProductDetailCacheEvictionAspect.java-18-21 (1)

18-21: ⚠️ Potential issue | 🟠 Major

evict() 실패 시 예외가 전파되어 원본 메서드 결과가 무효화될 수 있다.

@AfterReturning으로 실행되는 evict()에서 Redis 장애 등으로 예외가 발생하면, 원본 메서드(상품 변경)는 이미 성공했음에도 호출자에게 실패로 전파된다. 캐시 eviction은 TTL로 결국 만료되므로 best-effort로 처리하고, 실패 시 로깅만 수행하여 원본 트랜잭션 결과를 보존해야 한다.

🐛 예외 처리 추가 제안
+    private static final org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(PublicProductDetailCacheEvictionAspect.class);
+
     `@AfterReturning`("@annotation(com.loopers.application.product.cache.EvictPublicProductDetailCache) && args(productId,..)")
     public void evict(UUID productId) {
-        publicProductDetailCacheApplicationService.evict(productId);
+        try {
+            publicProductDetailCacheApplicationService.evict(productId);
+        } catch (Exception e) {
+            log.warn("Failed to evict product detail cache for productId={}, will expire by TTL", 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/cache/PublicProductDetailCacheEvictionAspect.java`
around lines 18 - 21, The evict(UUID productId) method in
PublicProductDetailCacheEvictionAspect currently calls
publicProductDetailCacheApplicationService.evict(productId) directly so failures
(e.g., Redis errors) will propagate and can invalidate the original successful
operation; wrap that call in a try-catch (catch Exception or RuntimeException),
log the error via the existing logger with context (productId and that cache
eviction failed), and do not rethrow so eviction becomes best-effort and the
original method result remains unaffected.
apps/commerce-api/src/main/java/com/loopers/application/product/cache/PublicProductDetailCacheAspect.java-34-36 (1)

34-36: ⚠️ Potential issue | 🟠 Major

joinPoint.proceed() 결과가 null일 경우 예외 또는 불필요한 캐시 저장이 발생할 수 있다.

상품이 존재하지 않아 null이 반환되거나 예외가 발생하는 경우, null 값이 캐시에 저장된다. Redis 직렬화 설정에 따라 NPE 또는 직렬화 오류가 발생할 수 있으며, 정상 저장되더라도 이후 cache hit 시 null이 반환되어 의도치 않은 동작을 유발한다.

🐛 null 체크 추가 제안
         ProductView resolved = (ProductView) joinPoint.proceed();
-        publicProductDetailCacheApplicationService.save(productId, resolved);
+        if (resolved != null) {
+            publicProductDetailCacheApplicationService.save(productId, resolved);
+        }
         return resolved;
🤖 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/cache/PublicProductDetailCacheAspect.java`
around lines 34 - 36, joinPoint.proceed() can return null (or throw), so avoid
saving null into the cache: call joinPoint.proceed() and assign to ProductView
resolved, then if (resolved != null) invoke
publicProductDetailCacheApplicationService.save(productId, resolved); otherwise
skip caching and simply return the null/absent result; do not catch and swallow
exceptions from joinPoint.proceed()—let them propagate (or rethrow) so failures
are not hidden.
apps/commerce-api/src/main/java/com/loopers/application/product/cache/PublicProductDetailCacheAspect.java-23-37 (1)

23-37: ⚠️ Potential issue | 🟠 Major

캐시 조회/저장 실패 시 예외 처리가 없어 원본 로직이 실행되지 않을 수 있다.

publicProductDetailCacheApplicationService.find()save()에서 Redis 장애로 예외가 발생하면, aspect가 예외를 전파하여 원본 로직(joinPoint.proceed())이 실행되지 않거나, 결과가 반환되지 않는다. 캐시는 보조 시스템이므로 실패 시 fallback으로 원본 로직을 실행해야 한다.

🐛 캐시 실패 시 fallback 처리 제안
     `@Around`("@annotation(com.loopers.application.product.cache.CachedPublicProductDetail) && args(productId)")
     public Object cache(ProceedingJoinPoint joinPoint, UUID productId) throws Throwable {
         if (!publicProductDetailCacheApplicationService.enabled()) {
             return joinPoint.proceed();
         }

-        Optional<ProductView> cached = publicProductDetailCacheApplicationService.find(productId);
-        if (cached.isPresent()) {
-            return cached.get();
+        try {
+            Optional<ProductView> cached = publicProductDetailCacheApplicationService.find(productId);
+            if (cached.isPresent()) {
+                return cached.get();
+            }
+        } catch (Exception e) {
+            log.warn("Cache lookup failed for productId={}, falling back to origin", productId, e);
+            return joinPoint.proceed();
         }

         ProductView resolved = (ProductView) joinPoint.proceed();
-        publicProductDetailCacheApplicationService.save(productId, resolved);
+        if (resolved != null) {
+            try {
+                publicProductDetailCacheApplicationService.save(productId, resolved);
+            } catch (Exception e) {
+                log.warn("Cache save failed for productId={}", productId, e);
+            }
+        }
         return resolved;
     }
🤖 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/cache/PublicProductDetailCacheAspect.java`
around lines 23 - 37, PublicProductDetailCacheAspect.cache currently calls
publicProductDetailCacheApplicationService.find(...) and save(...) without
handling failures; wrap the cache interactions in try/catch blocks so that any
exception from publicProductDetailCacheApplicationService.find or save is
caught, logged, and the method falls back to calling joinPoint.proceed() (or
returns the resolved ProductView) instead of propagating the exception; ensure
enabled() is still checked and that save failures do not prevent returning the
resolved result from joinPoint.proceed().
apps/commerce-api/src/main/java/com/loopers/infrastructure/category/redis/CategoryCacheRepositoryImpl.java-75-80 (1)

75-80: ⚠️ Potential issue | 🟠 Major

개별 키 작업 실패가 category:all 무효화까지 건너뛰게 만든다.

이 전략에서는 category:all에 TTL이 없으므로 목록 키 삭제가 사실상 유일한 일관성 장치다. 그런데 Line 77 또는 Line 86이 실패하면 Line 78/87은 실행되지 않고 예외도 삼켜져서, 오래된 목록 캐시가 무기한 남을 수 있다. 운영에서는 Redis 일시 장애 한 번으로 삭제/수정된 카테고리가 계속 노출될 수 있다. 개별 키 처리와 목록 키 무효화를 분리해서 category:all 삭제는 항상 별도로 시도하고, 상세 키 갱신 실패 시에는 해당 상세 키도 함께 제거한 뒤 warn/metric을 남기는 편이 안전하다. 추가 테스트로 opsForValue().set(...) 또는 delete(categoryKey(...))가 예외를 던져도 category:all은 제거되고 다음 findAll()이 DB 기준으로 재구성되는지 검증해 달라.

예시 수정안
     public void save(Category category) {
-        try {
-            redisTemplate.opsForValue().set(categoryKey(category.id()), writeCategory(CategoryCacheDocument.from(category)));
-            redisTemplate.delete(CATEGORY_ALL_KEY);
-        } catch (RuntimeException ignored) {
-        }
+        try {
+            redisTemplate.opsForValue().set(categoryKey(category.id()), writeCategory(CategoryCacheDocument.from(category)));
+        } catch (RuntimeException e) {
+            safeDelete(categoryKey(category.id()));
+            log.warn("Failed to upsert category cache. categoryId={}", category.id(), e);
+        } finally {
+            safeDelete(CATEGORY_ALL_KEY);
+        }
     }
 
     `@Override`
     public void delete(UUID id) {
-        try {
-            redisTemplate.delete(categoryKey(id));
-            redisTemplate.delete(CATEGORY_ALL_KEY);
-        } catch (RuntimeException ignored) {
-        }
+        safeDelete(categoryKey(id));
+        safeDelete(CATEGORY_ALL_KEY);
     }

Also applies to: 84-89

🤖 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/category/redis/CategoryCacheRepositoryImpl.java`
around lines 75 - 80, The save(Category) method currently swallows
RuntimeExceptions so a failure setting the individual key
(redisTemplate.opsForValue().set in save) can prevent CATEGORY_ALL_KEY deletion;
change save and the similar delete/update flow so that CATEGORY_ALL_KEY
invalidation is attempted unconditionally (move
redisTemplate.delete(CATEGORY_ALL_KEY) into a finally or its own try/catch), and
if the individual detail update (redisTemplate.opsForValue().set or
redisTemplate.delete(categoryKey(...))) fails, ensure you remove the
possibly-stale detail key and emit a warn/metric (use the same logger/metrics
mechanism) before rethrowing or continuing; add tests that simulate
opsForValue().set and delete(categoryKey(...)) throwing to assert
CATEGORY_ALL_KEY is still deleted and next findAll() falls back to DB
rebuilding.
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductSpecifications.java-24-24 (1)

24-24: ⚠️ Potential issue | 🟠 Major

문자열 리터럴로 지정한 필드명은 리팩터링 시 런타임 오류 위험이 있다.

ProductSpecifications의 "deletedAt", "brandReferenceId", "categoryReferenceId", "price" 와 ProductRepositoryImpl의 "likeCount", "id", "createdAt", "name" 등 문자열 리터럴은 컴파일 타임에 검증되지 않는다. ProductEntity나 AutoIncrementBaseEntity의 필드명을 변경할 때 이 파일들이 누락되면 런타임에 IllegalArgumentException이 발생한다.

현재 프로젝트는 QueryDSL을 사용 중이므로, JPA Metamodel 도입 시 빌드 복잡도를 고려해야 한다. 최소한 ProductApplicationServiceIntegrationTest 패턴을 활용하여 ProductListCriteria의 다양한 조건(brandId, categoryId, minPrice, maxPrice, deleted 필터)에 대한 통합 테스트를 추가하여 필드명 변경 시 회귀를 방지하도록 권장한다.

🤖 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/ProductSpecifications.java`
at line 24, 여러 곳에서 문자열 리터럴로 사용한 엔티티 필드명("deletedAt", "brandReferenceId",
"categoryReferenceId", "price", "likeCount", "id", "createdAt", "name")은 리팩터링 시
런타임 예외를 유발하므로 ProductEntity와 AutoIncrementBaseEntity에 각각 FIELD_* 상수(예:
FIELD_DELETED_AT, FIELD_ID 등)를 추가하고 ProductSpecifications의 predicates 생성부와
ProductRepositoryImpl의 쿼리 생성부에서 문자열 리터럴 대신 해당 상수를 사용하도록 교체하라; 추가로 QueryDSL/메타모델
도입이 부담스러우면 최소한 ProductApplicationServiceIntegrationTest에 ProductListCriteria의
brandId, categoryId, minPrice, maxPrice, deleted 필터 조합에 대한 통합 테스트를 추가하여 필드명 변경 시
회귀를 방지하라.
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java-76-105 (1)

76-105: ⚠️ Potential issue | 🟠 Major

커서 파싱 시 예외 처리가 누락되었다.

Integer.parseInt(cursor.primaryValue()) (Lines 78, 80, 92, 94) 및 ZonedDateTime.parse(cursor.primaryValue()) (Lines 85, 87)에서 잘못된 커서 값이 전달되면 NumberFormatException 또는 DateTimeParseException이 발생한다.

클라이언트가 조작된 커서를 전송할 경우 500 Internal Server Error가 반환되며, 이는 보안 취약점(error-based information disclosure)이 될 수 있다. 파싱 실패 시 적절한 예외(CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 커서입니다."))로 변환해야 한다.

수정 예시
 if (criteria.cursor() != null) {
     ProductCursor cursor = criteria.cursor();
+    try {
         Predicate cursorPredicate = switch (criteria.sortOption()) {
             case LIKES -> cb.or(
                     cb.lessThan(root.get("likeCount"), Integer.parseInt(cursor.primaryValue())),
                     // ...
             );
             // ...
         };
         predicate = cb.and(predicate, cursorPredicate);
+    } catch (NumberFormatException | DateTimeParseException e) {
+        throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 커서입니다.");
+    }
 }
🤖 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/ProductRepositoryImpl.java`
around lines 76 - 105, ProductRepositoryImpl currently calls
Integer.parseInt(cursor.primaryValue()) and
ZonedDateTime.parse(cursor.primaryValue()) directly in the cursorPredicate
switch (cases LIKES, LATEST, PRICE, NAME), which will throw
NumberFormatException or DateTimeParseException for malformed cursors; wrap the
parsing logic for those cases in try/catch blocks (or centralize parsing into a
small helper method used by the switch) and on parse failure throw new
CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 커서입니다."); ensure you replace the
raw parse calls in the switch (the places using Integer.parseInt and
java.time.ZonedDateTime.parse) with the safe-parsed values or the helper method
so malformed cursors return the BAD_REQUEST CoreException instead of a 500.
apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategorySeedInitializer.java-38-40 (1)

38-40: ⚠️ Potential issue | 🟠 Major

시드 데이터에 UUID.randomUUID()를 쓰면 재현성이 깨진다

운영 관점에서 로컬 성능 실험이나 장애 재현 시 카테고리 ID가 매번 달라지면, 같은 시나리오를 다시 실행해도 캐시 키와 연관 데이터가 달라져 결과 비교가 어려워진다. PR 목표가 재현 가능한 seed 인프라인 만큼, 고정 UUID 목록을 두거나 name 기반 결정적 UUID로 바꿔 시드 결과를 항상 동일하게 만드는 편이 안전하다.

변경 예시
+import java.nio.charset.StandardCharsets;
 ...
-                .map(name -> new CategoryEntity(UUID.randomUUID(), name))
+                .map(name -> new CategoryEntity(
+                        UUID.nameUUIDFromBytes(("category:" + name).getBytes(StandardCharsets.UTF_8)),
+                        name
+                ))

추가 테스트로는 빈 DB에서 두 번 초기화했을 때 동일한 카테고리 이름 집합이 동일한 UUID 집합으로 생성되는지와, 기대한 referenceId가 고정되는지를 검증하면 된다.

🤖 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/category/CategorySeedInitializer.java`
around lines 38 - 40, The current CategorySeedInitializer builds seedEntities
using UUID.randomUUID(), which breaks reproducibility; replace that with a
deterministic UUID strategy (e.g., use
UUID.nameUUIDFromBytes(name.getBytes(UTF_8)) or a fixed map of UUIDs keyed by
DEFAULT_CATEGORIES) so CategoryEntity instances for the same name always get the
same id; update the stream in the code that creates seedEntities to compute the
deterministic UUID from the category name instead of UUID.randomUUID(), and add
tests that run the initializer twice on an empty DB and assert the set of
generated UUIDs for DEFAULT_CATEGORIES is identical and matches expected
referenceIds.
apps/commerce-api/src/main/java/com/loopers/domain/cachesync/CacheSyncTask.java-6-35 (1)

6-35: ⚠️ Potential issue | 🟠 Major

CacheSyncTask는 필수 필드를 생성 시점에 막아야 한다

운영 관점에서 재시도 task는 장애 복구의 마지막 안전망인데, aggregateType, aggregateId, operationType, status, nextAttemptAt 같은 필수 값이 null이어도 객체가 만들어지면 실제 실패는 JPA 저장이나 재처리 워커에서 늦게 터져 복구 경로가 더 불안정해진다. record compact constructor에서 필수 필드를 Objects.requireNonNull로 검증해 잘못된 task를 생성 시점에 차단하는 편이 안전하다.

변경 예시
 import java.time.ZonedDateTime;
+import java.util.Objects;
 import java.util.UUID;
 ...
 ) {
+    public CacheSyncTask {
+        Objects.requireNonNull(aggregateType, "aggregateType must not be null");
+        Objects.requireNonNull(aggregateId, "aggregateId must not be null");
+        Objects.requireNonNull(operationType, "operationType must not be null");
+        Objects.requireNonNull(status, "status must not be null");
+        Objects.requireNonNull(nextAttemptAt, "nextAttemptAt must not be null");
+    }
+
     public static CacheSyncTask pending(

추가 테스트로는 pending(...)에 필수 인자를 null로 넣었을 때 즉시 예외가 나는지와, 정상 입력 시 PENDING, attemptCount=0, nextAttemptAt이 채워지는지를 검증하면 된다.

As per coding guidelines, "**/*.java: ... null 처리 ... 점검한다.and/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/cachesync/CacheSyncTask.java`
around lines 6 - 35, Add a compact constructor to CacheSyncTask that validates
required fields at creation: use Objects.requireNonNull for aggregateType,
aggregateId, operationType, status, and nextAttemptAt (and lastError if you want
non-null semantics) so any attempt to create an invalid record fails fast;
update the class to include this compact constructor (preserving the existing
static pending(...) factory) and add the necessary import for java.util.Objects.
apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategorySeedInitializer.java-31-33 (1)

31-33: ⚠️ Potential issue | 🟠 Major

인프라 계층의 CategorySeedInitializer에서 @Transactional 제거 필요

CategorySeedInitializer는 인프라 계층 컴포넌트이므로 @Transactional을 선언하면 안 된다. 운영 관점에서 초기화 로직과 일반 유스케이스의 트랜잭션 경계가 일치하지 않아 롤백 동작이 예측 불가능해진다. 아키텍처 원칙에 따라 트랜잭션 관리는 애플리케이션 계층(CategoryApplicationService)이 담당해야 한다.

run() 메서드는 @Transactional을 제거하고, 대신 애플리케이션 서비스에 seedIfEmpty() 같은 전용 메서드를 추가하여 트랜잭션 경계를 여기서 관리하도록 리팩토링한다. 그 후 통합 테스트로 롤백 시 카테고리 저장과 캐시 등록이 모두 반영되지 않는지, 정상 커밋 후에만 동기화가 실행되는지 확인한다.

🤖 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/category/CategorySeedInitializer.java`
around lines 31 - 33, Remove the `@Transactional` annotation from
CategorySeedInitializer.run(...) and delegate transactional seeding to the
application layer by adding a new method on CategoryApplicationService (e.g.
seedIfEmpty()) that wraps the seeding logic in a transaction; update
CategorySeedInitializer to inject and call
CategoryApplicationService.seedIfEmpty() instead of performing DB work directly,
and adjust/instrument integration tests to assert that both category persistence
and cache registration are rolled back on transaction failure and only
synchronized after a successful commit.
🟡 Minor comments (5)
apps/commerce-api/src/main/resources/application.yml-35-37 (1)

35-37: ⚠️ Potential issue | 🟡 Minor

운영 환경에서 jitter-enabled: false 기본값은 thundering herd 위험이 있다.

jitter-enabled: false가 모든 프로파일에 적용되므로, 운영 환경에서 다수의 캐시 키가 동시에 만료될 경우 DB에 순간적인 부하가 집중될 수 있다. jitter-ratio: 0.1을 설정해 두었으나 jitter-enabled가 꺼져 있어 의미가 없다.

수정안:

  1. 운영 프로파일(prd)에서 jitter-enabled: true로 오버라이드하거나, 기본값 자체를 true로 변경한다.
  2. 상품 상세 캐시 히트율과 만료 시점 분포를 모니터링하여 thundering herd 발생 여부를 확인한다.

추가 테스트: 캐시 TTL 만료 시점에 동시 요청 부하 테스트를 수행하여 DB 스파이크 발생 여부를 검증한다.

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/resources/application.yml` around lines 35 - 37,
The current application.yml sets jitter-enabled: false which risks
thundering-herd spikes when many cache keys expire; change configuration so
jitter is enabled in production—either set jitter-enabled: true as the default
or override it in the production profile (prd) to true, keep jitter-ratio: 0.1
(or tune it) and ttl as appropriate; after making the change, deploy to a
staging/prd-like environment and run concurrent cache-expiry load tests while
monitoring product-detail cache hit rate and DB/connection-pool metrics to
verify the mitigation.
docker/mysql/seed/03-load-csv.sql-11-16 (1)

11-16: ⚠️ Potential issue | 🟡 Minor

LOAD DATA LOCAL INFILE 기능 검증 강화가 필요하다

현재 인프라는 MySQL 서버와 시드 실행 클라이언트에 --local-infile=1 옵션을 모두 적용하고 있으나, 실제로 이 기능이 정상 작동하는지 검증하는 단계가 부족하다. 현재 healthcheck는 MySQL 서버의 가용성만 확인하고 local_infile 활성화 여부는 검증하지 않는다. 시드 실행 시 CSV 적재 실패 여부를 명확히 판단하기 어렵고, 오류 발생 시 원인 파악이 제한적이다.

시드 시작 전에 명시적인 preflight 검증을 추가하여 local_infile 기능 활성화 상태를 확인하고, 시드 실행 후 적재된 행 수를 검증하는 단계를 도입해야 한다. 또한 LOAD DATA 실행 시 상세 로깅과 오류 처리를 개선하여 장애 재현성을 높여야 한다.

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

In `@docker/mysql/seed/03-load-csv.sql` around lines 11 - 16, Add explicit
preflight and post-load checks around the LOAD DATA LOCAL INFILE step: before
running the LOAD DATA LOCAL INFILE '/seed-data/categories-seed.csv' INTO TABLE
staging_categories ... statement, run a query to assert @@local_infile = 1 (or
fail with a clear error message) to ensure the client/server local_infile
feature is enabled; after LOAD DATA completes, query ROW_COUNT() or SELECT
COUNT(*) FROM staging_categories WHERE ... to verify the expected number of rows
were imported and log both expected vs actual counts; also wrap the LOAD DATA
execution with enhanced logging of the full statement, any SQL warnings/errors
(SHOW WARNINGS), and a non-zero exit/error path when import or verification
fails so CI/ops can detect and troubleshoot failures.
apps/commerce-api/src/main/java/com/loopers/infrastructure/category/redis/CategoryCacheDocument.java-11-13 (1)

11-13: ⚠️ Potential issue | 🟡 Minor

null 방어 코드 부재 - category 파라미터가 null이면 NPE 발생한다.

from(Category category) 메서드에서 category가 null일 경우 category.id() 호출 시 NPE가 발생한다. 인프라 계층에서 예상치 못한 데이터로 인한 장애를 방지하려면 null 체크 또는 명시적 예외 처리가 필요하다.

🛡️ null 방어 코드 추가 제안
 public static CategoryCacheDocument from(Category category) {
+    if (category == null) {
+        throw new IllegalArgumentException("category must not be null");
+    }
     return new CategoryCacheDocument(category.id(), category.name());
 }
🤖 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/category/redis/CategoryCacheDocument.java`
around lines 11 - 13, The static factory CategoryCacheDocument.from(Category
category) currently dereferences category without null checks and can throw an
NPE; update from(...) in CategoryCacheDocument to validate the category
parameter (either return null/Optional, or throw a clear
IllegalArgumentException) before calling category.id() / category.name(), and
include a descriptive message when throwing so callers can understand the
contract.
apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductControllerTest.java-241-251 (1)

241-251: ⚠️ Potential issue | 🟡 Minor

첫 페이지 응답의 성공/커서 유효성 선검증이 빠져 플래키 위험이 있다.

Line 241-251에서 첫 요청의 상태코드와 nextCursor 존재를 먼저 단정하지 않으면, 실패 응답에서도 빈 문자열 커서를 추출해 후속 검증이 왜곡될 수 있다. 첫 요청에 status().isOk()$.data.nextCursor 유효성 단정을 추가하고, hasNext=false일 때 커서 미전달 시나리오 테스트를 별도로 추가하는 것이 안전하다.

수정 예시
-            String cursor = objectMapper.readTree(
-                            mockMvc.perform(get("/api/v1/products")
-                                            .param("brandId", brandId.toString())
-                                            .param("sort", "likes")
-                                            .param("size", "2"))
-                                    .andReturn()
-                                    .getResponse()
-                                    .getContentAsString())
+            String firstPageBody = mockMvc.perform(get("/api/v1/products")
+                                    .param("brandId", brandId.toString())
+                                    .param("sort", "likes")
+                                    .param("size", "2"))
+                            .andExpect(status().isOk())
+                            .andExpect(jsonPath("$.data.hasNext").value(true))
+                            .andExpect(jsonPath("$.data.nextCursor").isString())
+                            .andReturn()
+                            .getResponse()
+                            .getContentAsString();
+
+            String cursor = objectMapper.readTree(firstPageBody)
                     .path("data")
                     .path("nextCursor")
                     .asText();
🤖 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/ProductControllerTest.java`
around lines 241 - 251, In ProductControllerTest update the first mockMvc call
that retrieves the initial page to assert the HTTP status and cursor presence
before extracting it: add .andExpect(status().isOk()) and a jsonPath assertion
like jsonPath("$.data.nextCursor").isNotEmpty() (locate the mockMvc.perform(...)
block that currently reads response to objectMapper.readTree and extracts
"data.nextCursor"), then extract the cursor; also add a separate test case for
the scenario when hasNext=false that performs a request and asserts
status().isOk() and that $.data.nextCursor is absent or empty (use
jsonPath(...).doesNotExist() or isEmptyString()) to validate the no-cursor
behavior.
apps/commerce-api/src/test/java/com/loopers/application/brand/BrandApplicationServiceIntegrationTest.java-67-79 (1)

67-79: ⚠️ Potential issue | 🟡 Minor

설명 필드 동기화 검증이 현재는 거짓 양성을 허용한다.

Line 77의 assertThat(cached).contains("new")imageUrl 값인 new-img만 들어 있어도 통과한다. 운영에서는 Redis 읽기 모델의 설명 필드가 깨져도 CI가 놓치게 된다. 캐시 문자열을 역직렬화해서 필드별로 비교하거나, 최소한 서로 겹치지 않는 값으로 업데이트한 뒤 descriptionimageUrl을 각각 검증하는 편이 안전하다. 추가 테스트로 설명만 변경하고 이미지 URL은 유지하는 케이스를 넣어 필드별 동기화를 확인해 달라.

예시 수정안
-        Brand updated = brandApplicationService.update(created.id(), new UpdateBrandCommand("new", "new-img"));
+        Brand updated = brandApplicationService.update(created.id(), new UpdateBrandCommand("desc-v2", "img-v2"));
 
         String cached = redisTemplate.opsForValue().get("brand:" + created.id());
 
         assertThat(updated.description()).isEqualTo("new");
-        assertThat(cached).contains("new");
-        assertThat(cached).contains("new-img");
+        assertThat(cached).contains("\"description\":\"desc-v2\"");
+        assertThat(cached).contains("\"imageUrl\":\"img-v2\"");
🤖 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/brand/BrandApplicationServiceIntegrationTest.java`
around lines 67 - 79, The test updateAlsoSyncsRedisReadModel currently asserts
cached contains("new") which can pass if only imageUrl contains that substring;
instead retrieve and parse the cached read-model (from
redisTemplate.opsForValue().get("brand:" + created.id())) into its fields and
assert description.equals("new") and imageUrl.equals("new-img") explicitly, or
update the test to use non-overlapping values (e.g., description="desc-new" and
imageUrl="img-new") and assert each field separately; also add a second test
that calls brandApplicationService.update(created.id(), new
UpdateBrandCommand("desc-only", existingImageUrl)) to verify description-only
updates sync the Redis read model (use BrandApplicationService.create/ update,
CreateBrandCommand/UpdateBrandCommand and redisTemplate.opsForValue().get to
locate the code points).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a7794da0-bb42-48a8-a067-11901d9519c6

📥 Commits

Reviewing files that changed from the base of the PR and between d914a38 and d5a8bbd.

⛔ Files ignored due to path filters (10)
  • docker/mysql/seed-data/brands-seed.csv is excluded by !**/*.csv and included by **
  • docker/mysql/seed-data/categories-seed.csv is excluded by !**/*.csv and included by **
  • docker/mysql/seed-data/products-seed-300000.csv is excluded by !**/*.csv and included by **
  • docs/adr/2026-03-13-brand-category-cache-adr.md is excluded by !**/*.md and included by **
  • docs/adr/2026-03-13-like-count-denormalization-adr.md is excluded by !**/*.md and included by **
  • docs/adr/2026-03-13-product-search-index-benchmark-adr.md is excluded by !**/*.md and included by **
  • docs/adr/2026-03-13-product-search-paging-adr.md is excluded by !**/*.md and included by **
  • docs/adr/2026-03-13-public-product-cache-adr.md is excluded by !**/*.md and included by **
  • docs/design/03-class-diagram.md is excluded by !**/*.md and included by **
  • docs/design/04-erd.md is excluded by !**/*.md and included by **
📒 Files selected for processing (84)
  • apps/commerce-api/build.gradle.kts
  • apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApplicationService.java
  • apps/commerce-api/src/main/java/com/loopers/application/brand/BrandCacheRepository.java
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/category/CategoryApplicationService.java
  • apps/commerce-api/src/main/java/com/loopers/application/coupon/category/CategoryCacheRepository.java
  • apps/commerce-api/src/main/java/com/loopers/application/member/MemberAuthenticationService.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductApplicationService.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductLikeAplicationService.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductQueryFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/PublicProductDetailCacheApplicationService.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/PublicProductListQueryApplicationService.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/cache/CachedPublicProductDetail.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/cache/EvictPublicProductDetailCache.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/cache/PublicProductDetailCacheAspect.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/cache/PublicProductDetailCacheEvictionAspect.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/cache/PublicProductDetailCacheProperties.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/cache/PublicProductDetailCacheRepository.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/cache/PublicProductDetailCacheTtlPolicy.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/view/PublicProductListItemView.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/view/PublicProductListView.java
  • apps/commerce-api/src/main/java/com/loopers/domain/cachesync/CacheSyncAggregateType.java
  • apps/commerce-api/src/main/java/com/loopers/domain/cachesync/CacheSyncOperationType.java
  • apps/commerce-api/src/main/java/com/loopers/domain/cachesync/CacheSyncTask.java
  • apps/commerce-api/src/main/java/com/loopers/domain/cachesync/CacheSyncTaskRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/cachesync/CacheSyncTaskStatus.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/domain/product/ProductService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductCursor.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductCursorPage.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductListCriteria.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductListQuery.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/query/ProductSortOption.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandEntity.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/db/BrandDbPassThroughCacheRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/redis/BrandCacheDocument.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/redis/BrandCacheRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/redis/BrandCacheSyncer.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/CacheSyncFailurePersistence.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/CacheSyncTaskEntity.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/CacheSyncTaskJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/CacheSyncTaskRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryEntity.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategoryRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/category/CategorySeedInitializer.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/category/db/CategoryDbPassThroughCacheRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/category/redis/CategoryCacheDocument.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/category/redis/CategoryCacheRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/category/redis/CategoryCacheSyncer.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.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/ProductSpecifications.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/redis/PublicProductDetailCacheRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiControllerAdvice.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/main/resources/application.yml
  • apps/commerce-api/src/test/java/com/loopers/application/brand/BrandApplicationServiceIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/coupon/category/CategoryApplicationServiceIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/ProductApplicationServiceIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/PublicProductDetailCacheApplicationServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/PublicProductDetailCacheEvictionWiringTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/cache/PublicProductDetailCacheAspectTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/cache/PublicProductDetailCacheEvictionAspectTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/cache/PublicProductDetailCacheTtlPolicyTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/brand/redis/BrandCacheSyncerTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/BrandCategoryCacheToggleTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/LikeApiE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/LikeControllerTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductApiE2ETest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductControllerTest.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductQueryTest.java
  • docker-compose.yml
  • docker/infra-compose.yml
  • docker/mysql/init/01-schema.sql
  • docker/mysql/seed/02-reset.sql
  • docker/mysql/seed/03-load-csv.sql
  • modules/jpa/src/main/java/com/loopers/domain/AutoIncrementBaseEntity.java
  • modules/jpa/src/main/resources/jpa.yml
  • modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java
💤 Files with no reviewable changes (4)
  • apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
  • modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
  • apps/commerce-api/src/main/java/com/loopers/application/member/MemberAuthenticationService.java

Comment on lines +50 to +66
public void syncUpsert(Brand brand) {
try {
brandCacheRepository.save(brand);
} catch (RuntimeException e) {
log.error("브랜드 Redis 읽기 모델 동기화에 실패했습니다. brandId={}", brand.id(), e);
cacheSyncFailurePersistence.recordBrandUpsertFailure(brand, e);
}
}

public void syncDelete(UUID brandId) {
try {
brandCacheRepository.delete(brandId);
} catch (RuntimeException e) {
log.error("브랜드 Redis 읽기 모델 삭제 동기화에 실패했습니다. brandId={}", brandId, e);
cacheSyncFailurePersistence.recordBrandDeleteFailure(brandId, e);
}
}
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
# Description: 캐시 동기화 실패 재처리 스케줄러 존재 여부 확인

# CacheSyncTask 재처리 로직이 있는지 확인
rg -n "@Scheduled|CacheSyncTask" --type=java -C 3

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

Length of output: 21094


🏁 Script executed:

#!/bin/bash
# Search for scheduled methods that process cache sync tasks
# Look for `@Scheduled` annotations and methods that query PENDING status

rg -n "PENDING|@Scheduled" --type=java apps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/ -A 5

# Also search for batch or task scheduler in other directories
fd -t f -name "*Scheduler*" -o -name "*Batch*" --type f apps/commerce-api/src/main/java/ | head -20

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

Length of output: 315


🏁 Script executed:

#!/bin/bash
# Search for scheduled methods that process cache sync tasks

# Look for `@Scheduled` in cachesync infrastructure
rg "@Scheduled" --type=java apps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/ -A 10

# Search for any scheduler or batch processor files
fd -t f "Scheduler\|Batch" apps/commerce-api/src/main/java/

# Search for methods querying PENDING status
rg "CacheSyncTaskStatus\.PENDING|findByStatus|select.*status" --type=java -C 3

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

Length of output: 860


🏁 Script executed:

#!/bin/bash
# Search for any `@Scheduled` methods across entire project
rg "@Scheduled" --type=java apps/commerce-api/src/main/java/

# Also search for task/scheduler related components
find apps/commerce-api/src/main/java -type f -name "*.java" | xargs grep -l "TaskScheduler\|ScheduledExecutor\|@EnableScheduling" 2>/dev/null

# Check if there's a repository method to find PENDING tasks
rg "findBy|query" --type=java apps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/CacheSyncTaskJpaRepository.java -A 3

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Let's check the full CacheSyncTaskJpaRepository interface
cat apps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/CacheSyncTaskJpaRepository.java

# Search for all files in cachesync directory
echo "=== Files in cachesync directory ==="
ls -la apps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/

# Look for any service that might handle retries
rg "cachesync\|CacheSyncTask" --type=java apps/commerce-api/src/main/java -l | head -20

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

Length of output: 765


🏁 Script executed:

#!/bin/bash
# Check if there's any scheduled task or retry logic elsewhere
# Search across the entire project for `@Scheduled`
rg "@Scheduled" --type=java apps/commerce-api/

# Also search for cron expressions or scheduler configs
rg "cron\|@EnableScheduling\|SchedulingConfigurer" --type=java apps/commerce-api/ | head -20

# Check application.yml or properties for scheduler configuration
find apps/commerce-api -name "application*.yml" -o -name "application*.properties" | xargs cat 2>/dev/null | grep -i "scheduler\|task\|cron"

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Let's check if there are any tests or documentation that reference scheduler
rg "CacheSyncTask|read_model_sync_task" --type=java apps/commerce-api/ -l

# Check if there's any mention of retry or reprocessing
rg "retry|reprocess|batch" --type=java apps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/ -i

# Look at the complete CacheSyncFailurePersistence to understand the flow
cat apps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/CacheSyncFailurePersistence.java

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

Length of output: 4035


🏁 Script executed:

#!/bin/bash
# Comprehensive search for any scheduler in the entire repository
find apps/commerce-api -type f -name "*.java" -exec grep -l "@Scheduled\|TaskScheduler\|@EnableScheduling\|Executor.*Schedule" {} \;

# Check if there's a separate module or service for handling retries
find apps -type d -name "*scheduler*" -o -type d -name "*batch*" -o -type d -name "*retry*"

# Let's also check if CacheSyncTask has any methods for retry logic
cat apps/commerce-api/src/main/java/com/loopers/domain/cachesync/CacheSyncTask.java

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

Length of output: 1133


🏁 Script executed:

#!/bin/bash
# Search for scheduled tasks in the commerce-batch module
find apps/commerce-batch -type f -name "*.java" | head -20

# Look for `@Scheduled` annotations in batch module
rg "@Scheduled" --type=java apps/commerce-batch/ -B 2 -A 10

# Look for any CacheSyncTask processing in batch
rg "CacheSyncTask\|read_model_sync" --type=java apps/commerce-batch/ -B 2 -A 5

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

Length of output: 722


🏁 Script executed:

#!/bin/bash
# List all files in commerce-batch to see structure
find apps/commerce-batch/src/main/java -type f -name "*.java"

# Search for any scheduled job or cron in the batch module
rg "Scheduled\|@EnableScheduling\|cron" --type=java apps/commerce-batch/ -i

# Check the batch application main class
cat apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java

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

Length of output: 1287


🏁 Script executed:

#!/bin/bash
# Search more thoroughly in commerce-api for any scheduler or job that processes PENDING tasks
find apps/commerce-api/src/main/java -type f -name "*.java" | xargs grep -l "@Scheduled\|@EnableScheduling" 2>/dev/null

# Check entire repository for `@Scheduled`
rg "@Scheduled" --type=java . 2>/dev/null

# Look for any event listener or transaction listener that might handle retries
rg "TransactionSynchronization|EventListener" --type=java apps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/ -B 2 -A 5

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check if there are any Scheduled or scheduling related classes at all
rg "Scheduled" --type=java . 2>/dev/null | head -20

# Check application configuration files
find apps -name "application*.yml" -o -name "application*.properties" -o -name "application*.yaml" | xargs cat 2>/dev/null

# Look at the BrandCacheSyncer to understand complete flow
cat apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/redis/BrandCacheSyncer.java

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

Length of output: 5091


🏁 Script executed:

#!/bin/bash
# Final verification - check if there's any query method to retrieve PENDING tasks
cat apps/commerce-api/src/main/java/com/loopers/infrastructure/cachesync/CacheSyncTaskRepositoryImpl.java

# Check if there are any other cache sync syncer implementations
find apps/commerce-api/src/main/java -name "*Syncer.java" -o -name "*CacheSync*.java" | xargs wc -l

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

Length of output: 1791


캐시 동기화 실패 재처리 메커니즘이 구현되지 않았다.

CacheSyncFailurePersistence에서 예외를 catch하고 read_model_sync_task 테이블에 PENDING 상태로 기록하는 코드는 있으나, 해당 PENDING 레코드를 처리하는 스케줄러가 존재하지 않는다. CacheSyncTaskRepository에는 저장 기능만 있고 PENDING 상태 조회 메서드가 없으며, 전체 코드베이스에 @Scheduled 애너테이션도 없다. 이는 Redis 동기화 실패 시 해당 항목이 영구적으로 DB에 남아 재시도되지 않음을 의미한다.

수정 방안:

  1. CacheSyncTaskJpaRepositoryfindByStatusAndNextAttemptAtBefore(status, now) 메서드 추가
  2. @EnableScheduling을 포함한 스케줄러 구현 또는 commerce-batch에 배치 작업 추가
  3. 일정 간격(예: 1분)으로 PENDING 레코드를 조회하여 재처리하고, 재시도 횟수 초과 시 DEAD 상태로 변경
  4. 재처리 중 예외 발생 시 nextAttemptAt을 지수 백오프로 계산하여 무한 재시도 방지

테스트:

  • 캐시 동기화 실패 후 스케줄러 실행 시 PENDING 레코드가 COMPLETED 또는 DEAD 상태로 전환되는지 확인
  • 최대 재시도 횟수 초과 후 DEAD 상태가 되는지 검증
🤖 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/brand/redis/BrandCacheSyncer.java`
around lines 50 - 66, Add a retry scheduler that picks up PENDING
read_model_sync_task rows and replays cache sync attempts: add a repository
query findByStatusAndNextAttemptAtBefore(Status status, Instant now) to
CacheSyncTaskJpaRepository, implement a scheduled component (enable scheduling
via `@EnableScheduling`) in either the service or commerce-batch module that runs
e.g. every minute, queries pending tasks using the new
findByStatusAndNextAttemptAtBefore(Status.PENDING, Instant.now()), invokes the
same retry logic used by CacheSyncFailurePersistence to re-run
syncUpsert/syncDelete, increment attempt counts and set nextAttemptAt using
exponential backoff, and flip task status to COMPLETED or DEAD when success or
max attempts exceeded; ensure failures during retry update nextAttemptAt and
attempts rather than leaving tasks stuck.

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