Pretext가 요즘 핫합니다. 어떤 문제를 해결하려는 라이브러리인지, 어떤 특징이 있는지 간단히 살펴보겠습니다.
먼저 예제입니다.
- 슬라이더로 폭을 바꿀 수 있습니다.
- 클릭하면 작게 폭발합니다.
- 왼쪽 토글에서 Pretext / DOM 모드를 전환할 수 있습니다.
어떤 문제를 해결할까
"이 텍스트가 300px 컨테이너에서 몇 줄로 배치되고, 전체 높이는 몇 px인가?"
이 질문에 답하려면 지금까지는 보통 DOM에 직접 물어봐야 했습니다.
div.textContent = text;
div.style.width = '300px';
const height = div.offsetHeight;이 방식의 문제는 offsetHeight 같은 layout-dependent API를 DOM 변경 직후 읽을 경우, 브라우저가 정확한 값을 반환하기 위해 동기적으로 style / layout 계산을 flush할 수 있다는 점입니다. 흔히 말하는 forced synchronous layout이 여기서 발생할 수 있습니다. (물론 이것이 항상 전체 DOM을 다시 계산한다는 뜻은 아닙니다. 이미 layout이 최신 상태라면 추가 계산 없이 끝날 수도 있습니다. 다만 텍스트 측정이 잦고, 그 직전에 DOM 변경이 반복되는 상황이라면 비용이 커질 수 있습니다.)
텍스트 블록이 하나뿐이라면 큰 문제가 아닐 수 있는데요, 하지만 아래와 같은 경우에는 이야기가 달라집니다.
| 사용 예시 | 문제 |
|---|---|
| 가상화 리스트 | Slack 같은 채팅 UI에서는 메시지 수백~수천 개의 높이를 알아야 스크롤 위치를 정확히 계산할 수 있습니다. 렌더링 전에 높이를 모르거나, 렌더링 후에 다시 재측정하면 스크롤 점프가 생기기 쉽습니다. |
| Accordion 애니메이션 | height: auto는 일반적인 CSS transition만으로 자연스럽게 다루기 어렵습니다. 펼쳐진 높이를 px 단위로 알아야 매끄러운 애니메이션을 만들기 쉽습니다. |
| 반응형 리사이즈 | 창 크기가 바뀔 때마다 텍스트 블록들의 높이를 다시 측정해야 하는 경우가 있습니다. 이때 DOM 측정이 많이 겹치면 성능 비용이 누적될 수 있습니다. |
| CLS(Cumulative Layout Shift) 방지 | 텍스트가 나중에 측정되거나 렌더링 중에 높이가 바뀌면 아래 콘텐츠가 밀릴 수 있습니다. |
Pretext는 이 질문에 DOM에 직접 의존하지 않고 답하려는 라이브러리입니다.
import { prepare, layout } from '@chenglou/pretext';
const prepared = prepare(text, '16px Inter');
const { height, lineCount } = layout(prepared, 300, 24);prepare()는 내부적으로 텍스트를 분석하고, 브라우저의 텍스트 측정 결과를 바탕으로 필요한 정보를 준비합니다.
그 다음 layout()은 이 준비된 데이터를 바탕으로 산술 계산만으로 줄 바꿈과 높이를 계산합니다.
즉, 자주 바뀌는 폭에 대해 매번 DOM을 다시 만들고 측정하는 대신, 한 번 준비한 데이터를 재사용해서 빠르게 결과를 얻는 구조입니다.
공식 자료의 벤치마크 예시에서는 500개 텍스트 블록 기준으로 prepare()는 약 19ms, layout()은 약 0.09ms 수준으로 걸린다고 얘기하고 있습니다. DOM 측정 대비 약 300배 빠르다고 합니다. (이 수치는 특정 조건의 측정값이므로 참고만 해주세요.)
Canvas에 그릴 때도 필요할까?
Canvas에는 DOM처럼 내장된 텍스트 줄 바꿈 레이아웃이 없습니다. ctx.fillText()는 기본적으로 한 줄 텍스트를 그리는 API이기 때문에, 멀티라인 텍스트를 그리려면 보통 직접 줄 바꿈을 구현해야 합니다.
function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight) {
const words = text.split(" ");
let line = "";
let currentY = y;
for (const word of words) {
const testLine = line ? line + " " + word : word;
if (ctx.measureText(testLine).width > maxWidth && line) {
ctx.fillText(line, x, currentY);
line = word;
currentY += lineHeight;
} else {
line = testLine;
}
}
if (line) ctx.fillText(line, x, currentY);
}여기서 중요한 점은 measureText() 자체는 DOM에 실제로 그리지 않아도 사용할 수 있다는 것입니다. Canvas 컨텍스트의 font 설정을 기준으로 텍스트의 폭을 측정할 수 있고, 이 과정이 DOM reflow를 일으키는 것은 아닙니다.
문제는 줄 바꿈 로직이 생각보다 단순하지 않다는 데 있습니다.
- 영어는 공백 기준으로 어느 정도 처리할 수 있지만, 한국어는 글자 단위 줄 바꿈이 자연스럽게 필요합니다.
。,」같은 문자는 줄 머리에 오면 어색하거나 금지되는 경우가 있습니다.- 아랍어, 히브리어처럼 RTL이 섞이면 방향 처리까지 고려해야 합니다.
- 이모지
👨👩👧같은 ZWJ 시퀀스는 중간에서 잘못 자르면 깨질 수 있습니다. - 브라우저 엔진에 따라 줄 바꿈 경계가 미세하게 달라질 수도 있습니다.
즉, 단순히 폭만 재는 것과 브라우저가 실제로 배치할 법한 줄 바꿈을 재현하는 것은 전혀 다른 문제입니다.
Pretext는 바로 이 지점을 다룹니다.
const result = layoutWithLines(prepared, canvasWidth, lineHeight);
result.lines.forEach((line, i) => {
ctx.fillText(line.text, 0, i * lineHeight);
});Canvas 기반 에디터, 데이터 시각화, 게임 UI처럼 DOM을 최소화하거나 아예 사용하지 않는 환경에서는, 폭 측정 자체보다도 줄 바꿈 결과를 안정적으로 재현하는 로직이 더 중요해집니다. Pretext는 그 부분을 재사용 가능한 형태로 제공한다는 점에서 의미가 있습니다.
Canvas.measureText()만 직접 쓰면 되지 않나?
얼핏 보면 이렇게 생각할 수 있습니다.
폭만 재면 되는 것 아닌가?
Canvas.measureText()를 직접 쓰면 충분하지 않나?
하지만 measureText()는 기본적으로 문자열 한 덩어리의 폭을 알려주는 API입니다.
"이 텍스트가 300px 안에서 몇 줄이 되는가?"를 알려주지는 않습니다.
즉, 실제로는 아래 문제들을 직접 풀어야 합니다.
- 한국어 / 중국어 / 일본어 공백 기준 분할만으로는 부족합니다. 줄 바꿈 가능한 경계를 더 세밀하게 처리해야 합니다.
- 아랍어 / 히브리어 RTL, bidi 텍스트가 섞이면 메모리상의 순서와 시각적 순서가 달라질 수 있습니다.
- 이모지 / grapheme cluster 사용자가 보는 한 글자와 내부 코드포인트 수가 일치하지 않는 경우가 많습니다.
- 긴 단어 처리
overflow-wrap에 가까운 동작을 하려면 더 세밀한 분해가 필요합니다. - 브라우저별 차이 같은 텍스트라도 줄 바꿈 위치가 미세하게 다를 수 있습니다.
Pretext는 이런 문제를 줄이기 위해, 브라우저의 텍스트 측정 결과를 기준으로 줄 바꿈을 재현하는 쪽으로 설계되어 있습니다.
아래는 Pretext에서 사용하는 기술들이라고 하는데, 링크 들어가서 자세히 살펴봐도 좋을 것 같아요.
Intl.Segmenter로 텍스트 쪼개기 - 줄 바꿈을 하려면 먼저 "어디서 잘라도 되는가?"를 알아야 합니다. 영어는 공백 기준으로 단순하지만, 한국어는 글자 단위로 바꿀 수 있고("안녕" → ["안", "녕"]), 이모지 👨👩👧는 내부적으로 ZWJ(Zero Width Joiner)로 연결된 여러 코드포인트지만 화면에서는 한 글자입니다. Intl.Segmenter는 브라우저 내장 유니코드 분할 API로, UAX #29 표준에 따라 grapheme 클러스터(사람이 인식하는 "한 글자" 단위)를 정확하게 구분합니다. Pretext는 { granularity: 'grapheme' } 모드로 이걸 호출해서 텍스트를 줄 바꿈 가능한 세그먼트로 분할합니다.
Bidi 알고리즘으로 방향 처리 - 아랍어는 오른쪽에서 왼쪽(RTL)으로 씁니다. 그런데 "مرحبا Hello World مرحبا" 같이 LTR/RTL이 섞이면, 메모리의 저장 순서와 화면의 표시 순서가 달라지는데요. Unicode Bidirectional Algorithm은 각 문자를 L(Left-to-Right), R(Right-to-Left), AL(Arabic Letter) 등 타입으로 분류한 뒤, 임베딩 레벨을 계산해서 시각적 순서를 결정합니다. Pretext는 PDF.js의 bidi 구현을 포크해서 이 알고리즘을 수행하고, 줄 바꿈 시 방향을 반영합니다.
브라우저별 미세 차이 보정 - 같은 텍스트, 같은 폰트라도 Chrome(Blink)과 Safari(WebKit)에서 줄 바꿈 위치가 살짝 다를 수 있습니다. 예를 들어 "이 단어가 줄 끝에 들어가느냐 마느냐"를 판단할 때, Chromium은 0.005px 허용 오차를 쓰고 WebKit은 1/64px(≈0.016px)을 씁니다 (추정치). CJK 인용부호 뒤에 문자를 같은 줄에 유지할지 다음 줄로 넘길지도 다릅니다. Pretext는 getEngineProfile()로 현재 브라우저를 감지해서 이런 미세한 기준을 맞춥니다. 그래야 Pretext가 계산한 줄 바꿈이 브라우저의 실제 렌더링과 일치할 수 있으니까요.
사실 이런 것들을 다 구현하면... 그게 라이브러리야 연진아...
측정의 정합성은 어떻게 보장할까
자연스럽게 이런 질문이 따라옵니다.
Pretext가 계산한 높이나 줄 바꿈 결과가, 실제 브라우저 렌더링과 정말 잘 맞는가?
Pretext의 접근은 비교적 명확합니다.
브라우저의 텍스트 측정 결과를 ground truth에 가까운 기준으로 두고, 그 위에서 줄 바꿈과 배치 결과를 재현합니다. 별도의 외부 셰이핑 엔진을 쓰기보다는, 브라우저가 이미 제공하는 결과를 최대한 활용하는 방식입니다. 공식 저장소의 RESEARCH.md에는 개발 과정에서 브라우저 간 차이, 이모지 보정, system-ui 폰트의 한계, 실제 렌더링과의 비교 검증 등에 대한 내용이 정리되어 있습니다.
핵심은 다음 한 문장으로 요약할 수 있습니다.
Preprocessing beats runtime correction models.
즉, 런타임에서 복잡한 보정 모델을 반복적으로 돌리기보다, 가능한 많은 복잡성을 prepare() 단계에 몰아넣고, layout()은 빠르고 단순한 계산만 수행하게 만든다는 철학입니다. 이 점이 Pretext의 가장 중요한 설계 포인트라고 볼 수 있습니다.
사용 예시
API는 크게 두 단계로 나뉩니다.
prepare()계열 함수로 텍스트를 한 번 분석합니다.- 이후
layout()계열 함수를 여러 번 호출해 폭에 따른 결과를 계산합니다.
즉, 비싼 작업은 앞에서 한 번, 자주 반복되는 작업은 뒤에서 가볍게 처리하는 구조입니다.
높이 / 줄 수만 알면 될 때 - prepare + layout
가장 기본적인 패턴입니다. 주어진 폭에서 몇 줄이 되는지, 전체 높이가 얼마인지 알고 싶을 때 사용합니다.
import { prepare, layout } from '@chenglou/pretext';
const prepared = prepare("김수한무 거북이와 두루미", "bold 16px system-ui");
layout(prepared, 300, 24); // 폭 300px, 줄 높이 24px
// { lineCount: 2, height: 48 }
layout(prepared, 600, 24); // 폭 600px, 줄 높이 24px
// { lineCount: 1, height: 24 }폭이 바뀔 때마다 layout()만 다시 호출하면 되므로, 반응형 환경이나 가상화 리스트에서 특히 잘 어울립니다.
공백과 줄바꿈을 그대로 유지해야 하는 경우에는 pre-wrap 모드도 사용할 수 있습니다.
const prepared = prepare(textareaValue, '16px Inter', { whiteSpace: 'pre-wrap' });
const { height } = layout(prepared, textareaWidth, 20);이런 방식은 textarea 자동 높이 조절 같은 상황에도 응용할 수 있습니다.
줄별 텍스트가 필요할 때 - prepareWithSegments + layoutWithLines
높이만이 아니라, 각 줄에 어떤 텍스트가 들어가는지까지 알고 싶다면 이 API가 더 적합합니다.
import { prepareWithSegments, layoutWithLines } from '@chenglou/pretext';
const prepared = prepareWithSegments("긴 텍스트...", "16px Inter");
const result = layoutWithLines(prepared, 400, 24);
// result.lines = [
// { text: "긴 텍스트가 여기서", width: 380 },
// { text: "줄 바꿈된다.", width: 120 },
// ]Canvas나 SVG처럼 줄별로 직접 그려야 하는 환경에서 특히 유용합니다.
result.lines.forEach((line, i) => {
ctx.fillText(line.text, 0, i * 24);
});문자열 할당을 최소화하면서 줄 범위만 순회하고 싶다면 walkLineRanges도 사용할 수 있습니다.
let maxLineWidth = 0;
walkLineRanges(prepared, 400, (line) => {
maxLineWidth = Math.max(maxLineWidth, line.width);
});줄마다 폭이 다를 때 - layoutNextLine
모든 줄의 폭이 동일하지 않은 레이아웃도 가능합니다. 예를 들어 이미지나 플로팅 요소를 피해 텍스트가 흐르는 상황입니다.
import { prepareWithSegments, layoutNextLine } from '@chenglou/pretext';
const prepared = prepareWithSegments("긴 텍스트...", "16px Inter");
let cursor = { segmentIndex: 0, graphemeIndex: 0 };
const line1 = layoutNextLine(prepared, cursor, 300);
// 이미지 옆처럼 좁은 폭
const line2 = layoutNextLine(prepared, line1.end, 600);
// 이미지 아래처럼 넓은 폭이런 API는 일반적인 웹 UI보다는, 더 자유로운 텍스트 흐름이 필요한 커스텀 렌더링 환경에서 더 유용할 것 같아요.
예제 1 - Accordion
아코디언 예제입니다. Pretext로 펼쳐진 높이를 미리 계산해두면, DOM을 읽어가며 높이를 재는 방식보다 더 단순한 흐름으로 처리할 수 있습니다.
예제 2 - ASCII 그림판
선을 그으면 ASCII 파티클이 붙고, Reset하면 떨어지는 예제입니다.
Pretext 버전은 prepareWithSegments() + layoutWithLines()를 사용하고, DOM 버전은 숨겨진 <div>에 textContent를 넣은 뒤 offsetHeight를 읽는 방식으로 동작합니다.
여기서 build 시간은 포인터가 움직일 때마다 동기적으로 실행되는 build() 함수의 소요 시간을 의미합니다.
- Pretext 버전:
prepareWithSegments()+layoutWithLines() - DOM 버전: 숨겨진
<div>에 텍스트를 넣고offsetHeight읽기
이 시간이 길어질수록 포인터 이벤트 처리도 밀리게 됩니다.
실제 Performance 프로파일
위 그림판에서 양쪽 모두 500 파티클까지 그린 뒤, Chrome DevTools Performance 탭으로 프로파일링한 결과입니다.
| 지표 | Pretext | DOM | 배수 |
|---|---|---|---|
| Layout 이벤트 | 629회 | 8,656회 | 13.8x |
| Layout 총 시간 | 67ms | 497ms | 7.4x |
| Style Recalc | 77ms | 363ms | 4.7x |
| 포인터 이벤트 평균 | 0.12ms | 0.76ms | 6.3x |
(이 수치는 특정 데모와 특정 브라우저 환경에서의 결과입니다. DOM 측정과 산술 기반 계산이 얼마나 차이가 있는지 정도로 가볍게 보시는 편이 적절할 것 같아요!)
요약하면 흐름은 대략 이렇습니다.
Pretext: 포인터 이동 → layout() 중심 계산 → 빠르게 종료
DOM: 포인터 이동 → textContent 쓰기 → offsetHeight 읽기 → style/layout 계산 가능성 → 종료어디에 적용해볼 수 있을까
| 상황 | 기존 방식 | Pretext 적용 시 기대 효과 |
|---|---|---|
| 가상화 리스트 | 렌더링 후 높이 측정 | 렌더링 전에 높이를 계산해 스크롤 점프를 줄이기 쉬움 |
| 아코디언 애니메이션 | scrollHeight 읽기 | 사전 계산된 높이로 더 단순하게 제어 가능 |
| textarea 자동 높이 | 입력마다 scrollHeight 측정 | 입력마다 계산 기반으로 높이 예측 가능 |
| 반응형 리사이즈 | 모든 블록 재측정 | 준비된 데이터를 바탕으로 빠르게 재계산 가능 |
| CLS 방지 | 높이를 사후 보정 | 높이를 미리 계산해 시프트를 줄이기 쉬움 |
| Canvas / SVG 텍스트 | 줄 바꿈 직접 구현 | 줄별 결과를 재사용 가능한 형태로 얻을 수 있음 |
물론 모든 경우에 무조건 Pretext가 정답은 아닙니다. 텍스트 수가 적고, 측정이 드물고, DOM을 이미 충분히 쓰고 있다면 오히려 기존 방식이 더 단순할 수도 있을 것 같습니다. 하지만 텍스트 측정이 빈번하고 반복적이라면 Pretext가 좋은 선택지가 될 수 있을 것 같아요.
마무리하며
깊게 보지는 못했지만, 해당 라이브러리는 텍스트 레이아웃 문제를 DOM 측정에서 분리해내려는 시도인 것 같다는 생각이 들었습니다. 그리고 이 발상은 텍스트가 실시간으로 생성되고 수정되며 스트리밍되는 UI가 많아지는 지금 시점에서 꽤 의미 있게 느껴지는데요, AI 기반 인터페이스처럼 텍스트 길이와 배치가 계속 바뀌는 환경에서 DOM을 계속 읽어가며 맞추는 방식보다 사전 준비 + 빠른 재계산이라는 접근이 꽤나 유용할 것 같습니다.
구현 내부를 따라가 보면 Intl.Segmenter, bidi 처리, 브라우저별 차이 보정, 캐시 전략 등 흥미로운 주제가 많이 엮여 있어서, 조금 더 공부해보면 좋을 것 같다는 생각을 하며 글을 마무리 합니다.
레퍼런스
- GitHub - chenglou/pretext - 소스코드, RESEARCH.md
- Pretext Demos - 공식 인터랙티브 데모
- MDN - Intl.Segmenter - 유니코드 세그먼테이션 API
- MDN - CanvasRenderingContext2D.measureText() - Canvas 텍스트 측정
- UAX #29 - Unicode Text Segmentation
- UAX #9 - Unicode Bidirectional Algorithm