[volume 5] 인덱스 설계 및 캐시를 통한 읽기 성능 최적화#198
[volume 5] 인덱스 설계 및 캐시를 통한 읽기 성능 최적화#198najang wants to merge 5 commits intoLoopers-dev-lab:najangfrom
Conversation
- products: like_count, created_at, price 단일 인덱스 및 brand_id 복합 인덱스 6개 추가 - likes: user_id, created_at DESC 복합 인덱스 추가 - orders: user_id, created_at DESC 복합 인덱스 추가 - 전체 테이블 스캔 + filesort 제거로 조회 시간 약 87~490배 개선 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
📝 WalkthroughWalkthroughRedis 기반의 캐싱을 도입하여 상품 조회에 캐시-어사이드 패턴과 사용자별 isLiked 오버레이를 구현하였으며, 데이터베이스 인덱스를 추가하고 기본 페이지 크기를 조정하였다. Changes
Sequence DiagramsequenceDiagram
participant Client
participant Controller as ProductV1Controller
participant Facade as ProductFacade
participant Redis
participant Database as Product DB
participant UserContext as User Context
Client->>Controller: GET /products/{id}?userId=X
Controller->>Facade: findById(productId, userId)
Facade->>Redis: GET product:{id}
alt Cache Hit
Redis-->>Facade: ProductInfo (cached)
else Cache Miss
Facade->>Database: SELECT * FROM products WHERE id=?
Database-->>Facade: ProductInfo
Facade->>Redis: SET product:{id} = ProductInfo
end
Facade->>UserContext: Get user's isLiked status
Facade->>Facade: productInfo.withIsLiked(userLiked)
Facade-->>Controller: ProductInfo (with user-specific overlay)
Controller-->>Client: ApiResponse<ProductInfo>
Estimated Code Review Effort🎯 3 (Moderate) | ⏱️ ~28 minutes 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)
📝 Coding Plan for PR comments
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java`:
- Around line 94-100: The current ProductFacade.findAll caches any page=0
response using the raw pageable size, which allows infinite cache keys; change
the caching logic to only cache when pageable.getPageNumber()==0 AND
pageable.getPageSize() matches an allowed/normalized size (e.g., a single fixed
CACHE_PAGE_SIZE constant or limited to a MAX_CACHE_SIZE), otherwise bypass cache
and call productService.findAll(...). Update buildCacheKey to use the normalized
cache size (or a sentinel like "uncached" when not caching) so keys cannot
explode, and add tests calling findAll with size=10/20/1000 to assert only the
allowed size produces a Redis key and payload.
- Around line 122-125: The create method in ProductFacade currently calls
brandService.findById(...) and productService.create(...) but does not
invalidate the product list cache, so newly created products may not appear
until cache TTL expires; after creating the product (the return value of
productService.create called in ProductFacade.create), call
evictProductListCache(created.brandId()) to evict both the global "latest" and
brand-specific list caches before returning ProductInfo, and add a test that
warms the list cache, calls the admin create API, then fetches the list to
assert the new product appears immediately.
- Around line 80-86: After likeService.like()/unlike() completes in
LikeFacade.like()/unlike(), invalidate the product caches using the returned
Product.brandId: remove the exact key "product:detail:{productId}" and delete
pattern keys "product:list:all:*" and "product:list:{brandId}:*" so ProductInfo
and ProductListCacheEntry likeCount and likes_desc sorting are not stale;
perform the invalidation after the transaction-returned Product is obtained (use
product.getBrandId()) and ensure deletion uses masterRedisTemplate (or
equivalent) to evict cached list and detail entries. Also add a test that loads
the product detail and list caches, calls like() and unlike(), and asserts API
responses reflect updated likeCount and list ordering immediately.
In `@apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java`:
- Around line 18-20: 현재 엔티티의 `@Index` 어노테이션(Like.java의 `@Table` indexes)은 운영에서
ddl-auto: none 설정 때문에 DDL로 반영되지 않으므로, 엔티티 주석에 의존하지 말고 Flyway 또는 Liquibase
마이그레이션을 만들어 likes, orders, products 테이블에 대해 user_id + created_at DESC 인덱스를 명시적으로
추가하고 배포 스크립트에 포함시켜라; 마이그레이션 적용 후 information_schema.statistics 또는 SHOW INDEX를
사용해 각 테이블에 인덱스가 존재하는지 검증하도록 테스트를 추가하라.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: b6461258-7231-4b3c-bea1-3d8f0b91c456
⛔ Files ignored due to path filters (1)
docs/design/index-optimization.mdis excluded by!**/*.mdand included by**
📒 Files selected for processing (8)
apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductInfo.javaapps/commerce-api/src/main/java/com/loopers/application/product/ProductListCacheEntry.javaapps/commerce-api/src/main/java/com/loopers/domain/like/Like.javaapps/commerce-api/src/main/java/com/loopers/domain/order/Order.javaapps/commerce-api/src/main/java/com/loopers/domain/product/Product.javaapps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.javaapps/commerce-api/src/test/java/com/loopers/interfaces/api/ProductV1ApiE2ETest.java
| // 읽기 전용 조회 (Replica Preferred) - 캐시 히트 확인에 사용 | ||
| private final RedisTemplate<String, String> redisTemplate; | ||
| // 쓰기 전용 (Master) - 캐시 저장 및 삭제에 사용 | ||
| private final RedisTemplate<String, String> masterRedisTemplate; |
There was a problem hiding this comment.
Replica 읽기와 Master 쓰기를 분리하면 무효화 직후 stale cache를 다시 읽을 수 있다.
운영에서는 Master에서 키를 삭제하거나 갱신해도 Replica 복제가 늦으면 바로 다음 조회가 이전 값을 다시 읽을 수 있다. 그러면 이 PR이 의도한 update/delete 후 즉시 일관성이 깨지고, 단일 Redis로 도는 테스트에서는 이 문제가 드러나지 않는다. 수정안은 최소한 캐시 무효화 직후 정확성이 필요한 목록/상세 조회는 같은 노드에서 읽고 쓰도록 통일하는 것이다. 추가 테스트로는 읽기/쓰기 Redis를 분리한 환경에서 수정·삭제 직후 GET이 stale 응답을 내지 않는지 검증하면 된다.
| ProductInfo info = ProductInfo.from(product); | ||
| try { | ||
| masterRedisTemplate.opsForValue().set(cacheKey, objectMapper.writeValueAsString(info), Duration.ofMinutes(1)); | ||
| } catch (JsonProcessingException ignored) { | ||
| } | ||
| Boolean isLiked = userId != null ? likeService.isLiked(userId, productId) : null; | ||
| return ProductInfo.from(product, isLiked); | ||
| return info.withIsLiked(isLiked); |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "[1] Like write service candidates"
fd 'Like.*Service\.java' apps/commerce-api/src/main/java -x sed -n '1,260p' {}
echo
echo "[2] Cache invalidation usages"
rg -n -C3 'product:list:|product:detail:|evictProductListCache|masterRedisTemplate\.delete' apps/commerce-api/src/main/java
echo
echo "[3] Like count mutation call sites"
rg -n -C3 'increaseLikes\(|decreaseLikes\(|isLiked\(' apps/commerce-api/src/main/javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 17448
🏁 Script executed:
# Verify the controller/API layer to understand the call chain
fd 'Like.*Controller\.java' apps/commerce-api/src/main/java -x cat
# Check if there's a LikeFacade
fd 'LikeFacade\.java' apps/commerce-api/src/main/java -x cat
# Check imports in LikeApplicationService to see dependencies
grep -n "import\|package" apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java | head -20Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 4808
🏁 Script executed:
# Check if ProductFacade is accessible or if we need to expose evictProductListCache as public
grep -n "class ProductFacade\|public ProductInfo\|private void evictProductListCache" apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java | head -10
# Check the full signature of evictProductListCache
sed -n '159,164p' apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java
# Check if Product has brandId accessible
grep -n "brandId\|getBrandId" apps/commerce-api/src/main/java/com/loopers/domain/product/Product.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java
Length of output: 985
좋아요 쓰기 경로에서 상품 캐시를 무효화하지 않아 데이터 불일치 발생 가능하다.
LikeApplicationService.like()/unlike()에서 product.increaseLikes()/decreaseLikes()를 호출하고 저장하지만, 캐시 무효화가 없다. ProductInfo와 ProductListCacheEntry에 포함된 likeCount와 likes_desc 정렬이 최대 1분간 stale해진다.
수정안: LikeFacade.like()/unlike() 메서드에서 likeService.like/unlike() 트랜잭션 완료 후 반환된 Product의 brandId를 이용해 다음을 함께 무효화한다:
product:detail:{productId}exact key 삭제product:list:all:*와product:list:{brandId}:*패턴 삭제
추가 테스트: 상품 상세 캐시 적재 후 like/unlike 수행하고, API 응답에서 likeCount와 목록의 likes_desc 정렬 순서가 즉시 갱신되는지 검증한다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java`
around lines 80 - 86, After likeService.like()/unlike() completes in
LikeFacade.like()/unlike(), invalidate the product caches using the returned
Product.brandId: remove the exact key "product:detail:{productId}" and delete
pattern keys "product:list:all:*" and "product:list:{brandId}:*" so ProductInfo
and ProductListCacheEntry likeCount and likes_desc sorting are not stale;
perform the invalidation after the transaction-returned Product is obtained (use
product.getBrandId()) and ensure deletion uses masterRedisTemplate (or
equivalent) to evict cached list and detail entries. Also add a test that loads
the product detail and list caches, calls like() and unlike(), and asserts API
responses reflect updated likeCount and list ordering immediately.
| public Page<ProductInfo> findAll(Long brandId, Pageable pageable) { | ||
| return productService.findAll(brandId, pageable).map(ProductInfo::from); | ||
| // 첫 페이지가 아닌 경우 캐시 미적용 | ||
| if (pageable.getPageNumber() != 0) { | ||
| return productService.findAll(brandId, pageable).map(ProductInfo::from); | ||
| } | ||
|
|
||
| String cacheKey = buildCacheKey(brandId, pageable); |
There was a problem hiding this comment.
page=0의 임의 size를 전부 캐시하면 공개 API 하나로 캐시 키가 무한히 늘어난다.
지금 구현은 첫 페이지면 size 값과 무관하게 캐시하고, 키에도 pageSize를 그대로 넣는다. 운영에서는 ?page=0&size=1..N 요청만으로 Redis 키 수와 payload 크기가 끝없이 늘어나서 메모리 압박과 낮은 적중률로 바로 이어진다. 수정안은 캐시 대상 size를 고정값 하나로 제한하거나, 비정상적으로 큰 size는 캐시를 우회하도록 막는 것이다. 추가 테스트로는 size=10/20/1000 요청 후 허용한 size만 캐시 키가 생성되는지 검증하면 된다.
수정 예시
+ private static final int CACHEABLE_PAGE_SIZE = 20;
+
public Page<ProductInfo> findAll(Long brandId, Pageable pageable) {
- // 첫 페이지가 아닌 경우 캐시 미적용
- if (pageable.getPageNumber() != 0) {
+ // 첫 페이지 + 표준 페이지 크기만 캐시
+ if (pageable.getPageNumber() != 0 || pageable.getPageSize() != CACHEABLE_PAGE_SIZE) {
return productService.findAll(brandId, pageable).map(ProductInfo::from);
}Also applies to: 190-193
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java`
around lines 94 - 100, The current ProductFacade.findAll caches any page=0
response using the raw pageable size, which allows infinite cache keys; change
the caching logic to only cache when pageable.getPageNumber()==0 AND
pageable.getPageSize() matches an allowed/normalized size (e.g., a single fixed
CACHE_PAGE_SIZE constant or limited to a MAX_CACHE_SIZE), otherwise bypass cache
and call productService.findAll(...). Update buildCacheKey to use the normalized
cache size (or a sentinel like "uncached" when not caching) so keys cannot
explode, and add tests calling findAll with size=10/20/1000 to assert only the
allowed size produces a Redis key and payload.
| public ProductInfo create(Long brandId, String name, String description, int price, int stock, SellingStatus sellingStatus) { | ||
| brandService.findById(brandId); | ||
| return ProductInfo.from(productService.create(brandId, name, description, price, stock, sellingStatus)); | ||
| } |
There was a problem hiding this comment.
상품 생성 후 목록 캐시를 비우지 않아 새 상품이 최대 5분간 보이지 않는다.
목록 캐시는 첫 페이지를 5분 동안 유지하는데, create 경로는 현재 캐시 무효화를 하지 않는다. 그래서 latest 전체 목록과 브랜드별 목록이 이미 warm 상태면 새 상품 생성 직후에도 사용자는 이전 결과를 계속 받게 된다. 수정안은 생성 직후 evictProductListCache(created.brandId())를 호출하는 것이다. 추가 테스트로는 목록 캐시를 먼저 적재한 뒤 관리자 상품 생성 API를 호출하고, 다음 조회에서 새 상품이 즉시 보이는지 검증하면 된다.
수정 예시
public ProductInfo create(Long brandId, String name, String description, int price, int stock, SellingStatus sellingStatus) {
brandService.findById(brandId);
- return ProductInfo.from(productService.create(brandId, name, description, price, stock, sellingStatus));
+ ProductInfo info = ProductInfo.from(productService.create(brandId, name, description, price, stock, sellingStatus));
+ evictProductListCache(info.brandId());
+ return info;
}🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In
`@apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java`
around lines 122 - 125, The create method in ProductFacade currently calls
brandService.findById(...) and productService.create(...) but does not
invalidate the product list cache, so newly created products may not appear
until cache TTL expires; after creating the product (the return value of
productService.create called in ProductFacade.create), call
evictProductListCache(created.brandId()) to evict both the global "latest" and
brand-specific list caches before returning ProductInfo, and add a test that
warms the list cache, calls the admin create API, then fetches the list to
assert the new product appears immediately.
| @Table(name = "likes", indexes = { | ||
| @Index(name = "idx_user_created_at", columnList = "user_id, created_at DESC") | ||
| }) |
There was a problem hiding this comment.
운영 스키마에는 이 인덱스가 생성되지 않는다.
운영에서는 modules/jpa/src/main/resources/jpa.yml 기본값이 ddl-auto: none이라 이 @Index 선언이 실제 DDL로 반영되지 않는다. 지금 상태로는 local/test만 빨라지고 dev/qa/prd는 기존 스키마를 그대로 써서 likes뿐 아니라 같은 방식으로 추가한 orders, products 인덱스도 전부 누락된다. 수정안은 엔티티 어노테이션에 기대지 말고 Flyway/Liquibase나 운영 DDL migration으로 세 테이블 인덱스를 명시적으로 추가하는 것이다. 추가 테스트로는 마이그레이션 적용 후 information_schema.statistics 또는 SHOW INDEX로 각 인덱스 존재를 검증하면 된다.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@apps/commerce-api/src/main/java/com/loopers/domain/like/Like.java` around
lines 18 - 20, 현재 엔티티의 `@Index` 어노테이션(Like.java의 `@Table` indexes)은 운영에서 ddl-auto:
none 설정 때문에 DDL로 반영되지 않으므로, 엔티티 주석에 의존하지 말고 Flyway 또는 Liquibase 마이그레이션을 만들어
likes, orders, products 테이블에 대해 user_id + created_at DESC 인덱스를 명시적으로 추가하고 배포
스크립트에 포함시켜라; 마이그레이션 적용 후 information_schema.statistics 또는 SHOW INDEX를 사용해 각 테이블에
인덱스가 존재하는지 검증하도록 테스트를 추가하라.
📌 Summary
🧭 Context & Decision
문제 정의
products,likes,orders테이블에 정렬/필터 기준 컬럼에 인덱스가 없어 조회마다 전체 스캔 발생 (약 9.9만~10만 건)filesort제거 및 실제 읽은 rows 수 감소, 캐시 적용 후 E2E 테스트 통과선택지와 결정
deleted_at IS NULL조건에 인덱스 추가 — soft-delete 비율이 5%로 낮아 선택도가 좋지 않아 효과 미미like_count,created_at,price)에 단일/복합 인덱스 추가 — 정렬 비용 제거 및 LIMIT 조기 종료 가능isLiked는 사용자별 데이터이므로 캐시에서 제외하고 오버레이인덱스 적용 현황
인덱스 설계문서
productsidx_like_countlike_count DESCproductsidx_brand_like_countbrand_id, like_count DESCproductsidx_created_atcreated_at DESCproductsidx_brand_created_atbrand_id, created_at DESCproductsidx_priceprice ASCproductsidx_brand_pricebrand_id, price ASClikesidx_user_created_atuser_id, created_at DESCordersidx_user_created_atuser_id, created_at DESC캐시 적용 현황
product:list:{brandId or all}:{sort}:0:{size}product:detail:{productId}좋아요 정렬 개선
이미 이전에 개발 완료됨
좋아요 순 정렬은
Product.likeCount컬럼을 기준으로 함. 이 값의 정합성은LikeApplicationService가 보장함.처리 흐름
productRepository.findByIdWithLock(productId)(PESSIMISTIC_WRITE) → 중복 없으면likeRepository.save()+product.increaseLikes()productRepository.findByIdWithLock(productId)(PESSIMISTIC_WRITE) → 존재하면likeRepository.delete()+product.decreaseLikes()Like 저장/삭제와
likeCount변경이 동일 트랜잭션 내에서 처리되므로 두 값은 항상 일치함.동시성 전략
PESSIMISTIC_WRITE)likeCount변경이 하나의 트랜잭션LikeCount.decrease()에서 0 이하 방지 (CoreException)LikeCount는@Embeddable불변 VO로,increase()/decrease()호출 시 새 인스턴스를 반환하며Product에@Embedded로 매핑되어 있음.🏗️ Design Overview
변경 범위
commerce-api— Product, Like, Order 도메인products테이블 인덱스 6개 (idx_like_count,idx_brand_like_count,idx_created_at,idx_brand_created_at,idx_price,idx_brand_price)likes테이블 인덱스 1개 (idx_user_created_at)orders테이블 인덱스 1개 (idx_user_created_at)ProductFacade.findById()Cache-Aside 로직ProductInfo.withIsLiked()헬퍼 메서드주요 컴포넌트 책임
Product/Like/Order:@Table인덱스 선언으로 DDL 자동 반영ProductFacade: Cache-Aside 조회(목록·상세), 수정/삭제 시 캐시 Eviction 처리ProductInfo:withIsLiked(Boolean)— 캐시 히트 시 사용자별 좋아요 여부 오버레이🔁 Flow Diagram
Main Flow — 상품 상세 조회 (Cache-Aside)
sequenceDiagram autonumber participant Client participant ProductFacade participant Redis participant DB participant LikeService Client->>ProductFacade: findById(productId, userId) ProductFacade->>Redis: GET product:detail:{productId} alt 캐시 히트 Redis-->>ProductFacade: ProductInfo (isLiked=null) ProductFacade->>LikeService: isLiked(userId, productId) LikeService-->>ProductFacade: Boolean ProductFacade-->>Client: ProductInfo.withIsLiked(isLiked) else 캐시 미스 Redis-->>ProductFacade: null ProductFacade->>DB: findById(productId) DB-->>ProductFacade: Product ProductFacade->>Redis: SET product:detail:{productId} TTL=1min (isLiked=null) ProductFacade->>LikeService: isLiked(userId, productId) LikeService-->>ProductFacade: Boolean ProductFacade-->>Client: ProductInfo.withIsLiked(isLiked) endEviction Flow — 상품 수정/삭제
sequenceDiagram autonumber participant Client participant ProductFacade participant DB participant Redis Client->>ProductFacade: update / delete ProductFacade->>DB: update / delete ProductFacade->>Redis: DEL product:list:* (SCAN) ProductFacade->>Redis: DEL product:detail:{productId} ProductFacade-->>Client: result변경 목적
products, likes, orders 테이블의 정렬/필터 컬럼에 인덱스를 추가하고 Cache-Aside 패턴을 적용하여 DB 조회 성능을 개선하고, 반복 조회 시 Redis에서 응답하여 DB 부하를 제거합니다.
핵심 변경점
product:detail:{productId}키(TTL 1분), 목록 조회는product:list:{brandId|all}:{sort}:0:{size}키(TTL 5분, 첫 페이지만 캐시)리스크/주의사항
테스트/검증 방법
E2E 테스트(ProductV1ApiE2ETest)에서 캐시 히트/미스(redisTemplate.hasKey/keys), 목록 캐시 페이지별 미적용(page>0 캐시 미저장), 어드민 수정/삭제 시 캐시 무효화(SCAN 기반·정확 키 삭제) 동작 검증
✍️ 리뷰포인트
이번 인덱스 최적화 과정에서 deleted_at IS NULL 조건이 있는 쿼리들이 있었는데, 현재 테스트 데이터에서는 soft-delete 비율을 약 5% 정도로 낮게 설정했습니다. 따라서 복합 인덱스 설계 시 deleted_at 컬럼은 인덱스에 포함하지 않고, 대신 정렬이나 주요 필터 조건 컬럼만 포함하는 방식으로 설계했습니다.
다만 실무에서는 서비스가 운영되면서 soft-delete 데이터가 계속 누적될 수 있고, 초기에는 낮던 비율이 점점 높아질 수도 있을 것 같다는 생각이 들었습니다. 실무에서는 다음과 같은 부분을 어떻게 판단하시는지 궁금합니다.
멘토님께서는 실무에서 soft-delete 컬럼을 인덱스에 포함할지 여부를 어떤 기준으로 판단하시는지 궁금합니다.