Notice
Recent Posts
Recent Comments
Link
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
Archives
Today
Total
관리 메뉴

nomad-programmer

[Programming/C#] 가비지 컬렉션 (Garbage Collection) 본문

Programming/C#

[Programming/C#] 가비지 컬렉션 (Garbage Collection)

scii 2020. 10. 4. 23:28

CLR은 자동 메모리 관리(Automatic Memory Managment) 기능을 제공한다. 이 자동 메모리 관리 기능의 중심에는 가비지 컬렉션(Garbage Collection)이 있다.

가비지 컬렉션을 우리말로 바꾸면 "쓰레기 수거"라는 뜻으로, 여기에서 말하는 쓰레기란 더 이상 사용하지 않는 객체를 말한다. 가비지 컬렉션은 프로그래머로 하여금 컴퓨터가 무한한 메모리를 갖고 있는 것처럼 간주하고 코드를 작성할 수 있게 한다. C# 프로그래머는 무한한 메모리를 이용해 걱정 없이 코드를 작성하면 되므로 C/C++ 프로그래머들보다 훨씬 덜한 스트레스 속에서 일을 할 수 있게 된다.

CLR 안에는 가비지 컬렉션을 담당하는 "가비지 컬렉터(Garbage Collector)"가 있다. 작성한 프로그램이 객체를 할당해서 일을 하고 있으면, 가비지 컬렉터는 객체 중에 쓰레기인 것과 쓰레기가 아닌 것을 완변하게 분리해서 쓰레기들만 조용히 수거해간다.
매우 똑똑하게 일을 하지만 가비지 컬렉터도 역시 소프트웨어이기 때문에 CPU와 메모리 같은 컴퓨팅 자원을 소모한다. 개발자가 작성한 코드도 사용해야 하는 그 자원을 가비지 컬렉터도 같이 사용해야 한다는 이야기다. 만약 가비지 컬렉터가 최소한으로 이 자원을 사용하게 만들 수 있다면 개발한 프로그램의 성능을 아낀 자원의 양만큼 끌어올릴 수 있게 된다.

그렇다면 가비지 컬렉터가 최소한으로 자원을 사용하게 만들기 위해선 무엇일 필요할까? 우선 프로그래머가 가비지 컬렉터가 어떻게 동작하는지에 대한 메커니즘을 이애하고 있어야 한다. 그리고 그 메커니즘에 대한 이해를 바탕으로 코딩 지침을 세우고, 이 지침을 바탕으로 코드를 작성해야 한다. 

기본적으로 C#으로 작성된 모든 코드는 관리형 코드(Managed Code)에 속한다. CLR은 실행되는 코드에 대해 메모리 할당, 보안, 스레딩 등의 임무와 함께 쓰레기를 치우는 일도 책임지고 있다.

C#으로 비관리형 코드(Unmanged Code)도 작성할 수 있다. 비관리형 코드를 작성하기 위해서는 unsafe 키워드를 이용하면 되는데 물론 이 경우 CLR이 제공하는 서비스를 받을 수 없다.

CLR이 객체를 메모리에 할당하는 방법

C#으로 작성한 소스 코드를 컴파일해서 실행 파일을 만들고 이 실행 파일을 실행하면, CLR은 이 프로그램을 위한 일정 크기의 메모리를 확보한다. C-Runtime처럼 메모리를 쪼개는 일은 하지 않는다. 
메모리 공간을 통째로 확보해서 하나의 관리되는 힙(Managed Heap)을 마련한다. 그리고 CLR은 이렇게 확보한 관리되는 합 메모리의 첫 번째 주소에 "다음 객체를 할당할 메모리의 포인터"를 위치시킨다.

if (true)
{
    object a = new object();
}

위의 코드를 보면 실제 객체의 내용물은 힙에 할당되어있지만 a는 스택 메모리에 할당되어 객체 A가 위치하고 있는 힙 메모리의 주소를 참조하고 있을 뿐이다.
if 블록이 끝나면 a는 스택에서 사라지고 더 이상 존재하지 않게 된다. 남아 있는 객체는 코드의 어디에서도 접근할 수 없기 때문에 더 이상 사용할 수 없고 자리만 차지하고 있는 쓰레기가 되어버린다. 이 쓰레기는 곧 가비지 컬렉터가 집어가게 된다.

사라져버린 a처럼 할당된 메모리의 위치를 참조하는 객체를 일컬어 루트(Root)라고 부른다. 루트는 a의 경우처럼 스택에 생성될 수도 있고 정적 필드처럼 힙에 생성될 수도 있다.
.NET 애플리케이션이 실행되면 JIT 컴파일러가 이 루트들을 목록으로 만들고, CLR은 이 루트 목록을 관리하며 상태를 갱신한다. 이 루트가 중요한 이유는 가비지 컬렉터가 CLR이 관리하고 있던 루트 목록을 참조해서 쓰레기 수집을 하기 때문이다.

가비지 컬렉터가 루트 목록을 이용해 쓰레기 객체를 정리하는 과정은 다음과 같다.

  1. 작업을 시작하기 전에, 가비지 컬렉터는 모든 객체가 쓰레기라고 가정한다. 즉, 루트 목록 내의 어떤 루트도 메모리를 가리키지 않는다고 가정한다.
  2. 루트 목록을 순회하면서 각 루트가 참조하고 있는 힙 객체와의 관계 여부를 조사한다. 만약 루트가 참조하고 있는 힙의 객체가 또 다른 힙 객체를 참조하고 있다면 이 역시도 해당 루트와 관계가 있는 것으로 판단한다. 이 때 어떤 루트와도 관계가 없는 힙의 객체들은 쓰레기로 간주된다.
  3. 쓰레기 객체가 차지하고 있던 메모리는 이제 '비어 있는 공간'이 된다.
  4. 루트 목록에 대한 조사가 끝나면, 가비지 컬렉터는 이제 힙을 순회하면서 쓰레기가 차지하고 있떤 '비어 있는 공간'에 쓰레기의 인접 객체들을 이동시켜 차곡차곡 채워 넣는다. 모든 객체의 이동이 끝나면 깨끗한 상태의 메모리를 얻게 된다.

세대별 가비지 컬렉션

CRL의 메모리는 구역을 나눠 메모리에서 빨리 해제될 객체와 오래도록 살아남을 것 같은 객체들을 따로 담아 관리한다.

CLR은 메모리를 0, 1, 2의 3개 세대로 나누고 0세대에는 빨리 사라질 것으로 예상되는 객체들을, 2세대에는 오랫동안 살아남을 것으로 예상되는 객체들을 위치시킨다.
0세대에는 가비지 컬렉션을 한번도 겪지 않은 '갓 생성된' 객체들이 위치하고 2세대에는 최소 2회에서 수차례동안 가비지 컬렉션을 겪고도 살아남은 객체들이 위치한다.

2세대 가비지 컬렉션이 일어나면 0세대와 1세대 모두에 대해서도 가비지 컬렉션이 수행된다. 힙의 각 세대는 2세대 < 1세대 < 0세대 순으로 가비지 컬렉션 빈도가 높다. 이 때문에 2세대의 객체들은 오랫동안 살아남을 확률이 높고, 따라서 가비지 컬렉터도 상대적으로 관심을 덜 주는 편이다. 반면 0세대의 경우, 새롭게 할당되는 객체들은 모두 이 곳에 할당되는 데다가 빠르게 포화되기 때문에 가비지 컬렉터가 자주 방문하게 된다. 1세대의 경우는 2세대외 0세대의 가운데 있으니 가비지 컬렉터의 활약 빈도도 딱 그 정도 수준이다.

한편, 아무리 가비지 컬렉션이 프로그래머로 하여금 컴퓨터의 메모리 용량이 무한대라고 간주하게 하는 것이 존재의 이ㅠ라지만, 생명력이 강한 객체들을 애플리케이션 위에 계속 생성해 놓으면 얼마 가지 않아 2세대 힙이 가득 차게 될 것이다. 이 때 CLR은 애플리케이션의 실행을 "잠시 멈추고" 전체 가비지 컬렉션(FULL GC)을 수행함으로써 여유 메모리를 확보하려 든다. CLR이 FULL GC를 할때는 0세대부터 2세대까지의 메모리 전체에 걸쳐 쓰레기를 수집하는데, 애플리케이션이 차지하고 있던 메모리가 크면 클수록 FULL GC 시간이 길어지므로 애플리케이션이 정지하는 수긴도 그만큼 늘어나게 되는 문제가 발생한다. 이 문제는 가비지 컬렉션을 이해해야 하는 중요한 이유 중 하나이기도 하다.


CLR의 가비지 컬렉션 메커니즘에 근거한 효율적인 코드 작성을 위한 지침

  • 객체를 너무 많이 할당하지 말자.
  • 너무 큰 객체 할당을 피하자.
  • 너무 복잡한 참조 관계는 만들지 말자.
  • 루트를 너무 많이 만들지 말자.

* 객체를 너무 많이 할당하지 말자.

CLR의 객체 할당 속도가 빠르긴 하지만 너무 많은 수의 객체는 관리되는 합의 각 세대에 대해 메모리 포화를 초래하고, 이는 비번한 가비지 컬렉션을 부르는 결과를 낳는다. 단점의 효과가 장점이 주는 효과를 상쇄기키는 것이다.

물론 객체를 아예 할당하지 않고서는 코드를 작성할 수 없다. 이 지침은 다만 객체할당 코드를 작성할 때 꼭 필요한 객체인지와 필요 이상으로 많은 객체를 생성하는 코드가 아닌지의 여부를 고려하라는 뜻이다.

* 너무 큰 객체 할당을 피하자.

CLR은 보통 크기의 객체를 할당하는 힙과는 별도로 85KB 이상의 대형 객체를 할당하기 위한 "대형 객체 힙(LOH : Large Object Heap)"을 따로 유지한다. 평소에 사용하는 힙은 대형 객체 힙에 대비되는 개념으로 소형 객체 힙(SOH : Small Object Heap)이라고 부르기도 한다.

커다란 객체를 소형 객체 힙에 할당한다면 0세대가 빠르게 차오르게 되므로 가비지 컬렉션을 보다 자주 촉발하게 되고, 이는 애플리케이션의 성능 저하를 불러온다. 이런 이유 때문에 CLR이 대형 객체 힙을 별도로 유지하는 것은 탁월한 결정으로 보이지만, 대형 객체 힙도 약점이 없는 것은 아니다.
우선 대형 객체 힙은 동작 방식이 소형 객체 힙과 다르다. 소형 객체 힙에서는 "다음 객체를 할당할 포인터"가 위치한 메모리에 바로 객체를 할당하지만, 대형 객체 힙은 객체의 크기를 계산한 뒤 그만한 여유 공간이 있는지 힙을 탐색하여 할당한다.

가비지 컬렉션을 수행하고 난 뒤에 소형 객체 힙은 해제된 메모리 공간에 인접 객체들을 끌어당겨 차곡차곡 정리하지만, 대형 객체 힙은 해제된 공간을 그대로 둔다. 수 MB~수백 MB에 이르는 메모리를 복사하는 비용이 너무 비싸기 때문이다. 이 공간은 나중에 다른 객체들에게 할당되겠지만, 메모리를 0바이트도 낭비 없이 사용하는 소형 객체 힙과는 달리 큰 공간을 군데군데 낭비하게 된다. 결국 대형 객체 힙은 할당 시의 성능뿐만 아니라 메모리 공간 효율도 소형 객체 힙에 비해 크게 떨어진다. 대형 객체 힙은 동작 방식도 C-Runtime과 비슷하고, 문제점도 역시 비슷하다.

문제는 또 있다. CLR이 LOH를 2세대 힙으로 간주하기 때문에 LOH에 있는 쓰레기 객체가 수거되려면 2세대에 대한 가비지 컬렉션이 수행되어야 한다. 2세대에 대한 가비지 컬렉션은 전 세대에 대한 가비지 컬렉션을 촉발하고 순간이나마 애플리케이션의 정지를 불러온다.

* 너무 복잡한 참조 관계는 만들지 말자.

이 지침은 가비지 컬렉션 성능이 아닌 코드 가독성을 위해서라도 따라야 한다. 다음 코드의 클래스 선언을 보자.

class A
{
    public C c;
}

class B
{
    public A a;
}

class C
{
    public A a;
    public B[] b;
}

class D
{
    public A a;
    public B b;
    public C c;
}

이렇게 참조 관계가 많은 객체는 가비지 컬렉션 후에 살아남았을 때가 문제이다. 가비지 컬렉터는 가비지 컬렉션 후에 살아남은 객체의 세대를 옮기기 위해 메모리 복사를 수행한다. 이 때 참조 관계가 복잡한 객체의 경우에는단순히 메모리 복사를 하는 데서 끝나지 않는다. 객체를 구성하고 있는 각 필드 객체 간의 참조 관계를 일일이 조사해서 참조하고 있는 메모리 주소를 전부 수정한다. 클래스 구조를 간단하게 만들었다면 메모리 복사만으로 끝났을 일을 탐색과 수정까지 끌어들이게 되는 것이다.

참조 관계가 복잡한 객체가 갖고 있는 문제는 또 있다. 위 예제 코드의 D 클래스를 예로 들어 보자. D 클래스 자체는 생성된지 오래되어 2세대에 존재하는데, A형식의 필드 a를 새로 생성한 객체로 업데이트됐다고 가정해보자. 이 경우 D의 인스턴스는 2세대에 실고 있고 a 필드가 참조하고 있는 메모리는 0세대에 위치한다. 이 때 루트를 갖고 있지 않은 a는 0세대 가비지 컬렉션에 의해 수거될 위험에 노출된다. 
이 때 쓰기 장벽(Write Barrier) 이라는 장치를 통해 가비지 컬렉터로 하여금 a 필드가 루트를 갖고 있는 것으로 간주하게 해서 0세대 가비지 컬렉션을 모면하게 해준다.
쓰기 장벽을 생성하는 데 드는 오버 헤드가 크다. 참조 관계를 최소한으로 만들면 이런 오버헤드도 줄일 수 있다.

* 루트를 너무 많이 만들지 말자.

가비지 컬렉터는 루트 목록을 돌면서 쓰레기를 찾아낸다. 루트 목록이 작아진다면 그만큼 가비지 컬렉터가 검사를 수행하는 횟수가 줄어드므로 더 빨리 가비지 컬렉션을 끝낼 수 있다. 따라서 루트를 가급적 많이 만들지 않는 것이 성능에 유리하다. 이것은 필요 이상으로 객체를 만들지 말라는 지침만큼이나 기본적인 지침이다.

Comments