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. 문제 시나리오: 핫 리로드가 필요한 순간
시나리오 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.dll → plugin_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로 교체
핵심 단계:
- 파일 감시: inotify(Linux), FSEvents(macOS), ReadDirectoryChangesW(Windows)로
.so/.dll변경 감지 - 안전한 시점 대기: 현재 처리 중인 작업이 없을 때 리로드 (또는 새 요청만 새 인스턴스로)
- 기존 정리:
destroy호출 →dlclose/FreeLibrary - 재로드: 새
.so/.dll을dlopen/LoadLibrary로 로드 - 인스턴스 교체:
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 구조체를 변경했습니다.
해결:
- 호스트와 플러그인을 같은 툴체인·런타임으로 빌드 (
/MDvs/MT일치) PluginAPI는 C 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로 고정 |
핵심 원칙:
- 리로드 시점: 사용 중인 인스턴스가 없을 때, 또는 이중 버퍼링
- 플랫폼: Linux GCC
-fno-gnu-unique, Windows rename 패턴 - 검증: 버전·심볼 검사, 실패 시 롤백
- 프로덕션: 개발/테스트에만 사용, 프로덕션은 제한적
구현 체크리스트
- 플러그인을 C ABI로 분리
- GCC
-fno-gnu-unique적용 (Linux) - Windows: 임시 파일 빌드 → rename
- 파일 감시로
.so/.dll변경 감지 - 리로드 시점: 인스턴스 미사용 또는 이중 버퍼링
- 버전 검증
- 리로드 실패 시 롤백
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. 게임 엔진, 플러그인 기반 에디터, 서버 모듈 개발 등 반복 수정이 잦은 환경에서 활용합니다. 본문의 문제 시나리오와 안전한 리로드 패턴을 참고하세요.
Q. 프로덕션에서 핫 리로드를 써도 되나요?
A. 개발·테스트 환경에서만 권장합니다. 프로덕션에서는 버전 검증·롤백 전략을 갖춘 경우에만 제한적으로 사용하세요.
Q. 더 깊이 공부하려면?
A. jet-live, 플러그인 시스템 #55-2를 참고하세요.
참고 자료
- jet-live C++ Hot Reload
- Hot Code Reloading in C++
- 플러그인 시스템 #55-2
- 크로스 플랫폼 #55-4
한 줄 요약: dlopen/dlclose와 파일 감시로 플러그인을 런타임에 재로드해, 앱 재시작 없이 코드 변경을 반영할 수 있습니다.
관련 글
- C++ 동적 로딩 완벽 가이드 | dlopen·LoadLibrary·실전 패턴 [#55-2]
- C++ 플러그인 시스템 완벽 가이드 | dlopen·LoadLibrary·인터페이스·핫 리로드 [실전]
- C++ 디버깅 기초 완벽 가이드 | GDB·LLDB 브레이크포인트·워치포인트로 버그 5분 만에 찾기