Skip to main content

Command Palette

Search for a command to run...

Pretext: 브라우저의 텍스트 레이아웃 엔진을 JavaScript로 재구현한다는 것

프론트엔드 개발자라면 아는 고통

Published
7 min read
T

Software engineer for web tech. Interested in sustainable growth as software engineer.

"이 텍스트가 이 너비 안에서 몇 줄이 되는지"를 JavaScript로 알아내 본 적 있으신가요? 채팅 메시지의 높이를 미리 계산해서 가상 스크롤을 구현하거나, 텍스트가 넘치는지 판단해서 말줄임을 걸거나, 멀티라인 텍스트의 정확한 높이로 레이아웃 시프트를 방지하거나. 이런 작업을 해보신 분이라면 그 고통을 아실 겁니다.

방법은 하나뿐이에요. 브라우저한테 물어보는 거죠.

element.textContent = text
element.style.width = containerWidth + 'px'
const height = element.getBoundingClientRect().height // 여기서 reflow 발생

문제는 getBoundingClientRect()를 호출하는 순간 브라우저가 전체 문서의 레이아웃을 다시 계산한다는 점입니다. "layout reflow"라고 부르는, 웹에서 가장 비싼 연산 중 하나예요.

텍스트 하나를 측정하려고 전체 페이지를 다시 그립니다. 10개를 측정하면 10번 다시 그려요. 채팅 앱에서 메시지 100개를 화면에 뿌리려면? 100번입니다. 메시지마다 offsetHeight를 읽는 루프 안에서, 브라우저는 매번 "이전에 바뀐 게 있으니까 레이아웃 전체를 다시 계산해야겠다"고 판단하거든요.

그래서 개발자들이 온갖 꼼수를 씁니다. 측정을 모아서 한 번에 하거나(batching), 높이를 대충 추정하거나, 한 번 측정한 값을 캐싱하거나. 전부 깨지기 쉽고, 코드가 복잡해지고, 컴포넌트 경계를 망가뜨려요.

Pretext는 브라우저한테 안 물어봅니다

Pretext는 Cheng Lou(react-motion, ReasonML)가 만든 텍스트 레이아웃 라이브러리입니다. 접근이 근본적으로 달라요.

브라우저가 내부적으로 하는 텍스트 레이아웃 과정을 JavaScript로 재구현했습니다.

브라우저가 텍스트를 화면에 배치할 때 실제로 하는 일을 분해하면 이렇습니다:

  1. 공백 정규화 — CSS white-space: normal 규칙에 따라 연속 공백을 하나로 합치고, 앞뒤 공백을 제거

  2. 단어 분리 — 언어별로 다른 규칙으로 텍스트를 세그먼트로 쪼갬

  3. 너비 측정 — 각 세그먼트의 픽셀 너비를 계산

  4. 줄바꿈 결정 — 세그먼트 너비를 누적하다 컨테이너를 넘으면 줄바꿈

  5. 예외 처리 — trailing whitespace는 넘쳐도 줄바꿈하지 않고, 단어가 컨테이너보다 넓으면 글자 단위로 쪼갬

pretext는 이 다섯 단계를 전부 JavaScript로 구현합니다. 하나씩 코드와 대조해 볼게요.

1단계: 공백 정규화

CSS white-space: normal에서 브라우저는 탭, 개행, 연속 공백을 전부 하나의 공백으로 합칩니다. pretext의 normalizeWhitespaceNormal()이 정확히 이 동작을 복제해요.

// src/analysis.ts
export function normalizeWhitespaceNormal(text: string): string {
  let normalized = text.replace(collapsibleWhitespaceRunRe, ' ')
  if (normalized.charCodeAt(0) === 0x20) {
    normalized = normalized.slice(1)
  }
  if (normalized.length > 0 && normalized.charCodeAt(normalized.length - 1) === 0x20) {
    normalized = normalized.slice(0, -1)
  }
  return normalized
}

연속 공백을 하나로 합치고, 앞뒤 공백을 제거합니다. 브라우저의 공백 처리 규칙과 동일해요.

2단계: 세그먼트 분리 — 여기가 어렵습니다

영어는 공백으로 단어를 나누면 되지만, 세상의 모든 언어가 그렇지는 않습니다.

  • 한국어/중국어/일본어: 글자 단위로 줄바꿈이 가능하지만, 금칙처리(kinsoku)가 있습니다. "。"이나 "?"는 줄 시작에 올 수 없고, 여는 괄호는 줄 끝에 올 수 없어요.

  • 태국어/라오어: 공백 없이 이어 씁니다. 단어 경계를 알려면 사전이 필요해요.

  • 아랍어: 오른쪽에서 왼쪽으로 씁니다(RTL). 구두점이 공백 없이 붙기도 하고요.

pretext는 Intl.Segmenter로 언어별 단어 분리를 처리하고, 그 위에 CJK 금칙처리를 직접 구현합니다:

// src/analysis.ts — CJK 줄 시작 금지 문자
export const kinsokuStart = new Set([
  '\uFF0C', // ,
  '\uFF0E', // .
  '\uFF01', // !
  '\uFF1A', // :
  '\uFF1B', // ;
  '\uFF1F', // ?
  // ...
])

CJK 텍스트는 글자 단위로 쪼개되, "。"가 다음 줄 시작에 오지 않도록 앞 글자와 병합해요:

// src/layout.ts — CJK grapheme 분리 + 금칙처리 병합
if (segKind === 'text' && segMetrics.containsCJK) {
  for (const gs of graphemeSegmenter.segment(segText)) {
    const grapheme = gs.segment
    if (kinsokuStart.has(grapheme)) {
      unitText += grapheme  // 앞 단위에 병합 → 줄 시작 방지
      continue
    }
    // ...
  }
}

브라우저가 내부에서 하는 금칙처리를 JS로 동일하게 복제한 것입니다.

3단계: 너비 측정 — Canvas가 핵심입니다

각 세그먼트의 픽셀 너비를 알아야 줄바꿈 위치를 결정할 수 있겠죠. 여기서 DOM 대신 Canvas API의 measureText()를 씁니다:

// src/measurement.ts
export function getSegmentMetrics(seg: string, cache: Map<string, SegmentMetrics>): SegmentMetrics {
  let metrics = cache.get(seg)
  if (metrics === undefined) {
    const ctx = getMeasureContext()
    metrics = {
      width: ctx.measureText(seg).width,  // Canvas API — reflow 없음
      containsCJK: isCJK(seg),
    }
    cache.set(seg, metrics)  // 같은 세그먼트는 다시 측정하지 않음
  }
  return metrics
}

canvas.measureText()getBoundingClientRect()와 달리 DOM reflow를 일으키지 않습니다. Canvas는 독립된 측정 API이기 때문이에요. 그리고 결과를 Map<font, Map<segment, metrics>> 캐시에 저장하므로, "hello"라는 단어가 100번 나와도 측정은 한 번뿐입니다.

다만 이모지는 Canvas와 DOM 사이에 너비 차이가 있어요(Chrome/Firefox, macOS, font size < 24px). pretext는 이 차이를 자동으로 감지해서 보정합니다:

// src/measurement.ts — 이모지 보정 (폰트당 최초 1회, DOM read 1회)
const canvasW = ctx.measureText('😀').width
// ...DOM에 span을 넣어 실제 너비 측정...
const domW = span.getBoundingClientRect().width
correction = canvasW - domW  // 이 차이만큼 보정

이것이 pretext가 DOM을 건드리는 거의 유일한 순간입니다. 폰트당 1회, 캐시되고요.

4단계: 줄바꿈 — 순수 산술

세그먼트 너비가 전부 숫자 배열로 준비되면, 줄바꿈은 그냥 덧셈과 비교입니다:

// src/line-break.ts — layout()의 핵심 루프
for (let i = 0; i < widths.length; i++) {
  const w = widths[i]!
  const kind = kinds[i]!

  const newW = lineW + w                          // 덧셈
  if (newW > maxWidth + lineFitEpsilon) {          // 비교 → 넘치면 줄바꿈
    if (isSimpleCollapsibleSpace(kind)) continue   // trailing space는 무시 (CSS 동작)
    lineW = 0
    placeOnFreshLine(i)
    continue
  }
  lineW = newW                                     // 누적
}

이 루프에는 DOM 접근이 없습니다. Canvas 호출도 없어요. 문자열 연산도 없고요. 숫자 배열을 순회하며 더하고 비교하는 게 전부입니다.

단어가 컨테이너보다 넓으면(overflow-wrap: break-word) 미리 쪼개 둔 grapheme 너비 배열로 글자 단위 줄바꿈을 합니다. 이것도 같은 덧셈 루프예요.

두 단계로 나눈 이유

pretext의 API는 명확하게 두 단계로 나뉩니다:

// Phase 1: 텍스트 최초 등장 시 1회 — 분석 + 측정
const prepared = prepare(text, font)

// Phase 2: 리사이즈마다 — 순수 산술
const { lineCount, height } = layout(prepared, maxWidth, lineHeight)

prepare()는 비교적 비쌉니다. Intl.Segmenter로 텍스트를 분리하고, Canvas로 세그먼트마다 너비를 측정하거든요. 하지만 한 번만 하면 됩니다. 결과인 PreparedText너비에 독립적이에요. 같은 텍스트를 300px에서 배치하든 600px에서 배치하든, prepare()를 다시 호출할 필요가 없습니다.

layout()은 극도로 쌉니다. 파일 주석에 ~0.0002ms per text block이라고 적혀 있어요. 숫자 배열의 덧셈이 전부이니 당연하죠.

채팅 앱에서 메시지 500개의 높이를 구하는 상황을 비교하면:

전통 방식 pretext
측정 getBoundingClientRect() x 500 canvas.measureText() x 세그먼트 수 (캐시)
계산 브라우저 레이아웃 엔진 x 500 배열 덧셈 x 500
Reflow 500회 0회
시간 ~30ms+ ~0.1ms

500개 블록 기준 300배 이상 차이입니다. 글자 그대로 "불공정한 비교"인데, 그게 포인트예요. 아키텍처를 바꾸면 비교 자체가 불공정해질 정도로 빨라집니다.

정확도: 7,680개 조합에서 diff 0

"그래서 결과가 정확한가요?"가 당연한 다음 질문이겠죠.

pretext의 리포에는 accuracy/ 폴더가 있습니다. Chrome, Safari, Firefox 각각에 대해 4개 폰트 x 8개 폰트 사이즈 x 8개 컨테이너 너비 x 다국어 테스트 텍스트 = 7,680개 조합을 브라우저 실제 렌더링과 비교해요.

{
  "font": "\"Helvetica Neue\", Helvetica, Arial, sans-serif",
  "fontSize": 12, "lineHeight": 14, "width": 400,
  "actual": 120,
  "predicted": 120,
  "diff": 0
}

이 수치가 가능한 이유가 있습니다. Cheng Lou는 AI 에이전트(Claude Code, Codex)에게 브라우저의 실제 렌더링 결과를 ground truth로 보여주고, "이거랑 똑같이 계산하는 코드를 만들어"라고 시킨 뒤, 모든 너비에서 비교하고, 틀린 부분을 알려주고, 다시 고치게 하는 루프를 수 주간 돌렸어요. 리포에 CLAUDE.md(Claude Code용)와 AGENTS.md(Codex용)가 나란히 있는 것이 그 증거입니다.

이게 풀어주는 것들

"텍스트 높이를 DOM 없이 안다"는 것이 왜 대단한지, 구체적으로 풀리는 문제들이 있습니다.

가상화(Virtualization): 채팅 앱에서 메시지 10,000개를 스크롤할 때, 화면에 보이는 메시지만 렌더링합니다. 이를 위해 각 메시지의 높이를 알아야 하는데, 지금까지는 렌더링해서 재거나 추정했어요. pretext로 DOM 없이 정확한 높이를 계산할 수 있습니다.

채팅 버블 Shrinkwrap: 멀티라인 텍스트에서 "가장 넓은 줄의 너비"를 CSS로는 알 방법이 없습니다. width: fit-content는 줄바꿈 전 전체 너비를 반환할 뿐이에요. pretext는 각 줄의 실제 너비를 산술로 계산하므로, 가장 넓은 줄에 딱 맞는 버블을 만들 수 있습니다.

레이아웃 시프트 방지: 텍스트가 로딩되면서 페이지가 덜컹거리는 현상이요. 높이를 미리 계산해서 공간을 잡아두면 해결됩니다.

유저랜드 레이아웃: CSS가 지원하지 않는 레이아웃 — 텍스트가 이미지를 감싸며 흐르는 잡지 스타일, 장애물을 피하는 다단 레이아웃 — 을 JavaScript로 구현할 수 있습니다. pretext의 layoutNextLine() API는 줄마다 다른 너비를 받을 수 있어서, 불규칙한 컨테이너 형태에 텍스트를 흘릴 수 있어요.

핵심 인사이트

pretext가 증명한 것은 "Canvas measureText가 빠르다"는 기술적 사실이 아닙니다.

브라우저 레이아웃 엔진의 결과가 필요할 때, 그 엔진을 호출하는 것만이 유일한 방법은 아니라는 거예요. 엔진이 하는 일을 이해하고, 동일한 결과를 내는 더 가벼운 경로를 만들 수 있습니다. 측정은 Canvas로 한 번, 계산은 산술로, DOM 쓰기는 최종 결과만 한 번에. 이 패턴은 텍스트에만 국한되지 않아요.

프론트엔드에서 offsetWidth, scrollHeight, getBoundingClientRect()를 루프 안에서 호출하고 있는 모든 곳이 같은 구조적 문제를 안고 있습니다. "측정은 미리, 쓰기는 한 번에"라는 원칙을 적용할 수 있는 곳이 텍스트 말고도 분명 있을 겁니다.