개발 기술/개발 이야기

[JS] 메모리 누수는 왜 발생할까?(feat. 메모리 측정법)

by GicoMomg 2023. 12. 22.

1. js는 자동으로 메모리를 관리한다

  • 자바스크립트는 변수를 선언하거나 객체를 생성할 때, 데이터를 위한 메모리 공간을 확보한다.(메모리 할당)
const num = 101;     // 선언시 num을 위한 메모리 할당됨
const str = "hello"; // 선언시 str을 위한 메모리 할당됨
  • 그리고 데이터가 더 이상 필요 없으면, 가비지 콜렉션이라는 메모리 관리 기술을 이용해 메모리 공간을 비운다.(메모리 해제)
  • 이를 통해 더 이상 사용하지 않는 데이터가 메모리 공간을 차지하지 않도록 보장해준다.
  • 자바스크립트는 메모리 할당과 해제를 자동으로 관리한다. 그래서 우리가 자바스크립트를 사용할 때 메모리 관리에 대한 고민을 크게 하지 않게 된다.

 

💡 어? 그런데 자바스크립트에서 메모리 누수가 발생하는 경우가 있는데 왜 그런 걸까?
자동으로 메모리를 해제하는데??

  • 분명 자바스크립트는 메모리를 자동으로 관리한다고 했다.
  • 하지만 때때로 데이터를 사용하지 않지만 그 데이터의 메모리 공간이 유지되는, 메모리 누수가 발생한다!
  • 이번 시간에는 메모리 라이프 사이클, 그리고 메모리 누수가 발생하는 원인에 대해 알아보았다.



2. 메모리 라이프 사이클

  • JavaScript 메모리 라이프 사이클은 크게 할당, 사용, 해제로 나눌 수 있다.

단계 설명
1. 메모리 할당(메모리 공간 확보) - 변수를 선언할 때나 객체, 배열 등을 생성할 때 메모리 할당을 한다.
2. 메모리 사용 - 할당된 메모리를 읽거나 쓰는 단계이다.
- 변수에 새로운 값을 할당할 때, 객체의 속성을 읽을 때 메모리를 사용한다.
3. 메모리 해제(메모리 공간 비우기) - 객체가 더 이상 필요하지 않게 되면, 메모리에서 해제한다.

 

2-1. 메모리 할당하기

  • 변수를 선언하거나 객체, 함수 등을 생성할 때 메모리가 자동으로 할당된다.
let name = 'lulu'; // name을 위한 메모리 공간 확보
const age = 28;    // age을 위한 메모리 공간 확보

name = 'gicomong'; // name을 위한 메모리 공간 재확보
  • 여기서 주의할 점은 데이터 유형에 따라 사용하는 메모리 형태가 다르다는 것이다.
  • number, boolean와 같은 기본 유형은 스택 메모리를 사용하고, 객체, 배열, 함수와 같은 참조 유형은 힙 메모리를 사용한다.

 

2-1-1. 기본 유형, 스택 메모리를 사용한다.

  • 스택은 LIFO(후입선출) 방식으로 데이터를 저장하는 구조이다.
  • 자바스크립트는 기본 유형을 선언하면, 스택 메모리에 해당 데이터의 변수명과 값을 저장한다.
  • 스택 형식이기 때문에, pop(), push()로 빠르게 데이터에 접근할 수 있는 장점이 있다.
  • 단, 스택은 크기가 제한되어 있다. 그래서 너무 많은 변수가 저장되면 오버플로우가 발생할 수 있다.
  • 가장 최근에 선언된 변수는 스택 메모리의 맨 위에 배치된다.

 

2-1-2. 참조유형, 힙 메모리를 사용한다.

  • 객체, 배열, 함수와 같은 참조 유형은 힙 메모리를 사용한다.
  • 힙 메모리는 동적 메모리 할당 방식으로, 스택과 달리 크기가 유동적이며 용량이 크다.
  • 참조 유형은 아래 이미지와 같이, 참조 주소가 스택 메모리에 저장되고 실제 데이터가 힙 메모리에 저장된다.

 

2-2. 메모리 사용하기

  • 선언된 데이터에 접근한다는 건, 할당된 메모리를 읽고 쓰는 것을 의미한다.
  • 아래는 메모리 사용 예시로, n이 저장된 메모리 위치를 조회하고 해당 값을 검색한다.
const n = 12;

console.log(n); // 12

 

2-3. 메모리 해제하기

  • 선언된 데이터가 더 이상 필요하지 않으면 메모리 공간을 비운다.(메모리 해제)
  • JavaScript는 가비지 콜렉션이라는 자동 메모리 관리 방법을 사용한다.
  • 가비지 콜렉션은 메모리 할당을 추적하고 할당된 메모리 블록이 필요하지 않는지 판단해 회수한다.
  • 하지만 가비지 콜렉션이 완벽하게 메모리를 자동 해제하지 못한다.
  • 그 이유는 할당된 메모리가 더 이상 필요없는지 여부를 알기 어렵기 때문이다.

 

2-4. 가비지 켈렉션으로 메모리를 자동 관리한다.

  • 앞서 언급했듯이, 가비지 컬렉션은 자동으로 메모리를 관리하는 기법이다.
  • 가비지 콜렉터는 메모리 블록이 더 이상 필요하지 않는지 판단하여 회수한다.
  • 가바지 콜렉터는 두 가지 알고리즘 방식이 있는데, 간단히 알아보자.

2-4-1. reference counting 알고리즘

💡 이 알고리즘은 '어떤 곳에서도 참조하지 않는 객체'를 '더 이상 필요 없는 객체'라고 간주한다.
참조되지 않는 객체를 "가비지"라 지칭하고, 가비지에 대한 메모리를 해제한다.

  • reference counting 알고리즘 작동 방식은 아래와 같다.
let objectA = { value: "Hello" }; // 1
let objectB = objectA;            // 2

objectA = null;                   // 3
objectB = null;                   // 4
  1. { value: "Hello" } 객체가 생성되고, objectA 은 이 객체를 참조한다. 객체의 참조 횟수는 1이다.
  2. objectBobjectA가 참조하는 동일한 객체를 참조한다. 객체의 참조 횟수는 2로 증가한다.
  3. 객체에 대한 참조가 제거된다. 객체의 참조 횟수는 1로 감소한다.
  4. 마지막으로 objectBnull로 설정하면 객체의 참조 횟수가 0이 된다. 그러면, 자바스크립트 엔진은 객체가 더 이상 필요하지 않다 판단해, 해당 객체에 대한 메모리를 비운다.

 

2-4-2. Mark and Swap 알고리즘

💡 이 알고리즘은 "도달할 수 없는 객체"를 "더 이상 필요없는 객체"로 정의하여 메모리를 관리한다.
최신 엔진은 Mark and Swap 알고리즘을 사용한다.

  • Mark and Swap 알고리즘은 root(루트)라는 개념이 있는데, 루트는 전역 객체를 가리킨다.
  • 가비지 콜렉터는 root부터 root가 참조하는 객체, root가 참조하는 객체가 참조하는 객체 등을 찾는다.
  • 그리고 접근 가능한 객체를 active로 표시한다.
  • 그 다음, 가비지 콜렉터은 active 표시가 없는 모든 객체를 가비지로 간주해 메모리를 해제한다.

 

💡 자바스크립트 엔진은 최신인지에 따라 두 알고리즘 중 하나를 이용해 메모리를 관리한다.
하지만, 두 알고리즘을 사용해도 메모리 누수를 완전히 막을 수 없다ㅠ
그렇다면 어떤 경우에 메무리 누수가 발생할까?



3. JS의 메모리 누수가 발생하는 이유

3-1. 전역 변수를 선언할 때

3-1-1. 전역 변수 예시

  • 전역 변수는 전역에서 접근할 수 있어 데이터가 필요하지 않을 때를 판단하기 어렵다.
  • 그래서, 참조를 끊어도 메모리가 유지되는 문제가 발생한다.
// 전역변수 선언
let obj = { ... };

// obj를 null로 초기화해서 참조 끊기?
myData = null;

 

3-1-2. 메모리 누수를 막는 법

  • 이 경우, 함수 스코프 단위에서 변수를 선언해, 함수 내에서만 변수 접근이 가능하도록 제한하는 방법이 있다.
  • 함수 내에 변수를 선언하면, 함수가 필요하지 않을 때 변수들이 자동으로 가비지 처리가 된다.
function func() {
  let obj = { ... };
}

func();

 

3-2. setTimeout, setInterval을 초기화하지 않았을 때

3-2-1. setTimeout, setInterval 예시

  • setTimeoutsetInterval은 일정 시간에 특정 로직을 실행시킬 수 있는 스케쥴링 함수이다.
  • setTimeout 함수는 시간이 경과하면 로직을 실행하며, setInterval은 특정 시간 간격으로 로직을 반복 실행한다.
// 1초 후에 콘솔 출력
setTimeout(() => {
  console.log("나는 1초 후에 실행해");
}, 1000);
// 1초에 한 번씩 콘솔 출력
setInterval(() => {
  console.log("나는 1초마다 실행해");
}, 1000);

 

3-2-2. 메모리 누수를 막는 법

  • 스케쥴링 함수는 초기화하지 않으면 계속 메모리를 차지한다.
  • 그래서 각각의 스케쥴링 함수는 아래와 같은 방식으로 스케쥴링을 제거 해줘야 좋다.
// setTimeout을 별도 변수에 담고, clearTimeout로 제거하기

const timeoutId = setTimeout(() => {
  console.log("나는 1초 후에 실행해");
}, 1000);

clearTimeout(timeoutId);
// setInterval을 별도 변수에 담고, clearInterval로 제거하기

const timer = setInterval(() => {
  console.log("나는 1초마다 실행해");
}, 1000);

clearInterval(timer);

 

3-3. eventListener를 초기화하지 않았을 때

3-3-1. eventListener 예시

  • eventListener는 요소에 이벤트를 등록할 수 있는 리스너이다.
  • 만약 확인 버튼(confirmBtn)이 클릭할 때마다 콘솔을 출력한다고 가정하자.
  • 그리고 특정 상황에서 확인 버튼이 제거된다면, 이벤트 리스너는 메모리에서 제거될까? 아니다ㅠ
const confirmBtn = document.getElementById("confirmBtn");

// confirmBtn에 클릭이벤트 등록하기
confirmBtn.addEventListener("click", onClickConfrim);

function onClickConfrim() {
  console.log("확인 버튼이 클릭됐따!");
}

 

3-3-2. 메모리 누수를 막는 법

  • 이벤트 리스너는 여전히 버튼 요소를 참조하고 있기에, 가비지 대상이 되지 않는다.
  • 그래서 요소가 필요 없으면, removeEventListener로 이벤트 리스너를 제거해야한다.
const confirmBtn = document.getElementById("confirmBtn");

confirmBtn.removeEventListener("click", onClickConfrim);

function onClickConfrim() {
  console.log("확인 버튼이 클릭됐따!");
}

 

3-4. 사용하지 않는 값을 가진 클로저

3-4-1. 클로저란?

  • 클로저는 중첩 함수에서 외부 함수가 종료되어도 내부 함수가 외부 함수에 접근할 수 있는 환경이다.
  • 내부 함수는 함수 내부에 선언된 함수를 가리키고, 외부 함수는 내부 함수를 포함하는 함수를 지칭한다.
  • 아래의 예시에서 outer()는 외부 함수이며, inner()는 내부 함수이다.
  • 클로저는 로직의 응집성을 높인다는 장점이 있지만, 잘 못 사용하면 메모리 누수를 일으킨다.
function outer(){    
  const inner = function() {
    console.log('haha');
  } 
  inner();   
}
inner();  // Error: inner is not defined

 

3-4-2. 클로저에서 메모리 누수를 막는 법

  • 아래 코드를 보면, 외부 스코프의 변수(arr)는 사용되지 않지만 선언되어 있다.
  • 그리고 내부 스코프에서는 이 변수를 사용하고 있어 참조가 유지되고 있다.
function outer(){
    const arr = []; // 사용하지 않는 변수(원인)
    return function inner(v){
       arr.push(v);
    }
}

// 내부 함수에 접근하기
const appendNum = outer(); 

// 내부 함수 여러번 호출하기
for (let i=0; i< 999999; i++){
  appendNum(i); // 반복 호출하면서 arr변수의 메모리 증가!
}
  • 함수 스코프 변수는 함수의 호출이 완료되면 메모리에서 정리된다.
  • 하지만 클로저에서는 외부 스코프 변수를 내부 함수에서 참조하면, 참조가 유지된다.
  • 그래서 리턴되지 않는 변수(arr)를 외부 스코프에 선언하고 내부 함수에서 사용하면, 참조가 유지되어 누수가 발생한다.
  • 이 경우, 클로저 사용시 외부 범위의 변수를 사용하거나 내부 변수를 반환하도록 수정해야 한다.



4. 메모리 누수 측정하는 법

💡 메모리 누수가 발생하는 상황을 알아봤는데, 어떻게 해야 메모리 누수가 발생하는 지 알 수 있을까? Chrome에서 확인할 수 있다!

4-1. 성능 패널 이용하기

  • 성능 패널에서 기록을 하면, JS Heap 영역을 확인할 수 있다.

 

  • 기록 결과창에서 선 그래프의 JS Heap은 힙 메모리 사용량을 나타낸다.
  • 선 그래프가 상승 추세라면 프로그램이 지속적으로 메모리를 소비하고 있다는 말로, 메모리 누수가 있을 가능성이 있다.

 

4-2. 메모리 패널 이용하기

  • 메모리 패널은 실시간으로 메모리 사용량을 볼 수 있다.
  • 아래와 같은 순서로 [타임라인 할당 계측]을 ON을 하고 기록을 해보자!

 

  • 녹화를 하면, 그래프에서 파란색 히스토그램이 생성되는 것을 볼 수 있다.
  • 이 파란색 히스토그램은 현재 메모리 양을 나타낸다.

 

  • 히스토그램에서 회색은 이전에 차지하던 메모리 공간이 해제되었음을 나타낸다.
  • 만약 사이트에서 파란색 히스토그램만 생성되고 회색으로 변하지 않는다면, 메모리가 해제되지 않는다는 말로, 프로그램에 메모리 누수가 있을 수 있다.



5. 마치며…

  • 이번시간에는 자바스크립트의 메모리 라이프 사이클과 메모리 누수 원인 등을 알아보았다.
  • 메모리 누수는 주로 전역 변수 선언, setTimeout/setInterval 초기화 누락 등에 의해 발생한다.
  • 이를 방지하기 위해 전역 변수는 함수 스코프 단위에서 선언하며, setTimeoutclearTimeout로 제거하는 등의 방법이 있었다.
  • 만약 서비스에서 메모리 누수를 측정하고 싶다면, 크롬의 성능 패널과 메모리 패널을 이용해서 해결해보자.




출처



반응형

댓글