[PART12.제네릭·델리게이트·람다·LINQ(9/18)] 람다의 폐기 매개변수 _ (C# 9)
시그니처는 강제되지만 값은 필요 없을 때 / (_, _) => ... 한 줄로 끝내는 법 / 같은 _를 두 번 써도 되는 진짜 이유 / 한 번만 쓴 _가 폐기가 아닌 함정
목차
1. [문제 제기] — 시그니처는 강제되는데 값은 필요 없을 때
Unity에서 버튼을 누르면 효과음만 한 번 재생하는 기능을 만든다고 가정해 봅니다. C# 표준 이벤트 패턴은 항상 두 개의 인자를 요구합니다.
public event EventHandler Clicked; // (object sender, EventArgs e) 시그니처 강제
값을 안 쓰지만 두 인자는 받아야 합니다. C# 8까지는 이렇게 써야 했습니다.
button.Clicked += (sender, e) => SoundManager.Play("click");
button.Clicked += (s, _) => SoundManager.Play("click"); // 한쪽은 _도 가능
button.Clicked += (_1, _2) => SoundManager.Play("click"); // 둘 다 무시? 이름을 다르게
마지막 줄이 못생긴 이유는 분명합니다. 컴파일러가 같은 람다 안에 같은 이름 두 개를 못 받기 때문에 _1·_2 같은 의미 없는 이름을 어쩔 수 없이 붙인 것입니다. "안 쓴다"는 의도가 코드에서 사라지고, 대신 "이름이 두 개 필요해서 붙였다"는 잡음만 남습니다.
C# 9는 이 잡음을 한 줄로 정리합니다.
button.Clicked += (_, _) => SoundManager.Play("click");
이 글은 이 한 줄이 어떻게 가능한지, 왜 이상하게도 _를 한 번만 쓰면 폐기가 아닌지, 그리고 IL 레벨에서는 무엇이 보존되고 무엇이 사라지는지를 다룹니다.
2. [개념 정의] — "두 번 등장하면 폐기, 한 번이면 변수"
무대 위 더미 인형 비유
연극 무대에 등장인물 두 명이 필요한데 둘 다 대사가 없다고 해봅시다. 배우를 두 명 캐스팅하기는 부담스럽고, 그렇다고 자리를 비워두면 무대 구성이 무너집니다. 이때 더미 인형 두 개를 같은 위치에 세웁니다. 둘 다 이름이 "인형"이지만 각자의 자리는 분명합니다. 무대(델리게이트 시그니처)는 두 자리를 요구하지만, 대본(람다 본문)은 그 자리를 가리키지 않습니다.
폐기 매개변수가 정확히 그 더미 인형입니다. 시그니처상 자리는 차지하지만 본문에서는 호출되지 않는 매개변수입니다.
두 가지 모드를 한 그림으로

기본 코드로 두 모드 확인
_(밑줄) — 폐기(Discard) "이 자리에 값이 들어오지만 사용하지 않겠다"를 선언하는 자리 표시자. 람다 매개변수 위치에 두 번 이상 나타나면 모두 폐기로 처리되고, 본문에서 그 이름으로 접근할 수 없다. 한 번만 나타나면 하위 호환을 위해 일반 매개변수로 남는다.
예시:Action<int, int> log = (_, _) => Console.WriteLine("clicked");두 인자를 받지만 본문은 두 값 모두 무시
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>):
.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 // 본문은 매개변수를 한 번도 참조하지 않음
}
}
핵심 두 가지를 짚겠습니다.
- IL 메타데이터에 매개변수 이름이 그대로
_,_로 보존됩니다. IL/CLI는 같은 메서드 안에 매개변수 이름이 중복되어도 허용합니다 — 이름은 디버그·리플렉션 정보일 뿐 호출 규약은 위치(인덱스)로 정해지기 때문입니다. "이름이 같아서 못 쓴다"는 제약은 오직 C# 컴파일러가 강제하는 언어 규칙입니다. - 케이스 B는 본문에서
ldarg.1·ldarg.2가 단 한 번도 등장하지 않습니다. 폐기는 "값을 안 쓴다"는 약속이고, 컴파일러는 그 약속이 지켜지도록 본문에서 이름을 가리는 통로 자체를 끊어버린 것입니다.
C# 컴파일러가 만든 람다 본체 메서드 이름이 <Main>b__0_1 같은 형태인 이유는 [클로저 편(7번)](#)에서 설명했습니다 — 컴파일러가 람다를 메서드로 바꾸어 캐시 클래스 <>c에 정적 메서드로 굳혀 둔 결과입니다.
3. [내부 동작] — C# 컴파일러가 폐기를 강제하는 방식
매개변수 이름 처리 규칙 (C# 9 변경점)
C# 9 이전과 이후의 규칙을 한 그림으로 비교합니다.

한 번 등장 vs 두 번 등장의 컴파일 결과 비교
같은 .NET 8 SDK에서 두 케이스를 직접 빌드해 비교합니다.
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 레벨에서 구분되지 않습니다.
// (_, _) => 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도 같은 패턴이라 사고방식이 동일합니다.
// Before — C# 8 스타일, 의미 없는 이름이 잡음
public class TitleScene
{
public event EventHandler StartButtonPressed;
public void Bind()
{
StartButtonPressed += (sender, args) => SceneLoader.Load("Lobby");
}
}
// After — C# 9 폐기 매개변수
public class TitleScene
{
public event EventHandler StartButtonPressed;
public void Bind()
{
StartButtonPressed += (_, _) => SceneLoader.Load("Lobby");
}
}
/il-analysis 결과 — 두 람다는 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.onClick은 UnityEvent(인자 없음)라서 폐기 매개변수가 필요 없습니다. 그러나 사용자 정의 UnityEvent<T>나 UnityEvent<T1,T2>는 인자를 가집니다.
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) 두 인자를 받는 오버로드가 있습니다. 인덱스가 필요해 그 오버로드를 골랐는데 정작 본문에서 항목만 쓰는 경우, 폐기로 의도를 명확히 합니다.
// 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: _를 한 번만 쓰고 폐기로 착각하기
가장 흔한 함정입니다. "안 쓸 거니까 _로 두면 되겠지"라는 생각으로 한 번만 _를 쓰면, 컴파일러는 그것을 이름이 _인 일반 변수로 처리합니다.
// ❌ 폐기로 의도했지만 실제로는 일반 매개변수
Func<int, int> f = (_) => _ + 100;
Console.WriteLine(f(5)); // 105 — _가 매개변수 값으로 읽힘
본인이 작성할 때는 의도를 알지만, 다른 사람이 읽을 때는 "왜 폐기처럼 생긴 매개변수 값을 쓰지?"라며 혼동합니다. 더 위험한 변형은 외곽 스코프에 같은 이름의 변수가 있을 때입니다.
int _ = 999;
Func<int, int> f = (_) => _ * 2; // 람다의 _가 외곽 _를 섀도잉
Console.WriteLine(f(7)); // 14 — 람다 매개변수 값(7)이 우선
Console.WriteLine(_); // 999 — 외곽 변수는 그대로
람다 매개변수 _가 외곽 변수 _를 가립니다(섀도잉). 외곽 값이 의도치 않게 변하지는 않지만, 읽는 사람은 본문의 _가 어느 쪽인지 매번 추적해야 합니다.
// ✅ 한 자리만 안 쓴다면 의도를 드러내는 이름을 그대로 두기
Func<int, int> double_ = (x) => x * 2;
// ✅ 정말 폐기 의도라면 두 자리 이상에서만 활용
Action<int, int> tick = (_, _) => Frame.Advance();
/il-analysis — 한 번 사용 시 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: 패턴 매칭의 _와 헷갈리기
같은 글자지만 의미가 다릅니다. 상황을 보고 판단해야 합니다.
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: 람다 자체를 핸들러로 등록한 뒤 해제하지 못하기
폐기 매개변수와 직접 관련은 없지만, (_, _) => ... 패턴으로 한 줄에 핸들러를 끼워 넣을 때 함께 따라오는 위험입니다.
// ❌ 람다를 인라인으로 등록 — 해제 불가
button.Clicked += (_, _) => SoundManager.Play("click");
// button.Clicked -= ??? ← 같은 람다 인스턴스를 다시 만들 수 없으므로 해제 못 함
// ✅ 람다를 필드에 잡아두기 — 해제 가능
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# 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 패턴에서만 사용 가능했습니다.
if (int.TryParse("42", out _)) { /* 결과 무시, 성공 여부만 본다 */ }
C# 8 (2019): 람다 매개변수에 _ 두 번은 여전히 불가
// C# 8 — 컴파일 오류
Action<int, int> b = (int _, int _) => { };
// CS0100: '_' 이라는 매개변수 이름이 중복됩니다.
우회 방법으로 (_1, _2)나 (_, __) 같은 변형을 썼습니다.
C# 9 (2020): 람다·익명 메서드의 폐기 매개변수
이 글에서 다룬 변경입니다. 람다와 익명 메서드의 매개변수 목록에서 _가 두 번 이상 등장하면 모두 폐기로 처리합니다. 한 번만 등장하면 하위 호환을 위해 일반 매개변수로 남겨둡니다.
// C# 9 — 정상 컴파일
Action<int, int> b = (_, _) => { };
EventHandler h = (_, _) => Console.WriteLine("clicked");
같은 시기 도입된 [정적 람다 static (C# 9)](#)와 함께 쓰면 캡처도 막고 잡음도 줄일 수 있습니다.
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 같은 명시적 반환 타입이 그것입니다.