[PART12.제네릭·델리게이트·람다·LINQ(7/18)] 클로저(Closure) — 람다가 바깥 변수를 "기억"하는 현상
캡처(capture)란 무엇인가 / 컴파일러가 자동 생성하는 디스플레이 클래스(<>c__DisplayClass) / 값 캡처가 아닌 참조 캡처 / for vs foreach 의 IL 차이 / Unity 핫패스에서의 GC 스파이크와 메모리 누수
목차
1. [문제 제기] 람다가 떠나도 바깥 변수가 살아남아야 하는 순간
[06 람다식] 에서 람다가 일반 메서드로 컴파일된다는 것을 봤습니다. 그렇다면 람다 안에서 "바깥 메서드의 지역 변수" 를 쓰면 어떻게 될까요. 그 지역 변수는 메서드가 끝나면 스택에서 사라져야 하는데, 람다는 한참 뒤 다른 스레드·다른 프레임에서 호출될 수도 있습니다.
// 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 차이 (정적 캐싱
<>cvs 인스턴스 디스플레이 클래스)- 값 캡처가 아닌 참조 캡처 — 같은 변수를 캡처한 람다끼리 상태 공유
for루프와foreach루프의 캡처 시맨틱 차이와 IL 증거- Unity
Update·이벤트 구독에서 자주 만나는 GC 스파이크와 메모리 누수
이 글에서 다루지 않는 것
- 캡처를 컴파일러가 강제로 막는
static람다는 [08 정적 람다] 에서- 폐기 매개변수
_, 자연 타입 추론은 [09], [10] 에서- LINQ 의 지연 실행과 클로저의 결합은 [15 지연 실행] 에서
람다와 함수형 프로그래밍을 다루는 다른 언어들이 그러하듯, C# 도 "함수가 바깥 환경을 함께 끌고 다닌다" 는 개념을 지원합니다. 단, C# 의 경우 그 환경은 컴파일러가 만들어낸 숨겨진 클래스 인스턴스 로 구체화됩니다. 이 클래스의 모양과 수명을 이해하지 못하면 Update() 안에서 무심코 캡처를 만들어 매 프레임 힙을 쓰레기로 채우거나, 이벤트 구독을 해제하지 못해 GameObject 가 영영 회수되지 않는 누수를 겪게 됩니다.
2. [개념 정의] 캡처 — 람다가 외부 변수를 "기억" 한다는 말의 정확한 뜻
캡처(capture) 람다 또는 익명 메서드가 자신을 둘러싼 메서드의 지역 변수·매개변수 를 사용하는 행위. 컴파일러는 그 변수들을 스택이 아니라 별도의 참조 타입 클래스 의 필드로 옮겨서 람다가 호출될 때까지 살려둡니다. 그렇게 만들어진 "함수 + 함께 끌고 다니는 변수 환경" 의 묶음을 클로저(closure) 라고 부릅니다.
비유 — "도시락" 으로서의 클로저

스택의 지역 변수는 메서드가 끝나면 사라지지만, 컴파일러는 캡처된 변수를 힙에 새로 만든 클래스 인스턴스의 필드 로 옮겨 적습니다. 람다 본문은 그 클래스의 메서드가 되고, 우리가 쥐고 있는 Action·Func 델리게이트는 그 인스턴스를 가리킵니다. 델리게이트가 살아있는 한 인스턴스도 GC 대상이 아니므로, 캡처된 변수도 함께 살아남습니다.
가장 단순한 클로저
using System;
public class ClosureBasics
{
public Action CaptureLocal()
{
int bonus = 10; // 지역 변수
return () => Console.WriteLine(bonus);
// ↑ 람다가 'bonus' 를 사용 → 캡처 발생
}
}
CaptureLocal 이 반환된 시점에 bonus 라는 지역 변수는 사라져야 정상입니다. 하지만 호출 측이 반환된 Action 을 한참 뒤에 실행해도 10 이 출력됩니다. 그 이유는 컴파일러가 만들어낸 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); // 델리게이트가 인스턴스를 참조
}
세 가지를 확인할 수 있습니다.
<>c__DisplayClassN_M—<>와__가 들어간 식별자는 컴파일러만 만들 수 있는 이름입니다.N은 메서드 순번,M은 그 안의 클로저 순번입니다.- 캡처된 변수는
public필드 — 같은 메서드의 다른 람다나 메서드 본문에서 읽고 쓸 수 있어야 하므로 일부러public입니다. new가 호출됨 — 캡처가 있으면 메서드 호출마다 디스플레이 클래스 인스턴스가 힙에 새로 할당 됩니다. 이것이 뒤에서 다룰 GC 압력의 출발점입니다.
"이름 없는 식별자" 의 정체
<>c, <>9, <>c__DisplayClass1_0, b__0 같은 이름은 모두 C# 식별자 규칙으로는 사용자가 만들 수 없는 이름입니다 (< 와 > 가 들어 있음). 컴파일러는 의도적으로 이런 이름을 써서 사용자 코드와 충돌하지 않게 합니다. ILSpy 같은 도구로 디컴파일할 때 이 이름이 보이면 "컴파일러가 만든 보조 타입" 이라고 읽으면 됩니다.
3. [내부 동작] 캡처 유무로 갈리는 두 갈래의 IL
[06 람다식] 에서 람다가 두 가지 형태로 컴파일된다는 것을 짧게 짚었습니다. 클로저의 핵심은 그 두 가지 — 캡처가 없으면 정적 캐싱(<>c), 캡처가 있으면 인스턴스 디스플레이 클래스(<>c__DisplayClassN_M) — 의 차이가 GC 부담을 결정한다는 점입니다.
두 갈래 한눈에 비교

코드로 동시 비교
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 출력에서 가독성 위해 한국어 주석 추가).
// ── (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(...));cache가null이면 새Action을 만들어 대입하고 그 값을 반환, 다음 호출부터는 기존cache가 바로 반환됨.
핵심 차이는 두 줄로 요약됩니다.
| 구분 | 캡처 없음 (A) | 캡처 있음 (B) |
|---|---|---|
| 컴파일러 생성 클래스 | <>c (정적 싱글턴) |
<>c__DisplayClassN_M (인스턴스) |
| 람다 본문 메서드 | <>c 의 인스턴스 메서드(상태 없음) |
DisplayClass 의 인스턴스 메서드 |
| 델리게이트 생성 | 최초 1회, 이후 정적 필드 재사용 | 메서드 호출마다 매번 |
| 호출당 힙 할당 | 0 회 (캐시 적중) | 2 회 (DisplayClass + 델리게이트) |
| GC 부담 | 사실상 없음 | 호출 빈도에 비례해 누적 |
"참조 캡처" — 같은 변수면 상태가 공유된다
캡처는 변수의 값을 복사 하는 게 아니라 변수 자체(메모리 위치)를 공유 합니다. 같은 메서드 안에서 두 람다가 동일한 지역 변수를 캡처하면, 둘은 같은 디스플레이 클래스 인스턴스의 같은 필드 를 보게 됩니다.
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 필드 를 공유한다는 사실이 그대로 드러납니다.
[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 루프에서 람다
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 디컴파일 결과는 단호합니다.
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 또는 루프 안 지역 변수 복사
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 을 보면 디스플레이 클래스가 루프 안쪽 으로 들어와 있습니다.
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개의 객체가 힙에 쌓입니다.
// 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);
});
}
}
// 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. [함정과 주의사항] 클로저가 만드는 메모리 누수와 흔한 실수
함정 ① — 이벤트 구독 람다가 만든 누수
// ❌ 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;
}
}
문제는 두 가지가 겹쳐 있습니다.
- 델리게이트 동등성 — 매번 새로 만든 람다 인스턴스는 멤버가 같아도
-=로 매칭되지 않습니다 (구독 측과 해제 측이 서로 다른 디스플레이 클래스 인스턴스를 가리키기 때문). - 캡처 누수 — 람다는
this(즉HpBar인스턴스) 를 캡처합니다. 따라서player가 살아있는 한 이HpBar와 그fill까지 GC 대상이 되지 못합니다.
// ✅ 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 컬렉션이 잡고 있는 캡처
// ❌ 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 인스턴스가 영구히 메모리에 남는다
// ✅ 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"
// ❌ 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
// ✅ 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 의 MonoBehaviour 와 GameObject 는 C# 객체와 네이티브(C++) 객체가 한 쌍을 이룹니다. Destroy 는 네이티브 측만 즉시 파괴하고 C# 객체는 GC 대상이 되어야 회수됩니다. 그런데 클로저가 this 를 잡고 있으면 GC 가 못 회수해서 "C# 으로는 살아있지만 네이티브로는 죽은" 상태가 길게 남습니다. 이 상태에서 transform, gameObject, 컴포넌트 등에 접근하면 MissingReferenceException 이 발생합니다.
함정 ④ — 루프 안 람다의 누적 비용
// ❌ Bad — 100개 적에게 같은 콜백을 람다로 등록하면 디스플레이 클래스 100개
foreach (var enemy in enemies)
{
enemy.OnDeath += () => score += enemy.Reward; // enemy 캡처
}
각 람다가 자기 enemy 를 캡처하므로 디스플레이 클래스 인스턴스가 적 수만큼 생깁니다. 적이 죽으면 OnDeath 이벤트도 사라지면서 정리되지만, 적이 풀(pool) 처럼 재사용되는 구조라면 누적됩니다.
// ✅ 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# 2 — 익명 메서드 (delegate 키워드 사용)
public Action CaptureLocal()
{
int bonus = 10;
return delegate { Console.WriteLine(bonus); };
}
이때부터 컴파일러가 디스플레이 클래스를 만들어 캡처를 처리하는 메커니즘이 도입됐습니다. 람다식이 없던 시절이라 문법은 무거웠지만 IL 변환 방식은 지금과 거의 같습니다.
C# 3 (2007) — 람다식 문법
// C# 3 — 람다식 (=> 도입)
public Action CaptureLocal()
{
int bonus = 10;
return () => Console.WriteLine(bonus); // delegate 키워드 사라짐
}
문법만 바뀌었을 뿐, 컴파일러가 만드는 디스플레이 클래스와 IL 구조는 C# 2 와 동일합니다. 즉 클로저의 본질은 람다식 도입과 무관하게 그 이전부터 존재해 왔습니다.
C# 5 (2012) — foreach 변수 캡처 시맨틱 변경 ★
// 동일한 코드, 컴파일러 버전에 따라 결과가 다르다
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# 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> 같은 상태 매개변수 패턴으로 캡처 없이 동일한 효과를 얻는 방법을 다룹니다.
'C# 기초' 카테고리의 다른 글
| [PART12.제네릭·델리게이트·람다·LINQ(9/18)] 람다의 폐기 매개변수 `_` (C# 9) (0) | 2026.05.07 |
|---|---|
| [PART12.제네릭·델리게이트·람다·LINQ(8/18)] 정적 람다 — `static` 키워드로 캡처 사고를 컴파일 타임에 막는 법 (0) | 2026.05.07 |
| [PART12.제네릭·델리게이트·람다·LINQ(6/18)] 람다식 — `x => x * 2` (0) | 2026.05.07 |
| [PART12.제네릭·델리게이트·람다·LINQ(5/18)] 콜백 함수 — 메서드를 매개변수로 전달하는 패턴 (0) | 2026.05.07 |
| [PART12.제네릭·델리게이트·람다·LINQ(4/18)] 델리게이트 기초 — `Action` · `Func` · `Predicate` (0) | 2026.05.07 |