-
Notifications
You must be signed in to change notification settings - Fork 44
[volume - 5] 인덱스 및 캐시 적용을 통한 조회 최적화 - 김남진 #215
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: Namjin-kimm
Are you sure you want to change the base?
Changes from all commits
71323c4
a791b25
6f86cae
33f03e8
395c9f3
6f5f5dc
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| package com.loopers.application.product; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.transaction.event.TransactionPhase; | ||
| import org.springframework.transaction.event.TransactionalEventListener; | ||
|
|
||
| @RequiredArgsConstructor | ||
| @Component | ||
| public class ProductCacheEventListener { | ||
|
|
||
| private final ProductCacheRepository productCacheRepository; | ||
|
|
||
| /** | ||
| * DB 트랜잭션 커밋 완료 후에 캐시 무효화 실행. | ||
| * BEFORE_COMMIT이 아닌 AFTER_COMMIT을 사용하는 이유: | ||
| * 커밋 전에 캐시를 삭제하면, 삭제~커밋 사이 구간에 다른 요청이 | ||
| * 캐시 미스 → DB 조회(구 버전) → 구 버전 재캐싱하는 레이스가 발생하기 때문. | ||
| */ | ||
| @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) | ||
| public void handleProductChanged(ProductCacheEvictEvent event) { | ||
| productCacheRepository.evictAll(); | ||
| } | ||
|
|
||
| // 특정 상품의 상세 캐시만 핀포인트 무효화 (주문 재고 차감, 상품/브랜드 수정 시 호출) | ||
| @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) | ||
| public void handleProductDetailChanged(ProductDetailCacheEvictEvent event) { | ||
| productCacheRepository.evictDetail(event.productId()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.loopers.application.product; | ||
|
|
||
| /** | ||
| * 상품 데이터 변경 시 캐시 무효화를 요청하는 이벤트. | ||
| * 트랜잭션 커밋 후(AFTER_COMMIT)에 처리되어 | ||
| * "미커밋 상태에서 구 버전 재캐싱" 레이스 컨디션을 방지한다. | ||
| */ | ||
| public record ProductCacheEvictEvent() { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| package com.loopers.application.product; | ||
|
|
||
| import java.util.Optional; | ||
|
|
||
| /** | ||
| * 상품 캐시 포트 (Secondary Port / Driven Port). | ||
| * application 레이어는 이 인터페이스에만 의존 → Redis 등 구현 기술이 바뀌어도 Facade 코드 변경 없음. | ||
| */ | ||
| public interface ProductCacheRepository { | ||
|
|
||
| // ── 목록 캐시 ──────────────────────────────────────────────────────────── | ||
|
|
||
| // 캐시 조회 - 미스 시 Optional.empty() 반환 (예외 미전파) | ||
| Optional<ProductPageResult> getList(String cacheKey); | ||
|
|
||
| // 캐시 저장 (Redis 장애 시에도 예외 미전파) | ||
| void saveList(String cacheKey, ProductPageResult result); | ||
|
|
||
| // 상품 등록/수정/삭제 시 목록 캐시 전체 무효화 | ||
| void evictAll(); | ||
|
|
||
| // ── 상세 캐시 ──────────────────────────────────────────────────────────── | ||
|
|
||
| // 상세 캐시 조회 - 미스 시 Optional.empty() 반환 | ||
| Optional<ProductInfo> getDetail(Long productId); | ||
|
|
||
| // 상세 캐시 저장 (목록 페이지 워밍업 시 호출) | ||
| void saveDetail(Long productId, ProductInfo info); | ||
|
|
||
| // 특정 상품 상세 캐시 무효화 | ||
| void evictDetail(Long productId); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.loopers.application.product; | ||
|
|
||
| /** | ||
| * 특정 상품 상세 캐시를 무효화하는 이벤트. | ||
| * 재고 차감(주문), 상품 정보 수정/삭제, 브랜드명 수정 등 민감한 데이터 변경 시 발행. | ||
| * AFTER_COMMIT에서 처리되어 커밋 전 구 버전 재캐싱 레이스를 방지한다. | ||
| */ | ||
| public record ProductDetailCacheEvictEvent(Long productId) { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| package com.loopers.application.product; | ||
|
|
||
| import org.springframework.data.domain.Page; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| /** | ||
| * 상품 목록 조회 결과를 캐시에 저장하기 위한 직렬화 가능 record. | ||
| * Page<ProductInfo>는 PageImpl에 @JsonCreator가 없어 Jackson 역직렬화 불가 → 직접 필드를 선언하여 해결 | ||
| */ | ||
| public record ProductPageResult( | ||
| List<ProductInfo> products, | ||
| int page, | ||
| int size, | ||
| long totalElements, | ||
| int totalPages | ||
| ) { | ||
| public static ProductPageResult from(Page<ProductInfo> page) { | ||
| return new ProductPageResult( | ||
| page.getContent(), | ||
| page.getNumber(), | ||
| page.getSize(), | ||
| page.getTotalElements(), | ||
| page.getTotalPages() | ||
| ); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| package com.loopers.config; | ||
|
|
||
| import org.springframework.boot.context.properties.EnableConfigurationProperties; | ||
| import org.springframework.context.annotation.Configuration; | ||
|
|
||
| @Configuration | ||
| @EnableConfigurationProperties(CacheProperties.class) | ||
| public class CacheConfig { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| package com.loopers.config; | ||
|
|
||
| import org.springframework.boot.context.properties.ConfigurationProperties; | ||
|
|
||
| import java.util.HashMap; | ||
| import java.util.Map; | ||
| import java.util.concurrent.ThreadLocalRandom; | ||
|
|
||
| /** | ||
| * cache.* 설정을 타입 안전하게 바인딩. | ||
| * default-ttl-seconds: 전역 기본 TTL | ||
| * ttl-overrides: 캐시 이름별 TTL 재정의 (예: product:list → 180초) | ||
| * jitter-range-seconds: TTL에 ±N초 무작위 오차를 더해 동시 만료 방지 (캐시 스탬피드 완화) | ||
| * max-cacheable-page: 이 페이지 번호 미만(0-indexed)만 캐싱 - 딥 페이징 캐시 키 폭발 방지 | ||
| */ | ||
| @ConfigurationProperties(prefix = "cache") | ||
| public record CacheProperties( | ||
| long defaultTtlSeconds, | ||
| Map<String, Long> ttlOverrides, | ||
| long jitterRangeSeconds, | ||
| int maxCacheablePage | ||
| ) { | ||
| public CacheProperties { | ||
| if (ttlOverrides == null) { | ||
| ttlOverrides = new HashMap<>(); | ||
| } | ||
| } | ||
|
|
||
| public long getTtlSeconds(String cacheName) { | ||
| long base = ttlOverrides.getOrDefault(cacheName, defaultTtlSeconds); | ||
| if (jitterRangeSeconds <= 0) { | ||
| return base; | ||
| } | ||
| // Bounded Jitter: base ± jitterRange 범위에서 무작위 TTL | ||
| long jitter = ThreadLocalRandom.current().nextLong(-jitterRangeSeconds, jitterRangeSeconds + 1); | ||
| return Math.max(1, base + jitter); // 최소 1초 보장 | ||
| } | ||
|
|
||
| // page=0, 1, 2 → 캐싱 대상 / page=3 이상 → DB 직접 조회 | ||
| public boolean isCacheable(int pageNumber) { | ||
| return pageNumber < maxCacheablePage; | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,13 +7,22 @@ | |
| import jakarta.persistence.Column; | ||
| import jakarta.persistence.Embedded; | ||
| import jakarta.persistence.Entity; | ||
| import jakarta.persistence.Index; | ||
| import jakarta.persistence.Table; | ||
| import lombok.AccessLevel; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Entity | ||
| @Table(name = "product") | ||
| @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") | ||
| } | ||
| ) | ||
|
Comment on lines
+17
to
+25
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
# 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 정의가 생산 환경에 적용되지 않는 문제
권장 조치:
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);
🤖 Prompt for AI Agents |
||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| @Getter | ||
| public class Product extends BaseEntity { | ||
|
|
||
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.
ttlOverridesMap의 불변성을 보장해야 한다.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
🤖 Prompt for AI Agents