---
title: "UniText 는 줄을 그리디로 끊고 폰트 크기를 폭으로 바꿔 푼다"
description: "폰트 크기를 트윈할 때마다 다시 셰이핑하면 비쌉니다. UniText 는 줄을 되감기 지점 하나짜리 그리디로 끊고, 크기 변경은 가용 폭을 배율로 나누는 것으로 바꿔 셰이핑 없이 줄만 다시 끊습니다."
author: "유니얼"
pubDate: 2026-09-29
updatedDate: 2026-10-06
category: "game-dev/unity/libraries"
tags: ["Unity", "라이브러리", "UI", "uGUI"]
canonical: https://blog.unialgames.com/posts/unity-unitext-layout/
---
```csharp
label.FontSize = Mathf.Lerp(36, 72, t);   // 매 프레임 크기가 바뀐다
```

크기가 바뀌면 글자 폭이 바뀌고, 폭이 바뀌면 줄이 바뀝니다. 크기마다 셰이핑부터 다시 하면 버튼이 커지는 연출 하나가 매 프레임 HarfBuzz 를 부릅니다.

UniText 는 **줄을 되감기 지점 하나만 기억하는 그리디로 끊고, 폰트 크기 변경을 "상자를 줄이는 것" 으로 바꿔** 같은 셰이핑 결과 위에서 줄만 다시 끊습니다. 기준은 1.0.0 브랜치의 커밋 `2087eff` 입니다.

## 줄나누기에 들어오는 재료는 둘이다

줄을 나누는 일은 둘로 갈립니다. "어디서 끊어도 되나" 와 "그중 어디서 끊을까" 입니다.

**어디서 끊어도 되나** 는 글자만 보고 정해집니다. 유니코드는 글자마다 줄바꿈 성질을 정해 두었고, 그 성질로 글자 사이마다 셋 중 하나를 매기는 규칙(UAX #14)을 둡니다.

| 판정 | 뜻 | 예 |
| --- | --- | --- |
| 반드시 끊는다 | 여기서 줄이 바뀐다 | 줄바꿈 문자 뒤 |
| 끊어도 된다 | 폭이 모자라면 여기서 바꿔도 된다 | 공백 뒤, 한글과 한자의 글자 사이 |
| 끊으면 안 된다 | 여기서는 줄을 바꾸지 않는다 | 영어 단어의 글자 사이, 닫는 괄호 앞 |

이 판정은 상자 폭과 상관없어서 첫 패스가 미리 해 둡니다. 글자마다의 폭도 셰이핑 결과에서 뽑아 `cpWidths` 배열에 모아 둡니다.

그래서 줄나누기에 들어오는 것은 글자 사이마다의 판정과 글자마다의 폭, 둘입니다. 줄나누기는 이 둘과 상자 폭으로 **그중 어디서 끊을까** 만 정합니다. HarfBuzz 를 부르지 않습니다.

## 되감기 지점 하나만 기억하고 끊는다

`LineBreaker.WrapLines` 는 앞에서부터 글자 폭을 더하다가 상자 폭을 넘치면 **마지막으로 본 끊어도 되는 자리**로 되감습니다. 되감을 곳이 없으면 지금 글자 앞에서 강제로 자르고, 한 글자도 안 들어가면 넘친 채 둡니다. 들어갈 수 있는 데까지 채우고 끊는 이 방식을 그리디라고 부릅니다.

```csharp
for (cp = 0; cp < cpCount; cp++)
{
    lineWidth += cpWidths[cp];                 // 이 글자의 폭을 더한다

    while (lineWidth > maxWidth)               // 넘쳤다
    {
        if (lastBreak >= lineStart)
        {
            // 기억해 둔 끊어도 되는 자리로 되감아 줄을 만든다
            // 줄 = [lineStart..lastBreak]
            lineStart = lastBreak + 1;
            lineWidth -= widthAtLastBreak;     // 남은 폭을 다음 줄로 넘긴다
            lastBreak = -1;
        }
        else if (cp > lineStart)
        {
            // 되감을 곳이 없다. 지금 글자 앞에서 강제로 자른다
            // 줄 = [lineStart..cp-1]
            lineStart = cp;
            lineWidth = cpWidths[cp];
        }
        else
        {
            break;                             // 한 글자도 안 들어간다. 넘친 채 둔다
        }
    }

    if (breakAfter(cp) == Mandatory)
    {
        // 줄바꿈 문자다. 줄 = [lineStart..cp]
        lineWidth = 0;
    }
    else if (breakAfter(cp) == Optional)
    {
        lastBreak = cp;                        // 끊어도 되는 자리와
        widthAtLastBreak = lineWidth;          // 그때까지의 폭을 기억한다
    }
}
```

글자 폭이 모두 1 이고 상자 폭이 4 라고 하면 이렇게 끊깁니다.

- **`ab cd`.** 공백(2번) 뒤가 끊어도 되는 자리라 기억해 둡니다. `c` 까지 폭이 4 라 딱 맞고, `d` 에서 5 가 되어 넘칩니다. 공백 뒤로 되감아 첫 줄이 `ab ` 가 되고, `cd` 는 다음 줄로 갑니다.
- **`abcdef`.** 끊어도 되는 자리가 없습니다. `e` 에서 넘치면 되감을 곳이 없어 `e` 앞에서 강제로 자릅니다. 첫 줄이 `abcd` 가 되고 단어 중간에서 잘립니다.

기억하는 되감기 자리는 늘 하나입니다. 줄이 만들어지면 지우고 다음 끊어도 되는 자리를 기다립니다.

줄마다 채울 수 있는 만큼 채우므로 줄 길이가 들쭉날쭉할 수 있습니다. 문단 전체를 보고 줄 길이를 고르게 맞추는 방식도 있지만(조판 프로그램이 쓰는 Knuth-Plass), UniText 는 한 번 훑고 끝나고 할당이 없는 쪽을 골랐습니다. 비용이 숨은 곳이 하나 있습니다. 줄을 만들 때 런의 글리프를 처음부터 훑어 그 줄의 글리프를 찾아서, **런 하나가 여러 줄에 걸치면 줄 수만큼 다시 훑습니다.** 2,000자를 줄나누기 한 번 한 시간입니다.

| 문자열 | 런 수 | 줄 수 | 줄나누기 한 번 |
| --- | --- | --- | --- |
| 라틴만 | 1 | 20 | 0.143 ms |
| 라틴만 | 1 | 91 | 0.617 ms |
| 한국어와 라틴 번갈아 | 345 | 104 | 0.054 ms |

단일 런은 줄당 0.007 ms 로 시간이 줄 수에 정비례하고, 잘게 쪼개진 문자열은 줄이 늘어도 거의 늘지 않았습니다.

### 이렇게 쓴다

**넘치는 글은 직접 처리합니다.** 줄나누기는 한 글자도 안 들어가는 줄을 넘친 채 두고, 배치는 상자 밖으로 나간 줄도 그대로 그립니다. 번역으로 문장이 길어진 언어에서 글자가 버튼 밖으로 삐져나옵니다. 잘라야 하는 자리에는 말줄임을 넣는 `EllipsisModifier` 를 등록합니다. 크기를 줄여 맞출 자리에는 `AutoSize` 를 쓰되 말줄임을 같이 둡니다.

**공백 없이 쓰는 언어에는 끊을 자리를 넣어 줍니다.** 태국어, 라오어, 크메르어, 미얀마어는 단어 사이에 공백이 없습니다. 규칙은 이런 글을 사전으로 단어를 찾아 끊으라고 정하지만, 이 판에는 사전도 사전을 읽는 코드도 없습니다. 공백 없는 태국어 문장을 폭 300 에 넣자 네 줄 가운데 첫 줄 말고는 모두 단어 중간에서 시작했습니다. 번역 문자열의 단어 경계마다 폭 없는 공백(U+200B)을 넣으면 네 줄 모두 단어 경계에서 시작했습니다.

## 폰트 크기는 폭으로 바꿔 푼다

`cpWidths` 에 든 폭은 첫 패스에서 셰이핑한 크기 기준입니다. 폰트 크기가 두 배가 되면 글자 폭도 두 배가 됩니다. 이때 글자 폭을 전부 다시 구하는 대신 상자 폭을 반으로 줄여도 줄이 끊기는 자리는 같습니다. "글자가 두 배로 커졌다" 와 "상자가 반으로 줄었다" 는 줄나누기에게 같은 문제이기 때문입니다.

UniText 는 이렇게 풉니다. `cpWidths` 는 그대로 두고, 상자 폭을 `목표 크기 / 셰이핑 크기` 로 나눠 줄만 다시 끊습니다.

<x-calc>
<script type="application/json">
{
  "label": "폰트 크기를 바꾸면 줄나누기가 보는 폭",
  "inputs": [
    { "id": "w", "label": "컨테이너 폭", "options": [200, 300, 600], "default": 300, "suffix": "px" },
    { "id": "s", "label": "셰이핑 때 크기", "options": [36], "default": 36 },
    { "id": "f", "label": "지금 FontSize", "options": [18, 36, 54, 72], "default": 72 }
  ],
  "bars": [
    { "label": "glyphScale = FontSize / 셰이핑 크기", "expr": "f / s", "decimals": 2 },
    { "label": "줄나누기가 쓰는 최대 폭 = 폭 / glyphScale", "expr": "w / (f / s)", "unit": "셰이핑 단위", "accent": true, "decimals": 0 }
  ],
  "note": "cpWidths 는 셰이핑 때 크기 기준으로 고정되어 있습니다. FontSize 를 바꾸면 이 배열은 그대로 두고 오른쪽 폭만 바뀝니다."
}
</script>
</x-calc>

그래서 `FontSize` 는 전체 재구성이 아니라 레이아웃 재구성 플래그에 속하고, 크기를 매 프레임 바꿔도 HarfBuzz 호출은 0번입니다. 결과도 같습니다. 크기 72 로 셰이핑한 뒤 12 로 바꾼 컴포넌트와 처음부터 12 인 컴포넌트는 글리프 x 좌표 차가 최대 1.9e-6 이고 결과 크기(322.92 x 16.34)가 같았습니다. 대가가 하나 있습니다. 일부 폰트는 작은 크기에서 자간을 넓히는 것처럼 크기에 따라 글리프나 간격을 다르게 고릅니다(광학 크기). 셰이핑을 한 번만 하니 이런 기능은 반영되지 않습니다.

### 이렇게 쓴다

`AutoSize` 와 줄바꿈을 함께 켠 텍스트는 매 프레임 바꾸지 않습니다. 넘치면 맞는 크기를 찾느라 줄나누기를 최대 열 번 돌립니다. 300자 문단 한 번이 IL2CPP 에서 0.176 ms 였습니다.

- 타이머처럼 자주 바뀌는 숫자는 크기를 고정합니다.
- `AutoSize` 가 필요하면 최소와 최대 사이를 좁힙니다. 최대를 72 에서 40 으로 좁히자 열 번이 여덟 번, 0.108 ms 가 됐습니다.
- 한 줄이면 되는 텍스트는 줄바꿈을 끕니다. 크기를 계산 한 번으로 구합니다.

## 정렬은 문단의 시작과 끝을 기준으로 한다

줄이 정해지면 배치 단계(`TextLayout.Layout`)가 줄마다 글리프의 좌표를 매깁니다. 가로로 어디서 시작할지는 정렬 값과 그 줄이 속한 문단의 방향(`paragraphBaseLevel & 1`)으로 정합니다.

<x-compare>
<script type="application/json">
{
  "label": "폭 300 상자, 폭 120 줄의 시작 x",
  "tabs": [
    { "label": "LTR 줄 · Left", "text": "왼쪽에서 오른쪽 문단입니다. Left 는 말 그대로 왼쪽 끝에서 시작합니다.", "stats": [["isRtlLine", "false"], ["시작 x", "0"]] },
    { "label": "RTL 줄 · Left", "text": "문단 기본 레벨이 홀수인 줄입니다. 같은 Left 인데 오른쪽 끝에 붙습니다. Left 와 Right 는 물리적 좌우가 아니라 문단의 시작과 끝에 가깝습니다.", "stats": [["isRtlLine", "true"], ["시작 x", "300 - 120 = 180"]], "tone": "bad" },
    { "label": "Center", "text": "방향과 상관없이 남는 폭을 반으로 나눕니다.", "stats": [["isRtlLine", "상관없음"], ["시작 x", "(300 - 120) / 2 = 90"]] },
    { "label": "폭이 무한 · Center", "text": "상자 폭이 유한하지 않으면 정렬 계산 자체를 건너뛰고 x 를 0 으로 둡니다. 가운데 정렬을 줬는데 왼쪽에 붙어 보이는 원인입니다.", "stats": [["hasFiniteWidth", "false"], ["시작 x", "0"]], "tone": "bad" }
  ]
}
</script>
</x-compare>

`Left` 와 `Right` 는 물리적 좌우가 아니라 문단의 시작과 끝입니다. 패키지의 샘플 씬에서도 정렬이 Left 인 아랍어 문장이 오른쪽 여백 0 px 로 오른쪽에 붙어 그려졌습니다. 문장의 첫 글자가 아랍 문자라 문단 방향이 오른쪽에서 왼쪽이 됐기 때문입니다.

### 이렇게 쓴다

정렬 값은 "문단의 시작 쪽", "문단의 끝 쪽" 으로 읽습니다.

- 언어를 바꿀 때 정렬을 손으로 뒤집지 않습니다. 아랍어 UI 를 좌우로 뒤집으면서 정렬도 뒤집으면 두 번 뒤집혀 원래대로 돌아갑니다.
- 반드시 왼쪽에 붙어야 하는 숫자나 코드는 `BaseDirection` 을 왼쪽에서 오른쪽으로 고정합니다. `Auto` 로 두면 첫 글자에 따라 붙는 쪽이 바뀝니다.

## 정리

UniText 는 **줄을 그리디로 끊고, 폰트 크기 변경을 폭 변경으로 바꿔** 셰이핑 없이 풉니다.

- `WrapLines` 는 되감기 지점 하나만 기억합니다. 한 번 훑고 할당이 없지만, 긴 단일 런은 줄마다 글리프를 다시 훑습니다.
- 크기 변경은 `폭 / (목표 크기 / 셰이핑 크기)` 로 줄만 다시 끊고 HarfBuzz 를 부르지 않습니다.
- 정렬은 문단 방향 기준이라 오른쪽에서 왼쪽 줄의 `Left` 는 오른쪽에 붙습니다. 공백 없는 태국어 계열은 사전 없이 아무 데서나 끊깁니다.

그래서 넘치는 글에는 말줄임을 등록하고, 공백 없는 언어에는 폭 없는 공백을 넣고, 정렬은 언어를 바꿔도 손으로 뒤집지 않습니다.