본문 바로가기
C#.Net

[C#] 가비지 컬렉터(Garbage Collector)

by 호야호잇 2024. 8. 30.

출처(https://sam0308.tistory.com/22)

 

※ 해당 포스팅은 .Net 5.0 버전을 기준으로 작성되었습니다.

 

이번 포스팅에서는 C#의 가비지 컬렉터에 대해 알아보겠습니다.

1. 가비지 컬렉터(Garbage Collector)란

 C#, Java와 같은 언어에는 가비지 컬렉터(이하 GC)라는 메모리의 할당, 해제를 관리하는 관리자가 존재하기 때문에 사용자가 직접 메모리를 해제할 필요가 없습니다. 상당히 편리한 기능이지만, 그렇다고 사용자가 메모리 관리에 전혀 신경 쓸 필요가 없다는 뜻은 아닙니다. GC 호출 시 다른 스레드들을 일시정지하고 GC가 실행되기 때문에, 너무 잦은 GC 호출은 프로그램의 성능을 하락시킬 수 있습니다.

 

  GC는 이름 그대로 가비지를 수집하며, 여기서 가비지의 정의는 '더 이상 참조할 수 없게 된 객체'를 의미합니다. 객체 생성 시 Heap 영역에 데이터가 저장되는데, 코드 실행에 따라 해당 데이터에 참조가 없어지는 경우가 발생합니다.

class Program
{
    static void Main(string[] args)
    {
        ArrayTest();
    }

    static void ArrayTest()
    {
        int[] arr = new int[20000];
        for (int i = 0; i < 20000; i++)
            arr[i] = i;
    }
}

 아주 간단한 예시 코드입니다. 메인 함수에서 ArrayTest() 함수를 호출하였고, 해당 함수 안에서 int형 배열을 선언하였습니다. 배열은 힙(Heap) 영역에 생성되고, 함수에서 반복문을 돌며 각 원소에 값을 할당했습니다. 하지만 함수 호출이 끝난 시점에서 함수 안에서 선언한 지역 변수 arr의 주소는 스택 영역에서 해제되고, 힙 영역에는 더이상 접근할 수 없는 배열이 남게 됩니다. 이렇게 힙 영역에 남아있는 접근할 수 없게 된 데이터를 가비지라고 하며, GC의 수집 대상이 됩니다.

 

그림 1. 가비지 컬렉터의 세대 구분

 GC는 효율적인 수집을 위해 객체들에 세대를 매깁니다. 최근에 생성되어 가비지 컬렉팅을 아직 겪은 적이 없는 객체를 0세대, 가비지 컬렉팅을 1번 겪었지만 해제되지 않은 객체를 1세대, 2번 겪었지만 해제되지 않은 객체를 2세대라 합니다.

세대가 높아질수록, 즉 가비지 컬렉팅을 겪고도 생존한 객체들일수록 중요한 객체로 판단하는 방식입니다. 기본적으로 가비지 컬렉팅은 0세대를 대상으로 먼저 시행되며, 그러고도 필요한 메모리를 확보할 수 없을 경우에 1세대까지 포함하여 시행, 1세대까지 포함하여도 메모리 확보가 불가할 때 2세대까지 포함하게 됩니다. 2세대까지 포함한 모든 가비지 컬렉팅 수행 시 아예 프로세스를 일시정지하고 우선적으로 수행하기 때문에, 순간적인 프레임 드랍 등이 일어나게 됩니다.

 

 또한 GC는 객체를 그 크기에 따라 85,000바이트(85kb)보다 작으면 SOH(Small Of Heap), 크거나 같으면 LOH(Large Of Heap)로 구분합니다. SOH는 위에서 언급한대로 할당 직후 0세대부터 시작하며, LOH의 경우 처음부터 2세대로 등록됩니다. 가비지 컬렉팅을 겪고 생존한 객체를 중요하다고 판단하듯이, 크기가 큰 객체는 중요한 객체라고 판단하는 방식입니다. 또한 가비지 컬렉팅 후 SOH들은 메모리 단편화를 없애기 위해 재배치되지만, LOH의 경우 옮기는 과정에 발생하는 오버헤드가 크기 때문에 가비지 컬렉팅 후 이동되지 않습니다. 

2. GC 클래스

 GC는 기본적으로 사용자와 상관없이 자동으로 동작하지만, C#에는 GC를 어느정도 직접 다룰 수 있는 GC 클래스가 존재합니다. 이 중 메모리에 현재 할당된 바이트 수를 반환하는 함수와, 가비지 컬렉팅을 강제로 실행하는 함수를 소개하겠습니다.

class Program
{
    static void Main(string[] args)
    {
        //모든 세대의 가비지 수집
        GC.Collect();
        //0세대부터 (매개변수)세대까지 가비지 수집
        GC.Collect(1);
        //GCCollectionMode에 따라 즉시 실행 or 최적 타이밍에 실행
        GC.Collect(0, GCCollectionMode.Forced);
        GC.Collect(0, GCCollectionMode.Optimized);
        //blocking에 따라 차단 후 실행 or 백그라운드 실행
        GC.Collect(1, GCCollectionMode.Default, true);
        GC.Collect(1, GCCollectionMode.Default, false);
        //compacting에 따라 수집 후 compact 수행 여부 결정
        GC.Collect(2, GCCollectionMode.Default, true, true);
        GC.Collect(2, GCCollectionMode.Default, false, false);

        //메모리에 현재 할당된 바이트 수 반환(long)
        //forceFullCollection에 따라 반환 전에 가비지 수집
        GC.GetTotalMemory(true);
        GC.GetTotalMemory(false);



        GC.Collect(); //사전 메모리 정리
        /*
        가비지 생성량 측정할 코드
        여기에 작성
        */

        //실행 완료 후 메모리 확인
        long before = GC.GetTotalMemory(false);
        Console.WriteLine(before);
        //가비지 컬렉팅 후 잠시 대기
        GC.Collect();
        Thread.Sleep(3000);
        //가비지 컬렉팅 후 메모리 확인
        long after = GC.GetTotalMemory(false);
        Console.WriteLine(after);

        Console.WriteLine($"발생한 가비지: {before - after}");
    }
}

 GC.Collect() 함수 실행 시 GC가 즉시 가비지 컬렉팅을 수행하게 합니다. 매개변수 없이 사용 시 모든 세대의 가비지를 수집하며, 첫번째 매개변수로 정수를 전달하면 0세대~(매개변수)세대의 가비지를 수집합니다(음수 입력 시 InvalidArgumentExeption이 발생합니다).  두번째 매개변수로 GCCollectionMode 열거형을 입력하여 즉시 실행(Forced)하거나 GC가 가비지 컬렉팅을 수행하기에 가장 적합한 타이밍에 실행하도록(Optimized) 정할 수 있습니다. 해당 매개변수를 사용하지 않는 경우 디폴트는 Forced 입니다. 세번째 매개변수로 bool 값을 넘겨 가비지 컬렉팅이 다른 스레드를 블럭하고 실행되도록 하거나 백그라운드에서 실행되도록 할 수 있으며, 네번째 매개변수로 bool 값을 넘겨 compact 수행 여부를 결정할 수 있습니다. 여기서 compact란 위에서 언급한 가비지 컬렉팅 후 SOH 객체들을 재배치하는 과정을 말합니다.

 해당 함수를 통해 사용자가 수동으로 가비지 컬렉팅을 실행할 수 있지만, 이와 같은 수동 호출은 충분한 고려 하에 사용되어야 합니다. 위에서 언급하였듯이 GC는 객체의 세대를 구분하여 가비지 컬렉팅에서 살아남은 객체의 세대를 한단계 올리고, 객체는 세대가 올라갈수록 가비지 컬렉팅의 우선순위에서 밀려나게 됩니다. 즉, 불필요한 타이밍에 GC를 수동 호출하여 객체들의 세대를 괜히 올려주었다간 추후 가비지 컬렉팅의 효율이 떨어지는 상황이 발생할 수 있습니다. 

 

 GC.GetTotalMemory()는 현재 메모리에 할당된 바이트 수를 long 타입으로 반환합니다. 매개변수에 따라 현재 바이트 수를 즉시 반환하거나(false), 가비지 컬렉팅을 수행한 후 반환합니다(true). 해당 함수를 통해 특정 코드 실행 후의 가비지 생성량을 측정할 수도 있습니다. 상기 코드의 아래부분처럼 GC.GetTotalMemory()로 초기 메모리 확인 후 GC.Collect()로 가비지 컬렉팅 수행, 이후 다시 메모리를 확인하여 그 차이를 비교할 수 있습니다(가비지 컬렉팅의 완료를 확실히 하기 위해 GC.Collect() 호출 후 Thread.Sleep()을 사용하였습니다).

 

 

3. 가비지 생성 예방

 GC의 잦은 호출을 막기 위해서는 가비지가 불필요하게 많이 발생하지 않도록 주의할 필요가 있습니다. 제가 아는 선에서 가비지의 생성을 초래하는 경우들은 다음과 같습니다.

 

1) 불필요한 List의 사용

 List<T>는 동적으로 크기를 조절할 수 있는 편리한 자료구조이지만, 메모리 효율에 있어서는 좋지 않은 편입니다. 단적으로 말하자면, 일반 배열로 충분하다면 배열을 사용하는 것이 낫습니다.

class Program
{
    static void Main(string[] args)
    {
        GC.Collect(); //사전 메모리 정리
        ArrayTest();
        //ListTest();

        //함수 호출 후 메모리 확인
        long before = GC.GetTotalMemory(false);
        Console.WriteLine(before);
        //가비지 컬렉팅 후 잠시 대기
        GC.Collect();
        Thread.Sleep(3000);
        //가비지 컬렉팅 후 메모리 확인
        long after = GC.GetTotalMemory(false);
        Console.WriteLine(after);

        Console.WriteLine($"발생한 가비지: {before - after}");
    }

    static void ArrayTest()
    {
        int[] arr = new int[20000];
        for (int i = 0; i < 20000; i++)
            arr[i] = i;
    }

    static void ListTest()
    {
        List<int> list = new List<int>();
        for (int i = 0; i < 20000; i++)
            list.Add(i);
    }
}

그림 2. 좌)ArrayTest, 우)ListTest

 원소 2만개 크기의 배열을 선언한 후 하나씩 원소를 할당하는 ArrayTest() 함수와 리스트 선언 후 원소를 2만개 추가하는 ListTest()함수를 선언하였고, 각 함수를 호출한 직후의 메모리와 가비지 컬렉팅 후 메모리의 차이로 가비지를 계산하였습니다. 사진을 보시면 ListTest() 호출 후에 발생한 가비지가 ArrayTest() 호출 시보다 4배 가량 높은 것을 확인할 수 있습니다. 이런 현상이 나타나는 이유는 List의 내부 구조에 있습니다.

 

그림 3. List의 크기 변화

 위 사진은 List에 추가적으로 원소를 삽입하는 상황을 도식화한 것입니다. List는 Add(), Remove() 등으로 원소를 자유롭게 추가, 제거할 수 있어서 항상 원소갯수만큼의 크기를 가진다고 생각할 수 있지만, 실제로는 조금 다릅니다. Capacity라는 속성 값을 살펴보면 List가 현재 저장할 수 있는 최대 원소 갯수를 나타낸다고 되어있습니다. 즉, 내부적으로는 배열처럼 고정된 크기를 가지며 그 크기를 넘어서는 추가 원소 삽입 요청 시, 사진처럼 기존 크기의 2배의 리스트를 생성한 뒤 기존 List의 값을 복사하고 추가로 원소를 삽입하는 것입니다. 이 과정이 끝나고 나면 List의 인스턴스는 당연히 새롭게 생성한 List를 가리키게 되고, 기존의 List는 아무도 참조하지 않는 가비지가 됩니다. 특히 상기 코드처럼 원소를 하나씩 추가하는 과정을 많이 반복한다면, 잦은 데이터 복사와 가비지 생성을 동시에 유발하게 됩니다. List는 분명 장점이 많은 자료구조이지만, 그만큼 단점도 있다는 점을 명심해야 합니다.

 

2) string 변수에 무분별한 + 연산

 string 변수에는 + 연산을 사용하여 간단하게 텍스트를 추가할 수 있지만, 이 기능은 사실 사용할 때마다 가비지를 생성하게 됩니다. 

class Program
{
    static void Main(string[] args)
    {
        GC.Collect(); //사전 메모리 정리
        string str = "Hello World";
        for (int i = 0; i < 2000; i++)
            str += "!";

        //반복문 완료 후 메모리 확인
        long before = GC.GetTotalMemory(false);
        Console.WriteLine(before);
        //가비지 컬렉팅 후 잠시 대기
        GC.Collect();
        Thread.Sleep(3000);
        //가비지 컬렉팅 후 메모리 확인
        long after = GC.GetTotalMemory(false);
        Console.WriteLine(after);

        Console.WriteLine($"발생한 가비지: {before - after}");
    }
}

그림 4. string의 + 연산

 string 변수 str을 'Hello World'로 초기화해두고, 반복문을 돌며 + 연산을 통해 '!'를 2000번 추가하였습니다. 코드만 보면 string 변수 하나에 반복문으로 글자를 계속 추가했을 뿐이므로 별다른 가비지가 생성되었을 것처럼 보이진 않습니다. 하지만 실제로 실행해보면 위와 같이 상당한 가비지가 생성된 것을 알 수 있습니다. 사실 string 변수에 + 연산으로 문자 추가 시, 원본 뒤에 그대로 추가되는 것이 아니라 뒤에 붙일 문자열과 합친 새로운 문자열을 생성하고 변수가 새로운 문자열을 가리키게 됩니다. 이 때 원본 문자열에 더이상 접근할 수 없게 되므로 매 실행마다 가비지가 발생하는 것입니다. 이와 같은 문제를 방지하려면 자주 변경할 문자열은 string 변수 대신 StringBuilder 클래스를 사용하는 방법이 있으며, 해당 클래스에 관한 내용은 추후 포스팅에서 다루도록 하겠습니다.

 

3) 불필요한 큰 사이즈의 오브젝트 생성 

 위에서 언급하였듯이 85kb 이상의 객체는 LOH로 구분되어 시작부터 2세대로 등록되며, 가비지 컬렉팅 후에도 이동시키지 않습니다. 따라서 LOH 객체를 불필요하게 많이 만들면 힙 영역에 2세대 객체가 가득하여 가비지 컬렉팅 효율이 떨어지게 되며, 메모리 단편화가 발생할 수 있습니다. 큰 사이즈의 배열이나 List같은 자료구조를 직접 선언하는 경우에는 사용자가 인지하기 쉽지만, 큰 오브젝트를 생성하는 클래스의 인스턴스를 생성하는 등 간접적으로 LOH를 만들게 되는 경우에는 인지하지 못하고 넘어갈 수 있으므로 주의가 필요합니다.

 

 위와 같은 내용들 외에도 가비지를 생성하는 경우가 여럿 있을 것으로 보이며, 공부하며 추가로 알게되는 내용은 본문에 추가할 예정입니다.