(2/2) 조건부 쓰기(If-Match/ETag)로 경쟁 구간까지 닫기 — S3 원자적 버전 가드
📦 브라우저→S3 동시 편집 버전 가드 2부작 — 백엔드 없이 브라우저에서 공용 data.json 을 직접 저장하는 대시보드에서, 여러 명이 동시에 편집할 때 뒷사람이 앞사람 변경을 덮어쓰는 lost update 를 막는 이야기. rev 카운터 + 저장 직전 read-before-write 로 유실을 잡고(1편), 그 사이 남는 짧은 경쟁 구간을 조건부 쓰기(If-Match/ETag)로 원자적으로 닫는 것(2편)까지 이어집니다. 전체 2편.
- rev 버전 가드 — 저장 직전 재조회로 덮어쓰기 유실 막기
- 조건부 쓰기(If-Match/ETag) — GET~PUT 경쟁 구간까지 닫기 ← 지금 글
Summary
이전 글에서 rev 카운터와 저장 직전 재조회(read-before-write) 로 덮어쓰기 유실을 막았어요. 저장을 누르면 서버 최신본을 다시 GET 해서 rev 를 비교하고, 어긋나면 저장을 차단하는 방식이었죠. 대부분의 동시 저장은 여기서 걸러집니다.
그런데 1편 마지막에 솔직하게 적어둔 빈틈이 하나 있었어요. GET 으로 서버 rev 를 읽은 뒤 PUT 하기까지의 아주 짧은 구간이에요. 두 사람이 하필 이 구간에서 정확히 겹치면, 둘 다 “서버 rev = 내 기준선” 을 통과한 뒤 둘 다 PUT 해서 한쪽이 덮어써질 수 있어요. 이른바 TOCTOU(Time-of-check to time-of-use) 틈이에요.
이번 글은 이 틈을 닫습니다. 방법은 검사를 PUT 자체에 붙여서 저장 계층(S3)이 쓰는 순간 원자적으로 판정하게 하는 거예요. 바로 조건부 쓰기(conditional write) — If-Match 헤더에 내가 읽은 ETag 를 실어 보내는 방식입니다.
💡 이 글에서 다루는 것
- 1편의 rev 가드에 남는 경쟁 구간(TOCTOU)이 뭔지
- 왜 “읽고 나서 쓰기” 대신 “쓰기에 조건을 붙이기” 인가
- S3 조건부 쓰기 —
If-Match(ETag) 와If-None-Match: *- 클라이언트 재구성 — ETag 캡처 →
If-MatchPUT →412는 충돌- 실전 함정 — CloudFront 헤더 포워딩, CORS 로 ETag 노출
rev는 왜 그대로 남겨두나 (표시용 vs 정확성용)- 트레이드오프와 체크리스트
1. 남아 있던 경쟁 구간
1편의 저장 흐름을 시간순으로 펼쳐보면 틈이 보여요.
A: GET rev=5 ──────┐ ┌── PUT rev=6 (성공)
│ (이 사이 구간) │
B: GET rev=5 ──────┘ └── PUT rev=6 (A 를 덮어씀)
A 와 B 가 거의 동시에 GET 하면 둘 다 rev=5 를 읽어요. 둘 다 “서버 rev(5) = 내 기준선(5)” 검사를 통과하고, 둘 다 rev=6 으로 PUT 합니다. 나중에 쓴 쪽이 앞사람 걸 덮어써요.
원인은 검사(GET 비교)와 실행(PUT)이 서로 다른 두 번의 요청으로 나뉘어 있다는 데 있어요. 그 사이에 세상이 바뀔 수 있으니까요. 실사용에선 이 구간이 수십 밀리초라 어지간하면 한쪽이 걸리지만, “이론상 가능”을 “구조상 불가능”으로 바꾸는 게 이번 목표예요.
2. 읽고 나서 쓰기 → 쓰기에 조건을 붙이기
발상을 바꿔요. “먼저 읽어서 확인하고, 아무 일 없길 바라며 쓴다” 가 아니라, 쓰기 요청 자체에 조건을 달아 저장 계층이 쓰는 바로 그 순간 조건을 원자적으로 판정하게 합니다.
✅ 조건: “내가 마지막으로 읽은 그 버전이 지금도 그대로일 때만 써라. 아니면 거부해라.”
이러면 검사와 실행이 한 번의 원자적 연산으로 합쳐져요. 그 사이에 낄 틈이 없어집니다. 이게 조건부 쓰기(compare-and-swap 의 저장소 버전)예요.
3. S3 조건부 쓰기 — If-Match 와 If-None-Match
S3 는 객체마다 ETag 를 붙여요. 내용이 바뀌면 ETag 도 바뀌는, 일종의 내용 지문이에요. 이 ETag 를 조건으로 쓰면 돼요.
| 헤더 | 의미 | 실패 시 |
|---|---|---|
If-Match: <etag> |
현재 객체 ETag 가 이 값과 같을 때만 PUT | 412 Precondition Failed |
If-None-Match: * |
객체가 아직 없을 때만 PUT(최초 생성) | 412 Precondition Failed |
- 기존 파일 갱신엔
If-Match: <내가 읽은 ETag>를 써요. 내가 읽은 뒤 남이 저장했다면 서버 ETag 가 바뀌었을 테니412로 거부돼요. - 최초 생성엔
If-None-Match: *를 써요. 두 사람이 동시에 “처음 만들기” 를 시도해도 한쪽만 성공하고 나머지는412가 돼요.
💡 S3 의 PUT 대상
If-Match조건부 쓰기는 비교적 최근(2024년)에 추가된 기능이에요. 저장 계층이 직접 판정해주니, 앞단에 함수 하나 두지 않고도 원자적 CAS 를 얻을 수 있어요.
4. 클라이언트 재구성
핵심 변화는 두 가지예요. 로드할 때 ETag 를 캡처하고, 저장할 때 그 ETag 를 If-Match 로 실어 PUT 하는 것. 저장 직전 재조회 GET 은 이제 정확성을 위해선 필요 없어져요 — 조건부 PUT 한 번이 검사까지 겸하니까요.
먼저 로드예요. rev 는 JSON 본문에서, ETag 는 응답 헤더에서 캡처해요.
let LOADED_ETAG = null; // 로드 시점의 ETag = 내 기준선. 없으면 아직 파일 없음.
var LOADED_REV = 0; // rev 는 표시용으로 계속 유지
async function loadShared() {
const res = await fetch(DATA_URL, { cache: "no-store" });
if (res.status === 404) { LOADED_ETAG = null; return null; } // 파일 없음
if (!res.ok) throw new Error("GET " + res.status);
LOADED_ETAG = res.headers.get("ETag"); // ← 기준선은 이 ETag
const server = await res.json();
LOADED_REV = server.rev || 0; // 사람이 읽을 버전 번호
return server;
}
저장은 조건부 PUT 한 방이에요.
async function saveShared() {
const payload = collectEdits();
payload.rev = LOADED_REV + 1;
// 프리컨디션: 파일이 있으면 If-Match, 없으면 If-None-Match:*
const headers = { "Content-Type": "application/json" };
if (LOADED_ETAG) headers["If-Match"] = LOADED_ETAG;
else headers["If-None-Match"] = "*";
try {
const put = await fetch(PUT_URL, {
method: "PUT",
headers,
body: JSON.stringify(payload),
});
if (put.status === 412) { // 프리컨디션 실패 = 그 사이 남이 저장함
return conflictBlock(); // 저장 안 됨 → 복구 UX (1편과 동일)
}
if (!put.ok) throw new Error("PUT " + put.status); // 그 외 오류 → 폴백
LOADED_ETAG = put.headers.get("ETag"); // 새 기준선(PUT 응답의 ETag)
LOADED_REV = payload.rev;
dirty = false;
clearLocalDraft();
editnote("✅ 공유 저장됨(rev " + LOADED_REV + ")");
} catch (err) {
downloadFallback(payload, err); // 무손실 폴백 (1편과 동일)
}
}
바뀐 지점을 짚을게요.
- 검사와 쓰기가 한 요청으로 합쳐졌어요.
If-Match를 붙인 PUT 은 S3 가 쓰는 순간 ETag 를 대조하니, GET~PUT 사이에 낄 틈이 없어요. 2절에서 노린 원자성이 여기서 실현돼요. 412가 곧 충돌 신호예요. 1편에선 rev 를 손으로 비교해 충돌을 판정했는데, 이제 S3 가412로 대신 알려줘요. 충돌 UX(conflictBlock)는 1편 그대로 재사용해요 — 붉은 배너 + [내 편집 백업 내려받기] / [서버 최신본 불러오기].- 새 기준선은 PUT 응답의 ETag 예요. 저장 성공 후
LOADED_ETAG를 응답 헤더 값으로 갱신하면, 이어지는 저장도 곧바로 조건부로 이어져요.
5. 실전 함정 — CloudFront 와 CORS
로직은 위가 전부인데, 브라우저 → CloudFront → S3 경로에선 헤더 두 개 때문에 헛도는 경우가 많아요. 미리 짚어둘게요.
- CloudFront 가
If-Match·If-None-Match를 S3 로 전달하게 해야 해요. 오리진 요청 정책(origin request policy)에서 이 두 헤더를 포워딩 목록에 넣지 않으면, 조건이 S3 까지 도달하지 못하고 그냥 무조건 덮어써져요. 가드가 조용히 무력화되는 가장 흔한 원인이에요. - 브라우저가
ETag응답 헤더를 읽으려면 CORS 로 노출해야 해요. 응답에Access-Control-Expose-Headers: ETag가 없으면res.headers.get("ETag")가null이 나와요. 그러면 기준선이 안 잡혀 저장이 계속 최초 생성처럼 동작하죠.
🚨 두 설정 중 하나라도 빠지면 에러 없이 가드가 새요(덮어쓰기가 그냥 성공). 배포 후엔 일부러 충돌을 만들어
412가 실제로 뜨는지 한 번 확인하는 걸 추천드려요.
한 가지 더. S3 ETag 는 단일 PUT(멀티파트 아님) 객체에선 내용의 해시예요. 우리처럼 작은 JSON 하나면 그대로 쓰면 되지만, 멀티파트 업로드로 올린 객체는 ETag 형식이 달라요(- 뒤에 파트 수) — 설정 JSON 저장엔 해당 없지만 알아두면 좋아요.
6. rev 는 왜 그대로 두나
ETag 로 원자성이 확보됐으니 rev 는 사실 정확성을 위해선 없어도 돼요. 그래도 남겨둔 이유는 사람을 위한 것이에요.
| 역할 | 담당 |
|---|---|
| 원자성·충돌 판정(정확성) | ETag If-Match — 저장 계층이 판정 |
| 사람이 읽는 버전 표시·충돌 메시지 | rev — “✅ 공유 저장됨(rev 7)”, “서버 rev 8” |
ETag 는 "a1b2c3..." 같은 해시라 사람이 “몇 번째 저장” 인지 감을 못 잡아요. 반면 rev 는 단조 증가하는 정수라 배너·로그에 그대로 노출하기 좋아요. 정확성은 ETag 에, 가독성은 rev 에 나눠 맡기는 구성이에요.
7. 정리와 트레이드오프
두 편을 겹쳐 보면 이래요.
| 구분 | 1편 (rev 가드) | 2편 (조건부 쓰기) |
|---|---|---|
| 충돌 검사 위치 | 클라이언트가 GET 후 rev 비교 | S3 가 PUT 시 ETag 대조 |
| 경쟁 구간(TOCTOU) | 짧게 남음 | 없음(원자적) |
| 충돌 신호 | rev 불일치 | 412 Precondition Failed |
| 필요 조건 | 없음(순수 클라이언트) | S3 조건부 쓰기 + 헤더 포워딩 + CORS |
- 얻는 것: GET~PUT 사이의 마지막 틈이 사라져요. 두 사람이 정확히 겹쳐도 한쪽은
412로 확실히 걸러집니다. - 드는 값: 인프라 설정이 조금 늘어요(CloudFront 헤더 포워딩, CORS ETag 노출, S3 조건부 쓰기 지원). 순수 클라이언트만으로 끝나던 1편보다 손이 갑니다.
- 여전한 한계: 이건 락이 아니에요. 편집을 오래 붙들고 있으면 남이 먼저 저장해
412를 만날 수 있어요. 다만 이제 그 상황이 소리 없는 유실이 아니라 명확한 충돌로 드러나고, 편집분은 배너 백업·자동 다운로드로 항상 손에 남아요. 진짜 상호배제가 필요하면 편집 세션 단위의 서버 측 락으로 한 단계 더 올려야 해요.
정리하면, 1편이 “덮어쓰기를 대부분 잡는” 낙관적 가드였다면, 2편은 그걸 “구조상 못 빠져나가게” 조인 버전이에요. 작은 공용 JSON 하나를 여럿이 편집하는 사내 도구엔 이 정도면 충분히 단단해요.
일단 오늘은 여기까지…..
이 시리즈는 여기서 마무리할게요. 다음엔 이 편집분에 간단한 접근 제한(누가 저장할 수 있는지)을 얹는 이야기로 찾아올게요.