[volume-5] 인덱스와 캐시를 사용한 성능 최적화#204
Conversation
|
Important Review skippedReview was skipped due to path filters ⛔ Files ignored due to path filters (1)
CodeRabbit blocks several paths by default. You can override this behavior by explicitly including those paths in the path filters. For example, including ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughProduct 엔티티에 복합 인덱스 메타데이터가 추가되고, 대량 데이터용 MySQL 시드 스크립트가 추가되었으며, Product 읽기 경로에 Redis 기반 캐시 컴포넌트(ProductCacheStore)와 해당 캐시를 사용하는 ProductFacade 변경이 도입되었다다. Changes
Sequence Diagram(s)sequenceDiagram
participant Client as Client
participant Facade as ProductFacade
participant Cache as ProductCacheStore
participant Service as ProductService
participant DB as Database
participant RedisMaster as RedisMaster
Client->>Facade: getProduct(id)
Facade->>Cache: getProduct(id)
alt cache hit
Cache-->>Facade: ProductInfo(JSON)
Facade-->>Client: ProductInfo
else cache miss
Cache-->>Facade: empty
Facade->>Service: fetchProductById(id)
Service->>DB: SELECT product ...
DB-->>Service: row
Service-->>Facade: ProductInfo
Facade->>Cache: putProduct(id, ProductInfo)
Cache->>RedisMaster: SETEX product:detail:{id}
Cache-->>Facade: ack
Facade-->>Client: ProductInfo
end
운영 및 성능 관점의 검토 사항
🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 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 |
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java (1)
16-18: 동점 정렬 구간의 페이지 드리프트를 막으려면 보조 정렬 키를 포함한 인덱스가 필요하다.운영 관점에서
like_count동점이 많은 데이터에서는 offset 기반 페이징이 중복/누락을 만들 수 있다. 수정안은id DESC를 보조 정렬 키로 추가하고 인덱스도 동일 순서로 확장하는 것이다. 추가 테스트로 동일like_count대량 데이터에서 페이지 1/2/3 조회 시 중복/누락이 없는지 검증해야 한다.인덱스 수정 예시 diff
`@Table`(name = "product", indexes = { - `@Index`(name = "idx_product_brand_like", columnList = "ref_brand_id, like_count DESC"), - `@Index`(name = "idx_product_like", columnList = "like_count DESC") + `@Index`(name = "idx_product_brand_like_id", columnList = "ref_brand_id, like_count DESC, id DESC"), + `@Index`(name = "idx_product_like_id", columnList = "like_count DESC, id DESC") })
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java의 정렬도likeCount.desc(), id.desc()로 맞춰야 효과가 완성된다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java` around lines 16 - 18, Update the product DB indexes and repository sort to prevent page drift on equal like_count: in ProductEntity modify the `@Table` indexes (idx_product_brand_like and idx_product_like) to include id DESC as a tie-breaker (e.g., columnList = "ref_brand_id, like_count DESC, id DESC" and "like_count DESC, id DESC"), and in ProductRepositoryImpl change the sort to use likeCount.desc(), id.desc() so query ordering matches the index; then add a test that pages through many products with identical like_count (page 1/2/3) to assert no duplicates or gaps.db/seed-products.sql (1)
7-36: 시드 스크립트가 비멱등이라 재실행 시 데이터가 누적되어 성능 결과를 오염시킨다.운영/성능 관점에서 현재 구조는 재실행할 때마다 행이 누적되어 테스트 결과 비교가 불가능해진다. 수정안은 트랜잭션과 초기화(또는 명시적 upsert 정책)로 실행 결과를 결정적으로 만드는 방식이다. 추가 테스트로 스크립트를 2회 연속 실행한 뒤
brand=10,product=100000이 유지되는지 확인해야 한다.수정 예시 diff
+START TRANSACTION; + +-- 테스트 환경에서만 실행: 결정적 결과 보장을 위한 초기화 +DELETE FROM product; +DELETE FROM brand +WHERE name IN ('나이키','아디다스','뉴발란스','컨버스','반스','푸마','리복','언더아머','살로몬','노스페이스'); + -- 브랜드 10개 삽입 INSERT INTO brand (name, description, created_at, updated_at) VALUES @@ LIMIT 100000; + +COMMIT;🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@db/seed-products.sql` around lines 7 - 36, The seed SQL is not idempotent: re-running the INSERTs into brand and product will keep appending rows; modify the script to make it deterministic by wrapping operations in a transaction and clearing or upserting existing data first (e.g., BEGIN; TRUNCATE or DELETE FROM brand; TRUNCATE or DELETE FROM product; then run the INSERTs), or implement explicit UPSERT logic keyed on brand.name for the brand inserts and a deterministic key for product if needed; ensure the product generation still uses the `@i` counter and the same SELECT ... LIMIT 100000 pattern but after initialization so repeated runs yield exactly 10 brands and 100000 products, and add a quick verification step at the end (SELECT COUNT(*) FROM brand; SELECT COUNT(*) FROM product;) to confirm counts remain brand=10 and product=100000 after two consecutive executions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@db/seed-products.sql`:
- Around line 8-19: The seed assumes brand IDs 1–10 and uses a hardcoded random
ID which breaks FK if existing brand IDs differ; update the product insertion
logic in db/seed-products.sql to pick ref_brand_id from the actual brand.id set
(e.g., SELECT id FROM brand ORDER BY ... LIMIT 1 or use a subquery/ARRAY
sampling) so product.ref_brand_id always references an existing brand, and add a
post-seed validation that every product.ref_brand_id exists in brand.id to
ensure no FK failures (check in the same script using EXISTS/COUNT).
- Line 4: 현재 db/seed-products.sql에 예시 명령어 "mysql -u application -papplication
loopers < db/seed-products.sql"처럼 평문 비밀번호가 노출되어 있으니 해당 예시를 수정해 평문 비밀번호를 제거하고 안전한
사용법을 안내하세요; 예: 권장 문구로 "mysql -u application -p loopers < db/seed-products.sql"
(프롬프트에서 비밀번호 입력) 또는 환경변수 사용("MYSQL_PWD" 또는 도커/CI 비밀 관리 방법)으로 대체하고, README나 주석에 이
방식과 함께 저장소 전역에서 평문 패스워드 패턴(-p<password>)이 있는지 정적 검색을 실행하도록(예: git grep -nE
"-p[^[:space:]]") 권장하도록 문구를 추가하세요.
---
Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java`:
- Around line 16-18: Update the product DB indexes and repository sort to
prevent page drift on equal like_count: in ProductEntity modify the `@Table`
indexes (idx_product_brand_like and idx_product_like) to include id DESC as a
tie-breaker (e.g., columnList = "ref_brand_id, like_count DESC, id DESC" and
"like_count DESC, id DESC"), and in ProductRepositoryImpl change the sort to use
likeCount.desc(), id.desc() so query ordering matches the index; then add a test
that pages through many products with identical like_count (page 1/2/3) to
assert no duplicates or gaps.
In `@db/seed-products.sql`:
- Around line 7-36: The seed SQL is not idempotent: re-running the INSERTs into
brand and product will keep appending rows; modify the script to make it
deterministic by wrapping operations in a transaction and clearing or upserting
existing data first (e.g., BEGIN; TRUNCATE or DELETE FROM brand; TRUNCATE or
DELETE FROM product; then run the INSERTs), or implement explicit UPSERT logic
keyed on brand.name for the brand inserts and a deterministic key for product if
needed; ensure the product generation still uses the `@i` counter and the same
SELECT ... LIMIT 100000 pattern but after initialization so repeated runs yield
exactly 10 brands and 100000 products, and add a quick verification step at the
end (SELECT COUNT(*) FROM brand; SELECT COUNT(*) FROM product;) to confirm
counts remain brand=10 and product=100000 after two consecutive executions.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: df643a53-259c-4b01-87f1-cda83a1c1864
⛔ Files ignored due to path filters (1)
docs/index-performance-test.mdis excluded by!**/*.mdand included by**
📒 Files selected for processing (2)
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.javadb/seed-products.sql
| -- ============================================================ | ||
| -- 상품 더미 데이터 시드 스크립트 (10만 건) | ||
| -- 실행 전제: 앱을 먼저 실행해서 테이블이 생성된 상태여야 합니다. | ||
| -- 실행 방법: mysql -u application -papplication loopers < db/seed-products.sql |
There was a problem hiding this comment.
실행 예시에 비밀번호를 평문으로 노출하면 보안 운영 습관이 약화된다.
Line 4처럼 자격증명을 커맨드에 직접 넣으면 히스토리/문서 복사로 유출될 수 있다. 수정안은 비밀번호 프롬프트(-p)나 안전한 환경변수 방식을 안내하는 것이다. 추가 테스트로 저장소 전역에서 평문 패스워드 패턴(-p<password>)이 없는지 정적 검색을 권장한다.
수정 예시 diff
--- 실행 방법: mysql -u application -papplication loopers < db/seed-products.sql
+-- 실행 방법: mysql -u application -p loopers < db/seed-products.sql📝 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.
| -- 실행 방법: mysql -u application -papplication loopers < db/seed-products.sql | |
| -- 실행 방법: mysql -u application -p loopers < db/seed-products.sql |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@db/seed-products.sql` at line 4, 현재 db/seed-products.sql에 예시 명령어 "mysql -u
application -papplication loopers < db/seed-products.sql"처럼 평문 비밀번호가 노출되어 있으니 해당
예시를 수정해 평문 비밀번호를 제거하고 안전한 사용법을 안내하세요; 예: 권장 문구로 "mysql -u application -p loopers
< db/seed-products.sql" (프롬프트에서 비밀번호 입력) 또는 환경변수 사용("MYSQL_PWD" 또는 도커/CI 비밀 관리
방법)으로 대체하고, README나 주석에 이 방식과 함께 저장소 전역에서 평문 패스워드 패턴(-p<password>)이 있는지 정적 검색을
실행하도록(예: git grep -nE "-p[^[:space:]]") 권장하도록 문구를 추가하세요.
| INSERT INTO brand (name, description, created_at, updated_at) | ||
| VALUES | ||
| ('나이키', '글로벌 스포츠 브랜드', NOW(), NOW()), | ||
| ('아디다스', '독일 스포츠 브랜드', NOW(), NOW()), | ||
| ('뉴발란스', '미국 스포츠 브랜드', NOW(), NOW()), | ||
| ('컨버스', '캐주얼 스니커즈 브랜드', NOW(), NOW()), | ||
| ('반스', '스케이트 브랜드', NOW(), NOW()), | ||
| ('푸마', '독일 스포츠 브랜드', NOW(), NOW()), | ||
| ('리복', '영국 스포츠 브랜드', NOW(), NOW()), | ||
| ('언더아머', '미국 퍼포먼스 브랜드', NOW(), NOW()), | ||
| ('살로몬', '아웃도어 브랜드', NOW(), NOW()), | ||
| ('노스페이스','아웃도어 브랜드', NOW(), NOW()); |
There was a problem hiding this comment.
브랜드 ID를 1~10으로 가정하는 참조 방식은 FK 실패를 유발한다.
운영 관점에서 Line 27의 하드코딩 랜덤 ID는 기존 데이터가 있는 DB에서 즉시 실패하거나 잘못된 브랜드 연결을 만든다. 수정안은 실제 삽입/존재 브랜드의 id 집합에서 ref_brand_id를 선택하도록 바꾸는 방식이다. 추가 테스트로 브랜드 ID가 1부터 시작하지 않는 상태(예: 101~110)에서 스크립트를 실행해 FK 오류가 없는지와 product.ref_brand_id가 모두 brand.id에 존재하는지 검증해야 한다.
수정 예시 diff
INSERT INTO brand (name, description, created_at, updated_at)
VALUES
@@
('노스페이스','아웃도어 브랜드', NOW(), NOW());
+CREATE TEMPORARY TABLE tmp_seed_brand_ids AS
+SELECT id
+FROM brand
+WHERE name IN (
+ '나이키','아디다스','뉴발란스','컨버스','반스',
+ '푸마','리복','언더아머','살로몬','노스페이스'
+);
-- 상품 10만 건 삽입 (cross join으로 빠르게 생성)
SET `@i` = 0;
INSERT INTO product (name, ref_brand_id, price, stock, like_count, created_at, updated_at)
SELECT
CONCAT('상품_', `@i` := `@i` + 1),
- FLOOR(1 + RAND() * 10),
+ (SELECT id FROM tmp_seed_brand_ids ORDER BY RAND() LIMIT 1),
FLOOR(1 + RAND() * 100) * 1000,
@@
LIMIT 100000;
+
+DROP TEMPORARY TABLE tmp_seed_brand_ids;Also applies to: 24-36
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@db/seed-products.sql` around lines 8 - 19, The seed assumes brand IDs 1–10
and uses a hardcoded random ID which breaks FK if existing brand IDs differ;
update the product insertion logic in db/seed-products.sql to pick ref_brand_id
from the actual brand.id set (e.g., SELECT id FROM brand ORDER BY ... LIMIT 1 or
use a subquery/ARRAY sampling) so product.ref_brand_id always references an
existing brand, and add a post-seed validation that every product.ref_brand_id
exists in brand.id to ensure no FK failures (check in the same script using
EXISTS/COUNT).
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (2)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java (2)
74-79: 목록 캐시 미스 경로에서 브랜드 조회 수가 결과 건수에 비례한다현재는 목록을
ProductInfo로 변환하면서brandService.getById()를 상품마다 호출한다. 캐시 미스가 난 페이지가 커질수록 이 구간의 지연이 결과 건수에 비례해 커지고,BrandService가 저장소 접근을 포함한다면 이번 PR 의 인덱스/캐시 이득을 상당 부분 상쇄할 수 있다.수정안은
refBrandId를 distinct 로 모아 한 번에 조회한 뒤Map<Long, Brand>로 조합하거나, 아예productService.findProducts()가 브랜드 정보까지 함께 projection 하도록 바꾸는 것이다. 추가 테스트로는 동일 브랜드가 많은 페이지와 브랜드가 다양한 페이지 각각에서 브랜드 조회 수 또는 쿼리 수가 결과 건수와 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 74 - 79, The current mapping calls brandService.getById(...) once per product causing O(n) brand lookups; instead collect distinct product.getRefBrandId() from productService.findProducts(...), batch-fetch all Brands in one call (e.g., a new brandService.findByIds(Collection<Long>) or similar), build a Map<Long, Brand>, and then convert products to ProductInfo via ProductInfo.of(product, brandMap.get(product.getRefBrandId())); alternatively, extend productService.findProducts(...) to return product+brand projection so no per-item lookup is needed; also add performance/regression tests that assert brand lookup/query count does not scale 1:1 with result count for pages with many identical brands and pages with diverse brands.
49-56: 만료 시 동시 미스로 DB 부하가 다시 몰릴 수 있다지금은 전형적인 cache-aside 구조라서 핫 키가 비어 있거나 TTL 이 만료되는 순간, 같은 상품이나 같은 목록에 대한 동시 요청이 모두
productService와brandService경로를 탄다. 성능 최적화 PR 에서 이 구간을 그대로 두면 만료 시점의 burst traffic 이 DB 로 직행해서 p95/p99 지연이 다시 튈 수 있다.수정안은 제품 ID 와 검색 조건 단위로 single-flight 를 두거나, Redis 분산 락 또는 짧은 프로세스 로컬 락으로 최초 1건만 재계산하게 만드는 것이다. 대안으로 stale-while-revalidate 도 가능하다. 추가 테스트로는 동일
productId와 동일ProductSearchCommand에 대해 동시 요청을 여러 건 보냈을 때 DB 조회가 1회로 수렴하는지, 나머지 요청은 같은 캐시 결과를 받는지 검증하는 부하성 통합 테스트를 넣는 것이 좋다.Also applies to: 61-82
🤖 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 49 - 56, The cache-aside path in ProductFacade (productCacheStore.getProduct -> productService.getById / brandService.getById -> ProductInfo.of -> productCacheStore.putProduct) is vulnerable to thundering-herd on TTL expiry; modify the retrieval flow to ensure only one request recomputes and populates the cache per productId (or per ProductSearchCommand) by implementing a single-flight/locking mechanism (e.g., a short in-process lock keyed by productId, or a Redis distributed lock) or adopt stale-while-revalidate so stale values are served while a single background refresh runs, and add an integration test that issues concurrent requests for the same productId/ProductSearchCommand and asserts DB methods (productService.getById/brandService.getById) are called exactly once and all requests receive the same cached response.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java`:
- Around line 43-60: The Redis read/write currently only catches
JsonProcessingException; wrap the redisTemplate.opsForValue().get(key) +
objectMapper.readValue(...) in a broader try that catches DataAccessException
(and other runtime Redis connectivity exceptions) and return Optional.empty() on
those errors, and on JsonProcessingException additionally delete the bad key
(redisTemplate.delete(key)) to avoid repeated poisoned reads before returning
Optional.empty(); in putProduct(Long productId, ProductInfo productInfo) wrap
masterRedisTemplate.opsForValue().set(...) and
objectMapper.writeValueAsString(...) in a try that catches
JsonProcessingException and DataAccessException (or RuntimeException from Redis)
and only log a warning (do not rethrow) so the request can proceed; add unit
tests that mock Redis to throw on read/write to verify
ProductFacade.getProduct()/getProducts() fall back to DB results and a test
inserting corrupted JSON verifies the key is removed and treated as a cache
miss.
- Around line 21-25: 현재 ProductCacheStore에는 조회/적재만 있고 무효화 경로가 없어
stock/likeCount/신규상품 반영 지연 문제가 발생하므로 ProductCacheStore에 public
evictProduct(productId) 메서드와 목록 캐시용 브랜드/글로벌 버전을 관리하는
incrementListVersion(brandId?) API를 추가하고, ProductFacade의 쓰기 경로(주문 처리·좋아요 토글·상품
생성)에서 해당 evictProduct(...)를 호출하거나 상세 캐시는 즉시 삭제/쓰기-스루로 갱신하고 목록 캐시는 개별 키 삭제 대신
브랜드/전역 버전 키를 조합해 무효화하도록 변경하세요; 또한 통합 테스트를 추가해(캐시 예열 후) 주문 완료 시 detail/list의
stock 감소, 좋아요 변경 시 likeCount 즉시 반영, 상품 생성 시 해당 브랜드 목록에서 신상품 즉시 노출을 검증하세요.
---
Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java`:
- Around line 74-79: The current mapping calls brandService.getById(...) once
per product causing O(n) brand lookups; instead collect distinct
product.getRefBrandId() from productService.findProducts(...), batch-fetch all
Brands in one call (e.g., a new brandService.findByIds(Collection<Long>) or
similar), build a Map<Long, Brand>, and then convert products to ProductInfo via
ProductInfo.of(product, brandMap.get(product.getRefBrandId())); alternatively,
extend productService.findProducts(...) to return product+brand projection so no
per-item lookup is needed; also add performance/regression tests that assert
brand lookup/query count does not scale 1:1 with result count for pages with
many identical brands and pages with diverse brands.
- Around line 49-56: The cache-aside path in ProductFacade
(productCacheStore.getProduct -> productService.getById / brandService.getById
-> ProductInfo.of -> productCacheStore.putProduct) is vulnerable to
thundering-herd on TTL expiry; modify the retrieval flow to ensure only one
request recomputes and populates the cache per productId (or per
ProductSearchCommand) by implementing a single-flight/locking mechanism (e.g., a
short in-process lock keyed by productId, or a Redis distributed lock) or adopt
stale-while-revalidate so stale values are served while a single background
refresh runs, and add an integration test that issues concurrent requests for
the same productId/ProductSearchCommand and asserts DB methods
(productService.getById/brandService.getById) are called exactly once and all
requests receive the same cached response.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: d7d23bc5-c07b-43a4-a575-db8fd2687395
⛔ Files ignored due to path filters (1)
docs/cache-performance-test.mdis excluded by!**/*.mdand included by**
📒 Files selected for processing (2)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java
| private static final String DETAIL_KEY_PREFIX = "product:detail:"; | ||
| private static final String LIST_KEY_PREFIX = "product:list:"; | ||
|
|
||
| private static final Duration DETAIL_TTL = Duration.ofSeconds(180); | ||
| private static final Duration LIST_TTL = Duration.ofSeconds(30); |
There was a problem hiding this comment.
캐시 적재 경로만 있고 무효화 전략이 없다
현재 변경분에서는 ProductCacheStore 에 조회/적재 API만 있고, apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java:48-82 도 캐시 적재만 호출한다. 그런데 apps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.java:1-29 에는 stock, likeCount 같이 자주 바뀌는 값이 포함되어 있어서, 주문·좋아요·상품 생성 직후에도 detail/list 캐시가 TTL 동안 이전 값을 계속 반환할 수 있다. 운영에서는 재고 오표시나 좋아요 수 지연 반영으로 바로 사용자 혼선을 만든다.
수정안은 write path 에서 호출할 evictProduct(...) 와 목록 캐시 버전 증가 API를 먼저 추가하는 것이다. detail 캐시는 즉시 삭제 또는 write-through 갱신으로 맞추고, list 캐시는 page/sort 조합이 많으므로 개별 키 삭제보다 브랜드/전역 버전 키를 조합하는 방식이 안전하다. 추가 테스트로는 캐시 예열 후 주문 완료 시 상세/목록의 stock 이 즉시 감소하는지, 좋아요 변경 후 likeCount 가 즉시 반영되는지, 상품 생성 후 해당 브랜드 목록에 신상품이 바로 보이는지 통합 테스트를 넣어야 한다.
Also applies to: 41-97
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java`
around lines 21 - 25, 현재 ProductCacheStore에는 조회/적재만 있고 무효화 경로가 없어
stock/likeCount/신규상품 반영 지연 문제가 발생하므로 ProductCacheStore에 public
evictProduct(productId) 메서드와 목록 캐시용 브랜드/글로벌 버전을 관리하는
incrementListVersion(brandId?) API를 추가하고, ProductFacade의 쓰기 경로(주문 처리·좋아요 토글·상품
생성)에서 해당 evictProduct(...)를 호출하거나 상세 캐시는 즉시 삭제/쓰기-스루로 갱신하고 목록 캐시는 개별 키 삭제 대신
브랜드/전역 버전 키를 조합해 무효화하도록 변경하세요; 또한 통합 테스트를 추가해(캐시 예열 후) 주문 완료 시 detail/list의
stock 감소, 좋아요 변경 시 likeCount 즉시 반영, 상품 생성 시 해당 브랜드 목록에서 신상품 즉시 노출을 검증하세요.
| try { | ||
| String value = redisTemplate.opsForValue().get(key); | ||
| if (value == null) { | ||
| return Optional.empty(); | ||
| } | ||
| return Optional.of(objectMapper.readValue(value, ProductInfo.class)); | ||
| } catch (JsonProcessingException e) { | ||
| log.warn("Failed to deserialize product cache. key={}", key, e); | ||
| return Optional.empty(); | ||
| } | ||
| } | ||
|
|
||
| public void putProduct(Long productId, ProductInfo productInfo) { | ||
| String key = detailKey(productId); | ||
| try { | ||
| masterRedisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(productInfo), DETAIL_TTL); | ||
| } catch (JsonProcessingException e) { | ||
| log.warn("Failed to serialize product cache. key={}", key, e); |
There was a problem hiding this comment.
Redis 장애가 상품 조회 장애로 바로 번질 수 있다
현재는 Jackson 예외만 처리하므로 opsForValue().get()/set() 에서 발생하는 timeout, connection, DataAccessException 계열 예외가 그대로 전파된다. 운영에서는 캐시가 흔들려도 DB fallback 으로 서비스가 계속되어야 하는데, 지금 구현은 읽기 실패가 miss 로 전환되지 않고, 쓰기 실패도 이미 만든 응답을 반환하지 못한 채 요청 자체를 실패시킬 수 있다.
수정안은 읽기에서 Redis 접근 예외를 캐시 miss 로 흡수하고, 쓰기에서는 경고 로그만 남긴 뒤 본 요청은 성공시키는 것이다. 역직렬화 실패 시에도 같은 손상 데이터를 계속 읽지 않도록 문제 키를 삭제하는 처리까지 함께 넣는 편이 낫다. 추가 테스트로는 Redis read/write mock 이 예외를 던질 때 ProductFacade.getProduct() 와 ProductFacade.getProducts() 가 DB 결과를 정상 반환하는지, 손상된 JSON 이 들어 있을 때 miss 처리 후 해당 키가 제거되는지를 검증해야 한다. As per coding guidelines, **/*.java: "예외 흐름이 명확한지 점검한다".
Also applies to: 66-83
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java`
around lines 43 - 60, The Redis read/write currently only catches
JsonProcessingException; wrap the redisTemplate.opsForValue().get(key) +
objectMapper.readValue(...) in a broader try that catches DataAccessException
(and other runtime Redis connectivity exceptions) and return Optional.empty() on
those errors, and on JsonProcessingException additionally delete the bad key
(redisTemplate.delete(key)) to avoid repeated poisoned reads before returning
Optional.empty(); in putProduct(Long productId, ProductInfo productInfo) wrap
masterRedisTemplate.opsForValue().set(...) and
objectMapper.writeValueAsString(...) in a try that catches
JsonProcessingException and DataAccessException (or RuntimeException from Redis)
and only log a warning (do not rethrow) so the request can proceed; add unit
tests that mock Redis to throw on read/write to verify
ProductFacade.getProduct()/getProducts() fall back to DB results and a test
inserting corrupted JSON verifies the key is removed and treated as a cache
miss.
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java (1)
16-21: PRICE_ASC 정렬 인덱스 미적용 확인
ProductRepositoryImpl.java의getOrderSpecifier메서드를 보면PRICE_ASC정렬 타입도 지원하지만, 해당 정렬에 대한 인덱스는 추가되지 않았다.의도적인 결정이라면 문제없으나,
PRICE_ASC정렬 쿼리 빈도가 높을 경우 다음 인덱스 추가를 검토해야 한다:
(ref_brand_id, price ASC)- 브랜드 필터링 + 가격 정렬(price ASC)- 전체 상품 가격 정렬또한 4개의 인덱스가 추가되면
INSERT/UPDATE/DELETE성능에 영향을 줄 수 있으므로, 상품 테이블의 쓰기 빈도와 읽기 빈도를 고려하여 인덱스 개수를 조정해야 한다.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java` around lines 16 - 21, ProductEntity's `@Table` indexes do not include indices for PRICE_ASC used by ProductRepositoryImpl.getOrderSpecifier; update the `@Table`(indexes = {...}) on class ProductEntity to add indexes for price sorting such as `@Index`(name = "idx_product_brand_price_asc", columnList = "ref_brand_id, price ASC") and `@Index`(name = "idx_product_price_asc", columnList = "price ASC") (or remove/adjust if PRICE_ASC is intentionally rarely used), and then validate query plans and weigh write-impact before deploying.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java`:
- Around line 16-21: ProductEntity 클래스의 `@Table/`@Index(예: idx_product_brand_like,
idx_product_like, idx_product_brand_latest, idx_product_latest) 어노테이션은 문서화 목적일 뿐
운영환경(jpa.ddl-auto: none)에서는 인덱스가 생성되지 않으므로, Flyway 또는 Liquibase 마이그레이션 파일을 추가해
동일한 인덱스들을 생성하는 DDL을 배포에 포함시키고(예: CREATE INDEX ... ON product (...)), MySQL에서는
대용량 테이블 잠금 완화를 위해 인덱스 생성 DDL에 ALGORITHM=INPLACE, LOCK=NONE 옵션을 적용하도록 하며(또는
롤링/오프라인 윈도우 계획), 스테이징에서 실행 시간/잠금 영향 테스트 후 운영 배포 윈도우를 확보해 적용하세요.
---
Nitpick comments:
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java`:
- Around line 16-21: ProductEntity's `@Table` indexes do not include indices for
PRICE_ASC used by ProductRepositoryImpl.getOrderSpecifier; update the
`@Table`(indexes = {...}) on class ProductEntity to add indexes for price sorting
such as `@Index`(name = "idx_product_brand_price_asc", columnList = "ref_brand_id,
price ASC") and `@Index`(name = "idx_product_price_asc", columnList = "price ASC")
(or remove/adjust if PRICE_ASC is intentionally rarely used), and then validate
query plans and weigh write-impact before deploying.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 67252c24-a9c4-4743-a40c-b26fa3f31f5e
📒 Files selected for processing (1)
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java
| @Table(name = "product", indexes = { | ||
| @Index(name = "idx_product_brand_like", columnList = "ref_brand_id, like_count DESC"), | ||
| @Index(name = "idx_product_like", columnList = "like_count DESC"), | ||
| @Index(name = "idx_product_brand_latest", columnList = "ref_brand_id, created_at DESC"), | ||
| @Index(name = "idx_product_latest", columnList = "created_at DESC") | ||
| }) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: 마이그레이션 스크립트에서 해당 인덱스 생성 DDL 존재 여부 확인
# 1. Flyway/Liquibase 마이그레이션 파일 검색
echo "=== Searching for migration files ==="
fd -e sql -e xml -e yaml -e yml | xargs grep -l -i "idx_product" 2>/dev/null || echo "No migration files found with idx_product"
# 2. 인덱스 DDL 패턴 검색
echo ""
echo "=== Searching for CREATE INDEX statements ==="
rg -i "CREATE\s+(UNIQUE\s+)?INDEX.*idx_product" --type sql 2>/dev/null || echo "No CREATE INDEX statements found"
# 3. resources 하위 SQL 파일 확인
echo ""
echo "=== Listing SQL files in resources ==="
fd -e sql . --full-path | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 305
🏁 Script executed:
# 1. ProductEntity.java 전체 내용 확인
cat apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java
# 2. Hibernate 버전 확인
fd -name "pom.xml" -o -name "build.gradle" | xargs grep -A 5 "hibernate" 2>/dev/null | head -30
# 3. jpa.yml 설정 확인
fd -name "jpa.yml" -o -name "jpa.yaml" | xargs cat 2>/dev/null
# 4. 마이그레이션 디렉토리 구조 확인
find . -type d -name "db" -o -name "migration" -o -name "flyway" -o -name "liquibase" 2>/dev/null | head -10
# 5. 기존 SQL 마이그레이션 파일 형식 확인
cat db/seed-products.sql | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4073
🏁 Script executed:
# 1. jpa.yml 설정 확인
find . -type f \( -name "jpa.yml" -o -name "jpa.yaml" -o -name "application*.yml" -o -name "application*.yaml" \) | head -10
# 2. Hibernate 버전 확인 (pom.xml 또는 build.gradle)
find . -type f \( -name "pom.xml" -o -name "build.gradle" \) -exec grep -l "hibernate" {} \;
# 3. 마이그레이션 관련 의존성 확인
find . -type f -name "pom.xml" -exec grep -A 3 "flyway\|liquibase" {} +
# 4. db 디렉토리 구조 확인
find db -type f 2>/dev/null | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 318
🏁 Script executed:
# 1. jpa.yml 전체 내용 확인
cat modules/jpa/src/main/resources/jpa.yml
# 2. application.yml 확인
cat apps/commerce-api/src/main/resources/application.yml
# 3. pom.xml에서 Hibernate 및 Spring Boot 버전 확인
find . -maxdepth 3 -name "pom.xml" -type f | xargs grep -E "spring-boot-starter-data-jpa|hibernate|version" | grep -E "hibernate|version" | head -20
# 4. Flyway 의존성 확인
rg "flyway|liquibase" --type xmlRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 3191
🏁 Script executed:
# 1. 루트 및 각 모듈의 pom.xml 찾기
find . -name "pom.xml" -type f | head -10
# 2. 최상단 pom.xml에서 버전 정보 확인
cat pom.xml 2>/dev/null | grep -A 100 "<properties>" | grep -E "version|hibernate|spring" | head -20
# 3. 각 모듈 pom.xml에서 JPA/Hibernate 의존성 확인
find apps commerce-api -name "pom.xml" -type f -exec cat {} \; | grep -A 3 "spring-boot-starter-data-jpa\|hibernate-core" | head -20
# 4. Flyway 관련 설정파일 및 마이그레이션 디렉토리 확인
find . -type d -name "db" -o -type d -name "migration" 2>/dev/null
find . -type f -name "*flyway*" -o -name "*liquibase*" 2>/dev/nullRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 154
🏁 Script executed:
# 1. pom.xml 위치 찾기
git ls-files | grep "pom.xml"
# 2. 루트 pom.xml 확인
cat pom.xml 2>/dev/null | head -50
# 3. Hibernate 버전 확인
git ls-files | xargs grep -l "hibernate" 2>/dev/null | head -5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 493
🏁 Script executed:
# 1. pom.xml 찾기
fd "pom.xml"
# 2. 최상단 pom.xml 읽기
head -100 pom.xml
# 3. Hibernate 버전 정보 검색
grep -r "hibernate" pom.xml 2>/dev/null | head -10Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 151
🏁 Script executed:
# 1. 루트 디렉토리 구조 확인
ls -la | head -20
# 2. build.gradle 찾기
fd "build.gradle"
# 3. Gradle wrapper 확인
ls -la gradlew* settings.gradle* 2>/dev/null
# 4. 프로젝트 구조 전체 확인
git ls-files | grep -E "(pom|gradle|maven)" | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 2198
🏁 Script executed:
# 1. 루트 build.gradle.kts 확인
cat build.gradle.kts | head -100
# 2. modules/jpa/build.gradle.kts 확인
cat modules/jpa/build.gradle.kts
# 3. gradle.properties 버전 정보
cat gradle.propertiesRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4673
🏁 Script executed:
# 1. Hibernate 버전 확인 (Spring Boot BOM에 포함)
cat modules/jpa/build.gradle.kts | grep -i "spring-boot-starter-data-jpa\|hibernate"
# 2. Spring Boot 3.4.4에 포함된 Hibernate 버전 확인
# Spring Boot 3.4.x는 일반적으로 Hibernate 6.4.x 이상 포함
# 웹 검색으로 확인 필요
# 3. 마이그레이션 스크립트 부재 재확인
find . -type f \( -name "*.sql" -o -name "*.xml" \) -exec grep -l "idx_product_brand_like\|idx_product_like\|idx_product_brand_latest\|idx_product_latest" {} \;Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 237
🌐 Web query:
Spring Boot 3.4.4 Hibernate version
💡 Result:
Spring Boot 3.4.4 (its dependency management / BOM) uses Hibernate ORM 6.6.11.Final. [1]
운영 환경에서 인덱스 자동 생성이 되지 않으므로 마이그레이션 스크립트 필수
현재 jpa.yml에서 운영 환경(dev, qa, prd)의 ddl-auto: none 설정으로 인해 @Index 어노테이션은 스키마 문서화 목적일 뿐 실제 인덱스 생성되지 않는다.
현재 코드베이스에 마이그레이션 스크립트가 존재하지 않으므로, 운영 배포 전에 다음을 준비해야 한다:
- Flyway 또는 Liquibase를 통한 마이그레이션 스크립트 작성 또는 수동 DDL 스크립트 준비
- 대량 데이터가 존재하는 product 테이블의 경우 인덱스 생성으로 인한 테이블 락 발생 가능성 검토 (MySQL
ALGORITHM=INPLACE, LOCK=NONE옵션 활용) - 운영 환경에서 인덱스 생성 소요 시간 예측 및 배포 윈도우 확보
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductEntity.java`
around lines 16 - 21, ProductEntity 클래스의 `@Table/`@Index(예:
idx_product_brand_like, idx_product_like, idx_product_brand_latest,
idx_product_latest) 어노테이션은 문서화 목적일 뿐 운영환경(jpa.ddl-auto: none)에서는 인덱스가 생성되지 않으므로,
Flyway 또는 Liquibase 마이그레이션 파일을 추가해 동일한 인덱스들을 생성하는 DDL을 배포에 포함시키고(예: CREATE INDEX
... ON product (...)), MySQL에서는 대용량 테이블 잠금 완화를 위해 인덱스 생성 DDL에 ALGORITHM=INPLACE,
LOCK=NONE 옵션을 적용하도록 하며(또는 롤링/오프라인 윈도우 계획), 스테이징에서 실행 시간/잠금 영향 테스트 후 운영 배포 윈도우를
확보해 적용하세요.
📌 Summary
배경:
상품 목록 조회 API(GET /products)에서 브랜드 필터링과 정렬(좋아요순/최신순) 쿼리가 인덱스 없이 동작하여 테이블 풀스캔과
filesort가 발생했습니다. 데이터가 증가할수록 응답 시간이 선형적으로 증가하는 구조였습니다.
목표:
결과:
cache-performance-test.md와 index-performance-test.md에 성능 측정 결과를 업데이트 하였습니다.
선택지와 결정
사용 불가합니다.
성능을 보였고, 단일 인덱스는 전체 정렬 쿼리에서 필수적이었습니다. 두 패턴 모두 API에서 사용되므로 조합이 필요합니다.
📋 Checklist 현황
🔖 Index
[✅] brandId 기반 검색, 좋아요 순 정렬 처리
[✅] 인덱스 적용 및 전후 성능비교
❤️ Structure
[✅] 좋아요 수 조회 및 정렬 가능 (like_count 비정규화)
[✅] 좋아요 적용/해제 시 동기화 (원자적 UPDATE)
⚡ Cache
[✅] Redis 캐시 적용 (ProductCacheStore)
[✅] TTL 설정 (상세 180초, 목록 30초)
[✅] 캐시 미스 시 정상 동작 (DB fallback)
변경 목적
제품 조회의 성능 최적화를 위해 Redis 캐시 층과 데이터베이스 인덱스를 단계적으로 도입합니다. ProductEntity에 조회 쿼리 가속을 위한 인덱스를 추가하고, ProductCacheStore를 통해 자주 조회되는 상품 정보를 메모리에 캐싱하여 DB 접근을 줄입니다.
핵심 변경점
리스크 및 주의사항
테스트/검증 방법 확인사항