AEGLOSLAB ROS 2 Jazzy 대전

제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) — 인자가 셋이고 임계값은 생략할 수 없다.
  • Rate vs 타이머 — 왜 대부분의 경우 타이머가 옳은가.
  • 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 구독 TimeSource가 ROS_TIME 시계 갱신 node->now() 가 /clock 값을 반환 /clock이 안 오면 시간이 0에 멈춤
  • 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 함정 카탈로그

#함정증상대처
1use_sim_time=true인데 /clock 없음시간 0에 멈춤, 노드 무동작시뮬·bag의 /clock 발행 확인
2일부 노드만 use_sim_time시간대 불일치·tf 오류/**:로 전체 적용(1.9.7)
3시뮬에서 create_wall_timer주기와 스탬프 불일치노드 시계 기반 타이머
4경과 시간을 벽시계로 측정NTP 보정 시 음수·폭주STEADY_TIME
5dt 검증 없이 적분점프 시 발산방어적 dt(1.10.4)
6bag 루프에서 상태 미초기화매 루프마다 이상 동작점프 콜백
7Rate를 콜백 안에서executor 정지타이머로 전환
8발행 시점에 스탬프tf 변환 오차획득 시점 보존
9stamp=0 사용시간 정합성 상실실제 시각 사용
10클럭 타입 불일치예외 또는 조용한 오류타입 명시 변환
11데이터 나이 미검사묵은 데이터로 제어age check(1.10.7)
12다중 머신 시계 미동기화외삽 오류·동기화 실패chrony·PTP
13타이머 주기를 dt로 가정누적 오차실측 dt 사용
14QoS 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, 그리고 실시간 경로에서 로깅이 만드는 위험.