반응형

[PART6.배열과 문자열 기본(6/14)] string의 자주 쓰는 메서드 — 모든 호출이 새 객체를 만든다

Length·s[i]는 메서드 호출(get_Length·get_Chars) / ToUpper·Trim·Replace·Substring은 모두 새 string 반환 / Replace 체이닝의 임시 string N개 / Substring 대신 AsSpan으로 zero-alloc / Contains의 char 오버로드 / StringComparison.Ordinal로 문화권 함정 회피 / Unity UI 갱신의 GC 스파이크 패턴


1. 왜 string 메서드를 한 번에 정리해야 하는가

신입이 처음 만드는 GC 폭탄 중 단연 1위는 string 메서드 체이닝입니다.

C#
void Update()
{
    scoreText.text = ("Score : " + score)
        .Replace("0", "○")
        .Replace("1", "①")
        .ToUpper()
        .Trim();
}

위 코드 한 블록이 매 프레임 만드는 임시 string의 수 — 최소 5개. +로 만든 결합 결과 1개, Replace 두 번에 2개, ToUpper 1개, Trim 1개. 60FPS면 1초에 300개의 단명 객체, 1분이면 18,000개입니다. 이 게시글의 목표는 각 string 메서드가 어떤 IL을 만드는지, 어디서 새 객체가 생기는지, 그리고 어떻게 zero-alloc으로 같은 동작을 얻는지를 분석해 보는 것입니다.

핵심 원칙은 하나입니다. string은 불변 타입이므로, 변경하는 듯 보이는 모든 메서드는 사실 새 객체를 만들어 돌려준다. s.ToUpper()s를 바꾸지 않고 새 string을 반환합니다 — 이 사실 하나가 모든 함정의 출발점입니다.

(string의 불변성 자체에 대한 더 깊은 논의는 PART 6 #7 "문자열이 불변이라는 것의 의미"에서 다루고, 이 글은 자주 쓰는 메서드의 행동과 비용에 집중합니다.)


2. 길이와 인덱서

C#
public static int LengthOf(string s) => s.Length;
public static char At(string s, int i) => s[i];
IL
// LengthOf
IL_0001: callvirt instance int32 System.String::get_Length()              // ← 메서드 호출!

// At — s[i]
IL_0002: callvirt instance char System.String::get_Chars(int32)           // ← 메서드 호출!

배열의 arr.Length는 IL ldlen 단일 명령이지만, s.Lengthget_Length() 메서드 호출입니다. 인덱서 s[i]get_Chars(int) 메서드 호출입니다. 큰 차이는 아니지만, 루프 안에서 매번 평가하는 패턴(for (int i = 0; i < s.Length; i++))이 배열만큼 무료는 아니라는 점은 알아 두면 좋습니다 — 그래도 메서드 호출 한 번이라 일반적으로는 문제 없습니다.

char는 16비트 유니코드 코드 단위(UTF-16)를 표현하는 값 타입입니다 — 이모지나 일부 한자처럼 코드 단위 두 개로 표현되는 문자는 s[i] 한 번으로 전체를 못 잡으니, 그런 경우엔 Rune이나 StringInfo를 써야 합니다. 일반 ASCII나 한국어 한 글자 단위 작업이라면 char로 충분합니다.


3. 모든 변경 메서드는 새 string을 반환한다

s.ToUpper().Replace(

대소문자 변환 — ToUpperInvariant가 더 빠르다

C#
public static string Up(string s)    => s.ToUpper();              // 현재 문화권 의존
public static string UpInv(string s) => s.ToUpperInvariant();     // 문화권 비의존, 더 빠름
IL
IL_0001: callvirt instance string System.String::ToUpper()           // 새 string 반환
IL_0001: callvirt instance string System.String::ToUpperInvariant()  // 새 string 반환

두 메서드 모두 새 string을 반환합니다. 차이는 문화권 처리 비용ToUpper는 현재 스레드의 문화권 정보를 읽어 동작하지만, ToUpperInvariant는 미리 정해진 변환 규칙만 적용합니다.

신입이 자주 만드는 함정 — 터키어 문화권에서 소문자 'i'를 ToUpper하면 'İ'(점 있는 I)가 됨. 영어권 'I'(U+0049)와 다른 문자입니다. 식별자 비교나 시스템 데이터 처리에서 이 차이가 사용자별로 다른 결과를 만들면 디버깅이 매우 어렵습니다.

Invariant 변형 — 데이터 처리에는 항상 이쪽 사용자에게 보여 주는 텍스트가 아닌 내부 식별자·키·태그·로그를 변환할 때는 항상 ToUpperInvariant/ToLowerInvariant. 사용자에게 보여 주는 텍스트(이름·도시·메뉴)에만 ToUpper/ToLower(현재 문화권).

Replace 체이닝 — 임시 string 폭탄

C#
// ❌ 호출마다 새 string — 총 3개의 임시 객체
public static string ChainedReplace(string s)
    => s.Replace("a", "1").Replace("b", "2").Replace("c", "3");

// ✅ StringBuilder — 임시 string 1개(최종 ToString만)
public static string SbReplace(string s)
{
    StringBuilder sb = new(s);
    sb.Replace("a", "1");
    sb.Replace("b", "2");
    sb.Replace("c", "3");
    return sb.ToString();
}
IL
// ChainedReplace — 3번의 callvirt Replace
IL_000b: callvirt instance string System.String::Replace(string, string)
IL_001a: callvirt instance string System.String::Replace(string, string)
IL_0029: callvirt instance string System.String::Replace(string, string)
                              // ← 각 호출이 새 string을 만들어 반환

// SbReplace — StringBuilder 위에서 in-place 변경
IL_0002: newobj instance void StringBuilder::.ctor(string)               // SB 1개 생성
IL_0013: callvirt instance StringBuilder StringBuilder::Replace(string, string)
                              // ← StringBuilder 자신을 반환, 새 string 안 만듦
IL_0024: callvirt instance StringBuilder StringBuilder::Replace(string, string)
IL_0035: callvirt instance StringBuilder StringBuilder::Replace(string, string)
IL_003c: callvirt instance string Object::ToString()                     // 마지막에만 string 1개
패턴 임시 string 객체
s.Replace().Replace().Replace() 3개
StringBuilder + Replace × 3 + ToString 1개 (최종 결과)

세 번 이상 Replace를 체이닝한다면 거의 항상 StringBuilder로 옮기는 게 이득입니다. 두 번 이하라면 가독성을 위해 체이닝을 그대로 두는 편이 무난합니다.


4. Substring 대신 AsSpan

Substring(start, length)은 부분 문자열을 추출하지만 결과는 새 string이라 alloc이 발생합니다. 단순히 부분만 살피는 용도라면 ReadOnlySpan<char>로 받으면 zero-alloc입니다.

C#
// ❌ 새 string — 매 호출 alloc
public static int CountVowelsAlloc(string s, int start, int len)
{
    string sub = s.Substring(start, len);
    int n = 0;
    foreach (var c in sub) if ("aeiouAEIOU".IndexOf(c) >= 0) n++;
    return n;
}

// ✅ Span — alloc 0
public static int CountVowelsSpan(string s, int start, int len)
{
    ReadOnlySpan<char> sub = s.AsSpan(start, len);
    int n = 0;
    for (int i = 0; i < sub.Length; i++)
        if ("aeiouAEIOU".IndexOf(sub[i]) >= 0) n++;
    return n;
}
IL
// CountVowelsAlloc
IL_0004: callvirt instance string System.String::Substring(int32, int32)   // 새 string!

// CountVowelsSpan
IL_0004: call valuetype ReadOnlySpan`1<char>
              MemoryExtensions::AsSpan(string, int32, int32)               // 새 객체 X

부분 문자열을 한 번 보고 버릴 거라면 AsSpan 우선. 다른 곳에 저장하거나 다른 string 메서드의 인자로 넘겨야 한다면 어쩔 수 없이 Substring이 필요할 수 있습니다 — 하지만 .NET 6 이후 int.Parse·int.TryParse·string.Split 등 많은 API가 ReadOnlySpan<char> 오버로드를 제공해 zero-alloc 파싱이 가능합니다.


5. 검색 — Contains·StartsWith·EndsWith·IndexOf

char 오버로드 vs string 인자

C#
public static bool HasComma_String(string s) => s.Contains(",");   // string 인자
public static bool HasComma_Char(string s)   => s.Contains(',');   // char 오버로드 (C# 11+)
IL
// HasComma_String — Contains(string, ...)
IL_0001: ldstr ","
IL_0006: ldc.i4.4                                                  // StringComparison.Ordinal
IL_0007: callvirt instance bool System.String::Contains(string, valuetype StringComparison)

// HasComma_Char — Contains(char) 단일 인자
IL_0001: ldc.i4.s 44                                               // ',' 문자 코드
IL_0003: callvirt instance bool System.String::Contains(char)

단일 문자 검사라면 char 오버로드가 더 가볍습니다 — string 객체 ","를 만들 필요도, 비교 옵션을 처리할 필요도 없으니까요. 자주 호출되는 자리(예: 토큰 검사)에서는 s.Contains(',')s.Contains(",")보다 작은 차이로 빠릅니다.

StringComparison으로 문화권 함정 회피

C#
// ❌ 현재 문화권 — 터키어 등에서 다른 결과
a.StartsWith(b);

// ✅ 명시적 비교 규칙
a.StartsWith(b, StringComparison.Ordinal);            // 코드 포인트 그대로 (가장 빠름)
a.StartsWith(b, StringComparison.OrdinalIgnoreCase);  // 코드 포인트, 대소문자 무시
a.StartsWith(b, StringComparison.CurrentCulture);     // 사용자 화면용

StringComparison.Ordinal문화권 정보 자체를 안 본다 는 뜻이라, 가장 빠르고 결과가 항상 같습니다. URL·파일 경로·내부 키처럼 사람에게 보여 주지 않는 문자열 비교에는 거의 항상 Ordinal을 명시하는 편이 안전합니다.

Equals·Contains·IndexOf·StartsWith·EndsWith·LastIndexOf 모두 StringComparison 매개변수 오버로드가 있습니다. 새 코드를 짤 때는 가능한 모두 명시하는 습관을 권장합니다.

IndexOf·LastIndexOf

C#
int idx = s.IndexOf(',');                                       // -1이면 없음
int idx2 = s.IndexOf("test", StringComparison.OrdinalIgnoreCase);
int last = s.LastIndexOf('.');

검색 결과는 0 이상의 인덱스 또는 -1 입니다. 신입이 자주 만드는 함정 — if (s.IndexOf(",") > 0)처럼 > 0으로 비교하면 인덱스 0에 있을 때 못 잡습니다. 항상 >= 0 또는 != -1 또는 s.Contains(',')로 비교해야 합니다.


6. Trim·Split

C#
"  hello  ".Trim()                  // "hello"          (앞뒤 공백)
"  hello  ".TrimStart()             // "hello  "        (앞만)
"  hello  ".TrimEnd()               // "  hello"        (뒤만)
"...hello...".Trim('.')             // "hello"          (특정 문자)
"a,b,,c".Split(',')                 // ["a", "b", "", "c"]
"a,b,,c".Split(',', StringSplitOptions.RemoveEmptyEntries)
                                    // ["a", "b", "c"]  (빈 항목 제거)

Trim도 새 string을 반환합니다(공백 제거가 필요 없으면 같은 문자열 객체를 그대로 돌려주는 최적화는 있지만 의지할 정도는 아님).

Split은 더 비쌉니다 — 구분 결과로 string[] 한 개 + 각 부분 문자열 N개가 모두 힙에 만들어집니다. 핫패스에서 빈번한 파싱이라면 .NET 6+ ReadOnlySpan<char>.Split 또는 직접 IndexOf로 잘라 가며 처리하는 편이 alloc 0에 가깝습니다.

StringSplitOptions.RemoveEmptyEntries를 항상 검토해 두면 빈 항목 객체가 만들어지지 않아 메모리 부담이 줄어듭니다.


7. 실전 적용 — Unity UI 핫패스

Before/After: 매 프레임 string 메서드 체이닝

C#
// ❌ Before — 매 프레임 임시 string 5+개
void Update()
{
    scoreText.text = ("Score : " + score)
        .Replace(",", "")
        .ToUpper()
        .Trim();
}

// ✅ After — StringBuilder 캐싱 + 변경된 경우에만 갱신
private StringBuilder _sb = new(64);
private int _lastScore = -1;

void Update()
{
    if (score == _lastScore) return;     // 값이 바뀌지 않으면 string 작업 skip
    _lastScore = score;

    _sb.Clear();
    _sb.Append("SCORE : ");
    _sb.Append(score);
    scoreText.text = _sb.ToString();      // 갱신할 때만 string 1개
}

핵심 두 가지:

  1. 변경 감지 후 생성 — UI 라벨은 매 프레임 갱신할 필요가 없습니다. 값이 바뀐 순간에만 갱신하면 매 프레임 alloc이 사라집니다.
  2. StringBuilder 캐싱 — 인스턴스 필드로 재사용하고 Clear()로 길이만 0으로. 내부 char 버퍼가 그대로 살아 있어 다음 Append에서 새 객체를 만들지 않습니다.

Unity 모바일은 IL2CPP(Intermediate Language to C++ — IL을 C++로 변환해 네이티브 컴파일하는 백엔드)와 Boehm GC의 조합이라 string GC가 한 번 돌 때 화면이 더 길게 멈춥니다. UI 갱신 코드의 매 프레임 alloc은 작은 양처럼 보여도 누적되면 큰 차이가 됩니다.

TextMeshPro의 SetText로 alloc 더 줄이기

C#
// 한 단계 더: TextMeshPro의 SetText(StringBuilder) 또는 SetText(int)
scoreText.SetText("Score: {0}", score);   // 내부에서 StringBuilder 사용, alloc 최소

TextMeshPro의 SetText는 정수·실수 인자를 받는 오버로드가 있어 string을 만들지 않고 직접 바꿔 줍니다. 점수·체력·시간 같은 숫자 갱신은 이쪽이 더 가볍습니다.


8. 함정과 주의사항

함정 1 — s.Length는 메서드 호출, arr.Length와 다르다

s.Length는 IL callvirt String::get_Length() — 메서드 호출입니다. 값 자체는 캐시되어 있어 빠르지만, arr.Lengthldlen 단일 명령보다는 무겁다는 점은 알아 두세요. 루프 안에서 매번 부르는 패턴은 큰 차이가 아니지만, 극한 핫패스라면 한 번 캐시해 둘 수 있습니다.

C#
// 매우 큰 문자열을 안쪽 루프에서 돌릴 때만 의미 있음
int len = s.Length;
for (int i = 0; i < len; i++) { ... }

함정 2 — IndexOf > 0은 인덱스 0을 놓친다

C#
if (s.IndexOf(',') > 0)    // ❌ 첫 글자가 콤마면 false!
if (s.IndexOf(',') >= 0)   // ✅
if (s.Contains(','))       // ✅ 가독성 + 정확

함정 3 — + 연결로 매번 새 string 생성

C#
string log = "";
for (int i = 0; i < items.Length; i++)
    log += items[i] + ",";          // ❌ 반복마다 새 string

// ✅ StringBuilder 또는 string.Join
string log = string.Join(",", items);

(이 주제는 PART 6 #7 "문자열이 불변이라는 것의 의미" + #8 "StringBuilder"에서 더 자세히 다룹니다.)

함정 4 — string.Compare vs ==

C#
"Hello".Equals("hello")                                          // false
"Hello".Equals("hello", StringComparison.OrdinalIgnoreCase)       // true
"Hello" == "hello"                                                // false (==은 Ordinal 비교)

== 연산자는 항상 Ordinal 비교입니다. 대소문자 무시 비교가 필요하면 반드시 Equals(other, StringComparison.OrdinalIgnoreCase) 같은 명시적 형태를 써야 합니다.

함정 5 — 문화권 의존 메서드의 결과가 사용자별로 다름

C#
// 터키어 환경에서 다른 결과!
"identity".ToUpper()                  // "IDENTITY" (영어권) / "İDENTİTY" (터키어)
"identity".ToUpperInvariant()         // 항상 "IDENTITY"

내부 식별자·로그·태그를 처리하는 자리에는 항상 Invariant 변형 또는 StringComparison.Ordinal. 사용자 화면 표시는 현재 문화권 변형.


9. C# 버전별 변화

버전 변화 비고
1.0 Length, ToUpper/ToLower, Trim, Split, Replace, Substring, IndexOf 등 도입 기본
2.0 StringSplitOptions.RemoveEmptyEntries  
4.0 StringComparison 매개변수 오버로드 확대  
6.0 문자열 보간 $"..." (PART 6 #9)  
7.2 string.AsSpan / MemoryExtensions.AsSpan 도입 zero-alloc 파싱
11 Contains(char)·StartsWith(char)·EndsWith(char)·Replace(char) 단일 char 오버로드 확대 단일 문자 검사 가벼워짐
11 UTF-8 문자열 리터럴 "abc"u8 (PART 6 #11) 바이트 레벨 처리
13 (C# 13 일반) Span<T> ref struct 제약 일부 완화 PART 6 #12

핵심은 C# 7.2 이후 AsSpan이 도입되면서 zero-alloc 문자열 처리 패턴이 정착됐다는 점입니다. 새 코드에서 부분 문자열을 다룰 때는 거의 항상 Substring보다 AsSpan을 우선 검토해 봅니다.


10. 정리

  • [ ] s.Lengthcallvirt get_Length() — 메서드 호출. arr.Length(ldlen)와 차이는 작지만 0은 아니다.
  • [ ] s[i]callvirt get_Chars(int) — 메서드 호출.
  • [ ] 모든 문자열 변경 메서드는 새 string을 반환한다 — 원본은 절대 안 바뀐다.
  • [ ] 데이터/식별자 처리는 Invariant 변형 또는 StringComparison.Ordinal — 터키어 'i/İ' 같은 문화권 함정 회피 + 더 빠름.
  • [ ] Replace 3회 이상 체이닝 → StringBuilder 로 옮기면 임시 string이 N개에서 1개로 준다.
  • [ ] 부분 문자열을 한 번만 보고 버릴 거면 Substring 대신 s.AsSpan(start, len) — alloc 0.
  • [ ] 단일 문자 검사는 char 오버로드s.Contains(',')s.Contains(",")보다 가볍다 (C# 11+).
  • [ ] StringComparison 매개변수를 항상 명시 — 새 코드에서는 거의 항상 Ordinal/OrdinalIgnoreCase.
  • [ ] IndexOf 결과는 >= 0 또는 != -1로 비교 — > 0은 인덱스 0을 놓친다.
  • [ ] == 연산자는 항상 Ordinal 비교 — 대소문자 무시 비교는 Equals(other, StringComparison.OrdinalIgnoreCase).
  • [ ] Splitstring[] + 부분 string 모두 alloc — 빈도가 높은 파싱은 ReadOnlySpan<char>.Split(.NET 6+) 또는 직접 IndexOf.
  • [ ] Unity UI 라벨: 변경 감지(if (newValue == lastValue) return;) + StringBuilder 캐싱 + TextMeshPro.SetText. 매 프레임 alloc 0.
  • [ ] string 불변성과 비효율적인 + 연결의 더 깊은 분석은 PART 6 #7·#8.
반응형

+ Recent posts