C++에 Lua 임베딩하기: C API 스택 연산, 테이블 조작, Sol3 바인딩, 핫 리로드
들어가며: “스킬 밸런스 하나 바꾸려고 전체를 다시 빌드해요”
실제 겪는 문제 시나리오
게임·도구·플러그인 시스템을 만들 때 자주 겪는 상황입니다. 문제: 게임 로직·밸런스·이벤트 처리를 C++에 직접 넣으면, 수정할 때마다 전체 재컴파일이 필요합니다. 해결: Lua 같은 경량 스크립트 언어를 C++ 엔진에 연결하여, 런타임에 스크립트만 바꿔도 로직을 갱신할 수 있게 합니다.
flowchart TD
subgraph wrong[❌ C++ 하드코딩]
W1[밸런스 수정] --> W2[소스 수정]
W2 --> W3[C++ 재빌드]
W3 --> W4[테스트]
W4 --> W5[반복 비용 큼]
end
subgraph right[✅ Lua 스크립팅]
R1[밸런스 수정] --> R2[Lua 파일만 수정]
R2 --> R3[재시작 또는 핫 리로드]
R3 --> R4[즉시 테스트]
R4 --> R5[빠른 반복]
end
Lua C API의 lua_State와 스택 연산, 데이터 타입을 짚고, C++에서 Lua 함수를 부르고 Lua에서 C++ 함수를 부르는 양방향 예제와 테이블 조작을 다룹니다.
요구 환경: C++17 이상, Lua 5.3 이상 (권장: Lua 5.4)
Lua 스크립팅이 필요해지는 순간
게임 밸런스 수정 시마다 재빌드
문제: 스킬 데미지, 이동 속도, 아이템 드롭률 같은 밸런스 값이 C++ 상수로 박혀 있습니다. 기획자가 “이 스킬 데미지를 100에서 120으로” 요청할 때마다 C++ 수정 → 전체 빌드 → 테스트가 반복됩니다. 해결: Lua 테이블로 밸런스 데이터를 분리하고 런타임에 로드합니다. 스크립트만 수정하면 재빌드 없이, 핫 리로드를 붙이면 재시작 없이도 적용할 수 있습니다.
-- balance.lua
return {
skill_damage = 120,
move_speed = 5.0,
drop_rate = 0.15
}
사용자 플러그인·모드 지원
문제: 에디터·도구에서 사용자가 커스텀 동작을 추가하고 싶어 합니다. C++ 플러그인 DLL은 빌드 환경이 복잡하며, 보안 위험도 있습니다. 해결: Lua 스크립트로 플러그인 API를 노출하면, 사용자가 스크립트만 작성해 확장할 수 있습니다. 샌드박스로 제한된 API만 제공해 안전하게 합니다.
이벤트·퀘스트 시퀀스
문제: 퀘스트·이벤트·대화 시퀀스가 복잡한 조건 분기로 이어집니다. C++에 하드코딩하면 가독성과 유지보수가 어렵습니다. 해결: Lua 테이블이나 스크립트로 이벤트 시퀀스를 정의하면, 기획·스크립터가 직접 수정하기 쉽습니다.
-- quest_events.lua
function on_quest_start(quest_id)
if quest_id == 1 then
spawn_npc("merchant", 100, 200)
show_dialog("Welcome, adventurer!")
end
end
AI·행동 트리
문제: NPC 행동 로직이 C++에 있으면, “공격 거리 5 → 7로 바꿔볼까?” 같은 작은 실험을 할 때마다 빌드와 재시작을 반복해야 합니다. 해결: Lua로 행동 트리·AI 조건을 작성하면, 스크립트만 수정해 빠르게 반복할 수 있습니다.
설정 파일·데이터 테이블
문제: JSON·XML 파싱은 오버헤드가 있으며, C++에서 직접 수정하기 어렵습니다.
해결: Lua 테이블은 문법이 간단하며, dofile로 로드하면 바로 Lua 값으로 사용할 수 있습니다.
lua_State와 스택 인덱스 규칙
lua_State란?
lua_State*는 Lua 가상 머신의 핸들입니다. 모든 Lua C API 함수는 이 포인터를 첫 인자로 받습니다. Lua와 C++ 간의 모든 데이터 교환은 스택을 통해 이루어집니다.
flowchart TB
subgraph cpp[C++]
A[게임 엔진] --> B[lua_State*]
end
subgraph lua[Lua]
B --> C[스택]
C --> D[값/함수/테이블]
B --> E[글로벌 환경]
end
Lua 초기화 및 종료
extern "C" {
#include <lua.h>
#include <lualib.h>
#include <lauxlib.h>
}
#include <string>
#include <stdexcept>
class LuaEngine {
lua_State* L_ = nullptr;
public:
LuaEngine() {
L_ = luaL_newstate();
if (!L_) {
throw std::runtime_error("luaL_newstate failed");
}
luaL_openlibs(L_); // base, table, string, math 등 표준 라이브러리
}
~LuaEngine() {
if (L_) {
lua_close(L_);
L_ = nullptr;
}
}
lua_State* getState() const { return L_; }
};
luaL_newstate()는 새 Lua VM을 만들고, luaL_openlibs(L)는 base·table·string·math·io·os 등 표준 라이브러리를 모두 엽니다. 신뢰할 수 없는 스크립트를 돌린다면 io, os까지 열지 말고 뒤의 샌드박싱 절처럼 필요한 라이브러리만 엽니다. lua_close(L) 이후에는 L을 쓰면 안 됩니다. 또 Lua 헤더는 C로 작성되어 있으므로 extern "C"로 감싸거나, C++용으로 빌드된 Lua라면 lua.hpp를 포함해야 링크 에러가 나지 않습니다.
스택 인덱스 규칙
Lua 스택은 1-based입니다. 스택 바닥은 1, 꼭대기는 lua_gettop(L)로 얻습니다.
| 인덱스 | 의미 |
|---|---|
| 1 | 스택 바닥 (가장 먼저 푸시된 값) |
| -1 | 스택 꼭대기 (가장 최근 푸시된 값) |
| -2 | 꼭대기에서 두 번째 |
// 스택 크기 확인
int top = lua_gettop(L);
// 인덱스 변환: 절대 인덱스 ↔ 상대 인덱스
// 양수: 바닥부터 1, 2, 3, ...
// 음수: 꼭대기부터 -1, -2, -3, ...
푸시·조회·조작 스택 연산
푸시 연산 (C++ → 스택)
// 정수
lua_pushinteger(L, 42);
// 부동소수
lua_pushnumber(L, 3.14);
// 문자열 (Lua가 내부 복사본 보관)
lua_pushstring(L, "hello");
// 불리언
lua_pushboolean(L, 1); // true
lua_pushboolean(L, 0); // false
// nil
lua_pushnil(L);
// C 함수를 Lua에 등록
lua_pushcfunction(L, my_c_function);
// light userdata (Lua가 GC하지 않음, 포인터만 저장)
lua_pushlightuserdata(L, ptr);
// nil 반환 (반환값 없을 때)
// return 0;
조회 연산 (스택 → C++)
// 타입 확인
int type = lua_type(L, index);
// LUA_TNIL, LUA_TBOOLEAN, LUA_TLIGHTUSERDATA, LUA_TNUMBER,
// LUA_TSTRING, LUA_TTABLE, LUA_TFUNCTION, LUA_TUSERDATA, LUA_TTHREAD
// 값 읽기 (타입 확인 후)
lua_Integer ival = lua_tointeger(L, index);
lua_Number nval = lua_tonumber(L, index);
const char* sval = lua_tostring(L, index); // Lua가 소유, 수정 금지
bool bval = lua_toboolean(L, index);
void* pval = lua_touserdata(L, index);
// 안전한 조회 (타입 불일치 시 에러)
lua_Integer ival = luaL_checkinteger(L, 1); // 인자 1이 정수가 아니면 에러
lua_Number nval = luaL_checknumber(L, 2);
const char* sval = luaL_checkstring(L, 3);
// 선택적 조회 (기본값 사용)
lua_Integer ival = luaL_optinteger(L, 1, 0); // 없으면 0
const char* sval = luaL_optstring(L, 2, "");
스택 조작
// 꼭대기 값 제거 (1개)
lua_pop(L, 1);
// 인덱스 값을 꼭대기로 복사
lua_pushvalue(L, index);
// 스택 크기 설정 (늘리거나 줄임)
lua_settop(L, new_top);
// 인덱스 삽입 (해당 위치에 꼭대기 값 이동)
lua_insert(L, index);
// 스택 n개 제거
lua_pop(L, n); // lua_settop(L, -(n)-1)와 동일
C++ 함수와 객체를 Lua에 노출하기
C++ 함수를 Lua에 등록
// C 함수 시그니처: int lua_cfunc(lua_State* L)
// 반환값: 스택에 남길 값의 개수
static int lua_add(lua_State* L) {
lua_Integer a = luaL_checkinteger(L, 1);
lua_Integer b = luaL_checkinteger(L, 2);
lua_pushinteger(L, a + b);
return 1; // 반환값 1개
}
static int lua_log(lua_State* L) {
const char* msg = luaL_checkstring(L, 1);
printf("[Lua] %s\n", msg);
return 0; // 반환값 없음
}
void register_api(lua_State* L) {
lua_register(L, "add", lua_add);
lua_register(L, "log", lua_log);
// lua_register는 lua_pushcfunction + lua_setglobal과 동일
}
upvalue로 C++ 객체 전달
Lua C 함수는 upvalue로 외부 데이터를 받을 수 있습니다. lua_pushcclosure로 클로저를 만들 때 upvalue를 묶습니다.
struct GameEngine { int createEntity(); }; // 실제 엔진 클래스라고 가정
static int lua_create_entity(lua_State* L) {
// upvalue 1에서 GameEngine* 가져옴
auto* engine = static_cast<GameEngine*>(lua_touserdata(L, lua_upvalueindex(1)));
if (!engine) return 0;
int entity_id = engine->createEntity();
lua_pushinteger(L, entity_id);
return 1;
}
void register_entity_api(lua_State* L, GameEngine* engine) {
lua_pushlightuserdata(L, engine); // upvalue로 전달
lua_pushcclosure(L, lua_create_entity, 1); // upvalue 1개
lua_setglobal(L, "create_entity");
}
C++ 구조체/객체를 Lua 테이블로 전달
struct Vec2 {
float x, y;
};
static int push_vec2(lua_State* L, const Vec2& v) {
lua_createtable(L, 0, 2);
lua_pushnumber(L, v.x);
lua_setfield(L, -2, "x");
lua_pushnumber(L, v.y);
lua_setfield(L, -2, "y");
return 1; // 테이블 1개 푸시
}
C++에서 Lua 함수 호출하고 값 읽기
Lua 함수 호출 (C++에서)
bool call_lua_function(lua_State* L, const char* func_name, int a, int b) {
lua_getglobal(L, func_name);
if (!lua_isfunction(L, -1)) {
lua_pop(L, 1);
return false;
}
lua_pushinteger(L, a);
lua_pushinteger(L, b);
// lua_pcall(L, 인자 개수, 반환값 개수, 에러 핸들러 인덱스)
if (lua_pcall(L, 2, 1, 0) != LUA_OK) {
fprintf(stderr, "Lua error: %s\n", lua_tostring(L, -1));
lua_pop(L, 1);
return false;
}
lua_Integer result = lua_tointeger(L, -1);
lua_pop(L, 1);
printf("Result: %lld\n", (long long)result);
return true;
}
Lua 테이블에서 C++로 값 읽기
// Lua: config = { damage = 100, speed = 5.0 }
void read_config(lua_State* L) {
lua_getglobal(L, "config");
if (!lua_istable(L, -1)) {
lua_pop(L, 1);
return;
}
lua_getfield(L, -1, "damage");
int damage = lua_tointeger(L, -1);
lua_pop(L, 1);
lua_getfield(L, -1, "speed");
double speed = lua_tonumber(L, -1);
lua_pop(L, 1);
lua_pop(L, 1); // config 테이블 제거
printf("damage=%d, speed=%.1f\n", damage, speed);
}
Lua 반환값 처리
// Lua: return a, b, c
// C++에서 여러 반환값 받기: 호출 전 스택이 비어 있고
// lua_pcall(L, nargs, LUA_MULTRET, 0)으로 호출했다고 가정
void handle_multiple_returns(lua_State* L) {
int nresults = lua_gettop(L); // 반환값 개수 (호출 전 높이가 0일 때만 성립)
if (nresults >= 1) {
lua_Integer a = lua_tointeger(L, 1);
// ...
}
if (nresults >= 2) {
lua_Number b = lua_tonumber(L, 2);
// ...
}
lua_settop(L, 0); // 스택 비우기
}
C++에서 Lua 테이블 만들고 순회하기
C++에서 Lua 테이블 생성
// Lua: t = { x = 10, y = 20, name = "player" }
void create_table(lua_State* L) {
lua_createtable(L, 0, 3); // 배열 부분 0, 해시 부분 3
lua_pushinteger(L, 10);
lua_setfield(L, -2, "x");
lua_pushinteger(L, 20);
lua_setfield(L, -2, "y");
lua_pushstring(L, "player");
lua_setfield(L, -2, "name");
lua_setglobal(L, "t");
}
배열 형태 테이블
// Lua: arr = { 10, 20, 30 }
void create_array(lua_State* L) {
lua_createtable(L, 3, 0); // 배열 3개
lua_pushinteger(L, 10);
lua_rawseti(L, -2, 1);
lua_pushinteger(L, 20);
lua_rawseti(L, -2, 2);
lua_pushinteger(L, 30);
lua_rawseti(L, -2, 3);
lua_setglobal(L, "arr");
}
테이블 순회
// Lua: for k, v in pairs(t) do ... end
// C++에서 테이블 순회
void iterate_table(lua_State* L, int table_index) {
// 음수 인덱스(-1 등)는 아래에서 키를 push하면 가리키는 대상이 바뀌므로 절대 인덱스로 바꿔 둔다
table_index = lua_absindex(L, table_index);
lua_pushnil(L); // 첫 번째 키로 nil = 시작
while (lua_next(L, table_index) != 0) {
// 스택: ... key value
// key는 -2, value는 -1
if (lua_type(L, -2) == LUA_TSTRING) {
const char* key = lua_tostring(L, -2);
if (lua_isnumber(L, -1)) {
lua_Number val = lua_tonumber(L, -1);
printf("%s = %g\n", key, val);
}
}
lua_pop(L, 1); // value 제거, key는 다음 next용으로 유지
}
}
키를 읽을 때 lua_tostring을 숫자 키에 직접 호출하면 스택의 키 값이 문자열로 바뀌어 다음 lua_next가 혼란에 빠집니다. 그래서 위 코드는 타입이 문자열인 키에만 lua_tostring을 쓰고, 숫자 키를 문자열로 출력하려면 lua_pushvalue로 복사본을 만든 뒤 변환합니다.
Lua 테이블에서 C++로 구조체 읽기
struct Balance {
int damage;
double speed;
std::string name;
};
bool read_balance_from_lua(lua_State* L, const char* table_name, Balance& out) {
lua_getglobal(L, table_name);
if (!lua_istable(L, -1)) {
lua_pop(L, 1);
return false;
}
lua_getfield(L, -1, "damage");
out.damage = static_cast<int>(luaL_optinteger(L, -1, 0));
lua_pop(L, 1);
lua_getfield(L, -1, "speed");
out.speed = static_cast<double>(luaL_optnumber(L, -1, 1.0));
lua_pop(L, 1);
lua_getfield(L, -1, "name");
out.name = luaL_optstring(L, -1, "");
lua_pop(L, 1);
lua_pop(L, 1); // 테이블 제거
return true;
}
require로 로드한 모듈에서 테이블 가져오기
-- balance.lua
return {
damage = 100,
speed = 5.0,
name = "default"
}
// C++에서 balance.lua 로드 후 테이블 사용
bool load_balance(lua_State* L, const std::string& path) {
if (luaL_dofile(L, path.c_str()) != LUA_OK) {
fprintf(stderr, "%s\n", lua_tostring(L, -1));
lua_pop(L, 1);
return false;
}
// 스택 꼭대기에 return된 테이블이 있음
if (!lua_istable(L, -1)) {
lua_pop(L, 1);
return false;
}
lua_setglobal(L, "balance"); // balance라는 이름으로 저장
return true;
}
게임 엔진 API와 밸런스 테이블 로드 예제
게임 엔진 API 전체
// game_lua.cpp
extern "C" {
#include <lua.h>
#include <lualib.h>
#include <lauxlib.h>
}
#include <string>
#include <unordered_map>
#include <memory>
#include <cstdio>
struct Entity {
int id;
float x, y;
std::string tag;
};
class EntityManager {
int next_id_ = 0;
std::unordered_map<int, Entity> entities_;
public:
Entity& create_entity() {
int id = next_id_++;
entities_[id] = Entity{id, 0, 0, ""};
return entities_[id];
}
Entity* get_entity(int id) {
auto it = entities_.find(id);
return it != entities_.end() ? &it->second : nullptr;
}
void destroy_entity(int id) { entities_.erase(id); }
};
class GameScripting {
lua_State* L_;
std::unique_ptr<EntityManager> entities_;
int score_ = 0;
static int lua_create_entity(lua_State* L) {
auto* self = static_cast<GameScripting*>(lua_touserdata(L, lua_upvalueindex(1)));
auto& e = self->entities_->create_entity();
lua_pushinteger(L, e.id);
return 1;
}
static int lua_set_position(lua_State* L) {
auto* self = static_cast<GameScripting*>(lua_touserdata(L, lua_upvalueindex(1)));
int id = static_cast<int>(luaL_checkinteger(L, 1));
float x = static_cast<float>(luaL_checknumber(L, 2));
float y = static_cast<float>(luaL_checknumber(L, 3));
auto* e = self->entities_->get_entity(id);
if (e) {
e->x = x;
e->y = y;
}
return 0;
}
static int lua_add_score(lua_State* L) {
auto* self = static_cast<GameScripting*>(lua_touserdata(L, lua_upvalueindex(1)));
int delta = static_cast<int>(luaL_checkinteger(L, 1));
self->score_ += delta;
return 0;
}
static int lua_destroy_entity(lua_State* L) {
auto* self = static_cast<GameScripting*>(lua_touserdata(L, lua_upvalueindex(1)));
int id = static_cast<int>(luaL_checkinteger(L, 1));
self->entities_->destroy_entity(id);
return 0;
}
public:
GameScripting() : entities_(std::make_unique<EntityManager>()) {
L_ = luaL_newstate();
luaL_openlibs(L_);
lua_pushlightuserdata(L_, this);
lua_pushcclosure(L_, lua_create_entity, 1);
lua_setglobal(L_, "create_entity");
lua_pushlightuserdata(L_, this);
lua_pushcclosure(L_, lua_set_position, 1);
lua_setglobal(L_, "set_position");
lua_pushlightuserdata(L_, this);
lua_pushcclosure(L_, lua_add_score, 1);
lua_setglobal(L_, "add_score");
lua_pushlightuserdata(L_, this);
lua_pushcclosure(L_, lua_destroy_entity, 1);
lua_setglobal(L_, "destroy_entity");
}
~GameScripting() { lua_close(L_); }
bool run_file(const std::string& path) {
if (luaL_dofile(L_, path.c_str()) != LUA_OK) {
fprintf(stderr, "Lua error: %s\n", lua_tostring(L_, -1));
lua_pop(L_, 1);
return false;
}
return true;
}
void fire_collision(int a, int b) {
lua_getglobal(L_, "on_collision");
if (lua_isfunction(L_, -1)) {
lua_pushinteger(L_, a);
lua_pushinteger(L_, b);
if (lua_pcall(L_, 2, 0, 0) != LUA_OK) {
fprintf(stderr, "on_collision error: %s\n", lua_tostring(L_, -1));
lua_pop(L_, 1);
}
} else {
lua_pop(L_, 1);
}
}
int get_score() const { return score_; }
};
Lua 게임 로직 스크립트
-- init.lua: 게임 초기화
local player = create_entity()
set_position(player, 100, 200)
local coin = create_entity()
set_position(coin, 150, 250)
-- collision_handler.lua: 충돌 시
function on_collision(a_id, b_id)
add_score(10)
destroy_entity(b_id)
end
밸런스 테이블 로드
-- balance.lua
return {
skill_damage = 120,
move_speed = 5.0,
drop_rate = 0.15,
levels = { 100, 250, 500, 1000 }
}
// C++에서 밸런스 로드
struct GameBalance {
int skill_damage;
double move_speed;
double drop_rate;
std::vector<int> levels;
};
bool load_balance(lua_State* L, const std::string& path, GameBalance& out) {
if (luaL_dofile(L, path.c_str()) != LUA_OK) {
fprintf(stderr, "%s\n", lua_tostring(L, -1));
lua_pop(L, 1); // 에러 메시지를 스택에서 제거
return false;
}
if (!lua_istable(L, -1)) {
lua_pop(L, 1);
return false;
}
lua_getfield(L, -1, "skill_damage");
out.skill_damage = static_cast<int>(lua_tointeger(L, -1));
lua_pop(L, 1);
lua_getfield(L, -1, "move_speed");
out.move_speed = lua_tonumber(L, -1);
lua_pop(L, 1);
lua_getfield(L, -1, "drop_rate");
out.drop_rate = lua_tonumber(L, -1);
lua_pop(L, 1);
lua_getfield(L, -1, "levels");
if (lua_istable(L, -1)) {
int len = static_cast<int>(lua_rawlen(L, -1));
out.levels.reserve(len);
for (int i = 1; i <= len; ++i) {
lua_rawgeti(L, -1, i);
out.levels.push_back(static_cast<int>(lua_tointeger(L, -1)));
lua_pop(L, 1);
}
}
lua_pop(L, 1);
lua_pop(L, 1); // balance 테이블
return true;
}
CMake 빌드
# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(LuaGame LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
find_package(PkgConfig REQUIRED)
pkg_check_modules(LUA REQUIRED lua5.4)
add_executable(game_app main.cpp game_lua.cpp)
target_include_directories(game_app PRIVATE ${LUA_INCLUDE_DIRS})
target_link_libraries(game_app PRIVATE ${LUA_LIBRARIES})
# Ubuntu/Debian
sudo apt install liblua5.4-dev
# vcpkg
vcpkg install lua
nil 호출·인자 타입·스택 불균형 문제
”attempt to call a nil value (global ‘create_entity’)”
원인: C++에서 create_entity를 Lua에 등록하기 전에 스크립트가 실행됐거나, 등록 시 전역 이름이 다릅니다.
해결법:
// ✅ API 등록 후 스크립트 실행
void GameScripting::init() {
register_api(); // create_entity 등 등록
run_file("init.lua"); // 그 다음 스크립트 실행
}
-- ❌ 잘못된 예: create_entity 호출 시점에 아직 등록 안 됨
local id = create_entity() -- nil 호출 에러
“bad argument #1 to ‘set_position’ (number expected, got nil)”
원인: Lua에서 set_position(entity_id, x, y) 호출 시 entity_id가 nil이거나 잘못된 타입입니다.
해결법:
-- ❌ 잘못된 예
set_position(nil, 100, 200)
-- ✅ 올바른 예
local id = create_entity()
set_position(id, 100, 200)
// C++에서 방어 코드
static int lua_set_position(lua_State* L) {
if (lua_gettop(L) < 3) {
return luaL_error(L, "set_position(entity_id, x, y) requires 3 arguments");
}
if (!lua_isnumber(L, 1)) {
return luaL_error(L, "entity_id must be a number");
}
// ...
}
스택 오버플로우 / 불균형
원인: lua_push*와 lua_pop 개수가 맞지 않아 스택이 쌓이거나 부족합니다.
해결법:
// ✅ 반환값 개수 정확히
lua_pushinteger(L, result);
return 1; // 1개 반환
// ✅ 에러 시 스택 정리
if (lua_pcall(L_, 2, 0, 0) != LUA_OK) {
fprintf(stderr, "%s\n", lua_tostring(L_, -1));
lua_pop(L_, 1); // 에러 메시지 제거
}
// ✅ 호출 전후 스택 높이 일치 확인
int top_before = lua_gettop(L);
// ... 작업 ...
lua_settop(L, top_before); // 복원
Lua userdata가 가리키는 C++ 객체 수명
원인: Lua userdata가 가리키는 C++ 객체가 먼저 파괴되면, Lua에서 접근 시 크래시가 발생합니다. 해결법:
// shared_ptr을 userdata로 저장하며, __gc 메타메서드에서 정리
// 또는 Lua가 참조하는 동안 C++ 객체 수명을 연장 (예: 엔진이 소유)
// lightuserdata는 Lua가 GC하지 않으므로, C++ 측에서 수명 관리 필수
“module ‘xxx’ not found”
원인: Lua에서 require "mymodule"을 썼는데, package.path에 해당 경로가 없습니다.
해결법:
-- Lua 스크립트 상단에서 경로 추가 (문자열 연결 연산자는 ..)
package.path = package.path .. ";./scripts/?.lua"
// C++에서 package.path 설정
lua_getglobal(L_, "package"); // 스택: package
lua_getfield(L_, -1, "path"); // 스택: package, path
std::string path = lua_tostring(L_, -1);
path += ";./scripts/?.lua";
lua_pop(L_, 1); // 스택: package
lua_pushstring(L_, path.c_str()); // 스택: package, newpath
lua_setfield(L_, -2, "path"); // package.path = newpath, 스택: package
lua_pop(L_, 1); // 스택: (원래 상태)
lua_tostring 반환값 수명
원인: lua_tostring(L, i) 반환값은 Lua가 관리합니다. lua_pop 후에는 무효화됩니다.
해결법:
// ❌ 잘못된 예
const char* s = lua_tostring(L, -1);
lua_pop(L, 1);
printf("%s\n", s); // s는 이미 무효화됐을 수 있음
// ✅ 올바른 예: 즉시 복사
std::string str = lua_tostring(L, -1);
lua_pop(L, 1);
printf("%s\n", str.c_str());
lua_next 사용 시 테이블 무결성
원인: Lua 매뉴얼에 따르면 lua_next(그리고 Lua의 next) 순회 중에 테이블에 없던 필드를 새로 대입하면 다음 next의 동작이 정의되지 않습니다. 기존 필드의 값을 바꾸거나 nil을 대입해 지우는 것은 허용됩니다.
해결법: 새 키를 추가해야 한다면 순회할 키들을 먼저 수집한 뒤 순회가 끝나고 추가합니다.
스택 균형·luaL_check·traceback 사용 원칙
스택 균형 유지
- 모든 C 함수에서
lua_push*와return n개수가 일치해야 합니다. - 에러 시
lua_pop으로 스택 정리 후return 0또는lua_error.
luaL_check* / luaL_opt* 사용
lua_tointeger대신luaL_checkinteger로 타입 검증.- 잘못된 인자 시 Lua가 에러 메시지와 함께 중단.
upvalue로 상태 전달
- 전역 변수 대신 upvalue로
this포인터를 전달하면 여러lua_State나 엔진 인스턴스를 동시에 써도 서로 섞이지 않습니다. 단,lua_State하나는 스레드 안전하지 않으므로 한 번에 한 스레드에서만 접근해야 합니다.
에러 핸들러 (traceback)
// debug 라이브러리를 열지 않은 샌드박스에서도 동작하도록 C API의 luaL_traceback 사용
static int traceback(lua_State* L) {
const char* msg = lua_tostring(L, 1);
luaL_traceback(L, L, msg ? msg : "(error object is not a string)", 1);
return 1;
}
// 사용: 함수와 인자를 push하기 전에 핸들러를 먼저 넣어 두고 그 인덱스를 pcall에 넘긴다
// lua_pushcfunction(L, traceback);
// int errfunc = lua_gettop(L);
// lua_getglobal(L, "update"); /* 인자 push */
// lua_pcall(L, nargs, nresults, errfunc);
// lua_remove(L, errfunc);
스크립트 사전 컴파일
// 반복 실행 시 load 한 번, pcall 여러 번
if (luaL_loadfile(L_, "update.lua") != LUA_OK) { /* 문법 에러 처리 */ }
int chunk = lua_gettop(L_); // 컴파일된 청크를 스택에 보관
// 매 프레임: 청크를 복사해 호출 (pcall이 함수를 소비하므로)
lua_pushvalue(L_, chunk);
if (lua_pcall(L_, 0, 0, 0) != LUA_OK) { /* 에러 메시지 처리 후 pop */ }
local 사용 권장
-- ❌ 전역 변수: 접근할 때마다 _ENV 테이블 조회, 다른 스크립트와 이름 충돌 위험
player_id = create_entity()
-- ✅ 로컬 변수: 레지스터에 있어 조회가 빠르고 스코프가 명확
local player_id = create_entity()
샌드박싱·타임아웃·핫 리로드
샌드박싱
// 필요한 라이브러리만 연다. Lua 5.2+에서는 luaopen_*를 직접 호출하지 말고
// luaL_requiref로 열어야 전역 이름과 package.loaded가 올바르게 설정된다
lua_State* L = luaL_newstate();
luaL_requiref(L, "_G", luaopen_base, 1); lua_pop(L, 1);
luaL_requiref(L, LUA_TABLIBNAME, luaopen_table, 1); lua_pop(L, 1);
luaL_requiref(L, LUA_STRLIBNAME, luaopen_string, 1); lua_pop(L, 1);
luaL_requiref(L, LUA_MATHLIBNAME, luaopen_math, 1); lua_pop(L, 1);
// io, os, package, debug는 열지 않음
// base 라이브러리에도 파일을 읽는 dofile·loadfile과 바이트코드를 받을 수 있는 load가 있으므로 제거
for (const char* name : {"dofile", "loadfile", "load"}) {
lua_pushnil(L);
lua_setglobal(L, name);
}
Lua 바이트코드는 검증되지 않으므로, 신뢰할 수 없는 입력을 load로 바이트코드 모드로 실행하게 두면 VM 메모리를 손상시킬 수 있습니다. 소스만 받아야 한다면 luaL_loadbufferx에 모드 "t"를 지정합니다.
스크립트 타임아웃
#include <chrono>
// 호출마다 마감 시각을 정하고, 훅에서 그 시각을 넘었는지 확인
static std::chrono::steady_clock::time_point g_deadline; // lua_State가 여럿이면 extra space 등에 보관
static void lua_hook(lua_State* L, lua_Debug* ar) {
(void)ar;
if (std::chrono::steady_clock::now() > g_deadline) {
luaL_error(L, "script timeout");
}
}
g_deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(50);
lua_sethook(L_, lua_hook, LUA_MASKCOUNT, 10000); // 명령 1만 개마다 훅 호출
lua_pcall(L_, 0, 0, 0);
lua_sethook(L_, nullptr, 0, 0);
static int count처럼 함수 안 정적 카운터를 쓰면 호출이 끝나도 값이 리셋되지 않아, 누적 명령 수가 한도를 넘은 뒤에는 모든 스크립트 호출이 즉시 타임아웃됩니다. 호출 단위의 마감 시각이나 카운터를 호출 전에 초기화해야 합니다. 또 명령 수 훅은 Lua 명령만 세므로, C 함수(예: 긴 string.rep) 안에서 오래 머무는 경우는 막지 못합니다.
핫 리로드
void GameScripting::reload_script(const std::string& path) {
if (luaL_dofile(L_, path.c_str()) != LUA_OK) {
// 문법 에러가 있는 파일을 저장한 경우: 기존 함수 정의는 그대로 남아 게임은 계속 동작
log_error("Reload failed: %s", lua_tostring(L_, -1));
lua_pop(L_, 1);
return;
}
}
리로드는 파일을 다시 실행해 전역 함수 정의를 덮어쓰는 것이므로, 스크립트 최상위에 player = create_entity() 같은 상태 생성 코드가 있으면 리로드할 때마다 엔티티가 새로 생깁니다. 함수 정의만 담은 파일과 초기화 코드를 분리하고, 다른 곳에서 함수를 지역 변수로 잡아 둔(local f = on_collision) 참조는 리로드 후에도 예전 함수를 가리킨다는 점을 기억해야 합니다.
Sol3로 같은 일을 하기
Sol3는 위의 스택 조작을 템플릿으로 감춰 줍니다. 아래는 함수 등록, 클래스 바인딩, 안전한 실행을 한 번에 보여 주는 예입니다.
#define SOL_ALL_SAFETIES_ON 1
#include <sol/sol.hpp>
#include <cmath>
struct Vec2 {
float x = 0, y = 0;
Vec2() = default;
Vec2(float x_, float y_) : x(x_), y(y_) {}
float length() const { return std::sqrt(x * x + y * y); }
};
int main() {
sol::state lua;
lua.open_libraries(sol::lib::base, sol::lib::math);
lua.set_function("add", [](int a, int b) { return a + b; });
lua.new_usertype<Vec2>("Vec2",
sol::constructors<Vec2(), Vec2(float, float)>(),
"x", &Vec2::x, "y", &Vec2::y,
"length", &Vec2::length);
auto result = lua.safe_script(R"(
local v = Vec2.new(3, 4)
return add(1, 2) + v:length()
)", sol::script_pass_on_error);
if (result.valid()) {
double value = result; // 8
} else {
sol::error err = result; // 에러 메시지
}
}
SOL_ALL_SAFETIES_ON은 인자 타입 검사 등을 켜서 잘못된 호출이 크래시 대신 Lua 에러가 되게 합니다. Sol3는 Lua 5.1~5.4와 LuaJIT을 지원하고 C++17이 필요합니다.
LuaJIT 고려
- LuaJIT은 자주 실행되는 루프를 추적(trace)해 기계어로 컴파일하므로 숫자 계산·반복문이 많은 스크립트에서 크게 빨라집니다. 반면 JIT이 지원하지 않는 기능(NYI)을 쓰거나 C API(
lua_call등)로 C++과 자주 오가는 코드는 인터프리터로 돌아가 이득이 작습니다. C 함수 호출은 FFI로 바꾸면 JIT 대상이 됩니다. - LuaJIT은 Lua 5.1 C API를 제공하므로 5.3·5.4 전용 API(
lua_absindex는 5.2+, 정수 서브타입 등)를 쓴 코드는 수정이 필요합니다.
자주 묻는 질문 (FAQ)
Q. lua_tostring으로 받은 const char*를 lua_pop 뒤에 쓰면 왜 위험한가요?
A. lua_tostring이 반환하는 포인터는 Lua 스택에 있는 문자열 값의 내부 버퍼를 가리키며, 그 메모리는 Lua 가비지 컬렉터가 관리합니다. lua_pop으로 값을 스택에서 제거하면 해당 문자열이 수집될 수 있어 포인터가 댕글링 상태가 됩니다. 값을 스택에서 꺼내기 전에 std::string으로 복사해 두는 것이 안전합니다.
참고 자료
- Lua 5.4 Reference Manual
- Programming in Lua (4th ed.)
- Lua C API 간단 참조
- C++ 시리즈 #55-2: 동적 로딩
- C++ 시리즈 #55-1: 플러그인 시스템
같이 보면 좋은 글
- C++에 스크립트 엔진 붙이기
- pybind11로 C++와 Python 연결하기
- C++에 V8 임베딩하기
- C++ 플러그인 시스템 만들기: dlopen·LoadLibrary, C ABI 인터페이스, 핫 리로드