블로그로 돌아가기
C#

C# event handler 메모리 누수가 발생하는 이유와 해결 방법

C# 이벤트를 구독한 객체가 해제되지 않는 이유를 참조 구조와 가비지 컬렉션 관점에서 설명합니다. 이벤트 구독 해제, IDisposable, 익명 함수 처리, 약한 이벤트 패턴의 선택 기준도 함께 정리합니다.

C#.NETEventHandlerEventGarbage CollectionIDisposable
C# event handler 메모리 누수가 발생하는 이유와 해결 방법

C# event handler 메모리 누수가 발생하는 이유와 해결 방법

화면을 열었다 닫을 때마다 객체 수가 늘어나거나, 이미 종료한 객체의 이벤트 처리기가 계속 실행된다면 이벤트 구독을 먼저 확인해야 합니다.

C# 이벤트는 게시자와 구독자를 연결할 때 강한 참조를 만듭니다. 이벤트를 발생시키는 게시자가 구독자보다 오래 살아 있고 구독을 해제하지 않으면, 구독자는 더 이상 사용되지 않아도 가비지 컬렉션 대상이 되지 않을 수 있습니다.

다만 +=를 사용했다고 항상 메모리 누수가 발생하는 것은 아닙니다. 핵심은 게시자와 구독자의 수명 차이입니다.

적용 범위

이 글은 특정 UI 프레임워크에 종속되지 않은 일반적인 C# 이벤트를 기준으로 설명합니다.

  • C#의 eventEventHandler
  • 인스턴스 이벤트와 정적 이벤트
  • 이벤트 게시자와 구독자의 객체 수명
  • IDisposable을 이용한 구독 해제
  • 익명 함수와 람다 이벤트 처리기
  • WPF 등 일부 프레임워크의 약한 이벤트 패턴

이벤트 구독으로 객체가 남는 코드

다음 MessageBus는 애플리케이션에서 오래 유지되는 이벤트 게시자입니다.

public sealed class MessageBus { public event EventHandler? MessageReceived; public void Publish() { MessageReceived?.Invoke(this, EventArgs.Empty); } }

MessageViewMessageBus의 이벤트를 구독합니다.

public sealed class MessageView { private readonly MessageBus _messageBus; public MessageView(MessageBus messageBus) { _messageBus = messageBus; _messageBus.MessageReceived += OnMessageReceived; } private void OnMessageReceived(object? sender, EventArgs e) { Console.WriteLine("메시지를 받았습니다."); } }

외부 코드가 MessageView 참조를 제거하더라도 MessageBus가 계속 살아 있다면 MessageView가 바로 수집되지 않을 수 있습니다.

var messageBus = new MessageBus(); var view = new MessageView(messageBus); // 외부 코드에서는 더 이상 view를 사용하지 않습니다. view = null;

view 변수에 null을 대입했다고 해서 모든 참조가 사라지는 것은 아닙니다. MessageBus의 이벤트가 MessageView.OnMessageReceived를 가리키는 델리게이트를 보관하고 있기 때문입니다.

이벤트가 구독자를 참조하는 구조

인스턴스 메서드를 이벤트 처리기로 등록하면 델리게이트에는 다음 정보가 포함됩니다.

  • 호출할 메서드
  • 해당 메서드를 실행할 대상 객체
  • 같은 이벤트에 등록된 다른 처리기 목록

앞의 예제에서는 다음과 같은 참조 경로가 만들어집니다.

오래 살아 있는 객체 또는 GC Root MessageBus MessageReceived 이벤트 OnMessageReceived 델리게이트 MessageView 인스턴스

가비지 컬렉터는 사용자가 객체를 더 이상 사용할 의도가 있는지를 판단하지 않습니다. 루트에서 객체까지 도달할 수 있는 참조가 존재하는지를 검사합니다.

MessageBus가 여전히 도달 가능한 상태라면 이벤트가 참조하는 MessageView도 도달 가능한 객체로 처리됩니다. 가비지 컬렉터가 객체를 수집하지 않는 것은 정상적인 동작입니다.

이 때문에 이벤트로 인한 문제는 관리되지 않는 메모리가 해제되지 않는 전통적인 누수와 조금 다릅니다. 더 이상 필요하지 않은 객체가 참조 체인에 남아 계속 도달 가능한 상태가 되는 논리적인 메모리 누수입니다.

모든 이벤트 구독이 메모리 누수를 만드는 것은 아닙니다

다음 조건에서는 이벤트 구독을 해제하지 않아도 문제가 생기지 않을 수 있습니다.

  • 게시자와 구독자가 같은 시점에 함께 제거됩니다.
  • 이벤트 게시자가 구독자보다 먼저 제거됩니다.
  • 이벤트 게시자 자체가 외부에서 더 이상 참조되지 않습니다.
  • 애플리케이션 종료 시점까지 유지할 의도로 구독했습니다.

예를 들어 구독자가 게시자를 직접 소유하고 두 객체가 함께 수집된다면 객체 간 순환 참조가 있어도 가비지 컬렉터가 수집할 수 있습니다.

public sealed class Dialog { private readonly DialogTimer _timer = new(); public Dialog() { _timer.Tick += OnTick; } private void OnTick(object? sender, EventArgs e) { } }

외부에서 DialogDialogTimer에 접근할 수 있는 참조가 모두 사라진다면 두 객체는 함께 수집될 수 있습니다.

문제가 되는 구조는 주로 다음과 같습니다.

게시자의 수명 > 구독자의 필요한 수명

애플리케이션 전체에서 유지되는 서비스, 싱글턴, 정적 이벤트, 타이머, 이벤트 버스에 짧게 사용되는 화면이나 작업 객체가 구독할 때 특히 주의해야 합니다.

해결 방법 1: 사용이 끝날 때 이벤트 구독 해제하기

이벤트 구독은 +=로 추가하고 -=로 제거합니다.

public sealed class MessageView { private readonly MessageBus _messageBus; public MessageView(MessageBus messageBus) { _messageBus = messageBus; _messageBus.MessageReceived += OnMessageReceived; } public void Close() { _messageBus.MessageReceived -= OnMessageReceived; } private void OnMessageReceived(object? sender, EventArgs e) { Console.WriteLine("메시지를 받았습니다."); } }

화면이나 객체의 수명 종료 지점이 명확하다면 그 지점에서 이벤트를 제거합니다.

var view = new MessageView(messageBus); try { // view를 사용합니다. } finally { view.Close(); }

구독 해제가 필요한 시점

다음과 같은 수명 주기를 기준으로 해제할 수 있습니다.

  • 화면이 닫힐 때
  • 컴포넌트가 제거될 때
  • 세션이 종료될 때
  • 작업이 취소될 때
  • 객체의 Dispose가 호출될 때
  • 새로운 게시자로 교체하기 전

구독 코드와 해제 코드가 서로 멀리 떨어지면 한쪽을 수정하고 다른 쪽을 놓치기 쉽습니다. 가능하면 객체의 생성과 종료 흐름 안에서 두 코드를 함께 관리합니다.

해결 방법 2: IDisposable에서 구독 해제하기

이벤트를 구독하는 객체가 명확한 종료 시점을 가진다면 IDisposable로 정리 책임을 표현할 수 있습니다.

public sealed class MessageView : IDisposable { private readonly MessageBus _messageBus; private bool _disposed; public MessageView(MessageBus messageBus) { _messageBus = messageBus; _messageBus.MessageReceived += OnMessageReceived; } private void OnMessageReceived(object? sender, EventArgs e) { if (_disposed) { return; } Console.WriteLine("메시지를 받았습니다."); } public void Dispose() { if (_disposed) { return; } _messageBus.MessageReceived -= OnMessageReceived; _disposed = true; } }

호출자는 using 또는 명시적인 Dispose 호출로 수명 종료를 표현합니다.

using var view = new MessageView(messageBus); // view를 사용합니다.

블록 형태로 사용할 수도 있습니다.

using (var view = new MessageView(messageBus)) { messageBus.Publish(); }

using 블록이 끝나면 Dispose가 호출되고 이벤트 구독이 제거됩니다.

IDisposable이 적합한 경우

다음 조건에 해당하면 IDisposable을 검토합니다.

  • 객체가 하나 이상의 이벤트를 구독합니다.
  • 명확한 사용 종료 시점이 있습니다.
  • 다른 리소스와 함께 정리 작업을 수행합니다.
  • 호출자에게 정리 책임을 드러내야 합니다.
  • 여러 코드 경로에서 일관된 해제 방식이 필요합니다.

파이널라이저에 의존하면 안 되는 이유

다음과 같이 파이널라이저에서 이벤트를 해제하려는 접근은 문제를 해결하지 못할 수 있습니다.

public sealed class MessageView { private readonly MessageBus _messageBus; public MessageView(MessageBus messageBus) { _messageBus = messageBus; _messageBus.MessageReceived += OnMessageReceived; } ~MessageView() { _messageBus.MessageReceived -= OnMessageReceived; } private void OnMessageReceived(object? sender, EventArgs e) { } }

이벤트 게시자가 MessageView를 계속 참조하면 MessageView는 수집 대상이 되지 않습니다. 수집 대상이 아니므로 파이널라이저가 실행될 기회도 오지 않을 수 있습니다.

이벤트 구독 해제는 파이널라이저가 아니라 명시적인 수명 종료 코드에서 처리해야 합니다.

해결 방법 3: 익명 함수는 델리게이트 변수에 저장하기

다음처럼 익명 함수로 이벤트를 구독하면 코드가 짧아집니다.

messageBus.MessageReceived += (_, _) => { Console.WriteLine("메시지를 받았습니다."); };

하지만 나중에 같은 처리기를 -=에 전달하려면 처음 등록한 델리게이트를 보관해야 합니다.

다음처럼 새로운 람다식을 작성하는 방식에 의존해서는 안 됩니다.

messageBus.MessageReceived += (_, _) => { Console.WriteLine("메시지를 받았습니다."); }; // 처음 등록한 델리게이트를 보관하지 않았습니다. messageBus.MessageReceived -= (_, _) => { Console.WriteLine("메시지를 받았습니다."); };

구독 해제가 필요하다면 델리게이트를 필드나 변수에 저장합니다.

public sealed class MessageView : IDisposable { private readonly MessageBus _messageBus; private readonly EventHandler _messageHandler; private bool _disposed; public MessageView(MessageBus messageBus) { _messageBus = messageBus; _messageHandler = (_, _) => { Console.WriteLine("메시지를 받았습니다."); }; _messageBus.MessageReceived += _messageHandler; } public void Dispose() { if (_disposed) { return; } _messageBus.MessageReceived -= _messageHandler; _disposed = true; } }

복잡한 수명 관리가 필요하다면 익명 함수보다 이름이 있는 메서드를 사용하는 편이 구독과 해제 관계를 확인하기 쉽습니다.

_messageBus.MessageReceived += OnMessageReceived; // 수명 종료 시점 _messageBus.MessageReceived -= OnMessageReceived;

람다가 외부 객체를 캡처하는 경우

람다식이 this나 지역 변수를 사용하면 컴파일러가 해당 값을 참조하는 객체를 만들 수 있습니다.

public sealed class MessageView { private readonly MessageBus _messageBus; private readonly string _name; public MessageView(MessageBus messageBus, string name) { _messageBus = messageBus; _name = name; _messageBus.MessageReceived += (_, _) => { Console.WriteLine($"{_name}에서 메시지를 받았습니다."); }; } }

위 람다는 _name에 접근하기 위해 현재 MessageView 인스턴스를 참조합니다. 게시자가 람다 델리게이트를 보관하면 람다가 캡처한 MessageView도 함께 유지될 수 있습니다.

문제의 원인이 람다 문법 자체인 것은 아닙니다. 이름이 있는 인스턴스 메서드도 대상 객체에 대한 참조를 만듭니다.

차이는 익명 함수를 변수에 저장하지 않으면 나중에 정확한 델리게이트를 찾아 구독을 해제하기 어렵다는 점입니다.

해결 방법 4: 게시자가 바뀔 때 기존 구독 해제하기

이벤트 소스를 교체할 수 있는 객체에서는 이전 게시자의 구독부터 제거해야 합니다.

public sealed class MessageView : IDisposable { private MessageBus? _messageBus; private bool _disposed; public void SetMessageBus(MessageBus messageBus) { ArgumentNullException.ThrowIfNull(messageBus); if (ReferenceEquals(_messageBus, messageBus)) { return; } if (_messageBus is not null) { _messageBus.MessageReceived -= OnMessageReceived; } _messageBus = messageBus; _messageBus.MessageReceived += OnMessageReceived; } private void OnMessageReceived(object? sender, EventArgs e) { Console.WriteLine("메시지를 받았습니다."); } public void Dispose() { if (_disposed) { return; } if (_messageBus is not null) { _messageBus.MessageReceived -= OnMessageReceived; _messageBus = null; } _disposed = true; } }

기존 구독을 제거하지 않으면 이전 게시자와 새로운 게시자가 모두 같은 구독자를 참조할 수 있습니다.

이 경우 메모리 유지뿐 아니라 이벤트 처리기가 예상보다 여러 번 실행되는 문제도 발생합니다.

정적 이벤트를 특히 주의해야 하는 이유

정적 이벤트는 일반적으로 애플리케이션이나 AssemblyLoadContext와 비슷한 긴 수명을 가집니다.

public static class ApplicationEvents { public static event EventHandler? ConfigurationChanged; public static void RaiseConfigurationChanged() { ConfigurationChanged?.Invoke(null, EventArgs.Empty); } }

짧게 사용하는 객체가 이 이벤트를 구독하면 정적 이벤트가 해당 객체를 오래 유지할 수 있습니다.

public sealed class SettingsView : IDisposable { private bool _disposed; public SettingsView() { ApplicationEvents.ConfigurationChanged += OnConfigurationChanged; } private void OnConfigurationChanged(object? sender, EventArgs e) { Console.WriteLine("설정을 다시 불러옵니다."); } public void Dispose() { if (_disposed) { return; } ApplicationEvents.ConfigurationChanged -= OnConfigurationChanged; _disposed = true; } }

정적 이벤트를 구독할 때는 다음 항목을 함께 확인합니다.

  • 구독 객체가 언제 제거되는가
  • 어느 코드에서 -=를 호출하는가
  • 같은 객체가 여러 번 구독할 가능성이 있는가
  • 애플리케이션 종료 전까지 유지할 의도가 있는가

반복 구독도 확인해야 합니다

다음 메서드가 여러 번 호출되면 같은 이벤트 처리기가 중복 등록될 수 있습니다.

public void Activate() { _messageBus.MessageReceived += OnMessageReceived; }

Activate가 세 번 호출되고 한 번만 해제하면 구독이 남을 수 있습니다.

public void Deactivate() { _messageBus.MessageReceived -= OnMessageReceived; }

구독 상태를 명시적으로 추적하면 중복 등록을 막을 수 있습니다.

private bool _isSubscribed; public void Activate() { if (_isSubscribed) { return; } _messageBus.MessageReceived += OnMessageReceived; _isSubscribed = true; } public void Deactivate() { if (!_isSubscribed) { return; } _messageBus.MessageReceived -= OnMessageReceived; _isSubscribed = false; }

구독 횟수와 해제 횟수만 맞추는 것보다 구독 상태를 한 곳에서 관리하는 편이 오류를 찾기 쉽습니다.

해결 방법 5: 약한 이벤트 패턴 사용하기

일부 프레임워크는 이벤트 게시자가 구독자를 강하게 참조하지 않도록 약한 이벤트 패턴을 제공합니다.

WPF에서는 WeakEventManager 계열 API를 사용할 수 있습니다.

WeakEventManager<MessageBus, EventArgs>.AddHandler( messageBus, nameof(MessageBus.MessageReceived), OnMessageReceived);

해제할 때는 대응하는 메서드를 호출합니다.

WeakEventManager<MessageBus, EventArgs>.RemoveHandler( messageBus, nameof(MessageBus.MessageReceived), OnMessageReceived);

약한 이벤트 패턴에서는 이벤트 소스가 구독자의 수명을 직접 결정하지 않습니다. 다른 강한 참조가 없다면 구독자가 가비지 컬렉션 대상이 될 수 있습니다.

약한 이벤트 패턴이 적합한 경우

다음 조건에서 검토할 수 있습니다.

  • 게시자의 수명이 구독자보다 훨씬 깁니다.
  • 구독 해제 시점을 구독자가 명확히 알기 어렵습니다.
  • 프레임워크가 공식적인 약한 이벤트 API를 제공합니다.
  • 컨트롤이나 라이브러리처럼 사용자의 수명 관리를 강제하기 어렵습니다.

기본 해결책으로 사용하지 않는 이유

구독 종료 시점이 명확하다면 명시적인 -=가 코드 흐름을 이해하기 쉽습니다.

약한 이벤트 패턴은 다음 비용이 생길 수 있습니다.

  • 구현 구조가 복잡해집니다.
  • 프레임워크에 종속될 수 있습니다.
  • 이벤트 연결 관계를 추적하기 어려워질 수 있습니다.
  • 일반 이벤트 구독과 다른 디버깅 방식이 필요할 수 있습니다.

WPF의 WeakEventManager는 WPF API입니다. 일반 콘솔 애플리케이션이나 ASP.NET Core 프로젝트에서 동일한 코드를 그대로 사용할 수 있는 범용 C# 기능은 아닙니다.

이벤트 게시자에서 구독 목록을 비우면 되는가

이벤트를 선언한 클래스는 필요에 따라 내부에서 이벤트 델리게이트를 비울 수 있습니다.

public sealed class MessageBus : IDisposable { public event EventHandler? MessageReceived; public void Dispose() { MessageReceived = null; } }

이 방식은 게시자 자체가 종료되면서 모든 구독 관계도 끝나야 할 때 사용할 수 있습니다.

하지만 특정 구독자 하나만 더 이상 필요하지 않은 상황을 게시자가 대신 판단하기는 어렵습니다. 일반적으로는 구독자가 자신의 수명 종료 시점에 구독을 해제하는 구조가 명확합니다.

게시자에서 모든 처리기를 제거하면 아직 이벤트를 받아야 하는 다른 구독자까지 함께 끊길 수 있습니다.

메모리 누수가 의심될 때 확인할 증상

이벤트 구독 문제는 다음 형태로 나타날 수 있습니다.

  • 화면을 열고 닫을 때마다 같은 화면 객체 수가 증가합니다.
  • 닫힌 화면의 이벤트 처리기가 계속 실행됩니다.
  • 이벤트 한 번에 처리기가 여러 번 호출됩니다.
  • 작업을 취소했지만 관련 객체가 계속 동작합니다.
  • 메모리 프로파일러에서 종료된 객체가 계속 남아 있습니다.
  • 객체의 보존 경로에 이벤트 게시자와 델리게이트가 표시됩니다.

메모리 사용량이 증가한다는 사실만으로 이벤트 누수라고 단정할 수는 없습니다. 가비지 컬렉션 실행 시점, 캐시, 대형 객체 힙, 네이티브 리소스 등 다른 원인도 확인해야 합니다.

이벤트 문제는 객체가 왜 살아 있는지 보여 주는 보존 경로를 확인해야 합니다.

메모리 프로파일러로 확인하는 순서

UI 화면이나 반복 작업을 기준으로 다음 과정을 사용할 수 있습니다.

  1. 분석 대상 화면을 열기 전 메모리 스냅샷을 저장합니다.
  2. 화면을 여러 번 열고 닫습니다.
  3. 필요하다면 가비지 컬렉션을 실행합니다.
  4. 두 번째 메모리 스냅샷을 저장합니다.
  5. 대상 화면이나 구독 객체의 인스턴스 수를 비교합니다.
  6. 남아 있는 객체의 보존 경로를 확인합니다.
  7. 이벤트 게시자와 델리게이트를 통과하는 경로가 있는지 확인합니다.
  8. 구독 해제 코드를 추가한 뒤 같은 절차를 반복합니다.

단순히 전체 메모리 크기만 비교하는 것보다 특정 객체의 인스턴스 수와 참조 경로를 확인하는 편이 원인을 찾기 쉽습니다.

WeakReference로 확인할 때의 주의사항

테스트 코드에서 WeakReference와 강제 가비지 컬렉션을 사용할 수도 있습니다.

WeakReference reference = CreateSubscriber(messageBus); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); Console.WriteLine(reference.IsAlive);

하지만 이 결과만으로 누수 여부를 확정해서는 안 됩니다.

JIT 컴파일러가 지역 변수의 수명을 연장하거나, 테스트 코드가 의도하지 않은 참조를 유지할 수 있습니다. 가비지 컬렉션 실행 시점도 실제 애플리케이션과 다릅니다.

WeakReference 테스트는 보조 수단으로 사용하고, 실제 분석에서는 메모리 프로파일러의 객체 수와 보존 경로를 함께 확인합니다.

자주 하는 오해

GC가 있으므로 구독을 해제하지 않아도 된다

가비지 컬렉터는 도달할 수 없는 객체를 수집합니다.

이벤트 게시자가 구독자를 참조하고 있다면 구독자는 여전히 도달 가능한 객체입니다. 가비지 컬렉터가 이벤트 관계를 임의로 끊어 주지는 않습니다.

람다로 구독할 때만 문제가 발생한다

이름이 있는 인스턴스 메서드도 대상 객체를 참조합니다.

publisher.Changed += OnChanged;

람다가 특별히 더 강한 참조를 만드는 것은 아닙니다. 람다는 처음 등록한 델리게이트를 저장하지 않으면 구독 해제가 어려워지는 문제가 추가됩니다.

구독자 변수에 null을 대입하면 제거된다

다음 코드는 지역 변수의 참조 하나만 제거합니다.

subscriber = null;

이벤트 게시자가 구독자를 계속 참조하면 객체는 수집되지 않습니다.

Dispose를 구현하면 자동으로 호출된다

IDisposable은 가비지 컬렉터가 자동으로 호출하는 인터페이스가 아닙니다.

호출자가 using을 사용하거나 명시적으로 Dispose를 호출해야 합니다. 프레임워크가 객체 수명 종료 시 Dispose를 호출해 주는 구조라면 그 계약을 확인해야 합니다.

모든 이벤트는 반드시 해제해야 한다

게시자와 구독자가 같은 수명을 가지거나 함께 도달 불가능한 상태가 된다면 가비지 컬렉터가 객체 그래프를 수집할 수 있습니다.

무조건 모든 이벤트에 -=를 추가하기보다 게시자와 구독자의 수명을 먼저 확인합니다.

해결 방법 비교

방법적합한 상황장점주의사항
명시적인 -=종료 시점이 분명함동작이 직접적이고 추적하기 쉬움모든 종료 경로에서 호출해야 함
IDisposable객체 단위로 정리 책임을 표현함여러 구독을 한곳에서 정리할 수 있음호출자가 Dispose해야 함
델리게이트 변수 저장익명 함수를 나중에 해제함람다를 유지하면서 정확히 해제 가능변수를 객체 수명 동안 보관해야 함
구독 상태 관리활성화와 비활성화가 반복됨중복 구독을 방지할 수 있음상태 변경 코드를 한곳에서 관리해야 함
약한 이벤트 패턴해제 시점을 알기 어렵고 게시자가 오래 삶게시자가 구독자 수명을 결정하지 않음프레임워크 종속성과 복잡도가 생길 수 있음
게시자에서 전체 제거게시자 종료 시 모든 구독도 끝남게시자 정리 코드가 단순함필요한 다른 구독자도 모두 제거됨

어떤 방법을 선택해야 하는가

객체의 종료 시점을 알고 있다면 해당 시점에 -=를 호출합니다.

여러 이벤트 구독과 정리 작업을 객체 단위로 묶어야 한다면 IDisposable을 사용합니다.

익명 함수로 구독하면서 나중에 해제해야 한다면 델리게이트를 필드에 저장합니다.

활성화와 비활성화가 반복되는 객체라면 구독 상태를 추적해 중복 등록을 막습니다.

구독 해제 시점을 알기 어렵고 사용하는 프레임워크가 약한 이벤트 API를 제공한다면 약한 이벤트 패턴을 검토합니다.

정적 이벤트나 싱글턴 서비스처럼 애플리케이션 전체에서 살아 있는 게시자에는 짧은 수명의 객체를 구독할 때 해제 경로를 반드시 함께 설계해야 합니다.

정리

C# 이벤트 메모리 누수의 핵심 원인은 이벤트 게시자가 구독자의 이벤트 처리기 델리게이트를 보관한다는 점입니다.

게시자가 구독자보다 오래 살아 있고 이벤트 구독을 제거하지 않으면, 구독자는 더 이상 필요하지 않아도 가비지 컬렉션 대상이 되지 않을 수 있습니다.

+= 자체가 항상 문제를 만드는 것은 아닙니다. 다음 관계를 먼저 확인해야 합니다.

게시자의 수명 > 구독자의 필요한 수명

수명 종료 시점을 알고 있다면 -=로 구독을 해제합니다. 객체 단위의 정리가 필요하면 IDisposable을 사용합니다. 익명 함수는 델리게이트를 저장해야 나중에 정확하게 제거할 수 있습니다.

정적 이벤트, 싱글턴, 타이머, 애플리케이션 이벤트 버스는 오래 유지되는 게시자가 되기 쉬우므로 짧게 사용하는 화면이나 작업 객체가 구독할 때 특히 주의해야 합니다.