본문으로 건너뛰기

참조와 도달 가능성: 객체는 언제 회수 대상이 될까?

변수에 null을 대입하면 그 변수가 가리키던 객체도 사라질까? 다음 예제에서는 user의 참조를 끊어도 같은 객체를 계속 사용할 수 있다.

let user = { name: '민수' };
const users = [user];

user = null;

console.log(users[0].name); // '민수'

users.length = 0;

user = null은 변수에서 객체로 향하는 연결 하나만 끊는다. 그때는 users[0]을 통한 연결이 남아 있다. 마지막 줄에서 배열의 항목까지 제거하면, 다른 연결이 없다는 전제에서 객체는 가비지 컬렉션의 회수 대상이 될 수 있다. 실제로 언제 회수될지는 알 수 없다.

여기서 참조는 객체로 이어지는 연결이다. 객체가 회수 대상인지 판단하려면 GC가 어디에서 출발해 이 연결을 따라가는지도 알아야 한다.

이 글에서는 참조 경로가 생기고 끊기는 과정을 먼저 살펴본다. 이어서 순환 참조와 약한 참조를 예로 들어 도달 가능성을 어떻게 판단하는지 확인한다.


1. 참조: 객체를 대입하면 무엇이 복사될까?​

객체를 다른 변수에 대입하면 객체 자체가 복제되지 않는다. 객체를 가리키는 참조 값이 복사되어 두 변수가 같은 객체를 가리킨다.

let user = { name: '민수' };
let member = user;

member.name = '지수';

console.log(user.name); // '지수'
console.log(user === member); // true

user와 member는 서로 다른 변수지만 같은 객체에 접근한다. 따라서 member를 통해 프로퍼티를 수정하면 user로 읽을 때도 변경된 값을 확인한다.

user = null;

console.log(member.name); // '지수'

user = null은 user가 가진 참조를 끊는 동작이다. 객체 자체를 삭제하거나 member의 값을 바꾸는 명령은 아니다. member를 통한 연결은 여전히 남아 있다.

변수뿐 아니라 객체의 프로퍼티와 배열의 항목도 객체를 참조할 수 있다. 참조를 하나 끊는다고 객체로 이어지는 모든 연결이 사라지는 것은 아니다.


2. 도달 가능성: 어떤 참조가 남아 있어야 할까?​

GC가 참조를 따라가기 시작하는 지점을 **루트(root)**라고 한다. 실행 중인 코드가 사용하는 참조, 전역 영역에 보관된 참조, 런타임이 유지하는 참조 등이 루트에 포함된다. 정확한 구성은 엔진과 실행 환경에 따라 다르다.

도달 가능성(Reachability) 은 이 루트에서 참조를 따라 대상 객체까지 갈 수 있는지를 뜻한다. 루트에서 이어지는 경로가 하나라도 있으면 그 객체는 도달 가능하다.

let user = { name: '민수' };

const team = {
leader: user,
};

user = null;

console.log(team.leader.name); // '민수'

user의 참조는 끊겼지만 team.leader의 참조는 남아 있다. 예제 실행 중 team이 도달 가능한 상태라면 사용자 객체에도 도달할 수 있다.

루트 → team 객체 → leader 프로퍼티 → 사용자 객체

이 경로에서 team을 가리키는 쪽과 team.leader 중 하나만 끊어져도 이 경로로는 사용자 객체에 도달할 수 없다. 다만 다른 경로까지 모두 확인해야 회수 대상인지 판단할 수 있다.

team.leader = null;

이제 다른 도달 가능한 참조가 없다면 사용자 객체는 회수 대상이 될 수 있다.

객체를 한동안 읽지 않았다고 도달할 수 없는 객체가 되는 것은 아니다. 루트에서 객체까지 이어지는 경로가 남아 있는지가 중요하다.

반대로 프로그램에서 더는 쓰지 않을 객체라도 도달 가능한 경로가 남아 있다면 GC는 그 의도를 알 수 없다. 도달 가능성은 앞으로 필요한가에 대한 판단이 아니라 현재 루트에서 닿는가에 대한 판단이다.


3. GC: 서로 참조하는 객체도 회수할 수 있을까?​

GC(Garbage Collection) 는 도달할 수 없어진 객체의 메모리를 회수하는 과정이다. 객체 사이에 참조가 남아 있어도 루트에서 이어지는 경로가 없으면 회수 대상이 될 수 있다.

function createPair() {
const first = {};
const second = {};

first.partner = second;
second.partner = first;
}

createPair();

두 객체는 서로 참조한다. 하지만 함수가 끝난 뒤 외부에서 두 객체로 이어지는 연결은 없다. 루트에서 도달할 수 없으므로 둘 다 회수 대상이 될 수 있다.

즉, 순환 참조 자체가 반드시 메모리 누수를 만드는 것은 아니다.

이 과정을 이해하는 모델이 **Mark-and-Sweep(표시하고 회수하기)**이다. 위 예제에서 createPair() 실행이 끝나면 first와 second를 가리키던 지역 변수는 더 이상 접근 경로가 아니다. 두 객체 사이의 참조만 남는다.

  1. Mark(표시): GC가 루트에서 출발해 참조를 따라갈 수 있는 객체에 표시한다. 루트에서 first나 second로 이어지는 경로가 없으므로 두 객체는 표시되지 않는다.
  2. Sweep(회수): 표시되지 않은 객체를 회수한다. 따라서 first와 second는 서로를 참조하더라도 회수 대상이 된다.

핵심은 객체끼리 연결되어 있는지가 아니라 루트에서 그 연결에 들어갈 수 있는지다. 실제 엔진은 여러 최적화를 사용하지만, 여기서는 이 기준으로 회수 대상을 판단하면 된다.

회수 대상과 실제 회수 시점

도달할 수 없게 되었다고 즉시 메모리가 해제되는 것은 아니다. 실제 GC 실행 시점은 엔진이 결정한다. null 대입은 GC를 실행하는 명령이 아니다.


4. Retainer: 사용이 끝난 객체를 누가 붙잡고 있을까?​

객체가 예상보다 오래 남아 있다면 어떤 연결 때문에 계속 도달 가능한지 확인해야 한다. Retainer는 대상 객체를 붙잡고 있는 참조의 출처를 뜻한다. 대상 객체의 Retainer를 하나 찾는 데서 멈추지 말고, 루트까지 이어지는 경로를 따라가야 한다.

const cache = new Map();

function loadUser() {
const user = { id: 1, name: '민수' };

cache.set(user.id, user);
}

loadUser();

console.log(cache.get(1).name); // '민수'

함수 실행은 끝났지만 cache에는 사용자 객체의 참조가 남아 있다. cache가 계속 도달 가능한 상태라면 사용자 객체도 유지된다.

루트 → cache → Map의 값 → 사용자 객체
cache.delete(1);

항목을 제거하면 Map을 통한 연결이 끊긴다. 다른 참조가 없다면 사용자 객체는 회수 대상이 될 수 있다.

여기서 삭제하는 것은 객체 자체가 아니라 객체를 가리키는 참조다. JavaScript에는 객체의 메모리를 직접 해제하는 명령이 없다. 이 예제에서는 cache.delete(1)로 Map의 참조를 없앤다. 변수가 마지막 참조라면 재할당 가능한 변수에 null을 넣거나, 객체 속성이 마지막 참조라면 그 속성을 제거하는 식이다. 다른 참조도 모두 사라진 뒤 실제 메모리를 회수하는 시점은 GC가 결정한다.

필요한 데이터를 보관하는 것은 정상적인 유지다. 사용이 끝난 객체가 의도치 않게 계속 남아 메모리를 차지할 때 메모리 누수를 의심할 수 있다. 객체 하나의 크기보다 이런 객체가 반복해서 쌓이는 경우가 문제다.

DevTools에서 참조 확인하기​

Chrome DevTools의 Heap snapshot에서는 Retainers를 통해 대상 객체를 붙잡는 참조를 거슬러 올라갈 수 있다. Heap snapshot 실습 페이지에서는 Console에 코드를 붙여넣지 않고 화면에서 카드가 사라져도 객체가 남는 이유를 확인할 수 있다. StudyUser라는 이름은 스냅샷에서 객체를 쉽게 찾기 위한 것이다. 예제는 class 대신 생성자 함수를 사용한다.

  1. 실습 페이지에서 카드 만들기 → 화면에서 카드 제거를 차례로 누른다. 화면에서는 카드가 사라진다.
  2. DevTools에서 Memory → Heap snapshot → Take snapshot을 누른다. 스냅샷을 찍을 때 GC가 실행된다.
  3. Summary의 Class filter에 StudyUser를 입력한다. 화면에 카드가 없어도 인스턴스가 남아 있다면 선택한다.
  4. 아래 Retainers에서 객체를 직접 붙잡는 항목부터 루트까지 따라간다. 이 예제에서는 Map의 값, demoCache, Window로 이어진다. 표시되는 내부 노드의 이름과 단계는 Chrome 버전에 따라 다를 수 있다.

실습 페이지에서 Map 항목 삭제를 누른 뒤 새 스냅샷을 찍는다. StudyUser가 사라졌는지 확인하고, 여전히 보인다면 Retainers에서 다른 경로가 남아 있는지 살펴본다.

Retainers를 확인하는 이유는 사용이 끝난 객체를 붙잡고 있는 참조를 찾아 정리하기 위해서다. 이 예제에서는 화면에서 카드를 지워도 demoCache가 StudyUser를 참조한다. 이 경로를 확인해야 Map 항목을 삭제해 객체를 회수 대상으로 만들 수 있다. 의도적으로 보관 중인 객체라면 참조를 끊을 필요는 없다.


5. WeakMap: 부가 정보 때문에 원본 객체가 남지 않게 하려면?​

지금까지 본 변수, 프로퍼티, 배열 항목과 일반 Map의 키·값은 객체로 향하는 강한 참조를 만든다. 도달 가능한 Map이 객체를 키로 저장하면, 그 키 객체도 계속 도달 가능하다. 부가 정보를 보관하는 Map 때문에 더 이상 필요 없는 원본 객체가 남을 수 있다.

const rowInfo = new Map();

let row = document.createElement('tr');

rowInfo.set(row, { height: 40 });

row = null;

이 예제에서는 rowInfo가 도달 가능한 동안 Map의 키로 행 객체가 유지된다.

WeakMap은 키 객체를 약하게 참조한다. WeakMap을 통한 연결만으로는 키 객체가 도달 가능하다고 판단하지 않는다. 객체에 부가 정보를 연결하되, 그 정보 때문에 원본 객체의 수명이 늘어나지 않게 할 때 사용할 수 있다.

const rowInfo = new WeakMap();

let row = document.createElement('tr');

rowInfo.set(row, { height: 40 });

console.log(rowInfo.get(row).height); // 40

row = null;

다른 강한 참조가 없다면 행 객체는 회수 대상이 될 수 있다. 이 예제의 행은 문서에 추가하지 않았다. 실제 화면에 연결된 DOM 요소라면 문서를 통한 연결도 고려해야 한다.

왜 이 예제에서 WeakMap을 쓸까?

행마다 측정한 높이를 rowInfo에 저장해 두면, 같은 행 객체를 다시 사용할 때 측정값을 꺼낼 수 있다. 코드의 40은 측정값을 가정한 숫자다.

이후 행 삭제 기능으로 화면에서 행을 제거한다고 해 보자. Map을 썼다면 rowInfo.delete(row)도 호출해야 Map이 행을 계속 붙잡지 않는다. WeakMap을 썼다면 그 정리 코드를 따로 넣지 않아도 된다. 화면과 다른 곳에서 행을 가리키는 참조가 모두 사라지면, WeakMap의 기록만으로는 행이 남지 않는다.

즉, 행을 삭제할 때 행에 딸린 기록을 별도로 정리하지 않아도 되는 점이 편리하다. WeakMap이 화면에서 행을 삭제하거나 GC를 즉시 실행해 주는 것은 아니다.

WeakMap에서 약한 것은 값이 아니라 키다. 키가 다른 강한 경로로 도달 가능한 동안에는 WeakMap의 값도 유지된다. 키가 더는 도달 가능하지 않으면 대응하는 값도 회수 대상이 될 수 있다. 값이 키를 다시 참조하더라도 그것만으로 키가 살아남지는 않는다.

왜 순회와 size가 없을까?

GC 시점에 따라 키의 생존 여부가 달라질 수 있다. WeakMap은 이를 항목 목록이나 개수로 관찰하는 기능을 제공하지 않는다. 전체 항목을 순회하거나 개수를 관리해야 한다면 Map을 사용한다.

WeakMap의 키에는 객체와 비등록 심벌을 사용할 수 있다. 문자열이나 숫자는 키로 사용할 수 없다. 이 글에서는 객체 키를 사용하는 경우를 다룬다.


6. WeakSet: 객체를 붙잡지 않고 처리 여부를 기록할 수 있을까?​

WeakMap이 행에 높이 같은 부가 정보를 연결했다면, WeakSet은 이 행을 이미 처리했는지만 기록한다.

const initializedRows = new WeakSet();

function initializeRow(row) {
if (initializedRows.has(row)) return;

row.addEventListener('click', () => console.log('행 클릭'));
initializedRows.add(row);
}

목록을 다시 그리면서 initializeRow(row)가 같은 행에 두 번 호출되어도 클릭 이벤트는 한 번만 등록된다. initializedRows.has(row)가 이미 처리한 행인지 알려 주기 때문이다.

왜 이 예제에서 WeakSet을 쓸까?

나중에 행 삭제 기능으로 화면에서 행을 제거한다고 해 보자. 일반 Set에 처리 여부를 저장했다면 initializedRows.delete(row)도 호출해야 Set이 행을 계속 붙잡지 않는다. WeakSet은 행을 강하게 붙잡지 않으므로, 다른 강한 참조까지 사라지면 행은 회수 대상이 될 수 있다.

즉, 처리 여부는 기억하되 행을 삭제할 때 기록을 따로 정리하지 않아도 되는 점이 편리하다. 어떤 행이 들어 있는지 전체 목록을 확인해야 한다면 WeakSet 대신 Set을 쓴다.

WeakSet도 순회와 size를 지원하지 않는다. 저장할 수 있는 대상은 객체와 비등록 심벌이다. has(row)로 확인하려면 그 시점에 row를 가리키는 참조가 있어야 한다.

어떤 것을 선택할까?​

두 예제 모두 행이 더는 필요 없으면 그 행에 딸린 기록도 필요 없다는 상황이다. 차이는 기록할 내용이다. 행의 높이처럼 꺼내 쓸 값이 있으면 WeakMap을, 이미 초기화했는지 여부만 알면 되면 WeakSet을 쓴다. 두 자료구조 모두 객체의 내용이 아니라 객체 자체의 동일성으로 기록을 찾는다. 내용이 똑같은 새 행을 만들더라도 이전 행의 기록을 그대로 찾을 수는 없다.

WeakMap은 행 객체를 키로 높이 값을 저장하고, WeakSet은 행을 처리했는지 여부만 기록한다. 행에 대한 다른 강한 참조가 사라지면 둘 다 행을 붙잡지 않는다.

목적자료구조
키와 값을 보관하고 전체 항목을 순회한다Map
객체에 부가 정보를 연결하되 키 객체의 수명을 늘리지 않는다WeakMap
중복 없는 값을 보관하고 전체 항목을 순회한다Set
객체의 포함 여부를 기록하되 객체의 수명을 늘리지 않는다WeakSet

WeakMap과 WeakSet이 모든 메모리 누수를 해결하는 것은 아니다. 다른 곳에서 강하게 참조하고 있다면 객체는 계속 유지된다.


복습 문제​

1. 참조 경로가 둘이면 언제 회수 대상이 될까?​

퀴즈. ①과 ② 시점에서 사용자 객체는 각각 회수 대상이 될 수 있을까? 아래에 보인 경로 외에는 이 객체를 참조하지 않는다고 가정한다.

let user = { name: '민수' };
const members = [user];
const backup = { current: user };

user = null;
members.length = 0;

// ① 이 시점에서 사용자 객체는 회수 대상일까?

backup.current = null;

// ② 이 시점에서는 회수 대상일까?
정답 보기

① 회수 대상이 될 수 없다. user 변수와 배열 항목의 참조는 끊겼지만, backup.current를 통해 여전히 도달할 수 있다.

② 회수 대상이 될 수 있다. backup.current까지 끊으면, 가정한 범위에서 사용자 객체로 이어지는 경로가 모두 사라진다. 즉시 회수된다는 뜻은 아니다.

2. 순환 참조에 외부 경로가 남아 있다면?​

퀴즈. ①과 ② 시점에서 두 객체는 각각 회수 대상이 될 수 있을까? 아래에 보인 경로 외에는 두 객체를 참조하지 않는다고 가정한다.

function createPair() {
const first = { name: 'A' };
const second = { name: 'B' };

first.partner = second;
second.partner = first;

return first;
}

let pair = createPair();

// ① 이 시점에서 두 객체는 회수 대상일까?

pair = null;

// ② 이 시점에서는 회수 대상일까?
정답 보기

① 회수 대상이 될 수 없다. pair가 첫 객체를 가리키고, 첫 객체의 partner가 둘째 객체를 가리킨다. 두 객체 모두 루트에서 도달 가능하다.

② 회수 대상이 될 수 있다. pair = null로 외부 경로가 끊기면 두 객체는 서로를 가리키기만 한다. 루트에서 둘 중 어느 객체에도 도달할 수 없다.

3. Map과 WeakMap에 같은 객체를 넣으면?​

퀴즈. ①과 ② 시점에서 사용자 객체는 각각 회수 대상이 될 수 있을까? 다른 곳에서는 이 객체를 참조하지 않는다고 가정한다.

const users = new Map();
const metadata = new WeakMap();

let user = { id: 1, name: '민수' };

users.set(1, user);
metadata.set(user, { visited: true });

user = null;

// ① 이 시점에서 사용자 객체는 회수 대상일까?

users.delete(1);

// ② 이 시점에서는 회수 대상일까?
정답 보기

① 회수 대상이 될 수 없다.

도달 가능한 users Map이 값으로 사용자 객체를 강하게 참조한다. user 변수의 참조를 끊어도 Map을 통해 객체에 접근할 수 있다.

② 회수 대상이 될 수 있다.

users.delete(1)로 Map의 참조도 제거했다. WeakMap의 키로만 남은 참조는 객체의 회수를 막지 않는다.

WeakMap이 있다고 다른 강한 참조까지 없어지는 것은 아니다. 모든 강한 도달 경로가 사라졌는지를 확인해야 한다.


핵심 정리​

  • 참조는 객체에 접근할 수 있게 하는 연결이다.
  • 도달 가능성은 루트에서 객체까지 이어지는 경로가 있는지를 뜻한다.
  • GC는 도달할 수 없는 객체의 메모리를 회수한다.
  • Retainer를 확인하면 객체가 남아 있는 이유를 추적할 수 있다.
  • WeakMap은 키를, WeakSet은 항목을 강하게 붙잡지 않는다. 다른 강한 경로가 없다면 해당 객체는 회수 대상이 될 수 있다.

객체가 더 이상 필요 없다면 어떤 연결이 남아 있는지 확인해야 한다. 이벤트 리스너나 타이머가 만드는 연결을 실제로 정리하는 방법은 다음 ‘수명 종료와 정리’ 파트에서 이어진다.


참고​