스토리지를 날리고 브라우저 캐시에서 되찾기

작업일 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. 원래 이름 그대로 돌려놓기

복구한 파일을 다시 올릴 때 선택지가 두 개였다.

  1. 에디터로 다시 업로드한다 → 새 주소가 붙는다. 글 20개를 전부 열어 이미지를 다시 끼워야 한다
  2. 원래 오브젝트 키 그대로 올린다 → 글은 한 줄도 안 건드린다

2번을 골랐다. 스토리지의 SDK 를 쓰지 않고 서명을 직접 만들어 올리는 40줄짜리 스크립트를 썼다. 키를 내가 정할 수 있으면 되는 일이었다.

이게 가능했던 건 원래 설계 덕이다. 이 블로그는 이미지 주소를 상대 경로로만 저장한다. 절대 URL 을 본문에 박으면 공개 호스트를 바꾸는 순간 이미 쓴 글이 전부 옛 주소를 가리키게 되기 때문이다. 그래서 글 본문에는 /uploads/… 만 있고, 실제 저장소가 어디인지는 배포 설정 한 줄이 정한다.

호스트가 통째로 바뀌어도 글이 안 깨지도록 만든 구조가, 저장소가 사라졌다 다시 생기는 상황에서도 그대로 값을 했다.

덤으로 프록시 대상 호스트 이름에 오타가 있는 것도 그때 발견했다(storagestroage 로 적어 뒀다). 어차피 새로 연결할 이름이니 문서가 원래 쓰고 있던 이름으로 통일했다.


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개를 손으로 고치는 일과 스크립트 한 번 돌리는 일의 차이를 만들었다.