방 청소가 귀찮아서 미루고 미루다 보면 결국 쓰레기가 쌓인다.
프로그램도 마찬가지이다.
프로그램이 실행되면서 계속해서 데이터라는 쓰레기가 쌓이게 되는데,
이를 처리하지 않으면 시스템 속도가 느려지고 심지어는 오류가 발생할 수도 있다.

그렇다면 이 쌓이는 데이터 쓰레기는 누가 치워줄까?
바로 “가비지 컬렉터”라는 똑똑한 청소부가 그 역할을 한다.

가비지 컬렉션이 뭐고 왜 중요할까?

가비지 컬렉션(Garbage Collection)은 프로그램이 프로그램이 동적으로 할당했던 메모리 영역 중
더 이상 사용하지 않는 영역(메모리 공간)을 탐색하여 해제하는 자동 메모리 관리 기법을 말한다.
보통 줄여서 “GC”라고도 칭한다.

초창기 프로그래밍 언어에서는 개발자가 직접 메모리를 할당하고 해제해야 했다.
하지만 이는 매우 번거롭고 오류를 발생시키기 쉬운 작업이었다.
사용하지 않는 메모리를 해제하지 않으면 메모리 공간이 낭비되어
시스템 성능 저하, 심지어 시스템 멈춤 현상까지 발생할 수 있었으며,
이미 해제된 메모리 영역을 참조하는 포인터를 댕글링 포인터(Dangling Pointer)라고 하는데
이는 예측 불가능한 오류를 발생시킬 수 있었다.

위와 같은 문제들을 해결하기 위해 가비지 컬렉션이 생겼다.
가비지 컬렉터가 메모리를 알아서 관리해주기 때문에 개발자는 직접 메모리를 관리할 필요가 없어졌고,
메모리 누수나 댕글링 포인터로 인한 오류 가능성도 줄일 수 있게 됐다.

쉽게 말하면, 우리가 만든 프로그램 속에서 더 이상 필요 없는 데이터들을 컴퓨터가 알아서 쏙쏙 골라내서 깨끗하게 치워주는 역할을 한다.
덕분에 개발자인 우리는 메모리 관리의 부담을 덜고, 더욱 편리하고 안전하게 프로그램을 개발할 수 있다.

C/C++와 같은 언어에서는 개발자가 직접 메모리를 할당하고 해제해야 한다.
이는 매우 번거로울 뿐만 아니라 메모리 누수(memory leak)와 같은 치명적인 오류를 발생시킬 수 있다.
하지만 C#에서는 GC가 자동으로 메모리를 관리해주기 때문에 이러한 문제를 비교적으로 걱정할 필요가 없다.

가비지 컬렉션 vs 가비지 컬렉터, 무엇이 옳은 표현일까?

결론부터 말하면, 둘 다 틀린 표현이 아니다.

  • 가비지 컬렉터(Garbage Collector)메모리 관리를 담당하는 시스템의 한 구성 요소를 지칭하는 말이다. 마치 청소부처럼, 사용하지 않는 메모리를 찾아서 정리하는 역할을 수행한다.
  • 가비지 컬렉션(Garbage Collection)은 가비지 컬렉터가 실제로 메모리를 정리하는 작업 또는 행위를 가리킨다. 즉, 가비지 컬렉터가 하는 일이라고 할 수 있다.

비유하자면, ‘청소부 아저씨’와 ‘청소’의 관계와 비슷하다.
‘청소부 아저씨’는 특정 인물을 지칭하고, ‘청소’는 그 사람이 하는 일을 나타내는 것처럼 말이다.

가비지 컬렉션의 장단점

장점 👍

  1. 개발 생산성 향상: 가비지 컬렉션은 메모리 할당과 해제를 자동화하여, 개발자가 메모리 관리의 복잡성을 직접 다룰 필요 없이 비즈니스 로직에 집중할 수 있게 한다. 이를 통해 개발 과정이 단순화되고 생산성이 향상된다.
  2. 메모리 누수 방지: 사용되지 않는 객체를 자동으로 회수하여 메모리 누수를 방지한다. 이는 장기적인 애플리케이션 성능과 안정성을 유지하는 데 중요한 역할을 한다.
  3. 프로그램 안정성 증대: 수동 메모리 관리에서 발생할 수 있는 댕글링 포인터(이미 해제된 메모리 영역을 참조하는 포인터) 및 이중 해제와 같은 메모리 관련 오류를 줄여, 프로그램의 안정성을 크게 향상시킨다.

단점 👎

  1. 성능 오버헤드: 가비지 컬렉션은 주기적으로 시스템 리소스를 소비하며, 이로 인해 애플리케이션의 성능에 영향을 줄 수 있다. 특히, 실시간 응답성이 중요한 애플리케이션에서는 이러한 오버헤드가 성능 저하로 이어질 수 있다.
  2. 일시적 실행 중단: 가비지 컬렉션 과정에서 애플리케이션의 실행이 일시적으로 중단되는 “스톱 더 월드(Stop-the-World)” 현상이 발생할 수 있다. 이 현상은 특히 대규모 메모리 관리가 필요한 경우 사용자 경험에 부정적인 영향을 미칠 수 있다.

C# 가비지 컬렉터(GC)의 작동 원리

C#에서 가비지 컬렉션(GC)은 .NET의 메모리 관리 메커니즘으로,
더 이상 참조되지 않는 객체를 자동으로 회수하여 메모리 누수를 방지하고 효율적인 메모리 사용을 보장한다.
GC는 크게 다음과 같은 원리로 작동한다.

메모리 할당

객체가 생성되면, 메모리는 힙(Heap)영역에 할당된다.

은 객체가 동적으로 생성되는 메모리 영역을 말한다.
컴퓨터 프로그램이 동적으로 메모리를 할당하는 데 사용하는 메모리 영역으로
런타임 시점에 메모리를 할당하거나 해제할 수 있도록 설계된 메모리 풀(pool)이다.
주로 객체나 변수가 생성될 때 그 크기를 미리 알 수 없거나, 그 생명 주기가 유동적인 경우에 사용된다.

.NET에서는 가비지 컬렉션(GC) 알고리즘은 메모리 관리의 효율성을 극대화하기 위해
메모리를 세 가지 세대(Generation)로 나누어 관리한다.
이를 세대별 가비지 컬렉션 또는 세대별 GC라고 부른다.
쉽게 말하면, 힙 메모리를 나누어 객체의 수명에 따라 다른 방식으로 처리하는 방법이다.

  • 세대 0 : 가장 최근에 생성된 객체들이 모여 있는 곳이다.
    • 새로 생성된 객체
    • 주로 수명이 짧은 객체들을 포함 (임시 변수와 같은 객체들이 여기에 해당)
    • 가비지 수집 빈도가 가장 높으며, 대부분의 객체는 0세대에서 수집되어 메모리에서 해제 됨
    • 0세대가 가득 찰 경우, 가비지 컬렉션이 발생하여 메모리 확보
  • 세대 1 : 세대 0에서 살아남은 객체들이 이사를 가게 되는 곳이다.
    • 한 번 가비지 컬렉션을 통과한 객체
    • 수명이 짧은 객체와 수명이 긴 객체 사이의 버퍼 역할
    • 0세대에서 살아남은 객체가 승격되는 공간
    • 가비지 컬렉터에서 0세대를 수집할 때마다 1세대에 있는 객체들을 다시 검사하지 않음으로써 성능 최적화
  • 세대 2 : 세대 1에서도 살아남은 장수 객체들이 머무는 곳이다.
    • 여러 번 가비지 컬렉션을 통과한 오래된 객체
    • 수명이 긴 객체 포함 (서버 애플리케이션에서 프로세스의 수명 동안 유지되는 정적 데이터가 여기에 해당)
    • 2세대는 가장 드물게 수집되며, 이 세대에 존재하는 객체는 다음 수집 시까지 보존

가비지 컬렉션 트리거

GC는 시스템이 메모리 부족 상태에 빠질 때 또는 프로그래머가 GC.Collect() 를 명시적으로 호출할 때 트리거된다.
일반적으로는 세대 0에서 시작되며, 필요한 경우 세대 1과 2로 진행된다.
이 방식은 메모리 회수 작업의 효율성을 높이기 위해 설계되었다.

GC가 트리거되는 조건은 다음과 같다.

  • 메모리 부족 : 시스템 메모리가 부족할 때
  • 할당 한도 초과 : 특정 양의 메모리가 할당되었을 때
  • 명시적 호출 : GC.Collect() 와 같은 명시적인 호출이 있을 때
  • CLR 판단 : Common Language Runtime(CLR)이 필요하다고 판단할 때

GC는 주기적으로 각 세대를 검사하고, 더 이상 사용되지 않는 객체들을 찾아내어 메모리에서 제거한다.

CLR(Common Language Runtime)은 닷넷 애플리케이션이 실행되는 런타임 환경을 제공하는 것이다.

이해하기 쉽게 자바와 비교해보자면,
자바에서는 자바를 실행하기 위해 JVM이 필요하지만
C#에서는 씨샵 코드를 실행하기 위해서 CLR을 필요로 한다.

CLR은 윈도우 닷넷 계열의 언어(C#, F#, VB.NET 등)의 가상머신으로 이해하면 된다.

C# 가비지 컬렉션 동작 순서

1. 루트 참조 검사 (Mark Phase)

GC는 “루트(root)” 참조라고 불리는 변수와 객체들을 검사하여,
현재 참조되고 있는 모든 객체들을 마킹(marking)한다.
루트는 일반적으로 스택에 있는 로컬 변수, 정적 변수, CPU 레지스터, 또는 최상위 객체 등을 포함한다.
루트에서 접근할 수 없는 객체들은 “더 이상 필요 없는 객체”로 간주된다.

마킹(Marking)이란, 가비지 컬렉션(GC) 과정의 중요한 단계로,
GC가 메모리에서 어떤 객체가 여전히 사용 중인지(살아있는 상태인지)를 식별하는 작업이다.

2. 수집 및 메모리 해제(Sweep Phase)

마킹 단계에서 마킹되지 않은(추적하지 않은) 객체들은
더 이상 참조되지 않는 객체로 간주되어 메모리에서 해제(제거)된다.
세대 0의 객체들은 주로 여기에 포함되며, 한 번 회수된 메모리는 다시 사용 가능하다.

3. 압축 및 정리 (Compaction Phase)

GC는 메모리 단편화를 방지하기 위해 남아있는 객체들을 메모리 공간의 한쪽으로 이동시킨다.

가비지 컬렉션의 마지막 단계이다.
사용되지 않는 메모리 조각들을 정리하고, 남아있는 객체들을 연속된 메모리 블록으로 이동시킨다.
이를 통해 메모리의 단편화를 줄이고, 새로운 객체를 위한 공간을 확보한다.
쉽게 말하면, 남아 있는 객체들을 한쪽으로 이동시키고 남은 공간을 확보해 다른 객체가 들어올 자리를 마련하는 것이다.

메모리가 연속적으로 할당되어 있을 때 CPU 캐시 효율이 높아져 메모리 접근 성능이 향상된다.
단편화된 메모리는 캐시 미스가 늘어나 성능 저하를 유발할 수도 있다.

메모리 단편화란 메모리 공간이 작은 조각들로 흩어져 있어서,
충분한 총 메모리가 남아 있음에도 불구하고,
큰 객체를 할당할 수 있는 연속적인 공간이 부족해지는 현상을 의미한다.

즉, 메모리의 공간을 정리하지 않는다면 메모리의 총량은 동일하지만
사용 가능한 메모리와 효율성 측면에서 차이가 생길 수 있다.

가비지 컬렉션이 관리하지 않는 리소스 처리 방법

관리되지 않는 리소스(unmanaged resources)란 가비지 컬렉터가 직접 관리하지 않는 리소스를 의미한다.
관리되지 않는 리소스는 메모리 관리와 수명 관리를 수동으로 해야 하는 리소스들을 말한다.
이러한 리소스들은 주로 운영 체제의 자원이나 외부 라이브러리에서 제공하는 자원들이다.

가비지 컬렉터는 Finalize 메서드를 통해 객체가 가비지 컬렉션될 때
자동으로 호출하여 관리되지 않는 리소스를 해제하는데 사용한다.
직접 호출할 수 없으며 가비지 컬렉션터에 의해서만 호출된다.
C#에서는 소멸자를 통해 구현된다.

관리되지 않는 리소스의 예

  1. 파일 핸들: 파일에 접근하거나 작업하기 위해 운영 체제에서 할당된 핸들
  2. 데이터베이스 연결: 데이터베이스와의 연결을 위한 핸들 또는 커넥션 객체
  3. 네트워크 소켓: 네트워크 통신을 위해 사용되는 소켓
  4. 메모리 블록: Marshal.AllocHGlobal 또는 Marshal.AllocCoTaskMem 을 사용하여 할당된 비관리 메모리 블록
  5. 윈도우 핸들: 윈도우 애플리케이션에서 사용되는 핸들 또는 GUI 자원
  6. 그래픽 객체: GDI+, DirectX 또는 OpenGL에서 사용되는 그래픽 객체

관리되지 않는 리소스의 처리 방법

관리되지 않는 리소스는 자동으로 해제되지 않으므로 개발자가 직접 명시하여 자원을 해제해야 한다.
이를 위해 사용하는 메서드는 Dispose Finalize 가 있다.

IDisposable 인터페이스를 구현하여 Dispose 메서드를 제공하는 것이 일반적으로 권장된다.
Finalize 는 필요한 경우에만 사용하며, 성능에 영향을 줄 수 있으므로 주의해야 한다.

  • IDisposable 인터페이스
    • Dispose 메서드를 통해 구현되며, 프로그래머가 직접 호출해야 한다.
    • IDisposable 인터페이스를 구현한 객체는 Dispose 메서드를 호출하여 관리되지 않는 리소스를 명시적으로 해제할 수 있다.
    • Dispose 메서드는 객체가 더 이상 필요하지 않을 때 호출되어 자원을 정리하는 데 사용된다.
    • GC.SuppressFinalize(this) 를 호출하여 가비지 컬렉터가 해당 객체의 Finalize 메서드를 호출하지 않도록 할 수 있다.
      • 이 호출은 Dispose 메서드에서 리소스 해제를 완료한 경우 필요하다.
    • 주로 using 문과 함께 사용되며, using 문은 객체의 수명이 끝나면 자동으로 Dispose 메서드를 호출한다.
  • Finalize 메서드
    • Finalize 메서드는 객체가 가비지 컬렉션에 의해 수집될 때 호출된다.
    • 이 메서드에서 관리되지 않는 리소스를 해제할 수 있지만, 호출 시점을 예측할 수 없고 가비지 컬렉션 성능에 영향을 줄 수 있다.
    • Finalize 메서드는 IDisposable 패턴을 사용할 때 Dispose 메서드에서 호출하는 것이 일반적이다.
      • 이 경우, Dispose 메서드에서 관리되지 않는 리소스를 명시적으로 해제하고, Finalize 메서드에서는 이를 보충적으로 처리한다.

C#에서 가비지 콜렉터를 수동으로 호출하려면?

C#에서 가비지 컬렉터(GC)를 수동으로 호출할 수 있다.
일반적으로 C#에서는 가비지 컬렉션이 자동으로 이루어지지만,
특정 상황에서 개발자가 명시적으로 가비지 컬렉션을 트리거해야 할 경우에는
GC.Collect() 메서드를 이용해 가비지 컬렉션을 호출할 수 있다.

단, 가비지 컬렉터를 수동으로 호출하는 것은 성능에 영향을 미칠 수 있기 때문에 신중하게 사용해야 한다.
일반적으로는 .NET의 자동 가비지 컬렉션이 충분히 효율적이므로, 직접 호출할 필요가 없는 경우가 많다.

기본 가비지 컬렉션 호출

  • 모든 세대(0, 1, 2)의 가비지 컬렉션을 실행한다.
GC.Collect();
C#

특정 세대에 대해 가비지 컬렉션 호출

  • 매개변수로 세대 번호를 지정하면, 해당 세대와 그 이하 세대의 가비지 컬렉션이 실행된다.
GC.Collect(0); // 0세대만 수집
GC.Collect(1); // 1세대와 그 아래 세대 수집
GC.Collect(2); // 2세대와 그 아래 모든 세대 수집
C#

가비지 컬렉션 후 Finalizers 처리 대기

  • GC.WaitForPendingFinalizers() 를 호출하면 모든 종료자(finalizer, 파이널라이저)가 실행될 때까지 현재 스레드를 일시 중지한다.
  • 이 호출은 보통 GC.Collect() 후에 사용된다.
GC.Collect();
GC.WaitForPendingFinalizers();
C#

약한 참조 수집 강제 실행

  • 두 번의 GC.Collect() 호출 사이에 GC.WaitForPendingFinalizers() 를 호출하면 약한 참조를 포함한 모든 가비지 개체를 완전히 수집할 수 있다.
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
C#

약한 참조(Weak References)란?
애플리케이션에서 개체에 계속 액세스할 수 있는 동안 가비지 컬렉터에서 해당 개체를 수집할 수 있도록 하는 참조 유형을 의미한다.
객체를 참조하지만, 그 객체가 강한 참조로부터 더 이상 참조되지 않는다면 가비지 컬렉터가 수집할 수 있다.
약한 참조를 통해 참조된 객체는 수집되기 전까지는 접근할 수 있지만
가비지 컬렉션이 일어난 후에는 더 이상 접근할 수 없게 된다.

반면 강한 참조(Strong Reference)는 일반적으로 우리가 객체를 참조할 때 사용하는 참조 유형을 말한다.
객체가 강한 참조로 참조되는 한 가비지 컬렉터는 그 객체를 수집하지 않는다.
이럴 때, ‘애플리케이션은 개체에 대한 강력한 참조를 가진다’고 한다.

C# 가비지 컬렉션 샘플 코드

사용하지 않는 객체를 생성하여 가비지 컬렉션으로 메모리 관리해보기

객체를 생성하여 사용중인 메모리를 늘린 후,
가비지 컬렉션을 강제로 실행하여 사용하지 않는 객체의 메모리를 해제해본다.

namespace GarbageCollectionSample
{
    class Program
    {
        class SampleObject
        {
            public int Value { get; set; }
        }

        static void Main(string[] args)
        {
            // 메모리 사용량 측정을 위해 초기 메모리 사용량 출력
            Console.WriteLine("Initial memory usage: " + GC.GetTotalMemory(false) + " bytes");

            // 객체를 생성하여 메모리 사용량 증가
            CreateObjects();

            // 가비지 컬렉션 실행 전 메모리 사용량 출력
            Console.WriteLine("Memory usage before GC: " + GC.GetTotalMemory(false) + " bytes");

            // 가비지 컬렉션 강제 실행
            GC.Collect();
            GC.WaitForPendingFinalizers(); // 모든 Finalize 메서드가 호출될 때까지 기다림

            // 가비지 컬렉션 실행 후 메모리 사용량 출력
            Console.WriteLine("Memory usage after GC: " + GC.GetTotalMemory(false) + " bytes");
        }

        static void CreateObjects()
        {
            for (int i = 0; i < 10000; i++)
            {
                SampleObject obj = new SampleObject { Value = i };
                // 객체를 사용하지 않으므로 GC가 수집할 수 있음
            }
        }
    }
}
C#
가비지 컬렉션을 실행하여 사용중인 메모리를 확인해본 이미지

설명에서 사용된 샘플 코드는 여기에서!

포스팅에서 사용된 샘플 코드는 아래의 깃허브에서 확인할 수 있습니다.

C# 예제 프로젝트가 있는 깃허브 바로 가기

참고된 자료

  1. 마이크로소프트 .NET 레퍼런스

Similar Posts

댓글 남기기