안녕하세요 동쪽별입니다. 오랜만에 자바스크립트 관련 글을 쓰네요 😁
이번 글에서는 자바스크립트의 가비지 컬렉션에 대해 완전 정복해보도록 하겠습니다!
메모리 누수
자바스크립트 메모리는 단순 변수에 사용되는 스택 메모리와 복잡한 객체에 사용되는 힙 메모리로 구분됩니다.
단순 변수(= 원시 타입) : String, Number, Boolean, Null, Undefined, Symbol, BigInt 등
- 힙 메모리에 저장된 객체의 주소값 또한 스택 메모리에 저장됩니다.
복잡한 객체(= 참조 데이터 타입) : Object, Array, Function 등
아래 그림의 왼쪽은 스택 영역으로 실행 컨텍스트와 원시 타입의 데이터를 저장하는데 사용되고, 오른쪽은 힙 영역으로 객체를 저장하는데 사용되는 것을 볼 수 있습니다.
(실행 컨텍스트가 뭔지 잘 모르신다면 👉 알아보기)
그럼, fn2 함수 실행 컨텍스트 → fn1 함수 실행 컨텍스트 → 전역 실행 컨텍스트 순서로 함수가 실행될 것입니다.
실행 완료된 실행 컨텍스트는 스택에서 pop되어 제거될 것이구요.
위 그림에서 두 함수 실행 컨텍스트가 순차적으로 실행 완료 후 스택에서 제거된 것을 볼 수 있습니다.
이제 두 함수 실행 컨텍스트에서 참조한 힙 내부 객체들은 더이상 참조되지 않으므로 필요가 없어졌습니다.
→ 이러한 불필요한 메모리가 힙 내부에 쌓이면 메모리 누수로 인해 메모리를 낭비하여 성능이 떨어지게 될 것입니다.
메모리 누수? 사용되지 않는 메모리를 해제하지 못하여 계속 메모리를 점유하는 것
따라서! 가비지 컬렉터가 더이상 참조되지 않는 객체를 인지하고, 불필요한 메모리를 해제합니다.
위 그림에서 fn2 함수 실행 컨텍스트가 참조했던 힙 메모리 내 객체가 해제된 것을 볼 수 있습니다.
fn1 함수 실행 컨텍스트에서 참조했던 객체는 전역 실행 컨텍스트에서 여전히 참조하고 있기 때문에 해제되지 않았구요.
가비지 컬렉션
가비지 컬렉션으로 인해 메모리 누수가 방지되는 것을 알아보았습니다.
이러한 가비지 컬렉션에 대해 더 자세히 살펴봅시다.
- 현재 함수의 지역 변수와 매개변수
- 중첩 함수의 스코프 체인에 있는 함수에서 사용되는 변수와 매개변수
- 전역 변수
위 값들은 어떻게든 접근하거나 사용할 수 있기에 메모리에서 해제되지 않습니다.
이러한 값들을 루트(Root)라 부릅니다.
자바스크립트 엔진 내에서 가비지 컬렉터는 끊임없이 동작합니다.
가비지 컬렉터는 모든 객체를 모니터링하고 루트가 아닌 값, 즉 참조 없는 값들을 메모리에서 해제합니다.
위 그림과 같이 여러 객체들이 서로 참조하고 있다 하더라도, 루트인 전역 객체에서 참조하고 있지 않기 때문에 해당 객체들에 접근할 수 없습니다.
따라서, 루트가 아닌 값들이 루트와 연결되어 있지 않으면 가비지 데이터로 식별합니다.
V8 엔진의 가비지 컬렉션 동작 방식
대표적인 자바스크립트 엔진인 V8 엔진에서 가비지 컬렉의 동작 방식이 어떠한지 알아보겠습니다.
프로그램을 실행하면 메모리의 Resident Set이라는 빈 공간이 할당됩니다.
Resident Set? 메인 메모리에 유지되는 프로세스에 의해 점유되는 메모리
그리고 Resident Set은 스택 영역과 힙 영역으로 나뉩니다.
(힙 영역을 더 세부적으로 나눌 수 있지만, 가비지 컬렉션이 일어나는 부분인 New space와 Old space에 대해서만 다루겠습니다.)
New space에 새로 만들어진 객체가 저장됩니다. → Young generation
Old space에는 Nes space에서 살아남은 객체들이 저장됩니다. → Old generation
- Pointer space에는 다른 객체들을 참조하는 객체들이 저장됩니다.
- Data space에는 문자열, 실수 등의 데이터만을 가진 객체들이 저장됩니다.
New space는 2개의 Semi space로 나뉩니다.
- 객체는 처음에 New space의 첫번째 Semi space에 할당됩니다.
- 만약 가비지 컬렉션으로부터 한번 생존한다면 다른 Semi space로 이동합니다.
- 생존한 객체들이 또 한번 가비지 컬렉션으로부터 생존하면 Old space로 이동합니다.
왜 이러한 동작을 할까요?
"대부분의 경우 새로운 객체가 오래된 객체보다 쓸모없어질 가능성이 높다"
위 가설을 바탕으로 했을때, 오래된 객체를 포함하여 모든 객체를 매번 검사하는 것은 매우 비효율적입니다.
따라서 힙 영역을 두 영역으로 분류하고, 각 영역에 최적화된 GC(Garbage Collector, 가비지 컬렉터)들로 관리합니다.
- New space → 마이너(Minor) GC
- Old space → 메이저(Major) GC
그럼 마이너 GC와 메이저 GC의 관점으로 더 자세히 알아보겠습니다.
New space에 있는 대부분의 객체들은 마이너 GC에 의해 메모리 해제되길 원합니다.
마이너 GC에서 살아남으면 새로운 곳으로 대피합니다.
- 이 대피 과정을 위해서 언제나 하나의 Semi space는 비어있습니다.
- 비어있는 영역을 To space, 객체들이 머무르는 영역을 From space라 칭합니다.
From space에서 To space로 이동한 객체들은 연속적인 메모리로 이동합니다.
- 이는 메모리 단편화를 주기적으로 방지해 주는 장점이 있습니다.
- 그리고 객체는 새로운 메모리 주소값으로 포인터가 갱신됩니다.
메모리 단편화? 아래 현상들로 인해 사용가능한 메모리가 충분히 존재하지만 할당이 불가능한 상태를 말합니다.
1. 내부 단편화 : 메모리를 할당할 때 필요한 양보다 더 큰 메모리가 할당되어 할당이 불가능한 메모리가 생기는 현상
2. 외부 단편화 : 메모리 할당 및 해제의 반복으로 중간에 할당이 불가능한 메모리가 생기는 현상
대피가 완료된 후 From space에 남아있는, 즉 더이상 쓸모없는 객체들을 버립니다.
그리고 From space와 To space의 역할을 서로 바꿔줍니다.
→ 즉, 객체들이 존재하는 space가 From이 되고, 비어있는 space가 To가 되는 것입니다.
그리고 마이너 GC가 또 다시 동작한 후, 한번 생존했던 객체들이 또 다시 생존하면 Old space로 이동합니다.
Old space에 존재하는 객체들은 Mark-Sweep-Compact, Tri-Color 알고리즘으로 가비지 컬렉션 대상을 찾아냅니다.
내부 알고리즘
Mark-And-Sweep
1. 먼저 가비지 컬렉터는 루트 정보를 수집하고 이를 mark(기억)합니다.
2. 루트가 참조하고 있는 모든 객체를 방문하고 이것들 또한 mark합니다.
3. mark된 모든 객체를 방문하며, 그 객체들이 참조하는 객체들도 mark합니다.
(이미 mark된 객체를 다시 방문할 일은 없습니다.)
4. 루트에서 시작하여 모든 객체를 방문할 때까지 위 과정을 반복합니다.
5. 탐색이 종료되면 mark되지 않은 객체들을 메모리에서 sweep(해제)합니다.
이러한 mark-and-sweep 기법은 포인터 추적 방식 중 가장 단순한 기법으로, 할당받은 메모리 중 1비트를 남겨 메모리 사용 여부에 대한 표시로 사용합니다.
즉, mark하는 것이 남긴 1비트의 값을 변경하는 것이라 할 수 있겠습니다.
Tri-Color
Mark-And-Sweep 알고리즘은 접근 가능/불가능으로 두가지 경우에 대해서만 마킹을 했다면, Tri-Color 알고리즘은 3가지 색으로 마킹하는 것입니다.
1. 루트를 시작으로 DFS 순회하며 아래 3가지 색으로 마킹합니다.
- white : GC가 아직 탐색하지 못한 상태
- gray : 탐색은 했으나, 해당 객체가 참조하고 있는 객체가 있는지 확인을 안한 상태
- black : 해당 객체가 참조하고 있는 객체까지 확인을 한 상태
2. DFS 탐색 종료 후, 흰색으로 마킹된 객체들의 메모리 주소를 Free-List라 부르는 자료구조에 추가합니다.
Free-List? 메모리의 할당되지 않은 영역들이 연결된 연결리스트로, 메모리 할당이 필요하면 가장 끝 부분에 있는 영역을 제거하고 해당 영역을 사용합니다.
- 이제 이 주소들의 메모리 공간이 사용 가능하여, 새로운 객체를 저장할 수 있게 됩니다.
Mark-Sweep-Compact
Mark-And-Sweep(Tri-Color) 알고리즘 동작 이후, 단편화된 메모리 공간을 연속적으로 합쳐주는 동작을 합니다.
즉, 참조되어있는 메모리 주소 값을 변경하고 작업을 합니다.
이로인해 추가적인 메모리를 확보할 수 있습니다.
단점
1. 객체가 쓸모없는 시점을 프로그래머가 알고 있는 경우에도 위 알고리즘들을 사용함으로 비용이 듭니다.
2. GC가 행동하는 타이밍과 탐색 시간을 사전에 예측하기 어렵기에, 실시간 시스템에는 적합하지 않습니다.
- GC 알고리즘이 동작하게 되면 실시간 시스템(미사일 발사, 비행시스템 등) 동작이 멈출 수 있는 위험성이 생기게 됩니다.
효과적으로 활용하기
가비지 컬렉션에 대해 알아보았으니, 이를 효과적으로 사용해야겠죠?
1. 불필요한 전역 변수 사용하지 않기
전역 변수는 루트로서 가비지 컬렉터에 의해 메모리 해제가 되지 않습니다.
따라서 가능한 적게 전역 변수를 사용하는 것이 좋습니다.
2. DOM 노드 분리하지 않기
let btn = document.querySelector('button')
let child001 = document.querySelector('.child001')
let root = document.querySelector('#root')
btn.addEventListener('click', function() {
root.removeChild(child001)
})
위 코드는 버튼 클릭 후 .child001 클래스의 노드를 제거하는 동작을 합니다.
하지만 child001 변수는 아직 해당 노드를 참조하고 있습니다.
따라서 해당 노드의 메모리는 가비지 컬렉터에 의해 해제되지 않게 됩니다..
아래와 같이 코드를 변경해봅시다!
let btn = document.querySelector("button");
btn.addEventListener("click", function () {
let child001 = document.querySelector(".child001");
let root = document.querySelector("#root");
root.removeChild(child001);
});
.child001 클래스의 노드를 참조하는 것을 콜백 함수 내부로 이동했습니다.
해당 노드를 제거하고 콜백 함수 실행이 종료되고 나면, 노드의 참조 값이 사라지고 가비지 컬렉터에 의해 메모리 해제가 됩니다.
3. 불필요한 콘솔 출력 지양하기
console.log 메서드에 담은 객체는 브라우저에 의해 저장됩니다.
따라서 가비지 컬렉터에 의해 메모리 해제가 되지 않습니다.
개발 환경일 때 디버깅 목적으로 콘솔을 출력할 수 있지만, 실제 환경일 땐 가능한 한 콘솔에 데이터를 출력하지 않는 것이 좋겠습니다.
만약 콘솔 출력을 원한다면 다음과 같이 작성합시다.
if(isDev) {
console.log(obj)
}
4. 타이머 해제하기
setInterval(() => {
let myObj = largeObj
}, 1000)
setInterval 콜백 함수가 largeObj를 참조한 후, 타이머가 해제되지 않으면 largeObj의 메모리가 가비지 컬렉터에 의해 해제되지 않습니다.
따라서 아래와 같이 원하는 시점에 타이머를 해제하는 코드로 변경합시다!
let timer = setInterval(() => {
if (index === 3) {
clearInterval(timer);
}
let myObj = largeObj;
index++;
}, 1000);
.
.
.
개발자에게 효율적인 메모리 관리는 엄청 중요합니다.
메모리 누수가 증가하게 되면, 웹 페이지가 멈추거나 페이지 로딩 속도가 느려지는 등 문제가 발생할 수 있습니다.
따라서 자바스크립트의 가비지 컬렉션의 동작 방식에 대해 이해하고, 효율적으로 활용하도록 합시다 👍