그러냐

자바스크립트는 어떻게 작동하는가: 메모리 관리 + 4가지 흔한 메모리 누수 대처법 본문

javascript

자바스크립트는 어떻게 작동하는가: 메모리 관리 + 4가지 흔한 메모리 누수 대처법

관절분리 2019. 11. 19. 19:09
반응형

How JavaScript works: memory management + how to handle 4 common memory leaks

이 글은 원저자 Alexander Zlatkov의 허락을 받아 How JavaScript works: memory management + how to handle 4 common memory leaks을 번역한 것입니다.

몇 주 전 우리는 자바스크립트를 좀 더 깊이 살펴 보고 실제로 어떻게 작동하는지 알아보는 시리즈를 시작했습니다. 이 시리즈의 동기는 자바스크립트가 무엇으로 구성되어 있고 그 구성요소가 어떻게 맞물려 작동하는지 더 잘 알면 더 나은 코드와 앱을 작성할 수 있다는 생각이었습니다.

시리즈의 첫 번째 글은 엔진, 런타임, 콜스택의 개관에 초점을 맞췄습니다. 두 번째 글에서는 구글의 V8 자바스크립트 엔진의 내부 구성에 대해 자세히 살펴보고 더 나은 자바스크립트 코드 작성을 위한 몇 가지 팁도 제시했습니다.

세 번째인 이번 글에서 우리는 중요한 주제에 대해 다뤄볼까합니다. 바로 메모리 관리입니다. 개발자들이 매일 사용하는 프로그래밍 언어가 점점 더 성숙해지고 복잡해졌기 때문에 메모리 관리를 더욱 무시하는 경향도 생기는 듯 합니다. 또한 세션스택에서 따르고 있는 메모리누수 대처법에 대한 몇 가지 팁도 알아봅니다. 세션스택에서는 우리의 서비스와 결합되는 고객의 웹 앱에서 메모리 누수가 일어나거나 메모리 소비가 증가하지 않도록 하기 위해 이런 팁을 활용하고 있습니다.

개관

C와 같은 언어에는 malloc()나 free()와 같은 저수준의 메모리 관리를 위한 원시함수(primitive)를 존재합니다. 개발자들은 이들을 이용해 명시적으로 운영체제로부터 메모리를 할당 받거나 돌려주는 작업을 합니다.

자바스크립트에서는 무언가(객체, 문자열 등)가 생겨날 때 메모리가 할당되며 이들이 더 이상 사용되지 않을 때는 ‘자동으로’ 메모리가 반환되는데 이런 과정을 가비지컬렉션(garbage collection)이라고 합니다. 이렇게 자원을 반환하는 것이 자동으로 일어나는 것처럼 보이는 것 때문에 자바스크립트 개발자들(또한 다른 고수준 언어의 개발자들도)은 자신들이 더 이상 메모리관리를 신경쓰지 않아도 된다는 잘못된 생각을 하고 있습니다만 이것은 대단히 큰 실수입니다.

고수준 언어를 사용할 때도 개발자들은 메모리 관리에 대해 이해하고 있어야 합니다. 비록 기본적인 수준이어도 좋습니다. 자동 메모리 관리는 구현상의 제한이나 버그 때문에 문제가 있을 수 있으며 이때 개발자들이 이런 문제를 적절히 해결하거나 최소한의 트레이드오프와 기술부채로 적절한 우회법을 구현하려면 메모리 관리에 대해 이해해야 합니다.

메모리 생명주기

어떤 언어를 사용하든간에 메모리 생명주기는 거의 비슷합니다.

아래는 메모리 생명주기의 각 단계에 대한 대략적인 설명입니다.

  • 메모리 할당(allocate): 프로그램이 사용할 수 있도록 운영체제가 메모리를 할당합니다. 저수준 언어(예를 들어 C)에서는 이를 개발자가 명시적으로 처리해줘야 합니다. 그러나 고수준 언어에서는 개발자가 신경쓸 필요 없습니다.
  • 메모리 사용(use): 이제 할당된 메모리를 실제로 프로그램이 사용하는 단계입니다. 개발자가 코드 상에서 할당된 변수를 사용함으로써 읽기 쓰기 작업이 이루어집니다.
  • 메모리 해제(release): 프로그램에서 필요하지 않은 메모리 전체를 되돌려주어 다시 사용가능하게 만드는 단계입니다. 메모리 할당 작업과 마찬가지로 저수준 언어에서는 이를 명시적으로 처리해야 합니다.

콜스택과 메모리힙의 개념에 대한 짧은 개관은 첫 번째 글을 참고하십시오.

메모리란 무엇인가?

자바스크립트에서의 메모리에 대해 알아 보기 전에 일반적인 의미에서 메모리란 무엇이고 어떻게 작동하는지 간략하게 살펴보겠습니다.

하드웨어 수준에서, 컴퓨터 메모리는 많은 수의 플립플롭(flip flop)으로 구성되어 있습니다. 각각의 플립플롭은 몇 개의 트랜지스터를 갖고 있으며 하나의 비트를 저장할 수 있습니다. 개별 플립플롭은 고유한 식별자(unique identifier)를 통해 위치를 확인할 수 있기 때문에 우리가 그것을 읽거나 쓰는 것이 가능해집니다. 따라서 개념적으로 볼 때 컴퓨터 메모리는 여러 개의 비트로 구성된 커다란 하나의 배열로 볼 수 있으며 우리는 여기에 무언가를 읽고 쓸 수 있습니다.

우리는 인간이기 때문에 사고와 연산을 비트로하는 데에 익숙치 않습니다. 그 보다는 비트를 더 큰 그룹으로 모아 숫자를 표현하도록 합니다. 8비트는 1바이트라고 하고 바이트가 모이면 워드가 됩니다(워드는 16비트일 때도 있고 32비트일 때도 있습니다).

메모리에는 많은 것들이 저장됩니다.

  1. 프로그램에서 사용되는 모든 변수와 기타 데이터
  2. 운영체제 및 개별 프로그램의 코드

컴파일러와 운영체제는 힘을 합쳐 개발자들을 위해 대부분의 메모리 관리를 해주려고 하지만 이런 것들이 어떻게 돌아가는지를 한번 살펴보는 것도 좋겠습니다.

코드를 컴파일하면 컴파일러는 원시 데이터타입을 검사해서 얼마나 많은 메모리가 필요할지 미리 계산합니다. 그러면 필요로 하는 만큼의 양이 스택스페이스(stack space)라는 곳에서 프로그램에 할당됩니다. 이런 변수들이 할당되는 공간을 스택스페이스라고 부르는 까닭은 함수가 호출 되면 해당 함수의 메모리가 기존 메모리의 위에 추가되기 때문입니다. 함수가 종료되면 LIFO(후입선출)의 순서로 제거됩니다. 예를 들어 다음의 변수 선언문들을 살펴 보겠습니다.

int n; // 4 바이트
int x[4]; // 4개 요소를 가진 배열, 각각은 4바이트
double m; // 8 바이트

컴파일러는 즉각적으로 위 코드가 4 + 4 × 4 + 8 = 28 바이트를 필요로한다는 것을 알 수 있습니다.

이것이 현재 정수나 더블 타입의 숫자에 대해 동작하는 방식입니다. 20여년 전에는 정수는 보통 2바이트였고 더블은 4바이트였습니다. 이제는 더 이상 코드 상에서 기본적인 데이터 타입의 크기에 대한 고려는 하지 않아도 됩니다.

컴파일러는 변수가 저장될 스택에서 필요로 하는 바이트 만큼의 메모리를 운영체제에 요청을 할 수 있도록 상호작용 코드를 삽입합니다.

위의 예시에서 컴파일러는 각각의 변수의 정확한 메모리 주소를 알고 있습니다. 우리가 변수 n이라고 적을 때마다 내부적으로는 메모리 주소 4127963라는 식으로 번역됩니다.

그런데 여기서 우리가 x[4]를 접근하려고 시도 하면 사실은 m과 연결된 데이터에 접근하게 됩니다. 왜냐하면 아직 존재하지 않는 배열상의 요소에 접근하는 것이기 때문입니다. 이 요소는 실제로 할당된 배열의 요소인 x[3]보다 4바이트 멀리 떨어져 있습니다. 따라서 m의 비트를 약간 읽게 될 수 있습니다(혹은 덮어쓰게 될 수도 있습니다). 만약 이렇게 된다면 프로그램에서 원하지 않는 결과를 가져올 확률이 매우 큽니다.

어떤 함수가 다른 함수를 호출할 때 각각의 함수는 자신만의 스택 꾸러미를 갖게 됩니다. 거기에는 모든 지역 변수와 함수의 실행이 어디에서 이루어졌는지를 기억하는 프로그램 카운터가 들어 있습니다. 함수가 종료되면 함수의 메모리 블록은 다른 용도로 사용될 수 있도록 반환됩니다.

동적할당

어떤 변수가 얼마나 많은 메모리를 필요로할지 컴파일타임에 알지 못하면 일은 좀 더 복잡해집니다. 아래와 같은 코드를 작성한다고 가정해보겠습니다.

int n = readInput(); // 사용자로부터 입력을 받음...// n개의 요소로 이루어진 배열 생성

여기에서 컴파일러는 배열이 얼마나 많은 메모리를 필요로 할지 알 수 없습니다. 왜냐하면 이는 사용자가 제공하는 값에 따라 달라지기 때문입니다.

따라서 컴파일러는 스택에 변수를 위한 공간을 할당할 수가 없습니다. 대신 우리의 프로그램은 운영체제에 명시적으로 적당량의 공간을 요구하는 것을 런타임에 해야 합니다. 이러한 메모리는 힙공간(heap space)으로부터 할당 받습니다. 정적 메모리 할당과 동적 메모리 할당의 차이는 아래와 같이 정리할 수 있습니다.

정적으로 할당되는 메모리와 동적으로 할당되는 메모리의 차이

동적 메모리 할당이 어떻게 동작하는지 완전히 이해하려면 포인터를 이해하는데 더 많은 노력을 기울여야 하지만 아마도 이것은 이 글의 범위를 벗어나는 것 같습니다. 이에 대해 더 많이 알고 싶다면 댓글로 알려주세요. 요청이 많으면 앞으로 쓰는 글에서 포인터를 더 깊어 다루어보도록 하겠습니다.

자바스크립트에서의 메모리 할당

이제 자바스크립트에서 첫 번째 단계인 메모리 할당이 어떻게 작동하는지 알아보겠습니다.

자바스크립트는 개발자들을 메모리 할당의 책임에서 해방시켜주었습니다. 자바스크립트는 변수 할당 시점에 메모리 할당을 스스로 수행합니다.

var n = 374; // 숫자에 대한 메모리 할당
var s = 'sessionstack'; // 문자에 대한 메모리 할당
var o = {
a: 1,
b: null
}; // 객체 및 그 값에 대한 메모리 할당
var a = [1, null, 'str']; // (객체와 같음) 배열과 그 값에 대한
// 메모리 할당
function f(a) {
return a + 3;
} // 함수에 대한 할당(함수는 호출할 수 있는 객체임)
// 함수 표현식 또한 객체를 할당함
someElement.addEventListener('click', function() {
someElement.style.backgroundColor = 'blue';
}, false);

아래와 같은 몇몇 함수 호출은 객체 할당의 결과를 가져오기도 합니다.

var d = new Date(); // Date객체 할당var e = document.createElement('div'); // DOM 요소 할당

메소드는 새로운 값이나 객체를 할당할 수 있습니다.

var s1 = 'sessionstack';
var s2 = s1.substr(0, 3); // s2는 새로운 문자열이 됨
// 문자열은 불변(immutable)이므로 자바스크립트는 메모리를 할당하지 않고
// [0, 3]의 범위만 저장할 수도 있음
var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];
var a3 = a1.concat(a2);
// 4개의 요소를 가진 새로운 배열은
// a1과 a2 요소의 연결이 됨

자바스크립트에서 메모리의 사용

자바스크립트에서 할당된 메모리를 사용하는 것은 기본적으로 그 메모리 내에서 읽거나 쓰는 것을 뜻합니다.

이것은 객체의 속성이나 변수의 값을 읽거나 쓸 때 혹은 함수에 인수를 넘겨줄 때에도 일어납니다.

메모리가 더 이상 필요하지 않을 때 놓아주기

이 단계가 바로 대부분의 메모리 관리 문제가 일어나는 때입니다.

여기서 가장 어려운 작업은 할당된 메모리가 언제 더 이상 필요 없는지 알아내는 것입니다. 프로그램 상에서 일정한 메모리가 더 이상 사용되지 않고 있으며 이를 반환해야 하는지를 프로그래머가 판단해야 할 때도 종종 있습니다.

고수준의 언어에는 가비지컬렉터(garbage collector)라는 소프트웨어가 내장되어 있는데 이것의 역할은 메모리 할당을 추적하고 언제 할당된 메모리가 더 이상 사용되지 않는지 파악해서 자동으로 반환하는 것입니다.

불행하게도 이러한 과정은 추정에 기반하는데 왜냐하면 일반적으로 메모리의 일부가 필요할지를 알아내는 문제는 결정불가능(undecidable), 즉 알고리즘으로 풀 수 없는 문제이기 때문입니다.

대부분의 가비지컬렉터는 어떤 메모리를 가리키는 모든 변수가 스코프를 벗어나게 됐을 때 처럼 더 이상 접근 불가능한 메모리를 수집합니다. 이는 메모리 공간에서 수집할 수 있는 것들을에 대해 보수적인 입장에서 추측하는 것인데 왜냐하면 어떤 메모리 영역을 가리키는 변수는 아직 존재하지만 그것을 그 이후로 절대로 접근하지 않는 경우는 언제라도 생길 수 있기 때문입니다.

가비지컬렉션

어떤 메모리가 더 이상 필요 없음을 알아내는 것이 결정할수 없는 문제이기 때문에 가비지컬렉션은 이 일반적인 문제에 대해 제한적인 해결책만을 구현합니다. 이번 장에서는 주요 가비지컬렉션 알고리즘과 그 제한점을 이해하는데 필요한 개념들을 알아봅니다.

메모리 참조

가비지 컬렉션 알고리즘이 의존하고 있는 주요 개념은 참조(reference)입니다.

메모리 관리의 관점에서는 어떤 객체가 다른 객체에 (명시적이든 암묵적이든) 접근할 수 있으면 다른 객체를 참조한다고 말합니다. 예를 들어 자바스크립트 객체는 자신의 프로토타입에 대한 (암묵적) 참조를 갖고 있으며 자신의 속성 값에 대한 (명시적) 참조도 갖고 있습니다.

이런 관점에서 ‘객체’라는 것의 개념은 일반적인 자바스크립트 객체에서 좀 더 큰 범위로 확장되어 함수 스코프(function scope)나 글로벌 렉시컬 스코프(lexical scope)까지도 포함하는 것이 됩니다.

렉시컬 스코핑은 변수 이름이 중첩된 함수에서 해석되는 방식을 정의합니다. 중첩되어 있는 더 안 쪽의 함수는 부모 함수가 값을 반환한 다음에도 부모 함수의 스코프를 포함하고 있습니다.

참조횟수계산 가비지컬렉션

이것은 가장 단순한 형태의 가비지컬렉션 알고리즘입니다. 객체는 만약 그것을 가리키는 참조가 하나도 없는 경우 가비지컬렉션 대상(garbage collectible)으로 간주됩니다.

다음 코드를 살펴봅시다.

var o1 = {
o2: {
x: 1
}
};
// 두 객체가 생성됨
// 'o2'는 'o1'이 자신의 속성으로서 참조함
// 둘 다 가비지컬렉션 될 수 없음
var o3 = o1; // 'o3' 변수는 'o1'이 가리키는 오브젝트에 대한 참조를 갖는 두 번째임o1 = 1; // 이제 'o1'에 있던 객체는 하나의 참조만 남게 되고
// 그것은 'o3' 변수에 들어 있음
var o4 = o3.o2; // 'o2' 속성에 대한 참조
// 이제 이 객체는 두개의 참조를 가짐. 하나는 속성으로서
// 다른 하나는 'o4' 변수로서
o3 = '374'; // 원래 'o1'에 있던 객체는 이제 참조를 하는 곳이 없음
// 따라서 가비지컬렉션 될 수 있음
// 하지만 'o2' 속성은 'o4' 변수가 참조하므로 가비지컬렉션 될 수 없음
o4 = null; // 원래 'o1'객체 내에 있던 'o2'속성은 이제 참조하는 곳이 없으므로
// 가비지컬렉션 될 수 있음

순환 참조 때문에 생기는 문제

순환 때문에 생기는 제한점들이 있습니다. 아래 예제에서는 두 객체가 생성되고 서로를 참조하므로 순환참조가 생성됩니다. 이 객체들은 함수 호출 뒤에 스코프를 벗어나게 되므로 실질적으로는 쓸모가 없게 되고 이들이 차지하던 메모리는 반환될 수 있습니다. 하지만 참조횟수계산 알고리즘에서는 두 객체가 적어도 한 번은 참조한 것으로 간주하므로 둘 다 가비지컬렉션 될 수 없습니다.

function f() {
var o1 = {};
var o2 = {};
o1.p = o2; // o1은 o2를 참조함
o2.p = o1; // o2는 o1을 참조함. 이를 통해 순환 참조가 만들어짐.
}
f();

마크스위프 알고리즘

객체가 필요한지 결정하기 위해서 이 알고리즘은 해당 객체에 닿을 수 있는지(reachable)를 판단합니다.

마크스위프 알고리즘은 다음의 세 단계를 거칩니다.

  1. 루트(Roots): 일반적으로 루트는 코드에서 참조되는 전역 변수입니다. 예를 들어 자바스크립트에서 루트로 동작할 수 있는 전역 변수는 window 객체입니다. Node.js에서 이와 동일한 객체는 global입니다. 가비지컬렉터는 모든 루트의 완전한 목록을 만들어냅니다.
  2. 그런 다음 모든 루트와 그 자식들을 검사해서 활성화 여부를 표시합니다(활성상태이면 가비지가 아닙니다). 루트가 닿을 수 없는 것들은 가비지로 표시됩니다.
  3. 마지막으로 가비지컬렉터는 활성으로 표시되지 않은 모든 메모리를 OS에 반환합니다.

마크스위프 알고리즘의 동작을 시각화한 것

이 알고리즘은 앞선 것 보다는 낫다고할 수 있는데 왜냐하면 ‘참조받지 않는 객체’는 결국 닿을 수 없는 객체이기 때문입니다. 순환참조에서 본것처럼 이것의 반대가 항상 참이지는 않습니다.

2012년을 기준으로 모든 현대적인 브라우저들은 마크스위프 가비지컬렉터를 장착하고 있습니다. 지난 몇 년 간 자바스크립트 가비지컬렉션 분야에서 있었던 모든 개선은 가비지콜렉션 자체나 어떤 객체가 닿을 수 있느냐를 판별하는 것에 있었던 것이 아니라 바로 이 마크스위프 알고리즘의 구현에 대한 개선이었습니다.

이 글을 참조하시면 가비지컬렉션의 추적에 대한 상세한 설명과 마크-스위프 알고리즘 및 그 최적화에 대해 더 자세히 알 수 있습니다.

순환참조 문제는 해결

위의 첫 번째 예시에서 함수 호출에 값을 반환한 다음 두 객체는 더 이상 전역 객체로부터 닿을 수 있는 무언가가 참조하는 대상은 아닌 것이 되었습니다. 결과적으로 가비지컬렉터의 입장에서도 닿을 수 없는 것들이 됩니다.

서로에 대한 참조가 있기는 하지만 루트에서는 닿을 수 없는 상태입니다.

가비지컬렉터의 직관적이지 않은 행동

가비지컬렉터가 편리하기는 해도 그것 대로의 상충관계인 것들이 있습니다. 그 중 하나는 비결정주의(non-determinism)입니다. 다시 말하자면 가비지컬렉터는 예측이 어렵습니다. 개발자는 가비지칼렉션이 정확히 언제 작동할지 알기가 어렵습니다. 이는 때에 따라 프로그램이 실제로 필요한 것 보다 더 많은 메모리를 사용할 수도 있다는 뜻입니다. 또한 굉장히 민감한 애플리케이션의 경우 이렇게 잠깐씩 동작이 멈추는 것이 사용자들의 눈에 띌 수도 있습니다. 비결정주의가 가비지컬렉션이 언제 수행될지 확실히 알 수 없다는 뜻이기는 하지만 대부분의 가비지컬렉터들의 구현을 보면 공통적으로 메모리 할당 중에 가비지컬렉션을 넘겨주는 패턴을 보입니다. 할당이 일어나지 않으면 대부분의 가비지컬렉터는 아무일도 하지 않습니다. 다음 시나리오를 살펴봅시다.

  1. 대단히 큰 규모의 메모리 할당이 일어남
  2. 이 요소들의 대부분(혹은 전체)은 닿을 수 없음(unreachable)의 상태로 표시됨(더 이상 필요로 하지 않는 캐쉬에 대한 참조 포인터를 null로 만듦)
  3. 더 이상의 메모리 할당이 일어나지 않음

위 시나리오에서 대부분의 가비지컬렉터들은 더 이상의 컬렉션을 넘겨주지 않습니다. 다시 말하면 가비지컬렉션에 더할 만한, 닿을 수 없는 참조가 존재하기는 하지만 가비지컬렉터들이 이들을 가져가려하지 않습니다. 엄밀히 말해 이것을 메모리 누수라고 할 수는 없지만 그래도 보통 때보다는 더 많은 메모리를 사용하는 원인이 되기는 합니다.

메모리 누수란 무엇인가?

이름에서 알 수 있듯이 메모리누수(memory leaks)는 전에는 프로그램에서 사용했다가 더 이상 필요하지 않지만 아직 OS나 자유메모리 풀에 반환되지 않은 메모리 조각들을 말합니다.

메모리누수는 나빠요. 음 … 그래서?

프로그래밍 언어는 나름대로의 메모리 관리 기법을 갖고 있습니다. 하지만 어떤 메모리가 사용 중인지 아닌지는 결정할 수 없는 문제입니다. 다시 말하면 오직 개발자만이 어떤 메모리 조각이 운영체제에 반환될 수 있는지를 분명히 할 수 있는 것입니다.

어떤 프로그램 언어에서는 이러한 작업을 수행할 수 있는 기능을 개발자에게 제공하기도 합니다. 몇몇 언어에서는 개발자들이 사용되지 않는 메모리에 대해 완전히 명시적이기를 요구하기도 합니다. 이에 대해 더 관심 있으시다면 위키피디아에 수동 혹은 자동 메모리 관리에 대한 좋은 아티클이 있으니 살펴보시기 바랍니다.

네 가지 흔한 자바스크립트 메모리누수

1: 전역 변수

자바스크립트는 흥미로운 방식으로 선언되지 않은 변수를 처리합니다. 선언되지 않은 변수가 참조되면 전역 객체에 새로운 변수를 생성하는 것입니다. 브라우저상이라면 전역 객체는 window가 됩니다. 따라서,

function foo(arg) {
bar = "some text";
}

는 다음과 동일합니다.

function foo(arg) {
window.bar = "some text";
}

bar의 목적이 foo 함수 내의 어떤 변수를 가리키는 것이었다고 해봅시다. 하지만 var를 사용하여 변수를 선언하지 않으므로써 필요 없는 전역 변수가 생성될 것입니다. 위의 예에서는 이게 큰 손실을 끼치지는 않습니다. 하지만 더욱 위험한 경우도 생각해 볼 수 있을 것입니다.

또한 this를 이용해서도 뜻하지 않은 전역 변수를 생성할 수 있습니다.

function foo() {
this.var1 = "potential accidental global";
}
// 다른 함수 내에 있지 않은 foo를 호출하면 this는 글로벌 객체(window)를 가리킴
foo();

자바스크립트 파일의 상단에 use strict를 사용하면 위와 같은 모든 것들을 회피할 수 있습니다. 이 모드에서 자바스크립트는 예상치 못한 전역 변수 생성을 방지할 수 있는 훨씬 엄격한 파싱을 시도합니다.

기대치 않게 생성된 전역변수는 물론 문제입니다만 많은 경우에는 의도적으로 가비지컬렉터가 정리할 수 없는 전역변수를 사용하기도 합니다. 임시로 정보를 저장하거나 많은 양의 정보를 처리할 때 사용하는 전역 변수에 특별히 세심한 주의를 기울일 필요가 있습니다. 꼭 그래야 한다면 전역 변수를 사용할 수도 있지만 사용을 마친 다음에는 꼭 null로 할당하거나 다른 변수로 할당하시기 바랍니다.

2: 잊혀진 타이머 혹은 콜백함수

자바스크립트에서 많이 사용되는 setInterval을 예로 들어보겠습니다.

옵저버를 제공하는 라이브러리나 콜백을 받는 함수들을 보면 대부분 객체가 닿을 수 없는 상태가 되면 이들에 대한 참조도 닿을 수 없도록 해주고 있습니다. 하지만 이런 코드들도 종종 볼 수 있습니다.

var serverData = loadData();
setInterval(function() {
var renderer = document.getElementById('renderer');
if(renderer) {
renderer.innerHTML = JSON.stringify(serverData);
}
}, 5000); // 매 5초 마다 실행

위 코드는 참조 노드나 데이터가 더 이상 필요로하지 않는 타이머를 사용한 결과를 보여줍니다.

renderer객체는 어느 시점에 다른 것으로 대체되거나 제거될 수 있으며 그러면 인터벌 핸들러로 둘러쌓은 코드는 더 이상 필요 없게 됩니다. 이 인터벌 타이머는 아직 활성 상태이므로 가비지컬렉터는 이 핸들러나 그 내부의 것들을 가져가지 않습니다. 결국은 많은 양의 데이터를 저장하고 처리하고 있을 serverData도 가져가지 않게되는 것입니다.

옵저버를 사용할 때는 그 사용이 종료되었을 때 꼭 명시적으로 그것을 제거해야 합니다(해당 옵저버가 더 이상 필요하지 않거나 닿을 수 없는 상태일 때).

운이 좋게도 대부분의 현대적인 브라우저에서는 이러한 일을 대신 해줍니다. 이들은 개발자가 리스너를 제거하는 것을 잊었다고 하더라도 객체가 닿을 수 없는 상태가 되면 자동으로 옵저버 핸들러를 가져갑니다. 예전에는 이러한 경우에 대처를 하지 못했습니다(IE6 같은 경우).

하지만 그럼에도 불구하고 객체의 사용이 끝나면 그 옵저버는 제거하는 것이 모범사례입니다. 다음 예를 봅시다.

var element = document.getElementById('launch-button');
var counter = 0;
function onClick(event) {
counter++;
element.innerHtml = 'text ' + counter;
}
element.addEventListener('click', onClick);// 필요한 작업 수행element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// 이제 요소들이 스코프를 벗어나게 되면
// 순환참조를 잘 처리하지 못 하는 구형 웹브라우저에서도
// 해당 요소와 onClick 콜백을 가비지컬렉터가 가져감

더 이상 노드를 닿을 수 없는 상태로 만들기 전에 removeEventListener를 호출할 필요가 없습니다. 왜냐하면 현대적 브라우저들은 이러한 순환참조를 탐지하고 적절히 처리하는 가비지컬렉터를 지원하기 때문입니다.

만약 jQuery를(혹은 이런 기능을 지원하는 다른 라이브러리도 존재함) 사용한다면 노드가 사용 불가 상태가 되기 전에 리스너를 제거할 수 있습니다. jQuery는 프로그램이 구형 브라우저에서 동작할 때도 메모리 누수가 없도록 처리 해줍니다.

3: 클로져

자바스크립트 개발의 주요한 점 중 하나는 클로져입니다. 클로져는 자신을 감싸고 있는 바깥 함수의 변수에 접근할 수 있는 내부의 함수를 말합니다. 자바스크립트 런타임 구현의 특성상 다음과 같이 메모리누수를 일으키는 것이 가능합니다.

var theThing = null;var replaceThing = function () { var originalThing = theThing;
var unused = function () {
if (originalThing) // 'originalThing'에 대한 참조
console.log("hi");
};
theThing = {
longStr: new Array(1000000).join('*'),
someMethod: function () {
console.log("message");
}
};
};
setInterval(replaceThing, 1000);

일단 replaceThing이 호출 되면 theThing은 커다란 배열과 새로운 클로져(someMethod)를 포함하는 새로운 객체를 얻게 됩니다. 아직 originalThing은 unused 변수가 갖고 있는 클로져에 의해 참조되고 있습니다(which is theThing variable from the previous call to replaceThing). 기억할 점은 한 번 동일한 부모 스코프에 있는 클로져들에 대한 스코프가 생성되고 나면 이것은 공유된다는 점입니다.

위의 경우 someMethod 클로져를 위해 생성된 스코프는 unused와 공유되었습니다. unused는 originalThing에 대한 참조를 갖고 있습니다. unused가 다시 사용되지 않는다 해도 someMethod는 theThing을 통해 replaceThing의 스코프 바깥에서 사용될 수 있습니다(글로벌하게). 그리고 someMethod는 unused와 클로져 스코프를 공유하기 때문에 unused가 originalThing에 대해 갖고 있는 참조 때문에 강제로 활성 상태가 유지됩니다(두 클로져 사이에 공유된 전체 스코프). 이 때문에 가비지컬렉션이 작동하지 않습니다.

위의 예에서 someMethod 클로져에 대해 생성된 스코프는 unused와 공유되었고 또한 unused는 originalThing을 참조합니다. unused가 사용된적이 없다고 하더라 someMethod는 replaceThing 스코프의 바깥에서 theThing을 통해 사용될 수 있습니다. unused가 originalThing을 참조한다는 사실 때문에 unused는 활성 상태가 유지되는데 왜냐하면 someMethod가 unused와 클로져 스코프를 공유하기 때문입니다.

이러한 점들은 모두 꽤 큰 메모리누수를 일으킬 수 있습니다. 위 코드가 실행될 때 마다 메모리 사용량이 갑자기 증가하는 것을 볼 수 있을 것입니다. 가비지컬렉터가 수행되어도 그 크기는 줄어들지 않을 것입니다. 클로져들 사이의 연결된 리스트가 한번 생성되면 (이번 예시의 경우에는 그 루트가 theThing 변수가 됨) 각각의 클로져 스코프는 커다란 배열에 대한 간접적 참조를 전달합니다.

이 문제는 미티어(Meteor)팀에서 발견했으며 이에 대해 좀 더 자세하게 다룬 훌륭한 글이 있습니다.

4: DOM에서 벗어난 요소 참조

DOM 노드를 데이터 구조 속에 저장하는 경우가 있습니다. 테이블 내 몇 열의 내용을 빠르게 업데이트하고 싶은 상황이라고 가정해 봅시다. 각 열에 대한 참조를 딕셔너리나 배열에 저장하면 동일한 DOM 요소에 대해 두 개의 참조가 존재하는 셈입니다. 하나는 DOM 트리에, 하나는 딕셔너리에. 이 열들을 제거하고자 결정한다면 이 두개의 참조 모두가 닿을 수 없도록 해야하는 것을 잊지 말아야 합니다.

var elements = {
button: document.getElementById('button'),
image: document.getElementById('image')
};
function doStuff() {
elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
// image는 body 요소의 바로 아래 자식임
document.body.removeChild(document.getElementById('image'));
// 이 순간까지 #button 전역 요소 객체에 대한 참조가 아직 존재함
// 즉, button 요소는 아직도 메모리 상에 있고 가비지컬렉터가 가져갈 수 없음
}

DOM 트리의 리프 노드나 내부 노드를 참조할 때 고려해야 할 것이 또 있습니다. 테이블 내의 셀 태그 (예를 들어 <td>)를 참조하고 있다가 해당 테이블을 DOM에서 제거한 상태에서 해당 셀에 대한 참조를 갖고 있다면 커다란 메모리누수가 일어날 수 있습니다. 해당 셀만 놔두고 나머지 부분을 가비지컬렉터가 반환시켜줄거라고 생각할지도 모르겠습니다만 실제로는 그렇지 않습니다. 그 셀은 테이블의 자식노드이고 자식노드들은 부모에 대한 참조를 갖고 있기 때문에 테이블 셀에 대한 참조 하나만으로도 전체 테이블이 메모리에 남아 있게 됩니다.


세션스택의 개발자들은 메모리할당을 적절히 처리할 수 있도록 코드를 작성하는 모범 사례를 따르고 있습니다.

일단 사용자의 프로덕션 웹 앱이 세션스택에 통합되면 세션스택은 모든 것을 기록합니다. 모든 DOM 변화와 사용자 상호작용, 자바스크립트 에러, 스택트레이스, 실패한 네트워크 요청, 디버깅 메세지 등. 세션스택을 통해 웹앱의 문제를 비디오처럼 재생해 볼 수 있으며 사용자가 보는 화면과 동일한 화면을 볼 수 있습니다. 그런데 이 모든 것은 사용자의 웹 앱에 속도 저하 없이 이루어져야 합니다.

사용자들은 페이지를 리로딩하거나 앱 내부를 돌아다닐 수 있기 때문에 모든 옵저버, 인터셉터, 변수 할당 등이 적절히 처리되어야 합니다. 그래야 어떠한 메모리누수도 일어나지 않고 우리가 통합하는 앱의 메모리 소비가 증가하지 않기 때문입니다.

세션스택을 무료로 사용해 볼 수 있는 플랜도 있습니다.

Resources


후이서울에서 함께할 개발자를 찾고 있습니다. 자세한 사항은 WeWantYou를 참고하세요~

 

 

 

 

출처 : https://engineering.huiseoul.com/%EC%9E%90%EB%B0%94%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%9E%91%EB%8F%99%ED%95%98%EB%8A%94%EA%B0%80-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EA%B4%80%EB%A6%AC-4%EA%B0%80%EC%A7%80-%ED%9D%94%ED%95%9C-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EB%88%84%EC%88%98-%EB%8C%80%EC%B2%98%EB%B2%95-5b0d217d788d

반응형