반응형

[PART12.제네릭·델리게이트·람다·LINQ(9/18)] 람다의 폐기 매개변수 _ (C# 9)

시그니처는 강제되지만 값은 필요 없을 때 / (_, _) => ... 한 줄로 끝내는 법 / 같은 _를 두 번 써도 되는 진짜 이유 / 한 번만 쓴 _가 폐기가 아닌 함정


1. [문제 제기] — 시그니처는 강제되는데 값은 필요 없을 때

Unity에서 버튼을 누르면 효과음만 한 번 재생하는 기능을 만든다고 가정해 봅니다. C# 표준 이벤트 패턴은 항상 두 개의 인자를 요구합니다.

C#
public event EventHandler Clicked;   // (object sender, EventArgs e) 시그니처 강제

값을 안 쓰지만 두 인자는 받아야 합니다. C# 8까지는 이렇게 써야 했습니다.

C#
button.Clicked += (sender, e) => SoundManager.Play("click");
button.Clicked += (s, _) => SoundManager.Play("click");      // 한쪽은 _도 가능
button.Clicked += (_1, _2) => SoundManager.Play("click");    // 둘 다 무시? 이름을 다르게

마지막 줄이 못생긴 이유는 분명합니다. 컴파일러가 같은 람다 안에 같은 이름 두 개를 못 받기 때문에 _1·_2 같은 의미 없는 이름을 어쩔 수 없이 붙인 것입니다. "안 쓴다"는 의도가 코드에서 사라지고, 대신 "이름이 두 개 필요해서 붙였다"는 잡음만 남습니다.

C# 9는 이 잡음을 한 줄로 정리합니다.

C#
button.Clicked += (_, _) => SoundManager.Play("click");

이 글은 이 한 줄이 어떻게 가능한지, 왜 이상하게도 _를 한 번만 쓰면 폐기가 아닌지, 그리고 IL 레벨에서는 무엇이 보존되고 무엇이 사라지는지를 다룹니다.


2. [개념 정의] — "두 번 등장하면 폐기, 한 번이면 변수"

무대 위 더미 인형 비유

연극 무대에 등장인물 두 명이 필요한데 둘 다 대사가 없다고 해봅시다. 배우를 두 명 캐스팅하기는 부담스럽고, 그렇다고 자리를 비워두면 무대 구성이 무너집니다. 이때 더미 인형 두 개를 같은 위치에 세웁니다. 둘 다 이름이 "인형"이지만 각자의 자리는 분명합니다. 무대(델리게이트 시그니처)는 두 자리를 요구하지만, 대본(람다 본문)은 그 자리를 가리키지 않습니다.

폐기 매개변수가 정확히 그 더미 인형입니다. 시그니처상 자리는 차지하지만 본문에서는 호출되지 않는 매개변수입니다.

두 가지 모드를 한 그림으로

가능 (값 = 5 → 50)

기본 코드로 두 모드 확인

_ (밑줄) — 폐기(Discard) "이 자리에 값이 들어오지만 사용하지 않겠다"를 선언하는 자리 표시자. 람다 매개변수 위치에 두 번 이상 나타나면 모두 폐기로 처리되고, 본문에서 그 이름으로 접근할 수 없다. 한 번만 나타나면 하위 호환을 위해 일반 매개변수로 남는다.
예시: Action<int, int> log = (_, _) => Console.WriteLine("clicked"); 두 인자를 받지만 본문은 두 값 모두 무시
C#
using System;

class Program
{
    static void Main()
    {
        // 케이스 A: _가 한 번 — 일반 매개변수 (이름이 _인 변수)
        Func<int, int> single = (_) => _ * 10;
        Console.WriteLine(single(5));   // 50

        // 케이스 B: _가 두 번 — 모두 폐기
        Action<int, int> dual = (_, _) => Console.WriteLine("clicked");
        dual(1, 2);                     // "clicked"
    }
}

single 람다 본문의 _는 호출 인자 5를 가리키므로 _ * 10 = 50이 됩니다. 반면 dual 본문에서는 _라는 이름으로 어떤 인자에도 접근할 수 없습니다(시도하면 CS0103: '_' 이름이 현재 컨텍스트에 없습니다).

IL — 컴파일러는 무엇을 만들어냈는가

/il-analysis 결과(Release 빌드, .NET 8 SDK, <LangVersion>9</LangVersion>):

IL
.class nested private auto ansi sealed beforefieldinit '<>c'
    extends [System.Runtime]System.Object
{
    // 케이스 A: _ 한 번
    .method assembly hidebysig
        instance int32 '<Main>b__0_0' (
            int32 _              // 매개변수 이름이 그대로 _ 로 보존
        ) cil managed
    {
        IL_0000: ldarg.1         // 1번 인자(=_)를 스택에 적재
        IL_0001: ldc.i4.2
        IL_0002: mul
        IL_0003: ret
    }

    // 케이스 B: _ 두 번 — IL은 이름이 같아도 허용한다
    .method assembly hidebysig
        instance void '<Main>b__0_1' (
            int32 _,             // 두 매개변수 모두
            int32 _              // 같은 이름 _ 로 그대로 기록
        ) cil managed
    {
        IL_0000: ldstr "clicked"
        IL_0005: call void [System.Console]System.Console::WriteLine(string)
        IL_000a: ret             // 본문은 매개변수를 한 번도 참조하지 않음
    }
}

핵심 두 가지를 짚겠습니다.

  1. IL 메타데이터에 매개변수 이름이 그대로 _, _로 보존됩니다. IL/CLI는 같은 메서드 안에 매개변수 이름이 중복되어도 허용합니다 — 이름은 디버그·리플렉션 정보일 뿐 호출 규약은 위치(인덱스)로 정해지기 때문입니다. "이름이 같아서 못 쓴다"는 제약은 오직 C# 컴파일러가 강제하는 언어 규칙입니다.
  2. 케이스 B는 본문에서 ldarg.1·ldarg.2가 단 한 번도 등장하지 않습니다. 폐기는 "값을 안 쓴다"는 약속이고, 컴파일러는 그 약속이 지켜지도록 본문에서 이름을 가리는 통로 자체를 끊어버린 것입니다.

C# 컴파일러가 만든 람다 본체 메서드 이름이 <Main>b__0_1 같은 형태인 이유는 [클로저 편(7번)](#)에서 설명했습니다 — 컴파일러가 람다를 메서드로 바꾸어 캐시 클래스 <>c에 정적 메서드로 굳혀 둔 결과입니다.


3. [내부 동작] — C# 컴파일러가 폐기를 강제하는 방식

매개변수 이름 처리 규칙 (C# 9 변경점)

C# 9 이전과 이후의 규칙을 한 그림으로 비교합니다.

람다 매개변수 목록에서 `_`를 처리하는 규칙

한 번 등장 vs 두 번 등장의 컴파일 결과 비교

같은 .NET 8 SDK에서 두 케이스를 직접 빌드해 비교합니다.

C#
using System;

class Program
{
    static void Main()
    {
        // 한 번만 — 통과
        Func<int, int> single = (_) => _ * 10;
        Console.WriteLine(single(5));

        // 두 번이지만 본문에서 접근 시도 — 실패
        Action<int, int> dual = (_, _) => Console.WriteLine(_);
    }
}

빌드 결과:

$ dotnet build -c Release
Program.cs(9,61): error CS0103: '_' 이름이 현재 컨텍스트에 없습니다.

single 람다는 _가 한 번만 등장하므로 일반 매개변수입니다. 본문의 _는 그 인자를 가리킵니다. 반면 dual 람다는 _가 두 번 등장하므로 컴파일러가 두 매개변수를 모두 폐기로 만들었고, 본문이 Console.WriteLine(_)처럼 그 이름을 부르려 하자 "그 이름은 스코프에 없다"고 잘라냅니다. 같은 코드 안에 두 모드가 공존할 수 있고, 판단 단위는 하나의 람다(매개변수 이름 목록)입니다.

C# 8에서 같은 코드는 어떤 에러가 나는가

<LangVersion>8</LangVersion>으로 강제하고 같은 코드를 빌드합니다.

$ dotnet build -c Release
Program.cs(4,42): error CS8400: '람다 무시 항목 매개 변수' 기능은 C# 8.0에서 사용할 수 없습니다. 언어 버전 9.0 이상을 사용하세요.

흔히 "C# 8에서는 중복 매개변수 오류(CS0100)가 난다"고 설명되지만, 실제 .NET 8 SDK는 이 코드를 보자마자 CS8400(언어 버전 부족)로 잘라냅니다. 컴파일러가 C# 9 신규 기능 사용을 먼저 감지하고, 매개변수 중복 검사까지 가기 전에 거부하는 것입니다. 옛 C# 8 컴파일러였다면 의미상 CS0100이 떴겠지만, 현행 SDK가 보여주는 진단은 CS8400입니다.

IL 측면에서의 무차별성

다시 IL을 봅니다 — 일반 매개변수 람다와 폐기 매개변수 람다는 IL 레벨에서 구분되지 않습니다.

IL
// (_, _) => Console.WriteLine("clicked")  ← C# 폐기
.method assembly hidebysig instance void '<Main>b__0_1' (
    int32 _,
    int32 _
) cil managed { ... }

// (x, y) => Console.WriteLine("xy")        ← C# 일반
.method assembly hidebysig instance void '<Main>b__0_2' (
    int32 x,
    int32 y
) cil managed { ... }

차이는 단지 매개변수 이름 문자열뿐이고, 메서드 시그니처(타입, 호출 규약, 위치)는 동일합니다. 또 두 람다 모두 아무 변수도 캡처하지 않으므로(클로저 편 참고) 컴파일러는 두 람다를 <>c라는 캐시 클래스의 정적 인스턴스에 한 번만 만들어 두고 재사용합니다. 폐기 매개변수라고 해서 일반 람다보다 더 가벼워지거나 무거워지지 않습니다 — 시그니처 잡음을 줄이는 문법 설탕(syntactic sugar)일 뿐, 런타임 비용 차이는 없습니다.


4. [실전 적용] — 어디서 쓰면 빛나는가

이벤트 핸들러: Before/After

Unity가 아닌 표준 .NET EventHandler 시그니처를 먼저 봅니다 — Unity의 UnityEvent도 같은 패턴이라 사고방식이 동일합니다.

C#
// Before — C# 8 스타일, 의미 없는 이름이 잡음
public class TitleScene
{
    public event EventHandler StartButtonPressed;

    public void Bind()
    {
        StartButtonPressed += (sender, args) => SceneLoader.Load("Lobby");
    }
}
C#
// After — C# 9 폐기 매개변수
public class TitleScene
{
    public event EventHandler StartButtonPressed;

    public void Bind()
    {
        StartButtonPressed += (_, _) => SceneLoader.Load("Lobby");
    }
}

/il-analysis 결과 — 두 람다는 IL 레벨에서 매개변수 이름만 다르고 동일합니다.

IL
// Before
.method assembly hidebysig instance void '<Bind>b__1_0' (
    object sender,
    class [System.Runtime]System.EventArgs args
) cil managed { ... }

// After
.method assembly hidebysig instance void '<Bind>b__1_0' (
    object _,
    class [System.Runtime]System.EventArgs _
) cil managed { ... }

성능 차이는 0입니다. 변하는 것은 읽는 사람의 인지 부담입니다. (sender, args)를 보면 독자는 잠시 멈춰 "이걸 본문에서 쓰나?" 하고 확인합니다. (_, _)는 그 의문 자체를 차단합니다.

Unity의 Button.onClick 시그니처

Unity의 UnityEngine.UI.Button.onClickUnityEvent(인자 없음)라서 폐기 매개변수가 필요 없습니다. 그러나 사용자 정의 UnityEvent<T>UnityEvent<T1,T2>는 인자를 가집니다.

C#
using UnityEngine;
using UnityEngine.Events;

public class HpBar : MonoBehaviour
{
    [System.Serializable]
    public class HealthChanged : UnityEvent<int, int> { } // (현재값, 최대값)

    public HealthChanged onChanged;

    void Start()
    {
        // 현재값/최대값 둘 다 안 쓰고, 단순히 흔들리기만 하는 리스너
        onChanged.AddListener((_, _) => StartCoroutine(Shake()));
    }

    System.Collections.IEnumerator Shake() { yield return null; }
}

(curr, max) => ...로 두면 "쓰는 줄 알았는데 왜 안 쓰지?"라는 의문을 매번 유발합니다. (_, _)는 "이 리스너는 값과 무관하게 모션만 트리거한다"를 한눈에 보여줍니다.

LINQ — 인덱스 오버로드에서 인덱스만 무시

LINQ Select에는 (item, index) 두 인자를 받는 오버로드가 있습니다. 인덱스가 필요해 그 오버로드를 골랐는데 정작 본문에서 항목만 쓰는 경우, 폐기로 의도를 명확히 합니다.

C#
// Before — i가 안 쓰여 보이는데 진짜 안 쓰는지 한 번 더 확인 필요
var labels = items.Select((item, i) => $"[{item.Tag}]");

// After — i 자리는 단지 오버로드를 맞추기 위한 자리
var labels = items.Select((item, _) => $"[{item.Tag}]");
단, 실수Select(item => ...) 단일 인자 오버로드 대신 Select((item, _) => ...) 두 인자 오버로드를 고른 게 아닌지는 검토하세요. 폐기는 "안 쓴다는 표현"이지 "이 오버로드를 골라야 했다는 정당화"가 아닙니다.

판단 기준 한 줄

본문에서 안 쓰는 매개변수가 둘 이상이면 (_, _), 하나면 의미 있는 이름을 그대로 두거나 (_)로 명시.

폐기 매개변수가 적합한 곳은 결국 "시그니처가 외부에 의해 강제되는 콜백"입니다 — EventHandler<T>, UnityAction<T1,T2>, LINQ 인덱스 오버로드, 콜백 패턴이 그렇습니다.


5. [함정과 주의사항]

함정 1: _를 한 번만 쓰고 폐기로 착각하기

가장 흔한 함정입니다. "안 쓸 거니까 _로 두면 되겠지"라는 생각으로 한 번만 _를 쓰면, 컴파일러는 그것을 이름이 _인 일반 변수로 처리합니다.

C#
// ❌ 폐기로 의도했지만 실제로는 일반 매개변수
Func<int, int> f = (_) => _ + 100;
Console.WriteLine(f(5));   // 105 — _가 매개변수 값으로 읽힘

본인이 작성할 때는 의도를 알지만, 다른 사람이 읽을 때는 "왜 폐기처럼 생긴 매개변수 값을 쓰지?"라며 혼동합니다. 더 위험한 변형은 외곽 스코프에 같은 이름의 변수가 있을 때입니다.

C#
int _ = 999;
Func<int, int> f = (_) => _ * 2;   // 람다의 _가 외곽 _를 섀도잉
Console.WriteLine(f(7));   // 14 — 람다 매개변수 값(7)이 우선
Console.WriteLine(_);      // 999 — 외곽 변수는 그대로

람다 매개변수 _가 외곽 변수 _를 가립니다(섀도잉). 외곽 값이 의도치 않게 변하지는 않지만, 읽는 사람은 본문의 _가 어느 쪽인지 매번 추적해야 합니다.

C#
// ✅ 한 자리만 안 쓴다면 의도를 드러내는 이름을 그대로 두기
Func<int, int> double_ = (x) => x * 2;

// ✅ 정말 폐기 의도라면 두 자리 이상에서만 활용
Action<int, int> tick = (_, _) => Frame.Advance();

/il-analysis — 한 번 사용 시 IL은 일반 매개변수와 완전히 동일합니다.

IL
// (_) => _ * 2  ← C#에선 _ 이지만 IL에선 그저 매개변수 이름
.method assembly hidebysig instance int32 '<Main>b__0_0' (
    int32 _
) cil managed
{
    IL_0000: ldarg.1   // 1번 인자(_)를 그대로 사용
    IL_0001: ldc.i4.2
    IL_0002: mul
    IL_0003: ret
}

본문이 ldarg.1로 매개변수를 실제 사용하는 모습이 보입니다 — 폐기가 아니라는 결정적 증거입니다.

함정 2: 패턴 매칭의 _와 헷갈리기

같은 글자지만 의미가 다릅니다. 상황을 보고 판단해야 합니다.

C#
object value = (1, "hello");

// 패턴 매칭의 _ : "이 자리는 어떤 값이든 매칭"
if (value is (int, _)) { /* 두 번째 항목 타입 무관, 매칭 자체는 성공 */ }

// switch arm의 _ : "어떤 값이든 (default와 유사)"
string Describe(object v) => v switch
{
    int i => "정수 " + i,
    _     => "기타"     // 모든 나머지를 매칭
};

// 람다 매개변수의 _ : "이 자리에 인자가 들어오지만 본문에서 안 쓴다"
Action<int> log = _ => Console.WriteLine("called");

차이를 한 줄로 정리하면 — 패턴 매칭의 _값에 대한 와일드카드, 람다의 _선언된 이름에 대한 무시입니다.

함정 3: 람다 자체를 핸들러로 등록한 뒤 해제하지 못하기

폐기 매개변수와 직접 관련은 없지만, (_, _) => ... 패턴으로 한 줄에 핸들러를 끼워 넣을 때 함께 따라오는 위험입니다.

C#
// ❌ 람다를 인라인으로 등록 — 해제 불가
button.Clicked += (_, _) => SoundManager.Play("click");
// button.Clicked -= ???  ← 같은 람다 인스턴스를 다시 만들 수 없으므로 해제 못 함
C#
// ✅ 람다를 필드에 잡아두기 — 해제 가능
private EventHandler _clickHandler;

void OnEnable()
{
    _clickHandler = (_, _) => SoundManager.Play("click");
    button.Clicked += _clickHandler;
}

void OnDisable()
{
    button.Clicked -= _clickHandler;
}

Unity 모바일에서 GC(Garbage Collector — 사용하지 않는 객체의 메모리를 자동으로 회수하는 런타임 구성요소)는 짧은 프레임 시간에 큰 부담입니다. 핸들러를 해제하지 못하면 발행자(이벤트가 있는 객체)가 살아 있는 동안 구독자(이 람다가 캡처한 객체들)도 같이 살아남아 누수의 원인이 됩니다. 폐기 매개변수는 캡처를 줄여주지 않습니다 — 캡처 여부는 본문이 외곽 변수를 참조하는지로 결정됩니다.

함정 4: 폐기 매개변수에 기본값 줄 수 없음

C# 12부터 람다 매개변수에 기본값을 줄 수 있지만, 폐기 매개변수에는 줄 수 없습니다.

C#
// ✅ 일반 매개변수 기본값 (C# 12)
var add = (int x, int y = 1) => x + y;

// ❌ 폐기 매개변수에 기본값 — 컴파일 오류
var bad = (int _, int _ = 10) => 0;   // CS-error: 폐기에는 기본값 불가

논리적으로 자연스러운 제약입니다 — 본문에서 쓸 수 없는 값에 기본값을 정하는 게 의미가 없기 때문입니다.

함정 5: 폐기인지 아닌지 IL로 구분하려 하기

앞 IL 분석에서 보았듯, 일반 매개변수 람다와 폐기 매개변수 람다는 IL 레벨에서 구분되지 않습니다. 매개변수 이름이 _로 보존되어도 그것만으로는 "C# 코드에서 폐기였다"를 단정할 수 없습니다 — 일반 변수 이름이 _였을 수도 있습니다. 폐기 여부는 C# 컴파일러가 본문 분석 단계에서만 강제하는 언어 규칙이며, 컴파일된 어셈블리에는 그 흔적이 남지 않습니다.


6. [C# 버전별 변화]

C# 7 (2017): 패턴 매칭의 폐기

_를 폐기 의미로 처음 도입했지만, 람다 매개변수와는 무관했습니다. is 패턴, out _, switch 패턴에서만 사용 가능했습니다.

C#
if (int.TryParse("42", out _)) { /* 결과 무시, 성공 여부만 본다 */ }

C# 8 (2019): 람다 매개변수에 _ 두 번은 여전히 불가

C#
// C# 8 — 컴파일 오류
Action<int, int> b = (int _, int _) => { };
// CS0100: '_' 이라는 매개변수 이름이 중복됩니다.

우회 방법으로 (_1, _2)(_, __) 같은 변형을 썼습니다.

C# 9 (2020): 람다·익명 메서드의 폐기 매개변수

이 글에서 다룬 변경입니다. 람다와 익명 메서드의 매개변수 목록에서 _가 두 번 이상 등장하면 모두 폐기로 처리합니다. 한 번만 등장하면 하위 호환을 위해 일반 매개변수로 남겨둡니다.

C#
// C# 9 — 정상 컴파일
Action<int, int> b = (_, _) => { };
EventHandler h    = (_, _) => Console.WriteLine("clicked");

같은 시기 도입된 [정적 람다 static (C# 9)](#)와 함께 쓰면 캡처도 막고 잡음도 줄일 수 있습니다.

C#
button.Clicked += static (_, _) => Logger.Info("click");

C# 10~13: 폐기 매개변수 자체에는 변경 없음

C# 10 자연 타입, C# 12 람다 기본값, C# 14 매개변수 수정자 등 람다 주변 기능은 계속 확장됐지만, 폐기 매개변수의 시맨틱은 C# 9 이후 그대로입니다. C# 12 기본값과의 결합 제약은 함정 4에서 다뤘습니다.


7. [정리]

한 줄 요약

_가 람다 매개변수에 두 번 이상 등장하면 모두 폐기, 한 번이면 일반 변수. C# 9에서 도입된 시그니처 잡음 제거용 문법 설탕이고, IL/런타임 비용은 일반 람다와 동일하다.

체크리스트

  • [ ] 본문에서 안 쓰는 매개변수가 두 개 이상이면 (_, _)로 표현했다.
  • [ ] _한 번만 쓸 때는 폐기가 아니라 일반 매개변수임을 인지하고, 의도가 폐기라면 다른 자리도 비워서 두 개 이상으로 만들었다.
  • [ ] 폐기 매개변수의 본문에서 _로 접근하지 않았다(시도 시 CS0103).
  • [ ] 패턴 매칭의 _(값 와일드카드)와 람다 매개변수 _(이름 무시)를 혼동하지 않았다.
  • [ ] 인라인 람다를 이벤트에 직접 등록하지 않았다 — 해제하려면 필드에 잡아둔다.
  • [ ] 폐기 매개변수에는 기본값을 주지 않는다 (C# 12 결합 제약).

다음 주제로 가는 다리

폐기 매개변수는 람다 시리즈의 가장 작은 변경입니다. 다음 주제 [람다 자연 타입·반환 타입·특성 (C# 10)](#)에서는 람다 자체에 더 풍부한 표현을 부여하는 방법을 다룹니다 — var f = (int x) => x + 1 같은 자연 타입 추론과 var f = int (string s) => s.Length 같은 명시적 반환 타입이 그것입니다.

반응형

+ Recent posts