내비게이션 스택의 파라미터 파일에는 이런 줄이 있어요.
FollowPath:
plugin: "nav2_regulated_pure_pursuit_controller::RegulatedPurePursuitController"서버는 이 문자열을 읽어 컨트롤러 객체를 만들어요. 그런데 C++에는 문자열로 클래스를 만드는 기능이 없어요. 이 간극이 어떻게 메워지는지 따라가면, 프레임워크 소스가 처음 보는 무언가가 아니라 아는 문법의 조합으로 읽혀요.
갈아끼울 수 있어야 하는 자리에 인터페이스를 둬요
C++에는 interface 키워드가 없고, 순수 가상 함수만 있고 데이터 멤버가 없는 추상 클래스가 그 역할을 해요. 로봇 제어에서 인터페이스가 서는 자리는 정해져 있어요. 갈아끼울 수 있어야 하는 부품의 경계예요.
// 경로 추종 컨트롤러의 계약 — 구현이 아니라 호출 순서와 형태만 정한다
class Controller {
public:
virtual ~Controller() = default; // 인터페이스 첫 줄 규칙
virtual void activate() = 0; // 시작 전 1회 — 게인 로드, 상태 리셋
virtual double compute(double error) = 0; // 주기마다 — 오차를 받아 속도 명령
virtual void deactivate() = 0; // 멈출 때 1회 — 명령 0으로
};호출하는 쪽은 Controller*만 알아요. 시작할 때 한 번, 주기마다 계산, 끝나면 정리라는 순서까지가 계약이에요. 구현을 다른 클래스로 바꿔도 제어 루프 코드는 한 글자도 안 바뀌어요.
지켜야 할 관례가 몇 개 손에 남아요. 데이터 멤버를 넣지 않는 것, 전부 순수 가상으로 두는 것, 그리고 가상 소멸자를 직접 넣는 것이에요. 순수 가상이 하나라도 남아 있으면 그 클래스는 추상이라 인스턴스를 만들 수 없고, 파생이 전부 구현해야 비로소 만들 수 있는 타입이 돼요.
시그니처가 미묘하게 다르면 조용한 새 함수가 돼요
C++에서 재정의 시도가 빗나가면 에러가 아니라 새 함수 선언이에요.
class SmoothController : public Controller {
public:
void activate() override { /* ... */ }
void deactivate() override { /* ... */ }
double compute(float error) { return 0.0; } // double이 아니라 float — 재정의 아님
};compute(float)는 부모의 compute(double)과 다른 함수예요. 컴파일러 입장에서는 자식이 멤버 함수를 하나 추가한 것뿐이라 합법이고 경고도 없어요. 다행히 이 코드는 컴파일이 안 돼요. 부모의 순수 가상이 구현되지 않은 채 남아서 여전히 추상 클래스이고, 인스턴스를 만드는 줄에서 걸리거든요. 순수 가상 인터페이스의 숨은 장점이에요. 계약을 안 지키면 어차피 걸려요.
진짜 위험한 건 부모에 기본 구현이 있는 가상 함수예요. 부모가 “기본은 아무것도 안 함”인 선택적 훅을 제공할 때 자식이 const를 하나 더 붙여 쓰면, 추상도 안 되고 에러도 없이 새 함수가 돼요. 런타임에는 부모의 빈 구현이 계속 불리고, 증상은 “처리가 안 되는데 코드는 분명히 있음”으로 나타나요. 원인 지점인 선언 한 줄과 증상 지점인 런타임 동작이 완전히 떨어져 있는 종류의 버그예요.
override는 이 사고를 선언 지점의 컴파일 에러로 당겨요. 부모에 정확히 그 시그니처의 가상 함수가 있는지 검사하니, 오타든 const 누락이든 타입 차이든 그 줄에서 걸려요. 비용이 0이라 재정의하는 함수에는 전부 붙이는 게 맞아요.
가상 소멸자는 조심의 문제가 아니에요
가상 소멸자를 빠뜨리면 파생 소멸자가 안 불린다는 건 알려진 함정이에요. 인터페이스 구조에서 다른 점은, 이게 조심하면 피할 수 있는 게 아니라 구조적으로 반드시 밟게 되는 경로라는 거예요.
이유는 간단해요. 플러그인 구조의 목적 자체가 쓰는 쪽이 구체 타입을 모르게 하는 거예요. 제어 루프도 로더도 컨테이너도 전부 Controller*나 unique_ptr<Controller>만 들고 있어요. 그러니 소멸도 언제나 베이스 타입 포인터를 통해 일어나요. 파생 타입으로 지울 코드는 설계상 어디에도 없어요.
class CanController : public Controller {
int can_fd_ = -1; // CAN 소켓 핸들
public:
void activate() override { can_fd_ = open_can("can0"); }
double compute(double e) override { return e; }
void deactivate() override {}
~CanController() { if (can_fd_ >= 0) close_can(can_fd_); } // 핸들 반납
};
std::unique_ptr<Controller> ctrl = std::make_unique<CanController>();
ctrl.reset(); // ~Controller가 virtual이 아니면 ~CanController는 영원히 안 불린다증상은 크래시가 아니에요. 소켓이 안 닫힐 뿐이에요. 하드웨어 핸들은 운영체제가 프로세스 단위로 세는 자원이라, 컨트롤러를 교체할 때마다 열린 핸들이 하나씩 쌓여요. 며칠 뒤 “device busy”나 핸들 고갈로, 원인과 아주 먼 곳에서 나타나요.
스마트 포인터가 구해주지 않는다는 점도 짚어둘 만해요. unique_ptr<Controller>의 기본 삭제자는 베이스 포인터로 delete를 부를 뿐이라, 가상 소멸자가 없으면 raw 포인터와 똑같이 파생 소멸자를 건너뛰어요. C++ 소유권과 수명 — 스마트 포인터는 지우는 것을 잊지 않게 해 주지, 올바르게 지우는 건 가상 소멸자의 일이에요.
그래서 규칙이 한 줄이에요. 인터페이스를 선언하면 첫 줄에 virtual ~Base() = default;를 써요. 판단 기준은 “베이스 포인터로 지워지는가”인데, 인터페이스는 그 답이 항상 예라서 예외가 없어요.
호출이 갈아끼워지는 원리
컴파일러는 가상 함수를 가진 클래스마다 표를 하나씩 만들어요. 그 클래스의 가상 함수 실제 주소를 순서대로 적은 표예요. 그리고 객체마다 자기 클래스의 표를 가리키는 숨은 포인터가 하나 심겨요. 가상 함수가 있는 객체의 크기가 8바이트 커지는 이유예요.
ctrl->compute(err) 한 줄의 실제 동작은 객체의 숨은 포인터를 따라 표를 찾고, 표에서 해당 칸의 주소를 읽고, 그리로 점프하는 거예요. 칸 번호는 컴파일 시점에 확정돼요. 일반 호출이 주소가 박히는 직접 호출인 것과 달리 간접 참조가 두 번 추가되고 인라인 최적화가 막혀요. 수백 Hz 제어 루프에서는 잴 수도 없는 비용이지만, 초당 수백만 번 도는 안쪽 루프에 가상 호출을 두지 않는 관례는 여기서 나와요.
여기에 다음 절로 가는 실마리가 있어요. 표 어디에도 클래스 이름 문자열은 없어요. 컴파일이 끝나면 이름은 사라지고 주소만 남아요. 리플렉션이 있는 언어는 문자열로 객체를 만들 수 있지만 C++ 언어에는 그 통로가 없어요.
이름에서 팩토리로 가는 map 하나
플러그인 라이브러리가 그 간극을 세 단계로 메워요.
첫째, 구현을 공유 라이브러리로 빌드해요. 컨트롤러 구현은 서버 실행 파일에 링크되지 않고 별도의 .so 파일로 만들어져요. 서버는 어떤 컨트롤러가 존재하는지 컴파일 시점에 몰라요. 그래서 서버를 다시 빌드하지 않고 새 컨트롤러를 추가할 수 있어요.
둘째, 이름과 팩토리를 등록해요. 구현 쪽 소스 마지막의 등록 매크로가 “이 문자열이 오면 이 클래스를 만들어 인터페이스 포인터로 돌려주는 함수”를 .so 안에 심고, 패키지의 xml이 그 이름을 바깥에 광고해요.
셋째, 런타임에 로드해요. 로더가 문자열로 어느 .so인지 찾아 메모리에 올리고, 등록된 팩토리를 불러 인터페이스 포인터를 받아요. 이후는 첫 절 그대로예요.
세 단계에서 프레임워크 고유의 것은 .so 로드뿐이고 나머지는 맨 C++로 재현돼요. 심장부는 map 하나예요.
using Factory = std::function<std::unique_ptr<Controller>()>;
std::map<std::string, Factory> registry;
registry["p_ctrl"] = [] { return std::make_unique<PController>(); };
registry["can_ctrl"] = [] { return std::make_unique<CanController>(); };
std::unique_ptr<Controller> load(const std::string& name) {
auto it = registry.find(name);
if (it == registry.end()) return nullptr; // 미등록 이름 — 로드 실패
return it->second(); // 팩토리 호출 = 인스턴스 생성
}이 구조가 보이면 프레임워크 소스가 읽혀요. 내비게이션 코어의 컨트롤러와 플래너는 첫 절에서 만든 것과 같은 순수 가상 클래스이고, 설정·시작·정지·주기 계산이 계약이에요. 플러그인을 짠다는 건 이 인터페이스를 상속해 재정의한다는 것 이상이 아니에요. 제어 프레임워크도 같은 구조라 컨트롤러와 하드웨어 드라이버가 각각의 인터페이스를 상속해 .so로 나가요.
컴파일러가 못 지켜주는 곳은 한 군데
함정은 문자열 경계에 있어요. 클래스 이름과 xml과 파라미터 파일, 세 곳의 문자열이 일치해야 하는데 이 일치는 컴파일러가 검사해 주지 않아요. 설정에서 철자를 하나 틀리면 빌드는 멀쩡히 통과하고, 실행 시점에 로드 실패로 서버가 죽어요.
override가 시그니처 오타를 컴파일 에러로 당기는 이야기였다면, 여기는 타입 시스템 바깥이라 당길 방법이 없는 지점이에요. 그래서 플러그인이 안 뜨면 코드보다 먼저 세 곳의 문자열을 대조하는 게 순서예요.
플러그인은 마법이 아니라 순수 가상 인터페이스 하나, override 규칙 하나, 가상 소멸자 한 줄, 그리고 이름에서 팩토리로 가는 map 하나의 조합이에요.