pybind11로 C++와 Python 연결하기: 모듈·클래스 바인딩, NumPy zero-copy, GIL, 예외 변환
게임 엔진, 데스크톱 도구, 데이터 처리 라이브러리를 만들다 보면 성능이 중요한 부분은 C++로 두고, 자주 바뀌는 로직이나 사용자 자동화, 데이터 파이프라인은 Python에서 다루고 싶어지는 때가 옵니다. 게임 AI나 이벤트 조건처럼 기획 단계에서 자주 바뀌는 로직을 C++에 하드코딩하면 수정할 때마다 빌드를 다시 해야 하고, 반대로 Python으로 짠 파이프라인에서는 일부 루프만 C++로 옮기고 싶어집니다. pybind11은 이 둘을 잇는 대표적인 도구로, C++ 코드 안에 바인딩을 적어 Python 확장 모듈을 만들거나, 반대로 C++ 애플리케이션 안에 Python 인터프리터를 임베드할 수 있게 해 줍니다.
이 글은 함수와 클래스를 Python 모듈로 노출하는 기본부터, NumPy 배열을 복사 없이 주고받는 조건, C++ 예외와 Python 예외의 변환, GIL을 잡고 푸는 위치, 빌드와 배포에서 흔히 부딪히는 에러까지 다룹니다.
예제는 C++17, Python 3.8 이상, CMake 3.15 이상을 기준으로 합니다.
pybind11 모듈로 함수 노출하기
모듈이란?
Python에서 import example로 불러오는 것이 모듈입니다. pybind11은 C++ 코드를 빌드해 .pyd(Windows) 또는 .cpython-3xx-....so(Linux/macOS) 확장 모듈을 만들고, 이 모듈이 C++ 함수·클래스를 Python에 노출합니다.
flowchart LR
subgraph cpp[C++]
F[add, process, ...]
end
subgraph py[Python]
M[example 모듈]
M --> |add| F
M --> |process| F
end
최소 모듈: 함수 하나 노출
// example.cpp — 최소 pybind11 모듈
#include <pybind11/pybind11.h>
#include <string>
namespace py = pybind11;
int add(int a, int b) {
return a + b;
}
std::string greet(const std::string& name) {
return "Hello, " + name + "!";
}
PYBIND11_MODULE(example, m) {
m.doc() = "pybind11 예제 모듈";
m.def("add", &add, "두 정수를 더합니다", py::arg("a"), py::arg("b"));
m.def("greet", &greet, "인사 메시지를 반환합니다", py::arg("name"));
}
PYBIND11_MODULE(example, m)의 첫 인자 example이 import example에 쓰는 모듈 이름이고, 이 이름은 빌드 결과 파일 이름과도 일치해야 합니다. m.def("add", &add, ...)는 C++ 함수 add를 Python 이름 "add"로 등록하며, py::arg("a")를 붙여 두면 Python에서 example.add(a=1, b=2)처럼 키워드 인자로 부를 수 있습니다.
import example
print(example.add(1, 2)) # 3
print(example.add(a=10, b=20)) # 30
print(example.greet("World")) # Hello, World!
오버로드된 함수
C++에서 같은 이름의 함수가 인자 타입만 다를 때는 py::overload_cast로 어느 버전인지 지정해 각각 등록합니다.
#include <pybind11/pybind11.h>
namespace py = pybind11;
int add(int a, int b) { return a + b; }
double add(double a, double b) { return a + b; }
PYBIND11_MODULE(example, m) {
m.def("add", py::overload_cast<int, int>(&add), py::arg("a"), py::arg("b"));
m.def("add", py::overload_cast<double, double>(&add), py::arg("a"), py::arg("b"));
}
import example
print(example.add(1, 2)) # 3 (int)
print(example.add(1.5, 2.5)) # 4.0 (double)
pybind11은 오버로드를 두 번에 걸쳐 고릅니다. 먼저 암시적 변환 없이 인자 타입이 그대로 맞는 오버로드를 등록 순서대로 찾고, 없으면 변환을 허용해서 다시 등록 순서대로 찾습니다. 그래서 add(1, 2)는 등록 순서와 상관없이 int 버전이 선택되지만, 변환이 필요한 호출(예: int와 float를 섞은 add(1, 2.5))에서는 먼저 등록한 오버로드 중 변환 가능한 것이 선택됩니다.
기본값 인자
C++의 기본값은 바인딩에 자동으로 전달되지 않으므로 py::arg("scale") = 1처럼 다시 적어야 합니다.
#include <algorithm>
int process(int x, int scale = 1, bool clamp = false) {
int result = x * scale;
if (clamp) result = std::max(0, std::min(255, result));
return result;
}
PYBIND11_MODULE(example, m) {
m.def("process", &process,
py::arg("x"),
py::arg("scale") = 1,
py::arg("clamp") = false);
}
import example
print(example.process(10)) # 10
print(example.process(10, scale=2)) # 20
print(example.process(200, scale=2, clamp=True)) # 255
STL 컨테이너 자동 변환
#include <pybind11/stl.h>를 추가하면 std::vector, std::map 등이 Python list, dict와 자동으로 변환됩니다.
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <map>
#include <string>
#include <vector>
namespace py = pybind11;
std::vector<int> double_values(const std::vector<int>& input) {
std::vector<int> result;
for (int x : input) result.push_back(x * 2);
return result;
}
std::map<std::string, int> count_chars(const std::string& s) {
std::map<std::string, int> m;
for (char c : s) m[std::string(1, c)]++;
return m;
}
PYBIND11_MODULE(example, m) {
m.def("double_values", &double_values);
m.def("count_chars", &count_chars);
}
import example
print(example.double_values([1, 2, 3])) # [2, 4, 6]
print(example.count_chars("hello")) # {'e': 1, 'h': 1, 'l': 2, 'o': 1}
std::map은 키를 정렬해 저장하므로 결과 dict의 순서도 알파벳순입니다. 이 변환은 매 호출마다 원소를 하나씩 복사합니다. 그래서 C++ 함수가 std::vector<int>&를 받아 내용을 바꿔도 Python 쪽 list에는 반영되지 않고, 큰 리스트를 자주 넘기면 변환 비용이 계산보다 커질 수 있습니다. 큰 수치 데이터는 아래의 NumPy 배열로, 공유해야 하는 컨테이너는 PYBIND11_MAKE_OPAQUE와 py::bind_vector로 참조 형태로 노출합니다.
클래스·프로퍼티·상속 바인딩
기본 클래스 노출
#include <pybind11/pybind11.h>
#include <string>
namespace py = pybind11;
class Calculator {
public:
Calculator() : value_(0) {}
explicit Calculator(int initial) : value_(initial) {}
int add(int x) { value_ += x; return value_; }
int mul(int x) { value_ *= x; return value_; }
int get() const { return value_; }
void reset() { value_ = 0; }
private:
int value_;
};
PYBIND11_MODULE(example, m) {
py::class_<Calculator>(m, "Calculator")
.def(py::init<>())
.def(py::init<int>(), py::arg("initial"))
.def("add", &Calculator::add, py::arg("x"))
.def("mul", &Calculator::mul, py::arg("x"))
.def("get", &Calculator::get)
.def("reset", &Calculator::reset)
.def("__repr__", [](const Calculator& c) {
return "<Calculator value=" + std::to_string(c.get()) + ">";
});
}
py::class_<Calculator>(m, "Calculator")가 C++ 클래스를 Python 클래스로 등록하고, py::init<>()와 py::init<int>()는 생성자 오버로드를 각각 노출합니다. __repr__를 정의해 두면 print(calc)나 대화형 셸에서 객체 내용을 바로 확인할 수 있습니다.
import example
calc = example.Calculator(10)
calc.add(5)
print(calc.get()) # 15
print(calc) # <Calculator value=15>
프로퍼티 (getter/setter)
class Config {
public:
const std::string& get_name() const { return name_; }
void set_name(const std::string& n) { name_ = n; }
int get_level() const { return level_; }
void set_level(int l) { level_ = l; }
private:
std::string name_;
int level_ = 0;
};
PYBIND11_MODULE(example, m) {
py::class_<Config>(m, "Config")
.def(py::init<>())
.def_property("name", &Config::get_name, &Config::set_name)
.def_property("level", &Config::get_level, &Config::set_level);
}
import example
cfg = example.Config()
cfg.name = "Player1"
cfg.level = 10
print(cfg.name, cfg.level) # Player1 10
읽기 전용으로만 노출하려면 def_property_readonly를 씁니다.
.def_property_readonly("value", &Calculator::get)
상속 관계
class Base {
public:
virtual std::string name() const { return "Base"; }
virtual ~Base() = default;
};
class Derived : public Base {
public:
std::string name() const override { return "Derived"; }
};
PYBIND11_MODULE(example, m) {
py::class_<Base>(m, "Base")
.def("name", &Base::name);
py::class_<Derived, Base>(m, "Derived") // Base 상속 명시
.def(py::init<>());
}
py::class_<Derived, Base>로 기반 클래스를 알려 주면 Python에서도 isinstance(d, example.Base)가 참이 되고, Base에 등록한 name을 Derived 객체에서 호출해도 C++의 가상 함수 디스패치로 "Derived"가 반환됩니다. 다만 이것은 C++ 쪽 파생 클래스에 대한 이야기입니다. Python에서 class MyBase(example.Base)를 만들어 name을 오버라이드하고 그 객체를 C++ 코드가 호출하게 하려면, PYBIND11_OVERRIDE 매크로를 쓰는 “트램펄린” 클래스를 따로 정의해야 합니다. 그렇지 않으면 C++ 쪽에서는 Python의 오버라이드가 보이지 않습니다.
스마트 포인터 반환
C++에서 std::shared_ptr로 관리하는 객체는 홀더 타입을 py::class_의 두 번째 인자로 지정하면, Python에 넘긴 뒤에도 C++ 쪽과 참조 카운트를 공유합니다.
#include <memory>
class Engine {
public:
void run() { /* ... */ }
};
std::shared_ptr<Engine> create_engine() {
return std::make_shared<Engine>();
}
PYBIND11_MODULE(example, m) {
py::class_<Engine, std::shared_ptr<Engine>>(m, "Engine")
.def(py::init<>())
.def("run", &Engine::run);
m.def("create_engine", &create_engine);
}
홀더를 지정하지 않으면 기본 홀더는 std::unique_ptr입니다. 이 상태에서 std::shared_ptr<Engine>을 반환하는 함수를 등록하면 홀더 타입이 맞지 않아 런타임 에러가 나므로, 한 클래스에 대해 어떤 홀더를 쓸지 처음부터 정해 둡니다.
NumPy 배열 주고받기
NumPy 배열을 C++에 넘기기
py::array_t<T>로 NumPy ndarray를 받고, request()로 버퍼 정보(포인터, 차원, shape, strides)를 얻습니다. 배열의 dtype이 T와 같고 메모리 배치가 요구 조건과 맞으면 복사 없이 Python 배열과 같은 메모리를 가리킵니다.
주의할 점은 py::array_t<double>의 기본 플래그에 forcecast가 포함되어 있다는 것입니다. 정수 배열을 넘기면 pybind11이 조용히 double 배열로 변환한 복사본을 만들어 넘깁니다. 또 기본 상태에서는 연속 메모리가 보장되지 않아, arr[::2]처럼 슬라이싱된 배열을 ptr[i]로 순회하면 엉뚱한 원소를 읽습니다. 그래서 읽기만 하는 함수는 c_style | forcecast로 “필요하면 연속 배열로 변환해 달라”고 요구하고, 제자리 수정 함수는 forcecast를 빼고 인자에 noconvert()까지 붙여 조건이 맞지 않으면 아예 TypeError가 나게 하는 편이 안전합니다. forcecast를 빼는 것만으로는 부족한데, NumPy가 안전하다고 보는 변환(정수 → 실수 등)이나 비연속 → 연속 복사는 여전히 일어나기 때문입니다.
#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
#include <stdexcept>
namespace py = pybind11;
using ConstArray = py::array_t<double, py::array::c_style | py::array::forcecast>;
using InplaceArray = py::array_t<double, py::array::c_style>; // 변환 없이 원본만 허용
double sum_array(ConstArray arr) {
auto buf = arr.request();
if (buf.ndim != 1) {
throw std::runtime_error("1차원 배열만 지원합니다");
}
const double* ptr = static_cast<const double*>(buf.ptr);
double s = 0;
for (py::ssize_t i = 0; i < buf.size; ++i) s += ptr[i];
return s;
}
// 제자리(in-place) 수정: dtype이 float64이고 C 연속인 배열만 받음
void scale_array(InplaceArray arr, double factor) {
double* p = arr.mutable_data(); // 쓰기 불가 배열이면 예외
for (py::ssize_t i = 0; i < arr.size(); ++i) {
p[i] *= factor;
}
}
PYBIND11_MODULE(example, m) {
m.def("sum_array", &sum_array);
m.def("scale_array", &scale_array, py::arg("arr").noconvert(), py::arg("factor"));
}
import numpy as np
import example
arr = np.array([1.0, 2.0, 3.0, 4.0])
print(example.sum_array(arr)) # 10.0
example.scale_array(arr, 2.0)
print(arr) # [2. 4. 6. 8.] — 제자리 수정됨
example.scale_array(np.array([1, 2, 3]), 2.0) # int64 배열: TypeError
py::arg("arr").noconvert()는 이 인자에 대해 암시적 변환을 시도하지 말라는 표시입니다. 만약 scale_array가 변환을 허용했다면, 정수 배열을 넘겼을 때 변환된 임시 복사본만 두 배가 되고 원본은 그대로인 채로 에러 없이 끝납니다. 제자리 수정 API에서 가장 찾기 어려운 버그가 이 형태입니다.
C++에서 NumPy 배열 반환
py::array_t<T>를 만들어 반환합니다. 새로 만든 배열은 C 연속이고 쓰기 가능합니다.
#include <algorithm>
py::array_t<double> create_zeros(py::ssize_t n) {
py::array_t<double> arr(n);
std::fill(arr.mutable_data(), arr.mutable_data() + n, 0.0);
return arr;
}
py::array_t<double> linspace(double start, double stop, py::ssize_t num) {
py::array_t<double> arr(num);
double* ptr = arr.mutable_data();
double step = (num > 1) ? (stop - start) / (num - 1) : 0;
for (py::ssize_t i = 0; i < num; ++i) {
ptr[i] = start + step * i;
}
return arr;
}
PYBIND11_MODULE(example, m) {
m.def("create_zeros", &create_zeros);
m.def("linspace", &linspace);
}
import numpy as np
import example
z = example.create_zeros(5)
print(z) # [0. 0. 0. 0. 0.]
x = example.linspace(0, 1, 5)
print(x) # [0. 0.25 0.5 0.75 1. ]
C++에서 이미 가지고 있는 std::vector를 복사 없이 넘기고 싶다면, 벡터를 힙에 옮기고 그 수명을 py::capsule에 맡긴 뒤 py::array_t<double>(size, data, capsule)로 배열을 만들면 됩니다. 이렇게 하면 NumPy 배열이 살아 있는 동안 벡터가 해제되지 않습니다.
2차원 배열 (행렬)
py::array_t<double> matmul(ConstArray a, ConstArray b) {
auto buf_a = a.request();
auto buf_b = b.request();
if (buf_a.ndim != 2 || buf_b.ndim != 2) {
throw std::runtime_error("2차원 배열만 지원합니다");
}
py::ssize_t M = buf_a.shape[0], K = buf_a.shape[1], N = buf_b.shape[1];
if (buf_b.shape[0] != K) {
throw std::runtime_error("행렬 차원 불일치");
}
const double* ptr_a = static_cast<const double*>(buf_a.ptr);
const double* ptr_b = static_cast<const double*>(buf_b.ptr);
py::array_t<double> result({M, N});
double* ptr_r = result.mutable_data();
std::fill(ptr_r, ptr_r + M * N, 0.0);
for (py::ssize_t i = 0; i < M; ++i) {
for (py::ssize_t k = 0; k < K; ++k) {
double aik = ptr_a[i * K + k];
for (py::ssize_t j = 0; j < N; ++j) {
ptr_r[i * N + j] += aik * ptr_b[k * N + j];
}
}
}
return result;
}
i * K + k 같은 인덱스 계산은 배열이 C 연속이라는 가정에 기대므로, 매개변수 타입을 c_style 플래그가 붙은 ConstArray로 받았습니다. 루프 순서를 i-k-j로 둔 것은 안쪽 루프에서 ptr_b와 ptr_r을 연속으로 읽고 쓰게 해 캐시 효율을 높이기 위해서입니다. 실제 행렬 곱이라면 직접 짜기보다 NumPy 자체(BLAS 사용)나 Eigen을 쓰는 것이 훨씬 빠릅니다. 이 예제는 버퍼 접근 방법을 보이기 위한 것입니다.
C++ 예외와 Python 예외 변환
C++ 예외 → Python 예외
pybind11은 바인딩된 함수에서 빠져나온 C++ 예외를 Python 예외로 자동 변환합니다. 주요 대응은 다음과 같고, 표에 없는 std::exception 파생 예외(예: std::logic_error 자체)는 RuntimeError가 됩니다.
| C++ 예외 | Python 예외 |
|---|---|
std::exception | RuntimeError |
std::bad_alloc | MemoryError |
std::invalid_argument, std::domain_error, std::length_error, std::range_error | ValueError |
std::out_of_range | IndexError |
std::overflow_error | OverflowError |
#include <pybind11/pybind11.h>
#include <stdexcept>
namespace py = pybind11;
int safe_divide(int a, int b) {
if (b == 0) {
throw std::invalid_argument("0으로 나눌 수 없습니다");
}
return a / b;
}
PYBIND11_MODULE(example, m) {
m.def("safe_divide", &safe_divide);
}
import example
try:
example.safe_divide(10, 0)
except ValueError as e:
print(e) # 0으로 나눌 수 없습니다
사용자 정의 예외 등록
class CustomError : public std::exception {
public:
explicit CustomError(const std::string& msg) : msg_(msg) {}
const char* what() const noexcept override { return msg_.c_str(); }
private:
std::string msg_;
};
void may_fail(int x) {
if (x < 0) throw CustomError("음수는 허용되지 않습니다: " + std::to_string(x));
}
PYBIND11_MODULE(example, m) {
py::register_exception<CustomError>(m, "CustomError");
m.def("may_fail", &may_fail);
}
import example
try:
example.may_fail(-1)
except example.CustomError as e:
print(e) # 음수는 허용되지 않습니다: -1
register_exception으로 만든 Python 예외는 기본적으로 Exception을 상속합니다. 기존 Python 코드가 ValueError로 잡고 있었다면 세 번째 인자로 PyExc_ValueError를 넘겨 기반 클래스를 맞출 수 있습니다.
Python 예외를 C++에서 잡기
C++에서 Python 코드를 호출하다 Python 예외가 발생하면 pybind11은 이를 py::error_already_set C++ 예외로 던집니다. 이 객체는 Python 쪽 예외 정보(타입, 값, 트레이스백)를 들고 있으므로, C++에서 처리하고 끝낼 때는 메시지를 기록한 뒤 그대로 두면 되고, Python 호출자에게 다시 넘길 때는 다시 던지면 됩니다.
#include <pybind11/embed.h>
#include <iostream>
namespace py = pybind11;
int run_script(const std::string& code) {
try {
py::exec(code);
return 0;
} catch (const py::error_already_set& e) {
std::cerr << "Python 예외: " << e.what() << '\n';
if (e.matches(PyExc_KeyboardInterrupt)) throw; // 중단 요청은 위로 전달
return -1;
}
}
e.restore()는 예외를 Python의 에러 표시자로 되돌려 놓는 함수라, C 확장의 반환 경로처럼 바로 Python으로 돌아갈 때만 씁니다. C++에서 처리를 끝내는 코드에서 호출하면 에러 표시자가 남은 채로 다음 Python API 호출이 이어져 엉뚱한 곳에서 SystemError가 납니다.
예외 메시지에 문맥 덧붙이기
m.def("process", [](int x) {
try {
return do_process(x);
} catch (const std::exception& e) {
throw std::runtime_error(
std::string("process 실패 (x=") + std::to_string(x) + "): " + e.what());
}
});
이렇게 감싸면 Python 쪽 트레이스백만 보고도 어떤 입력에서 실패했는지 알 수 있습니다. 다만 원래 예외 타입이 std::invalid_argument였다면 ValueError가 RuntimeError로 바뀌므로, Python 코드가 예외 타입으로 분기한다면 타입을 유지해서 다시 던져야 합니다.
이미지 필터 엔진부터 Python 임베딩까지
이미지 필터 엔진
C++로 그레이스케일 변환을 구현하고, Python에서 NumPy 이미지를 넘겨 호출합니다.
// image_filter.cpp
#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
#include <cstdint>
#include <stdexcept>
namespace py = pybind11;
using Image = py::array_t<std::uint8_t, py::array::c_style | py::array::forcecast>;
py::array_t<std::uint8_t> grayscale(Image rgb) {
auto buf = rgb.request();
if (buf.ndim != 3 || buf.shape[2] != 3) {
throw std::invalid_argument("RGB 이미지 (H, W, 3) 필요");
}
py::ssize_t H = buf.shape[0], W = buf.shape[1];
const std::uint8_t* src = static_cast<const std::uint8_t*>(buf.ptr);
py::array_t<std::uint8_t> result({H, W});
std::uint8_t* dst = result.mutable_data();
{
py::gil_scoped_release release; // 순수 C++ 루프 동안 다른 Python 스레드 진행 허용
for (py::ssize_t i = 0; i < H * W; ++i) {
double r = src[i * 3 + 0], g = src[i * 3 + 1], b = src[i * 3 + 2];
dst[i] = static_cast<std::uint8_t>(0.299 * r + 0.587 * g + 0.114 * b + 0.5);
}
}
return result;
}
PYBIND11_MODULE(image_filter, m) {
m.doc() = "이미지 필터 C++ 확장";
m.def("grayscale", &grayscale, "RGB를 그레이스케일로 변환");
}
# test_filter.py
import numpy as np
import image_filter
rgb = np.random.randint(0, 256, (100, 100, 3), dtype=np.uint8)
gray = image_filter.grayscale(rgb)
print(gray.shape) # (100, 100)
print(gray.dtype) # uint8
가중치는 ITU-R BT.601 휘도 계수이고, + 0.5는 버림 대신 반올림하기 위한 것입니다. 이미지를 img[:, ::-1]처럼 뒤집어 넘기면 비연속 배열이 되는데, c_style | forcecast 덕분에 pybind11이 연속 복사본을 만들어 주므로 결과는 올바르게 나옵니다.
게임 엔진 스크립트 API
// game_engine.cpp
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>
#include <map>
#include <stdexcept>
#include <string>
#include <vector>
namespace py = pybind11;
class GameEngine {
public:
void spawn(const std::string& entity_id, double x, double y) {
entities_[entity_id] = {x, y};
}
std::pair<double, double> get_position(const std::string& entity_id) {
auto it = entities_.find(entity_id);
if (it == entities_.end()) {
throw std::out_of_range("엔티티 없음: " + entity_id);
}
return it->second;
}
std::vector<std::string> list_entities() {
std::vector<std::string> out;
for (const auto& p : entities_) out.push_back(p.first);
return out;
}
private:
std::map<std::string, std::pair<double, double>> entities_;
};
PYBIND11_MODULE(game_engine, m) {
py::class_<GameEngine>(m, "GameEngine")
.def(py::init<>())
.def("spawn", &GameEngine::spawn,
py::arg("entity_id"), py::arg("x"), py::arg("y"))
.def("get_position", &GameEngine::get_position)
.def("list_entities", &GameEngine::list_entities);
}
# game_script.py
import game_engine
engine = game_engine.GameEngine()
engine.spawn("player", 100.0, 200.0)
engine.spawn("enemy_1", 50.0, 50.0)
print(engine.get_position("player")) # (100.0, 200.0)
print(engine.list_entities()) # ['enemy_1', 'player'] (std::map이라 정렬됨)
CMake 빌드 설정
# CMakeLists.txt
cmake_minimum_required(VERSION 3.15)
project(PythonScripting LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
find_package(Python COMPONENTS Interpreter Development REQUIRED)
find_package(pybind11 CONFIG REQUIRED)
# 확장 모듈: pybind11_add_module이 pybind11::module 링크와 확장자 설정을 처리함
pybind11_add_module(image_filter image_filter.cpp)
pybind11_add_module(game_engine game_engine.cpp)
# 임베딩 실행 파일: libpython에 링크해야 하므로 pybind11::embed 사용
add_executable(embed_main embed_main.cpp)
target_link_libraries(embed_main PRIVATE pybind11::embed)
# 빌드 및 실행
mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release
cmake --build .
cd ..
PYTHONPATH=build python test_filter.py
PYTHONPATH=build python game_script.py
확장 모듈은 libpython에 링크하지 않고 이 모듈을 불러오는 인터프리터의 심볼을 쓰는 반면, 임베딩 실행 파일은 인터프리터 자체를 포함해야 하므로 libpython에 링크해야 합니다. 둘에 같은 타깃 설정을 쓰면 링크 에러가 나거나, 확장 모듈이 다른 libpython을 끌고 와 실행 중 크래시가 납니다.
C++ 앱에서 Python 임베딩
// embed_main.cpp
#include <pybind11/embed.h>
namespace py = pybind11;
int main() {
py::scoped_interpreter guard{}; // 인터프리터 시작, 소멸 시 종료
py::module_ sys = py::module_::import("sys");
py::print("Python", sys.attr("version"));
sys.attr("path").attr("insert")(0, "build"); // game_engine 모듈 위치
py::exec(R"(
import game_engine
engine = game_engine.GameEngine()
engine.spawn("test", 1.0, 2.0)
print("Position:", engine.get_position("test"))
)");
return 0;
}
py::exec에 넘기는 코드는 들여쓰기도 Python 문법으로 해석되므로, C++ 소스의 들여쓰기에 맞춰 원시 문자열 안의 코드를 들여 쓰면 IndentationError가 납니다. 코드를 왼쪽 끝에 붙여 쓰거나 Python의 textwrap.dedent를 거쳐 실행합니다. 확장 모듈을 따로 빌드하지 않고 실행 파일 안에 바로 넣고 싶다면 PYBIND11_EMBEDDED_MODULE(app_api, m)로 정의한 모듈을 import app_api로 불러올 수도 있습니다.
모듈 import·심볼·GIL 데드락 문제
”ModuleNotFoundError: No module named ‘example’”
빌드된 .pyd/.so 파일이 Python이 찾는 경로에 없는 경우입니다. 빌드 디렉터리를 PYTHONPATH에 추가하거나, 패키지로 만들어 pip install .로 설치합니다. 파일 이름의 cpython-311 같은 태그가 실행 중인 Python 버전과 다르면 파일이 있어도 찾지 못합니다.
export PYTHONPATH=/path/to/build:$PYTHONPATH
python -c "import example"
”ImportError: dynamic module does not define module export function (PyInit_example)”
PYBIND11_MODULE의 첫 인자와 파일 이름(import 이름)이 다를 때 나는 에러입니다. PYBIND11_MODULE(my_engine, m)이라면 결과물은 my_engine.cpython-3xx-...so여야 하고 import my_engine으로 불러와야 합니다.
”ImportError: … undefined symbol: _ZN6…”
C++ 심볼을 찾지 못한다는 에러로, 모듈이 의존하는 다른 C++ 라이브러리를 링크하지 않았거나(target_link_libraries 누락), 서로 다른 컴파일러·표준 라이브러리 버전으로 빌드한 라이브러리를 섞었을 때 주로 납니다. c++filt로 심볼 이름을 풀어 보면 어떤 함수가 빠졌는지 알 수 있습니다. 확장 모듈을 빌드한 Python과 다른 버전의 Python에서 불러온 경우에는 보통 이 에러보다 앞에서 모듈을 찾지 못하거나 ABI 관련 에러가 납니다.
”find_package(pybind11) failed”
CMake가 pybind11의 설정 파일을 찾지 못한 경우입니다. pip로 설치했다면 그 위치를 알려 줍니다.
pip install pybind11
cmake .. -Dpybind11_DIR=$(python3 -m pybind11 --cmakedir)
소스를 함께 가져오려면 FetchContent를 씁니다. GIT_TAG에는 사용할 릴리스 태그를 고정합니다.
include(FetchContent)
FetchContent_Declare(
pybind11
GIT_REPOSITORY https://github.com/pybind/pybind11.git
GIT_TAG v2.13.6
)
FetchContent_MakeAvailable(pybind11)
”Buffer has wrong number of dimensions”
NumPy 배열의 차원이 C++에서 기대한 것과 다를 때 납니다. 예를 들어 matmul에 np.array([1, 2, 3]) 같은 1차원 배열을 넘긴 경우입니다. Python 쪽에서 np.atleast_2d(arr)로 맞춰 넘기거나, C++에서 받은 차원을 메시지에 넣어 원인을 바로 알 수 있게 합니다.
if (buf.ndim != 2) {
throw std::invalid_argument("2차원 배열이 필요합니다 (받은 차원: " +
std::to_string(buf.ndim) + ")");
}
”TypeError: Unable to convert function return value”
C++에서 반환한 타입을 pybind11이 Python 타입으로 바꾸는 방법을 모를 때 납니다. 반환 타입의 클래스를 py::class_로 등록하지 않았거나, STL 컨테이너를 반환하면서 <pybind11/stl.h>를 포함하지 않은 경우가 대부분입니다. 같은 이유로 인자 쪽에서는 “incompatible function arguments” 에러가 납니다.
GIL 관련 크래시와 데드락
C++에서 만든 스레드가 GIL 없이 Python API를 호출하면 크래시가 나거나 상태가 깨집니다. 콜백을 부르기 전에 GIL을 획득합니다.
#include <pybind11/gil.h>
void callback_from_cpp_thread(const py::function& cb) {
py::gil_scoped_acquire acquire;
cb("C++ 스레드에서 호출");
}
반대 방향의 실수도 흔합니다. 긴 계산을 위해 py::gil_scoped_release로 GIL을 푼 구간 안에서 py::print를 부르거나 py::array_t의 request()·shape()처럼 Python 객체를 건드리면 Fatal Python error: PyThreadState_Get: no current thread로 프로세스가 죽습니다. 버퍼 포인터와 크기는 GIL을 쥔 상태에서 먼저 꺼내 두고, 해제 구간에서는 그 순수 C++ 데이터만 다룹니다.
double sum(py::array_t<double, py::array::c_style | py::array::forcecast> arr) {
auto buf = arr.request(); // GIL을 쥔 상태에서 꺼냄
const double* p = static_cast<const double*>(buf.ptr);
const py::ssize_t n = buf.size;
py::gil_scoped_release release; // 여기부터 Python 객체 접근 금지
double s = 0;
for (py::ssize_t i = 0; i < n; ++i) s += p[i];
return s; // release 소멸 시 GIL 재획득
}
중간에 Python 객체가 꼭 필요하면 그 부분만 py::gil_scoped_acquire로 감쌉니다. 또 GIL을 풀어도 다른 스레드가 같은 NumPy 배열을 동시에 수정하지 않는다는 보장은 없으므로, 공유 배열에 쓰는 코드는 별도로 동기화해야 합니다. C++ 스레드가 콜백으로 쓸 py::function을 보관해 둘 때는 그 객체의 복사와 소멸도 Python 참조 카운트를 바꾸므로 GIL을 쥔 상태에서 해야 합니다.
Windows: “LINK : fatal error LNK1104: cannot open file ‘python311.lib’”
링커가 Python 개발 라이브러리를 찾지 못하는 경우입니다. python.org의 “embeddable package”나 일부 축약 배포판에는 libs\python3xx.lib가 없고, 여러 Python이 설치되어 CMake가 다른 버전을 고른 경우에도 납니다. 개발 파일이 포함된 전체 설치판을 쓰고, cmake -DPython_EXECUTABLE=<모듈을 import할 python.exe 경로>(pybind11의 예전 FindPythonInterp 모드라면 -DPYTHON_EXECUTABLE=)처럼 빌드에 쓸 인터프리터를 명시해 실행 환경과 같은 버전으로 맞춥니다.
”Fatal Python error: init_fs_encoding: failed to get the Python codec of the filesystem encoding”
Python을 임베드한 실행 파일이 표준 라이브러리(encodings 패키지)를 찾지 못한 경우입니다. 잘못된 PYTHONHOME이 설정되어 있거나, 링크한 libpython과 다른 설치의 표준 라이브러리를 보고 있을 때 납니다. PYTHONHOME을 지우거나 링크한 Python의 prefix로 맞추고, 앱과 함께 Python을 배포한다면 표준 라이브러리 경로를 PyConfig로 명시합니다.
NumPy 배열 수정 시 “array is not writeable”
C++에서 mutable_data()나 mutable_unchecked()를 호출했는데 넘어온 배열이 읽기 전용일 때 납니다. np.frombuffer(bytes_obj)로 만든 배열이나 다른 배열의 읽기 전용 뷰가 흔한 경우입니다. Python 쪽에서 arr.copy()로 쓰기 가능한 배열을 만들어 넘기거나, 함수가 원본을 수정하지 않고 새 배열을 반환하도록 설계를 바꿉니다.
바인딩을 다듬는 습관
docstring과 버전 정보
m.def의 문자열 인자는 docstring이 되고, py::arg의 이름과 기본값은 Python의 시그니처 표시에 반영됩니다. Python에서 help(example.add)로 확인할 수 있습니다.
m.def("add", &add,
"두 정수를 더합니다.\n\n"
"a: 첫 번째 정수\n"
"b: 두 번째 정수",
py::arg("a") = 0, py::arg("b") = 0);
m.attr("__version__") = "1.0.0";
NumPy 입력 검증
배열 함수는 차원과 크기를 먼저 확인합니다. 빈 배열도 데이터 포인터가 널이 아닐 수 있으므로, “비어 있는가”는 포인터가 아니라 size로 판단합니다.
double process(py::array_t<double, py::array::c_style | py::array::forcecast> arr) {
auto buf = arr.request();
if (buf.ndim != 1)
throw std::invalid_argument("1차원 배열 필요");
if (buf.size == 0)
throw std::invalid_argument("빈 배열");
// ...
}
NumPy 의존성
pybind11 자체는 헤더 전용이고, pybind11/numpy.h도 컴파일할 때 NumPy 헤더를 요구하지 않습니다. 대신 py::array_t를 처음 사용할 때 pybind11이 내부적으로 NumPy를 import하므로, 이 기능을 쓰는 모듈은 실행 환경에 NumPy가 설치되어 있어야 합니다. 패키지로 배포한다면 numpy를 런타임 의존성에 넣어 둡니다.
스크립트 API를 운영할 때 생각할 것
샌드박스는 같은 프로세스 안에서 만들 수 없다
노출하는 C++ API를 줄이는 것만으로는 사용자 스크립트를 가둘 수 없습니다. 임베드된 CPython 안에서 실행되는 스크립트는 import os, import subprocess, open()을 그대로 쓸 수 있고, 내장 함수를 지우거나 import를 막는 시도는 객체 내부 속성을 거슬러 올라가는 방식으로 우회된다는 사실이 오래전부터 알려져 있습니다. Python 문서도 audit hook(sys.addaudithook) 같은 기능이 샌드박스가 아니라고 명시합니다. 신뢰할 수 없는 스크립트를 실행해야 한다면 별도 프로세스로 띄우고 운영체제 수준의 격리(컨테이너, seccomp, 권한이 낮은 사용자, 제한된 파일 시스템)를 적용해야 합니다.
같은 프로세스에서 실행하는 것은 사용자가 자기 컴퓨터에서 직접 쓰는 매크로처럼 스크립트 작성자를 신뢰할 수 있는 경우로 한정합니다. 이때도 파일 경로를 받는 API는 문자열에서 ..을 찾는 식으로 막지 말고, std::filesystem::weakly_canonical로 정규화한 경로가 허용된 루트 아래에 있는지 비교해야 절대 경로나 심볼릭 링크로 빠져나가는 경우를 막을 수 있습니다.
스크립트 컴파일 결과 캐싱
같은 스크립트를 반복 실행한다면 compile() 결과(코드 객체)를 캐시해 두면 매번 구문 분석하는 비용이 사라집니다.
#include <string>
#include <unordered_map>
class ScriptCache {
public:
py::object get(const std::string& script) {
auto it = cache_.find(script);
if (it != cache_.end()) return it->second;
py::object code = py::module_::import("builtins").attr("compile")(
script, "<script>", "exec");
cache_.emplace(script, code);
return code;
}
void clear() { cache_.clear(); } // 인터프리터 종료 전에 호출
private:
std::unordered_map<std::string, py::object> cache_;
};
캐시를 함수 안의 static 변수로 두면 안 됩니다. 정적 객체는 main이 끝난 뒤 소멸하는데, 그때는 scoped_interpreter가 이미 인터프리터를 종료한 상태라 py::object의 소멸자가 해제된 인터프리터의 참조 카운트를 건드려 종료 시점에 크래시가 납니다. 캐시는 인터프리터보다 먼저 사라지는 객체가 소유하게 합니다.
실행 시간 제한
무한 루프에 빠진 스크립트를 멈추는 가장 확실한 방법은 별도 프로세스에서 실행하고 시간이 지나면 프로세스를 종료하는 것입니다. 같은 프로세스라면 감시 스레드에서 PyErr_SetInterrupt()를 호출해 KeyboardInterrupt를 일으킬 수 있지만, 이 요청은 메인 스레드에서 Python 바이트코드를 실행하는 중에만 처리되므로, 스크립트를 다른 스레드에서 돌리거나 C 확장 안에서 오래 도는 코드는 멈추지 못합니다.
로깅과 설정 주입
C++ 로거를 Python에 노출하면 사용자 스크립트의 로그도 같은 곳에 모입니다. 설정값은 스크립트를 실행할 전역 이름공간(dict)에 넣어 주면 스크립트에서 config["key"]로 읽을 수 있습니다.
m.def("log", [](int level, const std::string& msg) {
Logger::get().log(level, msg);
});
// 임베딩 쪽: 스크립트 실행용 전역 이름공간에 config를 넣고 실행
py::dict scope;
scope["__builtins__"] = py::module_::import("builtins");
py::dict cfg;
for (const auto& [k, v] : app_config) cfg[py::str(k)] = py::str(v);
scope["config"] = cfg;
py::exec(script_code, scope);
wheel 배포
pip install my_engine으로 설치되는 패키지를 만들려면 빌드 백엔드가 확장 모듈을 컴파일하도록 설정해야 합니다. setuptools를 쓴다면 pyproject.toml만으로는 부족하고 setup.py에 pybind11.setup_helpers.Pybind11Extension으로 확장 모듈을 정의해야 하며, 위의 CMake 설정을 그대로 쓰고 싶다면 scikit-build-core를 백엔드로 지정하는 방법이 간단합니다.
# pyproject.toml (scikit-build-core + 위 CMakeLists.txt 재사용)
[build-system]
requires = ["scikit-build-core", "pybind11"]
build-backend = "scikit_build_core.build"
[project]
name = "my_engine"
version = "1.0.0"
requires-python = ">=3.8"
dependencies = ["numpy"]
이 경우 CMakeLists.txt에 install(TARGETS image_filter game_engine LIBRARY DESTINATION .)처럼 설치 대상을 적어 두어야 wheel 안에 모듈이 들어갑니다. 확장 모듈은 빌드에 쓴 Python의 마이너 버전과 ABI에 묶이기 때문에(파일 이름의 cpython-311-x86_64-linux-gnu 부분이 그 표시입니다) 지원할 Python 버전마다 wheel이 따로 필요합니다. 로컬에서 빌드한 .so를 다른 리눅스 배포판에 그대로 옮기면 glibc 버전 차이로 로드되지 않으므로, Linux wheel은 quay.io/pypa/manylinux_2_28_x86_64 같은 manylinux 이미지 안에서 빌드하고, 버전·플랫폼 조합은 cibuildwheel 같은 도구로 CI에서 한꺼번에 만드는 방식이 흔합니다.