6 분 소요

📦 브라우저→S3 동시 편집 버전 가드 2부작 — 백엔드 없이 브라우저에서 공용 data.json 을 직접 저장하는 대시보드에서, 여러 명이 동시에 편집할 때 뒷사람이 앞사람 변경을 덮어쓰는 lost update 를 막는 이야기. rev 카운터 + 저장 직전 read-before-write 로 유실을 잡고(1편), 그 사이 남는 짧은 경쟁 구간을 조건부 쓰기(If-Match/ETag)로 원자적으로 닫는 것(2편)까지 이어집니다. 전체 2편.

  1. rev 버전 가드 — 저장 직전 재조회로 덮어쓰기 유실 막기
  2. 조건부 쓰기(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-Match PUT → 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-MatchIf-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 하나를 여럿이 편집하는 사내 도구엔 이 정도면 충분히 단단해요.

일단 오늘은 여기까지…..
이 시리즈는 여기서 마무리할게요. 다음엔 이 편집분에 간단한 접근 제한(누가 저장할 수 있는지)을 얹는 이야기로 찾아올게요.


← 이전 글: (1/2) 백엔드 없이 브라우저→S3 공유 편집, 덮어쓰기 유실 막기 (rev 버전 가드)