Skip to content

[volume 5] 인덱스 설계 및 캐시를 통한 읽기 성능 최적화#198

Open
najang wants to merge 5 commits intoLoopers-dev-lab:najangfrom
najang:volume-5
Open

[volume 5] 인덱스 설계 및 캐시를 통한 읽기 성능 최적화#198
najang wants to merge 5 commits intoLoopers-dev-lab:najangfrom
najang:volume-5

Conversation

@najang
Copy link

@najang najang commented Mar 12, 2026

📌 Summary

  • 배경: 상품 목록/상세 조회 시 인덱스 없이 전체 테이블 스캔 + filesort가 발생해 조회 지연이 심각했음
  • 목표: 인덱스 추가로 DB 조회 성능 개선 + Cache-Aside 패턴 적용으로 반복 조회 시 Redis에서 응답
  • 결과: 인덱스 적용으로 조회 시간 약 87~490배 개선, 캐시 적용으로 반복 요청 시 DB 부하 제거

🧭 Context & Decision

문제 정의

  • 현재 동작/제약: products, likes, orders 테이블에 정렬/필터 기준 컬럼에 인덱스가 없어 조회마다 전체 스캔 발생 (약 9.9만~10만 건)
  • 문제(또는 리스크): 데이터가 증가할수록 조회 성능이 선형적으로 저하되며, 트래픽 집중 시 DB 부하가 심화됨
  • 성공 기준(완료 정의): 인덱스 적용 후 실행 계획에서 filesort 제거 및 실제 읽은 rows 수 감소, 캐시 적용 후 E2E 테스트 통과

선택지와 결정

  • 고려한 대안:
    • A: deleted_at IS NULL 조건에 인덱스 추가 — soft-delete 비율이 5%로 낮아 선택도가 좋지 않아 효과 미미
    • B: 정렬 기준 컬럼(like_count, created_at, price)에 단일/복합 인덱스 추가 — 정렬 비용 제거 및 LIMIT 조기 종료 가능
  • 최종 결정: 정렬 기준 컬럼 인덱스 채택 (단일 6개 + 복합 2개). 캐시는 목록(TTL 5분)과 상세(TTL 1분)를 분리해 적용
  • 트레이드오프: 캐시 적재 후 수정/삭제 시 Eviction 처리 필요. 상세 캐시의 isLiked는 사용자별 데이터이므로 캐시에서 제외하고 오버레이
  • 추후 개선 여지(있다면): 상세 캐시 TTL 조정, 목록 캐시의 2페이지 이상 캐시 전략 검토

인덱스 적용 현황

인덱스 설계문서

테이블 인덱스명 컬럼 대상 쿼리 개선 효과
products idx_like_count like_count DESC 전체 좋아요 순 목록 76.4ms → 0.478ms (약 160배)
products idx_brand_like_count brand_id, like_count DESC 브랜드별 좋아요 순 목록 56.9ms → 0.611ms (약 93배)
products idx_created_at created_at DESC 전체 최신 순 목록 90.2ms → 0.184ms (약 490배)
products idx_brand_created_at brand_id, created_at DESC 브랜드별 최신 순 목록 70ms → 0.805ms (약 87배)
products idx_price price ASC 전체 가격 순 목록 74.7ms → 0.369ms (약 202배)
products idx_brand_price brand_id, price ASC 브랜드별 가격 순 목록 67.1ms → 0.36ms (약 186배)
likes idx_user_created_at user_id, created_at DESC 사용자별 좋아요 목록 44.3ms → 0.666ms (약 67배)
orders idx_user_created_at user_id, created_at DESC 사용자별 주문 목록 53.2ms → 0.352ms (약 151배)

모든 케이스에서 full scan + filesort 제거, actual rows 약 99% 이상 감소

캐시 적용 현황

대상 캐시 키 패턴 TTL 쓰기 전략 Eviction 전략
상품 목록 (1페이지) product:list:{brandId or all}:{sort}:0:{size} 5분 Cache-Aside 수정/삭제 시 SCAN 패턴 삭제
상품 상세 product:detail:{productId} 1분 Cache-Aside 수정/삭제 시 exact key 삭제

isLiked는 사용자별 데이터이므로 캐시에 포함하지 않고, 히트 시 별도 조회 후 오버레이

좋아요 정렬 개선

이미 이전에 개발 완료됨

좋아요 순 정렬은 Product.likeCount 컬럼을 기준으로 함. 이 값의 정합성은 LikeApplicationService가 보장함.

처리 흐름

  • 좋아요 등록: productRepository.findByIdWithLock(productId)(PESSIMISTIC_WRITE) → 중복 없으면 likeRepository.save() + product.increaseLikes()
  • 좋아요 취소: productRepository.findByIdWithLock(productId)(PESSIMISTIC_WRITE) → 존재하면 likeRepository.delete() + product.decreaseLikes()

Like 저장/삭제와 likeCount 변경이 동일 트랜잭션 내에서 처리되므로 두 값은 항상 일치함.

동시성 전략

항목 내용
락 방식 Pessimistic Write Lock (PESSIMISTIC_WRITE)
원자성 Like 저장/삭제 + likeCount 변경이 하나의 트랜잭션
멱등성 이미 좋아요 상태면 중복 처리 없이 반환
검증 LikeCount.decrease() 에서 0 이하 방지 (CoreException)

LikeCount@Embeddable 불변 VO로, increase()/decrease() 호출 시 새 인스턴스를 반환하며 Product@Embedded로 매핑되어 있음.

🏗️ Design Overview

변경 범위

  • 영향 받는 모듈/도메인: commerce-api — Product, Like, Order 도메인
  • 신규 추가:
    • products 테이블 인덱스 6개 (idx_like_count, idx_brand_like_count, idx_created_at, idx_brand_created_at, idx_price, idx_brand_price)
    • likes 테이블 인덱스 1개 (idx_user_created_at)
    • orders 테이블 인덱스 1개 (idx_user_created_at)
    • ProductFacade.findById() Cache-Aside 로직
    • ProductInfo.withIsLiked() 헬퍼 메서드
  • 제거/대체: 없음

주요 컴포넌트 책임

  • Product / Like / Order: @Table 인덱스 선언으로 DDL 자동 반영
  • ProductFacade: Cache-Aside 조회(목록·상세), 수정/삭제 시 캐시 Eviction 처리
  • ProductInfo: withIsLiked(Boolean) — 캐시 히트 시 사용자별 좋아요 여부 오버레이

🔁 Flow Diagram

Main Flow — 상품 상세 조회 (Cache-Aside)

sequenceDiagram
  autonumber
  participant Client
  participant ProductFacade
  participant Redis
  participant DB
  participant LikeService

  Client->>ProductFacade: findById(productId, userId)
  ProductFacade->>Redis: GET product:detail:{productId}

  alt 캐시 히트
    Redis-->>ProductFacade: ProductInfo (isLiked=null)
    ProductFacade->>LikeService: isLiked(userId, productId)
    LikeService-->>ProductFacade: Boolean
    ProductFacade-->>Client: ProductInfo.withIsLiked(isLiked)
  else 캐시 미스
    Redis-->>ProductFacade: null
    ProductFacade->>DB: findById(productId)
    DB-->>ProductFacade: Product
    ProductFacade->>Redis: SET product:detail:{productId} TTL=1min (isLiked=null)
    ProductFacade->>LikeService: isLiked(userId, productId)
    LikeService-->>ProductFacade: Boolean
    ProductFacade-->>Client: ProductInfo.withIsLiked(isLiked)
  end
Loading

Eviction Flow — 상품 수정/삭제

sequenceDiagram
  autonumber
  participant Client
  participant ProductFacade
  participant DB
  participant Redis

  Client->>ProductFacade: update / delete
  ProductFacade->>DB: update / delete
  ProductFacade->>Redis: DEL product:list:* (SCAN)
  ProductFacade->>Redis: DEL product:detail:{productId}
  ProductFacade-->>Client: result
Loading

변경 목적

products, likes, orders 테이블의 정렬/필터 컬럼에 인덱스를 추가하고 Cache-Aside 패턴을 적용하여 DB 조회 성능을 개선하고, 반복 조회 시 Redis에서 응답하여 DB 부하를 제거합니다.

핵심 변경점

  • DB 인덱스 추가: Product 6개(like_count 단일·brand_id복합, created_at 단일·brand_id복합, price 단일·brand_id복합), Like/Order 각 1개(user_id+created_at DESC)
  • Cache-Aside 구현: 상품 상세 조회는 product:detail:{productId} 키(TTL 1분), 목록 조회는 product:list:{brandId|all}:{sort}:0:{size} 키(TTL 5분, 첫 페이지만 캐시)
  • isLiked 오버레이 방식: 캐시에는 isLiked=null 상태로 저장, 조회 시 사용자별 isLiked를 별도 조회해 ProductInfo.withIsLiked()로 오버레이
  • 캐시 무효화: 목록은 SCAN 기반 패턴 삭제(product:list:all:, product:list:{brandId}:), 상세는 정확 키 삭제
  • 기본 페이지 크기 변경: 10 → 20으로 업데이트(캐시 키 설계 베이스라인)

리스크/주의사항

  • 좋아요 정합성: PR 목표에서 "Like 저장/삭제와 product.likeCount 변경을 동일 트랜잭션으로 처리"라고 명시했으나, 코드 리뷰 결과 LikeApplicationService.isLiked() 호출이 트랜잭션 범위 밖에서 별도로 일어나는지 확인 필요
  • 마스터/슬레이브 Redis 구분: PESSIMISTIC_WRITE 락을 포함한 쓰기는 masterRedisTemplate을 사용하고 있으므로 Redis 이중화 구성의 동기화 정책 검증 필요

테스트/검증 방법

E2E 테스트(ProductV1ApiE2ETest)에서 캐시 히트/미스(redisTemplate.hasKey/keys), 목록 캐시 페이지별 미적용(page>0 캐시 미저장), 어드민 수정/삭제 시 캐시 무효화(SCAN 기반·정확 키 삭제) 동작 검증

✍️ 리뷰포인트

이번 인덱스 최적화 과정에서 deleted_at IS NULL 조건이 있는 쿼리들이 있었는데, 현재 테스트 데이터에서는 soft-delete 비율을 약 5% 정도로 낮게 설정했습니다. 따라서 복합 인덱스 설계 시 deleted_at 컬럼은 인덱스에 포함하지 않고, 대신 정렬이나 주요 필터 조건 컬럼만 포함하는 방식으로 설계했습니다.

다만 실무에서는 서비스가 운영되면서 soft-delete 데이터가 계속 누적될 수 있고, 초기에는 낮던 비율이 점점 높아질 수도 있을 것 같다는 생각이 들었습니다. 실무에서는 다음과 같은 부분을 어떻게 판단하시는지 궁금합니다.

  1. soft-delete 컬럼(deleted_at 등)은 보통 복합 인덱스에 포함시키는 편인지
  2. 아니면 초기 데이터 분포를 기준으로 설계한 뒤, 데이터 비율이 바뀌면 인덱스를 재설계하는 방식으로 운영하는지
  3. 또는 soft-delete 컬럼은 일반적으로 인덱스에 포함하지 않는 것이 더 권장되는지

멘토님께서는 실무에서 soft-delete 컬럼을 인덱스에 포함할지 여부를 어떤 기준으로 판단하시는지 궁금합니다.

najang and others added 5 commits March 12, 2026 23:12
- products: like_count, created_at, price 단일 인덱스 및 brand_id 복합 인덱스 6개 추가
- likes: user_id, created_at DESC 복합 인덱스 추가
- orders: user_id, created_at DESC 복합 인덱스 추가
- 전체 테이블 스캔 + filesort 제거로 조회 시간 약 87~490배 개선

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

coderabbitai bot commented Mar 12, 2026

📝 Walkthrough

Walkthrough

Redis 기반의 캐싱을 도입하여 상품 조회에 캐시-어사이드 패턴과 사용자별 isLiked 오버레이를 구현하였으며, 데이터베이스 인덱스를 추가하고 기본 페이지 크기를 조정하였다.

Changes

Cohort / File(s) Summary
캐시 인프라
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java, apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java, apps/commerce-api/src/main/java/com/loopers/application/product/ProductListCacheEntry.java
RedisTemplate과 ObjectMapper를 주입하여 캐시-어사이드 조회를 구현하고, findById(Long, Long) 오버로드 메서드로 사용자별 isLiked 오버레이를 지원한다. ProductInfo에 withIsLiked 빌더 메서드를 추가하여 불변성을 유지하며, ProductListCacheEntry 레코드로 페이지네이션 데이터를 캐싱한다.
데이터베이스 인덱스
apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java, apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java, apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
Like와 Order 엔티티에 user_id와 created_at DESC 복합 인덱스를 추가하고, Product 엔티티에 like_count, brand별 정렬, created_at, price 관련 총 6개의 인덱스를 추가하여 조회 성능을 최적화한다.
API 및 제어
apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java
기본 페이지 크기를 10에서 20으로 증가시켜 캐시 키 설계 기준선을 조정한다.
테스트
apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java
Redis 연동 캐시 통합 테스트를 추가하여 캐시 재입력, 캐시-화이트-리딩, 캐시 무효화 흐름과 어드민 작업 후 데이터 갱신을 검증한다.

Sequence Diagram

sequenceDiagram
    participant Client
    participant Controller as ProductV1Controller
    participant Facade as ProductFacade
    participant Redis
    participant Database as Product DB
    participant UserContext as User Context

    Client->>Controller: GET /products/{id}?userId=X
    Controller->>Facade: findById(productId, userId)
    Facade->>Redis: GET product:{id}
    alt Cache Hit
        Redis-->>Facade: ProductInfo (cached)
    else Cache Miss
        Facade->>Database: SELECT * FROM products WHERE id=?
        Database-->>Facade: ProductInfo
        Facade->>Redis: SET product:{id} = ProductInfo
    end
    Facade->>UserContext: Get user's isLiked status
    Facade->>Facade: productInfo.withIsLiked(userLiked)
    Facade-->>Controller: ProductInfo (with user-specific overlay)
    Controller-->>Client: ApiResponse<ProductInfo>
Loading

Estimated Code Review Effort

🎯 3 (Moderate) | ⏱️ ~28 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목은 인덱스 설계 및 캐시 적용을 통한 읽기 성능 최적화라는 변경의 핵심을 명확히 반영하고 있다.
Description check ✅ Passed PR 설명은 요구된 템플릿 구조(📌 Summary, 🧭 Context & Decision, 🏗️ Design Overview, 🔁 Flow Diagram)를 모두 포함하고 있으며, 인덱스 성능 개선 근거, 캐시 전략, 동시성 보장 메커니즘을 상세히 기술하고 있다.

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

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

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

❤️ Share

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

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 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/application/product/ProductFacade.java`:
- Around line 94-100: The current ProductFacade.findAll caches any page=0
response using the raw pageable size, which allows infinite cache keys; change
the caching logic to only cache when pageable.getPageNumber()==0 AND
pageable.getPageSize() matches an allowed/normalized size (e.g., a single fixed
CACHE_PAGE_SIZE constant or limited to a MAX_CACHE_SIZE), otherwise bypass cache
and call productService.findAll(...). Update buildCacheKey to use the normalized
cache size (or a sentinel like "uncached" when not caching) so keys cannot
explode, and add tests calling findAll with size=10/20/1000 to assert only the
allowed size produces a Redis key and payload.
- Around line 122-125: The create method in ProductFacade currently calls
brandService.findById(...) and productService.create(...) but does not
invalidate the product list cache, so newly created products may not appear
until cache TTL expires; after creating the product (the return value of
productService.create called in ProductFacade.create), call
evictProductListCache(created.brandId()) to evict both the global "latest" and
brand-specific list caches before returning ProductInfo, and add a test that
warms the list cache, calls the admin create API, then fetches the list to
assert the new product appears immediately.
- Around line 80-86: After likeService.like()/unlike() completes in
LikeFacade.like()/unlike(), invalidate the product caches using the returned
Product.brandId: remove the exact key "product:detail:{productId}" and delete
pattern keys "product:list:all:*" and "product:list:{brandId}:*" so ProductInfo
and ProductListCacheEntry likeCount and likes_desc sorting are not stale;
perform the invalidation after the transaction-returned Product is obtained (use
product.getBrandId()) and ensure deletion uses masterRedisTemplate (or
equivalent) to evict cached list and detail entries. Also add a test that loads
the product detail and list caches, calls like() and unlike(), and asserts API
responses reflect updated likeCount and list ordering immediately.

In `@apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java`:
- Around line 18-20: 현재 엔티티의 `@Index` 어노테이션(Like.java의 `@Table` indexes)은 운영에서
ddl-auto: none 설정 때문에 DDL로 반영되지 않으므로, 엔티티 주석에 의존하지 말고 Flyway 또는 Liquibase
마이그레이션을 만들어 likes, orders, products 테이블에 대해 user_id + created_at DESC 인덱스를 명시적으로
추가하고 배포 스크립트에 포함시켜라; 마이그레이션 적용 후 information_schema.statistics 또는 SHOW INDEX를
사용해 각 테이블에 인덱스가 존재하는지 검증하도록 테스트를 추가하라.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b6461258-7231-4b3c-bea1-3d8f0b91c456

📥 Commits

Reviewing files that changed from the base of the PR and between b1071ed and f923933.

⛔ Files ignored due to path filters (1)
  • docs/design/index-optimization.md is excluded by !**/*.md and included by **
📒 Files selected for processing (8)
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductListCacheEntry.java
  • apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java
  • apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
  • apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java
  • apps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java

Comment on lines +28 to +31
// 읽기 전용 조회 (Replica Preferred) - 캐시 히트 확인에 사용
private final RedisTemplate<String, String> redisTemplate;
// 쓰기 전용 (Master) - 캐시 저장 및 삭제에 사용
private final RedisTemplate<String, String> masterRedisTemplate;
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

Replica 읽기와 Master 쓰기를 분리하면 무효화 직후 stale cache를 다시 읽을 수 있다.

운영에서는 Master에서 키를 삭제하거나 갱신해도 Replica 복제가 늦으면 바로 다음 조회가 이전 값을 다시 읽을 수 있다. 그러면 이 PR이 의도한 update/delete 후 즉시 일관성이 깨지고, 단일 Redis로 도는 테스트에서는 이 문제가 드러나지 않는다. 수정안은 최소한 캐시 무효화 직후 정확성이 필요한 목록/상세 조회는 같은 노드에서 읽고 쓰도록 통일하는 것이다. 추가 테스트로는 읽기/쓰기 Redis를 분리한 환경에서 수정·삭제 직후 GET이 stale 응답을 내지 않는지 검증하면 된다.

Comment on lines +80 to +86
ProductInfo info = ProductInfo.from(product);
try {
masterRedisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(info), Duration.ofMinutes(1));
} catch (JsonProcessingException ignored) {
}
Boolean isLiked = userId != null ? likeService.isLiked(userId, productId) : null;
return ProductInfo.from(product, isLiked);
return info.withIsLiked(isLiked);
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 "[1] Like write service candidates"
fd 'Like.*Service\.java' apps/commerce-api/src/main/java -x sed -n '1,260p' {}

echo
echo "[2] Cache invalidation usages"
rg -n -C3 'product:list:|product:detail:|evictProductListCache|masterRedisTemplate\.delete' apps/commerce-api/src/main/java

echo
echo "[3] Like count mutation call sites"
rg -n -C3 'increaseLikes\(|decreaseLikes\(|isLiked\(' apps/commerce-api/src/main/java

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

Length of output: 17448


🏁 Script executed:

# Verify the controller/API layer to understand the call chain
fd 'Like.*Controller\.java' apps/commerce-api/src/main/java -x cat

# Check if there's a LikeFacade
fd 'LikeFacade\.java' apps/commerce-api/src/main/java -x cat

# Check imports in LikeApplicationService to see dependencies
grep -n "import\|package" apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java | head -20

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

Length of output: 4808


🏁 Script executed:

# Check if ProductFacade is accessible or if we need to expose evictProductListCache as public
grep -n "class ProductFacade\|public ProductInfo\|private void evictProductListCache" apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java | head -10

# Check the full signature of evictProductListCache
sed -n '159,164p' apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java

# Check if Product has brandId accessible
grep -n "brandId\|getBrandId" apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java

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

Length of output: 985


좋아요 쓰기 경로에서 상품 캐시를 무효화하지 않아 데이터 불일치 발생 가능하다.

LikeApplicationService.like()/unlike()에서 product.increaseLikes()/decreaseLikes()를 호출하고 저장하지만, 캐시 무효화가 없다. ProductInfoProductListCacheEntry에 포함된 likeCountlikes_desc 정렬이 최대 1분간 stale해진다.

수정안: LikeFacade.like()/unlike() 메서드에서 likeService.like/unlike() 트랜잭션 완료 후 반환된 ProductbrandId를 이용해 다음을 함께 무효화한다:

  • product:detail:{productId} exact key 삭제
  • product:list:all:*product:list:{brandId}:* 패턴 삭제

추가 테스트: 상품 상세 캐시 적재 후 like/unlike 수행하고, API 응답에서 likeCount와 목록의 likes_desc 정렬 순서가 즉시 갱신되는지 검증한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java`
around lines 80 - 86, After likeService.like()/unlike() completes in
LikeFacade.like()/unlike(), invalidate the product caches using the returned
Product.brandId: remove the exact key "product:detail:{productId}" and delete
pattern keys "product:list:all:*" and "product:list:{brandId}:*" so ProductInfo
and ProductListCacheEntry likeCount and likes_desc sorting are not stale;
perform the invalidation after the transaction-returned Product is obtained (use
product.getBrandId()) and ensure deletion uses masterRedisTemplate (or
equivalent) to evict cached list and detail entries. Also add a test that loads
the product detail and list caches, calls like() and unlike(), and asserts API
responses reflect updated likeCount and list ordering immediately.

Comment on lines 94 to +100
public Page<ProductInfo> findAll(Long brandId, Pageable pageable) {
return productService.findAll(brandId, pageable).map(ProductInfo::from);
// 첫 페이지가 아닌 경우 캐시 미적용
if (pageable.getPageNumber() != 0) {
return productService.findAll(brandId, pageable).map(ProductInfo::from);
}

String cacheKey = buildCacheKey(brandId, pageable);
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

page=0의 임의 size를 전부 캐시하면 공개 API 하나로 캐시 키가 무한히 늘어난다.

지금 구현은 첫 페이지면 size 값과 무관하게 캐시하고, 키에도 pageSize를 그대로 넣는다. 운영에서는 ?page=0&size=1..N 요청만으로 Redis 키 수와 payload 크기가 끝없이 늘어나서 메모리 압박과 낮은 적중률로 바로 이어진다. 수정안은 캐시 대상 size를 고정값 하나로 제한하거나, 비정상적으로 큰 size는 캐시를 우회하도록 막는 것이다. 추가 테스트로는 size=10/20/1000 요청 후 허용한 size만 캐시 키가 생성되는지 검증하면 된다.

수정 예시
+    private static final int CACHEABLE_PAGE_SIZE = 20;
+
     public Page<ProductInfo> findAll(Long brandId, Pageable pageable) {
-        // 첫 페이지가 아닌 경우 캐시 미적용
-        if (pageable.getPageNumber() != 0) {
+        // 첫 페이지 + 표준 페이지 크기만 캐시
+        if (pageable.getPageNumber() != 0 || pageable.getPageSize() != CACHEABLE_PAGE_SIZE) {
             return productService.findAll(brandId, pageable).map(ProductInfo::from);
         }

Also applies to: 190-193

🤖 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 94 - 100, The current ProductFacade.findAll caches any page=0
response using the raw pageable size, which allows infinite cache keys; change
the caching logic to only cache when pageable.getPageNumber()==0 AND
pageable.getPageSize() matches an allowed/normalized size (e.g., a single fixed
CACHE_PAGE_SIZE constant or limited to a MAX_CACHE_SIZE), otherwise bypass cache
and call productService.findAll(...). Update buildCacheKey to use the normalized
cache size (or a sentinel like "uncached" when not caching) so keys cannot
explode, and add tests calling findAll with size=10/20/1000 to assert only the
allowed size produces a Redis key and payload.

Comment on lines 122 to 125
public ProductInfo create(Long brandId, String name, String description, int price, int stock, SellingStatus sellingStatus) {
brandService.findById(brandId);
return ProductInfo.from(productService.create(brandId, name, description, price, stock, sellingStatus));
}
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

상품 생성 후 목록 캐시를 비우지 않아 새 상품이 최대 5분간 보이지 않는다.

목록 캐시는 첫 페이지를 5분 동안 유지하는데, create 경로는 현재 캐시 무효화를 하지 않는다. 그래서 latest 전체 목록과 브랜드별 목록이 이미 warm 상태면 새 상품 생성 직후에도 사용자는 이전 결과를 계속 받게 된다. 수정안은 생성 직후 evictProductListCache(created.brandId())를 호출하는 것이다. 추가 테스트로는 목록 캐시를 먼저 적재한 뒤 관리자 상품 생성 API를 호출하고, 다음 조회에서 새 상품이 즉시 보이는지 검증하면 된다.

수정 예시
     public ProductInfo create(Long brandId, String name, String description, int price, int stock, SellingStatus sellingStatus) {
         brandService.findById(brandId);
-        return ProductInfo.from(productService.create(brandId, name, description, price, stock, sellingStatus));
+        ProductInfo info = ProductInfo.from(productService.create(brandId, name, description, price, stock, sellingStatus));
+        evictProductListCache(info.brandId());
+        return info;
     }
🤖 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 122 - 125, The create method in ProductFacade currently calls
brandService.findById(...) and productService.create(...) but does not
invalidate the product list cache, so newly created products may not appear
until cache TTL expires; after creating the product (the return value of
productService.create called in ProductFacade.create), call
evictProductListCache(created.brandId()) to evict both the global "latest" and
brand-specific list caches before returning ProductInfo, and add a test that
warms the list cache, calls the admin create API, then fetches the list to
assert the new product appears immediately.

Comment on lines +18 to +20
@Table(name = "likes", indexes = {
@Index(name = "idx_user_created_at", columnList = "user_id, created_at DESC")
})
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

운영 스키마에는 이 인덱스가 생성되지 않는다.

운영에서는 modules/jpa/src/main/resources/jpa.yml 기본값이 ddl-auto: none이라 이 @Index 선언이 실제 DDL로 반영되지 않는다. 지금 상태로는 local/test만 빨라지고 dev/qa/prd는 기존 스키마를 그대로 써서 likes뿐 아니라 같은 방식으로 추가한 orders, products 인덱스도 전부 누락된다. 수정안은 엔티티 어노테이션에 기대지 말고 Flyway/Liquibase나 운영 DDL migration으로 세 테이블 인덱스를 명시적으로 추가하는 것이다. 추가 테스트로는 마이그레이션 적용 후 information_schema.statistics 또는 SHOW INDEX로 각 인덱스 존재를 검증하면 된다.

🤖 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/like/Like.java` around
lines 18 - 20, 현재 엔티티의 `@Index` 어노테이션(Like.java의 `@Table` indexes)은 운영에서 ddl-auto:
none 설정 때문에 DDL로 반영되지 않으므로, 엔티티 주석에 의존하지 말고 Flyway 또는 Liquibase 마이그레이션을 만들어
likes, orders, products 테이블에 대해 user_id + created_at DESC 인덱스를 명시적으로 추가하고 배포
스크립트에 포함시켜라; 마이그레이션 적용 후 information_schema.statistics 또는 SHOW INDEX를 사용해 각 테이블에
인덱스가 존재하는지 검증하도록 테스트를 추가하라.

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