텍스트 렌더링을 밑바닥부터 다시 짜봤습니다: First Principles의 미학


엔지니어링을 하다 보면 누구나 한 번쯤 빠지는 착각이 있습니다. “이거 그냥 내가 직접 짜는 게 더 빠르고 가볍지 않을까?”

대부분의 경우 이 생각은 NIH(Not Invented Here) 증후군 으로 끝나며 기술 부채만 남기기 십상입니다. 하지만 가끔은, 정말 아주 가끔은 바퀴를 다시 발명해보는 것이 엔지니어로서의 ‘직관(Intuition)‘을 기르는 데 결정적인 역할을 합니다. 오늘 소개할 글은 바로 그 ‘금단의 영역’인 폰트 렌더링(Font Rendering)을 밑바닥부터(First Principles) 구현한 이야기입니다.

우리가 매일 보는 화면 속 텍스트가 사실은 얼마나 복잡한 수학과 레거시의 산물인지, 그리고 왜 FreeType 같은 라이브러리가 20만 줄이 넘는 코드를 가질 수밖에 없는지 깊이 파헤쳐 보겠습니다.

왜 굳이 폰트 엔진을 직접 만드는가?

이 글의 저자 Brendan McCloskey는 아주 단순한 호기심에서 출발합니다.

“FreeType은 20만 라인이 넘는다는데, 텍스트 렌더링이 진짜 그렇게 어려울까?”

사실 우리는 OS나 브라우저가 제공하는 API 뒤에 숨어 텍스트 렌더링을 공기처럼 당연하게 여깁니다. 하지만 그 이면에는 다음과 같은 골치 아픈 문제들이 숨어 있습니다.

  1. 해상도 독립성: 폰트는 벡터(곡선)지만 화면은 픽셀(그리드)입니다. 어떻게 깨짐 없이 임의의 크기로 렌더링할 것인가?
  2. Anti-aliasing: 곡선을 직선 그리드에 구겨 넣을 때 발생하는 계단 현상을 어떻게 처리할 것인가?
  3. 레이아웃: 영어와 아랍어의 렌더링 규칙은 완전히 다릅니다. 커닝(Kerning)은 또 어떻고요.

저자는 C++로 직접 TTF(TrueType Font) 파서와 렌더러 를 구현하며 이 질문들에 정면으로 부딪칩니다. 개인적으로 이런 ‘Recreational Programming(취미 코딩)‘을 굉장히 높게 평가합니다. 추상화된 레이어 아래를 들여다본 경험은 나중에 성능 최적화나 트러블슈팅 상황에서 엄청난 차이를 만들어내기 때문입니다.

TTF 파일: 베지어 곡선의 미학

TTF 파일 포맷은 생각보다 직관적입니다. 핵심은 glyf(글리프 형상 데이터), loca(위치 인덱스), cmap(유니코드-글리프 매핑) 테이블입니다. 여기서 흥미로운 기술적 포인트는 곡선을 표현하는 방식 입니다.

TTF는 2차 베지어 곡선(Quadratic Bezier Curves)을 사용합니다. 시작점, 제어점(Control Point), 끝점. 이 세 점을 이용해 곡선을 그립니다. 그런데 TTF 스펙에는 재미있는 압축 기법이 숨어 있습니다.

  • 데이터 스트림: 점(On-Curve) -> 점(Off-Curve) -> 점(On-Curve) 순서가 일반적입니다.
  • 압축: 하지만 종종 Off-Curve -> Off-Curve 같은 상황이 발생합니다. 이 경우, 두 제어점의 중간 지점(Midpoint) 에 암시적인 On-Curve 점이 있다고 가정합니다.

저자는 이 데이터를 파싱하여 GlyphCurve 구조체로 변환하고, 이를 래스터라이즈할 준비를 마칩니다. 이 과정에서 ‘구멍 뚫린 도형’(예: 알파벳 B의 내부 구멍)을 처리하기 위해 도형의 방향(Clockwise vs Counter-Clockwise)을 이용하는 부분은 그래픽스 기초의 정석을 보여줍니다.

비트맵 래스터라이제이션의 한계

가장 먼저 시도한 방식은 전통적인 스캔라인(Scanline) 방식입니다. 픽셀의 중심이 도형 안에 있는지 밖에 있는지를 판단하는 것이죠.

  1. 타겟 비트맵의 각 Y축(Row)에 대해,
  2. 글리프의 윤곽선과 교차하는 X좌표들을 구하고,
  3. Winding Rule 을 적용해 펜이 칠해지는 구간(In)과 아닌 구간(Out)을 판별합니다.

이론상 완벽해 보이지만, 결과물은 처참합니다. 32px 크기에서도 계단 현상이 자글자글하죠. 저자는 “솔직히 꽤 구리다(Pretty bad)“라고 인정합니다. 비트맵 폰트는 크기를 키우면 깨지고, 줄이면 뭉개집니다. 현대적인 UI에서는 용납될 수 없는 품질이죠.

구원투수: SDF (Signed Distance Fields)

여기서 저자는 현대 게임 엔진(Unity의 TextMeshPro 등)이나 최신 렌더러들이 사용하는 SDF 방식으로 선회합니다. 개인적으로 이 부분이 이 포스트의 하이라이트라고 생각합니다.

SDF의 핵심 아이디어는 픽셀에 ‘색상’을 저장하는 것이 아니라, ‘가장 가까운 가장자리까지의 거리’ 를 저장하는 것입니다.

  • 내부: 거리가 음수
  • 외부: 거리가 양수
  • 경계: 0

이렇게 생성된 SDF 텍스처(아틀라스)는 GPU 셰이더에서 아주 적은 비용으로 고품질의 안티 앨리어싱을 구현할 수 있게 해줍니다. 프래그먼트 셰이더 코드를 보면 그 우아함에 감탄하게 됩니다.

float dist = texture(atlas_image, glyph_tex_coord).r;
// smoothstep을 이용해 경계면을 부드럽게 처리
float alpha = smoothstep(threshold - smoothing, threshold + smoothing, dist);
frag_color = vec4(text_color, alpha);

이 방식의 장점은 확장성 입니다. 작은 해상도의 SDF 텍스처 하나만 있으면, 셰이더 파라미터 조절만으로 테두리(Outline), 그림자(Shadow), 심지어 글로우(Glow) 효과까지 프로그램적으로 만들어낼 수 있습니다. 비트맵 방식이었다면 텍스처를 다시 구워야 했을 일들이죠.

Hacker News의 반응: 집단지성의 날카로움

이 글은 Hacker News에서도 뜨거운 반응을 얻었습니다. 시니어 엔지니어들의 댓글 중 눈여겨볼 만한 포인트들을 짚어드립니다.

1. 최적화에 대한 지적 (Active Edge List) 한 유저가 저자의 래스터라이제이션 로직에 대해 날카로운 지적을 했습니다. 저자는 모든 픽셀에 대해 모든 곡선 세그먼트의 교차를 계산하는 방식(Brute-force)을 썼는데, 이는 비효율적입니다.

“Active Edge List(AEL)를 사용하면 연산량을 획기적으로 줄일 수 있습니다. Y축으로 정렬된 엣지 리스트를 만들고, 현재 스캔라인에 걸치는 엣지들만 계산하면 됩니다.”

이게 바로 현업 그래픽스 엔지니어들이 밥 먹듯이 쓰는 최적화 기법입니다. 2차 방정식 풀이를 모든 픽셀마다 돌리는 건 CPU를 고문하는 행위니까요.

2. C/C++ Header-only 라이브러리 논쟁 저자가 구현체를 헤더 파일 하나에 때려 박은 것에 대해 “왜 그랬냐”는 질문과 “C++ 빌드 시스템이 엉망이라 이게 낫다”는 옹호가 맞붙었습니다. stb_truetype 같은 라이브러리들이 헤더 온리(Header-only) 방식을 채택하는 이유기도 하죠. 링커 설정의 고통을 겪어본 사람이라면 저자의 선택에 끄덕일 수밖에 없을 겁니다.

3. 디자인에 대한 불만 재미있게도 기술적인 내용만큼이나 블로그 디자인(검은 배경에 흰색 모노스페이스 폰트)에 대한 불만이 많았습니다. “내 시각 피질이 비명을 지른다”는 댓글이 베스트로 올라간 걸 보면, 가독성은 역시 엔지니어에게도 중요한 UX인가 봅니다.

마치며: 바퀴를 다시 발명해야 하는 이유

이 프로젝트를 프로덕션에 당장 쓸 수 있을까요? 아니요. FreeType이나 OS 네이티브 API를 쓰는 게 정신건강에 이롭습니다. 힌팅(Hinting), 복잡한 텍스트 레이아웃(Shaping), 이모지 지원 등 갈 길이 멉니다.

하지만 이 글은 “블랙박스를 두려워하지 않는 태도” 가 무엇인지 보여줍니다. 폰트 렌더링이라는 거대한 산을 베지어 곡선과 SDF라는 두 가지 핵심 원리로 분해하고 정복해 나가는 과정은 그 자체로 훌륭한 엔지니어링 케이스 스터디입니다.

여러분도 평소에 ‘마법’처럼 쓰고 있던 라이브러리가 있다면, 주말에 한 번쯤 그 뚜껑을 열어보시기 바랍니다. 그 안에는 생각보다 재미있는 원리들이 숨어있을 테니까요.