상세 컨텐츠

본문 제목

JavaScript | V8 엔진의 메모리 관리

프로그래밍 언어/JavaScript

by hyuga_ 2024. 3. 27. 21:18

본문

처음 JavaScript를 공부했던 1월에 자바스크립트의 동작 구조를 간단히 살펴본 바 있다.

직전 포스팅에서는 실행 컨텍스트 개념을 정리하였다.

그리고 최근에는 가비지 컬렉션(Garbage Collection)의 기본적인 개념에 대해서 살펴보았다.

 

이 두 개념에서 조금 더 나아가서, JavaScript에서 메모리 관리가 어떻게 이루어지는지를 전반적으로 알아보도록 하자! 

 

(잠깐 복습)

 

기본적으로 콜 스택(Call Stack)은 현재 실행 중인 함수의 실행 컨텍스트들을 쌓는 공간이다. 

[실행 컨텍스트 복습]

각 함수 호출은 실행 컨텍스트(Execution Context)라는 단위로 콜 스택에 쌓이게 된다.
실행 컨텍스트는 해당 함수의 실행에 필요한 모든 정보를 포함하는데, 여기에는
- 변수 환경(Variable Environment),
- 스코프 체인(Scope Chain),
- this 바인딩,
- 함수 내부에서 선언된 지역 변수와 매개변수(원시형 데이터 포함) 등이 포함된다.

 

 


 

1. JavaScript 에서의 메모리 할당 방법

'JS에서 선언된 데이터들은 데이터타입별로 어디에 저장되는가'를 결론부터 얘기하면

  • 변수에 원시 타입의 값을 할당할 때, 그 값(데이터)스택 메모리에 저장된다.
    • 변수의 이름(식별자)은 실행 컨텍스트의 렉시컬 환경에 존재하며, 스택에 저장된 해당 값의 위치(주소)를 가리키게 된다.
    • 원시 타입의 데이터는 불변성을 가지며, 변수에 새 값을 할당하면 새로운 메모리 위치에 값이 저장되고 변수는 새 메모리 주소를 참조하게 된다.
  • 변수에 객체, 배열, 함수와 같은 참조 타입의 데이터를 할당할 때, 실제 데이터는 힙 메모리에 저장된다.
    • 변수는 스택 메모리에 저장되며, 변수에 할당된 값은 힙에 저장된 데이터의 메모리 주소이다.
    • 변수의 식별자는 마찬가지로 렉시컬 환경에 존재하며, 변수가 참조 타입의 데이터를 가리키는 메모리 주소를 스택에 저장한다.

https://velog.io/@frank_kim/JS-%EC%9B%90%EC%8B%9C%ED%83%80%EC%9E%85-vs-%EC%B0%B8%EC%A1%B0%ED%83%80%EC%9E%85#%EC%9B%90%EC%8B%9C%ED%83%80%EC%9E%85-%EC%83%88%EB%A1%9C%EC%9A%B4-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EA%B3%B5%EA%B0%84-%EC%82%AC%EC%9A%A9

 

 

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

 

 

2. V8 엔진의 가비지 컬렉션 방식

스택 영역에 저장된 데이터들은 이후 OS에 의해 정리되지만, 힙 메모리는 그렇지 않다. 

따라서 JS가 가비지 컬렉션을 통해 이들을 정리하여 memory leak을 막는다. 

 

V8의 힙 영역 자세히 보기

V8은 힙 영역을 여러 space로 나눈다(New space, Old space, Large Object space, 코드 space, 셀 space, 속성 space, 맵 space). 그 중에서 GC가 관여하는 space는 New space와 Old space이다. 

 

New space는 새로운 객체 ~ GC로부터 한 번 살아남은 객체들이 저장되는 곳이며, Old space는 두 번 이상 살아남은 오래된 객체들이 저장되는 곳이다.

 

두 공간은 작동하는 GC도 다른데, New space는 마이너 GC, Old space는 메이저 GC가 작동한다. 마이너 GC는 객체들의 생명주기가 짧은 New space에서 빠르게 가비지 컬렉션을 하고, 메이저 GC는 메모리 사이즈가 큰 Old space에서 가비지 컬렉션을 한다.

 

이는 The Generational Hypothesis 라는 가설에 기반한 구조이다. 대부분의 경우 새로운 객체가 오래된 객체보다 쓸모없어질 가능성이 높다는 가설이다. 따라서 새로운 객체(New space 영역)들은 매번 검사하지만, 2번 이상 살아남은 Old space는 보다 최적화된 방식으로 검사함으로써 GC의 오버헤드를 줄이고자 하는 것이다.

 

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

 

 

New space(Young generation): 새로 만들어진 객체가 저장된다.

  • New space는 두 개의 Semi space로 이루어져있다. 
  • 이 Semi space들 중 하나는 새로운 객체가 들어가는 곳(from space)이고, 다른 하나는 GC로부터 살아남은 객체들을 보내는 to space이다. 
  • from space에서 to space로 갈 때, 해당 객체들은 메모리 상 연속된 공간에 배치된다. 이는 Fragmentation을 주기적으로 방지하는 효과가 있다. 
  • New space 메커니즘: [객체 생성 -> from space에 할당 -> 마크 앤 스위프 -> 살아남은 객체들은 to space로 -> from space 청소 -> to space와 from space의 역할 바꾸기 -> 마크 앤 스위프 -> 두번 살아남은 객체들은 Old space 로 (한번째 살아남은 객체들은 아까와 같이 to space)]

Minor GC evacuation -> Minor GC switch

 

minor GC twice live

 

 

 

Old space(Old generation): New space에서 마이너 가비지 컬렉션이 두 번 발생할 동안 살아남은 객체들이 저장된다. 이 영역은 다시 두 개로 나눌 수 있다.

  • Pointer space: 다른 객체를 참조하는 객체, 즉 다른 객체에 대한 포인터를 가진 객체
  • Data space: 문자열, 실수 등의 데이터만을 가진 객체

 

Major GC는 Mark-Sweep-Compact 알고리즘과 Tri-color 알고리즘을 사용한다. 기본적인 로직은 참조되지 않는 객체를 더 이상 쓸모없는 객체로 간주하는 방식으로, 다음 3단계를 거친다: [마킹 -> 스위핑 -> 압축]

  1. 마킹
    • 어떤 객체들이 가비지 컬렉션 대상인지 알아내기 위한 단계. Roots라는 실행 스택과 전역 객체를 담고 있는 객체의 set부터 시작해서 객체들을 dfs로 순회하며 Tri-color(white, gray, black)로 마킹한다. 
      1. white: GC가 아직 탐색하지 못한 상태
      2. gray: 탐색은 했으나, 해당 객체가 참조하고 있는 객체가 있는지 확인을 안한 상태
      3. black: 해당 객체가 참조하고 있는 객체까지 확인을 한 상태
  2. 스위핑
    • 여전히 흰색으로 마킹된 객체들의 메모리 주소를 free-list라고 부르는 자료구조에 추가한다. 이제 이 주소들의 메모리 공간은 사용 가능하여 새로운 객체가 저장 가능합니다.
  3. 압축
    • 메모리 단편화가 심한 페이지들을 재배치하여 추가적인 메모리를 확보한다.

 

 

 

3. V8의 가비지 컬렉션 퍼즈 해결 방식

이전에, 가비지 컬렉션이 동작하는 동안에는 프로그램이 멈추는, 즉 stop-the-world 발생이라는 부작용이 있음을 살펴보았다. V8에 기여하는 엔지니어들은 이를 해결하기 위해 다양한 방식을 도입하였다고 한다. 

 

간략하게 어떤 방식이 있는지만 살펴보자. 

1) Parallel 방식, 2) Incremental 방식, 3) Concurrent 방식, 4) Idle-time GC 방식

 

 

 


 

번외1. 그럼 JavaScript에선 Memory leak을 걱정하지 않아도 되는걸까?

JavaScript에 가비지 컬렉션이 있다고 해서 모든 메모리 누수를 걱정하지 않아도 되는 것은 아니다. 가비지 컬렉션은 대부분의 메모리 관리를 자동화하지만, 특정 상황에서는 여전히 메모리 누수가 발생할 수 있다.

 

메모리 누수의 일반적인 원인은 다음과 같다:

  1. 전역변수: 전역변수들은 애플리케이션의 생명주기 동안 메모리에서 제거되지 않는다. 따라서 의도치 않게 전역변수를 생성한다면 memory leak의 원인이 될 수 있다. 
  2. 이벤트 리스너: DOM 요소에 등록된 이벤트 리스너가 제거되지 않고 남아 있는 경우, 해당 DOM 요소와 리스너가 계속 메모리를 점유할 수 있다.
  3. 클로저: 클로저 사용 후에, 더 이상 필요하지 않은 외부 변수를 참조하고 있을 경우 memory leak을 유발할 수 있다.
  4. 타이머: setInterval 같은 타이머 함수가 취소되지 않고 남아 있는 경우, 마찬가지로 해당 함수가 참조하는 객체들이 메모리에서 해제되지 않을 수 있다.

 

 

 

번외2. JS 프로그래머가 할 수 있는 노력

그렇다면 코드를 짜는 개발자가 메모리를 최대한 효율적으로 쓰기 위해 어떤 노력을 할 수 있을까?

 

Q. 자바스크립트에서 메모리 누수를 방지하기 위한 베스트 프랙티스는 무엇인가요?

자바스크립트에서 메모리 누수를 방지하기 위한 베스트 프랙티스는 다음과 같습니다:

1. 전역변수 사용 최소화: 전역변수는 애플리케이션이 실행되는 동안 계속 메모리에 남아 있기 때문에 메모리 누수의 원인이 될 수 있습니다. 가능한 한 함수 내 지역 변수를 사용하고, 필요한 경우 모듈 패턴이나 즉시 실행 함수 표현식(IIFE)을 사용하여 글로벌 네임스페이스를 오염시키지 않도록 합니다.
(또한 전역변수는 오염될 가능성이 있으므로 그러한 문제를 방지하는 차원에서도 지역변수가 권장된다.)

2. 이벤트 리스너 정리: 페이지나 컴포넌트가 파괴될 때 이벤트 리스너를 명시적으로 제거하지 않으면, 해당 리스너가 계속해서 DOM 요소를 참조하게 되어 메모리 누수가 발생할 수 있습니다. 따라서, 필요하지 않은 이벤트 리스너는 removeEventListener 메서드를 사용하여 적절히 제거합니다.

3. 타이머 정리: setTimeout이나 setInterval 같은 타이머 함수를 사용할 때, 사용이 끝난 타이머는 clearTimeout이나 clearInterval을 호출하여 취소합니다. 이렇게 하지 않으면, 타이머 콜백이 계속해서 클로저에 있는 변수들을 참조하여 메모리 누수가 발생할 수 있습니다.

4. 클로저 사용 주의: 클로저는 강력하지만, 사용하지 않는 데이터를 계속 참조할 위험이 있습니다. 클로저를 사용할 때는 필요한 데이터만을 참조하도록 하고, 가능한 한 클로저의 수명을 짧게 유지합니다.

5. DOM 요소와의 직접 참조 제한: JavaScript 객체가 DOM 요소를 직접 참조하고 있을 경우, 해당 DOM 요소를 제거해도 JavaScript 객체의 참조로 인해 메모리에서 완전히 해제되지 않을 수 있습니다. 따라서, DOM 요소와의 참조를 가능한 한 줄이거나, 필요 없어진 참조는 null 처리를 통해 명시적으로 해제합니다.

6. 웹 애플리케이션의 메모리 사용 모니터링: 크롬 개발자 도구와 같은 브라우저의 개발자 도구를 사용하여 정기적으로 메모리 사용을 모니터링하고, 메모리 누수를 탐지합니다. 이를 통해 애플리케이션의 메모리 사용 패턴을 분석하고, 비정상적인 메모리 증가를 조기에 발견할 수 있습니다.

 

 

 

관련글 더보기