이번 글은 최근 포스팅 중인 닷넷 가비지 컬렉션 시리즈의 네 번째 글입니다. 첫 번째 글에서는 가비지 컬렉션의 기본 작동 원리를 살펴보았고 두 번째 글에서는 세대별 가비지 컬렉션을 살펴보았습니다. 그리고 세 번째 글에서는 LOH(Large Object Heap)에 대해 살펴보았습니다. 이번 포스트에는 가비지 컬렉션이 언제 발생하는가에 대해 살펴보도록 하겠습니다. 이 내용은 원본 글에 언급되지 않은 내용이므로 이미 원본 글을 다 읽어본 독자들에게도 유용한 정보가 되리라 믿습니다. (아니면 말고… ㅡ,.ㅡ)

가비지 컬렉션은 언제 발생하는가?

닷넷의 가비지 컬렉션이 언제 발생하는지 미리 예측할 수 있는 사람이 있다면 지금 당장 대학로에서 돗자리를 깔면 떼돈을 벌 것이다. 그만큼 가지지 컬렉션이 발생하는 시점은 미리 예측하기 어렵다는 말이 되겠다. 기본적으로 가비지 컬렉션은 어플리케이션이 어떻게 메모리를 사용하는가에 따라서 다른 주기로 발생하며 가비지 컬렉션에 소요되는 비용(CPU 점유율)도 다르다. 기본적으로 가비지 컬렉션은 다음 상황에서 발생한다고 말할 수 있다.

  • 메모리 할당이 일정 임계치(threshold)를 넘어 섰을 때
  • GC.Collect 메서드를 명시적으로 호출했을 때
  • 시스템이 메모리 부족현상을 겪고 있을 때

Excessive Allocation

메모리 할당이 일정 임계치를 넘어섰을 때 가비지 컬렉션이 발생한다는 것에 대해 조금 더 상세히 이야기 하자면 이렇다.

닷넷의 관리되는 힙 (managed heap)은 세대별로 나누어 진다는 것은 지난 포스트에서 무쟈게 많이 설명한 바 있다(아직 지난 포스트를 읽어 보지 못한 독자라면 후딱 링크를 클릭해 보아야 필자가 지금부터 이야기할 내용을 이해할 수 있다). Gen 0, Gen 1, Gen 2 각 세대별 힙과 LOH(Large Object Heap)는 각각 버짓(budget)이라 불리는 할당 임계치를 가지고 있다. 이 버짓을 초과하는 메모리 할당이 발생하면 해당 세대의 가비지 컬렉션이 발생하게 되는 것이다.  버짓은 고정된 값이 아니라 메모리 상황에 따라서, 그리고 가비지 컬렉션이 수행됨에 따라서 지속적으로 변화되고 튜닝 되는 값이다. 뜬금없이 GC.Collect를 호출하지 말라는 이유 중에 하나가 바로 여기에 있다. GC.Collect를 명시적으로 호출했을 때의 더 나쁜 점은 나중에 또 언급할 것이다.

0 세대 힙과 LOH은 사용자가 직접 new 연산자나 Activator.CreateInstance 메서드 호출에 의해 객체를 생성하고 메모리를 할당하는 힙이 되겠다. 따라서 0 세대의 버짓 혹은 LOH의 버짓을 초과하는 어플리케이션의 메모리 할당은 가비지 컬렉션을 유발하게 된다. 0 세대 버짓 소진으로는 0세대 가비지 컬렉션, 즉 GC 0이 발생되고 LOH의 버짓 소진은 2세대 가비지 컬렉션(full garbage collection이라고도 부른다)이 발생한다.

GC 0가 수행됨에 따라 0 세대 힙에서 살아남은 객체들은 1세대 힙으로 프로모션(promotion)되게 된다는 것은 지난 포스트에서 설명한 대로이다. 이 때 가비지 컬렉터는 1세대로 프로모션 된 객체들이 1세대 힙의 버짓을 초과하는지 판단한다. 만약 프로모션 된 객체들이 1세대 힙의 버짓을 초과하면 1세대에 대한 가비지 컬렉션, 즉 GC 1이 수행된다. 비슷한 방식으로 1세대에서 2세대로 프로모션 된 객체들이 GC 2의 버짓을 소모하면 2세대 가비지 컬렉션이 발생할 수도 있다.

GC.Collect 메서드 호출

개발자는 GC 클래스의 Collect 메서드 호출을 통해 가비지 컬렉터가 즉시 가비지 컬렉션을 수행하도록 지시할 수 있다. GC.Collect 메서드는 매개변수로 가비지 컬렉션을 수행할 세대를 명시할 수 있는데, 0, 1, 2 값만이 현재에는 유효하다. GC.Collect 메서드에 매개변수를 명시하지 않으면 2세대 가비지 컬렉션(0, 1세대 가비지 컬렉션 포함)이 발생함에 유념하자.

또, GC.Collect 메서드는 가비지 컬렉터가 스스로 지금 가비지 컬렉션을 수행할지 말지를 결정하도록 제어할 수도 있다. 예를 들어, 다음과 같은 코드는 2세대 가비지 컬렉션을 수행할 수도 그렇지 않을 수도 있다.

GC.Collect(2, GCCollectionMode.Optimized);

GC.Collect 메서드를 명시적으로 호출하라는 글은 구글신이나 심지어 네이뇬에게 물어보아도 찾아 볼 수 없을 것이다. 그만큼 어플리케이션이 직접 GC.Collect 메서드를 호출해서 남는 것이 없기 때문이다. 왜 그러한지는 조금 있다가 몰아서 상세히 설명하도록 하겠다.

시스템 메모리 부족 상황

Windows 운영체제는 시스템 전체적으로 메모리가 부족하면 메모리 확보를 위해 페이지 아웃, 스와핑(니가 생각하는 그 스와핑 말고 스왑 아웃말야……) 등 여러 가지 작업을 수행한다. 그 중 하나가 프로세스들에게 메모리 부족 상황을 알리는 것이다. CLR은 메모리 부족 상황이 되면, 일반적인 상황보다 적극적으로 가비지 컬렉션을 수행한다. 다시 말해 0, 1 세대 가비지 컬렉션은 물론이요 2 세대 가비지 컬렉션도 보다 자주 발생할 수 있다는 말이 되겠다.

가비지 컬렉션 발생 주기 모니터링

지금까지 충분한(?) 배경 지식을 쌓았으니 가비지 컬렉션이 언제 발생하는가 모니터링 하는 방법을 살펴보도록 하자. 가장 쉬운 방법은 주기적으로 (예를 들어 1초마다) GC.CollectionCount를 호출하는 것이다. 이 방법은 윈폼(winform) 어플리케이션과 같이 UI를 통해 가비지 컬렉션 주기를 표시할 수 있는 경우에나 가능할 뿐 ASP.NET 과 같은 서버 어플리케이션에서 사용하기엔 초큼 거시기 하다. 게다가 이 메서드는 다른 프로세스의 가비지 컬렉션 상황은 알 수 없다.

그 다음으로 손 쉬운 방법은 성능 모니터(perfmon.exe)를 사용하는 것이다. 성능 모니터를 구동하고 성능 카운터 카테고리 중에서 .NET CLR Memory를 선택한다. 그리고 나서 # Gen 0 Collections, # Gen 1 Collections, # Gen 2 Collections 카운터를 사용하면 된다. 물론, 카운터 인스턴스로는 감시하고자 하는 프로세스를 선택하자. 웹 어플리케이션이라면 w3wp 으로 시작하는 인스턴스를 선택하면 된다. 이들은 각각 CLR이 시작된 이래로 수행된 0세대, 1세대, 2세대 가비지 컬렉션의 총 회수를 나타낸다. 이 카운터 외에도 Induced GC 카운터도 가비지 컬렉션 발생 회수와 관계가 있다. 이 카운터는 GC.Collect 에 의해 수행된 가비지 컬렉션 회수를 나타낸다.

다음은 글로 만으로는 도무지 따라 하기 힘들어 하는 독자들을 위한 서비스 화면 캡처 되겠다.

PerfMon Add Counter
그림1. 가비지 컬렉션 수행 감시를 위한 성능 카운터 추가

.NET CLR Memory 카테고리의 카운터들은 이 외에도 닷넷 어플리케이션의 메모리 상황을 감시하거나 메모리 문제를 파악(해결이 아니다)하는데 아주 도움이 되는 많은 카운터들을 가지고 있다. 여기서 그 카운터들을 죄다 설명하지 않을 것이다. 앞으로 이 시리즈의 글에서 필요하다면 이들 카운터 값들을 언급하도록 하겠다.

어찌 되었건 이렇게 카운터들을 추가하면 카운터 들의 변화 값을 관찰할 수 있다. 관찰하고자 하는 카운터들의 경우 가비지 컬렉션이 발생하는 회수이므로 그림2와 같이 그래프 스타일을 “보고서” 형식으로 보는 것이 더 편리하다. 만약 오랜 시간 동안 가비지 컬렉션이 수행되는 주기를 관찰하고자 한다면 그래프 형식이 더 좋을 수도 있다. (이런 걸로 시비 걸지 말기 바란다… 험험…)

image
그림2. 가비지 컬렉션 발생 회수 관찰

그림2는 로컬 컴퓨터의 IIS 작업 프로세스인 w3wp.exe의 가비지 컬렉션 상황을 보여주고 있다. 관찰 대상인 w3wp.exe 프로세스는 프로세스가 시작된 이후로 3회의 0세대 가비지 컬렉션과 1회의 1 세대 가비지 컬렉션이 발생했으며 2세대 가비지 컬렉션은 아직 발생되지 않았다. 따라서 w3wp.exe 프로세스에서 발생한 가비지 컬렉션은 총 3회이다. 이중 1번이 1세대 가비지 컬렉션(0 세대를 포함하고 있음 잊지 말자)이기 때문에 4회가 아닌 3회인 것이다. 또 그림2에서 재미있는 것은 누군가가 GC.Collect 메서드를 한번 호출했다는 것을 # Induced GC 카운터를 통해 알 수 있다. 2세대 가비지 컬렉션이 발생하지 않은 것으로 보아 GC.Collect(0) 혹은 GC.Collect(1)을 호출한 것으로 파악된다. ASP.NET 엔진이 호출한 것으로 보이는 GC.Collect는 무조건 나쁘다고 볼 수 없다. (유명한 모 닷넷 사이트에서 본 가비지 컬렉션 글은 GC.Collect가 매개변수가 없는 것처럼 이야기 하드라… 쪽 팔리게… –_-;) 0, 1 세대에 대한 가비지 컬렉션은 무척 빠르기 때문에 ASP.NET 엔진이 적당하다고 판단하여 호출한 것으로 보인다.

가비지 컬렉션 주기에 대한 이해

가비지 컬렉션이 언제 발생하는지 살펴보았고 실제로 어플리케이션이 어떠한 주기로 가비지 컬렉션을 수행하는지 감시하는 방법도 살펴보았으니, 이제 관찰한 가비지 컬렉션 주기를 어떻게 이해할 것인지 생각해 보자. 지금까지 장황하게 구라를 친 이유가 바로 이 말이 하고 싶어서이다.

가비지 컬렉션은 앞서 설명한대로 3가지 경우에 발생한다.  가장 많은 상황은 잦은 메모리 할당에 의해 가비지 컬렉션이 발생한다. 시스템 전반에 걸쳐 메모리가 부족하지 않은 상황에서 리소스 모니터에서 가비지 컬렉션이 자주 발생하는 것이 관찰 되면 일단 어플리케이션 코드가 많은 메모리를 할당하지 않는지 우선 의심해 보아야 한다. 물론, GC.Collect 호출이 자주 발생하는지도 살펴보아야 하겠지만 그 따위 코드를 작성할  미친 쉑은 없을 것이다.

일반적으로 가비지 컬렉션의 주기는 길면 길 수록 좋다. 가비지 컬렉션이 발생하지 않는다는 얘기는 그만큼 메모리 할당 회수(메모리 사용량)이 적다는 얘기이기 때문이다. 가비지 컬렉션 주기가 길면 좋은 또 다른 이유는 할당된 객체들이 0세대에서 1세대, 1세대에서 2세대로 나이를 먹는데 시간이 오래 걸린다는 얘기와도 같다(뭔 말인지 이해가 안 된다면, 일단 숏을 잡고 10초간 반성한 후에 필자의 글을 읽어 보기 바란다). 가비지 컬렉션이 자주 발생하면 그만큼 객체들이 빠르게 늙어버려 2세대 힙으로 프로모션 되어 버릴 것이다. 2세대 힙의 크기가 크다면 2세대에 대한 가비지 컬렉션은 무시 못할 정도의 CPU의 자원을 소모함을 잊지 말자.

GC.Collect를 호출하지 말라는 이유가 바로 여기에 있다. CLR은 메모리 할당이 충분하게 이루어진 후에라야 가비지 컬렉션을 수행하는데, GC.Collect가 호출되면 곧바로 가비지 컬렉션을 수행하게 되고 쓰잘데기 없이 객체들이 나이를 쳐먹게 되는 것이다.

일반적으로 10회의 0 세대 가비지 컬렉션 발생 후에 1세대 가비지 컬렉션이 발생하고 10회의 1 세대 가비지 컬렉션 발생 후에 1회의 2세대 가비지 컬렉션이 발생하면 건강한 닷넷 어플리케이션으로 볼 수 있다. 다시 말해 100회의 0 세대 가비지 컬렉션 후에라야 2세대 가비지 컬렉션이 발생하면 좋다는 것이다. 성능 모니터를 통해 어플리케이션을 오랫동안 관찰한 후에 0 세대, 1세대, 2 세대 가비지 컬렉션 발생 회수를 비교하여 상대적으로 0 세대에 비해 1, 2세대 가비지 컬렉션이 자주 발생하면 어플리케이션의 메모리 할당 속도에 의심을 가져보아야 한다.

어플리케이션에 문제가 없어도 가비지 컬렉션은 잦은 주기로 발생할 수도 있다. 앞서 가비지 컬렉션이 발생하는 상황을 설명할 때 언급했듯이 시스템이 전반적인 메모리 부족 상황에 다다르면 CLR은 보다 적극적으로 가비지 컬렉션을 수행한다. 이 경우, 2세대 가비지 컬렉션이 보통 때보다 자주 발생할 수도 있다. 따라서 시스템 전체의 메모리 사용량을 염두 하면서 가비지 컬렉션이 발생하는 주기를 살펴보아야 한다.

만약 어플리케이션이 사용하는 메모리가 꾸준히 증가하지 않으면서 가비지 컬렉션이 자주 발생한다면 잠깐 사용하고 버리는 임시 객체를 많이 생성할 가능성이 매우 높다. 필자가 몇 년 전 모 보험 회사의 성능 튜닝에 들어갔을 때 보았던 코드는 수 백 개의 테이블을 가진 Typed DataSet이 문제의 원인이었다. 이 데이터 셋은 단순히 new 하는 것만으로도 64MB 정도의 메모리를 쳐먹었으며 이 메모리를 할당하는 동안 몇 차례의 가비지 컬렉션이 발생할 정도였다. 그리고 더 웃긴 것은, 이 뷩신 같은 데이터 셋을 테이블 1-2개 조회하는데 잠깐 사용하고 버려버리는 코드들이 발견 되었다. 이로 인해 0, 1, 2세대 가비지 컬렉션이 미친 듯이 발생했고 CPU는 가비지 컬렉션만 수행하다 볼일을 다 보고 웹 사이트는 무지하게 느려진 것이다. (이런 미친 코드를 작성한 SI 업체는 어디론가 도망가 버렸고 그 보험 회사는 닷넷 욕을 엄청 해 댔으며, 6개월 후 시스템 개편 때 미련 없이 이 어플리케이션을 자바로 갈아치워 버렸다!)

어떤 웹 서버의 w3wp.exe 프로세스가 메모리를 수백 MB(필자는 2MB가 싫다) 쳐먹고 있는데 이거 문제 아닌가 생각하기 전에 얼마나 자주 가비지 컬렉션이 발생하는지를 살펴볼 필요가 있다. 무조건 메모리를 많이 먹는 것이 나쁘지 않을 수 있다는 것이다. 가비지 컬렉션이 자주 발생하지 않는다면 시스템에 그만큼 충분한 메모리가 존재한다는 얘기이며 어플리케이션이 메모리 할당을 많이 하지 않는다는 말도 된다.

마지막으로 LOH에 대해서는 약간 주의를 할 필요가 있다. 85000 바이트 이상의 메모리 할당이 잦으면 LOH의 크기가 늘어나고 가비지 컬렉션이 발생하게 된다. 그런데 이 가비지 컬렉션은 항상 2 세대 가비지 컬렉션이기 때문에 0세대, 1세대에 대한 가비지 컬렉션도 동반된다. 이 경우, 젊은(할당된 지 얼마 되지 않은) 객체들이 아직 나이 먹을 시기도 되지 않았는데 공짜로 나이를 더 쳐먹게 된다. 뭔 말 인고 하니, 0세대 힙이 아직 충분한 버짓을 가지고 있어서 아직 가비지 컬렉션을 하지 않아도 되는데도 불구하고 LOH에 의한 가비지 컬렉션이 수행되어 객체들이 0세대에서 1세대로, 1세대에서 2세대로 프로모션 될 수 있다는 이야기이다. LOH에 대한 글에서도 언급한 바 있지만 커다란 객체를 임시적으로 만들었다가 버려버리는 것은 어플리케이션의 메모리 효율에 좋지 못한 영향을 줄 수 있다는 점을 잊지 말자.

결론

졸라 어려워 보이는 이번 글을 정리 해보자. 가비지 컬렉션은 사용자 코드의 메모리 할당(SOH 혹은 LOH)과 GC.Collect 호출 그리고 시스템 메모리 부족 상황에서 발생한다. 일반적으로 GC.Collect 메서드를 호출하는 코드가 없고, 시스템이 전반적으로 물리 메모리가 부족한 상황이 아니라면 가비지 컬렉션은 오로지 어플리케이션의 메모리 할당에 의해서만 발생한다.

가비지 컬렉션이 자주 발생하면 객체들이 빠르게 나이를 먹고 2세대 힙으로 진입하기 때문에 2세대 힙의 크기가 커지고 2세대 힙에 대한 가비지 컬렉션은 상당한 자원을 소모하므로 어플리케이션 성능에 좋지 못한 영향을 줄 수 있다. 따라서 불필요한 객체 할당이나 쓸데 없는 GC.Collect 호출은 최대한 피해야 하며, 특히 LOH에 대한 과도한 메모리 할당은 매우 부정적인 효과를 유발할 수 있으므로 코드에 상당한 주의가 필요하다.

다음 글은…

다음 글은 CLR의 내부 가비지 컬렉션 기법인 Concurrent GC, Background GC 그리고 Server GC에 대해 살펴보도록 하겠다. 세대별 가비지 컬렉션 기법과 더불어 CLR은 이들 가비지 컬렉션 기법을 통해 어플리케이션의 성능이 가비지 컬렉션의 영향을 최대한 적게 받도록 해준다. 특히, Background GC는 닷넷 프레임워크 4.0에 새로이 등장한 기능이기도 하다. 기대하는 사람은 별로 없겠지만 그래도 필자가 계획한 대로 글은 계속될 것이다. 뭐, 많은 블로그들이 그러하듯이 필자가 블로그 질을 하는 이유 중 하나가 자기 만족이기 때문에……


경고 : 이 글을 무단으로 복제/스크랩하여 타 게시판, 블로그에 게시하는 것은 허용하지 않습니다.