가비지 컬렉션 이해하기
자바스크립트는 메모리 할당과 해제를 자동으로 처리하는 언어다. 덕분에 개발자는 메모리 관리에 신경을 덜 쓰고도 생산성을 높일 수 있다. 하지만, 그렇다고 해서 메모리가 어떻게 할당되고 해제되는지 전혀 신경 쓰지 않아도 된다는 뜻은 아니다.
메모리 관리에 대해 생각하지 않고 개발하다 보면 불필요한 메모리가 계속 남아있는 메모리 누수가 발생할 수 있다. 이로 인해 앱 성능이 악화되고, 결국 사용자 경험에도 부정적인 영향을 미친다.
과거 RxJS 스트림 구독을 컴포넌트 언마운트 시에 제대로 해제하지 않아 메모리 누수가 발생하는 것을 경험한 적이 있다. 컴포넌트가 생성될 때마다 스트림에 구독은 계속 추가되었지만, 해제가 이루어지지 않아 메모리가 반환되지 않았고, 이는 앱 성능 저하로 이어졌다. 최근 면접에서 이에 대한 질문을 받았는데 만족스러운 답변을 하지 못한 것 같아 설 연휴 동안 가비지 컬렉션의 작동 원리를 공부해보기로 했다.
메모리 할당 및 해제
자바스크립트에서는 특정 값을 선언하면 그 값을 위한 메모리가 자동으로 할당된다. 특정 값을 선언하는 것에는 변수를 선언하는 것, 함수를 선언식이나 표현식으로 선언하는 것, 객체 리터럴을 통해 객체를 선언하는 것 등이 포함된다. 이렇게 선언된 값은 메모리를 할당받게 되고, 개발자는 값의 선언과 사용 외에 메모리 관리에 대해 신경 쓸 일이 거의 없다.
또한, 자바스크립트에서는 할당된 메모리를 해제해 반납하는 과정 역시 자동으로 이루어진다. C나 C++ 같은 프로그래밍 언어에서는 개발자가 필요한 만큼 메모리를 할당받고, 더 이상 필요하지 않을 때 직접 해제해야 하지만, 자바스크립트에서는 메모리 해제 시에도 개발자가 주로 관여할 필요가 없다.
개발자가 명시적으로 메모리 해제를 요청하지 않아도, 실행 환경이 불필요한 메모리를 자동으로 정리해 주는 과정을 가비지 컬렉션이라 부른다. 여기서 “가비지”는 더 이상 참조되지 않거나 사용되지 않는 메모리를 의미한다. 가비지 컬렉션은 불필요한 메모리를 정리하고, 그 공간을 새롭게 사용할 수 있도록 해준다. 또한, 필요에 따라 메모리 공간을 재배치하거나 압축해 메모리 단편화 문제를 해결하기도 한다.
가비지 컬렉션 덕분에 자바스크립트는 개발자에게 여러 가지 편의를 제공한다. 메모리를 직접 관리할 필요가 없으므로 메모리 누수, 이미 해제된 메모리에 접근하는 문제, 혹은 너무 일찍 메모리를 해제해 발생하는 문제를 줄일 수 있다. 이에 따라 불필요한 에러를 방지하고, 개발 생산성을 높이는 데 도움을 준다.
주요 가비지 컬렉션 기법
가비지 컬렉션의 핵심은 앞으로 프로그램이 더 이상 사용하지 않을 메모리를 파악하는 것이다. 하지만 실제로 사용하지 않을 메모리를 확실히 알아내는 것은 불가능하다. 그래서 가비지 컬렉터는 다양한 기법을 통해 사용되지 않을 가능성이 높은 메모리를 추론해 해제할 대상을 결정한다.
Reference Counting (참조 횟수 계산)
참조 횟수 계산은 가비지 컬렉터가 특정 메모리를 계속 유지할지 아니면 해제할지를 판단하기 위해 사용하는 기법 중 하나다. 이 기법은 특정 객체를 참조하는 다른 객체가 있는지를 따져 참조가 존재할 경우 메모리를 유지하고, 그렇지 않으면 할당을 해제한다.
구체적으로, 참조 횟수 계산 기법은 객체에 대한 참조가 생길 때마다 참조 횟수를 증가시키고, 참조가 제거되면 참조 횟수를 감소시킨다. 참조 횟수가 0이 되면 해당 객체는 더 이상 필요하지 않은 것으로 간주되어 가비지 컬렉터에 의해 메모리에서 제거된다.
참조 횟수 계산 기법은 참조 횟수만 확인하면 메모리를 해제할 대상을 빠르게 판단할 수 있다는 장점이 있다. 하지만, 참조가 추가되거나 제거될 때마다 참조 횟수를 업데이트해야 하므로 오버헤드가 발생한다. 또한, 순환 참조가 있는 경우 참조 횟수가 0이 되지 않아 메모리가 회수되지 않는 한계가 있다. 이 때문에 참조 횟수 계산은 일부 데이터베이스나 파일 시스템 등에서만 주로 사용된다.
Tracing (포인터 추적)
앞서 살펴본 참조 횟수 계산 방식은 객체 간의 참조 관계를 기반으로 메모리 회수를 결정했다. 하지만 포인터 추적 방식은 참조 여부와 무관하게 객체가 실제로 도달 가능한지를 기준으로 메모리 회수 여부를 판단한다.
이 방식을 적용하려면 탐색의 출발점이 필요하다. 이를 Root Set이라 하며, 콜 스택, 글로벌 객체, DOM 트리 등 프로그램 실행을 위해 기본적으로 유지되는 데이터가 포함된다.
포인터 추적 방식에서는 Root Set에서 출발해 도달할 수 있는 객체를 탐색한 후, 접근 불가능한 나머지 객체를 정리한다. 이 접근 방식은 참조 횟수 계산 방식의 단점인 순환 참조로 인한 메모리 누수 문제를 해결할 수 있다는 강점이 있다.
대표적인 Tracing 기반 가비지 컬렉션 기법으로는 Mark-Sweep 알고리즘과 Mark-Compact 알고리즘이 있다. Mark-Sweep 알고리즘과 Mark-Compact 알고리즘은 모두 Mark 단계와 Sweep/Compact 단계로 나뉜다.
Mark
Mark 단계에서는 Root Set에서 시작해 도달 가능한 모든 객체를 탐색하며, 방문한 객체에 도달 가능하다는 표시를 남긴다. 탐색이 끝나면 메모리 내 객체들은 도달 가능한 객체와 도달 불가능한 객체로 명확히 구분된다.
이 단계에서 흔히 사용되는 기법이 Tri-color Marking 알고리즘이다.
Tri-color Marking은 객체를 흰색, 회색, 검은색 세 가지로 구분해 탐색을 진행한다.
- 흰색: 아직 탐색되지 않은 객체
- 회색: 탐색 중이며, 이 객체에서 연결된 다른 객체들은 아직 확인되지 않은 상태
- 검은색: 탐색 완료된 객체로, 이 객체와 연결된 모든 객체가 이미 회색 또는 검은색으로 처리된 상태
초기 상태에서 모든 객체는 흰색이며, Root Set의 객체들을 회색으로 변경한 후 탐색 리스트에 저장한다. 리스트에서 회색 객체를 하나씩 꺼내 해당 객체와 연결된 모든 흰색 객체를 회색으로 변경한 후 리스트에 추가한다. 이후, 처음 꺼냈던 회색 객체는 검은색으로 변경한다. 깊이 우선 방식으로 유향 그래프를 순회하는 알고리즘이라고 생각할 수 있다.
Sweep
Sweep 단계에서는 Mark 단계에서 도달 불가능한 것으로 판정된 객체들을 가비지 컬렉터가 제거하고, 남은 메모리 공간을 재활용할 수 있도록 정리한다. 이 과정에서 참조 횟수 기반 방식과 달리, 순환 참조된 객체도 도달 불가능하다면 정상적으로 회수된다는 장점이 있다. 도달 불가능한 객체를 제거한 이후 남는 공간은 추후 새로운 값을 할당하거나 메모리 재배치 시에 사용된다.
Compact (압축)
압축 단계에서는 메모리 단편화가 발생한 영역의 객체들을 이동시켜 메모리 사용 효율을 극대화한다. 도달 불가능한 객체를 제거한 후, 남은 빈 공간으로 다른 객체들을 이동시켜 메모리 단편화를 최소화하고, 이 과정에서 비워진 메모리는 운영체제에 반환될 수도 있다.
압축을 통해 메모리를 더 효율적으로 사용할 수 있지만, 객체를 이동하는 과정에서 추가적인 비용이 발생하며, 기존 포인터를 업데이트해야 하는 부담이 있다.
V8
브라우저 엔진들은 위에서 살펴본 알고리즘 중 하나만 사용하는 것이 아니라, 가비지 컬렉션 성능을 최적화하기 위해 다양한 기법을 조합해 사용한다. 특히 V8 엔진의 가비지 컬렉션 방식에 대해 참고할만한 자료가 많아, V8이 가비지 컬렉션을 위해 사용하는 주요 기법들을 정리했다.
Generational Collection
세대별 가비지 컬렉션은 생성된 지 오래된 객체보다 새롭게 생성된 객체가 더 빠르게 사라질 가능성이 높다는 가설(generational hypothesis)에 기반을 둔, 메모리를 세대별로 나누어 관리하는 기법이다. 새로운 객체와 오래된 객체를 별도의 메모리 영역에 할당하고, 이 중 빠르게 사라질 가능성이 높은 영역에서 가비지 컬렉션을 더 자주 수행한다.
V8 엔진은 효율적인 가비지 컬렉션을 위해, 메모리 힙을 New-Space와 Old-Space로 나누어 관리한다. New-Space에는 새롭게 생성된 객체를 할당하고, 일정 횟수 이상 가비지 컬렉션을 버틴 객체는 Old-Space로 이동시킨다.
New-Space에서는 포인터를 증가시키는 방식으로 메모리를 할당하므로 매우 효율적이다. 하지만, 영역의 크기가 작아 메모리가 차면 빠르게 가비지 컬렉션을 수행해야 한다. New-Space에서 두 번의 가비지 컬렉션을 버틴 객체는 Old-Space로 이동된다.
메모리 재배치 시 오버헤드가 발생하지만, 대부분의 객체는 오래 살아남지 않으므로 재배치가 빈번하지 않다. 또한, 이 과정에서 Old-Space의 메모리 단편화를 줄일 수 있다. Old-Space에는 상대적으로 오래 살아남은 객체들이 모여 있기 때문에, New-Space보다 가비지 컬렉션이 덜 빈번하게 발생하며, 이를 통해 전체적인 가비지 컬렉션 성능을 최적화할 수 있다.
Two-Space Collection
Two-Space 가비지 컬렉션은 메모리를 두 개의 동일한 크기 영역으로 나누어 관리하는 기법이다. 새로운 객체는 한쪽 공간에만 할당되며, 공간이 가득 차면 가비지 컬렉션이 실행된다. 이 과정에서 살아남은 객체는 다른 공간으로 복사되고, 나머지 객체는 제거된다. 이후 앞선 과정이 반복되어 메모리가 할당되고 해제된다.
V8 엔진에서는 New-Space의 메모리 관리를 위해 Two-Space 가비지 컬렉션을 활용한다. New-Space는 To-Space와 From-Space라는 두 개의 동일한 크기 영역으로 나뉜다.
-
객체 할당: 새로운 객체는 To-Space에 할당된다. 이때, 메모리 할당을 위해 포인터를 증가시키며, 포인터가 To-Space 끝에 도달하면 가비지 컬렉션이 실행된다.
-
가비지 컬렉션 실행
- 두 공간의 역할이 서로 바뀐다. (To-Space → From-Space, From-Space → To-Space)
- 기존 From-Space에 있던 객체 중 유지할 메모리를 파악한다.
- 첫 가비지 컬렉션을 살아남은 객체는 To-Space로 이동한다.
- 2회차 가비지 컬렉션을 살아남은 객체는 Old-Space로 승격된다.
-
메모리 정리: From-Space의 모든 객체가 삭제된다.
New-Space에서 발생하는 가비지 컬렉션은 New-Space 내의 객체만 탐색하며, Old-Space는 건드리지 않는다. 덕분에 힙 전체를 탐색하는 가비지 컬렉션보다 빠르게 수행된다. 이러한 특성 때문에 New-Space에서 발생하는 가비지 컬렉션을 마이너 가비지 컬렉션이라 부르며, 이를 수행하는 방식을 Scavenging 가비지 컬렉션이라고 한다.
Two-Space 가비지 컬렉션은 불필요한 객체를 빠르게 정리하고 메모리 단편화를 해소하는데 효과적이지만, 주어진 공간의 절반만 사용할 수 있다는 단점이 있다. 따라서 메모리 낭비를 줄이기 위해 작은 크기의 메모리 영역에 적용하는 것이 이상적이다.
최적화
가비지 컬렉션은 기본적으로 동기적인 작업으로, 이를 수행하는 동안 모든 쓰레드의 작업이 멈추는 stop-the-world 현상이 발생한다. V8 엔진 개발에 참여했던 개발자에 의하면, 과거에는 V8 엔진의 가비지 컬렉션이 500ms에서 1000ms 정도 소요되는 경우도 종종 있었다고 한다. 60fps를 유지하기 위해 각 프레임에 약 16.6ms 정도만 할당되는 것을 고려하면, 가비지 컬렉션이 프레임 드랍 등 사용자 경험에 부정적인 영향을 미쳤을 것이다.
따라서 V8 엔진은 메인 쓰레드의 작업에 영향을 최소화하면서 가비지 컬렉션을 효율적으로 수행하기 위해 여러 최적화 기법을 도입했다. 주요 기법은 다음과 같다:
- Incremental Marking: 가비지 컬렉션을 한 번에 처리하는 대신, 작은 단위로 나누어 진행하여 메인 쓰레드의 작업을 방해하지 않음
- Lazy Sweeping: 비동기적으로 불필요한 객체를 제거해 메인 쓰레드의 작업에 영향을 최소화
- Parallel Garbage Collection: 여러 쓰레드를 사용하여 병렬로 가비지 컬렉션을 수행, 메인 쓰레드의 부하를 줄임
- Idle-time Garbage Collection: 브라우저의 유휴 시간 동안 가비지 컬렉션을 수행, 사용자 경험에 악영향을 줄임
이러한 기법들은 가비지 컬렉션의 성능을 최적화하고, 사용자 경험을 개선하는 데 중요한 역할을 한다.
참고자료
- Jay Conrod. A tour of V8: Garbage Collection
- Ulan Degenbaev, et al. Orinoco: young generation garbage collection
- Lorenz, Thorsten V8 Garbage Collector
- Marshall, Peter. Trash talk: the Orinoco garbage collector
- MDN. Memory management
- Memory Management Reference. Memory Management Reference