FEInterview Prep

javascript · high priority

JavaScript 메모리 관리 & 가비지 컬렉션

V8 Orinoco, 도달 가능성, 누수 패턴, WeakRef — SPA 메모리를 지키는 모든 것

advanced 난이도5시간토스카카오네이버배민라인
시작 전
이해도
매우 낮음

학습 개요

탄생 배경

쉬운 설명

복잡한 개념을 실생활 비유로 설명합니다.

도서관과 책 추적표

도서관(메모리)에서 책(객체)을 빌리려면 누군가의 이름(참조)이 책에 적혀 있어야 합니다. 사서(GC) 는 정기적으로 모든 사용자(루트)의 가방을 뒤져 그 안에 적힌 책 → 그 책이 가리키는 다른 책을 따라가며 "이 책은 살아 있다" 표시(Mark) 합니다. 마지막에 표시 안 된 책은 폐기(Sweep). WeakMap 은 "내 가방에 있긴 한데 사서 보기엔 없는 책" 같은 특수한 자루입니다 — 다른 사람 가방에 더 이상 없으면 사서가 그냥 가져갑니다.

핵심 개념

자바스크립트 GC 는 "이 객체를 앞으로 사용할까?" 를 결정할 수 없습니다. 결정 가능한 것은 단 하나, **도달 가능성** 입니다. 루트 집합(전역 객체, 현재 콜스택의 지역 변수, 활성 클로저, JS API 가 잡고 있는 native 핸들) 에서 출발해 참조를 따라가서 닿을 수 있는 객체는 살리고, 닿지 못하는 것은 죽었다고 판단합니다. 그래서 "다 썼지만 누군가 변수에 들고 있는" 객체는 GC 가 절대 수거하지 못합니다.

Reference Counting vs Reachability

Reference Counting (구식)
  • 객체마다 "나를 가리키는 참조 수" 를 들고 있음
  • 카운트가 0 이 되는 순간 즉시 해제
  • 순환 참조에서 카운트가 0 이 안 됨 → 영구 누수
  • Python(보조), 일부 옛 IE 의 BOM 객체에서 사용
1// 순환 참조에 약함
2let a = { name: 'a' };
3let b = { name: 'b' };
4a.b = b;
5b.a = a;
6a = null;
7b = null;
8// RC 만으로는 두 객체 모두 카운트 1 → 영구 생존
Reachability (V8)
  • 루트에서 도달 가능한지로 판단
  • 순환 참조라도 외부에서 닿을 수 없으면 죽음
  • 대신 mark 단계가 비싸 → 점진/병렬화 필요
  • 모든 모던 JS 엔진의 표준
1let a = { name: 'a' };
2let b = { name: 'b' };
3a.b = b; b.a = a;
4a = null;
5b = null;
6// 루트에서 a, b 어디로도 못 감 → 같이 수거 ✅

"메모리 해제" 라는 코드는 없다

JS 에는 free(obj) 같은 API 가 없습니다. 개발자가 할 수 있는 것은 **참조를 끊는 것** 뿐입니다. obj = null, cache.delete(key), removeEventListener 가 그것이고, 끊고 나면 그 다음은 GC 가 알아서 합니다 — 단, 끊어야만 합니다.

실무 적용

어떤 상황에서 사용하는가

며칠 켜둔 어드민 SPA 가 점점 무거워지고 모바일에서는 30분 만에 탭이 죽는다는 리포트가 들어왔다.

어떻게 적용하는가

먼저 Performance > Memory 그래프로 우상향 여부 확인 → 우상향이 보이면 의심 라우트(예: 대시보드) 에서 3-snapshot 기법. Snapshot diff 에서 Detached <HTMLElement> 와 평소 안 보이던 클래스가 매 라우트 이동마다 누적된다면 그 화면의 unmount cleanup 누락. Retainers 에서 누가 잡고 있는지(보통 등록 후 안 떼는 listener, clearInterval 빠뜨린 timer) 추적해 useEffect cleanup, AbortController, removeEventListener 추가.

흔한 실수와 안티패턴

  • 한 번의 GC 직후/직전을 비교해 "GC 가 안 도네!" 라고 판단 — Idle 시점에 GC 가 동시에 돌고 있을 수 있어 이상해 보임. DevTools 의 garbage collection 버튼으로 강제 한 번 돌리고 비교해야 함
  • WeakMap 으로 모든 캐시를 바꿔버림 — 비결정적 GC 시점 때문에 캐시 hit 률이 의도와 달라질 수 있음. WeakMap 은 "외부 참조가 곧 캐시 키" 인 패턴에서만 적합
  • FinalizationRegistry 의 finalizer 안에서 비즈니스 로직(예: 결제 마감) 수행 — 호출이 보장되지 않아 데이터 유실
  • 컴포넌트 cleanup 에서 비동기 함수에 `await` 넣음 — useEffect cleanup 은 동기여야 함. AbortController 로 끊거나 promise 로 cleanup 후처리

면접 질문

중급토스카카오네이버

답변 방향 힌트

GC 는 무엇을 보고 수거를 결정하는가, 그 기준이 코드 작성과 어떻게 충돌할 수 있는가.

반드시 언급할 키워드

  • GC 는 "사용 여부" 가 아니라 "도달 가능성" 으로 수거 결정
  • 루트(전역, 콜스택, 활성 클로저) 에서 닿을 수 있으면 살림
  • 개발자가 의도치 않게 참조 chain 을 유지하면 GC 가 손 못 댐
  • 대표 패턴: 떼지 않은 listener, clearInterval 안 한 timer, detached DOM 을 잡고 있는 배열, 전역 캐시

예상 꼬리 질문

  • 순환 참조가 있는 두 객체가 외부에서 더 이상 참조되지 않으면 GC 가 회수할 수 있나요? 왜 그런가요?
  • React 에서 가장 흔한 메모리 누수 패턴 두 가지를 코드 예시로 들어주세요.

자기 점검

스크롤 올리지 말고 답해보세요. Map 과 WeakMap 의 핵심 차이를 키 보유 방식 관점에서 설명해보세요.

기대 키워드

강한 참조약한 참조GC순회 불가size 없음

자주 하는 오해

"WeakMap 은 entry 가 자동으로 사라진다" 까지는 맞지만, "WeakMap 으로 바꾸기만 하면 누수 해결" 이라고 오해하는 경우가 많습니다. WeakMap 은 키가 곧 외부 객체일 때만 의미가 있고, 키 자체를 새로 만들어 넣으면 똑같이 누수됩니다.

detached DOM 이란 무엇이고, 왜 GC 가 자동으로 수거하지 못하는지 설명해보세요.

기대 키워드

DOM 트리에서 제거JS 참조도달 가능배열/closureHeap Snapshot

자주 하는 오해

`element.remove()` 만 호출하면 GC 가 알아서 가져갈 거라고 생각하는 경우가 많지만, 어딘가의 배열이나 closure 가 그 element 를 들고 있으면 DOM 트리에서만 떨어졌을 뿐 "살아 있는" 객체로 남습니다.

V8 이 Major GC 를 한 번에 다 처리하지 않고 Incremental + Concurrent 로 쪼개는 이유는?

기대 키워드

Stop-The-World60fps16ms메인 threadjank

자주 하는 오해

"GC 는 백그라운드에서 도니까 신경 안 써도 된다" 가 흔한 오해. 실제로는 mark 단계 자체는 메인 thread 의 도움을 일부 받기 때문에, 거대한 객체 할당이 몰리면 1프레임을 넘기는 정지(jank) 가 여전히 발생합니다.

학습 자료