Skip to content

바이트코드 캐싱은 빌드 타임 최적화로, JavaScript 를 바이트코드로 사전 컴파일하여 애플리케이션 시작 시간을 획기적으로 개선합니다. 예를 들어 TypeScript 의 tsc 를 바이트코드 활성화로 컴파일하면 시작 시간이 2 배 개선됩니다.

사용법

기본 사용법

--bytecode 플래그로 바이트코드 캐싱을 활성화합니다:

bash
bun build ./index.ts --target=bun --bytecode --outdir=./dist

이 명령은 두 개의 파일을 생성합니다:

  • dist/index.js - 번들된 JavaScript
  • dist/index.jsc - 바이트코드 캐시 파일

런타임에 Bun 은 자동으로 .jsc 파일을 감지하고 사용합니다:

bash
bun ./dist/index.js  # 자동으로 index.jsc 사용

스탠드얼론 실행 파일과 함께

--compile 로 실행 파일을 생성할 때 바이트코드가 바이너리에 임베드됩니다:

bash
bun build ./cli.ts --compile --bytecode --outfile=mycli

결과 실행 파일에는 코드와 바이트코드가 모두 포함되어 단일 파일로 최대 성능을 제공합니다.

다른 최적화와 결합

바이트코드는 축소 및 소스맵과 함께 잘 작동합니다:

bash
bun build --compile --bytecode --minify --sourcemap ./cli.ts --outfile=mycli
  • --minify 는 바이트코드 생성 전 코드 크기를 줄입니다 (코드가 적을수록 바이트코드도 적음)
  • --sourcemap 은 오류 보고를 유지합니다 (오류는 여전히 원본 소스를 가리킴)
  • --bytecode 는 파싱 오버헤드를 제거합니다

성능 영향

성능 개선은 코드베이스 크기에 따라 비례합니다:

애플리케이션 크기일반적인 시작 시간 개선
작은 CLI (< 100 KB)1.5-2 배 빠름
중대형 앱 (> 5 MB)2.5-4 배 빠름

더 큰 애플리케이션은 파싱할 코드가 더 많기 때문에 더 큰 이점을 얻습니다.

바이트코드 사용 시기

적합한 경우:

CLI 도구

  • 자주 호출되는 경우 (린터, 포맷터, 깃 훅)
  • 시작 시간이 전체 사용자 경험
  • 사용자가 90ms 와 45ms 시작 시간 차이를 체감
  • 예: TypeScript 컴파일러, Prettier, ESLint

빌드 도구 및 작업 러너

  • 개발 중 수백에서 수천 번 실행
  • 런타임당 절약된 밀리초가 빠르게 누적
  • 개발자 경험 개선
  • 예: 빌드 스크립트, 테스트 러너, 코드 생성기

스탠드얼론 실행 파일

  • 빠른 성능을 중시하는 사용자에게 배포
  • 단일 파일 배포가 편리
  • 파일 크기보다 시작 시간이 더 중요
  • 예: npm 또는 바이너리로 배포되는 CLI

사용하지 않는 것이 좋은 경우:

  • 작은 스크립트
  • 한 번만 실행되는 코드
  • 개발 빌드
  • 크기 제한이 있는 환경
  • 최상위 await 가 있는 코드 (지원되지 않음)

제한사항

CommonJS 만 지원

바이트코드 캐싱은 현재 CommonJS 출력 형식과 함께 작동합니다. Bun 의 번들러는 대부분의 ESM 코드를 CommonJS 로 자동 변환하지만, 최상위 await 는 예외입니다:

js
// 바이트코드 캐싱 방지
const data = await fetch("https://api.example.com");
export default data;

이유: 최상위 await 는 비동기 모듈 평가가 필요하며 CommonJS 로 표현할 수 없습니다. 모듈 그래프가 비동기가 되고 CommonJS 래퍼 함수 모델이 무너집니다.

해결 방법: 비동기 초기화를 함수 내부로 이동:

js
async function init() {
  const data = await fetch("https://api.example.com");
  return data;
}

export default init;

이제 모듈은 소비자가 필요할 때 await 할 수 있는 함수를 내보냅니다.

버전 호환성

바이트코드는 Bun 버전 간에 이식할 수 없습니다. 바이트코드 형식은 버전 간에 변경되는 JavaScriptCore 의 내부 표현에 연결되어 있습니다.

Bun 을 업데이트할 때 바이트코드를 다시 생성해야 합니다:

bash
# Bun 업데이트 후
bun build --bytecode ./index.ts --outdir=./dist

바이트코드가 현재 Bun 버전과 일치하지 않으면 자동으로 무시되고 코드는 JavaScript 소스 파싱으로 폴백됩니다. 앱은 계속 실행되지만 성능 최적화는 잃게 됩니다.

모범 사례: CI/CD 빌드 프로세스의 일부로 바이트코드를 생성하세요. .jsc 파일을 git 에 커밋하지 마세요. Bun 을 업데이트할 때마다 다시 생성하세요.

소스 코드仍需 필요

  • .js 파일 (번들된 소스 코드)
  • .jsc 파일 (바이트코드 캐시)

런타임에:

  1. Bun 은 .js 파일을 로드하고 @bytecode 프라그마를 확인한 후 .jsc 파일을 확인합니다
  2. Bun 은 .jsc 파일을 로드합니다
  3. Bun 은 바이트코드 해시가 소스와 일치하는지 검증합니다
  4. 유효하면 Bun 은 바이트코드를 사용합니다
  5. 무효하면 Bun 은 소스 파싱으로 폴백합니다

바이트코드는 난독화가 아닙니다

바이트코드는 소스 코드를 난독화하지 않습니다. 이는 최적화이지 보안 조치가 아닙니다.

프로덕션 배포

Docker

Dockerfile 에 바이트코드 생성을 포함하세요:

dockerfile
FROM oven/bun:1 AS builder
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

COPY . .
RUN bun build --bytecode --minify --sourcemap \
  --target=bun \
  --outdir=./dist \
  --compile \
  ./src/server.ts --outfile=./dist/server

FROM oven/bun:1 AS runner
WORKDIR /app
COPY --from=builder /dist/server /app/server
CMD ["./server"]

바이트코드는 아키텍처에 독립적입니다.

CI/CD

빌드 파이프라인 중에 바이트코드를 생성하세요:

yaml
# GitHub Actions
- name: 바이트코드로 빌드
  run: |
    bun install
    bun build --bytecode --minify \
      --outdir=./dist \
      --target=bun \
      ./src/index.ts

디버깅

바이트코드 사용 확인

.jsc 파일이 존재하는지 확인하세요:

bash
ls -lh dist/
txt
-rw-r--r--  1 user  staff   245K  index.js
-rw-r--r--  1 user  staff   1.1M  index.jsc

.jsc 파일은 .js 파일보다 2-8 배 더 커야 합니다.

바이트코드 사용 여부를 로그로 확인하려면 환경에서 BUN_JSC_verboseDiskCache=1 을 설정하세요.

성공하면 다음과 같이 로그가 출력됩니다:

txt
[Disk cache] cache hit for sourceCode

캐시 미스가 발생하면 다음과 같이 로그가 출력됩니다:

txt
[Disk cache] cache miss for sourceCode

Bun 이 현재 빌트인 모듈에 사용되는 JavaScript 코드를 바이트코드 캐싱하지 않기 때문에 여러 번 캐시 미스가 로그에 표시되는 것은 정상입니다.

일반적인 문제

바이트코드가 무시됨: 대개 Bun 버전 업데이트로 인해 발생합니다. 캐시 버전이 일치하지 않아 바이트코드가 거부됩니다. 다시 생성하여 해결하세요.

파일 크기가 너무 큼: 이는 예상된 사항입니다. 다음을 고려하세요:

  • 바이트코드 생성 전 --minify 로 코드 크기 줄이기
  • 네트워크 전송을 위해 .jsc 파일 압축 (gzip/brotli)
  • 시작 성능 향상이 크기 증가의 가치가 있는지 평가

최상위 await: 지원되지 않습니다. 비동기 초기화 함수를 사용하도록 리팩토링하세요.

바이트코드란?

JavaScript 를 실행할 때 JavaScript 엔진은 소스 코드를 직접 실행하지 않습니다. 대신 여러 단계를 거칩니다:

  1. 파싱: 엔진이 JavaScript 소스 코드를 읽고 추상 구문 트리 (AST) 로 변환합니다
  2. 바이트코드 컴파일: AST 를 바이트코드로 컴파일합니다 - 실행이 더 빠른 저수준 표현
  3. 실행: 바이트코드는 엔진의 인터프리터 또는 JIT 컴파일러에 의해 실행됩니다

바이트코드는 중간 표현입니다 - JavaScript 소스 코드보다 낮지만 머신 코드보다는 높은 수준입니다. 가상 머신을 위한 어셈블리 언어라고 생각하세요. 각 바이트코드 명령은 "이 변수를 로드", "두 숫자 더하기", "이 함수 호출"과 같은 단일 연산을 나타냅니다.

이 과정은 코드를 실행할 매번 발생합니다. 하루에 100 번 실행되는 CLI 도구가 있다면 코드는 100 번 파싱됩니다. 빈번한 콜드 스타트가 있는 서버리스 함수가 있다면 모든 콜드 스타트에서 파싱이 발생합니다.

바이트코드 캐싱을 사용하면 Bun 은 1 단계와 2 단계를 빌드 단계로 이동합니다. 런타임에 엔진은 사전 컴파일된 바이트코드를 로드하고 바로 실행으로 이동합니다.

지연 파싱이 이를 더 좋게 만드는 이유

최신 JavaScript 엔진은 지연 파싱이라는 똑똑한 최적화를 사용합니다. 모든 코드를 미리 파싱하지 않고 함수가 처음 호출될 때만 파싱합니다:

js
// 바이트코드 캐싱 없이:
function rarely_used() {
  // 이 500 줄 함수는 실제로 호출될 때만
  // 파싱됩니다
}

function main() {
  console.log("앱 시작");
  // rarely_used() 는 결코 호출되지 않으므로 파싱되지 않음
}

이는 파싱 오버헤드가 시작 비용뿐만 아니라 애플리케이션 수명 전체에 걸쳐 다양한 코드 경로가 실행될 때 발생함을 의미합니다. 바이트코드 캐싱을 사용하면 모든 함수가 사전 컴파일되며 지연 파싱되는 함수도 포함됩니다. 파싱 작업은 애플리케이션 실행 전체에 분산되는 대신 빌드 타임에 한 번 발생합니다.

바이트코드 형식

.jsc 파일 내부

.jsc 파일에는 직렬화된 바이트코드 구조가 포함되어 있습니다. 내부 내용을 이해하면 성능 이점과 파일 크기 절충 관계를 모두 설명하는 데 도움이 됩니다.

헤더 섹션 (로드할 때마다 검증):

  • 캐시 버전: JavaScriptCore 프레임워크 버전에 연결된 해시. 이는 한 Bun 버전으로 생성된 바이트코드가 정확히 그 버전에서만 실행되도록 보장합니다.
  • 코드 블록 타입 태그: 이것이 Program, Module, Eval 또는 Function 코드 블록인지 식별합니다.

SourceCodeKey (바이트코드가 소스와 일치하는지 검증):

  • 소스 코드 해시: 원본 JavaScript 소스 코드의 해시. Bun 은 바이트코드를 사용하기 전에 이것이 일치하는지 확인합니다.
  • 소스 코드 길이: 추가 검증을 위한 정확한 길이.
  • 컴파일 플래그: strict 모드, 스크립트 대 모듈, eval 컨텍스트 타입 등 중요한 컴파일 컨텍스트. 다른 플래그로 컴파일된 동일한 소스 코드는 다른 바이트코드를 생성합니다.

바이트코드 명령:

  • 명령 스트림: 실제 바이트코드 오피코드 - JavaScript 의 컴파일된 표현. 이는 가변 길이 바이트코드 명령 시퀀스입니다.
  • 메타데이터 테이블: 각 오피코드에는 관련 메타데이터가 있습니다 - 프로파일링 카운터, 타입 힌트, 실행 카운트 (아직 채워지지 않았더라도).
  • 점프 타겟: 제어 흐름 (if/else, 루프, switch 문) 을 위한 사전 계산된 주소.
  • 스위치 테이블: switch 문을 위한 최적화된 룩업 테이블.

상수 및 식별자:

  • 상수 풀: 코드의 모든 리터럴 값 - 숫자, 문자열, 불리언, null, undefined. 이들은 런타임에 소스에서 파싱할 필요가 없도록 실제 JavaScript 값 (JSValues) 으로 저장됩니다.
  • 식별자 테이블: 코드에 사용된 모든 변수 및 함수 이름. 중복 제거된 문자열로 저장됩니다.
  • 소스 코드 표현 마커: 상수가 어떻게 표현되어야 하는지 (정수, 더블, 빅인트 등) 를 나타내는 플래그.

함수 메타데이터 (코드의 각 함수에 대해):

  • 레지스터 할당: 함수에 필요한 레지스터 (로컬 변수) 수 - thisRegister, scopeRegister, numVars, numCalleeLocals, numParameters.
  • 코드 기능: 함수 특성의 비트마스크: 생성자인가? 애로우 함수인가? super 를 사용하는가? 테일 콜이 있는가? 이들은 함수가 어떻게 실행되는지에 영향을 미칩니다.
  • 어휘 스코프 기능: strict 모드 및 기타 어휘 컨텍스트.
  • 파싱 모드: 함수가 파싱된 모드 (normal, async, generator, async generator).

중첩 구조:

  • 함수 선언 및 표현식: 각 중첩 함수는 재귀적으로 자체 바이트코드 블록을 가집니다. 100 개의 함수가 있는 파일에는 구조 내에 중첩된 100 개의 별도 바이트코드 블록이 있습니다.
  • 예외 핸들러: Try/catch/finally 블록과 경계 및 핸들러 주소가 사전 계산됨.
  • 표현식 정보: 오류 보고 및 디버깅을 위해 바이트코드 위치를 소스 코드 위치로 매핑.

바이트코드에 포함되지 않는 것

중요하게도, 바이트코드에는 소스 코드가 포함되지 않습니다. 대신:

  • JavaScript 소스는 별도로 저장됩니다 (.js 파일에)
  • 바이트코드는 소스의 해시와 길이만 저장합니다
  • 로드 시 Bun 은 바이트코드가 현재 소스 코드와 일치하는지 검증합니다

이것이 .js.jsc 파일을 모두 배포해야 하는 이유입니다. .jsc 파일은 해당 .js 파일 없이는 쓸모가 없습니다.

절충 관계: 파일 크기

바이트코드 파일은 소스 코드보다 훨씬 큽니다 - 일반적으로 2-8 배.

바이트코드가 훨씬 큰 이유

바이트코드 명령은 상세합니다: 축소된 JavaScript 한 줄은 수십 개의 바이트코드 명령으로 컴파일될 수 있습니다. 예를 들어:

js
const sum = arr.reduce((a, b) => a + b, 0);

다음과 같은 바이트코드로 컴파일됩니다:

  • arr 변수 로드
  • reduce 속성 가져오기
  • 애로우 함수 생성 (자체 바이트코드 있음)
  • 초기 값 0 로드
  • 올바른 인수 수로 호출 설정
  • 실제로 호출 수행
  • 결과를 sum 에 저장

이 각 단계는 자체 메타데이터가 있는 별도 바이트코드 명령입니다.

상수 풀은 모든 것을 저장합니다: 모든 문자열 리터럴, 숫자, 속성 이름 - 모든 것이 상수 풀에 저장됩니다. 소스 코드에 "hello" 가 백 번 있더라도 상수 풀은 한 번만 저장하지만 식별자 테이블과 상수 참조는 오버헤드를 추가합니다.

함수당 메타데이터: 각 함수 - 작은 한 줄 함수조차 - 는 자체 완전한 메타데이터를 가집니다:

  • 레지스터 할당 정보
  • 코드 기능 비트마스크
  • 파싱 모드
  • 예외 핸들러
  • 디버깅을 위한 표현식 정보

1,000 개의 작은 함수가 있는 파일에는 1,000 세트의 메타데이터가 있습니다.

프로파일링 데이터 구조: 프로파일링 데이터가 아직 채워지지 않았더라도, 프로파일링 데이터를 보유할 구조가 할당됩니다. 여기에는 다음이 포함됩니다:

  • 값 프로파일 슬롯 (각 연산을 통해 흐르는 타입 추적)
  • 배열 프로파일 슬롯 (배열 액세스 패턴 추적)
  • 이진 산술 프로파일 슬롯 (수학 연산의 숫자 타입 추적)
  • 단항 산술 프로파일 슬롯

이들은 비어 있을 때도 공간을 차지합니다.

사전 계산된 제어 흐름: 점프 타겟, 스위치 테이블, 예외 핸들러 경계가 모두 사전 계산되어 저장됩니다. 이는 실행을 더 빠르게 만들지만 파일 크기를 증가시킵니다.

완화 전략

압축: 바이트코드는 gzip/brotli 로 매우 잘 압축됩니다 (60-70% 압축). 반복적인 구조와 메타데이터가 효율적으로 압축됩니다.

먼저 축소: 바이트코드 생성 전 --minify 사용은 도움이 됩니다:

  • 더 짧은 식별자 → 더 작은 식별자 테이블
  • 사용하지 않는 코드 제거 → 생성된 바이트코드 감소
  • 상수 폴딩 → 풀의 상수 감소

절충 관계: 2-4 배 더 큰 파일을 2-4 배 더 빠른 시작 시간과 교환합니다. CLI 의 경우 일반적으로 가치가 있습니다. 디스크 공간 몇 메가바이트가 중요하지 않은 장기간 실행되는 서버의 경우 문제는 더욱 적습니다.

버전 관리 및 이식성

아키텍처 간 이식성: ✅

바이트코드는 아키텍처에 독립적입니다. 다음이 가능합니다:

  • macOS ARM64 에서 빌드, Linux x64 에 배포
  • Linux x64 에서 빌드, AWS Lambda ARM64 에 배포
  • Windows x64 에서 빌드, macOS ARM64 에 배포

바이트코드에는 모든 아키텍처에서 작동하는 추상 명령이 포함되어 있습니다. 아키텍처별 최적화는 런타임에 JIT 컴파일 중에 발생하며 캐시된 바이트코드에서는 발생하지 않습니다.

버전 간 이식성: ❌

바이트코드는 Bun 버전 간에 안정적이지 않습니다. 이유는 다음과 같습니다:

바이트코드 형식 변경: JavaScriptCore 의 바이트코드 형식은 발전합니다. 새로운 오피코드가 추가되고, 오래된 것이 제거되거나 변경되며, 메타데이터 구조가 변경됩니다. 각 JavaScriptCore 버전에는 다른 바이트코드 형식이 있습니다.

버전 검증: .jsc 파일 헤더의 캐시 버전은 JavaScriptCore 프레임워크의 해시입니다. Bun 이 바이트코드를 로드할 때:

  1. .jsc 파일에서 캐시 버전을 추출합니다
  2. 현재 JavaScriptCore 버전을 계산합니다
  3. 일치하지 않으면 바이트코드가 조용히 거부됩니다
  4. Bun 은 .js 소스 코드 파싱으로 폴백합니다

애플리케이션은 계속 실행됩니다 - 성능 최적화만 잃게 됩니다.

우아한 저하: 이 설계는 바이트코드 캐싱이 "열린 실패"를 한다는 것을 의미합니다 - 문제가 발생하면 (버전 불일치, 손상된 파일, 누락된 파일) 코드는 정상적으로 계속 실행됩니다. 시작이 느려질 수 있지만 오류는 발생하지 않습니다.

링크되지 않은 vs 링크된 바이트코드

JavaScriptCore 는 "링크되지 않은"과 "링크된" 바이트코드 사이에 중요한 구분을 합니다. 이 분리가 바이트코드 캐싱을 가능하게 합니다:

링크되지 않은 바이트코드 (캐시됨)

.jsc 파일에 저장된 바이트코드는 링크되지 않은 바이트코드입니다. 다음을 포함합니다:

  • 컴파일된 바이트코드 명령
  • 코드에 대한 구조적 정보
  • 상수 및 식별자
  • 제어 흐름 정보

하지만 포함하지 않는 것:

  • 실제 런타임 객체에 대한 포인터
  • JIT 컴파일된 머신 코드
  • 이전 실행의 프로파일링 데이터
  • 호출 링크 정보 (어떤 함수가 어떤 함수를 호출하는지)

링크되지 않은 바이트코드는 불변이고 공유 가능합니다. 동일한 코드의 여러 실행이 모두 동일한 링크되지 않은 바이트코드를 참조할 수 있습니다.

링크된 바이트코드 (런타임 실행)

Bun 이 바이트코드를 실행할 때 "링크"합니다 - 다음을 추가하는 런타임 래퍼를 생성합니다:

  • 호출 링크 정보: 코드가 실행됨에 따라 엔진은 어떤 함수가 어떤 함수를 호출하는지 학습하고 해당 호출 사이트를 최적화합니다.
  • 프로파일링 데이터: 엔진은 각 명령이 몇 번 실행되는지, 어떤 타입의 값이 코드를 통해 흐르는지, 배열 액세스 패턴 등을 추적합니다.
  • JIT 컴파일 상태: 핫 코드의 베이스라인 JIT 또는 최적화 JIT(DFG/FTL) 컴파일된 버전에 대한 참조.
  • 런타임 객체: 실제 JavaScript 객체, 프로토타입, 스코프 등에 대한 포인터.

이 링크된 표현은 코드를 실행할 때마다 새로 생성됩니다. 이를 통해 다음이 가능해집니다:

  1. 비싼 작업 캐싱 (링크되지 않은 바이트코드로의 파싱 및 컴파일)
  2. 여전히 런타임 프로파일링 데이터 수집하여 최적화 안내
  3. 여전히 실제 실행 패턴에 기반한 JIT 최적화 적용

바이트코드 캐싱은 비싼 작업 (파싱 및 바이트코드 컴파일) 을 런타임에서 빌드 타임으로 이동합니다. 자주 시작하는 애플리케이션의 경우 디스크의 더 큰 파일을 대가로 시작 시간을 절반으로 줄일 수 있습니다.

프로덕션 CLI 및 서버리스 배포의 경우 --bytecode --minify --sourcemap 조합은 디버깅 가능성을 유지하면서 최상의 성능을 제공합니다.

Bun by www.bunjs.com.cn 편집