Skip to content
Merged
14 changes: 14 additions & 0 deletions src/main/java/roomescape/config/WebConfig.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,26 @@
package roomescape.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import roomescape.ratelimit.RateLimitInterceptor;

@Configuration
public class WebConfig implements WebMvcConfigurer {

private final RateLimitInterceptor rateLimitInterceptor;

public WebConfig(RateLimitInterceptor rateLimitInterceptor) {
this.rateLimitInterceptor = rateLimitInterceptor;
}

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(rateLimitInterceptor)
.addPathPatterns("/reservations", "/payments/**");
}
Comment on lines +19 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

μ˜ˆμ•½ μ—”λ“œν¬μΈνŠΈμ— ν•œλ„λ₯Ό μ μš©ν•˜μ‹€ λ•Œ μ •ν™• 경둜λ₯Ό μ‚¬μš©ν•˜μ‹  것 κ°™μŠ΅λ‹ˆλ‹€.
ν˜Ήμ‹œ μ •ν™•ν•œ '/reservation'μ˜ˆμ•½κ³Ό κ΄€λ ¨ν•œ 경둜만 μ²˜λ¦¬ν•˜μ‹œλ €λŠ” μ˜λ„μ…¨λŠ”μ§€ κΆκΈˆν•©λ‹ˆλ‹€


@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addViewController("/").setViewName("index");
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/roomescape/exception/GlobalExceptionHandler.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
private static final String DETAIL_INTERNAL_ERROR = "μš”μ²­μ„ μ²˜λ¦¬ν•˜λŠ” 쀑 μ•Œ 수 μ—†λŠ” 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.";
private static final String DETAIL_PAYMENT_FAILED = "결제 처리 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”.";
private static final String DETAIL_PAYMENT_UNCERTAIN = "결제 승인 κ²°κ³Όλ₯Ό ν™•μΈν•˜μ§€ λͺ»ν–ˆμŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ μ£Όλ¬Έ λ‚΄μ—­μ—μ„œ 결제 μƒνƒœλ₯Ό ν™•μΈν•΄μ£Όμ„Έμš”.";
private static final String DETAIL_OUTBOUND_RATE_LIMITED = "μ™ΈλΆ€ 결제 API 호좜 ν•œλ„λ₯Ό μ΄ˆκ³Όν–ˆμŠ΅λ‹ˆλ‹€. μž μ‹œ ν›„ λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”.";
private static final String ERRORS_PROPERTY = "errors";
private static final String POINTER_PREFIX = "/";

Expand Down Expand Up @@ -81,6 +82,16 @@ public ProblemDetail handleTransient(TransientDataAccessException ex, WebRequest
return buildProblem(HttpStatus.CONFLICT, ProblemType.CONCURRENCY_CONFLICT, DETAIL_TRANSIENT, Level.WARN, ex, request);
}

@ExceptionHandler(OutboundRateLimitException.class)
public ResponseEntity<ProblemDetail> handleOutboundRateLimit(OutboundRateLimitException ex, WebRequest request) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.SERVICE_UNAVAILABLE, DETAIL_OUTBOUND_RATE_LIMITED);
applyType(problem, ProblemType.OUTBOUND_RATE_LIMITED, request);
logException(Level.WARN, ex, HttpStatus.SERVICE_UNAVAILABLE, request);
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.header(HttpHeaders.RETRY_AFTER, String.valueOf(ex.retryAfterSeconds()))
.body(problem);
}

@ExceptionHandler(Exception.class)
public ProblemDetail handleUnexpected(Exception ex, WebRequest request) {
return buildProblem(HttpStatus.INTERNAL_SERVER_ERROR, ProblemType.INTERNAL_ERROR, DETAIL_INTERNAL_ERROR, Level.ERROR, ex, request);
Expand Down
15 changes: 15 additions & 0 deletions src/main/java/roomescape/exception/OutboundRateLimitException.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package roomescape.exception;

public class OutboundRateLimitException extends RoomescapeException {

private final long retryAfterSeconds;

public OutboundRateLimitException(long retryAfterSeconds) {
super("μ™ΈλΆ€ 결제 API 호좜 ν•œλ„λ₯Ό μ΄ˆκ³Όν–ˆμŠ΅λ‹ˆλ‹€. retryAfterSeconds=" + retryAfterSeconds);
this.retryAfterSeconds = retryAfterSeconds;
}

public long retryAfterSeconds() {
return retryAfterSeconds;
}
}
1 change: 1 addition & 0 deletions src/main/java/roomescape/exception/ProblemType.java
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public enum ProblemType {
PAYMENT_AMOUNT_MISMATCH("payment-amount-mismatch", "결제 κΈˆμ•‘ 뢈일치"),
PAYMENT_FAILED("payment-failed", "결제 μ‹€νŒ¨"),
PAYMENT_UNCERTAIN("payment-uncertain", "결제 κ²°κ³Ό 확인 ν•„μš”"),
OUTBOUND_RATE_LIMITED("outbound-rate-limited", "μ™ΈλΆ€ 호좜 ν•œλ„ 초과"),
INTERNAL_ERROR("internal-error", "μ„œλ²„ λ‚΄λΆ€ 였λ₯˜");

private static final String TYPE_BASE = "https://roomescape.example/problems/";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package roomescape.payment.toss;

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.exception.OutboundRateLimitException;
import roomescape.ratelimit.TokenBucketRateLimiter;

import java.io.IOException;

public class OutboundRateLimitInterceptor implements ClientHttpRequestInterceptor {

private final TokenBucketRateLimiter rateLimiter;

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

@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
throws IOException {
if (!rateLimiter.tryConsume()) {
throw new OutboundRateLimitException(rateLimiter.retryAfterSeconds());
}
return execution.execute(request, body);
}
}
58 changes: 58 additions & 0 deletions src/main/java/roomescape/payment/toss/RetryAfterInterceptor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package roomescape.payment.toss;

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;

import java.io.IOException;
import java.time.Duration;
import java.util.Optional;

public class RetryAfterInterceptor implements ClientHttpRequestInterceptor {

private final int maxAttempts;
private final Duration defaultBackoff;
private final Sleeper sleeper;

public RetryAfterInterceptor(int maxAttempts, Duration defaultBackoff, Sleeper sleeper) {
if (maxAttempts < 1) {
throw new IllegalArgumentException("maxAttemptsλŠ” 1 이상이어야 ν•©λ‹ˆλ‹€. maxAttempts=" + maxAttempts);
}
this.maxAttempts = maxAttempts;
this.defaultBackoff = defaultBackoff;
this.sleeper = sleeper;
}

@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body, ClientHttpRequestExecution execution)
throws IOException {
for (int attempt = 1; ; attempt++) {
ClientHttpResponse response = execution.execute(request, body);
if (response.getStatusCode().value() != HttpStatus.TOO_MANY_REQUESTS.value()) {
return response;
}
if (attempt >= maxAttempts) {
response.close();
throw new TossRateLimitException("ν† μŠ€ 호좜이 μš”μ²­ ν•œλ„(429)λ₯Ό μ΄ˆκ³Όν–ˆμŠ΅λ‹ˆλ‹€. attempts=" + attempt);
}
Duration backoff = retryAfter(response).orElse(defaultBackoff);
response.close();
sleeper.sleep(backoff);
}
}

private Optional<Duration> retryAfter(ClientHttpResponse response) throws IOException {
String value = response.getHeaders().getFirst(HttpHeaders.RETRY_AFTER);
if (value == null || value.isBlank()) {
return Optional.empty();
}
try {
return Optional.of(Duration.ofSeconds(Long.parseLong(value.trim())));
} catch (NumberFormatException e) {
return Optional.empty();
}
}
}
9 changes: 9 additions & 0 deletions src/main/java/roomescape/payment/toss/Sleeper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package roomescape.payment.toss;

import java.time.Duration;

@FunctionalInterface
public interface Sleeper {

void sleep(Duration duration);
}
20 changes: 20 additions & 0 deletions src/main/java/roomescape/payment/toss/ThreadSleeper.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package roomescape.payment.toss;

import java.time.Duration;

public class ThreadSleeper implements Sleeper {

@Override
public void sleep(Duration duration) {
long millis = duration.toMillis();
if (millis <= 0) {
return;
}
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("μž¬μ‹œλ„ λŒ€κΈ° 쀑 μΈν„°λŸ½νŠΈλ˜μ—ˆμŠ΅λ‹ˆλ‹€.", e);
}
}
}
29 changes: 28 additions & 1 deletion src/main/java/roomescape/payment/toss/TossClientConfig.java
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
package roomescape.payment.toss;

import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
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;

import java.nio.charset.StandardCharsets;
import java.time.Duration;
Expand All @@ -14,12 +16,35 @@
@Configuration
public class TossClientConfig {

@Bean
public Sleeper tossBackoffSleeper() {
return new ThreadSleeper();
}

@Bean
public RetryAfterInterceptor tossRetryAfterInterceptor(
@Value("${toss.retry.max-attempts}") int maxAttempts,
@Value("${toss.retry.default-backoff}") Duration defaultBackoff,
Sleeper tossBackoffSleeper
) {
return new RetryAfterInterceptor(maxAttempts, defaultBackoff, tossBackoffSleeper);
}

@Bean
public OutboundRateLimitInterceptor tossOutboundRateLimitInterceptor(
@Qualifier("outboundRateLimiter") TokenBucketRateLimiter outboundRateLimiter
) {
return new OutboundRateLimitInterceptor(outboundRateLimiter);
}

@Bean
public RestClient tossRestClient(
@Value("${toss.base-url}") String baseUrl,
@Value("${toss.secret-key}") String secretKey,
@Value("${toss.connect-timeout}") Duration connectTimeout,
@Value("${toss.read-timeout}") Duration readTimeout
@Value("${toss.read-timeout}") Duration readTimeout,
RetryAfterInterceptor tossRetryAfterInterceptor,
OutboundRateLimitInterceptor tossOutboundRateLimitInterceptor
) {
String basic = Base64.getEncoder()
.encodeToString((secretKey + ":").getBytes(StandardCharsets.UTF_8));
Expand All @@ -32,6 +57,8 @@ public RestClient tossRestClient(
.baseUrl(baseUrl)
.requestFactory(requestFactory)
.defaultHeader(HttpHeaders.AUTHORIZATION, "Basic " + basic)
.requestInterceptor(tossRetryAfterInterceptor)
.requestInterceptor(tossOutboundRateLimitInterceptor)
Comment on lines +60 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

ν˜„μž¬ 인터셉터λ₯Ό Retry -> Outbound 순으둜 λ“±λ‘ν•˜μ‹  점에 λŒ€ν•΄μ„œ μˆœμ„œμ— λŒ€ν•œ 선택 지점이 μžˆμœΌμ…¨λŠ”μ§€ κΆκΈˆν•©λ‹ˆλ‹€!

.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package roomescape.payment.toss;

public class TossRateLimitException extends TossPaymentException {

public TossRateLimitException(String message) {
super(message);
}
}
25 changes: 25 additions & 0 deletions src/main/java/roomescape/ratelimit/RateLimitConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package roomescape.ratelimit;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RateLimitConfig {

@Bean
public TokenBucketRateLimiter inboundRateLimiter(
@Value("${rate-limit.capacity}") long capacity,
@Value("${rate-limit.refill-per-sec}") double refillPerSec
) {
return new TokenBucketRateLimiter(capacity, refillPerSec, System::nanoTime);
}

@Bean
public TokenBucketRateLimiter outboundRateLimiter(
@Value("${outbound-rate-limit.capacity}") long capacity,
@Value("${outbound-rate-limit.refill-per-sec}") double refillPerSec
) {
return new TokenBucketRateLimiter(capacity, refillPerSec, System::nanoTime);
}
}
29 changes: 29 additions & 0 deletions src/main/java/roomescape/ratelimit/RateLimitInterceptor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package roomescape.ratelimit;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
public class RateLimitInterceptor implements HandlerInterceptor {

private final TokenBucketRateLimiter rateLimiter;

public RateLimitInterceptor(@Qualifier("inboundRateLimiter") TokenBucketRateLimiter inboundRateLimiter) {
this.rateLimiter = inboundRateLimiter;
}

@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (rateLimiter.tryConsume()) {
return true;
}
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setHeader(HttpHeaders.RETRY_AFTER, String.valueOf(rateLimiter.retryAfterSeconds()));
return false;
}
}
58 changes: 58 additions & 0 deletions src/main/java/roomescape/ratelimit/TokenBucketRateLimiter.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package roomescape.ratelimit;

import java.util.function.LongSupplier;

public class TokenBucketRateLimiter {

private static final double NANOS_PER_SECOND = 1_000_000_000.0;

private final long capacity;
private final double refillPerSec;
private final LongSupplier nanoTimeSupplier;

private double availableTokens;
private long lastRefillNanos;

public TokenBucketRateLimiter(long capacity, double refillPerSec, LongSupplier nanoTimeSupplier) {
if (capacity < 1) {
throw new IllegalArgumentException("capacityλŠ” 1 이상이어야 ν•©λ‹ˆλ‹€. capacity=" + capacity);
}
if (refillPerSec <= 0) {
throw new IllegalArgumentException("refillPerSecλŠ” 0보닀 컀야 ν•©λ‹ˆλ‹€. refillPerSec=" + refillPerSec);
}
this.capacity = capacity;
this.refillPerSec = refillPerSec;
this.nanoTimeSupplier = nanoTimeSupplier;
this.availableTokens = capacity;
this.lastRefillNanos = nanoTimeSupplier.getAsLong();
}

public synchronized boolean tryConsume() {
refill();
if (availableTokens >= 1.0) {
availableTokens -= 1.0;
return true;
}
return false;
}

public synchronized long retryAfterSeconds() {
refill();
if (availableTokens >= 1.0) {
return 0L;
}
double shortage = 1.0 - availableTokens;
return (long) Math.ceil(shortage / refillPerSec);
}

private void refill() {
long now = nanoTimeSupplier.getAsLong();
long elapsedNanos = now - lastRefillNanos;
if (elapsedNanos <= 0) {
return;
}
double refilled = (elapsedNanos / NANOS_PER_SECOND) * refillPerSec;
availableTokens = Math.min(capacity, availableTokens + refilled);
lastRefillNanos = now;
}
}
9 changes: 9 additions & 0 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,12 @@ toss.base-url=https://api.tosspayments.com
toss.secret-key=test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6
toss.connect-timeout=2s
toss.read-timeout=5s

rate-limit.capacity=100
rate-limit.refill-per-sec=50

toss.retry.max-attempts=3
toss.retry.default-backoff=1s

outbound-rate-limit.capacity=50
outbound-rate-limit.refill-per-sec=25
Loading