[PART6.배열과 문자열 기본(9/14)] 문자열 포맷팅 총정리 — 보간 · string.Format · 축자 · 원시 문자열
같은 결과를 만드는 네 가지 표기 / IL이 갈리는 지점은 string.Format(박싱)과 보간(DefaultInterpolatedStringHandler) / 축자 @"..."·원시 """..."""은 컴파일 시점 처리로 런타임 비용 0 / 포맷 지정자 {val:N2}·정렬 {val,10} / 새 코드는 거의 항상 보간 우선
목차
1. 네 가지 표기, 무엇이 다른가
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 비교
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);
// 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. 축자 문자열 @"..." — 백슬래시를 그대로
// ❌ 일반 — \\ 두 번씩 적어야 함
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"".";
// 축자도 결국은 일반 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
// ❌ 일반 — 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개… 이상으로 늘려 구분합니다. 들여쓰기는 종료 """의 위치를 기준으로 자동 정렬됩니다.
// 원시도 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} 형태로 정렬·형식을 지정합니다.
$"{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} |
// {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}
$"|{"a",-5}|{"b",5}|" // "|a | b|"
- 양수 폭: 우측 정렬 (앞에 공백 채움)
- 음수 폭: 좌측 정렬 (뒤에 공백 채움)
- 형식 문자열과 함께:
{val,10:N2}(폭 10, 천 단위 콤마+소수 2자리)
표 형태 출력에 자주 쓰입니다.
6. 문화권 함정 — Invariant vs 현재 문화권
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+)
// .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 라벨 갱신
// ❌ 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
}
디버그 로그 — 빈도 제한 + 보간
// ❌ 매 프레임 디버그 로그
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을 새 코드에서 굳이 사용
// ❌ 박싱 + 형식 문자열 런타임 파싱
string.Format("Score: {0}", score);
// ✅ 보간
$"Score: {score}";
다국어 리소스 파일에서 형식 문자열을 가져와야 하는 경우만 string.Format이 필요합니다. 일반 코드에서는 보간을 쓰세요.
함정 2 — 일반 문자열인데 굳이 보간
// ❌ 변수가 없는데 $ 붙임 — 의도 불명확
string s = $"Hello, World!";
// ✅ 그냥 문자열
string s = "Hello, World!";
자리 표시자가 없는데 $를 붙이면 컴파일러는 핸들러 코드를 만들 필요 없이 그냥 단순 string으로 처리하지만, 코드를 읽는 사람에게 "여기에 변수가 들어 있나?"라는 의문을 만듭니다. 자리 표시자가 있을 때만 $.
함정 3 — 축자 문자열의 "" 이스케이프
// ❌ 헷갈림
string s = @"He said "hi"."; // 컴파일 오류
// ✅ ""로 이스케이프
string s = @"He said ""hi"".";
// ✅ 원시 문자열이면 이스케이프 0
string s = """He said "hi"."""";
축자 문자열에서는 "를 두 개 연속("")으로 적어야 합니다 — 일반 백슬래시 이스케이프는 안 됩니다(애초에 백슬래시를 그대로 쓰는 게 축자의 목적).
함정 4 — 원시 문자열 들여쓰기
string s = """
Hello
""";
// 종료 """가 들여쓰기 4칸 → 시작 4칸 자동 제거
// 결과: "Hello"
종료 """의 들여쓰기가 기준이 됩니다. 들여쓰기를 그대로 살리고 싶으면 종료 """를 줄 시작에 붙입니다.
함정 5 — 형식 지정자에 잘못된 형식 문자
$"{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에서 다룬다.