Javascript의 메모리 구조
개요
자바스크립트도 자바와 같이 가비지 컬렉션을 지원하는 언어이다. 자동으로 메모리를 관리해준다는 점이 편하긴 하지만 잘못 알고 사용하게 된다면 메모리 leak을 야기할 수 있기 때문에 공부한 내용을 적어보려 한다.
이 글을 많이 참고하였다.
콜 스택과 메모리 힙
V8로 대표되는 자바스크립트 엔진은 Memory Heap과 Call stack 메모리 영역을 가지고 있다.
Call stack
primitive 타입에 대한 데이터가 저장되는 곳이다. 실행 컨텍스트를 통해 변수 identifier 저장, scope chain 및 scope에 따른 this 관리, 코드 실행 순서를 관리한다.
Call stack에 저장된 변수는 immutable하게 관리된다.
즉 a = 1
을 할당하고 다시 a = 2
를 할당한다면 1과 2에 대한 주소 값은 서로 다르고, a
가 참조하는 주소 값이 다르게 할당된다. (Java의 String 처럼 이해하면 쉬울 듯)
Memory Heap
reference 타입에 대한 데이터가 저장되는 곳이다.
array (refernce 타입이다)에 대한 참조 역시 Java와 비슷하게,
변수 identifier는 stack 메모리에 저장되고, 값은 Heap 영역의 메모리 주소를 가르킨다.
javascript에서 array에 push()
한다면 힙 영역의 값에 원소가 추가된다.
때문에 타입이 지정되지 않는 바닐라 자바스크립트에서도 아래와 같은 코드에서 오류가 발생한다.
const a = []
a.push(1)
a.push(2)
a = 3 // error
위 코드에서 const
로 선언된 a 변수는 메모리 변경이 불가능해진다.
push()
했을 때에는 Memory Heap에 원소 값들을 추가한다.
하지만 a가 가르키고 있는 주소값은 call stack이고, 이에 대한 메모리 변경은 불가능하기 때문에 a = 3
코드에서 에러가 발생한다. (let 이었다면?)
자바스크립트의 메모리 생존 주기
메모리 할당
- OS로부터 메모리 할당 받는 주기
- C와 같은 언어라면 개발자가 명시적으로 할당해줘야 하지만..
- 우리가 다루는 언어는 개발자가 보통 하지 않는다. 자동으로 언어가 알아서!
메모리 사용
- 할당 받은 메모리를 프로그램이 사용
- 개발자는 코드 상에서 변수를 할당하고 변수의 값을 사용하여 로직을 구현
- 변수에 대한 접근 (읽기)와 변수 변경 (쓰기) 작업 시 메모리를 사용한다고 표현
메모리 해제
- 프로그램에서 더 이상 사용되지 않는 메모리를 다시 OS에 반환
- 할당과 같이 C라면 개발자가 명시적으로 해야 하지만 역시 메모리 해제 또한 개발자는 신경쓰지 않아도..
개발자를 머리 아프게 하는 메모리 해제 프로세스
할당된 메모리를 적절히 사용한 후에 적재적소에 반환되면 아주 좋을텐데 안타깝게도 머신 레벨에서 할당된 메모리가 필요 없어지는 시점을 알기란 불가능하다.
저수준의 언어라면 개발자가 판단해서 메모리를 반환하겠지만.. 고수준의 언어는 가비지 컬렉터가 이를 대신해준다.
여러가지 가비지 컬렉션 방법
1. Reference Count
대표적으로 현재 파이썬에서 사용하고 있는 메모리 관리 방법. 객체에 대한 참조 개수를 세고, 참조 개수가 0이 되었을 때 가비지 컬렉션 대상이 된다. 파이썬이 GIL이 생긴 이유 역시 파이썬이 근본적으로 Reference Count를 하기 때문이다. 물론 파이썬은 Reference count를 global하게 하기 때문에 멀티 쓰레딩 환경에서 문제가 된 케이스이고, Reference count 방식의 가비지 컬렉션이 가지는 치명적인 문제가 있다.
Circular Reference
순환 참조라고 한다. 두 객체가 생성되고 서로를 참조하는 경우 순환참조가 발생한다. Reference Count 방식의 가비지 컬렉션에서는 해당 객체들은 영원히 Reference count가 0이 되는 일이 발생하지 않는다. 따라서 잠재적인 메모리 누수의 위험이 있다.
2. Mark and Sweep
가비지 컬렉션의 핵심은 객체가 이후에 필요한지, 아니면 사용하지 않는지 여부를 판단하는 것이다. Mark and sweep 알고리즘은 객체의 reachable을 판단한다.
- 코드에서 참조되는 전역 변수를 루트라고 부른다. 브라우저에서는
window
와 같은 객체가 전역 변수로 동작할 수 있고, Node.js에서는global
이 루트이다. - 루트에서부터 쭉 자식을 내려가면 검사하여, 현재 활성화되어 있는지를 체크한다. 만약 루트에서부터 순회를 마쳤을 때 활성화되어 있다고 체크되어 있지 않은 것들은 가비지로 표시한다.
- 표시한 모든 메모리를 OS에 반환
Mark and sweep 알고리즘을 사용하면 순환 참조를 해결할 수 있다. 전역 변수에서 서로 참조하고 있는 두 객체의 경우는 닿을 수 없어 가비지로 표시되기 때문이다.
하지만 이 또한 문제가 있는데..
가비지 컬렉터는 너무 어려워
가비지 컬렉션에서 가장 중요한 것은 개발자는 가비지 컬렉션이 언제 일어날 지 모른다는 것인 것 같다. 가비지 컬렉터를 가진 또 다른 고수준 언어인 자바에서도 매번 겪는 문제이며, 실시간 웹 서비스를 제공하는 입장에서는 Full GC의 발생 자체가 곧 장애의 원인이 되기도 한다.
결국, 가비지 컬렉터가 메모리 관리를 대신해주는 고수준 언어에서는 메모리 누수를 개발자가 최대한 신경써서 막아야 한다.
자바스크립트의 메모리 누수
1. 전역 변수
자바스크립트에서는 선언되지 않은 변수를 사용했을 때 전역 객체 (루트)에 이를 할당한다.
function foo() {
bar = "hello";
}
위 코드는 브라우저라면 window
에, node.js라면 global
에 bar속성을 추가하게 된다.
전역으로 설정된 변수는 가비지 컬렉션 대상이 되기 어렵기 때문에 (스코프가 전역이기 떄문에) 전역 변수의 사용은 의도한 행위가 아니라면 신중을 가해서 사용해야 한다.
그리고 대부분의 경우 이렇게 권고되지 않는 행위는 회피하기 위한 더 나은 방법이 항상 있다. 대부분의 경우 전역 변수를 사용하지 않고도 문제를 해결할 수 있는 경우라 짐작해본다.
자바스크립트에서는 사용하지 않는 전역 변수 생성을 막기 위해 use strict
옵션을 사용하여 막을 수 있다.
2. 불필요한 타이머 / 콜백 함수
자바스크립트에서 옵저버나 콜백을 받는 함수들은 객체가 unreachable한 상태가 되면 가지고 있는 것들에 대한 reference도 unreachable하도록 해준다.
하지만 setInterval
이나 타이머와 같은 함수를 사용했을 때에는 상기 함수드를 조심스럽게 사용해야 한다.
인터벌이나 타이머와 같은 함수들은 시간 종속적이다. 일정 시간이 지나기 전까지는 해당 함수에 대한 reference를 들고 있기 때문에 그 시간 전에 인터벌이나 타이버가 내부로 참조하고 있는 데이터가 변경되어 더 이상 필요 없게 되더라도 계속해서 데이터를 처리해야 하는 입장에 놓인다.
때문에, 옵저버를 사용할 때에는 사용이 종료되었을 때 명시적으로 옵저버를 제거해줘야 한다.
현대 브라우저에서는 옵저버에 대한 명시적인 사용이 없어도 자동으로 참조를 제거해주기도 한다. 하지만 언제까지나 브라우저가 정상적인 상황일 때의 가정이므로, 개발자의 입장에서는 옵저버를 사용할 때 항상 메모리 해제를 명시적으로 해줘야 하는 것은 맞는 것 같다.
3. 클로저
클로저는 자신을 감싸는 바깥 함수의 변수에 접근할 수 있는 내부의 함수이다.
한번 동일한 부모 스코프에 있는 클로저들에 대한 스코프가 생성된 이후에는 해당 스코프가 공유된다. 이는 외부 스코프에서 내부 스코프에 대한 참조가 일어날 수 있다는 것이고, 해당 참조를 조심스럽게 다루지 않는다면 역시 잠재적인 메모리 누수에 대한 위험을 가지고 있다.
4. DOM에서 벗어난 요소 참조
DOM 노드를 데이터 구조에 저장할 때, 테이블 내 특정 열의 내용을 업데이트하는 상황을 가정해 보자. 각 열에 대한 참조를 딕셔너리나 배열에 저장하면 동일한 DOM 요소에 대해 서로 다른 두 개의 참조가 존재하게 된다.
- DOM 트리에
- 딕셔너리 혹은 배열에
만약 해당 열이 제거된다면 두 개 (또는 이상)의 참조가 모두 unreachable 하도록 변경해줘야 한다.
<td>
와 같은 셀 태그를 참조하고 있다가,- 해당 테이블을 DOM에서 제거했음에도 불구하고,
- 해당 셀에 대한 참조를 가지고 있다면
전체 테이블에 대한 메모리 누수가 발생한다.
셀은 DOM 트리 상으로 보았을 때 테이블에 대한 자식 노드이고, 자식 노드들은 부모에 대한 참조를 가지고 있기 때문이다. 즉
<td>
셀 태그에 대한 reference가 남아 있다면 전체 테이블에 대한 참조가 남아있게 되는 셈이다.
GC는 개발자를 도와주는 친구이다. 못되게 굴어도 너무 미워하지 말고 잘 지낼 방법을 우리가 찾자.