홈시리즈

© 2026 Ki Chang. All rights reserved.

본 블로그의 콘텐츠는 CC BY-NC-SA 4.0 라이선스를 따릅니다.

☕후원하기소개JSON Formatter러닝 대기질개인정보처리방침이용약관

© 2026 Ki Chang. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

☕후원하기
소개|JSON Formatter|러닝 대기질|개인정보처리방침|이용약관

V8 메모리 구조와 가비지 컬렉션 — 객체는 어디에 살고, 언제 사라지는가 (6편)

정기창·2026년 3월 2일

들어가며

1~5편에서 Node.js의 런타임 구조, 이벤트 루프, 그리고 실제 NestJS 서버에서 이 모든 것이 어떻게 동작하는지를 추적했습니다. 5편 마지막에 "Node.js는 I/O를 위임하고 연결하는 일에 최적화된 런타임"이라는 결론을 내렸습니다.

하지만 한 가지 빠진 게 있습니다. 메모리입니다.

이벤트 루프가 콜백을 실행할 때마다 객체가 생성됩니다. HTTP 요청이 들어오면 req, res 객체가 만들어지고, DB 쿼리 결과가 JSON으로 파싱되고, 응답이 전송되면 그 객체들은 더 이상 필요 없어집니다. 이 객체들은 메모리 어디에 살고, 언제, 어떻게 사라지는가? 이것이 이번 편의 질문입니다.

V8 힙 메모리 구조 — 객체가 사는 곳

1편에서 V8을 "JS를 기계어로 번역하는 통역사"로 설명했습니다. V8은 통역만 하는 게 아니라 객체를 저장할 메모리도 직접 관리합니다. 이 메모리 영역을 힙(Heap)이라고 부릅니다.

V8 힙 메모리
├─ New Space (Young Generation)    ← 새로 생성된 객체
│   ├─ Semi-space A (from-space)
│   └─ Semi-space B (to-space)
├─ Old Space (Old Generation)      ← 오래 살아남은 객체
├─ Large Object Space              ← 큰 객체 (직접 Old Space 할당)
├─ Code Space                      ← JIT 컴파일된 기계어 코드
└─ Map Space                       ← 객체의 구조 정보 (Hidden Class)

New Space — 객체가 태어나는 곳

JS 코드에서 객체를 생성하면, 대부분 New Space에 할당됩니다.

const user = { name: '기창', role: 'admin' };  // → New Space에 할당
const posts = await BlogPost.find();             // → 결과 배열도 New Space
const formatted = JSON.parse(jsonString);        // → 파싱 결과도 New Space

New Space의 핵심 특징:

  • 크기가 작다 — 기본 1~8MB (64bit 기준). 전체 힙의 극히 일부
  • 두 개의 Semi-space로 나뉜다 — 이 구조가 Scavenge GC의 핵심
  • 대부분의 객체가 여기서 태어나고 여기서 사라진다 — 1984년 David Ungar가 실제 프로그램들의 객체 수명을 관찰한 결과, "대부분의 객체는 생성 직후 금방 죽는다"는 패턴이 반복적으로 나타났습니다. 이 경험적 관찰을 세대별 가설(Generational Hypothesis)이라고 부르며, V8은 이 가설에 기반해 젊은 객체(New Space)와 오래된 객체(Old Space)를 분리하는 구조를 설계했습니다

Old Space — 오래 살아남은 객체

New Space에서 GC를 2번 생존한 객체는 Old Space로 승격(promotion)됩니다.

// Old Space에 살게 되는 객체들의 예
const app = await NestFactory.create(AppModule);  // 서버 수명 동안 유지
const mongoConnection = mongoose.connection;       // DB 연결 — 서버 종료까지
const configService = app.get(ConfigService);      // DI 컨테이너의 싱글턴

Old Space 특징:

  • 크기가 크다 — 기본 ~1.4GB (64bit). --max-old-space-size로 조절 가능
  • GC가 느리다 — 객체가 많고, 참조 관계가 복잡하므로 Mark-Sweep-Compact 알고리즘 사용
  • 서버의 장기 메모리 사용량은 Old Space가 결정한다

Large Object Space, Code Space, Map Space

영역저장 대상특징
Large Object Space큰 객체 (기본 256KB 이상)New Space를 거치지 않고 바로 Old Space급 영역에 할당. GC 시 이동하지 않음 (복사 비용이 너무 크므로)
Code SpaceV8이 JIT 컴파일한 기계어자주 호출되는 함수가 TurboFan에 의해 최적화된 코드로 저장
Map SpaceHidden Class (객체 구조 메타데이터){ name, role } 같은 객체 형태 정보. 같은 형태의 객체는 같은 Map을 공유

Minor GC (Scavenge) — New Space 청소

New Space가 가득 차면 Scavenge라는 GC가 실행됩니다. scavenge는 "쓸 만한 것을 골라내다"라는 뜻으로, 죽은 객체를 찾아서 지우는 게 아니라 살아있는 객체만 골라서 옮기는 동작을 잘 설명하는 이름입니다.

Semi-space 복사 알고리즘

New Space는 두 개의 동일한 크기 영역으로 나뉩니다. 한쪽은 from-space(현재 사용 중), 다른 쪽은 to-space(비어있음).

Scavenge GC 동작:

1단계: from-space가 가득 참 → GC 시작

   from-space (사용 중)          to-space (비어있음)
   ┌──────────────────┐         ┌──────────────────┐
   │ ■ user (살아있음) │         │                  │
   │ □ temp (죽은 객체)│         │                  │
   │ ■ posts (살아있음)│         │                  │
   │ □ old  (죽은 객체)│         │                  │
   └──────────────────┘         └──────────────────┘

2단계: 살아있는 객체만 to-space로 복사

   from-space                    to-space
   ┌──────────────────┐         ┌──────────────────┐
   │ (전부 버림)       │         │ ■ user           │
   │                   │         │ ■ posts          │
   │                   │         │                  │
   │                   │         │                  │
   └──────────────────┘         └──────────────────┘

3단계: from-space와 to-space 역할 교체

   to-space (비어있음)           from-space (사용 중)
   ┌──────────────────┐         ┌──────────────────┐
   │                   │         │ ■ user           │
   │                   │         │ ■ posts          │
   │                   │         │                  │
   └──────────────────┘         └──────────────────┘

죽은 객체를 찾아서 지우는 게 아니라, 살아있는 객체만 복사하고 나머지는 통째로 버립니다. 이게 Scavenge가 빠른 이유입니다. 대부분의 객체가 금방 죽기 때문에, 복사할 양이 적습니다.

"살아있다"는 건 어떻게 판단하는가

루트(root)에서 참조 체인을 따라갈 수 있으면 살아있는 것입니다.

루트(Root)
├─ 글로벌 객체 (global / globalThis)
├─ 현재 콜 스택의 지역 변수
├─ 클로저가 캡처한 변수
└─ 활성 타이머, 이벤트 리스너 등

루트에서 참조 체인을 따라갈 수 있는 객체 = 살아있음
체인이 끊긴 객체 = 죽음 (GC 대상)
function handleRequest(req, res) {
  const user = { name: '기창' };     // ← 콜 스택에서 참조 중 → 살아있음
  const temp = { data: 'unused' };   // ← 콜 스택에서 참조 중 → 살아있음
  
  res.json(user);
}
// 함수 종료 → user, temp 모두 콜 스택에서 사라짐 → 참조 없음 → 죽음

Old Space로의 승격

Scavenge를 2번 생존한 객체는 "오래 살 놈"으로 판단되어 Old Space로 승격됩니다.

객체 생성 → New Space (from-space)
    ↓
1차 Scavenge 생존 → to-space로 복사 (플래그 표시)
    ↓
2차 Scavenge 생존 → Old Space로 승격

NestJS 서버에서 승격되는 대표적인 객체들:

객체이유
DI 컨테이너의 싱글턴 서비스서버 수명 동안 유지
Mongoose/Drizzle 연결 객체DB 연결이 끊기지 않는 한 유지
Express 미들웨어 체인한 번 등록되면 계속 참조됨
ConfigService의 환경변수서버 종료까지 유지
모듈 캐시 (require/import)한 번 로드된 모듈은 캐시에 영구 보관

반면 HTTP 요청/응답 객체, DB 쿼리 결과, JSON 파싱 결과 등은 요청이 끝나면 참조가 사라지므로 New Space에서 태어나서 New Space에서 죽습니다.

Major GC (Mark-Sweep-Compact) — Old Space 청소

Old Space가 일정 수준 이상 차면 Major GC가 실행됩니다. Scavenge처럼 "복사"하기엔 Old Space가 너무 크기 때문에, 다른 방식을 씁니다.

1단계: Mark (표시) — 삼색 마킹

GC가 마킹을 시작하면, 내부적으로 객체 그래프를 순회하는 루프가 돕니다. 이 루프가 각 객체의 메모리 주소를 읽고, 그 객체가 참조하는 다른 객체들의 주소 목록을 꺼냅니다. 이 과정에서 객체를 세 가지 색으로 분류합니다.

색의미
흰색GC가 아직 이 객체의 주소를 읽지 않았다 (GC 대상 후보)
회색이 객체는 살아있다고 확인했지만, 이 객체가 참조하는 자식 객체들은 아직 확인하지 않았다
검은색이 객체도 확인했고, 이 객체가 참조하는 모든 자식 객체도 확인을 시작했다

회색 = 작업 대기열(worklist)입니다. "아직 처리할 게 남아있다"는 표시입니다. 이 회색의 존재가 삼색 마킹의 핵심인데, 그 이유는 아래 Orinoco 섹션에서 설명합니다.

GC 마커의 동작을 의사 코드로 표현하면:

gray_queue = [루트가 직접 가리키는 객체들]

while (gray_queue가 비어있지 않음) {
    obj = gray_queue에서 하나 꺼냄          // 이 객체의 주소를 읽는다

    for (child of obj가 참조하는 객체들) {
        if (child가 흰색이면) {
            child를 회색으로 표시
            gray_queue에 추가
        }
    }

    obj를 검은색으로 표시                    // 자식을 모두 큐에 넣었으니 완료
}

// gray_queue가 비었다 = 마킹 종료
// 검은색 = 살아있음, 흰색 = 죽음 (제거 대상)
예시:

루트 → A → B → C
       ↘ D
그 외: E, F (루트에서 도달 불가능)

시작:  A:흰 B:흰 C:흰 D:흰 E:흰 F:흰  회색 큐: []

1. 루트가 A를 참조 → A 회색              회색 큐: [A]
2. A를 꺼냄. A→B, A→D → B 회색, D 회색. A 검은색   회색 큐: [B, D]
3. B를 꺼냄. B→C → C 회색. B 검은색      회색 큐: [D, C]
4. D를 꺼냄. D→없음. D 검은색            회색 큐: [C]
5. C를 꺼냄. C→없음. C 검은색            회색 큐: []

큐 비었음 → 마킹 종료
→ A, B, C, D 검은색(살아있음). E, F 흰색(죽음) → 제거.

2단계: Sweep (청소)

마킹이 끝나면, 흰색으로 남은 객체들의 메모리를 해제합니다. 객체를 이동시키는 게 아니라, 해당 메모리 영역을 "사용 가능"으로 표시합니다.

Sweep 전:  [■ A][□ 죽음][■ B][□ 죽음][□ 죽음][■ C]
Sweep 후:  [■ A][  빈칸 ][■ B][  빈칸  ][  빈칸 ][■ C]

문제가 보입니다. 빈칸이 여기저기 흩어져 있습니다 (메모리 단편화). 빈 공간의 합계는 충분한데, 연속된 큰 공간이 없어서 새 객체를 할당하지 못하는 상황이 생길 수 있습니다.

3단계: Compact (압축)

단편화를 해결하기 위해, 살아있는 객체들을 한쪽으로 밀어서 모읍니다.

Compact 전:  [■ A][  빈칸 ][■ B][  빈칸  ][  빈칸 ][■ C]
Compact 후:  [■ A][■ B][■ C][          빈 공간          ]

Compact는 비용이 큽니다. 객체를 실제로 이동시키고, 그 객체를 가리키는 모든 참조를 새 주소로 업데이트해야 합니다. 그래서 V8은 단편화가 심한 페이지에서만 선택적으로 Compact를 수행합니다.

Major GC가 Minor GC보다 느린 이유

Minor GC (Scavenge)Major GC (Mark-Sweep-Compact)
대상New Space (1~8MB)Old Space (~1.4GB)
알고리즘살아있는 객체 복사전체 마킹 → 삭제 → 압축
소요 시간보통 1~10ms수십~수백ms (최적화 전)
빈도자주 (New Space가 작으므로)드물게 (Old Space가 크므로)
Stop-the-World짧음길 수 있음

Stop-the-World란 GC가 실행되는 동안 JS 코드 실행이 멈추는 현상입니다. 3편에서 "콜 스택이 비어야 이벤트 루프가 돈다"고 했는데, GC도 마찬가지입니다. GC가 힙을 조사하는 동안 객체가 변하면 안 되기 때문입니다.

Orinoco — V8이 Stop-the-World를 줄이는 방법

Major GC가 수백ms 동안 JS를 멈추면, HTTP 요청 응답이 그만큼 지연됩니다. V8은 Orinoco라는 프로젝트를 통해 GC 일시 중단을 최소화하는 여러 기법을 도입했습니다.

Incremental Marking (증분 마킹)

마킹 작업을 한 번에 하지 않고, 조금씩 나누어 JS 실행 사이사이에 끼워넣습니다.

기존:  [=======마킹=======][JS 실행][===Sweep===]
       ← JS 멈춤 (긴 시간) →

증분:  [마킹][JS][마킹][JS][마킹][JS][Sweep]
       ← 짧은 중단 여러 번 →

삼색 마킹이 이걸 가능하게 합니다. 회색 큐가 곧 "여기까지 했다"는 북마크입니다. GC를 중간에 멈추고 JS를 실행한 뒤, 회색 큐에 남아있는 객체부터 다시 이어가면 됩니다. 만약 2색(흰색/검은색)만 썼다면, "어디까지 처리했는지" 알 수 없어서 GC를 멈출 수 없고, 한 번에 다 해야 합니다.

한 가지 문제가 더 있습니다. GC가 멈추고 JS가 실행되는 사이에, 이미 검은색(처리 완료)인 객체가 새로운 흰색 객체를 참조하면 어떻게 될까요? 검은색 객체는 다시 확인하지 않으니까, 새 참조를 놓칠 수 있습니다. V8은 이걸 Write Barrier로 방지합니다. 검은색 객체에 새 참조가 추가되면, 그 객체를 다시 회색으로 되돌리거나 참조된 객체를 회색으로 만들어서, 참조가 변해도 놓치지 않습니다.

Concurrent Marking/Sweeping (병렬 처리)

마킹과 스위핑 작업의 일부를 별도 스레드에서 메인 스레드와 동시에 수행합니다.

메인 스레드:    [JS 실행] [JS 실행] [JS 실행] [짧은 정지] [JS 실행]
헬퍼 스레드:    [====마킹====][====스위핑====]

2편에서 "Node.js는 싱글 스레드"라고 했는데, 이건 JS 실행이 싱글 스레드라는 뜻입니다. V8 내부의 GC 헬퍼 스레드는 libuv 스레드 풀과는 별개로, V8이 자체적으로 관리합니다.

Parallel Scavenge (병렬 스캐빈지)

Minor GC도 여러 스레드가 동시에 New Space의 살아있는 객체를 복사합니다. Stop-the-World 시간은 여전히 있지만, 여러 스레드가 나누어 처리하므로 시간이 단축됩니다.

기법적용 대상효과
Incremental MarkingMajor GC의 Mark 단계긴 중단 → 짧은 중단 여러 번
Concurrent Marking/SweepingMajor GC 전체메인 스레드 중단 최소화
Parallel ScavengeMinor GC복사 시간 단축

실전 — Node.js에서 메모리를 확인하는 방법

process.memoryUsage()

console.log(process.memoryUsage());
// {
//   rss:          52_428_800,   // Resident Set Size — OS가 이 프로세스에 할당한 전체 메모리
//   heapTotal:    34_078_720,   // V8 힙 전체 크기 (할당받은 공간)
//   heapUsed:     28_344_064,   // V8 힙 중 실제 사용 중인 크기
//   external:      1_587_432,   // V8 외부 C++ 객체가 사용하는 메모리 (Buffer 등)
//   arrayBuffers:    262_144,   // ArrayBuffer, SharedArrayBuffer
// }
필드의미주목할 때
rssOS가 프로세스에 할당한 실제 물리 메모리컨테이너 메모리 제한 (Docker, Coolify)과 비교할 때
heapUsedV8 힙에서 실제 사용 중인 양메모리 누수 감지 — 이 값이 계속 올라가면 누수
heapTotalV8이 OS에 요청한 힙 크기heapUsed보다 항상 크며, GC 후 줄어들 수 있음
externalV8 바깥 C++ 메모리Buffer를 많이 쓰는 서버에서 주목

--max-old-space-size

# Old Space 최대 크기를 512MB로 제한
node --max-old-space-size=512 main.js

# 기본값: 64bit 시스템에서 약 1.4GB
# 이 서버의 Coolify 설정: 메모리 384-512MB → Old Space 제한도 고려 필요

힙이 이 한계를 넘으면 FATAL ERROR: CALL_AND_RETRY_LAST Allocation failed - JavaScript heap out of memory 에러로 프로세스가 종료됩니다.

Chrome DevTools 힙 스냅샷

# 1. inspect 모드로 서버 실행
node --inspect main.js

# 2. Chrome에서 chrome://inspect 접속
# 3. Heap Snapshot 찍기 → 객체 분포 확인
# 4. 시간 간격을 두고 2개 찍어서 비교 → 늘어나는 객체 = 누수 후보

메모리 누수 — 객체가 죽지 않는 경우

메모리 누수는 GC의 버그가 아닙니다. 개발자가 참조를 끊지 않아서 GC가 "이건 아직 살아있다"고 판단하는 것입니다.

흔한 누수 패턴 4가지

1. 전역 변수 누적

// ❌ 요청마다 전역 배열에 추가 — 서버가 살아있는 한 참조가 사라지지 않음
const logs = [];
app.use((req, res, next) => {
  logs.push({ url: req.url, time: Date.now() });
  next();
});
// logs는 루트에서 참조 가능 → GC 대상이 아님 → 계속 커짐

2. 이벤트 리스너 미해제

// ❌ 요청마다 리스너를 등록하지만, 해제하지 않음
function handleRequest() {
  const handler = (data) => { /* ... */ };
  emitter.on('event', handler);
  // 함수가 끝나도 emitter가 handler를 참조 → handler의 클로저도 살아있음
}

3. 클로저가 큰 객체를 캡처

// ❌ 클로저가 불필요하게 큰 데이터를 캡처
function createHandler() {
  const hugeData = Buffer.alloc(100 * 1024 * 1024); // 100MB
  return function handler() {
    // hugeData를 직접 안 써도, 같은 스코프에 있으면 캡처될 수 있음
    return 'done';
  };
}
const leak = createHandler(); // handler가 살아있는 한 hugeData도 살아있음

4. 타이머 미정리

// ❌ setInterval로 등록한 콜백이 외부 객체를 참조
const cache = { data: loadExpensiveData() };
setInterval(() => {
  console.log(cache.data.length); // cache를 참조 → GC 불가
}, 60000);
// clearInterval 없이 cache = null 해도 타이머 콜백이 cache를 참조 중

NestJS 서버에서의 메모리 관리

5편에서 추적한 이 서버를 기준으로, 메모리 관점에서 정리합니다.

상황New SpaceOld Space
서버 시작 시모듈 로드 중 임시 객체DI 컨테이너, 연결 객체, 미들웨어, 설정값
HTTP 요청 처리req, res, DTO, 쿼리 결과변화 없음 (요청 단위 객체는 곧 사라짐)
Gemini AI 호출API 응답 JSON변화 없음 (응답 처리 후 참조 해제)
정상 상태Scavenge가 자주 돌며 정리완만하게 안정 (Major GC는 드물게)

정상적인 서버라면 heapUsed가 일정 범위 안에서 오르내려야 합니다. 요청이 올 때 잠깐 올라가고, GC 후 내려가는 톱니 모양이 정상입니다. 이 값이 계속 우상향하면 누수를 의심해야 합니다.

정리

개념핵심
V8 힙New Space(빠른 GC) + Old Space(느린 GC)로 구분. 세대별 가설 기반
Minor GC (Scavenge)Semi-space 복사. 살아있는 객체만 옮기고 나머지 통째로 버림
승격Scavenge 2번 생존 → Old Space로 이동
Major GC (Mark-Sweep-Compact)삼색 마킹으로 살아있는 객체 판별 → 죽은 객체 해제 → 단편화 시 압축
Orinoco증분 마킹, 병렬 처리, 동시 처리로 Stop-the-World 최소화
메모리 누수GC 버그가 아니라, 참조를 끊지 않아 GC가 제거할 수 없는 상태
모니터링process.memoryUsage()의 heapUsed 추이. 톱니 모양이 정상

1~5편에서 "JS 코드가 실행되고, I/O가 위임되고, 콜백이 실행되는 흐름"을 추적했습니다. 이번 편에서는 그 흐름 속에서 생성되는 객체들이 메모리 어디에 저장되고, 어떤 기준으로 정리되는지를 추적했습니다. V8의 GC는 개발자가 직접 호출하는 게 아니라, 힙이 차면 자동으로 실행됩니다. 개발자가 해야 할 일은 참조를 제때 끊어서, GC가 일할 수 있게 만드는 것입니다.

Node.jsV8가비지 컬렉션메모리GC면접 준비

관련 글

NestJS 서버로 추적하는 이벤트 루프 — 이론에서 실전으로 (5편)

이벤트 루프 이론을 실제 NestJS 서버 코드에 대입합니다. 서버 시작부터 HTTP 요청 처리까지, 6개 페이즈가 실제로 어떤 역할을 하는지 추적합니다.

관련도 88%

Node.js 소스코드를 직접 열어봤습니다 — 런타임, V8, libuv의 실체 (1편)

면접 준비를 하다가 "Node.js가 뭔가요?"라는 질문에 제대로 답할 수 없다는 걸 깨달았습니다. 런타임이 뭔지, V8과 libuv가 각각 무슨 역할인지, 실제 Node.js GitHub 소스코드를 열어서 os.hostname() 한 줄이 OS까지 도달하는 과정을 추적해봤습니다.

관련도 87%

이벤트 루프의 6개 페이즈 — 콜백은 어떤 순서로 실행되는가 (4편)

이벤트 루프의 6개 페이즈를 하나씩 살펴봅니다. 타이머 힙의 동작 방식, Poll이 메인 무대인 이유, setTimeout(0)과 setImmediate의 순서가 달라지는 원리, 그리고 process.nextTick과 Promise가 매 페이즈 사이에 끼어드는 구조까지 정리합니다.

관련도 87%