자바스크립트 메모리 관리 및 가비지 컬렉션 알아보기

ieun32·2024년 10월 6일
5

자바스크립트

목록 보기
1/1
post-thumbnail

메모리 관리와 가비지 컬렉터

컴퓨터가 프로그램을 실행할 때는, 필요한 정보를 메모리에 저장해 놓고 이를 사용하면서 실행한다. 이때 더 이상 필요하지 않은 정보를 삭제하지 않고 메모리에 계속 누적하면 어떻게 될까?

컴퓨터 프로그램이 필요하지 않은 메모리를 계속 점유하고 있는 현상을 메모리 누수(memory leak) 현상이라고 하고, 이러한 메모리 누수 현상이 지속되면 프로그램의 속도가 저하되거나 최악의 경우 프로그램이 실행되지 않을 수 있다.

따라서 프로그램을 실행할 때는 필요한 정보를 메모리에 저장하되, 더 이상 필요하지 않은 정보는 메모리에서 삭제하여 여유 공간을 확보할 수 있어야 한다.

이때 메모리에 정보를 저장하고 삭제하는 과정은 다음 단계로 나눌 수 있다.

  1. 필요 시 메모리에 정보 할당
  2. 할당된 메모리를 사용 (읽기, 쓰기)
  3. 필요하지 않을 경우 해제

이와 같은 단계를 메모리 생명 주기(Memory life cycle)라고 부른다.

메모리 생명 주기 중 사용 부분은 모든 언어에서 명시적으로 표현한다.

그러나 할당, 해제 부분은 언어에 따라 달라진다.

C와 같은 언어에서는 메모리 생명 주기 중 할당, 해제 과정을 개발자가 직접 malloc() 이나 free()와 같은 요소를 사용해서 명시적으로 표현해주어야 한다. 즉 메모리가 필요 없어지는 시점을 개발자가 직접 결정한다. 이러한 언어를 Unmanaged Language 라고 부른다.

반면 JavaScript와 같은 언어에서는 이러한 할당, 해제 과정을 명시적으로 표현해줄 필요가 없으며, 암묵적으로 할당 및 해제가 이루어진다. 이러한 언어를 Managed Language 라고 부른다.

이때 Managed Language에서는 메모리 해제를 알아서 해준다고 하였는데,

이것이 가능한 이유는 가비지 컬렉터(GC, Garbage Collector)를 사용하기 때문이다. 가비지 컬렉터는 메모리 할당을 추적하고, 할당된 메모리 블록이 더 이상 필요하지 않은 지 판단하여 회수하는 역할을 수행한다.

그러면 메모리 누수 관리는 가비지 컬렉터에게 온전히 맞기면 되겠네? 생각할 수 있다. 그러나 필요하지 않은 데이터가 무엇인지 판단하는 일은 쉽지 않고, 가비지 컬렉터가 있다고 해도 완벽하게 메모리 누수를 막는 것은 아니므로 이런 생각은 위험하다.

따라서 우리는 메모리 누수를 관리하는 방법에 관심을 갖고 좋은 프로그래밍이 무엇일 지 고민하는 것이 좋다. 메모리 누수 관리 방법은 언어마다, 환경마다 다르므로 자신이 사용하는 언어와 환경에 따라 다른 방법을 적용할 수 있다.

그러면 자바스크립트에서 가비지 컬렉터는 어떻게 동작할까?

그 전에 먼저 자바스크립트가 메모리에 데이터를 어떻게 저장하는 지 알아보자.

JS 엔진의 메모리 구조

자바스크립트 메모리 구조

출처 링크

자바스크립트 엔진의 메모리 구조는 크게 스택으로 이루어져있다.

자바스크립트는 싱글 스레드 언어이기 때문에 스택 메모리를 1개만 가진다.

스택 (Stack)


좌: stack 검색 이미지, 우: stack 사전적 의미

스택은 사전적으로 더미, 쌓다라는 뜻을 가진다. 특히 가지런히 정돈되어 쌓인 모습을 보고 stack이라고 부른다고 한다.

JS에서 스택은 실행 컨텍스트를 쌓아 올리고 삭제하는 그 콜스택을 말한다. 스택은 JS 엔진이 컴파일 시 데이터의 크기를 알고 있는 정적 데이터를 저장하는 영역이다.

이러한 스택에 저장되는 데이터들은 원시 자료형(primitive type)으로 구분된다.

원시 자료형에는 string, number, bool, bigInt, symbol, undefined, null이 있다.

원시 자료형이 스택에 저장되는 방식은 다음과 같다.

const a = 10;

만약 위와 같이 숫자 데이터를 선언한다면,

  1. 스택의 변수 영역에 식별자와 값의 정보를 저장하는 스택 메모리 주소를 저장한다.
  2. 메모리 주소를 따라가면 스택의 데이터 영역에 저장된 실제 값이 저장되어있다.

스택 메모리의 특징

이때 스택 메모리는 함수 실행 시 하나씩 스택 메모리가 쌓이고 함수 실행이 끝나면 인터프리터에 의해 자동으로 정리된다. 자바스크립트의 콜스택을 알고 있다면 무슨 말인지 이해할 수 있을 것이다.

따라서 메모리 누수 문제는 스택보다는 힙 메모리에 의해 생긴다.

힙 (Heap)


좌: heap 검색 이미지, 우: heap 사전적 의미

힙은 사전적으로 스택과 똑같이 더미, 쌓다라는 뜻을 갖는다. 특히 크기가 큰 대상이나 규모가 큰 경우 heap이라고 부른다고 한다.

힙은 사용자에 의해 동적으로 할당되고 해제되는 메모리 공간을 말한다.

JS에서 객체나 함수와 같이 크기를 미리 알 수 없는 데이터들을 저장하는 공간으로, 필요한 만큼의 공간을 동적으로 할당해준다.

이러한 힙에 저장되는 데이터들은 참조 자료형(Reference type)으로 구분된다.

참조 자료형에는 Array, Function, Date, RegExp, Map, WeakMap, Set, WeakSet, Math등이 있다.

참조 자료형이 힙에 저장되는 방식은 다음과 같다.

const a = {
	x: 10,
	y: 20
}

만약 위와 같은 객체를 선언한다면,

  1. 스택의 변수 영역에 식별자와 객체 정보가 저장된 힙 주소를 저장한다.
  2. 힙 주소를 따라가면 객체 내의 식별자 정보가 각각 저장되어 있고, 다시 스택의 데이터 영역 주소가 저장되어 있다.
  3. 스택 주소를 다시 따라가면 스택의 데이터 영역에 실제로 저장된 값이 있다.

힙 메모리의 특징

이때 힙 메모리는 알아서 필요 없는 메모리 공간을 지워주는 스택과는 달리 알아서 메모리 공간이 지워지지 않는다.

따라서 힙에서 필요 없는 메모리가 해제되지 않으면 메모리 누수가 발생하여 프로그램 속도 저하 등의 문제가 생길 수 있다.

메모리 누수 문제힙에 있는 쓸모 없는 메모리를 어떻게 찾고, 해제하느냐가 관건이라고 할 수 있다.

💡 이처럼 자바스크립트의 데이터 유형은 메모리 영역에 저장되는 형태에 따라
원시형(Primitive type)참조형(Reference Type)으로 구분됨

원시형은 데이터의 크기를 미리 알고 저장하는 정적 데이터를 저장하는 영역인 스택 영역에 저장하는 데이터, 그니까 데이터의 크기를 미리 알 수 있는 애들
string, number, bool, bigInt, symbol, undefined, null

참조형은 크기를 미리 알 수 없는 객체, 함수를 저장하는 힙 영역에 저장하는 데이터, 데이터의 크기를 미리 알 수 없는 애들
Array, Function, Date, RegExp, Map, WeakMap, Set, WeakSet, Math

이미지 출처

가비지 컬렉션 알고리즘

(feat. 필요없는 메모리를 판단하는 방법)

위에서 메모리 누수 문제는 힙 영역에서 주로 일어난다고 했다. 따라서 가비지 컬렉터는 힙 영역에서 활약하게 된다.

그렇다면 가비지 컬렉터는 어떤 원리로 동작할까?

가비지 컬렉터가 사용하는 대표적인 알고리즘으로는 참조 카운팅 알고리즘과 Mark-and-Sweep 알고리즘이 있다.

두 알고리즘이 어떤 방식으로 필요 없는 메모리를 판단하는 지 알아보자.

참조 카운팅 (Reference-counting)


참조 카운팅 알고리즘은 단순하게 구현된 알고리즘이다.

이 알고리즘은 ‘아무도 참조하지 않는 객체’를 필요 없는 객체로 간주한다.

참조 카운트가 0인 객체를 가비지 컬렉터가 수집해가고, 메모리를 해제한다.

// 1개의 객체가 생성되었고, 이 객체는 a 변수에 할당되어 참조되었다.
// 참조 카운트: 1
const a = {
	x: 10,
	y: 10
}

// 같은 객체를 b 변수에서도 참조하게 되었다.
// 참조 카운트: 2
const b = a;

// 변수 a에 다른 값이 할당되었다. 이제 객체를 참조하는 변수는 b 뿐이다.
// 참조 카운트: 1
const a = 10;

// 변수 b에도 다른 값이 할당되었다. 이제 객체를 아무도 참조하지 않는다.
// 참조 카운트: 0
const b = 'Hello'; // <--- ⓐ

// ⓐ시점에서 처음에 생성된 객체를 이제 아무도 참조하지 않으므로
// 가비지 컬렉터는 해당 객체가 저장된 힙 메모리를 해제할 수 있다.

이처럼 아무도 참조하지 않는 객체를 가비지 컬렉터가 수집하는 것이 합리적인 것 같이 보인다. 그러나 참조 카운팅 방식은 ‘순환 참조’가 발생했을 때 그 한계가 드러난다.

function family(){
	const woman = { name: "은영", age: 25 }
	const man = { name: "은우", age: 25 }

	woman.boyfriend = man;
	man.girlfriend = woman;
}

family();

위 코드에서는 family함수 안에 두 개의 객체가 생성되었는데, 각각 속성으로 서로 다른 객체를 참조하고 있다. 이러한 참조를 순환 참조라고 한다.

family 함수를 실행하고 나서는 더 이상 두 객체를 사용하는 부분이 없어 불필요해지니, 할당된 메모리는 회수 되어야 한다. 그러나 두 객체가 서로를 참조하고 있으므로, 참조 카운트가 각각 1이 되어 참조 카운팅 알고리즘에서는 둘 다 가비지 컬렉터의 대상으로 표시하지 않는다.

이러한 순환 참조는 메모리 누수의 흔한 원인이며, 참조 카운팅 알고리즘으로는 이러한 문제를 해결하기 어렵다. 따라서 최신 브라우저에서는 이러한 한계를 보완한 Mark-and-Sweep 알고리즘을 사용한다.

Mark-and-Sweep


마크 앤 스윕 알고리즘에서는 ‘루트에서 도달할 수 없는 객체’를 필요 없는 객체로 간주한다.

이 알고리즘은 roots 라는 객체의 집합을 가지고 있다.

JavaScript에서 root는 전역 객체를 가리킨다. 가비지 컬렉터는 주기적으로 roots로 부터 시작해 roots가 참조하는 객체들, 또 그 객체들이 참조하는 객체들을 찾는다.

이렇게 roots로 부터 시작하여 도달할 수 있는 모든 객체들을 찾고, 도달할 수 없는 모든 객체들을 가비지 컬렉터가 수집한다.

즉 마크 앤 스윕 알고리즘을 사용하면 가비지 컬렉션은 다음 단계를 거쳐 수행된다.
https://ko.javascript.info/garbage-collection#ref-746

이미지 출처
  1. 가비지 컬렉터가 루트(root) 정보를 수집하고 이것을 마크(mark) 한다. (마크 = 기억)
  2. 루트가 참조하고 있는 모든 객체를 방문하고 이것들을 마크한다.
  3. 마크된 모든 객체에 방문하고 그 객체가 참조하는 객체도 마크한다. 한번 방문한 객체는 전부 마크하기 때문에 같은 객체를 다시 방문하지는 않는다.
  4. 루트에서 도달 가능한 모든 객체를 방문할 때까지 위 과정을 반복한다.
  5. 마크되지 않은 모든 객체를 메모리에서 삭제한다.

이러한 마크 앤 스윕 알고리즘에서 순환 참조 한계를 어떻게 해결하는 지 알아보자.

function marry(man, woman) {
  woman.husband = man;
  man.wife = woman;

  return {
    father: man,
    mother: woman
  }
}

let family = marry({
  name: "John"
}, {
  name: "Ann"
});

위 코드에서 marry 함수를 실행시키면 순환 참조를 하고 있는 두 객체를 포함하는 새로운 객체를 반환한다.

이 객체들을 그림으로 표현하면 다음과 같이 표현할 수 있다.
https://ko.javascript.info/garbage-collection#ref-746

이미지 출처

root인 전역 변수에서 family 객체에 접근하면, fathermother key에 접근할 수 있고, fatherman객체를, motherwoman객체를 value로 가지고 있다.

man객체와 woman객체는 서로를 wife, husband key로 순환 참조하고 있다.

따라서 지금은 root에서 모든 객체에 도달 가능한 상태이므로 가비지 컬렉터가 수집할 객체는 없다.

이제 모든 객체에 도달할 수 없는 상태를 만들어보자.

family = null;

이제 메모리 내부 상태는 다음과 같아진다.
https://ko.javascript.info/garbage-collection#ref-746

이미지 출처

그러면 root인 전역 변수에서는 기존에 생성된 객체들한테 도달할 수 있는 경로가 없다.

각 객체들은 서로를 순환 참조하고 있으나 그것과는 관계 없이 root에서 도달할 수 없다면 가비지 컬렉터가 이들을 수집하여 메모리에서 제거한다.

가비지 컬렉션 최적화 기법

💡 V8 엔진 기준이며 https://fe-developers.kakaoent.com/2022/220519-garbage-collection/ 를 참고하였습니다.

언급했듯이, 최신 브라우저는 가비지 컬렉션 알고리즘으로 마크 앤 스윕 알고리즘을 사용한다. 이때 자바스크립트 엔진은 스크립트 실행에 최대한 영향을 미치지 않으면서 가비지 컬렉션을 더 빠르게 수행할 수 있도록 다양한 최적화 기법을 적용한다.

V8 엔진도 여러 최적화 기법을 적용했는데,

먼저 V8 엔진의 메모리 영역을 자세히 다시 살펴보자.

V8 엔진 메모리 구조


https://fe-developers.kakaoent.com/2022/220519-garbage-collection/

이미지 출처

V8 엔진의 메모리 구조는 프로그램을 실행하면 Resident Set 이라는 빈 공간이 할당되고, Resident Set은 스택 영역과 힙 영역으로 나뉜다. 이때 힙 영역에 주목해보자.

힙 영역은 세부적으로 New space, Old space, Large space, Code space, Shell space, Attribute Space, Map space로 이루어져 있는데,

가비지 컬렉션이 일어나는 부분은 New space와 Old space이다.

  • New space
    • 새로 만들어진 객체가 저장되는 영역
    • 여기 저장된 객체들을 Young generation이라고 함
  • Old space
    • New space에서 마이너 가비지 컬렉션이 2번 발생했는데 살아남은 객체들이 이동하고 저장되는 영역
    • 여기 저장된 객체들을 Old generation 이라고 함
    • Old space는 또 두 영역으로 나눌 수 있다.
      • Pointer space: 다른 객체를 참조하는 객체들
      • Data space: 데이터 만을 가진 객체 (문자열, 실수 등)

약간 이런 느낌인 것 같아서 그려봤다. ㅋㅋ

약간 이런 느낌인 것 같아서 그려봤다. ㅋㅋ

가비지 컬렉터 (GC)


V8 엔진은 힙 영역을 New, Old 세대 영역으로 나눴다. 이때 New space 영역은 2개의 semi space로 나뉜다. 객체들은 처음에 New space의 첫번째 semi space에 할당된다. 만약 GC로부터 한 번 생존한다면, 다른 semi space로 이동한다. 그리고 생존한 객체들이 또 한번 GC로부터 생존하면, Old space로 이동하게 된다.
https://fe-developers.kakaoent.com/2022/220519-garbage-collection/

이미지 출처

New space의 객체들은 생명주기가 짧다는 특징이 있고, Old space는 메모리 사이즈가 크다는 특징이 있다. 이때 가비지 컬렉터는 각 영역에 최적화된 별개의 가비지 컬렉터를 사용한다. New space의 가비지 컬렉터는 Minor GC이고, Old space의 가비지 컬렉터는 Major GC이다. Minor와 Major GC는 각각 다른 방식으로 동작한다.

V8에서 이렇게 객체들의 생명 주기에 따라 GC를 별도로 둔 것은,

The Generational Hypothesis에 의한 결정이라 할 수 있다. 이 가설은 대부분의 경우 새로운 객체가 오래된 객체보다 쓸모 없어질 가능성이 높다는 가설이다. 즉 오래된 객체는 재 사용될 가능성이 높으니 GC가 모든 객체를 매번 검사하는 것이 비효율적이라고 판단한 것이다.

1. 마이너 GC


마이너 GC는 New space 영역에서 활약한다고 했다. New space영역의 객체들은 앞서 말한 것처럼 Old space 객체들보다 자주 수집 될 가능성이 높다.

마이너 GC로부터 살아남은 객체들은 항상 새로운 곳으로 대피(evacuation) 한다.

대피 과정은 다음과 같다.

이미지 출처
  1. 2개의 semi space가 존재하고, 대피 과정을 위해 항상 1개의 semi space는 비어 있다.
  2. 비어 있는 영역을 To space, 객체들이 머무르는 영역을 From space라고 부른다.
  3. From space에서 To space로 이동할 때 살아남은 객체들은 연속적인 메모리로 이동한다. 따라서 메모리 단편화를 주기적으로 방지해준다는 장점이 있다. 그리고 객체는 새로운 메모리 주소값으로 포인터가 갱신된다.
  4. From space에서 To space로 생존한 객체들의 대피가 완료되면, From space에 남아있는, 더 이상 쓸모 없는 객체들을 버린다.
  5. 마지막으로 From space와 To space의 역할을 서로 바꿔준다.
  6. 이제 새로운 객체가 할당된다고 가정해보자. 먼저 From space의 다음 빈 주소에 객체가 할당되며, 새로운 객체는 생존하여 To space로 대피한다. 하지만 기존에 한 번 생존했던 4개의 객체는 또 한 번 생존하면 To space가 아닌 Old space로 이동한다.

2. 메이저 GC


Old space에 있는 객체들은 Mark-Sweep-Compact 알고리즘과 Tri-color 알고리즘을 사용하는 메이저 GC에 의해 가비지 컬렉션된다.

기본적으로 루트에서 도달할 수 없는 객체를 쓸모 없는 객체로 간주하며,
구체적으로는 마킹 - 스위핑 - 압축의 3단계로 나누어 동작한다.

1. 마킹

가비지 컬렉션 대상이 어떤 객체인 지 알아내기 위한 단계이고, Roots (실행 스택 + 전역 객체)라는 객체의 set 부터 시작해서 DFS 방식으로 객체들을 순회하며 Tri-color(white, gray, black)으로 마킹한다.

  • white는 GC가 아직 탐색하지 않은 상태
  • gray는 탐색했지만 연결된 객체가 있는 지 확인하지 않는 상태
  • black은 연결된 객체까지 확인한 상태

마킹은 다음 단계로 이루어진다.

이미지 출처
  1. 모든 객체를 white로 마킹한다.
  2. root 객체를 회색으로 마킹하고 덱 자료구조에 push_front 한다.
  3. 덱에서 pop_front를 하여 객체를 꺼내고 꺼낸 객체는 검은색으로 마킹한다.
  4. 꺼낸 객체가 참조하는 객체들을 회색으로 마킹하고 덱에 push_front한다. 이때 회색이나 검은색일 경우 방문된 객체이므로 덱에 push_front 하지 않는다.
  5. 위 과정을 반복하며 덱이 빌 때까지 반복한다.
  6. 그러면 최종적으로 모든 객체들은 흰색이거나 검은색으로 변한다.

2. 스위핑

마킹 과정 이후 여전히 흰색으로 마킹되어 있는 객체들의 메모리 주소를 free-list 라는 자료구조에 추가한다. 이제 free-list로 옮겨간만큼 메모리 여유 공간이 생겼고, 이 공간에 새로운 객체를 저장할 수 있게 된다.

3. 압축

메모리 단편화가 심한 페이지들을 재배치하여 추가적인 메모리를 확보한다.

Orinoco 프로젝트


이러한 마이너 GC와 메이저 GC가 활약할 때는 프로그램이 멈추게 된다.

이러한 현상을 stop-the-world 라고 부른다. 이 시간이 길어질수록 페이지가 느려지거나 렌더링이 지연되어 사용자들은 UX 경험이 좋지 못할 것이다.

이에 Orinoco 프로젝트를 통해 GC는 여러 최적화를 거쳐 발전해왔다. 최신 GC에는 어떤 시술이 추가 되었는지 알아보자.

Parallel (평행)

[*https://v8.dev/blog/trash-talk*](https://v8.dev/blog/trash-talk)

https://v8.dev/blog/trash-talk

기존에는 메인 스레드가 혼자 하던 일을 헬퍼 스레드들과 균등히 나누어 일하는 방식이다.

스레드 간 동기화를 처리해야 해서 오버헤드가 생기지만 stop-the-world 시간이 크게 감소한다.

Incremental (증분)

[*https://v8.dev/blog/trash-talk*](https://v8.dev/blog/trash-talk)

https://v8.dev/blog/trash-talk

메인 스레드가 적은 양의 작업을 간헐적으로 처리하는 방식이다. 메인 스레드에서 가비지 컬렉션에 소요하는 시간이 분산되어 좋은 UX를 제공할 수 있다.

Concurrent (경쟁 상대, 공동으로 작용)

[*https://v8.dev/blog/trash-talk*](https://v8.dev/blog/trash-talk)

https://v8.dev/blog/trash-talk

메인 스레드는 더 이상 가비지 컬렉션을 하지 않고, 헬퍼 스레드들이 수행한다. 기술적 구현은 어렵지만 메인 스레드의 stop-the-world 시간이 전혀 없다는 큰 장점이 있다.

Idle-time GC (유휴 시간 가비지 컬렉션)

[*https://v8.dev/blog/trash-talk*](https://v8.dev/blog/trash-talk)

https://v8.dev/blog/trash-talk

V8은 크롬과 같은 embedder에게 가비지 컬렉션을 유발할 수 있는 메커니즘을 제공한다. (개발자는 GC에 직접 접근 불가능) 크롬은 프로그램이 쉬는 free나 idle time을 포착해서 다음 작업 전까지 가비지 컬렉션을 유발한다.

예를 들어 1초에 60프레임을 제공하는 크롬은 1프레임을 렌더링 하기 위해 약 16ms(1s / 60)가 소모된다. 만약 애니메이션 프레임 렌더링 작업이 16ms 보다 빨리 끝나면, 크롬은 다음 프레임 작업 전까지 가비지 컬렉션을 유발하는 것이다.

Reference

  1. JavaScript의 메모리 관리 - JavaScript | MDN
  2. 가비지 컬렉션
  3. [JavaScript] - 자바스크립트 엔진 및 메모리 구조
  4. 자바스크립트 v8 엔진의 가비지 컬렉션 동작 방식 | 카카오엔터테인먼트 FE 기술블로그

0개의 댓글