Skip to content
Published on

작은 언어의 매력 — Janet, Lua와 임베더블 스크립팅의 세계

Authors

들어가며 — 왜 지금 다시 작은 언어인가

2026년 6월, Ian Henry의 에세이 "Why Janet"이 다시 Hacker News 상위에 올랐습니다. 처음 공개된 지 몇 년이 지난 글이 재소환된 배경에는 분명한 시대적 맥락이 있습니다. 거대 언어 생태계에 대한 피로감입니다.

요즘 개발자의 일상을 떠올려 보면 이해가 됩니다. Node.js 프로젝트 하나를 시작하면 node_modules에 수백 개의 의존성이 깔리고, 2026년 6월에는 npm 공급망 공격이 Red Hat Cloud Services 내부까지 침투했다는 소식이 들려왔습니다. Python 환경은 가상환경과 패키징 도구의 미로이고, Rust는 강력하지만 컴파일 시간과 학습 곡선이 만만치 않습니다. AI 코딩 에이전트가 보일러플레이트를 대신 써주는 시대가 되자, 역설적으로 "내가 전부 이해할 수 있는 작은 도구"에 대한 갈증이 커졌습니다.

작은 언어는 그 갈증에 대한 하나의 답입니다. 언어 명세 전체를 주말에 읽을 수 있고, 런타임이 단일 바이너리 하나이고, 의존성 그래프가 한눈에 들어오는 세계. 이 글에서는 임베더블 스크립팅 언어의 대표 주자인 Lua와 신흥 강자 Janet을 중심으로, 작은 언어가 무엇을 줄 수 있는지 살펴봅니다.

임베더블 언어란 무엇인가

임베더블(embeddable) 언어는 독립 실행보다 호스트 애플리케이션 안에 내장되어 동작하는 것을 일차 목표로 설계된 언어입니다. 핵심 특징은 다음과 같습니다.

  • 런타임이 C 라이브러리 형태로 제공되어 호스트 프로그램에 링크할 수 있음
  • 호스트와 스크립트 사이에 값을 주고받는 명확한 API(바인딩 인터페이스)가 있음
  • 런타임 크기가 작고(수백 KB 수준), 시작 시간이 짧음
  • 메모리 사용량과 실행을 호스트가 통제할 수 있음(샌드박싱, 리소스 제한)

전형적인 구조를 그림으로 보면 이렇습니다.

+--------------------------------------------------+
|              호스트 애플리케이션 (C/C++/Rust)        |
|                                                  |
|  +--------------------+   +-------------------+  |
|  |   코어 엔진          |   |  스크립트 VM        |  |
|  |  (성능 크리티컬)      |<->|  (Lua / Janet)    |  |
|  |  렌더링, IO, 물리     |   |  게임 로직, 설정,    |  |
|  +--------------------+   |  플러그인, 확장      |  |
|                           +-------------------+  |
|        C API 경계: 스택/레지스트리 기반 값 교환        |
+--------------------------------------------------+

이 구조의 장점은 명확합니다. 성능이 중요한 부분은 호스트 언어로, 자주 바뀌고 유연해야 하는 부분은 스크립트로 작성합니다. 게임 업계가 수십 년 동안 이 패턴으로 개발 속도와 실행 성능을 동시에 잡아 왔습니다.

Lua — 임베더블 언어의 교과서

Lua는 1993년 브라질 PUC-Rio 대학에서 태어나 30년 넘게 임베더블 언어의 표준으로 군림해 왔습니다. 성공 사례 목록이 곧 소프트웨어 산업의 단면도입니다.

  • 게임: World of Warcraft 애드온, Roblox(파생 언어 Luau), Garry's Mod, Defold 등 수많은 게임 엔진의 스크립팅 레이어
  • 웹 인프라: nginx 기반의 OpenResty가 Lua로 요청 처리 로직을 작성하게 해주며, Cloudflare가 초기 엣지 로직을 이 스택으로 운영했던 것으로 유명합니다
  • 데이터베이스: Redis의 EVAL 명령은 Lua 스크립트로 원자적 연산을 수행합니다
  • 에디터: Neovim은 설정과 플러그인 언어로 Lua를 채택해 VimScript를 사실상 대체했습니다

Lua가 이렇게 널리 쓰이는 이유는 설계 철학에 있습니다. 인터프리터 전체가 약 3만 줄의 ANSI C로 작성되어 있고, 외부 의존성이 없으며, 컴파일하면 수백 KB짜리 라이브러리가 나옵니다. 언어 자체도 작습니다. 자료구조는 사실상 테이블 하나뿐이고, 이 테이블로 배열, 해시맵, 객체, 모듈을 모두 표현합니다.

Lua 기본 문법 맛보기

-- 변수와 테이블: Lua의 유일한 복합 자료구조
local config = {
  name = "my-server",
  port = 8080,
  tags = { "web", "production" },
}

-- 함수는 일급 시민
local function greet(name)
  return "Hello, " .. name .. "!"
end

print(greet(config.name))

-- 테이블 순회
for i, tag in ipairs(config.tags) do
  print(i, tag)
end

-- 메타테이블로 객체지향 흉내내기
local Animal = {}
Animal.__index = Animal

function Animal.new(name, sound)
  local self = setmetatable({}, Animal)
  self.name = name
  self.sound = sound
  return self
end

function Animal:speak()
  print(self.name .. " says " .. self.sound)
end

local dog = Animal.new("Rex", "woof")
dog:speak()  -- Rex says woof

문법이 단순하다는 것은 배우기 쉽다는 뜻이기도 하지만, 더 중요하게는 구현이 작고 빠르다는 뜻입니다. 여기에 LuaJIT이라는 걸출한 JIT 컴파일러까지 있어서, 특정 워크로드에서는 C에 근접하는 성능을 냅니다.

C에서 Lua 임베딩하기

Lua의 C API는 스택 기반입니다. 호스트와 VM이 가상의 스택을 통해 값을 주고받습니다.

#include <lua.h>
#include <lauxlib.h>
#include <lualib.h>
#include <stdio.h>

/* C 함수를 Lua에 노출: 두 수를 더한다 */
static int l_add(lua_State *L) {
    double a = luaL_checknumber(L, 1);
    double b = luaL_checknumber(L, 2);
    lua_pushnumber(L, a + b);
    return 1;  /* 반환값 개수 */
}

int main(void) {
    lua_State *L = luaL_newstate();
    luaL_openlibs(L);

    /* C 함수 등록 */
    lua_register(L, "add", l_add);

    /* Lua 코드 실행 */
    if (luaL_dostring(L, "print('from lua:', add(3, 4))") != LUA_OK) {
        fprintf(stderr, "error: %s\n", lua_tostring(L, -1));
    }

    /* Lua 전역 변수 읽기 */
    luaL_dostring(L, "answer = 42");
    lua_getglobal(L, "answer");
    printf("answer from lua = %lld\n", (long long)lua_tointeger(L, -1));

    lua_close(L);
    return 0;
}

컴파일은 다음 한 줄이면 됩니다.

cc host.c -o host -llua -lm

50줄 미만의 코드로 호스트와 스크립트가 양방향으로 대화합니다. 이 진입 장벽의 낮음이 Lua가 30년을 살아남은 비결입니다.

Janet — 작은 Lisp의 재발견

Janet은 Calvin Rose(Fennel 언어의 원작자이기도 합니다)가 만든 Lisp 계열 언어입니다. Ian Henry의 "Why Janet"과 그의 무료 전자책 "Janet for Mortals"가 입문 경로로 자주 추천됩니다. Janet의 매력 포인트를 정리하면 다음과 같습니다.

  • 단일 바이너리: 인터프리터, 컴파일러, REPL이 1MB 안팎의 실행 파일 하나에 들어 있습니다
  • C 아말가메이션: 전체 런타임이 janet.c와 janet.h 단 두 파일로 배포되어, 프로젝트에 복사해 넣는 것만으로 임베딩이 시작됩니다
  • PEG 내장: 정규식 대신 PEG(Parsing Expression Grammar) 모듈이 표준 라이브러리에 들어 있어 파서를 선언적으로 작성할 수 있습니다
  • 이벤트 루프와 파이버: 경량 동시성이 언어 차원에서 지원됩니다
  • 네이티브 실행 파일 생성: jpm 빌드 도구로 스크립트를 정적 바이너리로 묶을 수 있습니다

Janet 기본 문법 맛보기

# Janet은 Lisp이지만 대괄호/중괄호 리터럴이 있어 읽기 편합니다
(def config
  {:name "my-server"
   :port 8080
   :tags ["web" "production"]})

(defn greet [name]
  (string "Hello, " name "!"))

(print (greet (config :name)))

# 시퀀스 처리: 함수형 스타일
(def doubled (map (fn [x] (* 2 x)) [1 2 3 4 5]))
(pp doubled)  # @[2 4 6 8 10]

# 가변/불변 자료구조가 모두 기본 제공
(def immutable-tuple [1 2 3])
(def mutable-array @[1 2 3])
(array/push mutable-array 4)

# 파이버로 제너레이터 만들기
(def counter
  (coro
    (for i 0 3
      (yield i))))
(each n counter (print n))  # 0 1 2

Janet의 비밀 병기 — PEG

정규식이 한계에 부딪히는 순간(중첩 구조, 재귀 패턴)에 PEG는 빛을 발합니다. 간단한 키=값 설정 파일 파서를 PEG로 쓰면 이렇습니다.

(def config-grammar
  ~{:ws (any (set " \t"))
    :key (capture (some (range "az" "AZ" "09" "__")))
    :value (capture (some (if-not "\n" 1)))
    :line (* :ws :key :ws "=" :ws :value)
    :main (some (* (+ :line :ws) (+ "\n" -1)))})

(def result (peg/match config-grammar "host = localhost\nport = 8080\n"))
(pp result)
# => @["host" "localhost" "port" "8080"]

문법 자체가 데이터 구조(테이블)이기 때문에 합성과 재사용이 쉽습니다. 정규식 문자열을 이어 붙이며 고통받던 경험이 있다면 이 선언적 스타일의 가치를 바로 느낄 수 있습니다.

C에서 Janet 임베딩하기

Janet 임베딩은 Lua보다도 단순하다는 평을 듣습니다. 아말가메이션 파일을 받아서 함께 컴파일하면 끝입니다.

#include "janet.h"
#include <stdio.h>

/* C 함수를 Janet에 노출 */
static Janet cfun_add(int32_t argc, Janet *argv) {
    janet_fixarity(argc, 2);
    double a = janet_getnumber(argv, 0);
    double b = janet_getnumber(argv, 1);
    return janet_wrap_number(a + b);
}

static const JanetReg cfuns[] = {
    {"native-add", cfun_add, "(native-add a b)\n\n두 수를 더한다."},
    {NULL, NULL, NULL}
};

int main(void) {
    janet_init();
    JanetTable *env = janet_core_env(NULL);
    janet_cfuns(env, "host", cfuns);

    Janet out;
    janet_dostring(env,
        "(print \"from janet: \" (native-add 3 4))",
        "main", &out);

    janet_deinit();
    return 0;
}
cc host.c janet.c -o host -lm -lpthread

파일 두 개를 복사하고 컴파일러 한 번 호출로 임베딩이 끝납니다. 빌드 시스템과의 싸움이 없다는 것, 이것이 작은 언어의 실용적 가치입니다.

작은 언어가 주는 것

작은 언어를 옹호하는 논거는 감성이 아니라 공학적 실리입니다.

전체 이해 가능성

Lua 레퍼런스 매뉴얼은 한나절이면 정독할 수 있습니다. Janet 문서도 마찬가지입니다. 언어의 모든 동작을 머릿속에 넣을 수 있다는 것은, 디버깅할 때 "언어가 이상한 짓을 했을 가능성"을 배제하고 내 코드에 집중할 수 있다는 뜻입니다. 거대 언어에서는 컴파일러 특수 케이스, 표준 라이브러리의 미묘한 동작, 빌드 도구의 마법까지 의심 대상이 됩니다.

빠른 시작과 짧은 피드백 루프

REPL을 띄우는 데 수십 밀리초, 스크립트 실행도 즉각적입니다. 거대 프레임워크의 콜드 스타트를 기다리며 집중력이 끊기는 일이 없습니다.

적은 의존성, 작은 공격 표면

2026년의 npm 공급망 공격 사태가 보여주듯, 의존성 하나하나가 공격 표면입니다. 표준 라이브러리만으로 대부분을 해결하는 작은 언어는 공급망 위험 자체가 구조적으로 작습니다.

설정 언어로서의 활용 — YAML 지옥 탈출

작은 언어의 또 다른 활용처는 설정입니다. YAML은 들여쓰기 함정, 암묵적 타입 변환(노르웨이 문제로 알려진 no 키워드의 불리언 해석 등), 반복 표현의 부재로 악명이 높습니다. 수백 줄의 Helm values 파일이나 CI 설정을 복사-붙여넣기로 관리해 본 사람이라면 공감할 것입니다.

스크립트 언어를 설정에 쓰면 변수, 함수, 조건문을 그대로 활용할 수 있습니다.

-- config.lua: 프로그래머블 설정
local base = {
  image = "myapp",
  replicas = 2,
}

local envs = {}
for _, name in ipairs({ "dev", "staging", "prod" }) do
  local cfg = {}
  for k, v in pairs(base) do cfg[k] = v end
  cfg.namespace = "myapp-" .. name
  if name == "prod" then cfg.replicas = 6 end
  envs[name] = cfg
end

return envs

반복과 분기가 언어 기능이므로 앵커/머지 키 같은 YAML 곡예가 필요 없습니다. 실제로 Neovim 커뮤니티가 init.vim에서 init.lua로 대이동한 것도 같은 동기였습니다. 설정이 곧 코드가 되면 검증, 모듈화, 재사용이 자연스러워집니다.

DSL 설계 입문 — 작은 언어 위에 더 작은 언어

임베더블 언어는 DSL(도메인 특화 언어)의 토대로도 훌륭합니다. 접근법은 크게 두 가지입니다.

  1. 내부 DSL: 호스트 스크립트 언어의 문법 안에서 도메인 어휘를 설계합니다. Lisp 계열인 Janet은 매크로 덕분에 이 방식이 특히 강력합니다.
  2. 외부 DSL: 독자 문법을 정의하고 파서를 작성합니다. Janet의 내장 PEG가 이 진입 장벽을 크게 낮춥니다.

Janet 매크로로 만든 미니 내부 DSL 예제입니다. HTTP 라우팅 테이블을 선언적으로 정의합니다.

(defmacro defroutes [name & routes]
  ~(def ,name
     ,(map (fn [[method path handler]]
             {:method method :path path :handler handler})
           routes)))

(defroutes app-routes
  [:get "/users" list-users]
  [:post "/users" create-user]
  [:get "/health" health-check])

# 매크로 전개 결과: 일반 데이터 구조의 배열
# 라우터 구현은 이 배열을 순회하기만 하면 됩니다

매크로가 컴파일 타임에 코드를 데이터로 변환해 주므로, 런타임 비용 없이 도메인 어휘를 얻습니다. "설정처럼 읽히지만 사실은 코드"인 인터페이스를 만드는 것이 내부 DSL의 정수입니다.

다른 후보들 — 임베더블 언어 생태계 지도

Lua와 Janet 외에도 살펴볼 만한 작은 언어들이 있습니다.

  • Wren: "Game Programming Patterns"와 "Crafting Interpreters"의 저자 Bob Nystrom이 만든 클래스 기반 스크립팅 언어. 문법이 친숙하고 파이버 기반 동시성을 지원하며, 구현 코드가 읽기 좋기로 유명합니다
  • Gravity: Marco Bambini가 Creo 개발 환경을 위해 만든 언어로, Swift와 닮은 문법이 특징입니다. C로 작성된 작은 런타임을 제공합니다
  • Fennel: 새 런타임이 아니라 Lua로 컴파일되는 Lisp입니다. Lua 생태계(LuaJIT, Neovim, 게임 엔진)를 그대로 쓰면서 Lisp 문법과 매크로를 얻습니다
  • Rhai: Rust 생태계를 위한 임베디드 스크립팅 언어. Rust 타입과의 통합이 매끄럽고, 기본값이 샌드박스 지향(연산 횟수 제한 등)이라 안전한 사용자 스크립팅에 적합합니다
  • mruby: Ruby의 경량 임베더블 구현. Ruby 문법을 선호하는 팀에 선택지가 됩니다
  • Squirrel: 게임 업계에서 오래 쓰인 언어로 Valve의 일부 타이틀에 채택된 이력이 있습니다

선택 기준 테이블

어떤 언어를 임베딩할지 고를 때 참고할 수 있는 비교표입니다.

기준LuaJanetWrenRhaiFennel
문법 계열절차형Lisp클래스 기반Rust 유사Lisp
런타임 크기매우 작음작음매우 작음작음Lua에 의존
호스트 언어CCCRustLua 런타임
JIT 선택지LuaJIT없음없음없음LuaJIT
생태계 규모매우 큼작지만 활발작음Rust 내 성장 중Lua 생태계 공유
동시성코루틴파이버, 이벤트 루프파이버호스트 위임코루틴
파싱 도구외부 라이브러리PEG 내장외부외부Lua 자산 활용
검증된 분야게임, 인프라CLI, 스크립팅게임, 학습Rust 앱 확장Neovim, 게임

실무 관점의 요약 가이드는 다음과 같습니다.

  • 검증된 생태계와 성능(LuaJIT)이 최우선이면 Lua
  • 단일 파일 임베딩, PEG, Lisp 매크로가 매력적이면 Janet
  • 호스트가 Rust라면 Rhai가 통합 비용이 가장 낮음
  • 이미 Lua 기반 호스트(Neovim 등)를 쓴다면 Fennel로 문법만 업그레이드

프로덕션 도입 시 고려사항

작은 언어를 사용자 대면 제품에 넣을 때는 두 가지를 깊이 검토해야 합니다.

샌드박싱

사용자가 작성한 스크립트를 실행한다면 격리는 필수입니다. 점검 목록은 다음과 같습니다.

샌드박싱 체크리스트
[ ] 위험 모듈 차단: 파일 IO, 프로세스 실행, 네트워크 접근을
    환경에서 제거했는가 (Lua라면 os, io 테이블 제거)
[ ] CPU 폭주 방어: 무한 루프를 끊을 수단이 있는가
    (Lua 디버그 훅의 instruction count, Rhai의 연산 제한,
     별도 스레드 + 타임아웃 등)
[ ] 메모리 제한: 커스텀 allocator나 GC 한도로 폭식을 막는가
[ ] 스택 깊이 제한: 재귀 폭탄으로 호스트가 죽지 않는가
[ ] 에러 경계: 스크립트 오류가 호스트 크래시로 번지지 않는가
    (protected call 경유 실행)

Lua는 환경 테이블을 제한하는 방식의 샌드박싱이 관행으로 정립되어 있고, Rhai는 처음부터 제한 실행을 염두에 두고 설계되었습니다. 어느 쪽이든 "기본 환경 그대로 사용자 코드 실행"은 금물입니다.

성능

인터프리터 언어의 호출 경계 비용을 이해해야 합니다.

  • 호스트-스크립트 경계 횟수를 줄이는 것이 최우선 최적화입니다. 픽셀 단위로 스크립트를 호출하면 어떤 언어도 느립니다. 프레임 단위, 이벤트 단위로 굵게 호출하세요
  • Lua가 병목이면 LuaJIT 전환을 검토합니다. 단, LuaJIT은 Lua 5.1 호환이라는 점, 메인테이너 체계가 본가와 다르다는 점을 함께 평가해야 합니다
  • Janet은 JIT이 없으므로 수치 연산 루프는 C 함수로 내리는 설계가 정석입니다
  • GC 일시정지가 문제라면 증분 GC 설정(Lua)이나 수동 GC 호출 시점 제어를 튜닝합니다

함정과 반론 — 큰 언어 생태계와의 균형

작은 언어 예찬론에는 반드시 짚어야 할 반론들이 있습니다.

첫째, 생태계의 부재는 실질 비용입니다. Janet으로 웹 서비스를 만들면 인증 라이브러리, ORM, 클라우드 SDK를 직접 작성하거나 C 바인딩으로 메워야 합니다. 표준 라이브러리가 닿지 않는 영역에서 "전부 이해 가능"의 대가는 "전부 직접 구현"입니다.

둘째, 채용과 온보딩 문제입니다. 팀에 Janet 경험자가 있을 확률은 낮습니다. 언어가 작아 학습은 빠르지만, 관용구와 모범 사례의 축적이 얕다는 점은 코드 리뷰 품질에 영향을 줍니다.

셋째, AI 도구와의 궁합도 현실적 변수입니다. 2026년 현재 코딩 에이전트는 학습 데이터가 풍부한 Python, TypeScript에서 가장 강합니다. 마이너 언어에서는 환각이 잦아, AI 보조 생산성이라는 거대 언어의 새 장점이 작은 언어의 상대적 약점이 됐습니다. 다만 언어가 작으면 에이전트에게 언어 명세 전체를 컨텍스트로 줄 수 있다는 역발상도 가능합니다. CLAUDE.md에 Janet 치트시트를 넣어 두는 식의 컨텍스트 엔지니어링이 실제로 통합니다.

결론은 이분법이 아닙니다. 제품의 코어는 주류 언어로, 확장 포인트와 설정과 도메인 로직은 작은 임베디드 언어로 구성하는 하이브리드가 Lua 30년사가 증명한 균형점입니다.

실무 적용 가이드 — 이번 주말에 시작하기

작은 언어를 체험하는 단계별 경로를 제안합니다.

  1. Janet 설치 후 REPL에서 30분 놀아보기. 공식 문서의 튜토리얼이면 충분합니다
# macOS
brew install janet
janet -e '(print "hello, small world")'

# 소스 빌드도 간단합니다
git clone https://github.com/janet-lang/janet.git
cd janet && make && sudo make install
  1. 일상 스크립트 하나를 Janet이나 Lua로 작성해 보기. 로그 파싱이라면 Janet PEG의 진가를 느낄 수 있습니다
  2. C 임베딩 미니 프로젝트: 위의 예제 코드를 바탕으로, 자기 프로그램에 스크립트 훅 하나를 뚫어 보기
  3. 설정 파일 하나를 Lua로 이관해 보기: 반복이 많은 YAML이 좋은 후보입니다
  4. 깊이 파고 싶다면 "Crafting Interpreters"를 읽고 직접 작은 언어를 만들어 보기. Wren 작가의 책이라 임베더블 설계 감각을 그대로 배울 수 있습니다

마치며

"Why Janet"이 다시 회자되는 현상은 단순한 복고가 아닙니다. 의존성 피로, 공급망 불안, 도구 복잡도의 누적 속에서, 전부 이해할 수 있는 작은 도구의 가치가 재평가되고 있다는 신호입니다.

작은 언어는 큰 언어를 대체하지 않습니다. 대신 큰 시스템의 틈새 — 설정, 확장, DSL, 사용자 스크립팅 — 에서 복잡도를 흡수하는 완충재 역할을 합니다. Lua가 게임과 인프라에서 증명했고, Janet이 더 현대적인 도구로 그 계보를 잇고 있습니다. 주말 하루를 투자해 REPL을 띄워 보시기 바랍니다. 언어 하나를 통째로 이해한다는 감각은 생각보다 큰 즐거움입니다.

참고 자료