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