반응형

[PART12.제네릭·델리게이트·람다·LINQ(7/18)] 클로저(Closure) — 람다가 바깥 변수를 "기억"하는 현상

캡처(capture)란 무엇인가 / 컴파일러가 자동 생성하는 디스플레이 클래스(<>c__DisplayClass) / 값 캡처가 아닌 참조 캡처 / for vs foreach 의 IL 차이 / Unity 핫패스에서의 GC 스파이크와 메모리 누수


1. [문제 제기] 람다가 떠나도 바깥 변수가 살아남아야 하는 순간

[06 람다식] 에서 람다가 일반 메서드로 컴파일된다는 것을 봤습니다. 그렇다면 람다 안에서 "바깥 메서드의 지역 변수" 를 쓰면 어떻게 될까요. 그 지역 변수는 메서드가 끝나면 스택에서 사라져야 하는데, 람다는 한참 뒤 다른 스레드·다른 프레임에서 호출될 수도 있습니다.

C#
// Unity 흔한 패턴 — 일정 시간 뒤 콜백
public void TakeDamage(int amount)
{
    int finalDamage = amount * 2;          // 지역 변수
    StartCoroutine(DelayedLog(1.0f, () =>
    {
        Debug.Log($"피해 {finalDamage} 적용"); // 1초 뒤 호출
    }));
    // ↑ 이 람다가 호출되는 시점에는 TakeDamage 가 이미 끝났다.
    //   finalDamage 는 어디에 살아 있는가?
}

TakeDamage 가 반환되면 finalDamage 가 들어 있던 스택 프레임은 다음 호출에 의해 덮어쓰입니다. 그런데도 1초 뒤 람다가 호출되면 finalDamage 의 값(예: 60) 이 정확히 출력됩니다. 마법이 아닙니다 — C# 컴파일러가 미리 손을 써둔 결과이고, 그 메커니즘이 바로 클로저(closure) 입니다.

이 글에서 다루는 것
  • 캡처(capture) 의 정의와 컴파일러가 만드는 디스플레이 클래스(display class, <>c__DisplayClassN_M) 의 구조
  • 캡처가 없는 람다와 있는 람다의 IL 차이 (정적 캐싱 <>c vs 인스턴스 디스플레이 클래스)
  • 값 캡처가 아닌 참조 캡처 — 같은 변수를 캡처한 람다끼리 상태 공유
  • for 루프와 foreach 루프의 캡처 시맨틱 차이와 IL 증거
  • Unity Update·이벤트 구독에서 자주 만나는 GC 스파이크와 메모리 누수

이 글에서 다루지 않는 것
  • 캡처를 컴파일러가 강제로 막는 static 람다는 [08 정적 람다] 에서
  • 폐기 매개변수 _, 자연 타입 추론은 [09], [10] 에서
  • LINQ 의 지연 실행과 클로저의 결합은 [15 지연 실행] 에서

람다와 함수형 프로그래밍을 다루는 다른 언어들이 그러하듯, C# 도 "함수가 바깥 환경을 함께 끌고 다닌다" 는 개념을 지원합니다. 단, C# 의 경우 그 환경은 컴파일러가 만들어낸 숨겨진 클래스 인스턴스 로 구체화됩니다. 이 클래스의 모양과 수명을 이해하지 못하면 Update() 안에서 무심코 캡처를 만들어 매 프레임 힙을 쓰레기로 채우거나, 이벤트 구독을 해제하지 못해 GameObject 가 영영 회수되지 않는 누수를 겪게 됩니다.


2. [개념 정의] 캡처 — 람다가 외부 변수를 "기억" 한다는 말의 정확한 뜻

캡처(capture) 람다 또는 익명 메서드가 자신을 둘러싼 메서드의 지역 변수·매개변수 를 사용하는 행위. 컴파일러는 그 변수들을 스택이 아니라 별도의 참조 타입 클래스 의 필드로 옮겨서 람다가 호출될 때까지 살려둡니다. 그렇게 만들어진 "함수 + 함께 끌고 다니는 변수 환경" 의 묶음을 클로저(closure) 라고 부릅니다.

비유 — "도시락" 으로서의 클로저

메서드 스택 프레임

스택의 지역 변수는 메서드가 끝나면 사라지지만, 컴파일러는 캡처된 변수를 힙에 새로 만든 클래스 인스턴스의 필드 로 옮겨 적습니다. 람다 본문은 그 클래스의 메서드가 되고, 우리가 쥐고 있는 Action·Func 델리게이트는 그 인스턴스를 가리킵니다. 델리게이트가 살아있는 한 인스턴스도 GC 대상이 아니므로, 캡처된 변수도 함께 살아남습니다.

가장 단순한 클로저

C#
using System;

public class ClosureBasics
{
    public Action CaptureLocal()
    {
        int bonus = 10;                 // 지역 변수
        return () => Console.WriteLine(bonus);
        // ↑ 람다가 'bonus' 를 사용 → 캡처 발생
    }
}

CaptureLocal 이 반환된 시점에 bonus 라는 지역 변수는 사라져야 정상입니다. 하지만 호출 측이 반환된 Action 을 한참 뒤에 실행해도 10 이 출력됩니다. 그 이유는 컴파일러가 만들어낸 IL 에 그대로 드러납니다.

IL
// ilspycmd 디컴파일 결과 (lv:CSharp1, 가독성 위해 일부 한국어 주석)
// (1) 컴파일러가 자동 생성한 디스플레이 클래스
[CompilerGenerated]
private sealed class <>c__DisplayClass1_0
{
    public int bonus;                          // 캡처된 변수가 필드로 승격

    internal void <CaptureLocal>b__0()         // 람다 본문 → 인스턴스 메서드
    {
        Console.WriteLine(bonus);
    }
}

// (2) 원래 메서드는 디스플레이 클래스를 'new' 하고 델리게이트로 감싼다
public Action CaptureLocal()
{
    <>c__DisplayClass1_0 cls = new <>c__DisplayClass1_0(); // 힙 할당!
    cls.bonus = 10;                                        // 변수 → 필드
    return new Action(cls.<CaptureLocal>b__0);             // 델리게이트가 인스턴스를 참조
}

세 가지를 확인할 수 있습니다.

  1. <>c__DisplayClassN_M<>__ 가 들어간 식별자는 컴파일러만 만들 수 있는 이름입니다. N 은 메서드 순번, M 은 그 안의 클로저 순번입니다.
  2. 캡처된 변수는 public 필드 — 같은 메서드의 다른 람다나 메서드 본문에서 읽고 쓸 수 있어야 하므로 일부러 public 입니다.
  3. new 가 호출됨 — 캡처가 있으면 메서드 호출마다 디스플레이 클래스 인스턴스가 힙에 새로 할당 됩니다. 이것이 뒤에서 다룰 GC 압력의 출발점입니다.

"이름 없는 식별자" 의 정체

<>c, <>9, <>c__DisplayClass1_0, b__0 같은 이름은 모두 C# 식별자 규칙으로는 사용자가 만들 수 없는 이름입니다 (<> 가 들어 있음). 컴파일러는 의도적으로 이런 이름을 써서 사용자 코드와 충돌하지 않게 합니다. ILSpy 같은 도구로 디컴파일할 때 이 이름이 보이면 "컴파일러가 만든 보조 타입" 이라고 읽으면 됩니다.


3. [내부 동작] 캡처 유무로 갈리는 두 갈래의 IL

[06 람다식] 에서 람다가 두 가지 형태로 컴파일된다는 것을 짧게 짚었습니다. 클로저의 핵심은 그 두 가지 — 캡처가 없으면 정적 캐싱(<>c), 캡처가 있으면 인스턴스 디스플레이 클래스(<>c__DisplayClassN_M) — 의 차이가 GC 부담을 결정한다는 점입니다.

두 갈래 한눈에 비교

캡처 없음 → <>c 정적 캐싱

코드로 동시 비교

C#
using System;

public class TwoBranches
{
    // (A) 캡처 없음
    public Action NoCapture()
    {
        return () => Console.WriteLine("hello");
    }

    // (B) 캡처 있음
    public Action CaptureLocal()
    {
        int bonus = 10;
        return () => Console.WriteLine(bonus);
    }
}

위 두 메서드를 컴파일한 IL 디컴파일 결과를 나란히 봅니다 (실제 ilspycmd 출력에서 가독성 위해 한국어 주석 추가).

IL
// ── (A) 캡처 없음 — 정적 캐싱 ──────────────────────────
[CompilerGenerated]
private sealed class <>c                          // 1) 상태 없는 도우미 클래스
{
    public static readonly <>c <>9 = new <>c();   //    싱글턴 인스턴스
    public static Action <>9__0_0;                //    델리게이트 캐시 슬롯

    internal void <NoCapture>b__0_0()             //    람다 본문 (인스턴스 메서드지만 캡처는 없음)
    {
        Console.WriteLine("hello");
    }
}

public Action NoCapture()
{
    // ?? 연산자: 왼쪽이 null 이면 오른쪽으로 대입
    return <>c.<>9__0_0
        ?? (<>c.<>9__0_0 = new Action(<>c.<>9.<NoCapture>b__0_0));
        // ↑ 처음 호출 시 1회만 new, 이후엔 캐시된 델리게이트 재사용
}

// ── (B) 캡처 있음 — 인스턴스 디스플레이 클래스 ─────────
[CompilerGenerated]
private sealed class <>c__DisplayClass1_0          // 메서드별로 다른 클래스
{
    public int bonus;                              // 캡처 변수 = 인스턴스 필드
    internal void <CaptureLocal>b__0() { Console.WriteLine(bonus); }
}

public Action CaptureLocal()
{
    <>c__DisplayClass1_0 cls = new <>c__DisplayClass1_0(); // ★ 힙 할당 ①
    cls.bonus = 10;
    return new Action(cls.<CaptureLocal>b__0);             // ★ 힙 할당 ②
}
?? — 널 병합 연산자 (Null-coalescing operator) 왼쪽 피연산자가 null 이 아니면 그 값을 반환하고, null 이면 오른쪽 피연산자 값을 반환한다. 첫 호출 때만 새 값을 만들고 이후엔 캐시를 재사용하는 패턴에 자주 등장한다.
예시: cache ?? (cache = new Action(...)); cachenull 이면 새 Action 을 만들어 대입하고 그 값을 반환, 다음 호출부터는 기존 cache 가 바로 반환됨.

핵심 차이는 두 줄로 요약됩니다.

구분 캡처 없음 (A) 캡처 있음 (B)
컴파일러 생성 클래스 <>c (정적 싱글턴) <>c__DisplayClassN_M (인스턴스)
람다 본문 메서드 <>c 의 인스턴스 메서드(상태 없음) DisplayClass 의 인스턴스 메서드
델리게이트 생성 최초 1회, 이후 정적 필드 재사용 메서드 호출마다 매번
호출당 힙 할당 0 회 (캐시 적중) 2 회 (DisplayClass + 델리게이트)
GC 부담 사실상 없음 호출 빈도에 비례해 누적

"참조 캡처" — 같은 변수면 상태가 공유된다

캡처는 변수의 값을 복사 하는 게 아니라 변수 자체(메모리 위치)를 공유 합니다. 같은 메서드 안에서 두 람다가 동일한 지역 변수를 캡처하면, 둘은 같은 디스플레이 클래스 인스턴스의 같은 필드 를 보게 됩니다.

C#
using System;

public class CounterClosure
{
    public (Action inc, Func<int> get) MakeCounter()
    {
        int n = 0;
        Action inc = () => n++;          // 두 람다 모두
        Func<int> get = () => n;         // 같은 'n' 을 캡처
        return (inc, get);
    }
}

// 사용 예
var (inc, get) = new CounterClosure().MakeCounter();
inc(); inc(); inc();
Console.WriteLine(get()); // → 3

IL 을 보면 두 람다가 같은 디스플레이 클래스 인스턴스의 같은 n 필드 를 공유한다는 사실이 그대로 드러납니다.

IL
[CompilerGenerated]
private sealed class <>c__DisplayClass4_0
{
    public int n;                                 // ★ 두 람다가 공유하는 필드

    internal void <Counter>b__0() { n++; }        // inc 람다
    internal int  <Counter>b__1() { return n; }   // get 람다
}

public ValueTuple<Action, Func<int>> Counter()
{
    <>c__DisplayClass4_0 cls = new <>c__DisplayClass4_0(); // 인스턴스 1개만
    cls.n = 0;
    Action  inc = new Action(cls.<Counter>b__0);            // 같은 cls 를 가리킴
    Func<int> get = new Func<int>(cls.<Counter>b__1);       // 같은 cls 를 가리킴
    return new ValueTuple<Action, Func<int>>(inc, get);
}

inc()cls.n++ 을 실행하면 get() 이 보는 cls.n 도 즉시 바뀝니다. 별도의 동기화 장치를 두지 않으면 여러 스레드에서 같은 캡처를 만지는 순간 즉시 경합 이 발생합니다. 이 사실은 곧이어 다룰 for 루프 캡처 함정의 근본 원인이기도 합니다.


4. [실전 적용] for vs foreach — 한 글자 차이가 만드는 IL 차이

캡처가 "변수 자체를 공유한다" 는 사실을 알면 가장 유명한 함정인 루프 변수 캡처 가 왜 일어나는지 한 줄로 설명됩니다. for 루프의 카운터 i 는 루프 전체에서 단 하나의 변수 입니다. 그 안에서 람다를 만들면 모든 람다가 같은 i 를 캡처하게 됩니다.

Before (잘못된 패턴) — for 루프에서 람다

C#
using System;
using System.Collections.Generic;

public class LoopCapture
{
    public List<Action> ForCapture()
    {
        var list = new List<Action>();
        for (int i = 0; i < 3; i++)
        {
            list.Add(() => Console.WriteLine(i));   // i 캡처
        }
        return list;
    }
}

// 호출 측
foreach (var a in new LoopCapture().ForCapture()) a();
// 기대: 0 1 2
// 실제: 3 3 3   ← 모두 같은 i (= 루프 종료 시 값) 를 본다

IL 디컴파일 결과는 단호합니다.

IL
public List<Action> ForCapture()
{
    List<Action> list = new List<Action>();

    // ★ 디스플레이 클래스를 루프 '바깥' 에서 단 1번 new
    <>c__DisplayClass2_0 cls = new <>c__DisplayClass2_0();
    cls.i = 0;

    while (cls.i < 3)
    {
        list.Add(new Action(cls.<ForCapture>b__0)); // 매 반복 같은 cls 참조
        cls.i++;                                    // 같은 필드를 갱신
    }
    return list;
}

루프 안에서 만든 세 개의 Action 모두 하나의 cls 인스턴스를 가리키며, cls.i 는 루프가 끝난 시점에 3 입니다. 그래서 나중에 호출하면 셋 다 3 을 출력합니다.

After (올바른 패턴) — foreach 또는 루프 안 지역 변수 복사

C#
using System;
using System.Collections.Generic;

public class LoopCapture
{
    // 방법 1: foreach — 컴파일러가 알아서 매 반복 새 변수
    public List<Action> ForeachCapture()
    {
        var list = new List<Action>();
        var items = new[] { 0, 1, 2 };
        foreach (var i in items)
        {
            list.Add(() => Console.WriteLine(i));   // 매 반복 새 i
        }
        return list;
    }

    // 방법 2: for 를 유지해야 한다면 안에서 명시적으로 복사
    public List<Action> ForCaptureFixed()
    {
        var list = new List<Action>();
        for (int i = 0; i < 3; i++)
        {
            int captured = i;                       // 루프 안 새 지역 변수
            list.Add(() => Console.WriteLine(captured));
        }
        return list;
    }
}

// 두 방법 모두 출력: 0 1 2

foreach 의 IL 을 보면 디스플레이 클래스가 루프 안쪽 으로 들어와 있습니다.

IL
public List<Action> ForeachCapture()
{
    List<Action> list = new List<Action>();
    int[] array = new int[3] { 0, 1, 2 };

    for (int idx = 0; idx < array.Length; idx++)
    {
        // ★ 매 반복마다 새 디스플레이 클래스 인스턴스를 new
        <>c__DisplayClass3_0 cls = new <>c__DisplayClass3_0();
        cls.i = array[idx];                            // 그 반복의 값
        list.Add(new Action(cls.<ForeachCapture>b__0)); // 그 인스턴스를 가리키는 델리게이트
    }
    return list;
}

세 개의 Action 이 각자 다른 cls 인스턴스를 가지므로, cls.i 도 각각 0, 1, 2 로 독립 보존됩니다. for 루프 안에서 명시적으로 int captured = i; 로 복사하는 패턴도 컴파일 결과상 같은 효과를 냅니다 — 매 반복마다 새 지역 변수가 생기므로 컴파일러가 매번 새 디스플레이 클래스를 만듭니다.

한 줄 정리

  디스플레이 클래스 new 위치 디스플레이 클래스 인스턴스 수 (3회 반복 시) 결과
for 루프에서 i 직접 캡처 루프 바깥 1회 1개 모두 같은 i (3, 3, 3)
for 루프에서 안쪽 지역 변수 캡처 루프 매 반복 3개 독립 (0, 1, 2)
foreach 루프에서 i 캡처 루프 매 반복 3개 독립 (0, 1, 2)

foreach 시맨틱은 C# 5 에서 바뀐 결과

이 동작은 처음부터 그랬던 게 아닙니다. C# 4 까지 foreach 의 반복 변수도 for 와 동일하게 "루프 전체에 단 하나" 였습니다. 너무 많은 개발자가 이 함정에 빠지자 (Eric Lippert 가 "최악의 디자인 결정 중 하나" 라고 평가), C# 5 부터 시맨틱이 바뀌어 매 반복마다 새 변수를 도입한 것처럼 동작하도록 변경됐습니다.

오래된 .NET Framework 라이브러리나 C# 4 시절 코드를 마이그레이션할 때 foreach 안의 람다 동작이 컴파일러 버전에 따라 달라질 수 있다는 점만 기억하면 됩니다. 지금 우리가 쓰는 모든 모던 C# 컴파일러(.NET Core 1.0 이상, .NET 5+)에서는 위 표대로 동작합니다.

Unity 핫패스 시나리오

Update() 는 보통 1초에 60회(또는 그 이상) 호출됩니다. 그 안에서 캡처가 있는 람다를 만들면 매 프레임 디스플레이 클래스 인스턴스 + 델리게이트 = 2개의 객체가 힙에 쌓입니다.

C#
// Before — 매 프레임 GC.Alloc 2회
public class EnemySpawner : MonoBehaviour
{
    [SerializeField] private float spawnDelay = 0.5f;

    void Update()
    {
        float currentDelay = spawnDelay * Time.timeScale;  // 지역 변수
        Scheduler.Run(() =>
        {
            // currentDelay 캡처 → 매 프레임 디스플레이 클래스 new
            SpawnAfter(currentDelay);
        });
    }
}
C#
// After — 캡처 제거, 또는 멤버 변수로 승격
public class EnemySpawner : MonoBehaviour
{
    [SerializeField] private float spawnDelay = 0.5f;
    private float _cachedDelay;        // 멤버 변수
    private Action _runOnce;           // 미리 캐싱한 델리게이트

    void Awake()
    {
        // 캡처 대상이 'this' 의 필드뿐이면 디스플레이 클래스가 생기지 않음
        // (this 캡처는 한 단계 가벼운 형태로 처리됨)
        _runOnce = () => SpawnAfter(_cachedDelay);
    }

    void Update()
    {
        _cachedDelay = spawnDelay * Time.timeScale;
        Scheduler.Run(_runOnce);       // 미리 만든 델리게이트 재사용
    }
}

After 패턴은 캡처를 완전히 없애지는 않지만(여전히 this 의 필드를 읽으므로), Update 안에서 새 객체를 만들지 않는다는 사실 이 중요합니다. 더 강력하게 캡처를 차단하고 싶다면 다음 글 [08 정적 람다] 에서 다룰 static 람다와 매개변수로 상태를 넘기는 오버로드(Action<TState>) 를 사용합니다.


5. [함정과 주의사항] 클로저가 만드는 메모리 누수와 흔한 실수

함정 ① — 이벤트 구독 람다가 만든 누수

C#
// ❌ Bad — 람다로 구독했더니 해제할 수가 없음
public class HpBar : MonoBehaviour
{
    [SerializeField] private Player player;
    [SerializeField] private Image fill;

    void OnEnable()
    {
        // 람다 자체가 매번 새 인스턴스 → 다음에 -= 할 핸들이 없다
        player.OnHpChanged += (hp) => fill.fillAmount = hp / 100f;
    }

    void OnDisable()
    {
        // 같은 코드를 -= 해도 '동등하지만 다른 인스턴스' 라 해제 안 됨
        player.OnHpChanged -= (hp) => fill.fillAmount = hp / 100f;
    }
}

문제는 두 가지가 겹쳐 있습니다.

  1. 델리게이트 동등성 — 매번 새로 만든 람다 인스턴스는 멤버가 같아도 -= 로 매칭되지 않습니다 (구독 측과 해제 측이 서로 다른 디스플레이 클래스 인스턴스를 가리키기 때문).
  2. 캡처 누수 — 람다는 this (즉 HpBar 인스턴스) 를 캡처합니다. 따라서 player 가 살아있는 한 이 HpBar 와 그 fill 까지 GC 대상이 되지 못합니다.
C#
// ✅ Good — 메서드(또는 캐시한 델리게이트) 로 구독하고 같은 참조로 해제
public class HpBar : MonoBehaviour
{
    [SerializeField] private Player player;
    [SerializeField] private Image fill;

    void OnEnable()  => player.OnHpChanged += OnHpChanged;
    void OnDisable() => player.OnHpChanged -= OnHpChanged;

    private void OnHpChanged(float hp) => fill.fillAmount = hp / 100f;
}

이름 있는 메서드는 매번 같은 델리게이트로 변환되므로 -= 가 정확히 매칭됩니다. 외부 변수도 캡처하지 않으므로 디스플레이 클래스 자체가 생기지 않습니다.

+= / -= (이벤트·델리게이트 결합·해제 연산자) 델리게이트나 이벤트의 호출 체인에 핸들러를 추가(+=)하거나 제거(-=) 한다. 같은 메서드 그룹은 같은 델리게이트로 취급되어 매칭되지만, 익명 람다는 매번 다른 인스턴스-= 로 해제할 수 없다.
예시: button.onClick.AddListener(OnClick); ... button.onClick.RemoveListener(OnClick); 이름 있는 메서드라 정확히 같은 델리게이트로 매칭되어 해제됨.

함정 ② — static 컬렉션이 잡고 있는 캡처

C#
// ❌ Bad — 정적 리스트가 람다를 잡고 있어 영원히 회수 안 됨
public static class GlobalActions
{
    public static List<Action> All = new();
}

public class TempUi : MonoBehaviour
{
    private string _id = "TEMP";

    void Start()
    {
        // 람다가 'this' 와 _id 를 캡처
        GlobalActions.All.Add(() => Debug.Log(_id));
    }
}

// → TempUi 가 Destroy 되어도 GlobalActions.All 안의 람다가
//   디스플레이 클래스 → this(TempUi) 를 참조하고 있어 GC 대상 아님
//   결과: 모든 TempUi 인스턴스가 영구히 메모리에 남는다
C#
// ✅ Good — 등록한 만큼 해제하거나, 약한 참조 패턴을 쓴다
public class TempUi : MonoBehaviour
{
    private Action _registered;

    void Start()
    {
        _registered = () => Debug.Log(name);
        GlobalActions.All.Add(_registered);
    }

    void OnDestroy()
    {
        GlobalActions.All.Remove(_registered); // 같은 인스턴스로 해제
    }
}

핵심 원칙: 수명이 긴 컬렉션·이벤트에 캡처 람다를 등록했다면 반드시 해제 짝을 만든다. Unity 에서는 OnEnable/OnDisable, Start/OnDestroy 가 자연스러운 짝입니다.

함정 ③ — 파괴된 MonoBehaviour 를 캡처해서 생기는 "가짜 null"

C#
// ❌ Bad — Destroy 된 GameObject 를 람다 안에서 만지면 예외
public class Bomb : MonoBehaviour
{
    void Start()
    {
        Invoke(nameof(Explode), 5f); // 5초 뒤
        Scheduler.Run(() => transform.position += Vector3.up); // this 캡처
    }
    void Explode() => Destroy(gameObject);
}

// 5초 뒤: gameObject 는 파괴 → Unity 의 == null 은 true
//        하지만 C# 참조로는 null 이 아님 (디스플레이 클래스가 잡고 있음)
//        transform 접근 → MissingReferenceException
C#
// ✅ Good — 람다 안에서 매번 살아있는지 확인하거나, 파괴 시 콜백 해제
public class Bomb : MonoBehaviour
{
    void Start()
    {
        Invoke(nameof(Explode), 5f);
        Scheduler.Run(() =>
        {
            if (this == null) return;             // Unity 의 fake-null 체크
            transform.position += Vector3.up;
        });
    }
    void Explode() => Destroy(gameObject);
}

Unity 의 MonoBehaviourGameObject 는 C# 객체와 네이티브(C++) 객체가 한 쌍을 이룹니다. Destroy 는 네이티브 측만 즉시 파괴하고 C# 객체는 GC 대상이 되어야 회수됩니다. 그런데 클로저가 this 를 잡고 있으면 GC 가 못 회수해서 "C# 으로는 살아있지만 네이티브로는 죽은" 상태가 길게 남습니다. 이 상태에서 transform, gameObject, 컴포넌트 등에 접근하면 MissingReferenceException 이 발생합니다.

함정 ④ — 루프 안 람다의 누적 비용

C#
// ❌ Bad — 100개 적에게 같은 콜백을 람다로 등록하면 디스플레이 클래스 100개
foreach (var enemy in enemies)
{
    enemy.OnDeath += () => score += enemy.Reward; // enemy 캡처
}

각 람다가 자기 enemy 를 캡처하므로 디스플레이 클래스 인스턴스가 적 수만큼 생깁니다. 적이 죽으면 OnDeath 이벤트도 사라지면서 정리되지만, 적이 풀(pool) 처럼 재사용되는 구조라면 누적됩니다.

C#
// ✅ Good — 상태 매개변수가 있는 이벤트라면 캡처 없이 처리
foreach (var enemy in enemies)
{
    enemy.OnDeathWithReward += AddReward; // (int reward) => ...
}

void AddReward(int reward) => score += reward;

이벤트 시그니처를 설계할 때 핸들러가 필요로 하는 정보를 인자로 전달 하도록 만들면 캡처 자체가 필요 없어집니다.


6. [C# 버전별 변화] 클로저 시맨틱의 진화

C# 의 클로저 자체는 익명 메서드를 도입한 C# 2 에서 처음 등장한 이후 큰 골격이 거의 바뀌지 않았지만, 시맨틱과 최적화 관련해서 의미 있는 변화가 두 번 있었습니다.

C# 2 (2005) — 익명 메서드 + 클로저 도입

C#
// C# 2 — 익명 메서드 (delegate 키워드 사용)
public Action CaptureLocal()
{
    int bonus = 10;
    return delegate { Console.WriteLine(bonus); };
}

이때부터 컴파일러가 디스플레이 클래스를 만들어 캡처를 처리하는 메커니즘이 도입됐습니다. 람다식이 없던 시절이라 문법은 무거웠지만 IL 변환 방식은 지금과 거의 같습니다.

C# 3 (2007) — 람다식 문법

C#
// C# 3 — 람다식 (=> 도입)
public Action CaptureLocal()
{
    int bonus = 10;
    return () => Console.WriteLine(bonus); // delegate 키워드 사라짐
}

문법만 바뀌었을 뿐, 컴파일러가 만드는 디스플레이 클래스와 IL 구조는 C# 2 와 동일합니다. 즉 클로저의 본질은 람다식 도입과 무관하게 그 이전부터 존재해 왔습니다.

C# 5 (2012) — foreach 변수 캡처 시맨틱 변경 ★

C#
// 동일한 코드, 컴파일러 버전에 따라 결과가 다르다
var actions = new List<Action>();
foreach (var i in new[] { 1, 2, 3 })
    actions.Add(() => Console.WriteLine(i));
foreach (var a in actions) a();

// C# 4 이하: 3 3 3   (foreach 변수도 단일)
// C# 5 이상: 1 2 3   (반복마다 새 변수)

Eric Lippert 가 공개적으로 "이전 동작은 잘못된 디자인이었다" 고 평가한 바 있는, 드물게 언어 시맨틱 자체가 깨지는 변경(breaking change) 입니다. 변경 폭에 비해 영향이 적었던 이유는 실수로 의존하던 코드가 거의 없었기 때문입니다 — 대부분의 사람들은 어차피 결과가 잘못 나와서 우회 패턴(int copy = i;) 을 쓰고 있었습니다.

C# 9 (2020) — static 람다로 캡처 자체를 컴파일 에러로 ★

C#
// C# 9 ─ static 키워드로 캡처를 강제 차단
public Action CaptureNotAllowed()
{
    int bonus = 10;
    return static () => Console.WriteLine(bonus);
    //     ^^^^^^                   ^^^^^
    // CS8820: A static anonymous function cannot
    //         contain a reference to 'bonus'.
}

캡처가 일어나는지 일어나지 않는지를 컴파일 시점에 강제 할 수 있게 됐습니다. LINQ 콜백·이벤트 핸들러처럼 핫패스에서 호출되는 람다를 static 으로 표시해두면 무심코 외부 변수를 잡아 디스플레이 클래스를 만드는 실수를 방지할 수 있습니다. 자세한 동작과 IL 차이는 다음 글 [08 정적 람다] 에서 다룹니다.

static (람다·로컬 함수에 적용 시) 람다나 로컬 함수 앞에 붙으면 해당 함수 안에서 바깥 지역 변수·매개변수·this·인스턴스 멤버를 캡처할 수 없도록 컴파일러가 강제 한다. 캡처 시도가 있으면 CS8820(람다) 또는 CS8421(로컬 함수) 컴파일 에러가 발생한다.
예시: Func<int,int> f = static x => x * 2; 외부 상태에 의존하지 않는 순수 함수임을 문법으로 보장 → 디스플레이 클래스 없이 정적 캐싱.

정리

버전 변경 영향
C# 2 익명 메서드 + 디스플레이 클래스 도입 클로저 메커니즘의 시작
C# 3 람다식 문법(=>) 문법만 변경, IL 동일
C# 5 foreach 변수가 매 반복 새 변수처럼 동작 가장 흔한 함정이 해소됨
C# 9 static 람다로 캡처 차단 컴파일러 검증 핫패스 안전성 향상

for 루프 변수의 시맨틱은 지금까지 한 번도 바뀐 적이 없습니다 — 여전히 단일 변수입니다. 따라서 for 안에서 람다를 만든다면 본문에서 명시적으로 지역 변수에 복사하는 패턴이 반드시 필요합니다.


7. [정리] 핵심 체크리스트

  • [ ] 캡처(capture) = 람다·익명 메서드가 외부 지역 변수·매개변수를 사용하는 행위. 컴파일러가 변수를 별도 클래스의 필드로 옮긴다.
  • [ ] 클로저(closure) = 함수 + 그 함수가 함께 끌고 다니는 변수 환경의 묶음.
  • [ ] 캡처 없는 람다<>c 정적 클래스 + 정적 캐시 필드 → 호출당 0 할당 (최초 1회만 new).
  • [ ] 캡처 있는 람다<>c__DisplayClassN_M 인스턴스 클래스 → 호출당 2 할당 (DisplayClass + 델리게이트).
  • [ ] 캡처는 값 복사가 아니라 변수 자체 공유 — 같은 변수를 캡처한 여러 람다는 상태를 공유한다.
  • [ ] for 루프 안에서 카운터를 캡처하면 모든 람다가 같은 i 를 본다 → 안에서 int captured = i; 로 명시 복사.
  • [ ] foreach 루프 는 C# 5 부터 매 반복마다 새 변수처럼 동작 → 캡처해도 안전.
  • [ ] 이벤트 구독 에 람다를 쓰면 -= 로 해제할 수 없다 → 이름 있는 메서드 또는 캐시한 델리게이트로 구독.
  • [ ] static 컬렉션·장기 콜백 에 등록한 캡처 람다는 this 와 캡처 변수를 GC 가 회수하지 못한다 → 등록한 만큼 해제 짝을 만든다.
  • [ ] Unity 에서 Update·OnValueChanged 같은 핫패스에 캡처 람다를 두면 매 프레임 GC.Alloc → 캐시한 델리게이트 또는 static 람다 + 매개변수 전달로 우회.
  • [ ] 파괴된 MonoBehaviour 가 캡처에 잡혀 있으면 fake-null 상태로 살아남아 MissingReferenceException 의 원인이 된다.

다음 글 [08 정적 람다] 에서는 static 키워드를 람다에 붙여 캡처 자체를 컴파일 에러로 만드는 방법, 그리고 Action<TState> 같은 상태 매개변수 패턴으로 캡처 없이 동일한 효과를 얻는 방법을 다룹니다.

반응형

+ Recent posts