EveryDay.DevUp

문자열 포맷팅 — String.Format·보간·Span 기반 포맷 본문

카테고리 없음

문자열 포맷팅 — String.Format·보간·Span 기반 포맷

EveryDay.DevUp 2026. 4. 1. 08:22

문자열 포맷팅 — String.Format·보간·Span 기반 포맷

문자열을 조합하는 방법은 C# 버전마다 달라졌다. String.Format에서 시작해 $"보간"을 거쳐 zero-alloc 포맷까지 — 겉보기엔 같은 결과를 내지만, 내부에서 일어나는 일은 완전히 다르다.

문제 제기 — 왜 포맷팅 방식이 중요한가

Unity 모바일 게임에서 플레이어의 HP를 화면에 표시하는 코드를 작성했다.

C#
void Update()
{
    uiText.text = string.Format("HP: {0} / {1}", currentHp, maxHp);
}

프로파일러를 열면 매 프레임 string.Format 호출에서 힙 할당이 3회 발생하고 있다. currentHpmaxHpint인데, string.Formatobject를 받기 때문에 매번 박싱(Boxing)이 일어난다. 60FPS 기준 1초에 180번의 쓸데없는 힙 할당이 생긴다.

"그럼 $"HP: {currentHp} / {maxHp}"로 바꾸면 되지 않나?"

C# 버전에 따라 다르다. C# 9 이하에서는 $""가 내부적으로 string.Format똑같이 동작한다. C# 10부터는 완전히 다른 메커니즘이 작동해서 박싱이 사라진다. 같은 $"" 문법인데 컴파일러 버전에 따라 성능이 완전히 달라지는 것이다.

이 글에서는 C#의 문자열 포맷팅이 어떻게 진화해 왔는지, 각 방식이 내부에서 무엇을 하는지, 그리고 Unity에서 어떤 방식을 선택해야 하는지를 IL 분석과 함께 파헤친다.


개념 정의 — 문자열 포맷팅의 세 세대

C# 문자열 포맷팅은 세 세대로 나눌 수 있다. 각 세대는 이전의 문제를 해결하기 위해 등장했다.

문자열 포맷팅의 세 세대

복합 서식 문자열이란

복합 서식 문자열 (Composite Format String) {인덱스[,정렬][:서식지정자]} 구조로 값을 문자열에 삽입하는 패턴이다. String.Format, Console.WriteLine, StringBuilder.AppendFormat 등이 이 방식을 사용한다.
예시: string.Format("{0,10:N2}", 1234.5)" 1,234.50" (10자리 폭, 소수점 2자리)

String.Format은 복합 서식 문자열의 대표적인 구현이다. 포맷 문자열 안에 {0}, {1} 같은 자리 표시자(placeholder)를 넣고, 뒤에 오는 인자들이 순서대로 치환된다.

C#
public static string FormatWithBoxing(string name, int score)
{
    return string.Format("Player: {0}, Score: {1}", name, score);
}
IL
IL_0001: ldstr "Player: {0}, Score: {1}"
IL_0006: ldarg.0                                     // name (string — 참조 타입, boxing 없음)
IL_0007: ldarg.1                                     // score (int — 값 타입)
IL_0008: box [System.Runtime]System.Int32             // int → object 박싱 — 힙 할당 발생!
IL_000d: call string System.String::Format(string, object, object)  // params 없이 2인자 오버로드

핵심은 box 명령어다. String.Format의 매개변수 타입이 object이기 때문에, int 같은 값 타입은 반드시 object로 변환(박싱)되어야 한다. 박싱은 값을 힙에 새 객체로 포장하는 과정이므로 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소) 압박의 원인이 된다.

주요 서식 지정자

복합 서식에서 콜론(:) 뒤에 붙는 서식 지정자(Format Specifier)는 값을 문자열로 변환하는 규칙을 정한다.

지정자 의미 예시 입력 출력
N 천 단위 구분 기호 12345.6 12,345.60
F 고정 소수점 12.345 (F2) 12.35
D 10진 정수, 자릿수 고정 42 (D5) 00042
X 16진수 255 FF
C 통화 123 ₩123
P 백분율 0.123 12.30%

서식 지정자는 $"" 보간에서도 동일하게 사용한다: $"{score:N0}""1,234".


내부 동작 — 컴파일러는 포맷팅을 어떻게 처리하는가

String.Format의 내부 처리 과정

String.Format 힙 할당 흐름

String.Format이 호출되면 내부에서는 다음 단계가 진행된다:

  1. 인자 전달 — 3개 이상이면 params object[] 배열이 힙에 할당된다. 2개 이하는 전용 오버로드(Format(string, object), Format(string, object, object))가 있어 배열 할당을 피한다.
  2. StringBuilder 생성 — 내부적으로 StringBuilder를 만든다.
  3. 포맷 문자열 파싱{0:N2} 같은 자리 표시자를 찾아 인덱스, 정렬, 서식 지정자를 추출한다.
  4. 값 변환 — 인자가 IFormattable을 구현하면 ToString(format, provider)를, 아니면 ToString()을 호출한다.
  5. 최종 string 생성StringBuilder.ToString()으로 결과 문자열 하나를 힙에 할당한다.
IFormattable 인터페이스 ToString(string? format, IFormatProvider? formatProvider) 메서드를 가진 인터페이스다. int, double, DateTime 등 .NET 기본 타입들이 구현하고 있으며, 서식 지정자(:N2 등)를 해석하는 데 사용된다.

결과적으로 String.Format 한 번 호출에 발생하는 힙 할당은:

  • object[] 배열 (3인자 이상)
  • 값 타입 인자 수만큼의 박싱 객체
  • 최종 string 객체
  • 내부 StringBuilder 버퍼

$"보간"이 String.Format이던 시절 (C# 6 ~ C# 9)

C# 6에서 도입된 문자열 보간($"")은 가독성을 크게 개선했지만, C# 9까지는 컴파일러가 이를 String.Format 호출로 변환하는 문법 설탕(Syntactic Sugar)에 불과했다.

C#
// 개발자가 작성한 코드
string result = $"Player: {name}, Score: {score}";

// C# 9 이하 컴파일러가 변환한 결과 (의사 코드)
string result = string.Format("Player: {0}, Score: {1}", name, score);

따라서 $""를 사용해도 String.Format과 동일한 박싱, 배열 할당 문제가 그대로 발생했다.

DefaultInterpolatedStringHandler — C# 10의 혁명

$

C# 10 / .NET 6부터 컴파일러는 $""String.Format이 아닌 DefaultInterpolatedStringHandler라는 ref struct로 변환한다. 이것이 문자열 포맷팅 역사상 가장 큰 변화다.

ref struct 스택에만 존재할 수 있는 구조체다. 힙에 할당되지 않으므로 GC 대상이 아니다. Span<T>DefaultInterpolatedStringHandler가 대표적이다.
C#
// C# 10+ 에서 동일한 $"" 보간
public static string InterpolationModern(string name, int score)
{
    return $"Player: {name}, Score: {score}";
}
IL
// DefaultInterpolatedStringHandler — ref struct로 스택에 생성
IL_0001: ldloca.s 0
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_001a: ldarg.0                                       // name (string)
IL_001b: call instance void DefaultInterpolatedStringHandler::AppendFormatted(string)
IL_0023: ldstr ", Score: "
IL_0028: call instance void DefaultInterpolatedStringHandler::AppendLiteral(string)
IL_0030: ldarg.1                                       // score (int)
IL_0031: call instance void DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0)  // 제네릭 — boxing 없음!

// 최종 문자열 생성
IL_0039: call instance string DefaultInterpolatedStringHandler::ToStringAndClear()

String.Format 버전과 비교하면 결정적 차이가 보인다:

  String.Format DefaultInterpolatedStringHandler
score 전달 box System.Int32object로 변환 AppendFormatted<int32>int 그대로
배열 할당 params object[] 필요 없음
중간 할당 박싱 객체 + 내부 StringBuilder ref struct 내부 버퍼 (스택)
최종 할당 string 1개 string 1개

Handler 내부 버퍼 전략

DefaultInterpolatedStringHandler 버퍼 전략

DefaultInterpolatedStringHandler는 내부적으로 다음과 같은 버퍼 전략을 사용한다:

  1. 초기 버퍼 — 생성자에서 리터럴 길이와 삽입 값 개수를 받아 필요한 크기를 추정한다. 짧은 문자열은 stackalloc으로 스택에 버퍼를 확보한다.
  2. 버퍼 확장 — 예상보다 길어지면 ArrayPool<char>.Shared에서 char 배열을 대여(Rent)한다.
  3. 최종 반환ToStringAndClear()에서 결과 string 하나만 힙에 할당하고, 대여한 배열은 반환(Return)한다.
ArrayPool<T> (배열 풀) 배열을 미리 풀에 보관하고 필요할 때 대여/반환하는 메커니즘이다. new T[]를 반복하는 것보다 GC 부담이 크게 줄어든다.

실전 적용 — Unity에서의 문자열 포맷팅

❌ Before — + 연산자로 문자열 조합 (매 프레임)

Update에서 + 연산자로 여러 값을 합치면 무슨 일이 벌어지는지 보자.

C#
// 매 프레임 호출되는 UI 갱신 코드
public static string BadFormatInLoop(string name, int hp, int maxHp)
{
    return "HP: " + hp + " / " + maxHp + " | " + name;
}
IL
IL_0001: ldc.i4.6
IL_0002: newarr [System.Runtime]System.String       // string[6] 배열 힙 할당!
IL_0009: ldstr "HP: "
IL_000e: stelem.ref
IL_0011: ldarga.s hp
IL_0013: call instance string System.Int32::ToString()  // hp.ToString() — string 힙 할당
IL_0018: stelem.ref
IL_001b: ldstr " / "
IL_0020: stelem.ref
IL_0023: ldarga.s maxHp
IL_0025: call instance string System.Int32::ToString()  // maxHp.ToString() — string 힙 할당
IL_002a: stelem.ref
IL_002d: ldstr " | "
IL_0032: stelem.ref
IL_0037: call string System.String::Concat(string[])    // 최종 string 힙 할당

5개 이상의 조각을 +로 연결하면 컴파일러는 string[] 배열을 만들고 Concat(string[]) 오버로드를 호출한다. 이 코드 한 번 실행에:

  • string[6] 배열 1개
  • hp.ToString(), maxHp.ToString() — string 2개
  • Concat 결과 — string 1개
  • 총 힙 할당 4회

✅ After — StringBuilder 캐싱

C#
private static readonly StringBuilder _cachedSb = new(64);

public static string GoodFormatCached(string name, int hp, int maxHp)
{
    _cachedSb.Clear();
    _cachedSb.Append("HP: ");
    _cachedSb.Append(hp);       // Append(int) 오버로드 — boxing 없음
    _cachedSb.Append(" / ");
    _cachedSb.Append(maxHp);    // Append(int) 오버로드 — boxing 없음
    _cachedSb.Append(" | ");
    _cachedSb.Append(name);
    return _cachedSb.ToString();
}
IL
IL_0001: ldsfld StringBuilder StringFormattingExamples::_cachedSb  // static 필드에서 로드 — new 없음!
IL_0006: callvirt StringBuilder::Clear()
IL_0011: ldstr "HP: "
IL_0016: callvirt StringBuilder::Append(string)
IL_0021: ldarg.1                                                    // hp (int)
IL_0022: callvirt StringBuilder::Append(int32)                     // int 전용 오버로드 — boxing 없음
IL_002d: ldstr " / "
IL_0032: callvirt StringBuilder::Append(string)
IL_003d: ldarg.2                                                    // maxHp (int)
IL_003e: callvirt StringBuilder::Append(int32)                     // int 전용 오버로드 — boxing 없음
IL_0049: ldstr " | "
IL_004e: callvirt StringBuilder::Append(string)
IL_0059: ldarg.0                                                    // name (string)
IL_005a: callvirt StringBuilder::Append(string)
IL_0065: callvirt Object::ToString()                               // 최종 string 1개만 힙 할당

StringBuilderstatic readonly로 캐싱하면 new StringBuilder() 할당이 사라진다. Append(int32) 오버로드가 int를 직접 받기 때문에 boxing도 없다. 힙 할당은 ToString() 한 번뿐이다.

✅ C# 10+ — $"" 보간이 최선

C# 10 이상 환경이라면 $""가 가장 간결하면서 성능도 좋다.

C#
public static string InterpolationModern(string name, int score)
{
    return $"Player: {name}, Score: {score}";
}

앞서 분석한 대로 DefaultInterpolatedStringHandler가 사용되어 boxing이 없고, 코드도 가장 읽기 쉽다. StringBuilder 캐싱보다 코드가 짧고, 컴파일러가 버퍼 크기를 자동으로 계산해주므로 실수할 여지도 적다.

🎮 Unity에서 TextMeshPro SetText 활용

Unity에서 UI 텍스트를 업데이트할 때는 TextMeshPro의 SetText가 최적의 선택이다. Unity의 Boehm GC는 세대별 수집이 없어 GC가 동작할 때 전체 힙을 탐색하므로, 표준 .NET보다 할당 비용이 훨씬 크다. 문자열 포맷팅에서 발생하는 작은 할당들이 쌓이면 GC 스파이크(순간적인 프레임 드랍)로 이어진다.

C#
using TMPro;

public class ScoreUI : MonoBehaviour
{
    [SerializeField] private TMP_Text scoreText;
    private int _lastScore = -1;

    void Update()
    {
        int currentScore = GameManager.Score;
        if (currentScore == _lastScore) return;  // 값이 바뀔 때만 갱신
        _lastScore = currentScore;

        // ❌ 이렇게 하면 매 호출마다 string 할당
        // scoreText.text = $"Score: {currentScore}";

        // ✅ SetText — 내부적으로 zero-alloc 포맷팅
        scoreText.SetText("Score: {0}", currentScore);
    }
}

SetText는 내부에서 자체 char[] 버퍼를 사용해 문자열을 조합한다. string 객체를 만들지 않고 텍스트 메시를 직접 갱신하므로 힙 할당이 0이다.


함정과 주의사항

❌ 함정 1 — + 연산자와 값 타입 혼합

C#
// 4개 이하 조각이면 Concat 전용 오버로드가 사용되지만...
static string ScoreDisplay(int score)
{
    return "Score: " + score;  // int.ToString()이 호출됨
}

+ 연산자로 stringint를 연결하면 컴파일러가 int.ToString()을 먼저 호출하고 Concat에 넘긴다. 박싱은 피하지만 ToString()이 새 string을 힙에 할당한다. $"Score: {score}"(C# 10+)라면 AppendFormatted<int>가 내부 버퍼에 직접 쓰므로 ToString() 호출 자체가 불필요하다.

❌ 함정 2 — String.Format에 5개 이상 인자

C#
// 5개 이상 인자 → params object[] 배열 할당 추가!
string result = string.Format("{0}/{1}/{2}/{3}/{4}", year, month, day, hour, minute);

String.Format의 전용 오버로드는 3인자까지만 있다. 4개 이상이면 params object[]가 사용되어 배열 힙 할당 + 모든 값 타입 boxing이 발생한다. C# 10+에서는 $""로 바꾸면 DefaultInterpolatedStringHandler가 이 문제를 자동으로 해결한다.

❌ 함정 3 — Unity의 C# 버전 주의

Unity의 C# 지원 상황을 반드시 확인해야 한다:

Unity 버전 C# 버전 $"" 내부 동작
2020.3 LTS C# 8 String.Format (boxing 발생)
2021.3 LTS C# 9 String.Format (boxing 발생)
2022.3 LTS C# 11 문법 런타임이 .NET Standard 2.1 기반DefaultInterpolatedStringHandler 미지원 가능
Unity 6 (2023.3+) C# 12 문법 .NET 핵심 API 확장 — Handler 지원 가능성 높음

핵심: Unity에서 C# 11 문법을 쓸 수 있다고 해서 C# 10의 런타임 최적화까지 적용되는 것은 아니다. Unity의 런타임(Mono/IL2CPP(Intermediate Language To C++, Unity가 IL 코드를 C++로 변환하여 네이티브 바이너리를 생성하는 AOT 컴파일러))이 DefaultInterpolatedStringHandler를 포함하는지 확인해야 한다. 미지원 환경에서는 StringBuilder 캐싱이나 ZString 같은 서드파티 라이브러리가 대안이다.

❌ 함정 4 — Debug.Log의 숨겨진 비용

C#
// 빌드에서 로그가 비활성화되어도 문자열은 이미 생성된다!
void Update()
{
    Debug.Log($"Frame: {Time.frameCount}, FPS: {1f / Time.deltaTime:F1}");
}

Debug.Log가 Release 빌드에서 출력되지 않더라도, 인자로 전달되는 $""메서드 호출 전에 이미 평가된다. 즉 문자열 생성과 힙 할당이 먼저 일어난다.

C#
// ✅ 조건부 컴파일로 호출 자체를 제거
[System.Diagnostics.Conditional("ENABLE_LOG")]
static void Log(string message) => Debug.Log(message);

[Conditional] 어트리뷰트를 사용하면 심볼이 정의되지 않았을 때 호출 자체가 컴파일에서 제거되므로 문자열 생성도 일어나지 않는다.


C# 버전별 변화

C# 6 — 문자열 보간 도입

C#
// Before (C# 5)
string result = string.Format("Name: {0}, Level: {1}", name, level);

// After (C# 6) — 가독성만 개선, 성능은 동일
string result = $"Name: {name}, Level: {level}";

C# 6의 $""는 컴파일러가 String.Format으로 변환하는 순수한 문법 설탕이었다. 성능 차이는 없고, 코드 가독성만 향상되었다.

C# 10 — DefaultInterpolatedStringHandler

C#
// 코드는 C# 6과 완전히 동일
string result = $"Name: {name}, Level: {level}";

같은 코드지만 C# 10 컴파일러는 DefaultInterpolatedStringHandler를 사용해 boxing과 배열 할당을 제거한다. 코드를 한 줄도 바꾸지 않아도 컴파일러 업그레이드만으로 성능이 개선되는 드문 사례다.

C# 10 — const 문자열 보간

C#
// 보간에 사용되는 모든 값이 컴파일 타임 상수이면 const 가능
const string Prefix = "Game";
const string Suffix = "Over";
const string Message = $"{Prefix} {Suffix}";  // 컴파일 타임에 "Game Over"로 확정
IL
.field public static literal string Prefix = "Game"
.field public static literal string Suffix = "Over"
.field public static literal string Message = "Game Over"   // 컴파일러가 미리 계산!

IL에서 Message 필드가 "Game Over" 리터럴로 저장되어 있다. 런타임 비용이 0이다. 단, 보간에 사용되는 모든 값이 const여야 한다 — 변수나 메서드 호출은 불가능하다.

C# 11 — Raw String Literal

C#
// Before — 이스케이프 지옥
string json = "{\n  \"name\": \"Player1\",\n  \"score\": 100\n}";

// After — Raw String Literal (C# 11)
string json = """
              {
                "name": "Player1",
                "score": 100
              }
              """;
Raw String Literal (원시 문자열 리터럴) 세 개 이상의 큰따옴표(""")로 시작하고 끝나는 문자열이다. 이스케이프 시퀀스(\n, \")를 처리하지 않으며, 들여쓰기는 닫는 """의 위치 기준으로 자동 제거된다.

보간과 함께 사용할 때는 $의 개수로 중괄호 해석을 제어한다:

C#
int score = 100;

// $ 1개 — {score}가 보간됨, JSON의 {는 {{로 이스케이프
string json1 = $"""
                {{
                  "score": {score}
                }}
                """;

// $$ 2개 — {{score}}가 보간됨, JSON의 {는 그대로
string json2 = $$"""
                {
                  "score": {{score}}
                }
                """;

Raw String Literal은 JSON, XML, SQL, 정규식 등 특수 문자가 많은 문자열을 다룰 때 가독성을 극대화한다.

ISpanFormattable — Zero-Alloc 포맷팅 (.NET 6+)

ISpanFormattable 인터페이스 bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider) 메서드를 가진 인터페이스다. 기존 ToString()이 새 string을 할당하는 반면, TryFormat이미 준비된 버퍼에 직접 쓰기 때문에 힙 할당이 없다.

int, double, DateTime 등 .NET 기본 타입은 이미 ISpanFormattable을 구현하고 있다. DefaultInterpolatedStringHandlerAppendFormatted<T>는 내부적으로 TISpanFormattable을 구현했는지 확인하고, 구현했다면 TryFormat을 호출해 버퍼에 직접 쓴다. 이것이 boxing 없이 값 타입을 포맷팅할 수 있는 핵심 메커니즘이다.

사용자 정의 타입에도 적용할 수 있다:

C#
public readonly struct GameScore : ISpanFormattable
{
    public readonly string PlayerName;
    public readonly int Score;

    public GameScore(string name, int score)
    {
        PlayerName = name;
        Score = score;
    }

    public bool TryFormat(Span<char> destination, out int charsWritten,
        ReadOnlySpan<char> format, IFormatProvider? provider)
    {
        return destination.TryWrite($"{PlayerName}: {Score}", out charsWritten);
    }

    public string ToString(string? format, IFormatProvider? provider)
        => $"{PlayerName}: {Score}";
}
IL
// TryFormat 내부 — TryWriteInterpolatedStringHandler 사용
IL_0009: newobj TryWriteInterpolatedStringHandler::.ctor(int32, int32, Span<char>, bool&)
IL_0014: ldarg.0
IL_0015: ldfld string GameScore::PlayerName
IL_001a: call bool TryWriteInterpolatedStringHandler::AppendFormatted(string)  // string 직접
IL_0023: ldstr ": "
IL_0028: call bool TryWriteInterpolatedStringHandler::AppendLiteral(string)
IL_0032: ldfld int32 GameScore::Score
IL_0037: call bool TryWriteInterpolatedStringHandler::AppendFormatted<int32>(!!0)  // int 직접, boxing 없음
IL_0043: call bool MemoryExtensions::TryWrite(Span<char>, TryWriteInterpolatedStringHandler&, int32&)

TryFormat 내부에서도 보간 핸들러가 사용된다. Span<char> 버퍼에 직접 쓰기 때문에 힙 할당이 0이다. 이 타입을 $"" 안에서 사용하면 DefaultInterpolatedStringHandlerTryFormat을 호출해 최적의 경로로 포맷팅한다.


정리

방식별 비교 요약

방식 힙 할당 Boxing 가독성 권장 환경
+ 연산자 중간 string + 배열(5개+) ToString() 호출 보통 2~3개 연결만
String.Format 배열 + boxing + 결과 발생 보통 레거시 코드
$"" (C# 6~9) = String.Format 발생 좋음 레거시 Unity
$"" (C# 10+) 결과 string만 없음 좋음 최우선 권장
StringBuilder 캐싱 ToString 1회 없음 (타입별 오버로드) 길어짐 구버전 Unity
TMP_Text.SetText 0 없음 좋음 Unity UI 최우선

핵심 체크리스트

  • [ ] C# 10+ 환경이면 $""가 최선이다DefaultInterpolatedStringHandler가 boxing과 배열 할당을 자동으로 제거한다
  • [ ] 구버전 Unity에서는 StringBuilderstatic readonly로 캐싱하고 타입별 Append 오버로드를 사용한다
  • [ ] String.Format은 값 타입 인자에 boxing을 일으킨다 — 새 코드에서는 피한다
  • [ ] UI 텍스트 갱신은 TMP_Text.SetText — 힙 할당 0으로 매 프레임 갱신 가능
  • [ ] 값이 바뀔 때만 UI를 갱신한다 — 이전 값과 비교하는 한 줄이 수백 번의 할당을 막는다
  • [ ] Debug.Log의 보간 문자열은 Release에서도 평가된다[Conditional]로 호출 자체를 제거하라
  • [ ] const 보간(C# 10)은 컴파일 타임에 확정된다 — 상수 조합에는 런타임 비용이 0
  • [ ] ISpanFormattable을 구현하면 사용자 정의 타입도 zero-alloc 포맷팅에 참여할 수 있다