-
[JavaScript] 자바스크립트 v8 엔진의 가비지 컬렉션2024년 12월 06일
- 유니얼
-
작성자
-
2024.12.06.:22
728x90JavaScript는 가비지 컬렉션이 있어서 메모리를 자동으로 관리되지만, 가끔 예상치 못한 성능 저하를 경험할 때가 있습니다. 이런 경우에 JavaScript의 메모리 관리와 가비지 컬렉션(Garbage Collection, GC)가 어떻게 동작하는 지 이해하는 것이 중요합니다. 이번 블로그에서는 Chrome 브라우저와 Node.js에서 사용되는 V8 엔진을 기준으로 메모리 관리가 어떻게 진행되는 지를 중점적으로 정리해보고자 합니다.
1, V8 엔진이란?
V8 엔진이란 Google에서 개발한 오픈소스 JavaScript 엔진으로, 구글 크롬 브라우저와 안드로이드 브라우저, Node.js에서 사용됩니다. 크로스 플랫폼(Windows, macOS, Linux등)에서도 동작하며, 독립적으로 실행 가능하거나 다른 애플리케이션의 일부로도 작동합니다.
2, 메모리의 생존 주기
먼저, 가비지 컬렉션의 대상인 메모리에 대해서 알아보고자 합니다. JavaScript의 메모리는 V8 엔진에 의해서 관리되며, 메모리 할당, 사용, 해제의 생명주기를 따릅니다.
1) 메모리 구조
JavaScript의 메모리는 두 가지 주요 영역으로 나뉩니다.
- 스택(Stack): 원시 타입(Primitive Type : 값을 직접적으로 다루는 데이터 타입, 값 타입)이 저장됩니다. 함수의 호출돠 실행 컨텍스트에 대한 정보도 스택에 저장됩니다. 빠르게 접근 가능하며, 크기가 고정된 메모리 영역입니다.
- 힙(Heap): 참조 타입(Reference Type) 데이터(객체, 배열, 함수 등)이 저장됩니다. 크기가 동적이며, 비정형 데이터를 저장하는 게 사용됩니다. 힙에 저장된 데이터의 참조 주소가 스텍에 저장되어 호출됩니다.
2) 메모리 생존 주기 : 할당
자바스크립트는 변수를 선언할 때 자동으로 메모리를 할당합니다. 원시 타입의 값들은 스택 영역에 저장되고, 참조 타입의 값들은 힙 영역에 저장되며, 그 힙 영역에 저장된 데이터의 주소값을 스택 영역에 저장됩니다. 또한 변수 식별자는 스택 영역 상의 실행 컨텍스트의 레시컬 환경에 저장됩니다.
const a = 10; // primitive 타입은 메모리 스택 영역에 저장 const b = [1, 2, 3]; // 배열, 함수, 객체는 힙 영역에 저장
3) 메모리 생존 주기 : 사용(참조)
V8 엔진에 의해 전역 실행 컨텍스트 렉시컬 환경에 있는 식별자 a,b를 참조합니다. 이를 통해 코드 실행 중 스택이나 힙에 저장된 데이터를 읽고 쓸 수 있습니다.
console.log(a); // 10 console.log(b[0]); // 1
3) 메모리 생존 주기 : 해제(가비지 컬렉션)
JavaScript는 C/C++과 달리 수동으로 메모리 해제가 불가능 합니다. 이때 가비지 컬렉터가 더 이상 참조되지 않는 메모리를 자동으로 해제합니다.
let obj = { name: "Alice" }; obj = null; // GC가 obj를 참조하는 코드가 없다고 판단하면 해제
3, 메모리 구조(V8 엔진)
그 다음, Node.js나 Chrome 브라우저에서 사용되는 V8 엔진의 메모리 구조에 대해 알아보고자 합니다. 프로그램을 실행하면 V8 엔진은 Resident Set이라는 메모리 공간을 할당하며, 이를 크게 스택(Stack)과 힙(Heap) 영역으로 나눕니다. 자바 스크립트는 당일 스레드(싱글 스레드)로 동작하므로 V8은 자바스크립트 컨텍스트 당 한 개의 프로세스를 사용합니다. 만약 서비스 워커를 사용한다면 워커 당 한 개의 새로운 프로세스를 생성합니다. 각각의 V8 프로세스에서 할당된 일정량의 메모리를 Resident Set이라고 합니다.
스택 메모리 영역
스택 메모리 영역은 싱글 스레드 환경에서 사용되는 고정된 크기를 가진 메모리입니다. V8의 프로세스마다 하나의 스택 영역을 가지게 욉니다. 스택은 함수 호출, 지역 변수, 원시 값, 객체 포인터를 포함한 정적인 데이터가 저장됩니다. 함수의 호출이 끝나면 OS에 의해서 자동으로 정리가 됩니다.
힙 메모리 영역
힙 메모리 영역은 객체나 동적인 데이터를 저장하는 메모리입니다. 힙 메모리는 메모리 영역에서 가장 큰 영역이면서 가비지 컬렉션(GC)이 발생하는 곳입니다. 힙 메모리 영역에서 New Space(Young Generation)과 Old Space(Old Generaction) 영역에서 가비지 컬렉션이 샐행됩니다. 이때 정리되지 않는 메모리들이 남아있으면 메모리 누수가 발생할 수 있습니다. 힙 영역은 다음과 같이 나눌 수 있습니다.
- New space (Young generation): 새로 생성된 객체가 저장되는 공간이입니다.
- Old space (Old generation): New space에서 여러 번 생존한 객체가 저장되는 공간입니다..
- Large Object space: 크기가 큰 객체가 저장되는 공간입니다. 라지 오브젝트들은 가비지 컬렉터가 작동하지 않습니다.
- Code space: JIT 컴파일로 생성된 기계어 코드 저장되는 영역입니다. 유일하게 실행 가능한 메모리가 있는 영역입니다.(코드들은 Large Object Space에 할당될 수도 있고 실행도 가능합니다.)
- 셀(Small Cell) space, 속성(Property) space, 맵(Map) space: 이 영역들은 각각 Cells, PropertyCells, Maps을 포함합니다. 각 영역은 모두 같은 크기의 객체들을 포함하며, 어떤 종류의 객체를 참조하는지에 대한 제약이 있어서 수집을 단순하게 만듭니다.
보다시피 스택은 자동으로 관리되고 V8 엔진 자체가 아닌 운영체제에서 자동으로 메모리를 해제하므로 걱정하지 않아도 됩니다. 하지만 힙은 운영 체제에 의해 자동으로 관리되지 않고 가장 큰 메모리 영역과 동적 데이터를 보유하고 있기 때문에, 시간이 지남에 따라 프로그램의 메모리가 기하급수적으로 증가할 수 있습니다. 또한 시간이 지나면서 조각화되어서 애플리케이션 속도가 느려질 수 있습니다.
4, 가비지 컬렉션(GC)
V8 엔진은 JavaScript 애플리케인션의 메모리를 효율적으로 관리하기 위해서 가비지 컬렉션(GC)를 가용합니다. 가비지 컬렉션은 더 이상 참조되지 않는 객체(Orphan Objects)를 식별하고 제거함으로써, 힙 메모리의 효율성을 유지합니다. V8 엔진은 효육적인 메모리 관리를 위해 세대별 관리(Generactional Garbage Collection)을 기반으로 다양한 최적화 기법을 전용한 GC를 사용합니다.
1) 왜 가비지 컬렉션을 사용하는지?
프로그램이 사용 가능한 것보다 더 많은 메모리를 힙에 할당할려고 할 때 메모리 부족 오류가 발생합니다. 또한 힙이 잘못 괸리되면 메모리 누수가 발생하여 사용자가 프로그램을 사용할 때 불편함을 겪을 수 있습니다. 이로 인해서 가비지 컬렉션을 통해서 더 이상 참조되지 않는 객체를 식별하고 메모리에서 제거하여 프로그램이 동작하기 위한 새로운 메모리 공간을 확보하는 프로세스입니다.
- 참조 없는 객체란? : 프로그램 실행 중 더 이상 어떤 변수나 객체에서도 참조되지 않는 메모리 영역을 의미합니다.
- 목표 : 메모리 누수를 방지하고, 애플리케이션이 효율적으로 메모리를 사용할 수 있도록 유지.
2) V8의 세대별 가비지 컬렉션
V8의 힙은 세대(Generaction)이라고 불리는 영역으로 나뉩니다. Young Generation(New Space), Old Generaction(Old Space)가 있습니다. 객체가 생성되면 먼서 Young Generaction에 메모리가 할당됩니다. 이후 GC가 동작하였을 때 해당 객체가 살아남으면(참조를 하고 있으면) Young Generaction의 하위 세대인 Intermediate Generaction으로 간주됩니다. 이후에도 GC가 동작하였을 때 살아남으면 그때, Old Generaction으로 이동합니다.
가비지 컬렉션에서는 "세대 가설"이라는 중요한 용어가 있습니다. 이는 기본적으로 대부분의 객체가 젊어서 죽는다는 것을 말합니다. 다시 말해, 대부분의 객체는 할당된 후 GC의 관점에서 거의 즉시 도달할 수 없게됩니다. 이는 V8이나 JavaScript 뿐만 아니라 대부분의 동적 언어에서도 해당됩니다.
V8의 세대별 힙 레이아웃은 객체 수명에 대한 이 사실을 활용하도록 설계되었습니다. GC는 압축/이동 GC로, 가비지 수집에서 살아남은 객체를 압축하여 복사한다는 의미입니다. 이때, 객체를 복사하는 과정은 비용이 많이 들지만, 세대별 가설에 따르면 실제로 가비지 수집에서 살아남은 객체는 매우 적습니다. 살아남은 객체만 이동함으로써 다른 모든 할당은 '암묵적' 가비지가 됩니다. 즉, 할당 수가 아닌 살아남은 객체 수에 비례하는 비용만 지불하면 되기 때문에 대다수의 세대별로 동작하는 가비지 컬렉션은 이 이론을 바탕으로 설계되었습니다.
(1) 마이너 GC(Minor GC)
마이너 GC는 New 영역 (Young Generaction)에서 참조되지 않는 객체(가비지)를 수집합니다. ' 세대 가설 '을 바탕으로 이떄 할당된 대부분의 객체들은 메모리에서 해제 되기 기다리기 떄문에 마이너 GC에서 대부분의 객체는 메모리에서 해제됩니다.
Minor GC의 작동 과정:
1 - 객체 생성:
새로운 객체가 생성되면 From Space라는 Semi Space 영역에 메모리가 할당됩니다. 이떄 From Space가 가득차면 Minor GC가 발생합니다.
2 - 객체 대피(Evacuation):
Minor GC에서 살아남은 객체들은 또 다른 Semi Space 영역인 To Space 영역으로 복사됩니다. 이 과정에서 객체는 연속적인 메모리 형태로 정렬되며, 메모리 단편화를 방지합니다. 복사된 객체의 메모리 주소는 새로 할당된 메모리 영역의 주소로 갱신됩니다. 이 때 살아남은 객체는 Imtermediate 세대로 구분되어집니다.
3 - 역할 전환:
이제 From Space와 To Space의 역할이 교환됩니다. 이떄 다음 GC에서는 To Space가 새로운 From Space가 되고 기존의 From Space가 To Space 영역이 됩니다.
4 - Old Generaction으로 이동:
또 다시 From Space 영역이 가득차게 되어서 Minor GC가 발생하면 새로 할당된 객체는 다시 To Space로 이동하지만 이미 한번 GC에서 살아남은 Imtermediate 세대로 구분되어진 객체들은 Old Space 영역으로 이동됩니다.
(2) 메이저 GC(Major GC)
메이저 GC는 Old Generaction에서의 GC입니다. 긴 생명 주기의 객체를 대상으로 메모리를 정리합니다. 어떤 문서에서는 전체 힙에서 가비지를 수집한다고 되어 있지만 이는 특정 조건에서 전체 힙을 포함할 수 있지만, 일반적으로는 Old Generaction을 대상으로 동작합니다. 예를 들어 Old Generaction의 객체가 New Geenraction을 참조할 경우나, 메모리가 부족한 경우에는 New Generaction, Old Generaction 전체를 대상으로 GC가 동작할 수 있습니다. 하지만 Major GC와 Minor GC는 독립적으로 관리되므러 Old Generaction은 Major GC, New Geneaction은 Minor GC를 기반으로 메모리가 관리됩니다. Major GC는 Mark-Sweep-Compact 알고리즘을 사용하여 다음 세 단계로 이루어집니다.
1- 마킹(Marking):
마킹 단계는 도달 가능한 객체를 식별하고 이를 활성 상태로 표기하는 단계입니다. 어떤 객체를 수집할 수 있는 지 파악하기 위해서 GC 루트(스택 포이터 혹은 전역 객체)에서 시작하여 객체 간의 참조를 따라가면서 참조하는 객체를 활성 상태로 활성 상태로 전환합니다. 이를 Tri-color 마킹(Tri-color Marking) 방식이라고 합니다. 이는 실행 스택 및 전역 객체(Roots)에서 시작하여 객체 그래프를 순회하며 이루어집니다. 이때 GC는 런타임에서 도달 가능한 모든 객체가 발견되어서 표시될 떄까지 이 프로세스를 재귀적으로 계속합니다.
Tri-color 마킹의 개념 :
Tri-color 마킹은 각 객체를 탐색 상태에 따라 다음 세 가지 색상으로 구분합니다:
- White (흰색): 아직 탐색되지 않은 객체입니다. 초기 상태에서 모든 객체는 흰색으로 마킹됩니다.
- Gray (회색): 탐색 중간 단계의 객체입니다. 탐색은 되었으나, 참조하는 다른 객체들을 아직 탐색하지 않은 상태입니다.
- Black (검은색): 자신과 참조하는 모든 객체가 탐색 완료된 상태입니다.. 가비지 컬렉션 대상이 아님.
마킹 단계의 작동 방식
마킹 단계는 **DFS(깊이 우선 탐색)**와 덱(Deque)을 활용하여 진행됩니다. 다음은 주요 과정입니다:
1단계: 초기화
- 모든 객체를 흰색으로 마킹합니다.
- Roots(실행 스택과 전역 객체)를 회색으로 마킹하고, 덱(Deque)에 추가합니다.
2단계: 덱 순회
- 덱이 비어있을 때까지 다음 과정을 반복합니다:
- 덱에서 객체를 pop_front로 꺼냅니다.
- 꺼낸 객체를 검은색으로 마킹합니다.
- 해당 객체가 참조하는 객체(인접 객체)들을 확인합니다:
- 참조된 객체가 흰색인 경우:
- 회색으로 마킹합니다.
- 덱의 **앞쪽(push_front)**에 추가합니다.
- 참조된 객체가 이미 회색 또는 검은색인 경우:
- 이미 탐색된 상태이므로 덱에 추가하지 않습니다.
- 참조된 객체가 흰색인 경우:
3단계: 완료
- 덱이 비어 있으면 마킹 단계가 종료됩니다.
- 최종적으로 검은색 객체는 도달 가능한 객체이며, 흰색 객체는 가비지 컬렉션 대상으로 간주됩니다.
2 - 스위핑(Sweeping : 청소):
스위핑은 활성되지 못한 객체들을 제거하고, 해제된 메모리를 자유 목록(Free List)이라는 데이터 구조에 추가하는 프로세스입니다. 자유 목록은 나중에 메모리를 할당 할려고 할 때, 적절한 크기의 메모리를 찾아서 할당하기 위해서 사용합니다. 이때, 빠른 조회를 위해 메모리의 청크의 크기로 구분됩니다.
3 - 압축(Compacting):
스위핑 이후에 살아남은 객체를 한쪽으로 이동하여 메모리의 단편화를 방지합니다. 이렇게 살아남은 객체를 한쪽으로 이동하여 조각화를 줄이고 메모리의 접근 속도를 향상시키는 데 기여합니다.
(3) Orinoco : 메이저 GC(Major GC) 최적화 기술
Orinoco는 V8 엔진에서 **Major GC(Mark-Sweep-Compact)**의 성능을 최적화하기 위해 설계된 프로젝트입니다. Major GC는 긴 생명 주기의 객체를 대상으로 메모리를 정리하는 과정에서 Stop-the-World 방식으로 동작하여 메인 스레드의 작업을 일시 정지시킬 수 있습니다. 이는 사용자 경험을 저하시킬 수 있기 때문에, Orinoco는 다양한 기술을 도입하여 이러한 문제를 완화하고 성능을 향상시킵니다.
1 - Incremental GC (증분 가비지 컬렉션)
인크리멘탈 GC는 GC 작업을 여러 개의 단계로 나누어서 점진적으로 수행하는 방법입니다. 한 번에 모든 작업을 처리하지 않고, 짧은 시간 동안 여러 번에 나눠 실행하여 일시 정지 시간을 줄입니다.
2 - Parallel GC (병렬 처리)
기존에는 메인 스레드가 혼자 모든 GC 작업을 수행했지만, Parallel GC는 여러 헬퍼 스레드가 메인 스레드와 함께 작업을 나누어 처리합니다. 스레드 간 동기화로 인해 약간의 오버헤드는 발생하지만, GC 완료 시간이 크게 단축됩니다.
3 - Concurrent GC (동시 처리):
Concurrent GC는 가비지 컬렉션 작업을 메인 스레드 대신 헬퍼 스레드에서 백그라운드로 수행합니다. 메인 스레드는 JavaScript 실행에만 집중할 수 있으므로 Stop-the-World 시간이 사실상 제거됩니다.
- 동시 마킹 (Concurrent Marking):
객체 참조를 추적하고 도달 가능한 객체를 마킹하는 작업을 헬퍼 스레드에서 백그라운드로 수행합니다. Write Barrier를 사용해 메인 스레드에서 발생하는 참조 변경을 추적합니다. 이를 통해서 메인 스레드의 부하를 줄이면서 마킹 작업을 완료합니다.
- 동시 스위핑 및 압축 (Concurrent Sweeping/Compacting):
가비지 객체 제거(스위핑)와 살아남은 객체의 재배치(압축)를 헬퍼 스레드에서 처리합니다. 메인 스레드의 작업 부하를 줄이고, 메모리 단편화를 방지합니다.
4 - 레이지 스위핑(Lazy Sweeping):
Orinoco 기술 중 하나로, 메모리가 실제로 필요해질 때까지 스위핑 작업을 지연하여 불필요한 작업을 줄이는 방식입니다. 페이지 단위로 스위핑을 수행하며, 필요한 경우에만 메모리를 정리하여 이후 해당 객체를 재할당하는 불필요한 작업을 줄입니다.
5. Idle-time GC (유휴 시간 GC)
GC 작업은 애플리케이션의 유휴 시간(idle time)을 활용하여 실행됩니다. 예를 들어, 크롬 브라우저는 60FPS로 동작하며, 각 프레임 렌더링에 약 16ms를 사용합니다. 만약 프레임 렌더링이 16ms보다 빨리 끝나면, 남은 유휴 시간에 GC 작업을 수행합니다.
결론
개발자는 V8 엔진의 가비지 컬렉션이 메모리를 자동으로 관리해 준다는 점에서 큰 장점을 누릴 수 있지만, GC는 애플리케이션 성능에 영향을 줄 수 있는 요소임을 이해해야 합니다. 특히, 예상치 못한 성능 저하를 겪을 때 GC의 동작 방식을 이해하고 최적화 방안을 적용하는 것이 중요합니다.
참조
https://ui.toast.com/weekly-pick/ko_20200228#v8-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EA%B5%AC%EC%A1%B0
https://v8.dev/blog/concurrent-marking
https://v8.dev/blog/trash-talk
https://fe-developers.kakaoent.com/2022/220519-garbage-collection/
반응형다음글이전글이전 글이 없습니다.댓글