---
title: "문자열은 바꿀 수 없다"
description: "문자열은 문자를 나란히 놓은 것입니다. 인덱스가 0부터인 이유부터, 고칠 수 없어서 + 가 매번 새 문자열을 만드는 것까지 봅니다."
author: "유니얼"
pubDate: 2026-09-08
category: "programming/csharp/basics"
tags: ["C#", "기초"]
canonical: https://blog.unialgames.com/posts/csharp-string-basics/
---
앞 글에서 글자 하나에 번호가 붙는 걸 봤습니다. `'A'` 는 65였습니다.

이제 글자 여러 개를 다룹니다. 그게 `string` 입니다.

```csharp
string s = "Hello";

Console.WriteLine(s.Length);   // 5
Console.WriteLine(s[0]);       // H
Console.WriteLine(s[4]);       // o
Console.WriteLine(s[5]);       // IndexOutOfRangeException
```

글자가 다섯 개인데 `s[5]` 가 없습니다. 그리고 첫 글자가 `s[1]` 이 아니라 `s[0]` 입니다.
이게 컴퓨터의 별난 습관처럼 보이는데, 앞의 이야기로 돌아가면 당연한 결과입니다.

## 문자열은 문자를 나란히 놓은 것

`"Hello"` 는 `char` 다섯 개입니다. 그리고 그 다섯 개가 메모리에 **빈틈없이 붙어 있습니다.**

앞 글에서 본 대로 `char` 는 2바이트니, 이 문자열은 10바이트를 통째로 씁니다.

붙어 있으면 좋은 점이 하나 있습니다. **몇 번째 글자든 계산으로 바로 찾습니다.**
시작 지점을 알고 칸 하나의 크기를 알면 나머지는 곱셈과 덧셈입니다.

> **찾아갈 자리 = 시작 지점 + 인덱스 × 칸 하나의 크기**

글자를 눌러 보세요.

<x-array>
<script type="application/json">
{
  "label": "\"Hello\" 에서 자리를 찾는 법",
  "labels": ["H", "e", "l", "l", "o"],
  "types": [["char", 2]],
  "default": 2,
  "count": 5,
  "baseAddr": 4096,
  "note": "주소는 설명을 위한 값입니다. 실제 주소는 실행할 때마다 달라집니다."
}
</script>
</x-array>

여기서 답이 나옵니다. **인덱스 0을 넣으면 시작 지점 그대로입니다.**

```text
0x1000 + 0 × 2 = 0x1000
```

첫 글자는 시작 지점에서 **0칸 떨어져 있습니다.** 그래서 0번입니다. 1번이 아닙니다.

> **인덱스는 "몇 번째냐" 가 아니라 "시작에서 몇 칸 떨어졌느냐" 입니다.**

달리기 출발선과 같습니다. 출발선에 서 있는 첫 사람은 **0미터 지점**에 있습니다.
1미터 지점이 아닙니다.

### 마지막은 `Length` 가 아니라 `Length - 1`

글자가 5개면 인덱스는 `0` 부터 시작하니 마지막은 `4` 입니다.

`s[5]` 는 다섯 칸 떨어진 자리인데, **거기는 문자열이 끝난 뒤**입니다.
남의 자리라서 예외를 던집니다.

```csharp
for (int i = 0; i < s.Length; i++)    // i <= s.Length 가 아닙니다
{
    Console.WriteLine(s[i]);
}
```

`<=` 로 쓰면 마지막 한 바퀴에서 예외가 납니다.
**반복문에서 `<` 를 쓰는 이유가 이것입니다.**

> 마지막 글자를 꺼낼 때는 `s[s.Length - 1]` 대신 `s[^1]` 을 쓸 수 있습니다.
> `^` 는 "끝에서부터" 라는 뜻이고 `^1` 이 마지막 글자입니다.
> 끝에서 세는 것이라 `^0` 은 없습니다.

전부 훑기만 할 거면 `foreach` 가 낫습니다. 인덱스를 안 쓰니 한 칸 넘어갈 일도 없습니다.

```csharp
foreach (char c in s)
{
    Console.WriteLine(c);
}
```

## 읽을 수는 있는데 쓸 수는 없다

인덱스로 글자를 **읽는 것**은 됩니다. 그런데 **쓰는 것**은 안 됩니다.

```csharp
string s = "Hello";

char c = s[0];    // 읽는 건 됩니다
s[0] = 'h';       // 컴파일 에러
```

C# 의 `string` 은 **불변(immutable)** 입니다.
한 번 만들어지면 그 내용이 끝까지 바뀌지 않습니다.

> 왜 이렇게 만들었을까요. 문자열은 여기저기서 돌려쓰기 때문입니다.
> 누군가 내용을 고칠 수 있으면 그 문자열을 들고 있던 다른 코드도 같이 바뀝니다.
> 바뀌지 않는다고 보장하면 그런 사고가 없고, 여러 곳에서 같이 읽어도 안전합니다.

그래서 문자열을 바꾸는 메서드들은 원본을 고치는 게 아니라
**바뀐 새 문자열을 돌려줍니다.**

```csharp
string s = "Hello";

s.ToUpper();              // 아무 일도 안 일어납니다
Console.WriteLine(s);     // Hello

s = s.ToUpper();          // 돌려받은 것을 다시 담아야 합니다
Console.WriteLine(s);     // HELLO
```

`ToUpper` · `ToLower` · `Replace` · `Trim` · `Substring` 전부 같습니다.
**돌려받지 않으면 아무 일도 일어나지 않습니다.** 처음에 자주 걸리는 자리입니다.

## `+` 는 새 문자열을 만든다

불변이라는 성질은 `+` 에서 비용이 됩니다.

```csharp
string s = "Hello";
s = s + " World";
```

기존 문자열이 늘어나는 게 아닙니다. **`"Hello World"` 라는 새 문자열이 따로 만들어지고**,
`s` 가 그쪽을 가리키게 됩니다. 원래 `"Hello"` 는 아무도 안 쓰게 되어 GC 를 기다립니다.

쓰던 종이에 이어 쓰는 게 아니라 **매번 새 종이에 전부 옮겨 적는 것**과 같습니다.

한두 번이면 아무 문제 없습니다. **반복문 안에 들어가면 달라집니다.**

<x-calc>
<script type="application/json">
{
  "label": "이어붙이는 동안 복사되는 글자 수",
  "inputs": [
    { "id": "n", "label": "이어붙이는 횟수", "options": [10, 100, 1000, 10000], "default": 1000, "suffix": "회" },
    { "id": "len", "label": "한 번에 붙이는 글자 수", "options": [1, 10, 50], "default": 10, "suffix": "자" }
  ],
  "bars": [
    { "label": "+ 로 이어붙이기", "expr": "len * n * (n + 1) / 2", "unit": "자" },
    { "label": "StringBuilder", "expr": "len * n * 2", "unit": "자", "accent": true }
  ],
  "verdict": { "expr": "bars[1] < bars[0]", "yes": "StringBuilder 가 이깁니다", "no": "차이가 거의 없습니다" },
  "note": "+ 는 매번 지금까지 만든 것을 전부 다시 옮겨 적습니다. 그래서 횟수의 제곱에 비례해 늘어납니다. StringBuilder 는 버퍼를 잡아두고 모자랄 때만 늘리므로, 늘릴 때의 복사까지 대략 두 배로 잡았습니다."
}
</script>
</x-calc>

**열 번은 차이가 없고, 만 번은 다른 세상입니다.**
`+` 는 매번 지금까지 쌓인 것을 전부 새 종이에 옮겨 적기 때문입니다.

그래서 반복문 안에서 문자열을 쌓을 때는 `StringBuilder` 를 씁니다.

<x-compare>
<script type="application/json">
{
  "label": "반복문 안에서 문자열 쌓기",
  "tabs": [
    { "label": "이렇게 하면", "code": "string log = \"\";\n\nfor (int i = 0; i < 10000; i++)\n{\n    log += i + \",\";\n}", "stats": [["만들어지는 문자열", "10,000개"], ["복사되는 글자", "횟수의 제곱에 비례"]], "tone": "bad" },
    { "label": "이렇게", "code": "var sb = new StringBuilder();\n\nfor (int i = 0; i < 10000; i++)\n{\n    sb.Append(i).Append(',');\n}\n\nstring log = sb.ToString();", "stats": [["만들어지는 문자열", "1개"], ["복사되는 글자", "횟수에 비례"]], "tone": "good" }
  ]
}
</script>
</x-compare>

`StringBuilder` 는 **바꿀 수 있는 버퍼**입니다. 다 쌓은 뒤 `ToString()` 으로
한 번만 문자열을 만듭니다. `using System.Text;` 가 필요합니다.

### 그래도 몇 번은 그냥 `+` 를 써도 된다

한 줄 안에서 여러 번 이어붙이는 것은 괜찮습니다.

```csharp
string name = "손" + "오공" + " 님";
```

컴파일러가 이런 연속된 `+` 를 **한 번의 합치기로 묶어줍니다.**
중간 문자열이 생기지 않습니다.

문제가 되는 건 **반복문처럼 한 번에 한 조각씩 여러 번 나눠 붙일 때**입니다.
그때는 컴파일러가 묶을 수가 없습니다.

읽기 좋게 쓸 거면 문자열 보간이 낫습니다.

```csharp
string msg = $"{name} 님의 점수는 {score}점입니다";
```

앞에 `$` 를 붙이면 중괄호 안에 변수를 그대로 넣을 수 있습니다.
`+` 로 이어붙이는 것보다 읽기 쉽고 속도도 같습니다.

## `Length` 가 눈에 보이는 글자 수가 아닐 수 있다

앞 글에서 `char` 가 UTF-16 한 조각이라 2바이트라고 했습니다.
여기에 한 가지가 딸려 옵니다.

```csharp
Console.WriteLine("한글".Length);   // 2  (글자 두 개, 맞습니다)
Console.WriteLine("👍".Length);     // 2  (글자는 하나인데요?)
```

`char` 는 **글자 하나가 아니라 UTF-16 한 조각**입니다.
대부분의 글자는 조각 하나에 들어가서 한글도 한자도 문제없습니다.
그런데 이모지 같은 일부 문자는 **조각 두 개를 써야 합니다.**

그래서 이모지가 섞인 문자열은 `Length` 가 눈에 보이는 글자 수와 다르고,
인덱스로 자르면 글자가 반토막 납니다.

> 눈에 보이는 글자 단위로 세거나 잘라야 한다면
> `System.Globalization.StringInfo` 를 쓰거나, 최신 C# 이라면
> `EnumerateRunes()` 를 씁니다. 사용자 닉네임 길이 제한처럼
> **사람이 세는 글자 수**가 필요한 자리에서 이 차이가 드러납니다.

## 정리

- 문자열은 문자를 메모리에 나란히 놓은 것이다.
- 인덱스는 순번이 아니라 시작 지점에서의 거리다. 그래서 0부터 시작한다.
- 마지막 인덱스는 `Length` 가 아니라 `Length - 1` 이다. 반복문에서 `<` 를 쓰는 이유다.
- `string` 은 불변이다. 읽을 수는 있어도 쓸 수는 없다.
- `ToUpper` 같은 메서드는 원본을 고치지 않고 새 문자열을 돌려준다. 돌려받아야 한다.
- `+` 는 매번 새 문자열을 만든다. 반복문 안에서는 `StringBuilder` 를 쓴다.
- 한 줄 안의 연속된 `+` 는 컴파일러가 묶어주므로 괜찮다.
- `char` 는 UTF-16 한 조각이라, 이모지가 섞이면 `Length` 가 보이는 글자 수와 다르다.