[PART12.제네릭·델리게이트·람다·LINQ(18/18)] 확장된 nameof 범위와 언바운드 제네릭 지원 — 마법의 문자열을 마지막까지 몰아내기
nameof는 식별자 이름을 컴파일 타임에 문자열로 치환한다 / C# 11은 특성 인수 안에서 매개변수 이름을 참조할 수 있게 했다 / C# 14는 nameof(List<>) 같은 언바운드 제네릭까지 받는다
목차
문제 제기 — 코드 안에 박힌 "문자열 이름"이 사라지지 않는 이유
Unity 프로젝트에서 다음 같은 코드를 자주 마주칩니다.
public void Initialize(PlayerController controller)
{
if (controller == null)
throw new ArgumentNullException("controller"); // ← 매개변수 이름을 문자열로 박았다
_controller = controller;
}
protected void OnPropertyChanged()
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs("Health")); // ← 속성 이름을 문자열로
}
문제는 리네임(rename) 리팩터링 입니다. controller 를 playerController 로 바꾸면 IDE는 매개변수 선언과 사용처는 바꿔주지만 "controller" 라는 문자열 리터럴은 손대지 않습니다. 컴파일은 멀쩡히 통과하지만 예외가 터졌을 때 ParamName 이 "controller" 로 나와 디버깅하는 사람을 헷갈리게 합니다. 이런 식으로 코드의 진실(이름) 과 문자열 리터럴(이름의 사본) 이 어긋나는 순간을 통틀어 "마법의 문자열(magic string)" 문제라고 부릅니다.
C# 6.0 의 nameof 연산자는 이 문제를 정면으로 풀었습니다. nameof(controller) 는 컴파일 타임에 "controller" 로 치환되므로, 매개변수 이름이 바뀌면 같이 바뀌거나(리네임 리팩터링이 따라옴) 컴파일 에러로 즉시 알려줍니다. 그런데도 두 가지 빈틈이 남아 있었습니다.
- 특성(Attribute) 인수 안에서 메서드의 매개변수 이름을
nameof로 가리킬 수 없었다. NRT(Nullable Reference Types, 참조 타입의 null 가능성을 컴파일러에 알려주는 분석 시스템) 를 쓸 때[NotNullIfNotNull("input")]처럼 매개변수 이름을 다시 문자열로 박아야 했습니다. - 언바운드 제네릭(unbound generic) 형식에
nameof를 쓸 수 없었다.nameof(List<int>)는 가능했지만nameof(List<>)는 컴파일 에러였습니다. 제네릭 정의 자체의 이름만 필요한 상황(리플렉션, 에러 메시지, 코드 생성기)에서 의미 없는 더미 타입(int같은) 을 채워야 했습니다.
C# 11 이 첫 번째 빈틈을, C# 14 가 두 번째 빈틈을 메웠습니다. 이 글에서는 두 확장이 각각 무엇을 해결했는지, IL 레벨에서 어떻게 처리되는지, Unity 환경에서 어떻게 활용할 수 있는지 를 살펴봅니다.
개념 정의 — nameof 는 컴파일러의 "이름 복사기"
식별자 이름표를 그대로 베껴 넣는 가위
nameof 가 하는 일을 비유로 표현하면 이렇습니다. 코드를 컴파일러에 넘기기 직전, 컴파일러가 들고 있는 가위가 식별자 이름표를 잘라 그 자리에 그대로 풀로 붙이는 작업 입니다. 잘라 붙인 결과물은 평범한 문자열 리터럴이라 런타임은 그것이 원래 변수였는지 메서드였는지 모릅니다. 컴파일 타임에 모든 일이 끝나기 때문에 런타임 비용은 0입니다.

nameof— 이름 연산자 (Name-of operator) 식별자(변수·매개변수·타입·멤버) 의 비정규화된(unqualified) 이름을 컴파일 타임에 문자열 상수로 가져오는 연산자입니다. 런타임에는 평범한 문자열 리터럴만 남습니다.
예시:string name = nameof(Player);Player라는 식별자가 존재하면"Player"가 대입됩니다. 식별자가 없으면 컴파일 에러가 납니다.
가장 작은 예시
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 가 흔적조차 남기지 않는다는 사실이 분명히 드러납니다.
// 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 어디에도 없습니다. 컴파일러가 그 자리를 미리 문자열로 치환했기 때문입니다.
내부 동작 — 컴파일러의 의미 분석 단계에서 "문자열 상수"로 굳는다
컴파일러 파이프라인에서의 위치

nameof 의 결과는 컴파일러 입장에서 컴파일 타임 상수(compile-time constant) 입니다. 따라서 const string 의 초기화식, switch 의 case 라벨, 그리고 이 글의 핵심인 특성(Attribute) 의 인수 같은 "상수만 허용되는 자리"에 자유롭게 쓸 수 있습니다.
닫힌 제네릭에서 이름만 가져오는 규칙
위 IL 에서 nameof(List<int>) 가 "List" 로 치환된 것을 보고 의아할 수 있습니다. 제네릭 타입 인자(<int>) 를 컴파일러가 통째로 버린 것입니다. 이 규칙이 C# 14 의 언바운드 제네릭 확장의 출발점입니다 — 어차피 결과는 "List" 로 동일하니, 굳이 의미 없는 <int> 같은 타입 인자를 채우라고 강요할 필요가 없다는 판단이었습니다.
// 모두 동일하게 "Dictionary" 를 반환한다
nameof(Dictionary<string, int>);
nameof(Dictionary<int, string>);
nameof(Dictionary<,>); // C# 14 부터 허용
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# 10 까지의 패턴 — 매개변수 이름이 문자열로 박힌다
[return: NotNullIfNotNull("input")]
public static string? Echo(string? input) => input;
input 을 value 로 리네임하면 IDE 의 일반적인 리팩터링은 매개변수만 바꾸고 "input" 문자열은 그대로 둡니다. 컴파일도 통과합니다. 결과적으로 분석기는 더 이상 어떤 매개변수와 반환값을 연결해야 하는지 모르게 되어 NRT 분석이 슬그머니 망가집니다. 컴파일러가 "이 문자열이 실제 매개변수 이름과 일치하지 않습니다" 같은 경고를 주지도 않았습니다.
[return: ...]— 반환값에 적용되는 특성 (Return target attribute) 메서드 시그니처 위에 있는 특성은 기본적으로 메서드 자체에 적용됩니다. 반환값에 적용하려면[return: 특성명]형태로 대상(target)을 명시합니다. 매개변수에 적용할 때는[param: ...]처럼 씁니다.
After — C# 11 의 확장된 nameof 범위
C# 11 부터는 같은 자리에서 nameof 가 매개변수를 가리킬 수 있습니다.
// ✅ C# 11+ — nameof 로 안전하게 참조
[return: NotNullIfNotNull(nameof(input))]
public static string? Echo(string? input) => input;
이제 input 을 리네임하면 컴파일러가 nameof 안의 식별자도 같이 추적해 깨지지 않게 보장합니다. 만약 매개변수가 사라지면 컴파일 에러가 즉시 납니다. NRT 분석의 정확성이 리팩터링에 의해 스리슬쩍 침식되는 일을 차단합니다.
이 두 코드는 메타데이터 레벨에서 완전히 동일 합니다. 컴파일러가 nameof(input) 을 미리 "input" 으로 치환해 특성 인수 자리에 박아 넣기 때문입니다. 실제로 컴파일된 메서드의 메타데이터(특성 블롭)를 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# 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# 14+ — 의도가 코드에 직접 드러난다
string listName = nameof(List<>); // "List"
string dictName = nameof(Dictionary<,>); // "Dictionary"
string roListName = nameof(IReadOnlyList<>); // "IReadOnlyList"
같은 코드의 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 에서 식별자 이름 보존
public class EnemyController : MonoBehaviour
{
private float _attackPower = 10f;
private void Update()
{
// ✅ _attackPower 가 _basePower 로 리네임되면 컴파일 에러로 즉시 노출
Debug.Log($"{nameof(_attackPower)} = {_attackPower}");
}
}
2) ArgumentNullException 의 매개변수 이름 정확하게 보고
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 에서 직렬화 필드 이름 자동 추적
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
이 경우 _maxHealth 가 private 이라도 nameof(PlayerStats._maxHealth) 의 인수는 컴파일 타임에 식별자만 검증하고 접근성 검사는 하지 않습니다(C# 사양). 즉 같은 어셈블리 안에 있다면 private 필드도 nameof 로 안전하게 가리킬 수 있습니다.
함정과 주의사항
함정 1 — 부분 채움 제네릭(Dictionary<string,>) 은 여전히 컴파일 에러
C# 14 가 풀어준 것은 완전히 비어 있는 제네릭 (<>, <,>, <,,>) 뿐입니다. 일부 타입 인자만 채워진 형태는 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.Score 와 Score 는 같은 결과를 줍니다.
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 안에서는 메서드를 "호출"할 수 없다
// ❌ 컴파일 에러
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# 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# 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로 가져오자 - [ ]
nameof는typeof(T).Name,MethodBase.GetCurrentMethod().Name같은 리플렉션 API 와 다르다 — 후자는 런타임 호출이지만nameof는 컴파일 타임 치환이다