2025-09-22 23:27

  • 자바스크립트 메모리 관리의 핵심은 ‘가비지 컬렉션’이라는 자동화된 프로세스에 있으며, 개발자는 이 과정을 이해하고 잠재적인 메모리 누수를 방지하는 코드를 작성해야 합니다.

  • 주요 메모리 누수 원인으로는 전역 변수 남용, 분리된 DOM 노드, 닫히지 않은 타이머와 콜백 등이 있으며, 크롬 개발자 도구와 같은 툴을 사용하여 진단하고 해결할 수 있습니다.

  • 효율적인 메모리 관리를 위해서는 객체 참조 해제, 스코프 체인 최적화, 그리고 WeakMap/WeakSet과 같은 자료구조를 활용하여 불필요한 메모리 점유를 최소화하는 것이 중요합니다.

자바스크립트 메모리 관리 완벽 핸드북 개발자라면 반드시 알아야 할 모든 것

자바스크립트는 현대 웹 개발의 심장과도 같은 언어. 동적인 웹 페이지부터 복잡한 서버 애플리케이션까지, 그 활용 범위는 무궁무진하다. 하지만 이처럼 강력한 언어를 사용하면서 많은 개발자가 간과하는 부분이 바로 메모리 관리다. “자바스크립트는 가비지 컬렉터가 알아서 다 해주지 않나요?”라고 반문할 수도 있다. 맞는 말이지만, 동시에 틀린 말이기도 하다. 자동화된 시스템이 모든 것을 해결해 줄 것이라는 막연한 믿음은 예상치 못한 메모리 누수와 성능 저하라는 재앙을 불러올 수 있다.

이 핸드북은 자바스크립트 메모리 관리의 세계를 깊이 탐험하기 위해 만들어졌다. 단순히 ‘메모리를 아껴 쓰자’는 식의 피상적인 조언을 넘어, 자바스크립트 엔진이 메모리를 어떻게 다루는지 그 근본 원리부터 시작해, 메모리 누수가 발생하는 구체적인 시나리오와 해결책, 그리고 코드를 한 차원 더 높은 수준으로 최적화하는 고급 기법까지 모든 것을 담았다. 이 글을 끝까지 읽고 나면, 당신은 더 이상 메모리 관리를 두려워하지 않고, 오히려 자신감 있게 코드를 제어하며 최적의 성능을 이끌어내는 개발자로 거듭나게 될 것이다.


1. 탄생 배경 자바스크립트는 왜 자동 메모리 관리를 선택했나

컴퓨터 과학의 초기, C/C++와 같은 저수준 언어(low-level language)들은 개발자에게 메모리 관리의 모든 권한과 책임을 부여했다. malloc()으로 필요한 만큼 메모리를 직접 할당하고, 사용이 끝나면 free()를 통해 반드시 해제해야 했다. 이는 하드웨어를 정밀하게 제어할 수 있다는 강력한 장점이 있었지만, 동시에 치명적인 단점을 안고 있었다. 바로 인간의 실수다.

메모리를 해제하는 것을 잊거나(메모리 누수, Memory Leak), 이미 해제된 메모리에 접근하려 하거나(댕글링 포인터, Dangling Pointer), 할당된 범위를 넘어 데이터를 쓰는(버퍼 오버플로우, Buffer Overflow) 등의 실수는 애플리케이션을 불안정하게 만들고, 때로는 시스템 전체를 마비시키는 원인이 되었다.

자바스크립트가 탄생한 1990년대 중반, 웹은 점점 더 동적이고 복잡한 상호작용을 요구하기 시작했다. 넷스케이프는 웹 페이지에 생동감을 불어넣을 스크립트 언어가 필요했고, 이 언어는 C++ 개발자뿐만 아니라 더 넓은 범위의 개발자들이 쉽고 빠르게 접근할 수 있어야 했다. 만약 자바스크립트마저 개발자에게 수동 메모리 관리를 요구했다면, 웹 개발의 진입 장벽은 훨씬 높아졌을 것이고 지금과 같은 폭발적인 생태계 확장은 불가능했을지도 모른다.

이러한 배경 속에서 자바스크립트는 가비지 컬렉션(Garbage Collection, GC) 이라는 자동 메모리 관리 방식을 채택했다. 개발자가 메모리 할당과 해제 시점을 일일이 신경 쓰지 않아도, 엔진이 알아서 ‘더 이상 사용되지 않는’ 메모리를 찾아내고 회수하는 것이다. 이로써 개발자는 비즈니스 로직 구현이라는 본질적인 목표에 더 집중할 수 있게 되었다. 즉, 자바스크립트의 자동 메모리 관리는 생산성안정성이라는 두 마리 토끼를 잡기 위한 탁월한 선택이었다.


2. 구조 자바스크립트 엔진의 메모리 저장소

자바스크립트 코드가 실행될 때, 엔진은 메모리를 여러 영역으로 나누어 사용한다. 가장 핵심적인 두 공간이 바로 스택(Stack)힙(Heap) 이다. 이 둘의 작동 방식을 이해하는 것이 메모리 관리의 첫걸음이다.

2.1. 스택 (Stack) 정적 메모리 할당

  • 역할: 원시 타입(Primitive Types) 데이터(Number, String, Boolean, null, undefined, Symbol, BigInt)와 함수 호출 정보(Execution Context)를 저장한다.

  • 특징:

    • 정적 할당: 컴파일 시점에 크기가 결정된다. 변수에 원시 값이 할당되면, 스택에는 그 값 자체가 저장된다.

    • LIFO (Last-In, First-Out): 가장 마지막에 들어온 데이터가 가장 먼저 나가는 ‘후입선출’ 구조다. 함수가 호출되면 해당 함수의 실행 컨텍스트가 스택에 쌓이고(push), 함수 실행이 끝나면 스택에서 제거된다(pop).

    • 빠른 속도: 데이터 크기가 고정되어 있고, 포인터 이동만으로 간단하게 메모리를 할당하고 해제할 수 있어 속도가 매우 빠르다.

    • 크기 제한: 스택의 크기는 제한적이어서, 너무 많은 데이터를 쌓으면 ‘스택 오버플로우(Stack Overflow)’ 에러가 발생할 수 있다. (재귀 함수가 무한 루프에 빠지는 경우가 대표적이다.)

JavaScript

function greet(name) {
  let message = "Hello, " + name; // message, name 변수는 스택에 저장
  console.log(message);
}

function main() {
  let user = "Alice"; // user 변수는 스택에 저장
  greet(user);
}

main();

위 코드에서 main 함수가 호출되면 main의 실행 컨텍스트가 스택에 쌓이고, user 변수가 그 안에 저장된다. 이어서 greet 함수가 호출되면 greet의 실행 컨텍스트가 main 위에 쌓이고, namemessage 변수가 저장된다. greet 함수 실행이 끝나면 스택에서 greet의 컨텍스트가 사라지고, main 함수 실행이 끝나면 main의 컨텍스트도 사라지며 프로그램이 종료된다.

2.2. 힙 (Heap) 동적 메모리 할당

  • 역할: 객체(Object), 배열(Array), 함수(Function) 등 참조 타입(Reference Types) 데이터를 저장한다.

  • 특징:

    • 동적 할당: 프로그램 실행 중에 데이터의 크기가 결정된다. 객체의 크기는 정해져 있지 않고, 런타임에 프로퍼티가 추가되거나 삭제될 수 있기 때문이다.

    • 복잡한 구조: 힙은 스택처럼 정돈된 구조가 아니라, 비어있는 공간을 찾아 데이터를 저장하는 방식이다. 이로 인해 메모리 할당과 해제 과정이 스택보다 복잡하고 느리다.

    • 참조: 변수에는 실제 데이터가 저장된 힙 메모리의 주소값(참조)만 스택에 저장된다.

JavaScript

let person1 = { name: "Alice" }; // 1. 힙에 { name: "Alice" } 객체 생성
                                  // 2. person1 변수는 스택에 생성되고, 힙에 있는 객체의 주소값을 저장
let person2 = person1;          // 3. person2 변수도 스택에 생성되고, 같은 주소값을 복사하여 저장

person2.name = "Bob";

console.log(person1.name); // "Bob"

이 예제에서 person1person2는 스택에 저장되지만, 그들이 가리키는 실제 객체 데이터는 힙에 존재한다. person2 = person1 코드는 주소값을 복사하는 것이므로, 두 변수는 힙에 있는 동일한 객체를 참조하게 된다. 따라서 person2의 프로퍼티를 변경하면 person1에도 영향을 미친다.

이처럼 스택과 힙은 각자의 역할에 맞게 메모리를 효율적으로 관리하는 핵심적인 공간이다.


3. 작동 원리 가비지 컬렉션의 마법

자바스크립트의 자동 메모리 관리는 가비지 컬렉터(Garbage Collector, GC) 가 수행한다. GC의 핵심 임무는 단 하나, “더 이상 필요 없는 메모리(가비지)를 찾아내어 회수하는 것”이다. 그렇다면 GC는 무엇을 기준으로 ‘필요 없는’ 메모리를 판단할까? 바로 ‘도달 가능성(Reachability)’ 이라는 개념이다.

‘도달 가능하다’는 것은 어떻게든 접근하거나 사용할 수 있는 값을 의미한다. GC는 주기적으로 루트(Root)에서부터 시작하여, 참조로 연결된 모든 객체를 탐색한다. 여기서 루트는 일반적으로 전역 변수(window 객체)나 현재 실행 중인 함수의 지역 변수 등을 의미한다.

3.1. Mark-and-Sweep 알고리즘

현대 자바스크립트 엔진이 사용하는 가장 대표적인 GC 알고리즘은 Mark-and-Sweep(표시하고-쓸기) 이다. 이 과정은 이름처럼 두 단계로 나뉜다.

  1. 표시 (Mark) 단계:

    • GC는 루트에서 시작하여, 참조를 따라 이동하며 접근 가능한 모든 객체에 ‘살아있음’ 표시를 한다.

    • 예를 들어, 전역 변수 A가 객체 B를 참조하고, B가 객체 C를 참조한다면, A, B, C 모두 ‘살아있음’으로 표시된다.

  2. 쓸기 (Sweep) 단계:

    • GC는 전체 힙 메모리를 순회하며, ‘살아있음’ 표시가 없는 모든 객체를 ‘가비지’로 간주하고 해당 메모리를 회수한다.

    • 회수된 메모리는 다시 ‘빈 공간’으로 관리되어, 이후 새로운 객체를 할당하는 데 사용된다.

이러한 과정을 통해 개발자가 명시적으로 deletefree를 호출하지 않아도, 참조가 끊어진 객체들은 알아서 메모리에서 사라지게 된다.

3.2. V8 엔진의 최적화: 세대별 가비지 컬렉션 (Generational GC)

Mark-and-Sweep 방식은 매우 효과적이지만, 전체 힙 메모리를 매번 스캔해야 하므로 객체가 많아질수록 성능 저하를 유발할 수 있다. 구글의 V8 엔진(Chrome, Node.js에서 사용)은 이를 최적화하기 위해 세대별 가비지 컬렉션이라는 기법을 사용한다.

이 기법은 “대부분의 객체는 생성된 지 얼마 안 되어 쓸모없어진다”는 ‘세대 가설(Generational Hypothesis)‘에 기반한다.

  • 영 제너레이션 (Young Generation): 새로 생성된 객체들이 위치하는 공간. 이 공간은 매우 작고, GC가 매우 빈번하게 발생한다.

    • Scavenger (Minor GC): 영 제너레이션에서 발생하는 GC. 매우 빠르며, 살아남은 객체는 다른 공간으로 이동시킨다. 여기서 여러 번 살아남은 객체는 ‘오래된’ 객체로 간주되어 올드 제너레이션으로 이동(Promotion)된다.
  • 올드 제너레이션 (Old Generation): 영 제너레이션에서 살아남은 객체들이 이동하는 공간. 이 공간의 객체들은 상대적으로 오래 살아남을 가능성이 높다고 판단되므로, GC가 덜 빈번하게 발생한다.

    • Mark-Sweep-Compact (Major GC): 올드 제너레이션에서 발생하는 GC. 일반적인 Mark-and-Sweep을 수행한 후, 단편화(fragmentation) 를 줄이기 위해 살아남은 객체들을 한쪽으로 모아 메모리를 압축하는(Compact) 과정을 추가로 진행한다.

이처럼 V8 엔진은 객체의 ‘나이’에 따라 다른 전략을 사용하여 GC의 효율을 극대화하고, 애플리케이션의 멈춤 현상(Stop-the-world)을 최소화한다.


4. 사용법 메모리 누수 식별 및 해결

가비지 컬렉터는 강력하지만 만능은 아니다. 개발자의 코딩 습관에 따라 GC가 수거해가지 못하는 ‘가비지 아닌 척하는 가비지’가 발생할 수 있는데, 이를 메모리 누수(Memory Leak) 라고 한다. 메모리 누수가 누적되면 애플리케이션의 성능이 점차 저하되고, 결국에는 탭이 멈추거나 브라우저가 강제 종료되는 최악의 상황을 맞이할 수 있다.

4.1. 흔한 메모리 누수 유형

1) 의도치 않은 전역 변수 (Accidental Global Variables)

var, let, const 키워드 없이 변수를 선언하면 해당 변수는 전역 객체(브라우저에서는 window)의 프로퍼티가 된다. 전역 변수는 애플리케이션이 종료될 때까지 살아있으므로, GC의 수거 대상이 되지 않는다.

JavaScript

// 나쁜 예
function createGlobal() {
  leakedData = new Array(1000000).join('*'); // leakedData는 window.leakedData가 됨
}

// 좋은 예
function createLocal() {
  'use strict'; // 스트릭트 모드를 사용하면 이런 실수를 방지할 수 있음
  const localData = new Array(1000000).join('*');
}

해결책: 항상 'use strict'; 모드를 사용하고, 모든 변수는 constlet으로 선언하는 습관을 들인다.

2) 잊혀진 타이머와 콜백 (Forgotten Timers or Callbacks)

setInterval이나 setTimeout과 같은 타이머 함수는 콜백 함수에 대한 참조를 유지한다. 만약 타이머를 시작하고 이를 명시적으로 중지(clearInterval, clearTimeout)하지 않으면, 콜백 함수와 콜백이 참조하는 외부 변수들은 영원히 메모리에 남게 된다.

JavaScript

// 나쁜 예
function startTimer() {
  const data = { value: Math.random() };
  setInterval(() => {
    // 이 콜백 함수는 data 객체를 계속 참조하고 있음
    console.log(data.value);
  }, 1000);
  // clearInterval이 호출되지 않으면 data 객체는 영원히 해제되지 않음
}

// 좋은 예
function startTimer() {
  const data = { value: Math.random() };
  const timerId = setInterval(() => {
    console.log(data.value);
  }, 1000);

  // 필요 없어지는 시점에 반드시 타이머를 제거해야 함
  // 예: 컴포넌트가 언마운트될 때, 페이지를 벗어날 때 등
  // someCondition.on('destroy', () => clearInterval(timerId));
}

해결책: 타이머, 이벤트 리스너 등 콜백을 사용하는 비동기 작업은 반드시 필요 없어지는 시점에 명시적으로 제거하는 코드를 작성한다. (예: React의 useEffect 클린업 함수)

3) 분리된 DOM 노드 (Detached DOM nodes)

DOM 요소를 변수에 저장해두었는데, 해당 요소가 DOM 트리에서 제거된 경우에 발생한다. 자바스크립트 코드에서는 여전히 이 변수를 통해 DOM 요소를 참조하고 있지만, 화면에는 보이지 않으므로 사실상 가비지나 다름없다.

JavaScript

let detachedElement;

function createAndRemove() {
  const element = document.createElement('div');
  element.textContent = 'I will be detached';
  document.body.appendChild(element);

  detachedElement = element; // 전역 변수가 DOM 요소를 참조

  // DOM 트리에서는 제거되었지만...
  document.body.removeChild(element);
}

createAndRemove();
// 이제 detachedElement는 화면에 보이지 않는 '유령' DOM 노드를 참조하고 있다.
// 이 참조가 사라지지 않는 한, 이 DOM 노드와 관련된 모든 메모리는 해제되지 않는다.

해결책: DOM 요소에 대한 참조는 필요 없을 때 null을 할당하여 명시적으로 연결을 끊어주는 것이 좋다.

4) 클로저의 오용 (Closures)

클로저는 자바스크립트의 강력한 기능이지만, 잘못 사용하면 메모리 누수의 주범이 될 수 있다. 내부 함수가 외부 함수의 변수를 참조할 때 클로저가 생성되는데, 이 내부 함수가 살아있는 한 외부 함수의 변수들도 메모리에 계속 남게 된다.

JavaScript

function outer() {
  const largeData = new Array(1000000).join('*'); // 거대한 데이터

  // 이 내부 함수는 largeData를 참조하는 클로저를 생성
  return function inner() {
    // 무언가 작업을 하지만 largeData를 직접 사용하지는 않음
    return 1;
  };
}

const myClosure = outer();
// myClosure가 살아있는 동안, outer 스코프의 largeData도 메모리에 계속 남아있게 된다.

해결책: 클로저를 사용할 때는 필요한 변수만 최소한으로 참조하도록 스코프를 신중하게 설계해야 한다.

4.2. 크롬 개발자 도구를 이용한 누수 탐지

메모리 누수를 진단하는 가장 강력한 도구는 브라우저의 개발자 도구다. 크롬을 기준으로 Performance 탭과 Memory 탭을 활용할 수 있다.

  1. Performance 탭:

    • 기록을 시작하고, 메모리 누수가 의심되는 액션을 여러 번 반복한다.

    • 그래프에서 ‘JS heap’ 사이즈가 시간이 지나도 줄어들지 않고 계단식으로 계속 증가한다면 메모리 누수를 의심할 수 있다.

  2. Memory 탭:

    • Heap snapshot: 특정 시점의 메모리 상태를 사진 찍듯이 저장한다.

    • 액션을 취하기 전과 후, 두 번의 스냅샷을 찍는다.

    • 두 번째 스냅샷을 선택하고 ‘Comparison’ 뷰로 변경하면, 두 스냅샷 사이의 메모리 변화를 비교할 수 있다. 여기서 비정상적으로 크기가 증가했거나, 사라져야 할 객체가 여전히 남아있는 것을 발견할 수 있다.

    • ‘Retainers’ 패널을 통해 해당 객체에 대한 참조가 어디서부터 시작되는지 역추적하여 누수의 원인을 찾을 수 있다.


5. 심화 내용 메모리 효율을 극대화하는 코드 작성법

메모리 누수를 방지하는 것을 넘어, 더 적극적으로 메모리를 효율적으로 사용하는 방법들이 있다.

5.1. 객체 참조 해제

더 이상 사용하지 않는 객체, 특히 큰 객체나 DOM 요소에 대한 참조는 null을 할당하여 명시적으로 연결을 끊어주는 것이 좋다. 이는 GC에게 해당 객체가 더 이상 필요 없다는 강력한 힌트를 준다.

JavaScript

let largeObject = { /* ... 매우 큰 데이터 ... */ };
// largeObject를 사용한 작업 수행
// ...

// 더 이상 필요 없다면
largeObject = null; // GC가 다음 실행 주기에서 메모리를 회수할 수 있도록 함

5.2. 스코프 체인 최소화

자바스크립트 엔진은 변수를 찾을 때 현재 스코프부터 시작하여 상위 스코프로 거슬러 올라가는 ‘스코프 체인’을 탐색한다. 클로저나 중첩 함수가 많아지면 스코프 체인이 길어지고, 이는 변수 탐색 시간을 늘릴 뿐만 아니라 더 많은 변수를 메모리에 유지하게 만들 수 있다. 가급적이면 함수 중첩을 최소화하고, 필요한 데이터는 인자로 전달하는 것이 좋다.

5.3. WeakMap과 WeakSet 활용

ES6에 도입된 WeakMapWeakSet은 ‘약한 참조(Weak Reference)‘를 만드는 특별한 컬렉션이다. 일반적인 Map이나 Set은 키(또는 값)로 추가된 객체에 대한 참조를 유지하므로, 다른 곳에서 해당 객체에 대한 참조가 모두 사라져도 Map이나 Set이 살아있는 한 GC의 대상이 되지 않는다.

하지만 WeakMapWeakSet의 참조는 ‘약하기’ 때문에, 다른 곳에서 원본 객체에 대한 참조가 모두 사라지면 GC는 이 객체를 메모리에서 제거할 수 있다. 이때 WeakMap/WeakSet에서도 해당 항목은 자동으로 사라진다.

이는 특히 DOM 요소에 대한 메타데이터를 저장하거나, 객체를 캐싱하는 용도로 사용할 때 메모리 누수를 방지하는 데 매우 유용하다.

JavaScript

// Map을 사용하는 경우 (메모리 누수 가능성)
let element = document.getElementById('my-element');
const metadataMap = new Map();
metadataMap.set(element, { data: 'some info' });

element.remove(); // DOM에서는 제거되었지만...
// metadataMap이 element 객체를 계속 참조하고 있으므로 메모리에서 해제되지 않음

// WeakMap을 사용하는 경우
let element2 = document.getElementById('my-element-2');
const metadataWeakMap = new WeakMap();
metadataWeakMap.set(element2, { data: 'some info' });

element2.remove(); // DOM에서 제거되면...
// 다른 참조가 없으므로 element2 객체는 GC 대상이 되고,
// metadataWeakMap에서도 해당 항목이 자동으로 제거됨. 메모리 누수 방지!

결론 더 나은 개발자를 향한 길

자바스크립트 메모리 관리는 ‘보이지 않는 위협’과 같다. 평소에는 문제없이 작동하는 것처럼 보이지만, 방치하면 어느 순간 애플리케이션의 발목을 잡는 치명적인 성능 저하로 이어진다. 자동화된 가비지 컬렉터가 많은 짐을 덜어주는 것은 사실이지만, 그것이 우리에게 주어진 면죄부는 아니다.

이 핸드북을 통해 우리는 자바스크립트 엔진이 메모리를 어떻게 다루는지, 스택과 힙의 역할부터 가비지 컬렉션의 정교한 알고리즘까지 그 내부를 들여다보았다. 또한, 의도치 않은 전역 변수, 잊혀진 타이머, 분리된 DOM 노드 등 메모리 누수를 유발하는 흔한 실수들과 이를 해결하는 방법을 배웠다. 마지막으로, 크롬 개발자 도구를 활용해 문제를 진단하고, WeakMap과 같은 고급 기능을 통해 코드를 한 단계 더 최적화하는 방법까지 살펴보았다.

결국, 뛰어난 개발자는 단순히 기능을 구현하는 사람을 넘어, 자신이 작성한 코드가 시스템 자원을 어떻게 사용하고 어떤 영향을 미치는지 깊이 이해하는 사람이다. 메모리 관리에 대한 이해는 당신의 코드를 더 안정적이고, 더 빠르고, 더 효율적으로 만들어 줄 것이다. 오늘부터라도 당신의 코드 속에 숨어있는 메모리 누수는 없는지, 더 효율적으로 개선할 부분은 없는지 살펴보는 습관을 들여보자. 그 작은 노력이 당신을 더 나은 개발자로 이끌어 줄 것이다.