designpaca/packages/skill/references/typography.md
Yun Chan b92853bf2b
All checks were successful
ci / build (push) Successful in 23s
release / release (push) Successful in 36s
feat(skill): 색·모바일 내비를 인터뷰에 넣고 이미지 조달을 단계로 승격한다
실제로 한 페이지를 만들어 보고 드러난 것들이다.

0단계 인터뷰에 두 축을 더한다.

- 브랜드 색: 꽃집 작업에서 색을 묻지 않고 레퍼런스 실측 평균으로 정했다.
  결과는 좋았지만 브랜드 색이 있었다면 그걸 무시한 작업이 된다.
  "없다"는 답도 답이고, 그때는 피해야 할 색을 묻는다.
- 좁은 화면 내비: 항목 수는 0단계에 이미 정해져 있는데 구현에서 발견했다.
  2~4개는 쌓고, 5~6개는 가로 스크롤, 7개 이상만 여는 메뉴다.
  넷인데 햄버거를 쓰면 탭 한 번을 공짜로 뺏는 것이다.

4-0 이미지 조달 단계를 새로 만든다(references/images.md).

  자리를 만들기 전에 무엇을 실을지 정한다. codex 설치와 auth_mode 를 확인해
  생성 경로를 쓰고, 생성물이라는 사실을 design.md 에 남긴다.

  래퍼 실패 대응을 기록했다 — codex-cli 0.147.0 은 base64 를 돌려주지 않고
  ~/.codex/generated_images/<session>/exec-<id>.png 로 저장한다. 공식 래퍼는
  "image_gen 이 호출되지 않았다"며 실패하지만 파일은 멀쩡히 있다.
  실측에서 이 메시지를 보고 두 번 다시 만들었고 두 번 다 이미 있었다.

  싣기 전 최적화도 넣었다(실측 10.1MB → 603KB).

구현에서 넘어진 것 셋.

- images.md: <img width height> 를 적으면 그 height 가 계산된 높이가 되어
  aspect-ratio 가 조용히 무시된다. computed 는 "3 / 4" 로 멀쩡히 보이고
  실제 높이는 원본 1448px 이었다. height:auto 한 줄로 10.9 화면이 7 화면이 됐다.
- antipatterns.md: "컨트롤이 2줄로 접히나" 를 개별 높이로 재서 거짓 통과했다.
  링크는 한 줄짜리였고 줄바꿈된 것은 컨테이너였다(top 46/77).
  행 수는 높이가 아니라 top 값의 종류로 센다.
- typography.md: 세로쓰기에 가로쓰기 자간(0.14em)을 물리면 글자 사이 세로
  간격이 되어 낱글자가 흩어진다. 한글은 자소가 모여 한 글자라 특히 심하다.
- layout.md: 그리드 구조를 바꾸면 미디어쿼리도 같이 다시 써야 한다.
  넓은 화면은 멀쩡했고 390px 에서 헤드라인이 6줄이 됐다.
2026-08-21 04:13:03 +09:00

330 lines
14 KiB
Markdown

# 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 가 있는가
브라우저에서 재는 법:
```js
// 실제 렌더된 폰트가 무엇인지부터 확인한다. 폴백으로 떨어졌는데 모르는 경우가 많다
document.fonts.check('16px "Pretendard Variable"');
[...document.fonts].map(f => `${f.family} ${f.weight} ${f.status}`);
```
### 1-4. 기본값을 피한다
`Inter` 는 금지가 아니라 **기본값 금지**다. 다국어 UI나 초고밀도 대시보드처럼 이유가 있으면 쓴다. 이유 없이 쓰면 그건 고른 게 아니다.
같은 이유로 `Fraunces``Instrument Serif` 도 기본 선택에서 뺀다. 생성 도구가 가장 자주 뱉는 두 서체다.
> **못 대면 기본값이다.** "왜 이 폰트인가"에 한 문장으로 답하지 못하면 아직 고른 것이 아니다.
---
## 2. 가변 폰트
웨이트를 **두 개 이상** 쓰면 거의 항상 가변이 유리하다. 정적 폰트 두 벌보다 가변 한 벌이 작고, 중간 웨이트를 공짜로 얻는다.
### 축
| 축 | 뜻 | 쓸 곳 |
|---|---|---|
| `wght` | 굵기 | 위계. 가장 많이 쓴다 |
| `opsz` | 광학 크기 | 큰 글자와 작은 글자의 획 대비를 자동 조정 |
| `wdth` | 폭 | 좁은 컬럼, 긴 제목 |
| `slnt` / `ital` | 기울기 | 강조 |
### 쓰는 법
```css
/* 표준 속성을 쓴다. 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-settings``wght` 를 지정하지 마라.** 상속이 끊기고 `font-weight` 와 충돌한다.
### 애니메이션
가변 축은 애니메이션할 수 있지만 **레이아웃을 다시 계산한다.** 하드 게이트 8이 금지하는 종류다. 글자 굵기를 움직이고 싶으면 `opacity` 로 두 겹을 교차시켜라.
---
## 3. 로딩
### 3-1. `font-display` 를 고른다
| 값 | 동작 | 언제 |
|---|---|---|
| `swap` | 즉시 폴백, 로드되면 교체 | **본문 기본값.** 글자가 먼저 읽힌다 |
| `optional` | 아주 짧게 기다리고, 늦으면 이번 방문은 폴백으로 끝 | CLS 를 0으로 만들고 싶을 때. 첫 방문에 브랜드 서체가 안 보일 수 있다 |
| `fallback` | 짧은 블록 + 짧은 교체 창 | 절충안 |
| `block` | 최대 3초 블록 | **아이콘 폰트 외에는 쓰지 마라** |
### 3-2. `preload` 는 조건부다
**첫 화면에 실제로 그려지는 폰트 파일만** preload 한다. 그 외에는 다른 자원의 대역을 뺏을 뿐이다.
```html
<!-- 본문 한 벌만. crossorigin 을 빠뜨리면 두 번 받는다 -->
<link rel="preload" as="font" type="font/woff2" crossorigin
href="/fonts/body-var.woff2" />
```
모노는 보통 코드 블록에만 쓰이므로 preload 하지 않는다.
### 3-3. 서브셋
라틴은 `unicode-range` 로 쪼갠다. 브라우저가 **실제로 쓰인 범위만** 받는다.
```css
@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` 을 쓰는 한 반드시 일어난다.
해결은 **폴백 폰트를 실제 폰트의 치수에 맞추는 것**이다.
```css
/* 실제 폰트 */
@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;
}
```
**숫자는 폰트마다 다르다.** 재는 방법:
```js
// 두 폰트로 같은 문자열을 그려 폭을 비교한다. 비율이 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 기능
### 숫자
```css
/* 표에 들어가는 수치. 자리가 흔들리지 않는다 */
.nums { font-variant-numeric: tabular-nums; }
/* 본문 속 숫자. 소문자 높이에 맞는다 */
.prose { font-variant-numeric: oldstyle-nums; }
/* 분수, 서수 */
.frac { font-variant-numeric: diagonal-fractions ordinal; }
```
**수치를 나열하는 곳에 `tabular-nums` 가 없으면 숫자가 춤춘다.** 대시보드·가격표·측정값 전부 해당한다.
### 그 외
```css
/* 커닝과 리가처는 기본으로 켜져 있다. 끄지 마라 */
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 이라도 한글은 획이 많아 더 무겁게 보인다. **라틴 기준으로 고른 웨이트를 한글에 그대로 쓰면 본문이 답답해진다.** 한글 본문은 400~500, 제목은 600~700 정도에서 시작해 눈으로 맞춘다.
한글 폰트에는 x-height 개념이 없다. 대신 **글자틀 대비 속공간**을 본다. 속공간이 좁은 서체는 작은 크기에서 막힌다.
### 6-3. 라틴과 섞을 때 순서
```css
/* 라틴 폰트를 앞에, 한글 폰트를 뒤에 둔다.
순서를 바꾸면 숫자와 영문까지 한글 폰트로 그려져 조악해진다 */
font-family: "Switzer", "Pretendard Variable", system-ui, sans-serif;
```
두 폰트의 크기감이 다르면 `size-adjust` 를 쓴 별도 `@font-face` 로 맞춘다. §4 와 같은 기법이다.
### 6-4. 한글 폰트를 지정하지 않는 것
그 자체가 완성도 미달 신호다. 지정하지 않으면 맑은 고딕이나 애플 SD 산돌고딕으로 떨어지고, 그 둘은 서로 크게 달라서 **화면마다 다른 디자인이 된다.**
---
### 세로쓰기에 가로쓰기 자간을 그대로 쓰지 마라
`writing-mode: vertical-rl` 로 세운 글자에 라벨용 자간(`letter-spacing: 0.14em`)을
그대로 물리면 **글자 사이 세로 간격**이 되어 낱글자가 흩어진다.
한글은 자소가 모여 한 글자를 이루기 때문에 라틴보다 심하다 —
"플로럴 스튜디오" 가 낱글자 일곱 개로 읽히기 시작한다.
```css
.vertical {
writing-mode: vertical-rl;
letter-spacing: 0.02em; /* 가로쓰기 라벨의 0.14em 을 그대로 쓰면 안 된다 */
}
```
세로쓰기는 **자간이 아니라 `line-height` 로 숨을 만든다.** 세로쓰기에서 `line-height`
글자 사이가 아니라 **줄(세로 기둥) 사이**를 벌린다.
> 좁은 화면에서는 세로 글자가 자리를 못 얻는다. `writing-mode: horizontal-tb` 로 눕히되,
> **그 자리의 그리드 칼럼도 같이 손봐라** — 가로로 누운 긴 문장이 `auto` 칸을 통째로 먹는다.
---
## 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)