이 블로그는 마크다운 글을 빌드할 때 정적 페이지로 굽는 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 주소로 직접 적게 하면 글 쓸 때마다 업로드부터 해야 해서 번거롭거든요.
그래서 본문에는 지금처럼 상대경로로 씁니다.

로컬 개발 서버는 글 폴더의 파일을 그대로 보여 줘요. 아직 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 LFS | git 으로 다루던 흐름이 그대로다 | 저장소 호스팅의 LFS 저장·전송 한도를 쓰고, 받는 쪽마다 설치가 필요하다 |
| 이미지 CDN 서비스 (Cloudinary 등) | 크기 변환·포맷 최적화를 알아서 해 준다 | 외부 서비스에 의존하고, 무료 한도를 넘으면 비용이 든다 |
| 배포 플랫폼의 저장소 (Vercel Blob 등) | 배포하는 곳과 한곳에서 관리한다 | 그 플랫폼에 묶인다 |
| S3 + 직접 만든 push (이 글) | 저렴하고, 줄이는 크기·캐시·검사 규칙을 내가 정한다 | 올리고 받는 스크립트를 직접 관리해야 한다 |
글이 몇 편 안 되고 이미지를 거의 안 바꾼다면 public/ 폴더로도 충분해요. 이미지를 자주 바꾸거나 다른 앱과 한 저장소를 쓴다면 git 밖에 두는 쪽이 낫고, 그중에서도 변환까지 맡기고 싶으면 이미지 CDN, 규칙을 직접 쥐고 싶으면 이 글처럼 S3 에 직접 올리는 방식이 맞아요.
대가
편하기만 한 건 아니에요.
- 단계가 하나 늘었다. 글을 공개하기 전에
media push를 해야 해요. 잊으면 빌드가 막아 주긴 하지만, 그래도 한 단계예요. - AWS 자격증명이 필요하다. 올리고 받는 쪽(내 PC)에만요. 빌드하는 Vercel 은 S3 를 읽지 않고 공개 주소만 쓰니까 자격증명이 없어도 됩니다.
- 비공개 글의 이미지도 주소는 공개다. 글을 아직 공개하지 않았어도, push 한 이미지는 주소를 알면 누구나 볼 수 있어요. 민감한 정보가 찍힌 스크린샷은 push 전에 한 번 더 봅니다.
정리
| 할 일 | 방법 |
|---|---|
| 글 쓰기 | 지금처럼 글 폴더 media/ 에 두고  |
| 올리기 | media push — 바뀐 것만, 1600px webp, 원본은 비공개 백업 |
| 커밋 | media.json 만 (media/ 는 .gitignore) |
| 빌드 | media.json 의 주소·크기로. 안 올렸거나 바뀌었으면 실패 |
| 캐시 | 이름에 내용 해시 → 1년 immutable |
| 다른 PC | media pull |
정적 블로그라도 글이 쌓이면 이미지가 저장소에서 가장 무거운 부분이 됩니다. 처음부터 git 밖에 두면, 나중에 기록을 정리하느라 고생할 일이 없어요.