Cheondi
개발 · API

API 워크플로와 재시도 경계

한 번의 HTTP 응답이 아니라 생성, 외부 처리, callback, requery까지 이어지는 API 흐름과 안전한 재시도를 정리한 기록입니다.

  • #api
  • #arazzo
  • #idempotency
  • #retry
  • #gateway

API 문서를 읽을 때 보통 endpoint부터 본다. request field와 response field를 맞추고 성공 코드를 받으면 첫 연결은 끝난다. 그런데 운영에서 오래 남는 문제는 한 번의 호출보다 그 다음 단계에서 생겼다.

요청은 성공했지만 외부 처리가 아직 진행 중일 수 있고, callback이 늦게 오거나 두 번 올 수도 있다. client는 timeout을 받았는데 server에서는 이미 요청을 만들었을 수 있다. 이때 같은 POST를 다시 보내도 되는지, 조회 API를 먼저 불러야 하는지 문서의 endpoint 목록만으로는 판단하기 어렵다.

Arazzo 1.1.0, Idempotency-Key Internet-Draft, Gateway API의 retry budget을 같이 읽으면서 API를 요청 하나가 아니라 상태가 움직이는 워크플로로 보는 방법을 정리했다.

Arazzo Workflow — API 호출 사이의 관계를 문서화하는 방법

OpenAPI는 각 endpoint의 입력과 출력을 설명하는 데 강하다. Arazzo는 여러 호출을 어떤 순서로 실행하고 앞 단계 결과를 다음 입력에 어떻게 쓰는지, 어느 조건에서 workflow가 성공하는지를 표현한다.

예를 들어 생성 요청 하나만 보면 간단하다.

POST /orders HTTP/1.1
Host: api.example.com
Content-Type: application/json

{"amount":"10.00","currency":"USD"}

실제 업무 흐름은 그보다 길 수 있다.

사용자 확인
  → 내부 주문 생성
  → 외부 처리 요청
  → redirect 또는 대기 화면
  → callback 수신
  → 필요하면 상태 requery
  → 내부 최종 상태 확정
  → 화면과 운영 목록 반영

이 흐름에서는 각 단계의 owner도 다르다. client가 redirect를 담당하고, API가 내부 transaction을 만들며, gateway가 외부 요청과 signature를 처리할 수 있다. callback은 별도 handler로 들어오고 운영 화면은 최종 상태를 다시 조회한다.

Arazzo 형식을 그대로 도입하지 않더라도 stepId, 입력의 출처, success criteria, 다음 단계라는 관점은 유용하다. 문서에 happy path만 그리지 않고 timeout, 취소, callback 누락, 중복 callback도 같은 workflow 안에 넣을 수 있기 때문이다.

Idempotency-Key — 응답을 못 받았을 때의 계약

HTTP 요청을 보낸 뒤 연결이 끊기면 client가 아는 사실은 “응답을 받지 못했다”뿐이다. server가 실행하지 않았는지, 실행 중인지, 이미 끝냈는지는 모른다. 이 상태에서 POST를 새로 보내면 같은 작업이 두 번 만들어질 수 있다.

Idempotency-Key는 같은 업무 요청의 재시도라는 사실을 server에 전달하는 방법이다.

POST /payments HTTP/1.1
Host: api.example.com
Idempotency-Key: 4c60d3b1-example
Content-Type: application/json

{"order_id":"example-123","amount":"10.00"}

server는 키와 요청 fingerprint, 처리 상태, 결과를 일정 기간 저장할 수 있다. 같은 키와 같은 payload가 다시 오면 새 작업을 만들지 않고 기존 결과를 반환한다. 같은 키인데 payload가 다르면 client 오류로 거절해야 한다.

idempotency_key
  request_fingerprint
  processing_status
  resource_id
  response_status
  expires_at

여기서 참고한 IETF 문서는 2026년 4월에 만료된 Internet-Draft다. 확정 표준이라고 쓰면 안 된다. 대신 키의 유일성, 만료, 동시 요청, fingerprint 충돌처럼 구현 전에 답해야 할 질문을 잘 모아둔 작업 문서로 볼 수 있다.

키가 있다고 모든 문제가 끝나는 것도 아니다. server가 작업을 시작하기 전에 키를 저장하지 않으면 동시에 들어온 두 요청이 모두 실행될 수 있다. 외부 provider 요청까지 전달한 뒤 프로세스가 죽으면 내부 idempotency record와 외부 상태가 어긋날 수도 있다. 이때는 provider reference로 requery할 수 있는 경로가 필요하다.

Retry Budget — 각 요청의 재시도와 전체 트래픽의 재시도

재시도 정책을 만들 때 보통 최대 3회, 1초 뒤 재시도처럼 한 요청을 기준으로 생각한다. 하지만 장애가 동시에 많은 요청에 영향을 주면 모든 instance가 세 번씩 다시 보내면서 실패 중인 서비스에 더 큰 부하를 줄 수 있다.

Gateway API v1.3에서 소개한 retry budget은 전체 요청량 대비 허용할 재시도 비율과 최소 재시도 수를 함께 제한한다. 개별 요청의 최대 횟수만이 아니라 시스템 전체에서 실패가 얼마나 증폭될 수 있는지를 보는 방식이다.

# 개념을 줄인 예시이며 실제 Gateway API 전체 필드는 아니다.
retry:
  attempts: 3
  backoff: exponential
  budget:
    percent: 10
    minimum: 5

업무 API에서는 모든 오류를 재시도 대상으로 두지 않는 것도 중요하다.

결과 기본 판단
입력 검증 실패 같은 요청 재시도 안 함
인증·권한 실패 자격 증명 상태 확인 후 중단
연결 전 timeout idempotency 계약이 있으면 제한적으로 재시도
응답 전 timeout 기존 상태 조회를 우선
명확한 일시 오류 backoff와 budget 안에서 재시도
알 수 없는 외부 상태 자동 반복보다 requery 또는 운영 확인

재시도는 성공 가능성을 높이는 기능이면서 중복 실행과 장애 증폭을 만드는 기능이기도 하다. 어느 상태에서 안전한지 증명되지 않으면 자동화하지 않는 편이 낫다.

Failure Boundary — HTTP 성공과 업무 성공 사이

비동기 API의 상태는 한 줄로 끝나지 않는다.

NEW → REQUESTED → PENDING → SUCCEEDED
                  ├──────→ FAILED
                  └──────→ EXPIRED

HTTP 200은 REQUESTED로 이동했다는 뜻일 수 있고 최종 SUCCEEDED를 보장하지 않는다. 반대로 client가 504를 받았어도 외부 시스템에서는 성공했을 수 있다. 그래서 내부 상태, 외부 reference, 마지막 요청 시각, callback 처리 결과를 따로 남겨야 한다.

내가 API workflow를 설계할 때 먼저 확인하고 싶은 질문은 다음과 같다.

  1. 최초 요청을 구분하는 내부 ID와 idempotency key는 무엇인가?
  2. 외부 시스템 reference는 어느 시점에 저장되는가?
  3. callback과 requery가 동시에 도착하면 누가 상태를 확정하는가?
  4. 이미 최종 상태인 transaction에 늦은 callback이 오면 어떻게 하는가?
  5. 사용자는 pending, failed, unknown을 어떻게 구분해서 보는가?
  6. 운영자는 raw 응답 없이도 어느 단계에서 멈췄는지 알 수 있는가?

상태 갱신은 허용되는 전이를 명시하는 편이 안전하다.

func canMove(from, to Status) bool {
    allowed := map[Status]map[Status]bool{
        Requested: {Pending: true, Failed: true},
        Pending:   {Succeeded: true, Failed: true, Expired: true},
    }
    return allowed[from][to]
}

실제 코드에는 동시성 제어, provider별 상태 mapping, 감사 정보가 더 필요하다. 그래도 현재 값과 새 값이 다르면 update하는 것보다 허용 전이를 먼저 적으면 늦은 callback이 성공을 실패로 되돌리는 문제를 막기 쉽다.

Arazzo, Idempotency-Key, retry budget은 서로 다른 층의 기술이다. 하나는 workflow를 표현하고, 하나는 같은 업무 요청을 식별하며, 하나는 실패가 트래픽으로 증폭되는 범위를 제한한다. 같이 읽으니 API 연동에서 빠졌던 부분이 더 잘 보였다.

다음 API를 붙일 때는 성공 response 예시만 정리하지 않고 요청 전 상태, 외부 처리, callback, 조회, 재시도, 최종 상태까지 한 장에 이어서 그려보려고 한다. 그 흐름을 설명할 수 있어야 timeout 뒤에 무엇을 해야 하는지도 코드로 정할 수 있다.


참고한 글과 코드