1. 메모리 기초: 스택과 힙 (Stack & Heap)
프로그램이 시작되면 메모리를 쓰게 돼. .NET 맥락에서는 (CLR — Common Language Runtime) 이 메모리가 자동으로 관리되고 주로 두 영역으로 나뉘어: 스택과 힙. 데이터가 어디에, 어떻게 저장되는지 이해하는 건 효율적이고 안정적인 코드를 쓰는 데 중요해.
스택 (Stack)
스택을 접시 더미처럼 생각하면 돼: 새 접시는 항상 위에 올리고, 꺼낼 때도 위에서 꺼내지. 이건 LIFO 원칙(Last In, First Out)이야. 스택은 매우 빠르지만 크기가 제한된 메모리 영역이야.
스택에 저장되는 것:
- 값 형식: 직접 선언된 변수의 실제 값.
- 객체에 대한 참조: 참조 타입의 경우 스택에는 힙에 있는 객체의 주소(참조)만 저장됨.
- 메서드 매개변수: 함수로 전달되는 값들.
- 로컬 변수: 메서드 안에서 선언된 변수들.
- 복귀 주소: 메서드 실행 후 돌아갈 위치.
스택 메모리는 해당 메서드가 끝나거나 변수가 범위를 벗어나면 자동으로 할당/해제돼서 매우 빠르게 동작해.
예제: 값 형식이 스택에 있을 때
void MyMethod()
{
int a = 10; // 'a'와 값 10 - 스택에 있음
bool flag = true; // 'flag'와 값 true - 스택에 있음
char initial = 'Z'; // 'initial'와 값 'Z' - 스택에 있음
// ...
} // MyMethod가 끝나면 'a', 'flag', 'initial'은 스택에서 제거돼.
예제: 스택의 참조
class MyObject { }
void AnotherMethod()
{
MyObject objRef; // 'objRef' (참조) - 스택에 있음. 실제 객체는 아직 생성되지 않음.
// ...
} // AnotherMethod가 끝나면 'objRef' (참조)는 스택에서 제거돼.
힙 (Heap)
힙은 훨씬 크고 유연한 메모리 영역이야. 여기서는 엄격한 LIFO 규칙이 없고, 데이터는 가능한 빈 공간에 배치돼. 힙은 더 크거나/또는 오래 살아야 하는 객체들에 쓰여.
힙에 저장되는 것:
- 참조 타입의 객체: class, 배열, string 같은 데이터 — new로 생성됨.
- 참조 타입 내부의 값 형식 필드: 값 형식(struct)이 참조 타입(class)의 필드라면 그 값은 객체 내부, 즉 힙에 저장돼.
힙의 메모리는 자동으로 GC(Garbage Collector)에 의해 관리돼.
예제: 클래스 객체는 힙에
class Person { public string Name; public int Age; }
void CreatePerson()
{
Person p = new Person(); // Person 객체는 힙에. 'p' (참조) 는 스택에 있음.
p.Name = "Alice"; // 문자열 "Alice" (또한 객체) - 힙에 있음.
p.Age = 30; // 30 (int, 값 형식) - Person 객체 내부(힙)에 있음.
// ...
} // CreatePerson가 끝나면 'p' (참조)는 스택에서 제거됨.
// 힙의 Person 객체는 도달 불가능해지고 GC의 대상이 돼.
예제: 배열은 힙에
void ProcessArray()
{
int[] numbers = new int[5]; // 5개의 int 배열 - 힙에 있음. 'numbers' (참조) - 스택에 있음.
numbers[0] = 10; // 요소 10 - 배열의 일부로 힙에 저장됨.
// ...
} // 배열은 더 이상 참조가 없을 때 GC에 의해 정리돼.
2. 가비지 컬렉션 (Garbage Collection)
힙에서의 메모리 관리는 GC 덕분에 자동으로 이뤄져. 이건 .NET의 핵심 기능 중 하나로, 수동으로 메모리를 해제하는 과정(메모리 누수의 흔한 원인)을 없애줘.
목적과 동작 원리
GC의 주요 목적은 프로그램에서 더 이상 사용되지 않는 힙의 객체들을 자동으로 해제하는 거야.
- 객체 생성: new로 생성하면 CLR이 힙에 공간을 할당해.
- 참조 추적: GC는 활성 참조들을 추적해; 스택, static 필드 또는 다른 도달 가능한 객체로부터 참조가 있으면 객체는 "도달 가능"으로 간주돼.
- "쓰레기" 판정: 참조가 없으면 그 객체는 "도달 불가능"이고 쓰레기로 간주돼.
- 수집: 필요할 때 GC는 도달 가능한 것들을 표시하고 도달 불가능한 객체들의 메모리를 해제해.
- 컴팩트화: 단편화를 줄이기 위해 남은 객체들을 이동시켜 연속된 영역을 만들 수 있어.
GC의 세대 모델 (Generational GC)
GC는 대부분의 객체가 "젊게 죽는다"는 사실을 이용해서 세대를 사용해:
- 세대 0 (Gen 0): 새로 생성된 객체들; 수집이 자주 있고 빠름.
- 세대 1 (Gen 1): Gen 0을 버틴 객체들; 수집이 덜 자주 일어나고 조금 더 오래 걸림.
- 세대 2 (Gen 2): 장수 객체들; 수집은 가장 드물고 비용이 많이 듦.
언제 GC가 실행돼?
- 새 할당을 위한 여유 메모리가 부족할 때.
- 유휴 시간 동안 정기 검사.
- 명시적 호출(보통 권장되지 않음)인 GC.Collect().
GC와 성능
GC가 실행되면 사용자 코드의 실행이 중단되어 잠깐의 멈춤(pause)이 생길 수 있어. 힙에 단기간만 사용되는 객체를 과도하게 생성하면 수집이 잦아져 성능이 떨어질 수 있어.
예제: 객체가 도달 불가능해지는 경우
class DataBlock { public byte[] Data; public DataBlock() => Data = new byte[1024 * 1024]; } // 1MB 데이터
void AllocateAndLose()
{
DataBlock block1 = new DataBlock(); // block1 객체는 힙에 있고, 'block1' 참조는 스택에 있음
// ... 코드 블록 ...
block1 = null; // 이제 DataBlock 객체에 대한 활성 참조가 없음. 도달 불가능해짐.
// 이 시점에서 GC가 아직 삭제하지 않았을 수 있지만, 삭제 후보가 된 상태야.
// 여기서 GC.Collect()를 호출하는 건 데모용일 뿐, 프로덕션에서는 하지 마.
Console.WriteLine("Calling GC.Collect()");
GC.Collect(); // GC가 지금 메모리를 정리할 수도(하지만 보장되진 않음) 있어
}
AllocateAndLose();
// AllocateAndLose가 끝나면 'block1' 참조는 스택에서 제거되고,
// 힙의 DataBlock 객체는 도달 불가능해져 수집 대상이 돼.
3. unmanaged 리소스 관리
GC는 .NET의 "managed" 메모리를 관리해. 하지만 unmanaged 리소스도 있고, 이들은 GC의 관리 범위를 벗어나서 명시적으로 해제해줘야 해:
- 파일 디스크립터(열려 있는 파일).
- 네트워크 소켓.
- 윈도우 핸들/그래픽 오브젝트(예: GDI+).
- OS에서 직접 할당한 메모리(예: P/Invoke로 할당한 경우).
파이널라이저 (Finalizers)
파이널라이저는 객체가 가비지 컬렉션되기 전에 unmanaged 리소스를 최종 정리하기 위해 실행되는 특별한 메서드야. 문법은 생성자와 비슷하지만 틸다(~)를 사용해:
class MyClassWithFinalizer
{
~MyClassWithFinalizer() { /* unmanaged 리소스 해제 */ }
}
GC는 언제 실행될지 예측할 수 없고, 파이널라이저는 별도의 쓰레드에서 지연되어 호출돼. 단점: 불확실성과 오버헤드(파이널라이저가 있는 객체는 두 번의 수집 주기를 거쳐야 함). 그래서 파이널라이저는 "보험용"이지 주된 정리 메커니즘이 아니야.
예제: 보험용 파이널라이저
class UnmanagedResourceHolder
{
private bool _resourceReleased = false;
// unmanaged 리소스 흉내
public UnmanagedResourceHolder() => Console.WriteLine("리소스 생성됨.");
public void ReleaseResource()
{
if (!_resourceReleased)
{
Console.WriteLine("리소스가 명시적으로 해제되었어.");
_resourceReleased = true;
}
}
// 파이널라이저 - GC가 마지막 수단으로 호출함
~UnmanagedResourceHolder()
{
Console.WriteLine("파이널라이저가 호출됨 (리소스가 명시적으로 해제되지 않았음).");
ReleaseResource(); // 리소스 해제 시도
}
}
인터페이스 IDisposable
IDisposable는 명시적으로 리소스를 해제하기 위한 선호되는 메커니즘이야. 하나의 메서드: void Dispose()를 포함해. 보통 Dispose()를 여러 번 호출해도 안전하게 만들고, 파이널라이저를 무시하도록 GC.SuppressFinalize(this)를 호출하는 패턴을 사용해.
예제: IDisposable 구현
using System.IO;
class MyFileWriter : IDisposable
{
private StreamWriter _writer;
private bool _disposed = false;
public MyFileWriter(string path)
{
_writer = new StreamWriter(path, true);
Console.WriteLine($"파일 '{path}' 열림.");
}
public void WriteLog(string message) => _writer.WriteLine(message);
// IDisposable 구현
public void Dispose()
{
Dispose(true); // 실제 Dispose 로직 호출
GC.SuppressFinalize(this); // GC에게 파이널라이저가 필요 없다고 알림
_disposed = true;
}
// 일반적인 Dispose 패턴을 위한 보호된 가상 메서드
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
// 관리되는 리소스 해제
_writer?.Dispose();
}
// unmanaged 리소스 해제(있다면)
Console.WriteLine("Dispose로 파일이 닫혔어.");
}
}
// 선택적 파이널라이저(보험용)
~MyFileWriter()
{
Console.WriteLine("파이널라이저로 파일이 닫혔어 (Dispose()가 호출되지 않았음).");
Dispose(false); // 명시적 호출이 아님을 표시하며 Dispose 호출
}
}
연산자 using
using 연산자는 구문적 설탕으로, 객체가 범위를 벗어날 때(예외가 나도) Dispose()가 호출되는걸 보장해. IDisposable을 구현한 객체와 작업할 때 가장 추천되는 방식이야.
예제: using으로 자동 정리
// 위의 MyFileWriter 사용 예
void ProcessFile(string fileName)
{
// using 블록이 끝나면 MyFileWriter 객체는 자동으로 Dispose 됨
using (var writer = new MyFileWriter(fileName))
{
writer.WriteLog("첫 번째 줄.");
writer.WriteLog("두 번째 줄.");
// 여기서 예외가 발생해도 Dispose()는 호출될 거야
} // 여기서 writer.Dispose()가 자동으로 호출됨
Console.WriteLine("using 블록이 끝났어, 파일 닫혔음.");
}
ProcessFile("testlog.txt");
// 이 경우 Dispose()가 명시적으로 호출됐기 때문에 MyFileWriter의 파이널라이저는 호출되지 않을 거야.
이 개념들을 이해하면 C#로 성능 좋고 신뢰성 있는, 확장 가능한 애플리케이션을 쓰는 데 큰 도움이 될 거야.
GO TO FULL VERSION