[volume - 5] 인덱스 및 캐시 적용을 통한 조회 최적화 - 김남진#215
[volume - 5] 인덱스 및 캐시 적용을 통한 조회 최적화 - 김남진#215Namjin-kimm wants to merge 6 commits intoLoopers-dev-lab:Namjin-kimmfrom
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>
- ProductDetailCacheEvictEvent: 상품 ID 기반 상세 캐시 핀포인트 무효화 이벤트 추가
- ProductCacheRepository: 상세 캐시 인터페이스(getDetail/saveDetail/evictDetail) 추가
- ProductCacheRepositoryImpl: 상세 캐시 구현 (키: loopers:product:detail:{id}, Replica 읽기 / Master 쓰기)
- ProductCacheEventListener: ProductDetailCacheEvictEvent 핸들러 추가 (AFTER_COMMIT)
- application.yml: product:detail TTL 60초 설정 (민감 정보는 이벤트 무효화, likeCount stale 허용 범위)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- ProductFacade.findById(): Cache-Aside 패턴 적용 (미스 시 DB 조회 후 상세 캐시 저장) - ProductAdminFacade: 상품 수정/삭제 시 상세 캐시 무효화 이벤트 발행 - OrderFacade: 주문 재고 차감 후 해당 상품 상세 캐시 무효화 이벤트 발행 - BrandAdminFacade: 브랜드명 수정/삭제 시 목록 캐시 + 해당 브랜드 상품 상세 캐시 무효화 이벤트 발행 (기존 누락 보완) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 채택 4개(idx_brand_deleted_created/price/likes, idx_deleted_created)와 미채택 2개(idx_deleted_price/likes)의 선택 근거 명시 - 결정 기준: 브랜드 필터 유무별 사용 빈도, 선택도, 쓰기 비용 트레이드오프 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
📝 WalkthroughWalkthrough이 변경 사항은 Spring 이벤트 기반의 Redis 캐싱 아키텍처를 도입한다. 상품 데이터 변경 시 ApplicationEventPublisher를 통해 캐시 무효화 이벤트를 발행하고, ProductCacheEventListener가 트랜잭션 커밋 후 캐시를 삭제한다. ProductFacade에는 Cache-Aside 패턴을 적용하고, 데이터베이스 인덱스를 추가하며, k6 성능 테스트 스크립트를 포함한다. Changes
Sequence DiagramsequenceDiagram
participant Client as Client
participant Controller as API Controller
participant Facade as ProductFacade
participant Cache as ProductCacheRepository
participant DB as Database
participant Publisher as ApplicationEventPublisher
participant Listener as ProductCacheEventListener
participant Redis as Redis
Client->>Controller: GET /products?brandId=1&page=0
Controller->>Facade: findAll(brandId, pageable)
Facade->>Facade: buildCacheKey(brandId, pageable)
Facade->>Cache: getList(cacheKey)
Cache->>Redis: GET loopers:product:list:...
alt Cache Hit
Redis-->>Cache: ProductPageResult
Cache-->>Facade: Optional<ProductPageResult>
Facade-->>Controller: ProductPageResult
Controller-->>Client: 200 OK (cached)
else Cache Miss
Redis-->>Cache: null
Cache-->>Facade: Optional.empty()
Facade->>DB: fetchFromDb(brandId, pageable)
DB-->>Facade: Page<Product>
Facade->>Facade: map to ProductPageResult
Facade->>Cache: saveList(cacheKey, result)
Cache->>Redis: SET with TTL
Redis-->>Cache: OK
Facade-->>Controller: ProductPageResult
Controller-->>Client: 200 OK (uncached)
end
Client->>Controller: PUT /products/{id}
Controller->>Facade: update(id, data)
Facade->>DB: save(product)
DB-->>Facade: updated Product
Facade->>Publisher: publishEvent(ProductCacheEvictEvent)
Publisher->>Listener: handleProductChanged(event)
Listener->>Cache: evictAll()
Cache->>Redis: DEL loopers:product:list:*
Redis-->>Cache: deleted count
Facade->>Publisher: publishEvent(ProductDetailCacheEvictEvent(id))
Publisher->>Listener: handleProductDetailChanged(event)
Listener->>Cache: evictDetail(id)
Cache->>Redis: DEL loopers:product:detail:{id}
Redis-->>Cache: deleted
Facade-->>Controller: updated
Controller-->>Client: 200 OK
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes 운영 관점 지적 사항1. 캐시 워밍 전략 부재문제점: Cache-Aside 패턴은 초기 조회 시 캐시 미스를 피할 수 없다. 트래픽이 집중된 페이지(예: page=0)에 대해 애플리케이션 시작 직후 또는 전체 캐시 무효화 후 데이터베이스 부하가 급증할 수 있다. 수정안:
추가 테스트:
2. 이벤트 리스너 실패 시 재시도 전략 부재문제점: 수정안:
추가 테스트:
3. 캐시 일관성 보장 부족문제점: 수정안:
추가 테스트:
4. CacheProperties의 jitterRangeSeconds 구현문제점: 수정안:
추가 테스트:
5. 페이지 기반 캐서빌리티 필터링의 한계문제점: 수정안:
추가 테스트:
6. Redis 키 삭제의 SCAN 사용문제점: 수정안:
추가 테스트:
7. 데이터베이스 인덱스 검증 부족문제점: 네 가지 복합 인덱스 추가( 수정안:
추가 테스트:
8. k6 성능 테스트의 실제 부하 시뮬레이션 한계문제점: 제공된 테스트 스크립트( 수정안:
추가 테스트:
보안 관점 추가 사항
🚥 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)
📝 Coding Plan
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment Tip CodeRabbit can enforce grammar and style rules using `languagetool`.Configure the |
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (12)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailCacheEvictEvent.java (1)
8-9: productId null 검증 부재이벤트 발행 시
productId가 null로 전달되면 캐시 무효화 로직에서 예상치 못한 동작이 발생할 수 있다. 방어적으로 생성 시점에 null 검증을 추가하는 것을 권장한다.♻️ 방어적 null 검증 추가
-public record ProductDetailCacheEvictEvent(Long productId) { +public record ProductDetailCacheEvictEvent(Long productId) { + public ProductDetailCacheEvictEvent { + if (productId == null) { + throw new IllegalArgumentException("productId must not be null"); + } + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailCacheEvictEvent.java` around lines 8 - 9, The ProductDetailCacheEvictEvent record lacks a null check for productId; add a compact canonical constructor to ProductDetailCacheEvictEvent that validates productId (e.g., via Objects.requireNonNull or explicit check) and throws a clear exception when null is passed so the event cannot be created with a null productId.apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageResult.java (1)
18-26: 캐시 저장 시 List 방어적 복사 부재
page.getContent()가 반환하는 리스트를 그대로 사용하고 있다. 이 객체가 Redis 캐시에 저장되고 역직렬화되어 재사용될 때는 문제가 없으나, 로컬 메모리에 캐싱하거나 반환된 객체를 재사용하는 경우 외부에서 리스트를 수정하면 캐시 데이터가 오염될 수 있다. 불변 리스트로 감싸면 이러한 위험을 차단할 수 있다.♻️ 불변 리스트로 방어적 복사
public static ProductPageResult from(Page<ProductInfo> page) { return new ProductPageResult( - page.getContent(), + List.copyOf(page.getContent()), page.getNumber(), page.getSize(), page.getTotalElements(), page.getTotalPages() ); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageResult.java` around lines 18 - 26, ProductPageResult.from currently uses page.getContent() directly which risks mutable list leakage; change the constructor call to pass an immutable defensive copy of the content (e.g., List.copyOf(page.getContent()) or Collections.unmodifiableList(new ArrayList<>(page.getContent()))) so callers and caches cannot mutate the stored list; update ProductPageResult.from to wrap page.getContent() with the chosen defensive-copy/immutability call and ensure any constructor/field types accept List<ProductInfo>.scripts/test_uncached.js (1)
27-31: BASE_URL 하드코딩으로 인한 환경 유연성 부족
localhost:8080이 하드코딩되어 있어 CI/CD 파이프라인이나 스테이징 환경에서 테스트 시 스크립트 수정이 필요하다. k6의 환경 변수 기능을 활용하면 재사용성이 높아진다.♻️ 환경 변수를 통한 BASE_URL 주입
+const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; + export default function () { - const res = http.get('http://localhost:8080/api/v1/products?page=10&sort=latest'); + const res = http.get(`${BASE_URL}/api/v1/products?page=10&sort=latest`); duration.add(res.timings.duration); check(res, { 'status 200': (r) => r.status === 200 }); sleep(0.5); }실행 예시:
K6_WEB_DASHBOARD=true k6 run -e BASE_URL=http://staging:8080 scripts/test_uncached.js🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/test_uncached.js` around lines 27 - 31, The test currently hardcodes 'http://localhost:8080' inside the exported default function and should use a k6 environment variable instead: read __ENV.BASE_URL (falling back to 'http://localhost:8080' if unset) and build the request URL from that base before calling http.get; update the URL usage in the exported default function (where http.get(...) is called) to use the constructed base + '/api/v1/products?page=10&sort=latest' so the script can be run in CI/staging by passing BASE_URL.scripts/cache_test.js (1)
23-23: BASE_URL 환경 변수 미지원
test_uncached.js와 마찬가지로 하드코딩된 URL은 다양한 테스트 환경에서 유연성을 제한한다.♻️ 환경 변수를 통한 BASE_URL 주입
-const BASE_URL = 'http://localhost:8080'; +const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080';🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/cache_test.js` at line 23, Replace the hardcoded BASE_URL constant with an environment-driven value: change the BASE_URL declaration so it reads from process.env.BASE_URL (with a sensible default like 'http://localhost:8080' if not set), update any tests that reference BASE_URL to use the new constant, and ensure test setup/documentation mentions setting BASE_URL (mirroring the approach used in test_uncached.js); modify the BASE_URL constant declaration in scripts/cache_test.js accordingly.scripts/test_cached.js (1)
27-31: BASE_URL 하드코딩다른 k6 스크립트와 동일하게 환경 변수를 통한 BASE_URL 주입을 권장한다.
♻️ 환경 변수 적용
+const BASE_URL = __ENV.BASE_URL || 'http://localhost:8080'; + export default function () { - const res = http.get('http://localhost:8080/api/v1/products?page=0&sort=latest'); + const res = http.get(`${BASE_URL}/api/v1/products?page=0&sort=latest`); duration.add(res.timings.duration); check(res, { 'status 200': (r) => r.status === 200 }); sleep(0.5); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@scripts/test_cached.js` around lines 27 - 31, The test_cached.js file hardcodes the base URL in the exported default function (see the http.get call), so update the function to read a BASE_URL from the environment (e.g., __ENV.BASE_URL) with a sensible fallback and use that when building the request URI for http.get('/api/v1/products?page=0&sort=latest'); ensure the rest of the function (duration.add, check, sleep) continues to use the response object unchanged; replace the literal 'http://localhost:8080' with the composed BASE_URL variable wherever used.apps/commerce-api/src/main/resources/application.yml (1)
32-38: 캐시 설정 검토 - 환경별 분리 고려TTL 설정과 jitter 적용이 적절하다. 다만
test프로파일에서도 동일한 캐시 설정이 적용되므로, 통합 테스트 시 캐시로 인한 테스트 격리 문제가 발생할 수 있다.권장 조치:
test프로파일에서max-cacheable-page: 0또는 별도 캐시 비활성화 고려- 또는 테스트 코드에서
@DirtiesContext또는 캐시 수동 클리어 적용🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/resources/application.yml` around lines 32 - 38, Current application.yml cache settings apply to all profiles and can cause test isolation issues; modify configuration to disable or reduce caching for the test profile by moving/overriding the cache keys under a "test" profile (e.g., set cache.max-cacheable-page: 0 or disable cache TTLs) so tests run without cached responses, or alternatively document in tests to use `@DirtiesContext` or explicitly clear the cache before/after tests; change the YAML to include a test profile override for the cache block (target keys: cache.default-ttl-seconds, cache.jitter-range-seconds, cache.max-cacheable-page, cache.ttl-overrides) so test runs use the adjusted values.apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEventListener.java (2)
26-29: 상세 캐시 무효화 핸들러도 동일하게 방어적 예외 처리를 고려할 수 있다.앞선 코멘트와 동일한 이유로, 방어적 try-catch 추가를 선택적으로 검토할 수 있다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEventListener.java` around lines 26 - 29, Add defensive exception handling to the ProductCacheEventListener.handleProductDetailChanged method: wrap the call to productCacheRepository.evictDetail(event.productId()) in a try-catch that catches any Exception, logs the error with context including the productId and the exception (using the class logger), and avoids rethrowing so the event listener does not break transaction commit processing for other listeners; follow the same pattern used for the other cache eviction handler in this class.
20-23: AFTER_COMMIT 이벤트 핸들러의 예외 전파 동작을 확인해야 한다.
@TransactionalEventListener(phase = AFTER_COMMIT)핸들러에서 예외가 발생하면 트랜잭션은 이미 커밋된 상태이므로 롤백되지 않지만, 예외가 전파되어 호출자에게 영향을 줄 수 있다. 현재ProductCacheRepositoryImpl.evictAll()이 내부에서 예외를 흡수하므로 문제없으나, 향후 구현 변경 시 방어적으로 try-catch를 추가하는 것이 안전하다.🛡️ 방어적 예외 처리 추가 제안
`@TransactionalEventListener`(phase = TransactionPhase.AFTER_COMMIT) public void handleProductChanged(ProductCacheEvictEvent event) { + try { productCacheRepository.evictAll(); + } catch (Exception e) { + log.warn("상품 목록 캐시 무효화 이벤트 처리 실패", e); + } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEventListener.java` around lines 20 - 23, The AFTER_COMMIT event handler handleProductChanged in ProductCacheEventListener should defensively catch and handle any runtime exceptions from productCacheRepository.evictAll() to avoid propagating errors after commit; wrap the call in a try-catch, log the exception (using the class Logger) with context like "failed to evict product cache" and swallow or handle it so exceptions from future changes in ProductCacheRepositoryImpl.evictAll() won't bubble up and affect callers.apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java (1)
82-85: 대량 키 삭제 시 파이프라인 또는 UNLINK 사용을 고려해야 한다.현재 구현은
cursor.hasNext()루프에서 키당 개별DEL호출하므로, 캐시 키가 많을 경우 N번의 Redis 왕복이 발생한다.운영 관점 문제점:
- 캐시 키 수천 개 시 무효화 지연 발생
- AFTER_COMMIT 핸들러 지연으로 응답 시간 영향 가능
개선 방안:
UNLINK(비동기 삭제) 사용으로 Redis 블로킹 감소- 키 수집 후
delete(Collection<K>)배치 삭제♻️ 배치 삭제 개선안
try (var cursor = connection.scan(options)) { + List<byte[]> keysToDelete = new ArrayList<>(); while (cursor.hasNext()) { - connection.del(cursor.next()); + keysToDelete.add(cursor.next()); + if (keysToDelete.size() >= 100) { + connection.del(keysToDelete.toArray(new byte[0][])); + keysToDelete.clear(); + } + } + if (!keysToDelete.isEmpty()) { + connection.del(keysToDelete.toArray(new byte[0][])); } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java` around lines 82 - 85, ProductCacheRepositoryImpl currently iterates cursor.hasNext() and calls connection.del(key) per key which causes N Redis round-trips; instead collect keys from the cursor into a list (e.g., List<byte[]> or List<String>) and perform a batched non-blocking delete using connection.unlink(keys) if available or connection.del(Collection) in one call; update the scan handling (the block around connection.scan(options), cursor and connection.del(cursor.next())) to accumulate keys and then call connection.unlink(keys) (or connection.del(keys) as fallback) to minimize round-trips and avoid blocking Redis during large cache invalidations.apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (2)
71-78: 정렬 조건이 없을 때 캐시 키 포맷을 명확히 해야 한다.
pageable.getSort()가Sort.unsorted()인 경우sortPart가 빈 문자열이 되어 키가loopers:product:list:brand=all:sort=:page=0:size=20형태가 된다. 기능적으로 문제없으나, 가독성과 디버깅 편의를 위해 기본값 지정을 고려할 수 있다.♻️ 빈 정렬 조건 처리
String sortPart = pageable.getSort().stream() .map(order -> order.getProperty() + "_" + order.getDirection()) .collect(Collectors.joining(",")); +if (sortPart.isEmpty()) { + sortPart = "default"; +}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java` around lines 71 - 78, The buildCacheKey method builds a key where sortPart can be empty when pageable.getSort() is unsorted; update buildCacheKey to detect an empty sortPart (from pageable.getSort() / sortPart) and substitute a clear default (e.g., "unsorted" or "none") so the returned key always contains an explicit sort token; locate buildCacheKey and the construction of sortPart (pageable.getSort().stream() ... Collectors.joining) and ensure the final String.format uses the non-empty/defaulted sort value.
29-35: findById의 Cache-Aside 패턴에서 캐시 저장 실패 시 로깅이 필요할 수 있다.
productCacheRepository.saveDetail()은 예외를 내부에서 흡수하나, 호출 측에서 캐시 저장 성공/실패를 인지할 수 없다. 현재 구현은 캐시 저장 실패해도 정상 응답하므로 기능적으로 문제없으나, 캐시 적중률 모니터링을 위해 메트릭 추가를 고려할 수 있다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java` around lines 29 - 35, In ProductFacade's cache-aside logic (the lambda used with productCacheRepository.getDetail and the call to productCacheRepository.saveDetail), add handling so failures to persist the cache are observable: either change productCacheRepository.saveDetail to return a boolean/Result and check it here, or wrap the existing saveDetail call in a try/catch that logs the error via the class logger (and/or increments a metric counter such as cacheSaveFailure) when an exception or false result occurs; ensure you reference ProductFacade, productCacheRepository.getDetail, productService.findById, brandService.findById and productCacheRepository.saveDetail so the failure is logged/recorded while still returning the ProductInfo as before.apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java (1)
51-53: 브랜드 수정/삭제 시 대량 상품 캐시 무효화 이벤트 발행 패턴을 배치 처리로 최적화 검토 권장현재 구현은
productIds.forEach(pid -> eventPublisher.publishEvent(new ProductDetailCacheEvictEvent(pid)))로 개별 이벤트를 발행한다. 이 패턴은 OrderFacade에서도 동일하게 사용되며, 이벤트는 AFTER_COMMIT 단계에서 안전하게 처리된다.현재 설계의 타당성:
findIdsByBrandIdAndDeletedAtIsNull은 SELECT p.id 구문으로 효율적 (메모리 효율적)- 1분 TTL + 낮은 관리자 수정 빈도 조건에서 개별 캐시 무효화는 허용 가능
- 브랜드명 수정 시 상품 목록/상세 캐시에 스냅샷된 브랜드명을 반영해야 하므로 정확한 무효화 필요
향후 최적화 방안:
상품 수가 증가하거나 관리자 수정이 빈번해진 후 Micrometer 메트릭으로 캐시 hit rate 측정 시 배치 무효화 이벤트(ProductDetailCacheBatchEvictEvent(List<Long> productIds)) 도입 검토한다. 현 단계에서는 모니터링과 필요시 추후 개선을 권장한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java` around lines 51 - 53, Replace the per-id event loop with a single batch event to avoid firing many events: keep publishing the existing ProductCacheEvictEvent, then publish one ProductDetailCacheBatchEvictEvent(productIds) instead of productIds.forEach(...). Ensure ProductDetailCacheBatchEvictEvent(List<Long> productIds) exists (or create it) and that eventPublisher.publishEvent is used with that batch event so downstream listeners can handle batch invalidation.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@apps/commerce-api/src/main/java/com/loopers/config/CacheProperties.java`:
- Around line 23-27: The compact constructor for record CacheProperties must
defensively copy and wrap ttlOverrides as an immutable map to prevent external
mutation; modify the compact constructor logic in CacheProperties to replace
direct new HashMap<>() allocation with an immutable copy (e.g., use
Map.copyOf(...) or Collections.unmodifiableMap(new HashMap<>(...))) so that
ttlOverrides is always stored as an unmodifiable map whether the incoming value
is null or provided, ensuring any provided map is copied and then made immutable
inside the CacheProperties compact constructor.
In `@apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java`:
- Around line 17-25: Product.java's `@Table/`@Index annotations are only metadata
because jpa.yml sets ddl-auto: none, so the four indexes declared
(idx_brand_deleted_created, idx_brand_deleted_price, idx_brand_deleted_likes,
idx_deleted_created) are not created in production; fix this by adding a Flyway
migration (under db/migration) or other deployment DDL that runs the equivalent
ALTER TABLE ... ADD INDEX statements for those index names/columns (as noted in
product-index-analysis.md), and after deployment verify with SHOW INDEX FROM
product; to ensure the indexes created successfully.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java`:
- Around line 56-59: In ProductCacheRepositoryImpl update the catch blocks that
call log.warn with e.getMessage() so the exception is logged with its
cause/stacktrace: change the logging invocation in the catch for the product
list cache lookup (and the other similar catch blocks in this class) to pass the
Throwable as the last parameter to the logger instead of only e.getMessage() so
the full stack trace and cause chain are recorded (i.e., keep the existing
message format and placeholders like "상품 목록 캐시 조회 실패 (key={}): {}" but add the
exception object as the final argument for methods in ProductCacheRepositoryImpl
where the current pattern uses e.getMessage()).
In `@modules/jpa/src/main/resources/jpa.yml`:
- Around line 39-43: 현재 jpa.yml에서 hibernate.ddl-auto가 기본(local) 프로필에서 고정된
값(validate)이라 빈 DB 환경에서 애플리케이션이 기동 실패할 수 있으니, hibernate.ddl-auto 설정을 환경변수로 오버라이드
가능하게 변경하거나 별도 프로필(local-init)을 추가해 초기 실행시 create를 사용하도록 하세요; 구체적으로 jpa.yml의
hibernate.ddl-auto 항목을 LOCAL_JPA_DDL_AUTO 환경변수(예:
${LOCAL_JPA_DDL_AUTO:validate})로 바꾸거나 local-init 프로필을 만들어 ddl-auto=create로 설정하고
이후 기본 local 프로필은 validate로 유지하도록 하고, 변경 후 LOCAL_JPA_DDL_AUTO=create로 빈 MySQL에서
최초 기동 테스트와 재기동(validate) 테스트를 수행해 동작을 확인하세요.
In `@scripts/test_cached.js`:
- Around line 1-4: The comment claims the test covers "page=0, 1 - 캐시 히트 구간" but
the script only requests page=0; either update the comment to reflect that only
page=0 is exercised, or modify the test to also issue requests for page=1 (e.g.,
add an HTTP request or loop that hits both "page=0" and "page=1" so the
cache-hit window is covered). Locate the part of the script that constructs the
request/query param "page=0" and either change the top comment text to match or
add the additional request for "page=1" (or a pages array loop) so both pages
are exercised as claimed.
---
Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.java`:
- Around line 51-53: Replace the per-id event loop with a single batch event to
avoid firing many events: keep publishing the existing ProductCacheEvictEvent,
then publish one ProductDetailCacheBatchEvictEvent(productIds) instead of
productIds.forEach(...). Ensure ProductDetailCacheBatchEvictEvent(List<Long>
productIds) exists (or create it) and that eventPublisher.publishEvent is used
with that batch event so downstream listeners can handle batch invalidation.
In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEventListener.java`:
- Around line 26-29: Add defensive exception handling to the
ProductCacheEventListener.handleProductDetailChanged method: wrap the call to
productCacheRepository.evictDetail(event.productId()) in a try-catch that
catches any Exception, logs the error with context including the productId and
the exception (using the class logger), and avoids rethrowing so the event
listener does not break transaction commit processing for other listeners;
follow the same pattern used for the other cache eviction handler in this class.
- Around line 20-23: The AFTER_COMMIT event handler handleProductChanged in
ProductCacheEventListener should defensively catch and handle any runtime
exceptions from productCacheRepository.evictAll() to avoid propagating errors
after commit; wrap the call in a try-catch, log the exception (using the class
Logger) with context like "failed to evict product cache" and swallow or handle
it so exceptions from future changes in ProductCacheRepositoryImpl.evictAll()
won't bubble up and affect callers.
In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailCacheEvictEvent.java`:
- Around line 8-9: The ProductDetailCacheEvictEvent record lacks a null check
for productId; add a compact canonical constructor to
ProductDetailCacheEvictEvent that validates productId (e.g., via
Objects.requireNonNull or explicit check) and throws a clear exception when null
is passed so the event cannot be created with a null productId.
In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java`:
- Around line 71-78: The buildCacheKey method builds a key where sortPart can be
empty when pageable.getSort() is unsorted; update buildCacheKey to detect an
empty sortPart (from pageable.getSort() / sortPart) and substitute a clear
default (e.g., "unsorted" or "none") so the returned key always contains an
explicit sort token; locate buildCacheKey and the construction of sortPart
(pageable.getSort().stream() ... Collectors.joining) and ensure the final
String.format uses the non-empty/defaulted sort value.
- Around line 29-35: In ProductFacade's cache-aside logic (the lambda used with
productCacheRepository.getDetail and the call to
productCacheRepository.saveDetail), add handling so failures to persist the
cache are observable: either change productCacheRepository.saveDetail to return
a boolean/Result and check it here, or wrap the existing saveDetail call in a
try/catch that logs the error via the class logger (and/or increments a metric
counter such as cacheSaveFailure) when an exception or false result occurs;
ensure you reference ProductFacade, productCacheRepository.getDetail,
productService.findById, brandService.findById and
productCacheRepository.saveDetail so the failure is logged/recorded while still
returning the ProductInfo as before.
In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductPageResult.java`:
- Around line 18-26: ProductPageResult.from currently uses page.getContent()
directly which risks mutable list leakage; change the constructor call to pass
an immutable defensive copy of the content (e.g., List.copyOf(page.getContent())
or Collections.unmodifiableList(new ArrayList<>(page.getContent()))) so callers
and caches cannot mutate the stored list; update ProductPageResult.from to wrap
page.getContent() with the chosen defensive-copy/immutability call and ensure
any constructor/field types accept List<ProductInfo>.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java`:
- Around line 82-85: ProductCacheRepositoryImpl currently iterates
cursor.hasNext() and calls connection.del(key) per key which causes N Redis
round-trips; instead collect keys from the cursor into a list (e.g.,
List<byte[]> or List<String>) and perform a batched non-blocking delete using
connection.unlink(keys) if available or connection.del(Collection) in one call;
update the scan handling (the block around connection.scan(options), cursor and
connection.del(cursor.next())) to accumulate keys and then call
connection.unlink(keys) (or connection.del(keys) as fallback) to minimize
round-trips and avoid blocking Redis during large cache invalidations.
In `@apps/commerce-api/src/main/resources/application.yml`:
- Around line 32-38: Current application.yml cache settings apply to all
profiles and can cause test isolation issues; modify configuration to disable or
reduce caching for the test profile by moving/overriding the cache keys under a
"test" profile (e.g., set cache.max-cacheable-page: 0 or disable cache TTLs) so
tests run without cached responses, or alternatively document in tests to use
`@DirtiesContext` or explicitly clear the cache before/after tests; change the
YAML to include a test profile override for the cache block (target keys:
cache.default-ttl-seconds, cache.jitter-range-seconds, cache.max-cacheable-page,
cache.ttl-overrides) so test runs use the adjusted values.
In `@scripts/cache_test.js`:
- Line 23: Replace the hardcoded BASE_URL constant with an environment-driven
value: change the BASE_URL declaration so it reads from process.env.BASE_URL
(with a sensible default like 'http://localhost:8080' if not set), update any
tests that reference BASE_URL to use the new constant, and ensure test
setup/documentation mentions setting BASE_URL (mirroring the approach used in
test_uncached.js); modify the BASE_URL constant declaration in
scripts/cache_test.js accordingly.
In `@scripts/test_cached.js`:
- Around line 27-31: The test_cached.js file hardcodes the base URL in the
exported default function (see the http.get call), so update the function to
read a BASE_URL from the environment (e.g., __ENV.BASE_URL) with a sensible
fallback and use that when building the request URI for
http.get('/api/v1/products?page=0&sort=latest'); ensure the rest of the function
(duration.add, check, sleep) continues to use the response object unchanged;
replace the literal 'http://localhost:8080' with the composed BASE_URL variable
wherever used.
In `@scripts/test_uncached.js`:
- Around line 27-31: The test currently hardcodes 'http://localhost:8080' inside
the exported default function and should use a k6 environment variable instead:
read __ENV.BASE_URL (falling back to 'http://localhost:8080' if unset) and build
the request URL from that base before calling http.get; update the URL usage in
the exported default function (where http.get(...) is called) to use the
constructed base + '/api/v1/products?page=10&sort=latest' so the script can be
run in CI/staging by passing BASE_URL.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: e9550a76-75b1-4d48-b30d-dc92a5043bcd
⛔ Files ignored due to path filters (1)
.docs/product-index-analysis.mdis excluded by!**/*.mdand included by**
📒 Files selected for processing (24)
apps/commerce-api/src/main/java/com/loopers/application/brand/BrandAdminFacade.javaapps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductAdminFacade.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEventListener.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheEvictEvent.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheRepository.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductDetailCacheEvictEvent.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductPageResult.javaapps/commerce-api/src/main/java/com/loopers/config/CacheConfig.javaapps/commerce-api/src/main/java/com/loopers/config/CacheProperties.javaapps/commerce-api/src/main/java/com/loopers/domain/product/Product.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.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/main/resources/application.ymlmodules/jpa/src/main/resources/jpa.ymlscripts/cache_test.jsscripts/report_cached.htmlscripts/report_uncached.htmlscripts/test_cached.jsscripts/test_extreme.jsscripts/test_uncached.js
| public CacheProperties { | ||
| if (ttlOverrides == null) { | ||
| ttlOverrides = new HashMap<>(); | ||
| } | ||
| } |
There was a problem hiding this comment.
ttlOverrides Map의 불변성을 보장해야 한다.
compact constructor에서 new HashMap<>()을 직접 할당하면, record 필드는 final이지만 Map 내용은 외부에서 수정 가능하다. @ConfigurationProperties로 바인딩된 설정 객체는 런타임에 변경되지 않아야 한다.
운영 관점 문제점:
- 실수로
cacheProperties.ttlOverrides().put(...)호출 시 전역 설정 오염 - 테스트에서 설정 변경 시 다른 테스트에 영향
🛡️ 불변 Map으로 감싸기
public CacheProperties {
if (ttlOverrides == null) {
- ttlOverrides = new HashMap<>();
+ ttlOverrides = Map.of();
+ } else {
+ ttlOverrides = Map.copyOf(ttlOverrides);
}
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| public CacheProperties { | |
| if (ttlOverrides == null) { | |
| ttlOverrides = new HashMap<>(); | |
| } | |
| } | |
| public CacheProperties { | |
| if (ttlOverrides == null) { | |
| ttlOverrides = Map.of(); | |
| } else { | |
| ttlOverrides = Map.copyOf(ttlOverrides); | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/commerce-api/src/main/java/com/loopers/config/CacheProperties.java`
around lines 23 - 27, The compact constructor for record CacheProperties must
defensively copy and wrap ttlOverrides as an immutable map to prevent external
mutation; modify the compact constructor logic in CacheProperties to replace
direct new HashMap<>() allocation with an immutable copy (e.g., use
Map.copyOf(...) or Collections.unmodifiableMap(new HashMap<>(...))) so that
ttlOverrides is always stored as an unmodifiable map whether the incoming value
is null or provided, ensuring any provided map is copied and then made immutable
inside the CacheProperties compact constructor.
| @Table( | ||
| name = "product", | ||
| indexes = { | ||
| @Index(name = "idx_brand_deleted_created", columnList = "brand_id, deleted_at, created_at DESC"), | ||
| @Index(name = "idx_brand_deleted_price", columnList = "brand_id, deleted_at, price"), | ||
| @Index(name = "idx_brand_deleted_likes", columnList = "brand_id, deleted_at, like_count DESC"), | ||
| @Index(name = "idx_deleted_created", columnList = "deleted_at, created_at DESC") | ||
| } | ||
| ) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Hibernate가 생성하는 실제 DDL 확인
rg -n "ddl-auto|hbm2ddl" --type=yaml apps/commerce-api/src/main/resources/Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Find application configuration files
find apps/commerce-api/src/main/resources -type f \( -name "application*.yml" -o -name "application*.properties" -o -name "*.properties" \) -exec head -50 {} + 2>/dev/null | head -100Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 1246
🏁 Script executed:
#!/bin/bash
# Check for Flyway migration directory
fd -t d "db|migration|sql" apps/commerce-api/src/main/resources --max-depth 3Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# View Product.java file to understand index definitions
cat -n apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2147
🏁 Script executed:
#!/bin/bash
# Check Hibernate version in pom.xml or build config
rg "hibernate|jpa" -A 2 apps/commerce-api/pom.xml 2>/dev/null | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Check jpa.yml file
cat -n apps/commerce-api/src/main/resources/jpa.ymlRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 161
🏁 Script executed:
#!/bin/bash
# Check pom.xml for Hibernate/JPA dependencies
cat apps/commerce-api/pom.xml | grep -A 3 -B 1 "hibernate\|spring-boot-starter-data-jpa\|flyway" | head -100Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 142
🏁 Script executed:
#!/bin/bash
# Search for any database-related files (schema, migration, init)
find apps/commerce-api -type f \( -name "*.sql" -o -name "schema*" -o -name "*migration*" \) 2>/dev/nullRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Check for Flyway in parent pom.xml
cat pom.xml 2>/dev/null | grep -A 3 -B 1 "flyway" | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Check directory structure
ls -la apps/commerce-api/src/main/resources/ 2>/dev/null | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 260
🏁 Script executed:
#!/bin/bash
# Find all YAML files
find apps/commerce-api/src/main/resources -name "*.yml" -o -name "*.yaml" 2>/dev/nullRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 137
🏁 Script executed:
#!/bin/bash
# Check parent pom.xml structure
cat pom.xml | head -100Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 124
🏁 Script executed:
#!/bin/bash
# Check if jpa.yml exists anywhere in the repo
find . -name "jpa.yml" 2>/dev/nullRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 125
🏁 Script executed:
#!/bin/bash
# List all application config files
find . -path "*/src/main/resources/*" -name "application*" -type f 2>/dev/nullRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 256
🏁 Script executed:
#!/bin/bash
# Read jpa.yml file
cat -n ./modules/jpa/src/main/resources/jpa.ymlRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2993
🏁 Script executed:
#!/bin/bash
# Check for build files (gradle, maven)
find . -maxdepth 2 -name "build.gradle*" -o -name "pom.xml" 2>/dev/null | head -10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 103
🏁 Script executed:
#!/bin/bash
# Look for Flyway migration directories
find . -type d -name "db" -o -type d -name "migration" -o -type d -name "flyway" 2>/dev/null | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Search for schema.sql or database initialization files
find . -name "schema.sql" -o -name "init*.sql" -o -name "database.sql" 2>/dev/nullRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Check build.gradle.kts for Flyway/Liquibase
cat build.gradle.kts | grep -i "flyway\|liquibase" -A 2 -B 2Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Check if there are any database schema files at all
find . -name "*.sql" 2>/dev/null | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Check gradle subprojects or modules that might handle database
find . -maxdepth 2 -name "build.gradle.kts" -exec grep -l "flyway\|liquibase\|database" {} \; 2>/dev/nullRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Check README or documentation for schema management
find . -maxdepth 3 -name "README*" -o -name "*.md" | xargs grep -l "schema\|database\|ddl\|migration" 2>/dev/null | head -10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Check if there's a separate directory for database scripts
ls -la . | grep -i "db\|database\|schema\|sql\|migration"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Look for any reference to database initialization in gradle files
find . -name "build.gradle.kts" -exec cat {} \; 2>/dev/null | grep -i "database\|schema" -A 2 -B 2 | head -50Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Check git for any deleted SQL files that might have existed
git log --name-only --pretty=format: -- "*.sql" 2>/dev/null | sort -u | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Check if there's actual database schema in git tracked files
git ls-files | grep -E "\.sql|\.ddl|schema|migration" 2>/dev/null | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Check the actual Hibernate version in gradle
cat build.gradle.kts | grep -i "spring\|hibernate" | head -30Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 957
🏁 Script executed:
#!/bin/bash
# Look for any documentation about how indexes are managed
find . -type f \( -name "*.md" -o -name "*.txt" -o -name "*.rst" \) -exec grep -l "index\|schema\|ddl" {} \; 2>/dev/null | head -10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 118
🏁 Script executed:
#!/bin/bash
# Check if indexes are referenced anywhere in repository documentation
rg "idx_brand_deleted_created|idx_deleted_created|@Index" --type=md --type=txt --type=yaml 2>/dev/null | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Read the product index analysis documentation
cat -n ./.docs/product-index-analysis.mdRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 17488
🏁 Script executed:
#!/bin/bash
# Check if there's any schema initialization or management documentation
find .docs -type f \( -name "*.md" -o -name "*.txt" \) -exec cat {} \; 2>/dev/null | head -200Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4140
🏁 Script executed:
#!/bin/bash
# Verify if there's any SQL execution or initialization mechanism for creating indexes
rg "CREATE INDEX|ALTER TABLE.*ADD INDEX" --type=sql --type=txt 2>/dev/null | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
🏁 Script executed:
#!/bin/bash
# Check if there are any scripts or documentation about database initialization
find . -maxdepth 3 -type f \( -name "*.sh" -o -name "*init*" -o -name "*setup*" \) 2>/dev/null | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 67
@Index 정의가 생산 환경에 적용되지 않는 문제
jpa.yml에서 ddl-auto: none으로 설정되어 있어 Hibernate가 DDL을 자동 생성하지 않는다. 따라서 Product.java의 @Index 애너테이션은 메타데이터일 뿐 실제 데이터베이스 인덱스 생성에 영향을 주지 않는다. product-index-analysis.md에서 분석한 4개의 인덱스가 생산 데이터베이스에 실제로 생성되려면, Flyway 마이그레이션 또는 명시적인 DDL 스크립트로 관리해야 한다.
권장 조치:
db/migration/디렉토리를 생성하고 Flyway를 통해 인덱스 DDL을 관리하거나- 데이터베이스 초기화 스크립트에 아래 DDL을 추가하여 배포 시 인덱스 자동 생성 확보:
ALTER TABLE product ADD INDEX idx_brand_deleted_created (brand_id, deleted_at, created_at DESC);
ALTER TABLE product ADD INDEX idx_brand_deleted_price (brand_id, deleted_at, price);
ALTER TABLE product ADD INDEX idx_brand_deleted_likes (brand_id, deleted_at, like_count DESC);
ALTER TABLE product ADD INDEX idx_deleted_created (deleted_at, created_at DESC);- 배포 후 각 환경에서
SHOW INDEX FROM product;로 인덱스 생성 여부 검증
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java`
around lines 17 - 25, Product.java's `@Table/`@Index annotations are only metadata
because jpa.yml sets ddl-auto: none, so the four indexes declared
(idx_brand_deleted_created, idx_brand_deleted_price, idx_brand_deleted_likes,
idx_deleted_created) are not created in production; fix this by adding a Flyway
migration (under db/migration) or other deployment DDL that runs the equivalent
ALTER TABLE ... ADD INDEX statements for those index names/columns (as noted in
product-index-analysis.md), and after deployment verify with SHOW INDEX FROM
product; to ensure the indexes created successfully.
| } catch (Exception e) { | ||
| log.warn("상품 목록 캐시 조회 실패 (key={}): {}", cacheKey, e.getMessage()); | ||
| return Optional.empty(); | ||
| } |
There was a problem hiding this comment.
예외 로깅 시 cause를 포함해야 한다.
현재 e.getMessage()만 로깅하면 스택 트레이스가 누락되어 디버깅이 어렵다. 특히 직렬화 실패 원인 파악 시 cause chain이 필요하다.
🔧 cause 포함 로깅
} catch (Exception e) {
- log.warn("상품 목록 캐시 조회 실패 (key={}): {}", cacheKey, e.getMessage());
+ log.warn("상품 목록 캐시 조회 실패 (key={})", cacheKey, e);
return Optional.empty();
}동일한 패턴이 Lines 68-69, 86-87, 91-92, 104-105, 116-117, 125-126에도 적용되어야 한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheRepositoryImpl.java`
around lines 56 - 59, In ProductCacheRepositoryImpl update the catch blocks that
call log.warn with e.getMessage() so the exception is logged with its
cause/stacktrace: change the logging invocation in the catch for the product
list cache lookup (and the other similar catch blocks in this class) to pass the
Throwable as the last parameter to the logger instead of only e.getMessage() so
the full stack trace and cause chain are recorded (i.e., keep the existing
message format and placeholders like "상품 목록 캐시 조회 실패 (key={}): {}" but add the
exception object as the final argument for methods in ProductCacheRepositoryImpl
where the current pattern uses e.getMessage()).
| properties: | ||
| hibernate: | ||
| generate_statistics: true | ||
| hibernate: | ||
| ddl-auto: create | ||
| ddl-auto: validate |
There was a problem hiding this comment.
로컬 기본 프로필에서 ddl-auto: validate 고정은 기동 장애를 유발할 수 있다
Line [43]에서 local 프로필을 validate로 고정하면, 빈 DB 기준으로 API/배치 앱이 즉시 기동 실패할 수 있어 개발/장애 재현 환경의 가용성이 떨어진다. 특히 apps/commerce-api/src/main/resources/application.yml와 apps/commerce-batch/src/main/resources/application.yml 모두 기본 프로필이 local이고, docker/infra-compose.yml에 스키마 초기화가 없어서 초기 실행 실패 가능성이 높다.
수정안으로 local에서만 DDL 전략을 오버라이드 가능하게 열어두거나(LOCAL_JPA_DDL_AUTO), local-init 프로필을 분리해 초기 1회 생성 후 validate로 전환하는 흐름을 명시하는 것이 안전하다.
🔧 제안 수정안
spring:
jpa:
show-sql: true
properties:
hibernate:
generate_statistics: true
hibernate:
- ddl-auto: validate
+ ddl-auto: ${LOCAL_JPA_DDL_AUTO:validate}추가 테스트는 아래 2가지를 권장한다.
- 빈 MySQL에서
local + LOCAL_JPA_DDL_AUTO=create로 첫 기동 성공 여부 확인한다. - 스키마 생성 후
local + LOCAL_JPA_DDL_AUTO=validate로 재기동 성공 여부 및 엔티티/인덱스 검증 동작 확인한다.
📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| properties: | |
| hibernate: | |
| generate_statistics: true | |
| hibernate: | |
| ddl-auto: create | |
| ddl-auto: validate | |
| properties: | |
| hibernate: | |
| generate_statistics: true | |
| hibernate: | |
| ddl-auto: ${LOCAL_JPA_DDL_AUTO:validate} |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@modules/jpa/src/main/resources/jpa.yml` around lines 39 - 43, 현재 jpa.yml에서
hibernate.ddl-auto가 기본(local) 프로필에서 고정된 값(validate)이라 빈 DB 환경에서 애플리케이션이 기동 실패할 수
있으니, hibernate.ddl-auto 설정을 환경변수로 오버라이드 가능하게 변경하거나 별도 프로필(local-init)을 추가해 초기
실행시 create를 사용하도록 하세요; 구체적으로 jpa.yml의 hibernate.ddl-auto 항목을 LOCAL_JPA_DDL_AUTO
환경변수(예: ${LOCAL_JPA_DDL_AUTO:validate})로 바꾸거나 local-init 프로필을 만들어
ddl-auto=create로 설정하고 이후 기본 local 프로필은 validate로 유지하도록 하고, 변경 후
LOCAL_JPA_DDL_AUTO=create로 빈 MySQL에서 최초 기동 테스트와 재기동(validate) 테스트를 수행해 동작을
확인하세요.
| /** | ||
| * 캐시 적용 페이지 부하 테스트 (page=0, 1 - 캐시 히트 구간) | ||
| * 실행: K6_WEB_DASHBOARD=true k6 run scripts/test_cached.js | ||
| */ |
There was a problem hiding this comment.
주석과 실제 테스트 범위 불일치
주석에는 "page=0, 1 - 캐시 히트 구간"이라고 명시되어 있으나, 실제 테스트는 page=0만 호출한다. 의도적으로 단일 페이지만 테스트하는 것이라면 주석을 수정하고, page=1도 테스트해야 한다면 요청을 추가해야 한다.
📝 주석 수정 또는 테스트 추가
Option 1: 주석 수정 (page=0만 테스트)
/**
- * 캐시 적용 페이지 부하 테스트 (page=0, 1 - 캐시 히트 구간)
+ * 캐시 적용 페이지 부하 테스트 (page=0 - 캐시 히트 구간)
* 실행: K6_WEB_DASHBOARD=true k6 run scripts/test_cached.js
*/Option 2: page=1 테스트 추가
export default function () {
const res = http.get('http://localhost:8080/api/v1/products?page=0&sort=latest');
duration.add(res.timings.duration);
check(res, { 'status 200': (r) => r.status === 200 });
+
+ const res2 = http.get('http://localhost:8080/api/v1/products?page=1&sort=latest');
+ duration.add(res2.timings.duration);
+ check(res2, { 'page=1 status 200': (r) => r.status === 200 });
+
sleep(0.5);
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /** | |
| * 캐시 적용 페이지 부하 테스트 (page=0, 1 - 캐시 히트 구간) | |
| * 실행: K6_WEB_DASHBOARD=true k6 run scripts/test_cached.js | |
| */ | |
| /** | |
| * 캐시 적용 페이지 부하 테스트 (page=0 - 캐시 히트 구간) | |
| * 실행: K6_WEB_DASHBOARD=true k6 run scripts/test_cached.js | |
| */ |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@scripts/test_cached.js` around lines 1 - 4, The comment claims the test
covers "page=0, 1 - 캐시 히트 구간" but the script only requests page=0; either update
the comment to reflect that only page=0 is exercised, or modify the test to also
issue requests for page=1 (e.g., add an HTTP request or loop that hits both
"page=0" and "page=1" so the cache-hit window is covered). Locate the part of
the script that constructs the request/query param "page=0" and either change
the top comment text to match or add the additional request for "page=1" (or a
pages array loop) so both pages are exercised as claimed.
📌 Summary
brandId필터 + 정렬 조건 조합으로 8가지 쿼리 패턴이 발생합니다. 인덱스 없이 Full Table Scan + filesort로 쿼리당 30~38ms가 소요되었고, 동일한(brandId, sort, page)요청이 반복되어도 매번 DB를 조회했습니다. 300명 동시 요청 시 HikariCP 커넥션 풀(40개)이 고갈되어 요청 대기열이 쌓이고 p(95) 2,450ms / 에러율 1.44%까지 치솟았습니다.Using index condition전환으로 주요 쿼리 96% 단축(38ms → 1.3~2.5ms). 캐시 적용 후 p(95) 66배 개선(2,450ms → 37ms), 처리량 2.6배 향상(132 → 345 req/s), 에러율 1.44% → 0%.🧭 Context & Decision
🔖 Index
문제 정의
brandId필터 및created_at DESC / price ASC / like_count DESC정렬 조건 모두 Full Table Scan + filesort 발생Page조회는 데이터 쿼리 + COUNT 쿼리를 동시에 실행하므로 API 호출 1회 = DB 쿼리 2회type: ALL제거 및Using index condition달성인덱스 개수 — 얼마나 선언할 것인가?
가장 먼저 고민한 것은 "인덱스를 몇 개 선언해야 하는가" 였습니다. 인덱스는 쓰기 시 B-Tree 갱신 비용이 발생하므로 무조건 많다고 좋지 않다고 생각했습니다.
유즈케이스를 추정한 결과,
brandId 유무 × 3가지 정렬로 쿼리 패턴이 나뉘고, B-Tree 인덱스는brand_id가 중간에 끼면 전체created_at DESC정렬에 사용할 수 없는 구조적 한계가 있습니다. 따라서brandId 있음과brandId 없음을 하나의 인덱스로 커버하는 것은 불가능하다고 판단했습다.결국
brandId 있음 3개 + brandId 없음 중 사용 빈도 높은 1개 = 4개로 결정했습니다.선택지와 결정
고려한 대안
(brand_id)인덱스:brand_id로 특정 브랜드 상품만 추려낸 뒤 행마다 힙 페이지에 접근해deleted_at IS NULL을 체크하고, 통과한 행들을 다시 정렬하는 방식입니다. 브랜드 필터를 포함한 최신순 정렬 쿼리 기준 13.43ms로, 인덱스가 없는 것보다는 낫지만 정렬 비용이 여전히 남아 있습니다.(brand_id, deleted_at, sort_column)인덱스: 등가(=) → IS NULL → 정렬 컬럼 순서로 선언하고, 인덱스 정렬 방향을ORDER BY방향과 일치시켜 ICP(Index Condition Pushdown)를 활성화했습니다. 동일 조건 기준 1.64ms로, 단일 인덱스 대비 8배 차이가 났습니다.deleted_at인덱스 제외: soft delete 컬럼이라NULL아니면 날짜값으로 카디널리티가 낮아 인덱스에 포함할 필요가 없다고 생각했습니다. 실제로 제외한 채 테스트한 결과, 전체 상품 수를 조회하는 COUNT 쿼리가 인덱스를 전혀 사용하지 못하고 Full Table Scan으로 회귀하였고, 데이터 행마다 힙 페이지에 접근하는 비용도 증가함을 확인했습니다.최종 결정: B. 복합 인덱스 4개 채택 (
idx_brand_deleted_created,idx_brand_deleted_price,idx_brand_deleted_likes,idx_deleted_created)트레이드오프:
하지만 1,000만 건 이상이 되어 버퍼풀이 전체 데이터를 담지 못하는 시점부터는 커버링 인덱스가 유리해집니다.
추후 개선 여지:
idx_deleted_price,idx_deleted_likes2개는 미선언 상태입니다. 브랜드 없이 전체 가격순/좋아요순으로 조회하는 요청의 실사용 빈도를 확인한 뒤 추가 여부를 결정할 예정입니다.⚡ Cache
문제 정의
(brandId, sort, page)조합 요청이 반복될 때마다 DB 쿼리 발생[결정 1] 캐시 추상화 방식 —
@CacheablevsRedisTemplate직접 사용@Cacheable어노테이션: Spring Cache 추상화로 선언만으로 캐싱 가능. 그러나Page<ProductInfo>를 캐시 값으로 쓸 경우 Jackson 역직렬화 실패 문제가 있다(PageImpl생성자가 Jackson 비친화적).또한 캐시 동작(TTL, evict 시점)이 어노테이션 뒤에 숨어 테스트·디버깅이 어렵습니다.
RedisTemplate직접 사용: 직렬화 방식, TTL, 예외 처리를 명시적으로 제어 가능.Page<T>대신ProductPageResultrecord로 래핑해 역직렬화 문제 해결ProductCacheRepository인터페이스를 포트로 선언하고 구현체는 infrastructure에 위치시켜 DIP를 준수했다[결정 2] 캐시 스탬피드 (Thunder Herd) — 어떻게 막을 것인가?
TTL이 동일한 키들이 같은 시각에 만료되면, 수백 개의 요청이 동시에 캐시 미스를 경험하고 모두 DB로 쏟아집니다.
A. 모든 키에 동일한 TTL: 구현이 가장 단순하지만 동시 만료 시 캐시 스템피드 문제를 발생시키는 주된 원인입니다.
B. Bounded Jitter TTL:
base ± jitterRange범위의 무작위 TTL 부여합니다 → 키마다 만료 시점이 분산되어 자연 만료 시 동시 만료가 방지됩니다.C. Redis 분산 락: 캐시 미스 시 한 스레드만 DB를 조회하고 나머지는 잠금 해제를 기다립니다.
evictAll()직후 스탬피드까지 완전 차단이 가능합니다. 그러나 분산 락 구현 복잡도가 높고 현재 단계에서 학습 범위를 초과한다고 판단최종 결정: B 채택. 자연 만료(TTL 만료)로 인한 동시 폭격은 방지 가능합니다.
evictAll()직후 스탬피드는 분산 락 없이 완전 해결이 어렵다는 점을 인지하고 허용 가능한 트레이드오프로 판단했습니다.추후 개선 여지: Redisson 기반 분산 락으로
evictAll()직후 스탬피드까지 차단 가능하게 구현할 수 있습니다.[결정 3] 캐시 정합성 — 쓰기 시 무효화 타이밍
DB 쓰기 중 다른 스레드가 구버전 데이터를 캐시에 저장하는 레이스 컨디션이 발생할 수 있습니다.
A. 트랜잭션 내 캐시 삭제 (기본 방식): 위 시나리오처럼 커밋 전에 삭제하면 커밋 전 데이터가 캐시에 올라갈 수 있습니다
B.
@TransactionalEventListener(AFTER_COMMIT): DB 커밋이 완전히 완료된 후 이벤트 처리로 캐시를 무효화합니다. 커밋 전 데이터 유입 차단C. Delayed Double Delete: 캐시를 즉시 삭제한 뒤 일정 시간 후 한 번 더 삭제합니다. Thread B가 읽기~SET 사이에 구버전을 저장해도 두 번째 삭제로 정리 가능합니다.
단, 딜레이 동안 구버전이 데이터가 또 다시 캐싱될 수 있고 비동기 처리 복잡도가 추가됩니다.
D. B + C 동시 적용: AFTER_COMMIT으로 커밋 전 유입을 막고, Delayed Double Delete로 AFTER_COMMIT 이후의 극단적 레이스(Thread B의 읽기~SET 간격에 커밋+무효화가 끼어드는 경우)까지 추가로 방어 가능합니다.
최종 결정: B 채택. D는 방어 깊이가 깊지만 Thread B의 레이스 발생 확률 자체가 극히 낮고, 발생해도 TTL 만료로 자연 복구된다는 점이 어느 정도 해결책이라고 생각했습니다. 복잡도 증가 대비 실익이 적다고 판단했습니다
트레이드오프:
AFTER_COMMIT이후에도 Thread B가 캐시 미스 → 구버전 DB 읽기 → SET 하는 사이에 Thread A의 커밋+무효화가 완료되는 극단적 레이스는 이론적으로 잔존합니다. 현실적으로 TTL 만료 시 자동 복구되므로 허용 가능한 수준으로 판단했습니다[결정 4] 딥 페이징 — 모든 페이지를 캐싱해야 하는가?
A. 모든 page 번호 캐싱: page=100, 200 등 뒤 페이지까지 모두 캐싱하면 히트율이 낮은 키가 메모리를 낭비한다.
(brandId 22가지 × sort 3가지 × page N개)로 키 수가 무제한으로 증가하는 캐시 키 폭발 문제도 있습니다.B. 앞 N페이지만 캐싱 (
maxCacheablePage): 실제 사용 트래픽의 대부분은 초반 페이지에 집중된다는 가정하에, page 0~2만 캐싱하고 그 이후는 DB 직접 조회로 처리. 최대 키 수 = 22 × 3 × 3 = 198개로 제한C. 커서 기반 페이지네이션: OFFSET 연산 자체가
N+LIMIT건을 읽고 N건을 버리는 비효율을 근본적으로 제거.그러나 현재 프로젝트가 이미 OFFSET 기반으로 설계되어 있어 이 부분은 고려하지 않았습니다.
최종 결정: B 채택.
maxCacheablePage=3으로 page 0~2만 캐싱.트레이드오프: OFFSET 기반 딥 페이징의 연산 비용 자체는 캐시로 해결되지 않습니다. page=10의
OFFSET 200 LIMIT 20은 여전히 220건을 읽고 200건을 버린다추후 개선 여지: 커서 기반 페이지네이션 또는 Deferred Join으로 근본 해결 가능
🏗️Design Overview
변경 범위
application/product,infrastructure/product,config,interfaces/api/productProductCacheRepository(포트),ProductCacheRepositoryImpl(어댑터),ProductPageResult,CacheProperties,CacheConfig,ProductCacheEvictEvent,ProductCacheEventListenerProductFacade의Page<ProductInfo>반환 →ProductPageResultrecord로 교체 (Jackson 역직렬화 문제 해결)주요 컴포넌트 책임
ProductCacheRepository(인터페이스): application 레이어의 캐시 포트.getList/saveList/evictAll3가지 오퍼레이션 노출. Redis를 모름ProductCacheRepositoryImpl: RedisTemplate 기반 구현체. 읽기는REPLICA_PREFERRED(Replica), 쓰기/삭제는MASTER. SCAN 기반evictAll()로KEYS *블로킹 명령어 대체. 모든 예외 내부 흡수 → Redis 장애 시 캐시 미스로 처리
CacheProperties:base ± jitterTTL 계산 및isCacheable(pageNumber)딥 페이징 분기 책임ProductCacheEventListener:@TransactionalEventListener(AFTER_COMMIT)— DB 커밋 완료 후 캐시 무효화 보장ProductAdminFacade: Redis 직접 의존 제거 →ApplicationEventPublisher로 이벤트 발행만 담당🔁 Flow Diagram
Cache-Aside 읽기 흐름 (Main Flow)
sequenceDiagram autonumber participant Client participant ProductV1Controller participant ProductFacade participant ProductCacheRepository participant Redis participant ProductService participant DB Client->>ProductV1Controller: GET /api/v1/products?page=0&sort=latest ProductV1Controller->>ProductFacade: findAll(brandId, pageable) alt 딥 페이징 (page >= maxCacheablePage=3) ProductFacade->>ProductService: findAll() ProductService->>DB: SELECT ... OFFSET N DB-->>ProductFacade: Page<Product> ProductFacade-->>Client: ProductPageResult (캐시 미적용) else 캐시 대상 구간 (page 0~2) ProductFacade->>ProductCacheRepository: getList(cacheKey) ProductCacheRepository->>Redis: GET loopers:product:list:... alt 캐시 히트 Redis-->>ProductFacade: ProductPageResult ProductFacade-->>Client: ProductPageResult (DB 접근 없음) else 캐시 미스 (또는 Redis 장애 → fallback) Redis-->>ProductCacheRepository: null / Exception ProductCacheRepository-->>ProductFacade: Optional.empty() ProductFacade->>ProductService: findAll() ProductService->>DB: SELECT ... DB-->>ProductFacade: Page<Product> ProductFacade->>ProductCacheRepository: saveList(cacheKey, result) ProductCacheRepository->>Redis: SET ... EX (base ± jitter초) ProductFacade-->>Client: ProductPageResult end end캐시 무효화 흐름 (Write-Around + AFTER_COMMIT)
🔗 핵심 코드
1. 복합 인덱스 선언
Product.java L17–L25컬럼 순서는
[등가(=) 조건] → [IS NULL 조건] → [정렬 컬럼]원칙을 따릅니다.B-Tree는 컬럼 순서대로 정렬되기 때문에 이 순서가 어긋나면 인덱스에서 정렬을 얻지 못해
Using filesort가 발생합니다.정렬 방향(
DESC)도 쿼리의ORDER BY방향과 일치시켜 ICP(Index Condition Pushdown)를 활성화 시켰습니다.brandId 없음케이스를 위한 인덱스(idx_deleted_created) 1개만 추가한 이유는, 브랜드 필터를 걸지 않는 유즈케이스 중에 가장 빈번하게 일어날 것이라고 추정했습니다.2. Cache-Aside 패턴 — 딥 페이징 분기 포함
ProductFacade.java L34–L74isCacheable()로 page 0~2만 캐싱하고, 그 이후 페이지는 DB를 직접 조회합니다.모든 페이지를 캐싱하면 히트되지 않는 키가 메모리를 낭비하고,
(brandId × sort × page)조합으로 캐시 키가 무제한 증가하는 키 폭발 문제가 생길것이라고 생각했습니다.캐시 키는
{서비스}:{도메인}:{오퍼레이션}:{파라미터}계층 구조로 설계해 패턴 기반evictAll()이 가능하도록 했습니다.3. Bounded Jitter TTL — Cache Stampede 완화
CacheProperties.java L29–L37모든 캐시 키에 동일한 TTL을 부여하면, 같은 시각에 대량의 키가 동시에 만료되어 수백 개의 요청이 한꺼번에 DB를 조회하는 Cache Stampede가 발생합니다.
base ± jitterRange범위의 무작위 TTL을 키마다 부여해 만료 시점을 분산시킴으로써, 자연 만료로 인한 동시 DB 부하 증가를 완화하였습니다.ttlOverrides를 통해 캐시 이름별로 TTL을 재정의할 수 있으며, 현재product:list에는 기본 300초 대신 180초를 적용하고 있습니다.4. AFTER_COMMIT 무효화 이벤트
ProductCacheEventListener.java L14–L23ProductAdminFacade는 Redis를 직접 알지 못하고, 상품 변경 시ProductCacheEvictEvent만 발행합니다.스프링 이벤트를 경유함으로써 쓰기 레이어(application)와 캐시 무효화 레이어(infrastructure)의 결합을 제거했습니다.
AFTER_COMMIT은 "DB 커밋이 완전히 완료된 후에만 캐시를 삭제한다"는 보장을 제공해, 커밋 전 구버전 데이터가 캐시에 유입되는 레이스 컨디션을 차단합니다.k6 부하 테스트 결과 (300 VU)
첨부 자료
인덱스 성능 분석 보고서
.docs/product-index-analysis.mdk6 부하 테스트 스크립트 및 결과 리포트
scripts/test_cached.jsscripts/test_uncached.jsscripts/cache_test.js체크리스트
🔖 Index
❤️ Structure
⚡ Cache
리뷰포인트!!!
1. 인덱스 선언 기준
멘토링 시간에 참조 ID와 정렬 컬럼은 꼭 인덱스에 들어가야 한다는 피드백을 듣고, (brand_id, deleted_at, 정렬 컬럼 3종) 인덱스는 반드시 필요하다고 판단하여 이렇게 선언하였습니다. 다만 브랜드 필터를 걸지 않는 케이스는 위 3개 인덱스를 타지 못하기 때문에, 동일하게 정렬 케이스별로 3개를 추가 선언하면 좋겠지만 인덱스가 너무 많아진다는 생각이 들었습니다. 결국 가장 요청이 빈번할 것으로 판단한 전체 상품 최신순 조회 케이스 1개만 추가로 선언했습니다. 실무에서도 참조 ID와 정렬 컬럼은 기본으로 인덱스를 선언하고, 그 외 케이스는 실제로 병목이 발생했을 때 그때그때 대응하는 방식으로 운영하는지 궁금합니다.
2. 캐시 전략
현재 상품 목록 전체 데이터를 캐싱하고 있습니다. 관리자가 상품 정보를 수정하면 캐시를 evict하는 방식을 적용했고, 좋아요처럼 빈번하게 발생하거나 주문으로 인한 재고 차감처럼 상대적으로 덜 빈번한 경우에는 evict 없이 TTL에 의존하고 있습니다. 실무마다 다르겠지만, 멘토님 회사에서는 어떤 전략을 쓰고 계신지 궁금합니다. 상품 어떤 정보가 바뀌든 항상 목록 캐시를 evict 후 재저장하는 방식을 쓰시는지, 아니면 TTL에 의존하여 stale 데이터를 허용하시는지 궁금합니다. 또 멘토링 때 상품 목록 페이지에서 가격 등 상세 정보는 별도의 캐시 전략으로 가져가는 경우도 있다고 하셨는데, 대략적인 방법이 궁금합니다!
3. 캐시 무효화와 재저장 사이의 stale 데이터 문제
캐시 무효화를 AFTER_COMMIT으로 처리하고 있는데, DB 커밋 완료 후 실제로 캐시가 evict되기까지의 짧은 순간에 다른 요청이 캐시 미스 → DB 조회(이미 커밋된 최신 데이터) → 캐시 재저장 흐름으로 정상 처리되는 경우는 괜찮습니다. 그런데 반대로, evict 직후 아직 트랜잭션이 커밋되기 전에 들어온 요청이 캐시 미스 → DB에서 이전 데이터 조회 → 구버전을 캐시에 올려버리는 상황도 이론적으로 가능합니다. 이 문제를 보완하기 위해 Delayed Double Delete(무효화 → 쓰기 → 일정 시간 후 한 번 더 무효화)도 고려해봤습니다만, 구현 복잡도 대비 실익이 크지 않다고 판단해 적용하지 않았습니다. 실무에서는 이 짧은 stale 구간을 어떻게 다루시는지 궁금합니다. 허용 가능한 수준으로 보고 TTL 만료 이후 자연 갱신을 기다리는 방식으로 운영하시는지, 아니면 별도로 커버하는 방법이 있으신지 궁금합니다!!