[PART12.메모리 관리와 성능(5/10)] 이벤트 핸들러와 메모리 누수 — 가장 흔한 누수 패턴
publisher가 subscriber를 붙잡는 구조 / += 의 IL 동작 / OnDestroy·Dispose 타이밍 / WeakEventHandler 의 한계
목차
1. 문제 제기 — 씬을 바꿨는데 메모리가 안 줄어든다
Unity 모바일 게임에서 타이틀 → 인게임 → 결과 → 타이틀 을 10번 왕복했더니 PSS(Proportional Set Size, 앱이 실제로 점유하는 물리 메모리) 가 200MB → 650MB 로 불어나 있다. Profiler 의 Memory → Detailed → Take Sample 로 덤프를 떠 보면, 이미 Destroy 된 HPBarPopup 인스턴스가 1 개가 아니라 10 개 살아 있다. 참조 체인을 따라가 보면 루트는 항상 같다 — 게임 내내 살아있는 GameManager 싱글톤이 OnPlayerDamaged 이벤트를 통해 죽은 팝업들을 붙잡고 있다.
신입 개발자가 가장 먼저 맞닥뜨리는 누수 패턴이 이 구조다. 원인은 세 글자로 압축된다: +=. 구독은 했는데 해제(-=)를 안 한 것이다.
⚠️ 왜 이 주제가 PART 12(메모리 관리와 성능) 에 있는가 C# 이벤트는 멀티캐스트 델리게이트(Multicast Delegate, 여러 핸들러를 묶어 한 번에 호출하는 함수 참조 컬렉션) 위에 얹혀 있다. 이 델리게이트는 구독자 객체를 강한 참조(Strong Reference, GC 가 회수를 막는 일반 참조) 로 붙잡는다. 즉, 이벤트 구독은 그 자체로 GC 루트 사슬을 확장하는 작업이며, 해제 타이밍을 놓치는 순간 누수가 된다. Unity 는 Boehm GC(비세대형·보수적 가비지 컬렉터, 모바일에서 큰 GC 스파이크를 유발) 를 쓰기 때문에, 이 누수가 쌓이면 GC 가 순회해야 할 객체 그래프가 커지면서 프레임 드랍(Spike) 이 같이 따라온다.
이 글에서 답할 질문은 네 가지다.
+=를 쓰면 IL 레벨에서 정확히 무슨 일이 벌어지는가?-=를 빼먹으면 왜 subscriber 가 GC 되지 못하는가?- 언제(
OnDestroy,OnDisable,Dispose) 해제해야 하는가? WeakEventHandler는 정말 만능 해결책인가?
2. 개념 정의 — 이벤트는 "구독자 명부를 들고 있는 호출자"다
비유
게임 매니저를 신문사, 화면에 떠 있는 UI 를 구독자라고 생각해 보자. 구독자가 이사를 갔는데 구독 해지 전화를 안 걸었다면, 신문사는 계속 그 빈 집 주소로 신문을 보낸다. 신문사 장부(구독자 명부) 에 주소가 남아 있는 한, 그 주소는 "아직 살아있는 주소"로 취급된다.
C# 이벤트도 똑같다. publisher 가 구독자 명부(델리게이트의 invocation list) 를 들고 있고, 이 명부에는 구독자 인스턴스의 강한 참조가 들어 있다. GC 는 명부에 주소가 있는 한 그 인스턴스를 "도달 가능(reachable)" 하다고 판단해 회수하지 않는다.
참조 구조 시각화

기본 C# 코드
using System;
public class GameManager
{
public static GameManager Instance { get; } = new GameManager();
public event Action<int> OnPlayerDamaged;
public void Raise(int dmg) => OnPlayerDamaged?.Invoke(dmg);
}
public class HPBarPopup
{
public HPBarPopup()
{
GameManager.Instance.OnPlayerDamaged += HandleDamage;
}
private void HandleDamage(int dmg) { /* UI 갱신 */ }
}
Unity 에서 이런 상황이 생긴다. 팝업을 new HPBarPopup() 으로 만들고 로컬 변수까지 버렸는데도 GameManager.Instance 가 살아있는 한 이 팝업은 GC 대상이 되지 않는다.
쉬운 설명 → 기술 정의
+= 는 publisher 가 "이 구독자에게 이벤트가 나면 연락해 달라" 는 요청을 자기 명부에 적는 행위다. 연락처(델리게이트) 에는 구독자 인스턴스 포인터가 그대로 박혀 있다.
정확히 말하면: C# 의 event 는 add_* / remove_* 라는 한 쌍의 접근자 메서드와 백킹 필드(delegate 필드) 로 구성된다. += 는 add_* 호출로 컴파일되고, add_* 내부는 Delegate.Combine 으로 기존 델리게이트와 새 델리게이트를 합친 새 인스턴스를 만들어 필드에 원자적으로 교체(Interlocked.CompareExchange) 한다.
3. 내부 동작 — += 는 IL 에서 Delegate.Combine 한 번, GC 는 도달 경로 한 번
컴파일러 변환 흐름

실제 IL — += 와 -=
public class Publisher
{
public event Action OnPing;
public void Raise() => OnPing?.Invoke();
}
public class Subscriber
{
private readonly Publisher _pub;
public Subscriber(Publisher pub)
{
_pub = pub;
_pub.OnPing += HandlePing; // += → add_OnPing
}
public void Dispose()
{
_pub.OnPing -= HandlePing; // -= → remove_OnPing
}
private void HandlePing() { }
}
위 코드를 ilspycmd 로 디컴파일하면 Publisher 가 event 키워드 한 줄에서 add_OnPing / remove_OnPing 두 개의 접근자를 자동 생성한다.
// Publisher::add_OnPing — `+=` 의 실체
.method public hidebysig specialname instance void add_OnPing(class Action 'value') {
.locals init ([0] Action, [1] Action, [2] Action)
IL_0000: ldarg.0
IL_0001: ldfld class Action Publisher::OnPing // 기존 델리게이트 읽기
IL_0006: stloc.0
IL_0007: ldloc.0
IL_0008: stloc.1
IL_0009: ldloc.1
IL_000a: ldarg.1 // 새로 추가할 핸들러
IL_000b: call Delegate::Combine(Delegate, Delegate) // 호출 목록 합치기 → 새 Action 반환
IL_0010: castclass Action
IL_0015: stloc.2
IL_0016: ldarg.0
IL_0017: ldflda class Action Publisher::OnPing
IL_001c: ldloc.2
IL_001d: ldloc.1
IL_001e: call Interlocked::CompareExchange(...) // 락-프리 원자 교체
IL_0023: stloc.0
IL_0024: ldloc.0
IL_0025: ldloc.1
IL_0026: bne.un.s IL_0007 // 경쟁 시 재시도 루프
IL_0028: ret
}
// Publisher::remove_OnPing — `-=` 는 Delegate.Combine 자리가 Delegate.Remove 로 바뀔 뿐 구조는 동일
// Subscriber::.ctor — `+=` 를 쓴 호출자 쪽 IL
IL_000d: ldarg.0
IL_000e: ldfld class Publisher Subscriber::_pub
IL_0013: ldarg.0 // this (HandlePing 의 target)
IL_0014: ldftn instance void Subscriber::HandlePing()
IL_001a: newobj Action::.ctor(object, native int) // Action 인스턴스 1개 힙 할당
IL_001f: callvirt Publisher::add_OnPing(class Action)
IL 해설 — 주목할 세 가지
newobj Action::.ctor(object, native int)—+=할 때마다 힙에Action델리게이트 객체가 새로 한 개 할당된다. 생성자 첫 인자가object(=this) 이다. 즉, 델리게이트 내부_target필드가 subscriber 인스턴스를 강한 참조로 붙잡는다. 이 한 줄이 누수의 물리적 원인이다.Delegate.Combine— 기존 델리게이트를 수정하는 것이 아니라, 두 invocation list 를 합친 완전히 새로운 불변(immutable) 델리게이트를 반환한다. 문자열처럼 "원본 수정 금지, 새로 만들어 교체" 방식이다.Interlocked.CompareExchange+bne.un.s재시도 루프 —event의 기본add/remove는 락-프리로 스레드 안전하게 필드를 교체한다. 값이 바뀌었으면 루프를 다시 돌아 재시도한다. 이 때문에+=/-=는 메서드 호출 한 번보다 비용이 크고, 매 프레임 반복하면 GC 스파이크의 원인이 된다.
델리게이트가 붙잡는 것의 메모리 레이아웃

팝업 하나만 남는 게 아니라, 팝업이 들고 있는 Texture2D 레퍼런스·자식 UI 계층·Material 인스턴스 까지 줄줄이 살아남는다. 모바일에서 200~500KB 짜리 UI 하나가 100 개 쌓이면 순식간에 수십 MB 누수다.
4. 실전 적용 — 언제 += 하고 언제 -= 할 것인가
핵심 원칙
📌 구독과 해제는 반드시 짝으로, 같은 생명주기 단위 안에서 처리한다.OnEnable에서+=했으면OnDisable에서-=. 생성자에서+=했으면Dispose()에서-=.
Unity MonoBehaviour 는 비활성화(SetActive(false)) → 재활성화(SetActive(true)) 가 빈번하므로, OnEnable/OnDisable 짝이 가장 안전하다. 오브젝트 풀링(object pooling, 쓰고 버리는 대신 비활성화해 재사용하는 패턴) 과도 잘 맞는다.
Before — 누수가 발생하는 기본형
using UnityEngine;
using System;
public class GameEvents
{
public static GameEvents Instance { get; } = new GameEvents();
public event Action<int> OnScoreChanged;
public void Raise(int s) => OnScoreChanged?.Invoke(s);
}
public class ScoreText : MonoBehaviour
{
void Start()
{
GameEvents.Instance.OnScoreChanged += UpdateText; // 구독
}
// OnDestroy/OnDisable 에서 -= 가 없다 → GameEvents 가 이 객체를 영원히 붙잡는다
void UpdateText(int score) { /* text.text = ... */ }
}
이 씬을 열었다 닫기를 반복하면 GameEvents.Instance.OnScoreChanged 의 invocation list 가 1 → 2 → 3 → N 으로 계속 늘어난다. 점수가 한 번 바뀔 때마다 이미 파괴된 ScoreText 들의 UpdateText 가 모두 호출되어 MissingReferenceException 이 연달아 찍힌다.
After — OnEnable/OnDisable 짝 + 메서드 그룹 캐시
using UnityEngine;
public class ScoreText : MonoBehaviour
{
// 주의: 람다 대신 '메서드 그룹' 을 그대로 전달 — -= 가 같은 델리게이트를 찾을 수 있게 한다
void OnEnable() => GameEvents.Instance.OnScoreChanged += UpdateText;
void OnDisable() => GameEvents.Instance.OnScoreChanged -= UpdateText;
void UpdateText(int score) { /* text.text = ... */ }
}
이제 씬을 1000 번 왕복해도 invocation list 는 항상 1 로 유지된다. 구독 시점마다 한 번씩 Action 인스턴스가 힙에 할당되는 비용은 남지만, 누수는 사라진다.
IL 비교 — 메서드 그룹 vs 람다
메서드 그룹 += UpdateText 의 IL:
IL_000f: ldftn instance void ScoreText::UpdateText(int32)
IL_0015: newobj Action`1::.ctor(object, native int)
IL_001a: callvirt GameEvents::add_OnScoreChanged(Action`1)
반면 람다 로 구독한 경우는 컴파일러가 내부적으로 서로 다른 숨은 메서드(<.ctor>b__1_0, <Unsubscribe>b__2_0) 를 만든다:
// 구독 시
IL_0014: ldftn BadSubscriber::'<.ctor>b__1_0'()
IL_001a: newobj Action::.ctor(object, native int)
IL_001f: callvirt Publisher::add_OnPing
// 해제 시 — '다른' 숨은 메서드를 가리키는 '다른' Action 인스턴스
IL_0007: ldftn BadSubscriber::'<Unsubscribe>b__2_0'() // ← b__2_0, 위의 b__1_0 과 다름
IL_000d: newobj Action::.ctor(object, native int)
IL_0012: callvirt Publisher::remove_OnPing
람다의 _methodPtr 이 가리키는 숨은 메서드가 서로 다르므로, Delegate.Remove 는 일치하는 엔트리를 찾지 못하고 조용히 실패한다(예외도 안 난다). 람다로 구독했는데 -= 를 똑같은 람다 리터럴로 쓰면 누수가 확정된다.
이를 해결하려면 람다를 필드에 캐시해야 한다:
public class Subscriber
{
private readonly Publisher _pub;
private readonly Action _handler;
public Subscriber(Publisher pub)
{
_pub = pub;
_handler = () => DoSomething(); // 캐시
_pub.OnPing += _handler;
}
public void Dispose() => _pub.OnPing -= _handler; // 같은 인스턴스로 해제 성공
void DoSomething() { }
}
Unity Button.onClick 은 왜 누수에 비교적 강한가
UnityEvent 는 순수 C# event 와 동작이 조금 다르다. 인스펙터에서 드래그-앤-드롭으로 연결한 리스너는 Unity 엔진이 직렬화(serialization) 시 target 을 UnityEngine.Object 참조로 저장해 두고, Invoke 전에 C++ 레이어에서 target 이 파괴됐는지 검사한 뒤 파괴됐으면 호출을 스킵한다. 그래서 인스펙터 기반 리스너는 실수로 해제를 빠뜨려도 누수로 번지지 않는다.
반면 코드로 AddListener 를 호출해 등록한 리스너는 UnityEvent 내부 m_Calls 리스트에 강한 참조로 남아 있어서, 순수 C# event 와 똑같이 누수가 난다.
| 연결 방식 | 저장 위치 | 누수 위험 |
|---|---|---|
| Inspector 드래그-앤-드롭 | PersistentCalls (직렬화, 약한 검증) |
거의 없음 |
button.onClick.AddListener(F) |
RuntimeCalls (강한 참조 리스트) |
있음 — 해제 필수 |
C# event |
델리게이트의 invocation list | 있음 — 해제 필수 |
Unity 실전 규칙: 인스펙터로 붙일 수 있으면 인스펙터로. 런타임 동적 연결은 AddListener 를 쓰되, 반드시 OnDisable 에서 RemoveListener 로 짝을 맞춘다.
5. 함정과 주의사항 — 신입이 반복하는 세 가지 실수
함정 1 — 람다로 구독하고 "똑같이" 람다로 해제
❌ 잘못된 패턴
public class DamagePopup : MonoBehaviour
{
void OnEnable() => GameEvents.Instance.OnDamage += (d) => Show(d);
void OnDisable() => GameEvents.Instance.OnDamage -= (d) => Show(d);
// ^^^^^^^^^^^^^^
// 다른 Action 인스턴스 — 매치 실패
void Show(int d) { }
}
앞서 본 IL 에서 확인했듯, 두 람다는 컴파일러가 서로 다른 숨은 메서드로 생성한다. -= 는 아무 일도 안 하고 리턴된다 — 누수 확정.
✅ 올바른 패턴 — 메서드 그룹 또는 람다 캐시
public class DamagePopup : MonoBehaviour
{
void OnEnable() => GameEvents.Instance.OnDamage += Show;
void OnDisable() => GameEvents.Instance.OnDamage -= Show;
void Show(int d) { }
}
메서드 그룹 Show 를 직접 쓰면 매번 새 Action 이 할당되긴 하지만 _target 과 _methodPtr 이 동일하므로 Delegate.Remove 가 invocation list 에서 정확히 제거할 수 있다.
함정 2 — Awake/Start 에서 구독 + OnDestroy 에서만 해제
❌ 잘못된 패턴
public class EnemyHUD : MonoBehaviour
{
void Start() => GameEvents.Instance.OnEnemyHit += Flash;
void OnDestroy() => GameEvents.Instance.OnEnemyHit -= Flash;
void Flash(int id) { }
}
이 코드는 오브젝트 풀링을 하는 순간 깨진다. 적 HUD 를 비활성화(SetActive(false)) 해서 풀로 되돌려 놓아도 OnDestroy 는 호출되지 않는다. 풀에 있는 동안 구독은 여전히 살아있어서 죽은(보이지도 않는) HUD 가 계속 Flash 를 실행한다.
✅ 올바른 패턴 —OnEnable/OnDisable짝
public class EnemyHUD : MonoBehaviour
{
void OnEnable() => GameEvents.Instance.OnEnemyHit += Flash;
void OnDisable() => GameEvents.Instance.OnEnemyHit -= Flash;
void Flash(int id) { }
}
OnDisable 은 비활성화·파괴·앱 종료 세 경우 모두에서 호출되므로, 풀링 환경에서도 누수가 나지 않는다.
함정 3 — Update 루프 안에서 +=/-=
❌ 잘못된 패턴
void Update()
{
if (isFocusing) GameEvents.Instance.OnTick += Handle; // 매 프레임 +=
else GameEvents.Instance.OnTick -= Handle;
}
IL 에서 본 newobj Action::.ctor 때문에 += 한 번마다 힙 할당이 발생한다. 60fps 에서 초당 120 번의 Action 할당, 거기에 Delegate.Combine 으로 invocation list 배열이 매번 새로 할당된다. 모바일 Boehm GC 에서는 수 초 내로 수 MB 의 GC 압박이 되어 프레임 스파이크가 찾아온다.
✅ 올바른 패턴 — 상태 플래그로 분기
void OnEnable() => GameEvents.Instance.OnTick += HandleTick;
void OnDisable() => GameEvents.Instance.OnTick -= HandleTick;
void HandleTick()
{
if (!isFocusing) return; // 핸들러 내부에서 조기 리턴
// ...
}
구독/해제는 OnEnable/OnDisable 에만 두고, 실행 여부는 핸들러 내부에서 판단한다.
WeakEventHandler 는 왜 "만능"이 아닌가
약한 이벤트 패턴(Weak Event Pattern) 은 publisher 가 subscriber 를 WeakReference(GC 가 회수를 막지 않는 참조) 로 쥐는 방식이다. 원리는 깔끔하지만 Unity 모바일 실전에서는 대부분의 경우 쓰지 않는 편이 낫다. 이유는 세 가지다.
- 보일러플레이트가 많다. 핸들러 래퍼, 약참조 검사, 자동 제거 로직을 직접 구현하거나 라이브러리를 붙여야 한다.
OnDisable에서-=한 줄이면 되는 문제에 굳이 복잡도를 더한다. WeakReference자체가 힙 객체다. 구독 1 건당WeakReference+ 래퍼 델리게이트 = 최소 2개의 추가 객체가 힙에 뜬다. IL 레벨에서newobj가 늘어나고,WeakReference.Target접근마다 핸들 체크 오버헤드가 붙는다.- GC 타이밍이 불확실하다. subscriber 가 "이미 쓰레기"이지만 아직 GC 가 안 돌아서 이벤트가 한두 번 더 실행되는 순간이 생긴다.
MissingReferenceException이 간헐적으로 터지는 원인이 되는데, 이건 명시적-=보다 디버깅이 몇 배 어렵다.
Unity 에서 약참조 이벤트가 의미 있는 경우는 플러그인·MVVM 프레임워크·데이터 바인딩 라이브러리처럼 subscriber 의 생명주기를 프레임워크가 통제할 수 없는 상황뿐이다. 게임 로직에서는 OnEnable/OnDisable 짝 + 메서드 그룹 구독이 항상 더 간단하고 빠르다.
함정 보너스 — static event
static event 는 publisher 자체가 AppDomain 수명 동안 살아있으므로 누수가 한 번 생기면 게임을 끌 때까지 회복되지 않는다. 싱글톤/정적 이벤트를 쓰려면 구독 해제가 두 배 더 엄격해야 한다.
6. C# 버전별 변화 — 이벤트 모델은 C# 1.0 이후 의미론이 바뀌지 않았다
event 키워드와 Delegate.Combine/Remove 의미론은 C# 1.0(.NET Framework 1.0, 2002) 부터 현재까지 변하지 않았다. 대신 주변부 문법이 계속 개선되어 구독/해제를 더 안전하게 쓸 수 있게 되었다.
| 버전 | 변화 | 누수 방지에 기여하는 점 |
|---|---|---|
| C# 1.0 | event 키워드, delegate |
기반 도입 |
| C# 2.0 | 익명 메서드, Action<T>/Func<T,...> |
제네릭 델리게이트로 EventHandler<T> 남용 감소 |
| C# 3.0 | 람다 표현식 | 양날의 검 — 간결해진 대신 람다 구독 해제 실패 함정이 여기서 태어남 |
| C# 6.0 | ?. 널 조건 연산자 |
OnPing?.Invoke() 로 멀티스레드 안전 호출이 한 줄이 됨 |
| C# 7.0+ | 표현식 본문, 튜플 | OnEnable/OnDisable 한 줄 구독 (=> event += handler) |
| C# 8.0 | using 선언 (using var x = ...) |
IDisposable 구현 subscriber 의 해제 범위 축소 |
Before — C# 1.x 스타일 수동 이벤트 (참고용)
public class OldPublisher
{
public delegate void PingHandler();
private PingHandler _ping;
public event PingHandler OnPing
{
add { _ping = (PingHandler)Delegate.Combine(_ping, value); }
remove { _ping = (PingHandler)Delegate.Remove (_ping, value); }
}
}
직접 add/remove 접근자를 구현한 형태다. IL 로 내려가면 컴파일러가 자동 생성한 것과 본질이 같다(Delegate.Combine/Remove 호출). 다만 여기엔 Interlocked.CompareExchange 가 없어서 스레드 안전하지 않다.
After — 현대 C# (자동 속성 스타일)
public class ModernPublisher
{
public event Action OnPing; // 컴파일러가 스레드 안전 add/remove 를 자동 생성
public void Raise() => OnPing?.Invoke();
}
IL 결과는 [3. 내부 동작] 에서 본 Interlocked.CompareExchange 루프 버전이 된다. 스레드 안전성이 무료로 주어지는 대신, += 비용이 메서드 호출 + 원자 연산 1회로 소폭 증가한다 — Unity 메인 스레드에서는 눈에 띄는 비용이 아니다.
💡 Unity 의 C# 버전 Unity 2021 LTS 이후는 C# 9, Unity 2022 LTS 는 C# 9 일부, Unity 6 은 C# 9~10 지원. 어느 버전이든 event 의 의미론은 동일하다 — "구독했으면 해제하라" 는 규칙은 예나 지금이나 같다.
7. 정리 — 체크리스트
게시 전에 아래 항목을 모두 확인한다.
| # | 체크 항목 | 이유 |
|---|---|---|
| 1 | += 한 줄마다 짝이 되는 -= 가 같은 파일에 있다 |
델리게이트의 _target 이 subscriber 를 강참조로 붙잡기 때문 |
| 2 | 구독은 OnEnable, 해제는 OnDisable 에 둔다 |
오브젝트 풀링에서도 누수가 나지 않음 |
| 3 | 핸들러는 메서드 그룹 또는 필드에 캐시한 람다로 전달한다 | 익명 람다는 -= 시 다른 인스턴스로 컴파일되어 제거 실패 |
| 4 | Update 루프 안에서 +=/-= 하지 않는다 |
프레임당 newobj Action + Delegate.Combine 이 GC 스파이크를 유발 |
| 5 | 일반 C# 클래스는 IDisposable 을 구현하고 Dispose() 에서 -= 한다 |
MonoBehaviour 생명주기 훅이 없는 객체의 표준 해제 경로 |
| 6 | UnityEvent.AddListener 로 동적 등록했다면 RemoveListener 로 반드시 해제한다 |
인스펙터 연결과 달리 런타임 등록은 강한 참조로 유지됨 |
| 7 | static event 는 해제 규칙을 두 배 엄격하게 지킨다 |
publisher 가 AppDomain 수명이라 누수가 영구화됨 |
| 8 | WeakEventHandler 는 프레임워크 영역에만 쓰고, 게임 로직에서는 명시적 -= 를 쓴다 |
추가 힙 할당, GC 타이밍 불확실성, 디버깅 난이도 증가 |
한 줄 결론: += 는 구독자 인스턴스를 publisher 의 GC 루트 사슬에 묶는 작업이다. 묶었으면 풀어야 한다 — 풀지 않으면 Profiler 의 Memory 탭에 죽은 객체들이 차곡차곡 쌓여, 언젠가 모바일에서 앱이 튕긴다.
'C# 심화' 카테고리의 다른 글
| [PART12.메모리 관리와 성능(7/10)] Span<T> / Memory<T> — 할당 없이 데이터를 다루는 방법 (0) | 2026.04.14 |
|---|---|
| [PART12.메모리 관리와 성능(6/10)] WeakReference<T> — GC를 방해하지 않는 참조 (0) | 2026.04.14 |
| [PART12.메모리 관리와 성능(4/10)] 메모리 누수가 발생하는 5가지 상황 (0) | 2026.04.14 |
| [PART12.메모리 관리와 성능(3/10)] using — 세 가지 using의 차이 (0) | 2026.04.14 |
| [PART12.메모리 관리와 성능(2/10)] IDisposable — 왜 Dispose 패턴이 필요한가 (0) | 2026.04.14 |