2026. 4. 25. 21:14ㆍ카테고리 없음
CloudFront -> Cloudflare Worker + R2 마이그레이션 삽질기
지난 글에서 S3 + CloudFront에서 Cloudflare R2 + Worker로 전환하며 생긴 기술부채에 대해 썼다.
그때는 신규 업로드 파일을 R2로 복제하고 DB의 url을 R2 도메인으로 바꾸는 과정에서 생긴 타이밍 문제를 다뤘다.
다만 지금은 전략이 조금 바뀌었다.
url을 직접 바꾸는 방식으로 가면, 나중에 CloudFront 도메인들을 Cloudflare 쪽으로 전부 바꿔야 한다.
문제는 그 URL들이 RDS, Firestore 등 여러 서비스에 파편화되어 있다는 점이었다. 어디에 어떤 도메인이 저장되어 있는지 파악하고 모두 업데이트하는 작업 자체가 꽤 번거로웠다.
그래서 마이그레이션 비용을 감수하더라도 우선 S3 -> R2 sync 로직을 배포하고, 기존 url은 그대로 두기로 했다. S3의 모든 파일이 R2로 마이그레이션되면, 그때 DNS 라우팅을 Cloudflare로 전환하는 방식이다.
이번 글은 그 과정에서 겪은 캐시 이야기다.
문제는 파일을 R2에 다 옮긴 뒤에도 남아 있었다. DNS를 Cloudflare로 돌렸을 때, 기존 CloudFront가 해주던 캐싱을 어떻게 재현할 것인가였다.
기존에는 CloudFront가 파일 서빙과 캐싱을 맡고 있었다. 특히 우리 비용에서 큰 비중을 차지하던 것도 CloudFront의 데이터 egress였다.
R2는 egress 비용이 없어서 이 부분을 줄일 수 있지만, 그렇다고 아무 생각 없이 Worker -> R2로 매번 파일을 읽어오면 이전하는 의미가 약해진다.
따라서 문제는 R2에 파일을 넣는 것이 아니라, CloudFront가 해주던 캐시 동작을 Cloudflare Worker + R2 구조에서 어떻게 다시 만들 것인가였다.
AI가 말한 캐시 모델은 틀렸다
처음 구조는 대략 이랬다.
Custom Domain
-> Cloudflare Worker
-> R2 binding
-> R2 bucket
도메인은 Cloudflare Worker에 연결하고, Worker 내부에서는 R2 binding을 통해 bucket에 접근한다. 기존 CloudFront signed cookie/query 검증도 Worker에서 처리하고, CORS나 Cache-Control 같은 응답 헤더도 Worker에서 조정하는 구조였다.
여기서 내가 처음 오해했던 부분이 있었다.
AI에게 물어보니 Cache-Control: public을 주면 Cloudflare CDN이 해당 리소스를 캐싱해서 다음 요청은 Worker까지 오지 않고 앞단에서 처리된다고 했다. 듣기엔 그럴듯했다. public이고 TTL도 있으니까 CDN이 알아서 처리할 것 같았다.
근데 실제로 해보니 Worker는 계속 호출됐다.
Worker invocation도 찍히고, 내가 넣어둔 로그도 계속 보였다. 그래서 다시 AI에게 따져 물었다. 방금 너는 public이면 Worker 앞단에서 처리된다고 하지 않았냐고...
그러니 대답이 바뀌었다.
아 미안하다. 그런 개념은 없다.
이때부터 이 작업은 문서와 실제 동작을 직접 확인하면서 가야겠다고 생각했다. AI는 방향을 잡는 데 도움은 되지만, 이런 세부 동작을 맡겨놓고 믿기엔 아직 위험했다.
문서를 다시 보니 내가 기대했던 구조가 아니었다.
caches.default는 Worker 앞단의 자동 CDN 캐시가 아니라, Worker 코드 안에서 직접 match, put을 호출하는 캐시였다. 캐시된 내용도 전 세계 데이터센터로 자동 복제되는 구조가 아니었다. (Cloudflare Workers Cache API)
즉 내가 잡아야 했던 모델은 이쪽이었다.
Client
-> Worker
-> caches.default
-> R2
처음엔 Worker 호출 자체를 줄여야 하나 싶었다. 그런데 Worker 호출 횟수와 실제 비용에 영향을 미치는 CPU 점유 시간을 기준으로 계산해보니, R2의 GetObject, HeadObject 같은 Class B operation 비용에 비하면 거의 무시 가능한 수준이었다.
결국 신경 써야 할 지점은 Worker가 호출되느냐가 아니었다. Worker 뒤에서 비싼 R2 자원을 계속 찌르지 않게 만드는 것이었다.
CloudFront에서는 private인데도 HIT였다
여기서 좀 헷갈렸던 이유가 있다. 기존 CloudFront + S3에서는 Cache-Control: private 응답인데도 x-cache: Hit from cloudfront가 나왔기 때문이다.
처음엔 이상했다. private이면 shared cache가 캐시하면 안 되는 거 아닌가..?
그래서 AWS 문서를 찾아봤다.

CloudFront의 콘텐츠가 캐시에 유지되는 기간 관리 문서를 보면, 오리진 응답에 Cache-Control: no-cache, no-store, private이 있더라도 CloudFront의 최소 TTL이 0보다 크면 캐시 정책의 최소 TTL을 사용한다고 되어 있다.
브라우저는 헤더 설정을 따르지만, CloudFront 캐싱은 배포의 cache policy와 TTL 설정 영향을 받는다.
그러니까 기존에 봤던 private인데도 CloudFront hit가 나는 동작은 이상한 게 아니었다. 우리가 쓰던 CloudFront 설정에서는 그렇게 동작할 수 있었다.
문제는 이 경험을 Cloudflare Worker + R2에 그대로 가져오면 안 된다는 점이었다.
CloudFront가 해주던 캐시 동작과 Cloudflare Worker 안에서 caches.default를 직접 사용하는 모델은 다르다. 둘 다 CDN/edge cache라는 단어를 쓰지만, 내가 제어해야 하는 지점은 완전히 달랐다.
R2는 egress가 무료지만 R2 접근은 무료가 아니다
R2의 가장 큰 장점은 egress 비용이 없다는 것이다. Cloudflare R2 가격 문서에서도 R2는 storage와 operation 기준으로 과금하고, egress bandwidth는 무료라고 설명한다. (Cloudflare R2 pricing)
근데 무료인 건 egress지, R2 접근 자체가 아니다.
2026년 4월 25일 기준으로 보면 요청 비용은 대략 이렇다.
- Cloudflare R2 Standard Class B
1 million requests당$0.36
- AWS S3 Standard
GET,SELECT및 기타 요청1,000 requests당$0.0004- 즉
1 million requests당$0.40수준 - 서울 리전 기준
R2의 Class B operation에는 GetObject, HeadObject가 포함된다. Cloudflare 문서의 asset hosting 예시도 하루 1,000만 read를 30일 동안 하면 Class B operation 비용만 $104.40이 나온다고 계산한다.
우리 작업의 목표는 비용 절감이었다.
그런데 CloudFront에서는 edge hit로 막히던 요청들이 Cloudflare로 옮긴 뒤 매번 R2 GetObject나 HeadObject로 떨어진다면, egress 비용을 줄이려고 이전하는 의미가 약해진다.
그래서 핵심은 public 콘텐츠냐 private 콘텐츠냐가 아니었다.
캐시를 잘 컨트롤해서 R2 접근을 최대한 edge cache 안에서 막아야 했다. 동시에 signed cookie, signed query, Cache-Control: private 같은 기존 보안 계약은 유지해야 했다.
이 두 가지를 같이 만족시키는 게 어려웠다.
private 응답도 캐시에 태워야 했다
처음엔 단순하게 생각했다.
R2에서 가져온 응답을 만들고, caches.default.put()으로 저장하면 될 줄 알았다.
const cached = await caches.default.match(cacheKey);
if (cached) {
return cached;
}
const response = await fetchFromR2(request, bucket, key);
ctx.waitUntil(caches.default.put(cacheKey, response.clone()));
return response;
public 리소스는 이 방식으로 어느 정도 기대한 대로 동작했다.
문제는 signed cookie/query가 필요한 리소스였다. 이런 리소스는 클라이언트에게 나가는 응답이 private이어야 했다. 인증을 거친 사용자에게만 보여야 하는 파일인데, HTTP 응답으로 public을 내려버리면 안 된다.
그런데 Cache-Control: private인 응답을 cache.put()하니 에러가 나는 것도 아니고, 그냥 조용히 저장되지 않았다. 다음 요청에서도 cache miss가 나고 다시 R2를 찔렀다.
이게 귀찮았던 이유는 실패가 너무 조용했다는 점이다.
- 에러가 발생하지 않는다.
- 이후
cache.match()는 계속undefined를 반환한다. - 로그에도 실패가 남지 않는다.
그래서 처음엔 cache.put()을 호출했으니 당연히 저장됐을 거라고 생각하기 쉽다.
문서를 보면 Cloudflare Cache API의 cache.put()은 응답의 Cache-Control을 존중하고, Cache-Control이 캐시하지 말라고 지시하거나 응답이 너무 크면 413이 날 수 있다고 되어 있다. cache.put()은 성공적으로 저장했는지와 무관하게 undefined로 resolve된다는 설명도 있어서, 그냥 호출했다고 캐시가 됐다고 믿으면 안 됐다. (Cloudflare Workers Cache API)
그렇다고 응답에서 private을 빼거나 public으로 바꾸는 건 더 위험했다.
예를 들어 FE 쪽에서 누군가 이 파일 요청을 Next 서버에서 프록시하도록 바꿨다고 생각해보자.
User A
-> Next server proxy
-> Worker 인증 통과
-> Cache-Control: public 응답
-> Next 서버나 앞단 CDN이 캐시
User B
-> 같은 proxy URL 요청
-> Worker 인증 없이 Next/CDN cache hit
-> User A가 받은 리소스 노출
Worker 내부에서 인증을 잘 해도, 외부로 나가는 HTTP 응답이 public이면 그 뒤에 어떤 shared cache가 붙을지 모른다. Next 서버일 수도 있고, 또 다른 CDN일 수도 있고, 프록시일 수도 있다.
그러니까 private이라는 응답 계약은 유지해야 했다.
문제는 이거였다.
private으로 응답해야 안전한데, private으로 저장하면 Worker Cache API에 안 들어간다.
저장본과 응답본을 분리하자
팀원들과 이 문제를 이야기하다가 내가 이런 말을 했다.
미친 소리긴 한데, 저장할 때만 Cache-Control 을 public 으로 바꾸고, 응답할 때 다시 private 으로 바꾸면 캐시를 어떻게든 태울 수 있을 것 같습니다...
말하고 나서도 좋은 방법처럼 들리진 않았다. private을 public으로 바꾼다는 말 자체가 위험하게 들린다. 팀원들도 그건 좀 바람직한 방법은 아닌 것 같다는 반응이었다.
나도 처음엔 기술부채를 만드는 것 같았다.
근데 이 작업은 비용 절감을 위해 하고 있었다. R2 접근을 캐시로 막지 못하면 이전 이유가 약해졌다. 그리고 Cloudflare Worker 구조상 요청은 반드시 Worker를 거친다. 인증도 cache lookup 전에 수행된다.
그렇다면 public으로 바꾸면 안 되는 건 클라이언트에게 나가는 응답이지, Worker Cache API에 넣는 내부 저장본까지 같은 계약을 가져야 하는 건 아닐 수 있었다.
문제는 public으로 저장하느냐가 아니라, public으로 응답하느냐였다.
팀원들과의 이야기 이후 AI에게도 물어봤는데, 사실상 이 방식이 유일한 우회라고 했다. 인터넷도 검색해봤지만 같은 상황에서 명확한 공식 패턴이나 사례 글을 찾지는 못했다.
결국 직접 검증해보기로 했다.
저장용 응답은 따로 clone해서 만든다. 이 저장본에는 원래 Cache-Control을 X-Original-Cache-Control에 보관하고, 저장용 Cache-Control만 public으로 바꾼다.
function buildCacheEntry(response: Response, isSigned: boolean): Response {
const cloned = response.clone();
const toCache = new Response(cloned.body, {
status: cloned.status,
headers: new Headers(cloned.headers),
});
if (isSigned) {
toCache.headers.set(
"X-Original-Cache-Control",
toCache.headers.get("Cache-Control") ?? "",
);
toCache.headers.set("Cache-Control", "public, max-age=86400");
}
return toCache;
}
그리고 cache hit가 나면 클라이언트에게 반환하기 전에 원래 값을 복원한다.
function restoreFromCacheEntry(cached: Response): Response {
const response = new Response(cached.body, {
status: cached.status,
headers: new Headers(cached.headers),
});
if (response.headers.has("X-Original-Cache-Control")) {
response.headers.set(
"Cache-Control",
response.headers.get("X-Original-Cache-Control") ?? "",
);
response.headers.delete("X-Original-Cache-Control");
}
return response;
}
실제로 검증해보니 캐시는 잘 됐고, 클라이언트는 원하던 Cache-Control: private 응답 헤더를 받을 수 있었다.
정리하면 각 계층이 보는 값은 이렇게 된다.
- 클라이언트 / 브라우저
- 보는 값:
private, max-age=86400 - 의도: 브라우저 로컬 캐시는 허용하되, 공유 캐시 재사용은 막는다.
- 보는 값:
- 중간 프록시 / CDN / Next proxy
- 보는 값:
private - 의도: 사용자 간 응답 재사용을 막는다.
- 보는 값:
- Worker Cache API 저장본
- 보는 값:
public, max-age=86400 - 의도:
cache.put()저장을 위해 내부 저장본만 우회한다.
- 보는 값:
- Worker 인증 로직
- 동작: 매 요청 수행
- 의도: 캐시 조회 전에 signed cookie/query를 검증한다.
처음엔 찝찝해 보였던 아이디어였지만, 문제를 풀긴 풀었다.
물론 이걸 깔끔한 아키텍처라고 말하긴 어렵다. 하지만 개발자는 완벽한 아키텍처와 코드를 작성하는 예술을 하는 사람이 아니다. 달성해야 하는 목표가 있고, 때로는 전략적으로 미래를 위한 설계를 하지만, 방법이 이것 하나뿐이라면 다음 작업자가 이 상황을 빠르게 파악할 수 있도록 기록하는 것이 더 낫다고 판단했다.
그래서 코드에도 왜 이런 짓을 하는지 주석을 남겼다.. :(
구현하며 챙긴 것들
몇 가지는 해두길 잘했다고 생각한다.
X-Cache 헤더
Worker에서 cache를 탔는지 바로 알 수 있게 X-Cache 헤더를 넣었다.
HIT: Worker Cache API에서 찾음MISS: Worker Cache API에 없어서 R2를 읽음BYPASS: Range 요청처럼 의도적으로 캐시를 우회함
이거 없으면 테스트할 때 정말 헷갈린다. Worker가 호출되는지, R2를 찌르는지, cache hit인지 로그만으로 계속 확인해야 한다.
X-Original-Cache-Control
이건 앞에서 설명한 우회 때문에 필요했다.
저장본에는 public을 넣어야 하지만, 응답은 원래 private으로 복원해야 한다. 그래서 저장할 때 원래 값을 X-Original-Cache-Control에 넣어두고, cache hit 시 다시 Cache-Control로 돌린다.
외부로 나가는 응답에서는 이 헤더를 제거한다.
signed URL cache key 정규화
signed URL은 사용자마다 query parameter가 달라도 결국 같은 파일을 내려주는 경우가 많아서, cache key에서는 query를 제거하고 pathname 기준으로 정규화했다.
HEAD도 캐시하기
처음엔 GET만 신경 쓰면 된다고 생각했는데, R2에서는 HeadObject도 Class B operation이다.
즉 HEAD 요청도 비용이 든다.
그래서 HEAD 요청이 들어왔을 때도 GET 캐시를 먼저 확인한다. 캐시된 GET 응답의 ETag, Last-Modified, Content-Type, Content-Length 같은 헤더를 이용해서 HEAD 응답을 만들면 R2 head()를 줄일 수 있다.
HEAD는 body만 없을 뿐, 메타데이터 관점에서는 GET과 같은 리소스를 바라본다.
Vary: Origin
CORS도 조심해야 했다.
허용된 Origin에 대해서만 Access-Control-Allow-Origin을 붙이는데, 이 응답이 캐시되면 다른 Origin 요청에 이전 Origin 헤더가 섞여 나갈 수 있다.
그래서 Origin에 따라 응답이 달라질 수 있는 경우 Vary: Origin을 넣었다.
캐시는 성능 문제이기도 하지만, 이런 경우엔 보안이나 이상한 CORS 버그와도 연결된다.
ETag 파싱
조건부 요청도 직접 처리했다.
If-None-Match는 단일 값만 오는 게 아니라 여러 ETag가 콤마로 올 수 있고, W/가 붙은 weak ETag도 있다. *도 처리해야 한다.
캐시 hit 상태에서 If-None-Match나 If-Modified-Since를 처리할 수 있으면 R2까지 가지 않고 Worker에서 바로 304를 줄 수 있다.
이것도 작아 보이지만 요청이 많아지면 결국 비용과 연결된다.
느낀 것
처음엔 R2의 egress 무료가 제일 큰 포인트라고 생각했다. 물론 맞다. 이 작업의 시작도 CloudFront egress 비용이었다.
근데 실제로 구현하면서 더 중요했던 건 캐시 모델을 정확히 이해하는 것이었다. CloudFront에서 되던 것이 Cloudflare Worker + R2에서도 같은 방식으로 되는 건 아니었다.
이번 작업에서 핵심은 두 가지였다.
- R2
GetObject,HeadObject를 얼마나 줄이느냐가 중요했다. - signed cookie/query와
Cache-Control: private같은 기존 보안 계약은 유지해야 했다.
그래서 저장용 캐시 정책과 클라이언트 응답 정책을 분리했다.
처음엔 찝찝해 보였던 아이디어였지만, 실제로 검증해보니 캐시가 잘 되었고 원하던 response header도 받을 수 있었다.
완벽한 해결책이라기보다는, 문제의 제약을 인정하고 그 안에서 비용과 보안을 같이 맞춘 해결책에 가깝다.
그리고 이런 작업일수록 설명을 남겨야 한다고 느꼈다.
나중에 누군가 이 코드를 보면 분명히 생각할 수 있다.
왜 private을 public으로 바꿨다가 다시 private으로 돌리지?
그때 이 글이나 주석이 없으면 그냥 이상한 코드가 된다. 하지만 이유를 알고 보면, 이건 실수로 만든 기술부채가 아니라 의도적으로 만든 경계다.
아마 이런 게 운영 코드에서 더 중요한 것 같다.
깔끔해 보이는 코드보다, 왜 이렇게 되었는지 설명 가능한 코드.