외부 결제 시스템과 연동하는 서비스는 요청이 성공하는 경우보다 실패하는 경우를 더 많이 고려해야 한다.
요청이 거부될 수도 있고, 응답이 늦을 수도 있고, 응답 자체가 오지 않을 수도 있다.
그래서 결제 연동의 핵심은 '정상 흐름을 만드는 일'이 아니라 '실패했을 때 시스템이 어떻게 버틸 것인가'를 설계하는 일에 가깝다.
이 글은 외부 결제 시스템 연동을 세 축으로 정리한다.
- 호출 흐름: 비동기 결제에서 상태를 어떻게 관리하는가
- 실패 처리: 타임아웃, 재시도, 서킷브레이커를 어떻게 조합하는가
- 복구 전략: 콜백이 오지 않을 때 어떻게 최종 일관성을 확보하는가
1. 호출 흐름
비동기 결제의 핵심은 요청과 처리가 분리되어 있다는 점이다.
요청을 보내면 즉시 결과가 오지 않는다. 결과는 나중에 콜백으로 도착하거나, 조회 API로 확인해야 한다.
1-1. 요청과 결과 확정 사이에 빈 구간이 생긴다
동기 결제는 요청-응답이 한 번에 끝난다. 비동기 결제는 아래 세 단계를 거친다.
- 결제 요청 → 접수 확인 (트랜잭션 키 발급)
- 처리 대기 (1초에서 수초)
- 결과 수신 (콜백 또는 조회)
이 구간 동안 결제 상태는 '요청됨'이다.
아직 성공도 실패도 아닌 이 중간 상태를 명시적으로 관리해야 한다.
1-2. 결제 상태와 주문 상태를 분리한다
결제는 외부 시스템의 처리 결과에 의존하고, 주문은 내부 비즈니스 흐름에 의존한다.
둘을 하나의 상태로 관리하면 결제가 진행 중인데 주문을 만료시키거나, 결제가 실패했는데 주문이 그대로 남는 불일치가 생긴다.
그래서 보통은 아래처럼 분리한다.
- 결제 상태: 요청됨 → 성공 / 실패
- 주문 상태: 결제 대기 → 결제 완료 / 결제 실패 / 만료
결제 결과가 확정되면 주문 상태도 함께 바꾼다.
이때 두 상태의 변경은 하나의 트랜잭션으로 묶어서 원자성을 보장한다.
예시 코드
void updatePaymentResult(String transactionKey, String status, String reason) {
Payment payment = findByTransactionKey(transactionKey);
if (payment.isAlreadySettled()) return;
payment.settle(status, reason);
paymentRepository.save(payment);
orderStatusManager.updateOrderStatus(payment.getOrderId(), status);
}
1-3. 결제 결과의 주도권은 결제 쪽에 둔다
주문과 결제가 분리되면 누가 상태를 주도하는지 정해야 한다.
결제 결과에 의한 상태 변경은 결제 쪽이 주도한다.
주문 쪽이 결제 결과를 직접 판단하면 책임이 흩어지고, 외부 시스템의 세부사항이 주문 로직에 스며든다.
유일한 예외는 주문 만료다.
장기 미결제 주문을 정리하는 것은 주문의 비즈니스 규칙이므로, 주문 쪽이 주도한다.
다만 결제가 진행 중인 주문은 만료 대상에서 제외해야 한다.
1-4. 동일 주문에 대한 중복 결제를 막는다
비동기 결제에서는 결과가 확정되기 전에 사용자가 다시 결제를 시도할 수 있다.
결제 상태가 '요청됨'인 건이 이미 있다면 추가 결제를 거부해야 한다.
반면 결제가 실패한 주문에 대해서는 재결제를 허용한다.
한도 초과나 잘못된 카드 같은 사유로 실패했을 때, 주문을 새로 만들지 않고 다른 카드로 재시도할 수 있어야 한다.
2. 실패 처리
외부 시스템은 언제든 느려지거나 거부할 수 있다.
실패 처리의 핵심은 외부 시스템의 장애가 내부 시스템 전체로 번지지 않게 막는 데 있다.
2-1. 타임아웃은 두 층으로 나눈다
외부 시스템 호출에는 두 종류의 지연이 있다.
- 연결 지연: 네트워크 연결이 맺어지는 시간
- 응답 지연: 연결은 됐지만 응답이 오는 시간
두 값은 의미가 다르고, 실패 시 후속 처리도 다르다.
연결 지연이 길면 요청이 도달하지 않았다고 판단할 수 있지만, 응답 지연이 길면 요청이 이미 도달했을 가능성이 있다.
이 차이가 재시도 가능 여부를 결정한다.
2-2. 재시도는 안전한 실패에만 적용한다
모든 실패를 재시도하면 안 된다.
재시도해도 되는 실패와 해서는 안 되는 실패를 분리해야 한다.
재시도가 안전한 경우는 아래와 같다.
- 외부 시스템이 요청을 명시적으로 거부한 경우 (트랜잭션 미생성)
- 연결 자체가 맺어지지 않은 경우 (요청 미도달)
재시도가 위험한 경우는 아래와 같다.
- 응답 지연으로 타임아웃이 발생한 경우 (요청이 도달했을 가능성)
- 서킷브레이커가 열려 있는 경우 (재시도해도 계속 차단)
- 비즈니스 규칙 위반인 경우 (재시도로 해결 불가)
응답 지연 타임아웃에서 재시도하면 외부 시스템에 중복 트랜잭션이 생길 수 있다.
이 경우는 재시도 대신 비동기 복구로 넘겨야 한다.
2-3. 재시도 간격은 점진적으로 늘린다
재시도 간격이 고정이면 동시 요청이 같은 시점에 몰릴 수 있다.
이 현상을 thundering herd라 부르며, 장애 중인 외부 시스템에 부하를 더 가중시킨다.
그래서 보통은 지수 백오프에 무작위 지연을 더한다.
예시 코드
// 200ms -> 400ms (+ random jitter)
RetryConfig.custom()
.maxAttempts(3)
.waitDuration(Duration.ofMillis(200))
.enableExponentialBackoff()
.enableRandomizedWait()
.build();
재시도 횟수도 비용 대비 효과로 정한다.
외부 시스템의 요청 성공률이 60%라면, 3회 시도 시 체감 성공률은 약 93%까지 올라간다.
4회째 개선폭은 2.6%에 불과하므로, 3회가 비용 대비 효과의 최적점이 된다.
2-4. 서킷브레이커는 외부 장애가 내부로 번지는 것을 막는다
외부 시스템이 장애 상태인데 계속 요청을 보내면 스레드가 블로킹되어 내부 시스템까지 멈출 수 있다.
서킷브레이커는 외부 시스템의 실패율이 임계치를 넘으면 요청을 즉시 차단하여 스레드를 보호한다.
서킷브레이커의 상태는 세 가지다.
- 닫힘: 모든 요청을 통과시킨다
- 열림: 모든 요청을 즉시 실패시킨다
- 반열림: 제한된 요청만 통과시켜 복구를 확인한다
서킷브레이커 설정에서 주의할 점은 관찰 범위와 판단 시작선을 분리하는 것이다.
- 관찰 범위는 최근 몇 건(또는 몇 초)을 현재 상태로 볼지 정한다
- 판단 시작선은 최소 몇 건이 쌓여야 실패율을 믿고 판단할지 정한다
판단 시작선이 없으면 서버 시작 직후 한두 건의 실패만으로 서킷이 열릴 수 있다.
1건 중 1건 실패는 실패율 100%지만, 10건 중 5건 실패와는 전혀 다른 상황이다.
2-5. 재시도와 서킷브레이커의 조합 순서가 중요하다
두 패턴을 함께 쓸 때는 바깥에 재시도, 안쪽에 서킷브레이커를 둔다.
예시 코드
// Retry(CircuitBreaker(외부 호출))
Retry retry = Retry.of("pgRetry", retryConfig);
CircuitBreaker cb = CircuitBreaker.of("pgCb", cbConfig);
Supplier<Result> decorated = Decorators.ofSupplier(() -> callPg())
.withCircuitBreaker(cb)
.withRetry(retry)
.decorate();
이 순서가 중요한 이유는 두 가지다.
- 서킷브레이커가 안쪽에 있으므로 개별 호출의 성공과 실패가 모두 기록된다. 서킷브레이커는 재시도 결과가 아니라 외부 시스템의 실제 건강 상태를 측정한다.
- 서킷이 열리면 재시도도 즉시 중단된다. 불필요한 재시도가 발생하지 않는다.
2-6. 실패 유형마다 후속 처리가 다르다
모든 실패를 같은 방식으로 처리하면 안 된다.
실패 유형에 따라 결제 상태를 다르게 관리해야 한다.
- 요청 거부 또는 연결 실패 → 재시도 소진 후 결제 실패 확정
- 응답 지연 타임아웃 → 결제 상태를 '요청됨'으로 유지하고 비동기 복구로 위임
- 서킷 열림 → 결제를 생성하지 않고 즉시 실패 반환
응답 지연 타임아웃에서 결제를 실패로 마킹하면, 외부 시스템에서는 결제가 성공인데 내부에서는 실패로 남는 불일치가 생긴다.
이 경우는 상태를 확정하지 않고 비동기 복구 경로에 맡기는 것이 안전하다.
3. 복구 전략
비동기 결제에서는 결과가 콜백으로 도착한다.
하지만 콜백은 유실될 수 있다. 네트워크 장애, 내부 서버 재시작, 콜백 서버 자체의 버그 등 여러 이유가 있다.
복구 전략의 핵심은 '콜백이 오지 않아도 결제 결과를 빠짐없이 반영하는 것'이다.
3-1. 콜백은 주경로이되 유일한 경로가 아니다
콜백은 가장 빠른 결과 수신 경로다. 하지만 콜백만으로는 상태 반영을 보장할 수 없다.
콜백이 실패했을 때 외부 시스템이 재시도하지 않는 경우가 있다.
이 경우 콜백에만 의존하면 결제 결과가 영원히 반영되지 않는 건이 생긴다.
그래서 콜백과 별개로 보조 경로를 두어야 한다.
3-2. 폴링 스케줄러가 미확정 결제를 주기적으로 확인한다
일정 시간이 지나도 결과가 확정되지 않은 결제를 주기적으로 외부 시스템에 조회하여 상태를 반영한다.
폴링 대상은 아래 조건을 만족하는 건이다.
- 결제 상태가 '요청됨'이다
- 생성 후 일정 시간(외부 시스템의 최대 처리 지연 이상)이 경과했다
폴링 결과에 따라 아래처럼 처리한다.
- 외부 시스템에서 성공 또는 실패가 확정됨 → 결제와 주문 상태를 반영한다
- 아직 처리 중 → 이번 주기에서는 건너뛰고 다음 주기에 재확인한다
- 외부 시스템에 해당 트랜잭션이 없음 → 요청이 도달하지 않은 것으로 판단하고 실패 처리한다
- 외부 시스템 조회 자체가 실패 → 건너뛰고 다음 주기에 재확인한다
예시 코드
void pollRequestedPayments() {
List<Payment> targets = queryService.findRequestedBefore(threshold);
for (Payment payment : targets) {
try {
PgResult result = pgGateway.query(payment);
if (result.isSettled()) {
commandService.updateResult(payment, result);
}
} catch (Exception e) {
log.error("poll failed: paymentId={}", payment.getId(), e);
}
}
}
건별로 독립 처리하는 것이 중요하다. 한 건의 실패가 다른 건의 처리를 방해하면 안 된다.
3-3. 응답 지연 타임아웃 건은 트랜잭션 키 없이 복구해야 한다
정상적인 요청 성공 시에는 외부 시스템이 트랜잭션 키를 발급한다.
하지만 응답 지연으로 타임아웃이 발생하면 트랜잭션 키를 받지 못한 채 결제가 '요청됨' 상태로 남는다.
이 경우에는 주문 식별자로 외부 시스템에 조회하여 매칭되는 트랜잭션을 찾아야 한다.
- 매칭 트랜잭션이 있으면 → 트랜잭션 키를 저장하고 결과를 반영한다
- 매칭 트랜잭션이 없으면 → 요청 미도달로 판단하고 실패 처리한다
3-4. 수동 복구 경로도 함께 둔다
스케줄러가 대부분의 미확정 건을 처리하지만, CS 대응이나 예외 상황을 위해 수동 복구 API도 필요하다.
특정 결제건을 지정하여 외부 시스템에 조회하고 상태를 반영하는 단순한 기능이면 충분하다.
3-5. 장기 미결제 주문은 별도로 정리한다
결제가 오랫동안 확정되지 않는 주문은 만료시켜야 한다.
만료 시에는 주문 생성 시 차감했던 자원(재고, 쿠폰 등)을 복원하는 보상 트랜잭션이 필요하다.
다만 결제가 진행 중인 주문은 만료 대상에서 제외해야 한다.
결제 상태가 '요청됨'인 건이 있다면 아직 결과가 확정되지 않았으므로 만료하면 안 된다.
정리
외부 결제 연동은 결국 세 가지로 정리된다.
- 호출 흐름: 비동기 결제의 중간 상태를 명시적으로 관리하고, 결제와 주문 상태를 분리하되 원자적으로 동기화한다
- 실패 처리: 타임아웃을 두 층으로 나누고, 재시도는 안전한 실패에만 적용하며, 서킷브레이커로 장애 확산을 막는다
- 복구 전략: 콜백에만 의존하지 않고, 폴링 스케줄러와 수동 복구로 최종 일관성을 확보한다
이 세 축을 나눠서 보면 결제 연동의 문제가 훨씬 빨리 좁혀진다.
결제 연동에서 마지막으로 확인할 질문은 이것이다.
'요청이 실패하거나 결과가 오지 않았을 때, 시스템은 스스로 복구할 수 있는가'