웹 페이지가 버벅이거나 동작을 멈추는 경우에는 여러 원인이 있을 수 있습니다. 그 중 한 가지 원인은 바로 메모리 누수 로, 메모리를 신경쓰지 않고 코드를 작성하면 결국 사용자 경험에 나쁜 영향을 미치게 됩니다. 오늘은 메모리 누수란 무엇이고, 어떤 상황에서 메모리 누수가 발생할 수 있는지 알아보도록 하겠습니다. 가비지 컬렉션 변수 또는 데이터가 더이상 필요하지 않다면 이들은 '가비지 변수' 또는 '가비지 데이터' 라는 미사용 값으로 분류됩니다. 만약 이 데이터가 계속 쌓이면 메모리 사용량을 초과하게 될 것이므로, 그러기 전에 가비지 컬렉션을 꾸준히 실행해야만 합니다. 자바스크립트의 가비지 컬렉션 C나 C++과는 달리 자바스크립트는 자동 가비지 컬렉션을 지원하지만 이것이 메모리 관리를 신경쓰지 않아도 된다는 것은 아닌데요, 자바스크립트의 가비지 컬렉션은 크게 두 가지 특징이 있습니다.
- 자바스크립트 엔진의 가비지 컬렉터는 콜 스택을 검사하면서 가비지 데이터를 찾고, 가비지 데이터를 메모리에서 해제한다.
- 전역 컨텍스트의 변수(전역 변수)는 일반적인 상황에서는 가비지 컬렉션의 대상이 되지 않는다.
오늘 다룰 '메모리 누수' 란 결국 가비지 컬렉터가 잡아내지 못하는 변수(함수)에 의해 발생하는 현상인데요, 그럼 이제 어떤 자바스크립트 코드가 메모리 누수의 원인이 될 수 있는지 알아보도록 하겠습니다. 개발자 도구의 Performance (또는 Memory) 탭을 활용하면 누수된 메모리 크기를 모니터링할수 있습니다. 전역 변수의 사용
지역 변수는 해당 컨텍스트가 종료됨과 동시에 '가비지 변수' 로 분류되지만, 전역 변수는 코드가 실행되는 한 언제 사용될지 모르기 때문에 일반적인 상황에서는 가비지 컬렉션의 대상이 되지 않습니다.코드로 예를 들어 보겠습니다. function fn1 () {
let a = {
name: 'chanmin'
}
let b = 3
function fn2() {
let c = [1, 2, 3]
}
fn2()
return a
}
let res = fn1()
함수 호출의 순서는 fn1() 다음 fn2() 이므로, 스택에도 이 순서대로 컨텍스트가 쌓이게 되면서 메모리에도 값이 할당됩니다.
fn2() 함수의 동작이 끝나면 fn2() 함수의 실행 컨텍스트는 사라지고, 1004번 주소에 할당되었던 [1,2,3] 는 가비지 데이터로 분류되어 메모리에서 해제됩니다.
이제 fn1() 함수가 동작을 마치고 종료됐습니다. fn1() 함수는 {name: "chanmin"} 객체를 리턴해 전역 변수 res 에 할당하고 있으므로, {name: "chanmin"} 객체는 가비지 데이터로 분류되지 않고 전역 컨텍스트에 등록됩니다.
이제 모든 함수의 동작이 끝나고 전역 컨텍스트만이 남은 상황인데, 일반적인 상황에서 전역 변수는 가비지 컬렉션에서 제외되므로 {name: "chanmin"} 객체는 계속해서 메모리를 점유하고 있는, 즉 메모리 누수의 원인이 되고 있습니다. 아마 코딩을 처음 배우실때 "전역 변수의 사용을 지양해라~" 는 메모를 보신 분들도 계실 텐데, 전역 변수는 지역 변수와의 이름 충돌뿐만 아니라 이렇게 메모리 누수의 원인으로도 작용할 수 있음에 유의하셔야 합니다. DOM의 삭제 <html lang="en">
<body>
<div id="root">
<div class="target">삭제할 요소</div>
<button>요소 삭제</button>
</div>
<script>
let btn = document.querySelector("button");
let target = document.querySelector(".target");
let root = document.querySelector("#root");
btn.addEventListener("click", function () {
root.removeChild(target);
});
</script>
</body>
</html>
여기 DOM 요소를 삭제할 수 있는 코드가 있습니다.
정말 아무런 문제도 없어 보이는 코드지만, 이 역시 메모리 누수의 위험이 있는 코드입니다. let btn = document.querySelector("button");
let target = document.querySelector(".target");
let root = document.querySelector("#root");
btn.addEventListener("click", function () {
root.removeChild(target);
});
바로 이 부분에서, root.removeChild(target) 를 통해 target 요소를 렌더 트리에서 삭제하는데까지는 성공했습니다. 그런데 target 변수는 전역 컨텍스트에 선언된 변수이므로 target 에 저장된 요소의 참조, 즉 document.querySelector('.target'); 의 리턴값은 여전히 메모리에 적재된 상황인데요, 따라서 위 코드는 이렇게 리팩토링할 수 있습니다. let btn = document.querySelector("button");
btn.addEventListener("click", function () {
let target = document.querySelector(".target");
let root = document.querySelector("#root");
root.removeChild(target);
});
이렇게 콜백 함수의 컨텍스트를 활용하면 버튼을 클릭할 때 요소가 렌더 트리에서 제거됨과 동시에 target 변수에 저장된 메모리의 참조 역시 해제되도록 개선할 수 있습니다. 콘솔 출력 (console.log() 사용하기) 실제 프러덕션에 배포할 코드에는 콘솔 출력을 절대 포함하지 않도록 컨벤션을 정해둔 경우가 대부분일 텐데요, 콘솔 출력을 포함하지 말아야 하는 이유는 콘솔에 불필요한 데이터를 노출하는 문제도 있지만, 콘솔 출력 역시 메모리 누수에 영향을 미친다는 것이 큰 이유입니다. 아래 코드를 통해 콘솔 출력과 메모리의 관계를 실험해 보겠습니다.
<html lang="en">
<body>
<button>버튼</button>
<script>
document.querySelector("button").addEventListener("click", function () {
let obj = new Array(10000000);
});
</script>
</body>
</html>
먼저 콘솔 출력을 수행하지 않은 결과물입니다. 버튼을 누를 때, 처음 한번만 메모리에 참조값이 할당되고 더이상 새로운 메모리를 차지하지 않는 모습입니다. (※ 사실 원래는 버튼을 누를 때마다 메모리에 할당되고 이전 메모리가 해제돼야 하는데, 왜 딱 한번만 할당됐는지는 잘 모르겠습니다.) 그에 반해 콘솔 출력을 수행하면 메모리(파란 막대)가 해제되지 않고, 계속해서 힙 영역에 쌓이게 되는 모습입니다.
극단적인 예시이긴 하지만, 계속된 메모리 누수의 결과 한 웹 페이지에서만 1기가가 넘는 메모리가 누수될 수도 있음을 알 수 있습니다. 이처럼 console 객체는 전역 컨텍스트에 포함된 객체이므로, 콘솔 출력을 잘못 사용하면 해당 메모리를 돌려받지 못함에 유의해야 합니다. 결론 메모리 누수의 원인은 이외에도 클로저를 잘못 사용하는 경우나, setInterval 등으로 생성한 타이머를 초기화하지 않는 등의 원인이 있습니다. 그러나 큰 맥락은 전역 변수에 값이 할당되는 것을 최대한 피해야 한다는 것과, 사용하지 않는 값은 초기값 또는 초기화 함수(clearInterval 등)으로 반드시 초기화해줘야 한다는 것입니다. 저도 한때는 요즘 컴퓨터의 성능만 믿고 최적화를 감안하지 않고 코드를 작성하곤 했는데요, 이번 글을 계기로 보다 최적화에 신경써 코드를 작성하게 될 것 같네요. 🙂
|