Skip to content

[volume - 5] 인덱스 및 캐시 적용을 통한 조회 최적화 - 김남진#215

Open
Namjin-kimm wants to merge 6 commits intoLoopers-dev-lab:Namjin-kimmfrom
Namjin-kimm:volume-5
Open

[volume - 5] 인덱스 및 캐시 적용을 통한 조회 최적화 - 김남진#215
Namjin-kimm wants to merge 6 commits intoLoopers-dev-lab:Namjin-kimmfrom
Namjin-kimm:volume-5

Conversation

@Namjin-kimm
Copy link

@Namjin-kimm Namjin-kimm commented Mar 13, 2026

📌 Summary

  • 배경: 상품 10만 건 기준, brandId 필터 + 정렬 조건 조합으로 8가지 쿼리 패턴이 발생합니다. 인덱스 없이 Full Table Scan + filesort로 쿼리당 30~38ms가 소요되었고, 동일한 (brandId, sort, page) 요청이 반복되어도 매번 DB를 조회했습니다. 300명 동시 요청 시 HikariCP 커넥션 풀(40개)이 고갈되어 요청 대기열이 쌓이고 p(95) 2,450ms / 에러율 1.44%까지 치솟았습니다.
  • 목표: 복합 인덱스로 DB에 도달한 쿼리의 실행 비용을 낮추고, Redis Cache-Aside 패턴으로 반복 요청이 DB에 도달하는 것 자체를 차단합니다. AFTER_COMMIT 이벤트로 쓰기 후 캐시 무효화의 정합성을 보장하고, TTL Jitter로 동시 만료에 의한 Cache Stampede를 완화합니다.
  • 결과: Full Table Scan → Using index condition 전환으로 주요 쿼리 96% 단축(38ms → 1.3~2.5ms). 캐시 적용 후 p(95) 66배 개선(2,450ms → 37ms), 처리량 2.6배 향상(132 → 345 req/s), 에러율 1.44% → 0%.

🧭 Context & Decision

🔖 Index

문제 정의

  • 현재 동작/제약: PK 인덱스만 존재. brandId 필터 및 created_at DESC / price ASC / like_count DESC 정렬 조건 모두 Full Table Scan + filesort 발생
  • 문제: 10만 건 기준 단일 쿼리 30~38ms. Spring Data JPA Page 조회는 데이터 쿼리 + COUNT 쿼리를 동시에 실행하므로 API 호출 1회 = DB 쿼리 2회
  • 성공 기준: 주요 쿼리에서 type: ALL 제거 및 Using index condition 달성

인덱스 개수 — 얼마나 선언할 것인가?

가장 먼저 고민한 것은 "인덱스를 몇 개 선언해야 하는가" 였습니다. 인덱스는 쓰기 시 B-Tree 갱신 비용이 발생하므로 무조건 많다고 좋지 않다고 생각했습니다.

유즈케이스를 추정한 결과, brandId 유무 × 3가지 정렬로 쿼리 패턴이 나뉘고, B-Tree 인덱스는 brand_id가 중간에 끼면 전체 created_at DESC 정렬에 사용할 수 없는 구조적 한계가 있습니다. 따라서 brandId 있음brandId 없음을 하나의 인덱스로 커버하는 것은 불가능하다고 판단했습다.

예: (deleted_at, brand_id, created_at DESC) 인덱스는 brand_id별로 쪼개진 created_at 정렬만 가능 — brandId 없는 전체 정렬에 Using filesort 발생

결국 brandId 있음 3개 + brandId 없음 중 사용 빈도 높은 1개 = 4개로 결정했습니다.

선택지와 결정

  • 고려한 대안

    • A. 단일 (brand_id) 인덱스: brand_id로 특정 브랜드 상품만 추려낸 뒤 행마다 힙 페이지에 접근해 deleted_at IS NULL을 체크하고, 통과한 행들을 다시 정렬하는 방식입니다. 브랜드 필터를 포함한 최신순 정렬 쿼리 기준 13.43ms로, 인덱스가 없는 것보다는 낫지만 정렬 비용이 여전히 남아 있습니다.
    • B. 복합 (brand_id, deleted_at, sort_column) 인덱스: 등가(=) → IS NULL → 정렬 컬럼 순서로 선언하고, 인덱스 정렬 방향을 ORDER BY 방향과 일치시켜 ICP(Index Condition Pushdown)를 활성화했습니다. 동일 조건 기준 1.64ms로, 단일 인덱스 대비 8배 차이가 났습니다.
    • C. 인덱스 6개 (brandId 없는 정렬 3종 전부 포함): 인덱스를 늘릴수록 좋다는 전제로 가능한 모든 정렬 조합에 인덱스를 선언해 보았습니다. 실제로 테스트해보니 6개가 동시에 존재할 때 전체 상품 수를 조회하는 COUNT 쿼리에서 옵티마이저가 가격순 인덱스의 skip scan을 선택해 오히려 56.72ms까지 느려지는 케이스가 발생했습니다. 인덱스가 많을수록 옵티마이저가 예상치 못한 실행 계획을 선택할 수 있다는 것을 직접 확인하였습니다.
    • D. deleted_at 인덱스 제외: soft delete 컬럼이라 NULL 아니면 날짜값으로 카디널리티가 낮아 인덱스에 포함할 필요가 없다고 생각했습니다. 실제로 제외한 채 테스트한 결과, 전체 상품 수를 조회하는 COUNT 쿼리가 인덱스를 전혀 사용하지 못하고 Full Table Scan으로 회귀하였고, 데이터 행마다 힙 페이지에 접근하는 비용도 증가함을 확인했습니다.
  • 최종 결정: B. 복합 인덱스 4개 채택 (idx_brand_deleted_created, idx_brand_deleted_price, idx_brand_deleted_likes, idx_deleted_created)

  • 트레이드오프:

    • 전체 상품 수를 조회하는 COUNT 쿼리는 인덱스 적용 후 12ms → 17~19ms로 오히려 느려졌습니다. 원인은 10만 건 + 워밍업된 버퍼풀 환경에서 B-Tree 탐색보다 버퍼 풀에 올라온 데이터를 순차적으로 읽는 것이 더 효율적이기 때문입니다.
      하지만 1,000만 건 이상이 되어 버퍼풀이 전체 데이터를 담지 못하는 시점부터는 커버링 인덱스가 유리해집니다.
  • 추후 개선 여지: idx_deleted_price, idx_deleted_likes 2개는 미선언 상태입니다. 브랜드 없이 전체 가격순/좋아요순으로 조회하는 요청의 실사용 빈도를 확인한 뒤 추가 여부를 결정할 예정입니다.


⚡ Cache

문제 정의

  • 현재 동작/제약: 인덱스 최적화 이후에도 동일한 (brandId, sort, page) 조합 요청이 반복될 때마다 DB 쿼리 발생
  • 문제: HikariCP 커넥션 풀(40개)이 병목. 300 VU 동시 요청 시 커넥션 대기 → p(95) threshold(2,000ms) 초과, 에러율 1% 초과
  • 성공 기준: 반복 요청에서 DB를 거치지 않고 응답, Redis 장애 시에도 서비스 무중단

[결정 1] 캐시 추상화 방식 — @Cacheable vs RedisTemplate 직접 사용

  • A. @Cacheable 어노테이션: Spring Cache 추상화로 선언만으로 캐싱 가능. 그러나 Page<ProductInfo>를 캐시 값으로 쓸 경우 Jackson 역직렬화 실패 문제가 있다(PageImpl 생성자가 Jackson 비친화적).
    또한 캐시 동작(TTL, evict 시점)이 어노테이션 뒤에 숨어 테스트·디버깅이 어렵습니다.
  • B. RedisTemplate 직접 사용: 직렬화 방식, TTL, 예외 처리를 명시적으로 제어 가능. Page<T> 대신 ProductPageResult record로 래핑해 역직렬화 문제 해결
  • 최종 결정: B 채택. 다만 application 레이어가 Redis 기술에 직접 의존하지 않도록 ProductCacheRepository 인터페이스를 포트로 선언하고 구현체는 infrastructure에 위치시켜 DIP를 준수했다

[결정 2] 캐시 스탬피드 (Thunder Herd) — 어떻게 막을 것인가?

TTL이 동일한 키들이 같은 시각에 만료되면, 수백 개의 요청이 동시에 캐시 미스를 경험하고 모두 DB로 쏟아집니다.

  • A. 모든 키에 동일한 TTL: 구현이 가장 단순하지만 동시 만료 시 캐시 스템피드 문제를 발생시키는 주된 원인입니다.

  • B. Bounded Jitter TTL: base ± jitterRange 범위의 무작위 TTL 부여합니다 → 키마다 만료 시점이 분산되어 자연 만료 시 동시 만료가 방지됩니다.

  • C. Redis 분산 락: 캐시 미스 시 한 스레드만 DB를 조회하고 나머지는 잠금 해제를 기다립니다. evictAll() 직후 스탬피드까지 완전 차단이 가능합니다. 그러나 분산 락 구현 복잡도가 높고 현재 단계에서 학습 범위를 초과한다고 판단

  • 최종 결정: B 채택. 자연 만료(TTL 만료)로 인한 동시 폭격은 방지 가능합니다. evictAll() 직후 스탬피드는 분산 락 없이 완전 해결이 어렵다는 점을 인지하고 허용 가능한 트레이드오프로 판단했습니다.

  • 추후 개선 여지: Redisson 기반 분산 락으로 evictAll() 직후 스탬피드까지 차단 가능하게 구현할 수 있습니다.


[결정 3] 캐시 정합성 — 쓰기 시 무효화 타이밍

DB 쓰기 중 다른 스레드가 구버전 데이터를 캐시에 저장하는 레이스 컨디션이 발생할 수 있습니다.

문제 시나리오:
Thread A (쓰기): DB UPDATE ... → [커밋 전] 캐시 DEL → DB COMMIT
Thread B (읽기):                               ↑ 캐시 미스 → DB 조회(구버전) → 캐시 SET
                                                  → 커밋 전 데이터가 캐시에 저장됨
  • A. 트랜잭션 내 캐시 삭제 (기본 방식): 위 시나리오처럼 커밋 전에 삭제하면 커밋 전 데이터가 캐시에 올라갈 수 있습니다

  • B. @TransactionalEventListener(AFTER_COMMIT): DB 커밋이 완전히 완료된 후 이벤트 처리로 캐시를 무효화합니다. 커밋 전 데이터 유입 차단

  • C. Delayed Double Delete: 캐시를 즉시 삭제한 뒤 일정 시간 후 한 번 더 삭제합니다. Thread B가 읽기~SET 사이에 구버전을 저장해도 두 번째 삭제로 정리 가능합니다.
    단, 딜레이 동안 구버전이 데이터가 또 다시 캐싱될 수 있고 비동기 처리 복잡도가 추가됩니다.

  • D. B + C 동시 적용: AFTER_COMMIT으로 커밋 전 유입을 막고, Delayed Double Delete로 AFTER_COMMIT 이후의 극단적 레이스(Thread B의 읽기~SET 간격에 커밋+무효화가 끼어드는 경우)까지 추가로 방어 가능합니다.

  • 최종 결정: B 채택. D는 방어 깊이가 깊지만 Thread B의 레이스 발생 확률 자체가 극히 낮고, 발생해도 TTL 만료로 자연 복구된다는 점이 어느 정도 해결책이라고 생각했습니다. 복잡도 증가 대비 실익이 적다고 판단했습니다

  • 트레이드오프: AFTER_COMMIT 이후에도 Thread B가 캐시 미스 → 구버전 DB 읽기 → SET 하는 사이에 Thread A의 커밋+무효화가 완료되는 극단적 레이스는 이론적으로 잔존합니다. 현실적으로 TTL 만료 시 자동 복구되므로 허용 가능한 수준으로 판단했습니다


[결정 4] 딥 페이징 — 모든 페이지를 캐싱해야 하는가?

  • A. 모든 page 번호 캐싱: page=100, 200 등 뒤 페이지까지 모두 캐싱하면 히트율이 낮은 키가 메모리를 낭비한다. (brandId 22가지 × sort 3가지 × page N개)로 키 수가 무제한으로 증가하는 캐시 키 폭발 문제도 있습니다.

  • B. 앞 N페이지만 캐싱 (maxCacheablePage): 실제 사용 트래픽의 대부분은 초반 페이지에 집중된다는 가정하에, page 0~2만 캐싱하고 그 이후는 DB 직접 조회로 처리. 최대 키 수 = 22 × 3 × 3 = 198개로 제한

  • C. 커서 기반 페이지네이션: OFFSET 연산 자체가 N+LIMIT건을 읽고 N건을 버리는 비효율을 근본적으로 제거.
    그러나 현재 프로젝트가 이미 OFFSET 기반으로 설계되어 있어 이 부분은 고려하지 않았습니다.

  • 최종 결정: B 채택. maxCacheablePage=3으로 page 0~2만 캐싱.

  • 트레이드오프: OFFSET 기반 딥 페이징의 연산 비용 자체는 캐시로 해결되지 않습니다. page=10의 OFFSET 200 LIMIT 20은 여전히 220건을 읽고 200건을 버린다

  • 추후 개선 여지: 커서 기반 페이지네이션 또는 Deferred Join으로 근본 해결 가능


🏗️Design Overview

변경 범위

  • 영향 받는 모듈/도메인: application/product, infrastructure/product, config, interfaces/api/product
  • 신규 추가: ProductCacheRepository (포트), ProductCacheRepositoryImpl (어댑터), ProductPageResult, CacheProperties, CacheConfig,
    ProductCacheEvictEvent, ProductCacheEventListener
  • 제거/대체: ProductFacadePage<ProductInfo> 반환 → ProductPageResult record로 교체 (Jackson 역직렬화 문제 해결)

주요 컴포넌트 책임

  • ProductCacheRepository (인터페이스): application 레이어의 캐시 포트. getList / saveList / evictAll 3가지 오퍼레이션 노출. Redis를 모름
  • ProductCacheRepositoryImpl: RedisTemplate 기반 구현체. 읽기는 REPLICA_PREFERRED(Replica), 쓰기/삭제는 MASTER. SCAN 기반 evictAll()KEYS *
    블로킹 명령어 대체. 모든 예외 내부 흡수 → Redis 장애 시 캐시 미스로 처리
  • CacheProperties: base ± jitter TTL 계산 및 isCacheable(pageNumber) 딥 페이징 분기 책임
  • ProductCacheEventListener: @TransactionalEventListener(AFTER_COMMIT) — DB 커밋 완료 후 캐시 무효화 보장
  • ProductAdminFacade: Redis 직접 의존 제거 → ApplicationEventPublisher로 이벤트 발행만 담당

🔁 Flow Diagram

Cache-Aside 읽기 흐름 (Main Flow)

sequenceDiagram
  autonumber
  participant Client
  participant ProductV1Controller
  participant ProductFacade
  participant ProductCacheRepository
  participant Redis
  participant ProductService
  participant DB

  Client->>ProductV1Controller: GET /api/v1/products?page=0&sort=latest
  ProductV1Controller->>ProductFacade: findAll(brandId, pageable)

  alt 딥 페이징 (page >= maxCacheablePage=3)
    ProductFacade->>ProductService: findAll()
    ProductService->>DB: SELECT ... OFFSET N
    DB-->>ProductFacade: Page<Product>
    ProductFacade-->>Client: ProductPageResult (캐시 미적용)
  else 캐시 대상 구간 (page 0~2)
    ProductFacade->>ProductCacheRepository: getList(cacheKey)
    ProductCacheRepository->>Redis: GET loopers:product:list:...

    alt 캐시 히트
      Redis-->>ProductFacade: ProductPageResult
      ProductFacade-->>Client: ProductPageResult (DB 접근 없음)
    else 캐시 미스 (또는 Redis 장애 → fallback)
      Redis-->>ProductCacheRepository: null / Exception
      ProductCacheRepository-->>ProductFacade: Optional.empty()
      ProductFacade->>ProductService: findAll()
      ProductService->>DB: SELECT ...
      DB-->>ProductFacade: Page<Product>
      ProductFacade->>ProductCacheRepository: saveList(cacheKey, result)
      ProductCacheRepository->>Redis: SET ... EX (base ± jitter초)
      ProductFacade-->>Client: ProductPageResult
    end
  end
Loading

캐시 무효화 흐름 (Write-Around + AFTER_COMMIT)

sequenceDiagram
  autonumber
  participant Admin
  participant ProductAdminFacade
  participant DB
  participant EventPublisher
  participant ProductCacheEventListener
  participant Redis

  Admin->>ProductAdminFacade: 상품 등록/수정/삭제
  ProductAdminFacade->>DB: INSERT / UPDATE / DELETE
  ProductAdminFacade->>EventPublisher: publishEvent(ProductCacheEvictEvent)
  Note over DB,EventPublisher: 트랜잭션 커밋 완료 대기

  EventPublisher->>ProductCacheEventListener: @TransactionalEventListener(AFTER_COMMIT)
  ProductCacheEventListener->>Redis: SCAN + DEL loopers:product:list:*
  Note over Redis: 커밋 완료 후 무효화 보장<br/>→ 커밋 전 데이터 캐시 유입 차단
Loading

🔗 핵심 코드

1. 복합 인덱스 선언

Product.java L17–L25

@Table(
    name = "product",
    indexes = {
        @Index(name = "idx_brand_deleted_created", columnList = "brand_id, deleted_at, created_at DESC"),
        @Index(name = "idx_brand_deleted_price",   columnList = "brand_id, deleted_at, price"),
        @Index(name = "idx_brand_deleted_likes",   columnList = "brand_id, deleted_at, like_count DESC"),
        @Index(name = "idx_deleted_created",       columnList = "deleted_at, created_at DESC")
    }
)

컬럼 순서는 [등가(=) 조건] → [IS NULL 조건] → [정렬 컬럼] 원칙을 따릅니다.
B-Tree는 컬럼 순서대로 정렬되기 때문에 이 순서가 어긋나면 인덱스에서 정렬을 얻지 못해 Using filesort가 발생합니다.
정렬 방향(DESC)도 쿼리의 ORDER BY 방향과 일치시켜 ICP(Index Condition Pushdown)를 활성화 시켰습니다.

brandId 없음 케이스를 위한 인덱스(idx_deleted_created) 1개만 추가한 이유는, 브랜드 필터를 걸지 않는 유즈케이스 중에 가장 빈번하게 일어날 것이라고 추정했습니다.


2. Cache-Aside 패턴 — 딥 페이징 분기 포함

ProductFacade.java L34–L74

// 상품 목록 조회 (Cache-Aside 패턴)
@Transactional(readOnly = true)
public ProductPageResult findAll(Long brandId, Pageable pageable) {
    // 딥 페이징 구간은 캐시 미적용 → DB 직접 조회
    if (!cacheProperties.isCacheable(pageable.getPageNumber())) {
        return fetchFromDb(brandId, pageable);
    }

    String cacheKey = buildCacheKey(brandId, pageable);

    // 캐시 히트: 즉시 반환
    return productCacheRepository.getList(cacheKey).orElseGet(() -> {
        // 캐시 미스: DB 조회 후 캐시에 저장
        ProductPageResult result = fetchFromDb(brandId, pageable);
        productCacheRepository.saveList(cacheKey, result);
        return result;
    });
}

/**
 * 캐시 키 레이어링 전략: {서비스}:{도메인}:{오퍼레이션}:{파라미터}
 * 예) loopers:product:list:brand=1:sort=createdAt_DESC:page=0:size=20
 */
private String buildCacheKey(Long brandId, Pageable pageable) {
    String brandPart = brandId == null ? "brand=all" : "brand=" + brandId;
    String sortPart = pageable.getSort().stream()
            .map(order -> order.getProperty() + "_" + order.getDirection())
            .collect(Collectors.joining(","));
    return String.format("loopers:product:list:%s:sort=%s:page=%d:size=%d",
            brandPart, sortPart, pageable.getPageNumber(), pageable.getPageSize());
}

isCacheable()로 page 0~2만 캐싱하고, 그 이후 페이지는 DB를 직접 조회합니다.
모든 페이지를 캐싱하면 히트되지 않는 키가 메모리를 낭비하고, (brandId × sort × page) 조합으로 캐시 키가 무제한 증가하는 키 폭발 문제가 생길것이라고 생각했습니다.
캐시 키는 {서비스}:{도메인}:{오퍼레이션}:{파라미터} 계층 구조로 설계해 패턴 기반 evictAll()이 가능하도록 했습니다.


3. Bounded Jitter TTL — Cache Stampede 완화

CacheProperties.java L29–L37

public long getTtlSeconds(String cacheName) {
    long base = ttlOverrides.getOrDefault(cacheName, defaultTtlSeconds);
    if (jitterRangeSeconds <= 0) {
        return base;
    }
    // Bounded Jitter: base ± jitterRange 범위에서 무작위 TTL
    long jitter = ThreadLocalRandom.current().nextLong(-jitterRangeSeconds, jitterRangeSeconds + 1);
    return Math.max(1, base + jitter); // 최소 1초 보장
}

모든 캐시 키에 동일한 TTL을 부여하면, 같은 시각에 대량의 키가 동시에 만료되어 수백 개의 요청이 한꺼번에 DB를 조회하는 Cache Stampede가 발생합니다.
base ± jitterRange 범위의 무작위 TTL을 키마다 부여해 만료 시점을 분산시킴으로써, 자연 만료로 인한 동시 DB 부하 증가를 완화하였습니다.
ttlOverrides를 통해 캐시 이름별로 TTL을 재정의할 수 있으며, 현재 product:list에는 기본 300초 대신 180초를 적용하고 있습니다.


4. AFTER_COMMIT 무효화 이벤트

ProductCacheEventListener.java L14–L23

/**
 * DB 트랜잭션 커밋 완료 후에 캐시 무효화 실행.
 * BEFORE_COMMIT이 아닌 AFTER_COMMIT을 사용하는 이유:
 * 커밋 전에 캐시를 삭제하면, 삭제~커밋 사이 구간에 다른 요청이
 * 캐시 미스 → DB 조회(구 버전) → 구 버전 재캐싱하는 레이스가 발생하기 때문.
 */
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleProductChanged(ProductCacheEvictEvent event) {
    productCacheRepository.evictAll();
}

ProductAdminFacade는 Redis를 직접 알지 못하고, 상품 변경 시 ProductCacheEvictEvent만 발행합니다.
스프링 이벤트를 경유함으로써 쓰기 레이어(application)와 캐시 무효화 레이어(infrastructure)의 결합을 제거했습니다.
AFTER_COMMIT은 "DB 커밋이 완전히 완료된 후에만 캐시를 삭제한다"는 보장을 제공해, 커밋 전 구버전 데이터가 캐시에 유입되는 레이스 컨디션을 차단합니다.


k6 부하 테스트 결과 (300 VU)

지표 캐시 미적용 (page=10, DB 직접 조회) 캐시 적용 (page=0, Redis 히트) 개선율
평균 응답 시간 849ms 15ms 56배 ↑
p(95) 응답 시간 2,450ms ❌ 37ms 66배 ↑
에러율 1.44% ❌ 0.00%
처리량 (req/s) 132 req/s 345 req/s 2.6배 ↑

캐시 미적용 시 HikariCP 커넥션 풀(40개)이 병목이 되어 300 VU 환경에서 커넥션 대기 발생 → p(95) 2,450ms로 threshold 초과, 에러율 1% 초과.
캐시 적용 후 Redis에서 응답이 반환되어 DB 커넥션 소모가 거의 없었고, p(95) 37ms로 66배 개선.


첨부 자료

인덱스 성능 분석 보고서

  • 파일: .docs/product-index-analysis.md
  • 내용: 인덱스 미적용 베이스라인부터 6개 인덱스 개별 단위 EXPLAIN 분석, 최종 채택 4개 인덱스와 미채택 2개 인덱스의 선택 근거까지 정리하였습니다.

k6 부하 테스트 스크립트 및 결과 리포트

파일 설명
scripts/test_cached.js 캐시 적용 구간(page=0) 단독 부하 테스트
scripts/test_uncached.js 캐시 미적용 구간(page=10, 딥 페이징) 단독 부하 테스트
scripts/cache_test.js 캐시 적용/미적용 구간을 동시 측정하여 응답 시간 차이를 비교하는 통합 테스트

체크리스트

🔖 Index

  • 상품 목록 API에서 brandId 기반 검색, 좋아요 순 정렬 등을 처리했다
  • 조회 필터, 정렬 조건별 유즈케이스를 분석하여 인덱스를 적용하고 전 후 성능비교를 진행했다

❤️ Structure

  • 상품 목록/상세 조회 시 좋아요 수를 조회 및 좋아요 순 정렬이 가능하도록 구조 개선을 진행했다
  • 좋아요 적용/해제 진행 시 상품 좋아요 수 또한 정상적으로 동기화되도록 진행하였다

⚡ Cache

  • Redis 캐시를 적용하고 TTL 또는 무효화 전략을 적용했다
  • 캐시 미스 상황에서도 서비스가 정상 동작하도록 처리했다

리뷰포인트!!!


1. 인덱스 선언 기준

멘토링 시간에 참조 ID와 정렬 컬럼은 꼭 인덱스에 들어가야 한다는 피드백을 듣고, (brand_id, deleted_at, 정렬 컬럼 3종) 인덱스는 반드시 필요하다고 판단하여 이렇게 선언하였습니다. 다만 브랜드 필터를 걸지 않는 케이스는 위 3개 인덱스를 타지 못하기 때문에, 동일하게 정렬 케이스별로 3개를 추가 선언하면 좋겠지만 인덱스가 너무 많아진다는 생각이 들었습니다. 결국 가장 요청이 빈번할 것으로 판단한 전체 상품 최신순 조회 케이스 1개만 추가로 선언했습니다. 실무에서도 참조 ID와 정렬 컬럼은 기본으로 인덱스를 선언하고, 그 외 케이스는 실제로 병목이 발생했을 때 그때그때 대응하는 방식으로 운영하는지 궁금합니다.


2. 캐시 전략

현재 상품 목록 전체 데이터를 캐싱하고 있습니다. 관리자가 상품 정보를 수정하면 캐시를 evict하는 방식을 적용했고, 좋아요처럼 빈번하게 발생하거나 주문으로 인한 재고 차감처럼 상대적으로 덜 빈번한 경우에는 evict 없이 TTL에 의존하고 있습니다. 실무마다 다르겠지만, 멘토님 회사에서는 어떤 전략을 쓰고 계신지 궁금합니다. 상품 어떤 정보가 바뀌든 항상 목록 캐시를 evict 후 재저장하는 방식을 쓰시는지, 아니면 TTL에 의존하여 stale 데이터를 허용하시는지 궁금합니다. 또 멘토링 때 상품 목록 페이지에서 가격 등 상세 정보는 별도의 캐시 전략으로 가져가는 경우도 있다고 하셨는데, 대략적인 방법이 궁금합니다!


3. 캐시 무효화와 재저장 사이의 stale 데이터 문제

캐시 무효화를 AFTER_COMMIT으로 처리하고 있는데, DB 커밋 완료 후 실제로 캐시가 evict되기까지의 짧은 순간에 다른 요청이 캐시 미스 → DB 조회(이미 커밋된 최신 데이터) → 캐시 재저장 흐름으로 정상 처리되는 경우는 괜찮습니다. 그런데 반대로, evict 직후 아직 트랜잭션이 커밋되기 전에 들어온 요청이 캐시 미스 → DB에서 이전 데이터 조회 → 구버전을 캐시에 올려버리는 상황도 이론적으로 가능합니다. 이 문제를 보완하기 위해 Delayed Double Delete(무효화 → 쓰기 → 일정 시간 후 한 번 더 무효화)도 고려해봤습니다만, 구현 복잡도 대비 실익이 크지 않다고 판단해 적용하지 않았습니다. 실무에서는 이 짧은 stale 구간을 어떻게 다루시는지 궁금합니다. 허용 가능한 수준으로 보고 TTL 만료 이후 자연 갱신을 기다리는 방식으로 운영하시는지, 아니면 별도로 커버하는 방법이 있으신지 궁금합니다!!

Namjin-kimm and others added 6 commits March 13, 2026 08:48
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ProductDetailCacheEvictEvent: 상품 ID 기반 상세 캐시 핀포인트 무효화 이벤트 추가
- ProductCacheRepository: 상세 캐시 인터페이스(getDetail/saveDetail/evictDetail) 추가
- ProductCacheRepositoryImpl: 상세 캐시 구현 (키: loopers:product:detail:{id}, Replica 읽기 / Master 쓰기)
- ProductCacheEventListener: ProductDetailCacheEvictEvent 핸들러 추가 (AFTER_COMMIT)
- application.yml: product:detail TTL 60초 설정 (민감 정보는 이벤트 무효화, likeCount stale 허용 범위)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ProductFacade.findById(): Cache-Aside 패턴 적용 (미스 시 DB 조회 후 상세 캐시 저장)
- ProductAdminFacade: 상품 수정/삭제 시 상세 캐시 무효화 이벤트 발행
- OrderFacade: 주문 재고 차감 후 해당 상품 상세 캐시 무효화 이벤트 발행
- BrandAdminFacade: 브랜드명 수정/삭제 시 목록 캐시 + 해당 브랜드 상품 상세 캐시 무효화 이벤트 발행 (기존 누락 보완)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 채택 4개(idx_brand_deleted_created/price/likes, idx_deleted_created)와 미채택 2개(idx_deleted_price/likes)의 선택 근거 명시
- 결정 기준: 브랜드 필터 유무별 사용 빈도, 선택도, 쓰기 비용 트레이드오프

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

coderabbitai bot commented Mar 13, 2026

📝 Walkthrough

Walkthrough

이 변경 사항은 Spring 이벤트 기반의 Redis 캐싱 아키텍처를 도입한다. 상품 데이터 변경 시 ApplicationEventPublisher를 통해 캐시 무효화 이벤트를 발행하고, ProductCacheEventListener가 트랜잭션 커밋 후 캐시를 삭제한다. ProductFacade에는 Cache-Aside 패턴을 적용하고, 데이터베이스 인덱스를 추가하며, k6 성능 테스트 스크립트를 포함한다.

Changes

Cohort / File(s) Summary
캐시 이벤트 및 저장소 계층
ProductCacheEvictEvent.java, ProductDetailCacheEvictEvent.java, ProductCacheRepository.java
마커 이벤트 타입 및 캐시 인터페이스 추가. 캐시 저장소는 목록/상세 조회, 저장, 무효화 메서드 정의.
캐시 이벤트 리스너 및 구현
ProductCacheEventListener.java, ProductCacheRepositoryImpl.java
이벤트 리스너는 AFTER_COMMIT 후 캐시 무효화. Redis 구현은 읽기/쓰기 분리, 예외 처리, SCAN 기반 대량 삭제로 블로킹 방지.
캐시 설정
CacheConfig.java, CacheProperties.java
TTL 오버라이드, 지터 적용(스탠피드 완화), 페이지 기반 캐서빌리티 필터링 추가.
Facade 계층 캐시 이벤트 발행
BrandAdminFacade.java, OrderFacade.java, ProductAdminFacade.java
브랜드/상품 변경 시 ProductCacheEvictEvent 발행, 주문 처리 시 ProductDetailCacheEvictEvent 발행.
ProductFacade 캐싱 로직
ProductFacade.java, ProductPageResult.java
Cache-Aside 패턴 구현: 조회 시 캐시 확인 후 미스 시 DB 조회 및 저장. ProductPageResult로 페이지 정보 캡슐화.
도메인 및 쿼리 최적화
Product.java, ProductJpaRepository.java
네 가지 복합 인덱스 추가(brand_id, deleted_at, created_at/price/like_count). 비관적 잠금 순서 결정화(데드락 회피).
API 계층 타입 변경
ProductV1Controller.java, ProductV1Dto.java
ProductPageResult로 변경하여 캐싱 가능한 타입으로 통일.
설정 파일
application.yml, jpa.yml
캐시 TTL(기본 300초), 지터(30초), 페이지 한계(3), 개별 TTL 오버라이드 추가. 하이버네이트 통계 활성화, DDL 검증 모드 전환.
성능 테스트 스크립트
cache_test.js, test_cached.js, test_uncached.js, test_extreme.js
k6 기반 캐시 적중/미스, 극한 부하 시나리오 테스트. 응답 시간 및 실패율 임계값 설정.

Sequence Diagram

sequenceDiagram
    participant Client as Client
    participant Controller as API Controller
    participant Facade as ProductFacade
    participant Cache as ProductCacheRepository
    participant DB as Database
    participant Publisher as ApplicationEventPublisher
    participant Listener as ProductCacheEventListener
    participant Redis as Redis

    Client->>Controller: GET /products?brandId=1&page=0
    Controller->>Facade: findAll(brandId, pageable)
    
    Facade->>Facade: buildCacheKey(brandId, pageable)
    Facade->>Cache: getList(cacheKey)
    Cache->>Redis: GET loopers:product:list:...
    
    alt Cache Hit
        Redis-->>Cache: ProductPageResult
        Cache-->>Facade: Optional<ProductPageResult>
        Facade-->>Controller: ProductPageResult
        Controller-->>Client: 200 OK (cached)
    else Cache Miss
        Redis-->>Cache: null
        Cache-->>Facade: Optional.empty()
        Facade->>DB: fetchFromDb(brandId, pageable)
        DB-->>Facade: Page<Product>
        Facade->>Facade: map to ProductPageResult
        Facade->>Cache: saveList(cacheKey, result)
        Cache->>Redis: SET with TTL
        Redis-->>Cache: OK
        Facade-->>Controller: ProductPageResult
        Controller-->>Client: 200 OK (uncached)
    end
    
    Client->>Controller: PUT /products/{id}
    Controller->>Facade: update(id, data)
    Facade->>DB: save(product)
    DB-->>Facade: updated Product
    Facade->>Publisher: publishEvent(ProductCacheEvictEvent)
    Publisher->>Listener: handleProductChanged(event)
    Listener->>Cache: evictAll()
    Cache->>Redis: DEL loopers:product:list:*
    Redis-->>Cache: deleted count
    Facade->>Publisher: publishEvent(ProductDetailCacheEvictEvent(id))
    Publisher->>Listener: handleProductDetailChanged(event)
    Listener->>Cache: evictDetail(id)
    Cache->>Redis: DEL loopers:product:detail:{id}
    Redis-->>Cache: deleted
    Facade-->>Controller: updated
    Controller-->>Client: 200 OK
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes


운영 관점 지적 사항

1. 캐시 워밍 전략 부재

문제점: Cache-Aside 패턴은 초기 조회 시 캐시 미스를 피할 수 없다. 트래픽이 집중된 페이지(예: page=0)에 대해 애플리케이션 시작 직후 또는 전체 캐시 무효화 후 데이터베이스 부하가 급증할 수 있다.

수정안:

  • 애플리케이션 초기화 시 인기 페이지(page=0, 1)에 대한 캐시 워밍 로직 추가 필요
  • CommandLineRunner 또는 ApplicationReadyEvent 리스너를 통해 상위 브랜드별 첫 페이지 사전 캐싱

추가 테스트:

  • 캐시 flush 후 순간 QPS 증가 시나리오 성능 측정
  • 데이터베이스 연결 풀 고갈 여부 모니터링

2. 이벤트 리스너 실패 시 재시도 전략 부재

문제점: ProductCacheEventListener가 Redis 접근 실패 시 예외를 로그만 하고 무시한다. 캐시 동기화 실패 시 데이터 일관성 문제가 발생할 수 있으며, 모니터링 알림 없이 조용히 실패할 수 있다.

수정안:

  • @Transactional(propagation = Propagation.REQUIRES_NEW) 추가하여 이벤트 처리를 별도 트랜잭션으로 격리
  • 실패 시 재시도 로직 추가 (Spring Retry 또는 Resilience4j)
  • 임계값 초과 실패 시 Alert 발생

추가 테스트:

  • Redis 다운 시나리오에서 메인 비즈니스 로직 정상 동작 확인
  • 캐시 무효화 실패 후 다음 조회에서 신선한 데이터 반환 확인

3. 캐시 일관성 보장 부족

문제점: OrderFacade에서 재고 감소 후 ProductDetailCacheEvictEvent 발행하지만, 다른 트랜잭션에서 동시에 캐시를 조회하는 경우 경합 조건이 발생할 수 있다. AFTER_COMMIT 이벤트라도 이벤트 처리 지연(락 대기 등)으로 인한 시간 격차가 존재한다.

수정안:

  • 캐시 무효화 시 TTL을 0에 가깝게 설정하거나 강제 삭제
  • 캐시 버전 관리 도입 (데이터 내부에 버전 필드 추가)
  • Read-Through 캐시 패턴 고려 (Redis에만 의존하지 말고 응용 계층에서 버전 확인)

추가 테스트:

  • 동시 다중 업데이트 후 캐시 조회 시 항상 최신 데이터 반환 확인
  • 캐시 TTL 경과 후 자동 갱신 검증

4. CacheProperties의 jitterRangeSeconds 구현

문제점: getTtlSeconds(String cacheName) 메서드에서 지터가 ±jitterRangeSeconds 범위로 랜덤하게 적용된다고 명시되어 있지만, 구현이 충분히 테스트되지 않았을 수 있다. 불균형한 지터 분포는 스탠피드 완화 효과를 감소시킨다.

수정안:

  • 지터 계산이 균등분포 Random을 사용하는지 확인
  • 설정된 TTL 범위(예: 60~300초)에서 지터로 인한 예상 분포 검증
  • ThreadLocalRandom 사용하여 멀티스레드 환경 최적화

추가 테스트:

  • 동일 캐시에 대해 다수 요청 시 TTL 분포가 균등분포를 따르는지 통계 검증
  • 대량의 캐시 만료 시점이 동시성을 유발하지 않는지 확인

5. 페이지 기반 캐서빌리티 필터링의 한계

문제점: isCacheable(int pageNumber) 메서드가 maxCacheablePage=3으로 고정되어 있다. 깊은 페이징(page=10 이상) 조회가 많은 경우, 캐시 없는 직접 DB 접근으로 부하가 집중될 수 있다.

수정안:

  • 페이지 깊이별로 다른 TTL 전략 적용 (깊은 페이지는 더 짧은 TTL)
  • 실제 트래픽 분포를 모니터링하고 maxCacheablePage 동적 조정
  • 깊은 페이징 대신 커서 기반 페이지네이션 마이그레이션 검토

추가 테스트:

  • page=0~2 vs page>=3 조회 성능 차이 측정
  • 설정값 변경 후 데이터베이스 부하 변화 모니터링

6. Redis 키 삭제의 SCAN 사용

문제점: evictAll()에서 SCAN을 통해 loopers:product:list:* 패턴 키를 삭제하지만, SCAN 기반 반복 삭제 시 레이턴시 스파이크가 발생할 수 있다. 대규모 키 셋에서는 여러 라운드의 SCAN 필요.

수정안:

  • SCAN 대신 Redis Streams 또는 Pub/Sub을 통해 비동기 캐시 무효화 처리
  • 캐시 무효화를 별도 스케줄러에서 처리 (예: ScheduledExecutorService)
  • 배치 크기(SCAN count) 및 타임아웃 설정 추가

추가 테스트:

  • 수백만 건의 캐시 키 존재 시 evictAll() 응답 시간 측정
  • SCAN 중 새 요청 들어올 시 지연 시간 측정

7. 데이터베이스 인덱스 검증 부족

문제점: 네 가지 복합 인덱스 추가(idx_brand_deleted_created, idx_brand_deleted_price 등)는 쿼리 최적화를 의도하지만, 실제 쿼리 플랜이 인덱스를 활용하는지 검증되지 않았다. 특히 idx_brand_deleted_created 인덱스에서 created_at DESC는 역순 스캔 비용을 증가시킬 수 있다.

수정안:

  • EXPLAIN을 통해 실제 쿼리가 추가된 인덱스를 사용하는지 확인
  • 인덱스 통계(cardinality) 업데이트 필요성 검토
  • 느린 쿼리 로그 활성화하여 비효율 인덱스 탐지

추가 테스트:

  • ProductFacade의 findAll(brandId, pageable) 쿼리 실행 계획 검증
  • 다양한 정렬 조건(최신순, 가격순, 좋아요순)별 인덱스 활용 확인

8. k6 성능 테스트의 실제 부하 시뮬레이션 한계

문제점: 제공된 테스트 스크립트(test_cached.js, test_uncached.js 등)에서 매 요청 후 sleep(0.5s) 또는 짧은 슬립이 있어, 실제 고부하 환경의 동시성을 정확히 시뮬레이션하지 못할 수 있다. 특히 test_extreme.js의 최대 500 VU는 로컬 개발 환경에서는 유용하나 프로덕션 스케일 검증이 부족하다.

수정안:

  • 요청 간 슬립 제거하거나 동적 조정 (실제 평균 응답 시간 기반)
  • 캐시 미스 시나리오 추가 (예: 무작위 페이지 번호로 캐시 히트율 변동)
  • 데이터베이스 연결 풀 고갈, Redis 타임아웃 등 실패 시나리오 포함

추가 테스트:

  • 프로덕션 리플리케이션 환경에서 실제 부하 테스트 실행
  • 메모리/CPU 프로파일링과 함께 병목 구간 식별

보안 관점 추가 사항

  • Redis 접근 권한 제어(ACL) 설정 확인 필요
  • CacheProperties의 TTL, jitterRangeSeconds 값이 외부 설정 주입으로 변조되지 않도록 검증
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 61.76% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 주요 변경사항인 인덱스 및 캐시 적용을 명확하게 표현하고 있으며, 개발자의 이름도 명시되어 있다.
Description check ✅ Passed PR 설명이 요구되는 모든 필수 섹션을 충실하게 다루고 있다.

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

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

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

❤️ Share

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

Tip

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

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

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (12)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailCacheEvictEvent.java (1)

8-9: productId null 검증 부재

이벤트 발행 시 productId가 null로 전달되면 캐시 무효화 로직에서 예상치 못한 동작이 발생할 수 있다. 방어적으로 생성 시점에 null 검증을 추가하는 것을 권장한다.

♻️ 방어적 null 검증 추가
-public record ProductDetailCacheEvictEvent(Long productId) {
+public record ProductDetailCacheEvictEvent(Long productId) {
+    public ProductDetailCacheEvictEvent {
+        if (productId == null) {
+            throw new IllegalArgumentException("productId must not be 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/application/product/ProductDetailCacheEvictEvent.java`
around lines 8 - 9, The ProductDetailCacheEvictEvent record lacks a null check
for productId; add a compact canonical constructor to
ProductDetailCacheEvictEvent that validates productId (e.g., via
Objects.requireNonNull or explicit check) and throws a clear exception when null
is passed so the event cannot be created with a null productId.
apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageResult.java (1)

18-26: 캐시 저장 시 List 방어적 복사 부재

page.getContent()가 반환하는 리스트를 그대로 사용하고 있다. 이 객체가 Redis 캐시에 저장되고 역직렬화되어 재사용될 때는 문제가 없으나, 로컬 메모리에 캐싱하거나 반환된 객체를 재사용하는 경우 외부에서 리스트를 수정하면 캐시 데이터가 오염될 수 있다. 불변 리스트로 감싸면 이러한 위험을 차단할 수 있다.

♻️ 불변 리스트로 방어적 복사
     public static ProductPageResult from(Page<ProductInfo> page) {
         return new ProductPageResult(
-                page.getContent(),
+                List.copyOf(page.getContent()),
                 page.getNumber(),
                 page.getSize(),
                 page.getTotalElements(),
                 page.getTotalPages()
         );
     }
🤖 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/ProductPageResult.java`
around lines 18 - 26, ProductPageResult.from currently uses page.getContent()
directly which risks mutable list leakage; change the constructor call to pass
an immutable defensive copy of the content (e.g., List.copyOf(page.getContent())
or Collections.unmodifiableList(new ArrayList<>(page.getContent()))) so callers
and caches cannot mutate the stored list; update ProductPageResult.from to wrap
page.getContent() with the chosen defensive-copy/immutability call and ensure
any constructor/field types accept List<ProductInfo>.
scripts/test_uncached.js (1)

27-31: BASE_URL 하드코딩으로 인한 환경 유연성 부족

localhost:8080이 하드코딩되어 있어 CI/CD 파이프라인이나 스테이징 환경에서 테스트 시 스크립트 수정이 필요하다. k6의 환경 변수 기능을 활용하면 재사용성이 높아진다.

♻️ 환경 변수를 통한 BASE_URL 주입
+const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
+
 export default function () {
-    const res = http.get('http://localhost:8080/api/v1/products?page=10&sort=latest');
+    const res = http.get(`${BASE_URL}/api/v1/products?page=10&sort=latest`);
     duration.add(res.timings.duration);
     check(res, { 'status 200': (r) => r.status === 200 });
     sleep(0.5);
 }

실행 예시:

K6_WEB_DASHBOARD=true k6 run -e BASE_URL=http://staging:8080 scripts/test_uncached.js
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/test_uncached.js` around lines 27 - 31, The test currently hardcodes
'http://localhost:8080' inside the exported default function and should use a k6
environment variable instead: read __ENV.BASE_URL (falling back to
'http://localhost:8080' if unset) and build the request URL from that base
before calling http.get; update the URL usage in the exported default function
(where http.get(...) is called) to use the constructed base +
'/api/v1/products?page=10&sort=latest' so the script can be run in CI/staging by
passing BASE_URL.
scripts/cache_test.js (1)

23-23: BASE_URL 환경 변수 미지원

test_uncached.js와 마찬가지로 하드코딩된 URL은 다양한 테스트 환경에서 유연성을 제한한다.

♻️ 환경 변수를 통한 BASE_URL 주입
-const BASE_URL = 'http://localhost:8080';
+const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/cache_test.js` at line 23, Replace the hardcoded BASE_URL constant
with an environment-driven value: change the BASE_URL declaration so it reads
from process.env.BASE_URL (with a sensible default like 'http://localhost:8080'
if not set), update any tests that reference BASE_URL to use the new constant,
and ensure test setup/documentation mentions setting BASE_URL (mirroring the
approach used in test_uncached.js); modify the BASE_URL constant declaration in
scripts/cache_test.js accordingly.
scripts/test_cached.js (1)

27-31: BASE_URL 하드코딩

다른 k6 스크립트와 동일하게 환경 변수를 통한 BASE_URL 주입을 권장한다.

♻️ 환경 변수 적용
+const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';
+
 export default function () {
-    const res = http.get('http://localhost:8080/api/v1/products?page=0&sort=latest');
+    const res = http.get(`${BASE_URL}/api/v1/products?page=0&sort=latest`);
     duration.add(res.timings.duration);
     check(res, { 'status 200': (r) => r.status === 200 });
     sleep(0.5);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/test_cached.js` around lines 27 - 31, The test_cached.js file
hardcodes the base URL in the exported default function (see the http.get call),
so update the function to read a BASE_URL from the environment (e.g.,
__ENV.BASE_URL) with a sensible fallback and use that when building the request
URI for http.get('/api/v1/products?page=0&sort=latest'); ensure the rest of the
function (duration.add, check, sleep) continues to use the response object
unchanged; replace the literal 'http://localhost:8080' with the composed
BASE_URL variable wherever used.
apps/commerce-api/src/main/resources/application.yml (1)

32-38: 캐시 설정 검토 - 환경별 분리 고려

TTL 설정과 jitter 적용이 적절하다. 다만 test 프로파일에서도 동일한 캐시 설정이 적용되므로, 통합 테스트 시 캐시로 인한 테스트 격리 문제가 발생할 수 있다.

권장 조치:

  • test 프로파일에서 max-cacheable-page: 0 또는 별도 캐시 비활성화 고려
  • 또는 테스트 코드에서 @DirtiesContext 또는 캐시 수동 클리어 적용
🤖 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 32 - 38,
Current application.yml cache settings apply to all profiles and can cause test
isolation issues; modify configuration to disable or reduce caching for the test
profile by moving/overriding the cache keys under a "test" profile (e.g., set
cache.max-cacheable-page: 0 or disable cache TTLs) so tests run without cached
responses, or alternatively document in tests to use `@DirtiesContext` or
explicitly clear the cache before/after tests; change the YAML to include a test
profile override for the cache block (target keys: cache.default-ttl-seconds,
cache.jitter-range-seconds, cache.max-cacheable-page, cache.ttl-overrides) so
test runs use the adjusted values.
apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEventListener.java (2)

26-29: 상세 캐시 무효화 핸들러도 동일하게 방어적 예외 처리를 고려할 수 있다.

앞선 코멘트와 동일한 이유로, 방어적 try-catch 추가를 선택적으로 검토할 수 있다.

🤖 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/ProductCacheEventListener.java`
around lines 26 - 29, Add defensive exception handling to the
ProductCacheEventListener.handleProductDetailChanged method: wrap the call to
productCacheRepository.evictDetail(event.productId()) in a try-catch that
catches any Exception, logs the error with context including the productId and
the exception (using the class logger), and avoids rethrowing so the event
listener does not break transaction commit processing for other listeners;
follow the same pattern used for the other cache eviction handler in this class.

20-23: AFTER_COMMIT 이벤트 핸들러의 예외 전파 동작을 확인해야 한다.

@TransactionalEventListener(phase = AFTER_COMMIT) 핸들러에서 예외가 발생하면 트랜잭션은 이미 커밋된 상태이므로 롤백되지 않지만, 예외가 전파되어 호출자에게 영향을 줄 수 있다. 현재 ProductCacheRepositoryImpl.evictAll()이 내부에서 예외를 흡수하므로 문제없으나, 향후 구현 변경 시 방어적으로 try-catch를 추가하는 것이 안전하다.

🛡️ 방어적 예외 처리 추가 제안
 `@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT)
 public void handleProductChanged(ProductCacheEvictEvent event) {
+    try {
         productCacheRepository.evictAll();
+    } catch (Exception e) {
+        log.warn("상품 목록 캐시 무효화 이벤트 처리 실패", 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/ProductCacheEventListener.java`
around lines 20 - 23, The AFTER_COMMIT event handler handleProductChanged in
ProductCacheEventListener should defensively catch and handle any runtime
exceptions from productCacheRepository.evictAll() to avoid propagating errors
after commit; wrap the call in a try-catch, log the exception (using the class
Logger) with context like "failed to evict product cache" and swallow or handle
it so exceptions from future changes in ProductCacheRepositoryImpl.evictAll()
won't bubble up and affect callers.
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java (1)

82-85: 대량 키 삭제 시 파이프라인 또는 UNLINK 사용을 고려해야 한다.

현재 구현은 cursor.hasNext() 루프에서 키당 개별 DEL 호출하므로, 캐시 키가 많을 경우 N번의 Redis 왕복이 발생한다.

운영 관점 문제점:

  • 캐시 키 수천 개 시 무효화 지연 발생
  • AFTER_COMMIT 핸들러 지연으로 응답 시간 영향 가능

개선 방안:

  1. UNLINK(비동기 삭제) 사용으로 Redis 블로킹 감소
  2. 키 수집 후 delete(Collection<K>) 배치 삭제
♻️ 배치 삭제 개선안
 try (var cursor = connection.scan(options)) {
+    List<byte[]> keysToDelete = new ArrayList<>();
     while (cursor.hasNext()) {
-        connection.del(cursor.next());
+        keysToDelete.add(cursor.next());
+        if (keysToDelete.size() >= 100) {
+            connection.del(keysToDelete.toArray(new byte[0][]));
+            keysToDelete.clear();
+        }
+    }
+    if (!keysToDelete.isEmpty()) {
+        connection.del(keysToDelete.toArray(new byte[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/product/ProductCacheRepositoryImpl.java`
around lines 82 - 85, ProductCacheRepositoryImpl currently iterates
cursor.hasNext() and calls connection.del(key) per key which causes N Redis
round-trips; instead collect keys from the cursor into a list (e.g.,
List<byte[]> or List<String>) and perform a batched non-blocking delete using
connection.unlink(keys) if available or connection.del(Collection) in one call;
update the scan handling (the block around connection.scan(options), cursor and
connection.del(cursor.next())) to accumulate keys and then call
connection.unlink(keys) (or connection.del(keys) as fallback) to minimize
round-trips and avoid blocking Redis during large cache invalidations.
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (2)

71-78: 정렬 조건이 없을 때 캐시 키 포맷을 명확히 해야 한다.

pageable.getSort()Sort.unsorted()인 경우 sortPart가 빈 문자열이 되어 키가 loopers:product:list:brand=all:sort=:page=0:size=20 형태가 된다. 기능적으로 문제없으나, 가독성과 디버깅 편의를 위해 기본값 지정을 고려할 수 있다.

♻️ 빈 정렬 조건 처리
 String sortPart = pageable.getSort().stream()
         .map(order -> order.getProperty() + "_" + order.getDirection())
         .collect(Collectors.joining(","));
+if (sortPart.isEmpty()) {
+    sortPart = "default";
+}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java`
around lines 71 - 78, The buildCacheKey method builds a key where sortPart can
be empty when pageable.getSort() is unsorted; update buildCacheKey to detect an
empty sortPart (from pageable.getSort() / sortPart) and substitute a clear
default (e.g., "unsorted" or "none") so the returned key always contains an
explicit sort token; locate buildCacheKey and the construction of sortPart
(pageable.getSort().stream() ... Collectors.joining) and ensure the final
String.format uses the non-empty/defaulted sort value.

29-35: findById의 Cache-Aside 패턴에서 캐시 저장 실패 시 로깅이 필요할 수 있다.

productCacheRepository.saveDetail()은 예외를 내부에서 흡수하나, 호출 측에서 캐시 저장 성공/실패를 인지할 수 없다. 현재 구현은 캐시 저장 실패해도 정상 응답하므로 기능적으로 문제없으나, 캐시 적중률 모니터링을 위해 메트릭 추가를 고려할 수 있다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java`
around lines 29 - 35, In ProductFacade's cache-aside logic (the lambda used with
productCacheRepository.getDetail and the call to
productCacheRepository.saveDetail), add handling so failures to persist the
cache are observable: either change productCacheRepository.saveDetail to return
a boolean/Result and check it here, or wrap the existing saveDetail call in a
try/catch that logs the error via the class logger (and/or increments a metric
counter such as cacheSaveFailure) when an exception or false result occurs;
ensure you reference ProductFacade, productCacheRepository.getDetail,
productService.findById, brandService.findById and
productCacheRepository.saveDetail so the failure is logged/recorded while still
returning the ProductInfo as before.
apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java (1)

51-53: 브랜드 수정/삭제 시 대량 상품 캐시 무효화 이벤트 발행 패턴을 배치 처리로 최적화 검토 권장

현재 구현은 productIds.forEach(pid -> eventPublisher.publishEvent(new ProductDetailCacheEvictEvent(pid)))로 개별 이벤트를 발행한다. 이 패턴은 OrderFacade에서도 동일하게 사용되며, 이벤트는 AFTER_COMMIT 단계에서 안전하게 처리된다.

현재 설계의 타당성:

  • findIdsByBrandIdAndDeletedAtIsNull은 SELECT p.id 구문으로 효율적 (메모리 효율적)
  • 1분 TTL + 낮은 관리자 수정 빈도 조건에서 개별 캐시 무효화는 허용 가능
  • 브랜드명 수정 시 상품 목록/상세 캐시에 스냅샷된 브랜드명을 반영해야 하므로 정확한 무효화 필요

향후 최적화 방안:
상품 수가 증가하거나 관리자 수정이 빈번해진 후 Micrometer 메트릭으로 캐시 hit rate 측정 시 배치 무효화 이벤트(ProductDetailCacheBatchEvictEvent(List<Long> productIds)) 도입 검토한다. 현 단계에서는 모니터링과 필요시 추후 개선을 권장한다.

🤖 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/brand/BrandAdminFacade.java`
around lines 51 - 53, Replace the per-id event loop with a single batch event to
avoid firing many events: keep publishing the existing ProductCacheEvictEvent,
then publish one ProductDetailCacheBatchEvictEvent(productIds) instead of
productIds.forEach(...). Ensure ProductDetailCacheBatchEvictEvent(List<Long>
productIds) exists (or create it) and that eventPublisher.publishEvent is used
with that batch event so downstream listeners can handle batch invalidation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@apps/commerce-api/src/main/java/com/loopers/config/CacheProperties.java`:
- Around line 23-27: The compact constructor for record CacheProperties must
defensively copy and wrap ttlOverrides as an immutable map to prevent external
mutation; modify the compact constructor logic in CacheProperties to replace
direct new HashMap<>() allocation with an immutable copy (e.g., use
Map.copyOf(...) or Collections.unmodifiableMap(new HashMap<>(...))) so that
ttlOverrides is always stored as an unmodifiable map whether the incoming value
is null or provided, ensuring any provided map is copied and then made immutable
inside the CacheProperties compact constructor.

In `@apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java`:
- Around line 17-25: Product.java's `@Table/`@Index annotations are only metadata
because jpa.yml sets ddl-auto: none, so the four indexes declared
(idx_brand_deleted_created, idx_brand_deleted_price, idx_brand_deleted_likes,
idx_deleted_created) are not created in production; fix this by adding a Flyway
migration (under db/migration) or other deployment DDL that runs the equivalent
ALTER TABLE ... ADD INDEX statements for those index names/columns (as noted in
product-index-analysis.md), and after deployment verify with SHOW INDEX FROM
product; to ensure the indexes created successfully.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java`:
- Around line 56-59: In ProductCacheRepositoryImpl update the catch blocks that
call log.warn with e.getMessage() so the exception is logged with its
cause/stacktrace: change the logging invocation in the catch for the product
list cache lookup (and the other similar catch blocks in this class) to pass the
Throwable as the last parameter to the logger instead of only e.getMessage() so
the full stack trace and cause chain are recorded (i.e., keep the existing
message format and placeholders like "상품 목록 캐시 조회 실패 (key={}): {}" but add the
exception object as the final argument for methods in ProductCacheRepositoryImpl
where the current pattern uses e.getMessage()).

In `@modules/jpa/src/main/resources/jpa.yml`:
- Around line 39-43: 현재 jpa.yml에서 hibernate.ddl-auto가 기본(local) 프로필에서 고정된
값(validate)이라 빈 DB 환경에서 애플리케이션이 기동 실패할 수 있으니, hibernate.ddl-auto 설정을 환경변수로 오버라이드
가능하게 변경하거나 별도 프로필(local-init)을 추가해 초기 실행시 create를 사용하도록 하세요; 구체적으로 jpa.yml의
hibernate.ddl-auto 항목을 LOCAL_JPA_DDL_AUTO 환경변수(예:
${LOCAL_JPA_DDL_AUTO:validate})로 바꾸거나 local-init 프로필을 만들어 ddl-auto=create로 설정하고
이후 기본 local 프로필은 validate로 유지하도록 하고, 변경 후 LOCAL_JPA_DDL_AUTO=create로 빈 MySQL에서
최초 기동 테스트와 재기동(validate) 테스트를 수행해 동작을 확인하세요.

In `@scripts/test_cached.js`:
- Around line 1-4: The comment claims the test covers "page=0, 1 - 캐시 히트 구간" but
the script only requests page=0; either update the comment to reflect that only
page=0 is exercised, or modify the test to also issue requests for page=1 (e.g.,
add an HTTP request or loop that hits both "page=0" and "page=1" so the
cache-hit window is covered). Locate the part of the script that constructs the
request/query param "page=0" and either change the top comment text to match or
add the additional request for "page=1" (or a pages array loop) so both pages
are exercised as claimed.

---

Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java`:
- Around line 51-53: Replace the per-id event loop with a single batch event to
avoid firing many events: keep publishing the existing ProductCacheEvictEvent,
then publish one ProductDetailCacheBatchEvictEvent(productIds) instead of
productIds.forEach(...). Ensure ProductDetailCacheBatchEvictEvent(List<Long>
productIds) exists (or create it) and that eventPublisher.publishEvent is used
with that batch event so downstream listeners can handle batch invalidation.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEventListener.java`:
- Around line 26-29: Add defensive exception handling to the
ProductCacheEventListener.handleProductDetailChanged method: wrap the call to
productCacheRepository.evictDetail(event.productId()) in a try-catch that
catches any Exception, logs the error with context including the productId and
the exception (using the class logger), and avoids rethrowing so the event
listener does not break transaction commit processing for other listeners;
follow the same pattern used for the other cache eviction handler in this class.
- Around line 20-23: The AFTER_COMMIT event handler handleProductChanged in
ProductCacheEventListener should defensively catch and handle any runtime
exceptions from productCacheRepository.evictAll() to avoid propagating errors
after commit; wrap the call in a try-catch, log the exception (using the class
Logger) with context like "failed to evict product cache" and swallow or handle
it so exceptions from future changes in ProductCacheRepositoryImpl.evictAll()
won't bubble up and affect callers.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailCacheEvictEvent.java`:
- Around line 8-9: The ProductDetailCacheEvictEvent record lacks a null check
for productId; add a compact canonical constructor to
ProductDetailCacheEvictEvent that validates productId (e.g., via
Objects.requireNonNull or explicit check) and throws a clear exception when null
is passed so the event cannot be created with a null productId.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java`:
- Around line 71-78: The buildCacheKey method builds a key where sortPart can be
empty when pageable.getSort() is unsorted; update buildCacheKey to detect an
empty sortPart (from pageable.getSort() / sortPart) and substitute a clear
default (e.g., "unsorted" or "none") so the returned key always contains an
explicit sort token; locate buildCacheKey and the construction of sortPart
(pageable.getSort().stream() ... Collectors.joining) and ensure the final
String.format uses the non-empty/defaulted sort value.
- Around line 29-35: In ProductFacade's cache-aside logic (the lambda used with
productCacheRepository.getDetail and the call to
productCacheRepository.saveDetail), add handling so failures to persist the
cache are observable: either change productCacheRepository.saveDetail to return
a boolean/Result and check it here, or wrap the existing saveDetail call in a
try/catch that logs the error via the class logger (and/or increments a metric
counter such as cacheSaveFailure) when an exception or false result occurs;
ensure you reference ProductFacade, productCacheRepository.getDetail,
productService.findById, brandService.findById and
productCacheRepository.saveDetail so the failure is logged/recorded while still
returning the ProductInfo as before.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageResult.java`:
- Around line 18-26: ProductPageResult.from currently uses page.getContent()
directly which risks mutable list leakage; change the constructor call to pass
an immutable defensive copy of the content (e.g., List.copyOf(page.getContent())
or Collections.unmodifiableList(new ArrayList<>(page.getContent()))) so callers
and caches cannot mutate the stored list; update ProductPageResult.from to wrap
page.getContent() with the chosen defensive-copy/immutability call and ensure
any constructor/field types accept List<ProductInfo>.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java`:
- Around line 82-85: ProductCacheRepositoryImpl currently iterates
cursor.hasNext() and calls connection.del(key) per key which causes N Redis
round-trips; instead collect keys from the cursor into a list (e.g.,
List<byte[]> or List<String>) and perform a batched non-blocking delete using
connection.unlink(keys) if available or connection.del(Collection) in one call;
update the scan handling (the block around connection.scan(options), cursor and
connection.del(cursor.next())) to accumulate keys and then call
connection.unlink(keys) (or connection.del(keys) as fallback) to minimize
round-trips and avoid blocking Redis during large cache invalidations.

In `@apps/commerce-api/src/main/resources/application.yml`:
- Around line 32-38: Current application.yml cache settings apply to all
profiles and can cause test isolation issues; modify configuration to disable or
reduce caching for the test profile by moving/overriding the cache keys under a
"test" profile (e.g., set cache.max-cacheable-page: 0 or disable cache TTLs) so
tests run without cached responses, or alternatively document in tests to use
`@DirtiesContext` or explicitly clear the cache before/after tests; change the
YAML to include a test profile override for the cache block (target keys:
cache.default-ttl-seconds, cache.jitter-range-seconds, cache.max-cacheable-page,
cache.ttl-overrides) so test runs use the adjusted values.

In `@scripts/cache_test.js`:
- Line 23: Replace the hardcoded BASE_URL constant with an environment-driven
value: change the BASE_URL declaration so it reads from process.env.BASE_URL
(with a sensible default like 'http://localhost:8080' if not set), update any
tests that reference BASE_URL to use the new constant, and ensure test
setup/documentation mentions setting BASE_URL (mirroring the approach used in
test_uncached.js); modify the BASE_URL constant declaration in
scripts/cache_test.js accordingly.

In `@scripts/test_cached.js`:
- Around line 27-31: The test_cached.js file hardcodes the base URL in the
exported default function (see the http.get call), so update the function to
read a BASE_URL from the environment (e.g., __ENV.BASE_URL) with a sensible
fallback and use that when building the request URI for
http.get('/api/v1/products?page=0&sort=latest'); ensure the rest of the function
(duration.add, check, sleep) continues to use the response object unchanged;
replace the literal 'http://localhost:8080' with the composed BASE_URL variable
wherever used.

In `@scripts/test_uncached.js`:
- Around line 27-31: The test currently hardcodes 'http://localhost:8080' inside
the exported default function and should use a k6 environment variable instead:
read __ENV.BASE_URL (falling back to 'http://localhost:8080' if unset) and build
the request URL from that base before calling http.get; update the URL usage in
the exported default function (where http.get(...) is called) to use the
constructed base + '/api/v1/products?page=10&sort=latest' so the script can be
run in CI/staging by passing BASE_URL.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e9550a76-75b1-4d48-b30d-dc92a5043bcd

📥 Commits

Reviewing files that changed from the base of the PR and between 25ab98b and 6f5f5dc.

⛔ Files ignored due to path filters (1)
  • .docs/product-index-analysis.md is excluded by !**/*.md and included by **
📒 Files selected for processing (24)
  • apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEventListener.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEvictEvent.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheRepository.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailCacheEvictEvent.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageResult.java
  • apps/commerce-api/src/main/java/com/loopers/config/CacheConfig.java
  • apps/commerce-api/src/main/java/com/loopers/config/CacheProperties.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.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/main/resources/application.yml
  • modules/jpa/src/main/resources/jpa.yml
  • scripts/cache_test.js
  • scripts/report_cached.html
  • scripts/report_uncached.html
  • scripts/test_cached.js
  • scripts/test_extreme.js
  • scripts/test_uncached.js

Comment on lines +23 to +27
public CacheProperties {
if (ttlOverrides == null) {
ttlOverrides = new HashMap<>();
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

ttlOverrides Map의 불변성을 보장해야 한다.

compact constructor에서 new HashMap<>()을 직접 할당하면, record 필드는 final이지만 Map 내용은 외부에서 수정 가능하다. @ConfigurationProperties로 바인딩된 설정 객체는 런타임에 변경되지 않아야 한다.

운영 관점 문제점:

  • 실수로 cacheProperties.ttlOverrides().put(...) 호출 시 전역 설정 오염
  • 테스트에서 설정 변경 시 다른 테스트에 영향
🛡️ 불변 Map으로 감싸기
 public CacheProperties {
     if (ttlOverrides == null) {
-        ttlOverrides = new HashMap<>();
+        ttlOverrides = Map.of();
+    } else {
+        ttlOverrides = Map.copyOf(ttlOverrides);
     }
 }
📝 Committable suggestion

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

Suggested change
public CacheProperties {
if (ttlOverrides == null) {
ttlOverrides = new HashMap<>();
}
}
public CacheProperties {
if (ttlOverrides == null) {
ttlOverrides = Map.of();
} else {
ttlOverrides = Map.copyOf(ttlOverrides);
}
}
🤖 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/config/CacheProperties.java`
around lines 23 - 27, The compact constructor for record CacheProperties must
defensively copy and wrap ttlOverrides as an immutable map to prevent external
mutation; modify the compact constructor logic in CacheProperties to replace
direct new HashMap<>() allocation with an immutable copy (e.g., use
Map.copyOf(...) or Collections.unmodifiableMap(new HashMap<>(...))) so that
ttlOverrides is always stored as an unmodifiable map whether the incoming value
is null or provided, ensuring any provided map is copied and then made immutable
inside the CacheProperties compact constructor.

Comment on lines +17 to +25
@Table(
name = "product",
indexes = {
@Index(name = "idx_brand_deleted_created", columnList = "brand_id, deleted_at, created_at DESC"),
@Index(name = "idx_brand_deleted_price", columnList = "brand_id, deleted_at, price"),
@Index(name = "idx_brand_deleted_likes", columnList = "brand_id, deleted_at, like_count DESC"),
@Index(name = "idx_deleted_created", columnList = "deleted_at, created_at DESC")
}
)
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
# Hibernate가 생성하는 실제 DDL 확인
rg -n "ddl-auto|hbm2ddl" --type=yaml apps/commerce-api/src/main/resources/

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Find application configuration files
find apps/commerce-api/src/main/resources -type f \( -name "application*.yml" -o -name "application*.properties" -o -name "*.properties" \) -exec head -50 {} + 2>/dev/null | head -100

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

Length of output: 1246


🏁 Script executed:

#!/bin/bash
# Check for Flyway migration directory
fd -t d "db|migration|sql" apps/commerce-api/src/main/resources --max-depth 3

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# View Product.java file to understand index definitions
cat -n apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java | head -50

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

Length of output: 2147


🏁 Script executed:

#!/bin/bash
# Check Hibernate version in pom.xml or build config
rg "hibernate|jpa" -A 2 apps/commerce-api/pom.xml 2>/dev/null | head -50

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check jpa.yml file
cat -n apps/commerce-api/src/main/resources/jpa.yml

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

Length of output: 161


🏁 Script executed:

#!/bin/bash
# Check pom.xml for Hibernate/JPA dependencies
cat apps/commerce-api/pom.xml | grep -A 3 -B 1 "hibernate\|spring-boot-starter-data-jpa\|flyway" | head -100

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

Length of output: 142


🏁 Script executed:

#!/bin/bash
# Search for any database-related files (schema, migration, init)
find apps/commerce-api -type f \( -name "*.sql" -o -name "schema*" -o -name "*migration*" \) 2>/dev/null

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check for Flyway in parent pom.xml
cat pom.xml 2>/dev/null | grep -A 3 -B 1 "flyway" | head -50

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check directory structure
ls -la apps/commerce-api/src/main/resources/ 2>/dev/null | head -50

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

Length of output: 260


🏁 Script executed:

#!/bin/bash
# Find all YAML files
find apps/commerce-api/src/main/resources -name "*.yml" -o -name "*.yaml" 2>/dev/null

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

Length of output: 137


🏁 Script executed:

#!/bin/bash
# Check parent pom.xml structure
cat pom.xml | head -100

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

Length of output: 124


🏁 Script executed:

#!/bin/bash
# Check if jpa.yml exists anywhere in the repo
find . -name "jpa.yml" 2>/dev/null

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

Length of output: 125


🏁 Script executed:

#!/bin/bash
# List all application config files
find . -path "*/src/main/resources/*" -name "application*" -type f 2>/dev/null

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

Length of output: 256


🏁 Script executed:

#!/bin/bash
# Read jpa.yml file
cat -n ./modules/jpa/src/main/resources/jpa.yml

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

Length of output: 2993


🏁 Script executed:

#!/bin/bash
# Check for build files (gradle, maven)
find . -maxdepth 2 -name "build.gradle*" -o -name "pom.xml" 2>/dev/null | head -10

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

Length of output: 103


🏁 Script executed:

#!/bin/bash
# Look for Flyway migration directories
find . -type d -name "db" -o -type d -name "migration" -o -type d -name "flyway" 2>/dev/null | head -20

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Search for schema.sql or database initialization files
find . -name "schema.sql" -o -name "init*.sql" -o -name "database.sql" 2>/dev/null

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check build.gradle.kts for Flyway/Liquibase
cat build.gradle.kts | grep -i "flyway\|liquibase" -A 2 -B 2

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check if there are any database schema files at all
find . -name "*.sql" 2>/dev/null | head -20

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check gradle subprojects or modules that might handle database
find . -maxdepth 2 -name "build.gradle.kts" -exec grep -l "flyway\|liquibase\|database" {} \; 2>/dev/null

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check README or documentation for schema management
find . -maxdepth 3 -name "README*" -o -name "*.md" | xargs grep -l "schema\|database\|ddl\|migration" 2>/dev/null | head -10

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check if there's a separate directory for database scripts
ls -la . | grep -i "db\|database\|schema\|sql\|migration"

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Look for any reference to database initialization in gradle files
find . -name "build.gradle.kts" -exec cat {} \; 2>/dev/null | grep -i "database\|schema" -A 2 -B 2 | head -50

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check git for any deleted SQL files that might have existed
git log --name-only --pretty=format: -- "*.sql" 2>/dev/null | sort -u | head -20

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check if there's actual database schema in git tracked files
git ls-files | grep -E "\.sql|\.ddl|schema|migration" 2>/dev/null | head -20

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check the actual Hibernate version in gradle
cat build.gradle.kts | grep -i "spring\|hibernate" | head -30

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

Length of output: 957


🏁 Script executed:

#!/bin/bash
# Look for any documentation about how indexes are managed
find . -type f \( -name "*.md" -o -name "*.txt" -o -name "*.rst" \) -exec grep -l "index\|schema\|ddl" {} \; 2>/dev/null | head -10

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

Length of output: 118


🏁 Script executed:

#!/bin/bash
# Check if indexes are referenced anywhere in repository documentation
rg "idx_brand_deleted_created|idx_deleted_created|@Index" --type=md --type=txt --type=yaml 2>/dev/null | head -20

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Read the product index analysis documentation
cat -n ./.docs/product-index-analysis.md

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

Length of output: 17488


🏁 Script executed:

#!/bin/bash
# Check if there's any schema initialization or management documentation
find .docs -type f \( -name "*.md" -o -name "*.txt" \) -exec cat {} \; 2>/dev/null | head -200

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

Length of output: 4140


🏁 Script executed:

#!/bin/bash
# Verify if there's any SQL execution or initialization mechanism for creating indexes
rg "CREATE INDEX|ALTER TABLE.*ADD INDEX" --type=sql --type=txt 2>/dev/null | head -20

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

Length of output: 67


🏁 Script executed:

#!/bin/bash
# Check if there are any scripts or documentation about database initialization
find . -maxdepth 3 -type f \( -name "*.sh" -o -name "*init*" -o -name "*setup*" \) 2>/dev/null | head -20

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

Length of output: 67


@Index 정의가 생산 환경에 적용되지 않는 문제

jpa.yml에서 ddl-auto: none으로 설정되어 있어 Hibernate가 DDL을 자동 생성하지 않는다. 따라서 Product.java의 @Index 애너테이션은 메타데이터일 뿐 실제 데이터베이스 인덱스 생성에 영향을 주지 않는다. product-index-analysis.md에서 분석한 4개의 인덱스가 생산 데이터베이스에 실제로 생성되려면, Flyway 마이그레이션 또는 명시적인 DDL 스크립트로 관리해야 한다.

권장 조치:

  1. db/migration/ 디렉토리를 생성하고 Flyway를 통해 인덱스 DDL을 관리하거나
  2. 데이터베이스 초기화 스크립트에 아래 DDL을 추가하여 배포 시 인덱스 자동 생성 확보:
ALTER TABLE product ADD INDEX idx_brand_deleted_created (brand_id, deleted_at, created_at DESC);
ALTER TABLE product ADD INDEX idx_brand_deleted_price   (brand_id, deleted_at, price);
ALTER TABLE product ADD INDEX idx_brand_deleted_likes   (brand_id, deleted_at, like_count DESC);
ALTER TABLE product ADD INDEX idx_deleted_created       (deleted_at, created_at DESC);
  1. 배포 후 각 환경에서 SHOW INDEX FROM product;로 인덱스 생성 여부 검증
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java`
around lines 17 - 25, Product.java's `@Table/`@Index annotations are only metadata
because jpa.yml sets ddl-auto: none, so the four indexes declared
(idx_brand_deleted_created, idx_brand_deleted_price, idx_brand_deleted_likes,
idx_deleted_created) are not created in production; fix this by adding a Flyway
migration (under db/migration) or other deployment DDL that runs the equivalent
ALTER TABLE ... ADD INDEX statements for those index names/columns (as noted in
product-index-analysis.md), and after deployment verify with SHOW INDEX FROM
product; to ensure the indexes created successfully.

Comment on lines +56 to +59
} catch (Exception e) {
log.warn("상품 목록 캐시 조회 실패 (key={}): {}", cacheKey, e.getMessage());
return Optional.empty();
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

예외 로깅 시 cause를 포함해야 한다.

현재 e.getMessage()만 로깅하면 스택 트레이스가 누락되어 디버깅이 어렵다. 특히 직렬화 실패 원인 파악 시 cause chain이 필요하다.

🔧 cause 포함 로깅
         } catch (Exception e) {
-            log.warn("상품 목록 캐시 조회 실패 (key={}): {}", cacheKey, e.getMessage());
+            log.warn("상품 목록 캐시 조회 실패 (key={})", cacheKey, e);
             return Optional.empty();
         }

동일한 패턴이 Lines 68-69, 86-87, 91-92, 104-105, 116-117, 125-126에도 적용되어야 한다.

🤖 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/ProductCacheRepositoryImpl.java`
around lines 56 - 59, In ProductCacheRepositoryImpl update the catch blocks that
call log.warn with e.getMessage() so the exception is logged with its
cause/stacktrace: change the logging invocation in the catch for the product
list cache lookup (and the other similar catch blocks in this class) to pass the
Throwable as the last parameter to the logger instead of only e.getMessage() so
the full stack trace and cause chain are recorded (i.e., keep the existing
message format and placeholders like "상품 목록 캐시 조회 실패 (key={}): {}" but add the
exception object as the final argument for methods in ProductCacheRepositoryImpl
where the current pattern uses e.getMessage()).

Comment on lines +39 to +43
properties:
hibernate:
generate_statistics: true
hibernate:
ddl-auto: create
ddl-auto: validate
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

로컬 기본 프로필에서 ddl-auto: validate 고정은 기동 장애를 유발할 수 있다

Line [43]에서 local 프로필을 validate로 고정하면, 빈 DB 기준으로 API/배치 앱이 즉시 기동 실패할 수 있어 개발/장애 재현 환경의 가용성이 떨어진다. 특히 apps/commerce-api/src/main/resources/application.ymlapps/commerce-batch/src/main/resources/application.yml 모두 기본 프로필이 local이고, docker/infra-compose.yml에 스키마 초기화가 없어서 초기 실행 실패 가능성이 높다.
수정안으로 local에서만 DDL 전략을 오버라이드 가능하게 열어두거나(LOCAL_JPA_DDL_AUTO), local-init 프로필을 분리해 초기 1회 생성 후 validate로 전환하는 흐름을 명시하는 것이 안전하다.

🔧 제안 수정안
 spring:
   jpa:
     show-sql: true
     properties:
       hibernate:
         generate_statistics: true
     hibernate:
-      ddl-auto: validate
+      ddl-auto: ${LOCAL_JPA_DDL_AUTO:validate}

추가 테스트는 아래 2가지를 권장한다.

  1. 빈 MySQL에서 local + LOCAL_JPA_DDL_AUTO=create로 첫 기동 성공 여부 확인한다.
  2. 스키마 생성 후 local + LOCAL_JPA_DDL_AUTO=validate로 재기동 성공 여부 및 엔티티/인덱스 검증 동작 확인한다.
📝 Committable suggestion

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

Suggested change
properties:
hibernate:
generate_statistics: true
hibernate:
ddl-auto: create
ddl-auto: validate
properties:
hibernate:
generate_statistics: true
hibernate:
ddl-auto: ${LOCAL_JPA_DDL_AUTO:validate}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@modules/jpa/src/main/resources/jpa.yml` around lines 39 - 43, 현재 jpa.yml에서
hibernate.ddl-auto가 기본(local) 프로필에서 고정된 값(validate)이라 빈 DB 환경에서 애플리케이션이 기동 실패할 수
있으니, hibernate.ddl-auto 설정을 환경변수로 오버라이드 가능하게 변경하거나 별도 프로필(local-init)을 추가해 초기
실행시 create를 사용하도록 하세요; 구체적으로 jpa.yml의 hibernate.ddl-auto 항목을 LOCAL_JPA_DDL_AUTO
환경변수(예: ${LOCAL_JPA_DDL_AUTO:validate})로 바꾸거나 local-init 프로필을 만들어
ddl-auto=create로 설정하고 이후 기본 local 프로필은 validate로 유지하도록 하고, 변경 후
LOCAL_JPA_DDL_AUTO=create로 빈 MySQL에서 최초 기동 테스트와 재기동(validate) 테스트를 수행해 동작을
확인하세요.

Comment on lines +1 to +4
/**
* 캐시 적용 페이지 부하 테스트 (page=0, 1 - 캐시 히트 구간)
* 실행: K6_WEB_DASHBOARD=true k6 run scripts/test_cached.js
*/
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

주석과 실제 테스트 범위 불일치

주석에는 "page=0, 1 - 캐시 히트 구간"이라고 명시되어 있으나, 실제 테스트는 page=0만 호출한다. 의도적으로 단일 페이지만 테스트하는 것이라면 주석을 수정하고, page=1도 테스트해야 한다면 요청을 추가해야 한다.

📝 주석 수정 또는 테스트 추가

Option 1: 주석 수정 (page=0만 테스트)

 /**
- * 캐시 적용 페이지 부하 테스트 (page=0, 1 - 캐시 히트 구간)
+ * 캐시 적용 페이지 부하 테스트 (page=0 - 캐시 히트 구간)
  * 실행: K6_WEB_DASHBOARD=true k6 run scripts/test_cached.js
  */

Option 2: page=1 테스트 추가

 export default function () {
     const res = http.get('http://localhost:8080/api/v1/products?page=0&sort=latest');
     duration.add(res.timings.duration);
     check(res, { 'status 200': (r) => r.status === 200 });
+
+    const res2 = http.get('http://localhost:8080/api/v1/products?page=1&sort=latest');
+    duration.add(res2.timings.duration);
+    check(res2, { 'page=1 status 200': (r) => r.status === 200 });
+
     sleep(0.5);
 }
📝 Committable suggestion

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

Suggested change
/**
* 캐시 적용 페이지 부하 테스트 (page=0, 1 - 캐시 히트 구간)
* 실행: K6_WEB_DASHBOARD=true k6 run scripts/test_cached.js
*/
/**
* 캐시 적용 페이지 부하 테스트 (page=0 - 캐시 히트 구간)
* 실행: K6_WEB_DASHBOARD=true k6 run scripts/test_cached.js
*/
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@scripts/test_cached.js` around lines 1 - 4, The comment claims the test
covers "page=0, 1 - 캐시 히트 구간" but the script only requests page=0; either update
the comment to reflect that only page=0 is exercised, or modify the test to also
issue requests for page=1 (e.g., add an HTTP request or loop that hits both
"page=0" and "page=1" so the cache-hit window is covered). Locate the part of
the script that constructs the request/query param "page=0" and either change
the top comment text to match or add the additional request for "page=1" (or a
pages array loop) so both pages are exercised as claimed.

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