From 458a737b97cac091113f3c696d951912b86bfff1 Mon Sep 17 00:00:00 2001 From: yoon Date: Sun, 8 Mar 2026 21:44:38 +0900 Subject: [PATCH 1/4] =?UTF-8?q?test:=20=EC=BD=94=EB=93=9C=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=88=84=EB=9D=BD=EB=90=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../like/LikeFacadeIntegrationTest.java | 48 ++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java index 2e719bad2..0cd645c2d 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java @@ -181,7 +181,7 @@ void like_concurrent_likeCount() throws InterruptedException { List userIds = new ArrayList<>(); for (int i = 0; i < concurrency; i++) { UserDto.UserInfo u = userFacade.register( - "likeuser" + i, "TestPass1!", "좋아요유저" + i, + "likeuser" + i, "TestPass1!", "좋아요유저", LocalDate.of(2000, 1, 1), "like" + i + "@loopers.com", Gender.MALE ); userIds.add(u.id()); @@ -218,4 +218,50 @@ void like_concurrent_likeCount() throws InterruptedException { Product product = productRepository.findById(productId).orElseThrow(); assertThat(product.getLikeCount()).isEqualTo(concurrency); } + + @DisplayName("동일한 상품에 여러 명이 동시에 좋아요 취소를 해도 likeCount가 정상 반영된다") + @Test + void unlike_concurrent_likeCount() throws InterruptedException { + int concurrency = 3; + List userIds = new ArrayList<>(); + for (int i = 0; i < concurrency; i++) { + UserDto.UserInfo u = userFacade.register( + "unlike" + i, "TestPass1!", "취소유저", + LocalDate.of(2000, 1, 1), "unlike" + i + "@loopers.com", Gender.MALE + ); + userIds.add(u.id()); + likeFacade.like(u.id(), productId); + } + + ExecutorService executor = Executors.newFixedThreadPool(concurrency); + CountDownLatch ready = new CountDownLatch(concurrency); + CountDownLatch start = new CountDownLatch(1); + CountDownLatch done = new CountDownLatch(concurrency); + List failures = Collections.synchronizedList(new ArrayList<>()); + + for (int i = 0; i < concurrency; i++) { + final Long uid = userIds.get(i); + executor.submit(() -> { + ready.countDown(); + try { + start.await(); + likeFacade.unlike(uid, productId); + } catch (Throwable t) { + failures.add(t); + } finally { + done.countDown(); + } + }); + } + + assertThat(ready.await(5, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + assertThat(done.await(15, TimeUnit.SECONDS)).isTrue(); + executor.shutdown(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + + assertThat(failures).isEmpty(); + Product product = productRepository.findById(productId).orElseThrow(); + assertThat(product.getLikeCount()).isZero(); + } } From 6b2d1334fc999421d58b2deba483f0d1833d829c Mon Sep 17 00:00:00 2001 From: yoon Date: Thu, 12 Mar 2026 17:20:59 +0900 Subject: [PATCH 2/4] =?UTF-8?q?perf:=20=EC=83=81=ED=92=88=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=84=B1=EB=8A=A5=20=EA=B0=9C=EC=84=A0=EC=9D=84=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20=EB=B3=B5=ED=95=A9=20=EC=9D=B8=EB=8D=B1?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/loopers/domain/product/Product.java | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java index 29ea15218..e7ec22058 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java @@ -4,6 +4,7 @@ import com.loopers.domain.product.exception.ProductInsufficientStockException; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.Index; import jakarta.persistence.Table; import jakarta.persistence.Version; import lombok.Getter; @@ -11,7 +12,14 @@ import java.math.BigDecimal; @Entity -@Table(name = "products") +@Table( + name = "products", + indexes = { + @Index(name = "idx_products_brand_id_like_count", columnList = "brand_id, like_count DESC"), + @Index(name = "idx_products_brand_id_created_at", columnList = "brand_id, created_at DESC"), + @Index(name = "idx_products_brand_id_price", columnList = "brand_id, price ASC") + } +) @Getter public class Product extends BaseEntity { From 007d1ebb685a964299d3a6f8a58a9a0d71e60e19 Mon Sep 17 00:00:00 2001 From: yoon Date: Thu, 12 Mar 2026 23:01:35 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EC=83=81=ED=92=88=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=B0=8F=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20Redis=20=EC=BA=90=EC=8B=9C=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 단건/목록 조회 캐시 구현 (단건 TTL 1시간, 목록 TTL 1분) - 단건/전체/브랜드별 목록 조회 캐시 적용, 변경/삭제 시 evict - 좋아요 증가/감소 시 상품 단건 캐시 evict - 목록 likeCount는 TTL 허용, 단건은 즉시 evict로 정합성 수준 분리 - 캐시 Miss/장애 시 DB fallback으로 서비스 정상 동작 보장 --- CLAUDE.md | 5 ++ .../loopers/application/like/LikeFacade.java | 4 + .../application/product/ProductFacade.java | 76 ++++++++++------ .../product/ProductCacheStore.java | 86 +++++++++++++++++++ .../application/like/LikeFacadeTest.java | 4 +- .../product/ProductFacadeTest.java | 4 +- 6 files changed, 153 insertions(+), 26 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java diff --git a/CLAUDE.md b/CLAUDE.md index 386b3ad1f..576d809ad 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -132,6 +132,11 @@ - 요구사항 분석, 설계 문서 작성 요청 시 해당 파일을 Read 도구로 읽고 지침을 따를 것 - 시퀀스 다이어그램, 클래스 다이어그램, ERD 작성 시 Mermaid 문법 사용 +### 조회 API 설계 리뷰 +- 파일: `.claude/skills/read-optimization-review/SKILL.md` +- 조회 API 설계 검토, 캐시 전략 리뷰, 읽기 최적화 리뷰 요청 시 해당 파일을 Read 도구로 읽고 지침을 따를 것 +- 구조적 리스크와 trade-off 관점에서만 분석하며, 구현 코드나 설정 값은 제안하지 않음 + ## 도메인 & 객체 설계 전략 - 도메인 객체는 비즈니스 규칙을 캡슐화해야 합니다. diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index 3c57e4730..72332f8a7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -8,6 +8,7 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.user.UserRepository; +import com.loopers.infrastructure.product.ProductCacheStore; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import jakarta.persistence.OptimisticLockException; @@ -36,6 +37,7 @@ public class LikeFacade { private final UserRepository userRepository; private final BrandRepository brandRepository; private final LikeService likeService; + private final ProductCacheStore productCacheStore; @Transactional @Retryable( @@ -80,6 +82,7 @@ public LikeDto.LikeResult likeWithStatus(Long userId, Long productId) { product.increaseLikeCount(); Like saved = likeRepository.save(result.like()); productRepository.save(product); + productCacheStore.evictProduct(productId); return new LikeDto.LikeResult(LikeDto.LikeInfo.from(saved), false); } @@ -109,6 +112,7 @@ public void unlike(Long userId, Long productId) { product.decreaseLikeCount(); likeRepository.save(like); productRepository.save(product); + productCacheStore.evictProduct(productId); } @Transactional(readOnly = true) diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index a31ca0d03..ff36aa6d2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -7,6 +7,7 @@ import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.exception.ProductInsufficientStockException; import com.loopers.domain.product.exception.ProductNotDeletedException; +import com.loopers.infrastructure.product.ProductCacheStore; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -28,6 +29,7 @@ public class ProductFacade { private final ProductRepository productRepository; private final BrandRepository brandRepository; private final ProductService productService; + private final ProductCacheStore productCacheStore; @Transactional public ProductDto.ProductInfo register(Long brandId, String name, String description, BigDecimal price, Integer stock) { @@ -58,43 +60,65 @@ public ProductDto.ProductInfo update(Long productId, String name, String descrip Product saved = productRepository.save(product); Brand brand = brandRepository.findById(saved.getBrandId()) .orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND)); + productCacheStore.evictProduct(productId); return ProductDto.ProductInfo.of(saved, brand.getName()); } @Transactional(readOnly = true) public Page getProducts(ProductSortType sortType, Pageable pageable) { - Pageable sorted = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sortType.toSort()); - Page products = productRepository.findAll(sorted); - - Set brandIds = products.stream() - .map(Product::getBrandId) - .collect(Collectors.toSet()); - - Map brandNameMap = brandRepository.findAllByIds(brandIds).stream() - .collect(Collectors.toMap(Brand::getId, Brand::getName)); - - return products.map(product -> - ProductDto.ProductInfo.of(product, brandNameMap.getOrDefault(product.getBrandId(), "")) - ); + int page = pageable.getPageNumber(); + int size = pageable.getPageSize(); + + return productCacheStore.getProductList(null, sortType, page, size) + .orElseGet(() -> { + Pageable sorted = PageRequest.of(page, size, sortType.toSort()); + Page products = productRepository.findAll(sorted); + + Set brandIds = products.stream() + .map(Product::getBrandId) + .collect(Collectors.toSet()); + + Map brandNameMap = brandRepository.findAllByIds(brandIds).stream() + .collect(Collectors.toMap(Brand::getId, Brand::getName)); + + Page result = products.map(product -> + ProductDto.ProductInfo.of(product, brandNameMap.getOrDefault(product.getBrandId(), "")) + ); + productCacheStore.putProductList(null, sortType, page, size, result); + return result; + }); } @Transactional(readOnly = true) public Page getProductsByBrand(Long brandId, ProductSortType sortType, Pageable pageable) { - Brand brand = brandRepository.findById(brandId) - .orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND)); - - Pageable sorted = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sortType.toSort()); - return productRepository.findByBrandId(brandId, sorted) - .map(product -> ProductDto.ProductInfo.of(product, brand.getName())); + int page = pageable.getPageNumber(); + int size = pageable.getPageSize(); + + return productCacheStore.getProductList(brandId, sortType, page, size) + .orElseGet(() -> { + Brand brand = brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND)); + + Pageable sorted = PageRequest.of(page, size, sortType.toSort()); + Page result = productRepository.findByBrandId(brandId, sorted) + .map(product -> ProductDto.ProductInfo.of(product, brand.getName())); + productCacheStore.putProductList(brandId, sortType, page, size, result); + return result; + }); } @Transactional(readOnly = true) public ProductDto.ProductInfo getProduct(Long productId) { - Product product = productRepository.findById(productId) - .orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND)); - Brand brand = brandRepository.findById(product.getBrandId()) - .orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND)); - return ProductDto.ProductInfo.of(product, brand.getName()); + return productCacheStore.getProduct(productId) + .orElseGet(() -> { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND)); + Brand brand = brandRepository.findById(product.getBrandId()) + .orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND)); + ProductDto.ProductInfo info = ProductDto.ProductInfo.of(product, brand.getName()); + productCacheStore.putProduct(productId, info); + return info; + }); } @Transactional @@ -105,6 +129,7 @@ public ProductDto.ProductInfo increaseStock(Long productId, Integer quantity) { Product saved = productRepository.save(product); Brand brand = brandRepository.findById(saved.getBrandId()) .orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND)); + productCacheStore.evictProduct(productId); return ProductDto.ProductInfo.of(saved, brand.getName()); } @@ -120,6 +145,7 @@ public ProductDto.ProductInfo decreaseStock(Long productId, Integer quantity) { Product saved = productRepository.save(product); Brand brand = brandRepository.findById(saved.getBrandId()) .orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND)); + productCacheStore.evictProduct(productId); return ProductDto.ProductInfo.of(saved, brand.getName()); } @@ -129,6 +155,7 @@ public void delete(Long productId) { .orElseThrow(() -> new CoreException(ErrorType.PRODUCT_NOT_FOUND)); productService.deleteProduct(product); productRepository.save(product); + productCacheStore.evictProduct(productId); } @Transactional @@ -144,6 +171,7 @@ public ProductDto.ProductInfo restore(Long productId) { Product saved = productRepository.save(product); Brand brand = brandRepository.findByIdIncludingDeleted(saved.getBrandId()) .orElseThrow(() -> new CoreException(ErrorType.BRAND_NOT_FOUND)); + productCacheStore.evictProduct(productId); return ProductDto.ProductInfo.of(saved, brand.getName()); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java new file mode 100644 index 000000000..4c8467478 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheStore.java @@ -0,0 +1,86 @@ +package com.loopers.infrastructure.product; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.product.ProductDto; +import com.loopers.application.product.ProductSortType; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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 java.time.Duration; +import java.util.List; +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ProductCacheStore { + + private static final String PRODUCT_KEY_PREFIX = "product:"; + private static final String PRODUCT_LIST_KEY_PREFIX = "product:list:"; + private static final Duration PRODUCT_TTL = Duration.ofHours(1); + private static final Duration PRODUCT_LIST_TTL = Duration.ofMinutes(1); + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + public Optional getProduct(Long productId) { + try { + String json = redisTemplate.opsForValue().get(productKey(productId)); + if (json == null) return Optional.empty(); + return Optional.of(objectMapper.readValue(json, ProductDto.ProductInfo.class)); + } catch (Exception e) { + log.warn("캐시 조회 실패 - product:{}", productId, e); + return Optional.empty(); + } + } + + public void putProduct(Long productId, ProductDto.ProductInfo info) { + try { + String json = objectMapper.writeValueAsString(info); + redisTemplate.opsForValue().set(productKey(productId), json, PRODUCT_TTL); + } catch (Exception e) { + log.warn("캐시 저장 실패 - product:{}", productId, e); + } + } + + public void evictProduct(Long productId) { + redisTemplate.delete(productKey(productId)); + } + + public Optional> getProductList(Long brandId, ProductSortType sortType, int page, int size) { + try { + String json = redisTemplate.opsForValue().get(listKey(brandId, sortType, page, size)); + if (json == null) return Optional.empty(); + ProductListCache cache = objectMapper.readValue(json, ProductListCache.class); + return Optional.of(new PageImpl<>(cache.content(), PageRequest.of(page, size), cache.totalElements())); + } catch (Exception e) { + log.warn("캐시 조회 실패 - product list brand:{} sort:{} page:{}", brandId, sortType, page, e); + return Optional.empty(); + } + } + + public void putProductList(Long brandId, ProductSortType sortType, int page, int size, Page result) { + try { + ProductListCache cache = new ProductListCache(result.getContent(), result.getTotalElements()); + String json = objectMapper.writeValueAsString(cache); + redisTemplate.opsForValue().set(listKey(brandId, sortType, page, size), json, PRODUCT_LIST_TTL); + } catch (Exception e) { + log.warn("캐시 저장 실패 - product list brand:{} sort:{} page:{}", brandId, sortType, page, e); + } + } + + private String productKey(Long id) { + return PRODUCT_KEY_PREFIX + id; + } + + private String listKey(Long brandId, ProductSortType sortType, int page, int size) { + return PRODUCT_LIST_KEY_PREFIX + "brand=" + brandId + ":sort=" + sortType + ":page=" + page + ":size=" + size; + } + + private record ProductListCache(List content, long totalElements) {} +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java index 50c597b32..016e68c33 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java @@ -8,6 +8,7 @@ import com.loopers.domain.product.ProductRepository; import com.loopers.domain.user.User; import com.loopers.domain.user.UserRepository; +import com.loopers.infrastructure.product.ProductCacheStore; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.BeforeEach; @@ -44,7 +45,8 @@ void setUp() { productRepository, userRepository, brandRepository, - new LikeService() + new LikeService(), + mock(ProductCacheStore.class) ); } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java index d56a4733f..9a1c50cdb 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -7,6 +7,7 @@ import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; import com.loopers.domain.product.ProductRepository; +import com.loopers.infrastructure.product.ProductCacheStore; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.BeforeEach; @@ -41,7 +42,8 @@ void setUp() { productFacade = new ProductFacade( productRepository, brandRepository, - new ProductService() + new ProductService(), + mock(ProductCacheStore.class) ); brand = Brand.create(new BrandName("LOOPERS"), new BrandDescription("루퍼스 브랜드")); From 6b60a8b720117bf92f555b8ab6a74a388d392109 Mon Sep 17 00:00:00 2001 From: yoon Date: Thu, 12 Mar 2026 23:40:48 +0900 Subject: [PATCH 4/4] =?UTF-8?q?docs:=20=EC=9D=B8=EB=8D=B1=EC=8A=A4/?= =?UTF-8?q?=EC=BA=90=EC=8B=9C=20=EC=A0=84=ED=9B=84=20JMeter=20=EB=B6=80?= =?UTF-8?q?=ED=95=98=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=A6=AC=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/performance/load-test-report.md | 120 +++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 docs/performance/load-test-report.md diff --git a/docs/performance/load-test-report.md b/docs/performance/load-test-report.md new file mode 100644 index 000000000..d6016af15 --- /dev/null +++ b/docs/performance/load-test-report.md @@ -0,0 +1,120 @@ +# 상품 조회 성능 최적화 부하 테스트 리포트 + +## 테스트 환경 + +| 항목 | 내용 | +|------|------| +| 서버 | Spring Boot 로컬 서버 (port 8080) | +| DB | MySQL 8.0 (Docker) | +| 캐시 | Redis (port 6379) | +| 데이터 | 브랜드 20개, 상품 100,000개 | +| 측정 도구 | JMeter | +| 설정 | Ramp-up 단계별, Loop Count = Infinite, Duration = 60초 | + +--- + +## Phase 1. 인덱스 전후 비교 (캐시 비활성화) + +### 테스트 조건 +- 캐시 완전 비활성화 (no-op) → 인덱스 단독 효과 측정 +- 적용 인덱스: `(brand_id, like_count DESC)` + +### 전체 지표 + +| 동시 사용자 | 평균(ms) 전→후 | p95(ms) 전→후 | p99(ms) 전→후 | TPS 전→후 | 오류율 전→후 | +|------------|--------------|--------------|--------------|----------|------------| +| 50명 | 262.84 → 171.08 (-34.9%) | 399 → 376 (-5.8%) | 473 → 473 (0%) | 172 → 256 (+48.4%) | 0% → 0% | +| 100명 | 579.83 → 351.16 (-39.4%) | 790 → 602 (-23.8%) | 987 → 880 (-10.8%) | 157 → 250 (+59.2%) | 0% → 0% | +| 200명 | 1348.96 → 814.22 (-39.6%) | 1,819 → 1,441 (-20.8%) | 2,001 → 1,728 (-13.6%) | 136 → 210 (+54.0%) | 0.01% → 0.01% | + +### API별 상세 지표 + +| 동시 사용자 | 브랜드별 목록 평균(ms) 전→후 | 전체 목록 평균(ms) 전→후 | 전체 목록 p95(ms) 전→후 | +|------------|--------------------------|----------------------|----------------------| +| 50명 | 259.62 → 85.13 (-67.2%) | 266.05 → 257.04 (-3.4%) | 403.95 → 421.00 (악화) | +| 100명 | 575.80 → 259.03 (-55.0%) | 583.85 → 443.28 (-24.1%) | 794 → 645 (-18.8%) | +| 200명 | 1,345.56 → 693.24 (-48.5%) | 1,352.37 → 935.20 (-30.8%) | 1,780 → 1,438 (-19.2%) | + +### 분석 + +**브랜드별 목록** +- 인덱스 `(brand_id, like_count DESC)` 가 직접 활용되어 평균 응답시간 48~67% 감소 +- Full Scan(100,000건) → Index lookup(5,000건)으로 스캔 범위 축소 + +**전체 목록** +- 인덱스 선두 컬럼(brand_id) 조건 없이 정렬만 하므로 인덱스 효과 제한적 +- 50명 구간에서 p95 오히려 소폭 악화 → Full Scan + filesort 구조 그대로 유지 +- **근본 해결책은 캐시** (모든 사용자가 동일한 응답을 받는 패턴) + +**TPS** +- 전 구간 48~59% 증가했으나 절대값은 136~256 수준으로 정체 +- 인덱스는 쿼리 경로를 개선하지만 DB 커넥션 병목 자체는 해소하지 못함 + +--- + +## Phase 2. 캐시 전후 비교 (인덱스 활성화) + +### 테스트 조건 +- 인덱스 on 고정 → 캐시 단독 효과 측정 +- 캐시 전: no-op / 캐시 후: Redis Cache-Aside 패턴 적용 +- JMeter 설정 변경 이유: 캐시 적용 후 응답이 빨라져 Loop Count 방식으로는 스레드가 조기 종료됨 → Duration 60초로 통일 + +### 전체 지표 + +| 동시 사용자 | 평균(ms) 전→후 | p95(ms) 전→후 | p99(ms) 전→후 | TPS 전→후 | 오류율 전→후 | +|------------|--------------|--------------|--------------|----------|------------| +| 50명 | 129 → 10 (-92.1%) | 347 → 17 (-95.1%) | 436 → 23 (-94.7%) | 369 → 4,428 (+12.0x) | 0% → 0% | +| 100명 | 267 → 18 (-93.2%) | 545 → 30 (-94.5%) | 685 → 40 (-94.2%) | 341 → 4,866 (+14.3x) | 0% → 0% | +| 200명 | 450 → 32 (-92.8%) | 810 → 68 (-91.6%) | 1,007 → 94 (-90.7%) | 369 → 5,024 (+13.6x) | 0% → 0% | + +### 분석 + +**응답시간** +- 전 구간 90% 이상 감소 +- 200명 p99 1,007ms → 94ms: 인덱스로 해결되지 않았던 전체 목록 꼬리 지연 완전 해소 + +**TPS** +- 13~14배 증가 (인덱스의 48~59% 증가와 극명한 대비) +- DB 쿼리를 Redis로 대체하면서 커넥션 풀 병목 제거 + +**오류율** +- 200명에서도 0% 유지: DB 커넥션 풀 한계 상황이 캐시로 차단됨 + +--- + +## 종합 비교 + +| 최적화 | 평균 응답시간 | TPS 변화 | 주요 효과 | +|--------|------------|---------|---------| +| 인덱스 | 35~40% 감소 | +48~59% | 브랜드별 쿼리 경로 최적화, filesort 제거 | +| 캐시 | 90%+ 감소 | +12~14배 | DB 요청 차단, 커넥션 풀 병목 해소 | + +### 인덱스 vs 캐시 역할 + +인덱스와 캐시는 대체 관계가 아닌 **계층적 방어선**이다. + +``` +요청 + ↓ +캐시 Hit → 즉시 응답 (Redis, ~10ms) + ↓ Miss +인덱스 활용 → 빠른 DB 쿼리 (~1ms, 브랜드별 기준) + ↓ (전체 목록 등 인덱스 미활용) +Full Scan → 느린 DB 쿼리 (~30ms+) +``` + +- 캐시 없이 인덱스만: DB 커넥션 병목, TPS 정체 +- 인덱스 없이 캐시만: 캐시 Miss 시 느린 쿼리 그대로 노출 → Cache Stampede 위험 +- **인덱스 + 캐시**: Miss 시에도 빠른 쿼리 보장 + 평상시 DB 부하 차단 + +--- + +## 캐시 설계 주요 결정사항 + +| 항목 | 결정 | 이유 | +|------|------|------| +| 단건 TTL | 1시간 | 상품 기본 정보 변경 빈도 낮음, 변경 시 즉시 evict | +| 목록 TTL | 1분 | likeCount stale 허용 범위 최소화 | +| 목록 likeCount stale 허용 | TTL 허용 | 좋아요 수는 실시간 정합성 불필요, 재고처럼 비즈니스 임팩트 없음 | +| 캐시 Miss/장애 시 | DB fallback | orElseGet() 패턴으로 서비스 정상 동작 보장 | +