빌드노트
오래된 화면의 PUT이 최신 문서를 덮어쓴다면: If-Match로 저장 거절하기
같은 ETag를 읽은 두 클라이언트가 차례로 저장할 때 200/200과 200/412의 최종 문서를 비교했다. 서버의 강한 ETag 검사와 원자적 갱신, 412 뒤 초안을 보존하고 다시 읽는 흐름을 설명한다.
문서를 열어 수정하는 사이 다른 사람이 먼저 저장했다. 내 화면에서 뒤늦게 보낸 PUT이 성공하면서 그 사람의 수정이 사라진다면, 서버가 내가 어느 상태를 보고 편집했는지 확인하지 않은 것이다. 조회 때 받은 강한 ETag를 If-Match에 넣어 보내고, 서버가 현재 값과 비교한 뒤 일치할 때만 저장하도록 해야 한다. 화면에서 저장 직전에 한 번 더 조회하는 것만으로는 그 조회와 저장 사이의 변경을 막지 못한다.
이 글에서는 메모리 문서 하나를 둔 loopback 서버로 이 차이를 확인했다. 실제 서비스의 장애 기록이 아니라 Windows 11·Python 3.12.10에서 별도로 실행한 합성 실험이다.
두 요청이 성공했지만 먼저 저장한 내용은 사라졌다
문서의 처음 본문은 seed, ETag는 "r1"이었다. 클라이언트 A와 B가 각각 GET해 같은 본문과 태그를 받은 뒤, A는 A edit, B는 B edit를 차례로 PUT했다. PUT은 문서 전체를 보내는 방식이며, 두 클라이언트 모두 상대의 수정 내용을 합치지 않았다.
먼저 두 PUT에서 If-Match를 생략했다. 다음은 응답과 마지막 GET을 묶어 적은 관찰 결과다.
A GET: 200, ETag "r1", body seed
B GET: 200, ETag "r1", body seed
A PUT: 200, ETag "r2", body A edit
B PUT: 200, ETag "r3", body B edit
마지막 GET: 200, ETag "r3", body B edit
둘 다 성공했지만 최종 문서에는 A의 수정이 없었다. 서버가 요청을 하나씩 처리해도 이런 갱신 유실은 생긴다. B가 편집한 바탕이 오래됐다는 사실을 검사하지 않았기 때문이다.
새 서버의 문서를 같은 초기 상태로 두고, 이번에는 두 클라이언트가 자신이 읽었던 태그를 If-Match: "r1"로 보냈다. A의 PUT은 200과 "r2"를 받았다. 이어 보낸 B의 요청은 아래와 같았다. 주소와 포트는 이 실험에서만 사용했고 서버는 실행 후 종료했다.
PUT /document HTTP/1.1
Host: 127.0.0.1:40955
Connection: close
X-Lab-Client: B
Content-Type: text/plain; charset=utf-8
Content-Length: 6
If-Match: "r1"
B edit
응답은 412 Precondition Failed였다. 응답 본문은 precondition failed였고, 마지막 GET은 "r2"와 A edit를 반환했다. 상태 코드만 바뀐 것이 아니라, 거절된 PUT이 문서를 바꾸지 않았음을 확인했다. 위의 X-Lab-Client는 로그에서 A와 B를 구별하는 실험용 헤더이며 저장 조건에는 참여하지 않았다.
ETag를 보내는 곳과 검사하는 곳을 함께 바꾼다
RFC 9110 §13.1.1 (새 탭에서 열림)은 If-Match를 현재 표현에 대한 전제조건으로 정의하고, 서버가 메서드 수행 전에 강한 비교로 검사하도록 규정한다. 조건이 거짓이면 요청한 수정을 수행하면 안 된다.
자기 API에서는 먼저 GET 응답의 본문과 ETag를 같은 편집 상태로 보관하는지 확인한다. 저장 요청을 만드는 곳에서는 그 ETag를 따옴표까지 그대로 If-Match에 넣는다. "r1"은 이 실험 서버가 붙인 값일 뿐이다. 클라이언트가 숫자를 올리거나, 본문을 임의로 해시해서 서버의 태그를 대신 만들지 않는다. 다른 문서에서 받은 태그도 섞지 않는다.
서버에서는 GET이 반환한 본문과 ETag가 같은 상태를 가리켜야 한다. 이 실험은 저장에 성공할 때마다 버전을 올려 새 태그를 만들었다. 강한 검증자는 표현 데이터가 바뀔 때 값도 바뀌어야 한다는 것이 RFC 9110 §8.8.1 (새 탭에서 열림)의 조건이다. 응답에 ETag라는 이름의 헤더를 넣는 것만으로는 이 조건을 만족하지 못한다.
저장 처리에서는 현재 태그 확인과 본문 교체를 하나의 원자적 작업으로 묶는다. 이번 서버는 Python의 직렬 HTTPServer를 사용했고, 한 요청의 검사와 변경이 끝나기 전에 다른 요청을 처리하지 않았다. 외부에서 문서를 바꾸는 경로도 없었다. Python의 서버 문서 (새 탭에서 열림)는 기본 TCPServer가 요청을 동기적으로 처리한다고 설명한다.
실제 서버가 요청을 병렬로 처리한다면 태그를 비교한 뒤 별도로 UPDATE하는 사이에 다른 저장이 끼어들 수 있다. DB에서 기대한 버전이 여전히 같은 경우에만 갱신하고 성공 여부를 돌려주는 연산 등으로 연결해야 한다. 이는 실험에서 얻은 구현 판단이며, 이 글에서 특정 DB의 트랜잭션이나 여러 서버 사이의 동시성 제어를 검증한 것은 아니다.
412 뒤에는 초안을 보존하고 최신 문서를 읽는다
B의 412는 B edit가 저장됐다는 응답이 아니다. 412의 정의 (새 탭에서 열림)는 서버에서 요청 전제조건이 거짓으로 판정됐다는 뜻이다. 이 서버는 412 응답에 ETag도 넣지 않았다. 클라이언트는 실패 응답에 최신 태그가 반드시 들어 있다고 가정할 수 없다.
B가 다시 GET하자 본문은 A edit, 태그는 "r2"였다. 여기서 필요한 것은 태그 교체만이 아니다. 새 태그를 얻자마자 옛 본문 B edit를 그대로 보내면 A의 내용을 다시 지울 수 있다. 화면에 남은 초안과 처음 읽은 본문을 보존하고, 새 문서와 비교해 어떤 내용을 저장할지 정한다.
실험에서는 두 변경을 반영하겠다는 명시적인 선택으로 본문을 A edit + B edit로 만들고 If-Match: "r2"를 붙였다. 이 PUT은 200, 다음 GET은 "r3"와 A edit + B edit를 반환했다. 자동 병합 알고리즘을 시험한 결과는 아니다. 새 본문을 정한 뒤에도 다른 저장이 먼저 끝나면 다시 412가 날 수 있으므로 같은 처리로 돌아가야 한다.
여기서는 서로 다른 수정 두 개를 보냈고 서버는 불일치마다 412를 반환했다. RFC는 이미 적용된 변경이라고 판별할 수 있는 경우 2xx 응답도 허용한다. 이 실험은 응답 유실 뒤 같은 저장을 재전송하는 그 경로까지 구현하지 않았다.
W/와 별표는 오래된 저장을 통과시키는 해법이 아니다
강한 비교는 양쪽 태그가 모두 약하지 않고 내부 태그가 같아야 성립한다. 약한 비교와의 차이는 RFC 9110 §8.8.3.2 (새 탭에서 열림)에 정의돼 있다. 다음 변형도 각각 새 합성 문서에서 실행했다.
- 현재 태그가
"r2"일 때If-Match: W/"r2"를 보냈다. 내부 문자열이 같아도 412였고A edit가 유지됐다. - 서버가 처음부터
W/"r1"을 반환하도록 바꿨다. 같은 약한 태그를 보내도 412, 요청에서 W/만 지워"r1"로 보내도 412였고 본문은seed로 남았다. 서버 쪽 태그가 여전히 약하므로 접두사 삭제로 강한 검증자를 만들 수 없다. - A의 저장 뒤 B가
If-Match: *를 보냈다. 200으로 저장돼 최종 본문이B edit가 됐다. 별표는 현재 표현이 존재하는지를 검사하므로, 내가 읽었던 버전인지 구별하지 않는다. - A의 저장 뒤 B가 If-Match를 생략해도 이 서버는 200으로 받아들여
B edit로 덮었다. 모든 편집 요청을 보호하려면 서버에서 조건부 저장을 필수로 하는 API 정책도 정해야 한다.
If-Match의 별표 의미는 RFC 9110 §13.1.1 (새 탭에서 열림)에 따른다. 약한 ETag만 제공하는 API라면 쓰기 전제조건에 쓸 강한 검증자를 서버에서 제공할 수 있는지부터 확인한다. 412를 없애려고 태그를 가공하거나 헤더를 빼는 것은 위 실험에서 보호를 우회하는 결과였다.
자기 환경의 확인 순서도 같다. 두 클라이언트가 같은 문서와 ETag를 읽고, 첫 저장 뒤 태그가 바뀌는지 본다. 다음에는 옛 태그를 붙인 두 번째 저장의 실제 전송 헤더, 응답 상태, 마지막 GET의 본문을 함께 확인한다. 오래된 저장이 200으로 새 내용을 덮는다면 헤더가 서버까지 전달됐는지, 원 서버가 강한 비교를 하는지, 비교와 갱신 사이에 틈이 있는지 좁혀 본다.
이번 관찰은 단일 메모리 문서와 직렬 처리에 한정된다. 허용한 If-Match 입력도 태그 하나 또는 별표뿐이며 태그 목록 전체의 파서 검증은 하지 않았다. DB·다중 프로세스·프록시·인증·브라우저 편집 UI는 실행하지 않았다. 304에서 저장된 응답을 재사용할지 고르는 문제와 달리, 여기서 확인한 것은 오래된 상태를 바탕으로 보낸 쓰기를 서버가 거절하는가이다.