제1부 계산 그래프의 코어
1.10 시간 — Clock·ROS Time·sim time·Rate
1.10.1 이 장에서 배우는 것 / 선수 지식
시간은 로봇 시스템에서 가장 조용히 틀리는 것이다. 잘못돼도 예외가 안 나고, 시뮬레이션에서는 정상적으로 동작하고, 실기에서만 tf 외삽 오류가 나거나 센서 융합이 어긋난다. 이 장의 목표는 ROS 2의 시간 모델을 정확히 세우고, "시뮬에서는 되는데 실기에서 안 된다"의 시간 관련 원인을 전부 열거하는 것이다.
배우는 것:
- 세 종류의 시계 — 시스템·ROS·단조(steady). 각각 언제 쓰는가.
use_sim_time의 정확한 동작 —/clock토픽,TimeSource, 그리고 무엇이 영향을 받고 무엇이 안 받는가.- 시간 점프(jump) — bag 재생 루프·시뮬 리셋 시 벌어지는 일과 대응.
create_jump_callback의 정확한 서명(1.10.11) — 인자가 셋이고 임계값은 생략할 수 없다.Ratevs 타이머 — 왜 대부분의 경우 타이머가 옳은가.now()를 쓰면 안 되는 곳 — 타임스탬프를 기록하는 올바른 지점.- 클럭 혼용 오류 — 예외가 나는 조합과 조용히 틀리는 조합.
- 분산 시스템의 시간 — 여러 머신에서 시계가 어긋날 때.
선수 지식: 1.4(토픽·콜백), 1.9(use_sim_time은 파라미터다). tf2의 시간 문제는 5.2, 센서 동기화는 7.7에서 이어받는다.
정직:
use_sim_time의 동작과TimeSource구조는 ROS 2 설계 문서(Clock and Time)와rclcpp소스에 근거한다. 타이머가 시뮬 시간을 따르는지 여부는 타이머 종류와 배포판에 따라 다를 수 있어 해당 절에(확인 필요)로 명시했다 — 이 구분이 실무에서 매우 중요하므로 자신의 환경에서 반드시 확인하기 바란다(목차 3).create_jump_callback의 서명만은 실행 확인했다(1.10.11) — 이 장의 예시가 컴파일되지 않아 정정했다.검증 상태(추가): 이 장의 서술 가운데 2곳은 이후 회차에서 Jazzy 실물과 대조해 확인했다 — 특히 시뮬 시간과 타이머의 관계를 0.5배속으로 실측했다(1.10.3.1). 표기가 없는 서술은 위 고지가 그대로 적용된다(목차 3).
1.10.2 세 종류의 시계
rclcpp::Clock(RCL_SYSTEM_TIME) // 벽시계 (OS 시계, NTP로 조정됨)
rclcpp::Clock(RCL_ROS_TIME) // ROS 시간 (기본; sim time 영향 받음)
rclcpp::Clock(RCL_STEADY_TIME) // 단조 증가 (뒤로 안 감, 조정 안 됨)
| 시계 | 뒤로 갈 수 있나 | 시뮬 영향 | 용도 |
|---|---|---|---|
SYSTEM_TIME | 예(NTP 보정·수동 변경) | 없음 | 절대 시각 기록, 파일명, 외부 시스템 연동 |
ROS_TIME | 예(시뮬 리셋·bag 루프) | 받음 | 데이터 타임스탬프, tf, 대부분의 로직 |
STEADY_TIME | 아니오 | 없음 | 경과 시간 측정, 타임아웃, 성능 계측 |
기본은 ROS_TIME이다. node->now()와 node->get_clock()은 ROS_TIME을 준다.
핵심 구분 — 시각(point in time) vs 기간(duration):
- 시각을 기록할 때(
header.stamp) →ROS_TIME. 시뮬·bag에서 함께 움직여야 한다. - 경과 시간을 측정할 때(타임아웃·성능) →
STEADY_TIME. 시계가 조정되어도 흔들리면 안 된다.
// ❌ 벽시계로 타임아웃 측정 — NTP 보정이 들어오면 결과가 틀린다
auto t0 = std::chrono::system_clock::now();
// ✅ 단조 시계
auto t0 = std::chrono::steady_clock::now();
// 또는
rclcpp::Clock steady(RCL_STEADY_TIME);
auto t0 = steady.now();
함정: NTP가 시계를 뒤로 되돌리는 것은 흔한 일이다(부팅 직후 특히). 벽시계로 측정한 경과 시간이 음수가 되어 타임아웃 로직이 폭주하는 사고가 실제로 발생한다. 경과 시간에는 무조건 단조 시계.
1.10.3 use_sim_time — 정확히 무슨 일이 일어나는가
use_sim_time은 모든 노드가 가진 표준 파라미터다(선언하지 않아도 존재).true면 노드는/clock토픽(rosgraph_msgs/msg/Clock)을 구독하고,ROS_TIME시계를 그 값으로 갱신한다./clock발행자가 없으면 시간이 0에서 멈춘다. 이것이 "시뮬 모드로 켰더니 노드가 아무것도 안 한다"의 원인이다.
# launch에서 전체에 적용
Node(..., parameters=[{'use_sim_time': True}])
# YAML로 전체 적용 (1.9.7)
/**:
ros__parameters:
use_sim_time: true
무엇이 영향을 받고 무엇이 안 받나 — 이 표가 이 장의 핵심이다:
| 대상 | 시뮬 시간 영향 |
|---|---|
node->now(), get_clock()->now() | 받음 |
tf2 버퍼·변환 조회 | 받음(같은 시계를 쓴다면) |
rclcpp::Rate | 기본은 안 받음(시스템 시계) — 생성자에 노드 시계를 넘기면 받는다(실행 확인) |
create_wall_timer | 안 받음 — 이름 그대로 wall clock |
create_timer(clock 지정) | 지정한 시계를 따름 |
std::this_thread::sleep_for | 안 받음 |
std::chrono::steady_clock | 안 받음 |
| 로그 타임스탬프 | 설정에 따라 다름(1.11) |
| QoS deadline·lifespan | 안 받음(미들웨어 수준) |
함정:
create_wall_timer는 시뮬 시간을 따르지 않는다. 이것이 가장 흔한 시뮬 관련 버그다. 시뮬레이션을 0.1배속으로 실행하면 wall timer는 여전히 실시간 주기로 실행되고,now()는 10배 느리게 간다 — 주기와 타임스탬프가 어긋난다. 시뮬 시간을 따라야 하는 주기 작업은 노드의 시계를 쓰는 타이머로 만들어야 한다.
// 시뮬 시간을 따르는 타이머 (노드 시계 사용)
timer_ = rclcpp::create_timer(
this, this->get_clock(), rclcpp::Duration::from_seconds(0.1),
std::bind(&MyNode::on_timer, this));
1.10.3.1 실측 — 0.5배속에서 두 타이머를 나란히 실행해 보면
실행 확인(2026-08-16). 이 절이 예고했던 검증을 실제로 했다 —
/clock을 실시간의 0.5배로 발행하고,use_sim_time:=true인 노드에서create_wall_timer(1s)와 노드 시계 기반rclcpp::create_timer(…, 1s)를 동시에 걸어 로그를 출력했다.
ROS #1 | wall=0.10s | now()=1.36s
WALL #1 | wall=1.00s | now()=1.81s
ROS #2 | wall=1.38s | now()=2.00s
WALL #2 | wall=2.00s | now()=2.31s
WALL #3 | wall=3.00s | now()=2.81s
ROS #3 | wall=3.39s | now()=3.00s
WALL #4 | wall=4.00s | now()=3.31s
WALL #5 | wall=5.00s | now()=3.81s
ROS #4 | wall=5.39s | now()=4.00s
읽는 법이 분명하다.
| 타이머 | 발화 간격(실시간) | 발화 간격(now()) | 결론 |
|---|---|---|---|
create_wall_timer(1s) | 1.00초 고정 | 0.5초씩 증가 | 시뮬 배속을 무시한다 |
| 노드 시계 타이머(1s) | 약 2.0초 | 1.00초 고정 | 시뮬 시간을 따른다 |
0.5배속에서 wall timer는 ROS timer보다 정확히 두 배 자주 발화했다. 위 함정이 말한 "주기와 타임스탬프가 어긋난다"가 그대로 재현된 것이다 — 20 Hz로 실행한다고 믿은 제어 루프가 시뮬 시간 기준으로는 10 Hz로 실행되는 셈이며, 그 상태로 튜닝한 게인은 실기에서 맞지 않는다.
rclcpp::Rate도 확인했다(헤더 원문). 기본 생성자가 Clock(RCL_SYSTEM_TIME)을 쓰므로 기본값은 시뮬 시간을 따르지 않는다. 시뮬을 따르게 하려면 생성자에 노드 시계를 넘겨야 한다. 참고로 rclcpp::GenericRate는 deprecated이며 rclcpp::Rate를 쓰라고 표시돼 있다.
견해: 시뮬 기반 개발을 시작하기 전에 이 실험을 자기 환경에서 한 번 해 보라. 10분이면 되고, 이후의 "시뮬에서는 되는데 실기에서 안 된다"의 한 부류 전체를 예방한다. 특히 배속을 1.0이 아닌 값으로 실행해 본 적이 없다면 이 차이를 만난 적이 없을 뿐이다.
QoS의 deadline·lifespan이 시뮬 시간을 따르지 않는다는 점도 중요하다(1.5.3). 미들웨어 계층은 ROS의 시간 개념을 모른다. 시뮬을 느리게 실행하면 deadline이 계속 미충족으로 보고된다.
1.10.4 시간 점프 — bag 루프·시뮬 리셋
ROS_TIME은 뒤로 갈 수 있다. bag을 --loop로 재생하면 끝에서 처음으로 돌아가고, 시뮬레이터를 리셋하면 0으로 간다.
시간이 뒤로 갈 때 벌어지는 일:
| 영향 | 설명 |
|---|---|
| tf2 버퍼 | 미래 데이터가 남아 조회가 실패하거나 비정상적인 값 반환(5.2) |
| 타이머 | 다음 발화 시각 계산이 어긋남 |
| 경과 시간 계산 | 음수 발생 |
| 필터·적분 | dt가 음수·거대값이 되어 발산 |
| 로그 | 시간이 거꾸로 흐르는 로그 |
점프 콜백으로 대응:
rcl_jump_threshold_t threshold;
threshold.on_clock_change = true; // 시계 종류 변경 시
threshold.min_forward.nanoseconds = RCL_S_TO_NS(1); // 1초 이상 전진
threshold.min_backward.nanoseconds = -RCL_S_TO_NS(1); // 1초 이상 후진
jump_handler_ = this->get_clock()->create_jump_callback(
[]() { /* 점프 직전 */ },
[this](const rcl_time_jump_t & jump) {
RCLCPP_WARN(get_logger(), "시간 점프 감지: %.3f초",
jump.delta.nanoseconds / 1e9);
reset_filters(); // 필터·적분 상태 초기화
tf_buffer_->clear(); // tf 버퍼 비우기
},
threshold); // ← 세 번째 인자를 반드시 넘긴다 (1.10.11)
견해: bag 재생·시뮬을 상시 쓰는 개발 환경이라면 상태를 가진 노드(필터·적분기·상태기계)에 점프 콜백을 등록하는 것이 좋다. 없으면 "루프가 반복될 때마다 이상해지는" 현상을 재현·설명하지 못한 채 시간을 버린다.
방어적 dt 계산 — 점프 콜백이 없어도 최소한 이것은:
auto now = this->now();
if (last_time_.nanoseconds() > 0) {
auto dt = (now - last_time_).seconds();
if (dt <= 0.0 || dt > MAX_REASONABLE_DT) {
RCLCPP_WARN(get_logger(), "비정상 dt=%.3f — 이번 주기 건너뜀", dt);
last_time_ = now;
return; // 계산하지 않고 넘어간다
}
integrate(dt);
}
last_time_ = now;
이 패턴은 제어·추정 노드에서 사실상 필수다. dt가 0이면 나눗셈에서 무한대가 나오고, 음수면 적분이 역행하고, 거대값이면 발산한다.
1.10.5 Rate vs 타이머
// ① Rate — 루프를 직접 실행한다
rclcpp::Rate rate(10);
while (rclcpp::ok()) {
do_work();
rate.sleep(); // 이 스레드를 블로킹
}
// ② 타이머 — executor가 호출한다
timer_ = create_wall_timer(100ms, std::bind(&MyNode::on_timer, this));
| 축 | Rate 루프 | 타이머 |
|---|---|---|
| 스레드 | 그 스레드를 점유 | executor가 관리 |
| 다른 콜백 | 막힌다(같은 스레드면) | 정상 동작 |
| 시뮬 시간 | 시계 의존(확인 필요) | 시계 선택 가능 |
| 노드 여러 개 | 어렵다 | 자연스럽다 |
| 코드 흐름 | 순차적, 읽기 쉬움 | 콜백 분산 |
견해: 노드 안에서는 타이머가 기본이고,
Rate는 예외다.Rate를 콜백 안이나spin()과 같은 스레드에서 쓰면 그 시간 동안 executor가 막혀 구독·서비스가 전부 멈춘다 — 1.7의 데드락과 같은 원인이다.Rate가 적절한 경우는 전용 스레드에서 실행되는 작업 루프(액션 서버의execute— 1.8.4)나spin()을 쓰지 않는 단순 프로그램이다.
타이머의 정밀도는 별개 주제다. create_wall_timer(10ms)가 정확히 10 ms마다 실행되는 것은 아니다 — OS 스케줄링, executor 부하, 콜백 실행 시간이 모두 영향을 준다. 드리프트·지터의 정확한 분석은 2.5에서 다룬다. 지금 알아야 할 것은 타이머 주기를 제어 루프의 정확한 dt로 가정하면 안 된다는 것이다 — 실제 dt는 매번 측정해야 한다(1.10.4의 방어적 계산).
1.10.6 타임스탬프를 어디서 기록하는가
원칙: 데이터가 생성된 시점의 시각을 기록한다. 발행하는 시점이 아니다.
// ❌ 나쁨 — 처리가 끝난 뒤 시각을 기록한다
auto msg = process_image(raw); // 30 ms 걸림
msg.header.stamp = this->now(); // 30 ms 늦은 시각
pub_->publish(msg);
// ✅ 좋음 — 획득 시점을 보존
auto capture_time = this->now(); // (더 좋게는 드라이버가 준 하드웨어 타임스탬프)
auto raw = camera_->grab();
auto msg = process_image(raw);
msg.header.stamp = capture_time;
pub_->publish(msg);
왜 중요한가: tf2는 header.stamp 시점의 변환을 찾는다(5.1). 스탬프가 30 ms 늦으면 30 ms 뒤의 로봇 자세로 변환하게 되고, 빠르게 움직이는 로봇에서는 수 cm의 오차가 된다. 센서 융합(7.6)·시간 동기화(7.7)도 전부 이 값에 의존한다.
더 좋은 것은 하드웨어 타임스탬프다. 카메라·LiDAR·IMU 중 상당수가 하드웨어 캡처 시각을 제공한다. 다만 그 시각은 장치의 시계 기준이므로 ROS 시간으로 변환해야 하며, 이 변환(오프셋 추정·드리프트 보정)이 정밀 시스템에서 별도의 공학 과제가 된다(7.7).
타임스탬프 0의 의미: header.stamp가 0이면 tf2는 "가장 최근 사용 가능한 변환"으로 해석한다. 편리해 보이지만 시간 정합성을 포기하는 것이며, 조용한 오차의 원천이다. 디버깅·프로토타입 외에는 쓰지 않는다.
1.10.7 클럭 혼용 오류
rclcpp::Time t1(0, 0, RCL_ROS_TIME);
rclcpp::Time t2(0, 0, RCL_SYSTEM_TIME);
auto d = t1 - t2; // ☠️ 예외 발생: 다른 시계끼리 뺄 수 없다
클럭 타입이 다른 Time 객체끼리의 연산은 예외를 던진다. 이는 좋은 설계다 — 조용히 틀리는 대신 즉시 알려준다.
조용히 틀리는 경우:
| 상황 | 왜 조용한가 |
|---|---|
메시지에서 온 stamp를 그대로 사용 | 메시지의 시각이 어느 시계인지 타입에 없다 |
| 여러 머신의 스탬프 비교 | 시계가 동기화됐다고 가정 |
| bag 데이터와 실시간 데이터 혼합 | 시간대가 완전히 다름 |
// 메시지 스탬프를 쓸 때 — 클럭 타입을 명시적으로 맞춘다
rclcpp::Time stamp(msg->header.stamp, this->get_clock()->get_clock_type());
auto age = this->now() - stamp;
if (age > rclcpp::Duration::from_seconds(0.5)) {
RCLCPP_WARN(get_logger(), "데이터가 %.2f초 묵음 — 무시", age.seconds());
return;
}
이 "나이 검사(age check)"는 안전 관련 노드에서 필수다. 오래된 센서 데이터로 제어를 계속하는 것은 데이터가 없는 것보다 위험하다.
1.10.8 분산 시스템에서의 시간
여러 컴퓨터로 구성된 로봇(주 컴퓨터 + 인지 컴퓨터 + MCU)에서는 시계 동기화 자체가 공학 과제다.
| 수단 | 정밀도(대략) | 비용 |
|---|---|---|
| NTP | 수 ms ~ 수십 ms | 낮음 |
| chrony(LAN 내부) | 1 ms 이하 가능 | 낮음 |
| PTP(IEEE 1588) | 마이크로초 | 하드웨어 지원 필요 |
| 하드웨어 트리거·PPS | 나노초 | 배선·설계 필요 |
정직: 위 수치는 일반적으로 알려진 범위이며, 실제 달성치는 네트워크·하드웨어·부하에 크게 의존한다
(확인 필요). 자신의 시스템에서 측정해야 한다.
동기화가 안 됐을 때의 증상:
- tf2 외삽 오류(
Lookup would require extrapolation into the future) — 다른 머신의 변환이 "미래"로 보인다. message_filters동기화 실패 — 시간이 안 맞아 짝이 안 지어진다(7.7).- 센서 융합 결과의 체계적 오차.
진단:
# 두 머신에서 각각
date +%s.%N
chronyc tracking # chrony 사용 시 오프셋 확인
ros2 topic echo /some_topic --field header.stamp # 스탬프 비교
견해: 로봇에 컴퓨터가 둘 이상이면 시계 동기화를 초기 인프라로 세우라. 나중에 tf 오류를 쫓다가 이 문제에 도달하는 경우가 매우 흔하며, 그때는 이미 며칠을 쓴 뒤다. MCU(micro-ROS — 6.8)가 섞이면 문제가 한 단계 더 어려워진다.
1.10.11 create_jump_callback은 인자가 셋이다 — 실측
이 책의 예시가 컴파일되지 않는다는 것을 코드 일괄 컴파일에서 발견했다(90차 · 실행 확인 — Jazzy · ros:jazzy-ros-base · rclcpp 28.1.21).
1.10.11.1 무엇이 틀렸나
책은 threshold를 선언해 놓고 넘기지 않았다.
error: no matching function for call to
‘rclcpp::Clock::create_jump_callback(<lambda()>, <lambda(const rcl_time_jump_t&)>)’
clock.hpp의 실제 선언은 이렇다.
JumpHandler::SharedPtr
create_jump_callback(
JumpHandler::pre_callback_t pre_callback,
JumpHandler::post_callback_t post_callback,
const rcl_jump_threshold_t & threshold); // ← 세 번째, 기본값 없음
threshold에 기본값이 없으므로 생략할 수 없다. 위 예시에서 threshold를 만들어 둔 것은 맞았고, 넘기는 줄만 빠져 있었다.
왜 이 오류가 오래 남았는가 — 문장으로 읽으면 "임계값을 정하고 콜백을 등록한다"로 완결돼 보인다. 선언과 사용이 붙어 있어 눈으로는 짝이 맞아 보이지만, 컴파일러만이 인자 개수를 센다. 같은 실수가 5.2에도 두 번 있었다(5.2.4·5.2.11).
1.10.11.2 threshold의 의미 — 왜 필수 인자인가
rcl_jump_threshold_t threshold;
threshold.on_clock_change = true; // 시계 종류가 바뀔 때도 부를까
threshold.min_forward.nanoseconds = RCL_S_TO_NS(1); // 이만큼 이상 전진하면
threshold.min_backward.nanoseconds = -RCL_S_TO_NS(1);// 이만큼 이상 후진하면
임계값이 없으면 콜백을 언제 불러야 할지 정할 수 없다. 시간은 매 순간 조금씩 나아가므로, "점프"를 정의하는 것이 곧 임계값이다. API가 기본값을 주지 않는 것은 의도적이라고 볼 만하다 — 기본값을 주면 "왜 안 불리지" 또는 "왜 계속 불리지"가 조용히 생긴다.
함정:
rcl_jump_threshold_t는 POD라 초기화되지 않는다. 위처럼 세 필드를 모두 채워라. 하나라도 빠뜨리면 쓰레기 값이 임계값이 되어 콜백이 안 불리거나 폭주한다(확인 필요 — 이 동작은 재현해 보지 않았다).
1.10.9 함정 카탈로그
| # | 함정 | 증상 | 대처 |
|---|---|---|---|
| 1 | use_sim_time=true인데 /clock 없음 | 시간 0에 멈춤, 노드 무동작 | 시뮬·bag의 /clock 발행 확인 |
| 2 | 일부 노드만 use_sim_time | 시간대 불일치·tf 오류 | /**:로 전체 적용(1.9.7) |
| 3 | 시뮬에서 create_wall_timer | 주기와 스탬프 불일치 | 노드 시계 기반 타이머 |
| 4 | 경과 시간을 벽시계로 측정 | NTP 보정 시 음수·폭주 | STEADY_TIME |
| 5 | dt 검증 없이 적분 | 점프 시 발산 | 방어적 dt(1.10.4) |
| 6 | bag 루프에서 상태 미초기화 | 매 루프마다 이상 동작 | 점프 콜백 |
| 7 | Rate를 콜백 안에서 | executor 정지 | 타이머로 전환 |
| 8 | 발행 시점에 스탬프 | tf 변환 오차 | 획득 시점 보존 |
| 9 | stamp=0 사용 | 시간 정합성 상실 | 실제 시각 사용 |
| 10 | 클럭 타입 불일치 | 예외 또는 조용한 오류 | 타입 명시 변환 |
| 11 | 데이터 나이 미검사 | 묵은 데이터로 제어 | age check(1.10.7) |
| 12 | 다중 머신 시계 미동기화 | 외삽 오류·동기화 실패 | chrony·PTP |
| 13 | 타이머 주기를 dt로 가정 | 누적 오차 | 실측 dt 사용 |
| 14 | QoS deadline이 시뮬을 따를 거라 기대 | 계속 미충족 | 미들웨어는 시뮬 시간을 모름 |
1.10.10 체크포인트
- 세 시계의 차이와 각각의 용도를 말할 수 있는가. 타임아웃 측정에는 무엇을 쓰는가, 왜인가.
use_sim_time=true일 때 노드가 실제로 하는 일을 순서대로 설명할 수 있는가./clock이 없으면 어떻게 되는가.- 시뮬 시간의 영향을 받는 것과 안 받는 것을 각각 세 가지 이상 들 수 있는가.
create_wall_timer를 시뮬레이션에서 쓰면 무슨 문제가 생기는가. 대안은?- 시간이 뒤로 갈 수 있는 상황 두 가지와, 그때 깨지는 것 네 가지를 말할 수 있는가.
- 제어·추정 노드에서 dt를 계산할 때 반드시 넣어야 할 방어 코드를 쓸 수 있는가.
Rate와 타이머 중 노드 안에서 기본이 되는 것은 무엇이며, 반대가 맞는 경우는 언제인가.- 타임스탬프를 획득 시점에 기록해야 하는 이유를 tf2와 연결해 설명할 수 있는가.
stamp=0의 대가는? - 클럭 타입 불일치가 예외를 던지는 것이 왜 좋은 설계인가. 조용히 틀리는 경우는 언제인가.
- 안전 관련 노드에서 데이터 나이를 검사해야 하는 이유는?
- 다중 컴퓨터 로봇에서 시계 동기화가 안 됐을 때 나타나는 대표 증상 세 가지를 말할 수 있는가.
create_jump_callback의 세 번째 인자는 무엇이며 왜 기본값이 없는가(1.10.11).
다음 장 1.11은 시간과 짝을 이루는 관측 수단 — 로깅을 다룬다. 로거 계층, 레벨 제어, /rosout, 그리고 실시간 경로에서 로깅이 만드는 위험.