---
title: "UniText 는 이모지 폰트를 빌드에 넣지 않고 OS 에서 빌려 쓴다"
description: "컬러 이모지 폰트 하나가 10 MB 를 넘어 빌드에 넣기 부담스럽습니다. UniText 는 이모지를 폰트 고르기 전에 표시해 두고 실행 중에 OS 의 이모지 폰트를 찾아 그려, 빌드 크기 대신 플랫폼마다 다른 그림과 메모리를 치릅니다."
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-emoji/
---
```csharp
uniText.Text = "좋아요 \U0001F600";   // 패키지에 이모지 폰트가 없는데 컬러 그림이 나온다
```

UniText 패키지에 든 폰트는 라틴, 아랍어, 히브리어뿐입니다. 컬러 이모지 폰트는 하나가 10 MB 를 넘기 때문입니다.

UniText 는 **이모지 폰트를 빌드에 넣지 않고, 실행 중에 그 기기의 OS 가 가진 이모지 폰트를 찾아 그립니다.** Windows 는 Segoe UI Emoji, macOS 는 Apple Color Emoji, 안드로이드는 그 기기의 폰트입니다. 빌드 크기를 아끼는 대신 플랫폼마다 그림이 다르고, 실행 중 메모리를 씁니다. 기준은 1.0.0 브랜치의 커밋 `2087eff` 입니다.

## 이모지가 보통 글자와 다른 점 셋

이모지를 그리는 일이 따로 떼어 다뤄지는 이유는 셋입니다.

**글자 번호 여러 개가 그림 하나입니다.** 가족 이모지 👨‍👩‍👧‍👦 는 사람 넷과 그 사이를 잇는 보이지 않는 글자(ZWJ, U+200D) 셋, 모두 일곱 개의 글자 번호로 적습니다. 국기는 지역 문자 둘, 피부색은 사람 뒤에 색 글자 하나가 붙습니다. 이렇게 화면에서 한 글자로 보이는 묶음을 그래핌 클러스터라고 부르고, 이모지인지는 묶음 전체를 보고 판정해야 합니다.

**색이 있습니다.** 보통 글리프는 윤곽선 하나라 거리값(SDF)으로 굽고 색은 셰이더가 칠합니다. 이모지는 그림 자체에 색이 있어서 이 방식을 쓸 수 없고, 색을 담는 아틀라스가 따로 필요합니다.

**폰트 안에 그림을 넣는 방식이 여럿입니다.** 표준이 하나로 모이지 못했습니다.

| 방식 | 폰트 안에 든 것 | 쓰는 곳 |
| --- | --- | --- |
| 비트맵 (CBDT, sbix) | 정해진 크기로 미리 그려 둔 PNG 그림 | Android 의 Noto Color Emoji, Apple Color Emoji |
| 벡터 (COLR) | 색마다 나눈 윤곽선 여러 겹과 색 정보 | Windows 의 Segoe UI Emoji, 새 Noto Color Emoji |

비트맵 방식은 그림을 꺼내 오면 끝입니다. 벡터 방식은 윤곽선마다 색이나 그라디언트를 채워 직접 그려야 합니다. COLR 의 새 판(COLRv1)은 "이 윤곽선을 이 그라디언트로 칠하고, 그 위에 이것을 돌려서 얹는다" 는 식의 그리기 지시가 나무 모양으로 들어 있습니다.

## 이모지는 폰트를 찾기 전에 -1 로 표시된다

셰이핑하기 전에 텍스트를 폰트가 같은 구간(런)으로 잘라야 하고, 그러려면 글자마다 어느 폰트로 그릴지 정해야 합니다. 보통 글자는 폰트 스택을 앞에서부터 뒤져 그 글자가 든 폰트를 찾습니다.

이모지는 이 과정을 건너뜁니다. 이모지로 판정된 묶음은 폰트를 찾아보지 않고 "이모지 폰트" 를 뜻하는 상수 `-1` 을 받습니다. 실제 OS 폰트로 바뀌는 것은 나중에 한 번입니다. 폰트 스택에 이모지 폰트가 없어도 되는 이유입니다.

```csharp
var len = end - start;

if (len == 1)
{
    // U+2000 미만은 이모지일 수 없다. 검사 없이 폰트 스택으로 간다
    if ((uint)cp < 0x2000)
        return fp.FindFontForCodepoint(cp);

    // 코드포인트 하나짜리 이모지면 폰트를 찾지 않고 -1 을 준다
    if (EmojiFont.IsAvailable && IsSingleCodepointEmoji(cp))
        return EmojiFont.FontId;

    return fp.FindFontForCodepoint(cp);
}

// 코드포인트 여러 개가 한 덩어리면 시퀀스 전체로 판정한다
if (EmojiFont.IsAvailable && EmojiSequenceClassifier.IsEmojiCluster(cluster))
    return EmojiFont.FontId;

return fp.FindFontForCodepoint(cluster[0]);
```

| 클러스터 | 지나는 길 | 받는 폰트 ID |
| --- | --- | --- |
| `A` (U+0041) | U+2000 미만이라 첫 분기에서 끝 | 라틴 폰트의 해시 |
| `가` (U+AC00) | 문턱보다 커서 이모지인지 한 번 묻고, 아님 | 한글 폰트의 해시 |
| 웃는 얼굴 (U+1F600) | 코드포인트 하나짜리 이모지로 판정 | -1 |
| 가족 (코드포인트 7개) | 시퀀스 판정기가 이모지로 판정 | -1 |
| 국기 문자 하나 (U+1F1F0) | 둘이 짝을 지어야 국기라 이모지가 아님 | 일반 폰트 조회 |

번호가 U+2000 보다 작은 글자(라틴, 키릴, 아랍, 히브리)는 이모지일 수 없어서 비교 한 번으로 빠져나갑니다. 가족 이모지처럼 여러 번호가 이어진 것은 먼저 한 묶음으로 묶인 뒤 묶음 전체로 판정되고, 국기 문자는 둘씩 짝지어집니다.

## 안드로이드는 네 단계로 고르고 하나만 검증한다

`-1` 을 실제 폰트로 바꾸려면 그 기기에서 이모지 폰트 파일을 찾아야 합니다. 플랫폼마다 길이 다릅니다.

| 플랫폼 | 이모지 폰트를 얻는 길 |
| --- | --- |
| Windows, macOS, Linux | 정해진 경로의 폰트 파일을 FreeType 으로 연다 |
| Android | 아래 네 단계로 파일을 찾아 FreeType 으로 연다 |
| iOS | 파일을 열지 못한다. OS 의 글자 그리기 API(Core Text)에 그려 달라고 한다 |
| WebGL | 파일 시스템이 없다. 브라우저의 캔버스에 그려 달라고 한다 |

iOS 가 다른 이유는 둘입니다. 앱이 시스템 폰트 파일을 직접 읽지 못하게 막혀 있고, Apple 의 이모지 폰트는 FreeType 이 풀지 못하는 방식으로 압축돼 있습니다. 그래서 글리프를 고르는 것부터 그리는 것까지 OS 에 맡기고 결과 그림만 받아 옵니다. WebGL 도 같은 이유로 브라우저에 맡기고, HarfBuzz 셰이핑도 건너뜁니다.

안드로이드는 제조사마다 폰트와 경로가 달라 네 단계를 차례로 내려갑니다.

1. OS 의 폰트 설정 파일(`fonts.xml`)에서 이모지용으로 표시된 폰트(`und-Zsye`)를 찾습니다.
2. API 29 이상이면 OS 에 폰트 목록을 물어 삼성, 화웨이, Noto 순으로 고릅니다.
3. 알려진 제조사 경로를 하나씩 확인합니다.
4. 폰트 폴더를 뒤집니다.

그리고 고른 파일을 FreeType 으로 열어 색이 있는 폰트인지, 웃는 얼굴(U+1F600) 글리프가 있는지 확인합니다.

```csharp
// 1. OS 폰트 설정 파일의 und-Zsye 패밀리
var xml = ParseAndroidFontsXml();
if (xml != null)
    return xml;

// 2. SystemFonts 열거. API 29 미만이면 null
var api = GetEmojiFromSystemFontsApi();
if (api != null)
    return api;

// 3. 하드코딩한 제조사 경로
foreach (var p in vendorPaths)
{
    if (File.Exists(p))
        return p;
}

// 4. 폰트 폴더 스캔
return Scan("/system/fonts/") ?? Scan("/product/fonts/");

// GetDefaultEmojiFont: 돌려받은 경로 하나만 검증한다
if (!ValidateEmojiFont(path))
    return null;                       // 실패해도 차순위로 돌아가지 않는다

return path;
```

| 기기 | 경로를 찾은 단계 | 검증 | 결과 |
| --- | --- | --- | --- |
| 설정 파일에 `und-Zsye` 가 있음 | 1 | 통과 | `NotoColorEmoji.ttf` |
| API 28, 설정 파일에 없음 | 3 | 통과 | `NotoColorEmoji.ttf` |
| 2 에서 고른 제조사 파일에 컬러 플래그가 없음 | 2 | 실패 | 이모지 폰트 없음 |

네 단계는 후보의 순위를 매길 뿐이고, 검증은 마지막에 한 번만 합니다.

검증은 최종 후보 하나에만 걸리고, 실패하면 다음 후보로 돌아가지 않습니다. 같은 기기에 멀쩡한 Noto 폰트가 있어도 이모지가 꺼집니다.

### 이렇게 쓴다

안드로이드에서 이모지가 통째로 빠지면 기기 로그에서 아래 줄을 찾습니다. 함께 찍힌 경로가 검증에 실패한 파일입니다.

```text
[SystemEmojiFont] Font validation failed: ...
```

앱 쪽에서 경로를 바로잡을 공개 API 는 없습니다. 꼭 보여야 하는 자리는 스프라이트로 그리고, 나머지는 `EmojiFont.Disabled = true` 로 이모지 처리를 끄고 일반 폰트 스택에 맡깁니다.

어느 기기에서나 같은 그림이 필요한 자리에도 시스템 이모지를 쓰지 않습니다. OS 와 제조사마다, OS 를 올릴 때마다 그림이 달라집니다. `EmojiFont.CreateFromPath` 로 폰트를 직접 만들어도 `-1` 은 언제나 시스템 폰트로 바뀌어서, 직접 만든 폰트는 쓰이지 않았습니다. 그림이 고정돼야 하면 스프라이트로 그립니다.

## 비트맵이 있으면 비트맵, 없을 때만 COLRv1 로 그린다

폰트를 열었으면 글리프를 그림으로 만들어야 합니다. 앞에서 본 두 방식에 따라 길이 갈립니다.

- **폰트에 비트맵이 들어 있으면** FreeType 으로 가장 가까운 크기의 그림을 꺼내 옵니다.
- **벡터뿐이면** 그리기 지시를 하나씩 따라가며 직접 칠합니다. FreeType 은 지시를 읽어 주기만 하고 칠하지는 않아서, 칠하는 일은 2D 벡터 그리기 라이브러리 Blend2D 가 합니다. UniText 의 네이티브 바이너리에 Blend2D 가 함께 묶여 있는 이유입니다.

세 폰트에 모두 있는 이모지 200개를 구운 값입니다.

| 폰트 (경로) | 렌더 | 패킹 | 합계 | 아틀라스 장 수 |
| --- | --- | --- | --- | --- |
| Noto Color Emoji COLRv1 판 | 11.8 ms | 15.1 ms | 39.8 ms | 2 |
| Noto Color Emoji CBDT 판 (비트맵) | 10.4 ms | 2.0 ms | 25.1 ms | 1 |
| Apple Color Emoji (sbix 비트맵) | 10.3 ms | 3.1 ms | 36.5 ms | 1 |

그리는 시간은 벡터 쪽이 글리프당 15% 안팎 비쌀 뿐이고, 합계를 벌린 것은 아틀라스 두 장에 걸쳐 올린 시간입니다.

아틀라스는 색을 담아야 하니 거리값 1바이트가 아니라 픽셀당 4바이트(RGBA32)입니다. 2048 × 2048 한 장이 **16 MB** 입니다. 굽는 크기는 128 이고, 비트맵 폰트는 폰트에 든 크기 가운데 가장 가까운 것을 씁니다(Apple Color Emoji 는 126). 서로 다른 이모지 300개가 한 장에 들어갔습니다.

## 같은 폰트가 셰이퍼 캐시에 두 번 들어간다

HarfBuzz 로 셰이핑하려면 폰트 파일 바이트를 네이티브 메모리에 통째로 복사해 두어야 하고, UniText 는 그 사본을 캐시에 넣어 둡니다. 이모지 폰트는 이 캐시에 두 번 들어갑니다. 셰이핑하는 코드는 `-1` 이라는 열쇠로 찾고, 글리프 폭을 묻는 코드는 폰트 해시(이모지 폰트는 파일 길이)로 찾기 때문입니다. 열쇠가 다르니 같은 폰트인 줄 모르고 사본을 두 벌 만듭니다.

이모지 없는 문장에서 1 이던 캐시 항목이 이모지 문장 뒤 3 이 됐고, 늘어난 열쇠는 `-1` 과 `192123488`(Apple Color Emoji 의 파일 길이)이었습니다. macOS 에서 프로세스 메모리는 183 MB 인 폰트의 약 두 배, 386 ~ 399 MB 늘었습니다.

### 이렇게 쓴다

이모지를 처음 쓰는 순간의 메모리를 예산에 넣습니다. 폰트 사본 두 벌에 아틀라스 한 장 16 MB 가 더해집니다. 이모지 선택 창처럼 많이 보여 주는 화면은 장이 늘고, 구운 글리프는 그대로 남습니다.

- 그런 화면은 열기 전후로 프로파일러의 텍스처 메모리와 프로세스 메모리를 확인합니다.
- 폰트 사본은 `Shaper.ClearAllCaches()` 로 비울 수 있습니다. 이모지를 다시 셰이핑하면 다시 만들어집니다.

빌드에서는 셰이더도 확인합니다. 이모지 머티리얼은 실행 중에 `Shader.Find("UI/Default")` 로 셰이더를 이름으로 찾습니다. Project Settings 의 Graphics 에 있는 Always Included Shaders 에서 `UI/Default` 가 빠지면 머티리얼을 만들다 `ArgumentNullException` 이 나고, 그 예외가 `Text` 세터를 끊어 이모지가 아닌 글자까지 텍스트 전체가 사라졌습니다. 로그에 `Parameter name: shader` 가 찍히고 텍스트가 통째로 사라지면 이 목록부터 봅니다.

## 정리

UniText 는 **이모지 폰트를 빌드에 넣지 않고 OS 에서 빌려 씁니다.**

- 이모지로 판정된 묶음은 폰트를 찾지 않고 `-1` 을 받고, 나중에 OS 의 이모지 폰트로 바뀝니다.
- 안드로이드는 네 단계로 후보를 고르고 하나만 검증합니다. iOS 와 WebGL 은 폰트 파일을 열지 못해 OS 와 브라우저에 그려 달라고 합니다.
- 폰트에 비트맵이 있으면 꺼내 쓰고, 벡터뿐일 때만 Blend2D 로 직접 칠합니다.
- 대가는 플랫폼마다 다른 그림, 한 장 16 MB 인 아틀라스, 네이티브 메모리의 폰트 사본 두 벌, 이름으로 찾는 `UI/Default` 셰이더입니다.

그래서 같은 그림이 필요한 자리는 스프라이트로 그리고, 이모지를 많이 쓰는 화면은 메모리를 재 보고, 빌드에 `UI/Default` 셰이더가 들어가는지 확인합니다.