Skip to main content
Summary

열 개의 터미널을 여는 로봇은 오래가지 못한다

처음 ROS2를 배울 때는 터미널을 하나 열고 노드를 실행한다. 다른 터미널을 열어 토픽을 확인하고, 또 다른 터미널에서 서비스를 호출하고, 액션 목표를 보내 본다. 학습 단계에서는 이 방식이 좋다. 각각의 노드가 무엇을 하는지 눈으로 확인할 수 있고, 문제가 생겼을 때 어느 명령에서 막혔는지 알기 쉽기 때문이다.

하지만 로봇 시스템이 조금만 커져도 상황이 달라진다. LiDAR 드라이버를 켜고, 카메라 노드를 켜고, 바퀴 제어 노드를 켜고, 로봇 모델을 발행하고, RViz를 띄우고, SLAM이나 Nav2까지 실행해야 한다. 터미널은 어느새 책상 위 메모지처럼 늘어난다. 더 큰 문제는 순서와 설정이다. 어제는 잘 움직이던 로봇이 오늘은 움직이지 않는다. 알고 보니 한 터미널에서 use_sim_time을 빠뜨렸거나, /cmd_vel 대신 /robot1/cmd_vel로 remapping해야 하는데 잊어버린 것이다.

이 장에서 배울 파라미터와 Launch는 바로 이 혼란을 정리하는 도구다. 파라미터는 코드 안에 박혀 있던 설정값을 밖으로 꺼내는 방법이고, Launch는 여러 노드를 정해진 설정으로 한 번에 실행하는 방법이다. ROS2의 launch 시스템은 여러 프로세스와 노드를 함께 시작하고, 실행에 필요한 설정을 전달하고, 더 큰 시스템을 구성하기 위한 구조로 사용된다.1

중요한 관점은 단순하다. 파라미터와 Launch를 제대로 쓰는 순간, 우리는 "노드를 몇 개 실행해 본 실험"에서 "다시 켤 수 있는 로봇 시스템"으로 넘어간다.

파라미터와 Launch로 실행 구조 만들기

하드코딩은 처음에는 빠르고, 나중에는 무겁다

다음과 같은 속도 제한 노드를 생각해 보자. 이 노드는 입력으로 들어온 속도 명령을 받아 너무 빠른 명령을 잘라 낸 뒤 실제 로봇 구동 토픽으로 다시 보낸다.

self.max_linear_speed = 0.3
self.max_angular_speed = 1.0
self.base_frame = 'base_link'
self.input_topic = '/nav_cmd_vel'
self.output_topic = '/cmd_vel'

처음에는 아무 문제가 없어 보인다. 최대 선속도는 초속 0.3m, 최대 각속도는 초당 1.0rad, 기준 프레임은 base_link, 입력 토픽은 /nav_cmd_vel, 출력 토픽은 /cmd_vel이다. 그런데 실제 로봇을 바꾸는 순간 문제가 시작된다.

작은 TurtleBot에서는 초속 0.3m가 적당했지만, 더 무거운 로봇에서는 초속 0.2m로 낮춰야 할 수 있다. 시뮬레이션에서는 /cmd_vel로 바로 보내도 되지만, 실제 로봇에서는 안전 정지 노드를 거쳐 /safe_cmd_vel로 보내야 할 수도 있다. 다중 로봇 환경에서는 base_link가 아니라 robot1/base_link처럼 namespace가 붙을 수도 있다.

이때마다 코드를 열어 숫자와 문자열을 고치고 다시 빌드한다면, 코드는 점점 실험 노트처럼 변한다. "이 값은 지난번 실험용인가?", "실제 로봇용인가?", "SLAM할 때 쓰던 값인가?", "Nav2용인가?" 같은 질문이 계속 생긴다.

그래서 설정값은 코드에서 분리해야 한다. 코드에는 로봇이 어떻게 동작해야 하는지의 논리를 두고, 바뀔 수 있는 값은 파라미터로 빼 둔다. 속도 제한, 로봇 치수, 토픽 이름, 프레임 이름, 센서 주기, 필터 임계값처럼 환경과 실험에 따라 바뀌는 값은 파라미터 후보라고 보면 된다.

파라미터는 노드의 조절 손잡이다

ROS2 파라미터는 노드가 가진 설정값이다. 노드는 시작할 때 파라미터를 선언하고, 그 값을 읽어 자신의 동작에 반영한다. 중요한 점은 파라미터가 노드에 속한다는 것이다. 전역 어딘가에 모든 값이 둥둥 떠 있는 것이 아니라, "이 노드의 max_linear_speed는 0.3이다"처럼 특정 노드의 설정으로 관리된다.

간단한 속도 제한 노드를 파라미터 방식으로 바꿔 보자.

import rclpy
from rclpy.node import Node
from geometry_msgs.msg import Twist


class SafetyLimiter(Node):
    def __init__(self):
        super().__init__('safety_limiter')

        self.declare_parameter('max_linear_speed', 0.3)
        self.declare_parameter('max_angular_speed', 1.0)
        self.declare_parameter('input_topic', 'nav_cmd_vel')
        self.declare_parameter('output_topic', 'cmd_vel')

        self.max_linear_speed = self.get_parameter('max_linear_speed').value
        self.max_angular_speed = self.get_parameter('max_angular_speed').value
        input_topic = self.get_parameter('input_topic').value
        output_topic = self.get_parameter('output_topic').value

        self.publisher = self.create_publisher(Twist, output_topic, 10)
        self.subscription = self.create_subscription(
            Twist,
            input_topic,
            self.on_cmd_vel,
            10,
        )

        self.get_logger().info(
            f'safety limiter started: linear={self.max_linear_speed}, '
            f'angular={self.max_angular_speed}'
        )

    def on_cmd_vel(self, msg):
        limited = Twist()
        limited.linear.x = max(
            -self.max_linear_speed,
            min(self.max_linear_speed, msg.linear.x),
        )
        limited.angular.z = max(
            -self.max_angular_speed,
            min(self.max_angular_speed, msg.angular.z),
        )
        self.publisher.publish(limited)


def main():
    rclpy.init()
    node = SafetyLimiter()
    rclpy.spin(node)
    node.destroy_node()
    rclpy.shutdown()


if __name__ == '__main__':
    main()

이 코드에서 핵심은 declare_parameter()와 get_parameter()다. declare_parameter()는 "이 노드는 이런 설정값을 사용한다"고 선언한다. 두 번째 인자로 기본값을 넣어 두면, 외부에서 값을 주지 않았을 때 그 값이 사용된다. get_parameter().value는 실제 값을 읽는다.

이제 속도를 바꾸기 위해 코드를 수정할 필요가 없다. 실행할 때 값을 넘기면 된다.

ros2 run my_robot_controller safety_limiter --ros-args \
  -p max_linear_speed:=0.15 \
  -p max_angular_speed:=0.5

이 명령은 같은 노드를 더 조심스러운 설정으로 실행한다. 실내에서 처음 테스트할 때는 속도를 낮추고, 넓은 환경에서 충분히 검증한 뒤 조금씩 올릴 수 있다. 코드는 그대로 두고, 설정만 바뀐다.

파라미터를 확인하고 바꾸는 명령어

실행 중인 노드의 파라미터는 ros2 param 명령으로 확인할 수 있다. 4장에서 명령어를 배울 때 ros2 param을 살짝 지나갔다면, 이제는 그 의미가 더 선명해질 것이다.

ros2 param list

이 명령은 현재 실행 중인 노드들이 가진 파라미터를 보여 준다. 특정 노드의 특정 파라미터 값을 보고 싶다면 다음처럼 입력한다.

ros2 param get /safety_limiter max_linear_speed

값을 바꿀 수도 있다.

ros2 param set /safety_limiter max_linear_speed 0.2

다만 여기서 조심할 점이 있다. ros2 param set으로 값을 바꿨다고 해서 모든 노드가 즉시 그 값을 동작에 반영하는 것은 아니다. 노드가 시작할 때 한 번만 값을 읽어 내부 변수에 저장했다면, 실행 중 변경은 적용되지 않을 수 있다. 실행 중 파라미터 변경에 반응하려면 파라미터 변경 콜백을 따로 구현해야 한다.

초보 단계에서는 이렇게 기억하면 충분하다. "시작할 때 바꿀 값"은 파라미터 파일이나 launch에서 전달하고, "실행 중에도 바꿀 값"은 별도의 콜백을 설계해야 한다.

파라미터 이름은 작게, 의미는 분명하게

파라미터는 편리하지만 아무 이름이나 붙이면 금방 혼란스러워진다. speed, value, mode처럼 너무 넓은 이름은 프로젝트가 커질수록 의미가 흐려진다. 반대로 max_linear_speed, laser_frame, wheel_radius, controller_frequency처럼 역할이 드러나는 이름은 YAML 파일만 읽어도 노드의 성격이 보인다.

파라미터로 빼기 좋은 값에는 공통적인 특징이 있다.

  • 로봇이나 센서가 바뀌면 함께 바뀌는 값

  • 실험 조건에 따라 자주 조정하는 값

  • 시뮬레이션과 실제 로봇에서 서로 달라지는 값

  • 코드의 알고리즘보다 운영 환경에 가까운 값

예를 들어 바퀴 반지름, 바퀴 사이 거리, LiDAR 프레임 이름, 카메라 해상도, 속도 제한, 장애물 감지 거리, 지도 파일 경로는 파라미터로 관리하기 좋다. 반면 "속도 명령을 제한한다"는 로직 자체는 코드에 남아 있어야 한다. 파라미터는 로직을 대체하는 것이 아니라, 로직이 사용할 조절 손잡이를 제공한다.

YAML 파일로 설정을 모으기

명령줄에서 -p 옵션으로 파라미터를 하나씩 넘기는 방식은 간단한 실험에는 좋다. 하지만 설정이 20개, 50개, 100개로 늘어나면 명령줄은 읽기 어려운 암호문이 된다. SLAM이나 Nav2처럼 설정 항목이 많은 패키지는 대부분 YAML 파일로 파라미터를 관리한다. 파라미터와 launch 파일을 함께 사용하면 실행 시점에 노드 설정을 바꾸기 쉽고, 같은 노드를 여러 환경에 맞게 재사용할 수 있다.2

보통 패키지 안에 config 디렉터리를 만들고, 그 안에 YAML 파일을 둔다.

my_robot_bringup/
  launch/
    robot.launch.py
  config/
    robot_sim.yaml
    robot_real.yaml

파라미터 파일은 노드 이름 아래에 ros__parameters를 두는 구조를 사용한다.

safety_limiter:
  ros__parameters:
    max_linear_speed: 0.3
    max_angular_speed: 1.0
    input_topic: nav_cmd_vel
    output_topic: cmd_vel
    use_sim_time: true

여기서 safety_limiter는 노드 이름이다. 코드에서 Node('safety_limiter')로 만든 이름과 YAML의 이름이 맞아야 한다. 이름이 맞지 않으면 YAML 파일을 넘겨도 파라미터가 적용되지 않는다. 이 실수는 정말 흔하다. 파라미터 파일을 잘 작성했는데 값이 바뀌지 않는다면, 첫 번째로 노드 이름을 확인하자.

ros__parameters에서 밑줄은 두 개다. ros_parameters도 아니고, ros-parameters도 아니다. YAML은 들여쓰기에도 민감하다. 탭 대신 공백을 쓰고, 같은 깊이의 항목은 같은 칸에 맞추자. 로봇이 움직이지 않을 때 원인이 알고리즘이 아니라 YAML 들여쓰기였다는 일은 생각보다 자주 일어난다.

시뮬레이션용 설정과 실제 로봇용 설정

파라미터 파일의 장점은 같은 코드에 서로 다른 설정을 입힐 수 있다는 점이다. 예를 들어 시뮬레이션에서는 조금 빠르게 움직여도 안전하다. 센서 토픽도 Gazebo나 다른 시뮬레이터가 만들어 주는 토픽을 사용한다. 반면 실제 로봇에서는 속도를 낮추고, 실제 드라이버가 발행하는 토픽을 구독해야 한다.

safety_limiter:
  ros__parameters:
    max_linear_speed: 0.5
    max_angular_speed: 1.5
    input_topic: nav_cmd_vel
    output_topic: cmd_vel
    use_sim_time: true
safety_limiter:
  ros__parameters:
    max_linear_speed: 0.2
    max_angular_speed: 0.7
    input_topic: nav_cmd_vel
    output_topic: safe_cmd_vel
    use_sim_time: false

두 파일의 차이는 작아 보이지만 운영 관점에서는 크다. 시뮬레이션용 설정을 실제 로봇에 잘못 적용하면 로봇이 예상보다 빠르게 움직일 수 있다. 반대로 실제 로봇용 설정을 시뮬레이션에 적용하면 /clock을 쓰지 않아 시간 동기화가 어긋날 수 있다.

use_sim_time은 특히 중요하다. 시뮬레이션에서는 실제 벽시계 시간이 아니라 시뮬레이터가 발행하는 /clock 시간을 사용한다. 그래야 센서 데이터, TF, 지도 작성, Navigation이 같은 시간 기준으로 움직인다. 실제 로봇에서는 일반적으로 use_sim_time을 false로 둔다.

Launch 파일은 로봇의 전원 시퀀스다

파라미터가 노드의 조절 손잡이라면, Launch 파일은 로봇의 전원 시퀀스다. 어떤 노드를 어떤 이름으로 실행할지, 어떤 파라미터 파일을 줄지, 어떤 토픽 이름을 바꿀지, 로그를 화면에 보여 줄지, namespace를 붙일지를 한곳에 적는다.

Python launch 파일은 보통 launch 디렉터리에 둔다.

my_robot_bringup/
  launch/
    safety.launch.py
  config/
    safety.yaml

가장 기본적인 launch 파일은 다음과 같다.

import os

from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch_ros.actions import Node


def generate_launch_description():
    bringup_dir = get_package_share_directory('my_robot_bringup')
    params_file = os.path.join(bringup_dir, 'config', 'safety.yaml')

    safety_limiter_node = Node(
        package='my_robot_controller',
        executable='safety_limiter',
        name='safety_limiter',
        output='screen',
        parameters=[params_file],
        remappings=[
            ('nav_cmd_vel', '/nav_cmd_vel'),
            ('cmd_vel', '/cmd_vel'),
        ],
    )

    return LaunchDescription([
        safety_limiter_node,
    ])

ROS2 launch 파일의 중심에는 generate_launch_description() 함수가 있다. 이 함수는 실행할 것들의 목록을 담은 LaunchDescription을 반환한다. 여기서는 Node 액션 하나만 넣었지만, 실제 로봇에서는 여러 노드를 함께 넣는다. Python launch 파일은 노드 실행, 파라미터, remapping 등을 코드 형태로 구성할 수 있어 복잡한 로봇 실행 구조를 표현하는 데 자주 쓰인다.3

이제 터미널에서 다음처럼 실행한다.

ros2 launch my_robot_bringup safety.launch.py

이 명령 하나로 safety_limiter 노드가 정해진 파라미터와 remapping을 가지고 시작된다. 더 이상 긴 ros2 run ... --ros-args ... 명령을 매번 다시 입력하지 않아도 된다.

Node 액션의 주요 옵션

Launch 파일에서 Node는 ROS2 노드를 실행하는 액션이다. 자주 쓰는 옵션은 몇 가지로 정리할 수 있다.

  • package: 실행 파일이 들어 있는 패키지 이름

  • executable: 실행할 노드 실행 파일 이름

  • name: 실행될 노드 이름

  • namespace: 노드와 토픽에 붙일 namespace

  • parameters: 노드에 전달할 파라미터 파일이나 딕셔너리

  • remappings: 토픽, 서비스, 액션 이름을 다른 이름으로 연결하는 규칙

  • output: 로그를 어디로 출력할지 지정하는 옵션

이 중 parameters와 remappings는 실제 로봇 프로젝트에서 특히 자주 사용된다. 코드 안에서는 상대 토픽 이름을 사용하고, launch 파일에서 실제 연결을 결정하면 노드 재사용성이 좋아진다.

예를 들어 속도 제한 노드 코드에서는 cmd_vel이라는 상대 이름만 사용한다. 시뮬레이션에서는 이것을 /cmd_vel로 연결하고, 실제 로봇에서는 /base_controller/cmd_vel_unstamped 같은 이름으로 연결할 수 있다. 노드는 "속도 명령을 제한한다"는 자기 역할만 알고, 전체 시스템에서 어디에 연결되는지는 launch가 결정한다.

이 분리가 중요하다. 좋은 ROS2 노드는 고립된 부품처럼 독립적이고, 좋은 launch 파일은 그 부품들을 로봇이라는 기계 안에 끼워 넣는 조립도처럼 동작한다.

실행할 때 값을 바꾸는 Launch Argument

매번 launch 파일을 수정해야 한다면 그것도 또 다른 하드코딩이다. 예를 들어 시뮬레이션에서는 use_sim_time:=true, 실제 로봇에서는 use_sim_time:=false로 실행하고 싶다. 로봇 이름도 robot1, robot2처럼 실행할 때 바꾸고 싶다. 이때 사용하는 것이 DeclareLaunchArgument와 LaunchConfiguration이다.

import os

from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node


def generate_launch_description():
    bringup_dir = get_package_share_directory('my_robot_bringup')
    default_params_file = os.path.join(bringup_dir, 'config', 'robot_sim.yaml')

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

    return LaunchDescription([
        DeclareLaunchArgument(
            'use_sim_time',
            default_value='true',
            description='Use simulation clock if true',
        ),
        DeclareLaunchArgument(
            'robot_name',
            default_value='robot1',
            description='Robot namespace',
        ),
        DeclareLaunchArgument(
            'params_file',
            default_value=default_params_file,
            description='Full path to the parameter file',
        ),

        Node(
            package='my_robot_controller',
            executable='safety_limiter',
            name='safety_limiter',
            namespace=robot_name,
            output='screen',
            parameters=[
                params_file,
                {'use_sim_time': use_sim_time},
            ],
            remappings=[
                ('nav_cmd_vel', 'nav_cmd_vel'),
                ('cmd_vel', 'cmd_vel'),
            ],
        ),
    ])

이제 실행할 때 값을 바꿀 수 있다.

ros2 launch my_robot_bringup robot.launch.py \
  use_sim_time:=false \
  robot_name:=realbot \
  params_file:=/home/robot/config/robot_real.yaml

여기서 DeclareLaunchArgument는 사용자가 넘길 수 있는 인자를 선언한다. LaunchConfiguration은 그 값을 launch 파일 안에서 가져와 쓰는 표현이다. launch 파일을 하나 만들어 두고 실행 시점에 값을 바꾸면, 시뮬레이션용 실행과 실제 로봇용 실행을 같은 구조로 관리할 수 있다.

주의할 점도 있다. LaunchConfiguration('use_sim_time')은 Python의 일반 문자열 값이 아니다. launch가 실행되는 과정에서 해석되는 값이다. 그래서 launch 파일 안에서 if use_sim_time == 'true'처럼 일반 Python 조건문으로 비교하려 하면 기대와 다르게 동작할 수 있다. 조건부 실행이 필요할 때는 IfCondition 같은 launch 전용 조건 기능을 사용한다.

namespace는 로봇에게 성을 붙이는 일이다

namespace는 이름 앞에 붙는 공간이다. robot1 namespace 아래에서 실행되는 safety_limiter 노드는 /robot1/safety_limiter가 된다. 상대 토픽 cmd_vel은 /robot1/cmd_vel이 된다.

이 기능은 다중 로봇 환경에서 특히 강력하다. 같은 launch 파일을 두 번 실행하되 robot_name만 다르게 주면, 같은 노드 구성을 가진 두 로봇을 서로 다른 이름 공간에서 실행할 수 있다.

ros2 launch my_robot_bringup robot.launch.py robot_name:=robot1
ros2 launch my_robot_bringup robot.launch.py robot_name:=robot2

물론 모든 문제가 자동으로 해결되는 것은 아니다. TF 프레임 이름, 지도 프레임, 센서 프레임, 토픽 remapping까지 전체적으로 일관되게 설계해야 한다. 그래도 namespace를 염두에 두고 노드를 작성하면 처음부터 확장 가능한 구조가 된다.

초보자가 자주 하는 실수는 코드 안에 절대 토픽 이름을 박아 넣는 것이다.

self.create_publisher(Twist, '/cmd_vel', 10)

이렇게 앞에 /가 붙은 절대 이름을 사용하면 namespace의 영향을 받지 않는다. robot1 namespace에서 실행해도 토픽은 여전히 /cmd_vel이다. 한 대의 로봇만 다룰 때는 문제가 없어 보이지만, 나중에 다중 로봇이나 서브시스템 분리를 할 때 발목을 잡는다.

가능하면 노드 내부에서는 cmd_vel, scan, odom처럼 상대 이름을 사용하고, 실제 연결은 launch 파일의 namespace와 remapping으로 조정하자.

IncludeLaunchDescription으로 큰 시스템 조립하기

작은 예제에서는 하나의 launch 파일에 모든 노드를 넣어도 괜찮다. 하지만 자율주행 로봇은 금방 커진다. 센서 드라이버, 로봇 모델, 상태 발행, 제어기, RViz, SLAM, Nav2가 모두 한 파일에 뒤섞이면 launch 파일도 새로운 종류의 스파게티 코드가 된다.

큰 프로젝트에서는 launch 파일을 역할별로 나누고, 상위 launch 파일에서 아래 launch 파일들을 포함하는 방식이 좋다. ROS2 문서에서도 큰 프로젝트를 관리할 때 launch 파일을 구조적으로 나누어 사용하는 방식을 다룬다.4

예를 들어 다음과 같이 나눌 수 있다.

my_robot_bringup/
  launch/
    bringup.launch.py
    sensors.launch.py
    description.launch.py
    navigation.launch.py
    rviz.launch.py
  config/
    robot_sim.yaml
    robot_real.yaml
    nav2.yaml
    rviz_config.rviz

상위 launch 파일은 각 구성요소를 조립한다.

import os

from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument, IncludeLaunchDescription
from launch.launch_description_sources import PythonLaunchDescriptionSource
from launch.substitutions import LaunchConfiguration


def include_launch(package_name, launch_file_name):
    package_dir = get_package_share_directory(package_name)
    return PythonLaunchDescriptionSource(
        os.path.join(package_dir, 'launch', launch_file_name)
    )


def generate_launch_description():
    use_sim_time = LaunchConfiguration('use_sim_time')
    robot_name = LaunchConfiguration('robot_name')

    return LaunchDescription([
        DeclareLaunchArgument('use_sim_time', default_value='true'),
        DeclareLaunchArgument('robot_name', default_value='robot1'),

        IncludeLaunchDescription(
            include_launch('my_robot_bringup', 'description.launch.py'),
            launch_arguments={
                'use_sim_time': use_sim_time,
                'robot_name': robot_name,
            }.items(),
        ),

        IncludeLaunchDescription(
            include_launch('my_robot_bringup', 'sensors.launch.py'),
            launch_arguments={
                'use_sim_time': use_sim_time,
                'robot_name': robot_name,
            }.items(),
        ),

        IncludeLaunchDescription(
            include_launch('my_robot_bringup', 'navigation.launch.py'),
            launch_arguments={
                'use_sim_time': use_sim_time,
                'robot_name': robot_name,
            }.items(),
        ),
    ])

이 구조에서는 bringup.launch.py가 전체 로봇 실행의 입구가 된다. 내부적으로 로봇 모델, 센서, Navigation 실행 파일을 포함하지만, 사용자는 한 명령만 기억하면 된다.

ros2 launch my_robot_bringup bringup.launch.py use_sim_time:=true robot_name:=robot1

이 방식의 장점은 재현성이다. "어제 어떤 터미널에서 어떤 명령을 쳤더라?"를 기억할 필요가 없다. launch 파일 자체가 실행 절차의 기록이 된다. 팀 프로젝트에서는 더 중요하다. 한 사람이 성공한 실행 구조를 launch 파일로 남기면, 다른 사람도 같은 방식으로 로봇을 켤 수 있다.

bringup 패키지는 로봇의 출발 버튼이다

ROS2 프로젝트에서는 bringup이라는 이름의 패키지를 자주 본다. bringup은 로봇 시스템을 실제로 띄우는 실행 묶음이라고 생각하면 된다. 센서 패키지, 제어 패키지, description 패키지, navigation 패키지가 각각 자기 역할을 한다면, bringup 패키지는 이들을 한곳에서 연결한다.

예를 들어 이동 로봇 프로젝트는 다음처럼 나눌 수 있다.

my_robot_ws/src/
  my_robot_description/
    urdf/
    meshes/
    launch/
  my_robot_controller/
    my_robot_controller/
    package.xml
    setup.py
  my_robot_sensors/
    launch/
    config/
  my_robot_navigation/
    config/
    launch/
  my_robot_bringup/
    launch/
    config/

description은 로봇의 형태와 TF 구조를 담당한다. controller는 바퀴 제어와 속도 제한 같은 제어 기능을 담당한다. sensors는 LiDAR, 카메라, IMU 드라이버 실행을 관리한다. navigation은 SLAM, AMCL, Nav2 관련 설정을 담는다. bringup은 이 모든 것을 실제 실행 순서로 묶는다.

이렇게 나누면 패키지의 책임이 분명해진다. 센서 드라이버를 바꾸더라도 navigation 설정 전체를 뒤질 필요가 줄어든다. 로봇 모델을 수정하더라도 속도 제한 노드 코드를 건드리지 않는다. 6장에서 노드를 기능별로 나누었던 이유가 패키지와 launch 구조에서도 이어지는 것이다.

config와 launch 파일을 설치하기

파이썬 패키지를 만들 때 launch 파일과 config 파일을 작성했는데, ros2 launch가 파일을 찾지 못하는 경우가 있다. 많은 경우 설치 설정을 빠뜨렸기 때문이다.

ament_python 패키지에서는 setup.py의 data_files에 launch와 config 파일을 포함해야 한다.

import os
from glob import glob
from setuptools import setup

package_name = 'my_robot_bringup'

setup(
    name=package_name,
    version='0.0.0',
    packages=[package_name],
    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/*.py')),
        (os.path.join('share', package_name, 'config'), glob('config/*.yaml')),
    ],
    install_requires=['setuptools'],
    zip_safe=True,
    maintainer='robot',
    maintainer_email='[email protected]',
    description='Bringup package for my robot',
    license='Apache-2.0',
    entry_points={
        'console_scripts': [],
    },
)

그다음 워크스페이스 루트에서 빌드하고 source한다.

colcon build
source install/setup.bash

이 과정을 거쳐야 get_package_share_directory('my_robot_bringup')가 설치된 공유 디렉터리를 찾고, 그 안의 launch와 config 파일을 사용할 수 있다. 파일을 작성했는데 launch에서 보이지 않는다면, 빌드와 source, 그리고 data_files 설정을 차례로 확인하자.

remapping은 노드 사이의 배선을 바꾸는 일이다

토픽 이름을 코드마다 직접 맞추면 작은 변경에도 여러 패키지를 수정해야 한다. remapping은 이 문제를 줄여 준다. 노드 내부에서 사용하는 이름과 실제 시스템에서 연결되는 이름을 launch 파일에서 바꿀 수 있기 때문이다.

예를 들어 Nav2는 속도 명령을 /cmd_vel로 내보내고, 실제 베이스 컨트롤러는 /diff_drive_controller/cmd_vel_unstamped를 구독한다고 해 보자. 중간에 속도 제한 노드를 넣는다면 다음처럼 배선할 수 있다.

Nav2
  -> /nav_cmd_vel
  -> safety_limiter
  -> /diff_drive_controller/cmd_vel_unstamped
  -> base controller

Launch 파일에서는 다음처럼 표현할 수 있다.

Node(
    package='my_robot_controller',
    executable='safety_limiter',
    name='safety_limiter',
    output='screen',
    parameters=[params_file],
    remappings=[
        ('nav_cmd_vel', '/cmd_vel'),
        ('cmd_vel', '/diff_drive_controller/cmd_vel_unstamped'),
    ],
)

이때 코드 안의 nav_cmd_vel과 cmd_vel은 그대로다. 시스템에 끼워 넣는 위치만 launch에서 바꾼다. 이 감각이 중요하다. 노드는 부품이고, remapping은 배선이다. 좋은 부품은 배선을 바꿔도 자기 역할을 유지한다.

RViz, SLAM, Nav2도 같은 원리로 묶인다

이 책의 뒤쪽에서는 SLAM과 Nav2를 본격적으로 다룬다. 그때 파라미터와 Launch의 중요성은 훨씬 커진다. SLAM은 지도 해상도, 스캔 토픽, 프레임 이름, 업데이트 주기 같은 설정을 필요로 한다. Nav2는 planner, controller, costmap, behavior tree 등 여러 구성요소의 파라미터를 사용한다.

이 많은 값을 명령줄에 하나씩 입력할 수는 없다. 그래서 slam.yaml, nav2.yaml 같은 파라미터 파일을 만들고, launch 파일에서 해당 파일을 넘긴다. RViz도 마찬가지다. 매번 Display를 새로 추가하는 대신 .rviz 설정 파일을 저장해 두고 launch에서 실행한다.

Node(
    package='rviz2',
    executable='rviz2',
    name='rviz2',
    output='screen',
    arguments=['-d', rviz_config_file],
    parameters=[{'use_sim_time': use_sim_time}],
)

이렇게 하면 로봇을 켤 때 센서 데이터, TF, 지도, 경로 표시가 같은 화면 구성으로 열린다. 실험을 반복할 때마다 RViz 설정을 다시 만질 필요가 없다.

자율주행 로봇 프로젝트의 launch 구조는 결국 다음 질문에 답해야 한다.

  • 어떤 노드들이 로봇을 구성하는가?

  • 각 노드는 어떤 설정값으로 실행되는가?

  • 토픽, 서비스, 액션 이름은 어떻게 연결되는가?

  • 시뮬레이션과 실제 로봇의 차이는 어디에서 선택되는가?

  • 전체 시스템을 한 번에 다시 실행할 수 있는가?

이 질문에 명확히 답할 수 있다면, 프로젝트는 이미 운영 가능한 형태에 가까워지고 있다.

자주 만나는 오류와 확인 순서

파라미터와 Launch를 처음 사용할 때는 작은 실수가 많다. 다행히 대부분은 확인 순서만 익히면 금방 찾을 수 있다.

첫째, 파라미터가 적용되지 않으면 노드 이름을 확인한다. YAML 파일의 최상위 이름과 실제 실행된 노드 이름이 다르면 파라미터가 들어가지 않는다.

ros2 node list
ros2 param list /safety_limiter

둘째, launch 파일을 찾지 못하면 파일 위치와 설치 설정을 확인한다. launch 디렉터리에 파일이 있어도 setup.py나 CMake 설치 규칙에 포함되지 않으면 설치 공간에 복사되지 않는다. 빌드 후에는 반드시 source install/setup.bash도 다시 실행해야 한다.

셋째, 토픽이 연결되지 않으면 remapping과 namespace를 함께 본다. 노드가 실제로 어떤 토픽을 발행하고 구독하는지 확인하자.

ros2 topic list
ros2 node info /robot1/safety_limiter

넷째, 시뮬레이션에서 시간이 이상하면 use_sim_time과 /clock을 확인한다. 어떤 노드는 시뮬레이션 시간을 쓰고, 어떤 노드는 실제 시간을 쓰면 TF나 센서 메시지에서 시간 불일치가 생길 수 있다.

ros2 param get /robot1/safety_limiter use_sim_time
ros2 topic echo /clock

다섯째, YAML 파일은 들여쓰기와 타입을 확인한다. true를 문자열 'true'로 넣었는지, 숫자를 문자열로 넣었는지, 공백이 어긋났는지 확인해야 한다. 설정 파일은 코드만큼이나 엄격하게 관리해야 한다.

작은 bringup을 직접 설계해 보기

이번 장의 내용을 하나의 작은 프로젝트로 정리해 보자. 목표는 속도 제한 노드와 RViz를 한 번에 실행하는 bringup 구조를 만드는 것이다. 실제 로봇이 없어도 구조 자체는 연습할 수 있다.

먼저 패키지 구조를 만든다.

my_robot_bringup/
  launch/
    bringup.launch.py
  config/
    safety.yaml
    view.rviz

safety.yaml에는 속도 제한 노드의 파라미터를 둔다.

safety_limiter:
  ros__parameters:
    max_linear_speed: 0.3
    max_angular_speed: 1.0
    input_topic: nav_cmd_vel
    output_topic: cmd_vel
    use_sim_time: true

bringup.launch.py에서는 파라미터 파일, use_sim_time, robot_name을 인자로 받을 수 있게 한다.

import os

from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node


def generate_launch_description():
    bringup_dir = get_package_share_directory('my_robot_bringup')
    default_params_file = os.path.join(bringup_dir, 'config', 'safety.yaml')
    default_rviz_file = os.path.join(bringup_dir, 'config', 'view.rviz')

    use_sim_time = LaunchConfiguration('use_sim_time')
    robot_name = LaunchConfiguration('robot_name')
    params_file = LaunchConfiguration('params_file')
    rviz_file = LaunchConfiguration('rviz_file')

    return LaunchDescription([
        DeclareLaunchArgument('use_sim_time', default_value='true'),
        DeclareLaunchArgument('robot_name', default_value='robot1'),
        DeclareLaunchArgument('params_file', default_value=default_params_file),
        DeclareLaunchArgument('rviz_file', default_value=default_rviz_file),

        Node(
            package='my_robot_controller',
            executable='safety_limiter',
            name='safety_limiter',
            namespace=robot_name,
            output='screen',
            parameters=[params_file, {'use_sim_time': use_sim_time}],
        ),

        Node(
            package='rviz2',
            executable='rviz2',
            name='rviz2',
            output='screen',
            arguments=['-d', rviz_file],
            parameters=[{'use_sim_time': use_sim_time}],
        ),
    ])

실행은 한 줄이면 된다.

ros2 launch my_robot_bringup bringup.launch.py

실제 로봇용으로 바꾸고 싶다면 실행 인자만 바꾼다.

ros2 launch my_robot_bringup bringup.launch.py \
  use_sim_time:=false \
  robot_name:=realbot \
  params_file:=/home/robot/config/safety_real.yaml

이 정도 구조만 갖춰도 프로젝트의 분위기가 달라진다. 노드를 실행하는 방법이 사람의 기억에 의존하지 않고 파일에 남는다. 설정값은 코드가 아니라 YAML에서 관리된다. 시뮬레이션과 실제 로봇 실행도 같은 launch 파일의 인자로 선택할 수 있다.

실험 코드에서 로봇 시스템으로

지금까지 우리는 노드, 토픽, 서비스, 액션을 배웠다. 각각은 ROS2의 중요한 부품이다. 하지만 부품을 잘 만드는 것과 시스템을 잘 운영하는 것은 조금 다른 문제다. 실제 로봇은 여러 노드가 동시에 움직이고, 각 노드는 서로 다른 설정을 가진다. 센서 이름, 프레임 이름, 토픽 이름, 속도 제한, 시간 설정이 조금만 어긋나도 전체 시스템은 흔들린다.

파라미터는 이 흔들림을 줄인다. 바뀔 수 있는 값을 코드 밖으로 꺼내고, 실험 조건에 따라 조정할 수 있게 한다. Launch는 실행 절차를 고정한다. 여러 노드를 정해진 이름과 설정으로 한 번에 시작하고, 필요한 launch 파일을 포함해 더 큰 시스템으로 조립한다.

앞으로 우리는 TF, URDF, RViz, Gazebo, SLAM, Nav2로 이동 로봇 시스템을 계속 확장할 것이다. 그때마다 파라미터 파일과 launch 파일은 뒤에서 조용히 중심을 잡아 줄 것이다. 로봇이 복잡해질수록 "어떻게 실행하는가"는 "무엇을 구현했는가"만큼 중요해진다.

다음 장부터는 로봇의 좌표계를 다룬다. 로봇의 몸통은 어디를 바라보고 있는지, LiDAR는 몸통에서 얼마나 떨어져 있는지, 지도 위에서 로봇은 어디에 있는지 설명하려면 좌표계가 필요하다. 그리고 그 좌표계 정보 역시 여러 노드가 함께 공유해야 한다. 이제 우리는 그 노드들을 한 번에 실행할 준비가 되어 있다.

참고

1Launch — ROS 2 Documentation: Humble documentation

2Parameters and launch files: creating configurable Nodes — (Murilo's) ROS2 Tutorial

3ROS2 Python Launch File Example - How to Start All Your Nodes at Once

4Managing large projects — ROS 2 Documentation: Rolling documentation

책 목록

ROS2로 완성하는 자율주행 로봇: 노드·TF·SLAM·Nav2 실전 설계

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기