[PART6.배열과 문자열 기본(11/14)] UTF-8 문자열 리터럴 "abc"u8 — 인코딩 변환과 힙 할당을 동시에 0으로
string은 UTF-16 / 일반 코드는 그대로 / u8 접미사는 컴파일 타임 UTF-8 인코딩 → ReadOnlySpan<byte> / 어셈블리 정적 데이터 섹션에 바이트 박힘 → 런타임 alloc 0 / 네트워크 패킷·파일 헤더 비교에서 Encoding.UTF8.GetBytes 대체 / 일반 텍스트 처리에는 사용 안 함
목차
1. 왜 string을 byte[]로 바꾸는 코드가 흔한가
게임 클라이언트가 서버와 패킷을 주고받을 때, 또는 세이브 파일의 매직 헤더를 확인할 때 — 거의 모든 네트워크 프로토콜과 파일 포맷은 UTF-8 바이트 스트림으로 정의되어 있습니다. C#의 string은 UTF-16(2바이트 단위) 시퀀스이라, 보낼 때마다 변환해야 합니다.
// ❌ 매 호출 — UTF-16 → UTF-8 변환 + 새 byte[] 할당
byte[] cmd = Encoding.UTF8.GetBytes("MOVE");
socket.Send(cmd);
이 한 줄이 매 프레임 호출되면 매 프레임 변환 비용 + GC alloc이 발생합니다. C# 11이 도입한 "MOVE"u8 리터럴은 이 문제를 컴파일 타임에 해결합니다.
// ✅ "MOVE"u8 — 컴파일 타임에 이미 UTF-8 바이트로 박혀 있음, alloc 0
ReadOnlySpan<byte> cmd = "MOVE"u8;
socket.Send(cmd);
이 글에서는 u8 리터럴이 IL에서 어떻게 처리되는지, 어떤 자리에서 써야 하고 어떤 자리에서는 쓰지 말아야 하는지를 다룹니다.
2. u8 접미사의 정체
컴파일 타임 UTF-8 인코딩
ReadOnlySpan<byte> a = "Hello"u8; // {0x48, 0x65, 0x6C, 0x6C, 0x6F}
ReadOnlySpan<byte> b = "한글"u8; // {0xED, 0x95, 0x9C, 0xEA, 0xB8, 0x80} (UTF-8 3바이트씩)
ReadOnlySpan<byte> c = "abc 123"u8;
- 접미사
u8은 문자열 리터럴에만 붙는다 (보간 X, 축자 X, 원시 X — C# 13에 일부 확장). - 결과 타입은
ReadOnlySpan<byte>— UTF-8 바이트 시퀀스. - 컴파일러가 컴파일 시점에 UTF-8 인코딩을 수행해 어셈블리 정적 데이터 섹션에 바이트 블록으로 박는다.
- 런타임에는 그 블록의 주소 + 길이만 가리키는
ReadOnlySpan<byte>struct를 만들 뿐 — 힙 할당 없음.
IL 비교 — Encoding.UTF8.GetBytes vs u8
public static byte[] OldWay() => Encoding.UTF8.GetBytes("MOVE");
public static ReadOnlySpan<byte> NewWay() => "MOVE"u8;
// OldWay — UTF-16 string 로드 + UTF-8 인코더로 변환 + 새 byte[] 할당
IL_0000: call class System.Text.Encoding System.Text.Encoding::get_UTF8()
IL_0005: ldstr "MOVE" // ← UTF-16 string 리터럴
IL_000a: callvirt instance uint8[] System.Text.Encoding::GetBytes(string)
// ← 새 byte[] 할당 + 변환
IL_000f: ret
// NewWay — 컴파일 타임 UTF-8 블록 주소 + Span struct (alloc 0!)
IL_0000: ldsflda valuetype '<PrivateImplementationDetails>'/'__StaticArrayInitTypeSize=5'
'8BD1DCB5...' // ← 정적 데이터 섹션의 UTF-8 바이트 블록 주소
IL_0005: ldc.i4.4 // 길이 4
IL_0006: newobj instance void ReadOnlySpan`1<uint8>::.ctor(void*, int32) // ← Span struct만 (스택)
IL_000b: ret
핵심 비교:
| 동작 | Encoding.UTF8.GetBytes("MOVE") |
"MOVE"u8 |
|---|---|---|
| UTF-16 string 객체 | 1개 (ldstr) |
0개 |
| 변환 비용 | 매 호출 변환 실행 | 0 (컴파일 타임 처리) |
새 byte[] 할당 |
1개 | 0 |
| 결과 타입 | byte[] (힙) |
ReadOnlySpan<byte> (스택) |
| 길이 4의 GC 부담 | 4 + 작은 헤더 = 작지만 매 호출 | 0 |

3. 가장 자주 쓰는 실전 패턴 — SequenceEqual
u8 리터럴의 베스트 매치는 바이트 시퀀스 비교입니다. 네트워크 패킷의 명령어, 파일 매직 헤더, 프로토콜 토큰 등.
public static bool IsMove(ReadOnlySpan<byte> payload)
=> payload.SequenceEqual("MOVE"u8);
public static bool IsAttack(ReadOnlySpan<byte> payload)
=> payload.SequenceEqual("ATTACK"u8);
IL_0000: ldarg.0 // payload
IL_0001: ldsflda ... '__StaticArrayInitTypeSize=5' ... // "MOVE" 정적 블록
IL_0006: ldc.i4.4
IL_0007: newobj instance void ReadOnlySpan`1<uint8>::.ctor(void*, int32)
IL_000c: call bool MemoryExtensions::SequenceEqual<uint8>(...) // 빠른 메모리 비교
SequenceEqual<byte>는 내부적으로 memcmp 수준의 빠른 메모리 비교를 사용해 매우 가볍습니다. 일반 string 비교가 UTF-16 → UTF-8 변환 + 동등 비교를 거치는 데 비해, u8 리터럴 비교는 바이트 단위 직접 비교라 한 단계가 통째로 빠집니다.
파일 매직 헤더 검증
public static bool IsRiff(ReadOnlySpan<byte> data) =>
data.Length >= 4 && data[..4].SequenceEqual("RIFF"u8);
public static bool IsZip(ReadOnlySpan<byte> data) =>
data.Length >= 2 && data[..2].SequenceEqual("PK"u8);
WAV·AVI 같은 RIFF 파일 헤더, ZIP 파일 헤더 등 고정된 바이트 시퀀스 검증에 그대로 들어맞습니다. 검증 로직 안에 byte[]를 만들지 않고 검증할 수 있어 핫패스 친화적입니다.
4. Span<byte>로 가공해야 한다면
u8 리터럴은 읽기 전용(ReadOnlySpan<byte>)이라 그 자체로는 수정 불가합니다. 가공이 필요하면 스택에 별도 버퍼를 잡고 복사합니다.
public static void ToUpper4()
{
ReadOnlySpan<byte> src = "abcd"u8;
Span<byte> buf = stackalloc byte[src.Length]; // 스택 할당, GC 부담 0
src.CopyTo(buf);
for (int i = 0; i < buf.Length; i++)
if (buf[i] >= (byte)'a' && buf[i] <= (byte)'z')
buf[i] = (byte)(buf[i] - 32);
// buf는 이제 ABCD
}
stackalloc byte[N] + CopyTo로 원본 변경 없이 가공 가능한 사본을 만듭니다. 이 패턴 전체에서 힙 할당이 없습니다 — 정적 데이터 + 스택만으로 처리.
자세한 stackalloc 사용법은 PART 6 #14에서 따로 다룹니다.
5. 일반 텍스트 처리에는 사용 안 함
u8 리터럴은 이진 데이터 전용입니다. 다음 자리에서는 절대 사용하지 마세요.
// ❌ UI 표시용
healthText.text = "HP"u8; // 컴파일 오류 — text는 string
// ❌ 사람이 읽는 메시지
Debug.Log("Hello"u8); // ReadOnlySpan<byte>는 ToString이 의미 없음
// ❌ 다국어 자원
string label = "안녕하세요"u8; // 컴파일 오류
이유:
- UI·로그·다국어 표시는 모두
string(UTF-16) 기반 — .NET BCL의 모든 텍스트 API가string을 기대. ToUpper·Replace·정규식 등 일반 문자열 메서드는ReadOnlySpan<byte>에 직접 적용 불가.- 한글·이모지 같은 멀티바이트 문자가 들어가면 byte 단위 인덱싱이 깨진다 — UTF-8은 가변 길이 인코딩이라 한 글자가 1~4바이트.
u8리터럴이 적절한 자리
- 네트워크 프로토콜의 고정 명령어·헤더
- 파일 포맷의 매직 시그니처
- JSON·HTTP의 키워드 비교 (
"Content-Type"u8등)- 네이티브(C/C++) 라이브러리에 넘길 UTF-8 문자열
이 외의 자리에는 그냥string을 쓰세요.
6. Unity 실전 활용
패턴 1 — 멀티플레이 패킷 명령어 비교
public class PacketHandler
{
public void OnReceive(ReadOnlySpan<byte> payload)
{
ReadOnlySpan<byte> cmd = payload[..4];
if (cmd.SequenceEqual("MOVE"u8)) HandleMove(payload[4..]);
else if (cmd.SequenceEqual("ATCK"u8)) HandleAttack(payload[4..]);
else if (cmd.SequenceEqual("CHAT"u8)) HandleChat(payload[4..]);
}
}
매 패킷 디스패치에서 byte[]나 string 임시 객체가 만들어지지 않습니다. 1초에 수백 패킷이 오는 멀티플레이 게임에서 GC 부담을 크게 줄입니다.
패턴 2 — 세이브 파일 매직 헤더
public class SaveLoader
{
private static readonly ReadOnlySpan<byte> MAGIC => "MYGM"u8; // 정적 프로퍼티
public bool IsValidSave(byte[] fileBytes)
{
ReadOnlySpan<byte> data = fileBytes;
return data.Length >= 4 && data[..4].SequenceEqual(MAGIC);
}
}
세이브 파일·에셋 번들의 시그니처 검증을 zero-alloc으로 처리합니다.
패턴 3 — 네이티브 플러그인 호출
[DllImport("MyNativeLib")]
public static extern int Native_Process(byte* utf8Data, int length);
public unsafe void Send(ReadOnlySpan<byte> data)
{
fixed (byte* ptr = data)
Native_Process(ptr, data.Length);
}
// 호출
Send("HELLO"u8); // 컴파일 타임 UTF-8 → 곧장 네이티브로
C++ 라이브러리는 거의 항상 const char* (UTF-8)을 기대합니다. u8 리터럴 + fixed 조합으로 마샬링 비용 없이 직접 전달할 수 있습니다.
Unity 모바일 GC 특수성
Unity 모바일은 IL2CPP(Intermediate Language to C++ — IL을 C++로 변환해 네이티브로 컴파일하는 빌드 백엔드)와 Boehm GC(보수적·정지형 가비지 컬렉터, 한 번 돌면 모든 매니지드 스레드를 멈춘다)의 조합이라 임시 byte[] 한 번이 즉시 GC 부담입니다. 멀티플레이 패킷 처리·세이브 검증 같은 빈도 높은 자리에 u8 리터럴이 들어가면 매 호출 alloc이 사라집니다.
7. 함정과 주의사항
함정 1 — 한글·멀티바이트 문자에서 길이 계산
ReadOnlySpan<byte> hi = "안녕"u8;
Console.WriteLine(hi.Length); // 6 ← 한글 한 글자가 UTF-8로 3바이트
Console.WriteLine("안녕".Length); // 2 ← string은 char(UTF-16) 단위
"안녕".Length는 2이지만 "안녕"u8.Length는 6입니다. u8은 바이트 길이, string.Length는 char(UTF-16 코드 단위) 길이. 사용자에게 보여 주는 길이가 필요하면 u8이 아닌 string 사용.
함정 2 — ReadOnlySpan<byte>는 클래스 필드 불가
public class Game
{
private ReadOnlySpan<byte> _magic = "MYGM"u8; // ❌ 컴파일 오류
}
Span<T>/ReadOnlySpan<T>는 ref struct(스택 한정)라 클래스 필드로 저장 불가. 정적 프로퍼티(static ReadOnlySpan<byte> M => "..."u8;) 또는 메서드 지역 변수로만 사용.
함정 3 — 동적 string에는 못 씀
string name = userInput;
ReadOnlySpan<byte> bytes = $"{name}"u8; // ❌ 컴파일 오류 — 보간 + u8 불가
u8은 컴파일 타임 처리라 리터럴에만 붙습니다. 동적으로 만든 string을 UTF-8로 바꾸려면 여전히 Encoding.UTF8.GetBytes(s)가 필요합니다.
함정 4 — .ToString() 호출 무의미
ReadOnlySpan<byte> hi = "Hello"u8;
string s = hi.ToString(); // "System.ReadOnlySpan`1[System.Byte]" — 의도와 다름
Span<T>의 ToString()은 타입 이름을 돌려줍니다. UTF-8 바이트를 string으로 디코딩하려면 Encoding.UTF8.GetString(hi)를 명시적으로 호출.
8. C# 버전별 변화
| 버전 | 변화 | 비고 |
|---|---|---|
| 1.0 | Encoding.UTF8.GetBytes(string) |
항상 변환 + alloc |
| 7.2 | Span<T>·ReadOnlySpan<T> |
인프라 도입 |
| 11.0 | "abc"u8 UTF-8 리터럴 |
컴파일 타임 인코딩 |
| 11.0 | 원시 문자열 """..."""(PART 6 #9) |
둘이 함께 나옴 |
| 12·13 | Span<T> 일부 제약 완화(PART 6 #12) |
C# 11의 다른 신문법(원시 문자열)이 더 화려해 보이지만, 성능 최적화 관점에서는 u8 리터럴이 더 결정적입니다. 네트워크·파일 I/O가 잦은 게임 코드에서 GC 부담을 통째로 줄이는 도구가 됩니다.
9. 정리
- [ ]
"abc"u8은ReadOnlySpan<byte>— UTF-8 바이트 시퀀스. 컴파일 타임에 어셈블리 정적 데이터 섹션에 박힘. - [ ] 변환 비용 0 + 힙 할당 0 — 런타임에는 스택 위 Span struct만 만들어진다.
- [ ] 베스트 매치:
payload.SequenceEqual("CMD"u8)같은 바이트 시퀀스 비교. - [ ] 가공이 필요하면
stackalloc byte[N]+CopyTo로 스택 사본 만들기. - [ ] 이진 데이터 전용 — UI·로그·다국어·동적 텍스트에는 사용 안 함. 그건
string(UTF-16). - [ ]
"안녕"u8.Length는 바이트 길이(6)."안녕".Length(2)와 다르다. - [ ]
ReadOnlySpan<byte>는 클래스 필드 불가 —static get프로퍼티 또는 지역 변수만. - [ ]
u8은 리터럴에만 — 보간($"..."u8)이나 동적 string에는 안 됨. 동적은Encoding.UTF8.GetBytes(s). - [ ] Unity 핫패스: 멀티플레이 패킷 명령어 비교, 세이브 매직 헤더 검증, 네이티브 라이브러리 호출.
- [ ] Unity 모바일 GC(Boehm + IL2CPP)에서 매 호출 임시
byte[]가 누적되던 자리를 alloc 0으로 바꾼다. - [ ]
Span<T>/ReadOnlySpan<T>의 일반 사용법은 PART 6 #12,stackalloc은 PART 6 #14에서 더 다룬다.