요청은 타임아웃됐는데 서버 작업은 끝났다면
목차
파일 처리 요청을 보냈는데 화면에는 타임아웃이 뜬다. 사용자는 다시 누르고 싶다. 그런데 서버는 이미 요청을 반영했고 성공 응답만 도착하지 않았다면, 두 번째 클릭은 무엇을 하게 될까?
문서 처리 파이프라인, 웹 화면, 비동기 파일 작업을 개발하면서 재시도 문제를 여러 경계에서 다뤘다. 여러 작업에서 반복해서 구분한 것은 작업의 실패와 결과를 확인하지 못한 상태였다. 응답을 못 받았다는 사실만으로 서버가 아무 일도 하지 않았다고 판단하면, 복구 과정이 중복 실행의 원인이 된다.
아래에서는 내부 구현 대신 카운터를 한 번 증가시키는 독립 예제를 사용한다. 회사의 실제 API나 장애 수치를 옮긴 사례가 아니다. 가상의 업무는 단순하지만, 요청을 보낸 뒤 결과를 잃는 경계는 실제 HTTP 연결과 SQLite 트랜잭션으로 재현한다.
응답을 기다리는 시간과 서버의 실행 시간
처리 순서를 요청 전송, 서버의 변경 확정, 클라이언트의 응답 수신으로 나누면 틈이 보인다. 서버가 변경을 확정한 뒤 응답이 유실되면 데이터는 바뀌었지만 클라이언트는 그 사실을 모른다. 반대로 클라이언트의 대기 시간이 먼저 끝나고 서버가 나중에 변경을 확정할 수도 있다.
여기서 커밋은 트랜잭션의 변경을 확정하는 동작이다. 클라이언트의 타임아웃은 응답을 기다리던 코드가 정한 한계에 도달했다는 관찰이다. 두 동작이 서로를 자동으로 취소해 주지는 않는다. 서버가 연결 종료를 감지해 작업을 취소하도록 구현했더라도, 이미 확정한 변경까지 되돌아가는지는 별도 계약이다.
다음 그림은 먼저 서버가 변경을 확정하고, 성공 응답을 보내지 않은 채 연결을 닫는 경우다. 위에서 아래로 시간이 흐른다. 클라이언트 쪽의 결과 불명과 저장소 쪽의 완료가 동시에 성립한다.
그림 아래의 예제에서는 재전송할 ID와 본문을 바꿀 수 있다. 서버가 원래 작업을 한 번 반영한 상태에서 시작한다. 같은 ID로 보내면 저장된 결과를 돌려주고, 새 ID로 보내면 새로운 작업으로 받아들인다. 이 화면은 순서를 이해하기 위한 메모리 모델이며 실제 네트워크 실험은 다음 절의 Python 파일에서 실행한다.
결과 불명은 서버의 새로운 처리 단계가 아니다. 클라이언트가 가진 정보의 상태다. 작업은 이미 완료됐거나, 아직 실행 중이거나, 서버에 도착하지 않았을 수 있다. 그러므로 결과 불명을 곧바로 실패로 저장하고 새 작업을 만드는 코드는 가능한 결과 중 하나를 근거 없이 고른 셈이다.
같은 작업을 다시 보내려면 무엇이 같아야 할까
카운터를 1 증가시키는 요청을 보냈다고 하자. 첫 요청이 반영됐지만 응답을 받지 못했다. 다음 요청에도 같은 JSON 본문을 넣으면 같은 작업일까?
본문만으로는 구별할 수 없다. 사용자가 의도적으로 카운터를 두 번 증가시킨 경우도 본문은 같다. 서버가 구별해야 할 것은 내용의 우연한 일치와 하나의 의도를 다시 전달한 경우다. 요청을 처음 만들 때 정한 작업 ID가 그 의도를 나타낸다. Idempotency-Key는 이런 ID를 전달하는 데 쓰는 헤더 이름이다.
같은 작업 ID로 여러 번 요청해도 업무 변경이 추가로 생기지 않는 성질을 멱등성이라고 한다. HTTP를 보낼 때마다 ID를 새로 만들면 서버는 재전송을 새 의도로 읽는다. AWS의 멱등 API 설계 글도 같은 파라미터가 언제나 같은 의도를 뜻하지 않는다는 이유로 호출자가 정한 요청 식별자를 설명한다.
| 클라이언트가 보내는 것 | 서버가 읽는 의미 | 이 예제의 결과 |
|---|---|---|
| 같은 ID, 같은 본문 | 원래 의도의 재전달 | 저장한 결과 반환 |
| 새 ID, 같은 본문 | 별도의 새 의도 | 카운터 추가 증가 |
| 같은 ID, 다른 본문 | 원래 ID의 의미가 바뀜 | 충돌로 거절 |
실제 서비스의 ID 범위에는 호출자나 계정, 수행할 연산도 필요하다. 서로 다른 사용자가 같은 문자열을 골랐다는 이유로 결과를 공유해서는 안 된다. 재시도 중 로그인 계정이 바뀌거나 사용자가 내용을 수정했다면, 이전 작업의 재전송인지 새 의도인지 다시 판단한다. 키를 알고 있다는 사실은 결과를 읽을 권한을 대신하지 않는다.
성공 응답을 끊어 중복 변경을 재현한다
전체 실행 파일을 저장해 실행한다. Python 표준 라이브러리만 사용한다. 서버는 127.0.0.1의 임시 포트에서 열리고, 종료할 때 스레드와 임시 데이터베이스를 정리한다. 외부 서비스에 요청하지 않는다.
python3 response-loss.py첫 대조 실험은 wrong-a라는 ID로 카운터를 증가시킨 뒤 응답을 끊는다. 클라이언트는 결과를 확인하지 못한다. 이어서 wrong-b로 같은 본문을 보내면 카운터는 2가 된다. 서버가 보기에는 서로 다른 두 작업이다.
두 번째 실험에서는 safe-a의 응답을 잃은 뒤에도 ID를 유지한다. 상태 조회와 재전송이 모두 처음 저장한 결과를 반환하는지 검사한다. 같은 요청을 다시 보내도 이 실험에서 추가된 값은 1이다.
결과를 기억하는 테이블을 여기서는 영수증이라고 부른다. 결제 기록을 뜻하는 이름은 아니다. 어떤 작업 ID에 어떤 입력과 결과가 확정됐는지 보관하는 기록이다. 결정적인 코드는 아래 부분이다. db는 실험용 SQLite 연결이다.
db.execute("BEGIN IMMEDIATE")
row = db.execute(
"SELECT amount, result FROM receipt WHERE operation=?", (operation,)
).fetchone()
if row:
db.rollback()
if row[0] != amount:
# HTTP 응답은 트랜잭션을 끝낸 뒤 보낸다.
return_conflict()
else:
return_saved_result(row[1])
else:
db.execute("UPDATE counter SET total=total+? WHERE id=1", (amount,))
result = db.execute("SELECT total FROM counter WHERE id=1").fetchone()[0]
db.execute("INSERT INTO receipt VALUES (?, ?, ?)", (operation, amount, result))
db.commit()본문에서는 HTTP 처리 부분을 return_conflict()와 return_saved_result()라는 설명용 이름으로 줄였다. 실행 파일에는 실제 409 응답과 저장한 결과의 반환 코드가 있다. amount가 이 예제에서 허용하는 유일한 입력이고, operation이 작업 ID다.
카운터 갱신과 영수증 삽입을 같은 트랜잭션에 묶어 둘 중 하나만 확정되는 상태를 막았다. 카운터만 먼저 확정하면 영수증을 남기기 전 종료된 요청이 재전송될 때 또 증가한다. 영수증만 먼저 확정하면 실제 변경을 하지 못했는데 완료 기록만 남을 수 있다. 둘을 함께 커밋해야 재전송이 확정된 변경과 연결된다.
이 실험의 BEGIN IMMEDIATE는 읽기 전에 쓰기 트랜잭션을 시작한다. SQLite는 한 번에 하나의 쓰기 트랜잭션만 허용한다. 이 경계를 이용해 영수증 확인과 최초 반영 사이의 경쟁을 직렬화한다. 쓰기 잠금 경합과 처리량의 비용도 함께 생긴다. 이 실험을 다른 DB나 여러 저장소에 옮길 때는 그곳의 고유 제약, 충돌 처리, 트랜잭션 범위를 다시 정해야 한다. SQLite 트랜잭션
Python 3.14.5와 SQLite 3.43.2에서 직접 실행한 출력은 다음과 같다. delta는 각 대조 실험 시작 직전과 종료 직후의 카운터 차이다. 앞선 실험의 누적값과 구별했다.
new key after lost response: total=2
same key after lost response: delta=1; receipt replayed
same key with changed payload: HTTP 409; delta=1
client timeout, lookup before commit: unknown
same request after release: completed; delta=1HTTP 전달은 여러 번 일어났고, 서버의 요청 처리 코드에도 여러 번 진입했다. 같은 ID의 업무 변경만 추가되지 않았다. 이를 실행 자체가 한 번뿐이었다고 표현하면 재전송 횟수와 처리 비용이 설명에서 빠진다.
상태 조회에 없으면 아직 안 한 것일까
마지막 두 출력은 더 까다로운 경우다. 서버는 late-a 요청을 받은 뒤 커밋 직전의 테스트 게이트에서 기다린다. 클라이언트의 소켓 대기 한계는 0.2초다. 서버가 게이트에 들어왔다는 이벤트를 먼저 확인하고, 클라이언트가 실제로 타임아웃된 뒤 상태를 조회한다.
이때 영수증은 없고 카운터도 그대로다. 그래도 요청이 실행되지 않을 것이라고 확정할 수는 없다. 게이트를 열면 이미 접수한 같은 요청이 커밋되고, 다음 조회는 완료를 반환한다. 임의의 sleep으로 서버 실행 순서를 추측하지 않고 이벤트로 멈춘 지점과 재개 시점을 정했다. 0.2초는 성능 측정값이 아니라 클라이언트 타임아웃을 일으키는 실험 설정이다.
| 관찰 시점 | 서버의 상황 | 클라이언트가 확인한 것 |
|---|---|---|
| 타임아웃 직후 | 요청을 받아 커밋 전에 대기 | 응답을 받지 못함 |
| 첫 상태 조회 | 영수증이 아직 없음 | 결과 불명 |
| 게이트 해제 뒤 | 원래 요청이 커밋됨 | 완료 영수증 |
단순한 미조회와 미실행의 확정은 다르다. 상태 조회가 다른 복제본이나 지연된 색인을 읽는다면, 완료한 작업도 당장 보이지 않을 수 있다. 이 실험은 같은 SQLite 파일을 읽기 때문에 그 지연조차 없다. 그런 단순한 환경에서도 아직 접수 중인 요청이 남아 있으면 빈 조회는 미실행 증명이 되지 않는다.
미실행을 확정하려면 서버의 접수 계약이 추가 정보를 제공해야 한다. 예를 들어 해당 작업을 더 이상 접수하지 못하도록 닫은 뒤 기존 실행이 없는지 확인하거나, 권위 있는 작업 원장에서 최종 미접수 상태를 확정할 수 있어야 한다. 구체적인 방법은 서버가 어떤 실행 경계를 소유하는지에 따라 달라진다. 클라이언트의 타이머만으로 이 증거를 만들 수는 없다.
키 하나로 해결되지 않는 경계
위 예제는 같은 데이터베이스 안의 변경만 보호한다. 영수증을 저장한 다음 외부 파일 처리기나 다른 API를 호출한다면 그 호출은 SQLite 트랜잭션에 포함되지 않는다. 외부 시스템에서 일을 끝냈는데 응답을 잃는 문제가 한 단계 뒤에서 다시 생긴다.
외부 시스템도 같은 논리 작업 ID를 받아 중복을 막는지, 확정 결과를 조회할 수 있는지 확인한다. 그런 계약이 없으면 호출자 쪽에 키를 저장했다는 이유만으로 외부 재호출이 안전해지지 않는다. 전달할 의도를 DB에 함께 저장하는 outbox 방식은 확정된 의도가 사라지는 문제를 다루지만, 전달 과정의 중복까지 자동으로 없애 주지는 않는다.
영수증의 수명도 보장의 일부다. 중복 기록을 지운 뒤 옛 요청이 도착하면 서버가 이를 새 요청으로 받아들일 수 있다. Stripe의 멱등 요청 문서는 키를 최소 24시간 보관한 뒤 제거할 수 있고, 제거한 키를 다시 사용하면 새 요청을 생성한다고 설명한다. 같은 키의 파라미터 불일치와 실행 시작 전의 검증 실패도 구분한다. 이 기간과 저장 조건은 Stripe의 계약이며 모든 API의 공통 기본값이 아니다.
그러므로 재시도 가능한 시간, 작업 ID의 보존 기간, 서버가 기록을 조회하는 경로를 함께 정한다. 페이지 새로고침이나 프로세스 재시작 뒤에도 같은 작업을 이어가야 한다면, 재전송할 ID를 휘발성 변수에만 두어서는 안 된다. 원래 ID를 잃은 상태는 새로운 ID를 만들 근거가 되지 않는다.
다시 누르기 전에 물어볼 것
읽기 요청과 데이터 변경 요청에 같은 재시도 정책을 적용하기 전에, 현재 연산이 추가로 만드는 효과를 살펴보자. 조회를 반복하는 비용과 파일을 다시 처리하거나 작업을 새로 등록하는 효과는 다르다. UI의 재시도 버튼도 현재 상태 재조회, 같은 작업의 재전송, 새 작업 생성을 구별해야 한다.
서버가 같은 ID와 같은 본문의 중복 처리를 보장한다면, 결과 불명인 원래 요청을 그 계약에 따라 다시 보내는 방법이 있다. 그런 보장이 없는 변경 요청은 상태 확인과 대사를 먼저 진행한다. 재시도 간격을 늘리는 backoff는 부하를 줄이지만, 무엇을 같은 작업으로 볼지 정해 주지는 않는다.
자신의 API에서 확인할 가장 작은 실험은 변경을 커밋한 직후 성공 응답을 끊는 것이다. 같은 ID로 다시 보냈을 때 기존 결과가 돌아오는지 확인하고, 이어서 같은 ID의 본문만 바꿔 보자. 추가 변경 없이 충돌을 알려 준다면 클라이언트와 서버가 같은 의도의 경계를 공유한다. 단순히 두 번째 요청이 성공했다는 사실보다, 첫 변경이 몇 번 반영됐는지가 재시도의 안전성을 설명한다.