반응형

[PART6.배열과 문자열 기본(7/14)] 문자열이 불변이라는 것의 의미 — string은 절대 안 바뀐다

string은 한 번 만들어지면 char 데이터가 절대 수정되지 않는다 / s.ToUpper();만 적으면 결과가 사라지는 함정 / a + b + c + d 단일 표현식은 String.Concat(N) 한 번으로 최적화 / 반복문 누적 +=은 매 반복 새 string / StringBuilder·string.Join·보간이 정답 / 멀티스레드 안전·Dictionary 키 안전·문자열 인터닝의 부수 효과


1. 한 줄짜리 함정

신입이 작성한 코드 한 줄로 이야기를 시작합니다.

C#
string s = "hello";
s.ToUpper();
Console.WriteLine(s);   // ?

답을 미리 적으면 — "hello"가 그대로 출력됩니다. s.ToUpper()가 만든 "HELLO"는 어디론가 사라졌습니다. C·Java·JavaScript에서 넘어온 사람이라면 "원본이 바뀐 게 아니라 새 string을 반환한다"는 말은 들어 봤겠지만, 이 한 줄 함정에서 진짜 의미가 드러납니다.

이 글은 string이 불변(immutable)이라는 사실의 진짜 의미와, 그 사실이 만드는 두 가지 결과를 다룹니다.

  1. 잘못 쓰면 GC 폭탄을 만든다 — 매 반복마다 임시 string이 누적되는 패턴이 가장 흔하다.
  2. 잘 쓰면 동시성과 메모리 효율의 무료 보너스를 받는다 — 멀티스레드 안전, Dictionary 키 안전, 문자열 인터닝.

2. 불변이란 무엇인가

비유 — 한번 인쇄된 책

string은 인쇄가 끝난 책 한 권과 같습니다. 책의 내용을 "수정"하고 싶다면 그 책을 고치는 게 아니라 새 책을 한 권 인쇄해야 합니다. 원본 책은 그대로 도서관에 있고, 수정된 책은 별도 객체로 어딘가에 놓입니다 — 도서관에 두 권이 같이 살아 있습니다.

s.ToUpper()도 정확히 같은 일을 합니다 — 새 책을 인쇄해서 돌려줍니다. 원본 s는 손도 안 댑니다.

메모리 그림

s.ToUpper() 호출 후 메모리 상태

IL로 본 함정

C#
public static string LooksLikeUpper(string s)
{
    s.ToUpper();         // ❌ 결과 버림 — 원본 s는 그대로
    return s;
}

public static string CorrectUpper(string s)
{
    s = s.ToUpper();     // ✅ 새 string을 다시 받음
    return s;
}
IL
// 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

LooksLikeUpperpop 한 줄이 결정적입니다. 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는 컴파일러가 자동으로 한 번에 합쳐 줍니다.

C#
public static string Joined4(string a, string b, string c, string d)
    => a + b + c + d;
IL
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

C#
// ❌ 반복마다 새 string
public static string BadLoop(string[] items)
{
    string result = "";
    for (int i = 0; i < items.Length; i++)
        result = result + items[i] + ",";       // 한 줄처럼 보이지만…
    return result;
}
IL
// 핵심 부분 — 루프 안에 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개. 컴파일러는 루프 외부의 누적 패턴을 알아채지 못해 최적화하지 못합니다.

반복문 누적 + 연결의 임시 string 누적

정답 1 — StringBuilder

C#
// ✅ 임시 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
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 (변경이 아니라 단순 결합일 때)

C#
public static string Joined(string[] items) => string.Join(",", items);

배열·컬렉션을 구분자로 한 번에 합치는 경우라면 string.Join이 가장 짧고 빠릅니다. 내부적으로 한 번에 정확한 길이를 계산하고 새 string을 한 번만 만듭니다 — StringBuilder보다도 효율적입니다(전용 빠른 길이 계산 + 단일 alloc).

정답 3 — 보간 $"..."

C#
string greet = $"Hello {name}, age {age}";

C# 6.0의 보간 문자열은 컴파일러가 String.Format 또는 (.NET 6+ / C# 10+) 보간 문자열 핸들러(DefaultInterpolatedStringHandler) 로 변환합니다. 후자는 내부적으로 StringBuilder 같은 풀링된 char 버퍼를 사용해 매우 효율적입니다.

C#
// ❌ 명시적 + 연결
string log = "Score: " + score + " / Time: " + time;

// ✅ 보간 — 가독성도 좋고 효율도 같거나 더 좋음
string log = $"Score: {score} / Time: {time}";

새 코드라면 거의 항상 보간을 우선으로 검토하세요. 가독성·정렬·서식 지정자({value:N2})까지 한 자리에서 표현됩니다.


4. 불변성의 부수 효과 — 무료 보너스

불변성은 단점만 있는 것이 아닙니다. 오히려 다른 언어가 따라 하기 어려운 강력한 보너스를 줍니다.

(1) 멀티스레드 안전 — lock 없이 공유 가능

C#
// 여러 스레드가 동시에 같은 string을 읽어도 안전
private static readonly string _appName = "MyGame";

void Worker1() { Console.WriteLine(_appName); }   // 안전
void Worker2() { Console.WriteLine(_appName); }   // 안전

string의 char 데이터는 절대 변하지 않으므로, 여러 스레드가 동시에 같은 객체를 읽어도 데이터가 깨질 일이 없습니다. lock·Interlocked 같은 동기화가 필요 없습니다 — 다른 가변 자료구조에 비해 큰 이점입니다.

(2) Dictionary 키로 안전

C#
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 리터럴은 메모리에 단 하나만 두고 모두 같은 참조로 만들어 줍니다.

C#
string a = "Hello";
string b = "Hello";
bool same = ReferenceEquals(a, b);    // true!  같은 객체

ab동일 객체를 가리키는 두 변수입니다. 같은 리터럴이 100번 나와도 메모리에는 1개만 있어 메모리 사용이 크게 줄어듭니다. 이는 string이 불변이라서 가능합니다 — 둘 다 같은 객체를 가리켜도 한쪽이 변경할 수 없으니 다른 쪽에 영향을 줄 일이 없습니다.

런타임에 만들어진 string(예: + 연결 결과, Convert.ToString 반환)은 인터닝 풀에 자동으로 들어가지 않습니다 — 필요하면 string.Intern(s)을 명시적으로 호출할 수 있지만, 일반 코드에서는 거의 쓸 일이 없습니다.


5. 실전 적용 — Unity 핫패스의 string 패턴

Before/After: 매 프레임 디버그 로그 + 연결

C#
// ❌ 매 프레임 임시 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초에 1번이면 GC 부담이 60배 준다.
  2. 보간 사용: + 연결보다 가독성 좋고 효율 같거나 더 좋다.

Before/After: 동적 UI 라벨

C#
// ❌ 매 프레임 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();처럼 단독 호출

C#
string s = "  hello  ";
s.Trim();                      // ❌ 결과 버림
Console.WriteLine($"[{s}]");   // [  hello  ]

s = s.Trim();                  // ✅

함정 2 — +로 큰 문자열을 누적

C#
// ❌ 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 ""

C#
string a = "";              // 컴파일러가 인터닝 — String.Empty와 같은 객체
string b = string.Empty;
ReferenceEquals(a, b);      // true (구현 의존이지만 보통 true)

신입이 자주 묻는 """이 빠른가, string.Empty가 빠른가" — 차이가 사실상 없습니다. 가독성으로 고르면 됩니다. (다만 null 체크 후 빈 문자열로 대체할 때는 s ?? string.Empty 같은 표현이 자연스럽습니다.)

함정 4 — ==는 값 비교지만 참조 비교가 아니다

C#
string a = "Hello";
string b = string.Concat("He", "llo");
a == b                          // true (값이 같으니까)
ReferenceEquals(a, b)           // false (객체는 다를 수 있음 — 인터닝 안 된 string은 별도 객체)

== 연산자는 string에 한해 값 비교(내용 비교)로 동작합니다. 다른 참조 타입은 기본적으로 참조 비교지만, stringEquals를 오버라이드해 값 비교를 하도록 만들어졌습니다. 헷갈리지 마세요.

함정 5 — 보간 안에서 + 연결

C#
// ❌ 보간 안에서 다시 +
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에서 다룬다.
반응형

+ Recent posts