한글 웹 타이포그래피 (7) 웹폰트 로딩과 성능

시리즈 한글 웹 타이포그래피 7 / 11
  1. 1한글 웹 타이포그래피 (1) 서구 규칙을 그대로 쓰면 안 되는 이유
  2. 2한글 웹 타이포그래피 (2) 명조와 고딕, 화면에서 무엇을 언제
  3. 3한글 웹 타이포그래피 (3) 글자 크기와 굵기, 그리고 본문 대비
  4. 4한글 웹 타이포그래피 (4) 글 길이와 줄끝 처리
  5. 5한글 웹 타이포그래피 (5) 자간·행간과 문단 간격
  6. 6한글 웹 타이포그래피 (6) 위계와 타입스케일: 본문에서 값을 뽑아 조립하기
  7. 7한글 웹 타이포그래피 (7) 웹폰트 로딩과 성능
  8. 8한글 웹 타이포그래피 (8) 속공간과 커닝
  9. 9한글 웹 타이포그래피 (9) 줄상자의 해부
  10. 10한글 웹 타이포그래피 (10) 문장부호가 데려오는 사이 공간
  11. 11한글 웹 타이포그래피 (11) 폴백 폰트가 밀어내는 줄
한글 웹 타이포그래피 (7) 웹폰트 로딩과 성능

6편에서 값은 다 나왔다. 서체, 크기, 굵기, 행간, 자간, measure, 줄끝, 문단 간격, 대비까지 하나의 CSS 변수 블록으로 조립했고, 그걸 복붙하면 한글 본문 조판은 끝난다. 그런데 값이 아무리 정확해도 그 값을 입는 폰트가 화면에 떠야 조판이 산다. 그리고 한글 웹폰트는 무겁다.

이 마지막 글은 그 무게를 다룬다. 앞의 여섯 편이 한글 본문의 값을 정하는 일이었다면, 이건 그 값이 실제로 빠르게 화면에 뜨게 만드는 마감이다. 조판보다 성능 쪽 이야기라 없어도 글은 읽히지만, 있으면 첫 화면이 눈에 띄게 가벼워진다.

한글 폰트는 왜 무거운가

라틴 알파벳 폰트는 담을 글자가 적다. 대문자 26, 소문자 26, 숫자와 문장부호를 다 더해도 글리프(glyph)가 수백 개 안쪽이다. 한글은 자릿수가 다르다. 현대 한글 음절만 유니코드에 11,172자(U+AC00부터 U+D7A3까지)가 배정돼 있다. 자음과 모음을 조합한 네모 글자 하나하나가 별개의 글리프라 그렇다.

그래서 잘 만든 한글 폰트 하나는 글자 데이터만으로도 파일이 수백 KB에서 MB 단위로 불어난다. 이걸 통째로 받게 두면, 방문자는 첫 글자가 뜨기 전에 그 무거운 파일을 다 내려받길 기다려야 한다. 라틴 폰트를 쓸 때는 신경 쓸 일 없던 문제가, 한글에서는 첫인상을 좌우하는 문제로 커진다.

무게를 줄이는 방향은 크게 둘이다. 하나는 받는 양 자체를 줄이는 것이고, 다른 하나는 다 받기 전까지 화면을 비워두지 않는 것이다. 이 블로그가 쓰는 방식을 하나씩 보자.

안 쓰는 글자는 안 받는다

11,172자를 다 받는 게 낭비인 이유는 단순하다. 한 페이지에 그 글자가 다 나오지 않기 때문이다. 이 글에 실제로 등장하는 한글은 많아야 몇백 자다. 나머지 만 자는 이 페이지를 읽는 데 필요 없다.

그래서 폰트를 통으로 받지 않고 조각내서 받는다. 이걸 서브셋(subset)이라고 한다. 폰트를 글자 범위별로 여러 조각으로 쪼갠 다음, 각 조각에 그 조각이 담당하는 글자 범위를 unicode-range로 표시해 둔다. 브라우저는 페이지를 그리다가 어떤 글자를 만나면, 그 글자가 든 조각만 골라서 내려받는다. 페이지에 안 나온 글자의 조각은 아예 요청하지 않는다.

이 블로그는 Pretendard의 이 동적 서브셋 방식을 그대로 쓴다. 페이지 <head>에 이 한 줄이 들어 있다.

<link rel="stylesheet" crossorigin
  href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable-dynamic-subset.min.css">

이 CSS 파일 안에는 글꼴 자체가 아니라 @font-face 규칙이 잔뜩 들어 있다. 규칙마다 unicode-range로 담당 글자 범위와 그 범위의 폰트 조각 주소가 적혀 있어서, 브라우저가 필요한 조각만 골라 받는 지도 역할을 한다. 덕분에 11,172자짜리 폰트를 걸어놔도, 실제로 오가는 폰트 데이터는 이 페이지에 쓰인 글자 몫으로 확 줄어든다. 한글 폰트를 쓰면서도 첫 화면이 가벼운 건 대부분 이 서브셋 덕이다.

CDN(콘텐츠 전송 네트워크)에서 불러오는 것도 같은 맥락이다. 폰트를 내 서버에 두는 대신 jsDelivr 같은 공용 CDN에서 받으면, 방문자와 가까운 서버가 응답하고 브라우저 캐시도 잘 탄다. 설치랄 게 링크 한 줄이라 관리도 편하다. 대신 남의 인프라에 의존한다는 트레이드오프가 붙는데, 이건 뒤에서 다시 짚는다.

굵기가 여러 갠데 파일은 하나

용량을 줄이는 두 번째 축은 굵기다. 이 블로그만 해도 본문은 보통 굵기, 제목은 굵게, 시리즈 라벨은 더 굵게 쓴다. 예전 방식대로라면 Regular, Bold, ExtraBold를 각각 다른 파일로 받아야 했다. 굵기 종류만큼 파일이 늘어난다.

가변 폰트(variable font)는 이걸 파일 하나로 접는다. 굵기를 낱개 파일로 나누는 대신, 폰트 안에 얇은 것부터 굵은 것까지 잇는 하나의 축을 넣어두고 그 위 아무 지점이나 꺼내 쓴다. 개념적으로 @font-face는 이런 모양이다.

/* 실제 Pretendard 파일엔 이런 블록이 글자 범위별로 잔뜩 들어 있다 */
@font-face {
  font-family: "Pretendard Variable";
  font-weight: 100 900;         /* 한 파일이 얇은 것부터 굵은 것까지 담당 */
  font-display: swap;           /* (다음 절에서) */
  src: url("pretendard-subset-01.woff2") format("woff2");
  unicode-range: U+AC00-D7A3;   /* 이 조각이 맡는 글자 범위 */
}

font-weight가 한 숫자가 아니라 범위(100 900)로 적힌 게 핵심이다. 이 한 파일이 그 사이 아무 굵기나 감당하니, 본문 400과 제목 700을 쓰겠다고 파일을 두 번 받지 않는다. 파일 형식이 woff2인 것도 거들어준다. woff2는 웹용으로 폰트를 강하게 압축한 형식이라, 같은 글자를 더 작은 용량으로 실어 나른다.

뜨기 전까지 뭘 보여줄까

받는 양을 줄여도 폰트가 도착하는 데는 얼마간 시간이 걸린다. 그 잠깐 동안 글자를 어떻게 처리할지가 로딩 성능의 체감을 가른다. 브라우저의 선택지는 둘이다. 폰트가 다 올 때까지 글자를 아예 안 그리고 빈자리로 두거나(FOIT, flash of invisible text), 일단 시스템에 있는 폰트로 먼저 그렸다가 웹폰트가 도착하면 그때 갈아 끼우거나(FOUT, flash of unstyled text).

본문 글은 일단 읽히는 게 먼저다. 그래서 font-display: swap을 쓴다. 폰트가 오기 전까지 시스템 폰트로 먼저 그려서 독자가 바로 읽기 시작하게 하고, 진짜 폰트는 도착하는 대로 바꿔 끼운다. 빈 화면을 보여주는 시간을 없애는 대신, 폰트가 바뀌는 순간 글자 폭이 조금 달라지며 줄이 살짝 밀리는 걸 감수하는 선택이다. 읽기가 목적인 지면에서는 빈 화면보다 이쪽이 낫다.

여기서 폴백(fallback) 폰트가 중요해진다. 웹폰트가 오기 전 잠깐을 책임지는 게 폴백이라, 스택 맨 뒤에 한글이 그려지는 시스템 폰트를 반드시 깔아둬야 한다. 안 그러면 그 잠깐 동안 한글이 네모 상자로 깨진다. 이 블로그 스택이 Pretendard 뒤에 -apple-system을 비롯한 시스템 폰트를 줄줄이 달아둔 이유가 이것이다. 폴백의 크기감이 Pretendard와 비슷할수록 폰트가 바뀔 때 밀림도 덜한데, 거기까지 맞추는 건 세밀한 성능 튜닝이라 이 글에서는 이름만 걸어둔다.

스택에 이름만 있는 폰트

로딩 이야기를 하다 보면 폰트 스택 자체를 정직하게 짚게 된다. 이 블로그 본문 스택은 얼마 전까지 이랬다.

--font-sans: "Geist", "Pretendard Variable", Pretendard, -apple-system, sans-serif;

라틴 폰트인 Geist가 맨 앞에 있었다. 그런데 이 블로그는 Geist를 어디서도 불러오지 않는다. 앞에서 본 Pretendard처럼 @font-face로 걸어둔 것도, 서버에 파일을 올려둔 것도 없다. 스택에 이름만 적혀 있을 뿐이다. 이름만 있는 폰트는 그 폰트가 기기에 이미 깔린 사람 화면에서만 뜨고, 나머지 대부분에게는 그냥 건너뛰어진다. 즉 거의 모든 방문자는 처음부터 Pretendard로 본문을 읽고 있었는데, 스택 첫 줄만 그 사실과 어긋나 있던 셈이다.

그래서 스택을 실제로 불러오는 폰트에 맞춰 Pretendard 하나로 정리했다.

--font-sans: "Pretendard Variable", Pretendard, -apple-system,
             BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;

화면에 뜨는 결과는 거의 그대로다. 어차피 대부분 Pretendard가 그리고 있었으니까. 달라진 건 선언한 스택이 실제 로딩과 일치한다는 점이다. 코드 폰트로 쓰던 --font-mono도 사정이 같아서, 불러오지도 않는 Geist Mono를 앞에서 빼고 시스템 고정폭 폰트로 시작하게 두었다. 성능 최적화라기보다 정직함의 문제다. 선언한 폰트와 실제로 오가는 폰트가 다르면, 로딩을 따질 때 스택부터 사람을 헷갈리게 한다.

더 짜낼 수 있는 것

여기까지가 이 블로그가 실제로 쓰는 방식이고, 더 밀어붙일 여지는 늘 있다.

하나는 연결을 미리 트는 것이다. CDN에서 폰트를 받으니 그 CDN에 처음 연결하는 데 시간이 든다. preconnect로 그 연결을 브라우저가 폰트를 요청하기 전에 미리 열어두면, 첫 조각이 조금 더 빨리 도착한다. 이 블로그는 아직 거기까지는 안 했다.

다른 하나는 아예 셀프 호스팅(self-hosting)으로 가는 것이다. 폰트를 내 서버에 직접 올리고, 이 사이트에 실제로 쓰이는 글자만 뽑아 직접 서브셋을 만들면, 남의 CDN에 기대지 않고 용량도 페이지에 맞게 더 조인다. 대신 폰트 파일 관리와 갱신을 내가 떠안는다. CDN의 편의와 셀프 호스팅의 통제 사이의 트레이드오프라, 규모와 손이 얼마나 남는지에 따라 고르면 된다.

개인 블로그 수준에서는 굳이 거기까지 안 가도 된다. 동적 서브셋으로 받는 양을 줄이고, 가변 폰트로 굵기 파일을 하나로 접고, swap으로 본문을 먼저 읽히는 것. 이 세 개만 챙겨도 무거운 한글 폰트가 첫 화면에서 발목을 잡지 않는다. 성능은 끝없이 파고들 수 있는 주제지만, 이 셋이 비용 대비 효과가 가장 큰 지점이다.

시리즈를 닫으며

일곱 편을 여기서 닫는다.

값은 6편에서 이미 다 나왔다. 서구 규칙을 한글에 그대로 쓰면 왜 어긋나는지에서 시작해(1편), 서체(2편), 크기·굵기·대비(3편), 줄 길이와 줄끝(4편), 자간·행간·문단 간격(5편)을 하나하나 정하고, 그걸 한 덩어리 CSS 변수 블록으로 조립했다(6편). 그 블록만 있으면 한글 본문 조판은 끝난다.

7편은 그 값이 화면에 가볍게 뜨게 만드는 마감이었다. 한글 폰트는 음절이 많아 무거우니, 서브셋으로 쓰는 글자만 받고, 가변 폰트로 굵기 파일을 하나로 접고, swap으로 본문을 먼저 읽힌다. 그리고 스택은 실제로 불러오는 폰트에 맞춰 정직하게 적는다. 조판 값을 다 정해놓고 로딩에서 무너지면 아무 소용이 없으니, 마지막 한 편을 여기에 썼다.

이 시리즈를 관통한 태도 하나만 남긴다. 값을 외우지 말고 왜 그 값인지를 쥐라는 것이다. 17px도 1.8도 40자도 절대 상수가 아니라 한글 글자 구조에서 유도한 출발선이었고, 실증 상수인지 조판 관습인지를 매번 구분해서 말했다. 성능도 같다. 서브셋과 가변 폰트와 swap이 왜 무게를 더는지 원리를 쥐고 있으면, 폰트가 바뀌고 도구가 바뀌어도 스스로 다시 판단할 수 있다. 그게 이 일곱 편으로 남기고 싶었던 것이다.

덧붙임. 본편은 여기서 닫지만, 5편에서 정한 자간 0과 행간 1.8이 왜 그렇게 갈렸는지 그 뿌리를 속공간 하나로 파고든 심화 한 편을 따로 얹었다. 8편, 속공간과 커닝에서 이어진다.

이 글, 어떠셨어요?