콘텐츠로 이동

재시도 처리

외부 API는 실패합니다. 네트워크는 흔들립니다. 벤더에게도 안 좋은 날이 있습니다. api-log는 모든 재시도 시도를 볼 수 있게 해줍니다 — 다만 어떻게 볼 수 있는지는 재시도가 HTTP 레이어(당신의 코드)에서 일어나는지 로그 쓰기 레이어(리스너)에서 일어나는지에 따라 다릅니다.

두 가지 재시도 레이어, 두 가지 이야기

레이어 누가 재시도 api_log에 보임?
HTTP 호출 (벤더로 가는 외부 요청) 당신의 코드 (@Retryable, Resilience4j, 직접 작성한 루프) 이벤트를 직접 발행할 때만 가능 — 아래 참고. 번들 RestApiClientUtil은 아직 재시도 컨텍스트를 전파하지 않음.
로그 쓰기 (리스너의 api_log INSERT) ApiEventListener 자체 — @Retryable(maxAttempts=3, backoff=1s) 투명. 실패한 로그 쓰기는 자동 재시도; 세 번 다 실패하면 리스너가 포기 (앱은 계속 동작).

이 가이드는 주로 HTTP 호출 레이어를 다룹니다.

주의: RestApiClientUtil과 HTTP 재시도

RestApiClientUtil 호출을 @Retryable로 감싸면:

@Retryable(retryFor = HttpServerErrorException.class, maxAttempts = 3)
public ChargeResult charge(ChargeRequest req) {
    return api.postSyncTyped("/charges", req, ChargeResult.class);  // ← 번들 클라이언트
}

…재시도는 동작하지만 api_log 행들이 시도 간 상관관계가 없습니다:

  • 호출마다 request_id (UUID) 생성됨. 시도 1의 행들과 시도 2의 행들이 서로 다른 correlation key 가짐.
  • 에러 행은 모두 retry_count = 0, is_retry = false (재시도 컨텍스트가 전달되지 않음).

재시도 타임라인 가시성이 중요하다면 그 호출은 RestApiClientUtil 대신 이벤트를 직접 발행하세요 (아래).

(RestApiClientUtil에 재시도 컨텍스트를 통합하는 건 로드맵에 있습니다 — Contributing 참고.)

HTTP 재시도 추적 — 이벤트 직접 발행

requestId를 당신이 소유하므로 시도 간 재사용 가능합니다. 깔끔한 재시도 타임라인을 얻는 지원되는 방법:

@Service
@RequiredArgsConstructor
public class FlakyVendorClient {

    private final ApplicationEventPublisher publisher;
    private final HttpClient http;   // 자체 HTTP 클라이언트

    public Result call(Request input) {
        ApiRequest req = ApiRequest.builder()
                .endpoint("/vendor/api")
                .payload(input.toJson())
                // 모든 재시도에서 같은 requestId — 이게 correlation key.
                .requestId(UUID.randomUUID().toString())
                .build();

        Exception lastError = null;

        for (int attempt = 0; attempt < 3; attempt++) {
            boolean isRetry = attempt > 0;
            publisher.publishEvent(new ApiCallInitiatedEvent(this, req));

            try {
                Result result = doHttpCall(req);
                publisher.publishEvent(new ApiCallSuccessEvent(this, req,
                    ApiResponse.builder().data(result.toJson()).statusCode(200).build()));
                return result;
            } catch (Exception e) {
                lastError = e;
                // retryCount = attempt; 1, 2번째 시도에서 isRetry = true
                publisher.publishEvent(new ApiCallErrorEvent(this, req, e, attempt, isRetry));
                if (attempt < 2) sleep(backoff(attempt));
            }
        }

        throw new RuntimeException("vendor unreachable after 3 attempts", lastError);
    }
}

두 번 실패하고 세 번째에 성공한 호출의 결과 — request_id로 상관된 여섯 행:

 id | event_type   | request_id  | retry_count | is_retry | status_code
----+--------------+-------------+-------------+----------+-------------
  6 | SUCCESS      | abc-...     |           0 | false    |         200
  5 | INITIATED    | abc-...     |           0 | false    |
  4 | RETRY_ERROR  | abc-...     |           1 | true     |         503
  3 | INITIATED    | abc-...     |           1 | true     |
  2 | RETRY_ERROR  | abc-...     |           0 | false    |         503
  1 | INITIATED    | abc-...     |           0 | false    |

타임라인 조회:

SELECT event_type, retry_count, status_code, timestamp
FROM api_log
WHERE request_id = 'abc-...'
ORDER BY id;

로그 쓰기 회복력 — ApiEventListener가 해주는 것

PostgreSQL 연결이 INSERT 도중 흔들려도, 단 한 번의 로그 행 손실로 트리거 요청 자체가 죽지 않도록 해야 합니다. ApiEventListener가 처리:

@EventListener
@Async
@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000))
public void onApiCallInitiated(ApiCallInitiatedEvent event) {
    apiLogService.saveApiCallInitiated(event);
}

세 저장 메서드(onApiCallInitiated, onApiCallSuccess, onApiCallError)에 각각 @Retryable(maxAttempts=3, backoff=1s) 적용. 저장이 던지면 Spring Retry가 리스너를 최대 3회 1초 백오프로 재시도. 소진되면 ERROR 레벨로 로깅하고 앱은 계속 — 비즈니스 경로는 불안정한 로그 테이블에 영향받지 않음.

투명함 — 설정 필요 없음. 이게 운영에서 켜둘 수 있게 만드는 핵심.

자주 쓰는 쿼리

재시도 끝에 성공한 호출:

SELECT request_id, endpoint, MAX(retry_count) AS attempts_before_success
FROM api_log
WHERE request_id IN (SELECT request_id FROM api_log WHERE event_type = 'SUCCESS')
  AND request_id IN (SELECT request_id FROM api_log WHERE event_type = 'RETRY_ERROR')
GROUP BY request_id, endpoint;

재시도 모두 소진하고 실패한 호출:

SELECT endpoint, request_id, MAX(retry_count) AS final_attempt, MAX(timestamp) AS gave_up_at
FROM api_log
WHERE request_id NOT IN (SELECT request_id FROM api_log WHERE event_type = 'SUCCESS')
  AND event_type = 'RETRY_ERROR'
GROUP BY endpoint, request_id
ORDER BY gave_up_at DESC;

엔드포인트별 재시도율 (최근 24h):

SELECT endpoint,
       COUNT(*) FILTER (WHERE event_type = 'RETRY_ERROR') AS retries,
       COUNT(*) FILTER (WHERE event_type IN ('SUCCESS','ERROR')) AS terminals
FROM api_log
WHERE timestamp > NOW() - INTERVAL '24 hours'
GROUP BY endpoint
HAVING COUNT(*) FILTER (WHERE event_type = 'RETRY_ERROR') > 0
ORDER BY retries DESC;

같이 보기