goodchuck 블로그

정적 블로그 이미지를 git 대신 S3 에 두는 법 — 명령 하나로 올리고 git 엔 목록만

9분 읽기

이 블로그는 마크다운 글을 빌드할 때 정적 페이지로 굽는 Next.js 사이트예요. 글은 저장소 안의 폴더 하나에 들어 있고, 글에 넣는 스크린샷도 같은 폴더에 둡니다.

content/posts/2026/10/28/01/
├── edit.md        글
└── media/         글에 넣는 이미지
    ├── home.png
    └── chart-top20.png

여기까지는 흔한 구조인데, 하나 다른 점이 있어요. media/ 폴더는 git 에 들어가지 않습니다. 이미지는 S3 에 있고, git 에는 S3 주소와 크기를 적은 목록(media.json)만 커밋해요.

이 글은 그 구조를 어떻게 만들었는지 정리한 기록입니다.

왜 이미지를 git 에 안 넣나

이 블로그는 웹 앱·API·데스크탑 앱이 여러 개 들어 있는 모노레포 안에 있어요. 글마다 스크린샷이 몇 장씩 붙으면 이미지가 저장소 전체를 무겁게 만듭니다.

더 큰 문제는 git 은 지워도 줄지 않는다는 점이에요. 스크린샷을 다시 찍어 바꾸면 예전 파일도 기록에 그대로 남아서, 저장소를 받을 때마다 예전 이미지까지 같이 받게 됩니다. 이미지를 고칠수록 저장소가 커지는 구조죠.

그래서 이미지 파일은 S3 에 두고, git 에는 "어떤 이미지가 어디에 어떤 크기로 있는지"만 남기기로 했어요.

글 쓰는 방법은 그대로

중요하게 생각한 건 글 쓰는 방법을 바꾸지 않는 것이었어요. 이미지를 S3 주소로 직접 적게 하면 글 쓸 때마다 업로드부터 해야 해서 번거롭거든요.

그래서 본문에는 지금처럼 상대경로로 씁니다.

![순위 차트 Top 20](./media/chart-top20.png)

로컬 개발 서버는 글 폴더의 파일을 그대로 보여 줘요. 아직 S3 에 올리기 전이어도 미리보기가 됩니다. 운영 빌드 때만 이 경로가 S3 주소로 바뀌어요.

올리기 — media push 한 번

글을 다 쓰면 명령 하나를 실행합니다.

pnpm --filter blog media push

이 명령이 하는 일은 네 가지예요.

media/home.png (git 에 없음)
   │  media push
   ├─▶ S3 공개본   blog/public/posts/2026/10/28/01/home.e3a20f87847e.webp
   ├─▶ S3 원본 백업 blog/originals/2026/10/28/01/media/home.png   (비공개)
   └─▶ media.json 갱신 (S3 키 · 원본 해시 · 가로세로)  ← 이것만 커밋

1. 바뀐 파일만 고른다

파일 내용으로 해시를 만들어 media.json 에 적힌 해시와 비교해요. 같으면 건너뛰고, 새 파일이거나 내용이 바뀐 것만 올립니다. 그래서 글이 수십 편이 돼도 매번 전부 올리지 않아요.

export function hashOf(buf) {
  return createHash('sha256').update(buf).digest('hex').slice(0, 12)
}

/** 원본 해시가 media.json 과 같으면 건너뛴다 */
export function planUploads(files, manifest) {
  return files.filter((f) => manifest[f.rel]?.hash !== f.hash)
}

2. 올리기 전에 줄인다

스크린샷 원본은 PNG 라 꽤 큽니다. 올리기 전에 sharp 로 최대 폭 1600px 의 webp 로 줄여요. 원래 더 작은 이미지는 키우지 않고요.

const info = await sharp(file)
  .rotate()                                              // 사진의 회전 정보 반영
  .resize({ width: 1600, withoutEnlargement: true })     // 1600px 보다 크면 줄이고, 작으면 그대로
  .webp({ quality: 82 })
  .toFile(out)
// info.width · info.height 를 media.json 에 같이 적는다

영상과 움짤(gif)은 프레임이 깨지지 않게 변환하지 않고 그대로 올립니다.

지금 이 블로그의 이미지는 32개인데, 원본은 합쳐서 6.45MB, 실제로 방문자가 받는 webp 공개본은 1.48MB 예요. 4분의 1 정도로 줄었습니다.

3. 원본은 따로 백업한다

공개본은 줄인 webp 라서 원본을 되살릴 수 없어요. 그래서 원본 PNG 도 같은 버킷의 비공개 경로에 따로 올려 둡니다. 공개 읽기는 public/ 아래만 허용하고, 나머지 경로는 바깥에서 읽을 수 없게 버킷 정책으로 막았어요.

4. media.json 을 쓴다

글 폴더에 이런 목록이 생기고, git 에는 이 파일만 커밋해요.

{
  "media/chart-top20.png": {
    "key": "blog/public/posts/2026/10/28/01/chart-top20.cca9ca927ee6.webp",
    "hash": "cca9ca927ee6",
    "width": 1280,
    "height": 800
  }
}

빌드 — 목록을 보고 주소를 만든다

운영 빌드는 Vercel 에서 하는데, 거기엔 이미지 원본이 없어요(git 에 없으니까요). 그래서 빌드는 이미지 파일 대신 media.json 을 읽습니다.

  • 본문의 ./media/chart-top20.png → media.json 에서 찾아 S3 주소로
  • 가로·세로도 media.json 에 있으니, 파일 없이도 이미지 자리를 미리 잡아 화면이 밀리지 않아요

올리는 걸 잊으면 빌드가 멈춘다

이 구조의 약점은 "push 를 잊어버리는 것"이에요. 글에 이미지를 넣고 push 를 안 하면, 운영에서 이미지가 깨진 채로 나갑니다.

그래서 이런 경우는 빌드를 실패시켰어요.

상황빌드 메시지
media.json 에 없다 (push 안 함)아직 S3 에 안 올렸어요 — pnpm --filter blog media push
내 PC 의 파일이 media.json 의 해시와 다르다 (고쳐 놓고 push 안 함)바뀌었는데 S3 에 안 올렸어요
파일도 목록도 없다파일이 없어요 (다른 PC 라면 pnpm --filter blog media pull)

두 번째가 특히 쓸모 있어요. 스크린샷을 다시 찍어 바꾸고 push 를 잊으면, 에러 없이 예전 이미지가 조용히 나가는 게 가장 찾기 어려운 실수거든요. 내 PC 에서 빌드할 때 해시를 한 번 더 비교해서 이걸 막습니다.

캐시 — 이름에 해시를 넣은 이유

공개본 이름에 내용 해시가 들어가 있는 걸 봤을 거예요.

chart-top20.cca9ca927ee6.webp

이미지를 바꾸면 해시가 바뀌니 주소도 바뀝니다. 그래서 한 번 올린 주소의 내용은 절대 바뀌지 않고, 브라우저에 1년 동안 다시 묻지 말라고(immutable) 캐시를 걸 수 있어요.

aws s3 cp out.webp s3://<버킷>/blog/public/posts/.../chart-top20.cca9ca927ee6.webp \
  --content-type image/webp \
  --cache-control "public, max-age=31536000, immutable"

같은 이름으로 덮어쓰는 방식이었다면, 이미지를 고쳐도 방문자의 브라우저에는 예전 이미지가 한동안 남아 있었을 거예요.

next/image 최적화는 끈다

Next.js 의 next/image 는 요청이 오면 이미지를 줄여 주는 최적화를 해 줘요. 그런데 이미 올리기 전에 줄였으니 두 번 할 필요가 없어서, S3 이미지에는 최적화를 껐습니다.

<Image src={media.src} width={media.width} height={media.height} unoptimized={!media.local} />

덤으로 Vercel 무료 플랜의 이미지 최적화 한도도 쓰지 않아요.

다른 PC 에서는 media pull

원본이 git 에 없으니, 다른 PC 에서 저장소를 받으면 글 폴더에 이미지가 없어요. 이때는 백업해 둔 원본을 받아 옵니다.

pnpm --filter blog media pull

media.json 에 적힌 파일 중 없는 것만 비공개 백업에서 받아 media/ 에 되돌려 놓아요. 버킷은 버전 관리를 켜 두어서, 실수로 덮어써도 예전 원본을 되살릴 수 있습니다.

다른 방법과 비교

정적 블로그의 이미지를 두는 방법은 이것 말고도 여럿 있어요. 상황에 따라 더 맞는 쪽이 다를 수 있어서 같이 정리해 둡니다.

방법장점단점
public/ 폴더에 그대로가장 간단하다. 설정이 없다git 이 무거워지는 문제가 그대로 남는다
Git LFSgit 으로 다루던 흐름이 그대로다저장소 호스팅의 LFS 저장·전송 한도를 쓰고, 받는 쪽마다 설치가 필요하다
이미지 CDN 서비스 (Cloudinary 등)크기 변환·포맷 최적화를 알아서 해 준다외부 서비스에 의존하고, 무료 한도를 넘으면 비용이 든다
배포 플랫폼의 저장소 (Vercel Blob 등)배포하는 곳과 한곳에서 관리한다그 플랫폼에 묶인다
S3 + 직접 만든 push (이 글)저렴하고, 줄이는 크기·캐시·검사 규칙을 내가 정한다올리고 받는 스크립트를 직접 관리해야 한다

글이 몇 편 안 되고 이미지를 거의 안 바꾼다면 public/ 폴더로도 충분해요. 이미지를 자주 바꾸거나 다른 앱과 한 저장소를 쓴다면 git 밖에 두는 쪽이 낫고, 그중에서도 변환까지 맡기고 싶으면 이미지 CDN, 규칙을 직접 쥐고 싶으면 이 글처럼 S3 에 직접 올리는 방식이 맞아요.

대가

편하기만 한 건 아니에요.

  • 단계가 하나 늘었다. 글을 공개하기 전에 media push 를 해야 해요. 잊으면 빌드가 막아 주긴 하지만, 그래도 한 단계예요.
  • AWS 자격증명이 필요하다. 올리고 받는 쪽(내 PC)에만요. 빌드하는 Vercel 은 S3 를 읽지 않고 공개 주소만 쓰니까 자격증명이 없어도 됩니다.
  • 비공개 글의 이미지도 주소는 공개다. 글을 아직 공개하지 않았어도, push 한 이미지는 주소를 알면 누구나 볼 수 있어요. 민감한 정보가 찍힌 스크린샷은 push 전에 한 번 더 봅니다.

정리

할 일방법
글 쓰기지금처럼 글 폴더 media/ 에 두고 ![](./media/a.png)
올리기media push — 바뀐 것만, 1600px webp, 원본은 비공개 백업
커밋media.json 만 (media/ 는 .gitignore)
빌드media.json 의 주소·크기로. 안 올렸거나 바뀌었으면 실패
캐시이름에 내용 해시 → 1년 immutable
다른 PCmedia pull

정적 블로그라도 글이 쌓이면 이미지가 저장소에서 가장 무거운 부분이 됩니다. 처음부터 git 밖에 두면, 나중에 기록을 정리하느라 고생할 일이 없어요.