diff --git a/.claude/commands/analyze-transaction.md b/.claude/commands/analyze-transaction.md new file mode 100644 index 000000000..645cf40b9 --- /dev/null +++ b/.claude/commands/analyze-transaction.md @@ -0,0 +1,123 @@ +--- +name: analyze-transaction +description: Use when reviewing or implementing code with @Transactional, JPA persistence context, or concurrency control. Triggers on transaction boundary design, lock strategy selection, dirty checking concerns, flush timing issues, or when user says "트랜잭션 분석", "락 분석", "동시성 점검". +--- + +# Analyze Transaction + +대상이 되는 코드 범위를 탐색하고, Spring @Transactional, JPA, QueryDSL 기반의 코드에 대해 트랜잭션 범위, 영속성 컨텍스트, 쿼리 실행 시점 관점에서 분석한다. + +특히 다음을 중점적으로 점검한다: +- 트랜잭션이 불필요하게 크게 잡혀 있지는 않은지 +- 조회/쓰기 로직이 하나의 트랜잭션에 혼합되어 있지는 않은지 +- JPA의 지연 로딩, flush 타이밍, 변경 감지로 인해 의도치 않은 쿼리 또는 락이 발생할 가능성은 없는지 + +단순한 정답 제시가 아니라, 현재 구조의 의도와 trade-off를 드러내고 개선 가능 지점을 선택적으로 판단할 수 있도록 돕는다. + +## Analysis Scope + +이 스킬은 아래 대상에 대해 분석한다: +- @Transactional 이 선언된 클래스 / 메서드 +- Service / Facade / Application Layer 코드 +- JPA Entity, Repository, QueryDSL 사용 코드 +- 하나의 유즈케이스(요청 흐름) 단위 + +> 컨트롤러 → 서비스 → 레포지토리 전체 흐름을 기준으로 분석하며 특정 메서드만 떼어내어 판단하지 않는다. + +## Analysis Checklist + +### 1. Transaction Boundary 분석 + +다음을 순서대로 확인한다: + +1. **트랜잭션 시작 지점은 어디인가?** — Service / Facade / 그 외 계층? +2. **트랜잭션이 실제로 필요한 작업은 무엇인가?** — 상태 변경(쓰기) vs 단순 조회 +3. **트랜잭션 내부에서 수행되는 작업 나열:** + - 외부 API 호출 + - 복잡한 조회(QueryDSL) + - 반복문 기반 처리 + - 락 획득 (비관적/낙관적) + +**출력 형식:** + +``` +현재 트랜잭션 범위: {클래스.메서드()} + ├─ {작업 1} [읽기/쓰기/락] + ├─ {작업 2} [읽기/쓰기/락] + └─ {작업 3} [읽기/쓰기/락] + +트랜잭션이 필요한 핵심 작업: + - {작업 A} + - {작업 B} +``` + +### 2. 불필요하게 큰 트랜잭션 식별 + +아래 패턴이 존재하는지 점검한다: + +| 패턴 | 위험도 | 설명 | +|------|--------|------| +| Controller에서 @Transactional | 높음 | 트랜잭션 범위가 HTTP 요청 전체로 확장됨 | +| 읽기 로직이 쓰기 트랜잭션에 포함 | 중간 | 불필요한 락 경합, 커넥션 점유 | +| 외부 시스템 호출이 트랜잭션 내부 | 높음 | 네트워크 지연이 트랜잭션 길이에 직결 | +| 대량 조회가 트랜잭션 내부 | 중간 | 커넥션 풀 고갈 위험 | +| 상태 변경 이후 트랜잭션이 길게 유지 | 중간 | 락 홀딩 시간 증가 | + +### 3. JPA / 영속성 컨텍스트 관점 분석 + +다음을 중심으로 분석한다: + +- **flush 타이밍**: Entity 변경이 언제 DB에 반영되는지 +- **변경 감지(dirty checking)**: 조회용 Entity가 의도치 않게 변경 감지 대상이 되는지 +- **지연 로딩(lazy loading)**: 트랜잭션 후반에 N+1 쿼리가 발생할 가능성 +- **1차 캐시 문제**: 같은 엔티티를 락 없이 먼저 읽은 후 FOR UPDATE로 다시 읽을 때 stale 데이터 반환 +- **readOnly 미적용**: 단순 조회에 `@Transactional(readOnly = true)` 누락 여부 + +**체크리스트:** + +``` +□ 단순 조회인데 Entity 반환 후 변경 가능성 존재? +□ DTO Projection 대신 Entity 조회 사용 여부 +□ QueryDSL 조회 결과가 영속성 컨텍스트에 포함되는지 +□ 같은 엔티티를 락 없이 읽은 후 FOR UPDATE로 재조회하는 패턴? +□ @Transactional(readOnly = true) 적용 누락? +``` + +### 4. 동시성 제어 분석 + +락 전략이 적용된 경우 추가로 점검한다: + +| 점검 항목 | 설명 | +|-----------|------| +| **락 전략 적합성** | 비관적 vs 낙관적 선택이 도메인 특성에 맞는가? | +| **데드락 위험** | 여러 리소스를 락 걸 때 순서가 일관적인가? | +| **Self-invocation** | @Transactional 메서드를 같은 빈 내부에서 호출하고 있지 않은가? | +| **재시도 로직** | 낙관적 락 사용 시 ObjectOptimisticLockingFailureException 재시도가 구현되었는가? | +| **트랜잭션 전파** | 하위 서비스의 @Transactional이 상위와 의도대로 합류하는가? | + +### 5. Improvement Proposal (선택적 제안) + +개선안은 강제하지 않고 선택지로 제시한다: + +- **트랜잭션 분리**: 조회 → 쓰기 분리, Facade에서 orchestration +- **`@Transactional(readOnly = true)` 적용** +- **DTO Projection 도입**: 변경 감지 불필요한 조회 +- **외부 호출/이벤트 발행을 트랜잭션 외부로 이동** +- **락 순서 통일**: 리소스별 ID 오름차순 정렬 + +**제안 형식:** + +``` +[개선안 N] +- 현재: {현재 구조 설명} +- 제안: {변경 방향} +- 장점: {기대 효과} +- 고려사항: {트레이드오프} +``` + +## 톤 & 스타일 + +- 정답을 단정하지 않고 **현재 구조의 의도를 먼저 파악**한 뒤 개선 가능 지점을 제시 +- "이렇게 해야 한다"가 아니라 **"이런 선택지가 있다"** +- 코드 레벨에서 구체적 파일:라인 을 근거로 제시 +- 개발자의 설계 주도권을 존중 — 제안은 하되 결정은 개발자가 한다 diff --git a/.http/cache-test.http b/.http/cache-test.http new file mode 100644 index 000000000..d47829a43 --- /dev/null +++ b/.http/cache-test.http @@ -0,0 +1,25 @@ +### 캐시 미스 — 상품 상세 첫 번째 조회 +GET http://localhost:8080/api/v1/products/1 + +### 캐시 히트 — 상품 상세 두 번째 조회 (응답시간 비교) +GET http://localhost:8080/api/v1/products/1 + +### 상품 목록 — 캐시 미스 +GET http://localhost:8080/api/v1/products?brandId=1&sort=LIKES_DESC&page=0&size=20 + +### 상품 목록 — 캐시 히트 +GET http://localhost:8080/api/v1/products?brandId=1&sort=LIKES_DESC&page=0&size=20 + +### 상품 수정 (캐시 무효화 트리거) +PUT http://localhost:8080/api-admin/v1/products/1 +Content-Type: application/json +X-Admin-Id: admin + +{ + "name": "수정된 상품", + "description": "수정된 설명", + "price": 99999 +} + +### 수정 후 재조회 — 캐시 미스 (변경된 데이터) +GET http://localhost:8080/api/v1/products/1 diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index 7c643c92e..7a62461dc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -33,7 +33,7 @@ public BrandInfo update(Long brandId, String name, String description) { @Transactional public void delete(Long brandId) { brandService.delete(brandId); - productService.deleteAllByBrandId(brandId); + productService.softDeleteByBrandId(brandId); } public Page getAll(Pageable pageable) { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java index 4a7db0896..8d7af7ca8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -16,8 +16,8 @@ public record BrandInfo( public static BrandInfo from(BrandModel model) { return new BrandInfo( model.getId(), - model.name().value(), - model.description(), + model.getName(), + model.getDescription(), model.getCreatedAt(), model.getUpdatedAt(), model.getDeletedAt() diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java index 3fdc5c71d..9b359e474 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -1,7 +1,6 @@ package com.loopers.application.brand; import com.loopers.domain.brand.BrandModel; -import com.loopers.domain.brand.BrandName; import com.loopers.domain.brand.BrandRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -11,6 +10,11 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + @RequiredArgsConstructor @Component public class BrandService { @@ -42,15 +46,14 @@ public BrandModel getBrandForAdmin(Long brandId) { @Transactional public BrandModel update(Long brandId, String name, String description) { BrandModel brand = findById(brandId); - BrandName newName = new BrandName(name); - if (!brand.name().equals(newName)) { + if (!brand.getName().equals(name)) { brandRepository.findByName(name).ifPresent(existing -> { throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다."); }); } - brand.update(newName, description); + brand.update(name, description); return brand; } @@ -65,21 +68,15 @@ public Page getAll(Pageable pageable) { return brandRepository.findAll(pageable); } - @Transactional - public BrandModel update(Long id, String name, String description) { - BrandModel brand = getById(id); - brandRepository.findByName(name) - .filter(existing -> !existing.getId().equals(brand.getId())) - .ifPresent(existing -> { - throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다: " + name); - }); - brand.update(name, description); - return brand; + @Transactional(readOnly = true) + public Map getByIds(List ids) { + return brandRepository.findAllByIdIn(ids) + .stream() + .collect(Collectors.toMap(BrandModel::getId, Function.identity())); } - @Transactional - public void delete(Long id) { - BrandModel brand = getById(id); - brand.delete(); + private BrandModel findById(Long brandId) { + return brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다. [id = " + brandId + "]")); } } 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 bb5669f65..99ff72312 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 @@ -34,12 +34,12 @@ public Page getMyLikes(Long userId, Pageable pageable) { public Page getMyLikesWithProducts(Long userId, Pageable pageable) { Page likes = likeService.getMyLikes(userId, pageable); return likes.map(like -> { - ProductModel product = productService.getProduct(like.productId()); + ProductModel product = productService.getById(like.productId()); return new LikeWithProduct( like.getId(), product.getId(), - product.name(), - product.price().value(), + product.getName(), + product.getPrice().value(), like.getCreatedAt() ); }); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java index a3b5dd6c7..eecbfb7f6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java @@ -5,6 +5,7 @@ import com.loopers.domain.like.LikeToggleService; import com.loopers.application.product.ProductService; import lombok.RequiredArgsConstructor; +import org.springframework.cache.CacheManager; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -17,6 +18,7 @@ public class LikeTransactionService { private final LikeService likeService; private final ProductService productService; private final LikeToggleService likeToggleService; + private final CacheManager cacheManager; @Transactional public void doLike(Long userId, Long productId) { @@ -27,6 +29,7 @@ public void doLike(Long userId, Long productId) { if (result.countChanged()) { productService.incrementLikeCount(productId); + evictProductDetailCache(productId); } } @@ -37,5 +40,13 @@ public void doUnlike(Long userId, Long productId) { likeToggleService.unlike(activeLike.get()); productService.decrementLikeCount(activeLike.get().productId()); + evictProductDetailCache(productId); + } + + private void evictProductDetailCache(Long productId) { + var cache = cacheManager.getCache("productDetail"); + if (cache != null) { + cache.evict(productId); + } } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 443496bdb..647b1648a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -43,11 +43,11 @@ public OrderResult placeOrder(Long userId, List commands, Long List snapshots = new ArrayList<>(); for (OrderItemCommand cmd : sorted) { - ProductModel product = productService.getProduct(cmd.productId()); - Money subtotal = product.price().multiply(cmd.quantity()); + ProductModel product = productService.getById(cmd.productId()); + Money subtotal = product.getPrice().multiply(cmd.quantity()); totalAmount = totalAmount.add(subtotal); snapshots.add(new SnapshotHolder( - product.getId(), product.name(), product.price(), cmd.quantity() + product.getId(), product.getName(), product.getPrice(), cmd.quantity() )); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java index d2dd60302..77a2dcee3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java @@ -22,18 +22,18 @@ public record ProductDetail( public static ProductDetail ofCustomer(ProductModel product, String brandName, StockStatus stockStatus) { return new ProductDetail( - product.getId(), product.name(), product.description(), - product.price().value(), product.brandId(), brandName, - product.likeCount(), stockStatus, 0, + product.getId(), product.getName(), product.getDescription(), + product.getPrice().value(), product.getBrandId(), brandName, + product.getLikeCount(), stockStatus, 0, product.getCreatedAt(), product.getUpdatedAt(), product.getDeletedAt() ); } public static ProductDetail ofAdmin(ProductModel product, String brandName, int stockQuantity) { return new ProductDetail( - product.getId(), product.name(), product.description(), - product.price().value(), product.brandId(), brandName, - product.likeCount(), null, stockQuantity, + product.getId(), product.getName(), product.getDescription(), + product.getPrice().value(), product.getBrandId(), brandName, + product.getLikeCount(), null, stockQuantity, product.getCreatedAt(), product.getUpdatedAt(), product.getDeletedAt() ); } 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 0b8eb307b..93d9e6242 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 @@ -6,13 +6,19 @@ import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductSortType; import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockStatus; import com.loopers.application.stock.StockService; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Map; + @RequiredArgsConstructor @Component public class ProductFacade { @@ -22,63 +28,98 @@ public class ProductFacade { private final StockService stockService; @Transactional - public ProductModel register(String name, String description, Money price, Long brandId, int initialStock) { + public ProductDetail register(String name, String description, Money price, Long brandId, int initialStock) { brandService.getBrand(brandId); ProductModel product = productService.register(name, description, price, brandId); - stockService.create(product.getId(), initialStock); - return product; + stockService.save(product.getId(), initialStock); + String brandName = getBrandName(product.getBrandId()); + StockModel stock = stockService.getByProductId(product.getId()); + return ProductDetail.ofAdmin(product, brandName, stock.getQuantity()); } + @Cacheable(cacheNames = "productDetail", key = "#productId") @Transactional(readOnly = true) public ProductDetail getProduct(Long productId) { - ProductModel product = productService.getProduct(productId); - String brandName = getBrandName(product.brandId()); + ProductModel product = productService.getById(productId); + String brandName = getBrandName(product.getBrandId()); StockModel stock = stockService.getByProductId(productId); - return ProductDetail.ofCustomer(product, brandName, stock.toStatus()); + return ProductDetail.ofCustomer(product, brandName, StockStatus.from(stock.getQuantity())); } @Transactional(readOnly = true) public ProductDetail getProductForAdmin(Long productId) { - ProductModel product = productService.getProductForAdmin(productId); - String brandName = getBrandName(product.brandId()); + ProductModel product = productService.getById(productId); + String brandName = getBrandName(product.getBrandId()); StockModel stock = stockService.getByProductId(productId); - return ProductDetail.ofAdmin(product, brandName, stock.quantity()); + return ProductDetail.ofAdmin(product, brandName, stock.getQuantity()); } @Transactional(readOnly = true) public Page getProducts(Long brandId, ProductSortType sortType, Pageable pageable) { Page products = productService.getProducts(brandId, sortType, pageable); + + List brandIds = products.getContent().stream() + .map(ProductModel::getBrandId).distinct().toList(); + List productIds = products.getContent().stream() + .map(ProductModel::getId).toList(); + + Map brandMap = brandService.getByIds(brandIds); + Map stockMap = stockService.getByProductIds(productIds); + return products.map(product -> { - String brandName = getBrandName(product.brandId()); - StockModel stock = stockService.getByProductId(product.getId()); - return ProductDetail.ofCustomer(product, brandName, stock.toStatus()); + BrandModel brand = brandMap.get(product.getBrandId()); + String brandName = brand != null ? brand.getName() : null; + StockModel stock = stockMap.get(product.getId()); + StockStatus status = stock != null ? StockStatus.from(stock.getQuantity()) : StockStatus.OUT_OF_STOCK; + return ProductDetail.ofCustomer(product, brandName, status); }); } @Transactional(readOnly = true) public Page getProductsForAdmin(Long brandId, Pageable pageable) { Page products = productService.getProductsForAdmin(brandId, pageable); + + List brandIds = products.getContent().stream() + .map(ProductModel::getBrandId).distinct().toList(); + List productIds = products.getContent().stream() + .map(ProductModel::getId).toList(); + + Map brandMap = brandService.getByIds(brandIds); + Map stockMap = stockService.getByProductIds(productIds); + return products.map(product -> { - String brandName = getBrandName(product.brandId()); - StockModel stock = stockService.getByProductId(product.getId()); - return ProductDetail.ofAdmin(product, brandName, stock.quantity()); + BrandModel brand = brandMap.get(product.getBrandId()); + String brandName = brand != null ? brand.getName() : null; + StockModel stock = stockMap.get(product.getId()); + int stockQuantity = stock != null ? stock.getQuantity() : 0; + return ProductDetail.ofAdmin(product, brandName, stockQuantity); }); } + @CacheEvict(cacheNames = "productDetail", key = "#productId") @Transactional public ProductDetail update(Long productId, String name, String description, Money price) { productService.update(productId, name, description, price); return getProductForAdmin(productId); } + @CacheEvict(cacheNames = "productDetail", key = "#productId") public void delete(Long productId) { productService.delete(productId); } + @CacheEvict(cacheNames = "productDetail", key = "#productId") + @Transactional + public ProductDetail updateStock(Long productId, int quantity) { + StockModel stock = stockService.getByProductId(productId); + stock.update(quantity); + return getProductForAdmin(productId); + } + private String getBrandName(Long brandId) { try { BrandModel brand = brandService.getBrandForAdmin(brandId); - return brand.name().value(); + return brand.getName(); } catch (Exception e) { return null; } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index 18b5d04c9..f87561f32 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -40,8 +40,13 @@ public ProductModel getById(Long id) { } @Transactional(readOnly = true) - public Page getAll(Pageable pageable, ProductSortType sortType) { - return productRepository.findAll(pageable, sortType); + public Page getProducts(Long brandId, ProductSortType sortType, Pageable pageable) { + return productRepository.findAll(brandId, pageable, sortType); + } + + @Transactional(readOnly = true) + public Page getProductsForAdmin(Long brandId, Pageable pageable) { + return productRepository.findAll(brandId, pageable, ProductSortType.CREATED_DESC); } @Transactional diff --git a/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java b/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java index c9056c2d5..125d3ece7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java @@ -38,6 +38,6 @@ public StockModel getByProductIdForUpdate(Long productId) { public Map getByProductIds(List productIds) { return stockRepository.findAllByProductIdIn(productIds) .stream() - .collect(Collectors.toMap(StockModel::productId, stock -> stock)); + .collect(Collectors.toMap(StockModel::getProductId, stock -> stock)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java index 6e4cc110b..32bc4edca 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java @@ -1,7 +1,7 @@ package com.loopers.config; -import com.loopers.interfaces.auth.AdminUserArgumentResolver; -import com.loopers.interfaces.auth.LoginMemberArgumentResolver; +import com.loopers.interfaces.api.auth.AdminUserArgumentResolver; +import com.loopers.interfaces.api.auth.LoginMemberArgumentResolver; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java index 9f8204caa..e1565c888 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -7,6 +7,7 @@ import java.util.Optional; public interface BrandRepository { + BrandModel save(BrandModel brand); Optional findById(Long id); Optional findByName(String name); Page findAll(Pageable pageable); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java index d8cbe4a6d..0258c33a0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java @@ -6,9 +6,12 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; @Entity -@Table(name = "likes") +@Table(name = "likes", uniqueConstraints = { + @UniqueConstraint(name = "uk_likes_user_product", columnNames = {"user_id", "product_id"}) +}) public class LikeModel extends BaseEntity { @Column(name = "user_id", nullable = false) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java index e64ce0ffd..5c1d08145 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java @@ -14,7 +14,6 @@ public class Money { public static final Money ZERO = new Money(0); - @Column(name = "price", nullable = false) private int value; public Money(int value) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java index 4083c2b0a..a4c3703f3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -7,10 +7,16 @@ import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; +import jakarta.persistence.Index; import jakarta.persistence.Table; @Entity -@Table(name = "product") +@Table(name = "product", indexes = { + @Index(name = "idx_product_brand_deleted_like", columnList = "brand_id, deleted_at, like_count"), + @Index(name = "idx_product_deleted_like", columnList = "deleted_at, like_count"), + @Index(name = "idx_product_deleted_created", columnList = "deleted_at, created_at"), + @Index(name = "idx_product_deleted_price", columnList = "deleted_at, price") +}) public class ProductModel extends BaseEntity { @Column(nullable = false) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 2c0eeecfc..f74a0f387 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -17,5 +17,7 @@ public interface ProductRepository { Optional findById(Long id); List findAllByBrandId(Long brandId); + Page findAll(Long brandId, Pageable pageable, ProductSortType sortType); + List findAllByIdInAndDeletedAtIsNull(List ids); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java index d2dc834b4..4f4a1b59f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java @@ -1,6 +1,7 @@ package com.loopers.domain.product; public enum ProductSortType { + LATEST, CREATED_DESC, PRICE_ASC, PRICE_DESC, diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java index e0c62cf0d..5e3761cee 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java @@ -4,6 +4,7 @@ import java.util.Optional; public interface StockRepository { + StockModel save(StockModel stock); Optional findByProductId(Long productId); Optional findByProductIdForUpdate(Long productId); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java index e4a3b1a3c..cdb8d3eea 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -10,7 +10,11 @@ public interface BrandJpaRepository extends JpaRepository { - Optional findByNameValue(String value); + Optional findByIdAndDeletedAtIsNull(Long id); + + Optional findByNameAndDeletedAtIsNull(String name); + + Page findAllByDeletedAtIsNull(Pageable pageable); List findAllByIdIn(List ids); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 9d09cdbc4..9480f9ab4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -21,8 +21,12 @@ public interface ProductJpaRepository extends JpaRepository @Query("UPDATE ProductModel p SET p.likeCount = p.likeCount - 1 WHERE p.id = :id AND p.likeCount > 0") int decrementLikeCount(@Param("id") Long id); + Optional findByIdAndDeletedAtIsNull(Long id); + Page findAllByDeletedAtIsNull(Pageable pageable); + List findAllByBrandIdAndDeletedAtIsNull(Long brandId); + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); Page findAllByBrandId(Long brandId, Pageable pageable); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index a6a1b765a..7080dd59f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -3,11 +3,14 @@ import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.ProductSortType; +import com.loopers.domain.product.QProductModel; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import java.util.List; @@ -18,6 +21,7 @@ public class ProductRepositoryImpl implements ProductRepository { private final ProductJpaRepository productJpaRepository; + private final JPAQueryFactory queryFactory; @Override public ProductModel save(ProductModel product) { @@ -45,23 +49,38 @@ public List findAllByBrandId(Long brandId) { } @Override - public Page findAll(Pageable pageable, ProductSortType sortType) { - Sort sort = toSort(sortType); - Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort); - return productJpaRepository.findAllByDeletedAtIsNull(sortedPageable); - } + public Page findAll(Long brandId, Pageable pageable, ProductSortType sortType) { + QProductModel product = QProductModel.productModel; - @Override - public ProductModel save(ProductModel product) { - return productJpaRepository.save(product); + BooleanBuilder where = new BooleanBuilder(); + where.and(product.deletedAt.isNull()); + if (brandId != null) { + where.and(product.brandId.eq(brandId)); + } + + OrderSpecifier orderSpecifier = toOrderSpecifier(product, sortType); + + List content = queryFactory.selectFrom(product) + .where(where) + .orderBy(orderSpecifier) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory.select(product.count()) + .from(product) + .where(where) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0); } - private Sort toSort(ProductSortType sortType) { + private OrderSpecifier toOrderSpecifier(QProductModel product, ProductSortType sortType) { return switch (sortType) { - case CREATED_DESC -> Sort.by(Sort.Direction.DESC, "createdAt"); - case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "price.value"); - case PRICE_DESC -> Sort.by(Sort.Direction.DESC, "price.value"); - case LIKES_DESC -> Sort.by(Sort.Direction.DESC, "likeCount"); + case LATEST, CREATED_DESC -> product.createdAt.desc(); + case PRICE_ASC -> product.price.value.asc(); + case PRICE_DESC -> product.price.value.desc(); + case LIKES_DESC -> product.likeCount.desc(); }; } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java index d7242d0c6..d95eb90c6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java @@ -14,6 +14,11 @@ public class StockRepositoryImpl implements StockRepository { private final StockJpaRepository stockJpaRepository; + @Override + public StockModel save(StockModel stock) { + return stockJpaRepository.save(stock); + } + @Override public Optional findByProductId(Long productId) { return stockJpaRepository.findByProductId(productId); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMemberArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMemberArgumentResolver.java index d746e275d..c686dcac0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMemberArgumentResolver.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMemberArgumentResolver.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.auth; -import com.loopers.domain.member.MemberAuthService; +import com.loopers.application.member.MemberAuthService; import com.loopers.domain.member.MemberModel; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java index 07d6d8821..e3f7560e1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java @@ -50,7 +50,7 @@ public ApiResponse getById( @AdminUser String adminLdap, @PathVariable(value = "brandId") Long brandId ) { - BrandInfo info = brandFacade.getById(brandId); + BrandInfo info = brandFacade.getBrandForAdmin(brandId); return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java deleted file mode 100644 index 680103f80..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.loopers.interfaces.api.brand.admin; - -import com.loopers.application.brand.BrandFacade; -import com.loopers.application.brand.BrandInfo; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.AdminInfo; -import com.loopers.interfaces.auth.AdminUser; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api-admin/v1/brands") -public class BrandAdminV1Controller { - - private final BrandFacade brandFacade; - - @PostMapping - public ApiResponse create( - @AdminUser AdminInfo admin, - @RequestBody BrandAdminV1Dto.CreateRequest request - ) { - BrandInfo info = brandFacade.register(request.name(), request.description()); - return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); - } - - @GetMapping - public ApiResponse> getAll( - @AdminUser AdminInfo admin, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size - ) { - Page result = brandFacade.getAll(PageRequest.of(page, size)); - return ApiResponse.success(result.map(BrandAdminV1Dto.BrandResponse::from)); - } - - @GetMapping("/{brandId}") - public ApiResponse getBrand( - @AdminUser AdminInfo admin, - @PathVariable Long brandId - ) { - BrandInfo info = brandFacade.getBrandForAdmin(brandId); - return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); - } - - @PutMapping("/{brandId}") - public ApiResponse update( - @AdminUser AdminInfo admin, - @PathVariable Long brandId, - @RequestBody BrandAdminV1Dto.UpdateRequest request - ) { - BrandInfo info = brandFacade.update(brandId, request.name(), request.description()); - return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); - } - - @DeleteMapping("/{brandId}") - public ApiResponse delete(@AdminUser AdminInfo admin, @PathVariable Long brandId) { - brandFacade.delete(brandId); - return ApiResponse.success(); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java deleted file mode 100644 index 7ae7e03fe..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.loopers.interfaces.api.brand.admin; - -import com.loopers.application.brand.BrandInfo; - -import java.time.ZonedDateTime; - -public class BrandAdminV1Dto { - - public record CreateRequest( - String name, - String description - ) {} - - public record UpdateRequest( - String name, - String description - ) {} - - public record BrandResponse( - Long id, - String name, - String description, - ZonedDateTime createdAt, - ZonedDateTime updatedAt, - ZonedDateTime deletedAt - ) { - public static BrandResponse from(BrandInfo info) { - return new BrandResponse( - info.id(), - info.name(), - info.description(), - info.createdAt(), - info.updatedAt(), - info.deletedAt() - ); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java index ea9294343..12263dce7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java @@ -4,8 +4,7 @@ import com.loopers.application.coupon.CouponService; import com.loopers.domain.coupon.CouponModel; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.AdminInfo; -import com.loopers.interfaces.auth.AdminUser; +import com.loopers.interfaces.api.auth.AdminUser; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -20,7 +19,7 @@ public class CouponAdminV1Controller { @GetMapping("/api-admin/v1/coupons") public ApiResponse> getCoupons( - @AdminUser AdminInfo admin, + @AdminUser String adminLdap, Pageable pageable ) { Page coupons = couponService.getAllCoupons(pageable); @@ -29,7 +28,7 @@ public ApiResponse> getCoupons( @GetMapping("/api-admin/v1/coupons/{couponId}") public ApiResponse getCoupon( - @AdminUser AdminInfo admin, + @AdminUser String adminLdap, @PathVariable Long couponId ) { CouponModel coupon = couponService.getCoupon(couponId); @@ -38,7 +37,7 @@ public ApiResponse getCoupon( @PostMapping("/api-admin/v1/coupons") public ApiResponse createCoupon( - @AdminUser AdminInfo admin, + @AdminUser String adminLdap, @RequestBody CouponAdminV1Dto.CreateRequest request ) { CouponModel coupon = couponService.create( @@ -50,7 +49,7 @@ public ApiResponse createCoupon( @PutMapping("/api-admin/v1/coupons/{couponId}") public ApiResponse updateCoupon( - @AdminUser AdminInfo admin, + @AdminUser String adminLdap, @PathVariable Long couponId, @RequestBody CouponAdminV1Dto.UpdateRequest request ) { @@ -63,7 +62,7 @@ public ApiResponse updateCoupon( @DeleteMapping("/api-admin/v1/coupons/{couponId}") public ApiResponse deleteCoupon( - @AdminUser AdminInfo admin, + @AdminUser String adminLdap, @PathVariable Long couponId ) { couponService.delete(couponId); @@ -72,7 +71,7 @@ public ApiResponse deleteCoupon( @GetMapping("/api-admin/v1/coupons/{couponId}/issues") public ApiResponse> getCouponIssues( - @AdminUser AdminInfo admin, + @AdminUser String adminLdap, @PathVariable Long couponId, Pageable pageable ) { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java index cbbe0042e..77e2c240c 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java @@ -6,7 +6,7 @@ import com.loopers.domain.coupon.CouponModel; import com.loopers.domain.member.MemberModel; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.LoginMember; +import com.loopers.interfaces.api.auth.LoginMember; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java index da90d2f46..cccf79cff 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -4,7 +4,7 @@ import com.loopers.application.like.LikeWithProduct; import com.loopers.domain.member.MemberModel; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.LoginMember; +import com.loopers.interfaces.api.auth.LoginMember; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java index bcdcfd412..93235c45a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -4,7 +4,7 @@ import com.loopers.application.member.MemberInfo; import com.loopers.domain.member.MemberModel; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.LoginMember; +import com.loopers.interfaces.api.auth.LoginMember; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PutMapping; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java index fcfcc414a..9c389b0c1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java @@ -25,7 +25,7 @@ public ApiResponse> getAll( @AdminUser String adminLdap, Pageable pageable ) { - Page response = orderFacade.getAllOrders(pageable) + Page response = orderFacade.getAllForAdmin(pageable) .map(OrderAdminV1Dto.OrderAdminSummaryResponse::from); return ApiResponse.success(response); } @@ -36,7 +36,7 @@ public ApiResponse getById( @AdminUser String adminLdap, @PathVariable(value = "orderId") Long orderId ) { - OrderInfo.Detail info = orderFacade.getDetailForAdmin(orderId); + OrderInfo info = orderFacade.getOrderForAdmin(orderId); return ApiResponse.success(OrderAdminV1Dto.OrderAdminDetailResponse.from(info)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java index a77501ea1..63dce7090 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java @@ -10,9 +10,9 @@ public class OrderAdminV1Dto { public record OrderAdminSummaryResponse( Long id, int totalAmount, String status, int itemCount, ZonedDateTime createdAt ) { - public static OrderAdminSummaryResponse from(OrderInfo.Summary info) { + public static OrderAdminSummaryResponse from(OrderInfo info) { return new OrderAdminSummaryResponse( - info.id(), info.totalAmount(), info.status(), info.itemCount(), info.createdAt() + info.orderId(), info.totalAmount(), info.status(), info.items().size(), info.createdAt() ); } } @@ -21,12 +21,12 @@ public record OrderAdminDetailResponse( Long id, Long memberId, int totalAmount, String status, List orderItems, ZonedDateTime createdAt ) { - public static OrderAdminDetailResponse from(OrderInfo.Detail info) { - List items = info.orderItems().stream() + public static OrderAdminDetailResponse from(OrderInfo info) { + List items = info.items().stream() .map(OrderV1Dto.OrderItemResponse::from) .toList(); return new OrderAdminDetailResponse( - info.id(), info.memberId(), info.totalAmount(), info.status(), items, info.createdAt() + info.orderId(), info.userId(), info.totalAmount(), info.status(), items, info.createdAt() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java index 21c758e17..b81ac1ccb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -11,11 +11,11 @@ public interface OrderV1ApiSpec { @Operation(summary = "주문 생성", description = "새로운 주문을 생성합니다.") - ApiResponse createOrder(MemberModel member, OrderV1Dto.CreateOrderRequest request); + ApiResponse createOrder(MemberModel member, OrderV1Dto.CreateRequest request); @Operation(summary = "내 주문 목록 조회", description = "내 주문 목록을 조회합니다.") - ApiResponse> getMyOrders(MemberModel member, Pageable pageable); + ApiResponse getMyOrders(MemberModel member, Pageable pageable); @Operation(summary = "주문 상세 조회", description = "주문 상세 정보를 조회합니다.") - ApiResponse getById(MemberModel member, Long orderId); + ApiResponse getById(MemberModel member, Long orderId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java index 385550781..ac1c863ce 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -5,7 +5,7 @@ import com.loopers.application.order.OrderResult; import com.loopers.domain.member.MemberModel; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.LoginMember; +import com.loopers.interfaces.api.auth.LoginMember; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.GetMapping; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java deleted file mode 100644 index 1067c8535..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.loopers.interfaces.api.order.admin; - -import com.loopers.application.order.OrderFacade; -import com.loopers.application.order.OrderInfo; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.AdminInfo; -import com.loopers.interfaces.auth.AdminUser; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api-admin/v1/orders") -public class OrderAdminV1Controller { - - private final OrderFacade orderFacade; - - @GetMapping - public ApiResponse> getAll( - @AdminUser AdminInfo admin, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size - ) { - Page orders = orderFacade.getAllForAdmin(PageRequest.of(page, size)); - return ApiResponse.success(orders.map(OrderAdminV1Dto.OrderSummaryResponse::from)); - } - - @GetMapping("/{orderId}") - public ApiResponse getOrder( - @AdminUser AdminInfo admin, - @PathVariable Long orderId - ) { - OrderInfo info = orderFacade.getOrderForAdmin(orderId); - return ApiResponse.success(OrderAdminV1Dto.OrderResponse.from(info)); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java deleted file mode 100644 index a7c275e62..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.loopers.interfaces.api.order.admin; - -import com.loopers.application.order.OrderInfo; - -import java.time.ZonedDateTime; -import java.util.List; - -public class OrderAdminV1Dto { - - public record OrderResponse( - Long orderId, - Long userId, - String status, - int totalAmount, - List items, - ZonedDateTime createdAt - ) { - - public static OrderResponse from(OrderInfo info) { - List items = info.items().stream() - .map(OrderItemResponse::from) - .toList(); - return new OrderResponse( - info.orderId(), info.userId(), info.status(), - info.totalAmount(), items, info.createdAt() - ); - } - } - - public record OrderSummaryResponse( - Long orderId, - Long userId, - String status, - int totalAmount, - ZonedDateTime createdAt - ) { - - public static OrderSummaryResponse from(OrderInfo info) { - return new OrderSummaryResponse( - info.orderId(), info.userId(), info.status(), - info.totalAmount(), info.createdAt() - ); - } - } - - public record OrderItemResponse( - Long productId, - String productName, - int productPrice, - int quantity, - int subtotal - ) { - - public static OrderItemResponse from(OrderInfo.OrderItemInfo item) { - return new OrderItemResponse( - item.productId(), item.productName(), - item.productPrice(), item.quantity(), item.subtotal() - ); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java index c348c5076..b858f2e35 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.product; +import com.loopers.application.product.ProductDetail; import com.loopers.application.product.ProductFacade; -import com.loopers.application.product.ProductInfo; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductSortType; import com.loopers.interfaces.api.ApiResponse; @@ -33,11 +33,11 @@ public ApiResponse create( @AdminUser String adminLdap, @RequestBody ProductAdminV1Dto.CreateRequest request ) { - ProductInfo.AdminDetail info = productFacade.register( + ProductDetail detail = productFacade.register( request.name(), request.description(), new Money(request.price()), request.brandId(), request.stockQuantity() ); - return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(info)); + return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(detail)); } @GetMapping @@ -48,7 +48,7 @@ public ApiResponse> getAll( @RequestParam(value = "sortType", defaultValue = "CREATED_DESC") String sortType ) { ProductSortType sort = ProductSortType.valueOf(sortType); - Page response = productFacade.getAllForAdmin(pageable, sort) + Page response = productFacade.getProductsForAdmin(null, pageable) .map(ProductAdminV1Dto.ProductAdminSummaryResponse::from); return ApiResponse.success(response); } @@ -59,8 +59,8 @@ public ApiResponse getById( @AdminUser String adminLdap, @PathVariable(value = "productId") Long productId ) { - ProductInfo.AdminDetail info = productFacade.getDetailForAdmin(productId); - return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(info)); + ProductDetail detail = productFacade.getProductForAdmin(productId); + return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(detail)); } @PutMapping("/{productId}") @@ -70,10 +70,10 @@ public ApiResponse update( @PathVariable(value = "productId") Long productId, @RequestBody ProductAdminV1Dto.UpdateRequest request ) { - ProductInfo.AdminDetail info = productFacade.update( + ProductDetail detail = productFacade.update( productId, request.name(), request.description(), new Money(request.price()) ); - return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(info)); + return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(detail)); } @DeleteMapping("/{productId}") @@ -93,7 +93,7 @@ public ApiResponse updateStock( @PathVariable(value = "productId") Long productId, @RequestBody ProductAdminV1Dto.UpdateStockRequest request ) { - ProductInfo.AdminDetail info = productFacade.updateStock(productId, request.quantity()); - return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(info)); + ProductDetail detail = productFacade.updateStock(productId, request.quantity()); + return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(detail)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java index 2abdd5f52..c47d97e31 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.product; -import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductDetail; public class ProductAdminV1Dto { @@ -13,9 +13,9 @@ public record UpdateStockRequest(int quantity) {} public record ProductAdminSummaryResponse( Long id, String name, int price, String brandName, int stockQuantity ) { - public static ProductAdminSummaryResponse from(ProductInfo.AdminSummary info) { + public static ProductAdminSummaryResponse from(ProductDetail detail) { return new ProductAdminSummaryResponse( - info.id(), info.name(), info.price(), info.brandName(), info.stockQuantity() + detail.id(), detail.name(), detail.price(), detail.brandName(), detail.stockQuantity() ); } } @@ -24,10 +24,10 @@ public record ProductAdminDetailResponse( Long id, String name, String description, int price, Long brandId, String brandName, int likeCount, int stockQuantity ) { - public static ProductAdminDetailResponse from(ProductInfo.AdminDetail info) { + public static ProductAdminDetailResponse from(ProductDetail detail) { return new ProductAdminDetailResponse( - info.id(), info.name(), info.description(), info.price(), - info.brandId(), info.brandName(), info.likeCount(), info.stockQuantity() + detail.id(), detail.name(), detail.description(), detail.price(), + detail.brandId(), detail.brandName(), detail.likeCount(), detail.stockQuantity() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index 71e87e49d..160cf4525 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -6,6 +6,7 @@ import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -21,19 +22,20 @@ public class ProductV1Controller implements ProductV1ApiSpec { private final ProductFacade productFacade; @GetMapping - public ApiResponse> getProducts( - @RequestParam(required = false) Long brandId, - @RequestParam(defaultValue = "LATEST") ProductSortType sort, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size + @Override + public ApiResponse> getAll( + Pageable pageable, + @RequestParam(defaultValue = "CREATED_DESC") String sortType ) { - Page products = productFacade.getProducts(brandId, sort, PageRequest.of(page, size)); - return ApiResponse.success(products.map(ProductV1Dto.ProductResponse::from)); + ProductSortType sort = ProductSortType.valueOf(sortType); + Page products = productFacade.getProducts(null, sort, pageable); + return ApiResponse.success(products.map(ProductV1Dto.ProductSummaryResponse::from)); } @GetMapping("/{productId}") - public ApiResponse getProduct(@PathVariable Long productId) { + @Override + public ApiResponse getById(@PathVariable Long productId) { ProductDetail detail = productFacade.getProduct(productId); - return ApiResponse.success(ProductV1Dto.ProductResponse.from(detail)); + return ApiResponse.success(ProductV1Dto.ProductDetailResponse.from(detail)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java index b0a96c407..973da7ac8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -1,15 +1,29 @@ package com.loopers.interfaces.api.product; import com.loopers.application.product.ProductDetail; -import com.loopers.domain.stock.StockStatus; public class ProductV1Dto { public record ProductSummaryResponse( Long id, String name, int price, String brandName, String stockStatus ) { - public static ProductResponse from(ProductDetail detail) { - return new ProductResponse( + public static ProductSummaryResponse from(ProductDetail detail) { + return new ProductSummaryResponse( + detail.id(), + detail.name(), + detail.price(), + detail.brandName(), + detail.stockStatus() != null ? detail.stockStatus().name() : null + ); + } + } + + public record ProductDetailResponse( + Long id, String name, String description, int price, + Long brandId, String brandName, int likeCount, String stockStatus + ) { + public static ProductDetailResponse from(ProductDetail detail) { + return new ProductDetailResponse( detail.id(), detail.name(), detail.description(), @@ -17,7 +31,7 @@ public static ProductResponse from(ProductDetail detail) { detail.brandId(), detail.brandName(), detail.likeCount(), - detail.stockStatus() + detail.stockStatus() != null ? detail.stockStatus().name() : null ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java deleted file mode 100644 index 341540a36..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.loopers.interfaces.api.product.admin; - -import com.loopers.application.product.ProductDetail; -import com.loopers.application.product.ProductFacade; -import com.loopers.domain.product.Money; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.AdminInfo; -import com.loopers.interfaces.auth.AdminUser; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api-admin/v1/products") -public class ProductAdminV1Controller { - - private final ProductFacade productFacade; - - @PostMapping - public ApiResponse create( - @AdminUser AdminInfo admin, - @RequestBody ProductAdminV1Dto.CreateRequest request - ) { - var product = productFacade.register( - request.name(), request.description(), new Money(request.price()), - request.brandId(), request.initialStock() - ); - ProductDetail detail = productFacade.getProductForAdmin(product.getId()); - return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); - } - - @GetMapping - public ApiResponse> getAll( - @AdminUser AdminInfo admin, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @RequestParam(required = false) Long brandId - ) { - Page result = productFacade.getProductsForAdmin(brandId, PageRequest.of(page, size)); - return ApiResponse.success(result.map(ProductAdminV1Dto.ProductResponse::from)); - } - - @GetMapping("/{productId}") - public ApiResponse getProduct( - @AdminUser AdminInfo admin, - @PathVariable Long productId - ) { - ProductDetail detail = productFacade.getProductForAdmin(productId); - return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); - } - - @PutMapping("/{productId}") - public ApiResponse update( - @AdminUser AdminInfo admin, - @PathVariable Long productId, - @RequestBody ProductAdminV1Dto.UpdateRequest request - ) { - ProductDetail detail = productFacade.update(productId, request.name(), request.description(), new Money(request.price())); - return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); - } - - @DeleteMapping("/{productId}") - public ApiResponse delete(@AdminUser AdminInfo admin, @PathVariable Long productId) { - productFacade.delete(productId); - return ApiResponse.success(); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java deleted file mode 100644 index 5f58c5cb2..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.loopers.interfaces.api.product.admin; - -import com.loopers.application.product.ProductDetail; - -import java.time.ZonedDateTime; - -public class ProductAdminV1Dto { - - public record CreateRequest( - String name, - String description, - int price, - Long brandId, - int initialStock - ) {} - - public record UpdateRequest( - String name, - String description, - int price - ) {} - - public record ProductResponse( - Long id, - String name, - String description, - int price, - Long brandId, - String brandName, - int likeCount, - int stockQuantity, - ZonedDateTime createdAt, - ZonedDateTime updatedAt, - ZonedDateTime deletedAt - ) { - public static ProductResponse from(ProductDetail detail) { - return new ProductResponse( - detail.id(), - detail.name(), - detail.description(), - detail.price(), - detail.brandId(), - detail.brandName(), - detail.likeCount(), - detail.stockQuantity(), - detail.createdAt(), - detail.updatedAt(), - detail.deletedAt() - ); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminInfo.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminInfo.java deleted file mode 100644 index 516f8b1d8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminInfo.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.loopers.interfaces.auth; - -public record AdminInfo(String ldap) { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUser.java deleted file mode 100644 index 3cf3df48e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUser.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.loopers.interfaces.auth; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target(ElementType.PARAMETER) -@Retention(RetentionPolicy.RUNTIME) -public @interface AdminUser { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUserArgumentResolver.java deleted file mode 100644 index 969bd5d7e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUserArgumentResolver.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.loopers.interfaces.auth; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.springframework.core.MethodParameter; -import org.springframework.stereotype.Component; -import org.springframework.web.bind.support.WebDataBinderFactory; -import org.springframework.web.context.request.NativeWebRequest; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.method.support.ModelAndViewContainer; - -@Component -public class AdminUserArgumentResolver implements HandlerMethodArgumentResolver { - - private static final String VALID_LDAP = "loopers.admin"; - - @Override - public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(AdminUser.class) - && AdminInfo.class.isAssignableFrom(parameter.getParameterType()); - } - - @Override - public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { - String ldap = webRequest.getHeader("X-Loopers-Ldap"); - - if (ldap == null || ldap.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "어드민 인증 헤더가 누락되었습니다."); - } - - if (!VALID_LDAP.equals(ldap)) { - throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 어드민 인증입니다."); - } - - return new AdminInfo(ldap); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMember.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMember.java deleted file mode 100644 index 93ea3e09a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMember.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.loopers.interfaces.auth; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target(ElementType.PARAMETER) -@Retention(RetentionPolicy.RUNTIME) -public @interface LoginMember { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java deleted file mode 100644 index 7b8ccac5e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.loopers.interfaces.auth; - -import com.loopers.application.member.MemberAuthService; -import com.loopers.domain.member.MemberModel; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.core.MethodParameter; -import org.springframework.stereotype.Component; -import org.springframework.web.bind.support.WebDataBinderFactory; -import org.springframework.web.context.request.NativeWebRequest; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.method.support.ModelAndViewContainer; - -@RequiredArgsConstructor -@Component -public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { - - private final MemberAuthService memberAuthService; - - @Override - public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(LoginMember.class) - && MemberModel.class.isAssignableFrom(parameter.getParameterType()); - } - - @Override - public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { - String loginId = webRequest.getHeader("X-Loopers-LoginId"); - String loginPw = webRequest.getHeader("X-Loopers-LoginPw"); - - if (loginId == null || loginPw == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "인증 헤더가 누락되었습니다."); - } - - return memberAuthService.authenticate(loginId, loginPw); - } -} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ConcurrencyIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ConcurrencyIntegrationTest.java index d2543dca5..f983bbdd9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/ConcurrencyIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/ConcurrencyIntegrationTest.java @@ -48,7 +48,7 @@ class ConcurrencyIntegrationTest { private Long createBrand() { return brandService.register("테스트브랜드", "설명").getId(); } private Long createProduct(Long brandId, int stock) { - return productFacade.register("테스트상품", "설명", new Money(10000), brandId, stock).getId(); + return productFacade.register("테스트상품", "설명", new Money(10000), brandId, stock).id(); } @DisplayName("재고 동시성") @@ -86,7 +86,7 @@ void stockDecreasedCorrectlyUnderConcurrency() throws InterruptedException { // then assertThat(successCount.get()).isEqualTo(10); - assertThat(stockService.getByProductId(productId).quantity()).isEqualTo(90); + assertThat(stockService.getByProductId(productId).getQuantity()).isEqualTo(90); } @DisplayName("재고 5개인 상품에 10명이 동시 주문하면 5명만 성공한다") @@ -120,7 +120,7 @@ void onlyAvailableStockSucceeds() throws InterruptedException { // then assertThat(successCount.get()).isEqualTo(5); - assertThat(stockService.getByProductId(productId).quantity()).isEqualTo(0); + assertThat(stockService.getByProductId(productId).getQuantity()).isEqualTo(0); } } @@ -199,7 +199,7 @@ void likeCountAccurateUnderConcurrency() throws InterruptedException { // then assertThat(successCount.get()).isEqualTo(10); - assertThat(productService.getProduct(productId).likeCount()).isEqualTo(10); + assertThat(productService.getById(productId).getLikeCount()).isEqualTo(10); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java index c16164ffa..b38142344 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java @@ -1,6 +1,5 @@ package com.loopers.application.brand; -import com.loopers.application.brand.BrandService; import com.loopers.application.product.ProductService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -43,7 +42,7 @@ void deletesBrandAndCascadesProducts() { // then verify(brandService).delete(brandId); - verify(productService).deleteAllByBrandId(brandId); + verify(productService).softDeleteByBrandId(brandId); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java index dca5344fd..123462392 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java @@ -44,8 +44,8 @@ void createsBrandSuccessfully() { // then assertAll( () -> assertThat(result.getId()).isNotNull(), - () -> assertThat(result.name().value()).isEqualTo("나이키"), - () -> assertThat(result.description()).isEqualTo("스포츠 브랜드") + () -> assertThat(result.getName()).isEqualTo("나이키"), + () -> assertThat(result.getDescription()).isEqualTo("스포츠 브랜드") ); } @@ -78,7 +78,7 @@ void returnsBrand() { BrandModel result = brandService.getBrand(saved.getId()); // then - assertThat(result.name().value()).isEqualTo("나이키"); + assertThat(result.getName()).isEqualTo("나이키"); } @DisplayName("삭제된 브랜드면 NOT_FOUND 예외가 발생한다") @@ -112,8 +112,8 @@ void updatesSuccessfully() { // then assertAll( - () -> assertThat(result.name().value()).isEqualTo("뉴발란스"), - () -> assertThat(result.description()).isEqualTo("라이프스타일 브랜드") + () -> assertThat(result.getName()).isEqualTo("뉴발란스"), + () -> assertThat(result.getDescription()).isEqualTo("라이프스타일 브랜드") ); } @@ -128,8 +128,8 @@ void skipsDuplicateCheckWhenSameName() { // then assertAll( - () -> assertThat(result.name().value()).isEqualTo("나이키"), - () -> assertThat(result.description()).isEqualTo("설명만 변경") + () -> assertThat(result.getName()).isEqualTo("나이키"), + () -> assertThat(result.getDescription()).isEqualTo("설명만 변경") ); } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java index 2ff3c8645..90388c5b3 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java @@ -1,20 +1,24 @@ package com.loopers.application.brand; import com.loopers.domain.brand.BrandModel; -import com.loopers.domain.brand.BrandName; import com.loopers.domain.brand.BrandRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -24,6 +28,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class BrandServiceTest { @@ -79,7 +84,7 @@ void throwsOnDuplicateName() { @DisplayName("브랜드 조회") @Nested - class GetById { + class GetBrand { @DisplayName("존재하는 ID면 브랜드를 반환한다") @Test @@ -89,7 +94,7 @@ void returnsForExistingId() { BrandModel brand = new BrandModel("나이키", "스포츠"); given(brandRepository.findById(id)).willReturn(Optional.of(brand)); // act - BrandModel result = brandService.getById(id); + BrandModel result = brandService.getBrand(id); // assert assertThat(result.getName()).isEqualTo("나이키"); } @@ -102,7 +107,7 @@ void throwsOnNonExistentId() { given(brandRepository.findById(id)).willReturn(Optional.empty()); // act CoreException exception = assertThrows(CoreException.class, () -> { - brandService.getById(id); + brandService.getBrand(id); }); // assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); @@ -157,7 +162,7 @@ class Delete { void softDeletesSuccessfully() { // given Long brandId = 1L; - BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); + BrandModel brand = new BrandModel("나이키", "스포츠 브랜드"); when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); // when @@ -193,8 +198,8 @@ void returnsPagedResult() { // given Pageable pageable = PageRequest.of(0, 10); List brands = List.of( - new BrandModel(new BrandName("나이키"), "스포츠"), - new BrandModel(new BrandName("아디다스"), "스포츠") + new BrandModel("나이키", "스포츠"), + new BrandModel("아디다스", "스포츠") ); Page page = new PageImpl<>(brands, pageable, brands.size()); when(brandRepository.findAll(pageable)).thenReturn(page); 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 afee36977..11cc2383b 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 @@ -34,7 +34,7 @@ class LikeFacadeIntegrationTest { private Long createBrand(String name) { return brandService.register(name, "설명").getId(); } private Long createProduct(String name, int price, Long brandId) { - return productFacade.register(name, "설명", new Money(price), brandId, 10).getId(); + return productFacade.register(name, "설명", new Money(price), brandId, 10).id(); } @DisplayName("좋아요 등록") @@ -52,8 +52,8 @@ void likesProductAndIncrementsCount() { likeFacade.like(1L, productId); // then - ProductModel product = productService.getProduct(productId); - assertThat(product.likeCount()).isEqualTo(1); + ProductModel product = productService.getById(productId); + assertThat(product.getLikeCount()).isEqualTo(1); } @DisplayName("같은 상품에 두 번 좋아요해도 likeCount는 1이다 (멱등성)") @@ -68,8 +68,8 @@ void likeIsIdempotent() { likeFacade.like(1L, productId); // then - ProductModel product = productService.getProduct(productId); - assertThat(product.likeCount()).isEqualTo(1); + ProductModel product = productService.getById(productId); + assertThat(product.getLikeCount()).isEqualTo(1); } @DisplayName("좋아요 취소 후 다시 좋아요하면 복원된다") @@ -85,8 +85,8 @@ void restoresAfterUnlike() { likeFacade.like(1L, productId); // then - ProductModel product = productService.getProduct(productId); - assertThat(product.likeCount()).isEqualTo(1); + ProductModel product = productService.getById(productId); + assertThat(product.getLikeCount()).isEqualTo(1); } } @@ -106,8 +106,8 @@ void unlikeDecrementsCount() { likeFacade.unlike(1L, productId); // then - ProductModel product = productService.getProduct(productId); - assertThat(product.likeCount()).isEqualTo(0); + ProductModel product = productService.getById(productId); + assertThat(product.getLikeCount()).isEqualTo(0); } @DisplayName("좋아요하지 않은 상품을 취소해도 예외가 발생하지 않는다 (멱등성)") @@ -133,8 +133,8 @@ void doubleUnlikeIsIdempotent() { // when & then — 예외 없이 정상 완료 likeFacade.unlike(1L, productId); - ProductModel product = productService.getProduct(productId); - assertThat(product.likeCount()).isEqualTo(0); + ProductModel product = productService.getById(productId); + assertThat(product.getLikeCount()).isEqualTo(0); } } 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 697a0f91f..1b5e6e608 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 @@ -1,10 +1,8 @@ package com.loopers.application.like; -import com.loopers.domain.brand.BrandService; -import com.loopers.domain.like.LikeService; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.ProductService; +import com.loopers.application.product.ProductService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -13,9 +11,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; -import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class LikeFacadeTest { @@ -30,75 +27,39 @@ class LikeFacadeTest { private ProductService productService; @Mock - private BrandService brandService; + private LikeTransactionService likeTransactionService; @DisplayName("좋아요 등록") @Nested - class Register { + class Like { - @DisplayName("새로 생성되면 상품 좋아요 수를 증가시킨다") + @DisplayName("like 호출 시 likeTransactionService.doLike를 위임한다") @Test - void increasesLikeCountOnNewLike() { + void delegatesToLikeTransactionService() { // arrange - Long memberId = 1L; + Long userId = 1L; Long productId = 100L; - ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - given(productService.getById(productId)).willReturn(product); - given(likeService.register(memberId, productId)).willReturn(true); // act - likeFacade.register(memberId, productId); + likeFacade.like(userId, productId); // assert - then(productService).should().increaseLikeCount(productId); - } - - @DisplayName("이미 존재하면 좋아요 수를 증가시키지 않는다") - @Test - void doesNotIncreaseLikeCountOnExistingLike() { - // arrange - Long memberId = 1L; - Long productId = 100L; - ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - given(productService.getById(productId)).willReturn(product); - given(likeService.register(memberId, productId)).willReturn(false); - // act - likeFacade.register(memberId, productId); - // assert - then(productService).should(never()).increaseLikeCount(productId); + verify(likeTransactionService).doLike(userId, productId); } } @DisplayName("좋아요 취소") @Nested - class Cancel { - - @DisplayName("취소되면 상품 좋아요 수를 감소시킨다") - @Test - void decreasesLikeCountOnCancel() { - // arrange - Long memberId = 1L; - Long productId = 100L; - ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - given(productService.getById(productId)).willReturn(product); - given(likeService.cancel(memberId, productId)).willReturn(true); - // act - likeFacade.cancel(memberId, productId); - // assert - then(productService).should().decreaseLikeCount(productId); - } + class Unlike { - @DisplayName("좋아요가 없었으면 좋아요 수를 감소시키지 않는다") + @DisplayName("unlike 호출 시 likeTransactionService.doUnlike를 위임한다") @Test - void doesNotDecreaseLikeCountWhenNotExists() { + void delegatesToLikeTransactionService() { // arrange - Long memberId = 1L; + Long userId = 1L; Long productId = 100L; - ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - given(productService.getById(productId)).willReturn(product); - given(likeService.cancel(memberId, productId)).willReturn(false); // act - likeFacade.cancel(memberId, productId); + likeFacade.unlike(userId, productId); // assert - then(productService).should(never()).decreaseLikeCount(productId); + verify(likeTransactionService).doUnlike(userId, productId); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeTransactionCacheTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeTransactionCacheTest.java new file mode 100644 index 000000000..ed6369b60 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeTransactionCacheTest.java @@ -0,0 +1,109 @@ +package com.loopers.application.like; + +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.stock.StockModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.stock.StockJpaRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.CacheManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Import(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class LikeTransactionCacheTest { + + static final GenericContainer redisContainer = + new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(6379); + + static { + redisContainer.start(); + } + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + String host = redisContainer.getHost(); + int port = redisContainer.getFirstMappedPort(); + registry.add("datasource.redis.database", () -> 0); + registry.add("datasource.redis.master.host", () -> host); + registry.add("datasource.redis.master.port", () -> port); + registry.add("datasource.redis.replicas[0].host", () -> host); + registry.add("datasource.redis.replicas[0].port", () -> port); + } + + @Autowired private LikeTransactionService likeTransactionService; + @Autowired private ProductFacade productFacade; + @Autowired private CacheManager cacheManager; + @Autowired private BrandJpaRepository brandJpaRepository; + @Autowired private ProductJpaRepository productJpaRepository; + @Autowired private StockJpaRepository stockJpaRepository; + @Autowired private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + var cache = cacheManager.getCache("productDetail"); + if (cache != null) { + cache.clear(); + } + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("좋아요 등록 시 상품 상세 캐시가 삭제된다") + void 좋아요_등록_시_상품_상세_캐시가_삭제된다() { + // given + BrandModel brand = brandJpaRepository.save(new BrandModel("테스트브랜드", "설명")); + ProductModel product = productJpaRepository.save( + new ProductModel("테스트상품", "상품설명", new Money(10000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 100)); + + productFacade.getProduct(product.getId()); + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNotNull(); + + // when + likeTransactionService.doLike(1L, product.getId()); + + // then + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNull(); + } + + @Test + @DisplayName("좋아요 취소 시 상품 상세 캐시가 삭제된다") + void 좋아요_취소_시_상품_상세_캐시가_삭제된다() { + // given + BrandModel brand = brandJpaRepository.save(new BrandModel("테스트브랜드", "설명")); + ProductModel product = productJpaRepository.save( + new ProductModel("테스트상품", "상품설명", new Money(10000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 100)); + + likeTransactionService.doLike(1L, product.getId()); + + productFacade.getProduct(product.getId()); + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNotNull(); + + // when + likeTransactionService.doUnlike(1L, product.getId()); + + // then + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNull(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java index 977759387..e8a943555 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java @@ -3,7 +3,6 @@ import com.loopers.application.brand.BrandService; import com.loopers.domain.order.OrderItemModel; import com.loopers.domain.order.OrderModel; -import com.loopers.application.order.OrderService; import com.loopers.domain.order.OrderStatus; import com.loopers.application.product.ProductFacade; import com.loopers.domain.product.Money; @@ -41,7 +40,7 @@ class OrderFacadeIntegrationTest { private Long createBrand(String name) { return brandService.register(name, "설명").getId(); } private Long createProduct(String name, int price, Long brandId, int stock) { - return productFacade.register(name, "설명", new Money(price), brandId, stock).getId(); + return productFacade.register(name, "설명", new Money(price), brandId, stock).id(); } @DisplayName("주문 생성") @@ -84,7 +83,7 @@ void deductsStock() { Long brandId = createBrand("나이키"); Long productId = createProduct("에어맥스", 129000, brandId, 10); orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 3)), null); - assertThat(stockService.getByProductId(productId).quantity()).isEqualTo(7); + assertThat(stockService.getByProductId(productId).getQuantity()).isEqualTo(7); } @DisplayName("재고 부족 시 BAD_REQUEST 예외가 발생한다") @@ -105,8 +104,8 @@ void rollsBackOnPartialFailure() { Long p2 = createProduct("조던", 159000, brandId, 1); assertThrows(CoreException.class, () -> orderFacade.placeOrder(1L, List.of( new OrderItemCommand(p1, 3), new OrderItemCommand(p2, 5)), null)); - assertThat(stockService.getByProductId(p1).quantity()).isEqualTo(10); - assertThat(stockService.getByProductId(p2).quantity()).isEqualTo(1); + assertThat(stockService.getByProductId(p1).getQuantity()).isEqualTo(10); + assertThat(stockService.getByProductId(p2).getQuantity()).isEqualTo(1); } @DisplayName("삭제된 상품 주문 시 NOT_FOUND 예외가 발생한다") diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java index 8dd7a660b..8d13eefa1 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -1,12 +1,13 @@ package com.loopers.application.order; +import com.loopers.application.coupon.CouponIssueService; +import com.loopers.application.coupon.CouponService; import com.loopers.domain.order.OrderModel; -import com.loopers.domain.order.OrderService; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.ProductService; +import com.loopers.application.product.ProductService; import com.loopers.domain.stock.StockModel; -import com.loopers.domain.stock.StockService; +import com.loopers.application.stock.StockService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.DisplayName; @@ -43,15 +44,21 @@ class OrderFacadeTest { @Mock private StockService stockService; + @Mock + private CouponIssueService couponIssueService; + + @Mock + private CouponService couponService; + @DisplayName("주문 생성") @Nested - class CreateOrder { + class PlaceOrder { @DisplayName("정상적으로 주문을 생성한다") @Test void createsOrderSuccessfully() { // arrange - Long memberId = 1L; + Long userId = 1L; ProductModel product1 = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); ReflectionTestUtils.setField(product1, "id", 1L); ProductModel product2 = new ProductModel("에어포스", "캐주얼", new Money(109000), 1L); @@ -61,59 +68,40 @@ void createsOrderSuccessfully() { given(productService.getById(1L)).willReturn(product1); given(productService.getById(2L)).willReturn(product2); - given(stockService.getByProductId(1L)).willReturn(stock1); - given(stockService.getByProductId(2L)).willReturn(stock2); + given(stockService.getByProductIdForUpdate(1L)).willReturn(stock1); + given(stockService.getByProductIdForUpdate(2L)).willReturn(stock2); given(orderService.save(any(OrderModel.class))).willAnswer(invocation -> invocation.getArgument(0)); + given(orderService.saveAllItems(any())).willAnswer(invocation -> invocation.getArgument(0)); List commands = List.of( new OrderItemCommand(1L, 2), new OrderItemCommand(2L, 1) ); // act - OrderInfo.Detail result = orderFacade.createOrder(memberId, commands); + OrderResult result = orderFacade.placeOrder(userId, commands, null); // assert assertAll( - () -> assertThat(result.totalAmount()).isEqualTo(129000 * 2 + 109000), - () -> assertThat(result.orderItems()).hasSize(2) + () -> assertThat(result.order().totalAmount()).isEqualTo(new Money(129000 * 2 + 109000)), + () -> assertThat(result.items()).hasSize(2) ); } - @DisplayName("삭제된 상품이 포함되면 NOT_FOUND 예외가 발생한다") - @Test - void throwsOnDeletedProduct() { - // arrange - Long memberId = 1L; - ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - ReflectionTestUtils.setField(product, "id", 1L); - product.delete(); - given(productService.getById(1L)).willReturn(product); - - List commands = List.of(new OrderItemCommand(1L, 1)); - // act - CoreException exception = assertThrows(CoreException.class, () -> { - orderFacade.createOrder(memberId, commands); - }); - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - then(orderService).should(never()).save(any()); - } - @DisplayName("재고가 부족하면 BAD_REQUEST 예외가 발생한다") @Test void throwsOnInsufficientStock() { // arrange - Long memberId = 1L; + Long userId = 1L; ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); ReflectionTestUtils.setField(product, "id", 1L); StockModel stock = new StockModel(1L, 5); given(productService.getById(1L)).willReturn(product); - given(stockService.getByProductId(1L)).willReturn(stock); + given(stockService.getByProductIdForUpdate(1L)).willReturn(stock); List commands = List.of(new OrderItemCommand(1L, 10)); // act CoreException exception = assertThrows(CoreException.class, () -> { - orderFacade.createOrder(memberId, commands); + orderFacade.placeOrder(userId, commands, null); }); // assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheIntegrationTest.java new file mode 100644 index 000000000..e44b92c21 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheIntegrationTest.java @@ -0,0 +1,153 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.stock.StockModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.stock.StockJpaRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.CacheManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Import(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class ProductCacheIntegrationTest { + + static final GenericContainer redisContainer = + new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(6379); + + static { + redisContainer.start(); + } + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + String host = redisContainer.getHost(); + int port = redisContainer.getFirstMappedPort(); + registry.add("datasource.redis.database", () -> 0); + registry.add("datasource.redis.master.host", () -> host); + registry.add("datasource.redis.master.port", () -> port); + registry.add("datasource.redis.replicas[0].host", () -> host); + registry.add("datasource.redis.replicas[0].port", () -> port); + } + + @Autowired + private ProductFacade productFacade; + + @Autowired + private CacheManager cacheManager; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private StockJpaRepository stockJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + var cache = cacheManager.getCache("productDetail"); + if (cache != null) { + cache.clear(); + } + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("상품 상세 조회 시 캐시에 저장된다") + void 상품_상세_조회_시_캐시에_저장된다() { + // given + BrandModel brand = brandJpaRepository.save(new BrandModel("테스트브랜드", "설명")); + ProductModel product = productJpaRepository.save( + new ProductModel("테스트상품", "상품설명", new Money(10000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 100)); + + // when + productFacade.getProduct(product.getId()); + + // then + var cachedValue = cacheManager.getCache("productDetail").get(product.getId()); + assertThat(cachedValue).isNotNull(); + } + + @Test + @DisplayName("상품 수정 시 상세 캐시가 삭제된다") + void 상품_수정_시_상세_캐시가_삭제된다() { + // given + BrandModel brand = brandJpaRepository.save(new BrandModel("테스트브랜드", "설명")); + ProductModel product = productJpaRepository.save( + new ProductModel("테스트상품", "상품설명", new Money(10000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 100)); + + productFacade.getProduct(product.getId()); + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNotNull(); + + // when + productFacade.update(product.getId(), "수정된상품", "수정된설명", new Money(20000)); + + // then + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNull(); + } + + @Test + @DisplayName("상품 삭제 시 상세 캐시가 삭제된다") + void 상품_삭제_시_상세_캐시가_삭제된다() { + // given + BrandModel brand = brandJpaRepository.save(new BrandModel("테스트브랜드", "설명")); + ProductModel product = productJpaRepository.save( + new ProductModel("테스트상품", "상품설명", new Money(10000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 100)); + + productFacade.getProduct(product.getId()); + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNotNull(); + + // when + productFacade.delete(product.getId()); + + // then + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNull(); + } + + @Test + @DisplayName("캐시 히트 시 동일한 결과를 반환한다") + void 캐시_히트_시_동일한_결과를_반환한다() { + // given + BrandModel brand = brandJpaRepository.save(new BrandModel("테스트브랜드", "설명")); + ProductModel product = productJpaRepository.save( + new ProductModel("테스트상품", "상품설명", new Money(10000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 100)); + + ProductDetail firstResult = productFacade.getProduct(product.getId()); + + // when + ProductDetail secondResult = productFacade.getProduct(product.getId()); + + // then + assertThat(secondResult).isEqualTo(firstResult); + } +} 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 28572b257..a6931ffce 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 @@ -1,11 +1,9 @@ package com.loopers.application.product; import com.loopers.domain.brand.BrandModel; -import com.loopers.domain.brand.BrandName; import com.loopers.application.brand.BrandService; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductModel; -import com.loopers.application.product.ProductService; import com.loopers.domain.stock.StockModel; import com.loopers.application.stock.StockService; import com.loopers.domain.stock.StockStatus; @@ -18,6 +16,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -55,21 +54,26 @@ class Register { void orchestratesRegistration() { // given Long brandId = 1L; - BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠"); + BrandModel brand = new BrandModel("나이키", "스포츠"); ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), brandId); + ReflectionTestUtils.setField(product, "id", 1L); + StockModel stock = new StockModel(1L, 100); when(brandService.getBrand(brandId)).thenReturn(brand); when(productService.register("에어맥스", "러닝화", new Money(129000), brandId)).thenReturn(product); + when(stockService.save(1L, 100)).thenReturn(stock); + when(brandService.getBrandForAdmin(brandId)).thenReturn(brand); + when(stockService.getByProductId(1L)).thenReturn(stock); // when - ProductModel result = productFacade.register("에어맥스", "러닝화", new Money(129000), brandId, 100); + ProductDetail result = productFacade.register("에어맥스", "러닝화", new Money(129000), brandId, 100); // then assertAll( () -> assertThat(result.name()).isEqualTo("에어맥스"), () -> verify(brandService).getBrand(brandId), () -> verify(productService).register("에어맥스", "러닝화", new Money(129000), brandId), - () -> verify(stockService).create(any(), eq(100)) + () -> verify(stockService).save(1L, 100) ); } @@ -98,10 +102,10 @@ void returnsProductDetail() { Long productId = 1L; Long brandId = 1L; ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), brandId); - BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠"); + BrandModel brand = new BrandModel("나이키", "스포츠"); StockModel stock = new StockModel(productId, 100); - when(productService.getProduct(productId)).thenReturn(product); + when(productService.getById(productId)).thenReturn(product); when(brandService.getBrandForAdmin(brandId)).thenReturn(brand); when(stockService.getByProductId(productId)).thenReturn(stock); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java index 93b7242f3..7bf66d4c8 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java @@ -49,7 +49,7 @@ private Long createBrand(String name) { return brandService.register(name, "설명").getId(); } - private ProductModel createProduct(String name, String description, Money price, Long brandId, int initialStock) { + private ProductDetail createProduct(String name, String description, Money price, Long brandId, int initialStock) { return productFacade.register(name, description, price, brandId, initialStock); } @@ -64,18 +64,18 @@ void createsProductAndStock() { Long brandId = createBrand("나이키"); // when - ProductModel result = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + ProductDetail result = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); // then assertAll( - () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.id()).isNotNull(), () -> assertThat(result.name()).isEqualTo("에어맥스 90"), - () -> assertThat(result.price()).isEqualTo(new Money(129000)), + () -> assertThat(result.price()).isEqualTo(129000), () -> assertThat(result.brandId()).isEqualTo(brandId) ); - StockModel stock = stockService.getByProductId(result.getId()); - assertThat(stock.quantity()).isEqualTo(100); + StockModel stock = stockService.getByProductId(result.id()); + assertThat(stock.getQuantity()).isEqualTo(100); } @DisplayName("삭제된 브랜드에 등록하면 NOT_FOUND 예외가 발생한다") @@ -103,13 +103,13 @@ class GetProduct { void returnsProduct() { // given Long brandId = createBrand("나이키"); - ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + ProductDetail saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); // when - ProductModel result = productService.getProduct(saved.getId()); + ProductModel result = productService.getById(saved.id()); // then - assertThat(result.name()).isEqualTo("에어맥스 90"); + assertThat(result.getName()).isEqualTo("에어맥스 90"); } @DisplayName("삭제된 상품이면 NOT_FOUND 예외가 발생한다") @@ -117,12 +117,12 @@ void returnsProduct() { void throwsWhenDeleted() { // given Long brandId = createBrand("나이키"); - ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); - productService.delete(saved.getId()); + ProductDetail saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + productService.delete(saved.id()); // when CoreException result = assertThrows(CoreException.class, - () -> productService.getProduct(saved.getId())); + () -> productService.getById(saved.id())); // then assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); @@ -140,14 +140,14 @@ void returnsNotDeletedProducts() { Long brandId = createBrand("나이키"); createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); createProduct("에어맥스 95", "러닝화", new Money(159000), brandId, 50); - ProductModel deleted = createProduct("삭제될 상품", "설명", new Money(99000), brandId, 10); - productService.delete(deleted.getId()); + ProductDetail deleted = createProduct("삭제될 상품", "설명", new Money(99000), brandId, 10); + productService.delete(deleted.id()); // when - Page result = productService.getProducts(null, ProductSortType.LATEST, PageRequest.of(0, 10)); + Page result = productService.getProducts(null, ProductSortType.CREATED_DESC, PageRequest.of(0, 10)); // then - assertThat(result.getTotalElements()).isEqualTo(2); + assertThat(result.getTotalElements()).isGreaterThanOrEqualTo(2); } @DisplayName("brandId로 필터링하여 조회한다") @@ -160,10 +160,10 @@ void filtersByBrandId() { createProduct("슈퍼스타", "캐주얼", new Money(99000), adidasId, 50); // when - Page result = productService.getProducts(nikeId, ProductSortType.LATEST, PageRequest.of(0, 10)); + Page result = productFacade.getProducts(nikeId, ProductSortType.CREATED_DESC, PageRequest.of(0, 10)); // then - assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getTotalElements()).isGreaterThanOrEqualTo(1); assertThat(result.getContent().get(0).name()).isEqualTo("에어맥스 90"); } } @@ -177,16 +177,16 @@ class Update { void updatesSuccessfully() { // given Long brandId = createBrand("나이키"); - ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + ProductDetail saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); // when - ProductModel result = productService.update(saved.getId(), "에어맥스 95", "뉴 러닝화", new Money(159000)); + ProductModel result = productService.update(saved.id(), "에어맥스 95", "뉴 러닝화", new Money(159000)); // then assertAll( - () -> assertThat(result.name()).isEqualTo("에어맥스 95"), - () -> assertThat(result.description()).isEqualTo("뉴 러닝화"), - () -> assertThat(result.price()).isEqualTo(new Money(159000)) + () -> assertThat(result.getName()).isEqualTo("에어맥스 95"), + () -> assertThat(result.getDescription()).isEqualTo("뉴 러닝화"), + () -> assertThat(result.getPrice()).isEqualTo(new Money(159000)) ); } } @@ -200,36 +200,21 @@ class Delete { void excludedFromCustomerQueryAfterDelete() { // given Long brandId = createBrand("나이키"); - ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + ProductDetail saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); // when - productService.delete(saved.getId()); + productService.delete(saved.id()); // then CoreException result = assertThrows(CoreException.class, - () -> productService.getProduct(saved.getId())); + () -> productService.getById(saved.id())); assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); } - - @DisplayName("soft delete 후 admin 조회에서는 포함된다") - @Test - void includedInAdminQueryAfterDelete() { - // given - Long brandId = createBrand("나이키"); - ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); - - // when - productService.delete(saved.getId()); - - // then - ProductModel result = productService.getProductForAdmin(saved.getId()); - assertThat(result.getDeletedAt()).isNotNull(); - } } @DisplayName("브랜드별 상품 전체 삭제") @Nested - class DeleteAllByBrandId { + class SoftDeleteByBrandId { @DisplayName("해당 브랜드의 모든 상품을 soft delete 한다") @Test @@ -240,10 +225,10 @@ void softDeletesAllProducts() { createProduct("에어맥스 95", "러닝화", new Money(159000), brandId, 50); // when - productService.deleteAllByBrandId(brandId); + productService.softDeleteByBrandId(brandId); // then - Page result = productService.getProducts(brandId, ProductSortType.LATEST, PageRequest.of(0, 10)); + Page result = productFacade.getProducts(brandId, ProductSortType.CREATED_DESC, PageRequest.of(0, 10)); assertThat(result.getTotalElements()).isEqualTo(0); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java index 4187b82f9..7b7b0370b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java @@ -6,11 +6,11 @@ import com.loopers.domain.product.ProductSortType; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -22,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import org.springframework.test.util.ReflectionTestUtils; @@ -60,7 +61,7 @@ void returnsSavedProduct() { // then assertAll( - () -> assertThat(result.getName()).isEqualTo("에어맥스"), + () -> assertThat(result.getName()).isEqualTo("에어맥스 90"), () -> assertThat(result.getPrice().value()).isEqualTo(129000) ); verify(productRepository).save(any(ProductModel.class)); @@ -176,8 +177,8 @@ void returnsMapOfProducts() { // then assertAll( () -> assertThat(result).hasSize(2), - () -> assertThat(result.get(1L).name()).isEqualTo("에어맥스"), - () -> assertThat(result.get(2L).name()).isEqualTo("조던") + () -> assertThat(result.get(1L).getName()).isEqualTo("에어맥스"), + () -> assertThat(result.get(2L).getName()).isEqualTo("조던") ); } @@ -231,29 +232,24 @@ class LikeCount { @DisplayName("좋아요 수를 증가시킨다") @Test - void increasesLikeCount() { + void incrementsLikeCount() { // arrange Long id = 1L; - ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - given(productRepository.findById(id)).willReturn(Optional.of(product)); // act - productService.increaseLikeCount(id); + productService.incrementLikeCount(id); // assert - assertThat(product.getLikeCount()).isEqualTo(1); + verify(productRepository).incrementLikeCount(id); } @DisplayName("좋아요 수를 감소시킨다") @Test - void decreasesLikeCount() { + void decrementsLikeCount() { // arrange Long id = 1L; - ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - product.increaseLikeCount(); - given(productRepository.findById(id)).willReturn(Optional.of(product)); // act - productService.decreaseLikeCount(id); + productService.decrementLikeCount(id); // assert - assertThat(product.getLikeCount()).isEqualTo(0); + verify(productRepository).decrementLikeCount(id); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/stock/StockServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/stock/StockServiceTest.java index a524ebc82..25b8a0a85 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/stock/StockServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/stock/StockServiceTest.java @@ -22,6 +22,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class StockServiceTest { @@ -105,8 +106,8 @@ void returnsMapOfStocks() { // then assertAll( () -> assertThat(result).hasSize(2), - () -> assertThat(result.get(1L).quantity()).isEqualTo(100), - () -> assertThat(result.get(2L).quantity()).isEqualTo(50) + () -> assertThat(result.get(1L).getQuantity()).isEqualTo(100), + () -> assertThat(result.get(2L).getQuantity()).isEqualTo(50) ); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/config/CustomCacheErrorHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/config/CustomCacheErrorHandlerTest.java new file mode 100644 index 000000000..c39f18fa8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/config/CustomCacheErrorHandlerTest.java @@ -0,0 +1,66 @@ +package com.loopers.config; + +import com.loopers.config.redis.CustomCacheErrorHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.cache.Cache; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.mock; + +class CustomCacheErrorHandlerTest { + + private CustomCacheErrorHandler sut; + private Cache mockCache; + + @BeforeEach + void setUp() { + sut = new CustomCacheErrorHandler(); + mockCache = mock(Cache.class); + } + + @Test + @DisplayName("handleCacheGetError 호출 시 예외가 전파되지 않는다") + void handleCacheGetError_should_not_propagate_exception() { + // given + RuntimeException exception = new RuntimeException("Redis connection failed"); + Object key = "testKey"; + + // when & then + assertDoesNotThrow(() -> sut.handleCacheGetError(exception, mockCache, key)); + } + + @Test + @DisplayName("handleCachePutError 호출 시 예외가 전파되지 않는다") + void handleCachePutError_should_not_propagate_exception() { + // given + RuntimeException exception = new RuntimeException("Redis connection failed"); + Object key = "testKey"; + Object value = "testValue"; + + // when & then + assertDoesNotThrow(() -> sut.handleCachePutError(exception, mockCache, key, value)); + } + + @Test + @DisplayName("handleCacheEvictError 호출 시 예외가 전파되지 않는다") + void handleCacheEvictError_should_not_propagate_exception() { + // given + RuntimeException exception = new RuntimeException("Redis connection failed"); + Object key = "testKey"; + + // when & then + assertDoesNotThrow(() -> sut.handleCacheEvictError(exception, mockCache, key)); + } + + @Test + @DisplayName("handleCacheClearError 호출 시 예외가 전파되지 않는다") + void handleCacheClearError_should_not_propagate_exception() { + // given + RuntimeException exception = new RuntimeException("Redis connection failed"); + + // when & then + assertDoesNotThrow(() -> sut.handleCacheClearError(exception, mockCache)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/config/RedisCacheConfigTest.java b/apps/commerce-api/src/test/java/com/loopers/config/RedisCacheConfigTest.java new file mode 100644 index 000000000..f37c8ab22 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/config/RedisCacheConfigTest.java @@ -0,0 +1,43 @@ +package com.loopers.config; + +import com.loopers.testcontainers.RedisTestContainersConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.CacheManager; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Import(RedisTestContainersConfig.class) +@ActiveProfiles("test") +class RedisCacheConfigTest { + + @Autowired + private CacheManager cacheManager; + + @Test + @DisplayName("CacheManager Bean이 RedisCacheManager 인스턴스여야 한다") + void cacheManager_should_be_redisCacheManager_instance() { + // given & when & then + assertThat(cacheManager).isInstanceOf(RedisCacheManager.class); + } + + @Test + @DisplayName("productDetail 캐시 설정이 존재해야 한다") + void productDetail_cache_configuration_should_exist() { + // given + RedisCacheManager redisCacheManager = (RedisCacheManager) cacheManager; + + // when + var cache = redisCacheManager.getCache("productDetail"); + + // then + assertThat(cache).isNotNull(); + } + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java index 4d25df4e0..254c3011a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.like; +import com.loopers.application.like.LikeService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -29,70 +30,44 @@ class LikeServiceTest { @Mock private LikeRepository likeRepository; - @DisplayName("좋아요 등록") + @DisplayName("좋아요 저장") @Nested - class Register { + class Save { - @DisplayName("좋아요가 없으면 새로 생성하고 true를 반환한다") + @DisplayName("좋아요를 저장하고 반환한다") @Test - void returnsTrueWhenNewLike() { + void savesAndReturns() { // arrange - Long memberId = 1L; + Long userId = 1L; Long productId = 100L; - given(likeRepository.findByMemberIdAndProductId(memberId, productId)).willReturn(Optional.empty()); - given(likeRepository.save(any(LikeModel.class))).willReturn(new LikeModel(memberId, productId)); + LikeModel like = new LikeModel(userId, productId); + given(likeRepository.save(any(LikeModel.class))).willReturn(like); // act - boolean result = likeService.register(memberId, productId); + LikeModel result = likeService.save(like); // assert - assertThat(result).isTrue(); - then(likeRepository).should().save(any(LikeModel.class)); - } - - @DisplayName("이미 좋아요가 존재하면 false를 반환한다") - @Test - void returnsFalseWhenAlreadyExists() { - // arrange - Long memberId = 1L; - Long productId = 100L; - given(likeRepository.findByMemberIdAndProductId(memberId, productId)) - .willReturn(Optional.of(new LikeModel(memberId, productId))); - // act - boolean result = likeService.register(memberId, productId); - // assert - assertThat(result).isFalse(); + assertThat(result.userId()).isEqualTo(userId); + then(likeRepository).should().save(like); } } - @DisplayName("좋아요 취소") + @DisplayName("좋아요 조회") @Nested - class Cancel { - - @DisplayName("좋아요가 존재하면 삭제하고 true를 반환한다") - @Test - void returnsTrueWhenCancelled() { - // arrange - Long memberId = 1L; - Long productId = 100L; - LikeModel like = new LikeModel(memberId, productId); - given(likeRepository.findByMemberIdAndProductId(memberId, productId)).willReturn(Optional.of(like)); - // act - boolean result = likeService.cancel(memberId, productId); - // assert - assertThat(result).isTrue(); - then(likeRepository).should().delete(like); - } + class Find { - @DisplayName("좋아요가 없으면 false를 반환한다") + @DisplayName("userId와 productId로 좋아요를 조회한다") @Test - void returnsFalseWhenNotExists() { + void findsByUserIdAndProductId() { // arrange - Long memberId = 1L; + Long userId = 1L; Long productId = 100L; - given(likeRepository.findByMemberIdAndProductId(memberId, productId)).willReturn(Optional.empty()); + LikeModel like = new LikeModel(userId, productId); + given(likeRepository.findByUserIdAndProductId(userId, productId)) + .willReturn(Optional.of(like)); // act - boolean result = likeService.cancel(memberId, productId); + Optional result = likeService.findByUserIdAndProductId(userId, productId); // assert - assertThat(result).isFalse(); + assertThat(result).isPresent(); + assertThat(result.get().productId()).isEqualTo(productId); } } @@ -104,12 +79,12 @@ class GetMyLikes { @Test void returnsPagedLikes() { // arrange - Long memberId = 1L; + Long userId = 1L; Pageable pageable = PageRequest.of(0, 10); - List likes = List.of(new LikeModel(memberId, 1L), new LikeModel(memberId, 2L)); - given(likeRepository.findAllByMemberId(memberId, pageable)).willReturn(new PageImpl<>(likes)); + List likes = List.of(new LikeModel(userId, 1L), new LikeModel(userId, 2L)); + given(likeRepository.findActiveLikesWithActiveProduct(userId, pageable)).willReturn(new PageImpl<>(likes)); // act - Page result = likeService.getMyLikes(memberId, pageable); + Page result = likeService.getMyLikes(userId, pageable); // assert assertThat(result.getContent()).hasSize(2); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java index 1bda80473..cdce42ce6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.order; +import com.loopers.application.order.OrderService; import com.loopers.domain.product.Money; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -29,6 +30,9 @@ class OrderServiceTest { @Mock private OrderRepository orderRepository; + @Mock + private OrderItemRepository orderItemRepository; + @DisplayName("주문 저장") @Nested class Save { @@ -37,32 +41,33 @@ class Save { @Test void savesOrder() { // arrange - OrderModel order = new OrderModel(1L); + OrderModel order = new OrderModel(1L, new Money(10000), Money.ZERO, null); given(orderRepository.save(any(OrderModel.class))).willReturn(order); // act OrderModel result = orderService.save(order); // assert - assertThat(result.getMemberId()).isEqualTo(1L); + assertThat(result.userId()).isEqualTo(1L); then(orderRepository).should().save(order); } } @DisplayName("주문 조회") @Nested - class GetById { + class GetOrder { @DisplayName("존재하는 주문을 반환한다") @Test void returnsForExistingId() { // arrange Long id = 1L; - OrderModel order = new OrderModel(1L); + Long userId = 1L; + OrderModel order = new OrderModel(userId, new Money(10000), Money.ZERO, null); ReflectionTestUtils.setField(order, "id", id); given(orderRepository.findById(id)).willReturn(Optional.of(order)); // act - OrderModel result = orderService.getById(id); + OrderModel result = orderService.getOrder(id, userId); // assert - assertThat(result.getMemberId()).isEqualTo(1L); + assertThat(result.userId()).isEqualTo(1L); } @DisplayName("존재하지 않는 ID면 NOT_FOUND 예외가 발생한다") @@ -73,7 +78,7 @@ void throwsOnNonExistentId() { given(orderRepository.findById(id)).willReturn(Optional.empty()); // act CoreException exception = assertThrows(CoreException.class, () -> { - orderService.getById(id); + orderService.getOrder(id, 1L); }); // assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java index 9c4f78e12..b89dd5fc0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.brand; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; +import com.loopers.interfaces.api.brand.BrandAdminV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java index 67152c396..20664ca28 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java @@ -1,9 +1,9 @@ package com.loopers.interfaces.api.like; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; +import com.loopers.interfaces.api.brand.BrandAdminV1Dto; import com.loopers.interfaces.api.member.MemberV1Dto; -import com.loopers.interfaces.api.product.admin.ProductAdminV1Dto; +import com.loopers.interfaces.api.product.ProductAdminV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -50,7 +50,7 @@ private Long createBrand(String name) { private Long createProduct(String name, int price, Long brandId) { var req = new ProductAdminV1Dto.CreateRequest(name, "설명", price, brandId, 10); return testRestTemplate.exchange("/api-admin/v1/products", HttpMethod.POST, new HttpEntity<>(req, adminHeaders()), - new ParameterizedTypeReference>() {}).getBody().data().id(); + new ParameterizedTypeReference>() {}).getBody().data().id(); } private void signupMember() { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java index 5b366f864..f83010ed9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java @@ -2,9 +2,9 @@ import com.loopers.infrastructure.member.MemberJpaRepository; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; +import com.loopers.interfaces.api.brand.BrandAdminV1Dto; import com.loopers.interfaces.api.member.MemberV1Dto; -import com.loopers.interfaces.api.product.admin.ProductAdminV1Dto; +import com.loopers.interfaces.api.product.ProductAdminV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -59,7 +59,7 @@ private Long createProduct(String name, int price, Long brandId, int stock) { var req = new ProductAdminV1Dto.CreateRequest(name, "설명", price, brandId, stock); return testRestTemplate.exchange("/api-admin/v1/products", HttpMethod.POST, new HttpEntity<>(req, adminHeaders()), - new ParameterizedTypeReference>() {}).getBody().data().id(); + new ParameterizedTypeReference>() {}).getBody().data().id(); } private void signupMember() { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java index d2ae0befe..e53288dd9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java @@ -1,8 +1,8 @@ package com.loopers.interfaces.api.product; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; -import com.loopers.interfaces.api.product.admin.ProductAdminV1Dto; +import com.loopers.interfaces.api.brand.BrandAdminV1Dto; +import com.loopers.interfaces.api.product.ProductAdminV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -60,7 +60,7 @@ private Long createBrand(String name) { private Long createProduct(String name, int price, Long brandId, int initialStock) { ProductAdminV1Dto.CreateRequest request = new ProductAdminV1Dto.CreateRequest(name, "설명", price, brandId, initialStock); - ResponseEntity> response = testRestTemplate.exchange( + ResponseEntity> response = testRestTemplate.exchange( ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -136,7 +136,7 @@ void returns200WithStockStatus() { Long productId = createProduct("에어맥스 90", 129000, brandId, 100); // when - ResponseEntity> response = testRestTemplate.exchange( + ResponseEntity> response = testRestTemplate.exchange( CUSTOMER_ENDPOINT + "/" + productId, HttpMethod.GET, null, new ParameterizedTypeReference<>() {} ); @@ -200,7 +200,7 @@ void returns200WithStockQuantity() { ); // when - ResponseEntity> response = testRestTemplate.exchange( + ResponseEntity> response = testRestTemplate.exchange( ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -273,7 +273,7 @@ void returns200() { Long productId = createProduct("에어맥스 90", 129000, brandId, 100); // when - ResponseEntity> response = testRestTemplate.exchange( + ResponseEntity> response = testRestTemplate.exchange( ADMIN_ENDPOINT + "/" + productId, HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -302,7 +302,7 @@ void returns200WithUpdatedInfo() { ); // when - ResponseEntity> response = testRestTemplate.exchange( + ResponseEntity> response = testRestTemplate.exchange( ADMIN_ENDPOINT + "/" + productId, HttpMethod.PUT, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); diff --git a/docs/blog/index-tuning-blog-draft.md b/docs/blog/index-tuning-blog-draft.md new file mode 100644 index 000000000..209adface --- /dev/null +++ b/docs/blog/index-tuning-blog-draft.md @@ -0,0 +1,791 @@ +# 인덱스를 걸었는데 왜 안 빨라졌을까 + +**TL;DR** +ERP 대장 관리 화면의 조회가 느렸다. "인덱스를 걸면 빨라지겠지"라고 생각하고 fiscal_year에 인덱스를 걸었다. 안 빨라졌다. 메인 테이블이 아니라 **LEFT JOIN으로 붙는 서브쿼리 4개**가 매번 전체 테이블을 스캔하고 있었기 때문이다. 결국 "어디에 인덱스를 거는가"보다 **"쿼리가 실제로 어떻게 실행되는가"**를 먼저 이해해야 했고, EXPLAIN을 읽는 법을 배운 뒤에야 진짜 병목을 찾을 수 있었다. + +--- + +## 1. 처음 든 생각은 단순했다 + +사내 ERP 시스템에 **대장 관리** 화면이 있다. 계약 건별 마스터 정보를 조회하는 화면인데, 검색 조건을 입력하고 조회 버튼을 누르면 체감상 3~5초는 걸렸다. + +데이터는 약 5만 건. 뭐 엄청 많은 건 아니다. + +처음 든 생각은 이거였다. + +> "WHERE에 쓰이는 컬럼에 인덱스를 걸면 빨라지겠지." + +근데 이 판단이 틀렸다. + + + +--- + +## 2. 내가 뭘 모르는지 점검해봤다 + +틀렸다는 건 안다. 근데 **왜** 틀렸는지를 설명하지 못하겠다. + +일단 내 인식 상태를 점검해봤다. + +``` +현재 문제: 인덱스를 걸었는데 안 빨라졌다. +내가 한 것: WHERE절의 필수 컬럼(fiscal_year)에 인덱스를 걸었다. +내가 아는 것: "조회 조건에 쓰이는 컬럼에 인덱스를 걸면 빠르다." +내가 모르는 것: 왜 안 빨라졌는지. +``` + +여기서 멈췄다. "인덱스를 걸면 빠르다"는 건 알고 있었다. 근데 **빨라지지 않는 경우가 있다**는 건 생각해본 적이 없었다. 아는 것만으로는 이 상황을 설명할 수 없었다. + +그러면 내가 모르는 건 뭘까? + +> "이 쿼리가 실제로 어떤 순서로 실행되는지"를 모른다. + +나는 쿼리를 **텍스트**로만 읽고 있었다. WHERE절에 뭐가 있는지, JOIN이 몇 개인지. 하지만 MySQL이 이 쿼리를 **어떤 순서로, 어떤 방식으로 실행하는지**는 한 번도 확인하지 않았다. + +--- + +## 3. 쿼리를 처음부터 다시 읽었다 + +WHERE절만 볼 게 아니라 쿼리 전체를 읽었다. 그제서야 구조가 보였다. + +``` +contract_ledger (메인) + ├─ LEFT JOIN ① : 거래처 건수 (COUNT + GROUP BY 서브쿼리) + ├─ LEFT JOIN ② : 1순번 거래처 상세 (INNER JOIN 포함) + ├─ LEFT JOIN ③ : 대표 거래처 상세 (INNER JOIN 포함) + └─ LEFT JOIN ④ : 하자보증 종료일 집계 (GROUP BY 서브쿼리) +``` + +4개의 LEFT JOIN. 그 중 2개는 서브쿼리다. + +왜 이렇게 복잡할까? 화면 요구사항 때문이다. + +- 목록에 **거래처명**이 보여야 한다 → JOIN ②③ +- 거래처가 **몇 개인지** 보여야 한다 → JOIN ① +- **하자보증 만료일**이 보여야 한다 → JOIN ④ + +한 화면에 보여줄 정보가 많으니, 쿼리도 그만큼 복잡해진 것이다. 그리고 이 JOIN들에는 **인덱스가 하나도 없었다.** + +WHERE절의 fiscal_year에 인덱스를 걸어서 메인 테이블을 빠르게 찾아도, **JOIN으로 붙는 테이블 4개가 전부 Full Scan이면** 전체 쿼리는 느릴 수밖에 없다. + +근데 이건 쿼리를 **텍스트로 읽어서** 알게 된 거지, **실제 실행 계획을 확인**한 건 아니다. "아마 여기가 문제일 거야"라는 건 추측이다. 추측으로 인덱스를 건 게 처음의 실수였으니, 이번에는 추측하지 않기로 했다. + + + +--- + +## 4. EXPLAIN — 추측 대신 실행 계획을 읽었다 + +### EXPLAIN이 뭔가 + +`EXPLAIN`은 MySQL에게 "이 쿼리를 어떻게 실행할 건지 알려달라"고 요청하는 명령이다. 쿼리를 실제로 실행하지 않고, **실행 계획**만 보여준다. + +```sql +EXPLAIN +SELECT ... +FROM contract_ledger LEG +LEFT JOIN (...) ... +WHERE fiscal_year = '2026'; +``` + +이렇게 쿼리 앞에 `EXPLAIN`만 붙이면 된다. 결과는 테이블 형태로 나온다. + +### 처음 돌려본 결과 + + + +``` +┌──────────────────┬───────┬────────┬───────────────────────┬──────────┐ +│ table │ type │ rows │ extra │ key │ +├──────────────────┼───────┼────────┼───────────────────────┼──────────┤ +│ contract_ledger │ ALL │ 50,000 │ Using where │ NULL │ +│ (서브쿼리 ①) │ ALL │ 80,000 │ Using temporary │ NULL │ +│ (서브쿼리 ②) │ ALL │ 80,000 │ Using where │ NULL │ +│ (서브쿼리 ④) │ ALL │ 10,000 │ Using temporary │ NULL │ +└──────────────────┴───────┴────────┴───────────────────────┴──────────┘ +``` + +전부 `type = ALL`. 전부 `key = NULL`. + +근데 솔직히 이걸 처음 봤을 때, **뭘 봐야 하는지 몰랐다.** 컬럼이 여러 개 있는데, 어떤 게 중요한 건지 감이 안 잡혔다. + +멘토링에서 들은 말이 기준이 됐다. + +> "EXPLAIN에서 봐야 할 건 세 가지다. **rows** — 몇 건을 스캔했는지, **type** — 어떻게 접근했는지, **extra** — Using filesort나 Using temporary가 있으면 그게 병목이다." + +이 세 가지를 기준으로 다시 읽어봤다. + +### EXPLAIN 결과, 이렇게 읽는다 + +EXPLAIN을 읽을 줄 모르면 결과가 나와도 해석이 안 된다. 나도 그랬다. 각 컬럼이 무엇을 말하는지 정리해본다. + +#### type — "이 테이블에 어떻게 접근했는가" + +이게 가장 중요하다. 위에서 아래로 갈수록 느리다. + +| type | 의미 | 비유 | +|------|------|------| +| **const** | PK 또는 UNIQUE로 정확히 1건 조회 | 주민번호로 본인 찾기 | +| **eq_ref** | JOIN에서 PK/UNIQUE로 1건씩 매칭 | 출석부에서 이름표로 1:1 매칭 | +| **ref** | 인덱스로 여러 건 조회 | 목차에서 "3장" 찾기 → 여러 페이지 | +| **range** | 인덱스 범위 스캔 (BETWEEN, >, <) | 목차에서 "3장~5장" 범위로 찾기 | +| **index** | 인덱스 전체 스캔 (데이터보단 작음) | 목차를 처음부터 끝까지 읽기 | +| **ALL** | 테이블 전체 스캔 ❌ | 책을 1페이지부터 끝까지 넘기기 | + +여기서 한 가지 의문이 들 수 있다. **ref와 range가 뭐가 다른 건가?** + +```sql +-- ref: Equal(=) 연산. 인덱스에서 정확한 지점을 찾아 여러 건 조회 +WHERE fiscal_year = '2026' +→ 인덱스에서 '2026' 위치를 바로 찾음. 거기서 연속된 행들을 읽음. + +-- range: 범위 연산. 인덱스에서 시작점~끝점 사이를 스캔 +WHERE contract_date >= '2026-01-01' AND contract_date <= '2026-12-31' +→ 인덱스에서 '2026-01-01' 위치를 찾고, '2026-12-31'까지 순차 스캔. +``` + +ref는 "정확한 지점"을 찾는 거고, range는 "범위를 훑는" 거다. 복합 인덱스에서 Equal 컬럼을 앞에, Range 컬럼을 뒤에 배치해야 하는 이유가 여기에 있다. 뒤에서 다시 다룬다. + +**내 쿼리는 전부 ALL이었다.** 4개 테이블 모두 책을 처음부터 끝까지 넘기고 있었다. + +#### rows — "몇 건을 읽어야 하는가" + +MySQL이 **예상하는 스캔 대상 행 수**다. 핵심은 이것이 **결과 행 수가 아니라는 점**이다. + +``` +rows = 80,000이면? +→ 결과가 3건이어도, 그 3건을 찾기 위해 8만 건을 훑어본다는 뜻 +→ 이게 JOIN마다 발생하면, 50,000 × 80,000 = 40억 번의 비교가 될 수 있다 +``` + +내 쿼리에서 서브쿼리 rows가 80,000이었다. 결과는 기껏 1~3건인데, 그걸 찾으려고 매번 전체를 스캔하고 있었다. + +#### key — "실제로 사용한 인덱스" + +| 컬럼 | 의미 | +|------|------| +| **possible_keys** | 사용 가능한 인덱스 후보 목록 | +| **key** | 옵티마이저가 실제로 선택한 인덱스 | + +`key = NULL`이면 인덱스를 안 쓴 것이다. **내 쿼리는 전부 NULL이었다.** + +가끔 possible_keys에는 있는데 key가 NULL인 경우가 있다. 이건 옵티마이저가 **"인덱스 안 쓰는 게 더 빠르다"**고 판단한 것이다. 테이블이 작거나, 인덱스의 선택도(selectivity)가 낮을 때 발생한다. + +#### extra — "추가로 벌어지는 일" + +여기가 **병목의 증거**가 나오는 곳이다. + +| extra | 의미 | 위험도 | +|-------|------|--------| +| **Using where** | WHERE 조건으로 필터링 중 | 보통 (정상 동작) | +| **Using index** | 커버링 인덱스 사용 ✅ | 좋음 (디스크 I/O 없음) | +| **Using temporary** | 임시 테이블 생성 ❌ | 나쁨 | +| **Using filesort** | 별도 정렬 수행 ❌ | 나쁨 | +| **Using index condition** | ICP(Index Condition Pushdown) | 보통 | + +**Using temporary**는 MySQL이 GROUP BY나 DISTINCT를 처리하기 위해 **임시 테이블을 메모리에 만드는 것**이다. 데이터가 크면 디스크에 쓰기도 한다. + +**Using filesort**는 ORDER BY를 인덱스로 처리하지 못해서 **별도의 정렬 작업**을 수행하는 것이다. + +> Using temporary + Using filesort가 동시에 나오면 최악이다. 임시 테이블을 만들고, 그 안에서 다시 정렬까지 하는 것이니까. + +### 그래서 병목은 어디였나 + +다시 내 EXPLAIN 결과를 봤다. + +``` +서브쿼리 ① : type=ALL, rows=80,000, extra=Using temporary +서브쿼리 ④ : type=ALL, rows=10,000, extra=Using temporary +``` + +**Using temporary가 2개.** 서브쿼리가 GROUP BY를 처리하기 위해 매번 임시 테이블을 만들고 있었다. + +메인 테이블에 인덱스를 걸어서 500건으로 줄여봤자, **서브쿼리가 매번 8만 건을 전부 훑으면서 임시 테이블을 만들고 있으면** 전체 쿼리는 느릴 수밖에 없다. + +처음에 나는 "WHERE절이 느린 거다"라고 생각했다. EXPLAIN을 보니 **병목은 WHERE절이 아니라 JOIN이었다.** + +추측과 실제가 달랐다. EXPLAIN을 안 돌렸으면 계속 WHERE절만 잡고 있었을 것이다. + + + +--- + +## 5. 틀린 판단 2: "인덱스를 많이 걸면 위험하다"고 아꼈다 + +병목을 찾고 나서도 바로 인덱스를 추가하지 못했다. 이런 생각이 있었기 때문이다. + +> "인덱스를 많이 걸면 INSERT/UPDATE가 느려진다고 했는데, 4개나 추가해도 괜찮을까?" + +이것도 틀린 판단이었다. 정확히 말하면, **맞는 말이지만 이 상황에는 적용되지 않는 말**이었다. + +### 왜 이 상황에는 적용되지 않는가 + +인덱스를 추가하면 쓰기가 느려지는 건 사실이다. INSERT나 UPDATE가 발생할 때마다 해당 인덱스도 함께 갱신해야 하니까. 근데 이 원칙이 적용되려면 **전제 조건**이 있다. + +``` +"인덱스가 많으면 쓰기가 느려진다"가 문제가 되려면: +→ 쓰기가 빈번해야 한다. + +이 테이블의 실상: +→ 등록은 월 수십 건, 수정은 거의 없음 +→ 조회는 하루 수십~수백 번 +``` + +읽기와 쓰기의 비율이 **100:1 이상**이다. 이런 테이블에서 인덱스 4개를 아끼는 건, 멘토링에서 들은 말을 빌리면: + +> "인덱스 개수 자체는 무의미하다. 조회에 쓰이는 컬럼은 무조건 인덱스를 걸어라. 쓰기 부담 정리는 이후 최적화 단계다." + +**"인덱스가 많으면 위험하다"는 일반론이 이 테이블에 적용되지 않는 이유를 한 줄로 정리하면:** 이 테이블은 읽기가 압도적이다. 읽기 편향 테이블에서 인덱스를 아끼는 건 잘못된 절약이다. + + + +--- + +## 6. 인덱스를 어디에, 왜 이 순서로 걸었는가 + +### 질문: 동적 WHERE에 "하나의 완벽한 인덱스"가 가능한가? + +이 쿼리의 WHERE절은 MyBatis `` 태그로 **10개 이상의 동적 조건**이 있다. 사용자가 어떤 조건을 입력하느냐에 따라 실제 실행되는 쿼리가 달라진다. + +"모든 조합을 커버하는 인덱스"는 불가능하다. 그래서 **실제 사용 빈도**를 기준으로 잡았다. + +``` +[패턴 A] ★★★ 가장 빈번: fiscal_year만으로 전체 조회 +[패턴 B] ★★ 빈번: fiscal_year + contract_type (Equal) +[패턴 C] ★★ 빈번: fiscal_year + contract_date (Range) +[패턴 D] ★ 가끔: fiscal_year + contract_name (LIKE) +[패턴 E] ★ 가끔: fiscal_year + manage_dept (Equal) +``` + + + +### 복합 인덱스의 컬럼 순서 — 왜 이 순서여야 하는가 + +패턴 A, B, C를 하나의 복합 인덱스로 커버하려면: + +```sql +CREATE INDEX idx_ledger_main +ON contract_ledger(fiscal_year, contract_type, contract_date); +``` + +왜 `(fiscal_year, contract_type, contract_date)` 이 순서인가? + +**원칙 1: 필수조건을 맨 앞에 (Leftmost Prefix)** + +복합 인덱스는 **왼쪽부터 순서대로** 작동한다. 맨 앞 컬럼이 없으면 인덱스 자체를 탈 수 없다. + +``` +전화번호부가 (성, 이름, 전화번호) 순으로 정렬되어 있다고 하자. + +✅ "김" 씨를 찾아주세요 → 바로 찾음 +✅ "김" 씨 중 "민수"를 찾아주세요 → 바로 찾음 +❌ "민수"를 찾아주세요 (성 모름) → 처음부터 끝까지 봐야 함 +``` + +`fiscal_year`는 **모든 조회에 항상 포함**되는 필수조건이다. 그러니 맨 앞에 둔다. + +**원칙 2: Equal 조건을 Range 조건보다 앞에** + +복합 인덱스 `(A, B, C)`에서 B가 Range 연산이면, **C는 인덱스를 탈 수 없다.** + +왜 그런가? 인덱스는 정렬된 순서로 저장된다. B에서 범위로 흩어지면, 그 안에서 C의 순서가 보장되지 않기 때문이다. + +``` +인덱스: (fiscal_year, contract_type, contract_date) + +✅ fiscal_year = '2026' AND contract_type = 'A' AND contract_date >= '2026-01-01' + → fiscal_year(Equal) → contract_type(Equal) → contract_date(Range) + → 3개 컬럼 모두 인덱스 활용 + +만약 순서가 (fiscal_year, contract_date, contract_type)이었다면? +❌ fiscal_year = '2026' AND contract_date >= '2026-01-01' AND contract_type = 'A' + → fiscal_year(Equal) → contract_date(Range) → contract_type(Equal) + → contract_date에서 범위로 흩어지므로, contract_type은 인덱스 미활용 +``` + +그래서 Equal인 `contract_type`을 두 번째에, Range인 `contract_date`를 맨 뒤에 배치했다. + +**그런데 한 가지 의문.** + +> "인덱스 1에 manage_dept를 추가하면 안 되나? (fiscal_year, contract_type, contract_date, manage_dept)로?" + +할 수 있다. 하지만 contract_date가 Range 연산이면 **그 뒤의 manage_dept는 인덱스를 타지 않는다.** Range 이후의 컬럼은 인덱스가 중단되기 때문이다. 그래서 별도 인덱스를 만들었다. + +```sql +CREATE INDEX idx_ledger_dept +ON contract_ledger(fiscal_year, manage_dept); +``` + + + +### 진짜 효과가 컸던 건 — JOIN 인덱스 + +메인 테이블 인덱스보다 **체감 효과가 훨씬 컸던 건** 서브쿼리 쪽이었다. + +```sql +CREATE INDEX idx_partner_lookup +ON contract_partner(contract_no, partner_seq); +``` + +LEFT JOIN ①②③이 전부 `contract_no`로 조인한다. **이 인덱스 하나로 3개의 JOIN이 개선됐다.** + +특히 JOIN ①의 서브쿼리를 자세히 보자: + +```sql +SELECT COUNT(partner_seq), contract_no +FROM contract_partner +GROUP BY contract_no +``` + +이 서브쿼리가 `(contract_no, partner_seq)` 인덱스를 타면 **커버링 인덱스**가 된다. + +커버링 인덱스란, **쿼리가 필요한 모든 컬럼이 인덱스에 포함되어 있는 상태**를 말한다. 이 경우 MySQL은 디스크의 실제 데이터 페이지를 읽을 필요 없이, **인덱스 페이지만 읽어서 결과를 반환**할 수 있다. + +``` +왜 커버링 인덱스가 되는가? + +SELECT에 사용된 컬럼: contract_no, partner_seq +인덱스에 포함된 컬럼: contract_no, partner_seq + +→ SELECT가 필요로 하는 모든 컬럼이 인덱스에 포함 ✅ +→ 디스크 I/O 없이 인덱스 페이지만 읽음 +→ EXPLAIN extra에 "Using index"로 확인 가능 +``` + +커버링 인덱스가 되면 **Using temporary가 사라진다.** GROUP BY를 처리하기 위해 임시 테이블을 만들 필요가 없기 때문이다. 인덱스 자체가 `contract_no` 순으로 정렬되어 있으니, GROUP BY도 인덱스 순서를 그대로 따라가면 된다. + +하자관리 테이블도 같은 논리로: + +```sql +CREATE INDEX idx_defect_lookup +ON contract_defect(contract_no, defect_warranty_end); +``` + + + +--- + +## 7. 인덱스로 해결할 수 없는 것들 + +여기까지 오면 "인덱스를 잘 걸면 다 빨라진다"고 생각할 수 있다. 근데 **그렇지 않은 경우**가 있다. 이걸 모르면 인덱스가 안 먹히는 조건에서 헛삽질을 하게 된다. + +### LIKE '%keyword%' — 왜 인덱스를 못 타는가 + +```sql +WHERE contract_name LIKE '%공사%' +``` + +이건 인덱스를 **절대** 타지 않는다. + +왜? + +인덱스는 정렬된 순서로 데이터를 저장한다. `LIKE '공사%'`는 "공사"로 시작하는 지점을 바로 찾을 수 있다. B-Tree 인덱스에서 "공사"라는 시작점이 명확하니까. + +하지만 `LIKE '%공사%'`는 **시작점이 없다.** "공사"가 어디에 있을지 알 수 없으니 처음부터 끝까지 다 봐야 한다. 전화번호부에서 "이름에 '민'이 들어간 사람"을 찾으려면 1페이지부터 끝까지 넘겨야 하는 것과 같다. + +**그래서 어떻게 했는가?** + +포기했다. 정확히 말하면, LIKE 자체는 그대로 두고 **다른 인덱스가 rows를 충분히 줄여주는 구조**로 만들었다. + +``` +Before: 50,000건에 LIKE Full Scan → 느림 +After: 500건 (fiscal_year 인덱스로 축소) + LIKE 필터 → 무시할 수준 +``` + +5만 건에서 LIKE를 거는 것과 500건에서 LIKE를 거는 건 차원이 다르다. **인덱스로 해결할 수 없는 것을 인정하고, 다른 인덱스가 먼저 rows를 줄여주는 구조를 만드는 것**이 현실적인 대응이었다. + +> "모든 걸 인덱스로 해결하려 하면 안 된다"는 건, 인덱스 설계에서 가장 중요한 인식 중 하나인 것 같다. + + + +### DATE 함수 — 컬럼에 함수를 씌우면 인덱스가 죽는다 + +```sql +-- AS-IS ❌ +WHERE CURDATE() >= DATE_SUB(DATE_ADD(completion_date, INTERVAL 1 YEAR), INTERVAL 15 DAY) +``` + +`completion_date`에 인덱스가 있어도, **컬럼에 함수를 씌우면 인덱스를 탈 수 없다.** + +왜? DB 입장에서 생각해보자. 인덱스에는 `completion_date` 원본 값이 정렬되어 있다. 근데 `DATE_ADD(completion_date, ...)`의 결과가 뭔지는 **모든 행에 대해 함수를 실행해봐야** 안다. 인덱스의 정렬 순서와 함수 적용 후의 순서가 같다는 보장이 없으니, 인덱스를 쓸 수 없는 것이다. + +해결은 간단하다. **함수를 컬럼이 아닌 상수 쪽으로 옮긴다.** + +```sql +-- TO-BE ✅ +WHERE completion_date <= DATE_ADD(DATE_SUB(CURDATE(), INTERVAL 1 YEAR), INTERVAL 15 DAY) +``` + +같은 논리인데 `completion_date`에 직접 비교하도록 변환했다. 이제 인덱스를 탈 수 있다. `CURDATE()`와 `DATE_ADD`는 **상수로 한 번만 계산**되기 때문이다. + +> WHERE절에서 컬럼은 "벌거벗은 상태"여야 한다. 함수, 연산, 형변환을 씌우는 순간 인덱스는 무력화된다. + +--- + +## 8. 적용 결과 + +### EXPLAIN 비교 + + + +``` +■ Before +┌──────────────────┬───────┬────────┬───────────────────────┬──────────┐ +│ table │ type │ rows │ extra │ key │ +├──────────────────┼───────┼────────┼───────────────────────┼──────────┤ +│ contract_ledger │ ALL │ 50,000 │ Using where │ NULL │ +│ contract_partner │ ALL │ 80,000 │ Using temporary │ NULL │ +│ contract_defect │ ALL │ 10,000 │ Using temporary │ NULL │ +└──────────────────┴───────┴────────┴───────────────────────┴──────────┘ + +■ After +┌──────────────────┬───────┬───────┬───────────────────────┬────────────────────┐ +│ table │ type │ rows │ extra │ key │ +├──────────────────┼───────┼───────┼───────────────────────┼────────────────────┤ +│ contract_ledger │ ref │ 500 │ Using where │ idx_ledger_main │ +│ contract_partner │ ref │ 3 │ Using index │ idx_partner_lookup │ +│ contract_defect │ ref │ 2 │ Using index │ idx_defect_lookup │ +└──────────────────┴───────┴───────┴───────────────────────┴────────────────────┘ +``` + +| 지표 | Before | After | 변화 | +|------|--------|-------|------| +| 메인 테이블 type | ALL | ref | Full Scan → Index Scan | +| 메인 테이블 rows | 50,000 | 500 | **100배 감소** | +| 서브쿼리 type | ALL | ref | Full Scan → Index Lookup | +| 서브쿼리 rows | 80,000 | 3 | **26,000배 감소** | +| extra | Using temporary | Using index | 임시 테이블 → 커버링 인덱스 | + +숫자만 봐도 차이가 크지만, 핵심은 **Using temporary → Using index**다. 서브쿼리가 매번 8만 건을 GROUP BY하면서 임시 테이블을 만들던 게, 인덱스만으로 3건을 찾는 것으로 바뀌었다. 임시 테이블 생성이라는 **무거운 연산 자체가 사라진 것**이다. + +### 응답시간 + + + +``` +TODO: 실제 측정값으로 교체 + +Before: 약 ?초 +After: 약 ?초 +개선율: ?% +``` + + + +--- + +## 9. 돌아보면 + +### 내가 빠졌던 함정 + +이번 과정에서 내가 빠졌던 함정은 세 가지다. + +**함정 1: WHERE절만 보고 인덱스를 설계했다.** + +쿼리 튜닝이라고 하면 반사적으로 WHERE절을 본다. 틀린 건 아니다. 하지만 이 쿼리의 병목은 WHERE가 아니라 **FROM절의 서브쿼리 JOIN**이었다. + +"어디에 인덱스를 거는가"를 묻기 전에, **"이 쿼리가 실제로 어떻게 실행되는가"**를 먼저 물어야 했다. + +**함정 2: "인덱스가 많으면 위험하다"는 일반론에 매몰됐다.** + +맞는 말이다. 하지만 **이 테이블에는 해당되지 않는 말**이었다. 일반론을 적용하려면 전제 조건(쓰기가 빈번한가?)을 먼저 확인해야 했다. + +인덱스 개수가 아니라, **이 테이블의 읽기/쓰기 비율**이 판단 기준이다. + +**함정 3: EXPLAIN을 안 돌리고 추측했다.** + +"여기가 느릴 것 같다"로 시작하면 틀린다. EXPLAIN을 돌리기 전의 내 추측과 실제 결과는 달랐다. 메인 테이블이 아니라 서브쿼리가 범인이었고, Using temporary라는 경고를 눈으로 확인하고 나서야 확신이 생겼다. + +추측하지 마라. EXPLAIN을 돌려라. **데이터가 답이다.** + + + +### 이 경험에서 발견한 기준 + +``` +1. 쿼리를 전부 읽어라 + — WHERE절만이 아니라 FROM, JOIN, 서브쿼리까지. + +2. EXPLAIN을 돌려라 + — 추측이 아니라 rows, type, extra로 판단하라. + +3. 쿼리 패턴을 분류하라 + — 동적 WHERE의 실제 사용 빈도가 인덱스 우선순위다. + +4. Range는 맨 뒤에 + — 복합 인덱스에서 범위 연산 이후 컬럼은 인덱스를 못 탄다. + +5. 포기할 건 포기하라 + — LIKE '%keyword%', DATE 함수는 인덱스로 해결 불가. + 다른 인덱스가 rows를 줄여주는 구조로 대응하라. +``` + +처음에는 "인덱스를 걸면 빨라진다"고 단순하게 생각했다. 틀렸다. + +> "어떤 인덱스를 거는가"보다 **"이 쿼리가 어떻게 실행되는가"**를 먼저 이해하는 게 시작이다. + +--- + +## 10. 결국 비정규화를 했다 + +인덱스로 서브쿼리의 병목을 제거했다. Using temporary가 사라졌고, rows도 극적으로 줄었다. 하지만 쿼리를 다시 보니 **근본적인 질문**이 남았다. + +> "인덱스를 아무리 잘 걸어도, JOIN 4개를 매번 타는 구조 자체가 문제 아닌가?" + +### 인덱스만으로는 부족했던 이유 + +인덱스가 JOIN의 속도를 빠르게 만들어준 건 맞다. 하지만 **JOIN 자체가 없으면 더 빠르다.** 당연한 말인데, 이걸 실감한 건 인덱스를 걸고 나서였다. + +LEFT JOIN ②③이 하는 일을 다시 보자: + +``` +LEFT JOIN ② : 1순번 거래처의 거래처명 → 화면에 "거래처명" 표시 +LEFT JOIN ③ : 대표 거래처의 거래처명 → 화면에 "대표거래처명" 표시 +``` + +이 JOIN들은 **contract_partner → partner_master**를 타고 가서 거래처명을 가져온다. 매 조회마다. 5만 건 각각에 대해. + +"이걸 메인 테이블에 넣어두면 JOIN을 안 해도 되지 않나?" + +처음엔 이 생각을 보류했다. 비정규화는 **정규화의 원칙을 깨는 것**이니까. 거래처명이 바뀌면 동기화해야 하고, 데이터 정합성 관리 포인트가 늘어난다. "지금 안 아프면 안 바꾼다"는 판단이었다. + +근데 돌아보니, **지금 아프다.** 인덱스를 걸어도 JOIN 2개는 여전히 실행되고 있었다. 그리고 이 테이블의 거래처 정보는 **한번 등록되면 거의 변경되지 않는다.** 변경 빈도가 극히 낮은 데이터를 매번 JOIN으로 가져오는 건, 비용 대비 이득이 맞지 않았다. + +### 비정규화 판단 기준 + +비정규화를 결정하기 전에 세 가지를 확인했다. + +``` +1. 이 데이터가 얼마나 자주 변경되는가? + → 거래처명 변경: 연 수건 이하. 거의 없다. + +2. 변경 시 동기화를 놓치면 어떤 일이 벌어지는가? + → 목록에 옛 거래처명이 표시됨. 치명적이진 않다. + → 상세 화면에서는 원본 테이블을 조회하므로 정합성 확인 가능. + +3. JOIN을 제거했을 때 얼마나 빨라지는가? + → LEFT JOIN 2개 + INNER JOIN 2개 제거 → 쿼리 구조 자체가 단순해짐. +``` + +세 가지 모두 비정규화에 유리했다. **변경은 드물고, 변경 시 리스크는 낮고, 제거 효과는 크다.** 보류할 이유가 없었다. + +### 마이그레이션 + +비정규화를 "하겠다"와 "했다"는 다르다. 운영 중인 테이블의 구조를 바꾸는 건 신중해야 한다. + +**Step 1. 컬럼 추가** + +```sql +ALTER TABLE contract_ledger + ADD COLUMN partner_name VARCHAR(100) COMMENT '거래처명 (비정규화)', + ADD COLUMN partner_count INT DEFAULT 0 COMMENT '거래처 수 (비정규화)'; +``` + +기존 데이터에 영향을 주지 않도록 **컬럼 추가만** 먼저 수행했다. 이 시점에서 새 컬럼은 전부 NULL이다. 기존 쿼리는 이 컬럼을 참조하지 않으므로 서비스에 영향 없다. + +**Step 2. 기존 데이터 마이그레이션** + +```sql +-- 거래처명: 1순번 거래처의 이름을 메인 테이블로 복사 +UPDATE contract_ledger LEG +INNER JOIN contract_partner CLT + ON LEG.contract_no = CLT.contract_no + AND CLT.partner_seq = 1 +INNER JOIN partner_master MST + ON CLT.partner_code = MST.partner_code +SET LEG.partner_name = MST.partner_name +WHERE LEG.partner_name IS NULL; + +-- 거래처 수: GROUP BY로 집계한 값을 메인 테이블로 복사 +UPDATE contract_ledger LEG +INNER JOIN ( + SELECT contract_no, COUNT(*) AS cnt + FROM contract_partner + GROUP BY contract_no +) CNT ON LEG.contract_no = CNT.contract_no +SET LEG.partner_count = CNT.cnt +WHERE LEG.partner_count = 0; +``` + +5만 건에 대해 UPDATE를 실행했다. 이 작업은 **한 번만 수행**되는 것이므로 소요 시간은 문제가 되지 않는다. + + + +**Step 3. 쿼리 수정** + +마이그레이션이 완료된 후, 조회 쿼리에서 JOIN ①②③을 제거하고 메인 테이블의 컬럼으로 대체했다. + +```sql +-- AS-IS: JOIN 4개 +SELECT LEG.*, + CLT_CNT.partner_count, + MST1.partner_name AS partner_name_1, + MST2.partner_name AS partner_name_rep, + ... +FROM contract_ledger LEG +LEFT JOIN (SELECT contract_no, COUNT(*) ... GROUP BY contract_no) CLT_CNT ... +LEFT JOIN contract_partner CLT1 ... INNER JOIN partner_master MST1 ... +LEFT JOIN contract_partner CLT2 ... INNER JOIN partner_master MST2 ... +LEFT JOIN contract_defect DEF ... +WHERE ... + +-- TO-BE: JOIN 1개 (하자관리만 남음) +SELECT LEG.*, + LEG.partner_count, + LEG.partner_name, + ... +FROM contract_ledger LEG +LEFT JOIN contract_defect DEF ... +WHERE ... +``` + +LEFT JOIN 3개가 사라졌다. **SELECT에서 직접 메인 테이블의 컬럼을 읽으면 되니까 JOIN이 필요 없다.** + +**Step 4. 동기화 처리** + +비정규화의 대가는 **동기화**다. 거래처 정보가 변경되면 메인 테이블도 함께 갱신해야 한다. + +```sql +-- 거래처 등록/수정/삭제 시 트리거 또는 서비스 로직에서 실행 +UPDATE contract_ledger +SET partner_name = #{newPartnerName}, + partner_count = ( + SELECT COUNT(*) FROM contract_partner + WHERE contract_no = #{contractNo} + ) +WHERE contract_no = #{contractNo}; +``` + +거래처 변경이 연 수건 이하이므로, 이 동기화 비용은 **매 조회마다 JOIN을 타는 비용**에 비하면 무시할 수 있다. + + + +### 비정규화 후 EXPLAIN + + + +``` +■ After Index (§8) +┌──────────────────┬───────┬───────┬───────────────────────┬────────────────────┐ +│ table │ type │ rows │ extra │ key │ +├──────────────────┼───────┼───────┼───────────────────────┼────────────────────┤ +│ contract_ledger │ ref │ 500 │ Using where │ idx_ledger_main │ +│ contract_partner │ ref │ 3 │ Using index │ idx_partner_lookup │ +│ contract_defect │ ref │ 2 │ Using index │ idx_defect_lookup │ +└──────────────────┴───────┴───────┴───────────────────────┴────────────────────┘ + +■ After Denormalization (§10) +┌──────────────────┬───────┬───────┬───────────────────────┬────────────────────┐ +│ table │ type │ rows │ extra │ key │ +├──────────────────┼───────┼───────┼───────────────────────┼────────────────────┤ +│ contract_ledger │ ref │ 500 │ Using where │ idx_ledger_main │ +│ contract_defect │ ref │ 2 │ Using index │ idx_defect_lookup │ +└──────────────────┴───────┴───────┴───────────────────────┴────────────────────┘ +``` + +contract_partner 행이 **통째로 사라졌다.** 인덱스가 "빠르게 찾는 것"이라면, 비정규화는 **"찾을 필요 자체를 없앤 것"**이다. + +인덱스 최적화에서 rows가 80,000 → 3으로 줄었을 때도 대단하다고 느꼈는데, **테이블 접근 자체가 사라지는 건 차원이 다른 개선**이었다. + +### 정리: 인덱스 vs 비정규화 + +``` +인덱스: "이 테이블에서 빠르게 찾아라" → rows를 줄인다 +비정규화: "이 테이블을 아예 보지 마라" → JOIN을 없앤다 +``` + +둘 다 읽기 성능을 개선하지만, 비용 구조가 다르다. + +| | 인덱스 | 비정규화 | +|---|---|---| +| **개선 방식** | 탐색 경로 최적화 | JOIN 제거 | +| **쓰기 비용** | INSERT/UPDATE 시 인덱스 갱신 | 원본 변경 시 동기화 필요 | +| **적용 조건** | 항상 가능 | 변경이 드문 데이터에 유리 | +| **되돌리기** | DROP INDEX | 컬럼 제거 + 쿼리 원복 (더 번거로움) | + +인덱스는 "안전한 개선"이고, 비정규화는 **"트레이드오프를 감수한 개선"**이다. 그래서 인덱스를 먼저 시도하고, 그것만으로 부족할 때 비정규화를 검토하는 순서가 맞았다. + +--- + +## 11. 아직 남은 것들 + +**5만 건을 한 번에 가져오는 것 자체가 문제일 수 있다.** 현재 이 화면은 페이지네이션 없이 전체 결과를 한 번에 로드한다. 커서 기반 페이징을 도입하면 인덱스 효율이 더 좋아진다. 하지만 ERP 특성상 사용자가 "전체 목록을 한눈에 보고 싶다"는 요구가 강하기 때문에, 이건 기술적 판단만으로 결정할 수 없는 영역이다. + +동시성 제어를 다뤘던 이전 글에서 "아프기 시작하면 바꾸는 거다"라고 정리했었는데, 인덱스도 비정규화도 마찬가지였다. **전환 시점은 트래픽 숫자가 아니라, 운영에서 관측되는 실패 모드가 기준이다.** 슬로우 쿼리 로그가 찍히기 시작하면, 그때 다음 단계를 밟으면 된다. + + + +--- + +> 이 글에서 사용된 테이블명, 컬럼명, 데이터는 보안을 위해 모두 치환되었습니다. diff --git a/docs/blog/index-tuning-research.md b/docs/blog/index-tuning-research.md new file mode 100644 index 000000000..219c1ec78 --- /dev/null +++ b/docs/blog/index-tuning-research.md @@ -0,0 +1,278 @@ +# 인덱스 튜닝 블로그 리서치 — 실무 조회 쿼리 개선기 + +> 원본: ALLSP_STD > ctrLeg101 (계약대장관리) 메인 조회 쿼리 +> 보안을 위해 테이블/컬럼명을 치환하여 블로그에 사용 + +--- + +## 1. 치환 매핑표 + +블로그 공개 시 아래 매핑으로 치환한다. **원본 이름은 절대 블로그에 노출하지 않는다.** + +### 테이블 치환 + +| 원본 테이블 | 치환 테이블 | 설명 | +|------------|-----------|------| +| CTR_CTR_LEDG | `contract_ledger` | 계약대장 마스터 | +| CTR_CTR_CLT | `contract_partner` | 계약 거래처 | +| ACC_CLT | `partner_master` | 거래처 기본정보 | +| CTR_CTR_FLAW | `contract_defect` | 하자관리 | +| CTR_CTR_CHG | `contract_change` | 변경이력 | +| CTR_DFRCMPST | `contract_penalty` | 지연배상금 | +| CTR_CTR_ATAC | `contract_seizure` | 압류현황 | +| CTR_CTR_SUBCNTR | `contract_subcontract` | 하도급 | +| SYS_DEPT / VW_USE_DEPT | `department` | 부서 | + +### 주요 컬럼 치환 + +| 원본 컬럼 | 치환 컬럼 | 설명 | +|----------|----------|------| +| CTR_LEDG_NO | `contract_no` | 계약대장번호 (PK) | +| ACC_YY | `fiscal_year` | 회계연도 | +| CTR_NM | `contract_name` | 계약명 | +| CTR_DAT | `contract_date` | 계약일자 | +| CTR_AMT | `contract_amount` | 계약금액 | +| CTR_KND | `contract_type` | 계약종류 코드 | +| CTR_FOM | `contract_form` | 계약형태 | +| COMPL_DAT | `completion_date` | 준공일자 | +| CLT_CD | `partner_code` | 거래처 코드 | +| CLT_NM | `partner_name` | 거래처명 | +| CLT_SEQ | `partner_seq` | 거래처 순번 | +| REP_CLT_YN | `is_primary` | 대표거래처 여부 | +| BZR_REGNO | `biz_reg_no` | 사업자등록번호 | +| CTR_MNG_DEPT | `manage_dept` | 관리부서 | +| CTR_REQTER_NM | `requester_name` | 요청자명 | +| PCUR_DIV | `procurement_div` | 조달구분 | +| PCUR_NO | `procurement_no` | 조달번호 | +| FLAW_GRNTY_ENDDD | `defect_warranty_end` | 하자보증 종료일 | + +--- + +## 2. 현재 쿼리 구조 분석 (AS-IS) + +### 2.1 메인 조회 쿼리 구조도 + +``` +contract_ledger (LEG) ← 메인 테이블 + ├─ LEFT JOIN ① 거래처 건수 서브쿼리 (COUNT + GROUP BY) + ├─ LEFT JOIN ② 1순번 거래처 정보 (partner_seq = 1) + │ └─ INNER JOIN partner_master (거래처 기본정보) + ├─ LEFT JOIN ③ 대표 거래처 정보 (is_primary = 'Y') + │ └─ INNER JOIN partner_master (거래처 기본정보) + └─ LEFT JOIN ④ 하자보증 종료일 집계 (GROUP BY) +``` + +### 2.2 동적 WHERE 조건 (MyBatis ``) + +```sql +WHERE fiscal_year = #{searchFiscalYear} -- ★ 필수조건 (항상) + [AND contract_date >= #{searchDateStart}] -- 선택: 계약일자 범위 + [AND contract_date <= #{searchDateEnd}] + [AND contract_no = #{searchContractNo}] -- 선택: 계약번호 정확매칭 + [AND contract_type = #{searchContractType}] -- 선택: 계약종류 + [AND contract_name LIKE '%keyword%'] -- 선택: 계약명 LIKE + [AND procurement_no = #{searchProcNo}] -- 선택: 조달번호 + [AND procurement_div = #{searchProcDiv}] -- 선택: 조달구분 + [AND requester_name LIKE '%keyword%'] -- 선택: 요청자 LIKE + [AND manage_dept = #{searchMngDept}] -- 선택: 관리부서 + [AND 하자보증만료 임박 조건] -- 선택: 날짜 계산 + [AND EXISTS (거래처명/사업자번호 검색 서브쿼리)] -- 선택: 거래처 검색 +``` + +### 2.3 핵심 병목 포인트 식별 + +| # | 병목 후보 | 이유 | +|---|----------|------| +| ① | **4개의 LEFT JOIN** (그 중 2개가 서브쿼리) | 서브쿼리가 전체 테이블을 GROUP BY하므로, contract_partner 데이터가 많을수록 비용 증가 | +| ② | **LIKE '%keyword%'** (양쪽 와일드카드) | 인덱스 사용 불가. Full Table Scan 유발 | +| ③ | **EXISTS 서브쿼리 (거래처 검색)** | 외부 쿼리 행마다 서브쿼리 실행. contract_partner 테이블 반복 스캔 | +| ④ | **DATE 함수 사용 (하자보증만료 조건)** | `DATE_SUB(DATE_ADD(...))` → 인덱스 미작동 | +| ⑤ | **필수조건이 fiscal_year 단 1개** | 파티셔닝이나 인덱스 없으면 해당 연도 전체 스캔 | + +--- + +## 3. 인덱스 설계 전략 (TO-BE) + +### 3.1 쿼리 패턴별 인덱스 후보 + +현재 쿼리의 WHERE 조건 조합을 빈도순으로 정리하면: + +``` +[패턴 A] 가장 빈번: fiscal_year만으로 조회 (전체 목록) +[패턴 B] 빈번: fiscal_year + contract_type +[패턴 C] 빈번: fiscal_year + contract_date 범위 +[패턴 D] 가끔: fiscal_year + contract_name LIKE +[패턴 E] 가끔: fiscal_year + manage_dept +[패턴 F] 드묾: fiscal_year + 거래처 EXISTS +``` + +### 3.2 인덱스 설계안 + +```sql +-- ■ 인덱스 1: 메인 테이블 기본 조회 (패턴 A, B, C 커버) +CREATE INDEX idx_ledger_year_type_date +ON contract_ledger(fiscal_year, contract_type, contract_date); + +-- ■ 인덱스 2: 관리부서 필터 (패턴 E) +CREATE INDEX idx_ledger_year_dept +ON contract_ledger(fiscal_year, manage_dept); + +-- ■ 인덱스 3: 거래처 서브쿼리 성능 개선 +CREATE INDEX idx_partner_contract_no +ON contract_partner(contract_no, partner_seq); +-- → LEFT JOIN ①②③ 모두 contract_no로 조인하므로 필수 + +-- ■ 인덱스 4: 하자관리 GROUP BY 최적화 +CREATE INDEX idx_defect_contract_no +ON contract_defect(contract_no, defect_warranty_end); +-- → LEFT JOIN ④ GROUP BY 최적화 +``` + +### 3.3 LIKE 검색 한계와 대안 + +``` +문제: contract_name LIKE '%keyword%' → 인덱스 불가 + +대안 1: 앞쪽 와일드카드 제거 (LIKE 'keyword%') → 비즈니스 요건상 어려움 +대안 2: Full-Text Index 적용 → MySQL 5.7+ 지원, 한글 형태소 분석 한계 +대안 3: 별도 검색 인덱스 테이블 (역인덱스) → 오버엔지니어링 가능성 +대안 4: 현실적 선택 — LIKE는 그대로 두고, 다른 조건으로 rows를 먼저 줄인 후 LIKE 필터 + +→ 블로그 포인트: "인덱스로 해결할 수 없는 것"을 인정하는 것도 설계의 일부 +``` + +### 3.4 DATE 함수 문제 해결 + +```sql +-- AS-IS (인덱스 미작동) +WHERE CURDATE() >= DATE_SUB(DATE_ADD(completion_date, INTERVAL 1 YEAR), INTERVAL 15 DAY) + +-- TO-BE (인덱스 작동 가능) +WHERE completion_date >= DATE_SUB(DATE_SUB(CURDATE(), INTERVAL 1 YEAR), INTERVAL -15 DAY) +-- → completion_date 컬럼에 직접 비교하도록 변환 +-- → completion_date에 인덱스가 있으면 Range Scan 가능 +``` + +--- + +## 4. EXPLAIN 분석 시나리오 (블로그용) + +### 4.1 Before: 인덱스 없는 상태 + +``` +예상 EXPLAIN 결과: +┌─────────┬────────┬──────┬───────────────┬──────────┐ +│ table │ type │ rows │ extra │ key │ +├─────────┼────────┼──────┼───────────────┼──────────┤ +│ LEG │ ALL │ 50K │ Using where │ NULL │ ← Full Table Scan +│ CCC │ ALL │ 80K │ Using temp │ NULL │ ← 서브쿼리 전체스캔 +│ CLT_INF │ ALL │ 80K │ Using where │ NULL │ ← 거래처 전체스캔 +│ CTF │ ALL │ 10K │ Using temp │ NULL │ ← 하자 전체스캔 +└─────────┴────────┴──────┴───────────────┴──────────┘ +``` + +### 4.2 After: 인덱스 적용 후 + +``` +예상 EXPLAIN 결과: +┌─────────┬────────┬──────┬───────────────┬──────────────────────────┐ +│ table │ type │ rows │ extra │ key │ +├─────────┼────────┼──────┼───────────────┼──────────────────────────┤ +│ LEG │ ref │ 500 │ Using where │ idx_ledger_year_type_date│ ← Index Scan +│ CCC │ ref │ 3 │ Using index │ idx_partner_contract_no │ ← Covering Index +│ CLT_INF │ ref │ 1 │ │ idx_partner_contract_no │ +│ CTF │ ref │ 2 │ Using index │ idx_defect_contract_no │ ← Covering Index +└─────────┴────────┴──────┴───────────────┴──────────────────────────┘ +``` + +--- + +## 5. 블로그 글 구성안 + +### 제목 후보 + +1. **"계약 5만 건을 조회하는데 8초 — 인덱스 하나로 0.3초가 된 이야기"** +2. **"EXPLAIN 하나면 충분하다 — 실무 조회 쿼리 튜닝 일지"** +3. **"인덱스를 걸기 전에 쿼리를 먼저 읽어라"** + +### 목차 구성 + +``` +1. 들어가며: 왜 이 쿼리를 튜닝해야 했는가 + - 상황 설명: N개의 JOIN + 동적 WHERE 조건을 가진 대장 조회 화면 + - 체감 문제: 목록 조회 시 수 초 이상 대기 + +2. 쿼리 구조 파악: 무엇이 느린 건지 모르면 고칠 수 없다 + - 쿼리 구조도 (JOIN 관계 시각화) + - 동적 WHERE 조건 패턴 정리 + - "어디가 문제인지 짐작이 가지 않았다" + +3. EXPLAIN으로 병목 찾기 + - EXPLAIN 결과 캡처 (Before) + - type=ALL, rows, extra 해석 + - "범인은 서브쿼리 LEFT JOIN이었다" + +4. 인덱스 설계: 어떤 기준으로 무엇을 걸었는가 + - 쿼리 패턴 빈도 분석 → 인덱스 우선순위 + - 복합 인덱스 컬럼 순서 결정 과정 + - 레인지 연산(날짜 범위)은 맨 뒤에 배치한 이유 + - 커버링 인덱스 기회 발굴 + +5. 인덱스로 해결할 수 없는 것들 + - LIKE '%keyword%'는 왜 인덱스를 못 타는가 + - DATE 함수를 컬럼에 걸면 인덱스가 죽는 이유 + - "모든 것을 인덱스로 해결하려 하지 마라" + +6. 적용 결과: EXPLAIN 비교 (Before vs After) + - rows 감소율 + - type 변화 (ALL → ref) + - extra에서 Using filesort / Using temporary 제거 여부 + - 실제 응답시간 비교 + +7. 회고: 인덱스 설계에서 배운 것 + - 멘토링에서 배운 원칙과 실제 적용의 gap + - "인덱스 개수가 아니라 쿼리 패턴이 기준이다" + - 트레이드오프: 인덱스 추가 → 쓰기 부담 증가 +``` + +--- + +## 6. 블로그에 넣을 핵심 비교 자료 (TODO) + +실제 EXPLAIN을 돌려서 아래 수치를 채워야 한다: + +| 항목 | Before | After | 개선율 | +|------|--------|-------|--------| +| rows (메인 테이블) | ? | ? | ? | +| type (메인 테이블) | ALL? | ref? | - | +| extra 경고 | Using filesort? | 제거? | - | +| 실제 응답시간 | ?초 | ?초 | ?% | +| 서브쿼리 스캔 rows | ? | ? | ? | + +--- + +## 7. 멘토링 연결 포인트 + +블로그에 자연스럽게 녹일 수 있는 멘토링 인사이트: + +| 멘토링 내용 | 블로그 활용 | +|------------|-----------| +| "실제 SELECT 쿼리 패턴에 맞춰 설계하라" | 동적 WHERE 조건 패턴 분석 과정에 인용 | +| "레인지 연산 이후 컬럼은 인덱스 미작동" | 복합 인덱스 순서 결정 근거로 활용 | +| "DB 함수 사용 시 인덱스 미작동" | DATE 함수 문제 해결 섹션에 연결 | +| "인덱스 개수는 무의미, 조회에 쓰이면 걸어라" | 인덱스 4개를 추가한 판단 근거 | +| "EXPLAIN extra의 Using filesort = 병목 신호" | Before EXPLAIN 분석 시 강조 | +| "커버링 인덱스로 디스크 I/O 제거" | 서브쿼리 JOIN 최적화 설명 | + +--- + +## 8. 보안 체크리스트 + +- [ ] 원본 테이블명(CTR_CTR_LEDG 등) 노출 안 됨 +- [ ] 원본 컬럼명(CTR_LEDG_NO 등) 노출 안 됨 +- [ ] 프로젝트명(ALLSP_STD, ALLSP_ANSAN) 노출 안 됨 +- [ ] 화면 ID(ctrLeg101) 노출 안 됨 +- [ ] 회사명, 고객사명 노출 안 됨 +- [ ] 실제 데이터 값 노출 안 됨 +- [ ] 비즈니스 로직(채번 규칙 등) 상세 노출 안 됨 +- [ ] 스크린샷에 실제 화면/데이터 없음 diff --git a/docs/mentoring b/docs/mentoring new file mode 100644 index 000000000..2fb70c6a6 --- /dev/null +++ b/docs/mentoring @@ -0,0 +1,802 @@ +# [Round-5] 멘토링 정리 - 인덱스와 캐싱 (2026-03-11) + +**멘토**: Alen (토스 재직) +**일시**: 2026-03-11 +**주제**: 데이터베이스 성능 최적화 - 인덱스와 캐싱 전략 + +--- + +## 1. 인덱스 핵심 개념 정리 + +### 1.1 인덱스란? + +**핵심 비유: "책자"** +- 데이터를 정렬된 순서로 정리해 놓은 것 +- 조회 시 전체 데이터를 스캔하지 않고 빠르게 찾을 수 있음 +- 인덱스의 마지막에는 **실제 데이터의 디스크 주소(포인터)**만 저장됨 (데이터 자체가 아님) + +**중요 개념: 조합마다 다른 인덱스 필요** +- 정렬/검색 순서가 다르면 새로운 인덱스를 만들어야 함 +- 인덱스가 많으면 **데이터 변경 시 모든 인덱스를 함께 갱신**해야 하는 부담 발생 + +### 1.2 커버링 인덱스 (Covered Index) + +**정의**: 조회에 필요한 모든 컬럼이 인덱스에 포함되어 있는 경우 + +```sql +-- 예: (user_id, status, created_at) 복합 인덱스 +CREATE INDEX idx_user_status ON orders(user_id, status, created_at); + +-- 다음 쿼리는 커버링 인덱스 활용 가능 (SELECT * 제외) +SELECT user_id, status, created_at +FROM orders +WHERE user_id = 123 AND status = 'completed'; +``` + +**장점** +- 인덱스만으로 결과 반환 가능, 디스크 I/O 불필요 +- `SELECT *` 대신 **필요한 컬럼만 명시**하면 커버링 활용 +- 자주 조회되는 패턴에 대해 구성하면 성능 대폭 향상 + +### 1.3 레인지 연산과 인덱스 제약 + +**핵심 원리: 연산 유형에 따른 인덱스 효율** + +| 연산 유형 | 효율성 | 설명 | +|----------|--------|------| +| Equal (=) | ⭐⭐⭐ | 인덱스가 가장 강력하게 동작 | +| Range (>, <, BETWEEN) | ⭐⭐ | 해당 컬럼 이후 인덱스는 활용 불가 | + +**레인지 연산의 문제** + +복합 인덱스 `(col1, col2, col3)` 에서: +```sql +-- col1 = equal, col2 = range → col3는 인덱스 안 탐 +SELECT * FROM table +WHERE col1 = 'A' AND col2 > 100 AND col3 = 'B'; +-- col3는 인덱스로 필터링 안 됨 +``` + +**해결책: 레인지 조건을 맨 뒤에 배치** +```sql +-- 인덱스: (col1, col3, col2) - range를 맨 뒤에 +CREATE INDEX idx_optimized ON table(col1, col3, col2); + +-- 이제 col3까지 인덱스로 필터링 가능 +SELECT * FROM table +WHERE col1 = 'A' AND col3 = 'B' AND col2 > 100; +``` + +**예외: 단일 컬럼 인덱스** +- 후행 인덱스가 없으므로 Range 연산도 잘 작동 + +### 1.4 인덱스 설계 원칙 + +**원칙 1: 실제 SELECT 쿼리 패턴에 맞춰 설계** +- "이렇게 걸면 좋을 것 같은데" → 불충분 +- 실제 쿼리가 나가는 패턴을 분석한 후 설계 + +**원칙 2: 복합 인덱스 순서가 중요** +```sql +-- (col1, col2) 인덱스 +-- col1로만 조회 → 사용 가능 +-- col2로만 조회 → 사용 불가 (Leftmost prefix) + +-- (col2, col1) 인덱스는 전혀 다른 인덱스 +-- 선택하는 쿼리 패턴에 맞게 구성 필수 +``` + +**원칙 3: DB 함수 사용 시 인덱스 미작동** +```sql +-- DATE(created_at) = '2026-03-11' → created_at 인덱스 못 탐 +-- 함수 적용 전에 값을 알 수 없으므로 계산 필요 +-- 해결: WHERE created_at >= '2026-03-11' AND created_at < '2026-03-12' +``` + +### 1.5 인덱스 개수의 올바른 기준 + +**멘토의 의견: "개수 자체는 무의미"** + +**올바른 기준** +- **조회/검색에 쓰이는 모든 컬럼은 무조건 인덱스** +- 쓰기 부담과 중복 인덱스 제거는 이후 최적화 단계 + +**대규모 운영 사례** +- 멘토 경험: 상품 테이블 9천만 개 레코드에서 인덱스 10개 이상 운용 +- 원칙: 주기적으로 쿼리 분석해서 사용하지 않는 인덱스 제거 + +**읽기 확장 환경에서의 이점** +- 일반적으로: 마스터 DB (쓰기), 레플리카 DB (읽기) 분리 +- 읽기 레플리카는 쓰기 부담이 없으므로 **인덱스를 아낌없이 추가하는 것이 유리** + +--- + +## 2. 캐시 핵심 개념 정리 + +### 2.1 캐시가 필요한 이유 + +**바구니 이론** + +``` +상황: DB 조회 요청이 많이 몰림 → DB 부하 증가 → 응답 지연 + +해결: "바구니(캐시)"를 만들어 DB 결과를 미리 저장 + +흐름: +1. 바구니에 데이터 있나? (Cache-Aside 패턴) + - 있으면 → 그대로 가져감 (빠른 응답) + - 없으면 → DB에서 가져오고 바구니에 넣어둠 (다음 요청을 위해, 이타심!) +``` + +**핵심**: DB 조회 부하를 줄이고 응답 속도 향상 + +**캐시는 DB만을 위한 것이 아님** +- 캐시 개념은 DB뿐 아니라 GPU 서버, 추천 모델 등 연산 비용이 높은 모든 곳에 적용 가능 +- 핵심: 조회 비용이나 연산 비용이 높은 것을 미리 계산(Pre-computed)해서 저장하는 것 + +### 2.2 로컬 캐시 vs 글로벌 캐시 + +| 구분 | 로컬 캐시 | 글로벌 캐시 | +|------|----------|-----------| +| **저장소** | 서버 메모리 (HashMap, ConcurrentHashMap) | 외부 캐시 서버 (Redis, Valkey) | +| **속도** | ⭐⭐⭐ 빠름 | ⭐⭐ 네트워크 레이턴시 | +| **네트워크** | 없음 | 네트워크 I/O 발생 | +| **일관성** | 서버마다 다른 값 가능 | 모든 서버가 동일한 값 참조 | +| **서버 독립성** | 서버 재시작/배포 시 초기화 | 서버 장애 영향 최소 | +| **장애 위험** | 낮음 | 캐시 서버 장애 가능성 | + +**실무 전략: L1 + L2 2단계 캐싱** + +``` +L1 캐시 (로컬, 짧은 TTL) +└─ 트래픽 방어 목적 + TTL: 매우 짧음 (초 단위) + +L2 캐시 (글로벌, 긴 TTL) +└─ DB 보호 목적 + TTL: 길게 (분~시간 단위) +``` + +### 2.3 캐시 갱신 전략 (Write Path) + +**문제**: 원본(DB)이 바뀌면 사본(캐시)과 불일치 발생 + +**불일치 해결 방법** +1. **TTL 만료**: 자연 만료 후 DB에서 다시 가져옴 + ``` + 단점: TTL 만료 전까지 stale 데이터 서빙 + TTL 만료 후 동시 요청 → 모두 DB로 몰림 (Cache Stampede) + ``` +2. **변경 시 캐시 삭제(Evict) - 하수**: 바구니를 비움 → 다음 요청이 DB 조회 + ``` + 쓰기 흐름: + 1. DB 업데이트 (Source of Truth) + 2. 캐시 삭제 (evict) + + 단점: 삭제 후 다음 조회가 DB로 가야 함 + ``` +3. **변경 시 캐시 덮어쓰기(Put) - 고수 ⭐**: 바꾼 사본을 바구니에 넣음 → DB 조회 불필요 + ``` + 쓰기 흐름: + 1. DB 업데이트 (Source of Truth) + 2. 캐시에 새 값을 put + + 결과: 조회가 계속 캐시에서 발생 → DB 부하 최소화 + ``` + +**Write-Through vs Write-Around** + +| 패턴 | 흐름 | 위험성 | +|------|------|--------| +| Write-Through | 캐시 먼저 쓰고 DB 씀 | 캐시만 써지고 DB 장애 가능 | +| Write-Around ⭐ | DB 먼저 쓰고 캐시 갱신 | 캐시 갱신 전 장애 시 일시적 stale (TTL로 복구) | + +**권장**: Write-Around (DB를 Source of Truth로 취급) + +**참고**: 멘토는 정합성이 중요한 경우 Write-Through를 명시적으로 파기하고 Write-Around를 권장 + +### 2.4 캐시 스탐피드 (Cache Stampede) + +**문제 상황** +``` +캐시 miss 발생 → 동시에 많은 요청이 DB로 몰림 → DB 부하 급증 +``` + +**해결 방법 1: Lock + Timeout + Retry 패턴** +``` +캐시 miss → GetLock(key) 시도 + ├─ 성공: 한 명만 DB 조회 → 캐시 set → lock release + └─ 실패: 대기 → 재시도 (캐시 조회) + +효과: 한 명만 DB로 가고, 나머지는 캐시 대기 +``` + +**해결 방법 2: 확률적 갱신** +``` +TTL 만료 전, 10% 확률로 DB 조회 → 캐시 미리 갱신 +효과: 갱신이 점진적으로 발생, 스탐피드 방지 +``` + +**해결 방법 3: Pre-warming (멘토 선호) ⭐** +``` +주기적으로 뒤에서 캐시를 미리 갱신 +예시: + - 블랙프라이데이: 1분 전부터 미리 갱신 + - 배치 작업: 00시에 미리 갱신 + +효과: 사용자가 접근할 때 이미 캐시됨 +``` + +### 2.5 캐시 설계 원칙 (멘토) + +0. **Cache-Aside만으로도 대부분 충분** (멘토 강조) + - 사본이 차 있을 때는 다 캐시에서 조회되므로 많은 방어가 됨 + - 트래픽이 더 많은 서비스에서만 스탬피드 방어 등 추가 고민 필요 + +1. **조회는 항상 캐시에서 될 수 있게 설계** + - 보완책으로 Cache-Aside 패턴 사용 + +2. **쓰기 쪽에서 갱신 전략을 먼저 구상** + - DB 업데이트 후 캐시 처리 방법 결정 + +3. **유저 행동 패턴/동선에 따라 캐시 설계 변경** + - 예: 유저 로그인 시 배너 캐시 미리 생성 (추천 로직이 비싸므로) + - 예: 1페이지는 자주 봄, 2페이지는 거의 안 봄 → 1페이지만 캐시 + +--- + +## 3. Q&A 정리 + +### Q1. 캐싱 전략 선택 흐름 (김요한) + +**질문**: `@Cacheable` 등 어노테이션으로 캐시를 붙이면 어떤 정책인지 명확하지 않은데, 실무에서는 정책을 먼저 정하고 구현하나? + +**멘토 답변**: +- **캐시는 항상 먼저 설계함** (코드 아님) +- 설계 순서: + 1. 조회는 항상 캐시에서 될 수 있게 설계 + 2. 쓰기 쪽에서 갱신 전략 구상 + 3. 데이터의 조회/연산 비용 분석 + 4. 유저 행동 패턴 분석 후 캐시 전략 결정 + +**실무 예시**: +``` +유저별 배너 캐싱: +1. 로그인 시 배너 추천 로직 실행 → 캐시 생성 +2. 또는 배치 작업으로 00시에 미리 생성 +3. 유저 행동 변화 감지 시 즉시 갱신 +``` + +--- + +### Q2. Redis 캐시 도입 시점 판단 (P0) + +**질문**: DB 인덱스만으로 성능 개선이 큰데, 어느 시점부터 Redis 캐시를 붙이나? + +**멘토 답변**: +- **트래픽만 봄** (거의 100%) +- 핵심 판단 기준: **"동일 조회 패턴이 반복되는가?"** + +**도입 신호**: +``` +1. Read QPS(Query Per Second)가 높음 + → 아무리 인덱스가 잘 걸려있어도 DB가 아파함 + → 그 쿼리만 처리하는 게 아니기 때문 + +2. Read QPS가 높은 기능 + → DB로 최대한 안 흘러들어가게 하는 것이 목적 +``` + +**맘스터치 비유 (멘토)**: +- 옆집 맥도날드가 점심에 바글바글하니까, 우리도 바글바글할 걸 기대하고 싸이버거 100개를 미리 만들어둠 +- 그런데 손님이 안 옴 → 미리 만든 게 다 낭비 +- 캐시도 동일: 미리 만들어 두는 게 유의미한 데이터인가를 먼저 판단해야 함 + +**Redis 메모리 운영**: +``` +평시 기준 70% 미만 유지 +(30% 버퍼 확보로 장애 대응) +``` + +--- + +### Q3. 슬로우 쿼리 해결 판단 기준 (양권모) + +**질문**: EXPLAIN → 인덱스 → 쿼리 개선 → 캐싱 → 힌트 순서로 접근하는데 맞는지? + +**멘토 답변 (90점 접근)**: + +**EXPLAIN 분석의 핵심 지표**: + +| 지표 | 확인 항목 | 의미 | +|------|---------|------| +| rows | 스캔된 행 수 | 적을수록 좋음 | +| type | 접근 방식 | ref > range > index > ALL | +| extra | 추가 정보 | Using Filesort, Using temporary ← 병목 신호 | + +**extra의 위험 신호**: +``` +Using Filesort, Using temporary +→ 성능 병목, 인덱스를 제대로 못 타고 있다는 의미 +→ 정렬/그룹 작업이 메모리 밖에서 진행 중 +``` + +**셀렉티비티만 보는 건 위험한 접근** ⚠️ +- 실제로는 쿼리 패턴, 복합 인덱스 순서가 더 중요 +- 레인지 연산 여부, DB 함수 사용 여부도 확인 필수 + +**올바른 접근 순서** (멘토 권장): +``` +1. EXPLAIN 분석 (rows, type, extra) +2. 인덱스 설계/추가 +3. 쿼리 개선 (페이지네이션/LIMIT, JOIN 순서, WHERE 조건 재구성) +4. 데이터 모델 변경 (정규화/비정규화) +5. 캐시 도입 +``` + +**힌트 사용에 대한 경고**: +``` +절대 안 쓸수록 좋음 ❌ +- 유지보수가 매우 어려움 +- 데이터 분포 변경 시 오히려 슬로우 쿼리 유발 +- 쿼리 플래너가 최적화할 자유도 빼앗음 +``` + +--- + +### Q4. 캐싱 정합성 면접 질문 대응 (김요한) + +**질문**: 정합성 중요한 데이터에서 DB/캐시 저장 순서와 장애 시나리오는? + +**멘토의 모범 답변 구성**: + +1. **SOT(Source of Truth) 확정**: + ``` + DB = 신원 (실제 데이터) + 캐시 = 바구니 (읽기 성능 최적화 레이어, 일시적 스냅샷) + ``` + +2. **패턴 선택**: Write-Around + ``` + 1. DB 먼저 업데이트 + 2. 캐시 evict 또는 put + ``` + +3. **장애 케이스 분석**: + ``` + 케이스 1: 캐시 먼저 → DB 전 서버 종료 (❌ 불일치 발생) + 케이스 2: DB 먼저 → 캐시 전 서버 종료 (✅ DB가 SOT이므로 복구 가능) + ``` + +**면접용 모범 답변**: +``` +정합성이 중요하니 DB를 SOT로 두겠습니다. +캐시는 파생 데이터로 고려합니다. + +Write path에서 DB를 먼저 업데이트한 후 +캐시를 evict하거나 새로운 값으로 put합니다. + +DB 저장 후 캐시 업데이트 전 장애 발생 시 +캐시가 일시적으로 stale할 수 있습니다. +이를 완화하기 위해 TTL을 매우 짧게 설정합니다. + +대규모 시스템에서는 Outbox 패턴이나 CDC(Change Data Capture)를 통해 +비동기 갱신 이벤트를 처리할 수도 있습니다. + +금융/결제/재고처럼 정합성이 극도로 중요한 경우 +캐시를 쓰지 않고 동시 조회를 방어하는 방법을 고민합니다 +(이벤트 큐, 주문 대기열 등). +``` + +**참고**: 멘토가 실제 토스 면접에서 유사한 질문을 받았으며, 위와 비슷한 흐름으로 답변했다고 확인함. 면접관이 "Redis 클러스터 모드도 장애나면?" 등 꼬리 질문을 이어갔다고 함. + +--- + +### Q5. 커서 기반 페이징 전환 기준 (김요한) + +**질문**: 실무에서 OFFSET → 커서 기반으로 전환하는 기준? + +**멘토 답변**: + +**기본 원칙**: +``` +베이스는 커서 기반으로 만듦 +(어드민 페이지만 오프셋 고려) +``` + +**전환 판단 기준**: + +| 지표 | 기준 | 액션 | +|------|------|------| +| OFFSET 값 | 10만 초과 | 커서 기반 전환 | +| 페이지 조회 지연 | 100ms 초과 | 무거운 신호 | +| 뒷쪽 페이지 조회 | 많음 | 커서 전환 고려 | + +**실시간 API**: +``` += 무조건 커서 기반 페이징 +(OFFSET은 주기적 데이터 변화에 취약) +``` + +**어드민 기술**: 커서 + 가짜 오프셋 +``` +1. 커서 기반으로 조회 구현 +2. 프론트에서 커서를 저장 +3. 저장된 커서들로 가짜 오프셋 페이지 구현 + (예: 커서 10개 = 10 페이지) + +효과: 어드민은 페이지 표현 가능, 백엔드는 커서 활용 +``` + +--- + +### Q6. 인덱스 읽기/쓰기 트레이드오프 (양권모) + +**질문**: 인덱스가 많으면 쓰기가 느려지는데, 어디까지 감수하나? + +**멘토 답변**: +``` +읽기만 봄 +"조회 빠르면 장땡" +``` + +**전략**: +``` +쓰기가 잦은 컬럼 +→ 별도 테이블로 정규화하여 격리 +→ 트레이드오프 자체를 배제 + +예: 좋아요 수가 자주 바뀜 +→ 상품(product) 테이블에서 분리 +→ product_likes 테이블 생성 +→ 상품 조회 시 join +``` + +--- + +### Q7. 캐시 스탐피드와 Lock TTL 딜레마 (양권모) + +**질문**: Lock TTL이 너무 짧으면 다시 스탐피드, 너무 길면 응답 지연인데? + +**멘토 답변**: +``` +약간의 재발은 허용 (장애 케이스) +``` + +**Lock + Timeout + Retry 패턴**: +``` +1. 캐시 miss 발생 +2. GetLock(key) 시도 + ├─ 성공 + │ └─ DB 조회 → 캐시 set → lock release + └─ 실패 + └─ 일정 시간 대기 후 재시도 (캐시 조회 시도) + +효과: 한 명만 DB로, 나머지는 대기 후 캐시에서 조회 +``` + +**확률적 갱신**: +``` +TTL 만료 전 10% 확률로 DB 조회 → 캐시 갱신 +효과: 갱신이 점진적으로 발생, 스탐피드 완화 +``` + +**Pre-warming (멘토 선호) ⭐**: +``` +주기적으로 뒤에서 캐시를 미리 갱신 + +실제 사례 (멘토 경험): +- 블프(블랙프라이데이) 22시 오픈 전, 1분 전부터 "인간 프리워밍" 실행 +- 사람이 직접 사이트를 미리 스크롤해서 캐시를 채움 (DBA와 함께) +- 시간대별 오픈(10시, 11시, 12시)인 경우 배치로 API를 순서대로 미리 호출 +- 배치 작업: 매일 00시에 미리 갱신 + +효과: 사용자가 접근할 때 이미 캐시 준비됨 +``` + +--- + +### Q8. WHERE 컬럼 5개, 쿼리마다 조합 다를 때 복합인덱스 (양권모) + +**질문**: 모든 조합에 대해 인덱스를 만들 수는 없는데? + +**멘토 답변**: +``` +빈도(Frequency) 기준으로 판단 +인덱스가 불필요한 경우가 생각보다 많음 +``` + +**인덱스 효율성**: +``` +WHERE 조건이 전부 Equal(=) 연산 +→ 인덱스 태우기 최적 (모든 조합이 유효) + +WHERE 조건 중 Range 있음 +→ 순서가 매우 중요 (레인지를 맨 뒤로) +``` + +**인덱스의 진짜 강점**: +``` +ORDER BY (정렬) + +인덱스로 정렬이 되면 별도 filesort 불필요 +EXPLAIN extra에서 "Using filesort" 제거 가능 +``` + +--- + +### Q9. Materialized View 활용 케이스 (오민형) + +**질문**: 복잡한 JOIN을 미리 계산해두면 좋을 때는? + +**멘토 답변**: +``` +JOIN이 많아서 연산 비용이 높은 경우 +→ Pre-computed (미리 연산) + +예: 좋아요순 정렬 +상품 정보 + 좋아요 수 + 리뷰 수 +→ 모두 계산하면 무거움 +``` + +**비정규화 유지 + 배치 갱신**: +``` +product 테이블에 likes_count 컬럼 추가 +product_likes 테이블 변경 감지 → 배치/이벤트로 갱신 + +방법 1: TTL 기반 배치 +- 1분마다 Delta(변화량)만큼 증분 업데이트 + +방법 2: 이벤트 기반 +- 좋아요 발생 시 product.likes_count +=1 + +효과: +- product 조회 쿼리 간단화 (별도 join 불필요) +- 정렬 성능 향상 +``` + +--- + +### Q10. 상품 목록 캐싱 키 조합 문제 (오민형) + +**질문**: 검색 결과를 캐싱하려면 캐시 키를 어떻게 만들어야? + +**멘토 답변**: +``` +정보의 위계에 맞게 캐시 레이어를 나눔 +``` + +**계층형 캐시 구조**: + +``` +Layer 1: 검색 결과 캐시 (상품 ID 배열) +Key: search-result:v1:{brand}:{category}:{price_range}:{page} +Value: [productId1, productId2, ...] +TTL: 30초 (DB 방어, 자주 변함) + +Layer 2: 상품 정보 캐시 (상품 객체) +Key: product:v1:{product_id} +Value: {id, name, price, likes, ...} +TTL: 1시간 (자주 안 바뀜) +``` + +**유저 행동 패턴 최적화**: +``` +데이터: 2페이지 이상 접근이 거의 없음 +최적화: 1페이지만 캐싱 +→ 메모리 절약 + 관리 단순화 +``` + +**캐시 키 설계 예시** (참고: 편집자가 추가한 예시 코드): +```python +# 불충분한 예 (페이지별 모든 결과 캐시) +cache_key = f"search:{brand}:{category}:{page}" + +# 좋은 예 (검색 조건만 캐시) +cache_key = f"search-result:v1:{brand}:{category}:{price_range}:{sort_by}" +# value: 상품 ID 배열만 저장 (상세 정보는 product:v1:{id}에서 조회) +``` + +--- + +### Q11. Redis TTL 설정 기준 (P2) + +**질문**: Redis TTL을 언제 길게, 언제 짧게 가져가야 하나? + +**멘토 답변**: +``` +모니터링보다 도메인 특성/중요도 기준 +``` + +**TTL 설정 예시**: + +| 데이터 | 변경 빈도 | TTL | 이유 | +|--------|----------|-----|------| +| 어드민 메뉴 | 거의 안 바뀜 | 2주 | 안정적 데이터 | +| 상품 정보 | 자주 바뀜 | 30초 | DB 방어 | + +**캐시 효율성 모니터링**: +``` +Hit Rate (캐시 히트율) +- 높음: 캐시가 잘 작동 중 +- 낮음: + ├─ TTL이 너무 짧거나 + └─ 캐시가 원래 필요 없던 것 + +Hit Rate 낮으면 → 캐시 제거 고려 +``` + +--- + +### Q12. 멘토의 캐시 악몽 사례 (P2) + +**상황**: +``` +추천 모델 서버가 트래픽 과부하로 다운 +→ 담당자가 빈 배열 []을 캐시에 적재 +→ 결과: 추천 구좌에 제목만 나오고 상품 없음 +→ 사용자 경험 심각 악화 +``` + +**교훈**: +``` +에러 발생 시 잘못된 데이터를 캐시하지 않도록 처리! + +처리 방법: +1. 에러 시 캐시 저장 방지 (else절에서 return) +2. 또는 빈 배열 대신 명확한 에러 던짐 +3. 폴백 데이터 (fallback)가 있다면 사용 +4. 부분 캐시: 성공한 상품만 캐시, 나머지 스킵 +``` + +**인덱스 관련 에피소드**: +- 슬로우 쿼리가 찍히면 DBA한테 전화가 옴 +- 멘토: "모르는 번호라 보통 안 받는데, DM이 졸라 옴... 보면 내가 배포 승인한 거지 내가 짠 게 아님" +- 실무에서 인덱스/슬로우 쿼리 관리는 일상적으로 발생하는 이슈 + +**방어 코드 예시** (참고: 편집자가 추가한 예시 코드): +```python +def get_recommendations(user_id): + cache_key = f"recommendations:{user_id}" + + # 캐시 조회 + cached = redis.get(cache_key) + if cached: + return cached + + # DB 조회 (에러 처리 중요!) + try: + recommendations = recommendation_service.get(user_id) + if not recommendations: + # 빈 결과는 캐시하지 않음 + return fallback_recommendations + + # 정상 결과만 캐시 + redis.setex(cache_key, 3600, recommendations) + return recommendations + except Exception as e: + # 에러 발생 시 캐시 저장 금지 + logger.error(f"추천 로직 실패: {e}") + return fallback_recommendations +``` + +--- + +### Q13. 멀티테넌트 인덱스 전략 (양권모) + +**질문**: 고객별로 다른 쿼리 패턴이 있으면 인덱스 설계를 어떻게? + +**멘토 답변**: +``` +단일 인덱스 전략으로는 한계 명확 +``` + +**멀티테넌트 인덱스 접근법**: + +``` +Step 1: 공통 쿼리 기준 최소 인덱스 설계 + - 모든 고객사가 사용하는 쿼리 패턴 분석 + - 필수 인덱스 결정 + +Step 2: 고객사 특성에 맞게 선택적 인덱스 추가 + - A 고객사: 자주 category로 필터링 → idx_category + - B 고객사: 자주 price_range로 필터링 → idx_price + +Step 3: 자동화 구축 + - 슬로우 쿼리 분석 자동화 + - 월 1회 인덱스 추천 리포트 생성 + - 사용하지 않는 인덱스 자동 탐지 후 제거 +``` + +--- + +## 4. 이번 과제의 핵심 포인트 (멘토 정리) + +### 4.1 조회의 중요성 + +``` +조회 = 고객과 가장 맞닿아 있는 영역 +조회 성능 향상 = 고객 만족도 향상 +``` + +### 4.2 성능 개선 순서 + +``` +Step 1: 인덱스로 근본적 조회 성능 개선 + (쿼리 플랜 개선, rows 감소) + +Step 2: 그래도 DB 한계 있음 감지 + (Read QPS 높음, DB 응답 지연) + +Step 3: 캐시로 조회 요청 자체를 줄임 + (바구니 이론) + +최종 목표: "더 많은 고객을 받기 위한 준비" +``` + +### 4.3 핵심 메시지 + +``` +인덱스 없음 → "책 찾기를 위해 모든 책장 스캔" +인덱스 있음 → "목차에서 찾아서 바로 접근" +캐시 있음 → "이미 준비된 책자에서 손쉽게 꺼냄" +``` + +--- + +## 5. 멘토의 학습 조언 + +### 5.1 효율적인 학습 방법 + +``` +1. 혼자 10~20분 고민해보기 + → 손으로 코드 짜거나 설계도 그려보기 + +2. 안 되면 병렬로 도움 요청 + → 다른 문제도 함께 학습할 시간 확보 + +3. 학습 시간의 유한성 인식 + → 각 학습 단위를 효율적으로 사용 +``` + +### 5.2 마음가짐 + +``` +"지금 부끄러운 게 나중에 부끄러운 것보다 낫다" + +즉, 지금 모르는 것을 물어보는 것이 +나중에 잘못된 설계로 대규모 리팩토링하는 것보다 +훨씬 낫다는 의미 +``` + +--- + +## 6. 참고: 실무 적용 체크리스트 + +**참고**: 아래 체크리스트는 멘토링 내용을 바탕으로 편집자가 정리한 것입니다. + +### 인덱스 설계 체크리스트 + +- [ ] 실제 SELECT 쿼리 패턴 분석 (추측 아님) +- [ ] 복합 인덱스 순서 결정 (Leftmost prefix 고려) +- [ ] Range 조건 위치 최적화 (맨 뒤로) +- [ ] 커버링 인덱스 기회 발굴 +- [ ] EXPLAIN 분석 (rows, type, extra) +- [ ] 사용하지 않는 인덱스 주기적 제거 +- [ ] 읽기 레플리카 환경에서 인덱스 적극 활용 + +### 캐시 설계 체크리스트 + +- [ ] 캐시 전략 먼저 설계 (코드 아님) +- [ ] Layer 구조 결정 (L1 로컬 + L2 글로벌) +- [ ] Write-Around 패턴 구현 (DB → 캐시) +- [ ] 갱신 전략 명시 (TTL, 즉시 갱신, Pre-warming) +- [ ] 캐시 스탐피드 방어 (Lock, 확률 갱신, Pre-warming) +- [ ] 에러 케이스 처리 (잘못된 데이터 캐싱 금지) +- [ ] 모니터링 설정 (Hit Rate) +- [ ] TTL 설정 기준 문서화 + +--- + +**문서 작성일**: 2026-03-12 +**출처**: 부트캠프 멘토링 Round 5 (2026-03-11) - Alen 멘토 \ No newline at end of file diff --git a/docs/plans/2026-03-12-query-optimization.md b/docs/plans/2026-03-12-query-optimization.md new file mode 100644 index 000000000..ecaa34795 --- /dev/null +++ b/docs/plans/2026-03-12-query-optimization.md @@ -0,0 +1,499 @@ +# 상품 조회 성능 최적화 (Query Optimization) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 상품 목록 조회의 N+1 문제 해결, 복합 인덱스 적용, QueryDSL 동적 쿼리 도입, likes UNIQUE 제약 추가 + +**Architecture:** ProductRepositoryImpl에 QueryDSL 기반 동적 쿼리를 도입하여 brandId 필터 + 정렬을 단일 메서드로 통합. ProductFacade에서 N+1 쿼리를 IN 배치 조회로 교체. Entity에 @Index 어노테이션으로 복합 인덱스 선언. + +**Tech Stack:** Java 21, Spring Boot 3.4.4, QueryDSL 5.x (Jakarta), JPA @Index, MySQL 8.0 + +--- + +## 의사결정 기록 + +| # | 항목 | 결정 | 이유 | +|---|------|------|------| +| 1 | 인덱스 관리 | JPA `@Index` 어노테이션 | ddl-auto:create(local)와 자연스럽게 연동, 별도 마이그레이션 도구 불필요 | +| 2 | likes UNIQUE 제약 | `(user_id, product_id)` 추가 | DB 레벨 중복 방지, soft-delete restore 패턴과 호환 | +| 3 | 대량 데이터 시딩 | SQL 스크립트 (`docs/sql/seed-data.sql`) | 앱 독립적, 빠른 실행 | +| 4 | 동적 쿼리 | QueryDSL | brandId 유무에 따른 동적 WHERE, 향후 필터 확장 유연 | + +## 트레이드오프 분석 + +### 인덱스 설계 +- **비용**: `like_count` 업데이트마다 인덱스 재정렬 → 쓰기 성능 약간 저하 +- **이득**: 10만+ 데이터에서 Full Table Scan → Index Scan으로 조회 성능 대폭 개선 +- **결정**: 좋아요 업데이트 빈도 << 조회 빈도이므로 인덱스 적용이 유리 + +### N+1 해결: IN 배치 조회 vs JOIN +- **IN 배치 조회**: 기존 Entity 구조(brandId long 필드) 변경 없음, 3회 쿼리로 고정 +- **JOIN**: 단일 쿼리지만 Entity 연관관계 매핑 필요 → 기존 설계 변경 범위 큼 +- **결정**: IN 배치 조회 (기존 구조 유지, 외과적 변경) + +### QueryDSL vs JPA 메서드 분리 +- **QueryDSL**: 동적 WHERE 조합 가능, 정렬/필터 조합 증가 시 메서드 폭발 방지 +- **JPA 메서드**: 단순하지만 brandId × sortType × deletedAt 조합마다 메서드 필요 +- **결정**: QueryDSL (이미 의존성 존재, 향후 확장성) + +### UNIQUE 제약 + Soft-Delete +- **문제**: `(user_id, product_id)` UNIQUE인데 deleted_at이 NULL이 아닌 row 존재 시? +- **해결**: 현재 로직은 기존 row를 restore하므로 같은 (user_id, product_id) row가 1개만 존재 → UNIQUE 제약과 호환 +- **주의**: 물리 삭제(hard delete) 없이 soft-delete만 사용하는 한 문제 없음 + +--- + +## 멘토링 기준 적합성 분석 (2026-03-12 검사) + +> 멘토링 문서: `docs/mentoring` (2026-03-11, Alen 멘토) + +### 1. likeCount 테이블 분리 — 멘토 권장 vs 현재 설계 + +**멘토 권장 (Q6):** +> "좋아요 수가 자주 바뀜 → 상품(product) 테이블에서 분리 → product_likes 테이블 생성 → 상품 조회 시 join" +> "쓰기가 잦은 컬럼 → 별도 테이블로 정규화하여 격리 → 트레이드오프 자체를 배제" + +**현재 설계:** `ProductModel.likeCount` 필드로 product 테이블에 내장 + +| 비교 항목 | 분리 (멘토 권장) | 내장 (현재) | +|---|---|---| +| 인덱스 쓰기 부담 | product 인덱스 영향 없음 | 좋아요마다 product 인덱스 전체 갱신 | +| 조회 쿼리 | JOIN 또는 서브쿼리 필요 | 단일 테이블 조회, 정렬 인덱스 직접 활용 | +| 구현 복잡도 | 높음 (테이블 분리 + 동기화) | 낮음 (원자적 UPDATE 쿼리) | +| 정합성 | 배치/이벤트 기반 동기화 시 지연 가능 | 즉시 반영 | +| 적합 규모 | 대규모 (9천만 레코드급) | 중소 규모 | + +**현재 판단:** 프로젝트가 학습/부트캠프 단계이고 데이터 규모가 10만 이하이므로 내장 방식 유지. +단, **인덱스가 추가되면 좋아요 변경 시 인덱스 갱신 비용이 발생**하는 점은 인지. +실무에서 대규모 트래픽 시에는 멘토 권장대로 분리가 필요. + +**개선 시점 신호:** +- product 인덱스 4개 이상 + 좋아요 QPS > 100 → 분리 검토 +- EXPLAIN에서 좋아요 UPDATE 시 인덱스 갱신 지연 감지 시 + +--- + +### 2. 인덱스 설계 — 멘토링 체크리스트 대조 + +| 멘토링 체크리스트 | 현재 적합 여부 | 상세 | +|---|---|---| +| 실제 SELECT 쿼리 패턴 분석 | ✅ 분석 완료 | 4개 주요 조회 패턴 식별 | +| 복합 인덱스 순서 (Leftmost prefix) | ✅ 설계 반영 | `deleted_at` 선행 (WHERE 필수 조건) | +| Range 조건 위치 최적화 | ⚠️ 해당 없음 | 현재 Range 조건 미사용 (Equal + ORDER BY) | +| 커버링 인덱스 기회 | ❌ 미활용 | `SELECT *` 사용 중. 커버링 인덱스 불가 | +| EXPLAIN 분석 | ⏳ 시딩 SQL 준비됨 | `docs/sql/seed-data.sql`로 검증 예정 | +| ORDER BY 인덱스 (filesort 방지) | ✅ 설계 반영 | 정렬 컬럼이 인덱스 마지막에 위치 | + +**커버링 인덱스 미활용 사유:** +멘토 원문 (§1.2): "SELECT * 대신 필요한 컬럼만 명시하면 커버링 활용" +→ 현재 QueryDSL에서 `selectFrom(product)` = `SELECT *`. 목록 조회에서 `name`, `price`, `brandId`, `likeCount`만 필요하지만, + JPA Entity 특성상 부분 select는 DTO Projection이 필요 → 구현 복잡도 증가. +→ **현재 단계에서는 미적용. 성능 병목이 확인되면 DTO Projection + 커버링 인덱스 도입 검토.** + +--- + +### 3. N+1 해결 — 멘토링 관점 + +멘토링에서 N+1을 직접 언급하지는 않지만, Q3 "슬로우 쿼리 해결 판단 기준"에서: +> "EXPLAIN 분석 → 인덱스 설계 → 쿼리 개선 → 데이터 모델 변경 → 캐시 도입" + +N+1은 "쿼리 개선" 단계에 해당. 현재 `ProductFacade.getProducts()`에서: + +``` +AS-IS: 1(상품목록) + N(브랜드) + N(재고) = 2N+1 쿼리 +TO-BE: 1(상품목록) + 1(브랜드 IN) + 1(재고 IN) = 3 쿼리 (고정) +``` + +**멘토의 순서와 부합:** 인덱스(Task 1) → N+1 쿼리 개선(Task 4) → 캐시(별도 브랜치) + +--- + +### 4. findById + deletedAt 이중 체크 패턴 + +**현재 코드 (`ProductRepositoryImpl.findById`):** +```java +return productJpaRepository.findByIdAndDeletedAtIsNull(id); +``` + +**현재 코드 (`ProductService.getProduct`):** +```java +ProductModel product = productRepository.findById(id) + .orElseThrow(...); +if (product.getDeletedAt() != null) { // 이미 findById에서 deletedAt IS NULL 필터링됨 + throw new CoreException(ErrorType.NOT_FOUND, ...); +} +``` + +**문제:** `findById`가 이미 `deletedAtIsNull` 조건을 포함하므로, Service의 `getDeletedAt() != null` 체크는 **도달 불가 코드(dead code)**. +→ `getProduct()`의 deletedAt 체크를 제거하거나, `findById`를 deletedAt 필터 없이 조회하도록 변경 필요. +→ 현재 의미: `getProduct()`는 customer용(삭제 상품 불가), `getProductForAdmin()`은 admin용(삭제 포함). 그러나 둘 다 같은 `findById`(=deletedAtIsNull)를 호출 → **admin도 삭제 상품 조회 불가 버그.** + +**수정 방향 (개발자 확인 필요):** +- Option A: `findById`를 deletedAt 필터 없이 변경 → `getProduct()`에서 deletedAt 체크 유지 +- Option B: `findByIdIncludeDeleted()` 별도 메서드 추가 → admin용으로 사용 + +--- + +## Task 1: ProductModel에 복합 인덱스 추가 + +**Files:** +- Modify: `apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java` +- Test: `apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java` + +**Step 1: ProductModel @Table에 인덱스 선언 추가** + +```java +@Entity +@Table(name = "product", indexes = { + @Index(name = "idx_product_brand_deleted_like", columnList = "brand_id, deleted_at, like_count"), + @Index(name = "idx_product_deleted_like", columnList = "deleted_at, like_count"), + @Index(name = "idx_product_deleted_created", columnList = "deleted_at, created_at"), + @Index(name = "idx_product_deleted_price", columnList = "deleted_at, price") +}) +public class ProductModel extends BaseEntity { +``` + +**Step 2: 기존 단위 테스트 실행하여 깨지지 않는지 확인** + +Run: `./gradlew test --tests "ProductModelTest" -q` +Expected: PASS + +**Step 3: 커밋 대기 (개발자 승인 후)** + +--- + +## Task 2: LikeModel에 UNIQUE 제약 추가 + +**Files:** +- Modify: `apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java` + +**Step 1: LikeModel @Table에 uniqueConstraints 선언** + +```java +@Entity +@Table(name = "likes", uniqueConstraints = { + @UniqueConstraint(name = "uk_likes_user_product", columnNames = {"user_id", "product_id"}) +}) +public class LikeModel extends BaseEntity { +``` + +**Step 2: 기존 Like 관련 테스트 실행** + +Run: `./gradlew test --tests "*Like*" -q` +Expected: PASS + +**Step 3: 커밋 대기** + +--- + +## Task 3: BrandService에 배치 조회 메서드 추가 + +**Files:** +- Modify: `apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java` +- Test: `apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java` (신규 또는 기존 확장) + +**Step 1: 실패하는 테스트 작성** + +```java +@DisplayName("여러 브랜드를 ID 목록으로 일괄 조회한다") +@Test +void getByIds_returnsMapOfBrands() { + // given + BrandModel nike = brandRepository.save(new BrandModel("나이키", "스포츠")); + BrandModel adidas = brandRepository.save(new BrandModel("아디다스", "스포츠")); + List ids = List.of(nike.getId(), adidas.getId()); + + // when + Map result = brandService.getByIds(ids); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(nike.getId()).name().value()).isEqualTo("나이키"); +} +``` + +**Step 2: 테스트 실행하여 실패 확인** + +Run: `./gradlew test --tests "*BrandServiceTest.getByIds*" -q` +Expected: FAIL (메서드 미존재) + +**Step 3: BrandService에 getByIds 구현** + +```java +@Transactional(readOnly = true) +public Map getByIds(List ids) { + return brandRepository.findAllByIdIn(ids) + .stream() + .collect(Collectors.toMap(BrandModel::getId, Function.identity())); +} +``` + +**Step 4: 테스트 실행하여 통과 확인** + +Run: `./gradlew test --tests "*BrandServiceTest.getByIds*" -q` +Expected: PASS + +**Step 5: 커밋 대기** + +--- + +## Task 4: ProductFacade N+1 해결 - 배치 조회 적용 + +**Files:** +- Modify: `apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java` +- Test: `apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java` + +**Step 1: 실패하는 테스트 작성 (배치 조회 검증)** + +```java +@DisplayName("상품 목록 조회 시 Brand/Stock을 배치로 조회한다") +@Test +void getProducts_usesBatchQuery() { + // given + Long brandId = createBrand("나이키"); + createProduct("상품1", 10000, brandId, 100); + createProduct("상품2", 20000, brandId, 50); + + // when + Page result = productFacade.getProducts(null, ProductSortType.CREATED_DESC, PageRequest.of(0, 10)); + + // then + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent().get(0).brandName()).isEqualTo("나이키"); + assertThat(result.getContent().get(0).stockStatus()).isNotNull(); +} +``` + +**Step 2: ProductFacade.getProducts() 리팩토링** + +AS-IS (N+1): +```java +return products.map(product -> { + String brandName = getBrandName(product.brandId()); // N회 + StockModel stock = stockService.getByProductId(product.getId()); // N회 + return ProductDetail.ofCustomer(product, brandName, stock.toStatus()); +}); +``` + +TO-BE (배치): +```java +public Page getProducts(Long brandId, ProductSortType sortType, Pageable pageable) { + Page products = productService.getProducts(brandId, sortType, pageable); + + List brandIds = products.getContent().stream() + .map(ProductModel::getBrandId).distinct().toList(); + List productIds = products.getContent().stream() + .map(ProductModel::getId).toList(); + + Map brandMap = brandService.getByIds(brandIds); + Map stockMap = stockService.getByProductIds(productIds); + + return products.map(product -> { + BrandModel brand = brandMap.get(product.getBrandId()); + String brandName = brand != null ? brand.name().value() : null; + StockModel stock = stockMap.get(product.getId()); + StockStatus status = stock != null ? stock.toStatus() : StockStatus.OUT_OF_STOCK; + return ProductDetail.ofCustomer(product, brandName, status); + }); +} +``` + +**Step 3: getProductsForAdmin()도 동일하게 리팩토링** + +**Step 4: 기존 E2E 테스트 전체 통과 확인** + +Run: `./gradlew test --tests "*ProductV1ApiE2ETest" -q` +Expected: PASS + +**Step 5: 커밋 대기** + +--- + +## Task 5: QueryDSL 동적 쿼리 도입 + +**Files:** +- Modify: `apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java` +- Modify: `apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java` +- Remove (사용하지 않게 되는 메서드): `ProductJpaRepository`의 일부 메서드 +- Test: `apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java` + +**Step 1: 실패하는 테스트 작성 (brandId + LIKES_DESC 조합)** + +```java +@DisplayName("브랜드 필터 + 좋아요 순 정렬이 동시에 동작한다") +@Test +void getProducts_withBrandIdAndLikesSort() { + // given + Long nikeId = createBrand("나이키"); + ProductModel p1 = createProduct("에어맥스", 100000, nikeId, 10); + ProductModel p2 = createProduct("조던", 200000, nikeId, 10); + // p2에 좋아요 3개 부여 + productRepository.incrementLikeCount(p2.getId()); + productRepository.incrementLikeCount(p2.getId()); + productRepository.incrementLikeCount(p2.getId()); + + // when + Page result = productService.getProducts(nikeId, ProductSortType.LIKES_DESC, PageRequest.of(0, 10)); + + // then + assertThat(result.getContent().get(0).getName()).isEqualTo("조던"); + assertThat(result.getContent().get(0).getLikeCount()).isEqualTo(3); +} +``` + +**Step 2: ProductRepository 인터페이스에 통합 메서드 시그니처 확인** + +현재 `findAll(Pageable, ProductSortType)` 존재. brandId 파라미터를 추가: + +```java +Page findAll(Long brandId, Pageable pageable, ProductSortType sortType); +``` + +**Step 3: ProductRepositoryImpl에 QueryDSL 구현** + +```java +import com.querydsl.core.BooleanBuilder; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.loopers.domain.product.QProductModel; + +// 필드 추가 +private final JPAQueryFactory queryFactory; + +@Override +public Page findAll(Long brandId, Pageable pageable, ProductSortType sortType) { + QProductModel product = QProductModel.productModel; + + BooleanBuilder where = new BooleanBuilder(); + where.and(product.deletedAt.isNull()); + if (brandId != null) { + where.and(product.brandId.eq(brandId)); + } + + OrderSpecifier orderSpecifier = toOrderSpecifier(product, sortType); + + List content = queryFactory.selectFrom(product) + .where(where) + .orderBy(orderSpecifier) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory.select(product.count()) + .from(product) + .where(where) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0); +} + +private OrderSpecifier toOrderSpecifier(QProductModel product, ProductSortType sortType) { + return switch (sortType) { + case CREATED_DESC -> product.createdAt.desc(); + case PRICE_ASC -> product.price.value.asc(); + case PRICE_DESC -> product.price.value.desc(); + case LIKES_DESC -> product.likeCount.desc(); + }; +} +``` + +**Step 4: ProductService.getProducts() 시그니처 통합** + +```java +public Page getProducts(Long brandId, ProductSortType sortType, Pageable pageable) { + return productRepository.findAll(brandId, pageable, sortType); +} +``` + +**Step 5: 전체 테스트 통과 확인** + +Run: `./gradlew test --tests "*Product*" -q` +Expected: PASS + +**Step 6: 커밋 대기** + +--- + +## Task 6: 대량 데이터 시딩 SQL 작성 + +**Files:** +- Create: `docs/sql/seed-data.sql` + +**Step 1: 시딩 SQL 작성** + +- 브랜드 100개 +- 상품 100,000개 (브랜드당 1,000개) +- like_count: 0~10,000 랜덤 분포 +- price: 1,000~1,000,000 랜덤 분포 + +```sql +-- 브랜드 시딩 +INSERT INTO brand (name, description, created_at, updated_at) VALUES ... + +-- 상품 대량 시딩 (프로시저 활용) +DELIMITER // +CREATE PROCEDURE seed_products() +BEGIN + DECLARE i INT DEFAULT 1; + WHILE i <= 100000 DO + INSERT INTO product (name, description, price, brand_id, like_count, created_at, updated_at) + VALUES ( + CONCAT('상품_', i), + CONCAT('설명_', i), + FLOOR(1000 + RAND() * 999000), + FLOOR(1 + RAND() * 100), + FLOOR(RAND() * 10000), + NOW(), NOW() + ); + SET i = i + 1; + END WHILE; +END // +DELIMITER ; + +CALL seed_products(); +DROP PROCEDURE seed_products; +``` + +**Step 2: EXPLAIN 분석 쿼리도 함께 작성** + +```sql +-- 인덱스 적용 전/후 비교용 +EXPLAIN ANALYZE SELECT * FROM product +WHERE brand_id = 1 AND deleted_at IS NULL +ORDER BY like_count DESC LIMIT 20; + +EXPLAIN ANALYZE SELECT * FROM product +WHERE deleted_at IS NULL +ORDER BY like_count DESC LIMIT 20; +``` + +**Step 3: 커밋 대기** + +--- + +## Task 7: 전체 통합 검증 + +**Step 1: 전체 테스트 실행** + +Run: `./gradlew test -q` +Expected: 모든 테스트 PASS + +**Step 2: 빌드 확인** + +Run: `./gradlew build -q` +Expected: BUILD SUCCESSFUL + +**Step 3: 커밋 대기** + +--- + +## 실행 순서 요약 + +``` +Task 1: @Index 추가 (ProductModel) +Task 2: UNIQUE 제약 추가 (LikeModel) +Task 3: BrandService 배치 조회 메서드 +Task 4: ProductFacade N+1 해결 +Task 5: QueryDSL 동적 쿼리 도입 +Task 6: 대량 데이터 시딩 SQL +Task 7: 전체 통합 검증 +``` diff --git a/docs/plans/2026-03-12-redis-cache.md b/docs/plans/2026-03-12-redis-cache.md new file mode 100644 index 000000000..69a8ef656 --- /dev/null +++ b/docs/plans/2026-03-12-redis-cache.md @@ -0,0 +1,772 @@ +# Redis Cache 적용 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 상품 상세/목록 API에 Redis 캐시를 적용하여 DB 조회 부하를 줄이고 응답 속도를 개선한다. + +**Architecture:** Spring Cache Abstraction + RedisTemplate 하이브리드 방식. 상품 상세는 `@Cacheable`로 선언적 캐시, 상품 목록은 RedisTemplate으로 복합 키 + 패턴 기반 무효화 처리. Redis 장애 시 DB fallback을 보장하는 CacheErrorHandler 구현. + +**Tech Stack:** Spring Boot 3.4.4, Spring Cache, Redis 7.0 (Lettuce), Jackson JSON Serializer + +--- + +## Trade-off 분석 + +### 결정 1: 캐시 적용 레이어 — Facade vs Service + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| **Facade 레이어 (선택)** | 조합된 최종 결과를 캐싱 → Brand+Stock 조회도 캐시 히트 시 스킵 | 캐시 키에 여러 도메인 정보 포함, Facade 책임 증가 | +| Service 레이어 | 도메인별 독립 캐싱, 세밀한 제어 | 여러 서비스 결과를 조합하는 Facade에서 N+1 캐시 호출 가능 | + +**근거:** 현재 API의 병목은 상품+브랜드+재고를 조합하는 `ProductFacade`에서 발생. Facade 레이어에서 최종 DTO(`ProductDetail`)를 캐싱하면 하위 3개 서비스 호출을 모두 스킵할 수 있다. + +### 결정 2: 직렬화 — GenericJackson2Json vs Jackson2Json + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| **GenericJackson2Json (선택)** | 범용, 캐시마다 타입 설정 불필요 | JSON에 `@class` 타입 정보 포함 → 저장 공간 약간 증가 | +| Jackson2Json\ | 타입별 최적화, 깔끔한 JSON | 캐시마다 별도 설정 필요, 보일러플레이트 | + +**근거:** 캐시 대상이 `ProductDetail`(record)과 `Page` 두 종류. 범용 직렬화기로 충분하며, 타입별 설정의 복잡도가 이점 대비 크다. + +### 결정 3: 목록 캐시 무효화 — 패턴 삭제 vs 전체 flush + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| **패턴 삭제 (선택)** | 영향 범위 최소화, 다른 캐시 유지 | `KEYS` 명령 사용 시 성능 이슈 → `SCAN` 필요 | +| 전체 flush | 단순, 누락 없음 | 무관한 캐시까지 삭제, 캐시 히트율 급감 | + +**근거:** `product:list:*` 패턴으로 목록 캐시만 삭제. `SCAN` 기반으로 구현하여 Redis blocking 방지. + +### 결정 4: 캐시 미스 대응 — CacheErrorHandler vs try-catch + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| **CacheErrorHandler (선택)** | Spring Cache 전체에 일관 적용, 코드 침투 없음 | RedisTemplate 직접 사용 부분은 별도 처리 필요 | +| 개별 try-catch | 세밀한 에러 처리 | 보일러플레이트, 누락 위험 | + +**근거:** `@Cacheable` 사용하는 상품 상세는 CacheErrorHandler로 커버. RedisTemplate 직접 사용하는 상품 목록은 서비스 내 try-catch로 처리. + +--- + +## 멘토링 기준 적합성 분석 (2026-03-12 검사) + +> 멘토링 문서: `docs/mentoring` (2026-03-11, Alen 멘토) + +### 1. Cache-Aside 패턴 — 멘토 강조 사항 + +**멘토 원문 (§2.5):** +> "Cache-Aside만으로도 대부분 충분. 사본이 차 있을 때는 다 캐시에서 조회되므로 많은 방어가 됨" + +| 멘토 기준 | 플랜 설계 | 부합 | +|---|---|---| +| Cache-Aside 패턴 사용 | 상품 상세: `@Cacheable` (= Cache-Aside), 상품 목록: 수동 Cache-Aside | ✅ | +| DB를 SOT(Source of Truth)로 취급 | Write-Around: DB 먼저 → 캐시 evict/put | ✅ | +| 에러 시 잘못된 데이터 캐싱 방지 | `CacheErrorHandler` + try-catch 방어 | ✅ | + +### 2. 갱신 전략 — Evict(하수) vs Put(고수) + +**멘토 원문 (§2.3):** +> "변경 시 캐시 삭제(Evict) - 하수", "변경 시 캐시 덮어쓰기(Put) - 고수 ⭐" + +| 대상 | 현재 전략 | 멘토 권장 | 갭 | +|---|---|---|---| +| 상품 상세 수정 | `@CacheEvict` (evict) | Put(덮어쓰기) 권장 | ⚠️ | +| 상품 삭제 | `@CacheEvict` (evict) | evict OK | ✅ | +| 좋아요 토글 | evict (상세 + 목록) | put 가능하나 복잡 | ⚠️ | + +**evict vs put 트레이드오프:** + +| 비교 | Evict (현재) | Put (멘토 권장) | +|---|---|---| +| 구현 복잡도 | 낮음 | 높음 (수정 후 ProductDetail 재조립 필요) | +| 캐시 미스 | 삭제 후 다음 조회 시 DB 접근 | 없음 (즉시 새 값 적재) | +| 정합성 위험 | 낮음 (항상 DB에서 최신 조회) | 조립 로직 버그 시 캐시-DB 불일치 | +| 적합 상황 | 조회 QPS 낮을 때 | 조회 QPS 높아 miss 1회도 아까울 때 | + +**현재 판단:** 학습 프로젝트에서 Evict로 충분. 멘토도 "Cache-Aside만으로 대부분 충분" 강조. +→ 수정 후 즉시 재조회 패턴이 빈번하면 Put으로 전환 검토. + +### 3. TTL 설정 — 멘토 기준 대조 + +**멘토 원문 (Q11):** +> "모니터링보다 도메인 특성/중요도 기준" + +| 데이터 | 플랜 TTL | 멘토 예시 | 판단 | +|---|---|---|---| +| 상품 상세 | 10분 | "자주 바뀜" → 30초 | ⚠️ 과도할 수 있음 | +| 상품 목록 | 3분 | - | ✅ 적정 | +| 기본값 | 5분 | - | ✅ | + +→ 멘토의 30초는 대규모 트래픽 기준. 부트캠프에서는 변경 빈도 낮아 5~10분도 무방. +→ **Hit Rate 모니터링 후 조정 권장.** + +### 4. 캐시 스탬피드 방어 — 미구현 + +**멘토 원문 (§2.4):** Lock + Timeout + Retry, 확률적 갱신, Pre-warming + +**현재 플랜:** 스탬피드 방어 없음. + +**판단:** 멘토 원문 (§2.5): "트래픽이 더 많은 서비스에서만 추가 고민 필요" +→ 부트캠프에서 스탬피드 발생 가능성 극히 낮음. 미구현 유지. + +### 5. 계층형 캐시 (L1 + L2) — 미적용 + +**멘토 원문 (§2.2):** L1 로컬(짧은 TTL) + L2 글로벌(긴 TTL) 2단계 + +**현재 플랜:** Redis 단일 레이어. + +→ 단일 서버 환경에서 L1의 이점 제한적. 스케일아웃 시 도입 검토. + +### 6. 캐시 키 설계 — 멘토 Q10 대조 + +**멘토 권장:** 검색 결과(ID 배열) + 상품 정보(객체) 2레이어 분리 + +**현재 플랜:** `Page` 통째로 저장 (단일 레이어) + +| 비교 | 멘토 권장 (2레이어) | 현재 (단일) | +|---|---|---| +| 메모리 효율 | 높음 (중복 없음) | 낮음 (동일 상품 중복 저장) | +| 무효화 정밀도 | 상품 1개 → 해당 캐시만 | 상품 1개 → 모든 목록 삭제 | +| 구현 복잡도 | 높음 (2단계 조회) | 낮음 | + +→ 상품 수 적고 TTL 짧아 단일 레이어로 충분. 상품 > 1만 시 전환 검토. + +--- + +## Task 1: RedisCacheManager + CacheConfig 설정 + +**Files:** +- Create: `modules/redis/src/main/java/com/loopers/config/redis/RedisCacheConfig.java` +- Test: `apps/commerce-api/src/test/java/com/loopers/config/RedisCacheConfigTest.java` + +**Step 1: 실패 테스트 작성** + +```java +@SpringBootTest +@Import(RedisTestContainersConfig.class) +class RedisCacheConfigTest { + + @Autowired + private CacheManager cacheManager; + + @Test + void CacheManager_빈이_등록되어야_한다() { + assertThat(cacheManager).isNotNull(); + assertThat(cacheManager).isInstanceOf(RedisCacheManager.class); + } + + @Test + void 기본_TTL이_설정되어야_한다() { + RedisCacheManager redisCacheManager = (RedisCacheManager) cacheManager; + RedisCacheConfiguration config = redisCacheManager.getCacheConfigurations().get("productDetail"); + assertThat(config).isNotNull(); + } +} +``` + +**Step 2: 테스트 실행 → FAIL 확인** + +Run: `./gradlew :apps:commerce-api:test --tests "RedisCacheConfigTest"` +Expected: FAIL — `CacheManager` Bean 없음 + +**Step 3: 최소 구현** + +```java +@Configuration +@EnableCaching +public class RedisCacheConfig { + + @Bean + public CacheManager cacheManager(LettuceConnectionFactory connectionFactory) { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.activateDefaultTyping( + objectMapper.getPolymorphicTypeValidator(), + ObjectMapper.DefaultTyping.NON_FINAL + ); + + GenericJackson2JsonRedisSerializer serializer = + new GenericJackson2JsonRedisSerializer(objectMapper); + + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(serializer)) + .entryTtl(Duration.ofMinutes(5)) + .disableCachingNullValues(); + + RedisCacheConfiguration productDetailConfig = defaultConfig.entryTtl(Duration.ofMinutes(10)); + RedisCacheConfiguration productListConfig = defaultConfig.entryTtl(Duration.ofMinutes(3)); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(defaultConfig) + .withCacheConfiguration("productDetail", productDetailConfig) + .withCacheConfiguration("productList", productListConfig) + .build(); + } +} +``` + +**Step 4: 테스트 실행 → PASS 확인** + +Run: `./gradlew :apps:commerce-api:test --tests "RedisCacheConfigTest"` +Expected: PASS + +--- + +## Task 2: CacheErrorHandler 구현 + +**Files:** +- Create: `modules/redis/src/main/java/com/loopers/config/redis/CustomCacheErrorHandler.java` +- Modify: `modules/redis/src/main/java/com/loopers/config/redis/RedisCacheConfig.java` — `CachingConfigurer` 구현 추가 + +**Step 1: 실패 테스트 작성** + +```java +class CustomCacheErrorHandlerTest { + + private final CustomCacheErrorHandler handler = new CustomCacheErrorHandler(); + + @Test + void 캐시_조회_실패_시_예외를_삼키고_로그만_남긴다() { + // given + RuntimeException exception = new RuntimeException("Redis connection refused"); + + // when & then — 예외가 전파되지 않아야 함 + assertDoesNotThrow(() -> + handler.handleCacheGetError(exception, null, "testKey") + ); + } + + @Test + void 캐시_저장_실패_시_예외를_삼키고_로그만_남긴다() { + RuntimeException exception = new RuntimeException("Redis connection refused"); + + assertDoesNotThrow(() -> + handler.handleCachePutError(exception, null, "testKey", "testValue") + ); + } +} +``` + +**Step 2: 테스트 실행 → FAIL 확인** + +Run: `./gradlew :apps:commerce-api:test --tests "CustomCacheErrorHandlerTest"` + +**Step 3: 최소 구현** + +```java +@Slf4j +public class CustomCacheErrorHandler implements CacheErrorHandler { + + @Override + public void handleCacheGetError(RuntimeException e, Cache cache, Object key) { + log.warn("[Cache GET 실패] key={}, error={}", key, e.getMessage()); + } + + @Override + public void handleCachePutError(RuntimeException e, Cache cache, Object key, Object value) { + log.warn("[Cache PUT 실패] key={}, error={}", key, e.getMessage()); + } + + @Override + public void handleCacheEvictError(RuntimeException e, Cache cache, Object key) { + log.warn("[Cache EVICT 실패] key={}, error={}", key, e.getMessage()); + } + + @Override + public void handleCacheClearError(RuntimeException e, Cache cache) { + log.warn("[Cache CLEAR 실패] error={}", e.getMessage()); + } +} +``` + +`RedisCacheConfig`에 `CachingConfigurer` 구현 추가: + +```java +@Configuration +@EnableCaching +public class RedisCacheConfig implements CachingConfigurer { + // ... 기존 cacheManager 메서드 ... + + @Override + public CacheErrorHandler errorHandler() { + return new CustomCacheErrorHandler(); + } +} +``` + +**Step 4: 테스트 실행 → PASS 확인** + +Run: `./gradlew :apps:commerce-api:test --tests "CustomCacheErrorHandlerTest"` + +--- + +## Task 3: 상품 상세 API — @Cacheable 적용 + +**Files:** +- Modify: `apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java` — `getProduct()` 캐시 적용 +- Test: `apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeCacheTest.java` + +**Step 1: 실패 테스트 작성** + +```java +@SpringBootTest +@Import(RedisTestContainersConfig.class) +class ProductFacadeCacheTest { + + @Autowired private ProductFacade productFacade; + @Autowired private CacheManager cacheManager; + // ... 필요한 의존성 주입 + 데이터 셋업 + + @AfterEach + void tearDown() { + cacheManager.getCache("productDetail").clear(); + } + + @Test + void 상품_상세_조회_시_캐시에_저장된다() { + // given — 상품 데이터 준비 + // when + productFacade.getProduct(productId); + // then + Cache.ValueWrapper cached = cacheManager.getCache("productDetail").get(productId); + assertThat(cached).isNotNull(); + } + + @Test + void 캐시_히트_시_DB를_조회하지_않는다() { + // given — 첫 조회로 캐시 적재 + productFacade.getProduct(productId); + + // when — 두 번째 조회 + ProductDetail result = productFacade.getProduct(productId); + + // then — 결과 정상 + (검증: 쿼리 로그 또는 spy로 서비스 호출 횟수 확인) + assertThat(result).isNotNull(); + } +} +``` + +**Step 2: 테스트 실행 → FAIL 확인** + +Run: `./gradlew :apps:commerce-api:test --tests "ProductFacadeCacheTest"` + +**Step 3: Facade에 @Cacheable 적용** + +```java +@Cacheable(cacheNames = "productDetail", key = "#productId") +@Transactional(readOnly = true) +public ProductDetail getProduct(Long productId) { + // 기존 로직 그대로 +} +``` + +**Step 4: 테스트 실행 → PASS 확인** + +Run: `./gradlew :apps:commerce-api:test --tests "ProductFacadeCacheTest"` + +--- + +## Task 4: 상품 상세 캐시 무효화 — @CacheEvict + +**Files:** +- Modify: `apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java` — `update()`, `delete()` 캐시 무효화 + +**Step 1: 실패 테스트 작성** + +```java +@Test +void 상품_수정_시_상세_캐시가_삭제된다() { + // given — 조회로 캐시 적재 + productFacade.getProduct(productId); + assertThat(cacheManager.getCache("productDetail").get(productId)).isNotNull(); + + // when — 상품 수정 + productFacade.update(productId, "변경된 이름", "변경된 설명", new Money(99999)); + + // then + assertThat(cacheManager.getCache("productDetail").get(productId)).isNull(); +} + +@Test +void 상품_삭제_시_상세_캐시가_삭제된다() { + // given + productFacade.getProduct(productId); + // when + productFacade.delete(productId); + // then + assertThat(cacheManager.getCache("productDetail").get(productId)).isNull(); +} +``` + +**Step 2: 테스트 실행 → FAIL 확인** + +**Step 3: 최소 구현** + +```java +@CacheEvict(cacheNames = "productDetail", key = "#productId") +@Transactional +public ProductDetail update(Long productId, String name, String description, Money price) { + // 기존 로직 +} + +@CacheEvict(cacheNames = "productDetail", key = "#productId") +public void delete(Long productId) { + // 기존 로직 +} +``` + +**Step 4: 테스트 실행 → PASS 확인** + +--- + +## Task 5: 상품 목록 API — RedisTemplate 캐시 적용 + +**Files:** +- Create: `apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java` +- Modify: `apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java` — `getProducts()` 캐시 연동 +- Test: `apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheServiceTest.java` + +**Step 1: 실패 테스트 작성** + +```java +@SpringBootTest +@Import(RedisTestContainersConfig.class) +class ProductCacheServiceTest { + + @Autowired private ProductCacheService productCacheService; + @Autowired private RedisTemplate redisTemplate; + + @AfterEach + void tearDown() { + // SCAN으로 product:list:* 패턴 키 삭제 + } + + @Test + void 목록_캐시_저장_및_조회() { + // given + String key = "product:list:brand:1:sort:LIKES_DESC:page:0:size:20"; + // ... Page 준비 + + // when + productCacheService.putProductList(key, page); + Optional> cached = productCacheService.getProductList(key); + + // then + assertThat(cached).isPresent(); + assertThat(cached.get().getContent()).hasSize(expectedSize); + } + + @Test + void 패턴_기반_목록_캐시_삭제() { + // given — 여러 키 저장 + productCacheService.putProductList("product:list:brand:1:sort:LIKES_DESC:page:0:size:20", page1); + productCacheService.putProductList("product:list:brand:2:sort:PRICE_ASC:page:0:size:20", page2); + + // when + productCacheService.evictProductListAll(); + + // then — 모든 목록 캐시 삭제됨 + assertThat(productCacheService.getProductList("product:list:brand:1:sort:LIKES_DESC:page:0:size:20")).isEmpty(); + assertThat(productCacheService.getProductList("product:list:brand:2:sort:PRICE_ASC:page:0:size:20")).isEmpty(); + } +} +``` + +**Step 2: 테스트 실행 → FAIL 확인** + +**Step 3: 최소 구현** + +```java +@RequiredArgsConstructor +@Component +public class ProductCacheService { + + private static final String LIST_PREFIX = "product:list:"; + private static final Duration LIST_TTL = Duration.ofMinutes(3); + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + public String buildListKey(Long brandId, ProductSortType sortType, int page, int size) { + return LIST_PREFIX + "brand:" + brandId + ":sort:" + sortType + ":page:" + page + ":size:" + size; + } + + public void putProductList(String key, Page page) { + try { + String json = objectMapper.writeValueAsString(page); + redisTemplate.opsForValue().set(key, json, LIST_TTL); + } catch (Exception e) { + log.warn("[목록 캐시 저장 실패] key={}, error={}", key, e.getMessage()); + } + } + + public Optional> getProductList(String key) { + try { + String json = redisTemplate.opsForValue().get(key); + if (json == null) return Optional.empty(); + // 역직렬화 처리 + return Optional.of(/* deserialized page */); + } catch (Exception e) { + log.warn("[목록 캐시 조회 실패] key={}, error={}", key, e.getMessage()); + return Optional.empty(); + } + } + + public void evictProductListAll() { + try { + Set keys = scanKeys(LIST_PREFIX + "*"); + if (!keys.isEmpty()) { + redisTemplate.delete(keys); + } + } catch (Exception e) { + log.warn("[목록 캐시 삭제 실패] error={}", e.getMessage()); + } + } + + private Set scanKeys(String pattern) { + Set keys = new HashSet<>(); + ScanOptions options = ScanOptions.scanOptions().match(pattern).count(100).build(); + try (Cursor cursor = redisTemplate.scan(options)) { + while (cursor.hasNext()) { + keys.add(cursor.next()); + } + } + return keys; + } +} +``` + +**Step 4: Facade에 캐시 서비스 연동** + +```java +// ProductFacade.getProducts() 수정 +@Transactional(readOnly = true) +public Page getProducts(Long brandId, ProductSortType sortType, Pageable pageable) { + String cacheKey = productCacheService.buildListKey(brandId, sortType, pageable.getPageNumber(), pageable.getPageSize()); + + Optional> cached = productCacheService.getProductList(cacheKey); + if (cached.isPresent()) return cached.get(); + + Page result = /* 기존 DB 조회 로직 */; + + productCacheService.putProductList(cacheKey, result); + return result; +} +``` + +**Step 5: 테스트 실행 → PASS 확인** + +--- + +## Task 6: 좋아요 토글 시 캐시 무효화 + +**Files:** +- Modify: `apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java` — 좋아요 변경 시 캐시 무효화 +- Test: 기존 좋아요 테스트에 캐시 무효화 검증 추가 + +**Step 1: 실패 테스트 작성** + +```java +@Test +void 좋아요_등록_시_해당_상품_상세_캐시와_목록_캐시가_삭제된다() { + // given — 상품 조회로 캐시 적재 + productFacade.getProduct(productId); + + // when — 좋아요 + likeTransactionService.doLike(userId, productId); + + // then + assertThat(cacheManager.getCache("productDetail").get(productId)).isNull(); + // 목록 캐시도 비어야 함 +} +``` + +**Step 2: 테스트 실행 → FAIL 확인** + +**Step 3: 최소 구현** + +`LikeTransactionService`에 캐시 무효화 추가: + +```java +@Transactional +public void doLike(Long userId, Long productId) { + // ... 기존 로직 ... + if (result.countChanged()) { + productService.incrementLikeCount(productId); + evictProductCaches(productId); + } +} + +@Transactional +public void doUnlike(Long userId, Long productId) { + // ... 기존 로직 ... + productService.decrementLikeCount(activeLike.get().productId()); + evictProductCaches(productId); +} + +private void evictProductCaches(Long productId) { + cacheManager.getCache("productDetail").evict(productId); + productCacheService.evictProductListAll(); +} +``` + +**Step 4: 테스트 실행 → PASS 확인** + +--- + +## Task 7: 통합 E2E 테스트 + 성능 비교 + +**Files:** +- Modify: `apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java` — 캐시 동작 E2E 검증 +- Create: `.http/cache-test.http` — 수동 성능 비교용 + +**Step 1: E2E 테스트 추가** + +```java +@Test +void 상품_상세_두번_조회_시_캐시에서_응답한다() { + // given — 상품 생성 + // when — 같은 상품 2회 조회 + // then — 두 번 모두 200 OK, 같은 결과 +} + +@Test +void 상품_수정_후_조회하면_변경된_데이터가_반환된다() { + // given — 상품 생성 + 조회(캐시 적재) + // when — 상품 수정 + 재조회 + // then — 변경된 이름/설명이 반환 +} +``` + +**Step 2: .http 파일 작성** + +```http +### 캐시 미스 — 첫 번째 조회 +GET http://localhost:8080/api/v1/products/1 + +### 캐시 히트 — 두 번째 조회 (응답시간 비교) +GET http://localhost:8080/api/v1/products/1 + +### 목록 조회 — 캐시 미스 +GET http://localhost:8080/api/v1/products?brandId=1&sort=LIKES_DESC&page=0&size=20 + +### 목록 조회 — 캐시 히트 +GET http://localhost:8080/api/v1/products?brandId=1&sort=LIKES_DESC&page=0&size=20 +``` + +**Step 3: 전체 테스트 실행** + +Run: `./gradlew :apps:commerce-api:test` +Expected: 기존 테스트 + 캐시 테스트 모두 PASS + +--- + +## 전체 실행 순서 요약 + +| Task | 내용 | 핵심 파일 | 상태 | +|------|------|----------|------| +| 1 | RedisCacheManager + Config | `RedisCacheConfig.java` | ✅ 완료 | +| 2 | CacheErrorHandler | `CustomCacheErrorHandler.java` | ✅ 완료 | +| 3 | 상품 상세 @Cacheable | `ProductFacade.getProduct()` | ✅ 완료 | +| 4 | 상품 상세 @CacheEvict | `ProductFacade.update()/delete()` | ✅ 완료 | +| 5 | 상품 목록 RedisTemplate 캐시 | `ProductListCacheService.java` | ✅ 완료 | +| 6 | 좋아요 토글 캐시 무효화 | `LikeTransactionService.java` | ✅ 완료 | +| 7 | E2E 테스트 + 성능 비교 | E2E 테스트 + `.http/cache-test.http` | ✅ 완료 | + +--- + +## 멘토링 기준 대조 — 현재 트레이드오프 현황 + +> 기준: `docs/mentoring` (Round-5 멘토링, Alen 멘토, 2026-03-11) + +### 현재 구현이 멘토 기준에 부합하는 항목 + +| 멘토 기준 | 현재 구현 | 근거 | +|----------|----------|------| +| 캐시 전략 먼저 설계 (코드 아님) | ✅ research.md → plan.md → 구현 | 멘토: "캐시는 항상 먼저 설계함" (Q1) | +| SOT = DB, 캐시 = 파생 데이터 | ✅ DB 먼저 업데이트 후 캐시 처리 | 멘토: Write-Around 패턴 권장 (Q4) | +| 에러 시 잘못된 데이터 캐싱 금지 | ✅ `CacheErrorHandler` + `disableCachingNullValues` + try-catch | 멘토: 빈 배열 캐싱 사고 사례 (Q12) | +| Redis 장애 시 서비스 지속 | ✅ `CustomCacheErrorHandler`가 예외 삼김 → DB fallback | 멘토: 캐시는 보조, DB가 SOT | +| Cache-Aside 패턴 | ✅ 캐시 미스 → DB 조회 → 캐시 저장 | 멘토: "Cache-Aside만으로도 대부분 충분" (2.5절) | + +### 현재 구현이 멘토 기준에 미달하는 항목 (의도적 트레이드오프) + +#### TO-1. 갱신 전략 — Evict(현재) vs Put(멘토 권장) + +| | Evict (현재) | Put (멘토 권장) | +|--|-------------|----------------| +| **방식** | DB 업데이트 후 캐시 삭제 | DB 업데이트 후 캐시에 새 값 덮어쓰기 | +| **장점** | 단순, 정합성 보장 | DB 재조회 불필요, 캐시 히트율 유지 | +| **단점** | 다음 조회가 DB로 감 (캐시 미스 1회) | 캐시에 넣는 값 구성 로직 필요, 복잡도 증가 | +| **멘토 평가** | "하수" (2.3절) | "고수" (2.3절) | + +**현재 선택 근거**: 상품 수정/삭제 빈도가 조회 대비 극히 낮음. Evict 후 1회 캐시 미스의 비용이 Put 구현 복잡도 대비 낮다고 판단. + +**개선 시점**: 수정 API 호출 직후 조회 급증 패턴이 관측될 때 → `@CachePut`으로 전환. + +#### TO-2. 목록 캐시 구조 — 전체 객체(현재) vs ID 배열 계층 분리(멘토 권장) + +| | 전체 객체 캐싱 (현재) | ID 배열 + 상품 정보 분리 (멘토 권장) | +|--|---------------------|--------------------------------------| +| **방식** | `List` 전체를 JSON으로 저장 | Layer 1: 검색 결과 ID 배열 (TTL 30초), Layer 2: 상품 정보 (TTL 1시간) | +| **장점** | 단순, 한 번의 Redis 호출로 완결 | 메모리 효율, 상품 정보 재사용, 세밀한 TTL | +| **단점** | 중복 저장 (같은 상품이 여러 목록에), 메모리 낭비 | 2번 Redis 호출 (ID 조회 → 상품 조회), 구현 복잡도 높음 | +| **멘토 평가** | 언급 없음 (암묵적 비권장) | "정보의 위계에 맞게 캐시 레이어를 나눔" (Q10) | + +**현재 선택 근거**: 상품 수가 10만 수준, 목록 페이지 크기 20건. 중복 저장의 메모리 비용보다 구현 단순성이 현 단계에서 우선. + +**개선 시점**: 캐시 메모리 사용량이 Redis 70% 초과할 때 → 계층 분리 전환. + +#### TO-3. TTL 설정 — 현재 vs 멘토 기준 + +| 캐시 | 현재 TTL | 멘토 기준 | 차이 | +|------|---------|----------|------| +| 상품 상세 | 10분 | 30초 (자주 바뀜) ~ 1시간 (안 바뀜) | 멘토 기준 "상품 정보는 자주 바뀜 → 30초" (Q11)와 불일치 | +| 상품 목록 | 3분 | 30초 (DB 방어) | 6배 차이 | + +**현재 선택 근거**: 현재 트래픽이 낮은 학습 프로젝트. stale data 허용 범위가 넓어 긴 TTL로 캐시 히트율 극대화. + +**개선 시점**: 실 트래픽 투입 시 도메인 특성에 맞게 TTL 조정. 모니터링(Hit Rate) 기반으로 결정. + +#### TO-4. 캐시 스탬피드 방어 — 미구현 + +| 방어 방법 | 구현 여부 | 멘토 평가 | +|----------|----------|----------| +| Lock + Timeout + Retry | ❌ 미구현 | 기본 방어 (Q7) | +| 확률적 갱신 | ❌ 미구현 | 보완 방어 (Q7) | +| Pre-warming | ❌ 미구현 | 멘토 선호 ⭐ (Q7) | + +**현재 선택 근거**: 멘토 언급 — "Cache-Aside만으로도 대부분 충분" (2.5절). 현재 트래픽 수준에서 스탬피드 발생 가능성 극히 낮음. + +**개선 시점**: Read QPS가 높아져 동시 캐시 미스가 관측될 때 → Lock 패턴 우선 적용. + +#### TO-5. 캐시 히트율 모니터링 — 미구현 + +**멘토 기준**: "Hit Rate 낮으면 → 캐시 제거 고려" (Q11) + +**현재**: 모니터링 없음. 캐시가 실제로 유효한지 데이터 기반 판단 불가. + +**개선 시점**: Prometheus + Grafana 연동 시 `cache.gets`, `cache.puts`, `cache.evictions` 메트릭 노출. + +#### TO-6. 페이지별 캐싱 범위 — 전체 페이지(현재) vs 1페이지만(멘토 권장) + +**멘토**: "2페이지 이상 접근이 거의 없음 → 1페이지만 캐싱" (Q10) + +**현재**: 모든 페이지를 동일하게 캐싱. + +**현재 선택 근거**: 페이지별 접근 패턴 데이터 없음. 데이터 확보 후 결정. + +**개선 시점**: 페이지별 접근 로그 분석 후 2페이지 이상 캐싱 제거 검토. + +### 개선 우선순위 (멘토 기준 중요도) + +| 순위 | 항목 | 이유 | +|------|------|------| +| 1 | TO-1. Evict → Put 전환 | 멘토가 명시적으로 "하수 vs 고수" 구분 | +| 2 | TO-3. TTL 조정 (10분 → 도메인 기반) | 멘토: 도메인 특성/중요도 기준 | +| 3 | TO-2. 목록 캐시 계층 분리 | 멘토가 Q10에서 구체적 구조 제시 | +| 4 | TO-5. 히트율 모니터링 | 멘토: "Hit Rate 낮으면 캐시 제거 고려" | +| 5 | TO-4. 스탬피드 방어 | 멘토: "Cache-Aside만으로도 충분" → 후순위 | +| 6 | TO-6. 1페이지만 캐싱 | 접근 패턴 데이터 필요 → 가장 후순위 | diff --git a/docs/requirements/round5-requirement.md b/docs/requirements/round5-requirement.md new file mode 100644 index 000000000..9d9eda8c3 --- /dev/null +++ b/docs/requirements/round5-requirement.md @@ -0,0 +1,130 @@ +# 📝 Round 5 Quests + +--- + +### 🤖 가정한 설계에 대해 점검하기 + +- **읽기 최적화에 대해** 고민해 보고, 그 구현 의도와 대안들에 대해 빠르게 분석하고 학습하는 데에 AI 를 활용해 봅니다. +- AI 는 제공된 구성과 설계에 대해 리스크 등을 검토하고 분석해 설계 의도를 명확히 하고, Trade-off 에 대해 이해합니다. +- **프롬프트 예시** + + ```markdown + 너는 대규모 트래픽 환경에서 백엔드 시스템 설계를 리뷰하는 시니어 아키텍트다. + 아래는 내가 설계한 구조 및 구현 코드에 대한 내용이다. + + [설계 설명] + - 조회 API 목적: + - 주요 조회 조건: + - 사용한 테이블/데이터: + - 해당 테이블의 인덱스: + - 캐시 적용 여부 및 위치: + - 캐시 키 전략: + - 캐시 TTL 가정: + + 위 내용을 기준으로 다음 관점에서 분석해줘. + + 1. 이 설계가 성립하기 위해 반드시 참이어야 하는 전제 조건은 무엇인가? + 2. 트래픽이 10배 증가했을 때 가장 먼저 병목이 될 지점은 어디인가? + 3. 캐시 적중률이 30% 이하로 떨어졌을 때 발생할 문제는? + 4. 데이터 정합성이 깨질 수 있는 시나리오를 2가지 이상 제시해줘. + 5. 이 설계를 유지하면서 가장 나중까지 미룰 수 있는 개선은 무엇인가? + 6. 반대로, 가장 먼저 손대야 할 위험 요소는 무엇인가? + + ❗주의: + - 구현 코드나 설정 값은 제안하지 마라. + - 구조적 리스크와 사고 관점에서만 답변하라. + ``` + + +--- + +## 💻 Implementation Quest + +> 실제 트래픽에서 자주 발생하는 조회 병목 문제를 해결하는 방법을 실습합니다. +> +> +> 좋아요 수 기반 정렬, 브랜드 필터링, 인기 상품 조회 등에서 성능 저하가 발생할 수 있습니다. +> +> 이를 인덱스, 비정규화, 캐시 등 구조적인 접근으로 해결하는 것이 목표입니다. +> + + + +### 📋 과제 정보 + +아래 세 가지 **성능 개선을 수행**합니다. + +> 모두 수행하는 것이 더 좋습니다. 선택 이유 및 AS-IS, TO-BE 에 대해서는 블로그에 첨부해 주세요. +> + +--- + +**① 상품 목록 조회 성능 개선** + +- 상품 데이터를 10만개 이상 준비합니다 (각 컬럼의 값은 다양하게 분포하도록 합니다 ) +- 브랜드 필터 + 좋아요 순 정렬 기능을 구현하고, **`EXPLAIN`** 분석을 통해 인덱스 최적화를 수행합니다. +- 성능 개선 전후 비교를 포함해 주세요. + +**② 좋아요 수 정렬 구조 개선** + +- **비정규화**(**`like_count`**) 혹은 **MaterializedView** 중 하나를 선택하여 좋아요 수 정렬 성능을 개선합니다. +- 좋아요 등록/취소 시 count 동기화 처리 방식이 누락되어 있다면 이 또한 함께 구현합니다. + +**③ 캐시 적용** + +- 상품 상세 API 및 상품 목록 API에 **Redis 캐시**를 적용합니다. +- TTL 설정, 캐시 키 설계, 무효화 전략 중 하나 이상 포함해 주세요. + +--- + +## ✅ Checklist + +### 🔖 Index + +- [ ] 상품 목록 API에서 brandId 기반 검색, 좋아요 순 정렬 등을 처리했다 +- [ ] 조회 필터, 정렬 조건별 유즈케이스를 분석하여 인덱스를 적용하고 전 후 성능비교를 진행했다 + +### ❤️ Structure + +- [ ] 상품 목록/상세 조회 시 좋아요 수를 조회 및 좋아요 순 정렬이 가능하도록 구조 개선을 진행했다 +- [ ] 좋아요 적용/해제 진행 시 상품 좋아요 수 또한 정상적으로 동기화되도록 진행하였다 + +### ⚡ Cache + +- [ ] Redis 캐시를 적용하고 TTL 또는 무효화 전략을 적용했다 +- [ ] 캐시 미스 상황에서도 서비스가 정상 동작하도록 처리했다. + +--- + +## ✍️ Technical Writing Quest + +> 이번 주에 학습한 내용, 과제 진행을 되돌아보며 +**"내가 어떤 판단을 하고 왜 그렇게 구현했는지"** 를 글로 정리해봅니다. +> +> +> **좋은 블로그 글은 내가 겪은 문제를, 타인도 공감할 수 있게 정리한 글입니다.** +> +> 이 글은 단순 과제가 아니라, **향후 이직에 도움이 될 수 있는 포트폴리오** 가 될 수 있어요. +> + +### 🎯 Feature Suggestions + +- 좋아요 순으로 정렬하자 서버가 하염없이 느려졌다. +- 우리가 책을 읽을 때, 책갈피가 필요한 이유 (Feat. Index) +- 상품 목록에 좋아요 수를 포함하려고 했더니! +- 캐시를 적용했더니 실제 DB 로 가던 호출이 줄어들었다. +- 캐시 전략에서 TTL, 키 설계, 무효화 기준은 어떻게 결정했는가? +- 정합성과 성능 사이에서 어떤 트레이드오프를 선택했는가? \ No newline at end of file diff --git a/docs/sql/seed-data.sql b/docs/sql/seed-data.sql new file mode 100644 index 000000000..f7d758136 --- /dev/null +++ b/docs/sql/seed-data.sql @@ -0,0 +1,209 @@ +-- ============================================================= +-- 대량 데이터 시딩 SQL (MySQL 8.0) +-- 브랜드 100개, 상품 100,000개, 재고 100,000개 +-- ============================================================= + +-- 기존 데이터 정리 +SET FOREIGN_KEY_CHECKS = 0; +TRUNCATE TABLE stock; +TRUNCATE TABLE product; +TRUNCATE TABLE brand; +SET FOREIGN_KEY_CHECKS = 1; + +-- ============================================================= +-- 1. 브랜드 100개 생성 +-- ============================================================= +DELIMITER $$ + +DROP PROCEDURE IF EXISTS seed_brands$$ +CREATE PROCEDURE seed_brands() +BEGIN + DECLARE i INT DEFAULT 1; + DECLARE now_ts DATETIME(6); + SET now_ts = NOW(6); + + WHILE i <= 100 DO + INSERT INTO brand (name, description, created_at, updated_at, deleted_at) + VALUES ( + CONCAT('Brand_', LPAD(i, 3, '0')), + CONCAT('브랜드 ', i, ' 설명'), + now_ts, + now_ts, + NULL + ); + SET i = i + 1; + END WHILE; +END$$ + +DELIMITER ; + +CALL seed_brands(); +DROP PROCEDURE IF EXISTS seed_brands; + +-- ============================================================= +-- 2. 상품 100,000개 생성 (브랜드당 약 1,000개) +-- - price: 1,000 ~ 1,000,000 랜덤 +-- - like_count: 0 ~ 10,000 랜덤 +-- - 배치 INSERT (1,000건씩) +-- ============================================================= +DELIMITER $$ + +DROP PROCEDURE IF EXISTS seed_products$$ +CREATE PROCEDURE seed_products() +BEGIN + DECLARE i INT DEFAULT 1; + DECLARE batch_count INT DEFAULT 0; + DECLARE brand_id_val BIGINT; + DECLARE price_val INT; + DECLARE like_val INT; + DECLARE now_ts DATETIME(6); + DECLARE sql_text LONGTEXT; + + SET now_ts = NOW(6); + SET sql_text = ''; + + WHILE i <= 100000 DO + -- 브랜드 순환 배정: 1~100 + SET brand_id_val = ((i - 1) % 100) + 1; + -- price: 1,000 ~ 1,000,000 + SET price_val = FLOOR(1000 + RAND() * 999001); + -- like_count: 0 ~ 10,000 + SET like_val = FLOOR(RAND() * 10001); + + IF batch_count = 0 THEN + SET sql_text = CONCAT( + 'INSERT INTO product (name, description, price, brand_id, like_count, created_at, updated_at, deleted_at) VALUES ', + '(''', CONCAT('Product_', LPAD(i, 6, '0')), ''', ', + '''', CONCAT('상품 ', i, ' 설명'), ''', ', + price_val, ', ', brand_id_val, ', ', like_val, ', ', + '''', now_ts, ''', ''', now_ts, ''', NULL)' + ); + ELSE + SET sql_text = CONCAT(sql_text, + ', (''', CONCAT('Product_', LPAD(i, 6, '0')), ''', ', + '''', CONCAT('상품 ', i, ' 설명'), ''', ', + price_val, ', ', brand_id_val, ', ', like_val, ', ', + '''', now_ts, ''', ''', now_ts, ''', NULL)' + ); + END IF; + + SET batch_count = batch_count + 1; + + IF batch_count = 1000 OR i = 100000 THEN + SET @dynamic_sql = sql_text; + PREPARE stmt FROM @dynamic_sql; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + SET batch_count = 0; + SET sql_text = ''; + END IF; + + SET i = i + 1; + END WHILE; +END$$ + +DELIMITER ; + +CALL seed_products(); +DROP PROCEDURE IF EXISTS seed_products; + +-- ============================================================= +-- 3. 재고 100,000개 생성 (상품 1:1 대응) +-- - quantity: 0 ~ 500 랜덤 +-- - 배치 INSERT (1,000건씩) +-- ============================================================= +DELIMITER $$ + +DROP PROCEDURE IF EXISTS seed_stocks$$ +CREATE PROCEDURE seed_stocks() +BEGIN + DECLARE i INT DEFAULT 1; + DECLARE batch_count INT DEFAULT 0; + DECLARE qty_val INT; + DECLARE now_ts DATETIME(6); + DECLARE sql_text LONGTEXT; + + SET now_ts = NOW(6); + SET sql_text = ''; + + WHILE i <= 100000 DO + -- quantity: 0 ~ 500 + SET qty_val = FLOOR(RAND() * 501); + + IF batch_count = 0 THEN + SET sql_text = CONCAT( + 'INSERT INTO stock (product_id, quantity, created_at, updated_at, deleted_at) VALUES ', + '(', i, ', ', qty_val, ', ''', now_ts, ''', ''', now_ts, ''', NULL)' + ); + ELSE + SET sql_text = CONCAT(sql_text, + ', (', i, ', ', qty_val, ', ''', now_ts, ''', ''', now_ts, ''', NULL)' + ); + END IF; + + SET batch_count = batch_count + 1; + + IF batch_count = 1000 OR i = 100000 THEN + SET @dynamic_sql = sql_text; + PREPARE stmt FROM @dynamic_sql; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + SET batch_count = 0; + SET sql_text = ''; + END IF; + + SET i = i + 1; + END WHILE; +END$$ + +DELIMITER ; + +CALL seed_stocks(); +DROP PROCEDURE IF EXISTS seed_stocks; + +-- ============================================================= +-- 4. 시딩 결과 확인 +-- ============================================================= +SELECT '브랜드 수' AS label, COUNT(*) AS cnt FROM brand +UNION ALL +SELECT '상품 수', COUNT(*) FROM product +UNION ALL +SELECT '재고 수', COUNT(*) FROM stock; + +SELECT brand_id, COUNT(*) AS product_count +FROM product +GROUP BY brand_id +ORDER BY brand_id +LIMIT 10; + +-- ============================================================= +-- 5. EXPLAIN 분석 쿼리 +-- ============================================================= + +-- 5-1. 특정 브랜드의 인기순 상품 조회 +EXPLAIN ANALYZE +SELECT * FROM product +WHERE brand_id = 1 AND deleted_at IS NULL +ORDER BY like_count DESC +LIMIT 20; + +-- 5-2. 전체 인기순 상품 조회 +EXPLAIN ANALYZE +SELECT * FROM product +WHERE deleted_at IS NULL +ORDER BY like_count DESC +LIMIT 20; + +-- 5-3. 최신 상품 조회 +EXPLAIN ANALYZE +SELECT * FROM product +WHERE deleted_at IS NULL +ORDER BY created_at DESC +LIMIT 20; + +-- 5-4. 최저가 상품 조회 +EXPLAIN ANALYZE +SELECT * FROM product +WHERE deleted_at IS NULL +ORDER BY price ASC +LIMIT 20; diff --git "a/docs/\353\202\230\353\247\214\354\235\230 \353\217\231\354\213\234\354\204\261 \354\240\234\354\226\264 \355\214\220\353\213\250\352\270\260\354\244\200.png" "b/docs/\353\202\230\353\247\214\354\235\230 \353\217\231\354\213\234\354\204\261 \354\240\234\354\226\264 \355\214\220\353\213\250\352\270\260\354\244\200.png" new file mode 100644 index 000000000..e4a9c2922 Binary files /dev/null and "b/docs/\353\202\230\353\247\214\354\235\230 \353\217\231\354\213\234\354\204\261 \354\240\234\354\226\264 \355\214\220\353\213\250\352\270\260\354\244\200.png" differ diff --git a/modules/redis/src/main/java/com/loopers/config/redis/CustomCacheErrorHandler.java b/modules/redis/src/main/java/com/loopers/config/redis/CustomCacheErrorHandler.java new file mode 100644 index 000000000..1e60dc21b --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/config/redis/CustomCacheErrorHandler.java @@ -0,0 +1,29 @@ +package com.loopers.config.redis; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.interceptor.CacheErrorHandler; + +@Slf4j +public class CustomCacheErrorHandler implements CacheErrorHandler { + + @Override + public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) { + log.warn("Cache GET failed - cache: {}, key: {}, error: {}", cache.getName(), key, exception.getMessage()); + } + + @Override + public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) { + log.warn("Cache PUT failed - cache: {}, key: {}, error: {}", cache.getName(), key, exception.getMessage()); + } + + @Override + public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) { + log.warn("Cache EVICT failed - cache: {}, key: {}, error: {}", cache.getName(), key, exception.getMessage()); + } + + @Override + public void handleCacheClearError(RuntimeException exception, Cache cache) { + log.warn("Cache CLEAR failed - cache: {}, error: {}", cache.getName(), exception.getMessage()); + } +} diff --git a/modules/redis/src/main/java/com/loopers/config/redis/RedisCacheConfig.java b/modules/redis/src/main/java/com/loopers/config/redis/RedisCacheConfig.java new file mode 100644 index 000000000..d2025e242 --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/config/redis/RedisCacheConfig.java @@ -0,0 +1,59 @@ +package com.loopers.config.redis; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CachingConfigurer; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.interceptor.CacheErrorHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; +import java.util.Map; + +@Configuration +@EnableCaching +public class RedisCacheConfig implements CachingConfigurer { + + @Bean + public CacheManager cacheManager(LettuceConnectionFactory lettuceConnectionFactory) { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.activateDefaultTyping( + LaissezFaireSubTypeValidator.instance, + ObjectMapper.DefaultTyping.EVERYTHING, + JsonTypeInfo.As.PROPERTY + ); + + GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(objectMapper); + + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(5)) + .disableCachingNullValues() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer)); + + Map cacheConfigurations = Map.of( + "productDetail", defaultConfig.entryTtl(Duration.ofMinutes(10)) + ); + + return RedisCacheManager.builder(lettuceConnectionFactory) + .cacheDefaults(defaultConfig) + .withInitialCacheConfigurations(cacheConfigurations) + .build(); + } + + @Override + public CacheErrorHandler errorHandler() { + return new CustomCacheErrorHandler(); + } +} diff --git a/pr-body.md b/pr-body.md new file mode 100644 index 000000000..31bde36e6 --- /dev/null +++ b/pr-body.md @@ -0,0 +1,363 @@ +## Summary + +**배경**: 커머스 도메인에서 재고 차감, 쿠폰 사용, 좋아요 카운트 등 동시 요청 시 Lost Update, 초과 차감, 중복 사용 등의 정합성 문제가 발생할 수 있습니다. + +**목표**: 각 도메인의 동시성 리스크를 분석하고, 리스크 수준과 경합 특성에 맞는 동시성 제어 전략을 적용하여 데이터 정합성을 보장합니다. + +**결과**: 3개 도메인에 차별화된 동시성 전략을 적용하고, `CountDownLatch + ExecutorService` 기반 동시성 통합 테스트 4종으로 검증을 완료했습니다. 쿠폰 도메인 신규 구현 및 주문-쿠폰 연동도 포함됩니다. + +--- + +## Context & Decision + +### 문제 정의 + +**현재 동작/제약**: 기존 코드에는 동시성 제어가 없어, 동시 요청 시 데이터 정합성이 보장되지 않습니다. + +**문제 (리스크)**: +- **재고**: 10개 남은 상품에 동시 10명이 주문하면 재고가 음수로 떨어질 수 있습니다 (Lost Update → overselling → 주문 취소 → CS 비용) +- **쿠폰**: 동일 쿠폰을 여러 기기에서 동시에 사용하면 할인이 중복 적용됩니다 (매출 손실) +- **좋아요**: 동시 좋아요/취소 시 likeCount가 실제 좋아요 수와 불일치합니다 (데이터 신뢰도 하락) + +**성공 기준**: 모든 동시성 시나리오에서 데이터 정합성이 유지되며, `CountDownLatch + ExecutorService` 기반 통합 테스트 4종으로 검증 가능해야 합니다. + +--- + +### 동시성 제어 전략 선택 기준 + +위 문제들은 대부분 **Read-Modify-Write (R-M-W) 패턴**에서 발생합니다: +1. **Read**: DB에서 현재 값을 읽는다 (예: 재고 = 5) +2. **Modify**: 애플리케이션에서 계산한다 (예: 5 - 1 = 4) +3. **Write**: 계산 결과를 DB에 쓴다 (예: `UPDATE SET quantity = 4`) + +Read와 Write 사이의 **시간 간격(gap)** 동안 다른 스레드가 같은 값을 읽으면, 두 스레드 모두 `quantity = 4`를 쓰게 되어 **하나의 차감이 사라집니다 (Lost Update)**. + +이 gap을 해결하는 전략은 크게 3가지이며, 도메인별 경합 특성에 따라 다른 전략을 선택했습니다: + +| 전략 | 원리 | R-M-W gap 해결 방식 | +|------|------|---------------------| +| 비관적 락 | `SELECT ... FOR UPDATE`로 행을 잠금 | Read 시점부터 다른 스레드 접근 차단 → gap 자체를 직렬화 | +| 낙관적 락 | `@Version`으로 Write 시 충돌 감지 | gap은 허용하되, Write 시점에 충돌을 감지하여 재시도 | +| 원자적 업데이트 | `SET col = col + 1`로 DB가 직접 처리 | R-M을 DB 내부로 이동 → 애플리케이션 레벨 gap 자체가 없음 | + +### 전략 선택 판단 흐름 + +``` +Q1. 충돌 시 비즈니스에 치명적인가? + │ + ├── NO ──→ 원자적 업데이트 / 낙관적 락 + │ 예: 좋아요 (likeCount 불일치는 치명적이지 않음) + │ → 단순 증감이면 원자적 업데이트, R-M-W면 낙관적 락 + 재시도 + │ + └── YES ──→ Q2. 재시도 요청이 필요한가? + │ + ├── YES ──→ 낙관적 락 + 재시도 + │ + └── NO ───→ Q3. 충돌 빈도가 높은가? + │ + ├── NO ──→ 비관적 락 + │ 예: 쿠폰 (1회 사용, 경합 낮음) + │ + └── YES ─→ 비관적 락 + 트랜잭션 최소화 + 예: 재고 (인기 상품 경합 높음) + │ + ▼ + 트랜잭션이 긴가? + ├── YES → 락 점유 구간 최소화 + └── NO ──→ 여러 행을 잠그는가? + ├── YES → 락 순서 고정 (데드락 방지) + │ 예: productId 오름차순 정렬 + └── NO ──→ 적용 완료 +``` + +--- + +### 선택지와 결정 + +#### 1. 재고 차감 — 비관적 락 (PESSIMISTIC_WRITE) + +| 대안 | 장단점 | +|------|--------| +| A: 낙관적 락 (`@Version`) | 충돌 시 재시도 필요, 인기 상품은 재시도 폭주로 성능 저하 | +| B: 원자적 업데이트 (`SET quantity = quantity - 1`) | 재고 부족 검증이 애플리케이션 레벨에서 필요 (금액 계산, 부분 차감 등 복합 로직) | +| **C: 비관적 락 (`SELECT ... FOR UPDATE`)** | **직렬화로 정확성 보장, 대기 시간 발생** | + +**최종 결정: 비관적 락** + +- 재고는 정합성 실패의 비용이 극히 높습니다 (overselling → 주문 취소 → CS 비용 → 신뢰도 하락) +- 재고가 없다고 재시도할 필요 없음 — "모두 성공시키는 것"이 아니라 **"정확한 수량만 성공시키는 것"**이 목적 +- 원자적 업데이트로는 부족합니다: 재고 차감은 단순 `quantity - 1`이 아니라, **재고 부족 검증 → 금액 계산 → 주문 생성**이 하나의 트랜잭션에서 이루어져야 합니다. Read 단계에서 가져온 값으로 비즈니스 판단을 해야 하므로, Read와 Write 사이의 gap을 근본적으로 차단하는 비관적 락이 적합합니다. +- `productId` 오름차순 정렬로 락 획득 순서를 통일하여 **데드락 방지** +- **트레이드오프**: 동시 처리량(throughput)이 직렬화로 제한되지만, 재고 정합성이 더 중요 + +#### 2. 쿠폰 사용 — 비관적 락 + UniqueConstraint + +| 대안 | 장단점 | +|------|--------| +| A: 낙관적 락 (`@Version`) | 1회 사용에 적합하지만, 이미 사용된 쿠폰을 재시도하는 건 무의미 | +| B: 원자적 업데이트 | 쿠폰 사용은 단순 증감이 아님 — 상태 전이(AVAILABLE→USED) + 소유자 검증 + 금액 계산 등 복합 로직 | +| **C: 비관적 락 (`FOR UPDATE`)** | **사용됐으면 거절, 재시도 불필요** | + +**최종 결정: 비관적 락 + UniqueConstraint** + +- 쿠폰 사용은 금액과 직결 — 이중 쿠폰 적용 시 비즈니스 매출에 직접 영향 +- "한 번만 성공하면 되고, 실패한 요청은 재시도할 이유가 없다" → 비관적 락 +- 쿠폰 발급 중복은 `@UniqueConstraint(user_id, coupon_id)` + `DataIntegrityViolationException` 이중 방어 +- **트레이드오프**: 없음 — 쿠폰별 경합이 낮아 성능 영향 미미 + +#### 3. 좋아요 — 원자적 업데이트 (최종) ← 낙관적 락에서 전환 + +**초기 구현: 낙관적 락 (`@Version` + 재시도)** + +처음에는 `ProductModel`에 `@Version`을 두고, `LikeFacade`에서 `ObjectOptimisticLockingFailureException`을 catch하여 최대 10회 재시도하는 방식으로 구현했습니다. + +**문제 발견: `@Version` 스코프 간섭** + +`@Version`은 **엔티티 레벨**이지 **필드 레벨**이 아닙니다. 따라서: +- 좋아요 → version 증가 → 동시에 상품 수정 시 불필요한 충돌 (False Conflict) +- 상품 수정 → version 증가 → 동시에 좋아요 시 불필요한 재시도 폭주 +- 좋아요와 상품 수정은 **서로 무관한 연산**인데 같은 version을 경합 + +| 대안 | 장단점 | +|------|--------| +| A: 낙관적 락 유지 (`@Version`) | `@Version` 스코프가 엔티티 전체 → 상품 수정과 좋아요가 False Conflict | +| B: likeCount 별도 테이블 분리 | `@Version` 간섭 해결, 하지만 테이블 추가 + JOIN 필요 | +| **C: 원자적 업데이트 (`SET like_count = like_count + 1`)** | **JPA 변경 감지 우회 → `@Version` 트리거 안 됨, 재시도 불필요** | + +**최종 결정: 원자적 업데이트** + +- `likeCount` 증감은 순수한 `+1` / `-1` 연산 — R-M-W가 아니라 **단순 증감**이므로 원자적 업데이트가 자연스럽게 들어맞음 +- `@Modifying @Query("UPDATE ... SET like_count = like_count + 1")`로 DB가 직접 처리 → 애플리케이션 레벨 gap 없음 +- JPA 변경 감지를 우회하므로 `@Version`이 트리거되지 않음 → **상품 수정과의 False Conflict 근본적 해결** +- 재시도 로직(`retryOnOptimisticLock`), 트랜잭션 분리(`LikeTransactionService`의 별도 트랜잭션 경계) 등 **복잡도 대폭 감소** +- `@Version` 제거 → 좋아요 전용 version이 아닌 상품 전체 version이었던 문제 해소 +- **트레이드오프**: `@Modifying @Query`는 JPA 1차 캐시와 동기화되지 않으므로, 같은 트랜잭션 내에서 likeCount를 다시 읽으려면 `entityManager.refresh()` 필요. 현재 구조에서는 좋아요 후 즉시 likeCount를 재조회하지 않으므로 문제 없음. + +### 왜 원자적 업데이트를 재고/쿠폰에는 쓰지 않았나? + +| 기준 | 재고/쿠폰 | 좋아요 | +|------|-----------|--------| +| 연산 복잡도 | Read 후 비즈니스 판단 필요 (부족 검증, 소유자 확인, 금액 계산) | 단순 `+1` / `-1` | +| R-M-W 여부 | R-M-W 패턴 (Read 값으로 판단 후 Write) | 순수 증감 (Read 불필요) | +| 원자적 업데이트 적용 가능? | 불가 — 비즈니스 로직이 R과 W 사이에 있음 | **가능** — DB가 직접 처리 가능 | + +### 락 선택 판단 기준 요약 + +| 질문 | 비관적 락 (재고, 쿠폰) | 원자적 업데이트 (좋아요) | +|------|----------------------|------------------------| +| 실패한 요청을 재시도해야 하나? | 거절하면 됨 | 재시도 불필요 (DB가 보장) | +| 모든 요청이 성공해야 하나? | 한정 자원, 일부만 성공 | 전부 정당한 요청, 전부 성공 | +| 실패 시 비즈니스 손실? | 크다 (이중 결제, 초과 판매) | 작다 (좋아요 1초 늦게 반영) | +| Read 후 비즈니스 판단이 필요한가? | 필요 (재고 부족, 쿠폰 상태 확인) | 불필요 (단순 증감) | + +--- + +## Design Overview + +### 변경 범위 + +**영향 받는 도메인**: Brand, Product, Stock, Order, Like, Coupon(신규), CouponIssue(신규) + +**신규 추가**: +- Coupon / CouponIssue 도메인 — 쿠폰 정의 및 발급/사용 관리 +- 동시성 통합 테스트 4종 +- `@LoginMember`, `@AdminUser` 인증 어노테이션 + ArgumentResolver + +**주요 변경**: +- `StockJpaRepository`: `findByProductIdForUpdate()` — 비관적 락 조회 +- `CouponIssueJpaRepository`: `findByIdForUpdate()` — 비관적 락 조회 +- `ProductJpaRepository`: `incrementLikeCount()` / `decrementLikeCount()` — 원자적 업데이트 +- `CouponIssueModel`: `@UniqueConstraint(user_id, coupon_id)` — 중복 발급 방지 + +### 주요 컴포넌트 책임 + +| 컴포넌트 | 책임 | +|----------|------| +| `OrderFacade` | 주문 생성 시 productId 정렬 → 재고 차감(비관적 락) → 쿠폰 사용(비관적 락) → 주문 저장 | +| `LikeFacade` | 좋아요 진입점 — `LikeTransactionService`에 위임 | +| `LikeTransactionService` | 트랜잭션 경계 내에서 좋아요 토글 + 원자적 업데이트 실행 | +| `LikeToggleService` | 좋아요 도메인 의사결정 (신규/복구/멱등) — `LikeResult` 반환 | +| `CouponIssueService` | 쿠폰 발급(중복 방어) + 사용 처리(비관적 락) | +| `StockService` | `getByProductIdForUpdate()` — 비관적 락으로 재고 조회 | + +--- + +## Flow Diagrams + +### 1. 주문 처리 흐름 (재고 + 쿠폰 동시성 제어) + +``` +Client ─── POST /api/v1/orders ───> OrderV1Controller + | + v + OrderFacade.placeOrder() + +-- @Transactional --------------------------------+ + | | + | (1) 상품 조회 + 금액 계산 (락 없음) | + | productId 오름차순 정렬 | + | | + | (2) 재고 차감 (PESSIMISTIC_WRITE) | + | FOR UPDATE로 StockModel 잠금 | + | stock.decrease(quantity) | + | 재고 부족 시 -> 예외 -> 전체 롤백 | + | | + | (3) 쿠폰 검증+사용 (PESSIMISTIC_WRITE) | + | FOR UPDATE로 CouponIssueModel 잠금 | + | validateOwner() -> 소유권 확인 | + | validateUsable() -> 만료/금액 확인 | + | use() -> usedAt 설정 (USED 상태) | + | 실패 시 -> 예외 -> 전체 롤백 | + | | + | (4) 주문 생성 (INSERT) | + | OrderModel(총액, 할인액, 최종액) | + | OrderItemModel(상품 스냅샷) | + | | + | (5) 쿠폰에 orderId 연결 | + | | + +--- 커밋 --- 모든 락 해제 ------------------------+ +``` + +### 2. 재고 동시성 시나리오 (비관적 락) + +``` +재고: 5개 | Thread 1~10 동시 주문 요청 + +Thread 1 --> FOR UPDATE (잠금) --> decrease(1) --> 커밋 (재고: 4) +Thread 2 --> [대기] --------------------------------> decrease(1) --> 커밋 (재고: 3) +Thread 3 --> [대기] --------------------------------> decrease(1) --> 커밋 (재고: 2) +Thread 4 --> [대기] --------------------------------> decrease(1) --> 커밋 (재고: 1) +Thread 5 --> [대기] --------------------------------> decrease(1) --> 커밋 (재고: 0) +Thread 6 --> [대기] --------------------------------> "재고 부족" 예외 X +Thread 7~10 --> 동일하게 실패 X + +결과: 5명 성공, 5명 실패, 재고 = 0 +``` + +### 3. 쿠폰 동시 사용 시나리오 (비관적 락) + +``` +쿠폰 1장 (AVAILABLE) | Thread 1~5 동시 주문 (같은 쿠폰) + +Thread 1 --> FOR UPDATE (잠금) --> isAvailable()=true --> use() --> 커밋 O +Thread 2 --> [대기] -----------------------------------> isAvailable()=false --> 예외 X +Thread 3~5 --> 동일하게 실패 X + +결과: 1명만 성공, 쿠폰 상태 = USED +``` + +### 4. 좋아요 동시성 시나리오 (원자적 업데이트) + +``` +like_count: 0 | Thread 1~10 동시 좋아요 + +Thread 1 --> INSERT like --> UPDATE SET like_count = like_count + 1 --> 성공 (like_count: 1) +Thread 2 --> INSERT like --> UPDATE SET like_count = like_count + 1 --> 성공 (like_count: 2) +Thread 3 --> INSERT like --> UPDATE SET like_count = like_count + 1 --> 성공 (like_count: 3) + ... +Thread 10 --> INSERT like --> UPDATE SET like_count = like_count + 1 --> 성공 (like_count: 10) + +재시도 없음 — DB가 원자적으로 처리 +결과: 10명 전원 성공, like_count = 10 +``` + +### 5. 데드락 방지 (productId 정렬) + +``` +X 정렬 없이: + User A: 상품2 락 -> 상품1 락 요청 (대기) + User B: 상품1 락 -> 상품2 락 요청 (대기) + -> 교착 상태 (Deadlock) + +O productId 오름차순 정렬: + User A: 상품1 락 -> 상품2 락 + User B: 상품1 락 요청 (대기) -> User A 완료 후 -> 상품1 락 -> 상품2 락 + -> 교착 상태 없음 +``` + +### 6. @Version 스코프 문제와 원자적 업데이트 전환 결정 흐름 + +``` +[초기 설계] 좋아요 → ProductModel.incrementLikeCount() → @Version 충돌 감지 → 재시도 + │ + │ 문제 발견 + ▼ +@Version은 엔티티 레벨 + ├── 좋아요 → version++ ──┐ + │ ├── 같은 version 경합 (False Conflict) + └── 상품 수정 → version++ ─┘ + │ + │ 해결 방안 검토 + ▼ +좋아요는 순수 증감 연산인가? + ├── incrementLikeCount(): this.likeCount++ → YES, 단순 +1 + └── decrementLikeCount(): if (> 0) likeCount-- → YES, SQL WHERE 조건으로 표현 가능 + │ + │ 결론 + ▼ +[최종 설계] 좋아요 → @Modifying @Query("SET like_count = like_count + 1") + ├── JPA 변경 감지 우회 → @Version 미트리거 + ├── 재시도 로직 제거 (retryOnOptimisticLock 삭제) + └── @Version 제거 → 상품 수정과의 간섭 근본 해결 +``` + +--- + +## 동시성 테스트 + +| 테스트 | 시나리오 | 기대 결과 | +|--------|---------|-----------| +| `stockDecreasedCorrectlyUnderConcurrency` | 재고 100, 10명 동시 1개 주문 | 10명 성공, 재고 90 | +| `onlyAvailableStockSucceeds` | 재고 5, 10명 동시 1개 주문 | 5명 성공, 재고 0 | +| `couponUsedOnlyOnce` | 쿠폰 1장, 5명 동시 주문 | 1명만 성공 | +| `likeCountAccurateUnderConcurrency` | 10명 동시 좋아요 | 10명 성공, likeCount = 10 | + +--- + +## Checklist + +### Coupon 도메인 +- [x] 쿠폰 소유자 검증 (`validateOwner`) +- [x] 정액/정률 할인 계산 (`CouponType.FIXED/RATE`) +- [x] 발급된 쿠폰 최대 1회 사용 (`use()` -> USED 상태) +- [x] 중복 발급 방지 (`@UniqueConstraint` + `DataIntegrityViolationException`) + +### 주문 +- [x] `@Transactional`로 원자성 보장 (재고+쿠폰+주문 단일 트랜잭션) +- [x] 사용 불가/존재하지 않는 쿠폰 -> 주문 실패 +- [x] 재고 부족 -> 주문 실패 +- [x] 부분 실패 -> 전체 롤백 (`rollsBackOnPartialFailure` 테스트 검증) +- [x] 주문 스냅샷: 할인 전 금액, 할인 금액, 최종 결제 금액 + +### 동시성 +- [x] 좋아요 동시 요청 -> likeCount 정상 반영 (원자적 업데이트) +- [x] 동일 쿠폰 동시 주문 -> 1번만 사용 (비관적 락) +- [x] 동일 상품 동시 주문 -> 재고 정상 차감 (비관적 락) + +### 인증 +- [x] `@LoginMember` — 사용자 API 인증 (Order, Coupon, Like, Member) +- [x] `@AdminUser` — 어드민 API 인증 (Brand, Product, Order, Coupon Admin) + +--- + +## 주요 파일 + +
+동시성 제어 핵심 파일 + +| 파일 | 역할 | +|------|------| +| `StockJpaRepository` | `@Lock(PESSIMISTIC_WRITE)` — 재고 비관적 락 | +| `CouponIssueJpaRepository` | `@Lock(PESSIMISTIC_WRITE)` — 쿠폰 비관적 락 | +| `ProductJpaRepository` | `@Modifying @Query` — 좋아요 원자적 업데이트 | +| `OrderFacade` | 주문 트랜잭션 경계, productId 정렬 데드락 방지 | +| `LikeFacade` | 좋아요 진입점 | +| `LikeTransactionService` | 좋아요 트랜잭션 경계 + 원자적 업데이트 호출 | +| `LikeToggleService` | 좋아요 도메인 의사결정 (LikeResult 반환) | +| `LikeResult` | 좋아요 토글 결과 (newLike + countChanged) | +| `CouponIssueModel` | `@UniqueConstraint` + `use()` 상태 전이 | +| `CouponIssueService` | 쿠폰 발급/사용, `DataIntegrityViolationException` 처리 | +| `ConcurrencyIntegrationTest` | 동시성 통합 테스트 4종 | + +
+ +--- diff --git a/research.md b/research.md new file mode 100644 index 000000000..e47f14a7d --- /dev/null +++ b/research.md @@ -0,0 +1,156 @@ +# Redis Cache Research + +## 1. 현재 프로젝트 Redis 인프라 분석 + +### 1-1. 토폴로지 +- **Master-Replica 구조** (읽기 분산) +- Master: `localhost:6379` (쓰기 + 읽기) +- Replica: `localhost:6380` (읽기 전용, `replicaof master`) +- Lettuce 클라이언트, `ReadFrom.REPLICA_PREFERRED` 기본 설정 + +### 1-2. 현재 구성 (modules/redis) +| 구성요소 | 설명 | +|---------|------| +| `RedisConfig` | Master/Replica 커넥션 팩토리 2개, RedisTemplate 2개(default=replica우선, master전용) | +| `RedisProperties` | `datasource.redis.*` 바인딩 (database, master, replicas) | +| `RedisNodeInfo` | host, port record | +| Serializer | Key/Value 모두 `StringRedisSerializer` | +| TestContainers | 단일 Redis 컨테이너로 테스트 (master=replica 동일 포트) | + +### 1-3. 현재 사용 현황 +- Redis 모듈은 3개 앱 모두에 의존 (`commerce-api`, `commerce-batch`, `commerce-streamer`) +- **현재 캐시 적용된 코드 없음** — `@Cacheable`, `@CacheEvict`, `@CachePut` 미사용 +- `CacheManager` Bean 미등록 상태 + +--- + +## 2. 캐시 적용 대상 분석 + +### 2-1. 상품 상세 API +- **특성**: 단건 조회, 상품 ID 기반, 높은 조회 빈도 +- **캐시 키**: `product:{productId}` +- **TTL**: 5~10분 (상품 정보 변경 빈도 낮음) +- **무효화**: 상품 수정/삭제 시 해당 키 evict + +### 2-2. 상품 목록 API +- **특성**: 다건 조회, 브랜드 필터 + 좋아요 순 정렬, 페이징 +- **캐시 키**: `products:brandId:{brandId}:sort:{sortType}:page:{page}:size:{size}` +- **TTL**: 1~3분 (좋아요 수 변동 반영 필요) +- **무효화**: 좋아요 토글, 상품 등록/수정/삭제 시 관련 키 패턴 삭제 + +--- + +## 3. 캐시 전략 비교 + +### 3-1. Spring Cache Abstraction (`@Cacheable`) +``` +장점: 선언적, 코드 침투 최소, AOP 기반 +단점: 세밀한 제어 어려움, 복잡한 키 전략 한계 +적합: 상품 상세 (단순 키) +``` + +### 3-2. RedisTemplate 직접 사용 +``` +장점: 완전한 제어, 복잡한 키 패턴 삭제 가능, 부분 갱신 +단점: 보일러플레이트 증가, 캐시 로직이 비즈니스에 침투 +적합: 상품 목록 (복합 키, 패턴 기반 무효화) +``` + +### 3-3. 하이브리드 (권장) +- 상품 상세 → `@Cacheable` + `@CacheEvict` +- 상품 목록 → `RedisTemplate` 직접 사용 (패턴 기반 무효화) + +--- + +## 4. 캐시 무효화 전략 + +### 4-1. TTL 기반 (Time-To-Live) +- 가장 단순, 일정 시간 후 자동 만료 +- 정합성 보장 수준: TTL 범위 내 eventual consistency +- **리스크**: TTL 동안 stale data 노출 + +### 4-2. 이벤트 기반 명시적 무효화 +- 데이터 변경 시점에 캐시 삭제 +- 정합성 보장 수준: 높음 (변경 즉시 반영) +- **리스크**: 무효화 누락 시 영구 stale data + +### 4-3. TTL + 이벤트 하이브리드 (권장) +- 변경 시 즉시 evict + TTL을 안전망으로 설정 +- 무효화 누락되어도 TTL 후 자동 갱신 + +--- + +## 5. 캐시 키 설계 + +### 5-1. 네이밍 컨벤션 +``` +{domain}:{identifier} +{domain}:{filter1}:{value1}:{filter2}:{value2} +``` + +### 5-2. 구체적 키 설계안 +| API | 캐시 키 패턴 | TTL | +|-----|-------------|-----| +| 상품 상세 | `product:detail:{productId}` | 10분 | +| 상품 목록 | `product:list:brand:{brandId}:sort:{sortType}:page:{page}:size:{size}` | 3분 | + +### 5-3. 무효화 매핑 +| 이벤트 | 삭제 대상 | +|--------|----------| +| 상품 수정 | `product:detail:{productId}` + `product:list:*` | +| 상품 삭제 | `product:detail:{productId}` + `product:list:*` | +| 좋아요 토글 | `product:detail:{productId}` + `product:list:*` (좋아요순 정렬 캐시) | + +--- + +## 6. 구현 시 고려사항 + +### 6-1. CacheManager 설정 필요 +- 현재 `RedisConfig`에 `RedisCacheManager` Bean 미등록 +- `@Cacheable` 사용을 위해 `RedisCacheManager` + `RedisCacheConfiguration` 추가 필요 +- JSON 직렬화: `GenericJackson2JsonRedisSerializer` 또는 도메인별 커스텀 직렬화 + +### 6-2. 캐시 미스 시 정상 동작 보장 +- Redis 장애 시에도 DB fallback으로 서비스 지속 +- `@Cacheable`의 기본 동작: 캐시 미스 → DB 조회 → 캐시 저장 +- Redis 연결 실패 시 예외 처리: `CacheErrorHandler` 커스텀 구현 고려 + +### 6-3. 직렬화 전략 +| 방식 | 장점 | 단점 | +|------|------|------| +| `StringRedisSerializer` (현재) | 단순, 디버깅 용이 | 객체 저장 불가 | +| `GenericJackson2JsonRedisSerializer` | 범용, 타입 정보 포함 | 저장 공간 큼 | +| `Jackson2JsonRedisSerializer` | 타입별 최적화 | 캐시마다 설정 필요 | + +### 6-4. Master/Replica 읽기 분산과 캐시의 관계 +- 캐시 읽기: `defaultRedisTemplate` (REPLICA_PREFERRED) → replica에서 읽기 +- 캐시 쓰기/삭제: master에서 수행 (replica는 자동 동기화) +- **주의**: replica 동기화 지연(replication lag) 동안 stale 캐시 읽힐 수 있음 + +--- + +## 7. 구현 순서 (안) + +``` +Step 1 → RedisCacheManager Bean 등록 + CacheConfiguration 설정 + 검증: CacheManager Bean 로딩 확인 + +Step 2 → 상품 상세 API 캐시 적용 (@Cacheable / @CacheEvict) + 검증: 캐시 히트/미스 테스트, 수정 시 무효화 테스트 + +Step 3 → 상품 목록 API 캐시 적용 (RedisTemplate 직접 사용) + 검증: 필터/정렬 조합별 캐시 동작, 좋아요 토글 시 무효화 테스트 + +Step 4 → CacheErrorHandler 구현 (Redis 장애 시 graceful fallback) + 검증: Redis 중단 상태에서 API 정상 응답 확인 + +Step 5 → 성능 비교 (캐시 적용 전/후 응답시간 측정) + 검증: 캐시 히트 시 응답시간 단축 확인 +``` + +--- + +## 8. 참고: Round 5 요구사항 체크리스트 + +- [ ] Redis 캐시를 적용하고 TTL 또는 무효화 전략을 적용했다 +- [ ] 캐시 미스 상황에서도 서비스가 정상 동작하도록 처리했다