블로그로 돌아가기
C#

C# IDisposable 사용법: using으로 리소스를 안전하게 해제하는 방법

C#에서 IDisposable이 필요한 이유와 using을 이용해 리소스를 안전하게 정리하는 방법을 설명합니다. 직접 IDisposable을 구현할 때 확인해야 하는 리소스 소유권, Dispose 패턴, 종료자 사용 조건도 함께 정리합니다.

C#.NETIDisposableDisposeusing리소스 관리
C# IDisposable 사용법: using으로 리소스를 안전하게 해제하는 방법

C# IDisposable 사용법: using으로 리소스를 안전하게 해제하는 방법

파일, 스트림, 소켓처럼 운영체제 리소스를 사용하는 객체는 더 이상 참조하지 않는다는 이유만으로 즉시 정리되지 않습니다. 가비지 컬렉터는 관리 객체의 메모리를 회수하지만, 파일 핸들이나 네트워크 연결의 정리 시점까지 보장하지는 않습니다.

IDisposable은 객체가 보유한 리소스를 명시적으로 정리할 수 있도록 Dispose() 메서드를 제공합니다. 객체를 사용하는 코드는 using으로 Dispose() 호출을 보장하고, 리소스를 소유하는 클래스는 자신의 수명이 끝날 때 내부 리소스도 함께 정리해야 합니다.

적용 범위

  • 언어: C#
  • 런타임: .NET
  • 주요 API: IDisposable, Dispose(), using
  • 대상 독자: 파일, 스트림, 연결 객체를 안전하게 관리하려는 초급 또는 중급 개발자
  • 버전 조건: 특정 .NET 버전에 종속되지 않는 기본 패턴을 기준으로 합니다.
  • 검증 범위: 예제 코드는 일반적인 C# 문법과 .NET API를 기준으로 작성했으며 실제 실행 검증이 필요합니다.

IDisposable이 필요한 이유

IDisposable은 다음 메서드 하나를 정의하는 인터페이스입니다.

public interface IDisposable { void Dispose(); }

Dispose()의 목적은 객체가 보유한 리소스를 더 이상 사용하지 않을 때 정리하는 것입니다.

대표적인 정리 대상은 다음과 같습니다.

  • 파일과 스트림
  • 소켓과 네트워크 연결
  • 데이터베이스 연결
  • 운영체제 핸들
  • 잠금이나 등록된 이벤트처럼 수명 관리가 필요한 상태
  • 다른 IDisposable 객체

Dispose()는 객체의 관리 메모리를 직접 삭제하는 메서드가 아닙니다. 관리 메모리는 가비지 컬렉터가 회수합니다. Dispose()는 가비지 컬렉터가 정리 시점을 알 수 없거나 직접 관리하지 않는 리소스를 먼저 반환하기 위한 메서드입니다.

Dispose를 직접 호출할 때 발생하는 문제

다음 코드는 StreamReader를 사용한 뒤 Dispose()를 직접 호출합니다.

using System.IO; static string ReadFirstLine(string path) { var reader = new StreamReader(path); string? line = reader.ReadLine(); if (line is null) { throw new InvalidDataException("파일이 비어 있습니다."); } reader.Dispose(); return line; }

정상적으로 마지막 줄까지 실행되면 reader.Dispose()가 호출됩니다. 하지만 파일을 읽는 과정이나 빈 파일을 검사하는 과정에서 예외가 발생하면 메서드가 즉시 종료됩니다.

이 경우 reader.Dispose()까지 실행되지 않습니다. 파일 스트림이 닫히는 시점도 가비지 컬렉션에 의존하게 됩니다.

Dispose()를 메서드 마지막에 작성하는 것만으로는 예외가 발생했을 때의 정리를 보장할 수 없습니다.

해결 방법 1: IDisposable 객체를 using으로 사용하기

이 방법이 적합한 경우

다른 클래스가 제공하는 IDisposable 객체를 일정한 범위에서 사용하고 정리해야 할 때는 using을 우선 사용합니다.

파일이나 스트림을 메서드 내부에서 생성하는 코드가 대표적인 사례입니다.

using 문 사용하기

using System.IO; static string ReadFirstLine(string path) { using (var reader = new StreamReader(path)) { return reader.ReadLine() ?? throw new InvalidDataException("파일이 비어 있습니다."); } }

프로그램 흐름이 using 블록을 벗어나면 C# 런타임은 reader.Dispose()를 호출합니다.

블록 안에서 return을 실행하거나 예외가 발생해도 리소스 정리 과정은 실행됩니다. 개념적으로는 다음 tryfinally 구조와 비슷하게 동작합니다.

StreamReader? reader = null; try { reader = new StreamReader(path); return reader.ReadLine() ?? throw new InvalidDataException("파일이 비어 있습니다."); } finally { reader?.Dispose(); }

직접 tryfinally를 작성할 수도 있지만, 일반적인 IDisposable 객체에는 using이 더 짧고 정리 의도가 분명합니다.

using 선언 사용하기

중괄호를 추가하지 않고 현재 범위가 끝날 때 객체를 정리하는 방식도 사용할 수 있습니다.

using System.IO; static string ReadFirstLine(string path) { using var reader = new StreamReader(path); return reader.ReadLine() ?? throw new InvalidDataException("파일이 비어 있습니다."); }

이 코드에서 reader.Dispose()는 메서드 범위가 끝날 때 호출됩니다.

using 선언은 코드의 들여쓰기를 줄일 수 있지만, 리소스를 오래 유지할 가능성이 있습니다. 메서드가 길고 파일을 초반에만 사용한다면 using 블록으로 범위를 좁히는 편이 낫습니다.

static void ProcessFile(string path) { string content; using (var reader = new StreamReader(path)) { content = reader.ReadToEnd(); } // 이 시점에는 파일 스트림이 정리된 상태입니다. ProcessContent(content); }

주의사항

이미 외부에서 생성한 변수를 using에 전달하면 블록이 끝난 뒤에도 변수 자체는 범위에 남을 수 있습니다.

var reader = new StreamReader(path); using (reader) { Console.WriteLine(reader.ReadLine()); } // reader 변수는 남아 있지만 내부 스트림은 이미 정리됐습니다.

정리된 객체를 다시 사용하면 ObjectDisposedException이 발생할 수 있습니다.

가능하면 객체를 using 문이나 using 선언 안에서 생성해 정리된 객체가 다른 코드에서 다시 사용되지 않도록 합니다.

using var reader = new StreamReader(path); Console.WriteLine(reader.ReadLine());

해결 방법 2: 리소스를 소유한 클래스에서 IDisposable 구현하기

이 방법이 적합한 경우

클래스가 내부에서 IDisposable 객체를 생성하고 필드로 보관한다면 해당 클래스가 리소스의 소유자입니다.

소유자는 자신의 사용이 끝날 때 내부 객체의 Dispose()도 호출해야 합니다. 이를 연쇄 정리라고 볼 수 있습니다.

다음 CsvWriter는 생성자에서 StreamWriter를 만들고 필드에 저장합니다.

수정 코드

using System; using System.Collections.Generic; using System.IO; public sealed class CsvWriter : IDisposable { private readonly StreamWriter _writer; private bool _disposed; public CsvWriter(string path) { _writer = new StreamWriter(path); } public void WriteRow(IEnumerable<string> values) { ThrowIfDisposed(); _writer.WriteLine(string.Join(",", values)); } public void Dispose() { if (_disposed) { return; } _writer.Dispose(); _disposed = true; } private void ThrowIfDisposed() { if (_disposed) { throw new ObjectDisposedException(nameof(CsvWriter)); } } }

사용하는 쪽에서는 CsvWriterusing으로 감쌉니다.

using var writer = new CsvWriter("output.csv"); writer.WriteRow(new[] { "id", "name" }); writer.WriteRow(new[] { "1", "Kim" });

현재 클래스가 생성한 StreamWriterCsvWriter가 소유합니다. 따라서 CsvWriter.Dispose()_writer.Dispose()를 호출합니다.

Dispose를 여러 번 호출해도 문제가 없어야 하는 이유

객체의 정리 경로가 여러 곳에 있으면 Dispose()가 두 번 이상 호출될 수 있습니다.

var writer = new CsvWriter("output.csv"); writer.Dispose(); writer.Dispose();

첫 번째 호출에서 이미 리소스를 정리했다면 두 번째 호출은 추가 작업 없이 종료하는 것이 안전합니다. 예제에서는 _disposed 필드로 중복 호출을 차단합니다.

Dispose()를 구현할 때는 다음 두 상태를 구분해야 합니다.

  1. 아직 정리되지 않은 상태
  2. 이미 정리된 상태

이미 정리된 객체에 작업 메서드를 호출했을 때는 ObjectDisposedException으로 잘못된 사용을 알릴 수 있습니다.

클래스를 sealed로 선언한 이유

CsvWriter는 상속을 고려하지 않은 예제이므로 sealed로 선언했습니다.

상속할 수 없는 클래스는 파생 클래스의 추가 리소스를 정리할 필요가 없습니다. 따라서 별도의 Dispose(bool) 확장 지점 없이 단순한 Dispose() 구현을 사용할 수 있습니다.

상속이 필요한 클래스라면 파생 클래스가 자신의 리소스를 정리할 수 있는 구조를 제공해야 합니다.

해결 방법 3: 상속 가능한 클래스에 Dispose 패턴 적용하기

상속 가능한 클래스는 protected virtual Dispose(bool disposing) 메서드를 제공하는 패턴을 사용합니다.

using System; using System.IO; public class TextFileWriter : IDisposable { private StreamWriter? _writer; private bool _disposed; public TextFileWriter(string path) { _writer = new StreamWriter(path); } public void WriteLine(string value) { ThrowIfDisposed(); _writer!.WriteLine(value); } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (_disposed) { return; } if (disposing) { _writer?.Dispose(); _writer = null; } _disposed = true; } private void ThrowIfDisposed() { if (_disposed) { throw new ObjectDisposedException(nameof(TextFileWriter)); } } }

Dispose(true)는 사용자가 Dispose()를 명시적으로 호출한 경로입니다. 이 경로에서는 클래스가 소유한 관리 객체인 _writer를 정리할 수 있습니다.

파생 클래스에 추가 리소스가 있다면 Dispose(bool)을 재정의하고 자신의 리소스를 먼저 정리한 뒤 기반 클래스의 메서드를 호출합니다.

using System.IO; public sealed class BufferedTextFileWriter : TextFileWriter { private MemoryStream? _buffer; private bool _disposed; public BufferedTextFileWriter(string path) : base(path) { _buffer = new MemoryStream(); } protected override void Dispose(bool disposing) { if (_disposed) { return; } if (disposing) { _buffer?.Dispose(); _buffer = null; } _disposed = true; base.Dispose(disposing); } }

파생 클래스가 기반 클래스의 정리 메서드를 호출하지 않으면 기반 클래스가 보유한 리소스가 남을 수 있습니다.

리소스 소유권을 먼저 확인해야 하는 이유

필드가 IDisposable을 구현한다는 이유만으로 항상 함께 정리해야 하는 것은 아닙니다. 누가 객체를 생성했고 누가 수명을 관리하는지 확인해야 합니다.

다음 두 경우를 구분합니다.

클래스가 내부 객체를 직접 생성한 경우

public CsvWriter(string path) { _writer = new StreamWriter(path); }

CsvWriterStreamWriter를 생성했으므로 일반적으로 CsvWriter가 소유권을 가집니다. CsvWriter.Dispose()가 내부 객체도 정리해야 합니다.

외부에서 객체를 전달받은 경우

public ReportService(TextWriter writer) { _writer = writer; }

이 코드만으로는 ReportService_writer의 소유권까지 전달받았는지 알 수 없습니다.

호출자가 같은 TextWriter를 다른 곳에서도 사용한다면 ReportService가 임의로 정리해서는 안 됩니다. 반대로 소유권을 명시적으로 이전하는 설계라면 ReportService가 정리할 수 있습니다.

생성자 주석, 매개변수 이름, 별도의 leaveOpen 옵션 등으로 소유권 규칙을 드러내야 합니다.

public sealed class ReportService : IDisposable { private readonly TextWriter _writer; private readonly bool _leaveOpen; private bool _disposed; public ReportService(TextWriter writer, bool leaveOpen) { _writer = writer; _leaveOpen = leaveOpen; } public void Dispose() { if (_disposed) { return; } if (!_leaveOpen) { _writer.Dispose(); } _disposed = true; } }

leaveOpentrue이면 호출자가 _writer의 수명을 계속 관리합니다. false이면 ReportService가 정리합니다.

종료자를 기본으로 추가하면 안 되는 이유

C#의 종료자(finalizer)는 객체가 가비지 컬렉터에 의해 수집될 때 정리 코드를 실행할 수 있는 기능입니다.

~NativeResource() { // 정리 코드 }

일반적인 IDisposable 클래스에 종료자를 추가할 필요는 없습니다. StreamWriter처럼 이미 IDisposable을 구현한 관리 객체만 소유한다면 Dispose()에서 해당 객체를 정리하면 됩니다.

종료자는 클래스가 네이티브 핸들이나 비관리 메모리를 직접 보유할 때만 검토합니다. 이 경우에도 직접 종료자를 작성하기보다 SafeHandle로 비관리 핸들을 감싸는 방식을 우선 고려합니다.

종료자는 실행 시점이 정해져 있지 않으며 구현 과정에서 관리 객체의 수명과 정리 순서를 잘못 처리하기 쉽습니다.

피해야 하는 해결 방법

메서드 마지막에서만 Dispose 호출하기

var stream = File.OpenRead(path); Process(stream); stream.Dispose();

Process()가 예외를 던지면 Dispose()가 호출되지 않습니다. using이나 tryfinally를 사용해야 합니다.

IDisposable을 구현한 모든 필드를 무조건 정리하기

외부에서 주입받은 객체를 임의로 정리하면 호출자가 사용 중인 리소스까지 닫을 수 있습니다. 필드의 타입보다 소유권 계약을 먼저 확인해야 합니다.

Dispose에서 GC.Collect 호출하기

public void Dispose() { _writer.Dispose(); GC.Collect(); }

Dispose()는 전체 가비지 컬렉션을 강제로 실행하기 위한 메서드가 아닙니다. GC.Collect()는 애플리케이션 전체의 가비지 컬렉션 동작에 영향을 주므로 일반적인 정리 코드에 추가하지 않습니다.

IDisposable만 구현하고 사용 코드에서 Dispose를 호출하지 않기

클래스가 IDisposable을 구현해도 호출자가 Dispose()를 호출하지 않으면 결정적인 정리가 이루어지지 않습니다.

var writer = new CsvWriter("output.csv"); writer.WriteRow(new[] { "id", "name" }); // Dispose 호출이 없습니다.

사용하는 쪽에서도 using을 적용해야 합니다.

using var writer = new CsvWriter("output.csv"); writer.WriteRow(new[] { "id", "name" });

비동기 정리를 Dispose에 억지로 넣기

리소스 정리에 비동기 작업이 필요하다면 IAsyncDisposableawait using을 검토합니다.

동기 Dispose() 안에서 비동기 작업을 강제로 대기하면 교착 상태나 불필요한 스레드 대기가 발생할 수 있습니다. 동기 정리와 비동기 정리는 별도의 계약으로 설계해야 합니다.

해결 방법 비교

상황권장 방식정리 시점주의사항
메서드 내부에서 IDisposable 객체를 잠시 사용함using블록이 끝날 때리소스 범위를 가장 좁게 유지합니다.
현재 메서드 범위 전체에서 객체를 사용함using 선언현재 범위가 끝날 때긴 메서드에서는 리소스가 오래 유지될 수 있습니다.
sealed 클래스가 내부 리소스를 소유함단순한 Dispose() 구현외부에서 Dispose()를 호출할 때중복 호출을 처리해야 합니다.
상속 가능한 클래스가 리소스를 소유함Dispose(bool) 패턴기반 클래스와 파생 클래스가 순차적으로 정리될 때파생 클래스가 base.Dispose(disposing)을 호출해야 합니다.
비관리 핸들을 직접 보유함SafeHandle 중심 설계SafeHandle.Dispose() 또는 안전장치인 종료자 실행 시직접 종료자를 구현하기 전에 SafeHandle을 검토합니다.
비동기 정리가 필요함IAsyncDisposableawait using비동기 범위를 벗어날 때동기 IDisposable과 역할을 구분합니다.

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

다른 라이브러리의 IDisposable 객체를 사용하는 코드라면 먼저 using을 적용합니다. 직접 tryfinally를 작성해야 할 특별한 정리 순서가 없다면 using이 기본 선택입니다.

클래스가 내부에서 IDisposable 객체를 생성하고 필드로 보관한다면 해당 클래스도 IDisposable을 구현합니다.

상속을 지원할 이유가 없다면 클래스를 sealed로 만들고 단순한 Dispose()를 구현합니다. 상속을 실제로 지원해야 한다면 Dispose(bool) 패턴을 제공합니다.

외부에서 전달받은 객체는 소유권이 명확할 때만 정리합니다. 소유권이 불분명하다면 leaveOpen 같은 옵션이나 문서화된 계약을 추가합니다.

비관리 핸들을 직접 다뤄야 한다면 종료자를 먼저 작성하지 않습니다. SafeHandle로 핸들을 감쌀 수 있는지 확인합니다.

정리 과정에서 비동기 I/O가 필요하다면 IDisposable만으로 처리하지 않고 IAsyncDisposable을 검토합니다.

자주 발생하는 추가 문제

Dispose 이후 객체를 다시 사용하는 경우

정리된 객체는 정상 상태가 아닙니다. 메서드가 내부 리소스에 접근하기 전에 정리 여부를 검사하고 ObjectDisposedException을 던질 수 있습니다.

private void ThrowIfDisposed() { if (_disposed) { throw new ObjectDisposedException(nameof(CsvWriter)); } }

Dispose를 호출했는데 파일이 바로 기록되지 않는 경우

StreamWriter.Dispose()는 내부 버퍼를 비우고 스트림을 닫습니다. 하지만 별도의 스트림을 leaveOpen 상태로 전달했거나 소유권을 외부에 남긴 구조라면 하위 스트림이 계속 열려 있을 수 있습니다.

래퍼 객체와 내부 스트림 중 어느 객체가 실제 소유권을 갖는지 확인해야 합니다.

의존성 주입 컨테이너가 생성한 객체를 직접 정리하는 경우

의존성 주입 컨테이너가 객체의 수명을 관리한다면 일반적으로 컨테이너가 등록된 수명 종료 시점에 Dispose()를 호출합니다.

컨테이너에서 전달받은 서비스를 사용 코드가 임의로 정리하면 같은 범위에서 해당 서비스를 사용하는 다른 코드에 영향을 줄 수 있습니다. 객체를 직접 생성했는지 컨테이너에서 전달받았는지 구분해야 합니다.

동작 확인

다음 명령으로 콘솔 프로젝트를 생성합니다.

dotnet new console -n DisposableSample cd DisposableSample

Program.cs를 다음 코드로 교체합니다.

using System; using System.Collections.Generic; using System.IO; using var writer = new CsvWriter("output.csv"); writer.WriteRow(new[] { "id", "name" }); writer.WriteRow(new[] { "1", "Kim" }); public sealed class CsvWriter : IDisposable { private readonly StreamWriter _writer; private bool _disposed; public CsvWriter(string path) { _writer = new StreamWriter(path); } public void WriteRow(IEnumerable<string> values) { ThrowIfDisposed(); _writer.WriteLine(string.Join(",", values)); } public void Dispose() { if (_disposed) { return; } _writer.Dispose(); _disposed = true; } private void ThrowIfDisposed() { if (_disposed) { throw new ObjectDisposedException(nameof(CsvWriter)); } } }

프로젝트를 실행합니다.

dotnet run

예상 결과는 프로젝트 실행 디렉터리에 다음 내용의 output.csv 파일이 생성되는 것입니다.

id,name 1,Kim

예제 코드는 일반적인 C# 및 .NET API를 기준으로 작성했습니다. 발행 전 실제 사용하는 SDK와 운영체제에서 dotnet builddotnet run을 실행해 확인해야 합니다. [실행 검증 필요]

정리된 객체의 재사용도 확인하려면 다음 코드를 별도 테스트에 추가할 수 있습니다.

var writer = new CsvWriter("output.csv"); writer.Dispose(); writer.WriteRow(new[] { "2", "Lee" });

예상 결과는 WriteRow()에서 ObjectDisposedException이 발생하는 것입니다. [실행 검증 필요]

정리

IDisposable은 가비지 컬렉터가 관리하지 않는 리소스나 명시적인 수명 관리가 필요한 객체를 정리하기 위한 계약입니다.

다른 클래스의 IDisposable 객체를 사용할 때는 using으로 예외 발생 여부와 관계없이 Dispose()가 호출되도록 합니다.

클래스가 내부 리소스를 직접 생성하고 소유한다면 해당 클래스도 IDisposable을 구현합니다. 상속이 필요하지 않다면 sealed 클래스와 단순한 Dispose() 구현을 우선합니다.

상속을 지원해야 할 때만 Dispose(bool) 패턴을 적용합니다. 비관리 핸들을 직접 다룬다면 자체 종료자보다 SafeHandle을 먼저 검토합니다.

구현 전에 가장 먼저 확인할 기준은 객체의 타입이 아니라 리소스의 소유권입니다.