가비지 컬렉션 모르시는분~!!

HyunHo Lee·2022년 10월 2일
77

개념

목록 보기
12/14
post-thumbnail

가비지 컬렉션(GC)

자바스크립트를 공부하다보면, 가끔씩 가비지 컬렉션(GC)이라는 단어가 등장한다. 직역하자면 '쓰레기 수집' 인데... 자바스크립트에서 쓰레기라고 할 만한게 뭐가 있을까? (사실 너무 많...)

사람은 필요없는 것을 버리고, 그것은 쓰레기가 된다. 그리고 청소부분들은 쓰레기를 발견하면 쓰레기통에 담아 버린다. 자바스크립트로 이루어진 프로젝트에서 쓰레기는 프로젝트에 영향을 전혀 끼치지 않는 코드일 것이다. 그렇다면 자바스크립트는 어떻게 '어머! 저건 쓰레기야!' 하고, 필요없는 코드를 판단할까? 오늘의 주제는 자바스크립트의 가비지 컬렉션의 신비이다.


GC는 자바스크립트 전용?!

const 다이어트 = "맛있게 먹으면 0칼로리";

가비지 컬렉션에는 정확히 어떤 것이 담기는 걸까? 가비지 컬렉션이란, 프로그램이 동적으로 할당했던 메모리 영역 중 필요 없게 된 부분을 해제 해주는 청소부이다. 생각해보면 프로그래밍에서 가비지란 필요 없는 부분일테고 청소부는 가비지가 메모리를 차지할 수 없게 치워야 할 것이다.

즉, 가비지 컬렉션은 '유효하지 않은 메모리 주소' 또는 '해제되지 않은 메모리 영역'을 가비지로 판단하고 청소한다. 그렇다면 다른 언어도 모두 같은 가비지 컬렉션을 사용할까?


// 자바스크립트

const number = [1, 2, 3];

// C언어
#include <stdio.h>

int main()
{
    int numArr[3] = { 1, 2, 3 };    
    return 0;
}

문득 C언어로 코딩을 처음 공부하던 때가 생각났다. 분명 C언어는 배열을 선언하는 경우 배열의 크기만큼 공간을 설정해줬다. 이것이 의미하는 바는 C언어와 같은 저수준 언어에서는 메모리를 직접 할당해주고, 관리하기 위해서는 해당 언어에서 제공하는 메소드(malloc, free 등)를 사용해야 한다는 것이다. 이에 비해 고수준 언어인 자바스크립트는 알아서 메모리를 할당하기 때문에 쉽게 배열을 선언할 수 있다. 또한, 메모리 관리도 직접하지 않고 청소부가 알아서 한다.

고수준 언어가 저수준 언어보다 좋다는게 절대 아니다


가비지 컬렉션의 이런 특징 덕분에 개발자는 언어를 쉽게 사용할 수 있다. 하지만 메모리를 알아서 정리해주다 보니, 개발자가 메모리를 고려하지 않고 알아서 정리하겠지 라며 코드를 마구잡이로 작성할 수도 있다.

그래서 우리는 가비지 컬렉션이 어떤 알고리즘을 사용해서 가비지를 판별하는지 알 필요가 있다. 프로젝트가 효율적으로 메모리를 사용하기 위해서는 개발자는 더 이상 필요 없는 코드를 가비지 컬렉션이라는 청소부가 잘 가져갈 수 있도록 쓰레기통에 담아놓아야 하기 때문이다.


가비지 컬렉션은 자바스크립트에서만 등장하는 것이 아니다. 하지만 언어마다 특징이 있고, 발전해온 역사가 있어 가비지 컬렉션의 모델이 다르고 알고리즘의 차이가 있다.

또한, GC는 자바스크립트 자체에 있는 것이 아니다. 우리가 프로젝트를 설계하면 브라우저에 있는 자바스크립트 엔진에서 처리한다.

오늘은 프론트엔드의 주 언어인 JavaScript의 GC에 알아 볼 것이다.

자바스크립트 이론 부시기글을 보면 이 글을 읽는데 도움이 될 것이다.


메모리 관리가 필요한 이유

그렇다면 굳이 메모리를 관리 해야하는 이유는 무엇일까? 메모리를 많이 잡아먹으면 생기는 문제점이 성능 저하가 발생하고, 유저는 느려진 웹 사이트를 이용하게 되어 UX가 나쁘다. 그렇기 때문에 개발자라면 메모리에 대해 이해하고 효율적으로 프로젝트를 설계할 의무가 있다.

자바스크립트에서는 메모리를 특이한 환경에서 관리한다. 메모리를 축내는 악마가 설계한 웹 애플리케이션을 실행한다고 가정하자. 메모리를 엄청나게 소모하고 있다. 브라우저에서 웹 사이트 하나 잘못 들어갔다고 컴퓨터의 메모리를 전부 사용해버려서 강제 종료 된다면 얼마나 불편할까? 그래서 웹 브라우저는 사용할 수 있는 메모리를 작게 만들었다. 실제로 다른 데스크톱 애플리케이션에 비해 웹 브라우저의 메모리는 매우 적다고 한다.


GC 알고리즘

자바스크립트의 가비지 컬렉션에 사용되는 알고리즘은 Reference-counting(참조-세기)와 Mark-and-sweep(표시하고-쓸기) 이렇게 2가지가 있다. 현재 많은 브라우저에서 채택하고 있는 알고리즘은 Mark-and-sweep이다. Reference-counting보다 효율적으로 가비지를 선택하기 때문이다. 그래도 두 알고리즘의 차이점을 이해하기 위해 모두 알아보자.

실제로 IE의 5, 6 버전에서는 Reference-counting를 사용했다고 한다. ( 기대를 져버리지 않는 IE... )


reference (참조)

let user = {
  name: "Ayaan"
};

Reference-counting를 이해하기 전에 참조(reference)에 대해 알아야 한다. 현재 user라는 변수는 name이라는 keyAyaan이라는 string값을 가진 value로 이루어진 객체를 참조하고 있다. 메모리를 할당 받은 것이다.

user = null

그렇다면 user에 null을 할당해서 user가 아무것도 참조하지 않는다면 어떻게 될까? 가비지 컬렉션의 Reference-counting 알고리즘은 어떤 다른 object도 참조하지 않는 object를 가비지로 선정한다. 이제 user는 어느것도 참조하지 않기 때문에 GC에 의해 메모리가 해제되는 것이다.

만약에 user에 null을 할당하기 전에, newObj = user 와 같이 객체를 무언가가 참조한다면 GC는 이 객체를 가비지로 판단하지 않을 것이다. 이러한 개념들은 자바스크립트의 불변성, 깊은 복사와 얕은 복사를 고려하면서 생각해보면 도움이 되기 때문에 간단하게 알아보자.


불변성과 자바스크립트의 메모리 관리

불변성

자바스크립트에서 불변성이란 값이나 상태를 변경할 수 없는 것을 의미한다. 원시 타입(string, number, bigint, boolean, undefined, ES6 부터 추가된 symbol)은 불변성을 지키고 있고, 원시 타입을 제외한 나머지인 참조 타입(배열, 객체, 함수 등)은 불변성을 지키지 않는다.

그런데 생각해보면 let str = 'Ayaan' 이라고 선언했던 것을 str = 'Hyunho'재할당 할 수 있다. str이라는 string 원시타입.. 이것은 값이나 상태가 변한것이 아닌가? 우리는 이것을 이해하기 위해서 자바스크립트의 메모리에 대해 이해할 필요가 있다.


원시 타입

let str = 'Ayaan';
변수 주소변수 데이터데이터 주소찐!! 데이터
1000key: str, value: 20002000'Ayaan'

위와 같이 string타입의 변수를 선언하면 메모리에는 이런 식으로 저장이 된다.


str = 'Hyunho';
변수 주소변수 데이터데이터 주소찐!! 데이터
1000key: str, value: 20012000'Ayaan'
2001'Hyunho'

그리고 str 변수에 새로운 값을 재할당하게 되면, 자바스크립트는 새로운 주소로 데이터를 생성한다. 그리고 변수가 가리키고 있는 데이터 주소를 2001로 변경한다. 즉, 2000이라는 주소를 가진 데이터는 변하지 않았고, 새로운 데이터를 위해 새로운 공간을 마련했으므로 불변성을 지킨 것이다. 이제 GC는 아무곳에서도 사용하지 않는 2000의 Ayaan이라는 데이터를 수집하여 메모리에서 해제할 것이다.

tip ) 원시 타입은 Stack 영역에 저장되고, 재할당 되는 경우 재할당된 값만 메모리 공간에 들어간다.


let b = 1;
let a = b;

b = 2;
a; // 1

또한, 원시 타입은 값 자체를 복사하여 사용한다. 한 마디로 b에 저장된 1이라는 원시값의 데이터 주소를 a도 갖게 되는 것이다. 그렇기 때문에 b에 새로운 메모리를 할당받은 2를 넣어도 a는 그대로 1을 가리키는 것이다. 즉, 새로운 변수에 기존에 한번 메모리에 저장된 원시값이 있다면 새로운 메모리 저장소를 생성하는 것이 아니라 그 주소를 할당받게 되는 것이다.


참조 타입

참조 타입은 원시 타입보다 조금 더 재밌(?)다. 바로 불변성이 없어 변경이 가능하기 때문이다. 객체를 변수에 할당하면 실제 객체의 값은 별도의 메모리 공간(Heap)에 저장되고, 그 공간을 참조하는 주소를 변수는 값으로 갖게 된다. 객체가 저장된 메모리 공간의 주소를 참조값이라고 한다. 객체를 할당한 변수를 다른 변수에 할당하면 객체값이 전달되는 것이 아닌 메모리 공간의 주소를 값으로 복사하여 전달하게 된다.

let a = 1;
let b = 1;
console.log(a === b); // true

const user1 = {
  name: "Ayaan"
};

const user2 = {
  name: "Ayaan"
};

console.log(user1 === user2) // false

그래서 객체는 이렇게 값이 똑같더라도 원시 타입과 다른 결과가 나온다. 값은 같지만 참조값이 다르기 때문이다. 이 특징으로 인해 우리는 얕은 복사와 깊은 복사에 대해서도 알아야 한다.


참조 타입은 왜 Heap을 사용할까

const user = {
  name: "Ayaan",
  age: 27
};
변수 주소변수 데이터데이터 주소찐!! 데이터객체 주소객체 데이터
1000key: user, value: 456452000'Ayaan'456452000하고 2001
200127

얕은 복사와 깊은 복사를 알아보기 전에 원시 타입은 Stack에 들어가는데 참조 타입은 왜 Heap을 사용하는지 생각해보자. 참조 타입은 원시 타입과 달리 배열이나 객체같은 형태기 때문에 여러개의 값이 존재하기 때문이다. (위의 간단한 객체인 user만 봐도 nameage가 있다.)

메모리 힙에 해당 객체에 대한 영역을 만들고, stack에 heap의 주소값을 넣어준다. 그리고 heap에서는 자신에 관련되어 할당된 원시값 메모리들을 연결하는 것이다.

원시 타입은 재할당을 통해서 변수에 저장된 값을 변경할 수 있었다. 데이터 주소를 바꿔가면서 말이다. 하지만 객체타입은 재할당 없이 객체에 연결된 데이터의 주소들을 갈아끼면서 추가 및 삭제가 가능하다는 것이다. 예를 들면, 위에서 user에 연결된 객체에서 age는 제거하고 싶다면 2001과 연결만 끊어버리면 된다. 변수는 그대로 원래 heap 주소를 가리키면서 말이다.


얕은 복사와 깊은 복사

  • 얕은(Shallow) 복사
const user1 = {
  name: "Ayaan",
  age: 27
};

// 같은 메모리 주소
const user2 = user1; 

// 얕은 복사 : Object.assign()
const user3 = Object.assign({}, user1);

// 얕은 복사 : spread operator
const user4 = {...user1}

객체를 복사할 때, 변수를 할당해버리면 같은 메모리 주소를 가리키게 된다. 그래서 객체를 수정하게 되면 두 변수에 영향을 끼치기 때문에 다른 방법인 얕은 복사를 사용해야 한다. Object.assign() 또는 전개 연산자(spread operator)를 통해 얕은 복사를 할 수 있다. 얕은 복사는 객체를 프로퍼티 값으로 갖는 객체의 경우 한 단계 까지만 복사한다.

array의 경우 얕은 복사로 slice를 사용할 수도 있다.


const user1 = {
  name: "Ayaan",
  age: 27,
  food: ["치킨", "피자"]
};

const user2 = {...user1};
user1.food.push("짜장면");
console.log(user1.food === user2.name); // true

하지만 얕은 복사에는 문제점이 존재한다. 바로 하위 중첩되어 있는 객체까지 복사하지 않는다는 것이다. 바로 food와 같은 객체 내에 존재하는 참조 타입 말이다. user1에만 짜장면을 추가하고 싶었지만 얕은 복사의 특징 때문에 user2에도 추가되어 버린 상황이다.


  • 깊은(Deep) 복사
const user1 = {
  name: "Ayaan",
  age: 27,
  food: ["치킨", "피자"]
};

// 깊은 복사 : JSON.stringify()
const user2 = JSON.parse(JSON.stringify(user1));

얕은 복사의 문제점을 해결해주는 깊은 복사이다. 하위에 중첩되어 있는 참조 타입도 모두 복사한다. 깊은 복사를 하기 위해서는 JSON.stringify()를 이용할 수 있다. 이 외에도 다양한 메소드를 탑재한 lodash라는 라이브러리의 cloneDeep, ramda을 사용할 수 있고, 순수 자바스크립트로 재귀함수를 통해 깊은 복사를 구현할 수도 있다.

자바스크립트에서도 structuredClone를 통해 깊은 복사가 가능해졌다고 한다.


Reference-counting

여러가지 설명으로 힘들지만.. 이제 본론으로 돌아와서 다시 가비지 컬렉션의 알고리즘에 대해 이야기해보자. Reference-counting 알고리즘은 아무것도 참조하지 않는 객체를 가비지로 분류해서 수집한다. 아무 필요 없는 녀석을 버린다니까 매우 효율적이고 타당해보인다. 하지만 Reference-counting은 순환 참조의 경우 메모리 누수가 발생한다.


순환 참조

function Circular(){
    const objectA = {};
    const objectB = {};
    objectA.objB = objectB;
    objectB.objA = objectA;
    return;
}

Circular();

이와 같이 서로 참조하는 것을 순환 참조라고 한다. Circular()함수가 실행되고, 종료 되어 우리의 프로젝트에 더 이상 필요가 없다. 개발자의 바램으로는 가비지에 수집되어야 한다. 하지만 서로 참조하고 있기 때문에 Reference-counting 알고리즘은 가비지로 생각하지 않는다. 과거 IE에서는 실제 이 알고리즘 때문에 많은 메모리 누수를 일으켰다고 한다.


Mark-and-sweep

Mark-and-sweep 알고리즘은 닿을 수 없는 오브젝트를 가비지로 판단한다. 참조가 아닌 도달가능성(reachablility)으로 중점을 두게 된 것이다. (그래도 참조 개념을 사용한다.)

Mark-and-sweep는 루트(root) 정보를 수집하고 기억(mark)한다. 그리고 루트가 참조하고 있는 모든 객체를 방문하고 마크하며, 모든 객체를 방문할 때까지 이 과정을 반복한다. 이 알고리즘으로 인해 Reference-counting에서 문제점이었던 순환 참조는 마크되지 않아 GC에 수집된다.

무조건 참조되었다고 해서 도달 가능한 것이 아니라 루트로부터 도달할 수 있어야 한다는 점을 주의하자. 또한, GC는 자동으로 실행되므로 강제로 멈추거나 실행할 수 없어 MDN에서는 이것을 자바스크립트에서 GC의 한계라고 표현하고 있다.

모던 자바스크립트 튜토리얼을 보면 자바스크립트 엔진은 실행에 영향을 미치지 않으면서 GC 성능을 증가시키기 위한 최적화 기법을 사용한다고 한다. (generational collection, incremental collection, idle-time collection)


V8에서의 GC 동작

V8엔진의 메모리 할당 방식

우리는 여태까지 자바스크립트 메모리에 대해 공부하고, GC 알고리즘을 알아보았다. 그렇다면 실제로 어떤식으로 사용되고 있을까? 크롬에서 사용하고 있는 자바스크립트 엔진은 V8이다. 프로그램을 실행하면 V8엔진은 콜 스택과 메모리 힙을 포함하고 있는 Resident Set을 할당한다. 콜 스택은 할 일이 끝나면 하나씩 비워지지만 메모리 힙은 GC가 일을 해야한다. Heap에서 GC가 사용되는 부분은 New space와 Old space이다.

New space에는 새로 만들어진 객체가 저장된다. 처음에 객체가 저장 될 경우 첫 번째 Semi space에 저장된다. GC로부터 한 번 생존하게 되면 두 번째 Semi space로 이동한다. 한 번 더 생존.. 즉, GC로부터 총 2번 생존하게 되면 Old space로 이동한다. 여기서 Old space는 다른 객체를 참조하는 객체인 Pointer space와 데이터만 갖는 Data space 영역으로 한번 더 나뉜다.

TOAST UI V8 메모리 사용에 대한 슬라이드에서 이해를 돕기 위한 좋은 자료를 제공하고 있다.


마이너 GC와 메이저 GC

그렇다면 왜 New space와 Old space로 나누는 것일까? GC가 모든 객체를 계속해서 검사하는 것은 비효율적이다. 그래서 새로운 객체가 오래된 객체보다 쓸모없어질 것이라는 가설(The Generational Hypothesis)로 두 공간을 나누고, 해당 영역에 최적화 된 GC를 적용한다. New space에서는 마이너 GC (Scavenger)가 사용되고 Old space에서는 메이저 GC가 사용된다.


마이너 GC

마이너 GC는 우리가 위에서 알아본 Mark-Sweep-Compact 알고리즘을 사용하여 살아남을 객체를 결정한다. New space에서는 2개의 Semi space가 있고, 마이너 GC를 견딘 객체가 다른 Semi space로 이동한다.

객체가 차있는 Semi space는 From space라고 부른다. 마이너 GC에 의해 2라는 객체만 살아남았다고 가정하자. 비어있는 Semi space인 To space객체2가 이동하고, 새로운 객체인 4는 From space로 변해버린 오른쪽 Semi space에 추가된다. 이렇게 Semi space는 상황에 따라 From과 To가 변경되면서 동작한다. 물론 객체2객체4는 1번 생존했는가 2번 생존했는가에 대한 데이터를 가지고 있을 것이다. 그래야 Old space로 이동시키기 때문이다.

메모리 단편화를 주기적으로 방지하는 장점이 있다고 한다.


메이저 GC

메이저 GC는 Mark-Sweep-Compact알고리즘과 Tri-color알고리즘을 사용한다. 깊이 우선 탐색(DFS)로 순회하며 Tri-color(white, gray, black)로 마킹하는 방식으로 가비지를 판단한다. 마킹, 스위핑, 압축이라는 3단계를 거치는데 자세한 부분은 Kakao 기술 블로그를 참고하자.

함께 참고하면 좋은 자료 : TOAST UI 기술 블로그


Orinoco

마이너 GC와 메이저 GC가 수행되면 프로그램이 멈추는 stop-the-world가 발생한다. 렌더링을 지연시켜 UX를 떨어뜨리는 주범이기도 하다. 이를 위해 Orinoco에서 GC를 발전시키고 있다.

  • Parallel : 메인 쓰레드가 혼자 하는 일을 헬퍼 쓰레드들과 균등하게 일을 나누어서 한다.
  • Incremental : 메인 쓰레드가 GC 작업을 간헐적으로 처리한다. 뭐 찔끔찔끔 한다는 의미인 것 같다. (UX적으로는 그래도 조금씩 렌더링 되는 UI를 보기 때문에 좋을 것 같다.)
  • Concurrent : 헬퍼 쓰레드가 GC를 담당한다. 메인 쓰레드에서 stop-the-world는 완전하게 사라진다.
  • Idle-time GC : 프로그램이 쉬는 free나 idle time을 알아내서 그 시간에 GC를 수행한다. (개발자가 GC에 직접 접근할 수 없지만 V8은 크롬에게 GC를 유발하는 메커니즘 제공)

마무리

오늘은 가비지 컬렉션(GC)에 대해 알아보았다. 이해를 돕기 위해 여러 가지 개념들도 함께 찾아보면서 잊고 있거나 새로 알게된 내용들이 많았다. 이 글을 보는 개발자들에게도 조금이라도 도움이 되었으면 좋겠다.

profile
함께 일하고 싶은 개발자가 되기 위해 달려나가고 있습니다.

8개의 댓글

comment-user-thumbnail
2022년 10월 2일

잘쓰네....짱나게....

1개의 답글
comment-user-thumbnail
2022년 10월 2일

잘쓰네....짱나게....

1개의 답글
comment-user-thumbnail
2022년 10월 3일

고급 주제네요. 좋은 아티클 감사합니다!

1개의 답글
comment-user-thumbnail
2022년 10월 4일

안녕하세요

가비지 컬렉션과 레퍼런스 타입, 얕은 복사 깊은 복사 등 자바스크립트에서 중요한 개념들을 잘 정리해 주셔서
다시 한번 되짚고 가게 되었습니다. 그리고 본문에 보완 드릴 내용이 있어 댓글을 작성합니다!

JSON.stringify()을 통한 복사는 완벽한 깊은 복사라고 할 수 없다고 생각합니다. 객체를 문자열로 변환 후 JSON.parse를 통해 새로운 객체를 생성할 경우 object array 등 값이 보존되지만 JSON의 값이 될 수 없는 타입은 복사가 되지 않습니다. 예를 들어 객체 안에 함수가 존재할 경우 JSON.stringify()를 통해 복사를 할 수 없습니다

1개의 답글