멱등적인 HTTP 메서드가 재시도에 중요한 이유
멱등적인 HTTP 메서드가 재시도에 중요한 이유
GET과 PUT, DELETE는 의미상 멱등적이어야 한다. POST는 기본적으로 그렇지 않지만 idempotency key와 결과 저장으로 특정 작업을 멱등하게 만들 수 있다.
목차
- #왜 이 문제가 생기는가
- #멱등성은 응답이 같다는 뜻이 아니다
- #HTTP 메서드별 의미
- #응답을 잃어버린 결제 요청
- #POST를 idempotency key로 보호하기
- #서버에서 중복 요청을 처리하는 방법
- #동시 요청과 원자성
- #재시도 정책까지 함께 설계하기
- #운영에서 확인할 것
- #결론
- #관련 노트
왜 이 문제가 생기는가
클라이언트가 결제 요청을 보낸 뒤 응답을 받기 전에 네트워크가 끊겼다고 하자. 화면에는 실패가 표시되지만 서버가 결제를 처리했는지는 알 수 없다. 사용자가 다시 버튼을 누르거나 클라이언트가 자동 재시도하면 결제가 두 번 생성될 수 있다.
분산 환경에서는 “요청이 실패했다”와 “서버 작업이 실행되지 않았다”가 같은 말이 아니다. 멱등성은 이런 불확실성 속에서 같은 의도의 요청을 반복해도 최종 상태가 중복되지 않도록 만드는 핵심 성질이다.
주문과 결제 코드는 멱등 처리 구조를 설명하기 위한 가상 예제다. 실제 결제사 연동이나 프로젝트 코드를 복사하지 않았다.
멱등성은 응답이 같다는 뜻이 아니다
연산이 멱등하다는 것은 같은 연산을 여러 번 적용한 최종 상태가 한 번 적용한 상태와 같다는 뜻이다.
f(f(x)) = f(x)
예를 들어 사용자 42의 표시 이름을 Mina로 설정하는 요청을 여러 번 보내도 최종 이름은 Mina다.
PUT /profiles/42 HTTP/1.1
Content-Type: application/json
{"displayName":"Mina"}
반대로 잔액에 1,000원을 추가하는 요청은 반복할수록 상태가 달라진다.
POST /wallets/42/credits HTTP/1.1
Content-Type: application/json
{"amount":1000}
멱등하더라도 매번 응답 코드와 로그까지 같아야 하는 것은 아니다. 첫 DELETE는 204, 이미 사라진 리소스에 대한 두 번째 DELETE는 404를 줄 수 있지만 서버의 최종 리소스 상태는 “없음”으로 같다.
HTTP 메서드별 의미
HTTP 메서드 자체가 구현을 자동으로 안전하게 만들지는 않지만 클라이언트와 중간 계층이 기대할 수 있는 의미를 제공한다.
| 메서드 | 의미상 멱등성 | 예 |
|---|---|---|
GET, HEAD |
O | 리소스를 조회한다 |
PUT |
O | 지정 URI의 표현을 교체·설정한다 |
DELETE |
O | 지정 리소스가 없는 상태로 만든다 |
POST |
기본적으로 X | 새 처리나 하위 리소스를 생성한다 |
PATCH |
패치 의미에 따라 다름 | 값을 설정할 수도, 증가시킬 수도 있다 |
다음 두 PATCH는 성격이 다르다.
{ "operation": "set", "status": "archived" }
{ "operation": "increment", "viewCount": 1 }
첫 연산은 같은 상태를 설정하도록 만들 수 있지만 두 번째는 반복할 때마다 값이 증가한다. 메서드 이름보다 실제 도메인 연산을 봐야 한다.
링크 미리보기, 검색 봇, 브라우저 재요청은 GET을 반복할 수 있다. GET /orders/42/cancel처럼 상태 변경을 GET으로 구현하면 사용자의 명시적 의도 없이 작업이 실행될 수 있다.
응답을 잃어버린 결제 요청
불확실한 실패는 다음 순서로 발생한다.
sequenceDiagram
participant C as Client
participant A as Payment API
participant D as Database
C->>A: POST /payments
A->>D: 결제 레코드 생성
D-->>A: commit 성공
A--xC: 201 응답이 네트워크에서 유실
Note over C: 처리 여부를 알 수 없음
C->>A: 같은 의도의 POST 재시도클라이언트가 받은 정보만으로 첫 요청이 실행되지 않았다고 단정할 수 없다. timeout, 연결 종료, gateway 502 모두 하위 서버의 처리 결과와 정확히 일치하지 않을 수 있다.
POST를 idempotency key로 보호하기
생성 작업인 POST도 클라이언트가 같은 논리 요청에 같은 idempotency key를 보내고 서버가 처리 결과를 기억하면 중복을 방지할 수 있다.
POST /payments HTTP/1.1
Content-Type: application/json
Idempotency-Key: 7fc2a4a2-0b94-47a9-ae43-example
{"orderId":"order-501","amount":32000,"currency":"KRW"}
재시도할 때는 새 키를 만들지 않고 같은 키를 사용한다.
async function createPayment(command) {
const key = crypto.randomUUID();
return retryWithBackoff(() =>
fetch("/payments", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(command),
}),
);
}
사용자가 별개의 결제를 새로 시작했다면 새 키를 발급한다. 키의 범위는 사용자 또는 가맹점 같은 주체와 함께 묶어, 다른 사용자가 우연히 같은 문자열을 사용해도 충돌하지 않게 한다.
서버에서 중복 요청을 처리하는 방법
서버는 키만 저장해서는 부족하다. 같은 키가 어떤 요청을 의미했는지, 현재 처리 상태와 반환할 결과가 무엇인지 관리해야 한다.
CREATE TABLE idempotency_requests (
owner_id BIGINT NOT NULL,
request_key VARCHAR(80) NOT NULL,
request_hash CHAR(64) NOT NULL,
status VARCHAR(16) NOT NULL,
response_code INT NULL,
response_body JSON NULL,
expires_at DATETIME NOT NULL,
PRIMARY KEY (owner_id, request_key)
);
처리 흐름은 다음과 같다.
- 인증된 주체와 idempotency key로 기존 레코드를 찾는다.
- 없으면
processing레코드를 원자적으로 만든다. - 같은 키와 같은 요청 hash가 완료 상태라면 저장한 결과를 반환한다.
- 같은 키인데 요청 내용이 다르면 충돌 오류를 반환한다.
- 처리 중이라면 기다리거나 명시적인 진행 중 응답을 준다.
type BeginResult =
| { kind: "acquired" }
| { kind: "completed"; status: number; body: unknown }
| { kind: "in-progress" }
| { kind: "conflict" };
async function beginIdempotentRequest(
ownerId: string,
key: string,
requestHash: string,
): Promise<BeginResult> {
// unique constraint와 transaction을 이용하는 저장 계층에 위임한다.
return idempotencyRepository.begin({ ownerId, key, requestHash });
}
request hash를 비교하지 않으면 클라이언트가 같은 키로 금액만 바꿨을 때 이전 결과를 돌려주는 위험이 있다. 키는 요청 본문을 대신하는 것이 아니라 동일 의도를 식별하는 수단이다.
동시 요청과 원자성
다음 구현은 조회와 생성 사이에 경쟁 조건이 있다.
// 좋지 않은 예
const existing = await repository.find(ownerId, key);
if (!existing) {
await repository.insert({ ownerId, key, status: "processing" });
}
두 요청이 동시에 find를 통과하면 둘 다 없다고 판단할 수 있다. (owner_id, request_key) unique constraint와 원자적인 insert 또는 transaction으로 승자를 하나만 만들어야 한다. 데이터베이스 제약은 마지막 방어선이다.
결제 레코드 생성과 멱등 결과 저장 사이의 실패도 생각해야 한다. 같은 데이터베이스에서 처리할 수 있다면 하나의 트랜잭션으로 묶는다. 외부 결제사 호출처럼 단일 트랜잭션으로 묶을 수 없다면 외부 시스템에도 안정적인 요청 식별자를 전달하고, 재조정 작업으로 중간 상태를 복구한다.
재시도 정책까지 함께 설계하기
멱등한 작업이라고 즉시 무한 재시도해도 되는 것은 아니다. 과부하 상태의 서버에 동시 재시도가 몰리면 장애를 키운다.
function retryDelay(attempt) {
const base = Math.min(500 * 2 ** attempt, 8_000);
const jitter = Math.random() * 300;
return base + jitter;
}
자동 재시도는 timeout, 일부 5xx, 429처럼 일시적일 가능성이 있는 실패에 제한하고 Retry-After가 있으면 존중한다. 검증 실패인 400이나 권한 실패인 403을 같은 요청으로 반복해도 성공 가능성은 낮다.
재시도 횟수보다 전체 deadline을 두는 편이 사용자 경험을 설명하기 쉽다. 각 시도 timeout, 최대 경과 시간, 취소 신호를 함께 정한다.
운영에서 확인할 것
- 논리적으로 같은 요청이 같은 키를 재사용하는가?
- 키의 범위와 만료 시간이 정해져 있는가?
- 같은 키로 다른 본문이 오면 거부하는가?
- 동시 요청을 unique constraint 또는 원자 연산으로 막는가?
- 성공 응답이 유실된 상황을 통합 테스트하는가?
- 로그에서 최초 처리와 재사용 응답을 구분할 수 있는가?
GET과 PUT, DELETE는 의미상 멱등적이어야 한다. POST는 기본적으로 그렇지 않지만 idempotency key와 결과 저장으로 특정 작업을 멱등하게 만들 수 있다.
결론
멱등성은 네트워크 실패 뒤 서버의 처리 여부를 알 수 없을 때 같은 의도를 안전하게 다시 전달하기 위한 계약이다. HTTP 메서드의 의미를 지키고, 중복 위험이 있는 POST에는 요청 내용과 결합된 idempotency key를 사용한다. 서버에서는 unique constraint와 트랜잭션으로 동시 요청을 막고, 클라이언트에서는 backoff와 deadline을 포함한 재시도 정책까지 함께 설계해야 한다.