@Bean
RestClient pgClient() {
var factory = new JdkClientHttpRequestFactory(HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2)).build());
factory.setReadTimeout(Duration.ofSeconds(3));
return RestClient.builder().baseUrl("https://pg.example.com")
.requestFactory(factory).build();
}
@Service
class PgService {
@Retry(name = "pg")
@CircuitBreaker(name = "pg", fallbackMethod = "payFallback")
String pay(String key, String body) {
return pgClient().post().uri("/pay")
.header("Idempotency-Key", key).body(body)
.retrieve().body(String.class);
}
String payFallback(String key, String body, Exception e) {
return "{\"status\":\"PENDING\"}"; // 미확인 상태로 응답, 4.2 절 대사 대상
}
}resilience4j:
retry:
instances.pg: { max-attempts: 3, wait-duration: 200ms, retry-exceptions: [java.io.IOException] }
circuitbreaker:
instances.pg: { sliding-window-size: 10, failure-rate-threshold: 50, wait-duration-in-open-state: 10s }애노테이션 순서가 실행 순서입니다. @Retry 가 @CircuitBreaker 를 감싸므로, 재시도 3회가 모두 실패해야 서킷의 실패 횟수가 1 올라갑니다. 순서를 반대로 두면 서킷이 재시도 한 번마다 실패를 세어 지나치게 빨리 OPEN 됩니다.
타임아웃 난 결제는 성공도 실패도 아닌 UNKNOWN 으로 저장하고, 스케줄러가 나중에 상대 조회 API 로 확정합니다.
CREATE TABLE payment (
id BIGINT PRIMARY KEY,
idempotency_key VARCHAR(64) NOT NULL UNIQUE,
status VARCHAR(10) NOT NULL, -- PENDING, UNKNOWN, PAID, FAILED
amount BIGINT NOT NULL
);try {
String res = pgClient.post(url, body, key);
mapper.updateStatus(key, "PAID");
} catch (ApiClient.ApiException e) {
mapper.updateStatus(key, "UNKNOWN"); // 처리 여부를 모름, 재시도 금지
}
// @Scheduled(fixedDelay = 60_000), 09 레슨 SafeTask 로 감싼다
for (String key : mapper.findUnknownKeys()) {
String status = pgClient.get("/pay/status/" + key); // 상대의 조회 API 로 확정
mapper.updateStatus(key, status);
}UNKNOWN 상태로 남은 결제는 사람이 개입하기 전까지 자동으로 재시도하면 안 됩니다. 조회 API 로 확정되기 전에는 성공인지 실패인지 우리도 상대도 알 수 없는 상태이기 때문입니다.
세마포어는 동시 개수를, 토큰 버킷은 시간당 개수를 제한합니다. 분당 100건을 예로 듭니다.
class TokenBucket {
private final long capacity = 100, refillMillis = 60_000 / 100; // 0.6초마다 1개
private final AtomicLong tokens = new AtomicLong(capacity);
private final AtomicLong lastRefill = new AtomicLong(System.currentTimeMillis());
boolean tryAcquire() {
long now = System.currentTimeMillis();
long elapsed = now - lastRefill.get();
long refill = elapsed / refillMillis;
if (refill > 0 && lastRefill.compareAndSet(lastRefill.get(), now)) {
tokens.set(Math.min(capacity, tokens.get() + refill));
}
return tokens.get() > 0 && tokens.decrementAndGet() >= 0;
}
}세마포어는 "지금 몇 개가 동시에 진행 중인가" 만 봅니다. 토큰 버킷은 "1분 동안 몇 개를 보냈는가" 를 보므로, 순간적으로 몰아 보내는 버스트를 막는 데 더 맞습니다.
상대 응답의 필드가 누락되거나 타입이 바뀌면, 조용히 null 로 흘려보내지 말고 즉시 예외로 막아야 합니다. 07 레슨의 MiniJson 을 그대로 씁니다.
Map<String, Object> json = MiniJson.parse(body);
Object paymentId = json.get("paymentId");
if (paymentId == null) {
throw new ApiClient.ApiException(0, "계약 위반: paymentId 누락");
}
if (!(paymentId instanceof String)) {
throw new ApiClient.ApiException(0, "계약 위반: paymentId 타입 변경 (" + paymentId.getClass() + ")");
}계약 위반을 여기서 잡지 않으면, null 이 결제 서비스 안쪽까지 흘러가 훨씬 찾기 어려운 오류로 나타납니다. 상대 API 가 문서와 다르게 응답을 바꾼 사고는 이 검증이 있어야 배포 당일에 바로 드러납니다.
이 검증은 ApiException(0, ...) 을 던지므로 재시도 대상이 아니라 즉시 실패로 처리됩니다. 상대가 응답 형식을 바꾼 것은 재시도로 고쳐지는 문제가 아니라 사람이 확인해야 하는 문제이기 때문입니다.