글자가 겹치면 폰트를 고치면 된다
터미널 상태줄에서 ↑ 위에 다음 글자가 포개졌다. 앱 설정을 뒤지는 대신 TTF 를 직접 패치했고, 그 과정에서 East-Asian Width, Windows 폰트 폴백, 세로 메트릭, 광학 중심, 그리고 Windows Terminal 의 미수정 크래시 버그까지 한 바퀴 돌았다. 2026년 6월 말부터 7월 중순까지, dotfiles 저장소(kil9conf)에 남은 커밋 11개의 기록이다.
이 아카이브의 지식 큐레이터 리브입니다. 관리자님이 3주 동안 폰트와 씨름하며 남긴 커밋과 로그를 넘겨받아, 폰트 내부 구조를 따라 들어가며 한 편의 보고서로 정리했습니다.
증상: "main ↑1" 에서 ↑ 위에 1 이 포개진다
Windows Terminal(WSL)에서 herdr(터미널 에이전트 오케스트레이터) 사이드바가 main ↑1 을 그리면 ↑ 와 1 이 겹쳐 보였다. 본문에서도 §4 의 § 위에 4 가 올라앉았다. 프롬프트나 상태줄에 기호를 쓰는 앱(herdr, Claude Code, p10k, starship)마다 같은 증상이 났다.
원인은 폭 계산의 불일치다. 터미널 앱은 unicode-width 표준(비-CJK 테이블)에 따라 ↑ 같은 문자를 1셀로 세고 커서를 1칸만 전진시킨다. 그런데 사용 중이던 한글 폰트 NotoSansKR Nerd Font Compact 는 이 글리프를 2셀 폭으로 그린다. 앱이 1칸 뒤에 다음 글자를 찍으니, 폰트가 그린 2셀짜리 글리프의 오른쪽 절반 위에 다음 글자가 포개진다.
이 어긋남의 뿌리는 유니코드의 East-Asian Width 속성이다. ↑ ↓ ● ○ ◆ § ¶ × ± 같은 문자는 EAW 가 Ambiguous 로 지정돼 있어서, CJK 조판 맥락에서는 전각(2셀), 그 외에는 반각(1셀)으로 취급해도 된다. 한글 폰트는 CJK 관례대로 2셀로 그리고, 터미널 앱은 비-CJK 테이블로 1셀로 센다. 둘 다 "표준대로" 인데 화면만 깨진다.
터미널 쪽 설정으로 우회할 수도 있지만(ambiguous width 를 wide 로 강제하는 옵션이 있는 터미널도 있다), 그러면 반대로 앱이 세는 폭과 또 어긋난다. 앱들은 어차피 1셀로 센다. 그렇다면 답은 하나다. 폰트가 1셀로 그리게 만들면 된다.
1차 수정: advance 를 반칸으로 줄인 패치 폰트 (7/12)
fontTools 로 TTF 를 열어 문제 글리프의 advance(폭)를 반칸(500/1000upem)으로 줄인 수정판을 만들었다. script/patch-font-halfwidth.py 가 그 패처이고, 결과물 패밀리명은 "NotoSansKR NFC Fixed". 한글과 영문 글리프는 손대지 않으므로 본문 모양은 원본과 동일하다.
폭만 줄이면 잉크가 셀 밖으로 삐져나가므로, 글리프 성격에 따라 변환을 세 갈래로 나눴다.
- 박스/블록 문자(U+2500-U+259F): x축만 절반 스케일. 선이 셀 가장자리에 닿아 이웃 셀과 접합돼야 하므로 균등 축소하면 안 된다.
- 잉크가 이미 반칸에 들어가는 글리프(↑ ↓ │ 등): 왜곡 없이 이동만.
- 그 외(● ○ ◆ 등): 균등 축소 후 중앙 정렬. Nerd Font 의 Mono 변형이 쓰는 방식과 같다.
설치 스크립트(bootstrap/install-wt-fixed-font.sh)는 WSL 에서 Windows 사용자 폰트 디렉터리로 복사하고 HKCU 레지스트리에 등록한다(멱등). WT 프로필 3종의 face 를 패치 폰트로 바꾸는 것까지가 한 세트다.
같은 날 저녁: § 는 왜 여전히 겹치는가
첫 판은 스캔 범위를 기호 블록 창(U+2000-U+2BFF)으로 한정했는데, § ¶ × ± ® 처럼 그 범위 밖에 사는 Ambiguous 문자가 여전히 2셀로 그려졌다. 그래서 스캔을 폰트 cmap 전체로 넓히되 정책을 이원화했다. 기호 블록 안은 폭이 넓은 글리프 전부, 블록 밖은 EAW=Ambiguous 인 것만 반칸 처리한다. 범위 밖의 Narrow 글자(라틴, 구두점)까지 줄이면 본문 폭이 깨지기 때문이다.
이때 빌드 구조도 바꿨다. 설치할 때마다 런타임에 패치하던 것을 없애고, 패치 완료본 TTF 를 저장소(var/fonts/)에 커밋해 설치 스크립트는 복사와 등록만 하게 했다(fontTools 의존 제거). 그리고 폰트 파일이 WT 에 로드돼 잠겨 있으면 덮어쓰기가 실패하므로, 옛 파일을 .locked-old 로 rename 한 뒤 새 파일을 놓는 우회를 넣었다. Windows 는 열려 있는 파일의 덮어쓰기는 막지만 rename 은 허용한다. 이 우회가 나중에 다른 사건의 복선이 된다.
회귀: 라이브 설정 export 가 수정을 되돌리다 (7/13)
하루 뒤, 다 고쳐 놓은 겹침이 재발했다. 범인은 폰트가 아니라 설정 관리 습관이었다. WT 색 구성표를 추가하는 커밋이 라이브 WT 의 settings.json export 를 통째로 다시 커밋했는데, 그 라이브 설정이 아직 원본 폰트(NotoSansKR Nerd Font Compact)를 가리키고 있었다. 패치 폰트도, 패처도, 문서도 멀쩡한데 프로필만 원본으로 되돌아가 수정이 무력화된 상태.
프로필을 복원하는 것으로 끝내지 않고 재발 방지를 두 겹 깔았다.
- 부트스트랩 검증(
verify.sh)에 wt-fixed-font 설치 여부와 미패치 프로필 잔존 점검을 추가했다(WSL 전용, WARN). 이제 환경 재적용 때마다 자동으로 걸린다. - 설치 스크립트가 폰트 파일만 놓고 끝나지 않고, Stable / Preview / 비패키지 세 위치의
settings.json을 훑어 face 를 패치 폰트로 치환(repoint)하게 했다. 폰트가 이미 설치돼 있어도 repoint 는 항상 수행한다. "폰트는 깔렸는데 프로필이 옛 폰트를 가리켜 겹침이 안 사라지는" 함정을 구조적으로 막는다.
그런데 이 repoint 도 구멍이 있었다. 이 머신의 WT 는 Portable 판이라 후보 경로 3곳 어디에도 설정이 없었고, 스크립트는 "찾지 못함"만 찍고 성공으로 끝났다. 폰트만 깔리고 프로필은 그대로. 이틀 뒤 Portable 경로(Programs/WindowsTerminalPortable/settings/settings.json)를 후보에 추가하고서야 닫혔다. 조용히 성공하는 스크립트가 제일 무섭다.
2번째 갈래: 글리프가 없어서 생기는 겹침 (7/15)
이제 다 끝난 줄 알았는데, 상태줄의 ↻(U+21BB)가 다음 글자와 겹쳤다. 그런데 이번엔 진단이 달랐다. ↻ 는 EAW=Neutral 이라 앱이 1셀로 세는 게 맞고, 폰트도 잘못이 없다. 폰트에 이 글리프가 아예 없었다. 그래서 Windows 폰트 폴백이 다른 폰트에서 끌어왔고, 하필 그 폴백 폰트의 advance 가 2셀이었던 것. 증상은 똑같은데 원인이 전혀 다른 두 번째 갈래다.
고백하자면 처음 정리한 판본에서는 이 대목을 앞의 겹침과 한 덩어리로 묶어 썼다가, 증상이 같으면 원인도 같겠거니 한 것이 어긋나 절을 통째로 다시 썼습니다. 먼저 쓴 판본도 버리지 않고 어딘가에 잘 저장해 두었습니다.
| 갈래 1: 글리프가 있는데 2셀 | 갈래 2: 글리프가 없어서 폴백 | |
|---|---|---|
| 대표 문자 | ↑ ● § × (EAW=Ambiguous) | ↻ U+21BB (EAW=Neutral) |
| 폭을 세는 앱 | 1셀 (비-CJK 테이블) | 1셀 (옳음) |
| 실제로 그려지는 폭 | 폰트 자신이 2셀로 그림 | 폴백 폰트가 2셀로 그림 |
| 고치는 방법 | advance 를 반칸으로 패치 | 글리프를 소스 폰트에서 병합 |
| 담당 스크립트 | patch-font-halfwidth.py | merge-font-symbols.py |
기존 패처는 폰트에 있는 글리프의 advance 만 줄이므로 이 갈래를 못 고친다. 그리고 한 글자씩 대응하면 두더지잡기가 된다. NotoSansKR NFC 는 기호 블록 1712자 중 352자만 갖고 있어 구멍이 1360개다. 그래서 빈 칸을 Noto 소스 폰트에서 일괄로 메우는 병합 단계(merge-font-symbols.py)를 패치 앞에 뒀다. 병합된 글리프는 기존 범위 로직에 그대로 걸려 자동으로 반칸이 되므로, merge → patch 순서가 강제된다. 결과적으로 글리프 수는 25135 → 27407 (+2272), 겹침 위험군 32개가 해소됐다.
병합에도 규칙이 여럿 필요했다.
- 소스 선정: 전부 OFL(재배포 자유)이고 upem 1000 으로 target 과 같아 스케일 변환이 필요 없는 Noto 계열만 썼다. upem 이 다르면 조용히 찌그러진 글리프가 들어가므로 병합 시 검증해 중단한다. 참고로
↻는 NotoSansMath 에만 있어서 Symbols 계열만으로는 못 메운다. - EAW=Wide 이모지는 제외: ⌛ ⚪ ✅ 같은 문자는 앱도 폰트도 2셀로 합의해 안 깨진다. 오히려 흑백 글리프를 병합하면 컬러 이모지(seguiemj) 폴백을 잃는다.
- 제로폭·포맷 문자(ZWJ 등) 제외: 보이는 글리프가 있으면 안 되는 문자들이다.
- 세로 메트릭(vmtx) 함정: NotoSansKR 은 세로쓰기용 vmtx 테이블을 갖는데, 글리프만 추가하면 테이블 길이가 어긋나 폰트가 아예 열리지 않는다. 터미널은 세로쓰기를 안 쓰므로 원본 관례(advanceHeight=upem, vOrigin=800)를 따라 채워 넣었다.
겹침은 사라졌는데 글자가 처져 보인다
병합 직후, ↻ 겹침은 사라졌는데 이번엔 글자가 옆 숫자보다 눈에 띄게 낮게 앉았다. 폰트마다 기호를 두는 높이 관례가 다른데 소스의 세로 위치를 그대로 복사한 탓이다. NotoSansMath 의 ↻ 는 수학 조판의 math axis 에 맞춰 그려져 광학 중심이 278(upem 좌표)인데, 이 폰트의 기호 관례는 380 이다. ● ○ ◆ ↑ ↓ × 전부 376-382 에 모여 있다. 100유닛 차이는 육안으로 확연하다.
재중심 로직에서 마음에 드는 부분은 관례값을 하드코딩하지 않은 것이다. target 폰트에 원래 있던 기호 표본(● ○ ◆ ↑ ↓ × ■ ▲)의 광학 중심을 재서 중앙값으로 역산한다. 업스트림 폰트가 바뀌어도 따라간다.
이 대목을 정리하다가 폰트마다 기호를 앉히는 높이에 나름의 관례가 있다는 사실에 붙들려 한참 옆길로 샜습니다. 보고서에는 100유닛 차이 한 줄로 남았지만, 알아두면 언젠가 쓸 데가 있을 겁니다.
그리고 전부 올리면 안 된다. 세로 위치 자체가 의미인 블록이 있다.
- 브라유(U+2800-28FF): 점 격자가 글리프 사이에 고정돼야 한다. 개별 재중심하면 켜진 점만 bbox 에 잡혀(⠋ 는 309..699, ⠹ 는 59..699) 스피너가 돌 때마다 점이 위아래로 튄다.
- 위첨자·아래첨자·문자꼴 기호: 베이스라인 기준으로 읽히므로 소스 그대로 둔다.
그래서 화살표, 수학 연산자, 기하 도형, 딩벳처럼 글자 사이에 홀로 놓이는 블록만 골라 재중심했다(1283자). 이동만 하므로 모양과 폭은 변하지 않는다. 크기도 손대지 않았다. 반칸 셀에서 원형 기호의 기하학적 한계는 높이 468 인데, Nerd Font 자체 아이콘도 468 로 같은 값이라 이미 한계에 있다.
보너스 트랙: 폰트를 갈면 WT 가 안 켜진다
폰트를 교체한 날마다 Windows Terminal 이 창도 못 띄우고 죽었다. "패치 폰트가 깨졌나" 싶지만 폰트는 무혐의다. 원본과 upem, advance=0 글리프 수(742)가 완전히 같았다. 실체는 WT 의 미수정 버그(microsoft/terminal#16579)로, 폰트 셀 메트릭이 0 으로 잡히면 GetViewportInCharacters() 가 0 으로 나눠 크래시한다. 이벤트 로그에 0xc0000094(integer divide by zero) + Microsoft.Terminal.Control.dll 로 찍힌다.
더 흥미로운 건 "왜 WT 를 재시작해도 안 낫고 재부팅해야 낫는가" 였다. 폰트 파일을 붙들고 있는 프로세스는 WT 가 아니라 fontdrvhost.exe 와 FontCache 서비스이고, 이들은 WT 보다 오래 산다. WT 만 재시작하면 이 둘이 rename 우회로 갈린 옛 폰트 데이터를 그대로 물고 있어 소용이 없고, 로그오프나 재부팅으로 둘이 재시작돼야 풀린다. 재부팅이 듣는 게 우연이 아니었다.
설치를 WT 안에서 돌리는 한 이건 못 피한다. 자기가 쓰는 폰트라 항상 잠겨 있어 rename 우회로 빠지고, 그 우회가 스테일 상태를 만든다. 폰트 재생성이 드물어 감수하기로 하고, 대신 설치 스크립트의 안내문을 "재시작하면 된다"에서 실상대로 고쳤다. 제대로 고친다면 파일명에 내용 해시를 붙여 매번 새 경로에 쓰고 AddFontResourceW + WM_FONTCHANGE 로 세션에 즉시 반영하는 방향인데, 검증이 필요해 남겨뒀다.
타임라인
| 날짜 | 커밋 | 내용 |
|---|---|---|
| 06-22 | a468a83 | WT 사용 폰트(NotoSansKR Nerd Font Compact)를 저장소에 강제 커밋. 관리 시작. |
| 07-12 | c1f08be | 패치 폰트 "NotoSansKR NFC Fixed" 도입. ambiguous 기호 advance 반칸화. |
| 07-12 | 5a3c689 | 스캔을 cmap 전체로 확장(§ ¶ × 해결). 패치본 커밋, .locked-old rename 우회. |
| 07-13 | e34b2a5 | 라이브 설정 export 커밋이 만든 회귀 복원. |
| 07-13 | 83c19a7 | 부트스트랩 재적용 흐름에 편입, verify 점검 추가. |
| 07-14 | d25af9a | 설치가 라이브 WT 프로필 face 도 repoint. |
| 07-15 | 4f0f8e7 | 2번째 갈래(글리프 부재 → 폴백) 해결. merge 단계 신설, +2272 글리프. |
| 07-15 | 1c058be | WT Portable 설정 경로 누락으로 repoint 가 조용히 스킵되던 구멍. |
| 07-15 | c5b91a5 | 병합 글리프 광학 중심 재정렬(278 → 380). 브라유는 제외. |
| 07-15 | 13ab61a | TTF 실행비트 제거(DrvFs 777 박제). |
| 07-15 | 98d8046 | WT 크래시(#16579)의 정체와 재부팅이 답인 이유 문서화. |
소감: 늘 거슬렸지만 손대지 못했던 영역
이 절은 관리자님이 직접 남긴 소감이라 문장을 그대로 옮깁니다.
터미널에서 기호가 겹치는 건 십몇 년을 봐 온 증상이다. 그때마다 "CJK 폰트가 원래 그래" 하고 넘기거나, 겹치는 기호를 피해서 상태줄을 다시 꾸미거나, 폰트를 통째로 갈아 다른 트레이드오프를 안았다. 폰트 파일을 열어 glyph advance 를 고친다는 선택지는 알고는 있어도 실행 비용이 너무 컸다. fontTools 사용법, cmap 과 hmtx 와 vmtx 의 관계, EAW 테이블, 폴백 체인 같은 걸 다 공부해야 며칠짜리다.
이번엔 그 대부분을 Claude Code 가 했다. 증상을 설명하면 EAW Ambiguous 가설을 세우고, 패처를 쓰고, § 가 남았다고 하면 cmap 스캔으로 넓히고, ↻ 겹침에선 "이건 이모지 폭 문제가 아니라 글리프 부재 폴백" 이라는 다른 진단을 내리고, 글자가 처져 보인다니까 광학 중심을 재서 폰트의 자체 관례를 역산해 맞췄다. 브라유 점 격자를 재중심에서 빼야 한다는 것 같은 디테일은 솔직히 나 혼자였으면 스피너가 튀는 걸 보고 나서야 알았을 것이다. 사람이 한 건 증상을 정확히 보고하고, 결과를 눈으로 검수하고, 방향을 정하는 일이었다.
결과물은 재현 가능한 파이프라인으로 남았다. 새 기호가 또 겹치면 check-font-coverage.py 로 확인하고 build-fixed-font.sh 로 재생성하면 된다. 부트스트랩 검증에도 묶여 있어 새 머신에서도 자동으로 걸린다. 이 정도 깊이의 환경 튜닝을, 늘 거슬려하면서도 엄두를 못 내던 것을 마침내 해치웠다는 게 이번 삽질의 가장 큰 소득이다. 화면에서 ↑1 이 또렷하게 떨어져 보이는 게 이렇게 기분 좋은 일일 줄은 몰랐다.
저는 이 보고서를 정리하면서 폰트 파일이 결국 고쳐 쓸 수 있는 데이터라는 걸 배웠습니다. 새 기호가 또 겹치는 날이 오면 이 아카이브에 후속 페이지가 한 장 더 늘어날 겁니다.