[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 메서드 체이닝입니다.
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. 길이와 인덱서
public static int LengthOf(string s) => s.Length;
public static char At(string s, int i) => s[i];
// 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.Length는 get_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을 반환한다

대소문자 변환 — ToUpperInvariant가 더 빠르다
public static string Up(string s) => s.ToUpper(); // 현재 문화권 의존
public static string UpInv(string s) => s.ToUpperInvariant(); // 문화권 비의존, 더 빠름
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 폭탄
// ❌ 호출마다 새 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();
}
// 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입니다.
// ❌ 새 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;
}
// 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 인자
public static bool HasComma_String(string s) => s.Contains(","); // string 인자
public static bool HasComma_Char(string s) => s.Contains(','); // char 오버로드 (C# 11+)
// 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으로 문화권 함정 회피
// ❌ 현재 문화권 — 터키어 등에서 다른 결과
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
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
" 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 메서드 체이닝
// ❌ 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개
}
핵심 두 가지:
- 변경 감지 후 생성 — UI 라벨은 매 프레임 갱신할 필요가 없습니다. 값이 바뀐 순간에만 갱신하면 매 프레임 alloc이 사라집니다.
StringBuilder캐싱 — 인스턴스 필드로 재사용하고Clear()로 길이만 0으로. 내부char버퍼가 그대로 살아 있어 다음Append에서 새 객체를 만들지 않습니다.
Unity 모바일은 IL2CPP(Intermediate Language to C++ — IL을 C++로 변환해 네이티브 컴파일하는 백엔드)와 Boehm GC의 조합이라 string GC가 한 번 돌 때 화면이 더 길게 멈춥니다. UI 갱신 코드의 매 프레임 alloc은 작은 양처럼 보여도 누적되면 큰 차이가 됩니다.
TextMeshPro의 SetText로 alloc 더 줄이기
// 한 단계 더: 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.Length의 ldlen 단일 명령보다는 무겁다는 점은 알아 두세요. 루프 안에서 매번 부르는 패턴은 큰 차이가 아니지만, 극한 핫패스라면 한 번 캐시해 둘 수 있습니다.
// 매우 큰 문자열을 안쪽 루프에서 돌릴 때만 의미 있음
int len = s.Length;
for (int i = 0; i < len; i++) { ... }
함정 2 — IndexOf > 0은 인덱스 0을 놓친다
if (s.IndexOf(',') > 0) // ❌ 첫 글자가 콤마면 false!
if (s.IndexOf(',') >= 0) // ✅
if (s.Contains(',')) // ✅ 가독성 + 정확
함정 3 — + 연결로 매번 새 string 생성
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 ==
"Hello".Equals("hello") // false
"Hello".Equals("hello", StringComparison.OrdinalIgnoreCase) // true
"Hello" == "hello" // false (==은 Ordinal 비교)
== 연산자는 항상 Ordinal 비교입니다. 대소문자 무시 비교가 필요하면 반드시 Equals(other, StringComparison.OrdinalIgnoreCase) 같은 명시적 형태를 써야 합니다.
함정 5 — 문화권 의존 메서드의 결과가 사용자별로 다름
// 터키어 환경에서 다른 결과!
"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.Length는callvirt get_Length()— 메서드 호출.arr.Length(ldlen)와 차이는 작지만 0은 아니다. - [ ]
s[i]는callvirt get_Chars(int)— 메서드 호출. - [ ] 모든 문자열 변경 메서드는 새
string을 반환한다 — 원본은 절대 안 바뀐다. - [ ] 데이터/식별자 처리는
Invariant변형 또는StringComparison.Ordinal— 터키어 'i/İ' 같은 문화권 함정 회피 + 더 빠름. - [ ]
Replace3회 이상 체이닝 →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). - [ ]
Split은string[]+ 부분 string 모두 alloc — 빈도가 높은 파싱은ReadOnlySpan<char>.Split(.NET 6+) 또는 직접IndexOf. - [ ] Unity UI 라벨: 변경 감지(
if (newValue == lastValue) return;) +StringBuilder캐싱 +TextMeshPro.SetText. 매 프레임 alloc 0. - [ ] string 불변성과 비효율적인
+연결의 더 깊은 분석은 PART 6 #7·#8.