반응형

[PART12.제네릭·델리게이트·람다·LINQ(8/18)] 정적 람다 — static 키워드로 캡처 사고를 컴파일 타임에 막는 법

static 람다가 무엇을 금지하고 무엇은 여전히 허용하는가 / 캡처 없는 일반 람다와 IL이 같다는 사실 / 그렇다면 가치는 "성능"이 아니라 "컴파일 타임 안전장치(CS8820·CS8821)" / Unity Update() LINQ 핫패스에서 무심코 this 를 캡처해 매 프레임 디스플레이 클래스를 newobj 하던 사고를 빌드 단계에서 차단


1. [문제 제기] "캡처 안 했다고 생각했는데" 매 프레임 힙이 쌓이는 사고

[07 클로저] 에서 우리는 람다가 캡처를 하면 컴파일러가 <>c__DisplayClass 라는 숨겨진 클래스를 만들고, 메서드가 호출될 때마다 그 클래스를 newobj 로 힙에 새로 올린다는 것을 확인했습니다. 그리고 캡처가 없는 람다는 <>c 라는 정적 캐싱 클래스에 한 번만 올라가서 프로그램 내내 재사용된다는 것도 봤습니다.

문제는 "내가 캡처를 했는지 안 했는지" 가 눈으로 잘 보이지 않는다 는 점입니다. Unity 에서 자주 나오는 다음 코드를 보겠습니다.

C#
// 잘못된 패턴: 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 람다는 이 문제를 정면으로 푸는 키워드입니다.

C#
// 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 클로저] 에서 이미 다뤘습니다 — 디스플레이 클래스의 구조, for vs foreach 의 캡처 차이, 메모리 누수 등
  • 람다 폐기 매개변수 _ 는 [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 과 같은 의미 — "어떤 인스턴스 상태에도 의존하지 않는다" 를 선언.
C#
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 은 항상 람다 매개변수 목록 직전 에 위치합니다. 일반 람다 작성 패턴과 동일하되 키워드가 한 단어 추가될 뿐입니다.

무엇이 금지되고 무엇이 허용되는가 — 한 표로 정리

❌ 금지 — 컴파일 에러

다음 코드는 위 표가 실제로 컴파일되는지를 보여주는 자기완결 예제입니다. 빌드해 보면 허용 항목은 모두 통과하고, 금지 항목은 한 줄도 빠짐없이 에러가 납니다.

C#
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)";
    }
}

핵심은 단 한 가지입니다 — "바깥 메서드의 상태를 끌어다 쓰지 않는다" 면 무엇이든 허용됩니다. conststatic readonly 는 클래스 레벨에 묶여 있어 람다가 this 없이도 접근 가능하므로 캡처가 발생하지 않습니다.


3. [내부 동작] 캡처 없는 일반 람다와 static 람다 — IL 이 같다

여기가 이 글의 핵심입니다. 결론부터 말하면:

캡처 없는 일반 람다 == static 람다 (IL 수준에서 동일)

static 키워드는 IL 출력을 더 빠르게 만들지 않습니다. 컴파일러는 캡처가 없으면 자동으로 정적 캐싱으로 변환하기 때문에, 키워드를 붙이든 안 붙이든 같은 IL 이 나옵니다.

4 가지 케이스를 한 코드에 담은 비교 예제

C#
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 입니다. 가독성을 위해 한국어 인라인 주석을 추가했습니다.

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
// 두 람다 본문 메서드의 IL — 100% 동일
IL_0000: ldarg.1     // 람다 매개변수 x
IL_0001: ldc.i4.2    // 상수 2
IL_0002: mul         // x * 2
IL_0003: ret

IL 증거 2 — 캡처가 있으면 디스플레이 클래스가 매번 newobj

IL
// (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 인스턴스 메서드 로 직접 컴파일하면 되기 때문입니다.

IL
// (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 풍경

람다 4 가지 케이스의 IL 풍경

static 람다는 이미 빠른 코드를 더 빠르게 만들지 않습니다. 그러나 (2)·(4) 같은 미래의 사고를 빌드 단계에서 차단합니다.


4. [실전 적용] 핫패스에서 사고를 사전 차단하는 패턴

Before/After — Update() LINQ 핫패스

C#
// ❌ 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);
    }
}
IL
// 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(...)
C#
// ✅ 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 로 풀어쓰기 (가장 빠름)
  • 외부 값을 매개변수로 받는 정적 메서드를 만들고 그 메서드를 호출하기
IL
// 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 분기가 핵심입니다 — 두 번째 호출부터는 ldsfldbrtrue.s → 바로 Where 로 점프합니다. 매 프레임 0 회 할당이 됩니다.

LINQ 가 아닌 콜백 — 이벤트 핸들러

C#
// ✅ 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 설계 — "이 콜백은 캡처하지 마세요" 를 강제하기

C#
// 라이브러리 작성자가 콜백 시그니처를 지정할 때
// 매개변수에 외부 컨텍스트를 같이 받는 형태로 설계하면 호출자가 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 는 그냥 필드라고 착각

C#
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 메서드 안이라고 안전한 것은 아니다

C#
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 — 인스턴스 메서드 호출도 캡처

C#
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 만 번 사용해도 추가 할당은 없습니다.

IL
// 첫 호출 — 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#
// 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#
// 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 가 도입한 또 다른 람다 편의 — 시그니처는 고정인데 값이 필요 없을 때 매개변수를 _ 로 쓰는 문법 — 을 다룹니다. 정적 람다와 함께 쓰면 "이벤트 핸들러처럼 시그니처는 강제되지만 본문은 단순한" 콜백을 가장 깔끔하게 표현할 수 있습니다.

반응형

+ Recent posts