자바스크립트 v8 엔진의 가비지 컬렉션 동작 방식
자바스크립트 데이터 타입에 관심이 생김에 따라 자연스럽게 메모리 공간의 확보와 참조 그리고 해제를 공부했습니다. C 언어 같은 저수준 언어는 free() 함수를 통해 개발자가 수동으로 메모리를 해제해야 합니다. 그런데 저는 지금까지 자바스크립트로 개발하면서 메모리를 직접 해제해 본 기억이 없었습니다. 과연 자바스크립트는 어떤 방식으로 메모리를 해제하는 걸까요? (이 글은 v8 엔진 기준으로 작성하였습니다) 메모리 생존 주기 먼저, 가비지 컬렉션의 대상인 메모리에 대해서 알아봅시다. 메모리는 다음과 같은 생존 주기를 지닙니다. 1. 할당한다. 자바스크립트는 변수를 선언할 때 자동으로 메모리를 할당합니다. 원시 타입의 값들은 스택 영역에 저장되고, 참조 타입의 값은 힙 영역에 저장되며, 그 주소값은 스택 영역에 저장됩니다. 또한 변수 식별자는 스택 영역 상의 실행 컨텍스트의 렉시컬 환경에 저장됩니다. const a = 10 // primitive 타입은 메모리 스택 영역에 저장
const b = [1, 2, 3] // 배열, 함수, 객체는 힙 영역에 저장 2. 사용한다(참조). v8 엔진에 의해 전역 실행 컨텍스트 렉시컬 환경에 있는 식별자 a, b를 참조합니다. console.log(a) // 10
console.log(b[0]) // 1 3. 필요 없으면 해제한다. C 언어나 C++ 언어처럼 수동으로 쓸모없는 메모리 영역을 해제할 수 없습니다. 그것을 자동으로 해주는 게 바로 가비지 컬렉터(GC)입니다. 그렇다면 GC는 어떤 기준으로 메모리 영역을 더 이상 쓸모없다고 판단할까요? 또 어떠한 원리로 가비지 컬렉션이 동작할까요? 메모리 구조(v8 엔진) 먼저 v8 엔진의 메모리 구조를 알아봅시다! 프로그램을 실행하면 메모리의 Resident Set이라는 빈 공간이 할당됩니다. Resident Set은 스택 영역과 힙 영역으로 나눌 수 있습니다. 자바스크립트의 경우 싱글쓰레드라서 스택 메모리를 하나만 가집니다. 이번 글에서 주의 깊게 봐야 할 것은 힙 메모리입니다. 왜냐하면 스택은 함수 호출이 끝난 후 OS에 의해 정리되지만, 힙 메모리는 그렇지 않기 때문입니다. 따라서 힙에 있는 쓸모없는 메모리가 해제되지 않는다면, 메모리 누수가 발생하여 프로그램 속도는 점점 느려질 것입니다. 힙 힙 영역은 세부적으로 New space, Old space, Large Object space, 코드 space, 셀 space, 속성 space, 맵 space로 이루어져 있습니다. 가비지 컬렉션이 일어나는 부분은 New space와 Old space이므로, 이번 글에서는 두 space에 대해서만 알아봅시다. New space(Young generation): 새로 만들어진 Object가 저장됩니다. Old space(Old generation): New space에서 마이너 가비지 컬렉션이 2번 발생할 동안 살아남은 객체들이 저장됩니다. 이 영역은 두 개로 나눌 수 있습니다. Pointer space: 다른 객체를 참조하는 객체, 즉 다른 객체에 대한 포인터를 가진 객체 Data space: 문자열, 실수 등의 데이터만을 가진 객체 가비지 컬렉터(GC) v8은 힙 영역을 generations라고 불리는 New, Old space로 나눴습니다. New space은 2개의 semi space로 또 나뉩니다. 객체는 처음에 New space의 첫 번째 semi space에 할당됩니다. 만약 GC로부터 한 번 생존한다면, 다른 semi space로 이동합니다. 그리고 생존한 객체들이 또 한 번 GC로부터 생존하면, Old space로 이동합니다. 가비지 컬렉션은 New space에서의 마이너 GC와 Old space에서의 메이저 GC로 각각 다른 방식으로 동작합니다. 우선 The Generational Hypothesis 에 대해 살펴보도록 하겠습니다. 이 가설은, 대부분의 경우 새로운 객체가 오래된 객체보다 쓸모없어질 가능성이 높다는 것입니다. 오래된 객체가 쓸모없어질 가능성이 낮은데 GC가 모든 객체를 매번 검사하는 것은 비효율적입니다. 객체의 특성에 맞춰 힙 영역을 크게 New space와 Old space로 분류하여 새로운 객체는 New space에서, 오래된 객체는 Old space에서 각 영역에 최적화된 GC들(마이너, 메이저)로 관리합니다. 마이너 GC는 객체들의 생명주기가 짧은 New space에서 빠르게 가비지 컬렉션을 하고, 메이저 GC는 메모리 사이즈가 큰 Old space에서 가비지 컬렉션을 합니다. 1. 마이너 GC(Scavenger) 먼저 마이너 GC에 대해 알아봅시다. New space의 쓰레기를 수집해 주는 마이너 GC입니다. 앞서 말했듯이, New space에 있는 대부분의 객체들은 수집되길 원합니다(메모리에서 해제). 마이너 GC에서 살아남은 객체들은 항상 새로운 곳으로 대피(evacuation) 합니다. 즉 2개의 semi space가 있고, 이 대피 과정을 위해서 언제나 1개의 semi space는 비어있습니다. 이 비어있는 영역은 To space, 객체들이 머무르는 영역은 From space라고 부릅니다. From space에서 To space로 이동할 때 살아남은 객체들은 연속적인(붙어있는) 메모리로 이동합니다. 이는 메모리 단편화를 주기적으로 방지해 주는 장점이 있습니다. 그리고 객체는 새로운 메모리 주소값으로 포인터가 갱신됩니다. Mark & Sweep 알고리즘으로 살아남을 객체를 정한다. From space에서 To space로 생존한 객체들의 대피가 완료되면, From space에 남아있는 더 이상 쓸모없는 객체들을 버립니다. 마지막으로 From space와 To space의 역할을 서로 바꿔줍니다. 새로운 객체가 할당된다고 가정해 봅시다. 먼저, From space의 다음 빈 주소에 할당됩니다. 그리고 새로운 객체는 생존하여 To space로 대피합니다. 하지만 기존에 한 번 생존했던 4개의 객체는 또 한 번 생존하면 To space가 아닌 Old space로 이동됩니다. 2. 메이저 GC Old space에 있는 객체들은 어떻게 가비지 컬렉션 되는지 알아봅시다. Mark-Sweep-Compact 알고리즘과 Tri-color 알고리즘을 사용합니다. 기본적인 로직은 참조되지 않는 객체를 더 이상 쓸모없는 객체로 간주합니다. 구체적으로는 3단계에 걸쳐서 동작합니다. 마킹 어떤 객체들이 가비지 컬렉션 대상인지 알아내기 위한 단계입니다. Roots라는 실행 스택과 전역 객체를 담고 있는 객체의 set부터 시작해서 객체들을 dfs로 순회하며 Tri-color(white, gray, black)로 마킹합니다. white: GC가 아직 탐색하지 못한 상태 gray: 탐색은 했으나, 해당 객체가 참조하고 있는 객체가 있는지 확인을 안한 상태 black: 해당 객체가 참조하고 있는 객체까지 확인을 한 상태 모든 객체는 흰색으로 마킹 root 객체를 회색으로 마킹 후 덱에 push_front 출처: https://v8.dev/blog/concurrent-marking 덱에서 pop_front 하여 객체를 꺼냄 꺼낸 객체는 검은색으로 마킹 꺼낸 객체가 참조하는 객체들은(인접 객체) 회색으로 마킹 후 덱에 push_front 5-1. 흰색 -> 회색인 경우에만 덱에 push_front 5-2. 여러 객체가 참조하는 객체인 경우 이미 회색 or 검은색일 수 있음 5-3. 회색 or 검은색인 경우 탐색 과정에서 방문된 객체이므로 덱에 push_front 하지 않음 출처: https://v8.dev/blog/concurrent-marking 3 ~ 5번 과정을 반복하며 덱이 빌 때까지 반복 최종적으로 모든 객체들이 흰색 or 검은색 출처: https://v8.dev/blog/concurrent-marking 스위핑 여전히 흰색으로 마킹된 객체들의 메모리 주소를 free-list라고 부르는 자료구조에 추가합니다. 이제 이 주소들의 메모리 공간은 사용 가능하여 새로운 객체가 저장 가능합니다. 압축 메모리 단편화가 심한 페이지들을 재배치하여 추가적인 메모리를 확보합니다. Orinoco 위에서 배운 전통적인 마이너 GC와 메이저 GC의 가비지 컬렉션이 수행할 때 프로그램이 멈추게 됩니다. 이를 stop-the-world라고 합니다. 이 시간이 길어질수록 페이지가 느려지거나 렌더링이 지연되어 사용자들은 UX 경험이 좋지 못할 것입니다. 이에 Orinoco 프로젝트를 통해 GC는 발전해 왔습니다. 최신의 GC에는 어떤 기술들이 추가되었는지 간략하게 알아봅시다. Parallel 기존에는 메인 쓰레드 혼자 하던 일을 헬퍼 쓰레드들과 균등하게 나누어 일을 합니다. 쓰레드 간의 동기화를 처리해야 해서 오버헤드는 생기지만 stop-the-world 시간이 크게 감소합니다. 출처: https://v8.dev/blog/trash-talk Incremental 메인 쓰레드가 적은 양의 작업을 간헐적으로 처리합니다. 메인 스레드에서 가비지 컬렉션에 소요하는 시간이 분산되어, 좋은 UX를 제공할 수 있습니다. 출처: https://v8.dev/blog/trash-talk Concurrent 메인 쓰레드는 더 이상 가비지 컬렉션을 하지 않고, 헬퍼 쓰레드들이 수행합니다. 기술적으로 구현하기는 어렵지만, 메인 쓰레드의 stop-the-wolrd 시간이 전혀 없다는 큰 장점이 있습니다. 출처: https://v8.dev/blog/trash-talk Idle-time GC 개발자는 GC에 직접 접근할 수 없습니다. 하지만 v8은 크롬과 같은 embedder에게 가비지 컬렉션을 유발할 수 있는 메커니즘을 제공합니다. 크롬은 프로그램이 쉬는 free나 idle time을 알 수 있습니다. 예를 들면, 1초에 60프레임을 제공하는 크롬은 1프레임을 렌더링 하기 위해 약 16ms(1s / 60)가 소모됩니다. 만약 애니메이션 프레임 렌더링 작업이 16ms 보다 빨리 끝나면, 크롬은 다음 프레임 작업 전까지 가비지 컬렉션을 유발합니다. 출처: https://v8.dev/blog/trash-talk 결론 v8엔진의 메모리와 GC 동작을 이해함으로써 효율적인 메모리 사용과 좋은 프로그래밍 패턴을 생각하는데 도움이 될 것입니다. 다음에는 우리가 개발하면서 마주칠 메모리 누수 문제들에 대해 공부해보고 알아보는 시간을 가질 것입니다. 참고 https://v8.dev/blog/trash-talk https://v8.dev/blog/concurrent-marking https://ui.toast.com/weekly-pick/ko_20200228 https://charming-kyu.tistory.com/19 https://blog.naver.com/PostView.nhn?isHttpsRedirect=trueɫlogId=dlaxodud2388&logNo=222307876737
