Back

[TIL] Pretext 톺아보기

2026. 03. 30.Yeji Kim
Frontend

Pretext가 요즘 핫합니다. 어떤 문제를 해결하려는 라이브러리인지, 어떤 특징이 있는지 간단히 살펴보겠습니다.

먼저 예제입니다.

  • 슬라이더로 폭을 바꿀 수 있습니다.
  • 클릭하면 작게 폭발합니다.
  • 왼쪽 토글에서 Pretext / DOM 모드를 전환할 수 있습니다.
0 chars0 lines계산 0μsDOM 측정: 0회
Container Width560px
hover · click · resize

어떤 문제를 해결할까

"이 텍스트가 300px 컨테이너에서 몇 줄로 배치되고, 전체 높이는 몇 px인가?"

이 질문에 답하려면 지금까지는 보통 DOM에 직접 물어봐야 했습니다.

js
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에 직접 의존하지 않고 답하려는 라이브러리입니다.

js
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이기 때문에, 멀티라인 텍스트를 그리려면 보통 직접 줄 바꿈을 구현해야 합니다.

js
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는 바로 이 지점을 다룹니다.

js
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는 크게 두 단계로 나뉩니다.

  1. prepare() 계열 함수로 텍스트를 한 번 분석합니다.
  2. 이후 layout() 계열 함수를 여러 번 호출해 폭에 따른 결과를 계산합니다.

즉, 비싼 작업은 앞에서 한 번, 자주 반복되는 작업은 뒤에서 가볍게 처리하는 구조입니다.

높이 / 줄 수만 알면 될 때 - prepare + layout

가장 기본적인 패턴입니다. 주어진 폭에서 몇 줄이 되는지, 전체 높이가 얼마인지 알고 싶을 때 사용합니다.

js
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 모드도 사용할 수 있습니다.

js
const prepared = prepare(textareaValue, '16px Inter', { whiteSpace: 'pre-wrap' });
const { height } = layout(prepared, textareaWidth, 20);

이런 방식은 textarea 자동 높이 조절 같은 상황에도 응용할 수 있습니다.

줄별 텍스트가 필요할 때 - prepareWithSegments + layoutWithLines

높이만이 아니라, 각 줄에 어떤 텍스트가 들어가는지까지 알고 싶다면 이 API가 더 적합합니다.

js
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처럼 줄별로 직접 그려야 하는 환경에서 특히 유용합니다.

js
result.lines.forEach((line, i) => {
  ctx.fillText(line.text, 0, i * 24);
});

문자열 할당을 최소화하면서 줄 범위만 순회하고 싶다면 walkLineRanges도 사용할 수 있습니다.

js
let maxLineWidth = 0;
 
walkLineRanges(prepared, 400, (line) => {
  maxLineWidth = Math.max(maxLineWidth, line.width);
});

줄마다 폭이 다를 때 - layoutNextLine

모든 줄의 폭이 동일하지 않은 레이아웃도 가능합니다. 예를 들어 이미지나 플로팅 요소를 피해 텍스트가 흐르는 상황입니다.

js
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을 읽어가며 높이를 재는 방식보다 더 단순한 흐름으로 처리할 수 있습니다.

브라우저에서 텍스트의 높이를 알려면 DOM에 물어봐야 했다. element.offsetHeight, getBoundingClientRect(), getComputedStyle() — 이 함수들은 정확한 값을 반환하기 위해 브라우저의 전체 레이아웃 파이프라인을 동기적으로 실행한다. 이것이 forced synchronous layout이다. 텍스트 블록이 10개면 10번, 100개면 100번, 1000개면 1000번의 레이아웃 재계산이 연쇄적으로 발생한다. Chrome DevTools의 Performance 패널에서 보면 보라색 Layout 블록이 폭포처럼 쏟아지는 것을 볼 수 있다. 이 방식은 1996년 CSS 1.0 이후로 근본적으로 바뀌지 않았다.
핵심 통찰은 단순하다: 글자의 폭을 Canvas.measureText()로 한 번만 측정해두면, 이후의 줄 바꿈과 높이 계산은 순수 산술이라는 것이다. prepare() 함수가 텍스트를 분석하고 각 세그먼트의 폭을 측정해서 캐시한다. 이후 layout() 함수는 캐시된 폭 값의 덧셈과 비교만으로 줄 바꿈을 결정하고 높이를 반환한다. DOM을 전혀 읽지 않으므로 reflow가 발생하지 않는다. 500개 텍스트 블록의 높이를 0.1ms 안에 계산한다. DOM 측정 대비 약 300배 빠르다.
CSS에서 height: auto로는 transition이 작동하지 않는다. 애니메이션하려면 구체적인 px 값이 필요하다. 기존에는 숨겨진 DOM 요소를 만들어서 scrollHeight를 읽거나, max-height을 넉넉한 값으로 잡는 해킹을 했다. 이 아코디언은 다르다. 각 항목의 텍스트를 Pretext의 prepare() + layout()에 넣으면, DOM에 렌더링하기 전에 정확한 높이를 알 수 있다. 그 높이를 CSS height 속성에 직접 넣어서 transition을 적용한다. 브라우저 창 크기를 바꿔보면 모든 항목의 높이가 Pretext를 통해 자동으로 재계산되는 것을 볼 수 있다. DOM 측정은 한 번도 일어나지 않는다.
영어, 한국어, 일본어, 중국어, 아랍어, 히브리어, 태국어, 미얀마어, 그리고 이모지(ZWJ 시퀀스 포함)까지 지원한다. 내부적으로 Intl.Segmenter API를 사용하여 유니코드 표준에 따라 텍스트를 분할한다. 일본어/중국어의 금칙(禁則) 처리 — 예를 들어 마침표(。)나 닫는 괄호(」)가 줄 머리에 오지 않도록 — 도 자동으로 처리된다. 아랍어와 히브리어 같은 RTL 언어는 Unicode Bidirectional Algorithm(UAX #9)으로 방향을 계산한다. 이 모든 것이 prepare() 안에서 한 번에 처리되고, layout()은 여전히 순수 산술만 수행한다.
Canvas.measureText()는 DOM Layout 엔진을 전혀 거치지 않는다. 브라우저의 폰트 셰이핑 엔진(Skia, CoreText 등)에 직접 접근해서 주어진 폰트와 크기에서 텍스트가 차지하는 폭을 계산한다. DOM 트리와 완전히 독립적이므로 Layout Reflow가 발생하지 않는다. 심지어 Web Worker에서도 호출할 수 있다(OffscreenCanvas). Pretext의 핵심 통찰은 이 함수의 결과를 캐시하면 DOM의 레이아웃 엔진이 하는 일을 순수 산술로 재현할 수 있다는 것이다.
텍스트가 많고 레이아웃이 동적으로 바뀌는 곳에서 빛난다. 채팅 앱에서 수천 개 메시지의 높이를 가상화 리스트에 전달할 때, 에디터에서 텍스트 박스 크기를 실시간으로 계산할 때, 아코디언이나 콜랩스 컴포넌트에서 애니메이션 높이를 알아야 할 때, 반응형 레이아웃에서 컨테이너 크기가 바뀔 때마다 텍스트의 높이를 재계산해야 할 때. Canvas나 WebGL에서 텍스트를 렌더링할 때도 유용하다 — DOM 없이 줄 바꿈 좌표를 알 수 있으므로.
Cheng Lou는 React 코어 팀 출신으로, 스프링 물리 기반 애니메이션 라이브러리 react-motion(21,700+ 스타)과 OCaml-to-JS 컴파일러 ReasonML/ReScript의 제작자다. 현재 Midjourney에서 UI 전체를 Bun 위에서 운영하고 있다. 그의 주장: CSS 스펙의 80%는 userland에서 텍스트를 더 잘 제어할 수 있었다면 필요 없었을 것이다. Pretext는 이 비전의 첫 번째 실증이다. 출시 4일 만에 GitHub 스타 16,000개를 넘겼다. 개발 과정에서 Claude Code와 Codex를 활용한 AI 검증 루프를 사용했다.
컨테이너 폭: 0px · 브라우저 창 크기를 바꿔보면 높이가 자동으로 재계산된다 (DOM 측정 없이)

예제 2 - ASCII 그림판

선을 그으면 ASCII 파티클이 붙고, Reset하면 떨어지는 예제입니다.

Pretext 버전은 prepareWithSegments() + layoutWithLines()를 사용하고, DOM 버전은 숨겨진 <div>textContent를 넣은 뒤 offsetHeight를 읽는 방식으로 동작합니다.

0 ptsbuild 0μsreflows: 0
PRETEXT
그려보세요
0 ptsbuild 0μsreflows: 0
DOM
그려보세요

여기서 build 시간은 포인터가 움직일 때마다 동기적으로 실행되는 build() 함수의 소요 시간을 의미합니다.

  • Pretext 버전: prepareWithSegments() + layoutWithLines()
  • DOM 버전: 숨겨진 <div>에 텍스트를 넣고 offsetHeight 읽기

이 시간이 길어질수록 포인터 이벤트 처리도 밀리게 됩니다.


실제 Performance 프로파일

위 그림판에서 양쪽 모두 500 파티클까지 그린 뒤, Chrome DevTools Performance 탭으로 프로파일링한 결과입니다.

지표PretextDOM배수
Layout 이벤트629회8,656회13.8x
Layout 총 시간67ms497ms7.4x
Style Recalc77ms363ms4.7x
포인터 이벤트 평균0.12ms0.76ms6.3x

(이 수치는 특정 데모와 특정 브라우저 환경에서의 결과입니다. DOM 측정과 산술 기반 계산이 얼마나 차이가 있는지 정도로 가볍게 보시는 편이 적절할 것 같아요!)

요약하면 흐름은 대략 이렇습니다.

text
Pretext:  포인터 이동 → layout() 중심 계산 → 빠르게 종료
DOM:      포인터 이동 → textContent 쓰기 → offsetHeight 읽기 → style/layout 계산 가능성 → 종료

어디에 적용해볼 수 있을까

상황기존 방식Pretext 적용 시 기대 효과
가상화 리스트렌더링 후 높이 측정렌더링 전에 높이를 계산해 스크롤 점프를 줄이기 쉬움
아코디언 애니메이션scrollHeight 읽기사전 계산된 높이로 더 단순하게 제어 가능
textarea 자동 높이입력마다 scrollHeight 측정입력마다 계산 기반으로 높이 예측 가능
반응형 리사이즈모든 블록 재측정준비된 데이터를 바탕으로 빠르게 재계산 가능
CLS 방지높이를 사후 보정높이를 미리 계산해 시프트를 줄이기 쉬움
Canvas / SVG 텍스트줄 바꿈 직접 구현줄별 결과를 재사용 가능한 형태로 얻을 수 있음

물론 모든 경우에 무조건 Pretext가 정답은 아닙니다. 텍스트 수가 적고, 측정이 드물고, DOM을 이미 충분히 쓰고 있다면 오히려 기존 방식이 더 단순할 수도 있을 것 같습니다. 하지만 텍스트 측정이 빈번하고 반복적이라면 Pretext가 좋은 선택지가 될 수 있을 것 같아요.


마무리하며

깊게 보지는 못했지만, 해당 라이브러리는 텍스트 레이아웃 문제를 DOM 측정에서 분리해내려는 시도인 것 같다는 생각이 들었습니다. 그리고 이 발상은 텍스트가 실시간으로 생성되고 수정되며 스트리밍되는 UI가 많아지는 지금 시점에서 꽤 의미 있게 느껴지는데요, AI 기반 인터페이스처럼 텍스트 길이와 배치가 계속 바뀌는 환경에서 DOM을 계속 읽어가며 맞추는 방식보다 사전 준비 + 빠른 재계산이라는 접근이 꽤나 유용할 것 같습니다.

구현 내부를 따라가 보면 Intl.Segmenter, bidi 처리, 브라우저별 차이 보정, 캐시 전략 등 흥미로운 주제가 많이 엮여 있어서, 조금 더 공부해보면 좋을 것 같다는 생각을 하며 글을 마무리 합니다.


레퍼런스