[2026] C++ 핫 리로드 완벽 가이드 | 동적 라이브러리·파일 감시·안전한 교체 [#55-7]

[2026] C++ 핫 리로드 완벽 가이드 | 동적 라이브러리·파일 감시·안전한 교체 [#55-7]

이 글의 핵심

C++ 핫 리로드 : 동적 라이브러리·파일 감시·안전한 교체 [#55-7]. 플러그인 수정할 때마다 앱을 재시작해야 하나요?와 핫 리로드가 필요한 순간를 축으로 문법·패턴·주의점을 예제와 함께 설명합니다.

들어가며: 플러그인 수정할 때마다 앱을 재시작해야 하나요?

”게임 로직 한 줄 바꿀 때마다 3분씩 빌드·재실행하는 게 너무 답답해요”

게임 엔진에서 AI 행동을 수정하거나, 이미지 에디터에서 필터 파라미터를 조정할 때마다 전체 앱을 다시 빌드하고 재시작하면 개발 속도가 크게 떨어집니다. 비유하면 “옷 한 벌 바꿀 때마다 몸 전체를 다시 만들어야 하는” 것처럼, 코드 일부만 바꿔도 전체를 다시 컴파일·실행해야 하는 C++의 특성이 개발 반복을 느리게 합니다. 핫 리로드(Hot Reload)실행 중인 앱을 종료하지 않고 수정된 동적 라이브러리(.so/.dll)를 다시 로드해 변경 사항을 즉시 반영하는 기법입니다. Unity, Unreal, Godot 같은 엔진들이 셰이더·스크립트·플러그인에 활용합니다. 문제의 핵심:

  • 동적 라이브러리는 dlopen/LoadLibrary로 로드되지만, 이미 로드된 .so/.dll은 수정·교체가 어렵습니다 (파일 잠금).
  • 사용 중인 인스턴스가 있으면 리로드 시 크래시 위험이 있습니다.
  • Windows는 파일 잠금으로 인해 빌드 중인 .dll을 바로 교체할 수 없습니다.
  • Linux GCC는 -fno-gnu-unique 없이 dlclose가 제대로 동작하지 않을 수 있습니다. 이 글에서 다루는 것:
  • 문제 시나리오: 핫 리로드가 필요한 실제 상황
  • 핵심 메커니즘: 파일 감시 → 언로드 → 재로드
  • 완전한 핫 리로드 예제: 플랫폼별 구현
  • 자주 발생하는 에러와 해결법
  • 모범 사례프로덕션 패턴 요구 환경: C++17 이상

실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.

목차

  1. 문제 시나리오: 핫 리로드가 필요한 순간
  2. 핫 리로드 기본 개념
  3. 핵심 구현
  4. 완전한 핫 리로드 예제
  5. 자주 발생하는 에러와 해결법
  6. 모범 사례
  7. 프로덕션 패턴
  8. 정리

1. 문제 시나리오: 핫 리로드가 필요한 순간

시나리오 1: 게임 AI 행동 트리 수정

문제: NPC 행동 로직을 바꿀 때마다 게임을 종료하고 2~3분 빌드 후 다시 실행합니다. “공격 거리 5 → 7로 바꿔볼까?” 같은 작은 실험을 10번 하려면 30분이 걸립니다. 해결: AI 모듈을 동적 라이브러리로 분리하고, 파일 감시로 .so/.dll 변경 시 자동 리로드합니다. 수정 후 빌드만 하면 게임은 계속 실행된 채 새 로직이 적용됩니다.

시나리오 2: 이미지 필터 파라미터 튜닝

문제: 블러 강도, 색상 보정 계수 등을 실시간으로 조정하고 싶습니다. 코드 수정 → 빌드 → 실행 → 스크린샷 확인을 반복하는데, 한 번에 1분 이상 소요됩니다. 해결: 필터를 플러그인으로 분리하고 핫 리로드를 적용합니다. 파라미터를 바꾼 뒤 플러그인만 재빌드하면 에디터는 그대로 두고 필터만 새로 로드됩니다.

시나리오 3: 서버 플러그인 A/B 테스트

문제: 트래픽 라우팅 로직을 두 가지 버전으로 A/B 테스트하려 합니다. 로직 변경마다 서버를 재시작하면 연결이 끊기고, 무중단 배포를 위해 복잡한 오케스트레이션이 필요합니다. 해결: 라우팅 로직을 플러그인으로 분리하고, 요청 처리 사이에 리로드합니다. 새 요청은 새 플러그인으로 처리하고, 기존 요청은 기존 인스턴스로 완료합니다.

시나리오 4: Windows에서 “다른 프로세스가 사용 중” 에러

문제: 플러그인을 수정하고 빌드했는데 “The process cannot access the file because it is being used by another process” 에러가 납니다. 호스트 앱이 .dll을 로드한 채로 있어서 파일이 잠겨 있습니다. 해결: 임시 파일에 빌드한 뒤 원본과 교체(rename)하는 방식으로 회피합니다. Windows는 로드 중인 .dll을 rename할 수 있으므로, plugin.dllplugin_old.dll로 이름을 바꾸고 새 plugin.dll을 복사합니다.

시나리오 5: Linux에서 dlclose 후에도 라이브러리가 메모리에 남음

문제: dlclose를 호출했는데, dlopen으로 다시 로드하면 이전 코드가 실행됩니다. GCC의 -fno-gnu-unique 없이 빌드하면 dlclose가 참조 카운트만 줄이고 실제 언로드가 되지 않을 수 있습니다. 해결: GCC로 플러그인을 빌드할 때 -fno-gnu-unique 플래그를 추가합니다. Clang은 해당 이슈가 없습니다. 아래 코드는 mermaid를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

flowchart TB
    subgraph problem[문제 상황]
        P1[코드 수정] --> P2[전체 빌드]
        P2 --> P3[앱 재시작]
        P3 --> P4[3분 대기]
        P4 --> P1
    end
    subgraph solution[핫 리로드 해결]
        S1[코드 수정] --> S2[플러그인만 빌드]
        S2 --> S3[파일 감시 감지]
        S3 --> S4[dlclose → dlopen]
        S4 --> S5[즉시 반영]
    end

2. 핫 리로드 기본 개념

핫 리로드 흐름

아래 코드는 mermaid를 사용한 구현 예제입니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

sequenceDiagram
    participant App as 호스트 앱
    participant Watcher as 파일 감시
    participant Old as 기존 .so/.dll
    participant New as 새 .so/.dll
    Watcher->>Watcher: .so/.dll 변경 감지
    Watcher->>App: 리로드 요청
    App->>App: 사용 중인 인스턴스 정리
    App->>Old: destroy(instance)
    App->>Old: dlclose / FreeLibrary
    App->>New: dlopen / LoadLibrary
    App->>New: create() → 새 인스턴스
    App->>App: 새 API로 교체

핵심 단계:

  1. 파일 감시: inotify(Linux), FSEvents(macOS), ReadDirectoryChangesW(Windows)로 .so/.dll 변경 감지
  2. 안전한 시점 대기: 현재 처리 중인 작업이 없을 때 리로드 (또는 새 요청만 새 인스턴스로)
  3. 기존 정리: destroy 호출 → dlclose/FreeLibrary
  4. 재로드: 새 .so/.dlldlopen/LoadLibrary로 로드
  5. 인스턴스 교체: create로 새 인스턴스 생성, 포인터 교체

리로드 가능한 코드 vs 불가능한 코드

리로드 가능리로드 불가
플러그인 .so/.dll호스트 메인 바이너리
C ABI로 노출된 함수호스트가 직접 호출하는 코드
플러그인 내부 상태호스트-플러그인 간 공유 전역 변수
독립적인 모듈호스트와 강하게 결합된 로직
설계 원칙: 리로드 대상은 인터페이스(함수 포인터)를 통해서만 호스트와 통신하고, 호스트 내부 타입이나 전역 상태를 직접 참조하지 않아야 합니다.

3. 핵심 구현

3.1 플랫폼별 동적 로딩 래퍼

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// hot_reload_loader.h
#pragma once
#include <string>
#include <functional>
#include <memory>
#ifdef _WIN32
#include <windows.h>
#else
#include <dlfcn.h>
#endif
class HotReloadLoader {
public:
    using Handle = void*;
    explicit HotReloadLoader(const std::string& path);
    ~HotReloadLoader();
    HotReloadLoader(const HotReloadLoader&) = delete;
    HotReloadLoader& operator=(const HotReloadLoader&) = delete;
    void* getSymbol(const char* name) const;
    bool isLoaded() const { return handle_ != nullptr; }
    const std::string& path() const { return path_; }
private:
    std::string path_;
    Handle handle_ = nullptr;
};

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// hot_reload_loader.cpp
#include "hot_reload_loader.h"
#include <stdexcept>
#ifdef _WIN32
HotReloadLoader::HotReloadLoader(const std::string& path)
    : path_(path) {
    handle_ = LoadLibraryA(path.c_str());
    if (!handle_) {
        DWORD err = GetLastError();
        throw std::runtime_error("LoadLibrary failed: " + path + " (error " + std::to_string(err) + ")");
    }
}
HotReloadLoader::~HotReloadLoader() {
    if (handle_) {
        FreeLibrary(static_cast<HMODULE>(handle_));
    }
}
void* HotReloadLoader::getSymbol(const char* name) const {
    if (!handle_) return nullptr;
    return reinterpret_cast<void*>(GetProcAddress(static_cast<HMODULE>(handle_), name));
}
#else
HotReloadLoader::HotReloadLoader(const std::string& path)
    : path_(path) {
    handle_ = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL);
    if (!handle_) {
        throw std::runtime_error(std::string("dlopen failed: ") + dlerror());
    }
}
HotReloadLoader::~HotReloadLoader() {
    if (handle_) {
        dlclose(handle_);
    }
}
void* HotReloadLoader::getSymbol(const char* name) const {
    if (!handle_) return nullptr;
    return dlsym(handle_, name);
}
#endif

3.2 플러그인 인터페이스 (C ABI)

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// plugin_interface.h — 호스트·플러그인 공유
#pragma once
#include <cstdint>
#include <cstddef>
#ifdef __cplusplus
extern "C" {
#endif
#define PLUGIN_API_VERSION 1
struct PluginAPI {
    uint32_t version;
    void* (*create)(const char* config);
    void (*destroy)(void* instance);
    int (*process)(void* instance, const void* input, size_t input_size,
                  void* output, size_t output_size);
};
#define PLUGIN_API_SYMBOL "plugin_api"
#ifdef __cplusplus
}
#endif

3.3 파일 감시 (플랫폼별)

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// file_watcher.h
#pragma once
#include <string>
#include <functional>
#include <atomic>
#include <thread>
class FileWatcher {
public:
    using Callback = std::function<void(const std::string& path)>;
    FileWatcher(const std::string& path, Callback callback);
    ~FileWatcher();
    void stop();
private:
    void watchLoop();
    std::string path_;
    Callback callback_;
    std::atomic<bool> running_{true};
    std::thread thread_;
};

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 반복문으로 데이터를 처리합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// file_watcher.cpp — Linux inotify 예시
#include "file_watcher.h"
#include <sys/inotify.h>
#include <unistd.h>
#include <sys/stat.h>
#include <cstring>
#include <iostream>
// 실행 예제
FileWatcher::FileWatcher(const std::string& path, Callback callback)
    : path_(path), callback_(std::move(callback)) {
    thread_ = std::thread(&FileWatcher::watchLoop, this);
}
FileWatcher::~FileWatcher() {
    stop();
    if (thread_.joinable()) {
        thread_.join();
    }
}
void FileWatcher::stop() {
    running_ = false;
}
void FileWatcher::watchLoop() {
    int fd = inotify_init();
    if (fd < 0) {
        std::cerr << "inotify_init failed\n";
        return;
    }
    int wd = inotify_add_watch(fd, path_.c_str(), IN_CLOSE_WRITE | IN_MOVED_TO);
    if (wd < 0) {
        std::cerr << "inotify_add_watch failed: " << path_ << "\n";
        close(fd);
        return;
    }
    char buf[4096];
    while (running_) {
        int n = read(fd, buf, sizeof(buf));
        if (n <= 0) continue;
        for (size_t i = 0; i < static_cast<size_t>(n); ) {
            auto* ev = reinterpret_cast<struct inotify_event*>(buf + i);
            if (ev->mask & (IN_CLOSE_WRITE | IN_MOVED_TO)) {
                std::string name;
                if (ev->len > 0) {
                    name = std::string(ev->name);
                }
                std::string full = path_ + "/" + name;
                if (name.find(".so") != std::string::npos || name.find(".dylib") != std::string::npos) {
                    callback_(full);
                }
            }
            i += sizeof(struct inotify_event) + ev->len;
        }
    }
    inotify_rm_watch(fd, wd);
    close(fd);
}

Windows용 ReadDirectoryChangesW (간단 요약): CreateFile로 디렉터리 열기 → ReadDirectoryChangesW로 변경 감지 → FILE_ACTION_MODIFIED 등에서 파일 감시. 구현은 플랫폼별로 분리하는 것이 좋습니다.

3.4 Windows: 파일 잠금 회피 (빌드 시 임시 파일 → rename)

아래 코드는 text를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

# 빌드 스크립트 (Windows)
# 1. plugin_new.dll로 빌드
# 2. plugin.dll 사용 중이면 rename 가능: plugin.dll → plugin_old.dll
# 3. plugin_new.dll → plugin.dll 복사
# 4. 호스트가 새 plugin.dll을 LoadLibrary로 로드

아래 코드는 cmake를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

# CMakeLists.txt — Windows 플러그인 빌드
add_library(plugin SHARED plugin.cpp)
set_target_properties(plugin PROPERTIES
    RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/plugins
    LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/plugins
)
if(MSVC)
    target_compile_options(plugin PRIVATE /MD)  # 호스트와 런타임 일치
endif()
REM build_plugin.bat — Windows에서 핫 리로드용 빌드
cmake --build build --target plugin
copy /Y build\plugins\plugin_new.dll build\plugins\plugin.dll

4. 완전한 핫 리로드 예제

예제 1: 최소 호스트 + 핫 리로드

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// hot_reload_host.h
#pragma once
#include "plugin_interface.h"
#include "hot_reload_loader.h"
#include <string>
#include <memory>
#include <functional>
#include <atomic>
class HotReloadHost {
public:
    using ReloadCallback = std::function<void()>;
    explicit HotReloadHost(const std::string& plugin_path);
    ~HotReloadHost();
    int process(const void* input, size_t input_size,
                void* output, size_t output_size);
    void setReloadCallback(ReloadCallback cb) { reload_callback_ = std::move(cb); }
    void reload();  // 수동 리로드
private:
    void loadPlugin();
    void unloadPlugin();
    std::string plugin_path_;
    std::unique_ptr<HotReloadLoader> loader_;
    const PluginAPI* api_ = nullptr;
    void* instance_ = nullptr;
    ReloadCallback reload_callback_;
};

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 에러 처리를 통해 안정성을 확보합니다, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// hot_reload_host.cpp
#include "hot_reload_host.h"
#include <stdexcept>
#include <iostream>
HotReloadHost::HotReloadHost(const std::string& plugin_path)
    : plugin_path_(plugin_path) {
    loadPlugin();
}
HotReloadHost::~HotReloadHost() {
    unloadPlugin();
}
void HotReloadHost::loadPlugin() {
    loader_ = std::make_unique<HotReloadLoader>(plugin_path_);
    auto* sym = loader_->getSymbol(PLUGIN_API_SYMBOL);
    if (!sym) {
        throw std::runtime_error("plugin_api symbol not found");
    }
    api_ = static_cast<const PluginAPI*>(sym);
    if (api_->version < PLUGIN_API_VERSION) {
        throw std::runtime_error("plugin version too old");
    }
    instance_ = api_->create("");
    if (!instance_) {
        throw std::runtime_error("plugin create failed");
    }
    std::cout << "[HotReload] Plugin loaded: " << plugin_path_ << "\n";
}
void HotReloadHost::unloadPlugin() {
    if (api_ && instance_) {
        api_->destroy(instance_);
        instance_ = nullptr;
        api_ = nullptr;
    }
    loader_.reset();
}
void HotReloadHost::reload() {
    unloadPlugin();
    loadPlugin();
    if (reload_callback_) {
        reload_callback_();
    }
}
int HotReloadHost::process(const void* input, size_t input_size,
                           void* output, size_t output_size) {
    if (!api_ || !instance_) return -1;
    return api_->process(instance_, input, input_size, output, output_size);
}

예제 2: 파일 감시 + 자동 리로드

// main.cpp — 파일 감시 + 핫 리로드
#include "hot_reload_host.h"
#include "file_watcher.h"
#include <iostream>
#include <vector>
#include <cstring>
#include <thread>
#include <chrono>
int main() {
    std::string plugin_path = "./plugins/libplugin.so";
    std::string plugin_dir = "./plugins";
    HotReloadHost host(plugin_path);
    host.setReloadCallback( {
        std::cout << "[HotReload] Reload complete, new logic active\n";
    });
    FileWatcher watcher(plugin_dir, [&host, &plugin_path](const std::string& path) {
        if (path.find("libplugin") != std::string::npos) {
            std::cout << "[HotReload] Detected change: " << path << "\n";
            try {
                host.reload();
            } catch (const std::exception& e) {
                std::cerr << "[HotReload] Reload failed: " << e.what() << "\n";
            }
        }
    });
    std::vector<std::uint8_t> input = {1, 2, 3, 4, 5};
    std::vector<std::uint8_t> output(5, 0);
    while (true) {
        int ret = host.process(input.data(), input.size(),
                              output.data(), output.size());
        if (ret > 0) {
            std::cout << "Result: ";
            for (int i = 0; i < ret; ++i) std::cout << (int)output[i] << " ";
            std::cout << "\n";
        }
        std::this_thread::sleep_for(std::chrono::seconds(1));
    }
    return 0;
}

예제 3: 플러그인 구현 (리로드 대상)

다음은 cpp를 활용한 상세한 구현 코드입니다. 필요한 모듈을 import하고, 클래스를 정의하여 데이터와 기능을 캡슐화하며, 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// plugin.cpp — 플러그인 (핫 리로드 대상)
#include "plugin_interface.h"
#include <cstring>
#include <cstdint>
struct PluginState {
    int counter = 0;
};
static void* create(const char* config) {
    (void)config;
    return new PluginState();
}
static void destroy(void* instance) {
    delete static_cast<PluginState*>(instance);
}
// 로직: 입력을 그대로 복사 (수정 후 리로드로 동작 확인)
static int process(void* instance, const void* input, size_t input_size,
                  void* output, size_t output_size) {
    auto* state = static_cast<PluginState*>(instance);
    state->counter++;
    if (!input || !output || output_size < input_size) return -1;
    size_t n = (input_size < output_size) ? input_size : output_size;
    std::memcpy(output, input, n);
    return static_cast<int>(n);
}
extern "C" {
PluginAPI plugin_api = {
    .version = PLUGIN_API_VERSION,
    .create = create,
    .destroy = destroy,
    .process = process,
};
}

예제 4: CMake 빌드 (전체)

다음은 cmake를 활용한 상세한 구현 코드입니다. 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

cmake_minimum_required(VERSION 3.16)
project(HotReloadDemo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
# 플러그인 인터페이스
add_library(plugin_interface INTERFACE)
target_include_directories(plugin_interface INTERFACE ${CMAKE_CURRENT_SOURCE_DIR})
# 플러그인 — GCC에서 dlclose가 제대로 동작하도록
add_library(plugin SHARED plugin.cpp)
target_link_libraries(plugin PUBLIC plugin_interface)
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
    target_compile_options(plugin PRIVATE -fno-gnu-unique)
endif()
# 호스트
add_executable(host
    main.cpp
    hot_reload_host.cpp
    hot_reload_loader.cpp
    file_watcher.cpp
)
target_link_libraries(host PRIVATE plugin_interface)
if(UNIX AND NOT APPLE)
    target_link_libraries(host PRIVATE dl)
endif()
# 출력 디렉터리
set_target_properties(plugin PROPERTIES
    LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/plugins
)
set_target_properties(host PROPERTIES
    RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}
)

아래 코드는 bash를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

# 빌드 및 실행
mkdir -p build && cd build
cmake ..
make
cp libplugin.so plugins/  # 또는 빌드 출력 디렉터리
./host

5. 자주 발생하는 에러와 해결법

에러 1: Linux에서 dlclose 후에도 이전 코드가 실행됨

원인: GCC의 -fno-gnu-unique 없이 빌드하면, dlclose가 참조 카운트만 줄이고 실제로 라이브러리를 언로드하지 않습니다. dlopen 다시 호출 시 이전 인스턴스가 재사용됩니다. 해결: 다음은 간단한 cmake 코드 예제입니다. 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

# CMakeLists.txt
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
    target_compile_options(plugin PRIVATE -fno-gnu-unique)
endif()
# 빌드 시 직접 지정
g++ -shared -fPIC -fno-gnu-unique -o libplugin.so plugin.cpp

에러 2: Windows “The process cannot access the file”

원인: 호스트가 plugin.dll을 로드한 상태에서 새로 빌드하면, plugin.dll이 잠겨 있어 덮어쓰기가 불가능합니다. 해결: 임시 파일에 빌드한 뒤 원본과 교체합니다. 아래 코드는 batch를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

REM build_plugin.bat
cmake --build build --target plugin
REM plugin.dll → plugin_old.dll
move /Y build\plugins\plugin.dll build\plugins\plugin_old.dll
REM plugin_new.dll → plugin.dll
move /Y build\plugins\plugin_new.dll build\plugins\plugin.dll

빌드 시 plugin_new.dll로 출력하고, 배포/실행 전에 rename으로 교체합니다.

에러 3: 리로드 중에 process() 호출로 크래시

원인: dlclose 직후에 api_->process(instance_, ...)를 호출하면, api_instance_가 이미 해제된 메모리를 가리킵니다. 해결: 리로드 전에 사용 중인 인스턴스가 없음을 보장합니다. 또는 이중 버퍼링: 새 인스턴스 로드 후, 다음 요청부터 새 인스턴스 사용, 기존 요청 완료 후 구 인스턴스 해제. 아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ✅ 안전한 리로드 패턴
void HotReloadHost::reload() {
    std::lock_guard<std::mutex> lock(mutex_);
    unloadPlugin();
    loadPlugin();
}

에러 4: “cannot open shared object file” (Linux)

원인: LD_LIBRARY_PATH에 플러그인 경로가 없거나, 플러그인의 의존 라이브러리를 찾지 못함. 해결:

LD_LIBRARY_PATH=./plugins:$LD_LIBRARY_PATH ./host

아래 코드는 cmake를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

# CMakeLists.txt — RPATH 설정
set_target_properties(plugin PROPERTIES
    BUILD_RPATH "\$ORIGIN"
    INSTALL_RPATH "\$ORIGIN"
)

에러 5: ABI 불일치로 리로드 후 크래시

원인: 플러그인을 새 컴파일러·다른 표준 라이브러리로 빌드했거나, PluginAPI 구조체를 변경했습니다. 해결:

  • 호스트와 플러그인을 같은 툴체인·런타임으로 빌드 (/MD vs /MT 일치)
  • PluginAPIC ABI만 사용, std::string 등 C++ 타입 금지
  • version 필드로 호환성 검사

에러 6: 파일 감시가 변경을 감지하지 못함

원인: inotify는 디렉터리를 감시합니다. 파일을 직접 path로 지정하면 감시가 안 됩니다. 또는 빌드 시 임시 파일에 쓰고 나중에 rename하면, IN_CLOSE_WRITE가 원본 파일이 아닌 임시 파일에 발생합니다. 해결: 플러그인 디렉터리를 감시하고, 파일명이 .so/.dll로 끝나는지 확인합니다. Windows에서 rename 시에는 IN_MOVED_TO에 해당하는 이벤트를 처리합니다.

에러 7: macOS에서 “Library not loaded” (코드 서명)

원인: macOS는 동적 라이브러리에 코드 서명을 요구할 수 있습니다. 개발 중 ad-hoc 서명이 필요합니다. 해결:

codesign -s - libplugin.dylib

에러 8: 리로드 후 “undefined symbol” 또는 링크 에러

원인: 플러그인이 호스트의 심볼(함수·변수)을 참조하는데, 호스트가 플러그인보다 먼저 언로드되거나, 플러그인만 재빌드하면서 호스트와 ABI가 어긋났습니다. 해결: 플러그인은 자기 자신의 코드만 포함하고, 호스트 기능이 필요하면 create함수 포인터로 전달받습니다. 호스트가 플러그인을 의존하지 않고, 플러그인이 호스트를 콜백으로만 사용합니다.

에러 9: 파일 감시가 너무 자주 트리거됨

원인: 빌드 도구가 파일을 여러 번 쓰거나, 임시 파일을 생성·삭제하면서 IN_CLOSE_WRITE가 연속 발생합니다. 해결: 디바운스를 적용해 짧은 시간(예: 500ms) 내 중복 이벤트를 무시합니다. 아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 디바운스 예시
std::chrono::steady_clock::time_point last_reload_;
const auto debounce_ms = 500;
void onFileChanged(const std::string& path) {
    auto now = std::chrono::steady_clock::now();
    if (std::chrono::duration_cast<std::chrono::milliseconds>(now - last_reload_).count() < debounce_ms)
        return;
    last_reload_ = now;
    reload_requested_ = true;
}

6. 모범 사례

1. 리로드 시점: 사용 중인 인스턴스가 없을 때

아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// ✅ 프레임/요청 경계에서 리로드
void gameLoop() {
    if (reload_requested_ && !isProcessing()) {
        host_.reload();
        reload_requested_ = false;
    }
    host_.process(...);
}

2. 이중 버퍼링 (무중단 처리)

아래 코드는 cpp를 사용한 구현 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 새 플러그인 로드 → 새 요청만 새 인스턴스로, 기존 요청은 구 인스턴스로 완료
class DualBufferHost {
    std::unique_ptr<PluginHost> current_;
    std::unique_ptr<PluginHost> next_;
    std::atomic<bool> use_next_{false};
    void reload() {
        next_ = loadNewPlugin();
        use_next_ = true;  // 다음 요청부터 next_ 사용
        // 기존 요청 완료 대기 후 current_ 해제
    }
};

3. 버전 검증

if (api_->version != PLUGIN_API_VERSION) {
    throw std::runtime_error("Plugin API version mismatch");
}

4. 리로드 실패 시 롤백

아래 코드는 cpp를 사용한 구현 예제입니다. 비동기 처리를 통해 효율적으로 작업을 수행합니다, 에러 처리를 통해 안정성을 확보합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

void reload() {
    auto backup = std::move(loader_);
    try {
        loadPlugin();
    } catch (...) {
        loader_ = std::move(backup);  // 이전 플러그인 유지
        throw;
    }
}

5. 플러그인 내부에서 호스트 전역 상태 참조 금지

아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// ❌ 나쁜 예 — 호스트 전역 변수 직접 참조
extern int g_host_config;
void process(...) {
    if (g_host_config) { ....}  // 리로드 시 호스트와 불일치
}
// ✅ 좋은 예 — create 시 config로 전달
void* create(const char* config) {
    int cfg = parseConfig(config);
    return new State(cfg);
}

6. 파일 감시 스레드와 메인 스레드 동기화

아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

// 파일 감시 콜백에서 직접 reload() 호출 시, 메인 스레드와 경쟁 가능
// → 플래그만 설정하고, 메인 루프에서 reload 수행
std::atomic<bool> reload_requested_{false};
FileWatcher watcher(plugin_dir,  {
    reload_requested_ = true;
});
void mainLoop() {
    if (reload_requested_.exchange(false)) {
        host.reload();
    }
}

7. 프로덕션 패턴

패턴 1: 개발/프로덕션 분리

#ifdef HOT_RELOAD_ENABLED
    FileWatcher watcher(plugin_dir, [&](const std::string& s) { ....});
#endif

프로덕션 빌드에서는 HOT_RELOAD_ENABLED를 끄고, 플러그인을 시작 시 한 번만 로드합니다.

패턴 2: 리로드 전 검증

아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 각 부분의 역할을 이해하면서 코드를 살펴보시기 바랍니다.

bool validateNewPlugin(const std::string& path) {
    HotReloadLoader test(path);
    auto* api = static_cast<const PluginAPI*>(test.getSymbol(PLUGIN_API_SYMBOL));
    return api && api->version == PLUGIN_API_VERSION;
}
void reload() {
    if (!validateNewPlugin(plugin_path_ + ".new")) {
        std::cerr << "New plugin validation failed, keeping current\n";
        return;
    }
    // rename .new → 원본 후 reload
}

패턴 3: 메트릭·로깅

아래 코드는 cpp를 사용한 구현 예제입니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

void reload() {
    auto start = std::chrono::steady_clock::now();
    unloadPlugin();
    loadPlugin();
    auto elapsed = std::chrono::steady_clock::now() - start;
    metrics_.recordReload(std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count());
}

패턴 4: 점진적 롤아웃

아래 코드는 cpp를 사용한 구현 예제입니다. 조건문으로 분기 처리를 수행합니다. 코드를 직접 실행해보면서 동작을 확인해보세요.

// 트래픽의 10%만 새 플러그인으로 처리
if (rand() % 100 < 10) {
    return next_host_->process(...);
} else {
    return current_host_->process(...);
}

패턴 5: 플러그인 샌드박스

리로드 시 새 플러그인이 호스트의 민감한 리소스(파일, 네트워크)에 직접 접근하지 못하도록, 콜백으로만 제공합니다. 다음은 간단한 cpp 코드 예제입니다. 클래스를 정의하여 데이터와 기능을 캡슐화하며. 코드를 직접 실행해보면서 동작을 확인해보세요.

struct HostContext {
    int (*readFile)(const char* path, void* buf, size_t size);
    void (*log)(int level, const char* msg);
};

8. 정리

항목요약
핵심dlopen/dlclose + 파일 감시로 .so/.dll 변경 시 재로드
Linux-fno-gnu-unique (GCC)로 dlclose가 실제 언로드되도록
Windows임시 파일 빌드 → rename으로 파일 잠금 회피
안전사용 중인 인스턴스가 없을 때만 리로드, 이중 버퍼링
C ABI호스트·플러그인 경계는 C ABI로 고정
핵심 원칙:
  1. 리로드 시점: 사용 중인 인스턴스가 없을 때, 또는 이중 버퍼링
  2. 플랫폼: Linux GCC -fno-gnu-unique, Windows rename 패턴
  3. 검증: 버전·심볼 검사, 실패 시 롤백
  4. 프로덕션: 개발/테스트에만 사용, 프로덕션은 제한적

구현 체크리스트

  • 플러그인을 C ABI로 분리
  • GCC -fno-gnu-unique 적용 (Linux)
  • Windows: 임시 파일 빌드 → rename
  • 파일 감시로 .so/.dll 변경 감지
  • 리로드 시점: 인스턴스 미사용 또는 이중 버퍼링
  • 버전 검증
  • 리로드 실패 시 롤백

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 게임 엔진, 플러그인 기반 에디터, 서버 모듈 개발 등 반복 수정이 잦은 환경에서 활용합니다. 본문의 문제 시나리오와 안전한 리로드 패턴을 참고하세요.

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

A. 개발·테스트 환경에서만 권장합니다. 프로덕션에서는 버전 검증·롤백 전략을 갖춘 경우에만 제한적으로 사용하세요.

Q. 더 깊이 공부하려면?

A. jet-live, 플러그인 시스템 #55-2를 참고하세요.

참고 자료


관련 글

... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3