CodeGym /행동 /C# SELF /C#의 메모리: 스택, 힙과 가비지 컬렉션 ( GC)

C#의 메모리: 스택, 힙과 가비지 컬렉션 ( GC)

C# SELF
레벨 65, 레슨 0
사용 가능

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의 주요 목적은 프로그램에서 더 이상 사용되지 않는 힙의 객체들을 자동으로 해제하는 거야.

  1. 객체 생성: new로 생성하면 CLR이 힙에 공간을 할당해.
  2. 참조 추적: GC는 활성 참조들을 추적해; 스택, static 필드 또는 다른 도달 가능한 객체로부터 참조가 있으면 객체는 "도달 가능"으로 간주돼.
  3. "쓰레기" 판정: 참조가 없으면 그 객체는 "도달 불가능"이고 쓰레기로 간주돼.
  4. 수집: 필요할 때 GC는 도달 가능한 것들을 표시하고 도달 불가능한 객체들의 메모리를 해제해.
  5. 컴팩트화: 단편화를 줄이기 위해 남은 객체들을 이동시켜 연속된 영역을 만들 수 있어.

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#로 성능 좋고 신뢰성 있는, 확장 가능한 애플리케이션을 쓰는 데 큰 도움이 될 거야.

2
과제
C# SELF, 레벨 65, 레슨 0
잠금
스택과 힙의 차이점
스택과 힙의 차이점
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION