반응형

[PART2.변수와 기본 데이터 타입(10/13)] 상수 문자열 보간 — const 문자열에 $"..." 허용 (C# 10)

const string x = $"{A}{B}"; 은 언제 허용되고, 왜 런타임 비용이 0인가 — constant folding 과 DefaultInterpolatedStringHandler 의 경계


1. 문제 제기 — "상수인데 왜 + 만 돼?"

Unity 프로젝트를 시작한 지 얼마 안 된 신입 개발자가 자주 하는 작업이 있습니다. Addressables 키, 로그 태그, 리소스 경로처럼 절대 런타임에 바뀌지 않는 문자열const 로 한곳에 모으는 작업입니다.

C#
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#
// C# 9 이전 — 컴파일 에러
public const string Player = $"{Prefabs}/{Characters}/Player";
//                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// CS0133: 'Player'에 할당할 식은 상수여야 합니다.

분명 PrefabsCharacters 도 상수인데, 왜 보간 문자열은 상수가 될 수 없었을까요? C# 10 이전까지 이 제약이 있었던 이유는, 문자열 보간이 내부적으로 string.Format(...) 호출로 풀렸고 메서드 호출의 결과는 원칙적으로 상수가 될 수 없기 때문입니다. 결국 같은 내용이라도 + 는 되고 $"..." 는 안 되는, 살짝 억울한 상태였습니다.

이 글이 답할 질문은 세 가지입니다.

  1. C# 10 에서 정확히 어떤 조건에서 const string x = $"..."; 이 허용되는가
  2. 컴파일러는 이 코드를 IL 로 어떻게 바꾸는가 — 그리고 일반 $"..." 와 무엇이 다른가
  3. 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 로컬라이제이션 키를 계층적으로 쌓는 상황을 가정합니다.

C#
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 결과입니다.

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
}

관찰해야 할 세 가지 사실

  1. Greeting 필드 선언이 IL 에서 이미 "Hello, World, v1" 로 완전히 결합돼 있습니다. $"Hello, {Name}, {Version}" 은 소스에만 있고, 컴파일 결과물에는 흔적조차 없습니다.
  2. UseConst 메서드는 Greeting 을 사용할 때 ldstr "Hello, World, v1" 단 한 줄만 실행합니다. 힙 할당이나 메서드 호출이 일절 없습니다.
  3. MainTitleKey 처럼 const 를 중첩 참조해도 똑같이 하나의 리터럴로 평탄화됩니다. UiRoot → MainMenu → MainTitleKey 의 2 단 참조가 모두 컴파일 타임에 풀립니다.

3. 내부 동작 — Constant Folding, 그리고 그 경계

3-1. Constant Folding 이란

컴파일러는 코드 안에서 값이 컴파일 타임에 완전히 결정되는 식을 발견하면, 실행 없이 결과를 미리 계산해서 그 자리에 꽂아 둡니다. 이걸 상수 접기 (Constant Folding) 라고 합니다. $"Hello, {Name}" 은 문법적으로는 보간이지만, 모든 자리 표시자가 const 이므로 의미상 "이미 결합된 단일 문자열" 과 완전히 같습니다. 컴파일러는 이 동일성을 이용해 ldstr "Hello, World" 로 접어버립니다.

3-2. + 결합과 $"..." 보간의 IL 비교

"그러면 그냥 + 쓰던 것과 완전히 같은가?" 라는 의문이 드는 게 정상입니다. 직접 비교해 봅니다.

C#
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);
    }
}
IL
// 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 이 어떻게 바뀌는지 살펴봅니다.

C#
namespace ILAnalysis
{
    public static class RuntimeInterpolation
    {
        public static string BuildGreeting(string name, int score)
        {
            // name, score 는 런타임 값 → const 불가
            return $"Hello, {name}, score={score}";
        }
    }
}
IL
.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
}

읽어야 할 포인트

  1. DefaultInterpolatedStringHandler 구조체가 로컬 변수에 생성됩니다. .NET 6+ 에서 문자열 보간을 빠르게 조립하기 위해 도입된 타입으로, 내부에 버퍼를 들고 있습니다.
  2. 생성자에 (literalLength=15, formattedCount=2) 를 넘겨 초기 버퍼 크기를 힌트로 제공합니다. "Hello, "(7) + ", score="(8) = 15 가 리터럴 길이, 자리 표시자 2개가 포맷 개수입니다.
  3. AppendLiteral"Hello, ", ", score=" 같은 정적 조각을 붙이고, AppendFormattedname, score 같은 런타임 값을 붙입니다.
  4. 마지막에 ToStringAndClear() 가 완성된 문자열을 힙에 새로 할당해서 반환합니다. 이 지점이 GC 가 기억하는 할당입니다.
  5. 참고로 자리 표시자가 단 하나인 보간($"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 프로젝트에서 자주 보는 패턴입니다.

C#
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 보간으로 계층을 그대로 표현

C#
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 루프에서 매 프레임 로그를 찍는 상황을 가정합니다.

C#
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 키 — 계층적 네이밍

C#
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 readonlyconst 가 아니다 — CS0133

가장 자주 걸리는 함정입니다.

C#
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 보간은 애초에 선택지가 아닙니다.

C#
public static class ValidConst
{
    public const string RuntimeName = "World";  // const 로 변경
    public const string Greeting = $"Hello, {RuntimeName}";  // ✅
}

5-2. 메서드 호출 · nameof · 간단한 연산

자리 표시자 안에서 허용되는 것은 컴파일 타임 상수식입니다. nameof(X) 는 컴파일 타임 문자열이므로 허용됩니다. 메서드 호출은 결과가 런타임에 결정되므로 불가합니다.

C#
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. 중괄호 자체를 넣으려면 {{ · }}

상수 보간 결과에 실제 중괄호를 넣고 싶다면 이스케이프해야 합니다.

C#
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 보간은 이미 컴파일 타임에 값이 확정돼 있어서, 포맷 변환 자체가 개념적으로 필요하지 않고 컴파일러가 상수식으로 인정하지도 않습니다.

C#
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#
// 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#
// 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 핫패스에서 어디에 고정 부분을 응축할지 자동으로 눈에 들어옵니다.

반응형

+ Recent posts