scriptc build fib.ts 한 줄이면 178KB짜리, 시작시간 약 2ms인 실행파일이 나온다.
--dynamic으로 내장 JS 엔진(quickjs-ng, 약 620KB)에 넘길 수도 있고, 이때 타입 경계를 넘는 모든 값은 런타임에 검증된다 — 거짓말하는 타입은 메모리를 오염시키는 대신 잡을 수 있는 TypeError를 던진다.
vercel-labs/scriptc · 제작 Vercel Labs · 라이선스 Apache-2.0 · 버전 0.0.22(실험적) · 홈페이지 scriptc.dev · 컴파일러=TypeScript / 런타임=C / 백엔드=LLVM+clang · macOS arm64 우선, Linux·Windows 크로스컴파일 · TrendShift 위클리 4위, 이번 주 +1.9k stars · 최신 커밋 2026-08-05)
scriptc는 터미널에서 쓰는 컴파일러다. npm install -g scriptc로 설치하고, scriptc run app.ts로 바로 실행하거나 scriptc build app.ts로 실행파일을 뽑는다. 나온 바이너리 안에는 JavaScript 엔진이 아예 들어 있지 않다. 그래서 시작이 즉각적이고(약 2.4ms), 메모리도 1~4MB로 가볍다. 비교하면 같은 프로그램을 Node로 돌리면 시작에 약 47ms, 메모리 67~116MB가 든다.
핵심 약속은 "Node에서 돌던 코드와 바이트 단위로 똑같이 동작한다"는 것이다. 방언도, 애노테이션도 없다. 타입 검사는 진짜 TypeScript 컴파일러가 그대로 하고, 컴파일된 결과는 Node 실행 결과와 표준출력·표준에러·종료코드가 한 글자도 다르지 않아야 한다(이걸 매 커밋마다 차등 테스트로 강제한다).
보통 TypeScript/JavaScript는 어디서든 V8 같은 통역사(엔진)를 함께 데려가야 말이 통한다. 그래서 짐(용량)이 무겁고, 매번 통역사를 깨우는 데(시작시간) 시간이 걸린다.
scriptc는 프로그램을 현지어(네이티브 기계어)로 미리 번역해 통역사 없이 혼자 걸어 다니게 만든다. 대신 "현지어로 못 옮기는 표현"이 나오면 억지로 얼버무리지 않고 "이건 통역 필요"라고 솔직히 표시하거나(--dynamic), 아예 번역을 거부한다.
2026년 7월, Vercel Labs가 이 저장소를 공개하자 프론트엔드·백엔드 개발자 모두가 반응했다. Bun·Deno가 이미 "빠른 JS 런타임" 경쟁을 벌이는 와중에, scriptc는 "런타임을 아예 없앤다"는 더 급진적인 목표를 들고나왔기 때문이다. 이유를 셋으로 정리하면:
마이크로소프트가 TypeScript 컴파일러를 Go로 다시 쓴 TS7(typescript@7)이 등장하면서 "TS를 진짜 빠르게 다루는 도구"가 화두가 됐다. scriptc는 이 typescript@7.0.2 네이티브 컴파일러를 프론트엔드로 끌어와 타입 검사에 쓴다. "타입 정보를 컴파일의 재료로 쓴다"는 발상 — 즉 TypeScript의 타입이 단순 주석이 아니라 실제 기계어 생성을 지휘하는 것 — 이 지금 가장 뜨거운 실험이다.
Next.js를 만든 Vercel의 실험 조직(Labs)이 내놓았다는 점에서 "장난감이 아니다"라는 신호를 준다. 게다가 SDK·컴파일러·런타임 전부 Apache-2.0으로 공개돼, 컴파일러가 TypeScript로, 런타임이 손으로 쓴 C로 어떻게 짜였는지 뜯어볼 수 있다.
대부분의 "TS→네이티브" 시도가 부딪히는 벽은 "어디까지 되고 어디부터 안 되는지 불투명"하다는 점이다. scriptc는 이걸 정면으로 뒤집어, scriptc coverage app.ts로 "전체 구문 중 몇 %가 정적 컴파일되는지"와 "막힌 이유(에러 코드)"를 표로 보여준다. 못 하는 건 조용히 틀리게 하지 않고 명시적으로 알린다 — 이 "예측 가능성"이 개발자들의 마음을 샀다.
Bun·Deno·Node의 단일 실행파일(SEA)은 결국 엔진을 바이너리에 넣는 방식이라 용량이 수십 MB다(Node SEA는 60~100MB). scriptc는 엔진을 빼고 네이티브 코드만 남겨 170~200KB로 만든다. React Native의 Static Hermes도 비슷한 목표지만, scriptc는 "일반 tsconfig 그대로, Node API까지"를 노린다는 점이 다르다.
이 저장소는 pnpm 모노레포이며, 크게 세 덩어리로 이해하면 쉽다. ① 사용자가 만지는 CLI, ② 실제 번역을 하는 컴파일러(TypeScript로 작성), ③ 완성된 바이너리에 링크되는 런타임(C로 작성).
| 요소 | 무엇 / 왜 |
|---|---|
패키지 scriptc | npm에 배포되는 CLI. bin: scriptc. 명령은 build / run / coverage 3종 |
| 주요 플래그 | --backend <llvm|c> · --dynamic(엔진 내장, +620KB) · --emit-ir(IR을 JSON으로) · --sanitize(메모리 검사) · --ffi(C 함수 바인딩) · --keep-c |
| Node 요구 | CLI는 Node 20+, 모노레포 개발은 Node 24+ (.node-version 24.15.0) |
@scriptc/compiler (TypeScript)놀랍게도 컴파일러 자체가 TypeScript로 짜여 있다(1,664개 .ts). 프론트엔드가 진짜 TS 컴파일러로 타입을 읽고, 그 답을 근거로 IR을 만든 뒤, 두 종류의 백엔드가 그 IR을 기계어로 옮긴다.
| 구성물 | 역할 |
|---|---|
| frontend/ | ts7/(TypeScript 7 네이티브 컴파일러 연동) · lowering/(lower-classes·lower-async·lower-generators 등 ~30개 낮추기 패스) · npm-static.ts |
| ir/ | 타입 IR 정의(nodes.ts) · 직렬화(serialize.ts) · 검증(validate.ts). 프론트/백엔드 사이 유일한 인터페이스 |
| backend/llvm/ | 기본 백엔드. 텍스트 LLVM IR을 뿜는다(emitter.ts 559KB). LLVM 티어 밖이면 C 백엔드로 자동 폴백 |
| backend/emission/ | C 백엔드(영원한 참조 구현). 사람이 읽는, 소스 줄 주석이 달린 C 출력(emit-exprs.ts 352KB) |
| 의존성 | 런타임에 typescript@7.0.2(TS7 포트) 사용 + 자기 자신은 typescript5(5.9.3)로 빌드 |
@scriptc/runtime (C)완성된 바이너리에 붙는 런타임은 손으로 쓴 C(222개 .c, 210개 .h)다. 핵심은 "쓰는 기능만큼만 링크"된다는 것 — hello-world는 libSystem만, 정규식 프로그램은 정규식 엔진만, http 서버는 네트워크 스택만 붙는다.
| 구성물 | 역할 |
|---|---|
| scr_async.c | 스택풀 파이버(fiber)로 async/await 구현 — 콜백 지옥 없이 진짜 스택을 저장 |
| scr_cycle.c | 참조 카운팅 + 순환 참조 수집기 — GC 멈춤(pause) 없는 메모리 관리 |
| 이벤트 루프 | scr_loop_kqueue.c(macOS)·epoll(Linux)·wsapoll(Windows) — OS별 비동기 I/O |
| 네트워크/서버 | scr_net.c·scr_http.c·scr_http2.c·scr_tls.c(mbedTLS 벤더링)·scr_fetch.c |
| 동적 섬(island) | scr_island.c + quickjs-ng — --dynamic일 때 정적화 못 한 코드를 실행하는 격리 구역 |
| 벤더 C 라이브러리 | quickjs-ng(JS 엔진)·mbedTLS(TLS)·curl·zlib·ryu(숫자 포매팅) |
scriptc의 심장은 "타입 검사 → 낮추기 → 타입 IR → 백엔드 → clang"으로 이어지는 파이프라인이다. 여기서 가장 독특한 건, 각 구문을 정적(static)·동적(dynamic)·거부(rejected) 세 등급으로 항상 명시적으로 나눈다는 점이다.
scriptc의 정체성은 "절대 조용히 잘못 컴파일하지 않는다"이다. 모든 구문은 셋 중 하나로 분류된다.
--dynamic): 내장 quickjs-ng(약 620KB)가 정적화 못 한 부분(npm 의존성의 JS, any 타입 코드)을 실행. 정적 코드로 되돌아오는 모든 값은 런타임 검증된다.# scriptc coverage app.ts — "몇 %가 정적으로 되는가"를 눈으로 확인 statements analyzed 4481 compile statically 4451 (99%) blockers: ×2 functions with optional parameters as values SC1090 ×1 Promise.reject SC2020
공항 보안검색대의 3개 게이트를 떠올리면 쉽다. 대부분 승객(코드)은 그냥 통과(정적). 뭔가 걸리는 승객은 정밀 검사 라인(동적 실행)으로 보내되 소지품을 하나하나 확인(런타임 검증)한다. 위험물은 아예 탑승 거부(거부). 중요한 건 "애매하다고 그냥 통과시키는 법이 없다"는 점이다.
scriptc의 간판 기능이다. 예를 들어 JSON.parse(s) as Config로 외부 데이터를 특정 타입이라 단언(cast)했는데 실제 데이터가 다르면? 보통 네이티브 언어라면 메모리가 오염되어 정의되지 않은 동작을 하지만, scriptc는 경로까지 짚어주는 잡을 수 있는 예외를 던진다(expected number at $.port, got string).
· 배열은 조밀(dense) — 잘못된 인덱스 접근·빈 배열 pop()은 undefined가 아니라 RangeError + 중단.
· 런타임 하드 트랩은 잡을 수 없다(abort). 단 사용자 throw·JSON·캐스트·fs·정규식 에러는 잡을 수 있다.
· 문자열은 UTF-8 저장(비교는 코드포인트 순), Object.keys/JSON.stringify는 선언 순서, 정렬은 안정 삽입정렬(V8은 TimSort).
LLVM 백엔드(빠름)와 C 백엔드(읽기 쉬움, 영원한 참조 구현)가 겹치는 영역에서는 반드시 바이트 단위로 동일한 출력을 내야 한다. 그리고 매 변경마다 두 검증 레인이 돈다: ① 차등 테스트(모든 코퍼스 프로그램을 Node와 네이티브로 각각 돌려 stdout/stderr/종료코드 바이트 비교, 코퍼스 1,049개), ② 메모리 안전 레인(AddressSanitizer + 참조 카운트 감사, 누수·use-after-free는 빌드 실패).
구조가 주는 교훈: "프론트엔드/IR/백엔드/런타임"이라는 컴파일러 교과서의 4계층이 폴더로 정확히 대응한다. ir/이 유일한 접점이라, LLVM 백엔드와 C 백엔드를 서로 독립적으로 발전시킬 수 있다. 이런 경계 설계는 실제 프로덕션 컴파일러가 어떻게 모듈화되는지 보여주는 좋은 예다.
parse → typecheck → lowering → IR → codegen이라는 흐름이 폴더와 파일로 그대로 드러난다. 특히 "lowering(낮추기)" — 클래스·제네릭·async 같은 고수준 문법을 단순한 형태로 단계적으로 풀어내는 과정을 lower-*.ts 30여 개로 나눈 방식이 학습에 좋다.
제네릭을 구체 타입별로 복제하는 monomorphization, 유니온 타입을 태그값(tagged value)으로 표현하기, 타입 좁히기(narrowing) 결과로 최적화를 결정하기 — Rust·C++ 템플릿이 하는 일을 TypeScript 세계에서 어떻게 구현하는지 볼 수 있다.
런타임 C 코드에서 참조 카운팅 + 순환 수집기(scr_cycle.c), 스택풀 파이버로 async 구현(scr_async.c), OS별 이벤트 루프(kqueue/epoll/wsapoll)를 직접 읽을 수 있다. "Node의 libuv가 하는 일"을 밑바닥부터 이해하는 교재다.
차등 테스트(레퍼런스=Node와 바이트 비교), 메모리 안전 레인(ASan), 두 백엔드 동일성 강제 — "빠른 것보다 틀리지 않는 것"을 CI로 보장하는 방법론이 담겨 있다. 백만 개 난수 double의 숫자 포매팅을 Node와 비교하는 퍼즈 테스트가 인상적이다.
| 항목 | 요구사항 |
|---|---|
| 필수 도구 | clang (macOS는 Xcode Command Line Tools에 포함). scriptc가 clang을 호출해 최종 링크 |
| 주 플랫폼 | macOS arm64(1급 지원). Linux·Windows는 크로스컴파일로 생성, 각각 차등 테스트 레인으로 검증 |
| 설치 | npm install -g scriptc (CLI는 Node 20+) |
| 산출물 크기 | 정적 170~200KB / --dynamic+의존성 시 약 3MB |
| 실행 자원 | 시작 약 2.4ms, RSS 1~4MB (Node 대비 시작 20배·메모리 20~100배 절감) |
| 빌드(개발) | Dockerfile.sandbox = ubuntu:24.04 + clang cmake llvm ccache zlib1g-dev, Node 24.15 · pnpm 11 |
버전이 0.0.22이고 CLI가 스스로 "(experimental)"이라 밝힌다. (0.0.18에서 최상위 await가 ESM 그래프 전체에서 컴파일되도록 추가되는 등 패치 릴리스가 빠르게 이어지는 중.) scriptc run은 CLI 인자 전달 미지원(빌드 후 직접 실행), FFI는 콜백/가변인자/구조체값전달 미지원, Windows 서버·child_process 미구현, 크로스컴파일 --dynamic 바이너리 미지원. 정수 추론·소유권 분석은 로드맵.
fib.ts를 만들어 scriptc build fib.ts로 컴파일하고, ls -la fib로 크기를, time ./fib로 시작시간을 측정하라. 같은 코드를 node fib.ts로 돌린 것과 용량·시작시간·메모리(RSS)를 표로 비교해보라.
내가 쓰던 작은 TS 스크립트에 scriptc coverage app.ts를 돌려, 몇 %가 정적 컴파일되는지와 막힌 SC 에러 코드를 확인하라. 걸린 구문을 문서(limitations)에서 찾아 "왜 안 되는지"를 정리하고, 가능하면 정적화되도록 리팩터링해보라.
--backend c --keep-c로 생성된 C 코드와 --emit-ir로 뽑은 타입 IR(JSON)을 나란히 열어, "내 TS 한 줄이 IR과 C에서 각각 어떻게 표현되는지"를 추적하라. 특히 유니온 타입이 태그값으로 바뀌는 지점을 찾아보라.
JSON.parse(입력) as {port:number} 형태로, 실제로는 문자열이 든 데이터를 캐스트해보라. Node에서는 조용히 통과하지만 scriptc 바이너리에서는 경로를 짚는 TypeError가 나는지 확인하고, 이 차이가 왜 "안전"인지 서술하라.
packages/runtime/src/scr_async.c(스택풀 파이버)와 scr_cycle.c(순환 수집기) 중 하나를 골라, 핵심 자료구조와 알고리즘을 200자로 요약하라. "Node의 libuv/GC와 무엇이 같고 다른지"를 한 문단으로 비교하면 심화.
| 주차 | 주제 · 목표 |
|---|---|
| 1주차 컴파일러 기초 | 파싱·AST·타입체크·lowering·IR 개념 잡기. Crafting Interpreters 앞부분 + scriptc frontend/lowering/ 파일 몇 개 실제로 읽기 |
| 2주차 코드 생성 & LLVM | LLVM IR 문법과 clang 파이프라인. "Kaleidoscope" LLVM 튜토리얼 → scriptc backend/llvm/emitter.ts 훑기. monomorphization·태그드 유니온 개념 정리 |
| 3주차 시스템 런타임 | 참조 카운팅 vs GC, 순환 참조 문제, 이벤트 루프(kqueue/epoll). scr_cycle.c·scr_loop_*.c 읽고 libuv와 비교 |
| 4주차 안전성 & 테스트 | 차등 테스트·퍼즈 테스트·AddressSanitizer. scriptc의 tests/corpus/와 CI(ci.yml)를 분석해 "정확성을 자동으로 지키는 법" 체득 |
--dynamic일 때, 정적화 못 한 코드를 내장 JS 엔진(quickjs-ng)으로 돌리는 격리 구역. 이 구역을 드나드는 값은 런타임 타입 검증을 거쳐 정적 코드의 안전을 지킨다.