스토리지를 날리고 브라우저 캐시에서 되찾기
작업일 2026-08-29 · 대상 업로드 이미지 전체 · 결과 52장 중 22장 원본 복원, 30장 대체
1. 증상
글을 쓰다가 이미지를 올리면 빨간 토스트가 떴다.
이미지 저장에 실패했습니다
그런데 글 목록의 썸네일은 멀쩡히 보였다. 그래서 처음엔 업로드 기능만 고장 난 줄 알았다. 이 관찰이 틀렸다는 걸 아는 데 시간이 좀 걸렸고, 나중엔 그 틀린 관찰이 복구의 실마리가 됐다.
2. 어디가 고장 났나
그 문구는 코드에 딱 한 군데 있다.
try {
await put(key, stored, mime)
} catch (err) {
console.error('storage put failed:', key, err)
throw new ApiError(502, 'STORAGE_ERROR', '이미지 저장에 실패했습니다.')
}
오브젝트 스토리지에 쓰는 단계에서 던지고 있었다. 파일 검증도, 이미지 변환도 이미 통과한 뒤다. 즉 파일 문제도 로그인 문제도 아니다.
여기서 이 코드의 결함이 하나 드러난다. catch (err) 가 진짜 원인을 삼키고 한 문장으로 뭉갠다. 자격증명이 죽었는지, 버킷이 없는지, 네트워크가 안 되는지가 전부 같은 메시지다. 진짜 이름은 서버 로그에만 남는다.
로그를 보러 가기 전에 밖에서 확인할 수 있는 것부터 쟀다.
| 확인 | 결과 | 읽는 법 |
|---|---|---|
/api/v1/health |
{"status":"ok", ...} |
DB 정상. 환경변수가 통째로 날아간 건 아니다 |
| 인증 없이 업로드 POST | 401 |
라우트와 모듈 로드는 정상. 이미지 변환 라이브러리도 살아 있다 |
| 이미지 주소 직접 요청 | 502 |
서빙도 죽어 있다 |
증상이 두 개였다. 업로드만이 아니라 이미 올려 둔 이미지도 전부 죽어 있었다. 목록 썸네일이 보이니까 서빙은 멀쩡하다고 믿고 있었는데, 서버에 직접 물어보니 아니었다.
3. 두 증상, 한 원인
이미지는 우리 도메인에서 나가고, 엣지가 스토리지를 대신 읽어 오는 구조다. 그 프록시 대상 호스트를 권한 네임서버에 직접 물었다.
블로그 도메인 → 정상 응답
이미지 호스트 → NXDOMAIN
존재하지 않는 이름이었다. 그래서 엣지가 502 를 낸다.
여기서 두 증상이 하나로 합쳐진다. 스토리지의 커스텀 도메인은 버킷에 딸린 설정이다. 버킷을 지우면 그 DNS 레코드도 같이 사라진다. 대시보드를 열어 보니 그대로였다.
You haven't created a bucket yet.
버킷을 실수로 지웠던 것이다. 업로드가 실패하는 것도(쓸 곳이 없다), 기존 이미지가 502 인 것도(읽을 곳이 없다) 같은 사건이었다.
되돌릴 수 없다. 이 스토리지에는 휴지통도 버전 관리도 없다. 지운 순간 바이트는 없다.
4. "썸네일은 살아 있다"의 정체
여기서 처음의 틀린 관찰로 돌아간다. 서버가 전부 502 인데 왜 목록 썸네일은 보였을까.
업로드 이미지는 이 헤더를 달고 나간다.
Cache-Control: public, max-age=31536000, immutable
immutable 이 붙은 1년짜리 캐시다. 브라우저는 서버에 묻지도 않고 디스크에서 꺼내 쓴다. 서버가 죽었는지 살았는지 알 방법이 없고, 알 필요도 없다고 배웠으니까.
썸네일이 살아 있던 게 아니다. 내 컴퓨터에 남아 있던 것이다.
그 말은 곧 — 원본 바이트가 아직 디스크에 있다.
5. 크롬 캐시에서 꺼내기
크롬의 HTTP 캐시는 두 겹이다.
- blockfile — 항목 메타데이터. 고정 크기 슬롯에 URL(키)과 본문 위치가 들어 있다
f_파일 — 본문이 큰 항목은 별도 파일로 따로 저장된다
이미지처럼 수십 KB 짜리는 메타데이터만 blockfile 에 있고 본문은 f_ 파일에 있다. 그래서 두 단계다. blockfile 을 훑어 URL 을 찾고, 거기 적힌 주소를 따라가 본문을 꺼낸다.
blockfile 의 항목 하나는 256바이트 고정이고, 우리가 쓸 값은 네 곳에 있다.
| 오프셋 | 내용 |
|---|---|
| 32 | 키 길이 |
| 36 | 키가 256바이트에 안 들어갈 때의 외부 주소 |
| 44 | 본문 크기 |
| 60 | 본문 주소 |
| 96 | 키(URL) — 짧으면 여기 그대로 |
헤더 뒤부터 256바이트씩 끊어 읽으면서 키가 우리 이미지 주소인 항목만 고르고, 본문 주소를 해석해 바이트를 꺼내면 된다. 주소 최상위 비트가 켜져 있으면 유효하고, 상위 3비트가 파일 종류다. 그 값이 0이면 외부 f_ 파일이고 나머지 비트가 파일 번호다.
꺼낸 바이트가 진짜 이미지인지는 앞 12바이트로 확인했다. RIFF 로 시작해서 8바이트 뒤에 WEBP 가 있으면 맞다.
결과.
캐시 색인에 남아 있던 이미지 주소 39개
본문까지 살아 있던 것 28개
본문이 이미 밀려난 것 11개
11개는 색인에는 이름이 있는데 본문 파일이 없었다. 캐시가 꽉 차면 오래된 본문부터 지워지는데, 색인 항목은 남아 있던 것이다.
6. 안 통한 길
웹 아카이브 — 스냅샷이 하나도 없었다. 만든 지 한 달 된 개인 블로그를 크롤러가 훑을 이유가 없다. 게다가 그날 아카이브 서비스 자체가 점검 중이었다.
다른 브라우저 · 다른 프로필 — 전부 0건. 이미지를 본 건 한 프로필뿐이었다.
7. 하마터면 틀릴 뻔한 것
캐시에서 못 찾은 것들을 어떻게든 채우고 싶어서 이런 생각을 했다.
본문 HTML 의
<img>에는width·height가 박혀 있다. 캐시의 모든 이미지 파일을 훑어서 치수가 일치하는 것을 찾으면 되지 않을까?
7천 개 파일을 훑어 341개의 이미지를 찾았고, 그중 6개가 치수까지 정확히 맞았다. 잃어버린 6장을 되찾은 줄 알았다.
그런데 그 6개의 치수가 이미 복구한 파일들의 치수와 똑같았다. 확인해 보니 전부 같은 캐시 항목이었다. 스크립트가 A 의 바이트를 B 의 이름으로 복사하고 있었던 것이다. 그대로 올렸으면 엉뚱한 이미지가 남의 글에 들어갔을 것이다. 게다가 눈에 잘 띄지도 않았을 것이다 — 어차피 그 블로그에 있던 스크린샷이니까.
치수는 이미지를 식별하지 못한다. 같은 스크린샷을 두 번 올리면 두 개의 다른 주소가 같은 치수를 갖는다.
걸러낸 방법은 캐시 색인을 한 번 더 쓰는 것이었다. 이번엔 반대 방향으로, 모든 항목의 "본문 파일 → URL" 지도를 만들었다. 그 6개 파일의 주인을 물었더니 전부 이미 복구한 이미지들이었다.
교훈은 이거다. 약한 근거로 맞춘 답은, 검증 가능한 다른 축을 찾아 대조하기 전까지는 답이 아니다. 그리고 그 축은 대개 이미 갖고 있는 데이터 안에 있다.
8. 원래 이름 그대로 돌려놓기
복구한 파일을 다시 올릴 때 선택지가 두 개였다.
- 에디터로 다시 업로드한다 → 새 주소가 붙는다. 글 20개를 전부 열어 이미지를 다시 끼워야 한다
- 원래 오브젝트 키 그대로 올린다 → 글은 한 줄도 안 건드린다
2번을 골랐다. 스토리지의 SDK 를 쓰지 않고 서명을 직접 만들어 올리는 40줄짜리 스크립트를 썼다. 키를 내가 정할 수 있으면 되는 일이었다.
이게 가능했던 건 원래 설계 덕이다. 이 블로그는 이미지 주소를 상대 경로로만 저장한다. 절대 URL 을 본문에 박으면 공개 호스트를 바꾸는 순간 이미 쓴 글이 전부 옛 주소를 가리키게 되기 때문이다. 그래서 글 본문에는 /uploads/… 만 있고, 실제 저장소가 어디인지는 배포 설정 한 줄이 정한다.
호스트가 통째로 바뀌어도 글이 안 깨지도록 만든 구조가, 저장소가 사라졌다 다시 생기는 상황에서도 그대로 값을 했다.
덤으로 프록시 대상 호스트 이름에 오타가 있는 것도 그때 발견했다(storage 를 stroage 로 적어 뒀다). 어차피 새로 연결할 이름이니 문서가 원래 쓰고 있던 이름으로 통일했다.
9. 못 살린 30장
원본이 사라진 30장은 원래와 똑같은 치수의 대체 이미지로 채웠다. 본문 HTML 에 width·height 가 박혀 있어서 정확한 크기를 알 수 있었다.
블로그 팔레트에 맞춘 담백한 판에 점선 테두리, 가운데에 작은 아이콘과 "이미지를 복구하지 못했습니다" 한 줄.
이렇게 한 이유는 셋이다.
- 레이아웃이 픽셀 단위로 그대로다. 글을 읽는 흐름이 안 깨진다
- 정직하다. 그럴듯한 다른 그림을 채워 넣으면 글이 거짓말을 하게 된다
- 되돌리기 쉽다. 나중에 원본을 찾으면 같은 키로 덮어쓰기만 하면 글 수정 없이 되살아난다
10. 마지막 함정
올리고 나서 확인하니 52장 중 50장만 살아났다. 두 장은 여전히 404 였다. 그런데 스토리지에 직접 물으면 200 이다.
Cache-Status: "Netlify Edge"; hit; ttl=13860
Age: 540
Cache-Control: max-age=14400
404 가 캐시돼 있었다. 업로드하기 전에 누군가(나였다) 그 두 주소를 요청했고, 그때 받은 404 를 엣지가 4시간짜리로 저장해 둔 것이다. 주소 뒤에 아무 쿼리나 붙이면 200 이 나왔다.
없는 것을 없다고 기억하는 것도 캐시의 일이다. 고쳐 놓고 확인했을 때 안 고쳐진 것처럼 보이면, 내가 고치기 전에 내가 한 요청을 의심해 볼 만하다.
11. 결과와 남은 것
| 사이트가 참조하는 이미지 | 52 |
| 브라우저 캐시에서 복원한 원본 | 22 |
| 같은 치수의 대체 이미지 | 30 |
| 고친 글 | 0 |
12. 남는 생각
캐시는 백업이 아니다. 그런데 이번엔 백업 역할을 했다. 이건 운이다. 몇 주만 늦었어도, 캐시를 한 번만 비웠어도 22장은 없었다. 자랑할 일이 아니라 백업이 없었다는 증거다.
에러 메시지가 원인을 삼키면 진단이 느려진다. "이미지 저장에 실패했습니다"는 사용자에겐 맞는 말이지만, 그 뒤에 붙은 진짜 이름을 어딘가에 남기지 않으면 서버 로그를 뒤지는 것 말고 방법이 없다.
한 증상만 보고 범위를 정하면 틀린다. "업로드가 안 된다"로 시작했지만 실제로는 이미 올려 둔 것도 전부 죽어 있었다. 증상 하나를 확인했으면, 같은 원인이 닿을 수 있는 다른 곳도 직접 찔러 봐야 한다.
되돌리기 쉬운 상태를 만들어 두는 게 복구력이다. 상대 경로 저장이라는 작은 원칙 하나가, 글 20개를 손으로 고치는 일과 스크립트 한 번 돌리는 일의 차이를 만들었다.
댓글 0
불러오는 중…