[PART2.변수와 기본 데이터 타입(10/13)] 상수 문자열 보간 — const 문자열에 $"..." 허용 (C# 10)
const string x = $"{A}{B}"; 은 언제 허용되고, 왜 런타임 비용이 0인가 — constant folding 과 DefaultInterpolatedStringHandler 의 경계
목차
1. 문제 제기 — "상수인데 왜 + 만 돼?"
Unity 프로젝트를 시작한 지 얼마 안 된 신입 개발자가 자주 하는 작업이 있습니다. Addressables 키, 로그 태그, 리소스 경로처럼 절대 런타임에 바뀌지 않는 문자열을 const 로 한곳에 모으는 작업입니다.
public static class AssetKeys
{
public const string Prefabs = "Prefabs";
public const string Characters = "Characters";
// 목적: "Prefabs/Characters/Player" 라는 단일 리터럴을 만들고 싶다
public const string Player = Prefabs + "/" + Characters + "/Player";
}
+ 로 쭉 이어 붙이는 방식은 동작은 하지만, 경로가 4~5단으로 깊어지면 코드가 금세 지저분해집니다. 그래서 자연스럽게 한 번쯤은 이런 코드를 시도해 봅니다.
// C# 9 이전 — 컴파일 에러
public const string Player = $"{Prefabs}/{Characters}/Player";
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// CS0133: 'Player'에 할당할 식은 상수여야 합니다.
분명 Prefabs 도 Characters 도 상수인데, 왜 보간 문자열은 상수가 될 수 없었을까요? C# 10 이전까지 이 제약이 있었던 이유는, 문자열 보간이 내부적으로 string.Format(...) 호출로 풀렸고 메서드 호출의 결과는 원칙적으로 상수가 될 수 없기 때문입니다. 결국 같은 내용이라도 + 는 되고 $"..." 는 안 되는, 살짝 억울한 상태였습니다.
이 글이 답할 질문은 세 가지입니다.
- C# 10 에서 정확히 어떤 조건에서
const string x = $"...";이 허용되는가 - 컴파일러는 이 코드를 IL 로 어떻게 바꾸는가 — 그리고 일반
$"..."와 무엇이 다른가 - Unity 핫패스에서 이걸 쓰면 왜 GC 스파이크가 한 번 덜 생기는가
const— 상수 (Constant) 컴파일 타임에 값이 확정되는 필드/지역 변수다. IL 상에서 필드로 남지 않고, 사용하는 쪽에 값 자체가 박혀 들어간다. 문자열이라면ldstr "..."리터럴로, 숫자라면ldc.i4.*리터럴로 치환된다.
$"..."— 문자열 보간 (String Interpolation) 문자열 안에 변수를 중괄호로 끼워 넣어 런타임에 결합하는 문법이다.$"Hello, {name}"은 "Hello, " 와name을 이어 붙인 새 문자열을 만든다. C# 6 에서 도입됐으며 내부 구현은 버전마다 조금씩 바뀌었다.
2. 개념 정의 — "모두 const 면 컴파일러가 미리 합친다"
2-1. 한 줄 요약과 비유
C# 10 상수 문자열 보간을 한 줄로 말하면 이렇습니다.
자리 표시자가 전부const면, 컴파일러가$"..."를 "이미 결합된 단일 리터럴"로 바꿔준다.
레고 블록에 비유하면 쉽습니다. 과거에는 완성품 레고를 사려면 블록들을 직접 + 로 붙여서 박스에 담아야 했습니다. C# 10 은 $"..." 이라는 새 포장지를 하나 더 허용한 것뿐이고, 레고 공장(컴파일러)은 여전히 조립을 미리 끝내서 완성품으로 배송합니다. 박스를 여는 사용자(런타임)는 조립 과정을 전혀 보지 않습니다.
2-2. 시각화 — 컴파일 타임 결합 vs 런타임 결합

왼쪽(const 보간)은 컴파일러가 모든 조각을 미리 알고 있어서 완성품 "UI.Title" 문자열을 IL 에 박아 버립니다. 오른쪽(non-const 보간)은 A, B 값이 런타임에야 결정되니 핸들러를 만들어서 차곡차곡 붙여야 합니다. 같은 $"..." 문법이지만 IL 레벨에서는 완전히 다른 세계입니다.
2-3. 기본 예시와 IL
Unity 에서 UI 로컬라이제이션 키를 계층적으로 쌓는 상황을 가정합니다.
using System;
namespace ILAnalysis
{
public static class ConstInterpolation
{
public const string Name = "World";
public const string Version = "v1";
public const string Greeting = $"Hello, {Name}, {Version}";
public const string UiRoot = "UI";
public const string MainMenu = $"{UiRoot}.MainMenu";
public const string MainTitleKey = $"{MainMenu}.Title";
}
public class Program
{
public static void UseConst()
{
string g = ConstInterpolation.Greeting; // const 보간 결과
string k = ConstInterpolation.MainTitleKey; // 중첩 const 보간 결과
Console.WriteLine(g);
Console.WriteLine(k);
}
}
}
ilspycmd 로 디컴파일한 IL 결과입니다.
.class public auto ansi abstract sealed beforefieldinit ILAnalysis.ConstInterpolation
{
// Fields — 모두 "literal" (컴파일 타임 상수 필드)
.field public static literal string Name = "World"
.field public static literal string Version = "v1"
.field public static literal string Greeting = "Hello, World, v1" // ← 이미 결합 완료
.field public static literal string UiRoot = "UI"
.field public static literal string MainMenu = "UI.MainMenu" // ← 이미 결합 완료
.field public static literal string MainTitleKey = "UI.MainMenu.Title" // ← 2 단 중첩도 결합 완료
}
.method public hidebysig static void UseConst () cil managed
{
.maxstack 1
.locals init (
[0] string,
[1] string
)
IL_0000: nop
IL_0001: ldstr "Hello, World, v1" // Greeting → 단일 리터럴 로드
IL_0006: stloc.0
IL_0007: ldstr "UI.MainMenu.Title" // MainTitleKey → 단일 리터럴 로드 (중첩 흔적 없음)
IL_000c: stloc.1
IL_000d: ldloc.0
IL_000e: call void [System.Console]System.Console::WriteLine(string)
IL_0013: nop
IL_0014: ldloc.1
IL_0015: call void [System.Console]System.Console::WriteLine(string)
IL_001a: nop
IL_001b: ret
}
관찰해야 할 세 가지 사실
Greeting필드 선언이 IL 에서 이미"Hello, World, v1"로 완전히 결합돼 있습니다.$"Hello, {Name}, {Version}"은 소스에만 있고, 컴파일 결과물에는 흔적조차 없습니다.UseConst메서드는Greeting을 사용할 때ldstr "Hello, World, v1"단 한 줄만 실행합니다. 힙 할당이나 메서드 호출이 일절 없습니다.MainTitleKey처럼const를 중첩 참조해도 똑같이 하나의 리터럴로 평탄화됩니다.UiRoot → MainMenu → MainTitleKey의 2 단 참조가 모두 컴파일 타임에 풀립니다.
3. 내부 동작 — Constant Folding, 그리고 그 경계
3-1. Constant Folding 이란
컴파일러는 코드 안에서 값이 컴파일 타임에 완전히 결정되는 식을 발견하면, 실행 없이 결과를 미리 계산해서 그 자리에 꽂아 둡니다. 이걸 상수 접기 (Constant Folding) 라고 합니다. $"Hello, {Name}" 은 문법적으로는 보간이지만, 모든 자리 표시자가 const 이므로 의미상 "이미 결합된 단일 문자열" 과 완전히 같습니다. 컴파일러는 이 동일성을 이용해 ldstr "Hello, World" 로 접어버립니다.
3-2. + 결합과 $"..." 보간의 IL 비교
"그러면 그냥 + 쓰던 것과 완전히 같은가?" 라는 의문이 드는 게 정상입니다. 직접 비교해 봅니다.
namespace ILAnalysis
{
// 예전 스타일 — C# 9 이전부터 동작
public static class OldStyle
{
public const string UiRoot = "UI";
public const string MainMenu = UiRoot + ".MainMenu";
public const string MainTitleKey = MainMenu + ".Title";
}
// 새 스타일 — C# 10
public static class NewStyle
{
public const string UiRoot = "UI";
public const string MainMenu = $"{UiRoot}.MainMenu";
public const string MainTitleKey = $"{MainMenu}.Title";
}
public class Program
{
public static void ShowOld() => System.Console.WriteLine(OldStyle.MainTitleKey);
public static void ShowNew() => System.Console.WriteLine(NewStyle.MainTitleKey);
}
}
// OldStyle 의 필드
.field public static literal string UiRoot = "UI"
.field public static literal string MainMenu = "UI.MainMenu"
.field public static literal string MainTitleKey = "UI.MainMenu.Title"
// NewStyle 의 필드
.field public static literal string UiRoot = "UI"
.field public static literal string MainMenu = "UI.MainMenu"
.field public static literal string MainTitleKey = "UI.MainMenu.Title"
.method public hidebysig static void ShowOld () cil managed
{
IL_0000: nop
IL_0001: ldstr "UI.MainMenu.Title" // + 결합 결과
IL_0006: call void [System.Console]System.Console::WriteLine(string)
IL_000b: nop
IL_000c: ret
}
.method public hidebysig static void ShowNew () cil managed
{
IL_0000: nop
IL_0001: ldstr "UI.MainMenu.Title" // $"..." 결합 결과 — 완전히 동일
IL_0006: call void [System.Console]System.Console::WriteLine(string)
IL_000b: nop
IL_000c: ret
}
결론: 생성 IL 바이트가 완전히 같습니다. 필드 선언도 같고, 사용하는 쪽의 명령어도 같습니다. C# 10 의 상수 문자열 보간은 순수한 소스 레벨 편의 기능이며, 런타임 동작이나 성능에 추가 이득을 주지도, 추가 비용을 부과하지도 않습니다. 얻는 것은 오직 가독성 입니다.
3-3. 일반 $"..." (non-const) — DefaultInterpolatedStringHandler 의 세계
비교를 위해 자리 표시자에 런타임 값이 하나라도 끼면 IL 이 어떻게 바뀌는지 살펴봅니다.
namespace ILAnalysis
{
public static class RuntimeInterpolation
{
public static string BuildGreeting(string name, int score)
{
// name, score 는 런타임 값 → const 불가
return $"Hello, {name}, score={score}";
}
}
}
.method public hidebysig static string BuildGreeting (string name, int32 score) cil managed
{
.maxstack 3
.locals init (
[0] valuetype [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler,
[1] string
)
IL_0000: nop
IL_0001: ldloca.s 0
IL_0003: ldc.i4.s 15 // literalLength = "Hello, " + ", score=" = 15 글자
IL_0005: ldc.i4.2 // formattedCount = 2 (name, score)
IL_0006: call instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::.ctor(int32, int32)
IL_000b: ldloca.s 0
IL_000d: ldstr "Hello, "
IL_0012: call instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::AppendLiteral(string)
IL_0017: nop
IL_0018: ldloca.s 0
IL_001a: ldarg.0
IL_001b: call instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::AppendFormatted(string)
IL_0020: nop
IL_0021: ldloca.s 0
IL_0023: ldstr ", score="
IL_0028: call instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::AppendLiteral(string)
IL_002d: nop
IL_002e: ldloca.s 0
IL_0030: ldarg.1
IL_0031: call instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0)
IL_0036: nop
IL_0037: ldloca.s 0
IL_0039: call instance string [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::ToStringAndClear()
IL_003e: stloc.1
IL_003f: br.s IL_0041
IL_0041: ldloc.1
IL_0042: ret
}
읽어야 할 포인트
DefaultInterpolatedStringHandler구조체가 로컬 변수에 생성됩니다..NET 6+에서 문자열 보간을 빠르게 조립하기 위해 도입된 타입으로, 내부에 버퍼를 들고 있습니다.- 생성자에
(literalLength=15, formattedCount=2)를 넘겨 초기 버퍼 크기를 힌트로 제공합니다."Hello, "(7) + ", score="(8) = 15가 리터럴 길이, 자리 표시자 2개가 포맷 개수입니다. AppendLiteral은"Hello, ",", score="같은 정적 조각을 붙이고,AppendFormatted는name,score같은 런타임 값을 붙입니다.- 마지막에
ToStringAndClear()가 완성된 문자열을 힙에 새로 할당해서 반환합니다. 이 지점이 GC 가 기억하는 할당입니다. - 참고로 자리 표시자가 단 하나인 보간(
$"Hello, {name}")은 컴파일러가 한 번 더 최적화해서String.Concat(string, string)호출로 바꿉니다. 할당은 여전히 한 번 발생합니다.
const 보간(단일 ldstr) vs non-const 보간(newobj + Append × N + ToStringAndClear) — 이것이 같은 $"..." 문법이 만들어내는 두 극단입니다.
4. 실전 적용 — Unity 에서 어디에 쓰는가
4-1. 판단 기준
| 상황 | 사용 | 이유 |
|---|---|---|
| Addressables / Resources 키 | ✅ | 경로 계층을 const 로 쪼개서 관리, 오타 방지 |
| 로그 태그 / 접두사 | ✅ | [Combat], [UI] 같은 고정 태그의 조합 |
| Localization 키 | ✅ | UI.Settings.Volume 식 계층 키 |
| PlayerPrefs 키 | ✅ | 버전 접두사 + 키 이름 결합 |
| 에러 코드 템플릿 상수부 | ✅ | "[ERR-{module}]" 같은 고정 템플릿만 const, 치환은 별도 포맷 |
| 동적 로그 메시지 | ❌ | 런타임 값 포함 → 일반 $"..." 또는 string.Create |
| 플레이어 이름 포함 메시지 | ❌ | 이름이 런타임에 결정 → const 불가 |
핵심 감각: "이 문자열의 모든 구성 조각이 빌드 시점에 고정되어 있나?" 가 기준입니다. 하나라도 런타임 값이 섞이면 const 보간은 선택지에서 빠집니다.
4-2. Before — + 로 경로를 엮는 관행
Unity 프로젝트에서 자주 보는 패턴입니다.
using UnityEngine;
namespace Game.Assets
{
public static class ResourcePaths
{
public const string Root = "Prefabs";
public const string Ui = "UI";
public const string Characters = "Characters";
public const string Vfx = "VFX";
// + 연결 — 길어질수록 가독성이 떨어진다
public const string MainMenu = Root + "/" + Ui + "/MainMenu";
public const string Settings = Root + "/" + Ui + "/Settings";
public const string Player = Root + "/" + Characters + "/Player";
public const string ExplosionVfx = Root + "/" + Vfx + "/Explosion";
}
public static class Loader
{
public static GameObject LoadMainMenu()
{
return Resources.Load<GameObject>(ResourcePaths.MainMenu);
}
}
}
동작에는 문제가 없습니다. 다만 구분자 "/" 가 따로 떨어져 나와 있어서, 경로 구조를 한눈에 파악하기 어렵고, 구분자를 . 로 바꾸는 리팩터링이 번거롭습니다.
4-3. After — const 보간으로 계층을 그대로 표현
using UnityEngine;
namespace Game.Assets
{
public static class ResourcePaths
{
public const string Root = "Prefabs";
public const string Ui = "UI";
public const string Characters = "Characters";
public const string Vfx = "VFX";
// $"..." — 경로 구조가 문자열 그대로 드러난다
public const string UiRoot = $"{Root}/{Ui}";
public const string CharRoot = $"{Root}/{Characters}";
public const string VfxRoot = $"{Root}/{Vfx}";
public const string MainMenu = $"{UiRoot}/MainMenu";
public const string Settings = $"{UiRoot}/Settings";
public const string Player = $"{CharRoot}/Player";
public const string ExplosionVfx = $"{VfxRoot}/Explosion";
}
public static class Loader
{
public static GameObject LoadMainMenu()
{
return Resources.Load<GameObject>(ResourcePaths.MainMenu);
}
}
}
얻는 것
UiRoot,CharRoot,VfxRoot처럼 중간 계층 상수를 만들어 리팩터링 포인트를 집중시킬 수 있습니다. 구분자를/→.로 바꾸려면 세 줄만 수정하면 됩니다.- 각 키의 문자열 모양이 실제로 생성될 값과 시각적으로 거의 같아서 디버깅할 때 헷갈림이 줄어듭니다.
- 그리고 IL 은 이전과 바이트 수준까지 동일합니다. 성능 측면에서 아무것도 잃지 않습니다. (3-2 절에서 증명했습니다.)
4-4. 로그 태그 상수 — 프레임 스파이크 회피
Update 루프에서 매 프레임 로그를 찍는 상황을 가정합니다.
using UnityEngine;
namespace Game.Diagnostics
{
public static class LogTag
{
public const string System = "GameSystem";
public const string Combat = "Combat";
public const string Ai = "AI";
// 태그 접두사 자체는 고정 → const 보간으로 한 번에 결합
public const string CombatPrefix = $"[{System}.{Combat}]";
public const string AiPrefix = $"[{System}.{Ai}]";
}
public class EnemyAi : MonoBehaviour
{
private int frameCount;
void Update()
{
frameCount++;
// After — 접두사는 const 리터럴, 변수 부분만 런타임 보간
// 런타임 핸들러는 frameCount 결합에만 사용됨
Debug.Log($"{LogTag.AiPrefix} tick={frameCount}");
}
}
}
정확히 말하면 이 코드에서도 최종 Debug.Log 인자는 DefaultInterpolatedStringHandler 를 한 번 거칩니다. frameCount 가 런타임 값이기 때문입니다. 하지만 LogTag.AiPrefix 는 이미 "[GameSystem.AI]" 단일 리터럴로 박혀 있어서, 핸들러 버퍼에 그대로 복사되는 한 조각으로 취급됩니다. 접두사를 $"[{system}.{sub}]" 로 매 프레임 재조립하는 것보다 버퍼 초기 크기 계산도 정확하고, 문자열 풀 히트율도 높습니다.
ℹ️ 핵심은 "Debug.Log 호출 자체를 없애라"가 아니라, "상수로 굳힐 수 있는 부분은 const 리터럴로 굳혀라"입니다. Update 루프에서 Debug.Log 를 남용하는 것은 여전히 나쁜 관행이지만, 로그를 남긴다면 고정 부분은 const 보간으로 응축하는 것이 바람직합니다.
4-5. Localization 키 — 계층적 네이밍
namespace Game.Localization
{
public static class LocKey
{
public const string Ui = "UI";
public const string MainMenu = $"{Ui}.MainMenu";
public const string Settings = $"{Ui}.Settings";
public const string MainTitle = $"{MainMenu}.Title";
public const string MainStart = $"{MainMenu}.Start";
public const string SettingsVolume = $"{Settings}.Volume";
public const string SettingsLanguage = $"{Settings}.Language";
}
}
어느 화면의 어느 요소인지 키 이름만 봐도 파악되고, 오타는 중간 상수(MainMenu, Settings) 하나만 고치면 모든 하위 키가 함께 수정됩니다.
5. 함정과 주의사항
5-1. static readonly 는 const 가 아니다 — CS0133
가장 자주 걸리는 함정입니다.
public static class InvalidConst
{
// static readonly — 런타임에 .cctor 에서 초기화됨
public static readonly string RuntimeName = "World";
// ❌ 컴파일 에러 CS0133
public const string Greeting = $"Hello, {RuntimeName}";
// ^^^^^^^^^^^^^^^^^^^^^^
}
실제 컴파일 결과:
error CS0133: 'InvalidConst.Greeting'에 할당할 식은 상수여야 합니다.
static readonly 는 "런타임 상수" 라고 불리긴 하지만, 값이 결정되는 시점이 정적 생성자(.cctor)가 실행되는 런타임이라 컴파일러가 컴파일 타임에 값을 알 수 없습니다. 당연히 보간 결과도 컴파일 타임 상수가 될 수 없습니다.
해결책: 자리 표시자로 쓸 값 자체를 const 로 만듭니다. DateTime.Now 처럼 컴파일 타임에 알 수 없는 값이라면 const 보간은 애초에 선택지가 아닙니다.
public static class ValidConst
{
public const string RuntimeName = "World"; // const 로 변경
public const string Greeting = $"Hello, {RuntimeName}"; // ✅
}
5-2. 메서드 호출 · nameof · 간단한 연산
자리 표시자 안에서 허용되는 것은 컴파일 타임 상수식입니다. nameof(X) 는 컴파일 타임 문자열이므로 허용됩니다. 메서드 호출은 결과가 런타임에 결정되므로 불가합니다.
public class Player { }
public static class MixedCases
{
public const string Name = "Player";
// ✅ nameof 는 const 로 취급 — "Player.Health" 로 결합됨
public const string HealthField = $"{nameof(Player)}.Health";
// ❌ 메서드 호출 — 런타임 값
public const string Greet = $"Hello, {"Bob".ToUpper()}";
// ^^^^^^^^^^^^^^^^ CS0133
// ❌ 변수 참조 — static readonly 든 일반 필드든 모두 불가
public static int counter = 0;
public const string Msg = $"count={counter}";
// ^^^^^^^ CS0133
}
nameof 가 허용된다는 점은 유용합니다. 리플렉션 없이 필드 경로 문자열을 const 로 만들 수 있어서 PlayerPrefs 키나 ScriptableObject 속성 경로 상수로 유용하게 쓰입니다.
5-3. 중괄호 자체를 넣으려면 {{ · }}
상수 보간 결과에 실제 중괄호를 넣고 싶다면 이스케이프해야 합니다.
public static class Templates
{
public const string Level = "3";
// ❌ 의도: "level={3}" — 실제로는 컴파일 에러 또는 의도 불일치
// public const string Raw = $"level={{Level}}"; // level={Level} 이 됨 (리터럴 { })
// ✅ 의도: "level={3}" — 중괄호 자체 + 값 주입
public const string Formatted = $"level={{{Level}}}";
// ^^ ^^
// {{ → { }} → }
}
{{ 는 { 한 글자, }} 는 } 한 글자로 접혀 들어갑니다. 보간 자리 표시자 {...} 와 리터럴 중괄호가 섞이면 읽기 어려워지니, 이 경우는 차라리 + 결합이 나을 수 있습니다.
5-4. 포맷 지정자는 쓸 수 없다
숫자의 자리수, 날짜 포맷 같은 포맷 지정자(:N2, :yyyy-MM-dd)는 일반 보간에서만 의미가 있습니다. const 보간은 이미 컴파일 타임에 값이 확정돼 있어서, 포맷 변환 자체가 개념적으로 필요하지 않고 컴파일러가 상수식으로 인정하지도 않습니다.
public const int Count = 42;
// ❌ :N2 포맷 지정자 — const 로 접을 수 없음 → CS0133
// public const string Line = $"count={Count:N2}";
// ✅ 포맷 없이 기본 ToString 결과를 원한다 → int 가 아니라 문자열 const 를 사용
public const string CountText = "42";
public const string Line = $"count={CountText}";
요령: const 보간에서 쓸 자리 표시자는 애초에 string const 로 통일해 두는 편이 안전합니다.
6. C# 버전별 변화
6-1. C# 6 — $"..." 도입, const 는 불가
// C# 6 ~ 9
public const string Prefix = "UI";
// ❌ 컴파일 에러 — 내부적으로 string.Format 호출로 풀림
// public const string Key = $"{Prefix}.Title";
// ✅ + 연결은 가능
public const string Key = Prefix + ".Title";
C# 6 에서 $"..." 는 대체로 string.Format(format, args) 호출로 컴파일됐습니다. 메서드 호출의 결과는 상수가 될 수 없으니 const 불가 제약이 따라왔습니다.
6-2. C# 10 — 모든 자리 표시자가 const 면 허용
// C# 10+
public const string Prefix = "UI";
public const string Key = $"{Prefix}.Title"; // ✅
컴파일러가 "모든 자리 표시자가 컴파일 타임 상수인 경우"에 한해 constant folding 을 적용합니다. 결과물 IL 은 + 결합과 완전히 동일합니다.
6-3. 참고: .NET 6 / C# 10 의 보간 핸들러
런타임 $"..." 의 내부 구현도 같은 시기에 크게 바뀌었습니다. C# 9 이전에는 대부분 string.Format 호출로 풀렸고, C# 10 / .NET 6 부터는 DefaultInterpolatedStringHandler 를 이용한 조립형 생성으로 전환되어 불필요한 박싱과 중간 배열 할당을 줄였습니다. const 보간 허용과 함께 묶어서 외우면, "C# 10 은 문자열 보간을 두 축에서 최적화했다" 로 정리할 수 있습니다.
| 버전 | const $"..." |
non-const $"..." 내부 구현 |
|---|---|---|
| C# 6 ~ 9 | ❌ 불가 — + 만 |
주로 string.Format 호출 |
| C# 10 (.NET 6+) | ✅ 허용 — 모든 자리 표시자가 const 일 때 | DefaultInterpolatedStringHandler 조립 |
7. 정리
한 줄 요약: C# 10 의 const 문자열 보간은 "자리 표시자가 전부 const 면 $"..." 도 const 로 쓸 수 있게 해주는 소스 편의 기능"이다. IL 은 + 결합과 완전히 동일하며, 런타임 비용은 0 이다.
체크리스트 — 코드 작성 시 기억할 것
- [ ] 자리 표시자가 전부
const인가? 하나라도static readonly/ 메서드 호출 / 변수면 쓸 수 없다 (CS0133) - [ ]
nameof(X)는 const 로 취급되어 자리 표시자로 쓸 수 있다 - [ ] 포맷 지정자(
:N2,:yyyy-MM-dd)는 const 보간에서 금지 — 문자열 const 로 맞춰 둔다 - [ ] Addressables/Resources 키, 로그 태그, Localization 키, PlayerPrefs 키 상수는
$"..."로 계층을 표현하면 가독성이 크게 개선된다 - [ ] 성능 측면에서
+연결 대비 이득도 손해도 없다 — 가독성만 개선된다 - [ ] 런타임 값이 포함된 보간(
$"[Tag] frame={n}")은 여전히DefaultInterpolatedStringHandler를 거치므로, 고정 접두사를 const 리터럴로 미리 굳히는 것이 GC 측면에서 유리하다
기억 포인트 한 장 그림
$"..." 는 결국 두 갈래:
├── 자리 표시자 전부 const → ldstr 한 줄 (소스 편의만 있음)
└── 하나라도 런타임 값 → Handler 생성 + Append × N + ToStringAndClear
이 갈래를 구분하는 감각이 생기면, Unity 핫패스에서 어디에 고정 부분을 응축할지 자동으로 눈에 들어옵니다.
'C# 기초' 카테고리의 다른 글
| [PART2.변수와 기본 데이터 타입(12/13)] default 리터럴과 값 타입 nullable (Nullable<T>) (0) | 2026.04.24 |
|---|---|
| [PART2.변수와 기본 데이터 타입(11/13)] 기본 형식 변환 — 암시적·명시적·Parse·TryParse·Convert (0) | 2026.04.24 |
| [PART2.변수와 기본 데이터 타입(9/13)] 상수 — const와 readonly (2) | 2026.04.24 |
| [PART2.변수와 기본 데이터 타입(8/13)] var — 타입 추론의 기본 사용법 (1) | 2026.04.24 |
| [PART2.변수와 기본 데이터 타입(7/13)] string — 문자의 묶음 (0) | 2026.04.24 |