EveryDay.DevUp

string vs StringBuilder vs Span<char> — 문자열 처리 도구 선택 본문

C# 심화

string vs StringBuilder vs Span<char> — 문자열 처리 도구 선택

EveryDay.DevUp 2026. 4. 1. 00:32

string vs StringBuilder vs Span<char> — 문자열 처리 도구 선택

문자열을 만들고, 조합하고, 잘라내는 세 가지 도구. 각각의 내부 구조를 알면 "언제 무엇을 쓸까"는 자동으로 결정된다.

문제 제기 — 왜 도구 선택이 중요한가

Unity 모바일 게임의 인벤토리 화면을 만들고 있다. 아이템 정보를 문자열로 조합해서 UI에 표시해야 한다.

C#
void Update()
{
    string info = "Name: " + itemName + " | Lv." + level + " | ATK: " + attack;
    uiText.text = info;
}

프로파일러를 열면 매 프레임 힙 할당이 4~5회 발생하고 있다. itemName, level, attack이 바뀌지 않았는데도 말이다. 60FPS 기준 1초에 240~300개의 쓰레기 문자열이 생긴다.

C#에는 문자열을 다루는 도구가 세 가지 있다 — string, StringBuilder, Span<char>. 셋은 같은 문자 데이터를 다루지만 메모리 할당 방식이 완전히 다르다. 잘못된 도구를 고르면 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소) 스파이크로 게임이 끊기고, 올바른 도구를 고르면 할당을 0에 가깝게 줄일 수 있다.


개념 정의 — 세 도구의 본질적 차이

레스토랑 비유로 이해하기

세 도구의 차이를 레스토랑에 비유하면 이렇다:

  • string = 완성된 요리. 접시에 담겨 나온 요리는 손댈 수 없다. 다른 재료를 추가하고 싶으면 새 접시에 처음부터 다시 담아야 한다. 기존 접시는 버려진다.
  • StringBuilder = 주방의 큰 냄비. 재료를 하나씩 넣고, 섞고, 마지막에 접시에 담는다(ToString). 냄비는 재사용 가능하다.
  • Span<char> = 도마 위의 칼질. 이미 있는 재료를 자르거나 일부만 집어 올린다. 새 재료를 만들지 않는다 — 기존 것을 보는 것뿐이다.
string

핵심 한 줄 정의

string — 불변 참조 타입. 힙에 할당되며, 모든 수정은 새 객체를 만든다.
StringBuilder — 가변 참조 타입. 내부 버퍼에 직접 쓰며, ToString() 시에만 string을 생성한다.
ReadOnlySpan<char> — 기존 메모리의 일부를 가리키는 읽기 전용 뷰. 스택에만 존재하며 힙 할당이 없다.

내부 동작 — 각 도구가 메모리를 다루는 방식

string — 수정할 때마다 새 객체

string은 불변(Immutable) 참조 타입이다. 핵심만 요약하면:

  • 모든 수정 연산(+, Replace, Substring)이 새 string 객체를 힙에 할당한다
  • 2~4개 연결은 String.Concat 전용 오버로드로 한 번에 처리된다 (중간 객체 없음)
  • 5개 이상이면 params string[] 배열까지 추가 할당
C#
// 3개 연결 — 컴파일러가 Concat(string, string, string) 하나로 최적화
static string ConcatFew(string a, string b, string c)
{
    return a + b + c;
}
IL
IL_0001: ldarg.0
IL_0002: ldarg.1
IL_0003: ldarg.2
IL_0004: call string System.String::Concat(string, string, string)  // 3인자 전용 오버로드 — 중간 객체 없음

a + b + cConcat(a, b, c) 한 번으로 컴파일된다. 중간에 a + b 결과를 만들었다가 다시 + c하는 것이 아니다. 하지만 이것도 최종 결과 string 1개는 반드시 힙에 할당된다.

StringBuilder — 내부 버퍼에 직접 쓰기

StringBuilder 내부 구조 — 청크 연결 리스트
StringBuilder 내부에 가변(mutable) char[] 버퍼를 가진 클래스. .NET Core/5+ 에서는 버퍼가 꽉 차면 새 청크를 연결 리스트로 이어붙인다. Append는 현재 청크 버퍼에 직접 쓰고, ToString() 호출 시 모든 청크를 하나의 string으로 합친다.

핵심 특성:

  • 기본 Capacity: 16. 예상 길이를 알면 new StringBuilder(256)처럼 지정하여 재할당을 줄인다
  • Append(int): int를 object로 boxing하지 않고 직접 처리 — ToString()을 거치지 않는다
  • Clear(): 내부 인덱스만 리셋, 버퍼 메모리는 유지 — 재사용에 최적

ReadOnlySpan<char> — 할당 없는 뷰

ref struct 스택에만 존재할 수 있는 특수 구조체. 힙 할당(boxing)이 불가능하고, 클래스 필드에 저장하거나 async 메서드에서 사용할 수 없다. Span<T>ReadOnlySpan<T>가 대표적이다.

ReadOnlySpan<char>는 내부적으로 관리 포인터(ref T) + 길이(int) 두 개의 필드만 가진다. 기존 string이나 배열의 일부 구간을 가리키는 창(window)일 뿐이다.

C#
// Span 기반 파싱 — zero allocation
static void ParseWithSpan(string data)
{
    ReadOnlySpan<char> span = data.AsSpan();
    ReadOnlySpan<char> header = span.Slice(0, 5);
    ReadOnlySpan<char> body = span.Slice(6);
    Console.WriteLine(header.Length);
    Console.WriteLine(body.Length);
}

// Substring 기반 파싱 — 매번 새 string 할당
static void ParseWithSubstring(string data)
{
    string header = data.Substring(0, 5);
    string body = data.Substring(6);
    Console.WriteLine(header);
    Console.WriteLine(body);
}
IL
// ✅ ParseWithSpan — 힙 할당 없음
IL_0002: call valuetype ReadOnlySpan`1<char> MemoryExtensions::AsSpan(string)    // 뷰 생성, 할당 없음
IL_000c: call instance valuetype ReadOnlySpan`1<!0> ReadOnlySpan`1<char>::Slice(int32, int32)  // 포인터 연산만
IL_0015: call instance valuetype ReadOnlySpan`1<!0> ReadOnlySpan`1<char>::Slice(int32)         // 포인터 연산만

// ❌ ParseWithSubstring — 매번 새 string 힙 할당
IL_0004: callvirt instance string System.String::Substring(int32, int32)  // 새 string 할당 ①
IL_000c: callvirt instance string System.String::Substring(int32)         // 새 string 할당 ②

차이가 명확하다:

  Substring AsSpan + Slice
locals init [0] string, [1] string (참조 타입) [0] ReadOnlySpan, [1] ReadOnlySpan (값 타입)
힙 할당 호출마다 새 string 0
반환 타입 string (힙) ReadOnlySpan (스택)
속도 O(n) — 문자 복사 O(1) — 포인터 + 길이 조정

실전 적용 — Before/After로 보는 올바른 도구 선택

❌ Before: 루프에서 string += → ✅ After: StringBuilder

이것이 가장 흔하면서 가장 치명적인 실수다.

C#
// ❌ Before: 루프에서 string 연결
static string ConcatLoop()
{
    string result = "";
    for (int i = 0; i < 100; i++)
    {
        result += i.ToString();
    }
    return result;
}

// ✅ After: StringBuilder 사용
static string BuilderLoop()
{
    var sb = new StringBuilder(256);
    for (int i = 0; i < 100; i++)
    {
        sb.Append(i);
    }
    return sb.ToString();
}
IL
// ❌ ConcatLoop — 루프 내부 (매 반복 실행)
IL_000f: call instance string System.Int32::ToString()           // i.ToString() → 새 string 할당 ①
IL_0014: call string System.String::Concat(string, string)      // result + 문자열 → 새 string 할당 ②
IL_0019: stloc.0                                                // 이전 result는 GC 수집 대상

// ✅ BuilderLoop — 루프 내부 (매 반복 실행)
IL_0001: ldc.i4 256
IL_0006: newobj instance void StringBuilder::.ctor(int32)       // StringBuilder 1개만 힙 할당 (루프 밖)
// ...루프 내부:
IL_0013: callvirt instance class StringBuilder StringBuilder::Append(int32)  // int를 직접 버퍼에 쓰기 — 할당 없음

핵심 차이:

  ❌ string += ✅ StringBuilder
루프 내 힙 할당 2회/반복 (ToString + Concat) 0회/반복
100회 반복 시 ~200개 string 객체 StringBuilder 1개 + 최종 string 1개
시간복잡도(입력 크기에 따른 연산 비용 증가율) O(n²) — 매번 이전 전체를 복사 O(n) — 버퍼 끝에 이어 쓰기

왜 O(n²)인가? result += i.ToString()은 매 반복에서:

  1. 현재 result 전체를 새 버퍼에 복사 (길이가 계속 증가)
  2. 새 부분을 이어붙이기

1번 반복에서 1글자, 2번에서 2글자, ... 100번에서 100글자를 복사하면, 총 복사량은 1+2+...+100 = 5,050 글자다. n번 반복이면 n(n+1)/2 → O(n²).

❌ Before: Substring으로 파싱 → ✅ After: Span으로 파싱

C#
// ❌ 서버 응답 파싱 — Substring은 매번 새 string 할당
static (int id, int score) ParseBad(string packet)
{
    // packet = "ID:1234|SCORE:5678"
    int sep = packet.IndexOf('|');
    string idPart = packet.Substring(3, sep - 3);    // 새 string ①
    string scorePart = packet.Substring(sep + 7);     // 새 string ②
    return (int.Parse(idPart), int.Parse(scorePart));
}

// ✅ Span으로 파싱 — 힙 할당 없음
static (int id, int score) ParseGood(string packet)
{
    ReadOnlySpan<char> span = packet.AsSpan();
    int sep = span.IndexOf('|');
    ReadOnlySpan<char> idPart = span.Slice(3, sep - 3);    // 뷰만 생성
    ReadOnlySpan<char> scorePart = span.Slice(sep + 7);     // 뷰만 생성
    return (int.Parse(idPart), int.Parse(scorePart));
}
int.Parse(ReadOnlySpan<char>) .NET Core 2.1+ 에서 추가된 Span 기반 파싱 API. 문자열을 string으로 변환하지 않고 Span에서 직접 숫자를 읽는다. 할당 없이 파싱할 수 있는 핵심 API다.
예시: int.Parse("1234".AsSpan()) → 힙 할당 없이 1234 반환

🎮 Unity 실전: Update에서 문자열 다루기

C#
// ❌ 매 프레임 힙 할당 3회 (ToString ×2 + Concat ×1)
static string BadUpdate(int score, int hp)
{
    return "Score: " + score + " | HP: " + hp;
}

// ✅ StringBuilder 캐싱 — 힙 할당 ToString 1회만
private static readonly StringBuilder _sb = new StringBuilder(64);

static string GoodUpdate(int score, int hp)
{
    _sb.Clear();
    _sb.Append("Score: ").Append(score).Append(" | HP: ").Append(hp);
    return _sb.ToString();
}
IL
// ❌ BadUpdate
IL_0008: call instance string System.Int32::ToString()                        // score → 새 string ①
IL_0014: call instance string System.Int32::ToString()                        // hp → 새 string ②
IL_0019: call string System.String::Concat(string, string, string, string)   // 4개 연결 → 새 string ③

// ✅ GoodUpdate
IL_0006: callvirt instance class StringBuilder StringBuilder::Clear()         // 인덱스 리셋만, 버퍼 유지
IL_0016: callvirt instance class StringBuilder StringBuilder::Append(string)  // "Score: " 버퍼에 쓰기
IL_001c: callvirt instance class StringBuilder StringBuilder::Append(int32)   // score를 직접 버퍼에 → ToString 없음
IL_0026: callvirt instance class StringBuilder StringBuilder::Append(string)  // " | HP: "
IL_002c: callvirt instance class StringBuilder StringBuilder::Append(int32)   // hp를 직접 버퍼에
IL_0037: callvirt instance string System.Object::ToString()                   // 최종 string 1개만 할당

IL로 증명되는 차이:

  ❌ BadUpdate ✅ GoodUpdate
Int32::ToString() 호출 2회 (새 string 2개) 0회
String::Concat 1회 (새 string 1개) 없음
StringBuilder::Append(int32) 없음 2회 (boxing 없이 직접)
총 힙 할당 3개 string 객체 1개 string (ToString)

Append(int32)가 핵심이다. BadUpdate에서는 score.ToString()으로 int를 먼저 string으로 변환한 후 연결하지만, GoodUpdate에서는 Append(int32) 오버로드가 int를 직접 버퍼에 문자로 쓴다 — ToString() 호출 자체가 없다.


함정과 주의사항

❌ 소수(2~3개) 연결에 StringBuilder를 쓰는 과잉 최적화

C#
// ❌ StringBuilder가 오히려 느린 경우
string result = new StringBuilder()
    .Append("Hello ")
    .Append(name)
    .ToString();

// ✅ 소수 연결은 + 가 더 효율적
string result = "Hello " + name;

2~3개 문자열 연결은 String.Concat 전용 오버로드가 한 번에 처리한다. 이 경우 StringBuilder를 쓰면 오히려 new StringBuilder() 힙 할당이 추가되어 더 비효율적이다.

판단 기준:

  • 연결 횟수가 고정되고 4개 이하면 → + (String.Concat)
  • 루프 안에서 반복 연결이면 → StringBuilder
  • 런타임에 연결 횟수가 결정되면 → StringBuilder

❌ Span을 필드에 저장하거나 async 메서드에서 사용

Span<T>ReadOnlySpan<T>ref struct이므로 스택에만 존재할 수 있다. 다음은 모두 컴파일 에러다:

C#
// ❌ 컴파일 에러 — 클래스 필드에 저장 불가
class Parser
{
    ReadOnlySpan<char> _cached; // CS8345
}

// ❌ 컴파일 에러 — async 메서드에서 사용 불가
async Task ParseAsync(string data)
{
    ReadOnlySpan<char> span = data.AsSpan(); // CS4012
    await Task.Delay(100);
    Console.WriteLine(span.Length);
}
ReadOnlyMemory<char> Span<T>의 스택 전용 제약을 극복하는 구조체. ref struct가 아니므로 필드에 저장하고 async 메서드에서 사용할 수 있다. 실제 데이터 처리가 필요한 시점에 .Span 프로퍼티로 ReadOnlySpan<T>를 얻어 사용한다.
예시: ReadOnlyMemory<char> mem = data.AsMemory(0, 5); → 나중에 mem.Span으로 접근
C#
// ✅ async에서는 Memory<T> 사용
async Task ParseAsync(string data)
{
    ReadOnlyMemory<char> mem = data.AsMemory();
    await Task.Delay(100);
    ReadOnlySpan<char> span = mem.Span; // await 이후 Span으로 변환
    Console.WriteLine(span.Length);
}

❌ StringBuilder를 매번 new하는 실수

C#
// ❌ 매 호출마다 새 StringBuilder 생성 — 힙 할당 낭비
void UpdateUI(int score)
{
    var sb = new StringBuilder();  // 매번 new!
    sb.Append("Score: ").Append(score);
    uiText.text = sb.ToString();
}

// ✅ 필드에 캐싱하고 Clear()로 재사용
private readonly StringBuilder _sb = new StringBuilder(32);

void UpdateUI(int score)
{
    _sb.Clear();  // 버퍼 유지, 인덱스만 리셋
    _sb.Append("Score: ").Append(score);
    uiText.text = _sb.ToString();
}

Clear()는 내부 버퍼를 해제하지 않고 사용 위치만 0으로 리셋한다. 다음 Append 때 기존 버퍼를 그대로 재사용하므로 추가 할당이 없다.


C# 버전별 변화

C# 6 이전 — String.Format 시대

C#
// 포맷 문자열 + boxing
string msg = string.Format("Score: {0}, HP: {1}", score, hp);
// score, hp가 int → object로 boxing 발생

String.Format은 내부적으로 StringBuilder를 사용하지만, params object[] 배열 할당 + 값 타입 인수의 boxing(값 타입이 참조 타입 object로 변환되는 것) 오버헤드가 있었다.

C# 6 — 문자열 보간 도입

C#
// 가독성 향상, 내부는 String.Format
string msg = $"Score: {score}, HP: {hp}";

$"..." 문법이 도입되었지만 컴파일러가 String.Format으로 변환했으므로 성능은 같았다.

C# 7.2 — Span<T> 도입

C#
// 할당 없는 문자열 슬라이싱이 가능해짐
ReadOnlySpan<char> part = text.AsSpan(0, 5);
int value = int.Parse(part); // Span 기반 파싱 API

Span<T>ReadOnlySpan<T>가 도입되면서 zero-allocation 문자열 처리가 가능해졌다.

C# 10 — DefaultInterpolatedStringHandler

C# 10에서 문자열 보간의 내부가 완전히 바뀌었다. 이전 글의 IL 분석에서 확인한 것처럼:

C#
// C# 10+: 동일한 $"..." 문법, 완전히 다른 내부
string msg = $"Score: {score}, HP: {hp}";
// → DefaultInterpolatedStringHandler (struct) 사용
// → boxing 없음, 스택 기반 버퍼
  C# 6~9 C# 10+
내부 처리 String.Format DefaultInterpolatedStringHandler (struct)
int 인수 boxing → object 제네릭 AppendFormatted<int> → boxing 없음
버퍼 StringBuilder 힙 할당 핸들러 내부 스택/풀 버퍼

고급: string.Create와 stackalloc

C#
// string.Create — 정확한 길이를 알 때 할당 1회로 직접 구성
static string CreateDirect(int id, string name)
{
    return string.Create(name.Length + 5, (id, name), (span, state) =>
    {
        state.name.AsSpan().CopyTo(span);
        span[state.name.Length] = '#';
        state.id.TryFormat(span.Slice(state.name.Length + 1), out _);
    });
}

// stackalloc + Span — 힙 할당 완전 제거
static int FormatOnStack(int value)
{
    Span<char> buffer = stackalloc char[16];
    value.TryFormat(buffer, out int written);
    return written;
}
stackalloc 스택에 메모리를 직접 할당하는 키워드. 힙을 거치지 않으므로 GC 부담이 없다. Span<T>과 결합하면 안전하게 사용할 수 있다. 스택 크기 제한(보통 1MB)이 있으므로 작은 버퍼에만 사용한다.
예시: Span<char> buf = stackalloc char[128]; → 힙 할당 0
IL
// string.Create — 최종 string 1개만 할당, 내부에서 Span<char>로 직접 쓰기
IL_002f: call string System.String::Create<ValueTuple`2<int32, string>>(int32, !!0, class SpanAction`2<char, !!0>)

// stackalloc — 힙 할당 완전 제거
IL_0004: localloc                                               // 스택에 32바이트(16 chars) 할당
IL_0008: newobj instance void Span`1<char>::.ctor(void*, int32) // 스택 메모리를 Span으로 래핑
IL_001f: call instance bool System.Int32::TryFormat(Span`1<char>, int32&, ...)  // 스택 버퍼에 직접 포맷

localloc이 핵심이다 — 이것은 힙이 아닌 스택 프레임에 메모리를 잡는 IL 명령어다. GC가 관여할 여지가 전혀 없다.


정리

문자열 처리 도구 선택 체크리스트:

  • 읽기 전용 텍스트, 딕셔너리 키string — 불변이므로 해시 기반 컬렉션에 안전
  • 소수(2~4개) 고정 연결+ 연산자 — String.Concat 전용 오버로드가 한 번에 처리
  • 루프/조건에서 반복 조합StringBuilderAppend는 버퍼에 직접 쓰기, boxing 없음
  • StringBuilder는 캐싱 → 필드에 두고 Clear()로 재사용, 초기 Capacity 지정
  • 문자열 파싱/슬라이싱ReadOnlySpan<char> — 할당 0, O(1) 슬라이스
  • async에서 Span이 필요하면ReadOnlyMemory<char> — 필드 저장 가능, .Span으로 변환
  • 정확한 길이를 알 때string.Create — 최종 string 1개만 할당, 내부에서 Span으로 직접 쓰기
  • 극한 최적화stackalloc char[N] + Span<char> — 힙 할당 완전 제거, 작은 버퍼에만
상황 도구 힙 할당
UI 텍스트 표시 (변경 드묾) string 최초 1회
매 프레임 UI 갱신 StringBuilder (캐싱) ToString 1회/갱신
네트워크 패킷 파싱 ReadOnlySpan<char> 0
루프 안 문자열 조합 StringBuilder ToString 1회
2~3개 고정 연결 string + 1회 (Concat)
스택 기반 포맷팅 stackalloc + Span 0