currenjin
wiki

멱등성

AI

이 문서의 본문 대부분은 생성형 AI가 작성했습니다. 아직 작성자의 충분한 사실 검증을 거치지 않았습니다.

재시도되는 요청을 식별하고 DB 효과와 결과 기록을 함께 확정하는 원리와 실패 조건

편집하기의견 남기기

이 문서는 같은 업무 요청이 여러 번 도착해도 효과를 중복시키지 않는 설계를 다룬다. 함수와 조건문, HTTP 요청·응답, SQL의 SELECT, INSERT, UPDATE를 읽을 수 있는 독자를 대상으로 한다. 트랜잭션과 유일 제약은 필요한 자리에서 설명한다.

상품 예약 서비스는 교육을 위한 가상 사례다. 실제 결제나 사용자의 운영 경험을 옮긴 것이 아니다. 한 데이터베이스 안에서 예약과 결과를 함께 기록하는 구현을 중심으로 배우고, 외부 API와 보존 만료에서 이 보장이 왜 끝나는지 살펴본다. Kafka나 특정 웹 프레임워크를 알아야 읽을 수 있는 문서는 아니다.

1장. 응답이 없다는 것은 어떤 뜻인가

고객이 상품 한 개를 예약한다. 클라이언트는 서버로 요청을 보내고, 서버는 남은 재고를 확인해 한 개 줄인 뒤 예약 번호를 응답한다. 이 정상 흐름에서는 고객이 누른 한 번의 버튼, 한 번의 통신, 하나의 예약이 대응한다.

서버가 예약을 저장한 직후 연결이 끊어지면 대응이 깨진다. 클라이언트가 정해진 대기 시간을 넘기는 시간 초과(timeout)를 겪었어도 서버의 예약은 남을 수 있다. 반대로 요청이 서버까지 도달하지 못했을 수도 있다. 응답을 받지 못한 쪽에서는 두 경우를 구별할 정보가 부족하다.

클라이언트가 보지 못한 서버 상황 같은 예약을 다시 보내지 않으면 새 예약으로 다시 보내면
요청이 도착하지 않음 예약이 만들어지지 않는다 최초 예약이 만들어질 수 있다
예약 저장 후 응답만 유실 예약은 있지만 결과를 모른다 예약이 하나 더 생길 수 있다

이때 다시 요청하는 동작이 재시도(retry)다. 통신 실패에 대응하려면 재시도가 필요하지만, 서버가 모든 도착을 새 구매로 해석하면 재고가 두 번 줄어든다. 클라이언트가 “아마 실패했을 것”이라고 추측하는 것으로는 해결되지 않는다.

따라서 고객이 의도한 한 번의 예약인 논리 요청(logical request)과 네트워크로 보내는 한 번의 시도(attempt)를 나눈다. 논리 요청 하나에 여러 시도가 속할 수 있다. 재시도할 때도 논리 요청의 식별자를 유지하고, 서버는 그 식별자로 이미 확정한 결과를 알아봐야 한다. 요청 식별자를 최초 전송 전에 만들고 저장하는 이유다. 재전송 직전에 새로 만들면 서버에게는 새로운 업무로 보인다.1

서버의 응답이 없어도 결과를 다시 알아볼 수 있게 만드는 것이 이 문서의 출발점이다. 네트워크 오류 자체를 없애거나 서버 코드가 한 번만 호출되게 만들려는 것이 아니다.

2장. 무엇이 같아야 멱등한가

멱등성(idempotency)은 같은 연산을 반복해서 적용한 효과가 한 번 적용한 효과와 같은 성질이다. 상태를 바꾸는 함수 f로 쓰면 f(f(s)) = f(s)다. 여기서 s는 관심 있는 상태이고, 등호는 어떤 효과를 비교하는지 정한 뒤에야 의미가 있다.

재고를 “4로 설정한다”는 연산은 다른 변경이 끼지 않는다면 반복해도 4다. “현재 재고에서 1을 뺀다”는 연산은 5에서 시작해 4, 3으로 바뀌므로 그 자체로는 멱등하지 않다. 두 번째 연산도 동일한 예약 식별자를 기억하는 절차로 감싸면, 같은 예약의 반복이 추가 차감을 만들지 않도록 할 수 있다. 원래 차감 식이 멱등해진 것이 아니라 중복 판정까지 포함한 전체 연산의 성질이 달라진다.

상태, 응답, 실행 횟수는 서로 다르다

삭제 요청을 처음 받았을 때 자원을 삭제하고 204를 보냈다고 하자. 다시 받았을 때 이미 없어서 404를 보내더라도 “그 자원이 없다”는 의도된 효과는 같다. 응답 코드까지 같은 것이 멱등성의 정의는 아니다. 다만 예약 API에서는 처음 만든 예약 번호를 다시 반환하는 편이 클라이언트의 결과 확인을 돕는다. 이 결과 재생(response replay)은 멱등성에 더해 설계하는 API 동작이다.2

HTTP 메서드가 멱등하다는 표준 정의와, 개별 예약 API가 결과를 저장하는 정책은 구분해야 한다. 메서드 분류와 제품별 차이는 DB 정상 흐름을 배운 뒤 8장에서 비교한다.

이 글의 예약 예제에서 비교하는 효과는 세 가지다. 같은 논리 요청에 대해 예약이 추가로 생기지 않고, 재고가 추가로 줄지 않으며, 최초에 확정한 응답을 다시 얻는다. 함수 호출 횟수, DB 조회 횟수, 접근 로그 수까지 하나라고 약속하지 않는다.

멱등성과 순서 보장은 다르다

“배송지를 서울로 설정한다”와 “배송지를 부산으로 설정한다”는 각각 같은 값을 반복해 쓸 수 있다. 그래도 부산으로 변경한 뒤 늦게 도착한 서울 요청을 적용하면 최신 의도를 잃는다. 연산 하나의 반복에 대한 성질만으로 서로 다른 연산의 순서를 결정할 수 없기 때문이다.

오래된 변경을 막으려면 데이터의 변경 순서를 나타내는 버전(version) 같은 추가 정보가 필요하다. 저장된 버전보다 새로운 변경만 반영하는 규칙은 멱등성 그 자체와 다른 문제를 해결한다. 7장에서 실제 반례를 확인한다.

3장. 서버는 같은 요청을 어떻게 알아보는가

멱등 키(idempotency key)는 여러 시도가 같은 논리 요청에 속한다는 표시다. 서로 다른 곳에서 생성해도 식별자가 겹칠 가능성을 작게 만드는 범용 고유 식별자(universally unique identifier, UUID)를 사용할 수 있지만, 키의 모양보다 수명과 사용 규칙이 먼저다. 같은 업무의 재시도에는 같은 키를 사용하고, 사용자가 의도한 별도의 구매에는 새 키를 사용한다.

서버가 요청 내용만 비교하면 충분할까? 고객은 같은 상품을 같은 수량으로 두 번 구매할 수 있다. 본문이 같다고 합치면 정상 구매를 없앤다. JSON은 이름과 값을 묶어 데이터를 표현하는 형식이다. 같은 구매를 나타낸 JSON에서 필드 순서나 공백만 달라졌다고 새 구매로 처리해도 안 된다. 요청의 의도는 키로, 그 의도에 묶인 입력의 일관성은 별도 비교로 확인한다.1

식별 범위와 입력을 함께 묶는다

가상의 예약 API는 다음 묶음을 사용한다.

값 예시 맡은 역할
인증된 고객 영역 shop-a 다른 고객 영역의 요청과 분리한다
업무 종류와 버전 reserve-v1 예약과 취소, 의미가 바뀐 API를 구분한다
멱등 키 request-7 한 예약의 반복 시도를 묶는다
검증한 입력 상품 book, 수량 1 같은 키를 다른 내용에 재사용했는지 확인한다

여러 고객의 데이터를 한 서비스가 나누어 관리할 때 고객별 영역을 테넌트(tenant)라고 한다. 키를 전역 문자열 하나로만 취급하면 두 테넌트의 우연히 같은 키가 충돌하거나 다른 테넌트의 결과를 잘못 반환할 수 있다. 이 예제의 식별자는 (tenant, operation, key) 전체다. 이것은 모든 API에 강제되는 표준이 아니라, 가상 서비스의 업무 범위를 표현한 선택이다.

클라이언트가 보낸 tenant 문자열을 그대로 믿는 것은 인증이 아니다. 실제 서버는 인증한 사용자에게 허용된 영역과 작업을 확인한 뒤 중복 기록을 조회해야 한다. 키를 알고 있다는 이유로 다른 사람의 예약 결과를 읽게 해서는 안 된다. 이 글의 로컬 함수는 이미 인증된 영역을 인자로 받았다고 가정하며 인증 서버를 구현하지 않는다.

요청 본문(payload)은 요청이 전달하는 데이터다. 서버는 그중 업무 의미를 바꾸는 필드를 검증하고 정해진 형태로 직렬화해 저장한다. 직렬화(serialization)는 객체를 저장·전송 가능한 문자열이나 바이트로 표현하는 것이다. 예제에서는 상품과 정수 수량만 정렬된 JSON 문자열로 저장해 비교한다. 시도마다 달라지는 추적 ID나 접속 시각은 비교 대상에서 뺀다.

본문이 크면 요약값인 해시(hash)를 저장할 수 있지만 충돌 가능성과 입력 정규화 규칙도 설계해야 한다. 이 예제는 해시를 쓰지 않으므로 해시만으로 동등성을 증명하는 문제가 없다. 통화 단위, 기본값, 생략과 null의 의미가 있는 실제 API에서는 그 차이를 먼저 정의해야 한다.

같은 키인데 수량이 달라지면

첫 요청이 request-7, 수량 1이고 다음 요청이 같은 키, 수량 2라면 서버가 임의로 둘을 합쳐서는 안 된다. 이전 예약을 조용히 반환하면 새 수량이 처리됐다고 오해할 수 있고, 새로운 예약으로 실행하면 키가 중복 억제 역할을 잃는다. 이 예제는 입력 불일치로 거절한다.

이제 같은 요청을 찾는 식별자와 입력 충돌 규칙이 정해졌다. 다음으로 이 정보를 어디에 기록해야 재시작과 재시도 뒤에도 예약 효과와 어긋나지 않는지 살펴본다.

4장. 기록과 업무 효과를 함께 확정한다

서버는 키를 기억하는 요청 기록과, 재고·예약이라는 업무 데이터를 관리한다. 여러 애플리케이션 프로세스가 같은 DB를 사용한다면 각 프로세스의 메모리 집합만으로는 중복을 막을 수 없다. 다른 프로세스가 기록을 보지 못하고, 재시작하면 기억도 사라지기 때문이다.

여기서는 세 테이블을 하나의 DB 파일에 둔다. inventory는 지금 살 수 있는 수량을, reservations는 이미 만들어진 예약을 담는다. receipts는 어떤 논리 요청이 어떤 입력으로 처리됐고 무슨 응답을 받았는지 기억한다. 재고만 보고는 request-7의 처리 여부를 알 수 없으므로 업무 데이터와 별도로 요청 기록이 필요하다. HTTP 서버나 메시지 브로커는 이 DB 트랜잭션의 일부가 아니다.

세 테이블은 무엇을 기억하는가

다음은 뒤의 실행 코드가 사용하는 필드다. 먼저 각 값의 역할을 읽고, 같은 값이 처리 단계마다 어떻게 바뀌는지 따라간다.

테이블 필드 역할과 예시
inventory tenant, product 재고를 찾는 조합. shop-a, book은 shop-a 영역의 book 재고를 뜻한다
inventory stock 남은 수량. 처음에는 5, 한 개 예약 뒤에는 4다
reservations id 생성된 예약을 구별하는 번호. 설명에서는 r-7로 줄여 쓴다
reservations tenant, product, qty 누구의 어떤 상품을 몇 개 예약했는지 저장한다. 성공 행에는 shop-a, book, 1이 남는다
receipts tenant, operation, request_key 다시 온 요청을 찾는 조합. shop-a, reserve-v1, request-7이다
receipts payload 처음 받은 업무 입력. 같은 키의 입력이 바뀌지 않았는지 비교한다
receipts status, response 확정한 응답 코드와 JSON 본문. 재시도에 새 결과를 만들지 않고 이 값을 돌려준다

r-7은 설명을 위한 예약 번호이며 실제 코드는 UUID로 새 번호를 만든다. 요청 키 request-7과 예약 번호는 다른 값이다. 클라이언트는 결과를 받기 전부터 요청 키를 가지고 있지만, 예약 번호는 서버가 예약을 만들 때 생긴다. 이 스키마에서 요청 기록과 예약의 연결은 response 안의 reservation_id로 표현하며, 두 테이블 사이의 외래 키 제약을 선언한 것은 아니다.

이 사례의 payload는 {"product": "book", "qty": 1}이라는 문자열이다. response는 성공하면 {"qty": 1, "reservation_id": "r-7"}이라는 문자열이 된다. 입력을 보관하는 이유는 재사용된 키의 내용을 검사하기 위해서이고, 응답을 보관하는 이유는 처음 발급한 예약 번호까지 재생하기 위해서다.

트랜잭션(transaction)은 여러 DB 작업을 하나의 확정 단위로 묶는다. 변경을 확정하는 것이 커밋(commit), 아직 확정하지 않은 변경을 취소하는 것이 롤백(rollback)이다. 이 예제는 요청 기록, 재고 감소, 예약 추가, 반환할 결과를 모두 같은 트랜잭션에서 처리한다. 성공하면 함께 남고, 중간 실패로 롤백하면 함께 사라지는 원자성(atomicity)을 사용한다.34

먼저 정상 흐름을 따라간다

초기 상태에는 inventory의 (shop-a, book, 5) 행만 있고, 이 요청의 예약과 요청 기록은 없다. 고객 영역 shop-a가 request-7로 book 한 개를 예약한다. 서버가 권한과 입력을 확인한 뒤 진행하는 순서는 다음과 같다. 2~5단계의 값은 처리 중인 연결이 보는 미확정 변경이다.

  1. 쓰기 트랜잭션을 시작하고 요청 기록을 찾는다. BEGIN IMMEDIATE가 성공하면 (shop-a, reserve-v1, request-7)로 receipts를 조회한다. 첫 요청이므로 행이 없다. 아직 재고는 5이고 예약도 없다.
  2. 입력을 가진 요청 행을 넣는다. receipts에 식별자 세 값과 payload={"product": "book", "qty": 1}을 저장한다. status와 response는 아직 값이 없는 NULL이다. 이 행만 따로 커밋하지 않으며, 이것을 남에게 보여 줄 완료 표시로 삼지도 않는다.
  3. 재고가 충분할 때만 차감한다. shop-a의 book 행에서 stock >= 1을 만족하므로 stock을 5에서 4로 바꾼다. 같은 트랜잭션 안에서 수행하므로 이 뒤에 실패하면 차감도 취소할 수 있다.
  4. 예약 행을 만든다. 새 예약 번호를 발급해 reservations에 (r-7, shop-a, book, 1)을 넣는다. 재고 감소와 예약 추가는 서로 다른 SQL이지만 아직 둘 다 미확정 상태다.
  5. 반환할 결과를 요청 기록에 채운다. 같은 receipts 행을 status=201, response={"qty": 1, "reservation_id": "r-7"}로 갱신한다. 이제 재시도에 필요한 입력과 결과가 모두 들어 있다.
  6. 함께 커밋한 뒤 응답한다. DB에는 재고 4, 예약 r-7 한 개, 완성된 요청 기록 한 개가 함께 남는다. 그 뒤 호출자에게 201과 예약 번호를 반환한다. DB 커밋과 네트워크 응답은 별개이므로 응답만 유실될 가능성은 남는다.

그림 4-1은 이 여섯 단계의 처리 순서와 DB 확정 범위를 요약한다. 각 상자가 독립 서버를 뜻하는 것은 아니다.

같은 DB 트랜잭션 안에서 요청 기록·재고 감소·예약·반환 결과를 함께 확정한 뒤 클라이언트에 응답한다

그림 4-1. 화살표는 서버의 처리 순서이며 네트워크 전달 보장이 아니다. 상자는 같은 DB의 한 트랜잭션 범위다. 요청을 받는 HTTP 서버와 클라이언트로 보내는 응답은 이 확정 단위에 포함되지 않는다. 그림은 재고가 충분한 최초 요청만 보여준다.

같은 요청을 다시 받으면 어디서 갈라지는가

응답을 받지 못한 클라이언트가 request-7, book 한 개를 다시 보낸다. 서버는 새 쓰기 트랜잭션을 시작한 뒤 같은 식별자로 조회한다. 이번에는 payload, 201, 예약 번호 r-7이 담긴 행을 찾는다.

서버는 먼저 저장한 payload와 이번 입력을 비교한다. 둘 다 {"product": "book", "qty": 1}이므로 저장한 응답을 읽고 트랜잭션을 끝낸다. 최초 경로의 2~5단계를 다시 수행하지 않는다. 따라서 재고는 4, 예약은 r-7 한 개, 요청 기록도 한 개 그대로다. 첫 커밋 직후 응답이 유실돼도 이 재조회로 결과를 복구할 수 있다.

반면 같은 키로 수량 2를 보내면 이번 payload가 다르다. 서버는 차감이나 새 예약 생성 전에 입력 충돌로 거절한다. 이 호출의 트랜잭션을 롤백하더라도 이전 호출이 확정한 r-7과 재고 4는 취소되지 않는다. 롤백은 현재 트랜잭션의 미확정 변경을 되돌리는 동작이기 때문이다.

최초 처리 중 실패하면 무엇이 남는가

이번에는 초기 재고 5에서 시작한 최초 요청이 4단계 직후 실패했다고 하자. 처리 중에는 재고 4와 예약 r-7이 있었지만, 커밋 전에 롤백하면 요청 기록·차감·예약 추가가 모두 취소된다. 확정 상태는 다시 재고 5, 예약 없음, 요청 기록 없음이다. 같은 키의 재시도는 기록을 찾지 못하므로 최초 경로로 들어갈 수 있다.

재고 부족은 이와 다른 경로다. 초기 재고가 0이라면 3단계에서 변경한 행 수가 0이고, 4단계의 예약 생성을 건너뛴다. 대신 요청 기록에 status=409, response={"error": "unavailable"}을 넣고 커밋한다. 재고는 0, 예약은 없지만 완성된 요청 기록은 한 개 남는다. 이후 재고가 늘어도 같은 키는 그 거절 결과를 재생한다. 새로운 구매 의도에는 새 키가 필요하다.

정수가 아닌 수량 같은 형식 오류는 트랜잭션 시작 전에 거절하므로 요청 기록도 만들지 않는다. 서버 내부 예외로 취소된 시도, 확정된 업무 거절, 입력 형식 오류를 모두 같은 실패로 취급하면 재시도 결과를 설명할 수 없다.

조회한 다음 삽입하면 충분하지 않은 이유

처리자 A와 B가 같은 키를 조회했는데 둘 다 “없음”을 읽을 수 있다. 각자가 예약을 만든 다음 키를 저장하면 확인 절차는 있었어도 중복은 생긴다. 조회와 저장 사이에 다른 실행이 끼는 경쟁 상태(race condition)다.

DB의 유일 제약(unique constraint)은 지정한 열 조합의 중복 저장을 거절한다. 기본 키(primary key)는 행의 식별자로 사용되는 제약이다. 예제는 receipts의 (tenant, operation, request_key)를 기본 키로 삼고 각 열을 NOT NULL로 지정한다. NULL은 값이 없음을 나타내며 NOT NULL은 그것을 허용하지 않는 조건이다. SQLite의 일반 테이블에서는 기본 키라는 선언만으로 NULL을 금지하지 않는 경우가 있어 각 열에 조건을 명시한다. 중복 처리 판단은 서버의 조회 관행만이 아니라 DB가 강제하는 규칙에 기대야 한다.5

단, 중복 키 오류와 DB 연결 실패는 다르다. 모든 DB 예외를 “이미 처리했음”으로 바꾸면 실제 미처리를 성공처럼 응답하게 된다. 중복으로 확인된 경우에만 저장한 입력과 결과를 읽는다. 그 밖의 실패는 성공으로 바꾸지 않고 호출자에게 전달하며, 재시도 가능 여부는 오류 원인에 따라 따로 판단한다.

동시에 같은 키가 도착하면

이 글은 SQLite의 BEGIN IMMEDIATE를 사용한다. 시작 시점에 쓰기 트랜잭션을 확보하고, 이미 다른 쓰기 트랜잭션이 있으면 설정된 대기 안에서 기다리거나 SQLITE_BUSY로 실패할 수 있다. SQLite는 동시에 한 쓰기 트랜잭션만 허용한다. 따라서 이 구현에서는 B가 A의 커밋 뒤 쓰기 트랜잭션을 시작하고 저장된 기록을 확인한다.36

이처럼 쓰기를 한 번에 하나씩 처리하면 다른 키의 요청도 기다린다. 간단하고 재현하기 쉽지만 높은 쓰기 처리량을 위한 일반 해법이라고 볼 수는 없다. 여러 쓰기를 동시에 진행하는 다른 DB로 옮길 때 BEGIN과 테이블 이름만 바꾸면 같은 동작이 된다고 가정해서는 안 된다.

A가 롤백하면 B는 기록이 없는 상태에서 최초 실행을 맡을 수 있다. A가 커밋하면 B는 그 결과를 재생한다. B의 대기 시간이 끝났다는 사실만으로 A가 실패했다는 결론을 내릴 수는 없다.

A가 receipts에 입력만 적은 시점에도 B는 아직 BEGIN IMMEDIATE에서 기다리므로 그 미완성 행을 조회하지 않는다. A가 결과를 채우고 커밋해 쓰기 권한을 놓은 다음에야 B가 트랜잭션을 시작하고 SELECT로 완성된 결과를 읽는다. 이는 B가 대기 제한 안에 쓰기 권한을 얻은 경우의 순서다.

세 장치의 역할을 나누어 보면, 원자성은 요청 기록과 업무 변경을 함께 남기거나 취소한다. 유일 제약은 같은 식별자의 요청 행 두 개를 금지한다. SQLite의 쓰기 직렬화는 이 함수의 두 처리자가 동시에 “기록 없음”을 읽고 최초 경로를 진행하지 못하게 한다. 유일 제약만 있다고 분리된 커밋까지 하나로 묶이는 것은 아니며, 쓰기를 차례로 실행한다고 결과를 저장할 필요가 없어지는 것도 아니다.

5장. 작은 구현을 실행하며 상태를 확인한다

아래 코드는 Python 표준 라이브러리와 SQLite만 사용한다. 2026-10-23에 Python 3.9.6과 SQLite 3.51.0에서 본문 코드를 추출해 실행했다. 이는 재현 환경이지 최신 버전이라는 뜻은 아니다. 별도 HTTP 서버나 외부 결제 서버 없이 요청 처리 함수를 직접 호출한다. 파일 DB를 사용하고 호출마다 새 연결을 열기 때문에, Python 객체 하나가 중복을 기억하는 실험은 아니다.

입력 비교가 재실행을 막는 위치

먼저 4장의 경로를 작은 코드 조각으로 연결한다. 아래 조각들은 전체 함수의 일부이므로 따로 실행하는 프로그램이 아니다. 완전한 코드는 이 장 뒤에 둔다.

상품 book, 수량 1을 검증한 뒤 payload에는 정렬된 JSON 문자열을, identity에는 ("shop-a", "reserve-v1", "request-7")을 만든다. 쓰기 트랜잭션을 시작한 다음 조회한 old는 기록이 없으면 None, 있으면 (payload, status, response) 순서의 값 묶음이다. old[0]은 저장 입력, old[1]은 응답 코드, old[2]는 응답 JSON을 가리킨다.

if old is not None:
    if old[0] != payload:
        raise ValueError("key reused with different input")
    result = (old[1], json.loads(old[2]))
    db.execute("COMMIT")
    return result

json.loads는 저장된 JSON 문자열을 Python 값으로 읽는다. 같은 입력이면 result에 (201, {"qty": 1, "reservation_id": "r-7"})을 구성해 반환한다. 여기서 return으로 함수를 끝내므로 아래의 차감과 예약 생성에 도달하지 않는다. 입력이 다르면 raise가 정상 진행을 멈추고 뒤에서 설명할 예외 처리로 이동시킨다.

기록이 없는 최초 요청은 이 분기를 건너뛰어 receipts에 식별자와 입력을 삽입한다. 이때 결과 필드는 비어 있다. 그다음 조건부 차감으로 업무 성공 여부를 정한다.

차감 조건과 결과 저장을 연결한다

changed = db.execute(
    "UPDATE inventory SET stock=stock-? "
    "WHERE tenant=? AND product=? AND stock>=?",
    (qty, tenant, product, qty)
).rowcount

?는 값을 SQL 문자열에 이어 붙이지 않고 매개변수로 전달하는 자리표시자다. 수량 1, 영역 shop-a, 상품 book, 수량 1이 차례로 들어간다. rowcount는 이 문장이 바꾼 행 수다. 재고가 5면 조건을 만족한 한 행이 4가 되고 changed는 1이다. 이 경우에만 예약 번호를 발급하고 예약 행을 넣는다. 상품이 없거나 재고가 부족하면 changed는 0이며, 예약 대신 409와 unavailable 본문을 준비한다. 조회한 재고를 Python에서 빼서 무조건 덮어쓰는 방식과 구분한다.7

어느 업무 결과든 반환하기 전에 같은 요청 기록에 저장한다.

db.execute(
    "UPDATE receipts SET status=?, response=? "
    "WHERE tenant=? AND operation=? AND request_key=?",
    (status, json.dumps(body, sort_keys=True)) + identity
)
db.execute("COMMIT")

성공 경로라면 status는 201, body는 예약 번호와 수량을 담는다. 재고 부족 경로라면 409와 오류 본문이다. json.dumps로 본문을 문자열로 바꾸고, 뒤에 identity를 붙여 갱신할 요청 행을 지정한다. 이렇게 결과를 채운 후 커밋해야 재시도가 완성된 응답을 찾는다. 응답 저장 전에 커밋하면 입력만 남은 행을 완료로 잘못 다루게 된다.

status, response의 완성을 DB 스키마 자체가 강제하는 것은 아니다. 이 함수의 모든 최초 처리 성공 커밋 경로가 값을 채우고, 중간 오류 경로가 롤백하는 순서로 지킨다. 이 테이블을 직접 쓰는 다른 코드가 생기면 그 전제를 다시 검토해야 한다.

오류를 받았을 때 커밋 여부를 나눈다

전체 함수는 DB 작업을 try 안에서 실행한다. 예외가 발생하면 정상 경로의 다음 줄로 가지 않고 except로 이동한다. 예외 처리의 핵심은 다음과 같다.

except BaseException:
    if db.in_transaction:
        db.execute("ROLLBACK")
    raise
finally:
    db.close()

db.in_transaction은 아직 트랜잭션이 열려 있는지 확인한다. 열려 있으면 ROLLBACK으로 현재 변경을 취소한다. 마지막 raise는 오류를 성공으로 바꾸지 않고 호출자에게 다시 전달한다. finally의 연결 종료는 성공과 실패 어느 경로에서도 실행된다.

초기 재고 5에서 fault="before_commit"으로 실패시키면, 재고를 4로 바꾸고 예약과 응답까지 기록했어도 아직 미확정이다. 예외 처리에서 롤백한 뒤에는 재고 5, 예약 없음, 요청 기록 없음으로 돌아간다. 반면 fault="after_commit"은 확정 뒤에 예외를 만든다. 이때 in_transaction은 거짓이므로 롤백하지 않고 재고 4, 예약 한 개, 완성된 응답이 남는다. 1장의 “응답을 못 받았지만 예약은 존재하는” 상황을 코드에서 구분한 것이다.

전체 실행 코드

이제 위 경로를 한 함수로 연결한다. 다음 내용을 idempotency_demo.py에 저장하고 python3 idempotency_demo.py로 실행할 수 있다. SQL은 코드의 executescript와 execute에서 실행된다. 예제의 fault는 실패 지점을 만드는 시험 전용 인자이며 외부 요청으로 노출하는 설정이 아니다.

isolation_level=None은 Python 드라이버의 암묵적 트랜잭션 시작 대신 SQL로 직접 BEGIN, COMMIT, ROLLBACK을 제어하려는 설정이다. 코드가 트랜잭션을 사용하지 않는다는 의미가 아니다. 연결 객체의 with 문은 트랜잭션의 커밋·롤백을 돕지만 연결을 닫아 주지는 않는다. 초기화와 조회에는 closing을 사용하고, 요청 함수는 finally에서 닫는다.7

initialize는 비어 있는 실험 DB의 준비 작업이다. executescript 전체를 하나의 업무 트랜잭션으로 감싼 예시가 아니며, 운영 중인 DB의 마이그레이션 도구로 쓰지 않는다.

import json
import sqlite3
import tempfile
import uuid
from contextlib import closing
from pathlib import Path

OPERATION = "reserve-v1"


def initialize(path):
    with closing(sqlite3.connect(path)) as db:
        db.executescript("""
        CREATE TABLE inventory (
            tenant TEXT NOT NULL, product TEXT NOT NULL,
            stock INTEGER NOT NULL CHECK (stock >= 0),
            PRIMARY KEY (tenant, product)
        );
        CREATE TABLE reservations (
            id TEXT PRIMARY KEY NOT NULL,
            tenant TEXT NOT NULL, product TEXT NOT NULL,
            qty INTEGER NOT NULL CHECK (qty > 0)
        );
        CREATE TABLE receipts (
            tenant TEXT NOT NULL, operation TEXT NOT NULL,
            request_key TEXT NOT NULL, payload TEXT NOT NULL,
            status INTEGER, response TEXT,
            PRIMARY KEY (tenant, operation, request_key)
        );
        INSERT INTO inventory VALUES ('shop-a', 'book', 5);
        INSERT INTO inventory VALUES ('shop-b', 'book', 5);
        """)


def reserve(path, tenant, key, product, qty, fault=None):
    # tenant is assumed to come from an authenticated caller.
    if not all(isinstance(x, str) and x for x in (tenant, key, product)):
        raise ValueError("non-empty strings required")
    if type(qty) is not int or not 1 <= qty <= 1000:
        raise ValueError("qty must be an integer from 1 to 1000")
    payload = json.dumps({"product": product, "qty": qty}, sort_keys=True)
    identity = (tenant, OPERATION, key)
    db = sqlite3.connect(path, timeout=5, isolation_level=None)
    try:
        db.execute("BEGIN IMMEDIATE")
        old = db.execute(
            "SELECT payload, status, response FROM receipts "
            "WHERE tenant=? AND operation=? AND request_key=?", identity
        ).fetchone()
        if old is not None:
            if old[0] != payload:
                raise ValueError("key reused with different input")
            result = (old[1], json.loads(old[2]))
            db.execute("COMMIT")
            return result
        db.execute(
            "INSERT INTO receipts (tenant, operation, request_key, payload) "
            "VALUES (?, ?, ?, ?)", identity + (payload,)
        )
        if fault == "after_claim":
            raise RuntimeError("failure before business write")
        changed = db.execute(
            "UPDATE inventory SET stock=stock-? "
            "WHERE tenant=? AND product=? AND stock>=?",
            (qty, tenant, product, qty)
        ).rowcount
        if changed == 1:
            reservation_id = uuid.uuid4().hex
            db.execute("INSERT INTO reservations VALUES (?, ?, ?, ?)",
                       (reservation_id, tenant, product, qty))
            status, body = 201, {"reservation_id": reservation_id, "qty": qty}
        else:
            status, body = 409, {"error": "unavailable"}
        db.execute(
            "UPDATE receipts SET status=?, response=? "
            "WHERE tenant=? AND operation=? AND request_key=?",
            (status, json.dumps(body, sort_keys=True)) + identity
        )
        if fault == "before_commit":
            raise RuntimeError("failure after business write")
        db.execute("COMMIT")
        if fault == "after_commit":
            raise TimeoutError("response lost after commit")
        return status, body
    except BaseException:
        if db.in_transaction:
            db.execute("ROLLBACK")
        raise
    finally:
        db.close()


if __name__ == "__main__":
    with tempfile.TemporaryDirectory() as directory:
        path = str(Path(directory) / "demo.db")
        initialize(path)
        first = reserve(path, "shop-a", "request-7", "book", 1)
        again = reserve(path, "shop-a", "request-7", "book", 1)
        with closing(sqlite3.connect(path)) as db:
            stock = db.execute(
                "SELECT stock FROM inventory WHERE tenant='shop-a'"
            ).fetchone()[0]
            count = db.execute("SELECT count(*) FROM reservations").fetchone()[0]
        print("same_response:", first == again)
        print("stock:", stock)
        print("reservations:", count)

실행하면 same_response: True, stock: 4, reservations: 1이 출력된다. 예약 번호 자체는 실행마다 새로 생성되지만, 동일 실행에서 두 호출이 반환한 번호는 같다. 이 출력은 함수가 한 번만 호출됐다는 뜻이 아니라 두 번 호출돼도 예약 효과가 하나라는 뜻이다.

코드에서 보장의 위치 찾기

앞의 부분 예제에서 찾은 보장 위치는 전체 코드에서도 같다. 재생 분기의 조기 반환, 조건부 차감, 응답을 채운 뒤 커밋하는 순서, 활성 트랜잭션만 롤백하는 예외 처리를 연결해서 읽으면 된다.

실패 지점을 바꿔 관찰하기

위 데모의 실행 부분은 정상 요청과 같은 키의 결과 재생만 수행한다. 다음 표는 별도 검증 코드에서 본문 함수를 호출해 확인한 결과다. 각 시험은 새 DB를 사용하며, 타임아웃은 네트워크 패킷 유실 대신 커밋 직후 예외로 표현한다.

after_commit 시험은 먼저 reserve(path, "shop-a", "request-7", "book", 1, fault="after_commit")를 호출한다. 검증 코드는 전달된 TimeoutError를 받아 두고 같은 DB를 조회해 재고 4와 예약 1개를 확인한다. 이어 fault 없이 같은 키와 입력으로 다시 호출하면 저장한 예약 번호를 받고, 다시 조회한 재고도 4다. 예외를 처리하지 않으면 프로그램이 그 자리에서 끝나므로 이 재호출까지 자동으로 진행되지는 않는다.

시험 실패 직후 확정 상태 같은 키로 다시 호출한 결과
after_claim 요청 기록·예약 없음, 재고 5 최초 처리, 예약 1개와 재고 4
before_commit 변경을 시도했지만 롤백되어 예약 없음, 재고 5 최초 처리, 예약 1개와 재고 4
after_commit 요청 기록·예약 1개, 재고 4 저장 결과 재생, 재고 4 유지
같은 키로 동시 호출 쓰기 트랜잭션이 순차 진행 모두 완료되면 같은 응답, 예약 1개
같은 키, 다른 수량 기존 결과 보존 입력 충돌로 거절, 추가 차감 없음
다른 키, 같은 수량 서로 다른 논리 요청 두 예약과 재고 3

동시 요청 시험은 별도 DB 연결을 사용하는 여러 스레드로 수행한다. 프로세스가 커밋 전에 강제로 끝나는 시험도 별도로 포함한다. 이것은 SQLite의 복구 동작을 작은 로컬 환경에서 확인하는 것이지, 저장장치 전원 차단이나 여러 데이터센터 장애 시험은 아니다.

6장. 보장이 성립하는 조건을 증명한다

이 구현이 지키려는 불변식(invariant)은 실행 중 어떤 경로를 택하더라도 확정된 상태에서 유지해야 할 규칙이다. 여기서는 같은 (tenant, operation, key)에 대해 확정 응답이 하나이며, 성공 응답에 대응하는 예약과 재고 감소가 중복되지 않는다는 규칙이다. 재고 부족 응답이라면 예약과 감소는 없다.

이를 두 경우로 나누어 볼 수 있다. 최초 실행이 커밋 전 실패했다면 효과와 요청 기록이 함께 롤백된다. 재시도가 최초 처리자가 되어도 이전 확정 효과가 없으므로 중복되지 않는다. 최초 실행이 커밋했다면 요청 기록과 효과가 함께 존재한다. 다음 실행은 그 기록을 확인하고 업무 변경을 건너뛴다.

이 논증에는 동시 요청이 둘 다 최초 실행으로 커밋하지 않는다는 조건이 추가된다. 예제의 SQLite 쓰기 직렬화와 기본 키 제약이 그 역할을 한다. 프로그램의 조회 순서만 지켰다거나 같은 프로세스에서 한 번 시험했다는 것으로는 이 조건을 대신할 수 없다.

범위를 빼고 “정확히 한 번”이라고 부르지 않는다

이 예제가 약속하는 것은 다음 전제 안에서 확정된 DB 효과의 중복 방지다.

  • 모든 처리자가 같은 DB와 같은 식별 규칙을 사용한다.
  • 요청 기록과 업무 변경을 같은 트랜잭션으로 쓴다.
  • 재시도는 같은 키와 입력을 사용하고, 그 기록이 보존돼 있다.
  • 요청 기록을 우회해서 업무 테이블을 변경하는 경로가 없다.
  • DB가 약속한 원자성과 내구성을 유지하는 저장·복구 환경을 사용한다.

내구성(durability)은 확정한 변경을 장애 뒤에도 유지하는 성질이다. DB의 기록이 사라지거나 과거 백업으로 되돌아간 뒤, 외부에 이미 전달된 결과까지 그대로 유지된다고 가정할 수는 없다. 이 글의 실험은 정상 저장장치와 로컬 DB에 한정하며 모든 하드웨어·복구 설정을 검증하지 않는다.4

중복이 없다는 것과 결국 성공한다는 것

원하지 않는 일이 일어나지 않는 성질을 안전성(safety), 필요한 처리가 결국 진행되는 성질을 진행성(liveness)이라고 부른다. 중복 차감을 막아도 DB가 계속 잠겨 있거나 서비스가 복구되지 않으면 예약은 완료되지 않는다. 멱등성이 재시도 성공을 보장하는 것은 아니다.

“적어도 한 번 전달”도 전달 체계의 재시도·보존·복구 조건을 필요로 한다. 여기에 중복을 흡수하는 업무 처리까지 갖추었을 때 특정 저장소의 효과를 한 번으로 제한할 수 있다. 메시지가 영원히 오지 않거나 처리할 수 없는 입력을 계속 보내면 효과가 생기지 않는다. 그러므로 실행 횟수, 전달 횟수, 확정된 효과를 구별해서 설명해야 한다.

재시도 자체에도 한도가 필요하다. 기다리는 시간을 점차 늘리는 지수 백오프(exponential backoff)와 무작위 편차(jitter)는 여러 호출자가 동시에 다시 몰리는 현상을 줄이려는 장치다. 중복 방지는 아니므로 같은 키를 유지하는 규칙을 대체하지 않는다. 인증 실패나 입력 충돌처럼 같은 요청으로 해결되지 않는 오류를 무조건 재시도해서도 안 된다.8

7장. 어디서 보장이 깨지는가

요청 기록과 업무 변경을 나누어 커밋한다

먼저 “처리했음”만 저장하고 재고를 줄이기 전에 종료되면, 재시도는 처리 기록을 보고 건너뛴다. 재고는 그대로이고 예약도 없다. 중복이 아니라 누락이다.

반대로 재고와 예약을 먼저 커밋하고 요청 기록을 쓰기 전에 종료되면, 재시도는 최초 실행으로 판단한다. 같은 예약이 다시 생기고 재고가 한 번 더 줄어든다. 두 저장의 순서만 바꾸어서는 실패할 수 있는 틈을 없애지 못한다. 동일 트랜잭션이 필요한 이유는 두 작업 사이를 빠르게 만드는 것이 아니라, 하나만 확정된 상태를 배제하기 위해서다.

검증용 반례는 기록만 남거나 효과만 남은 부분 확정 상태를 별도 커밋으로 만든 뒤 재시도한다. 기록 선행에서는 예약 0개와 처리 기록 1개, 효과 선행에서는 예약 2개와 재고 3이 남는다. 정상 구현의 안전성을 보여주는 양성 시험뿐 아니라 잘못된 구조가 정말 실패하는지도 확인한 것이다.

기록을 지우면 과거를 다시 알아볼 수 없다

처리 기록에는 저장 비용이 든다. 일정 기간 뒤 없애는 보존 시간(time to live, TTL)을 둘 수 있지만, 이 구현에는 자동 삭제가 없다. 성공 기록을 직접 지운 뒤 오래된 요청을 같은 키로 보내면 새 요청처럼 실행되어 예약이 두 개가 된다.

보존 기간은 “클라이언트가 보통 몇 초 재시도하는가”만으로 정하지 않는다. 지연된 네트워크 요청, 작업 큐의 재전달, 장애 복구, 수동 재처리가 얼마나 늦게 같은 업무를 다시 보낼 수 있는지를 함께 고려한다. 본문에는 모든 서비스에 맞는 기본 일수를 정하지 않는다.

Stripe API v1의 문서는 최소 24시간이 지난 키를 제거할 수 있고, 제거된 뒤 같은 키를 재사용하면 새 요청을 만든다고 설명한다. 이 수치는 해당 제품·API 세대의 계약이며 우리 예제의 만료 설정도, 일반 표준도 아니다.9

오래된 요청을 영구히 구분해야 한다면 업무 자체의 고유 식별자나 별도의 지속 기록을 둘 수 있다. 이 경우에도 정상적인 추가 적립·환불·재구매를 같은 업무로 합치지 않는 식별 규칙이 필요하다. 기록을 삭제하면서 무기한 중복 방지를 약속할 수는 없다.

외부 API를 DB 트랜잭션 안에서 호출하면

예약 트랜잭션 안에서 외부 결제 API를 호출했다고 하자. 결제는 성공했지만 로컬 DB가 롤백되면, 로컬 요청 기록이 없어 재시도가 결제를 다시 시도할 수 있다. DB 트랜잭션을 열어 둔 시간에 실행했다는 것과 그 트랜잭션이 외부 효과를 함께 확정한다는 것은 다르다.

외부 결제사가 같은 논리 요청을 식별하고 결과를 재사용하는 계약을 제공한다면, 재시도에도 같은 외부 키를 유지해 중복 가능성을 다룰 수 있다. 여전히 결제사와 로컬 DB의 상태를 대조하고, 응답이 불확실한 작업을 복구하는 절차가 필요하다. 로컬 롤백이 이미 보낸 이메일이나 결제를 되돌려 주지는 않는다.

업무 변경과 “나중에 보낼 작업”을 같은 DB 트랜잭션에 기록하는 아웃박스(outbox)도 사용할 수 있다. 별도 전달자가 기록을 읽어 외부로 보내지만, 전송 성공 뒤 완료 표시 전에 죽으면 다시 보낼 수 있다. 아웃박스는 발송할 의도를 잃지 않도록 돕고, 수신 측 중복 처리는 여전히 별개다. 이것은 동일 DB 원자성에서 도출한 경계 설명이며 외부 서비스를 실행한 시험 결과가 아니다.

서로 다른 변경의 순서가 뒤집힌다

현재 주소가 서울인 계정에 버전 2의 “부산으로 변경”을 반영한 뒤, 지연된 버전 1의 “서울로 변경”을 받으면 어떻게 될까? 값 덮어쓰기만 하면 서울로 돌아간다. 각 요청을 연달아 반복할 때 상태가 같다는 사실이 이 역전을 막지는 않는다.

저장된 버전보다 입력 버전이 큰 경우에만 값을 갱신하면 이 반례를 막을 수 있다. 단, 버전을 누가 발급하고 비교하는지, 누락된 중간 변경을 건너뛰어도 되는지는 업무 규칙이다. 최신 주소를 덮어쓰는 경우와 잔액에 각 증감을 모두 적용해야 하는 경우는 다르다. 단순히 높은 버전만 받는 규칙을 모든 이벤트에 적용하면 필요한 중간 효과를 버릴 수 있다.

8장. 실제 API와 이벤트에 옮길 때 결정할 것

HTTP 메서드와 제품의 결과 저장 정책을 구분한다

4~5장의 구현은 요청 키로 비멱등 차감을 감싸고 결과까지 저장했다. 이 구조를 실제 API에 적용할 때는 HTTP 메서드의 의미와 제품의 저장 정책을 나누어 읽어야 한다.

HTTP 표준에서 멱등 메서드는 의도된 서버 효과를 기준으로 한다. PUT, DELETE, 그리고 안전한 메서드들이 이에 해당한다. 여기서 안전함(safe)은 클라이언트가 상태 변경을 요청하지 않는 의미이지, 서버가 접근 로그조차 쓰지 않는다는 뜻이 아니다. 서버가 매 요청의 접근 로그를 따로 남겨도 요청한 자원 변경의 멱등성과 모순되지 않는다.2

반대로 메서드가 POST라는 이유만으로 멱등 처리 구현을 할 수 없는 것은 아니다. 애플리케이션이 식별자와 중복 처리 규칙을 제공할 수 있다. 하지만 클라이언트가 아무 POST나 같은 방식으로 재시도해도 된다고 추정해서는 안 된다. 헤더에 어떤 문자열을 붙였다는 사실이 서버 구현을 바꾸지는 않는다.

같은 키의 입력 비교는 실제 API에도 쓰인다. 다만 어떤 응답을 저장하고 언제 재시도를 허용하는지는 제품별로 다르다. Stripe API v1은 실행이 시작된 요청의 결과를 저장하며 같은 키의 매개변수를 비교한다. 조회한 문서는 API v2의 실패 재시도 동작을 별도로 설명하므로, 여기서는 v1의 규칙에만 한정한다. 이는 한 제품의 계약이지, 모든 멱등 API가 동일하게 동작한다는 뜻은 아니다.9

우리 예제의 409 저장도 그런 선택 중 하나다. HTTP가 멱등성을 정의한다고 해서 모든 서버가 같은 오류를 저장하거나, 보충된 재고를 같은 키로 다시 평가하는 것은 아니다. 아래 항목을 구체적으로 정해야 호출자가 결과를 해석할 수 있다.

결정 이 예제의 선택 다른 선택에서 확인할 것
키 범위 고객 영역·업무 버전·키 사용자 단위인지, 자원 단위인지
입력 충돌 예외로 거절, 기존 결과 보존 HTTP 오류와 클라이언트 수정 절차
동시 중복 DB 쓰기 대기 후 조회 처리 중 응답과 결과 조회 경로
확정 결과 성공과 재고 부족 저장 어떤 실패가 실행 시작 이전인지
보존 자동 만료 없음 만료 후 재실행·거절 규칙
외부 효과 구현하지 않음 외부 키·대조·복구 절차

오래 걸리는 작업은 이 예제처럼 트랜잭션을 계속 열어 두기 어렵다. “접수됨”을 먼저 확정하고 작업자가 나중에 실행한다면, 접수와 업무 완료는 서로 다른 상태다. 진행 중 기록의 소유자, 작업자 장애 뒤 재개, 완료 결과 조회를 따로 설계해야 한다. 시간이 지났다고 소유권을 넘겼을 때 이전 작업자가 계속 실행할 수도 있다. 이 문서의 짧은 단일 트랜잭션 구현을 그런 비동기 작업에 그대로 붙여서는 안 된다.

이벤트의 식별자를 고른다

여기서는 메시지를 보관했다가 업무 처리자에게 전달하는 중개 서버인 브로커(broker)를 가정한다. 메시지를 받아 업무를 처리하는 쪽이 소비자(consumer)다. 이 가상 전달 모델에서 브로커는 소비자의 완료 확인을 받지 못한 메시지를 다시 전달할 수 있다.

발신자가 같은 사건을 다시 보내도 유지하는 이벤트 ID를 사용하고, 소비자는 처리 기록과 DB 효과를 함께 확정한다. 예를 들어 소비자가 e-7이라는 예약 이벤트를 처음 받으면 DB에 e-7의 처리 기록과 예약·재고 변경을 함께 커밋한다. 그다음 브로커에 처리 완료를 알린다. 브로커가 완료를 확인하면 그 소비자에게 같은 메시지를 다시 처리하라고 보내지 않는 것이 이 예제의 정상 흐름이다. 수신자가 받을 때마다 새 ID를 만들면 중복을 알아보지 못한다.

주문 번호 하나를 키로 삼는 것이 항상 옳지는 않다. 한 주문에 적립, 추가 적립, 취소가 각각 있다면 모두 정상적인 다른 효과다. 반면 한 주문에 최초 적립을 하나만 허용한다는 업무 규칙이라면 그 업무 식별자를 유일하게 제한할 수 있다. 전송 봉투의 ID와 업무 자체의 ID가 무엇을 구별하는지 먼저 정한다.

DB 반영을 끝낸 뒤 브로커에 메시지 처리 완료를 알리는 확인 응답(acknowledgement)을 보낸다고 하자. DB 커밋 직후 종료되면 메시지가 재전달될 수 있지만 DB의 요청 기록으로 중복 효과를 건너뛸 수 있다. 확인 응답을 먼저 보내고 DB 반영 전에 종료되면 재전달되지 않아 누락될 수 있다. 이 순서 판단은 처리 기록의 존재만큼 중요하다. 구체적인 브로커의 보존·재전달 규칙은 별도로 확인해야 한다.

성공률만 보지 않는다

운영에서는 요청 시도 수와 실제 업무 생성 수를 분리해 관측한다. 결과 재생 횟수, 같은 키의 입력 충돌, DB 대기와 롤백, 오래된 키의 재등장도 구분하면 재시도가 어디서 생기는지 추적하기 쉽다. 중복 억제 건수가 많다는 사실만으로 장애라고 단정할 수는 없지만, 갑자기 늘면 응답 유실이나 클라이언트 재전송을 조사할 근거가 된다.

저장한 응답은 과거 결과다. 예약이 나중에 취소돼도 최초 201 응답 재생은 그 예약의 현재 유효성을 보장하지 않는다. 현재 상태를 확인하는 조회 API와 최초 요청 결과를 복구하는 기능을 분리해야 한다. 결과를 재생하기 전에도 현재 호출자의 권한을 확인하고, 응답에 민감정보를 과도하게 저장하지 않는 기준을 정한다.

새로운 시스템을 검토할 때는 “멱등하다”라는 형용사 대신 다음 문장을 채우면 된다. 어떤 식별자의 반복에 대해, 어느 저장소의 어떤 효과를, 어떤 보존·동시성·복구 조건 아래 중복시키지 않는가. 이 문장이 분명하면 타임아웃, 중복 요청, 복구 시점을 바꿨을 때 남는 상태도 설명할 수 있다.

선택적으로 확인할 질문

  1. 같은 고객이 같은 상품을 한 개씩 두 번 사고 싶다면 입력 본문을 키로 사용해도 되는가?
  2. 커밋 뒤 응답이 유실된 상황에서 재시도 키를 새로 만들면 무엇이 달라지는가?
  3. 재고 부족 결과를 저장한 뒤 재고가 보충됐다. 같은 키로 다시 호출하면 성공해야 하는가?
  4. 요청 기록만 백업에서 사라지고 외부 결제는 남아 있다면 로컬 DB의 중복 방지가 결제까지 지키는가?
해설
  1. 두 구매는 다른 의도다. 본문만으로 합치면 정상 구매 하나를 없앤다. 별도 키가 필요하다.
  2. 서버는 새로운 논리 요청으로 보고 추가 예약을 만들 수 있다. 새 키는 불확실성을 해결하지 않고 중복 판정 대상을 바꾼다.
  3. 이 예제는 저장한 실패를 재생하므로 성공하지 않는다. 새로운 구매 의도에는 새 키를 사용한다. 다른 API라면 그 API의 실패 저장 규칙을 확인한다.
  4. 그렇지 않다. 외부 결제는 이 DB 트랜잭션 밖에 있고 기록 보존이라는 전제도 깨졌다. 외부 시스템의 같은 업무 식별자와 결과 조회·대조가 필요하다.

용어 찾아보기

용어 본문
논리 요청·시도·시간 초과 1장
멱등성·결과 재생 2장
안전한 메서드·HTTP와 제품 정책 8장
멱등 키·테넌트·입력 비교 3장
트랜잭션·원자성·유일 제약·경쟁 상태 4장
불변식·안전성·진행성·내구성 6장
보존 시간·외부 효과·순서 역전 7장

근거와 더 읽을 범위

아래 출처는 정의와 구현 전제를 확인하기 위한 자료다. 가상의 테이블 구조, 예약 코드, 불변식 논증과 실패 주입 사례는 이 문서의 교육용 설계다. 제품 공식 구현이나 운영 권장 템플릿을 그대로 옮긴 것이 아니다. 링크를 열지 않아도 본문의 핵심 흐름을 이해할 수 있도록 설명했고, 제품별 전체 옵션과 성능 튜닝은 제외했다.

  1. AWS Builders’ Library, Making retries safe with idempotent APIs, “Same client request ID, different intent”, “Late arriving requests”. 고정 버전이 없는 공식 설명. https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/ ↩ ↩2

  2. IETF, RFC 9110 (2022), §9.2.1 Safe Methods, §9.2.2 Idempotent Methods. https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2 ↩ ↩2

  3. SQLite 공식 문서, Transactions §2, §2.1, §2.2. 이동 문서. https://www.sqlite.org/lang_transaction.html ↩ ↩2

  4. SQLite 공식 문서, Atomic Commit In SQLite, §3 및 §9. 이동 문서. https://www.sqlite.org/atomiccommit.html ↩ ↩2

  5. SQLite 공식 문서, CREATE TABLE §3.5 PRIMARY KEY, §3.6 UNIQUE, §3.8 NOT NULL. 이동 문서. https://www.sqlite.org/lang_createtable.html ↩

  6. SQLite 공식 문서, Isolation In SQLite. 이동 문서. https://www.sqlite.org/isolation.html ↩

  7. Python 3.9 공식 문서, sqlite3 “Controlling Transactions”, “Using sqlite3 efficiently”. https://docs.python.org/3.9/library/sqlite3.html#controlling-transactions ↩ ↩2

  8. AWS Builders’ Library, Timeouts, retries, and backoff with jitter. 고정 버전이 없는 공식 설명. https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/ ↩

  9. Stripe 공식 API 문서, Idempotent requests의 API v1 규칙. 2026-10-23 조회한 이동 문서이며 제품별 계약을 설명하는 비교 자료다. https://docs.stripe.com/api/idempotent_requests ↩ ↩2