Devin.KR

메시지 타입과 사용자 정의 인터페이스

개발자KR 조회 4

이 장에서 배우는 것

앞 장에서 두리는 배터리 잔량 하나를 Float32 토픽으로 흘려보냈다. 값 하나를 흘려보내는 데는 그것으로 충분했지만, 두리가 배달 목적지·현재 위치·남은 거리·배터리를 동시에 알려야 하는 순간이 오면 이야기가 달라진다. 이 장에서는 ROS 2가 이미 제공하는 메시지 타입을 살펴보고, 두리만의 필드 묶음을 .msg 파일로 정의한 다음, 그 정의를 별도 패키지로 분리하는 방법을 다룬다.

  • std_msgs와 geometry_msgs에 이미 정의된 메시지 타입을 필요에 맞게 고를 수 있다
  • .msg 파일로 여러 필드를 가진 사용자 정의 메시지를 정의할 수 있다
  • colcon build가 .msg 파일을 어떤 파이썬 코드로 바꾸는지 설명할 수 있다
  • 메시지 타입을 담는 인터페이스 패키지를 로직 패키지와 분리해야 하는 이유를 안다

문제 상황

두리 개발팀은 배달 중인 두리의 상태를 앱 화면에 띄우기로 했다. 화면에는 목적지, 현재 위치, 남은 거리, 배터리 잔량이 함께 보여야 한다. 값이 하나였던 배터리 토픽과 달리 이번에는 값이 여러 개다. 급한 대로 한 팀원이 std_msgs/String 하나에 값을 쉼표로 이어붙여 "101동 현관,42.0,85" 형태로 보내는 방법을 썼다.

문제는 그다음이었다. 다른 팀원이 구독 노드를 새로 만들면서 split(',')로 나눈 값의 순서를 헷갈려 배터리 값을 남은 거리로 잘못 읽었다. 문자열 안에 어떤 필드가 몇 번째인지는 코드 밖 어딘가의 문서에만 적혀 있었고, 필드를 하나 추가하자 순서가 통째로 밀리면서 기존 구독 코드가 조용히 틀린 값을 읽기 시작했다. 컴파일도, 실행도 에러 없이 멀쩡히 되는데 값만 틀린 상태였다.

이런 사고는 값에 이름과 타입이 없기 때문에 생긴다. ROS 2는 이 문제를 메시지 타입으로 해결한다. 메시지는 필드마다 이름과 타입을 갖고, 구독자는 문자열을 쪼개는 대신 msg.destination, msg.battery_percent처럼 이름으로 값을 꺼낸다.

구조화된 메시지를 쓰면 구독자마다 파싱 코드를 새로 만들 필요가 없다

기본 메시지 타입과 geometry_msgs

ROS 2에는 이미 많은 메시지 타입이 패키지로 제공된다. std_msgs 패키지는 값 하나를 담는 가장 단순한 타입들의 모음이다. 앞 장에서 쓴 std_msgs/Float32도 그중 하나로, 필드는 data 하나뿐이다. 값 하나만 필요하다면 새 메시지를 정의할 이유가 없다.

위치나 속도처럼 로봇이 흔히 다루는 공간 정보는 geometry_msgs 패키지에 이미 정의돼 있다. geometry_msgs/Point는 x, y, z 세 개의 float64 필드로 3차원 위치를 나타낸다. geometry_msgs/Twist는 선속도와 각속도를 담는다. 두리의 위치를 새 메시지 안에 넣을 때도 Point를 직접 다시 정의하지 않고 가져다 쓰면 된다. 이미 있는 타입을 재사용하면 다른 패키지나 도구(rviz2 같은)가 별도 변환 없이 그 값을 바로 이해한다는 이점도 있다.

두리에서 자주 쓰는 기본 메시지 타입
타입필드의미두리에서의 예
std_msgs/Stringdata: string텍스트 하나현재 동작 이름
std_msgs/Int32data: int32정수 하나적재함에 담긴 물품 수
std_msgs/Float32data: float32실수 하나앞 장의 배터리 잔량
geometry_msgs/Pointx, y, z: float643차원 위치두리의 현재 좌표

.msg 파일로 나만의 메시지 정의하기

기본 타입을 조합해도 부족할 때는 필드 여러 개를 묶은 메시지를 직접 정의한다. 메시지 정의는 .msg 확장자를 가진 텍스트 파일 하나로 끝난다. 두리의 배달 상태를 담을 DeliveryStatus.msg는 다음과 같이 쓴다.

string destination
geometry_msgs/Point position
float32 distance_remaining
uint8 battery_percent

한 줄에 "타입 필드이름" 형태로 적으면 된다. 다른 패키지의 메시지 타입도 geometry_msgs/Point처럼 패키지 이름을 붙여 그대로 필드 타입으로 쓸 수 있다.

필드 타입과 명명 규칙

  • 정수: int8, int16, int32, int64와 부호 없는 uint8, uint16, uint32, uint64
  • 실수: float32, float64
  • 문자열: string (길이 제한을 두려면 string<=32처럼 쓴다)
  • 불리언: bool
  • 다른 메시지 타입: geometry_msgs/Point처럼 "패키지이름/타입이름"
  • 배열: float32[] 처럼 대괄호를 붙이거나, float32[10]로 고정 길이, float32[<=10]로 상한을 둔다
  • 상수: 필드 대신 uint8 LOW=0처럼 대문자 이름과 =로 값을 고정한다

빌드하면 어떤 일이 벌어지나

.msg 파일은 그 자체로는 파이썬 코드가 아니다. colcon build 과정에서 rosidl이라는 생성기가 이 파일을 읽어 파이썬 클래스, C++ 구조체, 타입 지원 코드를 자동으로 만들어 install 디렉터리에 넣는다. DeliveryStatus.msg는 빌드 후 doori_interfaces.msg.DeliveryStatus라는 파이썬 클래스가 되고, 필드마다 타입에 맞는 기본값(문자열은 빈 문자열, float32는 0.0, uint8은 0)을 갖는 인스턴스를 만들 수 있다.

이 생성 단계는 반드시 다른 패키지가 빌드되기 전에 끝나 있어야 한다. colcon은 각 패키지의 package.xml에 적힌 의존 관계를 보고 빌드 순서를 정하므로, DeliveryStatus를 쓰는 노드 패키지는 반드시 doori_interfaces를 의존성으로 선언해야 한다. 메시지 정의가 실제로 어떤 필드를 갖는지는 빌드 후 ros2 interface show 패키지이름/msg/타입이름 명령으로 확인한다.

인터페이스 패키지를 따로 두는 이유

.msg 파일은 노드 코드와 같은 패키지에 둘 수 없다. rosidl의 코드 생성은 CMake 빌드 단계에서 일어나는데, 파이썬 노드를 담는 ament_python 패키지는 CMake를 아예 거치지 않기 때문이다. 그래서 메시지·서비스 정의만 담은 ament_cmake 패키지를 따로 만들고, 노드 코드는 그 패키지를 의존성으로 가져다 쓰는 구조가 된다.

이 분리는 빌드 제약을 피하는 것 이상의 의미가 있다. 인터페이스 패키지는 "두리와 외부가 주고받는 약속"만 담고 있어서, 나중에 두리의 상태를 화면에 띄우는 앱 노드나 물류 서버 연동 노드를 새로 만들어도 doori_interfaces 하나만 의존성으로 추가하면 된다. 노드 구현이 바뀌어도 인터페이스가 그대로면 그 노드를 쓰던 쪽은 아무것도 고칠 필요가 없다.

doori_delivery는 doori_interfaces를 거쳐 geometry_msgs에 의존하므로 이 순서로 빌드된다

완성 코드

doori_interfaces는 메시지 정의만 담은 인터페이스 패키지이고, doori_delivery는 그 메시지를 발행하는 노드 패키지다. 두 패키지는 같은 워크스페이스의 src 아래 나란히 둔다.

doori_interfaces/msg/DeliveryStatus.msg

string destination
geometry_msgs/Point position
float32 distance_remaining
uint8 battery_percent

doori_interfaces/CMakeLists.txt

cmake_minimum_required(VERSION 3.8)
project(doori_interfaces)

find_package(ament_cmake REQUIRED)
find_package(rosidl_default_generators REQUIRED)
find_package(geometry_msgs REQUIRED)

rosidl_generate_interfaces(${PROJECT_NAME}
  "msg/DeliveryStatus.msg"
  DEPENDENCIES geometry_msgs
)

ament_package()

doori_interfaces/package.xml

<?xml version="1.0"?>
<?xml-stylesheet type="text/xsl" href="http://download.ros.org/schema/package_format3.xsl"?>
<package format="3">
  <name>doori_interfaces</name>
  <version>0.0.1</version>
  <description>두리 배달 로봇의 사용자 정의 메시지 모음</description>
  <maintainer email="doori@example.com">doori</maintainer>
  <license>Apache-2.0</license>

  <buildtool_depend>ament_cmake</buildtool_depend>
  <buildtool_depend>rosidl_default_generators</buildtool_depend>

  <depend>geometry_msgs</depend>

  <exec_depend>rosidl_default_runtime</exec_depend>
  <member_of_group>rosidl_interface_packages</member_of_group>

  <export>
    <build_type>ament_cmake</build_type>
  </export>
</package>

doori_delivery/doori_delivery/status_publisher.py

import rclpy
from rclpy.node import Node
from geometry_msgs.msg import Point
from doori_interfaces.msg import DeliveryStatus


class StatusPublisher(Node):

    def __init__(self) -> None:
        super().__init__('status_publisher')
        self._publisher = self.create_publisher(DeliveryStatus, 'delivery_status', 10)
        self._timer = self.create_timer(1.0, self._on_timer)
        self._distance_remaining = 42.0

    def _on_timer(self) -> None:
        msg = DeliveryStatus()
        msg.destination = '101동 현관'
        msg.position = Point(x=self._distance_remaining * -0.1, y=0.0, z=0.0)
        msg.distance_remaining = self._distance_remaining
        msg.battery_percent = 85

        self._publisher.publish(msg)
        self.get_logger().info(
            f'남은 거리 {msg.distance_remaining:.1f}m, 배터리 {msg.battery_percent}%'
        )

        self._distance_remaining = max(0.0, self._distance_remaining - 6.0)


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


if __name__ == '__main__':
    main()

doori_delivery/package.xml

<?xml version="1.0"?>
<?xml-stylesheet type="text/xsl" href="http://download.ros.org/schema/package_format3.xsl"?>
<package format="3">
  <name>doori_delivery</name>
  <version>0.0.1</version>
  <description>두리 배달 로봇의 상태 발행 노드</description>
  <maintainer email="doori@example.com">doori</maintainer>
  <license>Apache-2.0</license>

  <depend>rclpy</depend>
  <depend>geometry_msgs</depend>
  <depend>doori_interfaces</depend>

  <export>
    <build_type>ament_python</build_type>
  </export>
</package>

doori_delivery/setup.py

from setuptools import find_packages, setup

package_name = 'doori_delivery'

setup(
    name=package_name,
    version='0.0.1',
    packages=find_packages(exclude=['test']),
    data_files=[
        ('share/ament_index/resource_index/packages',
         ['resource/' + package_name]),
        ('share/' + package_name, ['package.xml']),
    ],
    install_requires=['setuptools'],
    zip_safe=True,
    maintainer='doori',
    maintainer_email='doori@example.com',
    description='두리 배달 로봇의 상태 발행 노드',
    license='Apache-2.0',
    entry_points={
        'console_scripts': [
            'status_publisher = doori_delivery.status_publisher:main',
        ],
    },
)

보조 예제: message_flow_sim.py (ROS 없이 python3로 실행)

ROS 없이도 "필드 이름으로 값을 주고받는다"는 개념만 확인하고 싶다면, rclpy 대신 파이썬 표준 라이브러리만으로 메시지 흐름을 흉내 낼 수 있다. 아래 코드는 그대로 python3 message_flow_sim.py로 실행된다.

from dataclasses import dataclass
from typing import Callable, List


@dataclass
class Point:
    x: float
    y: float
    z: float


@dataclass
class DeliveryStatus:
    destination: str
    position: Point
    distance_remaining: float
    battery_percent: int


class TopicBus:
    def __init__(self) -> None:
        self._subscribers: List[Callable[[DeliveryStatus], None]] = []

    def subscribe(self, callback: Callable[[DeliveryStatus], None]) -> None:
        self._subscribers.append(callback)

    def publish(self, msg: DeliveryStatus) -> None:
        for callback in self._subscribers:
            callback(msg)


def print_status(msg: DeliveryStatus) -> None:
    print(
        f'[구독] 목적지={msg.destination} '
        f'위치=({msg.position.x:.1f}, {msg.position.y:.1f}) '
        f'남은거리={msg.distance_remaining:.1f}m '
        f'배터리={msg.battery_percent}%'
    )


def main() -> None:
    bus = TopicBus()
    bus.subscribe(print_status)

    route = [
        DeliveryStatus('101동 현관', Point(0.0, 0.0, 0.0), 42.0, 88),
        DeliveryStatus('101동 현관', Point(6.5, 2.0, 0.0), 28.5, 86),
        DeliveryStatus('101동 현관', Point(12.0, 5.5, 0.0), 9.0, 85),
        DeliveryStatus('101동 현관', Point(14.2, 7.0, 0.0), 0.0, 85),
    ]

    for msg in route:
        bus.publish(msg)


if __name__ == '__main__':
    main()

줄별 해설

DeliveryStatus.msg의 geometry_msgs/Point position은 다른 패키지가 이미 정의한 타입을 그대로 필드로 쓴 것이다. 이 줄 때문에 CMakeLists.txt에서 find_package(geometry_msgs REQUIRED)와 DEPENDENCIES geometry_msgs가 함께 필요하다.

doori_interfaces/package.xml의 <member_of_group>rosidl_interface_packages</member_of_group>와 <export><build_type>ament_cmake</build_type></export>는 이 패키지가 메시지·서비스 정의를 생성하는 인터페이스 패키지임을 ROS 2 빌드 도구에게 알려준다.

status_publisher.py의 from doori_interfaces.msg import DeliveryStatus는 빌드 후 생성된 파이썬 클래스를 가져오는 줄이다. msg.position = Point(x=..., y=0.0, z=0.0)는 중첩된 메시지 필드에 같은 타입의 인스턴스를 대입하는 부분으로, 여기에 튜플이나 딕셔너리를 넣으면 안 된다. self._distance_remaining = max(0.0, ...)는 남은 거리가 음수로 내려가지 않도록 막는다.

doori_delivery/package.xml의 <depend>doori_interfaces</depend>는 colcon이 두 패키지의 빌드 순서를 정하고 워크스페이스 도구가 의존성을 추적하는 데 쓰인다.

message_flow_sim.py의 TopicBus는 실제 ROS 토픽처럼 발행자가 구독자 목록을 몰라도 되게 만든 아주 단순한 흉내다. publish가 등록된 모든 콜백을 호출하는 부분이 토픽의 "여러 구독자에게 같은 메시지를 전달한다"는 성질과 대응한다.

실행 결과

워크스페이스에서 두 패키지를 빌드하고 메시지 정의를 확인하면 다음과 같다.

$ colcon build --packages-up-to doori_delivery
$ source install/setup.bash
$ ros2 interface show doori_interfaces/msg/DeliveryStatus
string destination
geometry_msgs/Point position
	float64 x
	float64 y
	float64 z
float32 distance_remaining
uint8 battery_percent

노드를 실행하면 1초마다 로그가 찍힌다. 타임스탬프와 노드 프로세스 번호는 실행할 때마다 달라지므로, 아래는 로그 내용만 맞춰 옮긴 예시다.

$ ros2 run doori_delivery status_publisher
[INFO] [status_publisher]: 남은 거리 42.0m, 배터리 85%
[INFO] [status_publisher]: 남은 거리 36.0m, 배터리 85%
[INFO] [status_publisher]: 남은 거리 30.0m, 배터리 85%
[INFO] [status_publisher]: 남은 거리 24.0m, 배터리 85%

보조 예제는 ROS 없이 그대로 실행되며, 출력은 코드에 적은 값과 정확히 일치한다.

$ python3 message_flow_sim.py
[구독] 목적지=101동 현관 위치=(0.0, 0.0) 남은거리=42.0m 배터리=88%
[구독] 목적지=101동 현관 위치=(6.5, 2.0) 남은거리=28.5m 배터리=86%
[구독] 목적지=101동 현관 위치=(12.0, 5.5) 남은거리=9.0m 배터리=85%
[구독] 목적지=101동 현관 위치=(14.2, 7.0) 남은거리=0.0m 배터리=85%

실무에서 자주 틀리는 것

인터페이스와 로직을 한 패키지에 넣기

ament_python 패키지 안에 msg 폴더를 두고 빌드가 알아서 처리해 주길 기대하는 실수다. ament_python 패키지는 CMake 단계 자체가 없어서 rosidl 생성기가 실행되지 않는다. 빌드는 에러 없이 끝나지만 DeliveryStatus 클래스가 만들어지지 않아 import할 때 실패한다.

# 잘못된 구성: doori_delivery(ament_python) 안에 msg를 둠
doori_delivery/
  package.xml        (build_type: ament_python)
  msg/
    DeliveryStatus.msg
  doori_delivery/
    status_publisher.py
# 올바른 구성: 인터페이스 패키지를 분리
doori_interfaces/     (build_type: ament_cmake)
  msg/
    DeliveryStatus.msg
doori_delivery/        (build_type: ament_python)
  doori_delivery/
    status_publisher.py

CMakeLists.txt에 의존 메시지 타입 선언 빠뜨리기

DeliveryStatus.msg가 geometry_msgs/Point를 쓰는데도 CMakeLists.txt에 그 사실을 알리지 않으면 빌드가 중간에 멈춘다.

rosidl_generate_interfaces(${PROJECT_NAME}
  "msg/DeliveryStatus.msg"
)
find_package(geometry_msgs REQUIRED)

rosidl_generate_interfaces(${PROJECT_NAME}
  "msg/DeliveryStatus.msg"
  DEPENDENCIES geometry_msgs
)

노드 패키지 package.xml에 인터페이스 패키지 의존성 빠뜨리기

doori_delivery가 doori_interfaces를 쓰면서도 의존성을 선언하지 않으면, colcon build --packages-up-to doori_delivery 같은 명령이 doori_interfaces를 함께 빌드해야 하는지 알지 못한다. 워크스페이스에 doori_interfaces가 아직 빌드돼 있지 않았다면 ros2 run 실행 시 import에서 실패한다.

<depend>rclpy</depend>
<depend>geometry_msgs</depend>
<depend>rclpy</depend>
<depend>geometry_msgs</depend>
<depend>doori_interfaces</depend>

중첩 메시지 필드에 튜플을 그대로 대입하기

position 필드는 geometry_msgs/Point 타입이다. 좌표를 튜플로 대입하면 코드는 실행되지만 발행 시점에 직렬화 코드가 필드를 찾지 못해 실패한다.

msg.position = (0.0, 0.0, 0.0)
from geometry_msgs.msg import Point

msg.position = Point(x=0.0, y=0.0, z=0.0)

한눈에 보기

메시지 타입을 고르는 기준
상황선택이유
값 하나만 있으면 될 때std_msgs 기본 타입새 메시지를 정의할 필요가 없다
위치·자세·속도 같은 공간 정보geometry_msgs 재사용표준 타입이라 다른 도구와 바로 호환된다
우리 로봇만의 의미 있는 필드 묶음사용자 정의 .msg필드 이름으로 값의 의도가 드러난다
여러 노드 패키지가 같은 메시지를 공유별도 인터페이스 패키지로 분리순환 의존 없이 재사용할 수 있다

연습 문제

  1. DeliveryStatus.msg에 남은 시간을 초 단위 정수로 담는 eta_seconds 필드를 추가하려 한다. 어떤 타입으로 선언할지 정하고, 빌드 후 필드가 잘 추가됐는지 확인할 명령을 쓰라.
  2. 인터페이스 패키지를 ament_cmake로 만들어야 하는 이유를 노드를 담는 ament_python 패키지와 비교해 설명하라.
  3. doori_delivery/package.xml에서 <depend>doori_interfaces</depend>를 빠뜨렸을 때, colcon build는 성공했는데 ros2 run이 실패하는 상황이 왜 생길 수 있는지 설명하라.
  4. message_flow_sim.py의 TopicBus에 구독자를 두 개 등록하면 어떤 일이 일어나는지 설명하고, 이것이 실제 ROS 토픽에서 여러 노드가 같은 토픽을 구독할 때와 어떤 점에서 비슷한지 서술하라.

정답과 해설

1. 초 단위 남은 시간은 음수가 될 일이 없으므로 uint32 eta_seconds처럼 부호 없는 정수로 선언하는 편이 값의 의미를 더 정확히 드러낸다. 필드를 추가하고 다시 빌드한 뒤 ros2 interface show doori_interfaces/msg/DeliveryStatus를 실행하면 새 필드가 목록에 나타나는지 확인할 수 있다.

2. rosidl의 메시지·서비스 코드 생성은 CMake 매크로(rosidl_generate_interfaces)로 이루어지는데, 이 매크로는 ament_cmake 빌드 과정에서만 실행된다. ament_python 패키지는 CMake 단계 없이 파이썬 파일을 그대로 설치하므로 그 안에서는 코드 생성이 일어나지 않는다. 그래서 메시지 정의는 ament_cmake로 만든 별도 패키지에 두고, 노드 코드가 있는 ament_python 패키지는 그 결과물을 가져다 쓰기만 한다.

3. package.xml의 <depend>는 colcon이 패키지 사이의 빌드 순서와 포함 여부를 판단하는 근거다. 이 선언이 없으면 colcon build --packages-up-to doori_delivery나 --packages-select를 쓸 때 colcon이 doori_interfaces를 함께 빌드해야 한다는 사실을 알지 못한다. doori_interfaces가 워크스페이스에 아직 빌드돼 있지 않았다면, doori_delivery만 빌드가 끝나고도 install 공간에 doori_interfaces가 없어서 ros2 run 실행 시 from doori_interfaces.msg import DeliveryStatus가 실패한다.

4. subscribe를 두 번 호출하면 _subscribers 리스트에 콜백이 두 개 쌓이고, publish가 호출될 때마다 두 콜백이 순서대로 실행되어 같은 메시지를 각자 따로 처리한다. 실제 ROS 토픽도 마찬가지로, 한 발행자가 발행한 메시지를 그 토픽을 구독하는 모든 노드가 각각 콜백으로 받는다. 발행자는 구독자가 몇 개인지, 누구인지 몰라도 되고, 구독자를 늘리기 위해 발행자 코드를 고칠 필요도 없다.

댓글 0

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

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