C++ 플러그인 시스템 만들기: dlopen·LoadLibrary, C ABI 인터페이스, 핫 리로드
이미지 에디터에 새 필터를 넣거나, 게임에 커뮤니티 모드를 끼워 넣거나, 서버에 고객사별 처리 로직을 붙일 때, 매번 호스트 애플리케이션을 고쳐 다시 빌드하고 배포하는 것은 부담이 큽니다. 서드파티가 기능을 만들려면 호스트 소스에 접근해야 하는 문제도 있습니다. 플러그인 시스템은 호스트가 공개한 인터페이스에 맞춰 따로 빌드한 공유 라이브러리(.so, .dylib, .dll)를 실행 중에 불러와 쓰는 구조입니다.
어려운 점은 호스트와 플러그인이 서로 다른 시점에, 때로는 서로 다른 컴파일러로 빌드된다는 것입니다. 같은 헤더를 써도 C++ 클래스의 배치나 표준 라이브러리 타입의 내부 구조가 다르면 경계를 넘는 순간 크래시가 납니다. 이 글은 dlopen/LoadLibrary를 감싸는 로더, C ABI로 정의한 플러그인 인터페이스, 버전을 확인하는 플러그인 매니저, 개발용 핫 리로드를 차례로 구현하고, 실제로 자주 만나는 문제를 정리합니다. 예제는 디렉터리 스캔에 std::filesystem을 쓰므로 C++17 이상이 필요합니다.
동적 로딩과 C ABI
동적 로딩
정적 링크는 라이브러리 코드를 빌드할 때 실행 파일에 넣고, 일반적인 동적 링크는 실행 파일이 시작될 때 로더가 의존 라이브러리를 함께 올립니다. 플러그인은 그와 달리 프로그램이 실행 중에 직접 라이브러리를 열고, 이름으로 함수나 변수의 주소를 찾아 호출합니다.
| 플랫폼 | 로드 | 심볼 조회 | 해제 | 오류 정보 | 확장자 |
|---|---|---|---|---|---|
| Linux | dlopen | dlsym | dlclose | dlerror | .so |
| macOS | dlopen | dlsym | dlclose | dlerror | .dylib (.so, 번들도 가능) |
| Windows | LoadLibrary | GetProcAddress | FreeLibrary | GetLastError | .dll |
경계를 C로 정의하는 이유
C++ 컴파일러는 오버로딩과 네임스페이스를 구분하기 위해 함수 이름을 변형(name mangling)합니다. void process(int)는 Itanium ABI를 따르는 GCC와 Clang에서 _Z7processi, MSVC에서 ?process@@YAXH@Z가 됩니다. 이름을 문자열로 찾아야 하는 dlsym 입장에서는 이 변형 규칙을 알아야 하고, 규칙은 컴파일러마다 다릅니다. 가상 함수 테이블의 배치와 std::string, std::vector 같은 표준 라이브러리 타입의 내부 구조도 컴파일러, 표준 라이브러리(libstdc++, libc++, MSVC STL), 그리고 디버그/릴리스 설정에 따라 달라집니다.
// 위험: 경계에 C++ 클래스와 std::string
class IPlugin {
public:
virtual int process(const std::string& input) = 0;
};
이 인터페이스는 호스트와 플러그인이 같은 컴파일러, 같은 표준 라이브러리, 같은 빌드 설정을 쓸 때만 안전합니다. 하나만 달라도 std::string의 크기나 필드 순서가 달라 세그멘테이션 폴트가 나고, 그 증상은 경계에서 멀리 떨어진 곳에서 나타나기도 합니다.
extern "C"로 선언한 함수는 이름이 변형되지 않고 플랫폼의 C 호출 규약을 따릅니다. 경계를 넘는 데이터를 포인터, 정수, C 구조체로 제한하면 호스트와 플러그인이 각자 다른 컴파일러로 빌드되어도 같은 배치를 가정하게 됩니다. 플러그인 내부에서는 C++의 모든 기능을 자유롭게 쓰되, 경계에서만 C로 번역하는 것이 핵심입니다.
sequenceDiagram
participant H as 호스트
participant P as 플러그인
H->>P: dlopen / LoadLibrary
H->>P: dlsym("plugin_api")
H->>P: create(config)
P-->>H: instance 포인터
H->>P: process(instance, input, output)
P-->>H: 결과 코드
H->>P: destroy(instance)
H->>P: dlclose / FreeLibrary
플랫폼 추상화 로더
// plugin_loader.h
#pragma once
#include <string>
class PluginLoader {
public:
explicit PluginLoader(const std::string& path);
~PluginLoader();
PluginLoader(const PluginLoader&) = delete;
PluginLoader& operator=(const PluginLoader&) = delete;
void* getSymbol(const char* name) const;
private:
void* handle_ = nullptr;
};
// plugin_loader.cpp
#include "plugin_loader.h"
#include <stdexcept>
#ifdef _WIN32
#include <windows.h>
PluginLoader::PluginLoader(const std::string& path) {
// LOAD_WITH_ALTERED_SEARCH_PATH: 플러그인이 의존하는 DLL을 플러그인 폴더에서도 찾음
handle_ = LoadLibraryExA(path.c_str(), nullptr, LOAD_WITH_ALTERED_SEARCH_PATH);
if (!handle_) {
throw std::runtime_error("LoadLibrary failed: " + path +
" (error " + std::to_string(GetLastError()) + ")");
}
}
PluginLoader::~PluginLoader() {
if (handle_) FreeLibrary(static_cast<HMODULE>(handle_));
}
void* PluginLoader::getSymbol(const char* name) const {
return reinterpret_cast<void*>(GetProcAddress(static_cast<HMODULE>(handle_), name));
}
#else
#include <dlfcn.h>
PluginLoader::PluginLoader(const std::string& path) {
// RTLD_NOW: 로드 시점에 모든 심볼을 해석해, 누락된 심볼이 있으면 바로 실패
// RTLD_LOCAL: 이 라이브러리의 심볼을 이후 로드되는 다른 라이브러리에 노출하지 않음
handle_ = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL);
if (!handle_) {
const char* err = dlerror();
throw std::runtime_error(std::string("dlopen failed: ") + (err ? err : "unknown"));
}
}
PluginLoader::~PluginLoader() {
if (handle_) dlclose(handle_);
}
void* PluginLoader::getSymbol(const char* name) const {
return dlsym(handle_, name);
}
#endif
RTLD_LAZY를 쓰면 함수가 처음 호출될 때 심볼을 해석하므로, 플러그인이 호스트에 없는 함수를 참조할 경우 한참 실행한 뒤에 프로세스가 종료됩니다. 플러그인 로더에서는 로드 시점에 실패하는 RTLD_NOW가 문제를 찾기 쉽습니다. dlopen에 슬래시가 없는 이름을 넘기면 LD_LIBRARY_PATH와 시스템 경로에서 찾으므로, 플러그인은 항상 경로를 포함해 넘깁니다.
C ABI 플러그인 인터페이스
// plugin_interface.h: 호스트와 플러그인이 공유하는 유일한 헤더
#pragma once
#include <stddef.h>
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
#define PLUGIN_API_MAJOR 1
#define PLUGIN_API_MINOR 0
typedef struct PluginAPI {
uint32_t api_major; /* 호스트와 다르면 로드 거부 */
uint32_t api_minor; /* 호환되는 기능 추가 시 증가 */
uint32_t struct_size; /* sizeof(PluginAPI): 끝에 추가된 필드가 있는지 판단 */
void* (*create)(const char* config);
void (*destroy)(void* instance);
/* 반환값: 0 이상이면 출력한 바이트 수, 음수면 오류 코드 */
int (*process)(void* instance, const void* input, size_t input_size,
void* output, size_t output_size);
/* 이후 버전의 필드는 여기 아래에만 추가 */
} PluginAPI;
typedef struct PluginInfo {
const char* name;
const char* version;
const char* author;
} PluginInfo;
#define PLUGIN_API_SYMBOL "plugin_api"
#define PLUGIN_INFO_SYMBOL "plugin_info"
#ifdef __cplusplus
}
#endif
// plugin_export.h
#pragma once
#ifdef _WIN32
#define PLUGIN_EXPORT __declspec(dllexport)
#else
#define PLUGIN_EXPORT __attribute__((visibility("default")))
#endif
인터페이스를 함수 하나씩 export하는 대신 함수 포인터를 담은 구조체 하나로 묶었습니다. 이렇게 하면 호스트는 심볼 하나만 찾으면 되고, 플러그인 내부 함수는 모두 static으로 숨길 수 있습니다.
버전 관리는 두 단계로 합니다. 호환되지 않는 변경(기존 함수의 시그니처나 의미 변경)은 api_major를 올리고, 호스트는 메이저가 다른 플러그인을 거부합니다. 함수를 새로 추가하는 호환 변경은 구조체 끝에 필드를 덧붙이고 api_minor를 올립니다. 새 호스트가 옛 플러그인을 로드하면 플러그인의 구조체에는 새 필드가 없으므로, 호스트는 struct_size를 보고 그 필드가 있을 때만 읽어야 합니다. 이 확인 없이 새 필드를 읽으면 플러그인의 다른 데이터를 함수 포인터로 해석해 엉뚱한 주소를 호출하게 됩니다. 기존 필드의 순서를 바꾸거나 중간에 끼워 넣으면 안 되는 것도 같은 이유입니다.
플러그인 호스트와 매니저
// plugin_host.h
#pragma once
#include "plugin_interface.h"
#include "plugin_loader.h"
#include <string>
class PluginHost {
public:
explicit PluginHost(const std::string& path);
~PluginHost();
PluginHost(const PluginHost&) = delete;
PluginHost& operator=(const PluginHost&) = delete;
int process(const void* in, size_t in_size, void* out, size_t out_size);
const PluginInfo* info() const { return info_; }
private:
PluginLoader loader_; // 멤버는 선언 역순으로 파괴되므로
const PluginAPI* api_ = nullptr; // 인스턴스 정리(소멸자 본문) 후에 라이브러리가 닫힘
const PluginInfo* info_ = nullptr;
void* instance_ = nullptr;
};
// plugin_host.cpp
#include "plugin_host.h"
#include <stdexcept>
PluginHost::PluginHost(const std::string& path) : loader_(path) {
api_ = static_cast<const PluginAPI*>(loader_.getSymbol(PLUGIN_API_SYMBOL));
if (!api_) throw std::runtime_error("plugin_api symbol not found: " + path);
if (api_->api_major != PLUGIN_API_MAJOR)
throw std::runtime_error("incompatible plugin API major version: " + path);
if (api_->struct_size < offsetof(PluginAPI, process) + sizeof(api_->process))
throw std::runtime_error("plugin_api struct too small: " + path);
info_ = static_cast<const PluginInfo*>(loader_.getSymbol(PLUGIN_INFO_SYMBOL));
instance_ = api_->create("");
if (!instance_) throw std::runtime_error("plugin create failed: " + path);
}
PluginHost::~PluginHost() {
if (instance_) api_->destroy(instance_);
// 이후 loader_ 소멸자가 dlclose/FreeLibrary 호출
}
int PluginHost::process(const void* in, size_t in_size, void* out, size_t out_size) {
return api_->process(instance_, in, in_size, out, out_size);
}
instance_는 플러그인이 만든 객체이므로 반드시 플러그인의 destroy로 해제해야 하고, 그것도 라이브러리를 닫기 전에 해야 합니다. 호스트 쪽에서 delete하면 다른 힙(Windows에서 CRT가 다른 경우)이나 다른 타입 정보로 해제하게 되고, 라이브러리를 먼저 닫으면 destroy 함수의 코드 자체가 메모리에서 사라집니다. 위 클래스는 멤버 파괴 순서를 이용해 이 순서를 보장합니다. 생성자에서 예외가 나면 이미 생성된 loader_만 파괴되므로 라이브러리도 정상적으로 닫힙니다.
// plugin_manager.h / .cpp
#pragma once
#include "plugin_host.h"
#include <filesystem>
#include <iostream>
#include <map>
#include <memory>
#include <string>
class PluginManager {
public:
void scanDirectory(const std::filesystem::path& dir) {
namespace fs = std::filesystem;
for (const auto& entry : fs::directory_iterator(dir)) {
if (!entry.is_regular_file()) continue;
const auto ext = entry.path().extension();
#ifdef _WIN32
if (ext != ".dll") continue;
#elif defined(__APPLE__)
if (ext != ".dylib" && ext != ".so") continue;
#else
if (ext != ".so") continue;
#endif
try {
auto host = std::make_unique<PluginHost>(entry.path().string());
plugins_[entry.path().stem().string()] = std::move(host);
} catch (const std::exception& e) {
std::cerr << "skip " << entry.path() << ": " << e.what() << "\n";
}
}
}
PluginHost* get(const std::string& name) {
auto it = plugins_.find(name);
return it != plugins_.end() ? it->second.get() : nullptr;
}
const std::map<std::string, std::unique_ptr<PluginHost>>& all() const { return plugins_; }
private:
std::map<std::string, std::unique_ptr<PluginHost>> plugins_;
};
한 플러그인의 로드 실패가 전체를 멈추지 않도록 플러그인 단위로 예외를 잡습니다. 플러그인 디렉터리는 신뢰할 수 있는 위치여야 합니다. 라이브러리를 로드하는 순간 그 안의 전역 생성자가 실행되므로, 쓰기 권한이 넓은 디렉터리를 스캔하면 임의 코드 실행 경로가 됩니다.
예제 플러그인: 그레이스케일 필터
// plugin_greyscale.cpp
#include "plugin_interface.h"
#include "plugin_export.h"
#include <cstdint>
#include <new>
namespace {
struct FilterState {};
void* create(const char*) noexcept {
return new (std::nothrow) FilterState();
}
void destroy(void* instance) noexcept {
delete static_cast<FilterState*>(instance);
}
// 입력: RGB 바이트 배열, 출력: 픽셀당 1바이트 밝기 (Y = 0.299R + 0.587G + 0.114B)
int process(void*, const void* input, size_t input_size,
void* output, size_t output_size) noexcept {
if (!input || !output || input_size % 3 != 0) return -1;
const size_t pixels = input_size / 3;
if (output_size < pixels) return -2;
auto* in = static_cast<const std::uint8_t*>(input);
auto* out = static_cast<std::uint8_t*>(output);
for (size_t i = 0; i < pixels; ++i) {
out[i] = static_cast<std::uint8_t>(
(299 * in[3 * i] + 587 * in[3 * i + 1] + 114 * in[3 * i + 2]) / 1000);
}
return static_cast<int>(pixels);
}
} // namespace
extern "C" {
PLUGIN_EXPORT PluginAPI plugin_api = {
PLUGIN_API_MAJOR, PLUGIN_API_MINOR, sizeof(PluginAPI),
create, destroy, process,
};
PLUGIN_EXPORT PluginInfo plugin_info = {"Greyscale Filter", "1.0", "pkglog"};
}
구조체 초기화에 .create = create 같은 지정 초기화자를 쓰고 싶을 수 있지만, C++에서는 C++20부터 표준이라서 C++17 빌드에서는 위처럼 순서대로 초기화합니다. 경계 함수에는 noexcept를 붙였습니다. C++ 예외가 C 인터페이스를 넘어 호스트로 전파되는 것은 지원되지 않는 동작이고, 호스트와 플러그인이 다른 런타임을 쓰면 프로세스가 그대로 종료됩니다. 플러그인 내부에서 예외가 날 수 있는 코드를 호출한다면 경계 함수 안에서 모두 잡아 오류 코드로 바꿔야 합니다. noexcept 함수에서 예외가 빠져나가면 std::terminate가 호출되므로, 적어도 문제가 경계를 넘어 엉뚱한 곳에서 터지지는 않습니다.
// main.cpp: 호스트
#include "plugin_manager.h"
#include <cstdint>
#include <iostream>
#include <vector>
int main() {
PluginManager mgr;
mgr.scanDirectory("./plugins");
for (const auto& [name, host] : mgr.all()) {
std::cout << "Loaded: " << name;
if (auto* info = host->info()) std::cout << " (" << info->name << " " << info->version << ")";
std::cout << "\n";
}
if (auto* grey = mgr.get("plugin_greyscale")) {
std::vector<std::uint8_t> rgb = {255, 0, 0, 0, 255, 0, 0, 0, 255};
std::vector<std::uint8_t> out(rgb.size() / 3);
int n = grey->process(rgb.data(), rgb.size(), out.data(), out.size());
for (int i = 0; i < n; ++i) std::cout << int(out[i]) << " "; // 76 149 29
std::cout << "\n";
}
}
CMake
cmake_minimum_required(VERSION 3.16)
project(PluginDemo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
add_library(plugin_interface INTERFACE)
target_include_directories(plugin_interface INTERFACE ${CMAKE_CURRENT_SOURCE_DIR})
add_library(plugin_greyscale MODULE plugin_greyscale.cpp)
target_link_libraries(plugin_greyscale PRIVATE plugin_interface)
set_target_properties(plugin_greyscale PROPERTIES
PREFIX "" # libplugin_greyscale가 아니라 plugin_greyscale
CXX_VISIBILITY_PRESET hidden # export 매크로가 붙은 심볼만 공개
LIBRARY_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/plugins
LIBRARY_OUTPUT_DIRECTORY_DEBUG ${CMAKE_BINARY_DIR}/plugins
LIBRARY_OUTPUT_DIRECTORY_RELEASE ${CMAKE_BINARY_DIR}/plugins)
add_executable(host main.cpp plugin_host.cpp plugin_loader.cpp)
target_link_libraries(host PRIVATE plugin_interface ${CMAKE_DL_LIBS})
cmake -S . -B build
cmake --build build
cd build && ./host # Windows: .\Debug\host.exe 처럼 구성 디렉터리의 실행 파일
MODULE 라이브러리는 링크용이 아니라 실행 중 로드 전용 라이브러리로, Windows에서도 DLL을 LIBRARY_OUTPUT_DIRECTORY에 둡니다(SHARED였다면 DLL은 RUNTIME_OUTPUT_DIRECTORY를 따릅니다). Visual Studio 같은 다중 구성 생성기는 출력 경로 뒤에 Debug/Release를 붙이므로 구성별 속성도 함께 지정했습니다. 이 설정이면 빌드 결과가 바로 build/plugins/plugin_greyscale.so(Windows는 .dll, macOS도 MODULE은 기본 확장자가 .so)에 생기므로 따로 복사할 필요가 없습니다. 호스트를 build에서 실행해야 ./plugins 상대 경로가 맞습니다.
개발용 핫 리로드
개발 중에는 플러그인만 다시 빌드하면 실행 중인 호스트가 새 코드를 쓰도록 만들 수 있습니다. 단순히 “파일이 바뀌면 같은 경로를 다시 로드”하면 동작하지 않는 이유가 세 가지 있습니다.
첫째, 같은 경로의 라이브러리가 이미 열려 있으면 dlopen은 새로 읽지 않고 기존 핸들의 참조 카운트만 올려 돌려줍니다. 새 PluginHost를 만든 뒤 옛 것을 지우는 순서라면 새 코드는 절대 로드되지 않습니다. 둘째, 로드된 .so를 cp처럼 제자리에서 덮어쓰면 이미 메모리에 매핑된 코드가 바뀌어 실행 중인 프로세스가 SIGBUS나 SIGSEGV로 죽을 수 있습니다(링커는 보통 새 파일을 만들어 교체하므로 괜찮지만 복사 스크립트는 위험합니다). 셋째, Windows는 로드된 DLL 파일을 잠그므로 빌드 시스템이 출력 파일을 덮어쓰지 못합니다.
세 문제를 함께 피하는 방법은 빌드 결과를 매번 새 이름의 임시 파일로 복사해 그 사본을 로드하는 것입니다.
// hot_reload.h
#pragma once
#include "plugin_host.h"
#include <filesystem>
#include <memory>
class HotReloadPlugin {
public:
explicit HotReloadPlugin(std::filesystem::path source) : source_(std::move(source)) {
reload();
}
PluginHost* get() { return host_.get(); }
// 주 루프에서 플러그인을 쓰지 않는 시점에 호출
bool reloadIfChanged() {
std::error_code ec;
auto t = std::filesystem::last_write_time(source_, ec);
if (ec || t == last_write_) return false;
reload();
return true;
}
private:
void reload() {
namespace fs = std::filesystem;
last_write_ = fs::last_write_time(source_);
fs::path copy = fs::temp_directory_path() /
(source_.stem().string() + "_" + std::to_string(++generation_) + source_.extension().string());
fs::copy_file(source_, copy, fs::copy_options::overwrite_existing);
try {
auto next = std::make_unique<PluginHost>(copy.string());
host_ = std::move(next); // 새 로드에 성공한 뒤에만 기존 플러그인 해제
} catch (const std::exception&) {
// 빌드가 덜 끝난 파일 등으로 실패하면 기존 플러그인을 유지
}
}
std::filesystem::path source_;
std::filesystem::file_time_type last_write_{};
std::unique_ptr<PluginHost> host_;
unsigned generation_ = 0;
};
사본 이름이 매번 다르므로 dlopen이 새 라이브러리로 인식하고, 원본 파일은 잠기지 않습니다. 오래된 사본 파일은 해당 라이브러리를 닫은 뒤 지우면 됩니다. 빌드 도중의 불완전한 파일을 읽을 수 있으므로, 수정 시간이 바뀐 뒤 잠시 기다렸다가 리로드하거나 빌드 시스템이 끝났다는 표시 파일을 만들게 하는 방법도 씁니다.
리로드는 호스트가 그 플러그인의 인스턴스나 함수 포인터, 플러그인이 반환한 문자열 포인터를 아무것도 들고 있지 않은 시점에만 해야 합니다. 플러그인의 코드와 정적 데이터는 라이브러리가 닫히는 순간 사라지기 때문입니다. 리로드 사이에 유지해야 할 상태가 있다면 플러그인 API에 상태를 직렬화하는 함수와 복원하는 함수를 추가해, 옛 인스턴스에서 꺼낸 상태를 새 인스턴스에 넘깁니다.
Linux에서 GCC로 빌드한 플러그인은 dlclose를 해도 메모리에서 내려가지 않는 경우가 있습니다. 인라인 함수의 정적 지역 변수나 템플릿의 정적 데이터 멤버에 GCC가 STB_GNU_UNIQUE 심볼을 만들면, 동적 로더가 그 라이브러리를 언로드할 수 없는 것으로 표시하기 때문입니다. 이 경우 같은 이름을 다시 로드해도 옛 코드가 남아 있으므로, 개발용 빌드에 -fno-gnu-unique를 추가합니다. 소멸자가 있는 thread_local 객체가 남아 있을 때도 언로드가 미뤄질 수 있습니다.
조건부 로딩: GPU 드라이버가 있을 때만
#include <dlfcn.h>
using cuInit_t = int (*)(unsigned int); // CUresult cuInit(unsigned int Flags)
bool tryEnableGpu() {
// 드라이버 패키지는 버전이 붙은 이름(libcuda.so.1)만 설치하는 경우가 많음
void* h = dlopen("libcuda.so.1", RTLD_NOW | RTLD_LOCAL);
if (!h) return false; // 드라이버 없음 → CPU 경로
auto cuInit = reinterpret_cast<cuInit_t>(dlsym(h, "cuInit"));
if (!cuInit || cuInit(0) != 0) { // 0 == CUDA_SUCCESS
dlclose(h);
return false; // 드라이버는 있지만 사용 가능한 GPU 없음
}
gpuBackend.attach(h); // 핸들은 GPU 백엔드가 소유
return true;
}
libcuda.so처럼 버전 없는 이름은 보통 개발용 패키지에만 있는 심볼릭 링크라서, 실행 환경에서는 libcuda.so.1처럼 SONAME으로 열어야 합니다. 라이브러리가 있어도 GPU가 없거나 드라이버가 맞지 않으면 초기화 함수가 오류를 반환하므로, 로드 성공만으로 판단하지 않고 초기화까지 확인합니다.
로더: 사본을 로드해 파일 잠금과 핸들 재사용 피하기
핫 리로드 로더에서 가장 중요한 결정은 “원본 파일을 직접 로드하지 않는다”입니다. 빌드 출력 파일을 매번 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 지역 변수는 라이브러리와 함께 사라진다는 점도 기억해야 합니다. 로드할 때마다 새로 초기화되므로, 플러그인 내부에 캐시나 싱글턴을 두면 리로드 후 비어 있게 됩니다.
자주 만나는 문제
plugin_api 심볼을 찾지 못한다면 C 링크로 선언하지 않았거나 export하지 않은 경우입니다. extern "C" 없이 정의하면 변수 이름도 맹글링되고, -fvisibility=hidden으로 빌드하면서 PLUGIN_EXPORT를 빠뜨리면 심볼이 숨겨집니다. Linux에서는 nm -D --defined-only plugin_greyscale.so, Windows에서는 dumpbin /exports plugin_greyscale.dll로 실제로 무엇이 export되었는지 확인합니다.
Linux에서 cannot open shared object file이 플러그인 자체가 아니라 그 의존 라이브러리 이름과 함께 나온다면, 플러그인이 링크한 다른 .so를 찾지 못한 것입니다. 플러그인을 빌드할 때 -Wl,-rpath,'$ORIGIN'을 주면 플러그인과 같은 디렉터리에서 의존 라이브러리를 찾습니다. Windows의 The specified module could not be found(오류 126)도 대개 의존 DLL 문제입니다. 기본 LoadLibrary는 의존 DLL을 실행 파일 디렉터리, 시스템 디렉터리, PATH에서 찾고 플러그인의 디렉터리는 보지 않으므로, 위 로더처럼 LOAD_WITH_ALTERED_SEARCH_PATH와 전체 경로를 쓰거나 LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR 플래그를 씁니다. 어떤 DLL이 빠졌는지는 Dependencies 같은 도구로 확인할 수 있습니다.
Windows에서 호스트와 플러그인이 서로 다른 CRT(예: 한쪽은 /MT, 다른 쪽은 /MD, 또는 Debug와 Release)를 쓰면 각자 다른 힙을 가집니다. 한쪽에서 malloc이나 new로 할당한 메모리를 다른 쪽에서 해제하면 힙 손상이 납니다. C 인터페이스를 쓰더라도 메모리 소유권 규칙이 필요한 이유이며, “할당한 쪽이 해제한다”는 원칙을 지키고 플러그인이 할당한 결과를 돌려줘야 한다면 해제 함수도 API에 포함시킵니다.
RTLD_GLOBAL로 로드하면 플러그인의 심볼이 이후 로드되는 라이브러리의 심볼 해석에 쓰입니다. 두 플러그인이 같은 이름의 전역 함수를 가지고 있으면 나중 플러그인이 먼저 로드된 쪽의 함수를 호출하는 일이 생깁니다. 기본은 RTLD_LOCAL로 두고, 플러그인 내부 심볼은 static, 익명 네임스페이스, -fvisibility=hidden으로 숨깁니다.
플러그인 A가 플러그인 B의 함수를 직접 호출하는 구조에서 B를 먼저 닫으면, A가 이미 해제된 코드를 호출해 크래시가 납니다. 플러그인에서 받은 함수 포인터나 객체를 라이브러리를 닫은 뒤에 쓰는 경우도 같습니다. 플러그인 사이의 직접 호출은 피하고 호스트가 서비스 레지스트리 같은 중개 API를 제공하는 편이 안전하며, 해제는 의존 관계의 역순으로 합니다.
자주 만나는 로드 오류
cannot open shared object file
dlopen 오류 메시지에 나오는 이름이 내가 연 라이브러리라면 경로 문제이고, 다른 라이브러리 이름이라면 내가 연 라이브러리가 의존하는 라이브러리를 찾지 못한 것입니다. 후자가 더 흔합니다. ldd ./plugins/libmylib.so로 not found인 의존성을 확인할 수 있습니다.
# 임시 해결: 검색 경로 추가
LD_LIBRARY_PATH=./plugins:$LD_LIBRARY_PATH ./myapp
# 라이브러리가 자기 옆의 의존성을 찾도록 빌드 시 RUNPATH 지정
g++ -shared -fPIC -o libmylib.so mylib.cpp -L. -ldep -Wl,-rpath,'$ORIGIN'
$ORIGIN은 그 라이브러리 파일이 있는 디렉터리로 해석되므로, 플러그인과 의존 라이브러리를 같은 디렉터리에 두고 배포할 수 있습니다. 셸이 $ORIGIN을 변수로 해석하지 않도록 작은따옴표로 감쌉니다. 최근 링커는 기본적으로 DT_RUNPATH를 기록하는데, 이 값은 그 라이브러리의 직접 의존성을 찾을 때만 쓰이고 의존성의 의존성에는 적용되지 않는다는 점도 알아 두면 원인을 찾기 쉽습니다.
undefined symbol
dlsym이 실패하고 dlerror()가 undefined symbol: add를 보고한다면, 대개 extern "C" 없이 정의해서 이름이 _Z3addii처럼 변형된 경우입니다. nm -D --defined-only libmylib.so로 실제 내보낸 이름을 확인합니다. -fvisibility=hidden으로 빌드했다면 내보낼 함수에 __attribute__((visibility("default")))를 붙여야 합니다.
dlopen 자체가 undefined symbol로 실패한다면 성격이 다릅니다. 로드하려는 라이브러리가 참조하는 함수를 아무도 제공하지 않는다는 뜻이고, 그 함수가 호스트 실행 파일에 있는 것이라면 호스트를 -rdynamic(-Wl,--export-dynamic)으로 링크해야 실행 파일의 심볼이 동적 심볼 테이블에 들어갑니다.
Windows 오류 126과 127
LoadLibrary가 126(ERROR_MOD_NOT_FOUND)으로 실패하면 지정한 DLL이나 그 의존 DLL을 찾지 못한 것입니다. 기본 검색 순서는 실행 파일의 디렉터리, 시스템 디렉터리, Windows 디렉터리, 현재 디렉터리, PATH 순이며(안전 DLL 검색 모드 기준), 로드하는 DLL이 있는 디렉터리는 포함되지 않습니다. 그래서 plugins\mylib.dll이 같은 폴더의 dep.dll을 쓰면 실패합니다. 위 래퍼처럼 전체 경로와 LOAD_WITH_ALTERED_SEARCH_PATH를 쓰거나, LoadLibraryExA에 LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR | LOAD_LIBRARY_SEARCH_DEFAULT_DIRS를 넘기면 해당 DLL의 디렉터리에서도 의존성을 찾습니다. Visual C++ 런타임 DLL이 설치되지 않은 컴퓨터에서도 같은 오류가 나며, 어떤 DLL이 빠졌는지는 Dependencies 같은 도구로 확인합니다. GetProcAddress의 127(ERROR_PROC_NOT_FOUND)은 해당 이름의 export가 없다는 뜻입니다.
macOS의 Library not loaded
dyld: Library not loaded: @rpath/libfoo.dylib는 로드하는 쪽에 @rpath를 채울 경로가 없을 때 납니다. 라이브러리의 install name이 @rpath/libfoo.dylib라면, 그것을 사용하는 실행 파일이나 라이브러리에 rpath를 추가합니다.
clang++ -shared -o libfoo.dylib foo.cpp -install_name @rpath/libfoo.dylib
clang++ -o myapp main.cpp -L. -lfoo -Wl,-rpath,@loader_path
@loader_path는 그 의존성을 요구한 바이너리의 디렉터리, @executable_path는 실행 파일의 디렉터리입니다. otool -L로 install name과 의존성을, otool -l의 LC_RPATH 항목으로 rpath를 확인할 수 있습니다.
dlclose 이후의 크래시
dlclose 이후에는 그 라이브러리에서 얻은 함수 포인터, 라이브러리가 만든 객체의 가상 함수 테이블, 라이브러리의 정적 데이터를 가리키는 포인터(예: 라이브러리가 반환한 문자열 리터럴)가 모두 무효가 됩니다. 함수 포인터를 핸들과 같은 객체에 묶어 수명을 함께 관리하고, 라이브러리가 만든 객체는 모두 그 라이브러리의 해제 함수로 파괴한 다음에 닫아야 합니다. 반대로 dlclose가 참조 카운트만 줄이고 실제로 언로드하지 않는 경우도 있습니다. 같은 라이브러리를 여러 번 열었거나, 다른 라이브러리가 그것에 의존하거나, GCC의 STB_GNU_UNIQUE 심볼 때문에 언로드할 수 없는 것으로 표시된 경우입니다. 그래서 “닫았으니 다시 열면 새 코드가 로드된다”고 가정한 핫 리로드 코드가 동작하지 않기도 합니다.
플랫폼별로 자주 만나는 문제
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 시점에 넘겨 주는 편이 의존 방향이 명확합니다.
운영 환경에서 고려할 것
플러그인은 호스트와 같은 주소 공간에서 실행되므로, 플러그인의 메모리 오류나 세그멘테이션 폴트는 호스트 전체를 죽입니다. 시그널 핸들러나 try/catch로는 이를 안전하게 복구할 수 없습니다. 서드파티 플러그인처럼 신뢰할 수 없는 코드를 실행하거나 플러그인의 장애가 호스트로 번지면 안 되는 서비스라면, 플러그인을 별도 프로세스에서 실행하고 IPC로 통신하는 구조가 유일하게 확실한 격리 방법입니다. 브라우저와 여러 IDE가 확장 기능을 별도 프로세스로 실행하는 이유입니다.
호스트의 기능(로그, 설정 조회 등)을 플러그인에 제공하려면, create에 호스트 함수 포인터를 담은 C 구조체를 넘깁니다. 이 구조체도 PluginAPI처럼 버전과 크기 필드를 두어 같은 방식으로 확장합니다. 호출 한 번의 오버헤드(간접 호출, 경계에서의 데이터 변환)가 문제 된다면 원소 하나씩이 아니라 배열 단위로 넘기는 API를 설계합니다. 스레드 안전성은 문서로 명시해야 합니다. 흔한 규칙은 “같은 인스턴스는 한 번에 한 스레드만 호출하고, 다른 인스턴스는 동시에 호출해도 된다”입니다.
경계 설계와 버전 확인
동적으로 로드한 라이브러리와의 경계에서는 C 타입만 주고받는 것이 원칙입니다. std::string이나 std::vector의 메모리 배치는 컴파일러, 표준 라이브러리, 빌드 설정에 따라 다를 수 있어서, 호스트와 라이브러리가 서로 다른 환경에서 빌드되면 같은 헤더를 써도 다른 배치를 가정하게 됩니다. 경계 함수에서 C++ 예외가 빠져나가게 해서도 안 됩니다.
extern "C" int process(const char* input, size_t len, char* output, size_t out_cap); // 안전
extern "C" std::string process(const std::vector<int>& input); // 같은 빌드 환경에서만 동작
라이브러리가 호스트와 호환되는지 확인하려면 버전 정보를 내보내고 로드 직후 검사합니다. 변수를 내보낸 경우 dlsym은 변수의 값이 아니라 주소를 돌려줍니다.
// 라이브러리
extern "C" __attribute__((visibility("default"))) const int plugin_abi_version = 2;
// 호스트
auto* ver = static_cast<const int*>(dlsym(handle, "plugin_abi_version"));
if (!ver || *ver != 2) {
// 호환되지 않는 라이브러리: 로드 거부
}
C++에서 네임스페이스 범위의 const 변수는 기본적으로 내부 링크이지만, extern "C"가 붙은 선언은 외부 링크가 되므로 위 변수는 내보내집니다. 버전 관리 방식과 구조체 기반 인터페이스 설계는 플러그인 시스템 글에서 자세히 다룹니다.
참고 자료
- dlopen(3) - Linux man page
- LoadLibraryExA - Microsoft Learn
- Dynamic-Link Library Search Order - Microsoft Learn
- Itanium C++ ABI
- C++ 시리즈 #38-3: PIMPL과 ABI
같이 보면 좋은 글
- C++ 동적 로딩