본문 바로가기

C#

C# UTF8String 리터럴과 UTF-8 문자열 처리 최적화

대부분의 데이터 교환은 UTF-8로 이루어집니다. C# 11/.NET 8에서는 u8 접미사를 통해 UTF-8 바이트 시퀀스를 바로 얻을 수 있어 문자열 변환 비용을 줄일 수 있습니다. 이 글은 UTF-8 리터럴과 Span 기반 API를 활용해 성능을 끌어올리는 실용 팁을 정리합니다.

1. UTF-8 리터럴(u8) 핵심 개념

문자열 리터럴 뒤에 u8을 붙이면 ReadOnlySpan<byte>를 얻습니다. 이 값은 힙 할당 없이 UTF-8 바이트로 즉시 사용 가능합니다.

using System;

class Program
{
    static void Main()
    {
        ReadOnlySpan<byte> hello = "안녕하세요"u8;
        Console.WriteLine(hello.Length); // UTF-8 바이트 길이

        // API에 바로 전달
        Console.WriteLine(CountNewLines("첫줄\n둘째줄"u8));
    }

    static int CountNewLines(ReadOnlySpan<byte> utf8)
    {
        int count = 0;
        foreach (byte b in utf8)
        {
            if (b == (byte)'\n') count++;
        }
        return count;
    }
}

주의사항: ReadOnlySpan<byte>는 ref struct이므로 박싱/필드 저장이 불가합니다. 필요 시 byte[]로 복사해 영속화합니다.

// 영속 저장이 필요할 때
static readonly byte[] GreetingBytes = "안녕하세요"u8.ToArray();

2. 문자열 ↔ UTF-8 변환 최적화(Span, stackalloc)

문자열을 UTF-8로 인코딩할 때는 미리 크기를 계산하고 Span 버전을 사용하면 추가 할당을 줄일 수 있습니다.

using System;
using System.Text;

static byte[] ToUtf8Bytes(string s)
{
    int byteCount = Encoding.UTF8.GetByteCount(s);
    Span<byte> buffer = byteCount <= 256 ? stackalloc byte[byteCount] : new byte[byteCount];
    int written = Encoding.UTF8.GetBytes(s.AsSpan(), buffer);
    return buffer[..written].ToArray(); // 필요 시만 배열로 변환
}

UTF-8 바이트를 문자열로 디코딩할 때도 Span 기반 API로 불필요한 중간 배열을 피할 수 있습니다.

using System;
using System.Text;

static string FromUtf8(ReadOnlySpan<byte> utf8)
{
    int charCount = Encoding.UTF8.GetCharCount(utf8);
    Span<char> charBuf = charCount <= 256 ? stackalloc char[charCount] : new char[charCount];
    int written = Encoding.UTF8.GetChars(utf8, charBuf);
    return new string(charBuf[..written]);
}

3. 문자열 대신 UTF-8 바이트로 파싱/검색

텍스트를 굳이 string으로 변환하지 않고 바이트 상태에서 처리하면 비용을 크게 절약할 수 있습니다. .NET 8의 SearchValues를 활용하면 다중 구분자 검색이 빠릅니다.

using System;
using System.Buffers;

static int CountDelimiters(ReadOnlySpan<byte> utf8)
{
    var delims = SearchValues.Create(stackalloc byte[] { (byte)',', (byte)';' });
    int count = 0;
    ReadOnlySpan<byte> rest = utf8;
    while (true)
    {
        int idx = rest.IndexOfAny(delims);
        if (idx < 0) break;
        count++;
        rest = rest[(idx + 1)..];
    }
    return count;
}

// 사용 예
int c = CountDelimiters("a,b;c"u8);

문자 의미 단위(코드포인트) 단위 처리가 필요하면 Rune을 사용합니다.

using System;
using System.Text;

foreach (var rune in "이모지🙂".EnumerateRunes())
{
    Console.WriteLine(rune); // UTF-16 surrogate를 안전하게 건너뜀
}

4. 파일/네트워크 I/O에서 UTF-8 설정

파일 저장 시 BOM을 피하려면 UTF8Encoding(false)를 명시합니다. 네트워크 전송은 바이트 중심으로 처리해 불필요한 변환을 줄이세요.

using System.IO;
using System.Text;

// BOM 없는 UTF-8 파일 쓰기
using var w = new StreamWriter("data.txt", append: false, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
w.WriteLine("데이터");

// HttpClient JSON 전송
using var client = new System.Net.Http.HttpClient();
var content = new System.Net.Http.StringContent("{\"a\":1}", Encoding.UTF8, "application/json");
var resp = await client.PostAsync("https://api.example.com", content);

5. u8 리터럴 실전 패턴

프로토콜 상수, 헤더 이름, 고정 토큰 등은 u8로 정의하고 바이트 기반 파서에 직접 전달합니다. string이 필요한 API에만 최소한으로 변환하세요.

// 프로토콜 상수 (필드 대신 프로퍼티 사용 가능)
static ReadOnlySpan<byte> CrLf => "\r\n"u8;

// 파서에 직접 전달
bool EndsWithCrLf(ReadOnlySpan<byte> data)
{
    return data.Length >= 2 && data[^2] == (byte)'\r' && data[^1] == (byte)'\n';
}

6. 성능 팁과 주의사항

1) 작은 버퍼는 stackalloc으로, 큰 버퍼는 배열로. 2) 문자열 생성은 최후의 수단으로. 3) 잘못된 UTF-8 입력을 고려해 DecoderFallback을 설정하거나 예외로 처리합니다. 4) u8 리터럴은 ReadOnlySpan<byte>이므로 필드에 저장할 수 없습니다. 필요하면 byte[]로 복사하세요.

using System.Text;

// 잘못된 입력 시 예외 발생하도록 설정
var utf8Strict = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);

정리: u8 리터럴과 Span 기반 Encoding API를 조합하면 문자열-바이트 간 변환을 최소화하고, 파싱/검색을 바이트 레벨에서 처리하여 성능을 크게 개선할 수 있습니다.