diff --git a/.claude/commands/analyze-transaction.md b/.claude/commands/analyze-transaction.md new file mode 100644 index 000000000..645cf40b9 --- /dev/null +++ b/.claude/commands/analyze-transaction.md @@ -0,0 +1,123 @@ +--- +name: analyze-transaction +description: Use when reviewing or implementing code with @Transactional, JPA persistence context, or concurrency control. Triggers on transaction boundary design, lock strategy selection, dirty checking concerns, flush timing issues, or when user says "트랜잭션 분석", "락 분석", "동시성 점검". +--- + +# Analyze Transaction + +대상이 되는 코드 범위를 탐색하고, Spring @Transactional, JPA, QueryDSL 기반의 코드에 대해 트랜잭션 범위, 영속성 컨텍스트, 쿼리 실행 시점 관점에서 분석한다. + +특히 다음을 중점적으로 점검한다: +- 트랜잭션이 불필요하게 크게 잡혀 있지는 않은지 +- 조회/쓰기 로직이 하나의 트랜잭션에 혼합되어 있지는 않은지 +- JPA의 지연 로딩, flush 타이밍, 변경 감지로 인해 의도치 않은 쿼리 또는 락이 발생할 가능성은 없는지 + +단순한 정답 제시가 아니라, 현재 구조의 의도와 trade-off를 드러내고 개선 가능 지점을 선택적으로 판단할 수 있도록 돕는다. + +## Analysis Scope + +이 스킬은 아래 대상에 대해 분석한다: +- @Transactional 이 선언된 클래스 / 메서드 +- Service / Facade / Application Layer 코드 +- JPA Entity, Repository, QueryDSL 사용 코드 +- 하나의 유즈케이스(요청 흐름) 단위 + +> 컨트롤러 → 서비스 → 레포지토리 전체 흐름을 기준으로 분석하며 특정 메서드만 떼어내어 판단하지 않는다. + +## Analysis Checklist + +### 1. Transaction Boundary 분석 + +다음을 순서대로 확인한다: + +1. **트랜잭션 시작 지점은 어디인가?** — Service / Facade / 그 외 계층? +2. **트랜잭션이 실제로 필요한 작업은 무엇인가?** — 상태 변경(쓰기) vs 단순 조회 +3. **트랜잭션 내부에서 수행되는 작업 나열:** + - 외부 API 호출 + - 복잡한 조회(QueryDSL) + - 반복문 기반 처리 + - 락 획득 (비관적/낙관적) + +**출력 형식:** + +``` +현재 트랜잭션 범위: {클래스.메서드()} + ├─ {작업 1} [읽기/쓰기/락] + ├─ {작업 2} [읽기/쓰기/락] + └─ {작업 3} [읽기/쓰기/락] + +트랜잭션이 필요한 핵심 작업: + - {작업 A} + - {작업 B} +``` + +### 2. 불필요하게 큰 트랜잭션 식별 + +아래 패턴이 존재하는지 점검한다: + +| 패턴 | 위험도 | 설명 | +|------|--------|------| +| Controller에서 @Transactional | 높음 | 트랜잭션 범위가 HTTP 요청 전체로 확장됨 | +| 읽기 로직이 쓰기 트랜잭션에 포함 | 중간 | 불필요한 락 경합, 커넥션 점유 | +| 외부 시스템 호출이 트랜잭션 내부 | 높음 | 네트워크 지연이 트랜잭션 길이에 직결 | +| 대량 조회가 트랜잭션 내부 | 중간 | 커넥션 풀 고갈 위험 | +| 상태 변경 이후 트랜잭션이 길게 유지 | 중간 | 락 홀딩 시간 증가 | + +### 3. JPA / 영속성 컨텍스트 관점 분석 + +다음을 중심으로 분석한다: + +- **flush 타이밍**: Entity 변경이 언제 DB에 반영되는지 +- **변경 감지(dirty checking)**: 조회용 Entity가 의도치 않게 변경 감지 대상이 되는지 +- **지연 로딩(lazy loading)**: 트랜잭션 후반에 N+1 쿼리가 발생할 가능성 +- **1차 캐시 문제**: 같은 엔티티를 락 없이 먼저 읽은 후 FOR UPDATE로 다시 읽을 때 stale 데이터 반환 +- **readOnly 미적용**: 단순 조회에 `@Transactional(readOnly = true)` 누락 여부 + +**체크리스트:** + +``` +□ 단순 조회인데 Entity 반환 후 변경 가능성 존재? +□ DTO Projection 대신 Entity 조회 사용 여부 +□ QueryDSL 조회 결과가 영속성 컨텍스트에 포함되는지 +□ 같은 엔티티를 락 없이 읽은 후 FOR UPDATE로 재조회하는 패턴? +□ @Transactional(readOnly = true) 적용 누락? +``` + +### 4. 동시성 제어 분석 + +락 전략이 적용된 경우 추가로 점검한다: + +| 점검 항목 | 설명 | +|-----------|------| +| **락 전략 적합성** | 비관적 vs 낙관적 선택이 도메인 특성에 맞는가? | +| **데드락 위험** | 여러 리소스를 락 걸 때 순서가 일관적인가? | +| **Self-invocation** | @Transactional 메서드를 같은 빈 내부에서 호출하고 있지 않은가? | +| **재시도 로직** | 낙관적 락 사용 시 ObjectOptimisticLockingFailureException 재시도가 구현되었는가? | +| **트랜잭션 전파** | 하위 서비스의 @Transactional이 상위와 의도대로 합류하는가? | + +### 5. Improvement Proposal (선택적 제안) + +개선안은 강제하지 않고 선택지로 제시한다: + +- **트랜잭션 분리**: 조회 → 쓰기 분리, Facade에서 orchestration +- **`@Transactional(readOnly = true)` 적용** +- **DTO Projection 도입**: 변경 감지 불필요한 조회 +- **외부 호출/이벤트 발행을 트랜잭션 외부로 이동** +- **락 순서 통일**: 리소스별 ID 오름차순 정렬 + +**제안 형식:** + +``` +[개선안 N] +- 현재: {현재 구조 설명} +- 제안: {변경 방향} +- 장점: {기대 효과} +- 고려사항: {트레이드오프} +``` + +## 톤 & 스타일 + +- 정답을 단정하지 않고 **현재 구조의 의도를 먼저 파악**한 뒤 개선 가능 지점을 제시 +- "이렇게 해야 한다"가 아니라 **"이런 선택지가 있다"** +- 코드 레벨에서 구체적 파일:라인 을 근거로 제시 +- 개발자의 설계 주도권을 존중 — 제안은 하되 결정은 개발자가 한다 diff --git a/.http/cache-test.http b/.http/cache-test.http new file mode 100644 index 000000000..d47829a43 --- /dev/null +++ b/.http/cache-test.http @@ -0,0 +1,25 @@ +### 캐시 미스 — 상품 상세 첫 번째 조회 +GET http://localhost:8080/api/v1/products/1 + +### 캐시 히트 — 상품 상세 두 번째 조회 (응답시간 비교) +GET http://localhost:8080/api/v1/products/1 + +### 상품 목록 — 캐시 미스 +GET http://localhost:8080/api/v1/products?brandId=1&sort=LIKES_DESC&page=0&size=20 + +### 상품 목록 — 캐시 히트 +GET http://localhost:8080/api/v1/products?brandId=1&sort=LIKES_DESC&page=0&size=20 + +### 상품 수정 (캐시 무효화 트리거) +PUT http://localhost:8080/api-admin/v1/products/1 +Content-Type: application/json +X-Admin-Id: admin + +{ + "name": "수정된 상품", + "description": "수정된 설명", + "price": 99999 +} + +### 수정 후 재조회 — 캐시 미스 (변경된 데이터) +GET http://localhost:8080/api/v1/products/1 diff --git a/.http/payment.http b/.http/payment.http new file mode 100644 index 000000000..0d5e84b20 --- /dev/null +++ b/.http/payment.http @@ -0,0 +1,64 @@ +### 결제 요청 +POST http://localhost:8080/api/v1/payments +X-Loopers-LoginId: user1 +X-Loopers-LoginPw: password1 +Content-Type: application/json + +{ + "orderId": 1, + "cardType": "SAMSUNG", + "cardNo": "1234-5678-9814-1451" +} + +### 결제 상태 조회 +GET http://localhost:8080/api/v1/payments/1 +X-Loopers-LoginId: user1 +X-Loopers-LoginPw: password1 + +### 주문별 결제 내역 조회 +GET http://localhost:8080/api/v1/payments?orderId=1 +X-Loopers-LoginId: user1 +X-Loopers-LoginPw: password1 + +### PG 콜백 (수동 테스트용) +POST http://localhost:8080/api/v1/payments/callback +Content-Type: application/json + +{ + "transactionKey": "20250816:TR:9577c5", + "orderId": "1", + "status": "SUCCESS", + "failureReason": null +} + +### 결제 상태 동기화 (콜백 미수신 시 PG에 직접 조회) +POST http://localhost:8080/api/v1/payments/1/sync +X-Loopers-LoginId: user1 +X-Loopers-LoginPw: password1 + +########################################################### +# PG Simulator 직접 요청 (localhost:8082) +# pg-simulator 레포: loopback-be-l2-kotlin-additionals +# 실행: ./gradlew :apps:pg-simulator:bootRun +########################################################### + +### [PG] 결제 요청 +POST http://localhost:8082/api/v1/payments +X-USER-ID: 1 +Content-Type: application/json + +{ + "orderId": "1351039135", + "cardType": "SAMSUNG", + "cardNo": "1234-5678-9814-1451", + "amount": "50000", + "callbackUrl": "http://localhost:8080/api/v1/payments/callback" +} + +### [PG] 결제 상태 조회 (transactionKey를 위 응답에서 복사) +GET http://localhost:8082/api/v1/payments/20260320:TR:5ffc99 +X-USER-ID: 1 + +### [PG] 주문별 결제 내역 조회 +GET http://localhost:8082/api/v1/payments?orderId=1351039135 +X-USER-ID: 1 diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts index 9ad4d8ea9..a0c7da1a9 100644 --- a/apps/commerce-api/build.gradle.kts +++ b/apps/commerce-api/build.gradle.kts @@ -2,6 +2,7 @@ dependencies { // add-ons implementation(project(":modules:jpa")) implementation(project(":modules:redis")) + implementation(project(":modules:kafka")) implementation(project(":supports:jackson")) implementation(project(":supports:logging")) implementation(project(":supports:monitoring")) @@ -14,6 +15,10 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}") + // resilience4j + implementation("io.github.resilience4j:resilience4j-spring-boot3") + implementation("org.springframework.boot:spring-boot-starter-aop") + // querydsl annotationProcessor("com.querydsl:querydsl-apt::jakarta") annotationProcessor("jakarta.persistence:jakarta.persistence-api") @@ -22,4 +27,7 @@ dependencies { // test-fixtures testImplementation(testFixtures(project(":modules:jpa"))) testImplementation(testFixtures(project(":modules:redis"))) + + // wiremock + testImplementation("org.springframework.cloud:spring-cloud-contract-wiremock") } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java index 7c643c92e..7a62461dc 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandFacade.java @@ -33,7 +33,7 @@ public BrandInfo update(Long brandId, String name, String description) { @Transactional public void delete(Long brandId) { brandService.delete(brandId); - productService.deleteAllByBrandId(brandId); + productService.softDeleteByBrandId(brandId); } public Page getAll(Pageable pageable) { diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java index 4a7db0896..8d7af7ca8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandInfo.java @@ -16,8 +16,8 @@ public record BrandInfo( public static BrandInfo from(BrandModel model) { return new BrandInfo( model.getId(), - model.name().value(), - model.description(), + model.getName(), + model.getDescription(), model.getCreatedAt(), model.getUpdatedAt(), model.getDeletedAt() diff --git a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java index 3fdc5c71d..9b359e474 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java @@ -1,7 +1,6 @@ package com.loopers.application.brand; import com.loopers.domain.brand.BrandModel; -import com.loopers.domain.brand.BrandName; import com.loopers.domain.brand.BrandRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -11,6 +10,11 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + @RequiredArgsConstructor @Component public class BrandService { @@ -42,15 +46,14 @@ public BrandModel getBrandForAdmin(Long brandId) { @Transactional public BrandModel update(Long brandId, String name, String description) { BrandModel brand = findById(brandId); - BrandName newName = new BrandName(name); - if (!brand.name().equals(newName)) { + if (!brand.getName().equals(name)) { brandRepository.findByName(name).ifPresent(existing -> { throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다."); }); } - brand.update(newName, description); + brand.update(name, description); return brand; } @@ -65,21 +68,15 @@ public Page getAll(Pageable pageable) { return brandRepository.findAll(pageable); } - @Transactional - public BrandModel update(Long id, String name, String description) { - BrandModel brand = getById(id); - brandRepository.findByName(name) - .filter(existing -> !existing.getId().equals(brand.getId())) - .ifPresent(existing -> { - throw new CoreException(ErrorType.CONFLICT, "이미 존재하는 브랜드 이름입니다: " + name); - }); - brand.update(name, description); - return brand; + @Transactional(readOnly = true) + public Map getByIds(List ids) { + return brandRepository.findAllByIdIn(ids) + .stream() + .collect(Collectors.toMap(BrandModel::getId, Function.identity())); } - @Transactional - public void delete(Long id) { - BrandModel brand = getById(id); - brand.delete(); + private BrandModel findById(Long brandId) { + return brandRepository.findById(brandId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "브랜드를 찾을 수 없습니다. [id = " + brandId + "]")); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java new file mode 100644 index 000000000..827b6ee66 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponFacade.java @@ -0,0 +1,37 @@ +package com.loopers.application.coupon; + +import com.loopers.domain.coupon.CouponModel; +import com.loopers.domain.coupon.event.CouponIssueRequestedEvent; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Component +public class CouponFacade { + + private final CouponService couponService; + private final ApplicationEventPublisher eventPublisher; + + /** + * 선착순 쿠폰 발급 요청. + * 즉시 발급하지 않고 Kafka로 비동기 처리 위임. + * 기본 유효성(만료, 수량)만 검증 후 이벤트 발행. + */ + @Transactional + public void requestCouponIssue(Long couponId, Long userId) { + CouponModel coupon = couponService.getCoupon(couponId); + + if (coupon.isExpired()) { + throw new CoreException(ErrorType.BAD_REQUEST, "만료된 쿠폰입니다."); + } + if (!coupon.isQuantityAvailable()) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 발급 수량이 초과되었습니다."); + } + + eventPublisher.publishEvent(new CouponIssueRequestedEvent(couponId, userId)); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java index 80888765e..0e51a4ee2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/coupon/CouponIssueService.java @@ -13,6 +13,7 @@ import org.springframework.transaction.annotation.Transactional; import java.util.List; +import java.util.Optional; @RequiredArgsConstructor @Component @@ -60,6 +61,11 @@ public Page getIssuesByCoupon(Long couponId, Pageable pageable return couponIssueRepository.findAllByCouponId(couponId, pageable); } + @Transactional(readOnly = true) + public Optional findByUserAndCoupon(Long userId, Long couponId) { + return couponIssueRepository.findByUserIdAndCouponId(userId, couponId); + } + @Transactional public void use(Long couponIssueId, Long userId, Long orderId) { CouponIssueModel couponIssue = getCouponIssue(couponIssueId); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java index bb5669f65..99ff72312 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeFacade.java @@ -34,12 +34,12 @@ public Page getMyLikes(Long userId, Pageable pageable) { public Page getMyLikesWithProducts(Long userId, Pageable pageable) { Page likes = likeService.getMyLikes(userId, pageable); return likes.map(like -> { - ProductModel product = productService.getProduct(like.productId()); + ProductModel product = productService.getById(like.productId()); return new LikeWithProduct( like.getId(), product.getId(), - product.name(), - product.price().value(), + product.getName(), + product.getPrice().value(), like.getCreatedAt() ); }); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeMetricsEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeMetricsEventListener.java new file mode 100644 index 000000000..fe947df27 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeMetricsEventListener.java @@ -0,0 +1,44 @@ +package com.loopers.application.like; + +import com.loopers.application.product.ProductService; +import com.loopers.domain.like.event.LikeToggledEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@RequiredArgsConstructor +@Component +public class LikeMetricsEventListener { + + private final ProductService productService; + private final CacheManager cacheManager; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handleLikeMetrics(LikeToggledEvent event) { + if (event.liked()) { + productService.incrementLikeCount(event.productId()); + } else { + productService.decrementLikeCount(event.productId()); + } + log.info("좋아요 집계 처리: productId={}, liked={}", event.productId(), event.liked()); + evictProductDetailCache(event.productId()); + } + + private void evictProductDetailCache(Long productId) { + try { + var cache = cacheManager.getCache("productDetail"); + if (cache != null) { + cache.evict(productId); + } + } catch (Exception e) { + log.warn("캐시 evict 실패 (productId={}): {}", productId, e.getMessage()); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java index a3b5dd6c7..ba6dd41f8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java @@ -3,8 +3,9 @@ import com.loopers.domain.like.LikeResult; import com.loopers.domain.like.LikeModel; import com.loopers.domain.like.LikeToggleService; -import com.loopers.application.product.ProductService; +import com.loopers.domain.like.event.LikeToggledEvent; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; @@ -15,8 +16,8 @@ public class LikeTransactionService { private final LikeService likeService; - private final ProductService productService; private final LikeToggleService likeToggleService; + private final ApplicationEventPublisher eventPublisher; @Transactional public void doLike(Long userId, Long productId) { @@ -26,7 +27,7 @@ public void doLike(Long userId, Long productId) { result.newLike().ifPresent(likeService::save); if (result.countChanged()) { - productService.incrementLikeCount(productId); + eventPublisher.publishEvent(new LikeToggledEvent(productId, true)); } } @@ -36,6 +37,6 @@ public void doUnlike(Long userId, Long productId) { if (activeLike.isEmpty()) return; likeToggleService.unlike(activeLike.get()); - productService.decrementLikeCount(activeLike.get().productId()); + eventPublisher.publishEvent(new LikeToggledEvent(productId, false)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/logging/UserActivityEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/logging/UserActivityEventListener.java new file mode 100644 index 000000000..8cd9dd39a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/logging/UserActivityEventListener.java @@ -0,0 +1,32 @@ +package com.loopers.application.logging; + +import com.loopers.domain.like.event.LikeToggledEvent; +import com.loopers.domain.order.event.OrderPlacedEvent; +import com.loopers.domain.payment.event.PaymentCompletedEvent; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +public class UserActivityEventListener { + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderPlaced(OrderPlacedEvent event) { + log.info("[UserActivity] 주문 생성 - userId={}, orderId={}, amount={}", + event.userId(), event.orderId(), event.totalAmountValue()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePaymentCompleted(PaymentCompletedEvent event) { + log.info("[UserActivity] 결제 {} - userId={}, orderId={}, paymentId={}", + event.success() ? "성공" : "실패", event.userId(), event.orderId(), event.paymentId()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeToggled(LikeToggledEvent event) { + log.info("[UserActivity] 좋아요 {} - productId={}", + event.liked() ? "등록" : "취소", event.productId()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java index 443496bdb..8c3892645 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java @@ -11,7 +11,9 @@ import com.loopers.application.product.ProductService; import com.loopers.application.stock.StockService; import com.loopers.domain.stock.StockModel; +import com.loopers.domain.order.event.OrderPlacedEvent; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; @@ -31,6 +33,7 @@ public class OrderFacade { private final StockService stockService; private final CouponIssueService couponIssueService; private final CouponService couponService; + private final ApplicationEventPublisher eventPublisher; @Transactional public OrderResult placeOrder(Long userId, List commands, Long couponIssueId) { @@ -43,11 +46,11 @@ public OrderResult placeOrder(Long userId, List commands, Long List snapshots = new ArrayList<>(); for (OrderItemCommand cmd : sorted) { - ProductModel product = productService.getProduct(cmd.productId()); - Money subtotal = product.price().multiply(cmd.quantity()); + ProductModel product = productService.getById(cmd.productId()); + Money subtotal = product.getPrice().multiply(cmd.quantity()); totalAmount = totalAmount.add(subtotal); snapshots.add(new SnapshotHolder( - product.getId(), product.name(), product.price(), cmd.quantity() + product.getId(), product.getName(), product.getPrice(), cmd.quantity() )); } @@ -85,6 +88,10 @@ public OrderResult placeOrder(Long userId, List commands, Long List savedItems = orderService.saveAllItems(items); + eventPublisher.publishEvent(new OrderPlacedEvent( + order.getId(), userId, (long) totalAmount.value() + )); + return OrderResult.of(order, savedItems); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java index db03fc5a5..87bd7bea2 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderService.java @@ -59,6 +59,16 @@ public List getOrderItems(Long orderId) { return orderItemRepository.findAllByOrderId(orderId); } + @Transactional + public void failOrderPayment(Long orderId) { + OrderModel order = findById(orderId); + if (order.status() == com.loopers.domain.order.OrderStatus.CREATED + || order.status() == com.loopers.domain.order.OrderStatus.PAYMENT_FAILED) { + return; + } + order.failPayment(); + } + private OrderModel findById(Long orderId) { return orderRepository.findById(orderId) .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다.")); diff --git a/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventListener.java b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventListener.java new file mode 100644 index 000000000..f2da8c3b2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/outbox/OutboxEventListener.java @@ -0,0 +1,82 @@ +package com.loopers.application.outbox; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.domain.coupon.event.CouponIssueRequestedEvent; +import com.loopers.domain.like.event.LikeToggledEvent; +import com.loopers.domain.order.event.OrderPlacedEvent; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxEventEnvelope; +import com.loopers.domain.outbox.OutboxRepository; +import com.loopers.domain.payment.event.PaymentCompletedEvent; +import com.loopers.domain.product.event.ProductViewedEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.time.ZonedDateTime; +import java.util.UUID; + +/** + * BEFORE_COMMIT: 도메인 TX와 같은 TX에서 outbox 저장. + * 도메인 이벤트가 커밋되면 outbox 레코드도 함께 커밋된다. + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class OutboxEventListener { + + private final OutboxRepository outboxRepository; + private final ObjectMapper objectMapper; + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleLikeToggled(LikeToggledEvent event) { + String eventType = event.liked() ? "LIKED" : "UNLIKED"; + saveOutboxEvent("Product", event.productId(), eventType, + "catalog-events", String.valueOf(event.productId()), event); + } + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleProductViewed(ProductViewedEvent event) { + saveOutboxEvent("Product", event.productId(), "PRODUCT_VIEWED", + "catalog-events", String.valueOf(event.productId()), event); + } + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleOrderPlaced(OrderPlacedEvent event) { + saveOutboxEvent("Order", event.orderId(), "ORDER_PLACED", + "order-events", String.valueOf(event.orderId()), event); + } + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handlePaymentCompleted(PaymentCompletedEvent event) { + saveOutboxEvent("Payment", event.paymentId(), "PAYMENT_COMPLETED", + "order-events", String.valueOf(event.orderId()), event); + } + + @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) + public void handleCouponIssueRequested(CouponIssueRequestedEvent event) { + saveOutboxEvent("Coupon", event.couponId(), "COUPON_ISSUE_REQUESTED", + "coupon-issue-requests", String.valueOf(event.couponId()), event); + } + + private void saveOutboxEvent(String aggregateType, Long aggregateId, String eventType, + String topic, String partitionKey, Object eventData) { + String eventId = UUID.randomUUID().toString(); + OutboxEventEnvelope envelope = new OutboxEventEnvelope(eventId, eventType, ZonedDateTime.now(), eventData); + + try { + String payload = objectMapper.writeValueAsString(envelope); + OutboxEvent outboxEvent = new OutboxEvent( + aggregateType, aggregateId, eventType, topic, partitionKey, payload + ); + outboxRepository.save(outboxEvent); + log.debug("Outbox 이벤트 저장: eventType={}, aggregateId={}", eventType, aggregateId); + } catch (JsonProcessingException e) { + log.error("Outbox 이벤트 직렬화 실패: eventType={}, aggregateId={}", eventType, aggregateId, e); + throw new RuntimeException("Outbox 이벤트 직렬화 실패", e); + } + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentCommand.java new file mode 100644 index 000000000..78a22731b --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentCommand.java @@ -0,0 +1,22 @@ +package com.loopers.application.payment; + +import com.loopers.domain.payment.CardType; + +public record PaymentCommand( + Long orderId, + CardType cardType, + String cardNo +) { + /** + * 카드번호 마스킹: "1234-5678-9814-1451" → "1234-****-****-1451" + */ + public String maskedCardNo() { + if (cardNo == null || cardNo.length() < 4) return cardNo; + String digitsOnly = cardNo.replaceAll("-", ""); + if (digitsOnly.length() < 8) return cardNo; + + String first4 = digitsOnly.substring(0, 4); + String last4 = digitsOnly.substring(digitsOnly.length() - 4); + return first4 + "-****-****-" + last4; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java new file mode 100644 index 000000000..7d469437f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java @@ -0,0 +1,153 @@ +package com.loopers.application.payment; + +import com.loopers.application.order.OrderService; +import com.loopers.config.PgProperties; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.payment.PaymentModel; +import com.loopers.domain.payment.PaymentStatus; +import com.loopers.infrastructure.payment.PgPaymentGateway; +import com.loopers.infrastructure.payment.dto.PgPaymentRequest; +import com.loopers.domain.payment.event.PaymentCompletedEvent; +import com.loopers.infrastructure.payment.dto.PgPaymentResponse; +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +/** + * TX 분리 패턴으로 PG 호출 동안 DB 커넥션을 점유하지 않음. + * 클래스 레벨 @Transactional 없음 — 각 단계별로 Service 레벨 TX 사용. + */ +@Slf4j +@RequiredArgsConstructor +@Component +public class PaymentFacade { + + private final PaymentService paymentService; + private final OrderService orderService; + private final PgPaymentGateway pgPaymentGateway; + private final PgProperties pgProperties; + private final ApplicationEventPublisher eventPublisher; + + /** + * TX 분리 패턴: + * TX-1 (preparePayment): 주문 검증 + 상태 변경 + 결제 레코드 생성 → DB 커넥션 반환 + * NO TX (PG 호출): 외부 HTTP 통신 — DB 커넥션 미점유 + * TX-2 (assignTransactionKey): transactionKey 저장 → DB 커넥션 반환 + */ + @CircuitBreaker(name = "pg", fallbackMethod = "requestPaymentFallback") + public PaymentInfo requestPayment(Long userId, PaymentCommand command) { + // TX-1: DB 작업만 (~15ms), 커밋 후 커넥션 즉시 반환 + PaymentModel payment = paymentService.preparePayment(userId, command); + + // NO TX: PG 호출 (100~500ms, 타임아웃 시 3초) — DB 커넥션 안 잡음 + PgPaymentRequest pgRequest = new PgPaymentRequest( + String.valueOf(command.orderId()), + command.cardType().name(), + command.cardNo(), + String.valueOf(payment.amount().value()), + pgProperties.callbackUrl() + ); + PgPaymentResponse pgResponse = pgPaymentGateway.requestPayment(pgRequest, String.valueOf(userId)); + + // TX-2: transactionKey 저장 (~5ms), 커밋 후 커넥션 즉시 반환 + paymentService.assignTransactionKey(payment.getId(), pgResponse.transactionKey()); + + return PaymentInfo.from(payment, pgResponse.transactionKey()); + } + + private PaymentInfo requestPaymentFallback(Long userId, PaymentCommand command, Throwable t) { + log.warn("PG 결제 요청 실패 - userId: {}, orderId: {}, reason: {}", userId, command.orderId(), t.getMessage()); + // TX-1이 이미 커밋됨 → 주문은 PAYMENT_PENDING, 결제는 PENDING 상태로 DB에 존재 + // → Polling 스케줄러가 PENDING 결제를 주기적으로 확인하여 복구 + return PaymentInfo.pgFailed(command.orderId(), "결제 시스템이 불안정합니다. 잠시 후 다시 시도해주세요."); + } + + @Transactional + public void handleCallback(String transactionKey, String status, String failureReason) { + PaymentModel payment = paymentService.getByTransactionKey(transactionKey); + OrderModel order = orderService.getOrderForAdmin(payment.orderId()); + + if ("SUCCESS".equals(status)) { + payment.markSuccess(); + order.confirmPayment(); + } else if ("FAILED".equals(status)) { + payment.markFailed(failureReason); + order.failPayment(); + } + + eventPublisher.publishEvent(new PaymentCompletedEvent( + payment.getId(), payment.orderId(), payment.userId(), "SUCCESS".equals(status) + )); + } + + @Transactional + public PaymentInfo syncPaymentStatus(Long paymentId) { + PaymentModel payment = paymentService.getById(paymentId); + if (payment.status() != PaymentStatus.PENDING) { + return PaymentInfo.from(payment); + } + + // Phase A: transactionKey가 있으면 PG에 직접 조회 + if (payment.transactionKey() != null) { + return syncWithTransactionKey(payment); + } + + // Phase C: transactionKey 없는 고아 → orderId로 PG 조회 + return syncOrphanPayment(payment); + } + + private PaymentInfo syncWithTransactionKey(PaymentModel payment) { + try { + PgPaymentResponse pgResponse = pgPaymentGateway.getPaymentStatus( + payment.transactionKey(), String.valueOf(payment.userId()) + ); + applyPgResult(payment, pgResponse.status(), pgResponse.failureReason()); + } catch (Exception e) { + log.warn("PG 상태 조회 실패 - transactionKey: {}, reason: {}", payment.transactionKey(), e.getMessage()); + } + return PaymentInfo.from(payment); + } + + private PaymentInfo syncOrphanPayment(PaymentModel payment) { + try { + PgPaymentResponse pgResponse = pgPaymentGateway.getPaymentStatus( + String.valueOf(payment.orderId()), String.valueOf(payment.userId()) + ); + if (pgResponse != null && pgResponse.transactionKey() != null) { + payment.assignTransactionKey(pgResponse.transactionKey()); + applyPgResult(payment, pgResponse.status(), pgResponse.failureReason()); + } + } catch (Exception e) { + log.warn("고아 결제 복구 실패 - orderId: {}, reason: {}", payment.orderId(), e.getMessage()); + } + return PaymentInfo.from(payment); + } + + private void applyPgResult(PaymentModel payment, String status, String failureReason) { + OrderModel order = orderService.getOrderForAdmin(payment.orderId()); + if ("SUCCESS".equals(status)) { + payment.markSuccess(); + order.confirmPayment(); + } else if ("FAILED".equals(status)) { + payment.markFailed(failureReason); + order.failPayment(); + } + } + + @Transactional(readOnly = true) + public PaymentInfo getPayment(Long paymentId) { + return PaymentInfo.from(paymentService.getById(paymentId)); + } + + @Transactional(readOnly = true) + public List getPaymentsByOrderId(Long orderId) { + return paymentService.getByOrderId(orderId).stream() + .map(PaymentInfo::from) + .toList(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentInfo.java new file mode 100644 index 000000000..c51640b41 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentInfo.java @@ -0,0 +1,55 @@ +package com.loopers.application.payment; + +import com.loopers.domain.payment.PaymentModel; + +import java.time.ZonedDateTime; + +public record PaymentInfo( + Long paymentId, + Long orderId, + Long userId, + String cardType, + String maskedCardNo, + int amount, + String status, + String transactionKey, + String failureReason, + ZonedDateTime createdAt +) { + public static PaymentInfo from(PaymentModel payment) { + return new PaymentInfo( + payment.getId(), + payment.orderId(), + payment.userId(), + payment.cardType().name(), + payment.maskedCardNo(), + payment.amount().value(), + payment.status().name(), + payment.transactionKey(), + payment.failureReason(), + payment.getCreatedAt() + ); + } + + public static PaymentInfo from(PaymentModel payment, String transactionKey) { + return new PaymentInfo( + payment.getId(), + payment.orderId(), + payment.userId(), + payment.cardType().name(), + payment.maskedCardNo(), + payment.amount().value(), + payment.status().name(), + transactionKey, + payment.failureReason(), + payment.getCreatedAt() + ); + } + + public static PaymentInfo pgFailed(Long orderId, String reason) { + return new PaymentInfo( + null, orderId, null, null, null, 0, + "PG_FAILED", null, reason, null + ); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentPollingScheduler.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentPollingScheduler.java new file mode 100644 index 000000000..0be92fcaf --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentPollingScheduler.java @@ -0,0 +1,53 @@ +package com.loopers.application.payment; + +import com.loopers.domain.payment.PaymentModel; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +@Component +public class PaymentPollingScheduler { + + private static final int FAIL_FAST_THRESHOLD = 3; + + private final PaymentService paymentService; + private final PaymentFacade paymentFacade; + + @Scheduled(fixedDelay = 60_000, initialDelay = 30_000) + public void pollPendingPayments() { + List pendingPayments = paymentService.getPendingPayments(); + if (pendingPayments.isEmpty()) { + return; + } + + log.info("PENDING 결제 {}건 폴링 시작", pendingPayments.size()); + + int consecutiveFailures = 0; + int recovered = 0; + + for (PaymentModel payment : pendingPayments) { + try { + paymentFacade.syncPaymentStatus(payment.getId()); + consecutiveFailures = 0; + recovered++; + } catch (Exception e) { + consecutiveFailures++; + log.warn("결제 상태 동기화 실패 - paymentId: {}, 연속 실패: {}/{}, reason: {}", + payment.getId(), consecutiveFailures, FAIL_FAST_THRESHOLD, e.getMessage()); + + if (consecutiveFailures >= FAIL_FAST_THRESHOLD) { + log.error("PG 연속 {}건 실패 — 폴링 사이클 조기 종료 (남은 {}건은 다음 사이클에서 처리)", + FAIL_FAST_THRESHOLD, pendingPayments.size() - recovered - consecutiveFailures); + break; + } + } + } + + log.info("PENDING 결제 폴링 완료 - 처리: {}/{}", recovered, pendingPayments.size()); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentService.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentService.java new file mode 100644 index 000000000..4a7336042 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentService.java @@ -0,0 +1,72 @@ +package com.loopers.application.payment; + +import com.loopers.application.order.OrderService; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.payment.PaymentModel; +import com.loopers.domain.payment.PaymentRepository; +import com.loopers.domain.payment.PaymentStatus; +import com.loopers.domain.product.Money; +import com.loopers.domain.payment.CardType; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class PaymentService { + + private final PaymentRepository paymentRepository; + private final OrderService orderService; + + @Transactional + public PaymentModel preparePayment(Long userId, PaymentCommand command) { + OrderModel order = orderService.getOrder(command.orderId(), userId); + order.startPayment(); + + PaymentModel payment = new PaymentModel( + order.getId(), + userId, + command.cardType(), + command.maskedCardNo(), + order.finalAmount() + ); + return paymentRepository.save(payment); + } + + @Transactional + public PaymentModel save(PaymentModel payment) { + return paymentRepository.save(payment); + } + + @Transactional(readOnly = true) + public PaymentModel getById(Long paymentId) { + return paymentRepository.findById(paymentId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제 정보를 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public PaymentModel getByTransactionKey(String transactionKey) { + return paymentRepository.findByTransactionKey(transactionKey) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 거래를 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public List getByOrderId(Long orderId) { + return paymentRepository.findAllByOrderId(orderId); + } + + @Transactional(readOnly = true) + public List getPendingPayments() { + return paymentRepository.findAllByStatus(PaymentStatus.PENDING); + } + + @Transactional + public void assignTransactionKey(Long paymentId, String transactionKey) { + PaymentModel payment = getById(paymentId); + payment.assignTransactionKey(transactionKey); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java index d2dd60302..77a2dcee3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductDetail.java @@ -22,18 +22,18 @@ public record ProductDetail( public static ProductDetail ofCustomer(ProductModel product, String brandName, StockStatus stockStatus) { return new ProductDetail( - product.getId(), product.name(), product.description(), - product.price().value(), product.brandId(), brandName, - product.likeCount(), stockStatus, 0, + product.getId(), product.getName(), product.getDescription(), + product.getPrice().value(), product.getBrandId(), brandName, + product.getLikeCount(), stockStatus, 0, product.getCreatedAt(), product.getUpdatedAt(), product.getDeletedAt() ); } public static ProductDetail ofAdmin(ProductModel product, String brandName, int stockQuantity) { return new ProductDetail( - product.getId(), product.name(), product.description(), - product.price().value(), product.brandId(), brandName, - product.likeCount(), null, stockQuantity, + product.getId(), product.getName(), product.getDescription(), + product.getPrice().value(), product.getBrandId(), brandName, + product.getLikeCount(), null, stockQuantity, product.getCreatedAt(), product.getUpdatedAt(), product.getDeletedAt() ); } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java index 0b8eb307b..360b7bdd0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java @@ -6,13 +6,21 @@ import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductSortType; import com.loopers.domain.stock.StockModel; +import com.loopers.domain.stock.StockStatus; import com.loopers.application.stock.StockService; +import com.loopers.domain.product.event.ProductViewedEvent; import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Map; + @RequiredArgsConstructor @Component public class ProductFacade { @@ -20,65 +28,102 @@ public class ProductFacade { private final ProductService productService; private final BrandService brandService; private final StockService stockService; + private final ApplicationEventPublisher eventPublisher; @Transactional - public ProductModel register(String name, String description, Money price, Long brandId, int initialStock) { + public ProductDetail register(String name, String description, Money price, Long brandId, int initialStock) { brandService.getBrand(brandId); ProductModel product = productService.register(name, description, price, brandId); - stockService.create(product.getId(), initialStock); - return product; + stockService.save(product.getId(), initialStock); + String brandName = getBrandName(product.getBrandId()); + StockModel stock = stockService.getByProductId(product.getId()); + return ProductDetail.ofAdmin(product, brandName, stock.getQuantity()); } - @Transactional(readOnly = true) + @Cacheable(cacheNames = "productDetail", key = "#productId") + @Transactional public ProductDetail getProduct(Long productId) { - ProductModel product = productService.getProduct(productId); - String brandName = getBrandName(product.brandId()); + ProductModel product = productService.getById(productId); + String brandName = getBrandName(product.getBrandId()); StockModel stock = stockService.getByProductId(productId); - return ProductDetail.ofCustomer(product, brandName, stock.toStatus()); + eventPublisher.publishEvent(new ProductViewedEvent(productId, null)); + return ProductDetail.ofCustomer(product, brandName, StockStatus.from(stock.getQuantity())); } @Transactional(readOnly = true) public ProductDetail getProductForAdmin(Long productId) { - ProductModel product = productService.getProductForAdmin(productId); - String brandName = getBrandName(product.brandId()); + ProductModel product = productService.getById(productId); + String brandName = getBrandName(product.getBrandId()); StockModel stock = stockService.getByProductId(productId); - return ProductDetail.ofAdmin(product, brandName, stock.quantity()); + return ProductDetail.ofAdmin(product, brandName, stock.getQuantity()); } @Transactional(readOnly = true) public Page getProducts(Long brandId, ProductSortType sortType, Pageable pageable) { Page products = productService.getProducts(brandId, sortType, pageable); + + List brandIds = products.getContent().stream() + .map(ProductModel::getBrandId).distinct().toList(); + List productIds = products.getContent().stream() + .map(ProductModel::getId).toList(); + + Map brandMap = brandService.getByIds(brandIds); + Map stockMap = stockService.getByProductIds(productIds); + return products.map(product -> { - String brandName = getBrandName(product.brandId()); - StockModel stock = stockService.getByProductId(product.getId()); - return ProductDetail.ofCustomer(product, brandName, stock.toStatus()); + BrandModel brand = brandMap.get(product.getBrandId()); + String brandName = brand != null ? brand.getName() : null; + StockModel stock = stockMap.get(product.getId()); + StockStatus status = stock != null ? StockStatus.from(stock.getQuantity()) : StockStatus.OUT_OF_STOCK; + return ProductDetail.ofCustomer(product, brandName, status); }); } @Transactional(readOnly = true) public Page getProductsForAdmin(Long brandId, Pageable pageable) { Page products = productService.getProductsForAdmin(brandId, pageable); + + List brandIds = products.getContent().stream() + .map(ProductModel::getBrandId).distinct().toList(); + List productIds = products.getContent().stream() + .map(ProductModel::getId).toList(); + + Map brandMap = brandService.getByIds(brandIds); + Map stockMap = stockService.getByProductIds(productIds); + return products.map(product -> { - String brandName = getBrandName(product.brandId()); - StockModel stock = stockService.getByProductId(product.getId()); - return ProductDetail.ofAdmin(product, brandName, stock.quantity()); + BrandModel brand = brandMap.get(product.getBrandId()); + String brandName = brand != null ? brand.getName() : null; + StockModel stock = stockMap.get(product.getId()); + int stockQuantity = stock != null ? stock.getQuantity() : 0; + return ProductDetail.ofAdmin(product, brandName, stockQuantity); }); } + @CacheEvict(cacheNames = "productDetail", key = "#productId") @Transactional public ProductDetail update(Long productId, String name, String description, Money price) { productService.update(productId, name, description, price); return getProductForAdmin(productId); } + @CacheEvict(cacheNames = "productDetail", key = "#productId") public void delete(Long productId) { productService.delete(productId); } + @CacheEvict(cacheNames = "productDetail", key = "#productId") + @Transactional + public ProductDetail updateStock(Long productId, int quantity) { + StockModel stock = stockService.getByProductId(productId); + stock.update(quantity); + return getProductForAdmin(productId); + } + private String getBrandName(Long brandId) { try { BrandModel brand = brandService.getBrandForAdmin(brandId); - return brand.name().value(); + return brand.getName(); } catch (Exception e) { return null; } diff --git a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java index 18b5d04c9..f87561f32 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/product/ProductService.java @@ -40,8 +40,13 @@ public ProductModel getById(Long id) { } @Transactional(readOnly = true) - public Page getAll(Pageable pageable, ProductSortType sortType) { - return productRepository.findAll(pageable, sortType); + public Page getProducts(Long brandId, ProductSortType sortType, Pageable pageable) { + return productRepository.findAll(brandId, pageable, sortType); + } + + @Transactional(readOnly = true) + public Page getProductsForAdmin(Long brandId, Pageable pageable) { + return productRepository.findAll(brandId, pageable, ProductSortType.CREATED_DESC); } @Transactional diff --git a/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java b/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java index c9056c2d5..125d3ece7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java +++ b/apps/commerce-api/src/main/java/com/loopers/application/stock/StockService.java @@ -38,6 +38,6 @@ public StockModel getByProductIdForUpdate(Long productId) { public Map getByProductIds(List productIds) { return stockRepository.findAllByProductIdIn(productIds) .stream() - .collect(Collectors.toMap(StockModel::productId, stock -> stock)); + .collect(Collectors.toMap(StockModel::getProductId, stock -> stock)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/config/KafkaTopicConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/KafkaTopicConfig.java new file mode 100644 index 000000000..f1e88016d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/KafkaTopicConfig.java @@ -0,0 +1,38 @@ +package com.loopers.config; + +import org.apache.kafka.clients.admin.NewTopic; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.config.TopicBuilder; + +@Configuration +public class KafkaTopicConfig { + + public static final String CATALOG_EVENTS = "catalog-events"; + public static final String ORDER_EVENTS = "order-events"; + public static final String COUPON_ISSUE_REQUESTS = "coupon-issue-requests"; + + @Bean + public NewTopic catalogEventsTopic() { + return TopicBuilder.name(CATALOG_EVENTS) + .partitions(3) + .replicas(1) + .build(); + } + + @Bean + public NewTopic orderEventsTopic() { + return TopicBuilder.name(ORDER_EVENTS) + .partitions(3) + .replicas(1) + .build(); + } + + @Bean + public NewTopic couponIssueRequestsTopic() { + return TopicBuilder.name(COUPON_ISSUE_REQUESTS) + .partitions(3) + .replicas(1) + .build(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/OutboxRelayConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/OutboxRelayConfig.java new file mode 100644 index 000000000..921667308 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/OutboxRelayConfig.java @@ -0,0 +1,18 @@ +package com.loopers.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +@EnableScheduling +@Configuration +public class OutboxRelayConfig { + + @Bean(name = "outboxRelayExecutor", destroyMethod = "shutdown") + public ExecutorService outboxRelayExecutor() { + return Executors.newFixedThreadPool(4); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/PgClientConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/PgClientConfig.java new file mode 100644 index 000000000..31e2a4a26 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/PgClientConfig.java @@ -0,0 +1,23 @@ +package com.loopers.config; + +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; + +@Configuration +@EnableConfigurationProperties(PgProperties.class) +public class PgClientConfig { + + @Bean + public RestTemplate pgRestTemplate(PgProperties pgProperties) { + return new RestTemplateBuilder() + .rootUri(pgProperties.baseUrl()) + .setConnectTimeout(Duration.ofMillis(pgProperties.timeout().connect())) + .setReadTimeout(Duration.ofMillis(pgProperties.timeout().read())) + .build(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/PgProperties.java b/apps/commerce-api/src/main/java/com/loopers/config/PgProperties.java new file mode 100644 index 000000000..46806d1d1 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/PgProperties.java @@ -0,0 +1,12 @@ +package com.loopers.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "pg") +public record PgProperties( + String baseUrl, + String callbackUrl, + TimeoutProperties timeout +) { + public record TimeoutProperties(int connect, int read) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/SchedulerConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/SchedulerConfig.java new file mode 100644 index 000000000..d21f1b91a --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/config/SchedulerConfig.java @@ -0,0 +1,20 @@ +package com.loopers.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.TaskScheduler; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; + +@Configuration +@EnableScheduling +public class SchedulerConfig { + + @Bean + public TaskScheduler taskScheduler() { + ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler(); + scheduler.setPoolSize(3); + scheduler.setThreadNamePrefix("scheduler-"); + return scheduler; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java index 6e4cc110b..32bc4edca 100644 --- a/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java +++ b/apps/commerce-api/src/main/java/com/loopers/config/WebMvcConfig.java @@ -1,7 +1,7 @@ package com.loopers.config; -import com.loopers.interfaces.auth.AdminUserArgumentResolver; -import com.loopers.interfaces.auth.LoginMemberArgumentResolver; +import com.loopers.interfaces.api.auth.AdminUserArgumentResolver; +import com.loopers.interfaces.api.auth.LoginMemberArgumentResolver; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java index 9f8204caa..e1565c888 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/brand/BrandRepository.java @@ -7,6 +7,7 @@ import java.util.Optional; public interface BrandRepository { + BrandModel save(BrandModel brand); Optional findById(Long id); Optional findByName(String name); Page findAll(Pageable pageable); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java index 808c66a36..310f6e275 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponModel.java @@ -29,6 +29,12 @@ public class CouponModel extends BaseEntity { @Column(name = "expired_at", nullable = false) private LocalDateTime expiredAt; + @Column(name = "max_quantity") + private Integer maxQuantity; + + @Column(name = "issued_count", nullable = false) + private int issuedCount; + protected CouponModel() { } @@ -38,9 +44,16 @@ public CouponModel(String name, CouponType type, int value, Money minOrderAmount this.value = value; this.minOrderAmount = minOrderAmount; this.expiredAt = expiredAt; + this.issuedCount = 0; guard(); } + public CouponModel(String name, CouponType type, int value, Money minOrderAmount, + LocalDateTime expiredAt, Integer maxQuantity) { + this(name, type, value, minOrderAmount, expiredAt); + this.maxQuantity = maxQuantity; + } + @Override protected void guard() { if (name == null || name.isBlank()) { @@ -81,6 +94,22 @@ public void validateUsable(Money orderAmount) { } } + public boolean hasQuantityLimit() { + return maxQuantity != null; + } + + public boolean isQuantityAvailable() { + if (!hasQuantityLimit()) return true; + return issuedCount < maxQuantity; + } + + public void incrementIssuedCount() { + if (hasQuantityLimit() && issuedCount >= maxQuantity) { + throw new CoreException(ErrorType.BAD_REQUEST, "쿠폰 발급 수량이 초과되었습니다."); + } + this.issuedCount++; + } + public void update(String name, CouponType type, int value, Money minOrderAmount, LocalDateTime expiredAt) { this.name = name; this.type = type; @@ -109,4 +138,12 @@ public Money minOrderAmount() { public LocalDateTime expiredAt() { return expiredAt; } + + public Integer maxQuantity() { + return maxQuantity; + } + + public int issuedCount() { + return issuedCount; + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponIssueRequestedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponIssueRequestedEvent.java new file mode 100644 index 000000000..ae9e2abf3 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/event/CouponIssueRequestedEvent.java @@ -0,0 +1,7 @@ +package com.loopers.domain.coupon.event; + +public record CouponIssueRequestedEvent( + Long couponId, + Long userId +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java index d8cbe4a6d..0258c33a0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java @@ -6,9 +6,12 @@ import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; @Entity -@Table(name = "likes") +@Table(name = "likes", uniqueConstraints = { + @UniqueConstraint(name = "uk_likes_user_product", columnNames = {"user_id", "product_id"}) +}) public class LikeModel extends BaseEntity { @Column(name = "user_id", nullable = false) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/like/event/LikeToggledEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/like/event/LikeToggledEvent.java new file mode 100644 index 000000000..0577f0adc --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/like/event/LikeToggledEvent.java @@ -0,0 +1,6 @@ +package com.loopers.domain.like.event; + +public record LikeToggledEvent( + Long productId, + boolean liked +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java index 24ca1d150..5536344a7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java @@ -65,6 +65,27 @@ protected void guard() { public Money finalAmount() { return finalAmount; } public Long couponIssueId() { return couponIssueId; } + public void startPayment() { + if (this.status != OrderStatus.CREATED && this.status != OrderStatus.PAYMENT_FAILED) { + throw new CoreException(ErrorType.BAD_REQUEST, "결제를 시작할 수 없는 주문 상태입니다."); + } + this.status = OrderStatus.PAYMENT_PENDING; + } + + public void confirmPayment() { + if (this.status != OrderStatus.PAYMENT_PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, "결제 확인을 처리할 수 없는 주문 상태입니다."); + } + this.status = OrderStatus.CONFIRMED; + } + + public void failPayment() { + if (this.status != OrderStatus.PAYMENT_PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, "결제 실패를 처리할 수 없는 주문 상태입니다."); + } + this.status = OrderStatus.PAYMENT_FAILED; + } + public void validateOwner(Long userId) { if (!this.userId.equals(userId)) { throw new CoreException(ErrorType.BAD_REQUEST, "본인의 주문만 조회할 수 있습니다."); diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java index 0ffc53f2e..f8ab13d48 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java @@ -2,6 +2,8 @@ public enum OrderStatus { CREATED, + PAYMENT_PENDING, + PAYMENT_FAILED, CONFIRMED, SHIPPING, DELIVERED, diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderPlacedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderPlacedEvent.java new file mode 100644 index 000000000..6de3d0af5 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderPlacedEvent.java @@ -0,0 +1,7 @@ +package com.loopers.domain.order.event; + +public record OrderPlacedEvent( + Long orderId, + Long userId, + Long totalAmountValue +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java new file mode 100644 index 000000000..80968eabe --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEvent.java @@ -0,0 +1,101 @@ +package com.loopers.domain.outbox; + +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.ZonedDateTime; +import java.util.UUID; + +@Getter +@Entity +@Table(name = "outbox_event", indexes = { + @Index(name = "idx_outbox_status_created", columnList = "status, created_at"), + @Index(name = "idx_outbox_status_updated", columnList = "status, updated_at") +}) +public class OutboxEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "event_id", nullable = false, unique = true, length = 36) + private String eventId; + + @Column(name = "aggregate_type", nullable = false, length = 50) + private String aggregateType; + + @Column(name = "aggregate_id", nullable = false) + private Long aggregateId; + + @Column(name = "event_type", nullable = false, length = 50) + private String eventType; + + @Column(name = "topic", nullable = false, length = 100) + private String topic; + + @Column(name = "partition_key", nullable = false, length = 100) + private String partitionKey; + + @Column(name = "payload", nullable = false, columnDefinition = "TEXT") + private String payload; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 20) + private OutboxStatus status; + + @Column(name = "retry_count", nullable = false) + private int retryCount; + + @Column(name = "error_message", length = 500) + private String errorMessage; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + @Column(name = "published_at") + private ZonedDateTime publishedAt; + + protected OutboxEvent() { + } + + public OutboxEvent(String aggregateType, Long aggregateId, String eventType, + String topic, String partitionKey, String payload) { + this.eventId = UUID.randomUUID().toString(); + this.aggregateType = aggregateType; + this.aggregateId = aggregateId; + this.eventType = eventType; + this.topic = topic; + this.partitionKey = partitionKey; + this.payload = payload; + this.status = OutboxStatus.PENDING; + this.retryCount = 0; + this.createdAt = ZonedDateTime.now(); + this.updatedAt = this.createdAt; + } + + public void markProcessing() { + this.status = OutboxStatus.PROCESSING; + this.updatedAt = ZonedDateTime.now(); + } + + public void markPublished() { + this.status = OutboxStatus.PUBLISHED; + this.publishedAt = ZonedDateTime.now(); + this.updatedAt = this.publishedAt; + } + + public void markFailed(String errorMessage) { + this.status = OutboxStatus.FAILED; + this.errorMessage = errorMessage; + this.retryCount++; + this.updatedAt = ZonedDateTime.now(); + } + + public void markRetry() { + this.status = OutboxStatus.PENDING; + this.updatedAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventEnvelope.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventEnvelope.java new file mode 100644 index 000000000..38c04674f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxEventEnvelope.java @@ -0,0 +1,15 @@ +package com.loopers.domain.outbox; + +import java.time.ZonedDateTime; + +/** + * Kafka 메시지 payload에 담기는 이벤트 봉투. + * eventId로 Consumer 멱등 처리, eventType으로 라우팅. + */ +public record OutboxEventEnvelope( + String eventId, + String eventType, + ZonedDateTime occurredAt, + Object data +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxRepository.java new file mode 100644 index 000000000..c9481aef6 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxRepository.java @@ -0,0 +1,17 @@ +package com.loopers.domain.outbox; + +import java.time.ZonedDateTime; +import java.util.List; + +public interface OutboxRepository { + + OutboxEvent save(OutboxEvent event); + + List findPendingEventsForUpdate(int batchSize); + + List findStalledProcessingEvents(ZonedDateTime threshold); + + List findRetryableFailedEvents(ZonedDateTime threshold, int maxRetryCount); + + void deletePublishedEventsBefore(ZonedDateTime threshold); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxStatus.java new file mode 100644 index 000000000..7a6143f10 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/outbox/OutboxStatus.java @@ -0,0 +1,8 @@ +package com.loopers.domain.outbox; + +public enum OutboxStatus { + PENDING, + PROCESSING, + PUBLISHED, + FAILED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/CardType.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/CardType.java new file mode 100644 index 000000000..45d76b65d --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/CardType.java @@ -0,0 +1,7 @@ +package com.loopers.domain.payment; + +public enum CardType { + SAMSUNG, + KB, + HYUNDAI +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentModel.java new file mode 100644 index 000000000..a0c363929 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentModel.java @@ -0,0 +1,90 @@ +package com.loopers.domain.payment; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; + +@Entity +@Table(name = "payments") +public class PaymentModel extends BaseEntity { + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "card_type", nullable = false) + private CardType cardType; + + @Column(name = "masked_card_no", nullable = false) + private String maskedCardNo; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "amount", nullable = false)) + private Money amount; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private PaymentStatus status; + + @Column(name = "transaction_key", unique = true) + private String transactionKey; + + @Column(name = "failure_reason") + private String failureReason; + + protected PaymentModel() {} + + public PaymentModel(Long orderId, Long userId, CardType cardType, String maskedCardNo, Money amount) { + this.orderId = orderId; + this.userId = userId; + this.cardType = cardType; + this.maskedCardNo = maskedCardNo; + this.amount = amount; + this.status = PaymentStatus.PENDING; + guard(); + } + + @Override + protected void guard() { + if (orderId == null) throw new CoreException(ErrorType.BAD_REQUEST, "주문 정보는 필수입니다."); + if (userId == null) throw new CoreException(ErrorType.BAD_REQUEST, "사용자 정보는 필수입니다."); + if (cardType == null) throw new CoreException(ErrorType.BAD_REQUEST, "카드 종류는 필수입니다."); + if (maskedCardNo == null || maskedCardNo.isBlank()) throw new CoreException(ErrorType.BAD_REQUEST, "카드번호는 필수입니다."); + if (amount == null) throw new CoreException(ErrorType.BAD_REQUEST, "결제 금액은 필수입니다."); + } + + public void assignTransactionKey(String transactionKey) { + this.transactionKey = transactionKey; + } + + public void markSuccess() { + if (this.status == PaymentStatus.SUCCESS) return; + if (this.status != PaymentStatus.PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, "PENDING 상태에서만 성공 처리할 수 있습니다."); + } + this.status = PaymentStatus.SUCCESS; + } + + public void markFailed(String reason) { + if (this.status == PaymentStatus.FAILED) return; + if (this.status != PaymentStatus.PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, "PENDING 상태에서만 실패 처리할 수 있습니다."); + } + this.status = PaymentStatus.FAILED; + this.failureReason = reason; + } + + public Long orderId() { return orderId; } + public Long userId() { return userId; } + public CardType cardType() { return cardType; } + public String maskedCardNo() { return maskedCardNo; } + public Money amount() { return amount; } + public PaymentStatus status() { return status; } + public String transactionKey() { return transactionKey; } + public String failureReason() { return failureReason; } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java new file mode 100644 index 000000000..62499d683 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java @@ -0,0 +1,12 @@ +package com.loopers.domain.payment; + +import java.util.List; +import java.util.Optional; + +public interface PaymentRepository { + PaymentModel save(PaymentModel payment); + Optional findById(Long id); + Optional findByTransactionKey(String transactionKey); + List findAllByOrderId(Long orderId); + List findAllByStatus(PaymentStatus status); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java new file mode 100644 index 000000000..c9f6454b2 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java @@ -0,0 +1,8 @@ +package com.loopers.domain.payment; + +public enum PaymentStatus { + PENDING, + SUCCESS, + FAILED, + CANCELLED +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/event/PaymentCompletedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/event/PaymentCompletedEvent.java new file mode 100644 index 000000000..6ebac1a3f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/event/PaymentCompletedEvent.java @@ -0,0 +1,8 @@ +package com.loopers.domain.payment.event; + +public record PaymentCompletedEvent( + Long paymentId, + Long orderId, + Long userId, + boolean success +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java index e64ce0ffd..5c1d08145 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Money.java @@ -14,7 +14,6 @@ public class Money { public static final Money ZERO = new Money(0); - @Column(name = "price", nullable = false) private int value; public Money(int value) { diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java index 4083c2b0a..a4c3703f3 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java @@ -7,10 +7,16 @@ import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; +import jakarta.persistence.Index; import jakarta.persistence.Table; @Entity -@Table(name = "product") +@Table(name = "product", indexes = { + @Index(name = "idx_product_brand_deleted_like", columnList = "brand_id, deleted_at, like_count"), + @Index(name = "idx_product_deleted_like", columnList = "deleted_at, like_count"), + @Index(name = "idx_product_deleted_created", columnList = "deleted_at, created_at"), + @Index(name = "idx_product_deleted_price", columnList = "deleted_at, price") +}) public class ProductModel extends BaseEntity { @Column(nullable = false) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java index 2c0eeecfc..f74a0f387 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java @@ -17,5 +17,7 @@ public interface ProductRepository { Optional findById(Long id); List findAllByBrandId(Long brandId); + Page findAll(Long brandId, Pageable pageable, ProductSortType sortType); + List findAllByIdInAndDeletedAtIsNull(List ids); } diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java index d2dc834b4..4f4a1b59f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductSortType.java @@ -1,6 +1,7 @@ package com.loopers.domain.product; public enum ProductSortType { + LATEST, CREATED_DESC, PRICE_ASC, PRICE_DESC, diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/event/ProductViewedEvent.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/event/ProductViewedEvent.java new file mode 100644 index 000000000..8e1872024 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/event/ProductViewedEvent.java @@ -0,0 +1,7 @@ +package com.loopers.domain.product.event; + +public record ProductViewedEvent( + Long productId, + Long userId +) { +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java index e0c62cf0d..5e3761cee 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/stock/StockRepository.java @@ -4,6 +4,7 @@ import java.util.Optional; public interface StockRepository { + StockModel save(StockModel stock); Optional findByProductId(Long productId); Optional findByProductIdForUpdate(Long productId); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java index e4a3b1a3c..cdb8d3eea 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/brand/BrandJpaRepository.java @@ -10,7 +10,11 @@ public interface BrandJpaRepository extends JpaRepository { - Optional findByNameValue(String value); + Optional findByIdAndDeletedAtIsNull(Long id); + + Optional findByNameAndDeletedAtIsNull(String name); + + Page findAllByDeletedAtIsNull(Pageable pageable); List findAllByIdIn(List ids); } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxJpaRepository.java new file mode 100644 index 000000000..c53ce78c8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxJpaRepository.java @@ -0,0 +1,33 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxStatus; +import jakarta.persistence.LockModeType; +import jakarta.persistence.QueryHint; +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.jpa.repository.QueryHints; +import org.springframework.data.repository.query.Param; + +import java.time.ZonedDateTime; +import java.util.List; + +public interface OutboxJpaRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "-2")}) // SKIP LOCKED + @Query("SELECT e FROM OutboxEvent e WHERE e.status = 'PENDING' ORDER BY e.createdAt ASC LIMIT :batchSize") + List findPendingEventsForUpdate(@Param("batchSize") int batchSize); + + @Query("SELECT e FROM OutboxEvent e WHERE e.status = 'PROCESSING' AND e.updatedAt < :threshold") + List findStalledProcessingEvents(@Param("threshold") ZonedDateTime threshold); + + @Query("SELECT e FROM OutboxEvent e WHERE e.status = 'FAILED' AND e.updatedAt < :threshold AND e.retryCount < :maxRetryCount") + List findRetryableFailedEvents(@Param("threshold") ZonedDateTime threshold, @Param("maxRetryCount") int maxRetryCount); + + @Modifying + @Query("DELETE FROM OutboxEvent e WHERE e.status = 'PUBLISHED' AND e.publishedAt < :threshold") + void deletePublishedEventsBefore(@Param("threshold") ZonedDateTime threshold); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRelayService.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRelayService.java new file mode 100644 index 000000000..8e3abd0e9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRelayService.java @@ -0,0 +1,151 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxRepository; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.header.internals.RecordHeader; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.time.ZonedDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +@Slf4j +@Component +public class OutboxRelayService { + + private static final int BATCH_SIZE = 100; + private static final int MAX_RETRY_COUNT = 5; + + private final OutboxRepository outboxRepository; + private final OutboxJpaRepository outboxJpaRepository; + private final KafkaTemplate kafkaTemplate; + private final ExecutorService outboxRelayExecutor; + + public OutboxRelayService( + OutboxRepository outboxRepository, + OutboxJpaRepository outboxJpaRepository, + KafkaTemplate kafkaTemplate, + @Qualifier("outboxRelayExecutor") ExecutorService outboxRelayExecutor + ) { + this.outboxRepository = outboxRepository; + this.outboxJpaRepository = outboxJpaRepository; + this.kafkaTemplate = kafkaTemplate; + this.outboxRelayExecutor = outboxRelayExecutor; + } + + /** + * Phase 1: PENDING 이벤트를 PROCESSING으로 전환 (FOR UPDATE SKIP LOCKED) + * Phase 2: partitionKey별 그루핑 → 전용 스레드풀로 Kafka 발행 + */ + @Scheduled(fixedDelay = 1000) + public void relay() { + // Phase 1: PENDING → PROCESSING + List events = fetchAndMarkProcessing(); + if (events.isEmpty()) { + return; + } + + // Phase 2: partitionKey별 그루핑 → Kafka 발행 + Map> grouped = events.stream() + .collect(Collectors.groupingBy(OutboxEvent::getPartitionKey)); + + List> futures = new ArrayList<>(); + for (Map.Entry> entry : grouped.entrySet()) { + CompletableFuture future = CompletableFuture.runAsync( + () -> publishEvents(entry.getValue()), + outboxRelayExecutor + ); + futures.add(future); + } + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); + log.info("Outbox relay 완료: {}건 처리", events.size()); + } + + @Transactional + public List fetchAndMarkProcessing() { + List events = outboxRepository.findPendingEventsForUpdate(BATCH_SIZE); + events.forEach(OutboxEvent::markProcessing); + return events; + } + + private void publishEvents(List events) { + for (OutboxEvent event : events) { + try { + ProducerRecord record = new ProducerRecord<>( + event.getTopic(), null, event.getPartitionKey(), event.getPayload() + ); + record.headers().add(new RecordHeader("X-Event-Type", + event.getEventType().getBytes(StandardCharsets.UTF_8))); + record.headers().add(new RecordHeader("X-Aggregate-Type", + event.getAggregateType().getBytes(StandardCharsets.UTF_8))); + record.headers().add(new RecordHeader("X-Event-Id", + event.getEventId().getBytes(StandardCharsets.UTF_8))); + + kafkaTemplate.send(record).get(10, TimeUnit.SECONDS); + event.markPublished(); + outboxJpaRepository.save(event); + } catch (Exception e) { + log.error("Kafka 발행 실패: eventId={}, error={}", event.getEventId(), e.getMessage()); + event.markFailed(truncate(e.getMessage(), 500)); + outboxJpaRepository.save(event); + } + } + } + + /** + * Recovery: 5분 이상 PROCESSING 상태인 이벤트 → PENDING 복구 + */ + @Scheduled(fixedDelay = 60000) + @Transactional + public void recoverStalledEvents() { + ZonedDateTime threshold = ZonedDateTime.now().minusMinutes(5); + List stalled = outboxRepository.findStalledProcessingEvents(threshold); + stalled.forEach(OutboxEvent::markRetry); + if (!stalled.isEmpty()) { + log.warn("Stalled 이벤트 복구: {}건", stalled.size()); + } + } + + /** + * Recovery: 30초 이상 된 FAILED 이벤트 → PENDING 재시도 (최대 5회) + */ + @Scheduled(fixedDelay = 30000) + @Transactional + public void retryFailedEvents() { + ZonedDateTime threshold = ZonedDateTime.now().minusSeconds(30); + List failed = outboxRepository.findRetryableFailedEvents(threshold, MAX_RETRY_COUNT); + failed.forEach(OutboxEvent::markRetry); + if (!failed.isEmpty()) { + log.info("Failed 이벤트 재시도: {}건", failed.size()); + } + } + + /** + * Cleanup: 1시간 이상 된 PUBLISHED 이벤트 삭제 + */ + @Scheduled(fixedDelay = 3600000) + @Transactional + public void cleanupPublishedEvents() { + ZonedDateTime threshold = ZonedDateTime.now().minusHours(1); + outboxRepository.deletePublishedEventsBefore(threshold); + log.info("Published 이벤트 정리 완료"); + } + + private String truncate(String str, int maxLength) { + if (str == null) return null; + return str.length() > maxLength ? str.substring(0, maxLength) : str; + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRepositoryImpl.java new file mode 100644 index 000000000..0551780bd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/outbox/OutboxRepositoryImpl.java @@ -0,0 +1,41 @@ +package com.loopers.infrastructure.outbox; + +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.ZonedDateTime; +import java.util.List; + +@RequiredArgsConstructor +@Repository +public class OutboxRepositoryImpl implements OutboxRepository { + + private final OutboxJpaRepository outboxJpaRepository; + + @Override + public OutboxEvent save(OutboxEvent event) { + return outboxJpaRepository.save(event); + } + + @Override + public List findPendingEventsForUpdate(int batchSize) { + return outboxJpaRepository.findPendingEventsForUpdate(batchSize); + } + + @Override + public List findStalledProcessingEvents(ZonedDateTime threshold) { + return outboxJpaRepository.findStalledProcessingEvents(threshold); + } + + @Override + public List findRetryableFailedEvents(ZonedDateTime threshold, int maxRetryCount) { + return outboxJpaRepository.findRetryableFailedEvents(threshold, maxRetryCount); + } + + @Override + public void deletePublishedEventsBefore(ZonedDateTime threshold) { + outboxJpaRepository.deletePublishedEventsBefore(threshold); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java new file mode 100644 index 000000000..10d57de1e --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java @@ -0,0 +1,14 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.PaymentModel; +import com.loopers.domain.payment.PaymentStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface PaymentJpaRepository extends JpaRepository { + Optional findByTransactionKey(String transactionKey); + List findAllByOrderId(Long orderId); + List findAllByStatus(PaymentStatus status); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java new file mode 100644 index 000000000..1b85a08b8 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java @@ -0,0 +1,42 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.PaymentModel; +import com.loopers.domain.payment.PaymentRepository; +import com.loopers.domain.payment.PaymentStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class PaymentRepositoryImpl implements PaymentRepository { + + private final PaymentJpaRepository paymentJpaRepository; + + @Override + public PaymentModel save(PaymentModel payment) { + return paymentJpaRepository.save(payment); + } + + @Override + public Optional findById(Long id) { + return paymentJpaRepository.findById(id); + } + + @Override + public Optional findByTransactionKey(String transactionKey) { + return paymentJpaRepository.findByTransactionKey(transactionKey); + } + + @Override + public List findAllByOrderId(Long orderId) { + return paymentJpaRepository.findAllByOrderId(orderId); + } + + @Override + public List findAllByStatus(PaymentStatus status) { + return paymentJpaRepository.findAllByStatus(status); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgClient.java new file mode 100644 index 000000000..9a28aef1f --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgClient.java @@ -0,0 +1,41 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.infrastructure.payment.dto.PgPaymentRequest; +import com.loopers.infrastructure.payment.dto.PgPaymentResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@RequiredArgsConstructor +@Component +public class PgClient { + + private final RestTemplate pgRestTemplate; + + public PgPaymentResponse requestPayment(PgPaymentRequest request, String userId) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-USER-ID", userId); + + HttpEntity httpEntity = new HttpEntity<>(request, headers); + return pgRestTemplate.postForObject("/api/v1/payments", httpEntity, PgPaymentResponse.class); + } + + public PgPaymentResponse getPaymentStatus(String transactionKey, String userId) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", userId); + + HttpEntity httpEntity = new HttpEntity<>(headers); + return pgRestTemplate.exchange( + "/api/v1/payments/{transactionKey}", + HttpMethod.GET, + httpEntity, + PgPaymentResponse.class, + transactionKey + ).getBody(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentGateway.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentGateway.java new file mode 100644 index 000000000..61fd4ce17 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentGateway.java @@ -0,0 +1,31 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.infrastructure.payment.dto.PgPaymentRequest; +import com.loopers.infrastructure.payment.dto.PgPaymentResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import io.github.resilience4j.bulkhead.annotation.Bulkhead; +import io.github.resilience4j.retry.annotation.Retry; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@RequiredArgsConstructor +@Component +public class PgPaymentGateway { + + private final PgClient pgClient; + + @Bulkhead(name = "pg") + @Retry(name = "pg") + public PgPaymentResponse requestPayment(PgPaymentRequest request, String userId) { + PgPaymentResponse response = pgClient.requestPayment(request, userId); + if (response == null || response.transactionKey() == null) { + throw new CoreException(ErrorType.PG_REQUEST_FAILED, "PG 응답이 유효하지 않습니다."); + } + return response; + } + + public PgPaymentResponse getPaymentStatus(String transactionKey, String userId) { + return pgClient.getPaymentStatus(transactionKey, userId); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/dto/PgPaymentRequest.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/dto/PgPaymentRequest.java new file mode 100644 index 000000000..7fb037d02 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/dto/PgPaymentRequest.java @@ -0,0 +1,9 @@ +package com.loopers.infrastructure.payment.dto; + +public record PgPaymentRequest( + String orderId, + String cardType, + String cardNo, + String amount, + String callbackUrl +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/dto/PgPaymentResponse.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/dto/PgPaymentResponse.java new file mode 100644 index 000000000..5a6db0a45 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/dto/PgPaymentResponse.java @@ -0,0 +1,8 @@ +package com.loopers.infrastructure.payment.dto; + +public record PgPaymentResponse( + String transactionKey, + String orderId, + String status, + String failureReason +) {} diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java index 9d09cdbc4..9480f9ab4 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductJpaRepository.java @@ -21,8 +21,12 @@ public interface ProductJpaRepository extends JpaRepository @Query("UPDATE ProductModel p SET p.likeCount = p.likeCount - 1 WHERE p.id = :id AND p.likeCount > 0") int decrementLikeCount(@Param("id") Long id); + Optional findByIdAndDeletedAtIsNull(Long id); + Page findAllByDeletedAtIsNull(Pageable pageable); + List findAllByBrandIdAndDeletedAtIsNull(Long brandId); + Page findAllByBrandIdAndDeletedAtIsNull(Long brandId, Pageable pageable); Page findAllByBrandId(Long brandId, Pageable pageable); diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java index a6a1b765a..7080dd59f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java @@ -3,11 +3,14 @@ import com.loopers.domain.product.ProductModel; import com.loopers.domain.product.ProductRepository; import com.loopers.domain.product.ProductSortType; +import com.loopers.domain.product.QProductModel; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; import org.springframework.stereotype.Component; import java.util.List; @@ -18,6 +21,7 @@ public class ProductRepositoryImpl implements ProductRepository { private final ProductJpaRepository productJpaRepository; + private final JPAQueryFactory queryFactory; @Override public ProductModel save(ProductModel product) { @@ -45,23 +49,38 @@ public List findAllByBrandId(Long brandId) { } @Override - public Page findAll(Pageable pageable, ProductSortType sortType) { - Sort sort = toSort(sortType); - Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort); - return productJpaRepository.findAllByDeletedAtIsNull(sortedPageable); - } + public Page findAll(Long brandId, Pageable pageable, ProductSortType sortType) { + QProductModel product = QProductModel.productModel; - @Override - public ProductModel save(ProductModel product) { - return productJpaRepository.save(product); + BooleanBuilder where = new BooleanBuilder(); + where.and(product.deletedAt.isNull()); + if (brandId != null) { + where.and(product.brandId.eq(brandId)); + } + + OrderSpecifier orderSpecifier = toOrderSpecifier(product, sortType); + + List content = queryFactory.selectFrom(product) + .where(where) + .orderBy(orderSpecifier) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory.select(product.count()) + .from(product) + .where(where) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0); } - private Sort toSort(ProductSortType sortType) { + private OrderSpecifier toOrderSpecifier(QProductModel product, ProductSortType sortType) { return switch (sortType) { - case CREATED_DESC -> Sort.by(Sort.Direction.DESC, "createdAt"); - case PRICE_ASC -> Sort.by(Sort.Direction.ASC, "price.value"); - case PRICE_DESC -> Sort.by(Sort.Direction.DESC, "price.value"); - case LIKES_DESC -> Sort.by(Sort.Direction.DESC, "likeCount"); + case LATEST, CREATED_DESC -> product.createdAt.desc(); + case PRICE_ASC -> product.price.value.asc(); + case PRICE_DESC -> product.price.value.desc(); + case LIKES_DESC -> product.likeCount.desc(); }; } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java index d7242d0c6..d95eb90c6 100644 --- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/stock/StockRepositoryImpl.java @@ -14,6 +14,11 @@ public class StockRepositoryImpl implements StockRepository { private final StockJpaRepository stockJpaRepository; + @Override + public StockModel save(StockModel stock) { + return stockJpaRepository.save(stock); + } + @Override public Optional findByProductId(Long productId) { return stockJpaRepository.findByProductId(productId); diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMemberArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMemberArgumentResolver.java index d746e275d..c686dcac0 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMemberArgumentResolver.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/auth/LoginMemberArgumentResolver.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.auth; -import com.loopers.domain.member.MemberAuthService; +import com.loopers.application.member.MemberAuthService; import com.loopers.domain.member.MemberModel; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java index 07d6d8821..e3f7560e1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/BrandAdminV1Controller.java @@ -50,7 +50,7 @@ public ApiResponse getById( @AdminUser String adminLdap, @PathVariable(value = "brandId") Long brandId ) { - BrandInfo info = brandFacade.getById(brandId); + BrandInfo info = brandFacade.getBrandForAdmin(brandId); return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java deleted file mode 100644 index 680103f80..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Controller.java +++ /dev/null @@ -1,71 +0,0 @@ -package com.loopers.interfaces.api.brand.admin; - -import com.loopers.application.brand.BrandFacade; -import com.loopers.application.brand.BrandInfo; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.AdminInfo; -import com.loopers.interfaces.auth.AdminUser; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api-admin/v1/brands") -public class BrandAdminV1Controller { - - private final BrandFacade brandFacade; - - @PostMapping - public ApiResponse create( - @AdminUser AdminInfo admin, - @RequestBody BrandAdminV1Dto.CreateRequest request - ) { - BrandInfo info = brandFacade.register(request.name(), request.description()); - return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); - } - - @GetMapping - public ApiResponse> getAll( - @AdminUser AdminInfo admin, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size - ) { - Page result = brandFacade.getAll(PageRequest.of(page, size)); - return ApiResponse.success(result.map(BrandAdminV1Dto.BrandResponse::from)); - } - - @GetMapping("/{brandId}") - public ApiResponse getBrand( - @AdminUser AdminInfo admin, - @PathVariable Long brandId - ) { - BrandInfo info = brandFacade.getBrandForAdmin(brandId); - return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); - } - - @PutMapping("/{brandId}") - public ApiResponse update( - @AdminUser AdminInfo admin, - @PathVariable Long brandId, - @RequestBody BrandAdminV1Dto.UpdateRequest request - ) { - BrandInfo info = brandFacade.update(brandId, request.name(), request.description()); - return ApiResponse.success(BrandAdminV1Dto.BrandResponse.from(info)); - } - - @DeleteMapping("/{brandId}") - public ApiResponse delete(@AdminUser AdminInfo admin, @PathVariable Long brandId) { - brandFacade.delete(brandId); - return ApiResponse.success(); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java deleted file mode 100644 index 7ae7e03fe..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/brand/admin/BrandAdminV1Dto.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.loopers.interfaces.api.brand.admin; - -import com.loopers.application.brand.BrandInfo; - -import java.time.ZonedDateTime; - -public class BrandAdminV1Dto { - - public record CreateRequest( - String name, - String description - ) {} - - public record UpdateRequest( - String name, - String description - ) {} - - public record BrandResponse( - Long id, - String name, - String description, - ZonedDateTime createdAt, - ZonedDateTime updatedAt, - ZonedDateTime deletedAt - ) { - public static BrandResponse from(BrandInfo info) { - return new BrandResponse( - info.id(), - info.name(), - info.description(), - info.createdAt(), - info.updatedAt(), - info.deletedAt() - ); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java index ea9294343..12263dce7 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponAdminV1Controller.java @@ -4,8 +4,7 @@ import com.loopers.application.coupon.CouponService; import com.loopers.domain.coupon.CouponModel; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.AdminInfo; -import com.loopers.interfaces.auth.AdminUser; +import com.loopers.interfaces.api.auth.AdminUser; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -20,7 +19,7 @@ public class CouponAdminV1Controller { @GetMapping("/api-admin/v1/coupons") public ApiResponse> getCoupons( - @AdminUser AdminInfo admin, + @AdminUser String adminLdap, Pageable pageable ) { Page coupons = couponService.getAllCoupons(pageable); @@ -29,7 +28,7 @@ public ApiResponse> getCoupons( @GetMapping("/api-admin/v1/coupons/{couponId}") public ApiResponse getCoupon( - @AdminUser AdminInfo admin, + @AdminUser String adminLdap, @PathVariable Long couponId ) { CouponModel coupon = couponService.getCoupon(couponId); @@ -38,7 +37,7 @@ public ApiResponse getCoupon( @PostMapping("/api-admin/v1/coupons") public ApiResponse createCoupon( - @AdminUser AdminInfo admin, + @AdminUser String adminLdap, @RequestBody CouponAdminV1Dto.CreateRequest request ) { CouponModel coupon = couponService.create( @@ -50,7 +49,7 @@ public ApiResponse createCoupon( @PutMapping("/api-admin/v1/coupons/{couponId}") public ApiResponse updateCoupon( - @AdminUser AdminInfo admin, + @AdminUser String adminLdap, @PathVariable Long couponId, @RequestBody CouponAdminV1Dto.UpdateRequest request ) { @@ -63,7 +62,7 @@ public ApiResponse updateCoupon( @DeleteMapping("/api-admin/v1/coupons/{couponId}") public ApiResponse deleteCoupon( - @AdminUser AdminInfo admin, + @AdminUser String adminLdap, @PathVariable Long couponId ) { couponService.delete(couponId); @@ -72,7 +71,7 @@ public ApiResponse deleteCoupon( @GetMapping("/api-admin/v1/coupons/{couponId}/issues") public ApiResponse> getCouponIssues( - @AdminUser AdminInfo admin, + @AdminUser String adminLdap, @PathVariable Long couponId, Pageable pageable ) { diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java index cbbe0042e..648ba5fad 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Controller.java @@ -1,16 +1,18 @@ package com.loopers.interfaces.api.coupon; +import com.loopers.application.coupon.CouponFacade; import com.loopers.application.coupon.CouponIssueService; import com.loopers.application.coupon.CouponService; import com.loopers.domain.coupon.CouponIssueModel; import com.loopers.domain.coupon.CouponModel; import com.loopers.domain.member.MemberModel; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.LoginMember; +import com.loopers.interfaces.api.auth.LoginMember; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import java.util.List; +import java.util.Optional; @RequiredArgsConstructor @RestController @@ -18,6 +20,7 @@ public class CouponV1Controller { private final CouponService couponService; private final CouponIssueService couponIssueService; + private final CouponFacade couponFacade; @PostMapping("/api/v1/coupons/{couponId}/issue") public ApiResponse issueCoupon( @@ -29,6 +32,34 @@ public ApiResponse issueCoupon( return ApiResponse.success(CouponV1Dto.CouponIssueResponse.from(issue)); } + /** + * 선착순 쿠폰 발급 요청 (비동기 — Kafka로 위임) + */ + @PostMapping("/api/v1/coupons/{couponId}/issue-async") + public ApiResponse requestCouponIssue( + @LoginMember MemberModel member, + @PathVariable Long couponId + ) { + couponFacade.requestCouponIssue(couponId, member.getId()); + return ApiResponse.success(null); + } + + /** + * 쿠폰 발급 결과 확인 (Polling) + * 비동기 발급 요청 후 클라이언트가 주기적으로 호출하여 발급 결과를 확인한다. + */ + @GetMapping("/api/v1/coupons/{couponId}/issue-status") + public ApiResponse getCouponIssueStatus( + @LoginMember MemberModel member, + @PathVariable Long couponId + ) { + Optional issue = couponIssueService.findByUserAndCoupon(member.getId(), couponId); + CouponV1Dto.CouponIssueStatusResponse response = issue + .map(i -> new CouponV1Dto.CouponIssueStatusResponse("ISSUED", CouponV1Dto.CouponIssueResponse.from(i))) + .orElseGet(() -> new CouponV1Dto.CouponIssueStatusResponse("PENDING", null)); + return ApiResponse.success(response); + } + @GetMapping("/api/v1/users/me/coupons") public ApiResponse> getMyCoupons( @LoginMember MemberModel member diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java index 590607135..e5c9c455e 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/coupon/CouponV1Dto.java @@ -25,4 +25,14 @@ public static CouponIssueResponse from(CouponIssueModel issue) { ); } } + + /** + * 비동기 쿠폰 발급 상태 응답. + * status: PENDING(아직 처리 안 됨), ISSUED(발급 완료) + */ + public record CouponIssueStatusResponse( + String status, + CouponIssueResponse issue + ) { + } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java index da90d2f46..cccf79cff 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/like/LikeV1Controller.java @@ -4,7 +4,7 @@ import com.loopers.application.like.LikeWithProduct; import com.loopers.domain.member.MemberModel; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.LoginMember; +import com.loopers.interfaces.api.auth.LoginMember; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import lombok.RequiredArgsConstructor; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java index bcdcfd412..93235c45a 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/member/MemberV1Controller.java @@ -4,7 +4,7 @@ import com.loopers.application.member.MemberInfo; import com.loopers.domain.member.MemberModel; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.LoginMember; +import com.loopers.interfaces.api.auth.LoginMember; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PutMapping; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java index fcfcc414a..9c389b0c1 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Controller.java @@ -25,7 +25,7 @@ public ApiResponse> getAll( @AdminUser String adminLdap, Pageable pageable ) { - Page response = orderFacade.getAllOrders(pageable) + Page response = orderFacade.getAllForAdmin(pageable) .map(OrderAdminV1Dto.OrderAdminSummaryResponse::from); return ApiResponse.success(response); } @@ -36,7 +36,7 @@ public ApiResponse getById( @AdminUser String adminLdap, @PathVariable(value = "orderId") Long orderId ) { - OrderInfo.Detail info = orderFacade.getDetailForAdmin(orderId); + OrderInfo info = orderFacade.getOrderForAdmin(orderId); return ApiResponse.success(OrderAdminV1Dto.OrderAdminDetailResponse.from(info)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java index a77501ea1..63dce7090 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderAdminV1Dto.java @@ -10,9 +10,9 @@ public class OrderAdminV1Dto { public record OrderAdminSummaryResponse( Long id, int totalAmount, String status, int itemCount, ZonedDateTime createdAt ) { - public static OrderAdminSummaryResponse from(OrderInfo.Summary info) { + public static OrderAdminSummaryResponse from(OrderInfo info) { return new OrderAdminSummaryResponse( - info.id(), info.totalAmount(), info.status(), info.itemCount(), info.createdAt() + info.orderId(), info.totalAmount(), info.status(), info.items().size(), info.createdAt() ); } } @@ -21,12 +21,12 @@ public record OrderAdminDetailResponse( Long id, Long memberId, int totalAmount, String status, List orderItems, ZonedDateTime createdAt ) { - public static OrderAdminDetailResponse from(OrderInfo.Detail info) { - List items = info.orderItems().stream() + public static OrderAdminDetailResponse from(OrderInfo info) { + List items = info.items().stream() .map(OrderV1Dto.OrderItemResponse::from) .toList(); return new OrderAdminDetailResponse( - info.id(), info.memberId(), info.totalAmount(), info.status(), items, info.createdAt() + info.orderId(), info.userId(), info.totalAmount(), info.status(), items, info.createdAt() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java index 21c758e17..b81ac1ccb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1ApiSpec.java @@ -11,11 +11,11 @@ public interface OrderV1ApiSpec { @Operation(summary = "주문 생성", description = "새로운 주문을 생성합니다.") - ApiResponse createOrder(MemberModel member, OrderV1Dto.CreateOrderRequest request); + ApiResponse createOrder(MemberModel member, OrderV1Dto.CreateRequest request); @Operation(summary = "내 주문 목록 조회", description = "내 주문 목록을 조회합니다.") - ApiResponse> getMyOrders(MemberModel member, Pageable pageable); + ApiResponse getMyOrders(MemberModel member, Pageable pageable); @Operation(summary = "주문 상세 조회", description = "주문 상세 정보를 조회합니다.") - ApiResponse getById(MemberModel member, Long orderId); + ApiResponse getById(MemberModel member, Long orderId); } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java index 385550781..ac1c863ce 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Controller.java @@ -5,7 +5,7 @@ import com.loopers.application.order.OrderResult; import com.loopers.domain.member.MemberModel; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.LoginMember; +import com.loopers.interfaces.api.auth.LoginMember; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.web.bind.annotation.GetMapping; diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java deleted file mode 100644 index 1067c8535..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Controller.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.loopers.interfaces.api.order.admin; - -import com.loopers.application.order.OrderFacade; -import com.loopers.application.order.OrderInfo; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.AdminInfo; -import com.loopers.interfaces.auth.AdminUser; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api-admin/v1/orders") -public class OrderAdminV1Controller { - - private final OrderFacade orderFacade; - - @GetMapping - public ApiResponse> getAll( - @AdminUser AdminInfo admin, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size - ) { - Page orders = orderFacade.getAllForAdmin(PageRequest.of(page, size)); - return ApiResponse.success(orders.map(OrderAdminV1Dto.OrderSummaryResponse::from)); - } - - @GetMapping("/{orderId}") - public ApiResponse getOrder( - @AdminUser AdminInfo admin, - @PathVariable Long orderId - ) { - OrderInfo info = orderFacade.getOrderForAdmin(orderId); - return ApiResponse.success(OrderAdminV1Dto.OrderResponse.from(info)); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java deleted file mode 100644 index a7c275e62..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/admin/OrderAdminV1Dto.java +++ /dev/null @@ -1,61 +0,0 @@ -package com.loopers.interfaces.api.order.admin; - -import com.loopers.application.order.OrderInfo; - -import java.time.ZonedDateTime; -import java.util.List; - -public class OrderAdminV1Dto { - - public record OrderResponse( - Long orderId, - Long userId, - String status, - int totalAmount, - List items, - ZonedDateTime createdAt - ) { - - public static OrderResponse from(OrderInfo info) { - List items = info.items().stream() - .map(OrderItemResponse::from) - .toList(); - return new OrderResponse( - info.orderId(), info.userId(), info.status(), - info.totalAmount(), items, info.createdAt() - ); - } - } - - public record OrderSummaryResponse( - Long orderId, - Long userId, - String status, - int totalAmount, - ZonedDateTime createdAt - ) { - - public static OrderSummaryResponse from(OrderInfo info) { - return new OrderSummaryResponse( - info.orderId(), info.userId(), info.status(), - info.totalAmount(), info.createdAt() - ); - } - } - - public record OrderItemResponse( - Long productId, - String productName, - int productPrice, - int quantity, - int subtotal - ) { - - public static OrderItemResponse from(OrderInfo.OrderItemInfo item) { - return new OrderItemResponse( - item.productId(), item.productName(), - item.productPrice(), item.quantity(), item.subtotal() - ); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1ApiSpec.java new file mode 100644 index 000000000..6f8d17f44 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1ApiSpec.java @@ -0,0 +1,30 @@ +package com.loopers.interfaces.api.payment; + +import com.loopers.domain.member.MemberModel; +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.util.List; + +@Tag(name = "Payment V1 API", description = "결제 API") +public interface PaymentV1ApiSpec { + + @Operation(summary = "결제 요청", description = "주문에 대한 결제를 요청합니다.") + ApiResponse requestPayment( + MemberModel member, PaymentV1Dto.PaymentRequest request + ); + + @Operation(summary = "결제 상태 조회", description = "결제 상세 정보를 조회합니다.") + ApiResponse getPayment( + MemberModel member, Long paymentId + ); + + @Operation(summary = "주문별 결제 내역 조회", description = "주문에 대한 모든 결제 시도를 조회합니다.") + ApiResponse> getPaymentsByOrderId( + MemberModel member, Long orderId + ); + + @Operation(summary = "PG 콜백 수신", description = "PG 시스템으로부터 결제 결과를 수신합니다.") + ApiResponse handleCallback(PaymentV1Dto.CallbackRequest request); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java new file mode 100644 index 000000000..0f17f6ff9 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java @@ -0,0 +1,69 @@ +package com.loopers.interfaces.api.payment; + +import com.loopers.application.payment.PaymentFacade; +import com.loopers.application.payment.PaymentInfo; +import com.loopers.domain.member.MemberModel; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.LoginMember; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +public class PaymentV1Controller implements PaymentV1ApiSpec { + + private final PaymentFacade paymentFacade; + + @PostMapping("/api/v1/payments") + @Override + public ApiResponse requestPayment( + @LoginMember MemberModel member, + @RequestBody PaymentV1Dto.PaymentRequest request + ) { + PaymentInfo info = paymentFacade.requestPayment(member.getId(), request.toCommand()); + return ApiResponse.success(PaymentV1Dto.PaymentResponse.from(info)); + } + + @GetMapping("/api/v1/payments/{paymentId}") + @Override + public ApiResponse getPayment( + @LoginMember MemberModel member, + @PathVariable Long paymentId + ) { + PaymentInfo info = paymentFacade.getPayment(paymentId); + return ApiResponse.success(PaymentV1Dto.PaymentResponse.from(info)); + } + + @GetMapping("/api/v1/payments") + @Override + public ApiResponse> getPaymentsByOrderId( + @LoginMember MemberModel member, + @RequestParam Long orderId + ) { + List infos = paymentFacade.getPaymentsByOrderId(orderId); + List responses = infos.stream() + .map(PaymentV1Dto.PaymentResponse::from) + .toList(); + return ApiResponse.success(responses); + } + + @PostMapping("/api/v1/payments/{paymentId}/sync") + public ApiResponse syncPaymentStatus( + @LoginMember MemberModel member, + @PathVariable Long paymentId + ) { + PaymentInfo info = paymentFacade.syncPaymentStatus(paymentId); + return ApiResponse.success(PaymentV1Dto.PaymentResponse.from(info)); + } + + @PostMapping("/api/v1/payments/callback") + @Override + public ApiResponse handleCallback( + @RequestBody PaymentV1Dto.CallbackRequest request + ) { + paymentFacade.handleCallback(request.transactionKey(), request.status(), request.failureReason()); + return ApiResponse.success(); + } +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java new file mode 100644 index 000000000..786922db7 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java @@ -0,0 +1,53 @@ +package com.loopers.interfaces.api.payment; + +import com.loopers.application.payment.PaymentCommand; +import com.loopers.application.payment.PaymentInfo; +import com.loopers.domain.payment.CardType; + +import java.time.ZonedDateTime; + +public class PaymentV1Dto { + + public record PaymentRequest( + Long orderId, + String cardType, + String cardNo + ) { + public PaymentCommand toCommand() { + return new PaymentCommand(orderId, CardType.valueOf(cardType), cardNo); + } + } + + public record PaymentResponse( + Long paymentId, + Long orderId, + String transactionKey, + String cardType, + String maskedCardNo, + int amount, + String status, + String failureReason, + ZonedDateTime createdAt + ) { + public static PaymentResponse from(PaymentInfo info) { + return new PaymentResponse( + info.paymentId(), + info.orderId(), + info.transactionKey(), + info.cardType(), + info.maskedCardNo(), + info.amount(), + info.status(), + info.failureReason(), + info.createdAt() + ); + } + } + + public record CallbackRequest( + String transactionKey, + String orderId, + String status, + String failureReason + ) {} +} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java index c348c5076..b858f2e35 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Controller.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.product; +import com.loopers.application.product.ProductDetail; import com.loopers.application.product.ProductFacade; -import com.loopers.application.product.ProductInfo; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductSortType; import com.loopers.interfaces.api.ApiResponse; @@ -33,11 +33,11 @@ public ApiResponse create( @AdminUser String adminLdap, @RequestBody ProductAdminV1Dto.CreateRequest request ) { - ProductInfo.AdminDetail info = productFacade.register( + ProductDetail detail = productFacade.register( request.name(), request.description(), new Money(request.price()), request.brandId(), request.stockQuantity() ); - return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(info)); + return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(detail)); } @GetMapping @@ -48,7 +48,7 @@ public ApiResponse> getAll( @RequestParam(value = "sortType", defaultValue = "CREATED_DESC") String sortType ) { ProductSortType sort = ProductSortType.valueOf(sortType); - Page response = productFacade.getAllForAdmin(pageable, sort) + Page response = productFacade.getProductsForAdmin(null, pageable) .map(ProductAdminV1Dto.ProductAdminSummaryResponse::from); return ApiResponse.success(response); } @@ -59,8 +59,8 @@ public ApiResponse getById( @AdminUser String adminLdap, @PathVariable(value = "productId") Long productId ) { - ProductInfo.AdminDetail info = productFacade.getDetailForAdmin(productId); - return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(info)); + ProductDetail detail = productFacade.getProductForAdmin(productId); + return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(detail)); } @PutMapping("/{productId}") @@ -70,10 +70,10 @@ public ApiResponse update( @PathVariable(value = "productId") Long productId, @RequestBody ProductAdminV1Dto.UpdateRequest request ) { - ProductInfo.AdminDetail info = productFacade.update( + ProductDetail detail = productFacade.update( productId, request.name(), request.description(), new Money(request.price()) ); - return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(info)); + return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(detail)); } @DeleteMapping("/{productId}") @@ -93,7 +93,7 @@ public ApiResponse updateStock( @PathVariable(value = "productId") Long productId, @RequestBody ProductAdminV1Dto.UpdateStockRequest request ) { - ProductInfo.AdminDetail info = productFacade.updateStock(productId, request.quantity()); - return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(info)); + ProductDetail detail = productFacade.updateStock(productId, request.quantity()); + return ApiResponse.success(ProductAdminV1Dto.ProductAdminDetailResponse.from(detail)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java index 2abdd5f52..c47d97e31 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductAdminV1Dto.java @@ -1,6 +1,6 @@ package com.loopers.interfaces.api.product; -import com.loopers.application.product.ProductInfo; +import com.loopers.application.product.ProductDetail; public class ProductAdminV1Dto { @@ -13,9 +13,9 @@ public record UpdateStockRequest(int quantity) {} public record ProductAdminSummaryResponse( Long id, String name, int price, String brandName, int stockQuantity ) { - public static ProductAdminSummaryResponse from(ProductInfo.AdminSummary info) { + public static ProductAdminSummaryResponse from(ProductDetail detail) { return new ProductAdminSummaryResponse( - info.id(), info.name(), info.price(), info.brandName(), info.stockQuantity() + detail.id(), detail.name(), detail.price(), detail.brandName(), detail.stockQuantity() ); } } @@ -24,10 +24,10 @@ public record ProductAdminDetailResponse( Long id, String name, String description, int price, Long brandId, String brandName, int likeCount, int stockQuantity ) { - public static ProductAdminDetailResponse from(ProductInfo.AdminDetail info) { + public static ProductAdminDetailResponse from(ProductDetail detail) { return new ProductAdminDetailResponse( - info.id(), info.name(), info.description(), info.price(), - info.brandId(), info.brandName(), info.likeCount(), info.stockQuantity() + detail.id(), detail.name(), detail.description(), detail.price(), + detail.brandId(), detail.brandName(), detail.likeCount(), detail.stockQuantity() ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java index 71e87e49d..160cf4525 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Controller.java @@ -6,6 +6,7 @@ import com.loopers.interfaces.api.ApiResponse; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -21,19 +22,20 @@ public class ProductV1Controller implements ProductV1ApiSpec { private final ProductFacade productFacade; @GetMapping - public ApiResponse> getProducts( - @RequestParam(required = false) Long brandId, - @RequestParam(defaultValue = "LATEST") ProductSortType sort, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size + @Override + public ApiResponse> getAll( + Pageable pageable, + @RequestParam(defaultValue = "CREATED_DESC") String sortType ) { - Page products = productFacade.getProducts(brandId, sort, PageRequest.of(page, size)); - return ApiResponse.success(products.map(ProductV1Dto.ProductResponse::from)); + ProductSortType sort = ProductSortType.valueOf(sortType); + Page products = productFacade.getProducts(null, sort, pageable); + return ApiResponse.success(products.map(ProductV1Dto.ProductSummaryResponse::from)); } @GetMapping("/{productId}") - public ApiResponse getProduct(@PathVariable Long productId) { + @Override + public ApiResponse getById(@PathVariable Long productId) { ProductDetail detail = productFacade.getProduct(productId); - return ApiResponse.success(ProductV1Dto.ProductResponse.from(detail)); + return ApiResponse.success(ProductV1Dto.ProductDetailResponse.from(detail)); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java index b0a96c407..973da7ac8 100644 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java +++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/ProductV1Dto.java @@ -1,15 +1,29 @@ package com.loopers.interfaces.api.product; import com.loopers.application.product.ProductDetail; -import com.loopers.domain.stock.StockStatus; public class ProductV1Dto { public record ProductSummaryResponse( Long id, String name, int price, String brandName, String stockStatus ) { - public static ProductResponse from(ProductDetail detail) { - return new ProductResponse( + public static ProductSummaryResponse from(ProductDetail detail) { + return new ProductSummaryResponse( + detail.id(), + detail.name(), + detail.price(), + detail.brandName(), + detail.stockStatus() != null ? detail.stockStatus().name() : null + ); + } + } + + public record ProductDetailResponse( + Long id, String name, String description, int price, + Long brandId, String brandName, int likeCount, String stockStatus + ) { + public static ProductDetailResponse from(ProductDetail detail) { + return new ProductDetailResponse( detail.id(), detail.name(), detail.description(), @@ -17,7 +31,7 @@ public static ProductResponse from(ProductDetail detail) { detail.brandId(), detail.brandName(), detail.likeCount(), - detail.stockStatus() + detail.stockStatus() != null ? detail.stockStatus().name() : null ); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java deleted file mode 100644 index 341540a36..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Controller.java +++ /dev/null @@ -1,77 +0,0 @@ -package com.loopers.interfaces.api.product.admin; - -import com.loopers.application.product.ProductDetail; -import com.loopers.application.product.ProductFacade; -import com.loopers.domain.product.Money; -import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.auth.AdminInfo; -import com.loopers.interfaces.auth.AdminUser; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -@RequiredArgsConstructor -@RestController -@RequestMapping("/api-admin/v1/products") -public class ProductAdminV1Controller { - - private final ProductFacade productFacade; - - @PostMapping - public ApiResponse create( - @AdminUser AdminInfo admin, - @RequestBody ProductAdminV1Dto.CreateRequest request - ) { - var product = productFacade.register( - request.name(), request.description(), new Money(request.price()), - request.brandId(), request.initialStock() - ); - ProductDetail detail = productFacade.getProductForAdmin(product.getId()); - return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); - } - - @GetMapping - public ApiResponse> getAll( - @AdminUser AdminInfo admin, - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "20") int size, - @RequestParam(required = false) Long brandId - ) { - Page result = productFacade.getProductsForAdmin(brandId, PageRequest.of(page, size)); - return ApiResponse.success(result.map(ProductAdminV1Dto.ProductResponse::from)); - } - - @GetMapping("/{productId}") - public ApiResponse getProduct( - @AdminUser AdminInfo admin, - @PathVariable Long productId - ) { - ProductDetail detail = productFacade.getProductForAdmin(productId); - return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); - } - - @PutMapping("/{productId}") - public ApiResponse update( - @AdminUser AdminInfo admin, - @PathVariable Long productId, - @RequestBody ProductAdminV1Dto.UpdateRequest request - ) { - ProductDetail detail = productFacade.update(productId, request.name(), request.description(), new Money(request.price())); - return ApiResponse.success(ProductAdminV1Dto.ProductResponse.from(detail)); - } - - @DeleteMapping("/{productId}") - public ApiResponse delete(@AdminUser AdminInfo admin, @PathVariable Long productId) { - productFacade.delete(productId); - return ApiResponse.success(); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java deleted file mode 100644 index 5f58c5cb2..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/product/admin/ProductAdminV1Dto.java +++ /dev/null @@ -1,52 +0,0 @@ -package com.loopers.interfaces.api.product.admin; - -import com.loopers.application.product.ProductDetail; - -import java.time.ZonedDateTime; - -public class ProductAdminV1Dto { - - public record CreateRequest( - String name, - String description, - int price, - Long brandId, - int initialStock - ) {} - - public record UpdateRequest( - String name, - String description, - int price - ) {} - - public record ProductResponse( - Long id, - String name, - String description, - int price, - Long brandId, - String brandName, - int likeCount, - int stockQuantity, - ZonedDateTime createdAt, - ZonedDateTime updatedAt, - ZonedDateTime deletedAt - ) { - public static ProductResponse from(ProductDetail detail) { - return new ProductResponse( - detail.id(), - detail.name(), - detail.description(), - detail.price(), - detail.brandId(), - detail.brandName(), - detail.likeCount(), - detail.stockQuantity(), - detail.createdAt(), - detail.updatedAt(), - detail.deletedAt() - ); - } - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminInfo.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminInfo.java deleted file mode 100644 index 516f8b1d8..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminInfo.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.loopers.interfaces.auth; - -public record AdminInfo(String ldap) { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUser.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUser.java deleted file mode 100644 index 3cf3df48e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUser.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.loopers.interfaces.auth; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target(ElementType.PARAMETER) -@Retention(RetentionPolicy.RUNTIME) -public @interface AdminUser { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUserArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUserArgumentResolver.java deleted file mode 100644 index 969bd5d7e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/AdminUserArgumentResolver.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.loopers.interfaces.auth; - -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import org.springframework.core.MethodParameter; -import org.springframework.stereotype.Component; -import org.springframework.web.bind.support.WebDataBinderFactory; -import org.springframework.web.context.request.NativeWebRequest; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.method.support.ModelAndViewContainer; - -@Component -public class AdminUserArgumentResolver implements HandlerMethodArgumentResolver { - - private static final String VALID_LDAP = "loopers.admin"; - - @Override - public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(AdminUser.class) - && AdminInfo.class.isAssignableFrom(parameter.getParameterType()); - } - - @Override - public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { - String ldap = webRequest.getHeader("X-Loopers-Ldap"); - - if (ldap == null || ldap.isBlank()) { - throw new CoreException(ErrorType.BAD_REQUEST, "어드민 인증 헤더가 누락되었습니다."); - } - - if (!VALID_LDAP.equals(ldap)) { - throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 어드민 인증입니다."); - } - - return new AdminInfo(ldap); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMember.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMember.java deleted file mode 100644 index 93ea3e09a..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMember.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.loopers.interfaces.auth; - -import java.lang.annotation.ElementType; -import java.lang.annotation.Retention; -import java.lang.annotation.RetentionPolicy; -import java.lang.annotation.Target; - -@Target(ElementType.PARAMETER) -@Retention(RetentionPolicy.RUNTIME) -public @interface LoginMember { -} diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java deleted file mode 100644 index 7b8ccac5e..000000000 --- a/apps/commerce-api/src/main/java/com/loopers/interfaces/auth/LoginMemberArgumentResolver.java +++ /dev/null @@ -1,39 +0,0 @@ -package com.loopers.interfaces.auth; - -import com.loopers.application.member.MemberAuthService; -import com.loopers.domain.member.MemberModel; -import com.loopers.support.error.CoreException; -import com.loopers.support.error.ErrorType; -import lombok.RequiredArgsConstructor; -import org.springframework.core.MethodParameter; -import org.springframework.stereotype.Component; -import org.springframework.web.bind.support.WebDataBinderFactory; -import org.springframework.web.context.request.NativeWebRequest; -import org.springframework.web.method.support.HandlerMethodArgumentResolver; -import org.springframework.web.method.support.ModelAndViewContainer; - -@RequiredArgsConstructor -@Component -public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { - - private final MemberAuthService memberAuthService; - - @Override - public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(LoginMember.class) - && MemberModel.class.isAssignableFrom(parameter.getParameterType()); - } - - @Override - public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, - NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { - String loginId = webRequest.getHeader("X-Loopers-LoginId"); - String loginPw = webRequest.getHeader("X-Loopers-LoginPw"); - - if (loginId == null || loginPw == null) { - throw new CoreException(ErrorType.BAD_REQUEST, "인증 헤더가 누락되었습니다."); - } - - return memberAuthService.authenticate(loginId, loginPw); - } -} diff --git a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java index 2c55138ef..84ef3477b 100644 --- a/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +++ b/apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java @@ -24,7 +24,11 @@ public enum ErrorType { PASSWORD_SAME_AS_OLD(HttpStatus.BAD_REQUEST, "Password Same As Old", "현재 비밀번호는 사용할 수 없습니다."), PASSWORD_CONTAINS_BIRTH_DATE(HttpStatus.BAD_REQUEST, "Password Contains Birth Date", "비밀번호에 생년월일을 포함할 수 없습니다."), MEMBER_NOT_FOUND(HttpStatus.UNAUTHORIZED, "Member Not Found", "회원을 찾을 수 없습니다."), - AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "Authentication Failed", "비밀번호가 일치하지 않습니다."); + AUTHENTICATION_FAILED(HttpStatus.UNAUTHORIZED, "Authentication Failed", "비밀번호가 일치하지 않습니다."), + + /** Payment 도메인 에러 */ + PG_REQUEST_FAILED(HttpStatus.BAD_GATEWAY, "PG Request Failed", "결제 시스템 요청에 실패했습니다."), + PG_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "PG Timeout", "결제 시스템 응답 시간이 초과되었습니다."); private final HttpStatus status; private final String code; diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml index 484c070d0..e4fb075ff 100644 --- a/apps/commerce-api/src/main/resources/application.yml +++ b/apps/commerce-api/src/main/resources/application.yml @@ -24,6 +24,52 @@ spring: - logging.yml - monitoring.yml +pg: + base-url: http://localhost:8082 + callback-url: http://localhost:8080/api/v1/payments/callback + timeout: + connect: 1000 + read: 3000 + +resilience4j: + retry: + instances: + pg: + max-attempts: 3 + wait-duration: 500ms + enable-exponential-backoff: true # 지수적 백오프 (500ms → 1s → 2s) + exponential-backoff-multiplier: 2 # 매 재시도마다 2배 + exponential-max-wait-duration: 3s # 최대 대기 시간 + retry-exceptions: + - org.springframework.web.client.ResourceAccessException + - org.springframework.web.client.HttpServerErrorException + ignore-exceptions: + - org.springframework.web.client.HttpClientErrorException + fail-after-max-attempts: true + circuitbreaker: + instances: + pg: + sliding-window-size: 10 + minimum-number-of-calls: 10 # 최소 10번 호출 후 실패율 계산 (기본값 100 함정 방지) + failure-rate-threshold: 50 + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 3 + max-wait-duration-in-half-open-state: 5s # HALF-OPEN 상태 최대 대기 (무한 대기 방지) + automatic-transition-from-open-to-half-open-enabled: true # 트래픽 없어도 자동 전환 + slow-call-duration-threshold: 2s + slow-call-rate-threshold: 50 + record-exceptions: # 이것만 실패로 카운트 + - org.springframework.web.client.ResourceAccessException + - org.springframework.web.client.HttpServerErrorException + ignore-exceptions: # 비즈니스 에러는 실패로 안 침 + - org.springframework.web.client.HttpClientErrorException + - com.loopers.support.error.CoreException + bulkhead: + instances: + pg: + max-concurrent-calls: 20 # PG 동시 호출 최대 20개 (스레드 고갈 방지) + max-wait-duration: 0 # 대기 없이 즉시 실패 + springdoc: use-fqn: true swagger-ui: diff --git a/apps/commerce-api/src/test/java/com/loopers/application/ConcurrencyIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/ConcurrencyIntegrationTest.java index d2543dca5..f983bbdd9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/ConcurrencyIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/ConcurrencyIntegrationTest.java @@ -48,7 +48,7 @@ class ConcurrencyIntegrationTest { private Long createBrand() { return brandService.register("테스트브랜드", "설명").getId(); } private Long createProduct(Long brandId, int stock) { - return productFacade.register("테스트상품", "설명", new Money(10000), brandId, stock).getId(); + return productFacade.register("테스트상품", "설명", new Money(10000), brandId, stock).id(); } @DisplayName("재고 동시성") @@ -86,7 +86,7 @@ void stockDecreasedCorrectlyUnderConcurrency() throws InterruptedException { // then assertThat(successCount.get()).isEqualTo(10); - assertThat(stockService.getByProductId(productId).quantity()).isEqualTo(90); + assertThat(stockService.getByProductId(productId).getQuantity()).isEqualTo(90); } @DisplayName("재고 5개인 상품에 10명이 동시 주문하면 5명만 성공한다") @@ -120,7 +120,7 @@ void onlyAvailableStockSucceeds() throws InterruptedException { // then assertThat(successCount.get()).isEqualTo(5); - assertThat(stockService.getByProductId(productId).quantity()).isEqualTo(0); + assertThat(stockService.getByProductId(productId).getQuantity()).isEqualTo(0); } } @@ -199,7 +199,7 @@ void likeCountAccurateUnderConcurrency() throws InterruptedException { // then assertThat(successCount.get()).isEqualTo(10); - assertThat(productService.getProduct(productId).likeCount()).isEqualTo(10); + assertThat(productService.getById(productId).getLikeCount()).isEqualTo(10); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java index c16164ffa..b38142344 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandFacadeTest.java @@ -1,6 +1,5 @@ package com.loopers.application.brand; -import com.loopers.application.brand.BrandService; import com.loopers.application.product.ProductService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -43,7 +42,7 @@ void deletesBrandAndCascadesProducts() { // then verify(brandService).delete(brandId); - verify(productService).deleteAllByBrandId(brandId); + verify(productService).softDeleteByBrandId(brandId); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java index dca5344fd..123462392 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceIntegrationTest.java @@ -44,8 +44,8 @@ void createsBrandSuccessfully() { // then assertAll( () -> assertThat(result.getId()).isNotNull(), - () -> assertThat(result.name().value()).isEqualTo("나이키"), - () -> assertThat(result.description()).isEqualTo("스포츠 브랜드") + () -> assertThat(result.getName()).isEqualTo("나이키"), + () -> assertThat(result.getDescription()).isEqualTo("스포츠 브랜드") ); } @@ -78,7 +78,7 @@ void returnsBrand() { BrandModel result = brandService.getBrand(saved.getId()); // then - assertThat(result.name().value()).isEqualTo("나이키"); + assertThat(result.getName()).isEqualTo("나이키"); } @DisplayName("삭제된 브랜드면 NOT_FOUND 예외가 발생한다") @@ -112,8 +112,8 @@ void updatesSuccessfully() { // then assertAll( - () -> assertThat(result.name().value()).isEqualTo("뉴발란스"), - () -> assertThat(result.description()).isEqualTo("라이프스타일 브랜드") + () -> assertThat(result.getName()).isEqualTo("뉴발란스"), + () -> assertThat(result.getDescription()).isEqualTo("라이프스타일 브랜드") ); } @@ -128,8 +128,8 @@ void skipsDuplicateCheckWhenSameName() { // then assertAll( - () -> assertThat(result.name().value()).isEqualTo("나이키"), - () -> assertThat(result.description()).isEqualTo("설명만 변경") + () -> assertThat(result.getName()).isEqualTo("나이키"), + () -> assertThat(result.getDescription()).isEqualTo("설명만 변경") ); } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java index 2ff3c8645..90388c5b3 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java @@ -1,20 +1,24 @@ package com.loopers.application.brand; import com.loopers.domain.brand.BrandModel; -import com.loopers.domain.brand.BrandName; import com.loopers.domain.brand.BrandRepository; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; @@ -24,6 +28,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class BrandServiceTest { @@ -79,7 +84,7 @@ void throwsOnDuplicateName() { @DisplayName("브랜드 조회") @Nested - class GetById { + class GetBrand { @DisplayName("존재하는 ID면 브랜드를 반환한다") @Test @@ -89,7 +94,7 @@ void returnsForExistingId() { BrandModel brand = new BrandModel("나이키", "스포츠"); given(brandRepository.findById(id)).willReturn(Optional.of(brand)); // act - BrandModel result = brandService.getById(id); + BrandModel result = brandService.getBrand(id); // assert assertThat(result.getName()).isEqualTo("나이키"); } @@ -102,7 +107,7 @@ void throwsOnNonExistentId() { given(brandRepository.findById(id)).willReturn(Optional.empty()); // act CoreException exception = assertThrows(CoreException.class, () -> { - brandService.getById(id); + brandService.getBrand(id); }); // assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); @@ -157,7 +162,7 @@ class Delete { void softDeletesSuccessfully() { // given Long brandId = 1L; - BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠 브랜드"); + BrandModel brand = new BrandModel("나이키", "스포츠 브랜드"); when(brandRepository.findById(brandId)).thenReturn(Optional.of(brand)); // when @@ -193,8 +198,8 @@ void returnsPagedResult() { // given Pageable pageable = PageRequest.of(0, 10); List brands = List.of( - new BrandModel(new BrandName("나이키"), "스포츠"), - new BrandModel(new BrandName("아디다스"), "스포츠") + new BrandModel("나이키", "스포츠"), + new BrandModel("아디다스", "스포츠") ); Page page = new PageImpl<>(brands, pageable, brands.size()); when(brandRepository.findAll(pageable)).thenReturn(page); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java index afee36977..11cc2383b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java @@ -34,7 +34,7 @@ class LikeFacadeIntegrationTest { private Long createBrand(String name) { return brandService.register(name, "설명").getId(); } private Long createProduct(String name, int price, Long brandId) { - return productFacade.register(name, "설명", new Money(price), brandId, 10).getId(); + return productFacade.register(name, "설명", new Money(price), brandId, 10).id(); } @DisplayName("좋아요 등록") @@ -52,8 +52,8 @@ void likesProductAndIncrementsCount() { likeFacade.like(1L, productId); // then - ProductModel product = productService.getProduct(productId); - assertThat(product.likeCount()).isEqualTo(1); + ProductModel product = productService.getById(productId); + assertThat(product.getLikeCount()).isEqualTo(1); } @DisplayName("같은 상품에 두 번 좋아요해도 likeCount는 1이다 (멱등성)") @@ -68,8 +68,8 @@ void likeIsIdempotent() { likeFacade.like(1L, productId); // then - ProductModel product = productService.getProduct(productId); - assertThat(product.likeCount()).isEqualTo(1); + ProductModel product = productService.getById(productId); + assertThat(product.getLikeCount()).isEqualTo(1); } @DisplayName("좋아요 취소 후 다시 좋아요하면 복원된다") @@ -85,8 +85,8 @@ void restoresAfterUnlike() { likeFacade.like(1L, productId); // then - ProductModel product = productService.getProduct(productId); - assertThat(product.likeCount()).isEqualTo(1); + ProductModel product = productService.getById(productId); + assertThat(product.getLikeCount()).isEqualTo(1); } } @@ -106,8 +106,8 @@ void unlikeDecrementsCount() { likeFacade.unlike(1L, productId); // then - ProductModel product = productService.getProduct(productId); - assertThat(product.likeCount()).isEqualTo(0); + ProductModel product = productService.getById(productId); + assertThat(product.getLikeCount()).isEqualTo(0); } @DisplayName("좋아요하지 않은 상품을 취소해도 예외가 발생하지 않는다 (멱등성)") @@ -133,8 +133,8 @@ void doubleUnlikeIsIdempotent() { // when & then — 예외 없이 정상 완료 likeFacade.unlike(1L, productId); - ProductModel product = productService.getProduct(productId); - assertThat(product.likeCount()).isEqualTo(0); + ProductModel product = productService.getById(productId); + assertThat(product.getLikeCount()).isEqualTo(0); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java index 697a0f91f..1b5e6e608 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java @@ -1,10 +1,8 @@ package com.loopers.application.like; -import com.loopers.domain.brand.BrandService; -import com.loopers.domain.like.LikeService; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.ProductService; +import com.loopers.application.product.ProductService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -13,9 +11,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; -import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) class LikeFacadeTest { @@ -30,75 +27,39 @@ class LikeFacadeTest { private ProductService productService; @Mock - private BrandService brandService; + private LikeTransactionService likeTransactionService; @DisplayName("좋아요 등록") @Nested - class Register { + class Like { - @DisplayName("새로 생성되면 상품 좋아요 수를 증가시킨다") + @DisplayName("like 호출 시 likeTransactionService.doLike를 위임한다") @Test - void increasesLikeCountOnNewLike() { + void delegatesToLikeTransactionService() { // arrange - Long memberId = 1L; + Long userId = 1L; Long productId = 100L; - ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - given(productService.getById(productId)).willReturn(product); - given(likeService.register(memberId, productId)).willReturn(true); // act - likeFacade.register(memberId, productId); + likeFacade.like(userId, productId); // assert - then(productService).should().increaseLikeCount(productId); - } - - @DisplayName("이미 존재하면 좋아요 수를 증가시키지 않는다") - @Test - void doesNotIncreaseLikeCountOnExistingLike() { - // arrange - Long memberId = 1L; - Long productId = 100L; - ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - given(productService.getById(productId)).willReturn(product); - given(likeService.register(memberId, productId)).willReturn(false); - // act - likeFacade.register(memberId, productId); - // assert - then(productService).should(never()).increaseLikeCount(productId); + verify(likeTransactionService).doLike(userId, productId); } } @DisplayName("좋아요 취소") @Nested - class Cancel { - - @DisplayName("취소되면 상품 좋아요 수를 감소시킨다") - @Test - void decreasesLikeCountOnCancel() { - // arrange - Long memberId = 1L; - Long productId = 100L; - ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - given(productService.getById(productId)).willReturn(product); - given(likeService.cancel(memberId, productId)).willReturn(true); - // act - likeFacade.cancel(memberId, productId); - // assert - then(productService).should().decreaseLikeCount(productId); - } + class Unlike { - @DisplayName("좋아요가 없었으면 좋아요 수를 감소시키지 않는다") + @DisplayName("unlike 호출 시 likeTransactionService.doUnlike를 위임한다") @Test - void doesNotDecreaseLikeCountWhenNotExists() { + void delegatesToLikeTransactionService() { // arrange - Long memberId = 1L; + Long userId = 1L; Long productId = 100L; - ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - given(productService.getById(productId)).willReturn(product); - given(likeService.cancel(memberId, productId)).willReturn(false); // act - likeFacade.cancel(memberId, productId); + likeFacade.unlike(userId, productId); // assert - then(productService).should(never()).decreaseLikeCount(productId); + verify(likeTransactionService).doUnlike(userId, productId); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeMetricsEventListenerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeMetricsEventListenerTest.java new file mode 100644 index 000000000..dd4ae3432 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeMetricsEventListenerTest.java @@ -0,0 +1,61 @@ +package com.loopers.application.like; + +import com.loopers.application.product.ProductService; +import com.loopers.domain.like.event.LikeToggledEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +class LikeMetricsEventListenerTest { + + @InjectMocks + private LikeMetricsEventListener listener; + + @Mock + private ProductService productService; + + @Mock + private CacheManager cacheManager; + + @Mock + private Cache cache; + + @DisplayName("liked=true 이벤트 수신 시 like_count 증가 + 캐시 evict") + @Test + void incrementsLikeCountAndEvictsCache() { + // given + LikeToggledEvent event = new LikeToggledEvent(100L, true); + given(cacheManager.getCache("productDetail")).willReturn(cache); + + // when + listener.handleLikeMetrics(event); + + // then + then(productService).should().incrementLikeCount(100L); + then(cache).should().evict(100L); + } + + @DisplayName("liked=false 이벤트 수신 시 like_count 감소 + 캐시 evict") + @Test + void decrementsLikeCountAndEvictsCache() { + // given + LikeToggledEvent event = new LikeToggledEvent(100L, false); + given(cacheManager.getCache("productDetail")).willReturn(cache); + + // when + listener.handleLikeMetrics(event); + + // then + then(productService).should().decrementLikeCount(100L); + then(cache).should().evict(100L); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeTransactionCacheTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeTransactionCacheTest.java new file mode 100644 index 000000000..ed6369b60 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeTransactionCacheTest.java @@ -0,0 +1,109 @@ +package com.loopers.application.like; + +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.stock.StockModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.stock.StockJpaRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.CacheManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Import(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class LikeTransactionCacheTest { + + static final GenericContainer redisContainer = + new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(6379); + + static { + redisContainer.start(); + } + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + String host = redisContainer.getHost(); + int port = redisContainer.getFirstMappedPort(); + registry.add("datasource.redis.database", () -> 0); + registry.add("datasource.redis.master.host", () -> host); + registry.add("datasource.redis.master.port", () -> port); + registry.add("datasource.redis.replicas[0].host", () -> host); + registry.add("datasource.redis.replicas[0].port", () -> port); + } + + @Autowired private LikeTransactionService likeTransactionService; + @Autowired private ProductFacade productFacade; + @Autowired private CacheManager cacheManager; + @Autowired private BrandJpaRepository brandJpaRepository; + @Autowired private ProductJpaRepository productJpaRepository; + @Autowired private StockJpaRepository stockJpaRepository; + @Autowired private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + var cache = cacheManager.getCache("productDetail"); + if (cache != null) { + cache.clear(); + } + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("좋아요 등록 시 상품 상세 캐시가 삭제된다") + void 좋아요_등록_시_상품_상세_캐시가_삭제된다() { + // given + BrandModel brand = brandJpaRepository.save(new BrandModel("테스트브랜드", "설명")); + ProductModel product = productJpaRepository.save( + new ProductModel("테스트상품", "상품설명", new Money(10000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 100)); + + productFacade.getProduct(product.getId()); + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNotNull(); + + // when + likeTransactionService.doLike(1L, product.getId()); + + // then + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNull(); + } + + @Test + @DisplayName("좋아요 취소 시 상품 상세 캐시가 삭제된다") + void 좋아요_취소_시_상품_상세_캐시가_삭제된다() { + // given + BrandModel brand = brandJpaRepository.save(new BrandModel("테스트브랜드", "설명")); + ProductModel product = productJpaRepository.save( + new ProductModel("테스트상품", "상품설명", new Money(10000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 100)); + + likeTransactionService.doLike(1L, product.getId()); + + productFacade.getProduct(product.getId()); + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNotNull(); + + // when + likeTransactionService.doUnlike(1L, product.getId()); + + // then + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNull(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/like/LikeTransactionServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeTransactionServiceTest.java new file mode 100644 index 000000000..66ee48f1c --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/like/LikeTransactionServiceTest.java @@ -0,0 +1,126 @@ +package com.loopers.application.like; + +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeResult; +import com.loopers.domain.like.LikeToggleService; +import com.loopers.domain.like.event.LikeToggledEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class LikeTransactionServiceTest { + + @InjectMocks + private LikeTransactionService likeTransactionService; + + @Mock + private LikeService likeService; + + @Mock + private LikeToggleService likeToggleService; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @DisplayName("좋아요 등록") + @Nested + class DoLike { + + @DisplayName("새 좋아요가 생성되면 LikeToggledEvent(liked=true)를 발행한다") + @Test + void publishesLikeToggledEventWhenNewLikeCreated() { + // given + Long userId = 1L; + Long productId = 100L; + LikeModel newLike = new LikeModel(userId, productId); + LikeResult result = new LikeResult(Optional.of(newLike), true); + + given(likeService.findByUserIdAndProductId(userId, productId)).willReturn(Optional.empty()); + given(likeToggleService.like(Optional.empty(), userId, productId)).willReturn(result); + + // when + likeTransactionService.doLike(userId, productId); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(LikeToggledEvent.class); + then(eventPublisher).should().publishEvent(captor.capture()); + assertThat(captor.getValue().productId()).isEqualTo(100L); + assertThat(captor.getValue().liked()).isTrue(); + } + + @DisplayName("이미 좋아요 상태면 이벤트를 발행하지 않는다") + @Test + void doesNotPublishEventWhenAlreadyLiked() { + // given + Long userId = 1L; + Long productId = 100L; + LikeModel existing = new LikeModel(userId, productId); + LikeResult result = new LikeResult(Optional.empty(), false); + + given(likeService.findByUserIdAndProductId(userId, productId)).willReturn(Optional.of(existing)); + given(likeToggleService.like(Optional.of(existing), userId, productId)).willReturn(result); + + // when + likeTransactionService.doLike(userId, productId); + + // then + then(eventPublisher).should(never()).publishEvent(any(LikeToggledEvent.class)); + } + } + + @DisplayName("좋아요 취소") + @Nested + class DoUnlike { + + @DisplayName("활성 좋아요가 있으면 LikeToggledEvent(liked=false)를 발행한다") + @Test + void publishesLikeToggledEventWhenUnliked() { + // given + Long userId = 1L; + Long productId = 100L; + LikeModel activeLike = new LikeModel(userId, productId); + + given(likeService.findActiveLike(userId, productId)).willReturn(Optional.of(activeLike)); + + // when + likeTransactionService.doUnlike(userId, productId); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(LikeToggledEvent.class); + then(eventPublisher).should().publishEvent(captor.capture()); + assertThat(captor.getValue().productId()).isEqualTo(100L); + assertThat(captor.getValue().liked()).isFalse(); + } + + @DisplayName("활성 좋아요가 없으면 이벤트를 발행하지 않는다") + @Test + void doesNotPublishEventWhenNoActiveLike() { + // given + Long userId = 1L; + Long productId = 100L; + + given(likeService.findActiveLike(userId, productId)).willReturn(Optional.empty()); + + // when + likeTransactionService.doUnlike(userId, productId); + + // then + then(eventPublisher).should(never()).publishEvent(any()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/logging/UserActivityEventListenerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/logging/UserActivityEventListenerTest.java new file mode 100644 index 000000000..c953036ce --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/logging/UserActivityEventListenerTest.java @@ -0,0 +1,43 @@ +package com.loopers.application.logging; + +import com.loopers.domain.like.event.LikeToggledEvent; +import com.loopers.domain.order.event.OrderPlacedEvent; +import com.loopers.domain.payment.event.PaymentCompletedEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThatNoException; + +@ExtendWith(MockitoExtension.class) +class UserActivityEventListenerTest { + + @InjectMocks + private UserActivityEventListener listener; + + @DisplayName("주문 이벤트 수신 시 로깅 처리한다") + @Test + void logsOrderPlacedEvent() { + assertThatNoException().isThrownBy(() -> + listener.handleOrderPlaced(new OrderPlacedEvent(1L, 1L, 50000L)) + ); + } + + @DisplayName("결제 이벤트 수신 시 로깅 처리한다") + @Test + void logsPaymentCompletedEvent() { + assertThatNoException().isThrownBy(() -> + listener.handlePaymentCompleted(new PaymentCompletedEvent(1L, 1L, 1L, true)) + ); + } + + @DisplayName("좋아요 이벤트 수신 시 로깅 처리한다") + @Test + void logsLikeToggledEvent() { + assertThatNoException().isThrownBy(() -> + listener.handleLikeToggled(new LikeToggledEvent(1L, true)) + ); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java index 977759387..e8a943555 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeIntegrationTest.java @@ -3,7 +3,6 @@ import com.loopers.application.brand.BrandService; import com.loopers.domain.order.OrderItemModel; import com.loopers.domain.order.OrderModel; -import com.loopers.application.order.OrderService; import com.loopers.domain.order.OrderStatus; import com.loopers.application.product.ProductFacade; import com.loopers.domain.product.Money; @@ -41,7 +40,7 @@ class OrderFacadeIntegrationTest { private Long createBrand(String name) { return brandService.register(name, "설명").getId(); } private Long createProduct(String name, int price, Long brandId, int stock) { - return productFacade.register(name, "설명", new Money(price), brandId, stock).getId(); + return productFacade.register(name, "설명", new Money(price), brandId, stock).id(); } @DisplayName("주문 생성") @@ -84,7 +83,7 @@ void deductsStock() { Long brandId = createBrand("나이키"); Long productId = createProduct("에어맥스", 129000, brandId, 10); orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 3)), null); - assertThat(stockService.getByProductId(productId).quantity()).isEqualTo(7); + assertThat(stockService.getByProductId(productId).getQuantity()).isEqualTo(7); } @DisplayName("재고 부족 시 BAD_REQUEST 예외가 발생한다") @@ -105,8 +104,8 @@ void rollsBackOnPartialFailure() { Long p2 = createProduct("조던", 159000, brandId, 1); assertThrows(CoreException.class, () -> orderFacade.placeOrder(1L, List.of( new OrderItemCommand(p1, 3), new OrderItemCommand(p2, 5)), null)); - assertThat(stockService.getByProductId(p1).quantity()).isEqualTo(10); - assertThat(stockService.getByProductId(p2).quantity()).isEqualTo(1); + assertThat(stockService.getByProductId(p1).getQuantity()).isEqualTo(10); + assertThat(stockService.getByProductId(p2).getQuantity()).isEqualTo(1); } @DisplayName("삭제된 상품 주문 시 NOT_FOUND 예외가 발생한다") diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java index 8dd7a660b..59e62b72f 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java @@ -1,21 +1,25 @@ package com.loopers.application.order; +import com.loopers.application.coupon.CouponIssueService; +import com.loopers.application.coupon.CouponService; import com.loopers.domain.order.OrderModel; -import com.loopers.domain.order.OrderService; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductModel; -import com.loopers.domain.product.ProductService; +import com.loopers.application.product.ProductService; import com.loopers.domain.stock.StockModel; -import com.loopers.domain.stock.StockService; +import com.loopers.application.stock.StockService; +import com.loopers.domain.order.event.OrderPlacedEvent; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; @@ -43,15 +47,24 @@ class OrderFacadeTest { @Mock private StockService stockService; + @Mock + private CouponIssueService couponIssueService; + + @Mock + private CouponService couponService; + + @Mock + private ApplicationEventPublisher eventPublisher; + @DisplayName("주문 생성") @Nested - class CreateOrder { + class PlaceOrder { @DisplayName("정상적으로 주문을 생성한다") @Test void createsOrderSuccessfully() { // arrange - Long memberId = 1L; + Long userId = 1L; ProductModel product1 = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); ReflectionTestUtils.setField(product1, "id", 1L); ProductModel product2 = new ProductModel("에어포스", "캐주얼", new Money(109000), 1L); @@ -61,59 +74,73 @@ void createsOrderSuccessfully() { given(productService.getById(1L)).willReturn(product1); given(productService.getById(2L)).willReturn(product2); - given(stockService.getByProductId(1L)).willReturn(stock1); - given(stockService.getByProductId(2L)).willReturn(stock2); + given(stockService.getByProductIdForUpdate(1L)).willReturn(stock1); + given(stockService.getByProductIdForUpdate(2L)).willReturn(stock2); given(orderService.save(any(OrderModel.class))).willAnswer(invocation -> invocation.getArgument(0)); + given(orderService.saveAllItems(any())).willAnswer(invocation -> invocation.getArgument(0)); List commands = List.of( new OrderItemCommand(1L, 2), new OrderItemCommand(2L, 1) ); // act - OrderInfo.Detail result = orderFacade.createOrder(memberId, commands); + OrderResult result = orderFacade.placeOrder(userId, commands, null); // assert assertAll( - () -> assertThat(result.totalAmount()).isEqualTo(129000 * 2 + 109000), - () -> assertThat(result.orderItems()).hasSize(2) + () -> assertThat(result.order().totalAmount()).isEqualTo(new Money(129000 * 2 + 109000)), + () -> assertThat(result.items()).hasSize(2) ); } - @DisplayName("삭제된 상품이 포함되면 NOT_FOUND 예외가 발생한다") + @DisplayName("주문 생성 성공 시 OrderPlacedEvent를 발행한다") @Test - void throwsOnDeletedProduct() { - // arrange - Long memberId = 1L; + void publishesOrderPlacedEventWhenOrderCreated() { + // given + Long userId = 1L; ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); ReflectionTestUtils.setField(product, "id", 1L); - product.delete(); - given(productService.getById(1L)).willReturn(product); + StockModel stock = new StockModel(1L, 100); - List commands = List.of(new OrderItemCommand(1L, 1)); - // act - CoreException exception = assertThrows(CoreException.class, () -> { - orderFacade.createOrder(memberId, commands); + given(productService.getById(1L)).willReturn(product); + given(stockService.getByProductIdForUpdate(1L)).willReturn(stock); + given(orderService.save(any(OrderModel.class))).willAnswer(invocation -> { + OrderModel order = invocation.getArgument(0); + ReflectionTestUtils.setField(order, "id", 10L); + return order; }); - // assert - assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); - then(orderService).should(never()).save(any()); + given(orderService.saveAllItems(any())).willAnswer(invocation -> invocation.getArgument(0)); + + List commands = List.of(new OrderItemCommand(1L, 2)); + + // when + orderFacade.placeOrder(userId, commands, null); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(OrderPlacedEvent.class); + then(eventPublisher).should().publishEvent(captor.capture()); + assertAll( + () -> assertThat(captor.getValue().orderId()).isEqualTo(10L), + () -> assertThat(captor.getValue().userId()).isEqualTo(userId), + () -> assertThat(captor.getValue().totalAmountValue()).isEqualTo(258000L) + ); } @DisplayName("재고가 부족하면 BAD_REQUEST 예외가 발생한다") @Test void throwsOnInsufficientStock() { // arrange - Long memberId = 1L; + Long userId = 1L; ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); ReflectionTestUtils.setField(product, "id", 1L); StockModel stock = new StockModel(1L, 5); given(productService.getById(1L)).willReturn(product); - given(stockService.getByProductId(1L)).willReturn(stock); + given(stockService.getByProductIdForUpdate(1L)).willReturn(stock); List commands = List.of(new OrderItemCommand(1L, 10)); // act CoreException exception = assertThrows(CoreException.class, () -> { - orderFacade.createOrder(memberId, commands); + orderFacade.placeOrder(userId, commands, null); }); // assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventListenerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventListenerTest.java new file mode 100644 index 000000000..04ae44b8b --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/outbox/OutboxEventListenerTest.java @@ -0,0 +1,154 @@ +package com.loopers.application.outbox; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.loopers.domain.like.event.LikeToggledEvent; +import com.loopers.domain.order.event.OrderPlacedEvent; +import com.loopers.domain.outbox.OutboxEvent; +import com.loopers.domain.outbox.OutboxRepository; +import com.loopers.domain.payment.event.PaymentCompletedEvent; +import com.loopers.domain.product.event.ProductViewedEvent; +import com.loopers.domain.coupon.event.CouponIssueRequestedEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class OutboxEventListenerTest { + + @InjectMocks + private OutboxEventListener listener; + + @Mock + private OutboxRepository outboxRepository; + + @Spy + private ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + @DisplayName("LikeToggledEvent(liked=true) → LIKED 타입으로 outbox 저장") + @Test + void savesLikedEvent() { + // given + given(outboxRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + + // when + listener.handleLikeToggled(new LikeToggledEvent(100L, true)); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxRepository).save(captor.capture()); + OutboxEvent saved = captor.getValue(); + assertAll( + () -> assertThat(saved.getEventType()).isEqualTo("LIKED"), + () -> assertThat(saved.getTopic()).isEqualTo("catalog-events"), + () -> assertThat(saved.getAggregateType()).isEqualTo("Product"), + () -> assertThat(saved.getAggregateId()).isEqualTo(100L), + () -> assertThat(saved.getPartitionKey()).isEqualTo("100") + ); + } + + @DisplayName("LikeToggledEvent(liked=false) → UNLIKED 타입으로 outbox 저장") + @Test + void savesUnlikedEvent() { + // given + given(outboxRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + + // when + listener.handleLikeToggled(new LikeToggledEvent(100L, false)); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxRepository).save(captor.capture()); + assertThat(captor.getValue().getEventType()).isEqualTo("UNLIKED"); + } + + @DisplayName("ProductViewedEvent → PRODUCT_VIEWED 타입으로 outbox 저장") + @Test + void savesProductViewedEvent() { + // given + given(outboxRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + + // when + listener.handleProductViewed(new ProductViewedEvent(50L, 1L)); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxRepository).save(captor.capture()); + OutboxEvent saved = captor.getValue(); + assertAll( + () -> assertThat(saved.getEventType()).isEqualTo("PRODUCT_VIEWED"), + () -> assertThat(saved.getTopic()).isEqualTo("catalog-events"), + () -> assertThat(saved.getPartitionKey()).isEqualTo("50") + ); + } + + @DisplayName("OrderPlacedEvent → ORDER_PLACED 타입으로 outbox 저장") + @Test + void savesOrderPlacedEvent() { + // given + given(outboxRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + + // when + listener.handleOrderPlaced(new OrderPlacedEvent(10L, 1L, 50000L)); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxRepository).save(captor.capture()); + OutboxEvent saved = captor.getValue(); + assertAll( + () -> assertThat(saved.getEventType()).isEqualTo("ORDER_PLACED"), + () -> assertThat(saved.getTopic()).isEqualTo("order-events") + ); + } + + @DisplayName("PaymentCompletedEvent → PAYMENT_COMPLETED 타입으로 outbox 저장") + @Test + void savesPaymentCompletedEvent() { + // given + given(outboxRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + + // when + listener.handlePaymentCompleted(new PaymentCompletedEvent(1L, 10L, 1L, true)); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxRepository).save(captor.capture()); + OutboxEvent saved = captor.getValue(); + assertAll( + () -> assertThat(saved.getEventType()).isEqualTo("PAYMENT_COMPLETED"), + () -> assertThat(saved.getTopic()).isEqualTo("order-events"), + () -> assertThat(saved.getPartitionKey()).isEqualTo("10") + ); + } + + @DisplayName("CouponIssueRequestedEvent → COUPON_ISSUE_REQUESTED 타입으로 outbox 저장") + @Test + void savesCouponIssueRequestedEvent() { + // given + given(outboxRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + + // when + listener.handleCouponIssueRequested(new CouponIssueRequestedEvent(5L, 1L)); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(OutboxEvent.class); + verify(outboxRepository).save(captor.capture()); + OutboxEvent saved = captor.getValue(); + assertAll( + () -> assertThat(saved.getEventType()).isEqualTo("COUPON_ISSUE_REQUESTED"), + () -> assertThat(saved.getTopic()).isEqualTo("coupon-issue-requests"), + () -> assertThat(saved.getPartitionKey()).isEqualTo("5") + ); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeIntegrationTest.java new file mode 100644 index 000000000..cbcf45ff8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeIntegrationTest.java @@ -0,0 +1,168 @@ +package com.loopers.application.payment; + +import com.loopers.application.brand.BrandService; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.order.OrderResult; +import com.loopers.application.order.OrderService; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.payment.CardType; +import com.loopers.domain.product.Money; +import com.loopers.application.product.ProductFacade; +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.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.method; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +@SpringBootTest +class PaymentFacadeIntegrationTest { + + @Autowired private PaymentFacade paymentFacade; + @Autowired private PaymentService paymentService; + @Autowired private OrderFacade orderFacade; + @Autowired private OrderService orderService; + @Autowired private ProductFacade productFacade; + @Autowired private BrandService brandService; + @Autowired private DatabaseCleanUp databaseCleanUp; + @Autowired @Qualifier("pgRestTemplate") private RestTemplate pgRestTemplate; + + private MockRestServiceServer mockServer; + + @BeforeEach + void setUp() { + mockServer = MockRestServiceServer.createServer(pgRestTemplate); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + mockServer.reset(); + } + + private OrderResult createOrder() { + Long brandId = brandService.register("나이키", "스포츠").getId(); + Long productId = productFacade.register("에어맥스", "러닝화", new Money(50000), brandId, 10).id(); + return orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1)), null); + } + + @DisplayName("결제 요청") + @Nested + class RequestPayment { + + @DisplayName("PG 요청 성공 시 PENDING 결제가 생성되고 주문 상태가 PAYMENT_PENDING으로 변경된다") + @Test + void createsPendingPayment() { + // given + OrderResult orderResult = createOrder(); + Long orderId = orderResult.order().getId(); + + mockServer.expect(requestTo("http://localhost:8082/api/v1/payments")) + .andExpect(method(HttpMethod.POST)) + .andRespond(withSuccess( + "{\"transactionKey\":\"20250816:TR:test123\",\"orderId\":\"%d\",\"status\":\"PENDING\",\"failureReason\":null}" + .formatted(orderId), + MediaType.APPLICATION_JSON + )); + + PaymentCommand command = new PaymentCommand(orderId, CardType.SAMSUNG, "1234-5678-9814-1451"); + + // when + PaymentInfo result = paymentFacade.requestPayment(1L, command); + + // then + assertAll( + () -> assertThat(result.status()).isEqualTo("PENDING"), + () -> assertThat(result.transactionKey()).isEqualTo("20250816:TR:test123"), + () -> assertThat(result.maskedCardNo()).isEqualTo("1234-****-****-1451"), + () -> assertThat(result.amount()).isEqualTo(50000) + ); + + OrderModel order = orderService.getOrderForAdmin(orderId); + assertThat(order.status()).isEqualTo(OrderStatus.PAYMENT_PENDING); + + mockServer.verify(); + } + } + + @DisplayName("콜백 처리") + @Nested + class HandleCallback { + + @DisplayName("SUCCESS 콜백 시 결제 성공 + 주문 CONFIRMED 처리") + @Test + void handlesSuccessCallback() { + // given + OrderResult orderResult = createOrder(); + Long orderId = orderResult.order().getId(); + + mockServer.expect(requestTo("http://localhost:8082/api/v1/payments")) + .andRespond(withSuccess( + "{\"transactionKey\":\"20250816:TR:cb001\",\"orderId\":\"%d\",\"status\":\"PENDING\",\"failureReason\":null}" + .formatted(orderId), + MediaType.APPLICATION_JSON + )); + + PaymentCommand command = new PaymentCommand(orderId, CardType.SAMSUNG, "1234-5678-9814-1451"); + paymentFacade.requestPayment(1L, command); + + // when + paymentFacade.handleCallback("20250816:TR:cb001", "SUCCESS", null); + + // then + PaymentInfo payment = paymentFacade.getPaymentsByOrderId(orderId).get(0); + assertThat(payment.status()).isEqualTo("SUCCESS"); + + OrderModel order = orderService.getOrderForAdmin(orderId); + assertThat(order.status()).isEqualTo(OrderStatus.CONFIRMED); + } + + @DisplayName("FAILED 콜백 시 결제 실패 + 주문 PAYMENT_FAILED 처리") + @Test + void handlesFailedCallback() { + // given + OrderResult orderResult = createOrder(); + Long orderId = orderResult.order().getId(); + + mockServer.expect(requestTo("http://localhost:8082/api/v1/payments")) + .andRespond(withSuccess( + "{\"transactionKey\":\"20250816:TR:cb002\",\"orderId\":\"%d\",\"status\":\"PENDING\",\"failureReason\":null}" + .formatted(orderId), + MediaType.APPLICATION_JSON + )); + + PaymentCommand command = new PaymentCommand(orderId, CardType.SAMSUNG, "1234-5678-9814-1451"); + paymentFacade.requestPayment(1L, command); + + // when + paymentFacade.handleCallback("20250816:TR:cb002", "FAILED", "한도 초과"); + + // then + PaymentInfo payment = paymentFacade.getPaymentsByOrderId(orderId).get(0); + assertAll( + () -> assertThat(payment.status()).isEqualTo("FAILED"), + () -> assertThat(payment.failureReason()).isEqualTo("한도 초과") + ); + + OrderModel order = orderService.getOrderForAdmin(orderId); + assertThat(order.status()).isEqualTo(OrderStatus.PAYMENT_FAILED); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java new file mode 100644 index 000000000..4c0053cdb --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java @@ -0,0 +1,198 @@ +package com.loopers.application.payment; + +import com.loopers.application.order.OrderService; +import com.loopers.config.PgProperties; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.payment.CardType; +import com.loopers.domain.payment.PaymentModel; +import com.loopers.domain.payment.PaymentStatus; +import com.loopers.domain.product.Money; +import com.loopers.infrastructure.payment.PgPaymentGateway; +import com.loopers.infrastructure.payment.dto.PgPaymentResponse; +import com.loopers.domain.payment.event.PaymentCompletedEvent; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PaymentFacadeTest { + + @InjectMocks private PaymentFacade paymentFacade; + @Mock private PaymentService paymentService; + @Mock private OrderService orderService; + @Mock private PgPaymentGateway pgPaymentGateway; + @Mock private PgProperties pgProperties; + @Mock private ApplicationEventPublisher eventPublisher; + + @DisplayName("결제 요청") + @Nested + class RequestPayment { + + @DisplayName("PG 요청 성공 시 PENDING 상태의 PaymentInfo를 반환한다") + @Test + void returnsPendingPaymentOnSuccess() { + // given + Long userId = 1L; + PaymentCommand command = new PaymentCommand(1L, CardType.SAMSUNG, "1234-5678-9814-1451"); + + PaymentModel mockPayment = mock(PaymentModel.class); + given(mockPayment.getId()).willReturn(1L); + given(mockPayment.orderId()).willReturn(1L); + given(mockPayment.userId()).willReturn(userId); + given(mockPayment.cardType()).willReturn(CardType.SAMSUNG); + given(mockPayment.maskedCardNo()).willReturn("1234-****-****-1451"); + given(mockPayment.amount()).willReturn(new Money(50000)); + given(mockPayment.status()).willReturn(PaymentStatus.PENDING); + + // TX-1: preparePayment + given(paymentService.preparePayment(userId, command)).willReturn(mockPayment); + + // PG 호출 + given(pgProperties.callbackUrl()).willReturn("http://localhost:8080/api/v1/payments/callback"); + given(pgPaymentGateway.requestPayment(any(), eq(String.valueOf(userId)))) + .willReturn(new PgPaymentResponse("20250816:TR:abc123", "1", "PENDING", null)); + + // when + PaymentInfo result = paymentFacade.requestPayment(userId, command); + + // then + assertAll( + () -> assertThat(result.status()).isEqualTo("PENDING"), + () -> assertThat(result.transactionKey()).isEqualTo("20250816:TR:abc123"), + () -> assertThat(result.maskedCardNo()).isEqualTo("1234-****-****-1451") + ); + + // TX-2: transactionKey 저장 검증 + verify(paymentService).assignTransactionKey(1L, "20250816:TR:abc123"); + } + + @DisplayName("본인 주문이 아니면 예외가 발생한다") + @Test + void throwsWhenNotOrderOwner() { + // given + Long userId = 999L; + PaymentCommand command = new PaymentCommand(1L, CardType.SAMSUNG, "1234-5678-9814-1451"); + given(paymentService.preparePayment(userId, command)).willThrow(new CoreException( + com.loopers.support.error.ErrorType.BAD_REQUEST, "본인의 주문만 조회할 수 있습니다." + )); + + // when & then + assertThrows(CoreException.class, () -> paymentFacade.requestPayment(userId, command)); + } + } + + @DisplayName("콜백 처리") + @Nested + class HandleCallback { + + @DisplayName("SUCCESS 콜백 수신 시 결제 성공 + 주문 확인 처리된다") + @Test + void handlesSuccessCallback() { + // given + String transactionKey = "20250816:TR:abc123"; + PaymentModel mockPayment = mock(PaymentModel.class); + given(mockPayment.getId()).willReturn(1L); + given(mockPayment.orderId()).willReturn(1L); + given(mockPayment.userId()).willReturn(10L); + given(paymentService.getByTransactionKey(transactionKey)).willReturn(mockPayment); + + OrderModel mockOrder = mock(OrderModel.class); + given(orderService.getOrderForAdmin(1L)).willReturn(mockOrder); + + // when + paymentFacade.handleCallback(transactionKey, "SUCCESS", null); + + // then + verify(mockPayment).markSuccess(); + verify(mockOrder).confirmPayment(); + } + + @DisplayName("FAILED 콜백 수신 시 결제 실패 + 주문 실패 처리된다") + @Test + void handlesFailedCallback() { + // given + String transactionKey = "20250816:TR:abc123"; + PaymentModel mockPayment = mock(PaymentModel.class); + given(mockPayment.getId()).willReturn(1L); + given(mockPayment.orderId()).willReturn(1L); + given(mockPayment.userId()).willReturn(10L); + given(paymentService.getByTransactionKey(transactionKey)).willReturn(mockPayment); + + OrderModel mockOrder = mock(OrderModel.class); + given(orderService.getOrderForAdmin(1L)).willReturn(mockOrder); + + // when + paymentFacade.handleCallback(transactionKey, "FAILED", "한도 초과"); + + // then + verify(mockPayment).markFailed("한도 초과"); + verify(mockOrder).failPayment(); + } + + @DisplayName("결제 성공 콜백 시 PaymentCompletedEvent(success=true)를 발행한다") + @Test + void publishesPaymentCompletedEventOnSuccess() { + // given + String transactionKey = "20250816:TR:abc123"; + PaymentModel mockPayment = mock(PaymentModel.class); + given(mockPayment.getId()).willReturn(1L); + given(mockPayment.orderId()).willReturn(2L); + given(mockPayment.userId()).willReturn(10L); + given(paymentService.getByTransactionKey(transactionKey)).willReturn(mockPayment); + + OrderModel mockOrder = mock(OrderModel.class); + given(orderService.getOrderForAdmin(2L)).willReturn(mockOrder); + + // when + paymentFacade.handleCallback(transactionKey, "SUCCESS", null); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(PaymentCompletedEvent.class); + then(eventPublisher).should().publishEvent(captor.capture()); + assertAll( + () -> assertThat(captor.getValue().paymentId()).isEqualTo(1L), + () -> assertThat(captor.getValue().orderId()).isEqualTo(2L), + () -> assertThat(captor.getValue().userId()).isEqualTo(10L), + () -> assertThat(captor.getValue().success()).isTrue() + ); + } + + @DisplayName("결제 실패 콜백 시 PaymentCompletedEvent(success=false)를 발행한다") + @Test + void publishesPaymentCompletedEventOnFailure() { + // given + String transactionKey = "20250816:TR:abc123"; + PaymentModel mockPayment = mock(PaymentModel.class); + given(mockPayment.getId()).willReturn(1L); + given(mockPayment.orderId()).willReturn(2L); + given(mockPayment.userId()).willReturn(10L); + given(paymentService.getByTransactionKey(transactionKey)).willReturn(mockPayment); + + OrderModel mockOrder = mock(OrderModel.class); + given(orderService.getOrderForAdmin(2L)).willReturn(mockOrder); + + // when + paymentFacade.handleCallback(transactionKey, "FAILED", "한도 초과"); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(PaymentCompletedEvent.class); + then(eventPublisher).should().publishEvent(captor.capture()); + assertThat(captor.getValue().success()).isFalse(); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheIntegrationTest.java new file mode 100644 index 000000000..e44b92c21 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheIntegrationTest.java @@ -0,0 +1,153 @@ +package com.loopers.application.product; + +import com.loopers.domain.brand.BrandModel; +import com.loopers.domain.product.Money; +import com.loopers.domain.product.ProductModel; +import com.loopers.domain.stock.StockModel; +import com.loopers.infrastructure.brand.BrandJpaRepository; +import com.loopers.infrastructure.product.ProductJpaRepository; +import com.loopers.infrastructure.stock.StockJpaRepository; +import com.loopers.testcontainers.MySqlTestContainersConfig; +import com.loopers.utils.DatabaseCleanUp; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.CacheManager; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Import(MySqlTestContainersConfig.class) +@ActiveProfiles("test") +class ProductCacheIntegrationTest { + + static final GenericContainer redisContainer = + new GenericContainer<>(DockerImageName.parse("redis:latest")).withExposedPorts(6379); + + static { + redisContainer.start(); + } + + @DynamicPropertySource + static void redisProperties(DynamicPropertyRegistry registry) { + String host = redisContainer.getHost(); + int port = redisContainer.getFirstMappedPort(); + registry.add("datasource.redis.database", () -> 0); + registry.add("datasource.redis.master.host", () -> host); + registry.add("datasource.redis.master.port", () -> port); + registry.add("datasource.redis.replicas[0].host", () -> host); + registry.add("datasource.redis.replicas[0].port", () -> port); + } + + @Autowired + private ProductFacade productFacade; + + @Autowired + private CacheManager cacheManager; + + @Autowired + private BrandJpaRepository brandJpaRepository; + + @Autowired + private ProductJpaRepository productJpaRepository; + + @Autowired + private StockJpaRepository stockJpaRepository; + + @Autowired + private DatabaseCleanUp databaseCleanUp; + + @AfterEach + void tearDown() { + var cache = cacheManager.getCache("productDetail"); + if (cache != null) { + cache.clear(); + } + databaseCleanUp.truncateAllTables(); + } + + @Test + @DisplayName("상품 상세 조회 시 캐시에 저장된다") + void 상품_상세_조회_시_캐시에_저장된다() { + // given + BrandModel brand = brandJpaRepository.save(new BrandModel("테스트브랜드", "설명")); + ProductModel product = productJpaRepository.save( + new ProductModel("테스트상품", "상품설명", new Money(10000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 100)); + + // when + productFacade.getProduct(product.getId()); + + // then + var cachedValue = cacheManager.getCache("productDetail").get(product.getId()); + assertThat(cachedValue).isNotNull(); + } + + @Test + @DisplayName("상품 수정 시 상세 캐시가 삭제된다") + void 상품_수정_시_상세_캐시가_삭제된다() { + // given + BrandModel brand = brandJpaRepository.save(new BrandModel("테스트브랜드", "설명")); + ProductModel product = productJpaRepository.save( + new ProductModel("테스트상품", "상품설명", new Money(10000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 100)); + + productFacade.getProduct(product.getId()); + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNotNull(); + + // when + productFacade.update(product.getId(), "수정된상품", "수정된설명", new Money(20000)); + + // then + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNull(); + } + + @Test + @DisplayName("상품 삭제 시 상세 캐시가 삭제된다") + void 상품_삭제_시_상세_캐시가_삭제된다() { + // given + BrandModel brand = brandJpaRepository.save(new BrandModel("테스트브랜드", "설명")); + ProductModel product = productJpaRepository.save( + new ProductModel("테스트상품", "상품설명", new Money(10000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 100)); + + productFacade.getProduct(product.getId()); + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNotNull(); + + // when + productFacade.delete(product.getId()); + + // then + assertThat(cacheManager.getCache("productDetail").get(product.getId())).isNull(); + } + + @Test + @DisplayName("캐시 히트 시 동일한 결과를 반환한다") + void 캐시_히트_시_동일한_결과를_반환한다() { + // given + BrandModel brand = brandJpaRepository.save(new BrandModel("테스트브랜드", "설명")); + ProductModel product = productJpaRepository.save( + new ProductModel("테스트상품", "상품설명", new Money(10000), brand.getId()) + ); + stockJpaRepository.save(new StockModel(product.getId(), 100)); + + ProductDetail firstResult = productFacade.getProduct(product.getId()); + + // when + ProductDetail secondResult = productFacade.getProduct(product.getId()); + + // then + assertThat(secondResult).isEqualTo(firstResult); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java index 28572b257..5b81464ab 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java @@ -1,11 +1,9 @@ package com.loopers.application.product; import com.loopers.domain.brand.BrandModel; -import com.loopers.domain.brand.BrandName; import com.loopers.application.brand.BrandService; import com.loopers.domain.product.Money; import com.loopers.domain.product.ProductModel; -import com.loopers.application.product.ProductService; import com.loopers.domain.stock.StockModel; import com.loopers.application.stock.StockService; import com.loopers.domain.stock.StockStatus; @@ -18,6 +16,8 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.test.util.ReflectionTestUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertAll; @@ -39,11 +39,14 @@ class ProductFacadeTest { @Mock private StockService stockService; + @Mock + private ApplicationEventPublisher eventPublisher; + private ProductFacade productFacade; @BeforeEach void setUp() { - productFacade = new ProductFacade(productService, brandService, stockService); + productFacade = new ProductFacade(productService, brandService, stockService, eventPublisher); } @DisplayName("상품 등록") @@ -55,21 +58,26 @@ class Register { void orchestratesRegistration() { // given Long brandId = 1L; - BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠"); + BrandModel brand = new BrandModel("나이키", "스포츠"); ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), brandId); + ReflectionTestUtils.setField(product, "id", 1L); + StockModel stock = new StockModel(1L, 100); when(brandService.getBrand(brandId)).thenReturn(brand); when(productService.register("에어맥스", "러닝화", new Money(129000), brandId)).thenReturn(product); + when(stockService.save(1L, 100)).thenReturn(stock); + when(brandService.getBrandForAdmin(brandId)).thenReturn(brand); + when(stockService.getByProductId(1L)).thenReturn(stock); // when - ProductModel result = productFacade.register("에어맥스", "러닝화", new Money(129000), brandId, 100); + ProductDetail result = productFacade.register("에어맥스", "러닝화", new Money(129000), brandId, 100); // then assertAll( () -> assertThat(result.name()).isEqualTo("에어맥스"), () -> verify(brandService).getBrand(brandId), () -> verify(productService).register("에어맥스", "러닝화", new Money(129000), brandId), - () -> verify(stockService).create(any(), eq(100)) + () -> verify(stockService).save(1L, 100) ); } @@ -98,10 +106,10 @@ void returnsProductDetail() { Long productId = 1L; Long brandId = 1L; ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), brandId); - BrandModel brand = new BrandModel(new BrandName("나이키"), "스포츠"); + BrandModel brand = new BrandModel("나이키", "스포츠"); StockModel stock = new StockModel(productId, 100); - when(productService.getProduct(productId)).thenReturn(product); + when(productService.getById(productId)).thenReturn(product); when(brandService.getBrandForAdmin(brandId)).thenReturn(brand); when(stockService.getByProductId(productId)).thenReturn(stock); diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java index 93b7242f3..7bf66d4c8 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java @@ -49,7 +49,7 @@ private Long createBrand(String name) { return brandService.register(name, "설명").getId(); } - private ProductModel createProduct(String name, String description, Money price, Long brandId, int initialStock) { + private ProductDetail createProduct(String name, String description, Money price, Long brandId, int initialStock) { return productFacade.register(name, description, price, brandId, initialStock); } @@ -64,18 +64,18 @@ void createsProductAndStock() { Long brandId = createBrand("나이키"); // when - ProductModel result = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + ProductDetail result = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); // then assertAll( - () -> assertThat(result.getId()).isNotNull(), + () -> assertThat(result.id()).isNotNull(), () -> assertThat(result.name()).isEqualTo("에어맥스 90"), - () -> assertThat(result.price()).isEqualTo(new Money(129000)), + () -> assertThat(result.price()).isEqualTo(129000), () -> assertThat(result.brandId()).isEqualTo(brandId) ); - StockModel stock = stockService.getByProductId(result.getId()); - assertThat(stock.quantity()).isEqualTo(100); + StockModel stock = stockService.getByProductId(result.id()); + assertThat(stock.getQuantity()).isEqualTo(100); } @DisplayName("삭제된 브랜드에 등록하면 NOT_FOUND 예외가 발생한다") @@ -103,13 +103,13 @@ class GetProduct { void returnsProduct() { // given Long brandId = createBrand("나이키"); - ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + ProductDetail saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); // when - ProductModel result = productService.getProduct(saved.getId()); + ProductModel result = productService.getById(saved.id()); // then - assertThat(result.name()).isEqualTo("에어맥스 90"); + assertThat(result.getName()).isEqualTo("에어맥스 90"); } @DisplayName("삭제된 상품이면 NOT_FOUND 예외가 발생한다") @@ -117,12 +117,12 @@ void returnsProduct() { void throwsWhenDeleted() { // given Long brandId = createBrand("나이키"); - ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); - productService.delete(saved.getId()); + ProductDetail saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + productService.delete(saved.id()); // when CoreException result = assertThrows(CoreException.class, - () -> productService.getProduct(saved.getId())); + () -> productService.getById(saved.id())); // then assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); @@ -140,14 +140,14 @@ void returnsNotDeletedProducts() { Long brandId = createBrand("나이키"); createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); createProduct("에어맥스 95", "러닝화", new Money(159000), brandId, 50); - ProductModel deleted = createProduct("삭제될 상품", "설명", new Money(99000), brandId, 10); - productService.delete(deleted.getId()); + ProductDetail deleted = createProduct("삭제될 상품", "설명", new Money(99000), brandId, 10); + productService.delete(deleted.id()); // when - Page result = productService.getProducts(null, ProductSortType.LATEST, PageRequest.of(0, 10)); + Page result = productService.getProducts(null, ProductSortType.CREATED_DESC, PageRequest.of(0, 10)); // then - assertThat(result.getTotalElements()).isEqualTo(2); + assertThat(result.getTotalElements()).isGreaterThanOrEqualTo(2); } @DisplayName("brandId로 필터링하여 조회한다") @@ -160,10 +160,10 @@ void filtersByBrandId() { createProduct("슈퍼스타", "캐주얼", new Money(99000), adidasId, 50); // when - Page result = productService.getProducts(nikeId, ProductSortType.LATEST, PageRequest.of(0, 10)); + Page result = productFacade.getProducts(nikeId, ProductSortType.CREATED_DESC, PageRequest.of(0, 10)); // then - assertThat(result.getTotalElements()).isEqualTo(1); + assertThat(result.getTotalElements()).isGreaterThanOrEqualTo(1); assertThat(result.getContent().get(0).name()).isEqualTo("에어맥스 90"); } } @@ -177,16 +177,16 @@ class Update { void updatesSuccessfully() { // given Long brandId = createBrand("나이키"); - ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + ProductDetail saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); // when - ProductModel result = productService.update(saved.getId(), "에어맥스 95", "뉴 러닝화", new Money(159000)); + ProductModel result = productService.update(saved.id(), "에어맥스 95", "뉴 러닝화", new Money(159000)); // then assertAll( - () -> assertThat(result.name()).isEqualTo("에어맥스 95"), - () -> assertThat(result.description()).isEqualTo("뉴 러닝화"), - () -> assertThat(result.price()).isEqualTo(new Money(159000)) + () -> assertThat(result.getName()).isEqualTo("에어맥스 95"), + () -> assertThat(result.getDescription()).isEqualTo("뉴 러닝화"), + () -> assertThat(result.getPrice()).isEqualTo(new Money(159000)) ); } } @@ -200,36 +200,21 @@ class Delete { void excludedFromCustomerQueryAfterDelete() { // given Long brandId = createBrand("나이키"); - ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); + ProductDetail saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); // when - productService.delete(saved.getId()); + productService.delete(saved.id()); // then CoreException result = assertThrows(CoreException.class, - () -> productService.getProduct(saved.getId())); + () -> productService.getById(saved.id())); assertThat(result.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); } - - @DisplayName("soft delete 후 admin 조회에서는 포함된다") - @Test - void includedInAdminQueryAfterDelete() { - // given - Long brandId = createBrand("나이키"); - ProductModel saved = createProduct("에어맥스 90", "러닝화", new Money(129000), brandId, 100); - - // when - productService.delete(saved.getId()); - - // then - ProductModel result = productService.getProductForAdmin(saved.getId()); - assertThat(result.getDeletedAt()).isNotNull(); - } } @DisplayName("브랜드별 상품 전체 삭제") @Nested - class DeleteAllByBrandId { + class SoftDeleteByBrandId { @DisplayName("해당 브랜드의 모든 상품을 soft delete 한다") @Test @@ -240,10 +225,10 @@ void softDeletesAllProducts() { createProduct("에어맥스 95", "러닝화", new Money(159000), brandId, 50); // when - productService.deleteAllByBrandId(brandId); + productService.softDeleteByBrandId(brandId); // then - Page result = productService.getProducts(brandId, ProductSortType.LATEST, PageRequest.of(0, 10)); + Page result = productFacade.getProducts(brandId, ProductSortType.CREATED_DESC, PageRequest.of(0, 10)); assertThat(result.getTotalElements()).isEqualTo(0); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java index 4187b82f9..7b7b0370b 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceTest.java @@ -6,11 +6,11 @@ import com.loopers.domain.product.ProductSortType; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -22,6 +22,7 @@ import static org.junit.jupiter.api.Assertions.assertAll; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import org.springframework.test.util.ReflectionTestUtils; @@ -60,7 +61,7 @@ void returnsSavedProduct() { // then assertAll( - () -> assertThat(result.getName()).isEqualTo("에어맥스"), + () -> assertThat(result.getName()).isEqualTo("에어맥스 90"), () -> assertThat(result.getPrice().value()).isEqualTo(129000) ); verify(productRepository).save(any(ProductModel.class)); @@ -176,8 +177,8 @@ void returnsMapOfProducts() { // then assertAll( () -> assertThat(result).hasSize(2), - () -> assertThat(result.get(1L).name()).isEqualTo("에어맥스"), - () -> assertThat(result.get(2L).name()).isEqualTo("조던") + () -> assertThat(result.get(1L).getName()).isEqualTo("에어맥스"), + () -> assertThat(result.get(2L).getName()).isEqualTo("조던") ); } @@ -231,29 +232,24 @@ class LikeCount { @DisplayName("좋아요 수를 증가시킨다") @Test - void increasesLikeCount() { + void incrementsLikeCount() { // arrange Long id = 1L; - ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - given(productRepository.findById(id)).willReturn(Optional.of(product)); // act - productService.increaseLikeCount(id); + productService.incrementLikeCount(id); // assert - assertThat(product.getLikeCount()).isEqualTo(1); + verify(productRepository).incrementLikeCount(id); } @DisplayName("좋아요 수를 감소시킨다") @Test - void decreasesLikeCount() { + void decrementsLikeCount() { // arrange Long id = 1L; - ProductModel product = new ProductModel("에어맥스", "러닝화", new Money(129000), 1L); - product.increaseLikeCount(); - given(productRepository.findById(id)).willReturn(Optional.of(product)); // act - productService.decreaseLikeCount(id); + productService.decrementLikeCount(id); // assert - assertThat(product.getLikeCount()).isEqualTo(0); + verify(productRepository).decrementLikeCount(id); } } } diff --git a/apps/commerce-api/src/test/java/com/loopers/application/stock/StockServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/application/stock/StockServiceTest.java index a524ebc82..25b8a0a85 100644 --- a/apps/commerce-api/src/test/java/com/loopers/application/stock/StockServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/application/stock/StockServiceTest.java @@ -22,6 +22,7 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class StockServiceTest { @@ -105,8 +106,8 @@ void returnsMapOfStocks() { // then assertAll( () -> assertThat(result).hasSize(2), - () -> assertThat(result.get(1L).quantity()).isEqualTo(100), - () -> assertThat(result.get(2L).quantity()).isEqualTo(50) + () -> assertThat(result.get(1L).getQuantity()).isEqualTo(100), + () -> assertThat(result.get(2L).getQuantity()).isEqualTo(50) ); } } diff --git a/apps/commerce-api/src/test/java/com/loopers/config/CustomCacheErrorHandlerTest.java b/apps/commerce-api/src/test/java/com/loopers/config/CustomCacheErrorHandlerTest.java new file mode 100644 index 000000000..c39f18fa8 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/config/CustomCacheErrorHandlerTest.java @@ -0,0 +1,66 @@ +package com.loopers.config; + +import com.loopers.config.redis.CustomCacheErrorHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.cache.Cache; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.mockito.Mockito.mock; + +class CustomCacheErrorHandlerTest { + + private CustomCacheErrorHandler sut; + private Cache mockCache; + + @BeforeEach + void setUp() { + sut = new CustomCacheErrorHandler(); + mockCache = mock(Cache.class); + } + + @Test + @DisplayName("handleCacheGetError 호출 시 예외가 전파되지 않는다") + void handleCacheGetError_should_not_propagate_exception() { + // given + RuntimeException exception = new RuntimeException("Redis connection failed"); + Object key = "testKey"; + + // when & then + assertDoesNotThrow(() -> sut.handleCacheGetError(exception, mockCache, key)); + } + + @Test + @DisplayName("handleCachePutError 호출 시 예외가 전파되지 않는다") + void handleCachePutError_should_not_propagate_exception() { + // given + RuntimeException exception = new RuntimeException("Redis connection failed"); + Object key = "testKey"; + Object value = "testValue"; + + // when & then + assertDoesNotThrow(() -> sut.handleCachePutError(exception, mockCache, key, value)); + } + + @Test + @DisplayName("handleCacheEvictError 호출 시 예외가 전파되지 않는다") + void handleCacheEvictError_should_not_propagate_exception() { + // given + RuntimeException exception = new RuntimeException("Redis connection failed"); + Object key = "testKey"; + + // when & then + assertDoesNotThrow(() -> sut.handleCacheEvictError(exception, mockCache, key)); + } + + @Test + @DisplayName("handleCacheClearError 호출 시 예외가 전파되지 않는다") + void handleCacheClearError_should_not_propagate_exception() { + // given + RuntimeException exception = new RuntimeException("Redis connection failed"); + + // when & then + assertDoesNotThrow(() -> sut.handleCacheClearError(exception, mockCache)); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/config/RedisCacheConfigTest.java b/apps/commerce-api/src/test/java/com/loopers/config/RedisCacheConfigTest.java new file mode 100644 index 000000000..f37c8ab22 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/config/RedisCacheConfigTest.java @@ -0,0 +1,43 @@ +package com.loopers.config; + +import com.loopers.testcontainers.RedisTestContainersConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.CacheManager; +import org.springframework.context.annotation.Import; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.test.context.ActiveProfiles; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +@Import(RedisTestContainersConfig.class) +@ActiveProfiles("test") +class RedisCacheConfigTest { + + @Autowired + private CacheManager cacheManager; + + @Test + @DisplayName("CacheManager Bean이 RedisCacheManager 인스턴스여야 한다") + void cacheManager_should_be_redisCacheManager_instance() { + // given & when & then + assertThat(cacheManager).isInstanceOf(RedisCacheManager.class); + } + + @Test + @DisplayName("productDetail 캐시 설정이 존재해야 한다") + void productDetail_cache_configuration_should_exist() { + // given + RedisCacheManager redisCacheManager = (RedisCacheManager) cacheManager; + + // when + var cache = redisCacheManager.getCache("productDetail"); + + // then + assertThat(cache).isNotNull(); + } + +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponModelQuantityTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponModelQuantityTest.java new file mode 100644 index 000000000..eb7944ed6 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/coupon/CouponModelQuantityTest.java @@ -0,0 +1,71 @@ +package com.loopers.domain.coupon; + +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CouponModelQuantityTest { + + @DisplayName("수량 제한이 없는 쿠폰은 항상 발급 가능하다") + @Test + void noQuantityLimitAlwaysAvailable() { + // given + CouponModel coupon = new CouponModel("테스트 쿠폰", CouponType.FIXED, 1000, + new Money(10000), LocalDateTime.now().plusDays(30)); + + // then + assertAll( + () -> assertThat(coupon.hasQuantityLimit()).isFalse(), + () -> assertThat(coupon.isQuantityAvailable()).isTrue() + ); + } + + @DisplayName("수량 제한이 있는 쿠폰은 발급 수량이 최대 수량 미만이면 발급 가능하다") + @Test + void quantityAvailableWhenUnderLimit() { + // given + CouponModel coupon = new CouponModel("선착순 쿠폰", CouponType.FIXED, 1000, + new Money(10000), LocalDateTime.now().plusDays(30), 100); + + // then + assertAll( + () -> assertThat(coupon.hasQuantityLimit()).isTrue(), + () -> assertThat(coupon.isQuantityAvailable()).isTrue(), + () -> assertThat(coupon.issuedCount()).isZero() + ); + } + + @DisplayName("incrementIssuedCount 호출 시 발급 수량이 증가한다") + @Test + void incrementIssuedCount() { + // given + CouponModel coupon = new CouponModel("선착순 쿠폰", CouponType.FIXED, 1000, + new Money(10000), LocalDateTime.now().plusDays(30), 100); + + // when + coupon.incrementIssuedCount(); + + // then + assertThat(coupon.issuedCount()).isEqualTo(1); + } + + @DisplayName("발급 수량이 최대에 도달하면 예외가 발생한다") + @Test + void throwsWhenQuantityExceeded() { + // given + CouponModel coupon = new CouponModel("선착순 쿠폰", CouponType.FIXED, 1000, + new Money(10000), LocalDateTime.now().plusDays(30), 1); + coupon.incrementIssuedCount(); // 1/1 발급 + + // when & then + assertThrows(CoreException.class, coupon::incrementIssuedCount); + assertThat(coupon.isQuantityAvailable()).isFalse(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java index 4d25df4e0..254c3011a 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/LikeServiceTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.like; +import com.loopers.application.like.LikeService; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -29,70 +30,44 @@ class LikeServiceTest { @Mock private LikeRepository likeRepository; - @DisplayName("좋아요 등록") + @DisplayName("좋아요 저장") @Nested - class Register { + class Save { - @DisplayName("좋아요가 없으면 새로 생성하고 true를 반환한다") + @DisplayName("좋아요를 저장하고 반환한다") @Test - void returnsTrueWhenNewLike() { + void savesAndReturns() { // arrange - Long memberId = 1L; + Long userId = 1L; Long productId = 100L; - given(likeRepository.findByMemberIdAndProductId(memberId, productId)).willReturn(Optional.empty()); - given(likeRepository.save(any(LikeModel.class))).willReturn(new LikeModel(memberId, productId)); + LikeModel like = new LikeModel(userId, productId); + given(likeRepository.save(any(LikeModel.class))).willReturn(like); // act - boolean result = likeService.register(memberId, productId); + LikeModel result = likeService.save(like); // assert - assertThat(result).isTrue(); - then(likeRepository).should().save(any(LikeModel.class)); - } - - @DisplayName("이미 좋아요가 존재하면 false를 반환한다") - @Test - void returnsFalseWhenAlreadyExists() { - // arrange - Long memberId = 1L; - Long productId = 100L; - given(likeRepository.findByMemberIdAndProductId(memberId, productId)) - .willReturn(Optional.of(new LikeModel(memberId, productId))); - // act - boolean result = likeService.register(memberId, productId); - // assert - assertThat(result).isFalse(); + assertThat(result.userId()).isEqualTo(userId); + then(likeRepository).should().save(like); } } - @DisplayName("좋아요 취소") + @DisplayName("좋아요 조회") @Nested - class Cancel { - - @DisplayName("좋아요가 존재하면 삭제하고 true를 반환한다") - @Test - void returnsTrueWhenCancelled() { - // arrange - Long memberId = 1L; - Long productId = 100L; - LikeModel like = new LikeModel(memberId, productId); - given(likeRepository.findByMemberIdAndProductId(memberId, productId)).willReturn(Optional.of(like)); - // act - boolean result = likeService.cancel(memberId, productId); - // assert - assertThat(result).isTrue(); - then(likeRepository).should().delete(like); - } + class Find { - @DisplayName("좋아요가 없으면 false를 반환한다") + @DisplayName("userId와 productId로 좋아요를 조회한다") @Test - void returnsFalseWhenNotExists() { + void findsByUserIdAndProductId() { // arrange - Long memberId = 1L; + Long userId = 1L; Long productId = 100L; - given(likeRepository.findByMemberIdAndProductId(memberId, productId)).willReturn(Optional.empty()); + LikeModel like = new LikeModel(userId, productId); + given(likeRepository.findByUserIdAndProductId(userId, productId)) + .willReturn(Optional.of(like)); // act - boolean result = likeService.cancel(memberId, productId); + Optional result = likeService.findByUserIdAndProductId(userId, productId); // assert - assertThat(result).isFalse(); + assertThat(result).isPresent(); + assertThat(result.get().productId()).isEqualTo(productId); } } @@ -104,12 +79,12 @@ class GetMyLikes { @Test void returnsPagedLikes() { // arrange - Long memberId = 1L; + Long userId = 1L; Pageable pageable = PageRequest.of(0, 10); - List likes = List.of(new LikeModel(memberId, 1L), new LikeModel(memberId, 2L)); - given(likeRepository.findAllByMemberId(memberId, pageable)).willReturn(new PageImpl<>(likes)); + List likes = List.of(new LikeModel(userId, 1L), new LikeModel(userId, 2L)); + given(likeRepository.findActiveLikesWithActiveProduct(userId, pageable)).willReturn(new PageImpl<>(likes)); // act - Page result = likeService.getMyLikes(memberId, pageable); + Page result = likeService.getMyLikes(userId, pageable); // assert assertThat(result.getContent()).hasSize(2); } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/like/event/LikeToggledEventTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/like/event/LikeToggledEventTest.java new file mode 100644 index 000000000..db15ce836 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/like/event/LikeToggledEventTest.java @@ -0,0 +1,22 @@ +package com.loopers.domain.like.event; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +class LikeToggledEventTest { + + @DisplayName("좋아요 이벤트 생성 시 productId와 liked 상태를 가진다") + @Test + void createLikeToggledEvent() { + // given + Long productId = 1L; + + // when + LikeToggledEvent event = new LikeToggledEvent(productId, true); + + // then + assertThat(event.productId()).isEqualTo(1L); + assertThat(event.liked()).isTrue(); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java index 92ff4e9d6..5926cfa64 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java @@ -67,4 +67,54 @@ void throwsWhenTotalAmountNull() { assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); } } + + @DisplayName("결제 상태 전이") + @Nested + class PaymentStatusTransition { + + @DisplayName("CREATED → PAYMENT_PENDING 전이가 가능하다") + @Test + void transitionsToPaymentPending() { + OrderModel order = new OrderModel(1L, new Money(10000), Money.ZERO, null); + order.startPayment(); + assertThat(order.status()).isEqualTo(OrderStatus.PAYMENT_PENDING); + } + + @DisplayName("PAYMENT_PENDING → CONFIRMED 전이가 가능하다 (결제 성공)") + @Test + void transitionsToConfirmed() { + OrderModel order = new OrderModel(1L, new Money(10000), Money.ZERO, null); + order.startPayment(); + order.confirmPayment(); + assertThat(order.status()).isEqualTo(OrderStatus.CONFIRMED); + } + + @DisplayName("PAYMENT_PENDING → PAYMENT_FAILED 전이가 가능하다") + @Test + void transitionsToPaymentFailed() { + OrderModel order = new OrderModel(1L, new Money(10000), Money.ZERO, null); + order.startPayment(); + order.failPayment(); + assertThat(order.status()).isEqualTo(OrderStatus.PAYMENT_FAILED); + } + + @DisplayName("PAYMENT_FAILED → PAYMENT_PENDING 전이가 가능하다 (재결제)") + @Test + void retriesPaymentFromFailed() { + OrderModel order = new OrderModel(1L, new Money(10000), Money.ZERO, null); + order.startPayment(); + order.failPayment(); + order.startPayment(); + assertThat(order.status()).isEqualTo(OrderStatus.PAYMENT_PENDING); + } + + @DisplayName("CONFIRMED 상태에서 startPayment를 호출하면 예외가 발생한다") + @Test + void throwsWhenAlreadyConfirmed() { + OrderModel order = new OrderModel(1L, new Money(10000), Money.ZERO, null); + order.startPayment(); + order.confirmPayment(); + assertThrows(CoreException.class, () -> order.startPayment()); + } + } } diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java index 1bda80473..cdce42ce6 100644 --- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java +++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceTest.java @@ -1,5 +1,6 @@ package com.loopers.domain.order; +import com.loopers.application.order.OrderService; import com.loopers.domain.product.Money; import com.loopers.support.error.CoreException; import com.loopers.support.error.ErrorType; @@ -29,6 +30,9 @@ class OrderServiceTest { @Mock private OrderRepository orderRepository; + @Mock + private OrderItemRepository orderItemRepository; + @DisplayName("주문 저장") @Nested class Save { @@ -37,32 +41,33 @@ class Save { @Test void savesOrder() { // arrange - OrderModel order = new OrderModel(1L); + OrderModel order = new OrderModel(1L, new Money(10000), Money.ZERO, null); given(orderRepository.save(any(OrderModel.class))).willReturn(order); // act OrderModel result = orderService.save(order); // assert - assertThat(result.getMemberId()).isEqualTo(1L); + assertThat(result.userId()).isEqualTo(1L); then(orderRepository).should().save(order); } } @DisplayName("주문 조회") @Nested - class GetById { + class GetOrder { @DisplayName("존재하는 주문을 반환한다") @Test void returnsForExistingId() { // arrange Long id = 1L; - OrderModel order = new OrderModel(1L); + Long userId = 1L; + OrderModel order = new OrderModel(userId, new Money(10000), Money.ZERO, null); ReflectionTestUtils.setField(order, "id", id); given(orderRepository.findById(id)).willReturn(Optional.of(order)); // act - OrderModel result = orderService.getById(id); + OrderModel result = orderService.getOrder(id, userId); // assert - assertThat(result.getMemberId()).isEqualTo(1L); + assertThat(result.userId()).isEqualTo(1L); } @DisplayName("존재하지 않는 ID면 NOT_FOUND 예외가 발생한다") @@ -73,7 +78,7 @@ void throwsOnNonExistentId() { given(orderRepository.findById(id)).willReturn(Optional.empty()); // act CoreException exception = assertThrows(CoreException.class, () -> { - orderService.getById(id); + orderService.getOrder(id, 1L); }); // assert assertThat(exception.getErrorType()).isEqualTo(ErrorType.NOT_FOUND); diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventTest.java new file mode 100644 index 000000000..75c4397f1 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/outbox/OutboxEventTest.java @@ -0,0 +1,94 @@ +package com.loopers.domain.outbox; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class OutboxEventTest { + + @DisplayName("OutboxEvent 생성 시 PENDING 상태와 UUID eventId가 할당된다") + @Test + void createsWithPendingStatusAndEventId() { + // given & when + OutboxEvent event = new OutboxEvent( + "Product", 1L, "LIKED", "catalog-events", "1", "{}" + ); + + // then + assertAll( + () -> assertThat(event.getStatus()).isEqualTo(OutboxStatus.PENDING), + () -> assertThat(event.getEventId()).isNotNull(), + () -> assertThat(event.getEventId()).hasSize(36), + () -> assertThat(event.getRetryCount()).isZero(), + () -> assertThat(event.getCreatedAt()).isNotNull() + ); + } + + @DisplayName("markProcessing → PROCESSING 상태로 전환") + @Test + void markProcessingChangesStatus() { + // given + OutboxEvent event = new OutboxEvent("Product", 1L, "LIKED", "catalog-events", "1", "{}"); + + // when + event.markProcessing(); + + // then + assertThat(event.getStatus()).isEqualTo(OutboxStatus.PROCESSING); + } + + @DisplayName("markPublished → PUBLISHED 상태 + publishedAt 설정") + @Test + void markPublishedSetsPublishedAt() { + // given + OutboxEvent event = new OutboxEvent("Product", 1L, "LIKED", "catalog-events", "1", "{}"); + event.markProcessing(); + + // when + event.markPublished(); + + // then + assertAll( + () -> assertThat(event.getStatus()).isEqualTo(OutboxStatus.PUBLISHED), + () -> assertThat(event.getPublishedAt()).isNotNull() + ); + } + + @DisplayName("markFailed → FAILED 상태 + retryCount 증가 + errorMessage 저장") + @Test + void markFailedIncrementsRetryCount() { + // given + OutboxEvent event = new OutboxEvent("Product", 1L, "LIKED", "catalog-events", "1", "{}"); + event.markProcessing(); + + // when + event.markFailed("Connection timeout"); + + // then + assertAll( + () -> assertThat(event.getStatus()).isEqualTo(OutboxStatus.FAILED), + () -> assertThat(event.getRetryCount()).isEqualTo(1), + () -> assertThat(event.getErrorMessage()).isEqualTo("Connection timeout") + ); + } + + @DisplayName("markRetry → PENDING 상태로 복구") + @Test + void markRetryResetsToPending() { + // given + OutboxEvent event = new OutboxEvent("Product", 1L, "LIKED", "catalog-events", "1", "{}"); + event.markProcessing(); + event.markFailed("error"); + + // when + event.markRetry(); + + // then + assertAll( + () -> assertThat(event.getStatus()).isEqualTo(OutboxStatus.PENDING), + () -> assertThat(event.getRetryCount()).isEqualTo(1) // retryCount는 유지 + ); + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentModelTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentModelTest.java new file mode 100644 index 000000000..27afdc087 --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentModelTest.java @@ -0,0 +1,147 @@ +package com.loopers.domain.payment; + +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class PaymentModelTest { + + @DisplayName("결제 생성") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성하면 상태가 PENDING이다") + @Test + void createsWithPendingStatus() { + // given + Long orderId = 1L; + Long userId = 1L; + CardType cardType = CardType.SAMSUNG; + String maskedCardNo = "1234-****-****-1451"; + Money amount = new Money(50000); + + // when + PaymentModel payment = new PaymentModel(orderId, userId, cardType, maskedCardNo, amount); + + // then + assertAll( + () -> assertThat(payment.orderId()).isEqualTo(orderId), + () -> assertThat(payment.userId()).isEqualTo(userId), + () -> assertThat(payment.cardType()).isEqualTo(CardType.SAMSUNG), + () -> assertThat(payment.maskedCardNo()).isEqualTo(maskedCardNo), + () -> assertThat(payment.amount()).isEqualTo(amount), + () -> assertThat(payment.status()).isEqualTo(PaymentStatus.PENDING), + () -> assertThat(payment.transactionKey()).isNull(), + () -> assertThat(payment.failureReason()).isNull() + ); + } + + @DisplayName("orderId가 null이면 예외가 발생한다") + @Test + void throwsWhenOrderIdNull() { + CoreException result = assertThrows(CoreException.class, + () -> new PaymentModel(null, 1L, CardType.SAMSUNG, "1234-****-****-1451", new Money(50000))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("userId가 null이면 예외가 발생한다") + @Test + void throwsWhenUserIdNull() { + CoreException result = assertThrows(CoreException.class, + () -> new PaymentModel(1L, null, CardType.SAMSUNG, "1234-****-****-1451", new Money(50000))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("cardType이 null이면 예외가 발생한다") + @Test + void throwsWhenCardTypeNull() { + CoreException result = assertThrows(CoreException.class, + () -> new PaymentModel(1L, 1L, null, "1234-****-****-1451", new Money(50000))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("maskedCardNo가 blank이면 예외가 발생한다") + @Test + void throwsWhenCardNoBlank() { + CoreException result = assertThrows(CoreException.class, + () -> new PaymentModel(1L, 1L, CardType.SAMSUNG, " ", new Money(50000))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("amount가 null이면 예외가 발생한다") + @Test + void throwsWhenAmountNull() { + CoreException result = assertThrows(CoreException.class, + () -> new PaymentModel(1L, 1L, CardType.SAMSUNG, "1234-****-****-1451", null)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("상태 전이") + @Nested + class StatusTransition { + + private PaymentModel createPayment() { + return new PaymentModel(1L, 1L, CardType.SAMSUNG, "1234-****-****-1451", new Money(50000)); + } + + @DisplayName("transactionKey를 설정할 수 있다") + @Test + void assignsTransactionKey() { + PaymentModel payment = createPayment(); + payment.assignTransactionKey("20250816:TR:9577c5"); + assertThat(payment.transactionKey()).isEqualTo("20250816:TR:9577c5"); + } + + @DisplayName("PENDING → SUCCESS 전이가 가능하다") + @Test + void transitionsToSuccess() { + PaymentModel payment = createPayment(); + payment.markSuccess(); + assertThat(payment.status()).isEqualTo(PaymentStatus.SUCCESS); + } + + @DisplayName("PENDING → FAILED 전이가 가능하다") + @Test + void transitionsToFailed() { + PaymentModel payment = createPayment(); + payment.markFailed("한도 초과"); + assertAll( + () -> assertThat(payment.status()).isEqualTo(PaymentStatus.FAILED), + () -> assertThat(payment.failureReason()).isEqualTo("한도 초과") + ); + } + + @DisplayName("SUCCESS 상태에서 다시 SUCCESS로 전이해도 멱등하다") + @Test + void idempotentSuccess() { + PaymentModel payment = createPayment(); + payment.markSuccess(); + payment.markSuccess(); + assertThat(payment.status()).isEqualTo(PaymentStatus.SUCCESS); + } + + @DisplayName("SUCCESS 상태에서 FAILED로 전이하면 예외가 발생한다") + @Test + void throwsWhenSuccessToFailed() { + PaymentModel payment = createPayment(); + payment.markSuccess(); + assertThrows(CoreException.class, () -> payment.markFailed("오류")); + } + + @DisplayName("FAILED 상태에서 SUCCESS로 전이하면 예외가 발생한다") + @Test + void throwsWhenFailedToSuccess() { + PaymentModel payment = createPayment(); + payment.markFailed("한도 초과"); + assertThrows(CoreException.class, () -> payment.markSuccess()); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgClientWireMockTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgClientWireMockTest.java new file mode 100644 index 000000000..e4aae85ee --- /dev/null +++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgClientWireMockTest.java @@ -0,0 +1,324 @@ +package com.loopers.infrastructure.payment; + +import com.loopers.application.brand.BrandService; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.order.OrderResult; +import com.loopers.application.payment.PaymentCommand; +import com.loopers.application.payment.PaymentFacade; +import com.loopers.application.payment.PaymentInfo; +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderStatus; +import com.loopers.application.order.OrderService; +import com.loopers.domain.payment.CardType; +import com.loopers.domain.product.Money; +import com.loopers.utils.DatabaseCleanUp; +import com.github.tomakehurst.wiremock.client.ResponseDefinitionBuilder; +import com.github.tomakehurst.wiremock.http.Fault; +import io.github.resilience4j.circuitbreaker.CircuitBreaker; +import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.ClassOrderer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestClassOrder; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.contract.wiremock.AutoConfigureWireMock; +import org.springframework.test.context.TestPropertySource; + +import java.util.List; + +import static com.github.tomakehurst.wiremock.client.WireMock.*; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +@SpringBootTest +@AutoConfigureWireMock(port = 0) +@TestPropertySource(properties = "pg.base-url=http://localhost:${wiremock.server.port}") +@TestClassOrder(ClassOrderer.OrderAnnotation.class) +@DisplayName("PG 연동 WireMock 테스트") +class PgClientWireMockTest { + + @Autowired private PaymentFacade paymentFacade; + @Autowired private OrderFacade orderFacade; + @Autowired private OrderService orderService; + @Autowired private ProductFacade productFacade; + @Autowired private BrandService brandService; + @Autowired private DatabaseCleanUp databaseCleanUp; + @Autowired private CircuitBreakerRegistry circuitBreakerRegistry; + + @BeforeEach + void setUp() { + removeAllMappings(); + resetAllRequests(); + resetAllScenarios(); + circuitBreakerRegistry.circuitBreaker("pg").reset(); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private OrderResult createOrder() { + Long brandId = brandService.register("나이키", "스포츠").getId(); + Long productId = productFacade.register("에어맥스", "러닝화", new Money(50000), brandId, 10).id(); + return orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1)), null); + } + + private static ResponseDefinitionBuilder pgOkResponse(Long orderId, String transactionKey) { + return aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withHeader("Connection", "close") + .withBody(""" + { + "transactionKey": "%s", + "orderId": "%d", + "status": "PENDING", + "failureReason": null + } + """.formatted(transactionKey, orderId)); + } + + @DisplayName("PG 정상 응답") + @Nested + @Order(1) + class PgSuccessResponse { + + @DisplayName("PG가 정상 응답하면 PENDING 결제가 생성되고 요청 헤더/바디가 올바르게 전달된다") + @Test + void createsPendingPaymentWithCorrectRequest() { + // given + OrderResult orderResult = createOrder(); + Long orderId = orderResult.order().getId(); + + stubFor(post(urlEqualTo("/api/v1/payments")) + .willReturn(pgOkResponse(orderId, "20250816:TR:wm001"))); + + PaymentCommand command = new PaymentCommand(orderId, CardType.SAMSUNG, "1234-5678-9814-1451"); + + // when + PaymentInfo result = paymentFacade.requestPayment(1L, command); + + // then + assertAll( + () -> assertThat(result.status()).isEqualTo("PENDING"), + () -> assertThat(result.transactionKey()).isEqualTo("20250816:TR:wm001"), + () -> assertThat(result.maskedCardNo()).isEqualTo("1234-****-****-1451"), + () -> assertThat(result.amount()).isEqualTo(50000) + ); + + verify(postRequestedFor(urlEqualTo("/api/v1/payments")) + .withHeader("X-USER-ID", equalTo("1")) + .withHeader("Content-Type", containing("application/json")) + .withRequestBody(matchingJsonPath("$.orderId", equalTo(String.valueOf(orderId)))) + .withRequestBody(matchingJsonPath("$.cardType", equalTo("SAMSUNG"))) + .withRequestBody(matchingJsonPath("$.amount", equalTo("50000")))); + } + } + + @DisplayName("전체 결제 플로우") + @Nested + @Order(2) + class FullPaymentFlow { + + @DisplayName("결제 요청 → 콜백 SUCCESS → 주문 CONFIRMED 전체 흐름이 정상 동작한다") + @Test + void fullSuccessFlow() { + // given + OrderResult orderResult = createOrder(); + Long orderId = orderResult.order().getId(); + + stubFor(post(urlEqualTo("/api/v1/payments")) + .willReturn(pgOkResponse(orderId, "20250816:TR:flow001"))); + + PaymentCommand command = new PaymentCommand(orderId, CardType.SAMSUNG, "1234-5678-9814-1451"); + + // when - 1. 결제 요청 + PaymentInfo paymentInfo = paymentFacade.requestPayment(1L, command); + assertThat(paymentInfo.status()).isEqualTo("PENDING"); + + // when - 2. PG 콜백 (SUCCESS) + paymentFacade.handleCallback("20250816:TR:flow001", "SUCCESS", null); + + // then + PaymentInfo completedPayment = paymentFacade.getPaymentsByOrderId(orderId).get(0); + OrderModel order = orderService.getOrderForAdmin(orderId); + + assertAll( + () -> assertThat(completedPayment.status()).isEqualTo("SUCCESS"), + () -> assertThat(order.status()).isEqualTo(OrderStatus.CONFIRMED) + ); + } + + @DisplayName("결제 요청 → 콜백 FAILED → 주문 PAYMENT_FAILED 전체 흐름이 정상 동작한다") + @Test + void fullFailureFlow() { + // given + OrderResult orderResult = createOrder(); + Long orderId = orderResult.order().getId(); + + stubFor(post(urlEqualTo("/api/v1/payments")) + .willReturn(pgOkResponse(orderId, "20250816:TR:flow002"))); + + PaymentCommand command = new PaymentCommand(orderId, CardType.SAMSUNG, "1234-5678-9814-1451"); + + // when - 1. 결제 요청 + PaymentInfo paymentInfo = paymentFacade.requestPayment(1L, command); + assertThat(paymentInfo.status()).isEqualTo("PENDING"); + + // when - 2. PG 콜백 (FAILED) + paymentFacade.handleCallback("20250816:TR:flow002", "FAILED", "잔액 부족"); + + // then + PaymentInfo failedPayment = paymentFacade.getPaymentsByOrderId(orderId).get(0); + OrderModel order = orderService.getOrderForAdmin(orderId); + + assertAll( + () -> assertThat(failedPayment.status()).isEqualTo("FAILED"), + () -> assertThat(failedPayment.failureReason()).isEqualTo("잔액 부족"), + () -> assertThat(order.status()).isEqualTo(OrderStatus.PAYMENT_FAILED) + ); + } + } + + @DisplayName("PG 5xx 에러 → Retry 후 Fallback") + @Nested + @Order(3) + class PgServerError { + + @DisplayName("PG가 500 응답을 반환하면 3번 재시도 후 Fallback으로 PG_FAILED 응답과 주문 PAYMENT_FAILED 처리된다") + @Test + void fallbackOn500AfterRetry() { + // given + OrderResult orderResult = createOrder(); + Long orderId = orderResult.order().getId(); + + stubFor(post(urlEqualTo("/api/v1/payments")) + .willReturn(aResponse() + .withStatus(500) + .withHeader("Connection", "close") + .withBody("Internal Server Error"))); + + PaymentCommand command = new PaymentCommand(orderId, CardType.SAMSUNG, "1234-5678-9814-1451"); + + // when + PaymentInfo result = paymentFacade.requestPayment(1L, command); + + // then - Fallback 응답 검증 + assertThat(result.status()).isEqualTo("PG_FAILED"); + + // then - 주문 상태 검증 + // TX 분리: TX-1이 커밋되어 주문은 PAYMENT_PENDING 상태 유지 + // → Polling 스케줄러가 복구하거나 사용자가 수동 동기화 + OrderModel order = orderService.getOrderForAdmin(orderId); + assertThat(order.status()).isEqualTo(OrderStatus.PAYMENT_PENDING); + + // then - Retry로 3번 호출되었는지 검증 + verify(3, postRequestedFor(urlEqualTo("/api/v1/payments"))); + } + } + + @DisplayName("PG 잘못된 응답 → Fallback") + @Nested + @Order(4) + class PgMalformedResponse { + + @DisplayName("PG가 transactionKey 없이 응답하면 Fallback으로 PG_FAILED 응답이 반환된다") + @Test + void fallbackOnMissingTransactionKey() { + // given + OrderResult orderResult = createOrder(); + Long orderId = orderResult.order().getId(); + + stubFor(post(urlEqualTo("/api/v1/payments")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withHeader("Connection", "close") + .withBody(""" + { + "transactionKey": null, + "orderId": "%d", + "status": "PENDING", + "failureReason": null + } + """.formatted(orderId)))); + + PaymentCommand command = new PaymentCommand(orderId, CardType.SAMSUNG, "1234-5678-9814-1451"); + + // when + PaymentInfo result = paymentFacade.requestPayment(1L, command); + + // then + assertThat(result.status()).isEqualTo("PG_FAILED"); + + // TX 분리: TX-1이 커밋되어 주문은 PAYMENT_PENDING 상태 유지 + // → Polling 스케줄러가 복구하거나 사용자가 수동 동기화 + OrderModel order = orderService.getOrderForAdmin(orderId); + assertThat(order.status()).isEqualTo(OrderStatus.PAYMENT_PENDING); + } + } + + @DisplayName("PG 네트워크 장애 → Retry 후 Fallback") + @Nested + @Order(5) + class PgNetworkFault { + + @DisplayName("PG 연결이 끊어지면 3번 재시도 후 Fallback으로 PG_FAILED 응답이 반환된다") + @Test + void fallbackOnConnectionReset() { + // given + OrderResult orderResult = createOrder(); + Long orderId = orderResult.order().getId(); + + stubFor(post(urlEqualTo("/api/v1/payments")) + .willReturn(aResponse().withFault(Fault.CONNECTION_RESET_BY_PEER))); + + PaymentCommand command = new PaymentCommand(orderId, CardType.SAMSUNG, "1234-5678-9814-1451"); + + // when + PaymentInfo result = paymentFacade.requestPayment(1L, command); + + // then + assertThat(result.status()).isEqualTo("PG_FAILED"); + + // TX 분리: TX-1이 커밋되어 주문은 PAYMENT_PENDING 상태 유지 + // → Polling 스케줄러가 복구하거나 사용자가 수동 동기화 + OrderModel order = orderService.getOrderForAdmin(orderId); + assertThat(order.status()).isEqualTo(OrderStatus.PAYMENT_PENDING); + + verify(3, postRequestedFor(urlEqualTo("/api/v1/payments"))); + } + + @DisplayName("PG가 불완전한 응답을 보내면 Fallback으로 PG_FAILED 응답이 반환된다") + @Test + void fallbackOnEmptyResponse() { + // given + OrderResult orderResult = createOrder(); + Long orderId = orderResult.order().getId(); + + stubFor(post(urlEqualTo("/api/v1/payments")) + .willReturn(aResponse().withFault(Fault.EMPTY_RESPONSE))); + + PaymentCommand command = new PaymentCommand(orderId, CardType.SAMSUNG, "1234-5678-9814-1451"); + + // when + PaymentInfo result = paymentFacade.requestPayment(1L, command); + + // then + assertThat(result.status()).isEqualTo("PG_FAILED"); + + // TX 분리: TX-1이 커밋되어 주문은 PAYMENT_PENDING 상태 유지 + // → Polling 스케줄러가 복구하거나 사용자가 수동 동기화 + OrderModel order = orderService.getOrderForAdmin(orderId); + assertThat(order.status()).isEqualTo(OrderStatus.PAYMENT_PENDING); + } + } +} diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java index 9c4f78e12..b89dd5fc0 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/brand/BrandV1ApiE2ETest.java @@ -1,7 +1,7 @@ package com.loopers.interfaces.api.brand; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; +import com.loopers.interfaces.api.brand.BrandAdminV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java index 67152c396..20664ca28 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/like/LikeV1ApiE2ETest.java @@ -1,9 +1,9 @@ package com.loopers.interfaces.api.like; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; +import com.loopers.interfaces.api.brand.BrandAdminV1Dto; import com.loopers.interfaces.api.member.MemberV1Dto; -import com.loopers.interfaces.api.product.admin.ProductAdminV1Dto; +import com.loopers.interfaces.api.product.ProductAdminV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -50,7 +50,7 @@ private Long createBrand(String name) { private Long createProduct(String name, int price, Long brandId) { var req = new ProductAdminV1Dto.CreateRequest(name, "설명", price, brandId, 10); return testRestTemplate.exchange("/api-admin/v1/products", HttpMethod.POST, new HttpEntity<>(req, adminHeaders()), - new ParameterizedTypeReference>() {}).getBody().data().id(); + new ParameterizedTypeReference>() {}).getBody().data().id(); } private void signupMember() { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java index 5b366f864..f83010ed9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/order/OrderV1ApiE2ETest.java @@ -2,9 +2,9 @@ import com.loopers.infrastructure.member.MemberJpaRepository; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; +import com.loopers.interfaces.api.brand.BrandAdminV1Dto; import com.loopers.interfaces.api.member.MemberV1Dto; -import com.loopers.interfaces.api.product.admin.ProductAdminV1Dto; +import com.loopers.interfaces.api.product.ProductAdminV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -59,7 +59,7 @@ private Long createProduct(String name, int price, Long brandId, int stock) { var req = new ProductAdminV1Dto.CreateRequest(name, "설명", price, brandId, stock); return testRestTemplate.exchange("/api-admin/v1/products", HttpMethod.POST, new HttpEntity<>(req, adminHeaders()), - new ParameterizedTypeReference>() {}).getBody().data().id(); + new ParameterizedTypeReference>() {}).getBody().data().id(); } private void signupMember() { diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java index d2ae0befe..e53288dd9 100644 --- a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java +++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java @@ -1,8 +1,8 @@ package com.loopers.interfaces.api.product; import com.loopers.interfaces.api.ApiResponse; -import com.loopers.interfaces.api.brand.admin.BrandAdminV1Dto; -import com.loopers.interfaces.api.product.admin.ProductAdminV1Dto; +import com.loopers.interfaces.api.brand.BrandAdminV1Dto; +import com.loopers.interfaces.api.product.ProductAdminV1Dto; import com.loopers.utils.DatabaseCleanUp; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.DisplayName; @@ -60,7 +60,7 @@ private Long createBrand(String name) { private Long createProduct(String name, int price, Long brandId, int initialStock) { ProductAdminV1Dto.CreateRequest request = new ProductAdminV1Dto.CreateRequest(name, "설명", price, brandId, initialStock); - ResponseEntity> response = testRestTemplate.exchange( + ResponseEntity> response = testRestTemplate.exchange( ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -136,7 +136,7 @@ void returns200WithStockStatus() { Long productId = createProduct("에어맥스 90", 129000, brandId, 100); // when - ResponseEntity> response = testRestTemplate.exchange( + ResponseEntity> response = testRestTemplate.exchange( CUSTOMER_ENDPOINT + "/" + productId, HttpMethod.GET, null, new ParameterizedTypeReference<>() {} ); @@ -200,7 +200,7 @@ void returns200WithStockQuantity() { ); // when - ResponseEntity> response = testRestTemplate.exchange( + ResponseEntity> response = testRestTemplate.exchange( ADMIN_ENDPOINT, HttpMethod.POST, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -273,7 +273,7 @@ void returns200() { Long productId = createProduct("에어맥스 90", 129000, brandId, 100); // when - ResponseEntity> response = testRestTemplate.exchange( + ResponseEntity> response = testRestTemplate.exchange( ADMIN_ENDPOINT + "/" + productId, HttpMethod.GET, new HttpEntity<>(null, adminHeaders()), new ParameterizedTypeReference<>() {} ); @@ -302,7 +302,7 @@ void returns200WithUpdatedInfo() { ); // when - ResponseEntity> response = testRestTemplate.exchange( + ResponseEntity> response = testRestTemplate.exchange( ADMIN_ENDPOINT + "/" + productId, HttpMethod.PUT, new HttpEntity<>(request, adminHeaders()), new ParameterizedTypeReference<>() {} ); diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponEntity.java new file mode 100644 index 000000000..8b2c4e763 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponEntity.java @@ -0,0 +1,45 @@ +package com.loopers.domain.coupon; + +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.LocalDateTime; + +/** + * commerce-streamer에서 쿠폰 발급 Consumer가 참조하는 읽기/쓰기용 엔티티. + * commerce-api의 CouponModel과 같은 테이블(coupon)을 매핑한다. + */ +@Getter +@Entity +@Table(name = "coupon") +public class CouponEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "max_quantity") + private Integer maxQuantity; + + @Column(name = "issued_count", nullable = false) + private int issuedCount; + + @Column(name = "expired_at", nullable = false) + private LocalDateTime expiredAt; + + protected CouponEntity() { + } + + public boolean isExpired() { + return LocalDateTime.now().isAfter(this.expiredAt); + } + + public boolean isQuantityAvailable() { + if (maxQuantity == null) return true; + return issuedCount < maxQuantity; + } + + public void incrementIssuedCount() { + this.issuedCount++; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueEntity.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueEntity.java new file mode 100644 index 000000000..7dbd307d4 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/coupon/CouponIssueEntity.java @@ -0,0 +1,58 @@ +package com.loopers.domain.coupon; + +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; + +/** + * commerce-streamer에서 쿠폰 발급 Consumer가 사용하는 엔티티. + * commerce-api의 CouponIssueModel과 같은 테이블(coupon_issue)을 매핑한다. + */ +@Getter +@Entity +@Table(name = "coupon_issue", uniqueConstraints = { + @UniqueConstraint(name = "uk_coupon_issue_user_coupon", columnNames = {"user_id", "coupon_id"}) +}) +public class CouponIssueEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "coupon_id", nullable = false) + private Long couponId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Column(name = "expired_at", nullable = false) + private LocalDateTime expiredAt; + + @Column(name = "used_at") + private LocalDateTime usedAt; + + @Column(name = "order_id") + private Long orderId; + + @Column(name = "created_at", nullable = false, updatable = false) + private ZonedDateTime createdAt; + + @Column(name = "updated_at", nullable = false) + private ZonedDateTime updatedAt; + + @Column(name = "deleted_at") + private ZonedDateTime deletedAt; + + protected CouponIssueEntity() { + } + + public CouponIssueEntity(Long couponId, Long userId, LocalDateTime expiredAt) { + this.couponId = couponId; + this.userId = userId; + this.expiredAt = expiredAt; + this.createdAt = ZonedDateTime.now(); + this.updatedAt = this.createdAt; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/idempotency/EventHandled.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/idempotency/EventHandled.java new file mode 100644 index 000000000..7e49c4466 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/idempotency/EventHandled.java @@ -0,0 +1,31 @@ +package com.loopers.domain.idempotency; + +import jakarta.persistence.*; +import lombok.Getter; + +import java.time.ZonedDateTime; + +@Getter +@Entity +@Table(name = "event_handled") +public class EventHandled { + + @Id + @Column(name = "event_id", length = 36) + private String eventId; + + @Column(name = "event_type", nullable = false, length = 50) + private String eventType; + + @Column(name = "handled_at", nullable = false) + private ZonedDateTime handledAt; + + protected EventHandled() { + } + + public EventHandled(String eventId, String eventType) { + this.eventId = eventId; + this.eventType = eventType; + this.handledAt = ZonedDateTime.now(); + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java new file mode 100644 index 000000000..0b9f556de --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/domain/metrics/ProductMetrics.java @@ -0,0 +1,55 @@ +package com.loopers.domain.metrics; + +import jakarta.persistence.*; +import lombok.Getter; + +@Getter +@Entity +@Table(name = "product_metrics") +public class ProductMetrics { + + @Id + @Column(name = "product_id") + private Long productId; + + @Column(name = "like_count", nullable = false) + private int likeCount; + + @Column(name = "view_count", nullable = false) + private int viewCount; + + @Column(name = "sale_count", nullable = false) + private int saleCount; + + @Version + @Column(name = "version", nullable = false) + private long version; + + protected ProductMetrics() { + } + + public ProductMetrics(Long productId) { + this.productId = productId; + this.likeCount = 0; + this.viewCount = 0; + this.saleCount = 0; + } + + public void incrementLikeCount() { + this.likeCount++; + } + + public void decrementLikeCount() { + if (this.likeCount > 0) { + this.likeCount--; + } + } + + public void incrementViewCount() { + this.viewCount++; + } + + public void incrementSaleCount(int quantity) { + this.saleCount += quantity; + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java new file mode 100644 index 000000000..02f1f8db5 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponIssueJpaRepository.java @@ -0,0 +1,11 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponIssueEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface CouponIssueJpaRepository extends JpaRepository { + + Optional findByUserIdAndCouponId(Long userId, Long couponId); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java new file mode 100644 index 000000000..c53427e86 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/coupon/CouponJpaRepository.java @@ -0,0 +1,17 @@ +package com.loopers.infrastructure.coupon; + +import com.loopers.domain.coupon.CouponEntity; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Optional; + +public interface CouponJpaRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT c FROM CouponEntity c WHERE c.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/idempotency/EventHandledJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/idempotency/EventHandledJpaRepository.java new file mode 100644 index 000000000..be37823eb --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/idempotency/EventHandledJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.idempotency; + +import com.loopers.domain.idempotency.EventHandled; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EventHandledJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java new file mode 100644 index 000000000..f691f0e6e --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/infrastructure/metrics/ProductMetricsJpaRepository.java @@ -0,0 +1,7 @@ +package com.loopers.infrastructure.metrics; + +import com.loopers.domain.metrics.ProductMetrics; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ProductMetricsJpaRepository extends JpaRepository { +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java new file mode 100644 index 000000000..532e94ca0 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CatalogEventConsumer.java @@ -0,0 +1,165 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.domain.idempotency.EventHandled; +import com.loopers.domain.metrics.ProductMetrics; +import com.loopers.infrastructure.idempotency.EventHandledJpaRepository; +import com.loopers.infrastructure.metrics.ProductMetricsJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.header.internals.RecordHeader; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.orm.ObjectOptimisticLockingFailureException; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +@Component +public class CatalogEventConsumer { + + private static final String DLT_TOPIC = "catalog-events.DLT"; + private static final int MAX_RETRY = 3; + + private final ProductMetricsJpaRepository productMetricsRepository; + private final EventHandledJpaRepository eventHandledRepository; + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + + @KafkaListener( + topics = "catalog-events", + groupId = "streamer-catalog", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consume(List> records, Acknowledgment ack) { + for (ConsumerRecord record : records) { + try { + processWithRetry(record); + } catch (Exception e) { + log.error("catalog-events 처리 실패 → DLQ 전송: offset={}, error={}", record.offset(), e.getMessage(), e); + sendToDlq(record, e); + } + } + ack.acknowledge(); + } + + /** + * @Version 낙관적 락 충돌 시 최대 MAX_RETRY 재시도. + * 동시 업데이트로 version이 맞지 않으면 재조회 후 재시도한다. + */ + private void processWithRetry(ConsumerRecord record) { + for (int attempt = 1; attempt <= MAX_RETRY; attempt++) { + try { + processRecord(record); + return; + } catch (ObjectOptimisticLockingFailureException e) { + log.warn("낙관적 락 충돌 (attempt {}/{}): offset={}", attempt, MAX_RETRY, record.offset()); + if (attempt == MAX_RETRY) { + throw e; + } + } + } + } + + @Transactional + public void processRecord(ConsumerRecord record) { + String eventId = getHeader(record, "X-Event-Id"); + String eventType = getHeader(record, "X-Event-Type"); + + if (eventId == null || eventType == null) { + log.warn("이벤트 헤더 누락: offset={}", record.offset()); + return; + } + + // 멱등 체크 + if (eventHandledRepository.existsById(eventId)) { + log.debug("이미 처리된 이벤트: eventId={}", eventId); + return; + } + + JsonNode envelope = parsePayload(record.value()); + if (envelope == null) return; + + JsonNode data = envelope.get("data"); + + switch (eventType) { + case "LIKED" -> handleLiked(data); + case "UNLIKED" -> handleUnliked(data); + case "PRODUCT_VIEWED" -> handleProductViewed(data); + default -> log.warn("알 수 없는 이벤트 타입: {}", eventType); + } + + eventHandledRepository.save(new EventHandled(eventId, eventType)); + } + + private void handleLiked(JsonNode data) { + Long productId = data.get("productId").asLong(); + ProductMetrics metrics = getOrCreateMetrics(productId); + metrics.incrementLikeCount(); + productMetricsRepository.save(metrics); + log.info("좋아요 집계: productId={}", productId); + } + + private void handleUnliked(JsonNode data) { + Long productId = data.get("productId").asLong(); + ProductMetrics metrics = getOrCreateMetrics(productId); + metrics.decrementLikeCount(); + productMetricsRepository.save(metrics); + log.info("좋아요 취소 집계: productId={}", productId); + } + + private void handleProductViewed(JsonNode data) { + Long productId = data.get("productId").asLong(); + ProductMetrics metrics = getOrCreateMetrics(productId); + metrics.incrementViewCount(); + productMetricsRepository.save(metrics); + log.debug("조회수 집계: productId={}", productId); + } + + private ProductMetrics getOrCreateMetrics(Long productId) { + return productMetricsRepository.findById(productId) + .orElseGet(() -> new ProductMetrics(productId)); + } + + private String getHeader(ConsumerRecord record, String key) { + var header = record.headers().lastHeader(key); + return header != null ? new String(header.value(), StandardCharsets.UTF_8) : null; + } + + private JsonNode parsePayload(String payload) { + try { + return objectMapper.readTree(payload); + } catch (Exception e) { + log.error("payload 파싱 실패: {}", e.getMessage()); + return null; + } + } + + private void sendToDlq(ConsumerRecord record, Exception exception) { + try { + ProducerRecord producerRecord = new ProducerRecord<>( + DLT_TOPIC, null, record.key(), record.value() + ); + // 원본 헤더 복사 + record.headers().forEach(h -> producerRecord.headers().add(h)); + // 에러 정보 추가 + producerRecord.headers().add(new RecordHeader("X-Error-Message", + exception.getMessage().getBytes(StandardCharsets.UTF_8))); + producerRecord.headers().add(new RecordHeader("X-Original-Topic", + record.topic().getBytes(StandardCharsets.UTF_8))); + kafkaTemplate.send(producerRecord); + log.info("DLQ 전송 완료: topic={}, offset={}", DLT_TOPIC, record.offset()); + } catch (Exception e) { + log.error("DLQ 전송 실패: offset={}, error={}", record.offset(), e.getMessage()); + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java new file mode 100644 index 000000000..d8bf3e912 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/CouponIssueConsumer.java @@ -0,0 +1,129 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.domain.coupon.CouponEntity; +import com.loopers.domain.coupon.CouponIssueEntity; +import com.loopers.domain.idempotency.EventHandled; +import com.loopers.infrastructure.coupon.CouponIssueJpaRepository; +import com.loopers.infrastructure.coupon.CouponJpaRepository; +import com.loopers.infrastructure.idempotency.EventHandledJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +@Component +public class CouponIssueConsumer { + + private final CouponJpaRepository couponRepository; + private final CouponIssueJpaRepository couponIssueRepository; + private final EventHandledJpaRepository eventHandledRepository; + private final ObjectMapper objectMapper; + + @KafkaListener( + topics = "coupon-issue-requests", + groupId = "streamer-coupon", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consume(List> records, Acknowledgment ack) { + for (ConsumerRecord record : records) { + try { + processRecord(record); + } catch (Exception e) { + log.error("coupon-issue-requests 처리 실패: offset={}, error={}", record.offset(), e.getMessage(), e); + } + } + ack.acknowledge(); + } + + @Transactional + public void processRecord(ConsumerRecord record) { + String eventId = getHeader(record, "X-Event-Id"); + if (eventId == null) { + log.warn("이벤트 헤더 누락: offset={}", record.offset()); + return; + } + + // 멱등 체크 + if (eventHandledRepository.existsById(eventId)) { + log.debug("이미 처리된 이벤트: eventId={}", eventId); + return; + } + + JsonNode envelope = parsePayload(record.value()); + if (envelope == null) return; + + JsonNode data = envelope.get("data"); + Long couponId = data.get("couponId").asLong(); + Long userId = data.get("userId").asLong(); + + // 비관적 락으로 쿠폰 조회 + CouponEntity coupon = couponRepository.findByIdForUpdate(couponId).orElse(null); + if (coupon == null) { + log.warn("쿠폰 없음: couponId={}", couponId); + eventHandledRepository.save(new EventHandled(eventId, "COUPON_ISSUE_REQUESTED")); + return; + } + + // 만료 체크 + if (coupon.isExpired()) { + log.info("만료된 쿠폰 발급 거부: couponId={}, userId={}", couponId, userId); + eventHandledRepository.save(new EventHandled(eventId, "COUPON_ISSUE_REQUESTED")); + return; + } + + // 수량 체크 + if (!coupon.isQuantityAvailable()) { + log.info("쿠폰 수량 초과: couponId={}, userId={}", couponId, userId); + eventHandledRepository.save(new EventHandled(eventId, "COUPON_ISSUE_REQUESTED")); + return; + } + + // 중복 발급 체크 + if (couponIssueRepository.findByUserIdAndCouponId(userId, couponId).isPresent()) { + log.info("이미 발급된 쿠폰: couponId={}, userId={}", couponId, userId); + eventHandledRepository.save(new EventHandled(eventId, "COUPON_ISSUE_REQUESTED")); + return; + } + + // 발급 처리 + try { + coupon.incrementIssuedCount(); + couponRepository.save(coupon); + + CouponIssueEntity issue = new CouponIssueEntity(couponId, userId, coupon.getExpiredAt()); + couponIssueRepository.save(issue); + + eventHandledRepository.save(new EventHandled(eventId, "COUPON_ISSUE_REQUESTED")); + log.info("쿠폰 발급 성공: couponId={}, userId={}", couponId, userId); + } catch (DataIntegrityViolationException e) { + log.info("쿠폰 중복 발급 방지 (UK): couponId={}, userId={}", couponId, userId); + eventHandledRepository.save(new EventHandled(eventId, "COUPON_ISSUE_REQUESTED")); + } + } + + private String getHeader(ConsumerRecord record, String key) { + var header = record.headers().lastHeader(key); + return header != null ? new String(header.value(), StandardCharsets.UTF_8) : null; + } + + private JsonNode parsePayload(String payload) { + try { + return objectMapper.readTree(payload); + } catch (Exception e) { + log.error("payload 파싱 실패: {}", e.getMessage()); + return null; + } + } +} diff --git a/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java new file mode 100644 index 000000000..a078b4f95 --- /dev/null +++ b/apps/commerce-streamer/src/main/java/com/loopers/interfaces/consumer/OrderEventConsumer.java @@ -0,0 +1,123 @@ +package com.loopers.interfaces.consumer; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.loopers.confg.kafka.KafkaConfig; +import com.loopers.domain.idempotency.EventHandled; +import com.loopers.infrastructure.idempotency.EventHandledJpaRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.kafka.clients.consumer.ConsumerRecord; +import org.apache.kafka.clients.producer.ProducerRecord; +import org.apache.kafka.common.header.internals.RecordHeader; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.Acknowledgment; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +@Component +public class OrderEventConsumer { + + private static final String DLT_TOPIC = "order-events.DLT"; + + private final EventHandledJpaRepository eventHandledRepository; + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + + @KafkaListener( + topics = "order-events", + groupId = "streamer-order", + containerFactory = KafkaConfig.BATCH_LISTENER + ) + public void consume(List> records, Acknowledgment ack) { + for (ConsumerRecord record : records) { + try { + processRecord(record); + } catch (Exception e) { + log.error("order-events 처리 실패 → DLQ 전송: offset={}, error={}", record.offset(), e.getMessage(), e); + sendToDlq(record, e); + } + } + ack.acknowledge(); + } + + @Transactional + public void processRecord(ConsumerRecord record) { + String eventId = getHeader(record, "X-Event-Id"); + String eventType = getHeader(record, "X-Event-Type"); + + if (eventId == null || eventType == null) { + log.warn("이벤트 헤더 누락: offset={}", record.offset()); + return; + } + + if (eventHandledRepository.existsById(eventId)) { + log.debug("이미 처리된 이벤트: eventId={}", eventId); + return; + } + + JsonNode envelope = parsePayload(record.value()); + if (envelope == null) return; + + JsonNode data = envelope.get("data"); + + switch (eventType) { + case "ORDER_PLACED" -> handleOrderPlaced(data); + case "PAYMENT_COMPLETED" -> handlePaymentCompleted(data); + default -> log.warn("알 수 없는 이벤트 타입: {}", eventType); + } + + eventHandledRepository.save(new EventHandled(eventId, eventType)); + } + + private void handleOrderPlaced(JsonNode data) { + Long orderId = data.get("orderId").asLong(); + Long userId = data.get("userId").asLong(); + Long totalAmount = data.get("totalAmountValue").asLong(); + log.info("주문 이벤트 수신: orderId={}, userId={}, totalAmount={}", orderId, userId, totalAmount); + } + + private void handlePaymentCompleted(JsonNode data) { + Long paymentId = data.get("paymentId").asLong(); + Long orderId = data.get("orderId").asLong(); + boolean success = data.get("success").asBoolean(); + log.info("결제 완료 이벤트 수신: paymentId={}, orderId={}, success={}", paymentId, orderId, success); + } + + private String getHeader(ConsumerRecord record, String key) { + var header = record.headers().lastHeader(key); + return header != null ? new String(header.value(), StandardCharsets.UTF_8) : null; + } + + private JsonNode parsePayload(String payload) { + try { + return objectMapper.readTree(payload); + } catch (Exception e) { + log.error("payload 파싱 실패: {}", e.getMessage()); + return null; + } + } + + private void sendToDlq(ConsumerRecord record, Exception exception) { + try { + ProducerRecord producerRecord = new ProducerRecord<>( + DLT_TOPIC, null, record.key(), record.value() + ); + record.headers().forEach(h -> producerRecord.headers().add(h)); + producerRecord.headers().add(new RecordHeader("X-Error-Message", + exception.getMessage().getBytes(StandardCharsets.UTF_8))); + producerRecord.headers().add(new RecordHeader("X-Original-Topic", + record.topic().getBytes(StandardCharsets.UTF_8))); + kafkaTemplate.send(producerRecord); + log.info("DLQ 전송 완료: topic={}, offset={}", DLT_TOPIC, record.offset()); + } catch (Exception e) { + log.error("DLQ 전송 실패: offset={}, error={}", record.offset(), e.getMessage()); + } + } +} diff --git a/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java b/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java new file mode 100644 index 000000000..b636743f2 --- /dev/null +++ b/apps/commerce-streamer/src/test/java/com/loopers/domain/metrics/ProductMetricsTest.java @@ -0,0 +1,80 @@ +package com.loopers.domain.metrics; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +class ProductMetricsTest { + + @DisplayName("ProductMetrics 생성 시 모든 카운트가 0이다") + @Test + void createsWithZeroCounts() { + // given & when + ProductMetrics metrics = new ProductMetrics(1L); + + // then + assertAll( + () -> assertThat(metrics.getProductId()).isEqualTo(1L), + () -> assertThat(metrics.getLikeCount()).isZero(), + () -> assertThat(metrics.getViewCount()).isZero(), + () -> assertThat(metrics.getSaleCount()).isZero() + ); + } + + @DisplayName("좋아요 증가/감소가 정상 동작한다") + @Test + void likeCountIncrementAndDecrement() { + // given + ProductMetrics metrics = new ProductMetrics(1L); + + // when + metrics.incrementLikeCount(); + metrics.incrementLikeCount(); + metrics.decrementLikeCount(); + + // then + assertThat(metrics.getLikeCount()).isEqualTo(1); + } + + @DisplayName("좋아요 카운트가 0 이하로 내려가지 않는다") + @Test + void likeCountFloorAtZero() { + // given + ProductMetrics metrics = new ProductMetrics(1L); + + // when + metrics.decrementLikeCount(); + + // then + assertThat(metrics.getLikeCount()).isZero(); + } + + @DisplayName("조회수 증가가 정상 동작한다") + @Test + void viewCountIncrement() { + // given + ProductMetrics metrics = new ProductMetrics(1L); + + // when + metrics.incrementViewCount(); + metrics.incrementViewCount(); + + // then + assertThat(metrics.getViewCount()).isEqualTo(2); + } + + @DisplayName("판매 수량 증가가 정상 동작한다") + @Test + void saleCountIncrement() { + // given + ProductMetrics metrics = new ProductMetrics(1L); + + // when + metrics.incrementSaleCount(3); + + // then + assertThat(metrics.getSaleCount()).isEqualTo(3); + } +} diff --git a/docs/blog/index-tuning-blog-draft.md b/docs/blog/index-tuning-blog-draft.md new file mode 100644 index 000000000..209adface --- /dev/null +++ b/docs/blog/index-tuning-blog-draft.md @@ -0,0 +1,791 @@ +# 인덱스를 걸었는데 왜 안 빨라졌을까 + +**TL;DR** +ERP 대장 관리 화면의 조회가 느렸다. "인덱스를 걸면 빨라지겠지"라고 생각하고 fiscal_year에 인덱스를 걸었다. 안 빨라졌다. 메인 테이블이 아니라 **LEFT JOIN으로 붙는 서브쿼리 4개**가 매번 전체 테이블을 스캔하고 있었기 때문이다. 결국 "어디에 인덱스를 거는가"보다 **"쿼리가 실제로 어떻게 실행되는가"**를 먼저 이해해야 했고, EXPLAIN을 읽는 법을 배운 뒤에야 진짜 병목을 찾을 수 있었다. + +--- + +## 1. 처음 든 생각은 단순했다 + +사내 ERP 시스템에 **대장 관리** 화면이 있다. 계약 건별 마스터 정보를 조회하는 화면인데, 검색 조건을 입력하고 조회 버튼을 누르면 체감상 3~5초는 걸렸다. + +데이터는 약 5만 건. 뭐 엄청 많은 건 아니다. + +처음 든 생각은 이거였다. + +> "WHERE에 쓰이는 컬럼에 인덱스를 걸면 빨라지겠지." + +근데 이 판단이 틀렸다. + + + +--- + +## 2. 내가 뭘 모르는지 점검해봤다 + +틀렸다는 건 안다. 근데 **왜** 틀렸는지를 설명하지 못하겠다. + +일단 내 인식 상태를 점검해봤다. + +``` +현재 문제: 인덱스를 걸었는데 안 빨라졌다. +내가 한 것: WHERE절의 필수 컬럼(fiscal_year)에 인덱스를 걸었다. +내가 아는 것: "조회 조건에 쓰이는 컬럼에 인덱스를 걸면 빠르다." +내가 모르는 것: 왜 안 빨라졌는지. +``` + +여기서 멈췄다. "인덱스를 걸면 빠르다"는 건 알고 있었다. 근데 **빨라지지 않는 경우가 있다**는 건 생각해본 적이 없었다. 아는 것만으로는 이 상황을 설명할 수 없었다. + +그러면 내가 모르는 건 뭘까? + +> "이 쿼리가 실제로 어떤 순서로 실행되는지"를 모른다. + +나는 쿼리를 **텍스트**로만 읽고 있었다. WHERE절에 뭐가 있는지, JOIN이 몇 개인지. 하지만 MySQL이 이 쿼리를 **어떤 순서로, 어떤 방식으로 실행하는지**는 한 번도 확인하지 않았다. + +--- + +## 3. 쿼리를 처음부터 다시 읽었다 + +WHERE절만 볼 게 아니라 쿼리 전체를 읽었다. 그제서야 구조가 보였다. + +``` +contract_ledger (메인) + ├─ LEFT JOIN ① : 거래처 건수 (COUNT + GROUP BY 서브쿼리) + ├─ LEFT JOIN ② : 1순번 거래처 상세 (INNER JOIN 포함) + ├─ LEFT JOIN ③ : 대표 거래처 상세 (INNER JOIN 포함) + └─ LEFT JOIN ④ : 하자보증 종료일 집계 (GROUP BY 서브쿼리) +``` + +4개의 LEFT JOIN. 그 중 2개는 서브쿼리다. + +왜 이렇게 복잡할까? 화면 요구사항 때문이다. + +- 목록에 **거래처명**이 보여야 한다 → JOIN ②③ +- 거래처가 **몇 개인지** 보여야 한다 → JOIN ① +- **하자보증 만료일**이 보여야 한다 → JOIN ④ + +한 화면에 보여줄 정보가 많으니, 쿼리도 그만큼 복잡해진 것이다. 그리고 이 JOIN들에는 **인덱스가 하나도 없었다.** + +WHERE절의 fiscal_year에 인덱스를 걸어서 메인 테이블을 빠르게 찾아도, **JOIN으로 붙는 테이블 4개가 전부 Full Scan이면** 전체 쿼리는 느릴 수밖에 없다. + +근데 이건 쿼리를 **텍스트로 읽어서** 알게 된 거지, **실제 실행 계획을 확인**한 건 아니다. "아마 여기가 문제일 거야"라는 건 추측이다. 추측으로 인덱스를 건 게 처음의 실수였으니, 이번에는 추측하지 않기로 했다. + + + +--- + +## 4. EXPLAIN — 추측 대신 실행 계획을 읽었다 + +### EXPLAIN이 뭔가 + +`EXPLAIN`은 MySQL에게 "이 쿼리를 어떻게 실행할 건지 알려달라"고 요청하는 명령이다. 쿼리를 실제로 실행하지 않고, **실행 계획**만 보여준다. + +```sql +EXPLAIN +SELECT ... +FROM contract_ledger LEG +LEFT JOIN (...) ... +WHERE fiscal_year = '2026'; +``` + +이렇게 쿼리 앞에 `EXPLAIN`만 붙이면 된다. 결과는 테이블 형태로 나온다. + +### 처음 돌려본 결과 + + + +``` +┌──────────────────┬───────┬────────┬───────────────────────┬──────────┐ +│ table │ type │ rows │ extra │ key │ +├──────────────────┼───────┼────────┼───────────────────────┼──────────┤ +│ contract_ledger │ ALL │ 50,000 │ Using where │ NULL │ +│ (서브쿼리 ①) │ ALL │ 80,000 │ Using temporary │ NULL │ +│ (서브쿼리 ②) │ ALL │ 80,000 │ Using where │ NULL │ +│ (서브쿼리 ④) │ ALL │ 10,000 │ Using temporary │ NULL │ +└──────────────────┴───────┴────────┴───────────────────────┴──────────┘ +``` + +전부 `type = ALL`. 전부 `key = NULL`. + +근데 솔직히 이걸 처음 봤을 때, **뭘 봐야 하는지 몰랐다.** 컬럼이 여러 개 있는데, 어떤 게 중요한 건지 감이 안 잡혔다. + +멘토링에서 들은 말이 기준이 됐다. + +> "EXPLAIN에서 봐야 할 건 세 가지다. **rows** — 몇 건을 스캔했는지, **type** — 어떻게 접근했는지, **extra** — Using filesort나 Using temporary가 있으면 그게 병목이다." + +이 세 가지를 기준으로 다시 읽어봤다. + +### EXPLAIN 결과, 이렇게 읽는다 + +EXPLAIN을 읽을 줄 모르면 결과가 나와도 해석이 안 된다. 나도 그랬다. 각 컬럼이 무엇을 말하는지 정리해본다. + +#### type — "이 테이블에 어떻게 접근했는가" + +이게 가장 중요하다. 위에서 아래로 갈수록 느리다. + +| type | 의미 | 비유 | +|------|------|------| +| **const** | PK 또는 UNIQUE로 정확히 1건 조회 | 주민번호로 본인 찾기 | +| **eq_ref** | JOIN에서 PK/UNIQUE로 1건씩 매칭 | 출석부에서 이름표로 1:1 매칭 | +| **ref** | 인덱스로 여러 건 조회 | 목차에서 "3장" 찾기 → 여러 페이지 | +| **range** | 인덱스 범위 스캔 (BETWEEN, >, <) | 목차에서 "3장~5장" 범위로 찾기 | +| **index** | 인덱스 전체 스캔 (데이터보단 작음) | 목차를 처음부터 끝까지 읽기 | +| **ALL** | 테이블 전체 스캔 ❌ | 책을 1페이지부터 끝까지 넘기기 | + +여기서 한 가지 의문이 들 수 있다. **ref와 range가 뭐가 다른 건가?** + +```sql +-- ref: Equal(=) 연산. 인덱스에서 정확한 지점을 찾아 여러 건 조회 +WHERE fiscal_year = '2026' +→ 인덱스에서 '2026' 위치를 바로 찾음. 거기서 연속된 행들을 읽음. + +-- range: 범위 연산. 인덱스에서 시작점~끝점 사이를 스캔 +WHERE contract_date >= '2026-01-01' AND contract_date <= '2026-12-31' +→ 인덱스에서 '2026-01-01' 위치를 찾고, '2026-12-31'까지 순차 스캔. +``` + +ref는 "정확한 지점"을 찾는 거고, range는 "범위를 훑는" 거다. 복합 인덱스에서 Equal 컬럼을 앞에, Range 컬럼을 뒤에 배치해야 하는 이유가 여기에 있다. 뒤에서 다시 다룬다. + +**내 쿼리는 전부 ALL이었다.** 4개 테이블 모두 책을 처음부터 끝까지 넘기고 있었다. + +#### rows — "몇 건을 읽어야 하는가" + +MySQL이 **예상하는 스캔 대상 행 수**다. 핵심은 이것이 **결과 행 수가 아니라는 점**이다. + +``` +rows = 80,000이면? +→ 결과가 3건이어도, 그 3건을 찾기 위해 8만 건을 훑어본다는 뜻 +→ 이게 JOIN마다 발생하면, 50,000 × 80,000 = 40억 번의 비교가 될 수 있다 +``` + +내 쿼리에서 서브쿼리 rows가 80,000이었다. 결과는 기껏 1~3건인데, 그걸 찾으려고 매번 전체를 스캔하고 있었다. + +#### key — "실제로 사용한 인덱스" + +| 컬럼 | 의미 | +|------|------| +| **possible_keys** | 사용 가능한 인덱스 후보 목록 | +| **key** | 옵티마이저가 실제로 선택한 인덱스 | + +`key = NULL`이면 인덱스를 안 쓴 것이다. **내 쿼리는 전부 NULL이었다.** + +가끔 possible_keys에는 있는데 key가 NULL인 경우가 있다. 이건 옵티마이저가 **"인덱스 안 쓰는 게 더 빠르다"**고 판단한 것이다. 테이블이 작거나, 인덱스의 선택도(selectivity)가 낮을 때 발생한다. + +#### extra — "추가로 벌어지는 일" + +여기가 **병목의 증거**가 나오는 곳이다. + +| extra | 의미 | 위험도 | +|-------|------|--------| +| **Using where** | WHERE 조건으로 필터링 중 | 보통 (정상 동작) | +| **Using index** | 커버링 인덱스 사용 ✅ | 좋음 (디스크 I/O 없음) | +| **Using temporary** | 임시 테이블 생성 ❌ | 나쁨 | +| **Using filesort** | 별도 정렬 수행 ❌ | 나쁨 | +| **Using index condition** | ICP(Index Condition Pushdown) | 보통 | + +**Using temporary**는 MySQL이 GROUP BY나 DISTINCT를 처리하기 위해 **임시 테이블을 메모리에 만드는 것**이다. 데이터가 크면 디스크에 쓰기도 한다. + +**Using filesort**는 ORDER BY를 인덱스로 처리하지 못해서 **별도의 정렬 작업**을 수행하는 것이다. + +> Using temporary + Using filesort가 동시에 나오면 최악이다. 임시 테이블을 만들고, 그 안에서 다시 정렬까지 하는 것이니까. + +### 그래서 병목은 어디였나 + +다시 내 EXPLAIN 결과를 봤다. + +``` +서브쿼리 ① : type=ALL, rows=80,000, extra=Using temporary +서브쿼리 ④ : type=ALL, rows=10,000, extra=Using temporary +``` + +**Using temporary가 2개.** 서브쿼리가 GROUP BY를 처리하기 위해 매번 임시 테이블을 만들고 있었다. + +메인 테이블에 인덱스를 걸어서 500건으로 줄여봤자, **서브쿼리가 매번 8만 건을 전부 훑으면서 임시 테이블을 만들고 있으면** 전체 쿼리는 느릴 수밖에 없다. + +처음에 나는 "WHERE절이 느린 거다"라고 생각했다. EXPLAIN을 보니 **병목은 WHERE절이 아니라 JOIN이었다.** + +추측과 실제가 달랐다. EXPLAIN을 안 돌렸으면 계속 WHERE절만 잡고 있었을 것이다. + + + +--- + +## 5. 틀린 판단 2: "인덱스를 많이 걸면 위험하다"고 아꼈다 + +병목을 찾고 나서도 바로 인덱스를 추가하지 못했다. 이런 생각이 있었기 때문이다. + +> "인덱스를 많이 걸면 INSERT/UPDATE가 느려진다고 했는데, 4개나 추가해도 괜찮을까?" + +이것도 틀린 판단이었다. 정확히 말하면, **맞는 말이지만 이 상황에는 적용되지 않는 말**이었다. + +### 왜 이 상황에는 적용되지 않는가 + +인덱스를 추가하면 쓰기가 느려지는 건 사실이다. INSERT나 UPDATE가 발생할 때마다 해당 인덱스도 함께 갱신해야 하니까. 근데 이 원칙이 적용되려면 **전제 조건**이 있다. + +``` +"인덱스가 많으면 쓰기가 느려진다"가 문제가 되려면: +→ 쓰기가 빈번해야 한다. + +이 테이블의 실상: +→ 등록은 월 수십 건, 수정은 거의 없음 +→ 조회는 하루 수십~수백 번 +``` + +읽기와 쓰기의 비율이 **100:1 이상**이다. 이런 테이블에서 인덱스 4개를 아끼는 건, 멘토링에서 들은 말을 빌리면: + +> "인덱스 개수 자체는 무의미하다. 조회에 쓰이는 컬럼은 무조건 인덱스를 걸어라. 쓰기 부담 정리는 이후 최적화 단계다." + +**"인덱스가 많으면 위험하다"는 일반론이 이 테이블에 적용되지 않는 이유를 한 줄로 정리하면:** 이 테이블은 읽기가 압도적이다. 읽기 편향 테이블에서 인덱스를 아끼는 건 잘못된 절약이다. + + + +--- + +## 6. 인덱스를 어디에, 왜 이 순서로 걸었는가 + +### 질문: 동적 WHERE에 "하나의 완벽한 인덱스"가 가능한가? + +이 쿼리의 WHERE절은 MyBatis `` 태그로 **10개 이상의 동적 조건**이 있다. 사용자가 어떤 조건을 입력하느냐에 따라 실제 실행되는 쿼리가 달라진다. + +"모든 조합을 커버하는 인덱스"는 불가능하다. 그래서 **실제 사용 빈도**를 기준으로 잡았다. + +``` +[패턴 A] ★★★ 가장 빈번: fiscal_year만으로 전체 조회 +[패턴 B] ★★ 빈번: fiscal_year + contract_type (Equal) +[패턴 C] ★★ 빈번: fiscal_year + contract_date (Range) +[패턴 D] ★ 가끔: fiscal_year + contract_name (LIKE) +[패턴 E] ★ 가끔: fiscal_year + manage_dept (Equal) +``` + + + +### 복합 인덱스의 컬럼 순서 — 왜 이 순서여야 하는가 + +패턴 A, B, C를 하나의 복합 인덱스로 커버하려면: + +```sql +CREATE INDEX idx_ledger_main +ON contract_ledger(fiscal_year, contract_type, contract_date); +``` + +왜 `(fiscal_year, contract_type, contract_date)` 이 순서인가? + +**원칙 1: 필수조건을 맨 앞에 (Leftmost Prefix)** + +복합 인덱스는 **왼쪽부터 순서대로** 작동한다. 맨 앞 컬럼이 없으면 인덱스 자체를 탈 수 없다. + +``` +전화번호부가 (성, 이름, 전화번호) 순으로 정렬되어 있다고 하자. + +✅ "김" 씨를 찾아주세요 → 바로 찾음 +✅ "김" 씨 중 "민수"를 찾아주세요 → 바로 찾음 +❌ "민수"를 찾아주세요 (성 모름) → 처음부터 끝까지 봐야 함 +``` + +`fiscal_year`는 **모든 조회에 항상 포함**되는 필수조건이다. 그러니 맨 앞에 둔다. + +**원칙 2: Equal 조건을 Range 조건보다 앞에** + +복합 인덱스 `(A, B, C)`에서 B가 Range 연산이면, **C는 인덱스를 탈 수 없다.** + +왜 그런가? 인덱스는 정렬된 순서로 저장된다. B에서 범위로 흩어지면, 그 안에서 C의 순서가 보장되지 않기 때문이다. + +``` +인덱스: (fiscal_year, contract_type, contract_date) + +✅ fiscal_year = '2026' AND contract_type = 'A' AND contract_date >= '2026-01-01' + → fiscal_year(Equal) → contract_type(Equal) → contract_date(Range) + → 3개 컬럼 모두 인덱스 활용 + +만약 순서가 (fiscal_year, contract_date, contract_type)이었다면? +❌ fiscal_year = '2026' AND contract_date >= '2026-01-01' AND contract_type = 'A' + → fiscal_year(Equal) → contract_date(Range) → contract_type(Equal) + → contract_date에서 범위로 흩어지므로, contract_type은 인덱스 미활용 +``` + +그래서 Equal인 `contract_type`을 두 번째에, Range인 `contract_date`를 맨 뒤에 배치했다. + +**그런데 한 가지 의문.** + +> "인덱스 1에 manage_dept를 추가하면 안 되나? (fiscal_year, contract_type, contract_date, manage_dept)로?" + +할 수 있다. 하지만 contract_date가 Range 연산이면 **그 뒤의 manage_dept는 인덱스를 타지 않는다.** Range 이후의 컬럼은 인덱스가 중단되기 때문이다. 그래서 별도 인덱스를 만들었다. + +```sql +CREATE INDEX idx_ledger_dept +ON contract_ledger(fiscal_year, manage_dept); +``` + + + +### 진짜 효과가 컸던 건 — JOIN 인덱스 + +메인 테이블 인덱스보다 **체감 효과가 훨씬 컸던 건** 서브쿼리 쪽이었다. + +```sql +CREATE INDEX idx_partner_lookup +ON contract_partner(contract_no, partner_seq); +``` + +LEFT JOIN ①②③이 전부 `contract_no`로 조인한다. **이 인덱스 하나로 3개의 JOIN이 개선됐다.** + +특히 JOIN ①의 서브쿼리를 자세히 보자: + +```sql +SELECT COUNT(partner_seq), contract_no +FROM contract_partner +GROUP BY contract_no +``` + +이 서브쿼리가 `(contract_no, partner_seq)` 인덱스를 타면 **커버링 인덱스**가 된다. + +커버링 인덱스란, **쿼리가 필요한 모든 컬럼이 인덱스에 포함되어 있는 상태**를 말한다. 이 경우 MySQL은 디스크의 실제 데이터 페이지를 읽을 필요 없이, **인덱스 페이지만 읽어서 결과를 반환**할 수 있다. + +``` +왜 커버링 인덱스가 되는가? + +SELECT에 사용된 컬럼: contract_no, partner_seq +인덱스에 포함된 컬럼: contract_no, partner_seq + +→ SELECT가 필요로 하는 모든 컬럼이 인덱스에 포함 ✅ +→ 디스크 I/O 없이 인덱스 페이지만 읽음 +→ EXPLAIN extra에 "Using index"로 확인 가능 +``` + +커버링 인덱스가 되면 **Using temporary가 사라진다.** GROUP BY를 처리하기 위해 임시 테이블을 만들 필요가 없기 때문이다. 인덱스 자체가 `contract_no` 순으로 정렬되어 있으니, GROUP BY도 인덱스 순서를 그대로 따라가면 된다. + +하자관리 테이블도 같은 논리로: + +```sql +CREATE INDEX idx_defect_lookup +ON contract_defect(contract_no, defect_warranty_end); +``` + + + +--- + +## 7. 인덱스로 해결할 수 없는 것들 + +여기까지 오면 "인덱스를 잘 걸면 다 빨라진다"고 생각할 수 있다. 근데 **그렇지 않은 경우**가 있다. 이걸 모르면 인덱스가 안 먹히는 조건에서 헛삽질을 하게 된다. + +### LIKE '%keyword%' — 왜 인덱스를 못 타는가 + +```sql +WHERE contract_name LIKE '%공사%' +``` + +이건 인덱스를 **절대** 타지 않는다. + +왜? + +인덱스는 정렬된 순서로 데이터를 저장한다. `LIKE '공사%'`는 "공사"로 시작하는 지점을 바로 찾을 수 있다. B-Tree 인덱스에서 "공사"라는 시작점이 명확하니까. + +하지만 `LIKE '%공사%'`는 **시작점이 없다.** "공사"가 어디에 있을지 알 수 없으니 처음부터 끝까지 다 봐야 한다. 전화번호부에서 "이름에 '민'이 들어간 사람"을 찾으려면 1페이지부터 끝까지 넘겨야 하는 것과 같다. + +**그래서 어떻게 했는가?** + +포기했다. 정확히 말하면, LIKE 자체는 그대로 두고 **다른 인덱스가 rows를 충분히 줄여주는 구조**로 만들었다. + +``` +Before: 50,000건에 LIKE Full Scan → 느림 +After: 500건 (fiscal_year 인덱스로 축소) + LIKE 필터 → 무시할 수준 +``` + +5만 건에서 LIKE를 거는 것과 500건에서 LIKE를 거는 건 차원이 다르다. **인덱스로 해결할 수 없는 것을 인정하고, 다른 인덱스가 먼저 rows를 줄여주는 구조를 만드는 것**이 현실적인 대응이었다. + +> "모든 걸 인덱스로 해결하려 하면 안 된다"는 건, 인덱스 설계에서 가장 중요한 인식 중 하나인 것 같다. + + + +### DATE 함수 — 컬럼에 함수를 씌우면 인덱스가 죽는다 + +```sql +-- AS-IS ❌ +WHERE CURDATE() >= DATE_SUB(DATE_ADD(completion_date, INTERVAL 1 YEAR), INTERVAL 15 DAY) +``` + +`completion_date`에 인덱스가 있어도, **컬럼에 함수를 씌우면 인덱스를 탈 수 없다.** + +왜? DB 입장에서 생각해보자. 인덱스에는 `completion_date` 원본 값이 정렬되어 있다. 근데 `DATE_ADD(completion_date, ...)`의 결과가 뭔지는 **모든 행에 대해 함수를 실행해봐야** 안다. 인덱스의 정렬 순서와 함수 적용 후의 순서가 같다는 보장이 없으니, 인덱스를 쓸 수 없는 것이다. + +해결은 간단하다. **함수를 컬럼이 아닌 상수 쪽으로 옮긴다.** + +```sql +-- TO-BE ✅ +WHERE completion_date <= DATE_ADD(DATE_SUB(CURDATE(), INTERVAL 1 YEAR), INTERVAL 15 DAY) +``` + +같은 논리인데 `completion_date`에 직접 비교하도록 변환했다. 이제 인덱스를 탈 수 있다. `CURDATE()`와 `DATE_ADD`는 **상수로 한 번만 계산**되기 때문이다. + +> WHERE절에서 컬럼은 "벌거벗은 상태"여야 한다. 함수, 연산, 형변환을 씌우는 순간 인덱스는 무력화된다. + +--- + +## 8. 적용 결과 + +### EXPLAIN 비교 + + + +``` +■ Before +┌──────────────────┬───────┬────────┬───────────────────────┬──────────┐ +│ table │ type │ rows │ extra │ key │ +├──────────────────┼───────┼────────┼───────────────────────┼──────────┤ +│ contract_ledger │ ALL │ 50,000 │ Using where │ NULL │ +│ contract_partner │ ALL │ 80,000 │ Using temporary │ NULL │ +│ contract_defect │ ALL │ 10,000 │ Using temporary │ NULL │ +└──────────────────┴───────┴────────┴───────────────────────┴──────────┘ + +■ After +┌──────────────────┬───────┬───────┬───────────────────────┬────────────────────┐ +│ table │ type │ rows │ extra │ key │ +├──────────────────┼───────┼───────┼───────────────────────┼────────────────────┤ +│ contract_ledger │ ref │ 500 │ Using where │ idx_ledger_main │ +│ contract_partner │ ref │ 3 │ Using index │ idx_partner_lookup │ +│ contract_defect │ ref │ 2 │ Using index │ idx_defect_lookup │ +└──────────────────┴───────┴───────┴───────────────────────┴────────────────────┘ +``` + +| 지표 | Before | After | 변화 | +|------|--------|-------|------| +| 메인 테이블 type | ALL | ref | Full Scan → Index Scan | +| 메인 테이블 rows | 50,000 | 500 | **100배 감소** | +| 서브쿼리 type | ALL | ref | Full Scan → Index Lookup | +| 서브쿼리 rows | 80,000 | 3 | **26,000배 감소** | +| extra | Using temporary | Using index | 임시 테이블 → 커버링 인덱스 | + +숫자만 봐도 차이가 크지만, 핵심은 **Using temporary → Using index**다. 서브쿼리가 매번 8만 건을 GROUP BY하면서 임시 테이블을 만들던 게, 인덱스만으로 3건을 찾는 것으로 바뀌었다. 임시 테이블 생성이라는 **무거운 연산 자체가 사라진 것**이다. + +### 응답시간 + + + +``` +TODO: 실제 측정값으로 교체 + +Before: 약 ?초 +After: 약 ?초 +개선율: ?% +``` + + + +--- + +## 9. 돌아보면 + +### 내가 빠졌던 함정 + +이번 과정에서 내가 빠졌던 함정은 세 가지다. + +**함정 1: WHERE절만 보고 인덱스를 설계했다.** + +쿼리 튜닝이라고 하면 반사적으로 WHERE절을 본다. 틀린 건 아니다. 하지만 이 쿼리의 병목은 WHERE가 아니라 **FROM절의 서브쿼리 JOIN**이었다. + +"어디에 인덱스를 거는가"를 묻기 전에, **"이 쿼리가 실제로 어떻게 실행되는가"**를 먼저 물어야 했다. + +**함정 2: "인덱스가 많으면 위험하다"는 일반론에 매몰됐다.** + +맞는 말이다. 하지만 **이 테이블에는 해당되지 않는 말**이었다. 일반론을 적용하려면 전제 조건(쓰기가 빈번한가?)을 먼저 확인해야 했다. + +인덱스 개수가 아니라, **이 테이블의 읽기/쓰기 비율**이 판단 기준이다. + +**함정 3: EXPLAIN을 안 돌리고 추측했다.** + +"여기가 느릴 것 같다"로 시작하면 틀린다. EXPLAIN을 돌리기 전의 내 추측과 실제 결과는 달랐다. 메인 테이블이 아니라 서브쿼리가 범인이었고, Using temporary라는 경고를 눈으로 확인하고 나서야 확신이 생겼다. + +추측하지 마라. EXPLAIN을 돌려라. **데이터가 답이다.** + + + +### 이 경험에서 발견한 기준 + +``` +1. 쿼리를 전부 읽어라 + — WHERE절만이 아니라 FROM, JOIN, 서브쿼리까지. + +2. EXPLAIN을 돌려라 + — 추측이 아니라 rows, type, extra로 판단하라. + +3. 쿼리 패턴을 분류하라 + — 동적 WHERE의 실제 사용 빈도가 인덱스 우선순위다. + +4. Range는 맨 뒤에 + — 복합 인덱스에서 범위 연산 이후 컬럼은 인덱스를 못 탄다. + +5. 포기할 건 포기하라 + — LIKE '%keyword%', DATE 함수는 인덱스로 해결 불가. + 다른 인덱스가 rows를 줄여주는 구조로 대응하라. +``` + +처음에는 "인덱스를 걸면 빨라진다"고 단순하게 생각했다. 틀렸다. + +> "어떤 인덱스를 거는가"보다 **"이 쿼리가 어떻게 실행되는가"**를 먼저 이해하는 게 시작이다. + +--- + +## 10. 결국 비정규화를 했다 + +인덱스로 서브쿼리의 병목을 제거했다. Using temporary가 사라졌고, rows도 극적으로 줄었다. 하지만 쿼리를 다시 보니 **근본적인 질문**이 남았다. + +> "인덱스를 아무리 잘 걸어도, JOIN 4개를 매번 타는 구조 자체가 문제 아닌가?" + +### 인덱스만으로는 부족했던 이유 + +인덱스가 JOIN의 속도를 빠르게 만들어준 건 맞다. 하지만 **JOIN 자체가 없으면 더 빠르다.** 당연한 말인데, 이걸 실감한 건 인덱스를 걸고 나서였다. + +LEFT JOIN ②③이 하는 일을 다시 보자: + +``` +LEFT JOIN ② : 1순번 거래처의 거래처명 → 화면에 "거래처명" 표시 +LEFT JOIN ③ : 대표 거래처의 거래처명 → 화면에 "대표거래처명" 표시 +``` + +이 JOIN들은 **contract_partner → partner_master**를 타고 가서 거래처명을 가져온다. 매 조회마다. 5만 건 각각에 대해. + +"이걸 메인 테이블에 넣어두면 JOIN을 안 해도 되지 않나?" + +처음엔 이 생각을 보류했다. 비정규화는 **정규화의 원칙을 깨는 것**이니까. 거래처명이 바뀌면 동기화해야 하고, 데이터 정합성 관리 포인트가 늘어난다. "지금 안 아프면 안 바꾼다"는 판단이었다. + +근데 돌아보니, **지금 아프다.** 인덱스를 걸어도 JOIN 2개는 여전히 실행되고 있었다. 그리고 이 테이블의 거래처 정보는 **한번 등록되면 거의 변경되지 않는다.** 변경 빈도가 극히 낮은 데이터를 매번 JOIN으로 가져오는 건, 비용 대비 이득이 맞지 않았다. + +### 비정규화 판단 기준 + +비정규화를 결정하기 전에 세 가지를 확인했다. + +``` +1. 이 데이터가 얼마나 자주 변경되는가? + → 거래처명 변경: 연 수건 이하. 거의 없다. + +2. 변경 시 동기화를 놓치면 어떤 일이 벌어지는가? + → 목록에 옛 거래처명이 표시됨. 치명적이진 않다. + → 상세 화면에서는 원본 테이블을 조회하므로 정합성 확인 가능. + +3. JOIN을 제거했을 때 얼마나 빨라지는가? + → LEFT JOIN 2개 + INNER JOIN 2개 제거 → 쿼리 구조 자체가 단순해짐. +``` + +세 가지 모두 비정규화에 유리했다. **변경은 드물고, 변경 시 리스크는 낮고, 제거 효과는 크다.** 보류할 이유가 없었다. + +### 마이그레이션 + +비정규화를 "하겠다"와 "했다"는 다르다. 운영 중인 테이블의 구조를 바꾸는 건 신중해야 한다. + +**Step 1. 컬럼 추가** + +```sql +ALTER TABLE contract_ledger + ADD COLUMN partner_name VARCHAR(100) COMMENT '거래처명 (비정규화)', + ADD COLUMN partner_count INT DEFAULT 0 COMMENT '거래처 수 (비정규화)'; +``` + +기존 데이터에 영향을 주지 않도록 **컬럼 추가만** 먼저 수행했다. 이 시점에서 새 컬럼은 전부 NULL이다. 기존 쿼리는 이 컬럼을 참조하지 않으므로 서비스에 영향 없다. + +**Step 2. 기존 데이터 마이그레이션** + +```sql +-- 거래처명: 1순번 거래처의 이름을 메인 테이블로 복사 +UPDATE contract_ledger LEG +INNER JOIN contract_partner CLT + ON LEG.contract_no = CLT.contract_no + AND CLT.partner_seq = 1 +INNER JOIN partner_master MST + ON CLT.partner_code = MST.partner_code +SET LEG.partner_name = MST.partner_name +WHERE LEG.partner_name IS NULL; + +-- 거래처 수: GROUP BY로 집계한 값을 메인 테이블로 복사 +UPDATE contract_ledger LEG +INNER JOIN ( + SELECT contract_no, COUNT(*) AS cnt + FROM contract_partner + GROUP BY contract_no +) CNT ON LEG.contract_no = CNT.contract_no +SET LEG.partner_count = CNT.cnt +WHERE LEG.partner_count = 0; +``` + +5만 건에 대해 UPDATE를 실행했다. 이 작업은 **한 번만 수행**되는 것이므로 소요 시간은 문제가 되지 않는다. + + + +**Step 3. 쿼리 수정** + +마이그레이션이 완료된 후, 조회 쿼리에서 JOIN ①②③을 제거하고 메인 테이블의 컬럼으로 대체했다. + +```sql +-- AS-IS: JOIN 4개 +SELECT LEG.*, + CLT_CNT.partner_count, + MST1.partner_name AS partner_name_1, + MST2.partner_name AS partner_name_rep, + ... +FROM contract_ledger LEG +LEFT JOIN (SELECT contract_no, COUNT(*) ... GROUP BY contract_no) CLT_CNT ... +LEFT JOIN contract_partner CLT1 ... INNER JOIN partner_master MST1 ... +LEFT JOIN contract_partner CLT2 ... INNER JOIN partner_master MST2 ... +LEFT JOIN contract_defect DEF ... +WHERE ... + +-- TO-BE: JOIN 1개 (하자관리만 남음) +SELECT LEG.*, + LEG.partner_count, + LEG.partner_name, + ... +FROM contract_ledger LEG +LEFT JOIN contract_defect DEF ... +WHERE ... +``` + +LEFT JOIN 3개가 사라졌다. **SELECT에서 직접 메인 테이블의 컬럼을 읽으면 되니까 JOIN이 필요 없다.** + +**Step 4. 동기화 처리** + +비정규화의 대가는 **동기화**다. 거래처 정보가 변경되면 메인 테이블도 함께 갱신해야 한다. + +```sql +-- 거래처 등록/수정/삭제 시 트리거 또는 서비스 로직에서 실행 +UPDATE contract_ledger +SET partner_name = #{newPartnerName}, + partner_count = ( + SELECT COUNT(*) FROM contract_partner + WHERE contract_no = #{contractNo} + ) +WHERE contract_no = #{contractNo}; +``` + +거래처 변경이 연 수건 이하이므로, 이 동기화 비용은 **매 조회마다 JOIN을 타는 비용**에 비하면 무시할 수 있다. + + + +### 비정규화 후 EXPLAIN + + + +``` +■ After Index (§8) +┌──────────────────┬───────┬───────┬───────────────────────┬────────────────────┐ +│ table │ type │ rows │ extra │ key │ +├──────────────────┼───────┼───────┼───────────────────────┼────────────────────┤ +│ contract_ledger │ ref │ 500 │ Using where │ idx_ledger_main │ +│ contract_partner │ ref │ 3 │ Using index │ idx_partner_lookup │ +│ contract_defect │ ref │ 2 │ Using index │ idx_defect_lookup │ +└──────────────────┴───────┴───────┴───────────────────────┴────────────────────┘ + +■ After Denormalization (§10) +┌──────────────────┬───────┬───────┬───────────────────────┬────────────────────┐ +│ table │ type │ rows │ extra │ key │ +├──────────────────┼───────┼───────┼───────────────────────┼────────────────────┤ +│ contract_ledger │ ref │ 500 │ Using where │ idx_ledger_main │ +│ contract_defect │ ref │ 2 │ Using index │ idx_defect_lookup │ +└──────────────────┴───────┴───────┴───────────────────────┴────────────────────┘ +``` + +contract_partner 행이 **통째로 사라졌다.** 인덱스가 "빠르게 찾는 것"이라면, 비정규화는 **"찾을 필요 자체를 없앤 것"**이다. + +인덱스 최적화에서 rows가 80,000 → 3으로 줄었을 때도 대단하다고 느꼈는데, **테이블 접근 자체가 사라지는 건 차원이 다른 개선**이었다. + +### 정리: 인덱스 vs 비정규화 + +``` +인덱스: "이 테이블에서 빠르게 찾아라" → rows를 줄인다 +비정규화: "이 테이블을 아예 보지 마라" → JOIN을 없앤다 +``` + +둘 다 읽기 성능을 개선하지만, 비용 구조가 다르다. + +| | 인덱스 | 비정규화 | +|---|---|---| +| **개선 방식** | 탐색 경로 최적화 | JOIN 제거 | +| **쓰기 비용** | INSERT/UPDATE 시 인덱스 갱신 | 원본 변경 시 동기화 필요 | +| **적용 조건** | 항상 가능 | 변경이 드문 데이터에 유리 | +| **되돌리기** | DROP INDEX | 컬럼 제거 + 쿼리 원복 (더 번거로움) | + +인덱스는 "안전한 개선"이고, 비정규화는 **"트레이드오프를 감수한 개선"**이다. 그래서 인덱스를 먼저 시도하고, 그것만으로 부족할 때 비정규화를 검토하는 순서가 맞았다. + +--- + +## 11. 아직 남은 것들 + +**5만 건을 한 번에 가져오는 것 자체가 문제일 수 있다.** 현재 이 화면은 페이지네이션 없이 전체 결과를 한 번에 로드한다. 커서 기반 페이징을 도입하면 인덱스 효율이 더 좋아진다. 하지만 ERP 특성상 사용자가 "전체 목록을 한눈에 보고 싶다"는 요구가 강하기 때문에, 이건 기술적 판단만으로 결정할 수 없는 영역이다. + +동시성 제어를 다뤘던 이전 글에서 "아프기 시작하면 바꾸는 거다"라고 정리했었는데, 인덱스도 비정규화도 마찬가지였다. **전환 시점은 트래픽 숫자가 아니라, 운영에서 관측되는 실패 모드가 기준이다.** 슬로우 쿼리 로그가 찍히기 시작하면, 그때 다음 단계를 밟으면 된다. + + + +--- + +> 이 글에서 사용된 테이블명, 컬럼명, 데이터는 보안을 위해 모두 치환되었습니다. diff --git a/docs/blog/index-tuning-research.md b/docs/blog/index-tuning-research.md new file mode 100644 index 000000000..219c1ec78 --- /dev/null +++ b/docs/blog/index-tuning-research.md @@ -0,0 +1,278 @@ +# 인덱스 튜닝 블로그 리서치 — 실무 조회 쿼리 개선기 + +> 원본: ALLSP_STD > ctrLeg101 (계약대장관리) 메인 조회 쿼리 +> 보안을 위해 테이블/컬럼명을 치환하여 블로그에 사용 + +--- + +## 1. 치환 매핑표 + +블로그 공개 시 아래 매핑으로 치환한다. **원본 이름은 절대 블로그에 노출하지 않는다.** + +### 테이블 치환 + +| 원본 테이블 | 치환 테이블 | 설명 | +|------------|-----------|------| +| CTR_CTR_LEDG | `contract_ledger` | 계약대장 마스터 | +| CTR_CTR_CLT | `contract_partner` | 계약 거래처 | +| ACC_CLT | `partner_master` | 거래처 기본정보 | +| CTR_CTR_FLAW | `contract_defect` | 하자관리 | +| CTR_CTR_CHG | `contract_change` | 변경이력 | +| CTR_DFRCMPST | `contract_penalty` | 지연배상금 | +| CTR_CTR_ATAC | `contract_seizure` | 압류현황 | +| CTR_CTR_SUBCNTR | `contract_subcontract` | 하도급 | +| SYS_DEPT / VW_USE_DEPT | `department` | 부서 | + +### 주요 컬럼 치환 + +| 원본 컬럼 | 치환 컬럼 | 설명 | +|----------|----------|------| +| CTR_LEDG_NO | `contract_no` | 계약대장번호 (PK) | +| ACC_YY | `fiscal_year` | 회계연도 | +| CTR_NM | `contract_name` | 계약명 | +| CTR_DAT | `contract_date` | 계약일자 | +| CTR_AMT | `contract_amount` | 계약금액 | +| CTR_KND | `contract_type` | 계약종류 코드 | +| CTR_FOM | `contract_form` | 계약형태 | +| COMPL_DAT | `completion_date` | 준공일자 | +| CLT_CD | `partner_code` | 거래처 코드 | +| CLT_NM | `partner_name` | 거래처명 | +| CLT_SEQ | `partner_seq` | 거래처 순번 | +| REP_CLT_YN | `is_primary` | 대표거래처 여부 | +| BZR_REGNO | `biz_reg_no` | 사업자등록번호 | +| CTR_MNG_DEPT | `manage_dept` | 관리부서 | +| CTR_REQTER_NM | `requester_name` | 요청자명 | +| PCUR_DIV | `procurement_div` | 조달구분 | +| PCUR_NO | `procurement_no` | 조달번호 | +| FLAW_GRNTY_ENDDD | `defect_warranty_end` | 하자보증 종료일 | + +--- + +## 2. 현재 쿼리 구조 분석 (AS-IS) + +### 2.1 메인 조회 쿼리 구조도 + +``` +contract_ledger (LEG) ← 메인 테이블 + ├─ LEFT JOIN ① 거래처 건수 서브쿼리 (COUNT + GROUP BY) + ├─ LEFT JOIN ② 1순번 거래처 정보 (partner_seq = 1) + │ └─ INNER JOIN partner_master (거래처 기본정보) + ├─ LEFT JOIN ③ 대표 거래처 정보 (is_primary = 'Y') + │ └─ INNER JOIN partner_master (거래처 기본정보) + └─ LEFT JOIN ④ 하자보증 종료일 집계 (GROUP BY) +``` + +### 2.2 동적 WHERE 조건 (MyBatis ``) + +```sql +WHERE fiscal_year = #{searchFiscalYear} -- ★ 필수조건 (항상) + [AND contract_date >= #{searchDateStart}] -- 선택: 계약일자 범위 + [AND contract_date <= #{searchDateEnd}] + [AND contract_no = #{searchContractNo}] -- 선택: 계약번호 정확매칭 + [AND contract_type = #{searchContractType}] -- 선택: 계약종류 + [AND contract_name LIKE '%keyword%'] -- 선택: 계약명 LIKE + [AND procurement_no = #{searchProcNo}] -- 선택: 조달번호 + [AND procurement_div = #{searchProcDiv}] -- 선택: 조달구분 + [AND requester_name LIKE '%keyword%'] -- 선택: 요청자 LIKE + [AND manage_dept = #{searchMngDept}] -- 선택: 관리부서 + [AND 하자보증만료 임박 조건] -- 선택: 날짜 계산 + [AND EXISTS (거래처명/사업자번호 검색 서브쿼리)] -- 선택: 거래처 검색 +``` + +### 2.3 핵심 병목 포인트 식별 + +| # | 병목 후보 | 이유 | +|---|----------|------| +| ① | **4개의 LEFT JOIN** (그 중 2개가 서브쿼리) | 서브쿼리가 전체 테이블을 GROUP BY하므로, contract_partner 데이터가 많을수록 비용 증가 | +| ② | **LIKE '%keyword%'** (양쪽 와일드카드) | 인덱스 사용 불가. Full Table Scan 유발 | +| ③ | **EXISTS 서브쿼리 (거래처 검색)** | 외부 쿼리 행마다 서브쿼리 실행. contract_partner 테이블 반복 스캔 | +| ④ | **DATE 함수 사용 (하자보증만료 조건)** | `DATE_SUB(DATE_ADD(...))` → 인덱스 미작동 | +| ⑤ | **필수조건이 fiscal_year 단 1개** | 파티셔닝이나 인덱스 없으면 해당 연도 전체 스캔 | + +--- + +## 3. 인덱스 설계 전략 (TO-BE) + +### 3.1 쿼리 패턴별 인덱스 후보 + +현재 쿼리의 WHERE 조건 조합을 빈도순으로 정리하면: + +``` +[패턴 A] 가장 빈번: fiscal_year만으로 조회 (전체 목록) +[패턴 B] 빈번: fiscal_year + contract_type +[패턴 C] 빈번: fiscal_year + contract_date 범위 +[패턴 D] 가끔: fiscal_year + contract_name LIKE +[패턴 E] 가끔: fiscal_year + manage_dept +[패턴 F] 드묾: fiscal_year + 거래처 EXISTS +``` + +### 3.2 인덱스 설계안 + +```sql +-- ■ 인덱스 1: 메인 테이블 기본 조회 (패턴 A, B, C 커버) +CREATE INDEX idx_ledger_year_type_date +ON contract_ledger(fiscal_year, contract_type, contract_date); + +-- ■ 인덱스 2: 관리부서 필터 (패턴 E) +CREATE INDEX idx_ledger_year_dept +ON contract_ledger(fiscal_year, manage_dept); + +-- ■ 인덱스 3: 거래처 서브쿼리 성능 개선 +CREATE INDEX idx_partner_contract_no +ON contract_partner(contract_no, partner_seq); +-- → LEFT JOIN ①②③ 모두 contract_no로 조인하므로 필수 + +-- ■ 인덱스 4: 하자관리 GROUP BY 최적화 +CREATE INDEX idx_defect_contract_no +ON contract_defect(contract_no, defect_warranty_end); +-- → LEFT JOIN ④ GROUP BY 최적화 +``` + +### 3.3 LIKE 검색 한계와 대안 + +``` +문제: contract_name LIKE '%keyword%' → 인덱스 불가 + +대안 1: 앞쪽 와일드카드 제거 (LIKE 'keyword%') → 비즈니스 요건상 어려움 +대안 2: Full-Text Index 적용 → MySQL 5.7+ 지원, 한글 형태소 분석 한계 +대안 3: 별도 검색 인덱스 테이블 (역인덱스) → 오버엔지니어링 가능성 +대안 4: 현실적 선택 — LIKE는 그대로 두고, 다른 조건으로 rows를 먼저 줄인 후 LIKE 필터 + +→ 블로그 포인트: "인덱스로 해결할 수 없는 것"을 인정하는 것도 설계의 일부 +``` + +### 3.4 DATE 함수 문제 해결 + +```sql +-- AS-IS (인덱스 미작동) +WHERE CURDATE() >= DATE_SUB(DATE_ADD(completion_date, INTERVAL 1 YEAR), INTERVAL 15 DAY) + +-- TO-BE (인덱스 작동 가능) +WHERE completion_date >= DATE_SUB(DATE_SUB(CURDATE(), INTERVAL 1 YEAR), INTERVAL -15 DAY) +-- → completion_date 컬럼에 직접 비교하도록 변환 +-- → completion_date에 인덱스가 있으면 Range Scan 가능 +``` + +--- + +## 4. EXPLAIN 분석 시나리오 (블로그용) + +### 4.1 Before: 인덱스 없는 상태 + +``` +예상 EXPLAIN 결과: +┌─────────┬────────┬──────┬───────────────┬──────────┐ +│ table │ type │ rows │ extra │ key │ +├─────────┼────────┼──────┼───────────────┼──────────┤ +│ LEG │ ALL │ 50K │ Using where │ NULL │ ← Full Table Scan +│ CCC │ ALL │ 80K │ Using temp │ NULL │ ← 서브쿼리 전체스캔 +│ CLT_INF │ ALL │ 80K │ Using where │ NULL │ ← 거래처 전체스캔 +│ CTF │ ALL │ 10K │ Using temp │ NULL │ ← 하자 전체스캔 +└─────────┴────────┴──────┴───────────────┴──────────┘ +``` + +### 4.2 After: 인덱스 적용 후 + +``` +예상 EXPLAIN 결과: +┌─────────┬────────┬──────┬───────────────┬──────────────────────────┐ +│ table │ type │ rows │ extra │ key │ +├─────────┼────────┼──────┼───────────────┼──────────────────────────┤ +│ LEG │ ref │ 500 │ Using where │ idx_ledger_year_type_date│ ← Index Scan +│ CCC │ ref │ 3 │ Using index │ idx_partner_contract_no │ ← Covering Index +│ CLT_INF │ ref │ 1 │ │ idx_partner_contract_no │ +│ CTF │ ref │ 2 │ Using index │ idx_defect_contract_no │ ← Covering Index +└─────────┴────────┴──────┴───────────────┴──────────────────────────┘ +``` + +--- + +## 5. 블로그 글 구성안 + +### 제목 후보 + +1. **"계약 5만 건을 조회하는데 8초 — 인덱스 하나로 0.3초가 된 이야기"** +2. **"EXPLAIN 하나면 충분하다 — 실무 조회 쿼리 튜닝 일지"** +3. **"인덱스를 걸기 전에 쿼리를 먼저 읽어라"** + +### 목차 구성 + +``` +1. 들어가며: 왜 이 쿼리를 튜닝해야 했는가 + - 상황 설명: N개의 JOIN + 동적 WHERE 조건을 가진 대장 조회 화면 + - 체감 문제: 목록 조회 시 수 초 이상 대기 + +2. 쿼리 구조 파악: 무엇이 느린 건지 모르면 고칠 수 없다 + - 쿼리 구조도 (JOIN 관계 시각화) + - 동적 WHERE 조건 패턴 정리 + - "어디가 문제인지 짐작이 가지 않았다" + +3. EXPLAIN으로 병목 찾기 + - EXPLAIN 결과 캡처 (Before) + - type=ALL, rows, extra 해석 + - "범인은 서브쿼리 LEFT JOIN이었다" + +4. 인덱스 설계: 어떤 기준으로 무엇을 걸었는가 + - 쿼리 패턴 빈도 분석 → 인덱스 우선순위 + - 복합 인덱스 컬럼 순서 결정 과정 + - 레인지 연산(날짜 범위)은 맨 뒤에 배치한 이유 + - 커버링 인덱스 기회 발굴 + +5. 인덱스로 해결할 수 없는 것들 + - LIKE '%keyword%'는 왜 인덱스를 못 타는가 + - DATE 함수를 컬럼에 걸면 인덱스가 죽는 이유 + - "모든 것을 인덱스로 해결하려 하지 마라" + +6. 적용 결과: EXPLAIN 비교 (Before vs After) + - rows 감소율 + - type 변화 (ALL → ref) + - extra에서 Using filesort / Using temporary 제거 여부 + - 실제 응답시간 비교 + +7. 회고: 인덱스 설계에서 배운 것 + - 멘토링에서 배운 원칙과 실제 적용의 gap + - "인덱스 개수가 아니라 쿼리 패턴이 기준이다" + - 트레이드오프: 인덱스 추가 → 쓰기 부담 증가 +``` + +--- + +## 6. 블로그에 넣을 핵심 비교 자료 (TODO) + +실제 EXPLAIN을 돌려서 아래 수치를 채워야 한다: + +| 항목 | Before | After | 개선율 | +|------|--------|-------|--------| +| rows (메인 테이블) | ? | ? | ? | +| type (메인 테이블) | ALL? | ref? | - | +| extra 경고 | Using filesort? | 제거? | - | +| 실제 응답시간 | ?초 | ?초 | ?% | +| 서브쿼리 스캔 rows | ? | ? | ? | + +--- + +## 7. 멘토링 연결 포인트 + +블로그에 자연스럽게 녹일 수 있는 멘토링 인사이트: + +| 멘토링 내용 | 블로그 활용 | +|------------|-----------| +| "실제 SELECT 쿼리 패턴에 맞춰 설계하라" | 동적 WHERE 조건 패턴 분석 과정에 인용 | +| "레인지 연산 이후 컬럼은 인덱스 미작동" | 복합 인덱스 순서 결정 근거로 활용 | +| "DB 함수 사용 시 인덱스 미작동" | DATE 함수 문제 해결 섹션에 연결 | +| "인덱스 개수는 무의미, 조회에 쓰이면 걸어라" | 인덱스 4개를 추가한 판단 근거 | +| "EXPLAIN extra의 Using filesort = 병목 신호" | Before EXPLAIN 분석 시 강조 | +| "커버링 인덱스로 디스크 I/O 제거" | 서브쿼리 JOIN 최적화 설명 | + +--- + +## 8. 보안 체크리스트 + +- [ ] 원본 테이블명(CTR_CTR_LEDG 등) 노출 안 됨 +- [ ] 원본 컬럼명(CTR_LEDG_NO 등) 노출 안 됨 +- [ ] 프로젝트명(ALLSP_STD, ALLSP_ANSAN) 노출 안 됨 +- [ ] 화면 ID(ctrLeg101) 노출 안 됨 +- [ ] 회사명, 고객사명 노출 안 됨 +- [ ] 실제 데이터 값 노출 안 됨 +- [ ] 비즈니스 로직(채번 규칙 등) 상세 노출 안 됨 +- [ ] 스크린샷에 실제 화면/데이터 없음 diff --git a/docs/design/mermaid/00-ddd-design-framework.md b/docs/design/mermaid/00-ddd-design-framework.md index d76c8cec5..1809464bf 100644 --- a/docs/design/mermaid/00-ddd-design-framework.md +++ b/docs/design/mermaid/00-ddd-design-framework.md @@ -132,6 +132,7 @@ infrastructure/ → 기술 구현 (JpaRepository) |-----------|------|------| | **카탈로그** (상품 + 브랜드) | Core | 고객에게 보여줄 상품을 관리. 비즈니스 전시의 핵심 | | **주문** | Core | 거래를 기록하고 관리. 매출의 직접적 근간 | +| **결제** | Core | 주문에 대한 대금 수납. 외부 PG 시스템과 연동, 비동기 상태 관리 | | **재고** | Supporting | 주문과 카탈로그를 보조. 중요하지만 독자적 경쟁력은 아님 | | **좋아요** | Supporting | 고객 선호 추적. 카탈로그 정렬(인기순)에 활용 | | **회원/인증** | Generic | 어디서나 비슷한 범용 기능. 외부 솔루션 대체 가능 | @@ -194,6 +195,9 @@ graph LR Order["Order (어그리게이트)"] OrderItem["OrderItem (엔티티)"] end + subgraph "결제 BC" + Payment["Payment (어그리게이트)"] + end subgraph "회원/인증 BC" Member["Member"] end @@ -204,6 +208,8 @@ graph LR Like -- "userId (ID 참조)" --> Member Order -- "userId (ID 참조)" --> Member OrderItem -- "productId + 스냅샷" --> Product + Payment -- "orderId (ID 참조)" --> Order + Payment -- "userId (ID 참조)" --> Member ``` **브랜드와 상품이 같은 BC인 근거:** @@ -222,15 +228,19 @@ graph TD Inventory["재고 BC
(Supporting)"] LikeCtx["좋아요 BC
(Supporting)"] OrderCtx["주문 BC
(Core)"] + PaymentCtx["결제 BC
(Core)"] LikeCtx -- "userId" --> Auth OrderCtx -- "userId" --> Auth + PaymentCtx -- "userId" --> Auth Catalog -- "brandId → Product" --> Catalog Inventory -- "productId" --> Catalog LikeCtx -- "productId" --> Catalog OrderCtx -- "productId + 스냅샷" --> Catalog OrderCtx -- "재고 차감" --> Inventory LikeCtx -. "likeCount 갱신" .-> Catalog + PaymentCtx -- "orderId + 상태 연동" --> OrderCtx + PaymentCtx -. "PG 결제 요청/콜백" .-> PaymentCtx ``` ### 통신 방식 (현재 모놀리스) @@ -241,6 +251,8 @@ graph TD | `ProductFacade` | `StockService` | 직접 호출 | 상품 + 재고 동시 생성 | API 호출 또는 이벤트 | | `LikeFacade` | `ProductService` | 직접 호출 | 삭제된 상품 체크 + likeCount 갱신 | **도메인 이벤트** | | `OrderFacade` | `ProductService` + `StockService` | 직접 호출 | 상품 확인 → 재고 차감 → 주문 생성 | Saga 패턴 | +| `PaymentFacade` | `OrderService` | 직접 호출 | 주문 조회 + 상태 전이 (PAYMENT_PENDING/CONFIRMED/FAILED) | API 호출 | +| `PaymentFacade` | `PgClient` (외부) | HTTP (RestTemplate) | PG 결제 요청 + 콜백 수신 | 동일 (이미 HTTP) | ### 설계적 주의 지점: `product.incrementLikeCount()` @@ -337,6 +349,8 @@ public ProductModel register(..., Long brandId, int initialStock) { | `ProductFacade` | application | 상품 + Stock 동시 생성, 브랜드 존재 확인 | | `LikeFacade` | application | 데이터 조회 → LikeToggleService에 판단 위임 → 결과 저장 | | `OrderFacade` | application | 재고 차감 + 스냅샷 + 주문 생성, 주문 조회 | +| `PaymentService` | application | 결제 CRUD (save, getById, getByTransactionKey, getByOrderId) | +| `PaymentFacade` | application | 결제 요청(PG 연동) + 콜백 처리 + 주문 상태 연동 | | `MemberFacade` | application | 회원 유스케이스 조율 (가입, 인증, 비밀번호 변경) | --- diff --git a/docs/design/mermaid/01-requirements.md b/docs/design/mermaid/01-requirements.md index 0a7cd3bae..b87cdb2c5 100644 --- a/docs/design/mermaid/01-requirements.md +++ b/docs/design/mermaid/01-requirements.md @@ -257,7 +257,78 @@ --- -## 5. 공통 +## 5. Payment (결제) + +### 유저 스토리 + +- 고객은 주문에 대한 결제를 요청할 수 있다. +- 결제는 외부 PG 시스템(pg-simulator)을 통해 비동기로 처리된다. +- PG 시스템이 결제를 처리하면 콜백으로 결과를 수신한다. +- 고객은 결제 상태를 조회할 수 있다. +- 고객은 주문에 대한 결제 내역을 조회할 수 있다. +- 결제 실패 시 재결제가 가능하다. + +### 기능 흐름 + +**결제 요청** + +1. 로그인 사용자만 가능 (@LoginMember) +2. 주문 존재 여부 및 소유권 확인 +3. 주문 상태를 PAYMENT_PENDING으로 변경 +4. PaymentModel 생성 (status: PENDING, 카드번호 마스킹 저장) +5. 결제 금액은 Order.finalAmount에서 가져옴 (서버 산출) +6. PG 시스템에 결제 요청 (RestTemplate, 타임아웃 설정) +7. PG 응답의 transactionKey를 PaymentModel에 저장 +8. PG 요청 실패 시 트랜잭션 롤백 (주문 상태 CREATED로 복원) + +**PG 콜백 수신** + +1. PG 시스템에서 비동기 처리 완료 후 콜백 (1s~5s) +2. transactionKey로 PaymentModel 조회 +3. SUCCESS → payment.markSuccess() + order.confirmPayment() +4. FAILED → payment.markFailed(reason) + order.failPayment() +5. 멱등성 보장: 동일 상태 재진입 시 무시 + +**재결제** + +1. 결제 실패(PAYMENT_FAILED) 상태의 주문만 재결제 가능 +2. 새로운 PaymentModel 생성 (1주문:N결제) +3. 이전 결제 기록은 이력으로 보존 + +### 비즈니스 규칙 + +- Payment는 Order와 별도 엔티티 (1주문:N결제, ID 참조) +- 결제 금액은 Order.finalAmount 사용 (단일 진실 공급원) +- 카드번호는 마스킹 저장: "1234-****-****-1451" +- PG 호출은 트랜잭션 내에서 수행 (1단계), 실패 시 전체 롤백 +- PaymentStatus: PENDING → SUCCESS | FAILED (멱등 전이) +- OrderStatus 확장: CREATED → PAYMENT_PENDING → CONFIRMED | PAYMENT_FAILED +- PAYMENT_FAILED → PAYMENT_PENDING 재전이 허용 (재결제) +- PG 타임아웃: connect 1s, read 3s + +### PG 시스템 특성 + +| 항목 | 값 | +|------|-----| +| 요청 성공 확률 | 60% | +| 요청 지연 | 100ms ~ 500ms | +| 처리 지연 | 1s ~ 5s (비동기, 콜백 수신) | +| 처리 결과 - 성공 | 70% | +| 처리 결과 - 한도 초과 | 20% | +| 처리 결과 - 잘못된 카드 | 10% | + +### API + +| Method | Endpoint | 설명 | 인증 | +|--------|----------|------|------| +| POST | `/api/v1/payments` | 결제 요청 | @LoginMember | +| GET | `/api/v1/payments/{paymentId}` | 결제 상태 조회 | @LoginMember | +| GET | `/api/v1/payments?orderId={orderId}` | 주문별 결제 내역 조회 | @LoginMember | +| POST | `/api/v1/payments/callback` | PG 콜백 수신 | 없음 (PG→서버) | + +--- + +## 6. 공통 ### 인증 (Q13: ArgumentResolver) @@ -285,19 +356,20 @@ --- -## 6. API 엔드포인트 요약 +## 7. API 엔드포인트 요약 -| 구분 | Customer | Admin | 합계 | -|------|----------|-------|------| -| Brand | 1 | 5 | 6 | -| Product | 2 | 5 | 7 | -| Like | 3 | 0 | 3 | -| Order | 3 | 2 | 5 | -| **합계** | **9** | **12** | **21** | +| 구분 | Customer | Admin | PG 내부 | 합계 | +|------|----------|-------|---------|------| +| Brand | 1 | 5 | 0 | 6 | +| Product | 2 | 5 | 0 | 7 | +| Like | 3 | 0 | 0 | 3 | +| Order | 3 | 2 | 0 | 5 | +| Payment | 3 | 0 | 1 | 4 | +| **합계** | **12** | **12** | **1** | **25** | --- -## 7. Q&A 트레이드오프 추적표 +## 8. Q&A 트레이드오프 추적표 | Q# | 결정 사항 | 반영 위치 | |----|-----------|-----------| diff --git a/docs/design/mermaid/02-ubiquitous-language.md b/docs/design/mermaid/02-ubiquitous-language.md index b8ef8bd86..41e7ba2e7 100644 --- a/docs/design/mermaid/02-ubiquitous-language.md +++ b/docs/design/mermaid/02-ubiquitous-language.md @@ -71,7 +71,7 @@ |------|------|------| | **OrderModel** | Entity | 주문 엔티티. userId, status(OrderStatus), totalAmount(Money) | | **OrderItemModel** | Entity | 주문 상세 엔티티. orderId, productId, 스냅샷(productName, productPrice), quantity. `subtotal()` 행위 메서드 | -| **OrderStatus** | Enum | 주문 상태. CREATED(현재 사용) → CONFIRMED → SHIPPING → DELIVERED → CANCELLED (미래 확장용) | +| **OrderStatus** | Enum | 주문 상태. CREATED → PAYMENT_PENDING → CONFIRMED → SHIPPING → DELIVERED → CANCELLED. PAYMENT_FAILED에서 재결제 가능 | | **스냅샷 (Snapshot)** | 비즈니스 개념 | 주문 시점의 상품명, 가격을 OrderItem에 복사 저장. 상품 삭제/변경 후에도 주문 내역 조회 가능 (Q9) | | **All or Nothing** | 비즈니스 규칙 | 재고 부족 또는 삭제된 상품 포함 시 주문 전체 실패. 부분 성공 없음 (Q19) | | **OrderService** | Domain Service | 주문 생성(총액 계산 포함), 조회 | @@ -79,7 +79,55 @@ --- -## 5. 공통 패턴 +## 5. 결제 BC (Payment) + +> 비즈니스 관심사: "주문에 대한 대금을 외부 PG 시스템을 통해 수납한다" +> Order와 별도 어그리게이트 — 1주문:N결제 구조 (재결제 이력 보존) + +### Payment (어그리게이트) + +| 용어 | 타입 | 설명 | +|------|------|------| +| **PaymentModel** | Entity | 결제 엔티티. BaseEntity 상속. orderId(ID 참조), userId, cardType, maskedCardNo, amount(Money), status, transactionKey, failureReason | +| **PaymentStatus** | Enum | 결제 상태. PENDING(요청 접수) → SUCCESS(성공) \| FAILED(실패) \| CANCELLED(취소) | +| **CardType** | Enum | 카드사. SAMSUNG, KB, HYUNDAI | +| **transactionKey** | 외부 식별자 | PG 시스템이 발급한 거래 고유 키. unique 제약. 콜백 수신 시 조회 기준 | +| **maskedCardNo** | String | 마스킹된 카드번호. "1234-\*\*\*\*-\*\*\*\*-1451" 형태로 DB 저장 | +| **PaymentService** | Application Service | 결제 CRUD (save, getById, getByTransactionKey, getByOrderId) | + +### PG 연동 + +| 용어 | 타입 | 설명 | +|------|------|------| +| **PgClient** | Infrastructure | RestTemplate 기반 PG HTTP 클라이언트. requestPayment, getPaymentStatus | +| **PgPaymentRequest** | DTO | PG 요청 DTO. orderId, cardType, cardNo(원본), amount, callbackUrl | +| **PgPaymentResponse** | DTO | PG 응답 DTO. transactionKey, orderId, status, failureReason | +| **callbackUrl** | 설정값 | PG가 결제 결과를 전송할 엔드포인트 URL. application.yml에서 관리 | +| **비동기 결제** | 비즈니스 개념 | 요청과 처리가 분리됨. 요청 시 PENDING 상태, 1~5초 후 콜백으로 최종 결과 수신 | + +### Application Layer + +| 용어 | 타입 | 설명 | +|------|------|------| +| **PaymentFacade** | Application Facade | 결제 유스케이스 조합. 주문 검증 → PG 호출 → 상태 관리. 콜백 처리 시 결제+주문 상태 동시 변경 | +| **PaymentCommand** | Command DTO | 결제 요청 입력. orderId, cardType, cardNo. `maskedCardNo()` 마스킹 메서드 포함 | +| **PaymentInfo** | Info DTO | 결제 조회 출력. `from(PaymentModel)` 팩토리 | + +### 상태 전이 규칙 + +| 전이 | 조건 | 트리거 | +|------|------|--------| +| Payment: PENDING → SUCCESS | PG 콜백 status=SUCCESS | handleCallback | +| Payment: PENDING → FAILED | PG 콜백 status=FAILED | handleCallback | +| Payment: SUCCESS → SUCCESS | 중복 콜백 | 멱등 — 무시 | +| Order: CREATED → PAYMENT_PENDING | 결제 요청 시 | requestPayment | +| Order: PAYMENT_PENDING → CONFIRMED | 결제 성공 시 | handleCallback (SUCCESS) | +| Order: PAYMENT_PENDING → PAYMENT_FAILED | 결제 실패 시 | handleCallback (FAILED) | +| Order: PAYMENT_FAILED → PAYMENT_PENDING | 재결제 시 | requestPayment | + +--- + +## 6. 공통 패턴 ### 6.1 엔티티 기반 diff --git a/docs/design/mermaid/03-sequence-payment-callback.mmd b/docs/design/mermaid/03-sequence-payment-callback.mmd new file mode 100644 index 000000000..7de46149b --- /dev/null +++ b/docs/design/mermaid/03-sequence-payment-callback.mmd @@ -0,0 +1,35 @@ +sequenceDiagram + participant Sim as pg-simulator (외부) + participant Ctrl as PaymentV1Controller + participant PF as PaymentFacade + participant PS as PaymentService + participant OS as OrderService + + %% 경로 1: PG 콜백 (정상) + Note over Sim: 비동기 처리 완료 (1s~5s 후) + Sim->>Ctrl: POST /api/v1/payments/callback + Note over Ctrl: 인증 없음 (PG→서버 내부 통신) + + activate PF + rect rgb(230, 240, 255) + Note right of PF: @Transactional + PF->>PS: getByTransactionKey(txKey) + PS-->>PF: PaymentModel (PENDING) + PF->>OS: getOrderForAdmin(orderId) + OS-->>PF: OrderModel (PAYMENT_PENDING) + + alt status == "SUCCESS" + PF->>PF: payment.markSuccess() + Note over PF: 결제 상태 → SUCCESS + PF->>PF: order.confirmPayment() + Note over PF: 주문 상태 → CONFIRMED + else status == "FAILED" + PF->>PF: payment.markFailed(reason) + Note over PF: 결제 상태 → FAILED + 실패 사유 기록 + PF->>PF: order.failPayment() + Note over PF: 주문 상태 → PAYMENT_FAILED
재결제 가능 (startPayment 허용) + end + end + deactivate PF + + PF-->>Sim: 200 OK diff --git a/docs/design/mermaid/03-sequence-payment-polling.mmd b/docs/design/mermaid/03-sequence-payment-polling.mmd new file mode 100644 index 000000000..d60e4ded8 --- /dev/null +++ b/docs/design/mermaid/03-sequence-payment-polling.mmd @@ -0,0 +1,62 @@ +sequenceDiagram + participant Sched as PaymentPollingScheduler + participant PF as PaymentFacade + participant PS as PaymentService + participant OS as OrderService + participant GW as PgPaymentGateway + participant PG as PgClient + participant Sim as pg-simulator (외부) + + Note over Sched: 60초 주기 실행 (@Scheduled) + Note over Sched: ThreadPoolTaskScheduler (poolSize=3) + + Sched->>PS: getPendingPayments() + PS-->>Sched: List (PENDING 상태) + + loop 각 PENDING 결제 처리 (fail-fast: 연속 3건 실패 시 중단) + Sched->>PF: syncPaymentStatus(paymentId) + + activate PF + rect rgb(230, 240, 255) + Note right of PF: @Transactional + + alt Phase A: transactionKey 있음 + PF->>GW: getPaymentStatus(txKey, userId) + GW->>PG: GET /api/v1/payments/{txKey} + PG->>Sim: GET /api/v1/payments/{txKey} + Sim-->>PG: {status, failureReason} + PG-->>GW: PgPaymentResponse + GW-->>PF: PgPaymentResponse + + else Phase C: transactionKey 없음 (고아 Payment) + Note over PF: TX-1 커밋 후 PG 호출 실패한 레코드 + PF->>GW: getPaymentStatus(orderId, userId) + GW->>PG: GET /api/v1/payments/{orderId} + PG->>Sim: orderId로 조회 + Sim-->>PG: {transactionKey, status, failureReason} + PG-->>GW: PgPaymentResponse + GW-->>PF: PgPaymentResponse + PF->>PF: payment.assignTransactionKey(txKey) + end + + alt status == "SUCCESS" + PF->>PF: payment.markSuccess() + PF->>PF: order.confirmPayment() + else status == "FAILED" + PF->>PF: payment.markFailed(reason) + PF->>PF: order.failPayment() + else status == "PENDING" + Note over PF: 아직 PG 처리 중 — 다음 사이클에서 재조회 + end + end + deactivate PF + + alt PG 조회 실패 + Note over Sched: consecutiveFailures++ + alt consecutiveFailures >= 3 + Note over Sched: PG 장애 판단 → 사이클 조기 종료
다음 60초 후 재시도 + end + else PG 조회 성공 + Note over Sched: consecutiveFailures = 0 + end + end diff --git a/docs/design/mermaid/03-sequence-payment-request.mmd b/docs/design/mermaid/03-sequence-payment-request.mmd new file mode 100644 index 000000000..891906259 --- /dev/null +++ b/docs/design/mermaid/03-sequence-payment-request.mmd @@ -0,0 +1,79 @@ +sequenceDiagram + participant C as Customer + participant Ctrl as PaymentV1Controller + participant PF as PaymentFacade + participant PS as PaymentService + participant OS as OrderService + participant GW as PgPaymentGateway + participant PG as PgClient (RestTemplate) + participant Sim as pg-simulator (외부) + + C->>Ctrl: 결제 요청 (POST /api/v1/payments) + Note over Ctrl: @LoginMember 인증 + + activate PF + Note right of PF: @CircuitBreaker(name="pg") + + %% TX-1: DB 작업만, 커밋 후 커넥션 즉시 반환 + rect rgb(230, 240, 255) + Note right of PS: TX-1 @Transactional (~15ms) + PF->>PS: preparePayment(userId, command) + PS->>OS: getOrder(orderId, userId) + OS-->>PS: OrderModel (상태: CREATED) + PS->>PS: order.startPayment() + Note over PS: 주문 상태 → PAYMENT_PENDING + PS->>PS: PaymentModel 생성 (PENDING) + PS-->>PF: PaymentModel (id 채번) + Note over PF: DB 커넥션 반환 ✅ + end + + %% NO TX: PG 호출 — DB 커넥션 미점유 + rect rgb(255, 245, 230) + Note right of GW: NO TX — DB 커넥션 안 잡음 + PF->>GW: requestPayment(pgRequest, userId) + Note over GW: @Bulkhead(max=20) + @Retry(max=3) + + loop Retry (최대 3회, exponential backoff) + GW->>PG: requestPayment(request, userId) + Note over PG: X-USER-ID 헤더 설정 + PG->>Sim: POST /api/v1/payments + Note over Sim: 요청 지연: 100ms~500ms
성공률: 60% + + alt PG 요청 성공 + Sim-->>PG: {transactionKey, status: PENDING} + PG-->>GW: PgPaymentResponse + Note over GW: Retry 종료 + else PG 5xx / 타임아웃 (재시도 대상) + Sim-->>PG: 500 또는 타임아웃 + Note over GW: 500ms → 1s → 2s 후 재시도 + end + end + end + + alt PG 최종 성공 + GW-->>PF: PgPaymentResponse + + %% TX-2: transactionKey 저장 + rect rgb(230, 240, 255) + Note right of PS: TX-2 @Transactional (~5ms) + PF->>PS: assignTransactionKey(paymentId, txKey) + Note over PF: DB 커넥션 반환 ✅ + end + + PF-->>C: 200 OK {paymentId, transactionKey, status: PENDING} + + else PG 최종 실패 (3회 재시도 후) 또는 서킷 오픈 + GW-->>PF: Exception + Note over PF: fallbackMethod 실행 + + rect rgb(255, 230, 230) + Note right of PF: Fallback (TX 없음) + Note over PF: TX-1은 이미 커밋됨
주문: PAYMENT_PENDING
결제: PENDING (txKey=null) + PF-->>C: 200 OK {status: PG_FAILED, message: "잠시 후 다시 시도해주세요"} + Note over PF: → Polling 스케줄러가 60초 후 복구 시도 + end + end + deactivate PF + + Note over Sim: 비동기 처리 (1s~5s) + Note over Sim: 성공 70% / 한도초과 20% / 잘못된카드 10% diff --git a/docs/design/mermaid/04-class-diagram.mmd b/docs/design/mermaid/04-class-diagram.mmd index 1857a280c..35cffe7f7 100644 --- a/docs/design/mermaid/04-class-diagram.mmd +++ b/docs/design/mermaid/04-class-diagram.mmd @@ -59,6 +59,8 @@ classDiagram class OrderStatus { <> CREATED + PAYMENT_PENDING + PAYMENT_FAILED CONFIRMED SHIPPING DELIVERED @@ -68,6 +70,9 @@ classDiagram -Long userId -OrderStatus status -Money totalAmount + +startPayment() + +confirmPayment() + +failPayment() } class OrderItemModel { -Long orderId @@ -82,11 +87,65 @@ classDiagram } } + namespace Payment { + class PaymentStatus { + <> + PENDING + SUCCESS + FAILED + CANCELLED + } + class CardType { + <> + SAMSUNG + KB + HYUNDAI + } + class PaymentModel { + -Long orderId + -Long userId + -CardType cardType + -String maskedCardNo + -Money amount + -PaymentStatus status + -String transactionKey + -String failureReason + +assignTransactionKey(String) + +markSuccess() + +markFailed(String) + } + class PgClient { + <> + +requestPayment(PgPaymentRequest, userId) PgPaymentResponse + +getPaymentStatus(txKey, userId) PgPaymentResponse + } + class PgPaymentGateway { + <> + +requestPayment(PgPaymentRequest, userId) PgPaymentResponse + +getPaymentStatus(txKey, userId) PgPaymentResponse + Note: @Bulkhead + @Retry + } + class PaymentFacade { + TX 분리 패턴 + @CircuitBreaker + Fallback + +requestPayment() PaymentInfo + +handleCallback() void + +syncPaymentStatus() PaymentInfo + } + class PaymentPollingScheduler { + <> + +pollPendingPayments() + Note: 60초 주기 + fail-fast(3건) + } + } + BrandModel *-- BrandName ProductModel *-- Money OrderModel *-- OrderStatus OrderModel *-- Money OrderItemModel *-- Money + PaymentModel *-- PaymentStatus + PaymentModel *-- CardType + PaymentModel *-- Money BrandFacade --> BrandService BrandService --> BrandModel @@ -104,4 +163,11 @@ classDiagram OrderFacade --> ProductService OrderFacade --> StockService OrderService --> OrderModel - OrderService --> OrderItemModel \ No newline at end of file + OrderService --> OrderItemModel + PaymentFacade --> PaymentService + PaymentFacade --> OrderService + PaymentFacade --> PgPaymentGateway + PgPaymentGateway --> PgClient + PaymentPollingScheduler --> PaymentService + PaymentPollingScheduler --> PaymentFacade + PaymentService --> PaymentModel \ No newline at end of file diff --git a/docs/design/mermaid/05-erd.mmd b/docs/design/mermaid/05-erd.mmd index 8baf2ac4d..fbe4c9885 100644 --- a/docs/design/mermaid/05-erd.mmd +++ b/docs/design/mermaid/05-erd.mmd @@ -6,6 +6,7 @@ erDiagram product ||--o{ member_like : "receives" product ||--o{ order_item : "ordered_as" orders ||--|{ order_item : "contains" + orders ||--o{ payments : "paid_by" member { bigint id PK @@ -56,4 +57,18 @@ erDiagram varchar product_name "snapshot" int product_price "snapshot" int quantity + } + payments { + bigint id PK + bigint order_id "refs orders, 1:N" + bigint user_id "refs member" + varchar card_type "CardType enum" + varchar masked_card_no "1234-****-****-1451" + int amount "Money VO" + varchar status "PaymentStatus" + varchar transaction_key UK "PG 거래 키" + varchar failure_reason "nullable" + timestamp created_at + timestamp updated_at + timestamp deleted_at "soft delete" } \ No newline at end of file diff --git a/docs/design/mermaid/06-payment-state-diagram.mmd b/docs/design/mermaid/06-payment-state-diagram.mmd new file mode 100644 index 000000000..947a9b09a --- /dev/null +++ b/docs/design/mermaid/06-payment-state-diagram.mmd @@ -0,0 +1,44 @@ +%% 결제 도메인 상태 전이 다이어그램 +%% OrderStatus와 PaymentStatus의 연동 관계 + Resilience4j Fallback + Polling 복구 + +stateDiagram-v2 + state "Order 상태" as OrderState { + [*] --> CREATED : 주문 생성 + CREATED --> PAYMENT_PENDING : startPayment() [TX-1] + PAYMENT_PENDING --> CONFIRMED : confirmPayment() [콜백/Polling] + PAYMENT_PENDING --> PAYMENT_FAILED : failPayment() [콜백/Polling] + PAYMENT_FAILED --> PAYMENT_PENDING : startPayment() [재결제] + PAYMENT_FAILED --> CANCELLED : cancel() + CONFIRMED --> SHIPPING : ship() + SHIPPING --> DELIVERED : deliver() + } + + state "Payment 상태" as PaymentState { + [*] --> PENDING : TX-1 커밋 (preparePayment) + PENDING --> SUCCESS : markSuccess() [콜백/Polling] + PENDING --> FAILED : markFailed() [콜백/Polling] + SUCCESS --> SUCCESS : markSuccess() [멱등] + FAILED --> FAILED : markFailed() [멱등] + } + + note right of OrderState + TX 분리 패턴 + Resilience4j 연동: + ───────────────────────────────── + 정상 플로우: + TX-1: CREATED→PAYMENT_PENDING + PENDING 생성 + NO TX: PG 호출 (@Retry 3회 + @Bulkhead 20) + TX-2: transactionKey 저장 + 콜백: SUCCESS→CONFIRMED / FAILED→PAYMENT_FAILED + + PG 실패 플로우 (Fallback): + TX-1: 이미 커밋됨 (PAYMENT_PENDING + PENDING) + Fallback: PG_FAILED 응답 반환 (txKey=null 고아 발생) + → 3-Tier 복구 안전망: + 1차: PG 콜백 (정상 도착 시) + 2차: Polling 60초 주기 자동 복구 + 3차: /payments/{id}/sync 수동 복구 + + 서킷브레이커 OPEN 시: + PG 호출 없이 즉시 Fallback 실행 + 주문/결제는 TX-1 커밋 상태로 유지 + end note diff --git a/docs/mentoring b/docs/mentoring new file mode 100644 index 000000000..2fb70c6a6 --- /dev/null +++ b/docs/mentoring @@ -0,0 +1,802 @@ +# [Round-5] 멘토링 정리 - 인덱스와 캐싱 (2026-03-11) + +**멘토**: Alen (토스 재직) +**일시**: 2026-03-11 +**주제**: 데이터베이스 성능 최적화 - 인덱스와 캐싱 전략 + +--- + +## 1. 인덱스 핵심 개념 정리 + +### 1.1 인덱스란? + +**핵심 비유: "책자"** +- 데이터를 정렬된 순서로 정리해 놓은 것 +- 조회 시 전체 데이터를 스캔하지 않고 빠르게 찾을 수 있음 +- 인덱스의 마지막에는 **실제 데이터의 디스크 주소(포인터)**만 저장됨 (데이터 자체가 아님) + +**중요 개념: 조합마다 다른 인덱스 필요** +- 정렬/검색 순서가 다르면 새로운 인덱스를 만들어야 함 +- 인덱스가 많으면 **데이터 변경 시 모든 인덱스를 함께 갱신**해야 하는 부담 발생 + +### 1.2 커버링 인덱스 (Covered Index) + +**정의**: 조회에 필요한 모든 컬럼이 인덱스에 포함되어 있는 경우 + +```sql +-- 예: (user_id, status, created_at) 복합 인덱스 +CREATE INDEX idx_user_status ON orders(user_id, status, created_at); + +-- 다음 쿼리는 커버링 인덱스 활용 가능 (SELECT * 제외) +SELECT user_id, status, created_at +FROM orders +WHERE user_id = 123 AND status = 'completed'; +``` + +**장점** +- 인덱스만으로 결과 반환 가능, 디스크 I/O 불필요 +- `SELECT *` 대신 **필요한 컬럼만 명시**하면 커버링 활용 +- 자주 조회되는 패턴에 대해 구성하면 성능 대폭 향상 + +### 1.3 레인지 연산과 인덱스 제약 + +**핵심 원리: 연산 유형에 따른 인덱스 효율** + +| 연산 유형 | 효율성 | 설명 | +|----------|--------|------| +| Equal (=) | ⭐⭐⭐ | 인덱스가 가장 강력하게 동작 | +| Range (>, <, BETWEEN) | ⭐⭐ | 해당 컬럼 이후 인덱스는 활용 불가 | + +**레인지 연산의 문제** + +복합 인덱스 `(col1, col2, col3)` 에서: +```sql +-- col1 = equal, col2 = range → col3는 인덱스 안 탐 +SELECT * FROM table +WHERE col1 = 'A' AND col2 > 100 AND col3 = 'B'; +-- col3는 인덱스로 필터링 안 됨 +``` + +**해결책: 레인지 조건을 맨 뒤에 배치** +```sql +-- 인덱스: (col1, col3, col2) - range를 맨 뒤에 +CREATE INDEX idx_optimized ON table(col1, col3, col2); + +-- 이제 col3까지 인덱스로 필터링 가능 +SELECT * FROM table +WHERE col1 = 'A' AND col3 = 'B' AND col2 > 100; +``` + +**예외: 단일 컬럼 인덱스** +- 후행 인덱스가 없으므로 Range 연산도 잘 작동 + +### 1.4 인덱스 설계 원칙 + +**원칙 1: 실제 SELECT 쿼리 패턴에 맞춰 설계** +- "이렇게 걸면 좋을 것 같은데" → 불충분 +- 실제 쿼리가 나가는 패턴을 분석한 후 설계 + +**원칙 2: 복합 인덱스 순서가 중요** +```sql +-- (col1, col2) 인덱스 +-- col1로만 조회 → 사용 가능 +-- col2로만 조회 → 사용 불가 (Leftmost prefix) + +-- (col2, col1) 인덱스는 전혀 다른 인덱스 +-- 선택하는 쿼리 패턴에 맞게 구성 필수 +``` + +**원칙 3: DB 함수 사용 시 인덱스 미작동** +```sql +-- DATE(created_at) = '2026-03-11' → created_at 인덱스 못 탐 +-- 함수 적용 전에 값을 알 수 없으므로 계산 필요 +-- 해결: WHERE created_at >= '2026-03-11' AND created_at < '2026-03-12' +``` + +### 1.5 인덱스 개수의 올바른 기준 + +**멘토의 의견: "개수 자체는 무의미"** + +**올바른 기준** +- **조회/검색에 쓰이는 모든 컬럼은 무조건 인덱스** +- 쓰기 부담과 중복 인덱스 제거는 이후 최적화 단계 + +**대규모 운영 사례** +- 멘토 경험: 상품 테이블 9천만 개 레코드에서 인덱스 10개 이상 운용 +- 원칙: 주기적으로 쿼리 분석해서 사용하지 않는 인덱스 제거 + +**읽기 확장 환경에서의 이점** +- 일반적으로: 마스터 DB (쓰기), 레플리카 DB (읽기) 분리 +- 읽기 레플리카는 쓰기 부담이 없으므로 **인덱스를 아낌없이 추가하는 것이 유리** + +--- + +## 2. 캐시 핵심 개념 정리 + +### 2.1 캐시가 필요한 이유 + +**바구니 이론** + +``` +상황: DB 조회 요청이 많이 몰림 → DB 부하 증가 → 응답 지연 + +해결: "바구니(캐시)"를 만들어 DB 결과를 미리 저장 + +흐름: +1. 바구니에 데이터 있나? (Cache-Aside 패턴) + - 있으면 → 그대로 가져감 (빠른 응답) + - 없으면 → DB에서 가져오고 바구니에 넣어둠 (다음 요청을 위해, 이타심!) +``` + +**핵심**: DB 조회 부하를 줄이고 응답 속도 향상 + +**캐시는 DB만을 위한 것이 아님** +- 캐시 개념은 DB뿐 아니라 GPU 서버, 추천 모델 등 연산 비용이 높은 모든 곳에 적용 가능 +- 핵심: 조회 비용이나 연산 비용이 높은 것을 미리 계산(Pre-computed)해서 저장하는 것 + +### 2.2 로컬 캐시 vs 글로벌 캐시 + +| 구분 | 로컬 캐시 | 글로벌 캐시 | +|------|----------|-----------| +| **저장소** | 서버 메모리 (HashMap, ConcurrentHashMap) | 외부 캐시 서버 (Redis, Valkey) | +| **속도** | ⭐⭐⭐ 빠름 | ⭐⭐ 네트워크 레이턴시 | +| **네트워크** | 없음 | 네트워크 I/O 발생 | +| **일관성** | 서버마다 다른 값 가능 | 모든 서버가 동일한 값 참조 | +| **서버 독립성** | 서버 재시작/배포 시 초기화 | 서버 장애 영향 최소 | +| **장애 위험** | 낮음 | 캐시 서버 장애 가능성 | + +**실무 전략: L1 + L2 2단계 캐싱** + +``` +L1 캐시 (로컬, 짧은 TTL) +└─ 트래픽 방어 목적 + TTL: 매우 짧음 (초 단위) + +L2 캐시 (글로벌, 긴 TTL) +└─ DB 보호 목적 + TTL: 길게 (분~시간 단위) +``` + +### 2.3 캐시 갱신 전략 (Write Path) + +**문제**: 원본(DB)이 바뀌면 사본(캐시)과 불일치 발생 + +**불일치 해결 방법** +1. **TTL 만료**: 자연 만료 후 DB에서 다시 가져옴 + ``` + 단점: TTL 만료 전까지 stale 데이터 서빙 + TTL 만료 후 동시 요청 → 모두 DB로 몰림 (Cache Stampede) + ``` +2. **변경 시 캐시 삭제(Evict) - 하수**: 바구니를 비움 → 다음 요청이 DB 조회 + ``` + 쓰기 흐름: + 1. DB 업데이트 (Source of Truth) + 2. 캐시 삭제 (evict) + + 단점: 삭제 후 다음 조회가 DB로 가야 함 + ``` +3. **변경 시 캐시 덮어쓰기(Put) - 고수 ⭐**: 바꾼 사본을 바구니에 넣음 → DB 조회 불필요 + ``` + 쓰기 흐름: + 1. DB 업데이트 (Source of Truth) + 2. 캐시에 새 값을 put + + 결과: 조회가 계속 캐시에서 발생 → DB 부하 최소화 + ``` + +**Write-Through vs Write-Around** + +| 패턴 | 흐름 | 위험성 | +|------|------|--------| +| Write-Through | 캐시 먼저 쓰고 DB 씀 | 캐시만 써지고 DB 장애 가능 | +| Write-Around ⭐ | DB 먼저 쓰고 캐시 갱신 | 캐시 갱신 전 장애 시 일시적 stale (TTL로 복구) | + +**권장**: Write-Around (DB를 Source of Truth로 취급) + +**참고**: 멘토는 정합성이 중요한 경우 Write-Through를 명시적으로 파기하고 Write-Around를 권장 + +### 2.4 캐시 스탐피드 (Cache Stampede) + +**문제 상황** +``` +캐시 miss 발생 → 동시에 많은 요청이 DB로 몰림 → DB 부하 급증 +``` + +**해결 방법 1: Lock + Timeout + Retry 패턴** +``` +캐시 miss → GetLock(key) 시도 + ├─ 성공: 한 명만 DB 조회 → 캐시 set → lock release + └─ 실패: 대기 → 재시도 (캐시 조회) + +효과: 한 명만 DB로 가고, 나머지는 캐시 대기 +``` + +**해결 방법 2: 확률적 갱신** +``` +TTL 만료 전, 10% 확률로 DB 조회 → 캐시 미리 갱신 +효과: 갱신이 점진적으로 발생, 스탐피드 방지 +``` + +**해결 방법 3: Pre-warming (멘토 선호) ⭐** +``` +주기적으로 뒤에서 캐시를 미리 갱신 +예시: + - 블랙프라이데이: 1분 전부터 미리 갱신 + - 배치 작업: 00시에 미리 갱신 + +효과: 사용자가 접근할 때 이미 캐시됨 +``` + +### 2.5 캐시 설계 원칙 (멘토) + +0. **Cache-Aside만으로도 대부분 충분** (멘토 강조) + - 사본이 차 있을 때는 다 캐시에서 조회되므로 많은 방어가 됨 + - 트래픽이 더 많은 서비스에서만 스탬피드 방어 등 추가 고민 필요 + +1. **조회는 항상 캐시에서 될 수 있게 설계** + - 보완책으로 Cache-Aside 패턴 사용 + +2. **쓰기 쪽에서 갱신 전략을 먼저 구상** + - DB 업데이트 후 캐시 처리 방법 결정 + +3. **유저 행동 패턴/동선에 따라 캐시 설계 변경** + - 예: 유저 로그인 시 배너 캐시 미리 생성 (추천 로직이 비싸므로) + - 예: 1페이지는 자주 봄, 2페이지는 거의 안 봄 → 1페이지만 캐시 + +--- + +## 3. Q&A 정리 + +### Q1. 캐싱 전략 선택 흐름 (김요한) + +**질문**: `@Cacheable` 등 어노테이션으로 캐시를 붙이면 어떤 정책인지 명확하지 않은데, 실무에서는 정책을 먼저 정하고 구현하나? + +**멘토 답변**: +- **캐시는 항상 먼저 설계함** (코드 아님) +- 설계 순서: + 1. 조회는 항상 캐시에서 될 수 있게 설계 + 2. 쓰기 쪽에서 갱신 전략 구상 + 3. 데이터의 조회/연산 비용 분석 + 4. 유저 행동 패턴 분석 후 캐시 전략 결정 + +**실무 예시**: +``` +유저별 배너 캐싱: +1. 로그인 시 배너 추천 로직 실행 → 캐시 생성 +2. 또는 배치 작업으로 00시에 미리 생성 +3. 유저 행동 변화 감지 시 즉시 갱신 +``` + +--- + +### Q2. Redis 캐시 도입 시점 판단 (P0) + +**질문**: DB 인덱스만으로 성능 개선이 큰데, 어느 시점부터 Redis 캐시를 붙이나? + +**멘토 답변**: +- **트래픽만 봄** (거의 100%) +- 핵심 판단 기준: **"동일 조회 패턴이 반복되는가?"** + +**도입 신호**: +``` +1. Read QPS(Query Per Second)가 높음 + → 아무리 인덱스가 잘 걸려있어도 DB가 아파함 + → 그 쿼리만 처리하는 게 아니기 때문 + +2. Read QPS가 높은 기능 + → DB로 최대한 안 흘러들어가게 하는 것이 목적 +``` + +**맘스터치 비유 (멘토)**: +- 옆집 맥도날드가 점심에 바글바글하니까, 우리도 바글바글할 걸 기대하고 싸이버거 100개를 미리 만들어둠 +- 그런데 손님이 안 옴 → 미리 만든 게 다 낭비 +- 캐시도 동일: 미리 만들어 두는 게 유의미한 데이터인가를 먼저 판단해야 함 + +**Redis 메모리 운영**: +``` +평시 기준 70% 미만 유지 +(30% 버퍼 확보로 장애 대응) +``` + +--- + +### Q3. 슬로우 쿼리 해결 판단 기준 (양권모) + +**질문**: EXPLAIN → 인덱스 → 쿼리 개선 → 캐싱 → 힌트 순서로 접근하는데 맞는지? + +**멘토 답변 (90점 접근)**: + +**EXPLAIN 분석의 핵심 지표**: + +| 지표 | 확인 항목 | 의미 | +|------|---------|------| +| rows | 스캔된 행 수 | 적을수록 좋음 | +| type | 접근 방식 | ref > range > index > ALL | +| extra | 추가 정보 | Using Filesort, Using temporary ← 병목 신호 | + +**extra의 위험 신호**: +``` +Using Filesort, Using temporary +→ 성능 병목, 인덱스를 제대로 못 타고 있다는 의미 +→ 정렬/그룹 작업이 메모리 밖에서 진행 중 +``` + +**셀렉티비티만 보는 건 위험한 접근** ⚠️ +- 실제로는 쿼리 패턴, 복합 인덱스 순서가 더 중요 +- 레인지 연산 여부, DB 함수 사용 여부도 확인 필수 + +**올바른 접근 순서** (멘토 권장): +``` +1. EXPLAIN 분석 (rows, type, extra) +2. 인덱스 설계/추가 +3. 쿼리 개선 (페이지네이션/LIMIT, JOIN 순서, WHERE 조건 재구성) +4. 데이터 모델 변경 (정규화/비정규화) +5. 캐시 도입 +``` + +**힌트 사용에 대한 경고**: +``` +절대 안 쓸수록 좋음 ❌ +- 유지보수가 매우 어려움 +- 데이터 분포 변경 시 오히려 슬로우 쿼리 유발 +- 쿼리 플래너가 최적화할 자유도 빼앗음 +``` + +--- + +### Q4. 캐싱 정합성 면접 질문 대응 (김요한) + +**질문**: 정합성 중요한 데이터에서 DB/캐시 저장 순서와 장애 시나리오는? + +**멘토의 모범 답변 구성**: + +1. **SOT(Source of Truth) 확정**: + ``` + DB = 신원 (실제 데이터) + 캐시 = 바구니 (읽기 성능 최적화 레이어, 일시적 스냅샷) + ``` + +2. **패턴 선택**: Write-Around + ``` + 1. DB 먼저 업데이트 + 2. 캐시 evict 또는 put + ``` + +3. **장애 케이스 분석**: + ``` + 케이스 1: 캐시 먼저 → DB 전 서버 종료 (❌ 불일치 발생) + 케이스 2: DB 먼저 → 캐시 전 서버 종료 (✅ DB가 SOT이므로 복구 가능) + ``` + +**면접용 모범 답변**: +``` +정합성이 중요하니 DB를 SOT로 두겠습니다. +캐시는 파생 데이터로 고려합니다. + +Write path에서 DB를 먼저 업데이트한 후 +캐시를 evict하거나 새로운 값으로 put합니다. + +DB 저장 후 캐시 업데이트 전 장애 발생 시 +캐시가 일시적으로 stale할 수 있습니다. +이를 완화하기 위해 TTL을 매우 짧게 설정합니다. + +대규모 시스템에서는 Outbox 패턴이나 CDC(Change Data Capture)를 통해 +비동기 갱신 이벤트를 처리할 수도 있습니다. + +금융/결제/재고처럼 정합성이 극도로 중요한 경우 +캐시를 쓰지 않고 동시 조회를 방어하는 방법을 고민합니다 +(이벤트 큐, 주문 대기열 등). +``` + +**참고**: 멘토가 실제 토스 면접에서 유사한 질문을 받았으며, 위와 비슷한 흐름으로 답변했다고 확인함. 면접관이 "Redis 클러스터 모드도 장애나면?" 등 꼬리 질문을 이어갔다고 함. + +--- + +### Q5. 커서 기반 페이징 전환 기준 (김요한) + +**질문**: 실무에서 OFFSET → 커서 기반으로 전환하는 기준? + +**멘토 답변**: + +**기본 원칙**: +``` +베이스는 커서 기반으로 만듦 +(어드민 페이지만 오프셋 고려) +``` + +**전환 판단 기준**: + +| 지표 | 기준 | 액션 | +|------|------|------| +| OFFSET 값 | 10만 초과 | 커서 기반 전환 | +| 페이지 조회 지연 | 100ms 초과 | 무거운 신호 | +| 뒷쪽 페이지 조회 | 많음 | 커서 전환 고려 | + +**실시간 API**: +``` += 무조건 커서 기반 페이징 +(OFFSET은 주기적 데이터 변화에 취약) +``` + +**어드민 기술**: 커서 + 가짜 오프셋 +``` +1. 커서 기반으로 조회 구현 +2. 프론트에서 커서를 저장 +3. 저장된 커서들로 가짜 오프셋 페이지 구현 + (예: 커서 10개 = 10 페이지) + +효과: 어드민은 페이지 표현 가능, 백엔드는 커서 활용 +``` + +--- + +### Q6. 인덱스 읽기/쓰기 트레이드오프 (양권모) + +**질문**: 인덱스가 많으면 쓰기가 느려지는데, 어디까지 감수하나? + +**멘토 답변**: +``` +읽기만 봄 +"조회 빠르면 장땡" +``` + +**전략**: +``` +쓰기가 잦은 컬럼 +→ 별도 테이블로 정규화하여 격리 +→ 트레이드오프 자체를 배제 + +예: 좋아요 수가 자주 바뀜 +→ 상품(product) 테이블에서 분리 +→ product_likes 테이블 생성 +→ 상품 조회 시 join +``` + +--- + +### Q7. 캐시 스탐피드와 Lock TTL 딜레마 (양권모) + +**질문**: Lock TTL이 너무 짧으면 다시 스탐피드, 너무 길면 응답 지연인데? + +**멘토 답변**: +``` +약간의 재발은 허용 (장애 케이스) +``` + +**Lock + Timeout + Retry 패턴**: +``` +1. 캐시 miss 발생 +2. GetLock(key) 시도 + ├─ 성공 + │ └─ DB 조회 → 캐시 set → lock release + └─ 실패 + └─ 일정 시간 대기 후 재시도 (캐시 조회 시도) + +효과: 한 명만 DB로, 나머지는 대기 후 캐시에서 조회 +``` + +**확률적 갱신**: +``` +TTL 만료 전 10% 확률로 DB 조회 → 캐시 갱신 +효과: 갱신이 점진적으로 발생, 스탐피드 완화 +``` + +**Pre-warming (멘토 선호) ⭐**: +``` +주기적으로 뒤에서 캐시를 미리 갱신 + +실제 사례 (멘토 경험): +- 블프(블랙프라이데이) 22시 오픈 전, 1분 전부터 "인간 프리워밍" 실행 +- 사람이 직접 사이트를 미리 스크롤해서 캐시를 채움 (DBA와 함께) +- 시간대별 오픈(10시, 11시, 12시)인 경우 배치로 API를 순서대로 미리 호출 +- 배치 작업: 매일 00시에 미리 갱신 + +효과: 사용자가 접근할 때 이미 캐시 준비됨 +``` + +--- + +### Q8. WHERE 컬럼 5개, 쿼리마다 조합 다를 때 복합인덱스 (양권모) + +**질문**: 모든 조합에 대해 인덱스를 만들 수는 없는데? + +**멘토 답변**: +``` +빈도(Frequency) 기준으로 판단 +인덱스가 불필요한 경우가 생각보다 많음 +``` + +**인덱스 효율성**: +``` +WHERE 조건이 전부 Equal(=) 연산 +→ 인덱스 태우기 최적 (모든 조합이 유효) + +WHERE 조건 중 Range 있음 +→ 순서가 매우 중요 (레인지를 맨 뒤로) +``` + +**인덱스의 진짜 강점**: +``` +ORDER BY (정렬) + +인덱스로 정렬이 되면 별도 filesort 불필요 +EXPLAIN extra에서 "Using filesort" 제거 가능 +``` + +--- + +### Q9. Materialized View 활용 케이스 (오민형) + +**질문**: 복잡한 JOIN을 미리 계산해두면 좋을 때는? + +**멘토 답변**: +``` +JOIN이 많아서 연산 비용이 높은 경우 +→ Pre-computed (미리 연산) + +예: 좋아요순 정렬 +상품 정보 + 좋아요 수 + 리뷰 수 +→ 모두 계산하면 무거움 +``` + +**비정규화 유지 + 배치 갱신**: +``` +product 테이블에 likes_count 컬럼 추가 +product_likes 테이블 변경 감지 → 배치/이벤트로 갱신 + +방법 1: TTL 기반 배치 +- 1분마다 Delta(변화량)만큼 증분 업데이트 + +방법 2: 이벤트 기반 +- 좋아요 발생 시 product.likes_count +=1 + +효과: +- product 조회 쿼리 간단화 (별도 join 불필요) +- 정렬 성능 향상 +``` + +--- + +### Q10. 상품 목록 캐싱 키 조합 문제 (오민형) + +**질문**: 검색 결과를 캐싱하려면 캐시 키를 어떻게 만들어야? + +**멘토 답변**: +``` +정보의 위계에 맞게 캐시 레이어를 나눔 +``` + +**계층형 캐시 구조**: + +``` +Layer 1: 검색 결과 캐시 (상품 ID 배열) +Key: search-result:v1:{brand}:{category}:{price_range}:{page} +Value: [productId1, productId2, ...] +TTL: 30초 (DB 방어, 자주 변함) + +Layer 2: 상품 정보 캐시 (상품 객체) +Key: product:v1:{product_id} +Value: {id, name, price, likes, ...} +TTL: 1시간 (자주 안 바뀜) +``` + +**유저 행동 패턴 최적화**: +``` +데이터: 2페이지 이상 접근이 거의 없음 +최적화: 1페이지만 캐싱 +→ 메모리 절약 + 관리 단순화 +``` + +**캐시 키 설계 예시** (참고: 편집자가 추가한 예시 코드): +```python +# 불충분한 예 (페이지별 모든 결과 캐시) +cache_key = f"search:{brand}:{category}:{page}" + +# 좋은 예 (검색 조건만 캐시) +cache_key = f"search-result:v1:{brand}:{category}:{price_range}:{sort_by}" +# value: 상품 ID 배열만 저장 (상세 정보는 product:v1:{id}에서 조회) +``` + +--- + +### Q11. Redis TTL 설정 기준 (P2) + +**질문**: Redis TTL을 언제 길게, 언제 짧게 가져가야 하나? + +**멘토 답변**: +``` +모니터링보다 도메인 특성/중요도 기준 +``` + +**TTL 설정 예시**: + +| 데이터 | 변경 빈도 | TTL | 이유 | +|--------|----------|-----|------| +| 어드민 메뉴 | 거의 안 바뀜 | 2주 | 안정적 데이터 | +| 상품 정보 | 자주 바뀜 | 30초 | DB 방어 | + +**캐시 효율성 모니터링**: +``` +Hit Rate (캐시 히트율) +- 높음: 캐시가 잘 작동 중 +- 낮음: + ├─ TTL이 너무 짧거나 + └─ 캐시가 원래 필요 없던 것 + +Hit Rate 낮으면 → 캐시 제거 고려 +``` + +--- + +### Q12. 멘토의 캐시 악몽 사례 (P2) + +**상황**: +``` +추천 모델 서버가 트래픽 과부하로 다운 +→ 담당자가 빈 배열 []을 캐시에 적재 +→ 결과: 추천 구좌에 제목만 나오고 상품 없음 +→ 사용자 경험 심각 악화 +``` + +**교훈**: +``` +에러 발생 시 잘못된 데이터를 캐시하지 않도록 처리! + +처리 방법: +1. 에러 시 캐시 저장 방지 (else절에서 return) +2. 또는 빈 배열 대신 명확한 에러 던짐 +3. 폴백 데이터 (fallback)가 있다면 사용 +4. 부분 캐시: 성공한 상품만 캐시, 나머지 스킵 +``` + +**인덱스 관련 에피소드**: +- 슬로우 쿼리가 찍히면 DBA한테 전화가 옴 +- 멘토: "모르는 번호라 보통 안 받는데, DM이 졸라 옴... 보면 내가 배포 승인한 거지 내가 짠 게 아님" +- 실무에서 인덱스/슬로우 쿼리 관리는 일상적으로 발생하는 이슈 + +**방어 코드 예시** (참고: 편집자가 추가한 예시 코드): +```python +def get_recommendations(user_id): + cache_key = f"recommendations:{user_id}" + + # 캐시 조회 + cached = redis.get(cache_key) + if cached: + return cached + + # DB 조회 (에러 처리 중요!) + try: + recommendations = recommendation_service.get(user_id) + if not recommendations: + # 빈 결과는 캐시하지 않음 + return fallback_recommendations + + # 정상 결과만 캐시 + redis.setex(cache_key, 3600, recommendations) + return recommendations + except Exception as e: + # 에러 발생 시 캐시 저장 금지 + logger.error(f"추천 로직 실패: {e}") + return fallback_recommendations +``` + +--- + +### Q13. 멀티테넌트 인덱스 전략 (양권모) + +**질문**: 고객별로 다른 쿼리 패턴이 있으면 인덱스 설계를 어떻게? + +**멘토 답변**: +``` +단일 인덱스 전략으로는 한계 명확 +``` + +**멀티테넌트 인덱스 접근법**: + +``` +Step 1: 공통 쿼리 기준 최소 인덱스 설계 + - 모든 고객사가 사용하는 쿼리 패턴 분석 + - 필수 인덱스 결정 + +Step 2: 고객사 특성에 맞게 선택적 인덱스 추가 + - A 고객사: 자주 category로 필터링 → idx_category + - B 고객사: 자주 price_range로 필터링 → idx_price + +Step 3: 자동화 구축 + - 슬로우 쿼리 분석 자동화 + - 월 1회 인덱스 추천 리포트 생성 + - 사용하지 않는 인덱스 자동 탐지 후 제거 +``` + +--- + +## 4. 이번 과제의 핵심 포인트 (멘토 정리) + +### 4.1 조회의 중요성 + +``` +조회 = 고객과 가장 맞닿아 있는 영역 +조회 성능 향상 = 고객 만족도 향상 +``` + +### 4.2 성능 개선 순서 + +``` +Step 1: 인덱스로 근본적 조회 성능 개선 + (쿼리 플랜 개선, rows 감소) + +Step 2: 그래도 DB 한계 있음 감지 + (Read QPS 높음, DB 응답 지연) + +Step 3: 캐시로 조회 요청 자체를 줄임 + (바구니 이론) + +최종 목표: "더 많은 고객을 받기 위한 준비" +``` + +### 4.3 핵심 메시지 + +``` +인덱스 없음 → "책 찾기를 위해 모든 책장 스캔" +인덱스 있음 → "목차에서 찾아서 바로 접근" +캐시 있음 → "이미 준비된 책자에서 손쉽게 꺼냄" +``` + +--- + +## 5. 멘토의 학습 조언 + +### 5.1 효율적인 학습 방법 + +``` +1. 혼자 10~20분 고민해보기 + → 손으로 코드 짜거나 설계도 그려보기 + +2. 안 되면 병렬로 도움 요청 + → 다른 문제도 함께 학습할 시간 확보 + +3. 학습 시간의 유한성 인식 + → 각 학습 단위를 효율적으로 사용 +``` + +### 5.2 마음가짐 + +``` +"지금 부끄러운 게 나중에 부끄러운 것보다 낫다" + +즉, 지금 모르는 것을 물어보는 것이 +나중에 잘못된 설계로 대규모 리팩토링하는 것보다 +훨씬 낫다는 의미 +``` + +--- + +## 6. 참고: 실무 적용 체크리스트 + +**참고**: 아래 체크리스트는 멘토링 내용을 바탕으로 편집자가 정리한 것입니다. + +### 인덱스 설계 체크리스트 + +- [ ] 실제 SELECT 쿼리 패턴 분석 (추측 아님) +- [ ] 복합 인덱스 순서 결정 (Leftmost prefix 고려) +- [ ] Range 조건 위치 최적화 (맨 뒤로) +- [ ] 커버링 인덱스 기회 발굴 +- [ ] EXPLAIN 분석 (rows, type, extra) +- [ ] 사용하지 않는 인덱스 주기적 제거 +- [ ] 읽기 레플리카 환경에서 인덱스 적극 활용 + +### 캐시 설계 체크리스트 + +- [ ] 캐시 전략 먼저 설계 (코드 아님) +- [ ] Layer 구조 결정 (L1 로컬 + L2 글로벌) +- [ ] Write-Around 패턴 구현 (DB → 캐시) +- [ ] 갱신 전략 명시 (TTL, 즉시 갱신, Pre-warming) +- [ ] 캐시 스탐피드 방어 (Lock, 확률 갱신, Pre-warming) +- [ ] 에러 케이스 처리 (잘못된 데이터 캐싱 금지) +- [ ] 모니터링 설정 (Hit Rate) +- [ ] TTL 설정 기준 문서화 + +--- + +**문서 작성일**: 2026-03-12 +**출처**: 부트캠프 멘토링 Round 5 (2026-03-11) - Alen 멘토 \ No newline at end of file diff --git a/docs/plans/2026-03-12-query-optimization.md b/docs/plans/2026-03-12-query-optimization.md new file mode 100644 index 000000000..ecaa34795 --- /dev/null +++ b/docs/plans/2026-03-12-query-optimization.md @@ -0,0 +1,499 @@ +# 상품 조회 성능 최적화 (Query Optimization) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 상품 목록 조회의 N+1 문제 해결, 복합 인덱스 적용, QueryDSL 동적 쿼리 도입, likes UNIQUE 제약 추가 + +**Architecture:** ProductRepositoryImpl에 QueryDSL 기반 동적 쿼리를 도입하여 brandId 필터 + 정렬을 단일 메서드로 통합. ProductFacade에서 N+1 쿼리를 IN 배치 조회로 교체. Entity에 @Index 어노테이션으로 복합 인덱스 선언. + +**Tech Stack:** Java 21, Spring Boot 3.4.4, QueryDSL 5.x (Jakarta), JPA @Index, MySQL 8.0 + +--- + +## 의사결정 기록 + +| # | 항목 | 결정 | 이유 | +|---|------|------|------| +| 1 | 인덱스 관리 | JPA `@Index` 어노테이션 | ddl-auto:create(local)와 자연스럽게 연동, 별도 마이그레이션 도구 불필요 | +| 2 | likes UNIQUE 제약 | `(user_id, product_id)` 추가 | DB 레벨 중복 방지, soft-delete restore 패턴과 호환 | +| 3 | 대량 데이터 시딩 | SQL 스크립트 (`docs/sql/seed-data.sql`) | 앱 독립적, 빠른 실행 | +| 4 | 동적 쿼리 | QueryDSL | brandId 유무에 따른 동적 WHERE, 향후 필터 확장 유연 | + +## 트레이드오프 분석 + +### 인덱스 설계 +- **비용**: `like_count` 업데이트마다 인덱스 재정렬 → 쓰기 성능 약간 저하 +- **이득**: 10만+ 데이터에서 Full Table Scan → Index Scan으로 조회 성능 대폭 개선 +- **결정**: 좋아요 업데이트 빈도 << 조회 빈도이므로 인덱스 적용이 유리 + +### N+1 해결: IN 배치 조회 vs JOIN +- **IN 배치 조회**: 기존 Entity 구조(brandId long 필드) 변경 없음, 3회 쿼리로 고정 +- **JOIN**: 단일 쿼리지만 Entity 연관관계 매핑 필요 → 기존 설계 변경 범위 큼 +- **결정**: IN 배치 조회 (기존 구조 유지, 외과적 변경) + +### QueryDSL vs JPA 메서드 분리 +- **QueryDSL**: 동적 WHERE 조합 가능, 정렬/필터 조합 증가 시 메서드 폭발 방지 +- **JPA 메서드**: 단순하지만 brandId × sortType × deletedAt 조합마다 메서드 필요 +- **결정**: QueryDSL (이미 의존성 존재, 향후 확장성) + +### UNIQUE 제약 + Soft-Delete +- **문제**: `(user_id, product_id)` UNIQUE인데 deleted_at이 NULL이 아닌 row 존재 시? +- **해결**: 현재 로직은 기존 row를 restore하므로 같은 (user_id, product_id) row가 1개만 존재 → UNIQUE 제약과 호환 +- **주의**: 물리 삭제(hard delete) 없이 soft-delete만 사용하는 한 문제 없음 + +--- + +## 멘토링 기준 적합성 분석 (2026-03-12 검사) + +> 멘토링 문서: `docs/mentoring` (2026-03-11, Alen 멘토) + +### 1. likeCount 테이블 분리 — 멘토 권장 vs 현재 설계 + +**멘토 권장 (Q6):** +> "좋아요 수가 자주 바뀜 → 상품(product) 테이블에서 분리 → product_likes 테이블 생성 → 상품 조회 시 join" +> "쓰기가 잦은 컬럼 → 별도 테이블로 정규화하여 격리 → 트레이드오프 자체를 배제" + +**현재 설계:** `ProductModel.likeCount` 필드로 product 테이블에 내장 + +| 비교 항목 | 분리 (멘토 권장) | 내장 (현재) | +|---|---|---| +| 인덱스 쓰기 부담 | product 인덱스 영향 없음 | 좋아요마다 product 인덱스 전체 갱신 | +| 조회 쿼리 | JOIN 또는 서브쿼리 필요 | 단일 테이블 조회, 정렬 인덱스 직접 활용 | +| 구현 복잡도 | 높음 (테이블 분리 + 동기화) | 낮음 (원자적 UPDATE 쿼리) | +| 정합성 | 배치/이벤트 기반 동기화 시 지연 가능 | 즉시 반영 | +| 적합 규모 | 대규모 (9천만 레코드급) | 중소 규모 | + +**현재 판단:** 프로젝트가 학습/부트캠프 단계이고 데이터 규모가 10만 이하이므로 내장 방식 유지. +단, **인덱스가 추가되면 좋아요 변경 시 인덱스 갱신 비용이 발생**하는 점은 인지. +실무에서 대규모 트래픽 시에는 멘토 권장대로 분리가 필요. + +**개선 시점 신호:** +- product 인덱스 4개 이상 + 좋아요 QPS > 100 → 분리 검토 +- EXPLAIN에서 좋아요 UPDATE 시 인덱스 갱신 지연 감지 시 + +--- + +### 2. 인덱스 설계 — 멘토링 체크리스트 대조 + +| 멘토링 체크리스트 | 현재 적합 여부 | 상세 | +|---|---|---| +| 실제 SELECT 쿼리 패턴 분석 | ✅ 분석 완료 | 4개 주요 조회 패턴 식별 | +| 복합 인덱스 순서 (Leftmost prefix) | ✅ 설계 반영 | `deleted_at` 선행 (WHERE 필수 조건) | +| Range 조건 위치 최적화 | ⚠️ 해당 없음 | 현재 Range 조건 미사용 (Equal + ORDER BY) | +| 커버링 인덱스 기회 | ❌ 미활용 | `SELECT *` 사용 중. 커버링 인덱스 불가 | +| EXPLAIN 분석 | ⏳ 시딩 SQL 준비됨 | `docs/sql/seed-data.sql`로 검증 예정 | +| ORDER BY 인덱스 (filesort 방지) | ✅ 설계 반영 | 정렬 컬럼이 인덱스 마지막에 위치 | + +**커버링 인덱스 미활용 사유:** +멘토 원문 (§1.2): "SELECT * 대신 필요한 컬럼만 명시하면 커버링 활용" +→ 현재 QueryDSL에서 `selectFrom(product)` = `SELECT *`. 목록 조회에서 `name`, `price`, `brandId`, `likeCount`만 필요하지만, + JPA Entity 특성상 부분 select는 DTO Projection이 필요 → 구현 복잡도 증가. +→ **현재 단계에서는 미적용. 성능 병목이 확인되면 DTO Projection + 커버링 인덱스 도입 검토.** + +--- + +### 3. N+1 해결 — 멘토링 관점 + +멘토링에서 N+1을 직접 언급하지는 않지만, Q3 "슬로우 쿼리 해결 판단 기준"에서: +> "EXPLAIN 분석 → 인덱스 설계 → 쿼리 개선 → 데이터 모델 변경 → 캐시 도입" + +N+1은 "쿼리 개선" 단계에 해당. 현재 `ProductFacade.getProducts()`에서: + +``` +AS-IS: 1(상품목록) + N(브랜드) + N(재고) = 2N+1 쿼리 +TO-BE: 1(상품목록) + 1(브랜드 IN) + 1(재고 IN) = 3 쿼리 (고정) +``` + +**멘토의 순서와 부합:** 인덱스(Task 1) → N+1 쿼리 개선(Task 4) → 캐시(별도 브랜치) + +--- + +### 4. findById + deletedAt 이중 체크 패턴 + +**현재 코드 (`ProductRepositoryImpl.findById`):** +```java +return productJpaRepository.findByIdAndDeletedAtIsNull(id); +``` + +**현재 코드 (`ProductService.getProduct`):** +```java +ProductModel product = productRepository.findById(id) + .orElseThrow(...); +if (product.getDeletedAt() != null) { // 이미 findById에서 deletedAt IS NULL 필터링됨 + throw new CoreException(ErrorType.NOT_FOUND, ...); +} +``` + +**문제:** `findById`가 이미 `deletedAtIsNull` 조건을 포함하므로, Service의 `getDeletedAt() != null` 체크는 **도달 불가 코드(dead code)**. +→ `getProduct()`의 deletedAt 체크를 제거하거나, `findById`를 deletedAt 필터 없이 조회하도록 변경 필요. +→ 현재 의미: `getProduct()`는 customer용(삭제 상품 불가), `getProductForAdmin()`은 admin용(삭제 포함). 그러나 둘 다 같은 `findById`(=deletedAtIsNull)를 호출 → **admin도 삭제 상품 조회 불가 버그.** + +**수정 방향 (개발자 확인 필요):** +- Option A: `findById`를 deletedAt 필터 없이 변경 → `getProduct()`에서 deletedAt 체크 유지 +- Option B: `findByIdIncludeDeleted()` 별도 메서드 추가 → admin용으로 사용 + +--- + +## Task 1: ProductModel에 복합 인덱스 추가 + +**Files:** +- Modify: `apps/commerce-api/src/main/java/com/loopers/domain/product/ProductModel.java` +- Test: `apps/commerce-api/src/test/java/com/loopers/domain/product/ProductModelTest.java` + +**Step 1: ProductModel @Table에 인덱스 선언 추가** + +```java +@Entity +@Table(name = "product", indexes = { + @Index(name = "idx_product_brand_deleted_like", columnList = "brand_id, deleted_at, like_count"), + @Index(name = "idx_product_deleted_like", columnList = "deleted_at, like_count"), + @Index(name = "idx_product_deleted_created", columnList = "deleted_at, created_at"), + @Index(name = "idx_product_deleted_price", columnList = "deleted_at, price") +}) +public class ProductModel extends BaseEntity { +``` + +**Step 2: 기존 단위 테스트 실행하여 깨지지 않는지 확인** + +Run: `./gradlew test --tests "ProductModelTest" -q` +Expected: PASS + +**Step 3: 커밋 대기 (개발자 승인 후)** + +--- + +## Task 2: LikeModel에 UNIQUE 제약 추가 + +**Files:** +- Modify: `apps/commerce-api/src/main/java/com/loopers/domain/like/LikeModel.java` + +**Step 1: LikeModel @Table에 uniqueConstraints 선언** + +```java +@Entity +@Table(name = "likes", uniqueConstraints = { + @UniqueConstraint(name = "uk_likes_user_product", columnNames = {"user_id", "product_id"}) +}) +public class LikeModel extends BaseEntity { +``` + +**Step 2: 기존 Like 관련 테스트 실행** + +Run: `./gradlew test --tests "*Like*" -q` +Expected: PASS + +**Step 3: 커밋 대기** + +--- + +## Task 3: BrandService에 배치 조회 메서드 추가 + +**Files:** +- Modify: `apps/commerce-api/src/main/java/com/loopers/application/brand/BrandService.java` +- Test: `apps/commerce-api/src/test/java/com/loopers/application/brand/BrandServiceTest.java` (신규 또는 기존 확장) + +**Step 1: 실패하는 테스트 작성** + +```java +@DisplayName("여러 브랜드를 ID 목록으로 일괄 조회한다") +@Test +void getByIds_returnsMapOfBrands() { + // given + BrandModel nike = brandRepository.save(new BrandModel("나이키", "스포츠")); + BrandModel adidas = brandRepository.save(new BrandModel("아디다스", "스포츠")); + List ids = List.of(nike.getId(), adidas.getId()); + + // when + Map result = brandService.getByIds(ids); + + // then + assertThat(result).hasSize(2); + assertThat(result.get(nike.getId()).name().value()).isEqualTo("나이키"); +} +``` + +**Step 2: 테스트 실행하여 실패 확인** + +Run: `./gradlew test --tests "*BrandServiceTest.getByIds*" -q` +Expected: FAIL (메서드 미존재) + +**Step 3: BrandService에 getByIds 구현** + +```java +@Transactional(readOnly = true) +public Map getByIds(List ids) { + return brandRepository.findAllByIdIn(ids) + .stream() + .collect(Collectors.toMap(BrandModel::getId, Function.identity())); +} +``` + +**Step 4: 테스트 실행하여 통과 확인** + +Run: `./gradlew test --tests "*BrandServiceTest.getByIds*" -q` +Expected: PASS + +**Step 5: 커밋 대기** + +--- + +## Task 4: ProductFacade N+1 해결 - 배치 조회 적용 + +**Files:** +- Modify: `apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java` +- Test: `apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeTest.java` + +**Step 1: 실패하는 테스트 작성 (배치 조회 검증)** + +```java +@DisplayName("상품 목록 조회 시 Brand/Stock을 배치로 조회한다") +@Test +void getProducts_usesBatchQuery() { + // given + Long brandId = createBrand("나이키"); + createProduct("상품1", 10000, brandId, 100); + createProduct("상품2", 20000, brandId, 50); + + // when + Page result = productFacade.getProducts(null, ProductSortType.CREATED_DESC, PageRequest.of(0, 10)); + + // then + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent().get(0).brandName()).isEqualTo("나이키"); + assertThat(result.getContent().get(0).stockStatus()).isNotNull(); +} +``` + +**Step 2: ProductFacade.getProducts() 리팩토링** + +AS-IS (N+1): +```java +return products.map(product -> { + String brandName = getBrandName(product.brandId()); // N회 + StockModel stock = stockService.getByProductId(product.getId()); // N회 + return ProductDetail.ofCustomer(product, brandName, stock.toStatus()); +}); +``` + +TO-BE (배치): +```java +public Page getProducts(Long brandId, ProductSortType sortType, Pageable pageable) { + Page products = productService.getProducts(brandId, sortType, pageable); + + List brandIds = products.getContent().stream() + .map(ProductModel::getBrandId).distinct().toList(); + List productIds = products.getContent().stream() + .map(ProductModel::getId).toList(); + + Map brandMap = brandService.getByIds(brandIds); + Map stockMap = stockService.getByProductIds(productIds); + + return products.map(product -> { + BrandModel brand = brandMap.get(product.getBrandId()); + String brandName = brand != null ? brand.name().value() : null; + StockModel stock = stockMap.get(product.getId()); + StockStatus status = stock != null ? stock.toStatus() : StockStatus.OUT_OF_STOCK; + return ProductDetail.ofCustomer(product, brandName, status); + }); +} +``` + +**Step 3: getProductsForAdmin()도 동일하게 리팩토링** + +**Step 4: 기존 E2E 테스트 전체 통과 확인** + +Run: `./gradlew test --tests "*ProductV1ApiE2ETest" -q` +Expected: PASS + +**Step 5: 커밋 대기** + +--- + +## Task 5: QueryDSL 동적 쿼리 도입 + +**Files:** +- Modify: `apps/commerce-api/src/main/java/com/loopers/domain/product/ProductRepository.java` +- Modify: `apps/commerce-api/src/main/java/com/loopers/infrastructure/product/ProductRepositoryImpl.java` +- Remove (사용하지 않게 되는 메서드): `ProductJpaRepository`의 일부 메서드 +- Test: `apps/commerce-api/src/test/java/com/loopers/application/product/ProductServiceIntegrationTest.java` + +**Step 1: 실패하는 테스트 작성 (brandId + LIKES_DESC 조합)** + +```java +@DisplayName("브랜드 필터 + 좋아요 순 정렬이 동시에 동작한다") +@Test +void getProducts_withBrandIdAndLikesSort() { + // given + Long nikeId = createBrand("나이키"); + ProductModel p1 = createProduct("에어맥스", 100000, nikeId, 10); + ProductModel p2 = createProduct("조던", 200000, nikeId, 10); + // p2에 좋아요 3개 부여 + productRepository.incrementLikeCount(p2.getId()); + productRepository.incrementLikeCount(p2.getId()); + productRepository.incrementLikeCount(p2.getId()); + + // when + Page result = productService.getProducts(nikeId, ProductSortType.LIKES_DESC, PageRequest.of(0, 10)); + + // then + assertThat(result.getContent().get(0).getName()).isEqualTo("조던"); + assertThat(result.getContent().get(0).getLikeCount()).isEqualTo(3); +} +``` + +**Step 2: ProductRepository 인터페이스에 통합 메서드 시그니처 확인** + +현재 `findAll(Pageable, ProductSortType)` 존재. brandId 파라미터를 추가: + +```java +Page findAll(Long brandId, Pageable pageable, ProductSortType sortType); +``` + +**Step 3: ProductRepositoryImpl에 QueryDSL 구현** + +```java +import com.querydsl.core.BooleanBuilder; +import com.querydsl.jpa.impl.JPAQueryFactory; +import com.loopers.domain.product.QProductModel; + +// 필드 추가 +private final JPAQueryFactory queryFactory; + +@Override +public Page findAll(Long brandId, Pageable pageable, ProductSortType sortType) { + QProductModel product = QProductModel.productModel; + + BooleanBuilder where = new BooleanBuilder(); + where.and(product.deletedAt.isNull()); + if (brandId != null) { + where.and(product.brandId.eq(brandId)); + } + + OrderSpecifier orderSpecifier = toOrderSpecifier(product, sortType); + + List content = queryFactory.selectFrom(product) + .where(where) + .orderBy(orderSpecifier) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch(); + + Long total = queryFactory.select(product.count()) + .from(product) + .where(where) + .fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0); +} + +private OrderSpecifier toOrderSpecifier(QProductModel product, ProductSortType sortType) { + return switch (sortType) { + case CREATED_DESC -> product.createdAt.desc(); + case PRICE_ASC -> product.price.value.asc(); + case PRICE_DESC -> product.price.value.desc(); + case LIKES_DESC -> product.likeCount.desc(); + }; +} +``` + +**Step 4: ProductService.getProducts() 시그니처 통합** + +```java +public Page getProducts(Long brandId, ProductSortType sortType, Pageable pageable) { + return productRepository.findAll(brandId, pageable, sortType); +} +``` + +**Step 5: 전체 테스트 통과 확인** + +Run: `./gradlew test --tests "*Product*" -q` +Expected: PASS + +**Step 6: 커밋 대기** + +--- + +## Task 6: 대량 데이터 시딩 SQL 작성 + +**Files:** +- Create: `docs/sql/seed-data.sql` + +**Step 1: 시딩 SQL 작성** + +- 브랜드 100개 +- 상품 100,000개 (브랜드당 1,000개) +- like_count: 0~10,000 랜덤 분포 +- price: 1,000~1,000,000 랜덤 분포 + +```sql +-- 브랜드 시딩 +INSERT INTO brand (name, description, created_at, updated_at) VALUES ... + +-- 상품 대량 시딩 (프로시저 활용) +DELIMITER // +CREATE PROCEDURE seed_products() +BEGIN + DECLARE i INT DEFAULT 1; + WHILE i <= 100000 DO + INSERT INTO product (name, description, price, brand_id, like_count, created_at, updated_at) + VALUES ( + CONCAT('상품_', i), + CONCAT('설명_', i), + FLOOR(1000 + RAND() * 999000), + FLOOR(1 + RAND() * 100), + FLOOR(RAND() * 10000), + NOW(), NOW() + ); + SET i = i + 1; + END WHILE; +END // +DELIMITER ; + +CALL seed_products(); +DROP PROCEDURE seed_products; +``` + +**Step 2: EXPLAIN 분석 쿼리도 함께 작성** + +```sql +-- 인덱스 적용 전/후 비교용 +EXPLAIN ANALYZE SELECT * FROM product +WHERE brand_id = 1 AND deleted_at IS NULL +ORDER BY like_count DESC LIMIT 20; + +EXPLAIN ANALYZE SELECT * FROM product +WHERE deleted_at IS NULL +ORDER BY like_count DESC LIMIT 20; +``` + +**Step 3: 커밋 대기** + +--- + +## Task 7: 전체 통합 검증 + +**Step 1: 전체 테스트 실행** + +Run: `./gradlew test -q` +Expected: 모든 테스트 PASS + +**Step 2: 빌드 확인** + +Run: `./gradlew build -q` +Expected: BUILD SUCCESSFUL + +**Step 3: 커밋 대기** + +--- + +## 실행 순서 요약 + +``` +Task 1: @Index 추가 (ProductModel) +Task 2: UNIQUE 제약 추가 (LikeModel) +Task 3: BrandService 배치 조회 메서드 +Task 4: ProductFacade N+1 해결 +Task 5: QueryDSL 동적 쿼리 도입 +Task 6: 대량 데이터 시딩 SQL +Task 7: 전체 통합 검증 +``` diff --git a/docs/plans/2026-03-12-redis-cache.md b/docs/plans/2026-03-12-redis-cache.md new file mode 100644 index 000000000..69a8ef656 --- /dev/null +++ b/docs/plans/2026-03-12-redis-cache.md @@ -0,0 +1,772 @@ +# Redis Cache 적용 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 상품 상세/목록 API에 Redis 캐시를 적용하여 DB 조회 부하를 줄이고 응답 속도를 개선한다. + +**Architecture:** Spring Cache Abstraction + RedisTemplate 하이브리드 방식. 상품 상세는 `@Cacheable`로 선언적 캐시, 상품 목록은 RedisTemplate으로 복합 키 + 패턴 기반 무효화 처리. Redis 장애 시 DB fallback을 보장하는 CacheErrorHandler 구현. + +**Tech Stack:** Spring Boot 3.4.4, Spring Cache, Redis 7.0 (Lettuce), Jackson JSON Serializer + +--- + +## Trade-off 분석 + +### 결정 1: 캐시 적용 레이어 — Facade vs Service + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| **Facade 레이어 (선택)** | 조합된 최종 결과를 캐싱 → Brand+Stock 조회도 캐시 히트 시 스킵 | 캐시 키에 여러 도메인 정보 포함, Facade 책임 증가 | +| Service 레이어 | 도메인별 독립 캐싱, 세밀한 제어 | 여러 서비스 결과를 조합하는 Facade에서 N+1 캐시 호출 가능 | + +**근거:** 현재 API의 병목은 상품+브랜드+재고를 조합하는 `ProductFacade`에서 발생. Facade 레이어에서 최종 DTO(`ProductDetail`)를 캐싱하면 하위 3개 서비스 호출을 모두 스킵할 수 있다. + +### 결정 2: 직렬화 — GenericJackson2Json vs Jackson2Json + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| **GenericJackson2Json (선택)** | 범용, 캐시마다 타입 설정 불필요 | JSON에 `@class` 타입 정보 포함 → 저장 공간 약간 증가 | +| Jackson2Json\ | 타입별 최적화, 깔끔한 JSON | 캐시마다 별도 설정 필요, 보일러플레이트 | + +**근거:** 캐시 대상이 `ProductDetail`(record)과 `Page` 두 종류. 범용 직렬화기로 충분하며, 타입별 설정의 복잡도가 이점 대비 크다. + +### 결정 3: 목록 캐시 무효화 — 패턴 삭제 vs 전체 flush + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| **패턴 삭제 (선택)** | 영향 범위 최소화, 다른 캐시 유지 | `KEYS` 명령 사용 시 성능 이슈 → `SCAN` 필요 | +| 전체 flush | 단순, 누락 없음 | 무관한 캐시까지 삭제, 캐시 히트율 급감 | + +**근거:** `product:list:*` 패턴으로 목록 캐시만 삭제. `SCAN` 기반으로 구현하여 Redis blocking 방지. + +### 결정 4: 캐시 미스 대응 — CacheErrorHandler vs try-catch + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| **CacheErrorHandler (선택)** | Spring Cache 전체에 일관 적용, 코드 침투 없음 | RedisTemplate 직접 사용 부분은 별도 처리 필요 | +| 개별 try-catch | 세밀한 에러 처리 | 보일러플레이트, 누락 위험 | + +**근거:** `@Cacheable` 사용하는 상품 상세는 CacheErrorHandler로 커버. RedisTemplate 직접 사용하는 상품 목록은 서비스 내 try-catch로 처리. + +--- + +## 멘토링 기준 적합성 분석 (2026-03-12 검사) + +> 멘토링 문서: `docs/mentoring` (2026-03-11, Alen 멘토) + +### 1. Cache-Aside 패턴 — 멘토 강조 사항 + +**멘토 원문 (§2.5):** +> "Cache-Aside만으로도 대부분 충분. 사본이 차 있을 때는 다 캐시에서 조회되므로 많은 방어가 됨" + +| 멘토 기준 | 플랜 설계 | 부합 | +|---|---|---| +| Cache-Aside 패턴 사용 | 상품 상세: `@Cacheable` (= Cache-Aside), 상품 목록: 수동 Cache-Aside | ✅ | +| DB를 SOT(Source of Truth)로 취급 | Write-Around: DB 먼저 → 캐시 evict/put | ✅ | +| 에러 시 잘못된 데이터 캐싱 방지 | `CacheErrorHandler` + try-catch 방어 | ✅ | + +### 2. 갱신 전략 — Evict(하수) vs Put(고수) + +**멘토 원문 (§2.3):** +> "변경 시 캐시 삭제(Evict) - 하수", "변경 시 캐시 덮어쓰기(Put) - 고수 ⭐" + +| 대상 | 현재 전략 | 멘토 권장 | 갭 | +|---|---|---|---| +| 상품 상세 수정 | `@CacheEvict` (evict) | Put(덮어쓰기) 권장 | ⚠️ | +| 상품 삭제 | `@CacheEvict` (evict) | evict OK | ✅ | +| 좋아요 토글 | evict (상세 + 목록) | put 가능하나 복잡 | ⚠️ | + +**evict vs put 트레이드오프:** + +| 비교 | Evict (현재) | Put (멘토 권장) | +|---|---|---| +| 구현 복잡도 | 낮음 | 높음 (수정 후 ProductDetail 재조립 필요) | +| 캐시 미스 | 삭제 후 다음 조회 시 DB 접근 | 없음 (즉시 새 값 적재) | +| 정합성 위험 | 낮음 (항상 DB에서 최신 조회) | 조립 로직 버그 시 캐시-DB 불일치 | +| 적합 상황 | 조회 QPS 낮을 때 | 조회 QPS 높아 miss 1회도 아까울 때 | + +**현재 판단:** 학습 프로젝트에서 Evict로 충분. 멘토도 "Cache-Aside만으로 대부분 충분" 강조. +→ 수정 후 즉시 재조회 패턴이 빈번하면 Put으로 전환 검토. + +### 3. TTL 설정 — 멘토 기준 대조 + +**멘토 원문 (Q11):** +> "모니터링보다 도메인 특성/중요도 기준" + +| 데이터 | 플랜 TTL | 멘토 예시 | 판단 | +|---|---|---|---| +| 상품 상세 | 10분 | "자주 바뀜" → 30초 | ⚠️ 과도할 수 있음 | +| 상품 목록 | 3분 | - | ✅ 적정 | +| 기본값 | 5분 | - | ✅ | + +→ 멘토의 30초는 대규모 트래픽 기준. 부트캠프에서는 변경 빈도 낮아 5~10분도 무방. +→ **Hit Rate 모니터링 후 조정 권장.** + +### 4. 캐시 스탬피드 방어 — 미구현 + +**멘토 원문 (§2.4):** Lock + Timeout + Retry, 확률적 갱신, Pre-warming + +**현재 플랜:** 스탬피드 방어 없음. + +**판단:** 멘토 원문 (§2.5): "트래픽이 더 많은 서비스에서만 추가 고민 필요" +→ 부트캠프에서 스탬피드 발생 가능성 극히 낮음. 미구현 유지. + +### 5. 계층형 캐시 (L1 + L2) — 미적용 + +**멘토 원문 (§2.2):** L1 로컬(짧은 TTL) + L2 글로벌(긴 TTL) 2단계 + +**현재 플랜:** Redis 단일 레이어. + +→ 단일 서버 환경에서 L1의 이점 제한적. 스케일아웃 시 도입 검토. + +### 6. 캐시 키 설계 — 멘토 Q10 대조 + +**멘토 권장:** 검색 결과(ID 배열) + 상품 정보(객체) 2레이어 분리 + +**현재 플랜:** `Page` 통째로 저장 (단일 레이어) + +| 비교 | 멘토 권장 (2레이어) | 현재 (단일) | +|---|---|---| +| 메모리 효율 | 높음 (중복 없음) | 낮음 (동일 상품 중복 저장) | +| 무효화 정밀도 | 상품 1개 → 해당 캐시만 | 상품 1개 → 모든 목록 삭제 | +| 구현 복잡도 | 높음 (2단계 조회) | 낮음 | + +→ 상품 수 적고 TTL 짧아 단일 레이어로 충분. 상품 > 1만 시 전환 검토. + +--- + +## Task 1: RedisCacheManager + CacheConfig 설정 + +**Files:** +- Create: `modules/redis/src/main/java/com/loopers/config/redis/RedisCacheConfig.java` +- Test: `apps/commerce-api/src/test/java/com/loopers/config/RedisCacheConfigTest.java` + +**Step 1: 실패 테스트 작성** + +```java +@SpringBootTest +@Import(RedisTestContainersConfig.class) +class RedisCacheConfigTest { + + @Autowired + private CacheManager cacheManager; + + @Test + void CacheManager_빈이_등록되어야_한다() { + assertThat(cacheManager).isNotNull(); + assertThat(cacheManager).isInstanceOf(RedisCacheManager.class); + } + + @Test + void 기본_TTL이_설정되어야_한다() { + RedisCacheManager redisCacheManager = (RedisCacheManager) cacheManager; + RedisCacheConfiguration config = redisCacheManager.getCacheConfigurations().get("productDetail"); + assertThat(config).isNotNull(); + } +} +``` + +**Step 2: 테스트 실행 → FAIL 확인** + +Run: `./gradlew :apps:commerce-api:test --tests "RedisCacheConfigTest"` +Expected: FAIL — `CacheManager` Bean 없음 + +**Step 3: 최소 구현** + +```java +@Configuration +@EnableCaching +public class RedisCacheConfig { + + @Bean + public CacheManager cacheManager(LettuceConnectionFactory connectionFactory) { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.activateDefaultTyping( + objectMapper.getPolymorphicTypeValidator(), + ObjectMapper.DefaultTyping.NON_FINAL + ); + + GenericJackson2JsonRedisSerializer serializer = + new GenericJackson2JsonRedisSerializer(objectMapper); + + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(serializer)) + .entryTtl(Duration.ofMinutes(5)) + .disableCachingNullValues(); + + RedisCacheConfiguration productDetailConfig = defaultConfig.entryTtl(Duration.ofMinutes(10)); + RedisCacheConfiguration productListConfig = defaultConfig.entryTtl(Duration.ofMinutes(3)); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(defaultConfig) + .withCacheConfiguration("productDetail", productDetailConfig) + .withCacheConfiguration("productList", productListConfig) + .build(); + } +} +``` + +**Step 4: 테스트 실행 → PASS 확인** + +Run: `./gradlew :apps:commerce-api:test --tests "RedisCacheConfigTest"` +Expected: PASS + +--- + +## Task 2: CacheErrorHandler 구현 + +**Files:** +- Create: `modules/redis/src/main/java/com/loopers/config/redis/CustomCacheErrorHandler.java` +- Modify: `modules/redis/src/main/java/com/loopers/config/redis/RedisCacheConfig.java` — `CachingConfigurer` 구현 추가 + +**Step 1: 실패 테스트 작성** + +```java +class CustomCacheErrorHandlerTest { + + private final CustomCacheErrorHandler handler = new CustomCacheErrorHandler(); + + @Test + void 캐시_조회_실패_시_예외를_삼키고_로그만_남긴다() { + // given + RuntimeException exception = new RuntimeException("Redis connection refused"); + + // when & then — 예외가 전파되지 않아야 함 + assertDoesNotThrow(() -> + handler.handleCacheGetError(exception, null, "testKey") + ); + } + + @Test + void 캐시_저장_실패_시_예외를_삼키고_로그만_남긴다() { + RuntimeException exception = new RuntimeException("Redis connection refused"); + + assertDoesNotThrow(() -> + handler.handleCachePutError(exception, null, "testKey", "testValue") + ); + } +} +``` + +**Step 2: 테스트 실행 → FAIL 확인** + +Run: `./gradlew :apps:commerce-api:test --tests "CustomCacheErrorHandlerTest"` + +**Step 3: 최소 구현** + +```java +@Slf4j +public class CustomCacheErrorHandler implements CacheErrorHandler { + + @Override + public void handleCacheGetError(RuntimeException e, Cache cache, Object key) { + log.warn("[Cache GET 실패] key={}, error={}", key, e.getMessage()); + } + + @Override + public void handleCachePutError(RuntimeException e, Cache cache, Object key, Object value) { + log.warn("[Cache PUT 실패] key={}, error={}", key, e.getMessage()); + } + + @Override + public void handleCacheEvictError(RuntimeException e, Cache cache, Object key) { + log.warn("[Cache EVICT 실패] key={}, error={}", key, e.getMessage()); + } + + @Override + public void handleCacheClearError(RuntimeException e, Cache cache) { + log.warn("[Cache CLEAR 실패] error={}", e.getMessage()); + } +} +``` + +`RedisCacheConfig`에 `CachingConfigurer` 구현 추가: + +```java +@Configuration +@EnableCaching +public class RedisCacheConfig implements CachingConfigurer { + // ... 기존 cacheManager 메서드 ... + + @Override + public CacheErrorHandler errorHandler() { + return new CustomCacheErrorHandler(); + } +} +``` + +**Step 4: 테스트 실행 → PASS 확인** + +Run: `./gradlew :apps:commerce-api:test --tests "CustomCacheErrorHandlerTest"` + +--- + +## Task 3: 상품 상세 API — @Cacheable 적용 + +**Files:** +- Modify: `apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java` — `getProduct()` 캐시 적용 +- Test: `apps/commerce-api/src/test/java/com/loopers/application/product/ProductFacadeCacheTest.java` + +**Step 1: 실패 테스트 작성** + +```java +@SpringBootTest +@Import(RedisTestContainersConfig.class) +class ProductFacadeCacheTest { + + @Autowired private ProductFacade productFacade; + @Autowired private CacheManager cacheManager; + // ... 필요한 의존성 주입 + 데이터 셋업 + + @AfterEach + void tearDown() { + cacheManager.getCache("productDetail").clear(); + } + + @Test + void 상품_상세_조회_시_캐시에_저장된다() { + // given — 상품 데이터 준비 + // when + productFacade.getProduct(productId); + // then + Cache.ValueWrapper cached = cacheManager.getCache("productDetail").get(productId); + assertThat(cached).isNotNull(); + } + + @Test + void 캐시_히트_시_DB를_조회하지_않는다() { + // given — 첫 조회로 캐시 적재 + productFacade.getProduct(productId); + + // when — 두 번째 조회 + ProductDetail result = productFacade.getProduct(productId); + + // then — 결과 정상 + (검증: 쿼리 로그 또는 spy로 서비스 호출 횟수 확인) + assertThat(result).isNotNull(); + } +} +``` + +**Step 2: 테스트 실행 → FAIL 확인** + +Run: `./gradlew :apps:commerce-api:test --tests "ProductFacadeCacheTest"` + +**Step 3: Facade에 @Cacheable 적용** + +```java +@Cacheable(cacheNames = "productDetail", key = "#productId") +@Transactional(readOnly = true) +public ProductDetail getProduct(Long productId) { + // 기존 로직 그대로 +} +``` + +**Step 4: 테스트 실행 → PASS 확인** + +Run: `./gradlew :apps:commerce-api:test --tests "ProductFacadeCacheTest"` + +--- + +## Task 4: 상품 상세 캐시 무효화 — @CacheEvict + +**Files:** +- Modify: `apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java` — `update()`, `delete()` 캐시 무효화 + +**Step 1: 실패 테스트 작성** + +```java +@Test +void 상품_수정_시_상세_캐시가_삭제된다() { + // given — 조회로 캐시 적재 + productFacade.getProduct(productId); + assertThat(cacheManager.getCache("productDetail").get(productId)).isNotNull(); + + // when — 상품 수정 + productFacade.update(productId, "변경된 이름", "변경된 설명", new Money(99999)); + + // then + assertThat(cacheManager.getCache("productDetail").get(productId)).isNull(); +} + +@Test +void 상품_삭제_시_상세_캐시가_삭제된다() { + // given + productFacade.getProduct(productId); + // when + productFacade.delete(productId); + // then + assertThat(cacheManager.getCache("productDetail").get(productId)).isNull(); +} +``` + +**Step 2: 테스트 실행 → FAIL 확인** + +**Step 3: 최소 구현** + +```java +@CacheEvict(cacheNames = "productDetail", key = "#productId") +@Transactional +public ProductDetail update(Long productId, String name, String description, Money price) { + // 기존 로직 +} + +@CacheEvict(cacheNames = "productDetail", key = "#productId") +public void delete(Long productId) { + // 기존 로직 +} +``` + +**Step 4: 테스트 실행 → PASS 확인** + +--- + +## Task 5: 상품 목록 API — RedisTemplate 캐시 적용 + +**Files:** +- Create: `apps/commerce-api/src/main/java/com/loopers/application/product/ProductCacheService.java` +- Modify: `apps/commerce-api/src/main/java/com/loopers/application/product/ProductFacade.java` — `getProducts()` 캐시 연동 +- Test: `apps/commerce-api/src/test/java/com/loopers/application/product/ProductCacheServiceTest.java` + +**Step 1: 실패 테스트 작성** + +```java +@SpringBootTest +@Import(RedisTestContainersConfig.class) +class ProductCacheServiceTest { + + @Autowired private ProductCacheService productCacheService; + @Autowired private RedisTemplate redisTemplate; + + @AfterEach + void tearDown() { + // SCAN으로 product:list:* 패턴 키 삭제 + } + + @Test + void 목록_캐시_저장_및_조회() { + // given + String key = "product:list:brand:1:sort:LIKES_DESC:page:0:size:20"; + // ... Page 준비 + + // when + productCacheService.putProductList(key, page); + Optional> cached = productCacheService.getProductList(key); + + // then + assertThat(cached).isPresent(); + assertThat(cached.get().getContent()).hasSize(expectedSize); + } + + @Test + void 패턴_기반_목록_캐시_삭제() { + // given — 여러 키 저장 + productCacheService.putProductList("product:list:brand:1:sort:LIKES_DESC:page:0:size:20", page1); + productCacheService.putProductList("product:list:brand:2:sort:PRICE_ASC:page:0:size:20", page2); + + // when + productCacheService.evictProductListAll(); + + // then — 모든 목록 캐시 삭제됨 + assertThat(productCacheService.getProductList("product:list:brand:1:sort:LIKES_DESC:page:0:size:20")).isEmpty(); + assertThat(productCacheService.getProductList("product:list:brand:2:sort:PRICE_ASC:page:0:size:20")).isEmpty(); + } +} +``` + +**Step 2: 테스트 실행 → FAIL 확인** + +**Step 3: 최소 구현** + +```java +@RequiredArgsConstructor +@Component +public class ProductCacheService { + + private static final String LIST_PREFIX = "product:list:"; + private static final Duration LIST_TTL = Duration.ofMinutes(3); + + private final RedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + public String buildListKey(Long brandId, ProductSortType sortType, int page, int size) { + return LIST_PREFIX + "brand:" + brandId + ":sort:" + sortType + ":page:" + page + ":size:" + size; + } + + public void putProductList(String key, Page page) { + try { + String json = objectMapper.writeValueAsString(page); + redisTemplate.opsForValue().set(key, json, LIST_TTL); + } catch (Exception e) { + log.warn("[목록 캐시 저장 실패] key={}, error={}", key, e.getMessage()); + } + } + + public Optional> getProductList(String key) { + try { + String json = redisTemplate.opsForValue().get(key); + if (json == null) return Optional.empty(); + // 역직렬화 처리 + return Optional.of(/* deserialized page */); + } catch (Exception e) { + log.warn("[목록 캐시 조회 실패] key={}, error={}", key, e.getMessage()); + return Optional.empty(); + } + } + + public void evictProductListAll() { + try { + Set keys = scanKeys(LIST_PREFIX + "*"); + if (!keys.isEmpty()) { + redisTemplate.delete(keys); + } + } catch (Exception e) { + log.warn("[목록 캐시 삭제 실패] error={}", e.getMessage()); + } + } + + private Set scanKeys(String pattern) { + Set keys = new HashSet<>(); + ScanOptions options = ScanOptions.scanOptions().match(pattern).count(100).build(); + try (Cursor cursor = redisTemplate.scan(options)) { + while (cursor.hasNext()) { + keys.add(cursor.next()); + } + } + return keys; + } +} +``` + +**Step 4: Facade에 캐시 서비스 연동** + +```java +// ProductFacade.getProducts() 수정 +@Transactional(readOnly = true) +public Page getProducts(Long brandId, ProductSortType sortType, Pageable pageable) { + String cacheKey = productCacheService.buildListKey(brandId, sortType, pageable.getPageNumber(), pageable.getPageSize()); + + Optional> cached = productCacheService.getProductList(cacheKey); + if (cached.isPresent()) return cached.get(); + + Page result = /* 기존 DB 조회 로직 */; + + productCacheService.putProductList(cacheKey, result); + return result; +} +``` + +**Step 5: 테스트 실행 → PASS 확인** + +--- + +## Task 6: 좋아요 토글 시 캐시 무효화 + +**Files:** +- Modify: `apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java` — 좋아요 변경 시 캐시 무효화 +- Test: 기존 좋아요 테스트에 캐시 무효화 검증 추가 + +**Step 1: 실패 테스트 작성** + +```java +@Test +void 좋아요_등록_시_해당_상품_상세_캐시와_목록_캐시가_삭제된다() { + // given — 상품 조회로 캐시 적재 + productFacade.getProduct(productId); + + // when — 좋아요 + likeTransactionService.doLike(userId, productId); + + // then + assertThat(cacheManager.getCache("productDetail").get(productId)).isNull(); + // 목록 캐시도 비어야 함 +} +``` + +**Step 2: 테스트 실행 → FAIL 확인** + +**Step 3: 최소 구현** + +`LikeTransactionService`에 캐시 무효화 추가: + +```java +@Transactional +public void doLike(Long userId, Long productId) { + // ... 기존 로직 ... + if (result.countChanged()) { + productService.incrementLikeCount(productId); + evictProductCaches(productId); + } +} + +@Transactional +public void doUnlike(Long userId, Long productId) { + // ... 기존 로직 ... + productService.decrementLikeCount(activeLike.get().productId()); + evictProductCaches(productId); +} + +private void evictProductCaches(Long productId) { + cacheManager.getCache("productDetail").evict(productId); + productCacheService.evictProductListAll(); +} +``` + +**Step 4: 테스트 실행 → PASS 확인** + +--- + +## Task 7: 통합 E2E 테스트 + 성능 비교 + +**Files:** +- Modify: `apps/commerce-api/src/test/java/com/loopers/interfaces/api/product/ProductV1ApiE2ETest.java` — 캐시 동작 E2E 검증 +- Create: `.http/cache-test.http` — 수동 성능 비교용 + +**Step 1: E2E 테스트 추가** + +```java +@Test +void 상품_상세_두번_조회_시_캐시에서_응답한다() { + // given — 상품 생성 + // when — 같은 상품 2회 조회 + // then — 두 번 모두 200 OK, 같은 결과 +} + +@Test +void 상품_수정_후_조회하면_변경된_데이터가_반환된다() { + // given — 상품 생성 + 조회(캐시 적재) + // when — 상품 수정 + 재조회 + // then — 변경된 이름/설명이 반환 +} +``` + +**Step 2: .http 파일 작성** + +```http +### 캐시 미스 — 첫 번째 조회 +GET http://localhost:8080/api/v1/products/1 + +### 캐시 히트 — 두 번째 조회 (응답시간 비교) +GET http://localhost:8080/api/v1/products/1 + +### 목록 조회 — 캐시 미스 +GET http://localhost:8080/api/v1/products?brandId=1&sort=LIKES_DESC&page=0&size=20 + +### 목록 조회 — 캐시 히트 +GET http://localhost:8080/api/v1/products?brandId=1&sort=LIKES_DESC&page=0&size=20 +``` + +**Step 3: 전체 테스트 실행** + +Run: `./gradlew :apps:commerce-api:test` +Expected: 기존 테스트 + 캐시 테스트 모두 PASS + +--- + +## 전체 실행 순서 요약 + +| Task | 내용 | 핵심 파일 | 상태 | +|------|------|----------|------| +| 1 | RedisCacheManager + Config | `RedisCacheConfig.java` | ✅ 완료 | +| 2 | CacheErrorHandler | `CustomCacheErrorHandler.java` | ✅ 완료 | +| 3 | 상품 상세 @Cacheable | `ProductFacade.getProduct()` | ✅ 완료 | +| 4 | 상품 상세 @CacheEvict | `ProductFacade.update()/delete()` | ✅ 완료 | +| 5 | 상품 목록 RedisTemplate 캐시 | `ProductListCacheService.java` | ✅ 완료 | +| 6 | 좋아요 토글 캐시 무효화 | `LikeTransactionService.java` | ✅ 완료 | +| 7 | E2E 테스트 + 성능 비교 | E2E 테스트 + `.http/cache-test.http` | ✅ 완료 | + +--- + +## 멘토링 기준 대조 — 현재 트레이드오프 현황 + +> 기준: `docs/mentoring` (Round-5 멘토링, Alen 멘토, 2026-03-11) + +### 현재 구현이 멘토 기준에 부합하는 항목 + +| 멘토 기준 | 현재 구현 | 근거 | +|----------|----------|------| +| 캐시 전략 먼저 설계 (코드 아님) | ✅ research.md → plan.md → 구현 | 멘토: "캐시는 항상 먼저 설계함" (Q1) | +| SOT = DB, 캐시 = 파생 데이터 | ✅ DB 먼저 업데이트 후 캐시 처리 | 멘토: Write-Around 패턴 권장 (Q4) | +| 에러 시 잘못된 데이터 캐싱 금지 | ✅ `CacheErrorHandler` + `disableCachingNullValues` + try-catch | 멘토: 빈 배열 캐싱 사고 사례 (Q12) | +| Redis 장애 시 서비스 지속 | ✅ `CustomCacheErrorHandler`가 예외 삼김 → DB fallback | 멘토: 캐시는 보조, DB가 SOT | +| Cache-Aside 패턴 | ✅ 캐시 미스 → DB 조회 → 캐시 저장 | 멘토: "Cache-Aside만으로도 대부분 충분" (2.5절) | + +### 현재 구현이 멘토 기준에 미달하는 항목 (의도적 트레이드오프) + +#### TO-1. 갱신 전략 — Evict(현재) vs Put(멘토 권장) + +| | Evict (현재) | Put (멘토 권장) | +|--|-------------|----------------| +| **방식** | DB 업데이트 후 캐시 삭제 | DB 업데이트 후 캐시에 새 값 덮어쓰기 | +| **장점** | 단순, 정합성 보장 | DB 재조회 불필요, 캐시 히트율 유지 | +| **단점** | 다음 조회가 DB로 감 (캐시 미스 1회) | 캐시에 넣는 값 구성 로직 필요, 복잡도 증가 | +| **멘토 평가** | "하수" (2.3절) | "고수" (2.3절) | + +**현재 선택 근거**: 상품 수정/삭제 빈도가 조회 대비 극히 낮음. Evict 후 1회 캐시 미스의 비용이 Put 구현 복잡도 대비 낮다고 판단. + +**개선 시점**: 수정 API 호출 직후 조회 급증 패턴이 관측될 때 → `@CachePut`으로 전환. + +#### TO-2. 목록 캐시 구조 — 전체 객체(현재) vs ID 배열 계층 분리(멘토 권장) + +| | 전체 객체 캐싱 (현재) | ID 배열 + 상품 정보 분리 (멘토 권장) | +|--|---------------------|--------------------------------------| +| **방식** | `List` 전체를 JSON으로 저장 | Layer 1: 검색 결과 ID 배열 (TTL 30초), Layer 2: 상품 정보 (TTL 1시간) | +| **장점** | 단순, 한 번의 Redis 호출로 완결 | 메모리 효율, 상품 정보 재사용, 세밀한 TTL | +| **단점** | 중복 저장 (같은 상품이 여러 목록에), 메모리 낭비 | 2번 Redis 호출 (ID 조회 → 상품 조회), 구현 복잡도 높음 | +| **멘토 평가** | 언급 없음 (암묵적 비권장) | "정보의 위계에 맞게 캐시 레이어를 나눔" (Q10) | + +**현재 선택 근거**: 상품 수가 10만 수준, 목록 페이지 크기 20건. 중복 저장의 메모리 비용보다 구현 단순성이 현 단계에서 우선. + +**개선 시점**: 캐시 메모리 사용량이 Redis 70% 초과할 때 → 계층 분리 전환. + +#### TO-3. TTL 설정 — 현재 vs 멘토 기준 + +| 캐시 | 현재 TTL | 멘토 기준 | 차이 | +|------|---------|----------|------| +| 상품 상세 | 10분 | 30초 (자주 바뀜) ~ 1시간 (안 바뀜) | 멘토 기준 "상품 정보는 자주 바뀜 → 30초" (Q11)와 불일치 | +| 상품 목록 | 3분 | 30초 (DB 방어) | 6배 차이 | + +**현재 선택 근거**: 현재 트래픽이 낮은 학습 프로젝트. stale data 허용 범위가 넓어 긴 TTL로 캐시 히트율 극대화. + +**개선 시점**: 실 트래픽 투입 시 도메인 특성에 맞게 TTL 조정. 모니터링(Hit Rate) 기반으로 결정. + +#### TO-4. 캐시 스탬피드 방어 — 미구현 + +| 방어 방법 | 구현 여부 | 멘토 평가 | +|----------|----------|----------| +| Lock + Timeout + Retry | ❌ 미구현 | 기본 방어 (Q7) | +| 확률적 갱신 | ❌ 미구현 | 보완 방어 (Q7) | +| Pre-warming | ❌ 미구현 | 멘토 선호 ⭐ (Q7) | + +**현재 선택 근거**: 멘토 언급 — "Cache-Aside만으로도 대부분 충분" (2.5절). 현재 트래픽 수준에서 스탬피드 발생 가능성 극히 낮음. + +**개선 시점**: Read QPS가 높아져 동시 캐시 미스가 관측될 때 → Lock 패턴 우선 적용. + +#### TO-5. 캐시 히트율 모니터링 — 미구현 + +**멘토 기준**: "Hit Rate 낮으면 → 캐시 제거 고려" (Q11) + +**현재**: 모니터링 없음. 캐시가 실제로 유효한지 데이터 기반 판단 불가. + +**개선 시점**: Prometheus + Grafana 연동 시 `cache.gets`, `cache.puts`, `cache.evictions` 메트릭 노출. + +#### TO-6. 페이지별 캐싱 범위 — 전체 페이지(현재) vs 1페이지만(멘토 권장) + +**멘토**: "2페이지 이상 접근이 거의 없음 → 1페이지만 캐싱" (Q10) + +**현재**: 모든 페이지를 동일하게 캐싱. + +**현재 선택 근거**: 페이지별 접근 패턴 데이터 없음. 데이터 확보 후 결정. + +**개선 시점**: 페이지별 접근 로그 분석 후 2페이지 이상 캐싱 제거 검토. + +### 개선 우선순위 (멘토 기준 중요도) + +| 순위 | 항목 | 이유 | +|------|------|------| +| 1 | TO-1. Evict → Put 전환 | 멘토가 명시적으로 "하수 vs 고수" 구분 | +| 2 | TO-3. TTL 조정 (10분 → 도메인 기반) | 멘토: 도메인 특성/중요도 기준 | +| 3 | TO-2. 목록 캐시 계층 분리 | 멘토가 Q10에서 구체적 구조 제시 | +| 4 | TO-5. 히트율 모니터링 | 멘토: "Hit Rate 낮으면 캐시 제거 고려" | +| 5 | TO-4. 스탬피드 방어 | 멘토: "Cache-Aside만으로도 충분" → 후순위 | +| 6 | TO-6. 1페이지만 캐싱 | 접근 패턴 데이터 필요 → 가장 후순위 | diff --git a/docs/plans/2026-03-19-payment-domain.md b/docs/plans/2026-03-19-payment-domain.md new file mode 100644 index 000000000..286b25421 --- /dev/null +++ b/docs/plans/2026-03-19-payment-domain.md @@ -0,0 +1,1746 @@ +# Payment Domain Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 주문에 대한 순수 결제 도메인을 구현한다. 장애 대응(CircuitBreaker, Retry, Fallback)은 이 단계에서 제외하고, 결제 엔티티 + PG 클라이언트 + 결제 API + 콜백 수신까지를 범위로 한다. + +**Architecture:** Payment를 Order와 별도 엔티티로 분리하여 1주문:N결제 구조를 지원한다. PG 호출은 트랜잭션 밖에서 수행하여 DB 커넥션 점유를 방지한다. 콜백 수신 시 결제 상태를 업데이트하고 주문 상태를 연동한다. + +**Tech Stack:** Java 21, Spring Boot 3.4.4, JPA, RestTemplate, MySQL 8.0, JUnit 5, TestContainers + +--- + +## 미결사항 결정 및 근거 + +### 1. Order-Payment 관계: **단순 ID 참조** (선택) + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| JPA `@ManyToOne` | 객체 그래프 탐색 편리, JOIN FETCH 가능 | 양방향 참조 시 순환 위험, 생명주기가 다른 엔티티 간 강결합 | +| **단순 ID 참조** | 느슨한 결합, Aggregate 경계 명확, 기존 프로젝트 패턴과 일치 | 조회 시 별도 쿼리 필요 | + +**근거:** 프로젝트 전체가 ID 참조 패턴을 따르고 있다(OrderItemModel.orderId, OrderModel.couponIssueId 등). Payment와 Order는 생명주기가 다르다(주문 1건에 결제 N번 시도 가능). Aggregate 경계를 존중하는 것이 이 프로젝트의 설계 철학에 부합한다. + +### 2. OrderStatus 확장: **PAYMENT_PENDING, PAYMENT_FAILED 추가** (선택) + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| 기존 유지 (Payment 상태로만 관리) | OrderStatus 변경 없음, 영향 범위 최소 | 주문 상태만 보고는 결제 진행 여부를 알 수 없음 | +| **OrderStatus 확장** | 주문 상태만으로 전체 흐름 파악 가능, 조회 쿼리 단순 | OrderStatus 변경에 따른 기존 코드 영향 검토 필요 | + +**근거:** 사용자가 "내 주문 목록"을 조회할 때, 주문 상태만으로 "결제 대기 중"인지 알 수 있어야 한다. Payment 테이블을 매번 JOIN하는 것보다 OrderStatus에 결제 흐름을 반영하는 것이 직관적이다. 기존 테스트에서 `CREATED` 상태만 검증하므로 하위 호환성 문제 없다. + +**상태 전이:** +``` +CREATED → PAYMENT_PENDING → CONFIRMED → SHIPPING → DELIVERED + → PAYMENT_FAILED → (재시도 가능) + → CANCELLED +``` + +### 3. 결제 금액: **Order의 finalAmount 사용** (선택) + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| **Order의 finalAmount** | 단일 진실 공급원, 금액 불일치 방지 | 주문-결제 간 의존성 | +| Request에서 받기 | 유연함 | 프론트와 서버의 금액 불일치 가능성, 검증 로직 추가 필요 | + +**근거:** 결제 금액은 주문의 최종 금액(할인 적용 후)이어야 한다. 클라이언트에서 금액을 보내면 조작 가능성이 있고, 서버에서 재계산하면 Order에서 가져오는 것과 동일하다. 따라서 Order.finalAmount를 신뢰 기반으로 사용한다. + +### 4. 카드번호 저장: **마스킹 저장** (선택) + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| 전체 저장 | 구현 단순 | PCI-DSS 위반, 보안 리스크 | +| **마스킹 저장** | 사용자에게 "어떤 카드로 결제했는지" 표시 가능, 보안 확보 | 마스킹 로직 필요 | +| 미저장 | 보안 최상 | 결제 내역에서 카드 정보 확인 불가 | + +**근거:** 실무에서는 PCI-DSS 때문에 전체 저장이 불가하다. 하지만 "1234-****-****-1451" 형태로 앞4자리+뒤4자리를 보여주는 것은 일반적인 UX이다. PG에 보낼 때는 전체 번호를 전송하되, DB에는 마스킹된 값만 저장한다. + +### 5. HTTP 클라이언트: **RestTemplate** (선택) + +| 선택지 | 장점 | 단점 | +|--------|------|------| +| **RestTemplate** | 과제 명시, 학습 비용 낮음, 별도 의존성 없음 | 유지보수 모드 (deprecated 방향) | +| RestClient | 현대적 API, Spring 6.1+ | 과제 명시와 다소 벗어남 | +| FeignClient | 선언적, 깔끔 | spring-cloud 의존성 추가 필요 | + +**근거:** 과제 요구사항에 "RestTemplate 혹은 FeignClient"로 명시되어 있다. RestTemplate이 가장 단순하고 별도 의존성이 필요 없다. PG 호출은 결제 요청 + 상태 확인 2~3개 엔드포인트뿐이므로 FeignClient의 선언적 장점이 크지 않다. + +### 6. pg-simulator 확보: **별도 결정** (이 Plan 범위 밖) + +pg-simulator는 별도 앱으로 실행하는 것이 전제이다. 이 Plan에서는 commerce-api 내부 코드만 다루며, PG 클라이언트의 통합 테스트에서는 MockRestServiceServer를 활용한다. + +### 7. 콜백 엔드포인트: **1단계에 포함** (선택) + +**근거:** 비동기 결제의 핵심은 "요청 → 콜백 수신 → 상태 업데이트"이다. 콜백 없이 결제 도메인을 만들면 PENDING 상태에서 영원히 멈추는 반쪽짜리 구현이 된다. 순수 결제 흐름의 완결성을 위해 콜백 수신까지 포함한다. 단, 콜백 미수신 시 폴링/복구 로직은 2~3단계로 미룬다. + +--- + +## 구현 범위 & 패키지 구조 + +``` +com.loopers/ +├── domain/payment/ +│ ├── PaymentModel.java # 결제 엔티티 +│ ├── PaymentStatus.java # PENDING | SUCCESS | FAILED | CANCELLED +│ ├── CardType.java # SAMSUNG | KB | HYUNDAI +│ └── PaymentRepository.java # Repository 인터페이스 +├── domain/order/ +│ └── OrderStatus.java # PAYMENT_PENDING, PAYMENT_FAILED 추가 +│ └── OrderModel.java # 상태 전이 메서드 추가 +├── application/payment/ +│ ├── PaymentService.java # 결제 도메인 서비스 +│ ├── PaymentFacade.java # 결제 유스케이스 (주문 조회 + PG 호출 + 상태 관리) +│ ├── PaymentInfo.java # Application DTO +│ └── PaymentCommand.java # 결제 요청 Command +├── infrastructure/payment/ +│ ├── PaymentRepositoryImpl.java # Repository 구현체 +│ ├── PaymentJpaRepository.java # JPA Repository +│ ├── PgClient.java # PG HTTP 클라이언트 +│ └── dto/ +│ ├── PgPaymentRequest.java # PG 요청 DTO +│ └── PgPaymentResponse.java # PG 응답 DTO +├── interfaces/api/payment/ +│ ├── PaymentV1Controller.java # 결제 API +│ ├── PaymentV1ApiSpec.java # Swagger 인터페이스 +│ └── PaymentV1Dto.java # Request/Response DTO +└── config/ + └── PgClientConfig.java # RestTemplate Bean + PG 설정 +``` + +**application.yml 추가:** +```yaml +pg: + base-url: http://localhost:8082 + callback-url: http://localhost:8080/api/v1/payments/callback + timeout: + connect: 1000 + read: 3000 +``` + +--- + +## Task 1: PaymentStatus Enum + CardType Enum + +**Files:** +- Create: `apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java` +- Create: `apps/commerce-api/src/main/java/com/loopers/domain/payment/CardType.java` + +**Step 1: PaymentStatus 작성** + +```java +package com.loopers.domain.payment; + +public enum PaymentStatus { + PENDING, + SUCCESS, + FAILED, + CANCELLED +} +``` + +**Step 2: CardType 작성** + +```java +package com.loopers.domain.payment; + +public enum CardType { + SAMSUNG, + KB, + HYUNDAI +} +``` + +**Step 3: 커밋** + +```bash +git add apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java \ + apps/commerce-api/src/main/java/com/loopers/domain/payment/CardType.java +git commit -m "feat: PaymentStatus, CardType Enum 추가" +``` + +--- + +## Task 2: PaymentModel 엔티티 (Red → Green) + +**Files:** +- Create: `apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentModelTest.java` +- Create: `apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentModel.java` + +**Step 1: 실패하는 테스트 작성** + +```java +package com.loopers.domain.payment; + +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class PaymentModelTest { + + @DisplayName("결제 생성") + @Nested + class Create { + + @DisplayName("유효한 정보로 생성하면 상태가 PENDING이다") + @Test + void createsWithPendingStatus() { + // given + Long orderId = 1L; + Long userId = 1L; + CardType cardType = CardType.SAMSUNG; + String maskedCardNo = "1234-****-****-1451"; + Money amount = new Money(50000); + + // when + PaymentModel payment = new PaymentModel(orderId, userId, cardType, maskedCardNo, amount); + + // then + assertAll( + () -> assertThat(payment.orderId()).isEqualTo(orderId), + () -> assertThat(payment.userId()).isEqualTo(userId), + () -> assertThat(payment.cardType()).isEqualTo(CardType.SAMSUNG), + () -> assertThat(payment.maskedCardNo()).isEqualTo(maskedCardNo), + () -> assertThat(payment.amount()).isEqualTo(amount), + () -> assertThat(payment.status()).isEqualTo(PaymentStatus.PENDING), + () -> assertThat(payment.transactionKey()).isNull(), + () -> assertThat(payment.failureReason()).isNull() + ); + } + + @DisplayName("orderId가 null이면 예외가 발생한다") + @Test + void throwsWhenOrderIdNull() { + CoreException result = assertThrows(CoreException.class, + () -> new PaymentModel(null, 1L, CardType.SAMSUNG, "1234-****-****-1451", new Money(50000))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("userId가 null이면 예외가 발생한다") + @Test + void throwsWhenUserIdNull() { + CoreException result = assertThrows(CoreException.class, + () -> new PaymentModel(1L, null, CardType.SAMSUNG, "1234-****-****-1451", new Money(50000))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("cardType이 null이면 예외가 발생한다") + @Test + void throwsWhenCardTypeNull() { + CoreException result = assertThrows(CoreException.class, + () -> new PaymentModel(1L, 1L, null, "1234-****-****-1451", new Money(50000))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("maskedCardNo가 blank이면 예외가 발생한다") + @Test + void throwsWhenCardNoBlank() { + CoreException result = assertThrows(CoreException.class, + () -> new PaymentModel(1L, 1L, CardType.SAMSUNG, " ", new Money(50000))); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + + @DisplayName("amount가 null이면 예외가 발생한다") + @Test + void throwsWhenAmountNull() { + CoreException result = assertThrows(CoreException.class, + () -> new PaymentModel(1L, 1L, CardType.SAMSUNG, "1234-****-****-1451", null)); + assertThat(result.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST); + } + } + + @DisplayName("상태 전이") + @Nested + class StatusTransition { + + private PaymentModel createPayment() { + return new PaymentModel(1L, 1L, CardType.SAMSUNG, "1234-****-****-1451", new Money(50000)); + } + + @DisplayName("transactionKey를 설정할 수 있다") + @Test + void assignsTransactionKey() { + PaymentModel payment = createPayment(); + payment.assignTransactionKey("20250816:TR:9577c5"); + assertThat(payment.transactionKey()).isEqualTo("20250816:TR:9577c5"); + } + + @DisplayName("PENDING → SUCCESS 전이가 가능하다") + @Test + void transitionsToSuccess() { + PaymentModel payment = createPayment(); + payment.markSuccess(); + assertThat(payment.status()).isEqualTo(PaymentStatus.SUCCESS); + } + + @DisplayName("PENDING → FAILED 전이가 가능하다") + @Test + void transitionsToFailed() { + PaymentModel payment = createPayment(); + payment.markFailed("한도 초과"); + assertAll( + () -> assertThat(payment.status()).isEqualTo(PaymentStatus.FAILED), + () -> assertThat(payment.failureReason()).isEqualTo("한도 초과") + ); + } + + @DisplayName("SUCCESS 상태에서 다시 SUCCESS로 전이해도 멱등하다") + @Test + void idempotentSuccess() { + PaymentModel payment = createPayment(); + payment.markSuccess(); + payment.markSuccess(); // 중복 콜백 대응 + assertThat(payment.status()).isEqualTo(PaymentStatus.SUCCESS); + } + + @DisplayName("SUCCESS 상태에서 FAILED로 전이하면 예외가 발생한다") + @Test + void throwsWhenSuccessToFailed() { + PaymentModel payment = createPayment(); + payment.markSuccess(); + assertThrows(CoreException.class, () -> payment.markFailed("오류")); + } + + @DisplayName("FAILED 상태에서 SUCCESS로 전이하면 예외가 발생한다") + @Test + void throwsWhenFailedToSuccess() { + PaymentModel payment = createPayment(); + payment.markFailed("한도 초과"); + assertThrows(CoreException.class, () -> payment.markSuccess()); + } + } +} +``` + +**Step 2: 테스트 실행 → 실패 확인** + +```bash +./gradlew test --tests "PaymentModelTest" -x compileTestJava +``` +Expected: FAIL (컴파일 에러 — PaymentModel 클래스 없음) + +**Step 3: PaymentModel 구현** + +```java +package com.loopers.domain.payment; + +import com.loopers.domain.BaseEntity; +import com.loopers.domain.product.Money; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import jakarta.persistence.*; + +@Entity +@Table(name = "payments") +public class PaymentModel extends BaseEntity { + + @Column(name = "order_id", nullable = false) + private Long orderId; + + @Column(name = "user_id", nullable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "card_type", nullable = false) + private CardType cardType; + + @Column(name = "masked_card_no", nullable = false) + private String maskedCardNo; + + @Embedded + @AttributeOverride(name = "value", column = @Column(name = "amount", nullable = false)) + private Money amount; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private PaymentStatus status; + + @Column(name = "transaction_key", unique = true) + private String transactionKey; + + @Column(name = "failure_reason") + private String failureReason; + + protected PaymentModel() {} + + public PaymentModel(Long orderId, Long userId, CardType cardType, String maskedCardNo, Money amount) { + this.orderId = orderId; + this.userId = userId; + this.cardType = cardType; + this.maskedCardNo = maskedCardNo; + this.amount = amount; + this.status = PaymentStatus.PENDING; + guard(); + } + + @Override + protected void guard() { + if (orderId == null) throw new CoreException(ErrorType.BAD_REQUEST, "주문 정보는 필수입니다."); + if (userId == null) throw new CoreException(ErrorType.BAD_REQUEST, "사용자 정보는 필수입니다."); + if (cardType == null) throw new CoreException(ErrorType.BAD_REQUEST, "카드 종류는 필수입니다."); + if (maskedCardNo == null || maskedCardNo.isBlank()) throw new CoreException(ErrorType.BAD_REQUEST, "카드번호는 필수입니다."); + if (amount == null) throw new CoreException(ErrorType.BAD_REQUEST, "결제 금액은 필수입니다."); + } + + public void assignTransactionKey(String transactionKey) { + this.transactionKey = transactionKey; + } + + public void markSuccess() { + if (this.status == PaymentStatus.SUCCESS) return; // 멱등 + if (this.status != PaymentStatus.PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, "PENDING 상태에서만 성공 처리할 수 있습니다."); + } + this.status = PaymentStatus.SUCCESS; + } + + public void markFailed(String reason) { + if (this.status == PaymentStatus.FAILED) return; // 멱등 + if (this.status != PaymentStatus.PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, "PENDING 상태에서만 실패 처리할 수 있습니다."); + } + this.status = PaymentStatus.FAILED; + this.failureReason = reason; + } + + public Long orderId() { return orderId; } + public Long userId() { return userId; } + public CardType cardType() { return cardType; } + public String maskedCardNo() { return maskedCardNo; } + public Money amount() { return amount; } + public PaymentStatus status() { return status; } + public String transactionKey() { return transactionKey; } + public String failureReason() { return failureReason; } +} +``` + +**Step 4: 테스트 실행 → 통과 확인** + +```bash +./gradlew test --tests "PaymentModelTest" +``` +Expected: ALL PASS + +**Step 5: 커밋** + +```bash +git add apps/commerce-api/src/test/java/com/loopers/domain/payment/PaymentModelTest.java \ + apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentModel.java +git commit -m "feat: PaymentModel 엔티티 구현 (TDD)" +``` + +--- + +## Task 3: OrderStatus 확장 + OrderModel 상태 전이 (Red → Green) + +**Files:** +- Modify: `apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java` +- Modify: `apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java` +- Modify: `apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java` + +**Step 1: OrderModelTest에 상태 전이 테스트 추가** + +```java +// OrderModelTest.java에 추가할 Nested 클래스 +@DisplayName("결제 상태 전이") +@Nested +class PaymentStatusTransition { + + @DisplayName("CREATED → PAYMENT_PENDING 전이가 가능하다") + @Test + void transitionsToPaymentPending() { + OrderModel order = new OrderModel(1L, new Money(10000), Money.ZERO, null); + order.startPayment(); + assertThat(order.status()).isEqualTo(OrderStatus.PAYMENT_PENDING); + } + + @DisplayName("PAYMENT_PENDING → CONFIRMED 전이가 가능하다 (결제 성공)") + @Test + void transitionsToConfirmed() { + OrderModel order = new OrderModel(1L, new Money(10000), Money.ZERO, null); + order.startPayment(); + order.confirmPayment(); + assertThat(order.status()).isEqualTo(OrderStatus.CONFIRMED); + } + + @DisplayName("PAYMENT_PENDING → PAYMENT_FAILED 전이가 가능하다") + @Test + void transitionsToPaymentFailed() { + OrderModel order = new OrderModel(1L, new Money(10000), Money.ZERO, null); + order.startPayment(); + order.failPayment(); + assertThat(order.status()).isEqualTo(OrderStatus.PAYMENT_FAILED); + } + + @DisplayName("PAYMENT_FAILED → PAYMENT_PENDING 전이가 가능하다 (재결제)") + @Test + void retriesPaymentFromFailed() { + OrderModel order = new OrderModel(1L, new Money(10000), Money.ZERO, null); + order.startPayment(); + order.failPayment(); + order.startPayment(); // 재시도 + assertThat(order.status()).isEqualTo(OrderStatus.PAYMENT_PENDING); + } + + @DisplayName("CONFIRMED 상태에서 startPayment를 호출하면 예외가 발생한다") + @Test + void throwsWhenAlreadyConfirmed() { + OrderModel order = new OrderModel(1L, new Money(10000), Money.ZERO, null); + order.startPayment(); + order.confirmPayment(); + assertThrows(CoreException.class, () -> order.startPayment()); + } +} +``` + +**Step 2: 테스트 실행 → 실패 확인** + +```bash +./gradlew test --tests "OrderModelTest" +``` +Expected: FAIL (컴파일 에러 — PAYMENT_PENDING 없음, startPayment 메서드 없음) + +**Step 3: OrderStatus에 값 추가** + +```java +package com.loopers.domain.order; + +public enum OrderStatus { + CREATED, + PAYMENT_PENDING, + PAYMENT_FAILED, + CONFIRMED, + SHIPPING, + DELIVERED, + CANCELLED +} +``` + +**Step 4: OrderModel에 상태 전이 메서드 추가** + +OrderModel.java의 `validateOwner` 메서드 아래에 추가: + +```java +public void startPayment() { + if (this.status != OrderStatus.CREATED && this.status != OrderStatus.PAYMENT_FAILED) { + throw new CoreException(ErrorType.BAD_REQUEST, "결제를 시작할 수 없는 주문 상태입니다."); + } + this.status = OrderStatus.PAYMENT_PENDING; +} + +public void confirmPayment() { + if (this.status != OrderStatus.PAYMENT_PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, "결제 확인을 처리할 수 없는 주문 상태입니다."); + } + this.status = OrderStatus.CONFIRMED; +} + +public void failPayment() { + if (this.status != OrderStatus.PAYMENT_PENDING) { + throw new CoreException(ErrorType.BAD_REQUEST, "결제 실패를 처리할 수 없는 주문 상태입니다."); + } + this.status = OrderStatus.PAYMENT_FAILED; +} +``` + +**Step 5: 테스트 실행 → 전체 통과 확인** + +```bash +./gradlew test --tests "OrderModelTest" +``` +Expected: ALL PASS + +**Step 6: 기존 Order 관련 테스트도 통과하는지 확인** + +```bash +./gradlew test --tests "*Order*" +``` +Expected: ALL PASS (기존 테스트는 CREATED 상태만 검증하므로 영향 없음) + +**Step 7: 커밋** + +```bash +git add apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatus.java \ + apps/commerce-api/src/main/java/com/loopers/domain/order/OrderModel.java \ + apps/commerce-api/src/test/java/com/loopers/domain/order/OrderModelTest.java +git commit -m "feat: OrderStatus에 결제 흐름 상태 추가 + 상태 전이 메서드 구현" +``` + +--- + +## Task 4: PaymentRepository (interface + impl) + +**Files:** +- Create: `apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java` +- Create: `apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java` +- Create: `apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java` + +**Step 1: Repository 인터페이스 작성 (domain 레이어)** + +```java +package com.loopers.domain.payment; + +import java.util.List; +import java.util.Optional; + +public interface PaymentRepository { + PaymentModel save(PaymentModel payment); + Optional findById(Long id); + Optional findByTransactionKey(String transactionKey); + List findAllByOrderId(Long orderId); +} +``` + +**Step 2: JPA Repository 작성 (infrastructure 레이어)** + +```java +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.PaymentModel; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface PaymentJpaRepository extends JpaRepository { + Optional findByTransactionKey(String transactionKey); + List findAllByOrderId(Long orderId); +} +``` + +**Step 3: Repository 구현체 작성 (infrastructure 레이어)** + +```java +package com.loopers.infrastructure.payment; + +import com.loopers.domain.payment.PaymentModel; +import com.loopers.domain.payment.PaymentRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class PaymentRepositoryImpl implements PaymentRepository { + + private final PaymentJpaRepository paymentJpaRepository; + + @Override + public PaymentModel save(PaymentModel payment) { + return paymentJpaRepository.save(payment); + } + + @Override + public Optional findById(Long id) { + return paymentJpaRepository.findById(id); + } + + @Override + public Optional findByTransactionKey(String transactionKey) { + return paymentJpaRepository.findByTransactionKey(transactionKey); + } + + @Override + public List findAllByOrderId(Long orderId) { + return paymentJpaRepository.findAllByOrderId(orderId); + } +} +``` + +**Step 4: 커밋** + +```bash +git add apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java \ + apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java \ + apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java +git commit -m "feat: PaymentRepository 인터페이스 및 구현체 추가" +``` + +--- + +## Task 5: PG 클라이언트 (Config + DTO + Client) + +**Files:** +- Modify: `apps/commerce-api/src/main/resources/application.yml` +- Create: `apps/commerce-api/src/main/java/com/loopers/config/PgClientConfig.java` +- Create: `apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/dto/PgPaymentRequest.java` +- Create: `apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/dto/PgPaymentResponse.java` +- Create: `apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgClient.java` + +**Step 1: application.yml에 PG 설정 추가** + +application.yml의 `springdoc:` 블록 위(spring 블록과 springdoc 사이)에 추가: + +```yaml +pg: + base-url: http://localhost:8082 + callback-url: http://localhost:8080/api/v1/payments/callback + timeout: + connect: 1000 + read: 3000 +``` + +**Step 2: PgClientConfig 작성** + +```java +package com.loopers.config; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; + +@Configuration +public class PgClientConfig { + + @Bean + public RestTemplate pgRestTemplate(PgProperties pgProperties) { + return new RestTemplateBuilder() + .rootUri(pgProperties.baseUrl()) + .setConnectTimeout(Duration.ofMillis(pgProperties.timeout().connect())) + .setReadTimeout(Duration.ofMillis(pgProperties.timeout().read())) + .build(); + } +} +``` + +**Step 3: PgProperties 작성 (같은 config 패키지)** + +```java +package com.loopers.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "pg") +public record PgProperties( + String baseUrl, + String callbackUrl, + TimeoutProperties timeout +) { + public record TimeoutProperties(int connect, int read) {} +} +``` + +> **주의:** `@EnableConfigurationProperties(PgProperties.class)`를 PgClientConfig에 추가하거나, main Application 클래스에 추가해야 한다. PgClientConfig에 추가하는 것이 응집도가 높다. + +PgClientConfig에 어노테이션 추가: +```java +@Configuration +@EnableConfigurationProperties(PgProperties.class) +public class PgClientConfig { ... } +``` + +**Step 4: PG 요청/응답 DTO 작성** + +```java +package com.loopers.infrastructure.payment.dto; + +public record PgPaymentRequest( + String orderId, + String cardType, + String cardNo, + String amount, + String callbackUrl +) {} +``` + +```java +package com.loopers.infrastructure.payment.dto; + +public record PgPaymentResponse( + String transactionKey, + String orderId, + String status, + String failureReason +) {} +``` + +**Step 5: PgClient 작성** + +```java +package com.loopers.infrastructure.payment; + +import com.loopers.infrastructure.payment.dto.PgPaymentRequest; +import com.loopers.infrastructure.payment.dto.PgPaymentResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@RequiredArgsConstructor +@Component +public class PgClient { + + private final RestTemplate pgRestTemplate; + + public PgPaymentResponse requestPayment(PgPaymentRequest request, String userId) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-USER-ID", userId); + + HttpEntity httpEntity = new HttpEntity<>(request, headers); + return pgRestTemplate.postForObject("/api/v1/payments", httpEntity, PgPaymentResponse.class); + } + + public PgPaymentResponse getPaymentStatus(String transactionKey, String userId) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-USER-ID", userId); + + HttpEntity httpEntity = new HttpEntity<>(headers); + return pgRestTemplate.exchange( + "/api/v1/payments/{transactionKey}", + org.springframework.http.HttpMethod.GET, + httpEntity, + PgPaymentResponse.class, + transactionKey + ).getBody(); + } +} +``` + +**Step 6: 커밋** + +```bash +git add apps/commerce-api/src/main/resources/application.yml \ + apps/commerce-api/src/main/java/com/loopers/config/PgClientConfig.java \ + apps/commerce-api/src/main/java/com/loopers/config/PgProperties.java \ + apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/dto/PgPaymentRequest.java \ + apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/dto/PgPaymentResponse.java \ + apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgClient.java +git commit -m "feat: PG 클라이언트 + RestTemplate 설정 추가" +``` + +--- + +## Task 6: PaymentService (도메인 서비스) + +**Files:** +- Create: `apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentService.java` + +**Step 1: PaymentService 작성** + +```java +package com.loopers.application.payment; + +import com.loopers.domain.payment.PaymentModel; +import com.loopers.domain.payment.PaymentRepository; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class PaymentService { + + private final PaymentRepository paymentRepository; + + @Transactional + public PaymentModel save(PaymentModel payment) { + return paymentRepository.save(payment); + } + + @Transactional(readOnly = true) + public PaymentModel getById(Long paymentId) { + return paymentRepository.findById(paymentId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제 정보를 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public PaymentModel getByTransactionKey(String transactionKey) { + return paymentRepository.findByTransactionKey(transactionKey) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 거래를 찾을 수 없습니다.")); + } + + @Transactional(readOnly = true) + public List getByOrderId(Long orderId) { + return paymentRepository.findAllByOrderId(orderId); + } +} +``` + +**Step 2: 커밋** + +```bash +git add apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentService.java +git commit -m "feat: PaymentService 도메인 서비스 추가" +``` + +--- + +## Task 7: PaymentCommand + PaymentInfo (Application DTO) + +**Files:** +- Create: `apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentCommand.java` +- Create: `apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentInfo.java` + +**Step 1: PaymentCommand 작성** + +```java +package com.loopers.application.payment; + +import com.loopers.domain.payment.CardType; + +public record PaymentCommand( + Long orderId, + CardType cardType, + String cardNo // 원본 카드번호 (마스킹 전) +) { + /** + * 카드번호 마스킹: "1234-5678-9814-1451" → "1234-****-****-1451" + */ + public String maskedCardNo() { + if (cardNo == null || cardNo.length() < 4) return cardNo; + String digitsOnly = cardNo.replaceAll("-", ""); + if (digitsOnly.length() < 8) return cardNo; + String first4 = digitsOnly.substring(0, 4); + String last4 = digitsOnly.substring(digitsOnly.length() - 4); + return first4 + "-****-****-" + last4; + } +} +``` + +**Step 2: PaymentInfo 작성** + +```java +package com.loopers.application.payment; + +import com.loopers.domain.payment.PaymentModel; + +import java.time.ZonedDateTime; + +public record PaymentInfo( + Long paymentId, + Long orderId, + Long userId, + String cardType, + String maskedCardNo, + int amount, + String status, + String transactionKey, + String failureReason, + ZonedDateTime createdAt +) { + public static PaymentInfo from(PaymentModel payment) { + return new PaymentInfo( + payment.getId(), + payment.orderId(), + payment.userId(), + payment.cardType().name(), + payment.maskedCardNo(), + payment.amount().value(), + payment.status().name(), + payment.transactionKey(), + payment.failureReason(), + payment.getCreatedAt() + ); + } +} +``` + +**Step 3: 커밋** + +```bash +git add apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentCommand.java \ + apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentInfo.java +git commit -m "feat: PaymentCommand, PaymentInfo DTO 추가" +``` + +--- + +## Task 8: ErrorType 확장 + +**Files:** +- Modify: `apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java` + +**Step 1: 결제 관련 ErrorType 추가** + +ErrorType.java의 마지막 값(`AUTHENTICATION_FAILED`) 뒤에 추가: + +```java +PG_REQUEST_FAILED(HttpStatus.BAD_GATEWAY, "PG Request Failed", "결제 시스템 요청에 실패했습니다."), +PG_TIMEOUT(HttpStatus.GATEWAY_TIMEOUT, "PG Timeout", "결제 시스템 응답 시간이 초과되었습니다."); +``` + +**Step 2: 커밋** + +```bash +git add apps/commerce-api/src/main/java/com/loopers/support/error/ErrorType.java +git commit -m "feat: 결제 관련 ErrorType 추가 (PG_REQUEST_FAILED, PG_TIMEOUT)" +``` + +--- + +## Task 9: PaymentFacade (핵심 유스케이스) (Red → Green) + +**Files:** +- Create: `apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java` +- Create: `apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java` + +**Step 1: 실패하는 테스트 작성 (Unit Test — Mock 기반)** + +```java +package com.loopers.application.payment; + +import com.loopers.application.order.OrderService; +import com.loopers.config.PgProperties; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.payment.CardType; +import com.loopers.domain.payment.PaymentModel; +import com.loopers.domain.payment.PaymentStatus; +import com.loopers.domain.product.Money; +import com.loopers.infrastructure.payment.PgClient; +import com.loopers.infrastructure.payment.dto.PgPaymentResponse; +import com.loopers.support.error.CoreException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class PaymentFacadeTest { + + @InjectMocks private PaymentFacade paymentFacade; + @Mock private PaymentService paymentService; + @Mock private OrderService orderService; + @Mock private PgClient pgClient; + @Mock private PgProperties pgProperties; + + @DisplayName("결제 요청") + @Nested + class RequestPayment { + + @DisplayName("PG 요청 성공 시 PENDING 상태의 PaymentInfo를 반환한다") + @Test + void returnsPendingPaymentOnSuccess() { + // given + Long userId = 1L; + PaymentCommand command = new PaymentCommand(1L, CardType.SAMSUNG, "1234-5678-9814-1451"); + + OrderModel mockOrder = mock(OrderModel.class); + given(mockOrder.getId()).willReturn(1L); + given(mockOrder.finalAmount()).willReturn(new Money(50000)); + given(mockOrder.status()).willReturn(OrderStatus.CREATED); + given(orderService.getOrder(1L, userId)).willReturn(mockOrder); + + given(pgProperties.callbackUrl()).willReturn("http://localhost:8080/api/v1/payments/callback"); + given(pgClient.requestPayment(any(), eq(String.valueOf(userId)))) + .willReturn(new PgPaymentResponse("20250816:TR:abc123", "1", "PENDING", null)); + + given(paymentService.save(any(PaymentModel.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // when + PaymentInfo result = paymentFacade.requestPayment(userId, command); + + // then + assertAll( + () -> assertThat(result.status()).isEqualTo("PENDING"), + () -> assertThat(result.transactionKey()).isEqualTo("20250816:TR:abc123"), + () -> assertThat(result.maskedCardNo()).isEqualTo("1234-****-****-1451") + ); + verify(mockOrder).startPayment(); + } + + @DisplayName("본인 주문이 아니면 예외가 발생한다") + @Test + void throwsWhenNotOrderOwner() { + // given + Long userId = 999L; + PaymentCommand command = new PaymentCommand(1L, CardType.SAMSUNG, "1234-5678-9814-1451"); + given(orderService.getOrder(1L, userId)).willThrow(new CoreException( + com.loopers.support.error.ErrorType.BAD_REQUEST, "본인의 주문만 조회할 수 있습니다." + )); + + // when & then + assertThrows(CoreException.class, () -> paymentFacade.requestPayment(userId, command)); + } + } + + @DisplayName("콜백 처리") + @Nested + class HandleCallback { + + @DisplayName("SUCCESS 콜백 수신 시 결제 성공 + 주문 확인 처리된다") + @Test + void handlesSuccessCallback() { + // given + String transactionKey = "20250816:TR:abc123"; + PaymentModel mockPayment = mock(PaymentModel.class); + given(mockPayment.orderId()).willReturn(1L); + given(paymentService.getByTransactionKey(transactionKey)).willReturn(mockPayment); + + OrderModel mockOrder = mock(OrderModel.class); + given(orderService.getOrderForAdmin(1L)).willReturn(mockOrder); + + // when + paymentFacade.handleCallback(transactionKey, "SUCCESS", null); + + // then + verify(mockPayment).markSuccess(); + verify(mockOrder).confirmPayment(); + } + + @DisplayName("FAILED 콜백 수신 시 결제 실패 + 주문 실패 처리된다") + @Test + void handlesFailedCallback() { + // given + String transactionKey = "20250816:TR:abc123"; + PaymentModel mockPayment = mock(PaymentModel.class); + given(mockPayment.orderId()).willReturn(1L); + given(paymentService.getByTransactionKey(transactionKey)).willReturn(mockPayment); + + OrderModel mockOrder = mock(OrderModel.class); + given(orderService.getOrderForAdmin(1L)).willReturn(mockOrder); + + // when + paymentFacade.handleCallback(transactionKey, "FAILED", "한도 초과"); + + // then + verify(mockPayment).markFailed("한도 초과"); + verify(mockOrder).failPayment(); + } + } +} +``` + +**Step 2: 테스트 실행 → 실패 확인** + +```bash +./gradlew test --tests "PaymentFacadeTest" +``` +Expected: FAIL (PaymentFacade 클래스 없음) + +**Step 3: PaymentFacade 구현** + +```java +package com.loopers.application.payment; + +import com.loopers.application.order.OrderService; +import com.loopers.config.PgProperties; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.payment.PaymentModel; +import com.loopers.infrastructure.payment.PgClient; +import com.loopers.infrastructure.payment.dto.PgPaymentRequest; +import com.loopers.infrastructure.payment.dto.PgPaymentResponse; +import com.loopers.support.error.CoreException; +import com.loopers.support.error.ErrorType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@RequiredArgsConstructor +@Component +public class PaymentFacade { + + private final PaymentService paymentService; + private final OrderService orderService; + private final PgClient pgClient; + private final PgProperties pgProperties; + + /** + * 결제 요청 흐름: + * 1. [TX-1] 주문 조회 + 주문 상태를 PAYMENT_PENDING으로 변경 + 결제 레코드 생성 (PENDING) + * 2. [TX 없음] PG 시스템 호출 → transactionKey 수신 + * 3. [TX-2] transactionKey 업데이트 + */ + public PaymentInfo requestPayment(Long userId, PaymentCommand command) { + // TX-1: 주문 검증 + 결제 레코드 생성 + PaymentModel payment = createPaymentRecord(userId, command); + + // TX 없음: PG 호출 (트랜잭션 밖) + PgPaymentResponse pgResponse = callPg(userId, command, payment); + + // TX-2: transactionKey 업데이트 + updateTransactionKey(payment.getId(), pgResponse.transactionKey()); + + return PaymentInfo.from(payment); + } + + @Transactional + protected PaymentModel createPaymentRecord(Long userId, PaymentCommand command) { + OrderModel order = orderService.getOrder(command.orderId(), userId); + order.startPayment(); + + PaymentModel payment = new PaymentModel( + order.getId(), + userId, + command.cardType(), + command.maskedCardNo(), + order.finalAmount() + ); + return paymentService.save(payment); + } + + private PgPaymentResponse callPg(Long userId, PaymentCommand command, PaymentModel payment) { + PgPaymentRequest pgRequest = new PgPaymentRequest( + String.valueOf(command.orderId()), + command.cardType().name(), + command.cardNo(), + String.valueOf(payment.amount().value()), + pgProperties.callbackUrl() + ); + + try { + PgPaymentResponse pgResponse = pgClient.requestPayment(pgRequest, String.valueOf(userId)); + if (pgResponse == null || pgResponse.transactionKey() == null) { + throw new CoreException(ErrorType.PG_REQUEST_FAILED, "PG 응답이 유효하지 않습니다."); + } + return pgResponse; + } catch (CoreException e) { + throw e; + } catch (Exception e) { + throw new CoreException(ErrorType.PG_REQUEST_FAILED, "PG 결제 요청에 실패했습니다: " + e.getMessage()); + } + } + + @Transactional + protected void updateTransactionKey(Long paymentId, String transactionKey) { + PaymentModel payment = paymentService.getById(paymentId); + payment.assignTransactionKey(transactionKey); + } + + /** + * PG 콜백 수신 처리 + */ + @Transactional + public void handleCallback(String transactionKey, String status, String failureReason) { + PaymentModel payment = paymentService.getByTransactionKey(transactionKey); + OrderModel order = orderService.getOrderForAdmin(payment.orderId()); + + if ("SUCCESS".equals(status)) { + payment.markSuccess(); + order.confirmPayment(); + } else if ("FAILED".equals(status)) { + payment.markFailed(failureReason); + order.failPayment(); + } + } + + @Transactional(readOnly = true) + public PaymentInfo getPayment(Long paymentId) { + return PaymentInfo.from(paymentService.getById(paymentId)); + } + + @Transactional(readOnly = true) + public List getPaymentsByOrderId(Long orderId) { + return paymentService.getByOrderId(orderId).stream() + .map(PaymentInfo::from) + .toList(); + } +} +``` + +**Step 4: 테스트 실행 → 통과 확인** + +```bash +./gradlew test --tests "PaymentFacadeTest" +``` +Expected: ALL PASS + +**Step 5: 커밋** + +```bash +git add apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java \ + apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java +git commit -m "feat: PaymentFacade 결제 요청/콜백 유스케이스 구현 (TDD)" +``` + +--- + +## Task 10: PaymentV1Dto + PaymentV1ApiSpec + PaymentV1Controller + +**Files:** +- Create: `apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java` +- Create: `apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1ApiSpec.java` +- Create: `apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java` + +**Step 1: PaymentV1Dto 작성** + +```java +package com.loopers.interfaces.api.payment; + +import com.loopers.application.payment.PaymentCommand; +import com.loopers.application.payment.PaymentInfo; +import com.loopers.domain.payment.CardType; + +import java.time.ZonedDateTime; + +public class PaymentV1Dto { + + public record PaymentRequest( + Long orderId, + String cardType, + String cardNo + ) { + public PaymentCommand toCommand() { + return new PaymentCommand(orderId, CardType.valueOf(cardType), cardNo); + } + } + + public record PaymentResponse( + Long paymentId, + Long orderId, + String transactionKey, + String cardType, + String maskedCardNo, + int amount, + String status, + String failureReason, + ZonedDateTime createdAt + ) { + public static PaymentResponse from(PaymentInfo info) { + return new PaymentResponse( + info.paymentId(), + info.orderId(), + info.transactionKey(), + info.cardType(), + info.maskedCardNo(), + info.amount(), + info.status(), + info.failureReason(), + info.createdAt() + ); + } + } + + public record CallbackRequest( + String transactionKey, + String orderId, + String status, + String failureReason + ) {} +} +``` + +**Step 2: PaymentV1ApiSpec 작성** + +```java +package com.loopers.interfaces.api.payment; + +import com.loopers.domain.member.MemberModel; +import com.loopers.interfaces.api.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +import java.util.List; + +@Tag(name = "Payment V1 API", description = "결제 API") +public interface PaymentV1ApiSpec { + + @Operation(summary = "결제 요청", description = "주문에 대한 결제를 요청합니다.") + ApiResponse requestPayment( + MemberModel member, PaymentV1Dto.PaymentRequest request + ); + + @Operation(summary = "결제 상태 조회", description = "결제 상세 정보를 조회합니다.") + ApiResponse getPayment( + MemberModel member, Long paymentId + ); + + @Operation(summary = "주문별 결제 내역 조회", description = "주문에 대한 모든 결제 시도를 조회합니다.") + ApiResponse> getPaymentsByOrderId( + MemberModel member, Long orderId + ); + + @Operation(summary = "PG 콜백 수신", description = "PG 시스템으로부터 결제 결과를 수신합니다.") + ApiResponse handleCallback(PaymentV1Dto.CallbackRequest request); +} +``` + +**Step 3: PaymentV1Controller 작성** + +```java +package com.loopers.interfaces.api.payment; + +import com.loopers.application.payment.PaymentFacade; +import com.loopers.application.payment.PaymentInfo; +import com.loopers.domain.member.MemberModel; +import com.loopers.interfaces.api.ApiResponse; +import com.loopers.interfaces.api.auth.LoginMember; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +public class PaymentV1Controller implements PaymentV1ApiSpec { + + private final PaymentFacade paymentFacade; + + @PostMapping("/api/v1/payments") + @Override + public ApiResponse requestPayment( + @LoginMember MemberModel member, + @RequestBody PaymentV1Dto.PaymentRequest request + ) { + PaymentInfo info = paymentFacade.requestPayment(member.getId(), request.toCommand()); + return ApiResponse.success(PaymentV1Dto.PaymentResponse.from(info)); + } + + @GetMapping("/api/v1/payments/{paymentId}") + @Override + public ApiResponse getPayment( + @LoginMember MemberModel member, + @PathVariable Long paymentId + ) { + PaymentInfo info = paymentFacade.getPayment(paymentId); + return ApiResponse.success(PaymentV1Dto.PaymentResponse.from(info)); + } + + @GetMapping("/api/v1/payments") + @Override + public ApiResponse> getPaymentsByOrderId( + @LoginMember MemberModel member, + @RequestParam Long orderId + ) { + List infos = paymentFacade.getPaymentsByOrderId(orderId); + List responses = infos.stream() + .map(PaymentV1Dto.PaymentResponse::from) + .toList(); + return ApiResponse.success(responses); + } + + @PostMapping("/api/v1/payments/callback") + @Override + public ApiResponse handleCallback( + @RequestBody PaymentV1Dto.CallbackRequest request + ) { + paymentFacade.handleCallback(request.transactionKey(), request.status(), request.failureReason()); + return ApiResponse.success(); + } +} +``` + +**Step 4: 컴파일 확인** + +```bash +./gradlew compileJava +``` +Expected: BUILD SUCCESSFUL + +**Step 5: 커밋** + +```bash +git add apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java \ + apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1ApiSpec.java \ + apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java +git commit -m "feat: Payment API 컨트롤러 + DTO + Swagger Spec 추가" +``` + +--- + +## Task 11: 통합 테스트 (PaymentFacade Integration) + +**Files:** +- Create: `apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeIntegrationTest.java` + +**Step 1: 통합 테스트 작성** + +> PG 클라이언트는 MockRestServiceServer로 모킹한다. 나머지는 실제 Spring Context + DB를 사용한다. + +```java +package com.loopers.application.payment; + +import com.loopers.application.brand.BrandService; +import com.loopers.application.order.OrderFacade; +import com.loopers.application.order.OrderItemCommand; +import com.loopers.application.order.OrderResult; +import com.loopers.application.order.OrderService; +import com.loopers.application.product.ProductFacade; +import com.loopers.domain.order.OrderModel; +import com.loopers.domain.order.OrderStatus; +import com.loopers.domain.payment.CardType; +import com.loopers.domain.payment.PaymentStatus; +import com.loopers.domain.product.Money; +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.beans.factory.annotation.Qualifier; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.*; +import static org.springframework.test.web.client.response.MockRestResponseCreators.*; + +@SpringBootTest +class PaymentFacadeIntegrationTest { + + @Autowired private PaymentFacade paymentFacade; + @Autowired private PaymentService paymentService; + @Autowired private OrderFacade orderFacade; + @Autowired private OrderService orderService; + @Autowired private ProductFacade productFacade; + @Autowired private BrandService brandService; + @Autowired private DatabaseCleanUp databaseCleanUp; + @Autowired @Qualifier("pgRestTemplate") private RestTemplate pgRestTemplate; + + private MockRestServiceServer mockServer; + + @BeforeEach + void setUp() { + mockServer = MockRestServiceServer.createServer(pgRestTemplate); + } + + @AfterEach + void tearDown() { + databaseCleanUp.truncateAllTables(); + } + + private OrderResult createOrder() { + Long brandId = brandService.register("나이키", "스포츠").getId(); + Long productId = productFacade.register("에어맥스", "러닝화", new Money(50000), brandId, 10).id(); + return orderFacade.placeOrder(1L, List.of(new OrderItemCommand(productId, 1)), null); + } + + @DisplayName("결제 요청") + @Nested + class RequestPayment { + + @DisplayName("PG 요청 성공 시 PENDING 결제가 생성되고 주문 상태가 PAYMENT_PENDING으로 변경된다") + @Test + void createsPendingPayment() { + // given + OrderResult orderResult = createOrder(); + Long orderId = orderResult.order().getId(); + + mockServer.expect(requestTo("/api/v1/payments")) + .andExpect(method(HttpMethod.POST)) + .andRespond(withSuccess( + """ + {"transactionKey":"20250816:TR:test123","orderId":"%d","status":"PENDING","failureReason":null} + """.formatted(orderId), + MediaType.APPLICATION_JSON + )); + + PaymentCommand command = new PaymentCommand(orderId, CardType.SAMSUNG, "1234-5678-9814-1451"); + + // when + PaymentInfo result = paymentFacade.requestPayment(1L, command); + + // then + assertAll( + () -> assertThat(result.status()).isEqualTo("PENDING"), + () -> assertThat(result.transactionKey()).isEqualTo("20250816:TR:test123"), + () -> assertThat(result.maskedCardNo()).isEqualTo("1234-****-****-1451"), + () -> assertThat(result.amount()).isEqualTo(50000) + ); + + // 주문 상태 확인 + OrderModel order = orderService.getOrderForAdmin(orderId); + assertThat(order.status()).isEqualTo(OrderStatus.PAYMENT_PENDING); + + mockServer.verify(); + } + } + + @DisplayName("콜백 처리") + @Nested + class HandleCallback { + + @DisplayName("SUCCESS 콜백 시 결제 성공 + 주문 CONFIRMED 처리") + @Test + void handlesSuccessCallback() { + // given + OrderResult orderResult = createOrder(); + Long orderId = orderResult.order().getId(); + + mockServer.expect(requestTo("/api/v1/payments")) + .andRespond(withSuccess( + """ + {"transactionKey":"20250816:TR:cb001","orderId":"%d","status":"PENDING","failureReason":null} + """.formatted(orderId), + MediaType.APPLICATION_JSON + )); + + PaymentCommand command = new PaymentCommand(orderId, CardType.SAMSUNG, "1234-5678-9814-1451"); + paymentFacade.requestPayment(1L, command); + + // when + paymentFacade.handleCallback("20250816:TR:cb001", "SUCCESS", null); + + // then + PaymentInfo payment = paymentFacade.getPaymentsByOrderId(orderId).get(0); + assertThat(payment.status()).isEqualTo("SUCCESS"); + + OrderModel order = orderService.getOrderForAdmin(orderId); + assertThat(order.status()).isEqualTo(OrderStatus.CONFIRMED); + } + + @DisplayName("FAILED 콜백 시 결제 실패 + 주문 PAYMENT_FAILED 처리") + @Test + void handlesFailedCallback() { + // given + OrderResult orderResult = createOrder(); + Long orderId = orderResult.order().getId(); + + mockServer.expect(requestTo("/api/v1/payments")) + .andRespond(withSuccess( + """ + {"transactionKey":"20250816:TR:cb002","orderId":"%d","status":"PENDING","failureReason":null} + """.formatted(orderId), + MediaType.APPLICATION_JSON + )); + + PaymentCommand command = new PaymentCommand(orderId, CardType.SAMSUNG, "1234-5678-9814-1451"); + paymentFacade.requestPayment(1L, command); + + // when + paymentFacade.handleCallback("20250816:TR:cb002", "FAILED", "한도 초과"); + + // then + PaymentInfo payment = paymentFacade.getPaymentsByOrderId(orderId).get(0); + assertAll( + () -> assertThat(payment.status()).isEqualTo("FAILED"), + () -> assertThat(payment.failureReason()).isEqualTo("한도 초과") + ); + + OrderModel order = orderService.getOrderForAdmin(orderId); + assertThat(order.status()).isEqualTo(OrderStatus.PAYMENT_FAILED); + } + } +} +``` + +**Step 2: 테스트 실행 → 통과 확인** + +```bash +./gradlew test --tests "PaymentFacadeIntegrationTest" +``` +Expected: ALL PASS + +**Step 3: 커밋** + +```bash +git add apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeIntegrationTest.java +git commit -m "test: PaymentFacade 통합 테스트 추가 (MockRestServiceServer)" +``` + +--- + +## Task 12: HTTP 파일 작성 + 전체 테스트 실행 + +**Files:** +- Create: `.http/payment.http` + +**Step 1: HTTP 파일 작성** + +```http +### 결제 요청 +POST http://localhost:8080/api/v1/payments +X-Loopers-LoginId: user1 +X-Loopers-LoginPw: password1 +Content-Type: application/json + +{ + "orderId": 1, + "cardType": "SAMSUNG", + "cardNo": "1234-5678-9814-1451" +} + +### 결제 상태 조회 +GET http://localhost:8080/api/v1/payments/1 +X-Loopers-LoginId: user1 +X-Loopers-LoginPw: password1 + +### 주문별 결제 내역 조회 +GET http://localhost:8080/api/v1/payments?orderId=1 +X-Loopers-LoginId: user1 +X-Loopers-LoginPw: password1 + +### PG 콜백 (수동 테스트용) +POST http://localhost:8080/api/v1/payments/callback +Content-Type: application/json + +{ + "transactionKey": "20250816:TR:9577c5", + "orderId": "1", + "status": "SUCCESS", + "failureReason": null +} +``` + +**Step 2: 전체 테스트 실행** + +```bash +./gradlew test +``` +Expected: ALL PASS + +**Step 3: 커밋** + +```bash +git add .http/payment.http +git commit -m "docs: 결제 API HTTP 테스트 파일 추가" +``` + +--- + +## 구현 순서 요약 + +| Task | 내용 | 유형 | 검증 | +|------|------|------|------| +| 1 | PaymentStatus, CardType Enum | 생성 | 컴파일 | +| 2 | PaymentModel 엔티티 | Red→Green | PaymentModelTest | +| 3 | OrderStatus 확장 + 상태 전이 | Red→Green | OrderModelTest + 기존 Order 테스트 | +| 4 | PaymentRepository (interface + impl) | 생성 | 컴파일 | +| 5 | PG 클라이언트 (Config + DTO + Client) | 생성 | 컴파일 | +| 6 | PaymentService | 생성 | 컴파일 | +| 7 | PaymentCommand, PaymentInfo DTO | 생성 | 컴파일 | +| 8 | ErrorType 확장 | 수정 | 컴파일 | +| 9 | PaymentFacade | Red→Green | PaymentFacadeTest (Unit) | +| 10 | Controller + API DTO + Spec | 생성 | 컴파일 | +| 11 | 통합 테스트 | 테스트 | PaymentFacadeIntegrationTest | +| 12 | HTTP 파일 + 전체 검증 | 문서 | 전체 테스트 | + +--- + +## 이 Plan에서 제외한 것 (후속 브랜치) + +| 항목 | 브랜치 | 이유 | +|------|--------|------| +| CircuitBreaker / Timeout / Fallback | `week6-feature/resilience` | 장애 대응은 순수 도메인 이후 | +| Retry 정책 | `week6-feature/resilience` | Nice-to-have | +| 콜백 미수신 시 폴링/복구 | `week6-feature/payment-callback` | PENDING 상태 복구 로직 | +| 스케줄러 기반 자동 확인 | `week6-feature/payment-callback` | 운영 안정성 | +| E2E 테스트 | Task 11 이후 추가 가능 | 통합 테스트로 충분히 검증 후 판단 | diff --git a/docs/plans/2026-03-20-resilience4j-design.md b/docs/plans/2026-03-20-resilience4j-design.md new file mode 100644 index 000000000..790e0c57b --- /dev/null +++ b/docs/plans/2026-03-20-resilience4j-design.md @@ -0,0 +1,47 @@ +# Resilience4j PG 장애 대응 설계 + +## 요구사항 +- Timeout: 외부 시스템 응답 지연 제어 (기존 RestTemplate 설정 존재) +- Retry: 일시적 실패 시 PG 호출만 재시도 (최대 3회) +- CircuitBreaker: PG 반복 실패 시 호출 차단, 시스템 보호 +- Fallback: 실패 시 주문 PAYMENT_FAILED 처리 + 사용자 응답 + +## 의사결정 +1. 라이브러리: Resilience4j (spring-boot3 + AOP) +2. 적용 레이어: PaymentFacade (비즈니스 상태 정리가 필요하므로) +3. Retry 범위: PG 호출 메서드만 분리하여 재시도 (주문/결제 레코드는 한 번만) +4. Retry 대상 예외: ResourceAccessException, HttpServerErrorException만 +5. Fallback: 트랜잭션 롤백 + 별도 트랜잭션으로 주문 PAYMENT_FAILED 변경 + +## 구현 범위 +1. build.gradle.kts에 resilience4j 의존성 추가 +2. application.yml에 retry, circuitbreaker 설정 +3. PaymentFacade 리팩터링 (Retry/CircuitBreaker/Fallback 적용) +4. PENDING 상태 복구용 폴링 API (콜백 미수신 대응) +5. 테스트 작성 + +## Resilience4j 설정값 +```yaml +resilience4j: + retry: + instances: + pg: + max-attempts: 3 + wait-duration: 500ms + retry-exceptions: + - org.springframework.web.client.ResourceAccessException + - org.springframework.web.client.HttpServerErrorException + ignore-exceptions: + - org.springframework.web.client.HttpClientErrorException + fail-after-max-attempts: true + + circuitbreaker: + instances: + pg: + sliding-window-size: 10 + failure-rate-threshold: 50 + wait-duration-in-open-state: 10s + permitted-number-of-calls-in-half-open-state: 3 + slow-call-duration-threshold: 2s + slow-call-rate-threshold: 50 +``` diff --git a/docs/plans/2026-03-27-step1-application-event.md b/docs/plans/2026-03-27-step1-application-event.md new file mode 100644 index 000000000..19a5a88df --- /dev/null +++ b/docs/plans/2026-03-27-step1-application-event.md @@ -0,0 +1,673 @@ +# Step 1: ApplicationEvent로 경계 나누기 — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 주문-결제, 좋아요 플로우에서 부가 로직을 ApplicationEvent로 분리하고, 유저 행동 로깅을 이벤트 기반으로 추가한다. + +**Architecture:** Facade가 주요 로직 완료 후 도메인 이벤트를 발행하고, 관심사별 리스너가 `@TransactionalEventListener(AFTER_COMMIT)`으로 부가 로직을 처리한다. 이벤트 클래스는 domain 패키지, 리스너는 application 패키지에 배치한다. + +**Tech Stack:** Spring ApplicationEvent, @TransactionalEventListener, @Async (알림 등 느린 작업용) + +--- + +## Task 1: LikeToggledEvent 정의 + 발행 + +좋아요 이벤트부터 시작. 가장 단순하고 변경 범위가 좁다. + +**Files:** +- Create: `apps/commerce-api/src/main/java/com/loopers/domain/like/event/LikeToggledEvent.java` +- Create: `apps/commerce-api/src/test/java/com/loopers/domain/like/event/LikeToggledEventTest.java` + +**Step 1: 이벤트 record 작성** + +```java +package com.loopers.domain.like.event; + +public record LikeToggledEvent( + Long productId, + boolean liked // true: 좋아요, false: 취소 +) {} +``` + +**Step 2: 이벤트 생성 단위 테스트** + +```java +package com.loopers.domain.like.event; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.assertThat; + +class LikeToggledEventTest { + + @DisplayName("좋아요 이벤트 생성 시 productId와 liked 상태를 가진다") + @Test + void createLikeToggledEvent() { + // given + Long productId = 1L; + + // when + LikeToggledEvent event = new LikeToggledEvent(productId, true); + + // then + assertThat(event.productId()).isEqualTo(1L); + assertThat(event.liked()).isTrue(); + } +} +``` + +**Step 3: 테스트 실행** + +Run: `./gradlew :apps:commerce-api:test --tests "com.loopers.domain.like.event.LikeToggledEventTest" -i` +Expected: PASS + +--- + +## Task 2: LikeTransactionService에서 이벤트 발행으로 전환 + +집계/캐시 직접 호출을 이벤트 발행으로 교체한다. + +**Files:** +- Modify: `apps/commerce-api/src/main/java/com/loopers/application/like/LikeTransactionService.java` +- Create: `apps/commerce-api/src/test/java/com/loopers/application/like/LikeTransactionServiceTest.java` + +**Step 1: 실패하는 테스트 작성 — 좋아요 시 이벤트 발행 검증** + +```java +package com.loopers.application.like; + +import com.loopers.application.product.ProductService; +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeResult; +import com.loopers.domain.like.LikeToggleService; +import com.loopers.domain.like.event.LikeToggledEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +@ExtendWith(MockitoExtension.class) +class LikeTransactionServiceTest { + + @InjectMocks + private LikeTransactionService likeTransactionService; + + @Mock + private LikeService likeService; + + @Mock + private LikeToggleService likeToggleService; + + @Mock + private ApplicationEventPublisher eventPublisher; + + @DisplayName("좋아요 등록") + @Nested + class DoLike { + + @DisplayName("새 좋아요가 생성되면 LikeToggledEvent(liked=true)를 발행한다") + @Test + void publishesLikeToggledEventWhenNewLikeCreated() { + // given + Long userId = 1L; + Long productId = 100L; + LikeModel newLike = new LikeModel(userId, productId); + LikeResult result = new LikeResult(Optional.of(newLike), true); + + given(likeService.findByUserIdAndProductId(userId, productId)).willReturn(Optional.empty()); + given(likeToggleService.like(Optional.empty(), userId, productId)).willReturn(result); + + // when + likeTransactionService.doLike(userId, productId); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(LikeToggledEvent.class); + then(eventPublisher).should().publishEvent(captor.capture()); + assertThat(captor.getValue().productId()).isEqualTo(100L); + assertThat(captor.getValue().liked()).isTrue(); + } + + @DisplayName("이미 좋아요 상태면 이벤트를 발행하지 않는다") + @Test + void doesNotPublishEventWhenAlreadyLiked() { + // given + Long userId = 1L; + Long productId = 100L; + LikeModel existing = new LikeModel(userId, productId); + LikeResult result = new LikeResult(Optional.empty(), false); + + given(likeService.findByUserIdAndProductId(userId, productId)).willReturn(Optional.of(existing)); + given(likeToggleService.like(Optional.of(existing), userId, productId)).willReturn(result); + + // when + likeTransactionService.doLike(userId, productId); + + // then + then(eventPublisher).should(never()).publishEvent(org.mockito.ArgumentMatchers.any(LikeToggledEvent.class)); + } + } + + @DisplayName("좋아요 취소") + @Nested + class DoUnlike { + + @DisplayName("활성 좋아요가 있으면 LikeToggledEvent(liked=false)를 발행한다") + @Test + void publishesLikeToggledEventWhenUnliked() { + // given + Long userId = 1L; + Long productId = 100L; + LikeModel activeLike = new LikeModel(userId, productId); + + given(likeService.findActiveLike(userId, productId)).willReturn(Optional.of(activeLike)); + + // when + likeTransactionService.doUnlike(userId, productId); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(LikeToggledEvent.class); + then(eventPublisher).should().publishEvent(captor.capture()); + assertThat(captor.getValue().productId()).isEqualTo(100L); + assertThat(captor.getValue().liked()).isFalse(); + } + } +} +``` + +**Step 2: 테스트 실행 — 실패 확인** + +Run: `./gradlew :apps:commerce-api:test --tests "com.loopers.application.like.LikeTransactionServiceTest" -i` +Expected: FAIL — LikeTransactionService에 ApplicationEventPublisher가 없음 + +**Step 3: LikeTransactionService 수정 — 이벤트 발행으로 전환** + +```java +package com.loopers.application.like; + +import com.loopers.domain.like.LikeResult; +import com.loopers.domain.like.LikeModel; +import com.loopers.domain.like.LikeToggleService; +import com.loopers.domain.like.event.LikeToggledEvent; +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@RequiredArgsConstructor +@Component +public class LikeTransactionService { + + private final LikeService likeService; + private final LikeToggleService likeToggleService; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public void doLike(Long userId, Long productId) { + Optional existing = likeService.findByUserIdAndProductId(userId, productId); + + LikeResult result = likeToggleService.like(existing, userId, productId); + result.newLike().ifPresent(likeService::save); + + if (result.countChanged()) { + eventPublisher.publishEvent(new LikeToggledEvent(productId, true)); + } + } + + @Transactional + public void doUnlike(Long userId, Long productId) { + Optional activeLike = likeService.findActiveLike(userId, productId); + if (activeLike.isEmpty()) return; + + likeToggleService.unlike(activeLike.get()); + eventPublisher.publishEvent(new LikeToggledEvent(productId, false)); + } +} +``` + +핵심 변경: `ProductService`, `CacheManager` 의존 제거 → `ApplicationEventPublisher`로 대체. + +**Step 4: 테스트 실행 — 성공 확인** + +Run: `./gradlew :apps:commerce-api:test --tests "com.loopers.application.like.LikeTransactionServiceTest" -i` +Expected: PASS + +**Step 5: 기존 테스트 확인 — LikeFacadeTest, LikeTransactionCacheTest 등** + +Run: `./gradlew :apps:commerce-api:test --tests "com.loopers.application.like.*" -i` +Expected: 기존 테스트 중 ProductService/CacheManager mock에 의존하는 것이 깨질 수 있음 → 다음 Task에서 수정 + +--- + +## Task 3: LikeMetricsEventListener 구현 + +좋아요 집계(like_count) + 캐시 evict을 담당하는 리스너. + +**Files:** +- Create: `apps/commerce-api/src/main/java/com/loopers/application/like/LikeMetricsEventListener.java` +- Create: `apps/commerce-api/src/test/java/com/loopers/application/like/LikeMetricsEventListenerTest.java` + +**Step 1: 실패하는 테스트 작성** + +```java +package com.loopers.application.like; + +import com.loopers.application.product.ProductService; +import com.loopers.domain.like.event.LikeToggledEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +class LikeMetricsEventListenerTest { + + @InjectMocks + private LikeMetricsEventListener listener; + + @Mock + private ProductService productService; + + @Mock + private CacheManager cacheManager; + + @Mock + private Cache cache; + + @DisplayName("liked=true 이벤트 수신 시 like_count 증가 + 캐시 evict") + @Test + void incrementsLikeCountAndEvictsCache() { + // given + LikeToggledEvent event = new LikeToggledEvent(100L, true); + given(cacheManager.getCache("productDetail")).willReturn(cache); + + // when + listener.handleLikeMetrics(event); + + // then + then(productService).should().incrementLikeCount(100L); + then(cache).should().evict(100L); + } + + @DisplayName("liked=false 이벤트 수신 시 like_count 감소 + 캐시 evict") + @Test + void decrementsLikeCountAndEvictsCache() { + // given + LikeToggledEvent event = new LikeToggledEvent(100L, false); + given(cacheManager.getCache("productDetail")).willReturn(cache); + + // when + listener.handleLikeMetrics(event); + + // then + then(productService).should().decrementLikeCount(100L); + then(cache).should().evict(100L); + } +} +``` + +**Step 2: 테스트 실행 — 실패 확인** + +Run: `./gradlew :apps:commerce-api:test --tests "com.loopers.application.like.LikeMetricsEventListenerTest" -i` +Expected: FAIL — 클래스 없음 + +**Step 3: 리스너 구현** + +```java +package com.loopers.application.like; + +import com.loopers.application.product.ProductService; +import com.loopers.domain.like.event.LikeToggledEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@RequiredArgsConstructor +@Component +public class LikeMetricsEventListener { + + private final ProductService productService; + private final CacheManager cacheManager; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void handleLikeMetrics(LikeToggledEvent event) { + if (event.liked()) { + productService.incrementLikeCount(event.productId()); + } else { + productService.decrementLikeCount(event.productId()); + } + evictProductDetailCache(event.productId()); + log.info("좋아요 집계 처리: productId={}, liked={}", event.productId(), event.liked()); + } + + private void evictProductDetailCache(Long productId) { + var cache = cacheManager.getCache("productDetail"); + if (cache != null) { + cache.evict(productId); + } + } +} +``` + +**Step 4: 테스트 실행 — 성공 확인** + +Run: `./gradlew :apps:commerce-api:test --tests "com.loopers.application.like.LikeMetricsEventListenerTest" -i` +Expected: PASS + +--- + +## Task 4: 기존 좋아요 테스트 수정 + 통합 확인 + +LikeTransactionService의 의존성이 바뀌었으므로 기존 테스트를 업데이트한다. + +**Files:** +- Modify: `apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeTest.java` (필요 시) +- Modify: `apps/commerce-api/src/test/java/com/loopers/application/like/LikeTransactionCacheTest.java` (필요 시) + +**Step 1: 전체 좋아요 테스트 실행하여 깨진 테스트 확인** + +Run: `./gradlew :apps:commerce-api:test --tests "com.loopers.application.like.*" --tests "com.loopers.domain.like.*" -i` + +**Step 2: 깨진 테스트를 새 구조에 맞게 수정** + +구체적 수정 내용은 Step 1 결과에 따라 결정. 주로: +- `ProductService`, `CacheManager` mock 제거 +- `ApplicationEventPublisher` mock 추가 +- 이벤트 발행 검증으로 assertion 변경 + +**Step 3: 전체 테스트 실행** + +Run: `./gradlew :apps:commerce-api:test -i` +Expected: ALL PASS + +--- + +## Task 5: OrderPlacedEvent + PaymentCompletedEvent 정의 + +주문/결제 도메인 이벤트를 정의한다. + +**Files:** +- Create: `apps/commerce-api/src/main/java/com/loopers/domain/order/event/OrderPlacedEvent.java` +- Create: `apps/commerce-api/src/main/java/com/loopers/domain/payment/event/PaymentCompletedEvent.java` + +**Step 1: 이벤트 record 작성** + +```java +package com.loopers.domain.order.event; + +public record OrderPlacedEvent( + Long orderId, + Long userId, + Long totalAmountValue +) {} +``` + +```java +package com.loopers.domain.payment.event; + +public record PaymentCompletedEvent( + Long paymentId, + Long orderId, + Long userId, + boolean success // true: 결제 성공, false: 결제 실패 +) {} +``` + +--- + +## Task 6: OrderFacade에서 OrderPlacedEvent 발행 + +**Files:** +- Modify: `apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java` +- Modify: `apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeTest.java` + +**Step 1: 실패하는 테스트 작성 — 주문 생성 시 이벤트 발행 검증** + +```java +// OrderFacadeTest에 추가 +@DisplayName("주문 생성 성공 시 OrderPlacedEvent를 발행한다") +@Test +void publishesOrderPlacedEventWhenOrderCreated() { + // given - 기존 placeOrder 성공 setup 재사용 + // ... (기존 mock setup) + + // when + orderFacade.placeOrder(userId, commands, null); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(OrderPlacedEvent.class); + then(eventPublisher).should().publishEvent(captor.capture()); + assertThat(captor.getValue().userId()).isEqualTo(userId); +} +``` + +**Step 2: OrderFacade에 ApplicationEventPublisher 추가 + placeOrder 끝에 이벤트 발행** + +```java +// OrderFacade에 필드 추가 +private final ApplicationEventPublisher eventPublisher; + +// placeOrder 메서드 마지막에 추가 (return 직전) +eventPublisher.publishEvent(new OrderPlacedEvent( + order.getId(), userId, totalAmount.value() +)); +``` + +**Step 3: 테스트 실행** + +Run: `./gradlew :apps:commerce-api:test --tests "com.loopers.application.order.OrderFacadeTest" -i` +Expected: PASS + +--- + +## Task 7: PaymentFacade에서 PaymentCompletedEvent 발행 + +**Files:** +- Modify: `apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java` +- Modify: `apps/commerce-api/src/test/java/com/loopers/application/payment/PaymentFacadeTest.java` + +**Step 1: 실패하는 테스트 — 결제 콜백 성공 시 이벤트 발행 검증** + +```java +// PaymentFacadeTest에 추가 +@DisplayName("결제 성공 콜백 시 PaymentCompletedEvent(success=true)를 발행한다") +@Test +void publishesPaymentCompletedEventOnSuccess() { + // given + // ... (기존 handleCallback 성공 setup) + + // when + paymentFacade.handleCallback(transactionKey, "SUCCESS", null); + + // then + ArgumentCaptor captor = ArgumentCaptor.forClass(PaymentCompletedEvent.class); + then(eventPublisher).should().publishEvent(captor.capture()); + assertThat(captor.getValue().success()).isTrue(); +} +``` + +**Step 2: PaymentFacade.handleCallback()에 이벤트 발행 추가** + +```java +// handleCallback 메서드 끝에 추가 +eventPublisher.publishEvent(new PaymentCompletedEvent( + payment.getId(), payment.orderId(), payment.userId(), "SUCCESS".equals(status) +)); +``` + +**Step 3: 테스트 실행** + +Run: `./gradlew :apps:commerce-api:test --tests "com.loopers.application.payment.PaymentFacadeTest" -i` +Expected: PASS + +--- + +## Task 8: UserActivityEventListener 구현 + +모든 도메인 이벤트를 구독하여 유저 행동 로깅을 처리한다. + +**Files:** +- Create: `apps/commerce-api/src/main/java/com/loopers/application/logging/UserActivityEventListener.java` +- Create: `apps/commerce-api/src/test/java/com/loopers/application/logging/UserActivityEventListenerTest.java` + +**Step 1: 실패하는 테스트 작성** + +```java +package com.loopers.application.logging; + +import com.loopers.domain.like.event.LikeToggledEvent; +import com.loopers.domain.order.event.OrderPlacedEvent; +import com.loopers.domain.payment.event.PaymentCompletedEvent; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.assertj.core.api.Assertions.assertThatNoException; + +@ExtendWith(MockitoExtension.class) +class UserActivityEventListenerTest { + + @InjectMocks + private UserActivityEventListener listener; + + @DisplayName("주문 이벤트 수신 시 로깅 처리한다") + @Test + void logsOrderPlacedEvent() { + assertThatNoException().isThrownBy(() -> + listener.handleOrderPlaced(new OrderPlacedEvent(1L, 1L, 50000L)) + ); + } + + @DisplayName("결제 이벤트 수신 시 로깅 처리한다") + @Test + void logsPaymentCompletedEvent() { + assertThatNoException().isThrownBy(() -> + listener.handlePaymentCompleted(new PaymentCompletedEvent(1L, 1L, 1L, true)) + ); + } + + @DisplayName("좋아요 이벤트 수신 시 로깅 처리한다") + @Test + void logsLikeToggledEvent() { + assertThatNoException().isThrownBy(() -> + listener.handleLikeToggled(new LikeToggledEvent(1L, true)) + ); + } +} +``` + +**Step 2: 리스너 구현** + +```java +package com.loopers.application.logging; + +import com.loopers.domain.like.event.LikeToggledEvent; +import com.loopers.domain.order.event.OrderPlacedEvent; +import com.loopers.domain.payment.event.PaymentCompletedEvent; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +public class UserActivityEventListener { + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleOrderPlaced(OrderPlacedEvent event) { + log.info("[UserActivity] 주문 생성 - userId={}, orderId={}, amount={}", + event.userId(), event.orderId(), event.totalAmountValue()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePaymentCompleted(PaymentCompletedEvent event) { + log.info("[UserActivity] 결제 {} - userId={}, orderId={}, paymentId={}", + event.success() ? "성공" : "실패", event.userId(), event.orderId(), event.paymentId()); + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeToggled(LikeToggledEvent event) { + log.info("[UserActivity] 좋아요 {} - productId={}", + event.liked() ? "등록" : "취소", event.productId()); + } +} +``` + +**Step 3: 테스트 실행** + +Run: `./gradlew :apps:commerce-api:test --tests "com.loopers.application.logging.UserActivityEventListenerTest" -i` +Expected: PASS + +--- + +## Task 9: 통합 테스트 + E2E 확인 + +전체 이벤트 플로우가 실제 Spring Context에서 동작하는지 확인한다. + +**Files:** +- Modify: `apps/commerce-api/src/test/java/com/loopers/application/like/LikeFacadeIntegrationTest.java` +- 실행: 기존 E2E 테스트 전체 + +**Step 1: 전체 테스트 실행** + +Run: `./gradlew :apps:commerce-api:test -i` +Expected: ALL PASS + +**Step 2: E2E 테스트로 이벤트 동작 확인** + +Run: `./gradlew :apps:commerce-api:test --tests "*E2ETest" -i` +Expected: ALL PASS — 이벤트가 실제로 발행/소비되어 like_count가 정상 반영 + +--- + +## Task 10: .http 파일 업데이트 + 최종 정리 + +**Files:** +- Modify: `.http/` 디렉토리의 관련 .http 파일 (있다면) + +**Step 1: 전체 빌드** + +Run: `./gradlew build` +Expected: BUILD SUCCESSFUL + +**Step 2: 불필요한 import 정리, unused 코드 제거** + +LikeTransactionService에서 제거된 ProductService, CacheManager import 확인. + +--- + +## 체크리스트 (과제 요구사항 매핑) + +- [x] 주문–결제 플로우에서 부가 로직을 이벤트 기반으로 분리 (Task 6, 7) +- [x] 좋아요 처리와 집계를 이벤트 기반으로 분리 (Task 2, 3) +- [x] 유저 행동 로깅을 이벤트로 처리 (Task 8) +- [x] 트랜잭션 간의 연관관계 고려 — AFTER_COMMIT + REQUIRES_NEW (Task 3) diff --git a/docs/requirements/round5-requirement.md b/docs/requirements/round5-requirement.md new file mode 100644 index 000000000..9d9eda8c3 --- /dev/null +++ b/docs/requirements/round5-requirement.md @@ -0,0 +1,130 @@ +# 📝 Round 5 Quests + +--- + +### 🤖 가정한 설계에 대해 점검하기 + +- **읽기 최적화에 대해** 고민해 보고, 그 구현 의도와 대안들에 대해 빠르게 분석하고 학습하는 데에 AI 를 활용해 봅니다. +- AI 는 제공된 구성과 설계에 대해 리스크 등을 검토하고 분석해 설계 의도를 명확히 하고, Trade-off 에 대해 이해합니다. +- **프롬프트 예시** + + ```markdown + 너는 대규모 트래픽 환경에서 백엔드 시스템 설계를 리뷰하는 시니어 아키텍트다. + 아래는 내가 설계한 구조 및 구현 코드에 대한 내용이다. + + [설계 설명] + - 조회 API 목적: + - 주요 조회 조건: + - 사용한 테이블/데이터: + - 해당 테이블의 인덱스: + - 캐시 적용 여부 및 위치: + - 캐시 키 전략: + - 캐시 TTL 가정: + + 위 내용을 기준으로 다음 관점에서 분석해줘. + + 1. 이 설계가 성립하기 위해 반드시 참이어야 하는 전제 조건은 무엇인가? + 2. 트래픽이 10배 증가했을 때 가장 먼저 병목이 될 지점은 어디인가? + 3. 캐시 적중률이 30% 이하로 떨어졌을 때 발생할 문제는? + 4. 데이터 정합성이 깨질 수 있는 시나리오를 2가지 이상 제시해줘. + 5. 이 설계를 유지하면서 가장 나중까지 미룰 수 있는 개선은 무엇인가? + 6. 반대로, 가장 먼저 손대야 할 위험 요소는 무엇인가? + + ❗주의: + - 구현 코드나 설정 값은 제안하지 마라. + - 구조적 리스크와 사고 관점에서만 답변하라. + ``` + + +--- + +## 💻 Implementation Quest + +> 실제 트래픽에서 자주 발생하는 조회 병목 문제를 해결하는 방법을 실습합니다. +> +> +> 좋아요 수 기반 정렬, 브랜드 필터링, 인기 상품 조회 등에서 성능 저하가 발생할 수 있습니다. +> +> 이를 인덱스, 비정규화, 캐시 등 구조적인 접근으로 해결하는 것이 목표입니다. +> + + + +### 📋 과제 정보 + +아래 세 가지 **성능 개선을 수행**합니다. + +> 모두 수행하는 것이 더 좋습니다. 선택 이유 및 AS-IS, TO-BE 에 대해서는 블로그에 첨부해 주세요. +> + +--- + +**① 상품 목록 조회 성능 개선** + +- 상품 데이터를 10만개 이상 준비합니다 (각 컬럼의 값은 다양하게 분포하도록 합니다 ) +- 브랜드 필터 + 좋아요 순 정렬 기능을 구현하고, **`EXPLAIN`** 분석을 통해 인덱스 최적화를 수행합니다. +- 성능 개선 전후 비교를 포함해 주세요. + +**② 좋아요 수 정렬 구조 개선** + +- **비정규화**(**`like_count`**) 혹은 **MaterializedView** 중 하나를 선택하여 좋아요 수 정렬 성능을 개선합니다. +- 좋아요 등록/취소 시 count 동기화 처리 방식이 누락되어 있다면 이 또한 함께 구현합니다. + +**③ 캐시 적용** + +- 상품 상세 API 및 상품 목록 API에 **Redis 캐시**를 적용합니다. +- TTL 설정, 캐시 키 설계, 무효화 전략 중 하나 이상 포함해 주세요. + +--- + +## ✅ Checklist + +### 🔖 Index + +- [ ] 상품 목록 API에서 brandId 기반 검색, 좋아요 순 정렬 등을 처리했다 +- [ ] 조회 필터, 정렬 조건별 유즈케이스를 분석하여 인덱스를 적용하고 전 후 성능비교를 진행했다 + +### ❤️ Structure + +- [ ] 상품 목록/상세 조회 시 좋아요 수를 조회 및 좋아요 순 정렬이 가능하도록 구조 개선을 진행했다 +- [ ] 좋아요 적용/해제 진행 시 상품 좋아요 수 또한 정상적으로 동기화되도록 진행하였다 + +### ⚡ Cache + +- [ ] Redis 캐시를 적용하고 TTL 또는 무효화 전략을 적용했다 +- [ ] 캐시 미스 상황에서도 서비스가 정상 동작하도록 처리했다. + +--- + +## ✍️ Technical Writing Quest + +> 이번 주에 학습한 내용, 과제 진행을 되돌아보며 +**"내가 어떤 판단을 하고 왜 그렇게 구현했는지"** 를 글로 정리해봅니다. +> +> +> **좋은 블로그 글은 내가 겪은 문제를, 타인도 공감할 수 있게 정리한 글입니다.** +> +> 이 글은 단순 과제가 아니라, **향후 이직에 도움이 될 수 있는 포트폴리오** 가 될 수 있어요. +> + +### 🎯 Feature Suggestions + +- 좋아요 순으로 정렬하자 서버가 하염없이 느려졌다. +- 우리가 책을 읽을 때, 책갈피가 필요한 이유 (Feat. Index) +- 상품 목록에 좋아요 수를 포함하려고 했더니! +- 캐시를 적용했더니 실제 DB 로 가던 호출이 줄어들었다. +- 캐시 전략에서 TTL, 키 설계, 무효화 기준은 어떻게 결정했는가? +- 정합성과 성능 사이에서 어떤 트레이드오프를 선택했는가? \ No newline at end of file diff --git a/docs/requirements/round6-requirement.md b/docs/requirements/round6-requirement.md new file mode 100644 index 000000000..a09aeea2c --- /dev/null +++ b/docs/requirements/round6-requirement.md @@ -0,0 +1,230 @@ +# 📝 Round 6 Quests + +--- + +## 💻 Implementation Quest + +> 외부 시스템(PG) 장애 및 지연에 대응하는 Resilience 설계를 학습하고 적용해봅니다. +`pg-simulator` 모듈을 활용하여 다양한 비동기 시스템과의 연동 및 실패 시나리오를 구현, 점검합니다. +> + + + +### 🤖 나의 시니어 파트너 + +- **외부 시스템과 연동되는 기능 설계를 분석**하고, **개발자와의 질의응답**을 통해 구조를 명확히 하며, **상태 불일치·트랜잭션 경계·장애 시나리오 관점**에서 리스크를 드러낼 수 있는 Skills 를 작성해봅니다. + + 작성 예시 ( **`~/.claude/skills/anylize-external-integration/SKILL.md`** ) + + ```markdown + --- + name: analyze-external-integration + description: + 외부 시스템(결제, 재고 시스템, 메시징, 서드파티 API 등)과 연동되는 기능의 설계를 분석한다. + 트랜잭션 경계, 상태 일관성, 실패 시나리오, 재시도 및 중복 실행 가능성을 중심으로 리스크를 드러낸다. + 설계를 대신 생성하지 않으며, 이미 작성된 설계를 검증하고 개선 선택지를 제시하는 데 사용한다. + 외부 시스템 호출이 포함된 기능 구현 전/후 설계 리뷰 목적으로 사용한다. + --- + + 외부 시스템 연동 설계를 분석할 때 반드시 다음 흐름을 따른다. + + ### 1️⃣ 기능이 아니라 "불확실성" 관점으로 재해석한다 + - 단순 호출 순서를 요약하지 않는다. + - 외부 시스템은 항상 다음을 만족한다고 가정한다: + - 지연될 수 있다 + - 실패할 수 있다 + - 중복 실행될 수 있다 + - 성공했지만 응답이 유실될 수 있다 + - 현재 설계가 이러한 불확실성을 어떻게 다루는지 설명한다. + + --- + + ### 2️⃣ 트랜잭션 경계를 검증한다 + - 외부 호출이 트랜잭션 내부에 존재하는지 확인한다. + - 외부 시스템과 내부 DB 상태가 하나의 트랜잭션처럼 다뤄지고 있는지 분석한다. + - 다음 질문을 반드시 포함한다: + - 외부 호출 실패 시 내부 상태는 어떻게 되는가? + - 내부 커밋 이후 외부 호출 실패 시 복구 가능한가? + - 외부 성공 후 내부 실패 시 상태는 어떻게 정합성을 유지하는가? + + --- + + ### 3️⃣ 상태 기반으로 구조를 다시 본다 + - 호출 흐름이 아니라 상태 전이를 중심으로 설명한다. + - 내부 도메인 상태와 외부 시스템 상태를 분리해서 정리한다. + - 두 상태가 어긋날 수 있는 지점을 명시한다. + + --- + + ### 4️⃣ 중복 요청 및 재시도 가능성을 분석한다 + - 네트워크 재시도 상황을 가정한다. + - 동일 요청이 두 번 실행될 경우 문제를 설명한다. + - 멱등성(Idempotency) 고려 여부를 확인한다. + + --- + + ### 5️⃣ 장애 시나리오를 최소 3가지 이상 생성한다 + - 정상 흐름보다 실패 흐름을 우선한다. + - 각 장애 상황에서: + - 데이터 정합성 + - 상태 불일치 + - 복구 가능성 + 을 분석한다. + + --- + + ### 6️⃣ 해결책은 정답처럼 제시하지 않는다 + - 현재 구조의 장점과 리스크를 분리한다. + - 대안 구조가 있다면 선택지 형태로 제시한다. + 예: + - 동기 호출 유지 + - 상태 기반 단계 분리 + - 비동기 이벤트 전환 + - 각 선택지의 복잡도와 운영 부담을 함께 설명한다. + + --- + + ### 7️⃣ 톤 & 스타일 가이드 + - 코드 레벨 수정안을 직접 제시하지 않는다. + - 설계를 비판하지 말고 리스크를 드러내는 리뷰 톤을 유지한다. + - 외부 시스템은 항상 신뢰할 수 없다는 전제를 유지한다. + - 구현보다 책임, 경계, 상태 일관성을 중심으로 분석한다. + + ``` + + +외부 시스템과 연동되는 기능 설계를 분석하고, +개발자와의 질의응답을 통해 구조를 명확히 하며, +상태 불일치·트랜잭션 경계·장애 시나리오 관점에서 리스크를 드러낼 수 있는 Skills 를 작성해봅니다. + +외부 시스템과 연동되는 기능 설계를 분석하고, +개발자와의 질의응답을 통해 구조를 명확히 하며, +상태 불일치·트랜잭션 경계·장애 시나리오 관점에서 리스크를 드러낼 수 있는 Skills 를 작성해봅니다. + +### **📦 결제 기능 추가** + +- 주문에 대한 결제 기능을 추가합니다. +- 주문항목과 결제 수단을 입력받아, 외부 결제 시스템과 연동 후 주문에 대한 결제 처리를 하는 API 를 작성합니다. + +```java +## commerce-api +POST {{commerce-api}}/api/v1/payments +X-Loopers-LoginId: +X-Loopers-LoginPw: +Content-Type: application/json + +{ + "orderId": "1351039135", + "cardType": "SAMSUNG", + "cardNo": "1234-5678-9814-1451", +} +``` + +### 💰 **결제 시스템 연동** + +```java +## PG-Simulator +### 결제 요청 +POST {{pg-simulator}}/api/v1/payments +X-USER-ID: +Content-Type: application/json + +{ + "orderId": "1351039135", + "cardType": "SAMSUNG", + "cardNo": "1234-5678-9814-1451", + "amount" : "5000", + "callbackUrl": "http://localhost:8080/api/v1/examples/callback" +} + +### 결제 정보 확인 +GET {{pg-simulator}}/api/v1/payments/20250816:TR:9577c5 +X-USER-ID: + +### 주문에 엮인 결제 정보 조회 +GET {{pg-simulator}}/api/v1/payments?orderId=1351039135 +X-USER-ID: +``` + +- PG 기반 카드 결제 기능을 추가합니다. +- PG 시스템은 로컬에서 실행가능한 `pg-simulator` 모듈이 제공됩니다. ( 별도 SpringBootApp ) +- PG 시스템은 **비동기 결제** 기능을 제공합니다. + +> *비동기 결제란, 요청과 실제 처리가 분리되어 있음을 의미합니다.* +**요청 성공 확률 : 60% +요청 지연 :** 100ms ~ 500ms +**처리 지연** : 1s ~ 5s +**처리 결과** +* 성공 : 70% +* 한도 초과 : 20% +* 잘못된 카드 : 10% +> + +- java + +[GitHub - Loopers-dev-lab/loopback-be-l2-java-additionals: 루프팩 BE L2 수강생들을 위한 추가사항](https://github.com/Loopers-dev-lab/loopback-be-l2-java-additionals) + +- kotlin + +[6주차 과제 / pg-simulator 모듈 추가 by hubtwork · Pull Request #1 · Loopers-dev-lab/loopback-be-l2-kotlin-additionals](https://github.com/Loopers-dev-lab/loopback-be-l2-kotlin-additionals/pull/1) + +### 📋 과제 정보 + +- 외부 시스템에 대해 적절한 타임아웃 기준에 대해 고려해보고, 적용합니다. +- 외부 시스템의 응답 지연 및 실패에 대해서 대처할 방법에 대해 고민해 봅니다. +- PG 결제 결과를 적절하게 시스템과 연동하고 이를 기반으로 주문 상태를 안전하게 처리할 방법에 대해 고민해 봅니다. +- 서킷브레이커를 통해 외부 시스템의 지연, 실패에 대해 대응하여 서비스 전체가 무너지지 않도록 보호합니다. + +--- + +## ✅ Checklist + +### **⚡ PG 연동 대응** + +- [ ] PG 연동 API는 RestTemplate 혹은 FeignClient 로 외부 시스템을 호출한다. +- [ ] 응답 지연에 대해 타임아웃을 설정하고, 실패 시 적절한 예외 처리 로직을 구현한다. +- [ ] 결제 요청에 대한 실패 응답에 대해 적절한 시스템 연동을 진행한다. +- [ ] 콜백 방식 + **결제 상태 확인 API**를 활용해 적절하게 시스템과 결제정보를 연동한다. + +### **🛡 Resilience 설계** + +- [ ] 서킷 브레이커 혹은 재시도 정책을 적용하여 장애 확산을 방지한다. +- [ ] 외부 시스템 장애 시에도 내부 시스템은 **정상적으로 응답**하도록 보호한다. +- [ ] 콜백이 오지 않더라도, 일정 주기 혹은 수동 API 호출로 상태를 복구할 수 있다. +- [ ] PG 에 대한 요청이 타임아웃에 의해 실패되더라도 해당 결제건에 대한 정보를 확인하여 정상적으로 시스템에 반영한다. + +--- + +## ✍️ (Optional) Technical Writing Quest + +> 이번 주에 학습한 내용, 과제 진행을 되돌아보며 +**"내가 어떤 판단을 하고 왜 그렇게 구현했는지"** 를 글로 정리해봅니다. +> +> +> **좋은 블로그 글은 내가 겪은 문제를, 타인도 공감할 수 있게 정리한 글입니다.** +> +> 이 글은 단순 과제가 아니라, **향후 이직에 도움이 될 수 있는 포트폴리오** 가 될 수 있어요. +> + + +### 🎯 Feature Suggestions + +- PG 응답이 느려서 서킷브레이커가 열렸다…? +- 응답이 안 와서 실패 처리했는데, PG에선 결제가 됐다고…? +- 주문 상태는 Pending 인데, 사용자는 결제 안내를 받았다. +- PG 장애 하나로 주문 전체가 멈춰버렸다. +- 결제가 실패하면 주문을 무조건 롤백해야 할까? +- 재시도 횟수는 몇 번이 적절했을까? +- 폴백 처리를 어떻게 했지? \ No newline at end of file diff --git a/docs/requirements/round7-requirement.md b/docs/requirements/round7-requirement.md new file mode 100644 index 000000000..30267e6e7 --- /dev/null +++ b/docs/requirements/round7-requirement.md @@ -0,0 +1,153 @@ +# 📝 Round 7 Quests + +--- + +## 💻 Implementation Quest + +> 이벤트 기반 아키텍처의 **Why → How → Scale** 을 한 주에 관통합니다. +Spring `ApplicationEvent`로 **경계를 나누는 감각**을 익히고, +Kafka로 **이벤트 파이프라인**을 구축한 뒤, **선착순 쿠폰 발급**에 적용합니다 +> + + + +### 📋 과제 정보 + +**Step 1 — ApplicationEvent로 경계 나누기** + +- **무조건 이벤트 분리**가 아니라, 주요 로직과 부가 로직의 경계를 판단한다. +- 주문–결제 플로우에서 부가 로직(유저 행동 로깅, 알림 등)을 이벤트로 분리한다. +- 좋아요–집계 플로우에서 eventual consistency를 적용한다. +- 트랜잭션 결과와의 상관관계에 따라 적절한 리스너(`@TransactionalEventListener` phase 등)를 활용한다. +- "이걸 이벤트로 분리해야 하는가? 에 대한 **판단 기준** 자체가 학습 포인트다. + +**Step 2 — Kafka 이벤트 파이프라인** + +- `commerce-api` → Kafka → `commerce-collector` 구조로 확장한다. +- Step 1에서 분리한 이벤트 중, **시스템 간 전파가 필요한 것**을 Kafka로 발행한다. +- Producer는 **Transactional Outbox Pattern**으로 At Least Once 발행을 보장한다. +- Consumer는 이벤트를 수취해 집계(좋아요 수 / 판매량 / 조회 수)를 `product_metrics`에 upsert한다. + +**Step 3 — Kafka 기반 선착순 쿠폰 발급** + +- Step 2에서 익힌 Kafka를 **실전 시나리오**에 적용한다. +- API는 발급 요청을 Kafka에 발행만 하고, Consumer가 실제 쿠폰을 발급한다. +- 발급 수량 제한(e.g. 선착순 100명)에 대한 **동시성 제어**를 구현한다. + +**토픽 설계** (예시) + +- `catalog-events` (상품/재고/좋아요 이벤트, key=productId) +- `order-events` (주문/결제 이벤트, key=orderId) +- `coupon-issue-requests` (쿠폰 발급 요청, key=couponId) + +**Producer, Consumer 필수 처리** + +- **Producer** + - acks=all, idempotence=true 설정 +- **Consumer** + - **manual Ack** 처리 + - `event_handled(event_id PK)` (DB or Redis) 기반의 멱등 처리 + - `version` 또는 `updated_at` 기준으로 최신 이벤트만 반영 + +> *왜 이벤트 핸들링 테이블과 로그 테이블을 분리하는 걸까? 에 대해 고민해보자* +> + +--- + +## ✅ Checklist + +### 🧾 Step 1 — ApplicationEvent + +- [ ] 주문–결제 플로우에서 부가 로직을 이벤트 기반으로 분리한다. +- [ ] 좋아요 처리와 집계를 이벤트 기반으로 분리한다. (집계 실패와 무관하게 좋아요는 성공) +- [ ] 유저 행동(조회, 클릭, 좋아요, 주문 등)에 대한 서버 레벨 로깅을 이벤트로 처리한다. +- [ ] 동작의 주체를 적절하게 분리하고, 트랜잭션 간의 연관관계를 고민해 봅니다. + +### 🎾 Step 2 — Kafka Producer / Consumer + +- [ ] Step 1의 ApplicationEvent 중 **시스템 간 전파가 필요한 이벤트**를 Kafka로 발행한다. +- [ ] `acks=all`, `idempotence=true` 설정 +- [ ] **Transactional Outbox Pattern** 구현 +- [ ] PartitionKey 기반 이벤트 순서 보장 +- [ ] Consumer가 Metrics 집계 처리 (product_metrics upsert) +- [ ] `event_handled` 테이블을 통한 멱등 처리 구현 +- [ ] manual Ack + `version`/`updated_at` 기준 최신 이벤트만 반영 + +### 🎫 Step 3 — 선착순 쿠폰 발급 + +- [ ] 쿠폰 발급 요청 API → Kafka 발행 (비동기 처리) +- [ ] Consumer에서 선착순 수량 제한 + 중복 발급 방지 구현 +- [ ] 발급 완료/실패 결과를 유저가 확인할 수 있는 구조 설계 (polling or callback) +- [ ] 동시성 테스트 — 수량 초과 발급이 발생하지 않는지 검증 + +--- + +## ✍️ Technical Writing Quest + +> 이번 주에 학습한 내용, 과제 진행을 되돌아보며 +**"내가 어떤 판단을 하고 왜 그렇게 구현했는지"** 를 글로 정리해봅니다. +> +> +> **좋은 블로그 글은 내가 겪은 문제를, 타인도 공감할 수 있게 정리한 글입니다.** +> +> 이 글은 단순 과제가 아니라, **향후 이직에 도움이 될 수 있는 포트폴리오** 가 될 수 있어요. +> + +### 📚 Technical Writing Guide + +### ✅ 작성 기준 + +| 항목 | 설명 | +| --- | --- | +| **형식** | 블로그 | +| **길이** | 제한 없음, 단 꼭 **1줄 요약 (TL;DR)** 을 포함해 주세요 | +| **포인트** | “무엇을 했다” 보다 **“왜 그렇게 판단했는가”** 중심 | +| **예시 포함** | 코드 비교, 흐름도, 리팩토링 전후 예시 등 자유롭게 | +| **톤** | 실력은 보이지만, 자만하지 않고, **고민이 읽히는 글**예: “처음엔 mock으로 충분하다고 생각했지만, 나중에 fake로 교체하게 된 이유는…” | + +--- + +### ✨ 좋은 톤은 이런 느낌이에요 + +> 내가 겪은 실전적 고민을 다른 개발자도 공감할 수 있게 풀어내자 +> + +| 특징 | 예시 | +| --- | --- | +| 🤔 내 언어로 설명한 개념 | Stub과 Mock의 차이를 이번 주문 테스트에서 처음 실감했다 | +| 💭 판단 흐름이 드러나는 글 | 처음엔 도메인을 나누지 않았는데, 테스트가 어려워지며 분리했다 | +| 📐 정보 나열보다 인사이트 중심 | 테스트는 작성했지만, 구조는 만족스럽지 않다. 다음엔… | + +### ❌ 피해야 할 스타일 + +| 예시 | 이유 | +| --- | --- | +| 많이 부족했고, 반성합니다… | 회고가 아니라 일기처럼 보입니다 | +| Stub은 응답을 지정하고… | 내 생각이 아닌 요약문처럼 보입니다 | +| 테스트가 진리다 | 너무 단정적이거나 오만해 보입니다 | + +### 🎯 Feature Suggestions + +- ApplicationEvent만으로 충분한 경계 vs Kafka가 필요한 경계, 그 기준은? +- 트랜잭션 안에 다 넣을 수 있지만, 굳이 나누는 이유 +- 좋아요는 동기, 집계는 비동기 — 상품의 좋아요 수가 바로 반영되어야 할까? +- Outbox Pattern 없이 Kafka만 쓰면 어떤 일이 벌어질까? +- 선착순 쿠폰을 Redis로 처리하는 것과 Kafka로 처리하는 것의 차이 +- 100장 한정 쿠폰에 1만 명이 동시에 요청하면? +- 멱등 처리를 DB로 할 때와 Redis로 할 때의 트레이드오프 \ No newline at end of file diff --git a/docs/research.md b/docs/research.md new file mode 100644 index 000000000..ddaf87241 --- /dev/null +++ b/docs/research.md @@ -0,0 +1,107 @@ +# Round 6 - PG 결제 연동 & Resilience 설계 + +## 브랜치 전략 + +### 구조 + +``` +volume-5 (기존 작업 완료) + └─ volume-6 (통합 브랜치) + ├─ week6-feature/payment-domain ← 1단계 + ├─ week6-feature/resilience ← 2단계 + └─ week6-feature/payment-callback ← 3단계 +``` + +### 각 브랜치 범위 + +| 브랜치 | 작업 범위 | 산출물 | +|--------|-----------|--------| +| `payment-domain` | 결제 도메인 모델(PaymentModel, PaymentStatus) + PG 클라이언트(RestTemplate) + 결제 요청 API | Entity, Repository, Service, Facade, Controller, PgClient | +| `resilience` | Timeout, CircuitBreaker, Fallback 적용 + (Optional) Retry | resilience4j 의존성, 설정, Fallback 핸들러 | +| `payment-callback` | PG 콜백 수신 API + 결제 상태 확인 폴링/수동 복구 API | Callback Controller, 상태 복구 로직 | + +### 머지 순서 (순차 — 의존성 존재) + +``` +1. payment-domain → volume-6 (PR 머지) +2. volume-6에서 resilience 분기 → 작업 → volume-6 머지 +3. volume-6에서 payment-callback 분기 → 작업 → volume-6 머지 +4. volume-6 → main (최종 PR) +``` + +### 이유 + +- **순서 의존성**: resilience는 PG 클라이언트가 있어야 적용 가능, callback은 결제 도메인이 있어야 의미 있음 +- **기존 패턴 유지**: `volume-N` + `weekN-feature/*` 네이밍 컨벤션 +- **리뷰 단위 분리**: 도메인/Resilience/콜백 각각 독립 리뷰 가능 + +--- + +## 현재 프로젝트 상태 분석 + +### 존재하는 것 + +| 항목 | 상태 | 경로/비고 | +|------|------|-----------| +| Order 도메인 | ✅ 완전함 | domain/order/, application/order/, infrastructure/order/, interfaces/api/order/ | +| Money VO | ✅ | domain/product/Money.java | +| BaseEntity | ✅ | modules/jpa/.../BaseEntity.java | +| ErrorType/CoreException | ✅ | support/error/ | +| Docker (MySQL, Redis, Kafka) | ✅ | docker/infra-compose.yml | +| 테스트 인프라 (TestContainers) | ✅ | MySQL, Redis TestContainers | + +### 새로 필요한 것 + +| 항목 | 상태 | 비고 | +|------|------|------| +| pg-simulator 모듈 | ❌ | 별도 SpringBoot App, GitHub에서 가져와야 함 | +| Payment 도메인 | ❌ | PaymentModel, PaymentStatus, PaymentRepository 등 | +| PG 클라이언트 | ❌ | RestTemplate 기반 외부 호출 (프로젝트 최초) | +| resilience4j 의존성 | ❌ | CircuitBreaker, Timeout, Retry, Fallback | +| 결제 콜백 수신 API | ❌ | PG → commerce-api 콜백 엔드포인트 | + +### OrderStatus 현재 상태 + +```java +CREATED, CONFIRMED, SHIPPING, DELIVERED, CANCELLED +``` + +→ 결제 흐름 반영을 위해 `PAYMENT_PENDING`, `PAYMENT_COMPLETED`, `PAYMENT_FAILED` 등 추가 검토 필요 + +--- + +## 기술적 고려사항 + +### PG-Simulator 특성 + +| 항목 | 값 | +|------|-----| +| 요청 성공 확률 | 60% | +| 요청 지연 | 100ms ~ 500ms | +| 처리 지연 | 1s ~ 5s | +| 처리 결과 - 성공 | 70% | +| 처리 결과 - 한도 초과 | 20% | +| 처리 결과 - 잘못된 카드 | 10% | + +### 비동기 결제 흐름 + +``` +[commerce-api] → POST /api/v1/payments → [pg-simulator] + │ + (1s~5s 처리) + │ + ▼ +[commerce-api] ← POST callbackUrl ← [pg-simulator] +``` + +- 요청과 처리가 분리됨 +- 콜백이 오지 않을 수 있음 → 상태 확인 API로 폴링 필요 + +### Resilience 적용 대상 + +| 패턴 | 적용 지점 | 목적 | +|------|-----------|------| +| Timeout | PG 결제 요청 호출 | 응답 지연 시 빠른 실패 | +| CircuitBreaker | PG 클라이언트 전체 | PG 장애 시 연쇄 실패 방지 | +| Fallback | CircuitBreaker OPEN 시 | 사용자에게 적절한 응답 반환 | +| Retry (Optional) | PG 결제 상태 확인 | 일시적 실패 복구 | diff --git a/docs/sql/seed-data.sql b/docs/sql/seed-data.sql new file mode 100644 index 000000000..f7d758136 --- /dev/null +++ b/docs/sql/seed-data.sql @@ -0,0 +1,209 @@ +-- ============================================================= +-- 대량 데이터 시딩 SQL (MySQL 8.0) +-- 브랜드 100개, 상품 100,000개, 재고 100,000개 +-- ============================================================= + +-- 기존 데이터 정리 +SET FOREIGN_KEY_CHECKS = 0; +TRUNCATE TABLE stock; +TRUNCATE TABLE product; +TRUNCATE TABLE brand; +SET FOREIGN_KEY_CHECKS = 1; + +-- ============================================================= +-- 1. 브랜드 100개 생성 +-- ============================================================= +DELIMITER $$ + +DROP PROCEDURE IF EXISTS seed_brands$$ +CREATE PROCEDURE seed_brands() +BEGIN + DECLARE i INT DEFAULT 1; + DECLARE now_ts DATETIME(6); + SET now_ts = NOW(6); + + WHILE i <= 100 DO + INSERT INTO brand (name, description, created_at, updated_at, deleted_at) + VALUES ( + CONCAT('Brand_', LPAD(i, 3, '0')), + CONCAT('브랜드 ', i, ' 설명'), + now_ts, + now_ts, + NULL + ); + SET i = i + 1; + END WHILE; +END$$ + +DELIMITER ; + +CALL seed_brands(); +DROP PROCEDURE IF EXISTS seed_brands; + +-- ============================================================= +-- 2. 상품 100,000개 생성 (브랜드당 약 1,000개) +-- - price: 1,000 ~ 1,000,000 랜덤 +-- - like_count: 0 ~ 10,000 랜덤 +-- - 배치 INSERT (1,000건씩) +-- ============================================================= +DELIMITER $$ + +DROP PROCEDURE IF EXISTS seed_products$$ +CREATE PROCEDURE seed_products() +BEGIN + DECLARE i INT DEFAULT 1; + DECLARE batch_count INT DEFAULT 0; + DECLARE brand_id_val BIGINT; + DECLARE price_val INT; + DECLARE like_val INT; + DECLARE now_ts DATETIME(6); + DECLARE sql_text LONGTEXT; + + SET now_ts = NOW(6); + SET sql_text = ''; + + WHILE i <= 100000 DO + -- 브랜드 순환 배정: 1~100 + SET brand_id_val = ((i - 1) % 100) + 1; + -- price: 1,000 ~ 1,000,000 + SET price_val = FLOOR(1000 + RAND() * 999001); + -- like_count: 0 ~ 10,000 + SET like_val = FLOOR(RAND() * 10001); + + IF batch_count = 0 THEN + SET sql_text = CONCAT( + 'INSERT INTO product (name, description, price, brand_id, like_count, created_at, updated_at, deleted_at) VALUES ', + '(''', CONCAT('Product_', LPAD(i, 6, '0')), ''', ', + '''', CONCAT('상품 ', i, ' 설명'), ''', ', + price_val, ', ', brand_id_val, ', ', like_val, ', ', + '''', now_ts, ''', ''', now_ts, ''', NULL)' + ); + ELSE + SET sql_text = CONCAT(sql_text, + ', (''', CONCAT('Product_', LPAD(i, 6, '0')), ''', ', + '''', CONCAT('상품 ', i, ' 설명'), ''', ', + price_val, ', ', brand_id_val, ', ', like_val, ', ', + '''', now_ts, ''', ''', now_ts, ''', NULL)' + ); + END IF; + + SET batch_count = batch_count + 1; + + IF batch_count = 1000 OR i = 100000 THEN + SET @dynamic_sql = sql_text; + PREPARE stmt FROM @dynamic_sql; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + SET batch_count = 0; + SET sql_text = ''; + END IF; + + SET i = i + 1; + END WHILE; +END$$ + +DELIMITER ; + +CALL seed_products(); +DROP PROCEDURE IF EXISTS seed_products; + +-- ============================================================= +-- 3. 재고 100,000개 생성 (상품 1:1 대응) +-- - quantity: 0 ~ 500 랜덤 +-- - 배치 INSERT (1,000건씩) +-- ============================================================= +DELIMITER $$ + +DROP PROCEDURE IF EXISTS seed_stocks$$ +CREATE PROCEDURE seed_stocks() +BEGIN + DECLARE i INT DEFAULT 1; + DECLARE batch_count INT DEFAULT 0; + DECLARE qty_val INT; + DECLARE now_ts DATETIME(6); + DECLARE sql_text LONGTEXT; + + SET now_ts = NOW(6); + SET sql_text = ''; + + WHILE i <= 100000 DO + -- quantity: 0 ~ 500 + SET qty_val = FLOOR(RAND() * 501); + + IF batch_count = 0 THEN + SET sql_text = CONCAT( + 'INSERT INTO stock (product_id, quantity, created_at, updated_at, deleted_at) VALUES ', + '(', i, ', ', qty_val, ', ''', now_ts, ''', ''', now_ts, ''', NULL)' + ); + ELSE + SET sql_text = CONCAT(sql_text, + ', (', i, ', ', qty_val, ', ''', now_ts, ''', ''', now_ts, ''', NULL)' + ); + END IF; + + SET batch_count = batch_count + 1; + + IF batch_count = 1000 OR i = 100000 THEN + SET @dynamic_sql = sql_text; + PREPARE stmt FROM @dynamic_sql; + EXECUTE stmt; + DEALLOCATE PREPARE stmt; + SET batch_count = 0; + SET sql_text = ''; + END IF; + + SET i = i + 1; + END WHILE; +END$$ + +DELIMITER ; + +CALL seed_stocks(); +DROP PROCEDURE IF EXISTS seed_stocks; + +-- ============================================================= +-- 4. 시딩 결과 확인 +-- ============================================================= +SELECT '브랜드 수' AS label, COUNT(*) AS cnt FROM brand +UNION ALL +SELECT '상품 수', COUNT(*) FROM product +UNION ALL +SELECT '재고 수', COUNT(*) FROM stock; + +SELECT brand_id, COUNT(*) AS product_count +FROM product +GROUP BY brand_id +ORDER BY brand_id +LIMIT 10; + +-- ============================================================= +-- 5. EXPLAIN 분석 쿼리 +-- ============================================================= + +-- 5-1. 특정 브랜드의 인기순 상품 조회 +EXPLAIN ANALYZE +SELECT * FROM product +WHERE brand_id = 1 AND deleted_at IS NULL +ORDER BY like_count DESC +LIMIT 20; + +-- 5-2. 전체 인기순 상품 조회 +EXPLAIN ANALYZE +SELECT * FROM product +WHERE deleted_at IS NULL +ORDER BY like_count DESC +LIMIT 20; + +-- 5-3. 최신 상품 조회 +EXPLAIN ANALYZE +SELECT * FROM product +WHERE deleted_at IS NULL +ORDER BY created_at DESC +LIMIT 20; + +-- 5-4. 최저가 상품 조회 +EXPLAIN ANALYZE +SELECT * FROM product +WHERE deleted_at IS NULL +ORDER BY price ASC +LIMIT 20; diff --git a/docs/week6/01-pg-simulator-setup/research.md b/docs/week6/01-pg-simulator-setup/research.md new file mode 100644 index 000000000..1073f458d --- /dev/null +++ b/docs/week6/01-pg-simulator-setup/research.md @@ -0,0 +1,132 @@ +# 01. PG Simulator 셋업 + +## 현재 상태 + +- Java additionals 레포(`loopback-be-l2-java-additionals`)에는 **초기 커밋만 존재** (pg-simulator 미포함) +- Kotlin additionals 레포에 PR#1로 pg-simulator 구조가 공개됨 +- pg-simulator는 **별도 SpringBoot 앱** (port 8082)으로 동작 + +## pg-simulator 확보 방법 (선택지) + +### 방법 A: Kotlin 버전 직접 사용 (추천) + +```bash +# Kotlin additionals 레포를 별도 디렉토리에 클론 +git clone https://github.com/Loopers-dev-lab/loopback-be-l2-kotlin-additionals.git +cd loopback-be-l2-kotlin-additionals +git checkout origin/pg-simulator # 또는 PR 브랜치 + +# pg-simulator만 실행 +./gradlew :apps:pg-simulator:bootRun +``` + +- **장점**: 즉시 사용 가능, 별도 포팅 작업 불필요 +- **단점**: Kotlin 프로젝트를 별도로 관리해야 함 + +### 방법 B: Java로 포팅하여 현재 프로젝트에 추가 + +``` +apps/ +├── commerce-api/ # 기존 +├── commerce-batch/ # 기존 +├── commerce-streamer/ # 기존 +└── pg-simulator/ # 새로 추가 +``` + +settings.gradle.kts에 `":apps:pg-simulator"` 추가 필요. + +- **장점**: 하나의 프로젝트에서 관리, Java로 통일 +- **단점**: 포팅 작업 필요 (약 10개 클래스) + +### 방법 C: Java additionals 레포에 pg-simulator가 올라올 때까지 대기 + +- **장점**: 공식 제공물 사용 +- **단점**: 일정 불확실 + +## pg-simulator 아키텍처 (Kotlin PR 기반 분석) + +### 포트 설정 + +| 앱 | 서버 포트 | Actuator 포트 | +|----|-----------|---------------| +| commerce-api | 8080 | 8081 | +| pg-simulator | 8082 | 8083 | + +### API 엔드포인트 + +```http +## 결제 요청 (비동기) +POST /api/v1/payments +Headers: X-USER-ID +Body: { orderId, cardType, cardNo, amount, callbackUrl } +Response: { transactionKey, status: "PENDING" } + +## 결제 정보 확인 +GET /api/v1/payments/{transactionKey} +Headers: X-USER-ID +Response: { transactionKey, orderId, status, ... } + +## 주문에 엮인 결제 정보 조회 +GET /api/v1/payments?orderId={orderId} +Headers: X-USER-ID +Response: [ { transactionKey, status, ... } ] +``` + +### 내부 처리 흐름 + +``` +1. POST /api/v1/payments 수신 + → Payment 엔티티 생성 (status: PENDING) + → PaymentCreated 이벤트 발행 + → 즉시 응답 반환 (transactionKey) + +2. @Async 비동기 처리 (1s ~ 5s 지연) + → handle(transactionKey) 호출 + → 확률 기반 결과 결정: + - 70%: approve() → status: SUCCESS + - 20%: limitExceeded() → status: FAILED + - 10%: invalidCard() → status: FAILED + → PaymentHandled 이벤트 발행 + +3. 콜백 전송 + → callbackUrl로 POST 요청 + → 결제 결과(TransactionInfo) 전달 +``` + +### 요청 실패 시뮬레이션 + +- 요청 자체의 성공률: 60% (40%는 요청 단계에서 실패) +- 요청 지연: 100ms ~ 500ms +- 이 부분은 코드에서 어떻게 구현되었는지 추가 확인 필요 + +### 핵심 도메인 모델 + +``` +Payment (Entity) +├── transactionKey (PK, "20250816:TR:9577c5" 형식) +├── userId +├── orderId +├── cardType (SAMSUNG, KB, HYUNDAI) +├── cardNo +├── amount +├── callbackUrl +└── status (PENDING → SUCCESS | FAILED) +``` + +### 의존 모듈 + +```kotlin +implementation(project(":modules:jpa")) // JPA + QueryDSL +implementation(project(":modules:redis")) // Redis (필요시) +implementation(project(":supports:jackson")) // JSON +implementation(project(":supports:logging")) // 로깅 +implementation(project(":supports:monitoring")) // 메트릭 +``` + +## 결정 필요 사항 + +| 항목 | 질문 | +|------|------| +| **확보 방법** | A/B/C 중 어떤 방법으로 pg-simulator를 확보할 것인가? | +| **DB 분리** | pg-simulator와 commerce-api가 같은 MySQL을 쓸 것인가, 별도 DB를 쓸 것인가? | +| **동시 실행** | 두 앱을 동시에 실행하는 방법 (터미널 2개 vs docker-compose 추가) | diff --git a/docs/week6/02-payment-domain/research.md b/docs/week6/02-payment-domain/research.md new file mode 100644 index 000000000..fc739b44d --- /dev/null +++ b/docs/week6/02-payment-domain/research.md @@ -0,0 +1,201 @@ +# 02. Payment 도메인 설계 + +## 목적 + +주문에 대한 결제 정보를 관리하는 도메인 모델을 설계한다. +PG 시스템과의 연동 결과를 내부 시스템에 반영하기 위한 상태 관리가 핵심이다. + +--- + +## 핵심 설계 판단 + +### 1. Order vs Payment 분리 여부 + +| 방안 | 설명 | 장점 | 단점 | +|------|------|------|------| +| **A. Order에 결제 상태 포함** | OrderStatus에 PAYMENT_PENDING 등 추가 | 단순함, 조회 쉬움 | 주문과 결제 책임 혼재, 재결제 시 상태 이력 유실 | +| **B. Payment 엔티티 분리 (추천)** | 별도 PaymentModel로 결제 이력 관리 | 1주문:N결제 가능, 책임 분리 명확 | 조회 시 JOIN 필요 | + +**추천: B안** — 하나의 주문에 대해 결제 실패 → 재시도가 가능하므로, 결제 시도마다 별도 레코드가 필요하다. + +### 2. 결제 상태 모델 + +```java +public enum PaymentStatus { + PENDING, // 결제 요청 접수 (PG에 요청 전송 완료) + SUCCESS, // 결제 성공 (PG 콜백 수신) + FAILED, // 결제 실패 (PG 콜백 수신 또는 타임아웃) + CANCELLED // 결제 취소 +} +``` + +### 3. 주문 상태와의 연계 + +``` +현재 OrderStatus: CREATED → CONFIRMED → SHIPPING → DELIVERED + → CANCELLED + +결제 연동 후: +CREATED → PAYMENT_PENDING → CONFIRMED → SHIPPING → DELIVERED + → PAYMENT_FAILED → (재시도 가능) + → CANCELLED +``` + +**판단 필요**: OrderStatus에 `PAYMENT_PENDING`, `PAYMENT_FAILED`를 추가할지, 기존 상태를 유지하고 Payment 상태로만 관리할지 + +--- + +## 예상 엔티티 설계 + +### PaymentModel + +```java +@Entity +@Table(name = "payments") +public class PaymentModel extends BaseEntity { + + // BaseEntity의 id(Long, auto-increment) 사용 + + @Column(nullable = false, unique = true) + private String transactionKey; // PG에서 받은 거래 키 + + @Column(nullable = false) + private Long orderId; // 주문 ID (FK 대신 ID 참조) + + @Column(nullable = false) + private Long userId; // 결제 요청 사용자 + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private CardType cardType; // 카드사 + + @Column(nullable = false) + private String cardNo; // 카드번호 + + @Embedded + private Money amount; // 결제 금액 (기존 Money VO 재사용) + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PaymentStatus status; // 결제 상태 + + private String failureReason; // 실패 사유 (한도초과, 잘못된카드 등) +} +``` + +### 관계 설계 + +``` +OrderModel (1) ←──── (N) PaymentModel + - 하나의 주문에 여러 결제 시도 가능 + - PaymentModel은 orderId(Long)으로 참조 (JPA 연관관계 X) + - 이유: Order와 Payment의 생명주기가 다름, 느슨한 결합 유지 +``` + +**판단 필요**: `@ManyToOne` JPA 연관관계 vs 단순 ID 참조 + +--- + +## 패키지 구조 + +``` +com.loopers/ +├── domain/payment/ +│ ├── PaymentModel.java # 결제 엔티티 +│ ├── PaymentStatus.java # 결제 상태 Enum +│ ├── CardType.java # 카드사 Enum +│ └── PaymentRepository.java # Repository 인터페이스 +├── application/payment/ +│ ├── PaymentFacade.java # 결제 유스케이스 조합 +│ ├── PaymentService.java # 결제 도메인 서비스 +│ ├── PaymentInfo.java # Application DTO +│ └── PaymentCommand.java # 결제 요청 Command +├── infrastructure/payment/ +│ ├── PaymentRepositoryImpl.java # Repository 구현체 +│ └── PaymentJpaRepository.java # JPA Repository +└── interfaces/api/payment/ + ├── PaymentV1Controller.java # 결제 API + ├── PaymentV1ApiSpec.java # Swagger 인터페이스 + └── PaymentV1Dto.java # Request/Response DTO +``` + +--- + +## API 설계 + +### 결제 요청 + +```http +POST /api/v1/payments +X-Loopers-LoginId: user1 +X-Loopers-LoginPw: password + +{ + "orderId": 1351039135, + "cardType": "SAMSUNG", + "cardNo": "1234-5678-9814-1451" +} +``` + +**Response (결제 요청 접수)** +```json +{ + "meta": { "result": "SUCCESS" }, + "data": { + "paymentId": 1, + "transactionKey": "20250816:TR:9577c5", + "orderId": 1351039135, + "status": "PENDING", + "amount": 5000 + } +} +``` + +### 결제 상태 조회 + +```http +GET /api/v1/payments/{paymentId} +``` + +### 주문별 결제 내역 조회 + +```http +GET /api/v1/payments?orderId={orderId} +``` + +--- + +## 트랜잭션 경계 설계 + +``` +결제 요청 흐름: + +1. [TX-1] 주문 조회 + 결제 레코드 생성 (status: PENDING) + → 내부 DB 커밋 + +2. [TX 없음] PG 시스템 호출 (외부 HTTP) + → transactionKey 수신 + +3. [TX-2] transactionKey 업데이트 + → 내부 DB 커밋 + +--- (비동기 대기) --- + +4. [TX-3] PG 콜백 수신 → 결제 상태 업데이트 (SUCCESS/FAILED) + → 주문 상태 연동 +``` + +**핵심 원칙**: 외부 호출(PG)은 트랜잭션 밖에서 수행한다. +- 이유: PG 응답 지연(100ms~500ms) 동안 DB 커넥션 점유 방지 +- 리스크: TX-1 커밋 후 PG 호출 실패 시 PENDING 상태 결제 잔존 → 복구 로직 필요 + +--- + +## 결정 필요 사항 + +| # | 항목 | 선택지 | +|---|------|--------| +| 1 | Order-Payment 관계 | JPA `@ManyToOne` vs 단순 ID 참조 | +| 2 | OrderStatus 확장 | PAYMENT_PENDING/FAILED 추가 vs 기존 유지 | +| 3 | 결제 금액 산출 | Order의 finalAmount 사용 vs Request에서 받기 | +| 4 | 카드번호 저장 | 전체 저장 vs 마스킹 저장 vs 저장하지 않음 | diff --git a/docs/week6/03-pg-client-integration/research.md b/docs/week6/03-pg-client-integration/research.md new file mode 100644 index 000000000..eb9028e10 --- /dev/null +++ b/docs/week6/03-pg-client-integration/research.md @@ -0,0 +1,208 @@ +# 03. PG 클라이언트 연동 + +## 목적 + +commerce-api에서 pg-simulator로 HTTP 호출을 수행하는 클라이언트를 구현한다. +프로젝트 최초의 외부 시스템 호출이므로, 기반 패턴을 잘 잡는 것이 중요하다. + +--- + +## HTTP 클라이언트 선택지 + +| 방안 | 특징 | 장점 | 단점 | +|------|------|------|------| +| **RestTemplate** | Spring 기본 제공, 동기 블로킹 | 단순함, 별도 의존성 없음, 학습 비용 낮음 | Deprecated 방향 (유지보수 모드) | +| **RestClient** | Spring 6.1+ 신규, 동기 블로킹 | RestTemplate 대체, 현대적 API | Spring Boot 3.2+ 필요 (충족) | +| **WebClient** | Spring WebFlux, 비동기 논블로킹 | 비동기 지원, 유연함 | webflux 의존성 추가 필요, 학습 비용 | +| **FeignClient** | Spring Cloud, 선언적 HTTP | 인터페이스 기반, 깔끔 | spring-cloud 의존성, 추가 설정 | + +**추천: RestClient 또는 RestTemplate** +- 과제 요구사항에 "RestTemplate 혹은 FeignClient"로 명시 +- 프로젝트가 Spring Boot 3.4.4이므로 RestClient도 사용 가능 +- 비동기 결제이므로 HTTP 호출 자체는 동기로 충분 (PG가 즉시 응답 후 비동기 처리) + +--- + +## PG 클라이언트 설계 + +### 클래스 구조 + +```java +// infrastructure/payment/PgClient.java +@Component +public class PgClient { + + private final RestTemplate restTemplate; + + // 결제 요청 + public PgPaymentResponse requestPayment(PgPaymentRequest request) { ... } + + // 결제 상태 확인 + public PgPaymentResponse getPaymentStatus(String transactionKey) { ... } + + // 주문별 결제 조회 + public List getPaymentsByOrderId(String orderId) { ... } +} +``` + +### PG 요청/응답 DTO + +```java +// infrastructure/payment/dto/PgPaymentRequest.java +public record PgPaymentRequest( + String orderId, + String cardType, + String cardNo, + String amount, + String callbackUrl +) {} + +// infrastructure/payment/dto/PgPaymentResponse.java +public record PgPaymentResponse( + String transactionKey, + String orderId, + String status, // "PENDING", "SUCCESS", "FAILED" + String failureReason // nullable +) {} +``` + +--- + +## 타임아웃 설정 + +### PG 시스템 지연 특성 + +| 구간 | 지연 범위 | +|------|-----------| +| 요청 지연 | 100ms ~ 500ms | +| 처리 지연 | 1s ~ 5s (비동기, 콜백으로 수신) | + +### 타임아웃 후보값 + +| 설정 | 값 | 근거 | +|------|-----|------| +| Connection Timeout | 1s | 네트워크 연결 자체는 빠르게 실패해야 함 | +| Read Timeout | 2s ~ 3s | 요청 지연 최대 500ms의 4~6배. 여유 확보 | + +```java +// config/PgClientConfig.java +@Configuration +public class PgClientConfig { + + @Bean + public RestTemplate pgRestTemplate() { + return new RestTemplateBuilder() + .setConnectTimeout(Duration.ofSeconds(1)) + .setReadTimeout(Duration.ofSeconds(3)) + .rootUri("http://localhost:8082") + .build(); + } +} +``` + +### 타임아웃 후 처리 + +``` +타임아웃 발생 시: +1. PG 요청은 실패로 간주 +2. BUT PG 쪽에서는 요청을 받았을 수 있음 +3. → 결제 상태 확인 API로 실제 상태를 확인해야 함 +4. → PaymentModel은 PENDING 상태로 유지 +5. → 콜백 수신 또는 수동 확인으로 최종 상태 결정 +``` + +**이것이 이번 과제의 핵심 시나리오**: +> "PG에 대한 요청이 타임아웃에 의해 실패되더라도 해당 결제건에 대한 정보를 확인하여 정상적으로 시스템에 반영한다" + +--- + +## 콜백 수신 설계 + +### 콜백 엔드포인트 + +```http +POST /api/v1/payments/callback +Content-Type: application/json + +{ + "transactionKey": "20250816:TR:9577c5", + "orderId": "1351039135", + "status": "SUCCESS", + "failureReason": null +} +``` + +### 콜백 처리 흐름 + +``` +PG 콜백 수신 + → transactionKey로 PaymentModel 조회 + → 상태 업데이트 (PENDING → SUCCESS/FAILED) + → (SUCCESS 시) OrderModel 상태 업데이트 (→ CONFIRMED) + → (FAILED 시) 실패 사유 기록, 주문 상태는 유지 또는 변경 +``` + +### 콜백 미수신 대응 + +``` +시나리오: PG는 결제 성공했지만 콜백이 유실됨 + → PaymentModel은 PENDING 상태로 남아있음 + → 사용자는 결제가 된 건지 안 된 건지 모름 + +대응 방안: + A. 수동 상태 확인 API + GET /api/v1/payments/{paymentId}/verify + → PG 상태 확인 API 호출 → 결과 반영 + + B. 스케줄러 기반 자동 폴링 + → PENDING 상태가 N분 이상 지속된 결제건을 주기적으로 확인 + → @Scheduled(fixedDelay = 60000) // 1분마다 + + C. 두 방법 병행 (추천) +``` + +--- + +## commerce-api → pg-simulator 호출 매핑 + +| commerce-api 동작 | PG API | 용도 | +|-------------------|--------|------| +| 결제 요청 | POST /api/v1/payments | 결제 접수 | +| 상태 확인 (복구용) | GET /api/v1/payments/{txKey} | 콜백 미수신 시 확인 | +| 주문별 결제 조회 | GET /api/v1/payments?orderId=X | 주문의 모든 결제 시도 확인 | + +--- + +## callbackUrl 설정 + +```yaml +# application.yml +pg: + base-url: http://localhost:8082 + callback-url: http://localhost:8080/api/v1/payments/callback + timeout: + connect: 1000 # ms + read: 3000 # ms +``` + +```java +@ConfigurationProperties(prefix = "pg") +public record PgProperties( + String baseUrl, + String callbackUrl, + TimeoutProperties timeout +) { + public record TimeoutProperties(int connect, int read) {} +} +``` + +--- + +## 결정 필요 사항 + +| # | 항목 | 선택지 | +|---|------|--------| +| 1 | HTTP 클라이언트 | RestTemplate vs RestClient vs FeignClient | +| 2 | 콜백 미수신 대응 | 수동 API만 vs 스케줄러 병행 | +| 3 | PG 인증 | X-USER-ID 헤더에 어떤 값을 넣을지 (userId? loginId?) | +| 4 | callbackUrl | 설정 파일 관리 vs 하드코딩 | diff --git a/docs/week6/04-resilience/research.md b/docs/week6/04-resilience/research.md new file mode 100644 index 000000000..f5e6fd92a --- /dev/null +++ b/docs/week6/04-resilience/research.md @@ -0,0 +1,225 @@ +# 04. Resilience 설계 + +## 목적 + +PG 시스템 장애 및 지연에 대응하여 commerce-api 서비스 전체가 무너지지 않도록 보호한다. +Resilience4j 라이브러리를 활용하여 Timeout, CircuitBreaker, Fallback, (Optional) Retry를 적용한다. + +--- + +## Resilience4j 소개 + +Spring Boot 3 환경에서 사용하는 경량 장애 허용 라이브러리. +각 패턴을 데코레이터(어노테이션)로 적용할 수 있다. + +### 의존성 추가 + +```kotlin +// build.gradle.kts (commerce-api) +implementation("io.github.resilience4j:resilience4j-spring-boot3:2.2.0") +implementation("org.springframework.boot:spring-boot-starter-aop") // 어노테이션 기반 사용 +``` + +--- + +## 패턴별 분석 + +### 1. Timeout (Must-Have) + +**목적**: PG 응답 지연 시 빠르게 실패하여 스레드 점유 방지 + +**RestTemplate Timeout vs Resilience4j TimeLimiter 차이** + +| 항목 | RestTemplate Timeout | Resilience4j TimeLimiter | +|------|---------------------|-------------------------| +| 적용 대상 | HTTP 커넥션/읽기 레벨 | 메서드 실행 레벨 | +| 설정 위치 | RestTemplate Bean | 어노테이션 또는 설정 파일 | +| 메트릭 | 없음 | Prometheus 자동 연동 | +| 조합 | 단독 | CircuitBreaker와 조합 가능 | + +**두 가지 모두 적용하는 것이 좋다**: +- RestTemplate Timeout: HTTP 레벨 보호 (네트워크 이상) +- TimeLimiter: 비즈니스 레벨 보호 (전체 메서드 실행 시간 제한) + +```yaml +resilience4j: + timelimiter: + instances: + pgPayment: + timeout-duration: 3s + cancel-running-future: true +``` + +--- + +### 2. CircuitBreaker (Must-Have) + +**목적**: PG 시스템 장애 시 요청을 차단하여 연쇄 실패 방지 + +**상태 전이** + +``` +CLOSED (정상) → 실패율 임계값 초과 → OPEN (차단) + │ + 대기 시간 경과 + │ + ▼ + HALF_OPEN (시험) + │ │ + 성공 → CLOSED + 실패 → OPEN +``` + +**설정 시 주의점** + +PG 시스템의 기본 실패율이 40%(요청 성공률 60%)이므로, CircuitBreaker 임계값 설정이 까다롭다. + +``` +문제: failureRateThreshold = 50으로 설정하면? + → PG 기본 실패율(40%)과 가까워 정상 상황에서도 OPEN될 수 있음 + +해결: "PG 장애"와 "정상적인 결제 거절"을 구분해야 한다 +``` + +**실패 판정 기준 설계** + +| 상황 | CircuitBreaker 실패로 카운트? | 이유 | +|------|-------------------------------|------| +| 타임아웃 (ReadTimeout) | ✅ Yes | PG 시스템 지연 → 장애 징후 | +| 커넥션 실패 (ConnectException) | ✅ Yes | PG 시스템 다운 | +| HTTP 5xx 응답 | ✅ Yes | PG 서버 에러 | +| HTTP 4xx 응답 (잘못된 요청) | ❌ No | 클라이언트 문제, PG는 정상 | +| 결제 거절 (한도초과, 잘못된카드) | ❌ No | 비즈니스 실패, PG는 정상 동작 중 | + +**→ recordExceptions에 인프라 에러만 등록하고, 비즈니스 실패는 ignoreExceptions로 처리** + +```yaml +resilience4j: + circuitbreaker: + instances: + pgPayment: + sliding-window-type: COUNT_BASED + sliding-window-size: 10 # 최근 10건 기준 + failure-rate-threshold: 50 # 50% 이상 실패 시 OPEN + wait-duration-in-open-state: 30s # OPEN 후 30초 대기 + permitted-number-of-calls-in-half-open-state: 3 # HALF_OPEN에서 3건 시험 + record-exceptions: + - java.net.ConnectException + - java.net.SocketTimeoutException + - org.springframework.web.client.ResourceAccessException + ignore-exceptions: + - com.loopers.support.error.CoreException # 비즈니스 예외는 무시 +``` + +--- + +### 3. Fallback (Must-Have) + +**목적**: CircuitBreaker OPEN 시 사용자에게 적절한 응답 반환 + +**Fallback 전략 선택지** + +| 방안 | 설명 | 적합한 상황 | +|------|------|-------------| +| A. 즉시 에러 반환 | "결제 시스템 점검 중" 메시지 | 단순하지만 사용자 경험 떨어짐 | +| B. PENDING 저장 후 재시도 안내 | 결제 레코드를 만들되 PG 호출 생략, 복구 시 재시도 | PG 복구 후 이어서 처리 가능 | +| C. 대기열 저장 | 요청을 큐에 넣고 나중에 처리 | 가장 유연하지만 복잡도 높음 | + +**추천: A안 (즉시 에러 반환)** — 과제 범위에서는 충분하며, 사용자에게 명확한 피드백 제공 + +```java +@CircuitBreaker(name = "pgPayment", fallbackMethod = "paymentFallback") +public PgPaymentResponse requestPayment(PgPaymentRequest request) { + return pgClient.requestPayment(request); +} + +private PgPaymentResponse paymentFallback(PgPaymentRequest request, Exception e) { + throw new CoreException(ErrorType.INTERNAL_ERROR, + "결제 시스템이 일시적으로 불안정합니다. 잠시 후 다시 시도해주세요."); +} +``` + +--- + +### 4. Retry (Nice-To-Have) + +**목적**: 일시적 실패 시 자동 재시도 + +**주의**: 결제 요청에 Retry를 적용하면 **중복 결제 위험**이 있다. + +| 적용 대상 | Retry 적합성 | 이유 | +|-----------|-------------|------| +| 결제 요청 (POST) | ❌ 부적합 | 멱등하지 않음, 중복 결제 위험 | +| 결제 상태 확인 (GET) | ✅ 적합 | 조회는 멱등, 부작용 없음 | +| 콜백 미수신 복구 | ✅ 적합 | 상태 확인 후 반영, 멱등 처리 가능 | + +```yaml +resilience4j: + retry: + instances: + pgStatusCheck: + max-attempts: 3 + wait-duration: 2s + retry-exceptions: + - java.net.SocketTimeoutException + - org.springframework.web.client.ResourceAccessException +``` + +--- + +## 적용 아키텍처 + +### 레이어별 Resilience 적용 위치 + +``` +Controller → Facade → [Resilience 경계] → PgClient → PG System + │ + CircuitBreaker + TimeLimiter + Retry (GET만) + Fallback +``` + +**적용 위치**: `PgClient` 또는 `PaymentFacade` 레벨 + +| 위치 | 장점 | 단점 | +|------|------|------| +| PgClient (인프라) | 외부 호출에 가장 가까움, 관심사 분리 | Facade의 비즈니스 로직과 분리됨 | +| PaymentFacade (애플리케이션) | 비즈니스 컨텍스트 포함한 Fallback 가능 | 인프라 관심사가 애플리케이션에 침투 | + +**추천**: Resilience 어노테이션은 Facade에, 실제 HTTP 호출은 PgClient에 분리 + +--- + +## Actuator + Prometheus 연동 + +Resilience4j는 자동으로 Actuator 엔드포인트와 Prometheus 메트릭을 제공한다. + +```yaml +# application.yml +management: + endpoints: + web: + exposure: + include: health, prometheus, circuitbreakers, circuitbreakerevents + health: + circuitbreakers: + enabled: true +``` + +모니터링 가능 항목: +- 서킷 상태 (CLOSED/OPEN/HALF_OPEN) +- 실패율, 느린 호출 비율 +- 호출 횟수, 실패 횟수 + +--- + +## 결정 필요 사항 + +| # | 항목 | 선택지 | +|---|------|--------| +| 1 | CircuitBreaker 실패 판정 | 인프라 에러만 vs 모든 예외 | +| 2 | Fallback 전략 | 즉시 에러 vs PENDING 저장 후 재시도 | +| 3 | Retry 범위 | GET(상태확인)만 vs POST(결제요청)도 포함 | +| 4 | Resilience 적용 위치 | PgClient vs PaymentFacade | +| 5 | OPEN 대기 시간 | 30s vs 60s vs 설정 가능하게 | diff --git "a/docs/\353\202\230\353\247\214\354\235\230 \353\217\231\354\213\234\354\204\261 \354\240\234\354\226\264 \355\214\220\353\213\250\352\270\260\354\244\200.png" "b/docs/\353\202\230\353\247\214\354\235\230 \353\217\231\354\213\234\354\204\261 \354\240\234\354\226\264 \355\214\220\353\213\250\352\270\260\354\244\200.png" new file mode 100644 index 000000000..e4a9c2922 Binary files /dev/null and "b/docs/\353\202\230\353\247\214\354\235\230 \353\217\231\354\213\234\354\204\261 \354\240\234\354\226\264 \355\214\220\353\213\250\352\270\260\354\244\200.png" differ diff --git a/modules/kafka/src/main/resources/kafka.yml b/modules/kafka/src/main/resources/kafka.yml index 9609dbf85..b93a24e40 100644 --- a/modules/kafka/src/main/resources/kafka.yml +++ b/modules/kafka/src/main/resources/kafka.yml @@ -14,7 +14,11 @@ spring: producer: key-serializer: org.apache.kafka.common.serialization.StringSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + acks: all retries: 3 + properties: + enable.idempotence: true + max.in.flight.requests.per.connection: 5 consumer: group-id: loopers-default-consumer key-deserializer: org.apache.kafka.common.serialization.StringDeserializer diff --git a/modules/redis/src/main/java/com/loopers/config/redis/CustomCacheErrorHandler.java b/modules/redis/src/main/java/com/loopers/config/redis/CustomCacheErrorHandler.java new file mode 100644 index 000000000..1e60dc21b --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/config/redis/CustomCacheErrorHandler.java @@ -0,0 +1,29 @@ +package com.loopers.config.redis; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.interceptor.CacheErrorHandler; + +@Slf4j +public class CustomCacheErrorHandler implements CacheErrorHandler { + + @Override + public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) { + log.warn("Cache GET failed - cache: {}, key: {}, error: {}", cache.getName(), key, exception.getMessage()); + } + + @Override + public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) { + log.warn("Cache PUT failed - cache: {}, key: {}, error: {}", cache.getName(), key, exception.getMessage()); + } + + @Override + public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) { + log.warn("Cache EVICT failed - cache: {}, key: {}, error: {}", cache.getName(), key, exception.getMessage()); + } + + @Override + public void handleCacheClearError(RuntimeException exception, Cache cache) { + log.warn("Cache CLEAR failed - cache: {}, error: {}", cache.getName(), exception.getMessage()); + } +} diff --git a/modules/redis/src/main/java/com/loopers/config/redis/RedisCacheConfig.java b/modules/redis/src/main/java/com/loopers/config/redis/RedisCacheConfig.java new file mode 100644 index 000000000..d2025e242 --- /dev/null +++ b/modules/redis/src/main/java/com/loopers/config/redis/RedisCacheConfig.java @@ -0,0 +1,59 @@ +package com.loopers.config.redis; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.CachingConfigurer; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.interceptor.CacheErrorHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; +import java.util.Map; + +@Configuration +@EnableCaching +public class RedisCacheConfig implements CachingConfigurer { + + @Bean + public CacheManager cacheManager(LettuceConnectionFactory lettuceConnectionFactory) { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.activateDefaultTyping( + LaissezFaireSubTypeValidator.instance, + ObjectMapper.DefaultTyping.EVERYTHING, + JsonTypeInfo.As.PROPERTY + ); + + GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(objectMapper); + + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofMinutes(5)) + .disableCachingNullValues() + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer)); + + Map cacheConfigurations = Map.of( + "productDetail", defaultConfig.entryTtl(Duration.ofMinutes(10)) + ); + + return RedisCacheManager.builder(lettuceConnectionFactory) + .cacheDefaults(defaultConfig) + .withInitialCacheConfigurations(cacheConfigurations) + .build(); + } + + @Override + public CacheErrorHandler errorHandler() { + return new CustomCacheErrorHandler(); + } +} diff --git a/pr-body.md b/pr-body.md new file mode 100644 index 000000000..31bde36e6 --- /dev/null +++ b/pr-body.md @@ -0,0 +1,363 @@ +## Summary + +**배경**: 커머스 도메인에서 재고 차감, 쿠폰 사용, 좋아요 카운트 등 동시 요청 시 Lost Update, 초과 차감, 중복 사용 등의 정합성 문제가 발생할 수 있습니다. + +**목표**: 각 도메인의 동시성 리스크를 분석하고, 리스크 수준과 경합 특성에 맞는 동시성 제어 전략을 적용하여 데이터 정합성을 보장합니다. + +**결과**: 3개 도메인에 차별화된 동시성 전략을 적용하고, `CountDownLatch + ExecutorService` 기반 동시성 통합 테스트 4종으로 검증을 완료했습니다. 쿠폰 도메인 신규 구현 및 주문-쿠폰 연동도 포함됩니다. + +--- + +## Context & Decision + +### 문제 정의 + +**현재 동작/제약**: 기존 코드에는 동시성 제어가 없어, 동시 요청 시 데이터 정합성이 보장되지 않습니다. + +**문제 (리스크)**: +- **재고**: 10개 남은 상품에 동시 10명이 주문하면 재고가 음수로 떨어질 수 있습니다 (Lost Update → overselling → 주문 취소 → CS 비용) +- **쿠폰**: 동일 쿠폰을 여러 기기에서 동시에 사용하면 할인이 중복 적용됩니다 (매출 손실) +- **좋아요**: 동시 좋아요/취소 시 likeCount가 실제 좋아요 수와 불일치합니다 (데이터 신뢰도 하락) + +**성공 기준**: 모든 동시성 시나리오에서 데이터 정합성이 유지되며, `CountDownLatch + ExecutorService` 기반 통합 테스트 4종으로 검증 가능해야 합니다. + +--- + +### 동시성 제어 전략 선택 기준 + +위 문제들은 대부분 **Read-Modify-Write (R-M-W) 패턴**에서 발생합니다: +1. **Read**: DB에서 현재 값을 읽는다 (예: 재고 = 5) +2. **Modify**: 애플리케이션에서 계산한다 (예: 5 - 1 = 4) +3. **Write**: 계산 결과를 DB에 쓴다 (예: `UPDATE SET quantity = 4`) + +Read와 Write 사이의 **시간 간격(gap)** 동안 다른 스레드가 같은 값을 읽으면, 두 스레드 모두 `quantity = 4`를 쓰게 되어 **하나의 차감이 사라집니다 (Lost Update)**. + +이 gap을 해결하는 전략은 크게 3가지이며, 도메인별 경합 특성에 따라 다른 전략을 선택했습니다: + +| 전략 | 원리 | R-M-W gap 해결 방식 | +|------|------|---------------------| +| 비관적 락 | `SELECT ... FOR UPDATE`로 행을 잠금 | Read 시점부터 다른 스레드 접근 차단 → gap 자체를 직렬화 | +| 낙관적 락 | `@Version`으로 Write 시 충돌 감지 | gap은 허용하되, Write 시점에 충돌을 감지하여 재시도 | +| 원자적 업데이트 | `SET col = col + 1`로 DB가 직접 처리 | R-M을 DB 내부로 이동 → 애플리케이션 레벨 gap 자체가 없음 | + +### 전략 선택 판단 흐름 + +``` +Q1. 충돌 시 비즈니스에 치명적인가? + │ + ├── NO ──→ 원자적 업데이트 / 낙관적 락 + │ 예: 좋아요 (likeCount 불일치는 치명적이지 않음) + │ → 단순 증감이면 원자적 업데이트, R-M-W면 낙관적 락 + 재시도 + │ + └── YES ──→ Q2. 재시도 요청이 필요한가? + │ + ├── YES ──→ 낙관적 락 + 재시도 + │ + └── NO ───→ Q3. 충돌 빈도가 높은가? + │ + ├── NO ──→ 비관적 락 + │ 예: 쿠폰 (1회 사용, 경합 낮음) + │ + └── YES ─→ 비관적 락 + 트랜잭션 최소화 + 예: 재고 (인기 상품 경합 높음) + │ + ▼ + 트랜잭션이 긴가? + ├── YES → 락 점유 구간 최소화 + └── NO ──→ 여러 행을 잠그는가? + ├── YES → 락 순서 고정 (데드락 방지) + │ 예: productId 오름차순 정렬 + └── NO ──→ 적용 완료 +``` + +--- + +### 선택지와 결정 + +#### 1. 재고 차감 — 비관적 락 (PESSIMISTIC_WRITE) + +| 대안 | 장단점 | +|------|--------| +| A: 낙관적 락 (`@Version`) | 충돌 시 재시도 필요, 인기 상품은 재시도 폭주로 성능 저하 | +| B: 원자적 업데이트 (`SET quantity = quantity - 1`) | 재고 부족 검증이 애플리케이션 레벨에서 필요 (금액 계산, 부분 차감 등 복합 로직) | +| **C: 비관적 락 (`SELECT ... FOR UPDATE`)** | **직렬화로 정확성 보장, 대기 시간 발생** | + +**최종 결정: 비관적 락** + +- 재고는 정합성 실패의 비용이 극히 높습니다 (overselling → 주문 취소 → CS 비용 → 신뢰도 하락) +- 재고가 없다고 재시도할 필요 없음 — "모두 성공시키는 것"이 아니라 **"정확한 수량만 성공시키는 것"**이 목적 +- 원자적 업데이트로는 부족합니다: 재고 차감은 단순 `quantity - 1`이 아니라, **재고 부족 검증 → 금액 계산 → 주문 생성**이 하나의 트랜잭션에서 이루어져야 합니다. Read 단계에서 가져온 값으로 비즈니스 판단을 해야 하므로, Read와 Write 사이의 gap을 근본적으로 차단하는 비관적 락이 적합합니다. +- `productId` 오름차순 정렬로 락 획득 순서를 통일하여 **데드락 방지** +- **트레이드오프**: 동시 처리량(throughput)이 직렬화로 제한되지만, 재고 정합성이 더 중요 + +#### 2. 쿠폰 사용 — 비관적 락 + UniqueConstraint + +| 대안 | 장단점 | +|------|--------| +| A: 낙관적 락 (`@Version`) | 1회 사용에 적합하지만, 이미 사용된 쿠폰을 재시도하는 건 무의미 | +| B: 원자적 업데이트 | 쿠폰 사용은 단순 증감이 아님 — 상태 전이(AVAILABLE→USED) + 소유자 검증 + 금액 계산 등 복합 로직 | +| **C: 비관적 락 (`FOR UPDATE`)** | **사용됐으면 거절, 재시도 불필요** | + +**최종 결정: 비관적 락 + UniqueConstraint** + +- 쿠폰 사용은 금액과 직결 — 이중 쿠폰 적용 시 비즈니스 매출에 직접 영향 +- "한 번만 성공하면 되고, 실패한 요청은 재시도할 이유가 없다" → 비관적 락 +- 쿠폰 발급 중복은 `@UniqueConstraint(user_id, coupon_id)` + `DataIntegrityViolationException` 이중 방어 +- **트레이드오프**: 없음 — 쿠폰별 경합이 낮아 성능 영향 미미 + +#### 3. 좋아요 — 원자적 업데이트 (최종) ← 낙관적 락에서 전환 + +**초기 구현: 낙관적 락 (`@Version` + 재시도)** + +처음에는 `ProductModel`에 `@Version`을 두고, `LikeFacade`에서 `ObjectOptimisticLockingFailureException`을 catch하여 최대 10회 재시도하는 방식으로 구현했습니다. + +**문제 발견: `@Version` 스코프 간섭** + +`@Version`은 **엔티티 레벨**이지 **필드 레벨**이 아닙니다. 따라서: +- 좋아요 → version 증가 → 동시에 상품 수정 시 불필요한 충돌 (False Conflict) +- 상품 수정 → version 증가 → 동시에 좋아요 시 불필요한 재시도 폭주 +- 좋아요와 상품 수정은 **서로 무관한 연산**인데 같은 version을 경합 + +| 대안 | 장단점 | +|------|--------| +| A: 낙관적 락 유지 (`@Version`) | `@Version` 스코프가 엔티티 전체 → 상품 수정과 좋아요가 False Conflict | +| B: likeCount 별도 테이블 분리 | `@Version` 간섭 해결, 하지만 테이블 추가 + JOIN 필요 | +| **C: 원자적 업데이트 (`SET like_count = like_count + 1`)** | **JPA 변경 감지 우회 → `@Version` 트리거 안 됨, 재시도 불필요** | + +**최종 결정: 원자적 업데이트** + +- `likeCount` 증감은 순수한 `+1` / `-1` 연산 — R-M-W가 아니라 **단순 증감**이므로 원자적 업데이트가 자연스럽게 들어맞음 +- `@Modifying @Query("UPDATE ... SET like_count = like_count + 1")`로 DB가 직접 처리 → 애플리케이션 레벨 gap 없음 +- JPA 변경 감지를 우회하므로 `@Version`이 트리거되지 않음 → **상품 수정과의 False Conflict 근본적 해결** +- 재시도 로직(`retryOnOptimisticLock`), 트랜잭션 분리(`LikeTransactionService`의 별도 트랜잭션 경계) 등 **복잡도 대폭 감소** +- `@Version` 제거 → 좋아요 전용 version이 아닌 상품 전체 version이었던 문제 해소 +- **트레이드오프**: `@Modifying @Query`는 JPA 1차 캐시와 동기화되지 않으므로, 같은 트랜잭션 내에서 likeCount를 다시 읽으려면 `entityManager.refresh()` 필요. 현재 구조에서는 좋아요 후 즉시 likeCount를 재조회하지 않으므로 문제 없음. + +### 왜 원자적 업데이트를 재고/쿠폰에는 쓰지 않았나? + +| 기준 | 재고/쿠폰 | 좋아요 | +|------|-----------|--------| +| 연산 복잡도 | Read 후 비즈니스 판단 필요 (부족 검증, 소유자 확인, 금액 계산) | 단순 `+1` / `-1` | +| R-M-W 여부 | R-M-W 패턴 (Read 값으로 판단 후 Write) | 순수 증감 (Read 불필요) | +| 원자적 업데이트 적용 가능? | 불가 — 비즈니스 로직이 R과 W 사이에 있음 | **가능** — DB가 직접 처리 가능 | + +### 락 선택 판단 기준 요약 + +| 질문 | 비관적 락 (재고, 쿠폰) | 원자적 업데이트 (좋아요) | +|------|----------------------|------------------------| +| 실패한 요청을 재시도해야 하나? | 거절하면 됨 | 재시도 불필요 (DB가 보장) | +| 모든 요청이 성공해야 하나? | 한정 자원, 일부만 성공 | 전부 정당한 요청, 전부 성공 | +| 실패 시 비즈니스 손실? | 크다 (이중 결제, 초과 판매) | 작다 (좋아요 1초 늦게 반영) | +| Read 후 비즈니스 판단이 필요한가? | 필요 (재고 부족, 쿠폰 상태 확인) | 불필요 (단순 증감) | + +--- + +## Design Overview + +### 변경 범위 + +**영향 받는 도메인**: Brand, Product, Stock, Order, Like, Coupon(신규), CouponIssue(신규) + +**신규 추가**: +- Coupon / CouponIssue 도메인 — 쿠폰 정의 및 발급/사용 관리 +- 동시성 통합 테스트 4종 +- `@LoginMember`, `@AdminUser` 인증 어노테이션 + ArgumentResolver + +**주요 변경**: +- `StockJpaRepository`: `findByProductIdForUpdate()` — 비관적 락 조회 +- `CouponIssueJpaRepository`: `findByIdForUpdate()` — 비관적 락 조회 +- `ProductJpaRepository`: `incrementLikeCount()` / `decrementLikeCount()` — 원자적 업데이트 +- `CouponIssueModel`: `@UniqueConstraint(user_id, coupon_id)` — 중복 발급 방지 + +### 주요 컴포넌트 책임 + +| 컴포넌트 | 책임 | +|----------|------| +| `OrderFacade` | 주문 생성 시 productId 정렬 → 재고 차감(비관적 락) → 쿠폰 사용(비관적 락) → 주문 저장 | +| `LikeFacade` | 좋아요 진입점 — `LikeTransactionService`에 위임 | +| `LikeTransactionService` | 트랜잭션 경계 내에서 좋아요 토글 + 원자적 업데이트 실행 | +| `LikeToggleService` | 좋아요 도메인 의사결정 (신규/복구/멱등) — `LikeResult` 반환 | +| `CouponIssueService` | 쿠폰 발급(중복 방어) + 사용 처리(비관적 락) | +| `StockService` | `getByProductIdForUpdate()` — 비관적 락으로 재고 조회 | + +--- + +## Flow Diagrams + +### 1. 주문 처리 흐름 (재고 + 쿠폰 동시성 제어) + +``` +Client ─── POST /api/v1/orders ───> OrderV1Controller + | + v + OrderFacade.placeOrder() + +-- @Transactional --------------------------------+ + | | + | (1) 상품 조회 + 금액 계산 (락 없음) | + | productId 오름차순 정렬 | + | | + | (2) 재고 차감 (PESSIMISTIC_WRITE) | + | FOR UPDATE로 StockModel 잠금 | + | stock.decrease(quantity) | + | 재고 부족 시 -> 예외 -> 전체 롤백 | + | | + | (3) 쿠폰 검증+사용 (PESSIMISTIC_WRITE) | + | FOR UPDATE로 CouponIssueModel 잠금 | + | validateOwner() -> 소유권 확인 | + | validateUsable() -> 만료/금액 확인 | + | use() -> usedAt 설정 (USED 상태) | + | 실패 시 -> 예외 -> 전체 롤백 | + | | + | (4) 주문 생성 (INSERT) | + | OrderModel(총액, 할인액, 최종액) | + | OrderItemModel(상품 스냅샷) | + | | + | (5) 쿠폰에 orderId 연결 | + | | + +--- 커밋 --- 모든 락 해제 ------------------------+ +``` + +### 2. 재고 동시성 시나리오 (비관적 락) + +``` +재고: 5개 | Thread 1~10 동시 주문 요청 + +Thread 1 --> FOR UPDATE (잠금) --> decrease(1) --> 커밋 (재고: 4) +Thread 2 --> [대기] --------------------------------> decrease(1) --> 커밋 (재고: 3) +Thread 3 --> [대기] --------------------------------> decrease(1) --> 커밋 (재고: 2) +Thread 4 --> [대기] --------------------------------> decrease(1) --> 커밋 (재고: 1) +Thread 5 --> [대기] --------------------------------> decrease(1) --> 커밋 (재고: 0) +Thread 6 --> [대기] --------------------------------> "재고 부족" 예외 X +Thread 7~10 --> 동일하게 실패 X + +결과: 5명 성공, 5명 실패, 재고 = 0 +``` + +### 3. 쿠폰 동시 사용 시나리오 (비관적 락) + +``` +쿠폰 1장 (AVAILABLE) | Thread 1~5 동시 주문 (같은 쿠폰) + +Thread 1 --> FOR UPDATE (잠금) --> isAvailable()=true --> use() --> 커밋 O +Thread 2 --> [대기] -----------------------------------> isAvailable()=false --> 예외 X +Thread 3~5 --> 동일하게 실패 X + +결과: 1명만 성공, 쿠폰 상태 = USED +``` + +### 4. 좋아요 동시성 시나리오 (원자적 업데이트) + +``` +like_count: 0 | Thread 1~10 동시 좋아요 + +Thread 1 --> INSERT like --> UPDATE SET like_count = like_count + 1 --> 성공 (like_count: 1) +Thread 2 --> INSERT like --> UPDATE SET like_count = like_count + 1 --> 성공 (like_count: 2) +Thread 3 --> INSERT like --> UPDATE SET like_count = like_count + 1 --> 성공 (like_count: 3) + ... +Thread 10 --> INSERT like --> UPDATE SET like_count = like_count + 1 --> 성공 (like_count: 10) + +재시도 없음 — DB가 원자적으로 처리 +결과: 10명 전원 성공, like_count = 10 +``` + +### 5. 데드락 방지 (productId 정렬) + +``` +X 정렬 없이: + User A: 상품2 락 -> 상품1 락 요청 (대기) + User B: 상품1 락 -> 상품2 락 요청 (대기) + -> 교착 상태 (Deadlock) + +O productId 오름차순 정렬: + User A: 상품1 락 -> 상품2 락 + User B: 상품1 락 요청 (대기) -> User A 완료 후 -> 상품1 락 -> 상품2 락 + -> 교착 상태 없음 +``` + +### 6. @Version 스코프 문제와 원자적 업데이트 전환 결정 흐름 + +``` +[초기 설계] 좋아요 → ProductModel.incrementLikeCount() → @Version 충돌 감지 → 재시도 + │ + │ 문제 발견 + ▼ +@Version은 엔티티 레벨 + ├── 좋아요 → version++ ──┐ + │ ├── 같은 version 경합 (False Conflict) + └── 상품 수정 → version++ ─┘ + │ + │ 해결 방안 검토 + ▼ +좋아요는 순수 증감 연산인가? + ├── incrementLikeCount(): this.likeCount++ → YES, 단순 +1 + └── decrementLikeCount(): if (> 0) likeCount-- → YES, SQL WHERE 조건으로 표현 가능 + │ + │ 결론 + ▼ +[최종 설계] 좋아요 → @Modifying @Query("SET like_count = like_count + 1") + ├── JPA 변경 감지 우회 → @Version 미트리거 + ├── 재시도 로직 제거 (retryOnOptimisticLock 삭제) + └── @Version 제거 → 상품 수정과의 간섭 근본 해결 +``` + +--- + +## 동시성 테스트 + +| 테스트 | 시나리오 | 기대 결과 | +|--------|---------|-----------| +| `stockDecreasedCorrectlyUnderConcurrency` | 재고 100, 10명 동시 1개 주문 | 10명 성공, 재고 90 | +| `onlyAvailableStockSucceeds` | 재고 5, 10명 동시 1개 주문 | 5명 성공, 재고 0 | +| `couponUsedOnlyOnce` | 쿠폰 1장, 5명 동시 주문 | 1명만 성공 | +| `likeCountAccurateUnderConcurrency` | 10명 동시 좋아요 | 10명 성공, likeCount = 10 | + +--- + +## Checklist + +### Coupon 도메인 +- [x] 쿠폰 소유자 검증 (`validateOwner`) +- [x] 정액/정률 할인 계산 (`CouponType.FIXED/RATE`) +- [x] 발급된 쿠폰 최대 1회 사용 (`use()` -> USED 상태) +- [x] 중복 발급 방지 (`@UniqueConstraint` + `DataIntegrityViolationException`) + +### 주문 +- [x] `@Transactional`로 원자성 보장 (재고+쿠폰+주문 단일 트랜잭션) +- [x] 사용 불가/존재하지 않는 쿠폰 -> 주문 실패 +- [x] 재고 부족 -> 주문 실패 +- [x] 부분 실패 -> 전체 롤백 (`rollsBackOnPartialFailure` 테스트 검증) +- [x] 주문 스냅샷: 할인 전 금액, 할인 금액, 최종 결제 금액 + +### 동시성 +- [x] 좋아요 동시 요청 -> likeCount 정상 반영 (원자적 업데이트) +- [x] 동일 쿠폰 동시 주문 -> 1번만 사용 (비관적 락) +- [x] 동일 상품 동시 주문 -> 재고 정상 차감 (비관적 락) + +### 인증 +- [x] `@LoginMember` — 사용자 API 인증 (Order, Coupon, Like, Member) +- [x] `@AdminUser` — 어드민 API 인증 (Brand, Product, Order, Coupon Admin) + +--- + +## 주요 파일 + +
+동시성 제어 핵심 파일 + +| 파일 | 역할 | +|------|------| +| `StockJpaRepository` | `@Lock(PESSIMISTIC_WRITE)` — 재고 비관적 락 | +| `CouponIssueJpaRepository` | `@Lock(PESSIMISTIC_WRITE)` — 쿠폰 비관적 락 | +| `ProductJpaRepository` | `@Modifying @Query` — 좋아요 원자적 업데이트 | +| `OrderFacade` | 주문 트랜잭션 경계, productId 정렬 데드락 방지 | +| `LikeFacade` | 좋아요 진입점 | +| `LikeTransactionService` | 좋아요 트랜잭션 경계 + 원자적 업데이트 호출 | +| `LikeToggleService` | 좋아요 도메인 의사결정 (LikeResult 반환) | +| `LikeResult` | 좋아요 토글 결과 (newLike + countChanged) | +| `CouponIssueModel` | `@UniqueConstraint` + `use()` 상태 전이 | +| `CouponIssueService` | 쿠폰 발급/사용, `DataIntegrityViolationException` 처리 | +| `ConcurrencyIntegrationTest` | 동시성 통합 테스트 4종 | + +
+ +--- diff --git a/research.md b/research.md new file mode 100644 index 000000000..e47f14a7d --- /dev/null +++ b/research.md @@ -0,0 +1,156 @@ +# Redis Cache Research + +## 1. 현재 프로젝트 Redis 인프라 분석 + +### 1-1. 토폴로지 +- **Master-Replica 구조** (읽기 분산) +- Master: `localhost:6379` (쓰기 + 읽기) +- Replica: `localhost:6380` (읽기 전용, `replicaof master`) +- Lettuce 클라이언트, `ReadFrom.REPLICA_PREFERRED` 기본 설정 + +### 1-2. 현재 구성 (modules/redis) +| 구성요소 | 설명 | +|---------|------| +| `RedisConfig` | Master/Replica 커넥션 팩토리 2개, RedisTemplate 2개(default=replica우선, master전용) | +| `RedisProperties` | `datasource.redis.*` 바인딩 (database, master, replicas) | +| `RedisNodeInfo` | host, port record | +| Serializer | Key/Value 모두 `StringRedisSerializer` | +| TestContainers | 단일 Redis 컨테이너로 테스트 (master=replica 동일 포트) | + +### 1-3. 현재 사용 현황 +- Redis 모듈은 3개 앱 모두에 의존 (`commerce-api`, `commerce-batch`, `commerce-streamer`) +- **현재 캐시 적용된 코드 없음** — `@Cacheable`, `@CacheEvict`, `@CachePut` 미사용 +- `CacheManager` Bean 미등록 상태 + +--- + +## 2. 캐시 적용 대상 분석 + +### 2-1. 상품 상세 API +- **특성**: 단건 조회, 상품 ID 기반, 높은 조회 빈도 +- **캐시 키**: `product:{productId}` +- **TTL**: 5~10분 (상품 정보 변경 빈도 낮음) +- **무효화**: 상품 수정/삭제 시 해당 키 evict + +### 2-2. 상품 목록 API +- **특성**: 다건 조회, 브랜드 필터 + 좋아요 순 정렬, 페이징 +- **캐시 키**: `products:brandId:{brandId}:sort:{sortType}:page:{page}:size:{size}` +- **TTL**: 1~3분 (좋아요 수 변동 반영 필요) +- **무효화**: 좋아요 토글, 상품 등록/수정/삭제 시 관련 키 패턴 삭제 + +--- + +## 3. 캐시 전략 비교 + +### 3-1. Spring Cache Abstraction (`@Cacheable`) +``` +장점: 선언적, 코드 침투 최소, AOP 기반 +단점: 세밀한 제어 어려움, 복잡한 키 전략 한계 +적합: 상품 상세 (단순 키) +``` + +### 3-2. RedisTemplate 직접 사용 +``` +장점: 완전한 제어, 복잡한 키 패턴 삭제 가능, 부분 갱신 +단점: 보일러플레이트 증가, 캐시 로직이 비즈니스에 침투 +적합: 상품 목록 (복합 키, 패턴 기반 무효화) +``` + +### 3-3. 하이브리드 (권장) +- 상품 상세 → `@Cacheable` + `@CacheEvict` +- 상품 목록 → `RedisTemplate` 직접 사용 (패턴 기반 무효화) + +--- + +## 4. 캐시 무효화 전략 + +### 4-1. TTL 기반 (Time-To-Live) +- 가장 단순, 일정 시간 후 자동 만료 +- 정합성 보장 수준: TTL 범위 내 eventual consistency +- **리스크**: TTL 동안 stale data 노출 + +### 4-2. 이벤트 기반 명시적 무효화 +- 데이터 변경 시점에 캐시 삭제 +- 정합성 보장 수준: 높음 (변경 즉시 반영) +- **리스크**: 무효화 누락 시 영구 stale data + +### 4-3. TTL + 이벤트 하이브리드 (권장) +- 변경 시 즉시 evict + TTL을 안전망으로 설정 +- 무효화 누락되어도 TTL 후 자동 갱신 + +--- + +## 5. 캐시 키 설계 + +### 5-1. 네이밍 컨벤션 +``` +{domain}:{identifier} +{domain}:{filter1}:{value1}:{filter2}:{value2} +``` + +### 5-2. 구체적 키 설계안 +| API | 캐시 키 패턴 | TTL | +|-----|-------------|-----| +| 상품 상세 | `product:detail:{productId}` | 10분 | +| 상품 목록 | `product:list:brand:{brandId}:sort:{sortType}:page:{page}:size:{size}` | 3분 | + +### 5-3. 무효화 매핑 +| 이벤트 | 삭제 대상 | +|--------|----------| +| 상품 수정 | `product:detail:{productId}` + `product:list:*` | +| 상품 삭제 | `product:detail:{productId}` + `product:list:*` | +| 좋아요 토글 | `product:detail:{productId}` + `product:list:*` (좋아요순 정렬 캐시) | + +--- + +## 6. 구현 시 고려사항 + +### 6-1. CacheManager 설정 필요 +- 현재 `RedisConfig`에 `RedisCacheManager` Bean 미등록 +- `@Cacheable` 사용을 위해 `RedisCacheManager` + `RedisCacheConfiguration` 추가 필요 +- JSON 직렬화: `GenericJackson2JsonRedisSerializer` 또는 도메인별 커스텀 직렬화 + +### 6-2. 캐시 미스 시 정상 동작 보장 +- Redis 장애 시에도 DB fallback으로 서비스 지속 +- `@Cacheable`의 기본 동작: 캐시 미스 → DB 조회 → 캐시 저장 +- Redis 연결 실패 시 예외 처리: `CacheErrorHandler` 커스텀 구현 고려 + +### 6-3. 직렬화 전략 +| 방식 | 장점 | 단점 | +|------|------|------| +| `StringRedisSerializer` (현재) | 단순, 디버깅 용이 | 객체 저장 불가 | +| `GenericJackson2JsonRedisSerializer` | 범용, 타입 정보 포함 | 저장 공간 큼 | +| `Jackson2JsonRedisSerializer` | 타입별 최적화 | 캐시마다 설정 필요 | + +### 6-4. Master/Replica 읽기 분산과 캐시의 관계 +- 캐시 읽기: `defaultRedisTemplate` (REPLICA_PREFERRED) → replica에서 읽기 +- 캐시 쓰기/삭제: master에서 수행 (replica는 자동 동기화) +- **주의**: replica 동기화 지연(replication lag) 동안 stale 캐시 읽힐 수 있음 + +--- + +## 7. 구현 순서 (안) + +``` +Step 1 → RedisCacheManager Bean 등록 + CacheConfiguration 설정 + 검증: CacheManager Bean 로딩 확인 + +Step 2 → 상품 상세 API 캐시 적용 (@Cacheable / @CacheEvict) + 검증: 캐시 히트/미스 테스트, 수정 시 무효화 테스트 + +Step 3 → 상품 목록 API 캐시 적용 (RedisTemplate 직접 사용) + 검증: 필터/정렬 조합별 캐시 동작, 좋아요 토글 시 무효화 테스트 + +Step 4 → CacheErrorHandler 구현 (Redis 장애 시 graceful fallback) + 검증: Redis 중단 상태에서 API 정상 응답 확인 + +Step 5 → 성능 비교 (캐시 적용 전/후 응답시간 측정) + 검증: 캐시 히트 시 응답시간 단축 확인 +``` + +--- + +## 8. 참고: Round 5 요구사항 체크리스트 + +- [ ] Redis 캐시를 적용하고 TTL 또는 무효화 전략을 적용했다 +- [ ] 캐시 미스 상황에서도 서비스가 정상 동작하도록 처리했다