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으로 복사해 두는 것이 안전합니다.

참고 자료


같이 보면 좋은 글