diff --git a/src/main/java/roomescape/controller/dto/response/BookingPaymentResponse.java b/src/main/java/roomescape/controller/dto/response/BookingPaymentResponse.java
new file mode 100644
index 0000000000..992d2491b6
--- /dev/null
+++ b/src/main/java/roomescape/controller/dto/response/BookingPaymentResponse.java
@@ -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()
+ );
+ }
+}
diff --git a/src/main/java/roomescape/controller/dto/response/BookingStatusResponse.java b/src/main/java/roomescape/controller/dto/response/BookingStatusResponse.java
index 158dc5945f..18c38c75f8 100644
--- a/src/main/java/roomescape/controller/dto/response/BookingStatusResponse.java
+++ b/src/main/java/roomescape/controller/dto/response/BookingStatusResponse.java
@@ -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) {
@@ -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())
);
}
}
diff --git a/src/main/java/roomescape/controller/view/PaymentSuccessController.java b/src/main/java/roomescape/controller/view/PaymentSuccessController.java
index ec18894247..7ef82a5daf 100644
--- a/src/main/java/roomescape/controller/view/PaymentSuccessController.java
+++ b/src/main/java/roomescape/controller/view/PaymentSuccessController.java
@@ -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;
@@ -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) {
@@ -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) {
@@ -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));
}
diff --git a/src/main/java/roomescape/controller/view/dto/PaymentFailRequest.java b/src/main/java/roomescape/controller/view/dto/PaymentFailRequest.java
new file mode 100644
index 0000000000..6f6c8dd7ea
--- /dev/null
+++ b/src/main/java/roomescape/controller/view/dto/PaymentFailRequest.java
@@ -0,0 +1,10 @@
+package roomescape.controller.view.dto;
+
+public record PaymentFailRequest(
+ String code,
+ String message,
+ String orderId,
+ Long paymentId,
+ String name
+) {
+}
diff --git a/src/main/java/roomescape/controller/view/dto/PaymentSuccessRequest.java b/src/main/java/roomescape/controller/view/dto/PaymentSuccessRequest.java
new file mode 100644
index 0000000000..a063788ff7
--- /dev/null
+++ b/src/main/java/roomescape/controller/view/dto/PaymentSuccessRequest.java
@@ -0,0 +1,9 @@
+package roomescape.controller.view.dto;
+
+public record PaymentSuccessRequest(
+ String paymentKey,
+ String orderId,
+ Long amount,
+ String name
+) {
+}
diff --git a/src/main/java/roomescape/domain/Payment.java b/src/main/java/roomescape/domain/Payment.java
index 207e1741a2..79083f98ad 100644
--- a/src/main/java/roomescape/domain/Payment.java
+++ b/src/main/java/roomescape/domain/Payment.java
@@ -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;
@@ -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
@@ -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;
}
@@ -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는 양수여야 합니다.");
@@ -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;
+ }
}
diff --git a/src/main/java/roomescape/domain/PaymentStatus.java b/src/main/java/roomescape/domain/PaymentStatus.java
index d2d2adee20..5f7415f8a4 100644
--- a/src/main/java/roomescape/domain/PaymentStatus.java
+++ b/src/main/java/roomescape/domain/PaymentStatus.java
@@ -4,6 +4,7 @@ public enum PaymentStatus {
READY,
FAILED,
CANCELED,
+ CHECK_REQUIRED,
CONFIRMED;
public static PaymentStatus fromTossStatus(String tossStatus) {
diff --git a/src/main/java/roomescape/exception/ErrorCode.java b/src/main/java/roomescape/exception/ErrorCode.java
index e1e7d4b210..e22de8a9fc 100644
--- a/src/main/java/roomescape/exception/ErrorCode.java
+++ b/src/main/java/roomescape/exception/ErrorCode.java
@@ -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;
diff --git a/src/main/java/roomescape/payment/client/GatewayRateLimitException.java b/src/main/java/roomescape/payment/client/GatewayRateLimitException.java
new file mode 100644
index 0000000000..94e6a48de6
--- /dev/null
+++ b/src/main/java/roomescape/payment/client/GatewayRateLimitException.java
@@ -0,0 +1,18 @@
+package roomescape.payment.client;
+
+import roomescape.service.payment.PaymentFailureCategory;
+import roomescape.service.payment.PaymentGatewayException;
+
+/**
+ * 토스가 429 를 반복해 재시도 횟수를 모두 소진했을 때 던지는 예외.
+ *
+ *
429 는 "아직 처리되지 않음"을 뜻하므로 결제는 그대로 두고(상태 유지) 안전하게 다시 시도할 수 있다.
+ */
+public class GatewayRateLimitException extends PaymentGatewayException {
+
+ public static final String CODE = "TOO_MANY_REQUESTS";
+
+ public GatewayRateLimitException(String message) {
+ super(PaymentFailureCategory.RATE_LIMITED, CODE, message);
+ }
+}
diff --git a/src/main/java/roomescape/payment/client/OutboundRateLimitException.java b/src/main/java/roomescape/payment/client/OutboundRateLimitException.java
new file mode 100644
index 0000000000..9c9c6592d7
--- /dev/null
+++ b/src/main/java/roomescape/payment/client/OutboundRateLimitException.java
@@ -0,0 +1,18 @@
+package roomescape.payment.client;
+
+import roomescape.service.payment.PaymentFailureCategory;
+import roomescape.service.payment.PaymentGatewayException;
+
+/**
+ * 나가는 결제 호출이 자체 Rate Limit 을 초과해 외부로 보내지 않고 거부할 때 던지는 예외.
+ *
+ *
호출이 토스에 도달하지 않았으므로 결제는 그대로 두고(상태 유지) 안전하게 다시 시도할 수 있다.
+ */
+public class OutboundRateLimitException extends PaymentGatewayException {
+
+ public static final String CODE = "OUTBOUND_RATE_LIMITED";
+
+ public OutboundRateLimitException(String message) {
+ super(PaymentFailureCategory.RATE_LIMITED, CODE, message);
+ }
+}
diff --git a/src/main/java/roomescape/payment/client/OutboundRateLimitInterceptor.java b/src/main/java/roomescape/payment/client/OutboundRateLimitInterceptor.java
new file mode 100644
index 0000000000..dd32a6a0a9
--- /dev/null
+++ b/src/main/java/roomescape/payment/client/OutboundRateLimitInterceptor.java
@@ -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 을 적용하는 인터셉터.
+ *
+ *
외부로 보내기 전에 토큰을 소비해, 한도를 넘으면 토스에 보내지 않고 {@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);
+ }
+}
diff --git a/src/main/java/roomescape/payment/client/RetryAfterInterceptor.java b/src/main/java/roomescape/payment/client/RetryAfterInterceptor.java
new file mode 100644
index 0000000000..9c9c86af18
--- /dev/null
+++ b/src/main/java/roomescape/payment/client/RetryAfterInterceptor.java
@@ -0,0 +1,71 @@
+package roomescape.payment.client;
+
+import java.io.IOException;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpRequest;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.client.ClientHttpRequestExecution;
+import org.springframework.http.client.ClientHttpRequestInterceptor;
+import org.springframework.http.client.ClientHttpResponse;
+
+/**
+ * 토스가 429 를 주면 Retry-After(초)만큼 기다렸다 재시도하는 인터셉터.
+ *
+ *
첫 호출을 포함해 최대 {@code maxAttempts} 회까지 시도하고, 그래도 429 면 무한 재시도를 막기 위해
+ * {@link GatewayRateLimitException} 으로 실패시킨다. Retry-After 가 없으면 짧은 고정 간격으로 폴백한다.
+ * 재시도는 멱등키를 유지한 채 같은 요청을 그대로 다시 보낸다.
+ */
+public class RetryAfterInterceptor implements ClientHttpRequestInterceptor {
+
+ private static final long DEFAULT_RETRY_AFTER_SECONDS = 1L;
+
+ private final int maxAttempts;
+
+ public RetryAfterInterceptor(int maxAttempts) {
+ this.maxAttempts = maxAttempts;
+ }
+
+ @Override
+ public ClientHttpResponse intercept(
+ HttpRequest request, byte[] body, ClientHttpRequestExecution execution) throws IOException {
+ ClientHttpResponse response = execution.execute(request, body);
+ int attempt = 1;
+ while (isTooManyRequests(response) && attempt < maxAttempts) {
+ long waitSeconds = parseRetryAfterSeconds(response);
+ response.close();
+ sleepSeconds(waitSeconds);
+ response = execution.execute(request, body);
+ attempt++;
+ }
+ if (isTooManyRequests(response)) {
+ response.close();
+ throw new GatewayRateLimitException("결제 요청이 많아 처리하지 못했습니다. 잠시 후 다시 시도해주세요.");
+ }
+ return response;
+ }
+
+ private boolean isTooManyRequests(ClientHttpResponse response) throws IOException {
+ return response.getStatusCode().value() == HttpStatus.TOO_MANY_REQUESTS.value();
+ }
+
+ private long parseRetryAfterSeconds(ClientHttpResponse response) {
+ String value = response.getHeaders().getFirst(HttpHeaders.RETRY_AFTER);
+ if (value == null) {
+ return DEFAULT_RETRY_AFTER_SECONDS;
+ }
+ try {
+ return Math.max(0, Long.parseLong(value.trim()));
+ } catch (NumberFormatException e) {
+ return DEFAULT_RETRY_AFTER_SECONDS;
+ }
+ }
+
+ private void sleepSeconds(long seconds) {
+ try {
+ Thread.sleep(seconds * 1000L);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IllegalStateException("재시도 대기 중 인터럽트되었습니다.", e);
+ }
+ }
+}
diff --git a/src/main/java/roomescape/payment/client/TossClientConfig.java b/src/main/java/roomescape/payment/client/TossClientConfig.java
index 60fcd9f140..e0b3da1000 100644
--- a/src/main/java/roomescape/payment/client/TossClientConfig.java
+++ b/src/main/java/roomescape/payment/client/TossClientConfig.java
@@ -6,7 +6,9 @@
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpHeaders;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.web.client.RestClient;
+import roomescape.ratelimit.TokenBucketRateLimiter;
@Configuration
public class TossClientConfig {
@@ -14,13 +16,27 @@ public class TossClientConfig {
@Bean
RestClient tossRestClient(
@Value("${toss.base-url:https://api.tosspayments.com}") String baseUrl,
- @Value("${toss.secret-key:}") String secretKey
+ @Value("${toss.secret-key:}") String secretKey,
+ @Value("${toss.connect-timeout-ms:1000}") int connectTimeoutMs,
+ @Value("${toss.read-timeout-ms:2000}") int readTimeoutMs,
+ @Value("${toss.max-attempts:3}") int maxAttempts,
+ @Value("${outbound-rate-limit.capacity:100}") long outboundCapacity,
+ @Value("${outbound-rate-limit.refill-per-second:100}") double outboundRefillPerSecond
) {
String encodedCredentials = Base64.getEncoder()
.encodeToString((secretKey + ":").getBytes(StandardCharsets.UTF_8));
+ SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
+ requestFactory.setConnectTimeout(connectTimeoutMs);
+ requestFactory.setReadTimeout(readTimeoutMs);
+ TokenBucketRateLimiter outboundRateLimiter =
+ new TokenBucketRateLimiter(outboundCapacity, outboundRefillPerSecond, System::nanoTime);
+
return RestClient.builder()
.baseUrl(baseUrl)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Basic " + encodedCredentials)
+ .requestFactory(requestFactory)
+ .requestInterceptor(new OutboundRateLimitInterceptor(outboundRateLimiter))
+ .requestInterceptor(new RetryAfterInterceptor(maxAttempts))
.build();
}
}
diff --git a/src/main/java/roomescape/payment/client/TossPaymentGateway.java b/src/main/java/roomescape/payment/client/TossPaymentGateway.java
index e152f1aeee..797dfcef91 100644
--- a/src/main/java/roomescape/payment/client/TossPaymentGateway.java
+++ b/src/main/java/roomescape/payment/client/TossPaymentGateway.java
@@ -5,9 +5,12 @@
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
+import org.springframework.web.client.RestClientException;
import roomescape.domain.PaymentStatus;
import roomescape.service.payment.PaymentConfirmation;
+import roomescape.service.payment.PaymentFailureCategory;
import roomescape.service.payment.PaymentGateway;
+import roomescape.service.payment.PaymentGatewayException;
import roomescape.service.payment.PaymentResult;
import roomescape.payment.client.dto.ConfirmRequest;
import roomescape.payment.client.dto.TossErrorResponse;
@@ -28,16 +31,28 @@ public TossPaymentGateway(RestClient tossRestClient, ObjectMapper objectMapper)
public PaymentResult confirm(PaymentConfirmation confirmation) {
ConfirmRequest request = new ConfirmRequest(
confirmation.paymentKey(), confirmation.orderId(), confirmation.amount());
- TossPaymentResponse response = tossRestClient.post()
- .uri("/v1/payments/confirm")
- .contentType(MediaType.APPLICATION_JSON)
- .body(request)
- .retrieve()
- .onStatus(HttpStatusCode::isError, (requestHeaders, clientResponse) -> {
- TossErrorResponse error = objectMapper.readValue(clientResponse.getBody(), TossErrorResponse.class);
- throw TossPaymentException.of(clientResponse.getStatusCode(), error);
- })
- .body(TossPaymentResponse.class);
+ TossPaymentResponse response;
+ try {
+ response = tossRestClient.post()
+ .uri("/v1/payments/confirm")
+ .contentType(MediaType.APPLICATION_JSON)
+ .header("Idempotency-Key", confirmation.orderId())
+ .body(request)
+ .retrieve()
+ .onStatus(HttpStatusCode::isError, (requestHeaders, clientResponse) -> {
+ TossErrorResponse error = objectMapper.readValue(clientResponse.getBody(), TossErrorResponse.class);
+ throw TossPaymentException.of(clientResponse.getStatusCode(), error);
+ })
+ .body(TossPaymentResponse.class);
+ } catch (TossPaymentException e) {
+ throw e;
+ } catch (RestClientException e) {
+ throw new PaymentGatewayException(
+ PaymentFailureCategory.CONFIRMATION_UNKNOWN,
+ "PAYMENT_CONFIRMATION_UNKNOWN",
+ "결제 승인 결과를 확인할 수 없습니다."
+ );
+ }
return new PaymentResult(
response.paymentKey(),
response.orderId(),
diff --git a/src/main/java/roomescape/ratelimit/RateLimitInterceptor.java b/src/main/java/roomescape/ratelimit/RateLimitInterceptor.java
new file mode 100644
index 0000000000..7b7b1e7890
--- /dev/null
+++ b/src/main/java/roomescape/ratelimit/RateLimitInterceptor.java
@@ -0,0 +1,57 @@
+package roomescape.ratelimit;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.web.servlet.HandlerInterceptor;
+import roomescape.controller.dto.response.ErrorResponse;
+import roomescape.exception.ErrorCode;
+
+/**
+ * 들어오는 요청에 토큰 버킷 Rate Limit 을 적용하는 인터셉터.
+ *
+ *
토큰이 없으면 컨트롤러를 호출하지 않고 429 + Retry-After 로 거부하며, 응답 본문은 앱 공통 에러 포맷
+ * ({@link ErrorResponse})으로 내려준다. 쓰기(POST) 요청만 토큰을 소비하므로, 같은 경로의 조회(GET)는 한도와
+ * 무관하게 통과한다.
+ */
+public class RateLimitInterceptor implements HandlerInterceptor {
+
+ private final TokenBucketRateLimiter rateLimiter;
+ private final ObjectMapper objectMapper;
+
+ public RateLimitInterceptor(TokenBucketRateLimiter rateLimiter, ObjectMapper objectMapper) {
+ this.rateLimiter = rateLimiter;
+ this.objectMapper = objectMapper;
+ }
+
+ @Override
+ public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
+ throws IOException {
+ if (!consumesToken(request)) {
+ return true;
+ }
+ if (rateLimiter.tryConsume()) {
+ return true;
+ }
+ rejectWithTooManyRequests(response);
+ return false;
+ }
+
+ private boolean consumesToken(HttpServletRequest request) {
+ return HttpMethod.POST.matches(request.getMethod());
+ }
+
+ private void rejectWithTooManyRequests(HttpServletResponse response) throws IOException {
+ ErrorCode errorCode = ErrorCode.TOO_MANY_REQUESTS;
+ response.setStatus(errorCode.getStatus().value());
+ response.setHeader(HttpHeaders.RETRY_AFTER, String.valueOf(rateLimiter.retryAfterSeconds()));
+ response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+ response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+ objectMapper.writeValue(response.getWriter(), ErrorResponse.from(errorCode, errorCode.getDetail()));
+ }
+}
diff --git a/src/main/java/roomescape/ratelimit/RateLimitWebConfig.java b/src/main/java/roomescape/ratelimit/RateLimitWebConfig.java
new file mode 100644
index 0000000000..f84896836a
--- /dev/null
+++ b/src/main/java/roomescape/ratelimit/RateLimitWebConfig.java
@@ -0,0 +1,32 @@
+package roomescape.ratelimit;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * 들어오는 Rate Limit 인터셉터를 결제·예약 핵심 경로에 등록한다. 한도 정책은 {@code rate-limit.*} 로 외부화한다.
+ */
+@Configuration
+public class RateLimitWebConfig implements WebMvcConfigurer {
+
+ private final TokenBucketRateLimiter rateLimiter;
+ private final ObjectMapper objectMapper;
+
+ public RateLimitWebConfig(
+ ObjectMapper objectMapper,
+ @Value("${rate-limit.capacity:100}") long capacity,
+ @Value("${rate-limit.refill-per-second:100}") double refillPerSecond
+ ) {
+ this.objectMapper = objectMapper;
+ this.rateLimiter = new TokenBucketRateLimiter(capacity, refillPerSecond, System::nanoTime);
+ }
+
+ @Override
+ public void addInterceptors(InterceptorRegistry registry) {
+ registry.addInterceptor(new RateLimitInterceptor(rateLimiter, objectMapper))
+ .addPathPatterns("/reservations", "/reservations/*/payments");
+ }
+}
diff --git a/src/main/java/roomescape/ratelimit/TokenBucketRateLimiter.java b/src/main/java/roomescape/ratelimit/TokenBucketRateLimiter.java
new file mode 100644
index 0000000000..02c38c0b16
--- /dev/null
+++ b/src/main/java/roomescape/ratelimit/TokenBucketRateLimiter.java
@@ -0,0 +1,59 @@
+package roomescape.ratelimit;
+
+import java.util.function.LongSupplier;
+
+/**
+ * 토큰 버킷(Token Bucket) 방식의 Rate Limiter.
+ *
+ *
{@code capacity} 는 허용 버스트(순간 최대치), {@code refillPerSecond} 는 평균 허용 TPS 상한이다.
+ * 시계를 {@link LongSupplier} 로 주입받아, 테스트에서 가짜 시계로 결정적으로 검증할 수 있다.
+ */
+public class TokenBucketRateLimiter {
+
+ private static final double NANOS_PER_SECOND = 1_000_000_000.0;
+
+ private final long capacity;
+ private final double refillPerSecond;
+ private final LongSupplier nanoClock;
+
+ private double availableTokens;
+ private long lastRefillNanos;
+
+ public TokenBucketRateLimiter(long capacity, double refillPerSecond, LongSupplier nanoClock) {
+ this.capacity = capacity;
+ this.refillPerSecond = refillPerSecond;
+ this.nanoClock = nanoClock;
+ this.availableTokens = capacity;
+ this.lastRefillNanos = nanoClock.getAsLong();
+ }
+
+ /**
+ * 토큰이 있으면 1개 소비하고 {@code true}, 없으면 {@code false} 를 반환한다.
+ */
+ public synchronized boolean tryConsume() {
+ refill();
+ if (availableTokens >= 1) {
+ availableTokens -= 1;
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * 다음 토큰 1개가 보충될 때까지 필요한 시간(초)을 올림으로 반환한다. 이미 충분하면 0.
+ */
+ public synchronized long retryAfterSeconds() {
+ refill();
+ if (availableTokens >= 1) {
+ return 0;
+ }
+ return (long) Math.ceil((1 - availableTokens) / refillPerSecond);
+ }
+
+ private void refill() {
+ long now = nanoClock.getAsLong();
+ double elapsedSeconds = (now - lastRefillNanos) / NANOS_PER_SECOND;
+ availableTokens = Math.min(capacity, availableTokens + elapsedSeconds * refillPerSecond);
+ lastRefillNanos = now;
+ }
+}
diff --git a/src/main/java/roomescape/repository/PaymentRepository.java b/src/main/java/roomescape/repository/PaymentRepository.java
index 39a4f32648..0fcf163f80 100644
--- a/src/main/java/roomescape/repository/PaymentRepository.java
+++ b/src/main/java/roomescape/repository/PaymentRepository.java
@@ -15,7 +15,7 @@ public class PaymentRepository {
private final JdbcTemplate jdbcTemplate;
- private final RowMapper paymentRowMapper = (resultSet, rowNum) -> new Payment(
+ private final RowMapper paymentRowMapper = (resultSet, rowNum) -> Payment.restore(
resultSet.getLong("id"),
resultSet.getLong("reservation_id"),
resultSet.getString("order_id"),
diff --git a/src/main/java/roomescape/service/BookingLookupService.java b/src/main/java/roomescape/service/BookingLookupService.java
index 24cba992c0..6920d6e11c 100644
--- a/src/main/java/roomescape/service/BookingLookupService.java
+++ b/src/main/java/roomescape/service/BookingLookupService.java
@@ -9,6 +9,7 @@
import org.springframework.transaction.annotation.Transactional;
import roomescape.exception.ErrorCode;
import roomescape.exception.RoomescapeException;
+import roomescape.repository.PaymentRepository;
import roomescape.service.dto.BookingStatus;
@Service
@@ -16,17 +17,23 @@
public class BookingLookupService {
private final ReservationService reservationService;
private final ReservationWaitingService reservationWaitingService;
+ private final PaymentRepository paymentRepository;
public BookingLookupService(ReservationService reservationService,
- ReservationWaitingService reservationWaitingService) {
+ ReservationWaitingService reservationWaitingService,
+ PaymentRepository paymentRepository) {
this.reservationService = reservationService;
this.reservationWaitingService = reservationWaitingService;
+ this.paymentRepository = paymentRepository;
}
public List findByName(String name) {
return Stream.concat(
reservationService.findByName(name).stream()
- .map(BookingStatus::reservation),
+ .map(reservation -> BookingStatus.reservation(
+ reservation,
+ paymentRepository.findLatestByReservationId(reservation.getId()).orElse(null)
+ )),
reservationWaitingService.findByName(name).stream()
.map(BookingStatus::waiting))
.sorted(Comparator
@@ -41,7 +48,10 @@ public List findByDateRange(LocalDate startDate, LocalDate endDat
}
return Stream.concat(
reservationService.findByDateRange(startDate, endDate).stream()
- .map(BookingStatus::reservation),
+ .map(reservation -> BookingStatus.reservation(
+ reservation,
+ paymentRepository.findLatestByReservationId(reservation.getId()).orElse(null)
+ )),
reservationWaitingService.findByDateRange(startDate, endDate).stream()
.map(BookingStatus::waiting))
.sorted(Comparator
diff --git a/src/main/java/roomescape/service/PaymentService.java b/src/main/java/roomescape/service/PaymentService.java
index d586abe5c3..5c43ca14ac 100644
--- a/src/main/java/roomescape/service/PaymentService.java
+++ b/src/main/java/roomescape/service/PaymentService.java
@@ -66,9 +66,9 @@ public PaymentResult confirm(String paymentKey, String orderId, Long amount) {
if (!payment.getAmount().equals(amount)) {
throw new PaymentAmountMismatchException(payment.getAmount(), amount);
}
- if (payment.getStatus() != PaymentStatus.READY) {
+ if (!canConfirm(payment)) {
throw new RoomescapeException(ErrorCode.PAYMENT_CONFIRMATION_NOT_ALLOWED,
- "결제 대기 상태의 결제만 승인할 수 있습니다.");
+ "결제 대기 또는 확인 필요 상태의 결제만 승인할 수 있습니다.");
}
PaymentResult result;
@@ -78,6 +78,9 @@ public PaymentResult confirm(String paymentKey, String orderId, Long amount) {
if (e.isDefinitiveFailure()) {
paymentRepository.update(payment.fail(e.getCode(), e.getMessage()));
}
+ if (e.requiresConfirmationCheck()) {
+ paymentRepository.update(payment.checkRequired(e.getCode(), e.getMessage()));
+ }
throw e;
}
if (result.status() != PaymentStatus.CONFIRMED) {
@@ -108,4 +111,8 @@ private Payment reuseOrCreatePayment(Payment payment, Long reservationId) {
throw new RoomescapeException(ErrorCode.PAYMENT_RETRY_NOT_ALLOWED,
"결제를 다시 시도할 수 없는 상태입니다.");
}
+
+ private boolean canConfirm(Payment payment) {
+ return payment.getStatus() == PaymentStatus.READY || payment.getStatus() == PaymentStatus.CHECK_REQUIRED;
+ }
}
diff --git a/src/main/java/roomescape/service/dto/BookingPaymentInfo.java b/src/main/java/roomescape/service/dto/BookingPaymentInfo.java
new file mode 100644
index 0000000000..535f621c78
--- /dev/null
+++ b/src/main/java/roomescape/service/dto/BookingPaymentInfo.java
@@ -0,0 +1,28 @@
+package roomescape.service.dto;
+
+import roomescape.domain.Payment;
+import roomescape.domain.PaymentStatus;
+
+public record BookingPaymentInfo(
+ String orderId,
+ Long amount,
+ String paymentKey,
+ PaymentStatus status,
+ String failureCode,
+ String failureMessage
+) {
+
+ public static BookingPaymentInfo from(Payment payment) {
+ if (payment == null) {
+ return null;
+ }
+ return new BookingPaymentInfo(
+ payment.getOrderId(),
+ payment.getAmount(),
+ payment.getPaymentKey(),
+ payment.getStatus(),
+ payment.getFailureCode(),
+ payment.getFailureMessage()
+ );
+ }
+}
diff --git a/src/main/java/roomescape/service/dto/BookingStatus.java b/src/main/java/roomescape/service/dto/BookingStatus.java
index da8540063a..498e4d84e5 100644
--- a/src/main/java/roomescape/service/dto/BookingStatus.java
+++ b/src/main/java/roomescape/service/dto/BookingStatus.java
@@ -1,6 +1,7 @@
package roomescape.service.dto;
import java.time.LocalDate;
+import roomescape.domain.Payment;
import roomescape.domain.Reservation;
import roomescape.domain.ReservationSlot;
import roomescape.domain.ReservationStatus;
@@ -17,9 +18,19 @@ public record BookingStatus(
Theme theme,
BookingType bookingType,
ReservationStatus reservationStatus,
- Long turn
+ Long turn,
+ BookingPaymentInfo payment
) {
+ public BookingStatus(Long id, String name, LocalDate date, ReservationTime time, Theme theme,
+ BookingType bookingType, ReservationStatus reservationStatus, Long turn) {
+ this(id, name, date, time, theme, bookingType, reservationStatus, turn, null);
+ }
+
public static BookingStatus reservation(Reservation reservation) {
+ return reservation(reservation, null);
+ }
+
+ public static BookingStatus reservation(Reservation reservation, Payment payment) {
ReservationSlot slot = reservation.getSlot();
return new BookingStatus(
reservation.getId(),
@@ -29,7 +40,8 @@ public static BookingStatus reservation(Reservation reservation) {
slot.getTheme(),
BookingType.RESERVATION,
reservation.getStatus(),
- null
+ null,
+ BookingPaymentInfo.from(payment)
);
}
@@ -44,7 +56,8 @@ public static BookingStatus waiting(WaitingWithTurn waitingWithTurn) {
slot.getTheme(),
BookingType.WAITING,
null,
- waitingWithTurn.turn()
+ waitingWithTurn.turn(),
+ null
);
}
}
diff --git a/src/main/java/roomescape/service/payment/PaymentFailureCategory.java b/src/main/java/roomescape/service/payment/PaymentFailureCategory.java
index 27bca68913..3eb1b395b7 100644
--- a/src/main/java/roomescape/service/payment/PaymentFailureCategory.java
+++ b/src/main/java/roomescape/service/payment/PaymentFailureCategory.java
@@ -3,5 +3,7 @@
public enum PaymentFailureCategory {
DEFINITIVE,
UNKNOWN,
- CONFIGURATION
+ CONFIRMATION_UNKNOWN,
+ CONFIGURATION,
+ RATE_LIMITED
}
diff --git a/src/main/java/roomescape/service/payment/PaymentGatewayException.java b/src/main/java/roomescape/service/payment/PaymentGatewayException.java
index 8d2535c466..280061f92a 100644
--- a/src/main/java/roomescape/service/payment/PaymentGatewayException.java
+++ b/src/main/java/roomescape/service/payment/PaymentGatewayException.java
@@ -22,4 +22,8 @@ public String getCode() {
public boolean isDefinitiveFailure() {
return failureCategory == PaymentFailureCategory.DEFINITIVE;
}
+
+ public boolean requiresConfirmationCheck() {
+ return failureCategory == PaymentFailureCategory.CONFIRMATION_UNKNOWN;
+ }
}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index bc223cdc71..485e09abd1 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -9,3 +9,15 @@ spring.h2.console.path=/h2-console
toss.base-url=https://api.tosspayments.com
toss.client-key=test_gck_docs_Ovk5rk1EwkEbP0W43n07xlzm
toss.secret-key=test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6
+toss.connect-timeout-ms=1000
+toss.read-timeout-ms=2000
+# 토스가 429 를 주면 Retry-After 만큼 대기 후 재시도하는 최대 시도 횟수(첫 호출 포함)
+toss.max-attempts=3
+
+# 들어오는 결제·예약 요청의 Rate Limit 정책 (capacity=허용 버스트, refill-per-second=평균 TPS 상한)
+rate-limit.capacity=100
+rate-limit.refill-per-second=100
+
+# 나가는 토스 호출의 Rate Limit 정책 (상대 한도를 넘지 않도록 보내기 전에 스스로 조절)
+outbound-rate-limit.capacity=100
+outbound-rate-limit.refill-per-second=100
diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css
index 68c628e1d7..63804679ce 100644
--- a/src/main/resources/static/css/app.css
+++ b/src/main/resources/static/css/app.css
@@ -86,6 +86,10 @@ main {
padding: 3rem 2rem 6rem;
}
+body.my-page main {
+ max-width: 1120px;
+}
+
/* 스텝 인디케이터 */
.steps {
display: flex;
@@ -791,7 +795,7 @@ button:disabled {
.table-wrap {
border: 1px solid var(--border);
border-radius: 8px;
- overflow: hidden;
+ overflow-x: auto;
background: var(--surface);
}
@@ -836,6 +840,26 @@ tbody tr.edit-row:hover {
background: rgba(200,169,110,0.04);
}
+body.my-page th,
+body.my-page td {
+ white-space: nowrap;
+}
+
+body.my-page td:nth-child(4) {
+ min-width: 140px;
+ white-space: normal;
+}
+
+body.my-page tr.edit-row td,
+body.my-page tr.detail-row td {
+ white-space: normal;
+}
+
+tbody tr.detail-row,
+tbody tr.detail-row:hover {
+ background: rgba(200,169,110,0.035);
+}
+
.sort-indicator {
display: inline-block;
min-width: 1.2rem;
@@ -897,6 +921,12 @@ tbody tr.edit-row:hover {
background: var(--accent-dim);
}
+.status-badge.failed {
+ border-color: rgba(213,108,91,0.45);
+ color: var(--danger);
+ background: rgba(213,108,91,0.08);
+}
+
.action-buttons {
display: flex;
justify-content: flex-end;
@@ -940,6 +970,70 @@ tbody tr.edit-row:hover {
min-height: 42px;
}
+.booking-detail-panel {
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ background: var(--surface-2);
+ padding: 1.1rem 1.2rem;
+}
+
+.booking-detail-title {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+ color: var(--text-primary);
+ font-weight: 500;
+ margin-bottom: 1rem;
+}
+
+.booking-detail-title span {
+ color: var(--accent);
+ font-size: 0.82rem;
+ font-weight: 400;
+}
+
+.booking-detail-grid {
+ display: grid;
+ grid-template-columns: minmax(220px, 0.75fr) minmax(320px, 1.25fr);
+ gap: 1.25rem;
+}
+
+.booking-detail-section h3 {
+ color: var(--text-muted);
+ font-size: 0.68rem;
+ font-weight: 500;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ margin-bottom: 0.65rem;
+}
+
+.booking-detail-section dl {
+ display: grid;
+ gap: 0.55rem;
+}
+
+.booking-detail-section dl > div {
+ display: grid;
+ grid-template-columns: 72px minmax(0, 1fr);
+ gap: 0.9rem;
+ align-items: start;
+}
+
+.booking-detail-section dt {
+ color: var(--text-secondary);
+ font-size: 0.78rem;
+}
+
+.booking-detail-section dd {
+ color: var(--text-primary);
+ font-size: 0.86rem;
+}
+
+.booking-detail-section .code-value {
+ overflow-wrap: anywhere;
+ word-break: break-word;
+}
+
.edit-actions {
display: flex;
justify-content: flex-end;
@@ -1262,6 +1356,15 @@ body.payment-result-fail {
.edit-grid {
grid-template-columns: 1fr;
}
+ .booking-detail-grid,
+ .booking-detail-title {
+ grid-template-columns: 1fr;
+ flex-direction: column;
+ }
+ .booking-detail-section dl > div {
+ grid-template-columns: 1fr;
+ gap: 0.2rem;
+ }
.edit-actions {
justify-content: stretch;
}
diff --git a/src/main/resources/templates/my-reservation.html b/src/main/resources/templates/my-reservation.html
index ccf626cd3f..6e9d517227 100644
--- a/src/main/resources/templates/my-reservation.html
+++ b/src/main/resources/templates/my-reservation.html
@@ -40,13 +40,14 @@
| 시간 |
테마 |
상태 |
+ 결제 상태 |
대기 순번 |
관리 |
- | 조회된 예약이 없습니다 |
+ 조회된 예약이 없습니다 |
@@ -64,6 +65,7 @@
let editingReservationId = null;
let editingTimeId = null;
let editingTimes = null;
+ let detailBookingKey = null;
window.onload = () => {
nameInput.addEventListener('keydown', event => {
@@ -94,7 +96,7 @@
}
resultMeta.textContent = '예약·대기 목록을 불러오는 중입니다.';
- reservationBody.innerHTML = `| 조회 중입니다 |
`;
+ reservationBody.innerHTML = `| 조회 중입니다 |
`;
fetch(`/bookings?name=${encodeURIComponent(name)}`)
.then(handleResponse)
@@ -103,6 +105,7 @@
editingReservationId = null;
editingTimeId = null;
editingTimes = null;
+ detailBookingKey = null;
renderReservations(name, reservations);
})
.catch(showError);
@@ -113,7 +116,7 @@
const sortedRows = [...rows].sort(compareReservation);
resultMeta.textContent = `${name}님의 예약·대기 ${rows.length}건`;
if (sortedRows.length === 0) {
- reservationBody.innerHTML = `| 조회된 예약이 없습니다 |
`;
+ reservationBody.innerHTML = `| 조회된 예약이 없습니다 |
`;
return;
}
@@ -130,6 +133,7 @@
${formatTime(reservation.time?.startAt)} |
${escapeHtml(reservation.theme?.name || '')} |
${renderStatusBadge(reservation)} |
+ ${renderPaymentStatusBadge(reservation)} |
${waiting ? `${reservation.turn}번째` : '-'} |
@@ -140,6 +144,9 @@
if (editing) {
row += renderEditRow(reservation);
}
+ if (isDetailOpen(reservation)) {
+ row += renderDetailRow(reservation);
+ }
return row;
}).join('');
}
@@ -147,7 +154,7 @@
function renderEditRow(reservation) {
return `
- |
+ |
현재 예약: ${reservation.date} ${formatTime(reservation.time?.startAt)} · ${escapeHtml(reservation.theme?.name || '')}
@@ -174,6 +181,76 @@
`;
}
+ function renderDetailRow(reservation) {
+ const payment = reservation.payment;
+ return `
+
+
+
+
+ ${escapeHtml(reservation.theme?.name || '')}
+ ${reservation.date} ${formatTime(reservation.time?.startAt)}
+
+
+
+ 예약 정보
+
+
+ - 예약자
+ - ${escapeHtml(reservation.name || '')}
+
+
+ - 테마
+ - ${escapeHtml(reservation.theme?.name || '')}
+
+
+ - 예약 일시
+ - ${reservation.date} ${formatTime(reservation.time?.startAt)}
+
+
+
+
+ 결제 정보
+ ${payment ? `
+
+
+ - 상태
+ - ${getPaymentStatusLabel(payment.status)}
+
+
+ - 주문번호
+ - ${escapeHtml(payment.orderId || '-')}
+
+
+ - 승인키
+ - ${escapeHtml(payment.paymentKey || '-')}
+
+
+ - 금액
+ - ${formatAmount(payment.amount)}
+
+ ${payment.failureCode ? `
+
+ - 실패 코드
+ - ${escapeHtml(payment.failureCode)}
+
+ ` : ''}
+ ${payment.failureMessage ? `
+
+ - 사유
+ - ${escapeHtml(payment.failureMessage)}
+
+ ` : ''}
+
+ ` : ' 결제 정보가 없습니다. '}
+
+
+
+ |
+
+ `;
+ }
+
function renderEditTimes(reservationId) {
if (editingTimes === null) {
return ' 날짜를 기준으로 시간을 불러오는 중입니다 ';
@@ -360,6 +437,7 @@
time: reservation.time?.startAt,
theme: reservation.theme?.name,
status: getStatusLabel(reservation),
+ payment: getPaymentStatusLabel(reservation.payment?.status),
turn: reservation.turn ?? 0
}[key] || '';
}
@@ -394,19 +472,50 @@
return value ? value.slice(0, 5) : '';
}
+ function formatAmount(value) {
+ if (value === null || value === undefined) {
+ return '-';
+ }
+ return `${Number(value).toLocaleString('ko-KR')}원`;
+ }
+
+ function getBookingKey(reservation) {
+ return `${reservation.bookingType}-${reservation.id}`;
+ }
+
+ function isDetailOpen(reservation) {
+ return detailBookingKey === getBookingKey(reservation);
+ }
+
+ function toggleDetail(id, status) {
+ const reservation = findReservation(id, status);
+ if (!reservation) {
+ showToast('예약 정보를 찾을 수 없습니다', 'error');
+ return;
+ }
+ const key = getBookingKey(reservation);
+ detailBookingKey = detailBookingKey === key ? null : key;
+ renderReservations(nameInput.value.trim(), reservations);
+ }
+
function renderActions(reservation, waiting, pendingPayment) {
if (waiting) {
return ` `;
}
+ const detailButton = ` `;
if (pendingPayment) {
+ const retryButton = reservation.payment?.status === 'CHECK_REQUIRED'
+ ? ''
+ : ` `;
return `
-
+ ${detailButton}
+ ${retryButton}
`;
}
return `
+ ${detailButton}
- 결제 취소 기능 준비 중
`;
}
@@ -432,6 +541,21 @@
return ' 예약 확정';
}
+ function renderPaymentStatusBadge(reservation) {
+ if (reservation.bookingType === 'WAITING') {
+ return ' -';
+ }
+ const status = reservation.payment?.status;
+ const label = getPaymentStatusLabel(status);
+ if (status === 'CONFIRMED') {
+ return ` ${label}`;
+ }
+ if (status === 'FAILED' || status === 'CANCELED') {
+ return ` ${label}`;
+ }
+ return ` ${label}`;
+ }
+
function getStatusLabel(reservation) {
if (reservation.bookingType === 'WAITING') {
return '대기';
@@ -439,6 +563,16 @@
return reservation.reservationStatus === 'PENDING' ? '결제 대기' : '예약 확정';
}
+ function getPaymentStatusLabel(status) {
+ return {
+ READY: '결제 대기',
+ CONFIRMED: '결제 완료',
+ FAILED: '결제 실패',
+ CANCELED: '결제 취소',
+ CHECK_REQUIRED: '확인 필요'
+ }[status] || '-';
+ }
+
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
@@ -476,7 +610,7 @@
function showError(error) {
resultMeta.textContent = '예약·대기 목록을 불러오지 못했습니다.';
- reservationBody.innerHTML = ` | 조회된 예약이 없습니다 | `;
+ reservationBody.innerHTML = ` | 조회된 예약이 없습니다 | `;
showToast(error.message, 'error');
}
diff --git a/src/main/resources/templates/payment-fail.html b/src/main/resources/templates/payment-fail.html
index f2da1fa699..9bc8b599bb 100644
--- a/src/main/resources/templates/payment-fail.html
+++ b/src/main/resources/templates/payment-fail.html
@@ -15,10 +15,13 @@
- PAYMENT NOT COMPLETED
+ PAYMENT NOT COMPLETED
!
- 결제를 완료하지 못했어요
- 예약은 결제 대기 상태로 유지됩니다. 내 예약에서 다시 결제하거나 취소할 수 있어요.
+ 결제를 완료하지 못했어요
+ 예약은 결제 대기 상태로 유지됩니다. 내 예약에서 다시 결제하거나 취소할 수 있어요.
예약 정보
@@ -38,7 +41,8 @@ 예약 정보
- 결제 실패 정보
+ 결제 실패 정보
- 에러 코드
diff --git a/src/test/java/roomescape/MissionStepTest.java b/src/test/java/roomescape/MissionStepTest.java
index 0cada19c65..6bc06701d6 100644
--- a/src/test/java/roomescape/MissionStepTest.java
+++ b/src/test/java/roomescape/MissionStepTest.java
@@ -355,7 +355,11 @@ void setup() {
.body("reservationId", is(1))
.body("paymentId", is(1));
- jdbcTemplate.update("UPDATE payment SET status = 'FAILED' WHERE id = 1;");
+ jdbcTemplate.update("""
+ UPDATE payment
+ SET status = 'FAILED', failure_code = 'REJECT_CARD_PAYMENT', failure_message = '카드가 거절되었습니다.'
+ WHERE id = 1;
+ """);
RestAssured.given().log().all()
.queryParam("name", "브라운")
diff --git a/src/test/java/roomescape/controller/admin/AdminBookingControllerTest.java b/src/test/java/roomescape/controller/admin/AdminBookingControllerTest.java
index d29353a454..189bd7a43b 100644
--- a/src/test/java/roomescape/controller/admin/AdminBookingControllerTest.java
+++ b/src/test/java/roomescape/controller/admin/AdminBookingControllerTest.java
@@ -55,9 +55,11 @@ class AdminBookingControllerTest {
.andExpect(jsonPath("$[0].bookingType").value("RESERVATION"))
.andExpect(jsonPath("$[0].reservationStatus").value("CONFIRMED"))
.andExpect(jsonPath("$[0].turn").value(nullValue()))
+ .andExpect(jsonPath("$[0].payment").value(nullValue()))
.andExpect(jsonPath("$[1].id").value(2))
.andExpect(jsonPath("$[1].bookingType").value("WAITING"))
.andExpect(jsonPath("$[1].reservationStatus").value(nullValue()))
+ .andExpect(jsonPath("$[1].payment").value(nullValue()))
.andExpect(jsonPath("$[1].turn").value(1));
verify(bookingLookupService, times(1)).findByDateRange(startDate, endDate);
diff --git a/src/test/java/roomescape/controller/user/BookingControllerTest.java b/src/test/java/roomescape/controller/user/BookingControllerTest.java
index 0ba649d140..389d654245 100644
--- a/src/test/java/roomescape/controller/user/BookingControllerTest.java
+++ b/src/test/java/roomescape/controller/user/BookingControllerTest.java
@@ -20,8 +20,10 @@
import org.springframework.test.web.servlet.MockMvc;
import roomescape.domain.ReservationTime;
import roomescape.domain.ReservationStatus;
+import roomescape.domain.PaymentStatus;
import roomescape.domain.Theme;
import roomescape.service.BookingLookupService;
+import roomescape.service.dto.BookingPaymentInfo;
import roomescape.service.dto.BookingStatus;
import roomescape.service.dto.BookingType;
@@ -51,9 +53,14 @@ class BookingControllerTest {
.andExpect(jsonPath("$[0].bookingType").value("RESERVATION"))
.andExpect(jsonPath("$[0].reservationStatus").value("CONFIRMED"))
.andExpect(jsonPath("$[0].turn").value(nullValue()))
+ .andExpect(jsonPath("$[0].payment.orderId").value("payment_confirmed_123456789012345"))
+ .andExpect(jsonPath("$[0].payment.amount").value(20000))
+ .andExpect(jsonPath("$[0].payment.paymentKey").value("test_payment_key"))
+ .andExpect(jsonPath("$[0].payment.status").value("CONFIRMED"))
.andExpect(jsonPath("$[1].id").value(2))
.andExpect(jsonPath("$[1].bookingType").value("WAITING"))
.andExpect(jsonPath("$[1].reservationStatus").value(nullValue()))
+ .andExpect(jsonPath("$[1].payment").value(nullValue()))
.andExpect(jsonPath("$[1].turn").value(1));
verify(bookingLookupService, times(1)).findByName("브라운");
@@ -74,7 +81,9 @@ private BookingStatus reservationBooking() {
ReservationTime time = new ReservationTime(1L, LocalTime.of(10, 0));
Theme theme = new Theme(1L, "테마", "설명", "썸네일");
return new BookingStatus(1L, "브라운", LocalDate.of(2099, 1, 1), time, theme,
- BookingType.RESERVATION, ReservationStatus.CONFIRMED, null);
+ BookingType.RESERVATION, ReservationStatus.CONFIRMED, null,
+ new BookingPaymentInfo("payment_confirmed_123456789012345", 20_000L,
+ "test_payment_key", PaymentStatus.CONFIRMED, null, null));
}
private BookingStatus waitingBooking() {
diff --git a/src/test/java/roomescape/controller/user/ReservationControllerTest.java b/src/test/java/roomescape/controller/user/ReservationControllerTest.java
index 85a8c9ae66..7d0cf9591a 100644
--- a/src/test/java/roomescape/controller/user/ReservationControllerTest.java
+++ b/src/test/java/roomescape/controller/user/ReservationControllerTest.java
@@ -315,7 +315,7 @@ private Reservation updatedReservation() {
}
private Payment payment() {
- return new Payment(1L, 1L, "payment_12345678901234567890123456789012", 20_000L, null,
+ return Payment.restore(1L, 1L, "payment_12345678901234567890123456789012", 20_000L, null,
PaymentStatus.READY, null, null);
}
}
diff --git a/src/test/java/roomescape/controller/view/PaymentSuccessControllerTest.java b/src/test/java/roomescape/controller/view/PaymentSuccessControllerTest.java
index 70c7cae3f2..d451430c9d 100644
--- a/src/test/java/roomescape/controller/view/PaymentSuccessControllerTest.java
+++ b/src/test/java/roomescape/controller/view/PaymentSuccessControllerTest.java
@@ -114,9 +114,31 @@ class PaymentSuccessControllerTest {
.andExpect(status().isOk())
.andExpect(view().name("payment-fail"))
.andExpect(model().attribute("code", "PAY_PROCESS_CANCELED"))
+ .andExpect(model().attribute("confirmationUnknown", false))
.andExpect(model().attribute("orderId", (Object) null));
}
+ @Test
+ void 결제_승인_결과를_알_수_없으면_확인_필요_상태로_렌더링한다() throws Exception {
+ String orderId = "payment_123456789012345678901";
+ given(paymentService.confirm("test_payment_key", orderId, 20_000L))
+ .willThrow(new PaymentGatewayException(
+ PaymentFailureCategory.CONFIRMATION_UNKNOWN,
+ "PAYMENT_CONFIRMATION_UNKNOWN",
+ "결제 승인 결과를 확인할 수 없습니다."));
+ given(paymentService.findReservationByOrderId(orderId)).willReturn(reservation());
+
+ mockMvc.perform(get("/payments/success")
+ .param("paymentKey", "test_payment_key")
+ .param("orderId", orderId)
+ .param("amount", "20000"))
+ .andExpect(status().isOk())
+ .andExpect(view().name("payment-fail"))
+ .andExpect(model().attribute("code", "PAYMENT_CONFIRMATION_UNKNOWN"))
+ .andExpect(model().attribute("confirmationUnknown", true))
+ .andExpect(model().attribute("reservation", PaymentReservationView.from(reservation())));
+ }
+
private Reservation reservation() {
return new Reservation(1L, new Reserver("브라운"),
new ReservationSlot(java.time.LocalDate.of(2099, 1, 1),
diff --git a/src/test/java/roomescape/domain/PaymentTest.java b/src/test/java/roomescape/domain/PaymentTest.java
index 8800fea08a..41a82c9de2 100644
--- a/src/test/java/roomescape/domain/PaymentTest.java
+++ b/src/test/java/roomescape/domain/PaymentTest.java
@@ -23,4 +23,45 @@ class PaymentTest {
.isInstanceOf(IllegalArgumentException.class)
.hasMessage("reservationId는 양수여야 합니다.");
}
+
+ @Test
+ void 승인된_결제는_paymentKey가_필요하다() {
+ assertThatThrownBy(() -> Payment.restore(1L, 1L, "payment_confirmed_123456789012345", 20_000L,
+ null, PaymentStatus.CONFIRMED, null, null))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("승인된 결제는 paymentKey가 필요합니다.");
+ }
+
+ @Test
+ void 실패한_결제는_실패_코드가_필요하다() {
+ assertThatThrownBy(() -> Payment.restore(1L, 1L, "payment_failed_123456789012345678", 20_000L,
+ null, PaymentStatus.FAILED, null, "카드 거절"))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessage("실패, 취소 또는 확인 필요 결제는 실패 코드가 필요합니다.");
+ }
+
+ @Test
+ void 결제_승인_결과_확인이_필요한_상태로_변경한다() {
+ Payment payment = Payment.ready(1L, 20_000L);
+
+ Payment checkRequiredPayment = payment.checkRequired(
+ "PAYMENT_CONFIRMATION_UNKNOWN", "결제 승인 결과를 확인할 수 없습니다.");
+
+ assertThat(checkRequiredPayment.getStatus()).isEqualTo(PaymentStatus.CHECK_REQUIRED);
+ assertThat(checkRequiredPayment.getFailureCode()).isEqualTo("PAYMENT_CONFIRMATION_UNKNOWN");
+ }
+
+ @Test
+ void 결제_승인_결과_확인이_필요한_결제를_승인하면_실패_정보를_초기화한다() {
+ Payment payment = Payment.restore(1L, 1L, "payment_check_required_123456789", 20_000L,
+ null, PaymentStatus.CHECK_REQUIRED,
+ "PAYMENT_CONFIRMATION_UNKNOWN", "결제 승인 결과를 확인할 수 없습니다.");
+
+ Payment confirmedPayment = payment.confirm("test_payment_key");
+
+ assertThat(confirmedPayment.getStatus()).isEqualTo(PaymentStatus.CONFIRMED);
+ assertThat(confirmedPayment.getPaymentKey()).isEqualTo("test_payment_key");
+ assertThat(confirmedPayment.getFailureCode()).isNull();
+ assertThat(confirmedPayment.getFailureMessage()).isNull();
+ }
}
diff --git a/src/test/java/roomescape/payment/client/OutboundRateLimitInterceptorTest.java b/src/test/java/roomescape/payment/client/OutboundRateLimitInterceptorTest.java
new file mode 100644
index 0000000000..aa2e5e4eda
--- /dev/null
+++ b/src/test/java/roomescape/payment/client/OutboundRateLimitInterceptorTest.java
@@ -0,0 +1,80 @@
+package roomescape.payment.client;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.util.concurrent.atomic.AtomicLong;
+import okhttp3.mockwebserver.Dispatcher;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import okhttp3.mockwebserver.RecordedRequest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.client.RestClient;
+import roomescape.ratelimit.TokenBucketRateLimiter;
+
+class OutboundRateLimitInterceptorTest {
+
+ private static final long ONE_SECOND_NANOS = 1_000_000_000L;
+
+ private MockWebServer server;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ server = new MockWebServer();
+ server.start();
+ server.setDispatcher(new Dispatcher() {
+ @Override
+ public MockResponse dispatch(RecordedRequest request) {
+ return new MockResponse()
+ .setResponseCode(200)
+ .setHeader("Content-Type", "application/json")
+ .setBody("{\"status\":\"DONE\"}");
+ }
+ });
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ server.shutdown();
+ }
+
+ @Test
+ void 자체_한도를_넘기면_외부로_보내지_않고_즉시_거부한다() {
+ AtomicLong clock = new AtomicLong(0);
+ RestClient client = clientWith(new TokenBucketRateLimiter(2, 1.0, clock::get));
+
+ assertThat(confirm(client)).contains("DONE");
+ assertThat(confirm(client)).contains("DONE");
+ assertThat(server.getRequestCount()).isEqualTo(2);
+
+ assertThatThrownBy(() -> confirm(client)).isInstanceOf(OutboundRateLimitException.class);
+ assertThat(server.getRequestCount()).isEqualTo(2);
+ }
+
+ @Test
+ void 토큰이_보충되면_다시_외부로_나간다() {
+ AtomicLong clock = new AtomicLong(0);
+ RestClient client = clientWith(new TokenBucketRateLimiter(1, 1.0, clock::get));
+
+ assertThat(confirm(client)).contains("DONE");
+ assertThatThrownBy(() -> confirm(client)).isInstanceOf(OutboundRateLimitException.class);
+
+ clock.addAndGet(ONE_SECOND_NANOS);
+
+ assertThat(confirm(client)).contains("DONE");
+ assertThat(server.getRequestCount()).isEqualTo(2);
+ }
+
+ private RestClient clientWith(TokenBucketRateLimiter rateLimiter) {
+ return RestClient.builder()
+ .baseUrl(server.url("/").toString())
+ .requestInterceptor(new OutboundRateLimitInterceptor(rateLimiter))
+ .build();
+ }
+
+ private String confirm(RestClient client) {
+ return client.post().uri("/v1/payments/confirm").retrieve().body(String.class);
+ }
+}
diff --git a/src/test/java/roomescape/payment/client/RetryAfterInterceptorTest.java b/src/test/java/roomescape/payment/client/RetryAfterInterceptorTest.java
new file mode 100644
index 0000000000..bc13555d63
--- /dev/null
+++ b/src/test/java/roomescape/payment/client/RetryAfterInterceptorTest.java
@@ -0,0 +1,83 @@
+package roomescape.payment.client;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.http.HttpHeaders;
+import org.springframework.web.client.RestClient;
+
+class RetryAfterInterceptorTest {
+
+ private MockWebServer server;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ server = new MockWebServer();
+ server.start();
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ server.shutdown();
+ }
+
+ @Test
+ void 게이트웨이가_429와_RetryAfter를_주면_대기후_재시도해_최종_200을_받는다() {
+ server.enqueue(new MockResponse()
+ .setResponseCode(429)
+ .setHeader(HttpHeaders.RETRY_AFTER, "1"));
+ server.enqueue(new MockResponse()
+ .setResponseCode(200)
+ .setHeader(HttpHeaders.CONTENT_TYPE, "application/json")
+ .setBody("{\"status\":\"DONE\"}"));
+ RestClient client = clientWith(3);
+
+ String body = client.post().uri("/v1/payments/confirm").retrieve().body(String.class);
+
+ assertThat(body).contains("DONE");
+ assertThat(server.getRequestCount()).isEqualTo(2);
+ }
+
+ @Test
+ void RetryAfter_헤더가_없으면_1초_고정_간격으로_폴백해_재시도한다() {
+ server.enqueue(new MockResponse().setResponseCode(429));
+ server.enqueue(new MockResponse()
+ .setResponseCode(200)
+ .setHeader(HttpHeaders.CONTENT_TYPE, "application/json")
+ .setBody("{\"status\":\"DONE\"}"));
+ RestClient client = clientWith(3);
+
+ long startNanos = System.nanoTime();
+ String body = client.post().uri("/v1/payments/confirm").retrieve().body(String.class);
+ long elapsedMillis = (System.nanoTime() - startNanos) / 1_000_000L;
+
+ assertThat(body).contains("DONE");
+ assertThat(server.getRequestCount()).isEqualTo(2);
+ assertThat(elapsedMillis).isGreaterThanOrEqualTo(1_000L);
+ }
+
+ @Test
+ void 재시도를_모두_소진해도_429면_게이트웨이_Rate_Limit_예외로_실패한다() {
+ server.enqueue(new MockResponse().setResponseCode(429).setHeader(HttpHeaders.RETRY_AFTER, "0"));
+ server.enqueue(new MockResponse().setResponseCode(429).setHeader(HttpHeaders.RETRY_AFTER, "0"));
+ server.enqueue(new MockResponse().setResponseCode(429).setHeader(HttpHeaders.RETRY_AFTER, "0"));
+ RestClient client = clientWith(3);
+
+ assertThatThrownBy(() -> client.post().uri("/v1/payments/confirm").retrieve().body(String.class))
+ .isInstanceOf(GatewayRateLimitException.class)
+ .hasFieldOrPropertyWithValue("code", GatewayRateLimitException.CODE);
+ assertThat(server.getRequestCount()).isEqualTo(3);
+ }
+
+ private RestClient clientWith(int maxAttempts) {
+ return RestClient.builder()
+ .baseUrl(server.url("/").toString())
+ .requestInterceptor(new RetryAfterInterceptor(maxAttempts))
+ .build();
+ }
+}
diff --git a/src/test/java/roomescape/payment/client/TossClientConfigTest.java b/src/test/java/roomescape/payment/client/TossClientConfigTest.java
new file mode 100644
index 0000000000..b825ed20a4
--- /dev/null
+++ b/src/test/java/roomescape/payment/client/TossClientConfigTest.java
@@ -0,0 +1,45 @@
+package roomescape.payment.client;
+
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.net.SocketTimeoutException;
+import java.util.concurrent.TimeUnit;
+import okhttp3.mockwebserver.MockResponse;
+import okhttp3.mockwebserver.MockWebServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.web.client.RestClientException;
+import org.springframework.web.client.RestClient;
+
+class TossClientConfigTest {
+
+ private MockWebServer server;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ server = new MockWebServer();
+ server.start();
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ server.shutdown();
+ }
+
+ @Test
+ void 응답이_느리면_read_timeout으로_끊는다() {
+ server.enqueue(new MockResponse()
+ .setBody("slow response")
+ .setBodyDelay(1, TimeUnit.SECONDS));
+ RestClient restClient = new TossClientConfig()
+ .tossRestClient(server.url("/").toString(), "test_secret_key", 500, 100, 3, 100, 100);
+
+ assertThatThrownBy(() -> restClient.get()
+ .uri("/")
+ .retrieve()
+ .body(String.class))
+ .isInstanceOf(RestClientException.class)
+ .hasRootCauseInstanceOf(SocketTimeoutException.class);
+ }
+}
diff --git a/src/test/java/roomescape/payment/client/TossPaymentGatewayTest.java b/src/test/java/roomescape/payment/client/TossPaymentGatewayTest.java
index 87de2c56da..219b76789e 100644
--- a/src/test/java/roomescape/payment/client/TossPaymentGatewayTest.java
+++ b/src/test/java/roomescape/payment/client/TossPaymentGatewayTest.java
@@ -5,6 +5,7 @@
import okhttp3.mockwebserver.MockResponse;
import okhttp3.mockwebserver.MockWebServer;
import okhttp3.mockwebserver.RecordedRequest;
+import okhttp3.mockwebserver.SocketPolicy;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -16,6 +17,7 @@
import roomescape.domain.PaymentStatus;
import roomescape.service.payment.PaymentConfirmation;
import roomescape.service.payment.PaymentFailureCategory;
+import roomescape.service.payment.PaymentGatewayException;
import roomescape.service.payment.PaymentResult;
import java.util.stream.Stream;
@@ -60,12 +62,55 @@ void tearDown() throws Exception {
RecordedRequest request = server.takeRequest();
assertThat(request.getMethod()).isEqualTo("POST");
assertThat(request.getPath()).isEqualTo("/v1/payments/confirm");
+ assertThat(request.getHeader("Idempotency-Key")).isEqualTo("payment_123456789012345678901");
assertThat(request.getBody().readUtf8())
.contains("\"paymentKey\":\"test_payment_key\"")
.contains("\"orderId\":\"payment_123456789012345678901\"")
.contains("\"amount\":20000");
}
+ @Test
+ void 토스가_429를_주어_재시도해도_멱등키는_유지된다() throws Exception {
+ server.enqueue(new MockResponse().setResponseCode(429).setHeader(HttpHeaders.RETRY_AFTER, "0"));
+ server.enqueue(new MockResponse()
+ .setHeader(HttpHeaders.CONTENT_TYPE, "application/json")
+ .setBody("""
+ {
+ "paymentKey": "test_payment_key",
+ "orderId": "payment_123456789012345678901",
+ "status": "DONE",
+ "totalAmount": 20000
+ }
+ """));
+ TossPaymentGateway gateway = new TossPaymentGateway(RestClient.builder()
+ .baseUrl(server.url("/").toString())
+ .requestInterceptor(new RetryAfterInterceptor(3))
+ .build(), new ObjectMapper());
+
+ gateway.confirm(new PaymentConfirmation(
+ "test_payment_key", "payment_123456789012345678901", 20_000L));
+
+ assertThat(server.getRequestCount()).isEqualTo(2);
+ RecordedRequest first = server.takeRequest();
+ RecordedRequest retried = server.takeRequest();
+ assertThat(first.getHeader("Idempotency-Key")).isEqualTo("payment_123456789012345678901");
+ assertThat(retried.getHeader("Idempotency-Key")).isEqualTo("payment_123456789012345678901");
+ }
+
+ @Test
+ void 토스_통신_예외를_승인_결과_확인_필요_예외로_변환한다() {
+ server.enqueue(new MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST));
+ TossPaymentGateway gateway = new TossPaymentGateway(RestClient.builder()
+ .baseUrl(server.url("/").toString())
+ .build(), new ObjectMapper());
+
+ org.assertj.core.api.Assertions.assertThatThrownBy(() -> gateway.confirm(new PaymentConfirmation(
+ "test_payment_key", "payment_123456789012345678901", 20_000L)))
+ .isInstanceOf(PaymentGatewayException.class)
+ .hasFieldOrPropertyWithValue("code", "PAYMENT_CONFIRMATION_UNKNOWN")
+ .hasFieldOrPropertyWithValue("failureCategory", PaymentFailureCategory.CONFIRMATION_UNKNOWN);
+ }
+
@ParameterizedTest
@MethodSource("errorResponses")
void 토스_오류를_코드에_맞는_예외로_변환한다(String code, int status,
diff --git a/src/test/java/roomescape/ratelimit/RateLimitInterceptorTest.java b/src/test/java/roomescape/ratelimit/RateLimitInterceptorTest.java
new file mode 100644
index 0000000000..5602197a5f
--- /dev/null
+++ b/src/test/java/roomescape/ratelimit/RateLimitInterceptorTest.java
@@ -0,0 +1,47 @@
+package roomescape.ratelimit;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+
+@SpringBootTest
+@AutoConfigureMockMvc
+@TestPropertySource(properties = {
+ "rate-limit.capacity=1",
+ "rate-limit.refill-per-second=0.001"
+})
+class RateLimitInterceptorTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Test
+ void 한도_내_쓰기_요청은_통과하고_초과_요청은_429와_RetryAfter를_받는다() throws Exception {
+ mockMvc.perform(post("/reservations").contentType(MediaType.APPLICATION_JSON).content("{}"))
+ .andExpect(status().isBadRequest());
+
+ mockMvc.perform(post("/reservations").contentType(MediaType.APPLICATION_JSON).content("{}"))
+ .andExpect(status().isTooManyRequests())
+ .andExpect(header().exists("Retry-After"))
+ .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
+ .andExpect(jsonPath("$.code").value("TOO_MANY_REQUESTS"))
+ .andExpect(jsonPath("$.detail").exists());
+ }
+
+ @Test
+ void 조회_요청은_한도와_무관하게_통과한다() throws Exception {
+ mockMvc.perform(get("/reservations").param("name", "브라운"))
+ .andExpect(status().isOk());
+ }
+}
diff --git a/src/test/java/roomescape/ratelimit/TokenBucketRateLimiterTest.java b/src/test/java/roomescape/ratelimit/TokenBucketRateLimiterTest.java
new file mode 100644
index 0000000000..e30e696758
--- /dev/null
+++ b/src/test/java/roomescape/ratelimit/TokenBucketRateLimiterTest.java
@@ -0,0 +1,113 @@
+package roomescape.ratelimit;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+class TokenBucketRateLimiterTest {
+
+ private static final long ONE_SECOND_NANOS = 1_000_000_000L;
+
+ @Test
+ void capacity만큼_통과한_뒤_거부되고_retryAfter는_보충_시간이다() {
+ AtomicLong clock = new AtomicLong(0);
+ TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(2, 1.0, clock::get);
+
+ assertThat(limiter.tryConsume()).isTrue();
+ assertThat(limiter.tryConsume()).isTrue();
+ assertThat(limiter.tryConsume()).isFalse();
+ assertThat(limiter.retryAfterSeconds()).isEqualTo(1L);
+ }
+
+ @Test
+ void 시간이_경과하면_토큰이_보충되어_다시_통과한다() {
+ AtomicLong clock = new AtomicLong(0);
+ TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(2, 1.0, clock::get);
+ limiter.tryConsume();
+ limiter.tryConsume();
+ assertThat(limiter.tryConsume()).isFalse();
+
+ clock.addAndGet(ONE_SECOND_NANOS);
+
+ assertThat(limiter.tryConsume()).isTrue();
+ }
+
+ @Test
+ void refillPerSecond가_평균_TPS_상한이_된다() {
+ AtomicLong clock = new AtomicLong(0);
+ TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(1, 2.0, clock::get);
+ assertThat(limiter.tryConsume()).isTrue();
+ assertThat(limiter.tryConsume()).isFalse();
+
+ clock.addAndGet(ONE_SECOND_NANOS / 2);
+
+ assertThat(limiter.tryConsume()).isTrue();
+ assertThat(limiter.tryConsume()).isFalse();
+ }
+
+ @Test
+ void 오래_기다려도_토큰은_capacity를_넘게_쌓이지_않는다() {
+ AtomicLong clock = new AtomicLong(0);
+ TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(2, 1.0, clock::get);
+
+ clock.addAndGet(ONE_SECOND_NANOS * 100);
+
+ assertThat(limiter.tryConsume()).isTrue();
+ assertThat(limiter.tryConsume()).isTrue();
+ assertThat(limiter.tryConsume()).isFalse();
+ }
+
+ @ParameterizedTest(name = "capacity={0} -> {1}번째 요청에서 거부")
+ @CsvSource({"5, 6", "1, 2", "3, 4"})
+ void 한도_파라미터에_따라_거부_시점이_달라진다(long capacity, int rejectAt) {
+ AtomicLong clock = new AtomicLong(0);
+ TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(capacity, 1.0, clock::get);
+
+ for (int i = 1; i < rejectAt; i++) {
+ assertThat(limiter.tryConsume()).as("%d번째 요청", i).isTrue();
+ }
+ assertThat(limiter.tryConsume()).as("%d번째 요청은 거부", rejectAt).isFalse();
+ }
+
+ @Test
+ void 동시_요청_여러개_중_capacity개만_통과한다() throws InterruptedException {
+ int capacity = 3;
+ int threadCount = 20;
+ AtomicLong clock = new AtomicLong(0);
+ TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(capacity, 1.0, clock::get);
+
+ CountDownLatch ready = new CountDownLatch(threadCount);
+ CountDownLatch start = new CountDownLatch(1);
+ AtomicInteger passed = new AtomicInteger();
+ ExecutorService pool = Executors.newFixedThreadPool(threadCount);
+
+ for (int i = 0; i < threadCount; i++) {
+ pool.submit(() -> {
+ ready.countDown();
+ try {
+ start.await();
+ if (limiter.tryConsume()) {
+ passed.incrementAndGet();
+ }
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ });
+ }
+
+ ready.await();
+ start.countDown();
+ pool.shutdown();
+ assertThat(pool.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
+
+ assertThat(passed.get()).isEqualTo(capacity);
+ }
+}
diff --git a/src/test/java/roomescape/repository/PaymentRepositoryTest.java b/src/test/java/roomescape/repository/PaymentRepositoryTest.java
index 1efa3db03d..4301f9e15d 100644
--- a/src/test/java/roomescape/repository/PaymentRepositoryTest.java
+++ b/src/test/java/roomescape/repository/PaymentRepositoryTest.java
@@ -47,8 +47,7 @@ void setUp() {
@Test
void 예약의_가장_최근_결제를_조회한다() {
Long reservationId = createPendingReservation();
- paymentRepository.insert(new Payment(null, reservationId, "payment_failed_12345678901234567890", 20_000L,
- null, PaymentStatus.FAILED, "REJECT_CARD_PAYMENT", "카드 거절"));
+ paymentRepository.insert(Payment.ready(reservationId, 20_000L).fail("REJECT_CARD_PAYMENT", "카드 거절"));
Payment latestPayment = paymentRepository.insert(Payment.ready(reservationId, 20_000L));
assertThat(paymentRepository.findLatestByReservationId(reservationId).orElseThrow().getId())
diff --git a/src/test/java/roomescape/service/BookingLookupServiceTest.java b/src/test/java/roomescape/service/BookingLookupServiceTest.java
index 7086b4778c..6ae7e69deb 100644
--- a/src/test/java/roomescape/service/BookingLookupServiceTest.java
+++ b/src/test/java/roomescape/service/BookingLookupServiceTest.java
@@ -10,6 +10,8 @@
import java.time.LocalTime;
import java.util.List;
import org.junit.jupiter.api.Test;
+import roomescape.domain.Payment;
+import roomescape.domain.PaymentStatus;
import roomescape.domain.Reservation;
import roomescape.domain.ReservationSlot;
import roomescape.domain.ReservationStatus;
@@ -20,6 +22,7 @@
import roomescape.domain.WaitingWithTurn;
import roomescape.exception.ErrorCode;
import roomescape.exception.RoomescapeException;
+import roomescape.repository.PaymentRepository;
import roomescape.service.dto.BookingStatus;
import roomescape.service.dto.BookingType;
@@ -27,9 +30,11 @@ class BookingLookupServiceTest {
private final ReservationService reservationService = mock();
private final ReservationWaitingService reservationWaitingService = mock();
+ private final PaymentRepository paymentRepository = mock();
private final BookingLookupService service = new BookingLookupService(
reservationService,
- reservationWaitingService);
+ reservationWaitingService,
+ paymentRepository);
private final LocalDate date = LocalDate.now().plusDays(1);
private final ReservationTime time = new ReservationTime(1L, LocalTime.parse("10:00"));
@@ -45,6 +50,10 @@ class BookingLookupServiceTest {
when(reservationService.findByName(name)).thenReturn(List.of(reservation));
when(reservationWaitingService.findByName(name)).thenReturn(List.of(waiting));
+ when(paymentRepository.findLatestByReservationId(1L)).thenReturn(java.util.Optional.of(
+ Payment.restore(1L, 1L, "payment_ready_123456789012345678901", 20_000L, null,
+ PaymentStatus.READY, null, null)
+ ));
List result = service.findByName(name);
@@ -55,7 +64,10 @@ class BookingLookupServiceTest {
.containsExactly(BookingType.WAITING, BookingType.RESERVATION),
() -> assertThat(result).extracting(BookingStatus::reservationStatus)
.containsExactly(null, ReservationStatus.CONFIRMED),
- () -> assertThat(result).extracting(BookingStatus::turn).containsExactly(1L, null));
+ () -> assertThat(result).extracting(BookingStatus::turn).containsExactly(1L, null),
+ () -> assertThat(result.get(1).payment().orderId()).isEqualTo("payment_ready_123456789012345678901"),
+ () -> assertThat(result.get(1).payment().status()).isEqualTo(PaymentStatus.READY),
+ () -> assertThat(result.get(0).payment()).isNull());
}
@Test
diff --git a/src/test/java/roomescape/service/PaymentServiceTest.java b/src/test/java/roomescape/service/PaymentServiceTest.java
index 3748b8e4a5..d07f91e068 100644
--- a/src/test/java/roomescape/service/PaymentServiceTest.java
+++ b/src/test/java/roomescape/service/PaymentServiceTest.java
@@ -58,7 +58,7 @@ class PaymentServiceTest {
void 실패한_결제_뒤에는_새_결제를_생성한다() {
LocalDateTime now = LocalDateTime.of(2026, 6, 24, 12, 0);
Reservation reservation = pendingReservation(1L, LocalDate.of(2099, 1, 1));
- Payment failedPayment = new Payment(1L, 1L, "payment_failed_12345678901234567890", 20_000L, null,
+ Payment failedPayment = Payment.restore(1L, 1L, "payment_failed_12345678901234567890", 20_000L, null,
PaymentStatus.FAILED, "REJECT_CARD_PAYMENT", "카드가 거절되었습니다.");
when(reservationService.findPendingByUser(1L, "브라운", now)).thenReturn(reservation);
when(paymentRepository.findLatestByReservationId(1L)).thenReturn(Optional.of(failedPayment));
@@ -79,7 +79,7 @@ class PaymentServiceTest {
void 준비된_결제가_있으면_기존_결제를_반환한다() {
LocalDateTime now = LocalDateTime.of(2026, 6, 24, 12, 0);
Reservation reservation = pendingReservation(1L, LocalDate.of(2099, 1, 1));
- Payment readyPayment = new Payment(1L, 1L, "payment_ready_123456789012345678901", 20_000L, null,
+ Payment readyPayment = Payment.restore(1L, 1L, "payment_ready_123456789012345678901", 20_000L, null,
PaymentStatus.READY, null, null);
when(reservationService.findPendingByUser(1L, "브라운", now)).thenReturn(reservation);
when(paymentRepository.findLatestByReservationId(1L)).thenReturn(Optional.of(readyPayment));
@@ -145,6 +145,28 @@ class PaymentServiceTest {
verify(reservationService).confirmPayment(1L);
}
+ @Test
+ void 확인_필요_상태의_결제도_같은_주문으로_승인을_재시도할_수_있다() {
+ Payment payment = Payment.restore(1L, 1L, "payment_check_required_123456789", 20_000L,
+ null, PaymentStatus.CHECK_REQUIRED,
+ "PAYMENT_CONFIRMATION_UNKNOWN", "결제 승인 결과를 확인할 수 없습니다.");
+ PaymentConfirmation confirmation = new PaymentConfirmation("test_payment_key", payment.getOrderId(), 20_000L);
+ PaymentResult result = new PaymentResult("test_payment_key", payment.getOrderId(), PaymentStatus.CONFIRMED, 20_000L);
+ when(paymentRepository.findByOrderId(payment.getOrderId())).thenReturn(Optional.of(payment));
+ when(paymentGateway.confirm(confirmation)).thenReturn(result);
+
+ PaymentResult actual = paymentService.confirm("test_payment_key", payment.getOrderId(), 20_000L);
+
+ assertThat(actual).isEqualTo(result);
+ verify(paymentGateway).confirm(confirmation);
+ ArgumentCaptor paymentCaptor = ArgumentCaptor.forClass(Payment.class);
+ verify(paymentRepository).update(paymentCaptor.capture());
+ assertThat(paymentCaptor.getValue().getStatus()).isEqualTo(PaymentStatus.CONFIRMED);
+ assertThat(paymentCaptor.getValue().getPaymentKey()).isEqualTo("test_payment_key");
+ assertThat(paymentCaptor.getValue().getFailureCode()).isNull();
+ verify(reservationService).confirmPayment(1L);
+ }
+
@Test
void 확정적인_승인_실패는_결제를_실패_상태로_저장한다() {
Payment payment = readyPayment();
@@ -180,6 +202,27 @@ class PaymentServiceTest {
verify(reservationService, never()).confirmPayment(any());
}
+ @Test
+ void 승인_결과를_알_수_없으면_확인_필요_상태로_저장한다() {
+ Payment payment = readyPayment();
+ PaymentConfirmation confirmation = new PaymentConfirmation("test_payment_key", payment.getOrderId(), 20_000L);
+ PaymentGatewayException exception = new PaymentGatewayException(
+ PaymentFailureCategory.CONFIRMATION_UNKNOWN,
+ "PAYMENT_CONFIRMATION_UNKNOWN",
+ "결제 승인 결과를 확인할 수 없습니다.");
+ when(paymentRepository.findByOrderId(payment.getOrderId())).thenReturn(Optional.of(payment));
+ when(paymentGateway.confirm(confirmation)).thenThrow(exception);
+
+ assertThatThrownBy(() -> paymentService.confirm("test_payment_key", payment.getOrderId(), 20_000L))
+ .isSameAs(exception);
+
+ ArgumentCaptor paymentCaptor = ArgumentCaptor.forClass(Payment.class);
+ verify(paymentRepository).update(paymentCaptor.capture());
+ assertThat(paymentCaptor.getValue().getStatus()).isEqualTo(PaymentStatus.CHECK_REQUIRED);
+ assertThat(paymentCaptor.getValue().getFailureCode()).isEqualTo("PAYMENT_CONFIRMATION_UNKNOWN");
+ verify(reservationService, never()).confirmPayment(any());
+ }
+
@Test
void 결제_실패를_저장하고_결제_대기_예약은_유지한다() {
Payment payment = readyPayment();
@@ -214,7 +257,7 @@ private Reservation pendingReservation(Long id, LocalDate date) {
}
private Payment readyPayment() {
- return new Payment(1L, 1L, "payment_ready_123456789012345678901", 20_000L, null,
+ return Payment.restore(1L, 1L, "payment_ready_123456789012345678901", 20_000L, null,
PaymentStatus.READY, null, null);
}
}
diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties
index c0efa9b762..4445307ae6 100644
--- a/src/test/resources/application.properties
+++ b/src/test/resources/application.properties
@@ -1,2 +1,4 @@
spring.datasource.url=jdbc:h2:mem:database;NON_KEYWORDS=DATE
spring.sql.init.data-locations=classpath:test-data.sql
+toss.connect-timeout-ms=1000
+toss.read-timeout-ms=2000
| |