본문으로 건너뛰기

© 2026 Molayo

HN요약2026. 05. 20. 02:30

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-gobabashkajokergo-jokergloatclojure JVM
바이너리 크기 (Binary size)10.7MB68MB26MB32MB26MB304MB (JDK)
시작 시간 (Startup)6.7ms18ms12ms13ms16ms363ms
유휴 메모리 (Idle memory)13.5MB27MB22MB23MB23MB92MB

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.walkprewalk, postwalk, keywordize-keys, stringify-keys, walk
clojure.ednread, read-string
clojure.pprintpprint, cl-format
clojure.testdeftest, 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
httpRing 스타일 서버 + 클라이언트, 스트리밍 응답(streaming responses)
jsonread-json, write-json (부동 소수점 보존, 레코드 인식)
transit롤링 캐시(rolling cache)를 포함한 transit+json 코덱(codec)
ossh, 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.Chango / <! / >!에 직접 연결됩니다:

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가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0