12시 방향의 0을 가리키는 바늘 하나짜리 시계 아래, 눈금자 왼쪽 끝 고리에서 오른쪽 끝까지 길게 뻗은 주황색 막대와 오른쪽 끝의 짧은 초록 막대, 그 사이에 마이너스 표시를 단 집게가 놓인 일러스트

ROS 2 use_sim_time 노드가 /clock을 받기 전 시각 0을 저장하면 첫 dt는 몇 초가 될까: 음수 검사를 통과하는 첫 주기 스파이크 계산하기


ROS 2 use_sim_time 노드가 /clock을 받기 전 시각 0을 저장하면 첫 dt는 몇 초가 될까. 답은 첫 /clock 시각 전체다. 시뮬레이션이 125.0초에서 시작했다면 첫 타이머 콜백의 dt(경과 시간, delta time)는 정상 주기 0.5초가 아니라 125초가 된다. 이 값은 양수라서 시간 역점프를 다룬 앞선 글에서 넣은 if dt < 0: 분기를 그대로 지나가고, 그 한 주기 동안 0.5 m/s로 적분한 거리는 0.25 m가 아니라 62.5 m로 기록된다.

뺄셈을 거꾸로 한 것도, 시계가 뒤로 간 것도 아니다. ROS 2 설계 문서가 /clock을 아직 받지 못한 노드의 ROS 시간을 0으로 정해 두었고, 그 0을 “초기화되지 않았다”는 신호로 다루라고 적어 두었을 뿐이다. 아래에서는 이 규칙을 dt·적분 거리·차분 속도·timeout 네 계산에 대입해 얼마나 틀어지는지 계산하고, 부호 검사와 별도로 필요한 시각 0 가드와 그 가드를 확인할 테스트 입력, 가드가 치르는 비용까지 정한다. 초 단위 사칙연산과 /clock이 시뮬레이터의 시각을 실어 나르는 토픽이라는 정도만 알면 따라올 수 있다. 근거는 공식 설계 문서·소스 읽기와 2026년 9월 27일 KST에 Python 3.12.3으로 돌린 산술 검산이며, ROS 노드·시뮬레이터·실물 로봇을 실행한 결과는 아니다.

/clock이 도착하기 전 노드의 ‘지금’은 0초다

먼저 용어 세 개를 맞춘다.

  • ROS 시간(ROS time, ROSTime): ROS 노드들이 함께 쓰는 논리적인 시각이다. use_sim_time이 꺼져 있으면 컴퓨터의 시스템 시각과 같다.
  • /clock 토픽: 시뮬레이터나 rosbag 재생기 같은 한 발행자가 “지금은 몇 초다”를 실어 보내는 토픽이다. 얼마나 자주, 어떤 해상도로 보낼지는 응용마다 다르다.
  • use_sim_time: 켜면 노드의 ROS 시간이 컴퓨터 시계 대신 /clock으로 받은 값을 따른다.

설계 문서에 따르면 use_sim_time이 켜진 노드의 ROS 시간은 시간 소스(Time Source)가 마지막으로 보고한 값을 돌려준다. 아직 아무것도 받지 못했다면 0을 돌려주며, 문서는 이어서 “A time value of zero should be considered an error meaning that time is uninitialized.”라고 적는다(ROS 2 Design — Clock and Time 원문, 고정 커밋 기준 L96-L98·L112-L117). 0은 “시뮬레이션이 0초 지점에 있다”가 아니라 “아직 시각을 모른다”는 뜻이다. 이 결정은 2017년 ros2/ros2 이슈 #350에서 ROS 1과 같은 방식으로 기록됐다.

아직 신호를 한 번도 받지 못한 전파시계가 00:00을 띄우고 있는 모습을 떠올리면 쉽다. 다만 비유는 여기까지다. 전파시계는 신호를 받은 뒤 스스로 초를 세지만, use_sim_time 노드의 ROS 시간은 스스로 흐르지 않고 마지막 /clock 값에 머문다. 10배속 시뮬레이션의 타임스탬프를 다룬 글에서 본 것처럼 이 노드의 ‘지금’은 곧 마지막으로 받은 /clock 값이다.

현재 구현도 같은 규칙을 따른다. 2026년 9월 27일에 아래 고정 커밋의 소스를 읽어 확인했다.

  • rclcpp는 ROS 시간을 켤 때 연결된 시계를 “zero or last recorded time”으로 맞추고, 마지막 메시지를 담는 캐시의 기본값은 0이다(time_source.cpp L45-L58, L183-L187).
  • rclpy는 “Zero time is a special value that means time is uninitialized”라는 주석과 함께 0 ns 시각으로 시작하고, /clock을 받을 때마다 값을 갱신한다(time_source.py L41-L42, L110-L123).
  • rcl의 rcl_clock_time_started()는 현재 시각이 0보다 클 때만 참을 돌려준다(time.c L75-L83).

이 0이 변수에 담기는 순간 문제가 생긴다. 노드 생성자에서 self.prev = self.get_clock().now()처럼 기준 시각을 잡아 두는 코드를 예로 들면, 그 줄이 실행될 때까지 /clock이 도착하지 않았다면 self.prev에는 0이 저장된다. 그 뒤 첫 /clock을 받고 첫 콜백이 돌면, 이 0이 ‘직전 시각’ 자리에 앉은 채로 계산이 시작된다.

125초에서 시작한 시뮬레이션의 첫 콜백을 계산한다

예제는 설명을 위해 만든 상황이며 특정 시뮬레이터나 로봇의 사양이 아니다.

  • 생성자에서 이전 시각 0과 위치 3.0 m를 함께 저장했다.
  • 첫 타이머 콜백이 실행될 때 받은 /clock은 125.0초다. 콜백 주기는 0.5초이므로 정상이라면 직전 기준 시각은 124.5초였어야 한다.
  • 로봇은 0.5 m/s로 움직였고, 실제로 흐른 시간은 한 주기(0.5초)라서 위치는 3.25 m가 됐다.
  • timeout은 “지금 − 마지막 기준 시각 ≥ 2초”이면 만료로 판정한다.

모든 시각은 시뮬레이션 초, 속도는 m/s, 거리는 m다. 입력값은 이진 부동소수점으로 정확히 표현되는 수로 골랐다.

계산 정상 (124.5 → 125.0) 기준 시각이 0 (0 → 125.0)
dt = 지금 − 이전 0.5 s 125 s (250배, 부호는 양수)
적분 거리 = 0.5 m/s × dt 0.25 m 62.5 m
차분 속도 = (3.25 − 3.0) m ÷ dt 0.5 m/s 0.002 m/s
2초 timeout 경과 0.5 s, 한도 미만이라 유지 경과 125 s가 한도를 123 s 초과해 만료
dt < 0 검사 False False

시뮬레이션 시간 축: 0초에 저장한 미초기화 이전 시각부터 125.0초 첫 콜백까지의 빨간 화살표 dt 125초와 124.5초에서 125.0초까지의 초록 화살표 정상 dt 0.5초 비교

같은 125초 dt가 두 계산을 반대 방향으로 비튼다. 속도에 dt를 곱하는 적분은 한 주기에 250배 커진다. 그러면 오도메트리(odometry, 바퀴 회전·관성 센서 등으로 조금씩 누적해 추정한 로봇 위치)에 62.5 m짜리 도약이 남는다. REP 105는 odom 프레임의 자세가 불연속 점프 없이 이어져야 한다고 정하는데, 이 도약은 바로 그 연속성을 깨는 한 번의 점프다. REP 105가 이 버그를 직접 다루지는 않으므로 이 연결은 이 글의 해석이다. 위치 변화를 dt로 나누는 차분 속도는 반대로 250배 작아져서, 실제로는 0.5 m/s로 움직인 로봇이 거의 멈춘 것처럼 보인다. timeout은 시작 유예 없이 첫 검사에서 바로 만료된다.

이 값들은 공개 저장소의 자체 예제 example.py로 계산했고, 결과와 해시는 검산 기록 verification.json에 남겼다. 2026년 9월 27일 00:45 KST에 Linux·Python 3.12.3 표준 라이브러리만으로 예제 디렉터리에서 python3 -I example.py를 다시 실행했을 때 종료 코드 0으로 assert 8개가 모두 통과했다. 기록된 값은 정상 dt 0.5, 미초기화 dt 125, 배율 250.0, 정상 거리 0.25, 스파이크 거리 62.5, 차분 속도 0.002, timeout 초과 123, 가드 적용 후 dt 0.5다. 표의 dt < 0 판정과 아래 가드의 건너뜀 판정은 이 파일이 아니라 네 번째 절의 실행 블록으로 확인한다.

dt < 0 검사는 부호만 보기 때문에 0을 놓친다

역점프 검사가 잡는 것은 “지금이 이전보다 앞선 시각”이다. rosbag을 되감거나 시뮬레이터를 리셋해 시간이 뒤로 가면 dt가 음수가 되므로 dt < 0이 정확히 그 사건을 가려낸다.

초기화되지 않은 0은 반대편에서 온다. 0은 가능한 가장 이른 시각이라 그 뒤의 어떤 /clock 값을 빼도 dt는 양수가 된다. 125 − 0 = 125이고 125 < 0은 False다. 검사는 형식상 정상적으로 동작했지만 질문 자체가 달랐다. 역점프 검사는 “시간이 거꾸로 갔는가”를 물었고, 여기서 틀린 것은 “기준점이 실제 시각이었는가”다.

콜백이 실행될 때까지도 /clock이 오지 않아 이전 시각과 현재 시각이 모두 0인 경우도 있다. 그러면 dt는 0이고 0 < 0도 False라 역시 통과한다. 적분 거리는 0이 되고, 차분 속도는 0으로 나누게 된다. Python의 float 나눗셈에서는 이것이 ZeroDivisionError가 되며(아래 실행 블록 7번째 줄), C++의 double에서 어떤 값이 나오는지는 확인하지 않았다. 그래서 검사 조건은 “이전 시각이 0인가”만이 아니라 “이전 또는 현재 시각이 0인가”여야 한다.

dt 상한을 두어 1초 이상은 자르는 식으로 막을 수 있을 것처럼 보인다. 그러나 앞의 표에서 적분은 과대, 차분은 과소로 틀어졌다. 이런 상황에서 상한은 오차의 크기를 줄일 뿐, 기준점이 실제 시각이 아니었다는 사실은 그대로 남긴다고 이 글은 해석한다. 상한 방식 자체를 검산하지는 않았지만, 원인이 “시각이 0″이라면 검사도 그 값을 직접 보는 편이 맞다.

0이면 계산하지 않고 기준점만 옮긴다

아래 guarded_dt는 이 글에서 구성한 가드이며 ROS 2의 공식 권장 코드가 아니다. 공식 근거는 “0을 미초기화 오류로 보라”는 설계 규칙과 뒤에 나올 started() 정의다. 이전·현재 시각 중 하나라도 0 이하면 계산을 건너뛰고, 그렇지 않을 때만 기존 역점프 분기를 거쳐 dt를 돌려준다. 0 가드가 역점프 분기보다 앞에 온다.

이 블록은 ROS API를 부르지 않는 순수 산술이라 ROS 2가 설치돼 있지 않아도 된다. Python 3(검증 3.12.3)만 있으면 되고 설치할 패키지·파일·가상환경은 없다. bash처럼 heredoc을 지원하는 셸에 그대로 붙여 넣는다.

python3 -I - <<'PY'; echo "exit=$?"
def guarded_dt(prev, now):
    if prev <= 0 or now <= 0:   # 0은 /clock 수신 전 미초기화 값: 계산하지 않는다
        return "skip"
    dt = now - prev
    if dt < 0:                  # 기존 역점프 분기
        return f"backward {dt}"
    return dt

cases = [(0.0, 125.0), (125.0, 125.5), (0.0, 0.0), (130.0, 125.0), (0.0, 0.5), (0.5, 1.0)]
for prev, now in cases:
    print(f"prev={prev} now={now} dt={now - prev} dt<0={now - prev < 0} guard={guarded_dt(prev, now)}")

try:
    print((3.25 - 3.0) / (0.0 - 0.0))
except ZeroDivisionError as error:
    print(f"ZeroDivisionError: {error}")

assert guarded_dt(0.0, 125.0) == "skip"
assert guarded_dt(125.0, 125.5) == 0.5
assert guarded_dt(0.0, 0.0) == "skip"
assert guarded_dt(130.0, 125.0) == "backward -5.0"
PY

2026년 9월 27일 01:06 KST에 Linux·Python 3.12.3에서 이 블록을 그대로 실행한 출력 전체다.

prev=0.0 now=125.0 dt=125.0 dt<0=False guard=skip
prev=125.0 now=125.5 dt=0.5 dt<0=False guard=0.5
prev=0.0 now=0.0 dt=0.0 dt<0=False guard=skip
prev=130.0 now=125.0 dt=-5.0 dt<0=True guard=backward -5.0
prev=0.0 now=0.5 dt=0.5 dt<0=False guard=skip
prev=0.5 now=1.0 dt=0.5 dt<0=False guard=0.5
ZeroDivisionError: float division by zero
exit=0

dt<0 열과 guard 열을 나란히 보면 두 검사가 무엇을 잡는지 갈린다. 각 줄이 곧 단위 테스트 입력이다.

  • (a) 1번째 줄, 이전 0 → 현재 125.0: dt<0=False라 부호 검사는 통과하지만 가드는 skip한다.
  • (b) 2번째 줄, 125.0 → 125.5: 가드를 지나 dt 0.5초로 정상 계산한다.
  • (c) 3번째 줄, 0 → 0: dt 0.0도 dt<0=False다. 가드가 건너뛰지 않으면 7번째 줄처럼 차분 속도에서 0으로 나누게 된다.
  • (d) 4번째 줄, 130.0 → 125.0: 0 가드와 무관하게 기존 역점프 분기로 간다.

exit=0은 마지막 assert 네 개가 모두 통과했다는 뜻이다. 가드는 판정만 하므로, 호출하는 콜백은 skip이든 아니든 끝에서 prev = now로 기준점을 옮겨야 다음 콜백부터 정상 dt가 나온다. 5·6번째 줄은 다음 절의 확인 문제에서 쓴다.

가드를 빼면 테스트 (a)가 어떻게 실패하는지도 같은 환경(01:06 KST)에서 확인했다. 역점프 분기만 남긴 함수다.

python3 -I - <<'PY'; echo "exit=$?"
def sign_only_dt(prev, now):
    dt = now - prev
    if dt < 0:                  # 역점프 분기만 있는 버전
        return f"backward {dt}"
    return dt

print(f"prev=0.0 now=125.0 result={sign_only_dt(0.0, 125.0)}")
assert sign_only_dt(0.0, 125.0) == "skip", "0 -> 125 was not skipped"
PY
prev=0.0 now=125.0 result=125.0
Traceback (most recent call last):
  File "<stdin>", line 8, in <module>
AssertionError: 0 -> 125 was not skipped
exit=1

부호 검사만 있으면 0 → 125가 dt 125.0으로 그대로 계산되고 테스트 (a)는 종료 코드 1로 실패한다. 자기 노드에 테스트를 붙였을 때 이 실패가 나오면 0 가드가 빠진 것이다.

실제 노드에서는 prev와 now가 float가 아니라 ROS Time 객체이므로 비교 방식이 달라진다. “0 이하”라는 기준은 rcl이 시작 여부를 판정하는 > 0과 같은 경계다. rclcpp에는 같은 판단을 하는 Clock::started()와, 시작될 때까지 기다리는 wait_until_started(timeout)이 있다. 헤더 주석은 이 대기 timeout을 “steady time”으로 잰다고 적는다. 여기서 SteadyTime(단조 시간)은 /clock과 무관하게 되돌아가지 않고 계속 증가만 하는 시계다(rcl time.h L57-L59). 이 선언은 Humble·Jazzy·Kilted·Rolling 헤더에서 확인했다(clock.hpp L147-L190 rolling, humble, Jazzy API 문서). rclpy rolling의 clock.py를 검색한 범위에서는 같은 이름의 메서드를 찾지 못했으므로, rclpy 노드라면 시각 객체의 nanoseconds가 0인지 직접 비교하는 방식이 남는다.

스스로 판정해 볼 세 입력

가드를 넣은 뒤라고 생각하고 답을 먼저 정해 본다.

  1. 시뮬레이터가 실제로 0초에서 시작해 0.5초 간격으로 /clock을 보낸다. 이전 0, 현재 0.5인 콜백과 그다음 이전 0.5, 현재 1.0인 콜백에서 가드는 각각 무엇을 할까.
  2. 가드 없이 이전 0, 현재 0인 콜백이 실행됐다. dt < 0 검사, 적분, 차분 속도는 각각 어떻게 될까.
  3. 이전과 현재 시각이 모두 0보다 크고, 현재가 이전보다 작다. 이 입력은 어느 분기에서 걸러질까.

1번은 0 → 0.5를 건너뛰고 기준점만 0.5로 옮긴 뒤, 0.5 → 1.0부터 dt 0.5초로 계산한다(실행 블록 5·6번째 줄). 이전 값이 0이라는 사실만으로는 “진짜 0초”와 “아직 모름”을 구분할 수 없기 때문이다. 유효한 첫 주기 하나가 버려진다. 2번은 dt가 0이라 dt < 0을 통과하고, 적분 거리는 0, 차분 속도는 0으로 나누게 된다(3·7번째 줄). 3번은 두 값 모두 0보다 커서 0 가드를 지나고, 음수 dt가 되어 기존 역점프 분기에서 걸린다(4번째 줄). 1번의 답이 바로 이 가드의 비용이다.

가드가 치르는 비용: 진짜 0초의 첫 샘플

값 0만 보고 판단하는 한 이 손실은 피할 수 없다. 라이브러리도 같은 선택을 했다. rclcpp의 started()는 “non-zero time”을 뜻하도록 정의되어 있고, rcl 헤더는 이 함수의 주 용도로 시뮬레이터가 일시정지(paused) 상태로 초기화되면 다시 시작될 때까지 ROS 시간이 0일 수 있는 상황을 설명한다(rcl time.h L171-L195).

rosbag2도 같은 문제를 같은 방식으로 풀었다. 2023년 6월 7일 병합된 PR #1354 “Don’t record sim-time messages before first /clock”는 시뮬레이션 시간을 쓸 때 현재 시각이 0이 아니게 될 때까지 기록하지 않도록 바꾸면서, 일부 메시지를 잃을 수 있지만 “more correct”하다고 비용을 밝혔다. 현재 README도 “Until the first /clock message is received, the recorder will not write any messages.”라고 적는다(README L100-L101, 관련 issue #1276).

첫 주기 하나를 잃어도 되는 적분·속도 추정이라면 이 비용은 작다. 첫 샘플까지 반드시 써야 하는 응용이라면, 검증하지 않은 설계 아이디어로서 값 0 검사 대신 “첫 /clock을 받았는가”를 별도 상태로 두는 방식을 검토할 수 있다.

계산이 말해 주는 곳과 말하지 않는 곳

첫 dt가 첫 /clock 시각 전체가 된다는 구조는 예제 값이 바뀌어도 같다. 첫 콜백이 3.0초라면 dt는 3.0초다. 반면 250배라는 배율은 125초와 0.5초 주기라는 가정에서 나온 숫자다. 이 글이 확인한 것은 공식 설계 문서와 소스의 해당 줄, 그리고 Python 산술까지다. ROS 2 노드와 /clock 발행자, 시뮬레이터, rosbag2 재생은 실행하지 않았다. 따라서 실제 배포판에서 첫 타이머 콜백이 첫 /clock보다 먼저 호출되는지, 첫 /clock이 언제 도착하는지, ROS 시간이 0일 때 타이머가 어떻게 스케줄되는지는 확인하지 않았다. 이 순서는 executor(실행기: 구독·타이머 같은 콜백을 언제 호출할지 정하는 ROS 2의 실행 관리자, ROS 2 Executors 문서)와 시뮬레이터, 배포판에 따라 다를 수 있다. 설계 문서는 설계 의도를 적은 문서이고(저장소 최종 파일 변경 2021년 10월 1일), 구현은 위 커밋의 소스 줄로만 대조했으므로 과거나 미래 배포판의 동작까지 보증하지는 않는다.

여기서 다룬 timeout은 ROS 시간으로 판정하는 시뮬레이션 로직의 timeout이다. 설계 문서는 SteadyTime의 용례로 하드웨어 timeout이 있는 주변장치와 통신하는 하드웨어 드라이버를 들고, 그런 노드는 SteadyTime 정보를 구현 안에 격리하라고 적는다(설계 L100-L104). 이 글은 한 걸음 더 나아가 watchdog(감시 타이머: 정해진 시간 안에 “살아 있다”는 신호가 오지 않으면 멈춤 같은 안전 동작을 일으키는 장치)과 안전 정지도 SteadyTime 쪽에 두고, ROS 시간 가드로 대신하지 않아야 한다고 판단한다. ROS 시간은 /clock 전에는 0이고 뒤로 갈 수도 있지만 SteadyTime은 단조 증가하기 때문이다.

두 오류는 위험의 방향도 다르다. 시각 0 때문에 명령 timeout이 즉시 만료되는 쪽은 로봇을 세우는 방향이라 시작 직후 오경보에 가깝다. 반면 62.5 m 적분 도약은 누적 위치 추정을 조용히 오염시킨다. 이 계산만 근거로 실물 로봇의 정지·제어 로직을 바꾸지 말고, 실물에 옮기기 전에 시뮬레이션에서 콜백 순서를 먼저 관측하되 ROS 시간에 의존하지 않는 하드웨어 비상정지는 그대로 유지한다.

use_sim_time을 켠 채 타이머 콜백에서 이전 시각을 저장하고 적분·차분·경과 판정을 직접 한다면, 역점프 분기가 이미 있더라도 0 가드는 별도로 필요하다. 먼저 이전 시각을 저장하는 지점 하나를 골라 (a) 0 → 125.0은 건너뛰고 (b) 125.0 → 125.5는 dt 0.5초를 내는지 확인하는 테스트 두 개를 추가해 보자. 테스트가 통과하면 그다음 판단은 첫 샘플 하나를 버려도 되는지다. 버릴 수 없다면 값 0 대신 첫 /clock 수신 여부를 상태로 두는 쪽을 검토한다.

제휴·협찬은 없다.

비슷한 글

답글 남기기

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