학습 목표

  • Launch가 단순 실행 Script가 아니라 System 구성 명세인 이유를 설명할 수 있다.
  • Python Launch에서 Node, Argument, Substitution, Parameter, Namespace와 Remap을 사용할 수 있다.
  • 다른 Launch를 Include하고 조건·Group·Event Handler로 실행 구성을 조합할 수 있다.
  • Process 시작과 Application 준비 완료가 다른 사건임을 설명할 수 있다.
  • Lifecycle Node의 Primary·Transition State와 전이 결과를 설명할 수 있다.
  • Python Lifecycle Node를 구현하고 CLI로 Configure·Activate·Deactivate·Cleanup할 수 있다.
  • Lifecycle Publisher와 일반 Subscription의 관리 범위를 구분할 수 있다.
  • 실제 Robot의 Driver·Localization·Controller를 안전한 순서로 시작·정지하는 구조를 설계할 수 있다.

1. Launch가 해결하는 문제

실제 Robot은 Driver, TF, Odometry, Sensor Fusion, Safety, Navigation, UI와 Logging Node를 함께 실행합니다. 이를 Terminal마다 수동으로 실행하면 이름, Parameter와 순서를 재현하기 어렵습니다.

수동 실행                         Launch 실행
────────────────────────────────────────────────
명령을 사람에게 의존              구성을 파일로 기록
Terminal마다 Parameter 입력        YAML·Argument로 재현
종료 Process를 하나씩 찾음          전체 Process를 감시·종료
Robot별 이름 충돌                  Namespace로 분리
배선 변경에 Code 수정              Remapping으로 연결 변경

Launch System은 다음을 기술하고 실행합니다.

  • 어떤 Process와 ROS Node를 실행할지
  • 이름과 Namespace를 어떻게 정할지
  • 어떤 Parameter, Argument와 Environment를 전달할지
  • Topic·Service 이름을 어떻게 Remap할지
  • 다른 Launch 구성을 어떻게 포함할지
  • Process 시작·종료와 기타 Event에 어떻게 반응할지

Launch는 Node 내부 초기화 성공과 Robot 안전 준비를 자동으로 보장하지 않습니다. Process 관리와 Application 상태 관리는 구분해야 합니다.


2. XML·YAML·Python Launch 선택

ROS 2는 XML, YAML과 Python Launch 형식을 지원합니다.

형식 장점 적합한 경우
XML 선언적이고 짧음 단순하고 정적인 구성
YAML Data 중심으로 읽기 쉬움 간단한 Node 목록
Python API 전체, 계산과 동적 생성 가능 복잡한 조건·Include·Event

Python만 정답인 것은 아닙니다. 팀이 읽기 쉬운 선언형 구성이면 XML도 좋은 선택입니다. 이 강의는 복잡한 Robot Bringup을 다루기 위해 Python 형식을 중심으로 설명합니다.

Python 파일 이름은 보통 something.launch.py로 지정합니다. ros2 launch 자동 탐색과 관례에 맞기 때문입니다.


3. Launch Package 만들기

mkdir -p ~/ros2_ws/src
cd ~/ros2_ws/src
ros2 pkg create robot_bringup \
  --build-type ament_python \
  --dependencies launch launch_ros
mkdir -p robot_bringup/launch robot_bringup/config robot_bringup/rviz

권장 구조입니다.

robot_bringup/
├── launch/
│   ├── sensors.launch.py
│   ├── navigation.launch.py
│   └── bringup.launch.py
├── config/
│   ├── sensors.yaml
│   └── safety.yaml
├── rviz/
│   └── robot.rviz
├── package.xml
└── setup.py

package.xml에 Runtime 의존성을 둡니다.

<exec_depend>ros2launch</exec_depend>
<exec_depend>launch</exec_depend>
<exec_depend>launch_ros</exec_depend>

4. Launch 파일 설치

Python Package의 setup.py에서 Launch와 Config 파일을 설치해야 다른 Workspace나 설치본에서도 찾을 수 있습니다.

import os
from glob import glob
from setuptools import setup

package_name = 'robot_bringup'

setup(
    name=package_name,
    # 나머지 Metadata 생략
    data_files=[
        ('share/ament_index/resource_index/packages',
         ['resource/' + package_name]),
        ('share/' + package_name, ['package.xml']),
        (os.path.join('share', package_name, 'launch'),
         glob('launch/*.launch.py')),
        (os.path.join('share', package_name, 'config'),
         glob('config/*.yaml')),
        (os.path.join('share', package_name, 'rviz'),
         glob('rviz/*.rviz')),
    ],
)

CMake Package에서는 다음처럼 설치합니다.

install(DIRECTORY launch config rviz
  DESTINATION share/${PROJECT_NAME}
)

Build 후 Overlay를 Source합니다.

cd ~/ros2_ws
colcon build --packages-select robot_bringup --symlink-install
source install/setup.bash

5. 가장 작은 Python Launch

# launch/minimal.launch.py
from launch import LaunchDescription
from launch_ros.actions import Node


def generate_launch_description():
    talker = Node(
        package='demo_nodes_cpp',
        executable='talker',
        name='talker',
        output='screen',
    )
    listener = Node(
        package='demo_nodes_py',
        executable='listener',
        name='listener',
        output='screen',
    )
    return LaunchDescription([talker, listener])
ros2 launch robot_bringup minimal.launch.py
ros2 node list
ros2 topic info /chatter --verbose

generate_launch_description()은 Launch가 실행할 Action들의 설명을 반환합니다. Python Script의 main()을 직접 실행하는 구조와 다릅니다.


6. Node Action의 주요 Option

Node(
    package='parameter_lab_py',
    executable='speed_governor',
    name='speed_governor',
    namespace='robot1',
    parameters=[{'max_speed_mps': 0.4}],
    remappings=[('requested_speed', 'cmd_vel_nav')],
    arguments=['--device', '/dev/ttyUSB0'],
    ros_arguments=['--log-level', 'info'],
    output='screen',
    emulate_tty=True,
)
Option 의미
package 실행 파일을 제공하는 Package
executable 설치된 실행 파일 이름
name ROS Node 이름 Override
namespace Node와 상대 ROS 이름에 붙는 Namespace
parameters Parameter YAML이나 Dictionary
remappings ROS 이름 연결 변경
arguments Process 실행 인자
ros_arguments ROS 전용 인자
output Log 출력 위치

executable은 Python Module 파일명이 아니라 setup.pyconsole_scripts에 등록된 실행 이름입니다.


7. Launch Argument와 LaunchConfiguration

외부에서 바꿀 값을 선언합니다.

from launch.actions import DeclareLaunchArgument
from launch.substitutions import LaunchConfiguration

use_sim_time = LaunchConfiguration('use_sim_time')
robot_name = LaunchConfiguration('robot_name')

return LaunchDescription([
    DeclareLaunchArgument(
        'use_sim_time',
        default_value='false',
        description='Simulation Clock 사용 여부',
    ),
    DeclareLaunchArgument(
        'robot_name',
        default_value='robot1',
        description='Robot Namespace',
    ),
    # Node Actions...
])
ros2 launch robot_bringup bringup.launch.py --show-args
ros2 launch robot_bringup bringup.launch.py \
  robot_name:=robot2 use_sim_time:=true

LaunchConfiguration은 파일을 읽을 때 확정된 Python 문자열이 아니라 실행 Context에서 평가되는 Substitution입니다.

# 잘못된 예
if LaunchConfiguration('use_rviz') == 'true':
    ...

조건에는 IfCondition이나 UnlessCondition을 사용합니다.


8. Parameter YAML 전달

from launch.substitutions import PathJoinSubstitution
from launch_ros.substitutions import FindPackageShare

params_file = PathJoinSubstitution([
    FindPackageShare('robot_bringup'),
    'config',
    'safety.yaml',
])

safety_node = Node(
    package='robot_safety',
    executable='safety_filter',
    name='safety_filter',
    parameters=[
        params_file,
        {'use_sim_time': LaunchConfiguration('use_sim_time')},
    ],
    output='screen',
)

절대 경로 /home/user/...를 하드코딩하지 않습니다. 설치된 Package Share에서 찾으면 다른 Computer와 Container에서도 재사용할 수 있습니다.

YAML의 Node Key와 실제 name·namespace가 일치하는지 확인합니다.

ros2 param list /robot1/safety_filter
ros2 param get /robot1/safety_filter stop_distance_m

9. Namespace로 Robot 두 대 실행

from launch.actions import GroupAction
from launch_ros.actions import Node, PushRosNamespace


def robot_group(namespace):
    return GroupAction([
        PushRosNamespace(namespace),
        Node(
            package='robot_driver',
            executable='base_driver',
            name='base_driver',
            output='screen',
        ),
        Node(
            package='robot_safety',
            executable='safety_filter',
            name='safety_filter',
            remappings=[
                ('cmd_vel_in', 'cmd_vel_nav'),
                ('cmd_vel_out', 'cmd_vel'),
            ],
            output='screen',
        ),
    ])


def generate_launch_description():
    return LaunchDescription([
        robot_group('robot1'),
        robot_group('robot2'),
    ])
ros2 node list
ros2 topic list | grep cmd_vel

TF Frame 이름은 Node Namespace와 별개로 관리되는 경우가 있으므로 Multi-Robot에서는 URDF Prefix와 Navigation Frame Parameter도 함께 설계합니다.


10. Remapping으로 배선 변경

Node Code가 상대 이름을 사용하면 Launch에서 연결을 바꿀 수 있습니다.

Node(
    package='robot_safety',
    executable='safety_filter',
    remappings=[
        ('cmd_vel_in', 'cmd_vel_muxed'),
        ('cmd_vel_out', 'cmd_vel_safe'),
        ('scan', 'front/scan'),
    ],
)
Navigation → cmd_vel_nav ┐
Joystick   → cmd_vel_joy ├→ Velocity Mux → cmd_vel_muxed
                         └→ Safety Filter → cmd_vel_safe → Driver

Code에 /robot1/cmd_vel 같은 절대 이름을 박아 넣으면 Namespace와 Remap 재사용성이 떨어집니다. 공통 Component는 상대 이름을 기본으로 사용합니다.


11. 조건부 실행

from launch.conditions import IfCondition, UnlessCondition

rviz_node = Node(
    package='rviz2',
    executable='rviz2',
    condition=IfCondition(LaunchConfiguration('use_rviz')),
    output='screen',
)

hardware_driver = Node(
    package='robot_driver',
    executable='base_driver',
    condition=UnlessCondition(LaunchConfiguration('use_sim_time')),
    output='screen',
)
ros2 launch robot_bringup bringup.launch.py \
  use_rviz:=false use_sim_time:=true

복잡한 Boolean 조합은 PythonExpression을 사용할 수 있지만 읽기 어렵다면 Launch를 Profile별 파일로 나누는 편이 낫습니다.


12. 다른 Launch Include

from launch.actions import IncludeLaunchDescription
from launch.launch_description_sources import PythonLaunchDescriptionSource

sensors_launch = IncludeLaunchDescription(
    PythonLaunchDescriptionSource(
        PathJoinSubstitution([
            FindPackageShare('robot_bringup'),
            'launch',
            'sensors.launch.py',
        ])
    ),
    launch_arguments={
        'use_sim_time': LaunchConfiguration('use_sim_time'),
        'namespace': LaunchConfiguration('namespace'),
    }.items(),
)

큰 System은 다음처럼 책임별 Launch로 나눕니다.

bringup.launch.py
├── description.launch.py
├── sensors.launch.py
├── localization.launch.py
├── navigation.launch.py
└── visualization.launch.py

Include되는 Launch가 받을 Argument를 --show-args로 확인하고 상위 파일이 명시적으로 전달합니다.


13. OpaqueFunction은 언제 사용하는가

Substitution 값을 Python Code에서 실제 문자열로 평가해야 할 때 OpaqueFunction을 사용할 수 있습니다.

from launch.actions import OpaqueFunction


def create_robot_nodes(context):
    count_text = LaunchConfiguration('robot_count').perform(context)
    count = int(count_text)
    nodes = []
    for index in range(count):
        nodes.append(Node(
            package='demo_nodes_cpp',
            executable='talker',
            namespace=f'robot{index + 1}',
            name='talker',
        ))
    return nodes


OpaqueFunction(function=create_robot_nodes)

OpaqueFunction을 남용하면 Launch 구성이 일반 Python Program처럼 복잡해져 정적 분석과 재사용이 어려워집니다. 단순 조건은 Condition, 단순 경로는 Substitution으로 해결합니다.


14. Process Event Handler

Launch는 Process Event를 관찰할 수 있습니다.

from launch.actions import LogInfo, RegisterEventHandler, Shutdown
from launch.event_handlers import OnProcessExit, OnProcessStart

driver = Node(
    package='robot_driver',
    executable='base_driver',
    name='base_driver',
    output='screen',
)

driver_started = RegisterEventHandler(
    OnProcessStart(
        target_action=driver,
        on_start=[LogInfo(msg='Driver Process가 시작되었습니다.')],
    )
)

driver_exited = RegisterEventHandler(
    OnProcessExit(
        target_action=driver,
        on_exit=[
            LogInfo(msg='Driver가 종료되어 Bringup을 내립니다.'),
            Shutdown(reason='critical driver exited'),
        ],
    )
)

Process Start Event는 OS Process가 생성됐다는 의미입니다. Serial Port 연결, Parameter 검증과 Sensor 첫 Data까지 준비됐다는 뜻은 아닙니다.


15. OnExecutionComplete와 TimerAction

일회성 Action 완료 후 다음 Action을 실행할 수 있습니다.

from launch.actions import ExecuteProcess, RegisterEventHandler
from launch.event_handlers import OnExecutionComplete

calibration = ExecuteProcess(
    cmd=['ros2', 'run', 'robot_tools', 'check_config'],
    output='screen',
)

start_driver_after_check = RegisterEventHandler(
    OnExecutionComplete(
        target_action=calibration,
        on_completion=[driver],
    )
)

고정 지연도 가능합니다.

from launch.actions import TimerAction

delayed_rviz = TimerAction(
    period=3.0,
    actions=[rviz_node],
)

TimerAction은 시각 지연이 요구사항 자체일 때 적절합니다. “이 Computer라면 3초 뒤 준비됐을 것”이라는 추측으로 Readiness를 대신하면 장비와 부하가 바뀔 때 깨집니다.


16. Respawn을 사용할 때 주의점

Node(
    package='robot_monitor',
    executable='battery_monitor',
    respawn=True,
    respawn_delay=2.0,
    output='screen',
)

자동 재시작은 일시적 장애 복구에 유용하지만 다음 위험이 있습니다.

  • 설정 오류로 무한 Crash Loop
  • Hardware를 반복 Open하며 상태 악화
  • 원인을 가리는 Log 폭주
  • Motor Driver 재시작 중 마지막 Command 유지

재시도 횟수, Backoff, Fault 상태, Operator 알림과 하위 Driver Watchdog를 함께 설계합니다. Critical Safety Process를 무조건 Respawn하는 것이 안전을 보장하지 않습니다.


17. Launch는 준비 순서를 보장하지 않는다

LaunchDescription에 Node를 앞에 적었다고 그 Node가 준비를 마친 뒤 다음 Node가 시작되는 것은 아닙니다.

t=0.0  Driver Process 시작
t=0.1  Controller Process 시작
t=0.2  Controller가 Service 검색
t=1.8  Driver가 Serial 연결 완료

견고한 방법:

  1. Consumer가 Service·Action·TF를 제한시간 동안 기다립니다.
  2. One-shot Data는 적절한 Transient Local QoS를 사용합니다.
  3. Readiness Topic·Service를 명확히 정의합니다.
  4. 상태 순서가 중요하면 Lifecycle Manager를 사용합니다.
  5. 실패 시 무한 대기가 아닌 Fault와 복구 정책을 둡니다.

Process Event는 Process 생존을, Lifecycle은 Node 상태를, Application Health는 실제 Data와 Hardware 상태를 표현합니다.


18. 완성된 Robot Bringup Launch

# launch/bringup.launch.py
from launch import LaunchDescription
from launch.actions import (
    DeclareLaunchArgument,
    GroupAction,
    IncludeLaunchDescription,
    RegisterEventHandler,
    Shutdown,
)
from launch.conditions import IfCondition
from launch.event_handlers import OnProcessExit
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution
from launch_ros.actions import Node, PushRosNamespace
from launch_ros.substitutions import FindPackageShare


def generate_launch_description():
    namespace = LaunchConfiguration('namespace')
    use_sim_time = LaunchConfiguration('use_sim_time')
    use_rviz = LaunchConfiguration('use_rviz')
    safety_params = PathJoinSubstitution([
        FindPackageShare('robot_bringup'), 'config', 'safety.yaml'])

    driver = Node(
        package='robot_driver', executable='base_driver',
        name='base_driver', output='screen', emulate_tty=True)

    safety = Node(
        package='robot_safety', executable='safety_filter',
        name='safety_filter',
        parameters=[safety_params, {'use_sim_time': use_sim_time}],
        remappings=[('cmd_vel_in', 'cmd_vel_muxed'),
                    ('cmd_vel_out', 'cmd_vel')],
        output='screen')

    rviz = Node(
        package='rviz2', executable='rviz2',
        condition=IfCondition(use_rviz), output='screen')

    return LaunchDescription([
        DeclareLaunchArgument('namespace', default_value='robot1'),
        DeclareLaunchArgument('use_sim_time', default_value='false'),
        DeclareLaunchArgument('use_rviz', default_value='true'),
        GroupAction([
            PushRosNamespace(namespace),
            driver,
            safety,
        ]),
        rviz,
        RegisterEventHandler(OnProcessExit(
            target_action=driver,
            on_exit=[Shutdown(reason='base driver stopped')],
        )),
    ])

19. Lifecycle Node가 필요한 이유

일반 Node는 생성 직후 Callback과 Timer가 동작하기 시작합니다. Hardware 준비와 다른 Node의 준비 상태를 순서대로 통제하기 어렵습니다.

Lifecycle Node는 외부 관리자가 명시적인 상태 전이를 요청하는 Managed Node입니다.

Process 시작
    ↓
Unconfigured ──configure──> Inactive ──activate──> Active
      ↑                         │                    │
      └────────cleanup──────────┘<──deactivate──────┘

적용 대상:

  • Camera·LiDAR·Motor Driver
  • Localization과 Map Server
  • Safety Filter와 Controller
  • Navigation Stack처럼 준비 순서가 중요한 묶음

단순 변환 Utility까지 모두 Lifecycle로 만들면 관리 복잡도가 더 커질 수 있습니다.


20. Primary State와 Transition State

Primary State

상태 의미
Unconfigured Process는 있지만 기능 자원 미구성
Inactive 자원 구성 완료, 정상 출력 비활성
Active 정상 기능 수행
Finalized 종료 완료, 정상 전이로 복귀하지 않음

Transition State

  • Configuring
  • Activating
  • Deactivating
  • CleaningUp
  • ShuttingDown
  • ErrorProcessing

전이 Callback이 실행되는 동안 Transition State를 거칩니다. Callback Result와 현재 상태에 따라 다음 Primary State가 결정되므로 “FAILURE면 언제나 같은 상태로 돌아간다”고 단순화하지 말고 공식 State Machine과 실제 transition_event를 확인합니다.


21. Lifecycle Callback 책임

Callback 권장 책임
on_configure Parameter 검증, Memory·Publisher 준비, Hardware Open
on_activate 출력 활성화, Timer 시작, Motor Enable 조건 확인
on_deactivate 안전 정지, 출력 비활성, 자원은 유지
on_cleanup Publisher·Subscription·Buffer·Hardware 해제
on_shutdown 어느 상태에서든 종료 정리
on_error Fault 기록, 안전화, 복구 가능 여부 결정

전이 Callback은 무한 대기하지 않아야 합니다. Hardware 연결에 Timeout을 두고 실패 이유를 Log와 Diagnostic 상태로 남깁니다.


22. Lifecycle Package 만들기

cd ~/ros2_ws/src
ros2 pkg create lifecycle_lab_py \
  --build-type ament_python \
  --dependencies rclpy lifecycle_msgs std_msgs sensor_msgs geometry_msgs

setup.py에 등록합니다.

entry_points={
    'console_scripts': [
        'managed_sensor = lifecycle_lab_py.managed_sensor:main',
        'scan_guard = lifecycle_lab_py.scan_guard:main',
    ],
},
cd ~/ros2_ws
colcon build --packages-select lifecycle_lab_py --symlink-install
source install/setup.bash

23. 가장 작은 Python Lifecycle Node

# lifecycle_lab_py/managed_sensor.py
import rclpy
from rclpy.lifecycle import LifecycleNode
from rclpy.lifecycle import State, TransitionCallbackReturn
from std_msgs.msg import String


class ManagedSensor(LifecycleNode):
    def __init__(self):
        super().__init__('managed_sensor')
        self.publisher = None
        self.timer = None

    def on_configure(self, state: State):
        self.get_logger().info('configure: 자원 생성')
        self.publisher = self.create_lifecycle_publisher(
            String, 'sensor_status', 10)
        self.timer = self.create_timer(1.0, self.tick)
        return TransitionCallbackReturn.SUCCESS

    def on_activate(self, state: State):
        self.get_logger().info('activate: 출력 활성화')
        return super().on_activate(state)

    def on_deactivate(self, state: State):
        self.get_logger().info('deactivate: 출력 비활성화')
        return super().on_deactivate(state)

    def on_cleanup(self, state: State):
        self.get_logger().info('cleanup: 자원 해제')
        if self.timer is not None:
            self.destroy_timer(self.timer)
            self.timer = None
        if self.publisher is not None:
            self.destroy_lifecycle_publisher(self.publisher)
            self.publisher = None
        return TransitionCallbackReturn.SUCCESS

    def tick(self):
        if self.publisher is None:
            return
        message = String()
        message.data = 'sensor ready'
        self.publisher.publish(message)


def main(args=None):
    rclpy.init(args=args)
    node = ManagedSensor()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.shutdown()


if __name__ == '__main__':
    main()

Timer Callback은 Inactive에서도 호출될 수 있지만 Lifecycle Publisher는 활성화되지 않았으므로 Message를 내보내지 않습니다. CPU 작업 자체까지 멈추려면 Timer를 Activate 때 생성하거나 Cancel하고 상태 Flag를 명시적으로 관리합니다.


24. CLI로 상태 전이

Terminal A:

ros2 run lifecycle_lab_py managed_sensor

Terminal B:

ros2 lifecycle nodes
ros2 lifecycle get /managed_sensor
ros2 lifecycle list /managed_sensor

ros2 lifecycle set /managed_sensor configure
ros2 lifecycle get /managed_sensor

ros2 lifecycle set /managed_sensor activate
ros2 topic echo /sensor_status

ros2 lifecycle set /managed_sensor deactivate
ros2 lifecycle set /managed_sensor cleanup
ros2 lifecycle set /managed_sensor shutdown

Transition Event를 관찰합니다.

ros2 topic echo /managed_sensor/transition_event

25. 안전한 ScanGuard Lifecycle 예제

# lifecycle_lab_py/scan_guard.py
import math

import rclpy
from geometry_msgs.msg import Twist
from rclpy.lifecycle import LifecycleNode
from rclpy.lifecycle import State, TransitionCallbackReturn
from rclpy.qos import qos_profile_sensor_data
from sensor_msgs.msg import LaserScan


class ScanGuard(LifecycleNode):
    def __init__(self):
        super().__init__('scan_guard')
        self.publisher = None
        self.subscription = None
        self.stop_distance = 0.0
        self.active = False

    def on_configure(self, state: State):
        self.declare_parameter('stop_distance_m', 0.4)
        value = self.get_parameter('stop_distance_m').value
        if not isinstance(value, float) or not math.isfinite(value):
            self.get_logger().error('stop_distance_m Type·값 오류')
            return TransitionCallbackReturn.FAILURE
        if not 0.1 <= value <= 3.0:
            self.get_logger().error('stop_distance_m 범위는 0.1~3.0m')
            return TransitionCallbackReturn.FAILURE

        self.stop_distance = value
        self.publisher = self.create_lifecycle_publisher(
            Twist, 'cmd_vel_safe', 1)
        self.subscription = self.create_subscription(
            LaserScan, 'scan', self.on_scan, qos_profile_sensor_data)
        return TransitionCallbackReturn.SUCCESS

    def on_activate(self, state: State):
        self.active = True
        return super().on_activate(state)

    def on_deactivate(self, state: State):
        # Publisher를 비활성화하기 전에 마지막 정지 명령 시도
        if self.publisher is not None:
            self.publisher.publish(Twist())
        self.active = False
        return super().on_deactivate(state)

    def on_cleanup(self, state: State):
        self.active = False
        if self.subscription is not None:
            self.destroy_subscription(self.subscription)
            self.subscription = None
        if self.publisher is not None:
            self.destroy_lifecycle_publisher(self.publisher)
            self.publisher = None
        return TransitionCallbackReturn.SUCCESS

    def on_scan(self, message):
        # 일반 Subscription은 Lifecycle이 자동 중지하지 않으므로 검사한다.
        if not self.active or self.publisher is None:
            return
        valid = [value for value in message.ranges
                 if math.isfinite(value)
                 and message.range_min <= value <= message.range_max]
        command = Twist()
        if valid and min(valid) >= self.stop_distance:
            command.linear.x = 0.2
        self.publisher.publish(command)


def main(args=None):
    rclpy.init(args=args)
    node = ScanGuard()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.shutdown()

중요한 사실은 Lifecycle Publisher만 자동 관리된다는 점입니다. 일반 Subscription과 Timer Callback은 Inactive에서도 실행될 수 있습니다. 실제 동작을 완전히 멈추려면 Activate·Deactivate에서 Entity, Timer 또는 상태를 명시적으로 관리합니다.


26. FAILURE와 ERROR를 구분

TransitionCallbackReturn의 의미:

  • SUCCESS: 요청한 전이를 정상 완료
  • FAILURE: 예상 가능한 조건 때문에 전이를 완료하지 못함
  • ERROR: 예상하지 못한 심각한 오류, Error Processing 경로

예시:

def on_configure(self, state):
    try:
        self.device = open_device(timeout_sec=2.0)
        if self.device is None:
            return TransitionCallbackReturn.FAILURE
        return TransitionCallbackReturn.SUCCESS
    except Exception as error:
        self.get_logger().exception(f'예상하지 못한 오류: {error}')
        return TransitionCallbackReturn.ERROR

on_error()에서는 Actuator를 안전화하고 부분 생성 자원을 정리합니다. 복구 가능 여부와 다음 상태는 공식 State Machine과 Client Library 동작을 기준으로 Test해야 합니다.


27. 전이 실패 시 부분 자원 정리

on_configure 중 Camera는 열었지만 Publisher 생성이 실패할 수 있습니다. 전이 실패가 자동으로 모든 외부 자원을 되돌려 주지는 않습니다.

def on_configure(self, state):
    try:
        self.camera = self.open_camera()
        self.publisher = self.create_lifecycle_publisher(Image, 'image', 10)
        return TransitionCallbackReturn.SUCCESS
    except Exception as error:
        if self.camera is not None:
            self.camera.close()
            self.camera = None
        if self.publisher is not None:
            self.destroy_lifecycle_publisher(self.publisher)
            self.publisher = None
        self.get_logger().error(f'configure 실패: {error}')
        return TransitionCallbackReturn.FAILURE

각 Callback은 반복 호출되어도 자원이 중복 생성되지 않게 작성하고, 모든 실패 지점의 Rollback을 Test합니다.


28. LifecycleNode Launch Action

Launch에서 Managed Node임을 표현할 수 있습니다.

from launch_ros.actions import LifecycleNode

managed_sensor = LifecycleNode(
    package='lifecycle_lab_py',
    executable='managed_sensor',
    name='managed_sensor',
    namespace='robot1',
    output='screen',
    parameters=[{'use_sim_time': False}],
)

이 Action으로 Process를 시작했다고 자동으로 Active가 되는 것은 아닙니다. 전이 요청을 보내는 Manager나 Launch Event가 필요합니다.


29. Launch Event로 Configure·Activate

Lifecycle 전이를 Launch Event로 요청할 수 있습니다. API Import와 Matcher는 배포판에 따라 확인합니다.

from launch.actions import EmitEvent, RegisterEventHandler
from launch.events import matches_action
from launch_ros.event_handlers import OnStateTransition
from launch_ros.events.lifecycle import ChangeState
from lifecycle_msgs.msg import Transition

configure_event = EmitEvent(event=ChangeState(
    lifecycle_node_matcher=matches_action(managed_sensor),
    transition_id=Transition.TRANSITION_CONFIGURE,
))

activate_after_inactive = RegisterEventHandler(
    OnStateTransition(
        target_lifecycle_node=managed_sensor,
        goal_state='inactive',
        entities=[EmitEvent(event=ChangeState(
            lifecycle_node_matcher=matches_action(managed_sensor),
            transition_id=Transition.TRANSITION_ACTIVATE,
        ))],
    )
)

단순 Demo에서는 Launch Event가 편하지만 다수 Node의 Retry, Timeout, Health와 역순 종료까지 관리하려면 전용 Lifecycle Manager Node가 더 명확합니다.


30. Lifecycle Manager 설계

Manager는 Service Client로 각 Managed Node의 상태를 확인하고 전이를 요청합니다.

시작 순서
1. Hardware Driver configure
2. Sensor Driver configure
3. Localization configure
4. Safety Controller configure
5. 모두 Inactive 확인
6. Driver → Sensor → Localization → Safety activate

정지 순서
1. Command Source deactivate
2. Safety·Controller deactivate
3. Sensor deactivate
4. Hardware Driver deactivate
5. 역순 cleanup

Manager가 가져야 할 정책:

  • Node별 전이 Timeout
  • 실패 시 Retry와 Backoff
  • 하나가 실패할 때 전체 Rollback 범위
  • Active 전 실제 Data Health 확인
  • Shutdown의 역순과 Actuator 안전화
  • 상태·실패 이유의 Diagnostic 공개

Nav2의 Lifecycle Manager가 Map Server, Planner, Controller 등 Managed Node를 순서대로 관리하는 대표 사례입니다.


31. Active와 Ready는 같지 않다

Lifecycle State가 Active라도 다음 문제가 있을 수 있습니다.

  • Camera는 Active지만 Frame이 오지 않음
  • Localization은 Active지만 Pose 품질이 나쁨
  • Controller는 Active지만 Motor Fault
  • Map Server는 Active지만 잘못된 Map Version

따라서 세 수준을 구분합니다.

Process Alive     OS Process가 존재
Lifecycle Active  기능 실행이 허용됨
Application Ready 실제 Data·Hardware·품질 조건 충족

Readiness는 Diagnostic Topic, Health Service 또는 명시적인 상태 Interface로 제공하고 Manager가 Activation 전후에 검증합니다.


32. 실제 Robot의 안전 시작 절차

전원·E-stop 확인
      ↓
Driver Configure: Port Open, Firmware·ID 검증
      ↓
Sensor Configure: Calibration·Frame·주기 검증
      ↓
Localization Configure: Map·TF 준비
      ↓
모두 Inactive + Health OK
      ↓
Safety Filter Activate
      ↓
Driver Activate
      ↓
Command Source Activate

Actuator를 가장 먼저 Activate하면 상위 Safety와 Command 소유권이 준비되기 전에 움직일 수 있습니다. 구체적인 순서는 Robot 위험 분석과 Driver 특성에 맞춰 정합니다.


33. 안전한 Deactivate와 Shutdown

Lifecycle Transition Service 왕복만 Emergency Stop으로 사용하지 않습니다. 정상 관리 정지는 다음 순서를 가질 수 있습니다.

def on_deactivate(self, state):
    self.command_enabled = False
    self.publish_zero_velocity()
    if not self.wait_until_stopped(timeout_sec=1.0):
        self.get_logger().error('정지 확인 Timeout')
        return TransitionCallbackReturn.ERROR
    self.disable_motor_command_path()
    return super().on_deactivate(state)

독립 E-stop, Motor Driver Watchdog와 전원 안전 회로는 Lifecycle과 별도로 유지합니다. Process가 Hang되면 on_deactivate 자체가 실행되지 않을 수 있기 때문입니다.


34. Launch와 Lifecycle의 역할 분담

요구 Launch Lifecycle
Process 실행·종료 담당 직접 담당하지 않음
Argument·경로·환경 담당 Node 내부 Parameter 사용
Namespace·Remap 담당 일반 ROS 이름 규칙 따름
다른 Launch 조합 담당 해당 없음
Node 기능 준비 상태 간접 관찰 명시적 상태 전이
Configure 실패 표현 Process Log 수준 Transition Result
일시 중지·재개 Process 재시작 가능 Deactivate·Activate
자원 정리 Process 종료 Cleanup Callback

둘은 경쟁 관계가 아니라 함께 사용합니다. Launch가 Manager와 Managed Node Process를 시작하고 Manager가 Lifecycle State를 통제합니다.


35. Test 전략

Launch Test 항목:

  • 기본 Argument와 Override
  • Package Share 경로와 설치 누락
  • Namespace·Remap 최종 이름
  • 조건별 Node 존재 여부
  • Critical Process 종료 시 정책
  • Include Argument 전달

Lifecycle Test 항목:

  • 모든 정상 전이
  • 잘못된 Parameter의 Configure Failure
  • Hardware 연결 Timeout
  • Activate 중 Fault
  • Deactivate의 실제 정지 확인
  • Cleanup 후 재Configure
  • 반복 전이의 자원 Leak
  • Manager 중간 실패와 Rollback
ros2 launch robot_bringup bringup.launch.py --show-args
ros2 launch -d robot_bringup bringup.launch.py
ros2 lifecycle get /robot1/managed_sensor
ros2 lifecycle list /robot1/managed_sensor

36. Launch 진단 순서

ros2 launch robot_bringup bringup.launch.py --show-args
ros2 pkg executables robot_driver
ros2 pkg prefix robot_bringup
ros2 launch -d robot_bringup bringup.launch.py
ros2 node list
ros2 topic list -t
ros2 param list /robot1/safety_filter
ros2 topic info /robot1/cmd_vel --verbose
증상 확인
Launch 파일을 못 찾음 설치·Source·파일명 launch.py
Executable을 못 찾음 setup.py entry point·설치
Node 이름이 예상과 다름 Namespace·name Override
Parameter 기본값 사용 YAML Key·경로·Type
조건이 무시됨 Python if 대신 Condition 사용
Include Argument 미적용 Child의 Declare와 전달 이름
Log가 안 보임 output='screen', Log 파일

37. Lifecycle 진단 순서

ros2 lifecycle nodes
ros2 lifecycle get /managed_sensor
ros2 lifecycle list /managed_sensor
ros2 service list | grep managed_sensor
ros2 topic echo /managed_sensor/transition_event
ros2 topic echo /rosout
증상 확인
Lifecycle 목록에 없음 일반 Node로 실행했는지, Interface 활성 여부
Configure 실패 Parameter·Hardware·Callback Log
Active인데 출력 없음 Lifecycle Publisher 활성화, super() 호출
Inactive인데 CPU 사용 Timer·Subscription은 자동 중지되지 않음
Cleanup 후 재실패 Handle을 None으로 복구했는지
Shutdown이 느림 Blocking I/O와 Timeout
Manager가 멈춤 전이 Service Timeout·Callback Group

38. 흔한 실수와 교정

  1. Launch가 Node 준비 완료까지 기다린다고 생각한다. Process Start와 Ready를 분리합니다.
  2. Python Launch만 가능하다고 생각한다. XML·YAML도 요구에 맞게 선택합니다.
  3. LaunchConfiguration을 Python 문자열처럼 비교한다. Condition 또는 Context 평가를 사용합니다.
  4. 절대 경로를 하드코딩한다. Package Share Substitution을 사용합니다.
  5. Launch 파일을 설치하지 않는다. setup.py나 CMake 설치 규칙을 추가합니다.
  6. TimerAction으로 Readiness를 추측한다. Service·Health·Lifecycle을 사용합니다.
  7. Process Start Event를 Hardware Ready로 본다. Application Health를 별도 확인합니다.
  8. Inactive면 모든 Callback이 멈춘다고 생각한다. 일반 Subscription·Timer를 직접 관리합니다.
  9. Lifecycle Publisher에서 super().on_activate()를 누락한다. Managed Entity 전이를 호출합니다.
  10. Configure 실패 때 부분 자원을 남긴다. 모든 실패 경로에 Rollback을 둡니다.
  11. Deactivate와 Cleanup을 혼동한다. 동작 중지와 자원 해제를 구분합니다.
  12. Lifecycle을 E-stop으로 사용한다. 독립 Safety 계층을 유지합니다.

39. 실습 체크리스트

  • [ ] 최소 Launch로 Talker와 Listener를 실행했다.
  • [ ] Launch 파일을 Package Share에 설치했다.
  • [ ] Argument와 LaunchConfiguration을 사용했다.
  • [ ] YAML Parameter와 Inline Override를 전달했다.
  • [ ] Namespace 두 개에서 같은 Node 구성을 실행했다.
  • [ ] Topic을 Remap하여 Pipeline을 만들었다.
  • [ ] IfCondition으로 RViz를 선택 실행했다.
  • [ ] 다른 Launch를 Include했다.
  • [ ] Process Start·Exit Event를 관찰했다.
  • [ ] Lifecycle Node를 Configure·Activate했다.
  • [ ] Inactive에서 Lifecycle Publisher 출력이 멈추는지 확인했다.
  • [ ] Timer·Subscription의 별도 관리 필요성을 확인했다.
  • [ ] Configure Failure와 Error를 재현했다.
  • [ ] Cleanup 후 다시 Configure했다.
  • [ ] 실제 Robot 시작·정지 순서와 Rollback을 설계했다.

40. 정리

Launch는 Robot System의 Process, 이름, Parameter, 경로, 조건과 Event 정책을 재현 가능한 구성으로 만듭니다. Substitution은 실행 Context에서 평가되며 Namespace·Remap·Include를 이용하면 같은 Component를 여러 Robot과 환경에서 재사용할 수 있습니다.

하지만 Launch는 Process를 시작할 뿐 Application 준비 완료를 자동 보장하지 않습니다. 준비 순서가 중요한 System에서는 Lifecycle Node가 Unconfigured·Inactive·Active·Finalized 상태와 명시적인 전이 결과를 제공합니다.

Launch와 Lifecycle을 함께 사용하면 Process 시작, 자원 구성, 기능 활성화, 정상 정지와 정리를 분리할 수 있습니다. 여기에 실제 Data Health, Timeout, Rollback, Driver Watchdog와 독립 Safety 계층을 더해야 신뢰할 수 있는 Robot Bringup이 완성됩니다.


ROBOT GLOSSARY

용어 정리

전체 용어 찾아보기 →
Launch런치
여러 Process와 ROS Node의 실행, 인자, 이름, Parameter, 조건과 Event 반응을 구성하고 관리하는 ROS 2 System입니다.
LaunchDescription실행 구성 설명
Launch Service가 방문하고 실행할 Action과 Entity의 구성을 담는 객체입니다.
Launch Action런치 동작
Node 실행, Argument 선언, Group, Include, Event 등록처럼 Launch가 수행할 한 가지 동작입니다.
Launch Argument런치 인자
Launch 실행 시 외부에서 전달하여 System 구성을 바꿀 수 있도록 선언한 값입니다.
LaunchConfiguration런치 구성 치환
Launch Context에서 Argument 값을 평가하는 Substitution 객체입니다.
Substitution치환 객체
Package 경로, Argument와 환경값처럼 Launch 실행 Context에서 문자열로 평가되는 표현입니다.
Remapping이름 재매핑
Node Code를 수정하지 않고 Topic·Service·Action 이름의 실제 연결을 바꾸는 기능입니다.
PushRosNamespaceROS 네임스페이스 적용
Group 안의 ROS Node와 상대 이름에 공통 Namespace를 적용하는 Launch Action입니다.
IncludeLaunchDescription런치 구성 포함
다른 Launch 파일을 Argument와 함께 현재 System 구성에 재사용하는 Action입니다.
Event Handler이벤트 처리기
Process 시작·종료나 Lifecycle 상태 전이 같은 Event를 감지해 추가 Action을 수행합니다.
OpaqueFunction불투명 함수 실행
Launch Context에서 Substitution을 평가하고 Python 함수로 동적인 Action 목록을 생성하는 기능입니다.
Lifecycle Node생명주기 노드
외부 관리자가 Configure·Activate·Deactivate·Cleanup 상태 전이를 요청할 수 있는 Managed ROS 2 Node입니다.
Unconfigured미설정 상태
Lifecycle Node Process는 존재하지만 정상 기능을 위한 자원을 아직 구성하지 않은 Primary State입니다.
Inactive비활성 상태
자원 구성은 완료됐지만 정상 출력을 활성화하지 않은 Lifecycle Primary State입니다.
Active활성 상태
Lifecycle Node가 정상 기능과 Managed Publisher 출력을 수행하도록 허용된 Primary State입니다.
Finalized종료 확정 상태
Lifecycle Node의 정상 상태 전이가 끝나고 종료된 Primary State입니다.
Transition상태 전이
Configure, Activate, Deactivate, Cleanup과 Shutdown처럼 Lifecycle State를 변경하는 요청과 처리 과정입니다.
Lifecycle Publisher생명주기 발행자
Lifecycle Node 상태에 따라 활성화·비활성화되어 Active일 때만 Message를 발행하는 Managed Publisher입니다.
Lifecycle Manager생명주기 관리자
여러 Managed Node의 상태를 확인하고 순서, Timeout, 실패와 Rollback 정책에 따라 전이를 요청하는 Node입니다.
Application Readiness응용 준비 상태
Process와 Node 상태뿐 아니라 실제 Data, Hardware와 품질 조건까지 기능 수행 준비가 완료된 상태입니다.

연습 문제

  1. Launch가 단순 Shell Script보다 재현 가능한 이유를 설명하세요.
  2. XML·YAML·Python Launch의 장단점과 선택 기준을 설명하세요.
  3. generate_launch_description()LaunchDescription의 역할은 무엇인가요?
  4. LaunchConfiguration을 일반 Python if로 비교하면 안 되는 이유는 무엇인가요?
  5. FindPackageShare와 PathJoinSubstitution을 사용해야 하는 이유는 무엇인가요?
  6. Namespace와 Remapping의 차이를 실제 Robot 두 대 예로 설명하세요.
  7. IncludeLaunchDescription과 Launch Argument 전달 과정을 설명하세요.
  8. OnProcessStart가 Node의 Application Ready를 보장하지 않는 이유는 무엇인가요?
  9. TimerAction으로 준비 순서를 만드는 방식이 취약한 이유는 무엇인가요?
  10. Lifecycle의 네 Primary State와 여섯 Transition State를 설명하세요.
  11. on_configure, on_activate, on_deactivate, on_cleanup의 책임을 구분하세요.
  12. Lifecycle Publisher와 일반 Subscription·Timer의 Inactive 동작 차이는 무엇인가요?
  13. Configure 도중 일부 자원을 만든 뒤 실패할 때 필요한 처리는 무엇인가요?
  14. Process Alive, Lifecycle Active와 Application Ready의 차이를 설명하세요.
  15. 실제 이동 Robot을 시작·정지할 때 Launch, Lifecycle Manager와 Safety 계층의 역할을 순서대로 설명하세요.

참고 자료