IE11 및 Chromium Edge에서 가상의 메모리 누수 상황 재현 및 해결하기

등록일시: 2020-05-05 08:00,  수정일시: 2020-05-05 08:00
조회수: 11,061

본문의 주제를 처음 준비하던 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 개발자 도구를 활용한 메모리 힙 스냅샷의 비교/분석을 통해 웹 브라우저의 메모리 누수를 감지하는 가장 기본적인 방법을 살펴봅니다.

시리즈 목차

문서 목차

들어가기 전에

본문에서는 실제 업무 환경에서 발생할 수 있는 가상의 메모리 누수 상황을 재현하여 지금까지 본 시리즈를 통해서 살펴본 모든 내용을 바탕으로 문제의 원인을 파악하고 그에 대한 해결 방안을 검토해봅니다.

간단히 참고를 위해서 말씀드리면, 본문에서 살펴보게 될 예제 코드는 몇 년 전 제가 실제 프로젝트 현장에서 경험한 상황을 극도로 간략하게 패턴만 정리해서 소개해드리는 것입니다. 당시 해당 프로젝트는 담당 개발팀에 의해 구축이 완료되어 이제 막 서비스가 시작된 상황이었으나, 메모리 누수 현상이 심각해서 일정 시간 브라우저를 재시작하지 않고 계속 사용할 경우 업무 중 동작이 멈추는 등의 현상이 있었습니다. 짧은 기간 동안 파악한 문제의 원인은 다양했지만, 본 시리즈의 주제에 가장 부합한다고 판단되는 사례 한 가지를 선정했습니다.

예제 코드 살펴보기

다음은 본문에서 살펴볼 예제 HTML 파일의 기본 내용입니다. 본 시리즈의 예전 글에서 살펴봤던 다른 예제 HTML 파일과 상당히 비슷하지만 몇 가지 다른 점이 존재합니다. 눈에 띄는 가장 큰 차이점은 외부 라이브러리 없이 전통적인 방식의 JavaScript 코드만 사용했던 시리즈의 다른 예제들과는 달리, 1.x 버전 대의 jQuery 라이브러리를 사용하고 있다는 것입니다. 다만 이번에도 제공되는 기능 자체는 매우 단순해서 Create 500 Btn. 버튼을 클릭하면 간단한 onclick 이벤트 핸들러가 설정된 새로운 버튼들이 한 번에 500개씩 만들어지고, Remove All 버튼을 클릭하면 반대로 생성된 모든 버튼들이 제거될 뿐입니다. (새 창에서 보기)

그러나 무엇보다도 이 예제 HTML 파일에서 가장 중요한 점은 불과 50줄도 되지 않는 이 간단한 파일에 매우 치명적인 메모리 누수를 유발하는 코드가 존재한다는 사실입니다.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>메모리 누수 - 새로운 HTML 요소 생성 및 제거 (jQuery)</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 500 Btn." onclick="fnCreate();" style="float:left;" />
    <input type="button" value="Remove All" onclick="fnRemove();" style="float:left;" />
    <div id="btnBox" style="clear:both;">
      <!-- 여기에 버튼들이 생성됩니다. -->
    </div>

    <script src="https://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.12.4.js"></script>
    <script>
      function fnCreate() {
        var $btnBox = $("#btnBox");
      
        for (var index = 1; index <= 500; index++) {
          var $btn = $('<input type="button" id="btn_' + index + '" value="Button ' + index + '" style="display:inline-block;" />');
          $btn.click(function () {
            alert(this.value);
          });
          $btnBox.append($btn);
        }
      }
      
      function fnRemove() {
        document.getElementById("btnBox").innerHTML = "";
      }
    </script>
  </body>
</html>

이미 짐작하고 있는 분들도 계시겠지만 이번 예제 HTML 파일에서 jQuery 라이브러리를, 그것도 최신 버전도 아닌 1.x 버전 대를 사용하고 있는 데에는 그만한 이유가 있습니다. 바로 메모리 누수가 이 버전 대의 jQuery 라이브러리와 관계되어 발생하기 때문입니다.

많은 분이 알고 계신 것처럼 1.x 버전 대의 jQuery 라이브러리는 IE6~8과 같은 구형 브라우저를 지원하기 위한 버전입니다. 물론 실제 개발 환경에서 jQuery 라이브러리를 사용해야 할 경우, 필요에 따라 원하는 최신 버전을 마음대로 선택할 수 있는 상황이라면 좋겠지만 안타깝게도 국내의 사정은 아직까지도 그럴 수만은 없는 상태입니다. 이미 운영 중인 시스템의 경우에는 상황이 더욱 심각해서 아직도 수많은 기업체에서는 기업 포털 등의 표준 브라우저가 IE 기반인 경우가 많고, 구축 당시에 사용된 1.x 버전 대의 jQuery 라이브러리가 남아 있는 사례도 많습니다. 새로 구축하는 시스템에서도 다소 보수적인 선택으로 인해 여전히 1.x 버전 대가 선택되기도 합니다. 역설적으로 그런 상대적으로 열악한 브라우저 환경에서 발생하는 메모리 누수야말로 더욱더 치명적이기 때문에 문제가 발생할 수 있는 상황을 보다 잘 파악하고 있어야 합니다.

그러나 다른 한편으로는 최신 버전의 jQuery 라이브러리나 요즘 주목받는 React, Angular, Vue.js 등과 같은 SPA(Single Page Application) 프레임워크라고 해서 본문의 주제와 같은 유형의 문제에서 자유로운 것만은 아닙니다. 완전히 똑같은 문제가 존재한다는 뜻은 아니지만, 특정 라이브러리나 프레임워크에는 미처 알려지지 않은 문제점이 얼마든지 내포되어 있을 수도 있고, 개발자가 충분한 이해 없이 코드를 작성할 경우 불필요한 문제를 만들어내기도 합니다.

따라서 중요한 점은 사용하는 라이브러리나 프레임워크와는 무관하게 다양한 방식의 테스트를 통해서 발생하는 문제점(본문에서는 메모리 누수)을 빠르게 파악하고 그에 따라 적절한 대응을 하는 것입니다. 단지 본문에서는 그 한 가지 사례로 1.x 버전 대의 jQuery 라이브러리를 주제로 다루고 있을 뿐입니다.

메모리 누수 발견하기

지금까지 본 시리즈에서 살펴본 대부분의 예제들은 애초부터 코드에 메모리 누수가 존재하는 상황을 전제로 하고 있습니다. 따라서 메모리 누수가 존재하는지 여부를 확인하는 과정을 생략하고 바로 메모리 힙 스냅샷부터 살펴보는 경우가 거의 대부분이었습니다. 비록 본문의 예제 역시 마찬가지인 상황이지만 그래도 이번에는 간단하게나마 메모리 누수를 확인하여 발견하는 과정부터 가볍게 살펴보도록 하겠습니다.

IE11에서 메모리 누수 발견하기

메모리 누수를 발견하기 위해서 IE11에서 시도해볼 수 있는 가장 손쉬운 방법은 검토하고자 하는 기능을 반복해서 수행하면서 F12 개발자 도구에서 메모리 창의 요약 화면이 제공하는 총 메모리 사용량 그래프를 살펴보는 것입니다. IE11에서 예제 페이지를 실행한 다음, F12 키를 눌러서 F12 개발자 도구를 실행하고 메모리 창을 선택합니다. 그런 다음, Ctrl+E 단축키를 누르거나 메모리 창 좌상단의 프로파일링 세션 시작 버튼을 클릭하여 프로파일링 세션을 시작하면 총 메모리 사용량 그래프가 기록되기 시작합니다.

이제 Create 500 Btn. 버튼과 Remove All 버튼을 차례대로 반복해서 눌러보면서 총 메모리 사용량 그래프를 살펴보면 됩니다. 다음은 20여 차례 버튼을 클릭하여 테스트한 결과를 보여줍니다. 단 이 그래프에서 마지막 부분에 메모리 사용량이 크게 늘어난 것처럼 보이는 부분은 스냅샷을 찍으면서 발생한 메모리 사용량의 증가로 인한 것일 뿐, 버튼 클릭 테스트로 비롯된 것이 아님에 주의하시기 바랍니다.

F12 개발자 도구 - 메모리 창 - IE11 - 총 메모리 사용량 그래프

이 그래프를 살펴보면 누가 봐도 메모리 사용량에 문제가 존재한다는 사실을 알 수 있습니다. 게다가 메모리 힙 스냅샷에서 개체 수 링크를 살펴보면 무려 11만 개가 넘는 개체가 남아 있을뿐더러 잠재적인 메모리 문제 수 링크까지 표시되고 있습니다. 보다 세부적인 메모리 힙 스냅샷의 상태는 잠시 후 메모리 누수의 원인을 찾아보면서 다시 자세하게 살펴보도록 하겠습니다.

특정 시점의 메모리 사용량을 보다 정확하게 알아보려면 마우스 커서를 그래프 위에 올려보면 됩니다. 다음은 테스트 시작 시점과 마무리 시점의 캡처를 살펴보기 편하게 편집한 결과입니다. 무려 100 MB 이상의 메모리 누수가 발생하고 있음을 확인할 수 있습니다.

F12 개발자 도구 - 메모리 창 - IE11 - 총 메모리 사용량 그래프

이번 예제의 경우, 테스트하고자 하는 대상 기능이 단 두 가지 함수로만 구성되어 있습니다. 그러나 만약 수 십여 개의 함수가 복잡하게 얽혀 있는 기능을 테스트해야 한다면, 그리고 그래프 상에서 메모리 사용량이 크게 증가하는 부분이 그중 어떤 함수인지 파악하고 싶다면 지금과 같은 그래프 정보만으로는 밝혀내기가 그리 쉽지 않을 것입니다. 그런 상황에서 크게 도움이 되는 지원 함수가 바로 performance.mark()입니다. 다만 이 함수는 브라우저에 따라서 사용하는 방법이 조금씩 다른데 IE11에서는 다음과 같이 적절한 위치에서 원하는 만큼 호출하기만 하면 됩니다. (새 창에서 보기)

...
    
      function fnCreate() {
        performance.mark("START: fnCreate");
        var $btnBox = $("#btnBox");
      
        for (var index = 1; index <= 500; index++) {
          var $btn = $('<input type="button" id="btn_' + index + '" value="Button ' + index + '" style="display:inline-block;" />');
          $btn.click(function () {
            alert(this.value);
          });
          $btnBox.append($btn);
        }
      }
      
      function fnRemove() {
        document.getElementById("btnBox").innerHTML = "";
        performance.mark("END: fnRemove");
      }
    
...

다음은 이렇게 변경한 예제 HTML 파일을 이용해서 총 메모리 사용량 그래프를 다시 얻은 결과입니다. 이번에는 간단하게 3회만 테스트했고 performance.mark() 지원 함수의 결과를 살펴보기 편하게 캡처를 편집했습니다. 그래프 상단의 타임라인에 표시된 주황색 역삼각형들이 바로 performance.mark() 지원 함수가 실행된 각 시점을 나타내며 이 역삼감형에 마우스 커서를 올려보면 표시 이름과 타임스탬프를 확인할 수 있습니다.

F12 개발자 도구 - 메모리 창 - IE11 - 총 메모리 사용량 그래프 - performance.mark() 함수

Chromium Edge에서 메모리 누수 발견하기

Performance 창을 활용한 방법

지금까지 살펴본 내용과 비슷한 방식으로 Chromium Edge에서도 메모리 사용량의 증감 추이를 확인할 수 있습니다. 다만 IE11에서 살펴본 그래프와 비슷한 형태로 메모리 사용량을 확인하기 위해서는 지금까지와는 달리 Performance 창을 사용해야 합니다. Chromium 계열의 다른 개발자 도구들이 대부분 그렇듯이 Performance 창도 엄청나게 다양한 기능과 정보를 제공하지만, 본문에서는 그중 메모리 사용량 그래프에 관해서만 집중적으로 살펴보도록 하겠습니다.

본문의 기본 예제 HTML 파일을 새 Chromium Edge 창에서 실행한 다음, F12 개발자 도구를 열고 Performance 창을 선택합니다. Ctrl+E 단축키를 누르거나 좌상단에 위치한 Record 버튼을 클릭하면 기록이 시작되는데, 그전에 Collect garbage 버튼을 몇 차례 클릭해서 최대한 깨끗한 상태에서 기록을 시작해야 합니다. 나중에 기록을 마치고 중지하기 직전에도 Collect garbage 버튼을 몇 차례 클릭해서 혹시라도 모를 잘못된 정보를 최소화하는 것이 좋습니다. 또한 Memory 항목은 선택 여부와 상관없이 기록 결과에 포함되기 때문에 크게 신경 쓰지 않아도 무방하지만, Screenshots 항목은 선택하지 않고 기록을 수행할 경우 결과에 포함되지 않습니다.

F12 개발자 도구 - Performance 창 - Edge - Record 버튼 및 Collect garbage 버튼

다음은 IE11에서와 동일한 방식으로 20여 차례 버튼을 클릭하여 테스트한 결과를 보여줍니다. 참고로 그래프를 살펴보기 편하도록 F12 개발자 도구 창 전체를 브라우저의 하단으로 이동했으며, 제공되는 정보가 너무 많아서 Screenshots 및 하단의 Summary 탭 등 주제와 무관한 부분은 해제하거나 숨겼습니다.

F12 개발자 도구 - Performance 창 - Edge - Memory 영역

이번에도 마우스 커서를 그래프 위에 올려보면 특정 시점의 메모리 사용량을 보다 정확하게 확인할 수 있습니다. 또한 그래프 상단에 위치한 특정 체크박스의 선택을 해제하면 관심 없는 그래프 시리즈를 숨길 수도 있습니다. 다음은 테스트 시작 시점과 마무리 시점의 캡처를 살펴보기 편하도록 편집한 결과입니다. Chromium Edge에서는 동일한 코드임에도 무려 400 MB 가량의 메모리 누수가 발생하고 있음을 확인할 수 있습니다.

F12 개발자 도구 - Performance 창 - Edge - Memory 영역

이전 섹션에서 살펴본 것처럼 Chromium Edge에서도 performance.mark() 지원 함수를 사용할 수는 있으나, 사용하기 전에 다음과 같이 코드를 한 줄 더 추가해줘야만 합니다. (새 창에서 보기)

...
    
      function fnCreate() {
        performance.mark("START: fnCreate");
        var $btnBox = $("#btnBox");
      
        for (var index = 1; index <= 500; index++) {
          var $btn = $('<input type="button" id="btn_' + index + '" value="Button ' + index + '" style="display:inline-block;" />');
          $btn.click(function () {
            alert(this.value);
          });
          $btnBox.append($btn);
        }
      }
      
      function fnRemove() {
        document.getElementById("btnBox").innerHTML = "";
        performance.mark("END: fnRemove");
        performance.measure("Button Cycle", "START: fnCreate", "END: fnRemove");
      }
    
...

다음은 이렇게 변경한 예제 HTML 파일을 이용해서 다시 기록을 수행한 결과입니다. 기존에는 제공되지 않던 Timings라는 영역이 나타나는데 이를 펼쳐보면 다음과 같이 명명된 타임스탬프 표시가 출력되는 것을 확인할 수 있습니다. (참고로 아래 그림에서는 Memory 영역이 해제된 상태입니다.)

F12 개발자 도구 - Performance 창 - Edge - performance.mark(), measure() 함수

그러나 단지 이런 용도 때문이라면 Chromium Edge에서는 performance.mark() 지원 함수를 사용할 필요가 없습니다. Timings 영역 하단에 위치한 Main 영역에 기본적으로 각 시점에 실행 중인 함수들의 정보가 호출 스택 구조까지 반영한 상태로 제공되기 때문입니다. 다음은 다시 한번 기록을 수행한 다음, 마우스로 fnCreate() 함수가 실행 중인 순간을 드래그해서 선택하여 집중적으로 살펴본 결과입니다.

F12 개발자 도구 - Performance 창 - Edge - Main 영역

IE11에서도 F12 개발자 도구성능 창에서 이와 비슷한 정보가 제공되지만, 정작 해당 성능 창에서는 반대로 메모리 사용량 그래프와 연동하여 정보를 보여주는 기능이 지원되지 않습니다.

이번 섹션에서 간단히 메모리 사용량 그래프에 관해서만 살펴본 Chromium EdgePerformance 창은 제공하는 기능이 매우 방대하기 때문에 자세히 알아보려면 본문과 비슷한 분량의 글이 몇 편은 더 필요합니다. 따라서 이 자리에서는 더 이상 세부적으로 언급하지 않도록 하겠습니다. 그리고 Performance 인터페이스에 대한 보다 자세한 정보는 다음 링크의 문서들을 참고하시기 바랍니다.

Memory 창의 Allocation instrumentation on timeline 프로파일링을 활용한 방법

지금까지 살펴본 다른 그래프들과 제공되는 형태는 조금 다르지만 Memory 창의 Allocation instrumentation on timeline 프로파일링을 활용해서 메모리 누수 여부를 검토해보는 방법도 있습니다. Allocation instrumentation on timeline 프로파일링은 매 50ms마다 타임라인 상의 해당 시점에 힙 메모리에 할당된 개체들에 대한 정보와 해당 개체들이 가비지 컬렉션에 의해 정상적으로 정리되었는지 여부를 보여줍니다.

본문의 기본 예제 HTML 파일을 새 Chromium Edge 창에서 실행한 다음, F12 개발자 도구를 열고 Memory 창을 선택합니다. 그리고 지금까지와는 다르게 프로파일링 유형을 Heap snapshot이 아닌 Allocation instrumentation on timeline으로 선택합니다. 또한 Record allocations stacks (extra performance overhead) 옵션 선택 여부는 본문의 주제와 큰 상관은 없지만 간단히 기능을 소개한다는 의미에서 선택하도록 하겠습니다. 이 옵션을 선택하면 특정 개체가 생성된 시점의 호출 스택 정보가 결과에 포함됩니다.

페이지 하단의 Start 버튼이나 좌상단의 Start recording heep profile 버튼을 클릭해서 기록을 시작합니다. 그리고 Create 500 Btn. 버튼과 Remove All 버튼을 차례대로 반복해서 눌러보면서 적절한 시점까지 정보를 수집한 다음, Stop recording heep profile 버튼을 클릭해서 기록을 마칩니다. 물론 이번에도 기록을 시작하기 직전과 마치기 직전에 각각 Collect garbage 버튼을 몇 차례 클릭해서 최대한 깨끗한 정보를 얻는 것이 좋습니다.

F12 개발자 도구 - Memory 창 - Edge - Allocation instrumentation on timeline

다음은 다섯 차례 버튼을 클릭하여 방금 설명한 내용대로 테스트한 결과를 보여줍니다. 그러나 먼저 이 그래프를 읽는 방법을 이해하지 못하면 현상을 제대로 파악할 수가 없습니다. 결론부터 말하자면 이 그래프는 예제 HTML 페이지에 심각한 메모리 누수가 존재함을 확연하게 보여주고 있습니다. 그래프의 각 막대는 타임라임 상의 해당 시점에 메모리 할당이 이루어졌음을 나타내며 그 높이는 할당된 메모리의 크기를 의미합니다. 여기서 문제는 바로 그래프 막대의 색상입니다.

F12 개발자 도구 - Memory 창 - Edge - Allocation instrumentation on timeline - 결과

다음은 위 그래프의 막대를 확대한 그림입니다. 자세히 살펴보면 회색과 파란색의 두 가지 부분이 존재하고 그중 파란색 부분의 비율이 압도적으로 높은 것을 확인할 수 있습니다. 각 막대에서 회색 부분은 해당 시점에 메모리에 할당되었으나 가비지 컬렉션에 의해 정상적으로 정리된 개체를 뜻하는 반면, 파란색 부분은 타임라인의 마지막까지 남아서 존재하는 개체를 뜻합니다. 결국 이 그래프가 의미하는 바는 생성된 개체 중 태반이 정리되지 않고 남아서 메모리 누수가 발생하고 있다는 뜻입니다.

F12 개발자 도구 - Memory 창 - Edge - Allocation instrumentation on timeline - 그래프 확대

그래프의 특정 영역을 마우스로 드래그하여 선택하면 해당 시점에 생성된 개체들의 정보만 별도로 구분해서 확인할 수도 있습니다.

F12 개발자 도구 - Memory 창 - Edge - Allocation instrumentation on timeline - 타임라인 선택

이번 테스트에서는 Record allocations stacks (extra performance overhead) 옵션을 선택했었기 때문에 적절한 개체를 선택하면 다음과 같이 해당 개체가 생성되던 당시의 호출 스택 정보를 확인할 수 있습니다. 이미 언급했던 것처럼 본문의 사례는 이 옵션의 기능을 활용하기에는 다소 적합하지 않지만 기능 자체를 소개하기 위해서 간단히 살펴봤습니다. (사실 대부분의 개체에 대해 정보가 아예 제공되지 않거나 크게 도움이 되지 않는 정보만 제공됩니다.)

F12 개발자 도구 - Memory 창 - Edge - Allocation instrumentation on timeline - 호출 스택

마지막으로 Allocation 보기를 선택하여 함수/메서드별로 생성한 개체수와 남아 있는 개체수, 할당된 메모리 크기와 남아 있는 크기를 살펴볼 수도 있습니다.

F12 개발자 도구 - Memory 창 - Edge - Allocation instrumentation on timeline - Allocation 보기

메모리 누수 원인 찾기

다양한 방식으로 검토해본 지금까지의 결과를 미루어 볼 때 예제 HTML 파일에 메모리 누수가 존재한다는 사실은 명백합니다. 그렇다면 이번에는 정확한 메모리 누수의 원인을 밝혀낼 차례입니다. 이를 위해서 여러 가지 방법을 시도해볼 수 있겠지만 일단 본문에서는 시리즈의 예전 글에서 살펴봤던 메모리 힙 스냅샷끼리 서로 비교하는 방법을 사용해보도록 하겠습니다.

다시 말해서 예제 HTML 파일을 로드한 직후의 최초 상태에서 첫 번째 메모리 힙 스냅샷을 찍고, Create 500 Btn. 버튼과 Remove All 버튼을 반복해서 몇 차례 누른 다음 다시 두 번째 메모리 힙 스냅샷을 찍어서 두 개의 스냅샷을 서로 비교해볼 것입니다. 다만 그 구체적인 과정은 이미 다른 글에서 자세히 살펴봤기 때문에 이 자리에서는 다시 일일이 언급하지 않도록 하겠습니다.

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

다음은 IE11에서 방금 설명한 내용처럼 버튼의 생성과 제거를 세 차례 반복하면서 두 번의 메모리 힙 스냅샷을 찍은 요약 화면 결과를 보여줍니다. 메모리 사용량 증감분개체 수 증감분이 모두 증가한 것을 확인할 수 있습니다. 간단히 산술적으로 계산해본다면 매번 버튼을 생성했다가 제거할 때마다 5,500개씩 개체가 제거되지 않고 남겨지고 있는 셈입니다.

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

계속해서 두 번째 스냅샷의 메모리 사용량 증감분 링크나 개체 수 증감분을 클릭해서 비교 보기 모드로 전환하고 유형 보기부터 살펴봅니다. 가장 먼저 눈에 띄는 부분은 정상적인 상황에서라면 모두 제거되었어야 할 HTMLInputElement 형식의 개체들이 여전히 대량으로 남아있다는 점입니다. 동적으로 생성했던 버튼들이 화면에서는 모두 사라졌으나 메모리에는 여전히 남아있다는 뜻입니다. 결론적으로 이번 예제에서도 분리된 DOM 트리가 발생했음을 알 수 있습니다. 아래 그림에는 전체 목록이 보이지 않지만, 현재 위치에서부터 스크롤 바 끝까지 모두가 HTMLInputElement 개체들입니다.

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

이렇게 HTMLInputElement 개체가 남아 있다는 것은 무언가가 이 개체들을 여전히 참조하고 있다는 뜻입니다. 예전 글에서 설명했던 것처럼 특정 개체를 참조하고 있는 다른 개체들에 대한 정보는 하단의 개체 참조 목록에서 확인할 수 있습니다. 다음은 HTMLInputElement 개체 중 하나를 선택하여 개체 참조 목록에서 해당 개체를 참조하고 있는 개체들의 참조 계층 정보를 살펴본 모습입니다.

F12 개발자 도구 - 메모리 창 - IE11 - 스냅샷 비교 (개체 참조 목록)

이 결과를 살펴보면 HTMLInputElement 개체를 참조하고 있는 개체 참조 그래프의 끝이 엉뚱하게도 $ 개체 및 jQuery 개체까지 연결되어 있음을 확인할 수 있습니다. 그리고 아직 무언가 단언하기는 이르지만 미묘하게도 cache라는 정체 모를 개체의 유지된 크기요약 화면에서 살펴본 메모리 사용량 증감분840.61 KB와 거의 동일한 840.2 KB라는 사실도 발견할 수 있습니다. 다시 말해서 cache 개체의 참조만 제거되면 누수가 발생한 거의 모든 메모리가 다시 반환되는 셈입니다.

이와 비슷한 정보를 루트 보기에서도 확인할 수 있습니다. 현재 비교 모드 상태의 루트 보기에서 (Global) 개체 하위에 존재하는 개체는 $ 개체와 jQuery 개체뿐입니다. 두 개체는 사실상 같은 개체이므로 $ 개체를 선택해서 하위 계층 구조를 확장해보면, 이번에도 다음과 같이 cache 개체가 하위의 개체들을 대량으로 참조하고 있음을 보여줍니다.

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

마지막으로 다음은 도미네이터 보기의 결과입니다. 크게 새로운 부분은 없지만 문서로만 본문을 읽어보시는 분들이 참고하실 수 있도록 도미네이터 보기의 관련 캡처까지 함께 추가했습니다.

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

지금까지 살펴본 내용을 모두 종합해보면 HTMLInputElement 개체들에 대한 분리된 DOM 트리가 대량으로 발생한 것이 메모리 누수의 주원인으로 판단됩니다. 그리고 대부분 예상하지 못하셨겠지만, 뜻밖에도 그 기저에는 jQuery 라이브러리가 관련되어 있을 확률이 매우 높아 보입니다. 특히 그중에서도 cache라는 많이 알려지지 않은 개체가 매우 의심스러운 상태입니다.

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

다음은 Chromium Edge에서 역시 동일한 방식으로 버튼의 생성과 제거를 세 차례 반복하면서 메모리 힙 스냅샷을 두 번 찍은 결과를 보여줍니다. 두 번째 스냅샷을 선택하고 Perspective 드롭다운에서 Comparison 항목을 선택해서 보기를 전환한 다음, 검토하기 편하도록 Class filter 텍스트 상자에 'Detached'라는 키워드를 입력하여 분리된 DOM 트리만 구분해서 살펴보겠습니다.

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

여전히 다양한 형식의 개체들이 보여지고 있지만, 증감분에 변화가 없는 CSSStyleDeclaration 형식 및 DocumentFragment 형식을 제외한 나머지 개체들은 다음 그림에서 확인할 수 있는 것처럼 거의 대부분이 개체 참조 그래프 상에서 특정 HTMLInputElement 개체의 하위 경로에 위치합니다. 더군다나 HTMLInputElement 이외의 개체들은 모두 증가한 메모리 크기가 0 Byte 입니다.

F12 개발자 도구 - 메모리 창 - Edge - 스냅샷 비교 (기타 형식)

따라서 이번에도 집중적으로 살펴봐야 할 항목은 HTMLInputElement 개체입니다. 다시 말해서 특정 상위 개체에서 HTMLInputElement 개체들에 대한 참조를 제거하면 분리된 DOM 트리가 해소되어 메모리 누수가 사라질 것이라는 의미입니다. 그리고 다음 그림에서 다시 한 번 확인할 수 있는 것처럼 해당 상위 개체로 가장 유력한 후보는 jQuery 라이브러리가 내부적으로 관리하는 cache 개체입니다.

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

그렇다면 이번에는 문제의 cache 개체가 과연 무엇인지, 그리고 jQuery 라이브러리에서 어떤 역할을 담당하고 있는지 알아봐야 할 차례입니다. 그러나 의외로 인터넷 상에서 이 개체에 대한 쓸모있는 정보를 찾기가 생각보다 쉽지 않습니다. 가령 구글링으로 'jQuery''cache'라는 키워드를 조합해서 검색해보면 대부분 Ajax 통신과 관련된 cache 옵션에 대한 결과만 반환됩니다. 다행히 본문의 주제에 부합하는 내용을 일목요연하게 담고 있는 문서를 발견하여 소개해 드립니다. 이 문서를 먼저 읽어봐도 좋고, 또는 다음 섹션을 먼저 읽어본 다음, 다시 이 문서를 읽어보셔도 좋습니다.

잘못된 jQuery 사용 방식으로 인한 메모리 누수

지금까지도 jQuery는 가장 널리 사용되는 웹 브라우저 기반의 JavaScript 라이브러리들 중 하나입니다. 상대적으로 러닝 커브가 낮은 편이어서 초보자도 큰 어려움 없이 간단하게 활용할 수 있는 반면, 다양한 기능들이 제공되기 때문에 아직도 많은 프로젝트에서 사용되고 있습니다.

비록 강제되는 사항은 아니지만, jQuery 같은 유형의 라이브러리를 사용할 때 개발자가 가급적 준수해야만 하는 원칙이 한 가지 있습니다. 바로 특정 기능을 구현할 때 라이브러리를 혼용해서 사용하지 말아야 한다는 방어적인 규칙이 그것으로, Vanilla JS 역시 하나의 라이브러리로 간주되어 그 대상에 포함됩니다.

결론부터 말하자면 본문의 예제 HTML 파일에서 발생하는 메모리 누수도 바로 이 규칙을 준수하지 않았기 때문에 나타나는 현상입니다. 가령 HTMLInputElement 개체를 생성하고 이벤트 핸들러를 설정할 때는 다음과 같이 jQuery를 사용하고 있습니다.

...
    
      function fnCreate() {
        var $btnBox = $("#btnBox");
      
        for (var index = 1; index <= 500; index++) {
          var $btn = $('<input type="button" id="btn_' + index + '" value="Button ' + index + '" style="display:inline-block;" />');
          $btn.click(function () {
            alert(this.value);
          });
          $btnBox.append($btn);
        }
      }
        
...

반면 개체를 제거할 때는 다음처럼 Vanilla JS를 이용해서 작업을 수행합니다.

...
    
      function fnRemove() {
        document.getElementById("btnBox").innerHTML = "";
      }
    
...

여기서 문제의 핵심은 1.x 버전 대의 jQuery 라이브러리가 이벤트 핸들러에 대한 정보를 대상 HTML 요소 그 자체가 아닌, 지금까지 반복적으로 살펴본 cache 개체에 저장한다는 점입니다. 그리고 어떤 면에서는 당연한 얘기지만 저장되는 정보 중에는 해당 이벤트 핸들러가 어떤 HTML 요소에 대한 것인지 식별하기 위한 HTML 요소에 대한 참조가 포함됩니다. 그러나 Vanilla JS의 API들은 cache 개체의 존재 자체를 알지 못합니다. 따라서 HTML 요소를 제거할 수는 있지만 cache 개체를 정리하지는 못하므로 이 개체에 저장된 이벤트 핸들러 관련 정보를 그대로 남겨두기 때문에 해당 정보의 일부인 HTML 요소에 대한 참조가 그대로 남아있게 되어 분리된 DOM 트리 형태의 메모리 누수가 발생하는 것입니다.

이벤트 핸들러 외에도 이런 비숫한 방식으로 cache 개체에 관련 정보가 저장되는 사례로는 data() 메서드가 있습니다. 가령 다음과 같은 코드를 설명할 때 일반적으로는 'BODY 요소에 각각 지정한 키로 문자열 및 개체 형식의 데이터를 저장합니다'라고 소개되지만, 사실 내부적으로는 이 데이터도 cache 개체에 저장됩니다.

$("body").data("name", "John Doe");
$("body").data("person", { firstName: "John", lastName: "Doe" });

본문의 예제 HTML 파일을 새 Chromium Edge 창에서 실행한 다음, 아무런 작업도 수행하지 않은 상태로 Console 창에서 cache 개체를 확인해 보면 당연히 다음과 같이 텅 비어 있습니다.

F12 개발자 도구 - Console 창 - Edge - cache 개체

반면 위의 코드를 실행한 다음 다시 확인해보면 실제로 데이터가 HTML 요소가 아닌 cache 개체에 저장되는 것을 확인할 수 있습니다. jQuery 라이브러리의 코드를 분석해보면 데이터를 저장할 개체가 DOM 요소인 경우에만 이런 방식으로 동작하며, 일반적인 JavaScript 개체의 경우에는 해당 개체에 직접 데이터가 추가됩니다.

F12 개발자 도구 - Console 창 - Edge - cache 개체

정리해보면 이 결과가 말해주는 바는 data() 메서드 역시 본문의 예제와 비슷한 패턴으로 사용할 경우 메모리 누수가 발생하게 될 것이라는 사실입니다.

의외로 이런 코드는 실제 프로젝트 환경에서 매우 높은 빈도로 발견되는 편이며, 많은 분이 메모리 누수에 대한 별다른 감안 없이 사용하곤 하는 코드 패턴입니다. 또한 대부분의 경우 크게 문제가 불거지지 않는 코드이기도 합니다. 그러나 몇 가지 조건이 맞아떨어지면 심각한 문제점을 만들어내는 원인이 되는데, 본문의 서두에서 언급했던 실제 사례가 바로 그런 경우였습니다.

가령 언급했던 프로젝트의 경우 내부적으로 메모리 누수를 만들어내는 이런 코드가 적용된 부분이 일반적으로 상단 메뉴라고 부르는 GNB(Global Navigation Bar) 영역이었습니다. 따라서 iframe 태그를 사용하는 UI 구성이었음에도 불구하고 브라우저를 재시작하거나 창 전체를 새로 고치지 않는 이상 누수된 메모리가 계속 누적되는 구조였습니다. 더군다나 해당 시스템의 사용자는 상대적으로 전산 지식이 부족한 콜 센터 상담사분들이었는데 어지간해서는 며칠 동안 컴퓨터 자체를 재시작하지 않고 계속 사용하는 분들이 많았습니다. 이런 상황에서 기타 다른 원인으로 발생한 메모리 누수와 더불어 주제의 원인으로 발생한 메모리 누수까지 더해져서 브라우저가 전혀 반응하지 않는 상황이 빈번하게 발생했던 것입니다.

특히 업무적인 특성도 큰 문제였는데, 일반적인 사무 환경과는 달리 고객과 통화를 하면서 동시에 녹취가 진행되는 형태의 업무를 진행하던 중 브라우저를 강제로 재시작해야만 문제가 해결됐기 때문에 담당자가 불편한 것은 물론이려니와 고객분에게도 양해를 구하고 모든 절차를 처음부터 다시 반복해야 하는 상황이었습니다. 이는 기업의 입장에서는 절대로 용납할 수 없는 문제입니다.

전역 jQuery 캐시

지금까지 본문에서 cache 개체라고 불렀던 개체의 정식 명칭은 전역 jQuery 캐시(Global jQuery Cache)입니다. 이 이름을 정의하는 공식 문서가 따로 존재하는 것은 아니지만 축소되지 않은 jQuery 라이브러리의 소스 코드를 살펴보면 주석 내용 중 이 명칭을 사용하고 있는 것을 발견할 수 있습니다(바로 직후에 해당 주석을 살펴봅니다). 따라서 지금부터는 본문에서도 이 명칭으로 부르도록 하겠습니다.

그런데 jQuery 라이브러리에서 본문의 예제와 같은 메모리 누수의 위험성을 내포하고 있는 전역 jQuery 캐시를 사용하고 있는 이유는 무엇일까요? 정말 아이러니하게도 그 이유는 구버전의 IE에서 발생할 수 있는 다양한 메모리 누수 상황에 적극적으로 대응하기 위한 것으로 추측됩니다. 라이브러리 내부에서 전역 jQuery 캐시에 데이터를 설정하는 작업을 처리하는 internalData() 내부 함수의 소스 코드 주석을 살펴보면 다음과 같은 문구가 존재합니다.

function internalData( elem, name, data, pvt /* Internal Use Only */ ) {
    if ( !acceptData( elem ) ) {
        return;
    }

    var ret, thisCache,
        internalKey = jQuery.expando,

        // We have to handle DOM nodes and JS objects differently because IE6-7
        // can't GC object references properly across the DOM-JS boundary
        isNode = elem.nodeType,

        // Only DOM nodes need the global jQuery cache; JS object data is
        // attached directly to the object so GC can occur automatically
        cache = isNode ? jQuery.cache : elem,

        // Only defining an ID for JS objects if its cache already exists allows
        // the code to shortcut on the same path as a DOM node with no cache
        id = isNode ? elem[ internalKey ] : elem[ internalKey ] && internalKey;
    
    ...

이 주석의 내용을 검토해보면 그 이하의 코드가 구버전의 IE가 사용하는 참조 카운팅(Reference Counting) 방식의 가비지 컬렉션이 가진 단점을 해소하기 위한 것임을 밝히고 있습니다. (사실 이 설명과 관련하여 내부적으로는 조금 더 복잡한 내용이 존재하지만 본문의 주제를 크게 벗어나는 범위이기 때문에 아래 '노트' 섹션에서만 잠시 언급하도록 하겠습니다.) IUnknown 인터페이스로 대표되는 COM 개발 경험을 갖고 있는 분들에게는 상당히 친숙한 용어인 참조 카운팅 방식에서는 개체에 대한 참조가 늘어날 때마다 참조 수가 증가하고 반대로 참조하는 개체가 줄어들 때마다 참조 수가 감소되다가, 결국 참조 수가 0이 되고 영역에서 벗어나면 가비지 컬렉션이 메모리에서 개체를 해제하는 형태입니다.

문제는 순환 참조(Circular Reference) 상황에서 발생하는데, 순환 참조란 두 개체가 서로 상대방을 참조하는 상황을 말합니다. 가령 다음 예제 코드에서 두 개체, onetwo는 서로를 참조하므로 상대 개체의 참조 수를 각각 1씩 증가시킵니다. 그리고 참조 카운팅 방식의 가비지 컬렉션에서 이 두 개체가 사용하는 메모리는 영원히 반환되지 않습니다. 왜냐하면 이 코드로는 두 개체의 참조 수가 결코 0으로 줄어들지 않기 때문입니다.

function fnCircularRef() {
  var one = {};
  var two = {};
  one.ref = two;    // one은 two를 참조합니다.
  two.ref = one;    // two는 one을 참조합니다.
}

fnCircularRef();

그러면 이번에는 실제 업무에서 흔하게 접할 수 있는 조금 더 현실적인 예제 코드를 살펴보도록 하겠습니다. 다음 예제 코드는 구버전의 IE에서 순환 참조 문제가 발생합니다. DIV 요소와 이벤트 핸들러가 서로를 참조하고 있기 때문입니다. (이 설명 역시 아래 '노트' 섹션에서 보완하여 설명합니다.)

function fnCircularRefClosure() {
  var div = document.createElement("DIV");
  div.onload = function() {
    var innerDiv = div;
  }
}

fnCircularRefClosure();

다행하게도 IE11을 비롯한 거의 모든 최신 브라우저들은 마크 앤 스윕(Mark-and-Sweep) 방식의 가비지 컬렉션을 사용하기 때문에 더 이상 이런 문제는 발생하지 않습니다. 마크 앤 스윕 방식에서는 참조 수와 무관하게 GC 루트(사실상 전역 Window 개체)로부터 도달할 수 없는 모든 개체를 가비지 컬렉션이 제거하기 때문에 순환 참조도 정상적으로 처리됩니다. 참조 카운팅 알고리즘과 마크 앤 스윕 알고리즘에 대한 보다 자세한 정보는 다음 링크의 문서들을 참고하시기 바랍니다.

노트

이번 섹션의 설명 중 짚고 넘어가야 할 부분이 몇 가지 있습니다. 일반적으로 알려진 것과는 달리 제가 알기로는 구버전의 IE 역시 특정 시점 이후로는 JavaScript 엔진에서 마크 앤 스윕 방식의 가비지 컬렉션을 사용합니다. 문제는 C++를 이용해서 COM으로 구현한 브라우저 개체 모델(BOM, Browser Object Model)문서 개체 모델(DOM, Document Object Model)참조 카운팅 방식의 가비지 컬렉션을 사용한다는 사실입니다. 결론적으로 하나의 브라우저 내에서 두 가지 방식을 모두 사용하고 있는 셈입니다. 따라서 엄밀하게 말한다면 구버전의 IE참조 카운팅 방식의 가비지 컬렉션(만)을 사용한다는 설명은 오해를 불러일으킬 수 있는 올바르지 않은 설명입니다.

구버전의 IE에서 순환 참조 문제가 발생하는 실질적인 경우는 JavaScript 개체와 COM 개체(대표적으로 DOM 요소)가 서로를 참조할 때입니다. 이번 섹션에서 살펴봤던 jQuery 라이브러리의 주석에서 '... IE6-7 can't GC object references properly across the DOM-JS boundary'라고 표현하고 있는 이유가 바로 이 때문입니다.

따라서 이번 섹션의 두 가지 예제 중, 개체 onetwo에 대한 코드는 참조 카운팅 방식의 가비지 컬렉션을 사용하는 일반적인 구형 브라우저를(IE 외에도 다양한 구형 브라우저가 존재하므로) 대상으로 논할 때는 적절한 예제이지만 구버전의 IE만 놓고 논한다면 적절한 예제가 아닙니다. 이 부분을 실제로 테스트해서 그 결과를 보여드릴지 여부도 고민해봤으나 주변에 구버전의 IE를 테스트 할 수 있는 환경이 없고, 설령 있다 하더라도 구버전의 IE에서는 F12 개발자 도구가 지원되지 않기 때문에 테스트 절차 자체가 문맥의 흐름에 지장을 줄 것으로 판단되어 제외했습니다.

대신 이 주제에 대한 보다 자세한 정보는 상당히 오래된 문서이기는 하지만 Memory Leakage in Internet Explorer 기사를 참고하시기 바랍니다.

메모리 누수 해결하기

메모리 누수의 원인을 알았으므로 이제 문제점을 해결할 차례입니다. 본문의 예제와 동일한 이유로 메모리 누수가 발생한다면, 다음과 같은 다양한 방법으로 문제점을 제거할 수 있습니다.

  • 메모리 누수가 발생하지 않는 jQuery 라이브러리 버전이 존재하는지 확인해보고, 만약 있다면 라이브러리를 마이그레이션 합니다.
  • 라이브러리를 혼용하는 형태로 구현된 부적절한 코드를 권장되는 방식으로 수정합니다.
  • 만약 가능하다면, 더욱 개선된 패턴으로 코드를 다시 작성합니다.

방법 1: jQuery 라이브러리 마이그레이션

먼저 메모리 누수가 발생하지 않는 jQuery 라이브러리 버전이 존재하는지 확인해보고, 만약 있다면 라이브러리 자체를 마이그레이션 하는 것도 고려해볼 만한 방법입니다. 다만 이 방법은 프로젝트의 상황을 고려하여 적용 여부를 신중하게 결정해야 합니다. 개발을 시작한 지 얼마 되지 않는 비교적 초기의 프로젝트라면 대부분 큰 걱정 없이도 다른 버전으로 마이그레이션 할 수 있겠지만, 이미 수년간 운영해오던 시스템에서는 예상했던 것보다 더 큰 부담이 될 수도 있습니다. 또한 구버전의 브라우저들을 지원할 것인지 여부 등에 따라서 결정에 영향을 받을 수 있습니다.

기본 예제 HTML 파일에서 코드 자체는 변경하지 않고 jQuery 라이브러리의 CDN 주소만 변경하여 버전을 바꿔가면서 간단히 테스트해보면 2.2.0 버전 이후부터는 메모리 누수 현상이 사라지는 것을 확인할 수 있습니다. 참고로 2.x 버전대부터는 내부의 구현 방식이 변경되어 $.cache 같은 코드를 사용해서 전역 jQuery 캐시에 직접 접근할 수도 없습니다. (새 창에서 보기)

방법 2: 부적절한 코드 수정

메모리 누수를 유발하는 부적절한 코드를 적절한 방식으로 수정하여 jQuery 라이브러리의 변경 없이도 문제를 해결할 수 있습니다. 아마도 이 방법이 가장 일반적이고 무난한 접근 방식일 것입니다. 본문의 예제와 같은 경우, 다음과 같이 innerHTML 속성을 사용하는 기존 Vanilla JS 코드를 jQuery 라이브러리의 empty() 메서드로 변경하기만 해도 됩니다. (새 창에서 보기)

...
    
      function fnRemove() {
        // document.getElementById("btnBox").innerHTML = "";
        $("#btnBox").empty();
      }
    
...

또는 다음과 같이 html() 메서드로 변경해도 됩니다.

...
    
      function fnRemove() {
        // document.getElementById("btnBox").innerHTML = "";
        $("#btnBox").html("");
      }
    
...

방법 3: 코드 구현 패턴 변경

마지막으로 살펴볼 방법은 개선된 코드 구현 패턴을 적용하여 코드 자체를 다시 작성하는 방법으로, 다음은 그중 한 가지 사례입니다. jQuery 라이브러리의 버전도 기본 예제 HTML 파일 그대로 1.x 대이고 innerHTML 속성을 사용하는 Vanilla JS 코드도 그대로이지만 더 이상 메모리 누수가 발생하지 않습니다. (새 창에서 보기)

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>메모리 누수 - 새로운 HTML 요소 생성 및 제거 (jQuery.on() Pattern)</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 500 Btn." onclick="fnCreate();" style="float:left;" />
    <input type="button" value="Remove All" onclick="fnRemove();" style="float:left;" />
    <div id="btnBox" style="clear:both;">
      <!-- 여기에 버튼들이 생성됩니다. -->
    </div>

    <script src="https://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.12.4.js"></script>
    <script>
      function fnCreate() {
        var $btnBox = $("#btnBox");
      
        for (var index = 1; index <= 500; index++) {
          $btnBox.append('<input type="button" id="btn_' + index + '" value="Button ' + index + '" style="display:inline-block;" />');
        }
      }
      
      function fnRemove() {
        document.getElementById("btnBox").innerHTML = "";
      }

      $(document).ready(function() {
        $("#btnBox").on("click", ":button", function(event) {
          alert(event.target.value);
          console.log(this == event.target);
        });
      });
    </script>
  </body>
</html>

이 코드 변경의 핵심은 버튼이 동적으로 생성될 때마다 매번 일일이 이벤트 핸들러를 설정하는 대신, 버튼들의 부모 요소인 DIV 요소에 이벤트 핸들러를 단 한번만 설정하고 실제로 이벤트가 발생해서 버블링에 의해 전달되면 해당 이벤트 핸들러에서 일괄적으로 처리한다는 점에 있습니다. 즉 이벤트 핸들러 자체는 단 한번만 설정되며 동적으로 생성되거나 제거되는 버튼에 의해 영향을 받지 않습니다.

변경된 결과 확인하기

마지막으로 변경된 결과를 확인해보고 글을 정리하도록 하겠습니다. 다만 지면 관계상 본문에서는 부적절한 코드를 적절한 방식으로 수정한 두 번째 방법의 결과만 살펴봅니다. 다른 두 가지 방법의 결과는 직접 확인해보시기 바랍니다.

다음은 IE11에서 기존과 같이 버튼을 20여 차례 클릭하여 테스트한 결과를 보여줍니다. 총 메모리 사용량 그래프도 안정적이고 개체 수 증감분 링크 역시 '증가 안 함'으로 메모리 누수가 사라진 것을 확인할 수 있습니다. 메모리 사용량 증감분은 미세하게 늘어났으나 이 정도는 코드 실행 중 자연스럽게 발생할 수 있는 수준입니다.

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

다음은 방금 전과 동일한 방식으로 Chromium EdgePerformance 창에서 메모리 사용량 증감 추이를 살펴본 모습입니다. 역시 그래프가 안정적인 결과를 보여주는 것을 확인할 수 있습니다.

F12 개발자 도구 - Performance 창 - Edge - 메모리 누수 해결 확인

그리고 다음은 Chromium EdgeMemory 창에서 Allocation instrumentation on timeline 프로파일링으로 메모리 누수 여부를 확인해본 결과입니다. 모두 다섯 차례 버튼을 클릭하여 테스트를 수행했으며 아직 파란색 부분이 남아 있는 첫 번째 테스트의 개체들은 실제로 세부 내용을 확인해보면 동적으로 생성되는 버튼들과는 아무 상관 없는 페이지 자체를 실행하기 위해서 필요한 개체들입니다.

F12 개발자 도구 - Memory 창 - Edge - Allocation instrumentation on timeline 프로파일링 확인

마지막으로 다음은 Chromium Edge에서 역시 동일한 방식으로 버튼의 생성과 제거를 세 차례 반복하면서 메모리 힙 스냅샷을 두 번 찍어서 분리된 DOM 트리만 출력한 Comparison 보기의 결과를 보여줍니다. 분리된 DOM 트리가 모두 사라진 것을 확인할 수 있습니다.

F12 개발자 도구 - Memory 창 - Edge - Comparison 보기 확인

정리

본문에서는 제가 개인적으로 경험한 실제 프로젝트의 사례를 바탕으로 흔하게 발생할 수 있는 가상의 메모리 누수 상황을 재현하여 지금까지 본 시리즈를 통해서 살펴본 모든 내용을 바탕으로 문제의 원인을 파악하고 그에 대한 해결 방안을 검토해봤습니다.

비록 본문의 예제 코드는 구버전의 jQuery 라이브러리를 부주의하게 사용하여 코드를 작성하는 제한적인 상황만을 가정하고 있지만, 비슷한 유형의 문제점은 사용하는 라이브러리 또는 프레임워크를 막론하고 언제 어디에서든지 발생할 수 있습니다. 중요한 것은 각자 개발 중인 환경에서 메모리 누수의 발생을 빠르게 감지하고 그 원인을 파악하여 적절한 대응을 수행할 수 있는 능력을 갖추는 것입니다.

지금까지 IE11Chromium Edge에서 메모리 누수를 감지하고 그 내용을 검토하는 방법에 관해서 살펴본 본 시리즈를 이것으로 마치겠습니다. 감사합니다.