Show HN: Go로 만든 Clojure 스타일 언어, 7ms 만에 부팅
요약
let-go는 Go 언어로 구현된 바이트코드 컴파일러와 스택 VM을 갖춘 Clojure 방언입니다. JVM 없이 단일 10.7MB 바이너리로 동작하며, 7ms라는 매우 빠른 콜드 스타트 성능과 낮은 메모리 사용량을 자랑합니다.
핵심 포인트
- Go 기반의 바이트코드 컴파일러 및 스택 VM을 사용하여 JVM 의존성 제거
- JVM 대비 약 50배, Babashka 대비 약 3배 빠른 6.7ms의 시작 시간 달성
- 독립형 바이너리 및 WASM 웹 페이지로 컴파일 가능하여 다양한 환경(CLI, 웹, Plan 9 등) 지원
- 영속적 데이터 구조, 지연 시퀀스 등 Clojure의 핵심 기능을 대부분 구현
- Go의 함수, 구조체, 채널과 양방향 상호 운용 가능
게으름뱅이 여러분, 안녕하세요! (λ-gophers 하하, 이해하셨나요?)
let-go는 Go로 작성된 바이트코드 컴파일러(bytecode compiler)와 스택 VM(stack VM)을 갖춘 Clojure 방언입니다. 단일 약 10.7MB 바이너리, 약 7ms의 콜드 스타트(cold start), JVM이 필요 없습니다. jank-lang 테스트 스위트를 통과합니다.
저는 이것을 2021년에 정교한 농담으로 시작했습니다. Go를 쓰는 척하면서 Clojure를 쓸 수 있는 핑계였죠. 그런데 유용해졌습니다. 저는 이를 CLI, 스크립트, 웹 서버에 사용하고 있으며, 그 위에 데몬이 없는(daemonless) 컨테이너 런타임을 구축했습니다. let-go 프로그램을 독립형 바이너리(standalone binaries)나 자기 완결적인 WASM 웹 페이지로 컴파일할 수 있습니다. 심지어 Plan 9과 ReMarkable 2에서도 실행됩니다.
이것은 Clojure JVM의 즉각적인 대체제(drop-in replacement)가 아닙니다. JAR를 로드하지 않으며 로드하는 것을 목표로 하지도 않습니다. 대부분의 관용적인(idiomatic) Clojure 코드는 수정 없이 실행되지만, 라이브러리 의존성이 있는 실제 프로젝트는 조정이 필요할 것입니다. 아래의 '알려진 제한 사항(Known limitations)'을 참조하세요.
- 질 높은 엔터테인먼트
- Clojure의 대부분 구현: 영속적 데이터 구조(persistent data structures), 지연 시퀀스(lazy seqs), 트랜스듀서(transducers), 프로토콜(protocols), 레코드(records), 멀티메서드(multimethods), core.async, BigInts
- 편안한 양방향 Go 상호 운용성 (functions, structs, channels)
- 바이트코드 및 독립형 바이너리로의 AOT 컴파일
- 단일
requestAnimationFrame내에서 런타임 부팅
(60fps 기준 10ms 남음) - 터미널 에뮬레이션이 포함된 자기 완결적 WASM 웹 페이지로 프로그램 컴파일
- Go 관련 본업을 하면서 Clojure를 쓰는 것을 합법적으로 만들기
- 브라우저 내 nREPL (WASM 상의 let-go VM, WebSocket을 통한 에디터)
- 확장 과제: let-go 바이트코드 → Go 번역
비목표(Non-goals): 즉각적인 JVM Clojure 대체제; 범용 Clojure를 위한 린터(linter)/포맷터(formatter).
let-go vs Babashka, Joker, go-joker, gloat, 그리고 Clojure JVM. 모든 벤치마크 파일은 수정 없이 실행되는 유효한 Clojure입니다. Apple M1 Pro 환경입니다.
| 항목 | let-go | babashka | joker | go-joker | gloat | clojure JVM |
|---|---|---|---|---|---|---|
| 바이너리 크기 (Binary size) | 10.7MB | 68MB | 26MB | 32MB | 26MB | 304MB (JDK) |
| 시작 시간 (Startup) | 6.7ms | 18ms | 12ms | 13ms | 16ms | 363ms |
| 유휴 메모리 (Idle memory) | 13.5MB | 27MB | 22MB | 23MB | 23MB | 92MB |
let-go는 작은 부분들에서 결정적인 승리를 거둡니다: 가장 작은 바이너리(binary), 가장 빠른 시작 시간 (JVM 대비 약 50배, Babashka 대비 약 3배), 그리고 가장 낮은 메모리 사용량입니다. 또한 map/filter와 같은 수명이 짧은 데이터 작업(7.9ms vs Babashka의 21.5ms)과 영속적 맵 (persistent maps, 20.8ms vs 23.7ms)에서도 승리합니다.
더 큰 수치적 워크로드(numerical workloads)에서는 다른 구현체들이 앞서 나갑니다. go-joker의 WASM JIT는 내부 수치 루프를 컴파일하여 피보나치(fib) 수열(1.47s vs 2.08s), tak, reduce, 그리고 트랜스듀서(transducers)에서 저희를 앞섭니다. JVM은 HotSpot이 예열(warm up)되면 긴 계산 실행에서 압도적인 성능을 보입니다. 저희는 대부분의 알고리즘 벤치마크에서 Babashka와 비슷한 수준이며, 업스트림(upstream) Joker보다는 10배 이상 빠릅니다 (바이트코드 VM(bytecode VM) vs 트리 워크(tree-walk)).
벤치마크별 전체 수치 및 방법론: benchmark/results.md.
jank-lang/clojure-test-suite를 대상으로 테스트 완료:
:clj 리더(reader)를 통한 232개 파일 전체에서 5621 / 5621개의 어서션(assertions) 통과하였으며, 알려진 실패, 컴파일 스킵(compile skips), 패닉 스킵(panic skips) 또는 런타임 스킵(runtime skips)은 없습니다.
| 네임스페이스 (Namespace) | 상태 (Status) |
|---|---|
clojure.core | 매크로(macros), 구조 분해(destructuring), 지연 시퀀스(lazy seqs), 트랜스듀서(transducers), 프로토콜(protocols), 레코드(records), deftype, reify, 멀티메서드(multimethods), 계층 구조(hierarchies), 아톰(atoms), 정규식(regex), 메타데이터(metadata), BigInt, BigDecimal |
clojure.string | 전체 (full) |
clojure.set | 전체 (full) |
clojure.walk | prewalk, postwalk, keywordize-keys, stringify-keys, walk |
clojure.edn | read, read-string |
clojure.pprint | pprint, cl-format |
clojure.test | deftest, is, testing, are, 픽스처(fixtures) |
clojure.core.async | 채널(channels), go / go-loop, alts!, mult / pub, pipe / merge / split (IOC가 아닌 실제 고루틴(goroutines)) |
io | 다형성 리더/라이터(polymorphic readers/writers), slurp / spit, 지연 라인 시퀀스(lazy line-seq), 인코딩(encoding), URL, with-open |
http | Ring 스타일 서버 + 클라이언트, 스트리밍 응답(streaming responses) |
json | read-json, write-json (부동 소수점 보존, 레코드 인식) |
transit | 롤링 캐시(rolling cache)를 포함한 transit+json 코덱(codec) |
os | sh, stat, ls, cwd, getenv / setenv, exit, os-name, arch, user-name, hostname, 구분자(separators) |
System |
JVM 형태: getProperty, getProperties, getenv, exit, currentTimeMillis, nanoTime. let-go.version, let-go.commit, user.home, user.dir, os.name, os.arch 등을 노출합니다. |
syscall |
직접적인 Linux 시스템 호출 (syscall) (mount, unshare, mknod, prctl, capset, seccomp, AppArmor) |
pods |
JSON / EDN / transit 기반의 Babashka pods |
let-go는 Babashka pods를 로드할 수 있어 SQLite, AWS, Docker, 파일 워칭 (file watching) 등 전체 pod 생태계를 활용할 수 있습니다.
(pods/load-pod 'org.babashka/go-sqlite3 "0.3.13")
(pod.babashka.go-sqlite3/execute! "app.db"
["create table users (id integer primary key, name text)"])
...
~/.babashka/pods/를 bb와 공유하므로, babashka로 pods를 설치하고 lg에서 바로 사용할 수 있습니다. 사용 가능한 항목은 pod 레지스트리 (pod registry)를 참조하세요.
STM 조정 (STM coordination): ref / dosync / alter / commute는 atom 기반의 호환성 별칭 (compatibility aliases)이며, 조정된 STM (coordinated STM)은 아닙니다.
비동기 에이전트 (Asynchronous agents): agent / send / send-off는 동기식 atom 기반 호환성 별칭입니다.
청크 단위 시퀀스 (Chunked sequences): lazy seqs는 unchunked 상태입니다.
사용자 정의 태그 리터럴 리더 (Custom tagged literal readers): 내장된 #uuid 및 #inst는 작동합니다. 알 수 없는 태그는 해당 페이로드 (payload)로 읽히며, *data-readers* / *default-data-reader-fn*은 구현되지 않았습니다.
Java 스타일 (Java-style): 프로토콜 (protocol) 구현은 작동하지만, JVM 호스트 메서드 (host methods)는 작동하지 않습니다. deftype / reify 메서드 본문 및 호스트 인터페이스 (host interfaces).
스펙 (Spec) (clojure.spec 없음): 정렬된 컬렉션 (sorted collections)은 작동합니다 (subseq / rsubseq, sorted-map, sorted-set, rseq). 범위 쿼리 (range queries)는 지원하지 않습니다.
concat* (quasiquote에서 내부적으로 사용됨)은 eager 방식이며, 사용자에게 노출되는 concat은 lazy 방식입니다.
<! / <!!는 동일하며, >! / >!!도 동일합니다 (Go 채널은 항상 블로킹(blocking)됩니다).
go 블록은 IOC 상태 머신이 아닌 실제 고루틴 (goroutines)입니다 (더 저렴하며, 블로킹 연산을 직접 호출할 수 있습니다).
- 숫자 계층 (Numeric tower)은 실용적입니다: JVM의 전체 원시 타입/클래스 모델(primitive/class model) 없이
int64,float64,BigInt, 유리수 (ratios),BigDecimal을 지원합니다. - 기본 정수
+/-/*/inc/dec는 오버플로 (overflow) 발생 시 예외를 던집니다.+'/-'/*'/inc'/dec'를 사용하세요.
BigInt 승격 (BigInt-promoting)을 위한 정밀 산술 (exact math)을 사용하려면 +' / -' / *' / inc' / dec'를 사용하세요. 정규 표현식 (Regex)은 Java 정규식 대신 Go 스타일의 re2를 사용합니다.
letfn은 전방 참조 (forward references)를 위해 내부적으로 아톰 (atoms)을 사용합니다.
let-go로 작성된 것들:
xsofy: 동일한 소스 코드로 브라우저와 터미널 모두에서 실행되는 로그라이크 (roguelike) 게임
lgcr: syscall 네임스페이스 (namespace)를 기반으로 구축된 데몬리스 (daemonless) 컨테이너 런타임 (runtime)
이 저장소(repo)에 포함된 내용:
let-go의 WASM 빌드를 실행하는 기본적인 브라우저 REPL.
brew tap nooga/let-go https://github.com/nooga/let-go
brew install let-go
Releases 섹션에서 Linux, macOS, Plan 9용 사전 빌드된 바이너리 (prebuilt binaries)를 제공합니다.
go install github.com/nooga/let-go@latest
lg # REPL
lg -e '(+ 1 1)' # 표현식 평가 (eval expression)
lg myfile.lg # 파일 실행
...
let-go는 프로그램을 바이트코드 (.lgb 파일)로 컴파일하고 이를 독립 실행형 실행 파일 (standalone executables)로 번들링할 수 있습니다.
lg -c app.lgb app.lg # 바이트코드로 컴파일
lg app.lgb # 바이트코드 실행
lg -b myapp app.lg # 자체 포함된 바이너리로 번들링
...
독립 실행형 바이너리는 사용자의 바이트코드가 추가된 lg의 복사본입니다. 이를 다른 머신으로 복사하면 바로 실행됩니다.
lg -w site app.lg # WASM 웹 앱으로 컴파일
open site/index.html
출력물은 자체 포함된 index.html (~6MB, WASM 인라인화, gzipped 방식)과 SharedArrayBuffer를 위해 GitHub Pages가 필요로 하는 COOP/COEP 헤더를 제공하는 서비스 워커 (service worker)로 구성됩니다. term 네임스페이스를 사용하는 프로그램은 xterm.js를 통해 ANSI 색상, 커서 위치 지정, 로우 키보드 입력 (raw keyboard input) 등 완전한 터미널 에뮬레이션 (terminal emulation)을 지원받습니다.
*compiling-aot* 변수는 -c / -b / -w 컴파일 중에는 true이며, 런타임 (runtime)에는 false입니다. 이는 컴파일 타임에 부수 효과 (side effects)가 발생하지 않도록 하는 데 유용합니다:
(defn -main []
(start-server))
(when-not *compiling-aot*
...
*in-wasm*은 WASM 빌드 내부에서 실행될 때 true가 됩니다.
let-go는 CIDER (Emacs), Calva (VS Code), Conjure (Neovim)와 호환되는 nREPL 서버를 제공합니다.
lg -n # 기본 포트 2137
lg -n -p 7888
작업 디렉토리에 .nrepl-port를 기록하여 에디터가 자동으로 이를 감지할 수 있게 합니다.
지원되는 작업 (ops): clone, close, eval, load-file, describe
completions, complete, info, lookup, ls-sessions, interrupt.
Emacs (CIDER): M-x cider-connect-clj를 실행하고, .nrepl-port 파일에서 포트(port)를 가져와 localhost에 연결합니다.
VS Code (Calva): let-go 프로젝트를 엽니다 (번들로 포함된 .vscode/settings.json이 연결 시퀀스를 등록합니다). nREPL이 이미 실행 중이라면 "Calva: Connect to a Running REPL Server"를, 그렇지 않다면 "Calva: Start a Project REPL and Connect (Jack-In)" → "let-go"를 사용하세요.
Neovim (Conjure): .nrepl-port 파일이 존재하면 자동으로 연결됩니다.
let-go는 Go 프로그램의 스크립팅 레이어(scripting layer)로 깔끔하게 임베딩(embed)됩니다. Go 값과 함수를 정의하여 VM(가상 머신)에 전달하면, 사용자가 제공한 Clojure 코드를 귀하의 데이터에 대해 실행할 수 있습니다. Go 구조체(structs)는 레코드(records)로 왕복(roundtrip)하며, Go 채널(channels)은 일급 객체인 let-go 채널이 되고, Go 함수는 let-go에서 호출할 수 있습니다.
import (
"github.com/nooga/let-go/pkg/api"
"github.com/nooga/let-go/pkg/vm"
...
등록된 구조체는 let-go 측에서 레코드가 됩니다. 변경되지 않은(unmutated) 값은 무료로 원래의 Go 타입으로 언박싱(unbox)되며, 변경된(mutated) 값은 vm.ToStruct[T]를 거칩니다.
type Item struct{ Name string; Price float64; Qty int }
vm.RegisterStruct[Item]("myapp/Item")
c.Def("item", Item{Name: "Widget", Price: 9.99, Qty: 5})
...
Go 채널과 vm.Chan은 go / <! / >!에 직접 연결됩니다:
inch := make(chan int)
outch := make(vm.Chan)
c.Def("in", inch)
...
pkg/api/interop_test.go에는 임베딩 예제 세트(defs, structs, channels, function calls)가 모두 포함되어 있습니다.
go test ./... -count=1 -timeout 30s
타입 체크를 수행하고 TypeScript를 실행하는 20MB 크기의 순수 Go(pure-Go) JS 런타임을 원하신 적이 있나요? 저의 다른 프로젝트를 확인해 보세요: https://github.com/nooga/paserati
AI 자동 생성 콘텐츠
본 콘텐츠는 HN OpenAI Codex의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기