모든 이야기

가비지 컬렉션: 사용하지 않는 메모리는 어떻게 알아낼까

프로그램이 객체를 만들 때마다 개발자가 정확한 순간에 메모리를 해제하도록 요구하면 실수하기 쉽다. 너무 일찍 해제하면 유효하지 않은 메모리를 사용하고, 너무 늦으면 공간을 낭비한다. 가비지 컬렉션은 더 이상 사용할 수 없는 객체를 찾아 메모리를 회수하는 자동 관리 방식이다. 여기서 핵심 기준은 사람이 필요 없다고 느끼는지가 아니라 실행 중 객체에 도달할 수 있는지다.

루트에서 도달하는 A와 B는 유지되고, 서로만 참조하는 X와 Y의 순환은 회수 대상으로 분리된 객체 그래프

추적 GC는 루트에서의 도달 가능성으로 객체의 생존 여부를 판단한다.

시작점에서 연결을 따라간다

추적 방식의 수집기는 루트에서 출발한다. 루트는 실행 중인 스택, 레지스터, 전역 변수 등에서 참조하는 값으로 구성되며 구체적인 범위는 실행기마다 다르다. 루트가 A를 가리키고 A가 B를 가리키면 B도 살아 있는 것으로 본다. 루트에서 어떤 참조 경로로도 도달할 수 없는 객체는 프로그램이 다시 사용할 수 없으므로 회수 후보가 된다.

단순한 표시 후 정리 방식은 도달 가능한 객체에 표시를 하고 나머지를 회수한다. 복사 수집기는 살아 있는 객체를 다른 영역으로 옮기며 공간을 정리할 수 있다. 이때 이동한 객체를 가리키는 참조도 올바르게 갱신해야 한다. 세대별 방식은 많은 객체가 짧게 살아남는 경향을 활용해 새 객체를 자주, 오래 살아남은 객체는 다른 정책으로 처리한다. 이는 모든 프로그램에 같은 비율로 적용되는 법칙은 아니다.

서로 가리킨다고 살아 있는 것은 아니다

객체 X와 Y가 서로를 참조하지만 루트에서 둘 중 어느 것도 가리키지 않는다고 하자. 추적 수집기는 이 순환 전체에 도달할 수 없음을 확인할 수 있다. 단순 참조 계수만 보면 각 객체가 다른 객체의 참조를 하나씩 받아 계수가 0이 되지 않아 회수하지 못할 수 있다. 이런 경우 순환을 처리하는 보조 알고리즘이 필요하다.

Python의 gc 문서는 CPython에서 순환 참조 수집이 참조 계수 방식을 보완한다고 설명한다. 따라서 Python의 메모리 관리나 일반적인 GC를 한 알고리즘으로만 설명해서는 안 된다. 구현과 버전에 따라 객체 이동 여부, 세대 구성, 수집 시점이 달라진다. 프로그램이 어떤 실행기에서 동작하는지 확인해야 비용과 해제 시점을 판단할 수 있다.

자동 관리에도 메모리 누수는 생긴다

전역 캐시에 사용이 끝난 결과를 계속 저장하면 루트에서 도달 가능한 상태가 유지된다. 수집기는 개발자의 의도를 모르므로 이를 함부로 회수하지 않는다. 이벤트 구독, 오래 남은 클로저, 무제한 캐시는 대표적인 참조 유지 원인이다. GC가 있는 언어에서의 누수 진단은 해제 호출이 빠졌는지만 찾기보다 객체를 누가 계속 붙잡고 있는지 추적하는 작업이 된다.

수집에는 CPU 시간과 추가 메모리, 일시적인 중단 비용이 든다. 일부 방식은 프로그램을 잠시 멈추고 전체 상태를 확인하며, 동시·점진 방식은 작업을 나누거나 함께 수행하는 대신 쓰기 장벽 같은 관리 비용을 더한다. 처리량, 최대 지연, 사용 공간 사이에 선택이 생긴다. 힙을 크게 잡으면 무조건 모든 성능 지표가 좋아진다고 단정할 수 없다.

파일, 소켓, 잠금 같은 외부 자원은 메모리와 별도로 생각해야 한다. 객체 회수가 언제 일어날지 기다리면 자원 반환이 늦어질 수 있으므로 명시적 종료나 범위 기반 정리 기능을 사용하는 편이 적절하다. GC는 메모리 수명을 관리하는 강력한 장치지만 자원 관리와 참조 설계의 책임까지 없애지는 않는다. 살아 있는 경로를 그려 보는 습관이 자동 관리 언어에서도 중요한 이유다.

참고 자료

가비지 컬렉션: 사용하지 않는 메모리는 어떻게 알아낼까 · ariser