(2/2) 서버리스 버전 가드 — 함수 하나로 설정 충돌을 원천 차단하기
- 백엔드 없이 S3 JSON 을 설정 파일로 — '누군 수정됨, 누군 안 보임' 을 없애기
- 서버리스 버전 가드 — 함수 하나로 설정 충돌을 원천 차단하기 ← 지금 글
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 을 설정 파일로 — ‘누군 수정됨, 누군 안 보임’ 을 없애기