9 분 소요

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 조건부 쓰기로 덮어쓰기 유실 막기

이제 두 번째 병, 덮어쓰기 유실입니다. 캐시를 아무리 잘 잡아도 이건 안 고쳐져요. 원인이 읽기가 아니라 쓰기에 있으니까요.

시나리오는 이래요.

  1. A와 B가 거의 동시에 편집 화면을 열어 같은 config.json(버전 v1)을 읽는다.
  2. A가 “제목” 을 고쳐 저장한다 → S3 는 v2.
  3. 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 로 올리고 있나
  • 업로드 순서가 설정 먼저 → 포인터 나중 인가
  • 읽는 fetchcache: "no-cache" (또는 포인터 패턴) 를 줬나
  • CloudFront 를 쓴다면 포인터만 짧은 TTL 인가 / 무효화 경로가 맞나
  • 저장 PUT 에 If-Match: <ETag> 를 걸어 유실을 막고 있나
  • 412 를 read-modify-write 재시도로 처리하나
  • 버킷 버전 관리(versioning) 를 켜서 사고 시 롤백 여지를 뒀나

💡 마지막 항목 하나 덧붙이면, 버킷 버전 관리는 켜두길 권해요. 조건부 쓰기로 유실은 막았어도, 편집 실수로 이상한 값을 저장하는 것까지는 못 막습니다. 버전 관리가 켜져 있으면 이전 객체 버전으로 언제든 되돌릴 수 있어서 마음이 편해져요.

정리하면 이렇습니다. 사람마다 화면이 다른 건 S3 탓이 아니라 그 앞의 캐시거나 애초에 유실된 저장이에요. 읽기는 Cache-Control(가능하면 포인터 패턴)로, 쓰기는 If-Match 조건부 쓰기로 잡으면, 서버 하나 없이도 “잘 저장되고 모두에게 같은 최신 화면” 이 됩니다.

일단 오늘은 여기까지…..
다음 글에서는 이 편집 화면에 프리사인 URL 을 발급하는 최소 창구(람다 하나)를 어떻게 안전하게 두는지 정리해볼게요.