Bun v1.3.13 메모리 회수와 장기 실행 프로세스 운영

Bun v1.3.13의 libpas 스캐빈저, mimalloc v3, GC 구조와 테스트 러너 플래그를 중심으로 메모리 제약 환경의 운영 전략을 정리한다.

2026-08-14 · 최초 발행 2026-05-05

메모리를 해제해도 RSS가 내려가지 않던 경로

Bun v1.3.13은 장기 실행 프로세스에서 나타난 심각한 메모리 누수 문제를 겨냥한 긴급 패치다. Linux와 Windows에서 libpas 스캐빈저를 활성화하고 mimalloc을 v2에서 v3로 올려 기준선 메모리 사용량을 5% 줄였다. 1 GiB 메모리 제한이 걸린 컨테이너에서 OOM으로 프로세스가 강제 종료되던 문제도 이 변경의 대상이다.

이번 릴리스는 메모리 관리와 테스트 실행 환경을 함께 손봤다. 메모리 쪽에서는 Linux·Windows의 libpas 스캐빈저 활성화, mimalloc v2에서 v3으로의 교체, 장시간 서버의 OOM 방지가 핵심이다. macOS는 이미 스캐빈저를 지원하고 있었다.

테스트 러너에는 테스트 파일별 격리 환경을 만드는 --isolate, N개 워커 프로세스로 병렬 실행하는 --parallel[=N], CI에서 테스트 파일을 round-robin 방식으로 결정론적으로 나누는 --shard=M/N이 추가됐다.

그 밖에 zlib-ng 2.3.3 업그레이드, 1,316개 WebKit 커밋 통합, Range 요청 지원, SHA3 알고리즘 추가, WebSocket Unix 소켓 지원도 포함됐다.

libpas 스캐빈저가 비어 있던 플랫폼

JavaScript 런타임의 메모리 문제를 GC만으로 설명하기는 어렵다. Bun은 JSC(JavaScriptCore)를 기반으로 하고, 힙 메모리 관리에는 libpas 할당자를 사용한다.

libpas는 할당 영역을 slab 단위로 다루는 segregated storage 구조를 쓴다. macOS에서만 켜져 있던 스캐빈저는 해제된 메모리 페이지를 주기적으로 OS에 되돌리는 역할을 했다. Linux와 Windows에서는 이 기능이 꺼져 있었기 때문에, 해제된 메모리도 프로세스 주소 공간에 계속 남을 수 있었다.

[해제된 메모리 페이지]
    ↓
[스캐빈저 주기 작동]
    ↓
[madvise(MADV_DONTNEED) 호출]
    ↓
[OS가 물리 메모리 페이지 회수]

Linux와 Windows에서도 이 경로가 활성화되면서 RSS(Resident Set Size)가 크게 줄었다. 대규모 JSON 파싱처럼 동적 할당이 많은 워크로드에서 메모리 압박을 완화하는 변화다.

JSC, libpas, mimalloc이 이어지는 메모리 계층

Bun의 메모리 관리는 하나의 할당자만으로 구성되지 않는다. JSC가 JavaScript 객체를 관리하고, libpas와 mimalloc이 그 아래에서 할당과 페이지 관리를 나눈다.

OS 계층Native 계층libpas 계층JavaScriptmadviseDONTNEEDJSC GC HeapEden GenerationOld GenerationSegregated Storage SlabsBitfit HeapsThread-Local Cachesmimalloc v3Per-Thread Page Lists4MB Segments물리 메모리가상 메모리

libpas의 Segregated Storage는 크기별 슬래브를 분리해 메모리 단편화를 낮춘다. Bitfit Heaps는 작은 할당을 위한 비트맵 기반 관리 방식이며, Thread-Local Caches(TLC)는 스레드별 지역 캐시로 lock 경쟁을 줄인다. 스캐빈저는 더 이상 쓰이지 않는 페이지를 주기적으로 OS에 반환한다.

mimalloc v3에서는 per-thread page-local free list가 도입돼 캐시 효율성이 높아졌고, 4MB 단위 segment 관리는 메모리 지역성을 개선한다. decommit 정책도 더 정교해져 메모리 압축성이 향상됐다. 이 계층 구조는 JSC가 관리하는 JavaScript 객체와 Zig·C++로 관리되는 native 자료구조를 함께 다룬다.

세대별 GC와 힙 압박을 다루는 방법

Bun이 사용하는 JSC GC는 generational, incremental mark-and-sweep 방식이다. 새 객체는 Eden Generation에 먼저 들어가고 빠르게 수집된다. 여러 GC를 살아남은 객체는 Old Generation으로 이동하며, 이 영역은 더 드물게 수집된다.

이 설계는 대부분의 객체가 생성 직후 사라진다는 약한 세대 가설(weak generational hypothesis)을 따른다.

할당률 모니터링 + 힙 크기 모니터링
    ↓
임계값 초과 시 GC 작동
    ↓
Eden 수집 먼저 시도 (빠름)
    ↓
메모리 부족 시 Full GC (느림)

1 GiB 제한 컨테이너에서 안정적으로 운영하려면 초기 힙 크기를 전체 메모리의 50% 이상으로 잡지 않아야 한다. node --expose-gc와 유사한 메모리 프로파일링 도구로 GC 압력도 관찰할 필요가 있다.

수명이 긴 대규모 객체 배열은 object pool 패턴으로 재사용하고, 데이터는 전체를 메모리에 올리는 대신 청크 단위 스트림으로 처리하는 편이 적합하다.

격리·병렬·샤딩으로 테스트 실행 구성하기

Bun v1.3.13은 큰 테스트 스위트를 다루기 위한 세 가지 플래그를 제공한다.

--isolate는 같은 OS 프로세스 안에서 테스트 파일마다 격리된 환경을 만든다. 파일별로 globalThis 상태를 초기화하므로 테스트 간 상태 오염을 막으면서 단일 프로세스 오버헤드를 줄인다.

bun test --isolate

--parallel[=N]은 N개 워커 프로세스에 테스트 파일을 나눠 병렬 실행한다. 기본값은 CPU 코어 수이며, work-stealing 스케줄러가 부하 균형을 유지한다. 결과 출력은 원자적으로 flush돼 충돌을 막는다.

bun test --parallel=4

--shard=M/N은 테스트 파일 목록을 N개 그룹으로 나눠 CI 러너에 배정한다. 렉시콘 정렬된 파일 목록을 round-robin으로 배분하므로 결과를 재현할 수 있고, --changed, --randomize와도 함께 쓸 수 있다.

bun test --shard=1/3  # CI에서 3개 러너 중 1번 실행
병렬 실행 (--parallel=2)Shard 분배 (--shard=M/N)테스트 파일 목록test_1.tstest_2.tstest_3.tstest_4.tstest_5.tstest_6.tsRunner 1test_1, test_4Runner 2test_2, test_5Runner 3test_3, test_6Worker 1Worker 2

CI와 로컬에서 테스트 러너를 조합하는 방식

마이크로서비스 아키텍처에서 수백 개 테스트 파일을 실행할 때는 CI 워커마다 shard를 배정할 수 있다. 각 워커는 자기 shard만 실행하므로 전체 시간은 1/N으로 단축된다.

# GitHub Actions 예제
- run: bun test --shard=${{ matrix.shard }}/${{ strategy.job-total }}

로컬에서는 변경된 파일만 골라 격리·병렬 실행하는 구성이 빠른 피드백에 맞는다.

bun test --parallel=auto --isolate --changed

단위 테스트와 DB 연결을 공유하는 통합 테스트는 실행 정책을 나누는 편이 낫다.

# 단위 테스트: 병렬 + 격리
bun test --parallel --isolate unit/

# 통합 테스트: 순차 실행 (DB 연결 공유)
bun test sequential integration/

장기 실행 서버에서 메모리 압박을 줄이는 코드 패턴

메모리 관리는 런타임 패치만으로 끝나지 않는다. 서버의 메모리 상태를 관찰하고, 반복 할당과 전체 로드를 피하는 코드 경로를 함께 관리해야 한다.

헬스 체크 엔드포인트에서 RSS와 힙 사용량을 노출하면 운영 중 메모리 변화를 확인할 수 있다.

// 서버 헬스 체크 엔드포인트
app.get('/health', (req, res) => {
  const used = process.memoryUsage();
  const rssPercent = (used.rss / 1024 / 1024 / 1024 * 100).toFixed(2);
  
  res.json({
    status: rssPercent > 80 ? 'warning' : 'healthy',
    memory: {
      heapUsed: `${(used.heapUsed / 1024 / 1024).toFixed(2)} MB`,
      rss: `${(used.rss / 1024 / 1024).toFixed(2)} MB`
    }
  });
});

장시간 실행되는 이벤트 루프에서는 객체를 계속 새로 만들기보다 버퍼를 풀로 재사용할 수 있다.

class BufferPool {
  private pool: Buffer[] = [];
  
  acquire(size: number): Buffer {
    return this.pool.pop() ?? Buffer.allocUnsafe(size);
  }
  
  release(buf: Buffer): void {
    this.pool.push(buf);
  }
}

파일이나 큰 입력은 전체를 올리지 않고 스트림으로 읽는다.

// 나쁜 패턴: 전체 파일 메모리 로드
const data = await file.text();

// 좋은 패턴: 스트림 처리
const stream = file.stream();
for await (const chunk of stream) {
  processChunk(chunk);
}

예측 가능한 시점에 전체 GC를 실행하는 방법도 있다.

// 오프피크 시간에 전체 GC
if (isOffPeakHour()) {
  if (globalThis.gc) {
    globalThis.gc();
  }
}

Chrome DevTools의 힙 스냅샷 비교, Node.js clinic.js와 유사한 도구를 통한 메모리 추이 분석, CI 성능 회귀 테스트 자동화는 누수 탐지에 사용할 수 있다.

Bun v1.3.13은 libpas 스캐빈저를 모든 플랫폼에서 활성화하고 mimalloc을 최신 버전으로 교체해 장기 실행 JavaScript 서버의 메모리 관리 경로를 개선했다. 테스트 러너의 --isolate, --parallel, --shard는 대규모 프로젝트의 CI/CD 시간 단축에도 연결된다. Bun 커뮤니티의 지속적인 개선과 WebKit 엔진 통합을 고려하면, 메모리 제약이 있는 엣지 컴퓨팅이나 서버리스 환경에서 그 가치가 돋보일 것으로 예상된다.

Sources

Bun메모리 관리가비지 컬렉션JavaScript 런타임테스트 자동화