8 분 소요

📦 백엔드 없이 설정 저장하기 2부작 — S3 에 올린 설정을 정적 HTML 이 읽어 화면을 그리고, 그걸 편집까지 하는 구조에서 "누군 수정됨, 누군 안 보임" 이 왜 생기는지 캐시·덮어쓰기 유실 두 갈래로 갈라 잡고(1편), 클라이언트 조건부 쓰기만으론 부족한 신뢰·검증·단조 버전 문제를, 함수 하나짜리 서버리스 '버전 가드' 로 compare-and-swap 하며 충돌을 원천 차단하는 법(2편)까지 이어집니다. 전체 2편.
  1. 백엔드 없이 S3 JSON 을 설정 파일로 — '누군 수정됨, 누군 안 보임' 을 없애기
  2. 서버리스 버전 가드 — 함수 하나로 설정 충돌을 원천 차단하기지금 글

Summary

이전 글에서 S3 에 올린 설정 JSON 을 정적 HTML 이 읽어 화면을 그리고 편집까지 하는 구조에서, “누군 수정됨, 누군 안 보임” 을 캐시(읽기)와 조건부 쓰기(쓰기) 두 갈래로 잡았어요. 특히 덮어쓰기 유실은 브라우저가 직접 If-Match 를 걸어 막았습니다.

그런데 그 방식엔 빈틈이 있어요. 쓰기 권한을 클라이언트가 통째로 쥐고 있다는 거예요. 브라우저가 마음만 먹으면(혹은 버그로) If-Match 를 빼먹고 덮어쓸 수도, 스키마에 안 맞는 값을 저장할 수도 있습니다. 그래서 이번 글은 쓰기 경로에 아주 얇은 서버리스 버전 가드 하나를 세웁니다. 서버를 상시 돌리는 게 아니라 함수 하나 — 이 가드가 버전을 compare-and-swap 으로 원자 교체하면서, 충돌은 유실 대신 409 로 되돌려요.

💡 이 글에서 다루는 것

  • 왜 클라이언트 If-Match 만으론 부족한가 (신뢰·검증·단조 버전)
  • 버전 가드란 — 버전 번호 하나로 하는 compare-and-swap
  • 아키텍처: 읽기는 정적 그대로, 쓰기만 가드 통과
  • 버전을 어디에 두나 — DynamoDB 조건부 업데이트 vs S3 If-Match
  • Lambda Function URL 로 만든 가드 핸들러 (서버 없음)
  • 클라이언트의 낙관적 저장 + 409 재시도
  • 가드 한 곳에서 덤으로 얻는 것 (검증·감사·접근제어)
  • 순수 정적과 비교한 트레이드오프, 체크리스트



1. 왜 클라이언트 If-Match 만으론 부족한가

1편의 If-Match 조건부 쓰기는 정말 잘 동작해요. 정직한 클라이언트끼리라면 유실이 안 납니다. 문제는 “정직한” 이라는 전제예요. 프리사인 URL 로 브라우저가 S3 객체를 직접 PUT 하는 구조라, 결국 쓰기의 규칙을 클라이언트가 지켜준다는 믿음 위에 서 있습니다.

여기서 세 가지가 아쉬워요.

부족한 점 클라이언트 If-Match 무엇이 문제인가
신뢰 규칙 준수를 클라이언트에 의존 If-Match 를 빼거나 무시하면 유실 재발
검증 각 클라이언트가 알아서 스키마 안 맞는 값이 그대로 저장됨
버전 불투명한 ETag “지금 v7 편집 중” 같은 단조 번호가 없음

신뢰 — 프리사인 PUT 은 그 객체에 대한 날것의 쓰기 권한이에요. 클라이언트가 조건을 안 걸고 덮어쓰면 막을 방법이 없습니다.
검증 — 저장 전에 “이 값이 규칙에 맞나” 를 강제하는 지점이 없어요. 클라이언트마다 검증을 따로 짜야 하고, 하나라도 빠뜨리면 깨진 설정이 올라갑니다.
버전ETag 는 내용 지문이라 사람이 읽기 어려워요. “당신은 v7 을 편집 중, 최신은 v9” 같은 안내를 주기 어렵죠.

이 셋을 한 곳에서 강제하는 방법이 쓰기 경로에 얇은 가드를 세우는 것입니다. 읽기는 1편처럼 정적으로 두되, 쓰기만 이 가드를 반드시 통과하게요.



2. 버전 가드란 — 버전 번호로 하는 compare-and-swap

핵심 아이디어는 단순해요. 설정에 단조 증가하는 버전 번호(v1, v2, v3 …)를 붙이고, 저장 요청에 “내가 읽었던 버전(base)” 을 함께 실어 보냅니다. 가드는 이렇게 판단해요.

  • 요청의 baseVersion현재 버전과 같으면 → 저장하고 버전을 +1.
  • 다르면(그 사이 누가 먼저 저장했으면) → 저장하지 않고 409 Conflict.

이게 바로 compare-and-swap(CAS), 곧 낙관적 동시성 제어예요. “내가 본 게 아직 그대로일 때만 바꿔줘” 라는 한 문장을 원자적으로 처리하는 겁니다. 1편의 If-Match 와 목표는 똑같지만, 결정을 클라이언트가 아니라 가드가 내린다는 게 달라요. 클라이언트가 아무리 우겨도 버전이 안 맞으면 가드가 거절합니다.

✅ “가드” 라고 서버를 떠올릴 필요는 없어요. 상시 떠 있는 서버가 아니라, 요청이 올 때만 깨어나는 함수 하나입니다. 평소엔 0원, 저장할 때만 잠깐 실행돼요. 그래서 “서버리스” 버전 가드입니다.



3. 아키텍처 — 읽기는 정적, 쓰기만 가드 통과

그림으로 보면 역할이 깔끔하게 갈려요. 읽기는 1편 그대로 S3/CDN 에서 정적으로, 쓰기만 가드 함수를 지나갑니다.

[읽기]  브라우저 ──▶ CloudFront/S3 ──▶ config.json (+ version)   ← 1편 캐시 패턴 그대로

[쓰기]  브라우저 ──▶ 버전 가드(함수) ──▶ CAS 검사
                                    ├─ 통과: S3 에 새 config 저장, 버전 +1 → 새 버전 반환
                                    └─ 실패: 저장 안 함 → 409 Conflict

읽기 트래픽은 가드를 안 거치니까, 가드는 가끔 있는 저장 요청만 처리해요. 부하가 거의 없고, 그래서 함수 하나로 충분합니다. 읽기 성능·캐시는 1편에서 만든 걸 그대로 물려받고, 이 글은 쓰기의 안전 만 책임지는 구조예요.



4. 버전을 어디에 두나 — 두 가지 방법

CAS 를 원자적으로 하려면 “버전 비교와 교체” 가 한 번에 일어나야 해요. 백엔드 없이 이걸 얻는 방법이 둘 있습니다.

방법 A — DynamoDB 조건부 업데이트 (권장)

DynamoDB 는 서버를 안 띄우는 서버리스 데이터스토어예요(온디맨드면 요청당 과금). 여기에 버전 카운터 한 줄을 두고 조건부 업데이트로 CAS 를 합니다. version 이 기대값일 때만 갱신되고, 아니면 예외가 나요.

# version == 기대값일 때만 +1 로 갱신. 아니면 ConditionalCheckFailedException.
table.update_item(
    Key={"configId": "site-config"},
    UpdateExpression="SET version = :new, s3key = :key, updatedAt = :ts",
    ConditionExpression="version = :base",
    ExpressionAttributeValues={
        ":base": base_version,      # 클라이언트가 제시한 버전
        ":new":  base_version + 1,
        ":key":  new_s3_key,
        ":ts":   now_iso,
    },
)

이 한 방이 원자적이라 경쟁 상황(race)이 끼어들 틈이 없어요. 버전이 사람이 읽는 정수라 감사·안내에도 좋습니다.

방법 B — S3 조건부 쓰기(If-Match) 만으로

DynamoDB 도 없이 S3 만 쓰고 싶다면, S3 의 조건부 쓰기를 가드 안에서 씁니다. 가드가 현재 객체의 ETag 를 확인하고, 그 ETag 일 때만 PutObject 하도록 If-Match 를 거는 거예요. 클라이언트가 아니라 가드가 이 조건을 강제하니, 1편의 신뢰 문제가 사라집니다.

# 가드 내부: 그 ETag 그대로일 때만 덮어쓴다. 아니면 PreconditionFailed.
s3.put_object(
    Bucket=BUCKET, Key="config.json",
    Body=new_body, ContentType="application/json",
    IfMatch=current_etag,     # S3 조건부 쓰기
)
기준 방법 A (DynamoDB) 방법 B (S3 If-Match)
추가 리소스 테이블 1개 없음
버전 형태 사람이 읽는 정수 불투명한 ETag
감사·메타 같이 저장 쉬움 별도 처리 필요
적합 버전·이력이 중요할 때 최소 구성으로 충분할 때

둘 다 “가드가 원자적으로 검사·교체” 라는 뼈대는 같아요. 버전 이력이나 감사를 챙기고 싶으면 A, 리소스를 최소로 하고 싶으면 B 입니다.



5. Lambda Function URL 로 만든 가드 (서버 없음)

가드는 Lambda Function URL 하나로 충분해요. API Gateway 도 필요 없이, 람다에 HTTPS 엔드포인트가 바로 붙는 방식이라 구성이 제일 가볍습니다. 함수는 이 순서로 일해요 — ①요청 파싱 → ②스키마 검증 → ③CAS → ④S3 저장 → ⑤새 버전 반환.

import json, boto3

s3 = boto3.client("s3")
ddb = boto3.resource("dynamodb").Table("app-config")
BUCKET = "<BUCKET>"

def handler(event, context):
    body = json.loads(event["body"])
    base_version = body["baseVersion"]
    config = body["config"]

    # ② 스키마 검증 — 여기서 막으면 깨진 설정이 절대 저장 안 됨
    if not is_valid(config):
        return _resp(422, {"error": "설정 형식이 올바르지 않습니다"})

    new_version = base_version + 1
    new_key = f"config/v{new_version}.json"

    # ④ 먼저 새 버전 객체를 불변으로 올린다 (해시/버전 이름)
    s3.put_object(
        Bucket=BUCKET, Key=new_key,
        Body=json.dumps(config).encode(),
        ContentType="application/json",
        CacheControl="public, max-age=31536000, immutable",
    )

    # ③ CAS — base 가 현재 버전과 같을 때만 포인터를 v+1 로 교체
    try:
        ddb.update_item(
            Key={"configId": "site-config"},
            UpdateExpression="SET version = :new, s3key = :key",
            ConditionExpression="version = :base",
            ExpressionAttributeValues={
                ":base": base_version, ":new": new_version, ":key": new_key,
            },
        )
    except ddb.meta.client.exceptions.ConditionalCheckFailedException:
        # 그 사이 누가 먼저 저장했다 → 유실 대신 충돌을 돌려준다
        return _resp(409, {"error": "conflict", "message": "다른 사람이 먼저 저장했어요"})

    # ⑤ 포인터(version.json)를 새 버전으로 갱신 — 읽기 쪽이 이걸 본다
    s3.put_object(
        Bucket=BUCKET, Key="version.json",
        Body=json.dumps({"version": new_version, "config": new_key}).encode(),
        ContentType="application/json", CacheControl="no-cache",
    )
    return _resp(200, {"version": new_version})

def _resp(status, obj):
    return {"statusCode": status, "headers": {"Content-Type": "application/json"},
            "body": json.dumps(obj)}

포인트가 몇 개 있어요.

  • 먼저 새 버전 객체를 올리고(불변), CAS 가 통과한 뒤에야 포인터를 바꿉니다. 1편의 “설정 먼저 → 포인터 나중” 순서를 가드 안에서 지키는 거예요.
  • 검증(②)이 가드 안에 있으니, 어떤 클라이언트도 깨진 설정을 저장할 수 없어요. 신뢰가 클라이언트에서 가드로 옮겨왔습니다.
  • 실제 저장 실패(409)는 조용히 덮어쓰는 대신 명시적 충돌로 돌아와요.



6. 클라이언트 — 낙관적 저장 + 409 재시도

클라이언트는 이제 S3 에 직접 PUT 하지 않아요. 가드에 POST 하고, 409 가 오면 최신을 다시 읽어 내 변경만 다시 얹어 재시도합니다(1편의 read-modify-write 와 같은 흐름이에요).

async function saveConfig(mutate, maxRetry = 4) {
  for (let attempt = 0; attempt < maxRetry; attempt++) {
    // 1) 최신 읽기 — 정적 경로 그대로 (가드 안 거침)
    const ptr = await fetch("/version.json", { cache: "no-cache" }).then(r => r.json());
    const config = await fetch("/" + ptr.config).then(r => r.json());

    // 2) 내 변경만 얹기
    const next = mutate(structuredClone(config));

    // 3) 가드에 저장 요청 — base 버전을 함께 제시
    const res = await fetch(GUARD_URL, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ baseVersion: ptr.version, config: next }),
    });

    if (res.status === 409) continue;                 // 누가 먼저 저장 → 다시 읽고 재시도
    if (res.status === 422) throw new Error("설정 형식 오류");
    if (!res.ok) throw new Error(`저장 실패: ${res.status}`);
    return (await res.json()).version;                // 새 버전
  }
  throw new Error("동시 편집이 너무 잦아요. 잠시 뒤 다시 시도하세요.");
}

읽기는 여전히 정적 캐시에서 빠르게, 저장만 가드를 지나요. 사용자 입장에선 1편과 똑같이 “저장” 버튼 한 번인데, 뒤에서 버전이 안 맞으면 자동으로 최신을 물어 다시 시도합니다.



7. 가드 한 곳에서 덤으로 얻는 것

버전 가드를 세우면 CAS 말고도 따라오는 게 많아요. 쓰기가 한 지점을 반드시 지나가니까, 거기에 원하는 규칙을 얹기만 하면 됩니다.

  • 스키마 검증 — 잘못된 설정을 저장 전에 422 로 거절. 깨진 값이 화면을 부수는 사고가 원천 차단돼요.
  • 감사 로그 — “누가, 언제, v7 → v8, 무엇을 바꿨나” 를 남기기 좋아요. DynamoDB 에 이력 항목을 같이 쓰거나 로그로 흘리면 됩니다.
  • 접근 제어 — 가드 앞에 인증(예: 토큰 검증)을 두면, 편집 권한을 여기서 통제해요. 프리사인처럼 날쓰기 권한을 뿌리지 않아도 됩니다.
  • 알림 — 저장 성공 시 Slack/webhook 을 쏘는 것도 이 한 곳에 붙이면 끝.

1편에서 클라이언트마다 흩어졌던 규칙들이 가드 한 곳으로 모이는 게 핵심이에요. 규칙이 한 군데 있으니 빠뜨릴 일도, 클라이언트마다 다르게 구현될 일도 없습니다.

🚨 가드가 유일한 쓰기 경로가 되려면, S3 버킷의 직접 쓰기 권한을 클라이언트에서 회수해야 해요. 편집자에게 프리사인 PUT 을 그대로 열어두면 가드를 우회할 수 있으니, 쓰기는 가드(람다 실행 역할)만 갖도록 버킷 정책을 좁히세요.



8. 트레이드오프 — 순수 정적 vs 가드, 그리고 체크리스트

가드를 세우는 건 공짜가 아니에요. 함수·(선택적) 테이블이라는 작은 운영 요소가 생깁니다. 그래서 설정의 성격에 맞춰 고르세요.

상황 추천
편집자 한둘, 서로 신뢰, 유실 드묾 1편(클라이언트 If-Match) — 가장 단순
편집자 여럿·불특정, 검증·권한 필요 버전 가드 — 규칙을 한 곳에서 강제
“누가 언제 바꿨나” 이력·감사 필요 버전 가드(DynamoDB) — 버전·로그
리소스 최소로, 서버 요소 0 1편 또는 가드 방법 B(S3 If-Match)

정리하면, 1편은 클라이언트가 규칙을 지킨다는 전제로 서버 요소 0 을 얻었고, 2편은 함수 하나를 들여 그 전제를 없앴어요. 신뢰·검증·단조 버전이 필요해지는 순간이 가드로 넘어갈 때입니다. 둘 다 상시 서버는 없어요 — 다만 2편은 “요청 때만 깨어나는 함수” 만큼의 서버리스를 더한 거예요.

배포 전 체크리스트예요.

  • 버전을 단조 증가 정수로 두고, 저장 요청에 baseVersion 을 싣나
  • CAS 를 원자적으로 하나 (DynamoDB 조건부 업데이트 또는 S3 If-Match)
  • 가드 안에서 스키마 검증을 거쳐 깨진 설정을 422 로 막나
  • 새 버전 객체를 먼저 올리고 포인터를 맨 마지막에 바꾸나
  • 충돌은 유실 대신 409 로 돌려 클라이언트가 재시도하나
  • S3 직접 쓰기 권한을 클라이언트에서 회수해 가드가 유일한 쓰기 경로인가
  • 읽기 캐시는 1편 포인터·불변 패턴을 그대로 쓰나
  • 버킷 버전 관리(versioning) 로 롤백 여지를 뒀나

일단 오늘은 여기까지…..
JSON 한 덩어리에서 시작해, 캐시·조건부 쓰기로 증상을 잡고(1편), 함수 하나짜리 서버리스 버전 가드로 충돌을 원천 차단하는(2편) 데까지 왔어요. 상시 서버 없이도 “잘 저장되고 모두에게 같은 최신 화면” 이 됩니다. 다음에 또 재미난 주제로 찾아올게요.


← 이전 글: (1/2) 백엔드 없이 S3 JSON 을 설정 파일로 — ‘누군 수정됨, 누군 안 보임’ 을 없애기