[PART6.배열과 문자열 기본(7/14)] 문자열이 불변이라는 것의 의미 — string은 절대 안 바뀐다
string은 한 번 만들어지면 char 데이터가 절대 수정되지 않는다 / s.ToUpper();만 적으면 결과가 사라지는 함정 / a + b + c + d 단일 표현식은 String.Concat(N) 한 번으로 최적화 / 반복문 누적 +=은 매 반복 새 string / StringBuilder·string.Join·보간이 정답 / 멀티스레드 안전·Dictionary 키 안전·문자열 인터닝의 부수 효과
목차
1. 한 줄짜리 함정
신입이 작성한 코드 한 줄로 이야기를 시작합니다.
string s = "hello";
s.ToUpper();
Console.WriteLine(s); // ?
답을 미리 적으면 — "hello"가 그대로 출력됩니다. s.ToUpper()가 만든 "HELLO"는 어디론가 사라졌습니다. C·Java·JavaScript에서 넘어온 사람이라면 "원본이 바뀐 게 아니라 새 string을 반환한다"는 말은 들어 봤겠지만, 이 한 줄 함정에서 진짜 의미가 드러납니다.
이 글은 string이 불변(immutable)이라는 사실의 진짜 의미와, 그 사실이 만드는 두 가지 결과를 다룹니다.
- 잘못 쓰면 GC 폭탄을 만든다 — 매 반복마다 임시 string이 누적되는 패턴이 가장 흔하다.
- 잘 쓰면 동시성과 메모리 효율의 무료 보너스를 받는다 — 멀티스레드 안전, Dictionary 키 안전, 문자열 인터닝.
2. 불변이란 무엇인가
비유 — 한번 인쇄된 책
string은 인쇄가 끝난 책 한 권과 같습니다. 책의 내용을 "수정"하고 싶다면 그 책을 고치는 게 아니라 새 책을 한 권 인쇄해야 합니다. 원본 책은 그대로 도서관에 있고, 수정된 책은 별도 객체로 어딘가에 놓입니다 — 도서관에 두 권이 같이 살아 있습니다.
s.ToUpper()도 정확히 같은 일을 합니다 — 새 책을 인쇄해서 돌려줍니다. 원본 s는 손도 안 댑니다.
메모리 그림

IL로 본 함정
public static string LooksLikeUpper(string s)
{
s.ToUpper(); // ❌ 결과 버림 — 원본 s는 그대로
return s;
}
public static string CorrectUpper(string s)
{
s = s.ToUpper(); // ✅ 새 string을 다시 받음
return s;
}
// LooksLikeUpper
IL_0001: ldarg.0 // s 로드
IL_0002: callvirt instance string System.String::ToUpper() // 새 string 반환
IL_0007: pop // ← 그 결과를 그냥 버림!
IL_0008: ldarg.0 // 원본 s를 다시 로드
IL_000d: ret // 그대로 반환
// CorrectUpper
IL_0001: ldarg.0
IL_0002: callvirt instance string System.String::ToUpper()
IL_0007: starg.s s // ← 새 string을 매개변수 s 자리에 덮어쓰기
IL_0009: ldarg.0
IL_000e: ret
LooksLikeUpper의 pop 한 줄이 결정적입니다. ToUpper()는 분명 새 string을 만들어 스택에 올렸는데, 그 다음 pop이 그걸 즉시 버리고 있습니다. 결과로 만들어진 새 string은 누구도 참조하지 않으니 곧장 GC 대상이 됩니다 — 게다가 만들어졌다 버려진 그 단명 객체는 핫패스에서 GC 부담이 됩니다.
s = s.ToUpper(); 패턴은 새 string을 변수에 다시 받습니다. 원본은 더 이상 참조되지 않으면 GC가 가져가고, 변수 s는 새 string을 가리키게 됩니다.
모든 string 변경 메서드의 사용 규칙s.ToUpper()·s.Trim()·s.Replace()·s.Substring()·s.Insert()·s.Remove()— 반환값을 받지 않으면 그 호출은 의미가 없다. 항상s = s.SomeMethod(...)또는var t = s.SomeMethod(...).
3. + 연결의 진짜 비용
string이 불변이라는 사실이 가장 큰 영향을 주는 자리는 문자열 연결입니다. 신입이 가장 자주 만드는 비효율 코드입니다.
단일 표현식은 컴파일러가 최적화
좋은 소식부터. 한 줄짜리 표현식 a + b + c + d는 컴파일러가 자동으로 한 번에 합쳐 줍니다.
public static string Joined4(string a, string b, string c, string d)
=> a + b + c + d;
IL_0000: ldarg.0
IL_0001: ldarg.1
IL_0002: ldarg.2
IL_0003: ldarg.3
IL_0004: call string System.String::Concat(string, string, string, string)
// ← 한 번의 호출로 끝!
IL_0009: ret
a + b + c + d 한 줄이 String.Concat(a, b, c, d) 단일 메서드 호출로 컴파일됩니다. 임시 string이 만들어지는 게 아닙니다 — Concat이 내부에서 정확한 길이를 한 번 계산해 새 string을 한 번만 할당합니다. 결과 객체는 1개입니다.
String.Concat은 인자 개수별 오버로드가 2/3/4까지 있고, 그 이상은 params string[] 또는 IEnumerable<string> 오버로드로 흘러갑니다.
반복문 누적은 최적화 불가 — N개의 임시 string
// ❌ 반복마다 새 string
public static string BadLoop(string[] items)
{
string result = "";
for (int i = 0; i < items.Length; i++)
result = result + items[i] + ","; // 한 줄처럼 보이지만…
return result;
}
// 핵심 부분 — 루프 안에 String.Concat 호출이 박혀 있음
IL_000b: ldloc.0 // result
IL_000d: ldelem.ref // items[i]
IL_000f: ldstr ","
IL_0014: call string System.String::Concat(string, string, string)
// ← 매 반복마다 새 string!
IL_0019: stloc.0 // result에 새 string 대입
여기가 함정입니다. result + items[i] + "," 한 줄이 IL에서 Concat(string, string, string) 한 번이라 단일 표현식 같지만, 그 호출이 루프 안에 들어 있습니다. 매 반복마다 새 string이 만들어지고, 직전 반복의 result는 즉시 가비지가 됩니다.
items.Length가 N이면 만들어졌다 버려지는 임시 string은 약 N개. 컴파일러는 루프 외부의 누적 패턴을 알아채지 못해 최적화하지 못합니다.

정답 1 — StringBuilder
// ✅ 임시 string 0개, 최종 ToString()에서만 1개
public static string GoodLoop(string[] items)
{
StringBuilder sb = new();
for (int i = 0; i < items.Length; i++)
{
sb.Append(items[i]);
sb.Append(',');
}
return sb.ToString();
}
IL_0001: newobj instance void StringBuilder::.ctor() // SB 1개
// 루프 안: sb.Append 호출만, string 생성 X
// 루프 끝나면 sb.ToString()으로 최종 string 1개 생성
StringBuilder는 내부에 char[] 버퍼를 두고 길이가 모자라면 두 배로 늘리는 가변(mutable) 문자열 클래스입니다. 추가 시 새 string을 만들지 않고 자기 버퍼에 char를 누적합니다 — 마지막 ToString()이 한 번 새 string을 만들 뿐입니다.
(자세한 내용은 PART 6 #8 "StringBuilder"에서 따로 다룹니다.)
정답 2 — string.Join (변경이 아니라 단순 결합일 때)
public static string Joined(string[] items) => string.Join(",", items);
배열·컬렉션을 구분자로 한 번에 합치는 경우라면 string.Join이 가장 짧고 빠릅니다. 내부적으로 한 번에 정확한 길이를 계산하고 새 string을 한 번만 만듭니다 — StringBuilder보다도 효율적입니다(전용 빠른 길이 계산 + 단일 alloc).
정답 3 — 보간 $"..."
string greet = $"Hello {name}, age {age}";
C# 6.0의 보간 문자열은 컴파일러가 String.Format 또는 (.NET 6+ / C# 10+) 보간 문자열 핸들러(DefaultInterpolatedStringHandler) 로 변환합니다. 후자는 내부적으로 StringBuilder 같은 풀링된 char 버퍼를 사용해 매우 효율적입니다.
// ❌ 명시적 + 연결
string log = "Score: " + score + " / Time: " + time;
// ✅ 보간 — 가독성도 좋고 효율도 같거나 더 좋음
string log = $"Score: {score} / Time: {time}";
새 코드라면 거의 항상 보간을 우선으로 검토하세요. 가독성·정렬·서식 지정자({value:N2})까지 한 자리에서 표현됩니다.
4. 불변성의 부수 효과 — 무료 보너스
불변성은 단점만 있는 것이 아닙니다. 오히려 다른 언어가 따라 하기 어려운 강력한 보너스를 줍니다.
(1) 멀티스레드 안전 — lock 없이 공유 가능
// 여러 스레드가 동시에 같은 string을 읽어도 안전
private static readonly string _appName = "MyGame";
void Worker1() { Console.WriteLine(_appName); } // 안전
void Worker2() { Console.WriteLine(_appName); } // 안전
string의 char 데이터는 절대 변하지 않으므로, 여러 스레드가 동시에 같은 객체를 읽어도 데이터가 깨질 일이 없습니다. lock·Interlocked 같은 동기화가 필요 없습니다 — 다른 가변 자료구조에 비해 큰 이점입니다.
(2) Dictionary 키로 안전
var counts = new Dictionary<string, int>();
counts["alice"] = 5;
string key = "alice";
counts[key] = 10;
// key.ToUpper(); ← 의미 없음, key는 여전히 "alice"
// 원본 "alice" 객체의 char 데이터가 바뀐 적이 없으므로
// counts의 "alice" 키와 여전히 매칭
Dictionary의 키는 해시 코드 기반 검색입니다. 키 객체의 내용이 객체 생애 중에 바뀌면 해시가 달라져 검색이 깨집니다. string은 불변이라 어떤 일이 있어도 해시가 변하지 않으므로 키로 완벽하게 안전합니다.
가변 객체(예: 변경 가능한 클래스)를 키로 쓰면 키를 한 번 등록한 뒤 그 객체를 외부에서 수정하면 Dictionary가 그 항목을 영영 못 찾는 함정에 빠집니다 — string은 그런 위험이 원천 차단되어 있습니다.
(3) 문자열 인터닝 (String Interning)
CLR(Common Language Runtime — .NET 코드를 실행하는 가상머신)은 컴파일 시 등장하는 같은 string 리터럴은 메모리에 단 하나만 두고 모두 같은 참조로 만들어 줍니다.
string a = "Hello";
string b = "Hello";
bool same = ReferenceEquals(a, b); // true! 같은 객체
a와 b는 동일 객체를 가리키는 두 변수입니다. 같은 리터럴이 100번 나와도 메모리에는 1개만 있어 메모리 사용이 크게 줄어듭니다. 이는 string이 불변이라서 가능합니다 — 둘 다 같은 객체를 가리켜도 한쪽이 변경할 수 없으니 다른 쪽에 영향을 줄 일이 없습니다.
런타임에 만들어진 string(예: + 연결 결과, Convert.ToString 반환)은 인터닝 풀에 자동으로 들어가지 않습니다 — 필요하면 string.Intern(s)을 명시적으로 호출할 수 있지만, 일반 코드에서는 거의 쓸 일이 없습니다.
5. 실전 적용 — Unity 핫패스의 string 패턴
Before/After: 매 프레임 디버그 로그 + 연결
// ❌ 매 프레임 임시 string 다수
void Update()
{
Debug.Log("Frame " + Time.frameCount + " / FPS: " + (1f / Time.deltaTime));
}
// ✅ 보간 + 변경 감지 + 빈도 제한
private float _logTimer;
void Update()
{
_logTimer += Time.deltaTime;
if (_logTimer < 1f) return; // 1초마다 한 번만 로그
_logTimer = 0f;
Debug.Log($"Frame {Time.frameCount} / FPS: {1f / Time.deltaTime:F1}");
}
핵심 두 가지:
- 빈도 제한: 디버그 로그는 매 프레임 필요 없음. 1초에 1번이면 GC 부담이 60배 준다.
- 보간 사용:
+연결보다 가독성 좋고 효율 같거나 더 좋다.
Before/After: 동적 UI 라벨
// ❌ 매 프레임 string + 연결
void Update()
{
healthText.text = "HP: " + currentHp + " / " + maxHp;
}
// ✅ 변경 감지 + 보간 + TextMeshPro의 SetText
private int _lastHp = -1;
void Update()
{
if (currentHp == _lastHp) return;
_lastHp = currentHp;
healthText.SetText("HP: {0} / {1}", currentHp, maxHp); // 내부 SB 사용
}
TextMeshPro의 SetText(format, args...)는 정수·실수 인자를 받아 내부 StringBuilder로 처리하므로 string 객체를 만들지 않습니다. Unity 핫패스 UI 갱신의 표준 패턴입니다.
Unity 모바일 GC 특수성
Unity 모바일은 IL2CPP(Intermediate Language to C++ — IL을 C++로 변환해 네이티브로 컴파일)와 Boehm GC(보수적·정지형 가비지 컬렉터, 한 번 돌면 모든 매니지드 스레드를 멈춘다)의 조합이라 string이 만든 GC 한 번이 데스크톱·서버 .NET보다 훨씬 길게 화면을 멈춥니다. 매 프레임 string 누적은 작은 양처럼 보여도 누적되면 5~15ms 프레임 스파이크로 곧장 이어집니다.
Unity Profiler의 GC.Alloc 컬럼이 매 프레임 0이 되도록 핫패스를 설계하는 것이 모바일 60FPS의 출발점이며, 그 첫걸음이 string 메서드와 + 연결을 의식적으로 줄이는 것입니다.
6. 함정과 주의사항
함정 1 — s.Trim();처럼 단독 호출
string s = " hello ";
s.Trim(); // ❌ 결과 버림
Console.WriteLine($"[{s}]"); // [ hello ]
s = s.Trim(); // ✅
함정 2 — +로 큰 문자열을 누적
// ❌ 1000개 항목이면 임시 string 약 1000개
string log = "";
foreach (var entry in entries) log += entry + "\n";
// ✅ 한 줄로
string log = string.Join("\n", entries);
// 또는 StringBuilder
var sb = new StringBuilder();
foreach (var entry in entries) sb.AppendLine(entry);
string log = sb.ToString();
함정 3 — string.Empty vs ""
string a = ""; // 컴파일러가 인터닝 — String.Empty와 같은 객체
string b = string.Empty;
ReferenceEquals(a, b); // true (구현 의존이지만 보통 true)
신입이 자주 묻는 """이 빠른가, string.Empty가 빠른가" — 차이가 사실상 없습니다. 가독성으로 고르면 됩니다. (다만 null 체크 후 빈 문자열로 대체할 때는 s ?? string.Empty 같은 표현이 자연스럽습니다.)
함정 4 — ==는 값 비교지만 참조 비교가 아니다
string a = "Hello";
string b = string.Concat("He", "llo");
a == b // true (값이 같으니까)
ReferenceEquals(a, b) // false (객체는 다를 수 있음 — 인터닝 안 된 string은 별도 객체)
== 연산자는 string에 한해 값 비교(내용 비교)로 동작합니다. 다른 참조 타입은 기본적으로 참조 비교지만, string은 Equals를 오버라이드해 값 비교를 하도록 만들어졌습니다. 헷갈리지 마세요.
함정 5 — 보간 안에서 + 연결
// ❌ 보간 안에서 다시 +
string msg = $"Hello {name + "!"}";
// ✅ 보간 자리 표시자에 식 그대로
string msg = $"Hello {name}!";
보간은 자리 표시자 안에 식을 그대로 넣을 수 있습니다 — 굳이 + 연결을 다시 쓸 필요 없습니다.
7. C# 버전별 변화
| 버전 | 변화 | 비고 |
|---|---|---|
| 1.0 | string 불변 / String.Concat / StringBuilder |
기본 |
| 6.0 | 문자열 보간 $"..." |
가독성·효율 모두 향상 |
| 7.0 | out 변수 인라인 / 튜플 분해 |
부분적으로 string 처리 단순화 |
| 7.2 | string.AsSpan |
zero-alloc 부분 처리 |
| 10 | 개선된 보간 핸들러 (DefaultInterpolatedStringHandler) |
보간이 String.Format보다 빠름 |
| 11 | UTF-8 문자열 리터럴 "abc"u8 (PART 6 #11) |
바이트 레벨 |
| 11 | 원시 문자열 리터럴 """...""" (PART 6 #9) |
멀티라인·이스케이프 없이 |
C# 10에서 보간 처리가 크게 개선됐습니다 — 이전엔 보간이 String.Format으로 변환되어 박싱(boxing)·중간 string 비용이 있었지만, C# 10 이후는 컴파일러가 풀링된 버퍼와 인터폴레이션 핸들러를 사용해 String.Format보다도 빠릅니다. 새 코드에서는 + 연결보다 보간을 우선으로 검토하세요.
8. 정리
- [ ]
string은 한 번 만들어진 후 절대 변하지 않는다 — char 데이터가 그 자리에서 수정되지 않는다. - [ ]
s.ToUpper()·s.Trim()·s.Replace()등 모든 변경 메서드는 새 string을 반환한다 — 반환값을 안 받으면 의미 없음. - [ ]
a + b + c + d단일 표현식은 컴파일러가String.Concat(string, string, string, string)한 번 호출로 최적화 → 임시 string 0개. - [ ] 반복문 안
result += x는 매 반복 새 string 생성. 컴파일러 최적화 불가. - [ ] 반복 누적은
StringBuilder·string.Join·보간$"..."으로 옮긴다. - [ ]
string.Join은 정확한 길이 한 번 계산 후 단일 alloc. 단순 결합에 가장 효율적. - [ ] C# 10+ 보간은 풀링된 핸들러를 써서
String.Format보다도 빠르다 — 새 코드에서+연결 대신 우선 검토. - [ ] 불변성 보너스 1 — 멀티스레드 안전:
lock없이 같은 string을 여러 스레드가 읽을 수 있다. - [ ] 불변성 보너스 2 — Dictionary 키 안전: 키 등록 후 객체 내용이 변할 일이 없으므로 검색이 깨지지 않는다.
- [ ] 불변성 보너스 3 — 문자열 인터닝: 같은 리터럴은 메모리에 1개.
ReferenceEquals("a", "a")= true. - [ ]
string의==연산자는 값 비교(내용 비교)로 동작 — 일반 참조 타입과 다름. - [ ] Unity 모바일 Boehm GC 환경에서는 string 한 번이 곧 5~15ms 프레임 스파이크. 빈도 제한 + 보간 +
SetText조합이 정답. - [ ]
StringBuilder의 더 깊은 사용법은 PART 6 #8, 보간/원시 문자열 등 포맷팅 도구는 PART 6 #9에서 다룬다.