며칠 전 GeekNews 피드를 넘기다가 "TypeScript를 JavaScript 엔진 없이 네이티브 바이너리로 컴파일한다"는 제목을 보고 잠깐 멈췄다. 인프라 쪽에서 일하다 보면 Node 컨테이너 이미지가 수백 MB로 부풀고, Lambda Cold Start가 몇백 ms씩 튀는 걸 매번 마주치는데, "엔진을 아예 빼버린다"는 문장은 그 지점을 정확히 건드린다.
Vercel Labs에서 공개한 scriptc 이야기다. 아직 공개된 지 며칠 안 된 실험 프로젝트라 프로덕션에 바로 넣을 물건은 아니지만, 아키텍처를 뜯어보면 우리가 매일 씨름하는 문제들과 어떻게 연결되는지 감이 온다. 실무자 관점에서 하나씩 정리해봤다.
1. 왜 지금 화제인가 — 어떤 문제를 푸는가
우리가 지금 TypeScript 코드를 서버에서 돌리는 방식을 생각해보자. 대략 이렇다.
tsc또는 esbuild/swc로 TS → JS 트랜스파일- Node(내부적으로 V8) 위에서 JS 실행
- 컨테이너에는 Node 런타임 + node_modules가 통째로 들어감
이 구조의 근본적인 비용이 두 가지다. 첫째, 런타임이 무겁다. V8은 JIT 컴파일러와 GC를 포함한 거대한 엔진이고, 이걸 부팅하는 데 시간이 든다. 둘째, 배포 산출물이 크다. Node 베이스 이미지에 의존성까지 얹으면 이미지가 쉽게 수백 MB로 간다.
scriptc의 발상은 단순하다. 정적으로 분석 가능한 TypeScript 코드는 아예 네이티브 코드로 컴파일해버리고, JS 엔진을 바이너리에 넣지 않는다. 원문 발췌 기준 수치를 그대로 옮기면 이렇다.
- 시작 시간: 약 2.4ms (Node는 약 47ms)
- 정적 바이너리 크기: 170~200KB (Node SEA는 60~100MB)
- 일반 RSS(메모리): 1~4MB (Node는 67~116MB)
측정 환경은 Apple M 시리즈이고, Node·Go·Rust·Zig와 동일 작업·동일 출력 기준으로 비교했다고 명시돼 있다. 숫자만 보면 "이게 진짜면 CLI 도구 배포 판이 바뀌겠는데" 싶은 수준이다. 다만 이 수치들은 벤더 자체 측정이니, 실제 워크로드에서 재현되는지는 각자 검증해야 한다.
2. 핵심 동작 원리 — 세 갈래로 갈리는 컴파일
scriptc를 이해하는 핵심은 "모든 코드를 다 네이티브로 만들지는 않는다"는 점이다. 코드를 세 부류로 나눠서 처리한다.
2-1. 정적 컴파일 (기본 모드)
타입 정보로 구조를 확정할 수 있는 코드는 네이티브로 컴파일한다. 파이프라인은 이렇게 흐른다.
TypeScript
→ tsc 파싱 · 타입 검사
→ lowering
→ typed IR
→ C
→ clang
→ 네이티브 실행 파일
여기서 중요한 건 프런트엔드가 실제 TypeScript 컴파일러(tsc) API를 쓴다는 점이다. 별도 방언이나 어노테이션이 아니라, 우리가 쓰던 그 tsconfig.json의 strict 설정과 es2025 라이브러리를 그대로 적용한다. @types/node가 프로젝트에 있으면 그것도 같이 타입 검사한다. 즉 "타입 검사는 진짜 tsc, 코드 생성은 네이티브"라는 조합이다.
비유하자면 판별 유니온(discriminated union) 같은 걸 생각하면 된다. TypeScript에서 태그 필드로 narrowing 하던 패턴을, scriptc는 그 태그 값을 그대로 활용해 네이티브 분기로 바꾼다. 동적 디스패치도 안전성이 증명되면 비가상화(devirtualization)한다. 백엔드는 LLVM이 기본이고, C 백엔드도 참조 구현으로 유지돼서 --backend c로 사람이 읽을 수 있는 C 코드를 뽑아볼 수 있다.
2-2. 동적 실행 (--dynamic)
npm 패키지의 JavaScript나 any 타입처럼 정적으로 못 잡는 코드는 어떻게 하나. 여기서 --dynamic 플래그를 주면 약 620KB짜리 quickjs-ng 엔진을 번들에 넣어서 그 부분만 인터프리터로 돌린다.
포인트는 "정적 코드로 값이 넘어올 때 런타임 검증을 한다"는 것이다. 선언된 타입과 실제 값이 다르면 메모리를 깨뜨리지 않고 TypeError를 던진다. 이 경계 설계가 없으면 동적 영역에서 넘어온 이상한 값이 네이티브 메모리를 손상시킬 수 있으니, 안전장치로서 필수다.
2-3. 거부
세 번째는 그냥 거부다. 처리 못 하는 구문은 조용히 잘못 컴파일하지 않고, 오류 코드·코드 프레임·수정 힌트를 주며 컴파일을 중단한다. 개인적으로 이 설계 방향은 마음에 든다. "어중간하게 돌아가는 것"보다 "명확하게 안 되는 것"이 인프라 입장에서는 훨씬 다루기 쉽다.
내 코드가 얼마나 정적 컴파일되는지 궁금하면 coverage 명령으로 확인할 수 있다.
$ scriptc coverage src/index.ts
# 예상 출력 형태 (원문 예시 기준)
Analyzed 4481 statements
Statically compiled: 4451 (99%)
Blocked: 30
- dynamic npm import (E....)
- any-typed value flow (E....)
원문 예시에서는 4,481개 문장 중 4,451개(99%)가 정적으로 컴파일됐다고 나온다. 다만 이건 "잘 맞는 코드베이스" 사례이고, 뒤에서 얘기할 함정을 보면 알겠지만 남의 라이브러리를 많이 끌어다 쓰면 이 숫자가 확 떨어진다.
3. 실무 관점 — 이점, 트레이드오프, 흔한 함정
3-1. 인프라 입장에서 매력적인 지점
이건 명확하다. 세 가지가 바로 와닿는다.
- 컨테이너 이미지 경량화: 런타임 의존성 없는 단일 바이너리 170~200KB면,
scratch나distroless베이스에 바이너리 하나만 얹으면 끝이다. Node 이미지 수백 MB와 비교하면 레지스트리 저장 비용, 이미지 pull 시간, 배포 파이프라인 속도가 전부 달라진다. - Cold Start: Lambda나 Cloud Run에서 매번 걸리는 그 문제. 시작 2.4ms 수치가 실제로 재현된다면 Cold Start 논의 자체가 거의 사라진다.
- 배포 단순화: node_modules, npm ci, lockfile 검증 같은 배포 단계 상당 부분이 빌드 시점으로 흡수된다. 실행 시점에는
node_modules를 읽지 않는다(동적 모드에서도 JS가 빌드 시 바이너리에 포함됨).
distroless 예시로 감을 잡아보면 이런 그림이다.
# 빌드 스테이지에서 scriptc로 네이티브 바이너리 생성
FROM node:22 AS build
RUN npm install -g scriptc
# clang 필요 (scriptc의 백엔드가 clang을 씀)
RUN apt-get update && apt-get install -y clang
WORKDIR /app
COPY . .
RUN scriptc build src/index.ts -o /app/server
# 런타임 스테이지 — 바이너리 하나만
FROM gcr.io/distroless/static
COPY --from=build /app/server /server
ENTRYPOINT ["/server"]
주의: 위 Dockerfile은 개념 예시다. Linux 바이너리는 교차 컴파일로 지원한다고 돼 있고, 주요 개발 플랫폼은 macOS arm64다. 리눅스 컨테이너 빌드에서 실제로 어떤 절차가 필요한지는 공식 문서 확인이 필요하다.
3-2. 트레이드오프 — 공짜가 아니다
먼저 속도. 시작은 빠르지만 처리 속도가 항상 빠른 건 아니다. HN 댓글에 나온 실측 사례를 그대로 옮기면, 바이트 배열 벤치마크에서 scriptc는 전용 최적화 후에도 Node 24보다 약 7.5배 느렸다고 한다. 대신 시작 12배 빠름(1.5ms vs 18.6ms), 메모리 72배 적게 씀(2.5MiB vs 181MiB)이었다. 즉 성격이 명확하다. 짧게 뜨고 죽는 워크로드(CLI, 함수형 서버리스)에 유리하고, 장시간 돌며 CPU를 갈아넣는 워크로드에는 불리하다. 이건 AOT 컴파일 언어 대비 JIT의 오래된 트레이드오프와 비슷하다.
다음으로 생태계. TypeScript의 진짜 힘은 언어 표현력이 아니라 npm 생태계 호환성이다. 그런데 대부분의 npm 패키지는 타입 선언(.d.ts)으로 인터페이스만 주고 실제 구현은 JS로 배포한다. 이걸 쓰려면 결국 --dynamic으로 quickjs를 끌어와야 하고, 그 순간 "네이티브의 이점"이 부분적으로 희석된다. 의존성이 많을수록 정적 비율이 떨어지고 바이너리가 커진다(동적 모드 + 내장 의존성 포함 시 약 3MB).
3-3. 흔한 함정 — 실제로 마주칠 에러
HN에서 여러 명이 자기 프로젝트에 돌려봤는데 "coverage 분석에서 수백 개 오류가 나서 사실상 못 썼다"는 후기가 있었다. 남의 라이브러리를 많이 쓰는 일반적인 앱을 그냥 넣으면 이 벽에 부딪힐 가능성이 높다.
특히 조심할 건 런타임 타입 단언이다. scriptc는 JSON.parse(...) as Config 같은 검사된 타입 단언에 런타임 검증 코드를 삽입한다. TypeScript에서 as는 컴파일 타임에 사라지는 캐스팅이라 우리가 "그냥 통과하겠지" 하고 넘기던 건데, scriptc에서는 실제 값이 선언과 다르면 이런 예외가 튀어나온다.
// config.ts
const config = JSON.parse(raw) as { port: number };
// 실행 시 raw가 { "port": "3000" } 이면:
Uncaught TypeError: expected number at $.port, got string
이건 사실 버그를 잡아주는 좋은 동작인데, 기존 Node에서는 조용히 통과하던 코드가 여기서는 죽는다는 걸 모르면 "왜 Node에선 되는데 여기선 터지지?" 하고 헤매게 된다. 원문에도 "Node와 의도적으로 다른 동작이 수십 건 있고 각각 번호가 부여돼 문서화된다"고 나온다. 마이그레이션할 때 이 차이 목록을 먼저 확인하는 게 순서다.
정적으로 지원 안 되는 구문을 만나면 컴파일 자체가 코드 프레임과 함께 거부된다(구체적 에러 코드 포맷은 문서 확인 필요). 요지는 런타임에 조용히 잘못되는 게 아니라 빌드에서 막힌다는 것이다.
3-4. 대안
목적별로 대안이 갈린다.
- npm 생태계를 최대한 유지하며 빠른 런타임이 필요하다 → Bun, Deno가 현실적. 여전히 JS 엔진 기반이지만 성숙도와 호환성이 다르다.
- 애초에 네이티브 컴파일이 목표이고 npm 의존이 거의 없다 → Go, Rust, Zig 같은 제대로 설계된 컴파일 언어를 쓰는 게 낫다는 지적이 HN에서 강하게 나왔다. TypeScript를 굳이 네이티브로 짜낼 이유가 약해진다.
- 기존 큰 TypeScript 코드베이스와 코드를 공유하는 CLI 도구를 만든다 → 이 지점이 scriptc가 가장 설득력 있는 유스케이스다. 팀이 이미 TS로 통일돼 있고, 작고 빠르게 뜨는 도구가 필요할 때.
4. 정리 — 한 줄 요약과 사용 판단
한 줄 요약: scriptc는 "정적 분석 가능한 TypeScript를 JS 엔진 없이 네이티브 바이너리로 뽑고, 안 되는 부분만 quickjs로 폴백하는" 실험적 AOT 컴파일러다.
지금 시점(공개 며칠)에서 내 판단은 이렇다.
- 당장 프로덕션 서버에 넣기: 아직 이르다. 생태계 호환성, 실사용 검증, 장기 유지보수 여부가 불확실하다. HN에서도 "5일 만에 전부 바이브 코딩된 프로젝트, 몇 달 뒤 유지보수 중단될 수도"라는 회의론이 상당하다.
- 지금 시험해볼 만한 곳: 의존성 적은 CLI 도구, 사이드 프로젝트. 특히 이미 TypeScript로 굴러가는 팀에서 작고 빠른 내부 도구를 만들 때
scriptc coverage로 정적 비율을 재보는 것부터 시작하면 된다. - 주목할 이유: 방향성. GraalVM Native나 .NET AOT가 걸어온 길처럼, 이게 계속 발전하면 서버리스/컨테이너 배포 판을 흔들 잠재력은 있다. 다만 그 길이 길고 험하다는 것도 선례들이 보여준다.
결론적으로 나는 "재밌는 실험이고 방향은 맞지만, 지금은 벤치마크와 coverage로 감만 잡아두는 단계"로 본다. 각자 코드베이스에 coverage 한 번 돌려보고 정적 비율이 몇 %인지 보는 것만으로도, 우리 코드가 얼마나 "정적으로 설명 가능한가"를 되돌아보는 재미는 확실히 있다.
참고 자료
※ 본문의 수치(시작 2.4ms, 바이너리 170~200KB 등)는 Vercel 자체 측정 및 HN 댓글 실측 사례를 인용한 것으로, 실제 환경에서의 재현은 각자 검증이 필요하다. Dockerfile·명령어 예시는 개념 설명용이며 정확한 CLI 옵션과 플랫폼별 빌드 절차는 공식 문서 확인을 권한다.
