Hugo static/에 둔 이미지는 원본 그대로 나갑니다 — 빌드 때 줄이고 WebP로 바꾸기
마크다운에  한 줄을 쓰면 Hugo가 알아서 줄여서 내보낼 거라고 생각하기 쉽습니다.
재보니 그렇지 않았습니다. 이미지를 어디에 두느냐에 따라 Hugo가 아예 손을 안 댑니다.
2026-10-04에 작은 테스트 사이트를 따로 만들어 세 가지 방법을 나란히 재봤습니다.

테스트 조건은 이렇습니다. 글 8편에 이미지를 1장씩 넣었고 이미지는 가로 2560px PNG 8장(합계 7,151,706바이트, 약 7.2MB)입니다.
기존 삽화를 macOS의 sips로 키워서 PNG로 바꾼 것이라 실제 사진보다는 단순한 그림입니다.
Hugo는 v0.165.0 extended, 컴퓨터는 Apple M2 Max입니다.
static/ 에 둔 이미지는 Hugo가 그대로 복사만 합니다

가장 흔한 배치부터 봤습니다. static/images/posts/p1/00.png 에 두고 본문에서 /images/posts/p1/00.png 로 부르는 방식입니다.
빌드 출력은 이랬습니다.
Static files │ 8
Processed images │ 0
Processed images(Hugo가 처리한 이미지 수)가 0입니다. 8장이 그대로 복사됐고 결과물 이미지는 7,151,706바이트, 원본과 같았습니다.
나온 HTML에는 width·height도 없습니다.
<img src="/images/posts/p1/00.png" alt="테스트 1" loading="eager" decoding="async">
Hugo 문서에도 이렇게 나와 있습니다. 이미지를 처리하려면 파일을
page resource(글 폴더 안의 파일), global resource(assets/ 안의 파일), remote resource(외부 URL) 중 하나로 가져와야 합니다
(Hugo · Image processing).
static/ 은 이 셋 어디에도 들지 않습니다. 템플릿에서 resources.Get 으로 찾으면 아무것도 돌아오지 않습니다.
그래서 static/ 에 둔 채로 줄이고 싶으면 빌드 전에 직접 변환해야 합니다.
$ cwebp -q 78 -resize 1280 0 00.png -o 00.webp
8장을 이렇게 바꿨더니 185,298바이트가 됐습니다. 이미지 하나에 화질 하나, 크기 하나로 끝나는 사이트면 이 방법으로도 충분합니다. 다만 원본을 고칠 때마다 다시 돌려야 하고 화면 폭에 맞춰 여러 크기를 내보내려면 그만큼 명령을 더 돌려야 합니다.
assets/ 로 옮기고 render-image 훅에서 줄이기

원본을 assets/images/posts/p1/00.png 로 옮기고 본문 마크다운은 한 글자도 바꾸지 않았습니다.
대신 render-image 훅(마크다운 이미지 한 줄을 HTML로 바꿀 때 Hugo가 부르는 템플릿, layouts/_markup/render-image.html)을 고쳤습니다.
{{- $u := urls.Parse .Destination -}}
{{- $img := "" -}}
{{- if not $u.IsAbs -}}
{{- with .Page.Resources.Get $u.Path -}}
{{- $img = . -}}
{{- else -}}
{{- with resources.Get (strings.TrimPrefix "/" $u.Path) -}}{{- $img = . -}}{{- end -}}
{{- end -}}
{{- end -}}
{{- with $img -}}
{{- $set := slice -}}
{{- $main := . -}}
{{- range slice 640 960 1280 -}}
{{- if le . $img.Width -}}
{{- $r := $img.Resize (printf "%dx webp q78" .) -}}
{{- $set = $set | append (printf "%s %dw" $r.RelPermalink $r.Width) -}}
{{- $main = $r -}}
{{- end -}}
{{- end -}}
<img src="{{ $main.RelPermalink }}" srcset="{{ delimit $set ", " }}"
sizes="(max-width: 760px) 100vw, 720px"
width="{{ $main.Width }}" height="{{ $main.Height }}" alt="{{ $.PlainText }}">
{{- else -}}
{{- warnf "render-image: %q 를 리소스로 못 찾았습니다 (%s)" $.Destination $.Page.File.Path -}}
<img src="{{ .Destination | safeURL }}" alt="{{ .PlainText }}">
{{- end -}}
핵심은 Resize "1280x webp q78" 한 줄입니다. 가로 1280px로 줄이고 높이는 비율대로, 형식은 WebP, 화질은 78이라는 뜻입니다.
화질을 적지 않으면 Hugo 기본값이 들어가는데, hugo config 로 보니 [imaging.webp] quality = 75 였습니다.
원본보다 큰 폭은 만들지 않도록 le . $img.Width 로 걸렀습니다.
빌드하면 이미지 한 장에서 세 가지 크기가 나오고 HTML은 이렇게 됩니다.
<img src="/images/posts/p1/00_hu_1b9308e35ad72f41.webp"
srcset="/images/posts/p1/00_hu_e764dab69b4f76f0.webp 640w, /images/posts/p1/00_hu_c5404b2cbd0d4062.webp 960w, /images/posts/p1/00_hu_1b9308e35ad72f41.webp 1280w"
sizes="(max-width: 760px) 100vw, 720px" width="1280" height="715" alt="테스트 1" ...>
srcset(화면 폭에 맞는 파일을 브라우저가 고르도록 후보를 나열한 속성)과 width·height 가 같이 나왔습니다.
원본 PNG는 결과물에 아예 없었습니다. 이 훅은 줄인 WebP의 주소(RelPermalink)만 꺼내고 원본의 주소는 꺼내지 않습니다. 아래에서 보듯 원본의 주소를 꺼내는 훅이면 PNG가 그대로 나갑니다.
마지막 warnf 는 덤입니다. 같은 훅을 static/ 에 둔 이미지에 걸어보니 빌드 때 이렇게 8줄이 찍혔습니다.
WARN render-image: "/images/posts/p1/00.png" 를 리소스로 못 찾았습니다 (posts/p1.md)
이 줄이 없으면 처리가 빠진 이미지가 경고 없이 원본 그대로 나갑니다.
세 가지 방법을 나란히 재봤습니다

| ① static 원본 | ② 수동 cwebp | ③ Hugo 자동 변환 | |
|---|---|---|---|
| 결과물 이미지 | 8개, 7,151,706 B | 8개, 185,298 B | 24개, 396,384 B |
| 1280px 파일만 합치면 | — | 185,298 B | 184,404 B |
width·height | 없음 | 없음 | 있음 |
srcset | 없음 | 없음 | 640·960·1280 |
| 빌드 시간(첫 빌드) | 0.05~0.06초 | 0.04~0.05초 | 0.93~0.96초 |
| 빌드 시간(두 번째부터) | 0.05초 | 0.04~0.05초 | 0.05초 |
빌드 시간은 /usr/bin/time -p 로 세 번씩 잰 real 값입니다. 수동 cwebp 8장 변환은 따로 0.65초가 걸렸습니다.
③의 합계가 ②보다 큰 것은 640·960px 파일까지 더한 값이라서입니다.
1280px끼리만 놓고 보면 cwebp와 Hugo가 거의 같았습니다. 장마다 차이는 2바이트에서 1,662바이트 사이였고, 8장 중 4장은 cwebp가, 4장은 Hugo가 더 컸습니다.
예를 들어 첫 장은 cwebp 13,398바이트, Hugo 13,758바이트였고 마지막 장은 45,588바이트와 43,926바이트였습니다.
용량만 보면 ②와 ③은 같다고 봐도 됩니다. (화질 비교는 하지 않았습니다.) 차이는 손이 얼마나 가느냐와 srcset·width·height 가 자동으로 붙느냐에서 납니다.
assets/ 에 두되 훅이 처리를 안 하는 경우도 재봤습니다. 이미지 크기만 읽어 width·height 를 박는 훅이면
PNG 8장이 그대로 나가고 width="2560" height="1430" 이 붙습니다.
assets/ 로 옮기는 것만으로 줄어들지는 않습니다. Resize 같은 처리 메서드를 불러야 줄어듭니다.
첫 빌드만 1초인 이유 — resources/_gen

③은 첫 빌드가 0.9초대였는데 두 번째부터는 0.05초였습니다. 처리 결과가 resources/_gen/images/ 에 남기 때문입니다.
hugo config 로 기본값을 보면 이렇게 나옵니다.
[caches.images]
dir = ':resourceDir/_gen'
maxage = -1
maxage = -1 은 만료가 없다는 뜻입니다(Hugo · Configure file caches).
24장을 만든 뒤 이 폴더는 428K, 파일 24개였습니다.
캐시가 언제 깨지는지도 확인했습니다.
- 훅에서
q78을q80으로만 바꿔도 24장을 전부 새로 만들었습니다(Hugo가 찍은Total기준 0.90초). 폴더에는 옛 파일이 그대로 남아 48개, 888K 가 됐습니다 hugo --gc로 빌드하면 안 쓰는 파일을 지워 24개로 돌아왔습니다- 원본 PNG 한 장만 바꾸면 그 장의 3개만 새로 만들어 0.30초(
Total기준)가 걸렸습니다
여기서 하나 걸리는 게 있습니다. 저는 resources/ 를 .gitignore 에 넣어 두었고 배포 워크플로에도 이 폴더를 캐시하는 단계가 없습니다.
이 상태로 이미지 처리를 쓰면 배포 서버는 매번 처음 빌드하는 셈입니다. 제 컴퓨터에서는 24장에 1초 정도라 문제가 없었지만 이미지가 많은 사이트라면 이 처리 시간이 배포마다 반복됩니다.
CI에서 resources/ 를 캐시했을 때 얼마나 줄어드는지는 이번에 재지 못했습니다.
글 폴더(page bundle)에 넣으면 원본도 같이 나갑니다

assets/ 대신 글 폴더에 이미지를 같이 두는 방법도 있습니다.
content/posts/p1/index.md 옆에 00.png 를 두고 본문에  로 쓰는 방식이고 이걸 page bundle이라고 부릅니다.
위 훅이 .Page.Resources.Get 을 먼저 찾기 때문에 같은 srcset 이 나왔습니다.
그런데 결과물 폴더를 보니 파일이 하나 더 있었습니다.
public/posts/p1/00_hu_1b9308e35ad72f41.webp
public/posts/p1/00_hu_c5404b2cbd0d4062.webp
public/posts/p1/00_hu_e764dab69b4f76f0.webp
public/posts/p1/00.png ← 628,852 B 원본
HTML 어디에도 연결되지 않은 원본 PNG가 같이 배포됩니다. 글 폴더 안의 파일은 기본적으로 전부 내보내기 때문입니다
(publishResources: true 가 기본값, Hugo · Build options).
글의 front matter에 아래를 넣자 원본이 빠지고 WebP 3개만 남았습니다.
build:
publishResources: false
assets/ 쪽에서는 이 설정이 필요 없었습니다.
다시 한다면 이 순서로 하겠습니다

- 빌드 출력의
Processed images가 0인지 먼저 봅니다. 본문 이미지가 있는데 0이면 원본이 그대로 나가고 있는 겁니다 - 원본은
static/이 아니라assets/에 둡니다. page bundle을 쓰면publishResources: false를 같이 넣습니다 - render-image 훅에서
Resize "1280x webp q78"처럼 폭·형식·화질을 한 번에 지정하고srcset·width·height를 같이 냅니다 - 리소스를 못 찾으면
warnf로 빌드 로그에 남깁니다. 조용히 원본이 나가는 걸 막는 장치입니다 - 화질 값을 바꾼 뒤에는
hugo --gc로resources/_gen의 옛 파일을 정리합니다
용량만 줄이는 게 목적이면 수동 cwebp도 결과 용량이 거의 같았습니다.
제가 훅을 고른 이유는 원본을 한 번 넣어두면 크기 여러 개와 width·height 까지 빌드가 맞춰주기 때문입니다.