AEGLOSLAB ROS 2 Jazzy 대전

제1부 계산 그래프의 코어

1.13 컴포지션 — 컴포넌트 노드와 프로세스 내 결합

1.13.1 이 장에서 배우는 것 / 선수 지식

1.1에서 "노드는 프로세스가 아니다"라고 했다. 이 장은 그 사실을 성능 도구로 바꾸는 방법이다. 여러 노드를 한 프로세스에 담으면 그 사이의 통신에서 직렬화·복사·네트워크 스택을 전부 건너뛸 수 있다 — 6 MB 크기의 이미지가 포인터 하나가 된다(1.3.6).

ROS 1의 nodelet이 하던 일이지만, ROS 2에서는 모든 노드가 처음부터 컴포넌트로 작성될 수 있고, 배치는 실행 시점에 결정된다. 이것이 ROS 2 아키텍처의 핵심 유연성 중 하나다.

배우는 것:

  • 컴포넌트 작성법 — 일반 노드와 무엇이 다른가, 등록 매크로의 정체.
  • 컨테이너 세 종류 — 단일 스레드·멀티스레드·격리형의 차이와 선택.
  • launch에서의 조립 — 정적 구성과 실행 중 로드.
  • intra-process 통신 — 컴포지션과 함께 켜야 실제 이득이 나온다.
  • 잃는 것 — 크래시 전파, 디버깅 난이도, 자원 격리 상실.
  • 배치 설계 — 어떤 노드를 함께 묶을 것인가.

선수 지식: 1.1(노드), 1.4(토픽 경로), 1.3.6(직렬화 우회), 1.12(라이프사이클 — 컴포넌트와 결합 가능). intra-process의 내부는 2.7, executor 상세는 2.3에서 이어받는다.

정직: 컴포넌트 컨테이너의 종류·동작은 rclcpp_components 소스와 문서에 근거한다. 성능 이득 수치는 하드웨어·메시지 크기·구독자 수에 따라 자릿수가 달라지므로 이 장에서는 구체 수치를 단정하지 않고 측정 방법을 제시한다(목차 3).

1.13.2 왜 컴포지션인가

별도 프로세스 같은 프로세스 + intra-process 직렬화 → UDP·SHM → 역직렬화 포인터 전달 camera_node processor_node camera 컴포넌트 processor 컴포넌트
비용별도 프로세스같은 프로세스(intra-process)
직렬화·역직렬화있음없음
메모리 복사있음조건 만족 시 0회
네트워크 스택통과없음
컨텍스트 스위치있음줄어듦
DDS 참가자프로세스마다 1개컨테이너 전체가 1개(실행 확인 — 3.3.5) → 디스커버리 부하 감소
디스커버리 엔티티(발행·구독)노드마다여전히 노드마다

효과가 큰 경우는 명확하다: 큰 메시지를 높은 빈도로. 이미지·포인트클라우드 파이프라인이 대표적이다. 반대로 작은 메시지를 저빈도로 주고받는 노드들을 묶어도 이득은 미미하다 — 묶는 이유가 성능이 아니라면 격리를 잃을 이유도 없다.

1.13.3 컴포넌트 작성

일반 노드와의 차이는 두 가지뿐이다.

// my_component.hpp
#include "rclcpp/rclcpp.hpp"

namespace my_pkg
{
class ImageProcessor : public rclcpp::Node
{
public:
  // ① 생성자가 NodeOptions를 받아 기반 클래스에 넘겨야 한다
  explicit ImageProcessor(const rclcpp::NodeOptions & options)
  : Node("image_processor", options)
  {
    sub_ = create_subscription<sensor_msgs::msg::Image>(
      "image_raw", rclcpp::SensorDataQoS(),
      std::bind(&ImageProcessor::on_image, this, std::placeholders::_1));
    pub_ = create_publisher<sensor_msgs::msg::Image>(
      "image_processed", rclcpp::SensorDataQoS());
  }
private:
  void on_image(sensor_msgs::msg::Image::UniquePtr msg);   // UniquePtr — 1.4.5
  rclcpp::Subscription<sensor_msgs::msg::Image>::SharedPtr sub_;
  rclcpp::Publisher<sensor_msgs::msg::Image>::SharedPtr pub_;
};
}
// my_component.cpp 끝에
#include "rclcpp_components/register_node_macro.hpp"
// ② 등록 매크로
RCLCPP_COMPONENTS_REGISTER_NODE(my_pkg::ImageProcessor)

NodeOptions를 받는 것이 결정적이다. 컨테이너가 이 인자로 리매핑·파라미터·intra-process 설정을 주입한다. 인자를 받지 않는 노드는 컨테이너에 로드될 수 없고, 로드되더라도 설정이 전달되지 않는다.

CMakeLists.txt:

find_package(rclcpp_components REQUIRED)

add_library(image_processor_component SHARED src/my_component.cpp)
target_compile_definitions(image_processor_component PRIVATE "COMPOSITION_BUILDING_DLL")
ament_target_dependencies(image_processor_component rclcpp rclcpp_components sensor_msgs)

rclcpp_components_register_nodes(image_processor_component "my_pkg::ImageProcessor")

install(TARGETS image_processor_component
  ARCHIVE DESTINATION lib LIBRARY DESTINATION lib RUNTIME DESTINATION bin)

독립 실행 파일도 함께 제공하는 것이 관행이다 — 개발·디버깅에는 단독 실행이 편하기 때문이다.

// standalone.cpp — 같은 컴포넌트를 단독 프로세스로
int main(int argc, char ** argv) {
  rclcpp::init(argc, argv);
  rclcpp::NodeOptions options;
  auto node = std::make_shared<my_pkg::ImageProcessor>(options);
  rclcpp::spin(node);
  rclcpp::shutdown();
}

견해: 새 노드는 기본적으로 컴포넌트로 작성하라. 비용은 생성자 시그니처 하나와 매크로 한 줄이고, 나중에 성능이 필요해졌을 때 코드 변경 없이 배치만 바꿀 수 있다. 처음부터 일반 노드로 만들면 나중에 전환하는 데 리팩터링이 필요하다.

ros2 component types              # 시스템에 등록된 컴포넌트 목록

1.13.4 컨테이너 세 종류

ros2 run rclcpp_components component_container            # ① 단일 스레드
ros2 run rclcpp_components component_container_mt         # ② 멀티스레드
ros2 run rclcpp_components component_container_isolated   # ③ 노드별 executor
컨테이너executor특징
component_containerSingleThreaded 하나모든 노드의 모든 콜백이 한 스레드에서 순차 실행
component_container_mtMultiThreaded 하나콜백 그룹 정책에 따라 병렬 실행
component_container_isolated노드마다 별도 executor노드 간 간섭 최소화

선택 기준:

① 단일 스레드 — 가장 단순하고 예측 가능하다. 콜백이 순차 실행되므로 노드 간 데이터 공유에 락이 필요 없다(같은 스레드이므로). 대신 한 노드의 느린 콜백이 다른 모든 노드를 막는다. 콜백이 전부 짧고 부하가 작을 때 적합.

② 멀티스레드 — 병렬 처리가 가능하지만 스레드 안전을 직접 책임져야 한다(2.9). 콜백 그룹 설계가 필수다(2.4). 서비스 호출이 얽히면 1.7의 데드락 회피에도 필요하다.

③ 격리형 — 노드마다 executor가 따로 돌아 한 노드의 지연이 다른 노드에 전파되지 않는다. intra-process의 이득은 유지하면서 실행 격리를 얻는다.

견해: ③(isolated)이 기본값으로 좋은 선택인 경우가 많다. ①의 "한 노드가 전부를 막는" 위험과 ②의 "전역 스레드 안전 부담"을 모두 피하면서 통신 최적화는 그대로 얻는다. 다만 스레드 수가 노드 수에 비례해 늘어나므로 코어가 적은 임베디드에서는 재검토가 필요하다. (확인 필요: isolated 컨테이너의 executor 종류·스레드 수 기본 정책)

1.13.5 launch에서 조립하기

정적 구성 — 컨테이너와 컴포넌트를 함께 실행한다:

from launch import LaunchDescription
from launch_ros.actions import ComposableNodeContainer
from launch_ros.descriptions import ComposableNode

def generate_launch_description():
    container = ComposableNodeContainer(
        name='perception_container',
        namespace='',
        package='rclcpp_components',
        executable='component_container_isolated',
        composable_node_descriptions=[
            ComposableNode(
                package='my_pkg', plugin='my_pkg::CameraDriver',
                name='camera',
                parameters=[{'device': '/dev/video0', 'fps': 30}],
                extra_arguments=[{'use_intra_process_comms': True}],   # ★ 필수
            ),
            ComposableNode(
                package='my_pkg', plugin='my_pkg::ImageProcessor',
                name='processor',
                remappings=[('image_raw', '/camera/image_raw')],
                extra_arguments=[{'use_intra_process_comms': True}],
            ),
        ],
        output='screen',
    )
    return LaunchDescription([container])

use_intra_process_comms: True를 누락하면 컴포지션의 성능 이득이 대부분 사라진다. 같은 프로세스에 있어도 DDS를 통해 통신하게 된다. 이것이 가장 흔한 실수다 — "컴포지션했는데 빨라지지 않았다"의 대부분이 이 한 줄이다.

실행 중 로드·언로드:

ros2 run rclcpp_components component_container_isolated &

ros2 component load /ComponentManager my_pkg my_pkg::ImageProcessor
ros2 component list
ros2 component unload /ComponentManager 1     # ID로 언로드

동적 로드는 테스트·진단에 유용하다 — 실행 중인 시스템에 관측 컴포넌트를 잠시 로드했다 떼는 식으로. 제품 배포에서는 launch 정적 구성이 재현성 면에서 낫다.

기존 컨테이너에 추가:

from launch_ros.actions import LoadComposableNodes

LoadComposableNodes(
    target_container='/perception_container',
    composable_node_descriptions=[ComposableNode(...)],
)

이 형태는 launch 파일을 모듈로 나눌 때 유용하다 — 기본 파이프라인 launch와 선택적 기능 launch를 분리하되 같은 컨테이너를 공유한다.

1.13.6 intra-process 통신 — 이득의 실체

컴포지션만으로는 부족하고 intra-process가 켜져야 실제 이득이 난다. 조건이 있다.

조건설명
같은 프로세스컴포지션 또는 수동 조합
use_intra_process_comms(true)발행·구독 양쪽 노드 모두
QoS 호환일반 규칙과 동일(1.5)
발행이 unique_ptrconst& 발행은 복사가 남는다

복사 횟수(1.4.5 재확인):

발행구독복사
unique_ptrunique_ptr 구독자 1개0
unique_ptrshared_ptr 구독자 N개0(공유 — 수정 금지)
unique_ptrunique_ptr 구독자 N개N-1
const&무엇이든최소 1

함정: intra-process에는 제약이 있다 — 다만 그 목록이 생각보다 짧다. TRANSIENT_LOCAL은 동작한다(늦은 구독자가 과거 메시지를 받고, 전달 경로도 intra-process가 맞다 — 실행 확인, 2.7.13.4). 대신 비용을 낸다 — 무복사는 유지되지만 지연이 VOLATILE 대비 약 60배다. 대상은 토픽뿐이며 서비스·액션은 아니다(2.7.12.1). 프로세스 밖 구독자가 있으면 그쪽은 일반 경로로 간다. 정확한 조건은 2.7.4와 2.7.13에 있다.

측정 없이 믿지 말 것:

# 컴포지션 전후 비교
top -H -p $(pgrep -f component_container)    # 스레드별 CPU
ros2 topic hz /image_processed               # 처리량 변화
ros2 topic bw /image_raw                     # 프로세스 밖 트래픽이 줄었나

더 정확한 측정은 ros2_tracing으로 발행→콜백 지연을 직접 측정하는 것이다(11.6·11.7). "빨라진 것 같다"는 근거가 되지 못한다.

1.13.7 무엇을 잃는가

컴포지션에도 비용이 든다. 이 절이 이 장에서 가장 중요하다.

잃는 것설명완화
크래시 격리한 컴포넌트의 segfault가 컨테이너 전체를 강제 종료한다위험한 컴포넌트는 분리, 안정성 검증
메모리 격리한 노드의 누수·오염이 전체에 영향정적 분석·sanitizer(4.10)
자원 제어컨테이너 단위로만 cgroup·우선순위 지정 가능실시간 노드는 분리(12.2)
재시작 단위한 노드만 재시작 불가라이프사이클로 대체(1.12)
디버깅 단순성스택 트레이스에 여러 노드가 섞임로거 이름으로 구분(1.11.2)
언어 혼합C++ 컴포넌트와 Python 노드는 같은 컨테이너에 못 담는다경계 설계
의존 충돌한 프로세스에 모든 라이브러리가 로드됨버전 충돌 주의

크래시 전파가 가장 실질적인 위험이다. 별도 프로세스면 감시자가 재시작하면 되지만, 컨테이너가 종료되면 그 안의 노드 전부가 함께 종료된다 — 그리고 그중 하나는 안전 관련 노드일 수 있다.

견해: 안전에 관여하는 노드(비상 정지·안전 감시·워치독)는 절대 다른 노드와 같은 컨테이너에 두지 않는다. 이것은 성능 최적화보다 우선하는 원칙이다. 안전 감시 노드가 인지 파이프라인의 버그로 함께 종료되는 시스템은 안전 논증을 세울 수 없다(14.10).

디버깅 팁:

# 어떤 컴포넌트가 어디에 있는지
ros2 component list
# 컨테이너 프로세스의 스레드 확인
ps -T -p $(pgrep -f component_container)
# gdb를 연결하면 모든 노드의 스택이 보인다 — 로거 이름으로 구분
gdb -p $(pgrep -f component_container)

1.13.8 배치 설계 — 무엇을 함께 묶을 것인가

예 예 예 아니오 아니오 아니오 노드 쌍 A→B 큰 메시지 고빈도인가? 분리 유지 (묶을 이유 없음) 둘 다 안정적인가? 분리 (크래시 위험) 안전 관련 노드인가? 분리 (격리 우선) 같은 컨테이너 + intra-process

묶는 것이 좋은 조합:

조합이유
카메라 드라이버 + 디베이어 + 정류(rectify)이미지가 연속으로 흐른다
LiDAR 드라이버 + 포인트클라우드 필터대용량 연속 처리
스테레오 쌍 + 시차 계산두 이미지가 한곳에 모여야 한다

분리하는 것이 좋은 조합:

대상이유
안전 감시·비상 정지격리 필수
실시간 제어 루프별도 CPU·우선순위 필요(12.2)
실험적·불안정 코드크래시 전파 방지
Python 노드컨테이너 공유 불가
자주 재시작하는 노드재시작 단위 분리

전형적인 로봇 시스템 배치 예:

프로세스담는 것
인지 컨테이너카메라·LiDAR 드라이버 + 전처리 파이프라인
항법 컨테이너Nav2 서버들(자체 컴포넌트로 제공됨 — 8.1)
제어 프로세스ros2_control(단독, 실시간 우선순위 — 6.6)
안전 프로세스워치독·비상 정지(단독)
관리 프로세스진단·기록·상태 보고(Python 가능)

견해: 배치는 성능·안정성·운영의 트레이드오프이지 순수 성능 문제가 아니다. "전부 한 컨테이너에"는 벤치마크에서는 좋아 보이지만 현장에서 무너진다. 시작은 분리된 프로세스로 하고, 측정으로 병목이 확인된 경로만 묶는 순서가 안전하다.

1.13.9 라이프사이클 + 컴포넌트

둘을 함께 쓸 수 있다.

class ManagedProcessor : public rclcpp_lifecycle::LifecycleNode
{
public:
  explicit ManagedProcessor(const rclcpp::NodeOptions & options)
  : rclcpp_lifecycle::LifecycleNode("managed_processor", options) { … }
};
RCLCPP_COMPONENTS_REGISTER_NODE(my_pkg::ManagedProcessor)

이 조합이 강력한 이유: 컴포지션으로 통신 비용을 없애면서, 라이프사이클로 개별 노드의 재시작 단위를 되찾는다(1.13.7의 "재시작 단위" 상실을 부분적으로 보상). Nav2가 정확히 이 구조다 — 컴포넌트로 제공되면서 라이프사이클로 관리된다.

1.13.10 함정 카탈로그

#함정증상대처
1use_intra_process_comms 누락컴포지션했는데 안 빨라짐extra_arguments에 추가
2NodeOptions 미수용 생성자컴포넌트 로드 실패·설정 미전달시그니처 수정
3const& 발행복사가 남음unique_ptr 발행
4shared_ptr 콜백에서 메시지 수정다른 구독자 오염unique_ptr 콜백(1.4.5)
5안전 노드를 컨테이너에 포함크래시 전파분리
6단일 스레드 컨테이너에 느린 콜백전체 정지_mt·_isolated
7멀티스레드 컨테이너에서 락 누락경쟁 조건콜백 그룹·동기화(2.4·2.9)
8Python 노드를 C++ 컨테이너에로드 불가별도 프로세스
9측정 없이 컴포지션이득 불명·복잡도만 증가트레이싱 측정(11.6)
10전부 한 컨테이너에크래시·운영 경직병목 경로만
11등록 매크로 누락component types에 안 보임RCLCPP_COMPONENTS_REGISTER_NODE
12CMake 등록 누락라이브러리는 있는데 로드 실패rclcpp_components_register_nodes

1.13.11 체크포인트

  • 컴포지션이 없애는 비용 네 가지를 말할 수 있는가. 효과가 큰 조건과 미미한 조건은?
  • 컴포넌트가 일반 노드와 다른 두 가지는 무엇인가. NodeOptions를 받아야 하는 이유는?
  • 컨테이너 세 종류의 차이와 각각이 맞는 상황을 말할 수 있는가.
  • "컴포지션했는데 빨라지지 않았다"의 가장 흔한 원인은 무엇인가.
  • intra-process에서 복사 0을 달성하는 조건 네 가지를 나열할 수 있는가.
  • 컴포지션으로 잃는 것 일곱 가지 중, 안전 시스템에서 가장 중요한 것은 무엇이며 어떻게 대응하는가.
  • 어떤 노드 쌍을 묶을지 판단하는 절차를 그릴 수 있는가.
  • 안전 감시 노드를 다른 노드와 같은 컨테이너에 두면 안 되는 이유를 안전 논증 관점에서 설명할 수 있는가.
  • 라이프사이클과 컴포넌트를 함께 쓰면 무엇을 되찾는가.
  • 컴포지션의 효과를 측정하는 방법 세 가지를 말할 수 있는가. "빨라진 것 같다"가 왜 근거가 못 되는가.

여기까지가 제1부 계산 그래프의 코어다. 노드·이름·인터페이스·직렬화·토픽·QoS·서비스·액션·파라미터·시간·로깅·라이프사이클·컴포지션 — ROS 2 애플리케이션을 구성하는 재료가 모두 나왔다.

그런데 이 장들을 관통하며 반복해서 등장한 것이 있다. executor다. 콜백이 실행되지 않는다(1.4), 서비스가 데드락에 빠진다(1.7), 액션 실행이 전체를 막는다(1.8), 컨테이너 종류를 골라야 한다(1.13) — 전부 실행 모델의 문제였고, 그때마다 "2.2~2.4에서 다룬다"고 미뤄 두었다. 다음 제2부가 그 약속을 이행한다. 2.1에서 rclcpp·rcl·rmw 계층을 해부하는 것부터 시작한다.

1.13.12 제1부를 마치며 — 계산 그래프의 코어

이 장은 제1부(1.1~1.13)의 마지막이므로, 부 전체를 하나의 관점으로 묶는다.

제1부가 반복해 말한 세 가지:

  1. 이름이 먼저다. 노드·토픽·서비스가 안 보이는 문제의 대부분은 통신이 아니라 이름·네임스페이스·리매핑에서 온다(1.1). 그래프를 의심하기 전에 이름을 확인하라.
  2. QoS는 설정이 아니라 계약이다. 호환되지 않으면 연결 자체가 성립하지 않고, 아무도 알려 주지 않는다(1.5·1.6). ros2 topic info -v가 그 계약을 보여 주는 유일한 창이다.
  3. 통신 수단은 의미로 고른다. 토픽·서비스·액션·파라미터는 성능이 아니라 상호작용의 성격으로 갈린다(1.7~1.9). 잘못 고르면 나중에 구조적 비용으로 치른다.

다음 부에서 무엇이 달라지나: 제1부가 "무엇이 오가는가"였다면 *제2부는 "그것이 언제 어떤 스레드에서 처리되는가"***다. 같은 코드가 executor 설정 하나로 동작이 바뀌는 세계로 들어간다.

제2부는 실행 모델·클라이언트 라이브러리 내부(2.1~2.10)다.