(1/2) 백엔드 없이 브라우저→S3 공유 편집, 덮어쓰기 유실 막기 (rev 버전 가드)
📦 브라우저→S3 동시 편집 버전 가드 2부작 — 백엔드 없이 브라우저에서 공용 data.json 을 직접 저장하는 대시보드에서, 여러 명이 동시에 편집할 때 뒷사람이 앞사람 변경을 덮어쓰는 lost update 를 막는 이야기. rev 카운터 + 저장 직전 read-before-write 로 유실을 잡고(1편), 그 사이 남는 짧은 경쟁 구간을 조건부 쓰기(If-Match/ETag)로 원자적으로 닫는 것(2편)까지 이어집니다. 전체 2편.
- rev 버전 가드 — 저장 직전 재조회로 덮어쓰기 유실 막기 ← 지금 글
- 조건부 쓰기(If-Match/ETag) — GET~PUT 경쟁 구간까지 닫기
Summary
백엔드 서버 없이, 브라우저에서 편집한 결과를 S3 의 공용 data.json 하나에 직접 PUT 해서 저장하는 대시보드를 하나 운영하고 있었어요. 혼자 쓸 땐 아무 문제가 없었는데, 편집자가 둘 이상 되니까 사고가 났습니다. 두 사람이 각자 수정 모드로 편집하다가 나중에 저장한 사람이 앞사람의 변경을 통째로 덮어써서 소리 없이 날려버리는 거예요. 이걸 lost update(갱신 유실) 라고 불러요.
락을 걸자니 백엔드가 없고, 편집 빈도도 낮아서 과해요. 그래서 낙관적 동시성 제어(optimistic concurrency control) 를 택했습니다. 평소엔 아무도 막지 않다가, 저장하는 순간에만 “내가 읽은 뒤 남이 먼저 저장했는지”를 검사해서, 충돌이면 저장을 차단하는 방식이에요. 서버가 없으니 이 검사도 전부 브라우저 JS 안에서 합니다.
💡 이 글에서 다루는 것
- lost update 가 왜 생기나 (공용 파일 하나 + 덮어쓰기 PUT)
rev정수 카운터와LOADED_REV(내 기준선) 개념- 저장 흐름 — 저장 직전 재조회(read-before-write) 후 rev 비교
- 충돌 났을 때 UX — 저장 차단 + 백업/최신본 복구 버튼
- 저장 실패 폴백 — 편집분 자동 다운로드로 무손실 보장
- 로드 정책 — 서버 우선 + 미저장 초안만 보존
- 이 방식의 한계(진짜 원자적 락은 아님)
1. 왜 유실이 생길까
구조를 먼저 보면 원인이 바로 보여요. 편집 결과가 향하는 곳은 공용 파일 하나예요.
사용자 A ─┐
├─▶ PUT data.json ─▶ S3 (공용 파일 하나)
사용자 B ─┘
PUT 은 그냥 통째로 덮어쓰기예요. A 가 09:00 에 로드해서 편집하는 동안, B 도 09:01 에 로드해서 편집한 뒤 09:05 에 먼저 저장하고, A 가 09:07 에 저장하면 — A 의 저장이 B 의 결과를 그대로 덮어씁니다. B 입장에선 방금 저장한 내 작업이 경고 하나 없이 사라진 거예요.
핵심은 “덮어쓰기 자체”가 아니라, 덮어쓰기 전에 아무도 그 사이 누가 저장했는지 확인하지 않는다는 점이에요. 그 확인 한 단계만 끼워 넣으면 유실은 막을 수 있어요.
2. rev 와 LOADED_REV
확인의 기준이 될 버전 번호가 필요해요. data.json 안에 정수 필드 rev 를 하나 둡니다. 규칙은 단순해요.
- 저장이 성공할 때마다
rev를 1 씩 올린다. - 클라이언트는 로드한 시점의 서버
rev를LOADED_REV(내 기준선)로 기억해둔다. - 저장할 땐 payload 에 항상
rev: LOADED_REV + 1을 실어 보낸다.
여기서 지켜야 할 불변식은 이거예요.
✅ 내가 저장하려는 순간, 서버의
rev가 여전히LOADED_REV와 같아야 안전하다.
서버 rev 가 내 LOADED_REV 보다 커졌다면 = 내가 로드한 뒤 누군가 이미 저장했다는 뜻이고, 그게 바로 충돌이에요.
// 로드 시점의 서버 rev 를 기준선으로 캡처. 파일이 아직 없으면 0(최초 저장 취급).
var LOADED_REV = 0;
초안(아직 저장 안 한 편집 상태)이 있든 없든 로드할 때 서버 rev 는 무조건 캡처해둬요. 그래야 나중에 저장할 때 비교할 기준선이 확보돼요.
3. 저장 흐름 — 저장 직전에 다시 읽는다
가장 중요한 결정은 언제 비교하느냐예요. 로드 시점의 rev 를 그대로 믿으면 안 돼요. 편집을 20분 하는 동안 남이 저장했을 수 있으니까요. 그래서 저장을 누른 바로 그 순간 서버 최신본을 다시 읽어서(read-before-write) 비교합니다.
async function saveShared() {
const payload = collectEdits(); // 현재 편집 상태 전체
try {
// 1) 저장 직전, 서버 최신본을 다시 읽는다 (캐시 금지)
let server = null;
const res = await fetch(DATA_URL, { cache: "no-store" });
if (res.status === 404) {
server = null; // 파일 자체가 없음 = 최초 저장, 진행 허용
} else if (!res.ok) {
throw new Error("GET " + res.status); // 도달은 했는데 rev 확인 불가 → 차단
} else {
server = await res.json();
}
// 2) 충돌 검사: 서버 rev 가 내 기준선과 다르면 남이 먼저 저장한 것
if (server && server.rev !== LOADED_REV) {
return conflictBlock(server.rev); // PUT 하지 않고 차단
}
// 3) 안전 → 이때만 PUT. rev 는 기준선 + 1
payload.rev = LOADED_REV + 1;
const put = await fetch(PUT_URL, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!put.ok) throw new Error("PUT " + put.status);
// 4) 저장 성공 → 기준선 갱신 + 초안 정리
LOADED_REV = payload.rev;
dirty = false;
clearLocalDraft();
editnote("✅ 공유 저장됨(rev " + LOADED_REV + ")");
} catch (err) {
downloadFallback(payload, err); // 무손실 폴백 (5절)
}
}
설계에서 신경 쓴 지점 세 개를 짚을게요.
- 저장 직전 재조회가 핵심이에요. 로드 시점이 아니라 저장 순간의 서버 rev 와 비교하니까, 편집 시간이 아무리 길어도 그 사이에 낀 남의 저장을 정확히 잡아냅니다.
- 404 는 진행을 허용해요. 파일이 아예 없다는 건 아직 아무도 저장 안 한 최초 상황이니, 그냥 저장하면 돼요.
- 404 가 아닌 GET 오류는 곧 차단이에요. 서버에 도달은 했는데 rev 를 확인할 수 없는 상태(5xx 등)라면, 덮어쓰기가 위험하니 PUT 하지 않고 폴백으로 보냅니다. “확인 못 하면 저장 안 한다”가 안전한 기본값이에요.
⚠️ 읽기 경로(
DATA_URL)와 쓰기 경로(PUT_URL)를 나눠 두는 것도 포인트예요. 읽기는 배포/캐시 경로로, 쓰기는 업로드 오리진의 절대경로로 보내서 서로 간섭하지 않게 했어요.
4. 충돌이 나면 — 막되, 잃지는 않게
충돌 검사에 걸리면(conflictBlock) 제일 중요한 건 내 편집을 PUT 하지 않는 것, 그리고 그렇다고 내 편집을 버리지도 않는 것이에요. 붉은 배너로 상황을 알리고 복구 경로 두 개를 줍니다.
function conflictBlock(serverRev) {
showBanner(
"⚠ 다른 사람이 먼저 저장했습니다(서버 rev " + serverRev + ") — 저장하지 않았습니다",
[
// 지금 내 편집 상태를 파일로 내려받아 두기 (수동 병합용)
{ label: "내 편집 백업 내려받기", onClick: () => downloadJSON(collectEdits()) },
// 서버 최신본을 다시 로드 (내 화면을 최신 기준으로 리셋)
{ label: "서버 최신본 불러오기", onClick: () => location.reload() },
]
);
}
이렇게 하면 사용자는 “내 것부터 백업해두고, 서버 최신본을 불러온 다음, 그 위에 내 변경을 다시 얹는다” 는 안전한 순서로 복구할 수 있어요. 무엇 하나 소리 없이 사라지지 않는 게 이 UX 의 목적이에요.
5. 저장이 아예 안 될 때 — 자동 다운로드 폴백
충돌 말고도 저장이 실패하는 경우가 있어요. 네트워크가 끊겼거나, 업로드 정책이 막혀 PUT 이 403 으로 거부되거나, GET 이 5xx 를 뱉거나. 이럴 때 편집분을 그냥 날리면 최악이니까, 편집 내용을 파일로 자동 다운로드하고 수동 반영 방법을 함께 안내해요.
function downloadFallback(payload, err) {
downloadJSON(payload); // 편집분을 data.json 으로 자동 저장
editnote("⚠ 공유 저장 실패 — data.json 내려받음(수동 반영 필요)");
alert(
"공유 저장에 실패했어요. 내려받은 data.json 을 아래로 반영하세요:\n\n" +
"aws s3 cp data.json s3://<your-bucket>/report/data.json \\\n" +
" --content-type application/json --region ap-northeast-2"
);
}
🚨 위 버킷 경로(
s3://<your-bucket>/...)는 예시로 가린 값이에요. 본인 버킷/경로로 바꿔서 안내하세요.
저장 인프라(S3 버킷 정책이나 CloudFront 동작 설정)가 잠깐 막혀 있어도, 사용자는 손에 든 data.json 을 CLI 로 직접 올려서 편집분을 잃지 않고 반영할 수 있어요. “안 되면 최소한 파일이라도 손에 쥐여준다”가 원칙이에요.
6. 로드 정책 — 서버 우선 + 미저장 초안만 보존
버전 가드는 로드 정책과 짝을 이뤄야 제대로 굴러가요. 예전엔 브라우저 로컬 저장(초안)이 서버본을 가려버려서, 남이 저장한 최신본을 못 보고 옛날 걸 편집하는 문제가 있었거든요. 그래서 로드 순서를 이렇게 정리했어요.
async function init() {
renderFromSeed(); // 1) 임베드 시드로 즉시 1차 렌더(빈 화면 방지)
try {
// 2) 항상 서버를 다시 읽는다 = 서버가 진실의 원천
const res = await fetch(DATA_URL, { cache: "no-store" });
if (res.ok) {
const server = await res.json();
LOADED_REV = server.rev || 0; // 초안이 있어도 rev 는 캡처(저장 기준선 확보)
if (!hasLocalDraft()) {
applyServer(server); // 미저장 초안이 없으면 서버본 적용
} else {
showDraftBanner(); // 초안이 있으면 유지 + 노란 배너로 경고
}
}
} catch (e) {
// fetch 실패(오프라인, file:// 등) → 초안이 있으면 초안 사용
if (hasLocalDraft()) useLocalDraft();
}
}
정리하면 이래요.
- 서버가 진실의 원천이에요. 로드할 땐 항상 서버
data.json을 다시 읽고, rev 를 기준선으로 캡처해요. - 미저장 초안이 없으면 그냥 서버본을 적용해요. 가장 흔한 경우고, 늘 최신본을 봐요.
- 미저장 초안이 있으면 초안을 유지하되 노란 배너로 “서버 최신본과 다를 수 있어요” 라고 경고하고, 언제든 [서버본 불러오기(로컬 폐기)] 를 누를 수 있게 해요.
- 저장에 성공하면 그 브라우저의 초안을 폐기(
clearLocalDraft)해서, 다시 서버본과 동기화된 상태로 돌아가요.
이렇게 하면 “옛 초안이 최신 서버본을 덮어 가리는” 사고 없이, 미저장 편집만 안전하게 살릴 수 있어요.
7. 한눈에 요약과 한계
한 그림으로 요약하면 이래요.
| 상황 | 동작 |
|---|---|
| 서버 rev = 내 기준선 | PUT 진행, 성공 시 rev + 1 |
| 서버 rev ≠ 내 기준선 | 저장 차단 + 붉은 배너(백업/최신본 복구) |
| 파일 없음(404) | 최초 저장으로 보고 진행 허용 |
| GET/PUT 오류 | 저장 차단 + data.json 자동 다운로드(수동 반영) |
| 로드 시 | 서버 우선, 미저장 초안만 보존(노란 배너) |
- 메커니즘: 락 없는 낙관적 동시성 제어.
rev정수 카운터 + 저장 직전 read-before-write 비교. - 안전 조건:
서버 rev === 내 기준선일 때만 PUT, 성공하면rev + 1. - 무손실 보장: 충돌이든 실패든, 편집분은 배너의 백업 버튼이나 자동 다운로드로 항상 손에 남아요.
마지막으로 한계도 솔직히 적어둘게요. 이건 진짜 원자적 락이 아니에요. GET 으로 재조회한 뒤 PUT 하기까지의 아주 짧은 구간에서 두 사람이 정확히 겹치면, 이론상 둘 다 통과할 수 있어요. 다만 실제로 두 명이 동시에 저장을 눌러도 대개 한쪽이 rev 불일치로 걸러지고, 편집자가 소수인 사내 도구 수준에선 이 정도로 충분했어요. 진짜 강한 보장이 필요하면 조건부 쓰기(If-Match/ETag)나 서버 측 compare-and-swap 으로 한 단계 더 올리면 돼요.
일단 오늘은 여기까지…..
다음 글에서는 이 낙관적 가드를 조건부 쓰기(ETag)로 한 번 더 조여서, 그 짧은 경쟁 구간까지 닫는 이야기를 정리해볼게요.
다음 글 →: (2/2) 조건부 쓰기(If-Match/ETag)로 경쟁 구간까지 닫기 — S3 원자적 버전 가드