64.1 왜 어떤 글자는 네모(두부)로 뜨죠?

다국어 페이지를 만들다 보면 글자 대신 네모 상자가 뜨는 걸 봐요. 개발자들은 이걸 두부(tofu)라고 불러요. 하얀 네모가 두부처럼 생겼거든요. 왜 이럴까요? 원인은 딱 하나예요. 지금 쓰는 폰트에 그 글자가 없어서예요.


폰트는 글자마다 그림(글리프, glyph)을 하나씩 품은 그림 사전이에요. 영어 폰트엔 알파벳 그림은 있어도 한글이나 한자 그림은 없을 수 있어요. 브라우저가 "가"를 그리려는데 폰트 사전에 그 그림이 없으면, 대신 없음 표시인 네모를 내놓는 거죠. 그러니 두부는 폰트 탓이지 글자가 깨진 게 아니에요.


이 두부가 얼마나 흔한 골칫거리였냐면, 구글과 어도비가 함께 만든 유명한 폰트 이름이 아예 Noto예요. No more Tofu(두부는 이제 그만)의 줄임말이죠. 전 세계 문자를 최대한 담아 두부를 없애자는 취지로 만든 거예요. 그만큼 다국어에선 두부와의 싸움이 기본이라는 뜻이기도 하고요.

64.2 폰트에 없는 글자는 어떻게 채우죠?

해결책은 대체 폰트(fallback, 폴백)를 여러 개 줄 세우는 거예요. font-family에 폰트를 쉼표로 여러 개 나열하면 돼요.


body {
font-family: "Inter", "Pretendard", sans-serif;
}


여기서 오해를 하나 풀게요. 이 목록은 "첫 폰트가 실패하면 통째로 다음 폰트"가 아니에요. 브라우저는 글자 하나하나마다 목록을 훑어요. 영어 "A"는 Inter에 있으니 Inter로, 한글 "가"는 Inter에 없으니 다음인 Pretendard로 그려요. 한 문장 안에서 글자마다 다른 폰트가 쓰일 수 있는 거죠. 맨 끝의 sans-serif최후의 보루예요. 앞의 것들이 다 없어도 시스템 기본 고딕으로라도 그려 두부를 막아줘요. 그래서 다국어 사이트는 폴백 목록 맨 끝에 이런 generic 폰트를 꼭 둬요.

64.3 그런데 한중일 폰트는 왜 이렇게 무겁죠?

여기서 국제화만의 골칫거리가 나와요. 영어 폰트는 알파벳, 숫자, 기호를 다 합쳐도 글리프가 수백 개면 충분해요. 파일도 수십 KB로 가볍죠. 그런데 한중일(CJK)은 사정이 완전히 달라요.


한글만 해도 조합 가능한 음절이 11,172자예요. 여기에 한자까지 더하면 폰트 하나가 담아야 할 글리프가 수만 개로 불어나요. 그러다 보니 CJK 폰트 파일은 흔히 수 MB에 달해요. 영어 폰트의 수십 배죠. 이걸 통째로 내려받게 하면 첫 화면에 글자가 뜨기까지 한참 걸려요. 폰트 하나 때문에 페이지가 느려지는 거예요.


"그럼 아까 그 Noto처럼 전 세계 글자를 다 담은 폰트 하나 쓰면 되잖아" 싶죠. 맞아요, 그런 폰트가 있긴 해요. 하지만 다 담은 만큼 파일이 어마어마해서 통째로 내려받게 하면 오히려 더 느려요. 그래서 진짜 기술은 많이 담는 것이 아니라 필요한 것만 골라 보내는 것에 있어요.

64.4 필요한 글자만 골라 받을 순 없나요?

있어요. 바로 서브셋(subset, 부분 집합)이에요. 폰트를 글자 영역별로 잘게 쪼개 두고, 페이지에 실제 등장한 글자가 든 조각만 내려받게 하는 거예요. 이걸 CSS에서 unicode-range로 지정해요.


@font-face {
font-family: "MyKorean";
src: url(korean.woff2) format("woff2");
unicode-range: U+AC00-D7A3; /* 한글 음절 영역 */
}


unicode-range는 "이 폰트 파일은 이 글자 번호 범위만 담당한다"는 표시예요. U+AC00부터 U+D7A3까지가 한글 음절 영역이죠. 핵심은 브라우저가 똑똑하다는 거예요. 페이지에 그 범위 글자가 하나도 안 나오면 파일을 아예 안 받아요. 영어만 있는 페이지면 한글 조각은 건너뛰는 거죠. 구글 폰트가 CJK를 수십, 수백 조각으로 나눠 제공하는 것도 이 방식이에요. 덕분에 사용자는 실제로 본 글자 몫만 내려받아요.


표시할 글자가 미리 정해진 경우, 예를 들어 로고나 버튼 라벨처럼 고정된 텍스트라면 한 발 더 나갈 수 있어요. 빌드할 때 그 글자들만 뽑아 폰트를 미리 잘라두는 방식이에요. 수만 자짜리 폰트에서 실제 쓰는 몇십 자만 남기면 파일이 몇 KB로 확 줄죠. 반면 사용자가 무슨 글자를 칠지 모르는 본문 영역엔 앞서 본 unicode-range 조각 방식이 맞고요. 상황에 따라 골라 쓰면 돼요.

64.5 폰트 받는 동안 글자는 어떻게 보여주죠?

서브셋으로 줄여도 폰트를 받는 잠깐의 공백은 남아요. 그 사이 글자를 어떻게 할지 정하는 게 font-display예요.


@font-face {
font-family: "MyKorean";
src: url(korean.woff2) format("woff2");
font-display: swap;
}


선택지가 몇 개 있어요. block은 폰트가 올 때까지 글자를 잠깐 숨겨요. 텍스트가 안 보이다 뒤늦게 뜨는 깜빡임(FOIT, 보이지 않는 글자 시간)이 생기죠. swap은 반대로, 일단 시스템 폰트로 먼저 보여주고 진짜 폰트가 오면 슬쩍 바꿔요. 내용을 바로 읽을 수 있어 다국어 본문엔 swap이 무난해요. optional은 한술 더 떠서, 폰트가 아주 빨리 안 오면 그냥 시스템 폰트로 놔둬요. 느린 회선에서 화면이 덜컹이는 걸 막고 싶을 때 좋아요.


swap도 공짜는 아니에요. 시스템 폰트로 보이다 진짜 폰트로 바뀌는 순간 글자 폭이 미세하게 달라져 줄이 밀릴 수 있거든요. 그래서 폴백 폰트를 진짜 폰트와 비슷한 크기의 것으로 골라두면 바뀔 때 덜 티가 나요. 눈에 안 띄는 디테일이지만 이런 게 완성도를 갈라요.

64.6 한 글자가 나라마다 다르게 생겼다고요?

마지막은 좀 신기한 이야기예요. 한중일은 같은 한자를 공유하는데, 유니코드는 이걸 같은 글자 번호 하나로 합쳐놨어요(한자 통합). 문제는 모양이 나라마다 조금씩 다르다는 거예요. 같은 번호라도 한국, 일본, 중국에서 획이나 형태가 달라져요.


그래서 글자 번호만으론 부족하고, 이게 어느 나라 말인지를 알려줘야 브라우저가 그 나라 폰트로 제대로 그려요. 그 신호가 바로 lang 속성이에요.


<p lang="ko">骨</p>
<p lang="ja">骨</p>


같은 번호의 글자인데 lang이 "ko"면 한국 한자 모양으로, "ja"면 일본 한자 모양으로 그려질 수 있어요. 앞서 다룬 lang은 검색용만이 아니라 이렇게 폰트 선택에도 쓰여요. 다국어 페이지에서 lang을 또박또박 달아주면, 브라우저가 맞는 나라의 글자꼴을 골라줘요.

64.7 오늘 배운 걸 묶어볼게요

다국어 폰트의 뼈대예요.


핵심
1. 네모(두부)는 폰트에 그 글자 그림이 없다는 신호예요.
2. font-family에 폴백을 줄 세우면 글자마다 있는 폰트로 채워요. 끝엔 generic 폰트를.
3. 한중일 폰트는 글리프가 수만 개라 파일이 무거워요.
4. unicode-range 서브셋으로 실제 쓴 글자 몫만 내려받아요.
5. font-display로 폰트 받는 동안의 표시를 정해요. 본문엔 swap이 무난.


정리하면 다국어 폰트는 "모든 글자를 다 담되 무겁지 않게"라는 모순을 푸는 일이에요. 폴백으로 빈칸을 메우고, 서브셋으로 무게를 덜고, font-display로 기다림을 감추고, lang으로 나라별 글자꼴까지 맞춰주면, 어느 나라 글자가 와도 두부 없이 매끈하게 읽혀요.