Devin.KR

launch 파일 - 여러 노드를 한 번에

개발자KR 조회 6

이 장에서 배우는 것

파라미터로 노드 하나의 동작을 코드 수정 없이 바꾸는 방법을 다룬 앞 장까지는, 노드를 띄울 때마다 터미널을 열고 ros2 run 명령과 파라미터 값을 직접 입력했다. 그런데 두리처럼 배터리 모니터와 속도 감시처럼 여러 노드가 함께 동작해야 하는 로봇이라면, 노드 수만큼 터미널을 열고 값을 일일이 맞춰 입력하는 일이 금방 번거로워진다. launch 파일은 어떤 노드를 어떤 이름과 값으로 실행할지를 파이썬 스크립트 하나로 묶어두는 도구다. 이 장에서는 Python으로 launch 파일을 작성하고, 실행 시점에 값을 바꾸는 인자, 이름 충돌을 막는 네임스페이스와 리매핑, 조건에 따라 노드를 켜고 끄는 방법을 다룬다.

  • Python launch 파일로 여러 노드를 한 번에 실행하는 구조를 이해한다.
  • DeclareLaunchArgument와 LaunchConfiguration으로 실행 시점 값을 전달한다.
  • 네임스페이스와 리매핑으로 같은 노드를 여러 대 띄울 때 이름 충돌을 막는다.
  • IfCondition으로 조건에 따라 특정 노드만 켜고 끈다.

문제 상황

두리를 창고 두 구역에 한 대씩 배치하기로 했다고 하자. 로봇 한 대를 띄우려면 배터리 모니터, 속도 감시 두 노드를 각각 별도 터미널에서 ros2 run으로 실행해야 하고, 배터리 부족 기준값도 매번 --ros-args -p로 입력해야 한다. 로봇을 두 대로 늘리자 문제가 겉으로 드러났다. 두 번째 두리도 기본 이름 그대로 노드를 띄우니 배터리 상태 토픽 이름이 겹쳐서, 로봇 2호기의 속도 감시 노드가 1호기의 배터리 값을 구독하는 사고가 났다. 게다가 실내 테스트베드에는 모터가 연결되어 있지 않은데, 속도 감시 노드가 배터리 임계값 아래로 떨어졌다고 판단해 정지 신호를 계속 내보내는 바람에 테스트 자체가 멈춰버렸다. 개발 중에는 이 노드를 꺼두고 싶지만, 실제 운영 중에는 반드시 켜져 있어야 한다. 노드를 손으로 하나씩 실행하고 끄는 방식으로는 이런 요구를 맞추기 어렵다.

launch 파일과 LaunchDescription

launch 파일은 패키지의 launch/ 디렉터리에 두는 평범한 파이썬 파일이다. ros2 launch 명령은 이 파일에서 generate_launch_description() 함수를 찾아 호출하고, 그 함수가 돌려주는 LaunchDescription 객체에 담긴 액션(action)들을 순서대로 실행한다. 가장 많이 쓰는 액션은 노드 하나를 실행하는 launch_ros.actions.Node다. package와 executable로 어떤 실행 파일을 띄울지 지정하고, name으로 노드 이름을 정한다.

주의할 점은 launch 파일도 결국 패키지 설치 결과물의 일부라는 것이다. setup.py의 data_files에 launch/ 디렉터리를 등록해야 colcon build 이후 설치 경로에 복사되고, 그래야 ros2 launch가 파일을 찾을 수 있다. 더 다양한 launch 액션 목록은 ROS 2 Jazzy launch 튜토리얼 문서에 정리되어 있다.

launch 파일 하나가 배터리 모니터와 속도 감시 노드를 함께 실행하고 토픽으로 연결한다

인자·리매핑·네임스페이스로 실행 시점에 값 바꾸기

DeclareLaunchArgument는 launch 파일 바깥, 즉 ros2 launch 패키지 파일.launch.py 인자:=값 형태의 명령줄에서 값을 받을 수 있는 인자를 선언한다. default_value를 지정해두면 값을 생략해도 동작한다. 선언한 인자는 LaunchConfiguration('인자이름')으로 읽는데, 이 객체는 그 자리에서 바로 문자열이 되는 것이 아니라 실제 ros2 launch가 실행되는 시점에 값으로 치환되는 지연 객체(substitution)다. 이 성질을 잊고 파이썬의 if로 곧바로 분기하면 원하는 대로 동작하지 않는데, 이는 뒤에서 다시 다룬다.

노드의 parameters에 LaunchConfiguration 값을 그대로 넘기면 항상 문자열로 전달된다. 노드가 실수형(float) 파라미터를 기본값으로 선언해뒀다면 타입이 맞지 않아 실행이 실패한다. launch_ros.parameter_descriptions.ParameterValue로 value_type을 지정해 원하는 타입으로 바꿔줘야 한다.

remappings는 노드 코드를 건드리지 않고 토픽·서비스 이름을 바꿔주는 목록이다. [('원래이름', '바꿀이름')] 형태의 튜플을 넣으면, 노드 안에서 'battery_state'라는 이름으로 연 토픽이 실제로는 'power_level'이라는 이름으로 나간다. namespace는 노드가 여는 모든 토픽·서비스 이름 앞에 /이름을 붙여준다. 두 대의 두리를 각각 dori_1, dori_2 네임스페이스로 띄우면, 노드 코드는 똑같아도 실제 토픽 경로는 서로 겹치지 않는다.

네임스페이스와 리매핑을 적용하면 두 로봇의 배터리 토픽 경로가 서로 겹치지 않는다
같은 노드 코드가 로봇마다 다른 토픽 경로로 연결되는 과정
robot_name노드 내부 토픽 이름namespace 적용remap 적용 후 최종 경로
dori_1battery_state/dori_1/battery_state/dori_1/power_level
dori_2battery_state/dori_2/battery_state/dori_2/power_level

조건부 실행으로 노드를 켜고 끄기

launch.conditions의 IfCondition과 UnlessCondition은 Node 액션의 condition 인자에 넣어, 실행 시점에 값이 참이면(또는 거짓이면) 그 노드를 시작하는 데 쓴다. 값이 거짓인 조건이 걸린 노드는 프로세스 자체가 만들어지지 않는다. 안전 감시 노드처럼 개발 중에는 끄고 운영 중에는 켜야 하는 노드에 IfCondition(LaunchConfiguration('enable_safety_guard'))를 걸어두면, 인자 하나만 바꿔서 켜고 끌 수 있다.

여기서 흔히 하는 실수가, generate_launch_description() 함수 안에서 파이썬 if로 노드를 리스트에 넣을지 말지 미리 정해버리는 것이다. LaunchConfiguration은 이 시점에 아직 실제 값으로 치환되지 않은 객체이므로, if 조건은 거의 항상 같은 결과로 평가되어 인자를 바꿔도 반영되지 않는다. 조건은 반드시 Node 액션의 condition 인자로 넘겨서, launch가 실제로 실행되는 시점에 평가되게 해야 한다. 숫자 비교처럼 더 복잡한 조건이 필요하면 launch.substitutions.PythonExpression으로 문자열 형태의 표현식을 넘길 수도 있다.

enable_safety_guard 값에 따라 speed_guard 노드가 시작되거나 건너뛰어진다

완성 코드

아래 네 파일로 두리 한 대(또는 여러 대)를 한 번에 실행하는 dori_bringup 패키지를 구성한다. dori_fleet.launch.py가 인자·리매핑·네임스페이스·조건부 실행을 모두 적용해 battery_monitor와 speed_guard 두 노드를 실행하고, 마지막 launch_simulator.py는 ROS 없이 그 흐름만 따로 확인해볼 수 있는 순수 파이썬 스크립트다.

launch/dori_fleet.launch.py

from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.substitutions import LaunchConfiguration
from launch.conditions import IfCondition
from launch_ros.actions import Node
from launch_ros.parameter_descriptions import ParameterValue


def generate_launch_description():
    robot_name_arg = DeclareLaunchArgument(
        'robot_name',
        default_value='dori_1',
        description='로봇 이름(네임스페이스로 사용)',
    )
    threshold_arg = DeclareLaunchArgument(
        'low_battery_threshold',
        default_value='20.0',
        description='배터리 부족 경고 기준값(%)',
    )
    safety_arg = DeclareLaunchArgument(
        'enable_safety_guard',
        default_value='true',
        description='speed_guard 노드 실행 여부',
    )

    robot_name = LaunchConfiguration('robot_name')
    threshold = ParameterValue(
        LaunchConfiguration('low_battery_threshold'), value_type=float)
    enable_safety_guard = LaunchConfiguration('enable_safety_guard')

    battery_monitor_node = Node(
        package='dori_bringup',
        executable='battery_monitor',
        name='battery_monitor',
        namespace=robot_name,
        parameters=[{'low_battery_threshold': threshold}],
        remappings=[('battery_state', 'power_level')],
    )

    speed_guard_node = Node(
        package='dori_bringup',
        executable='speed_guard',
        name='speed_guard',
        namespace=robot_name,
        parameters=[{'low_battery_threshold': threshold}],
        remappings=[('battery_state', 'power_level')],
        condition=IfCondition(enable_safety_guard),
    )

    return LaunchDescription([
        robot_name_arg,
        threshold_arg,
        safety_arg,
        battery_monitor_node,
        speed_guard_node,
    ])

dori_bringup/battery_monitor.py

import rclpy
from rclpy.node import Node
from std_msgs.msg import Float32


class BatteryMonitor(Node):

    def __init__(self):
        super().__init__('battery_monitor')
        self.declare_parameter('low_battery_threshold', 20.0)
        self._level = 100.0
        self._publisher = self.create_publisher(Float32, 'battery_state', 10)
        self._timer = self.create_timer(1.0, self._on_timer)

    def _on_timer(self):
        threshold = self.get_parameter('low_battery_threshold').value
        self._level = max(0.0, self._level - 2.5)
        msg = Float32()
        msg.data = self._level
        self._publisher.publish(msg)
        if self._level <= threshold:
            self.get_logger().warn(
                f'배터리 {self._level:.1f}% - 기준값 {threshold:.1f}% 이하')
        else:
            self.get_logger().info(f'배터리 {self._level:.1f}%')


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


if __name__ == '__main__':
    main()

dori_bringup/speed_guard.py

import rclpy
from rclpy.node import Node
from std_msgs.msg import Float32


class SpeedGuard(Node):

    def __init__(self):
        super().__init__('speed_guard')
        self.declare_parameter('low_battery_threshold', 20.0)
        self._subscription = self.create_subscription(
            Float32, 'battery_state', self._on_battery_state, 10)

    def _on_battery_state(self, msg: Float32):
        threshold = self.get_parameter('low_battery_threshold').value
        if msg.data <= threshold:
            self.get_logger().warn(
                f'속도 제한 모드 - 배터리 {msg.data:.1f}%')


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


if __name__ == '__main__':
    main()

launch_simulator.py

"""launch 파일의 인자 해석 · 네임스페이스 · 리매핑 · 조건부 실행 흐름을
ROS 없이 확인하는 보조 스크립트."""

from dataclasses import dataclass


@dataclass
class NodeSpec:
    name: str
    default_topic: str
    remap_to: str
    conditional: bool = False


def resolve_topic(namespace, remapped_name):
    return f'/{namespace}/{remapped_name}'


def build_fleet(robot_name, low_battery_threshold, enable_safety_guard):
    nodes = [
        NodeSpec('battery_monitor', 'battery_state', 'power_level'),
        NodeSpec('speed_guard', 'battery_state', 'power_level', conditional=True),
    ]
    started = []
    for spec in nodes:
        if spec.conditional and not enable_safety_guard:
            print(f'[건너뜀] {robot_name}/{spec.name} '
                  f'(enable_safety_guard=false)')
            continue
        topic = resolve_topic(robot_name, spec.remap_to)
        started.append((spec.name, topic))
        print(f'[시작] {robot_name}/{spec.name} -> {topic} '
              f'(low_battery_threshold={low_battery_threshold})')
    return started


def main():
    fleet_args = [
        {'robot_name': 'dori_1', 'low_battery_threshold': 20.0,
         'enable_safety_guard': True},
        {'robot_name': 'dori_2', 'low_battery_threshold': 30.0,
         'enable_safety_guard': False},
    ]
    for args in fleet_args:
        print(f'--- {args["robot_name"]} launch 인자 해석 ---')
        build_fleet(**args)
        print()


if __name__ == '__main__':
    main()

줄별 해설

  • DeclareLaunchArgument('robot_name', default_value='dori_1', ...) 세 줄은 명령줄에서 받을 수 있는 인자 세 개를 선언한다. 값을 생략하면 각각 dori_1, 20.0, true가 쓰인다.
  • threshold = ParameterValue(LaunchConfiguration('low_battery_threshold'), value_type=float)는 명령줄에서 문자열로 들어온 값을 실수형으로 바꿔, 노드가 declare_parameter('low_battery_threshold', 20.0)로 선언해둔 실수형 파라미터와 타입을 맞춘다.
  • battery_monitor_node와 speed_guard_node 모두 namespace=robot_name을 지정해, 실행할 때 넘긴 robot_name 값이 두 노드의 모든 토픽 앞에 그대로 붙는다.
  • remappings=[('battery_state', 'power_level')]은 두 노드 코드 안의 'battery_state'라는 내부 이름을 실제로는 'power_level'이라는 이름으로 내보내거나 구독하게 만든다. 두 노드에 같은 remap을 걸어야 서로 연결된다.
  • condition=IfCondition(enable_safety_guard)는 speed_guard_node에만 걸려 있다. enable_safety_guard 값이 false로 들어오면 이 노드는 launch 단계에서 아예 프로세스로 만들어지지 않는다.
  • battery_monitor.py의 _on_timer는 1초마다 배터리 값을 2.5씩 줄이며 발행하고, 값이 파라미터로 받은 기준값 이하가 되면 warn 로그를 남긴다.
  • speed_guard.py는 battery_state 토픽을 구독만 하고, 값이 기준값 이하일 때만 경고 로그를 남긴다. 이 노드가 꺼져 있어도 battery_monitor는 영향받지 않는다.
  • launch_simulator.py의 build_fleet는 conditional이 참인데 enable_safety_guard가 거짓이면 continue로 건너뛰어, 실제 launch 파일의 IfCondition 동작을 흉내 낸다.

실행 결과

기본 인자로 두리 1호기를 띄우면 다음과 같이 두 노드가 모두 시작된다.

$ ros2 launch dori_bringup dori_fleet.launch.py
[INFO] [launch]: All log files can be found below /home/dev/.ros/log/2026-09-29-10-00-00-051190
[INFO] [launch]: Default logging verbosity is set to INFO
[INFO] [battery_monitor-1]: process started with pid [51201]
[INFO] [speed_guard-2]: process started with pid [51202]
[battery_monitor-1] [INFO] [1759100400.100000000] [dori_1.battery_monitor]: 배터리 97.5%
[battery_monitor-1] [INFO] [1759100401.100000000] [dori_1.battery_monitor]: 배터리 95.0%

약 32초 뒤 배터리가 기준값 20.0% 이하로 내려가면 두 노드 모두 경고를 출력한다.

[battery_monitor-1] [WARN] [1759100432.100000000] [dori_1.battery_monitor]: 배터리 20.0% - 기준값 20.0% 이하
[speed_guard-2] [WARN] [1759100432.150000000] [dori_1.speed_guard]: 속도 제한 모드 - 배터리 20.0%

다른 창에서 노드 목록을 확인하면 네임스페이스가 적용된 이름이 보인다.

$ ros2 node list
/dori_1/battery_monitor
/dori_1/speed_guard

2호기를 안전 가드 없이 띄우면 speed_guard 프로세스 자체가 시작되지 않는다.

$ ros2 launch dori_bringup dori_fleet.launch.py \
    robot_name:=dori_2 low_battery_threshold:=30.0 enable_safety_guard:=false
[INFO] [launch]: All log files can be found below /home/dev/.ros/log/2026-09-29-10-05-00-051340
[INFO] [launch]: Default logging verbosity is set to INFO
[INFO] [battery_monitor-1]: process started with pid [51340]
[battery_monitor-1] [INFO] [1759100700.100000000] [dori_2.battery_monitor]: 배터리 97.5%

ROS 없이 흐름만 확인하려면 순수 파이썬 스크립트를 직접 실행한다.

$ python3 launch_simulator.py
--- dori_1 launch 인자 해석 ---
[시작] dori_1/battery_monitor -> /dori_1/power_level (low_battery_threshold=20.0)
[시작] dori_1/speed_guard -> /dori_1/power_level (low_battery_threshold=20.0)

--- dori_2 launch 인자 해석 ---
[시작] dori_2/battery_monitor -> /dori_2/power_level (low_battery_threshold=30.0)
[건너뜀] dori_2/speed_guard (enable_safety_guard=false)

실무에서 자주 틀리는 것

LaunchConfiguration 값을 파라미터에 그대로 넘긴다

문자열이 그대로 전달되어, 노드가 실수형으로 선언한 파라미터와 타입이 맞지 않는다.

# 틀린 코드
parameters=[{'low_battery_threshold': LaunchConfiguration('low_battery_threshold')}],
# 고친 코드
threshold = ParameterValue(
    LaunchConfiguration('low_battery_threshold'), value_type=float)
parameters=[{'low_battery_threshold': threshold}],

remap 이름에 네임스페이스를 직접 적어둔다

robot_name을 인자로 받는 의미가 사라지고, namespace가 적용되면 경로가 중복되거나 어긋난다.

# 틀린 코드
remappings=[('/dori_1/battery_state', '/dori_1/power_level')],
# 고친 코드
remappings=[('battery_state', 'power_level')],
namespace=robot_name,

조건 분기를 파이썬 if로 처리한다

LaunchConfiguration은 description을 만드는 시점에는 아직 실제 값으로 치환되지 않으므로, if가 항상 같은 쪽으로 평가되어 인자를 바꿔도 반영되지 않는다.

# 틀린 코드
def generate_launch_description():
    enable_safety_guard = LaunchConfiguration('enable_safety_guard')
    action_list = [battery_monitor_node]
    if enable_safety_guard:
        action_list.append(speed_guard_node)
    return LaunchDescription([robot_name_arg, threshold_arg, safety_arg] + action_list)
# 고친 코드
speed_guard_node = Node(
    package='dori_bringup',
    executable='speed_guard',
    name='speed_guard',
    namespace=robot_name,
    condition=IfCondition(enable_safety_guard),
)

launch 디렉터리를 setup.py에 등록하지 않는다

colcon build 결과물에 launch 파일이 복사되지 않아 ros2 launch가 파일을 찾지 못한다.

# 틀린 코드 - launch 디렉터리를 data_files에 넣지 않음
data_files=[
    ('share/ament_index/resource_index/packages',
     ['resource/' + package_name]),
    ('share/' + package_name, ['package.xml']),
],
# 고친 코드
import os
from glob import glob

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')),
],

한눈에 보기

이 장에서 쓴 launch 구성요소
개념구문이 장에서 쓴 예
실행 목록LaunchDescription([...])인자 3개 + 노드 2개
인자 선언DeclareLaunchArgumentrobot_name, low_battery_threshold, enable_safety_guard
인자 읽기LaunchConfigurationnamespace·parameters·condition에 전달
타입 변환ParameterValue(value_type=...)문자열 → float
이름 충돌 방지namespace, remappings/dori_1, /dori_2 별도 경로
조건부 실행IfConditionspeed_guard 노드 on/off

연습 문제

  1. 두리를 세 번째 창고 구역에 배치하려 한다. dori_fleet.launch.py 파일을 수정하지 않고 robot_name이 dori_3인 로봇을 띄우려면 어떤 명령을 실행해야 하는가?
  2. 다음과 같이 condition 대신 파이썬 비교를 쓰면 어떤 문제가 생기는지 설명하라.
    if enable_safety_guard == 'true':
        action_list.append(speed_guard_node)
  3. battery_monitor와 speed_guard 두 노드에 똑같이 remappings=[('battery_state', 'power_level')]을 건 이유는 무엇인가? speed_guard 쪽 remap을 빠뜨리면 어떤 문제가 생기는가?
  4. launch_simulator.py의 build_fleet에, low_battery_threshold가 0 이하이면 enable_safety_guard 값과 무관하게 speed_guard를 항상 건너뛰도록 조건을 하나 더 추가하려 한다. 어떻게 고치면 되는지 코드로 답하라.

정답과 해설

  1. ros2 launch dori_bringup dori_fleet.launch.py robot_name:=dori_3를 실행하면 된다. low_battery_threshold와 enable_safety_guard는 생략하면 기본값(20.0, true)이 쓰인다. DeclareLaunchArgument로 값을 외부에서 받도록 해두었기 때문에 launch 파일 자체는 그대로 두고 명령줄 인자만 바꿔서 재사용할 수 있다.
  2. LaunchConfiguration은 description을 만드는 시점에는 실제 문자열이 아니라 지연 치환 객체이므로, == 'true' 비교는 명령줄에서 어떤 값을 넘겨도 항상 같은 결과로 평가된다. 결과적으로 speed_guard는 enable_safety_guard:=false를 줘도, true를 줘도 똑같이(항상 추가되거나 항상 빠지거나) 동작해 인자가 실행에 반영되지 않는다. 조건은 Node의 condition=IfCondition(...)으로 넘겨 launch 실행 시점에 평가되게 해야 한다.
  3. 두 노드 모두 소스 코드 안에서는 'battery_state'라는 같은 내부 이름으로 토픽을 열기 때문에, 같은 remap을 적용해야 둘 다 동일한 실제 토픽 /dori_1/power_level을 가리킨다. speed_guard 쪽 remap을 빠뜨리면 그 노드는 여전히 /dori_1/battery_state를 구독하는데, battery_monitor는 /dori_1/power_level로 발행하므로 두 토픽이 서로 연결되지 않아 speed_guard에는 아무 값도 들어오지 않는다.
  4. 다음과 같이 조건을 하나 더 넣으면 된다.
    if spec.conditional and (
            not enable_safety_guard or low_battery_threshold <= 0):
        print(f'[건너뜀] {robot_name}/{spec.name} '
              f'(enable_safety_guard={enable_safety_guard}, '
              f'threshold={low_battery_threshold})')
        continue
    or로 두 조건을 묶어, enable_safety_guard가 참이더라도 low_battery_threshold가 0 이하면 여전히 건너뛰게 만든다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.