변환한 이미지에서 서체가 바뀌어 있거나, 한글은 멀쩡한데 영문만 다른 글꼴로 나오거나, 줄바꿈 위치가 화면과 달라지는 문제는 거의 전부 한 가지 사실에서 출발합니다. 그림을 그리는 주체가 내 브라우저가 아니라 서버 안의 Chromium이고, 그 서버에는 내 컴퓨터에 설치된 폰트가 하나도 없다는 점입니다.
CSS의 font-family는 폰트를 문서에 첨부하는 선언이 아닙니다. "이 이름의 폰트가 있으면 그것으로 그려 달라"는 요청일 뿐입니다. 내 컴퓨터에는 macOS의 Apple SD Gothic Neo, Windows의 맑은 고딕, 직접 설치한 Pretendard가 있으니 화면에서는 의도한 대로 보입니다. 그러나 이미지를 실제로 그리는 서버에는 그 폰트들이 없습니다.
이때 브라우저는 오류를 내지 않습니다. 그저 스택의 다음 후보로 조용히 넘어갑니다. 이 조용한 폴백이 문제를 어렵게 만듭니다. 콘솔에도 아무것도 남지 않고, 결과 이미지를 눈으로 봐야만 무언가 달라졌다는 것을 알 수 있습니다.
여기에 한 가지가 더 겹칩니다. 파일을 올렸든 코드를 붙여넣었든, 서버는 그것을 파일 하나로 저장한 뒤 file:// 주소로 엽니다. 그 옆에 있던 폰트 파일은 함께 올라가지 않으므로 url("./fonts/MyFont.woff2")처럼 상대 경로로 지정한 웹폰트는 예외 없이 실패합니다. 폰트를 확실히 적용하려면 절대 https:// 주소이거나, 파일 안에 직접 내장되어 있어야 합니다.
| 결과 이미지에서 보이는 증상 | 가장 흔한 원인 | 볼 곳 |
|---|---|---|
| 글자가 네모(□)로 나온다 | 어떤 폰트에도 그 글리프가 없음. 한글보다는 특수 기호·한자·최신 이모지 | 2·6번 |
| 서체가 통째로 다르다 | 스택에 적은 이름이 서버에 없음 | 2번 |
| 한글은 맞는데 영문·숫자만 다르다 | lang 누락, 또는 라틴 글리프가 없는 폰트 | 3번 |
| 굵은 글씨가 뭉개지거나 굵기 차이가 없다 | 그 굵기의 파일이 없어 합성 굵기로 그려짐 | 4번 |
| 코드·숫자의 열이 어긋난다 | monospace가 고정폭 폰트로 연결되지 않음 | 5번 |
| 줄바꿈 위치와 이미지 세로 길이가 화면과 다르다 | 폰트마다 글자 폭과 줄 높이가 달라 문서 전체 높이가 바뀜 | 8번 |
한 가지는 미리 짚어 두는 편이 좋겠습니다. 이 서버에는 한글 폰트가 설치되어 있으므로 한글이 네모로 나오는 일은 오히려 드뭅니다. 훨씬 흔한 것은 "깨졌다"기보다 조용히 다른 서체로 바뀐 경우이고, 이 글의 대부분은 그쪽을 다룹니다. 마지막 줄도 그냥 넘길 문제가 아닙니다. 이 도구는 문서 전체 높이를 그대로 캡처하므로, 폰트가 바뀌어 줄 수가 한 줄만 늘어도 결과 PNG의 세로 픽셀 수가 함께 달라집니다. 같은 규격으로 여러 장을 뽑아야 한다면 이 부분이 특히 성가십니다.
렌더링 컨테이너에 들어 있는 폰트는 아래가 전부이며, 각 폰트가 실제로 가지고 있는 굵기까지 함께 적었습니다. 표에 없는 이름은 웹폰트로 가져오지 않는 한 적용되지 않습니다.
| CSS에 적을 이름 | 계열 | 실제로 존재하는 굵기 |
|---|---|---|
"Noto Sans CJK KR" | 한글 고딕. 본문 기본값으로 무난합니다 | Regular(400), Bold(700) |
"Noto Serif CJK KR" | 한글 명조 | Regular, Bold |
"Noto Sans Mono CJK KR" | 한글까지 있는 고정폭 | Regular, Bold |
"NanumGothic" | 나눔고딕. 띄어쓰기 없이 한 단어 | Regular, Bold |
"NanumBarunGothic" | 나눔바른고딕 | Regular, Bold |
"NanumMyeongjo" | 나눔명조 | Regular, Bold |
"NanumSquare" | 나눔스퀘어. 각진 제목용 | Regular, Bold |
"NanumSquareRound" | 나눔스퀘어라운드 | Regular, Bold |
"NanumGothicCoding" | 나눔고딕코딩. 고정폭 | Regular, Bold |
"Liberation Sans""Liberation Serif""Liberation Mono""Liberation Sans Narrow" | 라틴 전용. Arial·Times·Courier와 글자 폭이 호환되는 대체 서체 | Regular, Bold, Italic, Bold Italic |
"Noto Color Emoji" | 컬러 이모지 | 단일 |
Google Fonts에서 쓰는 이름인 "Noto Sans KR"과 서버에 설치된 이름인 "Noto Sans CJK KR"은 서로 다른 문자열입니다. 웹폰트로 불러오지 않은 채 "Noto Sans KR"만 적으면 그 이름은 아무것도 가리키지 못하고, 스택의 다음 후보나 문서 기본 폰트로 넘어갑니다. 결과가 우연히 비슷해 보일 수는 있지만 어떤 파일이 쓰이는지 통제할 수 없게 되므로, 시스템 폰트로 쓸 때는 CJK가 들어간 이름을 적어 주세요.
body {
font-family: "Noto Sans CJK KR", "NanumGothic",
"Liberation Sans", "Noto Color Emoji", sans-serif;
}서버 보유 폰트만으로 구성해 폴백이 일어나지 않는 스택
폰트 스택은 문서 단위가 아니라 글자 하나하나 단위로 평가됩니다. 앞에서부터 그 글자를 가진 첫 번째 폰트가 선택되므로, 한 문단 안에서도 한글과 영문에 서로 다른 파일이 걸릴 수 있습니다. 영문만 다른 서체로 쓰고 싶다면 라틴 전용 폰트를 앞에 두고, 전체를 한 서체로 통일하고 싶다면 라틴 글리프까지 포함한 한글 폰트를 맨 앞에 두면 됩니다.
<html lang="ko"> 한 줄이 서체를 바꿉니다리눅스의 Chromium은 이름으로 찾지 못한 폰트를 고를 때 문서의 언어를 참고합니다. 그런데 Noto Sans CJK는 한국어판·일본어판·중국어판이 이름만 다른 채 한 묶음으로 설치되기 때문에, 언어 정보가 없으면 어느 판본이 뽑힐지 문서가 정해 주지 못합니다.
같은 HTML에서 lang만 바꿔 놓고 실제로 어떤 폰트 파일이 쓰였는지 확인해 보면 결과가 이렇게 갈립니다.
| 문서 설정 | 한글·한자에 쓰인 폰트 | 영문·숫자에 쓰인 폰트 |
|---|---|---|
<html lang="ko"> | Noto Sans CJK KR | Noto Sans CJK KR |
lang 없음 | Noto Sans CJK JP | Liberation Sans |
<html lang="en"> | Noto Sans CJK JP | Liberation Sans |
한글 자모의 모양은 세 판본이 사실상 공유하므로, 한글만 있는 문서라면 판본이 바뀌어도 잘 드러나지 않습니다. 문제가 되는 지점은 두 곳입니다. 하나는 한자를 섞어 쓸 때로, 같은 글자라도 일본어 기준의 자형으로 그려집니다. 다른 하나가 훨씬 흔한데, 영문과 숫자가 한글과 다른 파일로 넘어가면서 굵기와 자간이 미묘하게 어긋납니다. 한글 페이지인데 "영문만 어딘가 촌스럽다"는 인상은 대개 여기서 나옵니다.
즉 lang은 검색엔진이나 스크린 리더만을 위한 표시가 아니라, 이 도구에서는 실제로 어떤 폰트 파일이 쓰일지를 결정하는 설정입니다. 문서 첫 줄을 <html lang="ko">로 고치는 것만으로 사라지는 문제가 생각보다 많습니다.
서버에 설치된 한글 폰트는 하나같이 Regular와 Bold 두 벌뿐입니다. CSS의 폰트 매칭 규칙에 따라 요청한 굵기와 가장 가까운 파일이 선택되므로, 100부터 500까지는 Regular로, 600부터 900까지는 Bold로 모입니다. 같은 문장을 굵기만 바꿔 폭을 재어 보면 400과 500이 완전히 같은 값이고 700과 900도 마찬가지입니다. 300으로 얇게, 500으로 살짝 굵게 같은 설계는 서버 폰트만으로는 표현되지 않습니다. 중간 굵기가 꼭 필요하다면 그 굵기 파일을 가진 웹폰트를 불러와야 합니다.
반대로 굵기 파일이 아예 없는 폰트에 font-weight: 700을 주면 브라우저가 획을 부풀려 굵게 흉내 냅니다. 이것을 합성 굵기(faux bold)라고 합니다. 합성 굵기는 글자 폭은 그대로 둔 채 획만 두껍게 만들기 때문에, 획이 촘촘한 한글은 속공간이 메워져 특히 지저분해집니다.
폭이 변하지 않는다는 점 때문에 자바스크립트로 글자 폭을 재는 방법으로는 합성 굵기를 잡아낼 수 없습니다. 대신 font-synthesis: none;을 잠깐 넣고 한 번 변환해 보세요. 흉내로 만들어지던 굵기가 사라지므로, 어느 굵기가 실제 파일 없이 그려지고 있었는지 한눈에 드러납니다.
monospace를 믿으면 코드 정렬이 무너집니다<pre>와 <code>의 기본 서체는 총칭 이름인 monospace입니다. 그런데 이 서버에서 monospace는 고정폭 폰트로 연결되지 않습니다. 같은 개수의 i와 M을 나란히 재어 보면 폭이 세 배 가까이 벌어집니다. 코드나 표를 그대로 캡처하면 열이 맞지 않은 이미지가 나온다는 뜻입니다. 고정폭이 필요하면 폰트 이름을 직접 적어야 합니다.
pre, code, .num {
font-family: "NanumGothicCoding", "Noto Sans Mono CJK KR",
"Liberation Mono", monospace;
}세 폰트 모두 라틴 글자 폭은 고르게 유지됩니다. 다만 한글이 섞이면 차이가 생깁니다. "NanumGothicCoding"은 한글 한 글자가 라틴 두 글자와 정확히 같은 폭이라 한글 주석이 들어간 코드에서도 열이 흐트러지지 않는 반면, 나머지 둘은 한글 폭이 라틴의 두 배와 조금씩 어긋납니다. 한글 주석까지 세로로 맞춰야 한다면 "NanumGothicCoding"이 유일한 선택지입니다.
@font-face {
font-family: "MyBrand";
src: url("https://cdn.example.com/fonts/MyBrand-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
font-display: block;
}
@font-face {
font-family: "MyBrand";
src: url("https://cdn.example.com/fonts/MyBrand-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
font-display: block;
}
body {
font-family: "MyBrand", "Noto Sans CJK KR", sans-serif;
font-synthesis: none;
}굵기마다 @font-face를 따로 선언해야 합성 굵기를 피할 수 있습니다. src에 local("MyBrand")를 먼저 적어 두어도 서버에는 그 폰트가 없으므로 조용히 다음 url()로 넘어갑니다. 내 컴퓨터에서 미리보기 할 때 편하니 그대로 두어도 무방합니다.
CORS 헤더를 반드시 확인하세요. 폰트 파일은 이미지와 달리 언제나 교차 출처 검사를 거칩니다. 그리고 같은 출처인지 아닌지는 CSS 파일이 아니라 문서의 출처를 기준으로 판단하는데, 업로드된 HTML은 file://로 열리므로 문서 출처가 없는 상태입니다. 즉 모든 폰트 주소가 교차 출처로 취급됩니다. 폰트를 올려 둔 서버가 Access-Control-Allow-Origin: *을 내려주지 않으면 파일이 멀쩡해도 차단됩니다. 직접 운영하는 서버나 사내 CDN에 폰트를 올렸다면 이 헤더부터 점검하세요.
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;700&display=block">Google Fonts는 폰트 파일에 교차 출처 허용 헤더를 붙여 배포하므로 file:// 문서에서도 그대로 동작합니다. 다만 흔히 쓰는 display=swap 대신 display=block을 쓰는 편이 캡처 용도에서는 안전합니다. swap은 폰트가 준비되기 전에 일단 대체 폰트로 그려 두었다가 나중에 바꿔치기하는 방식이라, 타이밍이 어긋나면 대체 폰트 상태가 그대로 사진에 찍혀 버립니다.
한글 폰트 한 벌은 라틴 폰트와 비교가 되지 않게 큽니다. 이 서버에 설치된 파일만 봐도 나눔고딕 한 굵기가 4MB대이고, 여러 언어를 한 파일에 담은 Noto Sans CJK는 20MB에 육박합니다. 그래서 문구가 고정된 이미지, 예를 들어 배너 제목처럼 글자가 몇 개뿐이라면 text= 파라미터로 실제 쓰는 글자만 담은 폰트를 받는 편이 훨씬 낫습니다. 글자는 URL 인코딩해서 넣습니다.
<!-- "오늘의특가" 다섯 글자만 내려받는다 -->
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@700&display=block&text=%EC%98%A4%EB%8A%98%EC%9D%98%ED%8A%B9%EA%B0%80">이렇게 요청하면 응답으로 오는 @font-face는 딱 그 글자들만 담은 파일 하나를 가리킵니다. 내려받는 양이 수십 KB 수준으로 줄어들어 변환도 눈에 띄게 빨라집니다.
네트워크를 아예 타지 않기 때문에 폰트 서버의 응답 속도, CORS 헤더, 도메인 차단 같은 변수가 전부 사라집니다. 결과를 반드시 똑같이 재현해야 하는 상품 상세페이지나 카드뉴스라면 이 방법을 권합니다.
# Linux
base64 -w0 MyBrand-Regular.woff2 > MyBrand-Regular.b64
# macOS
base64 -i MyBrand-Regular.woff2 | tr -d '\n' > MyBrand-Regular.b64import base64, pathlib
data = base64.b64encode(
pathlib.Path("MyBrand-Regular.woff2").read_bytes()
).decode()
print('@font-face{font-family:"MyBrand";'
f'src:url("data:font/woff2;base64,{data}") format("woff2");'
'font-weight:400;font-style:normal;font-display:block;}')출력된 한 줄을 그대로 HTML의 <style> 안에 붙여 넣으면 됩니다.
@font-face {
font-family: "MyBrand";
src: url("data:font/woff2;base64,d09GMgABAAAAA...(중략)...AA=") format("woff2");
font-weight: 400;
font-style: normal;
font-display: block;
}base64로 바꾸면 용량이 원본보다 약 3분의 1 늘어납니다. 이 도구의 업로드 한도는 파일당 20MB이므로, 한글 폰트를 통째로 넣기보다 필요한 글자 범위만 남긴 서브셋 WOFF2를 만들어 넣는 편이 안전하고 변환도 빠릅니다.
이 서비스는 페이지를 열 때 networkidle0 상태를 기다립니다. 진행 중인 네트워크 요청이 하나도 없는 상태가 잠시 유지될 때까지 기다린다는 뜻이라, 폰트 파일을 내려받는 중이면 캡처가 시작되지 않습니다. HTML과 CSS에 처음부터 적혀 있는 웹폰트라면 대부분 제때 반영됩니다.
어긋나는 지점은 두 곳입니다. 첫째, 폰트 서버가 느려 페이지 로드 전체가 30초 제한을 넘기면 변환 자체가 실패합니다. 둘째, 브라우저는 화면에 실제로 그려야 할 글자가 생겼을 때 비로소 해당 폰트를 요청하므로, 스크립트가 나중에 삽입하는 텍스트에 쓰이는 굵기는 요청 자체가 늦게 발생합니다. 이렇게 늦은 요청은 이미 지나간 대기 판정에 포함되지 못합니다.
해결은 간단합니다. 문서를 읽는 시점에 필요한 굵기를 미리 요청해서, 그 요청이 대기 판정 안으로 들어오게 만들면 됩니다.
<script>
// 초기 화면에 바로 등장하지 않는 굵기까지 로드 단계에서 미리 요청한다.
// 이렇게 해야 폰트 요청이 networkidle0 대기에 포함된다.
if (document.fonts && document.fonts.load) {
document.fonts.load('400 16px "MyBrand"');
document.fonts.load('700 16px "MyBrand"');
}
</script>타이머로 화면을 드러내는 방식은 쓰지 마세요. "body를 visibility: hidden으로 숨겼다가 몇 초 뒤에 보여 준다"는 기법이 캡처 도구용으로 자주 소개되지만, 이 서비스에서는 위험합니다. 캡처는 네트워크가 잠잠해진 직후 아주 짧은 대기만 두고 곧바로 일어나기 때문에, 몇 초짜리 타이머가 끝나기 훨씬 전에 셔터가 눌릴 수 있습니다. 그러면 아무것도 없는 빈 이미지를 받게 됩니다. 굳이 숨김 처리를 하겠다면 고정 시간이 아니라 document.fonts.ready가 끝나는 시점에 거세요. 이 값은 폰트 로딩이 성공이든 실패든 정리되면 결정되므로 화면이 영원히 숨겨지지는 않습니다.
라틴 폰트는 글리프가 수백 개지만 한글은 완성형 음절만 11,172자입니다. 그래서 한글 웹폰트를 서브셋 없이 쓰면 한 굵기만으로도 파일이 상당히 커집니다. Google Fonts는 폰트를 여러 조각으로 나누고 각 조각에 unicode-range를 붙이는 방식으로 이 문제를 풉니다. 브라우저는 페이지에 실제로 등장하는 글자가 속한 조각만 내려받습니다. 대신 조각 수만큼 요청이 늘어나므로 네트워크가 느리면 지연이 누적됩니다.
직접 서브셋한 파일을 쓴다면 같은 font-family 이름으로 여러 @font-face를 선언하고 unicode-range만 다르게 주면 됩니다. 브라우저가 글자에 맞는 파일만 골라서 받습니다.
@font-face {
font-family: "MyBrand";
src: url("https://cdn.example.com/fonts/MyBrand-KR.woff2") format("woff2");
font-weight: 400;
font-display: block;
unicode-range: U+AC00-D7A3, /* 한글 음절 가–힣 */
U+1100-11FF, /* 한글 자모 */
U+3130-318F, /* 호환용 자모 */
U+FF01-FF60; /* 전각 영숫자·문장부호 */
}
@font-face {
font-family: "MyBrand";
src: url("https://cdn.example.com/fonts/MyBrand-Latin.woff2") format("woff2");
font-weight: 400;
font-display: block;
unicode-range: U+0000-00FF, /* 기본 라틴 + 라틴-1 */
U+2000-206F; /* 일반 문장부호 */
}Chromium은 한글을 기본적으로 음절 단위로 어디서나 끊습니다. "상품"이 줄 끝에서 "상 / 품"으로 갈라지는 이유입니다. word-break: keep-all을 주면 띄어쓰기 단위로만 끊깁니다. 대신 공백 없는 긴 단어나 URL이 상자를 뚫고 나갈 수 있으므로 overflow-wrap: anywhere를 함께 씁니다. 이 조합을 걸어 두면 폰트가 바뀌어 글자 폭이 달라지더라도 단어가 중간에서 갈라지는 일은 없기 때문에, 폴백이 일어났을 때 레이아웃이 덜 망가집니다.
.copy {
word-break: keep-all;
overflow-wrap: anywhere;
}이 서비스는 캡처 직전에 모든 요소에 -webkit-font-smoothing: antialiased와 text-rendering: optimizeLegibility를 적용하고, 브라우저 쪽에서도 LCD 서브픽셀 렌더링을 꺼 둡니다. 글자 가장자리를 색이 아니라 회색 농담으로만 처리한다는 뜻입니다. 색 번짐이 없어야 어떤 배경 위에 올려도 자연스럽기 때문인데, 화면에서 보던 것보다 획이 조금 담백해 보일 수 있습니다.
결과가 흐리게 느껴진다면 굵기를 한 단계 올려 실제 폰트 파일로 그리는 편이, 합성 굵기에 기대는 것보다 훨씬 깨끗합니다. 그리고 출력은 지정한 가로 폭의 2배 픽셀로 렌더링된 래스터 PNG입니다. 지정한 크기로 쓰면 선명하지만 그보다 크게 늘리면 결국 픽셀이 드러나므로, 최종적으로 쓰일 픽셀 폭의 절반을 가로 폭 값으로 넣는 것이 기준입니다.
어떤 폰트가 실제로 적용됐는지는 짐작하는 것보다 이미지에 찍어 두고 확인하는 편이 빠릅니다. 아래 조각을 임시로 넣고 한 번 변환해 보세요.
<div id="font-check" style="font: 13px monospace; color: #333"></div>
<script>
function widthOf(family) {
var s = document.createElement('span');
s.textContent = '다람쥐 헌 쳇바퀴 ABC 123';
s.style.cssText =
'position:absolute;visibility:hidden;white-space:nowrap;font:40px ' + family;
document.body.appendChild(s);
var w = Math.round(s.getBoundingClientRect().width);
s.remove();
return w;
}
document.fonts.ready.then(function () {
document.getElementById('font-check').textContent =
'MyBrand=' + widthOf('"MyBrand"') +
' / Noto=' + widthOf('"Noto Sans CJK KR"') +
' / 기준=' + widthOf('"__NoSuchFont__"');
});
</script>같은 문장을 서로 다른 폰트로 그렸을 때의 가로 폭을 재는 코드입니다. MyBrand 값이 기준 값과 같다면 웹폰트가 전혀 적용되지 않고 기본 폰트로 떨어진 상태입니다. 두 값이 다르다면 의도한 폰트가 실제로 걸린 것입니다. Noto 값은 서버 기본 한글 폰트의 폭이 얼마인지 비교하기 위한 참고치입니다. 확인이 끝나면 이 조각은 지우고 다시 변환하면 됩니다.
비슷해 보이는 document.fonts.check()는 이 용도로 쓸 수 없습니다. 존재하지 않는 폰트 이름을 넣어도 true를 돌려주기 때문입니다. 폭을 재는 방식이 번거로워 보여도 실제로 무엇이 그려졌는지 알려 주는 것은 이쪽입니다. 다만 앞서 설명했듯 합성 굵기는 폭을 바꾸지 않으므로, 이 코드로는 다른 폰트로 바뀐 것만 잡아낼 수 있습니다.
<html lang="ko">인가 — 한 줄로 한자 자형과 영문 서체가 함께 정리됩니다"Noto Sans CJK KR", "NanumGothic" 등https://로 시작하는 절대 주소인가 — 상대 경로는 반드시 실패합니다@font-face를 따로 선언했는가font-display가 swap이 아니라 block인가여기까지 확인했는데도 결과가 다르다면 문의 페이지로 문제가 된 HTML을 보내 주세요. 리소스 경로나 해상도 같은 다른 주제는 가이드 목록에서 확인할 수 있습니다.