Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
84baad0
refactor: Payment 생성 κ·œμΉ™ μΊ‘μŠν™”
tjdakf Jun 25, 2026
16662b9
refactor: 결제 콜백 μš”μ²­ νŒŒλΌλ―Έν„° DTO둜 뢄리
tjdakf Jun 25, 2026
075e54f
feat: ν† μŠ€ 결제 API 호좜 νƒ€μž„μ•„μ›ƒ μ„€μ •
tjdakf Jun 25, 2026
b3380af
feat: 결제 승인 확인 ν•„μš” μƒνƒœ μΆ”κ°€
tjdakf Jun 25, 2026
87bae19
feat: 결제 승인 톡신 μ˜ˆμ™Έ 처리
tjdakf Jun 25, 2026
3531e7f
feat: 결제 승인 확인 ν•„μš” ν™”λ©΄ μΆ”κ°€
tjdakf Jun 25, 2026
8e64b2c
feat: ν† μŠ€ 결제 승인 μš”μ²­μ— λ©±λ“±ν‚€ μΆ”κ°€
tjdakf Jun 25, 2026
9f71566
feat: 확인 ν•„μš” 결제 승인 μž¬μ‹œλ„ ν—ˆμš©
tjdakf Jun 25, 2026
7ddfd90
feat: μ˜ˆμ•½ 쑰회 응닡에 결제 정보 μΆ”κ°€
tjdakf Jun 25, 2026
18e467e
feat: λ‚΄ μ˜ˆμ•½ λͺ©λ‘μ— 결제 상세 ν‘œμ‹œ
tjdakf Jun 25, 2026
6b521f6
style: λ‚΄ μ˜ˆμ•½ 결제 상세 ν™”λ©΄ 정리
tjdakf Jun 25, 2026
a568cee
feat: 토큰 버킷 Rate Limiter κ΅¬ν˜„
tjdakf Jun 25, 2026
8e79a3a
feat: κ²°μ œΒ·μ˜ˆμ•½ μš”μ²­ Rate Limit 적용
tjdakf Jun 25, 2026
8221bc0
feat: ν† μŠ€ 결제 호좜 429 λ°±μ˜€ν”„ μž¬μ‹œλ„
tjdakf Jun 25, 2026
5c896a0
feat: ν† μŠ€ 결제 호좜 Rate Limit 적용
tjdakf Jun 25, 2026
0139bda
test: Retry-After 폴백과 μž¬μ‹œλ„ λ©±λ“±ν‚€ μœ μ§€ 검증 μΆ”κ°€
tjdakf Jun 25, 2026
19719db
fix: Rate Limit 초과 응닡에 JSON μ—λŸ¬ λ³Έλ¬Έ μΆ”κ°€
tjdakf Jun 25, 2026
c71d646
refactor: 결제 승인 경둜λ₯Ό μΈλ°”μš΄λ“œ Rate Limit λŒ€μƒμ—μ„œ μ œμ™Έ
tjdakf Jun 25, 2026
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
@@ -0,0 +1,28 @@
package roomescape.controller.dto.response;

import roomescape.domain.PaymentStatus;
import roomescape.service.dto.BookingPaymentInfo;

public record BookingPaymentResponse(
String orderId,
Long amount,
String paymentKey,
PaymentStatus status,
String failureCode,
String failureMessage
) {

public static BookingPaymentResponse from(BookingPaymentInfo payment) {
if (payment == null) {
return null;
}
return new BookingPaymentResponse(
payment.orderId(),
payment.amount(),
payment.paymentKey(),
payment.status(),
payment.failureCode(),
payment.failureMessage()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ public record BookingStatusResponse(
ReservationThemeResponse theme,
BookingType bookingType,
ReservationStatus reservationStatus,
Long turn
Long turn,
BookingPaymentResponse payment
) {

public static BookingStatusResponse from(BookingStatus bookingStatus) {
Expand All @@ -25,7 +26,8 @@ public static BookingStatusResponse from(BookingStatus bookingStatus) {
ReservationThemeResponse.from(bookingStatus.theme()),
bookingStatus.bookingType(),
bookingStatus.reservationStatus(),
bookingStatus.turn()
bookingStatus.turn(),
BookingPaymentResponse.from(bookingStatus.payment())
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ModelAttribute;
import roomescape.controller.view.dto.PaymentFailRequest;
import roomescape.controller.view.dto.PaymentReservationView;
import roomescape.controller.view.dto.PaymentSuccessRequest;
import roomescape.domain.Reservation;
import roomescape.service.payment.PaymentAmountMismatchException;
import roomescape.service.payment.PaymentGatewayException;
Expand All @@ -14,6 +16,8 @@
@Controller
public class PaymentSuccessController {

private static final String PAYMENT_CONFIRMATION_UNKNOWN = "PAYMENT_CONFIRMATION_UNKNOWN";

private final PaymentService paymentService;

public PaymentSuccessController(PaymentService paymentService) {
Expand All @@ -22,44 +26,37 @@ public PaymentSuccessController(PaymentService paymentService) {

@GetMapping("/payments/success")
public String success(
@RequestParam String paymentKey,
@RequestParam String orderId,
@RequestParam Long amount,
@RequestParam(required = false) String name,
@ModelAttribute PaymentSuccessRequest request,
Model model
) {
try {
PaymentResult result = paymentService.confirm(paymentKey, orderId, amount);
PaymentResult result = paymentService.confirm(request.paymentKey(), request.orderId(), request.amount());
model.addAttribute("result", result);
addReservationViewByOrderId(model, orderId);
model.addAttribute("name", name);
addReservationViewByOrderId(model, request.orderId());
model.addAttribute("name", request.name());
return "payment-success";
} catch (PaymentAmountMismatchException e) {
return failView(model, "AMOUNT_MISMATCH", e.getMessage(), orderId, name,
paymentService.findReservationByOrderId(orderId));
return failView(model, "AMOUNT_MISMATCH", e.getMessage(), request.orderId(), request.name(),
paymentService.findReservationByOrderId(request.orderId()));
} catch (PaymentGatewayException e) {
return failView(model, e.getCode(), e.getMessage(), orderId, name,
paymentService.findReservationByOrderId(orderId));
return failView(model, e.getCode(), e.getMessage(), request.orderId(), request.name(),
paymentService.findReservationByOrderId(request.orderId()));
}
}

@GetMapping("/payments/fail")
public String fail(
@RequestParam(required = false) String code,
@RequestParam(required = false) String message,
@RequestParam(required = false) String orderId,
@RequestParam(required = false) Long paymentId,
@RequestParam(required = false) String name,
@ModelAttribute PaymentFailRequest request,
Model model
) {
Reservation reservation = null;
if (paymentId != null) {
paymentService.fail(paymentId, code, message);
reservation = paymentService.findReservationByPaymentId(paymentId);
} else if (orderId != null) {
reservation = paymentService.findReservationByOrderId(orderId);
if (request.paymentId() != null) {
paymentService.fail(request.paymentId(), request.code(), request.message());
reservation = paymentService.findReservationByPaymentId(request.paymentId());
} else if (request.orderId() != null) {
reservation = paymentService.findReservationByOrderId(request.orderId());
}
return failView(model, code, message, orderId, name, reservation);
return failView(model, request.code(), request.message(), request.orderId(), request.name(), reservation);
}

private void addReservationViewByOrderId(Model model, String orderId) {
Expand All @@ -73,6 +70,7 @@ private String failView(Model model, String code, String message, String orderId
model.addAttribute("message", message);
model.addAttribute("orderId", orderId);
model.addAttribute("name", name);
model.addAttribute("confirmationUnknown", PAYMENT_CONFIRMATION_UNKNOWN.equals(code));
if (reservation != null) {
model.addAttribute("reservation", PaymentReservationView.from(reservation));
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package roomescape.controller.view.dto;

public record PaymentFailRequest(
String code,
String message,
String orderId,
Long paymentId,
String name
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package roomescape.controller.view.dto;

public record PaymentSuccessRequest(
String paymentKey,
String orderId,
Long amount,
String name
) {
}
54 changes: 47 additions & 7 deletions src/main/java/roomescape/domain/Payment.java
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@ public class Payment {
private final String failureCode;
private final String failureMessage;

public Payment(Long id, Long reservationId, String orderId, Long amount, String paymentKey,
PaymentStatus status, String failureCode, String failureMessage) {
private Payment(Long id, Long reservationId, String orderId, Long amount, String paymentKey,
PaymentStatus status, String failureCode, String failureMessage) {
validateReservationId(reservationId);
validateOrderId(orderId);
validateAmount(amount);
validateStatus(status);
validateStatusFields(status, paymentKey, failureCode);

this.id = id;
this.reservationId = reservationId;
Expand All @@ -34,24 +35,31 @@ public static Payment ready(Long reservationId, Long amount) {
return new Payment(null, reservationId, generateOrderId(), amount, null, PaymentStatus.READY, null, null);
}

public static Payment restore(Long id, Long reservationId, String orderId, Long amount, String paymentKey,
PaymentStatus status, String failureCode, String failureMessage) {
validateId(id);
return new Payment(id, reservationId, orderId, amount, paymentKey, status, failureCode, failureMessage);
}

public Payment withId(Long id) {
validateId(id);
return new Payment(id, reservationId, orderId, amount, paymentKey, status, failureCode, failureMessage);
}

public Payment confirm(String paymentKey) {
if (status != PaymentStatus.READY) {
throw new IllegalStateException("결제 λŒ€κΈ° μƒνƒœμ—μ„œλ§Œ μŠΉμΈν•  수 μžˆμŠ΅λ‹ˆλ‹€.");
if (!canConfirm()) {
throw new IllegalStateException("결제 λŒ€κΈ° λ˜λŠ” 확인 ν•„μš” μƒνƒœμ—μ„œλ§Œ μŠΉμΈν•  수 μžˆμŠ΅λ‹ˆλ‹€.");
}
if (paymentKey == null || paymentKey.isBlank()) {
throw new IllegalArgumentException("paymentKeyλŠ” λΉ„μ–΄ μžˆμ„ 수 μ—†μŠ΅λ‹ˆλ‹€.");
}
return new Payment(id, reservationId, orderId, amount, paymentKey, PaymentStatus.CONFIRMED,
failureCode, failureMessage);
null, null);
}

public Payment fail(String failureCode, String failureMessage) {
if (status != PaymentStatus.READY) {
throw new IllegalStateException("결제 λŒ€κΈ° μƒνƒœμ—μ„œλ§Œ μ‹€νŒ¨ μ²˜λ¦¬ν•  수 μžˆμŠ΅λ‹ˆλ‹€.");
if (!canConfirm()) {
throw new IllegalStateException("결제 λŒ€κΈ° λ˜λŠ” 확인 ν•„μš” μƒνƒœμ—μ„œλ§Œ μ‹€νŒ¨ μ²˜λ¦¬ν•  수 μžˆμŠ΅λ‹ˆλ‹€.");
}
PaymentStatus failedStatus = "PAY_PROCESS_CANCELED".equals(failureCode)
? PaymentStatus.CANCELED
Expand All @@ -60,6 +68,14 @@ public Payment fail(String failureCode, String failureMessage) {
failureCode, failureMessage);
}

public Payment checkRequired(String failureCode, String failureMessage) {
if (!canConfirm()) {
throw new IllegalStateException("결제 λŒ€κΈ° λ˜λŠ” 확인 ν•„μš” μƒνƒœμ—μ„œλ§Œ 확인 ν•„μš” μ²˜λ¦¬ν•  수 μžˆμŠ΅λ‹ˆλ‹€.");
}
return new Payment(id, reservationId, orderId, amount, paymentKey, PaymentStatus.CHECK_REQUIRED,
failureCode, failureMessage);
}

public Long getId() {
return id;
}
Expand Down Expand Up @@ -96,6 +112,12 @@ private static String generateOrderId() {
return "payment_" + UUID.randomUUID().toString().replace("-", "");
}

private static void validateId(Long id) {
if (id == null || id <= 0) {
throw new IllegalArgumentException("idλŠ” μ–‘μˆ˜μ—¬μ•Ό ν•©λ‹ˆλ‹€.");
}
}

private void validateReservationId(Long reservationId) {
if (reservationId == null || reservationId <= 0) {
throw new IllegalArgumentException("reservationIdλŠ” μ–‘μˆ˜μ—¬μ•Ό ν•©λ‹ˆλ‹€.");
Expand All @@ -119,4 +141,22 @@ private void validateStatus(PaymentStatus status) {
throw new IllegalArgumentException("statusλŠ” λΉ„μ–΄ μžˆμ„ 수 μ—†μŠ΅λ‹ˆλ‹€.");
}
}

private void validateStatusFields(PaymentStatus status, String paymentKey, String failureCode) {
if (status == PaymentStatus.READY && (paymentKey != null || failureCode != null)) {
throw new IllegalArgumentException("결제 λŒ€κΈ° μƒνƒœλŠ” paymentKeyλ‚˜ μ‹€νŒ¨ μ½”λ“œλ₯Ό κ°€μ§ˆ 수 μ—†μŠ΅λ‹ˆλ‹€.");
}
if (status == PaymentStatus.CONFIRMED && (paymentKey == null || paymentKey.isBlank())) {
throw new IllegalArgumentException("승인된 κ²°μ œλŠ” paymentKeyκ°€ ν•„μš”ν•©λ‹ˆλ‹€.");
}
if ((status == PaymentStatus.FAILED || status == PaymentStatus.CANCELED
|| status == PaymentStatus.CHECK_REQUIRED)
&& (failureCode == null || failureCode.isBlank())) {
throw new IllegalArgumentException("μ‹€νŒ¨, μ·¨μ†Œ λ˜λŠ” 확인 ν•„μš” κ²°μ œλŠ” μ‹€νŒ¨ μ½”λ“œκ°€ ν•„μš”ν•©λ‹ˆλ‹€.");
}
}

private boolean canConfirm() {
return status == PaymentStatus.READY || status == PaymentStatus.CHECK_REQUIRED;
}
}
1 change: 1 addition & 0 deletions src/main/java/roomescape/domain/PaymentStatus.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ public enum PaymentStatus {
READY,
FAILED,
CANCELED,
CHECK_REQUIRED,
CONFIRMED;

public static PaymentStatus fromTossStatus(String tossStatus) {
Expand Down
1 change: 1 addition & 0 deletions src/main/java/roomescape/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public enum ErrorCode {
PAYMENT_RETRY_NOT_ALLOWED(HttpStatus.CONFLICT, "결제λ₯Ό λ‹€μ‹œ μ‹œλ„ν•  수 μ—†λŠ” μ˜ˆμ•½μž…λ‹ˆλ‹€."),
PAYMENT_CONFIRMATION_NOT_ALLOWED(HttpStatus.CONFLICT, "결제 μŠΉμΈμ„ μ§„ν–‰ν•  수 μ—†λŠ” μƒνƒœμž…λ‹ˆλ‹€."),
TEMPORARY_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "μš”μ²­μ„ μ²˜λ¦¬ν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”."),
TOO_MANY_REQUESTS(HttpStatus.TOO_MANY_REQUESTS, "μš”μ²­μ΄ λ§Žμ•„ μ²˜λ¦¬ν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”."),
INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "μ„œλ²„μ— λ¬Έμ œκ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.");

private final HttpStatus status;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package roomescape.payment.client;

import roomescape.service.payment.PaymentFailureCategory;
import roomescape.service.payment.PaymentGatewayException;

/**
* ν† μŠ€κ°€ 429 λ₯Ό λ°˜λ³΅ν•΄ μž¬μ‹œλ„ 횟수λ₯Ό λͺ¨λ‘ μ†Œμ§„ν–ˆμ„ λ•Œ λ˜μ§€λŠ” μ˜ˆμ™Έ.
*
* <p>429 λŠ” "아직 μ²˜λ¦¬λ˜μ§€ μ•ŠμŒ"을 λœ»ν•˜λ―€λ‘œ κ²°μ œλŠ” κ·ΈλŒ€λ‘œ 두고(μƒνƒœ μœ μ§€) μ•ˆμ „ν•˜κ²Œ λ‹€μ‹œ μ‹œλ„ν•  수 μžˆλ‹€.
*/
public class GatewayRateLimitException extends PaymentGatewayException {

public static final String CODE = "TOO_MANY_REQUESTS";

public GatewayRateLimitException(String message) {
super(PaymentFailureCategory.RATE_LIMITED, CODE, message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package roomescape.payment.client;

import roomescape.service.payment.PaymentFailureCategory;
import roomescape.service.payment.PaymentGatewayException;

/**
* λ‚˜κ°€λŠ” 결제 호좜이 자체 Rate Limit 을 μ΄ˆκ³Όν•΄ μ™ΈλΆ€λ‘œ 보내지 μ•Šκ³  κ±°λΆ€ν•  λ•Œ λ˜μ§€λŠ” μ˜ˆμ™Έ.
*
* <p>호좜이 ν† μŠ€μ— λ„λ‹¬ν•˜μ§€ μ•Šμ•˜μœΌλ―€λ‘œ κ²°μ œλŠ” κ·ΈλŒ€λ‘œ 두고(μƒνƒœ μœ μ§€) μ•ˆμ „ν•˜κ²Œ λ‹€μ‹œ μ‹œλ„ν•  수 μžˆλ‹€.
*/
public class OutboundRateLimitException extends PaymentGatewayException {

public static final String CODE = "OUTBOUND_RATE_LIMITED";

public OutboundRateLimitException(String message) {
super(PaymentFailureCategory.RATE_LIMITED, CODE, message);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package roomescape.payment.client;

import java.io.IOException;
import org.springframework.http.HttpRequest;
import org.springframework.http.client.ClientHttpRequestExecution;
import org.springframework.http.client.ClientHttpRequestInterceptor;
import org.springframework.http.client.ClientHttpResponse;
import roomescape.ratelimit.TokenBucketRateLimiter;

/**
* λ‚˜κ°€λŠ” 결제 ν˜ΈμΆœμ— 토큰 버킷 Rate Limit 을 μ μš©ν•˜λŠ” 인터셉터.
*
* <p>μ™ΈλΆ€λ‘œ 보내기 전에 토큰을 μ†ŒλΉ„ν•΄, ν•œλ„λ₯Ό λ„˜μœΌλ©΄ ν† μŠ€μ— 보내지 μ•Šκ³  {@link OutboundRateLimitException}
* 으둜 κ±°λΆ€ν•œλ‹€. λ“€μ–΄μ˜€λŠ” μͺ½κ³Ό 같은 {@link TokenBucketRateLimiter} λ₯Ό λ°©ν–₯만 λ°”κΏ” μž¬μ‚¬μš©ν•œλ‹€.
*/
public class OutboundRateLimitInterceptor implements ClientHttpRequestInterceptor {

private final TokenBucketRateLimiter rateLimiter;

public OutboundRateLimitInterceptor(TokenBucketRateLimiter rateLimiter) {
this.rateLimiter = rateLimiter;
}

@Override
public ClientHttpResponse intercept(
HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
if (!rateLimiter.tryConsume()) {
throw new OutboundRateLimitException("결제 μš”μ²­μ΄ λ§Žμ•„ μ²˜λ¦¬ν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”.");
}
return execution.execute(request, body);
}
}
Loading