KO
모든 글
엔지니어링 / Imposia

Imposia는 HTML을 어떻게 페이지로 만들까

HTML과 CSS가 브라우저 안에서 페이지로 나뉘고, 하나의 확정된 문서가 미리보기·인쇄·EPUB으로 이어지기까지의 과정을 따라갑니다.

이 글의 목차
  1. 보여 주는 프레임과 준비하는 프레임
  2. 원본이 확정된 페이지가 되기까지
  3. 페이지 경계는 브라우저가 알려 줍니다
  4. 첫 페이지네이션을 빠르게 만드는 것
  5. 수정하면 실제로 일어나는 일
  6. 한 세대, 여러 결과물
  7. 지원 범위도 설계의 일부입니다

미리보기에서는 24페이지였는데 인쇄하니 25페이지가 나옵니다. 같은 원본을 화면과 인쇄에서 따로 페이지로 나누면 생길 수 있는 일입니다. 이때 어느 쪽을 믿어야 할까요?

Imposia는 이 질문에 먼저 답을 정해 둡니다. 기준은 마지막으로 페이지네이션에 성공한 페이지 문서 하나입니다. Core는 이 문서를 브라우저의 iframe 하나에 계속 보관합니다. React와 Viewer는 이 페이지를 그대로 보여 주고, 인쇄도 이 페이지를 사용합니다. EPUB은 같은 세대에서 보관해 둔 의미 구조 원본으로 만듭니다.

이 글에서는 그 문서가 어떻게 만들어지고 어떻게 교체되는지 순서대로 살펴봅니다.

보여 주는 프레임과 준비하는 프레임

mountPageDocument()는 독자가 보는 canonical iframe을 한 번 만들고, 컨트롤러를 제거할 때까지 유지합니다. HTML이나 CSS가 바뀌면 Core는 이 iframe을 직접 고치지 않습니다. 화면 밖에 임시 staging iframe을 새로 만들고, 거기서 다음 문서를 처음부터 준비합니다. staging iframe은 준비용 작업 공간일 뿐이라서 미리보기나 인쇄에 쓰이지 않습니다.

갱신의 두 결과. 성공하면 준비 프레임에서 2세대를 만드는 동안 독자 프레임은 1세대를 보여 주고, 한 번의 동기 확정으로 2세대로 바뀝니다. 실패·취소되거나 새 갱신이 오면 준비 프레임을 버리고 1세대가 그대로 보입니다.
준비가 실패하거나 더 새로운 갱신이 도착하면 작업 프레임을 버리고 1세대를 그대로 보여줍니다.

새 문서를 준비하는 동안에도 독자는 기존 페이지를 계속 봅니다. 준비가 끝나면 두 가지 결과 중 하나가 됩니다.

  • 성공하면 canonical iframe의 내용을 한 번의 동기 작업으로 교체합니다.
  • 실패하거나, 취소되거나, 더 새로운 갱신에 밀려나면 staging iframe을 버립니다. 화면에는 마지막으로 성공한 문서가 그대로 남습니다.

어느 쪽이든 독자가 반쯤 만들어진 문서를 보는 일은 없습니다.

두 iframe 모두 작성된 스크립트를 실행하지 않고, 네트워크에 직접 요청하지도 않습니다. 이미지·글꼴·스타일시트는 호스트 앱이 넘겨준 assetResolver를 거쳐야만 들어옵니다. Core는 돌려받은 바이트를 검사한 뒤 자신이 만든 임시 Blob URL로만 연결합니다. 이 경계는 자산 가이드에서 자세히 다룹니다.

원본이 확정된 페이지가 되기까지

Core가 받는 입력은 호스트 앱이 만든 완전한 원본입니다. HTML 문자열, light DOM, 또는 여러 항목을 묶은 Publication 중 하나입니다. 실행 중인 React 트리를 몰래 캡처하지는 않습니다. 무엇을 담을지는 호스트가 정하고, 그것을 어떻게 페이지로 나눌지는 Core가 정합니다.

원본에서 확정된 페이지까지
  1. 01HTML + CSS
  2. 02정제 + 자산 확인
  3. 03측정 + 페이지 분할
  4. 04완성된 페이지 확정
  5. 05미리보기 + 인쇄

Core는 임시 작업 프레임에서 준비하고 측정합니다. 완성된 세대만 독자가 보는 영구 프레임으로 옮깁니다.

준비 과정은 크게 세 단계입니다.

1. 입력을 받아들일 수 있는 형태로 만듭니다. 입력 크기를 확인하고 원본을 정제합니다. 허용된 자산을 해석한 뒤 내용을 staging iframe에 복사하고 한 번 더 정제합니다. 그다음 이 내용을 기준으로 지원하는 @page 규칙과 퍼블리싱 CSS를 컴파일합니다. EPUB 내보내기 등에 쓸 의미 구조 스냅샷도 이때 따로 보관합니다.

2. 페이지로 나눕니다. 글꼴과 이미지가 준비되면 측정용 요소 안에서 흐름을 페이지 크기에 맞춰 나눕니다. 페이지마다 좌우 면, 경고, 화면에 보이는 텍스트를 원본 순서대로 기록합니다.

한 번의 배치로 끝나지 않는 경우도 있습니다. target-counter(page)가 대표적입니다. 참조 대상이 몇 페이지에 있는지는 페이지를 나눠 봐야 알 수 있는데, 그 번호를 넣고 나면 글자 폭이 바뀌어 배치가 다시 달라질 수 있습니다. Core는 결과가 더 이상 바뀌지 않을 때까지 다시 측정합니다. 결과가 순환하거나 허용 횟수를 넘기면, 흔들리는 결과를 그대로 받아들이지 않고 오류로 알립니다.

3. 확정합니다. 모든 단계가 성공해야만 Core는 staging의 head, body, 언어를 canonical iframe에 한 번의 동기 작업으로 옮깁니다. 이어서 새 세대 번호와 페이지 정보를 담은 불변 페이지 문서를 공개합니다. 이전 세대가 쓰던 자원은 이 확정이 끝난 뒤에야 해제합니다.

페이지 경계는 브라우저가 알려 줍니다

레이아웃 엔진은 브라우저입니다. Imposia가 레이아웃을 흉내 내어 따로 계산하지 않습니다. Core는 브라우저에 박스와 글자가 실제로 어디에 놓였는지 묻고, 현재 페이지가 넘치면 허용된 지점에서 내용을 다음 페이지로 옮기거나 나눕니다.

원본은 이 정도로 단순해도 됩니다.

<style>
  @page { size: A4; margin: 18mm; }
  h2 { break-before: page; }
  figure { break-inside: avoid; }
</style>
<h1>현장 기록</h1>
<p>본문은 페이지 영역을 따라 계속 이어집니다…</p>
<h2>둘째 날</h2>
<figure><img src="map.png" alt="현장 지도"></figure>

이 원본에서는 다음과 같이 나뉩니다.

  • h2는 항상 새 페이지에서 시작합니다.
  • figure는 한 페이지에 들어가면 중간에서 잘리지 않습니다.
  • 긴 문단은 실제로 측정한 줄 경계에서 나뉘고, 지원하는 widow·orphan 규칙을 지킵니다.

표나 그리드처럼 복잡한 레이아웃은 문서로 정해 둔 구조 안에서만 나눕니다. 그 범위를 벗어나면 요소를 쪼개지 않고 통째로 두거나, 무엇을 대신했는지 알려 주는 경고를 남깁니다. 모든 CSS 레이아웃을 정확히 분할한다고 주장하지 않습니다. 지원 범위는 호환성 표에 기능별로 정리되어 있습니다.

첫 페이지네이션을 빠르게 만드는 것

첫 렌더링에서도 원본 전체를 살펴 모든 페이지를 만들어야 합니다. 이 작업 자체를 건너뛸 수는 없습니다. 대신 그 안에서 반복되는 브라우저 레이아웃 계산을 줄입니다.

  1. 아직 배치하지 않은 내용은 계산에서 뺍니다. 현재 페이지를 측정하는 동안 남은 원본을 숨겨 둡니다. 요소 하나를 놓을 때마다 뒤에 남은 긴 원본 전체를 브라우저가 다시 배치하지 않게 하려는 것입니다.
  2. 완성된 페이지는 64개씩 묶습니다. 측정용 요소 안에서 확정된 페이지를 묶음 단위로 둡니다. 다음 페이지를 측정할 때 브라우저가 앞선 페이지를 하나하나 다시 훑지 않아도 됩니다.
  3. 단순한 요소는 한꺼번에 놓습니다. 나눔 규칙과 레이아웃 조건상 안전하다고 확인된 연속 요소는 모아서 한 번에 추가하고 측정합니다. 넘치면 들어갈 수 있는 지점까지 찾아 다시 확인합니다. 조건이 복잡한 내용은 요소별로 하나씩 처리합니다.

측정에 쓴 빌드에는 이 세 가지가 모두 들어 있습니다. 다만 아래 수치는 전체 결과이므로, 각 최적화가 얼마나 기여했는지나 다른 라이브러리가 왜 느린지까지 알려 주지는 않습니다.

저장소의 Chromium 비교 벤치마크에서 커밋 4177daa 기준으로 200페이지 문서의 첫 페이지네이션이 끝나기까지 걸린 시간은 다음과 같습니다.

라이브러리완료 시간
Imposia131ms
Paged.js 0.4.3849ms
Vivliostyle 2.45.22,154ms

2026년 9월 24일, Apple M4와 Chromium 149에서 같은 예제 하나를 일곱 번 측정한 중앙값입니다. 모든 페이지를 다 만든 시점까지 잰 값이며, 첫 화면이 뜨는 시간이나 임의의 문서에서의 성능을 뜻하지 않습니다.

수정하면 실제로 일어나는 일

호스트가 controller.update({ html })를 호출하면 Imposia는 세대 전체를 새로 준비합니다. 바뀌지 않은 페이지 DOM을 재사용하거나 수정된 페이지만 다시 나누는 방식이 아닙니다.

준비 도중 더 새로운 갱신이 들어오면 진행 중이던 작업을 중단합니다. 그 작업이 정리될 때까지 기다린 다음 새 작업을 시작합니다. 그동안 화면에는 이전에 확정된 페이지가 계속 남아 있습니다.

그래서 "단어 하나 수정" 벤치마크는 일부 페이지만 다시 그리는 증분 알고리즘의 속도가 아닙니다. 완전한 새 문서를 준비하고 확정하기까지의 시간입니다. 준비하는 동안에도 독자는 이전에 확정된 페이지를 계속 볼 수 있습니다.

페이지네이션은 메인 스레드에서 협력적으로 실행됩니다. 입력 크기에 비례하는 반복 작업은 기본 8ms마다 브라우저에 실행 기회를 넘겨, 긴 문서를 처리하는 중에도 브라우저가 다른 일을 할 수 있게 합니다. 그렇다고 Web Worker에서 도는 것은 아닙니다. 실제 DOM 레이아웃을 측정해야 하고 마지막 확정도 하나의 동기 작업이므로, 긴 작업이 전혀 생기지 않는다고 보장하지는 않습니다.

한 세대, 여러 결과물

사용처읽는 대상결과
Viewer와 Reactcanonical iframe확정된 페이지를 다시 나누지 않고 그대로 보여 줍니다.
Reader 탐색과 검색확정된 개요, 페이지 번호, 텍스트같은 페이지 순서 안의 위치로 이동합니다.
네이티브 인쇄확정된 페이지와 스타일을 복제한 임시 인쇄 호스트브라우저 인쇄 창이 열리고, PDF로 저장도 그 안에서 고릅니다.
리플로우형 EPUB확정된 세대의 의미 구조 원본전자책 리더에서 자유롭게 다시 흐르는 EPUB 3.3을 만듭니다.

EPUB은 페이지를 그대로 옮긴 고정 레이아웃 사본이 아닙니다. 페이지 틀과 머리글·바닥글 같은 페이지 전용 장식은 빠집니다. 인쇄도 PDF 바이트를 돌려주지 않습니다. print()는 브라우저의 기본 인쇄 절차를 열 뿐입니다. 결과물마다 성격은 다르지만, 화면의 페이지 분할 기준은 여전히 하나입니다.

지원 범위도 설계의 일부입니다

Imposia는 브라우저 전용이며 React를 우선 지원하는 퍼블리싱 런타임입니다. 페이지 구조의 기준 브라우저는 Chromium입니다. Firefox와 WebKit에서도 API와 수명 주기는 똑같이 동작하지만, 글꼴 메트릭이 달라 페이지 경계가 달라질 수 있습니다. CSS 지원은 기능별로 선언하고, 정확히 나눌 수 없는 경우에는 제한된 범위와 경고를 함께 밝힙니다.

Imposia가 약속하는 것은 선언된 범위 안에서 완성되고 일관된 페이지 문서입니다. 임의의 HTML/CSS와의 완전한 호환, Node 렌더러, PDF 바이트, 수정 후에도 변하지 않는 페이지 번호는 약속하지 않습니다. 약속의 범위가 분명하기 때문에 미리보기·탐색·진단·인쇄가 모두 같은 확정 페이지를 기준으로 삼을 수 있습니다.

직접 해 보려면 첫 페이지 만들기나 Playground에서 시작하세요. 측정 방법과 재현 절차는 벤치마크 문서에 있습니다.