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

SpringResilience4jPaymentResilienceConsistencyBackend

들어가며

백엔드 개발을 하다 보면 외부 API를 호출할 일이 계속 늘어납니다. 결제 게이트웨이(PG), 인증, 알림, 사내 다른 서비스까지. 이들의 공통점은 우리가 통제할 수 없는 외부라는 점입니다. 상대가 언제 배포할지도, 장애가 나면 언제 복구될지도 우리가 알 수 없습니다.

같은 프로세스 안에서 도는 메서드 호출은 확정적입니다. 부르면 실행되고, 값을 리턴하거나 예외를 던집니다. 결과가 예상 범위를 벗어날 일이 없습니다. 반면 외부 API 호출은 네트워크를 거칩니다. 네트워크는 언제든 느려지고 끊기고 패킷을 잃을 수 있어, 애초에 신뢰할 수 있는 대상이 아닙니다. 그래서 외부 호출을 로컬 호출과 똑같이 다루면 안 됩니다. 실패할 수 있다는 전제 위에서 조심스럽게 다뤄야 합니다.

지금부터는 PG 결제 구현을 예시로, 외부 API 연동을 단계적으로 고도화해 보려 합니다.

기본 비즈니스 로직

여기 외부 PG에 결제를 요청하는 작은 Spring 서비스가 있습니다. 로직 자체는 단순하고 자명하며, 비즈니스 규칙상 틀린 곳도 없습니다.

@Service
@Transactional
@RequiredArgsConstructor
public class PaymentService {

    private final PgClient pgClient;
    private final PaymentRepository repository;

    public PaymentResponse pay(PayRequest request) {
        // 1) 결제 접수 기록
        Payment payment = repository.save(Payment.pending(request.orderId(), request.amount()));

        // 2) 외부 PG에 승인 요청
        PgResult result = pgClient.charge(request.orderId(), request.amount());

        // 3) 승인 결과 반영
        payment.markPaid(result.pgRef());
        return PaymentResponse.from(payment);
    }
}

POST /payments{orderId, amount}가 들어오면, 결제를 PENDING으로 남기고, PG에 승인을 요청하고, 성공하면 PAID로 확정합니다. happy path(PG가 곧바로 응답을 주는 경우)는 완벽하게 동작합니다.

정상 응답이 곧 결제 성공은 아니다

초안 코드는 pgClient.charge()가 리턴하면 무조건 markPaid()를 합니다. 만약 PG가 4xx나 5xx로 실패하면, RestClient가 그 상태 코드를 보고 예외를 던지고, 예외가 그대로 전파되어 트랜잭션이 롤백되므로 결제는 처리되지 않은 상태로 남습니다. 적어도 잘못 처리되지는 않습니다. 진짜 문제는 PG가 200 OK를 주면서 "이 카드는 한도 초과라 거절합니다"라고 답하는 경우입니다. HTTP는 성공했지만 결제는 거절된 것입니다.

여기서 반드시 구분해야 할 두 가지가 있습니다.

  • 카드 거절: 카드는 다양한 사유(오입력, 한도 초과, 정지 등)로 거절될 수 있고, 이는 PG가 제 일을 한 결과입니다. 장애가 아니라 정상적인 결과입니다. 같은 요청을 다시 보내도 결과는 같습니다.
  • 잘못된 요청(400): 우리가 보낸 요청 자체가 틀렸습니다(프론트·백엔드 DTO 밸리데이션 누락, PG 쪽 스키마 변경 등). 이것도 다시 보내봐야 소용없습니다. 코드를 고쳐야 합니다.

PG 명세에는 이런 응답이 어떤 형태로 오는지가 나와 있습니다. 하지만 그 응답을 성공으로 볼지, 재시도할지, 장애로 집계할지는 우리 몫입니다. 이 판단을 한곳에 모으려면, HTTP 응답을 도메인 언어로 번역하는 어댑터가 필요합니다.

@Component
@RequiredArgsConstructor
public class PgClientImpl implements PgClient {

    private final RestClient pgRestClient;

    @Override
    public PgResult charge(String orderId, long amount) {
        ChargeResponse res = pgRestClient.post()
            .uri("/charge")
            .body(new ChargeRequest(orderId, amount))
            .retrieve()
            .onStatus(HttpStatusCode::is4xxClientError, (req, resp) -> {
                throw new PgBadRequestException();          // 요청 자체가 잘못됨
            })
            .body(ChargeResponse.class);

        return switch (res.status()) {
            case "APPROVED" -> PgResult.approved(res.pgRef());
            case "DECLINED" -> throw new CardDeclinedException();  // 200이지만 거절
            default          -> throw new PgUnavailableException("unexpected: " + res.status());
        };
    }
}

이제 서비스는 "성공"만이 아니라 거절과 잘못된 요청을 각각 다르게 마무리합니다.

@Service
@Transactional
@RequiredArgsConstructor
public class PaymentService {

    private final PgClient pgClient;
    private final PaymentRepository repository;

    public PaymentResponse pay(PayRequest request) {
        Payment payment = repository.save(Payment.pending(request.orderId(), request.amount()));
        try {
            PgResult result = pgClient.charge(request.orderId(), request.amount());
            payment.markPaid(result.pgRef());
            return PaymentResponse.from(payment);
        } catch (CardDeclinedException e) {
            payment.markFailed();                          // 카드 거절(정상적인 결과)
            throw BusinessException.of(CARD_DECLINED);
        } catch (PgBadRequestException e) {
            payment.markFailed();
            throw BusinessException.of(PG_BAD_REQUEST);
        }
    }
}

거절과 잘못된 요청은 둘 다 FAILED로 남지만, 원인과 클라이언트에게 돌려줄 메시지가 다릅니다.

지금은 FAILED가 남지 않는다

앞서 봤듯 예외는 @Transactional을 롤백시킵니다. 그런데 이 catch 블록은 markFailed()로 상태를 바꾼 뒤 곧바로 BusinessException(이것도 RuntimeException)을 던집니다. 그래서 지금 단계에서는 markFailed()가 재던지기와 함께 되감겨, FAILED가 실제로는 남지 않습니다. 이 문제는 뒤의 "외부 호출과 트랜잭션 경계" 절에서 상태 변경을 독립 트랜잭션으로 분리하면서 해결합니다.

비즈니스 실패를 장애로 착각

카드 거절이 몰리는 것은 대개 정상 트래픽입니다(프로모션 종료 직후, 특정 카드사 이슈 등). 이걸 시스템 장애로 착각해 다루기 시작하면, 재시도로 자원을 낭비하고 뒤에 붙일 방어 장치들이 엉뚱하게 작동합니다. 정상 거절은 조용히, 진짜 장애는 시끄럽게 다뤄야 합니다.

같은 요청이 여러 번 들어오면? - Idempotency

같은 요청이 한 번만 온다는 보장은 없습니다. 사용자가 결제 버튼을 여러 번 클릭하거나(일명 따닥 이슈), 네트워크가 불안정해 클라이언트가 응답을 못 받은 채 같은 요청을 다시 보내거나, 중간 게이트웨이가 재전송합니다. 이 요청들을 그대로 처리하면 PG로 같은 결제가 중복으로 나가고, 이중과금으로 이어집니다.

멱등(idempotency)은 본래 수학 용어로, 같은 연산을 여러 번 적용해도 결과가 한 번 적용한 것과 같은 성질을 말합니다. 결제로 치면, 같은 요청이 몇 번 도착하든 실제로 돈이 빠져나가는 일은 한 번만 일어나게 하는 것입니다.

멱등은 보통 요청마다 붙는 멱등키로 보장합니다. 이 키는 재시도하는 주체, 즉 요청을 보내는 쪽이 만들어 헤더에 싣고, 재시도할 때도 같은 키를 그대로 다시 보냅니다. 그래야 서버가 이전과 같은 요청임을 알아봅니다. 형태는 대개 충돌 걱정이 없는 UUID v4 같은 무작위 문자열입니다.

@Entity
@Table(uniqueConstraints = @UniqueConstraint(
    name = "uq_payment_idem_key", columnNames = "idempotency_key"))
public class Payment {
    // 멱등키: 클라이언트가 보낸 값. DB 유니크 인덱스가 중복 삽입을 물리적으로 막는다.
    private String idempotencyKey;
    // PG가 승인 후 돌려주는 참조번호. 승인 전에는 null이다.
    private String pgRef;
    // ...
}
@Service
@Transactional
@RequiredArgsConstructor
public class PaymentService {

    private final PgClient pgClient;
    private final PaymentRepository repository;

    public PaymentResponse pay(String idempotencyKey, PayRequest request) {
        Payment payment;
        try {
            payment = repository.saveAndFlush(
                Payment.pending(idempotencyKey, request.orderId(), request.amount()));
        } catch (DataIntegrityViolationException duplicate) {
            // 유니크 위반 = 이미 처리(중)인 요청. PG를 다시 부르지 않고 기존 상태를 돌려준다
            Payment existing = repository.findByIdempotencyKey(idempotencyKey).orElseThrow();
            return PaymentResponse.from(existing);
        }
        // ... 이하 charge / markPaid / 거절 분기는 동일 ...
    }
}

결제를 먼저 PENDING 상태로 insert해 그 멱등키를 선점합니다. 같은 멱등키가 이미 있으면 유니크 제약에 걸려 DataIntegrityViolationException이 발생하고, 이건 "같은 요청이 이미 들어와 있다"는 신호입니다. 이때는 저장돼 있던 상태를 그대로 돌려줄 뿐, PG로 다시 요청하지 않습니다. 확정 상태(PAID/FAILED)이면 그 결과를, 아직 PENDING이면 "처리 중"(예: 409 Conflict)을 돌려줍니다.

핵심은 중복 판정을 애플리케이션이 아니라 유니크 제약에 맡긴다는 점입니다. "먼저 조회해서 없으면 insert"로 직접 판정하면, 두 요청이 거의 동시에 들어올 때 둘 다 "없음"을 보고 둘 다 insert해 버리는 동시성 문제가 생깁니다. 유니크 제약은 이 경쟁을 DB 한 곳에서 직렬화하므로, 같은 요청이 들어오더라도 커밋에 성공하는 것은 하나뿐입니다.

Loading diagram…

여기서는 가장 대표적인 방법인 DB 유니크 제약으로 멱등을 구현했지만, 이는 동시성을 다루는 여러 방법 중 하나입니다. 메모리·Redis를 이용한 락(예: Redis SETNX)이나 낙관적 락 등으로도 같은 문제를 다룰 수 있습니다. 한 층위에서 전부 보장할지, 여러 층위를 조합할지는 정합성과 성능 요구에 따라 달라집니다.

PG의 멱등 ≠ 우리의 멱등

많은 PG가 Idempotency-Key 헤더를 지원합니다. 우리도 2부에서 이 키를 PG로 함께 보내 우리 쪽에서 PG로 가는 홉의 중복을 PG가 걸러내게 합니다. 하지만 그것만으로 클라이언트에서 우리로 오는 홉의 중복까지 막지는 못합니다. PG가 멱등을 무시하는 "나쁜 PG"이거나, 우리 클라이언트가 같은 결제를 서로 다른 키로 두 번 만들어 버리면 PG의 멱등은 우리를 지켜주지 못합니다. 게다가 PG의 멱등이 보장하는 범위는 PG 쪽 처리뿐입니다. 같은 요청에 우리 내부 작업(포인트 적립, 주문 생성 등)이 딸려 있다면, 과금은 한 번이어도 그 작업들은 중복 실행될 수 있습니다. 그래서 두 홉은 각자 막아야 하고, 클라이언트에서 우리로 오는 홉의 멱등은 PG에 위임할 수 없는 우리 책임입니다.

멱등에서 놓치기 쉬운 것들
  • 멱등키 보관 기간: 멱등키를 영속 DB 행이 아니라 TTL로 보관하는 방식(예: Redis)이라면, 그 보관 기간이 클라이언트의 재시도 유효기간보다 길어야 합니다. 키가 만료된 뒤 같은 요청이 다시 오면 중복 판정을 하지 못합니다.
  • 같은 키, 다른 본문: 같은 멱등키로 금액이 다른 요청이 오면 클라이언트 버그이거나 공격 신호입니다. 원본 요청의 지문(해시)을 저장해 두고, 불일치하면 409로 거절합니다.

정상이어도 느리다 - 외부 호출과 트랜잭션 경계

대개 PG 호출은 빠르게 끝날 겁니다. 하지만 이것도 결국 네트워크를 거치는 외부 호출이라 언제든 느려질 수 있습니다. PG 쪽 트래픽이 몰리거나, 카드사 응답이 늦어지는 등 원인은 다양합니다. 건강한 PG도 승인에 몇 초가 걸릴 수 있고, 200 OK를 조금 늦게 주는 것은 장애가 아니라 정상 동작입니다.

문제는 코드가 이 호출을 트랜잭션 범위 내부에서 한다는 점입니다. @Transactional 아래에서 pgClient.charge()를 호출하면, PG 호출이 응답을 받을 때까지 DB 커넥션을 붙잡고 있게 됩니다.

@Service
@RequiredArgsConstructor
public class PaymentService {

    private final PgClient pgClient;
    private final PaymentRepository repository;

    @Transactional                            // ← 트랜잭션이 열린 채로
    public PaymentResponse pay(...) {
        Payment payment = repository.save(...);   // 커넥션 획득
        PgResult result = pgClient.charge(...);   // 응답이 올 때까지 커넥션을 쥐고 있음
        payment.markPaid(...);
    }                                          // ← 여기서야 커밋 & 커넥션 반납
}

DB 커넥션은 생성 비용이 비싸 풀에 미리 만들어 두고 나눠 쓰며, 그 수도 제한되어 있습니다. 결제 트래픽이 조금만 몰려도 느린 PG를 기다리는 요청들이 커넥션을 오래 쥐어 풀을 고갈시키고, 결제와 무관한 다른 API까지 커넥션을 얻지 못해 시스템 전체가 마비될 수 있습니다.

해법은 트랜잭션을 쪼개는 것입니다. 외부 호출은 어떤 트랜잭션에도 들어가지 않게 합니다.

[TX1] PENDING 저장 → 커밋          (여기서 커넥션 반납)
      PG 호출                       (트랜잭션 밖, 커넥션을 쥐지 않음)
[TX2] 결과(PAID/FAILED) 저장 → 커밋

상태 변경을 각각 독립 트랜잭션 메서드로 분리하고,

@Service
@RequiredArgsConstructor
public class PaymentPersistenceService {

    private final PaymentRepository repository;

    @Transactional
    public Payment savePending(String idempotencyKey, String orderId, long amount) {
        return repository.saveAndFlush(Payment.pending(idempotencyKey, orderId, amount));
    }

    @Transactional(readOnly = true)
    public Payment getByIdempotencyKey(String idempotencyKey) {
        return repository.findByIdempotencyKey(idempotencyKey).orElseThrow();
    }

    @Transactional
    public void markPaid(Long id, String pgRef) {
        repository.findById(id).ifPresent(p -> p.markPaid(pgRef));
    }

    @Transactional
    public void markFailed(Long id) {
        repository.findById(id).ifPresent(Payment::markFailed);
    }
}

오케스트레이션을 담당하는 pay()에서는 @Transactional을 뗍니다. 이제 PENDING 커밋과 결과 커밋 사이에서 외부 호출이 일어나고, 그 사이에는 어떤 커넥션도 쥐고 있지 않습니다.

// @Transactional 없음. 오케스트레이션만 담당
public PaymentResponse pay(String idempotencyKey, PayRequest request) {
    Payment payment;
    try {
        payment = persistence.savePending(idempotencyKey, request.orderId(), request.amount()); // TX1
    } catch (DataIntegrityViolationException duplicate) {
        return PaymentResponse.from(persistence.getByIdempotencyKey(idempotencyKey));
    }
    try {
        PgResult result = pgClient.charge(request.orderId(), request.amount()); // TX 밖
        persistence.markPaid(payment.getId(), result.pgRef());                  // TX2
        return PaymentResponse.paid(payment.getId());
    } catch (CardDeclinedException e) {
        persistence.markFailed(payment.getId());
        throw BusinessException.of(CARD_DECLINED);
    } catch (PgBadRequestException e) {
        persistence.markFailed(payment.getId());
        throw BusinessException.of(PG_BAD_REQUEST);
    }
}

트랜잭션을 분리하면 커넥션은 아끼지만, 스텝들을 묶던 원자성은 사라집니다. TX1(PENDING 커밋)과 PG 호출까지 끝난 뒤 TX2(markPaid) 직전에 서버가 죽으면, DB에는 PENDING만 남았는데 PG에서는 이미 결제된 어긋난 상태가 됩니다. 그래서 이 설계에는 어긋난 상태를 나중에 되맞추는 사후 보정이 따라와야 합니다. 혹은 트랜잭션 아웃박스 패턴으로 중단된 흐름을 이어 가게 할 수도 있습니다.

여기까지는 PG가 어떤 형태로든 응답을 돌려주는 상황이었습니다. 거절을 성공과 구분하고, 중복을 막고, 느린 응답이 DB를 잡지 않도록 했습니다.

하지만 외부 호출은 아무 답도 돌려주지 않거나, 한동안 계속 실패하기도 합니다. 응답이 없으면 성공도 실패도 판정할 수 없고, 실패가 계속되면 응답을 해석하는 것만으로는 부족하므로, 지금까지와는 다른 종류의 방어가 필요합니다. 다음 편에서는 이런 경우들을 다뤄 보려 합니다.

〜〜〜