C# - 델리게이트(Delegae). 람다식과 캡쳐, 클로져 문제 (feat. useState)

2025. 11. 4. 17:07·C#

사전 지식

델리게이트(Delegate) : 함수를 변수처럼 저장하고 실행할 수 있는 타입.

void PrintHello() => Console.WriteLine("Hello");
void PrintBye() => Console.WriteLine("Bye");

Action printer = PrintHello; // 함수 참조를 저장
printer(); // Hello

printer = PrintBye; 
printer(); // Bye

 

람다식(Lambda Expression) : 화살표 함수, 익명 함수 등으로 불리는 간단한 표현 문법.

Func<int, int> square = (x) => x * x;
Console.WriteLine(square(5)); // 25

 


캡쳐 (Capture)

람다식이 외부 스코프의 변수를 참조할 때, 그 변수를 "함수와 함께 저장하는 현상"을 캡쳐라 한다.

이전 글에서도 간단하게 다루었던 바 있다.

void Example()
{
    int val = 42;
    Func<int> getVal = () => val; // 외부 변수(val) 사용
    Console.WriteLine(getVal());  // 42
}

 

여기서 val은 Example 메서드 안에서 선언된 지역 변수지만, getVal 람다식 안에서 쓰이고 있다.

일반적으로 함수식 밖에 있는 변수이니 당연히 사용할 수 있지만, 이 경우 값 복사가 일어나 원본 val에는 영향을 미치지 않아야 한다.

하지만 람다식이 외부 변수를 참조할 경우 Stack이 아닌 Heap에 저장되어 참조와 같은 형태로 작동한다.

이로 인해 람다는 외부 변수의 생명주기를 함께 연장시킨다.

이를 캡쳐라 부른다.

 

클로저 (Closure)

캡쳐된 변수와 그 변수를 사용하는 함수가 함께 저장된 객체를 말한다.

즉, "함수가 외부 변수를 기억하는 상태로 존재하는 구조"다.

 

아래 함수를 확인해 보자.

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

 

Counter 내부의 count라는 숫자를 1씩 더하는 람다를 반환하는 간단한 구조다.

당연히, 함수의 생명주기가 다하면 int count 지역변수도 사라지기 때문에 일반적으로는 아무리 실행해도 int가 중첩되어 올라가리라고는 생각하지 않겠지만

 

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

 

실제로는 올라간다.

Counter() 함수가 종료되었는데도 count가 유지되고 있는 것이다.

 

이것이 바로 클로져다.

함수가 count를 "캡쳐"했고, 컴파일러가 그 변수를 따로 Heap으로 보존했기 때문에 발생하는 현상이다.

 


 

클로저의 활용

그럼 이걸 대체 어디다 쓰는가?

버그 같아도 보이는 왜 있는지 모르겠는 이러한 동작도 사실 쓸데가 있기에, 다 의미가 있기에 만든 것일 거다.

 

일단 내가 알고있는 가장 가까운 예시가 하나 있다.

React를 다루던 사람들은 useState를 반드시 써 봤을 것이다.

setState를 호출해서 상태를 갱신한 뒤에도, state 값은 사리지지 않고 남는다.

하지만 전역변수인 static과 같은 동작은 아니며, setState로 상태를 갱신하기 전까지 이전 상태를 계속해서 기억한 상태로 컴포넌트가 언마운트 되기 전까지 남아있는다.

 

이는 명백히 상태관리를 위해 의도된 리액트의 설계일 것이며, 현재 웹 시장의 대부분을 차지하고 있는 React에서 가장 자주 쓰이는 훅 중 하나로 자리잡을 정도면 매우 유용한 기능이라는 증거가 된다.

 

이처럼 이전 상태를 갖고있어야 하는 경우, 하지만 static 과 같은 전역 변수는 아닌 경우의 상태관리에서 사용해볼 수 있겠다.

React가 익숙한 사람이라면 클로저를 이용해 useState와 비슷한 커스텀클래스를 만들어 실전에서 사용해보는 것도 좋겠다.

말이 나온 김에 만들어보자.

 

 

클로저를 이용해 C#으로 useState 구현해 보기

using System;

public static class Hook
{
    public static (Func<T> getState, Action<T> setState) useState<T>(T initialValue)
    {
        T state = initialValue;

        Func<T> getState = () => state;
        Action<T> setState = (newValue) => state = newValue;

        return (getState, setState);
    }
}

 

class Program
{
    static void Main()
    {
        var (count, setCount) = Hook.useState(0);

        Console.WriteLine(count()); // 0
        setCount(1);
        Console.WriteLine(count()); // 1
        setCount(count() + 5);
        Console.WriteLine(count()); // 6
    }
}

 

useState 함수 내부의 지역 변수 state는 getState, setState 두 람다가 공통으로 캡쳐한다.

즉, 두 함수가 동일한 state를 참조하기 때문에 setState로 값을 바꾸면 getState에서도 반영된다.

 

 


클로저로 발생할 수 있는 문제 사례

바깥의 스코프에도 참조를 걸어버리니까 반복문에도 예상치 못한 버그를 걸어버린다.

List<Action> actions  = new List<Action>();

for (int i = 0; i < 10; i++)
{
    actions.Add(() => Console.WriteLine(i));
}

foreach (var action in actions)
{
    action();
}

// 10 10 10 . . . .

 

i가 반복할 때마다 새로 만들어지는 게 아니라, 클로저로 발생되어 참조된 게 10까지 증가한 상태로 계속 남기 때문에 벌어지는 일이다.

이걸 유니티에서 만약 버튼 이벤트 등록에 인지하지 못하고 넣게 된다면

모든 버튼이 리스트의 마지막 것과 같은 이벤트가 등록되는 대참사가 날 것이고,

클로저에 대해 모른다면 원인 조차도 찾기 어려울 것이다.

 

아니... 알더라도 지금이야 하나의 상황을 두고 명시하고 있으니 알 수 있는 것이지 코드 전체에서 찾기란 정말 큰 일일 것이다.

 

일단 이런 클로저가 의도한 게 아니라면 몇가지 방식으로 대처할 수 있다.

지역 변수에 복사해서 해당 값을 참조하는 형태로 사용하여, 참조할 값과 정상적으로 초기화 되어야 할 값을 명확하게 분리해서 사용하는 것이다.

for (int i = 0; i < buttons.Length; i++)
{
    int index = i;
    buttons[i].onClick.AddListener(() =>
    {
        Debug.Log($"Clicked: {index}");
    });
}

 

 

일단 나라면 이런 구조 사용을 지양할 거 같다...

 

 

더군다나 람다식이 계속해서 참조하고 있기 때문에 GC가 자동으로 수거하지도 않는다.

때문에 오남용시 메모리 누수에 이어 심한 경우 Stack Overflow 나 OutOfMemoryException으로 이어질 수 있다.

 

UnityAction action = () => Debug.Log("Clicked");
button.onClick.AddListener(action);

// 해제 시 동일 참조로만 제거 가능
button.onClick.RemoveListener(action);

 

Action myAction = () => Debug.Log("Hello");
myAction();
myAction = null; // 참조 해제

 

이렇게 명시적으로 참조하고 있는 델리게이트 자체를 해제해주는 것으로 클로저를 제거할 수 있다.

'C#' 카테고리의 다른 글

C# : GameJob 신규 공고 알람 앱 - WPF(MVVM), HttpClient, SQLite, Windows Toast  (0) 2026.04.14
C# : Chzzk(치지직) VOD / Clip 다운로더  (0) 2026.03.20
VisualStudioCode(VSC) 에서 한글 깨짐 이슈  (0) 2025.10.28
Design Pattern - Singleton Pattern (싱글톤 패턴)  (0) 2025.10.20
Design Pattern - Adapter Pattern (어댑터 패턴)  (0) 2025.10.17
'C#' 카테고리의 다른 글
  • C# : GameJob 신규 공고 알람 앱 - WPF(MVVM), HttpClient, SQLite, Windows Toast
  • C# : Chzzk(치지직) VOD / Clip 다운로더
  • VisualStudioCode(VSC) 에서 한글 깨짐 이슈
  • Design Pattern - Singleton Pattern (싱글톤 패턴)
ellug
ellug
Web / App / Game Dev
  • ellug
    dev.ellug
    ellug
  • 전체
    오늘
    어제
    • 분류 전체보기 (95)
      • Javascript (React, Next) (1)
      • C# (66)
      • Graphics (1)
      • Unity (25)
      • 프로그래머스 (0)
        • Lv.0 (0)
        • Lv.1 (0)
        • Lv.2 (0)
        • Lv.3 (0)
      • AI Agent (0)
      • StableDiffusion (0)
      • ETC (1)
      • 잡담 (0)
      • 유머 (0)
  • 블로그 메뉴

    • 홈
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    Abstract
    Network
    stack
    최적화
    큐
    스택
    Photon
    콘솔
    유니티
    unity
    Queue
    터미널
    알고리즘
    C#
    추상 클래스
    useState
    function
    Design Pattern
    delegate
    class
    자료구조
    네트워크
    interface
    struct
    console
    firebase
    inputsystem
    게임
    list
    클래스
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.4
ellug
C# - 델리게이트(Delegae). 람다식과 캡쳐, 클로져 문제 (feat. useState)
상단으로

티스토리툴바