
WSL 우분투에서 Pillow로 블로그 썸네일을 그리려는데 fc-list :lang=ko가 0줄을 돌려줬다. 한글 폰트가 하나도 없다는 뜻이다. sudo apt install fonts-noto-cjk가 정석이지만, 자동화 세션에서는 sudo가 비밀번호를 받을 터미널이 없어 막혔다. 이 글은 sudo 없이 사용자 홈에 Noto Sans KR을 설치하고, Pillow가 그 폰트를 찾게 하고, 제목 길이에 따라 글자 크기를 맞추는 썸네일 생성기를 만든 과정을 다룬다.
sudo 가 필요 없는 설치 경로가 있다
fontconfig는 시스템 폰트 디렉터리 말고도 사용자별 디렉터리를 읽는다. ~/.local/share/fonts다. 여기에 파일을 넣고 캐시만 갱신하면 그 사용자의 모든 프로그램이 폰트를 본다. 관리자 권한이 필요 없다.
폰트 파일은 Noto CJK 저장소에서 받았다. 이 저장소는 언어별 서브셋 OTF를 Sans/SubsetOTF/KR/ 아래에 두고 있어, 한국어만 필요하면 전체 CJK 묶음 대신 파일 두 개면 된다.
mkdir -p ~/.local/share/fonts
cd ~/.local/share/fonts
curl -sLO https://raw.githubusercontent.com/notofonts/noto-cjk/main/Sans/SubsetOTF/KR/NotoSansKR-Regular.otf
curl -sLO https://raw.githubusercontent.com/notofonts/noto-cjk/main/Sans/SubsetOTF/KR/NotoSansKR-Bold.otf
fc-cache -f
fc-list :lang=ko
# NotoSansKR-Bold.otf: Noto Sans KR:style=Bold
# NotoSansKR-Regular.otf: Noto Sans KR:style=Regular
파일은 각각 4.6MB, 4.8MB였다. 처음에 Google Fonts의 다운로드 URL을 시도했는데 zip 대신 HTML 페이지가 내려와서 버렸다. GitHub raw 경로가 스크립트에서 다루기 확실하다. 되돌리고 싶으면 파일 두 개를 지우고 fc-cache -f를 다시 돌리면 끝이다. 시스템은 건드리지 않았다.
Pillow 는 사용자 폰트 디렉터리를 알아서 뒤진다
Pillow 문서의 ImageFont.truetype(font, size) 설명에는 이런 대목이 있다. 파일을 지정한 이름으로 못 찾으면 "리눅스에서는 ~/.local/share/fonts, /usr/local/share/fonts, /usr/share/fonts" 같은 디렉터리도 찾아본다. 즉 파일명만 줘도 된다.
from PIL import ImageFont
font = ImageFont.truetype("NotoSansKR-Bold.otf", 64)
print(font.getbbox("티스토리 발행 자동화"))
# (0, 21, 559, 82)
다만 나는 경로를 명시하는 쪽을 택했다. 폰트가 없으면 Pillow가 OSError를 내는데, 그 시점에는 어디를 뒤졌는지가 메시지에 없다. 후보 경로 목록을 코드에 두고 차례로 존재 여부를 확인한 뒤, 하나도 없으면 "Noto Sans KR을 설치하라"는 메시지로 실패하게 했다. 설정 파일에서 다른 폰트 경로를 줄 수도 있게 했다.
def font_path(weight): # "bold" | "regular"
candidates = [
Path.home() / ".local/share/fonts" / f"NotoSansKR-{weight.title()}.otf",
Path(f"/usr/share/fonts/opentype/noto/NotoSansCJK-{weight.title()}.ttc"),
]
for p in candidates:
if p.exists():
return p
raise RuntimeError(f"한글 폰트를 찾지 못했습니다 ({weight})")
제목 길이에 맞춰 글자 크기를 줄이는 쪽이 자르는 것보다 낫다
썸네일은 1200×630, 어두운 배경에 제목을 흰색 굵은 글씨로 놓고 아래에 블로그 이름을 둔다. 문제는 제목 길이다. 12자짜리도 있고 38자짜리도 있다. 72px로 고정하면 긴 제목은 네 줄을 넘어 잘린다.
접근은 "3줄 안에 들어올 때까지 글자 크기를 4px씩 줄인다"다. 한글은 띄어쓰기가 적어서 textwrap.wrap의 단어 기준 줄바꿈이 잘 안 맞는다. 그래서 글자 폭을 크기의 0.95배로 어림잡아 한 줄 글자 수를 정하고, 실제 폭은 draw.textlength()로 다시 재서 넘치면 다음 크기로 넘어간다.
def fit_title(title, font_path, size, max_width, draw):
for s in range(size, 34, -4):
font = ImageFont.truetype(str(font_path), s)
per_line = max(6, int(max_width / (s * 0.95)))
lines = textwrap.wrap(title, width=per_line) or [title]
if len(lines) <= 3 and all(draw.textlength(l, font=font) <= max_width for l in lines):
return lines, font
return textwrap.wrap(title, width=24)[:3], ImageFont.truetype(str(font_path), 34)
38자 제목은 60px 세 줄로, 12자 제목은 72px 한 줄로 나왔다. 글자를 자르는 방식(…)과 비교하면, 검색 결과나 SNS 카드에서 제목이 통째로 읽힌다는 점이 다르다. 썸네일의 목적이 클릭이라면 제목을 잘라서는 안 된다.
하나 더: 설정값이 빈 문자열이면 기본값으로 안 넘어간다
첫 결과물에서 하단의 블로그 이름이 안 찍혔다. 코드는 cfg.get("thumbnail.footer", cfg.get("blog.name"))였다. 설정 파일에 footer: ""가 있었고, 빈 문자열은 "키가 존재한다"이므로 get의 기본값이 쓰이지 않았다. cfg.get("thumbnail.footer") or cfg.get("blog.name") or ""로 바꿔 해결했다. 사소하지만 설정 파일에 예시로 빈 값을 적어두는 습관과 dict.get의 의미가 충돌하는 전형적인 경우다.
반론. 시스템에 폰트를 설치하는 것이 더 깔끔하지 않은가. 다른 사용자도 쓰고, 패키지 관리자가 갱신도 해준다. 맞다. 사람이 직접 터미널을 잡고 있다면 apt가 낫다. 이 글의 방법은 sudo를 쓸 수 없는 상황, 즉 자동화 세션이나 권한이 제한된 컨테이너에서 쓰는 우회로다. 다만 결과 파일이 홈 디렉터리 안에 있어서 무엇을 어떻게 바꿨는지 추적하기 쉽다는 이점은 우회로 쪽에 있다.
참고 자료
- PIL.ImageFont — Pillow 문서 —
truetype이 탐색하는 디렉터리,getbbox - notofonts/noto-cjk — GitHub — 언어별 서브셋 OTF 위치와 라이선스
- Using HTTP cookies — MDN — 같은 파이프라인의 로그인 세션 문제를 다룬 글에서 함께 참고
'IT' 카테고리의 다른 글
| 설계 문서를 테스트로 강제하기, spec.yaml과 spec-check (0) | 2026.09.16 |
|---|---|
| AI 에이전트 보안, 권한을 어디까지 줄 것인가 (0) | 2026.09.16 |
| Playwright 고정 대기 대신 상태 대기를 써야 하는 이유 (0) | 2026.09.15 |
| 오픈소스 라이선스 변경, 기업이 자꾸 바꾸는 이유와 개발자의 대응 (1) | 2026.09.15 |
| Playwright intercepts pointer events 해결 (0) | 2026.09.14 |