반응형

[PART10.예외 처리 기본(8/9)] CallerArgumentExpression — 인수 표현식 문자열 자동 주입 (C# 10)

가드 절(guard clause)에서 매개변수 이름을 손으로 적던 시대의 종말 / 컴파일러가 호출자의 표현식을 그대로 박아 넣는 원리 / ArgumentNullException.ThrowIfNull 한 줄의 비밀 / Unity에서 쓰려면 무엇이 필요한가


1. 문제 제기 — "어디에서 null이 들어왔는지" 잃어버린 메시지

Unity 모바일 게임의 인벤토리 시스템에서 다음과 같은 코드를 자주 작성하게 됩니다. 서버에서 받아온 캐릭터 정보로 UI를 갱신하려는데, 어디선가 null이 들어와 NullReferenceException이 터집니다.

C#
public void UpdateProfileUI(User user)
{
    nameText.text = user.Profile.Nickname;     // 어디서 null인가?
    iconImage.sprite = user.Profile.Avatar;
}

스택 트레이스에는 NullReferenceException: Object reference not set to an instance of an object만 찍힙니다. user가 null인지, user.Profile이 null인지, user.Profile.Nickname이 null인지 알 수 없습니다. 그래서 우리는 가드 절(guard clause, 메서드 진입 직후 인수의 유효성을 검사하는 코드)을 넣기 시작합니다.

C#
public void UpdateProfileUI(User user)
{
    if (user is null) throw new ArgumentNullException(nameof(user));
    if (user.Profile is null) throw new ArgumentNullException(nameof(user.Profile));
    nameText.text = user.Profile.Nickname;
    iconImage.sprite = user.Profile.Avatar;
}
nameof — 식별자의 이름을 문자열로 가져오는 연산자 컴파일 타임에 변수·타입·멤버의 이름을 그대로 문자열로 치환한다. 리팩터링으로 이름이 바뀌면 자동으로 따라 변하므로, 하드코딩된 문자열보다 안전하다.
예시: nameof(user)"user" 단, nameof(user.Profile)"Profile"처럼 마지막 식별자만 문자열이 된다.

문제는 두 가지입니다. 첫째, 반복되는 보일러플레이트가 늘어납니다. 인수가 5개면 5줄을 같은 모양으로 적습니다. 둘째, nameof(user.Profile)"Profile"만 박힙니다. 호출 측에서 UpdateProfileUI(currentMatch.RemotePlayer) 같은 표현식으로 들어와도 메시지에는 "user"만 남습니다 — 호출자가 실제로 무엇을 넘겼는지는 알 수 없습니다.

C# 10이 도입한 [CallerArgumentExpression] 특성은 이 두 문제를 한 번에 해결합니다. 컴파일러가 호출자의 인수 표현식을 그대로 문자열로 박아 메서드에 전달해 줍니다. ArgumentNullException.ThrowIfNull(currentMatch.RemotePlayer) 한 줄을 적으면 예외 메시지에 "currentMatch.RemotePlayer" 가 그대로 남습니다.

기존 방식 (C# 9 이하)

2. 개념 정의 — 호출자의 인수 표현식을 컴파일러가 박아 준다

비유: 출입 서명부의 자동 기입란

회사 출입구에 종이 방문록이 있다고 합시다. 손님이 이름·소속·방문 사유를 직접 적어야 한다면 매번 같은 정보를 손으로 채우기가 번거롭습니다. 이때 안내 데스크에서 사원증을 찍으면 이름·소속이 자동으로 인쇄되는 시스템이 있다면, 손님은 사유만 적으면 됩니다.

[CallerArgumentExpression] 은 그 "자동 인쇄"입니다. 메서드 매개변수에 이 특성을 붙여 두면, 호출자가 그 자리에 적은 코드 그대로가 별도의 매개변수에 자동으로 채워져 들어옵니다. 단, 호출자가 직접 값을 채워 넣으면(사원증 대신 이름을 손으로 쓰면) 자동 기입은 무시됩니다.

특성 시그니처

[Attribute] — 특성(Attribute) 코드 요소에 메타데이터를 덧붙이는 선언이다. 컴파일러나 런타임이 이 메타데이터를 읽어 특별한 처리를 한다. [CallerArgumentExpression] 은 컴파일러에게 "이 매개변수에 호출자의 인수 표현식을 자동으로 채워 줘"라고 지시하는 역할이다.
예시: [CallerArgumentExpression(nameof(value))] string? expr = null value 인수의 표현식을 expr로 받음

이 특성은 System.Runtime.CompilerServices 네임스페이스에 정의되어 있습니다. 생성자 인수로 추적할 매개변수의 이름을 받습니다.

메서드 정의 (수신 측)

가장 단순한 예시

매개변수 value의 호출 표현식을 expr로 받는 메서드를 정의해 봅니다.

C#
using System;
using System.Runtime.CompilerServices;

class Player { public string? Address; }

class Program
{
    static void Main()
    {
        Player? user = null;
        try
        {
            NotNull(user?.Address);   // 호출 측이 적은 표현식 그대로 박힘
        }
        catch (ArgumentNullException ex)
        {
            Console.WriteLine(ex.ParamName);  // 출력: user?.Address
        }
    }

    static void NotNull(
        object? value,
        [CallerArgumentExpression(nameof(value))] string? expr = null)
    {
        if (value is null) throw new ArgumentNullException(expr);
    }
}

호출 시 두 번째 인수를 명시적으로 넘기지 않았는데도 ex.ParamName"user?.Address"로 찍힙니다. 컴파일러가 호출 시점의 표현식 텍스트(user?.Address)를 자동으로 채워 넣었기 때문입니다.

IL 분석 — 호출 측에 ldstr "user?.Address" 가 박힌다

위 코드를 컴파일해 호출 측 Main 의 IL을 보면 다음과 같습니다.

IL
.method private hidebysig static void Main () cil managed
{
    .entrypoint

    IL_0000: ldnull          // user = null
    IL_0001: dup
    IL_0002: dup
    IL_0003: brtrue.s IL_0009    // user?. 의 null 단축 평가
    IL_0005: pop
    IL_0006: ldnull
    IL_0007: br.s IL_000e
    IL_0009: ldfld string Player::Address    // 정상 경로면 Address 필드 로드

    IL_000e: ldstr "user?.Address"           // ★ 호출자의 표현식 자동 주입
    IL_0013: call void Program::NotNull(object, string)
    IL_0018: ret
}

핵심은 IL_000e: ldstr "user?.Address" 한 줄입니다. 호출자가 적은 표현식 텍스트가 IL에 문자열 리터럴로 박혀 NotNull 의 두 번째 인수로 전달됩니다.

ldstr — Load String 어셈블리 메타데이터 안의 문자열 리터럴을 평가 스택에 올리는 IL 명령. 런타임에 새로 문자열을 만들지 않고, 컴파일 시점에 이미 어셈블리에 박혀 있는 문자열을 가리키기만 한다.

리플렉션도, 동적 문자열 조립도 없습니다. 컴파일러가 이미 끝낸 일이라 런타임 비용이 0 입니다.


3. 내부 동작 — 컴파일러가 선택 매개변수를 자동으로 채우는 메커니즘

다른 Caller* 시리즈와 한 가족

CallerArgumentExpression 은 갑자기 등장한 기능이 아닙니다. C# 5에서 도입된 Caller* 특성 시리즈의 막내입니다.

Caller* 특성 시리즈

네 가지 특성 모두 동일한 규칙을 따릅니다.

  1. 선택 매개변수(기본값이 있는 매개변수)에만 붙일 수 있다.
  2. 호출자가 그 자리에 인수를 생략하면 컴파일러가 자동으로 채운다.
  3. 호출자가 명시적으로 값을 넘기면 컴파일러는 자동 채움을 하지 않고 그 값을 그대로 사용한다.

CallerArgumentExpression 만 다른 점은, 채워 넣는 내용이 "호출자의 정보"가 아니라 "호출자가 다른 매개변수에 넘긴 인수의 코드 텍스트" 라는 것입니다. 그래서 생성자 인수로 추적할 매개변수의 이름(nameof(value))을 받습니다.

컴파일러 변환 모델

호출 측 코드는 다음과 같이 컴파일러에 의해 재작성된다고 보면 됩니다.

C#
// 작성한 코드
NotNull(user?.Address);

// 컴파일러가 실제로 IL로 만든 호출
NotNull(user?.Address, "user?.Address");

이 변환은 호출 시점의 소스 코드를 기준으로 합니다. 호출자 어셈블리가 컴파일될 때 결정되며, 호출 받는 메서드(라이브러리)에는 어떤 변경도 일어나지 않습니다. 그래서 라이브러리를 다시 빌드하지 않아도 호출 측이 C# 10 컴파일러로 빌드되기만 하면 동작합니다.

IL 레벨 — 메서드 정의 측에 박힌 특성

수신 측 메서드 시그니처에는 다음과 같은 메타데이터가 박힙니다.

IL
.method private hidebysig static void NotNull (
        object 'value',
        [opt] string expr
    ) cil managed
{
    .param [2] = nullref
        .custom instance void [System.Runtime]System.Runtime.CompilerServices.CallerArgumentExpressionAttribute::.ctor(string)
            = ( 01 00 05 76 61 6c 75 65 00 00 )    // "value" UTF-8 인코딩
    ...
}

.param [2] 의 커스텀 어트리뷰트로 CallerArgumentExpressionAttribute("value") 가 박혀 있습니다. 호출 측 컴파일러는 이 메타데이터를 보고 "이 자리에는 첫 번째 인수의 표현식 텍스트를 넣어 주면 되겠구나"를 판단합니다. 런타임에는 이 어트리뷰트를 들여다보지도 않습니다 — 모든 결정은 컴파일 타임에 끝납니다.


4. 실전 적용 — ThrowIfNull과 가드 코드의 진화

.NET BCL이 미리 깔아 둔 가드 메서드들

C# 10이 등장하면서 .NET 6 표준 라이브러리에 가드 메서드들이 추가되었고, .NET 7·8을 거치며 종류가 크게 늘었습니다. 모두 내부적으로 [CallerArgumentExpression] 을 사용해서 메시지 품질을 자동으로 보장합니다.

메서드 도입 용도
ArgumentNullException.ThrowIfNull(arg) .NET 6 null 검사
ArgumentException.ThrowIfNullOrEmpty(arg) .NET 7 null 또는 빈 문자열 검사
ArgumentException.ThrowIfNullOrWhiteSpace(arg) .NET 8 null·공백 문자열 검사
ArgumentOutOfRangeException.ThrowIfNegative(arg) .NET 8 음수 검사
ArgumentOutOfRangeException.ThrowIfZero(arg) .NET 8 0 검사
ArgumentOutOfRangeException.ThrowIfGreaterThan(arg, other) .NET 8 상한 검사
ObjectDisposedException.ThrowIf(condition, instance) .NET 7 사용 후 호출 검사

이 메서드들은 모두 같은 패턴을 따릅니다.

C#
// .NET BCL의 실제 시그니처(축약)
public static void ThrowIfNull(
    [NotNull] object? argument,
    [CallerArgumentExpression(nameof(argument))] string? paramName = null)
{
    if (argument is null)
        throw new ArgumentNullException(paramName);
}

Before / After — 인벤토리 검증 코드

Unity 모바일 게임의 인벤토리 매니저에서 아이템을 추가하는 메서드를 검증한다고 합시다.

Before — C# 9 이하 스타일

C#
public void AddItem(InventorySlot slot, ItemData item, int count)
{
    if (slot is null)
        throw new ArgumentNullException(nameof(slot));
    if (item is null)
        throw new ArgumentNullException(nameof(item));
    if (count <= 0)
        throw new ArgumentOutOfRangeException(
            nameof(count), count, "count는 1 이상이어야 합니다.");

    slot.Items.Add(new ItemStack(item, count));
}

3개의 인수를 검증하는 데 6줄을 씁니다. 그리고 호출 측에서 manager.AddItem(player.MainBag, dropped.Item, dropped.Stack) 같은 표현식으로 들어와도 메시지에는 slot·item·count 만 남습니다.

After — C# 10 이후 스타일

C#
public void AddItem(InventorySlot slot, ItemData item, int count)
{
    ArgumentNullException.ThrowIfNull(slot);
    ArgumentNullException.ThrowIfNull(item);
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(count);

    slot.Items.Add(new ItemStack(item, count));
}

3줄로 줄었고, 호출자가 어떤 표현식을 넘겼는지 가 그대로 메시지에 남습니다. manager.AddItem(player.MainBag, dropped.Item, dropped.Stack) 호출에서 dropped.Item 이 null이면 ArgumentNullException: Value cannot be null. (Parameter 'item') 이 아니라 호출 측 컴파일러 버전에 따라 더 많은 정보를 담을 수 있습니다.

After 코드의 IL 증거

ArgumentNullException.ThrowIfNull(slot) 호출 측 IL을 보면 다음과 같습니다.

IL
IL_0001: ldarg.1                     // slot
IL_0002: ldstr "slot"                // ★ 자동 주입된 표현식
IL_0007: call void [System.Runtime]System.ArgumentNullException::ThrowIfNull(object, string)

작성한 코드에는 "slot" 이라는 문자열이 어디에도 없는데, IL에는 이미 박혀 있습니다. 컴파일러가 호출 시점의 표현식(slot — 이 경우엔 변수명)을 그대로 넣어 준 것입니다. 호출자가 manager.AddItem(player.MainBag, ...) 처럼 적었다면 이 자리에는 "player.MainBag" 이 박힙니다.

Debug.Assert 도 함께 진화했다

.NET 6 이후 Debug.AssertCallerArgumentExpression 을 사용하는 오버로드를 갖게 되었습니다.

C#
// .NET 6+ 오버로드 (축약)
public static void Assert(
    [DoesNotReturnIf(false)] bool condition,
    [CallerArgumentExpression(nameof(condition))] string? message = null)
{
    if (!condition) Fail(message);
}

이전에는 Debug.Assert(player.HP > 0) 가 실패하면 단순히 "Assertion failed"만 떴지만, 이제는 메시지에 "player.HP > 0" 이 자동으로 들어갑니다. 디버깅 메시지 품질이 한 단계 올라간 셈입니다.

C#
void OnPlayerDamaged(Player p, int damage)
{
    Debug.Assert(damage >= 0);          // 실패 시 메시지: "damage >= 0"
    Debug.Assert(p.HP > 0);             // 실패 시 메시지: "p.HP > 0"
    p.HP -= damage;
}

직접 만드는 도메인 가드 헬퍼

게임 도메인 전용 가드 헬퍼를 만들 때도 같은 패턴을 그대로 활용할 수 있습니다.

C#
public static class GameGuard
{
    public static void ValidPlayerId(
        int id,
        [CallerArgumentExpression(nameof(id))] string? expr = null)
    {
        if (id <= 0)
            throw new ArgumentOutOfRangeException(expr, id, "유효하지 않은 플레이어 ID입니다.");
    }

    public static void NotEmpty<T>(
        IReadOnlyCollection<T> collection,
        [CallerArgumentExpression(nameof(collection))] string? expr = null)
    {
        if (collection is null) throw new ArgumentNullException(expr);
        if (collection.Count == 0)
            throw new ArgumentException("컬렉션이 비어 있습니다.", expr);
    }
}

// 호출 측
GameGuard.ValidPlayerId(matchResult.WinnerId);
//   예외 메시지: Parameter 'matchResult.WinnerId'

GameGuard.NotEmpty(state.ActiveQuests);
//   예외 메시지: Parameter 'state.ActiveQuests'

호출자가 어떤 표현식을 넘기든 그대로 메시지에 남기 때문에, 로그 한 줄로 어떤 데이터에서 문제가 생겼는지 즉시 추적할 수 있습니다.

Unity 핫패스에서의 비용

CallerArgumentExpression 의 모든 동작은 컴파일 타임에 끝나고, 런타임에는 단지 미리 박힌 문자열을 인수로 전달할 뿐입니다. 따라서 매 프레임 호출되는 Update·FixedUpdate 핫패스에 가드 메서드를 넣어도 GC 할당이 발생하지 않습니다. 문자열은 어셈블리에 박힌 리터럴이라 인터닝(interning)되어 같은 인스턴스가 재사용됩니다.

C#
void Update()
{
    ArgumentNullException.ThrowIfNull(target);
    transform.position = Vector3.MoveTowards(transform.position, target.position, speed * Time.deltaTime);
}

값이 null이 아닌 한 ldstr 한 번 + 메서드 호출 한 번이 전부입니다. Unity Profiler에서도 추가 할당으로 잡히지 않습니다.


5. 함정과 주의사항

❌ 함정 1 — 기본값을 빠뜨린다

Caller* 시리즈의 가장 흔한 실수입니다. 매개변수에 기본값이 없으면 호출 측에서 인수를 생략할 수 없으므로 자동 채움이 적용될 자리가 없습니다.

C#
// ❌ 컴파일은 되지만 자동 채움이 동작하지 않는다
static void NotNull(
    object? value,
    [CallerArgumentExpression(nameof(value))] string expr)   // 기본값 없음
{
    if (value is null) throw new ArgumentNullException(expr);
}

// 호출 측에서는 매번 두 번째 인수를 직접 넘겨야 한다
NotNull(user, "user");
C#
// ✅ 기본값을 주면 호출 측이 생략할 수 있고, 그때만 컴파일러가 자동 주입한다
static void NotNull(
    object? value,
    [CallerArgumentExpression(nameof(value))] string? expr = null)
{
    if (value is null) throw new ArgumentNullException(expr);
}

NotNull(user);   // expr 자동으로 "user" 채워짐

❌ 함정 2 — 호출자가 명시적으로 인수를 넘기면 자동 주입은 무시된다

설명 메시지를 직접 넘기고 싶을 때 이 동작이 헷갈립니다.

C#
// 호출자가 두 번째 인수를 명시
NotNull(user, "여기에 user 가 들어와야 합니다");

// 컴파일러는 자동 채움을 하지 않고 호출자가 넘긴 값을 그대로 사용한다
// 결과: ParamName = "여기에 user 가 들어와야 합니다"

호출자가 인수를 직접 적으면 그 값이 우선합니다. 자동 주입은 인수가 생략된 경우에만 동작합니다. 가드 헬퍼를 사용자에게 노출할 때 "이 두 번째 매개변수는 절대 직접 넘기지 마세요"라고 안내하기보다는 차라리 보이지 않는 위치(맨 끝의 선택 매개변수)에 두는 것이 안전합니다.

❌ 함정 3 — nameof(value)의 인수 검증은 컴파일러가 한다

[CallerArgumentExpression("velue")] 처럼 매개변수 이름을 오타 내면 어떻게 될까요? 컴파일은 통과하지만 자동 주입이 동작하지 않고 기본값이 그대로 들어옵니다. 디버깅이 어렵기 때문에 항상 nameof()로 적는 것이 안전합니다.

C#
// ❌ 오타 — 자동 주입 실패. 경고는 뜨지만 빌드는 통과
static void NotNull(object? value,
    [CallerArgumentExpression("velue")] string? expr = null)
{
    if (value is null) throw new ArgumentNullException(expr);
}
NotNull(user);   // expr = null (자동 주입 안 됨)

// ✅ nameof — 매개변수명이 바뀌어도 자동으로 따라간다
static void NotNull(object? value,
    [CallerArgumentExpression(nameof(value))] string? expr = null)
{
    if (value is null) throw new ArgumentNullException(expr);
}
NotNull(user);   // expr = "user"

Roslyn 분석기는 잘못된 매개변수 이름에 대해 경고(CS8963 — CallerArgumentExpressionAttribute applied with invalid parameter name)를 띄우지만, 빌드 자체는 막지 않습니다. 경고를 무시하지 않도록 프로젝트 설정에서 <TreatWarningsAsErrors>true</TreatWarningsAsErrors> 를 켜는 것이 좋습니다.

❌ 함정 4 — ref/out 매개변수에는 적용할 수 없는 케이스

[CallerArgumentExpression] 자체는 string? 같은 일반 참조형 매개변수에 붙는 특성이라 문법 제약은 적지만, 추적 대상out 매개변수인 경우 의미가 모호해집니다. out 변수는 호출 시 값을 받기 위한 자리라 "표현식"이 아니라 "변수 이름"이 들어옵니다. 컴파일러는 이 경우에도 표현식 텍스트를 박지만, 활용도가 낮으므로 가드용 매개변수에는 일반 입력 매개변수만 추적하는 것이 일반적입니다.

❌ 함정 5 — Unity 환경에서 그냥 쓰면 컴파일 에러

이 부분은 별도 섹션에서 다룰 만큼 중요합니다.

Unity 버전별 C# 언어 지원

Unity 버전 기본 언어 버전 CallerArgumentExpression
Unity 2020.x C# 8 ❌ 사용 불가 (C# 10 필요)
Unity 2021.2+ C# 9 ❌ 언어 버전 부족
Unity 2022.3 C# 9 (기본) ❌ 언어 버전 부족
Unity 2023.1+ C# 9 (기본), C# 10 옵션 ⚠️ 컴파일러는 OK, 특성 폴리필 필요

Unity 2023.1 이상에서 C# 10을 활성화하려면 프로젝트 루트 또는 Assets/ 아래에 csc.rsp 파일을 두고 -langversion:10 옵션을 넣어야 합니다 (Unity 공식 가이드는 환경에 따라 다르므로 사용 중인 Unity 버전 매뉴얼을 확인하는 것이 안전합니다).

언어 버전을 올렸다고 끝이 아닙니다. CallerArgumentExpressionAttribute 타입 자체System.Runtime 어셈블리에 정의되어 있어야 합니다. Unity의 .NET Standard 2.1 프로파일에는 이 타입이 없습니다. 컴파일러 입장에서는 "특성을 박을 곳을 못 찾아서" 에러를 뱉습니다.

✅ 해결 방법 1 — 프로젝트에 직접 폴리필(polyfill) 정의

가장 단순한 방법은 프로젝트 어딘가에 빈 어트리뷰트 클래스를 직접 정의하는 것입니다. 컴파일러는 이 타입의 풀 네임만 매칭되면 받아들입니다.

C#
// Assets/Scripts/Polyfills/CallerArgumentExpressionAttribute.cs
#if !NET6_0_OR_GREATER
namespace System.Runtime.CompilerServices
{
    [AttributeUsage(AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)]
    internal sealed class CallerArgumentExpressionAttribute : Attribute
    {
        public CallerArgumentExpressionAttribute(string parameterName)
        {
            ParameterName = parameterName;
        }

        public string ParameterName { get; }
    }
}
#endif
#if NET6_0_OR_GREATER — 전처리 지시문 (Preprocessor directive) 컴파일 시점에 해당 조건이 참이면 코드 블록을 포함하고, 거짓이면 제외한다. 여기서는 .NET 6 이상에서만 BCL의 진짜 어트리뷰트가 제공되므로, Unity처럼 더 낮은 환경에서만 폴리필을 활성화하기 위해 사용한다.

internal 로 선언했기 때문에 다른 어셈블리와 충돌하지 않습니다. 실제 BCL이 이 타입을 제공하는 환경에서는 #if !NET6_0_OR_GREATER 가 거짓이라 폴리필이 빠집니다.

✅ 해결 방법 2 — PolySharp NuGet 패키지

매번 폴리필을 직접 쓰는 게 번거롭다면 PolySharp 같은 소스 제너레이터(Source Generator) 기반 패키지를 사용할 수 있습니다. PolySharp 은 현재 타깃 프레임워크에 누락된 C# 언어 기능 어트리뷰트들을 자동으로 채워 주므로, Unity처럼 구버전 BCL을 쓰는 환경에서도 별도 작성 없이 [CallerArgumentExpression] 을 사용할 수 있습니다 (Unity 프로젝트에서 NuGet 패키지를 쓰려면 NuGetForUnity 같은 도구가 필요합니다).

⚠️ Unity의 가드 메서드 호출 제약

폴리필을 깔아도 한 가지 주의할 점이 있습니다. ArgumentNullException.ThrowIfNull 같은 BCL 가드 메서드 자체는 .NET 6 이상에만 있습니다. Unity의 BCL에는 이 메서드가 없습니다. 따라서 직접 가드 헬퍼를 만들어 써야 합니다.

C#
// Assets/Scripts/Common/Guard.cs
using System;
using System.Runtime.CompilerServices;

public static class Guard
{
    public static void NotNull<T>(
        T? value,
        [CallerArgumentExpression(nameof(value))] string? expr = null)
        where T : class
    {
        if (value is null) throw new ArgumentNullException(expr);
    }

    public static void Positive(
        int value,
        [CallerArgumentExpression(nameof(value))] string? expr = null)
    {
        if (value <= 0)
            throw new ArgumentOutOfRangeException(expr, value, "양수여야 합니다.");
    }
}

// 호출
Guard.NotNull(weapon);              // ParamName: "weapon"
Guard.Positive(stats.AttackPower);  // ParamName: "stats.AttackPower"

⚠️ IL2CPP 환경

Unity 모바일 빌드는 IL2CPP를 통해 IL을 C++로 변환합니다. CallerArgumentExpression 으로 박힌 문자열은 단순 문자열 리터럴이라 IL2CPP가 그대로 C++ 문자열로 옮겨 줍니다. 따라서 모바일 빌드에서도 동일하게 동작하며, 추가 비용이나 호환성 문제가 없습니다.


6. C# 버전별 변화

C# 5 (2012) — Caller* 시리즈 도입

CallerMemberName, CallerFilePath, CallerLineNumber 가 추가되었습니다. 주로 INotifyPropertyChanged 패턴이나 로깅 시 호출 위치 추적에 사용되었습니다.

C#
// C# 5 — 호출자 정보 자동 주입의 시작
public static void Log(
    string message,
    [CallerMemberName] string member = "",
    [CallerFilePath] string file = "",
    [CallerLineNumber] int line = 0)
{
    Console.WriteLine($"[{file}:{line} {member}] {message}");
}

C# 9 이하 — 가드는 손으로

Caller* 시리즈가 호출 위치는 자동으로 알려 줬지만, 인수의 표현식 자체 는 알려 주지 못했습니다. 그래서 nameof 를 직접 적는 보일러플레이트가 표준이었습니다.

C#
// C# 9 이하 — 매번 nameof 직접 작성
public void SetTarget(GameObject target)
{
    if (target == null) throw new ArgumentNullException(nameof(target));
    this.target = target;
}

C# 10 (2021) — CallerArgumentExpression 추가

호출자의 인수 표현식을 자동으로 박는 특성이 추가되었습니다. ArgumentNullException.ThrowIfNull 가 .NET 6에 함께 도입되어 한 줄 가드가 표준이 되었습니다.

C#
// C# 10 + .NET 6 — 한 줄 가드
public void SetTarget(GameObject target)
{
    ArgumentNullException.ThrowIfNull(target);
    this.target = target;
}

C# 10 + .NET 6 → 7 → 8 — 가드 메서드의 확장

언어 기능 자체는 C# 10에서 끝났지만, 이를 활용한 BCL 가드 메서드들은 .NET 7·8을 거치며 빠르게 늘었습니다. ThrowIfNullOrEmpty(7), ThrowIfNegative·ThrowIfZero·ThrowIfGreaterThan(8) 등이 차례로 추가되어 거의 모든 일반적인 검증을 한 줄로 처리할 수 있게 되었습니다.

C#
// .NET 8 시대의 가드 코드
public void TakeDamage(int amount, float multiplier)
{
    ArgumentOutOfRangeException.ThrowIfNegative(amount);
    ArgumentOutOfRangeException.ThrowIfNegativeOrZero(multiplier);
    HP -= (int)(amount * multiplier);
}

IL 비교 — C# 9 vs C# 10

같은 동작을 하는 코드의 IL은 다음과 같이 변합니다.

IL
// C# 9 — nameof는 마지막 식별자만 박힘
IL_0000: ldarg.0
IL_0001: ldfld string Player::Address
IL_0006: ldstr "Address"                  // ★ nameof(user.Address) 결과
IL_000b: call void OldNotNull(object, string)

// C# 10 — 호출 표현식 전체가 박힘
IL_000e: ldstr "user?.Address"            // ★ CallerArgumentExpression 자동 주입
IL_0013: call void NewNotNull(object, string)

nameof(user.Address)"Address" 만 만드는 반면, CallerArgumentExpression"user?.Address" 라는 호출 표현식 전체 를 그대로 박습니다. 메시지의 정보량이 근본적으로 다릅니다.


7. 정리

이 글의 핵심을 한 페이지로 압축합니다.

한 줄 요약

[CallerArgumentExpression(nameof(x))] string? expr = null 매개변수를 두면, 호출자가 x 자리에 적은 표현식 텍스트가 컴파일 타임에 expr 로 자동 주입된다. 가드·단언 메시지의 정보량을 무료로 끌어올리는 C# 10 기능이다.

체크리스트

  • [ ] 새 메서드를 작성할 때 인수 검증은 ArgumentNullException.ThrowIfNull 등 BCL 가드 메서드를 우선 사용한다 (.NET 6+).
  • [ ] 직접 가드 헬퍼를 만들 때는 [CallerArgumentExpression(nameof(파라미터))] string? expr = null 패턴을 따른다.
  • [ ] 선택 매개변수 로만 사용한다 (= null 기본값 필수).
  • [ ] 매개변수 이름은 반드시 nameof() 로 적어 오타·리네임 위험을 차단한다.
  • [ ] Unity 2022.x 이하에서는 사용하지 않거나, 2023.1+ + 폴리필 조합으로 사용한다.
  • [ ] 폴리필은 프로젝트에 빈 어트리뷰트 클래스를 직접 정의하거나 PolySharp 같은 소스 제너레이터를 사용한다.
  • [ ] BCL의 ThrowIfNull 류 메서드는 .NET 6+ 전용이므로, Unity에서는 동일 패턴의 자체 Guard 헬퍼를 만든다.
  • [ ] 호출자가 두 번째 인수를 직접 넘기면 자동 주입은 무시된다는 점을 기억한다.

핵심 원리 한 그림

단계 어디서 일어나는가 비용
메서드 정의에 [CallerArgumentExpression(...)] 부착 라이브러리 컴파일 시점 0 (메타데이터만)
호출 측에서 표현식 텍스트를 IL의 ldstr 로 박음 호출자 컴파일 시점 0 (런타임 비용 없음)
메서드 본문이 박힌 문자열을 예외 메시지에 사용 런타임 (예외 발생 시점) 일반 문자열 인자 한 번

C# 10이 만든 한 줄짜리 변화지만, 가드 코드의 양·메시지 품질·디버깅 효율에서 모두 큰 차이를 만듭니다. .NET 6 이상 환경이라면 무조건 사용하고, Unity 환경이라면 폴리필 또는 자체 헬퍼로 동일한 이점을 가져올 수 있습니다.

반응형

+ Recent posts