CodeGym /행동 /C# SELF /클로저

클로저

C# SELF
레벨 51 , 레슨 3
사용 가능

1. 도입

프로그래밍에서 클로저(closure)는 JavaScript에서 문을 닫는 방식이 아니라, 람다 표현식이나 익명 메서드가 주변 컨텍스트의 변수를 캡처하고 그 변수가 선언된 블록이 끝난 뒤에도 그 값을 "기억"하는 메커니즘입니다. 간단히 말해, 클로저는 자신이 태어난 환경을 기억하는 함수로, 작은 여행 가방처럼 개인적인 값들(변수들)을 보관합니다.

클로저는 함수와 그 함수가 만들어졌을 때 존재하던 환경(scope)이 합쳐진 것입니다.

가장 단순한 클로저 예제

실습으로 살펴봅시다:


Func<int> MakeCounter()
{
    int count = 0;
    return () =>
    {
        count++;
        return count;
    };
}

다음처럼 호출해봅시다:


var counter = MakeCounter();
Console.WriteLine(counter()); // 1
Console.WriteLine(counter()); // 2
Console.WriteLine(counter()); // 3

이게 어떻게 동작하나?

  • 변수 count는 메서드 MakeCounter 안에서 선언되었습니다.
  • 람다 () => { ... }가 외부로 반환되어 이제 메서드 밖에서 살아있습니다.
  • 하지만! 그 람다는 메서드 MakeCounter가 끝난 이후에도 변수 count를 기억합니다.

이것이 바로 클로저입니다: 람다가 주변 컨텍스트의 변수 count를 "캡처"했습니다.

람다는 정확히 무엇을 캡처하나?

  • 주변 메서드(scope)의 로컬 변수들,
  • 메서드의 파라미터들,
  • 블록(for, foreach 등) 안의 변수들.

중요: 변수들은 값이 아니라 참조로 캡처됩니다! 클로저 안에서 변수를 변경하면 "외부"에서도 변경됩니다. 실제로 C# 컴파일러는 이런 변수들을 위해 특수 보조 클래스를 생성하지만, 개념적으로는 클로저를 이렇게 이해하면 충분합니다.

2. 클로저와 렉시컬 범위

클로저를 이용한 "함수 팩토리" 예제를 발전시켜봅시다:


Func<int, int> PowerFactory(int power)
{
    return x =>
    {
        int result = 1;
        for (int i = 0; i < power; i++)
            result *= x;
        return result;
    };
}

사용 예:


var square = PowerFactory(2);   // x^2
var cube = PowerFactory(3);     // x^3

Console.WriteLine(square(5)); // 25
Console.WriteLine(cube(2));   // 8

함수 squarecube는 서로 다른 값의 변수 power로 생성되었고, 각각 자신의 값을 기억합니다. PowerFactory를 호출할 때마다 캡처된 값들의 "작은 배낭"이 생성됩니다.

3. 캡처된 변수의 변화(뮤테이션)

루프 안에서 루프 변수 하나를 캡처하는 여러 람다를 만들면 무슨 일이 일어나는지 자주 궁금해집니다. 여기서 실수하기 쉽습니다.

예제: 루프 안의 클로저


var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
    actions.Add(() => Console.WriteLine(i));
}
foreach (var action in actions)
    action(); // ???

무엇을 기대하나요? 0, 1, 2? 실제 출력은 다음과 같습니다:

3
3
3

왜 그럴까요? 모든 람다가 같은 변수 i를 참조합니다. 루프가 끝날 때 i는 이미 3이고, 모든 Action은 그 값을 보게 됩니다.

수정된 버전

각 람다가 "자기만의" 값을 캡처하게 하려면 루프 몸체 안에서 새 변수를 만듭니다:


var actions = new List<Action>();
for (int i = 0; i < 3; i++)
{
    int copy = i;
    actions.Add(() => Console.WriteLine(copy));
}
foreach (var action in actions)
    action(); // 0 1 2

이제 copy는 각 반복마다 새로운 변수이고, 클로저는 바로 그것을 캡처합니다.

4. 실제 과제에서의 클로저 활용

데이터 처리와 콜백

비동기 작업을 하거나 실행을 지연할 때(이벤트 핸들러, 필터링, 작업 스케줄링) — 클로저는 로직과 파라미터를 함께 "포장"할 수 있게 해줍니다. 예를 들어:


void ProcessList(List<int> list, int threshold)
{
    var filtered = list.Where(x => x > threshold);
    foreach (var item in filtered)
        Console.WriteLine(item);
}

여기서 Where 안의 람다는 변수 threshold를 캡처합니다.

함수 "팩토리" 만들기

파라미터를 전달하면 그 파라미터가 내장된 함수를 얻습니다. 이 기법은 필터링 설정, 정렬 비교자, UI 반응 등을 구성할 때 유용합니다.

상태 관리

가끔은 별도 클래스를 만들지 않고도 약간의 상태를 보관할 필요가 있습니다:


Func<string, string> CreateGreeting()
{
    string prefix = "Hello";
    return name =>
    {
        return $"{prefix}, {name}!";
    };
}

5. 유용한 뉘앙스

내부 동작: C#에서 클로저는 어떻게 동작하나

람다가 캡처한 모든 것은 컴파일러에 의해 보조 클래스로 변환됩니다: 변수들은 필드가 되고, 람다는 그 클래스의 메서드가 됩니다. 그래서 변수들의 상태는 호출 사이에 유지됩니다.

각 "팩토리"는 작은 객체를 생성합니다. .NET은 이런 객체들을 효율적으로 관리하며 실제로 필요할 때만 할당합니다.

메모: 불필요하게 "큰" 객체를 캡처하지 말 것

큰 객체(예: UI 폼)를 캡처하면 그 객체는 람다가 살아있는 동안 해제되지 않습니다. 전형적인 메모리 누수 원인은 캡처된 "무거운" 컨텍스트와 함께 람다로 이벤트에 구독하고 나서 구독 해제를 하지 않는 것입니다 (+=/-=).

클로저와 컬렉션의 생명주기 — LINQ 예제

클로저 덕분에 LINQ가 유연해집니다: 필터는 자신의 파라미터를 기억합니다.


List<string> colors = new List<string> { "Red", "Green", "Blue", "Yellow" };
string startsWith = "B";
var filtered = colors.Where(c => c.StartsWith(startsWith));
foreach (var color in filtered)
    Console.WriteLine(color); // "Blue"

그런데 startsWith를 나중에 바꾸면 결과도 바뀝니다:


startsWith = "R";
foreach (var color in filtered)
    Console.WriteLine(color); // "Red"

이렇게 되는 이유는 클로저가 같은 변수 startsWith를 참조하고 있고, StartsWith는 매번 현재 값을 확인하기 때문입니다.

6. 클로저 작업 시 흔한 실수

실수 #1: 사용 시점까지 변경되는 변수를 캡처함.
루프 안에서 흔히 발생하는 상황: 클로저 안의 람다가 같은 루프 변수를 보고 있고, 호출 시점에는 이미 값이 바뀌어 기대와 다른 동작을 합니다. 해결법은 루프 내부에 별도 변수를 도입하는 것입니다.

실수 #2: 너무 큰 컨텍스트를 캡처함.
클로저가 특정 필드/값 대신 전체 객체를 끌고 오면 불필요한 의존성이 생기고 코드가 복잡해집니다. 필요한 것만 캡처하세요.

실수 #3: 무거운 리소스를 붙들고 있어 메모리 누수가 발생함.
람다로 이벤트에 구독하고 그 람다가 "무거운" 객체를 캡처한 뒤 구독을 해제하지 않으면 객체가 해제되지 않습니다. 구독의 생명주기를 관리하고 명시적으로 구독 해제(-=)를 하세요.

실수 #4: 코드의 관리성 상실.
클로저를 과도하게 사용하면 데이터의 출처와 변화 지점을 파악하기 어려워집니다. 로직을 가능한 가까운 곳에 두고 남용하지 마세요.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION