LabHub

블로그

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

한국어English日本語

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

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

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

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

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

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의 매력 포인트를 정리하면 다음과 같습니다.

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 외에도 살펴볼 만한 작은 언어들이 있습니다.

선택 기준 테이블

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

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

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

프로덕션 도입 시 고려사항

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

샌드박싱

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

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

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

성능

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

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

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

첫째, 생태계의 부재는 실질 비용입니다. 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을 띄워 보시기 바랍니다. 언어 하나를 통째로 이해한다는 감각은 생각보다 큰 즐거움입니다.

참고 자료

댓글

아직 댓글이 없습니다.

로그인하면 댓글을 쓸 수 있습니다