쓰레기 수집

Garbage Collection, GC. 영어를 그대로 읽어 가비지 컬렉션이라고도 부른다.

1 개요

메모리 관리 방법 중 하나로, 프로그래머가 동적으로 할당한 메모리 영역 중 더 이상 쓰이지 않는 영역을 자동으로 찾아내어 해제하는 기능이다. 존 매카시가 1959년에 LISP의 메모리 관리를 위해 처음 만들었다고 알려져 있다.

옛날의 언어들은 FORTRAN이나 BASIC처럼 동적인 메모리 할당 기능이 아예 없거나(...)[1] C처럼 프로그래머가 할당한 뒤 수동으로 해제까지 해 줘야 하는 방식이었는데, 사람이 하는 일이 항상 완벽할 수는 없는지라 메모리를 할당해놓고 필요없어진 뒤에도 해제를 안 해서 메모리 누수가 생기거나, 혹은 거꾸로 해제했던 메모리를 실수로 다시 사용하거나, 해제했던 메모리를 또 해제한다거나 하는 온갖 실수가 일어나 수많은 버그가 양산되곤 했다. 더욱이, 일반적으로 버그는 재현가능하고 오류가 있는 부분으로부터 가까운 곳에서 터져야 잡기 쉬운데, 메모리 관련 버그는 한참 떨어진 곳에서 터지는 데다가 재현불가능한 경우도 있어서 프로그래머에게 지옥같은 디버깅을 선사해준다.

쓰레기 수집을 지원하는 환경에서는 프로그래머가 직접 메모리를 접근하지 못하게 막는 대신 쓰레기 수집기(garbage collector)가 관리할 수 있는 방법으로 메모리 영역을 할당해준다. 그리고 새 메모리 영역을 할당해줄 수 없을 정도로 메모리를 많이 사용했다고 판단되면 쓰레기 수집기가 작동해서 쓰이지 않는 메모리 영역을 전부 찾아 해제하게 된다. 이 때, 쓰레기 수집기가 메모리를 임의로 정리해버릴 수 있기 때문에 쓰레기 수집기가 동작하는 동안에는 보통 프로그램 실행이 일시 중지되며, 쓰레기 수집기의 성능이 좋지 못하거나 탐색해야 할 메모리 영역이 너무 많거나 하는 경우에는 프로그램이 눈에 띌 정도로 오래 멈춰 있을 수도 있다.

한편 꼭 필요한 경우 완전한 비동기 GC를 만드는 것도 가능하기는 하다. Erlang의 가상레지스터인 BEAM의 경우가 그러한데, 메모리를 마이크로 프로세스라는 작업 단위로 쪼개서 할당하고 각 마이크로 프로세스 사이에 공유메모리를 엄격하게 통제하고 모든 변수에 불변성을 주어서 관리비용을 최대한 줄인 결과 하드웨어적인 가용자원만 확보되면 GC의 작동이 프로그램을 중단시키는 일이 없어지도록 만들었다. 다만 이건 매우 극단적인 경우로 GC로 인한 속도변화가 없어지는 대신 전체적인 속도에서 손해를 본다[2]. 결국 GC자체는 어떻게 만들어도 비교적 비싼작업이라는 소리다.

이것은 쓰레기 수집 방식의 가장 큰 단점 중 하나였으나, 요즘은 쓰레기 수집기의 성능이 많이 좋아지고, 컴퓨터 성능은 그것보다 훨씬 많이 좋아졌으며, 결정적으로 쓰레기 수집이 되면 프로그래밍하기가 훨씬 쉬워지고 버그 발생률도 낮아지기 때문에 최근에 등장하는 언어들은 거의 대부분 쓰레기 수집 기능을 기본으로 탑재하고 나온다.

JavaC#이 언어 및 가상머신 차원에서 쓰레기 수집을 지원하며, Python이나 Ruby, Perl 등의 스크립트 언어들도 대부분 지원한다. OCaml, Go 등의 언어는 네이티브 언어이지만 쓰레기 수집 기능을 사용한다.

2 동작 원리 기초

(가장 많이 알려진 자바 가상 머신 기준으로 서술)

단순하게 생각하면 쓰레기 수집기가 메모리 영역 사용을 감시하고 있다가 안전을 위해 강제로 프로그램을 모두 멈추고, 더 이상 쓰지 않는 메모리를 모두 추적해서 확보한 후 프로그램 실행을 다시 시작하면 될 것 같아 보인다.그러나 이렇게 하면 당연히 사용자는 뭔가 하다가 불규칙하게, 뜬금없이 프로그램이 뚝뚝 끊긴다고 느낄 수밖에 없다. 덕분에 자바는 등장한 초기에 성능이 영 아니라고 신나게 욕을 먹었다.

이후 똑똑한 사람들이 각종 프로그램의 메모리 사용 패턴을 관찰해보니, 객체에 메모리를 할당해서 더 안 쓰는 쓰레기가 될 때까지 걸리는 시간을 추적했을 때 대부분의 객체는 잠깐 쓰고 금세 버려지며, 반대로 오래 살아남아서 쓰이는 경우는 그렇게 많지 않다는 경향이 발견되었다.

따라서 위 경향에 맞추어 잠깐 쓰고 사라져도 되는 객체를 상대적으로 크기가 작은 New 영역에 할당하고, New 영역에서 기준 시간 이상으로 오래 살아남은 객체가 있다면 Old 영역으로 이동시켜 "세대" 구분을 하는 방법이 사용되고 있다. 대부분의 쓰레기는 New 영역에서 발생하므로, 상대적으로 작은 영역만 추적하면 적은 시간과 비용만 들여서 필요한 메모리를 짧은 시간 안에 확보할 수 있는 것이다.

다른 방식으로는 각 객체를 참조하는 횟수를 카운팅하여 0이 될 경우 수집하는 방식이 있다. 변수에 새로운 객체를 참조하게 하여 기존의 객체를 참조하지 않거나 참조중인 변수가 파괴되었을 때에 해당하는데 카운팅이 0이 되었다고 해서 곧바로 수집하지는 않고, 수집대상이 되는 메모리 공간이 일정 수준 이상이 되거나 강제 수집을 지시했을 때 일괄적으로 수집하는 것이다.

3 가비지 컬렉터의 한계

하지만 위에서 언급한 "세대" 개념을 도입한 쓰레기 수집기에도 한계는 있다. 대부분의 메모리 할당 요청은 New 영역에서 처리되므로 금세 처리 가능하겠지만, 만약 메모리 사용량이 많아지다가 Old 영역까지 꽉 차게 차면 위에 적힌 것처럼 모든 메모리 영역을 전부 뒤져야 하는 건 피할 수 없고, 반응성이 저하되는 문제도 피할 수 없다.

카운팅 개념의 쓰레기 수집기 또한 단점이 있다. 바로 할당만 해주고 이를 풀어주지 않으면 절대로 접근하지 않게 되는 공간이더라도 수집 대상에 포함되지 않는다는 것이다.[3] 무한루프로 동적할당을 요청하는 수준이 아니라면 큰 메모리를 잡아먹지는 않겠지만, 프로그래머의 입장에서는 그마저도 아깝게 느껴진다.

그리고 쓰레기 수집기를 쓰는 순간부터 성능 하락은 피할 수가 없다. 수집 목표공간이 높거나 수집 빈도수를 낮게 설정하면 수집이 진행되는 때에 극심한 성능 저하가 발생하며, 반대의 경우는 작지만 지속적인 성능 저하를 달고 지내게 된다.

또한 쓰레기 수집기가 존재하더라도 메모리 누수는 발생할 수 있다. 이는 쓰레기 수집기가 더 이상 접근이 불가능한 객체만 회수하기 때문이다. 설령 두 번 다시 사용하지 않는 객체라 할지라도, 프로그래머의 실수로 그 객체로 접근할 수 있는 경로가 하나라도 남게 되면 쓰레기 수집기는 객체를 사용할 가능성이 있다고 판단하고 회수하지 않는다. 게다가 만약 이러한 객체가 프로그램의 실행 도중 계속해서 누적되어 간다면, 프로그램은 메모리 부족으로 결국 뻗고 말 것이다. 이 문제는 근본적으로 쓰레기 수집기가 객체가 계속 사용될지 아닐지 스스로 판단할 수 없기 때문에, 객체에 접근할 수 있는 경로가 있을 경우 무조건 사용하는 객체로 간주해서 발생하는 문제다. 실제로 쓰레기 수집을 제공하는 언어를 사용한다 하더라도, 프로그램이 복잡해질수록 이러한 종류의 메모리 누수가 발생하는 것이 드물지 않다. 쓰레기 수집기가 많은 경우에 알아서 처리하긴 하지만 만능은 아니란 것.

4 쓰레기 수집이 적용된 프로그래밍 언어

나무위키에 항목이 작성된 프로그래밍 언어로 한정한다.

  1. 때문에 이런 언어들이 해 주는 메모리 관리란 그냥 전역변수 혹은 기껏해야 호출 스택에 의한 지역변수 관리 정도가 전부였다. 정 아쉬우면 BASIC의 PEEK/POKE 명령 같은 걸로 메모리 할당도 프로그래머가 직접 하던지 (......)
  2. 만드는 것도 어렵다. 외계인 고문하던 리즈시절 에릭슨에서 수년간 개발해서 겨우 상용화한것
  3. 접근해서 값을 읽거나 쓰는 동작이 일어나지 않더라도 일단은 이 주소를 참조하는 변수가 있으므로 카운팅은 여전히 0이 아니게 된다.