[번역] 자바스크립트의 내부 동작: 가비지 컬렉터(Garbage Collector)
Soshy·

메모리 누수가 발생하는 이유와 마크 앤 스윕(Mark-and-Sweep) 알고리즘의 작동 원리
티모시는 자신이 만들고 있는 대시보드를 멍하니 바라보고 있었습니다. 화면은 점점 느려졌고, 동작은 무거워졌습니다.
"이해가 안 돼. 나는 그저 객체를 생성하고 있을 뿐이야. 무거운 계산을 돌리는 것도 아닌데 왜 브라우저가 멈추는 걸까?"
마가렛은 칠판으로 걸어가 커다란 직각형을 하나 그렸습니다.
"그건 공간이 부족하기 때문이야, 티모시. 이게 바로 **힙(Heap)**이라는 거야."
힙(Heap): 메모리는 유한하다
"힙은 자바스크립트가 객체, 배열, 함수를 저장하는 공간이야." 마가렛이 설명했습니다. "공간이 넓긴 하지만 무한하지는 않지. 메모리를 해제하지 않고 계속 할당하기만 하면, 사용할 수 있는 공간이 바닥나게 돼. 결국 브라우저가 멈추는 거지."
"하지만 난 메모리를 해제한 적이 없는걸." 티모시가 말했습니다. "난 그냥 다음 함수로 넘어갈 뿐이야."
"메모리를 직접 수동으로 해제할 필요는 없어." 마가렛이 바로잡아 주었습니다. "**가비지 컬렉터(Garbage Collector, GC)**가 대신 해주니까. 하지만 GC도 아주 엄격한 규칙을 따르고 있어."
도달 가능성: 마크 앤 스윕(Mark-and-Sweep)
마가렛은 칠판 맨 위에 작은 원을 하나 그리고 **루트(Root, Window)**라고 적었습니다.
"GC가 어떤 객체를 남겨야하고 어떤 객체를 삭제해야 하는 지를 어떻게 알까?" 그녀가 물었습니다.
"내가 사용 중인지를 확인하나?"
"아니," 마가렛이 답했습니다. "**도달 가능성(Reachability)**을 확인하는 거야."
그녀는 루트에서 여러 객체로 이어지는 화살표를 그렸습니다.
"이게 바로 마크 앤 스윕(Mark-and-Sweep) 알고리즘이야."
- 루트(The Roots): GC는 "루트"에서 시작한다. (전역
window객체와 현재의 콜 스택) - 참조(The References): GC는 모든 참조(모든 변수와 속성)를 따라간다. 만약 객체가 루트와 연결되어 있다면, 그 객체는 "안전함(Safe)"으로 표시된다.
- 스윕(The Sweep): 루트에서 도달할 수 없는 모든 객체는 쓰레기(Garbage)로 간주된다. 삭제 대상으로 표시된 뒤 다음 GC 사이클에서 제거(Sweep)된다.
연결 해제 (The Disconnect)
마가렛은 칠판에 그려진 선 하나를 지웠습니다.
let user = { name: "Timothy" };
// 1. The 'user' variable is a Reference from the Root to the object.
user = null;
// 2. The Reference is broken.
"네가 user = null이라고 설정할 때, 넌 객체를 삭제한 게 아니야. 그저 참조를 끊었을 뿐이지." 마가렛이 설명했습니다.
"그럼 객체는 홀로 남겨지는 건가?" 티모시가 물었습니다.
"정확해. 다음번에 GC가 실행될 때, 루트(Root)에서 시작해서 그 객체를 찾으려고 하겠지만 결국 실패하게 될 거야. 도달할 수 없는 상태가 되었으니, 그 공간은 다시 회수(reclaimed)되는 거지."
메모리 누수 (The Memory Leak)
티모시는 자신의 느려진 코드를 빤히 바라보았습니다. "그러면 내 앱이 느려진 건, 더 이상 필요 없는 객체들을 내가 계속 붙잡고 있기 때문인가?"
"맞아, 그게 바로 **메모리 누수(Memory Leak)**야. 의도치않게 참조를 계속 살려두고 있는 것이지." 마가렛이 말했습니다.
그녀는 칠판에 개발자들이 흔히 빠지는 함정 하나를 적었습니다.
function startDashboard() {
const hugeData = new Array(100000).fill("Data");
// The Trap:
window.addEventListener('resize', () => {
console.log(hugeData.length);
});
}
티모시는 다이어그램을 유심히 살펴보았습니다.
- **루트(Root, Window)**에 이벤트 리스너(
resize)가 연결되어 있다. - **리스너(Listener)**는
hugeData를 사용하는 함수다. - **클로저(Closure)**에 의해, 리스너는
hugeData에 대한 참조를 유지해야만 한다.
"Window가 리스너를 붙잡고 있고, 그 리스너가 데이터를 붙잡고 있네." 티모시는 깨달았습니다.
"정답이야." 마가렛이 말했습니다. "startDashboard 함수의 실행이 끝나더라도 그 연결은 그대로 유지돼. GC가 보기에는 루트에서부터 네 거대한 배열까지 이어지는 명확한 경로가 살아있는 거지. 그러니 GC는 그 데이터를 삭제할 수 없어."
해결책 (The Solution)
"어떻게 해결할 수 있을까?" 티모시가 물었습니다.
"참조를 끊어내야만 해." 마가렛이 답했습니다.
그녀는 정리 코드(cleanup code)를 적어 내려갔습니다.
window.removeEventListener('resize', myListener);
"리스너를 제거하는 거야. 그러면 연결이 끊어지지. 거대한 데이터는 이제 도달 불가능한 상태가 되고, GC가 이를 깨끗하게 쓸어버릴 거야. 그러면 브라우저도 다시 숨을 쉴 수 있지."
결론 (The Conclusion)
"메모리 관리라는 건 모든 객체를 일일이 세세하게 관리하는 게 아니야." 마가렛은 손에 묻은 분필 가루를 털어내며 결론지었습니다.
"그건 데이터를 계속 살아있게 만드는 보이지 않는 연결 고리를 이해하는 일이란다. 필요 없는 연결은 끊어내고, 나머지는 가비지 컬렉터가 제 할 일을 하도록 맡기렴."
불필요한 참조가 GC에 의해 잘 제거되도록 돕는 방법을 쉽게 잘 풀어서 써준 글이라는 생각이 들었다. 돌이켜보면 나는 코드를 작성할 때, "왜 이 시점에 이 코드가 필요하지?"에 대한 명확한 이해 없이 관습적으로 cleanup code를 작성하곤 했던 것 같다. 이번 글을 통해 보이지 않는 참조의 연결 고리를 끊어주는 것이 메모리 관리에 왜 중요한 것인지 그 본질적인 이유를 깨달을 수 있어 좋았다.