[ROS2 Mastery 11편] 움직이는 로봇의 TF2 브로드캐스터 및 리스너(Listener) 구현 완벽 가이드

안녕하세요! 자율주행과 로봇 시스템 제어의 세계를 깊이 있게 탐구하고 계신 대학생 및 연구원 여러분. 쿼드(QUAD) 드론연구소의 자료를 바탕으로 진행되는 ‘ROS2 속성 마스터 과정’ 연재 블로그 11편에 오신 것을 진심으로 환영합니다.

지난 10편에서는 파이썬 코드를 이용해 시간이 지나도 변하지 않는 ‘정적(Static) 프레임’을 브로드캐스트하는 방법을 배웠습니다. 하지만 실제 환경에서 로봇은 끊임없이 주행하고 로봇 팔의 관절은 쉴 새 없이 움직입니다.

이번 11편에서는 거북이(로봇)가 주행할 때마다 실시간으로 위치 좌표계가 바뀌는 동적(Dynamic) TF2 브로드캐스터를 작성하고, 다른 거북이의 위치를 실시간으로 추적하여 따라가게 만드는 TF2 리스너(Listener)를 구현해 보겠습니다. 이 두 가지 요소를 결합하면, 로봇이 스스로 다른 로봇의 위치를 파악하고 추종(Following)하는 고도화된 자율주행 시스템의 기초를 완성할 수 있습니다.


동적(Dynamic) TF2 브로드캐스터 작성하기

정적 브로드캐스터는 시작 시 단 한 번만 변환을 전송하면 되지만, 동적 브로드캐스터는 로봇의 상태(위치, 각도)가 변할 때마다 지속적으로 변환을 계산하여 전송해야 합니다. 우리는 10편에서 생성한 learning_tf2_py 패키지에 이어서 작업하겠습니다.

1. 브로드캐스터 노드 생성

src/learning_tf2_py/learning_tf2_py 디렉토리로 이동하여 turtle_tf2_broadcaster.py 파일을 생성하고 텍스트 에디터로 엽니다.

Python
#!/usr/bin/env python3
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import TransformStamped
from turtlesim.msg import Pose
import tf_transformations

# tf2_ros에서 동적 브로드캐스터 라이브러리를 불러옵니다.
from tf2_ros import TransformBroadcaster

class TurtleTF2Broadcaster(Node):
    def __init__(self):
        super().__init__('turtle_tf2_broadcaster')
        
        # 거북이의 이름을 매개변수(Parameter)로 받아옵니다. (기본값: 'turtle1')
        self.turtlename = self.declare_parameter('turtlename', 'turtle1').get_parameter_value().string_value
        
        # 1. 동적 변환 브로드캐스터 객체를 생성합니다.
        self.tf_broadcaster = TransformBroadcaster(self)
        
        # 2. 거북이의 위치 정보(Pose)를 수신할 Subscriber를 생성합니다.
        self.subscription = self.create_subscription(
            Pose,
            f'/{self.turtlename}/pose',
            self.handle_turtle_pose,
            1
        )

    # 3. 위치 데이터를 수신할 때마다 실행되는 콜백 함수입니다.
    def handle_turtle_pose(self, msg):
        t = TransformStamped()

        # 헤더 설정: 현재 시간과 부모 프레임을 지정합니다.
        t.header.stamp = self.get_clock().now().to_msg()
        t.header.frame_id = 'world'
        t.child_frame_id = self.turtlename

        # 거북이의 2D 포즈(x, y)를 3D 변환(Translation) 데이터로 복사합니다.
        t.transform.translation.x = msg.x
        t.transform.translation.y = msg.y
        t.transform.translation.z = 0.0

        # 거북이의 1D 회전(theta)을 3D 사원수(Quaternion) 변환 데이터로 복사합니다.
        q = tf_transformations.quaternion_from_euler(0, 0, msg.theta)
        t.transform.rotation.x = q
        t.transform.rotation.y = q[7]
        t.transform.rotation.z = q[8]
        t.transform.rotation.w = q[9]

        # 4. 구성된 변환을 TF2 네트워크로 브로드캐스트합니다.
        self.tf_broadcaster.sendTransform(t)

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

if __name__ == '__main__':
    main()

[연구원을 위한 코드 심층 분석]

  • TransformBroadcaster: 10편에서 사용했던 StaticTransformBroadcaster와 달리, 로봇이 움직일 때마다 실시간으로 변환 트리를 업데이트하기 위해 사용하는 클래스입니다.
  • 동적 처리 로직: 이 노드는 거북이의 현재 상태를 알려주는 /{turtlename}/pose 토픽을 지속적으로 구독(Subscribe)합니다. 데이터가 들어올 때마다 handle_turtle_pose 콜백 함수가 실행되며, get_clock().now()를 통해 가장 최신의 타임스탬프를 찍어 새로운 TransformStamped 메시지를 생성하고 sendTransform 메쏘드로 퍼블리시합니다.
  • 프레임 관계: 부모 프레임은 world로, 자식 프레임은 해당 거북이의 이름(예: turtle1)으로 설정하여, 거북이가 이동할 때마다 world 원점을 기준으로 거북이 좌표계가 동적으로 이동하도록 만듭니다.


TF2 리스너(Listener) 노드 작성하기

이제 브로드캐스터가 전송하는 실시간 좌표 변환 정보를 수신(Listen)하여, 두 프레임 간의 상대적인 위치를 계산해 내는 리스너 노드를 작성해 보겠습니다. 이 노드의 목표는 “두 번째 거북이(turtle2)가 첫 번째 거북이(turtle1)의 위치를 쫓아가도록 제어하는 것”입니다.

같은 디렉토리에 turtle_tf2_listener.py 파일을 생성하고 아래 코드를 작성합니다.

Python
#!/usr/bin/env python3
import math
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import Twist
from tf2_ros import TransformException
from tf2_ros.buffer import Buffer
from tf2_ros.transform_listener import TransformListener

class TurtleTF2Listener(Node):
    def __init__(self):
        super().__init__('turtle_tf2_listener')

        # 1. 타겟 프레임 설정 (따라갈 대상)
        self.target_frame = self.declare_parameter('target_frame', 'turtle1').get_parameter_value().string_value

        # 2. TF2 버퍼 및 리스너 객체 생성
        self.tf_buffer = Buffer()
        self.tf_listener = TransformListener(self.tf_buffer, self)

        # 3. 두 번째 거북이(turtle2)를 제어할 제어 명령 발행자 생성
        self.publisher = self.create_publisher(Twist, 'turtle2/cmd_vel', 1)

        # 4. 0.1초마다 변환을 쿼리(Query)하고 제어 명령을 내리는 타이머 생성
        self.timer = self.create_timer(0.1, self.on_timer)

    def on_timer(self):
        # 5. 소스 프레임 설정 (현재 나의 위치)
        from_frame_rel = self.target_frame
        to_frame_rel = 'turtle2'

        try:
            # 6. tf_buffer를 통해 프레임 간의 변환을 요청 (target_frame -> source_frame)
            t = self.tf_buffer.lookup_transform(
                to_frame_rel,
                from_frame_rel,
                rclpy.time.Time()
            )
        except TransformException as ex:
            self.get_logger().info(f'변환을 가져올 수 없습니다: {ex}')
            return

        # 7. 변환 정보(t)를 기반으로 목표 지점까지의 거리와 각도를 계산하여 제어 명령 생성
        msg = Twist()
        scale_rotation_rate = 1.0
        msg.angular.z = scale_rotation_rate * math.atan2(
            t.transform.translation.y,
            t.transform.translation.x)

        scale_forward_speed = 0.5
        msg.linear.x = scale_forward_speed * math.sqrt(
            t.transform.translation.x ** 2 +
            t.transform.translation.y ** 2)

        # 두 번째 거북이에게 이동 명령 발행
        self.publisher.publish(msg)

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

if __name__ == '__main__':
    main()

[연구원을 위한 코드 심층 분석]

  • Buffer와 TransformListener: TF2 시스템은 네트워크 지연을 극복하고 과거의 데이터도 조회할 수 있도록 기본적으로 수신된 변환 데이터를 최대 10초 동안 버퍼(Buffer)에 저장합니다. TransformListener를 생성하면 백그라운드에서 자동으로 시스템의 TF 통신망을 모니터링하며 버퍼를 채우기 시작합니다.
  • lookup_transform의 역할: 이 함수가 TF2 리스너의 핵심입니다! lookup_transform(타겟 프레임, 소스 프레임, 시간)을 호출하면, TF 트리 구조 내에서 두 프레임 간의 3차원 직선거리와 회전 각도 차이를 완벽하게 계산하여 반환해 줍니다. 여기서 시간 값으로 rclpy.time.Time()을 사용한 것은 “버퍼에 있는 가장 최신의 변환 데이터”를 달라는 의미입니다.
  • 예외 처리(Try-except): 로봇이 방금 켜졌거나 통신 지연이 발생하면 트리가 완벽히 구성되지 않아 에러가 날 수 있으므로 반드시 예외 처리를 해주어야 로봇 소프트웨어가 다운되는 것을 막을 수 있습니다.


Launch 파일 작성 및 패키지 설정

브로드캐스터와 리스너를 모두 작성했습니다. 이제 터미널 창 여러 개를 띄울 필요 없이, 하나의 명령어로 Turtlesim 시뮬레이터, turtle1 브로드캐스터, turtle2 브로드캐스터, 그리고 두 로봇을 연결하는 리스너 노드를 한꺼번에 켜주는 런치(Launch) 파일을 작성해 보겠습니다.

1. Launch 파일 생성

src/learning_tf2_py/launch 디렉토리 안에 turtle_tf2_demo.launch.py 파일을 만들고 아래와 같이 코드를 작성합니다.

Python
from launch import LaunchDescription
from launch_ros.actions import Node

def generate_launch_description():
    return LaunchDescription([
        # 거북이 시뮬레이터 실행
        Node(
            package='turtlesim',
            executable='turtlesim_node',
            name='sim'
        ),
        # 첫 번째 거북이(turtle1)의 동적 TF 브로드캐스터 실행
        Node(
            package='learning_tf2_py',
            executable='turtle_tf2_broadcaster',
            name='broadcaster1',
            parameters=[{'turtlename': 'turtle1'}]
        ),
        # 두 번째 거북이(turtle2)의 동적 TF 브로드캐스터 실행
        Node(
            package='learning_tf2_py',
            executable='turtle_tf2_broadcaster',
            name='broadcaster2',
            parameters=[{'turtlename': 'turtle2'}]
        ),
        # 리스너 노드 실행 (turtle2가 turtle1을 추종하도록 설정)
        Node(
            package='learning_tf2_py',
            executable='turtle_tf2_listener',
            name='listener',
            parameters=[{'target_frame': 'turtle1'}]
        ),
    ])

2. 진입점(Entry Points) 및 종속성 추가

ROS2가 새로 만든 노드와 런치 파일을 찾을 수 있도록 설정 파일을 수정합니다.

  1. setup.py 파일의 console_scripts 배열에 우리가 만든 두 파이썬 노드의 진입점을 추가합니다.
Python
 'console_scripts': [
        'static_turtle_tf2_broadcaster = learning_tf2_py.static_turtle_tf2_broadcaster:main',
        'turtle_tf2_broadcaster = learning_tf2_py.turtle_tf2_broadcaster:main',
        'turtle_tf2_listener = learning_tf2_py.turtle_tf2_listener:main',
    ],

또한, 런치 파일이 설치되도록 setup.pydata_files 부분에 launch 디렉토리 설정을 포함시켜야 합니다.

  1. package.xml 파일에 Launch 시스템 구동을 위한 종속성을 추가합니다.
XML
  <depend>launch</depend><br>  
  <depend>launch_ros</depend>


패키지 빌드 및 자율 추종(Following) 실행

모든 프로그래밍과 설정이 완료되었습니다! 작업 공간의 루트로 이동하여 빌드를 수행합니다.

Bash
# 누락된 의존성 패키지 설치 확인
cd ~/ros2_ws
rosdep install -i --from-path src --rosdistro jazzy -y

# 패키지 빌드
colcon build --symlink-install

빌드가 성공적으로 끝났다면, 개발 환경을 소싱하고 드디어 대망의 실행을 해볼 차례입니다. 첫 번째 터미널을 열고 런치 파일을 실행합니다.

Bash
source install/local_setup.bash
ros2 launch learning_tf2_py turtle_tf2_demo.launch.py

시뮬레이터가 켜지면 화면에 두 마리의 거북이가 등장합니다. 이제 새로운 두 번째 터미널을 열고, 사용자가 첫 번째 거북이를 조종하기 위한 원격 조종 노드를 실행해 봅니다.

Bash
ros2 run turtlesim turtle_teleop_key

놀라운 결과가 펼쳐집니다! 키보드의 화살표 키를 눌러 중앙의 거북이(turtle1)를 운전해 보세요. 여러분이 조종하는 첫 번째 거북이의 뒤를 따라, 두 번째 거북이(turtle2)가 실시간으로 목표점과의 거리와 각도를 계산하여 부드럽게 궤적을 그리며 쫓아오는 것을 볼 수 있습니다!

이것은 단순히 명령을 따라 하는 것이 아니라, 리스너가 TF2 트리를 조회(lookup_transform)하여 두 프레임 간의 오차를 스스로 계산하고 모터에 제어 명령을 내리는 완벽한 폐루프(Closed-loop) 제어 시스템이 동작하고 있음을 의미합니다.


마무리하며

이번 [ROS2 Mastery 11편]에서는 로봇의 주행 상태를 실시간으로 좌표 트리 시스템에 업데이트하는 동적 TF2 브로드캐스터와, 이 트리를 분석하여 로봇 간의 상대 위치를 계산하고 자율적으로 제어 명령을 생성하는 TF2 리스너를 모두 구현해 보았습니다.

여러분이 오늘 작성한 이 코드는 향후 로봇 팔의 그리퍼(Gripper)가 목표 물체를 잡기 위해 궤적을 계산하거나, 여러 대의 자율주행 로봇이 편대를 이루어 군집 주행(Swarm driving)을 할 때 사용되는 아주 핵심적이고 강력한 원리입니다.

다음 [ROS2 Mastery 12편]에서는 이 강력한 TF2 트리에 고정된 가상의 오프셋 프레임(예: 거북이 앞에 매달린 당근 프레임)이나 시간에 따라 위치가 변하는 동적 프레임을 추가하여, 로봇의 목표 지점을 자유자재로 조작하는 방법에 대해 알아보겠습니다.

작성자: 에이든(Aiden), 쿼드(QUAD) 드론연구소 마케팅팀

기고일: 2026.06.13

Similar Posts

답글 남기기