본문 바로가기
Random

메모리 누수란 무엇이고 어떻게 해결할까

by SeanK 2022. 4. 6.

이번 주 기술면접을 보면서 들은 질문 중에 메모리 누수가 발생하면 어떻게 찾아낼 건가요?라는 질문이 있었는데, 

 

한 번도 생각해본 적 없는 주제라 많이 당황했던 기억이 난다. 

 

생각해보니 대규모 애플리케이션 서비스를 제공하는 업체 입장에서는 사소한 메모리 누수도 민감하게 작용할 수 있을 테니 주의해서 개발할 텐데 그 부분에 대해 미처 생각해보지 못했다니 앞으로는 좀 더 능동적인 학습이 필요할 것 같다. 

 

여하튼 오늘은 메모리 누수가 발생하면 어떻게 찾는가에 대해 알아보자.

 

우선은 메모리 누수 발생원인과 고의적으로 메모리 누수를 만드는 법에 대해 잘 설명된 글을 번역해 옮겨본다.

 

The Secrets of Memory Leaks in JavaScript You Don’t Know

혹시 인터뷰 도중 이런 질문을 받아본적 있는가?
"만약 웹페이지가 멈춘다면 무엇때문이라고 생각하나요? 이를 해결하는 방법을 아시나요?"
이런 질문은 웹페이지 기능 최적화에 대한 굉장히 광범위하면서도 깊은 질문이다. 필자는 아래와 같이 대답한 기억이 있다. 
 
1. 우선 데이터 전송을 늦추는 과도한 네트워크 요청이 있는지 확인합니다. 이러한 문제는 캐싱으로 해결 될 수 있습니다. 
2. 특정 리소스의 번들이 너무나도 큰 경우일 수 있습니다. 이럴 경우 번들을 나누어 줄 수 있습니다. 
3. 메인 스레드를 오랫동안 차지하는 반복문이 자바스크립트에 있는지 확인 할 수 있습니다. 
4. 렌더링 과정에서 반복된 리플로우와 리페인트과정이 있는지 체크합니다. 
5. 브라우저가 한 프레임에 너무많은 렌더링을 실행하였을 경우가 있습니다.
6. 나머지는 모르겠습니다...
이후 필자는 메모리 누수로 인한 멈춤이 있을 수 있다는 점을 깨달았다. 이번 글에서는 메모리 누수에 대해 논의해보려 한다. 

What is a Memory Leak?

메모리 누수란 부주의나 프로그램 에러로 더 이상 사용하지 않는 메모리를 제거하는 일에 실패한 경우를 말한다. 간단히 말해 어떤 변수가 100m의 메모리를 차지하는데 이를 사용하지 않는다고 가정해보자. 이를 직접 혹은 자동적으로 제거하지 않아 여전히 100m의 메모리를 차지하는 경우이다. 이러한 상황을 메모리 낭비 혹은 메모리 누수라고 부른다.

Stack Memory and Heap Memory

자바스크립트의 메모리는 간단한 변수를 저장하는 스택 메모리와 복잡한 객체를 저장하는 힙 메모리로 나뉘어 있다. 

  • 간단한 변수란 원시타입을 말한다: String, Number, Boolean, Null, Undefined, Symbol, Bigint
  • 복잡한 객체란 참조타입을 말한다: Object, Array, Function

Garbage Collection in JavaScript

메모리 누수의 정의에 따르면, 어떤 변수 혹은 데이터가 더 이상 필요하지 않으면 이를 쓰레기 변수 혹은 쓰레기 값이라고 부른다. 만약 쓰레기 값을 계속 메모리 안에 담아두면 결국 메모리 사용 허용량을 초과하게 된다. 그렇기 때문에 우리는 쓰레기 데이터를 회수해야 한다. 여기서 가비지 컬렉션 메커니즘 개념이 등장한다. 

 

가비지 콜렌션 메커니즘은 두 가지로 나뉜다: 수동과 자동

 

C와 C++는 수동 회수 매커니즘을 사용하는데 개발자가 처음 코드를 짤 때 변수를 메모리에 할당하고 더 이상 해당 메모리가 필요 없어지면 메모리를 제거하는 방식이다. 

 

반면에 Javascript는 자동 콜렉션을 사용하는데 자동 컬렉션을 사용하면 변수의 메모리 할당과 제거를 자동으로 해주기 때문에 개발자는 신경을 쓰지 않아도 된다. 하지만 메모리 관리에 전혀 신경을 쓰지 않아도 된다는 것은 아니다! 만약 그렇다면 위에서 말한 메모리 누수에 관해 논의할 필요도 없었을 것이다. 

 

지금부터 자바스크립트의 가비지 콜렉션 메커니즘에 대해 알아보자. 

 

일반적으로 글로벌 변수들은 회수되지 않는다. 따라서 로컬 스코프의 메모리 컬렉션에 집중하도록 하겠다.

 

아래 스니펫을 보자:

function fn1 () {
    let a = {
        name: 'bytefish'
    }
    
    let b = 3
    
    function fn2() {
        let c = [1, 2, 3]
    }
  
    fn2()
  
    return a
}
let res = fn1()

위의 코드의 콜스택 구조는 아래와 같다:

왼쪽 상자는 원시타입데이터와 실행 콘텍스트를 저장하는 스택 공간이다. 오른편은 힙 공간으로 객체를 저장하는 공간이다. 

 

fn2 함수가 실행 되며, 콜 스택은 위에서 아래로 실행 순서를 옮긴다. fn2 functional execution context => fn1 functional execution context => global execution context .

 

fn2 함수가 내부 실행을 마쳤을 때 fn2는 실행 콘텍스트에서 제거된다. fn2 실행 컨택스트는 메모리 공간에서 해제되고 그림은 아래와 같다. 

 

fn2의 내부 실행이 끝나면 fn1 functional execution context를 나와 화살표는 아래로 내려간다. 그리고 fn1은 스택 메모리에서 삭제된다. 

이제 프로그램은 글로벌 컨택스트에 있다.

 

자바스크립트 가비지 콜렉터는 일정 시간마다 콜 스택을 여행하며 쓰레기를 수집한다. 지금 가비지 콜렉터가 실행되었다고 가정해보자. 가비지 콜렉터가 콜스택을 여행 다니며 변수 b와 c를 더 이상 사용하지 않는다는 것을 알아냈고 이를 쓰레기 데이터로 인지하고 마크한다. f1함수가 변수 a를 반환하고 이것이 글로벌 변수 res에 저장됨으로 가비지 콜렉터는 이를 살아있는 데이터로 간주하고 마크한다. 별다른 작업이 없는 한가한 시점에 가비지 콜렉터는 쓰레기 데이터로 마크한 값들을 모두 메모리에서 치워버린다. 

 

요약하자면 이렇다:

1. 자바스크립트의 가비지 컬렉션은 쓰레기 데이터를 자동적으로 태그하고 청소하는 메커니즘이다.

2. 로컬 스코프를 벗어난 후 만약 변수가 그 어떠한 외부 스코프에 의해 참조되지 않는다면 이후 제거된다.

 

Observe Memory Usage with Chrome DevTools

크롬 데브 툴의 Performance와 Memory 패널을 이용해 자바스크립트 애플리케이션의 메모리 사용을 확인해 메모리 관리 메커니즘에 대해 더욱 자세한 이해할 수 있다. 

 

우선 아래와 같이 간단한 자바스크립트 프로그램을 준비하자:

<!DOCTYPE html>
<html>
  <body>
    <button onclick="myClick()">execute fn1</button>
    <script>
      function fn1() {
        let a = new Array(10000);
        return a;
      }

      let res = [];

      function myClick() {
        res.push(fn1());
      }
    </script>
  </body>
</html>

페이지는 아주 간단하다: 버튼이 하나 있고 버튼을 클릭할 때마다 res 배열에 새로운 배열을 생성해 저장할 것이다.

 

컴퓨터에 파일을 생성하고 브라우저에서 파일의 주소를 넣어서 창을 생성하자.

 

한 가지 주의할 점은 파일의 주소를 직접 넣어 창을 열어야 한다. vs코드나 다른 IDE를 이용해서는 안된다. 만약 후자를 이용한다면 hot-update 코드를 파일에 넣어 메모리 관측이 어렵게 할 수 있다. 필요하다면 일시적으로 브라우저의 익스텐션을 잠시 꺼두어도 좋다.

 

이제 브라우저의 DevTool을 열자.

패널에는 다양한 기능이 있다. 회색 점 버튼은 프로그램의 메모리 사용을 기록한다. 점 버튼을 눌러 기록을 시작하고 execute fn1 버튼을 반복해서 눌러보자.

 

결과:

선으로 된 차트는 힙 메모리 사용을 나타낸다. 매번 버튼을 클릭할 때마다, fn1 함수가 새로운 객체를 만들어 res 배열에 저장함으로써 힙 메모리 사용이 늘어나는 것을 확인할 수 있다. 그리고 가비지 콜렉터에 의해 수거가 되지 않고 있다.

 

만약 선 차트가 지속적으로 올라가고 콜백의 신호가 없다면 프로그램은 지속적으로 메모리를 소비하게 되고 프로그램은 메모리 누수를 일으킬 가능성이 있다.

Memory Panel

메모리 패널은 실시간 메모리 사용을 보여준다.

사용법:

기록을 시작하면 파란색 히스토그램이 그래프에 생성되는 것을 확인할 수 있다. 이는 타임라인에 따른 현재 메모리 량을 나타낸다.

 

만약 프로젝트의 파란 히스토그램이 회색으로 변하지 않는다면 메모리가 해제되지 않아 메모리 누수가 발생한 것일 수도 있다.

Memory Leaks Examples

그렇다면 메모리 누수가 발생할 수 있는 상황엔 어떤 것들이 있을까? 아래 공통적인 경우들이 있다.

 

  • 부적절한 클로저 사용
  • 의도하지 않은 전역 변수 생성
  • DOM 노드가 떨어진 경우
  • 콘솔 프린팅
  • 제거되지 않은 타이머

각 시나리오를 둘러보면 DevTool를 활용한 문제 해결을 해보도록 하자.

 

1. 부적절한 클로저 사용

앞서 설명한 예제에서, fn1 함수가 실행 콘텍스트에서 실행되고 변수 a는 쓰레기 데이터로 회수되었을 수도 있었으나, fn1 함수가 a 변수를 반환하고 이를 전역 변수의 res에 할당하면서 변수 a의 값을 참조하게 되었다. 따라서 변수 a는 활성화된 데이터로 마킹되어 메모리 공간에 계속 남게 되었다. 만약 res 변수를 이후 사용하지 않는다면 이는 클로저가 적절하게 사용되지 못한 경우의 예가 될 수 있다. 

 

클로저에 의한 메모리 누수를 Performance와 Memory를 통해 확인해 보자. 메모리 누수의 결과를 좀 더 분명하게 확인하기 위해 예제 코드를 아래와 같이 변경하였다. 

 

<!DOCTYPE html>
<html>
  <body>
    <button onclick="myClick()">execute fn1</button>
    <script>
      function fn1() {
        let a = new Array(10000);

        let b = 3;

        function fn2() {
          let c = [1, 2, 3];
        }

        fn2();

        return a;
      }

      let res = [];

      function myClick() {
        res.push(fn1());
      }
    </script>
  </body>
</html>

fn1 함수의 리턴 값을 전역 변수 res에 매번 추가하는 버튼을 만들었다. 그리고 performance 패널에서 메모리 커브를 기록해보자.

 

  • 기록을 시작하기 위해 회색 버튼을 클릭한다.
  • 그리고 시작할 때 있을 수도 있는 메모리를 없애기 위해 휴지통 버튼을 클릭한다.
  • 그리고 fn1함수 실행 버튼을 여러 번 클릭한다.
  • 마지막으로 가비지 컬랙션을 다시 실행한다.

예시:

결과:

결과를 통해 알 수 있듯이 fn1 함수를 호출할 때마다 힙 메모리 공간이 증가한다. 그리고 전체적인 커브가 상향한다. 그러다가 마지막 메모리 클리닝을 했을 때 마지막 커브가 처음 baseline보다 높다는 것을 확인할 수 있다. 이는 프로그램에 메모리 누수가 있음을 암시한다. 

 

메모리 누수를 확인하면, 메모리 패널을 통해 원인을 더욱 명백하게 찾아낼 수 있다. 

 

버튼을 클릭할 때마다, 파란색 선이 다이내믹 메모리 그래프에 표시된다. 그리고 가비지 컬렉션을 실행시켰을 때 파란색 선은 회식으로 변하지 않고 이는 할당된 메모리가 회수되지 않았음을 뜻한다. 

 

그리고 힙 스냅숏을 통해서 어떤 함수가 메모리 누수를 일으키고 있는지 확인할 수 있다. 

 

마우스 커서를 파란색 칼럼에 가져다면 해당 시간에 생성된 객체를 볼 수 있다. 객체를 클릭하면 어떤 함수가 해당 객체를 생성했는지 볼 수 있다. 이 함수가 메모리 누수의 범인이다. 

 

2. 의도하지 않은 전역 변수 생성

이전에 전역 변수는 일반적으로 가비지 컬렉터에 의해 수거되지 않는다고 말했다. 꼭 필요한 경우가 아니라면 전역 변수는 가능한 적게 사용하는 것이 좋다. 때때로 개발자들은 실수로 선언 없이 변수에 값을 할당하면서 몇몇 변수를 전역의 세계로 잃어버리곤 한다. 아래와 같이 말이다:

 

function fn1() {
   // `name` is not declared
   name = new Array(99999999)
}
fn1()

이경우 변수 name은 자동적으로 전역 변수로 생성된다. 그리고 크기가 큰 배열이 name에 할당된다. 이는 전역 변수이기 때문에 메모리 공간이 절대 줄어들지 않을 것이다. 

 

따라서 코딩을 할 때 주의를 기울여야 한다. 변수를 선언하기 전에 값을 할당하지 마라. 혹은 strict 모드를 사용하면 워닝 에러를 받아 실수가 있었음을 할 수 있다. 아래와 같이 말이다.

 

function fn1() {
    'use strict';
    name = new Array(99999999)
}
fn1()

 

3. DOM 노드가 떨어진 경우

직접 DOM 노드를 제거한다고 가정해보자. 이 경우 떨어진 DOM 노드의 메모리를 회수해야 하지만 몇몇 코드가 제거된 노드를 참조하고 있으면 메모리 회수가 되지 않는다. 예를 들어:

 

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <div id="root">
    <div class="child001">I am child element</div>
    <button>remove</button>
</div>
<script>
    let btn = document.querySelector('button')
    let child001 = document.querySelector('.child001')
    let root = document.querySelector('#root')
    
    btn.addEventListener('click', function() {
        root.removeChild(child001)
    })

</script>

</body>
</html>

위 코드상에서 버튼을 클릭하면. child001 노드가 제거된다. 비록 노드는 클릭으로 제거되지만 전역 변수 child001는 노드를 여전히 참조하고 있기 때문에 해당 노드 메모리는 제거되지 않는다.

 

메모리 패널로 테스트해 볼 수 있다. 

 

 

우선 프로그램 시작 전에 힙 스냅숏 기능으로 힙 메모리의 사용을 기록하고 .child001 DOM을 제거하고 다시 힙 메모리 사용을 기록한다. 

 

두 번째 스냅숏에서 detached 키워드를 입력하면 DOM 노드를 필터링할 수 있고 제거되지 않은 DOM 트리를 찾을 수 있다. 이는 가비지 컬렉터에 의해 회수되지 않았음을 뜻한다. 

 

이 또한 흔한 메모리 누수의 경우인데 아래와 같이 해결할 수 있다:

let btn = document.querySelector("button");
btn.addEventListener("click", function () {
    let child001 = document.querySelector(".child001");
    let root = document.querySelector("#root");
    root.removeChild(child001);
});

변경된점은 .child001 노드 참조를 클릭 이벤트의 콜백 함수로 옮기는 것뿐이다. 이렇게 되면 노드를 제거했을 때 콜백 함수의 실행 콘텍스트를 벗어나면서 자동으로 메모리에서 해제되고 메모리 누수가 발생하지 않는다. 

 

4. 콘솔 프린팅

콘솔 프린팅도 메모리 누수를 발생시키는가? 그렇다. 만약 브라우저가 콘솔 프린트 객체를 기억하지 않는다면 어떻게 우리가 콘솔에서 항상 해당 데이터를 확인할 수 있겠는가? 아래 코드를 살펴보자:

 

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <button>btn</button>
<script>
    document.querySelector('button').addEventListener('click', function() {
        let obj = new Array(1000000)

        console.log(obj);
    })
</script>

</body>
</html>

클릭 콜백 함수로 큰 배열 객체를 생성하고 콘솔 창에 띄우는 코드이다. 한번 실행시켜보자.

 

기록이 시작되면 콘텐츠의 baseline을 결정하기 위해 가비지 콜렉터가 실행된다. 그리고 버튼을 여러 번 클릭한 후에 마지막으로 가비지 콜렉터를 다시 실행시켜준다. 결과를 보면 힙 메모리가 지속적으로 올라가다가 결국 초기 baseline보다 높게 지속되는 것을 확인할 수 있다. 이것은 버튼을 클릭할 때마다 브라우저는 배열을 저장하고 console.log로 인해 회수 되지 못하고 있음을 뜻한다. 

 

다음, console.log를 다음과 같이 변경해보자

결과:

console.log가 없으면 obj가 생성되고 곧바로 삭제되는 것을 확인 할 수 있다. 마지막으로 가비지 콜렉터가 실행되면 메모리 라인이 초기값과 같아지는 것을 보아 메모리 누수가 없음을 알 수 있다.

 

Memory 패널로도 비슷하게 확인 할 수 있다. 

 

  • console.log와 같이 쓰였을 때

  • console.log가 없을 때

종합하자면 개발 환경에서 개발자는 디버깅 목적으로 콘솔을 사용할 수 있지만 배포 환경에서는 가능한 데이터를 프린트하면 안 된다. 따라서 많은 자바스크립트 코딩 스타일에서는 콘솔 로그 사용을 지양하고 있다. 

 

만약 정말로 변수를 프린트하고 싶다면 아래와 같이 사용할 수 있다. 

if(isDev) {
    console.log(obj)
}

5. 제거되지 않은 타이머

타이머가 제거되지 않으면 타이머 정의 또한 메모리 누수를 발생시킬 수 있다.

 

아래 예제를 살펴보자:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <button>start a timer</button>
<script>

    function fn1() {
        let largeObj = new Array(100000)

        setInterval(() => {
            let myObj = largeObj
        }, 1000)
    }

    document.querySelector('button')
      .addEventListener('click', function() {
        fn1()
    })
</script>

</body>
</html>

fn1 함수는 버튼을 클릭하면 실행되고 fn1 함수는 큰 largeObj 배열을 생성함과 동시에 setInterval 타이머를 만들어 낸다. 타이머의 콜백은 largeObj를 참조하는데 전체적인 메모리 할당이 어떻게 되는지 살펴보자.

버튼을 클릭하면 fn1 함수를 실행시킬 것이고 함수의 실행 콘텍스트를 벗어나게 된다. 그리고 함수의 지역변수들은 메모리에서 해제되어야 한다. 하지만 결과상에는 메모리 누수가 발생함을 보여준다. 즉 마지막 커브 높이가 초기 높이보다 높다.

 

메모리 패널로 다시 확인해 보자:

버튼을 클릭한 이후에, 브라우저가 largeObj 변수에 메모리를 할당하며 파란색 바가 메모리 그래프에 나타나는 것을 볼 수 있다. 이유는 setInterval 콜백이 largeObj를 참조하고 있으며 타이머가 해제되지 않아서 largeObj의 메모리도 사라지지 않는 것이다. 

 

이 문제를 어떻게 해결할 수 있을까? 만약 타머를 세 번만 실행해야 한다면 아래와 같이 코드를 변경할 수 있다.

 

  <body>
    <button>start a timer</button>
    <script>
      function fn1() {
        let largeObj = new Array(100000);
        let index = 0;
        
        let timer = setInterval(() => {
          if (index === 3) clearInterval(timer);
          let myObj = largeObj;
          index++;
        }, 1000);
      }

      document.querySelector("button").addEventListener("click", function () {
        fn1();
      });
    </script>

 

결론

프로젝트를 개발하는 과정에서 만약 퍼포먼스 문제를 직면한다면 메모리 누수와 관련되었을 수 있다. 그렇다면 위 다섯 가지 시나리오를 참조해 해결을 해볼 수 있을 것이다. 

 

비록 자바스크립트의 가비지 컬렉션은 자동으로 실행되지만 때때로 특정 변수를 수동으로 제거해야 할 때도 있다. 예를 들어 만약 당신이 특정 상황에서 특정 변수가 더 이상 사용되지 않을 것이란 것을 알지만 외부 변수에 의해 참조되어 메모리 해제가 되지 않는 다면 null를 할당해 변수가 이후 가비지 콜렉터에 의해 회수되도록 할 수 있다.

'Random' 카테고리의 다른 글

[Firebase] signOut API로 로그아웃 해야하는 이유  (0) 2022.04.14
데스크탑 local 환경 모바일로 접근하기  (0) 2022.04.14
MVC 모델이란  (0) 2022.03.29
[Web] SSR과 CSR  (0) 2022.03.28
[CORS] CORS 뽀개기  (0) 2022.03.25