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 2b00ee835..d169c3ed8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -8,6 +8,7 @@ import com.loopers.domain.product.ProductService; import com.loopers.domain.user.User; import com.loopers.domain.user.UserService; +import com.loopers.infrastructure.product.ProductCacheService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -24,6 +25,7 @@ public class LikeFacade { private final ProductService productService; private final UserService userService; private final BrandService brandService; + private final ProductCacheService productCacheService; @Transactional(readOnly = true) public List getLikedProducts(String loginId, String rawPassword) { @@ -45,6 +47,8 @@ public void addLike(String loginId, String rawPassword, Long productId) { User user = userService.authenticate(loginId, rawPassword); likeService.addLike(user.getId(), productId); productService.increaseLikesCount(productId); + productCacheService.delete(productId); // likes_count 변경 → 상세 캐시 무효화 + productCacheService.deleteListAll(); // likes_desc 정렬 순서 변경 → 목록 캐시 무효화 } @Transactional @@ -52,5 +56,7 @@ public void removeLike(String loginId, String rawPassword, Long productId) { User user = userService.authenticate(loginId, rawPassword); likeService.removeLike(user.getId(), productId); productService.decreaseLikesCount(productId); + productCacheService.delete(productId); // likes_count 변경 → 상세 캐시 무효화 + productCacheService.deleteListAll(); // likes_desc 정렬 순서 변경 → 목록 캐시 무효화 } } 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 d20f2f637..68b56dc29 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 @@ -21,7 +21,7 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import ObjectOptimisticLockingFailureException; +import org.springframework.orm.ObjectOptimisticLockingFailureException; import java.util.ArrayList; import java.util.Comparator; 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 9e62ea2b3..e7a48c042 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 @@ -4,6 +4,7 @@ import com.loopers.domain.brand.BrandService; import com.loopers.domain.product.Product; import com.loopers.domain.product.ProductService; +import com.loopers.infrastructure.product.ProductCacheService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; @@ -16,6 +17,7 @@ import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; @RequiredArgsConstructor @@ -24,24 +26,42 @@ public class ProductFacade { private final ProductService productService; private final BrandService brandService; + private final ProductCacheService productCacheService; @Transactional public ProductInfo createProduct(Long brandId, String name, Integer price, Integer stock, String description, String imageUrl) { Brand brand = brandService.getBrand(brandId); Product product = productService.createProduct(brandId, name, price, stock, description, imageUrl); + productCacheService.deleteListAll(); return ProductInfo.from(product, brand); } @Transactional(readOnly = true) public ProductInfo getProductDetail(Long id) { - Product product = productService.getProduct(id); - Brand brand = brandService.getBrand(product.getBrandId()); - return ProductInfo.from(product, brand); + // 1. 캐시 조회 + return productCacheService.get(id).orElseGet(() -> { + // 2. Cache Miss → DB 조회 + Product product = productService.getProduct(id); + Brand brand = brandService.getBrand(product.getBrandId()); + ProductInfo productInfo = ProductInfo.from(product, brand); + // 3. 캐시 저장 + productCacheService.set(productInfo); + return productInfo; + }); } @Transactional(readOnly = true) public Page getProducts(Long brandId, String sort, Pageable pageable) { + // 1. 캐시 조회 + Optional> cached = productCacheService.getList( + brandId, sort, pageable.getPageNumber(), pageable.getPageSize() + ); + if (cached.isPresent()) { + return cached.get(); + } + + // 2. Cache Miss → DB 조회 Pageable sortedPageable = PageRequest.of( pageable.getPageNumber(), pageable.getPageSize(), resolveSort(sort) ); @@ -51,13 +71,17 @@ public Page getProducts(Long brandId, String sort, Pageable pageabl Map brandMap = brandService.getBrandsByIds(brandIds).stream() .collect(Collectors.toMap(Brand::getId, b -> b)); - return products.map(p -> { + Page result = products.map(p -> { Brand brand = brandMap.get(p.getBrandId()); if (brand == null) { throw new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다."); } return ProductInfo.from(p, brand); }); + + // 3. 캐시 저장 + productCacheService.setList(brandId, sort, result); + return result; } @Transactional @@ -65,12 +89,16 @@ public ProductInfo updateProduct(Long id, String name, Integer price, Integer st String description, String imageUrl) { Product product = productService.updateProduct(id, name, price, stock, description, imageUrl); Brand brand = brandService.getBrand(product.getBrandId()); + productCacheService.delete(id); + productCacheService.deleteListAll(); return ProductInfo.from(product, brand); } @Transactional public void deleteProduct(Long id) { productService.deleteProduct(id); + productCacheService.delete(id); + productCacheService.deleteListAll(); } private Sort resolveSort(String sort) { diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheService.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheService.java new file mode 100644 index 000000000..97c039eaa --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductCacheService.java @@ -0,0 +1,139 @@ +package com.loopers.infrastructure.product; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.application.product.ProductInfo; +import com.loopers.config.redis.RedisConfig; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import org.springframework.data.redis.core.ScanOptions; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Slf4j +@Component +public class ProductCacheService { + + private static final String KEY_PREFIX = "product:detail:"; + private static final Duration TTL = Duration.ofMinutes(10); + + private static final String LIST_KEY_PREFIX = "product:list:"; + private static final Duration LIST_TTL = Duration.ofMinutes(5); + + // 목록 캐시 직렬화용 내부 레코드 + record ProductListCache(List content, long totalElements, int pageNumber, int pageSize) {} + + private final RedisTemplate defaultRedisTemplate; // 읽기 (Replica) + private final RedisTemplate masterRedisTemplate; // 쓰기 (Master) + private final ObjectMapper objectMapper; + + public ProductCacheService( + RedisTemplate defaultRedisTemplate, + @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate masterRedisTemplate, + ObjectMapper objectMapper + ) { + this.defaultRedisTemplate = defaultRedisTemplate; + this.masterRedisTemplate = masterRedisTemplate; + this.objectMapper = objectMapper; + } + + // 캐시 조회 - Redis 장애 시 Optional.empty() 반환하여 DB 폴백 + public Optional get(Long productId) { + try { + String json = defaultRedisTemplate.opsForValue().get(KEY_PREFIX + productId); + if (json == null) { + return Optional.empty(); + } + return Optional.of(objectMapper.readValue(json, ProductInfo.class)); + } catch (Exception e) { + log.warn("캐시 조회 실패 - productId: {}, error: {}", productId, e.getMessage()); + return Optional.empty(); + } + } + + // 캐시 저장 - TTL 10분 + public void set(ProductInfo productInfo) { + try { + String json = objectMapper.writeValueAsString(productInfo); + masterRedisTemplate.opsForValue().set(KEY_PREFIX + productInfo.id(), json, TTL); + } catch (Exception e) { + log.warn("캐시 저장 실패 - productId: {}, error: {}", productInfo.id(), e.getMessage()); + } + } + + // 캐시 무효화 + public void delete(Long productId) { + try { + masterRedisTemplate.delete(KEY_PREFIX + productId); + } catch (Exception e) { + log.warn("캐시 삭제 실패 - productId: {}, error: {}", productId, e.getMessage()); + } + } + + // 목록 캐시 조회 + public Optional> getList(Long brandId, String sort, int pageNumber, int pageSize) { + try { + String json = defaultRedisTemplate.opsForValue().get(listKey(brandId, sort, pageNumber, pageSize)); + if (json == null) { + return Optional.empty(); + } + ProductListCache cache = objectMapper.readValue(json, ProductListCache.class); + Page page = new PageImpl<>( + cache.content(), + PageRequest.of(cache.pageNumber(), cache.pageSize()), + cache.totalElements() + ); + return Optional.of(page); + } catch (Exception e) { + log.warn("목록 캐시 조회 실패 - brandId: {}, sort: {}, error: {}", brandId, sort, e.getMessage()); + return Optional.empty(); + } + } + + // 목록 캐시 저장 + public void setList(Long brandId, String sort, Page page) { + try { + ProductListCache cache = new ProductListCache( + page.getContent(), page.getTotalElements(), + page.getNumber(), page.getSize() + ); + String json = objectMapper.writeValueAsString(cache); + masterRedisTemplate.opsForValue().set( + listKey(brandId, sort, page.getNumber(), page.getSize()), json, LIST_TTL + ); + } catch (Exception e) { + log.warn("목록 캐시 저장 실패 - brandId: {}, sort: {}, error: {}", brandId, sort, e.getMessage()); + } + } + + // 목록 캐시 전체 무효화 (SCAN 기반 - 블로킹 방지) + public void deleteListAll() { + try { + ScanOptions options = ScanOptions.scanOptions() + .match(LIST_KEY_PREFIX + "*") + .count(100) + .build(); + List keys = new ArrayList<>(); + masterRedisTemplate.scan(options).forEachRemaining(keys::add); + if (!keys.isEmpty()) { + masterRedisTemplate.delete(keys); + } + } catch (Exception e) { + log.warn("목록 캐시 전체 삭제 실패: {}", e.getMessage()); + } + } + + private String listKey(Long brandId, String sort, int pageNumber, int pageSize) { + String brand = brandId != null ? String.valueOf(brandId) : "all"; + return LIST_KEY_PREFIX + brand + ":" + sort + ":" + pageNumber + ":" + pageSize; + } +} diff --git a/scripts/add-indexes.sql b/scripts/add-indexes.sql new file mode 100644 index 000000000..3a8256c6c --- /dev/null +++ b/scripts/add-indexes.sql @@ -0,0 +1,29 @@ +-- ============================================================ +-- products 테이블 인덱스 추가 스크립트 +-- 대상 DB : loopers +-- +-- [실행 방법] +-- docker exec -i docker-mysql-1 mysql -uapplication -papplication < scripts/add-indexes.sql +-- +-- [설계 근거] +-- idx_products_brand_likes : 브랜드 필터 + 좋아요 정렬 커버 +-- idx_products_brand_price : 브랜드 필터 + 가격 정렬 커버 +-- idx_products_created_at : 전체 최신순 정렬 커버 +-- idx_products_brand_created : 브랜드 필터 + 최신순 정렬 커버 +-- deleted_at 미포함 이유 : 100% IS NULL → 카디널리티 1 → 인덱스 효과 없음 +-- ============================================================ + +USE loopers; + +-- ------------------------------------------------------------ +-- 인덱스 추가 +-- ------------------------------------------------------------ +CREATE INDEX idx_products_brand_likes ON products (brand_id, likes_count); +CREATE INDEX idx_products_brand_price ON products (brand_id, price); +CREATE INDEX idx_products_created_at ON products (created_at); +CREATE INDEX idx_products_brand_created ON products (brand_id, created_at); + +-- ------------------------------------------------------------ +-- 결과 확인 +-- ------------------------------------------------------------ +SHOW INDEX FROM products; diff --git a/scripts/seed-data.sql b/scripts/seed-data.sql new file mode 100644 index 000000000..504a1108b --- /dev/null +++ b/scripts/seed-data.sql @@ -0,0 +1,85 @@ +-- ============================================================ +-- 상품 목록 조회 성능 개선을 위한 테스트 데이터 적재 스크립트 +-- 대상 DB : loopers +-- +-- [실행 전 필수 조건] +-- 1. Docker 실행: docker compose -f docker/infra-compose.yml up -d +-- 2. 앱 실행: ./gradlew :apps:commerce-api:bootRun +-- → Hibernate ddl-auto: create 가 brands, products, likes 테이블을 생성함 +-- 3. 앱 종료 후 이 스크립트 실행: +-- docker exec -i docker-mysql-1 mysql -uapplication -papplication < scripts/seed-data.sql +-- +-- [주의] 앱을 재시작하면 ddl-auto: create 로 인해 테이블이 초기화됩니다. +-- 데이터를 유지하려면 jpa.yml 의 ddl-auto 를 validate 로 변경 후 재시작하세요. +-- ============================================================ + +USE loopers; + +-- ------------------------------------------------------------ +-- 1. 브랜드 20개 INSERT +-- ------------------------------------------------------------ +INSERT INTO brands (name, description, created_at, updated_at) VALUES + ('Nike', 'Just Do It', NOW(), NOW()), + ('Adidas', 'Impossible is Nothing', NOW(), NOW()), + ('Puma', 'Forever Faster', NOW(), NOW()), + ('New Balance', 'Fearlessly Independent', NOW(), NOW()), + ('Reebok', 'Be More Human', NOW(), NOW()), + ('Under Armour','The Only Way is Through', NOW(), NOW()), + ('Converse', 'Shoes Are Boring. Wear Sneakers', NOW(), NOW()), + ('Vans', 'Off the Wall', NOW(), NOW()), + ('FILA', 'For the love of sport', NOW(), NOW()), + ('Asics', 'Sound Mind Sound Body', NOW(), NOW()), + ('Saucony', 'Run Your World', NOW(), NOW()), + ('Brooks', 'Run Happy', NOW(), NOW()), + ('Mizuno', 'For the Love of Sport', NOW(), NOW()), + ('Salomon', 'Time to Play', NOW(), NOW()), + ('Columbia', 'Tested Tough', NOW(), NOW()), + ('Patagonia', 'We are in Business to Save Our Home Planet', NOW(), NOW()), + ('North Face', 'Never Stop Exploring', NOW(), NOW()), + ('Lululemon', 'Elevate the World from Mediocrity', NOW(), NOW()), + ('Champion', 'It Takes a Little More', NOW(), NOW()), + ('Lacoste', 'A Little Green Crocodile', NOW(), NOW()); + +-- ------------------------------------------------------------ +-- 2. 상품 100,000개 INSERT (Recursive CTE - 빠른 방식) +-- brand_id : 1~20 랜덤 분포 +-- price : 1,000 ~ 500,000 (1,000원 단위) +-- stock : 0 ~ 1,000 +-- likes_count: 0 ~ 10,000 (롱테일 분포 - 대부분 낮고 소수만 높음) +-- created_at : 최근 1년 내 랜덤 날짜 +-- ------------------------------------------------------------ +SET cte_max_recursion_depth = 100000; + +INSERT INTO products + (brand_id, name, price, stock, description, image_url, + version, likes_count, created_at, updated_at, deleted_at) +WITH RECURSIVE nums AS ( + SELECT 1 AS n + UNION ALL + SELECT n + 1 FROM nums WHERE n < 100000 +) +SELECT + FLOOR(1 + RAND() * 20), + CONCAT('상품_', LPAD(n, 6, '0')), + FLOOR(1 + RAND() * 500) * 1000, + FLOOR(RAND() * 1001), + CONCAT('상품 ', n, '번의 상세 설명입니다.'), + CONCAT('https://cdn.example.com/products/', n, '.jpg'), + 0, + FLOOR(POW(RAND(), 3) * 10001), + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 365) DAY), + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 365) DAY), + NULL +FROM nums; + +-- ------------------------------------------------------------ +-- 3. 결과 확인 +-- ------------------------------------------------------------ +SELECT + COUNT(*) AS total_products, + COUNT(DISTINCT brand_id) AS brand_count, + MIN(price) AS min_price, + MAX(price) AS max_price, + ROUND(AVG(likes_count), 1) AS avg_likes, + MAX(likes_count) AS max_likes +FROM products;