(1/2) 백엔드 없이 S3 JSON 을 설정 파일로 — ‘누군 수정됨, 누군 안 보임’ 을 없애기
- 백엔드 없이 S3 JSON 을 설정 파일로 — '누군 수정됨, 누군 안 보임' 을 없애기 ← 지금 글
- 서버리스 버전 가드 — 함수 하나로 설정 충돌을 원천 차단하기
Summary
S3 버킷에 config.json 하나 올려두고, 정적 HTML 이 그걸 fetch 해서 화면을 그리는 구조. 게다가 그 JSON 을 편집 화면에서 고치면 다시 S3 에 저장까지 합니다. 서버 하나 없이 정적 호스팅만으로 “설정 편집기” 를 만든 거예요. 깔끔하죠.
그런데 운영하다 보면 꼭 이런 제보가 들어옵니다.
“저는 수정한 게 잘 보이는데요?”
“저는 아직 옛날 화면이에요. 안 바뀌었어요.”
같은 파일을 보는데 사람마다 화면이 다릅니다. 이게 두 개의 서로 다른 병이 겹쳐서 나는 증상이에요. 하나는 읽는 쪽(캐시), 하나는 쓰는 쪽(덮어쓰기 유실) 문제입니다. 이 글에서 둘을 갈라서 잡고, 백엔드 없이도 “잘 저장되고 모두에게 같은 최신 화면” 이 뜨게 만들어볼게요.
💡 이 글에서 다루는 것
- 왜 사람마다 다른 화면이 뜨는가 — 두 가지 원인 구분
- 흔한 오해 정리: “S3 가 늦게 반영돼서 그래” 는 이제 틀린 말
- 읽기 고치기 ① —
Cache-Control로 캐시 수명 다루기- 읽기 고치기 ② — 포인터 파일 + 불변 캐시 패턴 (핵심)
- CloudFront 를 앞에 뒀을 때의 무효화(invalidation)
- 쓰기 고치기 —
If-Match조건부 쓰기로 덮어쓰기 유실 막기- read-modify-write 재시도 루프
- 전체 그림과 배포 전 체크리스트
1. 증상부터 — 사람마다 다른 화면이 뜨는 두 가지 이유
“누군 보이고 누군 안 보인다” 를 뭉뚱그려 하나의 버그로 보면 절대 안 잡혀요. 실제로는 원인이 둘이고, 고치는 방법도 완전히 다릅니다.
| 증상 | 진짜 원인 | 고치는 곳 |
|---|---|---|
| A는 최신, B는 옛날 화면 (저장은 제대로 됨) | 캐시 — 브라우저/CDN 이 옛 JSON 을 붙들고 있음 | 읽기 쪽 (Cache-Control, 포인터) |
| 방금 저장했는데 잠시 뒤 딴 값으로 돌아감 | 덮어쓰기 유실 — 두 편집자가 서로를 밟음 | 쓰기 쪽 (If-Match) |
첫 번째는 저장은 잘 됐는데 화면이 옛것인 경우예요. S3 안의 파일은 이미 새 값인데, B의 브라우저(또는 그 앞의 CloudFront)가 몇 분 전에 받아둔 응답을 그냥 재사용하는 거죠.
두 번째는 저장 자체가 유실된 경우입니다. A와 B가 거의 동시에 편집 화면을 열어서 각자 고치고 저장하면, 나중에 저장한 사람이 앞사람 변경을 통째로 덮어써요. A는 “내가 분명 고쳤는데 사라졌다” 고 하고, B는 “난 잘 보인다” 고 합니다.
이 둘을 섞어서 “캐시 지우면 되겠지” 하고 캐시만 만지면, 유실 문제는 그대로 남아서 계속 재발해요. 그러니 읽기와 쓰기를 따로 잡습니다.
2. 먼저 오해 하나 — “S3 반영이 느려서” 가 아니에요
예전엔 정말로 S3 가 늦게 반영되던 시절이 있었습니다. 새로 덮어쓴 객체를 바로 읽으면 옛 값이 나올 수 있는 최종 일관성(eventual consistency) 이었죠. 그래서 “저장하고 좀 기다렸다 새로고침하세요” 가 정석 답변이었어요.
지금은 아닙니다. AWS 가 2020년 12월부터 S3 를 강한 읽기-쓰기 일관성(strong read-after-write consistency) 으로 바꿨어요. 지금은 PutObject 로 덮어쓰고 곧바로 GetObject 하면 무조건 방금 쓴 값이 나옵니다. 추가 비용도, 별도 설정도 없어요.
✅ 즉, “S3 자체가 늦게 반영돼서 사람마다 다르게 보인다” 는 이제 원인이 아닙니다. S3 에 들어간 값은 즉시 최신이에요. 사람마다 화면이 다르다면 범인은 S3 가 아니라 그 사이의 캐시, 아니면 애초에 저장이 유실된 것 둘 중 하나입니다.
이걸 먼저 못 박아두는 이유는, 엉뚱하게 “S3 에 sleep 걸고 재시도” 같은 코드를 넣는 삽질을 막기 위해서예요. S3 는 억울합니다. 진짜 범인은 아래 두 섹션에 있어요.
3. 읽기 고치기 ① — Cache-Control 로 캐시 수명 다루기
브라우저와 CloudFront 는 응답에 붙은 Cache-Control 헤더를 보고 “이 파일 얼마나 재사용해도 되나” 를 판단합니다. 그런데 S3 에 그냥 업로드하면 이 헤더가 안 붙어요. 헤더가 없으면 브라우저는 자기 나름의 휴리스틱으로 알아서 캐싱해버리고, 그게 사람마다 제각각이라 “누군 최신, 누군 옛것” 이 됩니다.
그래서 설정 JSON 에는 항상 명시적으로 Cache-Control 을 박아야 해요. 가장 단순한 방법은 “이 파일은 매번 서버에 다시 물어봐” 라고 시키는 겁니다.
객체를 올릴 때 지정합니다.
aws s3 cp config.json s3://my-config-bucket/config.json \
--content-type "application/json" \
--cache-control "no-cache"
여기서 no-cache 는 이름과 달리 “캐시하지 마” 가 아니에요. “캐시는 해두되, 쓰기 전에 서버에 바뀐 게 있는지 매번 물어봐” 라는 뜻입니다(재검증). 브라우저는 ETag 를 들고 조건부 요청을 보내고, 안 바뀌었으면 S3 가 304 Not Modified 로 짧게 답해요. 대역폭은 아끼면서 항상 최신을 보장하는 균형점입니다.
이미 올라가 있는 파일의 헤더만 바꾸고 싶으면 메타데이터만 갱신할 수도 있어요.
aws s3 cp s3://my-config-bucket/config.json s3://my-config-bucket/config.json \
--metadata-directive REPLACE \
--content-type "application/json" \
--cache-control "no-cache"
읽는 쪽 HTML 도 캐시를 우회하도록 fetch 옵션을 줍니다.
// 브라우저 캐시를 건너뛰고 항상 S3(또는 CDN)에 재검증을 시킨다
async function loadConfig() {
const res = await fetch("/config.json", { cache: "no-cache" });
if (!res.ok) throw new Error(`설정 로드 실패: ${res.status}`);
return res.json();
}
⚠️
no-store까지 가면 아예 캐시를 안 하니 매번 풀 다운로드라 트래픽이 늘어요. 설정 파일처럼 “자주 안 바뀌지만 바뀌면 즉시 반영돼야 하는” 데이터는no-cache(재검증) 가 딱 맞습니다.
이것만 해도 “누군 안 보임” 의 대부분이 사라집니다. 다만 매 로드마다 재검증 왕복이 한 번씩 붙는 게 아쉬울 수 있어요. 트래픽이 크거나 CloudFront 로 전 세계에 서빙한다면, 다음 섹션의 포인터 파일 패턴이 훨씬 깔끔합니다.
4. 읽기 고치기 ② — 포인터 파일 + 불변 캐시 (핵심 패턴)
no-cache 는 “매번 물어보기” 라서 정확하지만, 요청마다 왕복이 생깁니다. 더 좋은 방법은 바뀌지 않는 파일은 영원히 캐시하고, 바뀔 수 있는 건 아주 작은 파일 하나로 몰아넣는 거예요. CDN·정적 배포에서 표준으로 쓰는 패턴입니다.
구조는 이래요.
- 실제 설정은 내용 해시를 파일명에 박아서 올린다 →
config.a1b2c3.json - 이 파일들은 절대 안 바뀌므로 1년 캐시(불변)로 박는다.
- 대신 “지금 최신은 어느 파일이냐” 를 가리키는 아주 작은 포인터
version.json을 둔다. 이것만no-cache.
포인터 파일은 이렇게 생겼어요.
{ "config": "config.a1b2c3.json" }
읽는 쪽은 두 단계로 읽습니다. 작은 포인터만 재검증하고, 실제 설정은 캐시에서 바로 꺼내요.
async function loadConfig() {
// 1) 작은 포인터만 매번 재검증 (수십 바이트라 부담 거의 없음)
const ptr = await fetch("/version.json", { cache: "no-cache" }).then(r => r.json());
// 2) 해시 박힌 실제 설정은 불변이라 캐시에서 즉시 (default 캐시로 충분)
const config = await fetch("/" + ptr.config).then(r => r.json());
return config;
}
업로드 쪽은 새 해시 파일을 먼저 올리고 맨 마지막에 포인터를 바꿉니다.
# 1) 새 설정을 해시 이름으로 올린다 — 불변이니 1년 캐시
aws s3 cp config.a1b2c3.json s3://my-config-bucket/config.a1b2c3.json \
--content-type "application/json" \
--cache-control "public, max-age=31536000, immutable"
# 2) 포인터를 새 파일로 교체 — 이것만 no-cache
aws s3 cp version.json s3://my-config-bucket/version.json \
--content-type "application/json" \
--cache-control "no-cache"
이 순서가 중요해요. 새 설정 파일이 먼저 올라가 있어야 포인터가 그걸 가리키는 순간부터 모두가 온전한 새 값을 봅니다. 포인터를 먼저 바꾸면 아직 없는 파일을 가리키는 찰나에 404 를 볼 수 있어요.
이 패턴의 좋은 점을 정리하면 이래요.
- 실제 설정은 불변 캐시라 전 세계 어디서나 즉시 로드. 재검증 왕복이 없음.
- 바뀌는 건 수십 바이트짜리 포인터뿐이라
no-cache왕복이 있어도 거의 공짜. - 부분 반영이 없음 — 포인터가 바뀌는 그 한 순간에 모두가 통째로 새 값으로 전환. “반쯤 바뀐 화면” 이 안 생겨요.
5. CloudFront 를 앞에 뒀다면 — 무효화(invalidation)
S3 앞에 CloudFront(CDN) 를 뒀다면 캐시 층이 하나 더 생깁니다. 브라우저 캐시를 다 잡아도, CloudFront 엣지가 옛 JSON 을 붙들고 있으면 여전히 사람마다 다르게 보여요. 여기도 두 갈래로 해결합니다.
갈래 1 — 위의 포인터 패턴을 그대로 쓰기. 해시 파일은 불변이라 엣지에 영원히 캐시돼도 안전하고, 포인터만 CloudFront 에서 짧은 TTL(또는 no-cache)로 두면 됩니다. 이러면 무효화 자체가 거의 필요 없어져요. 그래서 4번 패턴이 CloudFront 환경에서 특히 강력합니다.
갈래 2 — 파일명을 안 바꾸고 그 자리에서 덮어쓰는 구조라면, 저장할 때마다 그 경로를 무효화해줘야 합니다.
aws cloudfront create-invalidation \
--distribution-id E1234ABCDEXAMPLE \
--paths "/config.json" "/version.json"
💡 무효화는 월 1,000 경로까지 무료, 그 이상은 경로당 과금이에요. 매 저장마다 여러 경로를 통으로 무효화하는 습관은 요금과 지연을 같이 늘립니다. 편집이 잦다면 무효화보다 포인터 패턴(갈래 1) 이 비용·속도 모두 유리해요.
한 가지 더. 브라우저 fetch 에 ?t=<timestamp> 같은 쿼리스트링을 붙여 캐시를 피하는 낡은 방법도 있는데, 이건 CloudFront 가 쿼리스트링을 캐시 키에 포함하도록 설정돼 있어야만 동작하고 엣지 캐시 적중률을 떨어뜨려요. 급할 때 임시로는 몰라도, 상시 패턴으로는 포인터 방식을 권합니다.
6. 쓰기 고치기 — If-Match 조건부 쓰기로 덮어쓰기 유실 막기
이제 두 번째 병, 덮어쓰기 유실입니다. 캐시를 아무리 잘 잡아도 이건 안 고쳐져요. 원인이 읽기가 아니라 쓰기에 있으니까요.
시나리오는 이래요.
- A와 B가 거의 동시에 편집 화면을 열어 같은
config.json(버전 v1)을 읽는다. - A가 “제목” 을 고쳐 저장한다 → S3 는 v2.
- B는 아직 v1 을 들고 있다가 “색상” 을 고쳐 저장한다 → B는 v1 기준으로 통째로 덮어써서 A의 제목 변경이 사라진다.
백엔드가 없으니 “누가 먼저” 를 조율해줄 서버가 없죠. 다행히 S3 자체가 조건부 쓰기를 지원합니다. “내가 읽었을 때의 그 버전 그대로일 때만 저장해줘” 라고 시킬 수 있어요. 객체의 ETag(내용 지문)를 If-Match 헤더로 넘기면 됩니다.
- 읽을 때 응답 헤더의
ETag를 기억해둔다. - 저장(PUT)할 때
If-Match: <그 ETag>를 같이 보낸다. - S3 안의 현재 ETag 와 같으면 → 저장 성공.
- 그 사이 누가 먼저 바꿔서 ETag 가 달라졌으면 → S3 가
412 Precondition Failed로 거절.
즉 B의 저장은 조용히 성공하는 대신 명시적으로 실패합니다. 유실 대신 충돌이 드러나는 거예요. 브라우저에서 프리사인 URL(또는 Cognito 자격증명)로 직접 PUT 한다고 하면, 대략 이런 모양입니다.
// 읽을 때 ETag 를 같이 챙겨둔다
async function loadConfigWithTag() {
const res = await fetch("/config.json", { cache: "no-cache" });
return { data: await res.json(), etag: res.headers.get("ETag") };
}
// 저장할 때 "그때 그 버전 그대로일 때만" 이라는 조건을 건다
async function saveConfig(presignedUrl, data, etag) {
const res = await fetch(presignedUrl, {
method: "PUT",
headers: { "Content-Type": "application/json", "If-Match": etag },
body: JSON.stringify(data),
});
if (res.status === 412) throw new ConflictError("다른 사람이 먼저 저장했어요");
if (!res.ok) throw new Error(`저장 실패: ${res.status}`);
return res.headers.get("ETag"); // 새 버전의 ETag
}
🚨 조건부 쓰기가 동작하려면 프리사인 URL 을 만들 때
If-Match가 서명에 포함되도록 허용해야 하고, 버킷 정책도 이를 막지 않아야 해요. 프리사인을 발급하는 최소한의 로직(람다·CLI)이 필요할 수 있는데, 이건 상태를 들고 있는 “백엔드” 라기보다 자격증명을 잠깐 빌려주는 창구에 가깝습니다. 편집자에게 쓰기 권한을 직접 심는 것보다 안전해요.
7. read-modify-write 재시도 루프
412 로 거절당했다고 사용자에게 “실패했어요, 다시 하세요” 만 띄우면 불친절하죠. 대개는 최신을 다시 읽어서 내 변경만 다시 얹고 재시도하면 자연스럽게 해결됩니다. 이걸 read-modify-write 루프로 감쌉니다.
async function updateConfig(mutate, getPresignedUrl, maxRetry = 3) {
for (let attempt = 0; attempt < maxRetry; attempt++) {
// 1) 최신 읽기 (+ ETag)
const { data, etag } = await loadConfigWithTag();
// 2) 내 변경만 얹기 (예: data.title = "새 제목")
const next = mutate(structuredClone(data));
// 3) 그 버전 그대로일 때만 저장
try {
const url = await getPresignedUrl(); // If-Match 허용 프리사인
return await saveConfig(url, next, etag);
} catch (e) {
if (e instanceof ConflictError) continue; // 누가 먼저 바꿨다 → 다시 읽고 재시도
throw e;
}
}
throw new Error("동시 편집이 너무 잦아요. 잠시 뒤 다시 시도하세요.");
}
핵심은 3번에서 덮어쓸 전체 객체가 아니라 “내가 만진 필드만” 얹는 겁니다(2번의 mutate). 그래야 재시도할 때 남의 변경을 보존한 채 내 변경만 다시 적용돼요. A는 제목, B는 색상을 고쳤다면, B의 재시도는 A의 제목이 반영된 최신을 읽어 거기에 색상만 다시 칠하고 저장합니다. 둘 다 살아남아요.
✅ 여기까지 오면 “방금 저장했는데 딴 값으로 돌아감” 이 사라집니다. 동시 편집은 유실 대신 자동 병합(또는 명시적 충돌 안내)으로 처리되니까요.
8. 전체 그림 — 잘 저장하고, 모두 같은 화면 보기
두 병을 각각 잡았으니 합쳐봅니다. 백엔드(상태를 들고 도는 서버) 없이, 정적 호스팅 + S3 조건부 쓰기 + 프리사인 창구만으로 이만큼 됩니다.
[편집 화면] [S3 버킷]
1) version.json 읽기 (no-cache) ──▶ 포인터
2) config.<hash>.json 읽기 ───────▶ 실제 설정 (+ ETag, 불변 캐시)
3) 필드 수정
4) 새 config.<hash2>.json 올리기 ─▶ (불변, 1년 캐시)
5) If-Match 로 version.json 교체 ─▶ 조건부 성공/412
6) 412 면 1)부터 재시도
[보는 화면]
A) version.json 재검증 ───────────▶ 항상 최신 포인터
B) config.<hash>.json 캐시서 즉시 ▶ 모두 동일한 값
배포 전에 이 체크리스트를 한 번 훑으면 “누군 수정됨, 누군 안 보임” 이 재발하지 않아요.
- 설정 JSON(또는 포인터)에
Cache-Control: no-cache명시했나 - 실제 설정 파일은 해시 이름 +
immutable로 올리고 있나 - 업로드 순서가 설정 먼저 → 포인터 나중 인가
- 읽는
fetch에cache: "no-cache"(또는 포인터 패턴) 를 줬나 - CloudFront 를 쓴다면 포인터만 짧은 TTL 인가 / 무효화 경로가 맞나
- 저장 PUT 에
If-Match: <ETag>를 걸어 유실을 막고 있나 412를 read-modify-write 재시도로 처리하나- 버킷 버전 관리(versioning) 를 켜서 사고 시 롤백 여지를 뒀나
💡 마지막 항목 하나 덧붙이면, 버킷 버전 관리는 켜두길 권해요. 조건부 쓰기로 유실은 막았어도, 편집 실수로 이상한 값을 저장하는 것까지는 못 막습니다. 버전 관리가 켜져 있으면 이전 객체 버전으로 언제든 되돌릴 수 있어서 마음이 편해져요.
정리하면 이렇습니다. 사람마다 화면이 다른 건 S3 탓이 아니라 그 앞의 캐시거나 애초에 유실된 저장이에요. 읽기는 Cache-Control(가능하면 포인터 패턴)로, 쓰기는 If-Match 조건부 쓰기로 잡으면, 서버 하나 없이도 “잘 저장되고 모두에게 같은 최신 화면” 이 됩니다.
일단 오늘은 여기까지…..
다음 글에서는 이 클라이언트 조건부 쓰기만으론 부족한 지점(신뢰·검증·단조 버전)을, 함수 하나짜리 서버리스 버전 가드로 메꿔서 설정 충돌을 아예 원천 차단하는 법을 정리해볼게요.