Skip to content

Commit 610afed

Browse files
authored
refactor: payment 장애대응로직 보강
* refactor: payment 서킷브레이커 구현 * feat: 결제 서킷브레이커 yml설정 추가 * refactor: order 엔티티 레벨 상태전이 가드, order 서비스 레벨 중복 방어 로직 추가 * refactor: timelimiter, retry제거, client timeout으로 장애 대응 * refactor: client호출 트랜잭션에서 분리, DB점유시간/실패 시 트랜잭션 롤백 문제 방지 * refactor: 멱등성 보장 검증로직 추가, 테스트 코드 추가
1 parent 2047df3 commit 610afed

13 files changed

Lines changed: 852 additions & 94 deletions

File tree

backend/src/main/java/com/back/api/payment/order/service/OrderService.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public class OrderService {
3131
private final OrderRepository orderRepository;
3232
private final TicketService ticketService;
3333
private final V2_OrderRepository v2_orderRepository;
34+
3435
/**
3536
* 주문 생성
3637
* draft 티켓 확인 -> 주문 생성 -> 티켓 상태 PAID로 변경
@@ -129,6 +130,19 @@ public V2_OrderResponseDto v2_createOrder(OrderRequestDto orderRequestDto, Long
129130
// 티켓이 DRAFT 상태인지 확인
130131
Ticket draft = ticketService.getDraftTicket(orderRequestDto.eventId(), orderRequestDto.seatId(), userId);
131132

133+
// 이미 PENDING 상태의 Order가 있는지 확인
134+
// 다중 요청 방어 로직
135+
Optional<V2_Order> existingOrder = v2_orderRepository.findByTicket_IdAndStatus(
136+
draft.getId(),
137+
OrderStatus.PENDING
138+
);
139+
140+
if (existingOrder.isPresent()) {
141+
// 기존 Order 재사용 (새로 만들지 않음)
142+
log.info("Duplicate order request, reusing existing orderId={}", existingOrder.get().getOrderId());
143+
return V2_OrderResponseDto.from(existingOrder.get());
144+
}
145+
132146
// 금액 일치 여부 확인
133147
Integer actualAmount = draft.getSeat().getPrice();
134148
if (!orderRequestDto.amount().equals(actualAmount.longValue())) {
@@ -169,4 +183,24 @@ public V2_Order v2_getOrderForPayment(String orderId, Long userId, Long clientAm
169183

170184
return order;
171185
}
186+
187+
/**
188+
* Order 검증 후 ticketId만 반환 (PG 호출 전 검증용)
189+
*/
190+
@Transactional(readOnly = true)
191+
public Long v2_validateAndGetTicketId(String orderId, Long userId, Long clientAmount) {
192+
V2_Order order = v2_getOrderForPayment(orderId, userId, clientAmount);
193+
return order.getTicket().getId();
194+
}
195+
196+
/**
197+
* 이미 결제 완료된 Order 조회 (멱등성 보장용)
198+
* - PAID 상태 + 소유자 확인
199+
*/
200+
@Transactional(readOnly = true)
201+
public Optional<V2_Order> v2_findPaidOrder(String orderId, Long userId) {
202+
return v2_orderRepository.findById(orderId)
203+
.filter(order -> order.getStatus() == OrderStatus.PAID)
204+
.filter(order -> order.getTicket().getOwner().getId().equals(userId));
205+
}
172206
}
Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,28 @@
11
package com.back.api.payment.payment.config;
22

3+
import java.net.http.HttpClient;
4+
import java.time.Duration;
35
import java.util.Base64;
46

57
import org.springframework.beans.factory.annotation.Value;
68
import org.springframework.context.annotation.Bean;
79
import org.springframework.context.annotation.Configuration;
10+
import org.springframework.http.client.JdkClientHttpRequestFactory;
811
import org.springframework.web.client.RestClient;
912

13+
/**
14+
* Toss Payments API RestClient 설정
15+
* 타임아웃 설정:
16+
* - 연결 타임아웃: 3초 - 서버 연결까지 대기 시간
17+
* - 읽기 타임아웃: 5초 - 응답 대기 시간 (Toss 권장 최대 30초이나 UX 고려)
18+
* 타임아웃 초과 시 ResourceAccessException 발생 → 서킷브레이커에서 처리
19+
*/
1020
@Configuration
1121
public class TossPaymentConfig {
1222

23+
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(3);
24+
private static final Duration READ_TIMEOUT = Duration.ofSeconds(5);
25+
1326
@Value("${toss.payments.secret}")
1427
private String secretKey;
1528

@@ -18,11 +31,19 @@ public RestClient tossRestClient() {
1831
String encodedKey = Base64.getEncoder()
1932
.encodeToString((secretKey + ":").getBytes());
2033

34+
// JDK HttpClient 기반 타임아웃 설정
35+
HttpClient httpClient = HttpClient.newBuilder()
36+
.connectTimeout(CONNECT_TIMEOUT)
37+
.build();
38+
39+
JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient);
40+
requestFactory.setReadTimeout(READ_TIMEOUT);
41+
2142
return RestClient.builder()
2243
.baseUrl("https://api.tosspayments.com")
2344
.defaultHeader("Authorization", "Basic " + encodedKey)
2445
.defaultHeader("Content-Type", "application/json")
46+
.requestFactory(requestFactory)
2547
.build();
2648
}
27-
2849
}

backend/src/main/java/com/back/api/payment/payment/dto/response/V2_PaymentConfirmResponse.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public record V2_PaymentConfirmResponse(
2525
* V2_Order로부터 응답 객체 생성
2626
* - 변수 추출로 중복 접근 방지 (getTicket() 1번만 호출)
2727
* - N+1 쿼리 방지를 위해 order는 fetch join 필수
28+
* - 멱등성 응답에서 Payment가 null일 수 있음 (이미 결제된 주문 조회 시)
2829
*/
2930
public static V2_PaymentConfirmResponse from(V2_Order order, boolean success) {
3031
var ticket = order.getTicket();
@@ -37,7 +38,7 @@ public static V2_PaymentConfirmResponse from(V2_Order order, boolean success) {
3738
success,
3839
order.getAmount(),
3940
ticket.getCreateAt(),
40-
payment.getMethod(),
41+
payment != null ? payment.getMethod() : null,
4142
ticket.getId(),
4243
event.getTitle(),
4344
event.getPlace(),

backend/src/main/java/com/back/api/payment/payment/service/PaymentService.java

Lines changed: 32 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package com.back.api.payment.payment.service;
22

3+
import java.util.Optional;
4+
35
import org.springframework.context.ApplicationEventPublisher;
46
import org.springframework.stereotype.Service;
57
import org.springframework.transaction.annotation.Transactional;
68

79
import com.back.api.payment.order.service.OrderService;
810
import com.back.api.payment.payment.client.PaymentClient;
11+
import com.back.domain.payment.order.entity.V2_Order;
912
import com.back.api.payment.payment.dto.request.PaymentConfirmCommand;
1013
import com.back.api.payment.payment.dto.request.V2_PaymentConfirmRequest;
1114
import com.back.api.payment.payment.dto.response.PaymentConfirmResult;
@@ -15,12 +18,8 @@
1518
import com.back.api.queue.service.QueueEntryProcessService;
1619
import com.back.api.ticket.service.TicketService;
1720
import com.back.domain.notification.systemMessage.OrderSuccessMessage;
18-
import com.back.domain.notification.systemMessage.OrderSuccessV2Message;
1921
import com.back.domain.payment.order.entity.Order;
20-
import com.back.domain.payment.order.entity.V2_Order;
2122
import com.back.domain.payment.payment.entity.ApproveStatus;
22-
import com.back.domain.payment.payment.entity.Payment;
23-
import com.back.domain.payment.payment.repository.PaymentRepository;
2423
import com.back.domain.ticket.entity.Ticket;
2524
import com.back.global.error.code.PaymentErrorCode;
2625
import com.back.global.error.exception.ErrorException;
@@ -29,18 +28,6 @@
2928
import lombok.RequiredArgsConstructor;
3029
import lombok.extern.slf4j.Slf4j;
3130

32-
/**
33-
* Payment 관련 비즈니스 로직 처리
34-
* TODO: PG 연동 시 트랜잭션 경계 재설정 필요
35-
*
36-
* [설계 방향]
37-
* 1. PG API 호출: 트랜잭션 밖으로 이동
38-
* 2. 핵심 DB 변경만 @Transactional
39-
* 3. Queue/Notification: @TransactionalEventListener(AFTER_COMMIT)
40-
*
41-
* [리팩토링 시점]
42-
* - 실제 PG 연동 구현 시 (TossPaymentClient 등)
43-
*/
4431
@Service
4532
@Slf4j
4633
@RequiredArgsConstructor
@@ -51,8 +38,8 @@ public class PaymentService {
5138
private final TicketService ticketService;
5239
private final QueueEntryProcessService queueEntryProcessService;
5340
private final ApplicationEventPublisher eventPublisher;
54-
private final PaymentRepository paymentRepository;
5541
private final TossPaymentService tossPaymentService;
42+
private final PaymentTransactionService paymentTransactionService;
5643
private final BusinessMetrics businessMetrics;
5744

5845
@Transactional
@@ -138,82 +125,47 @@ public PaymentReceiptResponse getPaymentReceipt(Long orderId, Long userId) {
138125
return PaymentReceiptResponse.from(order, ticket);
139126
}
140127

141-
@Transactional
128+
/**
129+
* V2 결제 승인 - PG 호출은 트랜잭션 밖에서 처리
130+
*
131+
* 트랜잭션 분리 이유:
132+
* - PG API 호출 중 DB 커넥션 점유 방지
133+
* - 외부 API 타임아웃 시 트랜잭션 롤백 문제 방지
134+
*
135+
* 처리 흐름:
136+
* 0. 멱등성 확인 (이미 결제 완료된 주문이면 기존 결과 반환)
137+
* 1. Order 검증 (읽기 트랜잭션)
138+
* 2. PG API 호출 (트랜잭션 밖)
139+
* 3. 성공/실패 DB 처리 (각각 단일 쓰기 트랜잭션 - PaymentTransactionService)
140+
*/
142141
public V2_PaymentConfirmResponse v2_confirmPayment(
143142
String orderId,
144143
String paymentKey,
145144
Long clientAmount,
146145
Long userId
147146
) {
147+
// 0. 멱등성 확인: 이미 결제 완료된 주문이면 기존 결과 반환
148+
Optional<V2_Order> paidOrder = orderService.v2_findPaidOrder(orderId, userId);
149+
if (paidOrder.isPresent()) {
150+
log.info("[Payment] 이미 결제 완료된 주문 - orderId: {}", orderId);
151+
// 간단한 응답 반환 (전체 데이터 조회 불필요)
152+
return new V2_PaymentConfirmResponse(orderId, true);
153+
}
148154

149-
// OrderService가 order의 정합성(주문자/주문상태/amount) 보장
150-
V2_Order order = orderService.v2_getOrderForPayment(orderId, userId, clientAmount);
151-
152-
// log.info("[v2 결제 디버깅] - 결제 승인 메서드: 결제 서비스 로그");
153-
// log.info("[v2 결제 디버깅] - orderId : {}", orderId);
154-
// log.info("[v2 결제 디버깅] - paymentKey : {}", paymentKey);
155-
// log.info("[v2 결제 디버깅] - userId : {}", userId);
156-
157-
V2_PaymentConfirmRequest request = new V2_PaymentConfirmRequest(orderId, paymentKey, order.getAmount());
155+
// 1. Order 검증 (읽기 트랜잭션)
156+
Long ticketId = orderService.v2_validateAndGetTicketId(orderId, userId, clientAmount);
158157

158+
// 2. PG 호출 (트랜잭션 밖)
159+
V2_PaymentConfirmRequest request = new V2_PaymentConfirmRequest(orderId, paymentKey, clientAmount);
159160
TossPaymentResponse result = tossPaymentService.confirmPayment(request);
160161

161-
log.info("[v2 결제 디버깅] - 결제 승인 결과 {}", result.status());
162-
163-
if (result.status() != ApproveStatus.DONE) { // 결제 승인 완료시 토스 API 응답 : Status = "DONE"
164-
order.markFailed();
165-
ticketService.failPayment(order.getTicket().getId()); // Ticket FAILED + Seat 해제
162+
// 3. 결과에 따라 분기 - PaymentTransactionService에서 단일 트랜잭션으로 처리
163+
if (result.status() != ApproveStatus.DONE) {
164+
paymentTransactionService.handleFailure(orderId, ticketId);
166165
businessMetrics.paymentConfirmFailure("TOSS_PAYMENT_NOT_DONE");
167-
//TODO 결제 실패 로직 추가
168166
throw new ErrorException(PaymentErrorCode.PAYMENT_FAILED);
169167
}
170168

171-
//결제 엔티티 생성 및 DB 저장 (결제 정보 저장)
172-
Payment savedPayment = paymentRepository.save(
173-
new Payment(
174-
paymentKey,
175-
orderId,
176-
order.getAmount(),
177-
result.method(),
178-
result.status()
179-
)
180-
);
181-
182-
order.setPayment(savedPayment);
183-
184-
// Order status PENDING -> PAID, paymentKey DB 저장 (주문 상태 업테이트)
185-
order.markPaid(result.paymentKey());
186-
187-
// 테스트용 : DataInit으로 만든 테스트 데이터 사용한 결제 테스트에서 사용
188-
//order.getTicket().getSeat().markAsReserved();
189-
190-
// Ticket 발급
191-
Ticket ticket = ticketService.confirmPayment(
192-
order.getTicket().getId(),
193-
userId
194-
);
195-
196-
// 결제 성공 메트릭
197-
businessMetrics.paymentConfirmSuccess(ticket.getEvent().getId());
198-
199-
// Queue 완료 // 테스트 데이터로 진행 시 : 큐 대기열이 없으므로 이부분도 주석처리
200-
queueEntryProcessService.completePayment(
201-
ticket.getEvent().getId(),
202-
userId
203-
);
204-
205-
String eventTitle = ticket.getEvent().getTitle();
206-
207-
// 알림 메시지 발행
208-
eventPublisher.publishEvent(
209-
new OrderSuccessV2Message(
210-
userId,
211-
orderId,
212-
order.getAmount(),
213-
eventTitle
214-
)
215-
);
216-
217-
return V2_PaymentConfirmResponse.from(order, true);
169+
return paymentTransactionService.handleSuccess(orderId, result, userId);
218170
}
219171
}

0 commit comments

Comments
 (0)