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

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은 해당 이슈가 없습니다.

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. 핫 리로드 기본 개념

핫 리로드 흐름

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 플랫폼별 동적 로딩 래퍼

// 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;
};
// 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)

// 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 파일 감시 (플랫폼별)

// 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_;
};
// 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)

# 빌드 스크립트 (Windows)
# 1. plugin_new.dll로 빌드
# 2. plugin.dll 사용 중이면 rename 가능: plugin.dll → plugin_old.dll
# 3. plugin_new.dll → plugin.dll 복사
# 4. 호스트가 새 plugin.dll을 LoadLibrary로 로드
# 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: 최소 호스트 + 핫 리로드

// 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_;
};
// 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: 플러그인 구현 (리로드 대상)

// 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_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}
)
# 빌드 및 실행
mkdir -p build && cd build
cmake ..
make
cp libplugin.so plugins/  # 또는 빌드 출력 디렉터리
./host

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

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

원인: GCC의 -fno-gnu-unique 없이 빌드하면, dlclose가 참조 카운트만 줄이고 실제로 라이브러리를 언로드하지 않습니다. dlopen 다시 호출 시 이전 인스턴스가 재사용됩니다.

해결:

# 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이 잠겨 있어 덮어쓰기가 불가능합니다.

해결: 임시 파일에 빌드한 뒤 원본과 교체합니다.

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_가 이미 해제된 메모리를 가리킵니다.

해결: 리로드 전에 사용 중인 인스턴스가 없음을 보장합니다. 또는 이중 버퍼링: 새 인스턴스 로드 후, 다음 요청부터 새 인스턴스 사용, 기존 요청 완료 후 구 인스턴스 해제.

// ✅ 안전한 리로드 패턴
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
# 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) 내 중복 이벤트를 무시합니다.

// 디바운스 예시
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. 리로드 시점: 사용 중인 인스턴스가 없을 때

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

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

// 새 플러그인 로드 → 새 요청만 새 인스턴스로, 기존 요청은 구 인스턴스로 완료
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. 리로드 실패 시 롤백

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

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

// ❌ 나쁜 예 — 호스트 전역 변수 직접 참조
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. 파일 감시 스레드와 메인 스레드 동기화

// 파일 감시 콜백에서 직접 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: 리로드 전 검증

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: 메트릭·로깅

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: 점진적 롤아웃

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

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

리로드 시 새 플러그인이 호스트의 민감한 리소스(파일, 네트워크)에 직접 접근하지 못하도록, 콜백으로만 제공합니다.

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를 참고하세요.


참고 자료

한 줄 요약: dlopen/dlclose와 파일 감시로 플러그인을 런타임에 재로드해, 앱 재시작 없이 코드 변경을 반영할 수 있습니다.


관련 글

  • C++ 동적 로딩 완벽 가이드 | dlopen·LoadLibrary·실전 패턴 [#55-2]
  • C++ 플러그인 시스템 완벽 가이드 | dlopen·LoadLibrary·인터페이스·핫 리로드 [실전]
  • C++ 디버깅 기초 완벽 가이드 | GDB·LLDB 브레이크포인트·워치포인트로 버그 5분 만에 찾기
... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3