Effective C# - .NET 리소스 관리
Managed Language인 C#의 리소스 관리를 효과적으로 사용하기
2023.07.31
Effective C# 3판 스터디, 그 두 번째 포스트
📚 목차
- 🔸 ITEM 11: .NET 리소스 관리에 대한 이해
- 🔸 ITEM 12: 할당 구문보다 맴버 초기화 구문이 좋다.
- 🔸 ITEM 13: 정적 클래스 멤버를 올바르게 초기화하라.
- 🔸 ITEM 14: 초기화 코드가 중복되는 것을 최소화하라.
- 🔸 ITEM 15: 불필요한 객체를 만들지 말라.
- 🔸 ITEM 16: 생성자 내에서는 절대로 가상 함수를 호출하지 말라.
- 🔸 ITEM 17: 표준 Dispose 패턴을 구현하라.
🔸 ITEM 11: .NET 리소스 관리에 대한 이해
.NET 개발에 있어서 메모리와 주요 리소스들이 어떻게 관리되는지 알아야 한다.
가비지 컬렉터가 전반적인 메모리 관리를 해준다.
가비지 컬렉터는 응용프로그램의 최상위 객체로부터 개별 객체까지의 도달 가능 여부를 확인하도록 설계되어 있다.
개발자는 개별 객체 해제나 참조 관계로 인해 발생하는 복잡한 문제를 고민할 필요가 없다.
가비지 컬렉터가 수행되면 관리 힙의 콤팩트 작업을 수행한다.
(사용 중이거나 도달 가능한 객체들을 한곳으로 옮겨서 조각난 가용 메모리를 큰 공간으로 만든다)
🔹 비관리 리소스
OS 자원 등 비관리 리소스는 개발자가 직접 관리해야 한다.
.NET 프레임워크는 개발자가 비관리 리소스를 손쉽게 관리할 수 있도록 finalizer
와 IDisposable
인터페이스를 제공한다.
finalizer
를 사용할 경우 여러 단점들이 발생해서 IDisposable
인터페이스를 사용하는 것이 좋다.
🔹 finalizer의 단점
class Car
{
~Car() // finalizer
{
// cleanup statements...
}
}
다음 예제와 같이 종료자를 식 본문 정의로 구현할 수도 있다.
public class Destroyer
{
public override string ToString() => GetType().Name;
~Destroyer() => Console.WriteLine($"The {ToString()} finalizer is executing.");
}
그럼 finalizer의 단점은 무엇인가?
-
finalizer
가 어느 시점에서 호출될 지 알 수 없다. -
finalizer
를 가진 객체는 가비지 컬렉터가 수행될 때 바로 해제되지 않는다.
🔸 ITEM 12: 할당 구문보다 맴버 초기화 구문이 좋다.
하나의 클래스에 여러 개의 생성자를 작성해야 할 때,
각각의 생성자에서 멤버 변수를 초기화 하는 것보다 멤버 초기화 구문을 사용하는 것이 좋다.
public class MyClass
{
// 컬렉션을 선언과 동시에 초기화
private List<string> labels = new List<string>();
}
이렇게 하면 각각의 생성자에서 labels를 초기화할 필요가 없어진다.
따라서 모든 생성자가 동일한 방법으로 멤버 변수를 초기화하는 경우에는
멤버 초기화 구문을 사용하는 것이 좋다. (코드 가독성 향상, 유지보수 용이)
🔹 멤버 초기화 구문의 특징
-
멤버 초기화 구문은 각 생성자의 앞쪽에 자동으로 추가된다.
-
베이스 클래스의 생성자가 호출되기 전에 초기화된다.
-
멤버 변수의 초기화 순서는 변수 선언 순서이다.
🔹 멤버 초기화 구문을 사용하지 않아도 되거나 사용할 수 없는 경우
-
0이나 null로 초기화하는 경우 (기본 초기화 루틴과 같기 때문)
-
예외 처리가 반드시 필요한 경우 (멤버 초기화 구문은 try로 감쌀 수 없다)
-
동일한 객체를 반복해서 초기화하는 경우 (객체가 두번 생성되어 가비지가 발생한다)
🔸 ITEM 13: 정적 클래스 멤버를 올바르게 초기화하라.
인스턴스 멤버 초기화와 마찬가지로 정적 멤버를 간단히 초기화하는 경우에는
멤버 초기화 구문을 사용하는 것이 좋다.
🔹 정적 멤버 변수를 초기화 하는 두 가지 방법
-
정적 멤버 초기화 구문
-
정적 생성자
static readonly
로 선언된 변수는 위 두 가지 방법으로만 할당될 수 있다.
🔹 정적 생성자
// 정적 생성자
static MySingleton2()
{
theOneAndOnly = new MySingleton2();
}
정적 생성자는 다음과 같은 특징을 가지고 있다.
- 타입 내에 정의된 모든 메서드, 변수, 속성에 최초로 접근하기 전에 자동으로 호출되는 메서드.
- 초기화 과정이 복잡한 경우에 사용하면 좋다. (예외 처리 등)
- 하나만 가질 수 있다.
- 인자를 넘길 수 없다. (CLR을 통해서만 호출되기 때문)
- 두번 이상 호출되지 않는다. (예외가 발생해도 다시 호출되지 않는다)
🔸 ITEM 14: 초기화 코드가 중복되는 것을 최소화하라.
생성자를 작성할 때에 동일한 코드를 반복적으로 사용해야 한다면
공용 생성자나 기본값을 갖는 매개변수를 취하는 생성자를 작성하면 좋다.
🔹 공용 생성자
public class Item14
{
// 데이터 컬렉션
private List<ImportantData> col1;
// 인스턴스 변수
private string name;
public Item14() : this(0, "")
{
}
public Item14(int initialCount) : this(initialCount, string.Empty)
{
}
public Item14(string name) : this(0, name)
{
}
public Item14(int initialCount, string name)
{
col1 = (initialCount > 0) ?
new List<ImportantData>(initialCount) :
new List<ImportantData>();
this.name = name;
}
}
🔹 기본값 매개변수 생성자
public class Item14_2
{
// 데이터 컬렉션
private List<ImportantData> col1;
// 인스턴스 변수
public string name;
// new() 제약 조건을 만족시키려면 이 생성자가 필요하다.
public Item14_2() :
this(0, string.Empty)
{
}
public Item14_2(int initialCount = 0, string name = "")
{
col1 = (initialCount > 0) ?
new List<ImportantData>(initialCount) :
new List<ImportantData>();
this.name = name;
}
}
매개변수 기본값은 컴파일 타임 상수만을 지정할 수 있다.
🔸 ITEM 15: 불필요한 객체를 만들지 말라.
너무 많은 객체를 생성하고 제거하면 가비지 컬렉터가 많은 프로세스 시간을 사용하게 되고
이는 심각한 성능 문제 야기할 수 있다.
🔹 자주 사용되는 지역변수를 멤버 변수로 변경하라
모든 참조 타입 객체는 동적으로 메모리를 할당한다.
따라서 지역변수로 객체를 선언할 경우에, 해당 객체는 메서드를 벗어나는 순간 가비지가 된다.
자주 사용되는 참조 타입 객체는 멤버 변수로 변경하여 재사용되도록 하는 것이 좋다.
🔹 의존성 주입 (Dependency Injection)을 활용하여 자주 사용되는 객체를 재활용하라
private static Brush blackBrush;
public static Brush Black
{
get
{
if (blackBrush == null)
blackBrush = new SolidBrush(Color.Black);
return blackBrush;
}
}
검정색 브러시(blackBrush)는 여러 곳에서 사용되는 브러시이다.
검정색 브러시를 멤버 변수로 선언한다고 해도 객체를 생성할 때마다 검정색 브러시가 생성된다면 비효율적이다.
위 코드처럼 검정색 브러시를 정적 변수로 선언하면 여러 객체가 하나의 브러시를 재사용할 수 있다.
또한 검정색 브러시를 생성하지 않았다가 최초로 호출되었을 때에 생성하여 불필요한 객체 생성을 줄였다.
하지만, 생성된 객체가 메모리상에 필요 이상으로 오랫동안 남아 있을 수 있으며
Dispose()
메서드의 호출 시점을 결정할 수 없기 때문에 비관리 리소스를 삭제할 수 없다.
🔹 변경 불가능한(immutable) 타입을 작성하는 경우 StringBuilder와 같은 기능을 함께 제공하는 것을 고려하라
string과 같이 변경 불가능한 타입은 수정이 불가능하다.
string을 수정할 경우 객체가 변경되는 것이 아니라 새로운 객체가 생성된다. (이전 객체는 가비지가 된다)
string str1 = "Hello, ";
System.Console.WriteLine(str1 + "World!");
🔸 ITEM 16: 생성자 내에서는 절대로 가상 함수를 호출하지 말라.
생성자가 수행을 완료하기 전까지는 객체가 완전히 생성된 것이 아니다.
객체가 완전히 생성되지 않았을 때에 가상 함수를 호출하면 이상 동작을 일으킨다.
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);
}
}
// new Derived("Constructed in main");
// 실행 결과: "Set by initializer"
Derived의 생성자를 호출하면 다음과 같은 과정을 따른다.
맴버 변수 초기화 → 베이스 생성자 (B()
) 수행 → Derived 생성자 수행
생성자를 호출하면 맴버 변수가 초기화된 이후에 베이스 생성자가 수행된다.
VFunc()는 Derived 클래스에서 재정의한 함수로 호출된다. (C#은 런타임에 타입을 고려하여 함수를 호출하기 때문)
이 때문에 Derived의 생성자에서 msg가 this.msg = msg로 초기화되지 않은 상태에서 Derived.VFunc()가 수행된다.
정리하면, 베이스 클래스의 생성자 내에서 가상 함수를 호출하면
파생 클래스가 가상 함수를 어떻게 구현 했는지에 따라 매우 민감하게 동작한다.
이는 매우 취약한 코드가 되어버린다.
따라서 생성자 내에서는 가상함수를 절대로 호출하지 않는 것이 좋다.
🔸 ITEM 17: 표준 Dispose 패턴을 구현하라.
IDisposable
인터페이스를 상속 받고 Dispose
패턴을 구현하면 비관리 리소스를 안정적으로 관리할 수 있다.
using System;
public class DisposableTest : IDisposable
{
private bool disposed = false;
public DisposableTest()
{
Console.WriteLine("This is DisposableTest Object.");
}
public void Dispose()
{
// 관리, 비관리 리소스 해제
Dispose(true);
// finalizer가 호출되지 않도록 함
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool isDisposing)
{
// 중복 실행 방지
if (disposed)
return;
if (isDisposing)
{
// 관리 리소스 해제
Console.WriteLine("Close Managed Resources");
}
// 비관리 리소스 해제
Console.WriteLine("Close Unmanaged Resources");
disposed = true;
}
~DisposableTest()
{
// finalizer에서 비관리 리소스 해제
Dispose(false);
}
}
정확한 구현 방식은 다음과 같다.
-
IDisposable
인터페이스 구현 -
비관리 리소스를 포함하는 경우,
finalizer
추가 (Dispose
를 호출하지 않을 경우 대비) -
리소스 정리 작업을 수행하는 가상 메서드 정의 (
Dispose
와finalizer
에서 호출)
🔹 finalizer가 필요한 이유
사용자가 Dispose()
를 반드시 호출할 것이라는 보장이 없기 때문이다.
🔹 Dispose()와 finalizer는 리소스 정리 작업만 수행해야 한다
Dispose()
나 finalizer
에서 리소스 정리가 아닌 다른 작업을 수행하게 되면
객체 생명 주기와 관련된 심각한 문제가 일어날 수 있다.
finalizer
에서 사라져야할 객체가 다시 살아나게 되면 다시 finalizer
를 호출하여 객체를 삭제할 수 없다.
객체가 살아난 것처럼 보여도 온전히 살아난 것은 아니다. (베이스 클래스의 필드는 시간이 지나면 정리된다)