| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
- 최적화
- base64
- Unity Editor
- 환급챌린지
- ui
- Job 시스템
- 샘플
- C#
- Dots
- unity
- Framework
- job
- RSA
- sha
- 암호화
- DotsTween
- TextMeshPro
- Custom Package
- 오공완
- 직장인자기계발
- Tween
- adfit
- 게임개발
- 패스트캠퍼스
- 프레임워크
- AES
- 가이드
- 패스트캠퍼스후기
- 2D Camera
- 직장인공부
- Today
- Total
EveryDay.DevUp
string vs StringBuilder vs Span<char> — 문자열 처리 도구 선택 본문
string vs StringBuilder vs Span<char> — 문자열 처리 도구 선택
문자열을 만들고, 조합하고, 잘라내는 세 가지 도구. 각각의 내부 구조를 알면 "언제 무엇을 쓸까"는 자동으로 결정된다.
목차
문제 제기 — 왜 도구 선택이 중요한가
Unity 모바일 게임의 인벤토리 화면을 만들고 있다. 아이템 정보를 문자열로 조합해서 UI에 표시해야 한다.
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— 불변 참조 타입. 힙에 할당되며, 모든 수정은 새 객체를 만든다.StringBuilder— 가변 참조 타입. 내부 버퍼에 직접 쓰며,ToString()시에만 string을 생성한다.ReadOnlySpan<char>— 기존 메모리의 일부를 가리키는 읽기 전용 뷰. 스택에만 존재하며 힙 할당이 없다.
내부 동작 — 각 도구가 메모리를 다루는 방식
string — 수정할 때마다 새 객체
string은 불변(Immutable) 참조 타입이다. 핵심만 요약하면:
- 모든 수정 연산(
+,Replace,Substring)이 새 string 객체를 힙에 할당한다 - 2~4개 연결은
String.Concat전용 오버로드로 한 번에 처리된다 (중간 객체 없음) - 5개 이상이면
params string[]배열까지 추가 할당
// 3개 연결 — 컴파일러가 Concat(string, string, string) 하나로 최적화
static string ConcatFew(string a, string b, string c)
{
return a + b + c;
}
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 + c가 Concat(a, b, c) 한 번으로 컴파일된다. 중간에 a + b 결과를 만들었다가 다시 + c하는 것이 아니다. 하지만 이것도 최종 결과 string 1개는 반드시 힙에 할당된다.
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)일 뿐이다.
// 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);
}
// ✅ 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
이것이 가장 흔하면서 가장 치명적인 실수다.
// ❌ 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();
}
// ❌ 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()은 매 반복에서:
- 현재
result전체를 새 버퍼에 복사 (길이가 계속 증가) - 새 부분을 이어붙이기
1번 반복에서 1글자, 2번에서 2글자, ... 100번에서 100글자를 복사하면, 총 복사량은 1+2+...+100 = 5,050 글자다. n번 반복이면 n(n+1)/2 → O(n²).
❌ Before: Substring으로 파싱 → ✅ After: Span으로 파싱
// ❌ 서버 응답 파싱 — 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에서 문자열 다루기
// ❌ 매 프레임 힙 할당 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();
}
// ❌ 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를 쓰는 과잉 최적화
// ❌ 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이므로 스택에만 존재할 수 있다. 다음은 모두 컴파일 에러다:
// ❌ 컴파일 에러 — 클래스 필드에 저장 불가
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으로 접근
// ✅ 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하는 실수
// ❌ 매 호출마다 새 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 시대
// 포맷 문자열 + 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 — 문자열 보간 도입
// 가독성 향상, 내부는 String.Format
string msg = $"Score: {score}, HP: {hp}";
$"..." 문법이 도입되었지만 컴파일러가 String.Format으로 변환했으므로 성능은 같았다.
C# 7.2 — Span<T> 도입
// 할당 없는 문자열 슬라이싱이 가능해짐
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# 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
// 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
// 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전용 오버로드가 한 번에 처리 - 루프/조건에서 반복 조합 →
StringBuilder—Append는 버퍼에 직접 쓰기, 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 |
'C# 심화' 카테고리의 다른 글
| 프로퍼티 — 필드와 무엇이 다른가 (0) | 2026.04.02 |
|---|---|
| struct 4형제 — struct, record struct, readonly struct, ref struct (0) | 2026.04.01 |
| string은 왜 불변인가 — intern pool의 실체 / == 이 값 비교인 이유 / 변경할 때마다 새 객체가 생기는 원리 (0) | 2026.03.31 |
| 형변환 완전 정리 — 암시적·명시적·as·is (0) | 2026.03.31 |
| readonly vs const — 무엇이 다른가 (0) | 2026.03.30 |
