ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 자바스크립트 메모리 관리(Garbage Collection)에 대해 알아보자
    개발 블로깅/Javascript 개념 2020. 9. 4. 17:06

     

    자바스크립트는 언어 특성상 메모리를 자동으로 관리하도록 동작하기 때문에 개발자가 '메모리 관리'에 대해 많이 소홀해질 수 있다는 특징을 가지고 있다. 서비스의 성능 관리를 신경 쓰려면 자바스크립트 내에서 메모리 관리가 어떻게 이루어지고 어떻게 메모리에 신경을 써야 하는지 파악할 필요가 있다. 

    그래서 이번 블로그에서 자바스크립트 내에서 메모리 관리 핵심 요소인 가비지 컬렉션(Garbage Collection)과 개발자가 메모리 효율성을 높이기 위해서 주의해야 할 점들에 대해 한번 정리해 보려고 한다.

     

    기본적인 메모리 사용

    우선 C언어와 같은 로우(Low) 레벨 언어에서는 메모리 관리를 위해 malloc()free()를 사용한다.

    #C언어의 메모리 할당

    #include <stdio.h>
    #include <stdlib.h>    // malloc, free 함수가 선언된 헤더 파일
    
    int main()
    {
        int num1 = 20;    // int형 변수 선언
        int *numPtr1;     // int형 포인터 선언
    
        numPtr1 = &num1;  // num1의 메모리 주소를 구하여 numPtr에 할당
    
        int *numPtr2;     // int형 포인터 선언
    
        numPtr2 = malloc(sizeof(int));    // int의 크기 4바이트만큼 동적 메모리 할당
    
        printf("%p\n", numPtr1);    // 006BFA60: 변수 num1의 메모리 주소 출력
                                    // 컴퓨터마다, 실행할 때마다 달라짐
    
        printf("%p\n", numPtr2);     // 009659F0: 새로 할당된 메모리의 주소 출력
                                    // 컴퓨터마다, 실행할 때마다 달라짐
    
        free(numPtr2);    // 동적으로 할당한 메모리 해제
    
        return 0;
    }
     

    반면, 자바스크립트는 객체가 생성되었을 때 자동으로 메모리를 할당하고 쓸모 없어졌을 때 자동으로 해제한다. 이러한 기능이 가비지 컬렉션(Garbage Collection)이라고 한다.

    그러나 메모리를 자동으로 할당해주면 개발자가 입장에서는 메모리를 신경 쓰지 않아도 되기 때문에 편할 수도 있지만, 이러한 점은 기능에 있어 잠재적 혼란의 원인을 발생시킬 수도 있다. 그리고 무엇보다 메모리로 인해 발생한 에러 원인을 찾기가 어렵게 될 수도 있다...
    그래서 아무리 자동으로 메모리를 관리해준다 하더라도 메모리 관리를 신경 쓰는 것은 엄청 중요하다!

    그러면 메모리에 대해서 조금 더 자세히 알아보자.


    메모리 생존 주기

    우선 메모리 생존 주기는 어떤 프로그래밍 언어이든 관계없이 아래와 같이 이루어진다.

    1. 필요할 때 메모리를 할당한다.
    2. 할당된 메모리를 사용한다. (읽기, 쓰기)
    3. 더 이상 필요하지 않으면 메모리를 해제한다.

    여기서 두 번째(메모리를 사용한다)는 개발자가 직접 코드를 짜면서 건드리기 때문에 명시적으로 사용된다. 그러나 첫 번째(메모리 할당)마지막(메모리 해제)은 위에 설명한 것처럼 로우(Low) 레벨의 언어에서는 사용자가 직접 메모리를 할당하고 해제하지만, 자바스크립트와 같은 하이(High) 레벨의 언어에서는 암묵적으로 메모리를 할당하고 해제한다.

    💡개발자가 직접 변수를 선언해서 쓰니까, 메모리 할당도 명시적으로 사용하는 것 아닌가요?
    const name = "inyong"와 같은 코드를 보고 '개발자가 직접 메모리 할당해주는 것 아니냐'라고 생각하면 안 된다. 저것 또한 변수를 선언함으로써 메모리를 자동으로 할당해준 것이기 때문이다. (위에 살짝 설명한 C언어 코드 참조.)

     

    그럼 지금부터 자바스크립트에서는 이러한 메모리 할당메모리 해제가 어떻게 일어나는지 알아보자.

     

    자바스크립트에서 메모리 할당

    자바스크립트에서 메모리가 할당되는 방식은 여러 가지가 있다.
    우선 첫 번째는 '선언'과 '값 초기화'이다.

    let n = 123; // 정수 타입에 맞게 메모리 자동 할당
    let s = 'Inyong' // 문자열 타입에 맞게 메모리 자동 할당
    
    let o = { a:1, b:null }; // 오브젝트와 해당 오브젝트 내에 포함된 값들을 담기 위한 메모리 할당
    
    let a = [1, null, 'abcd'] // 배열과 배열에 담긴 값들을 위한 메모리 할당
    
    function f(a){  // 함수를 위한 할당 (함수는 호출 가능한 오브젝트이다)
      return a + 2;
    }
    
    // 함수식 또한 오브젝트를 담기위한 메모리를 할당
    someElement.addEventListener('click', function(){
      someElement.style.backgroundColor = 'blue';
    }, false);
    

     

    변수를 선언할 때 특정 값을 미리 입력한 상태로 선언할 때 자동으로 메모리를 할당해주는데...
    그러면 과연 각 타입마다 어느 정도 크기의 메모리를 할당해주느냐.

     

    자바스크립트 타입 별 메모리 할당

    #Boolean 타입

    Boolean 타입은 True 혹은 False 중 하나의 값만 가지게 되므로 단 1Bit만을 차지한다.

     

    #Number 타입

    ECMAScript 표준에 따르면, 숫자의 자료형은 정수, 소수 상관없이 무조건 64Bit(8byte)을 할당받게 된다. 자바스크립트에서는 정수, 소수 등에 대한 특별한 자료형이 없기 때문이다. 

     

    # String 타입

    자바스크립트에서는 String 타입은 한 문자 당 16Bit를 할당받는다. 그러나 문자열은 한번 메모리가 할당되면 해제되기 전까지 변경이 불가능하다. 예를 들면 아래와 같다.

    var foo = 'inyong';
    var bar = s.substr(0, 3); // s2는 새로운 문자열
    // 자바스크립트에서 문자열은 immutable 값이기 때문에,
    // 메모리를 새로 할당하지 않고 단순히 [0, 3] 이라는 범위만 저장한다. 

     

    위 문자열에서 특정 구간을 자르는 substr() 함수를 이용하여 bar 변수에 문자열의 잘린 일부를 반환해도, 실제로는 잘려서 새롭게 데이터를 생성한 것이 아니라 해당되는 특정 구간의 문자열에 대한 주소 값만을 참조하도록 한 것이다.

     

    var a = ['ouais ouais', 'nan nan'];
    var a2 = ['generation', 'nan nan'];
    var a3 = a.concat(a2);
    // a 와 a2 를 이어붙여, 4개의 원소를 가진 새로운 배열

     

    이러한 immutable 한 특징은 문자열뿐 아니라 배열에서도 볼 수 있다.
    a3의 변수처럼 두 배열의 합치는 concat() 함수를 이용하면, 실제로 메모리 상에서는 합친 두 배열의 새로운 배열이 생성되는 것이 아니라, 두 배열의 데이터 주소만 합쳐지는 것이다.

     

    그럼 다시 돌아와서, 메모리가 할당되는 방식에 대해 계속 알아보자.
    두 번째로는 '함수 호출을 통한 할당'이 있다.

    var d = new Date(); // Date 개체를 위해 메모리를 할당
    
    var e = document.createElement('div'); // DOM 엘리먼트를 위해 메모리를 할당한다.

     

    메서드의 반환 값에 의해 새로운 변수에 할당하면 메모리 할당이 일어난다. 뿐만 아니라 DOM Element를 동적으로 생성하기 위해 'document.createElement' 함수를 이용할 때도 새롭게 메모리를 할당하게 된다.

     

    자바스크립트에서 더 이상 필요 없는 메모리를 해제

    할당된 메모리 중 쓰지 않는 메모리를 계속해서 남겨두게 되면 메모리 낭비로 인해 Memory Overflow 에러가 발생할 수 있다. 그래서 더 이상 쓰지 않는 메모리는 해제를 해줌으로써 메모리 공간을 확보해야 할 필요가 있다. 

    그러나 여기서 문제인 것은 "할당된 메모리가 더 이상 필요 없을 때"를 알아내기가 어렵다는 것이다.
    C언어와 같이 로우(Low) 레벨의 언어에서는 명시적으로 메모리를 할당하고 해제하기 때문에 개발자가 직접 결정하고 해제하는 방식이지만, 하이(High) 레벨의 언어는 그렇지 않기 때문이다. 

    그래서 이러한 언어들은 가비지 컬렉션(Garbage Collection)을 이용하여, 자동 메모리 관리 기법을 사용한다. 가비지 컬렉션의 목적은 '메모리 할당을 추적하고 할당된 메모리 블록이 더 이상 필요하지 않은지를 판단하여 회수하는 것'이다.

    💡그러면 가비지 컬렉션만 있으면 개발자는 메모리 관리에 신경 쓰지 않아도 되는 건가요?
    가비지 컬렉션으로 인해 개발자가 메모리 관리 부분에 있어 많은 편리성을 받을 수는 있다. 하지만 가비지 컬렉션이 메모리 관리 기법에 있어서 완벽한 방식은 아니란 점을 명시해야 한다.
    왜냐하면 어떤 메모리가 여전히 필요한지 아닌지를 판단하는 것은 '실제'가 아닌 특정 조건들로 인한 '예측'으로 판단된 비결정적 문제이기 때문이다.

     

    그러면 이제 자바스크립트에서 가비지 컬렉션이 어떤 방식으로 동작하는지 알아보도록 하자.

     

    가비지 컬렉션(Garbage Collection)

    가비지 컬렉션은 위에서 말한 것처럼, 할당된 메모리 블록이 더 이상 필요하지 않은지 판단해야 된다고 했었는데 이러한 판단하는 핵심 개념은 '참조'이다.
    A라는 메모리를 통해 B라는 메모리에 접근할 수 있다면 "B는 A에 참조된다"라고 한다. 예를 들어 모든 자바스크립트 오브젝트는 "prototype"을 암시적으로 참조하고 그 오브젝트의 속성을 명시적으로 참조한다.

    이러한 자바스크립트 내 '참조'의 개념에서도, '참조를 하지 않는다'라고 판단하는 조건이 여러 가지가 있는데, 아래에서 하나씩 알아보자.

     

    참조-세기(Reference-counting) 가비지 컬렉션

    참조-세기 알고리즘은 가장 소박한 알고리즘이다. 여기서는 '어떤 다른 오브젝트에서도 참조하지 않는 오브젝트'를 더 이상 필요하지 않은 메모리로 판단하여 메모리를 해제한다.

    var x = { 
      a: {
        b:2
      }
    }; 
    // 2개의 오브젝트가 생성되었다. 하나의 오브젝트는 다른 오브젝트의 속성으로 참조된다.
    // 나머지 하나는 'x' 변수에 할당되었다.
    // 명백하게 가비지 컬렉션이 수행될 메모리는 하나도 없다.
    
    
    var y = x;    // 'y' 변수는 위의 오브젝트를 참조하는 두 번째 변수이다.
    x = 1;        // 이제 'y' 변수가 위의 오브젝트를 참조하는 유일한 변수가 되었다.
    
    var z = y.a;  // 위의 오브젝트의 'a' 속성을 참조했다.
                  // 이제 'y.a'는 두 개의 참조를 가진다. 
                  // 'y'가 속성으로 참조하고 'z'라는 변수가 참조한다.
    
    y = "yo";     // 참조하던 유일한 변수였던 y에 다른 값을 대입했다.이제 맨 처음의 오브젝트를 참조하는 변수는 없다.
                  // 이제 오브젝트에 가비지 콜렉션이 수행될 수 있을까?
                  // 아니다. 오브젝트의 'a' 속성이 여전히 'z' 변수에 의해 참조되므로 
                  // 메모리를 해제할 수 없다.
    
    z = null;     // 'z' 변수에 다른 값을 할당했다. 
                  // 이제 맨 처음 'x' 변수가 참조했던 오브젝트를 참조하는 
                  // 다른 변수는 없으므로 가비지 콜렉션이 수행된다.

     

    위 예시 코드에서 보면, 맨 처음 x의 변수에 있는 오브젝트( '{ a: { b: 2 }}'의 데이터 )가 가장 아래에서 두 번째의 줄까지는 어떻게든 참조하고 있는 연결고리가 있지만, 가장 마지막으로 참조하던 z에서 null을 넣고 더 이상 참조하던 연결고리가 하나도 존재하지 않음으로써 메모리가 할당되었던 오브젝트 '{ a: { b: 2 }}'는 가비지 컬렉션이 의해 해제된다.

    그러나 이러한 참조-세기 알고리즘에는 '순환 참조'(서로 다른 오브젝트가 서로를 순환하게 참조) 문제가 발생하면 메모리 해제가 발생하지 않아 메모리 누수가 발생한다.

    function f(){
      var o = {};
      var o2 = {};
      o.a = o2; // o는 o2를 참조한다.
      o2.a = o; // o2는 o를 참조한다.
    
      return "azerty";
    }
    
    f();

     

    위 코드를 보면, o변수와 o2 변수가 메모리 할당된 뒤 f() 함수가 끝난 후에 더 이상 사용되지 않지만, 아직 서로를 참조하고 있으므로 참조-세기 알고리즘은 둘 다 가비지 컬렉션의 대상으로 판단하지 않는 것이다. 이러한 순환 참조는 메모리 누수의 흔한 원인이다.

     

    표시하고-쓸기(Mark-and-weep) 알고리즘

    이 알고리즘에서는 '더 이상 접근할 수 없는 오브젝트'를 더 이상 필요하지 않은 메모리로 판단하여 메모리를 해제하게 된다.

    표시하고-쓸기(Mark-and-weep)

    이 알고리즘은 roots라는 오브젝트의 집합을 가지고 있다. 주기적으로 가비지 컬렉터는 roots로부터 시작하여 roots가 참조하고 있는 오브젝트들, 그리고 해당 오브젝트들이 하위로 또 참조하고 있는 오브젝트들을 계속해서 돌면서 접근이 가능한 오브젝트들을 확인한다. 그렇게 메모리 할당은 되어 있지만 접근이 불가능한 오브젝트를 찾아내어 가비지 컬렉션을 수행한다.

    function f(){
      var o = {};
      var o2 = {};
      o.a = o2; // o는 o2를 참조한다.
      o2.a = o; // o2는 o를 참조한다.
    
      return "azerty";
    }
    
    f();
    // f() 함수 내 o와 o2변수는 더 이상 접근할 수 없다.

     

    아까 위의 코드를 다시 한번 봐보자.
    f() 함수 내 o와 o2변수는 서로를 참조하고 있지만, roots로부터 시작하면 이 두 변수는 더 이상 접근을 할 수 없다. 그래서 f() 함수가 끝나는 순간 이 두 변수는 메모리가 해제된다.

     

    function f(){
      var o = {};
      var o2 = {};
      o.a = o2; // o는 o2를 참조한다.
      o2.a = o; // o2는 o를 참조한다.
    
      return function(){
        console.log(o);
        console.log(o2);
      }
    }
    
    const a = f();
    a();
    

     

    해당 예시도 같이 봐보자. 여기서는 f() 함수가 끝나는 순간 o와 o2 변수가 어떻게 될까?
    f() 함수가 콜백 함수(콘솔 로그를 출력하는 함수)를 반환함으로써 같은 공간에 있던 o와 o2변수가 f() 함수가 끝난 뒤에도 여전히 메모리가 남아있게 된다. f() 함수가 함수를 반환함으로써, 반환된 함수는 여전히 o와 o2를 접근할 수 있기 때문이다.(클로저)

     

    function f(){
      var o = {};
      var o2 = {};
      o.a = o2; // o는 o2를 참조한다.
      o2.a = o; // o2는 o를 참조한다.
    
      return function(){
        console.log('hello~inyong');
      }
    }
    
    const a = f();
    a();
    

     

    해당 코드는 덤으로 한번 확인해보자. 과연 어떻게 될까?
    반환되는 함수가 o와 o2를 사용하고 있지 않기 때문에 f() 함수가 끝나면 o와 o2함수는 가비지 컬렉션이 일어날 것이라고 생각할 수도 있다. 하지만 이 또한 가비지 컬렉션이 이루어지지 않는다. 왜냐하면 반환된 함수는 o와 o2를 사용을 안 했을 뿐이고 원래는 접근이 가능하기 때문이다. (스코프 체이닝)

    그러니 변수를 쓰냐 안 쓰냐로 확인할 것이 아니라, 실제로 접근 자체가 가능한 로직인지, 접근이 가능한 스코프로 이루고 있는지로 확인을 해야 한다.

     

    표시하고-쓸기 알고리즘은 위에서 설명한 참조-세기 알고리즘보다 효율적이다. 왜냐하면 '더 이상 접근할 수 없는 오브젝트'는 '더 이상 참조하지 않는 오브젝트'가 될 수 있지만, '더 이상 참조하지 않는 오브젝트'는 '더 이상 접근할 수 없는 오브젝트'가 될 수 없기 때문이다. 그렇기에 현재 기준으로써는 아직까지 모든 최신 브라우저들은 가비지 컬렉션으로 '표시하고 쓸기(Mark-and-sweep)' 알고리즘을 사용하고 있다.

    하지만!!

    이러한 훌륭한 알고리즘에서도 여전히 메모리 누수가 발생할 수 있는 함정들이 여전히 발생할 수 있다. 그래서 어떤 메모리를 해제하는 것이 올바른지 정확하게 판단할 수 있는 것은 무엇보다도 개발자가 직접 확인하는 것이 베스트이다.
    그러나 아직까지는 자바스크립트에서는 개발자가 직접 명시적으로 코드를 이용하여 가비지 컬렉션을 작동시킬 수 없다는 한계가 있다.

    그래서 적어도 자바스크립트 개발자가 어떻게 메모리 관리에 신경 쓰면서 코드를 짤 수 있을지, 메모리 누수에 대한 대처법을 알아보자.

     

    최소한의 메모리 관리에 신경 쓰는 방법

    1. 의도치 않은 전역 변수 생성을 막기

    자바스크립트는 선언되지 않고 사용한 변수를 전역 변수로 처리하도록 되어 있다.

    function(){
      foo = "inyong~";
    }
    
    // 실행 시 아래와 동일
    
    function(){
      window.foo = "inyong~"; // window는 브라우저 내 최상단 DOM 오브젝트
    }

     

    var, let, const를 선언하지 않고 사용하게 되면, 해당 변수는 전역 객체의 하위 오브젝트로 지정됨으로써 전역 변수로 사용된다. 이로 인해 foo변수는 사용하지 않더라도 계속해서 불필요한 메모리로 남게 될 것이다.

    또한 this를 이용해서도 뜻하지 않은 전역 변수를 생성할 수도 있다. 아래 예시를 봐보자.

    function foo(){
      const bar = {
        a: function(){
          console.log(this); 
        }
      }
      // bar.a(); // {a:f함수} 출력
    }
    
    foo();

     

    this는 자신을 감싸고 있는 오브젝트를 가리킨다. 그래서 위 코드에서는 this 감싸고 있는 오브젝트는 bar인 것을 알 수 있다.
    그러나 아래 코드를 확인해보자.

    function foo() {
        this.bar = "potential accidental global";
    }
    // 다른 함수 내에 있지 않은 foo를 호출하면 this는 글로벌 객체(window)를 가리킴
    foo();

     

    여기서는 this를 감싸고 있는 특정 오브젝트가 없다. 정확히 말하면 글로벌 오브젝트인 window가 감싸고 있는 형태이므로 여기서 this는 window를 가리키게 된다. 그래서 위처럼 this.bar를 이용하게 되면 해당 bar 변수도 전역 변수가 되어버리고 만다.

    이러한 방식은 의도적으로 가비지 컬렉터가 정리할 수 없게 하기 위해 전역 변수 방식으로 사용할 수도 있지만, 그것이 아니고 임시로 정보를 저장하여 사용하는 것, 특히나 많은 양의 정보를 처리할 때 사용하는 용도라면 이러한 점을 조심해야 할 것이다.

     

    2. 잊혀진 타이머 혹은 콜백 함수

    자바스크립트에 많이 사용되는 타이머 함수와 콜백 함수로 인해서도 메모리 누수가 발생할 수 있다.
    아래의 setInterval을 예시로 확인해보자.

    function timeRun(){
      var serverData = 'hello~inyong';  
      
      setInterval(function() {
          var element = document.getElementById('someID');
          if(element) {
              element.innerHTML = JSON.stringify(serverData);
          }
      }, 5000); // 매 5초 마다 실행
    }
    
    timeRun();

     

    위 코드에서 보면 매 5초마다 특정 DOM 엘리먼트를 가져와서 하위로 serverData 변수 값을 주입한다. 해당 이벤트는 계속 활성화 상태가 되므로 해당 이벤트에 연결되어 있는 serverData 변수 메모리도 계속해서 사용하는 것으로 간주되어 그대로 남아있다.

    그러나 해당 엘리먼트는 다른 곳에서 언제든지 제거할 수 있는 가능성을 가지고 있고, 만약 제거가 된다면 serInterval()의 타이머 함수는 더 이상 의미 없는 이벤트 동작이 된다.

    그럼에도 불구하고 해당 setInterval 이벤트는 아직 활성 상태이므로 가비지 컬렉터가 이 이벤트 핸들러와 해당 이벤트 내부에서 사용되는 메모리들을 해제하지 않게 되고 5초마다 의미 없는 이벤트가 계속해서 동작하여 CPU의 낭비를 초래하게 된다.

    그나마 다행인 것은 serverData 변수에 할당된 메모리는 더 이상 사용하고 있는 곳이 없고 접근할 수 있는 연결고리가 없으므로 바로 가비지 컬렉션이 일어난다. 만약 serverData 변수가 setInterval 이벤트 내부에 있었으면 이 변수 또한 그대로 불필요한 메모리를 차지하고 있었을 것이다.

    그렇기에 이러한 타이머 함수를 사용할 때는 해당 타이머의 이벤트가 더 이상 의미가 없어졌을 때, 꼭 명시적으로 그것을 제거해야 할 필요가 있다. 

    3. 클로저

    우선 여기 부분을 이해하려면 스코프 체이닝에 대한 개념을 잘 알고 있어야 한다. 클로저를 이용하게 되면 함수가 끝나도 아직 스코프 체인이 그대로 남아 있어서 끝났던 함수 내의 요소들을 참조할 수 있으므로 메모리에서 사라지지 않는다.

    Meteor라는 개발자들이 해당 클로저로 인해 발생하는 메모리 누수의 특정 사례에 대해 설명한 글을 봤는데, 정말 너무 이해가 안 갔었다.. 이해하는데 이틀 정도 걸렸던 것 같다...

    우선 같이 코드를 봐보자.

    var theThing = null;
    
    var replaceThing = function () {
      var originalThing = theThing;
      
      theThing = {
        longStr: new Array(1000000).join('*'),
        someMethod: function () {
          console.log(someMessage);
        }
      };
    };
    
    setInterval(replaceThing, 1000);

     

    위 코드를 보면 replaceThing 함수를 1초에 한 번씩 실행시키는데, 아래에서 동작 과정을 나열해보겠다. 편의 상, 첫 번째로 호출된 replaceThing을 replaceThing(1), 두 번째 호출은 replaceThing(2)로 작성하겠다.

    1. replaceThing(1)은 theThing 전역 변수에 새 오브젝트는 넣는다. originalThing은 null이다.
    2. 그다음 replaceThing(2)이 호출되고, originalThing에는 전역 변수 theThing에 의해 replaceThing(1)에서 생성되었던 값을 참조하게 한다.
    3. 그다음 전역 변수 theThing에 다시 새로운 값을 넣는다. 이때 생성된 값 중 내부 함수인 someMethod는 originalThing을 참조할 수 있다. (위 코드에서는 실제로는 참조는 안 하고 있지만, 더 내부에 있는 someMethod는 바로 부모 스코프인 영역들을 참조할 수 있도록 스코프 체인을 이루고 있으면 메로리 해제를 하지 않는다.)
    4. 현재 replaceThing(2)의 originalThing 변수는 이전 replaceThing(1)을 참조하고 있다. 그리고 현재 replaceThing(2)에서 새로 만든 theThing의 값은 originalThing을, 즉 이전에 만들었던 값과 스코프 체인을 이루게 되는 것이다.

    그래서 결국 replaceThing이 호출될 때마다 이전의 theThing과 새로운 theThing이 계속해서 스코프 체인이 이루어지므로 참조할 수 있는 영역으로 판단하여 가비지 컬렉션이 이루어지지 않아야 한다.

    하지만 구글의 V8 엔진에서는 멋지게도 이러한 요소들까지 고려하여 setInterval 함수로 인해 불필요하게 스코프 체이닝이 일어나면 이전의 스포크 체인을 없애준다. 그래서 위 코드는 setInterval이 동작할 때마다 메모리가 일정하게 된다.

    하지만 문제의 코드는 바로 아래이다.

    var theThing = null;
    
    var replaceThing = function () {
      var originalThing = theThing;
      
      var unused = function () {
        if (originalThing) // 'originalThing'에 대한 참조
          console.log("hi");
      };
      
      theThing = {
        longStr: new Array(1000000).join('*'),
        someMethod: function () {
          console.log("message");
        }
      };
    };
    setInterval(replaceThing, 1000);

     

    위 코드의 동작 과정을 확인해보자.

    1. replaceThing(1)은 theThing 전역 변수에 새 오브젝트는 넣는다. originalThing은 null이다.
    2. 그다음 replaceThing(2)이 호출되고, originalThing에는 전역 변수 theThing에 의해 replaceThing(1)에서 생성되었던 값을 참조하게 한다.
    3. 그다음 내부 함수인 unused 변수를 생성한다. unusedoriginalThing을 사용한다. 즉, 현재 replaceThing(2)의 ununsed 내부 함수 안에 replaceThing(1)의 someMethod 내부 함수가 있는 것이다. 정리하면 replaceThing함수 내부에 unused 내부 함수에 someMethod 내부 함수가 있는 것임. 3중 내부 함수.
    4. 그다음 전역 변수 theThing에 다시 새로운 값을 넣는다. 이때 생성된 값 중 내부 함수인 someMethod는 originalThing과 unused를 참조할 수 있다.

      (여기서 중요!) 이때 someMethod가 참조하고 있는 unused는 내부 함수이다. 여기까지만 생각하면 unused 변수 위에 originalThing(이전에 생성된 값)의 someMethod와 스코프를 이루는 것과 같이 차이가 없다. (원래는 스코프 체이닝 연결이 돼서 가비지 컬렉션이 이루어지지 않아야 되는 것을 위에 말했듯이 멋쟁이 구글 V8가 이를 알아서 처리해줌)

      하지만 unused의 내부 함수는 또 하나의 내부 함수를 포함하고 있다. (originalThing, 즉 이전에 생성된 someMethod 함수)
      그러니까 결국 replace(2)에서 생성된 someMethod 함수는 바로 상위의 unused 함수와 스코프 체인을 이루면서도 unused 함수의 내부 함수 someMethod(replace(1)에서 생성했던 값)까지 스코프 체인을 이루게 되는 것이다.

    이렇게 더 깊이 스코프 체이닝이 이루어지는 것은 V8에서 처리를 하지 못하는 것 같다. 그래서 결국 replaceThing이 실행이 될 때마다 계속해서 스코프 체이닝이 이루어지고 참조할 수 있는 것으로 판단하여 메모리가 계속해서 증가하게 된다.

     

    4. DOM에서 벗어난 요소 참조

    DOM 엘리먼트들을 빠르게 불러와서 바로 사용하기 위해 특정 데이터 요소에 저장하여 사용하는 경우가 있다. 예를 들면 아래 코드에서 첫 번째 줄처럼 사용할 수도 있고, 혹은 특정 배열에 저장해서 사용할 수도 있을 것이다. 그러나 만약 이렇게 사용 중인 엘리먼트를 제거하기로 결정하면, DOM 트리 자체에서 제거하는 것뿐 아니라, 변수에 저장하여 참조하고 있는 연결고리도 함께 제거해 주어야 한다는 것을 잊으면 안 된다.

     

    var elements = {
        button: document.getElementById('button'),
        image: document.getElementById('image')
    };
    
    function doStuff() {
        elements.image.src = 'http://example.com/image_name.png';
    }
    
    function removeImage() {
        // image는 body 요소의 바로 아래 자식임
        document.body.removeChild(document.getElementById('image'));
        // 이 순간까지 #button 전역 요소 객체에 대한 참조가 아직 존재함
        // 즉, button 요소는 아직도 메모리 상에 있고 가비지컬렉터가 가져갈 수 없음
    }

     

    위 코드에서 removeImage() 함수를 실행시키면, image DOM 오브젝트가 DOM Tree에서는 삭제가 되므로 화면 상에서는 사라질 것이다. 그러나 해당 DOM 오브젝트는 첫 번째 줄의 elements변수에서 아직 참조를 하고 있는 중이므로 가비지 컬렉션이 일어나지 않게 되고 메모리 누수가 일어나게 된다.

    같은 경우에 대해 더욱 극심한 예시도 있다. 아래 코드를 확인해보자.

    var elements = {
        td: document.getElementById('td') // <table> 내 셀 태그인 <td>
    };
    
    function removeTable() {
        document.body.removeChild(document.getElementById('tableID')); // <table> 태그 제거
        // td 엘리먼트가 아직 참조 중이므로, 모든 table 내 데이터가 그대로 유지
    }

     

    DOM Tree에서 Table DOM 엘리먼트를 제거했다. 그러나 Table 오브젝트의 하위인 td 셀 태그 엘리먼트가 여전히 참조되고 있다. td 엘리먼트는 table엘리먼트를 참조하고 있으므로 해당 td 뿐 아니라 Table DOM 엘리먼트 내에서 사용되던 많은 오브젝트들이 메모리에서 제거되지 않고 여전히 남아있게 되어 커다란 누수가 일어나게 되는 것이다.

     

    function useTd() {
      var elements = {
        td: document.getElementById('td') // <table> 내 셀 태그인 <td>
      };    
    }
    
    function removeTable() {
      document.body.removeChild(document.getElementById('tableID')); // <table> 태그 제거
    }
    
    useTd();
    removeTable();
    

     

    다행히도 위 코드와 같이 td참조 방식이 전역 변수 관리가 아닌 함수 내 지역 변수로 잠깐 이용하는 것이라면, Table 오브젝트가 DOM Tree가 삭제되면서 정상적으로 가비지 컬렉션이 일어날 것이다. 왜냐하면  DOM Tree로 Table 오브젝트가 삭제된 뒤 useTd() 함수 내에서 elements 변수가 참조를 하고 있지만, 최상위 roots 오브젝트에서 더 이상 elements 변수에 접근할 수 없게 되었으므로 '표시하고-쓸기(Mark-and-weep)'에 의해 가비지 컬렉션 대상이 되어버리기 때문이다.

     

    이렇게 자바스크립트 내 가비지 컬렉션 동작원리와 여러 가지 메모리 누수가 일어날 수 있는 요소들에 대해 알아봤다. 
    특정 서비스에서 사용자들은 페이지를 리로딩하거나 서비스 내부를 돌아다닐 수 있기 때문에, 아무리 자바스크립트 내 가비지 컬렉터가 개발자를 대신해서 메모리 관리를 해준다 하더라도, 개발자는 항상 모든 변수 할당이나 타이머 등을 이용할 때 메모리 누수가 일어나지 않도록 주의해야 할 것이다. 

     


    마치며

    헥헥....엄청 빡쎄다...

    이번에 자바스크립트 메모리 관리에 대해 알아보면서 더 세심하게 신경 써야 할 부분들이 어떤 것들이 있는지 더 정확히 알게 된 것 같다. 특히 위의 메모리 누수에 관한 요소들을 공부하면서 어떻게 보면 작을 수도 있지만 저게 하나하나 계속 누적이 되다 보면 나중에는 돌이킬 수 없는 상황도 발생할 수도 있을 것 같고, 저런 것들을 몰랐으면 나중에 메모리 관련 에러가 발생해도 원인 찾기가 참 어려웠을 것이라 생각된다. 

    메모리 관리에 신경 쓰는 방법 중 세 번째 무한 스코프 체이닝이 일어나는 부분은 진짜 이해하기가 너무 어려웠다... 관련 글은 몇 개 있는데 다들 복붙 한 마냥 내용이 똑같다.. 그래도 계속해서 찾아보고 이해하려고 노력하니, 내가 스코프 체이닝 쪽 개념을 잘못 알고 있는 부분이 있어서 이해가 계속해서 안됐었던 것이었고, 개념을 다시 정확히 짚고 확인하니까 바로 이해할 수 있었다.

    가비지 컬렉션이란 것은 학부 때부터 알고는 있었지만 정확히 어떤 방식으로 동작하는지는 처음 알았다. 알아가는 과정에서도 내가 잘못 알고 있었던 개념들을 제대로 많이 잡게 되는 것 같고, 중간중간 애매 모호하게 알고 있던 지식들도 정확히 알아가는 과정이 된 것 같다.

    반응형

    댓글

Designed by Tistory.