Published on

JavaScript 가비지 컬렉션의 동작 원리

Authors
  • avatar
    Name
    GW

개요

JavaScript를 공부하다 보면 한 번쯤 이런 궁금증이 생깁니다.

함수가 종료되면 변수는 언제 사라질까?

사용하지 않는 객체는 누가 삭제해 주는 걸까?

C나 C++처럼 메모리를 직접 해제해야 하는 언어를 접해 본 적이 있다면 더욱 신기하게 느껴질 수 있습니다.

JavaScript에서는 free()나 delete 같은 메모리 해제 함수를 거의 사용하지 않습니다.

그럼에도 대부분의 프로그램이 정상적으로 동작하는 이유는 가비지 컬렉터(Garbage Collector) 가 사용하지 않는 메모리를 자동으로 찾아 해제해 주기 때문입니다.

덕분에 개발자는 메모리 관리보다 비즈니스 로직에 집중할 수 있습니다.

하지만 "자동으로 관리된다"는 것이 메모리 누수가 절대 발생하지 않는다는 뜻은 아닙니다.

이번 글에서는 다음 내용을 살펴보겠습니다.

  • JavaScript가 메모리를 어떻게 관리하는지
  • 가비지 컬렉터는 어떤 기준으로 메모리를 해제하는지
  • 실무에서 메모리 누수는 왜 발생하는지

메모리는 왜 관리해야 할까?

프로그램을 실행하면 다양한 데이터를 메모리에 저장하게 됩니다.

예를 들어 다음과 같은 코드가 있다고 해보겠습니다.

const name = '철수';

const profile = {
  age: 20,
  city: '서울',
};

문자열과 객체는 모두 메모리에 저장됩니다.

프로그램이 계속 실행되면서 새로운 객체를 만들기만 하고, 더 이상 사용하지 않는 데이터를 삭제하지 않으면 어떻게 될까요?

메모리는 무한하지 않기에 사용 가능한 공간이 점점 줄어들고, 결국 새로운 데이터를 저장하지 못하는 상황이 발생합니다.

그래서 더 이상 사용하지 않는 메모리를 적절한 시점에 해제하는 작업이 반드시 필요합니다.

JavaScript는 메모리를 어떻게 관리할까?

JavaScript의 메모리는 크게 Stack과 Heap으로 나누어 생각할 수 있습니다.

Memory
├── Stack
│   ├── number
│   ├── string
│   └── 객체를 가리키는 참조값
└── Heap
    ├── Object
    ├── Array
    └── Function

Stack에는 원시 값이나 객체를 가리키는 참조(reference)가 저장되고,

Heap에는 객체, 배열, 함수처럼 크기가 동적으로 변할 수 있는 데이터가 저장됩니다.

const person = {
  name: '철수',
};

예를 들어 위 코드의 메모리 구조를 단순화해서 표현하면 다음과 같습니다.

Stack                     Heap
┌──────────────┐          ┌──────────────────┐
│ person       │          │                  │
│ └─ 참조값 ─────┼─────────→│ { name: '철수' }  │
│              │          │                  │
└──────────────┘          └──────────────────┘

person 변수는 객체 자체를 저장하는 것이 아니라, Heap에 있는 객체를 가리키는 참조를 가집니다.

이때 중요한 것은 Heap에 있는 객체는 참조가 남아 있는 동안 계속 메모리에 존재한다는 점입니다.

가비지 컬렉션이란?

가비지 컬렉션은 더 이상 사용할 수 없는 메모리를 자동으로 찾아 해제하는 과정입니다.

그리고 이러한 가비지 컬렉션을 수행하는 JavaScript 엔진의 메모리 관리 구성 요소를 가비지 컬렉터 라고 합니다.

즉, 둘을 간단히 구분하면 다음과 같습니다.

  • 가비지 컬렉션(Garbage Collection): 사용하지 않는 메모리를 찾아 회수하는 과정
  • 가비지 컬렉터(Garbage Collector): 가비지 컬렉션을 수행하는 주체

여기서 많은 사람들이 "사용하지 않는다"를 "코드에서 더 이상 쓰지 않는다."라고 생각합니다.

하지만 JavaScript가 판단하는 기준은 조금 다릅니다.

JavaScript는 현재 접근 가능한 객체인지를 기준으로 메모리를 관리합니다.

따라서 객체가 메모리에 유지되는지는 해당 객체에 접근할 수 있는지에 따라 결정됩니다.

  • 접근 가능한 객체 → 메모리에 계속 유지
  • 접근 불가능한 객체 → 가비지 컬렉션 대상

언제 가비지 컬렉션의 대상이 될까?

다음 코드를 살펴보겠습니다.

let person = {
  name: '철수',
};

person = null;

객체가 생성된 직후에는 person이 Heap에 있는 객체를 참조하고 있습니다.

Stack                     Heap
┌──────────────┐          ┌──────────────────┐
│ person       │          │                  │
│ └─ 참조값 ─────┼─────────→│ { name: '철수' }  │
│              │          │                  │
└──────────────┘          └──────────────────┘

이 상태에서는 person을 통해 객체에 접근할 수 있기 때문에 객체가 메모리에 유지됩니다.

이후 person = null을 실행하면 person이 더 이상 기존 객체를 참조하지 않게 됩니다.

Stack                     Heap
┌──────────────┐          ┌──────────────────┐
│ person       │          │                  │
│ └─ null      │          │ { name: '철수' }  │
│              │          │                  │
└──────────────┘          └──────────────────┘
                                  ↑
                             참조하는 값 없음

이제 Heap의 객체를 가리키는 참조가 사라져 어디에서도 접근할 수 없는 상태가 됐습니다.

따라서 이 객체는 가비지 컬렉션의 대상이 됩니다.

여기서 중요한 점은 person = null을 실행했다고 즉시 메모리가 해제되는 것은 아니라는 것입니다.

JavaScript 엔진은 적절한 시점에 가비지 컬렉션을 실행하고, 접근할 수 없는 객체를 찾아 메모리를 회수합니다.

Mark and Sweep은 어떻게 동작할까?

그렇다면 JavaScript는 어떤 기준으로 메모리를 해제할까요?

대표적으로 사용하는 방식이 Mark and Sweep 알고리즘입니다.

동작은 생각보다 단순합니다.

먼저 JavaScript 엔진은 GC(Garbage Collection) Root를 시작점으로 선택합니다.

GC Root는 가비지 컬렉터가 메모리를 추적하기 시작하는 출발점으로, 전역 변수나 현재 실행 중인 함수에서 접근할 수 있는 값 등이 여기에 포함될 수 있습니다.

가비지 컬렉터는 이러한 GC Root에서 참조를 따라가며 도달 가능한 객체를 모두 표시(Mark) 합니다.

표시가 끝나면 표시된 객체는 메모리에 유지하고, 표시되지 않은 객체는 더 이상 접근할 수 없는 객체로 판단하여 메모리에서 제거(Sweep) 합니다.

아래 그림을 살펴보겠습니다.

Mark and Sweep 알고리즘

그림에서 초록색 객체(A, B, C, E)는 GC Root에서 직접 또는 간접적으로 도달할 수 있는 객체입니다.

따라서 Mark 단계에서 모두 표시되고 메모리에 계속 유지됩니다.

반면 회색 객체(D, F)는 GC Root에서 어떤 경로로도 도달할 수 없습니다.

이 객체들은 Mark 단계에서 표시되지 않으며, 이후 Sweep 단계에서 메모리가 해제됩니다.

즉, JavaScript는 객체가 언제 생성되었는지가 아니라,

GC Root에서 도달 가능한 객체인지

를 기준으로 메모리를 관리합니다.

조금 단순하게 표현하면 다음과 같습니다.

GC Root에서 도달 가능
↓
Mark (도달한 객체를 표시)
↓
메모리에 유지
GC Root에서 도달 불가능
↓
Mark되지 않음
↓
Sweep (메모리에서 제거)

많은 사람들이 "참조가 하나도 없으면 메모리가 해제된다." 라고 생각하지만, 조금 더 정확하게는

GC Root에서 도달할 수 없는 객체가 메모리 해제 대상이 됩니다.

즉, 객체끼리 서로 참조하고 있더라도 GC Root에서 도달할 수 없다면 모두 함께 가비지 컬렉션의 대상이 됩니다.

클로저는 왜 메모리가 해제되지 않을까?

여기까지 읽었다면 이런 의문이 생길 수 있습니다.

함수는 이미 종료됐는데, 클로저의 변수는 왜 계속 살아 있을까?

이전 클로저 글에서 살펴봤던 예제를 다시 가져와 보겠습니다.

const createCounter = () => {
  let count = 0;

  return () => {
    count += 1;
    return count;
  };
};

const counter = createCounter();

createCounter()는 이미 실행이 끝났지만 count는 계속 사용할 수 있습니다.

console.log(counter()); // 1
console.log(counter()); // 2

이유는 count가 여전히 접근 가능한 상태이기 때문입니다.

현재 메모리 구조를 단순하게 표현하면 다음과 같습니다.

counter
└─ 참조 → 반환된 함수
             │
             └─ [[Environment]] → Lexical Environment
                                      │
                                      └─ count = 0

counter는 createCounter()가 반환한 함수를 참조하고 있으며, 이 함수는 [[Environment]]를 통해 자신이 생성된 렉시컬 환경을 계속 참조합니다.

여기서 [[Environment]]는 함수가 생성될 때 외부 렉시컬 환경을 참조하기 위해 가지는 내부 슬롯입니다.

따라서 렉시컬 환경에 있는 count 역시 여전히 접근 가능한 상태로 남아 있습니다.

즉, 함수의 실행이 끝났다고 해서 해당 함수의 렉시컬 환경이 무조건 메모리에서 제거되는 것은 아닙니다.

가비지 컬렉터는 GC Root에서 참조를 따라 해당 데이터에 도달할 수 있는지를 기준으로 메모리를 관리합니다.

결국 counter를 통해 반환된 함수에 접근할 수 있는 동안에는 렉시컬 환경과 count 역시 계속 메모리에 유지됩니다.

메모리 누수는 어떻게 발생할까?

JavaScript는 메모리를 자동으로 관리하지만, 개발자의 코드 때문에 메모리가 계속 남아 있는 경우가 있습니다.

이를 메모리 누수(Memory Leak) 라고 합니다.

대표적인 예를 살펴보겠습니다.

useEffect(() => {
  const handleResize = () => {
    console.log(window.innerWidth);
  };

  window.addEventListener('resize', handleResize);
}, []);

겉보기에는 문제가 없어 보입니다.

하지만 컴포넌트가 사라져도 이벤트 리스너를 제거하지 않았다면 어떻게 될까요?

window가 계속 이벤트 리스너를 참조하고 있기 때문에 컴포넌트와 관련된 객체들이 메모리에 남아 있게 됩니다.

그래서 이벤트를 등록했다면 반드시 정리(cleanup)도 함께 해주는 것이 좋습니다.

useEffect(() => {
  const handleResize = () => {
    console.log(window.innerWidth);
  };

  window.addEventListener('resize', handleResize);

  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);

이처럼 불필요한 참조를 제거해야 가비지 컬렉터가 메모리를 정상적으로 회수할 수 있습니다.

이벤트 리스너 외에도 메모리 누수는 다양한 상황에서 발생할 수 있습니다.

  • 해제하지 않은 setInterval
  • 사용하지 않는 전역 변수
  • 참조를 계속 유지하는 캐시 객체
  • DOM을 계속 저장하고 있는 변수

공통점은 모두 더 이상 필요하지 않은 객체를 계속 참조하고 있다는 것입니다.

왜 JavaScript는 메모리를 자동으로 관리할까?

JavaScript는 웹 브라우저에서 누구나 쉽게 사용할 수 있는 언어를 목표로 만들어졌습니다.

만약 C처럼 개발자가 직접 메모리를 해제해야 했다면,

  • 메모리를 너무 일찍 해제하거나
  • 해제하지 못해 메모리가 계속 증가하거나
  • 이미 해제된 메모리에 접근하는 문제

등이 매우 자주 발생했을 것입니다.

그래서 JavaScript는 메모리 관리를 언어가 담당하도록 설계했습니다.

덕분에 개발자는 대부분의 경우 메모리 해제를 직접 신경 쓰지 않고도 안전하게 프로그램을 작성할 수 있습니다.

물론 자동으로 관리된다고 해서 모든 문제가 해결되는 것은 아닙니다.

가비지 컬렉터는 참조가 남아 있는 객체는 사용 중이라고 판단하기 때문에, 불필요한 참조를 계속 유지하면 메모리 누수는 여전히 발생할 수 있습니다.

자주 하는 오해

null을 대입하면 메모리가 즉시 해제된다?

아닙니다.

const person = {
  name: '철수',
};

person = null;

위 코드에서 person = null을 실행했다고 해서 바로 메모리가 해제되는 것은 아닙니다.

객체가 더 이상 접근할 수 없는 상태가 되면, 다음 가비지 컬렉션이 실행될 때 메모리가 회수됩니다.

함수가 종료되면 내부 변수는 모두 사라진다?

그렇지 않습니다.

클로저처럼 다른 함수가 해당 변수를 계속 참조하고 있다면 메모리는 유지됩니다.

가비지 컬렉터는 함수의 종료 여부가 아니라 현재 접근 가능한지 여부를 기준으로 판단합니다.

정리

JavaScript는 가비지 컬렉터를 통해 사용하지 않는 메모리를 자동으로 관리합니다.

하지만 "사용하지 않는다"는 것은 개발자가 더 이상 필요하지 않다고 생각하는 것이 아니라, 현재 접근 가능한 참조가 존재하는지를 의미합니다.

핵심 내용을 정리하면 다음과 같습니다.

  • JavaScript는 Stack과 Heap을 이용해 메모리를 관리합니다.
  • 객체는 참조가 남아 있는 동안 메모리에 유지됩니다.
  • 가비지 컬렉터는 접근 가능한 객체만 유지하고, 접근할 수 없는 객체를 회수합니다.
  • 대표적인 알고리즘은 Mark and Sweep입니다.
  • 클로저는 참조가 유지되므로 메모리가 해제되지 않습니다.
  • 메모리 누수는 대부분 불필요한 참조를 계속 유지하는 것에서 시작됩니다.

자동 메모리 관리는 JavaScript의 큰 장점 중 하나입니다.

하지만 가비지 컬렉터가 모든 문제를 해결해 주는 것은 아닙니다.

객체가 언제 메모리에서 사라지는지 이해하면 클로저, 이벤트 리스너, 캐시, React 컴포넌트의 생명주기까지 더 깊이 이해할 수 있으며, 불필요한 메모리 사용과 메모리 누수를 예방하는 데도 큰 도움이 됩니다.

참고 자료