같은 요청이 두 번 와도 한 번만 — 멱등 키(Idempotency-Key) 구현하기

정기창·

Go 로 짜인 서버를 TypeScript 로 다시 짜며 다시 공부한 정석을 적는 연재의 세 번째 글입니다. 앞 글에서는 같은 데이터를 두 번 받아도 한 번만 남기는 방법을 적었습니다. 이번에는 한 걸음 더 들어갑니다. 같은 요청이 두 번 와도 한 번만 처리하고, 두 번째 요청에도 첫 번째와 같은 응답을 돌려줘야 한다는 요구입니다.

예를 들면 "확정" 버튼입니다. 사용자가 버튼을 두 번 누를 수도 있고, 앱이 응답을 못 받아서 같은 요청을 다시 보낼 수도 있습니다. 어느 경우든 확정은 한 번만 일어나야 합니다.

여러 번 해도 한 번 한 것과 같으면 "멱등하다"고 부릅니다

엘리베이터 호출 버튼을 떠올려 보면 됩니다. 한 번 누르든 다섯 번 누르든 엘리베이터는 한 번 옵니다. 이렇게 같은 일을 여러 번 해도 결과가 한 번 한 것과 같은 성질을 멱등성(idempotency)이라고 부릅니다.

조회는 원래 멱등합니다. 몇 번을 읽어도 데이터가 바뀌지 않기 때문입니다. 문제는 무언가를 만드는 요청입니다. "확정해 주세요"를 두 번 받으면 서버는 확정을 두 번 만들기 쉽습니다.

재시도는 피할 수 없고, 그래서 중복도 피할 수 없습니다

그렇다면 앱이 다시 보내지 않으면 되지 않을까 싶지만, 그럴 수 없는 순간이 있습니다. 요청은 서버에 도착해 처리됐는데, 돌아오는 응답이 네트워크에서 사라지는 경우입니다.

앱                          서버
 │── 확정 요청 ─────────────▶│  확정 처리 완료
 │        ✕ 응답이 사라짐 ◀──│
 │  (성공인지 실패인지 모른다)
 │── 같은 요청 다시 보냄 ────▶│  또 확정?

앱 입장에서는 성공했는지 실패했는지 알 수 없습니다. 다시 보내지 않으면 실패했을 때 일이 사라지고, 다시 보내면 성공했을 때 일이 두 번 됩니다. 그래서 정석은 "다시 보내도 안전하게 만든다"입니다. 재시도를 막는 것이 아니라, 재시도를 받아도 결과가 같게 만드는 것입니다.

요청마다 이름표를 붙이면 서버가 "같은 요청"을 알아볼 수 있습니다

서버가 두 요청이 같은 것인지 알려면 이름표가 필요합니다. 앱은 사용자의 행동 하나마다 고유한 값을 하나 만들고, 다시 보낼 때도 같은 값을 붙입니다. 이 값을 멱등 키(Idempotency Key)라고 부릅니다. HTTP 에서는 Idempotency-Key 라는 헤더로 보내는 방식이 IETF 초안으로 제안된 적이 있습니다. 2025년 10월 판(-07)을 끝으로 만료된 초안이라 아직 표준은 아니지만, 여러 서비스가 쓰는 방식을 정리한 문서라 설계의 기준으로 삼기에 충분했습니다.

택배 송장 번호와 비슷합니다. 같은 송장 번호로 두 번 접수하면, 창구는 두 번째에 새로 접수하지 않고 "이미 접수됐습니다"라며 처음 영수증을 다시 내줍니다.

서버는 키와 요청의 지문과 그때의 응답을 함께 기억합니다

이름표만 기억하면 부족합니다. 서버가 기억해야 하는 것은 셋입니다.

기억할 것왜 필요한가
멱등 키같은 요청인지 알아보기 위해
요청 내용의 지문같은 키에 다른 내용이 오는 실수를 잡기 위해
그때의 응답재시도에 처음과 같은 답을 돌려주기 위해

지문은 해시로 만듭니다. 해시는 긴 내용을 짧은 고정 길이 값으로 요약한 것으로, 내용이 한 글자만 달라도 전혀 다른 값이 나옵니다. 흐름은 이렇게 짰습니다.

요청 도착 (키 k, 내용 지문 h)
 │
 ├─ 키 k 를 "처리 중"으로 등록 시도 ── 성공 ─▶ 실제 처리 → 응답을 저장하고 "완료"로 표시 → 응답
 │
 └─ 이미 있는 키 ─┬─ 지문이 다르다   ─▶ 422 (같은 키를 다른 요청에 썼다)
                 ├─ 아직 처리 중   ─▶ 409 (잠시 뒤 다시)
                 └─ 이미 완료      ─▶ 저장해 둔 처음 응답을 그대로 돌려준다

422 와 409 는 앞의 초안이 권하는 응답 코드이기도 합니다. 초안은 같은 키를 다른 내용에 다시 쓰면 422 를, 처음 요청이 아직 처리 중일 때 재시도가 오면 409 를 돌려주라고 적고 있습니다.

가장 중요한 한 줄은 맨 처음의 "등록 시도"입니다. 키를 기본 키(PRIMARY KEY)로 둔 표에 먼저 넣어 보고, 들어가면 내가 처리할 차례입니다. 같은 키가 동시에 여러 개 들어와도 데이터베이스는 그중 하나만 통과시키고 나머지에는 "중복" 에러를 냅니다. 여러 요청 가운데 누가 처리할지를 데이터베이스의 유일성 제약이 정해 주는 셈입니다.

try {
  await pool.query(
    "INSERT INTO idempotency_key (idem_key, request_hash, status) VALUES (?, ?, 'IN_PROGRESS')",
    [idemKey, hash],
  );
} catch (e) {
  if (e.code !== 'ER_DUP_ENTRY') throw e;
  const [[row]] = await pool.query(
    'SELECT request_hash, status, response_json FROM idempotency_key WHERE idem_key = ?',
    [idemKey],
  );
  if (row.request_hash !== hash) return { status: 422, body: { code: 'IDEMPOTENCY_KEY_REUSED' } };
  if (row.status === 'IN_PROGRESS') return { status: 409, body: { code: 'REQUEST_IN_PROGRESS' } };
  return row.response_json; // 처음 응답을 그대로
}
// 여기까지 왔다면 내가 처리한다. 실제 처리와 응답 저장을 한 트랜잭션으로 묶는다.

다섯 요청이 동시에 와도 확정은 한 번이었습니다

로컬 MySQL 에서 직접 돌려 봤습니다. 먼저 순서대로 세 번 보냈습니다. 두 번째는 키 순서만 바꾼 같은 내용이고, 세 번째는 같은 키에 금액만 다르게 보냈습니다.

첫 요청 (k-1, 주문 A-1, 1000원)  -> 201 {"confirmationId":1}
재시도 (같은 키, 같은 내용)        -> 201 {"confirmationId":1}  (저장된 응답을 다시 줌)
같은 키, 다른 금액 2000원         -> 422 IDEMPOTENCY_KEY_REUSED
주문 A-1 확정 건수 = 1

다음은 같은 키로 다섯 요청을 동시에 보냈습니다. 처리에 300ms 가 걸린다고 가정했습니다.

응답 상태별 개수 = {"201":1,"409":4}
주문 B-1 확정 건수 = 1
처리가 끝난 뒤 재시도 -> 201 {"confirmationId":2}  (저장된 응답)

다섯 요청 중 하나만 처리됐고, 나머지 넷은 "아직 처리 중"이라는 답을 받았습니다. 처리가 끝난 뒤 다시 보낸 요청은 처음 응답을 그대로 받았습니다. 다만 이 결과는 한 대의 데이터베이스에서 한 번 돌린 것이라, 부하가 큰 환경에서의 처리량이나 지연까지 보여 주지는 않습니다.

지문을 만들 때는 키 순서를 먼저 맞춰야 합니다

구현하며 한 번 걸린 곳이 있습니다. JSON 은 같은 내용이라도 항목의 순서가 다를 수 있습니다. 앱이 재시도하면서 항목 순서를 바꿔 보내면, 그대로 해시한 지문은 달라집니다. 그러면 정당한 재시도가 "다른 요청"으로 오해받아 422 를 받습니다.

JSON.stringify 그대로 : 79c2a35250d9 vs 294d72da627e  ← 같은 요청인데 다른 지문
항목 이름순 정렬 뒤   : 294d72da627e vs 294d72da627e

그래서 지문을 만들기 전에 항목을 이름순으로 정렬하는 정규화 단계를 넣었습니다. 정규화는 같은 뜻을 가진 여러 표현을 하나의 표준 모양으로 맞추는 일입니다.

처리 도중 서버가 죽으면 "처리 중"이 영원히 남습니다

이 방식에도 빈틈이 하나 있습니다. 키를 "처리 중"으로 등록한 직후 서버가 꺼지면, 그 키는 누구도 완료로 바꾸지 못한 채 남습니다. 이후의 재시도는 계속 409 만 받게 됩니다.

그래서 "처리 중"에는 기한이 있어야 합니다. 일정 시간이 지나도 완료되지 않은 등록은 주인이 사라진 것으로 보고 다른 요청이 다시 가져갈 수 있어야 합니다. 이 "기한이 있는 점유"는 다음 글의 주제인 시간 리스와 같은 이야기라, 거기서 자세히 적겠습니다. 앞의 초안도 키의 유효 기간과 만료 정책을 정해 문서에 밝혀 두라고 권합니다.

정리하며

  • 재시도를 막지 말고, 재시도를 받아도 안전하게 만듭니다. 응답이 사라지는 한 재시도는 피할 수 없습니다.
  • 키와 지문과 응답을 함께 기억합니다. 같은 키에 다른 내용이 오면 거절하고, 완료된 요청에는 처음 응답을 돌려줍니다.
  • 누가 처리할지는 데이터베이스의 유일성 제약에 맡깁니다. 동시에 들어온 같은 요청 가운데 하나만 통과시키는 일은 애플리케이션 코드보다 데이터베이스가 정확합니다.

같은 요청을 두 번 받는 것은 버그가 아니라 네트워크의 기본 성질이라는 생각이 들었습니다. 그렇다면 코드가 할 일은 그 성질을 없애는 것이 아니라 그 위에서도 결과가 흔들리지 않게 하는 것이었습니다.

실험 환경: MySQL 8.4.11(로컬 Docker 컨테이너) · Node.js v26.5.0 · mysql2 3.24.4 · 2026-09-29. 표 구조와 요청은 설명을 위해 새로 만든 예시입니다.

멱등성Idempotency-KeyAPI 설계MySQL동시성재시도