가비지 컬렉션은 프로그램에서 더 이상 사용하지 않는 메모리를 자동으로 정리하는 것이다. 이 기능을 가진 언어(혹은 엔진)는 자바, C#, 자바스크립트 등이 있다. C 언어 같은 저수준 언어에서는 메모리 관리를 위해 malloc()
과 free()
를 사용해 개발자가 스스로 메모리를 할당하고 해제해야 한다.
그러나 JavaScript는 C언어와는 반대로 고수준 언어로서, 객체가 생성되었을 때 자동으로 메모리를 할당하고 필요하지 않다면 자동으로 해제하는 가비지 컬렉션이 내장되어 있다.
고수준 언어와 저수준 언어는 무엇일까?
C언어가 저수준 언어라고 해서 고수준 언어인 JavaScript에 비해 뒤떨어지는 게 아니다. 프로그래밍 언어가 인간에게 친화적인지, 기계에게 친화적인지에 따라 고수준 언어와 저수준 언어로 갈리는 것이다.
저수준 언어는 보다 기계 친화적인 언어로 레지스터 및 메모리와 직접 상호 작용을 할 수 있기 때문에 전반적으로 빠르게 실행되는 응용 프로그램을 빌드하는 데에 사용된다. 또한 저수준 언어는 컴파일러나 인터프리터가 필요하지 않으므로 저수준 언어는 고수준 언어보다 빠른 편이다.
반대로 고수준 언어는 인간 친화적인 언어로, 인간이 이해하기 쉽고 다양한 작업을 수행하는 프로그램을 개발할 수 있다. 영어와 유사한 구문이 있기 때문에 컴파일러 또는 인터프리터를 사용하여 컴퓨터가 읽을 수 있는 기계어 코드로 변환해야 하며, 하드웨어와 직접 상호 작용하지는 않는다.
개발자가 직접 메모리를 할당하고 해제해야 하는 부분을 가비지 컬렉션이 도와주기 때문에, 개발자가 메모리 관리에 대해 고민할 필요가 없다는 잘못된 인상을 받을 수 있다. 실제로 지금까지 여러분은 작은 컴포넌트 및 앱을 개발하면서 메모리 할당 및 해제에 대해 깊은 고민을 하지 않았을 것이다. 그러나 가비지 컬렉션이 어떻게 동작하는지, JavaScript가 어떻게 메모리를 관리하는지 알아야 훗날 여러분이 개발한 앱의 속도 저하, 예기치 못한 종료, 느린 응답 속도와 같은 문제들이 왜 일어나는지 알 수 있다.
메모리 생존 주기는 그 어떤 프로그래밍 언어에 관계 없이 비슷하다.
2번의 할당된 메모리를 사용하는 것은 모든 언어에서 명시적으로 사용되는 부분이다. JavaScript로 예시를 들자면 개발자가 변수를 선언해 값을 할당하여 사용하는 부분이 되겠다. 그러나 1번과 3번은 C언어와 같은 기계친화적인 저수준 언어에서는 명시적이고, JavaScript와 같은 고수준 언어에서는 암묵적으로 작동한다. 따라서 여러분들이 직접 이 부분을 제어하지는 않았을 것이다.
JavaScript는 프로그래머 대신, 값을 선언할 때 자동으로 메모리를 할당해준다.
let arr = [100, 200, 300, 400]
여태껏 변수를 선언하고 배열을 할당하여 안에 요소를 집어넣을 때, 그 배열을 담을 메모리의 크기를 고려하지 않았을 것이다. 이 부분을 JavaScript가 배열과 배열에 담긴 값들을 위한 메모리 크기 할당을 알아서 진행했기 때문이다. 이 부분은 정수, 문자열, 함수, 객체 모든 부분에서 자동적으로 일어난다.
기본적으로 할당된 메모리를 읽고 쓰는 것을 의미한다. 변수나 객체 속성의 값을 읽고 쓰거나, 함수 호출 시에 함수에 인수를 전달하여 수행하는 방식으로 일어난다.
할당된 메모리가 더이상 필요 없다면 해제를 해야 앱의 성능을 저하시키지 않는다.
이 부분에서, 저수준 언어는 개발자가 직접 결정하고 해제하는 방식을 사용한다. 개발자가 직접 관여하기 때문에 개발자의 제어 정도가 굉장히 높은 편이다.
그러나 고수준 언어는 앞서 이야기 했듯 가비지 컬렉션이라는 자동 메모리 관리 방법을 내장한 상태이다. 가비지 컬렉션의 목적은 메모리 할당을 추적하고, 할당된 메모리 블록이 더이상 필요하지 않게 되었는지를 “스스로” 판단하여 필요하지 않다고 판단이 된다면 해당 메모리를 해제한다. 하지만 언어 스스로 메모리가 여전히 필요한지 필요하지 않은지 판단하는 것은 비결정적인 영역이다. 그래서 고수준 언어에 내장된 가비지 컬렉터들은 제한적인 해결책을 구현한다.
가비지 컬렉션 알고리즘은 이하 2가지 알고리즘이 가장 유명하다. 이 2가지 알고리즘이 의존하고 있는 개념은 참조(reference)이다.
참조(reference)
명시적이든, 암묵적이든 관계없이 메모리 관리 관점에서 어떤 객체가 다른 객체에 접근할 수 있다면 다른 객체를 참조한다고 말한다. 예를 들어서, JavaScript 객체는 자신의 프로토타입(prototype)에 대해 암묵적인 참조를 갖고 있고, 자신의 속성(property) 값에 대한 명시적 참조도 가지고 있다.
객체를 참조하는 것에 대해, 객체란 협의적 개념으로 일반적인 JavaScript 객체를 의미하지만 광의적 개념으로 함수 스코프(function scope)나 글로벌 렉시컬 스코프(global lexical scope)까지도 포함한다는 것을 알두자
렉시컬 스코핑(lexical scoping)
변수 이름이 중첩된 함수에서 해석되는 방식을 정의하는 것으로, 중첩되어 있는 더 안쪽의 함수는 부모 함수가 값을 반환한 다음에도 부모 함수의 스코프를 포함하고 있다.
한 객체를 참조하는 변수의 수를 추적하는 방법으로 가장 단순한 형태의 가비지 컬렉션 알고리즘이다.
객체를 참조하는 변수는 처음에는 특정 메모리에 대해 레퍼런스가 하나뿐이지만, 변수의 레퍼런스가 복사될 때마다 레퍼런스 카운트가 늘어난다. 객체를 참조하고 있던 변수의 값이 바뀌거나, 변수 스코프를 벗어나면 레퍼런스 카운트는 줄어든다. 레퍼런스 카운트가 0이 되면, 그 객체와 관련한 메모리는 비울 수 있다. 레퍼런스 카운트가 0이 된다는 말은 아무도 그 객체에 대한 레퍼런스를 가지고 있지 않다는 말과 같다.
이 방식은 순환 참조로 인한 문제가 생길 가능성이 높다.
function reference() {
var obj1 = {};
var obj2 = {};
obj1.p = obj2;
obj2.p = obj1;
}
reference();
위 코드에서는 두 객체가 생성되고 서로를 참조하고 있는 형태이기 때문에 순환 참조가 발생한다. 이 객체들은 함수 호출 뒤에는 스코프를 벗어나게 되므로 실질적으로 쓸모가 없게 된다. 그래서 이들이 차지하던 메모리는 반환될 수 있지만, 레퍼런스 카운팅 알고리즘에서는 두 객체가 적어도 한 번은 참조한 것으로 간주되기 때문에 둘 다 가비지컬렉션이 될 수 없게 된다.
한 객체에 flag를 두고, 가비지 컬렉션 사이클마다 flag에 표시 후 삭제하는 mark and sweep 방법이다.
객체에 in-use flag를 두고, 사이클마다 메모리 관리자가 모든 객체를 추적해서 사용 중인지 아닌지를 표시(mark)한다. 그 후 표시되지 않은 객체를 삭제(sweep)하는 단계를 통해 메모리를 해제한다. 현재 대부분의 가비지 컬렉션이 mark and sweep 알고리즘을 이용한 가비지 컬렉터를 장착하고 있다.
mark and sweep 알고리즘은 객체가 필요한지 결정하기 위해 해당 객체에 닿을 수 있는지 (reachable)을 판단합니다. 그리고 3단계를 거친다.
- 루트(Roots): 일반적으로 루트는 코드에서 참조되는 전역 변수이다. 예를 들어 자바스크립트에서 루트로 동작할 수 있는 전역 변수는 window 객체이다. Node.js에서 이와 동일한 객체는 global이다. 가비지컬렉터는 모든 루트의 완전한 목록을 만들어낸다.
- 그런 다음 모든 루트와 그 자식들을 검사해서 활성화 여부를 표시한다(활성상태이면 가비지가 아니다). 루트가 닿을 수 없는 것들은 가비지로 표시된다.
- 마지막으로 가비지컬렉터는 활성으로 표시되지 않은 모든 메모리를 OS에 반환한다.
이 방법은 앞선 레퍼런스 카운팅 방법보다는 나은데, ‘참조받지 않는 객체’는 ‘닿을 수 없는 객체’기 때문에 가비지 컬렉션을 통해 메모리를 해제할 수 있기 때문이다.
Garbage collected 언어에서 메모리 누수의 주요 원인은 예상치 못한 참조이다.
예상치 못한 참조는 개발자는 더 이상 사용되지 않을 것이라 생각했지만, 어떠한 이유로 활성화 상태인 루트 트리 안에 존재하는 메모리 조각들이다. 자바스크립트에서 예상치 못한 참조는 더이상 사용되지 않지만 코드 상 어딘가에 유지되어 해제되지 못한 변수들이다. 어떤 이들은 이를 개발자의 실수라고 말하기도 한다. 그래서 자바스크립트에서 발생할 수 있는 일반적인 메모리 누수 형태들을 이해하기 위해서는 흔히 까먹기 쉬운 참조들을 먼저 알 필요가 있다.
이런 메모리 누수는 일반적으로 3가지의 형태가 있다.
프로그래밍 언어의 메모리 관리 시스템이 특정 메모리가 실제 사용중인지 미사용중인지 완벽히 구분해내는 것은 사실상 불가능에 가깝다. 오직 그 코드를 작성한 개발자들만이 해당 메모리 조각을 운영체제로 반환시킬 수 있는지 여부를 명확히 알 수 있기 때문에 해당 부분들을 잘 확인하여 메모리 누수가 일어나는 부분을 막을 줄 알아야 한다.