홈시리즈멘토링

© 2026 정기창. All rights reserved.

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

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

© 2026 정기창. All rights reserved.

콘텐츠: CC BY-NC-SA 4.0

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

맥북에서 jest가 OOM 났는데, 알고 보니 macOS 메모리 지표가 거짓말이었다 — ts-jest에서 @swc/jest로

정기창·2026년 4월 22일

NestJS 백엔드 프로젝트의 jest 테스트를 돌리는데, 실행할 때마다 시스템이 점점 무거워지더니 터미널이 얼어붙기 직전까지 가는 현상을 몇 번이나 겪었습니다. 처음에는 단순히 "메모리가 부족한가 보다"라고만 생각했지만, 끝까지 파고 들어가 보니 의외의 사실이 여럿 있었습니다. 그중 가장 당황스러웠던 것은 제가 신뢰하던 메모리 지표가 사실은 거짓말을 하고 있었다는 것입니다.

1. 사건의 시작 — jest가 멈췄다

NestJS 백엔드에서 mongodb-memory-server를 포함한 통합 테스트를 돌리던 중 발생했습니다. pnpm test --runInBand를 실행하자, 출력이 한두 줄 나오다가 시스템 전체가 느려지기 시작했고, 결국 "또 OOM 날 것 같다"는 판단으로 중단할 수밖에 없었습니다.

저는 상황을 파악하려고 vm_stat을 호출했고, 다음과 같은 결과를 얻었습니다.

vm_stat | awk '/Pages free/ {printf "free: %.2f GB\n", $3*16384/1024/1024/1024}'
# free: 0.07 GB

0.07GB라는 숫자를 보고 "메모리 한계에 도달했다"고 확신했습니다. 즉시 실행 중인 앱을 몇 개 닫고 터미널을 다시 띄워야겠다고 결심했을 정도입니다. 그런데 Activity Monitor를 열어보자 이야기가 완전히 달라졌습니다.

2. 반전 — macOS는 사실 여유 있었다

스크린샷에는 이렇게 찍혀 있었습니다.

Physical Memory: 32.00 GB
Memory Used:     10.90 GB
Cached Files:     5.02 GB
Swap Used:        1.55 GB
Memory Pressure:  GREEN

Memory Pressure가 초록색이었고, 물리 메모리 32GB 중 실제 사용은 10.9GB뿐이었습니다. 그런데 왜 제가 호출한 vm_stat은 "여유 0.07GB"라고 대답했던 걸까요? 이 지점에서 macOS 메모리 회계 방식을 제대로 이해해야 한다는 생각이 들었습니다.

3. macOS의 "Pages free"는 전체 여유 메모리가 아니다

macOS는 리눅스와 달리 RAM을 놀리지 않는 철학으로 설계되어 있습니다. 비어 있는 페이지가 생기는 즉시 시스템이 Cached Files, Speculative, Purgeable 같은 재활용 가능한 캐시로 공격적으로 채워 넣습니다. 그래서 vm_stat이 반환하는 "Pages free"는 이 순간 즉시 배당 가능한 raw 페이지 수일 뿐이고, 실제로 앱이 요청하면 macOS가 캐시를 바로 회수해서 제공합니다.

macOS 메모리는 대략 다음처럼 나뉘어 있습니다.

카테고리 성격 회수 비용
Wired 커널/시스템 필수, 페이지아웃 불가 회수 불가
Active 앱이 최근 사용 중 높음 (swap 필요)
Inactive 앱이 한동안 안 쓴 페이지 중간
Cached Files 파일 캐시 낮음 (즉시 회수)
Compressed VM이 in-place 압축해둔 앱 메모리 압축 해제 필요
Free 정말 비어 있는 페이지 즉시 사용

평시에는 시스템이 "Free" 영역을 거의 0에 가깝게 유지합니다. 앱이 할당을 요청하면 Cached Files를 가장 먼저, 그다음 Inactive, 그다음 Compressed 순으로 회수합니다. 그러므로 "Pages free = 0.07GB"는 위기 신호가 아니라 기본값입니다.

진짜로 신뢰할 수 있는 지표는 memory_pressure 명령입니다.

memory_pressure | tail -2
# System-wide memory free percentage: 87%

이 수치는 macOS가 자체적으로 판단한 "여유 비율"이며, 캐시/압축/스왑을 모두 계산에 넣은 결과입니다. Swap Used가 증가 중인지와 함께 보면 실제 압박 여부를 판단할 수 있습니다. 돌이켜 생각해보면, 잘못된 지표 하나에 사로잡혀 멀쩡한 작업을 중단할 뻔했다는 사실이 부끄럽기도 했습니다.

4. 그럼에도 jest가 진짜 버거웠던 이유 — 4가지

메모리 지표가 거짓말이었다는 것은 알았지만, 터미널이 실제로 느려졌던 것은 사실입니다. 이유를 따로 추적해보니 네 가지가 엇갈려 있었습니다.

  1. V8 heap 기본 한도 ~2GB. Node.js의 Old Generation heap은 기본적으로 2GB에서 멈춥니다. 시스템 RAM이 남아돌아도 V8이 먼저 JavaScript heap out of memory로 터집니다.
  2. mongodb-memory-server가 spawn하는 mongod 바이너리. 통합 테스트마다 실제 mongod 프로세스가 뜨며, 300~500MB의 상주 메모리를 차지합니다. V8 heap 바깥이지만 시스템은 이를 Active로 계산합니다.
  3. Jest 기본 maxWorkers = CPU - 1. 옵션을 생략하면 Jest가 알아서 코어 수만큼 worker를 띄웁니다. M2 Pro의 12코어 환경에서는 worker 11개가 동시에 뜨고, 각각 자체 V8 heap을 가집니다. 이론상 22GB를 잠재적으로 점유하는 셈입니다.
  4. macOS jetsam. iOS에서 물려받은 커널 수준의 OOM 거버너로, 시스템 전체 압박이 임계치를 넘으면 가장 큰 non-critical 프로세스에 SIGKILL을 보냅니다. 에디터나 jest 부모 프로세스가 타깃이 될 수 있습니다.

이 중 제일 큰 범인은 worker 수였습니다. 에디터 통합 도구나 Language Server 같은 node 프로세스가 이미 몇 개 떠 있는 상태에서 jest가 기본 옵션으로 실행되면 Node 프로세스가 20개 가까이 뜨게 되고, 할당 속도가 회수 속도를 추월하는 순간 스왑이 급증하면서 체감상 OOM이 나타납니다. 그래서 --runInBand(worker 0)나 --maxWorkers=2가 필수였던 것입니다.

5. 본질적 해결 — ts-jest에서 @swc/jest로

워커 수를 줄여도 한 프로세스 안에서 ts-jest가 모든 TypeScript 파일을 메모리에 컴파일해 두는 구조는 바뀌지 않습니다. 그래서 변환기 자체를 교체하기로 했습니다. ts-jest는 각 worker 프로세스 안에 TypeScript 컴파일러를 띄우고 파일마다 변환하는 반면, @swc/jest는 Rust로 작성된 외부 바이너리가 변환을 담당합니다. V8 heap 부담이 절반 이하로 떨어지는 구조입니다.

NestJS 프로젝트이므로 데코레이터 메타데이터가 필수인데, .swcrc에 legacyDecorator: true와 decoratorMetadata: true를 명시하면 tsc와 동일하게 동작합니다.

{
  "jsc": {
    "parser": {
      "syntax": "typescript",
      "decorators": true,
      "dynamicImport": true
    },
    "transform": {
      "legacyDecorator": true,
      "decoratorMetadata": true
    },
    "target": "es2021",
    "keepClassNames": true
  },
  "module": { "type": "commonjs" },
  "sourceMaps": "inline"
}

package.json의 jest.transform을 "^.+\\.(t|j)s$": "@swc/jest" 한 줄로 바꾸고, 같은 방식으로 jest-e2e.json도 교체했습니다. 단 4줄 변경입니다.

6. 결과 — 숫자가 모든 것을 말해준다

이관 후 --runInBand --logHeapUsage로 측정한 수치는 체감을 훨씬 뛰어넘었습니다.

스펙 ts-jest 기준 @swc/jest 적용 후
단일 커넥터 spec (5 tests) 7~8초, heap 100MB+ 0.24초, heap 48MB
mongodb-memory-server 통합 spec (8 tests) heap 200~300MB heap 77MB, 0.45초
CI backend 전체 (947 tests) OOM 우려로 --max-old-space-size=6144 강제 29.8초, heap 피크 278MB (7GB 한도의 4%)

CI 워크플로에 붙어 있던 NODE_OPTIONS: '--max-old-space-size=6144'와 NestJS TestingModule이 suite마다 DI 컨테이너를 생성하여 worker당 2GB+ 메모리 누적 → 7GB runner에서 OOM이라는 주석은 이제 역사적 증거물이 되었습니다.

7. 남는 교훈

이 사건을 통해 다음 네 가지를 교훈으로 정리할 수 있었습니다.

  • macOS의 "Pages free"는 여유 메모리가 아니다. 진짜 지표는 memory_pressure 명령과 Activity Monitor의 Memory Pressure 색상입니다.
  • Jest의 기본 worker 수는 매우 공격적이다. --maxWorkers를 명시하지 않으면 CPU 코어 수만큼 Node 프로세스가 뜨며, 백그라운드에 여러 node 도구가 함께 떠 있는 개발 환경에서는 치명적일 수 있습니다.
  • ts-jest에서 @swc/jest로의 이관은 정직하게 도움이 된다. NestJS 데코레이터 의존성이 있어도 .swcrc 설정 한 번으로 동등 동작이 가능합니다.
  • 도구나 모니터링이 "위기"라고 판단할 때, 지표의 정의 자체를 다시 물어보는 것이 좋다는 것을 배웠습니다. 다음번에는 숫자 하나에 성급하게 반응하지 않도록 스스로를 훈련해야겠다는 다짐을 남긴 사건이었습니다.

그런데 이 이관 이후에도 조금 엉뚱한 일이 벌어졌습니다. 로컬에서는 모두 통과했는데 CI에서 갑자기 28개 테스트 suite가 컴파일 단계에서 깨지기 시작한 것입니다. 그 이야기는 다음 글에서 이어집니다.

jestswcts-jestNestJSmacOSPerformanceOOM

관련 글

Bun으로 갈아탈까? 실제 모노레포로 검증해본 결과

1633개 패키지를 가진 실제 프로덕션 모노레포에서 Bun 마이그레이션을 검토했습니다. 네이티브 모듈 호환성부터 체감 속도 예측, 워크트리 설치 시간 실측까지, 벤치마크 숫자가 아닌 현실적인 분석을 정리했습니다.

관련도 88%

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

V8 엔진의 힙 메모리 구조와 가비지 컬렉션 동작 원리를 추적합니다. New Space의 Scavenge부터 Old Space의 Mark-Sweep-Compact까지, 객체가 생성되고 이동하고 제거되는 과정을 살펴봅니다.

관련도 88%

k6와 실시간 Pool 모니터링으로 시스템 한계점 찾기

k6로 시스템 한계점을 찾는 Breakpoint 테스트와 NestJS Connection Pool 실시간 모니터링 시스템을 구현한 경험. 최적 RPS를 찾기까지의 과정을 정리했습니다.

관련도 87%