C#/Advanced C#

[Effective C#] 2장 .NET 리소스 관리

로파이 2021. 11. 7. 19:05

11. .NET 리소스 관리

.NET 환경에서 관리되는 자원은 메모리 할당과 해제가 항상 관리되며 더 이상 사용되지 않는 리소스는 가비지 컬렉터에 의해 수거된다.

.NET에 의해 관리되지 않는 데이터 베이스 연결, GDI 객체, COM 객체, 시스템 객체등의 비관리 리소스들도 있다.

 

관리되는 리소스들은 모든 리소스들의 참조 관계를 형성하는 참조 트리의 최상위 객체로 부터 도달 가능 여부를 판단하여 가비지 여부를판단한다.

 

힙에 생성되는 메모리들을 관리하며 가비지 컬렉터는 각 메모리 자원를 분류하여 0세대, 1세대, 2세대 등으로 나누어 오래 살아남을 자원과 빨리 소멸될 자원을 분리하여 관리한다.

 

0세대 자원들은 생성한지 얼마안 된 자원들이며 이들이 한 번 가비지인지 검사할 때마다 살아남게되면 1세대, 2세대 메모리 영역으로 승격한다. 매 가비지 컬렉터가 수집 시기가 되면 0세대의 메모리 자원들을 검사하고 0세대를 10번 검사할 때 1세대를 1번 검사하는 식으로 세대가 높을 수록 오래 살아남을 확률이 높으므로 수거되는 시간이 지연된다.

 

가비지 컬렉터에 의해 수거되어 각 세대에서 해제된 메모리가 듬성 듬성 존재한다면 살아 있는 메모리를 모아서 연속된 메모리 조각으로 정리하며, 이에 따라 모든 참조를 따라가 주소를 바꾸는 작업이 요구된다.

https://flylib.com/books/en/2.714.1.32/1/

비관리 자원들은 사용자가 직접 해제를 해야하므로 finalizer(C++의 소멸자)를 정의해서 해당 클래스의 인스턴스가 소유하고 있떤 비관리 자원을 명시적으로 해제해야한다.

 

finalizer를 가지고 있는 객체는 일반 객체의 메모리 해제 방식과 다르게 동작된다. finalizer 인스턴스는 수집되는것으로 판명되면, C#에서 관리하는 finalizer 큐에 삽입된다. 해당 메모리는 더 이상 참조되지는 않지만, 바로 해제되지 않고 finalizer 큐에 살아남으므로 한 세대를 승격한 자원과 비슷하게 실제 해제 시간이 매우 지연된다. 

 

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

다양한 생성자를 만들다 보면 특정 필드를 초기화하는 것을 깜빡할 수 있다. 이를 방지하기 위해 멤버 초기화 구문을 사용하여 모든 생성자에서 기본적으로 가장 먼저 초기화를 진행하는 멤버 초기화 구문을 사용하도록 한다.

public class MyClass
{
	// 컬렉션을 선언하는 동시에 초기화
	private List<string> labels = new List<string>();
}

 

정적 클래스의 초기화를 제외한 일반 인스턴스의 초기화 과정은 다음과 같다.

1. 모든 필드(저장 공간)을 0으로 초기화한다.

2. 멤버 초기화 구문을 실행한다.

3. 베이스 클래스의 생성자를 수행한다.

4. 해당 클래스의 생성자를 수행한다.

 

따라서 해당 클래스 및 베이스 클래스의 생성자 수행보다도 먼저 일어나므로 다양한 인자를 가진 생성자의 공통적인 초기화 구문을 멤버 초기화 구문으로 실현할 수 있다.

 

멤버 초기화구문을 사용하지 않는 경우는 다음과 같다.

1) 해당 멤버를 0 또는 null로 초기화한다.

2) 생성자에서 해당 멤버를 다시 초기화한다.

1) 의 경우 가장 먼저 0으로 초기화하기 때문에 번복된 작업이며 2)의 경우 초기화 구문을 사용하게 되면 두번의 초기화를 하는 것과 같다. 따라서 먼저 초기화에 의해 생성된 객체는 가비지가 된다.

 

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

정적 생성자는 타입내 정의된 모든 메서드, 변수, 속성에 최초로 접근하기 전에 자동으로 호출되는 특별한 메서드이다. 싱글턴 패턴을 구현하는 경우에 적절히 활용할 수 있다.

    public class Singleton
    {
        private static readonly Singleton Inst;

        static Singleton()
        {
            Inst = new Singleton();
        }

        static public Singleton GetInst() { return Inst; }
    }

 

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

정적 생성자를포함하는 C#의 초기화 단계는 다음과 같다.

 

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

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

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

4. 정적 생성자수행

5. 모든 필드(저장 공간)을 0으로 초기화한다.

6. 멤버 초기화 구문을 실행한다.

7. 베이스 클래스의 생성자를 수행한다.

8. 해당 클래스의 생성자를 수행한다.

 

5~8의 경우 일반 인스턴스의 초기화 과정이며 1~4의 정적 생성자 및 변수의 초기화 과정이 포함되어 있다.

 

위 과정을 고려하여 불필요하게 멤버가 두 번이상 초기화되는 것을 가급적 피하며 여러 생성자를 사용하는 경우 공통적인 생성자를 기준으로 this를 이용하여 생성자 코드를 만들어주는 것이 좋다.

    public class MyClass
    {
        private List<ImportantData> coll;

        private string Name;

        public MyClass() : this(0, string.Empty)
        {

        }

        public MyClass (int initialCount = 0, string name = "")
        {
            coll = (initialCount > 0) ?
                new List<ImportantData>(initialCount) : new List<ImportantData>();

            this.Name = name;
        }
    }

 

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

자주 호출되는 메서드에서 참조 타입을 매번 생성하는 것을 피하고 되도록이면 멤버 변수로 변경해야한다.

protected override void OnPaint(PaintEventArgs args)
{
  using (Font myFont = new Font("Arial", 10.f))
  {
  	args.Graphics.DrawString(DateTime.Now.ToString(), myFont, Brushes.Black, new PointF(0, 0));
  }

  base.OnPaint(args);
}

OnPaint가 자주 호출되는 함수라면 해당 함수 내에서 매번 폰트 객체를 생성하는 것은 성능상 매우 좋지 않다. 해당 myFont 객체는 함수 호출 종료와 동시에 참조하고 있는 메모리가 가비지가 되고 호출될 때마다 가비지를 생성할 것이다. 따라서 자주 사용되는 자원은 멤버 변수로 미리 생성하여 사용하는 것이 좋다.

 

Immutable 타입의 객체

System.String 객체는 문자열을 추가하거나 삭제할 때 내부 데이터를 자유롭게 수정하는 것처럼 보이지만 실제로는 새로운 String 객체를 만들고 수정된 문자열을 할당하는 것일 뿐이다. 변경 불가능 타입 Immutable 객체를 다룰 때 매번 새로운 객체가 생성되어 가바지를 만드는 것에 주의한다. 

   static public void Modify()
    {
        string msg = "Hello";
        msg += " My Name is";
        msg += " David";

        /* 다음 코드와 상동한다.
        
        string msg = "Hello";

        string tmp1 = new string(msg + " My Name is");
        msg = tmp1; // tmp1는 가비지

        string tmp2 = new string(msg + " David");
        msg = tmp2; // tmp2는 가비지
         */
    }

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

C++과 다르게 베이스 생성자에서 오버라이딩된 가상 함수를 호출하면 크래시가 나는 것은 아니나 예상되는 동작처럼 행동하지 않는다.

 

17. 표준 Dispose 패턴을 구현하라

비관리 자원을 다룰 때 어떻게 해야 안전하고 명확한 패턴으로 해제를 할 수 있는지 제시한다.

1) Dispose 패턴은 리소스 관리를 위해 IDisposable 인터페이스를 구현한다. 

2) 비관리 리소스를 멤버로 두는 경우 방어적으로 동작할 수 있게끔 finalizer 종료자를 추가한다.

3) Dispose()와 fianalizer는 실제 리소스 정리 작업을 각 해당 클래스에서 할 수 있게끔 가상 메서드로 정의한다.

 

파생 클래스는 다음과 같은 일을 해야한다.

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

2) 멤버 필드로 비관리 리소스를 포함한다면 finalizer를 추가해야한다.

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

 

finalizer를 사용할 경우 finalizer 큐에 들어가 실제 자원이 해제되는 시간이 매우 지연된다. 이를 피하기위해 Dispose 패턴을 구현하여 모든 관리/비관리 리소스를 해제 하도록 하고 finalizer 호출을 회피하도록 한다.

 

Dispose()는 해당 오브젝트가 이미 정리되었는지 확인하는 플래그를 두고 이미 정리되었다면 ObjectDisposed 예외를 던질 수 있다. 또한 finalizer를 회피하기 위해 GC.SuppressFinalize(this)를 호출한다.

 

Dispose() 패턴을 잘 구현하기 위해 가상 함수 Dispose(bool isDisposing)을 이용하여 구현할 수 있다.

 public class MyResourceHog : IDisposable
    {
        private bool alreadyDisposed = false;
        public void Dispose()
        {
            Dispose(true);

            GC.SuppressFinalize(this);
        }

        protected virtual void Dispose(bool isDisposing)
        {
            // 이미 처리되었다.
            if (alreadyDisposed)
                return;


            if(isDisposing)
            {
                // 관리 리소스를 정리
            }

            // 비관리 리소스를 정리

            alreadyDisposed = true;
        }
    }

    public class DerivedResourceHog : MyResourceHog
    {
        // 자신만의 disposed 플래그
        private bool disposed = false;

        protected override void Dispose(bool isDisposing)
        {
            if (disposed)
                return;

            if(isDisposing)
            {
                // 관리 리소스 정리
            }

            // 비관리 리소스 정리

            // 베이스 클래스의 리소스를 정리한다.
            base.Dispose(isDisposing);

            disposed = true;

        }
    }

void Dispose(bool isDisposing)을 이용하여 각 클래스에서는 자신이 관리하는 비관리/관리 리소스를 정리한다. 만약 관리되는 리소스를 제외하고 싶다면 Dispose(false)를 Dispose() 내부에서 호출하면 된다. 파생 클래스에서는 void Dispose(bool)에서 항상 base.Dispose(bool)을 호출하여 베이스 클래스의 자원 해제를 포함시켜야한다.

Dispose 패턴의 함수에서 GC.SuppressFinalize(this)를 포함하여 finalizer를 호출하는 것을 회피하도록 한다.

 

finalizer 에서는 반드시 비관리 리소스만 해제하도록 하며 다른 어떤 작업을 하지 않는다. 이미 finalizer를 호출하고 있는 객체는 도달불가능한 객체인데 이 객체를 참조하는 것을 만들거나 새로운 참조를 만들지 않는다.

 

출처 : Effective C# 빌 와그너 저 - 한빛 미디어