반응형

[PART12.제네릭·델리게이트·람다·LINQ(18/18)] 확장된 nameof 범위와 언바운드 제네릭 지원 — 마법의 문자열을 마지막까지 몰아내기

nameof는 식별자 이름을 컴파일 타임에 문자열로 치환한다 / C# 11은 특성 인수 안에서 매개변수 이름을 참조할 수 있게 했다 / C# 14는 nameof(List<>) 같은 언바운드 제네릭까지 받는다


문제 제기 — 코드 안에 박힌 "문자열 이름"이 사라지지 않는 이유

Unity 프로젝트에서 다음 같은 코드를 자주 마주칩니다.

C#
public void Initialize(PlayerController controller)
{
    if (controller == null)
        throw new ArgumentNullException("controller"); // ← 매개변수 이름을 문자열로 박았다
    _controller = controller;
}

protected void OnPropertyChanged()
{
    PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Health")); // ← 속성 이름을 문자열로
}

문제는 리네임(rename) 리팩터링 입니다. controllerplayerController 로 바꾸면 IDE는 매개변수 선언과 사용처는 바꿔주지만 "controller" 라는 문자열 리터럴은 손대지 않습니다. 컴파일은 멀쩡히 통과하지만 예외가 터졌을 때 ParamName"controller" 로 나와 디버깅하는 사람을 헷갈리게 합니다. 이런 식으로 코드의 진실(이름)문자열 리터럴(이름의 사본) 이 어긋나는 순간을 통틀어 "마법의 문자열(magic string)" 문제라고 부릅니다.

C# 6.0 의 nameof 연산자는 이 문제를 정면으로 풀었습니다. nameof(controller) 는 컴파일 타임에 "controller" 로 치환되므로, 매개변수 이름이 바뀌면 같이 바뀌거나(리네임 리팩터링이 따라옴) 컴파일 에러로 즉시 알려줍니다. 그런데도 두 가지 빈틈이 남아 있었습니다.

  1. 특성(Attribute) 인수 안에서 메서드의 매개변수 이름을 nameof 로 가리킬 수 없었다. NRT(Nullable Reference Types, 참조 타입의 null 가능성을 컴파일러에 알려주는 분석 시스템) 를 쓸 때 [NotNullIfNotNull("input")] 처럼 매개변수 이름을 다시 문자열로 박아야 했습니다.
  2. 언바운드 제네릭(unbound generic) 형식에 nameof 를 쓸 수 없었다. nameof(List<int>) 는 가능했지만 nameof(List<>) 는 컴파일 에러였습니다. 제네릭 정의 자체의 이름만 필요한 상황(리플렉션, 에러 메시지, 코드 생성기)에서 의미 없는 더미 타입(int 같은) 을 채워야 했습니다.

C# 11 이 첫 번째 빈틈을, C# 14 가 두 번째 빈틈을 메웠습니다. 이 글에서는 두 확장이 각각 무엇을 해결했는지, IL 레벨에서 어떻게 처리되는지, Unity 환경에서 어떻게 활용할 수 있는지 를 살펴봅니다.


개념 정의 — nameof 는 컴파일러의 "이름 복사기"

식별자 이름표를 그대로 베껴 넣는 가위

nameof 가 하는 일을 비유로 표현하면 이렇습니다. 코드를 컴파일러에 넘기기 직전, 컴파일러가 들고 있는 가위가 식별자 이름표를 잘라 그 자리에 그대로 풀로 붙이는 작업 입니다. 잘라 붙인 결과물은 평범한 문자열 리터럴이라 런타임은 그것이 원래 변수였는지 메서드였는지 모릅니다. 컴파일 타임에 모든 일이 끝나기 때문에 런타임 비용은 0입니다.

컴파일 타임 (C# → IL)
nameof — 이름 연산자 (Name-of operator) 식별자(변수·매개변수·타입·멤버) 의 비정규화된(unqualified) 이름을 컴파일 타임에 문자열 상수로 가져오는 연산자입니다. 런타임에는 평범한 문자열 리터럴만 남습니다.
예시: string name = nameof(Player); Player 라는 식별자가 존재하면 "Player" 가 대입됩니다. 식별자가 없으면 컴파일 에러가 납니다.

가장 작은 예시

C#
using System;
using System.Collections.Generic;

internal class Player
{
    public int Health { get; set; }
}

internal class Demo
{
    public static void Show()
    {
        var player = new Player();
        Console.WriteLine(nameof(player));         // "player"
        Console.WriteLine(nameof(Player));         // "Player"
        Console.WriteLine(nameof(player.Health));  // "Health"
        Console.WriteLine(nameof(List<int>));      // "List" — 제네릭 타입은 이름만 남는다
    }
}

nameof(player.Health) 는 점(.) 으로 묶인 식별자 체인 중 마지막 식별자 만 가져옵니다. 즉 "player.Health" 가 아니라 "Health" 입니다. 정규화된 전체 경로가 필요하면 따로 조립해야 합니다. 이 동작은 모든 버전에서 동일하며 C# 11 / 14 확장도 이 규칙을 그대로 따릅니다.

위 코드를 컴파일해 IL 을 보면 nameof 가 흔적조차 남기지 않는다는 사실이 분명히 드러납니다.

IL
// Demo::Show 메서드의 일부
IL_0000: ldstr      "player"           // nameof(player) → 그냥 문자열 적재
IL_0005: call       void Console::WriteLine(string)
IL_000a: ldstr      "Player"           // nameof(Player)
IL_000f: call       void Console::WriteLine(string)
IL_0014: ldstr      "List"             // nameof(List<int>) → "List" (타입 인자는 무시)
IL_0019: call       void Console::WriteLine(string)

ldstr 은 IL의 "문자열 리터럴 적재" 명령으로, 메타데이터의 문자열 풀에서 인터닝된 문자열을 스택에 올립니다. nameof 호출이라는 함수 호출은 IL 어디에도 없습니다. 컴파일러가 그 자리를 미리 문자열로 치환했기 때문입니다.


내부 동작 — 컴파일러의 의미 분석 단계에서 "문자열 상수"로 굳는다

컴파일러 파이프라인에서의 위치

1. 구문 분석

nameof 의 결과는 컴파일러 입장에서 컴파일 타임 상수(compile-time constant) 입니다. 따라서 const string 의 초기화식, switchcase 라벨, 그리고 이 글의 핵심인 특성(Attribute) 의 인수 같은 "상수만 허용되는 자리"에 자유롭게 쓸 수 있습니다.

닫힌 제네릭에서 이름만 가져오는 규칙

위 IL 에서 nameof(List<int>)"List" 로 치환된 것을 보고 의아할 수 있습니다. 제네릭 타입 인자(<int>) 를 컴파일러가 통째로 버린 것입니다. 이 규칙이 C# 14 의 언바운드 제네릭 확장의 출발점입니다 — 어차피 결과는 "List" 로 동일하니, 굳이 의미 없는 <int> 같은 타입 인자를 채우라고 강요할 필요가 없다는 판단이었습니다.

C#
// 모두 동일하게 "Dictionary" 를 반환한다
nameof(Dictionary<string, int>);
nameof(Dictionary<int, string>);
nameof(Dictionary<,>);   // C# 14 부터 허용
IL
IL_0014: ldstr "Dictionary"   // nameof(Dictionary<string,int>)
IL_001e: ldstr "Dictionary"   // nameof(Dictionary<,>)         ← C# 14

세 호출 모두 IL 레벨에서는 같은 ldstr "Dictionary" 한 줄로 컴파일됩니다. 어떤 표현을 쓰든 런타임 동작은 완전히 동일 하다는 뜻입니다.


실전 적용 — 두 확장이 풀어준 매듭

C# 11 확장 — 특성 인수 안에서 매개변수 이름 참조

Before — C# 10 까지의 답답함

NRT 환경에서 메서드의 입출력 null 의미를 컴파일러에 알려주려면 System.Diagnostics.CodeAnalysis 네임스페이스의 특성을 씁니다. 그중 NotNullIfNotNullAttribute 는 "특정 매개변수가 null 이 아니면 반환값도 null 이 아니다" 를 선언합니다. 그런데 그 "특정 매개변수"를 가리키는 방법이 매개변수 이름의 문자열 리터럴 이었습니다.

C#
// ❌ C# 10 까지의 패턴 — 매개변수 이름이 문자열로 박힌다
[return: NotNullIfNotNull("input")]
public static string? Echo(string? input) => input;

inputvalue 로 리네임하면 IDE 의 일반적인 리팩터링은 매개변수만 바꾸고 "input" 문자열은 그대로 둡니다. 컴파일도 통과합니다. 결과적으로 분석기는 더 이상 어떤 매개변수와 반환값을 연결해야 하는지 모르게 되어 NRT 분석이 슬그머니 망가집니다. 컴파일러가 "이 문자열이 실제 매개변수 이름과 일치하지 않습니다" 같은 경고를 주지도 않았습니다.

[return: ...] — 반환값에 적용되는 특성 (Return target attribute) 메서드 시그니처 위에 있는 특성은 기본적으로 메서드 자체에 적용됩니다. 반환값에 적용하려면 [return: 특성명] 형태로 대상(target)을 명시합니다. 매개변수에 적용할 때는 [param: ...] 처럼 씁니다.

After — C# 11 의 확장된 nameof 범위

C# 11 부터는 같은 자리에서 nameof 가 매개변수를 가리킬 수 있습니다.

C#
// ✅ C# 11+ — nameof 로 안전하게 참조
[return: NotNullIfNotNull(nameof(input))]
public static string? Echo(string? input) => input;

이제 input 을 리네임하면 컴파일러가 nameof 안의 식별자도 같이 추적해 깨지지 않게 보장합니다. 만약 매개변수가 사라지면 컴파일 에러가 즉시 납니다. NRT 분석의 정확성이 리팩터링에 의해 스리슬쩍 침식되는 일을 차단합니다.

이 두 코드는 메타데이터 레벨에서 완전히 동일 합니다. 컴파일러가 nameof(input) 을 미리 "input" 으로 치환해 특성 인수 자리에 박아 넣기 때문입니다. 실제로 컴파일된 메서드의 메타데이터(특성 블롭)를 IL 로 살펴보면 다음과 같습니다.

IL
.method public hidebysig static
    string Echo (
        string input
    ) cil managed
{
    .param [0]
        // NotNullIfNotNullAttribute(string) — 인수 길이 5 + "input" 의 ASCII 바이트가 박혀 있다
        .custom instance void [System.Runtime]System.Diagnostics.CodeAnalysis.NotNullIfNotNullAttribute::.ctor(string) = (
            01 00 05 69 6e 70 75 74 00 00
        )
    // 메서드 본문
    IL_0000: ldarg.0
    IL_0001: ret
}

메타데이터의 01 00 05 69 6e 70 75 74 00 00 은 특성 인수의 직렬화된 형식입니다. 05 가 문자열 길이, 69 6e 70 75 74 이 ASCII 의 i n p u t 입니다. nameof(input) 으로 썼든 "input" 으로 썼든 정확히 같은 바이트 시퀀스가 메타데이터에 박힙니다. 차이는 오직 컴파일 타임 안전성에만 있고 런타임 동작·메모리·성능은 완전히 동일합니다.

이 확장이 가장 빛나는 곳은 NRT 분석을 돕는 일련의 특성들입니다.

특성 의미
[NotNullIfNotNull(nameof(p))] 매개변수 p 가 null 이 아니면 반환값(또는 다른 매개변수)도 null 이 아니다
[NotNullWhen(true)] 와 함께 쓰는 매개변수 이름 인수 메서드가 특정 bool 을 반환할 때 매개변수가 null 이 아님
[MemberNotNullWhen(true, nameof(_field))] 특정 메서드 호출 후 멤버 필드가 null 이 아니다
[CallerArgumentExpression(nameof(arg))] (C# 10 부터) 호출자에 적힌 표현식 텍스트를 자동 주입

CallerArgumentExpression 처럼 C# 10 시점부터 이미 매개변수 이름을 인수로 받던 특성 도 C# 11 의 nameof 확장으로 안전성이 한 단계 올라갔습니다.

C# 14 확장 — 언바운드 제네릭에 nameof 사용

Before — 의미 없는 타입 인자를 채워야 했다

C# 13 까지는 다음과 같이 써야 했습니다.

C#
// ❌ C# 13 까지 — 진짜 필요한 건 "List" 라는 이름뿐인데, int 같은 더미 타입을 채워야 한다
string listName = nameof(List<int>);          // "List"
string dictName = nameof(Dictionary<int, int>); // "Dictionary"

// 시도 — 컴파일 에러
// string bad = nameof(List<>);  // CS8081 등

리플렉션 디버그 출력이나 코드 생성기에서 "제네릭 타입의 비정규화된 이름 을 안전하게 가져오는 것" 만 필요할 때, 의미 없는 int 같은 타입을 채워 넣는 건 코드를 읽는 사람에게 잘못된 신호를 줍니다. "왜 하필 int 를 골랐지?" 라는 질문이 따라붙습니다.

After — C# 14 의 언바운드 제네릭 지원

C# 14 부터 제네릭 정의의 빈 꺾쇠괄호를 그대로 받습니다.

C#
// ✅ C# 14+ — 의도가 코드에 직접 드러난다
string listName = nameof(List<>);              // "List"
string dictName = nameof(Dictionary<,>);       // "Dictionary"
string roListName = nameof(IReadOnlyList<>);   // "IReadOnlyList"

같은 코드의 IL 을 확인하면 닫힌 제네릭과 완전히 같은 결과로 컴파일됩니다.

IL
IL_0014: ldstr "List"
IL_0019: ldstr "List"           // nameof(List<>)        — C# 14
IL_001e: ldstr "Dictionary"     // nameof(Dictionary<,>) — C# 14
IL_0023: ldstr "IReadOnlyList"  // nameof(IReadOnlyList<>) — C# 14

런타임 비용도, 메타데이터도, 결과 문자열도 닫힌 제네릭과 동일합니다. 차이는 소스 코드의 가독성·의도 표현 뿐입니다.

Unity 에서의 활용 패턴

Unity 의 C# 컴파일러 지원 현황을 먼저 정리해 두는 것이 좋습니다.

Unity 버전 기본 C# 언어 버전 nameof C# 11 확장 nameof C# 14 확장
Unity 2022.3 LTS C# 9 사용 불가 (C# 11 미지원) 사용 불가
Unity 2023.x / 6.0 (Mono) C# 9 (기본) 사용 불가 (기본) 사용 불가
Unity 6.0 + 최신 컴파일러 패키지 일부 C# 10/11 기능 일부 가능 (추정) 일반적으로 미지원 (추정)
추정: Unity 의 C# 11 / 14 확장 기능 지원은 사용 중인 정확한 Unity 패치 버전과 컴파일러 패키지(예: com.unity.roslyn 같은 외부 분석기) 에 따라 달라집니다. 본인 프로젝트에서 빌드하기 전에 작은 테스트 코드로 직접 컴파일 가능 여부를 확인하시기를 권장드립니다. 본 문단의 표는 일반적인 경향에 대한 참고용입니다.

C# 11/14 확장이 막혀 있어도 C# 6 시점부터 사용 가능한 기본 nameof 는 모든 Unity 버전에서 그대로 동작합니다. Unity 신입 개발자가 자주 마주칠 패턴은 다음 셋입니다.

1) Debug.Log 에서 식별자 이름 보존

C#
public class EnemyController : MonoBehaviour
{
    private float _attackPower = 10f;

    private void Update()
    {
        // ✅ _attackPower 가 _basePower 로 리네임되면 컴파일 에러로 즉시 노출
        Debug.Log($"{nameof(_attackPower)} = {_attackPower}");
    }
}

2) ArgumentNullException 의 매개변수 이름 정확하게 보고

C#
public class WeaponSystem
{
    private readonly IInputProvider _input;

    public WeaponSystem(IInputProvider input)
    {
        // ✅ input 을 inputProvider 로 바꿔도 ParamName 이 자동으로 따라 바뀐다
        _input = input ?? throw new ArgumentNullException(nameof(input));
    }
}
?? — null 병합 연산자 (Null-coalescing operator) 왼쪽 피연산자가 null 이 아니면 그 값을, null 이면 오른쪽 피연산자를 반환합니다. 위 코드에서는 input 이 null 이 아니면 그 값을 대입하고, null 이면 오른쪽의 throw 표현식 이 실행되어 예외를 던집니다.
예시: string name = userInput ?? "Unknown";

3) Custom Editor 에서 직렬화 필드 이름 자동 추적

C#
public class PlayerStats : MonoBehaviour
{
    [SerializeField] private float _maxHealth = 100f;
}

#if UNITY_EDITOR
[CustomEditor(typeof(PlayerStats))]
public class PlayerStatsEditor : Editor
{
    public override void OnInspectorGUI()
    {
        // ✅ _maxHealth 를 리네임하면 nameof 도 자동으로 따라온다
        // 문자열 "_maxHealth" 로 박았다면 런타임에 "Property not found" 로 깨진다
        var prop = serializedObject.FindProperty(nameof(PlayerStats._maxHealth));
        EditorGUILayout.PropertyField(prop);
        serializedObject.ApplyModifiedProperties();
    }
}
#endif

이 경우 _maxHealthprivate 이라도 nameof(PlayerStats._maxHealth) 의 인수는 컴파일 타임에 식별자만 검증하고 접근성 검사는 하지 않습니다(C# 사양). 즉 같은 어셈블리 안에 있다면 private 필드도 nameof 로 안전하게 가리킬 수 있습니다.


함정과 주의사항

함정 1 — 부분 채움 제네릭(Dictionary<string,>) 은 여전히 컴파일 에러

C# 14 가 풀어준 것은 완전히 비어 있는 제네릭 (<>, <,>, <,,>) 뿐입니다. 일부 타입 인자만 채워진 형태는 C# 문법 자체가 거부합니다.

C#
// ❌ 컴파일 에러: error CS1031: 형식이 필요합니다 (Type expected)
var bad = nameof(Dictionary<string,>);

// ✅ 둘 중 하나
var ok1 = nameof(Dictionary<string, int>);  // 완전히 채움 — 결과: "Dictionary"
var ok2 = nameof(Dictionary<,>);            // 완전히 비움 — 결과: "Dictionary"

규칙은 단순합니다. 완전히 채우거나, 완전히 비우거나. 부분 채움은 nameof 뿐 아니라 typeof 에서도 일관되게 금지됩니다 — Dictionary<,> 는 "정의 자체"라는 명확한 의미가 있지만 Dictionary<string,> 은 무엇을 의미하는지 정의되어 있지 않기 때문입니다.

함정 2 — nameof(this.프로퍼티)nameof(프로퍼티) 는 같지만 의도가 흐릿하다

nameof(player.Health) 에서 결과는 마지막 식별자 "Health" 만 남습니다. 따라서 this.ScoreScore 는 같은 결과를 줍니다.

C#
public class Score
{
    public int Value { get; set; }
    public override string ToString() =>
        $"{nameof(Score)}.{nameof(this.Value)} = {Value}";
        //  ↑ "Score" 그대로            ↑ "Value" 만 — "this.Value" 가 아님
}

전체 경로("Score.Value") 가 필요하면 직접 조립해야 합니다. nameof 는 마지막 점 뒤만 가져온다는 규칙을 잊으면 로깅 결과가 의도와 달라집니다.

함정 3 — nameof 안에서는 메서드를 "호출"할 수 없다

C#
// ❌ 컴파일 에러
var bad = nameof(GetHealth());

// ✅ 메서드 자체의 이름은 인수 없이
var ok = nameof(GetHealth);   // "GetHealth"

nameof 의 인수는 호출 표현식이 아니라 식별자 또는 멤버 접근식 이어야 합니다. 괄호가 붙은 호출 표현식은 의미 분석 단계에서 거부됩니다.

함정 4 — Unity 의 옛 버전에서 C# 11 확장 attribute 패턴이 그냥 무시될 수 있다

[NotNullIfNotNull(nameof(input))] 같은 C# 11 패턴을 C# 9 까지만 지원하는 Unity 2022.3 LTS 환경에서 작성하면 컴파일 자체는 통과할 수 있습니다(특성 인수의 nameof 위치는 C# 6 부터 일부 허용되어 있었으나, 메서드 매개변수 참조는 C# 11 이전에는 스코프 밖이라 분석기가 받아들이지 못했음). 이 경우 NRT 분석기는 단순히 그 특성을 인식하지 못하거나 잘못 해석할 수 있습니다(추정).

해결책은 단순합니다: 현재 Unity 버전이 어떤 C# 언어 버전을 지원하는지 먼저 확인하고, 지원하지 않는 버전에서는 그냥 기본 nameof 패턴 만 사용하는 것입니다. NRT 친화 특성을 적극 활용하고 싶다면 일반 .NET 라이브러리 프로젝트로 분리해 그쪽에서만 C# 11+ 을 쓰는 방법이 안전합니다.


C# 버전별 변화 — nameof 의 진화 연표

버전 변화 핵심
C# 6.0 (2015) nameof 연산자 도입 식별자 이름을 컴파일 타임 문자열 상수로
C# 10 (2021) CallerArgumentExpression 등 신규 특성과 결합 nameof 와 함께 쓰기 좋은 특성들이 추가
C# 11 (2022) 특성 인수 안에서 메서드의 매개변수를 nameof 로 참조 가능 NRT 분석 특성의 안전성 향상
C# 14 (2025) nameof(List<>) 같은 언바운드 제네릭 형식 지원 제네릭 정의 이름만 필요할 때 의도 표현 향상

C# 10 → 11 — 특성 인수 안의 매개변수 참조

C#
// C# 10
[return: NotNullIfNotNull("input")]   // ❌ 매개변수 이름을 문자열로
public static string? Echo10(string? input) => input;

// C# 11
[return: NotNullIfNotNull(nameof(input))]  // ✅ 컴파일러가 추적
public static string? Echo11(string? input) => input;

두 코드는 같은 메타데이터로 컴파일되지만(01 00 05 69 6e 70 75 74 00 00), 후자는 매개변수 이름이 바뀔 때 컴파일러의 도움을 받습니다.

C# 13 → 14 — 언바운드 제네릭에 nameof 사용

C#
// C# 13 까지
string n13 = nameof(List<int>);   // "List" — 더미 타입 인자 필요

// C# 14
string n14 = nameof(List<>);      // "List" — 의도가 직접 드러남

IL 결과는 둘 다 ldstr "List" 한 줄로 동일합니다.

버전 간의 일관성 — 두 확장 모두 "치환 결과"는 변하지 않는다

C# 11 / 14 의 두 확장은 이미 가능했던 결과를 더 안전하고 간결하게 표현하는 길 을 추가했을 뿐입니다. 결과 문자열, IL, 메타데이터, 런타임 동작 어느 하나도 새로 바뀌지 않습니다. 그래서 기존 코드를 새 버전 nameof 패턴으로 마이그레이션해도 동작 변경 위험이 거의 없습니다.


정리

이 글에서 다룬 핵심을 체크리스트로 정리합니다.

  • [ ] nameof(x)컴파일 타임에 식별자 이름의 문자열 상수로 치환되며, IL 에는 ldstr "x" 한 줄만 남는다 — 런타임 비용은 0이다
  • [ ] nameof(player.Health) 는 마지막 식별자만 가져온다 → "Health" (전체 경로 아님)
  • [ ] nameof(List<int>)nameof(List<>) 는 모두 "List" 를 반환한다 — 결과는 같다
  • [ ] C# 11: [NotNullIfNotNull(nameof(input))] 처럼 특성 인수에서 메서드의 매개변수 이름을 안전하게 가리킬 수 있다 — NRT 분석의 리팩터링 안정성 확보
  • [ ] C# 14: nameof(List<>), nameof(Dictionary<,>) 처럼 언바운드 제네릭 정의에 직접 사용 가능하다 — 의미 없는 더미 타입 인자가 사라진다
  • [ ] 부분 채움(List<int,>) 은 모든 버전에서 금지 — 완전히 채우거나 완전히 비우거나
  • [ ] Unity 의 기본 nameof 패턴(Debug.Log, ArgumentNullException, Custom Editor)은 모든 Unity 버전에서 안전하게 쓸 수 있다 — 매개변수·필드·속성 이름을 문자열로 박지 말고 항상 nameof 로 가져오자
  • [ ] nameoftypeof(T).Name, MethodBase.GetCurrentMethod().Name 같은 리플렉션 API 와 다르다 — 후자는 런타임 호출이지만 nameof 는 컴파일 타임 치환이다
반응형

+ Recent posts