같은 요청을 두 번 보내면 POST와 PUT은 어떻게 갈리나

응답 없이 끊긴 주문 요청을 다시 보냈더니 두 건이 된 이유를 POST와 PUT의 멱등성 차이로 풀고, 버튼을 누를 때 만든 Idempotency-Key와 DB 키 테이블로 POST 재시도를 한 번만 처리하는 법을 보여 줍니다.

동딩
2026.10.12·6분 읽기·

응답이 끊긴 요청은 서버에서 어떻게 됐나

앱의 주문 버튼은 POST /orders를 보내고, 0.5초 안에 응답이 없으면 끊고 다시 보냅니다. 서버는 주문을 저장한 뒤 결제 승인까지 1초가 걸려 응답합니다. 앱은 요청이 서버에 닿지 못한 것인지 응답만 늦은 것인지 모르니 다시 보내고, 그 요청이 주문을 하나 더 만듭니다.

앱서버저장소
1POST /orders
2주문 1 저장
30.5초 응답 없음
4같은 POST 재시도
5주문 2 저장
6201, id 2
응답이 늦은 주문 요청을 앱이 다시 보낼 때

POST와 PUT은 같은 요청을 두 번 받으면 무엇이 다른가

POST는 보낸 내용을 서버가 알아서 처리하라는 요청입니다. 주문 번호는 서버가 매기니, 같은 본문이 두 번 오면 주문도 두 개 생깁니다.

PUT은 URI가 가리키는 자리를 보낸 내용으로 바꾸라는 요청입니다. 주문 1의 배송지를 바꾸는 PUT /orders/1/shipping-address는 두 번 보내도 같은 자리를 같은 주소로 다시 덮을 뿐입니다.

같은 요청 두 번이 서버에 남기는 것
POST /orders
앱
coffee coffee
주문 1
주문 2
주문 1건
PUT /orders/1/shipping-address
앱
마포구 마포구
주문 1의 배송지
배송지 1개
첫 요청

POST는 서버가 주문 1을 만들고, PUT은 주문 1의 배송지 자리에 마포구 주소를 놓습니다.

같은 요청 재시도

POST는 받은 요청을 새로 처리해 주문 2를 또 만듭니다. PUT은 같은 자리를 같은 주소로 덮어씁니다.

남은 상태

주문은 2건이 됐고, 배송지는 그대로 하나입니다.

같은 요청을 여러 번 보낸 결과가 한 번 보낸 결과와 같은 성질을 멱등(idempotent)이라고 합니다. 멱등은 응답이 아니라 서버에 남은 상태를 봅니다. 그래서 DELETE는 처음엔 200, 다음부터는 404를 받아도 멱등입니다.

고르는 기준은 자리를 누가 정하느냐입니다. 주문 번호처럼 서버가 매기는 자리에 새로 만들면 POST, 배송지처럼 클라이언트가 URI로 가리키는 자리를 통째로 바꾸면 PUT입니다.

그대로 다시 보내도 되는 메서드는 무엇인가

안전한 메서드(safe)는 서버 상태를 바꾸지 않는 읽기 전용 메서드로, GET·HEAD·OPTIONS·TRACE입니다. 안전하면 멱등이지만 거꾸로는 아닙니다. PUT과 DELETE는 상태를 바꾸지만 멱등입니다.

안전멱등그대로 재시도
GET지원지원지원
PUT미지원지원지원
DELETE미지원지원지원
POST미지원미지원미지원
PATCH미지원일부일부
메서드별 안전·멱등과 그대로 다시 보내도 되는지

멱등 요청은 응답을 읽기 전에 연결이 끊겨도 그대로 다시 보내면 됩니다. POST는 그렇지 않아서, 다시 보낼지와 중복을 어떻게 막을지를 앱과 서버가 정해야 합니다.

PATCH는 멱등이 보장되지 않습니다. 주문 수량을 2로 정하는 패치는 두 번 보내도 2지만, 수량을 1 올리는 패치는 보낼 때마다 늘어납니다.

다시 보내야 하는 POST에는 키를 붙인다

Idempotency-Key는 이 요청이 앞서 보낸 요청의 재시도임을 서버에 알리는 헤더입니다. 키는 버튼을 누를 때 crypto.randomUUID()로 한 번 만들고, 재시도에는 같은 키를 싣습니다. 재시도마다 키를 새로 만들면 서버에는 매번 다른 요청이라 주문이 또 늘어납니다.

js
async function placeOrder(item) {
const key = crypto.randomUUID(); // 버튼을 누를 때 한 번
for (let i = 0; i < 3; i++) {
try {
const res = await fetch('/orders', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Idempotency-Key': key,
},
body: JSON.stringify({ item }),
signal: AbortSignal.timeout(500),
});
if (res.status !== 409) return res.json();
} catch {} // 타임아웃이면 같은 키로 다시 보낸다
await new Promise((r) => setTimeout(r, 1000));
}
throw new Error('주문 실패');
}

같은 키가 동시에 오면 서버는 어떻게 막나

서버는 키를 메모리가 아니라 DB 테이블에 둡니다. 메모리에 두면 서버를 다시 띄우거나 여러 대로 늘렸을 때 키를 잃습니다. key를 기본 키로 두면 같은 키는 한 행만 들어갑니다.

sql
create table idempotency_keys (
key text primary key,
status int, -- 비어 있으면 처리 중
response jsonb,
created_at timestamptz not null default now()
);
-- 하루 지난 키는 주기적으로 지운다
delete from idempotency_keys
where created_at < now() - interval '24 hours';

요청이 오면 키부터 넣어 봅니다. 같은 키가 동시에 와도 행을 넣는 요청은 하나뿐이고, 나머지는 on conflict do nothing에 걸려 돌려받는 행이 없습니다. 행을 못 넣은 요청은 저장된 응답이 있으면 그대로 돌려주고, 아직 처리 중이면 409를 받습니다.

js
async function postOrder(req, res, body) {
const key = req.headers['idempotency-key'];
if (!key) return send(res, 201, await createOrder(body));
const { rows } = await db.query(
`insert into idempotency_keys (key) values ($1)
on conflict (key) do nothing returning key`, [key]);
if (rows.length === 0) {
const { rows: [saved] } = await db.query(
'select status, response from idempotency_keys where key = $1', [key]);
if (saved.status === null) return send(res, 409, { error: 'processing' });
return send(res, saved.status, saved.response);
}
try {
const order = await createOrder(body); // 주문 저장, 결제 승인
await db.query(
'update idempotency_keys set status = 201, response = $2 where key = $1',
[key, order]);
send(res, 201, order);
} catch (err) {
// 실패하면 키를 지워 재시도가 처음부터 하게 한다
await db.query('delete from idempotency_keys where key = $1', [key]);
throw err;
}
}
끊긴 주문 요청을 키 없이, 같은 키로 다시 보낸 결과
zsh · U=localhost:8787키 없음
$ curl -m 0.5 -X POST $U/orders \
-d '{"item":"coffee"}'
$ curl -X POST $U/orders \
-d '{"item":"coffee"}'
$ curl -s $U/orders | jq -c '.[]'
실행 결과
curl: (28) Operation timed out after 503 milliseconds with 0 bytes received
{"id":2,"item":"coffee"}
# 저장된 주문
{"id":1,"item":"coffee"}
{"id":2,"item":"coffee"}
$ K=$(uuidgen)
$ curl -m 0.5 -X POST $U/orders \
-H "Idempotency-Key: $K" \
-d '{"item":"coffee"}'
$ curl -X POST $U/orders \
-H "Idempotency-Key: $K" \
-w ' %{http_code}\n' \
-d '{"item":"coffee"}'
실행 결과
curl: (28) Operation timed out after 506 milliseconds with 0 bytes received
{"error":"processing"} 409
$ sleep 1
$ curl -X POST $U/orders \
-H "Idempotency-Key: $K" \
-w ' %{http_code}\n' \
-d '{"item":"coffee"}'
$ curl -s $U/orders | jq -c '.[]'
실행 결과
{"id":1,"item":"coffee"} 201
# 저장된 주문
{"id":1,"item":"coffee"}
키 없이

0.5초에 끊긴 첫 요청도 서버는 저장했습니다. 다시 보낸 요청이 id 2를 만들어 주문이 두 건입니다.

처리 중에 같은 키

첫 요청이 아직 결제 승인 중이라 같은 키로 다시 보낸 요청은 409를 받습니다.

처리가 끝난 뒤 같은 키

저장해 둔 첫 응답 id 1이 그대로 돌아오고, 주문은 한 건입니다.

키 행은 하루쯤 지나면 지웁니다. Stripe도 24시간이 지난 키를 정리하고, 그 뒤에 온 같은 키는 새 요청으로 처리합니다.

지금 할 일은 타임아웃 뒤 다시 보내는 POST를 찾는 것입니다. 그 요청에 버튼을 누를 때 만든 키를 싣고, 서버에는 키 테이블과 위 분기를 넣습니다. PUT도 핸들러가 배송지 변경 알림처럼 보낼 때마다 쌓이는 일을 하면 두 번 보낸 요청이 알림도 두 번 보내니, 이런 핸들러에도 같은 키를 받습니다.

Comments

댓글

powered by giscus ↗