C++ 핫 리로드 구현: 공유 라이브러리 재로딩, inotify·FSEvents 파일 감시, 상태 보존

들어가며: 한 줄 고칠 때마다 재시작해야 하나요?

게임에서 NPC의 공격 거리를 5에서 7로 바꿔 보고 싶을 뿐인데, 빌드하고 실행하고 테스트 지점까지 다시 이동하는 데 몇 분이 걸린다면 실험을 여러 번 해 보기 어렵습니다. C++은 스크립트 언어처럼 실행 중에 코드를 바꿔 끼우는 기능이 언어 차원에서 없기 때문에, 이 반복 시간이 개발 속도를 크게 좌우합니다.

핫 리로드는 실행 중인 프로그램을 종료하지 않고, 수정된 공유 라이브러리(.so, .dylib, .dll)를 다시 로드해 변경을 반영하는 기법입니다. 원리는 단순합니다. 자주 바뀌는 코드를 라이브러리로 분리하고, 새 빌드가 나오면 이전 라이브러리를 내리고 새 라이브러리를 올립니다. 하지만 실제로 구현하면 플랫폼마다 다른 함정이 있습니다. Windows는 로드된 DLL 파일을 잠가서 빌드가 실패하고, Linux의 GCC 빌드는 dlclose를 해도 라이브러리가 내려가지 않는 경우가 있으며, 리로드 순간에 이전 코드를 실행 중인 스레드가 있으면 크래시가 납니다. 게임이라면 리로드 후에도 월드 상태가 유지되어야 한다는 요구도 있습니다.

이 글에서는 플러그인 경계 설계, 로더와 파일 감시 구현, 안전한 교체 순서, 상태 보존 방법을 차례로 다룹니다.


리로드할 수 있는 코드와 없는 코드

핫 리로드의 흐름은 다음과 같습니다.

sequenceDiagram
    participant W as 파일 감시
    participant App as 호스트 메인 루프
    participant Old as 기존 라이브러리
    participant New as 새 라이브러리
    W->>App: 새 빌드 감지(플래그 설정)
    App->>App: 프레임 경계까지 대기
    App->>New: 사본 복사 후 dlopen / LoadLibrary
    App->>New: 심볼 조회, 버전 검사
    App->>App: 함수 테이블 교체
    App->>Old: 정리 후 dlclose / FreeLibrary

이 흐름이 성립하려면 호스트와 플러그인의 경계가 명확해야 합니다. 호스트 실행 파일 자체는 리로드할 수 없고, 호스트가 플러그인의 함수를 직접 링크해서 호출하는 코드도 교체되지 않습니다. 리로드 대상 코드는 호스트가 dlsym/GetProcAddress로 얻은 함수 포인터를 통해서만 호출되어야 합니다.

경계에는 C ABI를 쓰는 것이 원칙입니다. 호스트와 플러그인이 std::string이나 std::vector 같은 C++ 타입을 주고받으면, 두 쪽이 다른 컴파일러 옵션이나 표준 라이브러리 버전으로 빌드되었을 때 객체 레이아웃이 어긋날 수 있습니다. 개발 중 핫 리로드라면 같은 툴체인으로 빌드하므로 실제로 문제가 되는 경우는 드물지만, C 함수 포인터와 POD 구조체만 쓰면 이런 걱정 자체가 사라집니다. 메모리도 할당한 쪽이 해제하도록 create와 destroy를 쌍으로 두면, Windows에서 호스트와 플러그인이 서로 다른 C 런타임을 쓰는 경우에도 힙이 섞이지 않습니다.

리로드 후에도 남아 있는 포인터가 가장 흔한 크래시 원인입니다. 플러그인 안의 함수를 가리키는 포인터(콜백 등록 등), 플러그인의 정적 데이터를 가리키는 포인터(문자열 리터럴 포함), 플러그인이 정의한 클래스의 가상 함수 테이블이 모두 라이브러리가 내려가는 순간 무효가 됩니다. 예를 들어 플러그인이 "enemy"라는 문자열 리터럴의 포인터를 호스트의 이름표 테이블에 넘겨 두었다면, 리로드 후 그 포인터는 매핑이 해제된 메모리를 가리킵니다. 호스트가 보관하는 데이터는 모두 호스트 쪽으로 복사해 두어야 합니다.


플러그인 인터페이스

// plugin_interface.h — 호스트와 플러그인이 공유
#pragma once
#include <cstddef>
#include <cstdint>

#define PLUGIN_API_VERSION 1
#define PLUGIN_API_SYMBOL "plugin_api"

#if defined(_WIN32)
#  define PLUGIN_EXPORT __declspec(dllexport)
#else
#  define PLUGIN_EXPORT __attribute__((visibility("default")))
#endif

// C 호환 레이아웃의 함수 테이블. 멤버는 C 타입만 쓴다
struct PluginAPI {
    std::uint32_t version;
    void* (*create)(const char* config);
    void  (*destroy)(void* instance);
    int   (*process)(void* instance, const void* input, std::size_t input_size,
                     void* output, std::size_t output_size);
};

플러그인은 이 구조체 하나를 plugin_api라는 이름으로 내보내고, 호스트는 그 이름으로 구조체 주소를 찾아 함수 포인터를 사용합니다. 함수를 하나씩 dlsym으로 찾는 것보다 테이블 하나를 주고받는 편이 버전 관리가 쉽고, 교체도 포인터 하나로 끝납니다.

// plugin.cpp — 리로드 대상
#include "plugin_interface.h"
#include <cstring>

namespace {

struct PluginState {
    int counter = 0;
};

void* create(const char*) { return new PluginState(); }

void destroy(void* instance) { delete static_cast<PluginState*>(instance); }

int process(void* instance, const void* input, std::size_t input_size,
            void* output, std::size_t output_size) {
    auto* state = static_cast<PluginState*>(instance);
    if (!input || !output || output_size < input_size) return -1;
    ++state->counter;
    std::memcpy(output, input, input_size);   // 이 부분을 고쳐서 리로드를 확인
    return static_cast<int>(input_size);
}

}  // namespace

extern "C" {
PLUGIN_EXPORT PluginAPI plugin_api = {PLUGIN_API_VERSION, create, destroy, process};
}

.version = PLUGIN_API_VERSION처럼 지정 초기화자를 쓰는 예제를 흔히 보는데, 이 문법은 C++20부터 표준이고 C++17에서는 GCC·Clang 확장으로만 동작하며 MSVC에서는 C++20 모드가 필요합니다. 위에서는 순서대로 초기화했습니다. extern "C" { ... } 블록 안에 const를 붙여 선언하면 네임스페이스 범위의 const 변수는 내부 링키지가 되어 심볼이 내보내지지 않으므로, 여기서는 const를 붙이지 않았습니다.


로더: 사본을 로드해 파일 잠금과 핸들 재사용 피하기

핫 리로드 로더에서 가장 중요한 결정은 “원본 파일을 직접 로드하지 않는다”입니다. 빌드 출력 파일을 매번 plugin_1.dll, plugin_2.dll처럼 다른 이름으로 복사한 뒤 그 사본을 로드하면 세 가지 문제가 한꺼번에 해결됩니다.

  • Windows에서는 로드된 DLL 파일이 잠겨 덮어쓸 수 없습니다. 사본을 로드하면 원본은 잠기지 않으므로 빌드가 원본을 그대로 덮어쓸 수 있습니다.
  • dlopen은 이미 열려 있는 라이브러리와 같은 경로를 다시 열면 새로 로드하지 않고 기존 핸들의 참조 카운트만 올립니다. 이름이 매번 다르면 항상 새로 로드됩니다.
  • 새 버전을 먼저 로드해 검증하고, 성공했을 때만 이전 버전을 내릴 수 있습니다. 새 빌드에 문제가 있으면 이전 버전을 그대로 쓰면 됩니다.
// hot_reload_loader.h
#pragma once
#include <filesystem>
#include <stdexcept>
#include <string>
#include <system_error>
#ifdef _WIN32
#  include <windows.h>
#else
#  include <dlfcn.h>
#endif

class HotReloadLoader {
public:
    HotReloadLoader(const std::filesystem::path& original, unsigned generation) {
        namespace fs = std::filesystem;
        fs::path dir = fs::temp_directory_path() / "hotreload";
        fs::create_directories(dir);
        loaded_path_ = dir / (original.stem().string() + "_" + std::to_string(generation)
                              + original.extension().string());
        fs::copy_file(original, loaded_path_, fs::copy_options::overwrite_existing);
#ifdef _WIN32
        handle_ = LoadLibraryW(loaded_path_.c_str());
        if (!handle_) {
            throw std::runtime_error("LoadLibrary failed, error " + std::to_string(GetLastError()));
        }
#else
        handle_ = dlopen(loaded_path_.c_str(), RTLD_NOW | RTLD_LOCAL);
        if (!handle_) throw std::runtime_error(std::string("dlopen failed: ") + dlerror());
#endif
    }

    ~HotReloadLoader() {
        if (handle_) {
#ifdef _WIN32
            FreeLibrary(static_cast<HMODULE>(handle_));
#else
            dlclose(handle_);
#endif
        }
        std::error_code ec;
        std::filesystem::remove(loaded_path_, ec);   // Windows에서는 해제한 뒤에야 지울 수 있다
    }

    HotReloadLoader(const HotReloadLoader&) = delete;
    HotReloadLoader& operator=(const HotReloadLoader&) = delete;

    void* symbol(const char* name) const {
#ifdef _WIN32
        return reinterpret_cast<void*>(GetProcAddress(static_cast<HMODULE>(handle_), name));
#else
        return dlsym(handle_, name);
#endif
    }

private:
    std::filesystem::path loaded_path_;
    void* handle_ = nullptr;
};

RTLD_NOW는 로드 시점에 모든 심볼을 해석하게 해서, 정의되지 않은 심볼이 있는 빌드를 나중에 호출할 때가 아니라 로드할 때 바로 실패하게 합니다. RTLD_LOCAL은 이 라이브러리의 심볼이 이후에 로드되는 다른 라이브러리의 심볼 해석에 쓰이지 않게 해서, 이전 버전과 새 버전이 같은 이름의 심볼을 가져도 서로 섞이지 않게 합니다.

MSVC로 빌드한다면 PDB 파일도 비슷한 문제를 일으킵니다. 디버거가 로드된 DLL의 PDB를 열고 있으면 링커가 같은 이름의 PDB를 다시 쓰지 못해 빌드가 실패할 수 있습니다. 링커 옵션 /PDB로 빌드마다 다른 PDB 이름을 주는 방식이 흔히 쓰입니다.


Linux에서 dlclose가 라이브러리를 내리지 않는 경우

Linux에서 dlclose는 참조 카운트를 줄일 뿐이고, 실제 언로드 여부는 동적 로더가 판단합니다. 핫 리로드를 처음 만들 때 자주 만나는 원인은 GCC의 STB_GNU_UNIQUE 심볼입니다. GCC는 템플릿 클래스의 정적 데이터 멤버나 inline 함수 안의 static 지역 변수처럼 프로그램 전체에서 하나여야 하는 객체를 이 특수한 심볼 타입으로 내보내는데, glibc는 이런 심볼을 가진 라이브러리를 언로드할 수 없는(NODELETE) 것으로 표시합니다. C++ 표준 라이브러리 헤더만 include해도 이런 심볼이 생기기 쉬워서, 평범한 플러그인도 dlclose 후 메모리에 남아 있게 됩니다.

플러그인을 -fno-gnu-unique로 빌드하면 이 심볼 타입을 쓰지 않습니다. Clang은 기본적으로 STB_GNU_UNIQUE를 생성하지 않습니다. readelf -Ws libplugin.so | grep UNIQUE로 확인할 수 있습니다.

if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
    target_compile_options(plugin PRIVATE -fno-gnu-unique)
endif()

이 외에도 비자명한 소멸자를 가진 thread_local 변수를 쓰거나, 다른 라이브러리가 이 플러그인의 심볼을 참조하고 있거나, RTLD_NODELETE로 열린 경우에도 언로드되지 않습니다. 앞 절처럼 매번 다른 이름의 사본을 로드하면, 이전 버전이 메모리에 남더라도 새 버전은 항상 새로 로드되므로 동작에는 문제가 없고 메모리만 조금씩 늘어납니다. 개발 중 핫 리로드라면 받아들일 만한 비용입니다.


파일 감시

빌드가 끝났다는 것을 알아내는 방법은 플랫폼마다 다릅니다. Linux는 inotify, macOS는 FSEvents나 kqueue, Windows는 ReadDirectoryChangesW를 쓰고, 파일 수정 시각을 주기적으로 비교하는 폴링은 어디서나 동작합니다. 감시 대상이 파일 하나뿐이라면 1초 간격의 std::filesystem::last_write_time 폴링으로도 충분한 경우가 많습니다.

inotify로 파일 자체를 감시할 수도 있지만, 링커나 빌드 도구가 임시 파일에 쓴 뒤 rename으로 교체하면 감시가 걸린 inode가 사라져 이후 변경을 놓칩니다. 그래서 디렉터리를 감시하면서 파일 이름으로 거르는 편이 안전하고, 이벤트는 쓰기를 마치고 닫힌 IN_CLOSE_WRITE와 이름 변경으로 들어온 IN_MOVED_TO를 봅니다.

// file_watcher_linux.cpp
#include <atomic>
#include <functional>
#include <string>
#include <thread>
#include <poll.h>
#include <sys/inotify.h>
#include <unistd.h>

class FileWatcher {
public:
    using Callback = std::function<void()>;

    FileWatcher(std::string dir, std::string filename, Callback cb)
        : dir_(std::move(dir)), filename_(std::move(filename)), cb_(std::move(cb)),
          thread_([this] { loop(); }) {}

    ~FileWatcher() {
        running_ = false;
        thread_.join();
    }

private:
    void loop() {
        int fd = inotify_init1(IN_NONBLOCK | IN_CLOEXEC);
        if (fd < 0) return;
        int wd = inotify_add_watch(fd, dir_.c_str(), IN_CLOSE_WRITE | IN_MOVED_TO);
        if (wd < 0) { close(fd); return; }

        alignas(inotify_event) char buf[4096];
        while (running_) {
            pollfd pfd{fd, POLLIN, 0};
            if (poll(&pfd, 1, 200) <= 0) continue;     // 200ms마다 종료 여부 확인
            ssize_t n = read(fd, buf, sizeof(buf));
            for (ssize_t i = 0; i < n; ) {
                auto* ev = reinterpret_cast<const inotify_event*>(buf + i);
                if (ev->len > 0 && filename_ == ev->name) cb_();
                i += static_cast<ssize_t>(sizeof(inotify_event) + ev->len);
            }
        }
        inotify_rm_watch(fd, wd);
        close(fd);
    }

    std::string dir_, filename_;
    Callback cb_;
    std::atomic<bool> running_{true};
    std::thread thread_;   // 다른 멤버가 초기화된 뒤에 시작되도록 마지막에 선언
};

read를 블로킹으로 호출하면 감시 스레드가 이벤트가 올 때까지 멈춰 있어서, 소멸자에서 running_을 false로 바꿔도 스레드가 끝나지 않고 join이 영원히 기다립니다. 위 구현은 poll에 타임아웃을 주어 주기적으로 종료 플래그를 확인합니다. std::thread 멤버를 클래스의 마지막에 선언한 것도 의도적입니다. 멤버는 선언 순서대로 초기화되므로, 스레드가 앞에 있으면 아직 초기화되지 않은 filename_이나 cb_를 스레드가 먼저 읽을 수 있습니다.

빌드 도구는 파일 하나를 만드는 동안에도 여러 번 쓰고 닫거나, 링크 후 strip 같은 후처리를 할 수 있어서 이벤트가 연달아 옵니다. 첫 이벤트에서 바로 로드하면 아직 쓰는 중인 파일을 복사할 수 있으므로, 마지막 이벤트 후 수백 밀리초 동안 조용할 때 리로드하는 디바운스를 둡니다.


호스트: 새 버전을 먼저 올리고 교체하기

// hot_reload_host.h
#pragma once
#include "hot_reload_loader.h"
#include "plugin_interface.h"
#include <iostream>
#include <memory>
#include <mutex>

class HotReloadHost {
public:
    explicit HotReloadHost(std::filesystem::path plugin_path)
        : plugin_path_(std::move(plugin_path)), current_(load()) {}

    ~HotReloadHost() { unload(current_); }

    int process(const void* in, std::size_t in_size, void* out, std::size_t out_size) {
        std::lock_guard lock(mutex_);
        return current_.api->process(current_.instance, in, in_size, out, out_size);
    }

    // 새 버전 로드에 성공했을 때만 교체한다. 실패하면 기존 버전을 계속 쓴다.
    bool reload() {
        Loaded next;
        try {
            next = load();
        } catch (const std::exception& e) {
            std::cerr << "[hot-reload] keep old version: " << e.what() << "\n";
            return false;
        }
        {
            std::lock_guard lock(mutex_);
            std::swap(current_, next);
        }
        unload(next);   // 이전 버전: 더 이상 아무도 호출하지 않는다
        return true;
    }

private:
    struct Loaded {
        std::unique_ptr<HotReloadLoader> loader;
        const PluginAPI* api = nullptr;
        void* instance = nullptr;
    };

    Loaded load() {
        Loaded l;
        l.loader = std::make_unique<HotReloadLoader>(plugin_path_, ++generation_);
        auto* api = static_cast<const PluginAPI*>(l.loader->symbol(PLUGIN_API_SYMBOL));
        if (!api) throw std::runtime_error("plugin_api symbol not found");
        if (api->version != PLUGIN_API_VERSION) throw std::runtime_error("plugin API version mismatch");
        l.instance = api->create("");
        if (!l.instance) throw std::runtime_error("plugin create() failed");
        l.api = api;
        return l;
    }

    static void unload(Loaded& l) {
        if (l.api && l.instance) l.api->destroy(l.instance);
        l.api = nullptr;
        l.instance = nullptr;
        l.loader.reset();   // 인스턴스를 해제한 뒤에 라이브러리를 닫는다
    }

    std::filesystem::path plugin_path_;
    unsigned generation_ = 0;
    std::mutex mutex_;
    Loaded current_;
};

순서가 중요합니다. destroy는 플러그인 안의 코드이므로 라이브러리를 닫기 전에 호출해야 하고, 교체는 process와 같은 뮤텍스 아래에서 해야 다른 스레드가 교체 도중에 반쯤 바뀐 포인터 쌍을 읽지 않습니다. 이전 버전의 정리는 뮤텍스 밖에서 하는데, 교체가 끝난 뒤에는 current_를 통해 이전 버전에 접근할 방법이 없으므로 안전합니다. 단, process가 반환한 포인터나 플러그인이 등록한 콜백처럼 호출이 끝난 뒤에도 이전 라이브러리를 가리키는 것이 남아 있다면 이 보장은 깨집니다. 앞에서 경계를 C 함수 테이블로 좁힌 이유입니다.

// main.cpp
#include "hot_reload_host.h"
#include <atomic>
#include <chrono>
#include <thread>
#include <vector>

int main() {
    using namespace std::chrono_literals;
    HotReloadHost host("./plugins/libplugin.so");

    std::atomic<bool> changed{false};
    FileWatcher watcher("./plugins", "libplugin.so", [&] { changed = true; });

    std::vector<std::uint8_t> input = {1, 2, 3, 4, 5}, output(5);
    bool pending = false;
    auto last_change = std::chrono::steady_clock::now();

    while (true) {
        // 감시 스레드는 플래그만 세우고, 리로드는 메인 루프의 프레임 경계에서 한다
        if (changed.exchange(false)) {
            pending = true;
            last_change = std::chrono::steady_clock::now();
        }
        if (pending && std::chrono::steady_clock::now() - last_change > 300ms) {
            pending = false;
            host.reload();
        }

        int n = host.process(input.data(), input.size(), output.data(), output.size());
        // ... n 사용
        std::this_thread::sleep_for(100ms);
    }
}

감시 콜백 안에서 곧바로 reload()를 호출하지 않는 이유는 두 가지입니다. 콜백은 감시 스레드에서 실행되므로 메인 스레드의 호출과 경쟁하고, 게임 루프라면 프레임 중간에 코드가 바뀌어 한 프레임 안에서 이전 로직과 새 로직이 섞일 수 있습니다. 플래그만 세우고 메인 루프가 프레임 경계에서 처리하면 이 두 문제가 모두 사라집니다.


상태 보존

위 구현은 리로드할 때마다 create로 새 인스턴스를 만들므로 counter 같은 상태가 초기화됩니다. 게임에서 월드 상태가 리로드마다 초기화된다면 핫 리로드의 의미가 절반으로 줄어듭니다. 상태를 보존하는 방법은 크게 두 가지입니다.

첫째는 상태를 호스트가 소유하는 방식입니다. 호스트가 게임 상태 메모리 블록을 할당하고, 플러그인의 update(GameState*, float dt) 같은 함수에 포인터를 넘깁니다. 플러그인에는 상태가 없고 로직만 있으므로, 라이브러리를 교체해도 메모리는 그대로입니다. Handmade Hero 시리즈가 소개한 방식이고, 단일 헤더 라이브러리 cr.h도 비슷한 구조를 씁니다.

struct GameState {
    std::uint32_t layout_version;   // 구조체 레이아웃이 바뀌면 올린다
    float player_x, player_y;
    int   score;
    // 포인터를 두지 않는다. 플러그인 쪽 메모리를 가리키면 리로드 후 무효가 된다
};

struct GameAPI {
    std::uint32_t version;
    void (*update)(GameState* state, float dt);
};

이 방식의 약점은 구조체 레이아웃입니다. 새 코드에서 GameState에 필드를 추가하면 새 코드는 이전 레이아웃으로 채워진 메모리를 다른 오프셋으로 읽습니다. layout_version을 비교해서 다르면 변환 함수를 거치거나, 개발 중이라면 그냥 상태를 새로 초기화하는 것이 현실적입니다. 필드를 항상 구조체 끝에 추가하고 여유 공간을 미리 잡아 두는 식으로 충돌을 줄이기도 합니다.

둘째는 직렬화입니다. 리로드 직전에 이전 버전의 save_state 함수로 상태를 바이트 배열로 저장하고, 새 버전의 load_state로 복원합니다. 레이아웃 변경에 강하고 형식을 명시적으로 관리할 수 있지만, 모든 상태에 대해 직렬화 코드를 유지해야 하므로 작성 비용이 큽니다.

플러그인 안의 전역 변수와 static 지역 변수는 라이브러리와 함께 사라진다는 점도 기억해야 합니다. 로드할 때마다 새로 초기화되므로, 플러그인 내부에 캐시나 싱글턴을 두면 리로드 후 비어 있게 됩니다.


플랫폼별로 자주 만나는 문제

Linux에서 “cannot open shared object file”이 나오면 두 가지를 확인합니다. dlopen에 /가 없는 이름을 주면 LD_LIBRARY_PATH와 기본 경로에서 찾으므로, 상대 경로라도 ./plugins/libplugin.so처럼 /를 포함해야 현재 작업 디렉터리 기준으로 찾습니다. 경로가 맞는데도 실패한다면 플러그인이 의존하는 다른 라이브러리를 찾지 못하는 경우로, ldd libplugin.so로 “not found” 항목을 확인하고 플러그인에 $ORIGIN 기준 RPATH를 설정합니다.

macOS에서는 “Library not loaded” 에러가 대개 경로 문제입니다. .dylib에 기록된 install name이나 의존 라이브러리의 경로가 맞지 않을 때 나오므로 otool -L로 확인합니다. 코드 서명은 별개의 문제입니다. Apple Silicon에서는 모든 arm64 코드에 서명이 필요한데 링커가 ad-hoc 서명을 자동으로 붙이므로 보통은 신경 쓸 필요가 없지만, 빌드 후 바이너리를 수정(strip 등)하면 서명이 깨져 로드 시 프로세스가 종료될 수 있습니다. 이때는 codesign -s - -f libplugin.dylib로 다시 ad-hoc 서명합니다.

리로드 후 “undefined symbol”이 나온다면 플러그인이 호스트의 함수나 전역 변수를 직접 참조하는 경우가 많습니다. 호스트 기능이 필요하면 로그 함수 같은 것을 담은 함수 포인터 구조체를 create 시점에 넘겨 주는 편이 의존 방향이 명확합니다.


직접 만들지 않는 선택지

여기서 만든 방식은 “모듈 단위 재로드”입니다. 호스트와 플러그인의 경계를 설계해야 하고, 리로드할 코드는 반드시 플러그인 안에 있어야 합니다. 이 제약 없이 아무 함수나 수정하고 싶다면 다른 접근을 쓰는 도구가 있습니다. jet-live(Linux, macOS)와 Live++(상용, Windows와 게임 콘솔 중심)는 수정된 오브젝트 파일만 다시 컴파일한 뒤 실행 중인 프로세스에 로드하고, 기존 함수의 시작 부분을 새 함수로 점프하도록 패치합니다. 실행 파일을 플러그인으로 나눌 필요가 없는 대신, 함수 패치 방식 특유의 제약(인라인된 함수는 바뀌지 않음, 데이터 레이아웃 변경은 어려움)이 있습니다. Unreal Engine의 Live Coding이 Live++ 기반입니다. Runtime Compiled C++(RCC++)와 hscpp는 소스 파일을 런타임에 다시 컴파일해 객체 단위로 교체하는 방식입니다.

어떤 방식이든 핫 리로드는 개발 반복 속도를 위한 도구로 쓰는 것이 일반적입니다. 운영 중인 서버에서 코드를 교체하려면 여기서 다룬 문제 외에도 버전 검증, 롤백, 교체 도중의 요청 처리를 모두 보장해야 하므로, 보통은 프로세스를 새로 띄우고 트래픽을 옮기는 무중단 배포가 더 단순하고 안전합니다. 릴리스 빌드에서는 파일 감시 코드를 컴파일 옵션으로 빼 두는 것이 좋습니다.


자주 묻는 질문 (FAQ)

Q. 리로드 도중에 process()를 호출하면 크래시가 나는 이유는 무엇인가요?

A. 라이브러리를 닫은 뒤에는 이전 라이브러리의 API 테이블과 코드가 이미 매핑 해제된 메모리를 가리키므로, 다른 스레드가 그 사이에 process()를 호출하면 크래시가 납니다. 리로드와 호출을 같은 뮤텍스로 보호하고, 새 버전을 먼저 로드한 뒤 교체하고, 교체가 끝난 다음에 이전 인스턴스와 라이브러리를 정리하는 순서를 지켜야 합니다. 게임 루프라면 감시 스레드에서 직접 리로드하지 말고 플래그만 세운 뒤 프레임 경계에서 교체하는 것이 가장 단순합니다.

Q. 프로덕션에서 핫 리로드를 써도 되나요?

A. 대부분의 경우 개발·테스트 환경용으로 쓰는 것이 맞습니다. 운영 환경에서 코드를 교체하려면 버전 검증, 실패 시 롤백, 교체 도중의 요청 처리까지 보장해야 하는데, 새 프로세스를 띄워 트래픽을 옮기는 배포 방식이 이 문제들을 더 단순하게 해결합니다.


참고 자료


같이 보면 좋은 글