Skip to content

[Volume 5] 인덱스 설계,Redis 캐시 조회 최적화 - 임나현#193

Open
iohyeon wants to merge 15 commits intoLoopers-dev-lab:iohyeonfrom
iohyeon:volume-5
Open

[Volume 5] 인덱스 설계,Redis 캐시 조회 최적화 - 임나현#193
iohyeon wants to merge 15 commits intoLoopers-dev-lab:iohyeonfrom
iohyeon:volume-5

Conversation

@iohyeon
Copy link

@iohyeon iohyeon commented Mar 12, 2026

📌 Summary

  • 배경: EXPLAIN을 찍어보니 상품/주문 목록 모두 풀 테이블 스캔(type=ALL)이었다. 상품 19만건, 주문 9.8만건을 매번 전부 읽고 버리는 상태.
  • 목표: 인덱스 → Cursor → Redis Cache-Aside 순서로 최적화하고, 각 단계의 효과를 측정한다.
  • 결과: 상품 목록 API 566ms → 65ms (8.7배 개선), 20VU 부하에서 p95 < 100ms.

🧭 Context & Decision

전체 API 분석 현황 — 왜 이 2개를 골랐는가

10개 조회 API에 EXPLAIN을 찍어서 심각도를 분류했다. **기준은 type(스캔 방식), rows(스캔 행 수), filtered(통과율)**이다.

# API type rows filtered Extra 심각도
1 상품 목록 (GET /products) ALL 191,196 5.00% Using where; Using filesort 🔴 심각
2 주문 목록 (GET /orders) ALL 98,615 1.11% Using where; Using filesort 🔴 심각
3 쿠폰 목록 (GET /coupons) ALL 30,000 ~33% Using where 🟡 보통
4 장바구니 (GET /carts) ALL 12,763 ~10% Using where 🟡 보통
5 좋아요 목록 (GET /likes) ref ~100 100% Using where (EXISTS) 🟢 양호
6 주소 목록 (GET /addresses) ALL 5,989 ~17% Using where 🟢 양호
7 브랜드 좋아요 (GET /brand-likes) ref ~100 100% Using where (EXISTS) 🟢 양호
8 브랜드 목록 (GET /brands) ALL ~500 ~80% Using where 🟢 양호
9 상품 상세 (GET /products/{id}) const 1 100% - ✅ 최적
10 포인트 조회 (GET /points) eq_ref 1 100% - ✅ 최적

선정 기준: type=ALL + rows > 5만 + filtered < 10%를 "심각"으로 분류했다. 이 조합은 대량의 행을 풀스캔하면서 90% 이상을 버린다는 뜻이다.

  • 상품 목록: 19만건 풀스캔 + filesort. 95%를 읽고 버림. 공유 데이터라 모든 유저가 호출.
  • 주문 목록: 9.8만건 풀스캔. 98.89%를 읽고 버림. user_id 인덱스 자체가 없음.
  • 나머지 8개: 데이터 규모가 작거나(브랜드 500건, 주소 6,000건), PK/FK 인덱스로 이미 최적이거나(상품 상세, 포인트), 유저별 데이터라 rows가 작음(좋아요, 장바구니). 현재 규모에서 최적화 ROI가 낮다고 판단.

문제 정의

EXPLAIN을 찍어보니 상품/주문 모두 풀 테이블 스캔이었다.

상품 목록 (GET /api/v1/products)

| table | type | key  | rows    | filtered | Extra                       |
|-------|------|------|---------|----------|-----------------------------|
| p     | ALL  | NULL | 191,196 | 5.00     | Using where; Using filesort |
| b     | eq_ref | PRIMARY | 1  | 5.00     | Using where                 |

19만건 풀스캔 + filesort. filtered=5%이므로 95%의 행을 읽고 버린다. Brand 테이블 JOIN은 eq_ref로 빠르지만, products 쪽이 병목.

주문 목록 (GET /api/v1/orders)

| table | type | key  | rows   | filtered | Extra                       |
|-------|------|------|--------|----------|-----------------------------|
| o     | ALL  | NULL | 98,615 | 1.11     | Using where; Using filesort |
| oi    | ref  | FK   | 1      | 100.00   |                             |

9.8만건 풀스캔. filtered=1.11%로 98.89%를 읽고 버린다. user_id에 인덱스가 없다. Hibernate는 @ManyToOne FK에만 자동 인덱스를 만들고, ID 참조(Long 필드)에는 만들지 않기 때문이다. 추가로 FETCH JOIN으로 items를 전부 로딩하는데, 목록 응답(OrderSummaryResult)은 items를 한 번도 안 쓴다.

선택지와 결정

1. 쿼리 최적화: Brand JOIN을 어떻게 제거할 것인가?

  • 고려한 대안:
    • A: 서브쿼리 brand_id IN (SELECT ...)
    • B: 애플리케이션에서 ACTIVE 브랜드 ID를 먼저 조회 → 리터럴 IN 절로 주입
  • 최종 결정: B
  • 이유: 서브쿼리(A)는 MySQL 옵티마이저가 Dependent Subquery로 풀 위험이 있다. 리터럴 IN(B)은 옵티마이저가 상수 리스트로 인식해서 인덱스 활용이 확실하다.
  • 트레이드오프: 브랜드 조회를 위한 추가 쿼리 1회 발생. 브랜드 수가 수천 개로 커지면 IN 절이 비대해질 수 있으나, 현재 규모(ACTIVE 400개)에서는 충분히 실용적.

2. 상품 인덱스: 정렬 선두 vs WHERE 선두

  • 고려한 대안:
    • A: 정렬 선두 — (created_at DESC, id DESC, status, deleted_at)
    • B: WHERE 선두 — (status, deleted_at, created_at DESC)
  • 최종 결정: A (정렬 선두)
  • 이유: status IN ('ACTIVE','SOLDOUT') 통과율 85%, deleted_at IS NULL 통과율 95% → 합산 ~80%. 통과율이 높으면 LIMIT 20 채우기 위해 ~25개만 읽으면 된다. WHERE 선두(B)로 하면 IN(multiple equality)이 다음 컬럼의 정렬을 깨뜨려서 filesort가 발생한다.
  • 트레이드오프: 정렬 종류별(LATEST/PRICE/LIKES) + brandId 분기로 처음 6개 인덱스가 필요했는데, brandId 필터 시 ~6,000건 filesort가 sub-ms이므로 brand 전용 정렬 인덱스를 제거하여 4개로 축소.

3. 주문 인덱스: 왜 1개로 충분한가

  • 상품이 4개나 필요했던 이유: IN이 정렬을 깨뜨림 + 4종 동적 정렬 + brandId 분기
  • 주문은 user_id가 **순수 등호(=)**이므로 다음 컬럼(created_at)의 정렬이 깨지지 않는다. 정렬도 created_at DESC 고정.
  • 최종 결정: (user_id, created_at) 1개

4. 페이지네이션: 고객 Cursor vs 어드민 OFFSET

  • 고려한 대안:
    • A: 모든 API에 Cursor 적용
    • B: 고객 API만 Cursor, 어드민 API는 OFFSET 유지
  • 최종 결정: B
  • 이유: 고객은 "더 있어?"만 알면 되므로 COUNT가 불필요. 어드민은 "전체 1,234건 중 3/62페이지" 표시가 필수. COUNT 쿼리의 실측 비용은 ~100ms로, 전체 API 레이턴시의 54%를 차지했다.
  • 트레이드오프: "N번째 페이지로 점프" 불가. 무한 스크롤 UI에 적합하고 전통 페이지 번호 UI에는 부적합.

5. 캐시 무효화: TTL만? Delayed Double Delete?

  • 고려한 대안:
    • A: TTL만 신뢰 (가장 단순)
    • B: Delayed Double Delete (afterCommit DELETE + 500ms 후 2차 DELETE)
    • C: 캐시 버전(Generation) 방식
  • 최종 결정: B (상품), A+afterCommit (주문)
  • 이유: Cache-Aside + DELETE 패턴의 구조적 레이스 컨디션 — 읽기 스레드가 쓰기 커밋 전에 DB를 읽고, 캐시 DELETE 이후에 SET하면 stale data가 캐시에 남는다. 가격 변경 같은 민감 데이터에서 60초간 stale이 유지되면 CS 리스크. 주문은 개인 데이터라 동시 접근 확률이 구조적으로 낮으므로 afterCommit DELETE만 적용.
  • 트레이드오프: 500ms 내에 Thread B의 SET이 완료되지 않으면 여전히 stale 가능(확률적으로 매우 낮음). TTL이 최종 안전망.

6. 주문 목록 캐싱: 할 것인가, 말 것인가?

  • 고려한 대안:
    • A: 캐싱하지 않고 인덱스/쿼리 최적화에만 집중
    • B: 첫 페이지 + 기본 조회(최근 3개월)만 제한적 캐싱
  • 최종 결정: B
  • 이유: startAt/endAtnow() 기반 키를 쓰면 매 초마다 키가 달라져서 히트율이 0이 되는 문제를 발견. 캐시 키를 orders:list:{userId}로 단순화하고, 첫 페이지(cursor=null) + 커스텀 기간 없음 조건만 캐싱하여 해결.
  • 트레이드오프: 캐시 히트가 "같은 유저의 첫 페이지 반복 조회"에서만 발생하므로 효과 제한적. 커스텀 기간 조회, 2페이지 이후는 항상 DB 직접 조회.

🏗️ Design Overview

변경 범위

구분 파일 수 내용
쿼리 최적화 5개 Brand JOIN 제거, FETCH JOIN 분리, Cursor 페이지네이션
인덱스 5개 상품 4개 + 주문 1개, DDL + EXPLAIN 분석
캐시 4개 ProductCacheManager, OrderCacheManager, Facade 캐시 통합
도메인 3개 CursorResult, ProductCursor, CursorEncoder
테스트 2개 캐시 통합 테스트 (Product 6건, Order 3건)
부하 테스트 6개 k6 스크립트 (baseline, cache, concurrent)
시더 15개 14개 테이블 대량 데이터 시더

인덱스 전략

테이블 인덱스 컬럼 설계 근거
products idx_products_latest (created_at DESC, id DESC, status, deleted_at) LATEST 정렬 선두. 통과율 80%이므로 ~25건 스캔으로 LIMIT 20 충족. id 명시 포함으로 Cursor tie-breaking 보장
products idx_products_price (base_price ASC, id ASC, status, deleted_at) PRICE_ASC 정방향 + PRICE_DESC 역방향(Backward Index Scan) — 1개로 양방향 커버
products idx_products_likes (like_count DESC, id DESC, status, deleted_at) LIKES_DESC 정렬 선두
products idx_products_brand (brand_id, status, deleted_at) brandId 필터 시 WHERE 축소 후 ~6,000건 filesort 허용(sub-ms). 정렬 인덱스 불필요하여 brand 전용 1개로 축소
orders idx_orders_user_created (user_id, created_at) user_id 순수 등호(=)이므로 created_at 정렬이 깨지지 않음. 1개로 WHERE + ORDER BY 동시 커버

인덱스 개수를 6개 → 4개로 줄인 판단: brandId 필터 시 최대 ~6,000건인데, 이 규모의 filesort는 sub-ms 수준이다. brand 전용 정렬 인덱스 3개를 유지하면 INSERT마다 6개 인덱스를 갱신해야 하므로, 쓰기 비용 대비 읽기 이득이 적다.

캐시 키 전략

대상 키 패턴 TTL 캐싱 조건 무효화 전략
상품 상세 products:detail:{productId} 300초 모든 조회 Delayed Double Delete (afterCommit + 500ms 2차)
상품 목록 products:list:{sort}:{brandId|all} 60초 첫 페이지(cursor=null)만 Delayed Double Delete
주문 목록 orders:list:{userId} 300초 첫 페이지 + 기본 조회(최근 3개월)만 afterCommit DELETE만 (Double Delete 안 함)

키 설계 시 고려한 점:

  • 상품 목록: sort와 brandId를 키에 포함하여 정렬별/브랜드별 캐시 격리. brandId가 없으면 all로 표기. 2페이지 이후는 cursor 값이 유저마다 달라서 히트율이 0이므로 캐싱하지 않는다.
  • 주문 목록: 처음에 orders:list:{userId}:{startAt}:{endAt}로 설계했는데, startAt/endAt이 now() 기반이라 매 초마다 키가 달라져서 히트율이 0이 됐다. orders:list:{userId}로 단순화하고 기본 조회 조건만 캐싱하여 해결.
  • 상품 목록 무효화: KEYS/SCAN 패턴 매칭 대신 키를 직접 열거. 4개 정렬 x (all + 활성 브랜드 수) 조합을 Collection<String>으로 모아 일괄 DELETE. 현재 약 1,600개 키.
  • 상품 vs 주문 무효화 차이: 상품은 공유 데이터(모든 유저가 같은 목록을 봄)라 읽기/쓰기 동시 접근 확률이 높아 Double Delete 적용. 주문은 개인 데이터(본인 주문만 조회)라 동시 접근 확률이 구조적으로 낮아 afterCommit DELETE만으로 충분하다고 판단.

주요 컴포넌트 책임

1. 상품 목록 — Brand JOIN 제거 + 리터럴 IN

ProductRepositoryImpl.java

public CursorResult<Product> findAllDisplayableWithCursor(Long brandId, ProductSortType sort, ProductCursor cursor, int size) {
    List<Long> activeBrandIds = getActiveBrandIds();

    var query = queryFactory
            .selectFrom(product)
            .where(
                    product.deletedAt.isNull(),
                    product.status.in(ProductStatus.ACTIVE, ProductStatus.SOLDOUT),
                    product.brandId.in(activeBrandIds),   // JOIN 대신 리터럴 IN
                    brandIdEq(product, brandId),
                    cursorCondition(product, sort, cursor)
            )
            .orderBy(toOrderSpecifiers(product, sort))
            .limit(size + 1);

    List<Product> fetched = query.fetch().stream()
            .map(productMapper::toDomain).toList();
    return CursorResult.of(fetched, size);
}

2. 상품 인덱스 (4개)

CREATE INDEX idx_products_latest ON products (created_at DESC, id DESC, status, deleted_at);
CREATE INDEX idx_products_price  ON products (base_price ASC, id ASC, status, deleted_at);
CREATE INDEX idx_products_likes  ON products (like_count DESC, id DESC, status, deleted_at);
CREATE INDEX idx_products_brand  ON products (brand_id, status, deleted_at);

3. 주문 목록 — 인덱스 + FETCH JOIN 분리 + Cursor

주문 인덱스 (1개):

CREATE INDEX idx_orders_user_created ON orders (user_id, created_at);

FETCH JOIN 분리 — 목록/상세 조회 경로를 나눔:

OrderRepositoryImpl.java

// 목록 조회: FETCH JOIN 없음 → toDomainWithoutItems로 Lazy Loading 차단
public List<Order> findAllByUserId(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) {
    return orderJpaRepository.findOrdersByUserIdAndCreatedAtBetween(userId, startAt, endAt)
            .stream()
            .map(orderMapper::toDomainWithoutItems)  // items를 빈 리스트로 매핑
            .collect(Collectors.toList());
}

// 목록 + items 필요 시: 2단계 쿼리 (ID 추출 → ID IN FETCH JOIN)
public List<Order> findAllByUserIdWithItems(Long userId, ZonedDateTime startAt, ZonedDateTime endAt) {
    List<Long> ids = orderJpaRepository.findOrdersByUserIdAndCreatedAtBetween(userId, startAt, endAt)
            .stream().map(OrderEntity::getId).collect(Collectors.toList());
    if (ids.isEmpty()) return List.of();
    return orderJpaRepository.findAllByIdInWithItems(ids)
            .stream().map(orderMapper::toDomain).collect(Collectors.toList());
}

2단계 쿼리로 분리한 이유는 FETCH JOIN + LIMIT/OFFSET을 함께 쓰면 Hibernate가 전체 데이터를 메모리에 올린 뒤 애플리케이션에서 페이지네이션하기 때문이다(HHH000104 경고).

주문 EXPLAIN 기대 효과:

AS-IS TO-BE
type ALL ref/range
rows 98,615 ~20
filtered 1.11% 100%
Extra Using where; Using filesort Using where (filesort 제거)

4. Cursor 페이지네이션 — CursorResult, cursorCondition

CursorResult.java

public record CursorResult<T>(List<T> items, boolean hasNext) {
    public static <T> CursorResult<T> of(List<T> fetchedItems, int size) {
        boolean hasNext = fetchedItems.size() > size;
        List<T> items = hasNext ? fetchedItems.subList(0, size) : fetchedItems;
        return new CursorResult<>(items, hasNext);
    }
}

ProductRepositoryImpl.java — 커서 조건:

private BooleanExpression cursorCondition(QProductEntity product, ProductSortType sort, ProductCursor cursor) {
    if (cursor == null) return null;
    ProductSortType effectiveSort = sort != null ? sort : ProductSortType.LATEST;
    return switch (effectiveSort) {
        case LATEST -> product.createdAt.lt(cursor.createdAt())
                .or(product.createdAt.eq(cursor.createdAt()).and(product.id.lt(cursor.id())));
        case PRICE_ASC -> product.basePrice.gt(cursor.basePrice())
                .or(product.basePrice.eq(cursor.basePrice()).and(product.id.gt(cursor.id())));
        case PRICE_DESC -> product.basePrice.lt(cursor.basePrice())
                .or(product.basePrice.eq(cursor.basePrice()).and(product.id.lt(cursor.id())));
        case LIKES_DESC -> product.likeCount.lt(cursor.likeCount())
                .or(product.likeCount.eq(cursor.likeCount()).and(product.id.lt(cursor.id())));
    };
}

리뷰 포인트 1: 커서 조건에서 (sortKey, id) 복합 조건을 OR로 결합했는데, MySQL에서 이 패턴이 인덱스를 효율적으로 타는지 확신이 없다. WHERE created_at < ? OR (created_at = ? AND id < ?) 형태가 range scan으로 최적화되는지, 아니면 row_constructor (created_at, id) < (?, ?) 형태가 더 나은지 멘토님의 경험이 궁금하다.

5. 상품 캐시 — Delayed Double Delete

ProductCacheManager.java

private static final Duration DETAIL_TTL = Duration.ofSeconds(300);
private static final Duration LIST_TTL = Duration.ofSeconds(60);
private static final long DOUBLE_DELETE_DELAY_MS = 500;

public void registerDelayedDoubleDelete(Long productId) {
    TransactionSynchronizationManager.registerSynchronization(
            new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    evictProductCaches(productId);
                    CompletableFuture.delayedExecutor(DOUBLE_DELETE_DELAY_MS, TimeUnit.MILLISECONDS)
                            .execute(() -> evictProductCaches(productId));
                }
            }
    );
}

캐시 무효화 시나리오:

이벤트 상세 캐시 목록 캐시 메서드
상품 수정/삭제 해당 productId 삭제 전체 삭제 registerDelayedDoubleDelete(productId)
상품 생성 - 전체 삭제 registerDelayedDoubleDelete(null)
브랜드 삭제 소속 상품 전체 삭제 전체 삭제 registerBrandDeleteDoubleDelete(productIds)
브랜드 상태 변경 - 전체 삭제 registerListOnlyDoubleDelete()

목록 캐시의 "전체 삭제"에서 KEYS/SCAN 패턴 매칭 대신 키를 직접 열거하는 방식을 택했다. 4개 정렬 x (all + 활성 브랜드 수) 키를 Collection<String>으로 모아 일괄 DELETE.

리뷰 포인트 2: 현재 ACTIVE 브랜드 400개 x 4 정렬 = 약 1,600개 키를 매번 삭제하는데, 이 규모가 Redis에 부담이 되는지 궁금하다. SCAN 패턴 매칭이 더 적합한 시점이 있는지, 혹은 1,600개 일괄 DELETE가 실무에서 문제가 된 적 있는지 멘토님의 경험이 궁금하다.

6. 상품 Facade — Cache-Aside 통합

ProductFacade.java

@Transactional(readOnly = true)
public ProductCursorResult getDisplayableProductsWithCursor(Long brandId, ProductSortType sort, ProductCursor cursor, int size) {
    boolean isFirstPage = (cursor == null);

    if (isFirstPage) {
        Optional<ProductCursorResult> cached = productCacheManager.getProductList(sort, brandId);
        if (cached.isPresent()) return cached.get();
    }

    CursorResult<Product> result = productService.getDisplayableProductsWithCursor(brandId, sort, cursor, size);
    // ... mapping ...

    if (isFirstPage) {
        productCacheManager.putProductList(sort, brandId, cursorResult);
    }
    return cursorResult;
}

첫 페이지만 캐싱한다. 2페이지 이후는 cursor 값이 유저마다 달라서 히트율이 0이기 때문이다.

7. 주문 캐시 — afterCommit DELETE

OrderCacheManager.java

public void registerEvictAfterCommit(Long userId) {
    TransactionSynchronizationManager.registerSynchronization(
            new TransactionSynchronization() {
                @Override
                public void afterCommit() {
                    evictOrderList(userId);
                }
            }
    );
}

OrderFacade.java

@Transactional(readOnly = true)
public OrderCursorResult getOrdersWithCursor(Long userId, ZonedDateTime startAt, ZonedDateTime endAt,
                                              ZonedDateTime cursorCreatedAt, Long cursorId, int size,
                                              boolean isDefaultQuery) {
    if (isDefaultQuery) {
        Optional<OrderCursorResult> cached = orderCacheManager.getOrderList(userId);
        if (cached.isPresent()) return cached.get();
    }
    // ... DB 조회 ...
    if (isDefaultQuery) {
        orderCacheManager.putOrderList(userId, cursorResult);
    }
    return cursorResult;
}

리뷰 포인트 3: 주문 목록 캐싱의 실효성에 대해 고민이 깊었다. 캐시 히트가 "같은 유저의 첫 페이지 반복 조회"에서만 발생하므로 효과가 제한적이다. 캐싱보다 인덱스/쿼리 최적화에 집중하는 게 맞지 않았나? 멘토님이라면 개인 데이터 성격의 목록 조회에 캐시를 적용하시는지 궁금하다.

리뷰 포인트 4: 상품에는 Delayed Double Delete, 주문에는 afterCommit DELETE만 적용했다. "개인 데이터는 동시 접근 확률이 낮으므로 Double Delete 불필요"라는 판단이 합리적인지 멘토님의 의견이 궁금하다.

8. 캐시 정합성과 비즈니스 허용 범위

리뷰 포인트 5: 판매자가 10,000원 → 15,000원으로 수정했을 때, Delayed Double Delete에도 불구하고 극히 드물게 옛 가격이 캐시에 남을 수 있다. 유저가 목록에서 10,000원을 보고 장바구니에서 15,000원을 보면 CS 리스크다. 결제 시점에서 DB 가격 재검증이 이커머스 표준인지, "수백ms~수초의 가격 불일치"는 비즈니스적으로 허용 범위인지 멘토님의 실무 경험이 궁금하다.

9. 성능 측정 결과

각 최적화의 독립적 기여를 확인하기 위해 시나리오를 나눠서 순차 적용했다.

단계별 레이턴시 (상품 목록, LATEST 기준):

# 시나리오 EXPLAIN type rows filesort COUNT avg 개선 배수
1 Baseline (인덱스 없음 + OFFSET) ALL 191,196 O O (~100ms) 566ms 1.0x
2 Cursor only (인덱스 없음) ALL 191,196 O X 739ms 악화
3 인덱스 + OFFSET index ~25 X O (~100ms) 190ms 3.0x
4 인덱스 + Cursor index ~21 X X 88ms 6.4x
5 인덱스 + Cursor + Redis index ~21 X X 65ms 8.7x

개선 요인 분해:

인덱스 단독:  566ms → 190ms  ▼376ms (66%)
  → Full Table Scan 제거, filesort 제거, 스캔 행: 191K → ~25

Cursor 단독:  190ms → 88ms   ▼102ms (54%)
  → COUNT 쿼리 완전 제거 (~100ms), 쿼리 3회 → 2회

Redis 단독:   88ms → 65ms    ▼23ms (26%)
  → 캐시 히트 시 DB 완전 우회 (~15ms), miss/hit 혼합 avg

정렬별 Cursor 레이턴시:

sort Cursor (인덱스 O) OFFSET (인덱스 O) 개선
LATEST ~50ms 197.6ms 74%
PRICE_ASC ~100ms 203.3ms 51%
PRICE_DESC ~100ms 185.6ms 46%
LIKES_DESC ~100ms 174.3ms 43%

K6 부하 테스트 (20 VUs, 1분 40초):

지표
avg 48.15ms
p95 95.96ms
p99 137.75ms
TPS 46.03 req/s
에러율 (실제) 0% (404 제외)

10. 캐시 통합 테스트

ProductCacheIntegrationTest.java

# 테스트 검증 내용
1 상세 캐시 miss → DB 조회 → 캐시 저장 첫 조회 시 Redis에 키 생성 확인
2 상세 캐시 hit 두 번째 조회 시 DB 쿼리 없이 캐시에서 반환
3 상품 수정 → 캐시 무효화 Delayed Double Delete 후 캐시 삭제 확인 (700ms 대기)
4 목록 캐시 정렬별 격리 LATEST / PRICE_ASC 등 각 정렬이 별도 캐시 키
5 목록 캐시 브랜드별 격리 brandId별 캐시 키 분리 확인
6 상품 생성 → 목록 캐시 무효화 새 상품 생성 시 모든 정렬의 목록 캐시 삭제

OrderCacheIntegrationTest.java

# 테스트 검증 내용
1 첫 페이지 캐시 miss → hit 기본 조회 캐싱 및 반환 확인
2 커스텀 기간 조회 → 캐시 우회 isDefaultQuery=false 시 캐시 미사용
3 주문 취소 → 캐시 무효화 afterCommit DELETE 동작 확인

11. 데이터 시더

DataSeeder — 14개 테이블, @Profile("local").

테이블 데이터 수 분포 특성
User 5,000 균등
Brand 500 ACTIVE 80% / INACTIVE 20%
Product 200,000 Pareto (상위 80 브랜드에 80% 집중), 가격 Log-normal
ProductLike ~10,000,000 Power law (80%가 0~10개)
Inventory 200,000 상품 1:1
Order 100,000 최근 3개월 40% 집중
Payment 100,000 주문 1:1
IssuedCoupon 30,000 -

12. K6 부하 테스트 스크립트

k6/no-cache-baseline.js | k6/cache-protection.js | k6/concurrent-read-write.js

테스트 목적 VU 기대 성능
no-cache-baseline DB 직접 조회 성능 (캐시 우회) 20 p95 < 500ms
cache-protection 캐시 적중 시 DB 보호 효과 20 p95 < 100ms
concurrent-read-write 읽기 중 쓰기 발생 시 캐시 정합성 20 (19R + 1W) stale 미검출

🔁 Flow Diagram

상품 목록 조회 — Cache-Aside + Cursor

sequenceDiagram
  autonumber
  participant Client
  participant Controller
  participant Facade as ProductFacade
  participant Cache as ProductCacheManager<br/>(Redis)
  participant Service as ProductService
  participant DB as MySQL

  Client->>Controller: GET /products?sort=LATEST&size=20

  Controller->>Facade: getDisplayableProductsWithCursor(cursor=null)

  rect rgb(235, 245, 255)
    Note over Facade,Cache: 첫 페이지 → 캐시 확인
    Facade->>Cache: getProductList(LATEST, null)

    alt 캐시 HIT
      Cache-->>Facade: CursorResult (from Redis)
      Facade-->>Client: 200 OK (캐시 응답)
    else 캐시 MISS
      Cache-->>Facade: empty
      Facade->>Service: getDisplayableProductsWithCursor()
      Service->>DB: SELECT ... WHERE brand_id IN (...)<br/>AND (sortKey, id) < cursor<br/>ORDER BY ... LIMIT 21
      DB-->>Service: 21 rows
      Service-->>Facade: CursorResult(20 items, hasNext=true)
      Facade->>Cache: putProductList(LATEST, null, result)
      Facade-->>Client: 200 OK (DB 응답 + 캐시 저장)
    end
  end
Loading

상품 수정 → Delayed Double Delete

sequenceDiagram
  autonumber
  participant Admin
  participant Facade as ProductAdminFacade
  participant DB as MySQL
  participant CacheMgr as ProductCacheManager
  participant Redis

  Admin->>Facade: updateProduct(productId, ...)
  Facade->>DB: UPDATE product SET ...
  Facade->>CacheMgr: registerDelayedDoubleDelete(productId)
  Note over CacheMgr: afterCommit 콜백 등록

  Facade->>DB: COMMIT

  rect rgb(255, 235, 235)
    Note over CacheMgr,Redis: afterCommit 실행
    CacheMgr->>Redis: 1차 DELETE (상세 + 목록 전체)
    Note over CacheMgr: 500ms 대기
    CacheMgr->>Redis: 2차 DELETE (상세 + 목록 전체)
  end
Loading

✅ 과제 체크리스트

구분 요건 충족
쿼리 최적화 상품 목록 JOIN 제거 (리터럴 IN 방식)
주문 목록 FETCH JOIN 제거 (조회 경로 분리)
COUNT 쿼리 제거 (Cursor 페이지네이션)
인덱스 상품 인덱스 4개 (정렬 3 + brand 1)
주문 인덱스 1개 (user_id, created_at)
EXPLAIN 기반 AS-IS/TO-BE 분석
캐싱 상품 상세/목록 Cache-Aside (Redis)
주문 목록 Cache-Aside (첫 페이지 한정)
캐시 무효화 — Delayed Double Delete (상품)
캐시 무효화 — afterCommit DELETE (주문)
테스트 캐시 통합 테스트 (Product 6건 + Order 3건)
k6 부하 테스트 (baseline / cache / concurrent)
데이터 14개 테이블 시더 (20만 상품, 현실적 분포)
성능 상품 목록 566ms → 65ms (8.7x 개선)
20VU 부하 p95 < 100ms

변경 목적

상품 목록(GET /products)·주문 목록(GET /orders)에서 발생하던 풀 테이블 스캔(type=ALL)과 filesort를 해소하고 인덱스·Cursor·Redis 캐시를 단계적으로 적용해 응답시간을 평균 566ms → 65ms(8.7배)로 개선하고 20 VU 부하에서 p95 목표 달성을 노립니다.

핵심 변경점

products/orders 테이블에 복합 인덱스 추가(Product: idx_products_latest/price/likes/brand, Order: idx_orders_user_created), Brand JOIN 제거(활성 brand ID 선조회→IN), 페이지네이션을 OFFSET/COUNT 대신 CursorResult/ProductCursor 기반으로 전환하고 cursorCondition(정렬키+id OR 결합) 적용, Redis Cache-Aside 도입(상품 상세 TTL 300s, 목록 첫페이지만 TTL 60s; 주문 목록 기본조회 첫페이지만 300s) 및 Delayed Double Delete/afterCommit 무효화 전략과 목록 키(정렬×브랜드 약 1,600개)를 열거 삭제하는 무효화 방식 구현.

리스크/주의사항

목록 전체 무효화 시 Redis에서 대량 키 삭제·eviction(운영 비용/성능)과 비동기 무효화 지연으로 인한 일관성 문제 발생 가능성, Delayed Double Delete와 주문의 단순 afterCommit 방식 간 정책 차이로 복잡성 증가, QueryDSL로 작성된 OR/복합 cursorCondition이 실제 MySQL에서 인덱스를 기대대로 활용하는지(특히 OR 사용시 인덱스 스캔이 발생하지 않을 우려) 확인 필요.

테스트/검증 방법

통합 테스트: ProductCacheIntegrationTest·OrderCacheIntegrationTest로 캐시 적중·무효화 시나리오 검증; 부하 테스트: k6 스크립트(k6/product-list, concurrent-read-write 등)로 20 VU 기준 응답시간(p95/p99) 측정; 대규모 검증: 제공된 시더로 200k 상품·100k 주문을 채운 환경에서 Redis(maxmemory=512MB, allkeys-lru) 동작·eviction 영향 관찰 및 인덱스/쿼리 변경 전후 EXPLAIN 비교 권장. 추가 확인이 필요한 항목(확인 질문): Redis 전체 목록 키 삭제(약 1,600개 조합)로 실제 운영에서 DELETE 부담을 감당할 수 있는지, OR 기반 cursorCondition의 실행계획(EXPLAIN) 결과를 제공해 주실 수 있나요?

@coderabbitai
Copy link

coderabbitai bot commented Mar 12, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Redis 기반 상품·주문 캐시 관리자와 지연 더블-딜리트 무효화 도입, 페이지 기반에서 커서 기반 페이징으로 전환, QueryDSL 커서 쿼리 추가, 대규모 데이터 시더·k6 부하 스크립트 및 엔티티 인덱스 추가가 포함된 변경이다.

Changes

Cohort / File(s) Summary
캐시 관리자
apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java, apps/commerce-api/src/main/java/com/loopers/application/cache/OrderCacheManager.java
Redis+Jackson 캐시 어사이드 구현 추가, TTL 및 직렬화 오류 처리, 트랜잭션 후 지연 더블-딜리트 및 목록 무효화·엔큐 후커 제공이다.
애플리케이션 Facades 연동
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java, apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java, apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java, apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java, apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java
각 Facade에 CacheManager 의존성 주입 및 생성/수정/삭제/상태변경 시 register*DoubleDelete / registerEvictAfterCommit 호출 추가로 캐시 무효화 연결이다.
도메인·리포지토리 API → 커서 전환
apps/commerce-api/src/main/java/com/loopers/domain/common/CursorResult.java, .../product/ProductCursor.java, .../product/ProductRepository.java, .../order/OrderRepository.java, .../order/OrderService.java
제네릭 CursorResult·ProductCursor 추가, 서비스·리포지토리 시그니처를 페이지 기반에서 커서 기반으로 변경하여 CursorResult 반환으로 전환이다.
인프라 구현(쿼리·매퍼)
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java, .../order/OrderRepositoryImpl.java, .../order/OrderJpaRepository.java, .../order/OrderMapper.java
ProductRepositoryImpl에 브랜드 필터·다중 정렬·커서 조건 및 BrandJpaRepository 사용 추가, OrderRepositoryImpl에 JPAQueryFactory 기반 커서 쿼리 및 items 페치 전략 변경, OrderMapper에 items 제외 변환 추가이다.
API·컨트롤러·응답 모델·유틸
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/*, .../order/*, apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/CursorEncoder.java
Controller/Spec의 페이지→커서 전환, 응답 모델을 PagingInfo 포함 CursorListResponse로 변경, CursorEncoder로 커서 인코드·디코드 제공이다.
대규모 데이터 시더
apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/*, apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/DataSeeder.java
Users·Brands·Products·Orders 등 여러 대규모 배치 시더 추가 및 DataSeeder로 단계별 실행 흐름을 추가이다.
통합 테스트(캐시)
apps/commerce-api/src/test/java/com/loopers/application/cache/ProductCacheIntegrationTest.java, .../OrderCacheIntegrationTest.java
상품 상세/목록 및 주문 목록 캐시 적중·무효화·브랜드·정렬별 동작 검증 테스트 추가 및 더블 딜리트 비동기 대기 헬퍼 포함이다.
테스트 정리
apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java, apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductRepositoryIntegrationTest.java
페이지 기반 getOrders / findAllDisplayable 관련 테스트 삭제로 커서 기반 전환에 맞춘 테스트 정리이다.
엔티티 인덱스 추가
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java, .../order/OrderEntity.java, .../coupon/IssuedCouponEntity.java, .../address/UserAddressEntity.java
조회·정렬 성능 개선을 위한 복합·정렬 지향 인덱스(@Index)들이 @Table에 추가되었다.
인프라·문서·부하 스크립트
docker/infra-compose.yml, http/commerce-api/*.http, k6/*.js
Redis maxmemory 및 LRU 정책 설정 추가, API 문서 예제 cursor 기반 갱신, k6 성능 및 동시성 테스트 스크립트 다수 추가이다.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Controller as Controller
    participant Facade as ProductFacade
    participant CacheMgr as ProductCacheManager
    participant Repo as ProductRepositoryImpl
    participant Redis as Redis

    rect rgba(200, 150, 255, 0.5)
    Note over Client,Redis: 커서 기반 상품 목록 조회(캐시 어사이드 + 더블 딜리트 연계)
    Client->>Controller: GET /api/v1/products?sort=...&size=...&cursor=...
    Controller->>Controller: cursor 디코드 → ProductCursor
    Controller->>Facade: getDisplayableProductsWithCursor(brandId, sort, cursor, size)
    Facade->>CacheMgr: getProductList(sort, brandId)
    CacheMgr->>Redis: GET products:list:...
    alt 캐시 히트
        Redis-->>CacheMgr: JSON
        CacheMgr-->>Facade: Optional(ProductCursorResult)
    else 캐시 미스
        Redis-->>CacheMgr: null
        CacheMgr-->>Facade: Optional.empty
        Facade->>Repo: findAllDisplayableWithCursor(brandId, sort, cursor, size)
        Repo-->>Facade: CursorResult<Product>
        Facade->>CacheMgr: putProductList(sort, brandId, result)
        CacheMgr->>Redis: SET products:list:... (TTL 60s)
    end
    Facade-->>Controller: ProductCursorResult
    Controller->>Controller: nextCursor 생성 및 인코드
    Controller-->>Client: ProductCursorListResponse (products + paging)
    end
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 31.50% 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 제목은 인덱스 설계와 Redis 캐시 최적화라는 주요 변경사항을 명확히 요약하고 있다.
Description check ✅ Passed PR 설명이 템플릿의 모든 필수 섹션(Summary, Context & Decision, Design Overview, Flow Diagram)을 포함하고 있으며, EXPLAIN 분석, 선택지 검토, 성능 측정 결과까지 상세히 기록되어 있다.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
📝 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 use TruffleHog to scan for secrets in your code with verification capabilities.

Add a TruffleHog config file (e.g. trufflehog-config.yml, trufflehog.yml) to your project to customize detectors and scanning behavior. The tool runs only when a config file is present.

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/infrastructure/seeder/ProductLikeSeeder.java-60-72 (1)

60-72: ⚠️ Potential issue | 🟠 Major

중복 회피용 재추첨은 고좋아요 상품에서 시딩 시간을 급격히 늘린다.

현재 방식은 likeCountUSER_COUNT에 가까워질수록 마지막 사용자 몇 명을 뽑기 위해 난수를 계속 재시도하게 된다. 이 PR의 분포면 수천 좋아요 상품이 반복적으로 나오므로 시딩 시간이 비선형으로 늘고 로컬/CI 실행이 불안정해질 수 있다. 사용자 ID 배열을 부분 셔플하거나 BitSet 기반 샘플링으로 바꿔 한 번의 선형 패스로 고유 사용자를 뽑는 편이 안전하다. likeCount == USER_COUNT 경계값에서 제한 시간 안에 중복 없는 좋아요가 생성되는 테스트를 추가하는 편이 좋다.

🤖 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/seeder/ProductLikeSeeder.java`
around lines 60 - 72, The current retry loop in ProductLikeSeeder (usedUsers +
random retry) causes quadratic/severe slowdown when likeCount approaches
USER_COUNT; replace it with deterministic unique sampling: build a list/array of
user IDs 1..USER_COUNT and perform a partial Fisher–Yates shuffle to draw the
first likeCount unique IDs (or use a BitSet and iterate to pick the first
likeCount set bits) and then create likes from those IDs, removing the
while-loop and usedUsers set; also add a unit/integration test that seeds a
product with likeCount == USER_COUNT to assert all USER_COUNT unique likes are
created within a short timeout to catch regressions.
apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/IssuedCouponSeeder.java-59-67 (1)

59-67: ⚠️ Potential issue | 🟠 Major

USED 쿠폰에 미래 시각과 비어 있는 주문 참조가 섞일 수 있다.

createdAt이 현재에 가까우면 plusDays(1..30) 때문에 used_at이 미래가 되고, paidOrderIds가 부족하면 USED인데도 order_idused_at이 null인 레코드가 나온다. 이런 데이터는 사용 이력 조회와 통계 검증을 왜곡한다. usedAtcreatedAtnow 사이에서만 뽑고, 매핑할 PAID 주문이 없으면 USED를 만들지 말고 실패시키거나 다른 상태로 낮추는 편이 안전하다. 최근 생성 쿠폰과 paidOrderIds가 비어 있는 경우를 각각 넣어 used_at <= now, used_at >= created_at, USED => order_id != 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/seeder/IssuedCouponSeeder.java`
around lines 59 - 67, The current seeder may assign a usedAt in the future and
create USED records without an order when paidOrderIds is exhausted; modify the
block that handles status to (1) ensure usedAt is picked between createdAt and
now (e.g., random time between createdAt.plusSeconds(1) and now) rather than
using plusDays blindly, (2) only mark status "USED" and assign orderId when
paidOrderIds has an available id (use paidOrderIdx % paidOrderIds.size() safely
and increment paidOrderIdx only after a successful assignment), and (3) if no
paid order is available, downgrade the status (or skip creating a USED entry) so
USED => orderId != null and usedAt <= now && usedAt >= createdAt hold; add tests
that create recent coupons and empty paidOrderIds asserting those invariants for
ZonedDateTime createdAt, usedAt, orderId, status, paidOrderIdx, and
paidOrderIds.
apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/ProductLikeSeeder.java-40-45 (1)

40-45: ⚠️ Potential issue | 🟠 Major

선행 시더 누락을 경고만 하고 넘기면 부분 시딩을 정상으로 오인한다.

likeCountsPerProduct가 null이면 전체 좋아요 시딩이 통째로 빠지는데, 현재는 warn 후 계속 진행한다. 성능 측정과 캐시 검증에서 데이터셋이 불완전해도 실패로 드러나지 않아 원인 파악이 어려워진다. products.like_count를 DB에서 다시 읽어오거나, 최소한 예외를 던져 시딩을 즉시 중단하는 편이 안전하다. ProductLikeSeeder를 단독 실행했을 때 조용히 성공하지 않고 실패하거나 DB 기반으로 정상 보정되는 테스트를 추가하는 편이 좋다. As per coding guidelines 'null 처리, 방어적 복사, 불변성, equals/hashCode/toString 구현 안정성을 점검한다.'

최소 수정안
     int[] likeCountsPerProduct = productSeeder.getLikeCountsPerProduct();
     if (likeCountsPerProduct == null) {
-        log.warn("[ProductLikeSeeder] ProductSeeder 미실행. 건너뜁니다.");
-        return;
+        throw new IllegalStateException("ProductSeeder must run before ProductLikeSeeder");
     }
🤖 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/seeder/ProductLikeSeeder.java`
around lines 40 - 45, The seed() method in ProductLikeSeeder uses
productSeeder.getLikeCountsPerProduct() and only logs a warning if it returns
null, which lets the seeder silently produce incomplete data; change the
behavior so that when likeCountsPerProduct is null you either (A) reload the
like counts from the database (e.g., query products.like_count via the
ProductRepository/DAO) and proceed with the correct counts, or (B) fail fast by
throwing an unchecked exception (e.g., IllegalStateException) from
ProductLikeSeeder.seed() with a clear message; update callers/tests to expect
the new behavior and add a unit/integration test that runs
ProductLikeSeeder.seed() when productSeeder is absent to ensure it either throws
or correctly re-reads products.like_count from the DB.
apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/BrandSeeder.java-34-64 (1)

34-64: ⚠️ Potential issue | 🟠 Major

BrandSeeder가 ID를 명시하지 않으면 ID 시퀀스가 꼬져 후속 시더가 실패한다.

BrandSeederid 컬럼을 지정하지 않고 auto-increment에 의존하지만, ProductSeederBrandLikeSeeder는 브랜드 ID 범위 1~500을 직접 가정한다. DataSeederisAlreadySeeded() 메서드는 products 테이블만 검사하므로, 테이블이 부분 삭제되거나 초기화 후 재시딩되면 BrandSeeder가 ID 501부터 시작하게 되어 FK 참조 위반이나 ACTIVE/INACTIVE 분포 오류를 유발한다. 로컬 개발에서 테이블을 수동으로 삭제한 후 재시딩하거나, 통합 테스트 중 실패 후 재실행할 때 쉽게 재현된다.

권장 사항:

  1. BrandSeederINSERT 문에 id 컬럼을 명시하거나, 시딩 전에 모든 관련 테이블을 TRUNCATE하고 auto-increment를 초기화하는 로직을 DataSeeder에 추가한다.
  2. isAlreadySeeded() 검사를 모든 핵심 테이블(users, brands, products)을 확인하도록 강화하거나, 보다 안전한 대안으로 시딩 상태를 별도 플래그 테이블로 관리한다.
  3. 테이블이 이미 존재할 때 재시딩이 발생해도 참조 무결성과 데이터 분포가 유지되는지 확인하는 통합 테스트를 추가한다.
🤖 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/seeder/BrandSeeder.java`
around lines 34 - 64, BrandSeeder currently omits explicit id values which lets
the DB auto-increment advance (causing later seeds like ProductSeeder and
BrandLikeSeeder to assume brand IDs 1..500 and fail); fix by either (A)
inserting explicit id in BrandSeeder's INSERT (include id column and set
ps.setInt for ids 1..BRAND_COUNT in the BatchPreparedStatementSetter in
BrandSeeder) or (B) ensure DataSeeder resets state before seeding by truncating
related tables and resetting sequences (add logic to DataSeeder before calling
BrandSeeder to TRUNCATE brands, products, brand_likes and reset the brands
sequence), and also strengthen DataSeeder.isAlreadySeeded() to check users,
brands, products (or use a dedicated seed flag table) so re-seeding preserves
expected ID ranges referenced by ProductSeeder and BrandLikeSeeder.
apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/CartItemSeeder.java-65-72 (1)

65-72: ⚠️ Potential issue | 🟠 Major

삭제 시각이 생성 시각보다 과거로 내려갈 수 있다.

Line 65-72에서 createdAtdeleted_at을 독립적으로 생성해서 최근 생성된 항목은 deleted_at < created_at가 쉽게 발생한다. 이런 데이터는 soft delete 조건, 정렬, 이력 분석 결과를 왜곡하므로 deleted_at은 항상 createdAt 이후가 되도록 createdAt~now 구간에서 파생해야 한다. 시드 후 전체 cart_items에 대해 deleted_at is null or deleted_at >= created_at를 검증하는 테스트를 추가해야 한다.

🤖 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/seeder/CartItemSeeder.java`
around lines 65 - 72, In CartItemSeeder update the deleted_at generation so it
is derived from createdAt (not independently from now): when isDeleted is true
pick a random instant between createdAt and now (e.g., random offset from
createdAt up to now-createdAt) and use that for deleted_at to guarantee
deleted_at >= createdAt; also add a post-seed test that queries cart_items and
asserts for every row that deleted_at is null or deleted_at >= created_at to
prevent regressions.
apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/DataSeeder.java-76-115 (1)

76-115: ⚠️ Potential issue | 🟠 Major

부분 시딩 상태를 products 한 테이블로만 판정하면 재실행이 막힌다.

Line 77-115는 products 건수만 보고 전체 시딩 완료로 간주하는데, 현재 구조에서는 중간 단계에서 실패해도 앞선 배치가 커밋될 수 있어 반쪽 데이터베이스가 다음 부팅부터 영구히 skip될 수 있다. 특히 다른 시더들이 1..N ID 연속성을 전제로 참조하고 있어 한번 어긋나면 이후 FK, unique 충돌까지 연쇄된다. 마지막 성공 시점에만 기록하는 별도 seeding marker를 두거나 각 핵심 테이블의 기대 건수를 검증해 불일치 시 재시도 또는 중단하도록 바꾸고, productSeeder.seed() 직후 강제 실패시킨 뒤 재기동해도 skip되지 않는 통합 테스트를 추가해야 한다.

🤖 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/seeder/DataSeeder.java`
around lines 76 - 115, The current isAlreadySeeded() check relies solely on
products count which lets partial runs permanently skip later retries; update
the seeding strategy by (A) replacing or augmenting isAlreadySeeded() to
validate multiple critical tables' expected counts (e.g., products, users,
orders) or check a dedicated seeding marker/flag written only after the full run
completes, and (B) ensure the codepath that writes the marker occurs at the very
end of run() after all seeders (userSeeder.seed(), productSeeder.seed(),
orderSeeder.seed(), etc.) finish successfully; add an integration test that
simulates a failure immediately after productSeeder.seed() and verifies that on
restart the seeder does not skip and either retries missing tables or proceeds
until the final marker is written.
apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/CouponTemplateSeeder.java-73-91 (1)

73-91: ⚠️ Potential issue | 🟠 Major

쿠폰 상태와 유효기간을 따로 뽑으면 데이터 불변식이 깨진다.

Line 73-91은 statusvalid_from/valid_to를 독립 생성해서 ACTIVE인데 이미 만료됐거나 EXPIRED인데 아직 유효한 쿠폰을 쉽게 만든다. 상태와 기간을 함께 사용하는 조회, 캐시, 성능 테스트 결과가 왜곡되므로 최소한 ACTIVEEXPIRED는 기간과 일치하도록 기간을 상태에서 파생해야 한다. 시드 후 status별로 ACTIVE => validFrom <= now < validTo, EXPIRED => validTo < now를 검증하는 테스트를 추가해야 한다.

🤖 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/seeder/CouponTemplateSeeder.java`
around lines 73 - 91, The current seeder generates status via generateStatus()
separately from validFrom/validTo, which can create inconsistent rows (e.g.,
ACTIVE but already expired); modify CouponTemplateSeeder so that after calling
generateStatus() you derive validFrom and validTo from the status: for "ACTIVE"
set validFrom <= now and validTo > now (e.g., validFrom = now.minus(random up to
X days), validTo = now.plus(random up to Y days)), for "EXPIRED" set validTo <
now (both validFrom and validTo in the past), and for other statuses choose an
appropriate window; update the code paths that call ps.setTimestamp(10/11) and
ps.setString(12) to use these derived dates, and add a unit/integration test
that seeds some rows and asserts invariants: ACTIVE => validFrom <= now <
validTo, EXPIRED => validTo < now.
apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserSeeder.java-41-41 (1)

41-41: ⚠️ Potential issue | 🟠 Major

시드 비밀번호는 유효한 BCrypt 해시여야 한다.

라인 41의 "$2a$10$dummyHashedPasswordForSeeding"은 완전한 BCrypt 해시 형식이 아니다. BCrypt 해시는 $2a$10$<22글자 salt><31글자 hash> 구조(총 60글자)를 가져야 하는데, 현재 값은 salt와 hash 부분이 불완전하다. 애플리케이션이 BCryptPasswordEncoder.matches()를 통해 인증할 때 이 값은 false를 반환하거나 예외를 발생시켜 시드 계정 로그인이 실패한다.

미리 생성한 정상 BCrypt 해시를 상수로 저장하거나, 시딩 시 PasswordEncoder.encode("testPassword")로 생성한 값을 재사용하도록 수정한다. 시드 완료 후 시드 계정으로 실제 로그인 가능 여부를 검증하는 통합 테스트를 추가한다.

🤖 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/seeder/UserSeeder.java`
at line 41, The seeded password string in UserSeeder (the ps.setString(...) for
the password parameter) is not a valid 60-character BCrypt hash; replace the
hardcoded "$2a$10$dummyHashedPasswordForSeeding" with a real BCrypt hash or
generate one at seed time using your PasswordEncoder (e.g., call
passwordEncoder.encode("testPassword") inside the UserSeeder before
ps.setString) and store that value, and add/invoke an integration test that
verifies the seeded account can authenticate using
BCryptPasswordEncoder.matches(); ensure you update any constant names if you
extract the precomputed hash.
http/commerce-api/order.http-35-38 (1)

35-38: ⚠️ Potential issue | 🟠 Major

예제 파일에 비밀번호를 하드코딩하지 않는 편이 안전하다.

.http 파일은 저장소, 화면 공유, 로그 수집 과정에서 그대로 노출되기 쉬워 테스트 계정 유출로 바로 이어진다. 운영에서는 스테이징 계정 재사용이나 자동화 스크립트 전파로 피해 범위가 커질 수 있으니 X-Loopers-LoginPw{{ORDER_API_PASSWORD}} 같은 변수로 치환하고 실제 값은 로컬 전용 환경 파일에서만 주입하는 방식으로 바꾸는 편이 안전하다. 추가로 시크릿 스캐너가 *.http 파일의 자격증명을 차단하는지와, 변수 치환 후 샘플 요청이 정상 동작하는지 확인하는 검증을 넣어야 한다.

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

In `@http/commerce-api/order.http` around lines 35 - 38, Replace the hardcoded
header value for X-Loopers-LoginPw with a placeholder variable (e.g.,
{{ORDER_API_PASSWORD}}) so the .http example does not contain actual
credentials; update any local environment or VSCode REST Client environment file
to inject the real password locally, run the sample request to verify variable
substitution works, and add a quick check that your secret-scanning rules (if
any) allow variableized .http files to avoid false positives.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/CursorEncoder.java-39-40 (1)

39-40: ⚠️ Potential issue | 🟠 Major

잘못된 커서를 IllegalArgumentException으로 던지면 API 오류 포맷이 흔들릴 수 있다.

이 예외가 컨트롤러 밖으로 그대로 올라가면 다른 입력 오류와 응답 스키마가 달라질 수 있다. 운영에서는 클라이언트가 커서 오류만 별도 처리해야 하고 로그 상관 분석도 어려워지므로, 기존 공통 오류 체계의 CoreException으로 감싸되 cause는 유지하는 형태로 맞추는 편이 안전하다. 추가로 잘못된 Base64, 잘못된 JSON, 필수 필드 누락 커서 각각에 대해 동일한 오류 응답 본문이 내려오는 API 테스트를 넣어야 한다. Based on learnings "In the loop-pack-be-l2-vol3-java project, enforce unified error handling by routing errors through CoreException to ApiControllerAdvice to ensure a consistent response format."

apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/CursorEncoder.java-20-22 (1)

20-22: ⚠️ Potential issue | 🟠 Major

Map<String, Object> 역직렬화 시 타입 정보 손실로 인한 런타임 오류 위험

JavaTimeModule을 등록해도 Map<String, Object>로 역직렬화할 때는 ISO-8601 날짜 문자열이 String으로 유지되며 ZonedDateTime 등 구체적 타입으로 변환되지 않는다. 이로 인해 다음 페이지 요청 시 정렬 키를 타입에 맞게 캐스팅할 때 런타임 오류가 발생하며, 특정 정렬 조건에서만 간헐적인 400/500 에러가 발생하여 원인 파악이 어려워진다.

ProductCursorPayload, OrderCursorPayload 같은 명시적 DTO나 record로 분리해 타입을 고정하거나, JsonNode로 읽고 필드를 명시적으로 파싱하는 방식으로 개선한다. 추가로 날짜 기반 커서와 숫자 기반 커서 각각에 대해 encode→decode round-trip 테스트를 추가해 타입이 유지되는지 검증한다.

한편 decode() 메서드의 catch (Exception e) 블록이 모든 예외를 포괄하고 IllegalArgumentException으로 변환하므로, Base64 디코딩 오류와 JSON 파싱 오류를 구분하기 어렵다. 예외 타입을 세분화하거나 로그에 원인 예외의 타입과 메시지를 명시적으로 기록하도록 개선한다.

🤖 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/common/CursorEncoder.java`
around lines 20 - 22, CursorEncoder currently deserializes cursor payloads into
Map<String,Object> using MAPPER which loses concrete types (e.g. ISO-8601 dates
become String) causing runtime cast errors; change CursorEncoder to deserialize
into explicit DTOs/records (e.g. ProductCursorPayload, OrderCursorPayload) or
read into JsonNode and parse each field to the correct type (ZonedDateTime,
Long, etc.) rather than Map<String,Object>, update encode/decode to use those
types, add unit tests that round-trip date-based and numeric cursors to verify
type preservation, and in decode() replace the broad catch(Exception) with
finer-grained handling (separate Base64 decoding errors vs JSON parsing/type
errors) and include the original exception type/message in the thrown
IllegalArgumentException or throw specific exceptions so callers can distinguish
failure reasons.
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java-95-99 (1)

95-99: ⚠️ Potential issue | 🟠 Major

size 파라미터 상한값 검증이 없어 DoS 위험이 존재한다.

현재 size 파라미터는 검증 없이 직접 데이터베이스 .limit() 쿼리에 전달되므로, 클라이언트가 의도적으로 매우 큰 값(예: 1000000)을 요청할 경우 데이터베이스 부하와 메모리 오버플로우가 발생한다. ProductControllersize 파라미터에 @Max 제약을 추가하여 상한값을 제한해야 한다. AdminCouponFacade에서 Math.max(1, size)로 방어하는 패턴처럼, 이 메서드도 일관된 검증 전략을 적용해야 한다. 추가로 경계값 테스트(size=최대값+1, size=음수)를 통해 검증 규칙이 정상 작동함을 확인해야 한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java`
around lines 95 - 99, The getDisplayableProductsWithCursor method forwards the
client-provided size directly to the repository causing a DoS risk; add an upper
bound check (and lower-bound clamp) before calling
productRepository.findAllDisplayableWithCursor — either enforce `@Max` on the
ProductController size parameter and/or cap/validate size inside ProductService
(e.g., sanitize size = Math.max(1, Math.min(size, MAX_PAGE_SIZE))) in
getDisplayableProductsWithCursor and then call
productRepository.findAllDisplayableWithCursor with the sanitized value; add
unit tests for boundary cases (size = MAX_PAGE_SIZE + 1 and size < 1) to ensure
the validation works.
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCursor.java-13-19 (1)

13-19: ⚠️ Potential issue | 🟠 Major

팩토리 메서드에서 id 필드의 null 검증이 필요하다.

커서 기반 페이지네이션에서 id는 동점(tie-breaker) 역할을 하므로 필수 값이다. 첫 페이지는 커서 객체 자체를 null로 전달하는 방식으로 올바르게 처리되고 있으나, 팩토리 메서드(ofLatest(), ofPrice(), ofLikes())에서 null id를 검증하지 않아 잘못된 커서가 리포지토리 쿼리 실행 시 Querydsl에서 문제를 야기할 수 있다. 특히 클라이언트에서 보낸 커서가 손상되거나 id 필드가 누락된 경우 decodeCursor() 메서드에서 NPE가 발생한다.

각 팩토리 메서드에서 Objects.requireNonNull(id, "id는 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/domain/product/ProductCursor.java`
around lines 13 - 19, ProductCursor 팩토리 메서드에서 id가 null일 경우 Querydsl/NPE를 유발하므로
ofLatest(), ofPrice(), ofLikes() 내부에서 생성 직전에 Objects.requireNonNull(id, "id는
null이 될 수 없습니다")로 검증을 추가해 잘못된 커서 생성을 방어하고, decodeCursor()에서 NPE가 발생하지 않도록 관련 단위
테스트를 각각 추가하여 null id 입력 시 예외가 발생하는지를 검증하세요.
apps/commerce-api/src/test/java/com/loopers/application/cache/ProductCacheIntegrationTest.java-161-162 (1)

161-162: ⚠️ Potential issue | 🟠 Major

목록 캐시 키가 size를 누락한다.

여기서 기대하는 키 규약이 products:list:{sort}:{brand|all}로 고정되어 있어 size=20size=50 첫 페이지가 같은 엔트리를 공유하게 된다. 운영에서는 요청한 건수와 실제 응답 건수가 엇갈리고, 다음 커서도 이전 요청 크기에 오염될 수 있다. 캐시 키에 size를 포함하거나 캐시를 고정 size에만 허용하고, 20 → 50, 50 → 20 시나리오 통합 테스트를 추가해야 한다.

Also applies to: 179-180, 211-213

🤖 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/cache/ProductCacheIntegrationTest.java`
around lines 161 - 162, The test constructs a cache key missing the requested
page size (key = "products:list:LATEST:all"), causing different-size requests to
collide; update ProductCacheIntegrationTest to include the size in the cache key
generation used with redisTemplate.opsForValue().get(...) (e.g.,
"products:list:{sort}:{size}:{brandOrAll}") or enforce using a fixed size across
the cache helper, and then add integration assertions that exercise both size=20
-> size=50 and size=50 -> size=20 sequences to verify no cross-contamination;
adjust the other occurrences referenced around the product-list tests (the
blocks using redisTemplate.opsForValue().get at the other noted locations) to
use the same size-aware key format.
apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java-104-105 (1)

104-105: ⚠️ Potential issue | 🟠 Major

주문 목록 invalidation에 single delete만 두면 stale write race가 남는다.

첫 페이지 cache miss가 DB를 읽는 동안 주문 생성/취소가 커밋되고 캐시를 한 번만 삭제하면, 느린 읽기 쪽이 오래된 목록을 다시 써 넣을 수 있다. 그러면 방금 생성되거나 취소된 주문 상태가 TTL 300초 동안 잘못 노출될 수 있다. 상품과 동일하게 delayed double delete를 적용하거나 versioned key/namespace bump로 write-after-delete race를 끊는 편이 안전하다. 생성/취소와 기본 목록 조회를 교차 실행하는 동시성 통합 테스트도 추가해야 한다.

Also applies to: 136-137, 288-288, 322-323

🤖 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/order/OrderFacade.java`
around lines 104 - 105, The single cache deletion using
orderCacheManager.evictOrderList(userId) leaves a stale-write race; update the
OrderFacade methods that call evictOrderList (the places around the shown
snippet and the other occurrences) to employ a safe invalidation strategy:
either perform a delayed double-delete (call evictOrderList(userId), sleep a
small configurable interval, then call evictOrderList(userId) again) or
implement a versioned key/namespace bump for the user's order list (increment
the user's order-list version/namespace on create/cancel and read keys keyed by
that version) so readers cannot re-populate an older namespace; also add a
concurrency integration test that runs parallel create/cancel and list-read to
assert no stale results are cached for the TTL window.
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java-73-87 (1)

73-87: ⚠️ Potential issue | 🟠 Major

잘못된 상품 cursor가 일관된 오류 응답으로 처리되지 않는다.

운영 중 잘못된 cursor가 들어오면 CursorEncoder.decode, ZonedDateTime.parse, 숫자 캐스팅 예외와 여기의 IllegalArgumentException이 그대로 올라가서 API 오류 형식이 깨질 수 있다. decodeCursor 전체를 try/catch로 감싸 전용 CoreException으로 변환하고 cause를 보존하는 편이 안전하다. sort 불일치, sv/id 누락, 타입 불일치 케이스의 컨트롤러 테스트도 추가해야 한다. Based on learnings: "In the loop-pack-be-l2-vol3-java project, enforce unified error handling by routing errors through CoreException to ApiControllerAdvice to ensure a consistent response format." As per coding guidelines: "예외 처리 시 cause를 보존하고, 사용자 메시지와 로그 메시지를 분리하도록 제안한다."

apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java-86-90 (1)

86-90: ⚠️ Potential issue | 🟠 Major

잘못된 cursor 입력이 500으로 누수된다.

운영 중 만료되거나 변조된 cursor가 들어오면 CursorEncoder.decode, ZonedDateTime.parse, 숫자 캐스팅 예외가 그대로 전파되어 응답 포맷이 깨지고 500으로 보일 가능성이 높다. 디코딩/파싱 전체를 try/catch로 감싸 전용 CoreException으로 변환하고 cause를 보존하는 편이 안전하다. base64 손상, createdAt/id 누락, 타입 불일치 케이스의 컨트롤러 테스트도 추가해야 한다. Based on learnings: "In the loop-pack-be-l2-vol3-java project, enforce unified error handling by routing errors through CoreException to ApiControllerAdvice to ensure a consistent response format." As per coding guidelines: "예외 처리 시 cause를 보존하고, 사용자 메시지와 로그 메시지를 분리하도록 제안한다."

🤖 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/order/OrderController.java`
around lines 86 - 90, Wrap the whole cursor decoding/parsing block in
OrderController (the code that calls CursorEncoder.decode, ZonedDateTime.parse
and casts id) in a try/catch that catches all parsing/decoding exceptions,
create and throw a CoreException (preserving the original exception as the
cause) with a user-facing message, and ensure ApiControllerAdvice will translate
that to the unified response; also add controller tests for corrupted base64,
missing createdAt/id, and wrong types to validate the conversion to
CoreException and consistent error responses.
apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java-342-360 (1)

342-360: ⚠️ Potential issue | 🟠 Major

기본 주문 목록 캐시가 size를 구분하지 않는다.

현재 cache get/put가 userId만으로 결정되어 size=50 요청이 채운 첫 페이지를 size=20 요청이 그대로 재사용할 수 있다. 그러면 응답 건수와 PagingInfo.size가 어긋나고 커서도 잘못 이어진다. 캐시 키에 size를 포함하거나, 최소한 캐시 사용을 고정 size(예: 20)일 때만 허용해야 한다. 20 → 50, 50 → 20, size=0/1 회귀 테스트를 추가하는 편이 안전하다.

최소 수정안 예시
+        boolean useDefaultCache = isDefaultQuery && size == 20;
-        if (isDefaultQuery) {
+        if (useDefaultCache) {
             java.util.Optional<OrderCursorResult> cached = orderCacheManager.getOrderList(userId);
             if (cached.isPresent()) {
                 return cached.get();
             }
         }
@@
-        if (isDefaultQuery) {
+        if (useDefaultCache) {
             orderCacheManager.putOrderList(userId, cursorResult);
         }
🤖 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/order/OrderFacade.java`
around lines 342 - 360, The cache currently keys only by userId (calls to
orderCacheManager.getOrderList / putOrderList in OrderFacade) so different
requests with different size values can return incorrect pages; change the
caching logic to include the requested size (e.g., key = userId + ":" + size) or
restrict caching to a single fixed page size (check isDefaultQuery &&
size==FIXED_SIZE before get/put), update the cache manager usages accordingly,
and add unit tests covering size changes (20→50, 50→20, and edge sizes like 0/1)
to ensure PagingInfo.size and cursor continuity remain correct.
apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java-196-205 (1)

196-205: ⚠️ Potential issue | 🟠 Major

활성 브랜드만 열거하는 목록 무효화는 방금 삭제/비활성화된 브랜드 키를 놓친다.

Line 198의 getAllActiveBrands() 결과만으로 키를 만들면, afterCommit 시점에 이미 active 집합에서 빠진 brandId의 products:list:{sort}:{oldBrandId}는 TTL까지 남는다. 운영에서는 브랜드 삭제/비활성화 직후에도 해당 브랜드 필터 첫 페이지가 stale하게 응답된다. 현재 활성 브랜드 외에 이번 트랜잭션에서 영향받은 brandId를 명시적으로 합쳐서 삭제하거나, 브랜드별 version/prefix 무효화로 전환하는 편이 안전하다. 특정 브랜드 목록 캐시를 미리 채운 뒤 삭제/비활성화 후 같은 brandId로 재조회했을 때 stale 캐시가 사용되지 않는 테스트를 추가해 달라.

🤖 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/cache/ProductCacheManager.java`
around lines 196 - 205, evictAllProductListCache() currently builds eviction
keys only from brandService.getAllActiveBrands(), which misses brandIds
removed/disabled in the same transaction and leaves stale
products:list:{sort}:{oldBrandId} entries; update the method to also include the
set of brandIds affected by the current transaction (e.g., pass in or fetch
recently changed brandIds from the caller/brandService) when constructing keys
(still using LIST_KEY_PREFIX and iterating ProductSortType.values()), or switch
to a brand-specific version/prefix invalidation scheme so deactivations
immediately change cache keys; also add an integration test that pre-warms a
brand-specific products list cache, performs brand deactivate/delete, then
re-queries that brandId and asserts the stale cache is not returned.
apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java-156-167 (1)

156-167: ⚠️ Potential issue | 🟠 Major

브랜드 비활성화에서 상세 캐시를 남기면 비노출 상품 상세가 최대 300초 동안 계속 노출된다.

Line 157의 "상세는 브랜드 상태 무관" 가정이 현재 상세 조회 규칙과 맞지 않는다. ProductFacade.getProductDetail()의 DB 경로는 brandService.getActiveBrand(product.getBrandId())를 통과해야 하므로, 브랜드가 비활성 상태가 되면 해당 브랜드 상품 상세도 즉시 막혀야 한다. active→inactive 전환 시 영향받는 productId 목록을 함께 받아 상세 캐시도 1·2차 삭제하도록 바꾸고, 상태 전환 직후 상세 조회가 캐시 hit로 성공하지 않는 통합 테스트를 추가해 달라.

🤖 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/cache/ProductCacheManager.java`
around lines 156 - 167, The current registerListOnlyDoubleDelete() assumes
product detail cache is unaffected by brand state changes, but
ProductFacade.getProductDetail() calls
brandService.getActiveBrand(product.getBrandId()) so detail caches must be
evicted for affected productIds; modify registerListOnlyDoubleDelete() (or add a
new overload) to accept a Collection<Long> affectedProductIds and call
evictAllProductListCache() plus evictProductDetailCache(productId) for each id
immediately after commit and again after DOUBLE_DELETE_DELAY_MS, updating usages
to pass the list of productIds on active→inactive transitions; also add an
integration test that toggles a brand to inactive, supplies the impacted
productIds, and asserts that a subsequent ProductFacade.getProductDetail() does
not return a cached (hit) response immediately after the transition.
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java-55-75 (1)

55-75: ⚠️ Potential issue | 🟠 Major

첫 페이지 목록 캐시에 size 차원이 빠져 있어 서로 다른 요청이 오염된다.

Line 59와 Line 74의 캐시 API가 size를 받지 않아서, 동일한 brandId/sort 조합에 대해 size=20으로 캐시가 채워진 뒤 size=50 요청이 들어오면 20개 결과와 hasNext가 그대로 재사용된다. 캐시 계약을 size까지 포함하도록 바꾸되 무효화도 같은 차원으로 맞추거나, 이 API에서 허용하는 page size를 단일 값으로 고정하는 편이 안전하다. 같은 brandId/sortsize만 다르게 두 번 호출했을 때 서로 다른 캐시 엔트리를 사용하고 반환 개수와 hasNext가 각각 올바른지 테스트를 추가해 달라.

수정 예시
-            Optional<ProductCursorResult> cached = productCacheManager.getProductList(sort, brandId);
+            Optional<ProductCursorResult> cached = productCacheManager.getProductList(sort, brandId, size);
...
-            productCacheManager.putProductList(sort, brandId, cursorResult);
+            productCacheManager.putProductList(sort, brandId, size, cursorResult);
🤖 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 55 - 75, The cache currently ignores page size causing
cross-contamination; update getDisplayableProductsWithCursor to include size in
the cache key (use productCacheManager.getProductList(sort, brandId, size) and
productCacheManager.putProductList(sort, brandId, size, cursorResult) or adjust
the existing cache APIs accordingly) or alternatively enforce a single fixed
page size used by this method and only cache that size; ensure the cache
invalidation paths use the same size dimension; add unit tests for
getDisplayableProductsWithCursor that call it twice with the same brandId/sort
but different size values and assert separate cache entries are used and that
returned ProductCursorResult.items().size() and hasNext() match the underlying
service results.
apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java-60-83 (1)

60-83: ⚠️ Potential issue | 🟠 Major

Redis 연결 실패 시 조회 API까지 장애가 전파되고, 캐시 무효화가 중단될 수 있다.

redisTemplateget/set/delete 호출이 JSON 역직렬화 예외(JsonProcessingException)만 처리하고 있어서, Redis 연결 끊김, 타임아웃, 메모리 부족 등으로 인한 DataAccessException 계열 예외가 그대로 전파된다. 운영에서는 Redis 일시 장애만으로 상품 조회 API가 500이 되거나, 후속 무효화 콜백이 중간에서 끊겨 stale 캐시가 TTL까지 남을 수 있다.

수정안:

  • get/set/delete 호출을 각각 try-catch로 감싸서 DataAccessException을 별도로 잡는다.
  • 읽기(get) 실패는 Optional.empty()로 miss 처리하여 DB 조회로 폴백한다.
  • 쓰기(set) 실패는 경고 로그만 남기고 요청 자체는 성공시킨다(부분 쓰기는 무시).
  • 삭제(delete) 실패는 경고 로그만 남기고 나머지 키 삭제를 계속 시도한다.

추가 테스트:

  • RedisConnectionFailureException 시뮬레이션 시 상세/목록 조회가 DB 결과로 정상 응답하는지 확인한다.
  • 무효화 콜백에서 일부 삭제 실패 시에도 나머지 키 삭제를 계속 시도하는지 확인한다.
  • 테스트 구성: redisTemplate을 mock하여 get/set/delete에서 DataAccessException 계열 예외를 throw하도록 설정한다.
🤖 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/cache/ProductCacheManager.java`
around lines 60 - 83, The current getProductDetail and putProductDetail use
redisTemplate.get/set/delete without catching DataAccessException, so Redis
connection/timeouts will propagate; wrap each redisTemplate call in
try-catch(DataAccessException) around the redis access in getProductDetail (for
key = DETAIL_KEY_PREFIX + productId) so a read failure returns Optional.empty()
(fall back to DB) and logs a warning, wrap the write in putProductDetail
(redisTemplate.opsForValue().set with DETAIL_TTL) so a write failure only logs a
warning and does not fail the request, and ensure any redisTemplate.delete(key)
calls (including the one in the JsonProcessingException handler) are similarly
caught so deletion failures log a warning and allow other invalidations to
continue; keep existing JsonProcessingException handling for objectMapper
unchanged.
🟡 Minor comments (9)
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java-161-172 (1)

161-172: ⚠️ Potential issue | 🟡 Minor

빈 ID 목록은 즉시 반환해야 한다.

ids가 비어 있으면 결과는 항상 빈 리스트인데도 현재 구현은 브랜드 조회와 상품 조회를 계속 수행한다. 운영에서는 "좋아요 없음" 같은 흔한 정상 케이스가 불필요한 DB 부하와 지연으로 바뀐다. 수정안은 메서드 초입에서 빈 입력을 즉시 반환하도록 처리하는 것이다. 추가 테스트로 빈 ids 입력에서 빈 결과를 반환하고, 추가 조회 없이 종료되는지 리포지토리 테스트로 고정하는 것이 좋다.

예시 수정안
 `@Override`
 public List<Product> findAllByIdIn(List<Long> ids) {
+    if (ids == null || ids.isEmpty()) {
+        return List.of();
+    }
+
     QProductEntity product = QProductEntity.productEntity;
 
     List<Long> activeBrandIds = getActiveBrandIds();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java`
around lines 161 - 172, The method ProductRepositoryImpl.findAllByIdIn currently
calls getActiveBrandIds() and executes a query even when the input ids list is
empty; change the method to check if ids == null or ids.isEmpty() at the very
start and immediately return Collections.emptyList() (or List.of()) to avoid
calling getActiveBrandIds() and the query; update or add a repository unit test
for findAllByIdIn to pass an empty ids list and assert it returns an empty list
and that getActiveBrandIds()/queryFactory are not invoked (mock verification) to
prevent unnecessary DB work.
apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/BrandLikeSeeder.java-92-101 (1)

92-101: ⚠️ Potential issue | 🟡 Minor

좋아요가 비활성 또는 삭제 브랜드까지 생성된다.

generateBrandId()의 5% 경로는 401500을 반환하는데, 제공된 BrandSeeder 기준 이 구간은 비활성이고 491500은 soft delete다. 이렇게 만들면 사용자 좋아요가 실제 노출되지 않는 브랜드에 쌓여 조회 결과와 캐시 적중률 분석이 어긋난다. 기본 시나리오는 ACTIVE_BRAND_COUNT 이하의 브랜드만 선택하고, 비활성 브랜드 케이스가 꼭 필요하면 별도 시더로 분리하는 편이 안전하다. brand_likesbrands를 join해서 status = ACTIVE이면서 deleted_at is 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/seeder/BrandLikeSeeder.java`
around lines 92 - 101, generateBrandId() is currently returning IDs in 401–500
(inactive / soft-deleted brands) which causes likes to point at non-visible
brands; update the BrandLikeSeeder.generateBrandId() logic to only return IDs
within the active range (use the project constant ACTIVE_BRAND_COUNT or the same
upper bound used by BrandSeeder) and remove the branch that generates 401–500
IDs (or move that behavior into a separate inactive-brand seeder). Also add a
unit/integration test that inserts brand_likes and joins brands to assert that
only brands with status = ACTIVE and deleted_at IS NULL are referenced by likes
to catch regressions.
k6/cache-protection.js-29-39 (1)

29-39: ⚠️ Potential issue | 🟡 Minor

워밍업 없이 바로 측정하면 캐시 보호 효과가 왜곡된다.

주석은 "캐시 워밍업 후" 시나리오인데 실제 스크립트는 첫 요청부터 임계치에 포함해 cold miss와 cache hit를 섞어 측정한다. 운영에서는 이 결과를 근거로 캐시 보호 효과를 판단하면 p95가 환경마다 흔들려 성능 결론이 불안정해진다. setup()에서 정렬별 첫 페이지를 한 번씩 호출해 캐시를 채운 뒤 본문 시나리오에서는 hit 상태만 측정하도록 분리하는 편이 안전하다. 추가로 워밍업 유무에 따른 응답시간 차이를 비교하는 k6 시나리오를 하나 더 두어 캐시 보호 효과가 실제로 재현되는지 확인해야 한다.

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

In `@k6/cache-protection.js` around lines 29 - 39, The current default exported
function in k6/cache-protection.js mixes cold misses and hits because there is
no warmup; add a setup() function that iterates SORT_TYPES and calls
`${BASE_URL}/api/v1/products?sort=${sort}&size=20` once per sort to populate the
cache before the test, and modify the default exported function (the existing
anonymous export) to only issue requests that expect cache hits (preserve the
tag 'list_cached'); additionally add a second scenario (separate k6 scenario or
named function) that runs the same requests without calling setup() so you can
compare warmup vs no-warmup latencies. Ensure you reference SORT_TYPES,
BASE_URL, the request URL construction, and the 'list_cached' tag when making
these changes.
apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCursor.java-24-26 (1)

24-26: ⚠️ Potential issue | 🟡 Minor

ofPrice 팩토리 메서드가 정렬 타입을 검증하지 않는다.

ofPricePRICE_ASC 또는 PRICE_DESC에서만 사용되어야 하나, 어떤 ProductSortType이든 허용한다. 잘못된 정렬 타입 전달 시 쿼리 조건 불일치로 인해 예상치 못한 결과가 발생할 수 있다.

🛡️ 정렬 타입 검증 추가
 public static ProductCursor ofPrice(ProductSortType sort, Integer basePrice, Long id) {
+    if (sort != ProductSortType.PRICE_ASC && sort != ProductSortType.PRICE_DESC) {
+        throw new IllegalArgumentException("Price cursor requires PRICE_ASC or PRICE_DESC sort type");
+    }
     return new ProductCursor(sort, null, basePrice, null, id);
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCursor.java`
around lines 24 - 26, ofPrice 팩토리 메서드(ProductCursor.ofPrice)가 ProductSortType
검증을 하지 않아 잘못된 정렬 타입 전달 시 쿼리 불일치가 발생할 수 있습니다; ofPrice의 시작에서 전달된 sort가
ProductSortType.PRICE_ASC 또는 ProductSortType.PRICE_DESC인지 확인하고 그렇지 않으면
IllegalArgumentException을 던지도록 검증 로직을 추가하세요(에러 메시지에 허용되는 값들을 포함). 이 변경으로 잘못된 사용을
조기에 차단하고 호출자에게 명확한 예외를 제공할 수 있습니다.
k6/concurrent-read-write.js-21-21 (1)

21-21: ⚠️ Potential issue | 🟡 Minor

stalePriceCount 카운터가 정의만 되고 사용되지 않는다.

캐시 무효화 후 stale 데이터 반환을 감지하기 위해 정의된 것으로 보이나, 실제로 증가시키는 로직이 없다. 쓰기 후 읽기에서 이전 가격이 반환되는지 검증하는 로직을 추가하거나, 사용하지 않는다면 제거해야 한다.

♻️ Stale 데이터 감지 로직 예시
+let lastWrittenPrice = null;
+
 export function writer() {
   const newPrice = 10000 + Math.floor(Math.random() * 90000);
+  lastWrittenPrice = newPrice;
   const res = http.patch(`${BASE_URL}/api-admin/v1/products/1`,
     JSON.stringify({ basePrice: newPrice }),
     { headers: ADMIN_HEADER, tags: { name: 'write_update' } }
   );
   check(res, { 'update 200': (r) => r.status === 200 });
   sleep(10);
 }

 export function reader() {
   // ... existing code ...
   } else {
     const res = http.get(`${BASE_URL}/api/v1/products/1`,
       { tags: { name: 'read_detail' } });
     check(res, { '200 OK': (r) => r.status === 200 });
+    // Stale 감지: 쓰기 이후 이전 가격 반환 시 카운트
+    if (lastWrittenPrice && res.status === 200) {
+      const body = JSON.parse(res.body);
+      if (body.data && body.data.basePrice !== lastWrittenPrice) {
+        stalePriceCount.add(1);
+      }
+    }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@k6/concurrent-read-write.js` at line 21, The Counter stalePriceCount is
declared but never updated; either increment it when a stale price is detected
in the read-after-write validation flow (i.e., inside the logic that verifies
the read returns the newly written price—increment stalePriceCount when the
observed price equals the previous/old value) or remove the unused
stalePriceCount declaration; locate the read-after-write verification (the
scenario or function performing the write then subsequent read) and add a
stalePriceCount.add(1) call on detection of stale data, or delete the
stalePriceCount constant if you choose to drop stale detection entirely.
k6/no-cache-baseline.js-39-43 (1)

39-43: ⚠️ Potential issue | 🟡 Minor

setup에서 응답 구조 접근 시 방어 로직이 부족하다.

body.data.products에 직접 접근하는데, 응답 구조가 예상과 다르거나 products가 빈 배열인 경우 lastProduct가 undefined가 되어 이후 테스트에서 fallback 값 100000을 사용하게 된다. 이 fallback이 유효하지 않은 커서일 경우 모든 요청이 실패할 수 있다.

🛡️ 방어 로직 강화
       if (body.data && body.data.products && body.data.products.length > 0) {
         const lastProduct = body.data.products[body.data.products.length - 1];
-        result[sort] = lastProduct.id;
+        if (lastProduct && lastProduct.id) {
+          result[sort] = lastProduct.id;
+        }
       }
+    } else {
+      console.warn(`Failed to fetch initial cursor for ${sort}: ${res.status}`);
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@k6/no-cache-baseline.js` around lines 39 - 43, In the setup block that
populates result using response body, harden access to body.data.products: check
that body and body.data exist, that Array.isArray(body.data.products) and it has
length > 0, and that the computed lastProduct is defined and has an id before
assigning result[sort] = lastProduct.id; if any check fails, do not assign the
fallback 100000 as a valid cursor—either leave result[sort] undefined/null or
set a safe sentinel and log a warning so downstream code can handle
missing/invalid cursors; update the logic around lastProduct, result, and sort
accordingly.
apps/commerce-api/src/main/java/com/loopers/application/cache/OrderCacheManager.java-74-83 (1)

74-83: ⚠️ Potential issue | 🟡 Minor

트랜잭션 컨텍스트 부재 시 registerEvictAfterCommit 호출 방어 필요

TransactionSynchronizationManager.registerSynchronization()은 활성 트랜잭션이 없으면 IllegalStateException을 발생시킨다. 호출 측에서 반드시 @Transactional 내부에서 호출해야 하나, 방어적 코드가 없으면 운영 중 예기치 않은 장애로 이어질 수 있다.

방어 코드 추가 제안
 public void registerEvictAfterCommit(Long userId) {
+    if (!TransactionSynchronizationManager.isSynchronizationActive()) {
+        evictOrderList(userId);
+        return;
+    }
     TransactionSynchronizationManager.registerSynchronization(
             new TransactionSynchronization() {
                 `@Override`
                 public void afterCommit() {
                     evictOrderList(userId);
                 }
             }
     );
 }
🤖 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/cache/OrderCacheManager.java`
around lines 74 - 83, Wrap the call to
TransactionSynchronizationManager.registerSynchronization in a defensive check
inside OrderCacheManager.registerEvictAfterCommit: first call
TransactionSynchronizationManager.isSynchronizationActive() (or
isActualTransactionActive()) and only register the TransactionSynchronization
that calls evictOrderList(userId) if true; if no active transaction, avoid
calling registerSynchronization and instead call evictOrderList(userId) directly
(or log and schedule a safe async eviction) so registerSynchronization is never
invoked when no transaction is present.
apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java-53-53 (1)

53-53: ⚠️ Potential issue | 🟡 Minor

registerListOnlyDoubleDelete() 메서드를 명시적으로 사용하도록 변경하라

createProduct에서 registerDelayedDoubleDelete(null)을 호출하는데, 이는 목록 캐시만 무효화하려는 의도이다. 구현부를 보면 null 처리가 올바르게 되어 있으나, BrandAdminFacade의 changeBrandStatus에서는 동일한 목적으로 registerListOnlyDoubleDelete()를 사용하고 있다.

null 전달은 의도를 명시하지 못하므로 일관성 있게 전용 메서드를 사용하라:

productCacheManager.registerListOnlyDoubleDelete();
🤖 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/ProductAdminFacade.java`
at line 53, Change the call in ProductAdminFacade#createProduct from
productCacheManager.registerDelayedDoubleDelete(null) to the explicit
productCacheManager.registerListOnlyDoubleDelete() so the intent to invalidate
only list caches is clear and consistent with BrandAdminFacade; update the
invocation in the createProduct method to use registerListOnlyDoubleDelete and
remove the null argument usage of registerDelayedDoubleDelete.
apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java-76-80 (1)

76-80: ⚠️ Potential issue | 🟡 Minor

endAt 경계가 배타적으로 바뀌었다.

같은 리포지토리의 기존 조회 경로는 Between 기반인데 여기만 createdAt.lt(endAt)라서 endAt과 정확히 같은 시각의 주문이 빠진다. 날짜 경계 누락은 고객 문의 시 재현이 어려워 운영 부담이 크다. loe(endAt)로 맞추거나, 배타 경계가 의도라면 컨트롤러/스펙/테스트를 함께 맞춰 의미를 고정해야 한다. createdAt == endAt인 주문 포함 여부를 검증하는 저장소 테스트도 추가하는 편이 안전하다.

수정 예시
-                        order.createdAt.lt(endAt),
+                        order.createdAt.loe(endAt),
🤖 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/order/OrderRepositoryImpl.java`
around lines 76 - 80, OrderRepositoryImpl에서 쿼리 조건에 order.createdAt.lt(endAt)로 되어
있어 endAt과 정확히 같은 시각의 주문이 누락됩니다; order.createdAt.lt(endAt) 를
order.createdAt.loe(endAt)로 변경하여 기존 Between 경계와 일치시키거나(권장) 만약 배타 경계가 의도라면
컨트롤러/스펙/테스트를 함께 수정해 의미를 고정하세요; 변경 대상은 OrderRepositoryImpl의 where 절과 연관된 order,
createdAt, orderCursorCondition 참조 코드이며, 변경 후 createdAt == endAt인 주문이 포함되는지 검증하는
저장소 단위 테스트도 추가하십시오.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ff04a4e9-4797-41b0-83a0-29e85d0422ba

📥 Commits

Reviewing files that changed from the base of the PR and between bfc6bb5 and 2c6124d.

📒 Files selected for processing (51)
  • apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/cache/OrderCacheManager.java
  • apps/commerce-api/src/main/java/com/loopers/application/cache/ProductCacheManager.java
  • apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
  • apps/commerce-api/src/main/java/com/loopers/domain/common/CursorResult.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCursor.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderMapper.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/BrandLikeSeeder.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/BrandSeeder.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/CartItemSeeder.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/CouponTemplateSeeder.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/DataSeeder.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/InventorySeeder.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/IssuedCouponSeeder.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/OrderSeeder.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/PaymentSeeder.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/PointAccountSeeder.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/ProductLikeSeeder.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/ProductSeeder.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserAddressSeeder.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserSeeder.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/common/CursorEncoder.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderApiSpec.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderController.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderResponse.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductApiSpec.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductController.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductResponse.java
  • apps/commerce-api/src/test/java/com/loopers/application/cache/OrderCacheIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/cache/ProductCacheIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductRepositoryIntegrationTest.java
  • docker/infra-compose.yml
  • http/commerce-api/order.http
  • http/commerce-api/product.http
  • k6/cache-protection.js
  • k6/concurrent-read-write.js
  • k6/no-cache-baseline.js
  • k6/product-detail.js
  • k6/product-list.js
  • k6/product-load.js
💤 Files with no reviewable changes (2)
  • apps/commerce-api/src/test/java/com/loopers/infrastructure/product/ProductRepositoryIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java

Comment on lines +49 to +58
export default function (data) {
const sort = SORT_TYPES[Math.floor(Math.random() * SORT_TYPES.length)];
// 2페이지 요청 → cursor 파라미터 포함 → 캐시 우회 (첫 페이지만 캐싱)
const lastId = data[sort] || 100000;
const url = `${BASE_URL}/api/v1/products?sort=${sort}&size=20&cursorId=${lastId}`;
const res = http.get(url, { tags: { name: 'list_no_cache' } });

check(res, {
'200 OK': (r) => r.status === 200,
});
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
# 실제 API에서 커서 파라미터를 어떻게 처리하는지 확인
rg -n "cursorId|cursor" --type=java -A5 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/

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

Length of output: 3910


🏁 Script executed:

#!/bin/bash
# k6 테스트 파일에서 커서 파라미터를 어떻게 사용하는지 확인
cat -n k6/no-cache-baseline.js | head -60

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

Length of output: 2113


🏁 Script executed:

#!/bin/bash
# CursorEncoder 구현 확인
rg -n "class CursorEncoder|encode|decode" --type=java -A10 | grep -A10 "CursorEncoder"

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

Length of output: 9194


🏁 Script executed:

#!/bin/bash
# API 응답 구조 확인 - ProductResponse 클래스에서 nextCursor 또는 cursor 필드 찾기
rg -n "class ProductResponse|nextCursor|PagingInfo" --type=java -A15 apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ | head -80

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

Length of output: 7435


API 커서 파라미터 형식을 정확히 반영해야 한다.

현재 코드는 세 가지 문제가 있다:

  1. 파라미터 이름 오류: cursorId=${lastId}를 사용하나, API는 cursor 파라미터를 기대한다. (ProductController.java line 35)

  2. 파라미터 값 형식 오류: 순수 ID 값을 전달하나, API는 CursorEncoder로 Base64 인코딩된 JSON 형식을 요구한다. (CursorEncoder.java line 29, 37) 이 형식의 커서를 디코딩하면 {"sort": "...", "id": ..., "sv": "..."} 형태의 데이터가 추출되어야 한다.

  3. setup()에서 추출 값 오류: lastProduct.id만 추출하나, API 응답에서 제공하는 body.data.paging.nextCursor를 사용해야 한다. (ProductResponse.java line 58)

현재 상태에서 테스트는 실패한다. CursorEncoder.decode()가 순수 ID를 디코딩하려 할 때 "유효하지 않은 커서입니다" 예외를 발생시킨다. setup()에서 응답의 nextCursor를 추출하고, 요청 URL에 cursor 파라미터명으로 정확히 전달하도록 수정해야 한다.

수정 방안
export function setup() {
  const result = {};
  for (const sort of SORT_TYPES) {
    const res = http.get(`${BASE_URL}/api/v1/products?sort=${sort}&size=20`);
    if (res.status === 200) {
      const body = JSON.parse(res.body);
      if (body.data && body.data.paging && body.data.paging.nextCursor) {
        result[sort] = body.data.paging.nextCursor;  // nextCursor 추출
      }
    }
  }
  return result;
}

export default function (data) {
  const sort = SORT_TYPES[Math.floor(Math.random() * SORT_TYPES.length)];
  const cursor = data[sort];
  // cursor가 null인 경우 첫 페이지 요청으로 처리하거나 테스트 스킵
  const url = cursor 
    ? `${BASE_URL}/api/v1/products?sort=${sort}&size=20&cursor=${cursor}`  // cursor 파라미터 사용
    : `${BASE_URL}/api/v1/products?sort=${sort}&size=20`;
  const res = http.get(url, { tags: { name: 'list_no_cache' } });
  // ...
}

수정 후에는 실제 커서 기반 pagination이 정상 작동하는지, 각 정렬 타입별로 올바른 커서가 전달되는지 추가 테스트가 필요하다.

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

In `@k6/no-cache-baseline.js` around lines 49 - 58, The test fails because the
script sends a raw ID as cursor and uses the wrong parameter name; update
setup() to parse the service response and store body.data.paging.nextCursor for
each SORT_TYPES entry (instead of lastProduct.id), then change the default
exported function to read that stored cursor (e.g., data[sort]) and send it
using the query parameter name cursor (not cursorId); ensure the request falls
back to the first-page URL when no cursor is available so CursorEncoder.decode
is never fed a plain numeric ID.

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

🤖 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/infrastructure/seeder/OrderSeeder.java`:
- Around line 57-58: The generated order times are inconsistent with status
values in OrderSeeder: adjust the time generation logic so it enforces
status-specific constraints — for PENDING (detected via generateOrderStatus())
set orderedAt to within the last 30 minutes (or otherwise ensure expiresAt =
orderedAt.plusMinutes(30) yields expiresAt > now); for CANCELED set orderedAt to
a past time and set canceledAt to a timestamp between orderedAt and now (i.e.,
canceledAt = orderedAt.plusSeconds(randomWithin(0, now - orderedAt))); only
compute expiresAt for statuses that logically require it (e.g., PENDING) and
ensure it is > now; finally add seed test assertions that PENDING records have
expires_at > now and CANCELED records satisfy ordered_at <= canceled_at <= now
to prevent regressions.
- Around line 128-168: The current flushOrdersAndItems flow is non-atomic and
uses a fragile MAX(id) window to map new orders to items; change
flushOrdersAndItems to run the whole "flushOrders -> lookup inserted orders ->
flushOrderItems" sequence inside a single transaction (use `@Transactional` on
flushOrdersAndItems or programmatic TransactionTemplate) and stop using
lastIdBefore/MAX(id); instead query the just-inserted rows by their batch-unique
key (orderNumber) produced in the batch (use orderNumber to fetch orders and
their IDs after flushOrders returns), verify the returned newOrder list size
equals itemsPerOrder.size() and throw an exception if it does not, then map
itemsPerOrder to the fetched order IDs deterministically and call
flushOrderItems; keep references to flushOrders, flushOrderItems, itemsPerOrder,
and orderNumber to locate the changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 44733181-bc3e-471b-a821-59d388e13f0a

📥 Commits

Reviewing files that changed from the base of the PR and between 2c6124d and 02f07f1.

📒 Files selected for processing (1)
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/OrderSeeder.java

Comment on lines +57 to +58
String status = generateOrderStatus();
ZonedDateTime orderedAt = now.minusDays(random.nextInt(180)).minusHours(random.nextInt(24));
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

상태와 시간 필드의 정합성이 깨진다.

Line 58에서 orderedAt을 최근 180일 범위로 뽑고 Line 86에서 expiresAt = orderedAt.plusMinutes(30)을 넣으면, PENDING 주문 대부분이 생성 시점부터 이미 만료된 상태가 된다. 반대로 Line 85의 orderedAt.plusDays(1)CANCELED 주문에 미래 시각의 canceled_at을 만들 수 있다. 이런 데이터는 상태 기반 목록 조회, 캐시 검증, 통계 테스트를 왜곡한다.

상태별로 시간 제약을 분리하는 편이 안전하다. PENDINGorderedAt을 최근 30분 이내로 제한하거나 expiresAt > now를 보장하고, CANCELEDorderedAt <= canceledAt <= now를 만족하도록 생성해야 한다.

수정 예시
-            String status = generateOrderStatus();
-            ZonedDateTime orderedAt = now.minusDays(random.nextInt(180)).minusHours(random.nextInt(24));
+            String status = generateOrderStatus();
+            ZonedDateTime orderedAt;
+            ZonedDateTime canceledAt = null;
+            ZonedDateTime expiresAt = null;
+
+            if ("PENDING".equals(status)) {
+                orderedAt = now.minusMinutes(random.nextInt(30));
+                expiresAt = orderedAt.plusMinutes(30);
+            } else {
+                orderedAt = now.minusDays(random.nextInt(180)).minusHours(random.nextInt(24));
+                if ("CANCELED".equals(status)) {
+                    canceledAt = orderedAt.plusHours(random.nextInt(24) + 1);
+                    if (canceledAt.isAfter(now)) {
+                        canceledAt = now;
+                    }
+                }
+            }
...
-            ZonedDateTime canceledAt = "CANCELED".equals(status) ? orderedAt.plusDays(1) : null;
-            ZonedDateTime expiresAt = "PENDING".equals(status) ? orderedAt.plusMinutes(30) : null;

추가로 시더 테스트에서 PENDING -> expires_at > now, CANCELED -> ordered_at <= canceled_at <= now를 검증하는 케이스를 넣는 편이 좋다.

Also applies to: 85-86

🤖 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/seeder/OrderSeeder.java`
around lines 57 - 58, The generated order times are inconsistent with status
values in OrderSeeder: adjust the time generation logic so it enforces
status-specific constraints — for PENDING (detected via generateOrderStatus())
set orderedAt to within the last 30 minutes (or otherwise ensure expiresAt =
orderedAt.plusMinutes(30) yields expiresAt > now); for CANCELED set orderedAt to
a past time and set canceledAt to a timestamp between orderedAt and now (i.e.,
canceledAt = orderedAt.plusSeconds(randomWithin(0, now - orderedAt))); only
compute expiresAt for statuses that logically require it (e.g., PENDING) and
ensure it is > now; finally add seed test assertions that PENDING records have
expires_at > now and CANCELED records satisfy ordered_at <= canceled_at <= now
to prevent regressions.

Comment on lines +128 to +168
private void flushOrdersAndItems(List<Object[]> orderBatch, List<List<Object[]>> itemsPerOrder) {
// 주문 INSERT 전 마지막 ID 확인
Long lastIdBefore = jdbcTemplate.queryForObject(
"SELECT COALESCE(MAX(id), 0) FROM orders", Long.class
);

// 주문 INSERT
flushOrders(orderBatch);

// 방금 삽입된 주문들의 ID 조회
List<Long> newOrderIds = jdbcTemplate.queryForList(
"SELECT id FROM orders WHERE id > ? ORDER BY id",
Long.class, lastIdBefore
);

// 주문상품에 order_id 매핑 후 INSERT
List<Object[]> itemBatch = new ArrayList<>(5_000);
for (int i = 0; i < newOrderIds.size(); i++) {
Long orderId = newOrderIds.get(i);
List<Object[]> items = itemsPerOrder.get(i);

for (Object[] item : items) {
itemBatch.add(new Object[]{
orderId,
item[0], // productId
item[1], // productName
item[2], // brandName
item[3], // unitPrice
item[4] // quantity
});

if (itemBatch.size() >= 5_000) {
flushOrderItems(itemBatch);
itemBatch.clear();
}
}
}

if (!itemBatch.isEmpty()) {
flushOrderItems(itemBatch);
}
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

주문/주문상품 배치가 원자적이지 않고 ID 범위 매핑도 취약하다.

Line 130의 MAX(id) 조회, Line 135의 주문 insert, Line 138의 id 재조회, Line 160/167의 주문상품 insert가 각각 별도 JDBC 호출로 끝난다. 그래서 flushOrderItems() 중간에 예외가 나면 주문만 저장되고 order_items가 비는 배치가 남는다. 여기에 Line 138의 id > lastIdBefore 방식은 다른 세션의 주문 insert가 끼어들면 newOrderIds에 외부 행이 섞일 수 있어 itemsPerOrder와 매핑이 어긋난다. 이 상태는 DataSeederproducts 존재 여부만 보고 전체 시딩을 건너뛰는 구조와 만나면, 재기동으로도 자동 복구되지 않는다.

한 배치의 주문 insert / id 해석 / order_items insert를 하나의 트랜잭션으로 묶고, id 범위 대신 배치 내부의 고유 키인 orderNumber로 다시 조회해 매핑하는 편이 안전하다. 또한 조회된 주문 수와 itemsPerOrder.size()가 다르면 즉시 실패하도록 방어 코드를 두는 편이 좋다.

수정 예시
-    private void flushOrdersAndItems(List<Object[]> orderBatch, List<List<Object[]>> itemsPerOrder) {
-        Long lastIdBefore = jdbcTemplate.queryForObject(
-            "SELECT COALESCE(MAX(id), 0) FROM orders", Long.class
-        );
-
-        flushOrders(orderBatch);
-
-        List<Long> newOrderIds = jdbcTemplate.queryForList(
-            "SELECT id FROM orders WHERE id > ? ORDER BY id",
-            Long.class, lastIdBefore
-        );
+    private void flushOrdersAndItems(List<Object[]> orderBatch, List<List<Object[]>> itemsPerOrder) {
+        transactionTemplate.executeWithoutResult(status -> {
+            flushOrders(orderBatch);
+            List<Long> newOrderIds = findOrderIdsByOrderNumbers(orderBatch);
+            if (newOrderIds.size() != itemsPerOrder.size()) {
+                throw new IllegalStateException("Inserted orders and buffered items are out of sync");
+            }
 
-        List<Object[]> itemBatch = new ArrayList<>(5_000);
-        for (int i = 0; i < newOrderIds.size(); i++) {
-            Long orderId = newOrderIds.get(i);
-            List<Object[]> items = itemsPerOrder.get(i);
-            ...
-        }
+            List<Object[]> itemBatch = new ArrayList<>(5_000);
+            for (int i = 0; i < newOrderIds.size(); i++) {
+                Long orderId = newOrderIds.get(i);
+                List<Object[]> items = itemsPerOrder.get(i);
+                ...
+            }
+        });
     }

추가로 flushOrderItems()에서 예외를 강제로 발생시켜 전체 배치가 rollback되는지, 외부 주문 insert가 동시에 발생해도 주문-상품 매핑이 바뀌지 않는지를 검증하는 테스트를 추가하는 편이 좋다.

🤖 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/seeder/OrderSeeder.java`
around lines 128 - 168, The current flushOrdersAndItems flow is non-atomic and
uses a fragile MAX(id) window to map new orders to items; change
flushOrdersAndItems to run the whole "flushOrders -> lookup inserted orders ->
flushOrderItems" sequence inside a single transaction (use `@Transactional` on
flushOrdersAndItems or programmatic TransactionTemplate) and stop using
lastIdBefore/MAX(id); instead query the just-inserted rows by their batch-unique
key (orderNumber) produced in the batch (use orderNumber to fetch orders and
their IDs after flushOrders returns), verify the returned newOrder list size
equals itemsPerOrder.size() and throw an exception if it does not, then map
itemsPerOrder to the fetched order IDs deterministically and call
flushOrderItems; keep references to flushOrders, flushOrderItems, itemsPerOrder,
and orderNumber to locate the changes.

@iohyeon iohyeon changed the title Volume 5 - 임나현 [Volume 5] 인덱스 설계,Redis 캐시 조회 최적화 - 임나현 Mar 12, 2026
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

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

21-23: 현재 인덱스 설계는 커서 predicate 형태와 함께 실행 계획을 고정 검증하는 편이 안전하다.

ProductRepositoryImpl.cursorCondition()(sortKey < ?) OR (sortKey = ? AND id < ?) 형태인데, MySQL이 이를 단일 range scan으로 최적화하지 못하면 Line 21-23 인덱스가 있어도 cursor 이후 페이지에서 leaf scan과 filesort가 늘 수 있다. 운영에서는 첫 페이지보다 후속 페이지 p95가 흔들리는 형태로 드러난다. 가능하면 row constructor 비교로 predicate를 정규화하는 방안까지 검토하고, 최소한 EXPLAIN ANALYZE 기준으로 정렬 4종 × brand 필터 유무에서 key, rows examined, Using filesort를 회귀 테스트에 포함하는 것이 좋다.

🤖 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/ProductEntity.java`
around lines 21 - 23, The current cursor predicate emitted by
ProductRepositoryImpl.cursorCondition() uses "(sortKey < ?) OR (sortKey = ? AND
id < ?)" which can prevent MySQL from performing a single range scan even though
ProductEntity defines indexes idx_products_latest, idx_products_price and
idx_products_likes; change the predicate generation to use a row-constructor
comparison like "(sortKey, id) < (?, ?)" (or the equivalent normalized SQL) so
it can map to the composite index range scan, ensure the composite index column
order and nullability in ProductEntity matches the sort + id ordering, and add
regression tests that run EXPLAIN ANALYZE for each of the four sort orders ×
brand-filter on the repository methods asserting the chosen key, rows_examined
and absence of "Using filesort" to detect regressions.
🤖 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/infrastructure/address/UserAddressEntity.java`:
- Around line 17-19: The `@Index` on UserAddressEntity
(idx_user_addresses_user_created) only updates Hibernate metadata and doesn't
guarantee runtime creation; add an explicit migration that runs "CREATE INDEX
idx_user_addresses_user_created ON user_addresses (user_id, created_at);" in
your DB migration tool (e.g., Flyway/Liquibase) and include a rollback DROP
INDEX as appropriate, then add a CI/integration check that queries
information_schema.statistics and runs EXPLAIN on the target queries to ensure
the index is present and used; apply the same pattern for OrderEntity,
IssuedCouponEntity, and ProductEntity indexes referenced in the review so schema
changes are managed by migrations rather than relying solely on `@Index`.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java`:
- Around line 20-25: ProductEntity's `@Index` annotations are only metadata and
won't change the production schema (ddl-auto: none), so add a Flyway migration
that creates the new composite indexes shown in the entity (e.g., match the
columns for idx_products_latest, idx_products_price, idx_products_likes,
idx_products_brand) and drops the old indexes to realize the 6→4 reduction;
after deploying, run SHOW INDEX FROM products to confirm the physical indexes
and run EXPLAIN on the key queries (LATEST, PRICE_ASC, LIKES_DESC) that use the
cursor predicate (createdAt/id OR-style predicate) to ensure the new composite
indexes are being used—if EXPLAIN shows full scans, consider adjusting index
column order or rewriting the cursor predicate to a range-friendly form so the
composite index (created_at DESC, id DESC, status, deleted_at) can be used
efficiently.

---

Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java`:
- Around line 21-23: The current cursor predicate emitted by
ProductRepositoryImpl.cursorCondition() uses "(sortKey < ?) OR (sortKey = ? AND
id < ?)" which can prevent MySQL from performing a single range scan even though
ProductEntity defines indexes idx_products_latest, idx_products_price and
idx_products_likes; change the predicate generation to use a row-constructor
comparison like "(sortKey, id) < (?, ?)" (or the equivalent normalized SQL) so
it can map to the composite index range scan, ensure the composite index column
order and nullability in ProductEntity matches the sort + id ordering, and add
regression tests that run EXPLAIN ANALYZE for each of the four sort orders ×
brand-filter on the repository methods asserting the chosen key, rows_examined
and absence of "Using filesort" to detect regressions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 02b3e43a-4c70-4819-a2bd-de28735ac7f7

📥 Commits

Reviewing files that changed from the base of the PR and between 02f07f1 and b95558b.

📒 Files selected for processing (4)
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/address/UserAddressEntity.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponEntity.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java

Comment on lines +17 to +19
@Table(name = "user_addresses", indexes = {
@Index(name = "idx_user_addresses_user_created", columnList = "user_id, created_at")
})
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

운영 스키마에 실제 인덱스 생성이 보장되지 않는다

이 변경은 엔티티 메타데이터만 추가하므로, 운영에서 Hibernate DDL 생성을 사용하지 않으면 idx_user_addresses_user_created는 실제로 생성되지 않는다. 그러면 배포 후에도 user_addresses 조회가 풀스캔으로 남아 성능 개선이 적용되지 않고, 로컬/테스트와 운영의 실행 계획이 달라져 장애 분석도 어려워진다.

수정안으로는 @Index를 문서화 용도로 유지하더라도, 배포용 마이그레이션에 CREATE INDEX idx_user_addresses_user_created ON user_addresses (user_id, created_at);를 명시해 스키마 버전으로 관리해야 한다. 추가 테스트로는 MySQL 통합 테스트나 배포 전 검증 단계에서 information_schema.statistics로 인덱스 존재를 확인하고, 해당 조회 쿼리에 EXPLAIN을 걸어 실제로 이 인덱스를 타는지 검증하는 편이 안전하다. 같은 패턴이 apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderEntity.java:25-28, apps/commerce-api/src/main/java/com/loopers/infrastructure/coupon/IssuedCouponEntity.java:20-23, apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java:19-25에도 보이므로 함께 정리하는 편이 좋다.

🤖 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/address/UserAddressEntity.java`
around lines 17 - 19, The `@Index` on UserAddressEntity
(idx_user_addresses_user_created) only updates Hibernate metadata and doesn't
guarantee runtime creation; add an explicit migration that runs "CREATE INDEX
idx_user_addresses_user_created ON user_addresses (user_id, created_at);" in
your DB migration tool (e.g., Flyway/Liquibase) and include a rollback DROP
INDEX as appropriate, then add a CI/integration check that queries
information_schema.statistics and runs EXPLAIN on the target queries to ensure
the index is present and used; apply the same pattern for OrderEntity,
IssuedCouponEntity, and ProductEntity indexes referenced in the review so schema
changes are managed by migrations rather than relying solely on `@Index`.

Comment on lines +20 to +25
@Table(name = "products", indexes = {
@Index(name = "idx_products_latest", columnList = "created_at DESC, id DESC, status, deleted_at"),
@Index(name = "idx_products_price", columnList = "base_price ASC, id ASC, status, deleted_at"),
@Index(name = "idx_products_likes", columnList = "like_count DESC, id DESC, status, deleted_at"),
@Index(name = "idx_products_brand", columnList = "brand_id, status, deleted_at")
})
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== schema management config =="
rg -n --glob '*.{yml,yaml,properties}' 'ddl-auto|hibernate\.hbm2ddl|flyway|liquibase'

echo
echo "== product index migrations =="
rg -n --glob '*.{sql,java,kt,xml,yml,yaml}' 'idx_products_|create index|drop index|products'

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

Length of output: 13653


🏁 Script executed:

find . -type d \( -name db -o -name migration* -o -name flyway -o -name liquibase \) 2>/dev/null | head -20

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

Length of output: 67


🏁 Script executed:

fd -e sql | head -30

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

Length of output: 67


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java | head -150

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

Length of output: 6278


🏁 Script executed:

cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java | tail -n +150

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

Length of output: 3972


@Index 메타데이터만으로는 운영 DB의 인덱스가 실제로 변경되지 않는다.

운영 환경이 ddl-auto: none 설정이므로 Line 20-25의 @Index 어노테이션은 엔티티 메타데이터일 뿐 스키마 변경을 트리거하지 않는다. Flyway/Liquibase 마이그레이션 파일이 없어서 기존 인덱스는 그대로 유지되며, PR에서 설명한 6→4 축소 효과는 배포 후 반영되지 않는다. 결과적으로 쓰기 오버헤드와 메모리 점유가 계속된다.

추가로, 커서 조건이 (createdAt < cursor) OR (createdAt = cursor AND id < cursor) 형태의 OR 기반 술어식을 사용하는데, 복합 인덱스 (created_at DESC, id DESC, status, deleted_at)는 이 OR 조건을 효율적으로 처리하지 못한다. MySQL 옵티마이저가 인덱스 레인지 스캔 대신 필터링을 통한 전체 스캔을 선택할 가능성이 높다.

조치:

  1. 신규 인덱스 생성과 구 인덱스 삭제를 명시한 Flyway 마이그레이션 파일 추가
  2. 배포 후 SHOW INDEX FROM products로 최종 인덱스 구조 검증
  3. 주요 쿼리(LATEST, PRICE_ASC, LIKES_DESC)별 EXPLAIN 실행 계획 확인하여 커서 조건이 인덱스를 실제로 활용하는지 검증
🤖 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/ProductEntity.java`
around lines 20 - 25, ProductEntity's `@Index` annotations are only metadata and
won't change the production schema (ddl-auto: none), so add a Flyway migration
that creates the new composite indexes shown in the entity (e.g., match the
columns for idx_products_latest, idx_products_price, idx_products_likes,
idx_products_brand) and drops the old indexes to realize the 6→4 reduction;
after deploying, run SHOW INDEX FROM products to confirm the physical indexes
and run EXPLAIN on the key queries (LATEST, PRICE_ASC, LIKES_DESC) that use the
cursor predicate (createdAt/id OR-style predicate) to ensure the new composite
indexes are being used—if EXPLAIN shows full scans, consider adjusting index
column order or rewriting the cursor predicate to a range-friendly form so the
composite index (created_at DESC, id DESC, status, deleted_at) can be used
efficiently.

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.

🧹 Nitpick comments (3)
apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserSeeder.java (3)

39-60: 배치 청킹(chunking) 미적용 - 다른 seeder와 불일치

OrderSeederBATCH_SIZE = 2_000으로 청킹하여 메모리 사용량을 제어한다. UserSeeder는 5,000건을 단일 배치로 처리하며, 현재 규모에서는 문제없으나 USER_COUNT 증가 시 JDBC 드라이버의 배치 버퍼 메모리가 급증할 수 있다. 일관된 패턴 적용을 권장한다.

추가 테스트: USER_COUNT를 50,000 이상으로 늘려 메모리 사용량과 GC 영향을 프로파일링해볼 것.

🤖 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/seeder/UserSeeder.java`
around lines 39 - 60, UserSeeder currently calls jdbcTemplate.batchUpdate with
USER_COUNT all at once, risking large JDBC batch memory usage; modify UserSeeder
to chunk inserts into batches (reuse the existing BatchPreparedStatementSetter
logic) by iterating over USER_COUNT in windows of a configurable BATCH_SIZE
(match OrderSeeder's BATCH_SIZE = 2_000 or introduce the same constant),
invoking jdbcTemplate.batchUpdate for each chunk and passing an adjusted
BatchPreparedStatementSetter that maps the chunk index to the actual user index;
ensure getBatchSize() returns the current chunk size and use USER_COUNT and the
new BATCH_SIZE to control the loop.

37-37: BCryptPasswordEncoder를 static final 필드로 추출 권장

seed() 호출 시마다 BCryptPasswordEncoder 인스턴스를 생성한다. 현재는 seed()가 한 번만 호출되므로 성능 영향은 미미하나, encoder는 thread-safe하고 재사용 가능하므로 static 필드로 추출하면 다른 seeder 패턴과 일관성이 높아진다.

♻️ 수정안
+private static final BCryptPasswordEncoder PASSWORD_ENCODER = new BCryptPasswordEncoder();
+
 void seed() {
     log.info("[UserSeeder] {}명 생성 시작", USER_COUNT);
     long start = System.currentTimeMillis();

     ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Seoul"));
-    String encodedPassword = new BCryptPasswordEncoder().encode(SEEDING_RAW_PASSWORD);
+    String encodedPassword = PASSWORD_ENCODER.encode(SEEDING_RAW_PASSWORD);
🤖 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/seeder/UserSeeder.java`
at line 37, Extract the BCryptPasswordEncoder instantiation into a reusable
static final field on UserSeeder (e.g., private static final
BCryptPasswordEncoder ENCODER = new BCryptPasswordEncoder()) and replace the
inline new BCryptPasswordEncoder() call in seed() where encodedPassword is
computed so the method uses ENCODER.encode(SEEDING_RAW_PASSWORD); keep field
name ENCODER and update any imports if necessary.

20-21: USER_COUNTSEEDING_RAW_PASSWORD 상수의 접근 제어 검토 필요

USER_COUNTOrderSeeder, PointAccountSeeder 등에서 참조하므로 package-private이 적절하다. 그러나 SEEDING_RAW_PASSWORD는 현재 이 클래스 내부에서만 사용되므로 private으로 변경하여 노출 범위를 최소화하는 것이 안전하다. 운영 환경에서 seeder가 포함된 빌드가 배포될 경우, 패키지 내 다른 클래스에서 원문 비밀번호에 접근할 수 있다.

🔒 수정안
 static final int USER_COUNT = 5_000;
-static final String SEEDING_RAW_PASSWORD = "Hx7!mK2@";
+private static final String SEEDING_RAW_PASSWORD = "Hx7!mK2@";
🤖 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/seeder/UserSeeder.java`
around lines 20 - 21, Keep USER_COUNT package-private (no access modifier) so
other seeders like OrderSeeder and PointAccountSeeder can reference it, and
change SEEDING_RAW_PASSWORD to private (private static final) to limit its
visibility to UserSeeder only; locate the declarations of USER_COUNT and
SEEDING_RAW_PASSWORD in UserSeeder and adjust the access modifier for
SEEDING_RAW_PASSWORD while leaving USER_COUNT unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserSeeder.java`:
- Around line 39-60: UserSeeder currently calls jdbcTemplate.batchUpdate with
USER_COUNT all at once, risking large JDBC batch memory usage; modify UserSeeder
to chunk inserts into batches (reuse the existing BatchPreparedStatementSetter
logic) by iterating over USER_COUNT in windows of a configurable BATCH_SIZE
(match OrderSeeder's BATCH_SIZE = 2_000 or introduce the same constant),
invoking jdbcTemplate.batchUpdate for each chunk and passing an adjusted
BatchPreparedStatementSetter that maps the chunk index to the actual user index;
ensure getBatchSize() returns the current chunk size and use USER_COUNT and the
new BATCH_SIZE to control the loop.
- Line 37: Extract the BCryptPasswordEncoder instantiation into a reusable
static final field on UserSeeder (e.g., private static final
BCryptPasswordEncoder ENCODER = new BCryptPasswordEncoder()) and replace the
inline new BCryptPasswordEncoder() call in seed() where encodedPassword is
computed so the method uses ENCODER.encode(SEEDING_RAW_PASSWORD); keep field
name ENCODER and update any imports if necessary.
- Around line 20-21: Keep USER_COUNT package-private (no access modifier) so
other seeders like OrderSeeder and PointAccountSeeder can reference it, and
change SEEDING_RAW_PASSWORD to private (private static final) to limit its
visibility to UserSeeder only; locate the declarations of USER_COUNT and
SEEDING_RAW_PASSWORD in UserSeeder and adjust the access modifier for
SEEDING_RAW_PASSWORD while leaving USER_COUNT unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cb13bf4e-08ac-4937-8bf2-270bae244813

📥 Commits

Reviewing files that changed from the base of the PR and between b95558b and 5ef3650.

📒 Files selected for processing (1)
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/seeder/UserSeeder.java

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant