EveryDay.DevUp

[C#/Unity] 문자열 최적화 본문

AI Agent Skill

[C#/Unity] 문자열 최적화

EveryDay.DevUp 2026. 2. 7. 00:45

Unity 게임 개발에서 문자열 관리는 런타임 성능과 메모리 효율성을 결정하는 핵심 요소임.

C#의 문자열은 참조 형식으로 힙 메모리에 할당되며 불변성을 가짐.

무분별한 문자열 연산은 빈번한 GC(Garbage Collection) 호출을 유발하여 프레임 드랍의 직접적인 원인이 됨.

1. 문자열의 불변성과 힙 할당

문자열 객체는 생성 후 상태 변경이 불가능함. 문자열 수정 코드는 기존 데이터를 변경하지 않고 새로운 객체를 힙에 할당.

[C# 코드]

string text = "Hello";
text += " World";

[컴파일된 IL 코드]

IL_0001: ldstr      "Hello"              // 문자열 리터럴 "Hello"를 스택에 로드
IL_0006: stloc.0                         // 로드한 값을 지역 변수 0(text)에 저장
IL_0007: ldloc.0                         // text를 스택에 로드
IL_0008: ldstr      " World"             // 문자열 리터럴 " World"를 스택에 로드
IL_000d: call       string [System.Runtime]System.String::Concat(string, string)  // String.Concat 정적 메서드 호출
IL_0012: stloc.0                         // 반환된 새 문자열 객체를 text에 저장

[IL 코드 분석 및 해석] String.Concat은 새로운 메모리 영역을 확보하여 결과를 저장함. 기존 문자열 "Hello"는 참조를 잃고 가비지가 되어 GC 수집 대상이 됨.

[메모리 특징]

  • CLR은 문자열을 System.Object를 상속한 참조 형식으로 관리하며, 힙 할당 시 객체 헤더(8바이트), 길이 필드(4바이트), 문자 데이터, 종료 문자 공간이 필요함.
  • 데이터 변경이 불가능한 불변성(Immutability)을 가져 멀티스레드 환경에서 동기화 없이 공유 가능하며, 고정된 해시 코드로 Dictionary 키 성능이 일정함.
  • 단순 결합에도 새로운 메모리 할당이 발생하여 힙 파편화와 GC 부하를 유발하며, 0세대에서 상위 세대로 승격될 시 해제 비용이 증가함.

2. 문자열 리터럴과 인터닝 (C# 1.0 이상)

컴파일러는 동일 리터럴을 하나만 생성하여 공유하는 문자열 인터닝을 수행함. 리터럴 간 결합은 컴파일러의 상수 폴딩(Constant Folding) 기법을 통해 컴파일 타임에 처리됨. 이는 개발자가 작성한 "Unity" + "Engine" 연산을 컴파일러가 미리 "UnityEngine"이라는 하나의 리터럴로 합쳐 메타데이터에 기록하기 때문임.

[C# 코드]

string combined = "Unity" + "Engine";
string s1 = "Optimization";
string s2 = "Optimization";
bool isSameRef = ReferenceEquals(s1, s2); // True

[컴파일된 IL 코드]

IL_0001: ldstr      "UnityEngine"        // 컴파일 타임에 결합된 리터럴을 로드
IL_0006: stloc.0                         // combined에 저장
IL_0007: ldstr      "Optimization"       // 인터닝 풀에서 문자열 참조 로드
IL_000c: stloc.1                         // s1에 저장
IL_000d: ldstr      "Optimization"       // 동일한 풀 주소 참조
IL_0012: stloc.2                         // s2에 저장 (s1과 동일 주소)

[IL 코드 분석 및 해석] 컴파일러가 메타데이터 테이블에 리터럴을 등록하고, 런타임 시 CLR이 인터닝 풀(해시 테이블)에서 동일 문자열을 검색하여 동일한 참조를 반환함.

[작동 원리]

  • 컴파일러가 소스 코드 내의 모든 리터럴 문자열을 메타데이터에 기록함.
  • 런타임 진입 시 CLR 내부의 전역 해시 테이블(Intern Pool)에 해당 문자열들을 등록함.

[장점]

  • 동일한 내용의 리터럴이 메모리에 중복 할당되는 것을 방지하여 메모리 사용량을 줄임.
  • 참조 비교(ReferenceEquals)만으로 문자열 일치 여부를 판단할 수 있어 비교 성능이 O(1)로 최적화됨.

[단점]

  • 인터닝 풀에 등록된 문자열은 어플리케이션 종료 시까지 메모리에서 해제되지 않음.
  • 런타임에 동적으로 생성되는 문자열은 기본적으로 인터닝 대상이 아님.

3. 명시적 인터닝 (C# 1.0 이상)

런타임에 생성된 동적 문자열을 string.Intern 메서드를 통해 인터닝 풀에 강제로 등록하거나 기존 참조를 가져옴.

[C# 코드]

string dynamicString = new System.Text.StringBuilder().Append("Opti").Append("mization").ToString();
string interned = string.Intern(dynamicString);

[컴파일된 IL 코드]

IL_0019: ldloc.0                         // dynamicString을 스택에 로드
IL_001a: call       string [System.Runtime]System.String::Intern(string)  // Intern 메서드 호출
IL_001f: stloc.1                         // 반환된 풀 참조를 interned에 저장

[IL 코드 분석 및 해석] string.Intern은 런타임에 생성된 문자열 객체를 인터닝 풀과 대조하여 관리함.

[작동 원리]

  • 호출 시 내부 해시 테이블을 검색하여 동일한 문자열이 있으면 해당 참조를 반환함.
  • 없으면 전달된 문자열을 풀에 추가하고 해당 참조를 반환함.

[장점]

  • 반복적으로 생성되는 동일한 내용의 동적 문자열(아이템 이름, 데이터 키 등)의 중복 할당을 억제함.
  • 대규모 문자열 비교 연산이 잦은 로직에서 참조 비교를 유도하여 CPU 부하를 감소시킴.

[단점]

  • 풀 검색을 위한 해시 연산 오버헤드가 발생함.
  • 유니크한 문자열이 매우 많은 경우 풀의 크기가 비대해져 메모리 누수와 유사한 효과를 냄.

4. + 연산자와 string.Concat (C# 1.0 이상)

변수가 포함된 결합은 string.Concat 정적 메서드 호출로 치환됨.

[C# 코드]

string a = "1", b = "2", c = "3", d = "4", e = "5";
string shortConcat = a + b + c;
string longConcat = a + b + c + d + e;

[컴파일된 IL 코드]

// 3개 변수 결합
IL_001b: call       string [System.Runtime]System.String::Concat(string, string, string)
// 5개 변수 결합
IL_001e: newarr     [System.Runtime]System.String  // 배열 할당
IL_003f: call       string [System.Runtime]System.String::Concat(string[])

[IL 코드 분석 및 해석] 변수 개수에 따라 최적화된 오버로드를 선택함. 인자가 5개 이상이면 string[] 배열을 생성하여 전달함.

[작동 원리]

  • 결합할 모든 문자열의 총 길이를 먼저 계산함.
  • 단일 메모리 블록을 할당하고 각 문자열의 데이터를 순차적으로 복사(Memory Copy)함.

[장점]

  • 2~4개 변수 결합 시 전용 오버로드를 사용하여 배열 할당 없이 효율적으로 처리함.
  • 컴파일러가 자동으로 최적화된 호출 코드를 생성하므로 가독성이 좋음.

[단점]

  • 5개 이상의 변수 결합 시 임시 배열 객체 할당으로 인한 추가 가비지가 발생함.
  • 루프 내부에서 사용할 경우 반복적인 할당이 누적됨.

5. string.Format과 박싱 오버헤드 (C# 1.0 이상)

서식 문자열을 통한 데이터 포맷팅은 유연하지만 비용이 높음.

[C# 코드]

int health = 100;
string uiText = string.Format("HP: {0}", health);

[컴파일된 IL 코드]

IL_0009: ldloc.0                         // health 로드 (값 형식)
IL_000a: box        [System.Runtime]System.Int32  // 박싱 발생
IL_000f: call       string [System.Runtime]System.String::Format(string, object)

[IL 코드 분석 및 해석] int와 같은 값 형식을 전달할 때 object로 변환하기 위한 box 명령어가 실행되어 힙 할당이 발생함.

[작동 원리]

  • 서식 문자열({0})을 런타임에 파싱하여 인덱스와 포맷팅 규칙을 분석함.
  • 가변 인자(params object[]) 처리를 위해 배열 할당 및 박싱을 수행함.

[장점]

  • 숫자 형식(N2, X2 등)이나 날짜 포맷팅을 직관적으로 적용 가능함.
  • 다국어 처리 시 서식 문자열만 교체하여 유연하게 대응할 수 있음.

[단점]

  • 서식 파싱 비용이 매 호출마다 발생하여 성능이 낮음.
  • 값 형식 전달 시 박싱으로 인한 힙 메모리 낭비가 심함.

6. 문자열 보간 ($) 최적화 (C# 10.0 이상)

C# 6.0에서 최초 도입된 문자열 보간은 초기 버전에서 내부적으로 string.Format을 호출하여 박싱을 유발했으나, C# 10.0부터 핸들러 패턴을 도입하여 고질적인 성능 문제를 해결함.

[C# 코드]

int id = 500;
string name = "Unity";
string result = $"ID: {id}, Name: {name}";

[컴파일된 IL 코드 (C# 10 이상)]

IL_000d: ldloca.s   2                    // 핸들러 구조체 로드
IL_0012: call       instance void [System.Runtime]DefaultInterpolatedStringHandler::.ctor(int32, int32)
IL_0026: call       instance void [System.Runtime]DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0) // 제네릭 사용
IL_0041: call       instance string [System.Runtime]DefaultInterpolatedStringHandler::ToStringAndClear()

[IL 코드 분석 및 해석] DefaultInterpolatedStringHandler 구조체가 스택에서 할당되어 박싱 없이 데이터를 결합함.

[작동 원리]

  • C# 6.0 ~ 9.0: 컴파일러가 보간된 문자열을 string.Format 호출 코드로 변환함. 이로 인해 값 형식 인자 전달 시 박싱과 object 배열 할당이 수반됨.
  • C# 10.0 이상: 컴파일러가 핸들러 메서드 호출 시퀀스로 변환하며, 제네릭 AppendFormatted<T>를 통해 박싱 없이 내부 버퍼에 기록함.

[장점]

  • string.Format 대비 박싱과 임시 배열 할당이 제거되어 성능이 비약적으로 향상됨.
  • 컴파일 타임에 서식이 검증되어 런타임 오류 가능성이 낮음.

[단점]

  • Unity 2022.2 미만 버전이나 이전 C# 버전 환경에서는 이 혜택을 받을 수 없음.

7. StringBuilder 활용 (C# 1.0 이상)

대량의 문자열 조각을 결합할 때 사용하는 가변 문자열 클래스임.

[C# 코드]

var sb = new System.Text.StringBuilder(256);
for (int i = 0; i < 10; i++) { sb.Append(i); }
string result = sb.ToString();

[컴파일된 IL 코드]

IL_0006: newobj     instance void [System.Runtime]System.Text.StringBuilder::.ctor(int32)
IL_001f: callvirt   instance class StringBuilder StringBuilder::Append(int32)
IL_0036: callvirt   instance string Object::ToString()

[IL 코드 분석 및 해석] 내부 버퍼에 데이터를 누적한 후 마지막에 한 번만 문자열을 생성함.

[작동 원리]

  • 내부적으로 가변 크기의 char[] 배열을 버퍼로 사용함.
  • 버퍼 용량 초과 시 2배 크기의 새로운 배열을 할당하고 기존 데이터를 복사함.

[장점]

  • 루프 내 연산 시 중간 단계의 가비지 생성을 완벽히 차단함.
  • 대용량 텍스트 조립 시 가장 안정적인 성능을 제공함.

[단점]

  • StringBuilder 인스턴스 자체와 내부 버퍼 할당 비용이 존재함.
  • 소량의 결합(2~4개)에서는 오히려 + 연산자보다 오버헤드가 큼.

8. StringBuilderCache 패턴 (C# 2.0 이상)

StringBuilder의 인스턴스화 비용마저 제거하는 최적화 패턴임. 정적 필드에 인스턴스를 보관하여 재사용함으로써 힙 할당 부하를 최소화함.

[C# 코드]

public static class StringBuilderCache
{
    private const int MaxBuilderSize = 360;
    
    [System.ThreadStatic]
    private static System.Text.StringBuilder _cachedInstance;

    public static System.Text.StringBuilder Acquire(int capacity = 256)
    {
        if (capacity <= MaxBuilderSize)
        {
            System.Text.StringBuilder sb = _cachedInstance;
            if (sb != null)
            {
                // 캐시된 인스턴스가 존재하면 이를 반환하고 캐시 비움
                if (capacity <= sb.Capacity)
                {
                    _cachedInstance = null;
                    sb.Clear();
                    return sb;
                }
            }
        }
        // 캐시가 없거나 요청 용량이 크면 새로 생성
        return new System.Text.StringBuilder(capacity);
    }

    public static string GetStringAndRelease(System.Text.StringBuilder sb)
    {
        string result = sb.ToString();
        // 특정 크기 이하의 인스턴스만 다시 캐싱하여 메모리 점유 제어
        if (sb.Capacity <= MaxBuilderSize)
        {
            _cachedInstance = sb;
        }
        return result;
    }
}

[컴파일된 IL 코드]

IL_0000: ldsfld     class [System.Runtime]System.Text.StringBuilder StringBuilderCache::_cachedInstance // TLS 로드
IL_0005: stloc.0                         // 지역 변수에 저장
IL_0006: ldloc.0                         // 캐시 존재 여부 확인
IL_0007: brfalse.s  IL_001f              // null이면 생성 로직으로 점프
IL_0012: ldnull                          // null 로드
IL_0013: stsfld     class [System.Runtime]System.Text.StringBuilder StringBuilderCache::_cachedInstance // 필드 비움
IL_0018: ldloc.0                         // 인스턴스 로드
IL_0019: callvirt   instance class [System.Runtime]System.Text.StringBuilder [System.Runtime]System.Text.StringBuilder::Clear() // Clear 호출
IL_001e: ret                             // 반환

[IL 코드 분석 및 해석] ldsfld 명령어는 TLS(Thread Local Storage) 영역에서 현재 스레드 전용 필드를 읽어옴. stsfld로 필드를 null로 밀어내어 중복 사용을 방지하는 원자적 확보 메커니즘을 가짐.

[작동 원리]

  • ThreadStatic 어트리뷰트를 통해 각 스레드는 독립적인 정적 필드 복사본을 소유함. 이는 스레드 간 경합(Contention) 없이 락 프리(Lock-free) 재사용을 가능케 함.
  • Acquire 호출 시 캐시를 비우고(_cachedInstance = null), Release 호출 시 다시 채움으로써 단일 스레드 내에서의 인스턴스 독점을 보장함.
  • MaxBuilderSize 임계값을 설정하여 비정상적으로 커진 버퍼가 메모리를 영구 점유하는 현상을 방지함.

[장점]

  • 빈번한 문자열 조립 시 StringBuilder 자체의 힙 할당 비용을 0으로 만듦.
  • Unity 메인 스레드에서 반복 호출되는 UI 업데이트나 로그 생성 로직에서 GC 부하를 획기적으로 낮춤.

[단점]

  • 재진입(Re-entrancy) 불가: Acquire한 객체를 반납하기 전 다시 Acquire를 호출하면 새로운 할당이 발생함. 중첩된 메서드 호출 구조에서 주의가 필요함.
  • TLS 필드는 스레드가 종료될 때까지 메모리를 유지하므로 스레드 풀 환경에서 미세한 메모리 상주 비용이 발생함.

9. ReadOnlySpan<char> 슬라이싱 (C# 7.2 이상)

문자열 복사 없이 부분 문자열을 참조하는 최신 최적화 기법임. Substring은 새로운 문자열 인스턴스를 힙에 할당하지만, Span 슬라이싱은 원본 메모리의 포인터와 길이만 조정하여 가비지를 생성하지 않음.

[C# 코드]

string rawData = "Player:UnityExpert:Level99";

// Substring: 새로운 string 객체 생성 (힙 할당)
string sub = rawData.Substring(7, 11);

// ReadOnlySpan: 원본 문자열의 포인터만 참조 (할당 없음)
ReadOnlySpan<char> span = rawData.AsSpan().Slice(7, 11);

[컴파일된 IL 코드]

// Substring 호출
IL_0007: ldloc.0                         // rawData 로드
IL_0008: ldc.i4.7                        // 시작 인덱스 7
IL_0009: ldc.i4.s   11                   // 길이 11
IL_000b: callvirt   instance string [System.Runtime]System.String::Substring(int32, int32) // 새 문자열 객체 생성 및 반환
IL_0010: stloc.1                         // sub에 저장

// ReadOnlySpan 슬라이싱
IL_0011: ldloc.0                         // rawData 로드
IL_0012: call       valuetype [System.Runtime]System.ReadOnlySpan`1<char> [System.Runtime]System.MemoryExtensions::AsSpan(string) // Span으로 변환
IL_0017: ldc.i4.7                        // 시작 인덱스 7
IL_0018: ldc.i4.s   11                   // 길이 11
IL_001a: call       instance valuetype [System.Runtime]System.ReadOnlySpan`1<!0> [System.Runtime]System.ReadOnlySpan`1<char>::Slice(int32, int32) // 포인터/길이 조정
IL_001f: stloc.2                         // span에 저장 (스택 할당)

[IL 코드 분석 및 해석] Substring은 callvirt를 통해 런타임에 새 string 인스턴스를 할당하는 반면, ReadOnlySpan은 구조체(Value Type)로서 스택에 포인터 주소와 길이 정보만 저장함. Slice 메서드는 메모리 복사를 수행하지 않고 내부의 포인터 오프셋과 길이 값만 변경함.

[작동 원리]

  • 원본 문자열의 메모리 주소(Pointer)와 유효 범위(Length)만 관리하는 래퍼 구조체임.
  • Slice 시 데이터 복사 대신 산술적인 포인터 연산만 수행함.

[장점]

  • 파싱 로직에서 가비지 발생을 제로(Zero-garbage)화 할 수 있음.
  • 대규모 텍스트 처리 시 CPU 캐시 효율이 극대화됨.

[단점]

  • ref struct 제약으로 인해 비동기 메서드(async)나 클래스 필드에서 사용이 제한됨.

10. 실무 적용 방안 및 요약

  • 정적 텍스트: 리터럴 결합을 사용하여 런타임 부하를 제거함.
  • 변수 결합: Unity 2022.2+ 환경이라면 문자열 보간($)을 최우선으로 사용함.
  • 루프 로직: StringBuilderCache를 도입하여 할당 오버헤드를 방지함.
  • 파싱 최적화: Substring 호출을 지양하고 ReadOnlySpan<char>로 슬라이싱함.
  • 캐싱 전략: Update 메서드 내에서는 값이 변경될 때만 문자열을 갱신함.

11. 참고 자료