한글 웹 타이포그래피 (11) 폴백 폰트가 밀어내는 줄

시리즈 한글 웹 타이포그래피 11 / 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) 폴백 폰트가 밀어내는 줄
한글 웹 타이포그래피 (11) 폴백 폰트가 밀어내는 줄

7편에서 폴백 폰트를 잠깐 짚고 넘어갔다. 웹폰트가 도착하기 전 시스템 폰트로 먼저 글자를 그려 독자를 바로 읽게 하는 font-display: swap을 쓰면, 폰트가 바뀌는 순간 글자 폭이 조금 달라지며 줄이 살짝 밀린다고 했다. 그리고 “폴백의 크기감을 Pretendard와 맞추면 밀림도 덜한데, 거기까지는 세밀한 튜닝이라 이름만 걸어둔다"라고 미뤄 뒀다. 이 편이 그 미뤄둔 숙제다.

그 “살짝 밀림"에는 이름이 있다. 누적 레이아웃 이동(CLS, Cumulative Layout Shift)이다. 구글이 웹 성능 지표로 삼을 만큼, 읽던 자리가 갑자기 튀는 건 체감이 나쁘다. 그리고 이걸 거의 0으로 만드는 CSS 손잡이가 2020년 이후 브라우저에 들어왔다. 이번 편은 그 손잡이 네 개의 계산법을 실제 폰트 숫자로 끝까지 유도하고, 9편에서 해부한 줄상자 메트릭이 여기서 어떻게 다시 쓰이는지, 그리고 한글이 이 계산에서 유리한 지점과 불리한 지점을 함께 짚는다. 난이도 극상.

폰트가 바뀌는 순간 줄이 밀린다

문제의 뿌리는 단순하다. 폴백 폰트와 웹폰트는 서로 다른 폰트라, 같은 글에 같은 크기로 흘려도 한 글자가 차지하는 가로 폭과 한 줄이 차지하는 세로 높이가 다르다.

폴백이 웹폰트보다 글자가 넓으면, 같은 문단이 폴백에서는 네 줄이었다가 웹폰트로 바뀌면 세 줄로 줄어든다. 문단이 한 줄 짧아지면 그 아래 있던 제목이나 이미지가 위로 확 당겨진다. 반대로 폴백이 더 좁으면 아래 요소가 밑으로 밀린다. 세로도 마찬가지다. 폴백의 줄 높이가 크면 문단 전체가 더 높은 상자를 차지했다가, 교체되는 순간 납작해지며 아래가 점프한다. 읽는 사람 눈에는 멀쩡히 읽던 문단이 갑자기 출렁이고 아래 것들이 튀어 오르는 걸로 보인다.

웹폰트 교체와 레이아웃 이동(CLS)메트릭 안 맞춘 폴백다음 요소교체 전 위치점프메트릭 맞춘 폴백다음 요소점프 0
폴백 폰트가 웹폰트보다 크면(왼쪽) 문단이 더 높은 상자를 차지하다가, 교체되는 순간 납작해지며 아래 '다음 요소'가 위로 점프한다(빨강). 폴백 메트릭을 웹폰트에 맞춰 두면(오른쪽) 교체 전후 상자 크기가 같아 아무것도 움직이지 않는다.

핵심은 이거다. 폴백이 웹폰트와 글자 폭줄 높이만 같으면, 폰트가 실제로 바뀌어도 글자 모양만 살짝 달라질 뿐 상자 크기는 그대로다. 상자가 안 변하면 아래 요소도 안 움직인다. 그래서 목표는 폴백 폰트의 겉모습을 웹폰트로 위장시키는 게 아니라, 차지하는 공간(메트릭)만 웹폰트와 똑같이 맞추는 것이다. 모양은 달라도 좋으니 자리만 같으면 된다.

네 개의 손잡이

폴백이 차지하는 공간을 웹폰트에 맞추는 도구가 @font-face 안의 네 디스크립터다. 여기서 중요한 건, 이 네 개는 폴백 폰트를 다시 선언하는 @font-face에 붙는다는 점이다. 시스템 폰트에 local()로 이름을 주고, 그 위에 보정값을 얹어 “메트릭이 조정된 폴백"이라는 새 폰트를 만든다.

@font-face {
  font-family: "Pretendard-fallback";   /* 폴백에 새 이름을 붙인다 */
  src: local("Apple SD Gothic Neo");     /* 실제로는 시스템 한글 폰트 */
  size-adjust: 100%;        /* 글자 폭(advance)을 가로로 늘리고 줄인다 */
  ascent-override: 95%;     /* 베이스라인 위 높이 = 줄 위쪽 */
  descent-override: 24%;    /* 베이스라인 아래 깊이 = 줄 아래쪽 */
  line-gap-override: 0%;    /* 줄과 줄 사이 여분 */
}

body {
  /* 웹폰트 → 보정된 폴백 → 최후 폴백 순서 */
  font-family: "Pretendard Variable", "Pretendard-fallback", sans-serif;
}

네 손잡이가 각각 무엇을 당기는지는 9편에서 해부한 줄상자 메트릭과 정확히 대응한다.

  • size-adjust: 글자 하나가 차지하는 가로 폭(advance). 이걸 키우면 폴백 글자가 옆으로 넓어져 한 줄에 들어가는 글자 수가 웹폰트와 같아진다. 가로 밀림(줄 수 변화)을 잡는 손잡이다.
  • ascent-override: 베이스라인 위로 올라가는 높이. 줄상자의 위쪽 경계다.
  • descent-override: 베이스라인 아래로 내려가는 깊이. 줄상자의 아래쪽 경계다.
  • line-gap-override: 줄과 줄 사이에 더해지는 여분(9편의 line-gap).

ascent·descent·line-gap 셋이 합쳐져 한 줄의 세로 높이를 정하니, 이 셋을 웹폰트 값으로 덮으면 세로 밀림이 잡힌다. size-adjust가 가로를, 나머지 셋이 세로를 맡는 구조다.

폴백 상자를 웹폰트에 포개는 네 값베이스라인웹폰트(목표)폴백(보정 전)size-adjust→ 가로 폭ascent-override→ 윗선(어센트)descent-override→ 아랫선(디센트)line-gap-override→ 줄 사이 여분
폴백(빨강 점선)은 웹폰트(파랑)보다 글자가 넓고 줄상자도 크다. size-adjust가 가로 폭을, ascent·descent·line-gap-override가 위·아래·줄 사이를 웹폰트 선(파랑)으로 끌어당긴다. 모양은 그대로 두고 차지하는 공간만 포갠다.

계산법

이제 값을 어떻게 구하는지다. 네 값 모두 폰트 안에 이미 적힌 숫자에서 나온다. 폰트를 열면 두 종류의 숫자가 있다. 하나는 UPM(units per em), 폰트가 자기 좌표를 재는 격자 한 변의 크기다. 다른 하나는 그 격자로 잰 ascent·descent·line-gap과 각 글자의 advance(글자 폭)다. 오버라이드 값은 이 폰트 단위를 em(=현재 글자 크기)에 대한 비율로 환산한 것이다.

size-adjust부터. 가로 밀림을 없애려면 폴백 글자의 평균 폭이 웹폰트 글자의 평균 폭과 같아야 한다. 그래서 두 폰트의 평균 글자 폭 비율이 그대로 값이 된다.

size-adjust = 웹폰트 평균 글자폭 ÷ 폴백 평균 글자폭

평균 글자폭은 폰트의 모든 글자 advance를 그 언어에서 나오는 빈도로 가중평균한 값이다(도구들은 이걸 xWidthAvg라 부른다). 폴백이 웹폰트보다 좁으면 이 비율이 100%를 넘어 폴백을 옆으로 늘리고, 넓으면 100% 아래로 줄인다.

나머지 셋은 웹폰트의 세로 메트릭을 UPM으로 나눠 비율로 만든 값이다. 단, 앞에서 size-adjust로 폴백 전체를 이미 한 번 확대·축소했으므로, 그 배율만큼 한 번 더 나눠 상쇄해 줘야 최종 높이가 웹폰트와 정확히 맞는다.

ascent-override   = 웹폰트 ascent   ÷ (웹폰트 UPM × size-adjust)
descent-override  = 웹폰트 descent  ÷ (웹폰트 UPM × size-adjust)
line-gap-override = 웹폰트 line-gap ÷ (웹폰트 UPM × size-adjust)

주의할 곳은 세로 셋 모두 웹폰트의 메트릭을 쓴다는 점이다. size-adjust는 두 폰트를 비교해 얻지만, ascent·descent·line-gap은 폴백을 웹폰트로 덮어쓰는 것이라 폴백 자신의 세로값은 계산에 안 들어간다. 폴백의 원래 어센트가 얼마든, 우리는 그걸 웹폰트 값으로 갈아 끼운다.

실제로 계산해 보면

이 블로그가 쓰는 Pretendard Variable을 웹폰트로 놓고 실제 숫자를 넣어 보자. 폰트 파일에서 직접 읽은 값은 이렇다.

Pretendard  UPM      = 2048
            ascent   = 1949   (베이스라인 위)
            descent  =  494   (베이스라인 아래, 크기)
            line-gap =    0

세로 오버라이드 세 개를, 우선 폴백과 웹폰트의 평균 폭이 거의 같아 size-adjust = 100%인 경우로 계산하면(즉 배율 1로 나누면) 이렇게 떨어진다.

ascent-override   = 1949 ÷ 2048 = 95.2%
descent-override  =  494 ÷ 2048 = 24.1%
line-gap-override =    0 ÷ 2048 =  0%

만약 폴백이 웹폰트보다 평균 6% 좁아 size-adjust = 106%가 나왔다면, 세로 셋은 각각 1.06으로 한 번 더 나눈다. ascent-override = 1949 ÷ (2048 × 1.06) = 89.8% 하는 식이다. size-adjust가 커질수록 세로 오버라이드는 작아지는데, 폴백을 이미 통째로 키웠으니 세로는 그만큼 눌러 상쇄하는 것이다.

값 넷을 그대로 @font-face에 적으면, 폴백으로 그리는 동안에도 문단이 웹폰트와 똑같은 크기의 상자를 차지한다. 교체 순간 글자 모양만 Pretendard로 바뀌고 상자는 미동도 없다. CLS가 0에 수렴한다.

말로만 보면 실감이 안 나니 직접 눌러 보자. 아래 두 카드는 방금 계산한 ascent-override: 95.2%·descent-override: 24.1%를 오른쪽 폴백에만 적용한 것이다. 왼쪽은 메트릭을 일부러 어긋내 뒀다.

지금은 폴백 폰트로 그려진 상태(웹폰트가 아직 안 온 척). 버튼을 눌러 Pretendard로 바꿔 보자.

메트릭 안 맞춘 폴백
웹폰트는 늦게 온다.
그동안 폴백이 대신 그린다.
폰트가 바뀌는 순간,
이 아래 블록이 움직일까?
다음 요소
메트릭 맞춘 폴백
웹폰트는 늦게 온다.
그동안 폴백이 대신 그린다.
폰트가 바뀌는 순간,
이 아래 블록이 움직일까?
다음 요소
‘웹폰트 교체’를 눌러 보자. 왼쪽(메트릭 안 맞춘 폴백)은 ‘다음 요소’가 위로 툭 점프하고(레이아웃 이동), 오른쪽(맞춘 폴백)은 줄상자가 이미 웹폰트와 같아 아무것도 움직이지 않는다. 이 데모는 실제 size-adjust·오버라이드가 하는 일 — 폴백의 줄상자를 웹폰트에 맞추는 것 — 을 재현한 것이다.

왼쪽은 폰트가 바뀌는 순간 아래 블록이 위로 튀어 오른다. 오른쪽은 글자 모양만 Pretendard로 바뀔 뿐, 블록은 처음부터 끝까지 같은 자리에 있다. 두 카드의 코드 차이는 폴백 @font-face에 적힌 오버라이드 값 넉 줄뿐이다.

한글이 유리한 지점

여기서 한글 고유의 이점이 하나 나온다. size-adjust의 “평균 글자폭"을 구하는 일이 한글에서는 라틴보다 훨씬 단순하다.

라틴은 글자마다 폭이 제각각이다. Pretendard에서 대문자 A의 advance는 1470, 소문자 x는 1128, i는 훨씬 좁다. 그래서 라틴의 평균 글자폭은 어느 글자가 얼마나 자주 나오는지(영어라면 e·t·a가 흔하다)까지 가중해서 수십 개를 평균 내야 제대로 나온다. 폴백도 같은 방식으로 평균 내야 하니, 이 계산이 도구가 필요한 이유의 절반이다.

한글은 다르다. 3편에서 본 네모틀 때문에, 거의 모든 음절이 똑같은 advance를 갖는다. Pretendard에서 ‘가’, ‘값’, ‘한’, ‘글’의 advance는 전부 1770으로 같다. 음절이 무엇이든 한 칸을 꽉 채우니 폭이 하나로 고정된다. 그래서 한글 본문의 평균 글자폭은 가중평균이랄 것도 없이 그 고정 폭 하나다. size-adjust는 웹폰트 음절폭 ÷ 폴백 음절폭이라는 나눗셈 한 번으로 끝난다.

글자 폭: 라틴은 제각각, 한글은 균일라틴6924862457폭 제각각→ 빈도로 가중평균한글1770177017701770폭 균일→ 나눗셈 한 번
라틴은 글자마다 advance가 달라(위) 빈도까지 가중해 평균을 내야 한다. 한글은 네모틀이라 모든 음절의 advance가 같다(아래, Pretendard 1770 단위). 그래서 size-adjust의 '평균 글자폭'이 한글에서는 음절폭 하나로 정해져, 폭 매칭 계산이 라틴보다 단순하다.

세로도 사정이 비슷하다. 한글 폰트끼리는 네모틀을 꽉 채우는 설계가 비슷해 어센트·디센트의 편차가 라틴 세리프↔산세리프만큼 크지 않다. 그래서 폴백을 잘 고르면 오버라이드 없이도 원래 꽤 근접한다. 한글 네모틀은 조판을 빡빡하게 만들어 앞선 편들에서 내내 골칫거리였지만, 이 계산에서만은 폭이 하나로 고정된다는 그 성질이 이점으로 돌아온다.

한글이 불리한 지점

이점만 있으면 좋겠지만, 실무에서 한글 폴백 매칭을 어렵게 만드는 두 가지가 있다.

첫째는 폴백이 OS마다 다르다는 것이다. 라틴은 Arial이나 Helvetica처럼 거의 어디에나 있는 폴백을 하나 잡고 거기에 맞추면 됐다. 한글 시스템 폰트는 그렇지 않다. 맥·iOS는 Apple SD Gothic Neo, 윈도우는 맑은 고딕, 안드로이드는 Noto Sans CJK KR을 기본으로 쓴다. 세 폰트의 UPM도 advance도 어센트도 제각각이라, size-adjust 하나로 셋을 동시에 맞출 수 없다. 하나에 맞추면 나머지 둘이 어긋난다. 그래서 한글에서 완벽한 매칭은 OS별로 @font-face를 따로 두거나, 어느 한쪽(보통 점유율 높은 쪽)에 맞추고 나머지는 근사로 감수하는 타협이 된다.

둘째는 사파리다. size-adjust는 사파리도 지원한다(사파리 17). 하지만 ascent-override·descent-override·line-gap-override 세 개는 2026년 현재까지도 사파리가 구현하지 않았다(WebKit 이슈 219735가 아직 열려 있다). 크롬·엣지·파이어폭스만 셋을 받는다. 그래서 이 기능은 웹 표준의 공통 기반(Baseline)에 아직 못 든다.

문제는 어정쩡하게 되면 더 나빠질 수 있다는 점이다. 사파리에서 size-adjust로 가로만 늘려 놓고 세로(ascent·descent)는 못 맞추면, 폭은 맞는데 줄 높이가 틀어져 오히려 세로 밀림이 도드라진다. 그래서 세로 오버라이드는 그걸 지원하는 브라우저에서만 켜지도록 @supports로 감싸는 게 안전하다.

/* 세로 오버라이드 3종을 지원하는 브라우저에서만 적용 */
@supports (ascent-override: 90%) {
  @font-face {
    font-family: "Pretendard-fallback";
    src: local("Apple SD Gothic Neo");
    size-adjust: 100%;
    ascent-override: 95.2%;
    descent-override: 24.1%;
    line-gap-override: 0%;
  }
}

사파리는 이 블록을 통째로 건너뛰고 원래 폴백을 그대로 쓴다. 밀림이 남긴 하지만, 폭만 맞추고 높이를 어긋내는 것보다는 낫다. 되는 곳에서 완전히 잡고 안 되는 곳에선 원래대로 두는 점진적 향상 — 10편의 text-spacing-trim과 같은 태도다.

도구에 맡기기

계산 원리는 위에서 다 봤지만, 실무에서 매번 손으로 폰트를 열어 UPM과 advance를 재는 사람은 없다. 이 값을 뽑아 주는 도구가 있다.

  • capsize(@capsizecss/corecreateFontStack)는 웹폰트와 폴백 이름을 주면 위 계산을 대신 해 @font-face 블록과 font-family 스택을 통째로 만들어 준다. 예컨대 Open Sans에 Arial 폴백을 물리면 size-adjust: 105.6416%, ascent-override: 101.1768%, descent-override: 27.7323% 같은 값을 뱉는다. @capsizecss/metrics에는 유명 폰트의 메트릭이 미리 담겨 있다.
  • Fontaine은 빌드 단계에서 웹폰트 선언을 훑어 메트릭이 맞춰진 폴백 @font-face를 자동으로 끼워 넣는다. Nuxt·Astro 같은 프레임워크의 폰트 최적화가 내부적으로 이걸 쓴다.

원리를 쥐고 있으면 이 도구들이 뭘 계산하는지, 왜 사파리에서는 일부만 듣는지, 왜 한글 폴백은 OS별로 갈리는지를 알고 쓸 수 있다. 값 네 개가 어디서 왔는지 모르면 도구가 뱉은 숫자를 그대로 믿는 수밖에 없지만, 유도 과정을 알면 폴백을 바꾸거나 웹폰트를 바꿀 때 그 숫자가 어떻게 달라질지 스스로 판단한다.

정리

7편에서 이름만 걸어 둔 “폴백의 크기감 맞추기"를, 값이 나오는 데까지 밀어붙인 편이었다.

CLS의 원인   : 폴백↔웹폰트의 글자 폭·줄 높이가 달라 교체 순간 상자가 바뀐다
size-adjust  : 웹폰트 평균 글자폭 ÷ 폴백 평균 글자폭 (가로 밀림)
세로 3종     : 웹폰트 메트릭 ÷ (UPM × size-adjust) — ascent·descent·line-gap
Pretendard   : ascent 95.2% · descent 24.1% · line-gap 0% (size-adjust 100% 기준)
한글의 이점  : 네모틀 균일 advance(1770) → 평균 글자폭이 하나로 고정
한글의 난점  : OS별 폴백 제각각 + 사파리는 세로 오버라이드 미지원(@supports로 감싼다)

이 시리즈가 흰 공간을 배열하는 일이라고 내내 말했는데, 이번 편은 그 흰 공간이 폰트가 바뀌는 찰나에도 흔들리지 않게 붙드는 이야기였다. 값이 아무리 정확해도 화면에서 출렁이면 소용이 없다. 폴백이 웹폰트의 자리를 미리 정확히 차지해 두면, 독자는 폰트가 바뀌었다는 것조차 모른 채 처음부터 흔들림 없는 지면을 읽는다. 조판의 마지막 예의는, 바뀌는 순간을 들키지 않는 것이다.

이 글, 어떠셨어요?