외부 API를 안정적으로 연동하기 (2)

SpringResilience4jPaymentResilienceConsistencyBackend

1부에서는 PG가 어떤 형태로든 응답을 돌려주는 경우를 다뤘습니다. 카드 거절을 서버 장애와 구분하고, 멱등키로 중복 요청을 걸러내고, 외부 호출을 트랜잭션 경계 밖으로 빼내 느린 응답이 DB 커넥션을 붙잡지 않게 했습니다.

그렇다면 PG가 정상 응답을 돌려주지 못할 때는 어떻게 해야 할까요? 연결이 거부되고, 5xx가 나고, 응답이 오다가 끊기고, 때로는 요청을 보내고도 아무 답을 받지 못합니다.

응답이 오지 않는다 - 타임아웃과 UNKNOWN

요청을 보냈는데 타임아웃으로 응답을 받지 못했습니다. 그럼 타임아웃이 발생했으니 실패라고 보면 될까요? 타임아웃이 알려 주는 것은 "실패했다"가 아니라 결과를 모른다는 사실 하나뿐입니다. 성공도 실패도 아닌 제3의 상태입니다. 실제로 일어난 일은 둘 중 하나입니다.

  1. 요청이 PG에 닿기 전에 끊겼다 → 결제는 일어나지 않았다.
  2. PG가 처리를 마쳤는데 응답만 못 받았다 → 결제는 이미 일어났다.

두 경우 모두 똑같은 read timeout으로 관측되어, 이 예외 하나만으로는 그 뒤에서 결제가 이뤄졌는지 아닌지 구분할 수 없습니다.

Loading diagram…

이것은 분산 시스템의 고전인 두 장군 문제(Two Generals Problem)입니다. 신뢰할 수 없는 채널 위에서는 상대가 메시지를 받았다는 응답(ack)조차 유실될 수 있어서, "전달되지 않음"과 "전달됐지만 응답만 유실됨"을 원리상 구분할 방법이 없습니다. 그래서 타임아웃은 추측으로 메우는 대신, 나중에 PG에 처리 여부를 직접 되물어야 합니다.

타임아웃을 실패로 단정하면
  1. 실제로는 PG가 승인했는데 우리는 실패로 기록 → 고객은 돈을 냈는데 주문은 실패(정합성 붕괴).
  2. "실패했으니 재시도" → PG가 멱등을 보장하지 않는다면, 이미 처리된 요청을 다시 보내 이중과금.

이런 이유로 타임아웃은 FAILED가 아니라 별도의 UNKNOWN 상태로 다룹니다. UNKNOWN으로 남겨두면 (a) 함부로 실패로 확정하지 않고, (b) 함부로 재시도하지 않으며, (c) 나중에 PG에 되물어 진실을 확정할 여지를 남깁니다.

타임아웃을 UNKNOWN으로 다루려면, 타임아웃이 먼저 제대로 발생해야 합니다. connect timeout과 read timeout을 분리합니다.

@Configuration
public class RestClientConfig {

    @Bean
    RestClient pgRestClient() {
        var factory = new SimpleClientHttpRequestFactory();
        factory.setConnectTimeout(Duration.ofSeconds(1));
        factory.setReadTimeout(Duration.ofSeconds(2));
        return RestClient.builder().requestFactory(factory).build();
    }
}
  • connect timeout은 짧게. 연결 자체가 안 되면 오래 붙잡을 이유가 없습니다.
  • read timeout은 의존성의 p99보다 약간 위로. 정상적으로 느린 응답까지는 기다려 주되, 그 이상은 포기합니다.
  • 계층별로 안쪽(HTTP 클라이언트)이 바깥(요청 처리)보다 먼저 끊겨야, 바깥이 타임아웃 상황을 인지하고 처리할 수 있습니다.

타임아웃 예외를 번역할 때는 요청이 연결조차 못 한 채 끊겼는지(연결 거부, connect timeout), 아니면 이미 전송된 뒤 응답만 못 받았는지(read timeout)를 파악해야 합니다. 전자는 PG에 닿지 않았으니 다시 보내도 안전하지만, 후자는 처리됐을 가능성이 남아 UNKNOWN으로 보내야 합니다. 예외 타입만으로는 이 둘이 구분되지 않아, root cause의 메시지를 확인해 번역합니다.

try {
    // ... 위의 charge 로직 ...
} catch (PgException e) {
    throw e;                        // 이미 분류한 예외는 그대로
} catch (RestClientException e) {
    Throwable root = NestedExceptionUtils.getMostSpecificCause(e);
    if (root instanceof SocketTimeoutException
            && root.getMessage() != null
            && root.getMessage().contains("Read timed out")) {
        throw new PgTimeoutException(e);       // 실패가 아니라 "모름"
    }
    throw new PgUnavailableException(e);        // 연결 거부/리셋 등, 재시도 가능
}

다만 이런 메시지 문자열 매칭은 HTTP 클라이언트 구현에 묶여 있어서, 클라이언트를 바꾸면 이 번역 로직도 그에 맞게 고쳐야 합니다.

서비스는 이 예외를 잡아 UNKNOWN으로 보류합니다. 5xx로 터뜨리는 대신, 결제가 접수됐고 결과는 아직 확정되지 않았다는 의미의 200 OK를 돌려줍니다.

    try {
        PgResult result = pgClient.charge(request.orderId(), request.amount());
        persistence.markPaid(payment.getId(), result.pgRef());
        return PaymentResponse.paid(payment.getId());
    } catch (PgTimeoutException e) {
        log.warn("[pay] UNKNOWN(timeout) key={} paymentId={}", idempotencyKey, payment.getId());
        persistence.markUnknown(payment.getId());
        return PaymentResponse.unknown(payment.getId());
    } catch (CardDeclinedException e) {
        // ... 이하 거절/400 분기 동일 ...
    }

상태 기계에 결과를 알 수 없는 상태가 추가됩니다. 이 상태를 어떻게 처리하는지는 뒤의 정합성 복구에서 다룹니다.

순단은 흡수한다 - 재시도·백오프·지터

우리가 호출하는 외부 API는 대부분의 시간 동안 잘 동작하고, 몇 시간씩 이어지는 중단도 드물 겁니다. 다만 중단이 아예 없지는 않습니다. API 제공자가 배포를 진행하는 사이에 들어간 요청이 연결 거부로 튕기거나, 순간 부하로 5xx가 나는 등 순단(transient failure)이 아주 잠깐씩 발생하기 마련입니다. 이는 잠시 뒤에 다시 시도하면 대개 성공합니다.

그리고 이런 순단은 완전히 없앨 수 있는 게 아닙니다. 우리와 API 제공자 사이에는 어느 쪽 소유도 아닌 네트워크 구간이 있고, keep-alive 커넥션이 재사용 직전에 닫히는 레이스 같은 것들은 예방이 어렵습니다.

HTTP 스펙(RFC 9110)부터가 통신이 실패하면 멱등 요청을 자동으로 재시도할 수 있다고 명시하기 때문에, 순단은 전제하고 설계해야 합니다. 재시도는 순단을 흡수해 사용자에게 실패가 보이지 않게 합니다. 앞 절의 코드에서 연결 거부·리셋에 던진 PgUnavailableException이 재시도 대상입니다.

방어 로직은 서비스에 직접 넣지 않고, PgClient를 감싸는 게이트웨이(PgGateway)에 응집시킵니다. 서비스는 이제 PgClient 대신 이 게이트웨이를 주입받을 뿐, 방어의 존재를 모릅니다.

@Component
@RequiredArgsConstructor
public class ResilientPgGateway implements PgGateway {

    private final PgClient pgClient;

    @Override
    @Retry(name = "pg")
    public PgResult charge(PayCommand cmd) {
        return pgClient.charge(cmd);
    }
}

재시도는 같은 요청을 PG로 다시 보내는 것이라, 재시도가 안전하려면 PG가 그 중복을 걸러 줘야 합니다. 그래서 이 시점부터 charge는 멱등키를 함께 받아(PayCommand), PG 요청의 Idempotency-Key 헤더로 실어 보냅니다. 앞서 클라이언트 중복을 막던 그 키를, 이번엔 우리 재시도의 중복을 PG가 막는 데 쓰는 것입니다.

pgRestClient.post()
    .uri("/charge")
    .header("Idempotency-Key", cmd.idempotencyKey())   // 우리 재시도를 PG가 중복 제거
    .body(new ChargeRequest(cmd.orderId(), cmd.amount()))
    // ... 이하 상태 코드 번역은 앞과 동일 ...

재시도는 자칫 장애를 증폭시킬 수 있으므로, 다음과 같은 규칙을 함께 두는 것이 안전합니다.

  • backoff: 재시도 간격을 점점 늘려 회복 중인 상대를 몰아치지 않습니다. 대표적인 방식이 지수 백오프(exponential backoff)로, 간격을 100ms → 200ms → 400ms로 키웁니다.
  • jitter: 지터가 없으면 대기 중이던 요청들이 똑같은 간격으로 동시에 재시도해, 부하가 한순간에 다시 몰립니다(retry storm / thundering herd). 그래서 계산된 간격에 무작위 값을 더해 재시도 시점을 분산시킵니다.
  • 무엇을 재시도할지 제한: 아무 예외나 재시도하면 안 됩니다. 거절·400은 PG가 명확히 실패로 응답한 것이라 다시 보내도 결과가 같고, 타임아웃은 성공·실패를 알 수 없는 UNKNOWN이라 재시도하지 않고 뒤의 정합성 복구에서 처리합니다. 남는 재시도 대상은 순단(PgUnavailableException)뿐입니다.

이 규칙들을 설정으로 표현합니다.

resilience4j:
  retry:
    instances:
      pg:
        max-attempts: 3
        wait-duration: 100ms
        enable-exponential-backoff: true
        exponential-backoff-multiplier: 2
        enable-randomized-wait: true              # 지터 활성화
        randomized-wait-factor: 0.5               # 최대 ±50% 무작위(retry storm 방지)
        retry-exceptions:
          - com.example.payment.exception.PgUnavailableException   # 재시도 가능만
        ignore-exceptions:
          - com.example.payment.exception.PgTimeoutException        # 타임아웃은 UNKNOWN → 재시도 대신 정합성 복구
          - com.example.payment.exception.CardDeclinedException     # 거절은 다시 보내도 거절
          - com.example.payment.exception.PgBadRequestException     # 잘못된 요청은 고쳐야

한 가지 덧붙이면, max-attempts: 3 같은 고정 횟수는 API 제공자가 완전히 죽은 순간엔 오히려 독이 됩니다. 모든 요청이 3번씩 시도해 죽어 가는 상대에게 불필요한 부하를 더하기 때문입니다. 그래서 대규모에서는 재시도량을 전체 트래픽의 일정 비율로 제한하는 재시도 예산(retry budget)을 함께 두어, 실패율이 높아지면 재시도가 자동으로 줄어들게 합니다.

재시도가 안전한지는 '그 시도가 끝났는가'에 달려 있다

5xx는 PG가 응답을 돌려준 것이라, 과금이 됐든 아니든 그 요청은 더 이상 처리 중이 아닙니다. 그래서 같은 멱등키로 다시 보내면 PG가 중복을 걸러 주고 확정된 응답도 다시 받을 수 있어, 재시도가 비교적 안전한 편입니다. 타임아웃도 멱등키가 있으면 재시도해 이전 결과를 돌려받는 방식이 있지만(Stripe가 이렇게 권장합니다), 응답이 없는 동안 기존 요청이 아직 처리 중일 수 있고 이 in-flight 중복까지 PG가 안전하게 다뤄 준다는 보장은 PG마다 다릅니다. 그래서 이 글은 보수적으로, 타임아웃을 재시도하지 않고 UNKNOWN으로 남긴 뒤 정합성 복구에서 상태를 되물어(read-only로 안전하게) 확정합니다.

지속 장애는 끊어 낸다 - 서킷 브레이커

재시도는 일시적 실패를 메우는 장치입니다. 그렇다면 API 제공자가 장애나 점검으로 장시간 내려가 있으면 어떻게 될까요? 그동안에도 요청은 계속 들어오고, 그 하나하나가 내려간 상대를 호출해 타임아웃이 날 때까지 기다립니다(재시도가 있다면 호출은 몇 배로 증가). 이렇게 계속 두드리면 요청 스레드가 낭비되고, 회복 중인 상대에게 부하만 더 얹습니다. 이럴 땐 호출 자체를 잠시 멈춰야 합니다.

이를 서킷 브레이커(circuit breaker) 패턴으로 구현할 수 있습니다. 나가는 호출을 끊어 우리 자원을 지키고, 상대에게도 회복할 틈을 줍니다.

서킷은 세 상태를 가집니다. 평소에는 닫힌 채(CLOSED) 호출을 그대로 통과시키며 실패율을 기록합니다. 이때 실패율은 앱 기동 이후의 전체 누적이 아니라, 최근 호출만 담는 슬라이딩 윈도우(최근 N개 또는 최근 N초) 위에서 계산합니다. 실패율이 임계를 넘으면 열려서(OPEN) 한동안 모든 호출을 fail-fast로 중단합니다. 일정 대기 시간이 지나면 반쯤 열어(HALF_OPEN) 소수의 프로브 호출(실제 요청)만 흘려보내고, 성공하면 닫고(CLOSED), 실패하면 그대로 다시 엽니다(OPEN).

Loading diagram…

게이트웨이에 서킷을 한 겹 더 얹습니다.

@Component
@RequiredArgsConstructor
public class ResilientPgGateway implements PgGateway {
    // ...
    @Retry(name = "pg")
    @CircuitBreaker(name = "pg")
    public PgResult charge(PayCommand cmd) {
        return pgClient.charge(cmd);
    }
}
resilience4j:
  circuitbreaker:
    instances:
      pg:
        sliding-window-type: COUNT_BASED
        sliding-window-size: 10
        minimum-number-of-calls: 5              # 최소 표본(초반 우연으로 열리지 않게)
        failure-rate-threshold: 50              # 실패율 50% 넘으면 OPEN
        slow-call-duration-threshold: 1500ms    # read timeout(2s)보다 낮게: 타임아웃 전에 '느림'을 잡는다
        slow-call-rate-threshold: 80            # 느린 호출 비율이 80% 넘어도 OPEN
        wait-duration-in-open-state: 2s         # OPEN 유지 후 HALF_OPEN 프로브
        permitted-number-of-calls-in-half-open-state: 2
        record-exceptions:                      # 무엇을 "실패"로 셀지
          - com.example.payment.exception.PgTimeoutException
          - com.example.payment.exception.PgUnavailableException
        ignore-exceptions:                      # 비즈니스 실패·400은 집계 제외
          - com.example.payment.exception.CardDeclinedException
          - com.example.payment.exception.PgBadRequestException

record-exceptions에는 타임아웃과 불가용(unavailable)만 들어갑니다. 타임아웃은 재시도 목록에서는 뺐지만 서킷 집계에는 넣는데, 다시 보내는 것은 위험해도 PG에 문제가 있다는 신호로는 유효하기 때문입니다. 반대로 카드 거절과 400은 ignore-exceptions로 집계에서 제외합니다. 카드 거절은 정상적인 동작이며, 거절이 몰린다고 해서 서킷이 열려 버리면 멀쩡한 PG로 가는 정상 결제까지 전부 중단되기 때문입니다.

데코레이터 순서: Retry(바깥) → CircuitBreaker(안)

Resilience4j의 기본 aspect 순서는 재시도가 서킷을 감싸는 구조입니다(Retry가 바깥, CircuitBreaker가 안). 따라서 재시도 한 번 한 번이 서킷 윈도우에 개별 호출로 집계되어, 한 요청이 여러 번 실패하면 서킷도 그만큼 빠르게 찹니다. 안쪽의 서킷이 OPEN 상태가 되면, 실제 호출 없이 CallNotPermittedException으로 즉시 실패합니다. 이 예외는 폴백(다음 절)으로 처리되므로, 폴백이 무엇을 던지느냐에 따라 바깥 재시도의 실행 여부가 갈립니다(재시도 대상이 아닌 예외를 던지도록 폴백을 구성하면 재시도는 실행되지 않습니다).

실패율만 보면, 서킷은 "PG가 죽지는 않았지만 느려진" 상태를 놓치게 됩니다. 이런 경우도 정상적이지 않은 상황이거나 실패의 전조일 수 있으므로, slow-call-rate-threshold로 잡아 줍니다. 설정한 시간(slow-call-duration-threshold)을 넘긴 호출은 성공했더라도 "느린 호출"로 세고, 그 비율이 임계를 넘으면 서킷을 엽니다. 이때 임계는 read timeout보다 낮아야 의미가 있습니다. read timeout과 같거나 크게 잡으면 그 시간을 넘긴 호출은 이미 타임아웃으로 실패 처리돼, 정작 "느리지만 성공한" 호출을 한 건도 못 세기 때문입니다.

서킷 구성 시 참고사항
  • 표본이 안 차면 열리지 않는다: 실패율은 minimum-number-of-calls를 채운 뒤에야 계산됩니다. 이 값이 크면(라이브러리 기본값은 100) 트래픽이 적은 엔드포인트에서는 표본이 차지 않아서, 실패율이 100%여도 서킷이 사실상 열리지 않습니다. 저트래픽이면 호출 수 기준(COUNT_BASED) 대신 시간 기준(TIME_BASED, 최근 N초) 윈도우에 작은 최소 표본을 쓰는 편이 맞습니다.
  • 서킷 상태는 파드마다 따로다: 서킷 상태는 JVM 로컬이라, 레플리카가 여럿이면 각 파드가 자기 표본으로 독립적으로 판단합니다. 인스턴스가 많고 각자 트래픽이 적으면 실패율 추정이 부정확해지므로, 파드별 서킷만 믿지 말고 앞서 말한 재시도 예산처럼 비율 기반으로 저트래픽에서도 동작하는 장치를 함께 두는 편이 좋습니다.

막았으면 조용히 넘기지 않는다 - 폴백

서킷이 OPEN이면 내부 코드는 실행되지 않고 CallNotPermittedException이 발생합니다. 그럼 이때 무엇을 해야 할까요? 폴백(fallback)은 주 경로가 막혔을 때 품질을 낮춘 대체 응답(graceful degradation)을 돌려주는 자리입니다. 그리고 결제에서 폴백의 답은 PAIDFAILED도 아닌 UNKNOWN입니다. 서킷 OPEN도 타임아웃과 같은 정합성 복구 경로로 합류시키는 것입니다.

@Slf4j
@Component
@RequiredArgsConstructor
public class ResilientPgGateway implements PgGateway {
    // ...
    @Retry(name = "pg")
    @CircuitBreaker(name = "pg", fallbackMethod = "chargeFallback")   // 폴백 연결
    public PgResult charge(PayCommand cmd) {
        return pgClient.charge(cmd);
    }

    // 예외 타입을 한정했으므로 서킷이 OPEN일 때만 호출된다(요청이 PG에 도달조차 안 함).
    private PgResult chargeFallback(PayCommand cmd, CallNotPermittedException e) {
        log.warn("[FALLBACK] reason=circuit_open orderId={} → UNKNOWN(정합성 복구에 인계)", cmd.orderId());
        throw new PgTimeoutException(e);   // UNKNOWN으로 degrade
    }
}

폴백의 예외 파라미터 타입이 CallNotPermittedException으로 되어 있습니다. @CircuitBreaker에 명시했다고 서킷 OPEN 시에만 도는 건 아닙니다. 폴백은 데코레이트된 호출에서 나온 모든 예외에 반응하고, Resilience4j는 그중 던져진 예외 타입에 맞는 폴백만 부릅니다. 그래서 거절·타임아웃·불가용 같은 기존 처리 경로를 따르는 것들은 폴백 대상이 되지 않도록, 예외 타입을 CallNotPermittedException으로 한정합니다. 그렇게 하지 않으면 FAILED로 남아야 할 것들마저 UNKNOWN으로 처리됩니다.

폴백은 (1) 로그, (2) 메트릭, (3) 정상과 구분 가능한 상태를 남겨야 합니다. 특히 서킷 OPEN은 PG사 장애를 뜻하는 신호이므로, 경중에 따라 별도 알림 등을 설정해 운영자가 알아차릴 수 있도록 합니다.

한 의존성의 장애를 가둔다 - Bulkhead

사실 앞의 서킷만으로도 많은 경우 충분합니다. 문제가 되는 건 트래픽이 큰 환경입니다. 서킷이 정하는 것은 "이 PG를 계속 부를지 말지"일 뿐, PG가 우리 스레드를 한 번에 몇 개까지 붙잡을 수 있는지는 제한하지 않습니다. 그래서 트래픽이 크면, 서킷이 반응하기 전 짧은 사이에도 느려진 PG를 기다리는 호출이 각자 read timeout(2초)까지 스레드를 붙잡아 순식간에 쌓일 수 있습니다. 게다가 결제만 스레드를 쓰는 게 아닙니다. 조회, 알림, 다른 외부 연동이 같은 톰캣 요청 스레드풀을 공유한다면, 이 잠식이 결제와 아무 상관 없는 API까지 함께 멈춰 시스템 전체를 마비시킵니다.

서킷과 달리, Bulkhead(격벽)는 의존성별로 동시에 쓸 수 있는 자원 자체에 상한을 걸어, 한 의존성이 전체 스레드를 독차지하지 못하게 막습니다. 탐지 조건이나 타이밍을 따지기 전에, 한 곳의 장애가 전체를 마비시키는 일을 애초에 구조적으로 차단하는 것입니다. 최악의 경우 PG 관련 요청이 모두 대기한다 하더라도, 조회·알림 같은 나머지 기능은 정상으로 돌아갑니다. 배가 격벽으로 구획을 나눠 한 구획이 침수돼도 가라앉지 않는 것과 같은 발상입니다. 구현 방식은 두 가지입니다.

@Component
@RequiredArgsConstructor
public class ResilientPgGateway implements PgGateway {
    // ...
    @Retry(name = "pg")
    @CircuitBreaker(name = "pg", fallbackMethod = "chargeFallback")
    @Bulkhead(name = "pg", type = Bulkhead.Type.SEMAPHORE)   // PG 호출 동시 실행 수 상한
    public PgResult charge(PayCommand cmd) {
        return pgClient.charge(cmd);
    }
}
resilience4j:
  bulkhead:
    instances:
      pg:
        max-concurrent-calls: 20        # PG로 동시에 나가는 호출 상한
        max-wait-duration: 10ms         # 슬롯이 없으면 오래 기다리지 않고 빠르게 포기
구분Semaphore 방식ThreadPool 방식
실행 스레드호출자 스레드 그대로전용 풀의 별도 스레드
격리 수준동시 실행 개수 상한스레드 완전 분리 + 대기 큐
오버헤드낮음컨텍스트 스위칭·스레드 관리 비용
호출자 스레드호출 동안 계속 점유즉시 반환(논블로킹 위임 가능)
주의점블로킹 호출이면 그 스레드는 여전히 붙잡힘ThreadLocal(보안 컨텍스트 등) 전파를 챙겨야 함

Semaphore는 "동시에 몇 개까지"라는 개수 상한만 걸어 가볍습니다. 다만 호출 자체는 호출자 스레드에서 그대로 블로킹되므로, 개수를 넘긴 초과분만 빠르게 거절될 뿐 이미 진입한 호출의 스레드는 여전히 묶입니다. ThreadPool 방식은 전용 스레드풀로 호출을 위임해 호출자 스레드(톰캣 스레드)를 즉시 돌려받을 수 있어 격리가 더 강하지만, 스레드 관리 비용과 ThreadLocal 전파 문제가 따라옵니다.

대부분의 동기 호출에는 가벼운 Semaphore로 충분하고, 호출자 스레드를 반드시 지켜야 하는 경우에만 ThreadPool을 고릅니다. 예를 들어 외부 의존성이 PG·배송·쿠폰처럼 여럿이고 동시에 느려지면, Semaphore로는 각 몫만큼의 톰캣 스레드가 겹겹이 묶여 결국 풀이 고갈될 수 있습니다. 이럴 때 ThreadPool은 각 의존성을 자기 전용 풀에 가둬 톰캣 스레드를 지켜 줍니다.

슬롯을 얻지 못한 초과 호출은 BulkheadFullException으로 즉시 실패해 호출자에게 돌아갑니다.

남은 UNKNOWN을 진실로 - 정합성 복구

지금까지 우리는 타임아웃에서도, 서킷 폴백에서도 UNKNOWN 상태를 가질 수 있음을 고려해 왔습니다. UNKNOWN은 결국 늦게라도 결과를 확정해 주어야 합니다.

정합성 복구(reconciliation)는 UNKNOWN에 머문 결제(그리고 크래시로 오래 PENDING에 남은 결제)를 주기적으로 찾아, PG에 되물어(inquire) PAID/FAILED로 확정합니다. 조회(inquire)는 멱등이고 부작용이 없어서, 몇 번을 물어도 안전합니다.

복구 Job은 fixed-delay-ms 간격으로 반복 실행됩니다. fixed delay는 이전 실행이 끝난 시점부터 다음 실행까지의 간격이라, 조회 대상이 많아 한 사이클이 길어져도 실행이 겹치지 않습니다. 대상은 접수된 지 stale-after-seconds가 지나도록 UNKNOWN·PENDING에 머문 결제로 한정합니다. 방금 접수된 PENDING은 아직 진행 중인 정상 결제이고, 방금 생긴 UNKNOWN도 PG 쪽에서 기존 요청이 처리되는 중일 수 있습니다. 유예 시간을 두면 이런 진행 중인 결제와 경합하지 않고, 그 시간이 지나도록 남아 있는 것만 확정 대상으로 삼게 됩니다.

@Component
@RequiredArgsConstructor
public class PaymentReconciliationJob {

    private final PaymentRepository repository;
    private final PgClient pgClient;
    private final PaymentPersistenceService persistence;

    @Scheduled(fixedDelayString = "${lab.reconciliation.fixed-delay-ms}")
    public void reconcile() {
        List<Payment> stale = repository.findStale(staleAfter());
        for (Payment p : stale) {
            // UNKNOWN 결제엔 아직 pgRef가 없으므로, 우리가 PG에 보낸 주문 번호로 되묻는다
            PgInquiry inquiry = pgClient.inquire(p.getOrderId());
            switch (inquiry.status()) {
                case PAID    -> persistence.markPaid(p.getId(), inquiry.pgRef());
                case FAILED  -> persistence.markFailed(p.getId());
                case UNKNOWN -> persistence.recordFailedReconcile(p.getId(), maxAttempts);
            }
        }
    }
}

여기에도 안전장치가 하나 필요합니다. PG가 계속 "모름"을 답할 수도 있습니다(요청이 애초에 PG에 닿지 못한 경우 등). 자동 복구가 무한 루프에 빠지지 않도록, 확정 시도에 상한을 둡니다. 최대 횟수를 넘으면 MANUAL_REVIEW로 종결해 사람이 개입하게 합니다.

@Service
@RequiredArgsConstructor
public class PaymentPersistenceService {

    private final PaymentRepository repository;
    // ...
    @Transactional
    public void recordFailedReconcile(Long id, int maxAttempts) {
        repository.findById(id).ifPresent(p -> {
            p.recordReconcileAttempt();                 // 시도 횟수 +1
            if (p.getReconcileAttempts() >= maxAttempts) {
                p.markManualReview();                   // 사람에게 인계
            }
        });
    }
}

이제 UNKNOWN은 더 이상 막다른 상태가 아니라, 확정 상태(PAID/FAILED)로 수렴하거나 MANUAL_REVIEW로 넘어갑니다. 상태 기계가 완성됩니다.

Loading diagram…

되돌릴 수 없는 것을 되돌리기 - 보상 트랜잭션

지금까지의 내용은 모두 결제 자체에 대한 것이었습니다. 하지만 실제 서비스에서 결제는 대개 더 큰 흐름의 한 단계입니다. 예를 들어 주문은 결제 승인 → 재고 차감 → 주문 확정 순으로 진행되고, 이 단계들이 서로 다른 트랜잭션(때로는 다른 시스템)에 걸쳐 있습니다. 그러면 앞 단계는 성공했는데 뒤 단계가 실패하는 상황이 생길 수 있습니다. 결제는 승인됐는데 재고 차감이 실패하는 경우입니다.

만약 모든 것이 동일 트랜잭션 범위라면, 롤백으로 자연스럽게 없던 일이 될 것입니다. 하지만 결제는 애초에 트랜잭션에 속하지 않는 외부 호출입니다. 이미 PG로 나간 결제 승인은 우리 트랜잭션의 커밋/롤백 바깥에 있어 DB 롤백으로 되돌릴 수 없습니다.

여기서 보상 트랜잭션(compensating transaction)이 필요합니다. 이미 완료된 작업을 물리적으로 되돌리는 게 아니라, 그것을 논리적으로 상쇄하는 반대 방향의 작업을 새로 수행하는 것입니다. 결제의 보상은 승인취소(void)나 환불(refund)입니다.

@Service
@RequiredArgsConstructor
public class OrderService {
    // ...
    public void placeOrder(String idempotencyKey, OrderRequest req) {
        // 외부 side-effect 발생. 이 시점 이후로는 '롤백'이 불가능하다
        PaymentResponse payment = paymentService.pay(idempotencyKey, req.toPayRequest());
        try {
            inventory.deduct(req.orderId());   // 로컬 트랜잭션
            order.confirm(req.orderId());
        } catch (OutOfStockException e) {
            // 재고가 없다. 결제는 이미 나갔으므로 롤백이 아니라 '보상'으로 상쇄한다.
            paymentService.refund(payment.paymentId(), refundKeyFor(idempotencyKey));
            throw BusinessException.of(ORDER_OUT_OF_STOCK);
        }
    }
}

재고를 결제 전에 선점(선차감)하는 설계라면 이 충돌 자체를 줄일 수 있습니다. 하지만 그 경우에도 결제가 실패하면 이미 커밋된 선점을 해제하는 반대 방향의 작업이 필요합니다. 단계 배치는 보상의 방향(환불이냐 선점 해제냐)을 바꿀 뿐, 이미 반영된 단계를 새 작업으로 상쇄해야 한다는 사실은 변하지 않습니다.

void와 refund, 그리고 Saga

승인 직후 아직 매입(capture) 전이면 승인취소(void)로 흔적 없이 되돌릴 수 있고, 매입까지 끝났으면 환불(refund)로 상쇄합니다. 이렇게 여러 단계를 각자의 보상 작업으로 연결해 전체를 최종적으로 일관되게 맞추는 패턴이 Saga입니다. 분산 트랜잭션(2PC)으로 강하게 묶는 대신, 각 단계를 독립적으로 커밋하고 실패 시 보상으로 되감는 접근입니다.

마치며

1부의 첫 서비스 코드와 지금의 코드는 비즈니스 로직이 같습니다. 달라진 것은 외부 API를 신뢰하지 않는다는 전제 하나뿐입니다.

다만 이러한 방어가 모두 항상 필요한 것은 아닙니다. 멱등 없는 재시도는 위험하고, 지터가 없는 백오프는 retry storm을 부를 수 있습니다. 반대로 서킷과 Bulkhead처럼 같은 문제(자원 고갈)를 다른 각도에서 막아 상호보완하는 것들도 있어, 규모가 작다면 하나로 충분하기도 합니다. 결국 방어의 정도는 그 외부 호출의 목적과 중요도, 그리고 상대의 특성에 따라 유연하게 정하는 것입니다.

요즘 PG사는 대개 자체 SDK를 제공하고, 멱등키 관리나 재시도 같은 책임의 일부를 SDK가 대신 맡기도 합니다. 이에 따라 우리가 고려해야 할 방어의 범위도 달라집니다.

이 글은 PG 연동을 예시로 들었지만, 실은 외부 API 호출 전반에 대한 이야기였습니다. 이런 고민은 서비스가 잘게 쪼개질수록 더 중요해집니다. 하나의 프로세스 안에서 끝나던 호출이 MSA에서는 네트워크를 타는 원격 호출이 되고, 하나의 요청이 여러 서비스를 거치게 되는 만큼 호출이 실패할 수 있는 지점도 그만큼 늘어납니다. 게다가 이런 지점에 방어가 없다면, 장애는 거기서 멈추지 않고 전파되어 결국 시스템 전체가 멈추는 연쇄 장애(cascading failure)가 됩니다. 방치된 호출 지점 하나가 사실상 시스템 전체의 단일 장애점(SPOF)이 되는 것입니다. 이 글에서 다룬 내용의 상당수는 그런 전파를 끊어 내는 장치이기도 합니다.


참고

Resilience4j: Getting Started
Retry, CircuitBreaker, RateLimiter 등 회복탄력성 데코레이터를 함수형으로 조합하는 경량 라이브러리.
Amazon Builders' Library: Timeouts, retries, and backoff with jitter
타임아웃·재시도·지수 백오프·지터가 왜 함께 가야 하는지, retry storm을 어떻게 막는지 정리한 글.
Azure Architecture: Compensating Transaction pattern
롤백이 불가능한 분산 작업을 반대 방향 작업으로 상쇄하는 보상 트랜잭션과 Saga의 설계 지침.
Azure Architecture: Bulkhead pattern
의존성별로 자원을 격벽으로 나눠 한 의존성의 장애가 전체로 번지지 않게 하는 격리 패턴.
〜〜〜