Language/JavaScript

JS GC 내부 알고리즘 알아보기

SambaLim 2021. 12. 20. 22:20

모든 언어에서 프로그래밍을 하기 위해서는 메모리 공간을 사용해야합니다. 그리고 그 메모리 공간에 데이터는 할당하고 사용한 후에 해제해야합니다.

JS에서 데이터를 할당한 후에 사용하는 것은 개발자가 코드에서 명시적으로 작성합니다. 하지만 해제하는 행위는 Garbage Collection(GC)라는 자동 메모리 관리 기법을 사용합니다.

Reference Counting

레퍼런스 카운팅은 메모리 할당과 해제가 한 블럭 이내에서 이뤄질 수 없는 경우 사용합니다. GC를 구현하는 방법중 원리가 어렵지 않고 간단하며 프로세스에 연산 / 메모리 부하를 많이 주지 않아 초기버전의 GC나 Rust의 Rc/Arc 객체등에서 사용하고 있습니다.

레퍼런스 카운트는 동적으로 할당된 메모리를 참조하는 객체의 수를 의미합니다. 레퍼런스 카운트(Rc)는 처음 선언을 할때 그 값이 1이 되고, 카운트 값이 0이 되는 순간 메모리에서 지워집니다.

예시

function init() {
  let a = {}; // Rc(a) = 1 // a 처음 선언
  {
    let b = {}; // Rc(b) = 1 // b 처음 선언
    a.test = b; // Rc(b) = 2 // b를 a.test 에서도 접근가능
  }
  // Rc(b) = 1 // b라는 변수를 사용할 수 없음
}

init();
// Rc(a) = 0
// Rc(b) = 0 
  1. init() 함수 내에서 a 변수가 선언되고 빈객체({})가 할당되어 Rc(a) 가 1이 됩니다.
  2. 블록스코프
    1. b 변수가 선언되고 빈객체({})가 할당되어 Rc(b) 가 1이 됩니다.
    2. a.testb 가 할당되어 ba.test 에서도 접근 가능하게 됩니다. 따라서 Rc(b) 가 2가 됩니다.
  3. 블록스코프 끝
    1. b 변수를 사용할 수 없어 Rc(b) 가 1이 됩니다.
  4. init() 함수스코프 끝
    1. Rc(a) , Rc(b) 모두 0이 되고 이는 GC에 의해 메모리에서 해제됩니다.

순환 참조 문제

레퍼런스 카운팅은 순환 참조를 다루는 것에 한계가 있습니다. 두 객체가 서로 참조하는 속성으로 생성되어 순환구조를 생셩한 경우, 스코프를 벗어나더라도 두 객체가 서로를 참조하므로 레퍼런스 카운트가 0이 되지 않습니다.

예제

function circularReference() {
  let a = {}; // Rc(a) = 1 // a 처음 선언
  let b = {}; // Rc(b) = 1 // b 처음 선언

  a.test = b; // Rc(b) = 2 // b를 a.test 에서도 접근가능
  b.test = a; // Rc(b) = 2 // a를 b.test 에서도 접근가능
}

circularReference();
// 함수 스코프를 벗어나 Rc가 1씩 감소
// Rc(a) = 1, Rc(b) = 1

앞선 예제와 다르게 함수스코프를 벗어나 ab 에 접근할 수 없음에도 불구하고 a.testb.test 를 통해서 ab 에 접근이 가능합니다.

이와같이 순환 참조가 발생하는 경우 레퍼런스 카운팅에서는 해결할 방법이 없습니다. 따라서 프로그래머가 순환 참조를 만들어낼 경우 이는 계속 메모리상에 존재하게되어 메모리 누수에 원인이 됩니다.

Mark & Sweep

Mark & Sweep 으로 Reference Conting의 순환참조문제를 해결할 수 있습니다.

내부 알고리즘으로 Mart & Sweep 을 사용하는 가비지 컬렉션은 시작하는 Node를 Root(루트)라고 하고 사용되는 메모리 공간을 출처와 연결을 합니다.

가비지 컬렉션은 루트부터 시작하여 루트가 참조하고 있는 모든 객체를 방문하고 이들을 “Mark” 합니다. 그 뒤 Mark되지 않은 모든 객체를 메모리에서 삭제(Sweep)합니다.

예시1

{
    let a = {};
    let b = {};

    a.test = b;
  // We are here.
}

Block Scope 내부에서 실행

  1. 블럭 스코프 내에 ab 변수가 선언되고 빈객체({})가 할당됩니다.
  2. ab 는 상위코드가 없으므로 Root와 연결합니다.
  3. a.test 를 통해 b 를 접근할 수 있으므로 ab 를 참조하고 있어 연결해줍니다. (블럭 스코프내의 Root 부터 아래의 그래프와 같이 연결되어 있을겁니다.)
  4. 만약 현 상황에서 GC가 실행된다면 Root가 참조하고 있는 모든 객체를 방문하고 Mark합니다.
  5. 그 후, Mark 되지 않은 모든 객체를 메모리에서 삭제(Sweep)하는데 현재는 모두 Mark 되므로 가비지 컬렉션 대상인 객체가 없습니다.

예시2

{
    let a = {};
    let b = {};

    a.test = b;
}
// We are here.

Block Scope 외부에서 실행

앞선 예시와 다르게 블록 스코프 외부로 나왔을때 GC가 실행되었다고 가정해보겠습니다.

  1. Root에서 참조하고 있는 객체가 없으므로 Mark를 하지 않고 끝이납니다.
  2. Mark되지 않은 모든 객체(a , b)를 지워버립니다.

문제점

레퍼런스 카운팅을 사용할때는 그때그때 레퍼런스 카운트가 0이 된다면 지워버렸으면 됐습니다. 하지만 마크앤스윕은 실행될때마다 전체 객체를 탐색해야합니다. 따라서 최적화 없이 구현해서 사용할 경우 엔진에 영향을 미쳐 주기적으로 동작하던 프로그램이 멈출 수 있습니다.

마무리

JS도 다른 프로그래밍 언어와 마찬가지로 메모리 공간을 사용합니다. 하지만 코드내에서 우리는 메모리 공간에 데이터만 할당하고 해제하는 작업을 직접하지 않습니다.

해제하는 작업은 GC가 하는데 기존에는 레퍼런스 카운팅(Reference Counting) 알고리즘을 사용하곤 했습니다. 레퍼런스 카운팅은 구현이 쉽고 엔진에 부하를 많이 주지 않는 장점이 있지만, 순환참조가 있는 경우 메모리 누수가 발생하곤 했습니다.

최근 자바스크립트 엔진에서는 마크앤스윕(Mark & Sweep) 알고리즘을 사용합니다. 이를 통해 레퍼런스 카운팅이 가지고 있던 순환참조 문제를 해결하였습니다. 하지만 마크앤스윕 알고리즘은 최적화 없이 구현할 경우 전체 객체를 탐색하므로 엔진에 영향을 미칩니다.

참고문서