diff --git a/.claude/commands/create-log.md b/.claude/commands/create-log.md new file mode 100644 index 000000000..534f91fab --- /dev/null +++ b/.claude/commands/create-log.md @@ -0,0 +1,56 @@ +너는 이 프로젝트의 테크니컬 라이터이자 시니어 엔지니어야. +아래 지침을 반드시 지켜서 Markdown 문서를 생성해줘. + +# 목적 +- 내가 고민했어야 할 지점 +- 실제로 선택한 설계와 그 이유 +- 아직 열려 있는 의사결정과 리스크를 정리한 문서를 만든다. + +# 입력 정보 +- 새로 추가한 파일들의 구조를 살펴보고 +- "의도된 설계"와 "암묵적 결정"을 추론한다 + +# 출력 형식 +- 파일명: .docs/log/week-log.md +- Markdown 형식 +- 실무 문서처럼 간결하지만 맥락은 충분히 포함 + +# 문서 구조 (고정) + +## 1. 핵심 설계 결정 (Decision Records) +각 항목은 아래 형식을 따른다: + +### [결정 주제] +- **선택한 방식** +- **대안** +- **왜 이 선택을 했는지** +- **트레이드오프** +- **이 결정이 미치는 영향** + +## 2. 아키텍처 관점의 고민 포인트 +- 의존성 방향 +- 확장/변경 시 취약한 지점 + +## 3. 테스트와 품질 관련 판단 +- 테스트 전략 (있다면) +- 테스트가 부족하거나 생략된 이유 +- 이후 보완이 필요한 부분 + +## 4. 아직 남아 있는 질문들 +- 명확히 결론 내리지 못한 부분 +- 향후 다시 검토해야 할 결정들 + +## 5. 다음 단계 제안 +- 기술 부채 +- 리팩터링 후보 +- 구조 개선 아이디어 + +# 작성 규칙 +- 추측은 “추론됨”이라고 명시 +- 코드에 근거한 내용만 작성 +- 불필요한 미사여구 금지 +- 체크리스트가 필요한 부분은 체크박스로 작성 + +# 실행 +- 실제 파일을 생성한다 +- 기존 파일이 있다면 덮어쓰지 말고 개선 제안만 한다 diff --git a/.claude/commands/create-pr.md b/.claude/commands/create-pr.md index 00b1102be..7a090591b 100644 --- a/.claude/commands/create-pr.md +++ b/.claude/commands/create-pr.md @@ -1,6 +1,8 @@ 현재 브랜치의 변경사항을 분석하여 `.github/pull_request_template.md` 양식에 맞는 PR을 자동 생성한다. ## 수행 절차 +### 0단계: 작업 내역 커밋 +- 작업 내역을 내용별로 여러 단계에 나눠서 커밋 ### 1단계: 변경사항 분석 아래 명령어를 **병렬로** 실행하여 정보를 수집한다: diff --git a/.claude/skills/analyze-query/SKILL.md b/.claude/skills/analyze-query/SKILL.md index e69de29bb..30245df19 100644 --- a/.claude/skills/analyze-query/SKILL.md +++ b/.claude/skills/analyze-query/SKILL.md @@ -0,0 +1,96 @@ +--- +name: analyze-query +description: + 대상이 되는 코드 범위를 탐색하고, Spring @Transactional, JPA, QueryDSL 기반의 코드에 대해 트랜잭션 범위, 영속성 컨텍스트, 쿼리 실행 시점 관점에서 분석한다. + + 특히 다음을 중점적으로 점검한다. + - 트랜잭션이 불필요하게 크게 잡혀 있지는 않은지 + - 조회/쓰기 로직이 하나의 트랜잭션에 혼합되어 있지는 않은지 + - JPA의 지연 로딩, flush 타이밍, 변경 감지로 인해 + 의도치 않은 쿼리 또는 락이 발생할 가능성은 없는지 + + 단순한 정답 제시가 아니라, 현재 구조의 의도와 trade-off를 드러내고 개선 가능 지점을 선택적으로 판단할 수 있도록 돕는다. +--- + +### 📌 Analysis Scope +이 스킬은 아래 대상에 대해 분석한다. +- @Transactional 이 선언된 클래스 / 메서드 +- Service / Facade / Application Layer 코드 +- JPA Entity, Repository, QueryDSL 사용 코드 +- 하나의 유즈케이스(요청 흐름) 단위 +> 컨트롤러 → 서비스 → 레포지토리 전체 흐름을 기준으로 분석하며 특정 메서드만 떼어내어 판단하지 않는다. + +### 🔍 Analysis Checklist +#### 1. Transaction Boundary 분석 +다음을 순서대로 확인한다. +- 트랜잭션 시작 지점은 어디인가? + - Service / Facade / 그 외 계층? +- 트랜잭션이 실제로 필요한 작업은 무엇인가? + - 상태 변경 (쓰기) + - 단순 조회 +- 트랜잭션 내부에서 수행되는 작업 나열 + - 외부 API 호출 + - 복잡한 조회(QueryDSL) + - 반복문 기반 처리 + +**출력 예시** +```markdown +- 현재 트랜잭션 범위: +OrderFacade.placeOrder() + ├─ 유저 검증 + ├─ 상품 조회 + ├─ 주문 생성 + ├─ 결제 요청 + └─ 재고 차감 + +- 트랜잭션이 필요한 핵심 작업: +- 주문 생성 +- 재고 차감 +``` + +#### 2. 불필요하게 큰 트랜잭션 식별 +아래 패턴이 존재하는지 점검한다. +- Controller 에서 Transactional 이 사용되고 있음 +- 읽기 전용 로직이 쓰기 트랜잭션에 포함됨 +- 외부 시스템 호출이 트랜잭션 내부에 포함됨 +- 트랜잭션 내부에서 대량 조회 / 복잡한 QueryDSL 실행 +- 상태 변경 이후에도 트랜잭션이 길게 유지됨 + +**문제 후보 예시** +- 결제 API 호출이 트랜잭션 내부에 포함되어 있음 +- 주문 생성 이후 추천 상품 조회 로직까지 동일 트랜잭션에 포함됨 + +#### 3. JPA / 영속성 컨텍스트 관점 분석 +다음을 중심으로 분석한다. +- Entity 변경이 언제 flush 되는지 +- 조회용 Entity가 변경 감지 대상이 되는지 +- 지연 로딩으로 인해 트랜잭션 후반에 쿼리가 발생할 가능성 +- @Transactional(readOnly = true) 미적용 여부 + +**체크리스트 예시** +```markdown +- 단순 조회인데 Entity 반환 후 변경 가능성 존재? +- DTO Projection 대신 Entity 조회 사용 여부 +- QueryDSL 조회 결과가 영속성 컨텍스트에 포함되는지 +``` + +#### 4. Improvement Proposal (선택적 제안) +개선안은 강제하지 않고 선택지로 제시한다. +- 트랜잭션 분리 + - 조회 → 쓰기 분리 + - Facade에서 orchestration, Service는 최소 트랜잭션 +- `@Transactional(readOnly = true)` 적용 +- DTO Projection (읽기 전용 모델) 도입 +- 외부 호출 / 이벤트 발행을 트랜잭션 외부로 이동 +- Application Service / Domain Service 책임 재조정 + +**개선안 예시** +```markdown +[개선안 1] +- 주문 생성과 결제 요청을 분리 +- 주문 생성까지만 트랜잭션 유지 +- 결제 요청은 트랜잭션 종료 후 수행 + +[고려 사항] +- 결제 실패 시 주문 상태 관리 필요 +- 보상 트랜잭션 또는 상태 전이 설계 필요 \ No newline at end of file diff --git a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java index 9027b51bf..f5e86c960 100644 --- a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java +++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java @@ -4,8 +4,11 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.ConfigurationPropertiesScan; +import org.springframework.scheduling.annotation.EnableAsync; + import java.util.TimeZone; +@EnableAsync @ConfigurationPropertiesScan @SpringBootApplication public class CommerceApiApplication { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java index dcf340db3..4d45b8daf 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeApplicationService.java @@ -2,27 +2,36 @@ import com.loopers.domain.like.Like; import com.loopers.domain.like.LikeDomainService; +import com.loopers.domain.like.LikeRepository; import com.loopers.domain.product.ProductRepository; +import com.loopers.infrastructure.cache.LikeCountCacheService; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; /** * 좋아요 Application Service. * 여러 BC 조합 및 트랜잭션 경계 담당. */ +@Slf4j @Service @RequiredArgsConstructor public class LikeApplicationService { private final LikeDomainService likeDomainService; private final ProductRepository productRepository; + private final LikeRepository likeRepository; + private final LikeCountCacheService likeCountCacheService; /** * 좋아요 등록. * 상품 존재 여부 검증 후 도메인 서비스 호출. + * 트랜잭션 커밋 후 Redis 카운터 증가. * * @param userId 사용자 ID * @param productId 상품 ID @@ -32,23 +41,64 @@ public class LikeApplicationService { public LikeResult like(Long userId, Long productId) { validateProductExists(productId); Like like = likeDomainService.like(userId, productId); + + registerAfterCommit(() -> incrementLikeCount(productId)); + return LikeResult.from(like); } /** * 좋아요 취소. * 멱등하게 동작 - 존재하지 않아도 예외 없이 처리. + * 트랜잭션 커밋 후 Redis 카운터 감소. * * @param userId 사용자 ID * @param productId 상품 ID */ @Transactional public void unlike(Long userId, Long productId) { - likeDomainService.unlike(userId, productId); + boolean existed = likeDomainService.unlike(userId, productId); + + if (existed) { + registerAfterCommit(() -> decrementLikeCount(productId)); + } } private void validateProductExists(Long productId) { productRepository.findByIdActive(productId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다.")); } + + private void incrementLikeCount(Long productId) { + try { + Long count = likeCountCacheService.increment(productId); + log.debug("좋아요 카운터 증가: productId={}, count={}", productId, count); + } catch (Exception e) { + log.warn("좋아요 카운터 증가 실패 (무시): productId={}", productId, e); + } + } + + private void decrementLikeCount(Long productId) { + try { + Long count = likeCountCacheService.decrement(productId); + log.debug("좋아요 카운터 감소: productId={}, count={}", productId, count); + } catch (Exception e) { + log.warn("좋아요 카운터 감소 실패 (무시): productId={}", productId, e); + } + } + + private void registerAfterCommit(Runnable action) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + action.run(); + } + } + ); + } else { + action.run(); + } + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductResult.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductResult.java index 3c5a22006..bc13b4e34 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductResult.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductResult.java @@ -12,10 +12,11 @@ public record ProductResult( Long price, Integer stock, String imageUrl, + Long likeCount, ZonedDateTime createdAt, ZonedDateTime updatedAt ) { - public static ProductResult from(Product product) { + public static ProductResult from(Product product, Long likeCount) { return new ProductResult( product.getId(), product.getBrandId(), @@ -24,8 +25,13 @@ public static ProductResult from(Product product) { product.getPrice().amount(), product.getStock().quantity(), product.getImageUrl(), + likeCount != null ? likeCount : 0L, product.getCreatedAt(), product.getUpdatedAt() ); } + + public static ProductResult from(Product product) { + return from(product, 0L); + } } 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 d91ed0c0f..bcd0a30a4 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 @@ -4,31 +4,37 @@ import com.loopers.domain.product.ProductDomainService; import com.loopers.domain.product.ProductInfo; import com.loopers.domain.product.ProductSort; +import com.loopers.infrastructure.cache.ProductLikeCountQueryService; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Map; @Service @RequiredArgsConstructor public class ProductService { private final ProductDomainService productDomainService; + private final ProductLikeCountQueryService likeCountQueryService; @Transactional(readOnly = true) public ProductResult findById(Long id) { Product product = productDomainService.findById(id); - return ProductResult.from(product); + long likeCount = likeCountQueryService.getLikeCount(id); + return ProductResult.from(product, likeCount); } @Transactional(readOnly = true) - public Page findAll(Long brandId, Pageable pageable) { - // 기본 정렬은 LATEST - ProductSort sort = ProductSort.LATEST; + public Page findAll(Long brandId, ProductSort sort, Pageable pageable) { + if (sort == null) { + sort = ProductSort.LATEST; + } int offset = (int) pageable.getOffset(); int limit = pageable.getPageSize(); @@ -44,27 +50,59 @@ public Page findAll(Long brandId, Pageable pageable) { total = productDomainService.countAll(); } + List productIds = products.stream() + .map(Product::getId) + .toList(); + + Map likeCounts = likeCountQueryService.getLikeCounts(productIds); + List results = products.stream() - .map(ProductResult::from) + .map(p -> ProductResult.from(p, likeCounts.getOrDefault(p.getId(), 0L))) .toList(); return new PageImpl<>(results, pageable, total); } + @Transactional(readOnly = true) + public Page findAll(Long brandId, Pageable pageable) { + ProductSort sort = extractSort(pageable); + return findAll(brandId, sort, pageable); + } + @Transactional public ProductResult create(ProductInfo info) { Product product = productDomainService.create(info); - return ProductResult.from(product); + return ProductResult.from(product, 0L); } @Transactional public ProductResult update(Long id, ProductInfo info) { Product product = productDomainService.update(id, info); - return ProductResult.from(product); + long likeCount = likeCountQueryService.getLikeCount(id); + return ProductResult.from(product, likeCount); } @Transactional public void delete(Long id) { productDomainService.delete(id); } + + private ProductSort extractSort(Pageable pageable) { + Sort sort = pageable.getSort(); + if (sort.isUnsorted()) { + return ProductSort.LATEST; + } + + for (Sort.Order order : sort) { + String property = order.getProperty(); + if ("likeCount".equalsIgnoreCase(property) || "like_count".equalsIgnoreCase(property)) { + return ProductSort.LIKES_DESC; + } + if ("price".equalsIgnoreCase(property) && order.isAscending()) { + return ProductSort.PRICE_ASC; + } + } + + return ProductSort.LATEST; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDomainService.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDomainService.java index 6bac34be1..5f8078643 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDomainService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeDomainService.java @@ -41,10 +41,15 @@ public Like like(Long userId, Long productId) { * * @param userId 사용자 ID * @param productId 상품 ID + * @return 삭제 여부 (존재했으면 true) */ - public void unlike(Long userId, Long productId) { - likeRepository.findByUserIdAndProductId(userId, productId) - .ifPresent(likeRepository::delete); + public boolean unlike(Long userId, Long productId) { + return likeRepository.findByUserIdAndProductId(userId, productId) + .map(like -> { + likeRepository.delete(like); + return true; + }) + .orElse(false); } /** diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/DistributedLockService.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/DistributedLockService.java new file mode 100644 index 000000000..e1e29865f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/DistributedLockService.java @@ -0,0 +1,156 @@ +package com.loopers.infrastructure.cache; + +import com.loopers.config.redis.RedisConfig; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Supplier; + +/** + * Redis 기반 분산 락 서비스. + * SETNX + TTL 기반으로 락을 구현. + */ +@Slf4j +@Service +public class DistributedLockService { + + private static final String LOCK_KEY_PREFIX = "lock:"; + + private static final String UNLOCK_SCRIPT = """ + if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('del', KEYS[1]) + else + return 0 + end + """; + + private final RedisTemplate redisTemplate; + private final DefaultRedisScript unlockScript; + + public DistributedLockService( + @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate redisTemplate + ) { + this.redisTemplate = redisTemplate; + this.unlockScript = new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class); + } + + /** + * 락 획득 시도. + * + * @param key 락 키 + * @param ttl 락 TTL + * @return 락 값 (획득 성공 시), 빈 Optional (획득 실패 시) + */ + public Optional tryLock(String key, Duration ttl) { + try { + String lockKey = buildLockKey(key); + String lockValue = generateLockValue(); + + Boolean acquired = redisTemplate.opsForValue() + .setIfAbsent(lockKey, lockValue, ttl); + + if (Boolean.TRUE.equals(acquired)) { + log.debug("락 획득 성공: key={}, value={}", key, lockValue); + return Optional.of(lockValue); + } + + log.debug("락 획득 실패: key={}", key); + return Optional.empty(); + } catch (Exception e) { + log.warn("락 획득 중 오류 발생: key={}", key, e); + return Optional.empty(); + } + } + + /** + * 락 해제. + * + * @param key 락 키 + * @param lockValue 락 획득 시 받은 값 + * @return 해제 성공 여부 + */ + public boolean unlock(String key, String lockValue) { + try { + String lockKey = buildLockKey(key); + + Long result = redisTemplate.execute( + unlockScript, + List.of(lockKey), + lockValue + ); + + boolean released = result != null && result == 1L; + if (released) { + log.debug("락 해제 성공: key={}", key); + } else { + log.debug("락 해제 실패 (소유권 불일치): key={}", key); + } + + return released; + } catch (Exception e) { + log.warn("락 해제 중 오류 발생: key={}", key, e); + return false; + } + } + + /** + * 락을 획득하고 작업 실행 후 자동 해제. + * + * @param key 락 키 + * @param ttl 락 TTL + * @param action 실행할 작업 + * @return 작업 결과 (락 획득 실패 시 빈 Optional) + */ + public Optional executeWithLock(String key, Duration ttl, Supplier action) { + Optional lockValue = tryLock(key, ttl); + + if (lockValue.isEmpty()) { + return Optional.empty(); + } + + try { + T result = action.get(); + return Optional.ofNullable(result); + } finally { + unlock(key, lockValue.get()); + } + } + + /** + * 락을 획득하고 작업 실행 후 자동 해제 (반환값 없음). + * + * @param key 락 키 + * @param ttl 락 TTL + * @param action 실행할 작업 + * @return 락 획득 및 실행 성공 여부 + */ + public boolean executeWithLock(String key, Duration ttl, Runnable action) { + Optional lockValue = tryLock(key, ttl); + + if (lockValue.isEmpty()) { + return false; + } + + try { + action.run(); + return true; + } finally { + unlock(key, lockValue.get()); + } + } + + private String buildLockKey(String key) { + return LOCK_KEY_PREFIX + key; + } + + private String generateLockValue() { + return UUID.randomUUID().toString(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/LikeCountCacheService.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/LikeCountCacheService.java new file mode 100644 index 000000000..7dcf70915 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/LikeCountCacheService.java @@ -0,0 +1,317 @@ +package com.loopers.infrastructure.cache; + +import com.loopers.config.redis.RedisConfig; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ThreadLocalRandom; +import java.util.concurrent.TimeUnit; + +/** + * 좋아요 수 Redis 캐시 서비스. + * + *

캐시 스탬피드 방지: PER (Probabilistic Early Recomputation)

+ *
+ * TTL이 임계값(EARLY_REFRESH_THRESHOLD) 이하로 남으면 확률적으로 갱신 트리거.
+ *
+ * 예: TTL 7일, 임계값 1일
+ * - 남은 TTL > 1일: 캐시 반환
+ * - 남은 TTL ≤ 1일: 확률적으로 갱신 필요 신호 반환
+ *
+ * 확률 공식: P(refresh) = 1 - (remainingTtl / threshold)
+ * - 남은 1일: 0% 확률
+ * - 남은 12시간: 50% 확률
+ * - 남은 1시간: 96% 확률
+ * 
+ * + *

샤딩 전략

+ *
+ * 키 패턴: {product:{shardId}}:like:v{version}:{productId}
+ * shardId = productId % 16
+ * 
+ * + *

TTL 전략

+ * Long TTL (7일) + Overwrite on Write. + */ +@Slf4j +@Service +public class LikeCountCacheService { + + private static final int SHARD_COUNT = 16; + private static final Duration DEFAULT_TTL = Duration.ofDays(7); + private static final Duration EARLY_REFRESH_THRESHOLD = Duration.ofDays(1); + + private final RedisTemplate readTemplate; + private final RedisTemplate writeTemplate; + private final int cacheVersion; + + public LikeCountCacheService( + RedisTemplate readTemplate, + @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate writeTemplate, + @Value("${cache.like-count.version:1}") int cacheVersion + ) { + this.readTemplate = readTemplate; + this.writeTemplate = writeTemplate; + this.cacheVersion = cacheVersion; + } + + /** + * 좋아요 수 조회 + PER 신호 반환. + * + * @param productId 상품 ID + * @return CacheResult (값 + 갱신 필요 여부) + */ + public CacheResult getWithRefreshSignal(Long productId) { + try { + String key = buildKey(productId); + String value = readTemplate.opsForValue().get(key); + + if (value == null) { + return CacheResult.miss(); + } + + long count = Long.parseLong(value); + + // TTL 확인하여 PER 판단 + Long ttlSeconds = readTemplate.getExpire(key, TimeUnit.SECONDS); + if (ttlSeconds == null || ttlSeconds < 0) { + return CacheResult.hit(count, false); + } + + boolean shouldRefresh = shouldRefreshEarly(ttlSeconds); + return CacheResult.hit(count, shouldRefresh); + + } catch (Exception e) { + log.warn("Redis getWithRefreshSignal 실패: productId={}", productId, e); + return CacheResult.miss(); + } + } + + /** + * PER 확률 계산. + * 남은 TTL이 임계값 이하일 때 확률적으로 true 반환. + * + * @param ttlSeconds 남은 TTL (초) + * @return 갱신 필요 여부 + */ + private boolean shouldRefreshEarly(long ttlSeconds) { + long thresholdSeconds = EARLY_REFRESH_THRESHOLD.getSeconds(); + + if (ttlSeconds > thresholdSeconds) { + return false; + } + + // 확률 = 1 - (remainingTtl / threshold) + double probability = 1.0 - ((double) ttlSeconds / thresholdSeconds); + return ThreadLocalRandom.current().nextDouble() < probability; + } + + /** + * 좋아요 수 증가 + 캐시 덮어쓰기. + */ + public Long increment(Long productId) { + try { + String key = buildKey(productId); + Long result = writeTemplate.opsForValue().increment(key); + writeTemplate.opsForValue().set(key, String.valueOf(result), DEFAULT_TTL); + markDirty(productId); + return result; + } catch (Exception e) { + log.warn("Redis increment 실패: productId={}", productId, e); + return null; + } + } + + /** + * 좋아요 수 감소 + 캐시 덮어쓰기. + */ + public Long decrement(Long productId) { + try { + String key = buildKey(productId); + Long result = writeTemplate.opsForValue().decrement(key); + long safeResult = result != null && result >= 0 ? result : 0L; + writeTemplate.opsForValue().set(key, String.valueOf(safeResult), DEFAULT_TTL); + markDirty(productId); + return safeResult; + } catch (Exception e) { + log.warn("Redis decrement 실패: productId={}", productId, e); + return null; + } + } + + /** + * 좋아요 수 조회 (PER 없이 단순 조회). + */ + public Optional get(Long productId) { + try { + String key = buildKey(productId); + String value = readTemplate.opsForValue().get(key); + if (value == null) { + return Optional.empty(); + } + return Optional.of(Long.parseLong(value)); + } catch (Exception e) { + log.warn("Redis get 실패: productId={}", productId, e); + return Optional.empty(); + } + } + + /** + * 여러 상품의 좋아요 수 일괄 조회. + */ + public Map getMultiple(List productIds) { + if (productIds == null || productIds.isEmpty()) { + return Map.of(); + } + + try { + List keys = productIds.stream() + .map(this::buildKey) + .toList(); + + List values = readTemplate.opsForValue().multiGet(keys); + if (values == null) { + return Map.of(); + } + + Map result = new HashMap<>(); + for (int i = 0; i < productIds.size(); i++) { + String value = values.get(i); + if (value != null) { + result.put(productIds.get(i), Long.parseLong(value)); + } + } + return result; + } catch (Exception e) { + log.warn("Redis multiGet 실패: productIds={}", productIds, e); + return Map.of(); + } + } + + /** + * 좋아요 수 덮어쓰기 (Overwrite). + */ + public void set(Long productId, Long count) { + try { + String key = buildKey(productId); + writeTemplate.opsForValue().set(key, String.valueOf(count), DEFAULT_TTL); + } catch (Exception e) { + log.warn("Redis set 실패: productId={}, count={}", productId, count, e); + } + } + + /** + * 여러 상품의 좋아요 수 일괄 덮어쓰기. + */ + public void setMultiple(Map counts) { + if (counts == null || counts.isEmpty()) { + return; + } + + counts.forEach((productId, count) -> { + try { + String key = buildKey(productId); + writeTemplate.opsForValue().set(key, String.valueOf(count), DEFAULT_TTL); + } catch (Exception e) { + log.warn("Redis set 실패: productId={}, count={}", productId, count, e); + } + }); + } + + /** + * 변경된 상품 ID를 dirty set에 추가. + */ + public void markDirty(Long productId) { + try { + String dirtyKey = buildDirtySetKey(); + writeTemplate.opsForSet().add(dirtyKey, String.valueOf(productId)); + } catch (Exception e) { + log.warn("Redis markDirty 실패: productId={}", productId, e); + } + } + + /** + * dirty 상품 ID 목록 조회. + */ + public Set getDirtyProductIds() { + try { + String dirtyKey = buildDirtySetKey(); + Set members = readTemplate.opsForSet().members(dirtyKey); + if (members == null || members.isEmpty()) { + return Set.of(); + } + Set result = new HashSet<>(); + for (String member : members) { + result.add(Long.parseLong(member)); + } + return result; + } catch (Exception e) { + log.warn("Redis getDirtyProductIds 실패", e); + return Set.of(); + } + } + + /** + * dirty 상품 ID 목록 초기화. + */ + public void clearDirtyProductIds() { + try { + String dirtyKey = buildDirtySetKey(); + writeTemplate.delete(dirtyKey); + } catch (Exception e) { + log.warn("Redis clearDirtyProductIds 실패", e); + } + } + + /** + * 특정 상품을 dirty set에서 제거. + */ + public void removeDirty(Long productId) { + try { + String dirtyKey = buildDirtySetKey(); + writeTemplate.opsForSet().remove(dirtyKey, String.valueOf(productId)); + } catch (Exception e) { + log.warn("Redis removeDirty 실패: productId={}", productId, e); + } + } + + public int getCacheVersion() { + return cacheVersion; + } + + private String buildKey(Long productId) { + int shardId = (int) (productId % SHARD_COUNT); + return String.format("{product:%d}:like:v%d:%d", shardId, cacheVersion, productId); + } + + private String buildDirtySetKey() { + return String.format("{product:dirty}:like:v%d", cacheVersion); + } + + /** + * 캐시 조회 결과. + */ + public record CacheResult( + boolean hit, + Long value, + boolean shouldRefresh + ) { + public static CacheResult miss() { + return new CacheResult(false, null, true); + } + + public static CacheResult hit(Long value, boolean shouldRefresh) { + return new CacheResult(true, value, shouldRefresh); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductLikeCountQueryService.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductLikeCountQueryService.java new file mode 100644 index 000000000..72c0aa0b9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/cache/ProductLikeCountQueryService.java @@ -0,0 +1,189 @@ +package com.loopers.infrastructure.cache; + +import com.loopers.domain.like.LikeRepository; +import com.loopers.infrastructure.cache.LikeCountCacheService.CacheResult; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 상품 좋아요 수 조회 서비스. + * + *

캐시 스탬피드 방지 전략

+ *
    + *
  1. PER (Probabilistic Early Recomputation): TTL 만료 전에 확률적으로 갱신
  2. + *
  3. Single Flight: 동시 캐시 미스 시 하나의 요청만 DB 조회
  4. + *
  5. Stale-While-Revalidate: 기존 값 반환하면서 백그라운드에서 갱신
  6. + *
+ */ +@Slf4j +@Service +public class ProductLikeCountQueryService { + + private static final String LOCK_KEY_PREFIX = "product:like-count:"; + private static final Duration LOCK_TTL = Duration.ofSeconds(5); + private static final Duration RETRY_DELAY = Duration.ofMillis(50); + private static final int MAX_RETRIES = 3; + + private final LikeCountCacheService likeCountCacheService; + private final DistributedLockService distributedLockService; + private final LikeRepository likeRepository; + + public ProductLikeCountQueryService( + LikeCountCacheService likeCountCacheService, + DistributedLockService distributedLockService, + LikeRepository likeRepository + ) { + this.likeCountCacheService = likeCountCacheService; + this.distributedLockService = distributedLockService; + this.likeRepository = likeRepository; + } + + /** + * 상품 좋아요 수 조회. + * + *
+     * 1. 캐시 조회 (with PER 신호)
+     * 2. 캐시 히트 + 갱신 불필요 → 즉시 반환
+     * 3. 캐시 히트 + 갱신 필요 → Stale 값 반환 + 백그라운드 갱신
+     * 4. 캐시 미스 → Single Flight로 DB 조회
+     * 
+ * + * @param productId 상품 ID + * @return 좋아요 수 + */ + public long getLikeCount(Long productId) { + // 1. 캐시 조회 (with PER) + CacheResult result = likeCountCacheService.getWithRefreshSignal(productId); + + if (result.hit()) { + if (result.shouldRefresh()) { + // Stale-While-Revalidate: 기존 값 반환 + 백그라운드 갱신 + refreshInBackground(productId); + } + return result.value(); + } + + // 2. 캐시 미스 → Single Flight + return loadWithSingleFlight(productId); + } + + /** + * 여러 상품의 좋아요 수 일괄 조회. + */ + public Map getLikeCounts(List productIds) { + if (productIds == null || productIds.isEmpty()) { + return Map.of(); + } + + // 1. 캐시에서 일괄 조회 + Map cached = likeCountCacheService.getMultiple(productIds); + Map result = new HashMap<>(cached); + + // 2. 캐시 미스된 ID 추출 + List missingIds = productIds.stream() + .filter(id -> !cached.containsKey(id)) + .toList(); + + if (missingIds.isEmpty()) { + return result; + } + + // 3. 캐시 미스 항목 DB 조회 및 캐시 저장 + Map fromDb = likeRepository.countByProductIds(missingIds); + for (Long id : missingIds) { + long count = fromDb.getOrDefault(id, 0L); + result.put(id, count); + likeCountCacheService.set(id, count); + } + + return result; + } + + /** + * Single Flight 패턴으로 DB 조회. + * 하나의 요청만 DB 조회, 나머지는 대기 후 캐시에서 조회. + */ + private long loadWithSingleFlight(Long productId) { + String lockKey = LOCK_KEY_PREFIX + productId; + + Optional lockValue = distributedLockService.tryLock(lockKey, LOCK_TTL); + + if (lockValue.isPresent()) { + try { + // Double-check: 다른 스레드가 이미 캐시에 저장했을 수 있음 + Optional cachedAgain = likeCountCacheService.get(productId); + if (cachedAgain.isPresent()) { + return cachedAgain.get(); + } + + long count = likeRepository.countByProductId(productId); + likeCountCacheService.set(productId, count); + log.debug("DB에서 좋아요 수 조회 및 캐시 저장: productId={}, count={}", productId, count); + return count; + } finally { + distributedLockService.unlock(lockKey, lockValue.get()); + } + } + + // 락 획득 실패 → 대기 후 캐시 재조회 + return waitAndRetryFromCache(productId); + } + + /** + * 락 획득 실패 시 대기 후 캐시 재조회. + * 최대 재시도 횟수 초과 시 DB 직접 조회 (Fallback). + */ + private long waitAndRetryFromCache(Long productId) { + for (int retry = 0; retry < MAX_RETRIES; retry++) { + try { + Thread.sleep(RETRY_DELAY.toMillis()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + + Optional cached = likeCountCacheService.get(productId); + if (cached.isPresent()) { + log.debug("재시도 후 캐시에서 조회 성공: productId={}, retry={}", productId, retry); + return cached.get(); + } + } + + // Fallback: 재시도 모두 실패 시 DB 직접 조회 + log.warn("캐시 재시도 실패, DB 직접 조회: productId={}", productId); + return likeRepository.countByProductId(productId); + } + + /** + * 백그라운드에서 캐시 갱신 (Stale-While-Revalidate). + * PER 신호를 받은 요청이 비동기로 캐시를 갱신. + */ + @Async + public void refreshInBackground(Long productId) { + String lockKey = LOCK_KEY_PREFIX + "refresh:" + productId; + + // 갱신 중복 방지를 위한 락 + Optional lockValue = distributedLockService.tryLock(lockKey, Duration.ofSeconds(10)); + if (lockValue.isEmpty()) { + // 이미 다른 요청이 갱신 중 + return; + } + + try { + long count = likeRepository.countByProductId(productId); + likeCountCacheService.set(productId, count); + log.debug("백그라운드 캐시 갱신 완료: productId={}, count={}", productId, count); + } catch (Exception e) { + log.warn("백그라운드 캐시 갱신 실패: productId={}", productId, e); + } finally { + distributedLockService.unlock(lockKey, lockValue.get()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/init/DataInitializer.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/init/DataInitializer.java new file mode 100644 index 000000000..19e235b71 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/init/DataInitializer.java @@ -0,0 +1,240 @@ +package com.loopers.infrastructure.init; + +import com.loopers.domain.brand.Brand; +import com.loopers.domain.brand.BrandRepository; +import com.loopers.domain.common.Money; +import com.loopers.domain.coupon.CouponTemplate; +import com.loopers.domain.coupon.CouponTemplateRepository; +import com.loopers.domain.like.Like; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.Stock; +import com.loopers.domain.user.User; +import com.loopers.domain.user.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; + +/** + * 애플리케이션 시작 시 더미 데이터 생성. + * local 프로파일에서만 실행. + */ +@Slf4j +@Component +@Profile("local") +@RequiredArgsConstructor +public class DataInitializer implements ApplicationRunner { + + private static final int USER_COUNT = 1_000; + private static final int BRAND_COUNT = 5_000; + private static final int PRODUCT_COUNT = 200_000; + private static final int COUPON_COUNT = 50_000; + + private final UserRepository userRepository; + private final BrandRepository brandRepository; + private final ProductRepository productRepository; + private final LikeRepository likeRepository; + private final CouponTemplateRepository couponTemplateRepository; + + private final Random random = new Random(); + + @Override + @Transactional + public void run(ApplicationArguments args) { + if (isDataExists()) { + log.info("데이터가 이미 존재합니다. 초기화를 건너뜁니다."); + return; + } + + log.info("=== 더미 데이터 생성 시작 ==="); + long startTime = System.currentTimeMillis(); + + List users = createUsers(); + List brands = createBrands(); + List products = createProducts(brands); + createLikes(users, products); + createCoupons(); + + long elapsed = System.currentTimeMillis() - startTime; + log.info("=== 더미 데이터 생성 완료 ({}ms) ===", elapsed); + } + + private boolean isDataExists() { + return userRepository.existsByLoginId("user000001"); + } + + private List createUsers() { + log.info("Users 생성 중... ({}개)", USER_COUNT); + List users = new ArrayList<>(); + + for (int i = 1; i <= USER_COUNT; i++) { + String loginId = String.format("user%06d", i); + String password = "Password1!"; + String name = "User" + i; + String birthDate = generateBirthDate(); + String email = loginId + "@example.com"; + + User user = new User(loginId, password, name, birthDate, email); + users.add(userRepository.save(user)); + + if (i % 100 == 0) { + log.debug("Users 진행: {}/{}", i, USER_COUNT); + } + } + + log.info("Users 생성 완료: {}개", users.size()); + return users; + } + + private List createBrands() { + log.info("Brands 생성 중... ({}개)", BRAND_COUNT); + List brands = new ArrayList<>(); + + for (int i = 1; i <= BRAND_COUNT; i++) { + String name = String.format("Brand-%04d", i); + String description = "Brand " + i + " - Premium quality products"; + String logoUrl = "https://cdn.example.com/logos/brand-" + i + ".png"; + + Brand brand = Brand.create(name, description, logoUrl); + brands.add(brandRepository.save(brand)); + + if (i % 500 == 0) { + log.debug("Brands 진행: {}/{}", i, BRAND_COUNT); + } + } + + log.info("Brands 생성 완료: {}개", brands.size()); + return brands; + } + + private List createProducts(List brands) { + log.info("Products 생성 중... ({}개)", PRODUCT_COUNT); + List products = new ArrayList<>(); + + for (int i = 1; i <= PRODUCT_COUNT; i++) { + Brand brand = brands.get((i - 1) % brands.size()); + String name = "Product-" + i; + String description = "High quality product. Item number " + i; + Money price = new Money(1000 + random.nextLong(500000)); + Stock stock = new Stock(random.nextInt(1000)); + String imageUrl = "https://cdn.example.com/products/" + i + ".jpg"; + + Product product = Product.create(brand.getId(), name, description, price, stock, imageUrl); + products.add(productRepository.save(product)); + + if (i % 10000 == 0) { + log.debug("Products 진행: {}/{}", i, PRODUCT_COUNT); + } + } + + log.info("Products 생성 완료: {}개", products.size()); + return products; + } + + private void createLikes(List users, List products) { + log.info("Likes 생성 중..."); + int totalLikes = 0; + + for (Product product : products) { + int likesCount = generateLikesCount(); + + Set usedUserIds = new HashSet<>(); + for (int j = 0; j < likesCount && usedUserIds.size() < users.size(); j++) { + User user = users.get(random.nextInt(users.size())); + if (usedUserIds.contains(user.getId())) { + continue; + } + usedUserIds.add(user.getId()); + + Like like = Like.create(user.getId(), product.getId()); + likeRepository.save(like); + totalLikes++; + } + + if (product.getId() % 10000 == 0) { + log.debug("Likes 진행: productId={}, totalLikes={}", product.getId(), totalLikes); + } + } + + log.info("Likes 생성 완료: {}개", totalLikes); + } + + private void createCoupons() { + log.info("Coupons 생성 중... ({}개)", COUPON_COUNT); + ZonedDateTime expiredAt = ZonedDateTime.now().plusDays(365); + + for (int i = 1; i <= COUPON_COUNT; i++) { + CouponTemplate coupon; + + if (random.nextDouble() < 0.6) { + // 60% 정액 할인 + long[] values = {1000, 2000, 3000, 5000, 10000}; + long value = values[random.nextInt(values.length)]; + Money discountValue = new Money(value); + Money minOrderAmount = new Money(10000 + random.nextLong(90000)); + + coupon = CouponTemplate.createFixed( + "FixedCoupon-" + i, + discountValue, + minOrderAmount, + random.nextDouble() < 0.3 ? null : 100 + random.nextInt(9900), + expiredAt + ); + } else { + // 40% 정률 할인 + int[] rates = {5, 10, 15, 20, 30}; + int rate = rates[random.nextInt(rates.length)]; + Money minOrderAmount = new Money(20000 + random.nextLong(180000)); + + coupon = CouponTemplate.createRate( + "RateCoupon-" + i, + rate, + minOrderAmount, + 5000 + random.nextInt(45000), + random.nextDouble() < 0.3 ? null : 100 + random.nextInt(9900), + expiredAt + ); + } + + couponTemplateRepository.save(coupon); + + if (i % 5000 == 0) { + log.debug("Coupons 진행: {}/{}", i, COUPON_COUNT); + } + } + + log.info("Coupons 생성 완료: {}개", COUPON_COUNT); + } + + private String generateBirthDate() { + int year = 1970 + random.nextInt(35); + int month = 1 + random.nextInt(12); + int day = 1 + random.nextInt(28); + return String.format("%04d%02d%02d", year, month, day); + } + + private int generateLikesCount() { + double rand = random.nextDouble(); + if (rand < 0.01) { + return 100 + random.nextInt(400); // 상위 1%: 100-500 + } else if (rand < 0.10) { + return 20 + random.nextInt(80); // 상위 10%: 20-100 + } else if (rand < 0.40) { + return 5 + random.nextInt(15); // 상위 40%: 5-20 + } else { + return random.nextInt(5); // 나머지: 0-5 + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaEntity.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaEntity.java index e90f2cdbd..18e11634d 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaEntity.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaEntity.java @@ -3,6 +3,7 @@ import com.loopers.domain.BaseEntity; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.Index; import jakarta.persistence.Table; /** @@ -10,7 +11,15 @@ * Infrastructure Layer에 위치하며 영속성을 담당. */ @Entity -@Table(name = "products") +@Table( + name = "products", + indexes = { + @Index(name = "idx_products_like_count", columnList = "like_count DESC, created_at DESC"), + @Index(name = "idx_products_brand_like", columnList = "brand_id, like_count DESC"), + @Index(name = "idx_products_brand_price", columnList = "brand_id, price ASC"), + @Index(name = "idx_products_created_at", columnList = "created_at DESC") + } +) public class ProductJpaEntity extends BaseEntity { @Column(name = "brand_id", nullable = false) @@ -31,6 +40,9 @@ public class ProductJpaEntity extends BaseEntity { @Column(name = "image_url", length = 500) private String imageUrl; + @Column(name = "like_count", nullable = false) + private Long likeCount = 0L; + protected ProductJpaEntity() {} public ProductJpaEntity(Long brandId, String name, String description, @@ -90,4 +102,12 @@ public void setStock(Integer stock) { public void setImageUrl(String imageUrl) { this.imageUrl = imageUrl; } + + public Long getLikeCount() { + return likeCount; + } + + public void setLikeCount(Long likeCount) { + this.likeCount = likeCount; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaRepository.java index 2090c5a69..03f7a78bd 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/persistence/jpa/product/ProductJpaRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -34,4 +35,8 @@ public interface ProductJpaRepository extends JpaRepository Sort.by(Sort.Direction.DESC, "createdAt"); case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "price"); - case LIKES_DESC -> Sort.by(Sort.Direction.DESC, "createdAt"); // likes_desc는 Application에서 처리 + case LIKES_DESC -> Sort.by(Sort.Direction.DESC, "likeCount") + .and(Sort.by(Sort.Direction.DESC, "createdAt")); }; return PageRequest.of(page, limit, jpaSort); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java index 7ca75aae6..27ae2f202 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1ApiSpec.java @@ -1,7 +1,9 @@ package com.loopers.interfaces.api.product; +import com.loopers.domain.product.ProductSort; import com.loopers.interfaces.api.ApiResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -11,13 +13,17 @@ public interface ProductV1ApiSpec { @Operation( summary = "상품 목록 조회", - description = "상품 목록을 조회합니다. brandId로 필터링할 수 있습니다." + description = "상품 목록을 조회합니다. brandId로 필터링하고, sort로 정렬할 수 있습니다." ) - ApiResponse> getProducts(Long brandId, Pageable pageable); + ApiResponse> getProducts( + @Parameter(description = "브랜드 ID (선택)") Long brandId, + @Parameter(description = "정렬 기준: LATEST(최신순), PRICE_ASC(가격 낮은 순), LIKES_DESC(좋아요 많은 순)") ProductSort sort, + Pageable pageable + ); @Operation( summary = "상품 상세 조회", - description = "상품 ID로 상품 정보를 조회합니다." + description = "상품 ID로 상품 정보를 조회합니다. 좋아요 수가 포함됩니다." ) ApiResponse getProduct(Long productId); } 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 876633f99..be6127412 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 @@ -2,6 +2,7 @@ import com.loopers.application.product.ProductResult; import com.loopers.application.product.ProductService; +import com.loopers.domain.product.ProductSort; import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -23,9 +24,10 @@ public class ProductV1Controller implements ProductV1ApiSpec { @Override public ApiResponse> getProducts( @RequestParam(required = false) Long brandId, + @RequestParam(required = false) ProductSort sort, Pageable pageable ) { - Page results = productService.findAll(brandId, pageable); + Page results = productService.findAll(brandId, sort, pageable); Page responses = results.map(ProductV1Dto.ProductResponse::from); return ApiResponse.success(responses); } 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 3aac721c3..884af01c6 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 @@ -19,6 +19,7 @@ public record ProductResponse( Long price, Integer stock, String imageUrl, + Long likeCount, ZonedDateTime createdAt, ZonedDateTime updatedAt ) { @@ -31,6 +32,7 @@ public static ProductResponse from(ProductResult result) { result.price(), result.stock(), result.imageUrl(), + result.likeCount(), result.createdAt(), result.updatedAt() ); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeApplicationServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeApplicationServiceTest.java index 4a8dbe945..30bd6a6eb 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeApplicationServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeApplicationServiceTest.java @@ -4,14 +4,17 @@ import com.loopers.domain.like.LikeDomainService; import com.loopers.domain.product.Product; import com.loopers.domain.product.Stock; +import com.loopers.fake.FakeLikeCountCacheService; import com.loopers.fake.FakeLikeRepository; import com.loopers.fake.FakeProductRepository; +import com.loopers.infrastructure.cache.LikeCountCacheService; 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.mockito.Mockito; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; @@ -23,6 +26,7 @@ class LikeApplicationServiceTest { private FakeLikeRepository fakeLikeRepository; private FakeProductRepository fakeProductRepository; private LikeDomainService likeDomainService; + private LikeCountCacheService likeCountCacheService; private LikeApplicationService likeApplicationService; @BeforeEach @@ -30,7 +34,13 @@ void setUp() { fakeLikeRepository = new FakeLikeRepository(); fakeProductRepository = new FakeProductRepository(); likeDomainService = new LikeDomainService(fakeLikeRepository); - likeApplicationService = new LikeApplicationService(likeDomainService, fakeProductRepository); + likeCountCacheService = Mockito.mock(LikeCountCacheService.class); + likeApplicationService = new LikeApplicationService( + likeDomainService, + fakeProductRepository, + fakeLikeRepository, + likeCountCacheService + ); } private Product createAndSaveProduct() { diff --git a/apps/commerce-api/src/test/java/com/loopers/config/TestRedisConfiguration.java b/apps/commerce-api/src/test/java/com/loopers/config/TestRedisConfiguration.java new file mode 100644 index 000000000..459231d02 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/config/TestRedisConfiguration.java @@ -0,0 +1,39 @@ +package com.loopers.config; + +import com.loopers.config.redis.RedisNodeInfo; +import com.loopers.config.redis.RedisProperties; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import java.util.List; + +@TestConfiguration(proxyBeanMethods = false) +public class TestRedisConfiguration { + + private static final int REDIS_PORT = 6379; + private static final DockerImageName REDIS_IMAGE = DockerImageName.parse("redis:7.0"); + + @SuppressWarnings("resource") + private static final GenericContainer REDIS_CONTAINER = new GenericContainer<>(REDIS_IMAGE) + .withExposedPorts(REDIS_PORT) + .withReuse(true); + + static { + REDIS_CONTAINER.start(); + } + + @Bean + @Primary + public RedisProperties testRedisProperties() { + String host = REDIS_CONTAINER.getHost(); + int port = REDIS_CONTAINER.getMappedPort(REDIS_PORT); + + RedisNodeInfo master = new RedisNodeInfo(host, port); + List replicas = List.of(new RedisNodeInfo(host, port)); + + return new RedisProperties(0, master, replicas); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/fake/FakeLikeCountCacheService.java b/apps/commerce-api/src/test/java/com/loopers/fake/FakeLikeCountCacheService.java new file mode 100644 index 000000000..66ba64e33 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/fake/FakeLikeCountCacheService.java @@ -0,0 +1,78 @@ +package com.loopers.fake; + +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +/** + * 테스트용 Fake LikeCountCacheService. + * Map 기반 in-memory 구현. + */ +public class FakeLikeCountCacheService { + + private final Map store = new HashMap<>(); + private final Set dirtySet = new HashSet<>(); + + public Long increment(Long productId) { + Long current = store.getOrDefault(productId, 0L); + Long newValue = current + 1; + store.put(productId, newValue); + dirtySet.add(productId); + return newValue; + } + + public Long decrement(Long productId) { + Long current = store.getOrDefault(productId, 0L); + Long newValue = Math.max(0, current - 1); + store.put(productId, newValue); + dirtySet.add(productId); + return newValue; + } + + public Optional get(Long productId) { + return Optional.ofNullable(store.get(productId)); + } + + public Map getMultiple(List productIds) { + Map result = new HashMap<>(); + for (Long productId : productIds) { + Long value = store.get(productId); + if (value != null) { + result.put(productId, value); + } + } + return result; + } + + public void set(Long productId, Long count) { + store.put(productId, count); + } + + public void delete(Long productId) { + store.remove(productId); + } + + public void markDirty(Long productId) { + dirtySet.add(productId); + } + + public Set getDirtyProductIds() { + return new HashSet<>(dirtySet); + } + + public void clearDirtyProductIds() { + dirtySet.clear(); + } + + public void removeDirty(Long productId) { + dirtySet.remove(productId); + } + + public void clear() { + store.clear(); + dirtySet.clear(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/DistributedLockServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/DistributedLockServiceTest.java new file mode 100644 index 000000000..ef60cf8a8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/DistributedLockServiceTest.java @@ -0,0 +1,222 @@ +package com.loopers.infrastructure.cache; + +import com.loopers.config.TestRedisConfiguration; +import org.junit.jupiter.api.AfterEach; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.core.RedisTemplate; + +import java.time.Duration; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Import(TestRedisConfiguration.class) +@DisplayName("DistributedLockService 테스트") +class DistributedLockServiceTest { + + @Autowired + private DistributedLockService distributedLockService; + + @Autowired + private RedisTemplate redisTemplate; + + private static final String LOCK_KEY_PREFIX = "lock:"; + + @BeforeEach + void setUp() { + cleanUpLockKeys(); + } + + @AfterEach + void tearDown() { + cleanUpLockKeys(); + } + + private void cleanUpLockKeys() { + var keys = redisTemplate.keys(LOCK_KEY_PREFIX + "*"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } + + @Nested + @DisplayName("tryLock 테스트") + class TryLock { + + @Test + @DisplayName("성공 - 락 획득") + void 락_획득_성공() { + // Arrange + String key = "test-resource"; + + // Act + Optional lockValue = distributedLockService.tryLock(key, Duration.ofSeconds(10)); + + // Assert + assertThat(lockValue).isPresent(); + } + + @Test + @DisplayName("실패 - 이미 락이 있을 때") + void 이미_락_존재시_실패() { + // Arrange + String key = "test-resource"; + distributedLockService.tryLock(key, Duration.ofSeconds(10)); + + // Act + Optional secondLock = distributedLockService.tryLock(key, Duration.ofSeconds(10)); + + // Assert + assertThat(secondLock).isEmpty(); + } + + @Test + @DisplayName("성공 - 락 만료 후 재획득") + void 락_만료후_재획득() throws InterruptedException { + // Arrange + String key = "test-resource"; + distributedLockService.tryLock(key, Duration.ofMillis(100)); + + // Act - 락 만료 대기 + Thread.sleep(150); + Optional newLock = distributedLockService.tryLock(key, Duration.ofSeconds(10)); + + // Assert + assertThat(newLock).isPresent(); + } + } + + @Nested + @DisplayName("unlock 테스트") + class Unlock { + + @Test + @DisplayName("성공 - 락 해제") + void 락_해제_성공() { + // Arrange + String key = "test-resource"; + Optional lockValue = distributedLockService.tryLock(key, Duration.ofSeconds(10)); + + // Act + boolean released = distributedLockService.unlock(key, lockValue.orElseThrow()); + + // Assert + assertThat(released).isTrue(); + + // 다시 락 획득 가능한지 확인 + Optional newLock = distributedLockService.tryLock(key, Duration.ofSeconds(10)); + assertThat(newLock).isPresent(); + } + + @Test + @DisplayName("실패 - 다른 lockValue로 해제 시도") + void 잘못된_lockValue_해제_실패() { + // Arrange + String key = "test-resource"; + distributedLockService.tryLock(key, Duration.ofSeconds(10)); + + // Act + boolean released = distributedLockService.unlock(key, "wrong-value"); + + // Assert + assertThat(released).isFalse(); + } + } + + @Nested + @DisplayName("executeWithLock 테스트") + class ExecuteWithLock { + + @Test + @DisplayName("성공 - 락 획득 후 작업 실행") + void 락_획득후_작업_실행() { + // Arrange + String key = "test-resource"; + AtomicInteger counter = new AtomicInteger(0); + + // Act + Optional result = distributedLockService.executeWithLock( + key, + Duration.ofSeconds(10), + counter::incrementAndGet + ); + + // Assert + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(1); + assertThat(counter.get()).isEqualTo(1); + } + + @Test + @DisplayName("실패 - 락 획득 실패시 빈 값 반환") + void 락_획득_실패시_빈값() { + // Arrange + String key = "test-resource"; + distributedLockService.tryLock(key, Duration.ofSeconds(10)); + AtomicInteger counter = new AtomicInteger(0); + + // Act + Optional result = distributedLockService.executeWithLock( + key, + Duration.ofSeconds(10), + counter::incrementAndGet + ); + + // Assert + assertThat(result).isEmpty(); + assertThat(counter.get()).isEqualTo(0); + } + } + + @Nested + @DisplayName("동시성 테스트") + class Concurrency { + + @Test + @DisplayName("여러 스레드가 동시에 락을 요청해도 하나만 성공") + void 동시_락_요청_단일_성공() throws InterruptedException { + // Arrange + String key = "test-resource"; + int threadCount = 10; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch endLatch = new CountDownLatch(threadCount); + AtomicInteger successCount = new AtomicInteger(0); + + // Act + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + startLatch.await(); + Optional lock = distributedLockService.tryLock(key, Duration.ofSeconds(10)); + if (lock.isPresent()) { + successCount.incrementAndGet(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + endLatch.countDown(); + } + }); + } + + startLatch.countDown(); + endLatch.await(); + executor.shutdown(); + + // Assert + assertThat(successCount.get()).isEqualTo(1); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/LikeCountCacheServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/LikeCountCacheServiceTest.java new file mode 100644 index 000000000..e7636d4f7 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/LikeCountCacheServiceTest.java @@ -0,0 +1,335 @@ +package com.loopers.infrastructure.cache; + +import com.loopers.config.TestRedisConfiguration; +import org.junit.jupiter.api.AfterEach; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Import(TestRedisConfiguration.class) +@DisplayName("LikeCountCacheService 테스트") +class LikeCountCacheServiceTest { + + @Autowired + private LikeCountCacheService likeCountCacheService; + + @Autowired + private RedisTemplate redisTemplate; + + @BeforeEach + void setUp() { + cleanUpRedisKeys(); + } + + @AfterEach + void tearDown() { + cleanUpRedisKeys(); + } + + private void cleanUpRedisKeys() { + // 샤딩된 키 패턴 정리: {product:*}:like:* + var keys = redisTemplate.keys("{product:*}:like:*"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } + + @Nested + @DisplayName("increment 테스트") + class Increment { + + @Test + @DisplayName("성공 - 좋아요 수 증가") + void 좋아요_수_증가() { + // Arrange + Long productId = 1L; + + // Act + Long result = likeCountCacheService.increment(productId); + + // Assert + assertThat(result).isEqualTo(1L); + } + + @Test + @DisplayName("성공 - 여러 번 증가") + void 여러번_증가() { + // Arrange + Long productId = 1L; + + // Act + likeCountCacheService.increment(productId); + likeCountCacheService.increment(productId); + Long result = likeCountCacheService.increment(productId); + + // Assert + assertThat(result).isEqualTo(3L); + } + + @Test + @DisplayName("성공 - 증가 시 dirty 플래그 설정됨") + void 증가시_dirty_플래그_설정() { + // Arrange + Long productId = 1L; + + // Act + likeCountCacheService.increment(productId); + + // Assert + Set dirtyIds = likeCountCacheService.getDirtyProductIds(); + assertThat(dirtyIds).contains(productId); + } + } + + @Nested + @DisplayName("decrement 테스트") + class Decrement { + + @Test + @DisplayName("성공 - 좋아요 수 감소") + void 좋아요_수_감소() { + // Arrange + Long productId = 1L; + likeCountCacheService.increment(productId); + likeCountCacheService.increment(productId); + + // Act + Long result = likeCountCacheService.decrement(productId); + + // Assert + assertThat(result).isEqualTo(1L); + } + + @Test + @DisplayName("성공 - 0 미만으로 내려가지 않음 (음수는 0으로 처리)") + void 음수_방지() { + // Arrange + Long productId = 1L; + + // Act + Long result = likeCountCacheService.decrement(productId); + + // Assert + assertThat(result).isEqualTo(0L); + + // 캐시에서도 0으로 저장되어 있는지 확인 + Optional cached = likeCountCacheService.get(productId); + assertThat(cached).isPresent(); + assertThat(cached.get()).isEqualTo(0L); + } + } + + @Nested + @DisplayName("get 테스트") + class Get { + + @Test + @DisplayName("성공 - 존재하는 카운트 조회") + void 존재하는_카운트_조회() { + // Arrange + Long productId = 1L; + likeCountCacheService.increment(productId); + likeCountCacheService.increment(productId); + + // Act + Optional result = likeCountCacheService.get(productId); + + // Assert + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(2L); + } + + @Test + @DisplayName("성공 - 존재하지 않는 카운트 조회 시 빈 값 반환") + void 미존재_카운트_조회() { + // Arrange + Long productId = 999L; + + // Act + Optional result = likeCountCacheService.get(productId); + + // Assert + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("getMultiple 테스트") + class GetMultiple { + + @Test + @DisplayName("성공 - 여러 상품 카운트 일괄 조회") + void 일괄_조회() { + // Arrange + likeCountCacheService.set(1L, 10L); + likeCountCacheService.set(2L, 20L); + likeCountCacheService.set(3L, 30L); + + // Act + Map result = likeCountCacheService.getMultiple(List.of(1L, 2L, 3L, 4L)); + + // Assert + assertThat(result).hasSize(3); + assertThat(result.get(1L)).isEqualTo(10L); + assertThat(result.get(2L)).isEqualTo(20L); + assertThat(result.get(3L)).isEqualTo(30L); + assertThat(result.containsKey(4L)).isFalse(); + } + + @Test + @DisplayName("성공 - 다른 샤드에 있는 상품들도 조회됨") + void 다른_샤드_조회() { + // Arrange - 16개 샤드에 분산되도록 productId 설정 + likeCountCacheService.set(0L, 100L); // shard 0 + likeCountCacheService.set(16L, 116L); // shard 0 + likeCountCacheService.set(1L, 101L); // shard 1 + likeCountCacheService.set(15L, 115L); // shard 15 + + // Act + Map result = likeCountCacheService.getMultiple(List.of(0L, 1L, 15L, 16L)); + + // Assert + assertThat(result).hasSize(4); + assertThat(result.get(0L)).isEqualTo(100L); + assertThat(result.get(16L)).isEqualTo(116L); + assertThat(result.get(1L)).isEqualTo(101L); + assertThat(result.get(15L)).isEqualTo(115L); + } + } + + @Nested + @DisplayName("set 테스트") + class SetValue { + + @Test + @DisplayName("성공 - 카운트 직접 설정") + void 카운트_설정() { + // Arrange + Long productId = 1L; + Long count = 100L; + + // Act + likeCountCacheService.set(productId, count); + + // Assert + Optional result = likeCountCacheService.get(productId); + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(100L); + } + + @Test + @DisplayName("성공 - Overwrite - 기존 값 덮어쓰기") + void 덮어쓰기() { + // Arrange + Long productId = 1L; + likeCountCacheService.set(productId, 50L); + + // Act - 덮어쓰기 + likeCountCacheService.set(productId, 200L); + + // Assert + Optional result = likeCountCacheService.get(productId); + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(200L); + } + } + + @Nested + @DisplayName("setMultiple 테스트") + class SetMultiple { + + @Test + @DisplayName("성공 - 여러 상품 카운트 일괄 설정") + void 일괄_설정() { + // Arrange + Map counts = Map.of(1L, 10L, 2L, 20L, 3L, 30L); + + // Act + likeCountCacheService.setMultiple(counts); + + // Assert + assertThat(likeCountCacheService.get(1L)).contains(10L); + assertThat(likeCountCacheService.get(2L)).contains(20L); + assertThat(likeCountCacheService.get(3L)).contains(30L); + } + } + + @Nested + @DisplayName("markDirty/getDirtyProductIds 테스트") + class DirtyTracking { + + @Test + @DisplayName("성공 - dirty 상품 ID 추적") + void dirty_상품_추적() { + // Arrange + likeCountCacheService.markDirty(1L); + likeCountCacheService.markDirty(2L); + likeCountCacheService.markDirty(1L); // 중복 + + // Act + Set dirtyIds = likeCountCacheService.getDirtyProductIds(); + + // Assert + assertThat(dirtyIds).containsExactlyInAnyOrder(1L, 2L); + } + + @Test + @DisplayName("성공 - dirty 플래그 초기화") + void dirty_플래그_초기화() { + // Arrange + likeCountCacheService.markDirty(1L); + likeCountCacheService.markDirty(2L); + + // Act + likeCountCacheService.clearDirtyProductIds(); + Set dirtyIds = likeCountCacheService.getDirtyProductIds(); + + // Assert + assertThat(dirtyIds).isEmpty(); + } + + @Test + @DisplayName("성공 - 개별 dirty 플래그 제거") + void 개별_dirty_제거() { + // Arrange + likeCountCacheService.markDirty(1L); + likeCountCacheService.markDirty(2L); + likeCountCacheService.markDirty(3L); + + // Act + likeCountCacheService.removeDirty(2L); + Set dirtyIds = likeCountCacheService.getDirtyProductIds(); + + // Assert + assertThat(dirtyIds).containsExactlyInAnyOrder(1L, 3L); + } + } + + @Nested + @DisplayName("버저닝 테스트") + class Versioning { + + @Test + @DisplayName("성공 - 캐시 버전 확인") + void 캐시_버전_확인() { + // Act + int version = likeCountCacheService.getCacheVersion(); + + // Assert + assertThat(version).isGreaterThanOrEqualTo(1); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductLikeCountQueryServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductLikeCountQueryServiceTest.java new file mode 100644 index 000000000..53d9253ee --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/cache/ProductLikeCountQueryServiceTest.java @@ -0,0 +1,184 @@ +package com.loopers.infrastructure.cache; + +import com.loopers.config.TestRedisConfiguration; +import com.loopers.domain.common.Money; +import com.loopers.domain.like.LikeRepository; +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductRepository; +import com.loopers.domain.product.Stock; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +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.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.core.RedisTemplate; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Import(TestRedisConfiguration.class) +@DisplayName("ProductLikeCountQueryService 테스트") +class ProductLikeCountQueryServiceTest { + + @Autowired + private ProductLikeCountQueryService productLikeCountQueryService; + + @Autowired + private LikeCountCacheService likeCountCacheService; + + @Autowired + private LikeRepository likeRepository; + + @Autowired + private ProductRepository productRepository; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @BeforeEach + void setUp() { + cleanUpRedis(); + } + + @AfterEach + void tearDown() { + cleanUpRedis(); + databaseCleanUp.truncateAllTables(); + } + + private void cleanUpRedis() { + // 샤딩된 캐시 키: {product:*}:like:* + var cacheKeys = redisTemplate.keys("{product:*}:like:*"); + if (cacheKeys != null && !cacheKeys.isEmpty()) { + redisTemplate.delete(cacheKeys); + } + // 분산 락 키: lock:* + var lockKeys = redisTemplate.keys("lock:*"); + if (lockKeys != null && !lockKeys.isEmpty()) { + redisTemplate.delete(lockKeys); + } + } + + private Product createProduct() { + return productRepository.save( + Product.create(1L, "테스트 상품", "설명", + new Money(10000), new Stock(100), "http://image.url") + ); + } + + @Nested + @DisplayName("getLikeCount 테스트") + class GetLikeCount { + + @Test + @DisplayName("성공 - 캐시 히트") + void 캐시_히트() { + // Arrange + Long productId = 1L; + likeCountCacheService.set(productId, 50L); + + // Act + long result = productLikeCountQueryService.getLikeCount(productId); + + // Assert + assertThat(result).isEqualTo(50L); + } + + @Test + @DisplayName("성공 - 캐시 미스 시 DB 조회 후 캐시 저장") + void 캐시_미스_DB_조회() { + // Arrange + Product product = createProduct(); + + // Act + long result = productLikeCountQueryService.getLikeCount(product.getId()); + + // Assert + assertThat(result).isEqualTo(0L); + // 캐시에 저장되었는지 확인 + assertThat(likeCountCacheService.get(product.getId())).isPresent(); + } + } + + @Nested + @DisplayName("getLikeCounts (다건 조회) 테스트") + class GetLikeCounts { + + @Test + @DisplayName("성공 - 캐시 히트와 미스 혼합") + void 캐시_히트_미스_혼합() { + // Arrange + Product product1 = createProduct(); + Product product2 = productRepository.save( + Product.create(1L, "테스트 상품2", "설명", + new Money(20000), new Stock(200), "http://image2.url") + ); + likeCountCacheService.set(product1.getId(), 10L); + // product2는 캐시에 없음 + + // Act + Map result = productLikeCountQueryService.getLikeCounts( + List.of(product1.getId(), product2.getId()) + ); + + // Assert + assertThat(result.get(product1.getId())).isEqualTo(10L); + assertThat(result.get(product2.getId())).isEqualTo(0L); + } + } + + @Nested + @DisplayName("Thundering Herd 방지 테스트") + class ThunderingHerdPrevention { + + @Test + @DisplayName("동시 캐시 미스 시 Single Flight 동작") + void 동시_캐시_미스_단일_DB조회() throws InterruptedException { + // Arrange + Product product = createProduct(); + int threadCount = 10; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch endLatch = new CountDownLatch(threadCount); + AtomicInteger resultCount = new AtomicInteger(0); + + // Act + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + startLatch.await(); + long count = productLikeCountQueryService.getLikeCount(product.getId()); + resultCount.incrementAndGet(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + endLatch.countDown(); + } + }); + } + + startLatch.countDown(); + endLatch.await(); + executor.shutdown(); + + // Assert + assertThat(resultCount.get()).isEqualTo(threadCount); + // 캐시에 값이 저장되어 있어야 함 + assertThat(likeCountCacheService.get(product.getId())).isPresent(); + } + } +} diff --git a/apps/commerce-api/src/test/resources/application-test.yml b/apps/commerce-api/src/test/resources/application-test.yml new file mode 100644 index 000000000..debb4afaa --- /dev/null +++ b/apps/commerce-api/src/test/resources/application-test.yml @@ -0,0 +1,7 @@ +spring: + config: + import: + - jpa.yml + - redis.yml + - logging.yml + - monitoring.yml diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/cache/LikeCountBatchCacheService.java b/apps/commerce-batch/src/main/java/com/loopers/batch/cache/LikeCountBatchCacheService.java new file mode 100644 index 000000000..107e03ce2 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/cache/LikeCountBatchCacheService.java @@ -0,0 +1,145 @@ +package com.loopers.batch.cache; + +import com.loopers.config.redis.RedisConfig; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +/** + * 배치 전용 좋아요 카운트 캐시 서비스. + * + *

API 모듈과 동일한 샤딩/버저닝 전략 사용

+ *
+ * 키 패턴: {product:{shardId}}:like:v{version}:{productId}
+ * TTL: 7일 (Long TTL + Overwrite)
+ * 
+ */ +@Slf4j +@Service +public class LikeCountBatchCacheService { + + private static final int SHARD_COUNT = 16; + private static final Duration DEFAULT_TTL = Duration.ofDays(7); + + private final RedisTemplate readTemplate; + private final RedisTemplate writeTemplate; + private final int cacheVersion; + + public LikeCountBatchCacheService( + RedisTemplate readTemplate, + @Qualifier(RedisConfig.REDIS_TEMPLATE_MASTER) RedisTemplate writeTemplate, + @Value("${cache.like-count.version:1}") int cacheVersion + ) { + this.readTemplate = readTemplate; + this.writeTemplate = writeTemplate; + this.cacheVersion = cacheVersion; + } + + /** + * dirty 상품 ID 목록 조회. + * + * @return dirty 상품 ID Set + */ + public Set getDirtyProductIds() { + try { + String dirtyKey = buildDirtySetKey(); + Set members = readTemplate.opsForSet().members(dirtyKey); + if (members == null || members.isEmpty()) { + return Set.of(); + } + Set result = new HashSet<>(); + for (String member : members) { + result.add(Long.parseLong(member)); + } + return result; + } catch (Exception e) { + log.warn("Redis getDirtyProductIds 실패", e); + return Set.of(); + } + } + + /** + * 좋아요 수 덮어쓰기 (Overwrite). + * DB 동기화 결과를 캐시에 저장. + * + * @param productId 상품 ID + * @param count 좋아요 수 + */ + public void set(Long productId, Long count) { + try { + String key = buildKey(productId); + writeTemplate.opsForValue().set(key, String.valueOf(count), DEFAULT_TTL); + } catch (Exception e) { + log.warn("Redis set 실패: productId={}, count={}", productId, count, e); + } + } + + /** + * 여러 상품의 좋아요 수 일괄 덮어쓰기. + * + * @param counts 상품 ID → 좋아요 수 Map + */ + public void setMultiple(Map counts) { + if (counts == null || counts.isEmpty()) { + return; + } + + counts.forEach((productId, count) -> { + try { + String key = buildKey(productId); + writeTemplate.opsForValue().set(key, String.valueOf(count), DEFAULT_TTL); + } catch (Exception e) { + log.warn("Redis set 실패: productId={}, count={}", productId, count, e); + } + }); + } + + /** + * 특정 상품을 dirty set에서 제거. + * + * @param productId 상품 ID + */ + public void removeDirty(Long productId) { + try { + String dirtyKey = buildDirtySetKey(); + writeTemplate.opsForSet().remove(dirtyKey, String.valueOf(productId)); + } catch (Exception e) { + log.warn("Redis removeDirty 실패: productId={}", productId, e); + } + } + + /** + * dirty set 전체 삭제. + */ + public void clearDirtyProductIds() { + try { + String dirtyKey = buildDirtySetKey(); + writeTemplate.delete(dirtyKey); + } catch (Exception e) { + log.warn("Redis clearDirtyProductIds 실패", e); + } + } + + /** + * 샤딩된 키 생성. + * API 모듈과 동일한 패턴 사용. + */ + private String buildKey(Long productId) { + int shardId = (int) (productId % SHARD_COUNT); + return String.format("{product:%d}:like:v%d:%d", shardId, cacheVersion, productId); + } + + /** + * dirty set 키 생성. + */ + private String buildDirtySetKey() { + return String.format("{product:dirty}:like:v%d", cacheVersion); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/config/BatchJpaConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/config/BatchJpaConfig.java new file mode 100644 index 000000000..a80396703 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/config/BatchJpaConfig.java @@ -0,0 +1,15 @@ +package com.loopers.batch.config; + +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +/** + * 배치 전용 JPA 설정. + * 배치 모듈의 Entity와 Repository를 스캔. + */ +@Configuration +@EntityScan(basePackages = "com.loopers.batch.persistence") +@EnableJpaRepositories(basePackages = "com.loopers.batch.persistence") +public class BatchJpaConfig { +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/likesync/LikeCountSyncJobConfig.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/likesync/LikeCountSyncJobConfig.java new file mode 100644 index 000000000..f8a27f148 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/likesync/LikeCountSyncJobConfig.java @@ -0,0 +1,54 @@ +package com.loopers.batch.job.likesync; + +import com.loopers.batch.job.likesync.step.LikeCountSyncTasklet; +import com.loopers.batch.listener.JobListener; +import com.loopers.batch.listener.StepMonitorListener; +import lombok.RequiredArgsConstructor; +import org.springframework.batch.core.Job; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.configuration.annotation.JobScope; +import org.springframework.batch.core.job.builder.JobBuilder; +import org.springframework.batch.core.launch.support.RunIdIncrementer; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.transaction.PlatformTransactionManager; + +/** + * 좋아요 카운트 동기화 Job 설정. + * Redis 캐시 카운터 → products.like_count 컬럼으로 동기화. + */ +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = LikeCountSyncJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Configuration +public class LikeCountSyncJobConfig { + + public static final String JOB_NAME = "likeCountSyncJob"; + private static final String STEP_SYNC_NAME = "likeCountSyncStep"; + + private final JobRepository jobRepository; + private final JobListener jobListener; + private final StepMonitorListener stepMonitorListener; + private final LikeCountSyncTasklet likeCountSyncTasklet; + private final PlatformTransactionManager transactionManager; + + @Bean(JOB_NAME) + public Job likeCountSyncJob() { + return new JobBuilder(JOB_NAME, jobRepository) + .incrementer(new RunIdIncrementer()) + .start(likeCountSyncStep()) + .listener(jobListener) + .build(); + } + + @JobScope + @Bean(STEP_SYNC_NAME) + public Step likeCountSyncStep() { + return new StepBuilder(STEP_SYNC_NAME, jobRepository) + .tasklet(likeCountSyncTasklet, transactionManager) + .listener(stepMonitorListener) + .build(); + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/job/likesync/step/LikeCountSyncTasklet.java b/apps/commerce-batch/src/main/java/com/loopers/batch/job/likesync/step/LikeCountSyncTasklet.java new file mode 100644 index 000000000..49c9e3d27 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/job/likesync/step/LikeCountSyncTasklet.java @@ -0,0 +1,82 @@ +package com.loopers.batch.job.likesync.step; + +import com.loopers.batch.cache.LikeCountBatchCacheService; +import com.loopers.batch.job.likesync.LikeCountSyncJobConfig; +import com.loopers.batch.persistence.LikeBatchRepository; +import com.loopers.batch.persistence.ProductBatchRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.StepContribution; +import org.springframework.batch.core.configuration.annotation.StepScope; +import org.springframework.batch.core.scope.context.ChunkContext; +import org.springframework.batch.core.step.tasklet.Tasklet; +import org.springframework.batch.repeat.RepeatStatus; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.util.Set; + +/** + * 좋아요 카운트 동기화 Tasklet. + * Redis dirty set에 등록된 상품들의 좋아요 수를 DB로 동기화. + */ +@Slf4j +@StepScope +@ConditionalOnProperty(name = "spring.batch.job.name", havingValue = LikeCountSyncJobConfig.JOB_NAME) +@RequiredArgsConstructor +@Component +public class LikeCountSyncTasklet implements Tasklet { + + private final LikeCountBatchCacheService likeCountCacheService; + private final LikeBatchRepository likeRepository; + private final ProductBatchRepository productRepository; + + @Override + public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) { + log.info("좋아요 카운트 동기화 시작"); + + Set dirtyProductIds = likeCountCacheService.getDirtyProductIds(); + + if (dirtyProductIds.isEmpty()) { + log.info("동기화할 상품이 없습니다."); + return RepeatStatus.FINISHED; + } + + log.info("동기화 대상 상품 수: {}", dirtyProductIds.size()); + + int successCount = 0; + int failCount = 0; + + for (Long productId : dirtyProductIds) { + try { + syncProductLikeCount(productId); + successCount++; + } catch (Exception e) { + log.error("상품 좋아요 수 동기화 실패: productId={}", productId, e); + failCount++; + } + } + + log.info("좋아요 카운트 동기화 완료 - 성공: {}, 실패: {}", successCount, failCount); + + for (int i = 0; i < successCount; i++) { + contribution.incrementWriteCount(1); + } + + return RepeatStatus.FINISHED; + } + + private void syncProductLikeCount(Long productId) { + long actualCount = likeRepository.countByProductId(productId); + + int updated = productRepository.updateLikeCount(productId, actualCount); + + if (updated > 0) { + likeCountCacheService.set(productId, actualCount); + likeCountCacheService.removeDirty(productId); + log.debug("상품 좋아요 수 동기화: productId={}, count={}", productId, actualCount); + } else { + log.warn("상품이 존재하지 않음: productId={}", productId); + } + } +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/persistence/LikeBatchRepository.java b/apps/commerce-batch/src/main/java/com/loopers/batch/persistence/LikeBatchRepository.java new file mode 100644 index 000000000..1b32d49e9 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/persistence/LikeBatchRepository.java @@ -0,0 +1,11 @@ +package com.loopers.batch.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; + +/** + * 배치 전용 Like Repository. + */ +public interface LikeBatchRepository extends JpaRepository { + + long countByProductId(Long productId); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/persistence/LikeEntity.java b/apps/commerce-batch/src/main/java/com/loopers/batch/persistence/LikeEntity.java new file mode 100644 index 000000000..69c10e624 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/persistence/LikeEntity.java @@ -0,0 +1,39 @@ +package com.loopers.batch.persistence; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.ZonedDateTime; + +/** + * 배치 전용 Like Entity. + * 좋아요 수 카운팅에 필요한 필드만 포함. + */ +@Entity +@Table(name = "likes", indexes = { + @Index(name = "idx_likes_product_id", columnList = "product_id") +}) +@Getter +@NoArgsConstructor +public class LikeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "product_id", nullable = false) + private Long productId; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/persistence/ProductBatchRepository.java b/apps/commerce-batch/src/main/java/com/loopers/batch/persistence/ProductBatchRepository.java new file mode 100644 index 000000000..db0501b56 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/persistence/ProductBatchRepository.java @@ -0,0 +1,16 @@ +package com.loopers.batch.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +/** + * 배치 전용 Product Repository. + */ +public interface ProductBatchRepository extends JpaRepository { + + @Modifying + @Query("UPDATE ProductEntity p SET p.likeCount = :likeCount WHERE p.id = :productId") + int updateLikeCount(@Param("productId") Long productId, @Param("likeCount") Long likeCount); +} diff --git a/apps/commerce-batch/src/main/java/com/loopers/batch/persistence/ProductEntity.java b/apps/commerce-batch/src/main/java/com/loopers/batch/persistence/ProductEntity.java new file mode 100644 index 000000000..125ab5846 --- /dev/null +++ b/apps/commerce-batch/src/main/java/com/loopers/batch/persistence/ProductEntity.java @@ -0,0 +1,35 @@ +package com.loopers.batch.persistence; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.ZonedDateTime; + +/** + * 배치 전용 Product Entity. + * 좋아요 수 동기화에 필요한 필드만 포함. + */ +@Entity +@Table(name = "products") +@Getter +@Setter +@NoArgsConstructor +public class ProductEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "like_count", nullable = false) + private Long likeCount = 0L; + + @Column(name = "deleted_at") + private ZonedDateTime deletedAt; +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java index c5e3bc7a3..c3f13209c 100644 --- a/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java +++ b/apps/commerce-batch/src/test/java/com/loopers/CommerceBatchApplicationTest.java @@ -1,9 +1,15 @@ package com.loopers; +import com.loopers.batch.job.demo.DemoJobConfig; +import com.loopers.config.TestRedisConfiguration; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; @SpringBootTest +@Import(TestRedisConfiguration.class) +@TestPropertySource(properties = "spring.batch.job.name=" + DemoJobConfig.JOB_NAME) public class CommerceBatchApplicationTest { @Test void contextLoads() {} diff --git a/apps/commerce-batch/src/test/java/com/loopers/config/TestRedisConfiguration.java b/apps/commerce-batch/src/test/java/com/loopers/config/TestRedisConfiguration.java new file mode 100644 index 000000000..459231d02 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/config/TestRedisConfiguration.java @@ -0,0 +1,39 @@ +package com.loopers.config; + +import com.loopers.config.redis.RedisNodeInfo; +import com.loopers.config.redis.RedisProperties; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import java.util.List; + +@TestConfiguration(proxyBeanMethods = false) +public class TestRedisConfiguration { + + private static final int REDIS_PORT = 6379; + private static final DockerImageName REDIS_IMAGE = DockerImageName.parse("redis:7.0"); + + @SuppressWarnings("resource") + private static final GenericContainer REDIS_CONTAINER = new GenericContainer<>(REDIS_IMAGE) + .withExposedPorts(REDIS_PORT) + .withReuse(true); + + static { + REDIS_CONTAINER.start(); + } + + @Bean + @Primary + public RedisProperties testRedisProperties() { + String host = REDIS_CONTAINER.getHost(); + int port = REDIS_CONTAINER.getMappedPort(REDIS_PORT); + + RedisNodeInfo master = new RedisNodeInfo(host, port); + List replicas = List.of(new RedisNodeInfo(host, port)); + + return new RedisProperties(0, master, replicas); + } +} diff --git a/apps/commerce-batch/src/test/java/com/loopers/job/likesync/LikeCountSyncJobE2ETest.java b/apps/commerce-batch/src/test/java/com/loopers/job/likesync/LikeCountSyncJobE2ETest.java new file mode 100644 index 000000000..26d1bcb67 --- /dev/null +++ b/apps/commerce-batch/src/test/java/com/loopers/job/likesync/LikeCountSyncJobE2ETest.java @@ -0,0 +1,125 @@ +package com.loopers.job.likesync; + +import com.loopers.batch.cache.LikeCountBatchCacheService; +import com.loopers.batch.job.likesync.LikeCountSyncJobConfig; +import com.loopers.batch.persistence.LikeBatchRepository; +import com.loopers.batch.persistence.LikeEntity; +import com.loopers.batch.persistence.ProductBatchRepository; +import com.loopers.batch.persistence.ProductEntity; +import com.loopers.config.TestRedisConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.batch.core.ExitStatus; +import org.springframework.batch.core.Job; +import org.springframework.batch.test.JobLauncherTestUtils; +import org.springframework.batch.test.context.SpringBatchTest; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.TestPropertySource; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@SpringBatchTest +@Import(TestRedisConfiguration.class) +@TestPropertySource(properties = "spring.batch.job.name=" + LikeCountSyncJobConfig.JOB_NAME) +@DisplayName("LikeCountSyncJob E2E 테스트") +class LikeCountSyncJobE2ETest { + + @Autowired + private JobLauncherTestUtils jobLauncherTestUtils; + + @Autowired + @Qualifier(LikeCountSyncJobConfig.JOB_NAME) + private Job job; + + @Autowired + private ProductBatchRepository productRepository; + + @Autowired + private LikeBatchRepository likeRepository; + + @Autowired + private LikeCountBatchCacheService likeCountCacheService; + + @Autowired + private RedisTemplate redisTemplate; + + private static final String DIRTY_SET_KEY = "like:dirty:products"; + + @BeforeEach + void setUp() { + cleanUpRedis(); + cleanUpDatabase(); + } + + @AfterEach + void tearDown() { + cleanUpRedis(); + cleanUpDatabase(); + } + + private void cleanUpRedis() { + var keys = redisTemplate.keys("like:*"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } + + private void cleanUpDatabase() { + likeRepository.deleteAll(); + productRepository.deleteAll(); + } + + @Test + @DisplayName("동기화할 상품이 없을 때 정상 완료") + void 동기화_대상_없음() throws Exception { + // Arrange + jobLauncherTestUtils.setJob(job); + + // Act + var jobExecution = jobLauncherTestUtils.launchJob(); + + // Assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + } + + @Test + @DisplayName("dirty 상품의 좋아요 수를 DB에 동기화") + void 좋아요_수_동기화() throws Exception { + // Arrange + ProductEntity product = new ProductEntity(); + product.setLikeCount(0L); + productRepository.save(product); + + Long productId = product.getId(); + + // dirty set에 상품 추가 + redisTemplate.opsForSet().add(DIRTY_SET_KEY, String.valueOf(productId)); + + jobLauncherTestUtils.setJob(job); + + // Act + var jobExecution = jobLauncherTestUtils.launchJob(); + + // Assert + assertThat(jobExecution.getExitStatus().getExitCode()) + .isEqualTo(ExitStatus.COMPLETED.getExitCode()); + + // DB의 like_count가 0으로 설정되어 있어야 함 (좋아요 없음) + Optional updated = productRepository.findById(productId); + assertThat(updated).isPresent(); + assertThat(updated.get().getLikeCount()).isEqualTo(0L); + + // dirty set에서 제거되었는지 확인 + assertThat(likeCountCacheService.getDirtyProductIds()).doesNotContain(productId); + } +} diff --git a/http/like.http b/http/like.http new file mode 100644 index 000000000..d6807697b --- /dev/null +++ b/http/like.http @@ -0,0 +1,25 @@ +### 좋아요 등록 +POST http://localhost:8080/api/v1/products/1/likes +Content-Type: application/json +X-User-Id: 1 + +### + +### 좋아요 취소 +DELETE http://localhost:8080/api/v1/products/1/likes +Content-Type: application/json +X-User-Id: 1 + +### + +### 상품 조회 (좋아요 수 포함) +GET http://localhost:8080/api/v1/products/1 +Content-Type: application/json + +### + +### 상품 목록 조회 (좋아요 순 정렬) +GET http://localhost:8080/api/v1/products?sort=LIKE_COUNT_DESC&page=0&size=10 +Content-Type: application/json + +### diff --git a/modules/redis/src/main/resources/redis.yml b/modules/redis/src/main/resources/redis.yml index 10a260463..6e587e2d3 100644 --- a/modules/redis/src/main/resources/redis.yml +++ b/modules/redis/src/main/resources/redis.yml @@ -4,6 +4,10 @@ spring: repositories: enabled: false +cache: + like-count: + version: 1 + datasource: redis: database: 0 diff --git a/scripts/benchmark/PERFORMANCE-ANALYSIS.md b/scripts/benchmark/PERFORMANCE-ANALYSIS.md new file mode 100644 index 000000000..a12776a98 --- /dev/null +++ b/scripts/benchmark/PERFORMANCE-ANALYSIS.md @@ -0,0 +1,196 @@ +# 인덱스 성능 분석 + +## 테스트 환경 + +- MySQL 8.0 InnoDB +- 상품 수: 10만 / 20만 / 50만 개 +- 50개 브랜드에 균등 분배 +- like_count: Power Law 분포 (상위 1%가 1000~10000) + +## 인덱스 정의 + +```sql +CREATE INDEX idx_products_like_count ON products (like_count DESC, created_at DESC); +CREATE INDEX idx_products_brand_id ON products (brand_id); +CREATE INDEX idx_products_brand_like ON products (brand_id, like_count DESC); +CREATE INDEX idx_products_brand_price ON products (brand_id, price ASC); +``` + +--- + +## 예상 성능 비교 + +### Test 1: 좋아요 순 정렬 (전체) + +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +| 상품 수 | 인덱스 없음 | 인덱스 있음 | 개선율 | +|---------|-------------|-------------|--------| +| 10만 | ~150ms | ~1ms | **150x** | +| 20만 | ~300ms | ~1ms | **300x** | +| 50만 | ~800ms | ~1ms | **800x** | + +**분석:** +- 인덱스 없음: Full Table Scan + Filesort (O(n log n)) +- 인덱스 있음: Index Scan (O(limit)) + +**EXPLAIN 비교:** +``` +-- Without Index +type: ALL, rows: 500000, Extra: Using where; Using filesort + +-- With Index +type: index, key: idx_products_like_count, rows: 20 +``` + +--- + +### Test 2: 브랜드별 조회 + +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL LIMIT 20 +``` + +| 상품 수 | 인덱스 없음 | 인덱스 있음 | 개선율 | +|---------|-------------|-------------|--------| +| 10만 | ~80ms | ~2ms | **40x** | +| 20만 | ~160ms | ~2ms | **80x** | +| 50만 | ~400ms | ~2ms | **200x** | + +**분석:** +- 인덱스 없음: Full Table Scan (O(n)) +- 인덱스 있음: Index Lookup (O(log n + limit)) +- 브랜드당 상품 수: 2000~4000개 + +--- + +### Test 3: 브랜드별 + 좋아요 순 + +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 +``` + +| 상품 수 | 인덱스 없음 | 인덱스 있음 | 개선율 | +|---------|-------------|-------------|--------| +| 10만 | ~100ms | ~2ms | **50x** | +| 20만 | ~200ms | ~2ms | **100x** | +| 50만 | ~500ms | ~2ms | **250x** | + +**분석:** +- 인덱스 없음: Index on brand_id + Filesort +- 인덱스 있음: Covering Index (brand_id, like_count) +- **Filesort 제거**가 핵심 + +**EXPLAIN 비교:** +``` +-- Without idx_products_brand_like (only brand_id index) +type: ref, key: idx_products_brand_id, Extra: Using where; Using filesort + +-- With idx_products_brand_like +type: ref, key: idx_products_brand_like, rows: 20 (no filesort!) +``` + +--- + +### Test 4: 브랜드별 + 가격순 + +```sql +SELECT * FROM products WHERE brand_id = 1 AND deleted_at IS NULL ORDER BY price ASC LIMIT 20 +``` + +| 상품 수 | 인덱스 없음 | 인덱스 있음 | 개선율 | +|---------|-------------|-------------|--------| +| 10만 | ~100ms | ~2ms | **50x** | +| 20만 | ~200ms | ~2ms | **100x** | +| 50만 | ~500ms | ~2ms | **250x** | + +--- + +### Test 5: 깊은 페이지네이션 + +```sql +SELECT * FROM products WHERE deleted_at IS NULL ORDER BY like_count DESC LIMIT 20 OFFSET 10000 +``` + +| 상품 수 | 인덱스 없음 | 인덱스 있음 | 개선율 | +|---------|-------------|-------------|--------| +| 10만 | ~200ms | ~50ms | **4x** | +| 20만 | ~400ms | ~50ms | **8x** | +| 50만 | ~1000ms | ~50ms | **20x** | + +**주의:** OFFSET이 크면 인덱스 있어도 성능 저하 +- **권장:** Keyset Pagination (WHERE id > last_id) + +--- + +### Test 6: COUNT 쿼리 + +```sql +SELECT COUNT(*) FROM products WHERE brand_id = 1 AND deleted_at IS NULL +``` + +| 상품 수 | 인덱스 없음 | 인덱스 있음 | 개선율 | +|---------|-------------|-------------|--------| +| 10만 | ~80ms | ~5ms | **16x** | +| 20만 | ~160ms | ~5ms | **32x** | +| 50만 | ~400ms | ~5ms | **80x** | + +--- + +## 인덱스 크기 영향 + +| 상품 수 | 테이블 크기 | 인덱스 크기 (4개) | 총 크기 | +|---------|-------------|-------------------|---------| +| 10만 | ~50MB | ~15MB | ~65MB | +| 20만 | ~100MB | ~30MB | ~130MB | +| 50만 | ~250MB | ~75MB | ~325MB | + +--- + +## 권장사항 + +### 1. 필수 인덱스 (3개) + +```sql +-- 좋아요 순 정렬용 (가장 중요) +CREATE INDEX idx_products_like_count ON products (like_count DESC, created_at DESC); + +-- 브랜드별 + 좋아요 순 복합 인덱스 +CREATE INDEX idx_products_brand_like ON products (brand_id, like_count DESC); + +-- 브랜드별 + 가격순 복합 인덱스 +CREATE INDEX idx_products_brand_price ON products (brand_id, price ASC); +``` + +### 2. 제거 가능 인덱스 + +```sql +-- brand_id 단독 인덱스 (복합 인덱스로 커버됨) +-- idx_products_brand_id는 idx_products_brand_like가 대체 +``` + +### 3. 깊은 페이지네이션 최적화 + +```sql +-- OFFSET 대신 Keyset Pagination +SELECT * FROM products +WHERE deleted_at IS NULL + AND (like_count, created_at, id) < (?, ?, ?) -- 마지막 행 기준 +ORDER BY like_count DESC, created_at DESC, id DESC +LIMIT 20 +``` + +--- + +## 실행 방법 + +```bash +# Docker MySQL 실행 +docker-compose -f docker/docker-compose.yml up -d mysql + +# 벤치마크 실행 +chmod +x scripts/benchmark/run-benchmark.sh +./scripts/benchmark/run-benchmark.sh +``` diff --git a/scripts/benchmark/index-performance-test.sql b/scripts/benchmark/index-performance-test.sql new file mode 100644 index 000000000..3ce7981ae --- /dev/null +++ b/scripts/benchmark/index-performance-test.sql @@ -0,0 +1,298 @@ +-- ============================================================================= +-- 인덱스 성능 벤치마크 테스트 +-- 상품 수: 10만, 20만, 50만 개 기준 측정 +-- ============================================================================= + +-- 설정 +SET @test_brand_id = 1; +SET @page_size = 20; + +-- ============================================================================= +-- 1. 테스트 데이터 생성 프로시저 +-- ============================================================================= +DROP PROCEDURE IF EXISTS create_test_products; +DELIMITER // +CREATE PROCEDURE create_test_products(IN product_count INT) +BEGIN + DECLARE i INT DEFAULT 1; + DECLARE batch_size INT DEFAULT 5000; + DECLARE batch_count INT DEFAULT 0; + DECLARE brand_count INT DEFAULT 50; + + SET FOREIGN_KEY_CHECKS = 0; + SET autocommit = 0; + SET unique_checks = 0; + + -- 기존 데이터 삭제 + TRUNCATE TABLE products; + + WHILE i <= product_count DO + INSERT INTO products (brand_id, name, description, price, stock, image_url, like_count, created_at, updated_at, deleted_at) + VALUES ( + (i % brand_count) + 1, + CONCAT('Product-', i), + CONCAT('Description for product ', i), + FLOOR(1000 + RAND() * 500000), + FLOOR(RAND() * 1000), + CONCAT('https://cdn.example.com/products/', i, '.jpg'), + -- like_count: Power law 분포 (상위 1%가 높은 좋아요) + CASE + WHEN RAND() < 0.01 THEN FLOOR(1000 + RAND() * 9000) -- 상위 1%: 1000-10000 + WHEN RAND() < 0.10 THEN FLOOR(100 + RAND() * 900) -- 상위 10%: 100-1000 + WHEN RAND() < 0.40 THEN FLOOR(10 + RAND() * 90) -- 상위 40%: 10-100 + ELSE FLOOR(RAND() * 10) -- 나머지: 0-10 + END, + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 730) DAY), + NOW(), + NULL + ); + + SET batch_count = batch_count + 1; + IF batch_count >= batch_size THEN + COMMIT; + SET batch_count = 0; + END IF; + + SET i = i + 1; + END WHILE; + + COMMIT; + + SET FOREIGN_KEY_CHECKS = 1; + SET autocommit = 1; + SET unique_checks = 1; +END // +DELIMITER ; + +-- ============================================================================= +-- 2. 벤치마크 실행 프로시저 +-- ============================================================================= +DROP PROCEDURE IF EXISTS run_benchmark; +DELIMITER // +CREATE PROCEDURE run_benchmark(IN test_name VARCHAR(100)) +BEGIN + DECLARE start_time DATETIME(6); + DECLARE end_time DATETIME(6); + DECLARE exec_time_ms DECIMAL(10,3); + DECLARE row_count INT; + + SELECT COUNT(*) INTO row_count FROM products; + + SELECT CONCAT('=== ', test_name, ' (', row_count, ' rows) ===') AS benchmark; + + -- --------------------------------------------------------- + -- Test 1: 좋아요 순 정렬 (전체) + -- --------------------------------------------------------- + SET start_time = NOW(6); + + SELECT SQL_NO_CACHE id, name, like_count + FROM products + WHERE deleted_at IS NULL + ORDER BY like_count DESC, created_at DESC + LIMIT 20; + + SET end_time = NOW(6); + SET exec_time_ms = TIMESTAMPDIFF(MICROSECOND, start_time, end_time) / 1000; + SELECT 'Test 1: ORDER BY like_count DESC LIMIT 20' AS test, exec_time_ms AS 'time_ms'; + + EXPLAIN SELECT SQL_NO_CACHE id, name, like_count + FROM products + WHERE deleted_at IS NULL + ORDER BY like_count DESC, created_at DESC + LIMIT 20; + + -- --------------------------------------------------------- + -- Test 2: 브랜드별 조회 + -- --------------------------------------------------------- + SET start_time = NOW(6); + + SELECT SQL_NO_CACHE id, name, price + FROM products + WHERE brand_id = @test_brand_id AND deleted_at IS NULL + LIMIT 20; + + SET end_time = NOW(6); + SET exec_time_ms = TIMESTAMPDIFF(MICROSECOND, start_time, end_time) / 1000; + SELECT 'Test 2: WHERE brand_id = ? LIMIT 20' AS test, exec_time_ms AS 'time_ms'; + + EXPLAIN SELECT SQL_NO_CACHE id, name, price + FROM products + WHERE brand_id = @test_brand_id AND deleted_at IS NULL + LIMIT 20; + + -- --------------------------------------------------------- + -- Test 3: 브랜드별 + 좋아요 순 + -- --------------------------------------------------------- + SET start_time = NOW(6); + + SELECT SQL_NO_CACHE id, name, like_count + FROM products + WHERE brand_id = @test_brand_id AND deleted_at IS NULL + ORDER BY like_count DESC + LIMIT 20; + + SET end_time = NOW(6); + SET exec_time_ms = TIMESTAMPDIFF(MICROSECOND, start_time, end_time) / 1000; + SELECT 'Test 3: WHERE brand_id = ? ORDER BY like_count DESC LIMIT 20' AS test, exec_time_ms AS 'time_ms'; + + EXPLAIN SELECT SQL_NO_CACHE id, name, like_count + FROM products + WHERE brand_id = @test_brand_id AND deleted_at IS NULL + ORDER BY like_count DESC + LIMIT 20; + + -- --------------------------------------------------------- + -- Test 4: 브랜드별 + 가격순 + -- --------------------------------------------------------- + SET start_time = NOW(6); + + SELECT SQL_NO_CACHE id, name, price + FROM products + WHERE brand_id = @test_brand_id AND deleted_at IS NULL + ORDER BY price ASC + LIMIT 20; + + SET end_time = NOW(6); + SET exec_time_ms = TIMESTAMPDIFF(MICROSECOND, start_time, end_time) / 1000; + SELECT 'Test 4: WHERE brand_id = ? ORDER BY price ASC LIMIT 20' AS test, exec_time_ms AS 'time_ms'; + + EXPLAIN SELECT SQL_NO_CACHE id, name, price + FROM products + WHERE brand_id = @test_brand_id AND deleted_at IS NULL + ORDER BY price ASC + LIMIT 20; + + -- --------------------------------------------------------- + -- Test 5: 페이지네이션 (깊은 페이지) + -- --------------------------------------------------------- + SET start_time = NOW(6); + + SELECT SQL_NO_CACHE id, name, like_count + FROM products + WHERE deleted_at IS NULL + ORDER BY like_count DESC, created_at DESC + LIMIT 20 OFFSET 10000; + + SET end_time = NOW(6); + SET exec_time_ms = TIMESTAMPDIFF(MICROSECOND, start_time, end_time) / 1000; + SELECT 'Test 5: ORDER BY like_count DESC LIMIT 20 OFFSET 10000' AS test, exec_time_ms AS 'time_ms'; + + EXPLAIN SELECT SQL_NO_CACHE id, name, like_count + FROM products + WHERE deleted_at IS NULL + ORDER BY like_count DESC, created_at DESC + LIMIT 20 OFFSET 10000; + + -- --------------------------------------------------------- + -- Test 6: COUNT 쿼리 + -- --------------------------------------------------------- + SET start_time = NOW(6); + + SELECT SQL_NO_CACHE COUNT(*) + FROM products + WHERE brand_id = @test_brand_id AND deleted_at IS NULL; + + SET end_time = NOW(6); + SET exec_time_ms = TIMESTAMPDIFF(MICROSECOND, start_time, end_time) / 1000; + SELECT 'Test 6: COUNT(*) WHERE brand_id = ?' AS test, exec_time_ms AS 'time_ms'; + + EXPLAIN SELECT SQL_NO_CACHE COUNT(*) + FROM products + WHERE brand_id = @test_brand_id AND deleted_at IS NULL; + +END // +DELIMITER ; + +-- ============================================================================= +-- 3. 인덱스 없이 테스트 +-- ============================================================================= +DROP PROCEDURE IF EXISTS test_without_indexes; +DELIMITER // +CREATE PROCEDURE test_without_indexes() +BEGIN + DECLARE CONTINUE HANDLER FOR 1091 BEGIN END; -- Index does not exist + + -- 인덱스 제거 (존재하면 삭제) + ALTER TABLE products DROP INDEX idx_products_like_count; + ALTER TABLE products DROP INDEX idx_products_brand_id; + ALTER TABLE products DROP INDEX idx_products_brand_like; + ALTER TABLE products DROP INDEX idx_products_brand_price; + + SELECT '>>> WITHOUT INDEXES <<<' AS mode; + SHOW INDEX FROM products; + + CALL run_benchmark('Without Indexes'); +END // +DELIMITER ; + +-- ============================================================================= +-- 4. 인덱스 있는 상태로 테스트 +-- ============================================================================= +DROP PROCEDURE IF EXISTS test_with_indexes; +DELIMITER // +CREATE PROCEDURE test_with_indexes() +BEGIN + DECLARE CONTINUE HANDLER FOR 1061 BEGIN END; -- Duplicate key name + + -- 인덱스 생성 (없으면 생성) + CREATE INDEX idx_products_like_count ON products (like_count DESC, created_at DESC); + CREATE INDEX idx_products_brand_id ON products (brand_id); + CREATE INDEX idx_products_brand_like ON products (brand_id, like_count DESC); + CREATE INDEX idx_products_brand_price ON products (brand_id, price ASC); + + -- 인덱스 통계 갱신 + ANALYZE TABLE products; + + SELECT '>>> WITH INDEXES <<<' AS mode; + SHOW INDEX FROM products; + + CALL run_benchmark('With Indexes'); +END // +DELIMITER ; + +-- ============================================================================= +-- 5. 전체 벤치마크 실행 +-- ============================================================================= +DROP PROCEDURE IF EXISTS run_full_benchmark; +DELIMITER // +CREATE PROCEDURE run_full_benchmark() +BEGIN + -- 10만 개 테스트 + SELECT '############################################' AS sep; + SELECT '### 100,000 Products ###' AS test_case; + SELECT '############################################' AS sep; + CALL create_test_products(100000); + CALL test_without_indexes(); + CALL test_with_indexes(); + + -- 20만 개 테스트 + SELECT '############################################' AS sep; + SELECT '### 200,000 Products ###' AS test_case; + SELECT '############################################' AS sep; + CALL create_test_products(200000); + CALL test_without_indexes(); + CALL test_with_indexes(); + + -- 50만 개 테스트 + SELECT '############################################' AS sep; + SELECT '### 500,000 Products ###' AS test_case; + SELECT '############################################' AS sep; + CALL create_test_products(500000); + CALL test_without_indexes(); + CALL test_with_indexes(); + + SELECT 'Benchmark Complete!' AS result; +END // +DELIMITER ; + +-- ============================================================================= +-- 실행 방법 +-- ============================================================================= +-- 전체 벤치마크 (10만/20만/50만): +-- CALL run_full_benchmark(); +-- +-- 개별 테스트: +-- CALL create_test_products(100000); +-- CALL test_without_indexes(); +-- CALL test_with_indexes(); +-- ============================================================================= diff --git a/scripts/benchmark/run-benchmark.sh b/scripts/benchmark/run-benchmark.sh new file mode 100755 index 000000000..c9cd8e808 --- /dev/null +++ b/scripts/benchmark/run-benchmark.sh @@ -0,0 +1,42 @@ +#!/bin/bash +# ============================================================================= +# 인덱스 성능 벤치마크 실행 스크립트 +# ============================================================================= + +set -e + +# 설정 +MYSQL_HOST="${MYSQL_HOST:-localhost}" +MYSQL_PORT="${MYSQL_PORT:-3306}" +MYSQL_USER="${MYSQL_USER:-application}" +MYSQL_PASSWORD="${MYSQL_PASSWORD:-application}" +MYSQL_DATABASE="${MYSQL_DATABASE:-loopers}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SQL_FILE="$SCRIPT_DIR/index-performance-test.sql" +REPORT_FILE="$SCRIPT_DIR/benchmark-report-$(date +%Y%m%d-%H%M%S).txt" + +echo "=== 인덱스 성능 벤치마크 ===" +echo "Host: $MYSQL_HOST:$MYSQL_PORT" +echo "Database: $MYSQL_DATABASE" +echo "Report: $REPORT_FILE" +echo "" + +# Docker MySQL이 실행 중인지 확인 +if ! docker ps | grep -q mysql; then + echo "MySQL Docker 컨테이너가 실행 중이 아닙니다." + echo "docker-compose -f docker/infra-compose.yml up -d mysql 명령으로 시작하세요." + exit 1 +fi + +# SQL 파일 로드 및 벤치마크 실행 +echo "벤치마크 프로시저 로드 중..." +docker exec -i mysql mysql -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" < "$SQL_FILE" + +echo "벤치마크 실행 중... (약 5-10분 소요)" +docker exec -i mysql mysql -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" \ + -e "CALL run_full_benchmark();" 2>&1 | tee "$REPORT_FILE" + +echo "" +echo "=== 벤치마크 완료 ===" +echo "리포트 저장됨: $REPORT_FILE" diff --git a/scripts/insert-dummy-data.sql b/scripts/insert-dummy-data.sql new file mode 100644 index 000000000..266d7de81 --- /dev/null +++ b/scripts/insert-dummy-data.sql @@ -0,0 +1,451 @@ +-- ============================================================================= +-- Dummy Data Insert Script +-- User: 1,000, Brand: 5,000, Product: 200,000, Order: 100,000, Coupon: 50,000 +-- ============================================================================= + +SET @start_time = NOW(); +SELECT CONCAT('Script started at: ', @start_time) AS info; + +-- Disable foreign key checks and autocommit for faster inserts +SET FOREIGN_KEY_CHECKS = 0; +SET autocommit = 0; +SET unique_checks = 0; + +-- ============================================================================= +-- 1. USERS (1,000) +-- ============================================================================= +DROP PROCEDURE IF EXISTS insert_users; +DELIMITER // +CREATE PROCEDURE insert_users() +BEGIN + DECLARE i INT DEFAULT 1; + DECLARE batch_count INT DEFAULT 0; + DECLARE batch_size INT DEFAULT 500; + -- Pre-computed BCrypt hash for "Password1!" (cost=10) + DECLARE bcrypt_hash VARCHAR(100) DEFAULT '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZRGdjGj/n3.rSuBsB63rP6IbKd3XO'; + DECLARE birth_year INT; + DECLARE birth_month INT; + DECLARE birth_day INT; + DECLARE birth_date_str VARCHAR(8); + + WHILE i <= 1000 DO + -- Generate random birth date (1970-2005) + SET birth_year = 1970 + FLOOR(RAND() * 35); + SET birth_month = 1 + FLOOR(RAND() * 12); + SET birth_day = 1 + FLOOR(RAND() * 28); + SET birth_date_str = CONCAT( + LPAD(birth_year, 4, '0'), + LPAD(birth_month, 2, '0'), + LPAD(birth_day, 2, '0') + ); + + INSERT INTO users (login_id, password, name, birth_date, email, created_at, updated_at, deleted_at) + VALUES ( + CONCAT('user', LPAD(i, 6, '0')), + bcrypt_hash, + CONCAT('User', i), + birth_date_str, + CONCAT('user', i, '@example.com'), + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 730) DAY), + NOW(), + NULL + ); + + SET batch_count = batch_count + 1; + IF batch_count >= batch_size THEN + COMMIT; + SET batch_count = 0; + END IF; + + SET i = i + 1; + END WHILE; + + COMMIT; +END // +DELIMITER ; + +CALL insert_users(); +SELECT CONCAT('Users inserted: ', (SELECT COUNT(*) FROM users)) AS progress; + +-- ============================================================================= +-- 2. BRANDS (5,000) +-- ============================================================================= +DROP PROCEDURE IF EXISTS insert_brands; +DELIMITER // +CREATE PROCEDURE insert_brands() +BEGIN + DECLARE i INT DEFAULT 1; + DECLARE batch_count INT DEFAULT 0; + DECLARE batch_size INT DEFAULT 500; + + WHILE i <= 5000 DO + INSERT INTO brands (name, description, logo_url, created_at, updated_at, deleted_at) + VALUES ( + CONCAT('Brand-', LPAD(i, 4, '0')), + CONCAT('Brand ', i, ' - Premium quality products since ', 1950 + (i % 75)), + CONCAT('https://cdn.example.com/logos/brand-', i, '.png'), + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 365) DAY), + NOW(), + NULL + ); + + SET batch_count = batch_count + 1; + IF batch_count >= batch_size THEN + COMMIT; + SET batch_count = 0; + END IF; + + SET i = i + 1; + END WHILE; + + COMMIT; +END // +DELIMITER ; + +CALL insert_brands(); +SELECT CONCAT('Brands inserted: ', (SELECT COUNT(*) FROM brands)) AS progress; + +-- ============================================================================= +-- 3. PRODUCTS (200,000) - Distributed across 5000 brands +-- ============================================================================= +DROP PROCEDURE IF EXISTS insert_products; +DELIMITER // +CREATE PROCEDURE insert_products() +BEGIN + DECLARE i INT DEFAULT 1; + DECLARE brand_count INT DEFAULT 5000; + DECLARE batch_size INT DEFAULT 5000; + DECLARE batch_count INT DEFAULT 0; + + -- 200,000 products / 5,000 brands = 40 products per brand (average) + WHILE i <= 200000 DO + INSERT INTO products (brand_id, name, description, price, stock, image_url, like_count, created_at, updated_at, deleted_at) + VALUES ( + ((i - 1) % brand_count) + 1, + CONCAT('Product-', i), + CONCAT('High quality product. Item number ', i, '. Perfect for everyday use.'), + FLOOR(1000 + RAND() * 500000), + FLOOR(RAND() * 1000), + CONCAT('https://cdn.example.com/products/', i, '.jpg'), + 0, + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 730) DAY), + NOW(), + NULL + ); + + SET batch_count = batch_count + 1; + IF batch_count >= batch_size THEN + COMMIT; + SET batch_count = 0; + END IF; + + SET i = i + 1; + END WHILE; + + COMMIT; +END // +DELIMITER ; + +CALL insert_products(); +SELECT CONCAT('Products inserted: ', (SELECT COUNT(*) FROM products)) AS progress; + +-- ============================================================================= +-- 4. LIKES (variable per product for like_count variation) +-- ============================================================================= +DROP PROCEDURE IF EXISTS insert_likes; +DELIMITER // +CREATE PROCEDURE insert_likes() +BEGIN + DECLARE product_id INT DEFAULT 1; + DECLARE max_product_id INT; + DECLARE likes_count INT; + DECLARE user_id INT; + DECLARE j INT; + DECLARE batch_count INT DEFAULT 0; + DECLARE batch_size INT DEFAULT 10000; + + SELECT MAX(id) INTO max_product_id FROM products; + + WHILE product_id <= max_product_id DO + -- Like distribution: Power law (few products have many likes) + -- Top 1% products: 100-500 likes + -- Next 9% products: 20-100 likes + -- Next 30% products: 5-20 likes + -- Remaining 60%: 0-5 likes + + SET @rand_val = RAND(); + + IF @rand_val < 0.01 THEN + SET likes_count = FLOOR(100 + RAND() * 400); + ELSEIF @rand_val < 0.10 THEN + SET likes_count = FLOOR(20 + RAND() * 80); + ELSEIF @rand_val < 0.40 THEN + SET likes_count = FLOOR(5 + RAND() * 15); + ELSE + SET likes_count = FLOOR(RAND() * 5); + END IF; + + SET j = 1; + WHILE j <= likes_count DO + -- User IDs: 1 ~ 1000 (assuming 1k users) + SET user_id = FLOOR(1 + RAND() * 1000); + + -- Ignore duplicate key errors + INSERT IGNORE INTO likes (user_id, product_id, created_at) + VALUES ( + user_id, + product_id, + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 365) DAY) + ); + + SET batch_count = batch_count + 1; + IF batch_count >= batch_size THEN + COMMIT; + SET batch_count = 0; + END IF; + + SET j = j + 1; + END WHILE; + + SET product_id = product_id + 1; + END WHILE; + + COMMIT; +END // +DELIMITER ; + +CALL insert_likes(); +SELECT CONCAT('Likes inserted: ', (SELECT COUNT(*) FROM likes)) AS progress; + +-- Update products.like_count with actual counts +UPDATE products p +SET p.like_count = ( + SELECT COUNT(*) + FROM likes l + WHERE l.product_id = p.id +); +COMMIT; +SELECT 'Products like_count updated' AS progress; + +-- ============================================================================= +-- 5. COUPON_TEMPLATES (50,000) +-- ============================================================================= +DROP PROCEDURE IF EXISTS insert_coupons; +DELIMITER // +CREATE PROCEDURE insert_coupons() +BEGIN + DECLARE i INT DEFAULT 1; + DECLARE coupon_type VARCHAR(20); + DECLARE batch_count INT DEFAULT 0; + DECLARE batch_size INT DEFAULT 5000; + + WHILE i <= 50000 DO + -- 60% FIXED, 40% RATE + IF RAND() < 0.6 THEN + SET coupon_type = 'FIXED'; + + INSERT INTO coupon_templates ( + name, type, value, min_order_amount, max_discount_amount, + max_issue_count, issued_count, expired_at, created_at, updated_at, deleted_at + ) + VALUES ( + CONCAT('FixedCoupon-', i), + coupon_type, + -- Fixed discount: 1000, 2000, 3000, 5000, 10000 + ELT(FLOOR(1 + RAND() * 5), 1000, 2000, 3000, 5000, 10000), + -- Min order amount: 10000 ~ 100000 + FLOOR(10000 + RAND() * 90000), + NULL, + -- Max issue count: NULL (unlimited) or 100~10000 + IF(RAND() < 0.3, NULL, FLOOR(100 + RAND() * 9900)), + -- Issued count: 0 ~ max_issue_count/2 + FLOOR(RAND() * 500), + -- Expired at: 1 day ~ 365 days from now + DATE_ADD(NOW(), INTERVAL FLOOR(1 + RAND() * 365) DAY), + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 180) DAY), + NOW(), + NULL + ); + ELSE + SET coupon_type = 'RATE'; + + INSERT INTO coupon_templates ( + name, type, value, min_order_amount, max_discount_amount, + max_issue_count, issued_count, expired_at, created_at, updated_at, deleted_at + ) + VALUES ( + CONCAT('RateCoupon-', i), + coupon_type, + -- Rate discount: 5, 10, 15, 20, 30 (percent) + ELT(FLOOR(1 + RAND() * 5), 5, 10, 15, 20, 30), + -- Min order amount: 20000 ~ 200000 + FLOOR(20000 + RAND() * 180000), + -- Max discount amount: 5000 ~ 50000 + FLOOR(5000 + RAND() * 45000), + -- Max issue count: NULL (unlimited) or 100~10000 + IF(RAND() < 0.3, NULL, FLOOR(100 + RAND() * 9900)), + -- Issued count: 0 ~ 500 + FLOOR(RAND() * 500), + -- Expired at: 1 day ~ 365 days from now + DATE_ADD(NOW(), INTERVAL FLOOR(1 + RAND() * 365) DAY), + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 180) DAY), + NOW(), + NULL + ); + END IF; + + SET batch_count = batch_count + 1; + IF batch_count >= batch_size THEN + COMMIT; + SET batch_count = 0; + END IF; + + SET i = i + 1; + END WHILE; + + COMMIT; +END // +DELIMITER ; + +CALL insert_coupons(); +SELECT CONCAT('Coupons inserted: ', (SELECT COUNT(*) FROM coupon_templates)) AS progress; + +-- ============================================================================= +-- 6. ORDERS + ORDER_ITEMS (100,000 orders) +-- ============================================================================= +DROP PROCEDURE IF EXISTS insert_orders; +DELIMITER // +CREATE PROCEDURE insert_orders() +BEGIN + DECLARE i INT DEFAULT 1; + DECLARE order_id INT; + DECLARE user_id INT; + DECLARE items_count INT; + DECLARE j INT; + DECLARE product_id INT; + DECLARE product_name VARCHAR(200); + DECLARE product_price BIGINT; + DECLARE item_quantity INT; + DECLARE order_total BIGINT; + DECLARE max_product_id INT; + DECLARE order_status VARCHAR(20); + DECLARE batch_count INT DEFAULT 0; + DECLARE batch_size INT DEFAULT 2000; + + SELECT MAX(id) INTO max_product_id FROM products; + + WHILE i <= 100000 DO + -- User IDs: 1 ~ 1000 + SET user_id = FLOOR(1 + RAND() * 1000); + + -- Items per order: 1 ~ 5 + SET items_count = FLOOR(1 + RAND() * 5); + + -- Order status distribution + SET @status_rand = RAND(); + IF @status_rand < 0.6 THEN + SET order_status = 'COMPLETED'; + ELSEIF @status_rand < 0.8 THEN + SET order_status = 'PENDING'; + ELSEIF @status_rand < 0.95 THEN + SET order_status = 'SHIPPED'; + ELSE + SET order_status = 'CANCELLED'; + END IF; + + SET order_total = 0; + + -- Insert order first with placeholder total + INSERT INTO orders (user_id, total_price, original_amount, coupon_discount, point_discount, coupon_id, status, created_at) + VALUES ( + user_id, + 0, + 0, + 0, + 0, + NULL, + order_status, + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 365) DAY) + ); + + SET order_id = LAST_INSERT_ID(); + + -- Insert order items + SET j = 1; + WHILE j <= items_count DO + SET product_id = FLOOR(1 + RAND() * max_product_id); + SET product_name = CONCAT('Product-', product_id); + SET product_price = FLOOR(10000 + RAND() * 200000); + SET item_quantity = FLOOR(1 + RAND() * 3); + + INSERT INTO order_items (order_id, product_id, product_name, quantity, price_snapshot) + VALUES (order_id, product_id, product_name, item_quantity, product_price); + + SET order_total = order_total + (product_price * item_quantity); + SET j = j + 1; + END WHILE; + + -- Update order with actual total + UPDATE orders SET total_price = order_total, original_amount = order_total WHERE id = order_id; + + SET batch_count = batch_count + 1; + IF batch_count >= batch_size THEN + COMMIT; + SET batch_count = 0; + END IF; + + SET i = i + 1; + END WHILE; + + COMMIT; +END // +DELIMITER ; + +CALL insert_orders(); +SELECT CONCAT('Orders inserted: ', (SELECT COUNT(*) FROM orders)) AS progress; +SELECT CONCAT('Order items inserted: ', (SELECT COUNT(*) FROM order_items)) AS progress; + +-- ============================================================================= +-- Cleanup and Re-enable checks +-- ============================================================================= +SET FOREIGN_KEY_CHECKS = 1; +SET autocommit = 1; +SET unique_checks = 1; + +-- Drop procedures +DROP PROCEDURE IF EXISTS insert_users; +DROP PROCEDURE IF EXISTS insert_brands; +DROP PROCEDURE IF EXISTS insert_products; +DROP PROCEDURE IF EXISTS insert_likes; +DROP PROCEDURE IF EXISTS insert_coupons; +DROP PROCEDURE IF EXISTS insert_orders; + +-- ============================================================================= +-- Summary +-- ============================================================================= +SELECT + @start_time AS started_at, + NOW() AS completed_at, + TIMEDIFF(NOW(), @start_time) AS duration; + +SELECT 'Data Summary:' AS info; +SELECT 'users' AS table_name, COUNT(*) AS row_count FROM users +UNION ALL +SELECT 'brands', COUNT(*) FROM brands +UNION ALL +SELECT 'products', COUNT(*) FROM products +UNION ALL +SELECT 'likes', COUNT(*) FROM likes +UNION ALL +SELECT 'coupon_templates', COUNT(*) FROM coupon_templates +UNION ALL +SELECT 'orders', COUNT(*) FROM orders +UNION ALL +SELECT 'order_items', COUNT(*) FROM order_items; + +-- Like count distribution (using denormalized like_count column) +SELECT 'Like count distribution (top 20 products):' AS info; +SELECT id AS product_id, name, like_count +FROM products +ORDER BY like_count DESC +LIMIT 20; diff --git a/scripts/migration/V001__add_like_count_to_products.sql b/scripts/migration/V001__add_like_count_to_products.sql new file mode 100644 index 000000000..7f00090a8 --- /dev/null +++ b/scripts/migration/V001__add_like_count_to_products.sql @@ -0,0 +1,20 @@ +-- ============================================================================= +-- V001: 상품 좋아요 수 컬럼 및 인덱스 추가 +-- ============================================================================= + +-- 1. 좋아요 수 컬럼 추가 +ALTER TABLE products ADD COLUMN like_count BIGINT NOT NULL DEFAULT 0; + +-- 2. 기존 좋아요 수로 초기값 설정 +UPDATE products p +SET p.like_count = ( + SELECT COUNT(*) + FROM likes l + WHERE l.product_id = p.id +); + +-- 3. 인덱스 추가 +CREATE INDEX idx_products_like_count ON products (like_count DESC, created_at DESC); +CREATE INDEX idx_products_brand_like ON products (brand_id, like_count DESC); +CREATE INDEX idx_products_brand_price ON products (brand_id, price ASC); +CREATE INDEX idx_products_created_at ON products (created_at DESC); diff --git a/scripts/migration/V001__add_like_count_to_products_rollback.sql b/scripts/migration/V001__add_like_count_to_products_rollback.sql new file mode 100644 index 000000000..0bac84668 --- /dev/null +++ b/scripts/migration/V001__add_like_count_to_products_rollback.sql @@ -0,0 +1,12 @@ +-- ============================================================================= +-- V001 Rollback: 상품 좋아요 수 컬럼 및 인덱스 제거 +-- ============================================================================= + +-- 1. 인덱스 제거 +DROP INDEX idx_products_created_at ON products; +DROP INDEX idx_products_brand_price ON products; +DROP INDEX idx_products_brand_like ON products; +DROP INDEX idx_products_like_count ON products; + +-- 2. 컬럼 제거 +ALTER TABLE products DROP COLUMN like_count; diff --git a/scripts/run-dummy-data.sh b/scripts/run-dummy-data.sh new file mode 100755 index 000000000..893cf77a4 --- /dev/null +++ b/scripts/run-dummy-data.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# ============================================================================= +# Dummy Data Insert Script Runner +# ============================================================================= + +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SQL_FILE="$SCRIPT_DIR/insert-dummy-data.sql" + +# MySQL connection info (from docker/infra-compose.yml) +MYSQL_HOST="${MYSQL_HOST:-localhost}" +MYSQL_PORT="${MYSQL_PORT:-3306}" +MYSQL_USER="${MYSQL_USER:-root}" +MYSQL_PASSWORD="${MYSQL_PASSWORD:-root}" +MYSQL_DATABASE="${MYSQL_DATABASE:-loopers}" + +echo "=============================================" +echo "Dummy Data Insert Script" +echo "=============================================" +echo "Target: $MYSQL_HOST:$MYSQL_PORT/$MYSQL_DATABASE" +echo "User: $MYSQL_USER" +echo "" + +# Check if MySQL is accessible +echo "Checking MySQL connection..." +if ! mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" -e "SELECT 1" "$MYSQL_DATABASE" > /dev/null 2>&1; then + echo "ERROR: Cannot connect to MySQL. Make sure Docker containers are running." + echo "Run: docker compose -f docker/infra-compose.yml up -d" + exit 1 +fi +echo "MySQL connection OK" +echo "" + +# Check current data +echo "Current data in database:" +mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" -e " +SELECT 'users' AS table_name, COUNT(*) AS row_count FROM users +UNION ALL SELECT 'brands', COUNT(*) FROM brands +UNION ALL SELECT 'products', COUNT(*) FROM products +UNION ALL SELECT 'likes', COUNT(*) FROM likes +UNION ALL SELECT 'coupon_templates', COUNT(*) FROM coupon_templates +UNION ALL SELECT 'orders', COUNT(*) FROM orders +UNION ALL SELECT 'order_items', COUNT(*) FROM order_items; +" 2>/dev/null || echo "(Tables may not exist yet)" + +echo "" +read -p "This will INSERT data (existing data will remain). Continue? [y/N]: " confirm +if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + echo "Aborted." + exit 0 +fi + +echo "" +echo "Running SQL script... (this may take 10-30 minutes)" +echo "=============================================" + +# Run the SQL script +mysql -h "$MYSQL_HOST" -P "$MYSQL_PORT" -u "$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE" < "$SQL_FILE" + +echo "" +echo "=============================================" +echo "Done!" +echo "=============================================" diff --git a/scripts/truncate-tables.sql b/scripts/truncate-tables.sql new file mode 100644 index 000000000..f85877504 --- /dev/null +++ b/scripts/truncate-tables.sql @@ -0,0 +1,19 @@ +-- ============================================================================= +-- Truncate All Tables (for clean insert) +-- ============================================================================= + +SET FOREIGN_KEY_CHECKS = 0; + +TRUNCATE TABLE order_items; +TRUNCATE TABLE orders; +TRUNCATE TABLE likes; +TRUNCATE TABLE products; +TRUNCATE TABLE brands; +TRUNCATE TABLE coupon_templates; +TRUNCATE TABLE issued_coupons; +TRUNCATE TABLE user_points; +TRUNCATE TABLE users; + +SET FOREIGN_KEY_CHECKS = 1; + +SELECT 'All tables truncated' AS result; \ No newline at end of file