Web: JavaScript의 Garbage Collection (V8 엔진)

Heechan
HcleeDev
Published in
16 min readSep 9, 2022
Photo by Pawel Czerwinski on Unsplash

최근에 회사 프로덕트에서 메모리 이슈가 터진 적이 있었다. 메모리 때문에 브라우저가 터지는 버그가 터지는 경우가 얼마나 있을까 싶었지만… 우리 프로덕트가 이미지 처리를 좀 많이 하고 있는데, 해당 로직을 담당하는 객체가 제대로 Garbage Collection 처리가 되지 않아 선을 넘어버렸던 것이었다.

그래서 이번주에는 JS에서는 Garbage Collection을 어떻게 하고 있는지 알아보고자 한다.

Garbage Collection은 무엇이고, 왜?

메모리는 무한하지 않다. 메모리가 무한했다면 최적화를 고민할 필요도 별로 없을 것이고, 가상 메모리 같은 기술도 나올 이유가 없을 것이다.

프로그램을 돌리다보면 미디어 데이터의 크기가 너무 크다거나, 뭔가 Heap 공간에 객체가 너무 많이 만들어졌거나 하는 이유로 프로그램이 아예 터져버리는 경우도 생긴다.

메모리는 유한하고, 한 번에 돌아가는 프로세스는 많기에 우리 프로그램이 할당 받는 메모리가 엄청나게 큰 것도 아니다. 그러면 우리는 한정된 메모리 공간 속에서 어떻게 알뜰살뜰하게 메모리를 사용할 방법을 찾아야 한다.

메모리를 할당할 자리가 부족할 때 어떻게 자리를 만들 수 있을까?

복잡한 식당을 생각해보자. 새로운 손님이 오면 바빠서 못건들고 있던 자리를 치우고 새로운 손님을 받는다.

메모리도 마찬가지다. 사용하지 않는 데이터를 치우고 그 자리에 새로운 데이터가 자리 잡을 수 있도록 하면 된다.

하지만 매번 용량이 꽉 찰 때까지 기다렸다가 새로운 데이터가 올 때만 슬쩍 한 구석을 치우기로 마음 먹었다면 그닥 보기 좋지 않다. 쓰레기들은 미리미리 치워두는 것이 좋다. 따라서 요즘 언어들은 거의 모두 메모리 관리를 위해 주기적으로 이 쓰레기들을 치우는 기능을 포함하고 있다. 이를 Garbage Collection 개념이라고 한다.

출처: 위키백과

위 움짤을 보면, 메모리에 데이터가 쌓이다가, 쓸모 없어진 데이터를 지워서 공간을 확보하고 있다. 언어마다 이 방식은 다 다르겠지만, 어떠한 기준에 따라 필요없어진 데이터를 지우고 공간을 확보한다는 점은 같다.

이 Garbage Collection 기능의 가장 큰 장점은 당연하게도 메모리 누수를 막을 수 있다는 점이다. 굳이 개발자가 엄밀하게 생각하지 않아도 메모리 자리가 없어서 크래쉬가 발생할 확률이 굉장히 낮아진다.

그리고 직접 메모리 할당, 해제를 해주는 언어에서 흔히 발생하는 잘못된 주소에 접근, 이중 해제 같은 버그도 발생하지 않는다.

하지만 단점도 있다. 아까 말했지만 쓸모 없어진 데이터를 판단하는 기준이 언어마다 다르고, 상황에 따라 이를 계산하는 것 자체가 오래 걸릴 수도 있다는 점이다. Garbage Collection이 오래 걸릴 경우 프로그램이 잠깐 멈추거나 할 수도 있다.

또한, 개발자가 GC가 언제 어떻게 일어날지 추적하기가 힘들어진다. 객체를 코드 상에서는 null 을 할당하거나 해서 해제한 것 같지만, 실제로 메모리에서 없어지는 것은 GC가 돌아간 이후일 것이라 정확히 파악하기가 쉽지 않다.

아무래도 가장 큰 문제는 개발자가 메모리에 대해 고민하지 않아도 된다는 나쁜 버릇을 들게 한다는 점이다. 이런 GC가 있다고 하더라도 가끔 문제가 생길 때가 있다. 그럴 땐 오히려 고려 안하다가 해야 하니까 머리가 진짜 아파진다…

Garbage Collection의 대표적인 방식

Garbage Collection에서 쓸모없는 데이터가 무엇인지 가려내는 알고리즘은 상당히 중요하다. 그 대표적인 방식을 슬쩍 훑고 가려고 한다.

첫 번째는 Reference Counting이다.

해당 객체를 참조하는 녀석이 0이면 쓸모없는 데이터로 간주하고 지우는 방식이다. 예를 들어 아래와 같은 코드가 있다고 치자.

let user = { name: "Heechan" };user = null;

처음에 user 에 객체에 대한 참조값이 할당된다. 그러면 이제 { name: "Heechan" } 이라는 객체가 메모리 어딘가에 자리잡고 있다는건데, 바로 다음 코드에서 user = null;user 가 더 이상 이 객체를 바라보지 않게 했다.

이렇게 되면 이 객체는 참조 수가 0이 되어 GC의 대상이 되고, 메모리에서 사라지게 된다.

하지만 이 방식의 가장 큰 문제점은 순환 참조의 경우다.

function f() {
var x = {};
var y = {};
x.a = y; // x는 y를 참조합니다.
y.a = x; // y는 x를 참조합니다.

return "ehh";
}

f();

원래라면 이 경우 함수의 호출이 완료되면 xy 는 쓸모없는 객체가 되므로 할당이 해제되어야 한다. 하지만 서로가 참조하고 있으므로 Reference Count가 0이 되지 않는다. 이러면 전혀 쓸모없는 객체가 메모리에서 사라지지 않고 자리만 차지하게 된다.

이런 순환 참조로 인해 실제로 접근될 일이 없음에도 메모리에서 사라지지 않는 문제를 해결하기 위해, 주로 다른 방식을 이용한다.

그 방식은 Mark-and-Sweep 방식으로, 실제로 이 메모리에 Reachable, 즉 닿을 수 있는지 여부를 중요하게 판단한다.

이 알고리즘은 쓸모없는 데이터를 닿을 수 없는 데이터로 생각하기 때문에, Root로부터 출발해서 하나하나 닿는지 확인하다보면 바로 위에서 본 순환참조 예시의 경우에는 쓸모없는 데이터로 판단되어 지워질 수 있을 것이다.

이 방식의 기본 개념은 굉장히 간단하다.

출처: https://ko.javascript.info/garbage-collection

처음에는 Root에 마킹을 하고, Root로부터 뻗어나가 접근할 수 있는 모든 객체에 마킹을 해둔다.

위 이미지를 보면 오른쪽에 있는 세 개의 객체로 이루어진 섬이 있는데, 여기는 끝까지 닿지를 않으니 Unreachable한 녀석들로 분류할 수 있다.

GC 후 저 3개의 객체는 마킹이 되어있지 않으므로 메모리에서 삭제될 것이다.

이러면 순환 참조 걱정을 할 필요없이, 보다 효과적인 메모리 관리가 가능해진다.

JavaScript에서의 Garbage Collection — V8 엔진

학부 2학년, 3학년 때 시스템 프로그래밍, 운영체제 수업을 들으면서 울며 겨자먹기로 함께 했던 C언어를 생각해보자.

C언어는 요즘 우리가 사용하는 언어들에 비하면 꽤 Low한 언어다. 뭔가 동적으로 메모리를 사용하기 위해서는, malloc 같은 메서드를 이용해 메모리 공간을 요청 및 할당해야 하고, 다 쓰고 나면 free 로 지워줘야 한다. 운영체제에서 PintOS 과제를 하며 free 를 어디서 안써줬는지 찾느라 진땀 흘렸던 기억이 난다.

반면 요즘 우리가 사용하는 언어는 애당초 Garbage Collection을 염두에 두고 설계된 언어도 많고, 어지간하면 메모리 관리를 하기 위한 로직이 들어가있다.

JavaScript 또한 개발자를 할당과 해제의 늪에 가급적이면 빠지지 않도록 Garbage Collection을 지원한다. 정확히 말하면 브라우저에 달려있는 엔진이 지원하는건데… 과거 인터넷 익스플로러 같은 경우에는 Reference Count 방식으로 GC를 진행하기도 했지만, 최신 브라우저(2019년 기준, MDN 문서)는 모두 Mark-and-Sweep 방식을 나름 발전시켜 사용하고 있다고 한다.

그 중 대표적으로 Chrome의 V8 엔진이 돌아가는 방식을 알아보려고 한다.

JavaScript에서는 Garbage Collection이 자동으로 쓰레기를 치워줄 뿐만 아니라, 할당도 자동으로 해둔다. 개발자가 코드 상에서 뭔가 선언하면 그때 자동으로 메모리 공간을 요청하고, 데이터를 할당한다.

원시 타입의 경우에는 메모리의 ‘스택’ 공간에 할당되지만, 함수, 객체, 배열 같은 경우에는 메모리를 동적으로 할당할 수 있는 ‘힙’ 공간에 할당된다.

우리가 메모리 관리에서 신경써야 하는 부분은 힙 공간이다. 지금부터 알아볼 GC 과정도 힙 공간에서 발생한다.

V8 엔진에서는 이 메모리 공간을 아래와 같이 나누고 있다. 특히 Heap Memory 쪽을 주목하면 된다.

출처: https://deepu.tech/memory-management-in-v8/

Heap Memory가 여러 구역으로 나뉘어져있는데, 중요한건 위에 있는 두 가지다. New space와 Old space가 GC의 핵심 기능을 한다.

Large object space는 다른 공간의 용량을 넘어서는 큰 객체를 저장하기 위한 공간이다. Code space는 코드 객체를, Cell, Property Cell, Map space도 해당하는 객체들을 저장하는 공간이라고 하는데, 이에 대해서는 잘 모르겠다. 다만 이 친구들은 사이즈가 정해져있어서 GC 계산 과정에 도움을 준다고 한다.

중요한건 New space와 Old space니 그에 대해 알아보자.

New space의 Minor GC

V8 엔진은 단순한 Mark-and-Sweep 방식이 아닌, Generation 가설을 추가적으로 적용한다.

생각해보면 이 프로그램에 있는 거의 모든 객체를 돌아다니면서 마킹하고 치운다는건 간단한 작업이 아니다. 좀 더 효율적으로 하는 방법이 있을 것이다.

사실 대부분의 객체는 길게 살지 않는다. 금방 사라지게 되고, 몇몇 객체만 꽤 오랜 시간 살아있게 된다. V8 엔진은 이 아이디어를 이용해 공간을 두 개의 Generation으로 나누었다.

New space가 Young Generation이다. New space는 1~8MB 정도 크기의 공간으로, 여기엔 단순히 데이터를 쌓아올리는 방식으로 굴리므로 할당이 쉽다.

그러다가 New space의 공간이 가득 차면 New space에서 Minor GC를 행한다. Minor GC의 방식은 Scavenge라고 불린다. 이 방식은 용량이 비교적 작은 New space에서 발동되는 만큼 자주 불리고, 그에 따라 굉장히 빠르게 진행되어야 한다. 그래서 이 Scavenge는 Cheney’s algorithm을 기반으로 만들어졌다고 하는데, 그 과정에 대해 간단히 알아보자.

기본적으로 New space는 2개의 Semi space로 나뉜다. 그리고 둘 중 하나를 From-space로, 하나를 To-space로 생각한다.

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

From-space에 객체가 계속 쌓여서 Scavenge가 실행되면, 다른 곳에서 참조되고 있는 메모리만 따로 To-space에 옮긴다. 그 후 From-space는 정리한다. 이러면 필요한 녀석은 To-space에 들어가고, 쓸모없는 애들은 정리된다.

그 후 From과 To의 역할을 서로 바꾼다. (실제 알고리즘에서는 일단 바꾸고 시작하긴 하던데, 뭐 큰 차이는 없어보인다)

바꾸고 시간이 지나니 또 From-space의 공간이 가득 찼다. 이때 다시 Scavenge가 발생하는데, 위 이미지는 아까 살려두었던 4개의 메모리 공간(정확히 말하면 페이지 단위다)과 새로운 하나의 메모리 공간이 쓸모있는 데이터로 인정받았다. 이때 이미 이전에 한 번 살아남았던 메모리가 2번째 살아남을 경우 Old space로 승격된다. 그게 아닌 나머지 하나는 To-space로 옮겨진다.

대부분의 객체가 생기고 빠르게 사라지는 것이 맞다면 위와 같이 Generation을 나누어 New space에서만 빠른 속도의 Scavenge를 진행하는 것은 상당히 효과적으로 보인다.

그건 알겠는데, 사실 이 메모리가 어디서 참조되고 있는지는 어떻게 판단하는 것인가 싶다. New space 내에서 서로 참조하고 있거나, root에서 바로 연결된 메모리면 모르겠지만, Old space로 넘어간 수도 없이 많은 객체를 또 한번 싹 훑어서 체크하면 시간만 오래걸리고 괜히 Generation을 나눈 보람도 없다.

그래서 V8 엔진에서는 Write Barriers라고 불리는 기능을 포함하고 있다. Old space에서 New space를 향하는 포인터의 리스트를 저장하고, 이를 이용해 New space의 참조 현황을 확인해 GC를 진행할 수 있다.

괜히 저장해야 하는 정보가 늘어난다, 계산할게 늘어난다고 볼 수도 있겠지만 GC를 좀 더 효율적으로 할 수 있는 방안이다. 또한, 이를 좀 더 빠르게 하기 위해 노력한 점도 있다고 한다.

Old space와 Major GC

New space에 비해 큰 공간을 가지고 있기 때문에 작은 공간을 반으로 나눠서 체크하는 Scavenge 방식은 적절치 않다. 대신 Old space의 공간이 부족하다고 생각되면 Mark-and-Sweep 방식으로 GC를 실행한다. 따라서 Old space에서는 GC가 자주 일어나지 않는다.

Major GC는 3가지 과정으로 나눌 수 있다.

  • Marking

루트로부터 DFS 방식으로 연결된 객체들을 쭉 돌면서 Marking한다. 일단 처음 닿은 객체는 회색으로 Marking하고, 회색으로 Marking된 부모 객체와 연결된 아이들을 회색으로 Marking한 후 부모 객체를 검은색으로 Marking한다.

출처: https://v8.dev/blog/concurrent-marking

이 과정을 도달할 수 있는 모든 객체에 대해서 실행한다. 참고로 이 마킹은 메모리 공간 끄트머리에 따로 비트 공간을 마련해서 관리한다고 한다.

  • Sweep

위 Marking 과정에서 표시가 되지 않은, 아직 흰색인 객체들은 치워버린다. 해당 공간은 다시 사용해도 되는 메모리 리스트인 Free list에 등록한다.

  • Compacting

여기저기 파편화된 메모리를 다시 컴팩트하게 정렬하는 과정이다. Heap을 압축한다고 생각하면 된다.

출처: https://engineering.linecorp.com/ko/blog/go-gc/

조각 모음의 추억이 떠오른다.

이렇게 하면 단편화를 방지하고, 언제나 Heap 공간 끄트머리에 새로운 객체를 붙이면 되기 때문에 괜히 어디에 판단할지 고민하는 과정이 필요없어진다는 장점이 있다.

V8의 GC 최적화 프로젝트, Orinoco

GC가 진행되는 동안 원래는 Main Thread의 기존 작업이 멈추게 된다. 따라서 GC 시간이 길어지면 유저 입장에서는 영문 모를 렉이 걸릴테고, 좋지 않은 유저 경험을 안겨주게 될 것이다. 뭐 Minor GC야 얼마 안걸리는 경우가 많지만 Major GC는 시간이 꽤 걸린다. 처음에는 GC 때문에 0.5~1초씩 멈춰있는 경우가 흔했다고 한다.

그래서 V8 엔진은 이 GC를 최적화를 하기 위한 Orinoco 프로젝트를 진행했다. 그 결과로 몇 가지 최적화 방식이 추가되었다.

첫 번째는 Incremental이다. GC 작업을 잘게잘게 쪼개어서 Main Thread 작업 중간중간에 끼워넣는 것이다.

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

GC 작업을 5~10ms 정도의 작은 테스크로 쪼개서 위 이미지처럼 진행한다. 이러면 앱이 아예 멈추는 일까지는 생기지 않는다.

Incremental 방식은 이 이후 Sweep을 Lazy하게 진행했다. 바로 치우는게 아니라 어디에 어느정도 메모리를 이용할 수 있다는 정보만 알고 있다가, 메모리 공간이 필요할 때 치우고 새롭게 할당하는 방식을 이용했다. 다만 이 방식은 지금은 잘 사용되지 않는 것으로 보인다.

두 번째는 Parallel이다. 말그대로 여러 스레드에서 병렬적으로 처리하는 방식이다.

Main Thread를 잠깐 멈추는 방식이긴 하지만, 추가적인 Helper Thread를 이용해 빠르게 처리하기 위한 방식이다. 근데 JS는 싱글 스레드로 알고 있는데 어째서 추가 스레드가 있는건가 싶었는데, JS 코드가 돌아가는 곳이 Main Thread인 것이지 추가적인 Work Thread는 이용할 수 있다고 한다.

현재는 원래도 빠르게 진행되는 Minor GC, Scavenge를 더욱 빠르게 진행하기 위해 Parallel 방식을 이용한다고 한다.

세 번째는 Concurrent다.

Main Thread에서 코드가 돌아감과 동시에 Helper Thread에서 GC를 동작시키는 방식이다. 코드를 실행시키는 동안 Heap 구성이 또 바뀔 수도 있기 때문에, 굉장히 어려운 방식이다. 그래서 각 스레드 사이의 중간 작업을 Synchronize 해주는 과정이 들어가는 것으로 보인다.

Major GC는 단계별로 적용되는 방식이 다르다. Marking하는 과정은 Concurrent로 진행된다.

Marking이 완료되면 Main Thread는 Marking을 빠르게 마무리하고 Parallel 방식으로 남은 과정을 진행한다. Sweep Task, Compact Task를 나누어서 처리한다.

근데 위 이미지를 보면 Sweep이 다 안됐는데 Compact를 어떻게 하나? 싶기도 하다. 해당 블로그 글을 좀 읽어보니 모든 페이지가 Compact의 대상이 될 수는 없다고 한다. 아마 완벽하게 처리할 수는 없어도, 사용자 경험을 위해 10ms 이내로 마무리하고, 남은 부분은 차차 진행하는 방식으로 결정한 것이 아닐까 추측해본다.

결론

JavaScript는 객체의 메모리 주소를 보거나, 객체를 수동으로 직접 해제할 수 있는 방법을 제공하고 있지 않다. 그거 때문에 좀 답답한 경우도 있다.

GC가 이렇게 설명을 보면 그렇구나~ 하는데, 실제로 이걸 구현하기는 상당히 어렵겠다는 생각이 든다. 나도 그냥 겉핥기로 이정도까지만이라도 알아두어야겠다…

GC를 해주는건 개발하는 입장에선 굉장히 고맙지만, 가끔 그 알고리즘의 허점에 당해서 Memory Leaks를 찾아내야 하는 상황에는 진짜 어렵다. 최근 회사에서 검증할 때도 굉장히 어려웠다. 오히려 평소에 편하게 해줘서 개발자로서 버릇이 나빠진거려나 싶기도 하다.

참고한 것

--

--

Heechan
HcleeDev

Junior iOS Developer / Front Web Developer, major in Computer Science