결제 외부 연동 재시도 개선과 수령 ID 분리로 추적 안정화
목차
v3.1 배포하면서 오래 미뤄뒀던 결제 플랫폼 외부 연동 모듈을 손봤다. 새벽 시간대 외부 API 호출 실패율이 튀는 문제랑, 수령 결과 콜백에서 메시지 ID 구분이 안 돼서 추적이 안 되던 이슈, 두 개를 한 번에 처리함. 각각 따로 보면 PR 하나짜리 작업처럼 보이는데, 파고들면 연동 설계 판단이 엮여있는 류의 작업이었다.
재시도 로직, 뭐가 문제였나
기존 API 클라이언트의 재시도가 너무 단순했음. 고정 간격 1초, 최대 4회, 모든 예외에 동일하게 적용. 이 구조가 두 경로로 문제를 만들었다.
첫째, 4xx 응답까지 재시도하고 있었다. 400 Bad Request든 401이든 404든 예외 발생하면 무조건 재시도 큐에 들어갔다. 잘못된 요청을 3번 더 때리고 똑같이 실패하는 동안 커넥션이 풀에 묶이고, 그 사이에 정상 요청들이 대기에 쌓임.
둘째, 외부 throttle 타이밍이랑 간격이 안 맞았다. 새벽 피크 때 외부 측이 일정 간격으로 throttle을 거는데, 우리가 1초 고정으로 3번 밀어넣으면 다 죽는다. 운영 로그 보면 attempt 1, 2, 3이 줄줄이 503 받고 마지막 attempt에서 타임아웃. 재시도가 상황을 살리는 게 아니라 악화시키고 있었다는 게 지표로 드러남.
고친 방향은 두 가지 - 무엇을 재시도하지 않을지 명확히 끊는 것, 그리고 간격을 외부 상황에 맞게 분산하는 것.
| 항목 | Before | After |
|---|---|---|
| 재시도 트리거 | 모든 예외 | 5xx + 타임아웃 + 커넥션 에러만 |
| 백오프 | 1초 고정 × 3 | 지수 백오프 + jitter (0.5~3.5s) |
| 최대 시도 | 4회 | 3회 (감소) |
| 4xx 처리 | 재시도 | 즉시 fail-fast |
4xx는 재시도 대상에서 완전히 잘랐다. 클라이언트 요청 자체가 잘못되었거나, 인증이 만료되었거나, 리소스가 없는 경우인데 - 어느 쪽이든 같은 요청을 다시 보내서 해결되는 게 아니다. 즉시 실패로 처리하고 호출 측에 오류를 돌려줌으로써, 예상 못 했던 보너스로 평균 응답 시간도 같이 내려왔다.
지수 백오프에 jitter를 넣는 이유는, 여러 동시 요청이 같은 시점에 죽었다가 같은 간격 뒤에 동시에 깨어나는 걸 막기 위해서다. jitter 없는 지수 백오프는 thundering herd를 더 넓은 주기로 반복할 뿐임. 교과서에 있는 내용이지만 실제 로그로 패턴 보면 다르게 체감된다.
# Before: 1초 고정, 4회
attempt=1 t=0.000s status=503
attempt=2 t=1.001s status=503
attempt=3 t=2.003s status=503
attempt=4 t=3.005s timeout
# After: 지수 백오프 + jitter, 3회
attempt=1 t=0.000s delay=0ms status=503
attempt=2 t=0.820s delay=820ms status=503
attempt=3 t=2.970s delay=2150ms status=200 ← 살아남음
코드 패턴은 재시도 여부 판단과 백오프 계산을 분리해서 각각 단위 테스트하기 쉽게 뽑아뒀음. 이 모듈은 외부 의존성을 최소화하는 기조라 직접 구현했는데, tenacity나 stamina 쓰는 프로젝트라면 이 로직을 직접 짤 필요는 없다.
def should_retry(exc, status_code=None):
if status_code and 400 <= status_code < 500:
return False # 4xx: 즉시 실패
if isinstance(exc, (TimeoutError, ConnectionError)):
return True
if status_code and status_code >= 500:
return True
return False
def backoff_delay(attempt, base=0.5, cap=4.0):
exp = min(base * (2 ** attempt), cap)
return exp * (0.5 + random.random() * 0.5) # ±50% jitter
지표상 5xx 재시도 성공률이 18%에서 41%로 올라왔다. PR 올릴 때 이 수치 하나 넣었더니 리뷰 논의가 절반으로 줄었음. 왜 고정 간격 대신 이렇게 짰는지 설명할 게 없어지는 것.
메시지 ID 구분 — 추적 안 되던 이유
메신저 플랫폼 수령 결과 콜백을 받는 모듈에서, 발송 ID랑 수령 결과 ID를 같은 컬럼에 저장하고 있었다. 외부에 발송 요청을 보낼 때 우리가 생성한 dispatchId가 그대로 수령 결과 콜백으로 돌아오는 줄 알았는데, 실제로는 외부 플랫폼이 결과 처리 단계에서 receiveResultId를 새로 발급해서 돌려주고 있었던 거다.
두 ID의 의미가 다르다:
dispatchId- 발송 요청을 보낼 때 우리가 생성해서 외부에 전달하는 식별자. 발송 단계 추적용.receiveResultId- 외부 플랫폼이 결과 처리 완료 시점에 새로 발급해서 콜백으로 보내오는 ID. 결과 단계 추적용.
평상시엔 문제가 없어 보임. 성공 케이스는 발송 ID만 알아도 추적이 되니까. 실패 케이스 분석할 때 "어느 ID로 조회하고 있는 건지" 모호해지는 게 문제였다. 외부 측 대시보드에서는 receiveResultId로 조회해야 하는데, 우리 DB에는 둘이 같은 컬럼에 섞여있으니 실패 재현할 때마다 어느 쪽인지 따로 확인해야 했음.
외부 API 연동할 때 이 패턴은 생각보다 흔하다. 결제 게이트웨이의 주문 ID와 트랜잭션 ID가 다른 것처럼, 요청 ID와 결과 ID를 처리 단계별로 따로 관리하는 시스템이 많음. 연동 초기에 "같은 ID겠지"라는 가정을 하고 넘어가면 나중에 이 부채를 갚아야 한다.
수령 처리 서비스에서 두 컬럼으로 분리해서 저장하도록 스키마랑 매핑 레이어 둘 다 손봤음. 마이그레이션은 기존 행은 dispatchId로 간주하고 receiveResultId 컬럼은 NULL로 두는 방식. 과거 데이터를 역으로 채우려면 외부에 대량 조회를 해야 하는데, 비용 대비 효용이 없다고 판단해서 신규 데이터부터 양쪽 다 채워지도록 했다. 콜백 파싱 쪽에서 필드를 명시적으로 구분해서 저장하도록 바꾸는 게 생각보다 손이 좀 갔는데, 기존 코드가 콜백 응답 전체를 하나의 ID 필드에 우겨넣는 구조여서 거기서 시간을 좀 씀.
작업 돌아보면, 재시도 로직에서 제일 중요한 건 "무엇을 재시도할지" 결정보다 "무엇을 재시도하지 않을지" 먼저 잘라내는 거였다. fail-fast 케이스를 먼저 정의하고 그 외를 재시도로 보내는 순서가 안전함. 조건을 추가하는 건 나중에 얼마든지 할 수 있지만, 잘못된 재시도가 만들어낸 cascading 효과는 수습이 훨씬 어렵다.
ID 분리는 솔직히 진작에 했어야 하는 작업이었음. 연동 초기에 문서 제대로 안 읽고 가정으로 넘어간 부채가 수개월 동안 실패 추적할 때마다 조금씩 손해를 만들어온 거다. 이런 건 발견하는 순간 바로 끊는 게 맞다. 그리고 외부 시스템이 단계별로 별도 ID를 발급할 가능성은 연동 문서에서 각 콜백 필드의 출처를 먼저 확인하는 습관으로 예방할 수 있다.
댓글 0
첫 댓글 달아줘.