API 시간 초과가 발생했을 때 주문이 실제로 처리되었는지 어떻게 알 수 있을까?
요약
API 호출 중 시간 초과가 발생했을 때 주문 처리 여부를 알기 어렵습니다. 이 문제를 해결하기 위해 모든 작업에 '멱등성 키(idempotency key)'를 부여하여, 재시도 시에도 동일한 처리가 되도록 보장해야 합니다. 데이터베이스 고유 제약 조건과 같은 쓰기 작업을 통해 성공 여부를 결정하는 것이 핵심입니다.
핵심 포인트
- 시간 초과는 서버 작업 완료 여부를 알려주지 않습니다.
- 모든 API 요청에 멱등성 키를 부여하여 재시도 안전성을 확보하세요.
- 데이터베이스의 고유 제약 조건으로 중복 처리를 방지해야 합니다.
- 재시도는 항상 안전하도록 백엔드를 설계해야 합니다.
고객이 주문하기를 클릭합니다.
스피너가 돌아가다가... 돌아가다가... 이윽고: 요청 시간 초과(Request timed out).
그래서 고객은 다시 클릭합니다.
이제 당신에게는 두 개의 주문, 두 번의 결제 처리, 그리고 한 분노한 지원팀 문의가 생겼습니다.
여기 불편한 진실이 있습니다. 시간 초과는 서버가 작업을 수행했는지 여부에 대해 아무것도 알려주지 않습니다.
- 시간 초과는 '답변 없음'을 의미할 뿐, '주문 없음'을 의미하지는 않습니다.
- 모든 작업에 **멱등성 키(idempotency key)**를 부여하세요: 클라이언트가 한 번 생성하고, 재시도 시마다 재사용합니다.
if검사가 아닌 **데이터베이스 고유 제약 조건(database unique constraint)**이 어떤 시도가 성공했는지 결정하게 하세요.- 주문과 그 응답을 **같은 쓰기 작업(same write)**으로 저장하세요.
- 알려진 키를 사용하여 재시도하면 새 주문을 생성하는 대신 원래의 응답을 받게 됩니다.
실제로 무슨 일이 일어났는가
Client Server Database
│ │ │
│── POST /orders ──────────►│ │
...
서버는 제 역할을 했습니다. 단지 _응답(response)_만 유실된 것입니다.
클라이언트의 관점에서 시간 초과는 다음 세 가지 중 하나를 의미할 수 있습니다:
- 요청이 서버에 도달하지 않았을 경우.
- 요청이 서버에 도달했지만 실패했을 경우.
- 요청은 성공했지만, 응답만 유실된 경우.
클라이언트는 어떤 상황인지 알 수 없습니다. 따라서 백엔드는 재시도하는 것이 항상 안전하도록 구축되어야 합니다.
한 문장으로 정리한 해결책
작업에 재시도 전반에 걸쳐 동일하게 유지되는 식별자를 부여하세요.
수표를 생각해 보세요. 만약 #1042번 수표가 두 번 나타나더라도, 은행은 두 번 지급하지 않습니다. 이 수표 번호는 아무리 많이 제시되더라도 '지불하려는 의도'를 식별합니다.
**멱등성 키(idempotency key)**는 API 요청에 대한 수표 번호입니다.
1단계: 클라이언트가 한 번 키를 생성한다
// 고객이 이 주문을 하겠다고 결정했을 때, 키를 ONCE 생성합니다.
const pendingOrder = {
key: crypto.randomUUID(),
...
⚠️ 가장 흔한 버그: submitOrder() 내부에서 키를 생성하는 것입니다. 그러면 재시도할 때마다 새 키가 생기고, 마치 완전히 새로운 주문처럼 보여서 전체 목적을 무효화합니다.
만약 재시도(retries)가 페이지 새로고침이나 앱 재시작을 견뎌야 한다면, 보류 중인 작업과 해당 키를 영구적으로 저장할 수 있는 곳에 기록해야 합니다. 메모리 내 변수는 페이지가 닫히면 사라지기 때문입니다.
단순히 중복 요청 본문만 감지하면 안 되는 이유
동일한 요청이 항상 동일한 의도(intent)를 의미하는 것은 아니기 때문입니다. 고객은 실제로 같은 키보드를 두 번 주문하고 싶을 수도 있습니다. Amazon Builders' Library는 이 점을 지적합니다: 호출자(caller)가 어떤 시도들이 함께 속하는지 명시해야 합니다.
API 계약
| 요청 | 동작 |
|---|---|
| 새 키 (New key) | 주문 생성 (Create the order) |
| ... | |
| 🔐 모든 시도, 재전송(replays)을 포함하여 인증 및 권한 부여를 수행해야 합니다. Idempotency key는 자격 증명(credential)이 아닙니다. |
2단계: 주문과 재전송 기록을 함께 저장하기
이 방법은 합리적으로 보이지만, 실제로는 문제가 있습니다:
await createOrder(input);
await saveIdempotencyResult(key, response); // 💥 여기서 충돌 발생 = 기록 없음
만약 이 두 줄 사이에서 프로세스가 충돌하면, 주문은 존재하지만 어떤 것도 해당 키를 기억하지 못합니다. 다음 재시도 시 두 번째 주문이 생성됩니다.
주문과 해당 키의 기록은 반드시 함께 커밋되거나, 아니면 아예 커밋되지 않아야 합니다.
가장 간단한 방법은 이들을 같은 행에 보관하는 것입니다:
CREATE TABLE orders (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL,
...
이 UNIQUE 제약 조건(constraint) 덕분에 다른 모든 것이 작동하게 됩니다.
(이 테이블은 주문 생성 작업에만 사용됩니다. 공유되는 idempotency 테이블을 구축하는 경우, 고유 키에 작업 이름을 추가하십시오.)
3단계: 데이터베이스가 승자를 결정하도록 맡기기
명백한 접근 방식은 동시성(concurrency) 상황에서 실패합니다:
요청 A: SELECT key → 찾을 수 없음
요청 B: SELECT key → 찾을 수 없음
요청 A: INSERT order ✅
...
먼저 확인하고 나중에 삽입하는 방식은 두 요청 모두가 빠져나갈 수 있는 간극(gap)을 남깁니다. 대신, 그냥 삽입을 시도하고 PostgreSQL의 고유 제약 조건이 어떤 것이 승자인지 결정하도록 맡기십시오.
import { randomUUID } from "node:crypto";
import { Pool } from "pg";
...
이 코드가 하는 일 (쉽게 설명)
- 삽입 시도. 고유 제약 조건(unique constraint)은 고객과 키당 최대 한 행만 보장합니다.
- 삽입됨? 새로운 주문이므로
201을 반환합니다. - 삽입되지 않음? 이미 누군가 이 키를 사용했으므로, 저장된 내용을 읽어옵니다.
- 다른 페이로드?
409를 반환합니다. 해당 키가 다른 요청에 재사용되었기 때문입니다. - 같은 페이로드? 원래 응답을 반환합니다. 고객은 처음과 동일한 주문 ID를 보게 됩니다.
💡 많은 API는 또한 클라이언트가 재생(replay)인지 신규 생성인지를 알 수 있도록 Idempotent-Replayed: true와 같은 응답 헤더도 추가합니다.
별도의 SELECT를 사용하는 이유?
PostgreSQL의 기본 READ COMMITTED 격리 수준(isolation) 하에서는, ON CONFLICT DO NOTHING이 INSERT 문 자체로는 볼 수 없는 행에 의해 차단될 수 있습니다. 새로운 문은 신선한 스냅샷을 가져오기 때문에 해당 행을 볼 수 있습니다. PostgreSQL 문서에서 자세한 내용을 설명합니다.
여러 테이블에 쓰기?
관련된 모든 쓰기 작업을 하나의 명시적 트랜잭션(transaction)으로 묶으세요. node-postgres를 사용할 경우, 해당 트랜잭션의 모든 문은 풀(pool)에서 가져온 동일한 클라이언트를 사용해야 하며, pool.query()를 사용해서는 안 됩니다.
또한 제한된 데이터베이스 타임아웃을 설정하세요. 경쟁하는 요청을 기다리는 요청이 영원히 리소스를 점유해서는 안 됩니다.
이것이 보장하는 것과 그렇지 않은 것
✅ 보장되는 것: 주어진 고객과 키에 대해, 해당 키가 저장되어 있는 동안 최대 하나의 주문만 생성됩니다.
❌ 보장되지 않는 것:
- 고객이 응답을 받았는지 여부
- 결제가 성공했는지 여부
- 재고가 예약되었는지 여부
- 확인 이메일이 전송되었는지 여부
- 두 다른 키가 실제로 두 개의 다른 구매를 나타내는지 여부
재생(replay)은 원래의 생성 응답을 반환합니다. 만약 클라이언트가 주문의 현재 상태, 예를 들어 배송 중이거나 취소된 상태가 필요하다면, 그것은 별도의 GET /orders/:id 요청입니다.
데이터베이스 외부의 부작용
PostgreSQL 트랜잭션은 결제 제공업체로의 HTTP 호출을 포함할 수 없습니다. 카드를 청구한 후 충돌이 발생하면 데이터베이스는 그 청구에 대해 알지 못합니다.
일반적인 해결책은 트랜잭셔널 아웃박스 패턴 (transactional outbox pattern)입니다:
┌───────── 하나의 DB 트랜잭션 ─────────┐
│ INSERT order │
│ INSERT outbox_event (charge card) │
...
워커는 이벤트를 한 번 이상 전달할 수 있으므로, 소비자(consumer) 역시 중복 제거(deduplicate)해야 합니다. 모든 과정에서 아이덴티티(idempotency)가 중요합니다.
결제와 관련해서는 안정적인 결제 작업 ID를 사용하여 제공업체 자체의 아이덴티티 메커니즘을 사용하세요. 제공업체 호출이 시간 초과되면, 다시 시도하기 전에 결제의 상태를 확인하거나 조정(reconcile)해야 합니다.
만료되는 키가 보장성을 변경합니다
위 예시는 키를 영구적으로 유지합니다. 만약 이들을 별도의 테이블로 옮기고 오래된 것들을 삭제한다면, 매우 늦은 재시도(retry)는 마치 새것처럼 보이게 할 수 있습니다.
예를 들어 Stripe는 키가 최소 24시간이 지나면 제거될 수 있다고 문서화합니다. 이것은 Stripe의 계약일 뿐, 보편적인 규칙은 아닙니다.
경험적 규칙: 키는 가장 긴 현실적인 재시도 기간만큼은 유지해야 합니다. 비즈니스에서 영구적인 고유성이 필요하다면, 만료되는 키 저장소와 별개로 체크아웃 ID와 같은 지속 가능한(durable) 비즈니스 ID를 강제하세요.
위험한 경우를 테스트합니다
| 시나리오 | 예상 결과 |
|---|---|
| 동일한 키에 대한 두 개의 동시 요청 | 하나의 주문; 둘 다 성공 응답을 받음 |
| ... | |
| 가장 중요한 테스트는 응답 손실(lost response)입니다: 데이터베이스 커밋을 수행하고, 응답이 도착하기 전에 연결을 끊은 다음, 재시도해 보세요. 만약 두 번째 주문이 발생한다면, 프로덕션 트래픽에 의해 발견되기를 기다리는 버그가 있다는 의미입니다. |
체크리스트
- ✅ 작업당 한 번만 생성되어 재시도 시마다 재사용되는 키
- ✅ 재시도가 로드 또는 재시작을 생존해야 하는 경우 키가 영속화됨
- ✅ 데이터베이스에
UNIQUE (customer_id, idempotency_key)적용 - ✅ 비즈니스 쓰기 및 리플레이 응답이 원자적으로(atomically) 커밋됨
- ✅ 동일한 키와 다른 입력은
409를 반환함 - ✅ 재전송을 포함하여 모든 시도마다 인증 확인
- ✅ 외부 부작용은 Idempotent Consumer가 있는 Outbox를 통해 처리됨
- ✅ 키 유지 기간이 실제 재시도 창을 포괄함
- ✅ 응답 손실 케이스는 테스트로 커버됨
시간 초과는 답변이지, 질문이 아닙니다. 귀하의 API는 그 질문에 답할 수 있어야 합니다.
클라이언트가 첫 번째 시도가 성공했는지 여부를 알 수 없을 때, 귀하의 API는 원래 결과를 안전하게 반환할 수 있습니까?
현재 팀에서는 재시도를 어떻게 처리하고 계신가요? 댓글로 알려주세요. 👇
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기