IE11 및 Chromium Edge에서 메모리 힙 스냅샷 비교하기

등록일시: 2020-03-10 08:00,  수정일시: 2020-03-10 08:00
조회수: 5,900

본문의 주제를 처음 준비하던 2018년 후반만 해도 본문은 'IE11을 이용한 JavaScript 디버깅' 시리즈의 일부분으로 구성되어 몇 편에 걸쳐 나뉘어 제공될 예정이었습니다. 그러나 공교롭게도 거의 동일한 시기에 Microsoft가 Chromium Edge에 대한 구체적인 계획을 발표함으로써 시리즈 자체의 필요성이 크게 줄어들었고, 결과적으로 다루고자 계획했던 내용을 끝까지 진행하지 못한 체 시리즈는 사실상 중단된 상태입니다.

비록 'IE11을 이용한 JavaScript 디버깅' 시리즈의 내용이 IE11F12 개발자 도구를 활용하는 방법에 중점을 두고 있기는 하지만, 최종 목표는 단순히 그에 그치지 않고 상대적으로 UI 및 기능이 단순한 IE11F12 개발자 도구에 친숙해진 다음, 거의 동일한 UI와 기능을 제공하지만 대폭 개선된 Classic Edge를 다루고, 또다시 보다 풍부한 기능을 제공하는 Chrome 등의 최신 브라우저로 접근을 확대해나가는 것이었습니다. 그러나 중간 징검다리 역할을 해줄 Classic Edge의 존재 위치가 불확실해짐에 따라 시리즈의 전반적인 맥락이 끊어졌다는 판단이었습니다.

드디어 2020년 초인 현재, 정식으로 배포되기 시작한 Microsoft의 Chromium Edge는 나름대로 순조롭게 시장에 안착하고 있는 것으로 보입니다. F12 개발자 도구라는 관점에서만 본다면 Chrome과 거의 동일한 UI 및 기능을 제공하고 있어서 기본적인 수준에서는 둘 중 어떤 브라우저를 선택하더라도 별다른 차이가 없을 듯합니다. 개인적으로 과거 IE11/Classic Edge 계열과 Chrome 계열로 나눴던 F12 개발자 도구 그룹을 이제는 IE11 계열과 Chromium Edge/Chrome 계열로 분류해도 무방할 것 같습니다. (FireFox까지 다루고 싶은 욕심도 있었지만 현실적으로 제 역량으로는 무리라고 생각되어 본문에서는 다루지 않습니다.)

본문에서는 IE11Chromium EdgeF12 개발자 도구를 활용한 메모리 힙 스냅샷의 비교/분석을 통해 웹 브라우저의 메모리 누수를 감지하는 가장 기본적인 방법을 살펴봅니다.

시리즈 목차

문서 목차

들어가기 전에

브라우저 기반의 웹 응용 프로그램 개발에서 클라이언트 개발자가 빈번하게 구현하는 동작 중 하나는 다수의 개체를 동적으로 생성한 다음 업무 요건을 만족할 경우 해당 개체들을 제거하거나 데이터에 맞춰 다시 생성하는 기능입니다. 이런 기능은 대부분 JavaScript 개체나 DOM 요소를 다루는 경우가 많고 업무에 따라서는 개체 생성 후 제거 혹은 재생성으로 이어지는 순환 주기가 여러 차례 반복되는 경우도 흔합니다. 페이지를 로드한 직후에는 별다른 문제가 없지만, 사용 시간이 점차 길어질수록 UI의 반응이 느려지거나 동작이 원활하지 않다면 해당 기능에 메모리 누수를 유발하는 코드가 포함되어 있는지 의심해 보는 것이 합리적일 것입니다.

메모리 누수라는 관점에서 이런 기능을 명확하게 분석하기 위해서는 개체 생성 전/후의 메모리 힙 상태를 비교해볼 수 있어야만 합니다. 다시 말해서 1. 개체 생성 전의 메모리 힙 상태, 2. 개체 생성 후의 메모리 힙 상태, 3. 개체 제거 후의 메모리 힙 상태를 서로 비교할 수 있어야 하고, 그중에서도 특히 1. 번 상태와 3. 번 상태를 비교했을 때 사용된 메모리가 모두 반환된다면 메모리 누수가 없다고 생각해도 무방할 것입니다.

이전 글에서는 IE11Chromium EdgeF12 개발자 도구를 활용하여 메모리 힙 스냅샷을 찍는 기초적인 방법과 다양한 방식으로 스냅샷을 들여다볼 수 있는 몇 가지 보기들, 그리고 사용되는 용어들을 살펴봤습니다. 그러나 이전 글에서 알아본 기능만으로는 위에서 언급한 것과 같은 메모리 힙 간의 상태를 비교하기가 그리 쉽지 않습니다. 다행히도 IE11Chromium 계열의 브라우저는 각각의 스냅샷을 한 쌍씩 짝지어 비교할 수 있는 기능을 제공합니다.

본문에서는 이전 글에서 살펴본 F12 개발자 도구메모리 창 기능에서 한 단계 더 나아가서 메모리 힙 스냅샷 간의 비교/분석을 수행하는 방법을 살펴봅니다.

예제 코드 살펴보기

다음은 본문에서 사용할 예제 HTML 파일의 기본 내용입니다. jQuery 같은 외부 라이브러리는 아무것도 사용하지 않았으며 상당히 전통적인 방식의 JavaScript 코드만 이용하여 구현되었습니다. 제공되는 기능도 매우 단순합니다. Create 버튼과 Remove 버튼이 존재하며 Create 버튼을 클릭할 때마다 간단한 onclick 이벤트 핸들러가 설정된 새로운 버튼이 만들어집니다. 반대로 Remove 버튼을 클릭하면 만들어진 순서의 역순으로 만들어졌던 버튼이 하나씩 제거됩니다. (새 창에서 보기)

이번 예제 파일을 준비하면서 나름대로 신경쓴 부분이 한 가지 있습니다. 바로 동적으로 생성되는 HTML 요소를 비롯한 화면의 거의 모든 요소가 버튼 한 가지로만 구성되어 있다는 점입니다. 따라서 우리는 다른 개체들은 신경 쓸 필요 없이 버튼에만 집중하면 됩니다.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>메모리 누수 - 새로운 HTML 요소 생성 및 제거 (onclick)</title>
    <style>
      body {
        font-size: 12px;
        font-family: Tahoma;
      }
      input[type="button"] {
        display: block;
        width: 150px;
        height: 30px;
        margin: 5px;
      }
    </style>
  </head>
  <body>
    <input type="button" value="Create" onclick="fnCreate();" style="float:left;" />
    <input type="button" value="Remove" onclick="fnRemove();" style="float:left;" />
    <div id="btnBox" style="clear:both;">
      <!-- 여기에 버튼들이 생성됩니다. -->
    </div>

    <script>
      var index = 0;

      function fnCreate() {
        index++;

        var btn = document.createElement("input");
        btn.id = "btn_" + index;
        btn.type = "button";
        btn.value = "Button " + index;
        btn.onclick = function() {
          alert(this.value);
        };

        document.getElementById("btnBox").appendChild(btn);
      }

      function fnRemove() {
        if (index < 1) {
          return;
        }

        var btn = document.getElementById("btn_" + index);
        btn.onclick = null;
        btn.parentNode.removeChild(btn);

        index--;
      }
    </script>
  </body>
</html>

메모리 힙 스냅샷 비교하기

먼저 예제 HTML 파일을 대상으로 IE11Chromium Edge에서 메모리 힙 스냅샷끼리 서로 비교하는 가장 기초적인 방법부터 살펴보겠습니다. 구체적으로 이번 섹션은 다음과 같은 과정을 통해서 진행됩니다.

  1. IE11 또는 Chromium Edge에서 예제 페이지를 실행합니다.
  2. F12 키를 눌러서 F12 개발자 도구를 실행하고 메모리 창으로 이동합니다.
  3. 페이지가 로드된 최초 상태에서(Chromium Edge에서는 별도 준비 필요, 하단 '노트' 참고) 첫 번째 메모리 힙 스냅샷을 찍습니다.
  4. Create 버튼을 몇 차례 클릭해서(본문에서는 3회) 동적으로 버튼을 추가합니다.
  5. 다시 두 번째 메모리 힙 스냅샷을 찍습니다.
  6. 첫 번째와 두 번째 메모리 힙 스냅샷을 서로 비교/분석합니다.
노트

Chromium Edge에서는 본문의 주제에 집중하기 위해 약간의 준비가 필요합니다. 반드시 3. 번 과정을 수행하기 전에 먼저 Create 버튼을 여러 차례 클릭해서 여러 개의 버튼을 동적으로 생성했다가 다시 Remove 버튼을 여러 차례 클릭해서 생성된 버튼을 모두 삭제하도록 합니다. 간단히 비유하자면 이를 V8 엔진을 '예열'하는 작업이라고 생각하셔도 무방합니다. 무엇 때문에 이와 같은 준비가 필요한지는 이후의 관련 글에서 다시 자세하게 살펴봅니다.

IE11에서 메모리 힙 스냅샷 비교하기

IE11에서 방금 설명한 과정을 거쳐 두 번의 메모리 힙 스냅샷을 찍고 나면 다음과 비슷한 요약 화면을 얻게 됩니다. 물론 이 상태에서도 이전 글에서 살펴본 것과 동일한 방법을 사용하여 각각의 단일 스냅샷에 대한 유형 보기, 루트 보기, 도미네이터 보기를 활용할 수 있습니다. 여러 개의 스냅샷이 존재할 뿐 기본적인 기능 자체는 바뀐 것이 없습니다.

F12 개발자 도구 - 메모리 창 - IE11 - 스냅샷 비교 (요약)

그런데 첫 번째 스냅샷과 두 번째 스냅샷의 타일을 가만히 비교해보면 첫 번째 스냅샷에는 없던 정보가 두 번째 스냅샷에서는 제공되는 것을 볼 수 있습니다. 이 추가적인 정보를 통해서 직전 스냅샷 대비 메모리 사용량 증감분개체 수 증감분에 대한 정보를 파악할 수 있습니다. 가령 이번 예제에서는 메모리 사용량이 2.4 KB 증가했으며 추가된 개체 수는 6개, 제거된 개체 수는 0개로 전체적으로 개체 수가 6개 증가했음을 알 수 있습니다.

F12 개발자 도구 - 메모리 창 - IE11 - 스냅샷 비교 (요약)

그런데 증가한 개체 수가 왜 여섯 개인 걸까요? 본문에서는 버튼을 세 개만 추가했는데 말입니다. 그 이유를 알기 위해서는 본문의 주제인 비교 보기 모드로 전환해봐야 합니다.

비교 보기 모드

엄격하게 말해서 IE11비교 보기는 실제 보기가 아닌 일종의 모드입니다. 이전 글에서 살펴봤던 유형 보기, 루트 보기, 도미네이터 보기가 스냅샷끼리 비교하기 편리하도록 항목이 재조정되어 제공되는 모드라고 생각하시면 됩니다. (반면 Chromium EdgeComparison 보기는 실제로 보기의 한 유형입니다.)

비교 보기 모드로 스냅샷을 살펴보려면 두 번째 스냅샷에서 메모리 사용량 증감분 링크나 개체 수 증감분 링크를 클릭합니다. 또는 스냅샷 타일을 마우스 오른쪽 버튼으로 클릭한 다음, 비교... 메뉴를 선택하면 나타나는 서브 메뉴에서 비교할 대상 스냅샷을 선택해도 됩니다. 나중에 직접 살펴보겠지만 비교... 메뉴를 사용하는 경우에는 반드시 직전이나 직후의 스냅샷이 아니더라도 자유롭게 스냅샷을 선택할 수 있습니다. 반면 메모리 사용량 증감분 링크나 개체 수 증감분 링크를 사용하여 비교 보기 모드로 진입하는 경우에는 항상 해당 스냅샷과 직전 스냅샷이 자동으로 선택됩니다.

F12 개발자 도구 - 메모리 창 - IE11 - 스냅샷 비교 (유형)

단일 스냅샷을 살펴볼 때와 비교하여 추가된 칼럼들이 다수 존재하지만, 대부분은 쉽게 파악할 수 있는 정보를 담고 있으므로 따로 설명하지는 않겠습니다.

이 목록에서 무엇보다 눈에 띄는 점은 Function 형식의 개체가 세 개 추가되었다는 사실입니다. 버튼 개체를 세 개 추가했으니 정확한 개체 형식은 모르더라도 그에 대응하는 HTMLInputElement 개체가 세 개 추가되었으리라는 점은 대부분 예상했을 것입니다. 그러나 Function 개체까지 함께 생성되었으리라고 예상한 분들은 그다지 많지 않았을 것입니다.

이제 상단 목록의 btn.onclick 항목이나 개체 참조 목록의 [click event handler] (btn.onclick) 항목 위에 마우스 커서를 올려보면 다음과 같은 추가 정보가 제공됩니다.

F12 개발자 도구 - 메모리 창 - IE11 - 스냅샷 비교 (유형 - Function 개체 정보)

또한 해당 항목 링크를 클릭하면 다음과 같이 바로 디버거 창에서 해당 소스 원본이 열리고 관련된 위치로 이동합니다. 이 소스 원본을 보면 왜 세 개의 Function 개체가 추가됐는지 명확하게 이해할 수 있습니다. 버튼이 추가될 때마다 새로운 익명 함수 인스턴스가 만들어진 것입니다. 완전히 똑같은 함수의 인스턴스가 개별적으로 세 개나 생성됐다는 점이 다소 불만이기는 하지만 이에 대해서는 나중에 다시 살펴보도록 하겠습니다.

F12 개발자 도구 - 디버거 창 - IE11 - Function 개체 원본

비교 보기 모드에서는 루트 보기와 도미네이터 보기도 유형 보기와 비슷한 형태로 정보를 제공해줍니다. 다음은 루트 보기의 모습입니다.

F12 개발자 도구 - 메모리 창 - IE11 - 스냅샷 비교 (루트)

그리고 다음은 도미네이터 보기의 모습입니다.

F12 개발자 도구 - 메모리 창 - IE11 - 스냅샷 비교 (도미네이터)

기타: 범위 드롭다운

비교 보기 모드에서는 각 보기 상단에 다음과 같은 범위 드롭다운이 추가로 제공됩니다.

F12 개발자 도구 - 메모리 창 - IE11 - 스냅샷 비교 (범위 드롭다운)

먼저 스냅숏 #{n-x}의 남은 개체 수 항목을 선택하면 선행 스냅샷의 전체 개체 목록에서 후행 스냅샷에서는 삭제된 개체들을 제외한 나머지 개체들이 목록에 나타납니다. 참고로 각 항목 우측의 괄호로 감싸인 숫자는 해당 조건의 개체 수를 의미합니다. 그리고 스냅숏 #{n-x} 및 #{n} 사이에 추가된 개체 수 항목을 선택하면 선행 스냅샷과 후행 스냅샷 사이에 생성된 개체들만 나타나는데 이 항목이 기본값입니다. 마지막으로 스냅숏 #{n}의 모든 개체 수 항목을 선택하면 후행 스냅샷의 모든 개체가 목록에 나타납니다. 이 마지막 목록은 사실상 기본 모드에서 스냅샷을 살펴볼 때와 동일한 개체들을 보여줍니다. 단지 목록으로 제공되는 칼럼들의 종류만 다를 뿐입니다.

Chromium Edge에서 메모리 힙 스냅샷 비교하기

지금까지 살펴본 내용과 동일한 작업을 Chromium Edge에서 수행하여 두 차례 스냅샷을 찍으면 다음과 비슷한 결과를 얻을 수 있습니다. 이처럼 두 건 이상의 스냅샷이 존재하는 경우에만 Memory 창 상단의 Perspective 드롭다운에 Comparison 항목이 나타납니다. 참고로 Comparison 보기는 제공되는 칼럼의 개수가 많기 때문에 목록을 살펴보기 편하도록 F12 개발자 도구 창 전체를 브라우저 하단으로 이동시켰습니다. 드롭다운에서 Comparison 항목을 선택하여 Comparison 보기로 이동합니다.

F12 개발자 도구 - 메모리 창 - Edge - 스냅샷 비교 (Perspective 드롭다운)

노트

이번 섹션의 도입부에서 언급했던 것처럼 이 결과는 버튼 생성 및 삭제 작업을 몇 차례 수행한 이후에 캡처한 결과입니다. 만약 이 준비 단계를 빼먹는다면 본문의 내용과 전혀 다른 결과를 얻게 될 것입니다.

Comparison 보기

Chromium EdgeComparison 보기는 Summary 보기와 비슷한 관점에서 메모리 힙 스냅샷에 접근하지만, 개체 수 증감분메모리 사용량 증감분 파악에 중점을 둔 비교/분석에 특화된 목록 칼럼들을 제공합니다.

각 칼럼의 의미는 이해하기가 크게 어렵지 않으므로 간단하게 한 가지만 설명하고 길게 부연하지는 않도록 하겠습니다. 다음은 본문의 예제를 Comparison 보기로 살펴본 목록에서 ShadowRoot 개체의 증감분 정보를 찾아본 모습입니다. 다섯 개의 새로운 개체가 생성되었고 두 개의 개체가 제거되어 전체적으로 개체 수가 세 개 증가했음을 알 수 있습니다. 여기서 주의 깊게 살펴봐야 할 부분은 # New 칼럼과 # Deleted 칼럼에 찍혀 있는 점들입니다. 이 점들은 해당 개체가 생성된 개체인지 또는 삭제된 개체인지를 나타내주는 표시입니다. Alloc. Size 칼럼과 Freed Size 칼럼도 역시 비슷한 요령으로 파악하시면 됩니다.

F12 개발자 도구 - 메모리 창 - Edge - 스냅샷 비교 (생성 및 삭제 표시)

동적으로 생성된 버튼들에 대한 스냅샷 비교 결과를 간단히 살펴보면 역시 이번에도 세 개의 HTMLInputElement 개체가 생성되었으며 이벤트 핸들러인 세 개의 익명 함수가 클로저로 생성되었음을 알 수 있습니다.

F12 개발자 도구 - 메모리 창 - Edge - 스냅샷 비교

또한 각 익명 함수 항목 우측의 소스 원본 파일 링크를 클릭하면 IE11에서와 비슷하게 바로 Sources 창에서 해당 소스 원본이 열리고 관련된 위치로 이동합니다.

F12 개발자 도구 - Sources 창 - Edge - 클로저 원본

또는 익명 함수 항목을 마우스 오른쪽 버튼으로 클릭한 다음, 메뉴에서 Reveal in Sources panel 항목을 선택해도 됩니다.

F12 개발자 도구 - 메모리 창 - Edge - 스냅샷 비교 (Reveal in Sources panel)

만약 생성된 세 개의 HTMLInputElement 개체 중 어떤 개체가 화면의 어떤 버튼 요소에 대응하는 개체인지 알고 싶다면 이전 글에서 살펴본 것과 같이 각 항목 위에 마우스 커서를 올려보면 됩니다.

F12 개발자 도구 - Sources 창 - Edge - 스냅샷 비교 (버튼 요소 찾기)

또 다른 방법으로 Preview 팝업에서 id 등의 속성 값을 확인해볼 수도 있습니다.

F12 개발자 도구 - 메모리 창 - Edge - 스냅샷 비교 (Preview 팝업)

여기까지 살펴본 내용들만 돌이켜보면 모든 결과가 예상한 바와 같이 얻어졌다고 낙관적으로 생각할 수도 있습니다. 그러나 최소한 Chromium Edge의 경우에는 상황이 그리 간단하지가 않습니다.

단적으로 IE11에서는 동적으로 생성한 세 개의 버튼에 대해 HTMLInputElement 개체와 Function 개체가 각각 하나씩, 총 여섯 개의 개체가 추가되었으며 이는 누구나 납득할 수 있는 결과입니다. 매번 강조하는 것처럼 웹 개발자가 '기대하는 수준의 정보'를 충분히 '예상할 수 있는 형태'로 보여줍니다. 반면 Chromium Edge의 경우에는 사실 예상한 것보다 훨씬 더 많은 개체들이 생성됩니다. 다음은 # Delta 칼럼을 기준으로 내림차순으로 다시 정렬한 목록입니다.

F12 개발자 도구 - 메모리 창 - Edge - 스냅샷 비교 (증가한 개체)

직접 확인할 수 있는 것처럼 HTMLInputElement 개체와 클로저 외에 적어도 다섯 종류의 개체가 다수 증가했습니다. 더군다나 '증가'한 개체만 다섯 종류이고 최종적인 개체 수 증감분에는 변화가 없으나 일단 추가되었다가 다시 삭제된 개체들까지 감안한다면 (또는 일단 삭제되었다가 다시 추가된 개체들까지 감안한다면) 변경 내용이 예상을 훨씬 웃돕니다. 이번 예제의 경우 첫 번째 스냅샷과 두 번째 스냅샷 사이에 추가한 개체는 있지만 삭제한 개체는 없다는 점을 다시 한번 떠올려보면 V8 엔진 내부에서 무언가 복잡한 작업이 이루어지고 있음을 미루어 짐작할 수 있습니다.

참고

기본적으로 이벤트 핸들러가 설정되는 참조 경로 자체가 IE11과는 다릅니다. IE11의 경우 HTMLInputElement 개체가 Function 개체를 직접 참조하는 구조를 갖고 있습니다. 반면 Chromium Edge에서는 다음과 같이 훨씬 복잡한 참조 경로를 갖습니다.

...ShadowRootHTMLInputElementInternalNodeInternalNodeEventListenerV8EventHandlerNotNull(closure)

다음은 위의 목록을 V8EventHandlerNotNull 개체의 증감분 정보에 보다 집중해서 다시 살펴본 모습입니다.

F12 개발자 도구 - 메모리 창 - Edge - 스냅샷 비교 (증가한 개체 - V8EventHandlerNotNull)

이 목록에서 주의 깊게 살펴봐야 할 부분은 각 소스 원본 파일 링크 우측의 줄 번호입니다. 링크를 클릭해서 직접 확인해보면 36번 라인에 해당하는 원본 소스는 다음과 같이 동적으로 생성되는 각 버튼의 이벤트 핸들러입니다. 따라서 이 세 인스턴스는 당연히 생성되어야만 하는 것이 맞습니다.

...
        btn.onclick = function() {
          alert(this.value);
        };
...

반면 20번 라인에 대응하는 원본 소스는 Create 버튼의 onclick 특성입니다. 한 가지 기억해야 할 점은 이번 예제에서는 첫 번째 스냅샷을 찍기 전에 이미 V8 엔진을 '예열'하기 위해서 몇 차례 Create 버튼과 Remove 버튼을 클릭했다는 사실입니다. 게다가 첫 번째 스냅샷을 찍고 두 번째 스냅샷을 찍기 전에 세 차례나 Create 버튼을 클릭했습니다. 그럼에도 불구하고 20번 라인에 대응하는 인스턴스는 단 한 개만 존재합니다. 이 Comparison 보기의 결과를 어떻게 이해해야 할까요?

...
    <input type="button" value="Create" onclick="fnCreate();" style="float:left;" />
    <input type="button" value="Remove" onclick="fnRemove();" style="float:left;" />
...

또한 21번 라인에 해당하는 원본 소스는 Remove 버튼의 onclick 특성입니다. 첫 번째 스냅샷을 찍고 두 번째 스냅샷을 찍기 전까지 Remove 버튼을 클릭한 적이 없기 때문에 이 결과는 이해하기가 더 어렵습니다. 두 스냅샷을 각각 따로 살펴보면 먼저 @1271642496 인스턴스가 제거된 다음 @1277523456 인스턴스가 다시 생성되었음을 알 수 있는데 이런 동작의 기준은 무엇일까요?

...
    <input type="button" value="Create" onclick="fnCreate();" style="float:left;" />
    <input type="button" value="Remove" onclick="fnRemove();" style="float:left;" />
...

이처럼 V8 엔진은 내부적으로 복잡한 최적화 기법을 사용하고 있고 그 정보 중 상당수를 메모리 힙 스냅샷에 노출하기 때문에 Chromium 계열의 브라우저에서 메모리 힙 스냅샷을 능숙하게 분석하기 위해서는 많은 경험과 V8 엔진 자체에 대한 지식을 필요로 합니다. 본문에서 V8 엔진을 '예열'하도록 안내한 이유도 주제와 관련 없는 정보를 최소화하기 위함이었습니다. 또한 굳이 본 시리즈에서 IE11Chromium Edge메모리 힙 스냅샷 분석 기능을 병행해서 살펴보는 이유도 Chromium 계열의 브라우저만 집중적으로 살펴볼 경우, 대부분의 초보 개발자들이 도입부에서 포기할 것을 우려했기 때문입니다.

이와 같은 V8 엔진 내부의 정보들을 분석하는 방법에 대해서는 저 역시도 일부 영역에 대해서만 파악하고 있을 뿐입니다. 지면 관계상 이 주제에 관해서는 시리즈의 이후 글에서 다시 자세하게 살펴보도록 하겠습니다.

기타: Filter 드롭다운 및 Base snapshot 드롭다운

Summary 보기에서는 Filter 드롭다운을 활용하여, 그리고 Comparison 보기에서는 Base snapshot 드롭다운을 활용하여 다양한 방식으로 개체 목록을 살펴볼 수 있습니다. 이 중 Base snapshot 드롭다운은 두 건 이상의 스냅샷이 존재할 경우에만 나타납니다.

다음은 세 건의 스냅샷이 존재하는 상태에서 Summary 보기의 Filter 드롭다운을 살펴본 모습입니다. All objects 항목을 선택한 경우에만 현재 선택한 스냅샷의 모든 개체가 목록으로 제공되고, 나머지 항목들이 선택된 경우에는 좌측에서 선택한 스냅샷과 상관 없이 항상 선택한 항목에 해당하는 개체들만 목록에 나타납니다.

F12 개발자 도구 - 메모리 창 - Edge - Summary 보기 (Filter 드롭다운)

다음은 세 건의 스냅샷이 존재하는 상태에서 Comparison 보기의 Base snapshot 드롭다운을 살펴본 모습입니다. 좌측에서 선택한 스냅샷과 비교할 기준 스냅샷을 선택할 수 있습니다.

F12 개발자 도구 - 메모리 창 - Edge - Comparison 보기 (Filter 드롭다운)

메모리 힙 스냅샷 분석 실습

메모리 누수 여부 확인하기

지금까지 살펴본 내용을 바탕으로 예제 HTML 파일의 기능에 메모리 누수가 존재하는지 직접 검토해보도록 하겠습니다. 만약 어떤 방식으로든 메모리 누수가 존재한다면 동적으로 생성했던 버튼들을 모두 제거한 뒤에도 남겨진 JavaScript 개체나 DOM 요소가 존재할 것입니다. 그다지 어렵지 않은 작업으로 예상할 수도 있지만, 메모리 힙 스냅샷을 처음 분석하는 분들에게는 꼭 그런 것만도 아닙니다. 구체적인 확인 과정은 다음과 같습니다.

  1. IE11 또는 Chromium Edge에서 예제 페이지를 실행합니다. 지금까지 내용을 살펴보면서 발생한 영향을 최소화하기 위해 가급적 새로운 브라우저 창으로 여는 것이 좋습니다.
  2. F12 키를 눌러서 F12 개발자 도구를 실행하고 메모리 창으로 이동합니다.
  3. 페이지가 로드된 최초 상태에서(Chromium Edge에서는 별도 준비 필요, 하단 '노트' 참고) 첫 번째 메모리 힙 스냅샷을 찍습니다.
  4. Create 버튼을 몇 차례 클릭해서(본문에서는 3회) 동적으로 버튼을 추가합니다.
  5. 다시 두 번째 메모리 힙 스냅샷을 찍습니다.
  6. Remove 버튼을 몇 차례 클릭해서(본문에서는 3회) 추가된 버튼을 모두 제거합니다.
  7. 다시 세 번째 메모리 힙 스냅샷을 찍습니다.
  8. 첫 번째와 세 번째 메모리 힙 스냅샷을 서로 비교/분석합니다.
노트

이번에도 Chromium Edge에서는 3. 번 과정을 수행하기 전에 먼저 Create 버튼을 여러 차례 클릭해서 여러 개의 버튼을 동적으로 생성했다가 다시 Remove 버튼을 여러 차례 클릭해서 생성된 버튼을 모두 삭제하는 준비 작업을 잊지 않도록 합니다.

IE11에서 메모리 누수 여부 확인하기

다음은 IE11에서 방금 설명한 것과 같은 과정을 거쳐서 얻은 결과입니다. 일단 동적으로 생성했던 버튼들을 모두 제거한 세 번째 스냅샷 타일의 정보를 살펴보면 개체 수가 최초 상태의 첫 번째 스냅샷과 동일한 30개로 되돌아온 것을 확인할 수 있습니다. 즉 개체 수 증감분만 봤을 때는 정상적입니다.

F12 개발자 도구 - 메모리 창 - IE11 - 실습 - 메모리 누수 여부 확인

그런데 메모리 사용량 증감분을 살펴보면 조금 이상합니다. 두 번째 스냅샷 타일에서 2.4 KB 증가했던 메모리 사용량이 세 번째 스냅샷 타일에서는 1.96 KB만 반환되었습니다. 혹시 기능에 메모리 누수가 존재하는 것일까요? 세 번째 스냅샷 타일을 마우스 오른쪽 버튼으로 클릭한 다음, 비교... 메뉴 하위의 서브 메뉴에서 스냅숏 #1을 선택해서 비교 모드로 살펴보도록 하겠습니다. 다음은 그 결과입니다.

F12 개발자 도구 - 메모리 창 - IE11 - 실습 - 메모리 누수 여부 확인

무언가 유용한 정보가 제공되리라고 기대한 것과는 달리 나타나는 항목이 아무것도 없습니다. 그도 당연한 게 개체 수 증감분이 정상이라는 말은 생성됐던 개체들이 모두 정상적으로 제거되었다는 뜻이기 때문입니다. 따라서 비교 모드의 이 결과는 오히려 깔끔하게 개체가 제거된 상태를 보여줄 뿐, 메모리 누수가 존재한다는 근거는 될 수 없습니다.

조금 번거롭기는 하지만 첫 번째 스냅샷과 세 번째 스냅샷을 기본 모드의 유형 보기에서 하나씩 항목을 비교해보면 메모리 사용량에 차이가 나는 개체를 찾아낼 수 있습니다. 결론부터 말하자면 주된 원인은 바로 Create 버튼과 Remove 버튼 때문입니다. 다음은 두 스냅샷의 유형 보기를 비교하기 편하게 정리한 결과입니다.

F12 개발자 도구 - 메모리 창 - IE11 - 실습 - 메모리 누수 여부 확인

그렇지만 이 결과를 근거로 예제 HTML 파일의 기능에 메모리 누수가 존재한다고 판단하기는 어렵습니다. 두 버튼과 관련하여 작성된 특별한 코드가 전무할뿐더러 애초에 구현 자체가 지극히 단순하기 때문입니다. 특히 이 버튼들은 스냅샷을 찍는 동안 단지 여섯 차례 클릭만 되었을 뿐 생성되거나 삭제되는 등 어떠한 조작의 대상도 아니었습니다. 따라서 만약 이 현상이 메모리 누수가 맞다면 클릭 동작에 대한 브라우저 자체의 버그로 간주하는 것이 옳고, 또 그렇다면 웹 클라이언트 개발자 수준에서 개선할 수 있는 방법은 전혀 없습니다.

다음 메모리 힙 스냅샷 타일들의 모습을 살펴보시기 바랍니다. 이 결과는 버튼 생성 및 제거 작업 전반을 한 번이 아니라 여러 차례 반복하여 얻은 것입니다. 최초 1회의 버튼 생성 및 제거 작업 이후부터는 매번 2.18 KB가 추가되었다가 다시 2.18 KB가 안정적으로 반환되는 것을 확인할 수 있습니다. 결국 IE11Chromium 계열의 브라우저 만큼은 아니지만 나름대로 '예열'이 필요한 것으로 볼 수 있는 대목입니다. 이처럼 살펴보고자 하는 기능이 반복적으로 실행되는 유형의 기능인 경우에는 충분한 횟수의 동작을 수행한 이후에 메모리 힙 스냅샷을 분석하는 편이 보다 효과적입니다.

F12 개발자 도구 - 메모리 창 - IE11 - 실습 - 메모리 누수 여부 확인

개인적인 경험에 비추어볼 때, 메모리 누수가 없는 경우라고 하더라도 개체 수의 경우와는 달리 메모리 사용량이 완벽하게 반환되는 경우는 거의 없습니다. 단적으로 본문의 예제 HTML 파일이 얼마나 단순하게 구현되었는지 생각해본다면 실제 웹 응용 프로그램의 복잡한 구현 하에서 메모리가 완벽하게 반환된다는 것이 얼마나 어려운 일인지 예상할 수 있을 것입니다. 게다가 지금처럼 특정 기능만 분리해서 집중적으로 살펴보는 것도 대부분 불가능하며, 거의 항상 다른 수많은 기능들과 복합적으로 동작하고 특정 기능을 테스트하기 위해 수행할 수 있는 단계에 도달하기까지 많은 선행 과정이 필요한 상황도 많습니다.

결론적으로 예제 HTML 파일의 기능에는 메모리 누수가 존재하지 않는 것으로 판단할 수 있습니다. 다만 이처럼 상황에 따라서는 메모리 누수가 존재함을 증명하는 것보다 메모리 누수가 존재하지 않음을 증명하는 일이 더 힘들 수도 있습니다.

Chromium Edge에서 메모리 누수 여부 확인하기

다음은 Chromium Edge에서 동일한 과정을 거쳐서 얻은 결과로, 역시 이번에도 V8 엔진을 '예열'한 뒤에 세 번의 메모리 힙 스냅샷을 찍었습니다. 세 번째 스냅샷을 선택한 다음, Comparison 보기를 선택하고 Base snapshot 드롭다운에서 Snapshot 1을 비교 대상으로 선택했습니다. 그리고 # Delta 칼럼을 기준으로 내림차순으로 다시 정렬한 모습입니다.

F12 개발자 도구 - 메모리 창 - Edge - 실습 - 메모리 누수 여부 확인

이 결과를 살펴보면 첫 번째 스냅샷과 비교해봤을 때 제거되지 않고 세 번째 스냅샷에 남아있는 개체는 없음을 알 수 있습니다. 각자 환경에 따라 다르겠지만 # Delta 칼럼을 기준으로 다시 오름차순으로 정렬해보면 오히려 제거된 개체는 존재할 수도 있습니다. 그런 개체는 대부분 V8 엔진 내부에서 최적화 과정 중에 관리 목적으로 생성되었다가 삭제된 개체거나 '예열' 과정 중에 생성되었던 개체일 확률이 높습니다. 적어도 예제 HTML 파일에서 동적으로 관리하는 HTMLInputElement 개체와 클로저, 그리고 그 밖에 EventListener 개체, V8EventHandlerNotNull 개체 같은 관련 개체들은 확실하게 제거되었습니다. 즉 이번에도 개체 수 증감분은 정상적입니다.

이번에는 Base snapshot 드롭다운에서 Snapshot 2를 선택하여 두 번째 스냅샷과 세 번째 스냅샷을 비교해보도록 하겠습니다. 과연 올바른 HTMLInputElement 개체와 클로저가 정상적으로 제거된 것이 맞는지를 확인하기 위한 과정으로, 다음은 그 결과입니다.

F12 개발자 도구 - 메모리 창 - Edge - 실습 - 메모리 누수 여부 확인

그런데 막상 개체들을 확인해보려고 하면 문제가 한 가지 존재합니다. 각각의 스냅샷이 아닌 페이지 전체적인 관점에서 보면 생성되었던 세 개의 버튼은 현재 존재하지 않는 개체입니다. 목록을 살펴보면 HTMLInputElement 개체와 클로저가 각각 세 개씩 제거된 것을 확인할 수 있고, 이 여섯 개의 개체가 찾고 있던 개체들인 것 같기는 한데 짐작만 가능할 뿐 확실한 근거가 될만한 정보는 얻을 수가 없습니다. 이전 섹션에서 살펴봤던 것처럼 HTMLInputElement 개체 위에 마우스를 올려봐도 페이지에서 대응하는 버튼 요소가 반전된다거나 Preview 팝업이 나타나는 대신 'Preview is not available'이라는 문구만 나타납니다. 페이지에 해당 버튼은 더 이상 존재하지 않으니 말입니다. 클로저 역시 항목 우측에 제공되던 소스 원본 파일 링크가 제공되지 않아서 추적이 번거롭습니다.

이 문제를 해결할 수 있는 방법은 두 번째 스냅샷에서 추가된 HTMLInputElement 개체와 클로저의 개체 ID를 확인하여 이를 세 번째 스냅샷의 삭제된 개체들의 개체 ID와 대조해 보는 것입니다. 두 번째 스냅샷을 선택한 다음, Comparison 보기를 선택하고 Base snapshot 드롭다운에서 Snapshot 1을 비교 대상으로 선택합니다.

F12 개발자 도구 - 메모리 창 - Edge - 실습 - 메모리 누수 여부 확인

두 결과를 비교해보면 두 번째 스냅샷에서 추가되었던 개체가 세 번째 스냅샷에서 제거된 개체와 정확하게 일치함을 알 수 있습니다. 결론적으로 Chromium Edge에서도 예제 HTML 파일의 기능에는 메모리 누수가 존재하지 않는 것으로 판단할 수 있습니다.

분리된 DOM 트리 검토하기

분리된 DOM 트리(Detached DOM Tree)는 가장 빈번하게 발생하는 대표적인 메모리 누수 패턴 중 하나입니다. 분리된 DOM 트리로 인해 메모리 누수가 발생하는 구조 및 원인은 매우 단순합니다.

정상적인 상황에서는 페이지에 존재하는 특정 HTML 요소, 즉 DOM 노드가 전체 DOM 트리로부터 분리되어 제거될 경우 사용하던 메모리가 가비지 컬렉터에 의해 반환됩니다. 가령 본문의 예제에서도 동적으로 생성한 버튼을 Remove 버튼으로 제거할 때마다 매번 이런 작업이 수행되고 있다고 생각해도 무방합니다. (다만 실제로 가비지 컬렉터가 작업을 수행하는 시점을 개발자가 예상하거나 지정할 수는 없습니다.)

그런데 무언가 다른 이유로 분리된 DOM 노드를 여전히 JavaScript 개체나 변수에서 참조하고 있다면 가비지 컬렉터가 해당 DOM 노드의 메모리를 반환할 수 없게 됩니다. 여기에는 다양한 사유가 있을 수 있는데, 그저 개발자가 변수 정리를 잊어버렸을 수도 있고, 초보 개발자가 부주의하게 전역 변수를 사용했거나 의도치 않게 var 키워드를 누락했을 수도 있습니다. 또는 고급 개발자가 클로저와 엮인 복잡한 영역 문제를 미처 예상하지 못해서 제거되리라고 기대했던 변수가 여전히 남아있을 수도 있습니다.

그렇게 큰 문제가 아닌 것처럼 보일 수도 있지만 모든 문제가 그렇듯 몇 가지 조건을 만족할 경우 누수되는 메모리의 양이 매우 거대해질 수도 있습니다. 분리된 DOM 노드 역시 자식 노드들을 가질 수 있고, 자식 노드들 역시 반복적으로 각각 자식 노드를 가질 수 있다는 점을 기억해야 합니다. 그뿐만 아니라 각 노드는 각각 이벤트 핸들러를 가질 수 있고, 각 이벤트 핸들러는 또 다른 JavaScript 함수 또는 개체를 참조할 수도 있습니다. 최악의 경우 이런 메모리 누수가 발생하는 지점이 반복문 내부라면 파괴력이 상당할 것입니다. (바로 이런 점이 '분리된 DOM 노드' 대신 '분리된 DOM 트리'라는 명칭을 사용하는 이유입니다.)

분리된 DOM 트리에 대한 추가적인 정보는 Google의 다음 문서를 참고하시기 바랍니다.

이번 섹션에서는 본문의 예제 HTML 파일을 일부 수정하여 고의적으로 분리된 DOM 트리를 재현하고 메모리 힙 스냅샷을 분석하여 메모리 누수를 감지하는 방법을 살펴보도록 하겠습니다. 다음은 변경된 예제 HTML 파일의 내용입니다. (새 창에서 보기)

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>메모리 누수 - 새로운 HTML 요소 생성 및 제거 (detached ref.)</title>
    <style>
      body {
        font-size: 12px;
        font-family: Tahoma;
      }
      input[type="button"] {
        display: block;
        width: 150px;
        height: 30px;
        margin: 5px;
      }
    </style>
  </head>
  <body>
    <input type="button" value="Create" onclick="fnCreate();" style="float:left;" />
    <input type="button" value="Remove" onclick="fnRemove();" style="float:left;" />
    <div id="btnBox" style="clear:both;">
      <!-- 여기에 버튼들이 생성됩니다. -->
    </div>

    <script>
      var index = 0;
      var _LEAK_REF_ = null;

      function fnCreate() {
        index++;

        var btn = document.createElement("input");
        btn.id = "btn_" + index;
        btn.type = "button";
        btn.value = "Button " + index;
        btn.onclick = function() {
          alert(this.value);
        };

        document.getElementById("btnBox").appendChild(btn);

        _LEAK_REF_ = btn;
      }

      function fnRemove() {
        if (index < 1) {
          return;
        }

        var btn = document.getElementById("btn_" + index);
        btn.onclick = null;
        btn.parentNode.removeChild(btn);

        index--;
      }
    </script>
  </body>
</html>

변경된 예제 HTML 파일에서는 항상 마지막으로 생성된 버튼을 _LEAK_REF_라는 이름의 변수가 참조합니다. 또한 _LEAK_REF_ 변수는 전역 변수이기 때문에 개발자가 따로 작업을 수행하지 않는 이상 계속 참조를 유지할 것입니다.

IE11에서 분리된 DOM 트리 검토하기

변경된 예제 HTML 파일을 IE11에서 실행한 다음, 이전 섹션과 정확하게 동일한 방법으로 세 차례 메모리 스냅샷을 찍습니다. 다음은 그 결과를 보여줍니다.

가장 먼저 눈에 띄는 점은 세 번째 스냅샷 타일 우상단에 느낌표 아이콘으로 표시된 잠재적인 메모리 문제 수 링크입니다. 이 항목은 이름 그대로 IE11F12 개발자 도구메모리 힙 스냅샷에서 감지한 잠재적인 문제점의 개수를 나타냅니다.

F12 개발자 도구 - 메모리 창 - IE11 - 실습 - 분리된 DOM 트리 (요약)

그리고 두 번째 스냅샷과 세 번째 스냅샷의 타일을 서로 비교해보면 두 번째 스냅샷에서는 6개의 개체가 증가했으나 세 번째 스냅샷에서는 5개의 개체만 제거되었음을 알 수 있습니다. 메모리 사용량도 두 번째 스냅샷에서는 2.4 KB가 증가했으나 세 번째 스냅샷에서는 1.75 KB만 반환되었습니다. 결국 메모리 사용량 증감분개체 수 증감분 모두에 문제가 있는 셈입니다.

마우스로 잠재적인 메모리 문제 수 링크를 클릭하면 다음과 같이 세 번째 스냅샷의 유형 보기로 이동하게 됩니다. 문제점을 갖고 있는 _LEAK_REF_ 변수에 정확하게 느낌표 아이콘이 표시된 것을 볼 수 있습니다.

F12 개발자 도구 - 메모리 창 - IE11 - 실습 - 분리된 DOM 트리 (유형)

루트 보기와 도미네이터 보기도 유형 보기와 비슷한 형태로 정보를 제공해줍니다. 다음은 루트 보기의 모습입니다. _LEAK_REF_ 변수는 전역 변수로 선언되었기 때문에 전역 루트를 의미하는 (Global) 노드의 자식 노드로 나타납니다.

F12 개발자 도구 - 메모리 창 - IE11 - 실습 - 분리된 DOM 트리 (루트)

그리고 다음은 도미네이터 보기의 모습입니다.

F12 개발자 도구 - 메모리 창 - IE11 - 실습 - 분리된 DOM 트리 (도미네이터)

비교 보기 모드를 이용해서 문제점을 갖고 있는 개체들만 집중적으로 살펴볼 수도 있습니다. 다만 이번 예제에서는 범위 드롭다운에서 스냅숏 #2의 남은 개체 수(1) 항목을 선택해야만 원하는 항목을 볼 수 있습니다.

F12 개발자 도구 - 메모리 창 - IE11 - 실습 - 분리된 DOM 트리 - 스냅샷 비교 (유형)

만약 실제 웹 응용 프로그램에서 이렇게 메모리 누수를 유발하는 코드를 발견했다면 어떻게 코드를 변경해야 메모리 누수가 사라질까요? 여러가지 방법이 있겠지만 다음과 같이 _LEAK_REF_ 변수를 fnCreate 함수 내부에서만 유지되는 지역 변수로 바꿔도 됩니다.

...
      var index = 0;
      // var _LEAK_REF_ = null;
      
      function fnCreate() {
        index++;

        var btn = document.createElement("input");
        btn.id = "btn_" + index;
        btn.type = "button";
        btn.value = "Button " + index;
        btn.onclick = function() {
          alert(this.value);
        };

        document.getElementById("btnBox").appendChild(btn);

        var _LEAK_REF_ = btn;
        // 여기에서 추가적으로 필요한 작업을 수행합니다.
      }
...

Chromium Edge에서 분리된 DOM 트리 검토하기

계속해서 이번에는 변경된 예제 HTML 파일을 Chromium Edge에서 실행하고 V8 엔진의 '예열'을 마친 다음, 역시 동일한 방법으로 세 차례 메모리 스냅샷을 찍습니다.

다음은 그 결과에서 세 번째 스냅샷의 Comparison 보기를 선택하여 Snapshot 2와 비교하고 있는 모습입니다. 그러나 IE11과는 달리 개발자가 메모리 누수를 인지할 수 있을 만한 무언가 눈에 띄는 표시 같은 것은 보이지 않습니다.

F12 개발자 도구 - 메모리 창 - Edge - 실습 - 분리된 DOM 트리

대신 Chromium Edge에서는 Class filter 텍스트 상자에 'Detached'라는 키워드를 입력하여 분리된 DOM 트리가 존재하는지 검토해볼 수 있습니다.

가령 다음의 필터링 된 목록을 살펴보면 한 개의 Detached HTMLInputElement 개체 및 관련된 몇 개의 분리된 개체가 여전히 메모리에 남아있는 것을 확인할 수 있습니다. 또한 마우스로 Detached HTMLInputElement 개체를 선택한 다음, 하단의 Retainers 목록을 살펴보면 전역 Window 개체를 뜻하는 Window / {테스트 주소} 개체의 속성인 _LEAK_REF_ 변수가 문제의 Detached HTMLInputElement 개체를 참조하고 있음을 알 수 있습니다.

F12 개발자 도구 - 메모리 창 - Edge - 실습 - 분리된 DOM 트리

그리고 Detached HTMLInputElement 개체에 마우스 커서를 올려보면 이미 버튼은 제거되었음에도 불구하고 여전히 다음과 같이 Preview 팝업을 이용해서 개체의 속성 값들을 살펴볼 수 있는데, 'Preview is not available' 문구가 나타나는 대신 이렇게 실제 정보가 출력되는 이유도 아직 메모리에 인스턴스가 남아있기 때문입니다.

F12 개발자 도구 - 메모리 창 - Edge - 실습 - 분리된 DOM 트리

마지막으로 코드를 보완하여 메모리 누수를 해결한 다음, 메모리 힙 스냅샷에서 어떻게 나타나는지 확인해보고 본문을 마무리하도록 하겠습니다. 다만 이번에는 업무적인 이유 때문에 동적으로 생성한 버튼이 하나라도 존재하는 동안에는 가장 마지막 버튼의 참조를 유지해야 한다고 가정해보겠습니다. 문제를 해결할 수 있는 다양한 방법이 존재하겠지만 다음과 같이 마지막 버튼이 제거될 때 _LEAK_REF_ 변수의 참조를 함께 제거해주는 것도 한 가지 방법일 것입니다.

...
      function fnRemove() {
        if (index < 1) {
          return;
        }

        var btn = document.getElementById("btn_" + index);
        btn.onclick = null;
        btn.parentNode.removeChild(btn);

        index--;

        if (index < 1) {
          _LEAK_REF_ = null;
        }
      }
...

다시 테스트를 수행하여 메모리 힙 스냅샷을 찍어보면 더 이상 분리된 DOM 트리가 존재하지 않음을 확인할 수 있습니다.

F12 개발자 도구 - 메모리 창 - Edge - 실습 - 분리된 DOM 트리

정리

본문에서는 IE11Chromium EdgeF12 개발자 도구를 활용하여 비교 보기 모드 또는 Comparison 보기에서 메모리 힙 스냅샷 간의 비교/분석을 수행하는 방법을 알아보고 직접 간단한 예제도 살펴봤습니다.

이어지는 글에서는 메모리 힙 스냅샷에 대한 가장 본질적인 사항을 기초적인 부분부터 다시 살펴보도록 하겠습니다.