[JavaScript] 자바스크립트 메모리 관리

GonnabeAlright·2022년 4월 10일
0
post-thumbnail
post-custom-banner

메모리는 크게 스택과 힙메모리로 구별할 수 있다.

  • Stack: 메소드, 함수 프레임, 원시값, 객체의 포인터등 정적인 데이터가 저장되는 곳
  • Heap: 객체 또는 다이나믹 데이터 등이 저장되는 곳. 메모리 블록 중 가장 큰 영역이며 GC가 작업 하는 곳

V8은 가비지 콜렉션을 이용해서 힙 메모리를 관리한다. 스택에서 더 이상 참조되지 않는 객체의 메모리를 해제하여 다른 객체가 메모리를 할당하여 쓸 수 있도록. 한다. V8의 가비지 컬렉터는 더 이상 사용하지 않는 메모리를 해제하여 공간을 확보하는 책임이 있다. V8 가비지 컬렉터는 객체를 생성시점으로 묶어서 각각 다른 스테이지별로 별도로 관리한다. V8 가비지 컬렉터는 2개의 다른 스테이지와 세개의 다른 알고리즘을 사용한다."

C 언어 같은 저수준 언어에서는 메모리 관리를 위해 malloc()free()를 사용한다. 반면, 자바스크립트는 객체가 생성되었을 때 자동으로 메모리를 할당하고 쓸모 없어졌을 때 자동으로 해제한다. 이러한 자동 메모리 관리는 잠재적 혼란의 원인이기도 한데, 개발자가 메모리 관리에 대해 고민할 필요가 없다는 인상을 줄 수 있기 때문이다.

메모리 생존주기

메모리 생존주기는 프로그래밍 언어와 관계없이 비슷하다.

  1. 할당
  2. 사용 (읽기, 쓰기)
  3. 해제

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

1. 값 초기화

프로그래머를 할당 문제로 괴롭히지 않기 위해서, 자바스크립트는 값을 선언할 때 자동으로 메모리를 할당한다.

let n = 123; // 정수를 담기 위한 메모리 할당
let s = 'jinhyeok'; // 문자열을 담기 위한 메모리 할당

let o = {
  a: 1,
  b: null
}; // 오브젝트와 그 오브젝트에 포함된 값들을 담기 위한 메모리 할당

// 배열과 배열에 담긴 값들을 위한 메모리 할당
let a = [1, null, 'korea'];

function f(a) {
  return a + 2;
}; // 함수를 위한 할당(함수는 호출 가능한 오브젝트이다.)

2. 값 사용

값 사용이란 기본적으로는 할당된 메모리를 읽고 쓰는 것을 의미한다. 변수나 객체 속성의 값을 읽고 쓰거나 함수 호출시 함수에 인수를 전달하여 수행할 수 있다.

3. 할당된 메모리가 더 이상 필요없을 때 해제하기

이 단계에서 문제가 발생한다. "할당된 메모리가 더 이상 필요없을 때"를 알아내기가 어렵기 때문이다. 저수준 언에서는 메모리가 필요없어질 때를 개발자가 직접 결정하고 해제하는 방식을 사용한다. 하지만 자바스크립트와 같은 고수준 언어들은 "가비지 콜렉션(GC)"이라는 자동 메모리 관리 방법을 사용한다.

메모리 누수란 ?

간단히 말해 메모리 누수란 애플리케이션에서 더 이상 사용하지 않는 메모리가 힙에서 계속 남아 있어 메모리에서 쓸모없는 블록으로 존재하게 된다. 이러한 블록이 계속해서 생기게 되면 애플리케이션에서는 더 이상 사용할 메모리가 존재하지 않게 되고, 나아가 OS 또한 할당할 메모리가 남아나지 않아서 애플리케이션이 느려지고 크래쉬되거나 혹은 OS단에서 문제가 발생할 수 있다.

가비지 콜렉션 알고리즘의 핵심 개념은 참조이다. A라는 메모리를 통해 (명시적이든 암시적이든) B라는 메모리에 접근할 수 있다면 "B는 A에 참조된다"라고 한다. 예를 들어 자바스크립트 오브젝트는 prototype을 암시적으로 참조하고 그 오브젝트의 속성을 명시적으로 참조한다. 앞으로 "오브젝트"라는 어휘의 의미를 넓혀서 기존의 자바스크립트 오브젝트뿐만 아니라 함수 스코프도 포괄하자.

참조 세기(Reference-counting) 가비지 콜렉션

참조-세기 알고리즘은 가장 소박한 알고리즘이다. 이 알고리즘은 "더 이상 필요없는 오브젝트"를 "어떤 다른 오브젝트도 참조하지 않는 오브젝트"라고 정의한다. 이 오브젝트를 "가비지"라고 부르며, 이를 참조하는 다른 오브젝트가 하나도 없는 경우 수집이 가능하다.

let x = {
  a: {
    b: 2
  }
}
// 2개의 오브젝트가 생성되었다. 하나의 오브젝트는 다른 오브젝트의 속성으로 참조된다.
// 나머지 하나는 'x'변수에 할당되었다.
// 명백하게 가비지 콜렉션이 수행될 메모리는 하나도 없다.

let y = x; // 'y'변수는 위의 오브젝트를 참조하는 두번째 변수이다.

x = 1; // 이제 'y' 변수가 위의 오브젝트를 참조하는 유일한 변수가 되었다.

let z = y.a;
// 위의 오브젝트의 'a' 속성을 참조했다.
// 이제 'y.a'는 두 개의 참조를 가진다.
// 'y'가 속성으로 참조하고 'z'라는 변수가 참조한다. 

y = 'mozila';
// 이제 맨 처음 'y'변수가 참조했던 오브젝트를 참조하는 오브젝트는 없다.
// 이제 오브젝트에 가비지 컬렉션이 수행될 수 있을까 ?
// 아니다. 오브젝트 'a'의 속성이 여전히 'z' 변수에 의해 참조되므로 메모리를 해제할 수 없다.

z = null;
// 'z' 변수에 다른 값을 할당했다.
// 이제 맨 처음 'x'변수가 참조했던 오브젝트를 참조하는 
// 다른 변수는 없으므로 가비지 콜렉션이 수행된다.

한계: 순환참조

두 객체가 서로 참조하는 속성으로 순환 구조가 생성되는 경우 발생

function recursion() {
  let obj1 = {};
  let obj2 = {};
  obj1.a = obj2;	// obj1는 obj2를 참조한다.
  obj2.a = obj1;	// obj2는 obj1을 참조한다.
  
  return "ref each other";
}
recursion();

원래라면 obj1, obj2는 recursion() 함수의 지역변수로 할당되어, recursion() 함수 실행 후 메모리가 해제 되어야 하지만 실제 메모리 상에서는 두 객체가 서로를 참조하고 있기 때문에 메모리가 해제되지 않게 된다. 결국 recursion() 함수가 호출될 때마다 obj1, obj2 객체 만큼의 메모리가 할당된 뒤 해제되지 않고 남게 되어 메모리가 낭비 될 것이다.

표시하고 쓸기 (Mark-and-sweep) 알고리즘

주기적으로 가비지 콜렉터는 roots로부터 시작하여 roots가 참조하는 오브젝트들, 참조되는 오브젝트가 참조하는 오브젝트로 이동하면서 접근할 수 있는 오브젝트를 표시한다. 그리고 접근할 수 없는 오브젝트들에 대해 가비지 콜렉션을 수행한다.

접근할 수 있는 오브젝트가 되는 기준은 ?

  • 원래 접근 가능한 오브젝트 (root)
  • root가 참조하는 값이나 루트에서 참조할 수 있는 값

Mark-and-sweep example

let obj = { name: 'kim' };
let pointer = obj;
obj = null;

roots ➡️ pointer ➡️ obj로 접근이 가능하기 때문에 해제되지 않는다.

객체간 참조 상황

객체간 서로 참조하고 있어도 roots에서 도달 불가능한 경우 메모리가 해제된다.

수동 메모리 해제

어떤 메모리를 해제할지에 대해 수동으로 결정하려면 수동으로 "접근할 수 없는 객체"를 만들 수 있어야 하지만 표시하고 쓸기 알고리즘에서는 그 기능을 지원하지 않아 수동으로 메모리를 해제할 수 없다.

자바스크립트에서는 무엇이 메모리 누수를 발생시키는가 ?

V8의 가비지 콜렉터와 같은 자동 메모리 관리는 메모리 누수를 피하는데 초점이 맞춰져 있다. 예를 들어 순환 참조는 가비지 콜렉터의 고려대상이 아니지만, 힙의 원치 않는 참조로 인해 문제가 발생할 수 있다. 일반적인 메모리 누수의 상황은 아래와 같다.

  • 전역변수: 자바스크립트의 전역 변수는 루트 노드를 참조하기 때문에 (window, global) 애플리케이션의 생명주기 동안 절대로 가비지 콜렉팅이 되지 않아 계속해서 메모리를 점유하고 있게 된다. 따라서 글로벌 변수를 참조하고 있는 객체 또한 가비지 콜렉팅의 대상이 되지 않는다는 것을 의미한다. 루트로부터 커다란 객체 참조 그래프를 가지고 있다는 것은 결국 메모리 누수로 이어지게 된다.
  • 동시참조: 하나의 동일한 객체가 다양한 객체에서 참조될 때, 이 중 하나의 참조가 잘못된다면 전체 객체에서 메모리 누수가 발생할 수 있다.
  • 클로저: 자바스크립트의 클로저는 코드를 둘러싼 콘텍스트를 기억한다는 점에서 멋진 기능이다. 클로저가 힙의 큰 객체의 클로저를 참조하고 있다면, 클로저가 사용될 때까지 그 객체는 메모리에 남아 있게 된다. 이는 메모리 누수의 원인으로 이어질 수 있다.
  • 타이머 & 이벤트: setTimeout, setInterval, Observer 이벤트 리스너 등의 콜백이 적절한 조치없이 무거운 객체의 참조를 가지고 있을 경우 메모리 누수가 발생할 수 있다.

메모리 누수를 피하는 방법

전역 변수의 사용을 줄인다.

전역 변수는 절대로 가비지 컬렉팅이 되지 않으므로, 전역변수를 남용하지 않는 것이 제일 좋다.

실수로 전역변수를 선언하는 것을 주의한다.

function hello() {
  // 전역변수를 호이스팅 된다.
  foo = 'Message'
}

function hello() {
  // 여기서 this는 global이기 때문에 마찬가지로 호이스팅되어 전역변수가 된다.
  this.foo = 'Message'  
}

이러한 원치 않는 사고를 방지하기 위해서는 자바스크립트 파일 상단에 'use strict'를 선언해 두면 된다. 엄격한 모드에서 위의 코드는 에러를 발생시킨다. 만약 ES 모듈이나 타입스크립트 또는 바벨과 같은 트랜스파일러를 사용한다면 굳이 사용하지 않아도 된다. 최근 버전의 Nodejs에서는 '--use_strict'옵션으로 nodejs 환경 전역에 이 모드를 활성화 시킬 수 있다.

// 전역변수로 할당
const hello = () => {
  this.foo = 'Message';
}

화살표 함수를 사용하면 마찬가지로 전역변수를 생성할 수도 있다는 사실을 조심해야 한다. 이러한 경우에는 엄격 모드로 해결할 수 없고 eslint의 no-invalid-this로 해결하면 된다. 마지막으로 bindcall을 사용하는 함수에 전역 this를 바인딩하지 않도록 주의한다.

글로벌 스코프 사용을 줄인다.

글로벌 스코프의 사용은 가능한 줄이는 것이 좋다.

  1. 함수의 지역 스코프를 사용하여 가비지 콜렉터가 원할 때 메모리를 수집할 수 있게 해주자. 만약 특별한 제한 때문에 전역 스코프를 사용해야 한다면, 더 이상 사용하지 않게 되는 시점에 null을 넣어주면 된다.
  2. 전역변수는 오직 상수, 캐시 또는 재사용할 싱글턴 패턴에만 사용해야 한다. 함수와 클래스간에 데이터를 고유하기 위해서는 파라미터와 객체의 속성값으로 전달해주는 것이 좋다.
  3. 큰 객체를 전역 변수에 저장하지 말자. 만약 꼭 저장해야 한다면, 더 이상 사용하지 않을 때 null 처리를 해줘야 한다. 캐시 객체의 경우, 이 객체가 점점 커지는 것을 방지해야 한다.

스택 메모리를 잘 활용하자.

스택 접근은 힙 접근 보다 성능적으로도 우월하고, 메모리의 효율성도 높기 때문에 가능한 스택 변수를 많이 활용해야 한다. 이는 또한 실수로 일어나는 메모리 누수도 방지해준다. 물론, 실무상으로 오로지 스태틱 데이터만 쓸 수 있는 일은 없다. 실제 애플리케이션은 다양한 객체와 다이나믹 데이터를 사용해야 한다. 하지만 몇가지 트릭을 사용하여 스택을 조금 더 효율적으로 쓸 수 있다.

  1. 스택 변수로부터 힙 객체를 참조하는 것을 가능한 피해야 한다. 또한, 사용하지 않는 변수를 그냥 둬서는 안된다.
  2. 객체나 배열 내부의 값을 넘길 때는 전체 객체를 통째로 넘기는 대신에 이를 분해해서 필요한 것만 넘기는 것이 좋다. 이는 클로져 내부에서 불필요한 객체 참조를 피할 수 있다. 객체내부의 값은 대부분 원시값으로, 이는 스택을 사용하는데 도움이 된다.

힙 메모리를 효율적으로 활용하자.

실제 애플리케이션에서 힙메모리의 사용을 피할 수는 없지만, 아래 팁들을 이용하면 좀더 효율적으로 사용할 수 있다.

  1. 참조를 넘기는 대신에 가능하면 객체를 복사하는 게 좋다. 참조를 넘기는 것은 객체가 크거나, 복사하는 비용이 클 때 활용한다.
  2. 객체의 변이를 가능한 피해야 한다. 그 대신 전개 연산자를 사용하거나 Object.assign으로 복사하는 것이 좋다.
  3. 하나의 객체에 여러가지 참조를 만드는 것을 피해야 한다. 대신에 객체를 복사하는 것이 좋다.
  4. 수명이 짧은 변수를 활용하자.
  5. 큰 객체 트리를 만드는 것을 피해야 한다. 만약 이러한 것이 불가능하다면, 지역변수 내에서 보관하는 것이 좋다.
post-custom-banner

0개의 댓글