너무 간만에 씨리즈 글을 이어나가자니 뻘쭘 하네요. 이 글을 읽으시기 전에 가비지 컬렉션 다시 보기 씨리즈 글을 먼저 읽어 보시거나 기억을 떠올려 보시는 것이 좋을 듯합니다.
위 글을 읽어 본 후에 조금 더 여력이 생긴다면 다음 두 글까지도 읽으시면 더욱 도움이 될겁니다.
이 글은 Finalizer 사용 시 주의 사항들 이란 글의 다음 내용이기 때문에 이 글은 반드시 정독 하시거나 기억을 되살리신 후에 본문을 읽으시는 것이 정신 건강에 도움이 되리라 생각 합니다.
Dispose 패턴
닷넷 환경에서 가비지 컬렉션 메커니즘에 의해 객체의 제거와 자원의 정리가 비결정적이라는 점을 해결하기 위해 제시된 것이 바로 Dispose 패턴이다(요 말이 뭔 말인지 잘 모르겠다면 Finalizer 사용 시 주의 사항들을 먼저 읽어 보아야 할 것이다). Dispose 패턴은 IDisposable 인터페이스를 사용하여 더 이상 사용되지 않는 시스템 자원들을 즉시 반납하도록 하며 Finalizer 메서드를 호출하기 위해 객체가 힙 상에 남아 있지 않게 해준다.
간단한 IDisposable 인터페이스 구현
IDisposable 인터페이스는 독자들도 매우 익숙한 인터페이스이지만 이 인터페이스를 직접 구현하는 작업은 익숙하지 않을 것이다. IDisposable 인터페이스는 꼴랑 Dispose 메서드 하나만을 가지고 있는 인터페이스이다.
public interface IDisposable
{
void Dispose();
}
IDisposable 인터페이스를 구현하기란 매우 쉽다. Dispose 메서드에서 시스템 자원 정리 작업 등 필요한 정리 작업을 수행하기만 하면 된다. 앞서 Finalizer 사용 시 주의 사항들 에서 문제로 지적한 코드를 다시 기억해 보자([리스트1]).
1: class DangerousType
2: {
3: private StreamWriter _stream;
4:
5: public DangerousType()
6: {
7: _stream = new StreamWriter("Test.txt");
8: }
9:
10: ~DangerousType()
11: {
12: _stream.Close(); // 오류가 발생할 수도 있다!
13: }
14:
15: public void DoSomething()
16: {
17: //...... 생략 ......
18: }
19: }
[리스트1] Finalizer 만을 사용했을 때 문제를 유발할 수 있는 코드 예제
[리스트1]의 코드가 왜 문제가 되는지는 이전 글을 참고하도록 하고, [리스트 1]의 예제 코드에 IDisposable 인터페이스를 적용해 보면 [리스트 2]와 같다.
1: class LessDangerousType : IDisposable
2: {
3: private StreamWriter _stream;
4:
5: public LessDangerousType()
6: {
7: _stream = new StreamWriter("Test.txt");
8: }
9:
10: public void Dispose()
11: {
12: _stream.Close();
13: }
14:
15: public void DoSomething()
16: {
17: ... 생략 ...
18: }
19: }
[리스트2] IDisposable 패턴의 아주 간단한 구현 예제
IDisposable 인터페이스를 구현하고 Dispose 메서드를 호출하는 것은 전적으로 개발자의 몫이다. CLR은 IDisposable 인터페이스나 Dispose 메서드를 특별하게 취급하지 않는다(중요!). C#의 using 키워드 역시 C# 언어 수준에서 제공되는 기능일 뿐(try~finally 구문으로 변환) CLR이 Dispose를 자동으로 호출해 주지 않음에 유념해야 한다. 따라서 [리스트 2]와 같이 어떤 타입이 IDisposable 구현을 제공하더라도 다음과 같이 Dispose 메서드를 명시적으로 적절히 호출해 주어야 한다.
using (LessDangerousType obj = new LessDangerousType())
{
Obj.DoSomething();
}
C#의 using 키워드나 VB.NET의 Using 키워드는 객체가 IDisposable 인터페이스를 구현하는지 검사하고, 만약 구현한다면 try~finally 문장의 finally 구역에서 Dispose 메서드를 호출하도록 코드가 생성된다. 따라서 [리스트 2]의 LessDangerousType 클래스를 using 키워드와 함께 사용하면 반드시 Dispose 메서드가 호출되고 Dispose 메서드 내에서 StreamWriter 객체를 닫아주기 때문에 가비지 컬렉션이 수행될 때까지 파일이 열려 있는 채로 남아서 파일 자원이 비 효율적으로 사용되는 것을 막을 수 있다.
요기까지는 어려운 것이 하나도 없을 것이다. (응?)
Finalizer 와 Dispose 패턴
단순히 IDisposable 인터페이스를 구현하는 것만으로 시스템 자원을 안전하게 반납하거나 효율적으로 활용하는 것은 아니다. 앞서도 언급했지만 IDisposable 인터페이스는 CLR에게는 일반적인 하나의 인터페이스일 뿐 특별한 것이 아니다. 따라서 개발자가 반드시 Dispose 메서드를 호출해 주어야 하며 이것은 하나의 코딩 패턴일 뿐이다. 만약 개발자가 실수건 고의건 이 코딩 패턴을 지키지 않는다면 데이터베이스 연결, 파일 시스템, 메모리는 낭비될 것이며 심한 경우 시스템 자원 부족으로 서버의 성능 저하로 이어질 수도 있으며 최악의 경우에는 서버 다운까지도 발생할 수 있다.
특히, 클래스 라이브러리를 개발하는 경우에는 이 문제가 더욱 심각해 질 수 있다. 어플리케이션 코드를 개발하는 경우에는 코드에 대한 전수(!) 검사를 수행하여 Dispose를 호출하지 않는 코드를 찾아 내고 Dispose 메서드를 호출하도록 수정한다던가 하는 방법을 생각해 볼 수 있지만, 필자와 같이 클래스 라이브러리를 제작하여 제공하는 경우에는 이 클래스 라이브러리를 사용하는 임의의 코드가 Dispose 메서드를 호출하도록 강제할 방법이 뾰족하게 없기 때문이다.
이런 이유에서 다른 글에서 언급했던 닷넷의 Finalizer 와 IDisposable 인터페이스를 함께 사용하여 Dispose 메서드 호출을 통한 명시적인 자원 해제와 Finalizer 를 이용한 암시적인 자원 해제를 동시에 지원하는 것이 바로 Dispose 패턴이다. Dispose 패턴의 기본 원리는 명시적으로 Dispose 메서드가 호출되지 않았을 때에 Finalize 메서드 내에서 Dispose 메서드를 호출하는 방식을 사용한다. 다음 코드는 Finalize 메서드의 예를 보여 주고 있다.
class LessSafeType : IDisposable
{
......
~LessSafeType()
{
Dispose();
}
public void Dispose()
{
_stream.Dispose();
GC.SuppressFinalize();
}
......
}
위 코드는 명시적으로 Dispose 메서드가 호출된 경우, SuppressFinalize 메서드를 호출함으로써 CLR이 가비지 컬렉션이 발생하더라도 Finalize 메서드를 호출하지 않도록 하여 Dispose 메서드가 중복으로 호출되는 것을 막아 준다. 하지만 이 코드는 명시적으로 Dispose 메서드가 호출되지 않은 경우에는 [리스트 1]에서 발생했던 문제를 해결해 주지 못한다. 즉, 명시적으로 Dispose 메서드가 호출되지 않은 경우에는 Finalize 메서드가 Finalizer 스레드에 의해 호출되고 이미 Dispose 되었을 지도 모르는 StreamWriter 객체를 다시 Dispose 할 수도 있다는 말이 되겠다. 따라서 Dispose 메서드 구현은 명시적으로 Dispose 메서드가 호출되었는지 Finalizer에 의해 Dispose 메서드가 호출되었는지를 구분할 필요가 있으며 오직 명시적인 Dispose 메서드 호출인 경우에만 StreamWriter 객체를 Dispose 해주어야 한다.
명시적인 Dispose 메서드 호출을 구분하기 위해 추가적인 자원 해제 메서드를 추가하면 된다. 이 메서드의 이름은 임의대로 지을 수 있지만 MSDN과 닷넷 프레임워크에서는 관습상 Dispose 라는 이름을 사용하곤 한다. 이 이름 때문에 개발자들이 Dispose 패턴을 이해하는데 혼동을 가져오곤 한다. IDisposable 인터페이스의 Dispose 메서드가 아닌 자원 해제를 수행하므로 Cleanup 이나 FreeResource 와 같은 이름을 사용했더라면 Dispose 패턴이 어렵게 느껴지거나 혼동이 오지 않았을 것이다. 어찌 되었건 지금까지 설명한 Dispose 패턴이 적용된 예제 코드는 [리스트 3]과 같다.
1: class SafeType : IDisposable
2: {
3: private StreamWriter _stream;
4:
5: public SafeType()
6: {
7: _stream = new StreamWriter("Test.txt");
8: }
9:
10: ~SafeType()
11: {
12: Dispose(false)
13: }
14:
15: public void Dispose()
16: {
17: Dispose(true);
18: GC.SuppressFinalize();
19: }
20:
21: protected virtual void Dispose(bool disposing)
22: {
23: if (disposing)
24: {
25: _stream.Dispose();
26: }
27: }
28:
29: public void DoSomething()
30: {
31: ... 생략 ...
32: }
33: }
[리스트3] Dispose 패턴이 적용된 클래스 예제 코드
[리스트 3]에서 IDisposable 인터페이스의 Dispose 메서드와 bool 매개변수를 가진 Dispose 메서드를 혼동하지 말기 바란다. 개발자가 자원 해제를 위해 호출하는 Dispose 메서드는 public Dispose 메서드이며 Finalize 메서드와 IDisposable 인터페이스의 Dispose 메서드에서 호출 하는 메서드가 bool 매개변수를 가진 protected Dispose 메서드이다. IDisposable 인터페이스의 Dispose 메서드는 Dispose 메서드가 사용자에 의해 (using 문을 사용했거나) 명시적으로 호출되었음을 알리기 위해 true를 매개변수로 사용하고 Finalizer 에서는 암시적으로 호출되었음을 알리기 위해 false를 매개변수로 사용한다. 따라서 disposing 매개변수의 값이 true 인 경우에만 자원을 해제하고 있음에 주목하자. Finalizer에 의해 Dispose 메서드가 호출되는 경우(disposing 매개변수가 false 인 상황), Finalizer 스레드에 의해 StreamWriter 객체의 Finalize 메서드가 이미 호출되었거나 앞으로 호출될 수도 있기 때문에 StreamWirter 객체의 Dispose를 호출할 수 없음에 주목하도록 하자.
Protected Dispose 메서드가 가상 메서드인 이유는 이렇다. SafeType에서 파생된 클래스가 동일하게 Dispose 패턴을 사용해야 하는 경우, 파생 클래스에서 동일하게 Dispose 패턴을 또 구현할 필요가 없다. 따라서 파생 클래스에서 자원을 해제하고자 하는 경우에는 이 메서드를 오버라이드 함으로써 자신의 자원 해제작업을 수행할 수 있도록 하기 위함이다. 파생 클래스는 이 protected Dispose 메서드를 오버라이드 하여 자신의 자원 해제를 수행하고 base.Dispose 메서드를 호출함으로써 베이스 클래스도 자원을 해제할 기회를 주기만 하면 된다. base.Dispose 메서드를 호출하는 시점은 protected Dispose 메서드의 시작 혹은 마지막이 될 수 있다(이건 파생 클래스의 구현에 따라서 그때 그때 달라질 수 있지만 대개 뒤에서 호출하면 무리가 없다. 자원 해제는 할당한 순서의 반대로 해줘야 하기 땀시…)
class DerivedClass : SafeType
{
protected override void Dispose(bool disposing)
{
if (disposing)
{
......
}
base.Dispose(disposing);
}
}
앞서 언급했지만 MSDN에서 소개하고 있는 Dispose 패턴이나 Reflector로 닷넷 프레임워크 내부 코드를 살펴볼 때 등장하는 IDisposable 인터페이스의 구현을 보면 혼동하기 정말 쉬운데, 그 이유가 바로 Dispose 메서드의 이름과 disposing 매개변수가 의미하는 것인 경우가 많다. 또, disposing 매개변수가 true 인 경우에만 자원을 해제하는 이유에 대해서는 Finalizer 사용 시 주의 사항들에서 설명한 Finalizer의 작동 원리를 잘 이해해야만 한다.
어… 어렵다?
조금 어렵게 느껴질 수도 있을 것이다. 충분히 이해하고도 남는다. 이번 포스트를 잘 이해하려면 GC 작동 원리와 Finalizer 작동 방식을 모두 잘 알고 있어야 하기 때문이다. 필자의 글 쓰는 능력이 워낙 미천해서 더 상세히 설명하긴 무리가 있는 듯하다. 이해 안가는 부분이 있다면 필자에게 성질내기 전에 먼저 조상님과 면담부터 한 후에 댓글이나 메일로 질문을 해주기 바란다.
그나 저나… 제목을 보니깐 이게 Dispose 패턴의 기초라고? 그럼 고급 Dispose 패턴도 있다는 말인가?
안타깝게도 더욱 복잡한 것이 있긴 하다. 어떤 타입이 해제해야 할 자원이 WIN32 핸들과 같은 unmanaged 자원이라면 Dispose 패턴에서 고려해야 할 사항이 추가로 발생한다. 그것이 무엇인지에 대해서는 다음 포스트에서 살펴보도록 하자.
경고 : 이 글을 무단으로 복제/스크랩하여 타 게시판, 블로그에 게시하는 것은 허용하지 않습니다.