반응형

[PART6.배열과 문자열 기본(11/14)] UTF-8 문자열 리터럴 "abc"u8 — 인코딩 변환과 힙 할당을 동시에 0으로

string은 UTF-16 / 일반 코드는 그대로 / u8 접미사는 컴파일 타임 UTF-8 인코딩 → ReadOnlySpan<byte> / 어셈블리 정적 데이터 섹션에 바이트 박힘 → 런타임 alloc 0 / 네트워크 패킷·파일 헤더 비교에서 Encoding.UTF8.GetBytes 대체 / 일반 텍스트 처리에는 사용 안 함


1. 왜 stringbyte[]로 바꾸는 코드가 흔한가

게임 클라이언트가 서버와 패킷을 주고받을 때, 또는 세이브 파일의 매직 헤더를 확인할 때 — 거의 모든 네트워크 프로토콜과 파일 포맷은 UTF-8 바이트 스트림으로 정의되어 있습니다. C#의 stringUTF-16(2바이트 단위) 시퀀스이라, 보낼 때마다 변환해야 합니다.

C#
// ❌ 매 호출 — UTF-16 → UTF-8 변환 + 새 byte[] 할당
byte[] cmd = Encoding.UTF8.GetBytes("MOVE");
socket.Send(cmd);

이 한 줄이 매 프레임 호출되면 매 프레임 변환 비용 + GC alloc이 발생합니다. C# 11이 도입한 "MOVE"u8 리터럴은 이 문제를 컴파일 타임에 해결합니다.

C#
// ✅ "MOVE"u8 — 컴파일 타임에 이미 UTF-8 바이트로 박혀 있음, alloc 0
ReadOnlySpan<byte> cmd = "MOVE"u8;
socket.Send(cmd);

이 글에서는 u8 리터럴이 IL에서 어떻게 처리되는지, 어떤 자리에서 써야 하고 어떤 자리에서는 쓰지 말아야 하는지를 다룹니다.


2. u8 접미사의 정체

컴파일 타임 UTF-8 인코딩

C#
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

C#
public static byte[] OldWay() => Encoding.UTF8.GetBytes("MOVE");
public static ReadOnlySpan<byte> NewWay() => "MOVE"u8;
IL
// 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 리터럴의 베스트 매치는 바이트 시퀀스 비교입니다. 네트워크 패킷의 명령어, 파일 매직 헤더, 프로토콜 토큰 등.

C#
public static bool IsMove(ReadOnlySpan<byte> payload)
    => payload.SequenceEqual("MOVE"u8);

public static bool IsAttack(ReadOnlySpan<byte> payload)
    => payload.SequenceEqual("ATTACK"u8);
IL
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 리터럴 비교는 바이트 단위 직접 비교라 한 단계가 통째로 빠집니다.

파일 매직 헤더 검증

C#
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>)이라 그 자체로는 수정 불가합니다. 가공이 필요하면 스택에 별도 버퍼를 잡고 복사합니다.

C#
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 리터럴은 이진 데이터 전용입니다. 다음 자리에서는 절대 사용하지 마세요.

C#
// ❌ 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 — 멀티플레이 패킷 명령어 비교

C#
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 — 세이브 파일 매직 헤더

C#
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 — 네이티브 플러그인 호출

C#
[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 — 한글·멀티바이트 문자에서 길이 계산

C#
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>는 클래스 필드 불가

C#
public class Game
{
    private ReadOnlySpan<byte> _magic = "MYGM"u8;   // ❌ 컴파일 오류
}

Span<T>/ReadOnlySpan<T>는 ref struct(스택 한정)라 클래스 필드로 저장 불가. 정적 프로퍼티(static ReadOnlySpan<byte> M => "..."u8;) 또는 메서드 지역 변수로만 사용.

함정 3 — 동적 string에는 못 씀

C#
string name = userInput;
ReadOnlySpan<byte> bytes = $"{name}"u8;    // ❌ 컴파일 오류 — 보간 + u8 불가

u8컴파일 타임 처리라 리터럴에만 붙습니다. 동적으로 만든 string을 UTF-8로 바꾸려면 여전히 Encoding.UTF8.GetBytes(s)가 필요합니다.

함정 4 — .ToString() 호출 무의미

C#
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"u8ReadOnlySpan<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에서 더 다룬다.
반응형

+ Recent posts