개요

보스전이 있는 게임을 떠올려 보자. 화면 위에는 보스 체력바가 있고, 옆에는 퀘스트 기록창이 있다. 둘 다 보스의 “죽었다” 이벤트를 구독해 두고, 보스가 죽으면 알림을 받아 각자 할 일을 한다. 보스 쪽은 알림을 보낸 다음 보상 상자를 떨어뜨린다.

전투 도중 UI가 한 번 다시 만들어지면서 체력바가 닫혔다가 새로 생겼다. 그런데 옛 체력바는 구독을 풀지 않은 채 사라졌다. 이 상태로 보스를 잡으면 보상 상자가 안 떨어진다.

보상 코드에는 아무 문제가 없다. 일은 이렇게 이어진다.

  1. 보스가 “죽었다” 알림을 보낸다
  2. 알림 목록에 아직 남아 있던 옛 체력바도 알림을 받는다
  3. 옛 체력바가 이미 사라진 자기 화면 요소를 만지려다 예외가 난다
  4. 그 예외가 알림 뒤에 있던 보상 지급까지 멈춰 세운다

사라진 체력바가 왜 알림 목록에 남아 있는지, 사라진 객체의 코드가 왜 실행되는지, 예외 하나가 왜 보상까지 막는지는 C#의 delegate와 event가 안에서 무엇을 들고 있는지 보면 하나씩 풀린다.


delegate는 (대상, 메서드) 쌍이다

인스턴스 메서드를 담은 delegate는 인스턴스와 메서드를 함께 참조한다. static 메서드면 메서드만 참조한다.

Action<int> d = hpBar.Refresh;  // d는 (Target = hpBar, Method = Refresh)를 들고 있다

이 한 줄을 실행하면 d가 hpBar 객체에 대한 참조를 갖는다. 아래 내용은 전부 이 성질에서 나온다.


+=와 -=는 새 delegate를 만든다

여러 메서드를 담은 delegate를 multicast delegate라 하고, 담긴 메서드의 순서 있는 목록을 invocation list라 한다.

+=는 기존 delegate에 항목을 끼워 넣는 게 아니라 항목을 붙인 새 delegate를 만들어 변수에 다시 대입한다. -=는 항목을 뺀 새 delegate를 만든다. 어느 쪽이든 원래 delegate는 그대로 남는다.

+=와 -=가 실행될 때마다 onHit이 새 객체를 가리킨다

-=( 내부적으로 Delegate.Remove )에는 규칙이 세 개 있다.

  • Target과 Method가 같은 항목 중 마지막 하나만 지운다
  • 일치하는 항목이 없으면 원본을 그대로 돌려주고 에러를 내지 않는다
  • 결과가 비면 null을 돌려준다. 구독자가 하나도 없을 때 OnHit?.Invoke()처럼 ?.가 필요한 이유다

두 번째 규칙이 다음 절의 함정을 만든다.


람다 -=는 조용히 실패한다

람다는 작성한 자리마다 컴파일러가 이름 없는 메서드를 따로 만든다. 글자가 똑같은 람다라도 Method가 다르다.

void OnEnable()  { bus.OnHit += e => Log(e); }  // 컴파일러가 만든 메서드 #1 등록
void OnDisable() { bus.OnHit -= e => Log(e); }  // 메서드 #2를 지우려 함 → 일치 없음, 원본 그대로

-=에 넘긴 람다는 목록의 어떤 항목과도 일치하지 않고, 결과는 위 규칙대로 “원본 반환, 에러 없음”이다. 해제가 된 줄 알고 넘어가지만 구독은 그대로 남아 있다. 컴포넌트를 껐다 켤 때마다 구독이 하나씩 쌓인다.

이름 있는 메서드는 (this, Log) 쌍이 매번 같아서 해제된다. 이벤트를 받을 때마다 로그를 남기는 기록기를 만들어 껐다 다시 켜 본 적이 있는데, 수신 로그는 1회만 찍혔다. 이름 있는 메서드로 등록하고 해제했기 때문이다. 해제가 빠졌거나 람다로 등록했다면 구독이 겹쳐 2회가 찍혔을 것이다.


event는 대입과 호출을 막는다

public delegate 필드는 외부에서 두 가지 사고를 낼 수 있다.

public Action<int> OnHit;     // 그냥 delegate 필드

boss.OnHit = hpBar.Refresh;   // =로 대입 → 다른 구독자가 전부 지워진다
boss.OnHit.Invoke(9999);      // 외부에서 이벤트를 임의로 발생시킨다

event를 붙이면 선언한 클래스 밖에서는 +=와 -=만 허용되고, 대입과 호출은 컴파일 에러( CS0070 )가 된다.

public event Action<int> OnHit;  // 밖에서는 구독할 권리만, 발생시킬 권한은 Boss에게만

같은 실수를 런타임이 아니라 컴파일 시점에 막는다. 외부에 노출하는 delegate는 event로 선언한다.


구독이 만드는 참조는 한 방향이다

boss.OnHit += hpBar.Refresh 이후 참조를 따라가면 이렇다.

발행자 boss가 invocation list를 거쳐 구독자 hpBar를 붙잡는다

  • 발행자가 구독자보다 오래 살면, 구독자는 할 일이 끝나도 GC가 수거하지 못한다. 이게 이벤트 누수다
  • static event는 프로그램 전체 수명 동안 살아 있는 GC root다. 해제하지 않은 구독자는 씬이 바뀌어도 남는다
  • 구독자가 살아 있다는 이유만으로 발행자가 붙잡히지는 않는다. 구독이 만드는 참조는 발행자 → 구독자 한 방향뿐이다

그래서 체력바는 계속 살아 있고 보스가 먼저 Destroy되는 경우는 이벤트 누수가 아니다. 보스가 사라지면 보스가 들고 있던 invocation list도 함께 수거 대상이 된다.

구독 해제는 발행자와 구독자의 수명이 다를 때 참조를 끊는 작업이다. 발행자가 더 오래 사는 쪽( static 버스, 싱글턴, 씬 내내 사는 보스 )이면 구독자 쪽에서 반드시 해제한다.

GC Alloc은 새로 할당한 양이다

누수를 확인하려고 Unity Profiler를 열면 GC Alloc 열이 눈에 띈다. 나는 이 값의 정의를 잘못 알고 있었다. GC Alloc은 그 프레임에 관리 힙에 새로 할당한 양이다. 메모리를 늘리는 쪽이다. 정리는 GC 수거( collect )다.

“수거 대상이 됨”과 “실제로 수거됨”도 시점이 다르다. 참조가 끊기는 순간이 아니라 GC가 도는 시점에 회수된다. 구독을 해제했다고 그 프레임에 메모리가 줄어드는 게 아니다.


Destroy된 구독자는 여전히 불린다

개요의 보상 사고는 이 절에서 완성된다. 보스 쪽 코드는 이렇게 생겼다.

public class Boss : MonoBehaviour
{
    public event Action OnDeath;

    void Die()
    {
        OnDeath?.Invoke();  // 구독자들에게 보스가 죽었다고 알린다
        GiveReward();       // 그다음 보상 지급
    }
}

Destroy()를 하면 C++ 쪽 엔진 객체는 사라지지만, C# 래퍼 객체는 참조가 남아 있는 한 계속 존재한다. 이 상태에서 Unity가 오버로드한 ==는 null과 같다고 답하지만, ??나 is null 같은 순수 C# 검사는 null로 보지 않는다.

invocation list가 붙잡고 있는 건 이 C# 래퍼다. 그래서 해제하지 않은 구독이 남아 있으면, 발행자가 Invoke할 때 이미 Destroy된 구독자의 핸들러가 그대로 호출된다.

public class HpBar : MonoBehaviour
{
    [SerializeField] Boss boss;

    void Start() => boss.OnDeath += OnBossDeath;  // 구독만 하고 해제는 없다

    void OnBossDeath()
    {
        gameObject.SetActive(false);  // Destroy된 뒤라면 여기서 예외
    }
}

핸들러 안에서 Unity API에 접근하면 예외가 난다. 그리고 invocation list의 한 메서드가 예외를 던지고 그 안에서 잡지 않으면, 예외는 delegate를 호출한 쪽으로 올라가고 목록 뒤쪽 메서드는 호출되지 않는다.

Destroy된 hpBar의 예외가 questLog와 GiveReward를 끊는다

끊기는 건 두 가지다.

  • 목록 뒤쪽의 나머지 핸들러( 퀘스트 기록 같은 것 )
  • 발행자 코드에서 Invoke 뒤에 있던 처리( 보상 지급 )

구독자 하나의 버그가 다른 구독자와 발행자의 로직을 같이 멈춘다. 그래서 꼭 실행돼야 하는 처리는 이벤트를 쏘기 전에 둔다. 보스라면 보상을 먼저 지급하고 알림을 마지막에 보낸다. 멀티플레이 프로젝트에서 서버 판정을 먼저 처리하고 기록용 이벤트 발행을 맨 뒤에 둔 것도 같은 이유였다. 기록 쪽 버그가 게임 판정을 막지 않게 하려고.

반대 방향 참조는 필드가 만든다

체력바는 구독하려고 [SerializeField] Boss boss;로 보스를 들고 있다. 이 필드는 구독과 별개로 구독자 → 발행자 참조를 만든다. 보스가 Destroy돼도 체력바가 살아 있는 동안 보스의 C# 껍데기와 그 필드들이 메모리에 남는다. 원인은 이벤트 구독이 아니라 필드 참조다.


Invoke 도중에 구독이 바뀌면

Invoke는 시작 시점에 필드가 가리키던 invocation list 객체를 순회한다. 도중에 -=나 +=가 실행되면 필드는 새 객체를 가리키지만, 진행 중인 호출은 이전 목록으로 끝까지 돈다.

Invoke 도중 b가 자기를 해제해도 이번 호출은 a, b, c를 끝까지 돈다

변경은 다음 호출부터 적용된다. 그래서 “한 번 받고 자기 자신을 해제하는” 구독자도 안전하다.

void OnFirstHit(int damage)
{
    boss.OnHit -= OnFirstHit;  // 이번 Invoke는 원래 목록으로 끝까지 돈다
    ShowTutorialPopup();       // 첫 피격 때 한 번만
}

delegate가 불변이라서 생기는 성질이다. 구독 시스템을 List<Action>으로 직접 만들면 이게 공짜가 아니다. foreach로 도는 중에 핸들러가 자기를 Remove하면 컬렉션 수정 예외가 난다. 순회 전에 목록을 복사하거나, 해제를 순회가 끝난 뒤로 미루는 걸 직접 구현해야 한다.


정리

  • delegate는 (Target, Method) 쌍을 들고 있다. 인스턴스 메서드를 구독하면 그 인스턴스를 붙잡는다
  • +=/-=는 새 delegate를 만든다. -=는 일치하는 게 없어도 에러 없이 원본을 돌려준다
  • 구독과 해제는 이름 있는 메서드로 한다. 람다 -=는 실패해도 조용하다
  • 외부에 노출하는 delegate는 event로 선언한다
  • 구독은 발행자 → 구독자 한 방향으로 붙잡는다. 발행자가 오래 살면 구독자 쪽에서 해제한다
  • Destroy된 구독자의 핸들러도 호출되고, 그 예외는 목록 뒤쪽과 Invoke 뒤의 처리를 끊는다
  • Invoke 도중의 구독 변경은 다음 호출부터 적용된다

더 읽어볼 만한 것들


소감

게임 개발 코드들을 직접 작성한 때나, AI 코드를 검토할 때 단순하게 읽었고 정의만 알던 개념에 대해 다시한번 짚어보니 나름대로 의미있었던 것 같다.