-
Notifications
You must be signed in to change notification settings - Fork 44
[volume-5] 인덱스와 캐시를 사용한 성능 최적화 #204
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: pable91
Are you sure you want to change the base?
Changes from all commits
503a77c
70e50af
deb3c17
c3353fa
403fc3f
efd7b9a
f4b00a3
f247916
7d8581d
b3a827c
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,98 @@ | ||
| package com.loopers.infrastructure.product; | ||
|
|
||
| import com.fasterxml.jackson.core.JsonProcessingException; | ||
| import com.fasterxml.jackson.core.type.TypeReference; | ||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import com.loopers.application.product.ProductInfo; | ||
| import com.loopers.application.product.ProductSearchCommand; | ||
| import com.loopers.config.redis.RedisConfig; | ||
| import java.time.Duration; | ||
| import java.util.List; | ||
| import java.util.Optional; | ||
| import lombok.extern.slf4j.Slf4j; | ||
| import org.springframework.beans.factory.annotation.Qualifier; | ||
| import org.springframework.data.redis.core.RedisTemplate; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| @Slf4j | ||
| @Component | ||
| public class ProductCacheStore { | ||
|
|
||
| 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); | ||
|
|
||
| private final RedisTemplate<String, String> redisTemplate; | ||
| private final RedisTemplate<String, String> masterRedisTemplate; | ||
| private final ObjectMapper objectMapper; | ||
|
|
||
| public ProductCacheStore( | ||
| RedisTemplate<String, String> redisTemplate, | ||
| @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate<String, String> masterRedisTemplate, | ||
| ObjectMapper objectMapper | ||
| ) { | ||
| this.redisTemplate = redisTemplate; | ||
| this.masterRedisTemplate = masterRedisTemplate; | ||
| this.objectMapper = objectMapper; | ||
| } | ||
|
|
||
| public Optional<ProductInfo> getProduct(Long productId) { | ||
| String key = detailKey(productId); | ||
| 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); | ||
|
Comment on lines
+43
to
+60
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. Redis 장애가 상품 조회 장애로 바로 번질 수 있다 현재는 Jackson 예외만 처리하므로 수정안은 읽기에서 Redis 접근 예외를 캐시 miss 로 흡수하고, 쓰기에서는 경고 로그만 남긴 뒤 본 요청은 성공시키는 것이다. 역직렬화 실패 시에도 같은 손상 데이터를 계속 읽지 않도록 문제 키를 삭제하는 처리까지 함께 넣는 편이 낫다. 추가 테스트로는 Redis read/write mock 이 예외를 던질 때 Also applies to: 66-83 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
|
|
||
| public Optional<List<ProductInfo>> getProducts(ProductSearchCommand command) { | ||
| String key = listKey(command); | ||
| try { | ||
| String value = redisTemplate.opsForValue().get(key); | ||
| if (value == null) { | ||
| return Optional.empty(); | ||
| } | ||
| return Optional.of(objectMapper.readValue(value, new TypeReference<>() {})); | ||
| } catch (JsonProcessingException e) { | ||
| log.warn("Failed to deserialize product list cache. key={}", key, e); | ||
| return Optional.empty(); | ||
| } | ||
| } | ||
|
|
||
| public void putProducts(ProductSearchCommand command, List<ProductInfo> productInfos) { | ||
| String key = listKey(command); | ||
| try { | ||
| masterRedisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(productInfos), LIST_TTL); | ||
| } catch (JsonProcessingException e) { | ||
| log.warn("Failed to serialize product list cache. key={}", key, e); | ||
| } | ||
| } | ||
|
|
||
| private String detailKey(Long productId) { | ||
| return DETAIL_KEY_PREFIX + productId; | ||
| } | ||
|
|
||
| private String listKey(ProductSearchCommand command) { | ||
| return LIST_KEY_PREFIX | ||
| + "brandId=" + command.brandId() | ||
| + ":sort=" + command.sortType() | ||
| + ":page=" + command.page() | ||
| + ":size=" + command.size(); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,7 @@ | |
| import com.loopers.domain.product.Product; | ||
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.Index; | ||
| import jakarta.persistence.Table; | ||
| import lombok.NoArgsConstructor; | ||
| import org.hibernate.annotations.Comment; | ||
|
|
@@ -12,7 +13,12 @@ | |
| * Product DB 엔티티 | ||
| */ | ||
| @Entity | ||
| @Table(name = "product") | ||
| @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") | ||
| }) | ||
|
Comment on lines
+16
to
+21
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: 마이그레이션 스크립트에서 해당 인덱스 생성 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:
💡 Result: Spring Boot 3.4.4 (its dependency management / BOM) uses Hibernate ORM 6.6.11.Final. [1] 운영 환경에서 인덱스 자동 생성이 되지 않으므로 마이그레이션 스크립트 필수 현재 현재 코드베이스에 마이그레이션 스크립트가 존재하지 않으므로, 운영 배포 전에 다음을 준비해야 한다:
🤖 Prompt for AI Agents |
||
| @NoArgsConstructor | ||
| public class ProductEntity extends BaseEntity { | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,36 @@ | ||||||
| -- ============================================================ | ||||||
| -- 상품 더미 데이터 시드 스크립트 (10만 건) | ||||||
| -- 실행 전제: 앱을 먼저 실행해서 테이블이 생성된 상태여야 합니다. | ||||||
| -- 실행 방법: mysql -u application -papplication loopers < db/seed-products.sql | ||||||
|
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. 실행 예시에 비밀번호를 평문으로 노출하면 보안 운영 습관이 약화된다. Line 4처럼 자격증명을 커맨드에 직접 넣으면 히스토리/문서 복사로 유출될 수 있다. 수정안은 비밀번호 프롬프트( 수정 예시 diff--- 실행 방법: mysql -u application -papplication loopers < db/seed-products.sql
+-- 실행 방법: mysql -u application -p loopers < db/seed-products.sql📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||
| -- ============================================================ | ||||||
|
|
||||||
| -- 브랜드 10개 삽입 | ||||||
| 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()); | ||||||
|
Comment on lines
+8
to
+19
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. 브랜드 ID를 1~10으로 가정하는 참조 방식은 FK 실패를 유발한다. 운영 관점에서 Line 27의 하드코딩 랜덤 ID는 기존 데이터가 있는 DB에서 즉시 실패하거나 잘못된 브랜드 연결을 만든다. 수정안은 실제 삽입/존재 브랜드의 수정 예시 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 |
||||||
|
|
||||||
| -- 상품 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), | ||||||
| FLOOR(1 + RAND() * 100) * 1000, | ||||||
| FLOOR(RAND() * 500), | ||||||
| FLOOR(RAND() * 1000), | ||||||
| DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 365) DAY), | ||||||
| NOW() | ||||||
| FROM | ||||||
| information_schema.columns a, | ||||||
| information_schema.columns b | ||||||
| LIMIT 100000; | ||||||
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.
캐시 적재 경로만 있고 무효화 전략이 없다
현재 변경분에서는
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