C++ 플러그인 시스템 만들기: dlopen·LoadLibrary, C ABI 인터페이스, 핫 리로드

이미지 에디터에 새 필터를 넣거나, 게임에 커뮤니티 모드를 끼워 넣거나, 서버에 고객사별 처리 로직을 붙일 때, 매번 호스트 애플리케이션을 고쳐 다시 빌드하고 배포하는 것은 부담이 큽니다. 서드파티가 기능을 만들려면 호스트 소스에 접근해야 하는 문제도 있습니다. 플러그인 시스템은 호스트가 공개한 인터페이스에 맞춰 따로 빌드한 공유 라이브러리(.so, .dylib, .dll)를 실행 중에 불러와 쓰는 구조입니다.

어려운 점은 호스트와 플러그인이 서로 다른 시점에, 때로는 서로 다른 컴파일러로 빌드된다는 것입니다. 같은 헤더를 써도 C++ 클래스의 배치나 표준 라이브러리 타입의 내부 구조가 다르면 경계를 넘는 순간 크래시가 납니다. 이 글은 dlopen/LoadLibrary를 감싸는 로더, C ABI로 정의한 플러그인 인터페이스, 버전을 확인하는 플러그인 매니저, 개발용 핫 리로드를 차례로 구현하고, 실제로 자주 만나는 문제를 정리합니다. 예제는 디렉터리 스캔에 std::filesystem을 쓰므로 C++17 이상이 필요합니다.


동적 로딩과 C ABI

동적 로딩

정적 링크는 라이브러리 코드를 빌드할 때 실행 파일에 넣고, 일반적인 동적 링크는 실행 파일이 시작될 때 로더가 의존 라이브러리를 함께 올립니다. 플러그인은 그와 달리 프로그램이 실행 중에 직접 라이브러리를 열고, 이름으로 함수나 변수의 주소를 찾아 호출합니다.

플랫폼로드심볼 조회해제오류 정보확장자
Linuxdlopendlsymdlclosedlerror.so
macOSdlopendlsymdlclosedlerror.dylib (.so, 번들도 가능)
WindowsLoadLibraryGetProcAddressFreeLibraryGetLastError.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 객체가 남아 있을 때도 언로드가 미뤄질 수 있습니다.


자주 만나는 문제

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를 제공하는 편이 안전하며, 해제는 의존 관계의 역순으로 합니다.


운영 환경에서 고려할 것

플러그인은 호스트와 같은 주소 공간에서 실행되므로, 플러그인의 메모리 오류나 세그멘테이션 폴트는 호스트 전체를 죽입니다. 시그널 핸들러나 try/catch로는 이를 안전하게 복구할 수 없습니다. 서드파티 플러그인처럼 신뢰할 수 없는 코드를 실행하거나 플러그인의 장애가 호스트로 번지면 안 되는 서비스라면, 플러그인을 별도 프로세스에서 실행하고 IPC로 통신하는 구조가 유일하게 확실한 격리 방법입니다. 브라우저와 여러 IDE가 확장 기능을 별도 프로세스로 실행하는 이유입니다.

호스트의 기능(로그, 설정 조회 등)을 플러그인에 제공하려면, create에 호스트 함수 포인터를 담은 C 구조체를 넘깁니다. 이 구조체도 PluginAPI처럼 버전과 크기 필드를 두어 같은 방식으로 확장합니다. 호출 한 번의 오버헤드(간접 호출, 경계에서의 데이터 변환)가 문제 된다면 원소 하나씩이 아니라 배열 단위로 넘기는 API를 설계합니다. 스레드 안전성은 문서로 명시해야 합니다. 흔한 규칙은 “같은 인스턴스는 한 번에 한 스레드만 호출하고, 다른 인스턴스는 동시에 호출해도 된다”입니다.


참고 자료


같이 보면 좋은 글