폰트가 코드를 역어셈블한다고? Z80 Sans가 보여준 OpenType의 광기와 엔지니어링
우리가 매일 사용하는 텍스트 렌더링 스택의 밑바닥에는 생각보다 훨씬 거대하고 복잡한 괴물이 숨어있습니다. 대부분의 엔지니어에게 폰트란 그저 화면에 글자를 예쁘게 그려주는 정적인 리소스에 불과하겠지만, 사실 현대의 폰트 포맷(OpenType)은 거의 튜링 완전(Turing-complete)에 가까운 상태 머신을 내장하고 있습니다.
최근 Hacker News에서 제 시선을 사로잡은 프로젝트가 하나 있습니다. 바로 Z80 Sans라는 폰트입니다. 이 폰트는 단순한 모노스페이스 폰트가 아닙니다. 에디터에 Z80 머신 코드(16진수)를 타이핑하면, 폰트가 실시간으로 이를 해석해 Z80 어셈블리어로 화면에 렌더링합니다. 즉, 폰트 자체가 Disassembler 역할을 하는 셈입니다.
솔직히 처음 이 프로젝트를 봤을 때 헛웃음이 나왔습니다. “도대체 왜 이런 짓을?”이라는 생각이 들었지만, 구현 방식을 뜯어볼수록 이 개발자가 겪었을 엔지니어링적 고뇌와 이를 해결한 우아한 무식함(?)에 경의를 표하게 되었습니다.
폰트로 역어셈블러를 구현하는 원리: GSUB와 GPOS
이 마법의 핵심은 OpenType의 Glyph Substitution Table (GSUB)과 Glyph Positioning Table (GPOS)에 있습니다. 보통 이 기능은 ‘fi’ 같은 글자를 부드럽게 이어주는 Ligature(합자)를 만들거나, 아랍어처럼 문맥에 따라 글자 모양이 바뀌는 언어를 렌더링할 때 사용됩니다.
하지만 Z80 Sans의 제작자는 이 기능을 극한으로 악용했습니다. 16진수 문자열의 특정 시퀀스를 인식하여, 이를 어셈블리 니모닉(Mnemonic) 형태의 글리프로 치환해버린 것입니다.
문제는 Z80 인스트럭션 셋이 생각보다 훨씬 복잡하다는 점입니다.
- 조합의 폭발: Z80의 일부 명령어는 16비트 주소와 레지스터를 피연산자로 가집니다. 이를 모두 계산하면 무려 458,752개의 조합이 나옵니다.
- Out-of-order 피연산자: 이게 가장 골치 아픈 부분입니다. 16진수 바이트 스트림의 순서와 실제 어셈블리어로 표기될 때의 순서가 다릅니다. 폰트 렌더러는 기본적으로 텍스트를 순차적으로 읽어나가는데, 뒤에 올 바이트를 미리 내다보고(Lookahead) 앞의 글리프를 결정해야 하는 상황이 발생합니다.
- 리틀 엔디안과 부호 있는 오프셋: 주소값은 리틀 엔디안으로 들어오기 때문에 화면에 그릴 때는 순서를 뒤집어야 하고, 0x80~0xFF 범위의 오프셋은 2의 보수 음수로 변환해서 렌더링해야 합니다.
우아한 해결책, 그리고 노가다
이 엄청난 경우의 수를 사람이 일일이 매핑할 수는 없습니다. 제작자는 Python과 fonttools를 이용해 폰트의 내부 XML 포맷인 .ttx 파일을 직접 조작하는 스크립트를 작성했습니다. 재귀 하향 파서(Recursive descent parser)를 만들어 모든 가능한 인스트럭션 조합을 전개하고, 이를 폰트의 Contextual Chaining Rule로 변환했습니다.
제가 특히 인상 깊게 본 부분은 65,536 글리프 제한 을 우회한 방법입니다. 모든 완성된 문자열을 통째로 글리프로 만들면 폰트 포맷의 한계를 초과하게 됩니다. 그래서 주소를 렌더링할 때 0~F까지의 각 니블(Nibble)을 개별 글리프로 쪼개고, 간격 제어 문자를 활용해 한 글자씩 순차적으로 치환되도록 설계했습니다. 메모리 최적화를 위해 우리가 흔히 쓰는 청크 단위 쪼개기 기법을 폰트 렌더링에 적용한 셈입니다.
HarfBuzz WASM 시대의 낭만
최근 Hacker News 스레드에서도 언급되었듯, 요즘 폰트 해킹의 트렌드는 WebAssembly를 폰트 안에 집어넣는 것입니다. HarfBuzz 셰이퍼가 WASM을 지원하기 시작하면서, 폰트 안에 Tetris를 넣거나 심지어 LLM을 구동하는 미친 프로젝트들이 등장하고 있죠.
어떤 유저는 “그냥 Rust로 짜서 WASM으로 컴파일해 폰트에 넣었으면 훨씬 쉬웠을 텐데”라고 코멘트했습니다. 기술적으로는 100% 맞는 말입니다. WASM을 쓰면 일반적인 프로그래밍 패러다임으로 이 문제를 풀 수 있습니다. 하지만 제작자는 순수 OpenType GSUB 규칙만으로 이 복잡한 상태 머신을 깎아냈습니다. 엔지니어로서 저는 이 ‘제한된 환경에서의 극한의 최적화’에 더 큰 매력을 느낍니다. 마치 최신 프레임워크를 놔두고 순수 C와 비트 연산만으로 렌더링 엔진을 바닥부터 짜는 것과 같은 낭만이랄까요.
물론 다른 유저의 말처럼 Z80 대신 2바이트 오프코드가 없는 6502나 8051을 타겟으로 했다면 구현이 훨씬 수월했을 것입니다. 하지만 굳이 어려운 Z80을 택해 Out-of-order 파싱 문제를 정면으로 돌파한 점이 이 프로젝트의 가치를 높입니다.
결론: 그래서 이걸 어디에 쓰나요?
당연히 프로덕션 환경에서는 아무 짝에도 쓸모가 없습니다. 오히려 LibreOffice 같은 프로그램에 이 폰트를 적용하고 헥스 코드를 타이핑하다가 렌더링 엔진이 버벅거리는 것을 구경하는 용도에 가깝습니다.
하지만 이 프로젝트가 우리에게 주는 교훈은 명확합니다. 우리가 매일 당연하게 여기는 추상화 레이어(텍스트 렌더링, 폰트 셰이핑) 아래에는 엄청난 연산 능력을 가진 엔진이 잠들어 있다는 사실입니다. 가끔은 이렇게 기발하고 잉여로운 프로젝트들이 우리가 사용하는 기술 스택의 한계를 재발견하게 해줍니다.
이런 ‘Mad Genius’ 같은 해킹을 볼 때마다 아직 소프트웨어 엔지니어링에는 재미있는 구석이 많이 남아있다는 걸 느낍니다. 주말에 시간이 나신다면, Z80 Sans의 gen.py 스크립트가 어떻게 .ttx 파일을 찍어내는지 한번 읽어보시길 권합니다. 우리가 겪는 일상적인 비즈니스 로직의 복잡함이 한결 귀엽게 느껴질 것입니다.
References
- Original Article (GitHub): https://github.com/nevesnunes/z80-sans
- Hacker News Thread: https://news.ycombinator.com/item?id=47256810