센서 관측과 좌표 관계가 별도 경로로 들어와 목표 프레임의 위치 해석에 합쳐지는 ROS 2와 tf2 개념도

ROS 2와 tf2: 센서 데이터 전달과 좌표 변환을 구분하는 법


센서에서 물체 위치를 받아왔는데, 지도 위 어디에 놓아야 할지는 아직 모른다. ROS 2와 tf2의 역할을 나눠 보면 빠진 정보가 보인다. ROS 2는 로봇 프로그램의 구성 요소가 데이터를 주고받도록 돕고, tf2는 알려진 좌표계 사이의 관계를 시간과 함께 관리해 다른 기준으로 위치를 해석하도록 돕는다. 둘은 같은 일을 하는 대체재가 아니다.

함수의 입력과 출력을 연결하듯, 센서 값이 오는 길과 좌표 관계가 오는 길을 따로 따라가면 된다. 설치 명령보다 먼저 필요한 것은 두 길에 어떤 정보를 넣을지 정하는 일이다. 2026년 9월 17일 확인한 ROS 2 Jazzy 공식 문서와 geometry2 소스를 대조했으며, ROS 설치·통신·실물 로봇 실행은 검증하지 않았다.

노드와 토픽은 데이터를 연결하는 단위다

노드(node)는 ROS 2 시스템에서 하나의 일을 맡는 소프트웨어 단위다. 예를 들어 센서 값을 읽는 노드와 물체 위치를 계산하는 노드를 나눌 수 있다. 노드 하나가 반드시 컴퓨터 한 대나 독립 프로세스 하나를 뜻하지는 않는다. 여러 노드를 같은 프로세스에 둘 수도 있다. 공식 노드 설명

토픽(topic)은 데이터를 주고받을 때 사용하는 이름 있는 통로다. 발행자(publisher)가 메시지를 보내면 구독자(subscriber)가 받는다. 여기서 발행은 블로그 공개가 아니라 프로그램 사이의 데이터 전송이다. 메시지(message)는 필드와 자료형이 정해진 데이터 묶음이고, 센서 측정처럼 연속해서 생기는 데이터를 토픽으로 주고받을 수 있다. 공식 토픽 설명

API(Application Programming Interface)는 프로그램에서 기능을 호출하는 약속이다. Python에서 노드나 발행자를 만드는 API 묶음이 rclpy, C++의 대응 라이브러리가 rclcpp다. 이들은 프로그램이 ROS 2 기능을 쓰는 입구다. 어느 언어를 골라도 “무슨 데이터를 누가 만들고 누가 쓰는가”를 먼저 정해야 한다. 공식 클라이언트 라이브러리 설명

데이터 도착은 첫 단계일 뿐이다. “앞에 있는 점”이라는 정보에서 앞이 센서의 앞인지, 몸체의 앞인지는 별도로 알아야 한다. 토픽 이름을 맞췄다고 서로 다른 기준으로 적힌 위치가 저절로 같은 의미가 되지는 않는다.

tf2에 필요한 것은 위치 숫자뿐 아니라 관계다

좌표 프레임(coordinate frame)은 위치를 재는 원점과 축 방향의 묶음이다. 변환(transform)은 같은 점을 다른 프레임의 숫자로 표현할 때 필요한 회전·이동 관계다. tf2는 이 관계들을 연결된 트리로 관리하고, 시간에 따라 달라지는 관계도 저장해 조회한다. 센서가 실제로 어디에 설치됐는지를 빈 상태에서 알아내는 도구는 아니다. 공식 tf2 개념

회전과 이동 계산이 낯설면 좌표 프레임의 원점과 축 방향 예제를 먼저 살펴보면 된다. 여기서는 계산식 대신 변환에 필요한 자료를 누가 만드는지에 집중한다.

설명용 이동로봇을 하나 정하자. map은 지도, base_link는 로봇 몸체, sensor_link는 몸체에 단단히 고정한 평면 거리 센서의 프레임 이름이다. 관측한 점은 sensor_link 기준이고, 프로그램은 이를 map 기준으로 해석하려 한다. 아래 연결은 부모·자식 프레임 관계를 적은 것이며 실행 코드가 아니다.

map
└─ base_link       지도에서 본 몸체 관계: 이동에 따라 변함
   └─ sensor_link  몸체에서 본 센서 관계: 고정했다고 가정

센서에서 지도로 이어지는 두 관계가 있어야 한다. 몸체와 센서가 어떻게 붙어 있는지는 설계나 측정으로 준비하고, 지도에서 본 몸체의 위치·방향은 이를 추정하는 다른 구성 요소가 공급해야 한다. tf2 설치만으로 두 값이 생긴다고 가정하면, 라이브러리는 있어도 조회할 자료가 없는 설계가 된다. 이는 공식 문서의 broadcaster와 listener 역할을 이 예제에 적용한 판단이다.

정적 변환(static transform)은 두 프레임 사이의 상대관계가 변하지 않는 경우다. 세계에서 움직이지 않는다는 뜻이 아니다. 이 로봇이 방을 가로질러 이동해도 몸체에 고정된 센서의 상대 위치는 그대로일 수 있다. 반대로 센서를 회전 관절에 달았다면 그 관계는 동적으로 갱신해야 한다. 공식 문서도 고정 센서·부품의 관계를 정적 변환의 용도로 설명한다. 정적 변환 튜토리얼

센서 관측과 좌표 관계는 서로 다른 길로 들어온다

관측에는 “어느 프레임에서 언제 얻은 값인가”가 필요하고, tf2에는 그 프레임과 목표 프레임을 연결하는 관계가 필요하다. 한쪽만 준비하면 위치를 올바르게 해석할 수 없다.

센서 관측의 프레임·시각과 tf2의 변환 자료가 응용 노드에서 함께 사용되는 두 입력 흐름

위 그림은 이 글에서 설계한 개념도다. 실제 통신 화면이나 로봇 실행 결과가 아니다. 위쪽은 센서의 점 데이터가 오는 길이고, 아래쪽은 점을 다른 프레임으로 해석하는 데 쓸 관계가 오는 길이다.

변환을 보내는 구성 요소를 broadcaster라고 한다. 받는 쪽의 TransformListener는 변환 메시지를 받아 Buffer에 넣는다. Buffer는 나중에 조회할 변환 자료를 보관하는 객체다. Jazzy의 Python 구현에서는 /tf와 /tf_static을 구독하고 각각의 콜백, 즉 메시지가 왔을 때 실행되는 함수에서 자료를 저장한다. 따라서 센서 관측 토픽을 구독하는 것과 tf2 자료를 받는 것은 서로 다른 연결이다. TransformListener 구현

응용 노드는 Buffer에 목표 프레임, 출발 프레임, 필요한 시각을 지정해 변환을 요청한다. 이 예제에서는 목표가 map, 출발이 sensor_link다. Python API의 lookup_transform도 이 순서의 인수를 받는다. 반환값은 좌표 변환 자료이며, 관측한 점에 변환을 적용하는 처리는 그다음이다. frame_id라는 프레임 식별 문자열만 map으로 고쳐 적는다고 점의 숫자까지 변환되지는 않는다. Buffer API와 구현

공식 Python listener 예제를 읽을 때도 이 순서를 따라가면 된다. Buffer 생성, TransformListener 연결, lookup_transform 호출을 먼저 찾는다. 예제 전체에는 패키지 준비와 별도 노드 실행이 필요하므로 세 줄만 떼어 실행 가능한 프로그램이라고 보면 안 된다.

최신 변환이 관측 시각의 변환은 아니다

관측 시각이 10.0초이고 도착해 있는 최신 변환의 시각이 10.2초라고 가정하자. 실제 측정값이 아니라 차이를 설명하기 위한 조건이다. 두 시각 사이에 로봇 몸체가 움직였다면, 10.0초의 관측을 10.2초의 몸체 관계로 지도에 놓을 때 의미가 달라질 수 있다. 센서가 몸체에 고정돼 있어도 map과 base_link의 관계는 바뀌기 때문이다.

tf2에서 시각 0은 “지금 시각”이 아니라 버퍼에서 사용 가능한 최신 변환을 요청하는 특별한 값이다. 공식 시간 튜토리얼은 현재 시각을 요청해도 변환이 아직 도착하지 않았다면 실패할 수 있음을 보여준다. 오래된 관측을 다룰 때는 최신값으로 덮는 대신, 그 관측 시각의 변환을 얻을 수 있는지 확인해야 한다. 공식 시간 처리 설명

시간 제한을 두고 기다리는 timeout은 늦게 도착하는 자료를 기다리는 방법이다. 존재하지 않는 연결이나 과거 자료를 만들어 주지는 않는다. 위 예제도 관측과 변환이 같은 시계 기준을 사용한다고 가정한다. 운영 시에는 프레임 이름뿐 아니라 시계 기준과 필요한 시각의 자료가 준비됐는지도 함께 확인해야 한다.

실패를 만났을 때 “tf2 문제” 하나로 묶지 말고 다음처럼 나눈다. 아래는 관측한 장애 기록이 아니라 문서와 구현을 대조해 만든 점검 순서다.

보이는 상황 먼저 확인할 곳
센서 메시지가 오지 않는다 발행자·구독자의 토픽 이름, 메시지 형식, 통신 조건
센서 값은 오는데 변환을 찾지 못한다 출발·목표 프레임 이름과 둘을 잇는 관계의 공급 여부
최신 조회는 되는데 특정 시각 조회는 안 된다 해당 시각의 자료, 도착 지연, 시계 기준
조회는 되는데 위치 해석이 이상하다 변환 방향, 센서 설치 관계, 관측 프레임·시각의 정확성

첫 행에서는 QoS(Quality of Service)도 본다. 이는 데이터 전달의 신뢰성·보존 등 동작을 정하는 정책 묶음이다. 송수신 설정이 서로 호환되지 않으면 연결되지 않을 수 있으므로, 토픽 이름이 같다는 이유만으로 수신을 보장할 수 없다. 공식 QoS 호환성 설명

설치 전에 네 칸을 채우면 도입 이유가 분명해진다

이 글의 도입 판단은 라이브러리의 유명세가 아니라 필요한 연결에서 출발한다. 여러 구성 요소가 센서·상태 데이터를 주고받아야 하면 ROS 2의 노드와 토픽이 해결하는 문제를 검토한다. 여러 프레임과 시각에 따라 변하는 관계를 공유·조회해야 하면 그 안에서 tf2가 맡을 역할이 뚜렷해진다.

반대로 한 파일에서 고정된 두 좌표 사이의 계산만 공부한다면, 계산 예제부터 시작해도 된다. 통신과 프레임 이력을 아직 쓰지 않는 학습 단계에서 전체 구성을 먼저 설치할 필요는 없다. 이는 성능 비교 결과가 아니라, 앞서 나눈 책임을 기준으로 한 학습 순서의 제안이다.

자신의 센서 하나를 골라 다음 네 칸을 채워 보자.

설계카드 이 글의 예제
입력은 어느 프레임의 무슨 값인가 sensor_link 기준으로 표현한 물체의 점
그 값은 언제 얻었는가 같은 시계 기준의 10.0초 관측
어느 프레임의 표현이 필요한가 지도 프레임 map
필요한 관계를 누가 공급하는가 몸체–센서는 준비한 고정 관계, 지도–몸체는 별도 위치 추정 구성 요소

마지막 칸이 비어 있다면 설치 명령보다 자료 공급 방법을 먼저 정해야 한다. 관계를 올바르게 변환하는 기능과 그 관계 자체를 알아내는 기능은 다른 문제다.

설계카드를 채웠다면 답을 가리고 다음 조건을 확인해 보자.

  • 로봇이 움직이면 고정 센서 관계도 동적인가? 아니다. 두 프레임 사이의 상대관계가 고정이라는 가정이 기준이다.
  • 지도 표현을 얻을 때 출발 프레임은 무엇인가? 관측이 적힌 sensor_link다.
  • 10.0초 관측에 최신 10.2초 변환을 쓰면 항상 맞는가? 아니다. 시각 사이에 관계가 바뀔 수 있다.
  • 센서 설치 관계를 모르는 상태에서 tf2만 설치하면 해결되는가? 아니다. 변환을 공급할 근거가 먼저 필요하다.

문서의 입력 조건을 구별한 것과 실행 성능이나 로봇 동작을 확인한 것은 다르다. 실제 적용으로 넘어가면 설치 배포판·패키지와 시계 설정을 고정하고, 알려진 입력을 목표 프레임으로 바꾼 결과부터 확인해야 한다. 이 글의 흐름도만으로 실물 로봇을 움직여도 안전하다고 판단해서는 안 된다.

제휴·협찬은 없다.

rclpy에서 시각 0으로 조회하자 10.2초 최신 변환이 와서 10.0초 관측점이 지도에서 0.1m 옮겨졌고, 노드 시계의 현재 시각으로는 조회 자체가 실패했다

글의 예시를 ROS 2 Jazzy의 rclpy·tf2_ros로 한 프로세스 안에서 실제로 돌렸다. 발행 노드 tf2_roles_talker는 관측 점과 변환을 보내고, 응용 노드 tf2_roles_app은 관측 토픽을 구독하면서 Buffer와 TransformListener로 변환을 조회한다. 입력은 글의 조건을 숫자로 고정한 것이다. base_link → sensor_link 고정 관계는 이동 (0.2, 0, 0.1) m와 yaw 90°, map → base_link는 회전 없이 x = 0.5 × t (m)로 9.8·10.0·10.2초에 발행, 관측 점은 sensor_link 기준 (1, 0, 0)이고 시각은 10.0초다. 예측은 실행 전에 이 입력으로 손으로 계산해 계획서에 적은 값이다.

질문 글의 주장·계산 예측 실측(시간 값을 뺀 출력이 5회 모두 같았다)
관측 토픽만 있을 때 점이 오는가 데이터 전달은 별개의 연결. 점은 도착, frame_id sensor_link, 시각 10.0 수신 true, 1개, frame_id sensor_link, 시각 10.0, 점 [1.0, 0.0, 0.0]. 이때 버퍼의 프레임 목록은 비어 있었다({})
listener를 붙이면 구독이 어떻게 바뀌는가 /tf, /tf_static이 더해지고 /tf_static은 TRANSIENT_LOCAL 붙이기 전 /obs(VOLATILE, RELIABLE)뿐. 붙인 뒤 /tf(VOLATILE, RELIABLE)와 /tf_static(TRANSIENT_LOCAL, RELIABLE)이 더해졌다
broadcaster 없음, map ← sensor_link 실패(LookupException 계열). timeout 1초를 줘도 약 1초 기다린 뒤 실패 LookupException, “"map" passed to lookupTransform argument target_frame does not exist.”. timeout 1.0초일 때 1.012997–1.01408초 뒤 같은 예외
정적 관계만 있을 때 base_link ← sensor_link 성공, map ← sensor_link 실패 base_link ← sensor_link 성공, 이동 [0.2, 0.0, 0.1], 회전 qz·qw [0.707107, 0.707107]. map ← sensor_link는 위와 같은 LookupException
정적 관계를 0, 9.8, 10.0, 10.2, 1000초로 조회 모두 (0.2, 0, 0.1), 같은 회전 다섯 시각 모두 성공, 이동 [0.2, 0.0, 0.1], qz·qw [0.707107, 0.707107]. 돌려받은 변환의 시각은 요청한 시각과 같았다(0.0, 9.8, 10.0, 10.2, 1000.0)
시각 0(Time()) 조회 최신 = 10.2초, 이동 (5.3, 0, 0.1) 변환 시각 10.2, 이동 [5.3, 0.0, 0.1]
관측 시각 10.0초 조회 / 10.1초 조회 10.0초, 이동 (5.2, 0, 0.1) / 이동 x = 5.25 변환 시각 10.0, 이동 [5.2, 0.0, 0.1] / 변환 시각 10.1, 이동 [5.25, 0.0, 0.1]
관측 점의 지도 좌표 관측 시각 (5.2, 1.0, 0.1), 최신 변환 (5.3, 1.0, 0.1), 차이 0.1 m(= 0.5 m/s × 0.2 s) 관측 시각 [5.2, 1.0, 0.1], 최신 변환 [5.3, 1.0, 0.1]. 직접 쿼터니언 계산과 tf2_geometry_msgs.do_transform_point가 같은 값. 관측 시각 결과와의 거리 0.1 m
frame_id만 map으로 바꾼 점 숫자는 (1, 0, 0) 그대로, 올바른 값과 약 4.32 m 차이 frame_id map, 점 [1.0, 0.0, 0.0]. 관측 시각 결과와의 거리 4.318565 m
인수 순서 뒤집기 sensor_link ← map @10.0 역변환, 이동 (0, 5.2, −0.1). 지도 점을 되돌리면 (1, 0, 0) 이동 [-0.0, 5.2, -0.1], qz·qw [-0.707107, 0.707107]. 지도 점 왕복 결과 [1.0, 0.0, 0.0]
10.5초(최신 이후) 조회 ExtrapolationException ExtrapolationException, “extrapolation into the future … latest data is at time 10.200000”
9.0초(가장 오래된 9.8초 이전) 조회, timeout 없음 / 0.5초 timeout 0.5초여도 약 0.5초 뒤 ExtrapolationException 둘 다 ExtrapolationException, “extrapolation into the past … earliest data is at time 9.800000”. timeout 0.5초일 때 0.506953–0.507376초 뒤 실패
노드 시계 now()로 조회 ExtrapolationException ExtrapolationException(미래 방향). 요청 시각은 실행 1에서 1791962121.836794초, 예외 문구는 “latest data is at time 10.”에서 잘렸다(문구 앞 120자만 기록)
10.4초 자료를 0.3초 뒤 발행, timeout 없음 / 2초 timeout 없이 실패, timeout 2초로는 약 0.3초 뒤 성공 timeout 없이 ExtrapolationException(latest 10.200000). timeout 2.0초일 때 0.304266–0.305675초 뒤 성공, 이동 [5.4, 0.0, 0.1]. 실제 발행은 조회 호출 0.30035–0.300599초 뒤
그 뒤 Time() 재조회 10.4초 변환 변환 시각 10.4, 이동 [5.4, 0.0, 0.1]
모든 발행 뒤 새로 붙은 listener 정적 관계 수신, 동적 관계 미수신 정적 수신 true(0.010606–0.071974초 안), 동적 수신 false(2.001317–2.009121초 기다림). 버퍼에는 sensor_link 하나만 있었다
BEST_EFFORT 발행 → RELIABLE·BEST_EFFORT 구독 RELIABLE 수신 0, BEST_EFFORT 수신, incompatible QoS 이벤트 ≥ 1(RELIABILITY) 20개 발행에 RELIABLE 구독 0개, BEST_EFFORT 구독 20개. 발행자가 센 구독자 수 1. 이벤트 1회, 정책 RMW_QOS_POLICY_RELIABILITY. 발행 노드는 stderr에 “requesting incompatible QoS. No messages will be sent to it.” 경고를 남겼다

실행 1의 stdout에서 고른 원본 줄이다. 줄은 자르지 않았고, 사이의 다른 줄은 뺐다.

{"kind": "subscriptions", "node": "tf2_roles_app", "before_listener": [{"topic": "/obs", "durability": "VOLATILE", "reliability": "RELIABLE"}], "after_listener": [{"topic": "/obs", "durability": "VOLATILE", "reliability": "RELIABLE"}, {"topic": "/tf", "durability": "VOLATILE", "reliability": "RELIABLE"}, {"topic": "/tf_static", "durability": "TRANSIENT_LOCAL", "reliability": "RELIABLE"}]}
{"kind": "lookup", "label": "no_broadcaster_timeout", "pair": "map<-sensor_link", "requested_s": 0.0, "timeout_s": 1.0, "ok": false, "exception": "LookupException", "message": "\"map\" passed to lookupTransform argument target_frame does not exist. ", "elapsed_s": 1.013168}
{"kind": "lookup", "label": "latest_time0", "pair": "map<-sensor_link", "requested_s": 0.0, "timeout_s": null, "ok": true, "tf": {"stamp_s": 10.2, "xyz": [5.3, 0.0, 0.1], "qz_qw": [0.707107, 0.707107]}, "elapsed_s": 0.000131}
{"kind": "lookup", "label": "at_observation", "pair": "map<-sensor_link", "requested_s": 10.0, "timeout_s": null, "ok": true, "tf": {"stamp_s": 10.0, "xyz": [5.2, 0.0, 0.1], "qz_qw": [0.707107, 0.707107]}, "elapsed_s": 6.9e-05}
{"kind": "lookup", "label": "past_timeout", "pair": "map<-sensor_link", "requested_s": 9.0, "timeout_s": 0.5, "ok": false, "exception": "ExtrapolationException", "message": "Lookup would require extrapolation into the past.  Requested time 9.000000 but the earliest data is at time 9.800000, wh", "elapsed_s": 0.507031}
{"kind": "lookup", "label": "clock_now", "pair": "map<-sensor_link", "requested_s": 1791962121.836794, "timeout_s": null, "ok": false, "exception": "ExtrapolationException", "message": "Lookup would require extrapolation into the future.  Requested time 1791962121.836794 but the latest data is at time 10.", "elapsed_s": 9.6e-05}
{"kind": "point_error_vs_observation_time", "latest_time0_m": 0.1, "frame_id_relabel_m": 4.318565}
{"kind": "lookup", "label": "late_with_timeout", "pair": "map<-sensor_link", "requested_s": 10.4, "timeout_s": 2.0, "ok": true, "tf": {"stamp_s": 10.4, "xyz": [5.4, 0.0, 0.1], "qz_qw": [0.707107, 0.707107]}, "elapsed_s": 0.304541}
{"kind": "late_joiner", "static_received": true, "static_wait_s": 0.071645, "dynamic_received": false, "dynamic_wait_s": 2.002248, "frames": {"sensor_link": ["base_link", 0.0, 0.0]}}
{"kind": "qos", "publisher": "BEST_EFFORT", "sent": 20, "graph_subscription_count": 1, "received": {"reliable_sub": 0, "best_effort_sub": 20}, "incompatible_qos_events": 1, "last_policy": "RMW_QOS_POLICY_RELIABILITY", "total_count": 1}

측정 환경. 이미지 ros:jazzy-ros-base(ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca), ROS 2 Jazzy, Python 3.12.3, rclpy 7.1.12-1noble.20260902.053513(package.xml 기준 7.1.12), tf2_ros_py 0.36.22, RMW rmw_fastrtps_cpp(기본값). 컨테이너는 네트워크 없음, 1 CPU, 1 GiB로 제한했다. 노드는 MultiThreadedExecutor(4 스레드)가 백그라운드 스레드에서 돌렸고 조회는 메인 스레드에서 했다. 하네스가 5회 실행했다. 실행 1·2·3·5는 종료 코드 0이었고, 실행 4는 마지막 done 줄까지 출력한 뒤 종료 코드 139로 끝났으며 stderr에 “terminate called without an active exception”이 남았다(하네스 집계 ok_runs 4). 실행 4의 측정 줄도 다른 실행과 같은 값이다. 5회 모두 동적 변환은 재발행 없이(retried false) 세 시각이 모두 버퍼에 들어왔다. 하네스가 잰 실행당 시간은 8.59–13.06초, 스크립트가 잰 시간은 7.138795–7.202846초였다.

한계. 순수 소프트웨어 실측이다. 실물 로봇, 센서, 시뮬레이터, 위치 추정기는 없다. 지도–몸체 관계는 스크립트가 정한 등속 이동을 직접 발행한 값이고 정적 관계도 입력값이다. 그래서 “tf2가 관계를 알아내지 않는다”는 공급 전후의 조회 실패·성공으로만 보였고, 실제 센서 보정 절차는 재지 않았다. 통신은 한 프로세스·한 컨테이너 안이며 여러 컴퓨터, 실제 네트워크 지연, 시계 동기화 오차는 측정하지 않았다. 관측과 변환의 시각은 스크립트가 정한 값(9.8–10.4초)이고, 노드 시계는 이와 다른 기준의 값을 돌려줬다. Python(rclpy, tf2_ros_py) 동작만 봤고 rclcpp·C++ 버퍼는 측정하지 않았다. QoS는 신뢰성(reliability) 비호환 한 조합만 봤다. 대기·경과 시간은 실행마다 달라지므로 “timeout 근처에서 실패했나, 지연 근처에서 성공했나”만 판정에 썼다. 예외 문구는 앞 120자만 기록했다. 실행 4의 비정상 종료 원인은 조사하지 않았다.

측정과 해석을 나눠 읽기

위 구간은 출력 그대로의 측정이고, 이 문단부터는 측정을 근거로 한 해석이다.

해석 1: 시각 0은 “지금”이 아니라 “버퍼에 있는 가장 최근 자료”로 동작했다. 같은 조회에서 시각 0은 10.2초 변환을, 노드 시계의 현재 시각은 실패를 돌려줬다. 두 요청이 같은 뜻이었다면 결과가 갈리지 않았을 것이다. 다만 이 실험에서 현재 시각 조회가 실패한 직접 이유는 변환 시각을 10초 근처로 일부러 정해 노드 시계와 기준이 달랐기 때문이다. 실제 시스템에서 “현재 시각의 변환이 아직 도착하지 않아 실패한다”는 글의 설명은 이 실험으로 직접 재현한 것이 아니라, 같은 예외가 미래 방향 외삽으로 났다는 점에서 일치한다고 보는 것이다.

해석 2: 0.1m 차이는 tf2의 오차가 아니라 시각 선택의 결과다. 라이브러리 계산과 직접 계산이 같은 좌표를 냈고, 관측 시각으로 조회하면 예측한 좌표가 그대로 나왔다. 차이는 오직 “어느 시각의 몸체 위치를 썼는가”에서 생겼다. 이 크기는 스크립트가 정한 속도와 시각 간격에서 나온 값이므로, 실제 로봇에서 생길 어긋남의 크기로 읽지 않는다.

해석 3: timeout은 기다리는 시간일 뿐 자료를 만들지 않았다. 늦게 올 자료는 도착한 직후에 받아 왔지만, 연결이 없는 경우와 버퍼보다 오래된 시각은 주어진 시간을 다 쓴 뒤 똑같은 예외로 끝났다. 실패하는 조회에 timeout을 길게 주면 실패가 늦어질 뿐이라는 점이 이 결과에서 함께 드러난다.

해석 4: 정적 관계의 시각 값은 “언제 받았는가”를 알려 주지 않는다. 정적 변환은 어떤 시각으로 물어도 같은 값이었고, 돌려받은 변환의 시각은 요청한 시각을 그대로 담았다. tf2가 정적 관계를 특정 시각에 묶지 않고 모든 시각에 유효한 값으로 다루는 것으로 해석한다. 글이 말한 “상대관계가 변하지 않는 경우”라는 정의와 어긋나지 않는 동작이다.

해석 5: 늦게 시작한 노드는 지나간 동적 관계를 받지 못했다. /tf_static은 TRANSIENT_LOCAL이라 나중에 붙어도 받았고, VOLATILE인 /tf의 이미 지나간 메시지는 오지 않았다. 늦게 시작한 노드가 그 이전 시각의 관측을 지도로 옮기려 하면 해석 3과 같은 과거 방향 실패를 만날 것으로 추론하지만, 그 조합 자체는 이 실험에서 돌리지 않았다.

해석 6: 토픽 이름이 같아도 QoS가 맞지 않으면 구독자는 조용히 비어 있었다. RELIABLE 구독자는 예외 없이 0개를 받았고, 신호는 이벤트 콜백과 발행 쪽 경고 로그에만 남았다. 발행자가 센 구독자 수에도 이 구독자는 빠졌다. 글의 점검표 첫 행에서 “메시지가 오지 않는다”를 볼 때 구독자 수만 확인하면 비호환 구독자를 놓칠 수 있다는 뜻으로 읽는다.

해석 7: 실행 4의 종료 코드 139는 측정값과 분리해 둔다. 모든 측정 줄이 출력된 뒤 종료 단계에서 생겼고 값도 다른 실행과 같았으므로 위 표의 결론을 바꾸지 않는다고 판단한다. 백그라운드 executor 스레드와 종료 처리의 순서 문제일 가능성을 의심하지만 확인하지 않았다.

재현 코드와 실행별 원본 출력: GitHub

비슷한 글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다