Published on

V8 메모리 누수? WeakRef·FinalizationRegistry 실전

Authors

서버가 멀쩡히 돌아가다가도 RSS가 꾸준히 우상향하고, 어느 순간 OOMKilled 혹은 컨테이너 재시작으로 끝나는 패턴은 Node.js 운영에서 흔합니다. 문제는 “진짜 메모리 누수”와 “GC가 아직 회수하지 않은 정상 객체”가 겉으로는 비슷하게 보인다는 점입니다. V8의 힙은 자동으로 관리되지만, 우리가 만든 참조 구조가 객체를 계속 붙잡고 있으면 GC는 절대 회수하지 못합니다.

이 글에서는 다음을 목표로 합니다.

  • V8 메모리 증가의 대표 원인 패턴을 빠르게 분류하기
  • WeakRefFinalizationRegistry 를 캐시/리소스 관리에 안전하게 적용하기
  • “약한 참조를 썼는데도 누수처럼 보이는” 함정을 피하기

운영 환경에서 컨테이너가 자주 죽는다면, 애플리케이션 레벨 누수 외에도 메모리 제한과 cgroup 동작을 함께 확인해야 합니다. 관련해서는 K8s OOMKilled 반복? cgroup v2 메모리 진단도 같이 보면 원인 분리가 빨라집니다.

V8 메모리 증가가 “누수”로 보이는 4가지 착시

1) 힙이 아니라 RSS가 커지는 경우

Node.js에서 process.memoryUsage() 는 주로 V8 힙 관점입니다. 하지만 컨테이너/호스트 관점에서 보는 RSS에는 다음이 섞입니다.

  • V8 힙 외 메모리(코드/메타데이터)
  • 네이티브 애드온 메모리
  • 버퍼/ArrayBuffer(일부는 외부 메모리로 잡힘)
  • 메모리 할당자 동작으로 인한 반환 지연

따라서 힙 스냅샷에서 객체 수가 줄어도 RSS가 바로 내려오지 않을 수 있습니다. 이건 즉시 “누수”로 단정하면 삽질이 됩니다.

2) 캐시/맵이 무한히 커지는 진짜 누수

가장 흔한 누수는 “의도한 캐시”가 “무제한 저장소”가 되는 것입니다.

  • 키가 사용자 입력 기반이라 cardinality가 무한대
  • 만료 정책이 없거나, 만료되었는데도 삭제가 안 됨
  • Map 을 전역에 두고 계속 set 만 하는 구조

3) 이벤트 리스너/타이머가 참조를 붙잡는 누수

setInterval 콜백이 큰 객체를 클로저로 잡고 있거나, EventEmitter 에 리스너를 계속 등록하고 해제하지 않으면 객체가 살아남습니다.

4) “약한 참조면 안전”이라는 오해

WeakRef 는 강한 참조를 약하게 바꿔줄 뿐, GC 타이밍을 보장하지 않습니다. 또한 FinalizationRegistry 는 “finalize가 언젠가 호출될 수 있음”이지 “반드시 호출됨”이 아닙니다.

이 둘은 누수 해결의 만능키가 아니라, 특정한 형태의 캐시/리소스 관리에서 도움이 되는 도구입니다.

WeakRef와 FinalizationRegistry를 언제 써야 하나

WeakRef가 유용한 경우

  • “있으면 쓰고, 없으면 다시 만들면 되는” 메모이제이션/캐시
  • 객체 생명주기를 강제로 연장하고 싶지 않은 참조

즉, 캐시가 객체를 붙잡아서 GC를 방해하지 않게 만들고 싶을 때 유용합니다.

FinalizationRegistry가 유용한 경우

  • 객체가 GC로 수거될 때 “부가 정리 작업”을 하고 싶을 때
  • 단, 네이티브 리소스(파일 디스크립터, 소켓 등)를 확실히 닫는 용도로는 부적합

FinalizationRegistry 는 보조 수단입니다. 리소스 해제는 반드시 명시적인 close()dispose() 를 우선해야 합니다.

실전 1: WeakRef 기반 “GC 친화적” 캐시 만들기

아래는 키별로 객체를 캐싱하되, 캐시가 객체를 강하게 잡지 않도록 WeakRef 를 사용하는 패턴입니다.

핵심 포인트는 2가지입니다.

  • 캐시에 저장되는 값은 WeakRef 로 감싼다
  • deref() 결과가 undefined 이면 재생성한다
// gc-friendly-cache.js

class GcFriendlyCache {
  constructor(createFn) {
    this.createFn = createFn;
    this.map = new Map();
  }

  get(key) {
    const ref = this.map.get(key);
    if (ref) {
      const value = ref.deref();
      if (value !== undefined) return value;
      // GC로 사라졌다면 엔트리 정리
      this.map.delete(key);
    }

    const created = this.createFn(key);
    this.map.set(key, new WeakRef(created));
    return created;
  }

  size() {
    return this.map.size;
  }
}

export { GcFriendlyCache };

이 캐시는 “객체가 살아있으면 재사용”하지만, “객체를 살려두기 위해 붙잡지는 않습니다.”

그런데 Map 엔트리 자체는 남지 않나

맞습니다. WeakRef 가 가리키는 객체는 GC로 사라질 수 있지만, Map 의 키와 WeakRef 인스턴스는 남습니다. 그래서 위 예제처럼 deref()undefined 일 때 delete 로 청소하거나, 주기적으로 청소하는 루틴이 필요합니다.

실전 2: FinalizationRegistry로 캐시 엔트리 자동 청소하기

FinalizationRegistry 를 사용하면, 타깃 객체가 GC로 수거될 때 캐시 엔트리를 정리하도록 “예약”할 수 있습니다. 여기서 중요한 규칙이 있습니다.

  • registry 콜백은 “언젠가” 호출될 수 있음
  • 호출 순서/시점은 비결정적
  • 프로세스 종료 시 호출 보장 없음

즉, 정확한 정합성이 아니라 “메모리/엔트리 청소 힌트”로만 써야 합니다.

// weak-cache-with-finalization.js

class WeakCacheWithFinalization {
  constructor(createFn) {
    this.createFn = createFn;
    this.map = new Map();

    this.registry = new FinalizationRegistry((heldKey) => {
      // heldKey는 원시값/작은 데이터로 유지하는 것이 좋음
      this.map.delete(heldKey);
    });
  }

  get(key) {
    const existing = this.map.get(key);
    if (existing) {
      const value = existing.ref.deref();
      if (value !== undefined) return value;
      this.map.delete(key);
    }

    const value = this.createFn(key);
    const ref = new WeakRef(value);

    // unregisterToken을 넣어두면, 명시적으로 캐시 삭제할 때 해제 가능
    const unregisterToken = { key };

    this.map.set(key, { ref, unregisterToken });
    this.registry.register(value, key, unregisterToken);

    return value;
  }

  delete(key) {
    const entry = this.map.get(key);
    if (!entry) return false;

    this.registry.unregister(entry.unregisterToken);
    return this.map.delete(key);
  }
}

export { WeakCacheWithFinalization };

held value에 객체를 넣으면 왜 위험한가

FinalizationRegistry 의 held value로 큰 객체를 넣으면, 그 held value 자체가 강하게 유지되어 오히려 메모리 압박을 만들 수 있습니다. 그래서 키 같은 작은 원시값을 넣는 편이 안전합니다.

실전 3: “약한 참조 캐시”가 오히려 성능을 망치는 경우

WeakRef 캐시는 다음 상황에서 역효과가 날 수 있습니다.

  • 객체 생성 비용이 큰데, GC가 자주 발생해 캐시 히트가 낮아짐
  • 트래픽이 스파이크로 몰렸다가 빠지는 패턴에서, GC가 한 번 돌며 캐시가 대거 날아감
  • 캐시가 사실상 “재사용”이 아니라 “재생성”으로 동작

이 경우에는 차라리 명시적 TTL/LRU 캐시가 더 예측 가능하고 운영 친화적입니다.

즉, WeakRef 는 “메모리를 위해 성능을 일부 양보하는” 선택이 될 수 있습니다.

누수 진단 루틴: 힙 스냅샷과 함께 봐야 할 것들

1) 런타임 메모리 지표 빠르게 찍기

// mem-log.js

export function logMem(tag = "") {
  const m = process.memoryUsage();
  const toMb = (n) => Math.round((n / 1024 / 1024) * 10) / 10;

  console.log(
    JSON.stringify({
      tag,
      rssMb: toMb(m.rss),
      heapTotalMb: toMb(m.heapTotal),
      heapUsedMb: toMb(m.heapUsed),
      externalMb: toMb(m.external),
      arrayBuffersMb: toMb(m.arrayBuffers),
      ts: Date.now(),
    })
  );
}
  • heapUsed 가 계속 증가하면 힙 객체 누수 가능성이 큽니다.
  • external 이 커지면 Buffer/ArrayBuffer 혹은 네이티브 메모리 사용을 의심합니다.

2) 힙 스냅샷으로 “누가 붙잡고 있나” 확인

운영에서는 보통 --inspect 로 붙여 스냅샷을 뜨거나, heapdump 같은 도구를 사용합니다.

스냅샷에서 다음을 봅니다.

  • 특정 생성자(클래스) 인스턴스 수가 시간에 따라 단조 증가하는가
  • Retainers 경로에 전역 Map/Array/이벤트 리스너가 잡히는가

3) 컨테이너 OOM과 앱 누수를 분리

컨테이너 메모리 제한이 낮거나, external 이 커지는 경우는 힙 스냅샷만으로는 결론이 안 납니다. 이때는 cgroup 관점 진단이 필요합니다. 앞서 언급한 K8s OOMKilled 반복? cgroup v2 메모리 진단을 체크리스트로 삼으면 좋습니다.

WeakRef·FinalizationRegistry 사용 시 안전 수칙

1) FinalizationRegistry에 비즈니스 로직을 넣지 말 것

예를 들어 “DB에 상태 기록” 같은 로직을 finalize 콜백에 넣으면, 호출이 안 되는 순간 데이터 정합성이 깨집니다. 콜백은 캐시 엔트리 삭제 같은 부수 효과에만 사용하세요.

2) 명시적 해제 API를 먼저 설계할 것

네이티브 리소스는 반드시 다음처럼 명시적으로 닫게 만드세요.

class ResourceHolder {
  constructor(handle) {
    this.handle = handle;
    this.closed = false;
  }

  close() {
    if (this.closed) return;
    this.closed = true;
    // handle.close() 같은 명시적 해제
  }
}

FinalizationRegistry 는 “close를 깜빡한 경우를 줄이는 안전망” 정도로만 고려합니다.

3) WeakRef 캐시는 키 폭발을 막아야 한다

약한 참조를 써도 키가 무한히 늘면 Map 엔트리가 쌓입니다. 키 공간을 제한하거나, 주기적으로 청소하세요.

// periodic-sweep.js

export function startWeakCacheSweep(cache, intervalMs = 60_000) {
  const timer = setInterval(() => {
    // cache.map을 직접 스윕하는 구조라면 여기서 deref 검사
    // 구현에 따라 메서드로 감싸는 것을 권장
  }, intervalMs);

  timer.unref();
  return () => clearInterval(timer);
}

결론: “누수 해결”이 아니라 “참조 설계”의 문제

V8에서 메모리 누수의 본질은 대개 GC가 아니라 참조 그래프입니다. WeakRefFinalizationRegistry 는 그 그래프를 덜 끈끈하게 만들 수 있는 도구지만, 다음 원칙을 지키지 않으면 오히려 디버깅 난이도만 올립니다.

  • 강한 참조를 만들지 않도록 캐시/리스너/클로저 구조를 먼저 점검
  • WeakRef 는 재생성 가능하고 부정확해도 되는 캐시에만 적용
  • FinalizationRegistry 는 정합성 없는 “청소 힌트”로만 사용

Node.js 런타임 이슈를 같이 다루는 글로는 Node.js ERR_REQUIRE_ESM - ESM/CJS 충돌 해결법도 참고할 만합니다. 운영 중 문제를 한 번에 해결하기보다는, 재현 가능한 최소 사례를 만들고 원인 범위를 좁혀가는 방식이 결국 가장 빠릅니다.