유행 레포 딥다이브 · chenglou/pretext 분석

pretext 딥다이브
— DOM을 그리지 않고 텍스트 레이아웃을 계산하는 15KB 라이브러리

브라우저에 실제로 그려보지 않고도 "이 텍스트가 몇 줄이고 높이가 얼마인가"를 Canvas measureText로 계산하는 순수 TypeScript 라이브러리. RTL·이모지·CJK·금칙처리까지 국제화 함정을 거의 다 해결했다. ⭐48.1k · 런타임 의존성 0 · TypeScript 91.2% (저장소: chenglou/pretext · MIT · @chenglou/pretext v0.0.8 · React Motion 제작자 Cheng Lou)
목차
  1. 프로젝트 한줄 요약
  2. 왜 주목받는가
  3. 기술 스택 전체 지도
  4. 아키텍처 심화 분석
  5. 디렉토리 구조 해부
  6. 학습 포인트 (기술별)
  7. 하드웨어 / 시스템 요구사항
  8. 직접 해볼 수 있는 실습 과제
  9. 관련 기술 심화 학습 로드맵
  10. 핵심 키워드 사전
  11. 참고 링크

1프로젝트 한줄 요약

pretext가 정확히 무엇을 하는 도구인가

pretextDOM에 실제로 텍스트를 그리지 않고도 "그렸을 때의 줄 수와 높이"를 계산하는 멀티라인 텍스트 측정·배치 라이브러리다. 브라우저의 Canvas measureText API로 글자 폭을 재고, 그 폭들을 산술 계산해서 레이아웃을 결정한다. 채팅 말풍선 크기, 가상 스크롤 행 높이, 텍스트 에디터 줄바꿈처럼 "측정이 수백 번 반복되는" 상황을 위해 만들어졌다.

한 컷 비유

"옷을 입어보지 않고 가격표 목록만 받아 보는 쇼핑"

지금까지의 텍스트 측정은 보이지 않는 HTML 요소를 만들어 실제로 그려보고(시착), 높이를 재고(거울 확인), 지우는(탈의) 방식이었다. 500벌이면 종일 걸린다.

pretext는 가격표 목록(글자 폭 메트릭)만 먼저 한 번 뽑아두고, 그 다음부터는 산술 계산만으로 결과를 낸다. 시착 없이, 한 번에. 핵심은 "비싼 측정을 한 번만 하고(prepare), 싼 계산을 자주 반복(layout)"하는 2단계 분리다.

2왜 주목받는가

출시 48시간 만에 14k 스타, 현재 48.1k

제작자가 Cheng Lou — React Motion(React 애니메이션의 고전)을 만든 개발자다. 이름값에 더해, "DOM 측정은 느리다"는 웹 개발의 오래된 통증을 정면으로 푼 점이 화제가 됐다. 게다가 레포 안에 RESEARCH.md·CLAUDE.md·AGENTS.md로 "왜 이렇게 만들었는가"를 솔직하게 공개해, 라이브러리 설계 노하우 교과서로도 읽힌다.

기존 방식의 불편함
DOM 측정 — 그려보고 재고 지우는 비싼 반복

숨은 요소를 만들어 텍스트를 넣고 → 브라우저가 그릴 때까지 기다리고 → getBoundingClientRect()로 높이를 재고 → 지운다. 한 번이면 빠르지만, 수백 번 반복하면 강제 동기 레이아웃(리플로우)이 누적돼 60FPS(프레임당 16ms)를 깬다. 화면이 끊긴다.

pretext의 해법
Canvas measureText — 그리지 않고 폭만, 리플로우 0회

Canvas 2D의 measureText는 실제로 그리지 않고 폰트 엔진에 직접 물어 글자 폭을 돌려준다. DOM을 건드리지 않으니 리플로우가 없다. pretext는 이걸로 측정을 한 번 캐시한 뒤, 줄바꿈 계산은 순수 산술로 처리한다. 실제 benchmarks/chrome.json 기준 DOM interleaved 방식 43.25ms에서 layout() 0.144ms로 약 300배 차이를 보인다.

경쟁/대안 비교

비교 대상pretext의 위치
getBoundingClientRect()정확하지만 리플로우 발생. 측정이 소수면 이게 더 간단. pretext는 대량 반복 측정에서만 가치
opentype.js / fontkit폰트 파일을 직접 파싱(무겁다). pretext는 브라우저 폰트 엔진에 위임해 가볍고(0 의존성) 시스템 폰트와 일치
CSS만으로 처리높이를 "미리" 알 수 없음. 가상 스크롤은 그리기 전에 높이가 필요 → pretext가 그 빈틈을 채운다
직접 measureText 짜기가능하지만 RTL·이모지·CJK·금칙처리가 전부 함정. pretext는 이걸 15KB 안에 이미 풀어놓음

3기술 스택 전체 지도

언어 구성 · 런타임 의존성 · 빌드 · 테스트

언어 구성 (GitHub Linguist 기준)

TypeScript ███████████████████████████ 91.2% ← 라이브러리 본체 전부 HTML ███ 8.8% ← 데모/문서 페이지(pages/)

※ 단 두 언어. 빌드 도구·예제까지 거의 전부 TypeScript로 통일돼 있다.

런타임 의존성 — 0개

항목의미
패키지명@chenglou/pretextnpm 배포명. npm install @chenglou/pretext
버전0.0.8아직 초기 버전 (API가 바뀔 수 있음)
typemodule순수 ESM. CommonJS require 미지원
dependencies(없음)런타임 의존성 0 — 브라우저 내장 API에만 의존
main / types./dist/layout.js기본 진입점. 타입은 ./dist/layout.d.ts
서브 진입점./rich-inline리치텍스트 인라인 플로우 헬퍼(별도 export)

개발/빌드 도구 (devDependencies)

도구역할
TypeScript 6.0.2전체 코드 타입체크 + 빌드
Bun (@types/bun)테스트/스크립트 실행 런타임
oxlint + tsgolintRust 기반 초고속 린터 (oxlint-tsgolint로 타입 인지 린트)
knip미사용 export/의존성 탐지
marked문서(.md) → HTML 변환(데모 페이지 빌드)

스크립트는 accuracy-check, benchmark-check, corpus-check, build:package, site:build 등 20개 이상 — "정확도 검증·벤치마크·말뭉치 검증"을 자동화한 점이 이 레포의 특징이다.

4아키텍처 심화 분석

2단계 파이프라인 · 병렬 배열 · 제로 할당 핫패스

전체 데이터 흐름

┌──────────────────────────────────────────────────────────┐ │ 입력: "긴 텍스트 문자열" + 폰트 설정(font, letterSpacing) │ └───────────────────────────┬──────────────────────────────┘ │ ┌───────────────────▼───────────────────┐ │ Phase 1 · prepare() (비싸다 · 1회) │ │ ① Intl.Segmenter로 언어별 분절 │ │ ② Canvas measureText로 조각 폭 측정 │ │ ③ bidi(양방향) 처리 + CJK/이모지 표시 │ │ ④ 결과를 "병렬 숫자 배열"로 캐시 │ └───────────────────┬───────────────────┘ │ (캐시된 메트릭) ┌───────────────────▼───────────────────┐ │ Phase 2 · layout() (초저비용 · 매번) │ │ • 캐시 배열을 누적 합산하며 줄바꿈 위치 │ │ • DOM 0회 · Canvas 0회 · 새 객체 0개 │ │ → 줄 수 / 높이 / 각 줄 범위 반환 │ └───────────────────┬───────────────────┘ │ ┌───────────────────▼───────────────────┐ │ 렌더링: DOM · Canvas · SVG (호출측 자유) │ └────────────────────────────────────────┘

핵심 설계 패턴 ①: 측정/배치 2단계 분리

패턴
Prepare / Layout 분리 (비싼 일 1회, 싼 일 N회)
텍스트가 바뀔 때만 prepare()(측정)를 다시 하고, 창 크기가 바뀔 때마다 호출되는 layout()(줄바꿈 계산)은 산술만 하게 만든다. RESEARCH.md의 표현: "비싼 텍스트 작업은 prepare()에서 한 번, layout()은 산술만(arithmetic-only)."
텍스트 변경 → prepare() 다시 (비쌈) 창 리사이즈 → layout()만 다시 (거의 공짜) ← 여기가 자주 일어남 핵심: 자주 반복되는 경로(hot path)에서 비싼 일을 제거

핵심 설계 패턴 ②: 병렬 배열 (Structure of Arrays)

함정
"객체 배열로 깔끔하게 정리"하기

보통은 [{width:10, kind:'word'}, {width:5, kind:'space'}, ...]처럼 객체 배열로 짠다. 읽기엔 명확하지만 같은 종류 데이터가 메모리에 흩어져 CPU 캐시 효율이 나쁘다.

해결책
같은 타입을 한 배열에 — widths[], kinds[]

widths:[10,5,...], kinds:['word','space',...]처럼 컬럼별로 몰아넣는다. 폭만 순회할 때 다른 데이터는 건드리지 않아 CPU 캐시 적중률이 폭증한다. 게임 엔진·데이터베이스에서 쓰는 패턴이다.

핵심 설계 패턴 ③: 제로 할당 핫패스

용어
Zero-allocation Hot Path (제로 할당 핫패스)
자주 실행되는 코어 코드에서 중간 객체를 단 하나도 만들지 않는 기법. 새 객체는 결국 가비지 컬렉터(쓰레기 청소부)가 치워야 하고 청소는 비싸다. layout()은 미리 계산된 숫자 배열을 읽고 누적 합만 갱신하며, 새 객체·새 문자열을 만들지 않는다. walkLineRanges()처럼 줄 문자열조차 만들지 않는 저수준 API도 제공한다.

핵심 설계 패턴 ④: 브라우저별 보정값을 "측정해서 캐시"

Chrome·Firefox는 macOS에서 작은 크기 이모지를 Canvas상에서 DOM보다 2~3픽셀 더 넓게 잰다. pretext는 룩업 테이블을 박아두는 대신 폰트별 보정 계수를 런타임에 감지해 캐시한다. 환경이 바뀌어도 견디는 설계다.

핵심 설계 패턴 ⑤: 전처리 우선(preprocessing over heuristics)

아랍어·미얀마어 같은 복잡한 스크립트는 복잡한 폭 모델 대신 의미 단위 전처리(구두점 클러스터 병합, 반복 기호 표시 등)로 푼다. RESEARCH.md의 결론: "알고리즘적 정교함보다 브라우저에 근거를 두는 단순함이 더 가치 있었다."

5디렉토리 구조 해부

src/ 안에 국제화의 모든 함정이 파일별로 나뉘어 있다
chenglou/pretext/ ├── src/ # ⭐ 라이브러리 본체 (전부 TypeScript) │ ├── layout.ts # 메인 진입점 — prepare()/layout() 공개 API │ ├── measurement.ts # Canvas measureText로 폭 측정 + 폰트 보정 │ ├── line-break.ts # 줄바꿈 규칙(금칙처리/keep-all 등) │ ├── line-text.ts # 줄 범위 → 실제 텍스트로 복원 │ ├── bidi.ts # 양방향(RTL/LTR) 배치 — UAX #9 구현 │ ├── analysis.ts # 세그먼트 분석(CJK 판별, 이모지 카운트 등) │ ├── rich-inline.ts # 리치텍스트 인라인 플로우 헬퍼(서브 export) │ ├── layout.test.ts # 레이아웃 단위 테스트 │ ├── test-data.ts # 테스트 입력 데이터 │ └── generated/ # 코드 생성 산출물(유니코드 표 등) ├── accuracy/ # 실제 DOM 측정과 결과 일치 검증 ├── benchmarks/ # 성능 벤치마크 ├── corpora/ # 다국어 말뭉치(테스트용 텍스트 모음) ├── research-data/ # 연구 측정 데이터 ├── pages/ # 데모/문서 사이트(HTML) ├── status/ # 검증 상태 리포트 ├── scripts/ # 빌드/검증 스크립트 ├── shared/ # 공용 유틸 ├── .cursor/rules/ # Cursor 에디터 AI 규칙 ├── .github/workflows/ # CI(정확도·벤치마크 자동 검증) ├── README.md RESEARCH.md CLAUDE.md AGENTS.md # 설계 의사결정 문서들 ├── DEVELOPMENT.md STATUS.md TODO.md SECURITY.md CHANGELOG.md ├── package.json tsconfig.json

읽는 순서 추천: README.md(무엇을) → src/layout.ts(공개 API의 형태) → src/measurement.ts(폭을 어떻게 재는가) → src/bidi.ts(가장 어려운 함정) → RESEARCH.md(왜 이렇게 했는가). 함정 하나당 파일 하나로 나뉜 구조라, 관심 있는 주제만 골라 읽기 좋다.

6학습 포인트 (기술별)

이 레포에서 무엇을 배울 수 있는가

A. Canvas measureText — 숨은 측정 API

배울 것: DOM 리플로우 없이 글자 폭을 얻는 법. ctx.measureText(str).width 한 줄이 어떻게 "그리기"를 우회하는지, 그리고 그 결과가 실제 렌더링과 왜/얼마나 다른지(서브픽셀, 폰트별 보정).

B. 성능 설계 — 핫패스/콜드패스 분리

배울 것: "비싼 일은 한 번, 싼 일은 N번"이라는 보편 패턴. 어떤 일을 prepare로 밀어내고 layout은 산술만 남기는지를 보면, 자기 코드의 병목도 같은 눈으로 보게 된다.

C. 데이터 지향 설계 (SoA)

배울 것: 객체 배열 대신 병렬 배열로 CPU 캐시를 챙기는 법, 제로 할당으로 GC 부담을 없애는 법. JS에서도 이런 저수준 최적화가 의미 있다는 실증.

D. 국제화(i18n)의 실제 난이도

배울 것: 공백으로 단어를 나누면 왜 망하는지(CJK는 공백이 없음), Intl.Segmenter로 언어별 분절을 어떻게 위임하는지, 그래핌(이모지 결합)·금칙처리·RTL이 각각 무슨 문제인지. bidi.ts는 UAX #9 알고리즘의 실전 구현 예제다.

E. "정확도"를 코드로 보증하는 법

배울 것: accuracy/ + corpora/ + CI로 "계산값이 실제 DOM 측정과 일치하는가"를 자동 검증하는 구조. 측정 라이브러리답게 "맞다는 걸 어떻게 증명하나"가 코드에 박혀 있다.

F. 라이브러리 공개 설계 — 문서로 의사결정 남기기

배울 것: RESEARCH.md가 "무엇을 시도했고 왜 버렸는가"를 솔직히 기록한 사례. 좋은 오픈소스가 코드뿐 아니라 맥락(why)을 어떻게 남기는지 배울 수 있다.

7하드웨어 / 시스템 요구사항

하드웨어가 아니라 "런타임 능력"이 요구사항이다

pretext는 무거운 라이브러리가 아니라 브라우저 내장 API에 얹히는 15KB 코드다. 따라서 RAM·CPU 같은 하드웨어 사양보다 실행 환경이 특정 API를 지원하는가가 핵심 요구사항이다.

요구사항필요 이유없으면
Intl.Segmenter언어별 단어/글자 분절(필수)동작 불가 — 폴리필 필요
Canvas 2D measureText글자 폭 측정의 근간(필수)동작 불가
ESM 지원 번들러/런타임type:module, ESM 전용 배포CommonJS require 불가
실제 폰트 로드측정 대상 폰트가 로드돼 있어야 정확폴백 폰트로 잘못 측정
환경지원
최신 브라우저 (Chrome/Edge/Firefox/Safari)Intl.Segmenter·Canvas 모두 내장 → 그대로 동작
Node.js (서버 측 계산)Canvas 2D가 기본 없음 → node-canvas 등 폴리필 필요
구형 브라우저Intl.Segmenter 미지원 가능 → 폴리필 또는 미지원
정확도 주의
macOS에서 system-ui 폰트는 측정 정확도에 불리

README가 명시: 정확도를 위해서는 system-ui 폰트가 macOS에서 안전하지 않다. 구체적 폰트(예: 명시적 폰트 패밀리)를 지정하는 편이 측정-렌더 일치에 유리하다.

8직접 해볼 수 있는 실습 과제

난이도별 5가지 과제
과제 1

Canvas measureText 맛보기 ★ 초급 20분

브라우저 콘솔에서 DOM을 건드리지 않고 글자 폭을 직접 재본다. 이게 pretext의 출발점이다.

// 브라우저 콘솔에 그대로 입력
const ctx = document.createElement('canvas').getContext('2d');
ctx.font = '16px sans-serif';
ctx.measureText('테스트 Hello 👨‍👩‍👧‍👦').width;
// → 숫자 폭이 즉시 출력. DOM에는 아무 흔적도 없다.
과제 2

리플로우를 눈으로 확인하기 ★ 초급 40분

Chrome DevTools의 Performance 탭에서 getBoundingClientRect()를 500번 부르는 코드의 타임라인을 녹화한다. 보라색 "Recalculate Style / Layout" 막대가 쌓이는 걸 직접 본다. 이게 pretext가 없애려는 비용이다.

비교: 같은 500개를 measureText로 재는 코드도 녹화해, 레이아웃 막대가 사라지는 걸 나란히 본다.
과제 3

간이 높이 계산기 만들기 ★★ 중급 2시간

pretext의 핵심을 100줄로 직접 구현해본다(영어 한정 단순판).

① 문자열을 공백 기준으로 단어 배열로 자른다 → ② 각 단어를 measureText로 측정 → ③ 누적 폭이 maxWidth를 넘으면 줄바꿈 카운트 +1 → ④ 줄 수 × lineHeight로 높이 → ⑤ 실제 DOM 측정값과 오차 비교. "CJK·이모지를 넣으면 왜 깨지는가"까지 관찰하면 §4의 함정이 체감된다.
과제 4

pretext로 가상 스크롤 행 높이 잡기 ★★★ 고급 4시간

npm i @chenglou/pretext 후, 1000개 채팅 메시지 가상 리스트를 만든다. 각 메시지 높이를 prepare()layout()로 계산해 스크롤 위치를 정확히 잡고, 창 리사이즈 때 layout()만 다시 호출해 부드럽게 재정렬되는지 확인한다.

// 개념 코드 (API 형태 예시)
import { prepare, layout } from '@chenglou/pretext';

const prepared = prepare(message, { font: '14px Pretendard' });
const { height, lineCount } = layout(prepared, 360, 22);
// 리사이즈 시: 같은 prepared로 layout()만 다시 호출
과제 5

국제화 함정 재현 + RESEARCH.md 정독 ★★★ 고급 4시간

아랍어("مرحبا")와 영어가 섞인 문장, 가족 이모지(👨‍👩‍👧‍👦), 일본어 금칙 문자(。」)를 같은 측정기에 넣어 결과가 어떻게 틀어지는지 재현한다. 그 후 레포의 RESEARCH.md를 읽고 pretext가 각 함정을 어떻게 풀었는지(bidi/Segmenter/전처리) 대조한다.

심화: corpora/의 다국어 말뭉치를 가져다 자기 계산기에 돌려 오차율을 표로 만들어본다.

9관련 기술 심화 학습 로드맵

4주 학습 플랜 — 측정에서 국제화·성능까지

1주차: 텍스트 측정의 기초

요일학습 내용실습
브라우저 렌더링 파이프라인Style→Layout→Paint 단계 이해
리플로우(강제 동기 레이아웃)레이아웃 스래싱 직접 재현
Canvas 2D 기본measureText / TextMetrics 속성 탐구
getBoundingClientRect 비용DevTools로 측정 비용 녹화
pretext README 정독공개 API 형태 파악

2주차: 레이아웃 알고리즘

요일학습 내용실습
그리디 줄바꿈(word wrap)간이 줄바꿈기 구현
prepare/layout 2단계 분리src/layout.ts 읽기
letter-spacing / tab-size 처리옵션이 폭에 미치는 영향 실험
white-space: pre-wrap vs normal공백·하드브레이크 처리 비교
soft hyphen / keep-all줄바꿈 경계 옵션 실습

3주차: 국제화(i18n)

요일학습 내용실습
Intl.Segmenter언어별 word/grapheme 분절 실습
그래핌 클러스터(이모지 결합)👨‍👩‍👧‍👦가 1글자임을 검증
CJK 금칙처리(Kinsoku)。」가 줄 시작에 못 오게 처리
양방향 텍스트(UAX #9)src/bidi.ts 읽기 + RTL 혼용 실험
아랍어/미얀마어 전처리전처리 vs 런타임 보정 비교

4주차: 성능 & 검증

요일학습 내용실습
데이터 지향 설계(SoA)객체배열 vs 병렬배열 벤치
제로 할당 / GC 압박핫패스에서 객체 생성 제거
벤치마크 작성benchmarks/ 구조 분석
정확도 검증 자동화accuracy/ + corpora/ 읽기
종합: 가상 스크롤 통합pretext로 실제 리스트 구현

10핵심 키워드 사전

이 레포를 이해하는 데 꼭 필요한 용어들
키워드설명
measureTextCanvas 2D 메서드. 실제로 그리지 않고 현재 폰트로 그렸을 때의 글자 폭을 돌려준다. pretext의 측정 근간
Reflow / LayoutDOM 변경 시 영향받는 요소들의 위치·크기를 다시 계산하는 과정. 반복되면 비싸다 — pretext가 피하려는 비용
prepare()비싼 측정을 한 번만 수행해 메트릭을 캐시하는 1단계 함수
layout()캐시된 메트릭으로 maxWidth·lineHeight를 받아 줄 수/높이를 산술 계산하는 2단계 함수
prepareWithSegments()수동 줄 배치를 위해 더 풍부한 세그먼트 구조를 반환하는 변형
walkLineRanges()줄 문자열을 만들지 않고 줄 범위만 훑는 저수준 워커(제로 할당)
rich-inline서로 다른 스타일의 인라인 조각을 한 흐름으로 배치하는 리치텍스트 헬퍼(별도 export)
Intl.Segmenter언어별 규칙으로 텍스트를 단어/문장/글자로 분절하는 표준 API. CJK·태국어 등을 올바르게 자른다
Grapheme사람 눈에 한 글자로 보이는 단위. 가족 이모지처럼 여러 코드포인트가 합쳐져도 1 그래핌
금칙처리(Kinsoku)CJK에서 줄 시작/끝에 오면 안 되는 문자(。」 등)를 관리하는 줄바꿈 규칙
Bidi (UAX #9)LTR/RTL이 섞인 양방향 텍스트의 배치 순서를 정하는 유니코드 표준 알고리즘. bidi.ts가 구현
SoA (병렬 배열)Structure of Arrays. 컬럼별로 같은 타입을 몰아넣어 CPU 캐시 적중률을 높이는 데이터 배치
제로 할당 핫패스자주 도는 코드에서 새 객체를 만들지 않아 GC 비용을 없애는 기법
pre-wrap / keep-all공백·줄바꿈 보존(pre-wrap), 단어 안 끊기(keep-all) 등 CSS와 동일한 줄바꿈 옵션
letter-spacing글자 사이 간격(픽셀 숫자). pretext가 폭 계산에 반영하는 옵션
폰트 보정 계수macOS 이모지 폭 과대측정 등 환경별 오차를 런타임에 감지해 캐시하는 값

11참고 링크

더 깊이 파고들 때 유용한 자료들