[PART12.제네릭·델리게이트·람다·LINQ(6/18)] 람다식 — x => x * 2
=> 가 의미하는 바 / 표현식 람다 vs 문장 람다 / 컴파일러는 람다를 어떻게 일반 메서드로 바꾸는가 / C# 12 매개변수 기본값 / C# 14 ref·out·in·scoped 수정자
목차
1. [문제 제기] 한 번만 쓸 함수에 이름을 짓고 싶지 않을 때
Unity UI 작업 중 흔한 장면입니다. 버튼이 눌렸을 때 점수 1점 추가하고 효과음 한 번 재생. 이걸 위해 별도 메서드를 클래스 어딘가에 선언해야 한다면 어떨까요.
// Unity UI 콜백을 메서드로 분리한 경우 — 이름 짓기 자체가 부담
public class ScoreButton : MonoBehaviour
{
[SerializeField] private Button button;
[SerializeField] private AudioSource audio;
private int score;
void Start()
{
button.onClick.AddListener(OnClickAddScoreAndPlaySound);
// 콜백을 메서드로 분리하면 클래스 본문이 점점 늘어난다.
// OnClickStartGame, OnClickPause, OnClickOpenSettings ...
}
private void OnClickAddScoreAndPlaySound()
{
score++;
audio.Play();
}
}
콜백이 5개, 10개로 늘어나면 클래스에 일회용 메서드가 줄줄이 쌓입니다. 더 큰 문제는 [04 델리게이트] 와 [05 콜백 함수] 에서 본 것처럼 Where, Select, OrderBy 같은 LINQ 메서드는 매번 작은 람다를 인자로 받습니다. 이걸 전부 메서드로 분리하면 코드가 흐름을 잃습니다.
이 글에서 다루는 것
- 람다식 자체의 문법(표현식 람다 / 문장 람다 / 매개변수 괄호 규칙)
- 컴파일러가 람다를 일반 메서드로 변환하는 두 가지 방식 (
<>cvs<>c__DisplayClass)- C# 12 람다 매개변수 기본값 / C# 14 단순 람다의 수정자 지원
이 글에서 다루지 않는 것
- 클로저(캡처된 변수의 수명·함정)는 다음 [07 클로저] 에서
static람다로 캡처를 막는 기법은 [08 정적 람다] 에서- 폐기 매개변수
_, 자연 타입 추론은 [09], [10] 에서
람다식은 "이름 없는 함수를 식의 자리에 바로 쓰는 문법" 입니다. 본질은 그뿐인데, 컴파일러가 이걸 IL 로 바꿀 때 두 갈래로 나뉘기 때문에 성능 트레이드오프가 생기고, 그 갈래를 모르면 Update() 안에 무심코 람다를 쓰다가 GC 스파이크를 만나게 됩니다. 이 글은 거기까지 안내하는 게 목표입니다.
2. [개념 정의] => 왼쪽은 입력, 오른쪽은 결과
람다식의 형태 — 표현식 vs 문장
람다식은 매개변수 => 본문 형태로, => 왼쪽에 받을 매개변수, 오른쪽에 실행할 코드를 쓰는 익명 함수입니다. 본문이 한 줄 식이냐 여러 줄 블록이냐에 따라 두 가지 형태가 있습니다.
=>— 람다 연산자 (Lambda operator) "goes to" 라고 읽습니다. 왼쪽의 매개변수를 받아 오른쪽의 식 또는 블록으로 보낸다는 의미로, 매개변수 목록과 본문을 분리하는 역할을 합니다. C# 6 에서 도입된 식 본문 멤버(int X => 10;) 의=>와 같은 기호이지만 문법적 위치가 다르므로 헷갈리지 않도록 주의합니다.
예시:Func<int, int> square = x => x * x;x를 받아x * x를 반환하는 익명 함수를 만들어square변수에 담음.

// 표현식 람다 — 한 줄짜리 본문, 식의 결과가 곧 반환값
Func<int, int> doubler = x => x * 2;
// 문장 람다 — 중괄호로 묶고 return 을 명시
Func<int, int> doublerVerbose = x =>
{
int result = x * 2;
return result;
};
// 매개변수 0개 — 빈 괄호 필수
Action sayHello = () => Console.WriteLine("hello");
// 매개변수 2개 이상 — 괄호 필수
Func<int, int, int> add = (a, b) => a + b;
// 타입을 명시하면 1개여도 괄호 필수
Func<int, int> tripler = (int x) => x * 3;
핵심 규칙은 단 하나입니다 — 매개변수가 정확히 1개이고 타입을 생략할 수 있을 때만 괄호를 생략할 수 있습니다. 이 형태를 "단순 람다(simple lambda)" 라고 부르며, [05 콜백 함수] 와 [13 LINQ 메서드 구문] 에서 가장 자주 만나게 됩니다.
익명 메서드 — 람다의 옛날 표현
람다식이 등장하기 전 C# 2.0 에는 delegate 키워드를 쓴 익명 메서드가 있었습니다. 같은 동작을 두 가지로 비교해 보겠습니다.
// C# 2.0 — 익명 메서드: delegate 키워드 + 매개변수 타입 명시 필수
Func<int, int> doublerOld = delegate(int x) { return x * 2; };
// C# 3.0 람다식 — delegate 제거, 타입 추론, 식 본문 가능
Func<int, int> doublerNew = x => x * 2;
실제로 같은 IL 로 컴파일되며(둘 다 컴파일러 생성 메서드 + 델리게이트 인스턴스), 람다식은 익명 메서드의 문법적 후신입니다. 신규 코드는 람다식을 씁니다 — 익명 메서드는 거의 등장하지 않으며, 본 글은 람다식만 다룹니다.
3. [내부 동작] 람다는 컴파일 시점에 일반 메서드로 변환된다
CLR(Common Language Runtime, .NET 코드를 실제로 실행하는 가상 머신) 입장에서는 "람다식" 같은 개념이 없습니다. 컴파일러가 람다를 일반 클래스의 메서드로 변환하고, 그 메서드를 가리키는 델리게이트 인스턴스를 만들어 끼워 넣을 뿐입니다.
IL (Intermediate Language) — C# 컴파일러가 만들어내는 .NET 의 중간 언어. CLR 이 이 IL 을 다시 기계어로 번역하여 실행합니다. 이후 본문에서 "IL" 로 줄여 씁니다.
변환 방식은 외부 변수를 캡처하는지 여부 에 따라 두 갈래로 갈립니다. 이 차이가 람다의 성능 특성을 결정합니다.

캡처 없는 람다 — 숨김 클래스 <>c 의 정적 캐싱
class Program
{
static void Main()
{
Func<int, int> doubler = x => x * 2;
Console.WriteLine(doubler(5));
}
}
이 코드를 Release 모드로 컴파일하면 컴파일러는 Program 안에 <>c 라는 중첩 클래스를 만들고, 람다 본문을 그 클래스의 메서드로 옮깁니다.
.class nested private auto ansi sealed serializable beforefieldinit '<>c'
extends [System.Runtime]System.Object
{
.custom instance void [...]CompilerGeneratedAttribute::.ctor() = (...)
// 싱글톤 인스턴스 + 델리게이트 캐시 필드
.field public static initonly class Program/'<>c' '<>9'
.field public static class Func`2<int32, int32> '<>9__0_0'
// 정적 생성자 — 싱글톤을 한 번만 만든다
.method static void .cctor() cil managed
{
IL_0000: newobj instance void Program/'<>c'::.ctor()
IL_0005: stsfld class Program/'<>c' Program/'<>c'::'<>9'
IL_000a: ret
}
// 람다 본문이 인스턴스 메서드로 변환됨
.method assembly hidebysig instance int32 '<Main>b__0_0'(int32 x) cil managed
{
IL_0000: ldarg.1 // x 적재
IL_0001: ldc.i4.2 // 2 적재
IL_0002: mul // 곱셈
IL_0003: ret
}
}
.method private hidebysig static void Main() cil managed
{
.entrypoint
// 1) 캐시된 델리게이트 확인
IL_0000: ldsfld class Func`2<int32, int32> Program/'<>c'::'<>9__0_0'
IL_0005: dup
IL_0006: brtrue.s IL_001f // 캐시가 있으면 바로 사용
// 2) 처음일 때만 델리게이트 생성하고 캐시에 저장
IL_0008: pop
IL_0009: ldsfld class Program/'<>c' Program/'<>c'::'<>9'
IL_000e: ldftn instance int32 Program/'<>c'::'<Main>b__0_0'(int32)
IL_0014: newobj instance void Func`2<int32, int32>::.ctor(object, native int)
IL_0019: dup
IL_001a: stsfld class Func`2<int32, int32> Program/'<>c'::'<>9__0_0'
// 3) 호출
IL_001f: ldc.i4.5
IL_0020: callvirt instance !1 Func`2<int32, int32>::Invoke(!0)
IL_0025: call void [System.Console]System.Console::WriteLine(int32)
IL_002a: ret
}
IL 해설 — 핵심만:
<>c는 컴파일러가 자동 생성한 클래스로, 같은 클래스 안의 모든 캡처 없는 람다가 이 한 클래스에 모입니다. 이름 앞의<>는 일부러 사용자가 못 쓰는 식별자로 만들기 위한 표기입니다.<>9는<>c의 정적 인스턴스 —.cctor(정적 생성자) 가 프로그램 시작 시 단 한 번만 만듭니다.<>9__0_0은 델리게이트 캐시. 처음 람다를 만드는 호출에서newobj로Func<int,int>를 한 번만 생성한 뒤 정적 필드에 저장합니다.ldsfld→dup→brtrue.s패턴이 바로 "캐시 확인 → 있으면 재사용" 코드입니다.- 결과: 두 번째 호출부터는
ldsfld한 줄이면 끝. 람다 자체로 인한 힙 할당이 사라집니다(델리게이트 인스턴스 1개만 영구 유지).
캡처 있는 람다 — 디스플레이 클래스에 변수 옮기기
class Program
{
static void Main()
{
int factor = 2;
Func<int, int> multiplier = x => x * factor; // factor 캡처
Console.WriteLine(multiplier(5));
}
}
factor 는 Main 의 지역 변수인데 람다 안에서 사용됩니다. 컴파일러는 factor 의 수명을 람다와 맞추기 위해 별도 클래스로 옮깁니다.
// 캡처된 변수를 담는 디스플레이 클래스
.class nested private auto ansi sealed beforefieldinit '<>c__DisplayClass0_0'
extends [System.Runtime]System.Object
{
.field public int32 factor // 지역 변수가 인스턴스 필드로 승격됨
.method assembly hidebysig instance int32 '<Main>b__0'(int32 x) cil managed
{
IL_0000: ldarg.1 // x 적재
IL_0001: ldarg.0 // this (DisplayClass)
IL_0002: ldfld int32 Program/'<>c__DisplayClass0_0'::factor
IL_0007: mul
IL_0008: ret
}
}
.method private hidebysig static void Main() cil managed
{
.entrypoint
IL_0000: newobj instance void Program/'<>c__DisplayClass0_0'::.ctor() // ★ 매번 힙 할당
IL_0005: dup
IL_0006: ldc.i4.2
IL_0007: stfld int32 Program/'<>c__DisplayClass0_0'::factor // factor = 2
IL_000c: ldftn instance int32 Program/'<>c__DisplayClass0_0'::'<Main>b__0'(int32)
IL_0012: newobj instance void Func`2<int32, int32>::.ctor(object, native int) // ★ 델리게이트도 매번 새로
IL_0017: ldc.i4.5
IL_0018: callvirt instance !1 Func`2<int32, int32>::Invoke(!0)
IL_001d: call void [System.Console]System.Console::WriteLine(int32)
IL_0022: ret
}
IL 해설 — 핵심만:
<>c__DisplayClass0_0라는 새 클래스가 생기고, 원래 지역 변수였던factor가 그 클래스의 인스턴스 필드가 됩니다.Main의factor = 2대입은stfld(필드 저장) 로 바뀝니다.- 람다 본문은 이 디스플레이 클래스의 인스턴스 메서드
<Main>b__0이며,factor에 접근할 때ldfld(필드 적재) 로 읽습니다. - 가장 중요한 차이:
Main진입 시newobj로 디스플레이 클래스 인스턴스를 새로 만들고, 델리게이트도newobj로 새로 만듭니다. 캐싱 패턴이 사라졌습니다 — 매 호출마다 두 번의 힙 할당이 발생합니다. - 클래스 이름·필드 이름은 컴파일러가 정한 약속 —
<>c__DisplayClass{메서드인덱스}_{스코프인덱스}형식입니다.
이 변환의 부작용(루프 안에서 변수가 공유되는 함정·메모리 누수)은 다음 [07 클로저] 글의 주제이므로 본 글에서는 "변환 결과 매번 할당이 발생한다" 는 사실까지만 짚고 넘어갑니다.
4. [실전 적용] 캡처를 피해 람다를 캐싱 가능하게 만들기
같은 람다라도 어떻게 쓰느냐에 따라 캐싱되는 람다(할당 0) 가 되기도 하고, 매번 새로 할당되는 람다가 되기도 합니다. Unity 모바일 게임의 핫패스에서 이 차이는 즉시 GC 스파이크로 나타납니다.
Before — Update() 에서 캡처 있는 람다 매 프레임 생성
using System.Collections.Generic;
using UnityEngine;
public class EnemyManager : MonoBehaviour
{
[SerializeField] private List<Enemy> enemies;
[SerializeField] private Transform player;
void Update()
{
// ❌ player.position 을 캡처 — 매 프레임 DisplayClass + 델리게이트 신규 할당
Enemy nearest = enemies
.OrderBy(e => Vector3.SqrMagnitude(e.transform.position - player.position))
.FirstOrDefault();
if (nearest != null)
nearest.Highlight();
}
}
초당 60 프레임으로 실행되면 분당 3,600 개의 디스플레이 클래스 + Func 델리게이트 인스턴스 가 힙에 쌓입니다. Unity Profiler 의 GC Alloc 컬럼이 깜빡거리고, 일정 임계점에서 Boehm GC(Mono 백엔드의 가비지 컬렉터, IL2CPP 도 동일) 가 동작하며 1 프레임 분량의 정지가 생깁니다.
After — 람다 밖으로 변수를 빼서 캡처를 없앤다
using System.Collections.Generic;
using UnityEngine;
public class EnemyManager : MonoBehaviour
{
[SerializeField] private List<Enemy> enemies;
[SerializeField] private Transform player;
void Update()
{
// ✅ 람다는 캡처 없음(매개변수만 사용). For 루프로 직접 비교
Vector3 playerPos = player.position;
Enemy nearest = null;
float bestSqr = float.MaxValue;
for (int i = 0; i < enemies.Count; i++)
{
float sqr = Vector3.SqrMagnitude(enemies[i].transform.position - playerPos);
if (sqr < bestSqr)
{
bestSqr = sqr;
nearest = enemies[i];
}
}
if (nearest != null)
nearest.Highlight();
}
}
After 코드는 람다 자체를 없앴습니다. 핫패스에서 LINQ + 캡처 람다를 쓰지 않는 것이 가장 확실한 해법입니다. 정 LINQ 를 쓰고 싶다면 캡처를 피해야 합니다 — 이 검증을 위해 두 번째 비교를 IL 로 보겠습니다.
class Program
{
// 캡처 없는 람다: 외부 변수를 안 쓰고 매개변수만 사용
static int Run1() => Apply(x => x * 2);
// 캡처 있는 람다: 메서드 매개변수를 캡처
static int Run2(int factor) => Apply(x => x * factor);
static int Apply(Func<int, int> f) => f(5);
}
// Run1 — 캡처 없는 람다는 <>c 의 캐시를 재사용
.method static int32 Run1() cil managed
{
IL_0000: ldsfld class Func`2<int32, int32> Program/'<>c'::'<>9__0_0'
IL_0005: dup
IL_0006: brtrue.s IL_001f // 캐시 있으면 바로 호출 단계로
// ... 첫 호출에서만 newobj, 이후는 ldsfld 만
}
// Run2 — 매번 디스플레이 클래스 + 델리게이트 신규 할당
.method static int32 Run2(int32 factor) cil managed
{
IL_0000: newobj instance void Program/'<>c__DisplayClass1_0'::.ctor() // 힙 할당
IL_0005: dup
IL_0006: ldarg.0
IL_0007: stfld int32 Program/'<>c__DisplayClass1_0'::factor
IL_000c: ldftn instance int32 Program/'<>c__DisplayClass1_0'::'<Run2>b__0'(int32)
IL_0012: newobj instance void Func`2<int32, int32>::.ctor(object, native int) // 힙 할당
// ...
}
IL 해설: Run1 은 <>c 의 정적 캐시(<>9__0_0) 를 확인하는 패턴 — 두 번째 호출부터는 추가 할당 0. Run2 는 factor 라는 변수 하나만 끌어들였을 뿐인데 IL 첫 줄부터 newobj 가 두 번 등장합니다. "람다 안에서 외부 변수에 손을 대는 순간 캐싱이 깨지고 매 호출마다 힙 할당이 발생한다" 는 것이 이 글에서 가장 기억해야 할 한 줄입니다.
Unity IL2CPP(C# 을 C++ 로 번역한 뒤 다시 네이티브로 컴파일하는 빌드 백엔드) 도 이 변환 자체는 그대로 유지합니다 — 디스플레이 클래스가 C++ 객체로 번역될 뿐, 매 호출 힙 할당이 일어난다는 사실은 변하지 않습니다.
5. [함정과 주의사항] 같은 의미의 코드도 작성 방식에 따라 캡처가 달라진다
함정 1 — 람다 안에서 this 를 쓰면 자동으로 캡처된다
MonoBehaviour 안에서 람다를 쓸 때 this 멤버를 참조하면 — 명시하지 않아도 — this 가 캡처됩니다.
public class ScoreManager : MonoBehaviour
{
private int score;
private List<Enemy> enemies;
void OnEnable()
{
// ❌ enemies.Where(e => e.Score > score) — score 는 this.score 와 같다
// 컴파일러는 this 를 캡처하기 위해 디스플레이 클래스를 만든다
var strong = enemies.Where(e => e.Score > score).ToList();
}
}
// 컴파일된 결과: this 를 캡처하기 위한 DisplayClass 가 생성됨
.class nested private auto ansi sealed '<>c__DisplayClass2_0'
{
.field public class ScoreManager '<>4__this' // this 를 보관
.method instance bool '<OnEnable>b__0'(class Enemy e) cil managed
{
IL_0000: ldarg.1
IL_0001: callvirt instance int32 Enemy::get_Score()
IL_0006: ldarg.0
IL_0007: ldfld class ScoreManager '<>c__DisplayClass2_0'::'<>4__this'
IL_000c: ldfld int32 ScoreManager::score
IL_0011: cgt
IL_0013: ret
}
}
IL 해설: 디스플레이 클래스에 <>4__this 라는 필드가 생겨서 ScoreManager 인스턴스를 통째로 들고 있습니다. 람다 본문은 this.score 를 읽기 위해 두 번 ldfld 를 합니다. 무심코 멤버 한 글자를 쓴 결과 매 호출마다 힙 할당이 발생합니다.
After — 지역 변수로 한 번만 복사한 뒤 매개변수처럼 쓰기
public class ScoreManager : MonoBehaviour
{
private int score;
private List<Enemy> enemies;
void OnEnable()
{
// ✅ score 를 지역 변수로 떼어놓아도 캡처는 여전히 발생하지만,
// [08 정적 람다] 에서 다룰 static 키워드와 함께 쓸 때 도움이 된다.
// 가장 확실한 해결은 핫패스에서 LINQ + 캡처를 아예 쓰지 않는 것.
int threshold = score;
List<Enemy> strong = new();
for (int i = 0; i < enemies.Count; i++)
{
if (enemies[i].Score > threshold)
strong.Add(enemies[i]);
}
}
}
핫패스가 아닌 OnEnable 같은 1 회성 호출이라면 LINQ + 캡처 람다도 무방합니다 — 함정의 핵심은 "어디서 누가 얼마나 자주 호출되는가" 입니다.
함정 2 — => 가 식 본문 멤버와 람다 양쪽에 쓰여 헷갈린다
C# 6 부터 메서드·프로퍼티에도 => 를 쓸 수 있는데(식 본문 멤버), 이건 람다와 다릅니다.
public class Player
{
private int hp = 100;
// ❌ 헷갈림 — 이건 람다가 아니라 "식 본문 프로퍼티" (Hp 라는 읽기 전용 프로퍼티)
public int Hp => hp;
// ❌ 이것도 람다가 아님 — 식 본문 메서드
public int Damage(int amount) => hp -= amount;
// ✅ 람다 — Func/Action 같은 델리게이트 타입에 대입되는 익명 함수
public Func<int, int> Halver = x => x / 2;
}
같은 => 기호지만 좌변이 멤버 선언이면 식 본문 멤버, 우변이 식이고 좌변이 매개변수 형태면 람다입니다. 컴파일러가 디스플레이 클래스를 만드는 변환은 람다(Func/Action 등에 대입되는 형태) 에서만 일어납니다 — 식 본문 멤버는 일반 메서드와 똑같이 컴파일됩니다.
6. [C# 버전별 변화] 람다 문법은 매 버전 조금씩 더 강력해진다
람다식은 C# 3.0(2007) 도입 이후 거의 모든 메이저 버전마다 손이 갑니다. 본 글은 람다 자체 문법에 영향을 준 변화 중 C# 12 매개변수 기본값 과 C# 14 단순 람다의 수정자 지원 두 가지에 집중합니다. (C# 9 정적 람다·폐기 매개변수, C# 10 자연 타입은 [08]·[09]·[10] 별도 주제.)
C# 12 — 람다 매개변수에 기본값 지정 가능
이전에는 람다 매개변수에 기본값을 줄 수 없었습니다. 호출자가 항상 모든 인자를 채워야 했습니다. C# 12 부터는 일반 메서드처럼 = 로 기본값을 줄 수 있습니다.
// Before — C# 11 이하: 람다 기본값 불가, 별도 메서드 필요
int IncrementOld(int x, int step) => x + step;
// 호출 측에서 항상 두 인자를 채워야 함
// After — C# 12: 람다에 기본값 지정 가능
var increment = (int x, int step = 1) => x + step;
Console.WriteLine(increment(5)); // 6 ← step 생략 → 1 사용
Console.WriteLine(increment(5, 3)); // 8
이 코드의 IL 을 보면 컴파일러가 한 일이 드러납니다.
// 람다 본문 메서드의 시그니처에 [opt] 와 .param 메타데이터가 박힘
.method assembly hidebysig instance int32 '<Main>b__0_0'(
int32 x,
[opt] int32 step // ← optional 한정자
) cil managed
{
.param [2] = int32(1) // ← 두 번째 매개변수의 기본값 = 1
IL_0000: ldarg.1
IL_0001: ldarg.2
IL_0002: add
IL_0003: ret
}
// 람다를 담을 델리게이트 타입도 컴파일러가 자동 생성
.class private auto ansi sealed '<>f__AnonymousDelegate0`3'<T1, T2, TResult>
extends [System.Runtime]System.MulticastDelegate
{
.method instance !TResult Invoke(
!T1 arg1,
[opt] !T2 arg2 // ← Invoke 에도 동일하게 [opt] 부착
) runtime managed
{
.param [2] = int32(1)
}
}
// 호출 측 — increment(5) 가 increment(5, 1) 로 풀린다
IL_001f: dup
IL_0020: ldc.i4.5
IL_0021: ldc.i4.1 // ← 컴파일 시점에 기본값 1 이 그대로 박힘
IL_0022: callvirt instance !2 ...Invoke(!0, !1)
IL 해설:
- C# 12 컴파일러는 람다 본문 메서드와 그 메서드를 담을 **새 델리게이트 타입(
<>f__AnonymousDelegate03)** 을 동시에 만듭니다. 기존Func<,,>` 는 기본값을 표현할 메타데이터가 없으므로 새 타입이 필요합니다. - 매개변수의
[opt]는 IL 에서 "이 매개변수는 선택적(optional)" 이라는 의미의 한정자입니다. 그 옆의.param [2] = int32(1)가 "두 번째 매개변수의 기본값은 정수 1" 이라는 메타데이터입니다. 메서드 정의에 정확히 한 번만 박히고, 호출 측이 인자를 생략했을 때 컴파일러가 그 자리에 값을 채워 넣습니다. - 결과적으로
increment(5)는 IL 수준에서increment(5, 1)로 풀립니다 — 호출 측 IL 에ldc.i4.1이 그대로 박힌 것이 그 증거입니다.
실무 효용: 빌더 패턴이나 옵션 객체 없이도 LINQ 콜백·테스트 헬퍼·Mock 콜백에 합리적인 기본값을 줄 수 있습니다. Unity 의 작은 유틸리티 람다(예: var withFade = (float dur = 0.3f) => StartCoroutine(FadeRoutine(dur));) 에서 유용합니다.
C# 14 — 단순 람다(타입 생략)에서도 ref·in·out·scoped 사용 가능
C# 13 까지 단순 람다(매개변수 타입 생략) 는 매개변수 수정자를 붙일 수 없었습니다. out 같은 수정자를 쓰려면 타입까지 명시해야 했습니다.
// Before — C# 13 이하: 단순 람다에서 out 불가
TryParseDelegate parserOld = (string s, out int result) => int.TryParse(s, out result);
// (s, out result) => ← 컴파일 에러: 타입 생략 시 out 사용 불가
// After — C# 14: 타입 생략한 단순 람다에도 수정자 사용 가능
TryParseDelegate parser = (s, out result) => int.TryParse(s, out result);
// ↑ 타입 생략 ↑ 수정자만 명시
TryParseDelegate 는 delegate bool TryParseDelegate(string s, out int result); 로 미리 선언된 타입입니다. 컴파일러가 델리게이트 시그니처에서 타입을 추론하고, 람다에는 수정자(out) 만 적습니다.
// 컴파일된 람다 본문 — out 수정자가 그대로 IL 시그니처에 반영됨
.method assembly hidebysig instance bool '<Main>b__0_0'(
string s,
[out] int32& result // ← out 매개변수: 참조 전달 + [out] 한정자
) cil managed
{
IL_0000: ldarg.1
IL_0001: ldarg.2
IL_0002: call bool [System.Runtime]System.Int32::TryParse(string, int32&)
IL_0007: ret
}
IL 해설: C# 14 가 한 일은 IL 수준에서 보면 매우 단순합니다 — 이전부터 가능했던 람다 본문(타입 명시 + 수정자) 과 똑같은 IL 이 나옵니다. 차이는 오직 소스 코드에서 타입을 적지 않아도 된다는 것뿐입니다. int32& 는 .NET 의 "관리 포인터(managed pointer)" 로, ref·in·out 매개변수 모두 이 형태로 표현되며 out 만 추가로 [out] 한정자가 붙습니다.
params 만 여전히 타입 명시가 필요한 이유
C# 14 가 모든 수정자를 단순 람다에서 허용한 것은 아닙니다 — params 는 예외입니다.
// ❌ C# 14 에서도 컴파일 에러
Action<int[]> printAll = (params nums) => Console.WriteLine(nums.Length);
// ✅ params 는 여전히 타입 명시 필수
Action<int[]> printAllOk = (params int[] nums) => Console.WriteLine(nums.Length);
이유는 컴파일러가 호출 측에서 가변 인자들을 묶을 컬렉션 타입을 결정해야 하기 때문 입니다. params 는 int[] 이 될지 Span<int>(C# 13 의 params Span<>) 가 될지 등 여러 후보가 있습니다. 다른 수정자(ref·in·out)는 위임된 델리게이트 타입의 시그니처 한 자리만 보고 추론할 수 있지만, params 는 컬렉션 인스턴스를 어디에 어떻게 만들지 까지 결정해야 하므로 컴파일러가 타입 정보를 직접 받을 수밖에 없습니다.
7. [정리]
람다식은 "이름 없는 함수를 식의 자리에 바로 쓰는 문법" 이지만, 컴파일러 변환 메커니즘을 모르면 모바일 게임에서 GC 스파이크의 주범이 될 수 있습니다. 이 글에서 다룬 핵심을 압축합니다.
| 영역 | 한 줄 요약 |
|---|---|
| 문법 | => 왼쪽은 매개변수, 오른쪽은 본문. 한 줄 식이면 표현식 람다, 여러 줄이면 문장 람다 |
| 매개변수 괄호 | 1개 + 타입 생략일 때만 괄호 생략 가능 (단순 람다). 그 외엔 항상 괄호 필수 |
| 익명 메서드 | delegate(int x){...} 의 진화형 — 신규 코드는 람다만 씀 |
| 캡처 없는 람다 | <>c 의 정적 메서드 + 싱글톤 캐싱 → 추가 힙 할당 0 |
| 캡처 있는 람다 | <>c__DisplayClass 의 인스턴스 메서드 → 매 호출 newobj 두 번 (디스플레이 클래스 + 델리게이트) |
| Update() 함정 | LINQ + 캡처 람다는 매 프레임 GC Alloc — 핫패스에서는 for 루프 권장 |
this 자동 캡처 |
멤버 변수 한 글자만 써도 this 가 캡처됨 (<>4__this 필드 생김) |
| C# 12 | 람다 매개변수에 기본값 지정 가능 → IL 에 [opt] + .param 메타데이터, 새 익명 델리게이트 타입 자동 생성 |
| C# 14 | 단순 람다(타입 생략) 에 ref·in·out·scoped 사용 가능. params 만 예외 — 컬렉션 타입 결정 필요 |
다음 글 예고: [07 클로저] 에서는 본 글에서 짚고 넘어간 캡처 변수의 수명·for 루프 안 람다의 공유 문제·이벤트 구독에서의 메모리 누수를 본격적으로 다룹니다. [08 정적 람다] 는 static x => x*2 로 캡처 자체를 컴파일 에러로 막는 기법입니다.