제5부 좌표·모델
5.1 tf2 아키텍처 — 버퍼·리스너·브로드캐스터
5.1.1 이 장에서 배우는 것 / 선수 지식
로봇은 공간 안의 물체이고, 여러 부분으로 이루어져 각자 움직인다. 카메라가 본 점, 라이다가 측정한 거리, 바퀴가 돈 각도 — 이 값들은 전부 서로 다른 좌표계에서 나온다. "카메라 화면의 저 사람이 로봇 발밑 기준 어디인가"를 답하려면 좌표계 간 관계를 알아야 하고, 그 관계는 시간에 따라 변한다.
tf2는 이 문제를 푸는 ROS의 표준 답이다. 그리고 ROS 2에서 가장 많은 시간을 소모하는 하위 시스템이기도 하다 — 문제가 생겼을 때 원인이 좌표계인지 시간인지 네트워크인지 가리기 어렵기 때문이다.
이 장은 tf2의 구조를 세운다. 실전 함정은 다음 장 5.2에서 집중적으로 다룬다.
배우는 것:
- tf2가 푸는 문제 — 왜 단순한 변수 저장으로는 안 되는가.
- 아키텍처 — 브로드캐스터·토픽·리스너·버퍼의 역할 분담.
- 트리 제약 — 단일 부모·비순환·연결성이 왜 필수인가.
- 시간 축 — 버퍼가 이력을 갖는 이유와 보간.
- 정적 변환 —
/tf_static이 별도로 존재하는 이유. - REP 105 —
map·odom·base_link규약. - API — 조회·대기·변환 적용.
- QoS·CLI 실측(5.1.13) —
/tf·/tf_static이 실제로 쓰는 QoS와static_transform_publisher의 두 인자 방식.
선수 지식: 1.4(토픽), 1.6.3.3(TRANSIENT_LOCAL), 1.10(시간), 1.1.11(프레임 이름에 슬래시 금지). 회전 표현·동차변환 같은 수학은 자매서 robotics(RB-21~RB-24)가 다루며, 여기서는 ROS 2에서 어떻게 배선하는가에 집중한다(BK-04).
정직: tf2 API·동작은
ros2/geometry2저장소에 근거한다. 버퍼 기본 캐시 시간·/tf·/tf_staticQoS 기본값·static_transform_publisher인자 형식·정적 브로드캐스터의 중복 호출 동작은 Jazzy 컨테이너에서 실행 확인했다(아래 각 절에실행 확인으로 표시). 나머지 서술은 저장소 코드 기준이며 배포판에 따라 세부가 다를 수 있다(목차 3).
5.1.2 tf2가 푸는 문제
단순한 접근이 왜 실패하는가:
// ❌ 변환을 전역 변수에 저장?
Eigen::Isometry3d camera_to_base; // 언제 기준인가?
문제가 셋이다.
| 문제 | 설명 |
|---|---|
| 분산 | 변환을 아는 노드와 필요한 노드가 다르다. 바퀴 오도메트리는 드라이버가, 관절 각도는 robot_state_publisher가 안다 |
| 시간 | 로봇이 움직이면 변환이 계속 바뀐다. "지금"이 아니라 "그 데이터가 측정된 시점"의 변환이 필요하다 |
| 연쇄 | camera_optical → camera_link → base_link → odom → map처럼 여러 단계를 거쳐야 한다 |
두 번째가 특히 중요하다. 30 Hz 카메라 이미지를 50 ms 뒤에 처리한다면, 그 사이 로봇은 움직였다. 이미지가 촬영된 시각의 변환을 써야 하며, 그러려면 과거 변환을 보관하고 있어야 한다.
$$p_{\text{map}} = T_{\text{map} \leftarrow \text{odom}}(t) \cdot T_{\text{odom} \leftarrow \text{base}}(t) \cdot T_{\text{base} \leftarrow \text{cam}}(t) \cdot p_{\text{cam}}$$
여기서 모든 $T$가 같은 시각 $t$의 값이어야 한다. tf2가 하는 일이 이것이다 — 분산된 변환을 모으고, 시간별로 보관하고, 연쇄를 계산한다.
5.1.3 아키텍처
| 구성 요소 | 역할 |
|---|---|
TransformBroadcaster | 변환을 /tf에 발행 |
StaticTransformBroadcaster | 변하지 않는 변환을 /tf_static에 발행 |
/tf·/tf_static | 일반 토픽. 전역 이름(1.1.4) |
TransformListener | 두 토픽을 구독해 버퍼를 채운다 |
Buffer | 트리 구조 + 시간별 이력을 유지. 조회를 처리 |
핵심 통찰: tf2는 특별한 미들웨어가 아니라 토픽 두 개 위의 라이브러리다. 그래서 1.4~1.6의 토픽 지식이 그대로 적용된다 — QoS 불일치면 안 오고, 유실되면 공백이 생기고, 늦게 기동된 노드는 과거를 못 본다.
발행 측:
#include "tf2_ros/transform_broadcaster.h"
#include "tf2/LinearMath/Quaternion.h"
class OdomNode : public rclcpp::Node {
public:
OdomNode() : Node("odom_node") {
tf_broadcaster_ = std::make_unique<tf2_ros::TransformBroadcaster>(*this);
}
void publish_odom(double x, double y, double theta, const rclcpp::Time & stamp) {
geometry_msgs::msg::TransformStamped t;
t.header.stamp = stamp; // ★ 측정 시각 (1.10.6)
t.header.frame_id = "odom"; // 부모
t.child_frame_id = "base_link"; // 자식
t.transform.translation.x = x;
t.transform.translation.y = y;
t.transform.translation.z = 0.0;
tf2::Quaternion q;
q.setRPY(0, 0, theta);
t.transform.rotation = tf2::toMsg(q);
tf_broadcaster_->sendTransform(t);
}
private:
std::unique_ptr<tf2_ros::TransformBroadcaster> tf_broadcaster_;
};
수신 측:
#include "tf2_ros/buffer.h"
#include "tf2_ros/transform_listener.h"
class ConsumerNode : public rclcpp::Node {
public:
ConsumerNode() : Node("consumer") {
tf_buffer_ = std::make_unique<tf2_ros::Buffer>(this->get_clock());
tf_listener_ = std::make_shared<tf2_ros::TransformListener>(*tf_buffer_);
}
void on_scan(const sensor_msgs::msg::LaserScan::SharedPtr msg) {
try {
auto t = tf_buffer_->lookupTransform(
"base_link", // 대상 프레임 (to)
msg->header.frame_id, // 원본 프레임 (from)
msg->header.stamp, // ★ 데이터 시각
rclcpp::Duration::from_seconds(0.1)); // 타임아웃
// t 를 사용해 변환
} catch (const tf2::TransformException & ex) {
RCLCPP_WARN(get_logger(), "변환 실패: %s", ex.what());
return; // 반드시 처리 — 예외를 묵살하지 말 것
}
}
private:
std::unique_ptr<tf2_ros::Buffer> tf_buffer_;
std::shared_ptr<tf2_ros::TransformListener> tf_listener_;
};
함정:
TransformListener를 지역 변수로 만들면 즉시 소멸해 버퍼가 채워지지 않는다. 2.8.8의 rclpy 참조 유지 문제와 같은 성질이며, C++에서도 발생한다. 반드시 멤버로 보관한다.
함정:
lookupTransform은 예외를 던진다.try로 감싸지 않으면 노드가 종료된다. 그런데 예외를 포착해 로그만 남기고 넘어가는 것도 위험하다 — 변환이 계속 실패하는데 조용히 동작하는 것처럼 보인다.THROTTLE로그(1.11.5)와 함께 실패율을 진단 토픽으로 내는 것이 좋다(6.9).
5.1.4 트리 제약
tf2의 프레임 관계는 트리여야 한다. 그래프가 아니다.
| 제약 | 의미 | 위반 시 |
|---|---|---|
| 단일 부모 | 각 프레임의 부모는 하나뿐 | 두 노드가 같은 자식을 발행하면 충돌 |
| 비순환 | 순환이 없어야 한다 | 조회가 무한 루프·오류 |
| 연결성 | 두 프레임 사이에 경로가 있어야 조회 가능 | Could not find a connection 오류 |
"단일 부모"가 실무에서 가장 자주 깨진다. 두 노드가 같은 child_frame_id를 발행하면 tf2는 나중 것으로 덮어쓰거나 번갈아 쓰며, 결과가 흔들린다.
# 같은 프레임을 누가 발행하는지 확인
ros2 run tf2_ros tf2_monitor
# 또는 특정 프레임
ros2 run tf2_ros tf2_monitor odom base_link
전형적 충돌 사례:
| 사례 | 설명 |
|---|---|
| 오도메트리 노드 두 개 | 휠 오도메트리와 시각 오도메트리가 둘 다 odom → base_link |
| AMCL과 SLAM 동시 실행 | 둘 다 map → odom |
| 시뮬레이터와 실제 드라이버 | 둘 다 발행 |
| launch 파일 중복 포함 | 같은 노드가 두 번 뜸 |
견해: 오도메트리 소스가 여럿이면 융합 노드를 두는 것이 표준적 방법이다(
robot_localization— 7.6). 각 소스는 토픽으로 발행하고, 융합 노드 하나만 tf를 발행한다. "누가 tf를 발행하는가"를 시스템 설계에서 명확히 정하고 문서화하라 — 이것이 tf 문제의 상당수를 예방한다.
왜 트리인가: 두 프레임 사이의 경로가 유일해야 변환이 일의적으로 정해진다. 그래프라면 경로마다 다른 답이 나올 수 있고(측정 오차 때문에 실제로 다르다), 어느 것을 택할지 결정할 수 없다. 여러 경로의 정보를 융합하는 것은 추정 문제이며 tf2의 역할이 아니다 — 그것은 SLAM·상태 추정의 영역이다(7.6·8.2).
5.1.5 시간 축과 보간
버퍼는 각 변환의 이력을 시간순으로 보관한다.
보간이 자동으로 일어난다. 요청한 시각이 두 샘플 사이면 tf2가 보간한다 — 위치는 선형, 회전은 구면 선형 보간(slerp).
보간의 전제와 한계:
| 항목 | 설명 |
|---|---|
| 전제 | 두 샘플 사이에 부드럽게 움직였다 |
| 샘플 간격이 크면 | 오차가 커진다 — 고속 회전에서 특히 |
| 외삽은 하지 않는다 | 이력 범위 밖은 오류(5.2의 주제) |
캐시 시간:
// 기본값보다 길게 (bag 재생·느린 처리 파이프라인)
tf_buffer_ = std::make_unique<tf2_ros::Buffer>(
this->get_clock(), tf2::durationFromSec(30.0));
| 캐시 시간 | 트레이드오프 |
|---|---|
| 짧다 | 메모리 적음. 오래된 데이터 조회 실패 |
| 길다 | 과거 조회 가능. 메모리·조회 비용 증가 |
기본 캐시 시간은 10초다(실행 확인, Jazzy).
tf2/buffer_core.hpp의BUFFER_CORE_DEFAULT_CACHE_TIME = std::chrono::seconds(10)이며tf2_ros::Buffer생성자가 이 값을 기본 인자로 받는다. 실무에서는 파이프라인 지연에 맞춰 조정한다. 처리 지연이 큰 인지 파이프라인(7.8)이나 bag 재생 시 시간 점프(1.10.4)를 다룬다면 늘리는 것을 검토한다.
5.1.6 정적 변환 — 왜 별도인가
변하지 않는 변환(로봇 몸체에 볼트로 고정된 센서)을 계속 발행하는 것은 낭비다.
# CLI로 정적 변환 발행
ros2 run tf2_ros static_transform_publisher \
--x 0.1 --y 0 --z 0.2 --roll 0 --pitch 0 --yaw 0 \
--frame-id base_link --child-frame-id laser_link
# 옵션 방식이 정식이다. --frame-id·--child-frame-id 는 필수 인자다.
#include "tf2_ros/static_transform_broadcaster.h"
static_broadcaster_ = std::make_shared<tf2_ros::StaticTransformBroadcaster>(this);
geometry_msgs::msg::TransformStamped t;
t.header.stamp = this->now();
t.header.frame_id = "base_link";
t.child_frame_id = "laser_link";
t.transform.translation.x = 0.1;
t.transform.rotation.w = 1.0;
static_broadcaster_->sendTransform(t); // 한 번만 호출
/tf_static의 특수성:
| 성질 | 설명 |
|---|---|
| QoS | TRANSIENT_LOCAL + RELIABLE, depth 100(실행 확인, 1.6.3.3) — 늦게 기동된 노드도 받는다 |
| 발행 횟수 | 한 번이면 충분 |
| 시간 처리 | 버퍼가 모든 시각에 유효한 것으로 취급 |
| 누적 | 여러 발행자의 변환이 함께 보관된다 |
"모든 시각에 유효"가 중요하다. 정적 변환은 스탬프와 무관하게 조회되므로, 과거 시각으로 조회해도 성공한다. 이것이 시간 관련 오류를 크게 줄인다.
함정: 정적 변환이 정말 정적인지 확인하라. 팬틸트 위의 카메라, 접히는 팔, 교체 가능한 툴은 정적이 아니다. 정적으로 발행해 두고 실제로는 움직이면 조용한 오차가 된다.
같은 브로드캐스터로 여러 번 호출하면 어떻게 되나(실행 확인, Jazzy): StaticTransformBroadcaster는 내부에 net_message_라는 누적 메시지를 들고 있다. child_frame_id가 다르면 누적되고(두 번 호출 후 /tf_static을 받으면 둘 다 들어 있다), 같으면 대체된다(한 건만 남는다). 즉 "먼저 보낸 것이 사라진다"는 흔한 걱정은 사실이 아니다.
함정: 그래도 여러 정적 변환은 한 번의 호출에 벡터로 보내는 편이 낫다 — 호출할 때마다 누적된 전체가 다시 발행되므로, 프레임이 수십 개면 같은 데이터를 반복 전송하게 된다.
std::vector<geometry_msgs::msg::TransformStamped> transforms = {t1, t2, t3};
static_broadcaster_->sendTransform(transforms);
5.1.7 REP 105 — 표준 프레임 규약
생태계 전체가 이 규약을 전제한다. Nav2·SLAM·robot_localization이 이 이름과 의미를 기대한다.
| 프레임 | 의미 | 성질 |
|---|---|---|
earth | 지구 고정(다중 map 연결용) | 거의 안 씀 |
map | 세계 고정 기준 | 점프한다(재측위 시). 장기적으로 드리프트 없음 |
odom | 오도메트리 기준 | 연속적(점프 없음). 장기적으로 드리프트 |
base_link | 로봇 본체 기준 | 로봇에 고정 |
map과 odom을 나눈 이유가 이 규약의 핵심이다:
| 요구 | 만족하는 프레임 |
|---|---|
| 연속성(제어·지역 계획에 필수) | odom — 절대 점프하지 않는다 |
| 전역 정확성(장기 항법에 필수) | map — 재측위로 보정된다 |
둘 다 만족하는 프레임은 만들 수 없다. 전역 보정은 필연적으로 점프를 만들고, 점프하지 않으려면 드리프트를 감수해야 한다. 그래서 두 개를 두고 용도를 나눈다.
- 지역 제어·장애물 회피는
odom기준으로 — 점프하면 로봇이 급격히 움직인다. - 전역 목표·지도상 위치는
map기준으로.
누가 무엇을 발행하는가:
| 변환 | 발행자 |
|---|---|
map → odom | 측위 노드(AMCL·SLAM — 8.2·8.3) |
odom → base_link | 오도메트리 노드(휠·융합 — 7.6) |
base_link → 센서 | robot_state_publisher(URDF 기반 — 5.5) 또는 정적 발행 |
map → odom이 "보정량"이라는 점이 자주 오해된다. 측위 노드는 map → base_link를 직접 발행하지 않는다 — 그러면 base_link의 부모가 둘이 되어 트리 제약을 위반한다(5.1.4). 대신 map → odom을 발행해 오도메트리의 누적 오차를 보정한다.
$$T_{\text{map} \leftarrow \text{odom}} = T_{\text{map} \leftarrow \text{base}}^{\text{추정}} \cdot \left(T_{\text{odom} \leftarrow \text{base}}\right)^{-1}$$
추가 규약(REP 103·REP 105):
| 항목 | 규약 |
|---|---|
| 좌표 방향 | 오른손 좌표계, x 앞·y 왼쪽·z 위 |
| 단위 | SI — 미터·라디안·초 |
| 회전 | 라디안, 반시계가 양 |
| 광학 프레임 | 카메라는 _optical_frame을 별도로 — z 앞·x 오른쪽·y 아래(비전 관례) |
함정: 카메라의
camera_link와camera_optical_frame은 축 방향이 다르다. 전자는 ROS 관례(x 앞), 후자는 비전 관례(z 앞). 이미지 처리 결과는_optical_frame기준이며, 이를 혼동하면 90도 회전된 결과를 얻는다. 카메라 드라이버가 두 프레임과 그 사이 변환을 함께 제공하는 것이 관례다(7.3).
5.1.8 조회 API
기본 조회:
// target ← source 변환. stamp 시점.
auto t = tf_buffer_->lookupTransform(target_frame, source_frame, stamp, timeout);
고급 조회(서로 다른 시각·기준 프레임):
// source_frame의 source_time 시점 위치를
// target_frame의 target_time 시점 기준으로
auto t = tf_buffer_->lookupTransform(
target_frame, target_time,
source_frame, source_time,
fixed_frame, // 두 시각을 잇는 고정 프레임 (보통 "map" 또는 "odom")
timeout);
이것이 이동 로봇에서 유용하다. "1초 전에 본 물체가 지금 로봇 기준 어디인가"를 계산할 때 쓴다. fixed_frame은 그 시간 동안 움직이지 않았다고 가정할 프레임이다.
존재 확인:
if (tf_buffer_->canTransform(target, source, stamp, timeout, &error_msg)) {
auto t = tf_buffer_->lookupTransform(target, source, stamp);
}
메시지 변환:
#include "tf2_geometry_msgs/tf2_geometry_msgs.hpp"
#include "tf2_sensor_msgs/tf2_sensor_msgs.hpp"
geometry_msgs::msg::PointStamped p_in, p_out;
tf_buffer_->transform(p_in, p_out, "base_link", tf2::durationFromSec(0.1));
// 포인트클라우드도 (7.4)
sensor_msgs::msg::PointCloud2 cloud_out;
tf_buffer_->transform(cloud_in, cloud_out, "map");
tf2_* 변환 패키지를 포함해야 그 타입이 지원된다. 헤더를 빠뜨리면 컴파일 오류가 나는데, 원인이 명확하지 않아 헤맬 수 있다.
| 패키지 | 지원 타입 |
|---|---|
tf2_geometry_msgs | Point·Pose·Vector3·Quaternion·Twist 등 |
tf2_sensor_msgs | PointCloud2 |
tf2_eigen | Eigen 타입 상호 변환 |
tf2_kdl | KDL 타입 |
Python:
import tf2_ros
from tf2_ros import TransformException
import tf2_geometry_msgs # 임포트만 해도 변환이 등록된다
self.tf_buffer = tf2_ros.Buffer()
self.tf_listener = tf2_ros.TransformListener(self.tf_buffer, self)
try:
t = self.tf_buffer.lookup_transform(
'base_link', msg.header.frame_id, msg.header.stamp,
timeout=rclpy.duration.Duration(seconds=0.1))
except TransformException as ex:
self.get_logger().warn(f'변환 실패: {ex}', throttle_duration_sec=1.0)
return
함정: Python에서
import tf2_geometry_msgs를 빠뜨리면transform()호출 시 타입을 모른다는 오류가 난다. 임포트 자체가 등록 부작용을 갖는 구조라 린터가 "미사용 임포트"로 경고할 수 있다.
5.1.9 성능 고려
tf2 조회에도 비용이 든다.
| 비용 요소 | 설명 |
|---|---|
| 트리 탐색 | 두 프레임 간 경로 길이에 비례 |
| 보간 계산 | 샘플 검색 + 보간 |
| 락 경합 | 버퍼는 스레드 안전하며 내부 락을 쓴다(2.9) |
| 버퍼 크기 | 캐시 시간 × 발행 주파수 × 프레임 수 |
고빈도 루프에서 매번 조회하면 부담이 된다.
| 완화 | 설명 |
|---|---|
| 정적 변환 활용 | 변하지 않는 것은 /tf_static으로 — 조회가 훨씬 비용이 낮다 |
| 조회 결과 캐싱 | 같은 시각의 같은 변환을 반복 조회하지 않는다 |
| 프레임 체인 단축 | 불필요한 중간 프레임 제거 |
| 조회 빈도 감소 | 제어 주기마다가 아니라 필요할 때만 |
| 버퍼 캐시 시간 조정 | 필요 이상으로 길게 두지 않는다 |
견해: 실시간 제어 루프(6.6) 안에서 tf 조회를 하는 것은 신중해야 한다. 락 경합과 예외 가능성이 있어 지터 원인이 된다(2.5.5). 제어에 필요한 변환은 비실시간 스레드에서 미리 조회해 원자적으로 넘기는 구조(2.9.4의 이중 버퍼)가 안전하다.
5.1.10 진단 도구
# 프레임 트리를 PDF로 생성 (가장 유용)
ros2 run tf2_tools view_frames
# → frames_<타임스탬프>.pdf 생성
# 두 프레임 간 변환을 실시간 출력
ros2 run tf2_ros tf2_echo base_link laser_link
# 발행 주기·지연 통계
ros2 run tf2_ros tf2_monitor
ros2 run tf2_ros tf2_monitor odom base_link
# 원시 토픽 확인
ros2 topic hz /tf
ros2 topic echo /tf_static --qos-durability transient_local --once
view_frames가 진단의 출발점이다. 트리 전체를 그림으로 보여주고, 각 변환의 발행 주기·최근 시각을 표시한다. 연결이 끊긴 부분·예상 밖의 부모 관계가 즉시 보인다.
tf2_monitor가 보여주는 것:
| 항목 | 의미 |
|---|---|
| 발행 주기 | 얼마나 자주 오는가 |
| 평균 지연(delay) | 스탬프와 수신 시각의 차이 — 시계 동기 문제 탐지(1.10.8) |
| 최대 지연 | 최악값 |
| 브로드캐스터 목록 | 누가 발행하는가 — 중복 탐지(5.1.4) |
견해:
/tf_static을echo할 때 QoS 옵션을 반드시 주라(0.7.5). 기본 QoS로는 아무것도 안 나오고, 그것을 "정적 변환이 발행되지 않았다"로 오해하기 쉽다.
5.1.13 tf2가 실제로 쓰는 QoS와 CLI 인자 — 실측
앞 절들이 "/tf는 보통, /tf_static은 래치"라고 말한 것의 실제 코드값이다. Jazzy 컨테이너에서 tf2_ros/qos.hpp를 직접 열어 확인했다(실행 확인).
5.1.13.1 QoS 네 종류
tf2는 QoS를 그때그때 만들지 않고 네 개의 이름 붙은 클래스로 고정해 둔다. 리스너와 브로드캐스터가 짝을 이루므로, 기본 설정끼리는 항상 맞는다.
| 클래스 | 쓰는 곳 | depth | durability |
|---|---|---|---|
DynamicBroadcasterQoS | TransformBroadcaster → /tf | 100 | VOLATILE(기본) |
DynamicListenerQoS | TransformListener ← /tf | 100 | VOLATILE(기본) |
StaticBroadcasterQoS | StaticTransformBroadcaster → /tf_static | 100 | TRANSIENT_LOCAL |
StaticListenerQoS | TransformListener ← /tf_static | 100 | TRANSIENT_LOCAL |
신뢰성은 넷 다 rclcpp::QoS의 기본값인 RELIABLE이다. 정적 쪽만 생성자에서 transient_local()을 호출한다 — 소스에서 두 정적 클래스에만 그 한 줄이 있고 동적 쪽에는 없다.
depth 100이 뜻하는 것. /tf의 큐가 100이라는 말은, 여러 브로드캐스터가 동시에 대량 발행해도 리스너가 웬만해선 놓치지 않는다는 뜻이다. 반대로 리스너가 오래 막히면 100개를 넘겨 버린다 — 콜백에서 부하가 큰 일을 하면 tf가 조용히 유실되는 경로가 여기다(5.1.9).
함정: 직접
/tf를 구독하는 코드를 작성할 때 depth를 10 같은 기본값으로 두면 tf2 내부 리스너보다 훨씬 먼저 유실이 시작된다. 로봇 하나에 브로드캐스터가 여럿이면(robot_state_publisher+ 오도메트리 + 지역화) 100은 여유가 아니라 필요치다.
5.1.13.2 static_transform_publisher의 두 가지 인자 방식
CLI 인자 형식이 바뀌었고, 옛 형식도 아직 동작한다. 튜토리얼·블로그의 절대다수가 옛 형식이라 반드시 만난다.
# 새 방식 — 정식. --frame-id·--child-frame-id 는 필수
ros2 run tf2_ros static_transform_publisher \
--x 0.1 --y 0 --z 0.2 --roll 0 --pitch 0 --yaw 0 \
--frame-id base_link --child-frame-id laser_link
# 옛 방식 — 동작하지만 경고가 출력된다(실행 확인)
ros2 run tf2_ros static_transform_publisher 0.1 0 0.2 0 0 0 base_link laser_link
# [WARN] Old-style arguments are deprecated; see --help for new-style arguments
옛 방식의 인자 순서는 x y z yaw pitch roll parent child다(실행 확인 — 네 번째 값에 1.5708을 넣고 tf2_echo로 읽으면 RPY가 [0, 0, 1.571], 즉 yaw로 들어간다). 회전이 roll이 아니라 yaw부터라는 점이 함정이다. 새 옵션 방식은 --roll·--pitch·--yaw로 이름이 붙어 이 혼동이 없다 — 옮겨 쓸 이유가 이것이다.
함정: 옛 방식에는 쿼터니언 변형도 있었다(인자 9개면
x y z qx qy qz qw parent child). 인자 개수로 해석이 갈리므로, 값을 하나 빠뜨리면 오일러각이 쿼터니언으로 읽히며 조용히 틀린 자세가 나온다. 새 방식은--qx계열과--roll계열이 이름으로 갈려 이 사고가 없다.
5.1.11 함정 카탈로그
| # | 함정 | 증상 | 대처 |
|---|---|---|---|
| 1 | TransformListener를 지역 변수로 | 버퍼가 안 채워짐 | 멤버로 보관 |
| 2 | lookupTransform 예외 미처리 | 노드 크래시 | try/catch |
| 3 | 예외를 포착하고 무시 | 조용한 실패 지속 | THROTTLE 로그 + 진단 |
| 4 | 같은 자식 프레임을 두 노드가 발행 | 변환이 흔들림 | tf2_monitor·융합 노드 |
| 5 | 측위 노드가 map → base_link 발행 | 트리 위반 | map → odom 발행 |
| 6 | 프레임 이름에 앞 슬래시 | tf2 거부 | 슬래시 없이(1.1.11) |
| 7 | 광학 프레임 혼동 | 90도 회전된 결과 | _optical_frame 구분(7.3) |
| 8 | 움직이는 것을 정적으로 발행 | 조용한 오차 | 실제 가동성 확인 |
| 9 | /tf_static을 기본 QoS로 echo | 안 보임 | transient_local 옵션 |
| 10 | 캐시 시간이 파이프라인보다 짧음 | 조회 실패 | 버퍼 시간 확대 |
| 11 | 실시간 루프에서 tf 조회 | 지터·예외 | 사전 조회 + 이중 버퍼 |
| 12 | tf2_geometry_msgs 임포트 누락 | 타입 미지원 오류 | 헤더·임포트 추가 |
| 13 | map을 제어 기준으로 사용 | 재측위 시 로봇이 튐 | odom 기준(5.1.7) |
| 14 | 정적 변환을 개별 호출로 여러 번 | 일부만 유지될 수 있음 | 벡터로 한 번에 |
| 15 | 옛 static_transform_publisher 인자 순서 | 4번째가 roll이 아니라 yaw | 옵션 방식(--roll·--yaw) 사용(5.1.13.2) |
13번을 강조한다. map 프레임은 점프한다. 지역 제어·장애물 회피를 map 기준으로 구현하면 재측위 순간 목표가 급변해 로봇이 급격히 움직인다. REP 105가 두 프레임을 나눈 이유가 정확히 이것이다.
5.1.12 체크포인트
- tf2가 푸는 문제 세 가지를 말할 수 있는가. 왜 변환을 전역 변수에 저장하면 안 되는가.
- tf2의 다섯 구성 요소와 역할을 말할 수 있는가. "tf2는 토픽 두 개 위의 라이브러리"라는 사실의 실무적 함의는?
- 트리 제약 세 가지와 각각의 위반 증상을 말할 수 있는가. 왜 그래프가 아니라 트리여야 하는가.
- 오도메트리 소스가 여럿일 때의 표준적 구조는 무엇인가.
- 버퍼의 보간은 무엇을 전제하며, 언제 오차가 커지는가. 외삽은 하는가.
/tf_static의 특수성 네 가지를 말할 수 있는가. "모든 시각에 유효"가 왜 유용한가.- REP 105에서
map과odom을 나눈 이유를 두 요구사항으로 설명할 수 있는가. 지역 제어는 어느 기준인가. - 측위 노드가
map → base_link를 직접 발행하지 않는 이유는? 무엇을 발행하는가. camera_link와camera_optical_frame의 차이와, 혼동 시 증상은?- 서로 다른 시각의 변환을 조회하는 고급 API가 필요한 상황은?
fixed_frame의 역할은? - tf 조회의 비용 요소 네 가지와 완화책 다섯 가지를 들 수 있는가. 실시간 루프에서 권장되는 구조는?
- 진단의 출발점이 되는 명령과,
tf2_monitor가 알려주는 것 네 가지는? /tf와/tf_static의 QoS는 각각 무엇이며 depth는?static_transform_publisher의 옛 인자 순서에서 네 번째 값은 무엇인가(5.1.13).
다음 장 5.2는 이 구조 위에서 실제로 겪는 문제들 — 외삽 오류·시간 불일치·정적 변환 함정을 집중적으로 다룬다. ROS 2 사용자가 tf에서 잃는 시간의 대부분이 그 장의 내용이다.