반응형

[PART3.연산자와 표현식(11/11)] 문자열 보간과 기본 포맷팅 — $"..." 한 줄이 만들어내는 차이

가독성을 넘어 성능과 문화권까지 결정하는 문법 / C# 6의 string.Format → C# 10의 DefaultInterpolatedStringHandler 진화 / Unity 핫패스에서 안전하게 쓰는 법


1. 문제 제기 — Debug.Log 한 줄 때문에 프로파일러가 빨개질 때

Unity 신입 개발자가 처음 프로파일러를 열고 당황하는 장면이 있습니다. 플레이어 HP와 위치를 찍어 보려고 Update()에 다음 한 줄을 넣었을 때입니다.

C#
void Update()
{
    Debug.Log("Player " + name + " HP: " + hp + " Pos: " + transform.position);
}

처음엔 문제가 없어 보입니다. 그러나 프로파일러를 켜면 매 프레임 GC Alloc이 수백 바이트씩 잡히고, 스파이크가 튀기 시작합니다. 왜 그럴까요.

C#의 string은 불변(immutable) 객체입니다. + 로 붙일 때마다 새로운 문자열이 힙(heap, CPU가 참조 객체를 저장하는 동적 메모리 영역)에 할당되고, 중간 결과물이 그대로 쓰레기가 되어 GC(Garbage Collector, 메모리를 자동으로 회수하는 런타임 구성요소)의 부담이 됩니다. 그리고 동일한 상황을 다음과 같이 바꾸면 조금 나아집니다.

C#
void Update()
{
    Debug.Log($"Player {name} HP: {hp} Pos: {transform.position}");
}

$"..."문자열 보간(string interpolation) 이라 부릅니다. 읽기도 편하고, 최신 C# 에서는 내부 구조까지 최적화되어 있어 + 연결보다 훨씬 효율적입니다. 하지만 여전히 이 코드도 Unity 핫패스(hot path, 매 프레임 반복 호출되는 코드)에서는 위험합니다. 이유는 글을 읽어 나가면서 하나씩 풀어 보겠습니다.

이 글의 목표는 세 가지입니다.

  1. $"..." 의 기본 문법(포맷 지정자, 정렬 지정자, 이스케이프, verbatim·raw 조합)을 한 번에 정리합니다.
  2. 컴파일러가 이 문법을 IL(Intermediate Language, .NET 의 중간 언어) 수준에서 어떻게 번역하는지, C# 6 과 C# 10 에서 어떻게 달라졌는지 실측 IL 로 비교합니다.
  3. Unity 실전에서 Debug.Log($"...") 를 안전하게 쓰는 법, 문화권 이슈, string.Format · StringBuilder · Custom Interpolation Handler 중 언제 무엇을 쓸지 판단 기준을 제시합니다.

2. 개념 정의 — $"..." 은 "빈칸 채우기 레시피"다

2-1. 비유: 템플릿 쪽지와 빈칸

$"Name: {name}, Age: {age}" 를 한 문장으로 설명하자면 "빈칸이 뚫린 쪽지" 입니다. 미리 제목과 구분자를 적어둔 쪽지에서 {} 부분만 값으로 채워 넣는 구조입니다. + 연결이 "종이 조각을 여러 번 풀로 붙이는 방식" 이라면, 보간 문자열은 "한 장의 템플릿에 빈칸만 채우는 방식" 이라고 생각하면 됩니다.

① 템플릿 형태의 문자열 보간

2-2. 가장 기본 — 변수와 표현식을 그대로 박는다

$"..." — 문자열 보간 (String Interpolation) 문자열 앞에 $ 를 붙이면 {} 안의 식이 문자열로 변환되어 제자리에 삽입된다. {} 안에는 변수뿐만 아니라 메서드 호출, 삼항, LINQ 식 같은 임의의 C# 표현식을 쓸 수 있다.
예시: $"합: {a + b}" a 가 2, b 가 3이면 "합: 5" 가 된다.
C#
using System.Linq;

public class User
{
    public string Name { get; set; } = "";
    public int Age { get; set; }
}

public static class InterpBasics
{
    public static void Demo()
    {
        var user = new User { Name = "Alice", Age = 30 };

        // 변수 삽입
        System.Console.WriteLine($"이름: {user.Name}, 나이: {user.Age}");

        // 임의의 표현식 — 메서드 호출
        System.Console.WriteLine($"이름 길이: {user.Name.Length}");

        // 삼항 연산자
        System.Console.WriteLine($"분류: {(user.Age >= 20 ? "성인" : "미성년")}");

        // LINQ
        int[] scores = { 80, 90, 70 };
        System.Console.WriteLine($"평균: {scores.Average()}");
    }
}

이름 필드를 꺼내고, 메서드 결과를 넣고, 삼항도 쓰고, LINQ 평균까지 한 줄에 붙일 수 있습니다. {} 안이면 어떤 식이든 들어간다고 기억하면 됩니다. 다만 삼항 연산자처럼 콜론(:)이 등장하는 식은 반드시 괄호로 감싸야 합니다. 그렇지 않으면 뒤에서 설명할 포맷 지정자로 오해받습니다.

2-3. IL 로 첫 확인 — "무엇이 일어나는가"

$"Name: {name}, Age: {age}" 를 C# 10+ 컴파일러로 빌드하면 다음 IL 이 나옵니다.

C#
public static string Build()
{
    string name = "Alice";
    int age = 30;
    return $"Name: {name}, Age: {age}";
}
IL
.locals init (
    [0] string,
    [1] int32,
    [2] valuetype DefaultInterpolatedStringHandler   // ref struct 핸들러, 스택 로컬
)

IL_0000: ldstr      "Alice"
IL_0005: stloc.0
IL_0006: ldc.i4.s   30
IL_0008: stloc.1
IL_0009: ldloca.s   2
IL_000b: ldc.i4.s   13           // 리터럴 총 길이(대략값)
IL_000d: ldc.i4.2                // 보간 값 개수
IL_000e: call       DefaultInterpolatedStringHandler::.ctor(int32, int32)
IL_0013: ldloca.s   2
IL_0015: ldstr      "Name: "
IL_001a: call       DefaultInterpolatedStringHandler::AppendLiteral(string)
IL_001f: ldloca.s   2
IL_0021: ldloc.0
IL_0022: call       DefaultInterpolatedStringHandler::AppendFormatted(string)
IL_0027: ldloca.s   2
IL_0029: ldstr      ", Age: "
IL_002e: call       DefaultInterpolatedStringHandler::AppendLiteral(string)
IL_0033: ldloca.s   2
IL_0035: ldloc.1
IL_0036: call       DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0)  // 제네릭 → boxing 없음
IL_003b: ldloca.s   2
IL_003d: call       DefaultInterpolatedStringHandler::ToStringAndClear()
IL_0042: ret

지금 전체를 다 이해할 필요는 없습니다. 지금 단계에서 머리에 넣어둘 것은 세 가지입니다.

  • 컴파일러가 $"..."string.Format 한 줄로 바꾸지 않고, DefaultInterpolatedStringHandler 라는 구조체를 만들고 AppendLiteral / AppendFormatted 를 번갈아 호출하는 형태로 번역했다는 것.
  • 리터럴("Name: ", ", Age: ")은 AppendLiteral 로, 변수 값은 AppendFormatted 로 들어간다는 것.
  • AppendFormatted<int32>(!!0) — 이 제네릭 호출이 있다는 것. int 같은 값 타입이 object 로 boxing(값 타입을 참조 타입으로 감싸 힙에 올리는 변환)되지 않는다는 의미입니다.

C# 6 시절에는 이 구조가 아니었습니다. 얼마나 달랐는지는 3장 내부 동작에서 IL Before/After 쌍으로 비교하겠습니다.


3. 내부 동작 — string.Format 에서 DefaultInterpolatedStringHandler 까지

3-1. C# 6 ~ 9 시절 — string.Format(string, object[]) 로 번역

C# 6 초기 문자열 보간은 단순한 문법적 설탕(syntactic sugar, 더 편한 문법이지만 컴파일 후에는 기존 기능으로 되돌아가는 표현)이었습니다. $"Name: {name}, Age: {age}" 는 컴파일 시점에 string.Format("Name: {0}, Age: {1}", name, age) 로 치환됩니다.

C# 6 ~ 9: string.Format 방식

netstandard2.0 + LangVersion 7.3 으로 실제 컴파일한 IL 입니다.

C#
// LangVersion=7.3, TargetFramework=netstandard2.0
public static string Before()
{
    string name = "Alice";
    int age = 30;
    return $"Name: {name}, Age: {age}";
}
IL
.method public hidebysig static string Before () cil managed
{
    .maxstack 3
    .locals init (
        [0] string,
        [1] int32
    )

    IL_0000: ldstr      "Alice"
    IL_0005: stloc.0
    IL_0006: ldc.i4.s   30
    IL_0008: stloc.1
    IL_0009: ldstr      "Name: {0}, Age: {1}"        // 포맷 문자열 리터럴
    IL_000e: ldloc.0
    IL_000f: ldloc.1
    IL_0010: box        [netstandard]System.Int32    // ★ int → object boxing (힙 할당)
    IL_0015: call       string System.String::Format(string, object, object)
    IL_001a: ret
}

여기서 두 가지에 주목합니다.

  • box [System.Int32]ageobject 파라미터로 전달되기 위해 boxing 이 발생합니다. boxing 은 값 타입 내용을 힙에 새 객체로 복사하는 작업입니다.
  • call string.Format(string, object, object) — 이 오버로드는 인자가 3개 이하일 때 쓰입니다. 인자가 4개 이상이면 String::Format(string, object[]) 로 바뀌면서 추가로 object[] 배열이 힙에 할당됩니다.

Unity 핫패스에서 이 코드를 쓰면 프레임마다 boxing 1 회 + 문자열 1 회 = 최소 2 번의 힙 할당이 누적됩니다.

3-2. C# 10 이후 — DefaultInterpolatedStringHandler 로 전환

C# 10 부터 컴파일러는 $"..." 를 단순 string.Format 이 아니라, DefaultInterpolatedStringHandler 라는 ref struct(스택에만 살 수 있는 구조체)로 번역합니다.

C#
// LangVersion=10 이상, TargetFramework=net6.0 이상
public static string After()
{
    string name = "Alice";
    int age = 30;
    return $"Name: {name}, Age: {age}";
}
IL
.locals init (
    [0] string,
    [1] int32,
    [2] valuetype DefaultInterpolatedStringHandler
)

IL_0009: ldloca.s   2
IL_000b: ldc.i4.s   13
IL_000d: ldc.i4.2
IL_000e: call       DefaultInterpolatedStringHandler::.ctor(int32, int32)   // 핸들러 생성 (스택)
IL_0013: ldloca.s   2
IL_0015: ldstr      "Name: "
IL_001a: call       DefaultInterpolatedStringHandler::AppendLiteral(string)
IL_001f: ldloca.s   2
IL_0021: ldloc.0
IL_0022: call       DefaultInterpolatedStringHandler::AppendFormatted(string)
IL_0027: ldloca.s   2
IL_0029: ldstr      ", Age: "
IL_002e: call       DefaultInterpolatedStringHandler::AppendLiteral(string)
IL_0033: ldloca.s   2
IL_0035: ldloc.1
IL_0036: call       DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0)  // ★ 제네릭, boxing 없음
IL_003b: ldloca.s   2
IL_003d: call       DefaultInterpolatedStringHandler::ToStringAndClear()
IL_0042: ret

무엇이 사라졌고 무엇이 생겼는지 3 가지만 기억하세요.

  • box 명령어가 사라졌습니다. AppendFormatted<int32>(!!0) 는 제네릭이라 intobject 로 변환할 필요가 없습니다.
  • object[] 배열 할당이 사라졌습니다. 이제 보간 값은 배열에 모아져 string.Format 에 넘기지 않고, 각 값마다 개별 AppendFormatted 호출로 처리합니다.
  • 핸들러는 스택 로컬입니다. .locals init 에서 valuetype DefaultInterpolatedStringHandler 로 선언되어, 메서드가 끝나면 자동으로 사라집니다. 내부적으로는 ArrayPool<char> 에서 빌린 버퍼를 사용하고 ToStringAndClear 에서 반환합니다.

결과적으로 최신 컴파일러에서 $"..." 는 "읽기 쉬운 고성능" 이라는 두 마리 토끼를 잡았습니다.

3-3. 두 IL 을 한눈에 비교

항목 C# 6 ~ 9 (string.Format) C# 10+ (DefaultInterpolatedStringHandler)
포맷 문자열 "Name: {0}, Age: {1}" 런타임 파싱 컴파일 시점에 리터럴과 값 분리
값 전달 params object[] → 배열 할당 각 값마다 AppendFormatted<T> 호출
값 타입 처리 box 명령어로 힙 할당 제네릭이라 boxing 없음
중간 버퍼 string.Format 내부 StringBuilder ArrayPool<char> 기반 버퍼 (재사용)
힙 할당 횟수(int 2 개 보간 기준) 최대 3 회 (boxing 2 + 최종 string 1) 1 회 (최종 string 만)

4. 실전 적용 — 포맷 지정자 · 정렬 지정자 · 문화권

4-1. 포맷 지정자 {value:F2} — 숫자를 "어떻게 보여줄지"

{식:포맷} — 포맷 지정자 (Format specifier) 보간 중괄호 안에 콜론(:)을 붙인 뒤 포맷 코드를 써서 숫자·날짜·시간의 표기 방식을 지정한다. string.Format 과 동일한 표준 포맷 문자열을 그대로 사용한다.
예시: $"{1234.5:F2}""1234.50" (소수점 2 자리 고정)

자주 쓰는 표준 포맷 코드를 한 표로 정리합니다(값 1234.5678, 정수는 255 기준).

코드 의미 결과 예
F2 고정 소수점 2 자리 1234.57
N0 천 단위 구분 기호, 소수점 없음 1,235
C 현재 문화권의 통화 기호 ₩1,235 (ko-KR)
P1 백분율 1 자리 (자동 ×100) 0.12312.3 %
X 16 진수 (대문자) 255FF
D5 정수 10 진수, 최소 5 자리 25500255
E2 지수 표기 2 자리 1.23E+003
G 일반 표기 (디폴트) 1234.5678
yyyy-MM-dd HH:mm:ss 커스텀 날짜/시간 패턴 2025-05-01 13:45:00
C#
using System;

public static class FormatDemo
{
    public static void Run()
    {
        double value = 1234.5678;
        int    n     = 255;
        DateTime dt  = new DateTime(2025, 5, 1, 13, 45, 0);

        Console.WriteLine($"F2    → {value:F2}");        // 1234.57
        Console.WriteLine($"N0    → {value:N0}");        // 1,235
        Console.WriteLine($"C     → {value:C}");         // ₩1,235 (ko-KR 기준)
        Console.WriteLine($"P1    → {0.123:P1}");        // 12.3 %
        Console.WriteLine($"X     → {n:X}");             // FF
        Console.WriteLine($"D5    → {n:D5}");            // 00255
        Console.WriteLine($"E2    → {value:E2}");        // 1.23E+003
        Console.WriteLine($"dt    → {dt:yyyy-MM-dd HH:mm:ss}"); // 2025-05-01 13:45:00
    }
}

포맷 코드는 "외워야 하는 것" 이라기보다, 쓸 때마다 공식 문서를 한 번씩 열어 보기 좋은 참조 표입니다. 실무에서 가장 자주 쓰는 것은 F, N, D, X, 그리고 커스텀 날짜 패턴 다섯 가지입니다.

4-2. 정렬 지정자 {value,10} — 표 형태 로그 만들기

{식,너비} — 정렬 지정자 (Alignment specifier) 보간 중괄호 안에 쉼표와 정수를 붙이면 해당 값이 차지할 최소 문자 너비를 지정한다. 양수는 오른쪽 정렬(왼쪽 공백), 음수는 왼쪽 정렬(오른쪽 공백)이다. 포맷 지정자와 함께 쓰려면 ,너비:포맷 순서로 적는다.
예시: $"{value,10:F2}" — 10 자리 폭 안에서 오른쪽 정렬, 소수점 2 자리.

정렬은 로그를 표처럼 가지런히 찍을 때 엄청난 차이를 만듭니다.

C#
using System;

public static class AlignDemo
{
    public static void Run()
    {
        (string name, int hp, double atk)[] enemies =
        {
            ("고블린",      30,  4.5),
            ("오크 전사",   120, 12.75),
            ("드래곤",      9999, 187.125),
        };

        // 헤더: 이름은 왼쪽 정렬(-10), 숫자는 오른쪽 정렬
        Console.WriteLine($"{"이름",-10} {"HP",6} {"공격력",10}");
        Console.WriteLine(new string('-', 30));

        foreach (var e in enemies)
            Console.WriteLine($"{e.name,-10} {e.hp,6} {e.atk,10:F2}");
    }
}

출력은 다음과 같이 가지런합니다(공백 정렬 유지).

이름         HP         공격력
------------------------------
고블린         30       4.50
오크 전사     120      12.75
드래곤       9999     187.13

음수 너비는 왼쪽 정렬(문자열 컬럼에 적합), 양수 너비는 오른쪽 정렬(숫자 컬럼에 적합)이라는 공식만 기억하면 됩니다.

4-3. 이스케이프와 verbatim / raw 조합

{{, }} — 중괄호 이스케이프 보간 문자열에서 {} 문자 자체를 출력하려면 두 번 겹쳐 쓴다. $"a {{b}} c""a {b} c" 가 된다.
$@"..." / @$"..." — verbatim 보간 문자열 @ 가 붙으면 \n, \t 같은 이스케이프 시퀀스를 무시하고 문자 그대로 해석한다. 여러 줄도 가능하며 파일 경로나 정규식에 편리하다. C# 8 부터는 $@@$ 순서가 모두 허용된다.
$"""...""" / $$"""...""" — raw 보간 문자열 (C# 11+) 큰따옴표 3 개 이상으로 감싸며 이스케이프가 필요 없다. $ 개수와 { 개수가 일치해야 보간으로 해석되므로 $$ 를 쓰면 JSON 의 {} 를 그대로 쓸 수 있다.
C#
using System;

public static class QuotesDemo
{
    public static void Run()
    {
        string name = "Alice";

        // 1) 중괄호 이스케이프
        Console.WriteLine($"JSON 형태: {{ \"name\": \"{name}\" }}");
        // 출력: JSON 형태: { "name": "Alice" }

        // 2) verbatim 조합 — 경로
        string path = "Scripts";
        Console.WriteLine($@"C:\Project\{path}\Player.cs");
        // 출력: C:\Project\Scripts\Player.cs

        // 3) raw 문자열 보간 — JSON 블록
        int age = 30;
        string json = $$"""
        {
          "name": "{{name}}",
          "age":  {{age}}
        }
        """;
        Console.WriteLine(json);
        // 출력:
        // {
        //   "name": "Alice",
        //   "age":  30
        // }
    }
}

실무에서 느끼는 차이는 확실합니다. 경로 문자열을 쓸 때 \\ 로 백슬래시를 두 번 적는 것이 지겹다면 $@"..." 를, 멀티라인 JSON·SQL 을 템플릿처럼 쓰고 싶다면 $$"""...""" 가 답입니다.

4-4. Unity 실전 — Debug.Log($"...") 를 안전하게 쓰는 법

C# 10+ 에서 $"..." 가 빨라졌다고 해도, Unity 의 Update 마다 문자열을 만드는 것은 여전히 비용입니다. 문자열 자체가 최종 힙 객체이기 때문입니다. 또한 Unity 의 Debug.Log 는 메서드 호출 전에 인자 문자열이 이미 완성된 상태로 넘어옵니다. "로그 레벨이 꺼져 있어서 실제로 출력되지 않아도" 문자열 생성 비용은 그대로 발생한다는 뜻입니다.

Before — 릴리스 빌드에서도 문자열을 만든다:

C#
using UnityEngine;

public class Enemy : MonoBehaviour
{
    public string enemyName = "오크";
    public int hp = 100;

    void Update()
    {
        // ❌ 릴리스 빌드에서도 문자열이 생성되어 GC Alloc 발생
        Debug.Log($"[{enemyName}] HP: {hp}, Pos: {transform.position}");
    }
}

After — [Conditional] 로 호출 자체를 컴파일 타임에 제거:

C#
using System.Diagnostics;
using UnityEngine;

public static class DevLog
{
    // UNITY_EDITOR 심볼이 없는 릴리스 빌드에서는 호출 자체가 제거된다.
    // 호출이 제거되면 인자로 넘기는 $"..." 문자열 생성도 함께 사라진다.
    [Conditional("UNITY_EDITOR")]
    [Conditional("DEVELOPMENT_BUILD")]
    public static void Log(string message) => UnityEngine.Debug.Log(message);
}

public class Enemy : MonoBehaviour
{
    public string enemyName = "오크";
    public int hp = 100;

    void Update()
    {
        // ✅ 에디터 / 개발 빌드에서만 문자열이 만들어진다.
        DevLog.Log($"[{enemyName}] HP: {hp}, Pos: {transform.position}");
    }
}

[Conditional] 속성은 해당 심볼이 정의되지 않은 빌드에서 메서드 호출 자체를 IL 수준에서 제거합니다. 호출이 사라지면 인자로 넘기는 $"..." 보간도 함께 사라져, 릴리스 빌드의 Update 에서 GC Alloc 이 0 이 됩니다. Unity 공식 권장 패턴입니다.

여기서 "그럼 아예 문자열 보간을 쓰지 않으면 더 빠르지 않을까?" 라는 의문이 들 수 있습니다. 맞습니다. 더 나아간 방법은 6 장에서 설명할 Custom Interpolation Handler 입니다.

4-5. 문화권 이슈 — CurrentCultureInvariantCulture

문자열 보간은 기본적으로 실행 중인 스레드의 문화권(CultureInfo.CurrentCulture)을 따릅니다. 사람이 읽는 UI 에서는 장점이지만, 파일 이름·로그·API 요청 body 처럼 기계가 읽는 문자열에서는 심각한 문제가 됩니다.

C#
using System;
using System.Globalization;
using System.Threading;

public static class CultureDemo
{
    public static void Run()
    {
        double price = 1234.5;

        // 한국: "1,234.50"
        Thread.CurrentThread.CurrentCulture = new CultureInfo("ko-KR");
        Console.WriteLine($"KO: {price:N2}");

        // 독일: "1.234,50"  — 소수점이 쉼표, 천단위가 점
        Thread.CurrentThread.CurrentCulture = new CultureInfo("de-DE");
        Console.WriteLine($"DE: {price:N2}");

        // ❌ 위험: 독일어 환경에서 실행되면 "price=1.234,50" 이 되어
        //        서버가 파싱에 실패할 수 있다
        string query = $"price={price:F2}";
        Console.WriteLine(query);
    }
}

해결책은 문화권을 명시적으로 고정 하는 것입니다. C# 에서는 두 가지 관용 구문이 있습니다.

C#
using System.Globalization;

public static class InvariantDemo
{
    public static string MakeQuery(double price)
    {
        // 방법 1: FormattableString.Invariant  (C# 6+ 부터 사용 가능)
        string q1 = FormattableString.Invariant($"price={price:F2}");

        // 방법 2: string.Create + CultureInfo.InvariantCulture (C# 10+ 권장)
        string q2 = string.Create(CultureInfo.InvariantCulture, $"price={price:F2}");

        return q1 + " / " + q2;
    }
}

두 방법 모두 결과는 같습니다 — 어떤 문화권에서 실행하든 price=1234.50. 다만 IL 수준에서는 차이가 있습니다. string.Create(CultureInfo, $"...") 는 C# 10+ 에서 컴파일러가 핸들러 생성자에 IFormatProvider 를 직접 넘기는 코드로 번역합니다. 아래는 실제 컴파일한 IL 입니다.

C#
public static string WithCulture()
{
    double value = 1234.5;
    return string.Create(CultureInfo.InvariantCulture, $"value={value:F2}");
}
IL
.locals init (
    [0] float64,
    [1] class System.IFormatProvider,
    [2] valuetype DefaultInterpolatedStringHandler
)

IL_000a: call  System.Globalization.CultureInfo::get_InvariantCulture()
IL_000f: stloc.1
IL_0010: ldloc.1
IL_0011: ldloca.s   2
IL_0013: ldc.i4.6
IL_0014: ldc.i4.1
IL_0015: ldloc.1
IL_0016: call       DefaultInterpolatedStringHandler::.ctor(int32, int32, System.IFormatProvider) // ★ 문화권을 핸들러에 주입
IL_001b: ldloca.s   2
IL_001d: ldstr      "value="
IL_0022: call       DefaultInterpolatedStringHandler::AppendLiteral(string)
IL_0027: ldloca.s   2
IL_0029: ldloc.0
IL_002a: ldstr      "F2"
IL_002f: call       DefaultInterpolatedStringHandler::AppendFormatted<float64>(!!0, string)
IL_0034: ldloca.s   2
IL_0036: call       string System.String::Create(System.IFormatProvider, DefaultInterpolatedStringHandler&)
IL_003b: ret

핵심은 DefaultInterpolatedStringHandler::.ctor(int32, int32, IFormatProvider) 라인입니다. 일반 $"..." 에는 없던 3 번째 파라미터로 InvariantCulture 가 주입됩니다. 이후 AppendFormatted<float64> 가 이 핸들러를 통해 포맷팅하므로, 스레드의 CurrentCulture 와 무관하게 항상 . 구분자로 출력됩니다.

원칙을 한 줄로 요약하면 이렇습니다:

사람이 보는 값 → CurrentCulture (기본 $"...") 기계가 읽는 값 (로그·파일명·API·JSON·쿼리스트링) → InvariantCulture (string.Create 또는 FormattableString.Invariant)

5. 함정과 주의사항

5-1. 삼항 연산자의 콜론이 포맷 지정자로 해석됨

❌ 잘못된 패턴:

C#
int age = 30;
// 컴파일 에러 또는 잘못된 포맷팅
// age 가 20 이상이면 "성인" 이라는 문자열을 포맷으로 해석하려다 실패
Console.WriteLine($"분류: {age >= 20 ? "성인" : "미성년"}");

{age >= 20 ? "성인" : "미성년"} 에서 : 뒤의 "미성년"포맷 지정자로 해석하려다 에러가 납니다.

✅ 올바른 패턴 — 삼항 전체를 괄호로 감싸기:

C#
int age = 30;
Console.WriteLine($"분류: {(age >= 20 ? "성인" : "미성년")}");
// 출력: 분류: 성인

괄호 하나로 파싱 모호성이 사라집니다. : 을 포함하는 모든 식(딕셔너리 인덱서, 제네릭 타입 등)에도 동일한 규칙이 적용됩니다.

5-2. Unity Update 안에서의 $"..." — GC 압박

❌ 잘못된 패턴 — 매 프레임 문자열 생성:

C#
public class HpBar : MonoBehaviour
{
    public Text hpText;
    public int currentHp = 100;
    public int maxHp = 100;

    void Update()
    {
        // ❌ 프레임마다 문자열 한 개씩 힙에 할당
        hpText.text = $"{currentHp}/{maxHp}";
    }
}

✅ 올바른 패턴 — 값이 바뀔 때만 갱신:

C#
public class HpBar : MonoBehaviour
{
    public Text hpText;
    public int currentHp = 100;
    public int maxHp = 100;

    int cachedHp = -1;

    void Update()
    {
        // ✅ HP 가 변했을 때만 문자열 생성 — 보통 프레임당 0 회
        if (currentHp != cachedHp)
        {
            hpText.text = $"{currentHp}/{maxHp}";
            cachedHp = currentHp;
        }
    }
}

"프레임마다 같은 일을 반복하지 않는다" 는 원칙은 문자열에도 똑같이 적용됩니다. 변화 감지(cachedHp 비교) 한 줄로 대부분의 Update 에서 문자열 생성이 사라집니다.

5-3. 포맷 지정자 오타 — 런타임 예외

❌ 잘못된 패턴 — 지원하지 않는 포맷 코드:

C#
int n = 123;
// 컴파일은 통과하지만 실행 시 FormatException 발생
Console.WriteLine($"{n:Q2}");

포맷 코드는 컴파일 시점에 검증되지 않습니다. F, N, D, X, P, C, E, G 이 아닌 코드를 쓰면 런타임에 FormatException 이 납니다. Unity 릴리스 빌드에서 사용자만 보는 에러가 될 수 있습니다.

✅ 올바른 패턴 — 지원되는 코드를 쓰거나 커스텀 패턴을 명시:

C#
int n = 123;
Console.WriteLine($"{n:D5}");      // 00123
Console.WriteLine($"{n:X2}");      // 7B
Console.WriteLine($"{n:0000}");    // 0123 (커스텀 패턴)

값 타입별로 유효한 포맷 코드가 다릅니다. int 에는 D 가 되지만 double 에는 D 가 없습니다. 의심스러울 때는 Roslyn 의 .NET 포맷 문자열 인텔리센스를 활용하거나, 유닛 테스트로 먼저 검증하세요.

5-4. Debug.Assert($"...") 의 함정

❌ 잘못된 패턴 — 문자열이 항상 생성됨:

C#
using System.Diagnostics;

public static void Check(int hp)
{
    // ❌ hp 가 0 이상이어도 "Invalid HP: 100" 같은 문자열이 매번 생성된다
    Debug.Assert(hp >= 0, $"Invalid HP: {hp}");
}

표준 Debug.Assert 는 조건이 참이어도 두 번째 인자인 메시지 문자열을 이미 만들어 놓은 상태로 받습니다. 조건이 참일 때 포맷팅 비용을 스킵하려면 Custom Interpolation Handler 가 필요합니다. 이 이야기는 다음 장에서 이어갑니다.


6. C# 버전별 변화

C# 6 (2015) — 문법 도입, string.Format 치환

최초 도입 버전입니다. 문법적 설탕으로, 보간 표현식은 컴파일러가 string.Format 호출로 번역했습니다. 포맷 지정자와 정렬 지정자도 이때 전부 지원됩니다.

C#
// C# 6
string s = $"Name: {name}, Age: {age}";
// 컴파일 결과: string.Format("Name: {0}, Age: {1}", name, age)

IL 은 3-1 절의 Before 예시와 동일합니다 (box + object[] 할당).

C# 8 (2019) — @$ / $@ 순서 혼용 허용

사소하지만 실전에서 자주 마주치는 완화입니다. 이전에는 $@"..." 순서만 허용됐는데, C# 8 부터 @$"..." 도 컴파일됩니다. 기능적 차이는 없습니다.

C#
// C# 6 이후 항상 OK
string a = $@"C:\Project\{file}";
// C# 7 이하: 컴파일 에러 / C# 8 이후: OK
string b = @$"C:\Project\{file}";

C# 10 (2021) — DefaultInterpolatedStringHandler 도입

이 글에서 계속 이야기한 가장 큰 변화입니다. 값 타입 boxing 과 object[] 배열 할당이 사라졌습니다. 또한 이 버전에서 사용자가 직접 Interpolation Handler 를 만들 수 있는 확장 포인트가 열렸습니다.

Custom Handler 의 대표 사례는 Debug.Assert 의 메시지 포맷팅 최적화입니다. .NET 6 부터 표준 라이브러리에서 다음과 같이 동작합니다.

C#
// .NET 6+: 내부 시그니처 (개념적 표현)
public static void Assert(
    [DoesNotReturnIf(false)] bool condition,
    [InterpolatedStringHandlerArgument("condition")] ref AssertInterpolatedStringHandler message);

핵심은 [InterpolatedStringHandlerArgument("condition")] 속성입니다. 이 속성이 붙으면 컴파일러가 핸들러 생성자에 condition 값을 먼저 넘겨 보간 결과를 만들지 말지 결정할 기회를 줍니다. condition 이 참이면 핸들러 내부 플래그가 false 가 되고, 이후의 AppendFormatted 호출은 {} 안의 식조차 평가하지 않고 즉시 리턴됩니다.

사용자 코드는 이전과 똑같습니다.

C#
using System.Diagnostics;

Debug.Assert(hp >= 0, $"Invalid HP: {hp} at {DateTime.Now}");
// hp >= 0 이 참이면 $"..." 안의 DateTime.Now 도 호출되지 않는다

C# 10 이전에는 이 동작이 불가능했습니다. 로깅·어서션 라이브러리에서 약 30 ~ 90% 성능 개선이 보고되는 영역입니다.

C# 11 (2022) — raw 문자열 보간

큰따옴표 3 개 이상으로 감싸는 raw 문자열이 도입되면서 보간 문법과 결합됐습니다. $$"""...""" 처럼 $ 를 늘리면 그 개수만큼 { 를 겹쳐야 보간으로 해석됩니다 — JSON 이나 SQL 템플릿에 매우 실용적입니다. 문법만 추가됐고 IL 은 기본 $"..." 와 동일한 DefaultInterpolatedStringHandler 로 번역됩니다.

Unity 런타임 버전별 정리

Unity 는 자체 C# 컴파일러(Mono + Roslyn) 와 IL2CPP 를 쓰므로 C# 언어 버전이 .NET SDK 와 다릅니다.

Unity 버전 기본 지원 C# 의미
Unity 2020 LTS C# 8 raw/handler 미지원. 보간은 string.Format 치환
Unity 2021 LTS C# 9 같음. handler 미지원
Unity 2022 LTS C# 9 같음
Unity 2023 C# 9 + 일부 C# 10 (기본) DefaultInterpolatedStringHandler 부분 활용
Unity 6 (2024 LTS) C# 9 (+ 실험적 C# 10/11) 프로젝트 설정에서 LangVersion 을 올리면 handler 활용 가능

Unity 6 이전이라면 $"..." 는 여전히 string.Format + boxing 경로를 타는 경우가 많습니다. 핫패스에서는 이 글의 5-2, 4-4 패턴 — 변화 감지 + [Conditional] — 이 버전과 무관하게 안전합니다.


7. 정리 — 이것만 기억합시다

  • 기본 문법: $"...{식}..." 안에 어떤 C# 표현식도 넣을 수 있다. 삼항처럼 : 를 포함하는 식은 괄호로 감싼다.
  • 포맷 지정자 {식:코드}: 자주 쓰는 5 개는 F(고정 소수점), N(천 구분), D(정수 패딩), X(16 진수), 커스텀 날짜 패턴(yyyy-MM-dd). 포맷 오타는 컴파일러가 잡지 못하고 런타임 FormatException 으로 터진다.
  • 정렬 지정자 {식,너비}: 양수는 오른쪽 정렬, 음수는 왼쪽 정렬. 포맷과 함께 쓰면 {식,너비:코드} 순서.
  • IL 수준 변화: C# 6 ~ 9 는 string.Format + boxing + object[]. C# 10 부터는 DefaultInterpolatedStringHandler 로 boxing / 배열 할당이 사라짐. 최종 문자열 1 회만 힙에 할당.
  • Unity 핫패스: C# 10+ 여도 최종 string 은 힙에 만들어진다. Update 에서 매 프레임 $"..." 를 피하려면 변화 감지[Conditional("UNITY_EDITOR")] 로 감싼 로거를 쓴다.
  • 문화권: 사람이 보는 값은 기본(CurrentCulture), 기계가 읽는 값(로그·파일명·API)은 string.Create(CultureInfo.InvariantCulture, $"...") 또는 FormattableString.Invariant($"...") 로 고정한다.
  • 선택 기준: 일반 문자열 조합 → $"...". 반복문에서 대량 문자열 조립 → StringBuilder. 포맷 문자열을 런타임에 외부에서 받아야 할 때(리소스 파일 번역 등) → string.Format. 로깅·어서션처럼 조건에 따라 포맷팅을 스킵하고 싶을 때 → Custom Interpolation Handler.
반응형

+ Recent posts