| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 |
- 환급챌린지
- C#
- Dots
- Tween
- unity
- 암호화
- adfit
- Framework
- 프레임워크
- 직장인자기계발
- TextMeshPro
- 가이드
- 직장인공부
- 2D Camera
- base64
- 게임개발
- 최적화
- Unity Editor
- DotsTween
- 샘플
- job
- 오공완
- 패스트캠퍼스후기
- Custom Package
- AES
- Job 시스템
- sha
- ui
- 패스트캠퍼스
- RSA
- Today
- Total
EveryDay.DevUp
문자열 포맷팅 — String.Format·보간·Span 기반 포맷 본문
문자열 포맷팅 — String.Format·보간·Span 기반 포맷
문자열을 조합하는 방법은 C# 버전마다 달라졌다. String.Format에서 시작해 $"보간"을 거쳐 zero-alloc 포맷까지 — 겉보기엔 같은 결과를 내지만, 내부에서 일어나는 일은 완전히 다르다.
목차
문제 제기 — 왜 포맷팅 방식이 중요한가
Unity 모바일 게임에서 플레이어의 HP를 화면에 표시하는 코드를 작성했다.
void Update()
{
uiText.text = string.Format("HP: {0} / {1}", currentHp, maxHp);
}
프로파일러를 열면 매 프레임 string.Format 호출에서 힙 할당이 3회 발생하고 있다. currentHp와 maxHp는 int인데, string.Format은 object를 받기 때문에 매번 박싱(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)를 넣고, 뒤에 오는 인자들이 순서대로 치환된다.
public static string FormatWithBoxing(string name, int score)
{
return string.Format("Player: {0}, Score: {1}", name, score);
}
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이 호출되면 내부에서는 다음 단계가 진행된다:
- 인자 전달 — 3개 이상이면
params object[]배열이 힙에 할당된다. 2개 이하는 전용 오버로드(Format(string, object),Format(string, object, object))가 있어 배열 할당을 피한다. - StringBuilder 생성 — 내부적으로
StringBuilder를 만든다. - 포맷 문자열 파싱 —
{0:N2}같은 자리 표시자를 찾아 인덱스, 정렬, 서식 지정자를 추출한다. - 값 변환 — 인자가
IFormattable을 구현하면ToString(format, provider)를, 아니면ToString()을 호출한다. - 최종 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)에 불과했다.
// 개발자가 작성한 코드
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# 10+ 에서 동일한 $"" 보간
public static string InterpolationModern(string name, int score)
{
return $"Player: {name}, Score: {score}";
}
// 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.Int32 → object로 변환 |
AppendFormatted<int32> → int 그대로 |
| 배열 할당 | params object[] 필요 |
없음 |
| 중간 할당 | 박싱 객체 + 내부 StringBuilder | ref struct 내부 버퍼 (스택) |
| 최종 할당 | string 1개 | string 1개 |
Handler 내부 버퍼 전략
DefaultInterpolatedStringHandler는 내부적으로 다음과 같은 버퍼 전략을 사용한다:
- 초기 버퍼 — 생성자에서 리터럴 길이와 삽입 값 개수를 받아 필요한 크기를 추정한다. 짧은 문자열은
stackalloc으로 스택에 버퍼를 확보한다. - 버퍼 확장 — 예상보다 길어지면
ArrayPool<char>.Shared에서 char 배열을 대여(Rent)한다. - 최종 반환 —
ToStringAndClear()에서 결과string하나만 힙에 할당하고, 대여한 배열은 반환(Return)한다.
ArrayPool<T>(배열 풀) 배열을 미리 풀에 보관하고 필요할 때 대여/반환하는 메커니즘이다.new T[]를 반복하는 것보다 GC 부담이 크게 줄어든다.
실전 적용 — Unity에서의 문자열 포맷팅
❌ Before — + 연산자로 문자열 조합 (매 프레임)
Update에서 + 연산자로 여러 값을 합치면 무슨 일이 벌어지는지 보자.
// 매 프레임 호출되는 UI 갱신 코드
public static string BadFormatInLoop(string name, int hp, int maxHp)
{
return "HP: " + hp + " / " + maxHp + " | " + name;
}
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 캐싱
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_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개만 힙 할당
StringBuilder를 static readonly로 캐싱하면 new StringBuilder() 할당이 사라진다. Append(int32) 오버로드가 int를 직접 받기 때문에 boxing도 없다. 힙 할당은 ToString() 한 번뿐이다.
✅ C# 10+ — $"" 보간이 최선
C# 10 이상 환경이라면 $""가 가장 간결하면서 성능도 좋다.
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 스파이크(순간적인 프레임 드랍)로 이어진다.
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 — + 연산자와 값 타입 혼합
// 4개 이하 조각이면 Concat 전용 오버로드가 사용되지만...
static string ScoreDisplay(int score)
{
return "Score: " + score; // int.ToString()이 호출됨
}
+ 연산자로 string과 int를 연결하면 컴파일러가 int.ToString()을 먼저 호출하고 Concat에 넘긴다. 박싱은 피하지만 ToString()이 새 string을 힙에 할당한다. $"Score: {score}"(C# 10+)라면 AppendFormatted<int>가 내부 버퍼에 직접 쓰므로 ToString() 호출 자체가 불필요하다.
❌ 함정 2 — String.Format에 5개 이상 인자
// 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의 숨겨진 비용
// 빌드에서 로그가 비활성화되어도 문자열은 이미 생성된다!
void Update()
{
Debug.Log($"Frame: {Time.frameCount}, FPS: {1f / Time.deltaTime:F1}");
}
Debug.Log가 Release 빌드에서 출력되지 않더라도, 인자로 전달되는 $""는 메서드 호출 전에 이미 평가된다. 즉 문자열 생성과 힙 할당이 먼저 일어난다.
// ✅ 조건부 컴파일로 호출 자체를 제거
[System.Diagnostics.Conditional("ENABLE_LOG")]
static void Log(string message) => Debug.Log(message);
[Conditional] 어트리뷰트를 사용하면 심볼이 정의되지 않았을 때 호출 자체가 컴파일에서 제거되므로 문자열 생성도 일어나지 않는다.
C# 버전별 변화
C# 6 — 문자열 보간 도입
// 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# 6과 완전히 동일
string result = $"Name: {name}, Level: {level}";
같은 코드지만 C# 10 컴파일러는 DefaultInterpolatedStringHandler를 사용해 boxing과 배열 할당을 제거한다. 코드를 한 줄도 바꾸지 않아도 컴파일러 업그레이드만으로 성능이 개선되는 드문 사례다.
C# 10 — const 문자열 보간
// 보간에 사용되는 모든 값이 컴파일 타임 상수이면 const 가능
const string Prefix = "Game";
const string Suffix = "Over";
const string Message = $"{Prefix} {Suffix}"; // 컴파일 타임에 "Game Over"로 확정
.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
// 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,\")를 처리하지 않으며, 들여쓰기는 닫는"""의 위치 기준으로 자동 제거된다.
보간과 함께 사용할 때는 $의 개수로 중괄호 해석을 제어한다:
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을 구현하고 있다. DefaultInterpolatedStringHandler의 AppendFormatted<T>는 내부적으로 T가 ISpanFormattable을 구현했는지 확인하고, 구현했다면 TryFormat을 호출해 버퍼에 직접 쓴다. 이것이 boxing 없이 값 타입을 포맷팅할 수 있는 핵심 메커니즘이다.
사용자 정의 타입에도 적용할 수 있다:
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}";
}
// 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이다. 이 타입을 $"" 안에서 사용하면 DefaultInterpolatedStringHandler가 TryFormat을 호출해 최적의 경로로 포맷팅한다.
정리
방식별 비교 요약
| 방식 | 힙 할당 | 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에서는
StringBuilder를static readonly로 캐싱하고 타입별Append오버로드를 사용한다 - [ ]
String.Format은 값 타입 인자에 boxing을 일으킨다 — 새 코드에서는 피한다 - [ ] UI 텍스트 갱신은
TMP_Text.SetText— 힙 할당 0으로 매 프레임 갱신 가능 - [ ] 값이 바뀔 때만 UI를 갱신한다 — 이전 값과 비교하는 한 줄이 수백 번의 할당을 막는다
- [ ]
Debug.Log의 보간 문자열은 Release에서도 평가된다 —[Conditional]로 호출 자체를 제거하라 - [ ] const 보간(C# 10)은 컴파일 타임에 확정된다 — 상수 조합에는 런타임 비용이 0
- [ ] ISpanFormattable을 구현하면 사용자 정의 타입도 zero-alloc 포맷팅에 참여할 수 있다
