프로그램에 있어 동적 할당은 매우 중요한 부분을 차지한다. 단순히 함수 내부에서 스택 구조에 의해 할당되는 지역 변수와는 달리, 클래스의 인스턴스들은 힙 영역 안의 한구석에 자리를 잡으며 자신의 직책을 묵묵히 수행한다. 그리고 그들의 임무가 끝나면. 프로그래머가 안내하는 코드에 의해 힙 영역을 떠나게 된다.
하지만, 프로세스 안에서 할당된 수없이 많은 인스턴스들을 모두 추적해 원하는 시점에 해제를 하기란 매우 어려운 법이다. 게다가 사소한 실수로 인해 인스턴스들이 손아귀 밖으로 벗어났을 땐, 메모리 누수라는 거대한 눈덩이가 되어 프로세스를 덮치기 일쑤였다.
그래서, 여러 컴퓨터 학자들은 메모리를 프로그래머가 신경 쓰지 않아도 손쉽게 관리하는 방법을 고안하고, 마침내 가비지 컬렉션(쓰레기 수집)이라는 해결책을 떠올리게 되었다.
가비지 컬렉션 (Garbage Collection)
가비지 컬렉션은 무엇인가? 이 단어가 의미하는 바로는 쓰레기 수집인데, 자신의 임무가 끝나 더 이상 프로세스에서 요구하지 않는 메모리를 쓰레기라고 정의하며, 수집이라는 단어에 걸맞게 쓰레기 메모리의 해제를 내부적으로 수행(가비지 컬렉터)하는 메모리 관리법이다. 1959년 존 매커시에 의해 탄생됐고, LISP과 C#, JAVA 등의 언어가 이 메모리 관리 방식을 채택하고 있다.
이 덕분에 메모리를 할당할 때 어떻게 회수할지에 대한 프로그래머의 고민거리를 덜어주고, 메모리 누수를 미연에 방지해 줄 수 있게 되었다.
가비지 컬렉터가 어떻게 작동하는가?
일반적으로 가비지 컬렉터는 특정한 때(메모리 사용량이 일정 수준 이상일 때나 사전에 정해놓은 시간 때)에 프로세스에서 언제나 접근 가능한 root 메모리로부터 접근 가능한 메모리를 추적하고 마킹하며, 그렇지 않은 메모리를 반환하게 된다.
가비지 컬렉션의 종류
위에서 말한 작동 방식을 따르는 가장 기초적인 형식은 추적 기반 가비지 컬렉션이라고 부른다. 가장 간단하고 가벼운 방식이지만, 일시에 방대한 메모리를 검수하고 해제하는 과정은 프로세스의 부하를 가져다줄 수 있다. 이 덕분에 가비지 컬렉션에서 파생된 분류가 등장하게 된다. 한 가지 방식에서 여러 가지 분류의 발생은, 기본 방식에서 한계점을 발견하고, 해결을 위한 다양한 수단이라고 말할 수 있다.
점진적 가비지 컬렉션
점진적 가비지 컬렉션은 기본 방식에서 쓰레기 수집 과정을 단계적으로 나눠서 수행하게 된다. 결과적으로 쓰레기 수집 속도는 느려지지만, 연산량이 나눠지게 되어 처리의 부하를 줄일 수 있게 되었다.
세대별 가비지 컬렉션
동적으로 할당된 메모리는 다양한 목적을 가진다. 그렇기에 순간적으로 사용돼 금방 해제되어야 할 메모리가 있는가 하면, 프로그램의 중추적인 역할을 하며 프로세스에 오래 상주하는 메모리도 있기 마련이다. 세대별 가비지 컬렉션은 이들을 '세대'별로 나누어 상대적으로 오래 잔재하는 메모리에 대해 적은 빈도의 추적을 수행하게 된다.
메모리를 할당하는 시점에 새로운 세대로 이를 배치하게 되며, 여러 번의 가비지 컬렉션에도 해제되지 않았을 땐 오랜 세대로 이동하게 된다.
참조 횟수 카운팅 가비지 컬렉션
참조 횟수 카운팅 가비지 컬렉션은 기본적인 추적 기반 방식과는 달리 유난히 독특한 방법을 이용하는데, 각 메모리마다 다른 메모리로부터 참조된 횟수를 카운팅 하며, 이 값이 0이 될 시 즉시 메모리를 해제하는 방식이다.
이 카운팅 값은 다른 메모리에서 참조되었을 때 1을 더하며, 참조를 중단했을 시에 1을 빼게 된다.
가비지 컬렉션의 한계점
늘 그렇듯이 장점만 있는 해결책은 없는 법이다. 그렇기에 가비지 컬렉션에서도 여러 한계점이 지적되고 있다.
대표적으로 첫째, 내부적으로 정해놓은 가비지 컬렉터의 호출 시점으로 인해 가비지 컬렉션의 발생을 추측하기 어렵다는 것이다. 또한, 위에서도 언급되었듯이 가비지 컬렉터가 실행되는 동안에는 막대한 양의 연산이 발생하며, 원활한 작업 수행을 위해 그 순간에 프로세스 전체가 멈추게 된다. (Stop-the-world 상태)
이 문제점들은 실시간으로 응답해야 하는 프로그램에겐 치명적으로 작용할 수 있으며, 프레임이 일정 간격으로 갱신되어야 하는 비디오 게임에서도 문제를 야기할 수 있다.
해결책
이에 대한 최선의 해결책으로는 말 그대로 가비지를 줄이는 것이다. 가비지의 발생이 줄어들면 그만큼 가비지 컬렉터의 호출 빈도가 줄어들기 때문이다. 그렇기에 여러 프로그래머들이 가비지 발생량을 줄이기 위한 여러 가지 방법을 커뮤니티에서 제시하고 있다. 메모리의 빈번한 동적 할당을 자제하고, 캐싱을 권유하며, 문자열 관리에 유의하는 등...
그중에서도 독특한 방법으로 가비지 발생을 줄일 수 있는 오브젝트 풀링에 대해 아래에 이야기하고자 한다.
오브젝트 풀링 (Object Pooling)
힙 영역에 동적으로 할당된 메모리는 언젠간 반환 시기를 거치게 된다. 곧, 메모리는 할당한 만큼 가비지로 치환된다. 적지 않은 할당량을 가지는 메모리의 반복적인 생성과 삭제를 반복하는 지시를 수행할 경우 끔찍한 수준의 가비지 생성을 초래하게 된다. 할당된 인스턴스를 관리할 때 발생하는 가비지를 줄일 수 있는 방법이 있을까? 오브젝트 풀링은 오브젝트의 생성과 삭제를 가비지 컬렉터가 아닌 자신이 직접 관리하는 형식을 제공한다.
오브젝트 풀링은 오브젝트 풀이라는 구조에 프로세스에서 사용 중이지 않은 오브젝트(가비지)를 집합하며, 오브젝트를 새로 할당하는 대신 오브젝트 풀 안의 오브젝트를 가져와 사용할 수 있게 된다. (오브젝트 풀에서 가져올 수 있는 오브젝트가 없는 경우, 오브젝트 풀 안에서 새로 할당한 오브젝트를 가져올 수 있다.) 오브젝트를 더 이상 쓰지 않을 때 메모리를 해제하는 것이 아닌, 오브젝트 풀에 반환하여 오브젝트를 '재활용'할 수 있다.
오브젝트 풀링을 통해 생성된 객체에 대해 한 가지 추가된 작업이 있다면, 오브젝트를 가져와 사용할 때마다 초기화를 수행해야 하는 것이다. 이전에 사용되었던 오브젝트의 경우 가져올 시 반환 시점의 형태를 그대로 나타내기 때문이다. Initialize() 와 같은 함수를 만들어 가져오는 시점마다 호출하여 오브젝트의 내용을 초기화할 수 있다.
소규모 크로스 플랫폼 게임 개발 툴의 대명사로 떠오르는 유니티 엔진에서의 오브젝트 풀링은 최적화의 중요한 수단으로 부각된다.
유니티 엔진에서 한 객체의 의미를 담당하는 게임 오브젝트는 일반적으로 많은 메모리를 차지하는데, 탄환의 발사와 파티클의 생성과 같은 생명 주기가 짧은 게임 오브젝트의 잦은 생성과 파괴를 유도하는 지시는 가비지 컬렉터의 잦은 호출을 초래하고, 이는 게임 프레임의 끊김을 발생하게 한다. 유니티 엔진 내에서 가비지를 줄이는 행위는 최적화의 필수적인 사항이다.
유니티 엔진에서 사용하지 않는 오브젝트를 비활성화(표면적인 오브젝트의 기능은 종료되지만, 메모리는 유지) 하는 기능으로 오브젝트 풀링 패턴을 활용할 수 있다.