designpaca/packages/skill/references/typography.md
Yun Chan eb3ca69b5d 타이포그래피 스킬 신설 + 폴백 메트릭 보정
'폰트 관련된 것도 스킬이 좀 잘 해줘야 하는데 그렇지 못한 것 같다'는 지적.
점검해보니 size-adjust, ascent-override, unicode-range, OpenType 기능,
FOUT/FOIT, 폰트 라이선스가 스킬 어디에도 없었다. font-display 와 서브셋은
tokens.md 에 한 줄씩뿐이었다.

references/typography.md 신설
- 고르는 절차: 개수를 먼저 정하고, 성격을 브리프에서 끌어내고,
  x-height·대비·웨이트 범위·숫자 형태를 실제로 재서 검증한다
- 가변 폰트: 축, 표준 속성 우선, 가변 축 애니메이션이 왜 게이트 8 위반인지
- 로딩: font-display 값별 선택 기준, preload 조건, unicode-range
- 폴백 메트릭: size-adjust 와 override 3종. 재는 코드 포함
- OpenType: tabular-nums 등. font-variant-* 를 font-feature-settings 보다 먼저
- 한글: 동적 서브셋 원리, 웨이트가 라틴과 다르게 보이는 이유, 스택 순서
- 라이선스: 웹 임베딩 허용 여부가 상업 이용 허용과 다르다는 것

그리고 이 페이지가 그 규칙을 어기고 있었다
- 실측: 맑은 고딕이 Pretendard 보다 13.0% 넓다. 교체 순간 줄바꿈이 다시 잡힌다
- fonts.css 신설. OS 별로 폴백을 따로 정의하고 size-adjust 로 맞췄다
- 실측 차이 13.0% -> 0.0%

preflight.md 에 폰트 검사 6개를 추가했다. 지금까지 하나도 없었다.
2026-08-20 12:42:59 +09:00

13 KiB

typography — 폰트를 고르고 싣는 법

3단계에서 폰트를 정할 때, 4-1에서 적용할 때 읽는다.

타입 스케일과 간격은 tokens.md 가 다룬다. 이 문서는 어떤 글자를 쓰고 그것을 어떻게 화면에 올리는가만 다룬다.

이 문서를 읽는 법

상황 읽을 곳
폰트를 아직 안 골랐다 §1 선택 절차. 대부분 여기서 끝난다
웨이트를 두 개 이상 쓴다 §2 가변 폰트
로딩이 느리거나 글자가 늦게 뜬다 §3 로딩
폰트 로드 전후로 레이아웃이 흔들린다 §4 폴백 메트릭. 가장 자주 빠뜨리는 곳이다
숫자가 흔들리거나 자간이 이상하다 §5 OpenType
한글이 들어간다 §6. 조판 규칙 자체는 antipatterns.md §8
배포 전 §7 라이선스, §8 체크리스트

1. 고르는 절차

1-1. 먼저 개수를 정한다

두 벌이면 충분하다. 디스플레이 하나, 본문 하나. 코드나 수치가 있으면 모노 하나를 더한다.

세 번째 텍스트 폰트를 넣고 싶다면 위계를 웨이트로 못 만들고 있다는 신호다. 가변 폰트 한 벌의 300~700 구간이 서로 다른 폰트 두 벌보다 넓은 표현을 준다.

1-2. 성격을 브리프에서 끌어낸다

폰트를 고르기 전에 한 줄로 답한다: 이 글자는 무엇처럼 보여야 하는가.

브리프가 요구하는 것 글자에서 찾을 성질
정확함, 계기, 데이터 좁은 폭, 낮은 대비, 열린 카운터, 모노와 잘 붙는 것
편집, 읽는 시간, 권위 세리프 또는 고대비 산세, 넉넉한 x-height
도구, 중립, 신뢰 그로테스크. 성격은 웨이트와 여백이 만든다
인상, 기억 디스플레이 전용 서체. 본문에는 절대 쓰지 않는다

1-3. 후보를 실제 값으로 검증한다

이름과 인상으로 고르지 마라. 다음 넷을 본다.

  • x-height 비율 — 소문자 높이 ÷ 대문자 높이. 0.52 미만이면 작은 크기에서 흐려진다
  • 대비 — 획 굵기 차이. 큰 크기에서만 살아나는 폰트가 있다
  • 웨이트 범위 — 필요한 웨이트가 실제로 있는가. 없는 웨이트를 브라우저가 합성하면 뭉개진다
  • 숫자 형태 — 라이닝인가 올드스타일인가, tabular 가 있는가

브라우저에서 재는 법:

// 실제 렌더된 폰트가 무엇인지부터 확인한다. 폴백으로 떨어졌는데 모르는 경우가 많다
document.fonts.check('16px "Pretendard Variable"');
[...document.fonts].map(f => `${f.family} ${f.weight} ${f.status}`);

1-4. 기본값을 피한다

Inter 는 금지가 아니라 기본값 금지다. 다국어 UI나 초고밀도 대시보드처럼 이유가 있으면 쓴다. 이유 없이 쓰면 그건 고른 게 아니다.

같은 이유로 FrauncesInstrument Serif 도 기본 선택에서 뺀다. 생성 도구가 가장 자주 뱉는 두 서체다.

못 대면 기본값이다. "왜 이 폰트인가"에 한 문장으로 답하지 못하면 아직 고른 것이 아니다.


2. 가변 폰트

웨이트를 두 개 이상 쓰면 거의 항상 가변이 유리하다. 정적 폰트 두 벌보다 가변 한 벌이 작고, 중간 웨이트를 공짜로 얻는다.

쓸 곳
wght 굵기 위계. 가장 많이 쓴다
opsz 광학 크기 큰 글자와 작은 글자의 획 대비를 자동 조정
wdth 좁은 컬럼, 긴 제목
slnt / ital 기울기 강조

쓰는 법

/* 표준 속성을 쓴다. font-variation-settings 는 마지막 수단이다 */
h1 { font-weight: 640; }          /* 100 단위가 아니어도 된다 */
.lead { font-weight: 450; }

/* opsz 가 있으면 자동 적용된다. 끄지 마라 */
h1 { font-optical-sizing: auto; }

/* 표준 속성으로 못 여는 축만 이걸로 */
.wide { font-variation-settings: "wdth" 112; }

font-variation-settingswght 를 지정하지 마라. 상속이 끊기고 font-weight 와 충돌한다.

애니메이션

가변 축은 애니메이션할 수 있지만 레이아웃을 다시 계산한다. 하드 게이트 8이 금지하는 종류다. 글자 굵기를 움직이고 싶으면 opacity 로 두 겹을 교차시켜라.


3. 로딩

3-1. font-display 를 고른다

동작 언제
swap 즉시 폴백, 로드되면 교체 본문 기본값. 글자가 먼저 읽힌다
optional 아주 짧게 기다리고, 늦으면 이번 방문은 폴백으로 끝 CLS 를 0으로 만들고 싶을 때. 첫 방문에 브랜드 서체가 안 보일 수 있다
fallback 짧은 블록 + 짧은 교체 창 절충안
block 최대 3초 블록 아이콘 폰트 외에는 쓰지 마라

3-2. preload 는 조건부다

첫 화면에 실제로 그려지는 폰트 파일만 preload 한다. 그 외에는 다른 자원의 대역을 뺏을 뿐이다.

<!-- 본문 한 벌만. crossorigin 을 빠뜨리면 두 번 받는다 -->
<link rel="preload" as="font" type="font/woff2" crossorigin
      href="/fonts/body-var.woff2" />

모노는 보통 코드 블록에만 쓰이므로 preload 하지 않는다.

3-3. 서브셋

라틴은 unicode-range 로 쪼갠다. 브라우저가 실제로 쓰인 범위만 받는다.

@font-face {
  font-family: "Body";
  src: url("/fonts/body-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+2000-206F, U+2190-21BB;
  font-display: swap;
}

한글은 §6 을 보라. 범위가 아니라 글자 단위로 쪼개야 한다.

3-4. 셀프 호스팅 vs CDN

장점 대가
셀프 호스팅 요청 도메인 하나, 캐시 통제, 서드파티 장애 무관 업데이트를 직접 한다
CDN 설치가 한 줄 도메인이 늘고(각각 DNS+TLS), 그 서비스가 죽으면 글자가 죽는다

CDN 을 쓴다면 preconnect 를 반드시 건다. 셋 이상의 폰트 도메인은 그 자체가 결함이다.


4. 폴백 메트릭 — 가장 자주 빠뜨리는 곳

폰트가 늦게 오면 브라우저는 폴백으로 먼저 그리고 나중에 교체한다. 두 폰트의 행 높이와 글자 폭이 다르면 그 순간 레이아웃이 튄다. 이것이 CLS 의 흔한 원인이고, font-display: swap 을 쓰는 한 반드시 일어난다.

해결은 폴백 폰트를 실제 폰트의 치수에 맞추는 것이다.

/* 실제 폰트 */
@font-face {
  font-family: "Body";
  src: url("/fonts/body-var.woff2") format("woff2-variations");
  font-weight: 300 800;
  font-display: swap;
}

/* 같은 치수로 보정한 폴백. 이름을 따로 준다 */
@font-face {
  font-family: "Body Fallback";
  src: local("Arial");
  size-adjust: 107%;          /* 글자 크기를 맞춘다 */
  ascent-override: 92%;       /* 행 위 여백 */
  descent-override: 24%;      /* 행 아래 여백 */
  line-gap-override: 0%;
}

body {
  font-family: "Body", "Body Fallback", sans-serif;
}

숫자는 폰트마다 다르다. 재는 방법:

// 두 폰트로 같은 문자열을 그려 폭을 비교한다. 비율이 size-adjust 값이다
const measure = (family) => {
  const el = document.createElement("span");
  el.style.cssText = `position:absolute;visibility:hidden;font:100px ${family};white-space:nowrap`;
  el.textContent = "가나다ABCabc0123";
  document.body.appendChild(el);
  const w = el.getBoundingClientRect().width;
  el.remove();
  return w;
};
(measure("Arial") / measure('"Pretendard Variable"') * 100).toFixed(1) + "%";

ascent-override / descent-override 는 실제 폰트의 hhea 또는 OS/2 값을 unitsPerEm 으로 나눈 백분율이다. 도구가 없으면 행 높이가 눈에 띄게 튀지 않을 때까지 조정하고 그 값을 design.md 에 적어라.

font-display: optional 을 쓰면 이 문제가 사라진다. 대신 첫 방문자가 브랜드 서체를 못 볼 수 있다. 둘 중 하나는 골라야 한다. 아무것도 안 하는 것이 최악이다.


5. OpenType 기능

숫자

/* 표에 들어가는 수치. 자리가 흔들리지 않는다 */
.nums { font-variant-numeric: tabular-nums; }

/* 본문 속 숫자. 소문자 높이에 맞는다 */
.prose { font-variant-numeric: oldstyle-nums; }

/* 분수, 서수 */
.frac { font-variant-numeric: diagonal-fractions ordinal; }

수치를 나열하는 곳에 tabular-nums 가 없으면 숫자가 춤춘다. 대시보드·가격표·측정값 전부 해당한다.

그 외

/* 커닝과 리가처는 기본으로 켜져 있다. 끄지 마라 */
body { font-kerning: normal; }

/* 대문자 사이 여백 보정. 전부 대문자인 짧은 라벨에만 */
.caps { font-variant-caps: all-small-caps; letter-spacing: 0.06em; }

/* 폰트가 제공하는 대체 글자. 무엇이 있는지 확인하고 쓴다 */
.alt { font-feature-settings: "ss01" 1; }

font-variant-* 표준 속성이 있으면 그걸 쓴다. font-feature-settings 는 표준 속성이 없는 기능에만 쓴다 — 한 번 쓰면 다른 기본 기능이 꺼지기 때문이다.


6. 한글

조판 규칙(줄바꿈·행간·자간·한 줄 길이)은 antipatterns.md §8 이 정본이다. 여기서는 폰트 자체만 다룬다.

6-1. 서브셋은 선택이 아니다

한글 완성형은 11,172자다. 전체 폰트는 수 MB 다. 반드시 쪼갠다.

쪼개는 단위가 라틴과 다르다. 라틴은 unicode-range 로 블록을 나누면 되지만, 한글은 자주 쓰는 글자가 블록에 흩어져 있다. 그래서 글자 빈도로 나눈 수십 개 조각을 만들고 각각에 unicode-range 를 건다. 브라우저가 페이지에 실제로 쓰인 조각만 받는다.

이 방식을 동적 서브셋이라 부른다. Pretendard 의 dynamic-subset 빌드가 그것이고, 파일이 수십 개로 보이는 이유다.

파일 개수로 폰트 예산을 세지 마라. 한글 웹폰트가 파일 40개인 것은 정상이고 올바른 최적화다. 세야 할 것은 패밀리 수와 실제 전송량이다(tokens.md §5).

6-2. 웨이트가 라틴과 다르게 보인다

같은 700 이라도 한글은 획이 많아 더 무겁게 보인다. 라틴 기준으로 고른 웨이트를 한글에 그대로 쓰면 본문이 답답해진다. 한글 본문은 400500, 제목은 600700 정도에서 시작해 눈으로 맞춘다.

한글 폰트에는 x-height 개념이 없다. 대신 글자틀 대비 속공간을 본다. 속공간이 좁은 서체는 작은 크기에서 막힌다.

6-3. 라틴과 섞을 때 순서

/* 라틴 폰트를 앞에, 한글 폰트를 뒤에 둔다.
   순서를 바꾸면 숫자와 영문까지 한글 폰트로 그려져 조악해진다 */
font-family: "Switzer", "Pretendard Variable", system-ui, sans-serif;

두 폰트의 크기감이 다르면 size-adjust 를 쓴 별도 @font-face 로 맞춘다. §4 와 같은 기법이다.

6-4. 한글 폰트를 지정하지 않는 것

그 자체가 완성도 미달 신호다. 지정하지 않으면 맑은 고딕이나 애플 SD 산돌고딕으로 떨어지고, 그 둘은 서로 크게 달라서 화면마다 다른 디자인이 된다.


7. 라이선스

배포 전에 확인한다. 폰트 라이선스 위반은 조용히 있다가 청구서로 온다.

확인할 것
웹폰트 임베딩이 허용되는가 데스크톱 설치는 되는데 웹 임베딩은 금지인 폰트가 많다
페이지뷰 상한이 있는가 상업 폰트에 흔하다
도메인이 묶여 있는가 스테이징·프리뷰 도메인이 빠질 수 있다
수정·서브셋이 허용되는가 서브셋도 파생물이다
크레딧이 필요한가 CC-BY 계열

한국 무료 폰트의 함정: "상업적 이용 무료"가 곧 "웹폰트 임베딩 무료"는 아니다. 눈누(noonnu.cc)는 허용 범위를 표로 보여주니 웹폰트 항목을 직접 확인해라. SIL OFL 은 임베딩·수정·재배포가 모두 허용되지만 폰트 자체를 판매할 수 없고, 개명 조항이 있는 경우가 있다.

확인 결과를 design.md 에 한 줄로 남긴다: 본문 Pretendard(SIL OFL, 웹 임베딩 가능) / 디스플레이 ___.


8. 배포 전 체크리스트

  • 폰트 패밀리 수가 예산 안이다(tokens.md §5). 텍스트 두 벌 + 모노 하나가 상한
  • "왜 이 폰트인가"에 한 문장으로 답할 수 있다
  • 웨이트를 두 개 이상 쓰는데 정적 폰트를 여러 벌 받고 있지 않다
  • 없는 웨이트를 브라우저가 합성하고 있지 않다(font-synthesis-weight: none 으로 확인)
  • font-display 를 의식적으로 골랐다
  • preload 한 파일이 첫 화면에 실제로 쓰인다. crossorigin 이 있다
  • 폴백 메트릭을 보정했거나, optional 을 골랐다. 둘 중 하나는 했다
  • 수치를 나열하는 곳에 tabular-nums 가 있다
  • 한글이 있으면 서브셋을 쓴다. 파일 개수가 아니라 전송량으로 쟀다
  • 라틴 폰트가 폴백 스택 앞에 있다
  • 한글 폰트를 명시했다
  • 웹폰트 임베딩 라이선스를 확인하고 design.md 에 적었다
  • 폰트를 못 받은 상태로 페이지를 열어봤다. 그 상태로도 읽힌다

근거: research/references/03-trends-2026.md §7, 04-ai-slop-signatures.md §8 (조사일 2026-08-20)