[Volume 5] 상품 목록 조회 성능, 좋아요 수 정렬 구조 개선 및 캐시 적용#191
[Volume 5] 상품 목록 조회 성능, 좋아요 수 정렬 구조 개선 및 캐시 적용#191YoHanKi wants to merge 4 commits intoLoopers-dev-lab:YoHanKifrom
Conversation
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>
📝 WalkthroughWalkthroughRedis 기반 캐싱 시스템과 커서 기반 페이지네이션을 도입하며, Like 트랜잭션 처리를 강화하고, Product 조회를 위한 복합 인덱스 및 QueryDSL 기반 정렬을 구현했다. Changes
Sequence Diagram(s)sequenceDiagram
participant Client
participant Controller as ProductV1Controller
participant Facade as ProductFacade
participant App as ProductApp
participant CacheStore as ProductCacheStore
participant Cache as Redis
participant DB as ProductRepository
Client->>Controller: GET /api/v1/products/{productId}
activate Controller
Controller->>Facade: getProduct(productId)
activate Facade
Facade->>App: getProduct(productId)
activate App
App->>CacheStore: get(productId)
activate CacheStore
CacheStore->>Cache: 조회
activate Cache
Cache-->>CacheStore: null (Cache MISS)
deactivate Cache
CacheStore-->>App: Optional.empty()
deactivate CacheStore
App->>DB: findById(productId)
activate DB
DB-->>App: ProductModel
deactivate DB
App->>CacheStore: put(productId, ProductInfo)
activate CacheStore
CacheStore->>Cache: SET with TTL(60s)
activate Cache
Cache-->>CacheStore: OK
deactivate Cache
deactivate CacheStore
App-->>Facade: ProductInfo
deactivate App
Facade-->>Controller: ProductResponse
deactivate Facade
Controller-->>Client: 200 OK (ProductResponse)
deactivate Controller
sequenceDiagram
participant Client
participant Controller as ProductV1Controller
participant Facade as ProductFacade
participant App as ProductApp
participant Encoder as CursorEncoder
participant QueryFactory as JPAQueryFactory
participant DB as ProductRepositoryImpl
participant Cache as Redis
Client->>Controller: GET /api/v1/products/cursor?brandId=nike&sort=latest&cursor=null&size=10
activate Controller
Controller->>Facade: getProductsByCursor(brandId, sort, cursor, size)
activate Facade
Facade->>App: getProductsByCursor(brandId, sort, cursor, size)
activate App
App->>DB: findProductsByCursor(refBrandId, sort, cursor, size)
activate DB
alt cursor is null
Note over DB: 첫 페이지 (커서 없음)
else cursor is present
DB->>Encoder: decode(cursor, ProductCursor.class)
activate Encoder
Encoder-->>DB: ProductCursor
deactivate Encoder
Note over DB: 커서 타입 검증
end
DB->>QueryFactory: QueryDSL 쿼리 구성 (sortBy 조건 + 커서 술어)
activate QueryFactory
QueryFactory->>DB: 결과 조회 (size + 1)
deactivate QueryFactory
DB->>DB: hasNext & nextCursor 계산
Note over DB: hasNext = 조회 건수 > size
alt hasNext is true
DB->>Encoder: encode(ProductCursor.from(last, sortBy))
activate Encoder
Encoder-->>DB: Base64 encoded cursor
deactivate Encoder
end
DB-->>App: CursorPageResult<ProductModel>
deactivate DB
App-->>Facade: CursorPageResult<ProductInfo>
deactivate App
Facade->>Cache: 선택적 캐싱 (products 캐시)
Facade-->>Controller: CursorPageResult<ProductResponse>
deactivate Facade
Controller-->>Client: 200 OK (CursorListResponse)
deactivate Controller
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75분 변경사항의 복잡도는 커서 기반 페이지네이션의 QueryDSL 술어 로직, 3가지 정렬 전략 구현, Redis 캐싱 일관성 보장(Like eviction), 600+ 라인의 테스트 코드 검증에서 비롯된다. 다양한 도메인(캐싱, 페이지네이션, 쿼리 최적화)에 걸친 heterogeneous 변경사항이며, 각 파일의 논리적 정확성과 인덱스 설계의 쿼리 성능 영향을 개별적으로 검토해야 한다. Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Pull request overview
상품 목록 조회의 Full Table Scan + filesort 문제를 줄이고, Redis 캐시 및 커서 기반(Keyset) 페이징을 도입해 읽기 성능/DB 부하를 개선하려는 PR입니다. 또한 QueryDSL 기반 정렬 전략(enum) 및 커서 인코딩/디코딩을 추가하여 목록 조회 확장성을 높입니다.
Changes:
- 상품 목록 조회를 QueryDSL 기반으로 전환하고, 정렬 조건별 인덱스/정렬 전략(enum) 적용
- RedisCacheManager(+ Page 직렬화 MixIn) 및 상품 상세 Cache-Aside용 RedisTemplate 스토어 도입
- 커서 기반 상품 목록 API(
/api/v1/products/cursor) 및 커서 인코더/정렬별 Keyset 조건 추가 + 관련 통합/E2E 테스트 및 분석 문서 추가
Reviewed changes
Copilot reviewed 37 out of 37 changed files in this pull request and generated 15 comments.
Show a summary per file
| File | Description |
|---|---|
| scripts/seed-products.sql | 성능 테스트용 대량 시드 데이터 생성 스크립트 추가 |
| scripts/create-product-indexes.sql | 상품 목록 정렬 패턴 대응 복합 인덱스 생성 스크립트 추가 |
| modules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.java | Redis Testcontainers 설정(시스템 프로퍼티 세팅) 조정 |
| modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java | 테스트 DB truncate 후 캐시 클리어 로직 추가 |
| docs/performance/sort-strategy-analysis.md | 좋아요 정렬 전략 비교 분석 문서 추가 |
| docs/performance/index-analysis.md | 인덱스 적용 전/후 EXPLAIN 분석 문서 추가 |
| docs/performance/cache-strategy-analysis.md | 캐시 전략 비교 분석 문서 추가 |
| apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java | 커서 기반 목록 API E2E 테스트 추가 |
| apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java | 커서 기반 Repository 조회 통합 테스트 추가 |
| apps/commerce-api/src/test/java/com/loopers/domain/like/SortStrategyComparisonTest.java | 비정규화 vs MV 실험/비교 통합 테스트 추가 |
| apps/commerce-api/src/test/java/com/loopers/domain/like/LikeCountSyncIntegrationTest.java | like_count 동기화 정합성 통합 테스트 추가 |
| apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java | QueryDSL Q-class 의존성 예외 규칙 추가 |
| apps/commerce-api/src/test/java/com/loopers/application/product/RedisCacheIntegrationTest.java | Redis 캐시 적재/TTL/fallback 시나리오 통합 테스트 추가 |
| apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheIntegrationTest.java | 상품 캐시 히트/미스/무효화 통합 테스트 추가 |
| apps/commerce-api/src/test/java/com/loopers/application/product/CacheStrategyComparisonTest.java | 캐시 전략 비교 실험 테스트 추가 |
| apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java | 커서 기반 목록 응답 DTO 추가 |
| apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java | /cursor 엔드포인트 추가 |
| apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java | 커서 기반 목록 API 스펙/문서 추가 |
| apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductSortCondition.java | QueryDSL 정렬 전략 enum 추가 |
| apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java | 목록 QueryDSL 전환 + 커서 페이징 조회 추가 |
| apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java | 기존 native 정렬 쿼리 제거 |
| apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCursorCondition.java | 정렬별 Keyset 조건/OrderSpecifier 전략 enum 추가 |
| apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCursor.java | 커서 payload VO 추가 |
| apps/commerce-api/src/main/java/com/loopers/infrastructure/common/cursor/CursorEncoder.java | Base64 URL-safe 커서 인코더/디코더 추가 |
| apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/PageRequestMixin.java | PageRequest 역직렬화 MixIn 추가 |
| apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/PageImplMixin.java | PageImpl 역직렬화 MixIn 추가 |
| apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java | RedisCacheManager/TTL/직렬화 설정 추가 |
| apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java | brandId → refBrandId 해석 메서드 분리 |
| apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java | 커서 기반 목록 조회 API 추가 |
| apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java | 정렬 조건별 복합 인덱스 어노테이션 추가 |
| apps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.java | 좋아요 추가/삭제에 @Transactional 적용 |
| apps/commerce-api/src/main/java/com/loopers/domain/common/cursor/CursorPageResult.java | 커서 페이징 공통 결과 VO 추가 |
| apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java | 커서 목록 facade 메서드 추가(브랜드 enrich 포함) |
| apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheStore.java | 상품 상세 Cache-Aside RedisTemplate 스토어 추가 |
| apps/commerce-api/src/main/java/com/loopers/application/product/ProductApp.java | 상품 상세 Cache-Aside + 목록 @Cacheable + 커서 목록 로직 추가 |
| apps/commerce-api/src/main/java/com/loopers/application/like/LikeApp.java | 좋아요 변경 시 product/products 캐시 Evict 추가 |
| CLAUDE.md | 개발 프로세스/의사결정 체크포인트 문서 보강 |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java
Show resolved
Hide resolved
apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheStore.java
Show resolved
Hide resolved
.../commerce-api/src/test/java/com/loopers/application/product/CacheStrategyComparisonTest.java
Show resolved
Hide resolved
modules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.java
Show resolved
Hide resolved
...ommerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java
Show resolved
Hide resolved
apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheStore.java
Show resolved
Hide resolved
| public ResponseEntity<ApiResponse<ProductV1Dto.CursorListResponse>> getProductsByCursor( | ||
| @RequestParam(required = false) String brandId, | ||
| @RequestParam(required = false, defaultValue = "latest") String sort, | ||
| @RequestParam(required = false) String cursor, | ||
| @RequestParam(defaultValue = "10") int size | ||
| ) { |
There was a problem hiding this comment.
apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java
Show resolved
Hide resolved
.../commerce-api/src/test/java/com/loopers/application/product/CacheStrategyComparisonTest.java
Show resolved
Hide resolved
There was a problem hiding this comment.
Actionable comments posted: 20
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
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/main/java/com/loopers/application/product/ProductApp.java (1)
29-33:⚠️ Potential issue | 🟠 Major생성 후 목록 캐시가 비워지지 않아 신규 상품이 TTL 동안 누락된다
getProducts()를 캐시했는데createProduct()는products캐시를 무효화하지 않아서, 이미 워밍된 목록이 최대 TTL 동안 새 상품을 누락한다. 운영 관점에서는 생성 직후 목록 정합성이 깨지므로, 생성도 수정/삭제와 동일하게products전체 invalidation에 포함해야 한다. 전체 목록과 브랜드 목록 캐시를 먼저 채운 뒤 상품 생성 직후 새 상품이 둘 다 보이는지 회귀 테스트를 추가해 달라.변경 예시
- `@Transactional` + `@CacheEvict`(value = "products", allEntries = true) + `@Transactional` public ProductInfo createProduct(String productId, String brandId, String productName, BigDecimal price, int stockQuantity) { ProductModel product = productService.createProduct(productId, brandId, productName, price, stockQuantity); return ProductInfo.from(product); }Also applies to: 64-68
🤖 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/ProductApp.java` around lines 29 - 33, The createProduct path in ProductApp (method createProduct) does not evict the cached product lists so a newly created product can be missing until TTL expiry; update createProduct to invalidate the same caches used by getProducts (e.g., the global "products" cache and the brand-specific cache such as "productsByBrand") immediately after successful creation (use the same cache eviction mechanism you use for update/delete—CacheEvict annotations or CacheManager/Cache#evict calls inside ProductApp.createProduct while preserving `@Transactional` semantics). Also add a regression test that pre-warms the global and brand caches (call getProducts and the brand-list getter), then calls createProduct and asserts the new product appears in both cached endpoints.
🟡 Minor comments (8)
scripts/seed-products.sql-130-156 (1)
130-156:⚠️ Potential issue | 🟡 Minor배가(doubling) 단계에서 트랜잭션 미사용으로 복구가 어렵다
7회 배가 과정 중 어느 한 단계에서 실패하면 부분적으로 데이터가 삽입된 상태로 남는다. 이 경우:
- 어느 라운드까지 완료되었는지 파악하기 어렵다.
- 재실행 시
product_id중복으로 전체 실패한다.TRUNCATE후 처음부터 재실행해야 하므로 시간이 낭비된다.운영/테스트 환경에서 스크립트 실행 실패 시 복구 전략을 명확히 해야 한다.
개선 방안
- 각 라운드 완료 후 진행 상황을 로그로 출력:
-- Round A 완료 후 SELECT CONCAT('Round A 완료: ', COUNT(*), '건') AS progress FROM products;
- 또는 라운드별 접두사 존재 여부 확인 후 스킵하는 멱등성 로직 추가:
-- Round A가 이미 존재하면 스킵 INSERT INTO products (...) SELECT CONCAT('A', product_id), ... FROM products WHERE product_id NOT LIKE 'A%' AND NOT EXISTS (SELECT 1 FROM products p2 WHERE p2.product_id = CONCAT('A', products.product_id));단, 위 방식은 성능이 저하되므로 진행 로그 출력 방식을 권장한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/seed-products.sql` around lines 130 - 156, The current repeated INSERT INTO products ... SELECT CONCAT('X', product_id) FROM products sequence can leave the table partially duplicated on failure; fix by making each duplication round atomic and idempotent: wrap each INSERT INTO products ... SELECT CONCAT('A'|...,' product_id) ... operation in a transaction (BEGIN/COMMIT/ROLLBACK) and add an idempotency filter referencing product_id (e.g., WHERE product_id NOT LIKE 'A%' AND NOT EXISTS(SELECT 1 FROM products p2 WHERE p2.product_id = CONCAT('A', products.product_id))) to skip already-duplicated rows, and after each successful COMMIT emit a progress check (SELECT COUNT(*) ... AS progress) so you can detect completed rounds and safely resume.scripts/seed-products.sql-111-112 (1)
111-112:⚠️ Potential issue | 🟡 Minor
created_at과updated_at관계가 주석 설명과 다를 수 있다주석(Line 88)에서 "
created_at이updated_at보다 항상 이전"이라고 명시했으나, 실제 계산 로직에서는 두 값이 동일한 경우가 발생한다.
n < 365인 모든 행:n % 730 = n,n % 365 = n→ 동일한 값- 예:
n = 100일 때created_at = updated_at = NOW() - 100 DAY테스트 데이터의 정합성 검증 시 혼란을 줄 수 있으므로 주석을 수정하거나,
created_at이 항상updated_at보다 이전이 되도록 로직을 조정해야 한다.수정안 (created_at이 항상 이전이 되도록)
- NOW() - INTERVAL (t.n % 730) DAY AS created_at, - NOW() - INTERVAL (t.n % 365) DAY AS updated_at, + NOW() - INTERVAL ((t.n % 365) + 30) DAY AS created_at, + NOW() - INTERVAL (t.n % 365) DAY AS updated_at,🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/seed-products.sql` around lines 111 - 112, The generated created_at can equal updated_at because both use t.n % 730 and t.n % 365 for many rows; modify the created_at expression so it is always strictly older than updated_at (for example, wrap the created_at calculation in a CASE that adds 1 day when (t.n % 730) = (t.n % 365)), updating the expression that computes created_at (referencing created_at and t.n in the seed-products.sql diff) so created_at < updated_at for every row.apps/commerce-api/src/test/java/com/loopers/domain/like/SortStrategyComparisonTest.java-267-275 (1)
267-275:⚠️ Potential issue | 🟡 Minor동시성 테스트에서 worker 예외를 삼키면 원인 분석이 막힌다
현재는 deadlock, timeout, unique constraint 예외가 나도 최종 count mismatch만 남고 stack trace가 사라진다. 운영 관점에서는 락 회귀가 생겼을 때 CI 실패 원인을 바로 특정하지 못해 대응 시간이 길어진다.
Future<?>를 모아get()으로 다시 던지거나ConcurrentLinkedQueue<Throwable>에 저장한 뒤 테스트 끝에서 비어 있음을 assert해야 한다. 추가 테스트로는 지금 시나리오 그대로 예외 컬렉션이 비어 있는지 검증해서 간헐 실패 시 cause가 바로 드러나도록 만드는 편이 좋다.🔧 제안 수정
+ Queue<Throwable> errors = new ConcurrentLinkedQueue<>(); for (int i = 0; i < threadCount; i++) { long memberId = i + 1L; executor.submit(() -> { ready.countDown(); try { start.await(); insertLikeSync(memberId, syncDbId, nikeDbId); insertLikeOnly(memberId + 100L, batchDbId); - } catch (Exception ignored) { + } catch (Exception e) { + errors.add(e); } finally { done.countDown(); } }); } ready.await(); start.countDown(); done.await(); executor.shutdown(); + assertThat(errors).isEmpty();As per coding guidelines, "예외 처리 시 cause를 보존"하고 "통합 테스트는 격리 수준, 플래키 가능성, 테스트 데이터 준비/정리 전략을 점검한다."
Also applies to: 545-553, 588-595
🤖 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/domain/like/SortStrategyComparisonTest.java` around lines 267 - 275, The worker threads currently swallow exceptions inside the executor.submit block, hiding causes; collect and rethrow them at test end by submitting Runnables that capture thrown Throwables into a ConcurrentLinkedQueue<Throwable> (or collect Futures and call get()) instead of ignoring exceptions, then after done.await() assert the queue is empty (or rethrow any collected Throwables) so failures from insertLikeSync, insertLikeOnly, or other operations surface; update the test code that uses executor, ready, start, done to either store Futures and call get() or push caught Throwables into a shared ConcurrentLinkedQueue<Throwable> and assert/throw at the end.apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java-59-71 (1)
59-71:⚠️ Potential issue | 🟡 Minor
resolveRefBrandId와getProducts에@Transactional(readOnly = true)가 누락되었다.두 메서드 모두 DB 조회를 수행하지만 트랜잭션 경계가 없다. 문제점:
- 읽기 전용 트랜잭션 최적화(Replica 라우팅, Flush 생략) 미적용
- 여러 조회가 동일 트랜잭션 컨텍스트 없이 실행되어 일관성 보장 불가
createProduct,updateProduct,deleteProduct에는 암묵적으로 상위 계층에서 트랜잭션이 관리될 수 있으나, 조회 메서드는 명시적 선언이 권장된다.🔧 트랜잭션 어노테이션 추가
+ `@Transactional`(readOnly = true) public Long resolveRefBrandId(String brandId) { if (brandId == null || brandId.isBlank()) { return null; } BrandModel brand = brandRepository.findByBrandId(new BrandId(brandId)) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 ID의 브랜드가 존재하지 않습니다.")); return brand.getId(); } + `@Transactional`(readOnly = true) public Page<ProductModel> getProducts(String brandId, String sortBy, Pageable pageable) { Long refBrandId = resolveRefBrandId(brandId); return productRepository.findProducts(refBrandId, sortBy, pageable); }🤖 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 59 - 71, Add read-only transaction boundaries to the ProductService read methods by annotating resolveRefBrandId and getProducts with `@Transactional`(readOnly = true) (or apply at the class level if you prefer to cover all read methods); import org.springframework.transaction.annotation.Transactional and ensure the annotations are placed on the ProductService methods so repository calls (brandRepository.findByBrandId and productRepository.findProducts) execute within a read-only transaction for replica routing and consistent read semantics.apps/commerce-api/src/main/java/com/loopers/infrastructure/common/cursor/CursorEncoder.java-23-25 (1)
23-25:⚠️ Potential issue | 🟡 Minor예외 발생 시 원인(cause)이 보존되지 않는다.
현재
CoreException클래스는Throwable cause를 받는 생성자가 없다. 따라서encode()메서드(23-25줄)와decode()메서드(33-35줄)에서 원본 예외e를 전달할 수 없으며, JSON 파싱 오류나 Base64 디코딩 오류 등 실제 실패 원인이 완전히 손실된다. 운영 환경에서 문제 발생 시 사용자 메시지만으로는 원인 파악이 불가능해진다.
CoreException을 다음과 같이 수정하여 cause를 지원하도록 한다:public CoreException(ErrorType errorType, String customMessage, Throwable cause) { super(customMessage != null ? customMessage : errorType.getMessage(), cause); this.errorType = errorType; this.customMessage = customMessage; }그 후
CursorEncoder에서 다음과 같이 원본 예외를 전달한다:catch (Exception e) { throw new CoreException(ErrorType.INTERNAL_ERROR, "커서 인코딩 중 오류가 발생했습니다.", e); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/common/cursor/CursorEncoder.java` around lines 23 - 25, CursorEncoder's encode/decode catch blocks discard the original exception, losing cause information; add a Throwable-accepting constructor to CoreException (e.g., CoreException(ErrorType, String, Throwable)) that calls super(message, cause) and preserves errorType/customMessage, then update CursorEncoder.encode and CursorEncoder.decode to throw the new CoreException passing the caught exception as the cause so JSON parse/Base64 errors are preserved for debugging.apps/commerce-api/src/test/java/com/loopers/application/product/CacheStrategyComparisonTest.java-411-429 (1)
411-429:⚠️ Potential issue | 🟡 MinorcrossStrategy_likeChange_evictsBothCaches 테스트가 실제 캐시 무효화를 검증하지 않음
likeApp = null로 선언 후 사용하지 않고,redisTemplate.delete()로 직접 키를 삭제한다. 이는 좋아요 변경 시 실제@CacheEvict동작을 검증하지 않으며, 테스트 이름과 실제 동작이 불일치한다. 실제LikeApp을 주입하여 좋아요 등록/해제 후 캐시 무효화를 검증하거나, 테스트 이름을 수정하라.// 현재: 수동 삭제로 실제 동작 검증 불가 redisTemplate.delete(PRODUCT_KEY); redisTemplate.delete("products::null:latest:0:10"); // 권장: 실제 좋아요 동작 후 캐시 상태 검증 // likeApp.registerLike("prod1", userId); // assertThat(redisTemplate.hasKey(PRODUCT_KEY)).isFalse();🤖 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/CacheStrategyComparisonTest.java` around lines 411 - 429, The test crossStrategy_likeChange_evictsBothCaches currently bypasses the real cache-eviction logic by setting likeApp = null and manually deleting keys with redisTemplate.delete; replace this with invoking the real like service so the `@CacheEvict` annotations run: inject or use the existing com.loopers.application.like.LikeApp instance (e.g., LikeApp likeApp) and call the appropriate method that registers/removes a like for "prod1" (e.g., likeApp.registerLike or likeApp.toggleLike) after the productApp.getProduct/productApp.getProducts calls, then assert redisTemplate.hasKey(PRODUCT_KEY) and the products key are false; alternatively, if invoking LikeApp is not possible, change the test name to reflect manual key deletion instead of validating eviction.apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCursorCondition.java-18-21 (1)
18-21:⚠️ Potential issue | 🟡 Minor커서 필드 null 접근 시 NPE 가능성
toCursorPredicate메서드들이c.updatedAt(),c.likeCount(),c.price(),c.id()등을 직접 호출한다.ProductCursor의 필드가 null일 경우NullPointerException이 발생하며, 사용자에게 500 에러가 노출된다. 방어적 검증을 추가하거나,ProductCursor생성 시점에 필수 필드 검증을 수행하라.Also applies to: 31-37, 47-52
🤖 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/ProductCursorCondition.java` around lines 18 - 21, The toCursorPredicate methods in ProductCursorCondition call ProductCursor accessors (e.g., c.updatedAt(), c.id(), c.likeCount(), c.price()) without null checks which can cause NPEs; add defensive validation at the start of each toCursorPredicate (or a shared private validator) to require non-null cursor fields (use Objects.requireNonNull or explicit checks) and throw a clear IllegalArgumentException when a required field is missing, e.g., validate c.updatedAt() and c.id() before building the predicate in toCursorPredicate(QProductModel p, ProductCursor c) and do the same for the other overloaded toCursorPredicate variants that use likeCount/price.apps/commerce-api/src/test/java/com/loopers/application/product/CacheStrategyComparisonTest.java-147-172 (1)
147-172:⚠️ Potential issue | 🟡 MinorStampede 테스트에서 스레드 내 예외 무시로 인한 위음성(false negative) 가능성
catch (Exception ignored)블록에서 모든 예외가 무시되므로, 스레드 내부에서 assertion 실패나 런타임 예외가 발생해도 테스트가 통과할 수 있다. 실제 stampede 상황에서 발생하는 오류를 감지하지 못한다.제안: 예외를 수집하여 검증
+ List<Exception> errors = new CopyOnWriteArrayList<>(); for (int i = 0; i < threadCount; i++) { new Thread(() -> { ready.countDown(); try { start.await(); results.add(productApp.getProduct("prod1")); - } catch (Exception ignored) { + } catch (Exception e) { + errors.add(e); } finally { done.countDown(); } }).start(); } ready.await(); start.countDown(); done.await(10, TimeUnit.SECONDS); + assertThat(errors).isEmpty(); // 결과 정확성: 모든 스레드 동일 값🤖 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/CacheStrategyComparisonTest.java` around lines 147 - 172, In CacheStrategyComparisonTest, stop ignoring exceptions inside the thread lambda; instead capture them into a thread-safe collection (e.g., ConcurrentLinkedQueue<Throwable> exceptions) from the catch block used around productApp.getProduct, and after done.await verify the collection is empty (or rethrow the first exception) so assertion failures or runtime errors in threads fail the test; reference the thread lambda that currently does catch (Exception ignored), the results collection, and productApp.getProduct to locate where to add exception collection and the post-wait check.
🧹 Nitpick comments (12)
apps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.java (1)
102-110: 예외 규칙 범위를 QueryDSL 저장소 구현체로 제한해야 한다.현재 predicate는
com.loopers.infrastructure전체와 도메인 패키지의 모든Q*타입을 한 번에 면제한다. 운영 관점에서는 아키텍처 회귀를 막아야 하는 테스트가 너무 넓게 통과해, QueryDSL과 무관한 인프라 컴포넌트가 도메인 쿼리 타입에 의존해도 놓치게 된다. 소스 범위를RepositoryImpl또는 QueryDSL 전용 패키지로 좁히고, 가능하면 타깃도 실제 생성 Q-type 범위로 한정하는 편이 안전하다. 추가로RepositoryImpl -> Q*는 허용되고Converter나 캐시 어댑터의Q*의존은 실패하는 케이스를 별도 ArchUnit 테스트로 넣어 회귀를 막아야 한다.As per coding guidelines "`**/*Test*.java`: 단위 테스트는 경계값/실패 케이스/예외 흐름을 포함하는지 점검한다."예외 범위를 좁히는 예시다
.ignoreDependency( DescribedPredicate.describe( - "Infrastructure classes using QueryDSL", - javaClass -> javaClass.getPackageName().startsWith("com.loopers.infrastructure") + "Infrastructure QueryDSL repository implementations", + javaClass -> javaClass.getPackageName().startsWith("com.loopers.infrastructure") + && javaClass.getSimpleName().endsWith("RepositoryImpl") ), DescribedPredicate.describe( "QueryDSL Q-classes in domain package", javaClass -> javaClass.getSimpleName().startsWith("Q") && javaClass.getPackageName().startsWith("com.loopers.domain") ) )🤖 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/architecture/LayeredArchitectureTest.java` around lines 102 - 110, 현재 LayeredArchitectureTest의 ignoreDependency/DescribedPredicate 조합이 com.loopers.infrastructure 전체와 도메인 패키지의 모든 Q* 타입을 면제해 범위가 너무 넓으니, 예외를 저장소 구현으로 한정하도록 수정하세요: LayeredArchitectureTest에서 ignoreDependency 호출 내 첫 번째 predicate는 인프라 전체가 아니라 저장소 구현 패키지 또는 구현 클래스 이름(예: 패키지명 com.loopers.infrastructure.repository.impl 또는 클래스명 끝이 RepositoryImpl인 클래스)으로 좁히고, 두 번째 DescribedPredicate는 도메인의 실제 생성 Q-타입 영역으로 제한(예: com.loopers.domain.querydsl 또는 simpleName.startsWith("Q") && packageName().startsWith("com.loopers.domain.querydsl"))하여 RepositoryImpl -> Q* 만 허용되게 하세요; 추가로 Converter나 캐시 어댑터 같은 다른 인프라 컴포넌트가 Q*에 의존하는 경우를 방지하기 위해 별도의 ArchUnit 테스트를 추가해 Converter/*CacheAdapter -> Q* 의존은 실패하도록 검증하세요.scripts/seed-products.sql (2)
178-182: 검증 쿼리에서deleted_at필터 누락검증 쿼리(Line 163, 165-176, 178-182)에서
deleted_at IS NULL조건 없이 전체 데이터를 조회한다. 시드 데이터에서는deleted_at = NULL로 삽입하므로 현재는 문제가 없으나, 향후 soft delete 테스트 데이터가 추가되면 검증 결과가 왜곡될 수 있다.수정안
-SELECT COUNT(*) AS total_products FROM products; +SELECT COUNT(*) AS total_products FROM products WHERE deleted_at IS NULL; SELECT b.brand_name, COUNT(p.id) AS product_count, ... FROM products p JOIN brands b ON b.id = p.ref_brand_id +WHERE p.deleted_at IS NULL GROUP BY b.id, b.brand_name🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/seed-products.sql` around lines 178 - 182, The validation query that computes like_range counts is missing a soft-delete filter; update each SELECT (the expressions producing '0~49', '50~999', '1000~4999', '5000~9999' which currently filter on like_count) to include "deleted_at IS NULL" in their WHERE clauses (e.g., change WHERE like_count < 50 to WHERE deleted_at IS NULL AND like_count < 50) and apply the same deleted_at IS NULL filter to the other similar verification queries referenced (the blocks around the like_range/like_count SELECTs) so soft-deleted rows are excluded.
4-7: 기존 데이터 존재 시 충돌 처리 전략 미흡주석에서 "products 데이터가 없는 상태(truncate 이후)에서 실행할 것"이라고 명시했으나, 실제로 데이터가 있는 상태에서 실행하면
product_id중복 에러가 발생한다. 스크립트 시작 부분에 안전장치를 추가하는 것이 운영 실수를 방지한다.수정안
-- ============================================================================= -- 실행 전 안전 확인 (products 테이블이 비어 있는지 확인) -- ============================================================================= SELECT IF( (SELECT COUNT(*) FROM products) > 0, (SELECT CONCAT('ERROR: products 테이블에 ', COUNT(*), '건의 데이터가 존재합니다. TRUNCATE 후 재실행하세요.') FROM products), 'OK: products 테이블이 비어 있습니다. 시드 삽입을 시작합니다.' ) AS pre_check; -- 아래 주석을 해제하면 기존 데이터를 강제로 삭제 (주의!) -- TRUNCATE TABLE products;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/seed-products.sql` around lines 4 - 7, Add a pre-run safety check at the top of scripts/seed-products.sql that verifies products is empty before inserting seeds: run a SELECT COUNT(*) FROM products and if the count > 0 return/raise an error message (and avoid executing the insert statements), otherwise proceed and print an OK message; optionally include a commented TRUNCATE TABLE products line for intentional forced resets. Ensure this guard is the very first statements so product_id uniqueness conflicts are prevented when the script (seed-products.sql) is executed.apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.java (2)
353-355: 커서를 쿼리 문자열에 직접 이어 붙이지 않는 편이 안전하다.cursor 포맷이 Base64나 JSON처럼 예약 문자를 포함하면
+,/,=가 URL 파싱 과정에서 깨져 정상 커서도 400으로 바뀔 수 있다. 이 클래스 전반의&cursor=결합은UriComponentsBuilder.queryParam(...)으로 바꿔 인코딩을 보장하라. 추가 테스트로+,/,=가 포함된 커서에서도 다음 페이지 조회가 통과하는지 검증하라.변경 예시
+import org.springframework.web.util.UriComponentsBuilder; + -ResponseEntity<ApiResponse<ProductV1Dto.CursorListResponse>> secondResponse = testRestTemplate.exchange( - ENDPOINT_PRODUCTS_CURSOR + "?sort=latest&size=10&cursor=" + nextCursor, +String url = UriComponentsBuilder.fromPath(ENDPOINT_PRODUCTS_CURSOR) + .queryParam("sort", "latest") + .queryParam("size", 10) + .queryParam("cursor", nextCursor) + .toUriString(); +ResponseEntity<ApiResponse<ProductV1Dto.CursorListResponse>> secondResponse = testRestTemplate.exchange( + url, HttpMethod.GET, null, cursorResponseType );Also applies to: 384-385, 418-419, 448-449, 498-499
🤖 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/interfaces/api/product/ProductV1ControllerE2ETest.java` around lines 353 - 355, In ProductV1ControllerE2ETest replace string-concatenated query URLs passed to testRestTemplate.exchange (using ENDPOINT_PRODUCTS_CURSOR + "?sort=...&cursor=" + nextCursor) with a properly encoded URI built via UriComponentsBuilder.queryParam(...) (e.g., set path to ENDPOINT_PRODUCTS_CURSOR and add sort, size, cursor via queryParam) so the cursor is URL-encoded; update all occurrences (around the exchanges at the referenced blocks) and add an extra E2E test case that uses a cursor containing reserved characters like "+", "/", "=" to assert the subsequent page request succeeds.
463-477: 에러 E2E는 상태 코드만이 아니라 표준 응답 바디까지 고정해야 한다.지금은 400만 맞으면
ApiControllerAdvice를 우회하거나 에러 포맷이 깨져도 테스트가 통과한다. 운영에서는 클라이언트가ApiResponse의 실패 메타데이터와 에러 구조에 의존하므로, 두 케이스 모두meta.result와 에러 코드/메시지까지 assert하는 편이 안전하다. 추가 테스트로 잘못된 커서와 정렬 불일치가 동일한 응답 스키마를 유지하는지 검증하라.Based on learnings,
commerce-api에서는CoreException을ApiControllerAdvice로 라우팅해 일관된 응답 포맷을 유지해야 한다.Also applies to: 480-503
🤖 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/interfaces/api/product/ProductV1ControllerE2ETest.java` around lines 463 - 477, Update the E2E tests (ProductV1ControllerE2ETest) to assert the full standardized error response body instead of only the HTTP status: after the testRestTemplate.exchange calls for invalid cursor and for the sort mismatch case, deserialize the response body as ApiResponse<ProductV1Dto.CursorListResponse> and assert that body.getMeta().getResult() indicates failure and that body.getError() contains the expected error code and message (use the same error constants your application throws). Also ensure the controller path used in these tests triggers the application to throw CoreException (so ApiControllerAdvice formats the error) rather than bypassing it; if needed, make the controller/service throw the same CoreException instance or map the validation failure to CoreException so the ApiControllerAdvice formatting is exercised.apps/commerce-api/src/main/java/com/loopers/domain/common/cursor/CursorPageResult.java (1)
6-19: 가변 리스트를 그대로 들고 있어 불변 계약이 깨진다.
items를 그대로 저장하면 호출자가 원본 리스트나items()반환값을 수정해 응답 내용이 뒤늦게 바뀔 수 있다. 이 객체가 캐시되거나 여러 계층을 거칠수록 이런 변이는 추적이 어려워진다. 생성자에서List.copyOf(items)로 방어적 복사를 하고, 추가 테스트로 원본 리스트 변경과items().add(...)시도가 결과 객체 상태를 바꾸지 않는지 검증하라.As per coding guidelines, `**/domain/**/*.java`: 엔티티/값 객체/DTO 경계를 명확히 하고, 불변성과 캡슐화를 점검한다.변경 예시
public record CursorPageResult<T>( List<T> items, String nextCursor, boolean hasNext, int size ) { + public CursorPageResult { + items = List.copyOf(items); + } + public <R> CursorPageResult<R> map(Function<T, R> mapper) { return new CursorPageResult<>( items.stream().map(mapper).toList(), nextCursor, hasNext, 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/common/cursor/CursorPageResult.java` around lines 6 - 19, CursorPageResult currently stores the provided mutable List<T> directly which breaks immutability; add an explicit canonical constructor for the CursorPageResult record that defensively copies the list with List.copyOf(items) (and validate non-null if desired) so the record field is an immutable copy, and update the map(Function) to ensure it constructs the new CursorPageResult with the copied list as well; then add tests that mutate the original source list after construction and attempt items().add(...) to assert the CursorPageResult contents do not change.apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java (1)
20-20: Repository 계약은 문자열 정렬값보다 도메인 타입이 안전하다.여기서
String sortBy를 그대로 받으면 오타나 미지원 정렬이 런타임까지 내려가고, cursor 인코딩/디코딩도 stringly-typed로 남는다. Repository 경계는 도메인 정렬 enum/value object를 받도록 좁히고, 문자열 파싱은 controller 또는 application 계층에서 끝내는 편이 안전하다. 추가 테스트로 잘못된 sort 값이 repository 진입 전에 400으로 차단되는지 검증하라.🤖 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/ProductRepository.java` at line 20, Change the repository boundary to accept domain-typed sorting and cursor values: update ProductRepository.findProductsByCursor to take a specific Sort enum/value-object (e.g., ProductSort) and a Cursor value-object instead of raw String sortBy and String cursor, and make CursorPageResult<ProductModel> usage remain the same; move all String parsing/validation of sort and cursor into the controller or application service (parse request params into ProductSort and Cursor before calling findProductsByCursor) and throw a 400 on invalid inputs; add a unit/integration test that submits an invalid sort value and asserts the request is rejected with 400 before any ProductRepository interactions.apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java (1)
31-44: 기본 TTL이 설정되지 않아 미정의 캐시는 무기한 저장된다.
defaultConfig에 TTL이 설정되지 않아, "product"와 "products" 외의 캐시 이름이 사용될 경우 데이터가 만료되지 않는다. 개발 중 실수로 새 캐시명을 사용하면 Redis 메모리가 지속적으로 증가할 수 있다.또한, 트랜잭션 커밋 후 캐시 갱신을 보장하려면
transactionAware()를 고려해야 한다.🔧 기본 TTL 및 트랜잭션 인식 설정
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(serializer)) - .disableCachingNullValues(); + .disableCachingNullValues() + .entryTtl(Duration.ofMinutes(5)); // 기본 TTL 설정 Map<String, RedisCacheConfiguration> cacheConfigs = Map.of( "product", defaultConfig.entryTtl(Duration.ofSeconds(60)), "products", defaultConfig.entryTtl(Duration.ofSeconds(30)) ); return RedisCacheManager.builder(lettuceConnectionFactory) .cacheDefaults(defaultConfig) .withInitialCacheConfigurations(cacheConfigs) + .transactionAware() .build();🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheConfig.java` around lines 31 - 44, defaultConfig currently has no TTL so caches other than "product"/"products" never expire; update the RedisCacheConfiguration named defaultConfig to set a sensible global entryTtl (e.g., Duration.ofMinutes(...)) and enable transaction awareness on the manager by calling transactionAware() on RedisCacheManager.builder(lettuceConnectionFactory) before build(); keep the per-cache overrides for "product" and "products" but ensure they derive from the new defaultConfig with entryTtl applied.apps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.java (1)
306-328: 커서 페이지네이션 테스트 구조는 적절하다.첫 페이지, 다음 페이지 연속성, 전체 순회, 중간 삽입 시 중복 방지 등 핵심 시나리오가 잘 커버되어 있다.
다만, 다음 경계 케이스 테스트 추가를 권장한다:
size보다 데이터가 적을 때 (hasNext=false,nextCursor=null)- 데이터가 0건일 때 빈 결과 반환
size=1인 극단적 페이지 크기🤖 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/domain/product/ProductServiceIntegrationTest.java` around lines 306 - 328, Add three additional test cases inside the GetProductsByCursor nested class to cover edge boundaries: (1) when total items < page size — create a brand and fewer than 10 products, call productRepository.findProductsByCursor(null, "latest", null, 10) and assert result.items().size() equals count, result.hasNext() is false, and result.nextCursor() is null; (2) when there are zero products — create a brand but no products, call findProductsByCursor(...) and assert an empty items list, hasNext() false, and nextCursor() null; and (3) when page size = 1 — create multiple products, call findProductsByCursor(null, "latest", null, 1) and assert that items size is 1, hasNext() reflects remaining data, and nextCursor() is present for the first page; use existing helpers brandService.createBrand, productService.createProduct and assert using CursorPageResult<ProductModel>.apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductSortCondition.java (1)
36-42: 잘못된 sortBy 입력 시 silent default로 디버깅 어려움
from(String sortBy)메서드가 알 수 없는 값에 대해LATEST를 반환한다. 운영 환경에서 오타나 잘못된 파라미터가 전달될 경우 오류 없이 기본값이 적용되어 문제 파악이 지연될 수 있다. 최소한 로깅을 추가하거나, 허용된 값 목록을 명시적으로 검증하는 방안을 고려하라.🤖 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/ProductSortCondition.java` around lines 36 - 42, ProductSortCondition.from currently silently returns LATEST for unknown inputs; change it to validate sortBy and fail fast: in ProductSortCondition.from(String sortBy) check for null/empty, compare against the known constants ("likes_desc", "price_asc", "latest" if applicable) and if the value is not recognized either (a) throw an IllegalArgumentException with a clear message that includes the received value and the allowed values (referencing LIKES_DESC, PRICE_ASC, LATEST) or (b) log a warning using the class logger before throwing/returning; this makes invalid parameters visible in logs and prevents silent defaults.apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCursorCondition.java (1)
66-80: from()과 fromType()의 입력 형식 차이로 인한 혼란 가능성
from(String sortBy)는 소문자 snake_case ("likes_desc")를,fromType(String cursorType)는 대문자 enum 이름 ("LIKES_DESC")을 기대한다. 두 메서드의 역할과 호출 시점이 명확히 구분되지 않으면 잘못된 입력으로 silent default가 적용될 수 있다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCursorCondition.java` around lines 66 - 80, ProductCursorCondition.from and fromType accept differently formatted strings which can cause silent fallbacks; update them to a single consistent parsing strategy in ProductCursorCondition: normalize the input (e.g., sortBy -> sortBy.trim().toUpperCase(Locale.ROOT).replace('-', '_')) and then map to the enum cases (LIKES_DESC, PRICE_ASC, LATEST) or delegate to a single parse method; additionally handle unknown values by throwing a clear IllegalArgumentException instead of returning LATEST silently so callers can detect invalid inputs.apps/commerce-api/src/test/java/com/loopers/application/product/CacheStrategyComparisonTest.java (1)
276-302: writeThrough_transactionRollback 테스트가 실제 트랜잭션 롤백을 시뮬레이션하지 않음테스트 이름은 "트랜잭션 롤백 시 캐시-DB 불일치"를 검증한다고 하지만, 실제로는 캐시만 수동 갱신하고 DB 업데이트를 생략한다. 실제
@Transactional롤백 시나리오(예: 예외 발생으로 인한 롤백)를 재현하지 않아, Write-Through의 실제 취약점을 증명하지 못한다. 테스트 목적을 명확히 하거나, 실제 롤백 시나리오를 구현하라.🤖 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/CacheStrategyComparisonTest.java` around lines 276 - 302, The test writeThrough_transactionRollback_cacheAndDbDiverge only manually mutates productCacheStore and never triggers a real transactional rollback; change it to either (A) rename the test to reflect it only simulates cache mutation, or preferably (B) reproduce a real rollback by invoking a transactional operation that attempts the update and then throws to force rollback: add or use a method like productService.updatePriceTransactional(productId, newPrice) or execute via TransactionTemplate/TestTransaction inside the test so the DB update is executed within a `@Transactional` boundary and an exception is thrown (caught by the test) to cause rollback; after the rollback assert that productRepository.findByProductId(...) still returns the original DB price while productCacheStore (or productApp.getProduct(...)) shows the pre-updated cached value, keeping references to productApp.getProduct, productRepository.findByProductId, productCacheStore.put and the test method writeThrough_transactionRollback_cacheAndDbDiverge.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 846e86a8-24a9-4cd9-97b1-95dee09daa2b
⛔ Files ignored due to path filters (4)
CLAUDE.mdis excluded by!**/*.mdand included by**docs/performance/cache-strategy-analysis.mdis excluded by!**/*.mdand included by**docs/performance/index-analysis.mdis excluded by!**/*.mdand included by**docs/performance/sort-strategy-analysis.mdis excluded by!**/*.mdand included by**
📒 Files selected for processing (33)
apps/commerce-api/src/main/java/com/loopers/application/like/LikeApp.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductApp.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheStore.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-api/src/main/java/com/loopers/domain/common/cursor/CursorPageResult.javaapps/commerce-api/src/main/java/com/loopers/domain/like/LikeService.javaapps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.javaapps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.javaapps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/cache/CacheConfig.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/cache/PageImplMixin.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/cache/PageRequestMixin.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/common/cursor/CursorEncoder.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCursor.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCursorCondition.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductSortCondition.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.javaapps/commerce-api/src/test/java/com/loopers/application/product/CacheStrategyComparisonTest.javaapps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheIntegrationTest.javaapps/commerce-api/src/test/java/com/loopers/application/product/RedisCacheIntegrationTest.javaapps/commerce-api/src/test/java/com/loopers/architecture/LayeredArchitectureTest.javaapps/commerce-api/src/test/java/com/loopers/domain/like/LikeCountSyncIntegrationTest.javaapps/commerce-api/src/test/java/com/loopers/domain/like/SortStrategyComparisonTest.javaapps/commerce-api/src/test/java/com/loopers/domain/product/ProductServiceIntegrationTest.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ControllerE2ETest.javamodules/jpa/src/testFixtures/java/com/loopers/utils/DatabaseCleanUp.javamodules/redis/src/testFixtures/java/com/loopers/testcontainers/RedisTestContainersConfig.javascripts/create-product-indexes.sqlscripts/seed-products.sql
💤 Files with no reviewable changes (1)
- apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java
apps/commerce-api/src/main/java/com/loopers/application/like/LikeApp.java
Show resolved
Hide resolved
apps/commerce-api/src/main/java/com/loopers/application/product/ProductApp.java
Show resolved
Hide resolved
| public class ProductCacheStore { | ||
|
|
||
| private static final String KEY_PREFIX = "product::"; | ||
| private static final long TTL_SECONDS = 60; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# ProductCacheStore 사용처 확인
rg -n "ProductCacheStore" --type java -A2
# `@Cacheable`("product") 사용처 확인
rg -n '@Cacheable.*"product"' --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1633
🏁 Script executed:
# CacheConfig 클래스 전체 내용 확인
fd "CacheConfig.java" --type f | xargs cat -n
# ProductApp에서 productCacheStore 사용 패턴 확인
rg -n "productCacheStore\." --type java -B2 -A3
# ProductCacheStore 전체 구현 확인
cat -n apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheStore.java
# `@Cacheable` 사용처 전체 확인
rg -n "@Cacheable" --type java -B1 -A2Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 14400
🏁 Script executed:
# 최종 확인: CacheConfig에서 "product" 캐시가 실제로 사용되는지 확인
rg -n "\"product\"" --type java -B2 -A2 apps/commerce-api/src/
# ProductCacheStore 단독 사용 확인
rg -n "productCacheStore\.(get|put)" --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 5006
ProductCacheStore와 CacheConfig의 "product" 캐시 무효화가 불일치한다.
ProductCacheStore는 RedisTemplate을 사용해 product::{productId} 형태로 데이터를 저장하고, CacheConfig의 "product" 캐시는 Spring CacheManager를 통해 별도 네임스페이스로 관리된다. 업데이트 시 문제가 발생한다:
ProductApp.updateProduct()→productCacheStore.put()실행- 동시에
@CacheEvict(value="product")→ CacheManager 캐시만 무효화 - ProductCacheStore의 캐시는 무효화되지 않음 → 만료까지 stale 데이터 반환
ProductCacheStore를 CacheManager로 통합하거나, @CacheEvict 시 ProductCacheStore도 명시적으로 무효화하도록 수정해야 한다. 캐시 일관성 검증 테스트를 추가하여 무효화 동작을 확인한다.
🤖 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/ProductCacheStore.java`
around lines 14 - 17, ProductCacheStore uses RedisTemplate with keys prefixed
"product::" but CacheConfig's "product" cache is managed by Spring CacheManager,
causing `@CacheEvict`(value="product") in ProductApp.updateProduct() to not clear
ProductCacheStore entries; fix by either 1) integrating ProductCacheStore into
the Spring Cache abstraction (implement Cache and register it in CacheConfig
under name "product"), or 2) keep ProductCacheStore as-is but update
ProductApp.updateProduct() (or the method annotated with `@CacheEvict`) to call
productCacheStore.evict(productId) after the `@CacheEvict` runs (or add a custom
cache eviction listener that calls ProductCacheStore.evict(key)), and add a test
that updates a product, asserts both the CacheManager "product" cache and
ProductCacheStore (methods: ProductCacheStore.put/get/evict) reflect the
eviction to prevent stale reads.
| public Optional<ProductInfo> get(String productId) { | ||
| byte[] bytes = redisTemplate.opsForValue().get(KEY_PREFIX + productId); | ||
| if (bytes == null) { | ||
| return Optional.empty(); | ||
| } | ||
| return Optional.of((ProductInfo) serializer.deserialize(bytes)); | ||
| } | ||
|
|
||
| public void put(String productId, ProductInfo productInfo) { | ||
| byte[] bytes = serializer.serialize(productInfo); | ||
| redisTemplate.opsForValue().set(KEY_PREFIX + productId, bytes, TTL_SECONDS, TimeUnit.SECONDS); | ||
| } |
There was a problem hiding this comment.
Redis 장애 시 예외 처리가 누락되었다.
PR 설명에서 "Redis failure 시 DB fallback"을 언급했으나, 이 클래스에서는 Redis 연결 실패나 타임아웃 시 예외가 그대로 전파된다. Redis 장애가 전체 상품 조회 기능 장애로 이어질 수 있다.
운영 환경에서 Redis 불안정 시 서비스 가용성을 보장하려면 다음 처리가 필요하다:
get(): 예외 발생 시Optional.empty()반환 후 로그 기록put(): 예외 발생 시 무시하고 로그 기록 (캐시 갱신 실패는 치명적이지 않음)
🛡️ Redis 장애 대응 코드 제안
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
`@Component`
public class ProductCacheStore {
+ private static final Logger log = LoggerFactory.getLogger(ProductCacheStore.class);
public Optional<ProductInfo> get(String productId) {
+ try {
byte[] bytes = redisTemplate.opsForValue().get(KEY_PREFIX + productId);
if (bytes == null) {
return Optional.empty();
}
return Optional.of((ProductInfo) serializer.deserialize(bytes));
+ } catch (Exception e) {
+ log.warn("Redis 캐시 조회 실패, DB fallback 진행: productId={}", productId, e);
+ return Optional.empty();
+ }
}
public void put(String productId, ProductInfo productInfo) {
+ try {
byte[] bytes = serializer.serialize(productInfo);
redisTemplate.opsForValue().set(KEY_PREFIX + productId, bytes, TTL_SECONDS, TimeUnit.SECONDS);
+ } catch (Exception e) {
+ log.warn("Redis 캐시 저장 실패: productId={}", productId, e);
+ }
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| public Optional<ProductInfo> get(String productId) { | |
| byte[] bytes = redisTemplate.opsForValue().get(KEY_PREFIX + productId); | |
| if (bytes == null) { | |
| return Optional.empty(); | |
| } | |
| return Optional.of((ProductInfo) serializer.deserialize(bytes)); | |
| } | |
| public void put(String productId, ProductInfo productInfo) { | |
| byte[] bytes = serializer.serialize(productInfo); | |
| redisTemplate.opsForValue().set(KEY_PREFIX + productId, bytes, TTL_SECONDS, TimeUnit.SECONDS); | |
| } | |
| import org.slf4j.Logger; | |
| import org.slf4j.LoggerFactory; | |
| `@Component` | |
| public class ProductCacheStore { | |
| private static final Logger log = LoggerFactory.getLogger(ProductCacheStore.class); | |
| public Optional<ProductInfo> get(String productId) { | |
| try { | |
| byte[] bytes = redisTemplate.opsForValue().get(KEY_PREFIX + productId); | |
| if (bytes == null) { | |
| return Optional.empty(); | |
| } | |
| return Optional.of((ProductInfo) serializer.deserialize(bytes)); | |
| } catch (Exception e) { | |
| log.warn("Redis 캐시 조회 실패, DB fallback 진행: productId={}", productId, e); | |
| return Optional.empty(); | |
| } | |
| } | |
| public void put(String productId, ProductInfo productInfo) { | |
| try { | |
| byte[] bytes = serializer.serialize(productInfo); | |
| redisTemplate.opsForValue().set(KEY_PREFIX + productId, bytes, TTL_SECONDS, TimeUnit.SECONDS); | |
| } catch (Exception e) { | |
| log.warn("Redis 캐시 저장 실패: productId={}", productId, e); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheStore.java`
around lines 27 - 38, ProductCacheStore currently lets Redis exceptions
propagate from get(...) and put(...); wrap the redisTemplate calls inside
try-catch blocks so Redis failures don't break product read path: in get(String
productId) catch Redis/DataAccess/RuntimeException around
redisTemplate.opsForValue().get(...) and on exception log the error (include
productId and exception) and return Optional.empty(); in put(String productId,
ProductInfo productInfo) catch Redis/DataAccess/RuntimeException around
serializer.serialize(...) and redisTemplate.opsForValue().set(...) and on
exception log the failure (include productId, TTL_SECONDS, KEY_PREFIX and
exception) and swallow the exception so cache write failures are non-fatal. Use
the existing class logger (or add a private static final Logger) and preserve
serializer usage when no error occurs.
| public CursorPageResult<ProductInfo> getProductsByCursor(String brandId, String sortBy, String cursor, int size) { | ||
| CursorPageResult<ProductInfo> result = productApp.getProductsByCursor(brandId, sortBy, cursor, size); | ||
| return result.map(this::enrichProductInfo); | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# BrandApp.getBrandByRefId 캐시 적용 여부 확인
rg -n "getBrandByRefId" --type java -B2 -A5
# Brand 조회에 캐시가 있는지 확인
rg -n '@Cacheable.*brand' --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3034
🏁 Script executed:
#!/bin/bash
# CursorPageResult.map() 구현 확인
rg -n "class CursorPageResult" --type java -A30
# getProducts 메서드 확인 (N+1 패턴 비교)
rg -n "public.*getProducts" --type java -A10 apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
# 브랜드 정보를 한번에 조회하는 메서드 확인
rg -n "getBrand.*List\|getBrands\|getBrandsByIds" --type javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 893
🏁 Script executed:
#!/bin/bash
# Find CursorPageResult to understand map() behavior
fd -e java | xargs rg -l "class CursorPageResult|interface CursorPageResult" --type java
# Look for batch loading or multi-get methods for brands
rg -n "public.*BrandInfo.*List|public.*List.*Brand" --type java apps/commerce-api/src/main/java/com/loopers/application/brand/
# Check if any caching exists on brand queries
rg -n "@Cacheable|@Cache" --type java apps/commerce-api/src/main/java/com/loopers/application/brand/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Broader search for CursorPageResult definition
rg -n "CursorPageResult" --type java -B2 -A20 | head -100
# Check all methods in BrandApp
cat apps/commerce-api/src/main/java/com/loopers/application/brand/BrandApp.java
# Check for batch/collection methods in brand repository
rg -n "findAllById|findByIdIn" --type java apps/commerce-api/src/main/java/com/loopers/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 15894
🏁 Script executed:
#!/bin/bash
# Find CursorPageResult class definition
find . -type f -name "*.java" | xargs grep -l "class CursorPageResult"
# Look at the file
fd "CursorPageResult.java" -x cat {}
# Check BrandRepository methods
rg -n "public.*Brand.*find\|public.*List.*Brand" --type java apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 567
🏁 Script executed:
#!/bin/bash
# Check BrandRepository interface/class
find . -path "*/infrastructure/brand/*Repository*.java" -type f | xargs cat
# Search for any batch loading patterns with brands in the codebase
rg -n "findAllById|findByIdIn|IN\(" --type java apps/commerce-api/src/main/java/com/loopers/ | head -30
# Check if LikeFacade has similar issue and any solutions
cat apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2743
enrichProductInfo에서 브랜드 조회 시 N+1 쿼리 문제가 발생한다.
CursorPageResult.map(this::enrichProductInfo) 실행 시 각 상품마다 brandApp.getBrandByRefId() 가 호출되며, getBrandByRefId는 캐싱이 적용되지 않아 동일 브랜드의 상품이 다수 포함된 경우 중복 쿼리가 발생한다. 커서 페이지네이션에서 한 번에 조회하는 상품 수만큼 추가 데이터베이스 호출이 증가하므로 성능 저하를 초래한다.
이 문제는 getProducts() (39-42줄)와 LikeFacade.getMyLikedProducts() 에서도 동일한 패턴으로 발생한다.
개선 방안:
- 브랜드 정보를 일괄 조회 후 메모리에서 매핑 (IN 쿼리로 한 번에 조회)
- 상품 조회 시 브랜드 정보를 JOIN으로 함께 가져오기
getBrandByRefId에@Cacheable적용하여 중복 조회 방지
검증:
- 동일 브랜드 다수 상품 조회 시 쿼리 수 측정 (현재 상품 수 + 1 → 1로 감소 확인)
- 캐시 또는 배치 로딩 적용 후 부하 테스트 진행
🤖 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 44 - 47, enrichProductInfo causes an N+1 by calling
brandApp.getBrandByRefId per product when
CursorPageResult.map(this::enrichProductInfo) (used by getProductsByCursor,
getProducts and LikeFacade.getMyLikedProducts) runs; fix by batching brand
fetches: collect all distinct brandRefIds from the page, call brandApp to load
brands in one IN query (or add a join in product query) and build an in-memory
Map<brandRefId, Brand> to pass into enrichProductInfo (or refactor
enrichProductInfo to accept the map), alternatively annotate getBrandByRefId
with `@Cacheable` if caching is acceptable — verify by ensuring queries drop from
N+1 to 1 for the page.
📌 Summary
/api/v1/products/cursor)🧭 Context & Decision
문제 정의
ref_brand_id = ?필터와 좋아요순/최신/가격순 정렬을 사용하지만, 인덱스가 없어 브랜드 필터링 후 Full Table Scan + filesort 수행ALL→ref전환, 캐시 HIT 시 DB 쿼리 미발생, 수정 후 첫 조회에서 스탬피드 없음1️⃣ [Index] 정렬 조건별 인덱스 전략 — 3개 분리 vs 1개 복합
AS-IS EXPLAIN (인덱스 없음, 100,000건)
3개 쿼리 모두 Full Table Scan.
ref_brand_id필터가 있어도 인덱스 없어 전체 스캔 후 필터 적용.선택지와 결정
A: 정렬 조건별 분리 인덱스 3개 (
brand+like,brand+latest,brand+price)B: 1개 복합 인덱스 (
ref_brand_id, like_count, updated_at, price)최종 결정: A — 정렬 조건별 분리 인덱스 3개
TO-BE EXPLAIN (인덱스 적용 후)
Full Table Scan 제거 (ALL → ref), rows -81% (99,510 → 18,962). latest/price_asc는 filesort 완전 제거.
2️⃣ [Index] 정렬 로직 추상화 — switch vs enum Strategy
QueryDSL 도입 시 raw switch로 정렬 분기 구현. 확장성과 책임 분리를 위해 개선.
최종 결정: C —
ProductSortConditionenum. 신규 정렬 추가 = enum 상수 추가만으로 완결.3️⃣ [Structure] 좋아요 순 정렬 — 비정규화 vs Materialized View
products.like_count)product_like_stats)최종 결정: 비정규화 유지. 현재 요구사항이 "단순 per-product 좋아요 순 정렬"이므로 가장 단순하고 즉시 정합성 보장.
MV 도입 트리거: 브랜드별 인기 상품, 최근 N일 트렌딩 기능 추가 시 또는 likes 테이블이 수억 건을 초과할 때.
4️⃣ [Cache] 상세 vs 목록 — Cache-Aside / Read-Through 분리
getProductProductCacheStore, 수동 RedisTemplate)@Cacheable은 예외 전파가 AOP 내부에 감춰져 fallback 제어 어려움getProducts@Cacheable)Page<T>복합 타입의 직렬화가 복잡 → Jackson MixIn + 선언적 처리가 더 적합. 복합 키(brand·sort·page)는 SpEL로 자동 분리5️⃣ [Cache] 쓰기 전략 — Write-Around → Write-Through 전환
초기에는
@CachePut의 트랜잭션 불일치 문제(@Transactional커밋 전에 캐시 갱신 → 롤백 시 캐시-DB 불일치)를 이유로 Write-Around(@CacheEvict)를 선택.이후 캐시 스탬피드 실험에서 수치로 비교:
productCacheStore.put())@CacheEvict)결정 변경: Write-Through 전환
채택 이유:
like_count는 Read-Heavy 필드로 수정보다 조회가 압도적으로 많고, stale 위험은 TTL(300s) 내 단기 불일치로 한정되며 재고·결제 핵심 정합성과 무관.@CachePut대신 메서드 바디 내 명시적put()호출로 AOP 타이밍 이슈 회피.6️⃣ [Cursor] Keyset Pagination — OFFSET 대체 전략
문제 정의
OFFSET 기반 페이징의 구조적 한계:
선택지와 결정
최종 결정: A — Base64 인코딩. 커서 자체에
type필드를 포함해 정렬 불일치를 서버에서 검증. 내부 구조 노출이 실제 보안 위협이 되지 않는 도메인.Composite Cursor 설계
latestupdatedAt,idupdatedAt < c.updatedAt OR (updatedAt = c.updatedAt AND id < c.id)likes_desclikeCount,updatedAt,idlikeCount < c.likeCount OR (eq AND updatedAt < c.updatedAt) OR (eq AND eq AND id < c.id)price_ascprice,idprice > c.price OR (price = c.price AND id > c.id)id를 최종 tie-break으로 포함 → 결정적 정렬 보장PriceVO +@Convert필드: QueryDSLExpressions.booleanOperation(Ops.GT, SimplePath<Price>, Expressions.constant(new Price(...)))방식으로 타입 불일치 해소hasNext 판정 전략
COUNT(*) 쿼리 없이
size + 1개를 조회한 후 실제 반환 목록에서 마지막 항목 제거:🏗️ Design Overview
변경 범위
apps/commerce-api— product, like 도메인scripts/seed-products.sql— 성능 테스트용 시드 데이터 (10만건)scripts/create-product-indexes.sql— 복합 인덱스 DDLinfrastructure/product/ProductSortCondition.java— 정렬 전략 enuminfrastructure/cache/CacheConfig.java,PageImplMixin.java,PageRequestMixin.java— Redis 캐시 설정application/product/ProductCacheStore.java— 수동 Cache-Aside 구현체docs/performance/index-analysis.md,sort-strategy-analysis.md,cache-strategy-analysis.md— 분석 문서IndexStrategyComparisonTest.java,CacheStampedeHotKeyTest.java,SortStrategyComparisonTest.java,CacheStrategyComparisonTest.java— 전략 비교 테스트domain/common/cursor/CursorPageResult.java— 커서 페이징 공통 응답 VO (items,nextCursor,hasNext,size)infrastructure/common/cursor/CursorEncoder.java— Base64 URL-safe 인코딩/디코딩 + 잘못된 커서 시BAD_REQUESTinfrastructure/product/ProductCursor.java— 정렬 기준별 커서 VO (type,updatedAt,likeCount,price,id)infrastructure/product/ProductCursorCondition.java— enum Strategy: 정렬 조건별toCursorPredicate()+toOrderSpecifiers()ProductJpaRepositorynative query 3종 →ProductRepositoryImplQueryDSL 동적 쿼리ConcurrentMapCacheManager→RedisCacheManager(TTL 제어)updateProduct@CacheEvict(product)→productCacheStore.put()(Write-Through)주요 컴포넌트 책임
ProductSortCondition: QueryDSLOrderSpecifier생성 책임 캡슐화 (enum Strategy 패턴)ProductCacheStore: Redis 장애 시 fallback 포함한 Cache-Aside 패턴.put()/get()/evict()단순 인터페이스CacheConfig:RedisCacheManager+ TTL(product: 300s,products: 30s) +PageImplMixinJackson 설정LikeApp: 좋아요 추가/취소 시@CacheEvict(product)무효화로 동기화 보장🔁 Flow Diagram
상품 상세 조회 (Cache-Aside)
sequenceDiagram autonumber participant Client participant ProductApp participant ProductCacheStore participant Redis participant DB Client->>ProductApp: getProduct(productId) ProductApp->>ProductCacheStore: get(productId) ProductCacheStore->>Redis: GET product::{productId} alt 캐시 HIT Redis-->>ProductCacheStore: ProductInfo ProductCacheStore-->>ProductApp: Optional.of(info) ProductApp-->>Client: ProductInfo (DB 접근 없음) else 캐시 MISS Redis-->>ProductCacheStore: null ProductCacheStore-->>ProductApp: Optional.empty() ProductApp->>DB: findByProductId DB-->>ProductApp: ProductModel ProductApp->>ProductCacheStore: put(productId, info) ProductCacheStore->>Redis: SET product::{productId} TTL 300s ProductApp-->>Client: ProductInfo else Redis 장애 Redis-->>ProductCacheStore: Exception ProductCacheStore-->>ProductApp: Optional.empty() (fallback) ProductApp->>DB: findByProductId (정상 서비스 유지) DB-->>ProductApp: ProductModel ProductApp-->>Client: ProductInfo end상품 수정 (Write-Through)
sequenceDiagram autonumber participant Client participant ProductApp participant ProductService participant ProductCacheStore participant Redis participant DB Client->>ProductApp: updateProduct(productId, ...) ProductApp->>ProductService: updateProduct ProductService->>DB: UPDATE products SET ... DB-->>ProductService: ProductModel (updated) ProductService-->>ProductApp: ProductModel ProductApp->>ProductCacheStore: put(productId, info) ProductCacheStore->>Redis: SET product::{productId} TTL 300s (Overwrite) ProductApp-->>Client: ProductInfo Note over Redis: 수정 직후 캐시 HIT 보장 → 스탬피드 없음✅ Checklist
🔖 Index
ProductSortConditionenum Strategy 도입brand+like,brand+latest,brand+price)IndexStrategyComparisonTest— 3분리 인덱스 vs 복합 인덱스 EXPLAIN 비교SortStrategyComparisonTest— 비정규화 vs MV 동시성 측정 (50 스레드, 정확도 비교)❤️ Structure
products.like_count비정규화 컬럼 유지 (MV 대비 단순성·즉시 정합성 우위)LIKES_DESC정렬 조건 →ProductInfo.likeCount노출LikeApp@CacheEvict(product)→ 다음 조회 시 DB 최신값 반영LikeCountSyncIntegrationTest— 동시 좋아요 30건 정확도 검증⚡ Cache
ProductCacheStore, TTL 300s), Write-Through on update@Cacheable, TTL 30s), 수정/삭제 시allEntries=trueevictConcurrentMapCacheManager) → Redis(RedisCacheManager) 전환ProductCacheStore.get(): Redis 예외 시Optional.empty()반환 → DB fallback 자동 실행RedisCacheIntegrationTest— 캐시 키 적재/TTL/fallback/예외 시나리오 검증CacheStampedeHotKeyTest— 스탬피드 발생·완화(Mutex Lock), 핫키 집중도, Write-Through/Write-Around 비즈니스 전략 비교 (24개 테스트)변경 목적
상품 목록 조회 성능 개선 및 Redis 캐싱을 통한 읽기 트래픽 흡수. OFFSET 기반 페이지네이션의 선형 비용과 일관성 문제를 커서 기반 페이지네이션으로 대체.
핵심 변경점
인덱싱: ProductModel에 3개 복합 인덱스 추가 (brand+like_count, brand+updated_at, brand+price DESC)로 쿼리 실행 계획 ALL → ref로 개선. QueryDSL 마이그레이션: 기존 3개 JPA 네이티브 쿼리 메서드를 ProductRepositoryImpl의 QueryDSL 구현으로 완전 대체. 캐싱 전략: Detail은 Cache-Aside via ProductCacheStore (TTL 60s), List는 @Cacheable Read-Through (TTL 30s)로 분리. Write-Through: updateProduct에서 DB 업데이트 후 productCacheStore.put()으로 캐시 동시 갱신. 커서 페이지네이션: /api/v1/products/cursor 엔드포인트 추가, Base64 인코딩 커서 및 정렬별 합성 커서 필드 지원.
주의사항 및 확인 필요사항
캐시 일관성 위험: (1) updateProduct에서 put() 후 예외 발생 시 DB와 캐시 불일치 가능 - 예외 처리 전략 확인 필요. (2) like 변경 시 allEntries=true로 "products" 캐시 전체 무효화하여 캐시 히트율 저하 - fine-grained eviction 검토 필요. (3) Detail 조회는 Cache-Aside, List 조회는 Read-Through로 전략이 혼용되는데, 운영 중 일관성 유지 방안 확인 필요. 커서 정렬 순서 일관성: ProductSortCondition과 ProductCursorCondition의 LIKES_DESC 정렬이 모두 (likeCount DESC, updatedAt DESC)로 일치함을 확인. 네이티브 쿼리 완전 제거: ProductJpaRepository에서 기존 쿼리 메서드 완전 제거되었으므로 마이그레이션 완료 상태.
테스트 및 검증
930줄의 4개 테스트 클래스(CacheStrategyComparisonTest, ProductCacheIntegrationTest, RedisCacheIntegrationTest, LikeCountSyncIntegrationTest)로 캐시 전략, 스탬피드 저항성, like_count 동기화, 커서 페이지네이션을 검증. 추가로 ProductServiceIntegrationTest와 ProductV1ControllerE2ETest에서 커서 기반 조회의 중복 방지, 가격 정렬 일관성, 페이지 경계 검증 포함.