C++ Python 스크립팅 완벽 가이드 | pybind11 모듈·클래스·NumPy·예외 처리 [실전]

C++ Python 스크립팅 완벽 가이드 | pybind11 모듈·클래스·NumPy·예외 처리 [실전]

이 글의 핵심

C++ 앱에 Python 스크립팅을 붙일 때: pybind11 모듈 바인딩, 클래스 바인딩, NumPy 연동, 예외 처리. 문제 시나리오, 완전한 예제, 흔한 에러, 베스트 프랙티스, 프로덕션 패턴.

들어가며: “C++ 엔진에 사용자 스크립트를 붙이고 싶어요”

실제 겪는 문제 시나리오

게임 엔진·도구·플러그인 시스템을 만들 때, 로직을 C++에 박아두면 수정할 때마다 재빌드가 필요합니다. 반대로 Python만 쓰면 성능 병목이 생깁니다. 해결: C++로 핵심 엔진을 만들고, Python으로 스크립팅·플러그인·AI 파이프라인을 붙이는 하이브리드 구조입니다. pybind11은 이 과정을 가장 쉽게 만들어 주는 도구입니다.

flowchart TD
  subgraph wrong[❌ C++만 사용]
    W1[로직 수정] --> W2[전체 재빌드]
    W2 --> W3[15분 대기]
    W3 --> W4[개발 속도 저하]
  end
  subgraph right[✅ C++ + Python 스크립팅]
    R1[로직 수정] --> R2[Python 스크립트만 수정]
    R2 --> R3[재시작 또는 핫 리로드]
    R3 --> R4[빠른 반복 개발]
  end

문제의 핵심:

  • C++ 엔진은 고성능이지만 수정·배포가 무겁습니다.
  • Python은 생산성이 높지만 순수 루프는 느립니다.
  • pybind11로 C++ API를 Python에 노출하면, 양쪽 장점을 모두 활용할 수 있습니다.

이 글에서 다루는 것:

  • 문제 시나리오: Python 스크립팅이 필요한 실제 상황
  • 모듈 바인딩: C++ 함수를 Python 모듈로 노출
  • 클래스 바인딩: C++ 클래스를 Python 클래스로 노출
  • NumPy 연동: 배열을 복사 없이 넘기고 받기
  • 예외 처리: C++ 예외 ↔ Python 예외 변환
  • 자주 발생하는 에러와 해결법
  • 베스트 프랙티스프로덕션 패턴

요구 환경: C++17 이상, Python 3.6+, pybind11, CMake 3.15+

이 글을 읽으면:

  • pybind11으로 C++ API를 Python에 노출할 수 있습니다.
  • 모듈·클래스·NumPy·예외를 실전 수준으로 다룰 수 있습니다.
  • 프로덕션에서 겪는 문제를 예방하고 해결할 수 있습니다.

실무 적용 경험: 이 글은 대규모 C++ 프로젝트에서 실제로 겪은 문제와 해결 과정을 바탕으로 작성되었습니다. 책이나 문서에서 다루지 않는 실전 함정과 디버깅 팁을 포함합니다.

목차

  1. 문제 시나리오: Python 스크립팅이 필요한 순간
  2. pybind11 기본: 모듈 바인딩
  3. 클래스 바인딩
  4. NumPy 연동
  5. 예외 처리
  6. 완전한 Python 스크립팅 예제
  7. 자주 발생하는 에러와 해결법
  8. 베스트 프랙티스
  9. 프로덕션 패턴
  10. 구현 체크리스트

1. 문제 시나리오: Python 스크립팅이 필요한 순간

시나리오 1: 게임 AI 로직 수정 시마다 15분 빌드

문제: NPC 행동, 스킬 밸런스, 이벤트 트리거가 C++에 하드코딩되어 있습니다. 기획자가 “이 NPC가 5m 이내로 오면 도망가게 해줘”라고 요청할 때마다 C++ 수정 → 전체 빌드 → 테스트가 반복됩니다.

해결: C++ 엔진이 run_script("ai/npc_flee.py") 같은 API를 제공하고, Python 스크립트에서 조건·행동을 정의합니다. 스크립트만 수정하면 재시작 없이 적용 가능합니다.


시나리오 2: AI·데이터 파이프라인에서 C++ 연산 호출

문제: Python으로 전처리·학습 파이프라인을 짜고 있는데, 특정 루프(이미지 정규화, 커스텀 손실 함수)만 C++로 옮기고 싶습니다. ctypes·cffi는 수동 래핑이 번거롭습니다.

해결: pybind11으로 C++ 함수·클래스를 Python 모듈로 노출하면, import engine 한 줄로 고성능 연산을 호출할 수 있습니다. NumPy 배열을 복사 없이 넘길 수 있어 메모리 효율도 좋습니다.


시나리오 3: 사용자 플러그인·모드 지원

문제: 에디터·도구에서 사용자가 커스텀 동작을 추가하고 싶어 합니다. C++ 플러그인 DLL은 빌드 환경이 복잡하고, 보안 위험도 있습니다.

해결: Python 스크립트로 플러그인 API를 노출하면, 사용자가 스크립트만 작성해 확장할 수 있습니다. 샌드박스로 제한된 API만 제공해 안전하게 합니다.


시나리오 4: 설정·이벤트 시퀀스의 복잡한 조건

문제: 퀘스트·이벤트·대화 시퀀스가 복잡한 조건 분기로 이어집니다. C++에 하드코딩하면 가독성과 유지보수가 어렵고, JSON/YAML만으로는 표현력이 부족합니다.

해결: Python 스크립트로 이벤트 시퀀스를 정의하면, 기획·스크립터가 직접 수정하기 쉽습니다. if player.level >= 10 and quest_state[dragon] == "defeated": 같은 로직을 자연스럽게 표현할 수 있습니다.


시나리오 5: C++ 앱 내부에서 Python 임베딩

문제: C++ 데스크톱 앱이 사용자에게 매크로·자동화 스크립트 기능을 제공하려 합니다. Python 인터프리터를 앱에 임베드하고, 앱 API를 Python에 노출해 사용자가 app.open_file("data.txt") 같은 호출을 스크립트에서 할 수 있게 하고 싶습니다.

해결: pybind11의 pybind11/embed.h로 Python을 임베드하고, C++ 모듈을 등록해 import app_api로 앱 기능을 스크립트에서 호출할 수 있게 합니다.


시나리오 6: NumPy 배열과 C++ 버퍼 공유

문제: Python에서 NumPy로 1000×1000 이미지를 만들고, C++에서 픽셀 단위로 처리한 뒤 결과를 다시 NumPy로 받고 싶습니다. 복사가 발생하면 메모리와 시간이 낭비됩니다.

해결: pybind11의 py::array_t<T>는 NumPy의 버퍼 프로토콜을 활용해 복사 없이 C++에 포인터를 전달합니다. C++에서 수정한 내용이 그대로 Python 배열에 반영됩니다.


2. 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>

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): 첫 인자 exampleimport example의 모듈 이름입니다.
  • m.def("add", &add, ...): C++ 함수 add를 Python 이름 "add"로 등록합니다.
  • py::arg("a"): Python에서 키워드 인자로 example.add(a=1, b=2)처럼 호출할 수 있게 합니다.

Python에서 사용:

import example

print(example.add(1, 2))       # 3
print(example.add(a=10, b=20))  # 30
print(example.greet("World"))   # Hello, World!

오버로드된 함수

C++에서 같은 이름의 함수가 인자 타입만 다를 때, pybind11에 여러 버전을 등록합니다.

#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"));
}

Python에서:

import example
print(example.add(1, 2))      # 3 (int)
print(example.add(1.5, 2.5)) # 4.0 (double)

기본값 인자

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>

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"))  # {'h': 1, 'e': 1, 'l': 2, 'o': 1}

3. 클래스 바인딩

기본 클래스 노출

#include <pybind11/pybind11.h>

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__",  {
            return "<Calculator value=" + std::to_string(c.get()) + ">";
        });
}

핵심 포인트:

  • py::class_<Calculator>(m, "Calculator"): C++ 클래스를 Python 클래스로 등록합니다.
  • py::init<>(), py::init<int>(): 생성자 오버로드입니다.
  • __repr__: Python에서 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("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<>())
        .def("name", &Derived::name);
}

스마트 포인터 반환

C++에서 std::shared_ptr로 객체를 관리할 때, Python에 넘겨도 참조 카운팅이 유지됩니다.

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);
}

4. NumPy 연동

NumPy 배열을 C++에 넘기기

py::array_t<T>로 NumPy ndarray를 받습니다. request()로 버퍼 정보를 얻고, ptr로 데이터에 직접 접근합니다. 복사 없이 Python 배열과 같은 메모리를 공유합니다.

#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>

namespace py = pybind11;

double sum_array(py::array_t<double> arr) {
    auto buf = arr.request();
    if (buf.ndim != 1) {
        throw std::runtime_error("1차원 배열만 지원합니다");
    }
    double* ptr = static_cast<double*>(buf.ptr);
    size_t n = buf.size;
    double s = 0;
    for (size_t i = 0; i < n; ++i) s += ptr[i];
    return s;
}

// 제자리(in-place) 수정
void scale_array(py::array_t<double> arr, double factor) {
    auto buf = arr.request();
    double* ptr = static_cast<double*>(buf.ptr);
    for (size_t i = 0; i < buf.size; ++i) {
        ptr[i] *= factor;
    }
}

PYBIND11_MODULE(example, m) {
    m.def("sum_array", &sum_array);
    m.def("scale_array", &scale_array);
}
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.] — 제자리 수정됨

C++에서 NumPy 배열 반환

py::array_t<T>를 만들어 반환합니다. shapestrides를 지정할 수 있습니다.

py::array_t<double> create_zeros(size_t n) {
    auto arr = py::array_t<double>(n);
    auto buf = arr.request();
    double* ptr = static_cast<double*>(buf.ptr);
    std::fill(ptr, ptr + n, 0.0);
    return arr;
}

py::array_t<double> linspace(double start, double stop, size_t num) {
    auto arr = py::array_t<double>(num);
    auto buf = arr.request();
    double* ptr = static_cast<double*>(buf.ptr);
    double step = (num > 1) ? (stop - start) / (num - 1) : 0;
    for (size_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.  ]

2차원 배열 (행렬)

py::array_t<double> matmul(py::array_t<double> a, py::array_t<double> 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차원 배열만 지원합니다");
    }
    size_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("행렬 차원 불일치");
    }
    double* ptr_a = static_cast<double*>(buf_a.ptr);
    double* ptr_b = static_cast<double*>(buf_b.ptr);

    auto result = py::array_t<double>({M, N});
    auto buf_r = result.request();
    double* ptr_r = static_cast<double*>(buf_r.ptr);
    std::fill(ptr_r, ptr_r + M * N, 0.0);

    for (size_t i = 0; i < M; ++i) {
        for (size_t j = 0; j < N; ++j) {
            for (size_t k = 0; k < K; ++k) {
                ptr_r[i * N + j] += ptr_a[i * K + k] * ptr_b[k * N + j];
            }
        }
    }
    return result;
}

연속 메모리 보장

NumPy 배열이 C 연속(C-contiguous)이 아닐 수 있습니다. py::array::c_style로 요청하거나, np.ascontiguousarray()로 Python에서 넘깁니다.

double sum_safe(py::array_t<double> arr) {
    // 비연속 배열이면 복사본 요청
    py::array_t<double> c_arr = py::array::ensure(
        arr, py::array::c_style | py::array::forcecast);
    auto buf = c_arr.request();
    double* ptr = static_cast<double*>(buf.ptr);
    double s = 0;
    for (size_t i = 0; i < buf.size; ++i) s += ptr[i];
    return s;
}

5. 예외 처리

C++ 예외 → Python 예외

pybind11은 C++ 예외를 자동으로 Python 예외로 변환합니다.

C++ 예외Python 예외
std::exceptionRuntimeError
std::logic_errorValueError
std::runtime_errorRuntimeError
std::out_of_rangeIndexError
std::invalid_argumentValueError
#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

Python 예외를 C++에서 잡기

C++에서 Python 코드를 호출할 때, Python 예외가 발생하면 py::error_already_set가 설정됩니다. py::error_already_set::clear()로 초기화하거나, try/except로 잡을 수 있습니다.

#include <pybind11/pybind11.h>
#include <pybind11/embed.h>

namespace py = pybind11;

int call_python_func(const std::string& code) {
    try {
        py::exec(code);
        return 0;
    } catch (py::error_already_set& e) {
        py::print("Python 예외:", e.what());
        e.restore();  // Python에서 예외 상태 유지
        return -1;
    }
}

예외 메시지 개선

m.def("process",  {
    try {
        return do_process(x);
    } catch (const std::exception& e) {
        throw std::runtime_error(
            std::string("process 실패 (x=") + std::to_string(x) + "): " + e.what());
    }
});

6. 완전한 Python 스크립팅 예제

예제 1: 이미지 필터 엔진

C++로 그레이스케일 변환을 구현하고, Python에서 NumPy 이미지를 넘겨 호출합니다.

// image_filter.cpp
#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
#include <stdexcept>
#include <algorithm>

namespace py = pybind11;

py::array_t<uint8_t> grayscale(py::array_t<uint8_t> rgb) {
    auto buf = rgb.request();
    if (buf.ndim != 3 || buf.shape[2] != 3) {
        throw std::invalid_argument("RGB 이미지 (H, W, 3) 필요");
    }
    size_t H = buf.shape[0], W = buf.shape[1];
    const uint8_t* src = static_cast<const uint8_t*>(buf.ptr);
    auto result = py::array_t<uint8_t>({H, W});
    uint8_t* dst = static_cast<uint8_t*>(result.request().ptr);

    for (size_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<uint8_t>(0.299 * r + 0.587 * g + 0.114 * b);
    }
    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

# 100x100 RGB 이미지
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

예제 2: 게임 엔진 스크립트 API

// game_engine.cpp
#include <pybind11/pybind11.h>
#include <pybind11/stl.h>

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::runtime_error("엔티티 없음: " + 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())         # ['player', 'enemy_1']

예제 3: CMake 빌드 설정

# CMakeLists.txt
cmake_minimum_required(VERSION 3.15)
project(PythonScripting LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 17)

find_package(Python3 COMPONENTS Interpreter Development REQUIRED)
find_package(pybind11 CONFIG REQUIRED)

pybind11_add_module(image_filter image_filter.cpp)
pybind11_add_module(game_engine game_engine.cpp)

target_link_libraries(image_filter PRIVATE pybind11::module)
target_link_libraries(game_engine PRIVATE pybind11::module)
# 빌드 및 실행
mkdir build && cd build
cmake .. -DCMAKE_BUILD_TYPE=Release
cmake --build .
cd ..
PYTHONPATH=build python test_filter.py
PYTHONPATH=build python game_script.py

예제 4: C++ 앱에서 Python 임베딩

// embed_main.cpp
#include <pybind11/embed.h>
#include <iostream>

namespace py = pybind11;

int main() {
    py::scoped_interpreter guard{};

    py::module_ sys = py::module_::import("sys");
    py::print("Python", sys.attr("version"));

    py::exec(R"(
        import game_engine
        engine = game_engine.GameEngine()
        engine.spawn("test", 1.0, 2.0)
        pos = engine.get_position("test")
        print("Position:", pos)
    )");

    return 0;
}

7. 자주 발생하는 에러와 해결법

에러 1: “ModuleNotFoundError: No module named ‘example’”

원인: 빌드된 .pyd/.so 파일이 Python이 찾는 경로에 없습니다.

해결:

# PYTHONPATH에 빌드 디렉터리 추가
export PYTHONPATH=/path/to/build:$PYTHONPATH
python -c "import example"

또는 빌드 결과물을 site-packages에 복사:

cp build/example*.so $(python3 -c "import site; print(site.getsitepackages()[0])")

에러 2: “ImportError: … undefined symbol: _ZN6…”

원인: 모듈 이름 불일치. PYBIND11_MODULE(example, m)의 첫 인자와 import example이 일치해야 합니다. 또는 Python 버전/ABI 불일치(예: Python 3.10으로 빌드했는데 3.11에서 로드).

해결:

  • 모듈 이름 확인: PYBIND11_MODULE의 첫 인자 = import 이름
  • 빌드 시 사용한 Python과 실행 시 Python 버전 일치

에러 3: “find_package(pybind11) failed”

원인: pybind11이 CMake에서 찾을 수 없습니다.

해결:

pip install pybind11
export CMAKE_PREFIX_PATH=$(python3 -c "import pybind11; print(pybind11.get_cmake_dir())")
cmake ..

또는 FetchContent로 pybind11 포함:

include(FetchContent)
FetchContent_Declare(
  pybind11
  GIT_REPOSITORY https://github.com/pybind/pybind11.git
  GIT_TAG v2.11.1
)
FetchContent_MakeAvailable(pybind11)

에러 4: “Buffer has wrong number of dimensions”

원인: NumPy 배열의 차원이 C++에서 기대한 것과 다릅니다.

// ❌ 2차원 기대했는데 1차원 전달
py::array_t<double> matmul(py::array_t<double> a, ...);
# Python: example.matmul(np.array([1,2,3]), ...)  # 1차원

해결: Python에서 차원 확인 후 전달:

a = np.atleast_2d(arr)
example.matmul(a, b)

또는 C++에서 buf.ndim 검사 후 명확한 에러 메시지:

if (buf.ndim != 2) {
    throw std::invalid_argument("2차원 배열이 필요합니다 (받은 차원: " +
                                std::to_string(buf.ndim) + ")");
}

에러 5: “TypeError: Unable to convert function return value”

원인: C++에서 반환한 타입을 pybind11이 Python 타입으로 변환하지 못합니다. 예: std::unique_ptr를 그대로 반환했는데 py::class_에 등록되지 않은 경우.

해결: 반환 타입을 pybind11이 지원하는 타입으로 바꾸거나, py::class_에 해당 타입을 등록합니다.

에러 6: GIL(Global Interpreter Lock) 관련 데드락

원인: C++ 스레드에서 Python API를 호출할 때 GIL을 잡지 않으면 크래시나 데드락이 발생합니다.

해결:

#include <pybind11/gil.h>

void callback_from_cpp_thread() {
    py::gil_scoped_acquire acquire;
    py::print("Python 호출");
    py::object result = py::module_::import("mymodule").attr("func")();
}

에러 7: “Fatal Python error: Py_Initialize: unable to load the file system codec”

원인: Python 임베딩 시 PYTHONHOME이 잘못 설정되었거나, 다른 Python 설치와 충돌합니다.

해결:

  • PYTHONHOME을 설정하지 않거나, 올바른 Python prefix로 설정
  • 동적 링크된 Python과 일치하는 라이브러리 경로 확인

에러 8: NumPy 배열 수정 시 “ValueError: assignment destination is read-only”

원인: NumPy 배열이 읽기 전용으로 생성되었거나, C++에서 const 포인터로 받았습니다.

해결: Python에서 np.ascontiguousarray(arr, dtype=np.float64)로 쓰기 가능한 연속 배열을 넘깁니다. C++에서는 py::array_t<double>을 non-const로 받습니다.


8. 베스트 프랙티스

1. 모듈 이름과 파일명 일치

PYBIND11_MODULE(my_engine, m)이면 빌드 결과물은 my_engine.cpython-3xx.so이고, import my_engine으로 불러옵니다.

2. docstring 추가

m.def("add", &add, "두 정수를 더합니다",
      py::arg("a") = 0, py::arg("b") = 0,
      "a: 첫 번째 정수\nb: 두 번째 정수");

Python에서 help(example.add)로 도움말을 볼 수 있습니다.

3. NumPy 배열 검증

double process(py::array_t<double> arr) {
    auto buf = arr.request();
    if (buf.ndim != 1)
        throw std::invalid_argument("1차원 배열 필요");
    if (!buf.ptr)
        throw std::invalid_argument("빈 배열");
    // ...
}

4. GIL 관리

  • C++에서 Python 콜백을 호출할 때: py::gil_scoped_acquire
  • 오래 걸리는 C++ 연산 중 Python 호출이 없으면: py::gil_scoped_release로 GIL 해제해 다른 스레드가 Python 실행 가능하게

5. 예외 메시지 한글화

throw std::invalid_argument("배열 크기가 0입니다");

6. 버전 정보 노출

m.attr("__version__") = "1.0.0";

7. 의존성 최소화

pybind11은 헤더 전용이지만, NumPy 연동 시 numpy 패키지가 런타임에 필요합니다. py::array_t를 쓰는 모듈은 import numpy 없이도 동작하지만, 사용자 스크립트에서 NumPy를 넘기려면 NumPy가 설치되어 있어야 합니다.


9. 프로덕션 패턴

패턴 1: 샌드박스 / 제한된 API

사용자 스크립트에 전체 시스템에 대한 접근을 주지 않습니다. 노출할 API만 pybind11로 바인딩하고, 파일 시스템·네트워크 등은 래퍼를 통해 제한합니다.

// 샌드박스: safe_open만 노출, std::fstream 직접 노출 안 함
m.def("safe_open",  {
    if (path.find("..") != std::string::npos)
        throw std::invalid_argument("상위 디렉터리 접근 금지");
    return open_restricted(path);
});

패턴 2: 스크립트 캐싱

동일 스크립트를 반복 실행할 때, 컴파일된 바이트코드를 캐시합니다.

py::object compile_and_cache(const std::string& script) {
    static std::unordered_map<std::string, py::object> cache;
    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[script] = code;
    return code;
}

패턴 3: 타임아웃

스크립트 실행이 무한 루프에 빠지지 않도록, 별도 프로세스에서 실행하거나 signal을 사용해 타임아웃을 둡니다. (Python의 signal 모듈 또는 subprocess 활용)

패턴 4: 로깅 연동

C++ 로거를 Python에 노출해 사용자 스크립트에서 로그를 남길 수 있게 합니다.

m.def("log",  {
    Logger::get().log(level, msg);
});

패턴 5: 설정 주입

class ScriptContext {
public:
    void set_config(const std::map<std::string, std::string>& cfg) {
        config_ = cfg;
    }
    py::dict get_config_py() const {
        py::dict d;
        for (const auto& p : config_) d[py::str(p.first)] = py::str(p.second);
        return d;
    }
private:
    std::map<std::string, std::string> config_;
};

// Python 스크립트에서 config[key]로 접근
py::getattr(scope, "config") = ctx.get_config_py();

패턴 6: wheel 배포

setuptools로 pip install my_engine으로 설치 가능한 패키지를 만들고, manylinux·win_amd64 등 플랫폼별 wheel을 빌드해 PyPI에 배포합니다.

# pyproject.toml
[build-system]
requires = ["setuptools", "pybind11"]
build-backend = "setuptools.build_meta"

[project]
name = "my_engine"
version = "1.0.0"
requires-python = ">=3.8"

10. 구현 체크리스트

Python 스크립팅 도입 시 확인할 항목:

  • PYBIND11_MODULE의 모듈 이름과 import 이름 일치
  • py::arg로 키워드 인자·기본값 지원
  • NumPy 배열: ndim, shape, ptr null 검사
  • 예외: 사용자 정의 예외 register_exception 등록
  • GIL: 멀티스레드에서 Python 호출 시 gil_scoped_acquire
  • docstring: m.def(..., "설명") 추가
  • CMake: find_package(pybind11) 또는 FetchContent
  • Python 버전: 빌드·실행 환경 일치
  • __version__ 등록
  • 프로덕션: 샌드박스, 타임아웃, 로깅 검토

정리

항목설명
모듈 바인딩m.def()로 C++ 함수를 Python에 노출
클래스 바인딩py::class_<>로 C++ 클래스를 Python 클래스로 등록
NumPy 연동py::array_t<T>로 복사 없이 배열 전달
예외 처리C++ 예외 → Python 예외 자동 변환, 사용자 예외 등록
GIL멀티스레드에서 gil_scoped_acquire 사용
프로덕션샌드박스, 캐싱, 타임아웃, wheel 배포

핵심 원칙:

  1. 모듈 이름PYBIND11_MODULEimport에서 일치시킨다.
  2. NumPyrequest()로 버퍼 정보를 얻고, 차원·크기를 검증한다.
  3. 예외는 Python에서 잡을 수 있도록 명확한 메시지와 함께 던진다.
  4. 멀티스레드에서는 GIL을 올바르게 관리한다.

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 게임 로직, AI 파이프라인, 도구 플러그인, 사용자 스크립트 등에 활용합니다. 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. pybind11 vs ctypes vs cffi?

A. pybind11은 C++ 타입을 자동으로 바인딩하고 NumPy 연동이 뛰어납니다. ctypes/cffi는 C API 위주이고 수동 래핑이 필요합니다. C++ 코드가 많다면 pybind11이 적합합니다.

Q. 더 깊이 공부하려면?

A. pybind11 공식 문서를 참고하세요. C++ 시리즈 #35-1: pybind11에서도 기본 개념과 성능 비교를 다룹니다.


참고 자료


한 줄 요약: pybind11으로 C++을 Python에서 불러 쓸 수 있어, 고성능 엔진과 스크립팅의 장점을 결합할 수 있습니다.


관련 글

  • C++ Lua 스크립팅 완벽 가이드 | Lua C API·스택·테이블·바인딩 [실전]
  • C++ JavaScript 스크립팅 완벽 가이드 | V8 임베딩·컨텍스트·C++↔JS 바인딩 [실전]
  • C++ 스크립팅 엔진 통합 | Lua·Python·JavaScript 바인딩 완벽 가이드 [#55-3]
... 996 lines not shown ... Token usage: 63706/1000000; 936294 remaining Start-Sleep -Seconds 3