From 6a037489c1d07068b17780c01e1956c67a50c26d Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Thu, 4 Dec 2025 21:35:32 +0900 Subject: [PATCH 01/31] =?UTF-8?q?feat:=20DIP=EC=9C=84=EB=B0=98=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../loopers/domain/product/ProductCache.java | 17 +++++ .../domain/product/ProductService.java | 72 +++++------------- .../product/RedisProductCache.java | 76 +++++++++++++++++++ 3 files changed, 112 insertions(+), 53 deletions(-) create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCache.java create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCache.java diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCache.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCache.java new file mode 100644 index 000000000..74ccbc614 --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductCache.java @@ -0,0 +1,17 @@ +package com.loopers.domain.product; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +import java.util.Optional; + +public interface ProductCache { + + Optional> getProductList(Long brandId, Pageable pageable); + + void putProductList(Long brandId, Pageable pageable, Page products); + + Optional getProductDetail(Long productId); + + void putProductDetail(Long productId, Product product); +} diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java index b12dd45bd..87700911f 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java @@ -5,12 +5,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; -import java.time.Duration; - /** * packageName : com.loopers.domain.product * fileName : ProductService @@ -27,64 +24,33 @@ @RequiredArgsConstructor public class ProductService { - private static final Duration TTL_LIST = Duration.ofMinutes(10); - private static final Duration TTL_DETAIL = Duration.ofMinutes(5); - - private final RedisTemplate redisTemplate; private final ProductRepository productRepository; + private final ProductCache productCache; @Transactional(readOnly = true) public Page getProducts(Long brandId, Pageable sortedPageable) { - String key = "product:list:" - + (brandId == null ? "all" : brandId) + ":" - + sortedPageable.getPageNumber() + ":" - + sortedPageable.getPageSize(); - - try { - Page cached = (Page) redisTemplate.opsForValue().get(key); - if (cached != null) { - return cached; - } - } catch (Exception e) { - return (brandId == null) - ? productRepository.findAll(sortedPageable) - : productRepository.findByBrandId(brandId, sortedPageable); - } - - Page products = (brandId == null) - ? productRepository.findAll(sortedPageable) - : productRepository.findByBrandId(brandId, sortedPageable); - - try { - redisTemplate.opsForValue().set(key, products, TTL_LIST); - } catch (Exception ignored) { - } - - return products; + return productCache.getProductList(brandId, sortedPageable) + .orElseGet(() -> { + Page products = fetchProducts(brandId, sortedPageable); + productCache.putProductList(brandId, sortedPageable, products); + return products; + }); } public Product getProduct(Long productId) { - String key = "product:detail:" + productId; - - try { - Product cached = (Product) redisTemplate.opsForValue().get(key); - if (cached != null) { - return cached; - } - } catch (Exception e) { - return productRepository.findById(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 상품이 없습니다")); - } - - Product product = productRepository.findById(productId) - .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 상품이 없습니다")); - - try { - redisTemplate.opsForValue().set(key, product, TTL_DETAIL); - } catch (Exception ignored) { - } + return productCache.getProductDetail(productId) + .orElseGet(() -> { + Product product = productRepository.findById(productId) + .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "해당 상품이 없습니다")); + productCache.putProductDetail(productId, product); + return product; + }); + } - return product; + private Page fetchProducts(Long brandId, Pageable sortedPageable) { + return (brandId == null) + ? productRepository.findAll(sortedPageable) + : productRepository.findByBrandId(brandId, sortedPageable); } } diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCache.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCache.java new file mode 100644 index 000000000..e93cd23bd --- /dev/null +++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/product/RedisProductCache.java @@ -0,0 +1,76 @@ +package com.loopers.infrastructure.product; + +import com.loopers.domain.product.Product; +import com.loopers.domain.product.ProductCache; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class RedisProductCache implements ProductCache { + + private static final Duration TTL_LIST = Duration.ofMinutes(10); + private static final Duration TTL_DETAIL = Duration.ofMinutes(5); + + private final RedisTemplate redisTemplate; + + @Override + @SuppressWarnings("unchecked") + public Optional> getProductList(Long brandId, Pageable pageable) { + String key = listKey(brandId, pageable); + try { + Page cached = (Page) redisTemplate.opsForValue().get(key); + return Optional.ofNullable(cached); + } catch (Exception ignored) { + return Optional.empty(); + } + } + + @Override + public void putProductList(Long brandId, Pageable pageable, Page products) { + String key = listKey(brandId, pageable); + try { + redisTemplate.opsForValue().set(key, products, TTL_LIST); + } catch (Exception ignored) { + } + } + + @Override + public Optional getProductDetail(Long productId) { + String key = detailKey(productId); + try { + Product cached = (Product) redisTemplate.opsForValue().get(key); + return Optional.ofNullable(cached); + } catch (Exception ignored) { + return Optional.empty(); + } + } + + @Override + public void putProductDetail(Long productId, Product product) { + String key = detailKey(productId); + try { + redisTemplate.opsForValue().set(key, product, TTL_DETAIL); + } catch (Exception ignored) { + } + } + + private String listKey(Long brandId, Pageable pageable) { + String sortkey = pageable.getSort().toString(); + return "product:list:" + + (brandId == null ? "all" : brandId) + ":" + + pageable.getPageNumber() + ":" + + pageable.getPageSize() + ":" + + sortkey; + } + + private String detailKey(Long productId) { + return "product:detail:" + productId; + } +} From c41ffe60f75b4142c327d9d10a07b0f996be6906 Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Thu, 4 Dec 2025 21:36:11 +0900 Subject: [PATCH 02/31] =?UTF-8?q?chore:=20BaseEntity=EC=83=81=EC=86=8D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20createdAt=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/java/com/loopers/domain/order/Order.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java index f2bcc9b81..ec5d485cb 100644 --- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java +++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java @@ -6,7 +6,6 @@ import jakarta.persistence.*; import lombok.Getter; -import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -39,9 +38,6 @@ public class Order extends BaseEntity { @Enumerated(EnumType.STRING) private OrderStatus status; - @Column(nullable = false) - private LocalDateTime createdAt; - @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) private List orderItems = new ArrayList<>(); @@ -52,7 +48,6 @@ private Order(String userId, OrderStatus status) { this.userId = requiredValidUserId(userId); this.totalAmount = 0L; this.status = requiredValidStatus(status); - this.createdAt = LocalDateTime.now(); } public static Order create(String userId) { From afcae1cec5478578292b8328210ba0ceb8b86b5a Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Thu, 4 Dec 2025 21:36:20 +0900 Subject: [PATCH 03/31] =?UTF-8?q?chore:=20=ED=94=BC=EB=93=9C=EB=B0=B1=20?= =?UTF-8?q?=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/loopers/application/like/LikeFacade.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 5d885672e..5d9c7628e 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 @@ -23,11 +23,11 @@ public class LikeFacade { private final LikeService likeService; - public void createLike(String userId, Long productId) { + public void like(String userId, Long productId) { likeService.like(userId, productId); } - public void deleteLike(String userId, Long productId) { + public void unlike(String userId, Long productId) { likeService.unlike(userId, productId); } } From df0fb034c14506df7d3de813714b95315de2603d Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Thu, 4 Dec 2025 21:37:29 +0900 Subject: [PATCH 04/31] docs : 6round.md --- docs/6round/6round.md | 110 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 docs/6round/6round.md diff --git a/docs/6round/6round.md b/docs/6round/6round.md new file mode 100644 index 000000000..046e6ad73 --- /dev/null +++ b/docs/6round/6round.md @@ -0,0 +1,110 @@ +# 📝 Round 6 Quests + +--- + +## 💻 Implementation Quest + +> 외부 시스템(PG) 장애 및 지연에 대응하는 Resilience 설계를 학습하고 적용해봅니다. +`pg-simulator` 모듈을 활용하여 다양한 비동기 시스템과의 연동 및 실패 시나리오를 구현, 점검합니다. +> + + + +### **📦 추가 요구사항** + +```java +###결제 요청 + +POST { + { + pg - simulator + } +}/api/v1/payments +X-USER-ID:135135 +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:135135 + + ### +주문에 엮인 +결제 정보 +조회 + +GET { + { + pg - simulator + } +}/api/v1/payments?orderId=1351039135 +X-USER-ID:135135 +``` + +- 결제 수단으로 PG 기반 카드 결제 기능을 추가합니다. +- PG 시스템은 로컬에서 실행가능한 `pg-simulator` 모듈이 제공됩니다. ( 별도 SpringBootApp ) +- PG 시스템은 **비동기 결제** 기능을 제공합니다. + +> *비동기 결제란, 요청과 실제 처리가 분리되어 있음을 의미합니다.* +**요청 성공 확률 : 60% +요청 지연 :** 100ms ~ 500ms +**처리 지연** : 1s ~ 5s +**처리 결과** + +* 성공 : 70% +* 한도 초과 : 20% +* 잘못된 카드 : 10% + +> + +### 📋 과제 정보 + +- 외부 시스템에 대해 적절한 타임아웃 기준에 대해 고려해보고, 적용합니다. +- 외부 시스템의 응답 지연 및 실패에 대해서 대처할 방법에 대해 고민해 봅니다. +- PG 결제 결과를 적절하게 시스템과 연동하고 이를 기반으로 주문 상태를 안전하게 처리할 방법에 대해 고민해 봅니다. +- 서킷브레이커를 통해 외부 시스템의 지연, 실패에 대해 대응하여 서비스 전체가 무너지지 않도록 보호합니다. + +--- + +## ✅ Checklist + +### **⚡ PG 연동 대응** + +- [ ] PG 연동 API는 RestTemplate 혹은 FeignClient 로 외부 시스템을 호출한다. +- [ ] 응답 지연에 대해 타임아웃을 설정하고, 실패 시 적절한 예외 처리 로직을 구현한다. +- [ ] 결제 요청에 대한 실패 응답에 대해 적절한 시스템 연동을 진행한다. +- [ ] 콜백 방식 + **결제 상태 확인 API**를 활용해 적절하게 시스템과 결제정보를 연동한다. + +### **🛡 Resilience 설계** + +- [ ] 서킷 브레이커 혹은 재시도 정책을 적용하여 장애 확산을 방지한다. +- [ ] 외부 시스템 장애 시에도 내부 시스템은 **정상적으로 응답**하도록 보호한다. +- [ ] 콜백이 오지 않더라도, 일정 주기 혹은 수동 API 호출로 상태를 복구할 수 있다. +- [ ] PG 에 대한 요청이 타임아웃에 의해 실패되더라도 해당 결제건에 대한 정보를 확인하여 정상적으로 시스템에 반영한다. \ No newline at end of file From 7b1111be6428dd9c4eb6f440162a59b27f28ff5b Mon Sep 17 00:00:00 2001 From: adminhelper <201401339hs@gmail.com> Date: Fri, 5 Dec 2025 17:55:39 +0900 Subject: [PATCH 05/31] =?UTF-8?q?docs:=20md=ED=8C=8C=EC=9D=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/6round/6round.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/6round/6round.md b/docs/6round/6round.md index 046e6ad73..e7bdc8e11 100644 --- a/docs/6round/6round.md +++ b/docs/6round/6round.md @@ -6,7 +6,6 @@ > 외부 시스템(PG) 장애 및 지연에 대응하는 Resilience 설계를 학습하고 적용해봅니다. `pg-simulator` 모듈을 활용하여 다양한 비동기 시스템과의 연동 및 실패 시나리오를 구현, 점검합니다. ->