[PART6.배열과 문자열 기본(8/14)] StringBuilder — 가변 char 버퍼로 string 누적의 GC를 끊는다
내부 char[] 버퍼 + 길이 / 두 배 확장 / Append·Insert·Replace·Clear·ToString / 메서드 체이닝(this 반환) / Capacity 미리 잡기 / 짧은 결합엔 쓰지 말 것 / 핫패스에서 인스턴스 캐싱 + Clear로 zero-alloc / TextMeshPro SetText(StringBuilder)
목차
1. +로 누적하는 게 왜 비싸진다고 했었는가
PART 6 #7에서 본 그림 — 반복문 안에서 result += items[i] + ","; 한 줄이 매 반복마다 새 string 객체를 만듭니다. N번 돌면 임시 string 약 N개. Unity 핫패스에서 이게 가장 자주 만나는 GC 폭탄이었습니다.
// ❌ N개의 임시 string
string log = "";
for (int i = 0; i < 1000; i++) log += $"Line {i}\n";
// ✅ StringBuilder로 1개
StringBuilder sb = new();
for (int i = 0; i < 1000; i++) sb.Append("Line ").Append(i).Append('\n');
string log = sb.ToString();
StringBuilder는 이 문제를 푸는 표준 도구입니다. 내부에 변경 가능한(mutable) char[] 버퍼를 두고, Append마다 그 버퍼에 쓰기를 할 뿐 새 string을 만들지 않습니다. 마지막 ToString()에서만 한 번 새 string이 만들어집니다 — N개의 alloc이 1개로 줄어드는 것입니다.
이 글에서는 StringBuilder의 내부 동작·자주 쓰는 메서드·언제 쓰지 말아야 하는가·Unity 핫패스에서의 캐싱 패턴을 다룹니다.
2. StringBuilder란 무엇인가
비유 — 지우고 덧쓸 수 있는 화이트보드
string이 인쇄가 끝난 책이라면, StringBuilder는 화이트보드입니다. 글씨를 지우고, 덧붙이고, 중간에 끼워 넣을 수 있고, 자리가 모자라면 더 큰 보드로 옮겨 가도 됩니다. 마지막에 보드 내용을 카메라로 찍어 한 장의 사진(=string)으로 만드는 것이 ToString() 호출입니다.
내부 구조

StringBuilder 객체가 들고 있는 정보는 두 가지입니다.
char[]버퍼 — 실제 문자가 저장되는 가변 배열. 처음에는 작게 시작하고 모자라면 두 배로 키운다.Length— 현재 사용 중인 문자 수.Capacity(버퍼 길이)는 그보다 같거나 크다.
StringBuilder sb = new(); // 기본 Capacity = 16
sb.Append("Hello");
// → 버퍼 = ['H','e','l','l','o', _, _, _, _, _, _, _, _, _, _, _]
// → Length = 5, Capacity = 16
sb.Append("World!");
// → 버퍼 = ['H','e','l','l','o','W','o','r','l','d','!', _, _, _, _, _]
// → Length = 11, Capacity = 16
11자가 들어갔는데 Capacity가 16이라 같은 버퍼에 그대로 누적합니다. 만약 Length가 17이 되려는 순간이면 Capacity가 32로 두 배 늘어나면서 기존 내용을 새 버퍼로 복사합니다.
두 배 확장의 비용
StringBuilder sb = new(); // Capacity = 16
for (int i = 0; i < 100; i++)
sb.Append("ABCDEFGHIJ"); // 매번 10자
100×10 = 1000자를 누적할 때 버퍼가 16 → 32 → 64 → 128 → 256 → 512 → 1024로 6번 확장됩니다. 확장할 때마다 새 char[] 할당 + 기존 데이터 복사 + 기존 char[] 폐기가 일어납니다. 작은 비용은 아닙니다.
해결책은 Capacity 미리 잡기 — 결과 길이를 어림잡을 수 있다면 생성자 인자로 알려 줍니다.
StringBuilder sb = new(1024); // 처음부터 1024 — 확장 0회
3. 자주 쓰는 메서드와 IL
public static string Sample()
{
StringBuilder sb = new();
sb.Append("Hello");
sb.Append(' ');
sb.Append("World");
sb.AppendLine();
sb.AppendFormat("Value: {0:N2}", 3.14);
return sb.ToString();
}
IL_0001: newobj instance void StringBuilder::.ctor() // SB 1개 생성
IL_000d: callvirt instance StringBuilder StringBuilder::Append(string) // "Hello"
IL_0016: callvirt instance StringBuilder StringBuilder::Append(char) // ' '
IL_0022: callvirt instance StringBuilder StringBuilder::Append(string) // "World"
IL_0029: callvirt instance StringBuilder StringBuilder::AppendLine() // \r\n
IL_003e: box System.Double // ← 박싱 발생!
IL_0043: callvirt instance StringBuilder StringBuilder::AppendFormat(string, object)
IL_004a: callvirt instance string Object::ToString() // 최종 string 1개
핵심 두 가지:
- 모든
Append는callvirt메서드 호출이지만 새string을 안 만든다. 자기 버퍼에 쓰기만 한다. AppendFormat이나Append(object)오버로드는 값 타입 인자에 박싱이 발생할 수 있습니다 (box System.Double한 줄). 핫패스에서는Append(int)·Append(double)같은 타입 전용 오버로드를 쓰면 박싱이 사라집니다.
Append(object)vsAppend(int)— 박싱 차이 ``csharp sb.AppendFormat("v={0}", 3.14); // ❌ box System.Double sb.Append("v=").Append(3.14); // ✅ Append(double) — 박싱 없음``
4. 메서드 체이닝 — this 반환
public static string Make() =>
new StringBuilder()
.Append("a=").Append(1).Append(", b=").Append(2)
.ToString();
IL_0000: newobj instance void StringBuilder::.ctor()
IL_000a: call instance StringBuilder StringBuilder::Append(string) // "a="
IL_0010: callvirt instance StringBuilder StringBuilder::Append(int32) // 1
IL_001a: callvirt instance StringBuilder StringBuilder::Append(string) // ", b="
IL_0020: callvirt instance StringBuilder StringBuilder::Append(int32) // 2
IL_0025: callvirt instance string Object::ToString()
StringBuilder의 모든 변경 메서드는 자기 자신을 반환합니다. 그래서 위처럼 한 줄에 체이닝이 가능합니다. IL을 보면 pop이 없습니다 — 비체이닝 코드(sb.Append(...); sb.Append(...);)는 IL에서 매번 반환된 StringBuilder를 pop으로 버리지만, 체이닝은 그대로 다음 호출에 전달됩니다. 체이닝이 약간 더 짧고 IL도 더 깔끔합니다.
5. 핵심 메서드 한 자리 정리
| 메서드 | 역할 | 새 string 생성? |
|---|---|---|
Append(...) |
끝에 추가 (오버로드 다수: string·char·int·double·…) | 없음 |
AppendLine([s]) |
끝에 추가 + 줄바꿈 | 없음 |
AppendFormat(fmt, args) |
형식 지정 후 추가 | 없음 (단, args가 값 타입이면 박싱) |
Insert(idx, ...) |
특정 인덱스에 삽입 | 없음 |
Remove(idx, len) |
범위 제거 | 없음 |
Replace(old, new) |
전체 치환 (in-place) | 없음 |
Clear() |
내용 비움 (Length = 0과 동등, 버퍼는 그대로) |
없음 |
ToString() |
현재 내용으로 새 string 생성 |
있음 (1개) |
Length (set) |
길이 직접 변경 (잘라내기) | 없음 |
Capacity (set) |
버퍼 크기 변경 | 필요시 새 버퍼 (드물게 쓰임) |
ToString()만이 새 string을 만든다는 점이 중요합니다. 모든 누적 작업은 ToString() 한 번까지 미루고, 그동안 임시 string은 0개로 유지하는 게 핵심 패턴입니다.
6. 언제 StringBuilder를 쓰지 말아야 하는가
StringBuilder도 객체입니다 — 생성 자체에 약간의 비용이 있고, ToString() 호출에도 비용이 있습니다. 짧은 결합에는 오히려 +나 보간이 더 빠릅니다.
// ❌ 과한 사용 — StringBuilder가 만드는 alloc이 + 연결보다 비싸다
StringBuilder sb = new();
sb.Append(name);
sb.Append(": ");
sb.Append(score);
return sb.ToString();
// ✅ 짧은 결합은 보간
return $"{name}: {score}";
// ✅ 또는 String.Concat
return name + ": " + score;
경험적 기준:
| 상황 | 추천 |
|---|---|
| 결합 항목이 2~5개, 한 번에 끝남 | +(String.Concat) 또는 보간 $"..." |
항목이 많고 string.Join으로 가능 |
string.Join(separator, items) (가장 효율적) |
| 반복문 안에서 누적 | StringBuilder |
| 매 호출마다 만들고 버려야 한다 (1회용) | +/보간 |
| 같은 자리에서 매 프레임 호출 (핫패스) | 인스턴스 캐싱 + Clear (다음 섹션) |
7. 핫패스 패턴 — 인스턴스 캐싱 + Clear()
매 프레임 호출되는 자리에서 매번 new StringBuilder()를 만들면, StringBuilder 자체와 그 내부 char[] 버퍼가 매 프레임 alloc됩니다. 캐싱 패턴이 정답입니다.
public class Renderer
{
private readonly StringBuilder _sb = new(64); // 인스턴스 필드 — 한 번만 alloc
private int _lastScore = -1;
public string FormatScore(int score)
{
if (score == _lastScore) return _sb.ToString(); // 변경 감지
_lastScore = score;
_sb.Clear(); // 길이 0, 내부 버퍼 그대로
_sb.Append("SCORE: ");
_sb.Append(score);
return _sb.ToString();
}
}
Clear()는 IL적으로 Length = 0과 같습니다 — 내부 char[] 버퍼는 그대로 살아 있고, 다음 Append 호출에서 그 버퍼에 다시 쓰기 시작합니다. 한 번 알아 둔 Capacity 안에서만 쓴다면 이 메서드는 추가 alloc 0.
new StringBuilder(64)로 처음부터 64 버퍼를 잡아 두면 일반적인 짧은 라벨에서는 확장이 일어나지 않습니다.
8. 실전 적용 — Unity의 TextMeshPro.SetText(StringBuilder)
Before/After: 매 프레임 string 갱신
// ❌ Before — 매 프레임 alloc 다수
void Update()
{
healthText.text = $"HP: {currentHp} / {maxHp}";
// 보간이 만든 string 1개 alloc
}
// ✅ After — 캐시 SB + SetText(StringBuilder) → alloc 0
private readonly StringBuilder _hpSb = new(32);
private int _lastHp = -1;
void Update()
{
if (currentHp == _lastHp) return;
_lastHp = currentHp;
_hpSb.Clear();
_hpSb.Append("HP: ").Append(currentHp).Append(" / ").Append(maxHp);
healthText.SetText(_hpSb); // ← string 안 만듦
}
TextMeshPro의 SetText(StringBuilder) 오버로드는 인자로 받은 StringBuilder의 내부 char 데이터를 직접 읽어 렌더링합니다. ToString()을 호출하지 않으니 string 객체조차 만들어지지 않습니다. 인스턴스 캐싱과 결합하면 매 프레임 alloc 0인 UI 갱신이 완성됩니다.
추가로 TextMeshPro는 정수·실수를 받는 SetText("HP: {0}", currentHp) 오버로드도 있어, 단순 형식이라면 StringBuilder 없이도 alloc 0이 가능합니다.
Unity 모바일 GC 특수성
Unity 모바일은 IL2CPP(Intermediate Language to C++ — IL을 C++로 변환해 네이티브로 컴파일하는 빌드 백엔드)와 Boehm GC(보수적·정지형 가비지 컬렉터, 한 번 돌면 모든 매니지드 스레드를 멈춘다)의 조합이라 string 한 번에 만든 GC가 5~15ms 프레임 스파이크로 곧장 이어집니다. UI 라벨 갱신 코드의 alloc 0 달성이 모바일 60FPS의 출발점이며, StringBuilder 캐싱이 그 첫 도구입니다.
9. 함정과 주의사항
함정 1 — 짧은 결합에 StringBuilder 남용
위에서 본 대로, 결합이 2~5개라면 +/보간이 빠릅니다. "무조건 StringBuilder"라는 규칙은 없습니다.
함정 2 — ToString() 여러 번 호출
// ❌ 매번 새 string
for (int i = 0; i < 1000; i++)
Debug.Log(sb.ToString()); // 1000개 string!
// ✅ ToString은 마지막에 한 번
Debug.Log(sb.ToString());
함정 3 — AppendFormat으로 박싱 누적
sb.AppendFormat("v1={0}, v2={1}, v3={2}", 1.0, 2.0, 3.0);
// ❌ object[] 배열 1개 + double 박싱 3개 = 박스 4개
sb.Append("v1=").Append(1.0)
.Append(", v2=").Append(2.0)
.Append(", v3=").Append(3.0);
// ✅ 박싱 0
AppendFormat은 가독성은 좋지만 핫패스에선 박싱 비용이 있을 수 있습니다. 단순한 형식은 직접 Append 체이닝이 더 빠릅니다.
함정 4 — Capacity 너무 크게 잡기
StringBuilder sb = new(1_000_000); // ❌ 항상 1MB char[] 점유
Capacity가 너무 크면 사용하지 않는 메모리를 계속 점유합니다. 결과 길이의 1.5배 정도가 적당한 출발점입니다. 정확한 길이를 알면 그대로 잡습니다.
함정 5 — 멀티스레드 비안전
string은 불변이라 멀티스레드 안전이지만 StringBuilder는 가변 객체이므로 멀티스레드 안전이 아닙니다. 여러 스레드가 같은 StringBuilder를 동시에 수정하면 데이터가 깨집니다. 스레드 간 공유가 필요하면 lock을 쓰거나, 각 스레드가 자기 StringBuilder를 갖도록 합니다.
10. C# 버전별 변화
| 버전/시기 | 변화 | 비고 |
|---|---|---|
| 1.0 | StringBuilder 도입 |
기본 |
| 4.0 | Clear() 메서드 추가 (이전엔 Length = 0) |
|
| 6.0 | 보간 $"..." 도입 — 짧은 결합 대안으로 부상 |
|
| 10.0 | Append에 interpolated string handler 인자 직접 받는 오버로드 — 보간 인자가 박싱 없이 추가됨 |
성능 개선 |
| .NET 6+ | 보간 처리가 풀링된 핸들러로 — String.Format보다 빨라짐 |
StringBuilder보다 짧은 결합에 보간 우선 |
C# 10/.NET 6 이후에는 sb.Append($"v={value}") 같은 보간 인자 직접 추가가 박싱 없이 처리됩니다. 보간을 StringBuilder 안에서 그대로 쓰는 패턴이 자연스러워졌습니다.
11. 정리
- [ ]
StringBuilder= 가변char[]버퍼 + 길이. 변경 작업이 새string을 만들지 않는다. - [ ] Capacity가 모자라면 두 배 확장(새 버퍼 alloc + 복사). 결과 길이를 알면
new StringBuilder(N)으로 미리 잡아 확장 회수를 줄인다. - [ ]
Append·AppendLine·Insert·Remove·Replace·Clear모두 자기 자신(this) 반환 → 메서드 체이닝 가능. - [ ]
ToString()만 새string1개를 만든다. 모든 누적은ToString()까지 미룬다. - [ ]
AppendFormat·Append(object)는 값 타입 인자에 박싱 발생 — 핫패스에선 타입별 오버로드(Append(int)·Append(double)) 직접 사용. - [ ] 짧은 결합(2~5개)에는 보간/
String.Concat이 더 빠르다.StringBuilder도 alloc이 있다. - [ ] 반복 누적은
StringBuilder. 단순 결합은string.Join. 선택. - [ ] 핫패스 인스턴스 캐싱 +
Clear(): 인스턴스 필드 SB → 매 호출Clear→ 같은 버퍼 재사용 → alloc 0. - [ ]
StringBuilder는 멀티스레드 비안전. 공유 시lock또는 스레드별 인스턴스. - [ ] Unity TextMeshPro
SetText(StringBuilder)와 결합하면 매 프레임 UI 갱신이 alloc 0이 된다. - [ ] Unity 모바일(IL2CPP + Boehm GC)에서 string GC 한 번이 5~15ms 프레임 스파이크 —
StringBuilder캐싱이 60FPS의 출발점. - [ ] 더 깊은 포맷팅(보간·
string.Format·축자·원시 문자열)은 PART 6 #9에서 다룬다.