사후 분석: 블로그 표지 이미지가 S3에서 복원되지 않은 이유

발행: (2026년 5월 22일 PM 06:09 GMT+9)
10 분 소요
원문: Dev.to

출처: Dev.to

포트폴리오에 있는 여섯 개의 블로그 포스트가 표지 이미지가 깨진 채로 오래도록 남아 있었습니다. 이미지들은 S3에 저장돼 있었고, 복구를 위한 관리 명령도 작성해서 실행했었습니다. 그런데도—아무 일도 일어나지 않았습니다. 매번 빈 표지 이미지가 나오죠.

여기서 무슨 일이 있었는지, 왜 그런 일이 발생했는지, 그리고 어떻게 해결했는지를 자세히 풀어보겠습니다.

제 포트폴리오 백엔드는 DigitalOcean Droplet 위에서 Docker Compose로 구동되는 Cookiecutter Django 프로젝트입니다. 블로그 포스트 표지 이미지는 AWS S3에 보관됩니다. BlogPost 모델의 cover 필드는 ImageField이며, blog_posts/deploy.webp 와 같은 상대 경로를 저장합니다—Django의 S3 스토리지 백엔드가 media/ 접두사를 붙이고 전체 URL을 만들어 줍니다.

Hashnode를 헤드리스 CMS로 사용하던 것을 포기하고 모든 포스트를 직접 만든 Django 백엔드로 마이그레이션하면서, 표지 이미지 파일명도 CUID 기반(blog_posts/cmoxrumae00ms2em7bje5at07.png 등)으로 바뀌었습니다. 그런데 이 CUID들은 S3에 존재하지 않았습니다—실제 파일들은 deploy.webp, manual.webp, sortinghashnode.webp 와 같이 설명적인 이름으로 별도로 업로드돼 있었죠.

이를 해결하기 위해 Gemini가 restore_covers.py 라는 관리 명령을 만들었습니다. 이 명령은 두 가지 매칭 전략을 사용합니다:

  • Exact CUID match — S3에서 blog_posts/{cuid}.webp 등을 찾음
  • Fuzzy slug match — 포스트 슬러그를 토큰화하고, 키워드가 겹치는 S3 파일명을 찾음

명령을 실행했지만, 여섯 개 포스트 중 여전히 표지가 깨진 상태였습니다.

S3 파일명은 모두 소문자 단어를 이어 붙인 형태였습니다: sortinghashnode.webp, trackingpage.webp, postmortem.webp. 퍼지 매처는 포스트 슬러그와 각 S3 파일명에 대해 get_keywords() 를 호출해 키워드 집합을 만든 뒤, 교집합을 계산합니다.

문제는 여기 있습니다. get_keywords()re.findall(r"[a-zA-Z0-9]+", text) 로 토큰화합니다. 파일명에 적용하면:

get_keywords("sortinghashnode.webp")
# → {"sortinghashnode"}   ← 하나의 토큰

포스트 슬러그에 적용하면:

get_keywords("sorting-hashnode-series-posts-how-to-display-the-latest-post-first")
# → {"sorting", "hashnode", "series", "posts", "display", "latest", "first"}

{"sortinghashnode"}{"sorting", "hashnode", ...} 의 교집합은 비어 있습니다. 점수 = 0, 매치되지 않음.

같은 실패가 버킷에 있는 모든 연결된 파일명에 적용되었습니다. trackingpage.webptrackingpage 를 포함하는 슬러그와 매치되지 않았고, postmortem.webpmortem (post는 불용어) 과 매치되지 않았습니다. 어느 것도 0 이상의 점수를 받지 못했습니다.

수정 방법은 단순 집합 교집합을 부분 문자열 포함 검사로 바꾸고, 짧은 단어에 의한 오탐을 방지하기 위해 최소 토큰 길이를 4자로 제한하는 것이었습니다:

min_token_len = 4
overlap = set()
for pk in post_keywords:
    for fk in file_keywords:
        if pk == fk or (
            len(pk) >= min_token_len
            and len(fk) >= min_token_len
            and (pk in fk or fk in pk)
        ):
            overlap.add(pk)

이제 "sorting""sortinghashnode" 에 포함되므로 True, 점수 +1. "hashnode" 도 포함돼 점수 +1. 올바른 파일이 매치됩니다.

두 번째로 명령이 조용히 실패한 이유는 Docker Composedocker-compose -f docker-compose.local.yml 를 실행했기 때문입니다. 이 파일은 .envs/.local/.django 를 로드하는데, 여기에는 AWS 자격 증명이 없습니다.

storage.listdir("blog_posts") 가 AWS 자격 증명 없이 호출되면, 예외를 잡아내는 except Exception 에 의해 조용히 무시되거나 로컬 파일시스템 스토리지 백엔드가 활성화돼 빈 리스트를 반환합니다. 명령 출력에는 다음과 같은 메시지가 나타났습니다:

Could not list storage directory directly: ...
Fuzzy matching won't be available.

하지만 전체 명령은 0 으로 종료됐고, “파일이 없었다”는 요약만 보여졌습니다. storage_files 가 비었기 때문에 퍼지 루프는 전혀 실행되지 않았고, 모든 포스트가 “스토리지에 기존 파일을 찾을 수 없음” 분기로 넘어갔습니다.

이 관리 명령은 프로덕션 컨테이너 안에서 실행되도록 설계됐으며, 그곳에서는 .envs/.production/.django 에 올바른 AWS 자격 증명이 이미 설정돼 있습니다.

위 두 가지 수정을 적용해도, 포스트 “How I Fixed the Hashnode GraphQL API Stale Cache Bug (Stellate CDN)” 은 여전히 표지가 깨진 상태였습니다—왜냐하면 S3에 매치되는 파일 자체가 전혀 없었기 때문입니다. DB에는 blog_posts/cmlyqj0cc006627lvguola3gg.png 라는 레코드가 있었지만, 해당 파일에 대한 설명적인 이름이 업로드된 적이 없었습니다. 이 경우는 Django admin을 통해 수동으로 업로드해야 합니다.

나머지 다섯 개 포스트에 대해서는 올바른 표지 경로를 직접 지정하는 Django 데이터 마이그레이션을 작성했습니다:

COVER_FIXES = {
    "how-to-manually-backup-wordpress-sites-via-ssh": "blog_posts/manual.webp",
    "deploying-cookiecutter-django-on-a-digitalocean-droplet-ubuntu-24-04-lts": "blog_posts/deploy.webp",
    "post-mortem-the-march-2026-axios-supply-chain-attack": "blog_posts/postmortem.webp",
    "sorting-hashnode-series-posts-how-to-display-the-latest-post-first": "blog_posts/sortinghashnode.webp",
    "tracking-page-views-in-a-react-spa-with-google-analytics-4": "blog_posts/trackingpage.webp",
}

def fix_covers(apps, schema_editor):
    BlogPost = apps.get_model("blogs", "BlogPost")
    for slug, cover_path in COVER_FIXES.items():
        BlogPost.objects.filter(slug=slug).update(cover=cover_path)

이 마이그레이션은 다음 프로덕션 배포 시 python manage.py migrate 가 실행될 때 자동으로 적용되므로, 별도의 SSH 작업이 필요 없습니다.

퍼지 매처에 대한 부분 문자열 포함 수정은 restore_covers.py 에 패치돼서, 앞으로 비슷한 파일명에도 정상적으로 동작합니다.

여섯 번째 포스트는 Django admin을 통해 수동으로 이미지를 업로드하는 것이 남은 작업입니다.

핵심 문제는 복구 명령이 실패했을 때 너무 조용히 넘어갔다는 점입니다. “fuzzy matching won’t be available” 라는 로그는 남겼지만, 파일 리스트가 비어 있었을 때도 “could not restore” 열이 0인 깔끔한 요약만 출력했습니다. 이 때문에 성공한 것처럼 보였죠.

개선 방안: storage.listdir() 가 완전히 실패하면, 명령은 비정상 종료 코드와 함께 즉시 종료하도록 해야 합니다. 파일이 하나도 없어서 아무것도 매치되지 않는 상황에서 조용히 성공하는 것보다, 크게 실패를 알리는 것이 낫습니다.

마지막으로, 슬러그와 파일명 간 불일치는 처음부터 예측 가능한 문제였습니다. 파일들은 사람이 직접 짧고 설명적인 이름으로 업로드했지만, DB 레코드는 Hashnode에서 온 긴 CUID를 가지고 있었기 때문이죠. slug → filename 매핑 파일(예: 간단한 JSON 사전)을 미리 준비했다면, 퍼지 히스토리 대신 결정적인 복구가 가능했을 것입니다.

0 조회
Back to Blog

관련 글

더 보기 »

내 스킬

프로젝트를 위한 AI 지시문을 만들고, 설치하고, 관리하세요 — 코딩이 필요 없습니다. CREATE 이름을 정하고, 카테고리를 선택하고, 원하는 것을 설명하세요 — 마법사가 자동으로 구성합니다.