§ 0한 줄 요약
두 방법은 “어느 축의 여유를 쓰는가”로 갈린다. 나머지 차이는 모두 여기서 파생된다.
“아래위에 빈 자리 있잖아.
박스를 세로로 키우고 주변을 조금씩 옮기자.”
width = 그대로 ← 핵심
줄 수 = 그대로
text height = 그대로 (실측값)
container height ↑ 가능
y 좌표 변경 가능
새 줄바꿈을 예측하지 않는다. 그래서 안전하지만, 세로 여유가 없으면 못 푼다.
“아래위 공간이 부족하네.
옆 공간을 써서 텍스트 줄 수를 줄이자.”
text width ↑ ← 핵심
container width ↑
줄 수 ↓
text height ↓ 가능
x / y 좌표 변경 가능
줄바꿈을 예측해야 한다. 그래서 강력하지만, 예측이 틀릴 수 있어 렌더 재검증이 붙는다.
§ 1박스가 두 개다 — 텍스트 bbox ≠ 컨테이너
이 문제에서 가장 먼저 분리해야 하는 개념. 둘을 하나로 보면 알고리즘이 성립하지 않는다.
- 실제 글자가 차지하는 영역
- 폭이 바뀌면 줄바꿈이 바뀐다
- 줄 수가 바뀌면 높이가 바뀐다 →
h = n(w) · λ
- 텍스트를 담는 배경 도형/박스
- 텍스트는 컨테이너 밖으로 나가면 안 된다
- 필요하면 컨테이너도 함께 조정한다
알고리즘은 텍스트와 컨테이너를 독립된 두 요소로 보지 않는다. 언제나 한 쌍(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이 하는 일 전부가 보인다.
Method 1이 하는 일 — 5줄
- 텍스트 width를 유지한다 → 줄바꿈이 고정된다 → 예측이 필요 없다
- 현재 렌더된 실측 텍스트 높이를 그대로 쓴다
- 컨테이너 높이를 필요한 만큼 증가시킨다 (hug)
- 주변 여백을 회수·재분배한다
- 필요하면 주변 요소의 y 좌표를 최소한만 이동한다
§ 4여백 대수와 Abacus는 다른 질문에 답한다
둘이 비슷해 보이지만 담당 질문이 다르다. 구현에서 두 모듈로 분리하는 게 맞다.
“남은 공간을 gap / margin에 어떻게 배분할까?”
총 남은 여백 220px
→ 55 / 55 / 55 / 55
입력: 요소 크기 합, 페이지 크기 · 출력: 각 gap의 크기
1D 스택이면 나눗셈 한 번. 축 경로(X-path / Y-path)를 따라 균등 분배.
“크기가 정해졌을 때, 원래 위치에서 가장 덜 움직이면서 겹치지 않는 좌표는?”
min Σ wᵢ(vᵢ − dᵢ)²
s.t. uᵢ + gap ≤ vⱼ
입력: 크기 + 원본 좌표 + 분리 제약 · 출력: 최종 좌표
정렬은 등식 제약 xi = xj로 넣으면 블록이 묶여 함께 움직인다 → 정렬 보존이 공짜.
순서는 여백 대수로 목표 좌표(di)를 만들고 → Abacus로 법제화(legalize). 반대로 하면
Abacus가 만든 좌표를 여백 대수가 다시 깨뜨린다.
§ 5공용 엔진 Abacus PlaceRow를 직접 돌려보기
“최소 변위”가 왜 단순 밀어내기보다 나은지는 숫자로 보면 바로 납득된다. 슬라이더로 B를 키워보자.
단순 밀어내기는 오른쪽 요소만 계속 밀린다. 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(); // 겹치면 앞으로 흡수하고 재계산
}
}
max-displacement 제약(“원래 자리에서 이 이상 벗어나지 마라”).
우리 문제는 오버플로우가 세로에서 나고 가로 위치가 정렬 의미를 담으므로 세로 축으로 bias를 걸어야 한다.
§ 6Method 2 폭을 넓혀 줄 수를 줄이기
먼저 오해 하나를 지우자. “텍스트 높이가 작아진다”는 fontSize를 줄인다는 뜻이 아니다.
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는 “가로에서 세로를 빌린다”.
§ 7Minimal Configuration — 후보를 K개로 줄이기
줄 수는 폭에 대해 부드럽게 감소하지 않는다. 계단처럼 변한다. 그래서 모든 폭을 후보로 볼 필요가 없다.
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이 결정되면 선택은 단순한 필터 + 최선 선택이 된다. 후보 중 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) // 줄 수 최소 = 높이 최소
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 width —
opsz가변축, 정수 포인트 양자화 - 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 좌표 | 변경 가능 | 변경 가능 |
| 줄바꿈 예측 | 없음 | 필요 |
| 정확성 | 실측 기반 | 예측 포함 |
| 해결력 | 안전하지만 제한적 | 더 강력 |
| 핵심 보조 알고리즘 | 여백 대수 + Abacus | Minimal 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 1로 width 고정 → 줄바꿈 고정 → 실측 데이터만 사용해서 얼마나 해결되는지 확인한다.
그래도 세로 공간이 부족한 케이스에만 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을 한 변수에 몰아넣어 “요소가 한쪽으로 치우쳐 한쪽에 큰 여백” 증상이 수학적 산물로 재현된다.
② 키우기는 국소적, 줄이기는 전역적. “키우기+밀어내기”와 “축소로 여백 회수”를 별도 스테이지로 분리하고, 후자는 전자가 페이지를 불균형하게 남긴 경우에만 실행한다.
③ 실현불가는 명시적으로 검출해서 신호로 올린다. 조용히 폰트 축소로 떨어지지 않는다.