Skip to content

[volume - 5] 인덱스와 캐시 설계#219

Open
dame2 wants to merge 2 commits intoLoopers-dev-lab:dame2from
dame2:volume-5
Open

[volume - 5] 인덱스와 캐시 설계#219
dame2 wants to merge 2 commits intoLoopers-dev-lab:dame2from
dame2:volume-5

Conversation

@dame2
Copy link

@dame2 dame2 commented Mar 13, 2026

📌 Summary

  • 배경: 커머스 플랫폼의 핵심 기능인 쿠폰, 포인트, 주문 도메인이 미구현 상태였고, 좋아요 조회 시 매번 COUNT 쿼리를 실행하여 Hot Row 경합 및 성능 저하 위험이 존재했습니다.
  • 목표: 쿠폰/포인트/주문 도메인 전체 계층 구현 및 좋아요 수 캐싱을 통한 조회 성능 최적화
  • 결과: 정액/정률 할인 쿠폰, 포인트 적립/차감, 할인 적용 주문 기능 완성. Redis 기반 좋아요 카운터 캐싱으로 DB 부하 감소.

🧭 Context & Decision

문제 정의

  • 현재 동작/제약: 좋아요 수 조회 시 likes 테이블 COUNT(*) 쿼리 실행. 쿠폰/포인트 도메인 미존재.
  • 문제(또는 리스크):
    • Hot Row 경합: 인기 상품 좋아요 집중 시 DB UPDATE 경합
    • Thundering Herd: 캐시 미스 시 동시 다발적 DB 조회
    • 할인 로직 부재로 실제 주문 시나리오 처리 불가
  • 성공 기준:
    • 좋아요 조회 시 Redis 캐시 우선 조회
    • 쿠폰 발급/사용, 포인트 적립/차감 API 동작
    • 동시성 테스트 통과

선택지와 결정


도메인 설계

  • 쿠폰 할인 정책: Strategy 패턴 적용
    • A: if-else 분기 → 확장 시 코드 수정 필요
    • B: Strategy 패턴 (CouponDiscountPolicy) → 새 할인 유형 추가 용이
    • 최종 결정: B (FixedCouponDiscountPolicy, RateCouponDiscountPolicy)
  • 쿠폰 발급 수 관리: Aggregate 내부 카운터
    • A: 발급 시마다 COUNT 쿼리 → N+1, 동시성 문제
    • B: CouponTemplate.issuedCount 필드 관리 → 단일 UPDATE
    • 최종 결정: B (CouponTemplate.issue() 내부에서 증가)
  • 포인트 잔액 관리: 단일 Aggregate
    • A: 트랜잭션 히스토리 기반 계산 → 조회 시 SUM 필요
    • B: UserPoint.balance 필드 직접 관리 → O(1) 조회
    • 최종 결정: B (UserPoint.earn(), deduct())

캐싱 전략

  • 좋아요 캐싱 전략: Redis 카운터 버퍼링
    • A: products.like_count 직접 UPDATE → Hot Row 경합
    • B: Redis INCRBY 후 배치 동기화 → DB 부하 분산
    • 최종 결정: B (LikeCountCacheService + LikeCountSyncJob)
  • 캐시 스탬피드 방지: 3중 방어
    • A: 단순 TTL → 만료 시 Thundering Herd
    • B: PER만 적용 → 확률적 갱신, 미스 시 여전히 경합
    • C: PER + Single Flight + Stale-While-Revalidate → 완전 방어
    • 최종 결정: C (ProductLikeCountQueryService)
  • Hot Key 방지: Redis 키 샤딩
    • A: 단일 키 패턴 → 특정 노드 집중
    • B: Hash Tag 샤딩 ({product:{shardId}}) → 16개 샤드 분산
    • 최종 결정: B (LikeCountCacheService.buildKey())
  • 캐시 TTL 전략: Long TTL + Overwrite
    • A: Short TTL (1시간) → 잦은 갱신, DB 부하
    • B: Long TTL (7일) + Write 시 덮어쓰기 → 갱신 최소화
    • 최종 결정: B (DEFAULT_TTL = 7일)

동시성 제어

  • 포인트 차감 동시성: 비관적 락
    • A: 낙관적 락 (@Version) → 충돌 시 재시도 필요
    • B: 비관적 락 (SELECT FOR UPDATE) → 순차 처리 보장
    • 최종 결정: B (UserPointJpaRepository.findByUserIdForUpdate())
  • 쿠폰 발급 동시성: DB 유니크 제약
    • A: 애플리케이션 레벨 중복 체크 → Race Condition
    • B: DB 유니크 제약 (user_id, template_id) → 원자적 보장
    • 최종 결정: B (issued_coupons 테이블 유니크 인덱스)
  • 분산 락 구현: Redis SETNX
    • A: DB 락 테이블 → 추가 테이블, 느림
    • B: Redis SETNX + TTL → 빠름, 자동 만료
    • 최종 결정: B (DistributedLockService)

인덱스 설계

  • 좋아요 순 정렬 인덱스: 복합 인덱스
    • A: like_count 단일 인덱스 → 동점 시 filesort
    • B: (like_count DESC, created_at DESC) → filesort 제거
    • 최종 결정: B (idx_products_like_count)
  • 브랜드별 조회 인덱스: 복합 인덱스로 통합
    • A: brand_id 단독 + 별도 정렬 인덱스 → 인덱스 중복
    • B: (brand_id, like_count DESC), (brand_id, price ASC) → 커버링
    • 최종 결정: B (idx_products_brand_like, idx_products_brand_price)

배치 동기화

  • 동기화 주기: 5분 간격
    • A: 실시간 (이벤트 기반) → 복잡도 증가
    • B: 1분 간격 → DB 부하
    • C: 5분 간격 + dirty flag → 변경분만 처리
    • 최종 결정: C (LikeCountSyncJob 5분 주기)
  • 동기화 대상 추적: Redis Set (dirty flag)
    • A: 전체 상품 스캔 → 비효율
    • B: 변경된 productId만 Set에 기록 → 최소 처리
    • 최종 결정: B (like:dirty Set)

트레이드오프

  • 캐시-DB 간 일시적 불일치 허용 (eventual consistency, 최대 5분)
  • 분산 락 TTL 5초 → 데드락 방지 vs 락 조기 만료 위험
  • Long TTL 7일 → DB 부하 감소 vs 메모리 사용량 증가

추후 개선 여지

  • 캐시 무효화 재시도 큐 (Phase 4)
  • likes COUNT vs like_count 정합성 모니터링 Job (Phase 5)
  • deleted_at 컬럼 Partial Index 적용
  • 깊은 페이지네이션 Keyset Pagination 전환

🏗️ Design Overview

변경 범위

  • 영향 받는 모듈/도메인: commerce-api (coupon, point, order, like, product), commerce-batch
  • 신규 추가:
    • domain/coupon/*: CouponTemplate, IssuedCoupon, 할인 정책
    • domain/point/*: UserPoint
    • domain/order/Order: 할인 적용 필드 추가
    • infrastructure/cache/*: LikeCountCacheService, DistributedLockService, ProductLikeCountQueryService
    • batch/job/likesync/*: LikeCountSyncJobConfig, LikeCountSyncTasklet
  • 제거/대체: 없음

주요 컴포넌트 책임

  • CouponTemplate: 쿠폰 템플릿 Aggregate Root (발급 가능 여부 검증, 발급 수 관리)
  • IssuedCoupon: 사용자에게 발급된 쿠폰 (사용 처리, 만료 검증)
  • CouponDiscountPolicy: 할인 금액 계산 전략 인터페이스
  • UserPoint: 사용자 포인트 Aggregate Root (적립/차감, 잔액 검증)
  • LikeCountCacheService: Redis 좋아요 카운터 INCR/DECR/GET
  • DistributedLockService: Redis SETNX 기반 분산 락
  • ProductLikeCountQueryService: Single Flight 패턴 적용 좋아요 수 조회
  • LikeCountSyncTasklet: dirty flag 기반 Redis→DB 배치 동기화

🔁 Flow Diagram

쿠폰 발급 Flow

sequenceDiagram
    autonumber
    participant Client
    participant CouponV1Controller
    participant CouponUserService
    participant CouponTemplateRepository
    participant IssuedCouponRepository
    participant DB

    Client->>CouponV1Controller: POST /api/v1/coupons/{templateId}/issue
    CouponV1Controller->>CouponUserService: issue(userId, templateId)
    CouponUserService->>CouponTemplateRepository: findById(templateId)
    CouponTemplateRepository->>DB: SELECT
    DB-->>CouponTemplateRepository: CouponTemplate
    CouponUserService->>IssuedCouponRepository: existsByUserIdAndTemplateId()
    IssuedCouponRepository->>DB: SELECT
    alt 이미 발급됨
        DB-->>IssuedCouponRepository: true
        CouponUserService-->>CouponV1Controller: throw COUPON_ALREADY_ISSUED
    else 발급 가능
        DB-->>IssuedCouponRepository: false
        CouponUserService->>CouponUserService: template.issue()
        CouponUserService->>IssuedCouponRepository: save(issuedCoupon)
        IssuedCouponRepository->>DB: INSERT
        CouponUserService->>CouponTemplateRepository: save(template)
        CouponTemplateRepository->>DB: UPDATE issued_count
        CouponUserService-->>CouponV1Controller: IssuedCouponResult
    end
    CouponV1Controller-->>Client: 201 Created
Loading

할인 적용 주문 Flow

sequenceDiagram
    autonumber
    participant Client
    participant OrderV1Controller
    participant OrderApplicationService
    participant IssuedCouponRepository
    participant UserPointRepository
    participant OrderRepository
    participant DB

    Client->>OrderV1Controller: POST /api/v1/orders/with-discount
    OrderV1Controller->>OrderApplicationService: placeOrderWithDiscount(request)
    OrderApplicationService->>OrderApplicationService: 원가 계산
    
    alt 쿠폰 적용
        OrderApplicationService->>IssuedCouponRepository: findById(couponId)
        IssuedCouponRepository->>DB: SELECT
        DB-->>IssuedCouponRepository: IssuedCoupon
        OrderApplicationService->>OrderApplicationService: coupon.calculateDiscount(originalAmount)
        OrderApplicationService->>OrderApplicationService: coupon.use()
        OrderApplicationService->>IssuedCouponRepository: save(coupon)
        IssuedCouponRepository->>DB: UPDATE status=USED
    end
    
    alt 포인트 적용
        OrderApplicationService->>UserPointRepository: findByUserId(userId)
        UserPointRepository->>DB: SELECT FOR UPDATE
        DB-->>UserPointRepository: UserPoint
        OrderApplicationService->>OrderApplicationService: userPoint.deduct(pointAmount)
        OrderApplicationService->>UserPointRepository: save(userPoint)
        UserPointRepository->>DB: UPDATE balance
    end
    
    OrderApplicationService->>OrderApplicationService: Order.createWithDiscount()
    OrderApplicationService->>OrderRepository: save(order)
    OrderRepository->>DB: INSERT
    OrderApplicationService-->>OrderV1Controller: OrderResult
    OrderV1Controller-->>Client: 201 Created
Loading

좋아요 등록 + 캐시 업데이트 Flow

sequenceDiagram
    autonumber
    participant Client
    participant LikeV1Controller
    participant LikeApplicationService
    participant LikeRepository
    participant LikeCountCacheService
    participant Redis
    participant DB

    Client->>LikeV1Controller: POST /api/v1/products/{productId}/likes
    LikeV1Controller->>LikeApplicationService: addLike(userId, productId)
    LikeApplicationService->>LikeRepository: save(like)
    LikeRepository->>DB: INSERT
    DB-->>LikeRepository: Like
    LikeApplicationService->>LikeApplicationService: afterCommit callback 등록
    LikeApplicationService-->>LikeV1Controller: LikeResult
    LikeV1Controller-->>Client: 201 Created
    
    Note over LikeApplicationService,Redis: Transaction Commit 후
    LikeApplicationService->>LikeCountCacheService: increment(productId)
    LikeCountCacheService->>Redis: INCRBY like:count:{productId} 1
    Redis-->>LikeCountCacheService: newCount
    LikeCountCacheService->>Redis: SADD like:dirty {productId}
    Redis-->>LikeCountCacheService: OK
Loading

좋아요 수 조회 (Single Flight) Flow

sequenceDiagram
    autonumber
    participant Client
    participant ProductV1Controller
    participant ProductLikeCountQueryService
    participant LikeCountCacheService
    participant DistributedLockService
    participant Redis
    participant DB

    Client->>ProductV1Controller: GET /api/v1/products/{id}
    ProductV1Controller->>ProductLikeCountQueryService: getLikeCount(productId)
    ProductLikeCountQueryService->>LikeCountCacheService: get(productId)
    LikeCountCacheService->>Redis: GET like:count:{productId}
    
    alt 캐시 히트
        Redis-->>LikeCountCacheService: count
        LikeCountCacheService-->>ProductLikeCountQueryService: count
    else 캐시 미스
        Redis-->>LikeCountCacheService: null
        ProductLikeCountQueryService->>DistributedLockService: tryLock("like:load:{productId}")
        DistributedLockService->>Redis: SETNX lock:like:load:{productId}
        alt 락 획득 성공
            Redis-->>DistributedLockService: true
            ProductLikeCountQueryService->>DB: SELECT COUNT(*) FROM likes
            DB-->>ProductLikeCountQueryService: count
            ProductLikeCountQueryService->>LikeCountCacheService: set(productId, count)
            LikeCountCacheService->>Redis: SET like:count:{productId}
            ProductLikeCountQueryService->>DistributedLockService: unlock()
        else 락 획득 실패
            Redis-->>DistributedLockService: false
            ProductLikeCountQueryService->>ProductLikeCountQueryService: wait 50ms
            ProductLikeCountQueryService->>LikeCountCacheService: get(productId)
            LikeCountCacheService->>Redis: GET
            Redis-->>LikeCountCacheService: count
        end
    end
    ProductLikeCountQueryService-->>ProductV1Controller: likeCount
    ProductV1Controller-->>Client: ProductResponse
Loading

좋아요 수 배치 동기화 Flow

sequenceDiagram
    autonumber
    participant Scheduler
    participant LikeCountSyncJob
    participant LikeCountSyncTasklet
    participant LikeCountBatchCacheService
    participant Redis
    participant ProductBatchRepository
    participant DB

    Scheduler->>LikeCountSyncJob: trigger (5분 주기)
    LikeCountSyncJob->>LikeCountSyncTasklet: execute()
    LikeCountSyncTasklet->>LikeCountBatchCacheService: getDirtyProductIds()
    LikeCountBatchCacheService->>Redis: SMEMBERS like:dirty
    Redis-->>LikeCountBatchCacheService: Set<productId>
    
    loop 각 productId에 대해
        LikeCountSyncTasklet->>LikeCountBatchCacheService: getLikeCount(productId)
        LikeCountBatchCacheService->>Redis: GET like:count:{productId}
        Redis-->>LikeCountBatchCacheService: count
        LikeCountSyncTasklet->>ProductBatchRepository: updateLikeCount(productId, count)
        ProductBatchRepository->>DB: UPDATE products SET like_count = ?
        LikeCountSyncTasklet->>LikeCountBatchCacheService: removeDirtyFlag(productId)
        LikeCountBatchCacheService->>Redis: SREM like:dirty {productId}
    end
    
    LikeCountSyncTasklet-->>LikeCountSyncJob: RepeatStatus.FINISHED
Loading

🤖 Generated with Claude Code

요약

변경 목적: 좋아요 테이블의 COUNT(*) 쿼리로 인한 Hot Row 경합 문제를 Redis 캐싱으로 해결하고, 쿠폰/포인트/주문 도메인 기능을 완성하여 전자상거래 핵심 기능 구현.

핵심 변경점:

  • Redis 기반 캐시 인프라 도입 (LikeCountCacheService, DistributedLockService로 분산 락 지원, ProductLikeCountQueryService는 Single Flight 패턴으로 thundering herd 방지)
  • 배치 작업으로 Redis 더티 세트의 좋아요 수를 주기적으로 DB에 동기화 (LikeCountSyncJob)
  • Strategy 패턴 기반 쿠폰 할인 정책 (정액/정률 할인)
  • Order 도메인에 쿠폰/포인트 할인 필드 추가 및 UserPoint 도메인 구현
  • 상품 정렬 기능 확장 (LIKES_DESC 지원) 및 좋아요 수 응답 추가

리스크/주의사항:

  • Eventual consistency 적용으로 캐시-DB 불일치 가능성 (PR에서 향후 개선 계획 명시)
  • 캐시 동기화 실패 시 폴백 메커니즘이 제대로 작동하는지 검증 필요
  • 확인 질문: DataInitializer에서 생성되는 200,000개 상품과 대규모 좋아요 데이터로 인한 초기화 성능 영향을 어느 수준으로 예상하시나요?

테스트/검증 방법:

  • Redis 컨테이너 기반 통합 테스트 (TestRedisConfiguration + Testcontainers)
  • 동시 캐시 미스 시 단일 DB 조회 보장 테스트 (동시성 100+ 스레드)
  • 배치 동기화 E2E 테스트 (더티 세트 → DB 동기화)
  • SQL 벤치마크 스크립트로 인덱스 성능 측정 (100k~500k 데이터 규모)

dame2 and others added 2 commits March 13, 2026 16:45
Phase 1: Redis 카운터 버퍼링
- LikeCountCacheService: Redis INCRBY/DECRBY 기반 좋아요 수 관리
- LikeApplicationService: afterCommit 훅으로 Redis 카운터 업데이트

Phase 2: Thundering Herd 방지
- DistributedLockService: Redis SETNX 기반 분산 락
- ProductLikeCountQueryService: Single Flight 패턴 적용 조회

Phase 3: 배치 동기화
- LikeCountSyncJobConfig: Spring Batch Job 정의
- LikeCountSyncTasklet: Redis 카운터 → DB 동기화

기타
- ProductJpaEntity: like_count 컬럼 추가
- DataInitializer: 더미 데이터 생성 (local 프로파일)
- DB 마이그레이션 스크립트 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
- create-pr: PR 생성 명령어 개선
- create-log: 작업 로그 생성 명령어 추가
- analyze-query: 쿼리 분석 스킬 위치 이동

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@coderabbitai
Copy link

coderabbitai bot commented Mar 13, 2026

📝 Walkthrough

Walkthrough

좋아요 기능에 Redis 기반 캐싱과 트랜잭션 동기화를 추가하였다. DistributedLockService와 LikeCountCacheService를 신규 구현하고, 상품 목록 API에 좋아요 수를 포함시켰다. 배치 작업으로 캐시-DB 동기화를 수행하며, 관련 테스트와 데이터 마이그레이션 스크립트를 추가하였다.

Changes

Cohort / File(s) Summary
비동기 처리 활성화
apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java
@EnableAsync 어노테이션을 추가하여 비동기 메서드 처리 기능을 활성화하였다.
Like 도메인 계층
apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDomainService.java
unlike 메서드의 반환 타입을 void에서 boolean으로 변경하여 삭제 성공 여부를 나타내도록 수정하였다.
Like 애플리케이션 서비스
apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java
LikeRepository, LikeCountCacheService를 주입받아 트랜잭션 커밋 후 콜백으로 캐시 카운트를 업데이트하는 로직을 추가하였다.
분산 잠금 서비스
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/DistributedLockService.java
Redis 기반 분산 잠금 구현으로 tryLock, unlock, executeWithLock 메서드를 제공하여 동시성 제어를 수행한다.
좋아요 카운트 캐시 서비스
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/LikeCountCacheService.java
Redis 백업 캐시로 좋아요 수를 관리하며, 확률적 조기 갱신(PER)과 더티 추적 메커니즘을 구현하였다.
상품 좋아요 수 조회 서비스
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductLikeCountQueryService.java
캐시-우선 조회 흐름과 Single Flight 패턴으로 병렬 DB 접근을 방지하고, 비동기 백그라운드 갱신을 지원한다.
상품 응답 및 서비스
apps/commerce-api/src/main/java/com/loopers/application/product/ProductResult.java, apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java
ProductResult에 likeCount 필드를 추가하고, ProductService에서 좋아요 수를 함께 조회하여 응답하도록 수정하였다.
상품 JPA 계층
apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaEntity.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaRepository.java, apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductRepositoryImpl.java
like_count 컬럼을 추가하고, 복합 인덱스를 정의하며, 좋아요 수 기반 정렬 로직을 강화하였다.
상품 API
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
getProducts 메서드에 sort 파라미터를 추가하고, ProductResponse에 likeCount를 포함시켜 API 응답을 확장하였다.
초기화 데이터
apps/commerce-api/src/main/java/com/loopers/infrastructure/init/DataInitializer.java
local 프로파일에서 1,000명의 사용자, 5,000개의 브랜드, 200,000개의 상품, 50,000개의 쿠폰을 생성하는 데이터 초기화 로직을 추가하였다.
배치 캐시 서비스
apps/commerce-batch/src/main/java/com/loopers/batch/cache/LikeCountBatchCacheService.java
배치 작업 전용 Redis 캐시 서비스로 더티 세트 조회, 다중 카운트 설정, 더티 플래그 관리 기능을 제공한다.
배치 설정 및 작업
apps/commerce-batch/src/main/java/com/loopers/batch/config/BatchJpaConfig.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/likesync/LikeCountSyncJobConfig.java, apps/commerce-batch/src/main/java/com/loopers/batch/job/likesync/step/LikeCountSyncTasklet.java
JPA 스캔 설정과 좋아요 카운트 동기화 배치 작업을 정의하여, 더티 세트의 상품들을 DB 조회로 갱신하고 캐시를 업데이트한다.
배치 저장소
apps/commerce-batch/src/main/java/com/loopers/batch/persistence/LikeEntity.java, apps/commerce-batch/src/main/java/com/loopers/batch/persistence/LikeBatchRepository.java, apps/commerce-batch/src/main/java/com/loopers/batch/persistence/ProductBatchRepository.java, apps/commerce-batch/src/main/java/com/loopers/batch/persistence/ProductEntity.java
배치용 JPA 엔티티와 저장소 인터페이스를 추가하여 배치 작업에서 좋아요 수를 동기화한다.
테스트 인프라
apps/commerce-api/src/test/java/com/loopers/config/TestRedisConfiguration.java, apps/commerce-batch/src/test/java/com/loopers/config/TestRedisConfiguration.java
Testcontainers를 사용하여 Redis 컨테이너를 테스트 환경에 자동 구성한다.
테스트 대체 구현
apps/commerce-api/src/test/java/com/loopers/fake/FakeLikeCountCacheService.java
메모리 기반 가짜 캐시 서비스로 단위 테스트에서 캐시 동작을 모의한다.
테스트 스위트
apps/commerce-api/src/test/java/com/loopers/application/like/LikeApplicationServiceTest.java, apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/DistributedLockServiceTest.java, apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/LikeCountCacheServiceTest.java, apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductLikeCountQueryServiceTest.java, apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java, apps/commerce-batch/src/test/java/com/loopers/job/likesync/LikeCountSyncJobE2ETest.java
캐싱, 분산 잠금, 배치 동기화의 동작을 검증하는 통합 테스트를 추가하였다.
테스트 설정
apps/commerce-api/src/test/resources/application-test.yml
테스트 환경에서 필요한 설정 파일을 임포트하는 프로파일 설정을 추가하였다.
데이터베이스 마이그레이션
scripts/migration/V001__add_like_count_to_products.sql, scripts/migration/V001__add_like_count_to_products_rollback.sql
like_count 컬럼을 products 테이블에 추가하고, 성능 최적화를 위한 인덱스 4개를 정의하는 Flyway 마이그레이션을 추가하였다.
캐시 설정
modules/redis/src/main/resources/redis.yml
캐시 좋아요 수의 버전을 설정 파일에 추가하였다.
벤치마크 및 샘플 스크립트
scripts/benchmark/index-performance-test.sql, scripts/benchmark/run-benchmark.sh, scripts/insert-dummy-data.sql, scripts/run-dummy-data.sh, scripts/truncate-tables.sql, http/like.http
인덱스 성능 벤치마크, 대량 데이터 생성, 테이블 정리, API 호출 예제를 제공하는 운영 스크립트를 추가하였다.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Controller as ProductV1Controller
    participant Service as LikeApplicationService
    participant Domain as LikeDomainService
    participant Repository as LikeRepository
    participant TxnMgr as TransactionSynchronizationManager
    participant Cache as LikeCountCacheService

    User->>Controller: like(userId, productId)
    Controller->>Service: like(userId, productId)
    Service->>Domain: like(userId, productId)
    Domain->>Repository: save(like)
    Repository-->>Domain: like saved
    Domain-->>Service: success
    
    Service->>TxnMgr: registerAfterCommit(incrementCallback)
    TxnMgr-->>Service: callback registered
    
    Note over TxnMgr: Transaction commits...
    TxnMgr->>Service: execute incrementCallback
    Service->>Cache: increment(productId)
    Cache-->>Service: updated count
    Service-->>TxnMgr: done
    
    Service-->>Controller: likeCount updated
    Controller-->>User: response
Loading
sequenceDiagram
    actor User
    participant Controller as ProductV1Controller
    participant QueryService as ProductLikeCountQueryService
    participant Cache as LikeCountCacheService
    participant Lock as DistributedLockService
    participant Repository as LikeRepository

    User->>Controller: getProducts(brandId, sort)
    Controller->>QueryService: getLikeCounts([productIds])
    
    loop For each productId
        QueryService->>Cache: getWithRefreshSignal(productId)
        
        alt Cache Hit
            Cache-->>QueryService: (hit=true, value, shouldRefresh=false)
            QueryService-->>QueryService: return cached value
        else Cache Miss
            Cache-->>QueryService: (hit=false, value=null, shouldRefresh=false)
            QueryService->>Lock: tryLock(key, ttl)
            
            alt Lock Acquired
                Lock-->>QueryService: Optional.of(lockValue)
                QueryService->>Cache: double-check cache
                QueryService->>Repository: countByProductId(productId)
                Repository-->>QueryService: actualCount
                QueryService->>Cache: set(productId, actualCount)
                QueryService->>Lock: unlock(key, lockValue)
                Lock-->>QueryService: true
                QueryService-->>QueryService: return actualCount
            else Lock Not Acquired
                Lock-->>QueryService: Optional.empty()
                QueryService->>QueryService: waitAndRetryFromCache(productId)
                alt Retry Success
                    QueryService->>Cache: get(productId)
                    Cache-->>QueryService: Optional.of(value)
                else Retry Exhausted
                    QueryService->>Repository: countByProductId(productId)
                    Repository-->>QueryService: actualCount
                end
            end
        end
    end
    
    QueryService-->>Controller: Map<productId, likeCount>
    Controller-->>User: products with likeCounts
Loading
sequenceDiagram
    participant Scheduler as Spring Batch Scheduler
    participant Job as LikeCountSyncJob
    participant Tasklet as LikeCountSyncTasklet
    participant Cache as LikeCountBatchCacheService
    participant Repository as LikeRepository
    participant DB as ProductRepository
    participant Redis as Redis

    Scheduler->>Job: execute job
    Job->>Tasklet: execute(stepContribution, chunkContext)
    
    Tasklet->>Cache: getDirtyProductIds()
    Cache->>Redis: SMEMBERS dirty:{version}:like
    Redis-->>Cache: Set<productId>
    Cache-->>Tasklet: dirtyIds
    
    loop For each productId in dirtyIds
        Tasklet->>Repository: countByProductId(productId)
        Repository-->>Tasklet: actualCount
        
        Tasklet->>DB: updateLikeCount(productId, actualCount)
        DB-->>Tasklet: rows updated
        
        alt Update Success
            Tasklet->>Cache: set(productId, actualCount)
            Cache->>Redis: SETEX key value
            Redis-->>Cache: OK
            
            Tasklet->>Cache: removeDirty(productId)
            Cache->>Redis: SREM dirty:{version}:like productId
            Redis-->>Cache: OK
        else Update Failed
            Tasklet-->>Tasklet: log warning, continue
        end
    end
    
    Tasklet-->>Job: RepeatStatus.FINISHED
    Job-->>Scheduler: job completed
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 21.53% 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 제목은 '[volume - 5] 인덱스와 캐시 설계'로, 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
📝 Coding Plan
  • Generate coding plan for human review comments

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

❤️ Share

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

Tip

CodeRabbit can generate a title for your PR based on the changes.

Add @coderabbitai placeholder anywhere in the title of your PR and CodeRabbit will replace it with a title based on the changes in the PR. You can change the placeholder by changing the reviews.auto_title_placeholder setting.

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: 2

Note

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

🟠 Major comments (22)
apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java-7-11 (1)

7-11: ⚠️ Potential issue | 🟠 Major

기본 비동기 실행기 사용으로 인한 스레드 폭증 위험을 해결해야 한다

라인 11의 @EnableAsync 활성화만 있고 명시적 ThreadPoolTaskExecutor 설정이 없어서 Spring의 기본 SimpleAsyncTaskExecutor를 사용하게 된다. 이 실행기는 스레드 풀이 없어 비동기 요청이 발생할 때마다 새로운 스레드를 무제한으로 생성하므로, 좋아요 버튼 클릭 같은 높은 트래픽 상황에서 스레드 폭증으로 인한 OOM, 지연, 서버 재기동이 발생할 수 있다.

ThreadPoolTaskExecutor를 명시적으로 설정하여 스레드 풀 크기, 큐 용량, 거절 정책을 제한해야 한다. @Async("실행기명")으로 특정 메서드가 사용할 실행기를 명시하는 것이 필수다.

수정 예시
`@Configuration`
`@EnableAsync`
public class AsyncConfig implements AsyncConfigurer {

    `@Bean`(name = "likeCountAsyncExecutor")
    public Executor likeCountAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(8);
        executor.setMaxPoolSize(16);
        executor.setQueueCapacity(1000);
        executor.setThreadNamePrefix("like-count-async-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

그 후 ProductLikeCountQueryService의 메서드를 다음과 같이 수정한다.

`@Async`("likeCountAsyncExecutor")
public void refreshInBackground(Long productId) {
    // ...
}

추가 테스트로 다음을 검증해야 한다: (1) 동시 100개 요청 시 active 스레드가 최대치로 제한되는지, (2) 큐 포화 시 CallerRunsPolicy로 인해 호출 스레드에서 직접 실행되는지, (3) 비동기 메서드 내 DistributedLockService 예외 발생 시 로깅과 복구 처리가 정상인지 확인한다.

🤖 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/CommerceApiApplication.java`
around lines 7 - 11, The app enables `@EnableAsync` but relies on Spring's
unbounded SimpleAsyncTaskExecutor; add a dedicated ThreadPoolTaskExecutor bean
(e.g., bean name "likeCountAsyncExecutor") configured with sensible core/max
pool sizes, queueCapacity, threadNamePrefix and a RejectedExecutionHandler like
CallerRunsPolicy (initialize the executor), and register it in a new AsyncConfig
class annotated with `@Configuration` and `@EnableAsync`; then update the
asynchronous method (e.g., ProductLikeCountQueryService.refreshInBackground) to
use `@Async`("likeCountAsyncExecutor") so async calls use the bounded pool; after
changes, add tests to verify max active threads, queue saturation behavior, and
exception handling in DistributedLockService inside the async method.
scripts/insert-dummy-data.sql-247-264 (1)

247-264: ⚠️ Potential issue | 🟠 Major

쿠폰 발급 수 불변식이 깨진다.

max_issue_count를 생성한 뒤 issued_count를 별도로 0..499에서 다시 뽑고 있어서, max_issue_count가 100~499인 행에서는 issued_count > max_issue_count가 쉽게 나온다. 운영 관점에서는 발급 한도 초과 상태가 기본 데이터처럼 섞여 들어가서 쿠폰 발급/사용 API와 배치 검증이 왜 실패했는지 구분하기 어려워진다.

수정안으로는 max_issue_count를 먼저 지역 변수에 담고, 값이 있을 때는 issued_count0..max_issue_count 범위에서만 생성해야 한다. 추가 테스트로는 적재 후 max_issue_count IS NOT NULL AND issued_count > max_issue_count 조건의 건수가 0인지 검증해야 한다.

Also applies to: 272-293

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

In `@scripts/insert-dummy-data.sql` around lines 247 - 264, The INSERT currently
generates max_issue_count with IF(RAND() < 0.3, NULL, FLOOR(100 + RAND() *
9900)) and separately picks issued_count as FLOOR(RAND() * 500), which can
produce issued_count > max_issue_count; change the INSERT construction so
max_issue_count is computed first into a local/generated variable and then
issued_count is derived conditionally: if max_issue_count IS NULL then pick a
standalone random issued_count, otherwise pick issued_count = FLOOR(RAND() *
(max_issue_count + 1)) (or similar) to guarantee issued_count <=
max_issue_count; apply the same fix to the second INSERT block referenced (lines
~272-293) and add a post-load check that counts rows WHERE max_issue_count IS
NOT NULL AND issued_count > max_issue_count equals 0.
scripts/insert-dummy-data.sql-10-12 (1)

10-12: ⚠️ Potential issue | 🟠 Major

비어 있지 않은 DB에서는 관계가 조용히 깨진다.

brand_id1..5000으로 고정하고, user_idproduct_id도 연속 PK를 가정해 뽑고 있다. 여기에 FK 체크까지 꺼져 있어서 기존 데이터가 있거나 ID gap이 있는 DB에서는 신규 상품/좋아요/주문이 기존 행에 붙거나 고아 레코드가 그대로 적재된다. 운영 관점에서는 이 데이터셋으로 수행한 성능·동시성·기능 검증 결과를 신뢰할 수 없게 된다.

수정안으로는 실제로 이번 스크립트가 삽입한 사용자/브랜드/상품 ID 집합을 임시 테이블에 저장해 그 집합에서만 참조하도록 바꾸는 편이 안전하다. 이 수준까지 바꾸기 어렵다면 최소한 시작 전에 대상 테이블이 비어 있지 않으면 즉시 중단해야 한다. 추가 테스트로는 선행 데이터가 1건 이상 있는 상태에서 스크립트를 실행한 뒤, LEFT JOIN으로 고아 FK가 0건인지와 신규 레코드가 의도한 부모 집합만 참조하는지 확인해야 한다.

Also applies to: 121-124, 167-170, 190-198, 337-339, 376-382

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

In `@scripts/insert-dummy-data.sql` around lines 10 - 12, The script disables
FK/unique checks (SET FOREIGN_KEY_CHECKS, SET autocommit, SET unique_checks)
which allows silent corruption when the DB already contains rows; change the
script to first verify target tables are empty and abort if any have rows, or
(preferably) capture the exact IDs the script inserts into temporary tables
(e.g., temp_inserted_users, temp_inserted_brands, temp_inserted_products) and
use those temp tables as the only valid parent sets for subsequent
INSERTs/LIKE/ORDER rows instead of hard-coding id ranges; restore and enforce
FK/unique checks and commit only after verification, and add a post-run LEFT
JOIN check to assert there are zero orphaned FK references before finishing.
apps/commerce-api/src/main/java/com/loopers/infrastructure/init/DataInitializer.java-146-163 (1)

146-163: ⚠️ Potential issue | 🟠 Major

좋아요 seed가 like_count 동기화 경로를 완전히 우회한다.

여기서는 likes row만 저장하고 products.like_count, Redis count, dirty set을 전혀 갱신하지 않아서 배치 sync도 이 데이터를 따라잡지 못할 가능성이 크다. 시드 종료 후 products.like_count를 한 번 backfill하거나, 초기화도 실제 애플리케이션 경로를 통해 캐시/dirty set을 함께 갱신하도록 바꿔 달라. 추가로 초기화 직후 상품 상세/목록 API에서 seed된 인기 상품의 likeCount가 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/infrastructure/init/DataInitializer.java`
around lines 146 - 163, The createLikes method currently writes only Like rows
(via Like.create and likeRepository.save) and does not update
products.like_count, the Redis counters, or the dirty set, which breaks sync;
modify DataInitializer.createLikes to either call the application's like
creation flow (e.g., the service method that handles likes so it updates DB,
increments Redis counters and marks the product in the dirty set) or, if doing a
direct seed, perform a backfill after seeding that updates Product.like_count
and the Redis counts and adds product IDs to the dirty set so the batch sync
sees them; finally, add an integration test that seeds data and then calls the
product detail/list API to assert the returned likeCount for seeded popular
products is > 0.
apps/commerce-api/src/main/java/com/loopers/infrastructure/init/DataInitializer.java-54-68 (1)

54-68: ⚠️ Potential issue | 🟠 Major

초기화 전체를 단일 트랜잭션과 영속성 컨텍스트에 묶으면 로컬 부팅이 막힌다.

지금 구조는 20만 상품, 대량 likes, 5만 쿠폰을 한 번에 붙잡아서 메모리 사용량·redo log·rollback 비용이 모두 커진다. 배치를 1k~5k 단위로 끊고 각 배치마다 별도 트랜잭션과 flush/clear를 적용해 달라. 추가로 축소된 데이터셋과 실제 목표 데이터셋 각각에 대해 초기화 완료 시간과 peak memory를 확인하는 통합 테스트나 수동 벤치마크를 남겨 달라.

🤖 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/init/DataInitializer.java`
around lines 54 - 68, The DataInitializer.run currently performs full dataset
creation (createUsers, createBrands, createProducts, createLikes, createCoupons)
inside a single `@Transactional` method causing huge memory/redo/rollback cost;
refactor so each large insert sequence (especially createProducts and
createLikes/createCoupons) is split into batches of ~1k–5k records and each
batch is committed in its own transaction (create batch helper methods and call
them from run), and after persisting each batch call EntityManager.flush() and
EntityManager.clear() to release the persistence context; also add a small
integration test or manual benchmark that runs the initializer against a reduced
dataset and the target dataset recording completion time and peak memory to
validate performance and stability.
scripts/migration/V001__add_like_count_to_products.sql-9-14 (1)

9-14: ⚠️ Potential issue | 🟠 Major

좋아요 백필을 상관 서브쿼리로 두면 마이그레이션 시간이 급격히 늘 수 있다.

현재 쿼리는 상품 수만큼 likes 집계를 반복할 수 있어서 대량 데이터에서 배포 시간이 길어지고 DDL 이후 lock 유지 시간이 늘어난다. likes를 한 번만 GROUP BY product_id로 집계한 뒤 JOIN UPDATE로 바꾸는 편이 안전하다. 추가로 운영 데이터 크기에 가까운 샘플에서 EXPLAIN과 dry-run을 돌려 likes.product_id 인덱스를 타는지 확인해 달라.

수정 예시
-UPDATE products p
-SET p.like_count = (
-    SELECT COUNT(*)
-    FROM likes l
-    WHERE l.product_id = p.id
-);
+UPDATE products p
+LEFT JOIN (
+    SELECT l.product_id, COUNT(*) AS like_count
+    FROM likes l
+    GROUP BY l.product_id
+) lc ON lc.product_id = p.id
+SET p.like_count = COALESCE(lc.like_count, 0);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/migration/V001__add_like_count_to_products.sql` around lines 9 - 14,
The current UPDATE uses a correlated subquery over products which will
scan/aggregate likes per product and slow migration; instead compute likes per
product once with SELECT product_id, COUNT(*) FROM likes GROUP BY product_id and
then perform an UPDATE products p JOIN (that aggregate) a ON a.product_id = p.id
SET p.like_count = a.count (and handle zeroes for products with no likes),
replacing the correlated subquery in V001__add_like_count_to_products.sql; after
changing, run EXPLAIN and a dry-run on a production-sized sample to confirm the
query uses the likes.product_id index and that locks/time are acceptable.
scripts/migration/V001__add_like_count_to_products.sql-18-18 (1)

18-18: ⚠️ Potential issue | 🟠 Major

브랜드별 좋아요 정렬 인덱스가 실제 정렬 조건을 끝까지 덮지 못한다.

브랜드 필터 목록이 like_count DESC, created_at DESC로 정렬되면 현재 인덱스로는 동률 구간에서 filesort가 남아 페이지 조회 때마다 DB 부하가 커진다. 인덱스를 (brand_id, like_count DESC, created_at DESC)로 확장해 정렬과 페이징을 같이 태우는 편이 안전하다. 추가로 해당 조회의 EXPLAIN ANALYZE를 붙여 Using filesort가 사라지는지 확인해 달라.

수정 예시
-CREATE INDEX idx_products_brand_like ON products (brand_id, like_count DESC);
+CREATE INDEX idx_products_brand_like ON products (brand_id, like_count DESC, created_at DESC);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/migration/V001__add_like_count_to_products.sql` at line 18, 인덱스
idx_products_brand_like가 like_count DESC까지만 포함해 동률 구간에서 filesort가 발생하므로 products
테이블의 인덱스를 (brand_id, like_count DESC, created_at DESC)로 확장해 생성하도록 변경하고, 변경 후 해당
쿼리에 대해 EXPLAIN ANALYZE를 실행해 "Using filesort"가 사라졌는지 확인해 결과를 첨부하세요; 참고로 수정 대상은 기존
CREATE INDEX idx_products_brand_like ON products (brand_id, like_count DESC) 선언을
대체하는 인덱스 생성문이며, 쿼리 정렬 키인 brand_id, like_count, created_at 순서를 유지해야 합니다.
http/like.http-21-22 (1)

21-22: ⚠️ Potential issue | 🟠 Major

HTTP 예제의 정렬 파라미터값이 실제 enum과 불일치하여 수정이 필수다.

HTTP 문서의 sort=LIKE_COUNT_DESC는 실제 enum 상수 LIKES_DESC와 다르다. 이 불일치로 인해 예제를 그대로 실행하면 400 Bad Request가 발생한다. ProductSort enum과 API 명세에 정의된 LIKES_DESC로 예제를 수정해야 한다. 또한 HTTP 예제 파일의 각 요청을 실행하는 자동 스모크 테스트를 추가하여 문서와 코드의 동기화를 보장해야 한다.

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

In `@http/like.http` around lines 21 - 22, The HTTP example uses
sort=LIKE_COUNT_DESC which doesn't match the ProductSort enum constant
LIKES_DESC; update the request example in the like.http snippet to use
sort=LIKES_DESC so it matches the ProductSort enum and avoids 400s, and add an
automated smoke test that executes each request in the like.http file (or its
HTTP collection runner) to validate examples against the running API on CI;
locate the example by the GET request line and the ProductSort enum/constant
names (ProductSort, LIKES_DESC) to make the change and add the smoke-test job
that fails the build if any example returns a non-2xx response.
apps/commerce-batch/src/test/java/com/loopers/config/TestRedisConfiguration.java-20-37 (1)

20-37: ⚠️ Potential issue | 🟠 Major

패턴 기반 정리로는 Redis 상태 누수를 완전히 방지할 수 없다.

withReuse(true)로 인해 컨테이너가 재사용되고 고정된 DB 0을 여러 테스트 클래스가 공유하는 구조는 문제가 맞다. 일부 테스트 클래스(LikeCountSyncJobE2ETest, ProductLikeCountQueryServiceTest, LikeCountCacheServiceTest, DistributedLockServiceTest)에서 @BeforeEach/@AfterEach로 정리를 수행하지만, 모두 패턴 기반 정리(redisTemplate.keys("like:*")delete())를 사용한다. 이는 다음 문제들을 야기한다:

  • 각 테스트가 정의한 특정 패턴의 키만 정리되므로 다른 패턴의 고아 키나 TTL 만료되지 않은 키가 남음
  • redisTemplate.keys() + delete()는 원자적이지 않아 동시 테스트 환경에서 race 발생 가능
  • CommerceBatchApplicationTest는 TestRedisConfiguration을 사용하지만 정리 로직이 전혀 없음
  • 재사용 컨테이너는 로컬 머신의 이전 실행 결과까지 유지하므로 cross-run 오염 가능

withReuse(false)로 변경하거나, 패턴 기반이 아닌 FLUSHDB 명령으로 DB 0 전체를 매 테스트마다 비워야 한다. 현재 방식은 격리를 보장하지 못하므로 캐시·분산락 테스트의 순서 의존성 문제를 완전히 해결할 수 없다.

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

In
`@apps/commerce-batch/src/test/java/com/loopers/config/TestRedisConfiguration.java`
around lines 20 - 37, The reuse flag on the test Redis container enables
cross-test and cross-run state leakage; change REDIS_CONTAINER.withReuse(true)
to withReuse(false) so each test run gets a fresh container, and add a global DB
flush step that runs before each test (e.g., a shared test base or
TestExecutionListener used by tests like LikeCountSyncJobE2ETest,
ProductLikeCountQueryServiceTest, LikeCountCacheServiceTest,
DistributedLockServiceTest and CommerceBatchApplicationTest) that invokes
FLUSHDB against the container (use RedisTemplate or direct connection to
REDIS_CONTAINER host/port) to atomically clear DB 0 instead of pattern-based
deletion; keep the testRedisProperties method and RedisProperties usage
unchanged while ensuring the cleanup runs against
REDIS_CONTAINER.getHost()/getMappedPort(REDIS_PORT).
scripts/truncate-tables.sql-5-17 (1)

5-17: ⚠️ Potential issue | 🟠 Major

외래키 체크 복구가 실패 경로에서 보장되지 않는다.

운영 관점에서 중간 TRUNCATE 실패 시 FOREIGN_KEY_CHECKS 복구가 누락되면 동일 세션 후속 작업에서 무결성 붕괴가 발생할 수 있다.
수정안은 기존 값을 저장 후 복구하도록 변경하고, 이 스크립트를 반드시 전용 세션에서 실행하도록 명시하는 것이다.
추가 테스트로 의도적으로 존재하지 않는 테이블 TRUNCATE를 끼워 넣어 실패시킨 뒤 SELECT @@FOREIGN_KEY_CHECKS가 원복되는지 검증하는 실행 시나리오 테스트가 필요하다.

수정 예시
-SET FOREIGN_KEY_CHECKS = 0;
+SET `@OLD_FOREIGN_KEY_CHECKS` = @@FOREIGN_KEY_CHECKS;
+SET FOREIGN_KEY_CHECKS = 0;
 ...
-SET FOREIGN_KEY_CHECKS = 1;
+SET FOREIGN_KEY_CHECKS = `@OLD_FOREIGN_KEY_CHECKS`;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/truncate-tables.sql` around lines 5 - 17, Save the current
FOREIGN_KEY_CHECKS value into a user variable before disabling it, perform the
TRUNCATEs, and always restore the original value at the end (even on error) by
using a guarded restore pattern (store @@FOREIGN_KEY_CHECKS into e.g.
`@old_fk_checks`, SET FOREIGN_KEY_CHECKS=0, run TRUNCATE TABLE
order_items/.../users, then SET FOREIGN_KEY_CHECKS=@old_fk_checks); document
that this script must be executed in a dedicated session and add a test scenario
that injects an invalid TRUNCATE (nonexistent table) to verify SELECT
@@FOREIGN_KEY_CHECKS returns the original value after failure.
apps/commerce-batch/src/main/java/com/loopers/batch/cache/LikeCountBatchCacheService.java-57-64 (1)

57-64: ⚠️ Potential issue | 🟠 Major

dirty ID 파싱 실패가 전체 동기화를 무효화한다.

운영 관점에서 Line 59에서 숫자 파싱 하나만 실패해도 Line 64로 빠져 빈 집합을 반환하므로 해당 배치 주기의 전체 동기화가 건너뛰어져 캐시-DB 불일치가 장시간 지속될 수 있다.
수정안은 멤버 단위로 파싱 예외를 격리해 잘못된 값만 건너뛰고 유효한 ID는 계속 처리하는 것이다.
추가 테스트로 dirty set에 ["101", "bad", "102"]가 있을 때 결과가 {101, 102}인지 검증하는 테스트가 필요하다.

수정 예시
         Set<Long> result = new HashSet<>();
         for (String member : members) {
-            result.add(Long.parseLong(member));
+            try {
+                result.add(Long.parseLong(member));
+            } catch (NumberFormatException nfe) {
+                log.warn("dirty productId 파싱 실패: member={}", member);
+            }
         }
         return result;

As per coding guidelines "**/*Service*.java: 외부 호출에는 ... 실패 시 대체 흐름을 제안한다."

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

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/cache/LikeCountBatchCacheService.java`
around lines 57 - 64, In LikeCountBatchCacheService (the method iterating over
members to build result), avoid letting a single NumberFormatException abort the
whole conversion: change the loop to parse each String member individually,
catching and logging parse errors per-member (e.g., log.warn with the bad value
and exception) and skip only the invalid entries while still adding valid Longs
to result; ensure the method returns the set of successfully parsed IDs instead
of an empty set on any parse failure; add a unit test asserting that input
["101","bad","102"] yields {101,102}.
apps/commerce-batch/src/main/java/com/loopers/batch/job/likesync/step/LikeCountSyncTasklet.java-50-66 (1)

50-66: ⚠️ Potential issue | 🟠 Major

부분 실패를 삼키면 배치 모니터링이 무력화된다

개별 상품 동기화 예외를 로그만 남기고 계속 진행하면, 실제로는 일부 상품이 갱신되지 않았어도 Step이 정상 종료로 기록될 가능성이 높다. 운영에서는 like_count 드리프트가 누적돼도 배치 실패로 감지되지 않아 재처리와 알람이 모두 늦어진다. 실패 건이 하나라도 있으면 최종적으로 예외를 다시 던지거나 custom exit status를 내려 모니터링과 재시도가 걸리도록 바꾸는 편이 낫다. 추가로 dirty 상품 하나에서 예외를 강제로 발생시키고 Job exit code가 COMPLETED가 아님을 검증하는 테스트를 넣어야 한다. As per coding guidelines, "예외 흐름이 명확한지 점검한다."

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

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/likesync/step/LikeCountSyncTasklet.java`
around lines 50 - 66, The loop currently swallows per-product exceptions
(syncProductLikeCount) and always returns RepeatStatus.FINISHED, which hides
partial failures; change the logic so that if any iteration fails (failCount >
0) you either rethrow an exception after the loop or set the Step/Job exit
status to a non-COMPLETED value (e.g.,
contribution.setExitStatus(ExitStatus.FAILED) or throw new RuntimeException with
context) so monitoring/retry triggers; update references around dirtyProductIds
loop, syncProductLikeCount, contribution.incrementWriteCount and the final
return (RepeatStatus.FINISHED) accordingly, and add a unit/integration test that
forces syncProductLikeCount to throw for one dirty product and asserts the
Job/Step exit code is not COMPLETED.
scripts/benchmark/run-benchmark.sh-6-6 (1)

6-6: ⚠️ Potential issue | 🟠 Major

파이프라인 실패가 가려져도 스크립트가 성공으로 끝난다

docker exec ... | teepipefail이 없으면 tee의 종료 코드만 반환한다. MySQL 인증이나 프로시저 실행이 실패해도 마지막에 완료 메시지를 찍어 잘못된 리포트를 정상 결과처럼 남길 수 있다. set -euo pipefail로 바꾸고 실패 시 완료 배너를 출력하지 않도록 처리해야 한다. 추가로 잘못된 비밀번호나 존재하지 않는 프로시저로 실행했을 때 종료 코드가 0이 아니고 완료 메시지가 출력되지 않는 테스트를 해 두는 편이 안전하다.

예시 수정안이다
-set -e
+set -euo pipefail

Also applies to: 37-42

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

In `@scripts/benchmark/run-benchmark.sh` at line 6, The script currently uses only
"set -e" so pipeline failures (e.g., the "docker exec ... | tee" that runs
MySQL/procedure) can be masked by tee returning 0; change the shell options to
"set -euo pipefail" and update the run-benchmark.sh flow so the completion
banner/finished message is only printed when the critical commands exit
successfully (check the exit status of the docker exec / mysql invocation and
bail out before printing the banner). Locate the pipeline invocation
(references: the docker exec ... | tee line) and surrounding banner/echo logic
and add explicit error handling/early return so failures (bad password or
missing procedure) propagate and the banner is not shown; also add simple
CI/test commands that run with an invalid password and with a non-existent
procedure to verify non-zero exit codes.
scripts/benchmark/index-performance-test.sql-238-241 (1)

238-241: ⚠️ Potential issue | 🟠 Major

벤치마크 인덱스가 실제 조회 조건을 반영하지 못한다

모든 측정 쿼리에 deleted_at IS NULL이 들어가는데 인덱스에는 빠져 있어서, 소프트 삭제 데이터가 쌓인 운영 상태의 실행 계획을 재현하지 못한다. 이 상태로 나온 수치는 실제 서비스와 다른 인덱스 결정을 유도할 수 있다. deleted_at을 포함한 복합 인덱스로 정의를 맞추고, 실제 마이그레이션 정의가 있다면 이 스크립트도 동일 정의를 재사용하도록 맞추는 편이 안전하다. 추가로 삭제 데이터가 일정 비율 섞인 데이터셋에서 EXPLAIN과 벤치마크를 다시 돌려 의도한 인덱스가 선택되는지 확인하는 테스트가 필요하다.

예시 수정안이다
-    CREATE INDEX idx_products_like_count ON products (like_count DESC, created_at DESC);
-    CREATE INDEX idx_products_brand_id ON products (brand_id);
-    CREATE INDEX idx_products_brand_like ON products (brand_id, like_count DESC);
-    CREATE INDEX idx_products_brand_price ON products (brand_id, price ASC);
+    CREATE INDEX idx_products_like_count ON products (deleted_at, like_count DESC, created_at DESC);
+    CREATE INDEX idx_products_brand_id ON products (brand_id, deleted_at);
+    CREATE INDEX idx_products_brand_like ON products (brand_id, deleted_at, like_count DESC);
+    CREATE INDEX idx_products_brand_price ON products (brand_id, deleted_at, price ASC);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/benchmark/index-performance-test.sql` around lines 238 - 241, The
benchmark index definitions (idx_products_like_count, idx_products_brand_id,
idx_products_brand_like, idx_products_brand_price) omit the deleted_at predicate
while all test queries include "deleted_at IS NULL", so the benchmark doesn't
reproduce real plan choices; update those CREATE INDEX statements to include
deleted_at (e.g., as the leading or trailing column to match real query
predicate and sort needs), align them with the actual migration/index
definitions used in production (reuse the same composite definitions if
available), and then re-run EXPLAIN and the performance tests on a dataset with
a realistic fraction of soft-deleted rows to verify the intended index is
chosen.
apps/commerce-batch/src/main/java/com/loopers/batch/job/likesync/step/LikeCountSyncTasklet.java-74-80 (1)

74-80: ⚠️ Potential issue | 🟠 Major

없는 상품은 active dirty set에서 정리해야 한다

updateLikeCount()가 0을 반환하는 경우는 재시도해도 성공할 가능성이 낮은데, 현재는 경고 로그만 남기고 dirty 플래그를 유지한다. 운영에서는 동일한 productId를 매 배치마다 다시 세고 다시 경고하게 되어 불필요한 부하와 로그 노이즈가 계속 쌓인다. 해당 ID는 active dirty set에서 제거하고, 필요하면 별도 reconciliation/dead-letter set으로 보내 원인만 추적하는 방식이 안전하다. 추가로 DB에 없는 productId를 dirty set에 넣고 한 번 실행한 뒤 active dirty set에서 빠졌는지 검증하는 테스트를 추가해야 한다.

예시 수정안이다
         if (updated > 0) {
             likeCountCacheService.set(productId, actualCount);
             likeCountCacheService.removeDirty(productId);
             log.debug("상품 좋아요 수 동기화: productId={}, count={}", productId, actualCount);
         } else {
+            likeCountCacheService.removeDirty(productId);
             log.warn("상품이 존재하지 않음: productId={}", productId);
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/likesync/step/LikeCountSyncTasklet.java`
around lines 74 - 80, The else-branch in LikeCountSyncTasklet (where
updateLikeCount(...) returns 0) currently only logs a warning and leaves the ID
in the active dirty set; change it to remove the ID from the active dirty set by
calling likeCountCacheService.removeDirty(productId) and, if your cache supports
it, move the ID to a reconciliation/dead-letter set (e.g.,
likeCountCacheService.moveToDeadLetter(productId) or
likeCountCacheService.addDeadLetter(productId)) so it won’t be retried every
batch; also add a unit/integration test asserting that when updateLikeCount
returns 0 the productId is removed from the active dirty set (and optionally
placed into the dead-letter set) to verify the behavior.
apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/DistributedLockServiceTest.java-215-217 (1)

215-217: ⚠️ Potential issue | 🟠 Major

동시성 테스트 대기에는 타임아웃이 필요하다

Line 215의 endLatch.await()는 실패 시 무기한 대기하여 파이프라인을 멈출 수 있다. 이는 장애 분석 시간을 크게 늘린다.
수정안으로 await(timeout, unit)로 제한하고 실패 시 즉시 단정 실패 처리하며, 종료 경로에서 shutdownNow()까지 보강하는 것을 권장한다.
추가 테스트로 의도적으로 한 작업을 지연시켜 타임아웃이 정상적으로 실패를 보고하는지 검증해야 한다.

제안 diff
-            endLatch.await();
-            executor.shutdown();
+            boolean completed = endLatch.await(5, java.util.concurrent.TimeUnit.SECONDS);
+            assertThat(completed).isTrue();
+            executor.shutdownNow();
🤖 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/infrastructure/cache/DistributedLockServiceTest.java`
around lines 215 - 217, Replace the unbounded wait at endLatch.await() in
DistributedLockServiceTest with a bounded wait using endLatch.await(timeout,
unit) and assert that it returns true (fail the test immediately if it returns
false); also strengthen the teardown by calling executor.shutdownNow() after
executor.shutdown() when the await fails or in finally cleanup to ensure threads
are terminated; update the test case(s) so one task is artificially delayed
(e.g., sleep) to verify the timeout path reports failure as expected.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java-27-30 (1)

27-30: ⚠️ Potential issue | 🟠 Major

sort 파라미터 명칭을 명확히 하고 에러 메시지에 가능한 enum 값을 제시해야 한다

Line 27의 @RequestParam(required = false) ProductSort sort는 ApiControllerAdvice의 MethodArgumentTypeMismatchException 핸들러에 의해 통합 에러 포맷으로 처리되므로, 포맷 일관성 문제는 없다. 다만 두 가지 개선이 필요하다.

  1. 에러 메시지 품질: 현재 MethodArgumentTypeMismatchException 핸들러(라인 33-39)는 잘못된 enum 값에 대해 "값 'INVALID'이(가) 잘못되었습니다"만 반환하며, 사용 가능한 값(LATEST, PRICE_ASC, LIKES_DESC)을 제시하지 않는다. JSON 본문의 enum 에러는 가능한 값을 안내하는데(라인 54-66), 쿼리 파라미터 에러는 그렇지 않아 일관성이 떨어진다. MethodArgumentTypeMismatchException 핸들러를 개선하여 enum 타입일 경우 가능한 값을 함께 제시하도록 수정해야 한다.

  2. 파라미터 이름 충돌: 현재 sort 파라미터명은 Pageable의 기본 sort 파라미터와 명칭이 겹쳐 향후 혼선 가능성이 있다. productSort로 변경하여 명시적으로 분리하는 것이 운영상 안전하다.

추가 테스트로 ?sort=INVALID 요청 시 개선된 에러 메시지가 제공되는지, 그리고 ?sort=LATEST&page=0&sort=id,desc 처럼 파라미터명이 겹칠 때 동작이 정상인지 확인해야 한다.

🤖 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/interfaces/api/product/ProductV1Controller.java`
around lines 27 - 30, Rename the query parameter in ProductV1Controller from
sort to productSort (update the `@RequestParam` name and the call to
productService.findAll(brandId, sort, pageable) accordingly to pass the new
ProductSort productSort) to avoid collision with Pageable.sort, and update the
MethodArgumentTypeMismatchException handler in ApiControllerAdvice so that when
the exception parameter type is an enum (e.g., ProductSort.class) it appends the
list of valid enum values (ProductSort.values()) to the error message (instead
of just "값 'X'이(가) 잘못되었습니다") so the response for invalid query values like
?productSort=INVALID returns the possible values; add/adjust tests to assert
?productSort=INVALID returns the enriched message and that
?productSort=LATEST&page=0&sort=id,desc behaves correctly.
apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/DistributedLockServiceTest.java-86-93 (1)

86-93: ⚠️ Potential issue | 🟠 Major

Thread.sleep 기반 TTL 검증과 무제한 await는 테스트 신뢰도를 해친다

Line 92의 고정 대기(150ms)는 Redis TTL 만료 시점의 스케줄링 편차를 고려하지 않아 CI 부하 상황에서 간헐 실패를 야기한다. 락 만료 판정 신뢰도가 떨어진다.

Line 215의 endLatch.await()는 타임아웃 없이 호출되어, 스레드가 hang되거나 예외가 발생할 경우 파이프라인이 무한 정지된다.

수정안:

  • Line 92: 고정 sleep 제거, 일정 시간 내 짧은 간격 폴링(Awaitility 라이브러리 권장)으로 만료 시점을 안정적으로 확인한다.
  • Line 215: endLatch.await(10, TimeUnit.SECONDS) 같이 적절한 타임아웃을 추가한다.
  • 수정 후 동시성 테스트를 30~50회 반복 실행해 실패율이 0에 수렴하는지 검증한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/DistributedLockServiceTest.java`
around lines 86 - 93, In the 락_만료후_재획득 test, remove the fixed Thread.sleep(150)
and replace it with a short-interval polling/await (use Awaitility or
equivalent) that repeatedly checks until the first lock TTL has expired and
tryLock becomes possible within a bounded timeout; target a small retry interval
and an overall timeout to tolerate scheduling variance. Also update the
unbounded endLatch.await() call to use a bounded wait like endLatch.await(10,
TimeUnit.SECONDS) to avoid indefinite hangs. After making these changes (methods
referenced: 락_만료후_재획득 and the endLatch.await call), run the concurrency test
30–50 times to ensure failure rate approaches zero.
apps/commerce-api/src/test/java/com/loopers/config/TestRedisConfiguration.java-17-23 (1)

17-23: ⚠️ Potential issue | 🟠 Major

Redis 테스트 컨테이너의 부동 태그와 불완전한 cleanup 전략이 로컬 테스트 재현성을 떨어뜨린다

redis:7.0 부동 태그는 현재 7.0.15를 가리키지만 향후 변경될 수 있어 테스트 재현성을 약화시킨다. 패치 버전(redis:7.0.15)으로 고정하거나 digest로 pin하도록 변경한다.

withReuse(true)는 ~/.testcontainers.properties에 testcontainers.reuse.enable=true를 설정한 경우에만 로컬 개발 환경에서 작동한다. 현재 CI 환경에서는 재사용이 비활성화되어 있으나, 개발자 로컬 환경에서 재사용되는 경우 Redis 데이터가 유지되며 테스트 간 격리가 약해진다. 로컬 재사용을 피하려면 withReuse(true) 제거를 권장한다.

현재 테스트들이 lock:* 또는 {product:}:like: 등 특정 프리픽스만 정소하는 부분적 cleanup을 수행하므로, 다른 테스트에서 생성한 키가 남아 교차 오염을 유발할 수 있다. 모든 테스트 클래스에서 @BeforeEach/@AfterEach에 RedisCleanUp.truncateAll()을 호출하여 Redis 전체를 초기화하도록 통일한다.

🤖 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/config/TestRedisConfiguration.java`
around lines 17 - 23, TestRedisConfiguration에서 사용중인 REDIS_IMAGE을 부동 태그 대신 패치
버전(예: "redis:7.0.15" 또는 이미지 digest)로 고정하고, REDIS_CONTAINER 인스턴스에서
withReuse(true)를 제거하여 로컬 컨테이너 재사용으로 인한 상태 유지 문제를 방지하세요; 또한 테스트 간 완전한 격리를 위해 모든
관련 테스트 클래스의 `@BeforeEach` 또는 `@AfterEach` 훅에서 RedisCleanUp.truncateAll()을 호출하도록
통일(혹은 공통 테스트 베이스 클래스에 추가)해 모든 키를 초기화하도록 하세요 (참조 심볼: REDIS_IMAGE,
REDIS_CONTAINER, withReuse, RedisCleanUp.truncateAll, TestRedisConfiguration).
apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductLikeCountQueryServiceTest.java-148-181 (1)

148-181: ⚠️ Potential issue | 🟠 Major

테스트가 단일 DB 조회를 실제로 검증하지 않는다

현재 assertion은 모든 스레드가 끝났는지와 캐시 저장 여부만 보므로, 구현이 회귀되어도 여러 스레드가 DB를 동시에 조회하는 상황을 잡지 못한다. LikeRepository.countByProductId()를 spy/mock 해서 호출 횟수를 1회로 단언하고, endLatch.await()에는 timeout을 넣어 교착 시 CI가 무한 대기하지 않게 해 달라. 느린 DB 응답 케이스도 함께 추가해 재시도 budget 초과 시나리오를 막는 편이 안전하다. 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/infrastructure/cache/ProductLikeCountQueryServiceTest.java`
around lines 148 - 181, The test currently only asserts thread completion and
cache presence but doesn't verify single DB lookup; update
ProductLikeCountQueryServiceTest to spy/mock LikeRepository (or inject a test
spy) and assert LikeRepository.countByProductId(product.getId()) is invoked
exactly once when calling productLikeCountQueryService.getLikeCount
concurrently, add a timeout to endLatch.await(...) to avoid CI hang, and add an
additional test case that simulates a slow DB response (e.g., delay in the
mocked countByProductId) to validate retry/budget behavior and ensure the
single-flight logic still prevents multiple DB calls.
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductLikeCountQueryService.java-30-32 (1)

30-32: ⚠️ Potential issue | 🟠 Major

현재 재시도 budget으로는 느린 DB에서 herd가 다시 열린다

현재 budget은 최대 150ms뿐이라 DB count 조회가 그보다 조금만 느려도 대기 중 요청들이 동시에 countByProductId()로 떨어져, 부하 순간에 보호해야 할 DB를 다시 몰아치게 된다. 재시도 소진 후에는 캐시가 채워질 때까지 더 기다리거나 락 재시도로 단일 조회를 유지하고, 최소한 fallback 결과도 캐시에 기록해 연쇄 miss를 끊어야 한다. DB 조회를 500ms 이상 지연시킨 동시성 테스트를 추가해, 동시에 들어온 요청에서도 DB 호출 수가 1회로 유지되는지 확인해 달라. As per coding guidelines "**/*Service*.java: 외부 호출에는 타임아웃/재시도/서킷브레이커 고려 여부를 점검한다.`"

Also applies to: 143-161

🤖 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/ProductLikeCountQueryService.java`
around lines 30 - 32, The retry budget is too small (LOCK_TTL, RETRY_DELAY,
MAX_RETRIES) causing herd to reopen under slow DB; increase the retry window
(e.g., raise MAX_RETRIES and RETRY_DELAY or extend LOCK_TTL) and change the
lock/fallback logic in ProductLikeCountQueryService (the methods that acquire
the lock and call countByProductId()) so that when retries are exhausted you
either wait for the cache to be populated or re-attempt a lock-acquire loop that
keeps a single in-flight DB call, and always write a safe fallback value into
the cache on failure to break chain misses; add a concurrency integration test
that simulates DB countByProductId() taking >=500ms and asserts only one DB
invocation for many concurrent requests to verify herd protection.
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductLikeCountQueryService.java-66-69 (1)

66-69: ⚠️ Potential issue | 🟠 Major

@Async가 같은 빈 내부 호출로 인해 적용되지 않는다

refreshInBackground() 메서드는 @Async 어노테이션이 있지만, 66-69번 줄에서 같은 빈 내부로 직접 호출되므로 Spring AOP 프록시를 거치지 않아 동기 실행된다. 캐시가 TTL 만료(PER) 상태인 hit 요청은 stale 값을 즉시 반환해야 하는데, 현재는 DB count 조회를 호출 스레드에서 동기 대기하게 된다. 운영 환경에서 DB 응답이 지연되면 조회 API의 지연이 급격히 악화된다.

refreshInBackground()를 별도 빈으로 분리해 외부 호출하거나, ApplicationEventPublisher 또는 TaskExecutor를 사용해 명시적으로 비동기 실행하도록 수정한다. getLikeCount()가 호출 스레드에서 즉시 반환되고 별도 스레드에서 캐시가 갱신되는지 검증하는 통합 테스트를 추가한다.

해당 코드 위치
// 66-69번 줄: 같은 빈 내부 직접 호출
if (result.shouldRefresh()) {
    refreshInBackground(productId);
}

// 168-188번 줄: `@Async` 메서드 정의
`@Async`
public void refreshInBackground(Long productId) {
    // ...
}
🤖 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/ProductLikeCountQueryService.java`
around lines 66 - 69, ProductLikeCountQueryService calls its own `@Async` method
refreshInBackground(...) directly, so Spring proxying is bypassed and the
refresh runs synchronously; fix by moving the async work into a separate bean
(e.g., ProductLikeCountRefresher.refreshInBackground(Long)) or invoke refresh
via an injected TaskExecutor/ApplicationEventPublisher so the call goes through
Spring and executes on a different thread; update getLikeCount(...) to submit
the refresh task (executor.execute(...) or publisher.publishEvent(...)) instead
of calling refreshInBackground(...) directly, keep the `@Async` or executor-based
implementation on the new bean/method, and add an integration test that asserts
getLikeCount(...) returns immediately for a PER (TTL-expired) cache hit while
the DB refresh happens asynchronously (e.g., use a CountDownLatch or test double
to verify background refresh invocation).
🟡 Minor comments (3)
apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaEntity.java-110-112 (1)

110-112: ⚠️ Potential issue | 🟡 Minor

likeCount setter에 무결성 가드가 필요하다.

운영 관점에서 Line 110의 setter가 null 또는 음수를 허용하면 flush 시점 제약 위반이나 비정상 정렬(좋아요 음수)로 이어져 배치/조회 흐름 장애를 유발할 수 있다.
수정안은 setter에서 null/음수 입력을 즉시 차단하여 엔티티 불변식을 유지하는 것이다.
추가 테스트로 setLikeCount(null)setLikeCount(-1L) 호출 시 예외가 발생하는 단위 테스트를 추가하는 것이 필요하다.

수정 예시
 public void setLikeCount(Long likeCount) {
-    this.likeCount = likeCount;
+    if (likeCount == null || likeCount < 0) {
+        throw new IllegalArgumentException("likeCount must be non-null and >= 0");
+    }
+    this.likeCount = likeCount;
 }

As per coding guidelines "**/*.java: null 처리, 방어적 복사, 불변성 ... 점검한다."

🤖 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/persistence/jpa/product/ProductJpaEntity.java`
around lines 110 - 112, In ProductJpaEntity, the setter method setLikeCount
should enforce invariants by rejecting null and negative values: update
setLikeCount(Long likeCount) to validate the input and throw an unchecked
exception (e.g., IllegalArgumentException or NullPointerException per project
convention) when likeCount == null or likeCount < 0 to prevent invalid state;
then add unit tests asserting that calling ProductJpaEntity.setLikeCount(null)
and setLikeCount(-1L) each throw the expected exception.
apps/commerce-batch/src/test/java/com/loopers/job/likesync/LikeCountSyncJobE2ETest.java-95-124 (1)

95-124: ⚠️ Potential issue | 🟡 Minor

핵심 성공 경로를 검증하지 않아 집계 로직이 망가져도 테스트가 통과한다

현재 시나리오는 dirty 상품만 만들고 LikeEntity를 넣지 않아서 기대값이 0이다. countByProductId()updateLikeCount()가 잘못 구현돼도 이 테스트는 계속 초록색이라 실제 배치 회귀를 잡지 못한다. 최소 1건 이상의 좋아요를 저장한 뒤 DB likeCount가 정확히 그 수로 동기화되는지 검증하는 케이스를 별도로 추가하는 편이 낫다. 추가로 다건 좋아요, 없는 상품, 부분 실패 시 exit status까지 함께 검증해야 한다. As per coding guidelines, "단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다. 통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다."

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

In
`@apps/commerce-batch/src/test/java/com/loopers/job/likesync/LikeCountSyncJobE2ETest.java`
around lines 95 - 124, Add a positive-path E2E test in LikeCountSyncJobE2ETest
that actually exercises the aggregation logic: create a ProductEntity, persist
one or more LikeEntity records for that product (and optionally multiple likes
to test aggregation), add the productId to the DIRTY_SET_KEY via redisTemplate,
run the batch via jobLauncherTestUtils.launchJob()/job, then assert the
jobExitStatus is COMPLETED, the
productRepository.findById(productId).get().getLikeCount() equals the exact
number of persisted LikeEntity rows, and that
likeCountCacheService.getDirtyProductIds() no longer contains the productId;
also add separate assertions/cases for a product with zero likes and a
partial-failure or non-existent-product scenario to validate exit status and
error handling.
scripts/benchmark/run-benchmark.sh-9-13 (1)

9-13: ⚠️ Potential issue | 🟡 Minor

표시한 MYSQL_HOST/MYSQL_PORT가 실제 연결에 사용되지 않는다

현재는 컨테이너 내부에서 mysql 클라이언트를 실행하면서 -h/-P를 넘기지 않기 때문에, 출력에 어떤 host/port를 적더라도 실제 대상은 항상 컨테이너 내부 MySQL이다. 운영 관점에서는 리포트 메타데이터가 거짓이 되고, 다른 포트나 원격 DB를 벤치마크한다고 착각한 채 잘못된 환경 결과를 남길 수 있다. 이 스크립트를 컨테이너 전용으로 고정할 것이라면 host/port 설정과 출력 자체를 제거하고 컨테이너 이름만 받도록 단순화하고, 외부 대상 지원이 목적이라면 -h "$MYSQL_HOST" -P "$MYSQL_PORT"를 명시해야 한다. 추가로 MYSQL_HOST를 의도적으로 틀리게 넣었을 때 실제로 실패하는지, 또는 설정을 제거했다면 출력에도 host/port가 남지 않는지 확인하는 테스트가 필요하다.

Also applies to: 20-21, 34-38

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

In `@scripts/benchmark/run-benchmark.sh` around lines 9 - 13, The script declares
MYSQL_HOST/MYSQL_PORT but never passes them to the mysql client, so the
benchmark always targets the container-local DB; update the mysql invocation(s)
in run-benchmark.sh (look for the commands that call mysql around the blocks
referencing MYSQL_HOST, MYSQL_PORT, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE)
to explicitly include -h "$MYSQL_HOST" -P "$MYSQL_PORT" (and keep -u
"$MYSQL_USER" -p"$MYSQL_PASSWORD"), or alternatively remove
MYSQL_HOST/MYSQL_PORT variables and their mentions if you intend to lock this
script to the container host only; after the change add a small smoke test that
runs the script with an invalid MYSQL_HOST to ensure it fails when external host
is unreachable (or that host/port are not printed when you removed them).
🧹 Nitpick comments (6)
scripts/insert-dummy-data.sql (1)

221-226: 좋아요 수 반영은 한 번 집계해서 갱신하는 편이 낫다.

현재 쿼리는 상품 20만 건 각각에 대해 likes를 다시 세고 있어서 마지막 단계가 병목이 된다. 운영 관점에서는 시드 시간이 크게 흔들리고 products 갱신 구간이 길어져 이후 검증 작업까지 지연된다.

수정안으로는 likes를 한 번 GROUP BY product_id로 집계한 뒤 JOIN UPDATE로 반영하는 방식이 낫다. 추가 테스트로는 대표 볼륨에서 실행 시간을 비교하고, 샘플 상품 몇 건에 대해 products.like_countlikes 실집계가 일치하는지 확인해야 한다.

♻️ 제안하는 수정안
-UPDATE products p
-SET p.like_count = (
-    SELECT COUNT(*)
-    FROM likes l
-    WHERE l.product_id = p.id
-);
+UPDATE products p
+LEFT JOIN (
+    SELECT product_id, COUNT(*) AS like_count
+    FROM likes
+    GROUP BY product_id
+) l ON l.product_id = p.id
+SET p.like_count = COALESCE(l.like_count, 0);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/insert-dummy-data.sql` around lines 221 - 226, The current UPDATE on
table products (UPDATE products p ... WHERE l.product_id = p.id) recalculates
COUNT(*) per product causing a hotspot; instead aggregate likes once with a
GROUP BY product_id (e.g., via a CTE or a temporary aggregated table of
product_id, cnt) and then perform a single JOIN UPDATE to set
products.like_count = agg.cnt for matching product_id; reference the
products.like_count column, likes.product_id and the UPDATE products p statement
when editing; after changing, run timing comparisons on a representative data
volume and spot-check a few product ids by comparing products.like_count vs a
direct COUNT(*) on likes filtered by product_id to validate correctness.
apps/commerce-api/src/test/java/com/loopers/fake/FakeLikeCountCacheService.java (1)

16-33: 이 fake는 동시성 테스트에 투입되면 결과를 오염시킬 수 있다.

HashMap/HashSet 기반의 read-modify-write는 병렬 increment/decrement에서 lost update를 만들 수 있어서 실제 캐시 구현과 무관한 flaky를 낸다. ConcurrentHashMapnewKeySet()으로 바꾸고 merge/compute로 원자적으로 갱신해 달라. 추가로 ExecutorService로 같은 productId를 동시에 증감시키는 테스트를 넣어 fake 자체의 선형화 가능성을 확인해 달라.

수정 예시
-    private final Map<Long, Long> store = new HashMap<>();
-    private final Set<Long> dirtySet = new HashSet<>();
+    private final Map<Long, Long> store = new java.util.concurrent.ConcurrentHashMap<>();
+    private final Set<Long> dirtySet = java.util.concurrent.ConcurrentHashMap.newKeySet();

     public Long increment(Long productId) {
-        Long current = store.getOrDefault(productId, 0L);
-        Long newValue = current + 1;
-        store.put(productId, newValue);
+        Long newValue = store.merge(productId, 1L, Long::sum);
         dirtySet.add(productId);
         return newValue;
     }

     public Long decrement(Long productId) {
-        Long current = store.getOrDefault(productId, 0L);
-        Long newValue = Math.max(0, current - 1);
-        store.put(productId, newValue);
+        Long newValue = store.compute(
+            productId,
+            (id, current) -> Math.max(0L, (current == null ? 0L : current) - 1L)
+        );
         dirtySet.add(productId);
         return newValue;
     }
🤖 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/fake/FakeLikeCountCacheService.java`
around lines 16 - 33, FakeLikeCountCacheService의 store와 dirtySet은
HashMap/HashSet으로 read-modify-write를 사용해 동시성에서 lost update를 유발하므로 store를
ConcurrentHashMap으로, dirtySet을 ConcurrentHashMap.newKeySet()으로 교체하고
increment/decrement는 getOrDefault/put 대신 ConcurrentHashMap.merge 또는 compute를 사용해
원자적으로 갱신하도록 변경하라; 또한 같은 productId에 대해 동시 증감이 발생할 때 fake 자체가 선형화 가능한지 검증하는 테스트를
추가하되 ExecutorService로 여러 스레드가 같은 productId를 동시에 increment/decrement하게 하고 최종 합계와
중간 반환값들이 일관되는지(assertions) 확인하도록 구현하라 (참고 식별자: FakeLikeCountCacheService, store,
dirtySet, increment, decrement).
apps/commerce-batch/src/main/java/com/loopers/batch/persistence/LikeBatchRepository.java (1)

10-10: 단건 카운트만으로는 배치에서 N+1 쿼리 병목이 발생한다.

운영 관점에서 dirty 상품이 많을 때 상품별 countByProductId 반복 호출은 DB 왕복이 급증해 배치 지연과 피크 시간대 부하를 유발한다.
수정안은 상품 ID 목록 기반 집계 쿼리(IN + GROUP BY)를 추가해 한 번에 카운트를 가져오도록 바꾸는 것이다.
추가 테스트로 100개 상품 ID 입력 시 집계 결과 정확성과 호출 쿼리 수 감소를 검증하는 통합 테스트가 필요하다.

리팩터링 예시
 public interface LikeBatchRepository extends JpaRepository<LikeEntity, Long> {

     long countByProductId(Long productId);
+
+    `@Query`("""
+        SELECT l.productId as productId, COUNT(l.id) as likeCount
+        FROM LikeEntity l
+        WHERE l.productId IN :productIds
+        GROUP BY l.productId
+    """)
+    java.util.List<ProductLikeCountView> countByProductIds(`@Param`("productIds") java.util.Collection<Long> productIds);
+
+    interface ProductLikeCountView {
+        Long getProductId();
+        Long getLikeCount();
+    }
 }

As per coding guidelines "**/*Repository*.java: N+1 가능성 ... 대량 데이터에서의 병목을 점검한다."

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

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/persistence/LikeBatchRepository.java`
at line 10, The single-count method countByProductId on LikeBatchRepository
causes N+1 DB roundtrips in batch jobs; add a bulk aggregation method (e.g.,
countByProductIdIn or findCountsByProductIds) that accepts a List<Long>
productIds and returns counts grouped by productId (Map<Long,Long> or a DTO
list) implemented with a single IN + GROUP BY query, replace per-id calls in the
batch job with this bulk method, and add an integration test that supplies 100
product IDs to assert aggregated counts are correct and that the number of DB
queries is drastically reduced.
apps/commerce-api/src/test/java/com/loopers/application/like/LikeApplicationServiceTest.java (1)

37-43: 캐시 부수효과 검증 테스트가 빠져 있다

Line 37-43에서 캐시 서비스를 주입했지만 호출 검증이 없어, 운영에서 좋아요 수 캐시가 누락되어 DB/캐시 불일치가 발생해도 테스트가 통과할 수 있다.
수정안으로 like/unlike 성공·실패 경로별 increment/decrement 호출 여부를 Mockito.verify(...)로 명시 검증하거나, FakeLikeCountCacheService로 최종 카운트를 검증하는 테스트를 추가하는 것이 좋다.
추가 테스트로 중복 좋아요(CONFLICT)에서는 증가 호출이 발생하지 않는지, 멱등 unlike에서는 감소 호출이 과도하게 발생하지 않는지까지 포함해야 한다.

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/test/java/com/loopers/application/like/LikeApplicationServiceTest.java`
around lines 37 - 43, 현재 LikeApplicationServiceTest에서 likeCountCacheService를 주입만
하고 호출 검증이 없으므로 LikeApplicationService의 like/unlike 흐름에서 캐시 부수효과를 검증하도록 테스트를
추가하세요: LikeApplicationService의 like(...)와 unlike(...) 성공 경로에서는
likeCountCacheService.increment(...) / decrement(...)가 각각 한 번 호출되는지
Mockito.verify로 검증하고, 실패/예외 경로(예: 중복 좋아요로 인한 CONFLICT)에서는 increment 호출이 발생하지
않는지, 멱등 unlike 호출 시 decrement가 과도하게 호출되지 않는지 검증하거나 대체로
FakeLikeCountCacheService를 만들어 최종 카운트를 검증하도록 교체해 경계값·실패케이스·예외 흐름을 모두 포함시키세요.
apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductRepositoryImpl.java (1)

107-108: 동률 정렬 시 페이지 경계 안정성이 부족하다

Line 107-108 정렬은 likeCount, createdAt가 동일한 데이터에서 순서가 비결정적일 수 있어, 운영 중 오프셋 페이징에서 중복/누락 조회를 유발할 수 있다. 3차 정렬키로 id DESC를 추가해 순서를 고정하는 편이 안전하다.
추가 테스트로 likeCountcreatedAt가 같은 상품을 limit보다 많이 생성한 뒤, 연속 페이지 조회 시 ID 중복/누락이 없는지 검증하는 케이스를 권장한다.

제안 diff
-            case LIKES_DESC -> Sort.by(Sort.Direction.DESC, "likeCount")
-                .and(Sort.by(Sort.Direction.DESC, "createdAt"));
+            case LIKES_DESC -> Sort.by(Sort.Direction.DESC, "likeCount")
+                .and(Sort.by(Sort.Direction.DESC, "createdAt"))
+                .and(Sort.by(Sort.Direction.DESC, "id"));

As per coding guidelines 쿼리 조건 누락/과다 조회, 정렬/인덱스 활용 가능성, 대량 데이터에서의 병목을 점검한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductRepositoryImpl.java`
around lines 107 - 108, ProductRepositoryImpl의 LIKES_DESC 정렬(Sort.by(...
"likeCount").and(Sort.by(... "createdAt")))은 likeCount와 createdAt이 동일한 경우 순서가
비결정적이어서 오프셋 페이징에서 중복/누락을 유발하니, LIKES_DESC 분기에서 3차 키로 id DESC를 추가해 정렬을 고정하고(예:
추가적인 .and(Sort.by(Sort.Direction.DESC, "id"))), 변경 후에는 테스트를 추가해 likeCount와
createdAt이 동일한 상품을 limit보다 많이 만들어 연속 페이지 조회 시 ID 중복/누락이
없는지(ProductRepositoryImpl의 해당 페이징 쿼리/메서드 호출을 사용하여) 검증하세요.
apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/LikeCountCacheServiceTest.java (1)

195-200: 샤드 수 하드코딩은 테스트 유지보수성을 떨어뜨린다

Line 195-200의 16개 샤드 가정은 내부 샤딩 정책 변경 시 기능은 정상인데 테스트만 깨지는 상황을 만든다.
수정안으로 샤드 수를 서비스 상수/설정값에서 참조하거나, 샤드 수에 독립적인 랜덤 ID 세트 검증으로 바꾸는 것을 권장한다.
추가 테스트로 임의 ID 집합에 대해 setMultiple/getMultiple round-trip이 보존되는지 검증하면 구현 변경에도 안정적이다.

🤖 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/infrastructure/cache/LikeCountCacheServiceTest.java`
around lines 195 - 200, The test currently hardcodes a "16 shard" assumption
causing brittleness; update LikeCountCacheServiceTest to avoid relying on a
fixed shard count by either (a) reading the shard count from the service/config
constant (e.g., a SHARD_COUNT or a getter on LikeCountCacheService) and using it
to compute IDs instead of magic numbers like 16, or (b) replace the fixed IDs
with a shard-agnostic randomized ID set and assert round-trip behavior using
likeCountCacheService.setMultiple(...) and
likeCountCacheService.getMultiple(...) (or repeated set/get) to verify values
are preserved; adjust assertions to use the dynamically generated IDs so the
test remains valid if internal sharding changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4107ae2d-716a-4519-be16-6086def6175e

📥 Commits

Reviewing files that changed from the base of the PR and between 627e473 and 3e3d70a.

⛔ Files ignored due to path filters (4)
  • .claude/commands/create-log.md is excluded by !**/*.md and included by **
  • .claude/commands/create-pr.md is excluded by !**/*.md and included by **
  • .claude/skills/analyze-query/SKILL.md is excluded by !**/*.md and included by **
  • scripts/benchmark/PERFORMANCE-ANALYSIS.md is excluded by !**/*.md and included by **
📒 Files selected for processing (42)
  • apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java
  • apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductResult.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDomainService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/DistributedLockService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/LikeCountCacheService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductLikeCountQueryService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/init/DataInitializer.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaEntity.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductRepositoryImpl.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/like/LikeApplicationServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/config/TestRedisConfiguration.java
  • apps/commerce-api/src/test/java/com/loopers/fake/FakeLikeCountCacheService.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/DistributedLockServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/LikeCountCacheServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductLikeCountQueryServiceTest.java
  • apps/commerce-api/src/test/resources/application-test.yml
  • apps/commerce-batch/src/main/java/com/loopers/batch/cache/LikeCountBatchCacheService.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/config/BatchJpaConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/likesync/LikeCountSyncJobConfig.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/job/likesync/step/LikeCountSyncTasklet.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/persistence/LikeBatchRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/persistence/LikeEntity.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/persistence/ProductBatchRepository.java
  • apps/commerce-batch/src/main/java/com/loopers/batch/persistence/ProductEntity.java
  • apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java
  • apps/commerce-batch/src/test/java/com/loopers/config/TestRedisConfiguration.java
  • apps/commerce-batch/src/test/java/com/loopers/job/likesync/LikeCountSyncJobE2ETest.java
  • http/like.http
  • modules/redis/src/main/resources/redis.yml
  • scripts/benchmark/index-performance-test.sql
  • scripts/benchmark/run-benchmark.sh
  • scripts/insert-dummy-data.sql
  • scripts/migration/V001__add_like_count_to_products.sql
  • scripts/migration/V001__add_like_count_to_products_rollback.sql
  • scripts/run-dummy-data.sh
  • scripts/truncate-tables.sql

Comment on lines +122 to +145
public Long increment(Long productId) {
try {
String key = buildKey(productId);
Long result = writeTemplate.opsForValue().increment(key);
writeTemplate.opsForValue().set(key, String.valueOf(result), DEFAULT_TTL);
markDirty(productId);
return result;
} catch (Exception e) {
log.warn("Redis increment 실패: productId={}", productId, e);
return null;
}
}

/**
* 좋아요 수 감소 + 캐시 덮어쓰기.
*/
public Long decrement(Long productId) {
try {
String key = buildKey(productId);
Long result = writeTemplate.opsForValue().decrement(key);
long safeResult = result != null && result >= 0 ? result : 0L;
writeTemplate.opsForValue().set(key, String.valueOf(safeResult), DEFAULT_TTL);
markDirty(productId);
return safeResult;
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

🌐 Web query:

Redis에서 존재하지 않는 키에 INCR를 호출하면 0에서 시작하는가, 그리고 INCR후 별도의SET을 수행하는 조합은 동시 클라이언트 사이에서 원자적인가?

💡 Result:

  • 존재하지 않는 키에 INCR 호출 시 시작값: 키가 없으면 Redis가 그 키를 먼저 0으로 간주/설정한 뒤 증가시키므로, INCR 결과는 **1**이 됩니다(“0에서 시작”한다고 표현한다면 초깃값을 0으로 두고 증가). [1]

  • INCR 후 별도의 SET(두 커맨드 조합)의 원자성: INCR 단일 커맨드 자체는 원자적이지만, INCR 다음에 별도의 SET을 보내는 두 커맨드의 “묶음”은 그 사이에 다른 클라이언트 명령이 끼어들 수 있어 원자적이지 않습니다. 이런 조합을 원자적으로 만들려면 MULTI/EXEC 트랜잭션으로 묶거나(큐잉된 명령들을 원자적으로 실행) [2], Lua 스크립트(EVAL)로 한 번에 실행해야 합니다. [3]

Sources: [1] Redis INCR command docs, [2] Redis MULTI command docs, [3] Redis Lua scripting (EVAL) docs.


INCR/DECRSET으로 인한 동시성 카운터 손실

현재 구현은 INCR/DECR로 값을 증감한 뒤 동일 키에 SET을 실행한다. 이 두 커맨드는 원자적이지 않으므로, 동시 요청에서 늦게 도착한 쓰기가 더 최신의 증가값을 덮어써 카운터가 손실된다. 또한 캐시 키가 없을 때 INCR은 0에서 시작해 1을 반환하므로, 기존 DB 좋아요 수를 무시하고 초기화된다. 이는 캐시 냉시작 직후 응답값과 이후 배치 동기화 값의 불일치를 야기한다.

Lua 스크립트 또는 MULTI/EXEC 트랜잭션으로 증감과 TTL 연장을 원자적으로 처리하고, 키 미존재 시에는 DB 기준 절대값으로 초기화한 뒤 증감을 수행하도록 수정하다. 기존 좋아요가 존재하는 상품에서 캐시를 비운 뒤 동시에 like/unlike 요청을 보내 최종 카운트와 Redis 값이 일치하는지 검증하는 테스트를 추가하다.

🤖 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/LikeCountCacheService.java`
around lines 122 - 145, The increment/decrement methods (increment, decrement)
use non-atomic INCR/DECR followed by SET
(writeTemplate.opsForValue().increment/decrement and
writeTemplate.opsForValue().set), which can be lost under concurrency and
wrongly initialize a missing key to 1; replace these with an atomic Redis
operation (use a Lua script or MULTI/EXEC) that: 1) checks if the key exists, if
not reads the authoritative DB count and initializes the key to that base value
before applying the delta, 2) performs the increment/decrement and updates the
TTL in one atomic step, and 3) still calls markDirty(productId) as needed;
update buildKey usage accordingly and add a concurrency test that clears the
cache for an existing product, fires concurrent like/unlike requests, and
asserts final Redis value equals the DB-synced like count.

Comment on lines +69 to +77
private void syncProductLikeCount(Long productId) {
long actualCount = likeRepository.countByProductId(productId);

int updated = productRepository.updateLikeCount(productId, actualCount);

if (updated > 0) {
likeCountCacheService.set(productId, actualCount);
likeCountCacheService.removeDirty(productId);
log.debug("상품 좋아요 수 동기화: productId={}, count={}", productId, actualCount);
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

DB 커밋 전에 Redis를 갱신하면 최신 좋아요나 롤백 결과를 잃을 수 있다

countByProductId() 이후에 set()removeDirty()를 바로 호출하면, 같은 상품에 대한 새 좋아요가 동기화 중간에 커밋되거나 Step 트랜잭션이 마지막에 롤백될 때 Redis만 더 최신이거나 더 오래된 값으로 남고 dirty 플래그까지 사라질 수 있다. 이 경우 다음 배치가 다시 잡지 못해 최종 수렴이 깨진다. 상품 단위 락이나 버전 토큰으로 동기화 구간을 보호하고, Redis 갱신과 dirty 제거는 DB 커밋 이후에만 수행하도록 옮겨야 한다. 추가로 countByProductId()removeDirty() 사이에 새 좋아요를 넣는 동시성 테스트와, DB 롤백 시 dirty 플래그가 유지되는 테스트를 같이 두는 편이 안전하다.

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

In
`@apps/commerce-batch/src/main/java/com/loopers/batch/job/likesync/step/LikeCountSyncTasklet.java`
around lines 69 - 77, syncProductLikeCount currently reads DB and immediately
calls likeCountCacheService.set(...) and removeDirty(...), which can lose
correctness if new likes are committed during sync or if the DB transaction
rolls back; change the flow so DB-side operations
(likeRepository.countByProductId and productRepository.updateLikeCount in
syncProductLikeCount) remain inside the transactional step but defer cache
updates—call likeCountCacheService.set(productId, actualCount) and
likeCountCacheService.removeDirty(productId) only after the DB transaction
successfully commits (e.g., via a transaction commit callback, an afterCommit
hook, or by moving cache updates to the outer coordinator that runs
post-commit), and protect the sync window for a product using a product-level
lock or optimistic version token to avoid concurrent updates; add tests that
insert a like between countByProductId and cache removal to confirm eventual
consistency and a rollback test ensuring the dirty flag remains set when the DB
transaction aborts.

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