---
title: "자르고 찾는 것은 전부 새 문자열이다"
description: "Substring 의 둘째 인자는 끝 번호가 아니라 개수입니다. IndexOf 는 못 찾으면 -1 을 돌려줍니다. 두 가지만 알아도 문자열 버그의 대부분이 없어집니다."
author: "유니얼"
pubDate: 2026-09-09
category: "programming/csharp/basics"
tags: ["C#", "기초"]
canonical: https://blog.unialgames.com/posts/csharp-string-methods/
---
앞에서 이렇게 적었습니다.

> `ToUpper` · `ToLower` · `Replace` · `Trim` · `Substring` 전부 같습니다.
> 돌려받지 않으면 아무 일도 일어나지 않습니다.

이제 그 메서드들을 실제로 씁니다. 규칙은 하나뿐입니다.
**원본은 그대로 두고 새 문자열을 돌려줍니다.**

```csharp
string text = "Hello, World!";

text.ToUpper();             // 결과를 아무도 안 받았습니다
Console.WriteLine(text);    // Hello, World!

string upper = text.ToUpper();
Console.WriteLine(upper);   // HELLO, WORLD!
Console.WriteLine(text);    // Hello, World!   ← 원본은 여전히 그대로
```

문자열이 불변이라서 그렇습니다. 고칠 방법이 없으니 새로 만드는 것 말고는 할 수 있는 게 없습니다.

이 글에서 다루는 것은 여섯 개입니다. 전부 같은 규칙을 따릅니다.

| 메서드 | 하는 일 | 예 | 결과 |
| --- | --- | --- | --- |
| `Substring(i, n)` | `i` 번부터 `n` 개 잘라내기 | `text.Substring(7, 5)` | `"World"` |
| `IndexOf(x)` | 처음 나오는 칸 번호 | `text.IndexOf('W')` | `7` |
| `Contains(x)` | 들어 있는지 | `text.Contains("World")` | `true` |
| `Trim()` | 앞뒤 공백 제거 | `"  hi  ".Trim()` | `"hi"` |
| `Replace(a, b)` | `a` 를 전부 `b` 로 | `text.Replace("World", "C#")` | `"Hello, C#!"` |
| `Split(c)` | 구분자로 쪼개기 | `"a,b,c".Split(',')` | `["a", "b", "c"]` |

## `Substring` 의 둘째 인자는 개수다

가장 많이 헷갈리는 자리입니다. 두 번째 숫자는 **끝 번호가 아니라 개수**입니다.

<figure class="diagram">
<svg viewBox="0 0 680 190" role="img" aria-label="Hello, World! 의 각 글자에 0부터 12까지 번호가 붙어 있고 7번부터 다섯 칸이 잘려 나오는 그림">
<g stroke="var(--border)" fill="var(--bg)">
<rect x="40" y="30" width="44" height="44" rx="6"></rect>
<rect x="84" y="30" width="44" height="44" rx="6"></rect>
<rect x="128" y="30" width="44" height="44" rx="6"></rect>
<rect x="172" y="30" width="44" height="44" rx="6"></rect>
<rect x="216" y="30" width="44" height="44" rx="6"></rect>
<rect x="260" y="30" width="44" height="44" rx="6"></rect>
<rect x="304" y="30" width="44" height="44" rx="6"></rect>
<rect x="568" y="30" width="44" height="44" rx="6"></rect>
</g>
<g stroke="var(--accent)" fill="var(--accent-soft)">
<rect x="348" y="30" width="44" height="44" rx="6"></rect>
<rect x="392" y="30" width="44" height="44" rx="6"></rect>
<rect x="436" y="30" width="44" height="44" rx="6"></rect>
<rect x="480" y="30" width="44" height="44" rx="6"></rect>
<rect x="524" y="30" width="44" height="44" rx="6"></rect>
</g>
<g font-family="var(--font-mono)" font-size="17" text-anchor="middle" fill="var(--text)">
<text x="62" y="58">H</text>
<text x="106" y="58">e</text>
<text x="150" y="58">l</text>
<text x="194" y="58">l</text>
<text x="238" y="58">o</text>
<text x="282" y="58">,</text>
<text x="326" y="58">␣</text>
<text x="590" y="58">!</text>
</g>
<g font-family="var(--font-mono)" font-size="17" text-anchor="middle" fill="var(--accent)">
<text x="370" y="58">W</text>
<text x="414" y="58">o</text>
<text x="458" y="58">r</text>
<text x="502" y="58">l</text>
<text x="546" y="58">d</text>
</g>
<g font-family="var(--font-mono)" font-size="12" text-anchor="middle" fill="var(--text-muted)">
<text x="62" y="90">0</text>
<text x="106" y="90">1</text>
<text x="150" y="90">2</text>
<text x="194" y="90">3</text>
<text x="238" y="90">4</text>
<text x="282" y="90">5</text>
<text x="326" y="90">6</text>
<text x="370" y="90">7</text>
<text x="414" y="90">8</text>
<text x="458" y="90">9</text>
<text x="502" y="90">10</text>
<text x="546" y="90">11</text>
<text x="590" y="90">12</text>
</g>
<g stroke="var(--accent)" stroke-width="1.6" fill="none">
<path d="M348 108 L348 120 L568 120 L568 108"></path>
</g>
<text x="458" y="142" text-anchor="middle" font-size="14" fill="var(--accent)">7번부터 다섯 칸</text>
<text x="458" y="168" text-anchor="middle" font-family="var(--font-mono)" font-size="14" fill="var(--text-muted)">text.Substring(7, 5)</text>
</svg>
<figcaption>둘째 인자 5는 마지막 번호가 아니라 가져올 칸의 개수입니다.</figcaption>
</figure>

```csharp
string text = "Hello, World!";

text.Substring(7, 5);    // "World"    7번부터 5칸
text.Substring(7);       // "World!"   7번부터 끝까지
text.Substring(7, 10);   // 예외       7 + 10 이 13을 넘습니다
```

시작 위치에 개수를 더한 값이 `Length` 를 넘으면 `ArgumentOutOfRangeException` 이 납니다.
앞에서 `s[5]` 가 예외였던 것과 같은 이유입니다. **없는 칸을 달라고 한 것**입니다.

> 끝 번호로 자르는 게 익숙하다면 범위 연산자를 쓸 수 있습니다.
> `text[7..12]` 는 7번부터 12번 **앞까지**입니다. 앞에서 본 `^1` 과 같은 문법이고,
> `text[7..]` 은 7번부터 끝까지, `text[..5]` 는 처음부터 5번 앞까지입니다.
> 반복문에서 `i < length` 를 쓰는 것과 같은 규칙이라 익숙해지면 이쪽이 덜 헷갈립니다.

## `IndexOf` 는 못 찾으면 `-1` 을 준다

자를 위치를 미리 아는 경우는 드뭅니다. 보통은 찾아서 자릅니다.

```csharp
int i = email.IndexOf('@');
string domain = email.Substring(i + 1);
```

이 두 줄에 함정이 있습니다. `IndexOf` 는 **못 찾으면 `-1`** 을 돌려주는데,
그걸 그대로 `Substring` 에 넘기면 예외가 납니다.

**찾은 다음에는 반드시 확인합니다.**

<x-flow>
<script type="application/json">
{
  "label": "찾아서 자르기",
  "lines": [
    "string email = ...;",
    "int i = email.IndexOf('@');",
    "if (i >= 0)",
    "{",
    "    string domain = email.Substring(i + 1);",
    "    Console.WriteLine(domain);",
    "}",
    "else",
    "{",
    "    Console.WriteLine(\"@ 가 없습니다\");",
    "}"
  ],
  "cases": [
    {
      "label": "@ 가 있을 때",
      "state": "email = \"id@mail.com\"",
      "path": [
        { "line": 0, "why": "주소를 담습니다.", "state": "email = \"id@mail.com\"" },
        { "line": 1, "why": "@ 가 처음 나오는 칸 번호를 얻습니다.", "state": "i = 2" },
        { "line": 2, "why": "찾았으므로 0 이상입니다.", "verdict": "true" },
        { "line": 4, "why": "@ 다음 칸부터 끝까지 잘라냅니다.", "state": "domain = \"mail.com\"" },
        { "line": 5, "why": "잘라낸 것을 출력합니다.", "out": "mail.com" }
      ],
      "skip": [7, 8, 9, 10]
    },
    {
      "label": "@ 가 없을 때",
      "state": "email = \"idmail.com\"",
      "path": [
        { "line": 0, "why": "@ 가 빠진 주소입니다.", "state": "email = \"idmail.com\"" },
        { "line": 1, "why": "못 찾았으므로 -1 입니다.", "state": "i = -1" },
        { "line": 2, "why": "-1 은 0보다 작습니다.", "verdict": "false" },
        { "line": 9, "why": "여기로 옵니다. 자르기는 하지 않습니다.", "out": "@ 가 없습니다" }
      ],
      "skip": [3, 4, 5, 6]
    }
  ],
  "note": "확인 없이 Substring(i + 1) 로 갔다면 두 번째 경우에 Substring(0) 이 되어, 예외 없이 주소 전체가 도메인이 됩니다. 더 나쁜 종류의 버그입니다."
}
</script>
</x-flow>

두 번째 경우가 특히 위험합니다. `i` 가 `-1` 이면 `i + 1` 은 `0` 이라
**예외도 안 나고 잘못된 값이 조용히 나옵니다.** 프로그램은 멀쩡히 도는데
`domain` 에 주소 전체가 들어가 있습니다.

같은 가족으로 이런 것들이 있습니다.

```csharp
text.Contains("World");      // true          들어 있는지만
text.StartsWith("Hello");    // true          이걸로 시작하는지
text.EndsWith("!");          // true          이걸로 끝나는지
text.LastIndexOf('l');       // 10            뒤에서부터 찾기
```

위치가 필요 없고 있는지만 알면 되는 경우에는 `Contains` 가 낫습니다.
`-1` 을 확인할 일이 없어서 위 함정을 아예 만들지 않습니다.

## `Trim` 은 앞뒤만, `Replace` 는 전부

```csharp
"   hi   ".Trim();               // "hi"        가운데 공백은 그대로
"the fox".Replace("fox", "cat"); // "the cat"
"a-b-c".Replace("-", "");        // "abc"       빈 문자열로 바꾸면 삭제
```

`Trim` 은 **앞뒤 공백만** 없앱니다. `"a b".Trim()` 은 `"a b"` 그대로입니다.
`TrimStart` 와 `TrimEnd` 로 한쪽만 다듬을 수도 있습니다.

`Replace` 는 찾은 것을 **전부** 바꿉니다. 하나만 바꾸는 옵션은 없습니다.
그리고 **대소문자를 구분합니다.** `"Fox"` 를 찾을 때 `"fox"` 는 안 걸립니다.

```csharp
string input = Console.ReadLine().Trim();
```

사용자 입력은 받자마자 `Trim` 하는 습관을 들이면 좋습니다.
복사해서 붙여 넣은 값에는 앞뒤 공백이 딸려 오는 경우가 많고,
그 공백 때문에 비교가 실패하면 원인을 찾기 어렵습니다.

## `Split` 은 구분자로 쪼갠다

`Split` 만 돌려주는 것이 다릅니다. 문자열 하나가 아니라 **문자열 여러 개**,
그러니까 [배열](/posts/csharp-array/)입니다.

```csharp
string s = "Apple,Banana,Cherry";
string[] fruits = s.Split(',');

Console.WriteLine(fruits.Length);   // 3
Console.WriteLine(fruits[0]);       // Apple

foreach (string f in fruits)
{
    Console.WriteLine(f);
}
```

**구분자는 결과에 남지 않습니다.** 점선을 따라 종이를 자르면 점선 자체는 버려지는 것과 같습니다.
그래서 조각의 개수는 구분자 개수보다 하나 많습니다.

한 가지 주의할 게 있습니다. **구분자가 붙어 있으면 가운데에 빈 문자열이 생깁니다.**

```csharp
"a,,b".Split(',');        // ["a", "", "b"]   길이 3
"a,,b".Split(',', StringSplitOptions.RemoveEmptyEntries);   // ["a", "b"]
```

사용자가 쉼표를 두 번 찍었다고 프로그램이 깨지면 곤란하니,
입력을 쪼갤 때는 `RemoveEmptyEntries` 를 같이 넘기는 편이 안전합니다.
`TrimEntries` 를 함께 주면 각 조각의 앞뒤 공백까지 다듬어 줍니다.

반대 방향은 `string.Join` 입니다.

```csharp
string joined = string.Join(", ", fruits);   // "Apple, Banana, Cherry"
```

`Split` 과 `Join` 이 짝입니다. 쪼개서 손보고 다시 합치는 것이
문자열 처리의 가장 흔한 모양입니다.

## 정리

- 문자열 메서드는 전부 원본을 두고 새 문자열을 돌려준다. 돌려받아야 한다.
- `Substring(i, n)` 의 `n` 은 끝 번호가 아니라 개수다. `i + n` 이 `Length` 를 넘으면 예외다.
- 끝 번호로 쓰고 싶으면 범위 연산자 `text[7..12]` 를 쓴다.
- `IndexOf` 는 못 찾으면 `-1` 이다. 자르기 전에 반드시 확인한다.
- `-1` 을 그대로 넘기면 예외가 아니라 잘못된 값이 조용히 나올 수 있다.
- 있는지만 알면 되는 자리에는 `Contains` 를 쓴다. 확인할 `-1` 이 없다.
- `Trim` 은 앞뒤만, `Replace` 는 전부 바꾸고 대소문자를 구분한다.
- `Split` 은 배열을 돌려준다. 구분자는 사라지고, 붙어 있으면 빈 칸이 생긴다.