| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
- sha
- unity
- 가이드
- 패스트캠퍼스
- Custom Package
- Dots
- ui
- Job 시스템
- Tween
- 직장인자기계발
- adfit
- TextMeshPro
- job
- C#
- 직장인공부
- 게임개발
- 암호화
- base64
- 2D Camera
- AES
- Unity Editor
- 패스트캠퍼스후기
- Framework
- 프레임워크
- 오공완
- 환급챌린지
- DotsTween
- 샘플
- 최적화
- RSA
- Today
- Total
EveryDay.DevUp
string은 왜 불변인가 — intern pool의 실체 / == 이 값 비교인 이유 / 변경할 때마다 새 객체가 생기는 원리 본문
string은 왜 불변인가 — intern pool의 실체 / == 이 값 비교인 이유 / 변경할 때마다 새 객체가 생기는 원리
EveryDay.DevUp 2026. 3. 31. 22:27string은 왜 불변인가
한 번 만들어진 문자열은 절대 바뀌지 않는다. 이 단순한 규칙 하나가 인턴 풀, == 값 비교, StringBuilder의 존재 이유를 모두 설명한다.
목차
문제 제기 — 왜 string의 불변성을 알아야 하는가
Unity로 모바일 게임을 만들고 있다. 매 프레임 점수를 갱신하는 UI 코드가 있다.
void Update()
{
scoreText.text = "Score: " + currentScore;
}
겉보기엔 한 줄짜리 간단한 코드다. 하지만 프로파일러를 열면 매 프레임 새로운 string 객체가 힙(Heap)에 생성되고 있다. 60FPS 기준 1초에 60개, 1분이면 3,600개의 쓰레기 문자열이 쌓인다.
어느 순간 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소)가 작동하면서 게임이 멈칫한다 — 이른바 GC 스파이크다.
왜 이런 일이 일어나는가? "Score: " + currentScore가 기존 문자열을 수정하는 것이 아니라 매번 새로운 문자열 객체를 만들기 때문이다.
string이 왜 이렇게 설계되었는지, 그래서 어떤 최적화가 가능한지, 그리고 어떤 함정이 숨어 있는지를 알아야 Unity에서 부드러운 프레임을 유지할 수 있다.
개념 정의 — 불변(Immutable)이란 무엇인가
도서관의 출판된 책
string의 불변성을 이해하는 가장 쉬운 비유는 출판된 책이다.
도서관에 "C# 입문"이라는 책이 있다. 이 책의 내용에 오타를 발견했다고 해서 이미 인쇄된 책의 글자를 지우고 다시 쓸 수는 없다. 오타를 수정하려면 새 판(새 객체)을 찍어야 한다. 기존 초판은 그대로 남아 있고, 다른 독자가 초판을 읽고 있어도 아무 영향이 없다.
C#의 string이 정확히 이렇게 동작한다. 한 번 생성된 문자열 객체는 내부 데이터를 절대 변경할 수 없다. 변경처럼 보이는 모든 연산은 실제로는 새로운 객체를 만들어 반환한다.
불변(Immutable) 객체가 생성된 이후 내부 상태를 변경할 수 없는 성질. string은 참조 타입이지만 불변으로 설계되어, 모든 "수정" 연산이 새 객체를 반환한다.
아래 코드로 확인해 보자.
string greeting = "hello";
greeting = greeting + " world";
Console.WriteLine(greeting);
IL_0001: ldstr "hello" // "hello" 리터럴을 스택에 로드
IL_0006: stloc.0 // greeting 변수에 저장
IL_0007: ldloc.0 // greeting("hello")을 스택에 로드
IL_0008: ldstr " world" // " world" 리터럴을 스택에 로드
IL_000d: call string System.String::Concat(string, string) // 두 문자열을 연결 → 새 string 객체 반환
IL_0012: stloc.0 // greeting이 새 객체를 가리키도록 참조 변경
핵심: String.Concat은 기존 "hello" 객체를 수정하지 않는다. 힙에 "hello world"라는 새 객체를 할당하고 그 참조를 반환한다. 기존 "hello" 객체는 누구도 참조하지 않게 되어 GC 수집 대상이 된다.
내부 동작 — CLR은 string을 어떻게 관리하는가
인턴 풀 — 같은 리터럴은 하나만 존재한다
string이 불변이기 때문에 가능한 강력한 최적화가 있다. 동일한 내용의 문자열 리터럴은 메모리에 단 한 번만 저장하고 공유한다 — 이것이 인턴 풀(Intern Pool)이다.
CLR(Common Language Runtime, C# 코드를 실행하는 런타임 환경)은 프로세스가 시작될 때 내부에 해시 테이블을 만든다. 코드에 등장하는 모든 문자열 리터럴("hello", "world" 등)은 이 테이블에 등록된다. 같은 내용의 리터럴이 여러 곳에서 사용되면, 모두 힙에 있는 하나의 객체를 가리킨다.
불변이 아니라면 이 구조는 재앙이 된다 — 한 곳에서 공유 객체를 수정하면 다른 모든 곳에서 예상치 못한 값이 나온다. 불변성이 공유의 전제 조건인 셈이다.
코드로 직접 확인해 보자.
string a = "hello";
string b = "hello";
Console.WriteLine(object.ReferenceEquals(a, b)); // True — 같은 객체
string c = "hel" + "lo"; // 컴파일러 상수 접기
Console.WriteLine(object.ReferenceEquals(a, c)); // True
string part = "lo";
string d = "hel" + part; // 런타임 연결 — 새 객체
Console.WriteLine(object.ReferenceEquals(a, d)); // False
Console.WriteLine(a == d); // True — 값 비교
object.ReferenceEquals(a, b)— 참조 동등성 비교 두 변수가 힙에서 같은 객체를 가리키는지 확인한다. 내용이 아닌 메모리 주소를 비교한다.
예시:object.ReferenceEquals("a", "a")→true(인턴 풀에서 같은 객체)
// a와 b — 같은 리터럴 "hello"
IL_0001: ldstr "hello" // 인턴 풀에서 "hello" 참조 로드
IL_0006: stloc.0 // a에 저장
IL_0007: ldstr "hello" // 같은 인턴 풀 참조를 다시 로드 — a와 동일한 객체
IL_000c: stloc.1 // b에 저장
IL_000d: ldloc.0
IL_000e: ldloc.1
IL_000f: ceq // 참조 비교 (object.ReferenceEquals) → True
// c = "hel" + "lo" — 컴파일러가 "hello"로 상수 접기(constant folding)
IL_0017: ldstr "hello" // 컴파일 시점에 "hel"+"lo" → "hello"로 합쳐짐
IL_001c: stloc.2 // c도 같은 인턴 풀 객체를 참조
// d = "hel" + part — 런타임 연결
IL_0027: ldstr "lo"
IL_002c: stloc.3 // part = "lo"
IL_002d: ldstr "hel"
IL_0032: ldloc.3
IL_0033: call string System.String::Concat(string, string) // 런타임에 새 객체 생성
IL_0038: stloc.s 4 // d는 별도의 새 객체를 참조
// a == d — 연산자 오버로딩으로 값 비교
IL_0045: ldloc.0
IL_0046: ldloc.s 4
IL_0048: call bool System.String::op_Equality(string, string) // 값 비교 → True
IL 분석 포인트:
ldstr은 인턴 풀을 조회한다. IL 명령어ldstr은 메타데이터에 기록된 문자열 리터럴을 인턴 풀에서 찾아 참조를 반환한다. 같은 리터럴이면 항상 같은 참조가 로드된다.- 컴파일러 상수 접기(Constant Folding).
"hel" + "lo"는 양쪽 모두 리터럴이므로 컴파일러가 컴파일 시점에"hello"로 합친다. IL에는ldstr "hello"만 남는다 —String.Concat호출 자체가 없다. ceqvsop_Equality.object.ReferenceEquals는 IL의ceq(참조 비교)로 컴파일된다. 반면a == d는String.op_Equality(값 비교)를 호출한다.String.Concat은 새 객체를 만든다. 런타임에 변수를 포함한 연결은String.Concat을 호출하고, 이는 항상 새로운 힙 객체를 할당한다.
== 연산자가 값 비교인 이유
C#에서 string은 참조 타입(reference type)이다. 참조 타입의 ==는 원래 두 변수가 같은 객체를 가리키는지(참조 비교)를 확인한다. 하지만 string은 예외다.
C# 설계자들은 개발자가 문자열을 비교할 때 "내용이 같은가?"를 확인하고 싶어한다는 점을 알았다. 그래서 System.String 클래스에 ==와 != 연산자를 오버로딩(재정의)했다.
연산자 오버로딩 (Operator Overloading) 클래스가+,==,!=같은 연산자의 동작을 재정의하는 기능.string은==를 "참조 비교"에서 "값 비교"로 재정의했다.
예시:"hello" == "hello"→ 참조가 달라도true(내용이 같으므로)
String.op_Equality의 내부 동작 순서는 이렇다:
- 참조 비교 —
ReferenceEquals(a, b)가 true면 즉시 반환 (인턴 풀 덕에 자주 발생하는 빠른 경로) - null 체크 — 둘 중 하나라도 null이면 false
- 길이 비교 — 길이가 다르면 즉시 false
- 문자 비교 — 포인터 기반으로 4~8바이트 청크 단위 비교
인턴 풀에 있는 같은 리터럴끼리 비교하면 1단계에서 끝나므로 O(1)이다. 런타임 생성 문자열 비교는 4단계까지 가지만, 길이가 다르면 3단계에서 끝나므로 대부분 빠르다.
CLR 내부 string 메모리 레이아웃
string 객체가 힙에서 어떤 모습으로 저장되는지 살펴보자.
string 객체는 힙에 연속된 메모리 블록으로 저장된다:
- Object Header (8바이트) — GC 정보, 해시코드 캐시, 동기화 블록 인덱스
- Method Table Pointer (8바이트) — 타입 정보를 가리키는 포인터
_stringLength(4바이트) — 문자열 길이를 저장하는 int 필드- 문자 데이터 — UTF-16(유니코드 문자를 2바이트로 인코딩하는 방식) 인코딩된 char 배열이 연속 배치
문자 데이터가 객체 내부에 인라인으로 배치된다는 점이 중요하다. 별도의 char[] 배열을 참조하는 것이 아니라, 객체 하나에 모든 데이터가 들어 있다. 이 덕분에 캐시 지역성(CPU가 데이터에 빠르게 접근할 수 있는 특성)이 좋다.
실전 적용 — Before/After로 보는 올바른 문자열 사용법
❌ Before: 루프에서 += 연결 → ✅ After: StringBuilder
루프 안에서 문자열을 +=로 반복 연결하면, 매 반복마다 새 string 객체가 생성된다. 100번 반복하면 99개의 쓰레기 문자열이 힙에 쌓인다.
// ❌ Before: 루프에서 문자열 연결
static string BuildBad()
{
string result = "";
for (int i = 0; i < 100; i++)
{
result += i.ToString();
}
return result;
}
// ✅ After: StringBuilder 사용
static string BuildGood()
{
var sb = new System.Text.StringBuilder();
for (int i = 0; i < 100; i++)
{
sb.Append(i);
}
return sb.ToString();
}
StringBuilder내부에 가변(mutable) 문자 버퍼를 가진 클래스. 문자열을 추가할 때 버퍼에 직접 쓰므로 새 객체를 만들지 않는다.ToString()호출 시에만 최종 string 객체를 한 번 생성한다.
// ❌ BuildBad — 루프 내부
IL_000c: ldloc.0 // result 로드
IL_000d: ldloca.s 1 // i의 주소 로드
IL_000f: call instance string System.Int32::ToString() // i.ToString() → 새 string 할당 ①
IL_0014: call string System.String::Concat(string, string) // result + 문자열 → 새 string 할당 ②
IL_0019: stloc.0 // result에 새 참조 저장
// ✅ BuildGood — 루프 내부
IL_000c: ldloc.0 // sb 로드
IL_000d: ldloc.1 // i 로드 (int 그대로)
IL_000e: callvirt instance class StringBuilder StringBuilder::Append(int32) // 버퍼에 직접 추가 — 새 string 없음
IL_0013: pop // 반환값(sb 자신) 버림
차이가 명확하다:
| ❌ Before (+=) | ✅ After (StringBuilder) | |
|---|---|---|
| 루프 내 힙 할당 | 2회/반복 (ToString + Concat) | 0회/반복 (int 직접 Append) |
| 100회 반복 시 총 할당 | ~200개 string 객체 | StringBuilder 1개 + 최종 string 1개 |
| 시간복잡도(입력 크기에 따른 연산 비용 증가율) | O(n²) — 매번 이전 내용 전체를 복사 | O(n) — 버퍼에 이어 쓰기 |
🎮 Unity 실전: Update에서 문자열 다루기
Unity의 Update() 메서드는 매 프레임(60FPS 기준 초당 60회) 호출된다. 여기서 문자열을 만들면 GC 스파이크로 직결된다.
// ❌ 매 프레임 새 문자열 생성 — GC 스파이크 유발
void Update()
{
uiText.text = "Score: " + currentScore;
}
// ✅ 값이 변할 때만 갱신 + StringBuilder 캐싱
private readonly StringBuilder _sb = new StringBuilder(32);
private int _lastScore = -1;
void Update()
{
if (currentScore != _lastScore)
{
_lastScore = currentScore;
_sb.Clear();
_sb.Append("Score: ").Append(currentScore);
uiText.text = _sb.ToString();
}
}
개선 포인트:
- 조건 검사: 값이 실제로 바뀔 때만 문자열을 만든다
- StringBuilder 캐싱:
_sb를 필드로 두고Clear()로 재사용한다. 매번new StringBuilder()를 호출하지 않는다 - 초기 용량 지정:
new StringBuilder(32)로 버퍼 재할당을 줄인다
함정과 주의사항
❌ object 타입으로 캐스팅하면 == 가 참조 비교로 바뀐다
string의 ==가 값 비교인 것은 컴파일 타임에 양쪽이 모두 string 타입일 때만 적용된다. 한쪽이라도 object로 캐스팅되면 ==는 다시 참조 비교로 돌아간다.
string a = "hello";
object b = "hel" + new string('l', 1) + "o"; // 런타임 생성
Console.WriteLine(a == (string)b); // True — op_Equality (값 비교)
Console.WriteLine(a.Equals(b)); // True — String.Equals (값 비교)
Console.WriteLine((object)a == b); // False — 참조 비교!
// a == (string)b — string 타입끼리 비교
IL_0021: castclass System.String // b를 string으로 캐스팅
IL_0026: call bool System.String::op_Equality(string, string) // 값 비교 호출
// a.Equals(b) — 가상 메서드 호출
IL_0033: callvirt instance bool System.Object::Equals(object) // String이 재정의한 Equals 호출 → 값 비교
// (object)a == b — object 타입끼리 비교
IL_003e: ldloc.0 // a를 object로 (이미 참조 타입이라 변환 없음)
IL_003f: ldloc.1 // b (object 타입)
IL_0040: ceq // IL 수준의 참조 비교 — op_Equality 호출 없음!
핵심: IL의 ceq와 String.op_Equality의 차이다.
(object)a == b: 컴파일러가 양쪽을object로 보고ceq(참조 비교)를 생성한다a == (string)b: 컴파일러가 양쪽을string으로 보고op_Equality(값 비교)를 호출한다
Unity에서 이 함정에 빠지기 쉬운 상황: 제네릭 메서드에서 T == T 비교를 하면, T가 string이어도 ==는 참조 비교가 된다. EqualityComparer<T>.Default.Equals(a, b)를 사용해야 한다.
❌ String.Intern 남용은 메모리 누수를 유발한다
String.Intern()으로 동적 문자열을 인턴 풀에 등록할 수 있다. 하지만 인턴 풀에 등록된 문자열은 앱이 종료될 때까지 GC가 수집하지 못한다.
// ❌ 사용자 입력을 인턴 풀에 넣는 위험한 코드
void ProcessMessage(string userInput)
{
string interned = string.Intern(userInput);
// interned는 앱 수명 동안 메모리에 남는다!
}
// ✅ 인턴 풀 대신 Dictionary로 캐싱
private readonly Dictionary<string, string> _cache = new();
string GetCached(string key)
{
if (!_cache.TryGetValue(key, out var cached))
{
cached = key;
_cache[key] = cached;
}
return cached;
}
인턴 풀은 미리 알려진 고정 문자열(에러 코드, 상태 이름 등)에만 사용해야 한다. 사용자 입력이나 동적으로 생성되는 문자열을 인턴하면 메모리가 계속 증가한다.
❌ Substring은 항상 새 객체를 만든다
// ❌ Before: Substring — 새 string 객체 할당
static void ParseBad(string data)
{
string header = data.Substring(0, 5);
Console.WriteLine(header);
}
// ✅ After: AsSpan — 할당 없음
static void ParseGood(string data)
{
ReadOnlySpan<char> header = data.AsSpan(0, 5);
Console.WriteLine(header.ToString());
}
ReadOnlySpan<char>기존 문자열의 일부 구간을 새 객체 없이 참조하는 뷰(view). 원본 데이터를 복사하지 않으므로 힙 할당이 없다. 스택에만 존재하는ref struct이므로 필드에 저장하거나 async 메서드에서 사용할 수 없다.
// ❌ ParseBad
IL_0004: callvirt instance string System.String::Substring(int32, int32) // 새 string 할당
// ✅ ParseGood
IL_0004: call valuetype ReadOnlySpan`1<char> MemoryExtensions::AsSpan(string, int32, int32) // 할당 없음, 뷰만 생성
Substring은 callvirt으로 새 string 객체를 힙에 할당한다. 반면 AsSpan은 call로 스택에 ReadOnlySpan<char> 구조체(값 타입)를 만들 뿐이다. 문자열 파싱이 빈번한 코드에서는 AsSpan이 GC 압박을 크게 줄인다.
단, ReadOnlySpan<char>는 ref struct이므로 필드에 저장하거나 비동기 메서드에서 사용할 수 없다. 이런 경우에는 ReadOnlyMemory<char>(data.AsMemory(0, 5))를 사용한다.
C# 버전별 변화
C# 6 이전 — String.Format
// C# 6 이전: String.Format
string message = string.Format("Player: {0}, Score: {1}", name, score);
String.Format은 내부적으로 StringBuilder를 사용하지만, 포맷 문자열 파싱 + boxing(값 타입 인수가 object로 변환) 오버헤드가 있었다.
C# 6 — 문자열 보간 도입
// C# 6+: 문자열 보간 — 가독성 향상, 내부는 String.Format
string message = $"Player: {name}, Score: {score}";
C# 6에서 $"..." 문법이 도입되었지만, 컴파일러는 이를 String.Format 호출로 변환했다. 가독성은 좋아졌지만 성능은 같았다.
C# 10 — DefaultInterpolatedStringHandler
C# 10에서 문자열 보간의 내부 구현이 완전히 바뀌었다.
// C# 10+: 동일한 문법, 완전히 다른 내부 동작
static string FormatScore(string playerName, int score)
{
return $"Player: {playerName}, Score: {score}";
}
// C# 10+ — DefaultInterpolatedStringHandler 사용
IL_0001: ldloca.s 0 // 핸들러는 값 타입(struct) — 스택에 할당
IL_0003: ldc.i4.s 17 // 리터럴 부분 총 길이 = 17
IL_0005: ldc.i4.2 // 보간 구멍 개수 = 2
IL_0006: call instance void DefaultInterpolatedStringHandler::.ctor(int32, int32) // 예상 크기로 버퍼 할당
IL_000d: ldstr "Player: "
IL_0012: call instance void DefaultInterpolatedStringHandler::AppendLiteral(string)
IL_001b: call instance void DefaultInterpolatedStringHandler::AppendFormatted(string) // name 추가
IL_0023: ldstr ", Score: "
IL_0028: call instance void DefaultInterpolatedStringHandler::AppendLiteral(string)
IL_0031: call instance void DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0) // score — boxing 없음!
IL_0039: call instance string DefaultInterpolatedStringHandler::ToStringAndClear() // 최종 string 반환
핵심 개선점:
DefaultInterpolatedStringHandler는struct다.ldloca.s(로컬 변수 주소 로드)로 접근하며, 힙이 아닌 스택에 존재한다.AppendFormatted<int32>— int를object로 boxing하지 않고 제네릭으로 직접 처리한다.String.Format의 boxing 문제가 사라졌다.- 예상 크기 기반 버퍼 할당 — 생성자에서 리터럴 길이(17)와 구멍 개수(2)를 받아 적절한 크기의 버퍼를 미리 할당한다. 버퍼 재할당이 줄어든다.
| C# 6~9 (String.Format) | C# 10+ (Handler) | |
|---|---|---|
| int 인수 | boxing → object | 제네릭 → boxing 없음 |
| 버퍼 | StringBuilder 내부 할당 | 핸들러 내부 스택/풀 버퍼 |
| 할당 횟수 | 포맷 파싱 + args 배열 + 결과 | 결과 string 1개만 |
정리
string 불변성을 이해하면 자연스럽게 따라오는 핵심 체크리스트:
- string은 불변이다 — 모든 "수정" 연산(
+,Replace,Substring등)은 새 객체를 힙에 할당한다 - 인턴 풀은 불변 덕에 가능하다 — 같은 리터럴은 메모리에 한 번만 존재하고 공유한다.
ldstrIL 명령어가 인턴 풀을 조회한다 ==는op_Equality오버로딩 — 양쪽이string타입일 때만 값 비교.object로 캐스팅하면 참조 비교로 돌아간다- 루프에서
+=금지 — O(n²) 복잡도 + 매 반복 힙 할당.StringBuilder로 O(n) + 할당 최소화 - Unity Update에서 문자열 생성 최소화 — 값 변경 시에만 갱신 + StringBuilder 캐싱 + 초기 용량 지정
Substring대신AsSpan— 할당 없는 뷰로 문자열 파싱- C# 10+ 보간 문자열 —
DefaultInterpolatedStringHandler가 boxing 없이 효율적으로 처리 String.Intern남용 금지 — 인턴 풀은 GC가 수집하지 않으므로, 동적 문자열을 넣으면 메모리 누수
'C# 심화' 카테고리의 다른 글
| struct 4형제 — struct, record struct, readonly struct, ref struct (0) | 2026.04.01 |
|---|---|
| string vs StringBuilder vs Span<char> — 문자열 처리 도구 선택 (0) | 2026.04.01 |
| 형변환 완전 정리 — 암시적·명시적·as·is (0) | 2026.03.31 |
| readonly vs const — 무엇이 다른가 (0) | 2026.03.30 |
| var — 타입 추론의 원리와 한계 (0) | 2026.03.30 |
