본문 바로가기
빌드노트 검색

빌드노트

curl은 성공했는데 서버에는 같은 POST가 두 번 들어왔다

curl의 POST 재시도가 최종 200으로 끝나도 서버 효과는 두 번 발생할 수 있다. 카운터 증가 뒤 503을 반환하는 loopback 서버에서 재시도 0회·1회와 --fail을 비교하고, 최종 상태·수신 횟수·중복 방지 계약을 따로 확인한다.

curl --retry 1로 보낸 POST가 최종 200을 반환했어도 서버 작업이 한 번만 수행됐다고 볼 수는 없다. 첫 요청이 값을 바꾼 뒤 503을 반환하면, 재시도된 요청이 같은 값을 다시 바꿀 수 있다. 이를 확인하려고 만든 loopback 실험에서는 curl의 종료 코드가 0인데 서버 카운터는 2가 됐다. 실제 서비스의 장애 기록이 아니라, 처리와 응답의 순서를 정해 놓고 비교한 결과다.

이 증상을 조사한다면 기존 curl 명령과 호출하는 셸 스크립트에서 --retry부터 찾는다. 같은 작업을 반복해도 되는지 확인하지 못했다면 해당 쓰기 요청의 명시적 재시도를 --retry 0으로 멈추고, 이미 보낸 작업은 서버의 결과 조회로 확인한다. 이 변경이 이전 요청을 취소하거나 바깥 실행기의 재시도까지 막아 주지는 않는다.

마지막 200에는 앞선 503이 보이지 않는다

curl의 --retry 문서 (새 탭에서 열림)는 503을 일시 오류에 따른 재시도 대상으로 설명한다. --retry 1의 1은 최초 요청 뒤에 허용하는 추가 재시도 횟수다. 기본값인 0은 재시도하지 않는다. 온라인 매뉴얼은 확인 당시 8.23.1 기준이어서, 직접 실행한 curl 8.21.0의 같은 옵션 문서 (새 탭에서 열림)도 대조했다.

실험 서버는 127.0.0.1에만 열었다. 본문 delta=1을 받은 POST마다 메모리 카운터를 1 올리고, 첫 요청에는 503, 다음 요청에는 200을 반환하게 했다. 첫 503 이전에 이미 카운터가 증가하도록 일부러 구성했다. 응답 본문은 비워 두었고 외부 서비스나 실제 데이터 저장소는 사용하지 않았다. 비교 조건마다 새 서버와 카운터 0에서 시작했다.

다음은 그 서버에 보낸 PowerShell 명령이다. 자신의 테스트 핸들러에서 비교하려면 먼저 위 처리 순서와 수신 로그를 준비하고, $url을 직접 소유한 loopback 엔드포인트의 주소로 지정한다. 예를 들어 자기 서버가 8765 포트에서 /increment를 제공할 때만 $url = 'http://127.0.0.1:8765/increment'로 설정한다. 주소만 실제 서비스로 바꾸어 시험하지 않는다. 아래 두 줄은 최종 HTTP 상태와 curl 종료 코드를 함께 확인한다.

curl.exe -q --noproxy '*' --silent --show-error --connect-timeout 1 --max-time 2 --retry 1 --retry-max-time 4 --data-binary 'delta=1' --write-out 'http=%{http_code}\n' $url
"curl_exit=$LASTEXITCODE"

Windows PowerShell 5.1에서 curl.exe를 호출했으며 curl 8.21.0, 실험 서버는 Python 3.12.10이었다. --data-binary가 POST 본문을 보내므로 -X POST는 붙이지 않았다. 첫 인자인 -q는 기본 curl 설정 파일을 읽지 않게 하고, --noproxy '*'는 실험 요청이 프록시를 거치지 않게 한다. 본문 전송 (새 탭에서 열림), 기본 설정 제외 (새 탭에서 열림), 프록시 제외 (새 탭에서 열림)

클라이언트의 실제 출력은 두 줄이었다.

http=200
curl_exit=0

같은 실행의 서버 로그에는 아래 두 행이 남았다. counter는 요청 수를 따로 센 값이 아니라, 각 요청이 수행한 증가 작업 뒤의 값이다.

POST body=delta=1 counter=1 status=503
POST body=delta=1 counter=2 status=200

메서드는 두 번 모두 POST였고 본문 바이트도 같은 7바이트였다. curl은 첫 요청의 증가 작업을 되돌리지 않고 같은 입력을 다시 보냈다. 마지막 상태를 출력하는 %{http_code}만으로는 이 수신 이력을 알 수 없다. --write-out의 http_code 정의 (새 탭에서 열림)

--retry 1만 --retry 0으로 바꾸고 새 서버에서 실행하자 http=503, curl_exit=0이 나왔고 카운터는 1이었다. --fail을 쓰지 않은 이 조건에서는 HTTP 오류 응답을 정상적으로 받았다고 curl 자체가 실패 종료하지는 않았다. 그래서 종료 코드와 HTTP 상태도 구별해야 한다.

첫 요청부터 200을 반환하는 정상 조건도 비교했다. 이때는 재시도 설정이 0이든 1이든 수신 1회, 카운터 1이었다. 재시도 횟수는 항상 채우는 반복 횟수가 아니다. 위 명령을 자기 서버에서 비교할 때도 매번 카운터와 첫 응답 조건을 초기화해야 한다. 초기화 없이 두 번째 명령을 보내면 처음부터 200을 받아 문제가 사라진 것처럼 보일 수 있다.

이 입력에서 연결 실패가 나면 우선 자신의 테스트 서버가 떠 있는지와 주소를 확인한다. 503 뒤에도 수신이 한 번뿐이면 실제 첫 응답과 curl 버전, 재시도·시간 설정을 대조한다. 최종 200인데 카운터가 1이라면 서버의 중복 처리 방지나 첫 응답 조건을 확인한다. 어느 경우든 클라이언트의 최종 출력만으로 실행 횟수를 추정하지 않는다.

--fail은 중복 실행을 되돌리지 않는다

같은 두 오류 조건에 --fail도 추가했다. 재시도 0회에서는 최종 503에 curl 종료 코드가 22로 바뀌었지만, 이미 수행한 증가 작업은 남아 카운터가 1이었다. 재시도 1회에서는 503 다음 200을 받고 종료 코드 0, 카운터 2가 됐다. 첫 503에 대한 오류 문구는 표준 오류에 남았다.

--fail 문서 (새 탭에서 열림)는 HTTP 오류를 curl 오류로 보고하게 하는 옵션을 설명한다. 이 옵션을 붙였다고 서버의 작업이 취소되거나, 같은 POST의 반복 실행이 막히지는 않는다. 이번 실험은 --retry-all-errors 없이도 이 결과가 나왔다.

재시도를 켜기 전에 작업의 계약을 확인한다

RFC 9110의 멱등성 설명 (새 탭에서 열림)에서 중요한 기준은 동일 요청을 여러 번 적용한 의도된 효과가 한 번 적용했을 때와 같은가다. 이번의 ‘1 증가’는 반복할 때마다 결과가 달라진다. 본문이 같다는 사실만으로 멱등하지는 않으며, POST 자체가 멱등성을 보장하지도 않는다. RFC는 요청 의미가 실제로 멱등하거나 최초 요청이 적용되지 않았음을 알 수 있는 수단 없이, 비멱등 요청을 자동 재시도하지 않도록 권고한다.

따라서 먼저 서버 수신 기록과 실제 작업 결과를 나누어 확인한다. 같은 업무 식별자로 요청이 두 번 들어왔는지 보고, 해당 업무의 상태 조회에서 효과도 두 번 발생했는지 확인한다. 수신 2회라도 서버가 중복을 걸렀다면 효과는 1회일 수 있다. 반대로 503을 받았다는 이유만으로 효과가 0회라고 판단할 수는 없다.

중복 방지 키를 지원하는 API라면 서버가 어떤 키를 어떤 범위와 기간 동안 기억하는지, 같은 키의 요청이 겹치거나 본문이 달라질 때 어떻게 처리하는지 확인해야 한다. 이 키가 재시도 사이에도 유지되고 서버의 처리 계약이 실제로 중복 효과를 막아야 한다. 클라이언트가 임의로 Idempotency-Key 헤더를 추가하는 것만으로 그 계약이 생기지는 않는다. 이는 API 설계에서 확인할 조건이며 이번 실험에서 중복 방지 구현을 검증한 것은 아니다.

직접 확인한 범위는 작은 고정 본문의 HTTP/1.1 POST와, 정상 200 및 효과 발생 뒤 503을 주는 합성 서버다. 실제 데이터베이스의 커밋·롤백, 연결이 끊겨 응답을 못 받은 경우, 입력 스트림의 재전송, 프록시나 작업 실행기의 재시도는 검증하지 않았다. 자신의 실패 건에서는 서버의 작업 결과를 조회한 뒤 재전송 여부를 정해야 한다.

← 전체 글 보기