반응형

[PART6.배열과 문자열 기본(9/14)] 문자열 포맷팅 총정리 — 보간 · string.Format · 축자 · 원시 문자열

같은 결과를 만드는 네 가지 표기 / IL이 갈리는 지점은 string.Format(박싱)과 보간(DefaultInterpolatedStringHandler) / 축자 @"..."·원시 """..."""은 컴파일 시점 처리로 런타임 비용 0 / 포맷 지정자 {val:N2}·정렬 {val,10} / 새 코드는 거의 항상 보간 우선


1. 네 가지 표기, 무엇이 다른가

C#에서 문자열을 만드는 방법은 시간이 지나며 늘어났습니다. 같은 결과를 내는 네 가지 표기를 한자리에 늘어놓아 봅니다.

C#
string name = "Alice";
int age = 30;

// (a) 보간 — C# 6.0
string s1 = $"Hello {name}, age {age}";

// (b) string.Format — 1.0부터
string s2 = string.Format("Hello {0}, age {1}", name, age);

// (c) 축자 + 보간 — 백슬래시 그대로
string s3 = $@"C:\Users\{name}";

// (d) 원시 문자열 + 보간 — C# 11
string s4 = $$"""
{
  "name": "{{name}}",
  "level": {{age}}
}
""";

겉보기엔 줄 수 차이지만, 실제로는 IL 변환 결과가 모두 다릅니다. 보간은 .NET 6/C# 10 이후 박싱 없는 핸들러로, string.Format은 박싱이 있는 가변 인자 호출로, 축자/원시는 컴파일 시점 처리로 — 이 글의 목표는 그 차이를 IL 레벨에서 풀어 보는 것입니다.


2. 보간 vs string.Format — IL이 결정적으로 다르다

IL 비교

C#
public static string ByInterp(string name, int age) => $"Hello {name}, age {age}";
public static string ByFormat(string name, int age) => string.Format("Hello {0}, age {1}", name, age);
IL
// ByInterp — DefaultInterpolatedStringHandler 사용 (.NET 6+, C# 10+)
IL_0005: call instance void DefaultInterpolatedStringHandler::.ctor(int32, int32)
IL_0011: call instance void DefaultInterpolatedStringHandler::AppendLiteral(string)        // "Hello "
IL_001a: call instance void DefaultInterpolatedStringHandler::AppendFormatted(string)      // name
IL_0027: call instance void DefaultInterpolatedStringHandler::AppendLiteral(string)        // ", age "
IL_0030: call instance void DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0)  // age
                              // ← 제네릭 메서드라 박싱 없음
IL_0038: call instance string DefaultInterpolatedStringHandler::ToStringAndClear()         // 최종 string

// ByFormat — 가변 인자 + 박싱
IL_0000: ldstr "Hello {0}, age {1}"
IL_0007: box  System.Int32                                                                  // ← 박싱 발생!
IL_000c: call string System.String::Format(string, object, object)

핵심:

동작 보간 (C# 10+) string.Format
박싱 없음 (제네릭 AppendFormatted<T>) 발생 (object 매개변수)
임시 객체 핸들러 struct 1개 (스택) + 최종 string 1개 매개변수가 object[]로 묶일 수 있음 + 박스 객체 + string 1개
런타임 처리 미리 결정된 컴파일러 코드 형식 문자열 파싱
$

결론: 새 코드는 거의 항상 보간 $"..."을 쓰면 됩니다. string.Format은 다국어 리소스 파일(.resx)에서 형식 문자열을 외부 데이터로 받아야 할 때만 명시적으로 씁니다.

C# 10/.NET 6 이전이라면 그 이전에는 보간이 그냥 string.Format으로 컴파일됐기 때문에 성능 차이가 없었습니다 — 가독성으로만 보간을 골랐습니다. 지금은 가독성·성능 둘 다 보간이 우위라 선택의 여지가 없습니다.

3. 축자 문자열 @"..." — 백슬래시를 그대로

C#
// ❌ 일반 — \\ 두 번씩 적어야 함
string p = "C:\\Users\\Player\\Save.json";

// ✅ 축자
string p = @"C:\Users\Player\Save.json";

// 축자 + 보간
string p = $@"C:\Users\{userName}\Save";

// 따옴표가 들어가야 한다면 ""로 이스케이프
string j = @"He said ""hi"".";
IL
// 축자도 결국은 일반 string 리터럴 — 컴파일러가 변환만 다르게
IL_0000: ldstr "C:\\Users\\Player\\Save.json"
IL_0005: ret

축자 문자열은 컴파일 시점에 일반 string으로 변환되어 IL은 ldstr 단일 명령입니다. 런타임 비용은 일반 리터럴과 동일 — 표기법이 짧아질 뿐입니다.

자주 쓰는 자리:

  • 파일 경로 (@"C:\Game\Saves")
  • 정규 표현식 (@"\d{3}-\d{4}")
  • 멀티라인 텍스트 (개행을 그대로 포함)

4. 원시 문자열 """...""" (C# 11) — 이스케이프 0

C#
// ❌ 일반 — JSON 따옴표마다 이스케이프
string json = "{\n  \"name\": \"Hero\",\n  \"level\": 12\n}";

// ✅ 원시 — 그대로 적기만 하면 끝
string json = """
{
  "name": "Hero",
  "level": 12
}
""";

// 원시 + 보간 — $$ 두 개로 자리 표시자 구분, 자리 표시자도 {{ }}
int level = 12;
string json = $$"""
{
  "name": "Hero",
  "level": {{level}}
}
""";

원시 문자열은 세 개 이상의 큰따옴표로 시작/종료합니다. 안에 따옴표가 있으면 시작·종료 따옴표를 4개·5개… 이상으로 늘려 구분합니다. 들여쓰기는 종료 """의 위치를 기준으로 자동 정렬됩니다.

IL
// 원시도 ldstr 단일 명령 (컴파일러가 일반 string으로 변환)
IL_0000: ldstr "{\n  \"name\": \"Hero\",\n  \"level\": 12\n}"
경우 추천
짧은 단일 행 일반 "..."
백슬래시 위주 축자 @"..."
따옴표·여러 줄 위주 (JSON·XML·SQL·HTML) 원시 """..."""
위에 보간 추가 필요 $"..." / $@"..." / $$"""..."""

특히 JSON·SQL·HTML 하드코딩에는 원시 문자열이 압도적으로 깔끔합니다. Unity 에디터 스크립트에서 메뉴 정의나 설정 템플릿을 코드 안에 박을 때 자주 쓰입니다.

$$"""...""" — 자리 표시자는 {{ }} 일반 보간은 {name}이지만 원시 + 보간 모드($$)에서는 자리 표시자도 두 글자 {{name}}이 됩니다. 텍스트 안의 {}이 그대로 들어가야 하기 때문 — $ 개수가 자리 표시자를 묶는 중괄호 개수가 됩니다($$${{{ }}}).

5. 포맷 지정자 — 정렬과 형식

보간/string.Format 자리 표시자 안에 {value, alignment : formatString} 형태로 정렬·형식을 지정합니다.

C#
$"{1234567.89:N2}"        // "1,234,567.89"   천 단위 콤마 + 소수점 2자리
$"{99.5:C}"               // "$99.50"          통화 (현재 문화권)
$"{0.345:P1}"             // "34.5 %"          백분율
$"{255:X4}"               // "00FF"            16진수 4자리
$"{42:D5}"                // "00042"           10진수 5자리 (앞 0 채움)
$"|{"abc",-5}|{123,5}|"   // "|abc  |  123|"   좌측/우측 정렬
$"{DateTime.Today:yyyy-MM-dd}"   // "2026-05-01"  날짜 형식
$"{DateTime.Now:HH:mm:ss}"       // "14:30:15"

자주 쓰는 형식 문자열:

형식 의미 예시
N 천 단위 콤마 {1234:N0} → "1,234"
C 통화 {99.5:C} → "$99.50"
F 고정 소수점 {3.14159:F2} → "3.14"
P 백분율 (×100) {0.345:P1} → "34.5 %"
D 정수 (앞 0 채움) {42:D5} → "00042"
X 16진수 {255:X4} → "00FF"
yyyy-MM-dd 날짜 {date:yyyy-MM-dd}
HH:mm:ss 시간 {time:HH:mm:ss}
IL
// {1234567.89:N2} — 형식 문자열을 그대로 핸들러에 전달
IL_000b: ldc.r8 1234567.89
IL_0014: ldstr "N2"
IL_0019: call instance void DefaultInterpolatedStringHandler
              ::AppendFormatted<float64>(!!0, string)

AppendFormatted<T>(T, string) 오버로드가 형식 문자열까지 한 번에 받습니다. 박싱 없이 형식 지정까지 처리됩니다.

정렬: {val, width}

C#
$"|{"a",-5}|{"b",5}|"   // "|a    |    b|"
  • 양수 폭: 우측 정렬 (앞에 공백 채움)
  • 음수 폭: 좌측 정렬 (뒤에 공백 채움)
  • 형식 문자열과 함께: {val,10:N2} (폭 10, 천 단위 콤마+소수 2자리)

표 형태 출력에 자주 쓰입니다.


6. 문화권 함정 — Invariant vs 현재 문화권

C#
double n = 1234.5;

// ❌ 현재 문화권 — 사용자 환경에 따라 다른 결과
$"{n:N2}";                // 한국: "1,234.50" / 독일: "1.234,50"

// ✅ 문화권 비의존
n.ToString("N2", System.Globalization.CultureInfo.InvariantCulture);
// → 항상 "1,234.50"

// 또는 FormattableString.Invariant
System.FormattableString.Invariant($"{n:N2}");

데이터 직렬화·로그·파일 이름처럼 사람이 안 읽는 자리에는 항상 Invariant 를 써야 합니다. 사용자 화면에는 현재 문화권을 그대로 둡니다 — 한국 사용자에게 1.234,50을 보여주면 안 되니까요.

PART 6 #6 "string의 자주 쓰는 메서드"의 StringComparison.Ordinal과 같은 원칙입니다.


7. StringBuilder와 보간 결합 (C# 10+)

C#
// .NET 6+ / C# 10+ 이후 — 박싱 없는 핸들러 자동 사용
sb.Append($"v={x}, w={y}");

// 옛 패턴 (지금도 동작하지만 박싱 위험 있음)
sb.AppendFormat("v={0}, w={1}", x, y);

StringBuilder.Append도 보간 인자 직접 받는 오버로드가 있어 핸들러 최적화를 그대로 받습니다. AppendFormat보다 Append($"...") 가 이제는 더 가볍습니다.


8. 실전 적용 — Unity 핫패스 패턴

Before/After: UI 라벨 갱신

C#
// ❌ Before — 매 프레임 보간 호출
void Update()
{
    healthText.text = $"HP: {currentHp:N0} / {maxHp:N0}";
    // 매 프레임 string 1개 alloc
}

// ✅ After — 변경 감지 + StringBuilder 캐싱 + SetText(StringBuilder)
private readonly StringBuilder _sb = new(32);
private int _lastHp = -1;

void Update()
{
    if (currentHp == _lastHp) return;
    _lastHp = currentHp;

    _sb.Clear();
    _sb.Append("HP: ").Append(currentHp).Append(" / ").Append(maxHp);
    healthText.SetText(_sb);                 // alloc 0
}

디버그 로그 — 빈도 제한 + 보간

C#
// ❌ 매 프레임 디버그 로그
void Update()
{
    Debug.Log($"Frame {Time.frameCount}, Δt={Time.deltaTime:F4}");
}

// ✅ 1초마다 한 번
private float _logT;
void Update()
{
    _logT += Time.deltaTime;
    if (_logT < 1f) return;
    _logT = 0f;
    Debug.Log($"Frame {Time.frameCount}, Δt={Time.deltaTime:F4}");
}

매 프레임 디버그 로그는 보간이 보간이라도 매 프레임 string alloc이 있습니다. 빈도를 제한하는 패턴이 더 큰 차이를 만듭니다.

Unity 모바일 GC 특수성

Unity 모바일은 IL2CPP(Intermediate Language to C++ — IL을 C++로 변환해 네이티브로 컴파일하는 빌드 백엔드)와 Boehm GC(보수적·정지형 가비지 컬렉터, 한 번 돌면 모든 매니지드 스레드를 멈춘다)의 조합이라 string GC 한 번이 5~15ms 프레임 스파이크입니다. 보간이 빨라졌어도 매 프레임 alloc은 여전히 부담입니다 — UI는 변경 감지, 로그는 빈도 제한이 정답입니다.


9. 함정과 주의사항

함정 1 — 옛 string.Format을 새 코드에서 굳이 사용

C#
// ❌ 박싱 + 형식 문자열 런타임 파싱
string.Format("Score: {0}", score);

// ✅ 보간
$"Score: {score}";

다국어 리소스 파일에서 형식 문자열을 가져와야 하는 경우만 string.Format이 필요합니다. 일반 코드에서는 보간을 쓰세요.

함정 2 — 일반 문자열인데 굳이 보간

C#
// ❌ 변수가 없는데 $ 붙임 — 의도 불명확
string s = $"Hello, World!";

// ✅ 그냥 문자열
string s = "Hello, World!";

자리 표시자가 없는데 $를 붙이면 컴파일러는 핸들러 코드를 만들 필요 없이 그냥 단순 string으로 처리하지만, 코드를 읽는 사람에게 "여기에 변수가 들어 있나?"라는 의문을 만듭니다. 자리 표시자가 있을 때만 $.

함정 3 — 축자 문자열의 "" 이스케이프

C#
// ❌ 헷갈림
string s = @"He said "hi".";    // 컴파일 오류

// ✅ ""로 이스케이프
string s = @"He said ""hi"".";

// ✅ 원시 문자열이면 이스케이프 0
string s = """He said "hi"."""";

축자 문자열에서는 "를 두 개 연속("")으로 적어야 합니다 — 일반 백슬래시 이스케이프는 안 됩니다(애초에 백슬래시를 그대로 쓰는 게 축자의 목적).

함정 4 — 원시 문자열 들여쓰기

C#
string s = """
    Hello
""";
// 종료 """가 들여쓰기 4칸 → 시작 4칸 자동 제거
// 결과: "Hello"

종료 """의 들여쓰기가 기준이 됩니다. 들여쓰기를 그대로 살리고 싶으면 종료 """를 줄 시작에 붙입니다.

함정 5 — 형식 지정자에 잘못된 형식 문자

C#
$"{value:Z}";    // 의미 없음 — Z는 표준 형식 문자가 아니라 그대로 출력
                 // ToString("Z")로 호출되어 사용자 정의 형식이 없으면 그대로

표준 형식 문자(N·C·F·D·P·X·G·R·E)와 사용자 정의 형식(yyyy-MM-dd, 0.00, #,##0)만 의미가 있습니다. 모르는 글자는 무시되거나 그대로 출력됩니다.


10. C# 버전별 변화

버전/시기 변화 비고
1.0 string.Format 가변 인자, 박싱
1.0 축자 문자열 @"..." 컴파일 시점 처리
6.0 보간 $"..." 가독성, 내부는 string.Format
7.0 nameof(보간 안 X)·Tuple 보간 표기법 자체는 변화 없음
10.0 / .NET 6 DefaultInterpolatedStringHandler — 박싱 없는 보간 성능 큰 도약
11.0 원시 문자열 """...""" 이스케이프 없이
11.0 UTF-8 문자열 리터럴 "abc"u8 (PART 6 #11) 바이트 레벨

C# 10/.NET 6의 보간 핸들러 도입이 모든 게임의 룰을 바꾼 변화입니다. 이전에는 핫패스에서 string.Format이나 보간을 피했지만, 이제는 보간을 안전하게 쓸 수 있고 string.Format만 박싱 함정으로 남았습니다.

C# 11의 원시 문자열은 게임 코드보다는 도구 코드(JSON 템플릿·SQL·HTML)에서 큰 효과를 발휘합니다.


11. 정리

  • [ ] 새 코드는 거의 항상 보간 $"..." — 가독성·성능 모두 우위.
  • [ ] string.Format은 다국어 리소스 등 외부 형식 문자열을 받아야 할 때만.
  • [ ] C# 10/.NET 6+ 보간DefaultInterpolatedStringHandler로 박싱 없음 — string.Format보다 빠르다.
  • [ ] 축자 @"...": 백슬래시 그대로(파일 경로·정규식). 따옴표는 ""로 이스케이프.
  • [ ] 원시 """..."""(C# 11+): 이스케이프 0, 들여쓰기 자동 정리. JSON·SQL·HTML 하드코딩에 압도적.
  • [ ] $$"""...""": 원시 + 보간. 자리 표시자는 {{name}}(중괄호 두 개).
  • [ ] 포맷 지정자: {val,width:format}. 자주 쓰는 형식 — N(콤마), C(통화), F(소수), P(백분율), X(16진), yyyy-MM-dd(날짜).
  • [ ] 정렬: 양수 폭 = 우측, 음수 폭 = 좌측. 표 출력에 유용.
  • [ ] 문화권: 데이터·로그·파일에는 Invariant. 사용자 화면에만 현재 문화권.
  • [ ] StringBuilder.Append($"...")(C# 10+)는 박싱 없이 동작 — AppendFormat보다 가볍다.
  • [ ] Unity 핫패스: 보간이 빨라졌어도 alloc은 있다 → UI는 변경 감지, 로그는 빈도 제한, UI 갱신은 SB 캐싱 + SetText(StringBuilder).
  • [ ] 보간 자리 표시자 안에서 줄바꿈 가능(C# 11+) — PART 6 #10에서 다룬다.
반응형

+ Recent posts