C++ 동적 로딩: dlopen·LoadLibrary로 플러그인 불러오기와 크로스 플랫폼 래퍼
보통 C++ 프로그램은 필요한 라이브러리를 빌드할 때 정합니다. 정적 링크라면 라이브러리 코드가 실행 파일에 들어가고, 동적 링크라면 실행 파일에 “이 라이브러리가 필요하다”는 기록이 남아 프로그램이 시작될 때 운영체제의 로더가 함께 올립니다. 동적 로딩은 이와 달리 프로그램이 실행 중에 직접 라이브러리 파일을 열고, 그 안의 함수를 이름으로 찾아 호출하는 방식입니다. 어떤 라이브러리를 쓸지, 아예 쓸지 말지를 실행 중에 결정할 수 있습니다.
| 방식 | 라이브러리가 결정되는 시점 | 라이브러리가 없을 때 |
|---|---|---|
| 정적 링크 | 빌드 시 | 해당 없음 (실행 파일에 포함) |
| 동적 링크 | 빌드 시 기록, 프로그램 시작 시 로드 | 프로그램이 시작되지 않음 |
| 동적 로딩 | 실행 중 dlopen/LoadLibrary 호출 시 | 호출이 실패를 반환하고 프로그램이 대처 |
이 글은 Linux와 macOS의 dlopen/dlsym/dlclose, Windows의 LoadLibrary/GetProcAddress/FreeLibrary로 라이브러리를 불러오는 방법과 둘을 감싸는 크로스 플랫폼 래퍼, 그리고 실제로 자주 만나는 로드 오류를 다룹니다. 플러그인 인터페이스 설계는 C++ 플러그인 시스템 만들기에서 이어서 다룹니다.
동적 로딩을 쓰는 경우
가장 흔한 쓰임은 플러그인입니다. 이미지 에디터의 필터나 게임 모드를 별도 라이브러리로 빌드해 plugins/ 디렉터리에 넣으면, 호스트가 디렉터리를 스캔해 불러옵니다.
선택적 의존성도 대표적인 경우입니다. NVIDIA GPU를 쓰는 기능이 있는 프로그램이 CUDA 드라이버 라이브러리(libcuda.so.1, Windows는 nvcuda.dll)에 직접 링크하면, 드라이버가 설치되지 않은 컴퓨터에서는 프로그램이 아예 시작되지 않습니다. 실행 중에 dlopen으로 열어 보고 실패하면 CPU 경로로 대체하면, 하나의 바이너리로 두 환경을 모두 지원할 수 있습니다.
서로 다른 구현을 고르는 경우에도 씁니다. 같은 인터페이스를 가진 여러 백엔드(예: 그래픽 API별 렌더러, CPU 명령어 집합별 최적화 커널)를 각각 라이브러리로 만들어 두고, 실행 환경을 보고 하나를 로드합니다. 기능별로 라이브러리를 나눠 배포하는 데에도 쓸 수 있지만, 동적 로딩 자체가 코드를 보호해 주지는 않습니다. 배포된 .so/.dll은 실행 파일과 마찬가지로 분석할 수 있으므로, 유료 기능을 보호하는 효과는 그 라이브러리를 아예 배포하지 않는 데서만 나옵니다.
Linux와 macOS: dlopen, dlsym
로드할 라이브러리
dlsym은 문자열로 심볼을 찾으므로, 내보낼 함수는 extern "C"로 선언해 C++ 이름 변형(name mangling)을 막습니다.
// mylib.cpp
#include <cstdio>
extern "C" {
int add(int a, int b) { return a + b; }
void greet(const char* name) { std::printf("Hello, %s!\n", name); }
}
# Linux
g++ -std=c++17 -shared -fPIC -o libmylib.so mylib.cpp
# macOS
clang++ -std=c++17 -shared -fPIC -o libmylib.dylib mylib.cpp
-fPIC는 어느 주소에 로드되어도 동작하는 위치 독립 코드를 만듭니다. x86-64 Linux에서 공유 라이브러리에 넣을 오브젝트는 이 옵션으로 컴파일해야 링크됩니다.
로드와 호출
// main_dlopen.cpp
#include <dlfcn.h>
#include <iostream>
int main() {
#ifdef __APPLE__
const char* path = "./libmylib.dylib";
#else
const char* path = "./libmylib.so";
#endif
void* handle = dlopen(path, RTLD_NOW | RTLD_LOCAL);
if (!handle) {
std::cerr << "dlopen failed: " << dlerror() << "\n";
return 1;
}
using AddFn = int (*)(int, int);
using GreetFn = void (*)(const char*);
auto add = reinterpret_cast<AddFn>(dlsym(handle, "add"));
auto greet = reinterpret_cast<GreetFn>(dlsym(handle, "greet"));
if (!add || !greet) {
std::cerr << "dlsym failed: " << dlerror() << "\n";
dlclose(handle);
return 1;
}
std::cout << "add(3, 5) = " << add(3, 5) << "\n";
greet("World");
dlclose(handle);
}
g++ -std=c++17 -o main_dlopen main_dlopen.cpp -ldl
./main_dlopen
glibc 2.34부터는 dlopen 등이 libc 본체에 들어가서 -ldl이 없어도 링크되지만, 이전 버전과의 호환을 위해 붙여 두는 편이 안전합니다(빈 호환 라이브러리가 남아 있어 붙여도 문제가 없습니다). macOS는 이 함수들이 시스템 라이브러리에 포함되어 있어 -ldl이 필요 없습니다. 경로에 슬래시가 없으면 dlopen은 LD_LIBRARY_PATH, 실행 파일의 RUNPATH, 시스템 캐시 등에서 라이브러리를 찾고 현재 디렉터리는 보지 않으므로, 위처럼 ./를 붙이거나 절대 경로를 씁니다.
dlopen 플래그
RTLD_NOW와 RTLD_LAZY는 라이브러리 안의 함수 참조를 언제 해석할지 정합니다. RTLD_NOW는 로드할 때 모두 해석하므로, 라이브러리가 존재하지 않는 함수를 참조하면 dlopen이 바로 실패합니다. RTLD_LAZY는 각 함수가 처음 호출될 때 해석하므로 로드는 조금 빠르지만, 해석할 수 없는 함수가 있으면 한참 실행하다가 그 함수를 처음 호출하는 순간 symbol lookup error로 프로세스가 종료됩니다. dlsym 자체의 동작에는 영향이 없습니다. 플러그인처럼 외부에서 온 라이브러리라면 문제를 로드 시점에 발견하는 RTLD_NOW가 낫습니다.
RTLD_LOCAL과 RTLD_GLOBAL은 이 라이브러리의 심볼을 이후에 로드되는 라이브러리가 참조할 수 있는지를 정합니다. RTLD_GLOBAL로 로드하면 그 심볼이 전역 검색 범위에 추가되어, 나중에 로드되는 라이브러리의 미해석 참조를 채우는 데 쓰입니다. 두 플러그인이 같은 이름의 전역 함수를 가지고 있으면 먼저 로드된 쪽의 정의가 선택되어, 뒤의 플러그인이 자기 함수 대신 앞 플러그인의 함수를 호출하는 일이 생깁니다. 특별한 이유가 없다면 RTLD_LOCAL을 씁니다.
오류 처리와 RAII
#include <dlfcn.h>
#include <stdexcept>
#include <string>
class DynamicLibrary {
public:
explicit DynamicLibrary(const std::string& path)
: 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 : path));
}
}
~DynamicLibrary() { if (handle_) dlclose(handle_); }
DynamicLibrary(DynamicLibrary&& other) noexcept : handle_(other.handle_) { other.handle_ = nullptr; }
DynamicLibrary& operator=(DynamicLibrary&&) = delete;
DynamicLibrary(const DynamicLibrary&) = delete;
DynamicLibrary& operator=(const DynamicLibrary&) = delete;
template <typename Fn>
Fn symbol(const char* name) const {
dlerror(); // 이전 오류 상태를 비움
void* sym = dlsym(handle_, name);
if (const char* err = dlerror()) {
throw std::runtime_error(std::string("dlsym failed: ") + err);
}
return reinterpret_cast<Fn>(sym);
}
private:
void* handle_;
};
dlsym이 nullptr을 돌려주는 것만으로는 실패라고 단정할 수 없습니다. 심볼의 값이 실제로 NULL일 수 있기 때문입니다. 정확한 방법은 위처럼 호출 전에 dlerror()로 오류 상태를 비우고, 호출 후 dlerror()가 NULL이 아닌지 보는 것입니다. glibc의 dlerror는 스레드별 상태를 가지지만, 한 번 읽으면 지워지므로 한 오류를 두 번 읽을 수는 없습니다.
Windows: LoadLibrary, GetProcAddress
// mylib_win.cpp
#include <cstdio>
extern "C" {
__declspec(dllexport) int add(int a, int b) { return a + b; }
__declspec(dllexport) void greet(const char* name) { std::printf("Hello, %s!\n", name); }
}
cl /LD /EHsc mylib_win.cpp /Fe:mylib.dll # MSVC
g++ -std=c++17 -shared -o mylib.dll mylib_win.cpp # MinGW
Linux의 공유 라이브러리는 기본적으로 모든 전역 심볼을 내보내지만, Windows DLL은 __declspec(dllexport)나 .def 파일로 지정한 심볼만 내보냅니다. 이것을 빠뜨리면 GetProcAddress가 nullptr을 반환합니다. 32비트 x86에서 __stdcall 함수는 extern "C"여도 _add@8처럼 장식된 이름이 될 수 있으므로, 이름을 고정하려면 .def 파일을 씁니다. x64에서는 호출 규약이 하나라서 이 문제가 없습니다.
// main_win.cpp
#include <windows.h>
#include <iostream>
#include <string>
std::string lastErrorMessage() {
DWORD err = GetLastError(); // 다른 API를 부르기 전에 먼저 읽음
if (err == 0) return "no error";
char* buf = nullptr;
DWORD len = FormatMessageA(
FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
nullptr, err, 0, reinterpret_cast<LPSTR>(&buf), 0, nullptr);
std::string msg = len ? std::string(buf, len) : "error " + std::to_string(err);
if (buf) LocalFree(buf);
return msg;
}
int main() {
HMODULE h = LoadLibraryA("mylib.dll");
if (!h) {
std::cerr << "LoadLibrary failed: " << lastErrorMessage() << "\n";
return 1;
}
using AddFn = int (*)(int, int);
auto add = reinterpret_cast<AddFn>(GetProcAddress(h, "add"));
if (!add) {
std::cerr << "GetProcAddress failed: " << lastErrorMessage() << "\n";
FreeLibrary(h);
return 1;
}
std::cout << "add(3, 5) = " << add(3, 5) << "\n";
FreeLibrary(h);
}
GetLastError는 마지막 실패한 API의 오류 코드를 스레드별로 보관하므로, 실패 직후 다른 API(로그 출력 포함)를 호출하기 전에 읽어야 합니다. DLL에 DllMain을 둘 수 있지만, 그 안에서는 로더 잠금을 쥔 상태라서 다른 DLL을 로드하거나 스레드를 기다리는 일을 하면 교착 상태가 날 수 있습니다. 초기화가 필요하면 명시적인 init 함수를 내보내 호스트가 호출하게 하는 편이 안전합니다.
크로스 플랫폼 래퍼
// dynamic_loader.hpp
#pragma once
#include <stdexcept>
#include <string>
#ifdef _WIN32
#include <windows.h>
#else
#include <dlfcn.h>
#endif
class DynamicLoader {
public:
explicit DynamicLoader(const std::string& path) {
#ifdef _WIN32
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()) + ")");
#else
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 : path));
}
#endif
}
~DynamicLoader() {
#ifdef _WIN32
if (handle_) FreeLibrary(static_cast<HMODULE>(handle_));
#else
if (handle_) dlclose(handle_);
#endif
}
DynamicLoader(const DynamicLoader&) = delete;
DynamicLoader& operator=(const DynamicLoader&) = delete;
template <typename Fn>
Fn get(const char* name) const {
#ifdef _WIN32
void* sym = reinterpret_cast<void*>(GetProcAddress(static_cast<HMODULE>(handle_), name));
#else
void* sym = dlsym(handle_, name);
#endif
if (!sym) throw std::runtime_error(std::string("symbol not found: ") + name);
return reinterpret_cast<Fn>(sym);
}
// "mylib" → libmylib.so / libmylib.dylib / mylib.dll (CMake 기본 이름 규칙)
static std::string fileName(const std::string& base) {
#ifdef _WIN32
return base + ".dll";
#elif defined(__APPLE__)
return "lib" + base + ".dylib";
#else
return "lib" + base + ".so";
#endif
}
private:
void* handle_ = nullptr;
};
#include "dynamic_loader.hpp"
#include <iostream>
int main() {
try {
DynamicLoader lib("./" + DynamicLoader::fileName("mylib"));
auto add = lib.get<int (*)(int, int)>("add");
std::cout << "add(3, 5) = " << add(3, 5) << "\n";
} catch (const std::exception& e) {
std::cerr << e.what() << "\n";
return 1;
}
}
함수 포인터 타입으로의 reinterpret_cast는 C++ 표준에서 “조건부 지원”이지만, POSIX가 dlsym 결과를 함수 포인터로 쓰는 것을 요구하므로 주요 플랫폼에서는 문제없이 동작합니다. GCC는 -Wpedantic에서 경고를 낼 수 있습니다.
CMake
cmake_minimum_required(VERSION 3.16)
project(DynamicLoadDemo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
add_library(mylib SHARED mylib.cpp)
add_executable(host main.cpp)
target_link_libraries(host PRIVATE ${CMAKE_DL_LIBS})
add_dependencies(host mylib) # 링크하지 않지만 함께 빌드
CMake는 플랫폼별 기본 이름 규칙(libmylib.so, libmylib.dylib, mylib.dll)을 이미 적용하므로 접두사와 확장자를 직접 지정할 필요가 없습니다. ${CMAKE_DL_LIBS}는 dlopen이 들어 있는 라이브러리가 필요한 플랫폼에서만 그 이름(Linux의 dl)으로 확장됩니다. 호스트가 라이브러리를 링크하지 않으므로 add_dependencies로 빌드 순서만 지정합니다.
조건부 로딩: 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가 없거나 드라이버가 맞지 않으면 초기화 함수가 오류를 반환하므로, 로드 성공만으로 판단하지 않고 초기화까지 확인합니다.
자주 만나는 로드 오류
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 심볼 때문에 언로드할 수 없는 것으로 표시된 경우입니다. 그래서 “닫았으니 다시 열면 새 코드가 로드된다”고 가정한 핫 리로드 코드가 동작하지 않기도 합니다.
경계 설계와 버전 확인
동적으로 로드한 라이브러리와의 경계에서는 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
- ld.so(8) - 검색 순서와 $ORIGIN
- LoadLibraryExA - Microsoft Learn
- Dynamic-Link Library Search Order - Microsoft Learn
- C++ 시리즈 #38-3: PIMPL과 ABI