C++ 예제로 배우는 소프트웨어 설계 기법 (13) — 플러그인(Plug-in)

5편의 자기 등록 팩토리는 새 도형 파일을 추가해도 기존 코드를 고치지 않았지만, 프로그램은 다시 빌드해야 했다. 플러그인(Plug-in)은 여기서 한 걸음 더 나아가 이미 배포된 호스트 프로그램에 공유 라이브러리(.so)를 실행 중에 읽어 들여 기능을 붙인다. 호스트와 플러그인은 따로 빌드되므로 둘 사이의 계약을 인터페이스 하나와 C 함수 몇 개로 좁혀야 한다. 이 글에서는 dlopen/dlsym 기반 플러그인 구조를 만들고, 이름 맹글링, API 버전 검사, 언로드 순서, dlclose가 실제로 언로드하지 않는 경우까지 g++ 13.3으로 확인한다.

이 시리즈의 다른 글

호스트와 플러그인의 계약

#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_pluginstatic 객체 생성자 실행
사용IPlugin 가상 함수 호출구현
언로드destroy_plugin → dlclosestatic 객체 소멸자 실행

주의사항

상황문제대응
———
팩토리 함수에 extern "C" 누락dlsym 실패extern "C"로 내보내는 매크로 사용
인터페이스 변경 후 옛 플러그인 로드vtable 불일치로 엉뚱한 함수 호출API 버전 함수로 로드 전에 검사
호스트가 플러그인 객체를 delete할당자·런타임이 다르면 힙 손상플러그인이 제공하는 destroy 함수 사용
객체 파괴 전 dlclose언매핑된 코드 실행, segfaultRAII로 파괴 → dlclose 순서 고정
모든 심볼 export다른 라이브러리가 바인딩해 언로드 불가, 심볼 충돌version script 또는 -fvisibility=hidden
인터페이스로 STL 타입 전달컴파일러·표준 라이브러리 버전이 다르면 ABI 불일치같은 툴체인으로 빌드하거나 C 타입으로 경계 정의
플러그인이 던진 예외가 호스트로 전파RTTI 공유 문제로 catch 실패 가능경계에서 예외를 에러 코드로 변환

마무리

  • 플러그인 계약은 인터페이스 하나와 C 링키지 생성·파괴·버전 함수로 최소화한다.
  • 객체 파괴 후 라이브러리를 내리는 순서를 RAII로 고정하고, 로드 전에 API 버전을 확인한다.
  • 계약 함수만 export하지 않으면 dlclose가 실제로 언로드하지 않을 수 있다.

참고

답글 남기기