---
title: "문자열을 반복문 안에서 이으면 매번 새로 만든다"
description: "못 바꾼다는 성질의 대가는 반복 횟수만큼 붙습니다. 그런데 대부분의 자리에서는 신경 쓸 필요가 없습니다."
author: "유니얼"
pubDate: 2026-09-09
category: "programming/csharp/advanced"
tags: ["C#", "최적화"]
canonical: https://blog.unialgames.com/posts/csharp-stringbuilder/
---
앞에서 문자열은 바꿀 수 없다고 했습니다. `+` 로 이으면 원래 것을 늘리는 것이 아니라 새 문자열을 만든다고요.

그때는 성질만 짚고 넘어갔습니다. 이제 그 성질이 얼마짜리인지 봅니다.

```csharp
string s = "";
for (int i = 0; i < 10000; i++)
{
    s += i;
}
```

이 코드는 문자열을 만 번 만듭니다. 하나가 아니라요.

> **못 바꾸니까 이을 때마다 새로 만들고, 만들 때마다 앞의 것을 전부 복사합니다.**

## 한 바퀴 돌 때마다 무슨 일이

`s += i` 는 `s = s + i` 입니다. 세 단계입니다.

1. `s` 의 길이 + `i` 의 길이만큼 새 문자열 자리를 힙에 잡습니다
2. 옛 `s` 를 통째로 복사해 넣습니다
3. `i` 를 뒤에 붙이고, 그 새 문자열의 주소를 `s` 에 담습니다

옛 `s` 는 아무도 안 가리키게 되어 버려집니다.

문제는 2번입니다. **바퀴가 돌수록 복사할 양이 늘어납니다.**
백 번째 바퀴에서는 앞의 백여 글자를 복사하고, 오천 번째에서는 오천여 글자를 복사합니다.

그래서 비용이 반복 횟수에 비례하는 것이 아니라 **제곱에 비례**합니다.
열 배 돌면 열 배가 아니라 백 배가 됩니다.

<x-calc>
<script type="application/json">
{
  "label": "직접 해보기",
  "inputs": [
    { "id": "n", "label": "반복 횟수", "options": [10, 100, 1000, 10000, 100000], "default": 10000, "suffix": "회" },
    { "id": "len", "label": "한 번에 붙이는 글자 수", "options": [1, 5, 20], "default": 5, "suffix": "자" }
  ],
  "bars": [
    { "label": "+= 로 잇기", "expr": "len * n * (n + 1) / 2 * 2 / 1024", "unit": "KB" },
    { "label": "StringBuilder", "expr": "len * n * 2 * 2 / 1024", "unit": "KB", "accent": true }
  ],
  "verdict": { "expr": "bars[0] / bars[1] > 10", "yes": "차이가 열 배를 넘습니다", "no": "이 정도면 신경 쓸 것 없습니다" },
  "note": "복사한 총 글자 수를 바이트로 환산한 값입니다. 문자 하나가 2바이트, StringBuilder 는 늘릴 때 두 배씩 잡는다고 봤습니다."
}
</script>
</x-calc>

반복 횟수를 10 에서 100 으로 열 배 올려 보면 왼쪽 막대가 백 배 가까이 뜁니다.
`StringBuilder` 쪽은 딱 열 배만 오릅니다. 이 기울기 차이가 전부입니다.

## StringBuilder 는 미리 크게 잡고 채운다

`StringBuilder` 는 문자열이 아니라 **문자를 담는 배열**을 들고 있습니다. 그리고 그 배열은 바꿀 수 있습니다.

```csharp
var sb = new StringBuilder();
for (int i = 0; i < 10000; i++)
{
    sb.Append(i);
}
string s = sb.ToString();
```

`Append` 는 배열 뒤에 이어 씁니다. 복사가 없습니다.
자리가 모자라면 그때 더 큰 배열로 옮기는데, 이때 **두 배씩** 늘립니다.
그래서 만 번 붙이는 동안 옮기는 일은 열몇 번밖에 안 일어납니다.

앞에서 `List` 가 칸이 모자라면 알아서 늘린다고 한 것과 같은 방식입니다.
`List` 의 그 성질을 문자에 적용한 것이 `StringBuilder` 라고 봐도 됩니다.

몇 자쯤 될지 알면 미리 알려 주면 옮기는 일까지 없앨 수 있습니다.

```csharp
var sb = new StringBuilder(50000);
```

`ToString()` 을 부를 때 딱 한 번 진짜 문자열이 만들어집니다. 그 전까지는 문자열이 없습니다.

## 그런데 대부분은 신경 쓸 필요가 없다

여기가 더 중요합니다. `+` 를 보면 무조건 `StringBuilder` 로 바꾸는 것은 손해입니다.

**한 식 안에서 잇는 것은 이미 최적화되어 있습니다.**

```csharp
string s = a + b + c + d;
```

이건 네 번 만드는 것이 아닙니다. 컴파일러가 `string.Concat(a, b, c, d)` 한 번으로 바꿉니다.
전체 길이를 먼저 재고 한 번에 만듭니다. `StringBuilder` 보다 오히려 빠릅니다.

**보간 문자열도 마찬가지입니다.**

```csharp
string s = $"{이름}님 {점수}점";
```

요즘 컴파일러는 이걸 안에서 알아서 처리합니다. 손으로 `StringBuilder` 를 쓰는 것과 비슷하거나 낫습니다.

**진짜 문제는 반복문 안에서 변수에 계속 누적할 때**뿐입니다.

```csharp
// 이것만 문제입니다
for (...) { s += 무언가; }
```

한 식 안이면 컴파일러가 전체 길이를 미리 알 수 있는데, 반복문은 몇 바퀴 돌지 모르니 도와줄 수가 없습니다.

## 기준

| 상황 | 무엇을 |
| --- | --- |
| 한 식에서 몇 개 잇기 | `+` 나 `$"..."` 그대로 |
| 반복문 안에서 누적 | `StringBuilder` |
| 구분자로 목록 잇기 | `string.Join` |
| 같은 조각을 여러 번 | `string.Concat` 이나 `new string(c, n)` |

`string.Join` 은 알아 둘 만합니다. 대부분의 "반복문으로 문자열 잇기" 가 이걸로 한 줄이 됩니다.

```csharp
string s = string.Join(", ", 이름들);
```

안에서 `StringBuilder` 를 쓰고 있어서 직접 쓰는 것과 성능이 같습니다. 그리고 마지막 쉼표를 지우는 코드가 필요 없습니다.

## Unity 라면

매 프레임 UI 텍스트를 갱신하는 자리가 이 이야기의 전형입니다.

```csharp
void Update()
{
    점수텍스트.text = "점수: " + 점수;   // 프레임마다 문자열 하나
}
```

한 식이라 `Concat` 한 번이니 그 자체는 싸지만, 어쨌든 프레임마다 힙에 문자열이 하나 생깁니다.
초에 예순 개, 분에 삼천육백 개입니다. 앞에서 박싱을 볼 때와 같은 이야기입니다. 값이 아니라 횟수입니다.

가장 싼 해결은 문자열을 안 만드는 것입니다.

```csharp
private int 이전점수 = -1;

void Update()
{
    if (점수 != 이전점수)
    {
        점수텍스트.text = "점수: " + 점수;
        이전점수 = 점수;
    }
}
```

점수가 안 바뀌면 아무것도 안 만듭니다. `StringBuilder` 를 꺼내는 것보다 이쪽이 먼저입니다.

## 정리

- 문자열은 못 바꾸니 `+=` 는 매번 새로 만들고 앞의 것을 **전부 복사**합니다
- 그래서 반복 횟수의 제곱에 비례해 비싸집니다. 열 배 돌면 백 배입니다
- `StringBuilder` 는 문자 배열을 들고 두 배씩 늘립니다. 복사가 거의 없습니다
- **한 식 안의 `+` 와 `$"..."` 는 이미 최적화되어 있습니다.** 바꿀 필요가 없습니다
- 문제는 **반복문 안에서 누적하는 것**뿐입니다. 대개 `string.Join` 으로 끝납니다
- 매 프레임 도는 자리라면 아예 안 만드는 것이 먼저입니다