본문으로 건너뛰기
피드

버셀 실험실의 Scriptc, TypeScript를 Node 없이 네이티브 바이너리로 컴파일

backend 약 9분
vote
0
댓글
북마크

Vercel Labs가 공개한 Scriptc는 일반 TypeScript 코드를 Node, V8, JavaScript 엔진 없이 작은 네이티브 실행 파일로 컴파일하는 실험적 컴파일러다. 정적으로 컴파일 가능한 코드와 동적 실행이 필요한 코드를 명확히 나누고, Node와 결과가 바이트 단위로 맞는지 800개 이상 테스트로 검증한다.

  • 1

    TypeScript 코드를 수정 없이 네이티브 바이너리로 빌드

  • 2

    정적 컴파일 기본값, 필요할 때만 QuickJS 기반 동적 모드 사용

  • 3

    Apple M 시리즈 기준 시작 시간 약 2.4ms, 바이너리 크기 170~200KB 수준

  • 4

    Node와 출력, 종료 코드, 숫자 포맷까지 맞추는 차등 테스트 운영

  • Vercel Labs가 scriptc라는 TypeScript-to-native 컴파일러를 공개함

    • 목표는 꽤 과감함. 평범한 TypeScript를 작은 네이티브 실행 파일로 만들고, 결과 바이너리 안에는 Node도 V8도 JavaScript 엔진도 안 넣겠다는 것
    • 예시로 피보나치 TypeScript 파일을 빌드하면 약 178KB짜리 자체 실행 바이너리가 나오고, 시작 시간은 약 2ms 수준이라고 제시함
  • 핵심 컨셉은 “대부분의 TypeScript는 생각보다 정적이다”에 가까움

    • scriptc coverage app.ts를 돌리면 어떤 문장이 정적으로 컴파일됐고, 어떤 부분이 막혔는지 보여줌
    • 예시에서는 4,481개 문장 중 4,451개가 정적으로 컴파일돼 99% 커버리지를 기록했고, optional parameter나 Promise.reject 같은 구체적인 blocker 코드도 표시됨
  • 실행 모델은 세 단계로 나뉨. 애매하게 몰래 처리하지 않는 게 포인트임

    • 기본은 정적 컴파일. 네이티브 코드로 내려가고 엔진은 없음
    • --dynamic을 켜면 QuickJS 계열 엔진 약 620KB를 넣어서 npm 패키지의 배포 JavaScript나 any 타입 코드를 실행함
    • 그 외에 처리할 수 없는 코드는 구체적인 에러 코드, 코드 프레임, 재작성 힌트와 함께 거절됨

중요

> 이 프로젝트가 재밌는 지점은 “TypeScript를 네이티브로 컴파일한다”보다 “Node와 다르게 동작하면 실패로 본다”는 검증 철학임.

  • 지원 범위도 장난감 수준만 노린 건 아님

    • 클래스, 단일 상속, 동적 디스패치, 클로저, 제네릭 단형화, discriminated union, async/await, 예외 처리, destructuring, spread, iterator, 정규식 등을 다룸
    • 문자열은 UTF-16 의미론을 맞추고, 배열/Map/Set의 순서와 identity, JSON 캐스팅 검증, typed array, Buffer, Error 계층도 지원한다고 설명함
    • Node API 쪽도 fs, path, process, child_process, os, crypto, url, zlib, timer, signal handler, net, http, https, tls, dgram, dns, fs.watch, readline까지 꽤 넓게 잡음
  • fetch와 일부 WHATWG 웹 API도 네이티브 네트워크 스택 위에서 처리함

    • stream, Headers, AbortSignal을 지원하고 redirect, gzip, AbortSignal.timeout, Node 스타일 error cause도 챙김
    • libcurl이나 시스템 HTTP 의존성 없이 자체 net/TLS 스택을 쓰는 방향임
  • npm 의존성은 정적 세계와 동적 세계를 분리해서 다룸

    • --dynamic을 켜면 Node의 모듈 해석 알고리즘으로 패키지를 찾고, .d.ts로 타입 체크한 뒤, 실제 JavaScript를 빌드 시점에 바이너리에 포함함
    • 런타임에는 node_modules를 읽지 않는다고 못 박음
    • 정적 코드로 다시 넘어오는 값은 런타임 검증을 거쳐서, 타입이 거짓이면 메모리 오염 대신 잡을 수 있는 TypeError가 나게 함
  • 검증 체계는 이 프로젝트의 진짜 승부처임

    • 800개 이상의 corpus 프로그램을 Node와 네이티브 바이너리 양쪽에서 실행하고 stdout, stderr, exit code를 바이트 단위로 비교함
    • 숫자 포맷도 JavaScript와 같아야 해서, 백만 개 double 값으로 fuzz 검증했다고 함
    • 서버는 실제 client driver로 양쪽 구현을 때려보며 비교함
    • AddressSanitizer와 reference-count audit도 전체 corpus에 돌려 leak이나 use-after-free가 있으면 빌드 실패로 처리함
  • 성능 수치는 CLI 도구 만드는 사람 입장에선 눈이 감

    • Apple M 시리즈 기준 시작 시간은 Scriptc 약 2.4ms, Node 약 47ms로 제시됨
    • 정적 바이너리는 170~200KB, --dynamic과 embedded dependency를 넣으면 약 3MB 수준이라고 함
    • 비교 대상으로 Go는 약 2MB, Node SEA는 60~100MB로 제시됨
    • 메모리 RSS는 일반적으로 14MB이며, Node는 67116MB 범위였다고 설명함

ℹ️참고

> 아직 integer inference와 ownership analysis는 로드맵에 있다고 밝힘. 즉 “Rust 대체”보다는 “Node 없이 TypeScript CLI/서버를 작게 배포” 쪽에 먼저 꽂히는 물건임.

  • 탈출구도 몇 가지 준비돼 있음

    • comptime(() => ...)는 빌드 시점에 TypeScript를 실행해서 결과를 literal로 바이너리에 박아 넣음
    • --ffi는 TypeScript 선언을 C ABI 호출에 연결하고, manifest에 선언된 archive, object, system library를 링크함
    • JSON.parse(...) as Config 같은 캐스팅에는 런타임 검증을 넣어 $.port에 문자열이 들어왔다는 식으로 경로까지 알려주는 에러를 낼 수 있음
  • 내부 구조는 컴파일러, 런타임, CLI로 나뉨

    • packages/compiler는 TypeScript compiler API에서 IR로 낮추고 LLVM/C 백엔드로 넘김
    • packages/runtime은 reference-counted value, cycle collector, stackful fiber, event loop, 서버 스택, JS와 같은 숫자 포맷을 담당함
    • packages/cliscriptc build, run, coverage 명령을 제공함

기술 맥락

  • 이 프로젝트의 선택은 “TypeScript를 새 언어처럼 제한하자”가 아니라 “기존 TypeScript를 최대한 그대로 받아들이자”에 가까워요. 그래서 TypeScript compiler API로 먼저 파싱과 타입 체크를 하고, 정적으로 낮출 수 있는 construct만 IR로 보낸다는 구조를 택한 거예요.

  • --dynamic을 기본값으로 두지 않은 것도 중요해요. npm 생태계까지 전부 품으려면 JavaScript 엔진을 넣는 게 편하지만, 그러면 작은 네이티브 바이너리라는 장점이 흐려지거든요. Scriptc는 정적 컴파일을 기본으로 두고, 엔진이 필요할 때 개발자가 명시적으로 선택하게 만들었어요.

  • Node 호환성을 말로만 주장하지 않고 differential testing으로 잡는 이유는 JavaScript 의미론이 생각보다 까다롭기 때문이에요. 숫자 포맷, 예외 속성, async 스케줄링, Map 순서 같은 작은 차이가 실제 프로그램에서는 버그로 튀어나올 수 있거든요.

  • LLVM과 C 백엔드를 같이 두는 설계도 현실적이에요. LLVM은 성능 좋은 네이티브 코드를 만들기 좋지만, C 백엔드는 사람이 읽고 추적하기 쉬운 참조 경로가 돼요. 컴파일러 프로젝트에서는 디버깅 가능한 출력이 성능만큼 중요해지는 순간이 많아요.

TypeScript를 그냥 스크립트 언어로만 보던 시각에 꽤 세게 태클을 거는 프로젝트다. 아직 실험적이지만, CLI 도구나 작은 서버를 배포할 때 Node 런타임을 통째로 들고 다니는 비용을 줄일 수 있다는 점은 꽤 매력적임.

댓글

댓글

댓글을 불러오는 중...

backend

데카트론 독일, 웹 결제에 유럽식 계좌이체 ‘웨로’ 붙였다

데카트론 독일이 자사 웹사이트 결제 수단에 유럽 결제 이니셔티브의 웨로를 도입했다. 카드 네트워크를 거치지 않는 실시간 계좌 간 결제로 수수료를 낮추고, 멤버십과 결제를 묶어 고객 유지까지 노리는 움직임이다.

backend

SIMD는 고수 전용 흑마법이 아니라, 모든 개발자가 알아야 할 반복문 최적화임

미첼 하시모토가 SIMD를 ‘복잡한 고성능 영역’으로만 볼 필요가 없다고 설명한 글이다. Ghostty의 실제 Zig 코드를 예로 들며, 큰 배열이나 문자열을 훑는 뜨거운 반복문은 벡터 너비만큼 한 번에 처리하는 5단계 패턴으로 생각할 수 있다고 풀어낸다.

backend

스타트업이 Postgres로 버티려면 알아야 할 생존 팁

Hatchet이 2년 동안 프로덕션 Postgres를 굴리며 겪은 문제를 스키마, 쿼리, 마이그레이션, 커넥션, 쿼리 플래너, autovacuum, 파티셔닝까지 한 번에 정리한 글이다. 핵심은 “인덱스만 붙이면 끝”이 아니라, 락·통계·배치·부풀어 오른 테이블까지 운영 관점에서 같이 봐야 한다는 점이다.

backend

Julia Evans가 Django로 ‘2010년식 웹사이트’를 만들며 좋았던 것들

Julia Evans가 프론트엔드 중심의 작은 앱 대신, SQL 데이터베이스와 서버 렌더링 HTML을 쓰는 Django 방식으로 웹사이트를 만들며 느낀 장점과 불편함을 정리했다. QuerySet, 템플릿 필터, 자동 마이그레이션은 생산성을 높였고, 클래스 기반 뷰와 성능 설정은 여전히 고민거리로 남았다. 특히 템플릿 캐시 설정 하나로 처리량이 초당 2-3 요청에서 약 12 요청으로 오른 대목이 실전적이다.

backend

클릭하우스가 PgBouncer 처리량을 4배로 끌어올린 방법

ClickHouse Managed Postgres 팀은 단일 스레드인 PgBouncer를 여러 프로세스로 띄우고 `so_reuseport`와 피어링(peering)을 조합해 16 vCPU 머신을 제대로 쓰게 만들었다. 동일한 AWS 환경에서 단일 프로세스는 약 8.7만 TPS 근처에서 막혔지만, 16개 프로세스 구성은 약 33.6만 TPS까지 올라갔다.