C++에 스크립트 엔진 붙이기: Lua·pybind11·V8 선택 기준과 바인딩, 샌드박싱

들어가며: 엔진을 고르는 것이 바인딩보다 먼저다

게임 로직, 밸런스 값, 이벤트 시퀀스를 C++에 직접 넣으면 수정할 때마다 전체 재컴파일이 필요합니다. 스크립트 엔진을 붙이면 스크립트만 바꿔서 로직을 갱신할 수 있습니다. 문제는 엔진을 한 번 고르면 되돌리기 어렵다는 점입니다. 스크립트 API가 수백 개로 늘어나고 기획자가 작성한 스크립트가 쌓이면, 엔진을 바꾸는 것은 사실상 재작성입니다.

그래서 이 글은 바인딩 코드 자체보다 선택 기준에 집중합니다. 각 엔진의 바인딩 심화는 별도 글에 있습니다.

요구 환경: C++17 이상, Lua 5.4 / Python 3.8+ / V8 (선택)


스크립팅이 필요해지는 상황

상황누가 스크립트를 쓰나신뢰 수준먼저 떠올릴 엔진
밸런스·퀘스트·이벤트 시퀀스사내 기획자·스크립터신뢰Lua
사용자 모드·플러그인외부 사용자불신Lua(샌드박스) 또는 V8
Python 학습 파이프라인의 핫 루프사내 개발자신뢰pybind11 확장 모듈
웹·Node.js와 같은 비즈니스 로직 공유사내 개발자신뢰V8 (또는 QuickJS)

표에서 가장 중요한 열은 “신뢰 수준”입니다. 사내 기획자가 쓰는 스크립트라면 실수(무한 루프, nil 호출)만 막으면 되지만, 외부 사용자가 올리는 모드라면 악의적인 코드를 가정해야 합니다. 이 차이가 엔진 선택을 가장 크게 좌우합니다. 3절에서 보겠지만 세 엔진은 격리 가능한 수준이 크게 다릅니다.

또 하나 짚을 점은, 밸런스 값처럼 로직이 아니라 데이터인 경우에는 스크립트 엔진이 필요 없을 수도 있다는 것입니다. 숫자 테이블만 바꾸면 되는 상황에서 Lua를 붙이면, 기획자가 테이블 안에 함수를 넣기 시작하는 순간 데이터 파일이 코드가 됩니다. JSON이나 TOML로 충분한지 먼저 따져 보고, 조건 분기와 이벤트 반응이 실제로 필요할 때 스크립트를 도입하는 편이 유지보수 부담이 작습니다.


선택 기준

임베딩인가, 확장인가

가장 먼저 구분할 것은 누가 main을 갖는가입니다.

  • 임베딩(embedding): C++ 앱이 주인이고, 그 안에서 스크립트 인터프리터를 만들어 스크립트를 실행합니다. 게임 엔진 + Lua, 데스크톱 앱 + V8이 이 형태입니다.
  • 확장(extending): Python 같은 인터프리터가 주인이고, C++ 코드는 import되는 확장 모듈로 들어갑니다. pybind11의 주 용도는 이쪽입니다.

pybind11로도 pybind11::embed를 써서 Python을 C++ 안에 임베드할 수 있지만, 이 경우 배포물에 Python 런타임(수십 MB의 표준 라이브러리 포함)을 함께 실어야 하고, 사용자 PC에 설치된 Python과 버전이 섞이는 문제를 직접 관리해야 합니다. “C++ 앱에 스크립트를 붙인다”가 목적이라면 Python 임베딩은 비용이 가장 큰 선택지에 속합니다. 반대로 “Python 코드에서 C++ 속도가 필요하다”면 pybind11 확장이 가장 자연스럽습니다.

CMake에서도 이 구분이 드러납니다. 확장 모듈은 pybind11_add_module로 만들고 Python 라이브러리에 링크하지 않습니다(인터프리터가 로드할 때 심볼이 해결됨). 임베딩 실행 파일만 pybind11::embed에 링크합니다.

find_package(pybind11 CONFIG REQUIRED)

# 확장: Python에서 import game_engine
pybind11_add_module(game_engine engine_module.cpp)

# 임베딩: C++ 실행 파일 안에서 Python 인터프리터 실행
add_executable(tool_with_python main.cpp)
target_link_libraries(tool_with_python PRIVATE pybind11::embed)

확장 모듈에 pybind11::embed를 링크하면 libpython이 두 번 로드되는 형태가 되어, 플랫폼에 따라 import 시 크래시나 “undefined symbol” 에러로 나타납니다.

비교표

기준Lua 5.4Python (pybind11)V8
주 형태임베딩확장 (임베딩도 가능)임베딩
런타임 크기수백 KB 수준, 소스 몇십 개 파일인터프리터 + 표준 라이브러리수십 MB, 빌드에 전용 도구(gn/ninja) 필요
빌드 난이도C 소스를 그대로 컴파일pip/CMake로 비교적 쉬움가장 어려움, 버전별 API 변경 잦음
스레드 모델lua_State마다 단일 스레드GIL 하나(3.13 free-threaded 빌드 제외)Isolate마다 한 번에 한 스레드(v8::Locker)
실행 시간 제한count 훅같은 프로세스에서는 어려움TerminateExecution (다른 스레드에서 호출)
메모리 제한커스텀 할당자어려움ResourceConstraints로 힙 상한
불신 코드 격리환경을 직접 구성하면 가능사실상 불가 (별도 프로세스 필요)Isolate 단위로 가능
생태계작음가장 큼npm이지만 Node API는 없음

몇 가지를 덧붙입니다.

V8에 “Node.js가 들어 있지는 않다”는 점을 자주 오해합니다. V8은 JavaScript 엔진일 뿐이고, require, fs, setTimeout, fetch는 Node.js나 브라우저가 제공하는 것입니다. npm 패키지를 그대로 가져다 쓰려고 V8을 임베드했다가, 대부분의 패키지가 Node API에 의존해서 실행되지 않는 상황을 만나게 됩니다. 로직 공유가 목적이라면 공유할 코드를 순수 JS로 한정해야 합니다. 크기가 부담이면 QuickJS 같은 경량 엔진이 대안이지만 JIT가 없어 연산 성능은 V8보다 낮습니다.

Python의 GIL은 임베딩 시 특히 까다롭습니다. C++ 워커 스레드에서 Python 콜백을 부르려면 매번 py::gil_scoped_acquire가 필요하고, 반대로 Python에서 부른 C++ 함수가 오래 걸리는 연산을 한다면 py::gil_scoped_release로 GIL을 풀어야 다른 Python 스레드가 멈추지 않습니다. 이 두 규칙 중 하나라도 어기면 데드락이나 성능 저하가 납니다.

#include <pybind11/pybind11.h>
namespace py = pybind11;

// Python에서 호출되는 무거운 C++ 연산: GIL을 풀고 계산
double heavy_compute(const std::vector<double>& v) {
    py::gil_scoped_release release;   // 이 블록 동안 다른 Python 스레드 진행 가능
    double sum = 0;
    for (double x : v) sum += x * x;
    return sum;                        // 반환 시 소멸자에서 GIL 재획득
}

// C++ 워커 스레드에서 Python 콜백 호출: GIL을 잡고 호출
void notify_from_worker(py::object callback, int value) {
    py::gil_scoped_acquire acquire;
    callback(value);
}

Lua의 단순함은 제약이기도 합니다. 표준 라이브러리가 작아서 JSON 파싱, 정규식, 네트워크 같은 기능은 C++에서 노출하거나 서드파티 라이브러리를 붙여야 합니다. 대신 스크립트가 할 수 있는 일이 정확히 “C++이 노출한 것”으로 한정되므로, 게임 엔진처럼 스크립트의 권한을 좁게 유지하고 싶은 경우에는 이 작음이 장점이 됩니다. C API가 스택 기반이라 바인딩을 손으로 쓰면 장황한데, 실무에서는 Sol3 같은 C++ 래퍼를 쓰는 경우가 많습니다. 다만 Sol3는 템플릿이 무거워 바인딩 파일의 컴파일 시간이 크게 늘어나므로, 바인딩 코드를 별도 번역 단위로 분리해 두는 편이 좋습니다.

결정 흐름

flowchart TB
    A{Python이 main인가?} -->|예| P[pybind11 확장 모듈]
    A -->|아니오| B{불신 코드를 돌리나?}
    B -->|아니오| C{JS 로직 공유가 필수인가?}
    C -->|아니오| L[Lua]
    C -->|예| V[V8 또는 QuickJS]
    B -->|예| D{메모리·시간 상한을 엔진이 보장해야 하나?}
    D -->|Lua 할당자·훅으로 충분| L2[Lua + 직접 구성한 샌드박스]
    D -->|강한 격리 필요| V2[V8 Isolate 또는 별도 프로세스]

바인딩과 샌드박싱: 엔진별로 무엇이 다른가

Lua: 스택과 upvalue

Lua C API는 lua_State의 가상 스택으로 값을 주고받습니다. C 함수는 스택에서 인자를 읽고(luaL_checkinteger 등), 결과를 푸시한 뒤 반환값 개수를 리턴합니다. C++ 객체 포인터는 lua_pushcclosure로 클로저를 만들 때 upvalue에 묶어 전달하고, C++에서 Lua 함수를 부를 때는 lua_getglobal → 인자 푸시 → lua_pcall 순서로 호출합니다. 스택 연산, 테이블 조작, 엔티티 API 전체 예제, 자주 나는 에러(nil 호출, bad argument, 스택 불균형, lua_tostring 수명)는 Lua 스크립팅 글에 코드와 함께 정리되어 있으므로 여기서는 반복하지 않습니다.

이 글에서 더 볼 것은 샌드박스를 어떻게 구성하느냐입니다. luaL_openlibs는 io, os, package까지 모두 엽니다. 필요한 라이브러리만 열 때는 luaopen_*를 직접 호출하지 말고 luaL_requiref를 씁니다(luaopen_*를 그냥 부르면 라이브러리 테이블이 스택에 남을 뿐 전역에 등록되지 않습니다).

extern "C" {
#include <lua.h>
#include <lualib.h>
#include <lauxlib.h>
}
#include <cstdlib>

struct MemLimit { size_t used = 0, limit = 16 * 1024 * 1024; };

// 커스텀 할당자: 상한을 넘으면 nullptr → Lua는 "not enough memory" 에러
static void* limited_alloc(void* ud, void* ptr, size_t osize, size_t nsize) {
    auto* m = static_cast<MemLimit*>(ud);
    size_t old = ptr ? osize : 0;
    if (nsize == 0) { m->used -= old; std::free(ptr); return nullptr; }
    if (m->used - old + nsize > m->limit) return nullptr;
    void* p = std::realloc(ptr, nsize);
    if (p) m->used = m->used - old + nsize;
    return p;
}

lua_State* make_sandbox(MemLimit* mem) {
    lua_State* L = lua_newstate(limited_alloc, mem);
    luaL_requiref(L, "_G", luaopen_base, 1);        lua_pop(L, 1);
    luaL_requiref(L, "table", luaopen_table, 1);    lua_pop(L, 1);
    luaL_requiref(L, "string", luaopen_string, 1);  lua_pop(L, 1);
    luaL_requiref(L, "math", luaopen_math, 1);      lua_pop(L, 1);
    // base 라이브러리 안에도 파일을 읽거나 임의 코드를 로드하는 함수가 있다
    for (const char* name : {"dofile", "loadfile", "load", "collectgarbage"}) {
        lua_pushnil(L);
        lua_setglobal(L, name);
    }
    return L;
}

io와 os를 열지 않는 것만으로는 부족하다는 점이 핵심입니다. base 라이브러리의 dofile·loadfile은 파일 시스템에 접근하고, load는 바이트코드 문자열을 받을 수 있습니다. Lua 바이트코드는 검증되지 않으므로 조작된 바이트코드는 인터프리터 자체를 깨뜨릴 수 있습니다. 텍스트 스크립트만 받는다면 C++에서 luaL_loadbufferx(L, buf, len, name, "t")처럼 모드를 "t"로 지정해 바이트코드 로드를 막습니다.

무한 루프는 count 훅으로 끊습니다. 카운터를 함수 안의 static으로 두면 스크립트 실행이 끝나도 값이 남아서, 다음 스크립트가 시작하자마자 타임아웃이 나는 버그가 생깁니다. 카운터는 실행 단위마다 초기화해야 합니다.

static int g_budget = 0;   // 실행 단위마다 재설정 (멀티 State라면 State별로 보관)

static void budget_hook(lua_State* L, lua_Debug*) {
    if (--g_budget <= 0) luaL_error(L, "script timeout (instruction budget exceeded)");
}

bool run_with_budget(lua_State* L, int nargs, int budget_steps) {
    g_budget = budget_steps;
    lua_sethook(L, budget_hook, LUA_MASKCOUNT, 1000);   // 1000 명령마다 1 차감
    int rc = lua_pcall(L, nargs, 0, 0);
    lua_sethook(L, nullptr, 0, 0);
    if (rc != LUA_OK) lua_pop(L, 1);                    // 에러 메시지 제거
    return rc == LUA_OK;
}

훅 방식에는 한계가 있습니다. C++이 노출한 함수 안에서 오래 걸리는 작업(예: 큰 경로 탐색)은 Lua 명령 수로 잡히지 않으므로, 노출하는 C++ API 자체에도 비용 상한이 필요합니다. 또 LuaJIT에서는 JIT 컴파일된 루프 안에서 훅이 호출되지 않을 수 있어서 같은 방식이 그대로 통하지 않습니다.

Python: 바인딩은 쉽고, 격리는 어렵다

pybind11은 C++ 함수와 클래스를 선언적으로 노출합니다.

#include <pybind11/pybind11.h>
namespace py = pybind11;

class GameEngine {
public:
    void load_level(const std::string& path) { /* ... */ }
    int score() const { return score_; }
private:
    int score_ = 0;
};

PYBIND11_MODULE(game_engine, m) {
    py::class_<GameEngine>(m, "GameEngine")
        .def(py::init<>())
        .def("load_level", &GameEngine::load_level)
        .def_property_readonly("score", &GameEngine::score);
}

바인딩의 편의성은 세 엔진 중 가장 좋지만, 샌드박싱은 가장 약합니다. __builtins__에서 open이나 __import__를 지워도 ().__class__.__base__.__subclasses__() 같은 경로로 내부 객체에 도달하는 우회 기법이 오래전부터 알려져 있고, CPython 개발진도 인터프리터 내부 샌드박싱은 지원하지 않는다는 입장입니다. 외부 사용자의 Python 스크립트를 받아야 한다면 별도 프로세스로 띄우고, 그 프로세스를 OS 수준에서 제한(컨테이너, seccomp, 권한 없는 사용자)해야 합니다. 이 비용을 감수할 이유가 없다면 사용자 스크립트용으로는 Python을 고르지 않는 편이 낫습니다.

pybind11 모듈·클래스·NumPy 버퍼 공유·예외 변환은 Python 스크립팅 글에서 다룹니다.

V8: 격리는 강하고, 비용이 크다

V8은 Isolate 하나가 독립된 힙을 가지는 JS 실행 환경이고, Context가 그 안의 전역 스코프입니다. Isolate를 만들 때 힙 상한을 정할 수 있고, 다른 스레드에서 isolate->TerminateExecution()을 호출해 실행 중인 스크립트를 중단할 수 있습니다. 즉 Lua에서 할당자와 훅으로 직접 만들어야 하는 제한을 엔진이 API로 제공합니다.

v8::Isolate::CreateParams params;
params.array_buffer_allocator = v8::ArrayBuffer::Allocator::NewDefaultAllocator();
params.constraints.ConfigureDefaultsFromHeapSize(0, 64 * 1024 * 1024);  // 힙 상한 64MB
v8::Isolate* isolate = v8::Isolate::New(params);

// 감시 스레드: 제한 시간이 지나면 실행 중단
std::thread watchdog([isolate] {
    std::this_thread::sleep_for(std::chrono::milliseconds(200));
    isolate->TerminateExecution();
});

(실제 코드에서는 스크립트가 먼저 끝나면 감시 스레드를 취소할 수 있도록 조건 변수를 씁니다.) 힙 상한에 도달하면 V8은 기본적으로 프로세스를 중단(OOM)시키므로, 이를 복구 가능한 에러로 바꾸려면 AddNearHeapLimitCallback으로 상한을 잠시 늘려 주고 TerminateExecution을 거는 처리가 필요합니다. 이런 세부 동작은 V8 버전마다 바뀌는 경우가 많습니다. 예를 들어 종료 시 호출하던 v8::V8::ShutdownPlatform()은 최근 버전에서 v8::V8::DisposePlatform()으로 바뀌었습니다. V8을 고른다면 특정 버전에 고정하고, 업그레이드를 별도 작업으로 계획해야 합니다.

초기화, C++ 함수를 FunctionTemplate으로 노출하는 방법, 객체 래핑은 JavaScript 스크립팅 글에 있습니다.


엔진과 무관한 운영 원칙

스크립트 API는 좁고 안정적으로

스크립트에 노출한 함수는 공개 API가 됩니다. 게임이 출시되고 모드가 쌓이면, C++ 쪽 이름 하나를 바꾸는 것도 호환성 문제입니다. 초기에는 편의를 위해 내부 함수를 이것저것 노출하기 쉬운데, 나중에 줄이는 것은 늘리는 것보다 훨씬 어렵습니다. 노출 함수 목록을 한 파일에 모아 두고, 스크립트 API 버전을 두어(스크립트 쪽에 요구 버전을 선언하게 하고 엔진이 로드 시 확인) 깨지는 변경을 명시적으로 관리하는 편이 좋습니다.

스크립트 에러가 엔진을 죽이지 않게

세 엔진 모두 “보호된 호출” 경로가 있습니다. Lua는 lua_pcall, pybind11은 py::error_already_set 예외, V8은 v8::TryCatch입니다. 스크립트에서 C++로 들어오는 경계와 C++에서 스크립트로 나가는 경계 양쪽 모두에서 에러를 잡아야 합니다. 특히 Lua의 luaL_error는 longjmp로 C++ 스택을 건너뛰므로(Lua를 C로 빌드한 경우), 바인딩 함수 안에 소멸자가 필요한 C++ 객체가 있으면 소멸자가 호출되지 않습니다. Lua를 C++로 컴파일해 예외 기반으로 바꾸거나, 바인딩 함수에서 인자 검사를 RAII 객체 생성보다 먼저 하는 식으로 대응합니다.

성능 경계

스크립트 호출에는 경계 비용(인자 변환, 스택 조작, GIL 획득)이 있습니다. 매 프레임 수천 번 호출되는 로직은 C++에 두고, 스크립트는 이벤트 반응·초기화·규칙 판단처럼 호출 빈도가 낮은 곳에 둡니다. “무엇을 스크립트로 둘 것인가”를 호출 빈도로 나누면 엔진 선택에서 성능이 큰 변수가 되지 않는 경우가 많습니다.

체크리스트

  • 스크립트 작성자의 신뢰 수준을 정했다 (사내 / 외부)
  • 불신 코드라면: 라이브러리 화이트리스트, 바이트코드 로드 차단, 명령 수·메모리 상한 (또는 별도 프로세스)
  • 스크립트→C++, C++→스크립트 양방향 경계에서 에러를 잡는다
  • 노출 API 목록과 버전을 관리한다
  • 스레드 모델(State/GIL/Isolate)에 맞게 호출 스레드를 정했다

정리

항목LuaPython (pybind11)JavaScript (V8)
맞는 상황C++ 앱 안의 게임 로직, 모드Python 코드가 C++ 연산을 호출JS 로직 공유, 강한 격리
통합 방식C API 스택, upvalue확장 모듈 (임베딩은 비용 큼)Isolate / Context
격리직접 구성 (할당자, 훅, 화이트리스트)같은 프로세스에서는 불가Isolate 힙 상한, TerminateExecution
가장 큰 비용작은 생태계, 바인딩 수작업런타임 배포, GIL빌드·버전 관리

다음 단계: 엔진을 정했다면 각 엔진 글(Lua, Python, JavaScript)로 넘어가 바인딩을 구현합니다.


참고 자료


같이 보면 좋은 글