본문 바로가기

C#

C# 생성자와 소멸자

생성자와 소멸자는 객체의 시작과 끝을 책임지는 핵심 요소입니다. 생성자는 초기화 로직을, 소멸자(파이널라이저)는 비관리 자원 정리를 다룹니다. 실무에서는 Dispose 패턴과 함께 올바르게 사용하는 것이 중요합니다.

1. 기본/매개변수 생성자

클래스에 생성자를 하나도 정의하지 않으면 컴파일러가 매개변수 없는 기본 생성자를 제공합니다. 하나라도 정의하면 기본 생성자는 자동으로 생성되지 않습니다.

public class Person
{
    public string Name { get; }
    public int Age { get; }

    // 기본 생성자
    public Person()
    {
        Name = "Unknown";
        Age = 0;
    }

    // 매개변수 생성자
    public Person(string name, int age)
    {
        Name = name;
        Age = age;
    }
}

var p1 = new Person();
var p2 = new Person("Alice", 30);

2. 생성자 체이닝과 base 호출

중복 초기화를 줄이려면 this(...)로 체이닝합니다. 상속에서는 base(...)로 기본 클래스 생성자를 명시적으로 호출합니다.

public class ConnectionOptions
{
    public string Host { get; }
    public int Port { get; }

    public ConnectionOptions() : this("localhost", 5432) { }

    public ConnectionOptions(string host, int port)
    {
        Host = host;
        Port = port;
    }
}

public class BaseService
{
    public BaseService(int retryCount) { /* ... */ }
}

public class OrderService : BaseService
{
    public OrderService() : base(3)
    {
        // BaseService 생성자 실행 후 이 본문이 실행됩니다.
    }
}

3. 정적 생성자

정적 생성자는 타입이 처음 사용되기 전에 한 번만 실행됩니다. 매개변수/접근 제한자를 가질 수 없고, 명시적으로 호출할 수 없습니다.

public class AppConfig
{
    public static string RootPath { get; }

    static AppConfig()
    {
        // 한 번만 실행
        RootPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "MyApp");
    }
}

// 최초 접근 시 정적 생성자가 실행됩니다.
Console.WriteLine(AppConfig.RootPath);

4. 불변 설계와 readonly 초기화

생성자에서만 설정되는 불변 상태를 만들 때 readonly 필드를 사용합니다. 복잡한 상태는 생성자에서 유효성 검사를 통해 일관성을 보장합니다.

public class Money
{
    public readonly decimal Amount;
    public readonly string Currency;

    public Money(decimal amount, string currency)
    {
        if (amount < 0) throw new ArgumentOutOfRangeException(nameof(amount));
        if (string.IsNullOrWhiteSpace(currency)) throw new ArgumentException("currency");
        Amount = amount;
        Currency = currency.ToUpperInvariant();
    }
}

5. 소멸자(파이널라이저)의 사실

C#의 소멸자 ~ClassName()는 CLR의 파이널라이저입니다. 실행 시점이 비결정적이며, GC에 의해 별도의 파이널라이저 스레드에서 호출됩니다. 일반적으로 비관리 자원 해제가 필요할 때만 구현합니다. 관리 객체에 의존하는 로직은 피해야 합니다.

public class NativeHolder
{
    // 예시용 비관리 핸들
    private IntPtr _nativeHandle;

    public NativeHolder()
    {
        _nativeHandle = AcquireNative();
    }

    ~NativeHolder()
    {
        // 최후의 보루: 비관리 자원만 해제
        if (_nativeHandle != IntPtr.Zero)
        {
            ReleaseNative(_nativeHandle);
            _nativeHandle = IntPtr.Zero;
        }
    }

    [DllImport("native.dll")] private static extern IntPtr AcquireNative();
    [DllImport("native.dll")] private static extern void ReleaseNative(IntPtr h);
}

- 소멸자는 오버로드할 수 없고, 직접 호출할 수 없습니다.

- 성능에 영향을 줄 수 있으므로 꼭 필요한 경우에만 사용합니다.

6. IDisposable과 Dispose 패턴 권장

대부분의 경우 소멸자 대신 IDisposable을 구현하고 using을 사용합니다. 비관리 자원이 필요하면 SafeHandle을 쓰고, 소멸자는 마지막 안전장치로 둡니다.

public sealed class FileWriter : IDisposable
{
    private bool _disposed;
    private readonly FileStream _stream; // 관리 자원 (내부적으로 비관리를 가짐)

    public FileWriter(string path)
    {
        _stream = new FileStream(path, FileMode.Create, FileAccess.Write, FileShare.None);
    }

    public void WriteLine(string text)
    {
        ThrowIfDisposed();
        var bytes = Encoding.UTF8.GetBytes(text + Environment.NewLine);
        _stream.Write(bytes, 0, bytes.Length);
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this); // 파이널라이저가 있다면 큐에서 제거
    }

    protected void Dispose(bool disposing)
    {
        if (_disposed) return;
        if (disposing)
        {
            // 관리 자원 해제
            _stream.Dispose();
        }
        // 비관리 자원 해제 위치(있다면)
        _disposed = true;
    }

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

// 사용 예: using이 Dispose를 보장합니다.
using (var writer = new FileWriter("out.txt"))
{
    writer.WriteLine("Hello");
}

비관리 자원을 직접 다룰 경우 SafeHandle을 권장합니다. 이때는 별도 소멸자 없이 Dispose에서 SafeHandle.Dispose()만 호출하면 됩니다.

public sealed class NativeWrapper : IDisposable
{
    private bool _disposed;
    private readonly SafeFileHandle _handle; // SafeHandle 파생형 예시

    public NativeWrapper(SafeFileHandle handle)
    {
        _handle = handle ?? throw new ArgumentNullException(nameof(handle));
    }

    public void Dispose()
    {
        if (_disposed) return;
        _handle.Dispose();
        _disposed = true;
        GC.SuppressFinalize(this);
    }
}

7. 베스트 프랙티스 요약

- 생성자에서는 필수 의존성 주입과 유효성 검사만 수행합니다. 무거운 I/O는 지연 초기화로 분리합니다.

- 생성자에서 가상 메서드를 호출하지 않습니다. 파생 클래스 상태가 완전히 초기화되지 않았을 수 있습니다.

- 정적 생성자에서 던진 예외는 타입 초기화를 실패하게 하므로 반드시 처리 또는 안정적으로 작성합니다.

- 소멸자는 최소화하고, IDisposable과 using을 기본으로 사용합니다.

- Dispose에서 GC.SuppressFinalize(this)를 호출해 파이널라이저 오버헤드를 줄입니다.

- 파이널라이저에서는 비관리 자원만 다루고, 다른 관리 객체에 의존하지 않습니다.

생성자와 소멸자를 올바르게 이해하면 객체 수명 주기를 명확하게 관리할 수 있습니다. 특히 자원 관리가 중요한 코드에서는 Dispose 패턴을 표준으로 삼으시길 권장합니다.