ROS 2 Executor와 콜백 그룹 동시성 실행 원리

ROS 2의 논리 단위인 노드와 운영체제 프로세스·스레드의 차이를 짚고, 느린 AI 추론 콜백이 고속 제어 타이머를 막지 않도록 Executor와 콜백 그룹을 설계하는 방법을 설명합니다.

무료 공개 · 최근 수정

논리 단위인 노드와 운영체제 프로세스·스레드의 관계

ROS 2의 노드 그래프 시각화 도구(rqt_graph)를 켜면 화면에 네모난 상자(노드)와 화살표(토픽)가 깔끔하게 그려집니다. 이 그림을 처음 보는 입문자는 흔히 "상자 하나가 운영체제 프로세스 하나이고, 토픽마다 알아서 병렬 스레드로 동시에 돌겠지"라고 생각합니다.

하지만 이 직관을 믿고 노드 하나 안에 250ms가 걸리는 카메라 영상 분석 함수와 20ms마다 실행되어야 하는 충돌 방지 타이머 함수를 함께 넣으면, 영상 분석이 도는 동안 20ms 타이머가 10번 넘게 밀려 버리는 현상을 겪게 됩니다. 왜 이런 일이 생기는지 이해하려면 논리적 설계도와 실제 운영체제 위에서의 실행 구조를 구분해야 합니다(프로세스와 스레드의 차이 참조).

먼저 짚어야 할 핵심은 ROS 2의 노드가 운영체제의 프로세스(Process)나 스레드(Thread)와 1:1로 같지 않다는 사실입니다. 노드는 토픽·서비스·액션·파라미터를 묶어 관리하는 ROS 2 그래프 위의 논리적 객체(rclpy.node.Node 또는 rclcpp::Node 인스턴스)입니다(로봇 코드를 노드로 나누는 이유와 ROS 2 분산 구조 참조).

반면 이 노드 객체의 코드를 실제로 메모리에 올리고 CPU 코어에서 실행하는 주체는 운영체제의 프로세스와 스레드입니다. 개발자가 어떻게 배치하느냐에 따라 노드와 프로세스의 관계는 세 가지 형태로 달라집니다.

배치 방식프로세스 수와 노드 수스레드 구성특징 및 대표 용도
기본 1:1 배치 (ros2 run)프로세스 1개 = 노드 1개단일 스레드 (기본값)장애 격리가 가장 확실하며 입문·일반 노드에 적합
다중 스레드 노드프로세스 1개 = 노드 1개다중 스레드 풀한 노드 안에서 느린 연산과 빠른 타이머를 병렬 처리
단일 프로세스 다중 노드프로세스 1개 = 노드 N개단일 또는 다중 스레드대용량 카메라 영상 등을 프로세스 간 복사 없이 공유

즉 노드 그래프는 "누가 누구와 어떤 토픽으로 대화하는가"라는 통신 설계도일 뿐이고, 그 노드들을 몇 개의 프로세스와 몇 개의 스레드로 돌릴지는 개발자가 코드에서 선택하는 실행 설계의 영역입니다.

이벤트를 감지해 콜백을 깨우는 Executor와 Wait Set의 동작

ROS 2 노드는 개발자가 직접 while 루프를 돌며 소켓 버퍼를 계속 확인(Polling)하지 않습니다. 대신 "카메라 토픽 메시지가 도착하면 이 함수를 실행해 줘", "50ms 타이머가 만료되면 저 함수를 불러 줘"라고 함수를 미리 등록해 두는 이벤트 기반 구조를 씁니다. 이렇게 특정 이벤트가 발생했을 때 호출되도록 등록해 둔 함수를 콜백(Callback)이라고 합니다.

그렇다면 등록된 수많은 콜백 중 지금 당장 실행할 준비가 된 것을 누가 확인하고 깨워 줄까요? 이 역할을 맡는 실행 엔진이 바로 Executor이며, 이벤트 대기열 감시부터 콜백 그룹 스케줄링으로 이어지는 내부 실행 주기는 다음 흐름도를 따릅니다.

ROS 2 Executor와 콜백 그룹 기반 동시성 스케줄링 흐름

그림: Wait Set에서 준비된 이벤트를 꺼내 콜백 그룹 규칙대로 배분하는 Executor 흐름

파이썬이나 C++ 노드 코드의 마지막 줄에 항상 등장하는 rclpy.spin(node) 또는 rclcpp::spin(node)가 바로 기본 Executor를 생성해 무한 실행 루프에 들어가는 명령입니다. 내부 동작은 세 단계로 이루어집니다.

  1. 대기 집합(Wait Set) 감시: Executor는 하단 rcl과 DDS 계층(ROS 2 계층 구조와 DDS 무마스터 자동 디스커버리 참조)의 대기 집합을 통해 타이머 만료, 구독 토픽 도착, 서비스 요청, 액션 응답 중 새로 도착한 이벤트가 있는지 확인합니다. 아무 이벤트도 없으면 CPU를 소모하지 않고 대기합니다.
  2. 준비된 작업 인출: 이벤트가 감지되면 실행 가능한 콜백을 꺼내 옵니다.
  3. 스레드 배분 및 실행: 연결된 스레드 중 규칙상 실행 가능한 스레드에 콜백 함수를 넘겨 실행한 뒤 다시 1단계로 돌아갑니다.

여기서 가장 중요한 기본 규칙이 있습니다. rclpy.spin(node)와 rclcpp::spin(node)가 기본으로 사용하는 SingleThreadedExecutor(단일 스레드 Executor)는 오직 스레드 1개만으로 노드 안의 모든 콜백을 하나씩 순서대로 처리합니다.

느린 콜백이 빠른 타이머를 막는 현상과 콜백 그룹 분리

단일 스레드 Executor를 쓸 때 왜 느린 작업이 빠른 제어 루프를 멈추게 하는지 구체적인 수치로 살펴보겠습니다. 한 노드에 20ms(50Hz)마다 모터 상태를 점검하는 타이머 콜백 A와, 토픽이 들어올 때마다 250ms 동안 딥러닝 추론을 수행하는 구독 콜백 B가 함께 들어 있다고 가정합니다.

단일 스레드가 콜백 B를 실행하기 시작하면, 그 250ms 동안 스레드는 콜백 B에 완전히 묶입니다. 그사이에 20ms 타이머가 12번이나 만료되더라도 Executor에는 남는 스레드가 없으므로 콜백 A는 250ms가 끝날 때까지 단 한 번도 실행되지 못하고 밀립니다.

이 문제를 해결하려면 스레드 수를 늘리는 MultiThreadedExecutor를 도입하면서, 동시에 어떤 콜백끼리 겹쳐서 실행해도 되는지 정해 주는 콜백 그룹(Callback Group)을 지정해야 합니다.

[참고] MultiThreadedExecutor만 쓰면 왜 병렬 실행이 안 될까요? 모든 노드는 생성될 때 기본 콜백 그룹으로 상호 배타 그룹(MutuallyExclusiveCallbackGroup)을 하나 가집니다. 별도 그룹을 지정하지 않고 만든 콜백들은 전부 이 기본 그룹 하나에 들어갑니다. 상호 배타 그룹은 "같은 그룹 안의 콜백은 동시에 실행하지 않는다"는 규칙을 강제하므로, MultiThreadedExecutor에 스레드를 8개 주더라도 콜백 그룹을 나누지 않으면 결국 한 번에 하나씩만 실행됩니다.

ROS 2가 제공하는 두 가지 콜백 그룹의 차이는 명확합니다.

  • MutuallyExclusiveCallbackGroup(상호 배타 그룹): 같은 그룹에 속한 콜백들은 절대 동시에 실행되지 않습니다. 서로 다른 그룹에 속한 콜백끼리는 다른 스레드에서 동시에 실행될 수 있습니다.
  • ReentrantCallbackGroup(재진입 허용 그룹): 같은 그룹에 속한 콜백이라도 여러 스레드에서 동시에 실행될 수 있습니다. 심지어 똑같은 콜백 함수 하나가 이전 호출이 끝나기 전에 다른 스레드에서 또 호출될 수 있으므로, 공유 변수를 다룰 때 반드시 잠금(Lock / Mutex) 처리를 해야 합니다.

따라서 무거운 비전 추론 콜백과 빠른 20ms 안전 타이머를 같은 노드에 두어야 한다면, 아래 파이썬 코드처럼 두 콜백을 서로 다른 상호 배타 그룹으로 분리하고 MultiThreadedExecutor로 실행하는 것이 가장 안전하고 직관적인 해결책입니다.

import time
import rclpy
from rclpy.node import Node
from rclpy.callback_groups import MutuallyExclusiveCallbackGroup
from rclpy.executors import MultiThreadedExecutor
 
class SafeRobotNode(Node):
    def __init__(self):
        super().__init__('safe_robot_node')
        # 1. 빠른 제어용 그룹과 느린 AI 추론용 그룹을 분리
        self.fast_group = MutuallyExclusiveCallbackGroup()
        self.slow_group = MutuallyExclusiveCallbackGroup()
 
        # 20ms(50Hz) 안전 점검 타이머 -> fast_group 배정
        self.fast_timer = self.create_timer(
            0.02, self.safety_check_cb, callback_group=self.fast_group
        )
        # 500ms 주기 무거운 추론 타이머 -> slow_group 배정
        self.slow_timer = self.create_timer(
            0.5, self.heavy_vision_cb, callback_group=self.slow_group
        )
 
    def safety_check_cb(self):
        self.get_logger().info('20ms 안전 타이머 정상 동작 중')
 
    def heavy_vision_cb(self):
        self.get_logger().info('--- 250ms 무거운 추론 시작 ---')
        time.sleep(0.25)  # 250ms 동안 이 스레드를 점유해도 fast_group은 다른 스레드에서 계속 실행됨
        self.get_logger().info('--- 250ms 무거운 추론 완료 ---')
 
def main():
    rclpy.init()
    node = SafeRobotNode()
    # 2. 최소 2개 이상의 스레드를 가진 MultiThreadedExecutor로 실행
    executor = MultiThreadedExecutor(num_threads=2)
    executor.add_node(node)
    executor.spin()

프로세스 경계를 줄이는 컴포저블 노드와 실행 배치 기준

지금까지는 주로 "프로세스 1개에 노드 1개"를 띄우는 구성을 살펴보았습니다. 그런데 4K 고해상도 카메라 노드에서 초당 60프레임의 원본 영상을 발행하고, 같은 컴퓨터의 장애물 검출 노드가 이를 구독하면 어떤 일이 벌어질까요? 두 노드가 서로 다른 프로세스에 있으면 매 프레임 수십 메가바이트의 데이터를 직렬화(Serialization)해 프로세스 경계 너머로 복사하느라 CPU와 메모리 대역폭을 크게 소모합니다.

이때 노드 코드는 기능별로 깔끔하게 분리해 둔 채, 실행할 때만 여러 노드를 단일 운영체제 프로세스(컴포넌트 컨테이너) 안에 함께 적재해 돌리는 기술이 컴포저블 노드(Composable Node, 컴포넌트)입니다. 주로 C++(rclcpp_components)에서 널리 쓰이며, 노드를 독립 실행 파일이 아니라 공유 라이브러리(.so) 형태로 빌드해 한 프로세스에 묶습니다.

같은 프로세스 주소 공간을 공유하는 컴포저블 노드끼리는 프로세스 내부 통신(Intra-process Communication) 옵션을 켜서, 대용량 이미지나 3D 포인트 클라우드 데이터를 복사하지 않고 메모리 포인터(std::unique_ptr)만 넘기는 제로 카피(Zero-copy) 방식으로 전달할 수 있습니다.

실제 로봇 프로젝트를 설계할 때는 다음 순서로 실행 배치를 결정하면 시행착오를 줄일 수 있습니다.

  1. 개발 초기에는 기능별로 노드를 나누고 1 노드 = 1 프로세스 + SingleThreadedExecutor로 단순하게 시작해 장애 격리와 디버깅 편의성을 확보합니다.
  2. 한 노드 안에서 서비스 호출 대기나 무거운 연산이 빠른 주기 타이머를 가로막는 현상이 측정되면, MultiThreadedExecutor와 MutuallyExclusiveCallbackGroup 분리를 적용합니다.
  3. 카메라·라이다 같은 대용량 센서 파이프라인에서 프로세스 간 데이터 복사 지연이 병목으로 확인될 때 해당 노드들을 C++ 컴포저블 노드로 묶어 단일 프로세스 컨테이너에 배치합니다.

이로써 왜 로봇 소프트웨어를 분산 노드로 나누는지(1편), 중앙 마스터 없이 계층 구조와 DDS가 노드를 어떻게 연결하는지(2편), 토픽·서비스·액션과 QoS를 어떻게 맞추는지(3편), 그리고 노드 내부에서 Executor와 콜백 그룹이 어떻게 코드를 실행하는지까지 ROS 2 아키텍처의 전체 뼈대를 모두 살펴보았습니다.

참고 문서

설명이 어렵거나 잘못된 부분을 발견하셨나요?

문서 수정 의견 보내기