-
Notifications
You must be signed in to change notification settings - Fork 44
[volume-5] 인덱스와 캐시를 사용한 성능 최적화 #196
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: hyejin0810
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,139 @@ | ||
| package com.loopers.infrastructure.product; | ||
|
|
||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.loopers.application.product.ProductInfo; | ||
| import com.loopers.config.redis.RedisConfig; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.beans.factory.annotation.Qualifier; | ||
| import org.springframework.data.domain.Page; | ||
| import org.springframework.data.domain.PageImpl; | ||
| import org.springframework.data.domain.PageRequest; | ||
| import org.springframework.data.redis.core.RedisTemplate; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| import org.springframework.data.redis.core.ScanOptions; | ||
|
|
||
| import java.time.Duration; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
| import java.util.Optional; | ||
| import java.util.Set; | ||
|
|
||
| @Slf4j | ||
| @Component | ||
| public class ProductCacheService { | ||
|
|
||
| private static final String KEY_PREFIX = "product:detail:"; | ||
| private static final Duration TTL = Duration.ofMinutes(10); | ||
|
|
||
| private static final String LIST_KEY_PREFIX = "product:list:"; | ||
| private static final Duration LIST_TTL = Duration.ofMinutes(5); | ||
|
|
||
| // 목록 캐시 직렬화용 내부 레코드 | ||
| record ProductListCache(List<ProductInfo> content, long totalElements, int pageNumber, int pageSize) {} | ||
|
|
||
| private final RedisTemplate<String, String> defaultRedisTemplate; // 읽기 (Replica) | ||
| private final RedisTemplate<String, String> masterRedisTemplate; // 쓰기 (Master) | ||
| private final ObjectMapper objectMapper; | ||
|
|
||
| public ProductCacheService( | ||
| RedisTemplate<String, String> defaultRedisTemplate, | ||
| @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate<String, String> masterRedisTemplate, | ||
| ObjectMapper objectMapper | ||
| ) { | ||
| this.defaultRedisTemplate = defaultRedisTemplate; | ||
| this.masterRedisTemplate = masterRedisTemplate; | ||
| this.objectMapper = objectMapper; | ||
|
Comment on lines
+35
to
+46
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Replica 읽기로 즉시 무효화 보장이 깨진다 관련 설정인 Also applies to: 47-57, 80-95 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| // 캐시 조회 - Redis 장애 시 Optional.empty() 반환하여 DB 폴백 | ||
| public Optional<ProductInfo> get(Long productId) { | ||
| try { | ||
| String json = defaultRedisTemplate.opsForValue().get(KEY_PREFIX + productId); | ||
| if (json == null) { | ||
| return Optional.empty(); | ||
| } | ||
| return Optional.of(objectMapper.readValue(json, ProductInfo.class)); | ||
| } catch (Exception e) { | ||
| log.warn("캐시 조회 실패 - productId: {}, error: {}", productId, e.getMessage()); | ||
| return Optional.empty(); | ||
|
Comment on lines
+57
to
+59
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# message-only 예외 로깅 패턴 점검
rg -n -C1 'log\.warn\(".*error: \{\}", .*e\.getMessage\(\)\)' \
apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheService.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 885 🏁 Script executed: #!/bin/bash
# ProductCacheService의 모든 로그 패턴 확인
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheService.java | grep -E 'log\.(warn|error|info)'
# 파일의 전체 라인 수 확인
wc -l apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheService.javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 759 🏁 Script executed: #!/bin/bash
# ProductCacheService 전체 내용 확인 (구조 파악)
cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheService.java | head -150Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 6466 Redis 캐시 조회/저장 실패 로그에서 예외의 cause를 누락하여 스택트레이스 추적이 불가능하다
이 패턴은 6개 위치에서 반복되므로 모두 수정해야 한다:
추가로 역직렬화 실패( 수정 예시- log.warn("캐시 조회 실패 - productId: {}, error: {}", productId, e.getMessage());
+ log.warn("캐시 조회 실패 - productId: {}", productId, e);
- log.warn("캐시 저장 실패 - productId: {}, error: {}", productInfo.id(), e.getMessage());
+ log.warn("캐시 저장 실패 - productId: {}", productInfo.id(), e);
- log.warn("캐시 삭제 실패 - productId: {}, error: {}", productId, e.getMessage());
+ log.warn("캐시 삭제 실패 - productId: {}", productId, e);
- log.warn("목록 캐시 조회 실패 - brandId: {}, sort: {}, error: {}", brandId, sort, e.getMessage());
+ log.warn("목록 캐시 조회 실패 - brandId: {}, sort: {}", brandId, sort, e);
- log.warn("목록 캐시 저장 실패 - brandId: {}, sort: {}, error: {}", brandId, sort, e.getMessage());
+ log.warn("목록 캐시 저장 실패 - brandId: {}, sort: {}", brandId, sort, e);
- log.warn("목록 캐시 전체 삭제 실패: {}", e.getMessage());
+ log.warn("목록 캐시 전체 삭제 실패", e);🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
|
|
||
| // 캐시 저장 - TTL 10분 | ||
| public void set(ProductInfo productInfo) { | ||
| try { | ||
| String json = objectMapper.writeValueAsString(productInfo); | ||
| masterRedisTemplate.opsForValue().set(KEY_PREFIX + productInfo.id(), json, TTL); | ||
| } catch (Exception e) { | ||
| log.warn("캐시 저장 실패 - productId: {}, error: {}", productInfo.id(), e.getMessage()); | ||
| } | ||
| } | ||
|
|
||
| // 캐시 무효화 | ||
| public void delete(Long productId) { | ||
| try { | ||
| masterRedisTemplate.delete(KEY_PREFIX + productId); | ||
| } catch (Exception e) { | ||
| log.warn("캐시 삭제 실패 - productId: {}, error: {}", productId, e.getMessage()); | ||
| } | ||
| } | ||
|
|
||
| // 목록 캐시 조회 | ||
| public Optional<Page<ProductInfo>> getList(Long brandId, String sort, int pageNumber, int pageSize) { | ||
| try { | ||
| String json = defaultRedisTemplate.opsForValue().get(listKey(brandId, sort, pageNumber, pageSize)); | ||
| if (json == null) { | ||
| return Optional.empty(); | ||
| } | ||
| ProductListCache cache = objectMapper.readValue(json, ProductListCache.class); | ||
| Page<ProductInfo> page = new PageImpl<>( | ||
| cache.content(), | ||
| PageRequest.of(cache.pageNumber(), cache.pageSize()), | ||
| cache.totalElements() | ||
| ); | ||
| return Optional.of(page); | ||
| } catch (Exception e) { | ||
| log.warn("목록 캐시 조회 실패 - brandId: {}, sort: {}, error: {}", brandId, sort, e.getMessage()); | ||
| return Optional.empty(); | ||
| } | ||
| } | ||
|
|
||
| // 목록 캐시 저장 | ||
| public void setList(Long brandId, String sort, Page<ProductInfo> page) { | ||
| try { | ||
| ProductListCache cache = new ProductListCache( | ||
| page.getContent(), page.getTotalElements(), | ||
| page.getNumber(), page.getSize() | ||
| ); | ||
| String json = objectMapper.writeValueAsString(cache); | ||
| masterRedisTemplate.opsForValue().set( | ||
| listKey(brandId, sort, page.getNumber(), page.getSize()), json, LIST_TTL | ||
| ); | ||
| } catch (Exception e) { | ||
| log.warn("목록 캐시 저장 실패 - brandId: {}, sort: {}, error: {}", brandId, sort, e.getMessage()); | ||
| } | ||
| } | ||
|
|
||
| // 목록 캐시 전체 무효화 (SCAN 기반 - 블로킹 방지) | ||
| public void deleteListAll() { | ||
| try { | ||
| ScanOptions options = ScanOptions.scanOptions() | ||
| .match(LIST_KEY_PREFIX + "*") | ||
| .count(100) | ||
| .build(); | ||
| List<String> keys = new ArrayList<>(); | ||
| masterRedisTemplate.scan(options).forEachRemaining(keys::add); | ||
| if (!keys.isEmpty()) { | ||
| masterRedisTemplate.delete(keys); | ||
| } | ||
|
Comment on lines
+121
to
+129
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# scan 사용 지점 및 커서 처리 패턴 점검
rg -n -C2 '\.scan\(' --type=java
rg -n -C2 'Cursor<' --type=javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 805 🏁 Script executed: cat -n apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheService.java | sed -n '110,135p'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 1252 🏁 Script executed: rg -n 'import.*Cursor' --type=javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 67 🌐 Web query:
💡 Result:
Sources: Citations:
🏁 Script executed: rg -n '\.scan\(' apps/commerce-api/src/main/java --type=java -A3Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 642 🏁 Script executed: rg -n 'delete\(' apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheService.java --type=java -B2 -A2Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 588 🏁 Script executed: # deleteListAll 메서드의 호출 지점 확인
rg -n 'deleteListAll' apps/commerce-api/src/main/java --type=javaRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 944 🌐 Web query:
💡 Result:
In practice, the returned cursor is typically a ScanOptions options = ScanOptions.scanOptions().match("foo:*").count(1000).build();
try (Cursor<String> c = redisTemplate.scan(options)) {
while (c.hasNext()) {
String key = c.next();
// ...
}
}Sources: [1] [2] 🏁 Script executed: # 프로젝트의 Spring Data Redis 버전 확인
find . -name "pom.xml" -o -name "build.gradle" | xargs grep -l "spring-data-redis" | head -1 | xargs cat | grep -A2 -B2 "spring-data-redis"Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 67 SCAN 커서 미종료와 전체 키 메모리 적재로 연결 누수 및 메모리 압박 발생 현재 구현은 다음 두 가지 운영상 문제를 야기한다:
배치 단위로 즉시 삭제하고 try-with-resources로 커서를 안전하게 종료하도록 수정하고, 대량 키(예: 10만 개) 시나리오에서 메모리 사용량과 무효화 지연을 측정하는 부하 테스트를 추가해야 한다. 수정 예시+import org.springframework.data.redis.core.Cursor;
...
- List<String> keys = new ArrayList<>();
- masterRedisTemplate.scan(options).forEachRemaining(keys::add);
- if (!keys.isEmpty()) {
- masterRedisTemplate.delete(keys);
- }
+ List<String> batch = new ArrayList<>(500);
+ try (Cursor<String> cursor = masterRedisTemplate.scan(options)) {
+ while (cursor.hasNext()) {
+ batch.add(cursor.next());
+ if (batch.size() == 500) {
+ masterRedisTemplate.delete(batch);
+ batch.clear();
+ }
+ }
+ }
+ if (!batch.isEmpty()) {
+ masterRedisTemplate.delete(batch);
+ }🤖 Prompt for AI Agents |
||
| } catch (Exception e) { | ||
| log.warn("목록 캐시 전체 삭제 실패: {}", e.getMessage()); | ||
| } | ||
| } | ||
|
|
||
| private String listKey(Long brandId, String sort, int pageNumber, int pageSize) { | ||
| String brand = brandId != null ? String.valueOf(brandId) : "all"; | ||
| return LIST_KEY_PREFIX + brand + ":" + sort + ":" + pageNumber + ":" + pageSize; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| -- ============================================================ | ||
| -- products 테이블 인덱스 추가 스크립트 | ||
| -- 대상 DB : loopers | ||
| -- | ||
| -- [실행 방법] | ||
| -- docker exec -i docker-mysql-1 mysql -uapplication -papplication < scripts/add-indexes.sql | ||
| -- | ||
| -- [설계 근거] | ||
| -- idx_products_brand_likes : 브랜드 필터 + 좋아요 정렬 커버 | ||
| -- idx_products_brand_price : 브랜드 필터 + 가격 정렬 커버 | ||
| -- idx_products_created_at : 전체 최신순 정렬 커버 | ||
| -- idx_products_brand_created : 브랜드 필터 + 최신순 정렬 커버 | ||
| -- deleted_at 미포함 이유 : 100% IS NULL → 카디널리티 1 → 인덱스 효과 없음 | ||
| -- ============================================================ | ||
|
|
||
| USE loopers; | ||
|
|
||
| -- ------------------------------------------------------------ | ||
| -- 인덱스 추가 | ||
| -- ------------------------------------------------------------ | ||
| CREATE INDEX idx_products_brand_likes ON products (brand_id, likes_count); | ||
| CREATE INDEX idx_products_brand_price ON products (brand_id, price); | ||
| CREATE INDEX idx_products_created_at ON products (created_at); | ||
| CREATE INDEX idx_products_brand_created ON products (brand_id, created_at); | ||
|
Comment on lines
+21
to
+24
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: ProductFacade에서 brandId 없이 호출되는 정렬 조합 확인
# brandId 파라미터가 null인 상태로 정렬 옵션이 사용되는 호출 패턴 확인
rg -n -A5 'getProducts' --type=java | head -50
# findAllByDeletedAtIsNull 메서드 정의 및 사용처 확인
ast-grep --pattern 'findAllByDeletedAtIsNull($$$)'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 5990 🏁 Script executed: # Find ProductRepositoryImpl and understand the query patterns
fd 'ProductRepositoryImpl' -t f --type=java
# Also find ProductRepository or ProductJpaRepository
fd 'ProductRepository' -t f --type=java | grep -E '(Impl|Jpa)'Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 428 🏁 Script executed: # Look at ProductFacade to understand sort option mappings
rg -n 'sort' apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java -A 3 -B 3Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 1495 🏁 Script executed: # Check ProductService to see how getProducts handles different sort options
rg -n 'getProducts|sort|price|likes' apps/commerce-api/src/main/java/com/loopers/domain/product/ -t java -A 2 | head -100Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 6133 🏁 Script executed: # Find ProductRepositoryImpl and examine the findProducts method
fd 'ProductRepositoryImpl' --type f
# Also check for ProductRepository interfaces
fd 'ProductRepository' --type f | grep -v testRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 354 🏁 Script executed: # Search for findProducts implementation
rg -n 'findProducts' apps/commerce-api/src/main -A 10 | head -80Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 4057 🏁 Script executed: # Look for findAllByDeletedAtIsNull usage
rg -n 'findAllByDeletedAtIsNull' apps/commerce-api/src/main -B 2 -A 5Repository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 5848 🏁 Script executed: # Check the SQL script for idempotency (IF NOT EXISTS clause)
cat scripts/add-indexes.sqlRepository: Loopers-dev-lab/loop-pack-be-l2-vol3-java Length of output: 1259 브랜드 필터 없이 가격 또는 좋아요순으로 정렬할 때 인덱스 미보유 및 재실행 시 실패 위험
컨트롤러에서 또한 SQL 스크립트의 권장 인덱스 추가 및 수정안CREATE INDEX IF NOT EXISTS idx_products_brand_likes ON products (brand_id, likes_count);
CREATE INDEX IF NOT EXISTS idx_products_brand_price ON products (brand_id, price);
CREATE INDEX IF NOT EXISTS idx_products_created_at ON products (created_at);
CREATE INDEX IF NOT EXISTS idx_products_brand_created ON products (brand_id, created_at);
-- 브랜드 필터 없는 정렬 쿼리 커버
CREATE INDEX IF NOT EXISTS idx_products_price ON products (price);
CREATE INDEX IF NOT EXISTS idx_products_likes ON products (likes_count);🤖 Prompt for AI Agents |
||
|
|
||
| -- ------------------------------------------------------------ | ||
| -- 결과 확인 | ||
| -- ------------------------------------------------------------ | ||
| SHOW INDEX FROM products; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,85 @@ | ||
| -- ============================================================ | ||
| -- 상품 목록 조회 성능 개선을 위한 테스트 데이터 적재 스크립트 | ||
| -- 대상 DB : loopers | ||
| -- | ||
| -- [실행 전 필수 조건] | ||
| -- 1. Docker 실행: docker compose -f docker/infra-compose.yml up -d | ||
| -- 2. 앱 실행: ./gradlew :apps:commerce-api:bootRun | ||
| -- → Hibernate ddl-auto: create 가 brands, products, likes 테이블을 생성함 | ||
| -- 3. 앱 종료 후 이 스크립트 실행: | ||
| -- docker exec -i docker-mysql-1 mysql -uapplication -papplication < scripts/seed-data.sql | ||
| -- | ||
| -- [주의] 앱을 재시작하면 ddl-auto: create 로 인해 테이블이 초기화됩니다. | ||
| -- 데이터를 유지하려면 jpa.yml 의 ddl-auto 를 validate 로 변경 후 재시작하세요. | ||
| -- ============================================================ | ||
|
|
||
| USE loopers; | ||
|
|
||
| -- ------------------------------------------------------------ | ||
| -- 1. 브랜드 20개 INSERT | ||
| -- ------------------------------------------------------------ | ||
| INSERT INTO brands (name, description, created_at, updated_at) VALUES | ||
| ('Nike', 'Just Do It', NOW(), NOW()), | ||
| ('Adidas', 'Impossible is Nothing', NOW(), NOW()), | ||
| ('Puma', 'Forever Faster', NOW(), NOW()), | ||
| ('New Balance', 'Fearlessly Independent', NOW(), NOW()), | ||
| ('Reebok', 'Be More Human', NOW(), NOW()), | ||
| ('Under Armour','The Only Way is Through', NOW(), NOW()), | ||
| ('Converse', 'Shoes Are Boring. Wear Sneakers', NOW(), NOW()), | ||
| ('Vans', 'Off the Wall', NOW(), NOW()), | ||
| ('FILA', 'For the love of sport', NOW(), NOW()), | ||
| ('Asics', 'Sound Mind Sound Body', NOW(), NOW()), | ||
| ('Saucony', 'Run Your World', NOW(), NOW()), | ||
| ('Brooks', 'Run Happy', NOW(), NOW()), | ||
| ('Mizuno', 'For the Love of Sport', NOW(), NOW()), | ||
| ('Salomon', 'Time to Play', NOW(), NOW()), | ||
| ('Columbia', 'Tested Tough', NOW(), NOW()), | ||
| ('Patagonia', 'We are in Business to Save Our Home Planet', NOW(), NOW()), | ||
| ('North Face', 'Never Stop Exploring', NOW(), NOW()), | ||
| ('Lululemon', 'Elevate the World from Mediocrity', NOW(), NOW()), | ||
| ('Champion', 'It Takes a Little More', NOW(), NOW()), | ||
| ('Lacoste', 'A Little Green Crocodile', NOW(), NOW()); | ||
|
|
||
| -- ------------------------------------------------------------ | ||
| -- 2. 상품 100,000개 INSERT (Recursive CTE - 빠른 방식) | ||
| -- brand_id : 1~20 랜덤 분포 | ||
| -- price : 1,000 ~ 500,000 (1,000원 단위) | ||
| -- stock : 0 ~ 1,000 | ||
| -- likes_count: 0 ~ 10,000 (롱테일 분포 - 대부분 낮고 소수만 높음) | ||
| -- created_at : 최근 1년 내 랜덤 날짜 | ||
| -- ------------------------------------------------------------ | ||
| SET cte_max_recursion_depth = 100000; | ||
|
|
||
| INSERT INTO products | ||
| (brand_id, name, price, stock, description, image_url, | ||
| version, likes_count, created_at, updated_at, deleted_at) | ||
| WITH RECURSIVE nums AS ( | ||
| SELECT 1 AS n | ||
| UNION ALL | ||
| SELECT n + 1 FROM nums WHERE n < 100000 | ||
| ) | ||
| SELECT | ||
| FLOOR(1 + RAND() * 20), | ||
| CONCAT('상품_', LPAD(n, 6, '0')), | ||
| FLOOR(1 + RAND() * 500) * 1000, | ||
| FLOOR(RAND() * 1001), | ||
| CONCAT('상품 ', n, '번의 상세 설명입니다.'), | ||
| CONCAT('https://cdn.example.com/products/', n, '.jpg'), | ||
| 0, | ||
| FLOOR(POW(RAND(), 3) * 10001), | ||
| DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 365) DAY), | ||
| DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 365) DAY), | ||
|
Comment on lines
+70
to
+71
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
현재 두 컬럼을 독립적으로 랜덤 생성해서 시간 역전 데이터가 만들어질 수 있다. 이런 샘플은 정렬·감사·변경 이력 관련 검증을 왜곡하므로 🤖 Prompt for AI Agents |
||
| NULL | ||
| FROM nums; | ||
|
|
||
| -- ------------------------------------------------------------ | ||
| -- 3. 결과 확인 | ||
| -- ------------------------------------------------------------ | ||
| SELECT | ||
| COUNT(*) AS total_products, | ||
| COUNT(DISTINCT brand_id) AS brand_count, | ||
| MIN(price) AS min_price, | ||
| MAX(price) AS max_price, | ||
| ROUND(AVG(likes_count), 1) AS avg_likes, | ||
| MAX(likes_count) AS max_likes | ||
| FROM products; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
상품 변경 캐시 무효화도 커밋 이후로 미뤄야 한다
생성/수정/삭제 모두 같은 트랜잭션 안에서 Redis 키를 먼저 지우고 있다. 이 순서에서는 커밋 전에 다른 요청이 이전 DB 상태를 읽어 캐시를 다시 채우는 경쟁 조건이 생겨, 방금 반영한 상품 정보가 오래된 캐시로 덮일 수 있다. 무효화는
afterCommit훅이나 커밋 후 이벤트 리스너로 옮기는 편이 안전하다. 추가로 생성/수정/삭제 각각에 대해 “커밋 전 조회가 캐시를 재생성하더라도 커밋 후 최종 캐시는 최신 상태”인지 보는 동시성 통합 테스트를 두는 것이 좋다.Also applies to: 87-101
🤖 Prompt for AI Agents