C#의 문자열은 불변입니다. s += "x"는 s를 늘리지 않고, 이전 문자와 새 문자를 담은 새 문자열을 만들며, 이전 문자열은 쓰레기가 됩니다. 한 번이라면 아무것도 아닙니다. 수천 번 도는 루프에서는 매 반복마다 지금까지 만든 모든 것을 복사하므로 전체 작업량이 길이의 제곱으로 늘어납니다.
System.Text에 있는 StringBuilder는 변경 가능한 버퍼입니다. Append는 끝의 여유 공간에 쓰면서 가끔 버퍼를 키우고, ToString()은 마지막에 문자열 하나를 만듭니다.
출력:
Order #1042, total 59.97
Status: shipped
**********
51
루프 안의 이어 붙이기가 느린 이유
같은 텍스트를 두 가지 방법으로 만들어 봅니다. 시간은 컴퓨터마다 다르므로 아래 숫자는 예시일 뿐입니다. 중요한 것은 차이이며, 루프가 커질수록 벌어집니다:
출력 예:
string += 482 ms, length 188890
StringBuilder 1 ms, length 188890
20,000번 추가하면 += 버전은 모두 합쳐 거의 20억 개의 문자를 복사합니다. StringBuilder는 각 문자를 대략 두 번, 추가할 때 한 번과 ToString()에서 한 번 복사하므로 1밀리초 근처에 머뭅니다.
Append, AppendLine, AppendFormat
Append에는 모든 기본 제공 타입에 대한 오버로드가 있고 같은 빌더를 반환하므로 호출을 연결할 수 있습니다. AppendLine은 인수 뒤에 줄바꿈을 추가합니다. AppendFormat은 string.Format처럼 복합 서식 문자열을 받습니다:
출력:
RECEIPT
-------
Espresso 2.40
Croissant 3.10
Orange juice 4.50
TOTAL 10.00
보간 문자열을 추가하는 것(sb.Append($"..."))도 괜찮고 읽기 좋습니다. .NET 6 이상에서는 컴파일러가 중간 문자열을 만들지 않고 조각들을 빌더에 바로 씁니다.
AppendLine은 Environment.NewLine을 씁니다. Linux와 macOS에서는 \n, Windows에서는 \r\n입니다. 파일 형식이 특정 줄 끝을 요구한다면 직접 추가하세요.
Insert, Remove, Replace와 인덱서
문자열과 달리 StringBuilder는 제자리에서 편집할 수 있습니다:
출력:
Hello, World
Hello, Maya
Maya
maya
maya!
[] length 0
Length를 더 작은 값으로 설정하면 잘립니다. 끝의 구분자를 없애는 가장 저렴한 방법입니다. Clear()는 길이를 0으로 만들고 할당된 버퍼는 유지하므로, 바깥 루프의 반복마다 빌더 하나를 재사용할 수 있습니다.
인덱서는 최근에 추가한 부분에는 빠르지만, 내용이 연결된 청크에 저장되기 때문에 아주 큰 빌더에서 임의 접근을 하면 느릴 수 있습니다. 문자를 읽어야 한다면 ToString()을 한 번 호출하고 문자열에서 인덱싱하세요.
끝에 남는 구분자 문제
전형적인 용도는 쉼표로 구분된 목록 만들기입니다. 항목마다 뒤에 구분자를 붙이면 끝에 하나가 남습니다:
출력:
csharp, dotnet, linq
csharp, dotnet, linq
항목이 이미 컬렉션에 있다면 string.Join이 더 짧고 최소한 같은 속도입니다. StringBuilder는 조건부 섹션, 중첩 루프, 섞인 서식처럼 모양이 로직에 따라 달라지는 텍스트에 쓰세요.
용량
StringBuilder는 16글자 공간으로 시작합니다. 공간이 모자라면 이미 담고 있는 전체만큼 큰(블록당 최대 8,000글자) 새 저장 블록을 연결하므로, 작을 때는 용량이 두 배씩 늘고 그 뒤로는 8,000글자 단위로 늡니다. 늘어날 때 이미 저장된 텍스트를 복사하지는 않습니다. 결과의 대략적인 길이를 안다면 생성자에 용량을 넘기세요. 그러면 빌더가 늘어날 필요가 전혀 없습니다:
출력:
12000
10000
True
True
미리 크기를 정하는 것은 최적화일 뿐 필수가 아닙니다. 늘어날 때 새 블록을 할당하지만 기존 텍스트를 옮기지 않으므로, 성능이 중요한 경로에서 큰 문자열을 많이 만드는 경우가 아니라면 기본값으로 충분합니다.
StringBuilder를 쓰지 말아야 할 때
StringBuilder가 모든 +의 더 빠른 대체품은 아닙니다. 컴파일러는 한 식 안의 a + b + c + d를 이미 하나의 string.Concat 호출로 바꾸며, 이 호출은 최종 길이를 계산하고 각 조각을 한 번씩 복사합니다. 빌더가 더 많은 코드로 하는 일과 같습니다. 다음 경우에는 일반 문자열을 쓰세요:
- 조각 수가 적고 정해져 있을 때:
$"{first} {last} ({age})". - 조각이 컬렉션에 있고 구분자가 필요할 때:
string.Join(", ", items). - 조각에 구분자가 필요 없을 때:
string.Concat(parts). - 루프 밖에서 몇 번 이어 붙일 때.
루프 안에서 추가하거나, 미리 알 수 없는 횟수만큼 추가하거나, 만드는 도중 텍스트를 편집(삽입, 바꾸기, 제거)할 때 StringBuilder를 쓰세요. 앞뒤로 변환하면(루프 안의 sb.ToString()) 이점이 사라지므로 마지막에 한 번만 호출하세요.
스트림도 대안입니다. 큰 파일을 만들 때는 전체 내용을 메모리에 만들지 말고 진행하면서 StreamWriter에 줄을 쓰세요. 파일을 참고하세요.
자주 묻는 질문
C#의 StringBuilder란 무엇인가요?
System.Text.StringBuilder는 여러 단계에 걸쳐 문자열을 만들기 위한 변경 가능한 문자 버퍼입니다. Append는 매번 새 문자열을 만드는 대신 버퍼 끝에 추가하고, ToString()은 마지막에 한 번만 최종 문자열을 만듭니다. 루프에서 텍스트를 만드는 표준 도구입니다.
언제 문자열 이어 붙이기 대신 StringBuilder를 써야 하나요?
컬렉션으로부터 보고서, CSV 파일, HTML을 만들 때처럼 루프 안에서 또는 알 수 없는 횟수만큼 추가할 때입니다. 정해진 몇 조각(a + b + c, 또는 보간 문자열 하나)이라면 일반 이어 붙이기도 똑같이 빠르고 더 읽기 쉽습니다. 컴파일러가 이미 하나의 string.Concat 호출로 합치기 때문입니다.
StringBuilder에서 마지막 문자는 어떻게 제거하나요?
Length를 줄입니다. if (sb.Length > 0) sb.Length--;는 복사 없이 마지막 문자를 버립니다. sb.Remove(sb.Length - 1, 1)도 같은 일을 합니다. 끝에 붙은 쉼표를 벗기는 흔한 방법이지만, string.Join을 쓰면 처음부터 끝에 구분자가 붙지 않습니다.
StringBuilder는 스레드로부터 안전한가요?
아니요. 여러 스레드에서 같은 StringBuilder에 동시에 Append를 호출하면 내용이 손상되거나 예외가 발생할 수 있습니다. 스레드마다 별도의 빌더를 주고 결과를 합치거나, 공유 빌더를 lock으로 보호하세요.
C#에서 AppendLine은 무엇을 추가하나요?
텍스트 뒤에 Environment.NewLine을 추가하며, 이는 Windows에서 \r\n, Linux와 macOS에서 \n입니다. 파일 형식의 \n이나 HTTP 헤더의 \r\n처럼 특정 줄 끝이 필요하다면 대신 Append("\n")로 명시적으로 추가하세요.