C++,C를 알고 있다는 사실이 C#을 배우는데 얼마나 큰 해로움인가에 대해서

그렇게 작동하지 않습니다. 왜냐하면 C#이니까요.

finally
Published

April 8, 2025

Abstract

“이 책은 도대체 왜 이런 이상한 논의를 하는걸까?…”

파일 복사를 위한 간단한 예제

  • 아주 간단한 파일 복사기를 만든다고 가정
  • 이 클래스는 생성자에서 두 개의 경로를 받아 스트림 두 개를 열고, CopyAsync 메서드에서 한 스트림의 내용을 다른 스트림으로 복사
#nullable enable

public sealed class FileCopier : IDisposable
{
    // 소스 스트림과 대상 스트림
    private readonly Stream _source;
    private readonly Stream _destination;

    // 생성자: 경로를 받아 FileStream을 생성
    public FileCopier(string sourcePath, string destinationPath)
    {
        _source = new FileStream(sourcePath, FileMode.Open);
        _destination = new FileStream(destinationPath, FileMode.Create);
    }

    // 비동기 복사 메서드
    public async Task CopyAsync()
        => await _source.CopyToAsync(_destination);

    // IDisposable 구현: Dispose 메서드 호출
    public void Dispose() => Dispose(disposing: true);

    // 종료자(Finalizer): Dispose가 호출되지 않았을 때 리소스 해제를 시도
    ~FileCopier()
    {
        Console.WriteLine("종료자 ~FileCopier 실행 중");
        Dispose(disposing: false); // 관리되지 않는 리소스만 정리하도록 호출 (여기서는 잘못된 구현)
    }

    // 실제 리소스 해제 로직
    private void Dispose(bool disposing)
    {
        // disposing 플래그는 여기서는 사용되지 않았지만, 일반적으로 관리되는 리소스 해제 여부를 결정
        _source.Dispose();
        _destination.Dispose();
    }
}
  • 이 클래스는 IDisposable 인터페이스를 구현하고, Dispose 메서드가 호출되지 않았을 경우 리소스가 해제되도록 보장하기 위해 종료자(finalizer)를 가지고 있음
private static async Task Copy(string source, string destination)
{
    // using 문을 사용하여 FileCopier 인스턴스 관리 (자동으로 Dispose 호출)
    using (var copier = new FileCopier(source, destination))
    {
        await copier.CopyAsync();
    }
}

static async Task Main(string[] args)
{
    string source = "source.txt";
    string destination = "destination.txt";

    try
    {
        await Copy(source, destination);
        Console.WriteLine("복사 완료!");
    }
    catch (Exception ex)
    {
        // 오류 발생 시 메시지 출력 및 GC 강제 실행 (예시 목적)
        Console.WriteLine($"오류: {ex.Message}");
        GC.Collect();
        GC.Collect();
    }
}
  • 실행 결과는 다음과 같습니다:
Error: Could not find file 'C:\Users\sd\source\repos\DisposableTrickiness\DisposableTrickiness\bin\Debug\net9.0\source.txt'
Press 'Enter' to exit.
Running ~FileCopier
Unhandled exception. System.NullReferenceException: Object reference not set to an instance of an object.
   at DisposableTrickiness.FileCopier.Dispose(Boolean disposing) in C:\Users\seteplia\source\repos\DisposableTrickiness\DisposableTrickiness\Program.cs:line 35
   at DisposableTrickiness.FileCopier.Finalize() in C:\Users\sd\source\repos\DisposableTrickiness\DisposableTrickiness\Program.cs:line 30
   at System.GC.RunFinalizers()
  • 애플리케이션은 소스 파일이 없다는 메시지를 출력하지만, 그 후에 NullReferenceException과 함께 충돌
  • 호출 스택을 보면 NullReferenceException이 종료자에서 발생하고 있음을 알 수 있음

왜? 이게 무슨 일일까요?!?

종료자가 어떻게 작동하는지, 그리고 왜 여러분이 생각하는 것보다 더 까다로울 수 있는지 깊이 파고들어 봅시다!

애플리케이션이 충돌하는 이유?

종료자는 가비지 컬렉터(GC)에 의해 제어되는 전용 스레드에서 실행됩니다. 일반 스레드와 마찬가지로, 종료자 스레드에서 처리되지 않은 예외가 발생하면 애플리케이션이 충돌합니다. 모든 종료자를 실행하기 위한 단일 전용 스레드가 존재하기 때문에, 종료자 내에서 블로킹 호출(blocking call)을 하는 것 또한 좋은 생각이 아닙니다.

지속적으로 실행하는 애플리케이션에서 종료자 스레드가 차단되었을 때 어떤 “이상한” 일이 발생할지 상상조차 하고 싶지 않기 때문에 종료자에서 임의의 코드를 호출하지 말고, 예외가 밖으로 빠져나가지 못하게 해야 함

null 비허용성(non-nullability)이 활성화되었는데 왜 NullReferenceException이 발생할까?

Nullable 참조 타입은 순전히 컴파일 시점의 기능이며 몇 가지 알려진 제약 사항이 있는데, 종료자가 그중 하나입니다. 종료자는 생성자가 성공적으로 완료되었는지 여부에 관계없이 모든 객체에 대해 실행됩니다.

C++ 배경 지식이 있는 일부 사람들은 종료자가 C++의 소멸자(destructor)와 비슷하다고 생각할 수 있습니다. C++ 소멸자는 완전히 생성된 인스턴스에 대해서만 실행됩니다. 하지만 C++ 소멸자와 달리, C# 종료자는 생성자가 예외를 던지며 실패하더라도 실행됩니다. 이는 C# 컴파일러 관점에서는 _source_destination 필드가 null이 될 수 없다고 보지만, 생성자가 필드가 할당되기 전에 실패하면 종료 과정에서 이 필드들이 null 상태일 수 있다는 것을 의미합니다.

종료자에서 _source와 _destination을 건드려도 될까?

여기서 해결책은 _source?.Dispose();_destination?.Dispose(); 처럼 null 조건부 연산자(?.)를 사용하는 것이 아니라, 애초에 종료자에서 “관리되는 리소스(managed resources)”를 건드리지 않는 것입니다.

여기에는 두 가지 이유가 있습니다.

  1. 종료 순서는 비결정적(non-deterministic)입니다.
  2. 종료자는 “관리되지 않는 리소스(unmanaged resources)”를 정리하기 위해 설계되었으며, Stream은 “관리되는 리소스”입니다.

다시 처음부터 이 점들을 명확히 해 봅시다.

C++ 배경 지식이 있다면 종료 순서가 생성 순서의 반대라고 생각할 수 있습니다. 하지만 그렇지 않습니다. CLR(Common Language Runtime)은 객체의 의존성 체인이나 생성 순서를 추적하지 않습니다. 단순히 모든 인스턴스를 전역 종료 큐(global finalization queue)에 등록할 뿐입니다. 이 큐는 “중요 종료 가능(critical finalizable)” 객체(클래스가 CriticalFinalizerObject에서 파생될 때)에 대해 특별한 처리를 하며, “일반” 객체의 종료가 “중요” 객체의 종료 전에 발생한다는 것을 보장합니다. 하지만 일반 또는 중요 종료 세그먼트 내에서 어떤 순서로 종료가 발생하는지에 대한 보장은 없습니다.

만약 객체 A가 객체 B를 참조한다면, 객체 A의 종료는 객체 B의 종료 전후 어디에서든 발생할 수 있습니다. 여러분은 이를 제어할 수 없습니다. 하지만 두 번째 측면이 왜 애초에 종료자에서 “관리되는” 리소스를 건드리지 말아야 하는지를 설명합니다. 종료자는 오직 “관리되지 않는” 리소스를 정리하기 위해 설계되었습니다!

“관리되는 리소스”와 “관리되지 않는 리소스”의 차이점은 무엇인가?

어떤 경우에는 용어가 중요하며, “관리되는” 리소스와 “관리되지 않는” 리소스의 개념을 이해하는 것은 .NET에서 리소스를 올바르게 처리하는 방법을 이해하는 데 중요합니다.

만약 어떤 클래스가 IDisposable을 구현한다면 그것은 “관리되는 리소스”입니다. 만약 IntPtr (또는 유사한 것)이라면 그것은 “관리되지 않는 리소스”입니다. IntPtr을 종료자를 가진 IDisposable 클래스로 감싸면, 관리되는 리소스를 얻게 됩니다!

CLR은 메모리를 자동으로 “관리”합니다. 인스턴스가 “스코프를 벗어나고” 애플리케이션 코드에서 더 이상 접근할 수 없게 되면 가비지 컬렉션 대상이 됩니다. GC가 실행될 때 객체가 사용하던 메모리는 회수됩니다. 이 과정 뒤에는 상당한 복잡성이 있지만, 그 모든 복잡성은 메모리를 처리하기 위해 만들어졌습니다. CLR은 다른 종류의 리소스를 자동으로 관리할 수 없습니다. 만약 malloc을 통해 관리되지 않는 힙(unmanaged heap)에 리소스가 할당되었거나 운영 체제로부터 불투명 핸들(opaque handler)을 얻었다면, CLR은 그것들이 접근 불가능하게 되었을 때 자동으로 해제할 수 없습니다.

리소스를 “관리되게” 만들려면, 기반이 되는 “관리되지 않는” 리소스를 IDisposable 인터페이스를 구현하는 클래스로 감싸야 합니다. 이는 리소스를 즉시 정리(eager clean-up)하기 위함이며, 사용자가 리소스 정리를 잊고 인스턴스가 GC에 의해 수집될 때 리소스를 정리하기 위한 종료자도 가져야 합니다.

// ManagedWrapper 자체는 관리되는 리소스입니다.
public class ManagedWrapper : IDisposable
{
    // IntPtr은 관리되지 않는 리소스를 나타냅니다.
    private readonly IntPtr _resource;

    public ManagedWrapper()
    {
        _resource = Allocate(); // 리소스를 할당하는 PInvoke 호출
    }

    public void Dispose()
    {
        Free(_resource); // 리소스를 해제하는 PInvoke 호출
        GC.SuppressFinalize(this); // 종료자 호출 방지
    }

    // 종료자: Dispose가 호출되지 않은 경우 리소스 해제
    ~ManagedWrapper() => Free(_resource);
}

이 경우, IntPtr _resource는 관리되지 않는 리소스이고, ManagedWrapper의 인스턴스는 관리되는 리소스입니다.

결론

  • 종료자에서 처리되지 않은 예외는 애플리케이션 충돌을 일으킵니다.
  • Nullable 필드는 생성자가 예외를 던질 경우 종료자에서 null일 수 있습니다.
  • 종료자는 관리되는 리소스를 건드려서는 안 됩니다. 오직 관리되지 않는 리소스를 정리하기 위해 설계되었습니다.
  • 관리되지 않는 리소스가 없는 클래스는 종료자를 가져서는 안 됩니다.
  • 종료 순서는 보장되지 않습니다.