Devin.KR

시간과 좌표 맛보기 - 타임스탬프와 tf

개발자KR 조회 3

이 장에서 배우는 것

지금까지 두리는 토픽으로 값을 주고받고, 서비스로 요청에 응답하고, 파라미터로 동작을 바꾸고, launch 파일로 여러 노드를 한 번에 띄웠다. 이번 장에서는 그 메시지들이 언제 만들어졌는지를 다룬다. 로그를 남기거나 ros2 bag으로 기록을 재생할 때, 메시지가 도착한 시각과 실제로 측정된 시각이 다르면 원인 분석이 뒤틀린다. 또한 두리 몸체 여기저기에 달린 센서마다 자신만의 좌표계를 가지므로, 값을 하나의 기준으로 모으려면 좌표계 사이의 관계를 알아야 한다. 이 장은 그 관계를 표현하는 tf의 개념만 소개하고, 실제 브로드캐스터·리스너 코드는 이 시리즈의 심화서에서 다룬다.

  • 시스템 시간과 시뮬레이션 시간의 차이와 use_sim_time 파라미터의 역할을 구분한다
  • 메시지 header.stamp 에 시각을 채우고 수신 측에서 지연 시간을 계산한다
  • rclpy.time.Time 과 Duration 으로 두 시각의 차이를 안전하게 구한다
  • tf가 표현하는 좌표계 사이의 관계와 TransformStamped 메시지의 구조를 이해한다
  • 이 장에서 다루지 않는 부분이 무엇인지 안다

문제 상황

두리 개발팀은 거리 센서 노드와 주행 제어 노드를 따로 만들어 launch 파일로 함께 띄우고 있다. 어느 날 두리가 장애물 바로 앞에서 반 박자 늦게 멈추는 문제가 보고됐다. rqt_console로 로그를 열어보니 거리 센서는 분명 0.4m를 발행했는데, 주행 제어 노드는 그 값을 한참 지난 뒤에야 받아서 처리한 흔적이 있었다. 문제는 로그에 찍힌 시각이 메시지를 받은 시각인지 만든 시각인지 구분되지 않는다는 점이었다.

게다가 개발팀은 시뮬레이터에서 배속을 두 배로 걸어 테스트하는 중이었는데, 노드 하나는 시뮬레이션 시간을 따르고 다른 하나는 컴퓨터의 실제 시계를 따르고 있어서 애초에 두 노드가 다른 시간표로 움직이고 있었다. 여기에 더해 새로 붙인 카메라가 보는 위치와 거리 센서가 보는 위치를 그냥 더해서 두리의 위치를 계산했더니 값이 맞지 않는다는 이야기도 나왔다. 두 센서가 두리 몸체의 서로 다른 지점에 달려 있어서, 애초에 좌표계 자체가 다르기 때문이다.

ROS 2 시간의 두 얼굴: 시스템 시간과 시뮬레이션 시간

ROS 2 노드가 self.get_clock().now()를 호출하면 두 가지 시계 중 하나를 읽는다. 기본값은 컴퓨터의 시스템 시계, 흔히 말하는 벽시계 시간이다. 반면 시뮬레이터 위에서 두리를 돌릴 때는 시뮬레이터가 /clock 토픽으로 자신만의 시각을 흘려보내고, 노드가 use_sim_time 파라미터를 true로 선언하면 get_clock().now()는 벽시계 대신 그 /clock 값을 읽는다. 시뮬레이터를 두 배속으로 돌리면 시뮬레이션 시간도 실제 시간보다 두 배 빠르게 흐른다.

문제는 use_sim_time을 노드마다 따로 설정한다는 점이다. launch 파일에서 한 노드에는 이 파라미터를 true로 넘기고 다른 노드에는 깜빡하고 넘기지 않으면, 두 노드는 같은 로봇 안에서 서로 다른 시계를 보게 된다. 이러면 header.stamp로 지연을 계산하거나 ros2 bag으로 기록을 재생할 때 값이 전혀 맞지 않는다. 이 장의 완성 코드는 이해를 돕기 위해 use_sim_time을 다루지 않고 시스템 시간만 쓰지만, 시뮬레이터와 함께 두리를 돌릴 계획이라면 두리와 관련된 모든 노드에 같은 use_sim_time 값을 넘겨야 한다.

같은 get_clock().now() 호출도 use_sim_time 설정에 따라 다른 시계를 가리킨다
시스템 시간과 시뮬레이션 시간이 다른 점
구분시간 출처use_sim_time대표 상황
시스템 시간컴퓨터의 운영체제 시계false(기본값)실제 하드웨어로 두리를 움직일 때
시뮬레이션 시간시뮬레이터가 보내는 /clock 토픽true배속을 걸어 시뮬레이터에서 테스트할 때

메시지에 시간 도장 찍기: header.stamp

센서 값이나 제어 명령을 실어 나르는 대부분의 표준 메시지 타입은 맨 앞에 std_msgs/Header 필드를 갖고 있다. Header는 stamp와 frame_id 두 값을 담는다. stamp는 이 메시지가 나타내는 값이 언제 측정됐는지를 적는 자리이고, frame_id는 그 값이 어느 좌표계를 기준으로 하는지를 적는 자리다. 발행 노드가 self.get_clock().now().to_msg()로 현재 시각을 stamp에 채워 넣으면, 구독 노드는 그 값을 rclpy.time.Time.from_msg()로 다시 시각 객체로 되돌려 지금 시각과 뺄셈할 수 있다. 두 Time 객체를 빼면 Duration 객체가 나오고, 여기서 nanoseconds 속성을 꺼내 밀리초 단위로 바꾸면 메시지가 발행된 뒤 지금까지 얼마나 시간이 흘렀는지, 즉 지연(latency)을 구할 수 있다.

여기서 흔히 헷갈리는 점은 stamp가 보낸 시각이 아니라 측정한 시각을 뜻하도록 관례가 잡혀 있다는 것이다. 센서 드라이버가 값을 읽자마자 stamp를 찍고, 그 값을 가공하고 발행하는 데 몇 밀리초가 더 걸릴 수도 있다. 지연을 재는 목적이 센서가 본 세상과 지금 사이의 시차를 아는 것이라면 이 관례가 맞지만, 네트워크 전송에 걸린 시간만 재고 싶다면 stamp를 찍는 위치를 따로 설계해야 한다.

stamp는 메시지가 만들어진 시각이고 now는 받는 시각이며 그 차이가 지연이다

좌표계 사이의 관계: tf 맛보기

두리에 거리 센서와 카메라를 붙이면 각 장치는 자신이 달린 위치를 기준으로 값을 낸다. 예를 들어 앞쪽 거리 센서는 front_range_link라는 좌표계를 기준으로 내 앞 1.2m라고 말하고, 몸체 전체는 base_link라는 좌표계를 기준으로 움직인다. 이 두 좌표계 사이의 위치·방향 차이를 알아야 거리 센서가 본 1.2m 앞이 두리 몸체 기준으로 어디인지 계산할 수 있다. ROS 2는 이런 좌표계 사이의 관계를 tf(2)라는 이름의 체계로 다룬다.

tf의 핵심 아이디어는 좌표계를 나무 구조로 이어 붙이는 것이다. 보통 map(고정된 세계 좌표계) 아래에 odom(로봇이 움직이기 시작한 지점 기준), 그 아래에 base_link(로봇 몸체), 다시 그 아래에 각 센서의 좌표계가 매달린다. 이 나무의 가지 하나하나는 TransformStamped라는 메시지로 표현되는데, 이 메시지 역시 header.stamp를 가진다. 이 변환은 이 시각 기준으로 유효하다는 뜻이다. 여기에 더해 child_frame_id로 어느 좌표계가 어느 좌표계에 매달려 있는지를 밝히고, transform 필드에 이동과 회전 값을 담는다. 노드는 tf2_ros의 브로드캐스터로 이 변환을 계속 흘려보내고, 다른 노드는 리스너로 그 값을 받아 거리 센서 좌표계의 점을 몸체 좌표계로 옮겨 달라는 식의 질의를 한다.

이 장은 tf가 무엇을 위한 도구인지, 어떤 모양의 메시지를 주고받는지까지만 다룬다. 실제로 브로드캐스터를 만들고 좌표 변환을 계산하는 코드는 이 책의 범위를 벗어나므로, 두리에 여러 센서를 정말로 통합해야 할 때는 tf2 공식 튜토리얼과 이 시리즈의 심화서에서 이어서 다룬다.

map부터 front_range_link까지 좌표계는 tf로 사슬처럼 이어진다
tf 관련 용어가 가리키는 것
용어의미
frame_id값의 기준이 되는 좌표계 이름
child_frame_id변환이 매달리는 대상 좌표계 이름
브로드캐스터좌표계 사이의 변환을 계속 발행하는 쪽
리스너변환을 모아 두고 필요할 때 좌표를 바꿔 주는 쪽

완성 코드

range_publisher.py

import rclpy
from rclpy.node import Node
from sensor_msgs.msg import Range


class RangePublisher(Node):
    def __init__(self):
        super().__init__('range_publisher')
        self.publisher_ = self.create_publisher(Range, 'duri/front_range', 10)
        self.timer = self.create_timer(0.5, self.publish_range)
        self.count = 0

    def publish_range(self):
        msg = Range()
        msg.header.stamp = self.get_clock().now().to_msg()
        msg.header.frame_id = 'front_range_link'
        msg.radiation_type = Range.INFRARED
        msg.field_of_view = 0.3
        msg.min_range = 0.05
        msg.max_range = 2.0
        msg.range = 1.2 - 0.05 * self.count
        self.count += 1
        self.publisher_.publish(msg)
        self.get_logger().info(
            f'거리 발행: {msg.range:.2f}m '
            f'(stamp={msg.header.stamp.sec}.{msg.header.stamp.nanosec:09d})')


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


if __name__ == '__main__':
    main()

latency_monitor.py

import rclpy
from rclpy.node import Node
from rclpy.time import Time
from sensor_msgs.msg import Range


class LatencyMonitor(Node):
    def __init__(self):
        super().__init__('latency_monitor')
        self.subscription = self.create_subscription(
            Range, 'duri/front_range', self.on_range, 10)

    def on_range(self, msg):
        stamp_time = Time.from_msg(msg.header.stamp)
        now = self.get_clock().now()
        latency_ms = (now - stamp_time).nanoseconds / 1e6
        flag = '지연 경고' if latency_ms > 100 else '정상'
        self.get_logger().info(
            f'range={msg.range:.2f}m 지연={latency_ms:.1f}ms [{flag}]')


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


if __name__ == '__main__':
    main()

stamp_latency_demo.py (ROS 없이 실행하는 보조 예제)

from dataclasses import dataclass


@dataclass
class StampedMessage:
    seq: int
    stamp_sec: float  # 메시지를 만든 시각(초)


def make_messages():
    # 센서가 0.1초 간격으로 값을 만들었다고 가정한 표본 데이터
    stamps = [0.00, 0.10, 0.20, 0.30, 0.40]
    return [StampedMessage(seq, stamp) for seq, stamp in enumerate(stamps)]


def receive_delays():
    # 전송·처리 지연이 매번 다르다고 가정한 표본 데이터(초)
    return [0.012, 0.018, 0.052, 0.021, 0.140]


def main():
    messages = make_messages()
    delays = receive_delays()
    total_latency_ms = 0.0
    for msg, delay in zip(messages, delays):
        arrival_sec = msg.stamp_sec + delay
        latency_ms = delay * 1000
        total_latency_ms += latency_ms
        flag = "지연 경고" if latency_ms > 100 else "정상"
        print(
            f"seq={msg.seq} stamp={msg.stamp_sec:.2f}s "
            f"도착={arrival_sec:.2f}s 지연={latency_ms:.1f}ms [{flag}]"
        )
    average = total_latency_ms / len(messages)
    print(f"평균 지연: {average:.1f}ms")


if __name__ == "__main__":
    main()

줄별 해설

range_publisher.py에서 살펴볼 부분은 다음과 같다.

  • msg.header.stamp = self.get_clock().now().to_msg() — 현재 시각을 ROS 2가 메시지에 담는 형식(Time 메시지)으로 바꿔 stamp에 넣는다.
  • msg.header.frame_id = 'front_range_link' — 이 거리 값이 어느 좌표계를 기준으로 하는지 밝힌다. 나중에 tf로 다른 좌표계와 이어 붙일 때 이 이름이 열쇠가 된다.
  • msg.range = 1.2 - 0.05 * self.count — 값이 실제로 바뀌어야 지연을 관찰하는 재미가 있으므로, 호출할 때마다 거리를 조금씩 줄인다.

latency_monitor.py에서는 시간 계산이 핵심이다.

  • stamp_time = Time.from_msg(msg.header.stamp) — 메시지 안의 Time 값을 다시 rclpy의 Time 객체로 되돌린다. 초·나노초로 쪼개진 값을 직접 빼면 자리올림을 놓치기 쉬우므로 이 변환을 거친다.
  • latency_ms = (now - stamp_time).nanoseconds / 1e6 — 두 Time 객체를 빼면 Duration이 나오고, 나노초 단위 값을 1e6으로 나눠 밀리초로 바꾼다.
  • flag = '지연 경고' if latency_ms > 100 else '정상' — 임계값을 넘으면 로그에서 바로 눈에 띄도록 표시만 남긴다. 실제 제어 로직에서는 이 값을 보고 두리를 멈추는 판단까지 이어질 수 있다.

stamp_latency_demo.py는 ROS 없이 같은 계산을 손으로 흉내 낸다.

  • StampedMessage — header.stamp 대신 stamp_sec 필드 하나로 메시지가 만들어진 시각만 남긴 뼈대다.
  • arrival_sec = msg.stamp_sec + delay — 실제로는 네트워크나 처리 지연만큼 도착이 늦어진다는 점을 표본 지연 값으로 흉내 낸다.
  • flag = "지연 경고" if latency_ms > 100 else "정상" — latency_monitor.py와 같은 임계값 판단을 그대로 옮겨, ROS 없이도 같은 개념을 확인할 수 있게 했다.

실행 결과

두 노드를 각각 다른 터미널에서 실행한다.

$ ros2 run duri_time range_publisher
[INFO] [range_publisher]: 거리 발행: 1.20m (stamp=1732601234.045213000)
[INFO] [range_publisher]: 거리 발행: 1.15m (stamp=1732601234.545391000)
[INFO] [range_publisher]: 거리 발행: 1.10m (stamp=1732601235.045208000)
$ ros2 run duri_time latency_monitor
[INFO] [latency_monitor]: range=1.20m 지연=3.8ms [정상]
[INFO] [latency_monitor]: range=1.15m 지연=4.1ms [정상]
[INFO] [latency_monitor]: range=1.10m 지연=3.6ms [정상]

stamp 값과 지연 수치는 실행할 때마다 달라지지만, 형태는 이 예시와 같다. latency_monitor를 다른 컴퓨터에서 네트워크를 거쳐 실행하면 지연 값이 눈에 띄게 커지는 것도 관찰할 수 있다.

순수 파이썬 보조 예제는 ROS 설치 없이 그대로 실행된다.

$ python3 stamp_latency_demo.py
seq=0 stamp=0.00s 도착=0.01s 지연=12.0ms [정상]
seq=1 stamp=0.10s 도착=0.12s 지연=18.0ms [정상]
seq=2 stamp=0.20s 도착=0.25s 지연=52.0ms [정상]
seq=3 stamp=0.30s 도착=0.32s 지연=21.0ms [정상]
seq=4 stamp=0.40s 도착=0.54s 지연=140.0ms [지연 경고]
평균 지연: 48.6ms

실무에서 자주 틀리는 것

노드마다 다르게 설정한 use_sim_time

launch 파일에서 한 노드에만 시뮬레이션 시간을 켜면, 나머지 노드는 계속 벽시계를 읽는다.

Node(
    package='duri_time',
    executable='range_publisher',
    parameters=[{'use_sim_time': True}],
),
Node(
    package='duri_time',
    executable='latency_monitor',
),

두리와 관련된 모든 노드에 같은 값을 넘겨야 한다.

Node(
    package='duri_time',
    executable='range_publisher',
    parameters=[{'use_sim_time': True}],
),
Node(
    package='duri_time',
    executable='latency_monitor',
    parameters=[{'use_sim_time': True}],
),

stamp를 채우지 않고 보내기

stamp를 건드리지 않으면 기본값인 0으로 남는다.

msg = Range()
msg.range = 1.2
self.publisher_.publish(msg)

이 메시지를 받아 지연을 계산하면 현재 시각에서 0을 뺀 값이 그대로 나와 지연이 수십 년 단위로 튀어나온다. 발행하기 직전에 반드시 현재 시각을 채운다.

msg = Range()
msg.header.stamp = self.get_clock().now().to_msg()
msg.range = 1.2
self.publisher_.publish(msg)

Time 객체 대신 초·나노초를 직접 빼기

stamp의 sec 필드만 꺼내 뺄셈하면 나노초 자리올림이 무시된다.

latency_sec = now.seconds_nanoseconds()[0] - msg.header.stamp.sec

두 값이 초 경계를 걸쳐 있으면 오차가 초 단위로 남는다. Time 객체끼리 빼서 Duration으로 처리해야 나노초까지 정확하다.

stamp_time = Time.from_msg(msg.header.stamp)
latency_ms = (now - stamp_time).nanoseconds / 1e6

frame_id를 비워 두거나 아무 이름이나 채우기

frame_id를 빈 문자열로 두면 값 자체는 전달되지만 어느 좌표계 기준인지 알 길이 없다.

msg.header.frame_id = ''

tf 트리는 이 이름으로 좌표계를 찾으므로, 실제로 센서가 달린 좌표계 이름을 정확히 채워야 나중에 tf2 리스너가 변환을 찾을 수 있다.

msg.header.frame_id = 'front_range_link'

한눈에 보기

이 장에서 다룬 개념 정리
개념설명이 장에서 쓴 코드
시스템 시간use_sim_time이 false일 때 get_clock().now()가 읽는 벽시계range_publisher.py, latency_monitor.py
시뮬레이션 시간시뮬레이터가 /clock으로 흘리는 시각, use_sim_time=true일 때 사용다루지 않음(개념만 소개)
header.stamp메시지가 나타내는 값을 측정한 시각msg.header.stamp = self.get_clock().now().to_msg()
tf좌표계 사이의 위치·방향 관계를 나무 구조로 표현다루지 않음(심화서 예고)

연습 문제

  1. 두리에 달린 카메라 노드와 라이다 노드 중 하나만 launch 파일에서 use_sim_time을 true로 설정했다. 시뮬레이터에서 두 노드의 로그 시각을 비교하면 어떤 문제가 생기는지 설명하라.
  2. 다음 코드에서 지연 값이 비정상적으로 크게 나오는 이유를 찾아 고쳐라.
    msg.header.stamp = Header().stamp  # 기본값 그대로 둠
    ...
    latency_ms = (now - Time.from_msg(msg.header.stamp)).nanoseconds / 1e6
    
  3. front_range_link 좌표계에서 측정한 거리값을 base_link 좌표계 기준으로 바꾸려면 어떤 정보가 더 필요한가?
  4. stamp_latency_demo.py의 delays 리스트 값을 [0.005, 0.008, 0.009, 0.010, 0.012]로 바꾸면 "지연 경고"가 몇 번 출력되는지 코드 구조만 보고 답하라.

정답과 해설

  1. 시뮬레이션 시간을 따르는 노드는 시뮬레이터 배속에 맞춰 시각이 흐르고, 나머지 노드는 실제 벽시계를 따르므로 두 로그의 시각 차이가 실제 지연과 무관하게 어긋난다. 배속을 걸수록 차이가 커진다. 두리와 관련된 모든 노드가 같은 use_sim_time 값을 가져야 한다.
  2. Header()를 새로 만들면 stamp가 0으로 초기화된 채 남아 있다. now에서 이 0 시각을 빼면 지연이 실제 경과 시간이 아니라 현재 시각 전체에 가까운 거대한 값으로 나온다. self.get_clock().now().to_msg()로 실제 현재 시각을 stamp에 채워야 한다.
  3. 두 좌표계 사이의 변환, 즉 위치와 회전 값을 담은 TransformStamped 형태의 tf 정보가 필요하다. 이 장은 그 변환을 어떻게 구하고 발행하는지까지는 다루지 않았으므로, 실제 구현은 tf2_ros 브로드캐스터·리스너를 다루는 심화 내용에서 이어진다.
  4. 모든 지연 값이 0.1초(100ms)보다 작으므로 latency_ms > 100 조건을 만족하는 경우가 없다. "지연 경고"는 0번 출력되고 다섯 줄 모두 "정상"으로 표시된다.

댓글 0

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

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