[Effective C#] Ch2. .NET 리소스 관리

2022. 4. 11. 16:05카테고리 없음

.NET 프로그램은 관리 환경에서 수행되기 때문에 C# 프로그램의 설계에 적지 않은 영향을 미친다. 관리 환경의 장점을 온전히 활용하려면 다른 수행 환경에서 익숙해진 생각의 틀을 .NET 공용 언어 런타임(CLR)에 맞게 근본적으로 변경해야 한다.

객체의 생명주기를 이해하려면 .NET의 가비지 컬렉션의 동작 방식을 잘 알아야하고, 비관리 리소스를 어떻게 다룰지에 대해서도 정확히 이해해야 한다.

 

✅ .NET 리소스 관리에 대한 이해

훌륭한 .NET 개발자가 되기 위해서는 무엇보다 관리 환경에서 메모리와 주요 리소스들이 어떻게 관리되는지를 올바르게 이해해야 한다.

 

GC는 관리되는 메모리를 관장하며 네이티브 환경과는 다르게 메모리 누수, 댕글링 포인터, 초기화되지 않는 포인터, 여타의 메모리 관리 문제를 개발자들이 직접 다루지 않도록 자동화해준다. 하지만 비관리 리소스(데이터베이스 연결, GDI+ 객체, COM 객체, 시스템 객체 등)는 여전히 개발자가 관리해야 한다.

여기에 더해 이벤트 핸들러나 델리게이트 등도 잘못 사용하며 이들이 참조하고 있는 객체들이 불필요하게 오랫동안 메모리에 남게 된다.

결과를 반환하는 쿼리 등도 자칫 잘못 사용하면 예상보다 더 오랫동안 메모리 메모리를 점유하곤 한다.

 

가비지 수집기는 응용 프로그램 내의 최상위 객체로부터 참조 트리를 구성하여 도달 가능한 객체를 살아 있는 객체로 판단하고 도달 불가능(Unreachable)한 객체를 가비지로 간주한다.

가비지 수집기가 수행되면 관리 힙에 대하여 콤팩트(Compact) 작업을 수행한다.

 

👉 콤팩트 작업: 사용중인(혹은 도달 가능한) 객체들을 한 쪽으로 차곡차곡 옮겨서 조각난 가용 메모리를 단일의 큰 메모리 공간으로 만드는 과정을 말한다.

 

하지만 그 외의 비관리 리소스는 여전히 개발자가 관리해야 한다.

비관리 리소스의 생명주기를 개발자가 쉽게 관리할 수 있도록 finalizer와 IDisposable 인터페이스라는 두 가지 매커니즘을 제공한다.

finalizer는 비관리 리소스에 대한 해제 작업이 반드시 수행될 수 있도록 도와주는 방어적 매커니즘이다.

하지만 단점이 많다. 따라서 IDisposable 인터페이스를 통해서 적시에 비관리 리소스가 빠르게 해제될 수 있도록 구현하는 것이 좋다.

 

finalizer의 경우에는 상당한 시간이 경과한 다음에야 비로소 가비지 수집기에 의해서 호출된다.

불편한 진실은 정확히 어느 시점에 finalizer가 호출될지를 아무도 알 수 없다.

단지 객체가 가비지가 되면 소멸자를 통해 언젠가는 호출된다는 점은 C++에서는 좋지만, C#에서는 좋지 않은 코드다.

C++에서 대중적으로 사용되는 리소스 해제 구문은 예외가 발생하는 경우에도 올바르게 동작한다.

그러나 C#에서는 제대로 동작하지 않으며 설사 동작한다 하더라도 동일한 방식으로 동작하지는 않는다.

 

finalizer를 가지고 있는 객체는 메모리를 더 오래 점유하고 finalizer를 수행하기 위해 추가적인 스레드가 필요하다.

.NET의 가비지 수집기는 가비지 수집 과정을 최적화하기 위해서 세대(generation)라는 개념을 사용한다.

이를 통해 가비지가 될 가능성이 높은 객체를 더 빠르게 찾아낼 수 있게 된다.

finalizer를 가진 객체는 즉각 제거되지 못하므로 1세대 객체가 되고, 이 경우 9번의 가비지 수집 절차가 추가적으로 수행된 이후에나 비로소 메모리에서 제거될 가능성이 있다.

 

그렇다면 리소스를 해제하는 가장 좋은 방법은 finalizer를 활용하는 것이 아니라 IDisposable 인터페이스와 표준 Dispose 패턴을 활용하는 것이다.

✅ 할당 구문보다 멤버 초기화 구문이 좋다

종종 둘 이상의 생성자를 작성하다 보면 모든 생성자 내에서 멤버 변수들을 초기화해야 함에도 불구하고 자칫 초기화 코드를 누락하는 경우가 있다. 이러한 오류를 범하지 않으려면 생성자의 본문에서 멤버 변수에 값을 할당하기보다 멤버 초기화 구문을 사용하는 것이 좋다.

 

다음 경우는 멤버 초기화 구문을 사용하지 않는 것이 좋다.

1) 객체를 0이나 null로 초기화하는 경우

2) 생성자마다 초기화방식이 다른 경우

만약 방식이 혼재하는 경우에는 멤버 초기화 구문을 사용하지 않는 것이 좋다.

public class MyClass2
{
	private List<string> labels = new List<string>();
    
    MyClass()
    {
    }
    
    MyClass(int size)
    {
    	labels = new List<string>(size);
    }
}

이렇게되면 실제로 2개의 객체가 생성되고, 그 중 하나는 즉각 가비지가 된다.

 

3) 예외 처리가 반드시 필요한 경우

멤버 초기화 구문은 try로 감쌀 수 없기 때문에 초기화 과정에서 예외가 발생하면 예외가 외부로 전파된다.

 

✅ 정적 클래스 멤버를 올바르게 초기화하라

정적 멤버 변수를 포함하는 타입이 있다면 인스턴스를 생성하기 전에 반드시 정적 멤버 변수를 초기화해야 한다.

방식

1) 정적 멤버 초기화 구문

2) 정적 생성자

- 타입 내에 정의된 모든 메서드, 변수, 속성에 최초로 접근하기 전에 자동으로 호출되는 특이한 메서드

 

class SimpleClass
{
    // Static variable that must be initialized at run time.
    static readonly long baseline;

    // Static constructor is called at most one time, before any
    // instance constructor is invoked or member is accessed.
    static SimpleClass()
    {
        baseline = DateTime.Now.Ticks;
    }
}

 

정적 필드를 초기화하는 과정이 매우 복잡하거나 혹은 상당한 자원을 소비하는 경우라면

Lazy<T>를 사용해 해당 필드에 최초로 접근하는 시점까지 초기화 작업을 미룰 수 있다.

 

인스턴스 멤버 초기화와 마찬가지로 정적 멤버를 간단히 초기화하는 경우라면, 정적 생성자를 사용하기 보다는 멤버 초기화 구문을 사용하는 것이 좋다.

public class MySingleton
{
  private static readonly MySingleton theOneAndOnly = new MySingleton();

  public static MySingleton TheOnly
  {
    get
    {
      return the theOneAndOnly;
    }
  }
  
  private MySingleton()
  {

  }
}

 

예외가 발생할 가능성이 있는 경우에는, 멤버 초기화 구문 대신 반드시 정적 생성자를 사용해야 한다

✅ 초기화 코드가 중복되는 것을 최소화하라

일반적으로 여러 개의 생성자를 오버로딩하기보다는 기본값을 가지는 매개변수를 사용하여 생성자를 작성하는 것이 좋다.

 

추가) 특정 타입으로 첫번째 인스턴스를 생성할 때 수행되는 과정

1. 정적 변수의 저장 공간을 0으로 초기화

2. 정적 변수에 대한 초기화 구문 수행

3. 베이스 클래스의 정적 생성자 수행

4. 정적 생성자 수행

5. 인스턴스 변수의 저장 공간을 0으로 초기화

6. 인스턴스 변수에 대한 초기화 구문 수행

7. 적절한 베이스 클래스의 인스턴스 생성자 수행

8. 인스턴스 생성자 수행

 

C#은 객체가 생성될 때 어떤 식으로든 모든 객체가 초기화될 것임을 보장한다.

생성자를 작성할 때 유념할 부분은 멤버들을 원하는 값으로 초기화할 때 가능한 한 한 번만 초기화가 이뤄지도록 해야 한다. 이를 위해서 단순한 리소스의 경우 멤버 초기화 구문을 이용하고 복잡한 초기화 과정이 필요한 경우에만 생성자를 사용하는 것도 좋은 방법이다.

 

 

✅ 불필요한 객체를 만들지 말라

가비지 수집기는 사용자를 대신하여 메모리를 훌륭히 관리하며 사용하지 않은 객체를 효율적인 방식으로 제거한다.

하지만 이러한 작업이 아무리 효율적이라 하더라도 힙에서 새로운 객체를 생성하고 삭제하는 작업은 그러한 일을 전혀 하지 않는 것에 비해 상대적으로 많은 프로세서 시간을 사용하는 것이 사실이며, 너무 많은 객체를 생성하면 심각한 성능 저하를 유발한다.

 

모든 참조 타입의 객체는 그것이 설사 지역 변수라 하더라도 동적으로 메모리를 할당한다.이렇게 할당된 객체는 이 객체를 참조하는 상위 객체가 삭제되면 가비지가 된다.지역 변수의 경우 그 변수를 선언한 메서드를 벗어나는 순간 가비지가 되어 더 이상 살아 있는 객체로 간주되지 않는다.

 

가장 흔히 저지르는 나쁜 예)윈도우의 Paint 이벤트 핸들러 내에서 GDI 객체를 할당하는 경우OnPaint()는 매우 자주 호출되는 이벤트 핸들러 중 하나인데 이 예제에서는 이 이벤트 핸들러가 호출될 때마다 동일한 Font 객체를 매번 다시 생성한다. 가비지 수집기는 이렇게 생성된 객체를 제거할 책임이 있다. 가비지 수집 작업을 수행할지는 사용 중인 메모리 양과 메모리의 할당 주기를 기반으로 결정된다.따라서 메모리 할당을 자주 반복하면 사용되는 메모리의 양이 많아져서 가비지 수집 작업이 수행될 가능성이 높아질 뿐 아니라 할당 주기가 짧기 때문에 가비지 수집 작업을 더 자주 수행할 가능성이 있다.이 경우는 Font 객체를 지역 변수로 선언한 것이 아니라 멤버 변수로 변경하여 폰트 객체를 한 번만 생성한 후 이를 재사용하도록 개선할 수 있다. Paint 이벤트가 발생할 때마다 새로운 가비지가 생성되지 않으므로 가비지 수집기가 해야하는 일의 양도 줄게 된다.

그런데 Font 타입과 같이 IDisposable 인터페이스를 구현한 타입의 객체를 지역 변수에서 멤버 변수로 변경하면  이 클래스도 반드시 IDisposable을 구현해야 한다.

 

하지만 경우에 따라서는 생성된 객체가 메모리상에 필요 이상으로 오랫동안 남아 있을 수 있다.또한 Dispose() 메서드를 호출해야할 시점을 결정해야 할 시점을 결정할 수 없기 때문에 비관리 리소스를 삭제할 수 없다는 것도 매우 큰 단점이다.

 

응용 프로그램의 성능에 영향을 주지 않기 위해서 객체 생성을 최소화하기 위한 두 가지 기법✨자주 사용되는 지역변수를 멤버 변수로 변경종속성 삽입자주 사용되는 객체를 생성했다가 이를 재활용변경불가능한 타입 활용ex) System.Stringstring 클래스 내의 += 연산자는 기존 문자열에 새로운 문자열을 더하는 것이 아니라 완전히 새로운 string 객체를 생성하여 반환한다.1) 문자열 보간2) StringBuilder이 클래스는 실제로 수정 가능한 문자열을 나타내기 위한 타입으로, 새로운 문자열을 생성하거나 수정, 변경 등의 작업을 수행할 수 있으며, 최종적으로 변경 불가능한 string 타입의 객체를 가져오는 기능

 

 

✅ 생성자 내에서는 절대로 가상 함수를 호출하지 말라

객체가 완전히 생성되기 이전에 가상 함수를 호출하면 이상 동작을 일으킨다.

 

베이스 클래스의 생성자 내에서 가상 함수를 호출하면 파생 클래스가 가상 함수를 어떻게 구현했는지에 따라 매우 민감하게 동작한다.

class B
{
  protected B()
  {
    VFunc();
  }

  protected virtual void VFunc()
  {
    Console.WriteLine("VFunc in B");
  }
}


class Derived : B
{
  private readonly string msg = "Set by initializer";

  public Derived(string msg)
  {
    this.msg = msg;
  }

  protected override void VFunc()
  {
    Console.WriteLine(msg);
  }

  public static void Main()
  {
    var d = new Derived("Constructed in main");
  }
}

베이스 클래스의 생성자를 살펴보면 자기 클래스 내에 정의 된 가상 함수를 호출하고 있다.하지만 파생 클래스가 가상 함수를 이미 재정의하고 있기 때문에 런타임에는 파생 클래스에서 재정의한 함수가 호출된다.왜냐하면 런타임에 객체의 타입이 Derived이기 때문이다.C#의 정의에 따르면 생성자의 본문으로 진입하는 순간 해당 객체는 이미 초기화가 완료된 것으로 간주한다.모든 멤버 변수를 초기화 구문을 이용하고 초기화할 수 있는지는 모르겠지만, 대부분의 경우 모든 멤버 변수가 이 시점에 유효한 값을 갖도록 초기화되었다고 단정하기는 어렵다.단지 멤버 변수에 대한 초기화 구문을 완료했을 뿐이며 실상 파생 클래스의 생성자 본문은 아직 수행조차 되지 않았기 때문이다.

 

 

 

✅ 표준 Dispose 패턴을 구현하라

표준 Dispose 패턴은 가비지 수집기와 연계되어 동작하며 불가피한 경우에만 finalizer를 호출하도록 하여 성능에 미치는 부정적인 영향을 최소화한다.

이 패턴은 비관리 리소스를 다루기 위한 가장 효과적인 방법이다.

 

상속 계통상 최상위의 베이스 클래스는 다음과 같은 작업을 수행해야 한다.

- 리소스를 정리하기 위해서 IDisposable 인터페이스를 구현해야 한다.

- 멤버 필드로 비관리 리소스를 포함하는 경우에 한해 방어적으로 동작할 수 있도록 finalizer를 추가해야 한다.

- Dispose와 finalizer(존재하는 경우)는 실제로 리소스 정리 작업을 수행하는 다른 가상 메서드에 작업을 위임하도록 작성돼야 한다.

파생 클래스가 고유의 리소스 정리 작업이 필요한 경우 이 가상 메서드를 재정의할 수 있도록 하기 위함이다.

 

파생 클래스

- 파생 클래스가 고유의 리소스 정리 작업을 수행해야 한다면 베이스 클래스에서 정의한 가상 메서드를 재정의한다.

- 멤버 필드로 비관리 리소스를 포함하는 경우에만 finalizer를 추가해야 한다.

- 베이스 클래스에서 정의하고 있는 가상 함수를 반드시 재호출해야 한다.

 

비관리 리소스를 포함하는 클래스는 반드시 finalizer를 구현해야 한다.

사용자가 Dispose() 메서드를 항상 올바르게 호출할 것이라고 가정할 수는 없다.

finalizer도 없고 Dispose()를 호출하는 것조차 잊어버리면 리소스가 누수된다.

비관리 리소스가 누수되지 않고 올바르게 정리될 것임을 보장하는 유일한 방법은 finalizer를 구현하는 것이다.

 

대신 finalier가 없는 가비지 객체는 즉각 메모리에서 제거되지만, finalizer를 가진 객체는 여전히 메모리에 남게 된다.

가비지 수집기는 finalizer 큐라는 곳에 객체들의 참조를 삽입해두고, finalizer 스레드라는 특별한 스레드를 이용하여 이 큐에 포함된 객체들의 finalizer를 순차적으로 호출한다.

 

finalizer를 호출한 객체들에 대해서는 더 이상 finalizer를 호출할 필요가 없음을 나타내는 플래그를 설정하고이제 메모리로부터 제거될 수 있는 대상으로 간주한다.불행한 것은 앞서 finalizer 큐에 삽입된 객체들은 이전에 수행된 가비지 수집 작업을 통해서 정리되지 못한 객체이므로 자연스럽게 한 세대가 높아진다. 이 때문에 다른 객체에 비해서 상대적으로 메모리에 오래 살아남게 된다.

 

IDisposable.Dispose() 작업 순서

1. 모든 비관리 리소스를 정리한다.

2. 모든 관리 리소스를 정리한다.

3. 객체가 이미 정리되었음을 나타내기 위한 상태 플래그 설정

앞서 이미 정리된 객체에 대하여 추가로 정리 작업이 요청될 경우 이 플래그를 확인하여 ObjectDisposed 예외를 발생시킨다.

4. finalizer 호출 회피. 이를 위해 GC.SuppressFinalize(this)를 호출한다.

 

 

그래서 IDisposable만 쓰면 된다는 거야?

 

아니다. 아직이다..

파생 클래스가 자신이 포함하고 있는 리소스를 정리하는 것은 그렇다 치더라도

베이스 클래스가 포함하고 있는 리소스는 어떻게 정리해야 할까?

 

1) 파생 클래스가 finalizer나 자신만의 IDisposable을 구현할 때 반드시 베이스 클래스에서 구현한 함수를 호출하도록 해야 베이스 클래스도 올바르게 리소스 정리

2) finalize와 Dispose()메서드는 중복될 가능성 제거하기 위해 추가적인 작업

protected로 선언된 가상 헬퍼 함수(virtual helper function)

→ 리소스 정리를 위한 공통 작업을 수행하고 파생 클래스에게 리소스를 정리할 기회 제공

protected virtual void Dispose(bool isDisposing);

이 가상 함수를 구현해두면 finalizer와 Dispose 양쪽에서 사용할 수 있다.

관리 리소스와 비관리 리소스를 모두 제거하려면 isDisposing으로 true를 전달하고,

비관리 리소스만 정리하려면 false를 전달해야 한다.

 

표준 Dispose 패턴 규칙

- 이미 정리된 객체에 대하여 멤버 메서드를 호출한 경우 ObjectDisposedException 예외를 발생

- 반드시 비관리 리소스를 포함하는 경우에만 finalizer 구현

- Dispose 메서드 내에서는 리소스 정리 작업만을 수행