[PART12.제네릭·델리게이트·람다·LINQ(8/18)] 정적 람다 — static 키워드로 캡처 사고를 컴파일 타임에 막는 법
static 람다가 무엇을 금지하고 무엇은 여전히 허용하는가 / 캡처 없는 일반 람다와 IL이 같다는 사실 / 그렇다면 가치는 "성능"이 아니라 "컴파일 타임 안전장치(CS8820·CS8821)" / Unity Update() LINQ 핫패스에서 무심코 this 를 캡처해 매 프레임 디스플레이 클래스를 newobj 하던 사고를 빌드 단계에서 차단
목차
1. [문제 제기] "캡처 안 했다고 생각했는데" 매 프레임 힙이 쌓이는 사고
[07 클로저] 에서 우리는 람다가 캡처를 하면 컴파일러가 <>c__DisplayClass 라는 숨겨진 클래스를 만들고, 메서드가 호출될 때마다 그 클래스를 newobj 로 힙에 새로 올린다는 것을 확인했습니다. 그리고 캡처가 없는 람다는 <>c 라는 정적 캐싱 클래스에 한 번만 올라가서 프로그램 내내 재사용된다는 것도 봤습니다.
문제는 "내가 캡처를 했는지 안 했는지" 가 눈으로 잘 보이지 않는다 는 점입니다. Unity 에서 자주 나오는 다음 코드를 보겠습니다.
// 잘못된 패턴: Update() 안의 LINQ 람다
public class TargetingSystem : MonoBehaviour
{
[SerializeField] private float _attackRange = 5f; // 인스펙터 값
private List<Enemy> _enemies = new();
void Update()
{
var inRange = _enemies.Where(e => e.distance < _attackRange);
// ↑ this._attackRange
foreach (var e in inRange) e.TakeDamage(1);
}
}
_attackRange 는 인스턴스 필드입니다. 람다 안에서 그 필드를 쓰는 순간, C# 컴파일러는 this 를 암시적으로 캡처 합니다. 그 결과 매 프레임 Update() 가 호출될 때마다 Where 에 넘기는 델리게이트를 새로 만들기 위해 Func<Enemy,bool> 인스턴스를 힙에 새로 할당 합니다 (이 경우는 디스플레이 클래스가 따로 만들어지지 않고 메서드가 인스턴스 메서드로 컴파일되지만, 델리게이트 객체는 매번 새로 만들어집니다 — IL 절에서 증거를 보입니다).
초당 60 프레임이라면 1 초에 60 번, 1 분이면 3,600 번의 힙 할당이 추가됩니다. 작은 객체라 보이지 않다가, 일정 메모리가 쌓이면 GC(Garbage Collector, 사용하지 않는 메모리를 자동으로 회수하는 런타임 구성요소) 가 일을 하면서 프레임이 잠깐 멈추는 GC 스파이크가 발생합니다.
문제의 핵심: 작성자는 "단순히 멤버 필드를 비교하는 가벼운 람다" 라고 생각했지만 실제로는 캡처가 일어나는 람다 였다는 것을, 코드만 봐서는 자각하기 어렵다는 점입니다.
C# 9 가 도입한 static 람다는 이 문제를 정면으로 푸는 키워드입니다.
// static 키워드 한 글자만 추가
var inRange = _enemies.Where(static e => e.distance < _attackRange);
// ↑ 컴파일 에러 CS8821:
// 정적 익명 함수는 'this' 또는 'base'에 대한
// 참조를 포함할 수 없습니다.
빌드 자체가 실패하므로 사고가 런타임이 아니라 컴파일 단계 에서 잡힙니다. static 람다의 진짜 가치는 바로 이것 — 성능을 끌어올리는 마법이 아니라, 캡처가 끼어드는 것을 빌드 단계에서 차단하는 안전장치입니다.
이 글에서 다루는 것
static람다가 컴파일러에게 시키는 것 — 무엇이 금지되고(this, 지역 변수), 무엇이 허용되는가(const,static readonly, 람다 매개변수)- 캡처 없는 일반 람다와
static람다의 IL 이 사실상 동일하다는 결정적 증거- 그래서
static의 가치는 성능 최적화가 아니라 CS8820 / CS8821 컴파일 에러로 캡처 사고를 잡아주는 컴파일 타임 안전장치 라는 점- Unity
Update()·LINQ 핫패스에서 사고를 사전 차단하는 패턴- C# 11 이후의 메서드 그룹 캐싱 / C# 14 의 매개변수 수정자(
ref/in/out) 와의 시너지
이 글에서 다루지 않는 것
- 캡처 자체의 동작 원리는 [07 클로저] 에서 이미 다뤘습니다 — 디스플레이 클래스의 구조,
forvsforeach의 캡처 차이, 메모리 누수 등- 람다 폐기 매개변수
_는 [09], 자연 타입 추론은 [10] 에서
2. [개념 정의] static 람다 — "이 람다는 외부 상태를 절대 안 건드린다" 는 선언
static람다 (Static Anonymous Function, C# 9) 람다 식 또는 익명 메서드 앞에static키워드를 붙여, 그 람다가 자신을 둘러싼 메서드의 지역 변수·매개변수·this·인스턴스 멤버를 캡처할 수 없음 을 컴파일러에게 강제하는 기능. 위반 시 컴파일 에러 (CS8820 — 지역 변수/매개변수 캡처, CS8821 —this/base캡처) 가 발생합니다.
비유 — "도시락 금지" 라고 적힌 출근 도장
[07 클로저] 에서 캡처가 있는 람다를 "도시락(디스플레이 클래스 인스턴스) 을 들고 다니는 함수" 에 비유했습니다. 정적 람다는 그 비유를 이어받아 "도시락 반입 금지" 도장이 찍힌 함수입니다.

문법 — 키워드 한 단어를 람다 식 앞에 붙인다
static(람다·익명 함수 한정자) 람다 식 또는 익명 메서드 앞에 붙여 캡처를 금지한다. C# 8 까지는 메서드·필드·클래스 한정자로만 쓰였지만, C# 9 부터 람다 식의 한정자로도 사용 가능.
예시:Func<int,int> f = static x => x * 2;일반 메서드의static과 같은 의미 — "어떤 인스턴스 상태에도 의존하지 않는다" 를 선언.
using System;
public class Syntax
{
public void Demo()
{
// (1) 표현식 람다
Func<int, int> a = static x => x * 2;
// (2) 문장 람다
Func<int, int> b = static (int x) =>
{
int squared = x * x; // 람다 자신의 지역 변수는 OK
return squared + 1;
};
// (3) 매개변수가 여러 개인 람다
Func<int, int, int> c = static (x, y) => x + y;
// (4) 익명 메서드 (구식 문법)
Action<int> d = static delegate(int x) { Console.WriteLine(x); };
}
}
static 은 항상 람다 매개변수 목록 직전 에 위치합니다. 일반 람다 작성 패턴과 동일하되 키워드가 한 단어 추가될 뿐입니다.
무엇이 금지되고 무엇이 허용되는가 — 한 표로 정리

다음 코드는 위 표가 실제로 컴파일되는지를 보여주는 자기완결 예제입니다. 빌드해 보면 허용 항목은 모두 통과하고, 금지 항목은 한 줄도 빠짐없이 에러가 납니다.
using System;
public class AllowedDemo
{
private const int LIMIT = 100;
private static readonly int RO_LIMIT = 200;
public void OK()
{
// 람다 매개변수 — 캡처가 아님
Func<int, int> a = static x => x * 2;
// 람다 내부 지역 변수
Func<int, int> b = static x => { int doubled = x * 2; return doubled; };
// const, static readonly, static 메서드
Func<int, bool> c = static x => x > LIMIT;
Func<int, bool> d = static x => x > RO_LIMIT;
Func<int, int> e = static x => Math.Abs(x);
// null·default 같은 리터럴
Func<string?, string> f = static s => s ?? "(none)";
}
}
핵심은 단 한 가지입니다 — "바깥 메서드의 상태를 끌어다 쓰지 않는다" 면 무엇이든 허용됩니다. const 와 static readonly 는 클래스 레벨에 묶여 있어 람다가 this 없이도 접근 가능하므로 캡처가 발생하지 않습니다.
3. [내부 동작] 캡처 없는 일반 람다와 static 람다 — IL 이 같다
여기가 이 글의 핵심입니다. 결론부터 말하면:
캡처 없는 일반 람다 == static 람다 (IL 수준에서 동일)
static 키워드는 IL 출력을 더 빠르게 만들지 않습니다. 컴파일러는 캡처가 없으면 자동으로 정적 캐싱으로 변환하기 때문에, 키워드를 붙이든 안 붙이든 같은 IL 이 나옵니다.
4 가지 케이스를 한 코드에 담은 비교 예제
using System;
using System.Linq;
using System.Collections.Generic;
public class Compare
{
private int _threshold = 10;
// (1) 캡처 없는 일반 람다
public void NonCapturingLambda()
{
Func<int, int> f = x => x * 2;
Console.WriteLine(f(5));
}
// (2) 지역 변수 캡처 — 디스플레이 클래스 발생
public void CapturingLambda()
{
int factor = 3;
Func<int, int> f = x => x * factor;
Console.WriteLine(f(5));
}
// (3) static 람다 (캡처 없음) — (1) 과 IL 이 같을 것이다
public void StaticLambda()
{
Func<int, int> f = static x => x * 2;
Console.WriteLine(f(5));
}
// (4) this 캡처 — Where 가 인스턴스 메서드 자체를 가리킴
public IEnumerable<int> CapturingThisInLinq(IEnumerable<int> source)
{
return source.Where(x => x > _threshold);
// ↑ this._threshold (암시적)
}
}
IL 증거 1 — (1) NonCapturingLambda vs (3) StaticLambda 가 글자 그대로 같다
ilspycmd -il 로 디컴파일한 IL 입니다. 가독성을 위해 한국어 인라인 주석을 추가했습니다.
// (1) 캡처 없는 일반 람다 — Compare::NonCapturingLambda
.method public hidebysig instance void NonCapturingLambda () cil managed
{
// <>c::<>9__1_0 — 델리게이트 캐싱 정적 필드
IL_0000: ldsfld class Func`2<int32, int32> Compare/'<>c'::'<>9__1_0'
IL_0005: dup
IL_0006: brtrue.s IL_001f // 이미 캐싱돼 있으면 재사용
IL_0008: pop
IL_0009: ldsfld class Compare/'<>c' Compare/'<>c'::'<>9' // 싱글턴 인스턴스
IL_000e: ldftn instance int32 Compare/'<>c'::'<NonCapturingLambda>b__1_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> Compare/'<>c'::'<>9__1_0' // 캐시 저장
IL_001f: ldc.i4.5
IL_0020: callvirt instance !1 Func`2<int32, int32>::Invoke(!0)
IL_0025: call void Console::WriteLine(int32)
IL_002a: ret
}
// (3) static 람다 — Compare::StaticLambda
.method public hidebysig instance void StaticLambda () cil managed
{
IL_0000: ldsfld class Func`2<int32, int32> Compare/'<>c'::'<>9__3_0' // 캐싱 필드만 다름
IL_0005: dup
IL_0006: brtrue.s IL_001f
IL_0008: pop
IL_0009: ldsfld class Compare/'<>c' Compare/'<>c'::'<>9'
IL_000e: ldftn instance int32 Compare/'<>c'::'<StaticLambda>b__3_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> Compare/'<>c'::'<>9__3_0'
IL_001f: ldc.i4.5
IL_0020: callvirt instance !1 Func`2<int32, int32>::Invoke(!0)
IL_0025: call void Console::WriteLine(int32)
IL_002a: ret
}
두 메서드의 IL 은 캐싱 필드 이름(<>9__1_0 vs <>9__3_0) 과 람다 본문 메서드 이름(<NonCapturingLambda>b__1_0 vs <StaticLambda>b__3_0) 만 다릅니다. 명령어 시퀀스, 분기 패턴, 사용하는 클래스(<>c) 모두 동일합니다.
람다 본문 메서드 자체도 글자 그대로 같습니다.
// 두 람다 본문 메서드의 IL — 100% 동일
IL_0000: ldarg.1 // 람다 매개변수 x
IL_0001: ldc.i4.2 // 상수 2
IL_0002: mul // x * 2
IL_0003: ret
IL 증거 2 — 캡처가 있으면 디스플레이 클래스가 매번 newobj
// (2) 지역 변수를 캡처하는 일반 람다 — Compare::CapturingLambda
.method public hidebysig instance void CapturingLambda () cil managed
{
// 매 호출마다 디스플레이 클래스를 힙에 새로 만든다
IL_0000: newobj instance void Compare/'<>c__DisplayClass2_0'::.ctor()
IL_0005: dup
IL_0006: ldc.i4.3
IL_0007: stfld int32 Compare/'<>c__DisplayClass2_0'::factor // 캡처된 변수 저장
IL_000c: ldftn instance int32 Compare/'<>c__DisplayClass2_0'::'<CapturingLambda>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 Console::WriteLine(int32)
IL_0022: ret
}
(1) (3) 과는 풍경이 완전히 다릅니다.
<>c정적 캐싱이 사라지고<>c__DisplayClass2_0라는 별도의 클래스가 생겼습니다.- 메서드 진입과 동시에
newobj instance void <>c__DisplayClass2_0::.ctor()— 호출마다 힙 할당 이 일어납니다. - 게다가 그 뒤에
newobj가 한 번 더 —Func델리게이트 객체도 매번 새로 만듭니다. brtrue.s캐싱 분기 자체가 없습니다. 캐시할 수 없으니까요(매 호출마다 다른factor값이 들어올 수 있기 때문).
IL 증거 3 — this 만 캡처하는 LINQ 람다는 디스플레이 클래스도 안 만든다
흥미롭게 _threshold 같은 인스턴스 필드만 캡처하면 컴파일러는 별도의 디스플레이 클래스를 만들지 않습니다. 람다 본문을 Compare 클래스 자신의 private 인스턴스 메서드 로 직접 컴파일하면 되기 때문입니다.
// (4) this (인스턴스 필드) 캡처 — Compare::CapturingThisInLinq
.method public hidebysig instance class IEnumerable`1<int32> CapturingThisInLinq (
class IEnumerable`1<int32> source) cil managed
{
IL_0000: ldarg.1 // source
IL_0001: ldarg.0 // this
IL_0002: ldftn instance bool Compare::'<CapturingThisInLinq>b__5_0'(int32)
IL_0008: newobj instance void Func`2<int32, bool>::.ctor(object, native int) // ← 매 호출마다 델리게이트
IL_000d: call class IEnumerable`1<!!0> Enumerable::Where(...)
IL_0012: ret
}
// 람다 본문 = Compare 의 인스턴스 메서드
.method private hidebysig instance bool '<CapturingThisInLinq>b__5_0' (int32 x) cil managed
{
IL_0000: ldarg.1
IL_0001: ldarg.0 // this
IL_0002: ldfld int32 Compare::_threshold
IL_0007: cgt
IL_0009: ret
}
여기서도 디스플레이 클래스는 없지만, Func<int,bool> 자체는 newobj 로 매번 새로 만들어집니다 — 델리게이트의 target 필드에 매번 다른 this 인스턴스가 들어갈 수 있기 때문에 캐싱 불가입니다.
한 장으로 정리 — 4 가지 IL 풍경

static 람다는 이미 빠른 코드를 더 빠르게 만들지 않습니다. 그러나 (2)·(4) 같은 미래의 사고를 빌드 단계에서 차단합니다.
4. [실전 적용] 핫패스에서 사고를 사전 차단하는 패턴
Before/After — Update() LINQ 핫패스
// ❌ Before — 의도하지 않은 this 캡처가 매 프레임 델리게이트를 새로 만든다
public class TargetingSystem_Bad : MonoBehaviour
{
[SerializeField] private float _attackRange = 5f;
private List<Enemy> _enemies = new();
void Update()
{
// _attackRange 는 this._attackRange — 캡처 발생
// 컴파일러는 람다 본문을 TargetingSystem_Bad 의 인스턴스 메서드로 만들고,
// Where() 호출마다 새 Func<Enemy,bool> 델리게이트를 newobj 한다
foreach (var e in _enemies.Where(e => e.distance < _attackRange))
e.TakeDamage(1);
}
}
// Update 안의 Where 호출 부분 — 매 프레임 newobj 가 일어남
IL_0001: ldarg.0
IL_0002: ldftn instance bool TargetingSystem_Bad::'<Update>b__0'(Enemy)
IL_0008: newobj instance void Func`2<Enemy, bool>::.ctor(object, native int)
IL_000d: call Enumerable::Where(...)
// ✅ After — static 람다 + 매개변수 오버로드로 "this" 와 단절
public class TargetingSystem_Good : MonoBehaviour
{
[SerializeField] private float _attackRange = 5f;
private List<Enemy> _enemies = new();
void Update()
{
float range = _attackRange; // 한 번만 읽어 지역 변수에 박는다
// 1) 캡처 시도가 있으면 빌드가 깨진다 — 안전장치
// 2) static 람다는 매 호출 사이에 재사용되므로 델리게이트 newobj 가 사라진다
// (단, 아래 Where 는 매개변수 1 개짜리이므로 range 를 람다 안에 못 넣음.
// range 는 LINQ 외부에서 직접 비교하거나, 매개변수를 받는 별도 메서드를 사용)
foreach (var e in _enemies)
{
if (e.distance < range)
e.TakeDamage(1);
}
}
// LINQ 형태를 유지하고 싶으면 외부 상태를 매개변수로 명시적으로 받는 패턴
private static IEnumerable<Enemy> InRange(IEnumerable<Enemy> source, float range)
{
// 캐시된 정적 람다를 직접 만들 수는 없지만,
// static 키워드로 "절대 캡처 안 함" 을 보장하고
// range 는 호출자가 명시적으로 전달
return source.Where(static (Enemy e) => e.distance < 5f);
// ↑ 상수만 가능
// ↑ range 를 캡처하려 하면 CS8820. 외부 변수가 필요하면 위처럼 foreach 로 풀거나
// 상수·const 로 빼야 한다.
}
}
static람다와 LINQ 의 한계 LINQ 의Where·Select는 매개변수를 1 개만 받는 델리게이트를 받습니다. 외부 값을 람다 안에 직접 넣고 싶으면 캡처가 필수이므로static람다와 양립할 수 없습니다.
이런 경우 두 가지 길이 있습니다:
- LINQ 를 포기하고
foreach로 풀어쓰기 (가장 빠름)- 외부 값을 매개변수로 받는 정적 메서드를 만들고 그 메서드를 호출하기
// After 의 InRange 안 Where — Bad 와 다른 점:
// 1) static 람다이므로 TargetingSystem_Good/'<>c' 정적 캐싱 클래스를 사용
// 2) ldsfld + brtrue.s + stsfld 패턴 — 처음 한 번만 newobj
IL_0001: ldsfld class Func`2<Enemy, bool> TargetingSystem_Good/'<>c'::'<>9__N_0'
IL_0006: dup
IL_0007: brtrue.s IL_0020 // 캐시 hit 면 newobj 건너뜀
IL_0009: pop
IL_000a: ldsfld TargetingSystem_Good/'<>c' TargetingSystem_Good/'<>c'::'<>9'
IL_000f: ldftn instance bool TargetingSystem_Good/'<>c'::'<InRange>b__N_0'(Enemy)
IL_0015: newobj instance void Func`2<Enemy, bool>::.ctor(object, native int)
IL_001a: dup
IL_001b: stsfld class Func`2<Enemy, bool> TargetingSystem_Good/'<>c'::'<>9__N_0'
IL_0020: call Enumerable::Where(...)
brtrue.s 분기가 핵심입니다 — 두 번째 호출부터는 ldsfld → brtrue.s → 바로 Where 로 점프합니다. 매 프레임 0 회 할당이 됩니다.
LINQ 가 아닌 콜백 — 이벤트 핸들러
// ✅ static 람다로 깔끔하게 끝나는 흔한 케이스
public static class Logger
{
public static event Action<string>? OnMessage;
}
public class Boot
{
public void Setup()
{
// static 람다 — 외부 상태 캡처 없음
// 이벤트 핸들러는 한 번 등록되면 프로세스 내내 살아남으므로
// newobj 한 번이면 끝나고, 캡처가 들어가면 영원히 살아남는 메모리 누수 위험
Logger.OnMessage += static msg => Console.WriteLine($"[LOG] {msg}");
// ❌ 만약 누군가 무심코 인스턴스 필드를 끼워 넣었다면 빌드 실패 → 사고 사전 차단
// Logger.OnMessage += static msg => Console.WriteLine($"[{this.Name}] {msg}");
// ↑ CS8821
}
}
이벤트 핸들러는 수명이 매우 길기 때문에 캡처된 변수도 함께 영생합니다 ([07 클로저] 의 메모리 누수 절 참조). static 키워드를 습관처럼 붙이면 무심코 this 를 끼워 넣어 MonoBehaviour 가 영원히 GC 안 되는 사고를 빌드 시점에 잡을 수 있습니다.
라이브러리 API 설계 — "이 콜백은 캡처하지 마세요" 를 강제하기
// 라이브러리 작성자가 콜백 시그니처를 지정할 때
// 매개변수에 외부 컨텍스트를 같이 받는 형태로 설계하면 호출자가 static 람다를 쓰기 좋다
public static class Worker
{
// ✅ 좋은 설계 — TState 를 함께 받음 → 호출자가 캡처 없이 상태 전달 가능
public static void Run<TState>(TState state, Action<TState> callback) =>
callback(state);
// ❌ 나쁜 설계 — 호출자가 외부 상태를 쓰려면 캡처를 강제당함
public static void RunBad(Action callback) => callback();
}
class Caller
{
private string _name = "Player";
void Use()
{
// ✅ 캡처 없음 — static 가능, 델리게이트 캐싱
Worker.Run(_name, static name => Console.WriteLine($"hi {name}"));
// ❌ static 못 씀 — _name 캡처 필요
Worker.RunBad(() => Console.WriteLine($"hi {_name}"));
}
}
.NET 표준 라이브러리도 점진적으로 이 패턴을 따르고 있습니다 — Dictionary<TKey,TValue>.GetValueRefOrAddDefault, ConditionalWeakTable.GetValue<TKey>(TKey, CreateValueCallback<TKey,TValue>) 등.
5. [함정과 주의사항] 신입이 자주 마주치는 4 가지
❌ 함정 1 — _field 는 그냥 필드라고 착각
public class Trap1 : MonoBehaviour
{
private int _hp;
void Update()
{
// ❌ "_hp 는 그냥 필드일 뿐이잖아? static 안 붙여도 캡처 안 일어나겠지"
var alive = new[] { 1, 2, 3 }.Where(x => x < _hp);
// ↑ this._hp — 인스턴스 필드 = this 캡처
// 차이를 보고 싶으면 static 으로 강제해 본다
// var alive2 = new[] { 1, 2, 3 }.Where(static x => x < _hp); // CS8821
}
}
해법: 람다 진입 직전에 지역 변수로 복사하거나(여전히 캡처지만 적어도 의도가 분명), 외부에 매개변수로 받는 정적 메서드로 빼낸다.
❌ 함정 2 — static 메서드 안이라고 안전한 것은 아니다
public static class Trap2
{
public static void Run(int factor) // factor 는 메서드 매개변수
{
// ❌ static 메서드 안이라도 매개변수는 외부 상태 — 캡처 발생
Func<int, int> f = static x => x * factor;
// ↑ CS8820
}
}
static 메서드 안에서도 메서드 매개변수와 지역 변수는 람다 입장에서 "외부" 입니다. static 람다는 어떤 외부도 안 본다는 뜻이지, "static 컨텍스트면 자유" 가 아닙니다.
❌ 함정 3 — 인스턴스 메서드 호출도 캡처
public class Trap3
{
public void Helper() { Console.WriteLine("hi"); }
public static void StaticHelper() { Console.WriteLine("hi"); }
public void Demo()
{
// ❌ 인스턴스 메서드 호출 → 암시적 this → CS8821
Action a = static () => Helper(); // CS8821
// ✅ 정적 메서드는 OK
Action b = static () => StaticHelper();
}
}
람다 본문 안에서 Helper() 라고만 쓰면 컴파일러는 this.Helper() 로 해석합니다. 따라서 this 캡처가 일어나고, static 람다 규칙에 위배됩니다.
❌ 함정 4 — static 람다라고 GC 부담이 0 이라고 단언하지 말 것
static 람다라도 델리게이트가 처음 만들어지는 한 번 의 newobj 는 일어납니다 (<>9__N_M 캐싱 필드가 비어 있을 때). 그 후로 같은 람다를 100 만 번 사용해도 추가 할당은 없습니다.
// 첫 호출 — newobj 1 회
IL_0008: pop
IL_0009: ldsfld Compare/'<>c' Compare/'<>c'::'<>9'
IL_000e: ldftn ...
IL_0014: newobj Func`2::.ctor(...) // ← 여기서 1 회 할당
IL_0019: dup
IL_001a: stsfld ... // 캐싱 필드에 저장
// 두 번째 호출부터 — brtrue.s 가 newobj 를 건너뜀
IL_0000: ldsfld Func`2 ...'<>9__N_0'
IL_0005: dup
IL_0006: brtrue.s IL_001f // 분기로 newobj 스킵
이 동작은 동일 어셈블리·동일 람다 식 위치에서만 캐시가 공유됩니다. 같은 모양의 람다라도 다른 메서드에 작성되면 다른 캐싱 필드가 생성됩니다.
6. [C# 버전별 변화]
| 버전 | 변화 | 본 글과의 관계 |
|---|---|---|
| C# 8 이하 | 람다에 static 부착 불가 — 캡처 없는 람다는 컴파일러가 자동으로 정적 캐싱했지만, 캡처 사고를 막을 언어 차원의 수단이 없었음 |
static 람다 자체가 없음 |
| C# 9 (.NET 5) | static 람다 / 익명 함수 도입 — 캡처 시도 시 CS8820·CS8821 에러 |
본 글의 주제 |
| C# 10 (.NET 6) | 람다 자연 타입 추론(var f = (int x) => x + 1;), 명시적 반환 타입 |
var x = static (int n) => n + 1; 처럼 자연 타입 + static 결합 가능 |
| C# 11 (.NET 7) | 메서드 그룹 변환 캐싱 — Action a = SomeStaticMethod; 처럼 메서드 그룹을 델리게이트에 대입할 때도 정적 캐싱 적용 |
static 람다와 함께 "0 할당" 코드를 만드는 또 하나의 도구 |
| C# 12 (.NET 8) | 람다 매개변수 기본값((int n = 1) => n * 2) |
static 과 직교 — 함께 사용 가능 |
| C# 14 (.NET 10) | 매개변수 타입을 생략한 단순 람다에서도 ref·in·out·scoped 사용 가능 |
static 람다 + in·ref 매개변수 조합으로 구조체 복사 없는 0 할당 콜백 가능 |
Before/After — C# 8 vs C# 9
// C# 8 — 키워드를 허용하지 않으므로 컴파일 에러
// (실수로 캡처가 들어가도 빌드가 그대로 통과 → 런타임에 GC 부담만 증가)
Func<int, int> f = static x => x * 2;
// ↑ CS8400: 'static anonymous function' 은
// 언어 버전 9.0 이상에서만 사용 가능
// C# 9 이상 — OK, 그리고 캡처 시도는 빌드 차단
Func<int, int> f = static x => x * 2;
After — C# 11 + C# 14 시너지
// C# 11: 메서드 그룹 캐싱 — 정적 메서드를 델리게이트에 대입해도 캐싱
public static int Double(int x) => x * 2;
public void Demo()
{
Func<int, int> f1 = Double; // C# 11 부터 매번 newobj 안 함 — 캐싱
Func<int, int> f2 = static x => x * 2; // 동일하게 캐싱
}
// C# 14: 단순 람다 + ref/in/out + static 결합 (예시 — 실제 동작은 .NET 10 + C# 14 환경 필요)
public static bool TryParseDouble(ReadOnlySpan<char> text, out double value)
{
var parser = static (text, out value) => double.TryParse(text, out value);
// ↑ static 으로 캡처 차단 + out 으로 결과 반환
return parser(text, out value);
}
7. [정리]
static 람다는 새로운 기능이 아니라 기존에 컴파일러가 이미 하던 정적 캐싱을 명시적으로 강제하고, 그 강제가 깨질 때 컴파일 에러로 알려주는 안전장치입니다.
이것만 기억하면 충분
- ✓
static x => ...— 람다 앞에static키워드 한 단어 - ✓ 금지 — 바깥 지역 변수·매개변수 (CS8820),
this·인스턴스 멤버·base(CS8821) - ✓ 허용 — 람다 매개변수, 람다 내부 지역 변수,
const,static readonly,static메서드 호출 - ✓ IL 은 캡처 없는 일반 람다와 동일 — 같은
<>c클래스 + 같은<>9__N_M캐싱 필드 + 같은brtrue.s분기 - ✓ 가치는 성능이 아니라 컴파일 타임 안전장치 — 미래의 무심코 캡처를 빌드 단계에서 차단
- ✓ Unity
Update()·이벤트 핸들러·LINQ 콜백 — 고빈도 / 장수명 콜백에 습관적으로static부착 - ✓ LINQ 와의 한계 — 외부 값을 람다에 넣고 싶으면 매개변수가 1 개인 LINQ 시그니처 때문에
foreach로 풀거나 정적 헬퍼로 빼야 한다 - ✓ 인스턴스 메서드 호출도 캡처 —
static () => Helper()는 CS8821,static () => StaticHelper()는 OK
다음 글 안내
다음 [09 람다의 폐기 매개변수 _] 에서는 같은 C# 9 가 도입한 또 다른 람다 편의 — 시그니처는 고정인데 값이 필요 없을 때 매개변수를 _ 로 쓰는 문법 — 을 다룹니다. 정적 람다와 함께 쓰면 "이벤트 핸들러처럼 시그니처는 강제되지만 본문은 단순한" 콜백을 가장 깔끔하게 표현할 수 있습니다.
'C# 기초' 카테고리의 다른 글
| [PART12.제네릭·델리게이트·람다·LINQ(10/18)] 람다 자연 타입·반환 타입·특성 (C# 10) (0) | 2026.05.07 |
|---|---|
| [PART12.제네릭·델리게이트·람다·LINQ(9/18)] 람다의 폐기 매개변수 `_` (C# 9) (0) | 2026.05.07 |
| [PART12.제네릭·델리게이트·람다·LINQ(7/18)] 클로저(Closure) — 람다가 바깥 변수를 "기억"하는 현상 (0) | 2026.05.07 |
| [PART12.제네릭·델리게이트·람다·LINQ(6/18)] 람다식 — `x => x * 2` (0) | 2026.05.07 |
| [PART12.제네릭·델리게이트·람다·LINQ(5/18)] 콜백 함수 — 메서드를 매개변수로 전달하는 패턴 (0) | 2026.05.07 |