---
title: "UniText 의 태그는 등록한 것만 읽힌다"
description: "UniText 에 <b> 를 넣으면 등록 전에는 글자 그대로 나옵니다. 태그는 찾는 규칙과 효과를 넣는 모디파이어를 한 쌍으로 등록해야 읽히고, 셰이핑 전에 지워진 뒤 코드포인트 번호로 효과가 붙습니다."
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-markup/
---
```csharp
uniText.Text = "<b>굵게</b>";   // 화면에 <b>굵게</b> 가 그대로 나온다
```

UniText 에는 기본으로 켜진 태그가 없습니다. 에러도 경고도 없이 `<` 가 평범한 글자로 찍힙니다.

UniText 의 마크업은 **"무엇을 찾을지"(파싱 규칙)와 "찾은 범위에 무엇을 할지"(모디파이어)를 한 쌍으로 등록한 것만** 태그로 읽습니다. 읽힌 태그는 셰이핑 전에 지워지고, 효과는 코드포인트 번호에 적어 두었다가 메시를 만들 때 붙습니다. 기준은 1.0.0 브랜치의 커밋 `2087eff` 입니다.

## 규칙과 모디파이어를 짝지어 등록한다

| 조각 | 계약 | 하는 일 |
| --- | --- | --- |
| 파싱 규칙 | `IParseRule` (태그형은 `TagParseRule`) | 원문에서 패턴을 찾아 범위와 파라미터를 만든다 |
| 모디파이어 | `BaseModifier` (글리프 효과는 `GlyphModifier<T>`) | 그 범위에 효과를 기록하고 메시 때 정점에 입힌다 |

둘을 나눈 덕에 문법만 갈아 끼울 수 있습니다. `<b>`, 직접 만든 마크다운 규칙의 `**굵게**`, BBCode 규칙의 `[b]` 가 모두 같은 `BoldModifier` 를 씁니다. 파서는 등록된 규칙에게만 물으니 쓰지 않는 태그의 파싱 비용도 없습니다. 대가는 등록을 빠뜨리면 마크업이 전부 평문이 된다는 것입니다.

<x-compare>
<script type="application/json">
{
  "label": "같은 문자열, 등록 여부만 다를 때",
  "tabs": [
    {
      "label": "등록하지 않았을 때",
      "code": "uniText.Text = \"<color=red>경고</color> 남은 시간 3초\";",
      "text": "파서에게 물어볼 규칙이 없어서 '<' 도 평범한 글자입니다. 화면에 <color=red>경고</color> 남은 시간 3초 가 그대로 나옵니다. 에러나 경고는 없습니다.",
      "tone": "bad"
    },
    {
      "label": "ColorParseRule + ColorModifier 등록",
      "code": "uniText.RegisterModifier(new ModRegister\n{\n    Rule = new ColorParseRule(),\n    Modifier = new ColorModifier()\n});\nuniText.Text = \"<color=red>경고</color> 남은 시간 3초\";",
      "text": "규칙이 <color=red> 와 </color> 를 찾아 지우고, 모디파이어가 '경고' 두 글자에 빨간색을 기록합니다. 셰이핑에 넘어가는 텍스트는 '경고 남은 시간 3초' 입니다.",
      "tone": "good"
    }
  ]
}
</script>
</x-compare>

내장 태그는 `<b>`, `<i>`, `<u>`, `<s>`, `<upper>`, `<color>`, `<size>`, `<gradient>`, `<cspace>`, `<line-height>`, `<line-spacing>`, `<ellipsis>`, `<link>`, `<obj/>` 이고, 등록은 인스펙터의 Mod Registers, 코드의 `RegisterModifier`, 여러 컴포넌트가 나눠 쓰는 `ModRegisterConfig` 에셋 셋 중 하나로 합니다.

## 태그는 셰이핑 전에 지워지고 번호가 옮겨진다

캔버스가 그리기 직전, 첫 패스보다 먼저 `AttributeParser` 가 태그를 찾아 지웁니다. 셰이핑은 태그가 있었다는 것을 모릅니다. 문제는 번호입니다. 규칙이 찾은 범위는 원문 번호인데, 효과는 태그가 빠진 텍스트에 걸어야 합니다. 파서는 원문을 한 번 훑으며 원문 번호에서 새 번호로 가는 표를 만들고, 지운 글자에는 `-1` 을 적습니다.

```csharp
// offset: 지금까지 지운 글자 수
for (var i = 0; i <= text.Length; i++)
{
    if (i >= removal.start && i < removal.end)
    {
        // 태그 글자다. 버리고 -1 을 적는다
        offset++;
        indexMap[i] = -1;
    }
    else
    {
        // 남는 글자다. 지운 수만큼 앞으로 당긴 번호를 적는다
        indexMap[i] = i - offset;
        clean += text[i];
    }
}

RemapSpanIndices(spans, indexMap);   // 규칙이 찾은 범위를 새 번호로 옮긴다
```

`a<b>b</b>c` 를 넣으면 표가 이렇게 채워집니다.

| 원문 번호 | 0 | 1~3 | 4 | 5~8 | 9 | 10 |
| --- | --- | --- | --- | --- | --- | --- |
| 글자 | `a` | `<b>` | `b` | `</b>` | `c` | 끝 |
| 새 번호 | 0 | -1 | 1 | -1 | 2 | 3 |

- 태그를 걷어 낸 텍스트는 `abc` 입니다.
- 굵게 칠할 범위는 원문 번호로 `[4, 5)` 였고, 표로 옮기면 `[1, 2)` 입니다. `abc` 의 가운데 `b` 가 정확히 걸립니다.
- 표의 칸 수는 원문 길이 + 1 입니다. 문자열 끝 위치(10)도 옮겨 적어야 범위의 끝을 바꿀 수 있기 때문입니다.

번호 변환은 한 번 더 있습니다. C# 의 `string` 은 글자를 16비트 `char` 로 담는데, 이모지처럼 번호가 큰 글자는 `char` 둘을 써서 하나를 적습니다(서로게이트 쌍). 이 표는 `char` 번호이고 파이프라인 뒤쪽은 글자 하나에 번호 하나인 코드포인트 번호를 쓰므로, 두 `char` 를 한 번호로 접는 두 번째 표를 거칩니다. 모디파이어가 받는 범위는 **태그를 걷어 낸 텍스트의 코드포인트 번호**입니다. 닫히지 않은 태그는 텍스트 끝을 닫는 위치로 잡힙니다.

## 효과는 번호에 적어 두었다가 메시 때 붙는다

모디파이어의 `Apply` 는 바로 그리지 않고 코드포인트마다 한 칸인 버퍼에 값을 적어 둡니다. 정점에 입히는 것은 한참 뒤 메시를 만들 때입니다. 셰이핑을 거치면 글자 수와 글리프 수가 달라지기 때문에, 글리프마다 "이 글리프는 원문의 몇 번째 글자에서 나왔다" 는 번호가 붙어 있습니다. 이 번호를 `cluster` 라고 부르고, 메시 생성기는 이 번호로 효과를 적어 둔 칸을 찾습니다.

<x-wiring>
<script type="application/json">
{
  "label": "<color=red> 하나가 정점 색이 되기까지",
  "width": 860,
  "height": 250,
  "nodes": [
    { "id": "rule", "title": "ColorParseRule", "sub": "<color=…> 를 찾는다", "x": 0, "y": 8, "w": 180, "h": 54 },
    { "id": "parser", "title": "AttributeParser", "sub": "태그를 지우고 번호를 옮긴다", "x": 340, "y": 8, "w": 180, "h": 54 },
    { "id": "mod", "title": "ColorModifier.Apply", "sub": "코드포인트 범위에 색", "x": 680, "y": 8, "w": 180, "h": 54 },
    { "id": "buf", "title": "속성 버퍼", "sub": "uint[], 코드포인트마다 한 칸", "x": 680, "y": 180, "w": 180, "h": 54 },
    { "id": "mesh", "title": "메시 생성", "sub": "쿼드를 찍을 때마다 OnGlyph", "x": 340, "y": 180, "w": 180, "h": 54 },
    { "id": "vert", "title": "정점 색", "sub": "쿼드의 네 꼭짓점", "x": 0, "y": 180, "w": 180, "h": 54 }
  ],
  "edges": [
    { "from": "rule", "to": "parser", "label": "여는 태그, 닫는 태그 위치", "bend": 0 },
    { "from": "parser", "to": "mod", "label": "Apply(start, end, \"red\")", "bend": 0 },
    { "from": "mod", "to": "buf", "label": "SetValueRange", "bend": 0 },
    { "from": "parser", "to": "mesh", "label": "태그 없는 텍스트 → 셰이핑 · 배치", "bend": 0 },
    { "from": "mesh", "to": "buf", "label": "cluster 번 칸을 읽는다", "bend": 0 },
    { "from": "mesh", "to": "vert", "label": "네 정점에 덮어쓴다", "bend": 0 }
  ],
  "steps": [
    { "edge": 0, "why": "규칙이 <color=red> 와 </color> 를 찾아 내용 구간과 태그 구간을 돌려줍니다." },
    { "edge": 1, "why": "파서가 내용 구간을 코드포인트 번호로 옮겨 모디파이어에 넘깁니다." },
    { "edge": 2, "why": "모디파이어는 그 범위의 칸마다 빨간색을 uint 하나로 묶어 적어 둡니다. 아직 그리는 것은 없습니다." },
    { "edge": 3, "why": "태그가 빠진 텍스트가 코드포인트 분해, 방향 분석, 셰이핑, 줄나누기, 배치를 차례로 지납니다." },
    { "edge": 4, "why": "글리프 하나를 쿼드로 찍을 때마다 OnGlyph 가 울리고, 모디파이어는 그 글리프의 cluster(원본 코드포인트 번호) 칸을 읽습니다. 0 이면 해당 없음입니다." },
    { "edge": 5, "why": "값이 있으면 방금 찍힌 네 정점의 색을 바꿉니다. 알파는 컴포넌트 기본색의 것을 씁니다." }
  ],
  "note": "파싱 시점에는 번호에 값을 적기만 하고, 정점은 메시를 만드는 순간 글리프의 cluster 로 다시 찾아갑니다."
}
</script>
</x-wiring>

그래서 합자로 글자 둘이 글리프 하나가 되든 방향 분석이 순서를 뒤집든 효과가 제자리를 찾습니다. 순서도 정해져 있습니다. 범위는 닫는 태그를 만날 때 목록에 쌓이고 `Apply` 는 역순으로 돌기 때문에, `<color=red>a<color=blue>b</color>c</color>` 에서 b 는 파랑, a 와 c 는 빨강입니다. 중첩한 같은 태그는 안쪽이 이깁니다.

## 주의할 점

### 1. 쓰는 태그를 전부 등록했는지 번역 문자열로 확인한다

**안 지키면 생기는 문제.** 등록하지 않은 태그는 에러 없이 글자로 나옵니다. 번역 쪽에서 `<size>` 를 쓰기 시작하면 그 언어 화면에만 `<size=120%>` 가 찍힙니다. 모디파이어 인스턴스 하나를 두 컴포넌트에 `RegisterModifier` 하면 소유권 검사에 걸려 `Debug.LogError` 가 납니다.

**이렇게 쓴다.** 쓰는 규칙과 모디파이어를 `ModRegisterConfig` 에셋 하나에 모아 프리팹마다 넣습니다. 에셋은 컴포넌트마다 복제되어 등록되므로 여러 컴포넌트가 같이 써도 됩니다(에디터에서 두 컴포넌트가 각자 제 색으로 그려지고 에러 0 건).

### 2. 닫는 태그를 빠뜨리지 않는다

**안 지키면 생기는 문제.** 닫히지 않은 태그는 텍스트 끝까지 적용됩니다. 닫는 태그가 빠진 번역 한 줄이 문장 전체를 빨갛게 만들고, 화면만 보고는 어느 문자열이 원인인지 알기 어렵습니다.

**이렇게 쓴다.** 번역 문자열 검사에 여는 태그와 닫는 태그의 짝 맞추기를 넣습니다. 프로그램이 강조를 넣는 경우라면 태그를 문자열로 잇지 말고 `RangeRule` 로 범위를 지정합니다.

### 3. 색 태그의 알파로 투명도를 조절하지 않는다

**안 지키면 생기는 문제.** `ColorModifier` 는 정점 색의 알파를 컴포넌트 기본색의 알파로 덮어씁니다. `<color=#FF000080>` 의 80 은 무시되어 불투명하게 나옵니다(컴포넌트 알파 1 에서 255, 0.5 에서 128).

**이렇게 쓴다.** 부분 투명이 필요하면 그 부분을 별도 UniText 로 나눠 컴포넌트 알파를 조절합니다. 글 전체 페이드는 컴포넌트 알파만 바꾸면 태그 색까지 따라옵니다.

### 4. 사용자 입력에 AutoOpenUrl 을 켜 두지 않는다

**안 지키면 생기는 문제.** `AutoOpenUrl` 이 켜진 `LinkModifier` 는 링크 값을 스킴 검사 없이 `Application.OpenURL` 에 넘깁니다. `<link=javascript:alert(1)>` 도 `file:///C:/Windows` 도 그대로 넘어갔습니다. 히트 테스트는 글리프 박스 밖이라도 가장 가까운 글리프 중심까지 20 이내면 그 글리프로 쳐서, 링크 옆 빈 곳을 눌러도 열립니다.

**이렇게 쓴다.** `AutoOpenUrl = false` 로 두고 `LinkClicked` 에서 스킴과 도메인을 확인한 뒤 엽니다. 채팅이나 닉네임을 그리는 컴포넌트에는 링크 규칙을 아예 등록하지 않습니다.

## 정리

UniText 의 태그는 **규칙과 모디파이어를 한 쌍으로 등록한 것만** 읽힙니다.

- 등록하지 않은 태그는 에러 없이 글자로 나옵니다. 대신 규칙만 바꾸면 태그, 마크다운, BBCode 가 같은 효과를 씁니다.
- 태그는 셰이핑 전에 지워지고, 범위는 표 두 장을 거쳐 태그 없는 텍스트의 코드포인트 번호로 옮겨집니다.
- 효과는 번호 칸에 적어 두었다가 메시 때 `cluster` 로 붙습니다. 중첩 태그는 안쪽이 이기고, 색 태그의 알파는 컴포넌트 알파로 덮입니다.