GC 가 치우지 못하는 것이 있다
앞에서 GC 가 아무도 안 가리키는 것을 알아서 치운다고 했습니다. 그러면 우리가 치울 일이 없어야 할 텐데, 이런 코드가 있습니다.
using (var 파일 = new StreamWriter("log.txt"))
{
파일.WriteLine("안녕");
}
using 이라고 적어서 끝나는 자리를 표시했습니다. GC 가 알아서 한다면 왜 이걸 적을까요.
GC 는 힙의 메모리만 압니다. 파일 핸들이나 소켓처럼 운영체제가 빌려준 것은 모릅니다.
GC 가 아는 것과 모르는 것
new StreamWriter(...) 를 하면 두 가지가 생깁니다.
메모리는 늦게 회수돼도 별일이 없습니다. 남는 메모리가 있으면 GC 는 굳이 안 돕니다.
파일 핸들은 다릅니다. 열린 동안 다른 프로그램이 그 파일을 못 엽니다. 운영체제가 빌려주는 핸들은 개수도 정해져 있습니다. 안 돌려주고 계속 빌리기만 하면 언젠가 바닥납니다.
그리고 StreamWriter 는 쓴 내용을 버퍼에 모아 뒀다가 한꺼번에 내보냅니다.
닫지 않으면 버퍼에 남은 내용이 파일에 안 적힙니다. 프로그램이 끝나 버리면 그대로 사라집니다.
이런 것을 관리되지 않는 자원이라고 부릅니다. GC 밖에 있다는 뜻입니다.
그래서 Dispose 가 있다
돌려주는 방법이 필요합니다. 그게 IDisposable 입니다. 메서드 하나짜리 인터페이스입니다.
public interface IDisposable
{
void Dispose();
}
Dispose() 를 부르면 그 안에서 핸들을 닫고 버퍼를 내보냅니다.
GC 를 기다리는 것이 아니라 우리가 시점을 정합니다.
문제는 부르는 걸 잊는다는 것입니다. 그리고 중간에 예외가 나면 못 부릅니다.
var 파일 = new StreamWriter("log.txt");
파일.WriteLine(계산()); // 여기서 터지면
파일.Dispose(); // 이 줄에 못 옵니다
using 은 try/finally 다
using 은 이 문제를 문법으로 막습니다.
앞에서 본 finally 그대로입니다. 예외가 나든 return 으로 나가든 Dispose() 는 반드시 불립니다.
C# 8 부터는 괄호 없이 쓸 수도 있습니다. 이러면 그 블록이 끝날 때 정리됩니다.
void 쓰기()
{
using var 파일 = new StreamWriter("log.txt");
파일.WriteLine("안녕");
} // 여기서 Dispose
내가 만드는 쪽
관리되지 않는 자원을 들고 있는 클래스를 만들면 IDisposable 을 붙입니다.
class 연결 : IDisposable
{
private Socket _소켓;
private bool _치웠나;
public void Dispose()
{
if (_치웠나) return; // 두 번 불려도 괜찮게
_소켓?.Close();
_소켓 = null;
_치웠나 = true;
}
}
한 가지만 지키면 됩니다. 두 번 불려도 터지지 않게 만듭니다.
using 안에서 직접 Dispose() 를 부르는 코드가 섞이면 두 번 불립니다. 흔한 일입니다.
내가 만든 클래스가 다른 IDisposable 을 필드로 들고 있으면 그것도 같이 치웁니다.
그러지 않으면 내 것만 정리되고 안에 든 것이 남습니다.
어디서 만나나
IDisposable 이 붙은 것은 대개 이런 것들입니다.
- 파일:
StreamReader,StreamWriter,FileStream - 네트워크:
HttpClient,Socket,TcpClient - 데이터베이스:
SqlConnection,SqlCommand - 그림:
Bitmap,Graphics
Unity 에서는 조금 다른 얼굴로 나옵니다.
using (var 결과 = new NativeArray<float>(100, Allocator.Temp))
{
...
}
NativeArray 는 GC 가 관리하지 않는 메모리를 직접 잡습니다.
안 치우면 에디터가 누수를 경고합니다.
Texture2D 나 Mesh 처럼 유니티 객체는 Dispose 가 아니라 Destroy 로 치웁니다.
이름은 다르지만 “GC 가 모르는 것을 우리가 시점을 정해 돌려준다” 는 이야기는 같습니다.
한 가지 예외: HttpClient
거의 모든 IDisposable 은 using 으로 감싸는 것이 맞는데 HttpClient 는 반대입니다.
// 이렇게 쓰면 안 됩니다
using (var client = new HttpClient()) { ... }
Dispose 를 해도 아래의 소켓 연결이 한동안 대기 상태로 남습니다.
매번 만들고 버리면 그 대기가 쌓여서 결국 연결을 못 만듭니다.
HttpClient 는 하나 만들어 오래 쓰는 것이 맞습니다.
규칙을 외우기보다 왜 치우는지를 보면 이런 예외도 설명이 됩니다.
치우는 목적은 자원을 빨리 돌려주는 것인데, HttpClient 는 돌려주는 것이 오히려 손해라서 그렇습니다.
정리
- GC 는 힙 메모리만 압니다. 파일 핸들, 소켓, 네이티브 메모리는 모릅니다
- 그래서 언제 도는지 모르는 GC 를 기다리면 파일이 잠긴 채 남거나 버퍼가 안 비워집니다
IDisposable.Dispose()로 우리가 시점을 정해 돌려줍니다using은try/finally로 풀립니다. 예외가 나도 반드시 정리됩니다- 내가 만들 때는 두 번 불려도 터지지 않게 하고, 안에 든
IDisposable도 같이 치웁니다
Dispose 를 부르는 것은 우리 몫입니다. using 이 도와주기는 하지만, 그것도 우리가 적어야 합니다.
적는 것을 깜빡하면 아무도 대신 닫아 주지 않습니다.
GC 가 치우기 직전에 대신 실행되는 것을 붙일 수 있습니다. 안전망처럼 보입니다. 그런데 붙이면 그 객체는 오히려 더 늦게 치워집니다.