Skip to content

[volume - 5] 인덱스 추가 및 캐시 설계 - 김윤선#217

Open
kimyam1008 wants to merge 4 commits intoLoopers-dev-lab:kimyam1008from
kimyam1008:feature/round5-read-optimization
Open

[volume - 5] 인덱스 추가 및 캐시 설계 - 김윤선#217
kimyam1008 wants to merge 4 commits intoLoopers-dev-lab:kimyam1008from
kimyam1008:feature/round5-read-optimization

Conversation

@kimyam1008
Copy link

@kimyam1008 kimyam1008 commented Mar 13, 2026

📌 Summary

  • 배경: 상품 10만 건 환경에서 브랜드별/전체 목록 조회 시 Full Scan + filesort 발생으로 응답 지연 및 동시 트래픽 증가 시 DB 커넥션 병목 발생
  • 목표: 인덱스로 쿼리 경로를 최적화하고, 캐시로 DB 요청 자체를 차단하여 조회 성능 개선
  • 결과: 인덱스 적용 후 브랜드별 목록 평균 응답시간 최대 67% 감소, 캐시 적용 후 전 구간 응답시간 90%+ 감소 및 TPS 13~14배 증가

🧭 Context & Decision

문제 정의

  • 현재 동작/제약: 상품 목록 조회 시 인덱스 없이 Full Scan + filesort 수행, 동시 사용자 200명 기준 평균 응답시간 1,348ms
  • 문제(또는 리스크): 트래픽 증가 시 DB 커넥션 풀 고갈로 장애 가능성, 전체 목록은 인덱스만으로 해결 불가
  • 성공 기준(완료 정의): 동시 사용자 200명 기준 평균 응답시간 500ms 이하, 오류율 0% 유지

선택지와 결정

인덱스 설계

  • 고려한 대안:
    • A: brand_id 단독 인덱스 → 필터는 되지만 like_count 정렬 filesort 여전히 발생
    • B: (brand_id, like_count DESC) 복합 인덱스 → 필터 + 정렬 모두 인덱스로 처리
  • 최종 결정: B 채택, 추가로 (brand_id, created_at DESC), (brand_id, price ASC) 인덱스 추가
  • 트레이드오프: 인덱스 3개 유지로 INSERT/UPDATE 시 쓰기 부하 소폭 증가
  • 추후 개선 여지: 전체 목록(brand_id 조건 없음)은 인덱스 효과 제한적 → 캐시로 커버

캐시 전략

  • 고려한 대안:
    • A: 좋아요 변경 시 목록 캐시도 evict → 구현 복잡도 높음 (목록 키가 page/sort 조합이라 특정 상품 포함 목록만 골라서 evict 불가)
    • B: 목록 likeCount는 TTL 허용 → 좋아요 수는 재고처럼 비즈니스 임팩트 없어 stale 허용 가능
  • 최종 결정: B 채택, 단건은 변경 시 즉시 evict / 목록은 TTL(1분)로 관리
  • 트레이드오프: 목록 likeCount 최대 1분 stale 허용
  • 추후 개선 여지: Cache Stampede 대응 전략 미적용, 캐시 워밍업 미적용

🏗️ Design Overview

변경 범위

  • 영향 받는 모듈/도메인: commerce-api - product, like 도메인
  • 신규 추가: ProductCacheStore (infrastructure 레이어)
  • 제거/대체: 없음

주요 컴포넌트 책임

  • Product: @Table 에 복합 인덱스 3개 정의
  • ProductCacheStore: Redis 기반 단건/목록 캐시 read/write/evict, Miss/장애 시 Optional.empty() 반환으로 DB fallback 트리거
  • ProductFacade: 조회 시 Cache-Aside 패턴 적용, 변경/삭제 시 단건 캐시 evict
  • LikeFacade: 좋아요 증가/감소 시 단건 캐시 evict

🔁 Flow Diagram

Main Flow (캐시 Hit)

sequenceDiagram
  autonumber
  participant Client
  participant ProductFacade
  participant ProductCacheStore
  participant DB

  Client->>ProductFacade: 상품 조회 요청
  ProductFacade->>ProductCacheStore: getProduct(productId)
  ProductCacheStore-->>ProductFacade: Hit (캐시 데이터 반환)
  ProductFacade-->>Client: 응답
Loading

Fallback Flow (캐시 Miss / Redis 장애)

sequenceDiagram
  autonumber
  participant Client
  participant ProductFacade
  participant ProductCacheStore
  participant DB

  Client->>ProductFacade: 상품 조회 요청
  ProductFacade->>ProductCacheStore: getProduct(productId)
  ProductCacheStore-->>ProductFacade: Miss or 장애 → Optional.empty()
  ProductFacade->>DB: findById(productId)
  DB-->>ProductFacade: 상품 데이터
  ProductFacade->>ProductCacheStore: putProduct(productId, info)
  ProductFacade-->>Client: 응답
Loading

Evict Flow (좋아요 변경)

sequenceDiagram
  autonumber
  participant Client
  participant LikeFacade
  participant DB
  participant ProductCacheStore

  Client->>LikeFacade: 좋아요 요청
  LikeFacade->>DB: likeCount 업데이트
  DB-->>LikeFacade: 완료
  LikeFacade->>ProductCacheStore: evictProduct(productId)
Loading

변경 목적

10만 건 상품 환경에서 브랜드별/전체 목록 조회 시 Full Scan 및 filesort 발생으로 인한 성능 저하(평균 응답시간 1,348ms) 해결을 위해 DB 인덱스 추가 및 Redis 캐시 도입.

핵심 변경점

  • 인덱스 설계: Product 엔티티에 3개 복합 인덱스 추가 — (brand_id, like_count DESC), (brand_id, created_at DESC), (brand_id, price ASC)로 브랜드별 정렬 쿼리 최적화
  • 캐시 구현: ProductCacheStore(신규) 통해 Cache-Aside 패턴 적용 — 단건 1시간 TTL, 목록 1분 TTL로 구성하고 조회 miss 시 DB 폴백
  • 캐시 연동: ProductFacade의 getProduct/getProductsByBrand/getProducts와 LikeFacade의 like/unlike에서 캐시 조회 및 evict 구현
  • 변경 추적: update/delete/restore/increaseStock/decreaseStock 및 좋아요 변경 시 productCacheStore.evictProduct() 호출로 스테일 캐시 제거

리스크/주의사항

  • 전체 목록(brand_id 없음) 캐시: getProducts에서 brandId를 null로 전달하여 캐시하는데, 이것이 의도된 설계인지 확인 필요 (목록 캐시 key 충돌 우려)
  • 캐시 워밍업 미실행: 초기 요청 시 cache miss로 인한 응답 지연 가능
  • Cache Stampede 미대응: 동시 다중 miss 시 DB 연쇄 부하 가능성
  • 목록 캐시 부분 evict 불가: 특정 항목만 선택적으로 무효화할 수 없어 TTL 의존

테스트/검증 방법

  • ProductFacadeTest, LikeFacadeTest에서 mock ProductCacheStore 주입하여 단위 테스트 커버
  • LikeFacadeIntegrationTest에 unlike_concurrent_likeCount 추가로 동시 좋아요 취소 검증
  • 확인 질문: 테스트에서 실제 캐시 동작(evict, 조회)의 호출 여부를 검증하는 테스트가 있는지, 아니면 의존성 주입만 변경된 것인지?

- 단건/목록 조회 캐시 구현 (단건 TTL 1시간, 목록 TTL 1분)
- 단건/전체/브랜드별 목록 조회 캐시 적용, 변경/삭제 시 evict
- 좋아요 증가/감소 시 상품 단건 캐시 evict
- 목록 likeCount는 TTL 허용, 단건은 즉시 evict로 정합성 수준 분리
- 캐시 Miss/장애 시 DB fallback으로 서비스 정상 동작 보장
@coderabbitai
Copy link

coderabbitai bot commented Mar 13, 2026

📝 Walkthrough

Walkthrough

이 PR은 상품 및 좋아요 캐싱을 위해 Redis 기반 ProductCacheStore를 도입하고, LikeFacade와 ProductFacade에 캐시 무효화 로직을 추가하며, 데이터베이스 성능 최적화를 위해 Product 테이블에 복합 인덱스 3개를 추가한다.

Changes

Cohort / File(s) Summary
Product Caching Infrastructure
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java
Redis 기반 캐싱 컴포넌트 신규 추가: 상품 개별 조회(1시간 TTL)와 브랜드별 페이징 목록 조회(1분 TTL)를 지원하며, 캐시 미스 시 fallback 및 에러 로깅 처리 포함.
Like Operations Cache Eviction
apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java
좋아요/취소 작업 후 ProductCacheStore.evictProduct() 호출로 상품 캐시 무효화.
Product Operations Cache Integration
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
ProductFacade 전체 메서드에 캐시 레이어 통합: 조회는 캐시 우선(read-through), 수정/삭제/복원 시 캐시 eviction 수행.
Database Index Optimization
apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
Product 테이블에 3개 복합 인덱스 추가: (brand_id, like_count DESC), (brand_id, created_at DESC), (brand_id, price ASC).
Test Updates
apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java, apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java, apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java
ProductCacheStore 의존성 모킹 추가 및 동시성 unlike 테스트 신규 추가.

Sequence Diagram

sequenceDiagram
    participant Client
    participant ProductFacade
    participant ProductCacheStore
    participant ProductRepository
    participant Database

    Client->>ProductFacade: getProduct(productId)
    ProductFacade->>ProductCacheStore: getProduct(productId)
    alt Cache Hit
        ProductCacheStore-->>ProductFacade: Optional<ProductInfo>
    else Cache Miss
        ProductCacheStore-->>ProductFacade: Optional.empty()
        ProductFacade->>ProductRepository: findById(productId)
        ProductRepository->>Database: SELECT * FROM products
        Database-->>ProductRepository: ProductEntity
        ProductRepository-->>ProductFacade: ProductEntity
        ProductFacade->>ProductCacheStore: putProduct(productId, ProductInfo)
        ProductCacheStore-->>ProductFacade: cached
    end
    ProductFacade-->>Client: ProductInfo

    Client->>ProductFacade: updateProduct(productId, ...)
    ProductFacade->>ProductRepository: update(productId, ...)
    ProductRepository->>Database: UPDATE products SET ...
    Database-->>ProductRepository: success
    ProductRepository-->>ProductFacade: updated
    ProductFacade->>ProductCacheStore: evictProduct(productId)
    ProductCacheStore-->>ProductFacade: evicted
    ProductFacade-->>Client: success
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~30분

Possibly related PRs

  • PR #185: LikeFacade의 unlike 경로에 대한 코드 수준 변경으로 캐시 eviction 로직 추가와 동시에 적용되는 부분이 있다.
  • PR #188: 동일한 상품 캐싱 및 캐시 eviction 구현을 LikeFacade/ProductFacade에 적용하는 동일 코드 수준 변경이다.
  • PR #103: ProductCacheStore를 도입하고 LikeFacade와 ProductFacade의 동일 클래스에 캐시 eviction 호출을 삽입하는 관련 변경이다.

운영/성능 관점 검토사항

1. 캐시 일관성 전략 확인 필요

  • ProductCacheStore의 1시간 TTL(개별 상품)과 1분 TTL(페이징 목록)의 차이로 인한 일관성 문제 가능성이 있다. 예를 들어, 상품 정보 수정 직후 개별 캐시는 무효화되지만 목록 캐시는 1분간 유지될 수 있다. TTL 동기화 또는 계단식 eviction 메커니즘 추가를 검토하기 바란다.

2. 캐시 Warmup 전략 부재

  • 현재 구현은 read-through 방식으로 처음 요청 시에만 캐시가 채워진다. 트래픽 급증 시나리오에서 캐시 미스로 인한 DB 부하 증가 가능성이 있다. 필요시 애플리케이션 시작 시 자주 접근되는 상품 목록을 사전 로드하는 warmup 로직 추가를 검토하기 바란다.

3. Redis 연결 장애 처리 검증

  • ProductCacheStore의 에러 핸들링은 로깅과 fallback을 수행하나, 지속적인 Redis 연결 실패 시 응답 지연이 누적될 수 있다. circuit breaker 패턴 또는 timeout 설정 강화를 검토하기 바란다.

4. 데이터베이스 인덱스 생성 전 성능 검증

  • Product 테이블의 3개 복합 인덱스는 조회 성능을 향상시키지만, 쓰기 작업(insert/update/delete) 성능에 미치는 영향을 사전 테스트하기 바란다. 특히 brand_id로 필터링되는 대량 데이터 변경 시나리오에서 인덱스 유지 비용을 측정하고 필요시 인덱스 단순화를 고려하기 바란다.

5. 동시성 테스트 범위 확대

  • unlike_concurrent_likeCount 테스트는 취소 작업만 검증한다. 좋아요 추가와 취소가 동시에 발생하는 혼합 시나리오, 그리고 캐시 무효화 중 조회 요청이 오는 race condition 시나리오에 대한 테스트 추가를 권장한다.
🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% 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
  • Generate coding plan for human review comments

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

❤️ Share

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

Tip

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

Set the reviews.auto_title_instructions setting to generate a title for your PR based on the changes in the PR with custom instructions.

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java (1)

43-50: ⚠️ Potential issue | 🟡 Minor

LikeFacade 캐시 무효화 동작에 대한 단위 테스트 검증이 없다

운영 관점에서 좋아요/취소 후 캐시가 무효화되지 않아도 현재 테스트는 이를 잡아내지 못한다. 수정안으로 ProductCacheStore 목을 멤버로 두고 like/unlike 성공 경로에서 evictProduct(productId) 호출을 검증해야 한다. 추가 테스트로 중복 좋아요/좋아요 없음 취소 같은 no-op 경로에서는 evictProduct가 호출되지 않음을 함께 검증해야 한다.

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

In
`@apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java`
around lines 43 - 50, The tests for LikeFacade lack verification that
ProductCacheStore eviction occurs; replace the inline
mock(ProductCacheStore.class) with a member mock field (e.g., productCacheStore)
and inject it into the LikeFacade constructor used in LikeFacadeTest; then
add/modify tests to assert that on successful like/unlike flows the mock's
evictProduct(productId) is invoked (verify(evictProduct) called with the
expected productId after calling LikeFacade.like/unlike), and add tests for
no-op paths (duplicate like or removing non-existent like) to verify
evictProduct is NOT called (verifyNoInteractions or verify(..., never())).
Ensure tests exercise the actual service paths (using LikeService and repository
mocks) so the eviction verification is meaningful.
🤖 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/like/LikeFacade.java`:
- Line 85: productCacheStore.evictProduct is being called immediately (e.g., in
the methods where productCacheStore.evictProduct is invoked) which can cause
stale DB reads to be re-cached before the write transaction commits; change
those immediate evictions to run in an after-commit callback (use
TransactionSynchronizationManager.registerSynchronization or
TransactionSynchronization.afterCompletion / afterCommit equivalent) so eviction
executes only when the transaction successfully commits, and ensure evictions
are skipped on rollback; update the places currently calling
productCacheStore.evictProduct (the two occurrences) to register an afterCommit
task that calls productCacheStore.evictProduct(productId), and add integration
tests covering “concurrent read during pending commit re-caching” and
“transaction rollback should not delete cache” scenarios to verify correct
behavior.

In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java`:
- Around line 32-33: Right now cache invalidation is only in ProductFacade
(productCacheStore) so direct saves via OrderFacade, OrderPlacementTxService,
BrandFacade (calls to productRepository.save(...)) can leave stale single-item
caches; emit a ProductChangedEvent from every place that mutates a product
(replace direct productCacheStore.invalidate calls), implement a single
`@TransactionalEventListener`(phase = AFTER_COMMIT) listener that clears
productCacheStore for the given product IDs, and add integration tests covering
order creation/cancellation and brand deletion/restoration asserting getProduct
returns the updated value immediately after these flows.
- Around line 63-64: ProductFacade currently calls
productCacheStore.evictProduct immediately in multiple write methods, which can
cause stale-cache races before DB commit; replace those immediate evict calls
with a shared helper (e.g., evictAfterCommit or registerEvictAfterCommit) that
defers productCacheStore.evictProduct(productId) to run only after the
transaction successfully commits (use transaction synchronization/afterCommit
hook used in your stack) and call this helper from all affected write methods
(update, delete, restore, increase, decrease) instead of direct evictProduct
calls; also add tests for update/delete/restore/increase/decrease that simulate
concurrent reads during the pre-commit window to assert no long-lived stale
cache remains.

In `@apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java`:
- Around line 15-22: The `@Table` index annotations on Product (indexes
idx_products_brand_id_like_count, idx_products_brand_id_created_at,
idx_products_brand_id_price) are not applied when ddl-auto is disabled, so add
an explicit migration to create these indexes (e.g., Flyway/Liquibase SQL
migration with CREATE INDEX statements for the three names) and include a
deployment pipeline verification step that queries information_schema.statistics
to assert the three index names exist for the products table; update migration
files and CI/CD checks accordingly.

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java`:
- Around line 51-53: evictProduct currently calls
redisTemplate.delete(productKey(productId)) without handling exceptions, so
Redis failures will bubble up and rollback surrounding `@Transactional` writes;
change evictProduct to mirror
getProduct/putProduct/getProductList/putProductList by wrapping the delete in a
try-catch that catches RuntimeException (or Redis-specific exceptions), logs the
error with context (productId and key) and suppresses it (do not rethrow),
ensuring cache failures don’t fail the DB transaction; also add tests in
ProductFacade.update, increaseStock, and decreaseStock to simulate Redis failure
and assert the transaction still commits.

In
`@apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java`:
- Around line 42-47: The test must assert cache interactions to prevent
regressions: change the inline mock to a field-level mock ProductCacheStore and
inject it into the ProductFacade constructor, then add verifications for cache
hit/miss behavior for ProductFacade.getProduct, getProducts, and
getProductsByBrand (verify the cache read methods were called and that writes
occur on misses); also verify evictProduct is called for write paths that should
invalidate cache and verify evictProduct is NOT called for idempotent/no-op
update paths. Reference the ProductFacade constructor injection, the
ProductCacheStore mock, the getProduct/getProducts/getProductsByBrand methods,
and the evictProduct method when adding these verify assertions.

---

Outside diff comments:
In
`@apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java`:
- Around line 43-50: The tests for LikeFacade lack verification that
ProductCacheStore eviction occurs; replace the inline
mock(ProductCacheStore.class) with a member mock field (e.g., productCacheStore)
and inject it into the LikeFacade constructor used in LikeFacadeTest; then
add/modify tests to assert that on successful like/unlike flows the mock's
evictProduct(productId) is invoked (verify(evictProduct) called with the
expected productId after calling LikeFacade.like/unlike), and add tests for
no-op paths (duplicate like or removing non-existent like) to verify
evictProduct is NOT called (verifyNoInteractions or verify(..., never())).
Ensure tests exercise the actual service paths (using LikeService and repository
mocks) so the eviction verification is meaningful.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 673a66e2-b397-44c3-99b4-a1a743070cfe

📥 Commits

Reviewing files that changed from the base of the PR and between be98b93 and 6b60a8b.

⛔ Files ignored due to path filters (2)
  • CLAUDE.md is excluded by !**/*.md and included by **
  • docs/performance/load-test-report.md is excluded by !**/*.md and included by **
📒 Files selected for processing (7)
  • apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java
  • apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
  • apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
  • apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java
  • apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java
  • apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java

product.increaseLikeCount();
Like saved = likeRepository.save(result.like());
productRepository.save(product);
productCacheStore.evictProduct(productId);
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 85와 Line 115에서 캐시를 즉시 삭제하면, 커밋 전 동시 조회가 DB의 이전 값을 읽어 1시간 TTL로 다시 캐시할 수 있어 운영 중 데이터 신뢰도가 떨어진다. 수정안으로 캐시 무효화는 afterCommit 훅에서 실행해야 한다. 추가 테스트로 “쓰기 트랜잭션 커밋 지연 중 동시 조회” 시나리오와 “롤백 시 캐시 미삭제” 시나리오를 통합 테스트에 넣어야 한다.

수정 예시 (after-commit eviction)
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
@@
-        productCacheStore.evictProduct(productId);
+        evictProductAfterCommit(productId);
@@
-        productCacheStore.evictProduct(productId);
+        evictProductAfterCommit(productId);
     }
+
+    private void evictProductAfterCommit(Long productId) {
+        if (TransactionSynchronizationManager.isSynchronizationActive()) {
+            TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
+                `@Override`
+                public void afterCommit() {
+                    productCacheStore.evictProduct(productId);
+                }
+            });
+            return;
+        }
+        productCacheStore.evictProduct(productId);
+    }

Also applies to: 115-115

🤖 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/like/LikeFacade.java`
at line 85, productCacheStore.evictProduct is being called immediately (e.g., in
the methods where productCacheStore.evictProduct is invoked) which can cause
stale DB reads to be re-cached before the write transaction commits; change
those immediate evictions to run in an after-commit callback (use
TransactionSynchronizationManager.registerSynchronization or
TransactionSynchronization.afterCompletion / afterCommit equivalent) so eviction
executes only when the transaction successfully commits, and ensure evictions
are skipped on rollback; update the places currently calling
productCacheStore.evictProduct (the two occurrences) to register an afterCommit
task that calls productCacheStore.evictProduct(productId), and add integration
tests covering “concurrent read during pending commit re-caching” and
“transaction rollback should not delete cache” scenarios to verify correct
behavior.

Comment on lines +32 to 33
private final ProductCacheStore productCacheStore;

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

캐시 무효화가 ProductFacade 경로에만 묶여 있어 다른 쓰기 경로에서 정합성이 깨진다

운영 관점에서 현재 무효화는 이 파사드 경로에만 있어, OrderFacade(apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java Line 84-92), OrderPlacementTxService(Line 92-98), BrandFacade(Line 76-80, 94-100)처럼 productRepository.save(...)를 직접 호출하는 경로에서는 단건 캐시가 오래 남을 수 있다. 수정안으로 제품 변경 후행 이벤트(예: ProductChangedEvent)를 발행하고 단일 리스너에서 after-commit 무효화를 통합해야 한다. 추가 테스트로 주문 생성/취소, 브랜드 삭제/복구 후 getProduct가 즉시 최신 값을 반환하는 통합 테스트를 보강해야 한다.

🤖 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 32 - 33, Right now cache invalidation is only in ProductFacade
(productCacheStore) so direct saves via OrderFacade, OrderPlacementTxService,
BrandFacade (calls to productRepository.save(...)) can leave stale single-item
caches; emit a ProductChangedEvent from every place that mutates a product
(replace direct productCacheStore.invalidate calls), implement a single
`@TransactionalEventListener`(phase = AFTER_COMMIT) listener that clears
productCacheStore for the given product IDs, and add integration tests covering
order creation/cancellation and brand deletion/restoration asserting getProduct
returns the updated value immediately after these flows.

Comment on lines +63 to 64
productCacheStore.evictProduct(productId);
return ProductDto.ProductInfo.of(saved, brand.getName());
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 63, 132, 148, 158, 174의 즉시 삭제는 커밋 전 재캐시 경쟁 상태를 만들어 오래된 단건 캐시가 1시간 유지될 수 있다. 수정안으로 공통 헬퍼를 두고 afterCommit에서만 evictProduct를 실행해야 한다. 추가 테스트로 update/delete/restore/increase/decrease 각각에 대해 “커밋 전 동시 조회 시 오래된 캐시 고착이 없는지”를 검증해야 한다.

Also applies to: 132-133, 148-149, 158-159, 174-175

🤖 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 63 - 64, ProductFacade currently calls
productCacheStore.evictProduct immediately in multiple write methods, which can
cause stale-cache races before DB commit; replace those immediate evict calls
with a shared helper (e.g., evictAfterCommit or registerEvictAfterCommit) that
defers productCacheStore.evictProduct(productId) to run only after the
transaction successfully commits (use transaction synchronization/afterCommit
hook used in your stack) and call this helper from all affected write methods
(update, delete, restore, increase, decrease) instead of direct evictProduct
calls; also add tests for update/delete/restore/increase/decrease that simulate
concurrent reads during the pre-commit window to assert no long-lived stale
cache remains.

Comment on lines +15 to +22
@Table(
name = "products",
indexes = {
@Index(name = "idx_products_brand_id_like_count", columnList = "brand_id, like_count DESC"),
@Index(name = "idx_products_brand_id_created_at", columnList = "brand_id, created_at DESC"),
@Index(name = "idx_products_brand_id_price", columnList = "brand_id, price ASC")
}
)
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

인덱스 애노테이션만으로는 운영 스키마에 반영되지 않는다

운영 관점에서 ddl-auto: none 환경이면 Line 15-22의 인덱스 정의가 실제 DB에 생성되지 않아 성능 개선이 적용되지 않는다. 수정안으로 명시적 마이그레이션 SQL(Flyway/Liquibase/수동 DDL)을 추가해야 한다. 추가 테스트로 배포 파이프라인에서 information_schema.statistics 검증을 넣어 세 인덱스 존재를 자동 확인해야 한다.

운영 반영용 SQL 예시
CREATE INDEX idx_products_brand_id_like_count
    ON products (brand_id, like_count DESC);

CREATE INDEX idx_products_brand_id_created_at
    ON products (brand_id, created_at DESC);

CREATE INDEX idx_products_brand_id_price
    ON products (brand_id, price ASC);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java`
around lines 15 - 22, The `@Table` index annotations on Product (indexes
idx_products_brand_id_like_count, idx_products_brand_id_created_at,
idx_products_brand_id_price) are not applied when ddl-auto is disabled, so add
an explicit migration to create these indexes (e.g., Flyway/Liquibase SQL
migration with CREATE INDEX statements for the three names) and include a
deployment pipeline verification step that queries information_schema.statistics
to assert the three index names exist for the products table; update migration
files and CI/CD checks accordingly.

Comment on lines +51 to +53
public void evictProduct(Long productId) {
redisTemplate.delete(productKey(productId));
}
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
set -euo pipefail

# 1) evictProduct가 예외를 처리하지 않는지 확인
rg -n "public void evictProduct|redisTemplate.delete" apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java -C2

# 2) evictProduct가 트랜잭션 메서드에서 직접 호출되는지 확인
rg -n "(`@Transactional`|evictProduct\\()" apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java -C2
rg -n "(`@Transactional`|evictProduct\\()" apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java -C2

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

Length of output: 4218


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) View full ProductCacheStore.java to see all methods
cat apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java

# 2) Check if get/put methods have try/catch
grep -n "try\|catch\|public.*get\|public.*put" apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java || true

# 3) Search for other evict methods in the codebase
find apps/commerce-api -name "*.java" -type f | xargs grep -l "public.*evict" 2>/dev/null || true

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

Length of output: 4468


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Search for all evict methods in the codebase
find apps/commerce-api -name "*.java" -type f -exec grep -l "evict" {} \; 2>/dev/null

# 2) Check ProductService to see if there are other eviction patterns
rg -n "evict|clear\(\)" apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java -C2 || true

# 3) Check if there are transactional boundaries after evictProduct call
rg -B5 "evictProduct\(" apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java | head -30

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

Length of output: 1961


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify the exact line where evictProduct is called in a `@Transactional` context
# to confirm transaction boundary
rg -B10 "evictProduct\(productId\)" apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java | head -40

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

Length of output: 1986


Redis 삭제 예외 전파로 쓰기 트랜잭션이 실패할 수 있다

Line 51-53의 evictProduct() 메서드는 예외 처리가 없어 Redis 장애 시 예외가 그대로 전파된다. 호출 지점이 모두 @Transactional 메서드 내에 있고 productRepository.save() 이후에 실행되므로, Redis 연결 실패 시 캐시 삭제 예외가 전체 트랜잭션을 롤백시킨다. 캐시는 부가 기능이므로 인프라 장애가 핵심 쓰기 작업을 실패하게 하면 안 된다.

같은 클래스의 getProduct(), putProduct(), getProductList(), putProductList() 메서드는 모두 예외를 처리하므로 evictProduct()도 동일하게 수정해야 한다.

수정 예시
 public void evictProduct(Long productId) {
-    redisTemplate.delete(productKey(productId));
+    try {
+        redisTemplate.delete(productKey(productId));
+    } catch (Exception e) {
+        log.warn("캐시 삭제 실패 - product:{}", productId, e);
+    }
 }

추가로 ProductFacade.update(), increaseStock(), decreaseStock() 등 evictProduct()를 호출하는 @Transactional 메서드에서 Redis 장애 시에도 트랜잭션이 성공하는지 테스트해야 한다.

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

In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java`
around lines 51 - 53, evictProduct currently calls
redisTemplate.delete(productKey(productId)) without handling exceptions, so
Redis failures will bubble up and rollback surrounding `@Transactional` writes;
change evictProduct to mirror
getProduct/putProduct/getProductList/putProductList by wrapping the delete in a
try-catch that catches RuntimeException (or Redis-specific exceptions), logs the
error with context (productId and key) and suppresses it (do not rethrow),
ensuring cache failures don’t fail the DB transaction; also add tests in
ProductFacade.update, increaseStock, and decreaseStock to simulate Redis failure
and assert the transaction still commits.

Comment on lines 42 to 47
productFacade = new ProductFacade(
productRepository,
brandRepository,
new ProductService()
new ProductService(),
mock(ProductCacheStore.class)
);
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

캐시 연동 회귀를 막는 검증이 누락되어 있다

운영 관점에서 캐시 로직이 깨져도 현재 테스트는 통과할 수 있어 장애를 사전에 차단하기 어렵다. 수정안으로 ProductCacheStore를 필드 목으로 보관하고 getProduct/getProducts/getProductsByBrand의 hit/miss 및 write 메서드의 evictProduct 호출 여부를 verify로 검증해야 한다. 추가 테스트로 idempotent/no-op 경로에서 evictProduct가 호출되지 않는 케이스까지 포함해야 한다.

🤖 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/product/ProductFacadeTest.java`
around lines 42 - 47, The test must assert cache interactions to prevent
regressions: change the inline mock to a field-level mock ProductCacheStore and
inject it into the ProductFacade constructor, then add verifications for cache
hit/miss behavior for ProductFacade.getProduct, getProducts, and
getProductsByBrand (verify the cache read methods were called and that writes
occur on misses); also verify evictProduct is called for write paths that should
invalidate cache and verify evictProduct is NOT called for idempotent/no-op
update paths. Reference the ProductFacade constructor injection, the
ProductCacheStore mock, the getProduct/getProducts/getProductsByBrand methods,
and the evictProduct method when adding these verify assertions.

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