캡처와 클로저 ( Capture & Closure )
C# 에서 람다식이나 익명 함수를 사용할 때 , 바깥에 있는 지역 변수에 접근하는 코드가 종종 등장한다.
int bonus = 10;
Func<int, int> calc = x => x + bonus;
이 때 bonus 는 calc 함수 내부에서 정의된 변수가 아니다.
그럼에도 불구하도 calc 는 bonus 에 접근하고 그 값을 참조한다.
이런 현상을 변수 캡처 ( Capture )라고 한다.
그리고 이런 캡처 기능을 가진 함수 객체를 클로저 ( Closure ) 라고 부른다.
변수 캡처 ( Capture )
변수 캡처는 람다식이나 익명 함수가 자신이 선언된 위치의 외부 변수를 참조하는 것이다.
변수를 복사해서 저장하는 것이 아니라 , 변수의 참조를 저장한다.
람다 ( 또는 익명 메서드 )가 , 바깥에 있는 지역 변수를 참조하기 위해 그 변수를 "잡아서 저장해 두는 것"
int x = 10;
Action printX = () =>
{
Console.WriteLine(x);
};
x = 20;
printX();
이 코드는 x 에 대한 복사본을 사용하는 것이 아니라, x 변수의 현재 값을 항상 참조한다.
- 많은 사람들이 "10이겠지?" 라고 착각하지만
- 실제 출력은 20 이다.
왜 20이 나올까?
- 람다는 x의 "값"을 복사해두는 것이 아니다.
- x 라는 변수 자체를 캡처해서 이후에 x 가 변경되면 클로저 안에서도 변경된 값을 보게 된다.
클로저는 "x 의 스냅샷 ( 값 한 번 )" 이 아니라
"x 라는 박스를 통째로 들고 다닌다" 고 이해하면 편하다.
클로저 ( Closure )
캡처한 변수를 포함하고 있는 함수 객체를 클로저라고 한다.
람다식과 익명 함수는 클로저를 생성할 수 있는 구조를 가지고 있다.
함수 ( 람다 )가, 자신이 만들어질 때 주변에 있던 변수들을 "잡아서 같이 들고 다니는" 기능
Func<int, int> makeAdder(int value)
{
return x => x + value;
}
이 함수는 내부에 value 라는 지역 변수를 가지고 있으며 반환되는 람다식은 그 value 를 캡처한다.
makeAdder 함수의 실행이 끝나더라도 value 는 클로저 안에서 계속 살아있다.
- 보통 변수는 스코프 ( Scope ) 를 벗어나면 사라진다.
- 그런데 클로저가 해당 변수를 캡처 ( Capture ) 하면
함수가 살아 있는 동안 그 변수도 계속 살아있고
함수 안에서 읽고 / 쓰기가 가능해진다.
C# 에서는 람다식 , 익명 메서드가 클로저를 많이 사용한다.
내부에서 어떻게 구현하는지 정리
C# 컴파일러는 캡처가 필요한 경우 , 익명 클래스를 자동으로 생성한다.
해당 클래스는 캡처된 변수들을 필드로 저장하고 , 람다식은 이 익명 클래스의 메서드처럼 처리된다.
▼ 컴파일러는 이런 코드를 보면
int x = 10;
Action a = () => Console.WriteLine(x);
▼ 이런 식으로 "숨겨진 클래스"를 자동으로 만든다고 생각하면 된다.
class DisplayClass
{
public int x; // 캡처된 변수
public void Print()
{
Console.WriteLine(x);
}
}
var obj = new DisplayClass();
obj.x = 10;
Action a = obj.Print;
- x 는 이제 DisplayClass 인스턴스의 필드가 된다.
- 람다 ( ) => Console.WriteLine( x ) 는 사실 obj.Print 메서드로 바뀐다.
- 그래서 스코프를 벗어났어도 , obj 가 살아있는 동안 x 도 계속 살아있는 것처럼 보이게 된다.
이게 클로저 + 캡처의 실체라고 보면 된다.
▼ 예시 코드 1
int counter = 0;
Action incr = () =>
{
counter++;
Console.WriteLine(counter);
};
incr(); // 1
incr(); // 2
incr(); // 3
- counter 는 원래 지역 변수
- 하지만 incr 가 counter 를 캡처했기 때문에
incr 델리게이트가 존재하는 동안 counter 도 살아있고 값이 누적된다.
▼ 예시 코드 2
Action[] actions = new Action[3];
for (int i = 0; i < 3; i++)
{
actions[i] = () => Console.WriteLine(i);
}
foreach (var act in actions)
{
act();
}
▼ 초보자 예상
0
1
2
▼ 실제 출력
3
3
3
- 람다는 매번 새로운 i 를 캡처하는 것이 아니라 루프에 있는 같은 i 변수를 하나 캡처한다
- for 문이 다 돌고 나면 i 는 3 이 되어있다.
- actions 안의 모든 람다는 그 동일한 i 를 보고 있으므로 3, 3, 3 을 출력한다.
▼ 올바르게 쓰려면 , 루프 안에서 별도 변수에 복사해서 캡처해야 한다.
for (int i = 0; i < 3; i++)
{
int temp = i; // 새로운 박스
actions[i] = () => Console.WriteLine(temp);
}
▼ 출력
0
1
2
캡처 & 클로저의 장점
- 상태를 가지는 함수를 만들 수 있다
함수가 환경 ( 변수 )을 함께 들고 다니면서 일종의 "작은 객체" 처럼 행동한다 - 코드를 간결하게 만들 수 있다
별도 클래스를 정의하지 않고 , 람다만으로 콜백 / 이벤트 핸들러 작성 가능하다 - 고차 함수 , 함수형 스타일에 잘 어울린다
LINQ 이벤트 시스템 , 비동기 로직 등에서 유용하다
캡처 & 클로저의 단점
- 의도치 않은 변수 공유
for 루프 예제처럼 "각 람다가 각자 다른 값을 잡을 줄 알았는데 실제로는 하나를 공유" 하는 문제 - 생명 주기가 길어진다
원래는 블록을 벗어나면 사라질 지역 변수가
클로저에 캡처되는 바람에 힙 객체 안에서 오래 살아남을 수 있다.
큰 객체를 캡처하면 메모리가 오래 잡혀 있을 수도 있다. - 디버깅이 헷갈릴 수 있다
값이 언제 바뀌는지 , 어떤 람다가 어떤 변수를 보고 있는지 파악이 어렵다
"어디서 변경됐지?" 라는 고민을 많이 하게 된다
C# 에서 클로저는 함수형 프로그래밍을 구성하는 중요한 요소이며 , 상태를 함수 안에 캡슐화할 수 있는 수단으로 사용된다.
클로저를 사용하면 좋을 상황
- 함수가 바깥 변수의 상태를 기억해야 할 때
- 코드 실행 시점과 함수 정의 시점이 다를 때
- 비동기 처리나 이벤트 핸들러에서 외부 상태에 접근할 때
정리
캡처 : 박스 ( 외부 변수 )를 람다 안으로 끌어오는 과정
클로저 : 변수 박스를 통째로 들고 다니는 함수
'⭐C Sharp > 12. 델리게이트' 카테고리의 다른 글
| invoke (0) | 2025.10.09 |
|---|---|
| 델리게이트 ( Delegate ) (0) | 2025.09.28 |