2025. 9. 7. 18:23ㆍSTUDY/C#
클로저의 개념
일반적으로 지역변수는 함수가 끝나면 스택에서 사라진다. 하지만 람다식(익명 메서드)과 같은 함수는 단순히 '코드 조각'이 아니라 외부 변수를 캡쳐해서 함께 저장하는 객체를 만든다. 즉 컴파일러가 람다용으로 힙에 별도의 참조형 객체를 만들어 그 안에 저장한다.
이렇듯, 함수(람다, 익명 메서드)가 자기 외부의 지역 변수를 캡처해서, 함수 실행이 끝난 뒤에도 그 변수를 계속 참조할 수 있게 하는 기능을 클로저(Closure)라고 한다.
예시
void Example()
{
var isInit = false;
Action<int> callback = uid =>
{
if (isInit)
Console.WriteLine("다시 호출됨");
else
Console.WriteLine("최초 호출");
isInit = true;
};
callback(10); //최초 호출
callback(20); //다시 호출됨
}
isInit 은 Example 메서드 내부의 지역 변수이다. 그런데 uid => {...} 이 람다식 안에서 isInit을 참조하고 수정하고 있다. 이때 람다가 해당 지역 변수를 캡처(closure)했다고 표현한다.
보통 지역 변수라면 Example 메서드가 끝날 때 사라져야 한다. 하지만 컴파일러는 isInit를 클로저 컨텍스트 객체(closure context) 로 옮겨서 람다가 계속 접근할 수 있게 바꾼다. 그래서 메서드가 끝난 뒤에도 내부에서 나중에 콜백이 실행될때 isInit값이 그대로 유지된다.
컴파일러 개념적인 내부 변환
// 컴파일러가 암묵적으로 만드는 클래스 (클로저 컨텍스트)
sealed class <>c__DisplayClass0_0
{
public bool isInit; // 캡처된 지역변수 → 필드
public void <Example>b__0(int uid) // 람다 본문 → 인스턴스 메서드
{
if (this.isInit)
Console.WriteLine("다시 호출됨");
else
Console.WriteLine("최초 호출");
this.isInit = true;
}
}
void Example()
{
var context = new <>c__DisplayClass0_0();
context.isInit = false;
Action<int> callback = new Action<int>(context.<Example>b__0);
callback(10);
callback(20);
}
위와 같이 컴파일러가 내부적으로 바꿔버린다. 즉, isInit은 지역 변수처럼 보이지만, 람다에서 사용되면 클로저 컨텍스트의 필드로 승격되고, 그래서 이후에 콜백이 여러번 불려도 값이 계속 유지되어, 최초 호출인지, 다시 호출인지 같은 상태를 간단히 관리할 수 있게 된다.
IL 코드
.class nested private auto ansi sealed beforefieldinit '<>c__DisplayClass0_0'
extends [System.Runtime]System.Object
{
.field public bool isInit
.method public hidebysig specialname rtspecialname
instance void .ctor() cil managed
{
IL_0000: ldarg.0
IL_0001: call instance void [System.Runtime]System.Object::.ctor()
IL_0006: ret
}
// 람다 본문: void <Example>b__0(int32 uid)
.method assembly hidebysig instance void '<Example>b__0'(int32 uid) cil managed
{
// if (this.isInit) ...
IL_0000: ldarg.0
IL_0001: ldfld bool '<>c__DisplayClass0_0'::isInit
IL_0006: brfalse.s IL_0013
// true 분기: "다시 호출됨"
IL_0008: ldstr "다시 호출됨"
IL_000d: call void [System.Console]System.Console::WriteLine(string)
IL_0012: br.s IL_001e
// false 분기: "최초 호출"
IL_0013: ldstr "최초 호출"
IL_0018: call void [System.Console]System.Console::WriteLine(string)
// this.isInit = true;
IL_001e: ldarg.0
IL_001f: ldc.i4.1
IL_0020: stfld bool '<>c__DisplayClass0_0'::isInit
IL_0025: ret
}
}
- ldfld bool '<>c__DisplayClass0_0'::isInitDropDown
→ 클로저 컨텍스트 객체의 isInitDropDown 필드를 읽기(load field). - stfld bool '<>c__DisplayClass0_0'::isInitDropDown
→ 그 필드에 새로운 값을 쓰기(store field).
즉, 원래 지역 변수처럼 보이던 isInit은 실제로는 힙 객체 필드로 변환되고, 람다가 실행될때마다 이 필드를 읽고 쓴다.
클로저의 장단점
장점
1. 간결하고 직관적인 코드
|
단점
1. 예상치 못한 메모리 누수
캡처된 변수들은 클로저 컨텍스트 객체에 담겨 힙에 살아남는다. 람다가 살아있는 동안 GC로 수거되지 않아, 의도치 않게 큰 객체를 오래 붙잡고 있을 수 있다.
2. 루프 캡처 버그
//의도 : 0,1,2 실제 출력 : 3,3,3
var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
actions.Add(() => Console.WriteLine(i)); // i를 그대로 캡처
}
foreach (var a in actions) a(); // 3, 3, 3
for문의 i는 하나의 변수로 존재하고, 람다들은 그 하나의 변수를 공유해서 루프 종료 시점값을 보게 된다. 이를 방지하기 위해 복사본을 만들어 각 반복마다 다른 변수를 캡처하게끔 해야한다.
3. 성능 오버헤드
컴파일러가 클로저 컨텍스트 클래스를 생성하고, 변수를 힙에 할당한다. 자잘한 람다 남발시, 성능 저하 및 메모리 압박, GC 부담을 만들 수 있다.
4. 읽기 어려운 코드(암묵성)
클로저가 어떤 변수를 캡처하는지 한눈에 안보이면 가독성이 떨어지기 때문에 디버깅시 '값이 왜 안사라지고 남아있지?' 와 같은 혼란이 생긴다.
이렇게 보면 단점이 눈에 띄어서 '안쓰는게 낫지 않나?' 싶지만 '적재적소'에 쓰면 생산성을 크게 올려주는 도구이다. 핵심은 언제 쓰고, 언제 피하고, 어떻게 위험을 줄이느냐 이다.
언제 '써야' 이득인가?
UI 버튼 핸들러, 네트워킹 완료 콜백 등 '나중에 실행'되는 작업에 작은 문맥을 넘길때 클로저를 사용한다면 간결하게 상태를 넘길 수 있다. 이렇듯 콜백/이벤트에서 주변 상태를 살짝 들고 가야할때 활용할 수 있다.
언제 '피해야' 하는가?
매 프레임/루프에서 매번 캡처가 생기거나 수명 긴 콜백이 큰 객체들을 붙잡는 경우 GC나 메모리 누수가 발생할 수 있다.
유니티로 버튼 이벤트 예시
button.onClick.AddListener(OnClick); // 메서드 그룹: 캡처 없음, 할당 없음
static void OnClick() { /* ... */ }
'캡처 없는' 람다/델리게이트를 우선으로 사용한다.
void Bind(Button btn, int slotId) {
btn.onClick.AddListener(() => SelectSlot(slotId)); // slotId만 가볍게 캡처
}
클로저 활용하여 간단하게 상태를 넘길 수 있다면 활용했을때 간결하게 구현할 수 있다.
class SlotSelector {
readonly Inventory _inv;
public SlotSelector(Inventory inv) { _inv = inv; }
public void Bind(Button btn, int slotId) {
btn.onClick.AddListener(() => Select(slotId));
}
void Select(int id) { /* _inv 사용 */ }
}
긴 수명이나 큰 상태일때는 명시적으로 선언하는 것이 좋다.
'STUDY > C#' 카테고리의 다른 글
| IL 코드 명령어들 (0) | 2025.09.07 |
|---|---|
| 공용 언어 런타임(CLR)의 실행 모델 (1) | 2025.02.02 |
| [C#] async, await (0) | 2024.12.04 |
| [C#] 동기 비동기 개념 이해하기 (0) | 2024.11.28 |
| [C#] Array vs List vs ArrayList (0) | 2024.11.12 |