Rhai 입문: Rust 앱에 임베디드 스크립트 박아 넣기

Rhai 입문: Rust 앱에 임베디드 스크립트 박아 넣기

Rust로 서비스를 짜다 보면 가끔 이런 욕심이 생깁니다. “이 분기 로직을 코드 변경 없이 운영자가 살짝 바꿀 수 있으면 좋을 텐데”, “고객사마다 다른 헤더 변환 규칙을 그때그때 다른 바이너리로 빌드하긴 너무 무거운데”처럼요. 이런 상황을 위한 도구가 바로 임베디드 스크립트 엔진입니다.

이번 글에서는 Rust 생태계에서 자주 쓰이는 Rhai 스크립트 엔진을 소개합니다. 호스트 Rust 코드에 박아 넣는 방법부터 Rhai 자체 문법까지 한 번에 살펴보겠습니다.

임베디드 스크립트가 필요한 순간

서비스 본체는 Rust로 단단하게 컴파일해두지만, 몇 가지 동작은 운영 중에도 바꾸고 싶을 때가 있습니다. 예를 들어 라우팅 규칙, 헤더 정책, 가격 계산 공식, 알림 임계값 같은 것들이죠. 이런 로직을 매번 코드에 박아 두면 작은 정책 변경에도 빌드와 배포가 따라붙어서 운영 부담이 커집니다.

스크립트 엔진을 임베드하면 이 부분을 분리할 수 있습니다. 핵심 로직은 그대로 Rust로 두고, 정책처럼 자주 바뀌는 부분만 스크립트로 빼서 외부 파일에서 읽어 들이거나 데이터베이스에서 끌어오는 식이죠.

Apollo Router는 GraphQL 게이트웨이의 핵심 라우팅 엔진을 Rust로 두고, 사용자가 요청/응답을 가로채 변형하는 부분은 Rhai 스크립트로 풀어 쓰도록 설계해두었습니다.

Rhai의 특징

Rhai는 Rust로 작성된 임베디드 스크립트 엔진인데요. 비슷한 자리를 다투는 도구로 LuaDeno Core 같은 옵션도 있지만, Rhai는 다음 같은 지점에서 매력이 있습니다.

우선 순수 Rust 구현입니다. C 라이브러리에 의존하지 않으니 빌드와 배포가 단순하고, cargo 한 줄로 통합이 끝납니다.

다음으로 샌드박스가 기본입니다. 파일 시스템 접근, 네트워크, OS 호출 같은 것이 기본 엔진에 아예 노출되어 있지 않습니다. 호스트에서 명시적으로 허용한 함수만 스크립트가 호출할 수 있죠. 사용자 입력으로 들어온 스크립트를 실행해야 하는 시나리오에서 안전합니다.

마지막으로 Rust와 친화적인 문법입니다. let, fn, if, while, for 같은 키워드와 메서드 호출 문법이 Rust와 거의 같아서 진입 장벽이 낮습니다. 다만 동적 타이핑이라는 점은 Rust와 다른데, 이 차이는 글 후반부에서 자세히 다룹니다.

Hello World

Cargo.tomlrhai 크레이트를 추가하면 시작할 수 있습니다.

Cargo.toml
[dependencies]
rhai = "1"

가장 단순한 사용법은 Engine을 만들고 스크립트 문자열을 평가하는 것입니다.

use rhai::Engine;

fn main() {
    let engine = Engine::new();

    let result: i64 = engine.eval("40 + 2").unwrap();
    println!("{result}"); // 42
}

Engine::new()는 표준 라이브러리를 모두 포함한 기본 엔진을 만듭니다. eval::<T>(script)은 스크립트를 평가하고 결과를 지정한 타입으로 캐스팅해 돌려주죠. 평가 도중 에러가 나면 ResultErr로 빠지고, 호스트 코드에서는 익숙한 Rust 에러 처리 패턴으로 받아주면 됩니다.

같은 스크립트를 반복 실행할 거라면 compile로 한 번만 파싱해두고 eval_ast로 재사용하는 편이 빠릅니다.

let ast = engine.compile("40 + 2").unwrap();
let result: i64 = engine.eval_ast(&ast).unwrap();

Rust 함수를 Rhai에 노출하기

스크립트 엔진의 진짜 가치는 호스트 코드의 함수를 스크립트에서 호출할 수 있게 만들 때 드러나는데요. register_fn으로 Rust 함수를 등록하면 됩니다.

use rhai::Engine;

fn double(x: i64) -> i64 {
    x * 2
}

fn main() {
    let mut engine = Engine::new();
    engine.register_fn("double", double);

    let result: i64 = engine.eval("double(21)").unwrap();
    println!("{result}"); // 42
}

클로저도 그대로 등록할 수 있어서, 호스트 측 상태를 캡처해 노출하기에 편리합니다.

let prefix = "[LOG]".to_string();
engine.register_fn("log", move |msg: &str| {
    println!("{prefix} {msg}");
});

engine.run("log(\"hello rhai\")").unwrap();
// [LOG] hello rhai

Rhai는 등록된 함수의 시그니처를 보고 적절한 타입 변환을 자동으로 처리합니다. 예를 들어 위에서 &str을 받도록 등록했으면, 스크립트의 문자열 리터럴이 자동으로 매칭되죠.

Rust 타입 등록하기

함수만 노출해도 충분한 경우가 많지만, 도메인 객체를 통째로 스크립트에 넘기고 싶을 때도 있는데요. 이럴 땐 register_type_with_name으로 커스텀 타입을 등록합니다.

use rhai::Engine;

#[derive(Debug, Clone)]
struct User {
    name: String,
    age: i64,
}

impl User {
    fn new(name: &str, age: i64) -> Self {
        Self { name: name.into(), age }
    }

    fn greet(&mut self) -> String {
        format!("안녕하세요, {}님! ({}세)", self.name, self.age)
    }
}

fn main() {
    let mut engine = Engine::new();

    engine
        .register_type_with_name::<User>("User")
        .register_fn("user", User::new)
        .register_fn("greet", User::greet);

    let result: String = engine
        .eval(r#"
            let u = user("Dale", 35);
            u.greet()
        "#)
        .unwrap();

    println!("{result}");
    // 안녕하세요, Dale님! (35세)
}

여기서 두 가지를 짚어두면, 첫째로 Rhai에서 메서드는 첫 번째 인자가 &mut self인 함수와 같습니다. u.greet()처럼 호출하면 내부적으로 greet(u)가 실행되죠. 둘째로 등록된 타입은 반드시 Clone을 구현해야 합니다. Rhai는 값 의미론(value semantics)을 따라서 스크립트 간 경계를 넘을 때 복제가 일어나거든요.

변수와 동적 타이핑

여기서부터는 Rhai 스크립트 자체의 문법을 둘러봅니다. 호스트에서 eval로 넘기는 그 코드 안에서 어떤 문법이 통하는지 살펴보는 거죠.

변수 선언은 Rust처럼 let, 상수는 const를 씁니다. 다만 let으로 만든 변수는 기본적으로 변경 가능해서, Rust처럼 mut을 따로 붙일 필요가 없습니다.

let count = 0;
count += 1;       // OK
count = 99;       // OK
const MAX = 100;

Rhai의 모든 값은 내부적으로 Dynamic 타입을 갖습니다. 변수는 어떤 타입의 값이든 받을 수 있고, 같은 변수에 다른 타입을 다시 대입해도 됩니다.

let x = 42;       // i64
x = "hello";      // string으로 변경 OK
x = [1, 2, 3];    // array로 다시 변경 OK

값의 실제 타입이 궁금할 땐 type_of()로 확인할 수 있죠. 기본 자료형은 정수(i64), 실수(f64), 불리언(bool), 문자(char), 문자열(string), 단위(())입니다. Rust의 다양한 정수 타입(u32, i16 등)이 하나의 i64로 단순화된 셈인데요. 빌드 옵션으로 f32i32로 바꿀 수도 있지만 보통은 기본값을 그대로 씁니다.

제어 흐름

if/else, while, loop, for, switch가 모두 있고, Rust와 거의 같은 모양입니다.

let n = 5;
if n > 0 { print("양수"); }
else if n < 0 { print("음수"); }
else { print("영"); }

for i in 0..5 {
    print(i);   // 0, 1, 2, 3, 4
}

switch는 Rust의 match와 비슷하지만 약간 더 가볍게 동작합니다. 단일 값 비교 위주라 정밀한 패턴 매칭은 약하지만, 가드(if)까지 지원해서 일상 분기에는 충분합니다.

switch type_of(value) {
    "i64" => print("정수입니다"),
    "string" if value.len() < 5 => print("짧은 문자열"),
    "string" => print("긴 문자열"),
    _ => print("그 외")
}

함수와 클로저

스크립트 안에서 정의하는 함수는 Rust처럼 fn으로 시작합니다. 다만 타입 명시는 없습니다.

fn double(x) {
    x * 2
}

마지막 표현식이 자동 반환되는 점도 Rust와 같고, 클로저는 파이프(|) 스타일을 그대로 따랐습니다.

let nums = [1, 2, 3, 4];
let doubled = nums.map(|x| x * 2);
print(doubled);     // [2, 4, 6, 8]

배열의 map, filter, reduce 같은 함수형 메서드가 표준으로 제공되어 클로저를 끼우는 패턴이 자주 등장합니다.

배열과 객체 맵

배열은 []로 Rust나 JS와 같습니다. 여기서 처음 보면 가장 헷갈리는 게 객체 맵인데요. 일반 중괄호 {}가 아니라 #{}로 시작합니다.

let nums = [1, 2, 3];
nums.push(4);
print(nums[0]);         // 1

let user = #{
    name: "Dale",
    age: 35
};
print(user.name);       // Dale
user.age = 36;

{} 자리는 코드 블록을 위해 비워두고, 객체 리터럴은 #을 붙여 구분하는 디자인이죠. JS나 Python에서 넘어왔다면 처음 한두 번은 꼭 실수하게 됩니다.

에러 처리: throw와 try/catch

Rust의 Result 대신 Rhai는 예외 모델을 씁니다. throw로 던지고 try/catch로 잡습니다.

try {
    if input == "" {
        throw "입력이 비어 있습니다";
    }
    process(input);
} catch (err) {
    print(`에러: ${err}`);
}

throw에는 어떤 값이든 던질 수 있어서, 에러 코드용 정수나 객체 맵을 던져 부가 정보를 전달하는 패턴도 자주 보입니다.

throw #{ code: 404, message: "not found" };

호스트 Rust 코드에서는 이 예외를 EvalAltResult로 받아서 처리합니다. Rhai 스크립트 안에서는 예외 모델이지만, 호스트 코드 입장에서는 결국 Result로 변환되어 흐르는 셈이죠.

샌드박스: 폭주하는 스크립트 막기

신뢰할 수 없는 스크립트를 실행해야 한다면 자원 한도를 명시적으로 걸어두는 게 안전합니다. Rhai는 여러 단계의 제한 장치를 제공하는데, 가장 흔히 쓰는 두 가지를 보겠습니다.

set_max_operations는 스크립트가 실행할 수 있는 총 연산 수를 제한합니다.

let mut engine = Engine::new();
engine.set_max_operations(10_000);

// 무한 루프가 들어와도 1만 연산을 넘으면 자동으로 중단됨
let result = engine.run("loop {}");
assert!(result.is_err());

set_max_expr_depths는 표현식과 함수 호출의 중첩 깊이를 제한해서 스택 오버플로우를 막습니다.

engine.set_max_expr_depths(64, 32);
// 글로벌 스코프에서 64단계, 함수 안에서 32단계까지 허용

이외에도 문자열 길이(set_max_string_size), 배열 크기(set_max_array_size), 호출 스택 깊이(set_max_call_levels)를 따로 걸 수 있습니다. 기본 엔진에는 파일/네트워크 접근이 없기 때문에, 호스트가 노출하지 않은 기능은 스크립트가 시도조차 할 수 없다는 게 가장 큰 안전 장치입니다.

Rust와 헷갈리기 쉬운 차이

Rust 출신이 자주 발을 헛디디는 지점만 모아두면 다음과 같습니다.

객체 맵 리터럴이 {}가 아니라 #{}입니다. {}만 쓰면 코드 블록으로 해석되기 때문에 의도와 전혀 다른 결과가 나옵니다. Rhai에서 가장 흔히 마주치는 실수죠.

let user = { name: "Dale" };   // 에러: 코드 블록으로 해석됨
let user = #{ name: "Dale" };  // OK: 객체 맵 리터럴

let이 기본적으로 변경 가능해서 let mut을 따로 안 써도 됩니다. 함수 시그니처에 매개변수와 반환 타입을 적지 않습니다. Result가 아니라 예외 모델이라 ? 연산자도, Option도 없습니다. 문자열 보간은 백틱(`)과 ${} 조합이라 JS의 템플릿 리터럴과 동일하고요.

let name = "Dale";
print(`hello, ${name}!`);

소유권과 빌림 같은 개념도 없습니다. 값은 기본적으로 복제되며, 호스트에서 등록한 커스텀 타입도 Clone을 요구합니다.

어디에 쓰면 좋을까

Rhai가 빛나는 자리는 대체로 다음과 같습니다.

게이트웨이나 프록시의 정책 엔진이 대표적인데요. 들어오는 요청의 헤더를 다시 쓰거나 인증 토큰을 검증하는 규칙을 운영자가 직접 스크립트로 갈아 끼울 수 있습니다. 앞서 본 Apollo Router가 바로 이 자리에 Rhai를 끌어다 쓰는 사례죠.

게임이나 시뮬레이터의 모드 시스템에서도 자주 보입니다. 빠른 핵심 루프는 Rust로 짜고, AI 행동 트리나 이벤트 핸들러를 사용자 모드 작성자에게 Rhai로 풀어주는 식이죠.

CLI 도구의 사용자 정의 훅도 좋은 사례입니다. 빌드 도구나 배포 자동화 CLI에서 “이 시점에 사용자가 정의한 스크립트를 실행하고 싶다”는 요구가 있을 때, 신뢰 없는 입력을 안전하게 받아서 실행하는 데 적합합니다.

반대로 잘 안 맞는 자리도 있는데요. 매우 짧은 핫 패스에서 수백만 번씩 호출되는 로직이라면 인터프리터 오버헤드가 누적되어 부담이 됩니다. 이런 자리에는 차라리 동적 디스패치나 트레이트 객체로 분기하는 편이 빠릅니다.

마치며

Rhai는 “Rust 본체는 그대로 두되 일부 동작만 바깥에서 풀어 쓰고 싶다”는 욕구를 가장 가볍게 풀어주는 도구입니다. cargo add rhai 한 줄로 시작할 수 있고, 노출할 함수와 타입을 명시적으로 등록하기 때문에 호스트와 스크립트 사이의 경계가 분명합니다. 문법은 동적 타이핑이 붙은 Rust 부분 집합이라고 생각하시면 큰 그림이 잘 잡히고요.

Rust 생태계의 다른 도구가 궁금하시다면 Rust 관련 글을 함께 둘러보시기 바랍니다.

더 자세한 내용은 Rhai 공식 문서를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord