최근 면접에서 가비지 컬렉션 관련된 질문을 받았고, 이에 대해서 공부해볼 필요가 있다고 생각해서 Mdn의 Memory management를 기반으로 정리해봤다.
우선 이 글은 자바스크립트의 관점에서 가비지 컬렉션을 바탕으로 하고 있다.
우선 메모리는 할당 => 사용(읽기, 쓰기) => 해제 의 사이클을 가지게 되는데 여기서 할당과 해제는 언어에 따라 작동 방식이 다르다.
우선 C같은 저수준 언어에서는 메모리 관리를 개발자가 제어할 수 있다. C는 malloc
, free
, calloc
등을 이용해서 메모리를 할당하고 해제하는데 이는 더 효율적이고 세밀한 제어가 가능하다는 장점이 있지만, 메모리 관리의 난이도가 높고 프로그래머가 신경써야 할 부분이 많아져서 부담이 증가한다는 점이 있다.
CERT C라는 C언어 관련 보안 책을 이용해 수업을 들었던 적이 있는데 C가 최적의 메모리 성능을 제공할 수 있으나 관리의 어려움이나 개발자의 실수로 발생하는 보안적 문제가 있었다.
Javascript, Java, C++ 같은 고수준 언어에서는 메모리를 암묵적으로 관리한다. 메모리가 필요할 때 메모리을 할당하고, 더 이상 메모리가 필요하지 않을 때 가비지 컬렉터가 이를 해제하는 방식이다.
물론 고수준 언어가 메모리관리를 신경쓰지 않아도 된다는 뜻은 아니다.
그럼 이제 자바스크립트는 어떻게 메모리를 관리하는지 알아보자.
우선 자바스크립트는 값 선언 시점이나 함수 호출의 결과등을 할당 하기 위해서 메모리를 할당한다.
const n = 123; // 정수 123을 담기 위한 메모리 할당
const obj = {
x: 1,
y: 1,
} // 객체와 해당 객체에 포함된 값들을 담기 위한 메모리 할당
function func(param) {
} // 함수를 위한 할당 (자바스크립트에서 함수는 일급 객체로 값으로 쓰일 수 있다.)
const date = new Date(); // Date 객체를 위한 메모리 할당
const div = document.createElement("div") // DOM Element를 위한 메모리 할당
할당 된 메모리는 값을 읽거나 쓰기 위해서 사용한다.
자바스크립트는 할당된 메모리가 더이상 필요 없다고 판단되면 메모리를 해제한다.
이 글의 주요 주제인 가비지 컬렉션은 메모리 해제와 관련이 있다.
메모리 할당 시점을 정하는 것에는 문제가 없지만, 메모리 해제 시점을 정하는 것은 어렵다.
"할당된 메모리가 현 시점에서 계속 필요한지 아닌지를 어떻게 판단하는가?"
이를 고려해야 하기 때문인데, 가비지 컬렉션은 할당된 메모리가 더 이상 필요하지 않은지를 판단하기 위해 몇가지 알고리즘을 베이스로 설계된다.
우선 Reference-counting 알고리즘은 객체가 참조되는 횟수를 기록하여 객체가 사용될 여부를 판단하는 단순한 알고리즘이다.
어떤 객체 A를 참조하고 있는 다른 객체가 있다면, 객체 A가 아직 필요한 객체라고 판단하는 방식이다.
이 알고리즘은 순환참조가 있는 경우 한계가 있다. 참조를 기준으로 하기 때문에 더 이상 사용되지 않는 두 객체가 서로를 참조하고 있다면 이들은 절대 해제되지 않는다.
function f() {
const x = {};
const y = {};
x.a = y; // x는 y를 참조합니다.
y.a = x; // y는 x를 참조합니다.
return "azerty";
}
f();
// 함수 f의 호출이 완료되었기 때문에 더이상 x, y에 할당된 객체는 쓸모가 없다.
// 하지만 x가 y를, y가 x를 참조하고 있기 때문에 해당 메모리는 해제되고 회수되지 못한다.
이렇게 필요없어진 객체가 서로를 참조함으로서 각 객체의 참조 수는 1에서 떨어지지 않고, 가비지 컬렉션의 대상으로 포함되지 못한다.
이러한 순환 참조가 메모리 누수를 발생시킨다.
이러한 문제로 인해서 현재의 최신 브라우저는 참조 횟수를 세는 알고리즘을 이용하지 않는다.
내가 면접에서 대답한 방식은 이 방식이였다. 어렴풋이 가비지 컬렉션이 참조와 관련되어 동작한다고 알고 있었기 때문이다.
Mark-and-Sweep 알고리즘은 필요없는 객체를 판단하는 기준으로 도달 가능성 (reachable)을 이용한다.
이 방식의 알고리즘은 root 객체(자바스크립트의 전역 객체)가 참조하는 객체들을 찾고, 찾은 객체들에서 또 재귀적으로 참조하고 있는 객체를 찾아, 불필요한 객체들을 찾는 방식이다.
이 방식은 참조 카운팅 방식에 비해서 명확하게 동작한다.
현재의 대부분의 엔진들이 사용하는 가비지 수집 알고리즘은 이 알고리즘을 개선한 알고리즘들이라고 한다. (generational/incremental/concurrent/parallel garbage collection)
generational과 관련해서 면접 중에 자바가 세대별로 가비지를 수집한다는 얘기를 해주셨는데 추후에 더 찾아보고 싶다.
아래에서 해당 괄호 속 경우에 대해서 정리했다.
하지만 자바스크립트 메모리 관리에는 여전히 한계가 있다. C같은 저수준 언어는 당연히 메모리를 수동으로 조작하고, 자바는 수동으로 가비지 컬렉션을 실행시키는 방법이 있다.(.gc)
하지만 자바스크립트는 JS엔진에서 플래그를 이용해서 관리하는 방법이 있을지는 몰라도 언어 자체에는 그런 기능이 존재하지 않고, 추후에도 지원하지 않을 것이라고 한다.
그렇기 때문에 가비지 컬렉션이 언제 진행되는지 예측하기 어렵고, 가비지 컬렉터가 동작해야만 메모리가 해제되기 때문에 메모리에 최적화되어 있지는 않다.
보통 JS의 가비지 컬렉션은 보통 다음과 같은 상황에 실행된다고 한다.
가비지 컬렉터가 메모리를 효과적으로 수집하게 하기 위해서 다음과 같은 사항들을 신경쓸 수 있다고 한다.
전역 변수는 가비지 컬렉터에 의해서 메모리 해제가 되지 않기 때문에 불필요한 전역 변수 사용을 지양하는 것이 좋다.
console.log에 담긴 객체는 브라우저가 관리하기 때문에 가비지 컬렉션이 메모리 해제를 할 수 없다고 한다
타이머를 설정하고 해당 타이머를 해제하지 않으면 해당 타이머에 콜백으로 전달된 함수 내부의 객체는 해제되지 않는다고 한다.
setInterval(()=>{
let obj = {};
},1000)
// 타이머가 해제되지 않아서 해당 객체는 수집되지 못한다.
이벤트 핸들러로 등록한 함수가 큰 데이터 배열 등을 참조하고 있다면, 이런 메모리가 해제되지 않고 메모리 누수로 이어질 수 있다.
세대별 GC를 알아보고 싶어서 공부하던 중에 Kakao FE 기술 블로그에서 JS V8엔진의 가비지 컬렉션 동작 방식에 대한 글을 읽게 되었고, 관련된 내용이 있는 것 같아서 추가로 정리해 봤다.
V8은 객체들이 저장되는 힙을 세대에 따라서 New Space(2개의 Semi space를 가진다.), Old Space로 구분한다.
객체는 생성되면 처음에 New Space의 첫번째 Semi space에 할당되고, 가비지 컬렉션의 수집에서 한번 생존한다면
두번째 Semi Space로 이동된다. 이후 또 생존한다면 Old Space로 이동한다
첫 번째 Semi Space => 두 번째 Semi Space => Old Space
이런 방식으로 동작하는 이유는 "새로운 객체가 오래된 객체보다 쓸모없어질 가능성이 높다." 라는 가설을 바탕으로 객체를 분류하는 방식이다.
왜 살아남았다는 것은 곧 강하다는 인터넷 밈이 떠오를까..
이렇게 분류해서 없어질 가능성이 높은 객체들을 자주 검사하고, 오래된 객체들은 가끔 검사하여 가비지 컬렉션된다.
New Space는 마이너 GC, Old Space는 메이저 GC를 이용해서 가비지 컬렉션을 수행한다.
자바는 0,1,2 세대 방식으로 조금 다른 세대별 GC를 적용하는 것 같다.
우선 마이너 GC는 New Space의 객체들에 대해서 가비지 컬렉션을 수행하고(Mark-Sweep 알고리즘), 살아남은 객체는 두번째 semi Space로 대피(메모리 이동) 시킨다.
Space를 이동시키는 과정에서 메모리를 연속적으로 만들어서 메모리 단편화를 줄일 수 있다고 한다.
오..
이렇게 대피가 완료되면, 첫번째 Semi Space에 남아있는 객체(불필요한 객체)들을 버리고, 두 Semi Space의 역할을 교체한다.
오래 살아남아서 Old Space에 있는 객체들은 메이저 GC에 의해 가비지 컬렉션된다.
메이저 GC는 두 알고리즘을 잘 혼합해서 가비지 컬렉션을 수행하는데
Mark-Sweep-Compact 알고리즘 : 불필요한 메모리 삭제로 인한 메모리 단편화를 줄이기 위한 과정이 추가된 Mark-Sweep 알고리즘
Tri-Color 알고리즘은 객체를 3종류로 구분하여 순회에 도움을 준다.
이렇게 객체를 구분하고 deque(덱 자료구조)를 이용해서 DFS로 순회를 하면서 해제할 객체를 탐색한다.
자세한 알고리즘의 동작은
Kakao FE 기술블로그-메이저GC를 참고하면 그림과 함께 자세하게 설명하고 있다.
가비지 컬렉션이 수행될 떄에는 프로그램이 동작을 중단하고 컬렉션을 수행하게 된다.
이렇게 동작이 멈추는 것을 Stop-the-world라고 하며, 이를 줄이기 위해서 가비지 컬렉션은 여러 방향으로 발전하고 있다.
병렬 스레드를 이용해서 가비지 컬렉션을 수행한다. (JS는 싱글 스레드지만 JS엔진은 멀티 스레드다.) 스레드간 동기화라는 오버헤드를 감수하지만 Stop-the-world를 줄일 수 있다.
메인 스레드가 GC를 짧게 짧게 간헐적으로 수행하는 방식으로 가비지 컬렉션에 소요되는 시간이 분할되어 UX 경험을 높인다.
메인 쓰레드가 아닌 헬퍼 스레드가 가비지 컬렉션을 수행한다. 기술적으로 어렵지만 Stop-the-world 시간이 제로라는 장점이 있다.
V8엔진에서 크롬등에게 가비지 컬렉션을 맡기는 것으로, 크롬이 렌더링 과정에서 프로그램을 쉬는 시간(초당 60프레임 중 일부 프레임만 사용하여 렌더링을 끝내는 경우처럼)에 가비지 컬렉션을 적용하는 방식이다.
자바스크립트 가비지 컬렉션에 대해서 정리해 보면서 기존에 잘못 알고 있던 부분이나 메모리 관리와 관련된 많은 내용을 알 수 있는 기회가 되었다. 글을 작성하면서 의문이 드는 부분에 대해 Java나 C++의 가비지 컬렉션에 대해서도 참고를 했고, 추후 다른 언어를 학습하게 될 때 해당 언어의 가비지 컬렉션을 익히는데 많은 도움이 될 것 같다.