API retry 로직 잘못 짜서 장애 난 사례: http 네트워크 오류를 안전하게 다루는 방법

API retry는 네트워크 오류를 견디기 위한 장치입니다. 그런데 retry 정책을 잘못 짜면 장애를 막는 코드가 오히려 장애를 키우는 원인이 됩니다. 특히 http API 호출, 결제 승인, 외부 인증, 알림 발송처럼 외부 시스템에 의존하는 로직에서는 retry 기준을 신중하게 잡아야 합니다.

http API retry 로직에서 실제로 문제가 된 상황

http 기반 외부 API를 호출하는 서비스에서 간헐적인 네트워크 timeout이 발생했습니다. 처음에는 단순한 외부 API 지연으로 보였습니다. 그래서 개발팀은 요청 실패 시 최대 3번까지 다시 호출하도록 retry 로직을 넣었습니다.

문제는 retry 자체가 아니라 retry를 적용한 방식이었습니다. 모든 실패를 같은 실패로 보고 재시도했고, 재시도 간격도 거의 없었습니다. 외부 API가 느려지는 순간 내부 서버의 요청 대기 시간이 길어졌고, 그 사이 같은 요청이 여러 번 외부로 나갔습니다.

처음 의도는 좋았습니다. 일시적인 네트워크 오류를 사용자에게 바로 노출하지 않기 위한 처리였기 때문입니다. 하지만 운영에서는 retry가 항상 선의로만 동작하지 않습니다. 잘못된 retry는 실패 요청을 줄이는 게 아니라 실패 요청을 복제합니다.

 

API retry 장애의 증상과 영향도

API retry 문제가 터졌을 때 가장 먼저 보인 증상은 외부 API timeout 증가였습니다. 단순히 호출이 실패하는 정도가 아니라, 내부 서버의 요청 처리 시간도 같이 늘어났습니다. 사용자는 같은 화면에서 오래 기다리다가 실패 메시지를 보거나, 일부 요청은 성공했는데 화면에서는 실패로 보이는 상황을 겪었습니다.

특히 문제가 된 부분은 결제 후 상태 확인 API였습니다. 결제 승인 요청 자체는 외부 PG에서 처리되었지만, 내부 서버가 결과 확인 요청을 반복하면서 같은 거래에 대한 상태 조회가 짧은 시간에 여러 번 발생했습니다.


사용자 요청 1회
  └─ 내부 API 호출
      └─ 외부 결제 상태 조회 실패
          ├─ retry 1
          ├─ retry 2
          └─ retry 3

요청 하나만 보면 큰 문제가 없어 보입니다. 하지만 동시에 여러 사용자가 같은 흐름을 타면 이야기가 달라집니다. 1개의 실패 요청이 3개 또는 4개의 외부 요청으로 늘어나고, 외부 API가 느린 상태에서는 그 요청들이 다시 대기열을 만듭니다.

장애 대응 중에는 로그에 같은 거래 번호가 짧은 시간 안에 반복해서 찍히는 패턴이 보였습니다. 이때 단순히 "외부 API가 느리다"로만 보면 원인을 놓치기 쉽습니다. 내부 retry 정책이 외부 지연을 증폭시키고 있는지 같이 봐야 합니다.

 

잘못된 API retry 코드 예시

API retry 장애에서 자주 보는 코드는 실패 예외를 넓게 잡고 무조건 다시 시도하는 형태입니다. Java나 Spring Boot 프로젝트에서도 아래와 비슷한 코드가 들어가는 경우가 있습니다.


public PaymentStatus getPaymentStatus(String transactionId) {
    int maxRetry = 3;

    for (int i = 0; i < maxRetry; i++) {
        try {
            return paymentClient.getStatus(transactionId);
        } catch (Exception e) {
            log.warn("payment status api failed. retry={}", i + 1, e);
        }
    }

    throw new IllegalStateException("payment status api failed");
}

겉으로 보면 간단하고 읽기 쉬운 코드입니다. 하지만 운영 관점에서는 위험한 부분이 많습니다. 어떤 실패를 재시도할지 구분하지 않고, 재시도 간격도 없으며, 마지막 실패 원인도 제대로 보존하지 않습니다.

더 큰 문제는 이 코드가 http 응답 상태를 구분하지 않는다는 점입니다. 500, 502, 503, 504처럼 일시적인 서버 오류일 수 있는 응답과 400, 401, 403, 404처럼 재시도해도 성공 가능성이 낮은 응답을 같은 방식으로 처리합니다.

재시도하면 안 되는 실패까지 retry한 문제

API retry에서 가장 먼저 정해야 할 기준은 "무엇을 재시도하지 않을 것인가"입니다. 많은 팀이 재시도할 대상을 먼저 떠올리지만, 운영에서는 재시도 금지 조건을 명확히 잡는 쪽이 더 안전합니다.

예를 들어 인증 토큰이 잘못되어 401이 발생했다면 같은 요청을 다시 보내도 성공하지 않습니다. 요청 파라미터가 잘못되어 400이 발생한 경우도 마찬가지입니다. 이런 요청은 retry가 아니라 요청 생성 로직이나 인증 처리 흐름을 고쳐야 합니다.


재시도 후보
- connection timeout
- read timeout
- 502 Bad Gateway
- 503 Service Unavailable
- 504 Gateway Timeout
- 429 Too Many Requests, 단 Retry-After 기준 준수

재시도 제외
- 400 Bad Request
- 401 Unauthorized
- 403 Forbidden
- 404 Not Found
- 비즈니스 검증 실패

실무에서는 409 Conflict나 422 Unprocessable Entity 같은 응답도 주의해서 봐야 합니다. API 제공자의 의미 정의에 따라 재시도 대상이 될 수도 있고, 명확한 비즈니스 실패일 수도 있습니다. 문서만 보지 말고 실제 응답 바디와 운영 로그를 같이 확인해야 판단이 정확해집니다.

retry 간격이 없어 요청이 몰린 문제

재시도 간격이 없는 retry는 대부분 좋은 선택이 아닙니다. 외부 API가 이미 느리거나 불안정한 상태인데 곧바로 다시 요청을 보내면, 실패한 요청을 더 빠르게 쌓는 구조가 됩니다.

이때 필요한 것이 backoff입니다. 실패할수록 재시도 간격을 조금씩 늘려 외부 시스템과 내부 시스템 모두에게 회복할 시간을 줍니다. 여기에 jitter를 더하면 여러 서버 인스턴스가 같은 타이밍에 동시에 재시도하는 문제를 줄일 수 있습니다.


1회 실패 후 200ms 대기
2회 실패 후 500ms 대기
3회 실패 후 1000ms 대기

여기에 0~200ms 정도의 랜덤 지연을 추가해
동시 재시도 집중을 줄입니다.

단순한 sleep을 넣는 것으로 끝내면 안 됩니다. 요청 처리 스레드를 오래 붙잡는 방식은 내부 서버에도 부담을 줍니다. 사용자 응답이 꼭 필요한 동기 API인지, 비동기 재처리로 넘길 수 있는 작업인지 먼저 나누는 편이 좋습니다.

 

네트워크 오류와 http 응답 오류를 구분해야 하는 이유

API retry를 설계할 때 네트워크 오류와 http 응답 오류는 다르게 봐야 합니다. 둘 다 호출 실패처럼 보이지만 원인이 다르고 대응 방식도 다릅니다.

네트워크 오류는 요청이 서버까지 도달했는지 확신하기 어려운 경우가 많습니다. connection timeout은 연결 자체가 제대로 맺어지지 않았을 가능성이 큽니다. 반면 read timeout은 요청은 전달되었지만 응답을 제때 받지 못한 상황일 수 있습니다.


connection timeout
- 연결을 맺는 단계에서 실패
- 서버에 요청이 도달하지 않았을 가능성이 상대적으로 큼

read timeout
- 요청 후 응답을 기다리다가 실패
- 서버에서 처리는 되었지만 응답만 늦었을 가능성이 있음

이 차이는 결제, 쿠폰 지급, 포인트 적립처럼 부작용이 있는 API에서 매우 중요합니다. read timeout이 발생했다고 무조건 같은 요청을 다시 보내면 중복 처리 가능성이 생깁니다. 그래서 부작용이 있는 API에는 idempotency key나 거래 고유 키가 필요합니다.

 

API retry 장애 원인 추적 과정

이 문제를 추적할 때는 단순히 에러 로그 개수를 보는 것만으로는 부족했습니다. 실패 로그가 늘어난 것은 결과일 뿐이고, retry가 어느 지점에서 얼마나 반복되었는지를 확인해야 했습니다.

먼저 같은 transactionId가 짧은 시간 안에 몇 번 호출되었는지 확인했습니다. 그다음 내부 API 요청 1건이 외부 API 호출 몇 건으로 증폭되는지 로그를 묶어 봤습니다. 이 과정에서 사용자 요청 수보다 외부 API 호출 수가 훨씬 많다는 점이 드러났습니다.


확인한 항목
- 내부 requestId 기준 외부 API 호출 횟수
- transactionId 기준 중복 호출 횟수
- http status code별 retry 발생 횟수
- timeout 발생 시점과 retry 간격
- 마지막 실패 원인이 사용자 응답에 어떻게 반영되는지

여기서 중요한 점은 retry 로그를 별도로 남겨야 한다는 것입니다. 단순히 "외부 API 호출 실패"만 남기면 최초 실패인지, 재시도 실패인지 구분하기 어렵습니다. retry count, requestId, transactionId, http status, elapsed time 정도는 함께 남기는 편이 좋습니다.

 

API retry 대안 비교: 무조건 retry보다 정책이 중요합니다

API retry 문제를 해결할 때 선택지는 하나가 아닙니다. 모든 실패를 즉시 retry하는 방식에서 벗어나, 요청 성격에 맞게 정책을 나누는 것이 중요합니다.

동기 retry

사용자 응답 전에 반드시 결과가 필요한 경우에는 동기 retry를 사용할 수 있습니다. 다만 횟수와 시간을 짧게 제한해야 합니다. 사용자를 오래 기다리게 하면서 내부 스레드를 붙잡는 방식은 유지보수 단계에서 문제가 되기 쉽습니다.


적합한 경우
- 사용자 화면에 즉시 결과가 필요한 조회성 API
- 짧은 지연으로 복구 가능한 일시적 오류
- 중복 호출 부작용이 없는 요청

비동기 재처리

결과를 즉시 확정하지 않아도 되는 작업은 비동기 재처리로 넘기는 편이 낫습니다. 예를 들어 알림 발송, 정산 보조 데이터 적재, 외부 상태 동기화 같은 작업은 사용자 요청 흐름에서 분리할 수 있습니다.

비동기 재처리는 실패를 숨기는 방식이 아닙니다. 실패한 작업을 별도 저장소에 남기고, 재처리 횟수와 상태를 관리해야 합니다. 실패 원인을 확인할 수 없는 비동기 처리는 나중에 더 큰 운영 부담이 됩니다.

circuit breaker

외부 API가 일정 수준 이상 실패하면 잠시 호출을 막는 방식도 필요합니다. 이것이 circuit breaker입니다. retry가 실패를 다시 시도하는 장치라면, circuit breaker는 실패가 확산되지 않도록 멈추는 장치에 가깝습니다.

외부 시스템 장애가 명확한 상황에서 계속 retry를 보내면 내부 서비스까지 같이 흔들립니다. 이때는 빠르게 실패 응답을 주거나, 대체 응답을 제공하거나, 나중에 재처리하도록 흐름을 바꾸는 편이 안전합니다.

 

Spring Boot에서 안전한 API retry 코드로 개선하기

Spring Boot에서 API retry를 구현할 때는 직접 for문을 쓰는 것보다 정책을 분리해서 관리하는 편이 좋습니다. retry 횟수, 대상 예외, backoff 전략, 로깅 방식을 명확히 분리해야 코드 의도가 드러납니다.

아래 예시는 외부 API 호출에서 일시적 오류만 제한적으로 재시도하는 형태입니다. 실제 프로젝트에서는 사용하는 HTTP client에 따라 예외 타입과 응답 처리 방식이 달라질 수 있습니다.


public PaymentStatus getPaymentStatus(String transactionId) {
    RetryPolicy retryPolicy = new RetryPolicy(
        3,
        Set.of(502, 503, 504, 429)
    );

    RuntimeException lastException = null;

    for (int attempt = 1; attempt <= retryPolicy.maxAttempts(); attempt++) {
        long startedAt = System.currentTimeMillis();

        try {
            return paymentClient.getStatus(transactionId);
        } catch (HttpClientException e) {
            if (!retryPolicy.canRetry(e.statusCode()) || attempt == retryPolicy.maxAttempts()) {
                throw e;
            }

            log.warn(
                "payment api retry. transactionId={}, status={}, attempt={}, elapsedMs={}",
                transactionId,
                e.statusCode(),
                attempt,
                System.currentTimeMillis() - startedAt
            );

            sleepWithBackoff(attempt);
            lastException = e;
        } catch (NetworkTimeoutException e) {
            if (attempt == retryPolicy.maxAttempts()) {
                throw e;
            }

            log.warn(
                "payment api timeout retry. transactionId={}, attempt={}",
                transactionId,
                attempt
            );

            sleepWithBackoff(attempt);
            lastException = e;
        }
    }

    throw lastException;
}

이 코드도 완성형은 아닙니다. 하지만 무조건 Exception을 잡고 반복하는 코드보다는 의도가 분명합니다. 어떤 http status를 retry할지 드러나고, timeout과 http 응답 오류를 구분하며, retry 로그도 남깁니다.

Retry-After 헤더를 무시하지 않기

429 Too Many Requests나 503 Service Unavailable 응답에서는 Retry-After 헤더가 내려올 수 있습니다. 이 값은 API 제공자가 다시 요청해도 되는 시점을 알려주는 신호입니다. 이 헤더를 무시하고 고정 간격으로 재시도하면 호출 제한 정책을 계속 건드릴 수 있습니다.


HTTP/1.1 429 Too Many Requests
Retry-After: 10

이 경우에는 최소 10초 이후 재시도하는 식으로 처리해야 합니다. 내부 정책상 사용자 요청에서 10초를 기다릴 수 없다면, 동기 retry가 아니라 비동기 재처리 대상으로 넘기는 것이 더 적절합니다.

 

idempotency key 없이 retry하면 중복 처리가 생깁니다

API retry에서 가장 조심해야 하는 영역은 부작용이 있는 요청입니다. 결제 승인, 환불 요청, 쿠폰 발급, 포인트 적립, 주문 생성 같은 API는 같은 요청이 두 번 처리되면 데이터가 어긋날 수 있습니다.

이런 API에는 idempotency key를 사용해야 합니다. 같은 idempotency key로 들어온 요청은 한 번만 처리하고, 이후 동일 요청은 기존 결과를 반환하도록 설계합니다.


POST /payments/approve
Idempotency-Key: order-20260530-0001

{
  "orderId": "order-20260530-0001",
  "amount": 15000
}

실무에서는 idempotency key를 단순 랜덤 값으로 만들면 안 되는 경우가 많습니다. 같은 비즈니스 요청을 식별할 수 있어야 하므로 주문 번호, 거래 번호, 요청 생성 시각, 요청 타입 등을 기준으로 설계합니다. 중요한 것은 retry할 때마다 키가 바뀌지 않아야 한다는 점입니다.

 

장애 이후 적용한 해결 방법

장애 이후에는 retry 로직을 단순히 줄이는 것이 아니라 정책 자체를 다시 정리했습니다. 먼저 외부 API별로 retry 가능 여부를 문서화했습니다. 같은 http API라도 조회 API와 변경 API는 다르게 다뤄야 하기 때문입니다.


정리한 기준
- 조회 API: 제한적 동기 retry 허용
- 생성 API: idempotency key 없으면 retry 금지
- 결제 승인 API: 거래 고유 키 기반으로만 retry
- 알림 API: 사용자 요청과 분리해 비동기 재처리
- 4xx 응답: 기본적으로 retry 제외
- 429 응답: Retry-After 기준 우선 적용

그다음 retry 로그 포맷을 통일했습니다. 로그에 attempt, maxAttempts, statusCode, exceptionType, elapsedMs, requestId, businessKey를 남기도록 했습니다. 장애가 났을 때 원인을 빠르게 좁히려면 로그가 사람이 읽을 수 있는 구조여야 합니다.

마지막으로 외부 API 호출부에 timeout을 명확히 설정했습니다. timeout 없이 retry만 있는 코드는 위험합니다. 연결 timeout, 읽기 timeout, 전체 요청 제한 시간을 나눠 잡고, retry까지 포함한 최대 대기 시간이 사용자 응답 기준을 넘지 않도록 조정했습니다.

 

API retry 적용 후 확인해야 할 지표

API retry를 고친 뒤에는 성공률만 보면 부족합니다. retry 덕분에 성공한 요청과 retry 때문에 지연된 요청을 나눠 봐야 합니다. 성공률이 좋아 보여도 사용자 응답 시간이 크게 늘었다면 좋은 개선이라고 보기 어렵습니다.


확인할 지표
- 외부 API 호출 수
- retry 발생 비율
- retry 후 성공 비율
- retry 후 최종 실패 비율
- http status code별 실패 비율
- timeout 유형별 발생 비율
- 사용자 요청 전체 응답 시간

개인적으로는 retry 후 성공 비율을 중요하게 봅니다. retry가 자주 발생하는데 최종 성공률이 낮다면 그 retry는 효과가 약한 정책입니다. 이 경우에는 재시도 횟수를 늘리는 것이 아니라 circuit breaker, 비동기 재처리, 사용자 안내 방식 변경을 검토해야 합니다.

 

API retry 로직을 만들 때 지켜야 할 기준

API retry는 실패를 덮기 위한 코드가 아닙니다. 일시적인 실패를 제한적으로 흡수하고, 복구 가능성이 없는 실패는 빠르게 드러내기 위한 정책입니다.

무조건 3회 retry는 기준이 아닙니다

"실패하면 3번 재시도"는 편하게 쓰기 좋은 규칙처럼 보입니다. 하지만 모든 API에 같은 횟수를 적용하면 문제가 생깁니다. 조회 API, 결제 API, 알림 API, 정산 API는 실패했을 때의 의미가 다릅니다.

retry 횟수는 기술 기준만으로 정할 수 없습니다. 사용자 응답 시간, 외부 API 제한 정책, 중복 처리 가능성, 실패 후 보정 가능성을 함께 봐야 합니다.

retry보다 timeout이 먼저입니다

timeout이 없는 상태에서 retry를 넣으면 실패 감지가 늦어집니다. 요청 하나가 오래 대기하고, 그 뒤에 다시 요청을 보내면 전체 처리 시간이 더 길어집니다. 그래서 API retry를 설계할 때는 connection timeout과 read timeout을 먼저 정해야 합니다.

timeout은 너무 짧아도 문제이고 너무 길어도 문제입니다. 외부 API의 평균 응답 시간, 지연 패턴, 사용자 요청 흐름을 보고 정해야 합니다. 내부 배치 작업과 사용자-facing API의 timeout 기준도 다르게 잡는 편이 좋습니다.

로그 없이 retry하지 않습니다

retry는 실패를 한 번 숨기는 효과가 있습니다. 최종적으로 성공하면 사용자는 문제를 모르고 지나갑니다. 그래서 내부에서는 retry가 얼마나 발생하는지 반드시 관찰할 수 있어야 합니다.

로그와 지표가 없으면 retry는 나중에 디버깅하기 어려운 코드가 됩니다. 장애가 난 뒤에야 "언제부터 이렇게 많이 재시도했지?"라고 찾기 시작하면 대응이 늦어집니다.

 

API retry 장애에서 남은 과제

retry 정책을 고쳤다고 모든 문제가 끝나지는 않았습니다. 외부 API 장애가 길어질 때 사용자에게 어떤 메시지를 보여줄지, 비동기 재처리 상태를 운영자가 어떻게 확인할지, 실패한 거래를 어떤 기준으로 보정할지까지 정리해야 했습니다.

특히 결제나 주문처럼 돈과 관련된 흐름에서는 retry보다 상태 관리가 더 중요합니다. 요청이 성공했는지 실패했는지 모호한 구간이 생기기 때문입니다. 이때는 외부 시스템의 최종 상태를 다시 조회하고, 내부 상태와 맞추는 보정 작업이 필요합니다.

팀 협업 관점에서는 API 연동 문서에 retry 정책을 함께 남기는 것이 도움이 됩니다. 단순히 URL, method, request, response만 적는 문서는 운영 중 필요한 판단 기준을 담기 어렵습니다. 어떤 오류를 재시도하고, 어떤 오류는 바로 실패 처리하며, 어떤 오류는 비동기 재처리로 넘길지까지 남겨야 합니다.

 

API retry 로직 정리

API retry는 네트워크 오류를 다룰 때 필요한 기능입니다. 하지만 모든 실패를 다시 시도하는 코드는 안전하지 않습니다. http status code, timeout 유형, API의 부작용 여부, idempotency key 유무를 기준으로 정책을 나눠야 합니다.

장애를 겪고 나면 retry를 넣는 것보다 retry하지 않아야 할 조건을 정하는 일이 더 중요하다는 것을 알게 됩니다. 재시도는 실패를 줄이는 도구가 될 수도 있지만, 잘못 설계하면 실패를 더 많이 만드는 코드가 됩니다.

실무에서 API retry를 설계한다면 다음 정도는 기본 기준으로 가져가는 편이 좋습니다.


API retry 체크리스트

- retry 대상 http status code를 명확히 정했는가
- 4xx 응답을 무조건 재시도하고 있지는 않은가
- connection timeout과 read timeout을 구분하는가
- retry 간격에 backoff와 jitter가 있는가
- 429, 503 응답의 Retry-After를 반영하는가
- 부작용이 있는 API에 idempotency key가 있는가
- retry 횟수와 최종 실패 원인을 로그로 남기는가
- 사용자 요청에서 기다릴 수 없는 작업을 비동기로 분리했는가
- 외부 API 장애 시 circuit breaker를 고려했는가

retry는 많이 할수록 안정적인 코드가 아닙니다. 필요한 실패에만 제한적으로 적용하고, 재시도해도 해결되지 않는 실패는 빠르게 분리해야 합니다. 그래야 http 네트워크 오류를 견디면서도 내부 서비스까지 흔들리지 않는 구조를 만들 수 있습니다.