Skip to content
Closed
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
import jakarta.persistence.*;
import lombok.Getter;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;

Expand Down Expand Up @@ -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<OrderItem> orderItems = new ArrayList<>();

Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Page<Product>> getProductList(Long brandId, Pageable pageable);

void putProductList(Long brandId, Pageable pageable, Page<Product> products);

Optional<Product> getProductDetail(Long productId);

void putProductDetail(Long productId, Product product);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<String, Object> redisTemplate;
private final ProductRepository productRepository;
private final ProductCache productCache;


@Transactional(readOnly = true)
public Page<Product> getProducts(Long brandId, Pageable sortedPageable) {
String key = "product:list:"
+ (brandId == null ? "all" : brandId) + ":"
+ sortedPageable.getPageNumber() + ":"
+ sortedPageable.getPageSize();

try {
Page<Product> cached = (Page<Product>) redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}
} catch (Exception e) {
return (brandId == null)
? productRepository.findAll(sortedPageable)
: productRepository.findByBrandId(brandId, sortedPageable);
}

Page<Product> 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<Product> 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<Product> fetchProducts(Long brandId, Pageable sortedPageable) {
return (brandId == null)
? productRepository.findAll(sortedPageable)
: productRepository.findByBrandId(brandId, sortedPageable);
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Object> redisTemplate;

@Override
@SuppressWarnings("unchecked")
public Optional<Page<Product>> getProductList(Long brandId, Pageable pageable) {
String key = listKey(brandId, pageable);
try {
Page<Product> cached = (Page<Product>) redisTemplate.opsForValue().get(key);
return Optional.ofNullable(cached);
} catch (Exception ignored) {
return Optional.empty();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

@Override
public void putProductList(Long brandId, Pageable pageable, Page<Product> products) {
String key = listKey(brandId, pageable);
try {
redisTemplate.opsForValue().set(key, products, TTL_LIST);
} catch (Exception ignored) {
}
}

@Override
public Optional<Product> 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;
}
}
109 changes: 109 additions & 0 deletions docs/6round/6round.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# 📝 Round 6 Quests

---

## 💻 Implementation Quest

> 외부 시스템(PG) 장애 및 지연에 대응하는 Resilience 설계를 학습하고 적용해봅니다.
`pg-simulator` 모듈을 활용하여 다양한 비동기 시스템과의 연동 및 실패 시나리오를 구현, 점검합니다.

<aside>
🎯

**Must-Have (이번 주에 무조건 가져가야 좋을 것-**무조건****하세요**)**

- Fallback
- Timeout
- CircuitBreaker

**Nice-To-Have (부가적으로 가져가면 좋을 것-**시간이****허락하면****꼭****해보세요**)**

- Retryer

</aside>

### **📦 추가 요구사항**

```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
```
Comment on lines +27 to +68

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

API 예시의 포맷팅을 수정하세요.

코드 블록 내 JSON 형식이 깨져있습니다. HTTP 요청 예시와 JSON 페이로드가 혼재되어 가독성이 떨어집니다.

다음과 같이 분리하여 작성하는 것을 권장합니다:

-###결제 요청
-
-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"
-}
+### 결제 요청
+
+POST http://localhost:8080/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"
+}
🤖 Prompt for AI Agents
In docs/6round/6round.md around lines 27 to 68, the API example block mixes HTTP
request lines, placeholders and broken JSON resulting in unreadable output;
separate each example into distinct, clearly labeled code blocks (one for the
HTTP request line + headers and one for the JSON body), remove stray placeholder
braces (e.g., "{ { pg - simulator } }"), ensure headers use correct values
(e.g., Content-Type: application/json), and provide valid, properly formatted
JSON payloads and separate GET examples so the request line, headers and body
are each clearly shown and syntactically correct.


- 결제 수단으로 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 에 대한 요청이 타임아웃에 의해 실패되더라도 해당 결제건에 대한 정보를 확인하여 정상적으로 시스템에 반영한다.