post-replace-relayout

Method 1 / Method 2 — 무엇이 다르고, 왜 순서가 중요한가

내용 대치 후 텍스트가 컨테이너를 넘칠 때 쓰는 두 가지 전략을 움직여 보면서 이해하기 위한 문서. 아래 데모의 줄 수·높이·후보 폭은 하드코딩이 아니라 이 페이지가 브라우저에서 실제로 측정한 값입니다.

§ 0한 줄 요약

두 방법은 “어느 축의 여유를 쓰는가”로 갈린다. 나머지 차이는 모두 여기서 파생된다.

Method 1 · 세로 공간 재분배

“아래위에 빈 자리 있잖아.
박스를 세로로 키우고 주변을 조금씩 옮기자.”

width          = 그대로   ← 핵심
줄 수           = 그대로
text height     = 그대로 (실측값)
container height ↑ 가능
y 좌표          변경 가능

새 줄바꿈을 예측하지 않는다. 그래서 안전하지만, 세로 여유가 없으면 못 푼다.

Method 2 · 가로 공간을 빌려 쓰기

“아래위 공간이 부족하네.
옆 공간을 써서 텍스트 줄 수를 줄이자.”

text width       ↑      ← 핵심
container width  ↑
줄 수             ↓
text height       ↓ 가능
x / y 좌표        변경 가능

줄바꿈을 예측해야 한다. 그래서 강력하지만, 예측이 틀릴 수 있어 렌더 재검증이 붙는다.

공통 엔진. 크기를 바꾸면 반드시 이웃과 부딪힌다. 그 뒤처리(겹침 제거 + 최소 이동)는 Abacus / VPSC가 두 방법 공용으로 담당한다. Method 1 전용이 아니다 — §5에서 직접 돌려볼 수 있다.

§ 1박스가 두 개다 — 텍스트 bbox ≠ 컨테이너

이 문제에서 가장 먼저 분리해야 하는 개념. 둘을 하나로 보면 알고리즘이 성립하지 않는다.

Text bounding box
  • 실제 글자가 차지하는 영역
  • 폭이 바뀌면 줄바꿈이 바뀐다
  • 줄 수가 바뀌면 높이가 바뀐다 → h = n(w) · λ
Container
  • 텍스트를 담는 배경 도형/박스
  • 텍스트는 컨테이너 밖으로 나가면 안 된다
  • 필요하면 컨테이너도 함께 조정한다
대치 전 → 대치 후, 무엇이 깨지는가 버튼으로 상태 전환
컨테이너 (실선) 텍스트 bbox (점선) 넘친 부분

알고리즘은 텍스트와 컨테이너를 독립된 두 요소로 보지 않는다. 언제나 한 쌍(pair)으로 다룬다 — “텍스트가 들어갈 최소 높이”가 컨테이너 높이의 하드 제약이 되는 형태로.

// 컨테이너 의미론의 인코딩 — 이 한 줄이 §3·§6의 출발점
hard:  h_container ≥ textHeight(w_container, fontScale)   // 넘치면 안 된다
soft:  w_container = w_orig   (strong)                    // 폭은 되도록 유지
soft:  gap_1 = gap_2 = …      (medium)                    // 여백은 되도록 균등
weak:  (x, y) = (x_orig, y_orig)                          // stay constraint = 최소 변위

§ 2절대좌표는 고정인가? — 아니다

두 방법 모두 원래 좌표를 가능한 한 지키지만, hard constraint로 못박지는 않는다.

✗ 잘못된 해석

“x/y는 반드시 여기 있어야 한다”

→ 크기를 키우는 순간 겹침이 생기고, 문제가 비실현가능(infeasible)해진다. 해가 없으니 결국 폰트를 70%까지 줄이는 최악의 폴백으로 떨어진다.

✓ 올바른 해석

“가능하면 여기 있고 싶다”

→ 원래 위치는 목적함수의 타깃값 di일 뿐. 제약을 지키면서 Σ wi(xi − di를 최소화한다.

정확한 표현은 이것 하나다.
“원래 위치를 가능한 한 보존하되, 충돌을 피하기 위해 최소한만 이동한다.”
고정이 꼭 필요한 요소(로고·핀 고정)는 좌표를 못박는 대신 가중치 wi를 아주 크게 주면 된다. 별도 코드 경로가 필요 없다는 게 이 형식화의 실무적 이점.

이 “최소 변위 재배치”를 담당하는 것이 Abacus / VPSC 계열이다. 목적함수가 정확히 “디자이너의 원본에서 최소로 벗어나기”이므로 우리 문제와 그대로 맞물린다.

§ 3Method 1 세로 공간 재분배 — 숫자 3개짜리 장난감 예제

페이지 높이 600px, 요소 3개. 5단계를 눌러가며 보면 Method 1이 하는 일 전부가 보인다.

600px 페이지 · A / B / C · B의 내용이 대치되었다 ← → 방향키로도 이동
요소 A / C 대치된 요소 B 겹침 원래 위치

Method 1이 하는 일 — 5줄

  1. 텍스트 width를 유지한다 → 줄바꿈이 고정된다 → 예측이 필요 없다
  2. 현재 렌더된 실측 텍스트 높이를 그대로 쓴다
  3. 컨테이너 높이를 필요한 만큼 증가시킨다 (hug)
  4. 주변 여백을 회수·재분배한다
  5. 필요하면 주변 요소의 y 좌표를 최소한만 이동한다
Step 4가 알려주는 것. 요소 높이 합이 280 → 380px으로 늘었지만 페이지는 600px이므로 남은 여백이 여전히 220px 있다. 즉 공간이 부족한 게 아니라 여백 분배가 문제였다. 이 진단이 Method 1이 성립하는 조건이고, 반대로 여기서 남은 여백이 음수로 나오면 그때가 Method 2로 넘어갈 신호다.

§ 4여백 대수와 Abacus는 다른 질문에 답한다

둘이 비슷해 보이지만 담당 질문이 다르다. 구현에서 두 모듈로 분리하는 게 맞다.

여백 대수 · 공간 분배

“남은 공간을 gap / margin에 어떻게 배분할까?”

총 남은 여백 220px
→ 55 / 55 / 55 / 55

입력: 요소 크기 합, 페이지 크기 · 출력: 각 gap의 크기
1D 스택이면 나눗셈 한 번. 축 경로(X-path / Y-path)를 따라 균등 분배.

Abacus / VPSC · 위치 결정 (legalization)

“크기가 정해졌을 때, 원래 위치에서 가장 덜 움직이면서 겹치지 않는 좌표는?”

min Σ wᵢ(vᵢ − dᵢ)²
s.t. uᵢ + gap ≤ vⱼ

입력: 크기 + 원본 좌표 + 분리 제약 · 출력: 최종 좌표
정렬은 등식 제약 xi = xj로 넣으면 블록이 묶여 함께 움직인다 → 정렬 보존이 공짜.

순서는 여백 대수로 목표 좌표(di)를 만들고 → Abacus로 법제화(legalize). 반대로 하면 Abacus가 만든 좌표를 여백 대수가 다시 깨뜨린다.

§ 5공용 엔진 Abacus PlaceRow를 직접 돌려보기

“최소 변위”가 왜 단순 밀어내기보다 나은지는 숫자로 보면 바로 납득된다. 슬라이더로 B를 키워보자.

1D 행 배치 · 트랙 0~600 · 박스 4개 (B가 커진다) C는 가중치 6 = 거의 핀 고정
80px
① 단순 밀어내기 (push-right)
② Abacus PlaceRow (최소 변위)
push-right 비용
0
Abacus 비용
0
개선
클러스터
원래(이상적) 위치 병합된 클러스터 = 함께 움직인다 비용 = Σ w·Δ²

단순 밀어내기는 오른쪽 요소만 계속 밀린다. Abacus는 겹치는 박스들을 클러스터로 병합하고 클러스터의 최적 위치를 가중평균으로 잡아, 왼쪽 요소도 조금 물러나게 해서 총 변위를 나눠 갖는다. solver 없이 좌→우 1패스, 선형시간에 정확해.

function placeRow(cells /* ideal 기준 정렬 */, xMin, xMax) {
  const clusters = [];
  for (const c of cells) {
    const last = clusters.at(-1);
    // 앞 클러스터와 안 겹치면 새 클러스터, 겹치면 흡수
    if (!last || last.x + last.w <= c.ideal) clusters.push(newCluster());
    addCell(clusters.at(-1), c);
    collapse(clusters, xMin, xMax);          // 병합 연쇄 해소
  }
  return clusters.flatMap(cl => layoutInside(cl));
}

function addCell(cl, c) {
  cl.cells.push(c);
  cl.wsum += c.weight;
  cl.q    += c.weight * (c.ideal - cl.w);    // ← cl.w = 클러스터 내 offset
  cl.w    += c.w;
  cl.x     = cl.q / cl.wsum;                 // ← 가중평균 = 이 클러스터의 최적 위치
}

function collapse(clusters, xMin, xMax) {
  for (;;) {
    const cl = clusters.at(-1);
    cl.x = clamp(cl.q / cl.wsum, xMin, xMax - cl.w);   // 페이지 경계 반영
    const prev = clusters.at(-2);
    if (!prev || prev.x + prev.w <= cl.x) break;       // 안 겹치면 확정
    merge(prev, cl); clusters.pop();                   // 겹치면 앞으로 흡수하고 재계산
  }
}
알아야 할 실패 모드. 축별 순차 해결은 심한 겹침에서 한 방향으로 크게 밀어 극단적 종횡비를 만든다. 대응: x/y 축 교대 + damping, 그리고 max-displacement 제약(“원래 자리에서 이 이상 벗어나지 마라”). 우리 문제는 오버플로우가 세로에서 나고 가로 위치가 정렬 의미를 담으므로 세로 축으로 bias를 걸어야 한다.

§ 6Method 2 폭을 넓혀 줄 수를 줄이기

먼저 오해 하나를 지우자. “텍스트 높이가 작아진다”는 fontSize를 줄인다는 뜻이 아니다.

라이브 데모 · 폰트 크기는 고정, 폭만 바꾼다 실제 브라우저 줄바꿈으로 측정 중
150px
24px
줄 수 n(w)
텍스트 높이
px
λ (행 높이)
px
min-content
px
max-content
px

min-content = 가장 긴 분리불가 단어 폭 = 폭의 하드 하한. max-content = 한 줄로 다 들어가는 폭 = 폭을 더 키워도 의미 없는 상한.

슬라이더를 왼쪽↔오른쪽으로 움직여 보면 관계가 명확하다.

text width ↑
  → 줄바꿈 감소
    → line count ↓
      → text bounding box height ↓        (fontSize는 그대로!)

예: fontSize = 24px 유지
   width 120 → 3줄 → text height 90
   width 180 → 2줄 → text height 60
   width 300 → 1줄 → text height 30

즉 Method 2는 글씨를 작게 만드는 대신, 옆의 빈 공간을 써서 세로 공간을 확보하는 방식이다. Method 1이 “세로에서 세로를 빌린다”면 Method 2는 “가로에서 세로를 빌린다”.

Method 2의 핵심 난점. Method 1은 현재 렌더된 높이를 그대로 쓰므로 예측할 게 없다. 하지만 Method 2는 “폭을 몇 px까지 넓히면 3줄이 2줄이 될까?”다시 렌더하기 전에 답해야 한다. 이건 측정이 아니라 예측 문제다. → §7, §9

§ 7Minimal Configuration — 후보를 K개로 줄이기

줄 수는 폭에 대해 부드럽게 감소하지 않는다. 계단처럼 변한다. 그래서 모든 폭을 후보로 볼 필요가 없다.

n(w) 계단 곡선 · §6의 텍스트/폰트로 실시간 계산 막대·계단에 커서를 올려보세요
n(w) 계단 함수 최소구성 후보 wk 현재 폭

위 표의 “지배되는 구간”이 요점이다. 어떤 구간이 전부 3줄이라면 — 가령 150 ~ 219px가 전부 3줄이라면 — 3줄을 선택할 거면 그 구간의 가장 작은 폭만 보면 된다. 나머지 폭은 같은 높이에 폭만 낭비하므로 지배(dominated)된다. 남는 후보는:

configs = [(w₁,h₁), …, (w_K,h_K)]
  w_k = min{ w : n(w) ≤ k }     ← k줄이 되는 최소 폭 (폭에 대한 이분탐색)
  h_k = k · λ

h_min(w) = λ · min{ k : w ≥ w_k }   ← 근사 없이 정확
w₁ = max-content,  w_K ≥ min-content

이 후보 집합이 minimal configuration이고, 단어 W개인 텍스트는 최대 W개의 후보를 갖는다. 즉 우리 문제의 유일한 진짜 난점(계단함수)이 근사 없이 이산 선택으로 환원된다.

구현 규칙 두 개.
w_k폭에 대한 이분탐색으로 구한다 (Chromium의 text-wrap: balance와 동일한 방식 — 1 CSS px에서 정지).
② 후보 리스트는 컨테이너·폰트크기당 1회 사전계산하고, 탐색 루프 안에서 텍스트를 재측정하지 않는다. 글리프 advance가 선형이면 w_k(s) = s · w_k(1)로 리스케일하면 된다.
연속 완화만 쓰면 안 된다. n ≈ ⌈sA/(ηw)⌉ 같은 완화(직각쌍곡선)는 경계·pruning용으로만 쓰고, 반드시 진짜 최소구성으로 올림(round up)해야 한다. CSS가 min-content↔max-content를 선형 보간하는 것이 바로 오버플로우 버그의 원인 — 완화만 쓰면 높이를 과소예측한다.

§ 8Constraint Graph & Slack — 얼마나 넓힐 수 있나

Method 2에서 텍스트를 무한정 넓힐 수는 없다. 오른쪽이 비어 보여도 실제로는 제약이 걸려 있다.

옆에 공간이 있어 보여도 ⓐ 이웃이 핀 고정일 수 있고 ⓑ 다른 요소와 정렬 관계가 있고 ⓒ 페이지 경계를 넘을 수 있고 ⓓ 최소 gap을 유지해야 한다. 그래서 현재 위상과 제약을 그래프로 만들고 묻는다:

“A가 다른 제약을 깨지 않고 추가로 몇 px까지 커질 수 있는가?”

이 여유가 slack이다.

slack을 바꿔가며 §7의 후보 중 무엇이 살아남는지 보기 후보는 §6의 텍스트에서 실시간 계산됨
+130px
A 현재 폭 사용 가능한 slack 이웃 B (제약)

Slack이 결정되면 선택은 단순한 필터 + 최선 선택이 된다. 후보 중 wk ≤ wcur + slack인 것만 남기고, 그중 줄 수가 가장 적은(= 세로를 가장 많이 아끼는) 것을 고른다.

maxWidth = w_cur + horizontalSlack(A)

feasible = configs.filter(c => c.w <= maxWidth)
if (feasible.length === 0) → Method 2로도 못 푼다 → 열화 래더로
chosen  = feasible.at(-1)          // 줄 수 최소 = 높이 최소
slack 있는 컨테이너를 키우고, critical path에 있는 컨테이너를 옮긴다. 이게 “grow phase / move phase”보다 나은 조직 원리다. slack이 0인 요소가 critical path이고, 제약 그래프의 양수 사이클 / LP 비실현가능은 “이 템플릿은 양보 없이는 정말 불가능하다”는 깨끗한 신호 — 조용히 폰트 70%로 떨어지지 말 것.

Method 2에서도 Abacus가 필요한 이유

A의 폭을 키우면 옆 요소와 부딪힌다. [A]   [B][A A A][B]. 결국 x 좌표를 최소한만 이동해 다시 배치해야 하고, 줄 수가 줄어 text height가 바뀌면 y축도 다시 조정한다. Abacus는 Method 1 전용이 아니라 두 방법의 공용 배치 엔진이다 (§5의 데모를 그대로 x축에 쓰면 된다).

§ 9Method 2는 왜 렌더 재검증이 필수인가

“width = 220이면 2줄이 될 것이다”는 실제 렌더 전 예측이다. 예측은 틀릴 수 있다.

예측을 깨뜨리는 것들
  • font metrics — 폴백 폰트, hinting
  • letter spacing / word spacing
  • CJK 금칙 (line-break rules)
  • glyph widthopsz 가변축, 정수 포인트 양자화
  • rounding — subpixel vs CSS px
그래서 마지막 게이트
render(sheetJson)
→ measure()
→ assert(예측 줄 수 === 실제 줄 수)
→ assert(넘침 없음)
→ assert(신규 겹침 없음)

틀렸으면 다음 후보(wk−1)로 한 칸 물러나 재시도. Warm start를 쓰면 2회차는 거의 무료다.

고정점 루프로 생각하라. 파이프라인이 아니다. 높이가 방금 고른 폭에 의존하기 때문에 왕복이 불가피하다.
(a) 폭 고정 → (b) 줄바꿈 높이 측정 → (c) 컨테이너 세로 확장
→ (d) 최소 변위로 위치 법제화(Abacus/VPSC)
→ (e) 폭이 바뀌었거나 페이지를 넘쳤으면 (a)로
실무에서 2회로 수렴한다 (iOS Auto Layout의 2-pass와 같은 이유). Method 1은 (a)에서 폭을 안 바꾸므로 (e)가 거의 안 돈다 — 그래서 재검증이 선택적이다.

§ 10최종 비교표

항목 Method 1 Method 2
핵심세로 공간 재분배가로 공간을 써서 줄 수 감소
Text width고정증가 가능
Container width기본적으로 고정증가 가능
줄 수그대로감소 가능
Text height그대로 (실측)감소 가능
Container height증가 가능텍스트 결과에 맞춰 조정
x 좌표최대한 유지, 변경 가능변경 가능
y 좌표변경 가능변경 가능
줄바꿈 예측없음필요
정확성실측 기반예측 포함
해결력안전하지만 제한적더 강력
핵심 보조 알고리즘여백 대수 + AbacusMinimal Configuration + Constraint Graph + Abacus
렌더 재검증선택적사실상 필수
가장 쉽게 기억하는 문장

“아래위에 빈 자리 있잖아. 박스를 세로로 키우고 주변을 조금씩 옮기자.”

vertical container growth
+ gap redistribution
+ Abacus
가장 쉽게 기억하는 문장

“아래위 공간이 부족하네. 옆 공간을 써서 텍스트 줄 수를 줄이자.”

minimal configuration
+ constraint graph slack
+ width expansion
+ Abacus
+ render verification

§ 11구현할 때의 정신 모델

처음부터 width · height · font · lineCount · x · y · gap을 전부 동시에 최적화하지 않는다.

의사결정 흐름왼쪽이 싸고 정확 → 오른쪽이 강력하고 위험

먼저 Method 1width 고정 → 줄바꿈 고정 → 실측 데이터만 사용해서 얼마나 해결되는지 확인한다. 그래도 세로 공간이 부족한 케이스에만 Method 2로 넘어가 width 변경 → 줄바꿈 변경 → 예측 + 실제 렌더 검증을 쓴다.

정확하게 풀 수 있는 쉬운 문제를 먼저 풀고,
정말 필요한 경우에만 줄바꿈 예측이 들어가는 어려운 문제로 넘어간다.

파이프라인 스켈레톤

function replaceLayout(sheet: SheetJson): SheetJson {
  // 0. 사전계산 — 컨테이너·폰트크기당 1회. 루프 안에서 재측정 금지
  const configs = new Map(textNodes.map(n => [n.id, minimalConfigs(n)]));

  // 1. 위상 동결 — 관계 그래프는 절대 탐색 변수로 풀지 않는다 (다항성의 근원)
  const G = freezeRelations(sheet);   // 좌/우 · 상/하 · 정렬 그룹 · 최소 gap

  // 2. 실측 — 지금 렌더된 상태를 읽는다
  const m = measure(sheet);          // 줄 수, 텍스트 높이, 넘침량
  if (!m.hasOverflow) return sheet;

  // ── Method 1 : 폭 고정, 세로만 ─────────────────────────────
  let out = hugContainersVertically(sheet, m);      // h_i ← textHeight (실측)
  out = redistributeGaps(out, G, 'y');             // 여백 대수 (공간 분배)
  out = abacus(out, G, { axis: 'y', bias: 'y' });  // 최소 변위 법제화

  if (fits(out)) return out;                        // ✅ 예측 0회로 끝

  // ── Method 2 : 가로 slack을 빌려 줄 수를 줄인다 ───────────────
  for (const n of overflowing(out)) {
    const maxW  = n.w + horizontalSlack(G, n);      // constraint graph
    const cand  = configs.get(n.id)!.filter(c => c.w <= maxW);
    if (!cand.length) continue;                    // 이 노드는 Method 2로도 안 됨
    n.w = cand.at(-1)!.w;                          // 줄 수 최소 후보
    n.predictedLines = cand.at(-1)!.k;
  }
  out = abacus(out, G, { axis: 'x' });             // 가로 충돌 정리
  out = redistributeGaps(out, G, 'y');
  out = abacus(out, G, { axis: 'y', bias: 'y' });

  // 3. 렌더 재검증 — Method 2에서는 사실상 필수
  const v = render(out);
  if (v.lines !== predicted || v.hasOverflow) return fallbackLadder(out, v);
  //   열화 래더: 기하 → 여백 → 자간 → 수평 → 행간(≤20%) → 폰트(≥0.90)
  return out;
}
마지막 주의 세 개.
① 목적함수는 반드시 이차(QP). LP를 쓰면 simplex가 slack을 한 변수에 몰아넣어 “요소가 한쪽으로 치우쳐 한쪽에 큰 여백” 증상이 수학적 산물로 재현된다.
키우기는 국소적, 줄이기는 전역적. “키우기+밀어내기”와 “축소로 여백 회수”를 별도 스테이지로 분리하고, 후자는 전자가 페이지를 불균형하게 남긴 경우에만 실행한다.
③ 실현불가는 명시적으로 검출해서 신호로 올린다. 조용히 폰트 축소로 떨어지지 않는다.