이전 글
이전 글에서는 V8 엔진과 V8 엔진의 메모리 구조에 대해 간단하게 알아보았다.
이러한 메모리 구조에서 동적인 데이터를 어떻게 관리하는지 가비지 컬렉션을 통해 알아보려고 한다.
# 가지비 컬렉션이란
가비지 컬렉션 (Garbage Collection, GC)은 자동 메모리 관리의 한 형태이다. 이는 Heap 영역에서 동적으로 할당되어 있던 메모리 중 필요 없게 된 객체를 주기적으로 청소해 주는 프로세스이다.
C / C++ 언어에서는 malloc, calloc, realloc, free 등을 이용하여 동적으로 메모리를 할당/해제를 수동으로 진행한다. JavaScript 와 같은 프로그래밍 언어에서 개발자는 객체를 생성할 때 명시적으로 메모리를 할당하거나 해제할 필요가 없다. 대신, 가비지 컬렉터가 사용되지 않는 메모리를 정리하는 작업을 담당한다.
# Node.js의 V8엔진과 가비지 컬렉션
Node.js의 구조를 논할 때, 크게 V8 JavaScript 엔진, libuv (이벤트 루프), libio (스레드 풀) 등을 이야기한다. 그중 오늘의 주제인 메모리 관리, 즉, 가비지 컬렉션과 관계가 있는 것은 V8 엔진이다. 오늘은 Node.js의 가비지 컬렉션에 대한 주제를 다룸으로 V8엔지의 간단한 소개와 그의 가비지 컬렉션 동작에 대해서만 다룰 예정이다.
# V8 엔진의 가비지 컬렉션
V8 엔진의 가비지 컬렉션은 Heap의 객체들을 생성된 시기에 따라 그룹화하고, 각각 다른 단계에서 정리한다. 이를 세대별?(Generational) 이라고 표현하고 있으며 크게 두 가지 단계와 성능 최적화를 위한 Orinoco 프로젝트의 세 가지 알고리즘으로 이루어져 있다.
우선 V8 엔진의 대표 가비지 컬렉션인 Minor GC와 Major GC의 개념과 메커니즘에 대해 알아보자.
## Minor GC (Scavenger)
Minor GC (Scaenger) 는 New Space의 공간을 깔끔하고 단순하게 유지하며, 새 객체가 할당되는 공간의 효율성을 극대화한다.
### New Space (Young Generation) 의 크기와 객체 할당 방식
[ New Space 크기 ]
New Space 는 새로 생성된 객체를 저장하는 공간으로, 크기가 작게 설정된다.
대략 1MB에서 8MB 정도로 설정되며, 애플리케이션 동작 패턴에 따라 유동적으로 저장된다.
[ 객체 할당 방식 ]
할당 포인터 (allocation pointer) 가 현재 사용할 수 있는 메모리 위치를 가리킨다.
새로운 객체가 생성되면, 해당 포인터가 가리키는 위치에 객체가 지정되며, 객체를 저장한 후, 포인터는 다음 위치로 이동한다.
[ Minor GC가 발생하는 조건 ]
할당 포인터가 New Space 끝에 도달하면, 더 이상 새 객체를 저장할 공간이 없게 된다. 이 시점에서 Minor GC가 실행되어 New Space를 정리하고 공간을 확보한다.
### Semi-Space 구조와 Cheney 알고리즘
Semi-Space 구조와 Chenery 알고리즘은 V8 엔진의 Minor GC에서 사용되는 핵심 메커니즘으로, New Space를 효율적으로 관리하며 객체의 생존 여부에 따라 공간을 정리하고 최적화하는 방식을 제공한다.
[ New Space의 Semi-Space 구조]
New Space는 두 개의 동일한 크기의 공간으로 나뉜다.
- From-Space
- 새로 생성된 객체가 저장되는 기본 간간
- 주로 새로 만든 객체들이 여기에서 살아남는다.
- To-Space
- Minor GC가 실행될 때 살아남은 객체가 복사되는 공간
- Minor GC 후, To-Space와 From-Space의 역할이 서로 바뀐다.
- 이전의 To-Space가 새로운 From-Space가 되고, 새로 비워진 공간은 To-Space가 된다.
[ 특수 객체의 처리 ]
실행 가능한 코드 (e.g., 함수 내부에 있는 기계어 코드)와 같은 특정 종류의 객체는 New Space가 아닌 Old Space에 바로 저장된다.
- 이는 메모리 구조와 실행 효율성을 높이기 위한 특별한 예외 처리
[ Cheney 알고리즘 ]
Cheney 알고리즘은 객체 복사 기반의 가비지 컬렉션 알고리즘이다.
Minor GC가 실행되면:
1. From-Space에서 살아있는 객체만 골라낸다.
- Stack과 글로벌 변수 등에서 시작하여 객체 그래프를 탐색한다.
2. 살아있는 객체를 To-Space로 복사한다.
- 파편화를 완전히 제거하면서 데이터를 한 곳에 밀집시킨다.
3. 객체가 복사된 쉬, 기존의 메모리 위치에 포인터 업데이트 정보 (Forwarding Address) 를 기록한다.
- 이를 통해 모든 참조가 새 위치를 가리키도록 수정한다.
위 과정은 아래 Minor GC의 과정 내용에서 더 상세히 다루겠다.
### Minor GC의 과정
각 상황 혹은 흐름에 따른 Monor GC의 과정이다.
1. From-Space에 객체가 가득 찼을 때
새로운 객체를 할당하려고 하지만, 공간 부족으로 인해 Minor GC가 시작된다.
MonorGC는 스택 포인터 (GC root) 에서 시작해 객체 그래프를 순환적으로 탐색한다.
Alive 객체 (사용 중인 객체) 를 식별하고 To-Space로 이동시킨다. 이 과정에서 참조된 객체도 함께 이동되며 포인터가 업데이트된다.
2. To-Space로 이동 및 조각화 제거
살아남은 객체는 To-Space의 연속된 메모리 블록에 복사되므로 단편화가 제거된다.
모든 From-Space의 객체가 처리되면 남아 있는 객체 (사용되지 않는 객체) 는 가비지로 간주되어 삭제된다.
3. 공간 교환
Minor GC 완료 후, To-Space와 From-Space의 역할이 바뀐다.
이제 새로운 객체는 새롭게 할당된 From-Space에 저장된다.
4. 다음 Monor GC 및 Old Space 로의 이동
두 번째 Minor GC가 발생하면, 두 번째로 살아남은 객체는 Old Space로 이동된다.
첫 번째 GC에서 살아남은 객체는 To-Space로 이동되고, 나머지 가비지는 삭제된다.
Monor GC가 끝나면 To-Space와 From-Space가 다시 교환된다.
### Write Barrier와 Old-to-New 참조 관리
위 내용들을 보다 보면 New Space에 존재하는 데이터들의 메모리 위치는 충분히 수정될 수 있다. 그러나 Old Space에서 분명 New Space의 객체를 참조하는 경우가 많을 텐데 이는 어떻게 관리될까?
Monor GC는 Old Space의 객체를 참색하지 않기 때문에, Old Space에서 New Space를 참고하는 모든 포인터를 별도록 관리한다.
이 작업은 Write Barrier라는 프로세스를 통해 수행되며, Store Buffer에 기록된다.
### Minor GC 요약
Moinor GC는 새 객체를 위한 공간을 확보하기 위해 New Space를 정리하고 압축하는 역할을 수행한다. 이 과정은 매우 빈번하게 일어나지만, V8 엔진의 최적화 덕분에 사용자에게는 거의 영향을 미치지 않는 효율적인 프로세스라고 한다.
## Major GC
Major GC는 Old Space 에서 수행하는 가비지 컬렉션 프로세스이다. 이 단계에서 Heap 전체를 대상으로 정리를 하며, 오래된 객체와 그 객체가 점유한 메모리를 관리한다. Major GC는 Minor GC에 비해 더 복잡하고 시간이 많이 소요되지만, Old Space 의 공간을 최적화하여 메모리 단편화를 줄이고 시스템 성능을 향상시키는 중요한 역할을 한다.
### Major GC의 주요 단계
Major GC는 Mark-Sweep-Compact 알고리즘에 기반하며, 크게 세 가지 단계로 이루어져 있다.
[ Marking ]
Marking은 살아있는 객체 (사용 중인 객체)를 실별하는 것을 목표로 한다.
GC는 실행 Stack과 글로벌 객체를 시작점 (root set)으로 삼아 객체 그래프를 탐색한다.
이 과정에서 도달 가능한 객체는 "reachable"로 표시되며, 도달 가능한 객체는 삭제 대항이 된다.
객체 그래프는 깊이 우선 탐색(DFS) 방식으로 순회되며, 마킹된 객체는 살아있음(alive)으로 간주된다.
[ Sweeping ]
Sweeping은 메모리에서 비어 있는 공간을 식별하고 이를 재사용 가능하도록 표시하는 것을 목표로 한다.
마킹 단계에서 살아있지 않다고 판정된 객체의 메모리를 해제하고, 해당 메모리 주소를 프리 리스트 (free list)에 추가한다.
프리 리스트는 크기에 따라 나뉘어 있어, 새로운 객체를 할당할 때 적합한 크기의 메모리를 빠르게 찾을 수 있다.
[ Compacting ]
Compacting은 메모리 단편화를 줄이고 효율적인 메모리 할당을 지원하는 것을 목표로 한다.
살아남은 객체를 한 곳으로 이동하여 연속된 메미로 공간에 밀집시키고, 그 과정에서 메모리 참조 포인터를 업데이트한다.
컴팩팅은 메모리 조각화를 줄여 새 객체를 더 빠르게 할당할 수 있도록 한다. 다만, 모든 페이지에서 수행되지는 않으며, 단편화 기준 (heuristic)에 따라 선택적으로 실행된다.
### Major GC 요약
Major GC는 오래된 객체가 저장된 Old Space를 정리하고, 메모리 사용량을 최적화하는 역할을 수행한다. Minor GC보다 덜 빈번하게 실행되지만, 더 많은 데이터를 처리하고 복잡한 작업을 수행하므로 상대적으로 시간이 오래 걸릴 수 있다. 따라서 Major GC는 응용 프로그램 성능에 영향을 미칠 수 있으나, V8 엔진은 이를 최소화하기 위해 Incremental Marking 등 다양한 최적화 기술을 활용하고 있다.
## Orinoco 프로젝트
앞서 V8 엔진의 가비지 컬렉션 메커니즘을 살펴보았다. 이번에는 V8 엔진의 가비지 컬렉션 개선 프로젝트인 Orinoco에 대해 간단하게 알아보려고 한다. Orinoco는 V8엔진의 가비지 컬렉션 시스템을 의미하며, 성능을 크게 향상시킨 주요한 기술적 혁신을 포함하고 있다고 한다. 이를 통해 가비지 컬렉션의 효율성뿐만 아니라, 사용자의 경험을 최우선으로 고려한 접근 방식이 구현되었다고 이야기한다.
Orinoco 프로젝트의 핵심 목표는 "메인 스레드를 자유롭게 한다"는 것이다. 기존의 "Stop-the-World" 방식에서는 가비지 컬렉션 작업이 진행되는 동안 JavaScript 코드의 실행을 멈춰야 했기 때문에, 이는 사용자 경험에 큰 영향을 미쳤다고 한다. 이러한 문제를 해결하기 위해 Orinoco는 Parellel, Incremental, Concurrent 방식을 채택하여 메인 스레드의 차단을 최소화하려고 노력하였다.
[ Parellel 방식 ]
Parellel 방식에서는 메인 스레드와 보조 스레드가 동시에 작업을 수행한다. 하지만 여전히 "Stop-the-World" 방식을 사용하므로, 가비지 컬렉션 동안 JavaScript 실행이 중단된다. 그럼에도 불구하고 여러 개의 보조 스레드를 통해 작업이 나누어지기 때문에, 전체적으로 걸리는 시간을 짧아진다. V8 엔진에서는 객체를 스레드 간에 동기화하여 처리하는 방식으로 병렬 작업을 수행한다.
[ Incremental 방식 ]
Incremental 방식에서는 메인 스레드가 가비지 컬렉션 작업을 소규모로 분할하여 진행한다. 이 방식에서는 가비지 컬렉션이 한 번에 완료되지 않고 JavaScript 실행과 간헐적으로 섞여서 진행된다. 즉 가비지 컬렉션을 여러 번에 걸쳐 나누어 실행하되, 각 작업 사이에는 JavaScript 코드가 실행될 수 있도록 한다. 이를 통해 애플리케이션이 계속해서 사용자 입력에 반응하거나 애니메이션을 진행할 수 있게 된다. 물론 Incremental 방식은 메인 스레드의 작업을 늘리긴 하지만, 전체적인 응답성에는 긍정적인 영향을 가져온다.
[ Concurrent 방식]
Concurrent 방식은 메인 스레드가 계속해서 JavaScript를 실행하는 동안 보조 스레드가 백그라운드에서 가비지 컬렉션 작업을 진행하는 방식이다. 이 방식은 가장 어려운 부분이라고 이야기한다. 이유는, JavaScript 의 Heap이 언제든지 변경될 수 있기 때문이다. 보조 스레드와 메인 스레드가 동시에 작업을 수행하면서, 객체가 수정되거나 삭제되는 상황에서 동기화 문제를 해결해야 된다. 하지만 이 방식을 통해, 메인 스레드는 JavaScript 실행에 전념할 수 있고, 가비지 컬렉션은 백그라운드에서 효율적으로 처리된다.
### V8 가비지 컬렉션에서의 Orinoco 프레젝트
Orinoco는 V8 엔진의 가비지 컬렉션 방식을 크게 개선했다고 한다. 위에서 살펴본 Minor GC와 Major GC 에 최적화 내용을 대입해 보자.
[ Minor GC ]
V8 엔진에서는 Parellel Scavenging 방식을 사용하여 여러 보조 스레드가 New Space를 청소하고, 살아남은 객체들을 다른 공간으로 이동시키는 작업을 한다.
[ Major GC ]
Major GC 에서는 JavaScript 실행과 보조 스레드의 작업을 동시에 이루어진다. 이 과정에서 Concurrent Marking이 진행되며, 이는 메인 스레드가 JavaScript를 실행하는 동안에 보조 스레드가 백그라운드에서 객체를 마킹하는 방식이다.
[ Idle-time GC ]
V8 은 사용자가 JavaScript 를 실행하지 않는 유휴 시간을 활용하여, GC 작업을 미리 실행할 수 있도록 한다. 예를 들어, 웹 애플리케이션에서 애니메이션 프레임을 렌더링 하는 동안 유휴 시간이 발생하면, V8은 그 시간을 활용해 GC 작업을 수행한다.
### Orinoco 프로젝트 요약
Orinoco는 V8 엔진의 가비지 컬렉션 시스템을 개선한 프로젝트로, "Stop-the-World" 방식의 단점을 해결하려고 했다. Parellel, Incremental, Concurrent 방식으로 메인 스레드의 차단을 최고화하며, 가비지 컬렉션 작업을 병렬, 분할, 백그라운드 등의 방식으로 처리해 사용자 경험을 향상시켰다. 이를 통해 V8 엔진은 Minor GC와 Major GC 최적화를 통해 성능을 크게 향상시켰다.
이번 글에서는 V8 엔진의 가비지 컬렉션의 종류가 어떤 것이 있는지와 성능을 자랑하는 V8 엔진의 가비지 컬렉션이 어떤 노력으로 효율성을 높일 수 있었는지까지 알아보았다. 단일 스레드로 동작하지만 성능에서 지지 않는 JavaScript가 빠르고 지연 없이 가비지 컬렉션을 수행할 수 있었던 이유가 병렬 처리, 보조 스레드가 존재하기 때문이라는 이유가 놀라웠다.
참고
https://v8.dev/blog/trash-talk
https://jayconrod.com/posts/55/a-tour-of-v8-garbage-collection
'Backend > Node.js' 카테고리의 다른 글
[Node.js] Node.js의 가비지 컬렉션 (Garbage Collection, GC) 1 _ V8 엔진의 메모리 구조 (Stack과 Heap) (0) | 2024.12.03 |
---|---|
[Node.js] Util 모듈의 promisify() 함수 사용법 (0) | 2023.02.21 |
[Node.js] RxJS란 (0) | 2023.02.18 |
[Node.js] 스트림이란 (Stream) ② (0) | 2023.02.18 |
[Node.js] 스트림이란 (Stream) ① (0) | 2023.02.18 |