5편의 자기 등록 팩토리는 새 도형 파일을 추가해도 기존 코드를 고치지 않았지만, 프로그램은 다시 빌드해야 했다. 플러그인(Plug-in)은 여기서 한 걸음 더 나아가 이미 배포된 호스트 프로그램에 공유 라이브러리(.so)를 실행 중에 읽어 들여 기능을 붙인다. 호스트와 플러그인은 따로 빌드되므로 둘 사이의 계약을 인터페이스 하나와 C 함수 몇 개로 좁혀야 한다. 이 글에서는 dlopen/dlsym 기반 플러그인 구조를 만들고, 이름 맹글링, API 버전 검사, 언로드 순서, dlclose가 실제로 언로드하지 않는 경우까지 g++ 13.3으로 확인한다.
이 시리즈의 다른 글
- C++ 예제로 배우는 소프트웨어 설계 기법 (11) — 반복자(Iterator)
- C++ 예제로 배우는 소프트웨어 설계 기법 (12) — 애플리케이션 프레임워크(Application Framework)
- C++ 예제로 배우는 소프트웨어 설계 기법 (14) — 알림 센터(NotificationCenter)
호스트와 플러그인의 계약
#pragma once
#include <string>
constexpr int PLUGIN_API_VERSION = 2;
struct IPlugin { // 호스트와 플러그인이 공유하는 유일한 계약
virtual ~IPlugin() = default;
virtual const char* name() const = 0;
virtual std::string process(const std::string& in) = 0;
};
// 이름 맹글링 없이 dlsym으로 찾을 수 있게 C 링키지로 내보낸다
extern "C" {
using create_fn = IPlugin* (*)();
using destroy_fn = void (*)(IPlugin*);
using version_fn = int (*)();
}
#define EXPORT_PLUGIN(T) \
extern "C" IPlugin* create_plugin() { return new T; } \
extern "C" void destroy_plugin(IPlugin* p) { delete p; } \
extern "C" int plugin_api_version() { return PLUGIN_API_VERSION; }호스트가 아는 것은 IPlugin 인터페이스와 C 링키지 함수 세 개뿐이다. 객체 생성과 파괴를 모두 플러그인 안에서 하게 해서 할당자가 섞이지 않게 했다.
플러그인과 호스트
#include <cctype>
#include "plugin_api.h"
struct Upper : IPlugin {
const char* name() const override { return "upper"; }
std::string process(const std::string& in) override
{
std::string s = in;
for (auto& c : s) c = std::toupper((unsigned char)c);
return s;
}
};
EXPORT_PLUGIN(Upper)#include <string>
#include "plugin_api.h"
struct Reverse : IPlugin {
const char* name() const override { return "reverse"; }
std::string process(const std::string& in) override { return {in.rbegin(), in.rend()}; }
};
EXPORT_PLUGIN(Reverse)#include <dlfcn.h>
#include <cstdio>
#include <filesystem>
#include <memory>
#include <vector>
#include "plugin_api.h"
class Plugin { // 라이브러리 핸들과 객체 수명을 함께 관리
void* lib_ = nullptr;
IPlugin* obj_ = nullptr;
destroy_fn destroy_ = nullptr;
public:
explicit Plugin(const std::string& path)
{
lib_ = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL);
if (!lib_) throw std::runtime_error(dlerror());
auto ver = reinterpret_cast<version_fn>(dlsym(lib_, "plugin_api_version"));
auto create = reinterpret_cast<create_fn>(dlsym(lib_, "create_plugin"));
destroy_ = reinterpret_cast<destroy_fn>(dlsym(lib_, "destroy_plugin"));
if (!ver || !create || !destroy_) { dlclose(lib_); throw std::runtime_error("missing symbol in " + path); }
if (ver() != PLUGIN_API_VERSION) {
int v = ver();
dlclose(lib_);
throw std::runtime_error(path + ": api v" + std::to_string(v) + " != v" + std::to_string(PLUGIN_API_VERSION));
}
obj_ = create();
}
~Plugin()
{
if (obj_) destroy_(obj_); // 객체를 먼저 파괴하고
if (lib_) dlclose(lib_); // 코드는 그 다음에 내린다
}
Plugin(const Plugin&) = delete;
Plugin& operator=(const Plugin&) = delete;
IPlugin* operator->() const { return obj_; }
};
int main(int argc, char** argv)
{
std::vector<std::unique_ptr<Plugin>> plugins;
for (auto& e : std::filesystem::directory_iterator(argc > 1 ? argv[1] : "plugins")) {
try { plugins.push_back(std::make_unique<Plugin>(e.path().string())); }
catch (const std::exception& ex) { std::printf(" skip: %s\n", ex.what()); }
}
std::string text = "hello plugin";
for (auto& p : plugins) {
text = (*p)->process(text);
std::printf(" %-8s -> %s\n", (*p)->name(), text.c_str());
}
}g++ -std=c++20 -Wall -fPIC -shared -o plugins/upper.so upper.cpp
g++ -std=c++20 -Wall -fPIC -shared -o plugins/reverse.so reverse.cpp
g++ -std=c++20 -Wall -o host host.cpp # 호스트는 플러그인을 링크하지 않는다$ bash build.sh
$ ./host
upper -> HELLO PLUGIN
reverse -> NIGULP OLLEH
호스트는 플러그인을 링크하지 않고 plugins/ 디렉터리의 .so를 실행 중에 찾아 읽는다. Plugin 클래스가 객체를 먼저 파괴하고 라이브러리를 나중에 내리도록 순서를 고정한다.
API 버전 검사
#include "plugin_api.h"
struct Old : IPlugin {
const char* name() const override { return "old"; }
std::string process(const std::string& in) override { return in; }
};
extern "C" IPlugin* create_plugin() { return new Old; }
extern "C" void destroy_plugin(IPlugin* p) { delete p; }
extern "C" int plugin_api_version() { return 1; } // 예전 API로 빌드된 플러그인$ g++ -std=c++20 -fPIC -shared -o plugins/old.so old.cpp
$ ./host
skip: plugins/old.so: api v1 != v2
upper -> HELLO PLUGIN
reverse -> NIGULP OLLEH
IPlugin에 가상 함수가 추가·삭제되면 vtable 배치가 바뀌어 옛 플러그인은 엉뚱한 함수를 부른다. 버전을 먼저 물어 맞지 않는 플러그인은 객체를 만들기 전에 거른다.
extern "C"를 빠뜨리면
#include "plugin_api.h"
struct Echo : IPlugin {
const char* name() const override { return "echo"; }
std::string process(const std::string& in) override { return in; }
};
IPlugin* create_plugin() { return new Echo; } // extern "C"를 빠뜨림
extern "C" void destroy_plugin(IPlugin* p) { delete p; }
extern "C" int plugin_api_version() { return PLUGIN_API_VERSION; }$ g++ -std=c++20 -fPIC -shared -o echo.so nomangle.cpp
$ nm -D echo.so | grep -E "create|destroy"
00000000000037d9 T _Z13create_pluginv
00000000000040d4 W _ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEE10_M_destroyEm
0000000000004008 W _ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEE9_M_createERmm
0000000000003804 T destroy_plugin
$ mkdir -p p2 && cp echo.so p2/ && ./host p2
skip: missing symbol in p2/echo.so
C++ 함수는 _Z13create_pluginv처럼 맹글링된 이름으로 내보내져 dlsym("create_plugin")이 찾지 못한다.
객체보다 라이브러리를 먼저 내리면
#include <dlfcn.h>
#include <cstdio>
#include "plugin_api.h"
int main()
{
void* lib = dlopen("./plugins/upper.so", RTLD_NOW);
auto create = reinterpret_cast<create_fn>(dlsym(lib, "create_plugin"));
IPlugin* p = create();
std::printf(" %s\n", p->process("abc").c_str());
dlclose(lib); // 객체보다 라이브러리를 먼저 내림
std::printf(" calling after dlclose...\n");
std::fflush(stdout);
delete p; // vtable과 소멸자 코드가 이미 언매핑됨
std::printf(" never reached\n");
}$ g++ -std=c++20 -Wall -o ue unload_early.cpp
$ ./ue; echo "exit=$?"
ABC
calling after dlclose...
bash: line 1: 5272 Segmentation fault (core dumped) ./ue
exit=139
delete p는 플러그인의 vtable과 소멸자 코드를 거치는데, dlclose로 이미 언매핑되어 segfault가 난다.
dlclose가 언로드하지 않는 경우
#include <cstdio>
#include "plugin_api.h"
struct Banner {
Banner() { std::printf(" [autoreg.so] static ctor\n"); }
~Banner() { std::printf(" [autoreg.so] static dtor\n"); }
} banner; // 로드·언로드 시점에 실행된다
struct Noop : IPlugin {
const char* name() const override { return "noop"; }
std::string process(const std::string& in) override { return in; }
};
EXPORT_PLUGIN(Noop)#include <dlfcn.h>
#include <cstdio>
int main(int, char** argv)
{
std::printf(" before dlopen\n");
void* lib = dlopen(argv[1], RTLD_NOW);
std::printf(" after dlopen\n");
dlclose(lib);
std::printf(" after dlclose\n");
}$ g++ -std=c++20 -fPIC -shared -o autoreg.so autoreg.cpp
$ g++ -std=c++20 -o lo load_one.cpp
$ ./lo ./autoreg.so
before dlopen
[autoreg.so] static ctor
after dlopen
after dlclose
[autoreg.so] static dtor
플러그인의 static 소멸자가 dlclose 시점이 아니라 프로세스 종료 시점에 불렸다. 즉 dlclose 뒤에도 라이브러리가 메모리에 남아 있었다.
$ LD_DEBUG=bindings ./lo ./autoreg.so 2>&1 | grep "libstdc++.so.6 \[0\] to ./autoreg.so" | grep -o "symbol.*" | cut -c1-80
symbol `_ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEE12_M_constructIPcEE
symbol `_ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEE9_M_createERmm' [GL
symbol `_ZNSt7__cxx1112basic_stringIcSt11char_traitsIcESaIcEE10_M_disposeEv' [GL
$ nm -D --defined-only autoreg.so | wc -l
72
플러그인이 std::string 템플릿 인스턴스 등 72개 심볼을 내보냈고, libstdc++가 그중 일부를 플러그인 쪽 정의에 바인딩했다. 다른 라이브러리가 참조하는 동안에는 동적 링커가 언로드하지 않는다.
$ cat plugin.map
{
global: create_plugin; destroy_plugin; plugin_api_version;
local: *;
};
$ g++ -std=c++20 -fPIC -shared -Wl,--version-script=plugin.map -o autoreg.so autoreg.cpp
$ nm -D --defined-only autoreg.so
0000000000001259 T create_plugin
0000000000001284 T destroy_plugin
00000000000012ae T plugin_api_version
$ ./lo ./autoreg.so
before dlopen
[autoreg.so] static ctor
after dlopen
[autoreg.so] static dtor
after dlclose
version script로 계약 함수 세 개만 내보내면 다른 라이브러리가 바인딩할 심볼이 없어 dlclose에서 바로 언로드된다.
| 단계 | 호스트 | 플러그인 |
|---|---|---|
| — | — | — |
| 빌드 | plugin_api.h만 포함, .so 링크 안 함 | -fPIC -shared, 계약 함수만 export |
| 로드 | dlopen → 버전 확인 → create_plugin | static 객체 생성자 실행 |
| 사용 | IPlugin 가상 함수 호출 | 구현 |
| 언로드 | destroy_plugin → dlclose | static 객체 소멸자 실행 |
주의사항
| 상황 | 문제 | 대응 |
|---|---|---|
| — | — | — |
팩토리 함수에 extern "C" 누락 | dlsym 실패 | extern "C"로 내보내는 매크로 사용 |
| 인터페이스 변경 후 옛 플러그인 로드 | vtable 불일치로 엉뚱한 함수 호출 | API 버전 함수로 로드 전에 검사 |
호스트가 플러그인 객체를 delete | 할당자·런타임이 다르면 힙 손상 | 플러그인이 제공하는 destroy 함수 사용 |
객체 파괴 전 dlclose | 언매핑된 코드 실행, segfault | RAII로 파괴 → dlclose 순서 고정 |
| 모든 심볼 export | 다른 라이브러리가 바인딩해 언로드 불가, 심볼 충돌 | version script 또는 -fvisibility=hidden |
| 인터페이스로 STL 타입 전달 | 컴파일러·표준 라이브러리 버전이 다르면 ABI 불일치 | 같은 툴체인으로 빌드하거나 C 타입으로 경계 정의 |
| 플러그인이 던진 예외가 호스트로 전파 | RTTI 공유 문제로 catch 실패 가능 | 경계에서 예외를 에러 코드로 변환 |
마무리
- 플러그인 계약은 인터페이스 하나와 C 링키지 생성·파괴·버전 함수로 최소화한다.
- 객체 파괴 후 라이브러리를 내리는 순서를 RAII로 고정하고, 로드 전에 API 버전을 확인한다.
- 계약 함수만 export하지 않으면
dlclose가 실제로 언로드하지 않을 수 있다.