발행한 글의 홈 화면 커버가 축소본 대신 원본 크기 이미지로 나왔다. 원인을 찾아 축소본을 나중에 올렸다. 그런데도 화면은 그대로였다. 새로고침을 몇 번 해도, 다른 브라우저로 열어도 결과는 같았다.

이 저장소 이미지 서빙 코드(src/lib/r2.ts)를 뜯어보고서야 이게 파일이 안 올라간 문제가 아니라는 걸 알았다. 파일은 R2 버킷에 멀쩡히 있었다. 문제는 브라우저와 서버 사이에 낀 캐시가 예전 응답을 계속 붙들고 있다는 데 있었다.

진짜 이미지를 올려도 화면이 안 바뀌는 증상

이 블로그는 커버 이미지를 640px, 1024px, 1600px 세 가지 너비로 내보낸다. srcset이라는 속성에 이 세 후보를 나열해 두면 브라우저가 화면 크기에 맞는 걸 알아서 고른다. 문제는 이 세 후보 중 축소본 두 개(640px, 1024px)가 모든 글에 다 있는 게 아니었다는 점이다. 예전에 발행된 글 33개는 일괄로 축소본을 만들어 뒀지만, 그 뒤로 새로 발행된 글은 원본 하나만 있고 축소본 파일 자체가 R2에 없었다.

브라우저가 없는 축소본 URL을 요청하면 서버는 어떻게 응답해야 할까? 처음 만든 코드는 그 URL 요청을 원본 파일로 대신 응답하도록 짜여 있었다. 깨진 이미지 아이콘을 띄우는 것보다는 전송량이 늘더라도 원본을 보여주는 편이 나으니 합리적인 선택이었다. 여기까지는 문제가 없었다.

문제는 그다음이었다. 축소본이 없어서 원본으로 대신 응답한 이 결과물에, 실제 축소본이 있을 때와 똑같은 캐시 수명이 붙었다. 이 서비스의 이미지 캐시 정책은 Cache-Control: public, max-age=31536000, immutable이다. 초 단위로 31,536,000은 정확히 1년이고, immutable은 이 응답이 신선하다고 간주되는 그 1년 동안에는 브라우저가 굳이 서버에 재검증하러 오지 않아도 된다는 선언이다. 대신 응답한 원본 이미지에도 이 헤더가 그대로 붙었다. 일반 요청에서는 이 응답이 캐시에 남아 신선한 동안 재검증이 생략돼, 진짜 축소본을 올려도 purge·캐시 우회·URL 변경 없이는 곧바로 반영되지 않을 수 있다.

처음엔 파일이 잘못 올라간 줄 알았다. R2 버킷 콘솔에서 축소본 키를 직접 열어 봤다. 파일은 있었고 크기도 정상이었다. 브라우저 개발자 도구의 네트워크 탭을 열어 그 URL 요청을 다시 보냈더니 응답 코드는 200인데 상태 칸에는 "from disk cache"라고 찍혔다. 서버까지 요청이 가지도 않고 있다는 뜻이었다. 캐시 무시(하드 리프레시)로 다시 열어야 그제야 서버가 실제로 뭘 돌려주는지 볼 수 있었는데, 거기서 immutable 값이 그대로 눈에 들어왔다. 파일은 새것인데 헤더는 예전 답을 1년 동안 그대로 믿으라고 말하고 있었다.

덮어쓰면 되겠지, 라는 생각이 안 통하는 이유

처음 이 문제를 마주쳤을 때 든 생각은 단순했다. 같은 경로에 새 파일을 덮어쓰면 다음 요청부터는 새 내용이 나가지 않을까. 결과는 아니었다. immutable, max-age=1년 정책 아래에서는 같은 키에 다른 바이트를 올려도, 캐시된 일반 요청은 서버 파일의 변경을 확인하지 않으므로 purge나 URL 변경이 없으면 반영이 늦어진다.

이 저장소가 imageKey() 함수를 만든 이유가 여기에 있다. 이 함수는 호출할 때마다 무작위 UUID를 새로 뽑아 images/[prefix/]{uuid}.{ext} 형태의 키를 만든다. 재발행이 예전 키를 다시 쓰지 않도록 만드는 장치다. 현재 발행 경로에서는 새 이미지를 저장할 때마다 imageKey()로 새 UUID 키를 만들고 기존 키를 덮어쓰지 않으므로, 이 경로에서 발급된 오래된 키에는 immutable을 적용할 수 있다.

폴백 응답은 이 전제를 깨고 들어왔다. 축소본 URL(...@640.webp)은 고정된 이름이다. 재발행 때마다 새로 생기는 게 아니라, 커버 원본이 정해지는 순간 coverVariantKey(key, width) 함수가 항상 같은 규칙으로 만들어내는 파생 이름이다. 그 이름 뒤에 있는 실제 파일은 시간이 지나며 "없음"에서 "있음"으로 바뀔 수 있다. 처음엔 없어서 원본을 대신 보여주다가, 나중에 진짜 축소본이 올라오면 같은 URL이 다른 내용을 돌려줘야 정상이다. 그런데 이 URL에 immutable을 붙이면, 캐시가 신선하다고 보는 그 기간 동안에는 이 변화가 일반 요청에 곧바로 반영되지 않는다. 같은 이름이라도 상태가 바뀔 수 있는 URL과, 정말 한 번 정해지면 끝인 URL을 같은 규칙으로 다룬 게 이 사고의 핵심이었다.

변형 이미지가 없어 원본으로 대신 응답한 결과가 immutable 1년 캐시로 굳어버려, 이후 진짜 변형을 올려도 일반 요청에는 최대 1년 동안 옛 응답이 계속 나갈 수 있는 시간 순서를 보여주는 다이어그램
변형이 없던 시점의 임시 응답이 첫 요청에서 그대로 1년짜리 캐시로 박히면, 그 뒤에 진짜 변형을 올려도 purge나 URL 변경 없이는 일반 요청에 반영이 늦어진다.

코드를 따라가 찾은 진짜 원인

이 축소본은 애초에 실시간으로 리사이즈해 주는 서비스가 있어서 만들어지는 게 아니다. 이 저장소에는 이미지 크기를 즉석에서 바꿔주는 서버가 없다. 대신 coverVariantKey가 정한 이름 규칙에 맞춰 축소본을 미리 만들어 올려 둬야만 그 URL이 실제 객체와 연결된다. 그리고 이 함수가 축소본 이름을 만들어 주는 대상은 아무 커버나가 아니다. COVER_KEY_WITH_SMALL_VARIANT라는 정규식이 images/uploads/<uuid>.webp 형태(소문자·대문자 uuid, 확장자 webp)로 정확히 일치하는 키만 통과시킨다. 이 모양을 벗어난 커버 키는 애초에 coverVariantKeynull을 돌려주고, srcset 자체가 안 붙는다. 즉 이번 사고가 벌어질 수 있는 범위는 이 정확한 이름 규칙을 따르는 커버로 한정돼 있었다.

serveImage 함수의 흐름은 이렇다. 먼저 Cache API에서 이미 저장된 응답이 있는지 확인한다. 있으면 그걸 그대로 돌려준다. 없으면 요청받은 키로 R2에서 객체를 찾는다. 그 키를 못 찾으면 DERIVED_VARIANT_KEY라는 정규식(/^(.+)@\d+\.webp$/i)으로 이 키가 "파생된 변형 키처럼 생겼는지"를 검사한다. images/uploads/<uuid>@640.webp처럼 끝이 @숫자.webp 형태면 @640 부분을 떼어낸 원본 키(baseKeyForVariant)로 한 번 더 찾아본다. 재귀 없이 딱 한 단계만 대신 찾는다. 정말 변형 형태가 아닌 키가 없으면 그대로 404다.

여기까지는 지금도 그대로다. 문제였던 건 그다음, 헤더를 정하는 지점이었다. 고치기 전 코드는 R2에서 뭘 찾아왔든 상관없이 Cache-Control에 똑같은 IMMUTABLE_CACHE_CONTROL 값을 박아 넣었다. 원본을 그대로 찾아온 정상 히트든, 변형이 없어서 대신 찾아온 폴백이든 코드 입장에서는 구분이 없었다. 둘 다 "객체를 찾았으니 캐시해도 된다"는 같은 문장으로 처리됐다.

발행 직후 축소본 업로드가 끝나기 전에 그 URL이 누군가에게 한 번이라도 요청되면 이 조합이 사고로 이어졌다. 실제로 그런 일이 있었다. 서비스에 반영한 직후, 축소본이 아직 안 올라간 시점에 홈 화면이 그 URL을 요청했고, 서버는 원본으로 대신 응답하면서 그 응답을 1년짜리로 캐시에 박아 버렸다. 이후 진짜 축소본을 R2에 제대로 올려도 캐시된 URL은 계속 원본을 내보냈다. 파일은 맞게 올라갔는데 화면은 안 바뀌는, 원인을 모르면 미궁에 빠지기 좋은 증상이었다.

고친 방법과 회귀 테스트

고친 방향은 원인 그대로였다. 정상 히트와 폴백의 캐시 수명을 코드 차원에서 분리했다. servedVariantFallback이라는 불리언 값을 두고, R2에서 요청받은 키를 그대로 찾았으면 false, 파생 키를 못 찾아 원본으로 대신 찾았으면 true로 기록한다. 이 값에 따라 헤더를 갈랐다. 정상 히트는 그대로 IMMUTABLE_CACHE_CONTROL(public, max-age=31536000, immutable)을 쓰고, 폴백은 새로 만든 FALLBACK_CACHE_CONTROL(public, max-age=300)을 쓴다. 300초, 5분짜리 짧은 캐시다.

Cloudflare Workers의 Cache API는 cache.put()에 넘긴 Response 객체의 Cache-Control 헤더를 그대로 TTL로 쓴다. 그래서 코드를 새로 짤 필요 없이 응답에 실어 보내는 헤더 값만 갈라주면 캐시 계층도 그 값을 그대로 따라간다.

고친 뒤에는 기존 회귀 테스트(test/images.test.ts)에 각 경로의 Cache-Control 값을 확인하는 검증을 추가했다. 변형 키가 실제로 있을 때는 IMMUTABLE_CACHE_CONTROL이 나오는지, 변형은 없고 원본만 있을 때는 FALLBACK_CACHE_CONTROL이 나오는지를 각각 확인한다. 변형도 원본도 둘 다 없을 때, 변형 형태가 아닌 키가 그냥 없을 때, @는 있어도 뒤에 숫자가 아닌 문자가 붙은 키(images/uploads/foo@bar.webp)일 때까지 다섯 가지 경우를 서로 다른 바이트 값으로 저장해 두고 실제로 어느 객체가 나왔는지까지 대조한다. 헤더 값 하나 바꾸는 수정치고는 과해 보일 수 있지만, 이 다섯 케이스는 원본과 파생 키를 다루는 로직 전체의 경계이기도 해서 헤더 분리가 그 경계를 건드리지 않았다는 것도 같이 증명해 준다.

개발자가 정상 히트와 폴백 응답의 캐시 수명을 코드에서 분리해 회귀 테스트로 고정하는 모습을 표현한 일러스트
같은 응답 처리 경로 안에서도 정상 히트와 임시 폴백은 서로 다른 캐시 수명을 가져야 한다는 걸 코드와 테스트로 함께 고정했다.

응답별 캐시 정책은 다음과 같다.

응답 종류 같은 URL이 나중에 다른 내용을 돌려줄 수 있나 실제 헤더 판단
정상 히트 (요청한 키의 객체를 그대로 찾음) 아니다 — 현재 발행 경로에서는 imageKey()가 호출마다 새 UUID를 발급하고 기존 키를 덮어쓰지 않아, 같은 키에 다른 내용이 올라오지 않는다 IMMUTABLE_CACHE_CONTROL (1년, immutable) 안전
변형 폴백 (변형 키가 없어 원본으로 대신 응답) 그렇다 — 나중에 진짜 변형이 올라오면 같은 URL이 다른 바이트를 돌려줘야 한다 FALLBACK_CACHE_CONTROL (5분) immutable 금지, 짧은 캐시만
에러 (요청한 키도 파생 원본도 못 찾음, 404) 판단 불가 — 나중에 그 객체가 새로 생길 수 있다 헤더 자체를 안 붙임, Cache API에도 저장하지 않음 캐시하지 않음

판단 기준표와 남은 숙제

정상 응답·폴백 응답·에러 응답별로 immutable 캐시를 붙여도 되는지 판단하는 기준과 실제 헤더값을 정리한 트리 다이어그램
위 표를 나무 구조로 다시 그린 것이다. 같은 200 응답이라도 "지금 맞는가"가 아니라 "앞으로도 계속 맞는가"로 물어야 갈래가 갈린다.

세 번째 줄은 코드를 다시 읽다가 알게 된 사실이다. serveImage는 객체를 못 찾으면 헤더를 만들고 cache.put()을 호출하는 코드에 닿기도 전에 곧바로 404 Response를 반환한다. 그래서 존재하지 않는 키에 대한 응답은 Cache API에 아예 안 남는다. 나중에 같은 이름으로 진짜 파일이 올라와도 그 요청은 캐시를 거치지 않고 R2까지 다시 물어보게 된다는 뜻이라, 이 경로는 애초에 이번 사고의 위험군이 아니었다.

지금 고친 상태에도 남은 창은 있다. 폴백 응답의 캐시 수명을 5분으로 줄였을 뿐이지 0으로 만든 건 아니다. 발행 직후 5분 사이에 그 URL이 요청되고, 그 5분 안에 진짜 변형까지 올라오는 흔치 않은 타이밍이 겹치면, 해당 캐시를 타는 요청은 최초 폴백 저장 시점부터 최대 5분간 원본을 받을 수 있다. 예전의 1년 캐시에 비하면 영향은 크게 줄었지만, 완전히 사라진 것은 아니다. 캐시를 강제로 지우는(purge) 절차까지 넣을지는 아직 결정하지 못했다. 지금 이 코드에는 그런 purge 기능이 따로 없어서, 5분이 지나기 전에 문제를 알아챈다면 남은 방법은 새 키로 다시 올리는 것뿐이다. 지금은 발행 파이프라인이 변형 업로드를 커버 발행과 최대한 붙여서 처리하는 쪽으로 이 창을 좁히는 데 무게를 두고 있다.

이번 일로 배운 건 캐시 수명을 정할 때 "이 응답이 지금 맞는가"가 아니라 "이 응답이 앞으로도 계속 맞는가"를 물어야 한다는 점이다. 두 질문은 비슷해 보여도 답이 갈린다. 폴백 응답은 첫 번째 질문에는 "그렇다"고 답할 수 있다. 지금 이 순간에는 원본을 보여주는 게 맞는 선택이었으니까. 그런데 두 번째 질문에는 "아니다"로 답해야 한다. 축소본이 언제 올라올지 코드는 알 수 없고, 올라오는 순간 정답이 바뀐다. immutable을 붙일지 말지는 결국 이 두 번째 질문에 달려 있다. 폴백처럼 내용이 바뀔 수 있는 응답에는 짧은 캐시 수명을 써야 한다.

비슷한 함정을 재시도 로직에서 실패 시 폴백을 어떻게 처리할지 다룬 글에서도 마주쳤다. 그때도 핵심은 같았다. 정상 경로와 예외 경로를 같은 규칙으로 뭉뚱그리면, 예외가 정상인 척 오래 살아남는다. immutable은 캐시가 유효한 동안 재검증하지 않아도 된다는 약속이므로, 임시 응답에 붙이면 purge나 URL 변경 없이는 수정 반영이 늦어진다. 헤더 한 줄이 코드에서는 사소해 보여도, 그 한 줄이 가리키는 약속의 무게는 응답 종류마다 다르게 매겨야 한다.

자주 묻는 질문

immutable을 붙인 이미지를 나중에 진짜로 바꾸려면 어떻게 하나요?

같은 키에 덮어쓰지 않고 새 키로 올린다. 이 저장소의 imageKey() 함수는 호출할 때마다 무작위 UUID를 새로 발급해, 이전에 올린 객체와 같은 경로를 다시 쓰지 않도록 만든다.

폴백 응답도 원본과 똑같이 1년씩 캐시해도 되나요?

안 된다. 폴백은 지금은 없어서 대신 준다는 임시 상태이므로 immutable을 붙이면 안 된다. 이 코드베이스는 폴백 전용으로 max-age=300짜리 별도 헤더(FALLBACK_CACHE_CONTROL)를 따로 둔다.

변형 이미지가 없을 때 왜 그냥 404를 내지 않고 원본으로 대신 응답하나요?

브라우저가 고른 srcset 후보가 깨진 이미지로 나오는 것보다, 전송량이 늘더라도 원본을 보여주는 편이 낫기 때문이다. 다만 이 폴백은 임시 상태라서 캐시 수명은 정상 응답과 분리해야 한다.

에러 응답(404)도 이 코드에서 캐시되나요?

아니다. 객체를 찾지 못하면 함수는 헤더를 설정하고 Cache API에 저장하는 코드에 도달하기 전에 곧바로 404 응답을 반환한다. cache.put() 호출 자체가 일어나지 않는다.