launch 파일에서 rosbag2 녹화를 시작하려면: ros2 bag record를 프로세스로 띄울 때 시작 지연과 첫 /clock 전까지 기록되지 않는 메시지 수 계산하기
로봇 노드와 ros2 bag record를 launch 파일 하나에 넣으면 bag(rosbag2가 토픽 메시지를 저장한 녹화 파일)은 로봇이 첫 메시지를 보낸 순간부터 채워질까. rosbag2 README는 launch 파일에서 rosbag2 녹화를 시작하려면 ros2 bag record 명령을 노드가 아닌 프로세스(ExecuteProcess)로 띄우라고 먼저 안내한다. 그렇게 뜬 녹화기는 토픽 구독을 만든 뒤에야 메시지를 받는다. 그래서 “같이 시작했다”와 “녹화가 시작됐다” 사이에 틈이 생기고, 그 틈에 발행된 메시지는 bag에 남지 않는다. 2020년 ROS Answers에 올라온 질문도 launch 하나로 녹화까지 시작하는 방법을 물었다.
틈의 크기는 “토픽 주기 × 녹화기가 구독하기 전까지 흐른 시간”으로 미리 셀 수 있다. 다만 ROS 2 Jazzy 컨테이너에서 같은 구성을 5회 돌려 보니 이 곱셈에 단서가 붙었다. TimerAction의 2.0초를 그대로 넣어 센 30 Hz 카메라 60개는 하한이었고, 실제로 빠진 수는 66~110개였다. 녹화기 프로세스가 뜨고 구독을 만드는 시간이 timer 바깥에서 더해졌기 때문이다. 반대로 --use-sim-time으로 첫 /clock을 3초 기다리는 동안 발행된 90개는 5회 모두 bag에 하나도 없어 계산과 그대로 맞았다.
측정은 2026-10-19에 고정 이미지 ros:jazzy-ros-base의 1 CPU 컨테이너에서 정수 하나짜리 메시지로 했다. 녹화기는 ros2 bag record CLI 프로세스로만 띄웠고, 시뮬레이터와 실물 로봇은 쓰지 않았다. 따라오는 데는 토픽 발행·구독과 “Hz × 초 = 메시지 수” 곱셈이면 충분하다.
ros2 bag record CLI는 launch에서 프로세스로 띄운다
런치 파일(launch file)은 여러 노드와 프로그램을 한 번에 시작하는 설정 파일이다. ROS 2 공식 launch 튜토리얼은 “Launch files are used to start nodes, services and execute processes.”라고 쓴다(Using-Substitutions.rst L39). 녹화기에 해당하는 것은 마지막의 “execute processes”다.
ros2 bag record는 패키지에 든 노드 실행 파일이 아니고 ros2 명령의 하위 동사(verb)다. 그래서 rosbag2 README의 “Using in launch” 절은 이 명령을 node 액션 대신 executable로 부르라고 안내한다(rosbag2 README L672-L702). Jazzy README에도 같은 절이 L365-L395에 있다.
여기서 액션(action)은 launch가 실행할 일 하나를 말한다. 노드 실행(Node), 프로세스 실행(ExecuteProcess), 지연 실행(TimerAction)이 모두 launch의 액션이다. ROS 2 통신 방식인 ‘액션’과는 이름만 같다.
README의 예에서 핵심 줄만 옮기면 다음과 같다.
<executable cmd="ros2 bag record -a" output="screen" />
launch.actions.ExecuteProcess(cmd=['ros2', 'bag', 'record', '-a'], output='screen')
-a는 모든 토픽을 녹화한다. 토픽을 고르려면 배포판 문법부터 확인한다. Humble은 ros2 bag record /cam /imu처럼 위치 인자를 쓴다(Humble README L83-L86). Jazzy와 Kilted는 --topics /cam /imu가 정식이고 위치 인자에는 “(deprecated)”가 붙는다(Jazzy record.py L64-L72). Rolling에는 --topics만 남아 있다. 자기 설치본의 옵션은 ros2 bag record --help가 가장 정확하다.
같은 README는 바로 다음 문단에서 다른 방법도 소개한다. rosbag2_transport 패키지의 record·play 기능을 launch.actions.Node로 직접 띄우는 방법이다. README는 그 장점을 “the Python layer handling the ros2 bag CLI is completely skipped”라고 적고, 예시 코드로는 player를 띄운다(README L705-L721, Jazzy는 L398-L414). 이어지는 절은 녹화기와 재생기를 컴포넌트 컨테이너(component container, 여러 C++ 노드를 한 프로세스에 함께 올리는 실행기)에 올리는 방법을 다룬다(README L723, Jazzy는 L416). 구독이 생기기 전 메시지를 받지 못한다는 아래 원리는 어느 방식에도 해당하지만, Python CLI 층이 빠지면 구독까지 걸리는 시간은 달라질 수 있고 그 차이는 이번 실측 밖이다.
아래는 이 글의 실험 스크립트가 실제로 만들어 실행한 launch 파일(사례 B)을 읽기 쉽게 줄인 것이다. 로봇 노드 역할은 /cam(30 Hz 카메라 역할)과 /imu(200 Hz, IMU는 가속도와 회전 속도를 재는 관성 측정 장치 Inertial Measurement Unit)를 발행하는 Python 스크립트가 맡고, 실제 파일에는 스크립트 경로와 설정 인자가 더 붙는다. 토픽 지정은 Jazzy 문법이다.
from launch import LaunchDescription
from launch.actions import ExecuteProcess, TimerAction
def generate_launch_description():
robot = ExecuteProcess(cmd=['python3', 'robot.py'], output='screen')
recorder = ExecuteProcess(
cmd=['ros2', 'bag', 'record', '-o', 'bag', '--topics', '/cam', '/imu', '/tf_static'],
output='screen')
return LaunchDescription([robot, TimerAction(period=2.0, actions=[recorder])])
TimerAction(period=2.0, ...)은 공식 튜토리얼 예제가 같은 형태로 ExecuteProcess를 늦추는 데서 가져왔다(example_substitutions_launch.py L51-L72). 녹화기를 늦추면 무엇이 빠지는지 보려는 대조 조건이지 권장 구성은 아니다. 이 timer는 벽시계(wall clock, 컴퓨터의 실제 시계)로 기다린다. 구현이 asyncio.wait(..., timeout=period)이기 때문이다(timer_action.py L113-L115). ROS 시간으로 기다리는 쪽은 launch_ros의 ROSTimer이고, docstring은 sim time을 쓰려면 SetUseSimTime 액션도 필요하다고 적는다(ros_timer.py L39-L46). ROSTimer는 이번에 실행하지 않았다.
같은 launch여도 bag은 녹화기가 구독한 순간부터 쌓인다
녹화기(recorder)는 ros2 bag record가 만드는 ROS 노드다. launch가 녹화기 프로세스를 시작해도 곧바로 메시지가 들어오지는 않는다. 프로세스가 뜨고, 노드를 만들고, 토픽 탐색(topic discovery, 녹화할 토픽을 ROS 그래프에서 찾아 구독을 만드는 과정)을 거쳐 구독이 생겨야 한다. Jazzy에서 탐색 간격 -p/--polling-interval의 기본값은 100 ms다(record.py L125-L127).
구독 전에 나간 메시지가 사라지는 이유는 QoS(Quality of Service, 토픽 통신 방식을 정하는 설정)의 내구성(durability)에 있다. 내구성은 발행자가 이미 보낸 메시지를 늦게 붙은 구독자에게도 줄지 정한다. 기본값인 volatile(휘발성)은 보관하지 않는다. QoS 문서의 호환표에서 volatile 발행자와 volatile 구독자의 조합은 “New messages only”다(About-Quality-of-Service-Settings.rst L179-L206). 센서 토픽 대부분이 이 경우에 해당하므로 녹화기 구독이 생기기 전의 메시지는 녹화기에 도착하지 않는다.
방송이 시작된 뒤 라디오를 켠 상황과 비슷하다. 켜기 전에 나간 방송은 다시 들을 수 없다. 이 비유는 volatile 토픽에서만 맞는다. ROS 2에는 발행자가 메시지를 보관했다가 늦게 온 구독자에게 건네는 설정도 있고, 이것은 아래 /tf_static에서 다룬다.
그래서 빠지는 수는 다음처럼 센다.
빠지는 메시지 수 ≈ 토픽 주기(Hz, 메시지/초) × 발행이 시작된 뒤 녹화기 구독이 생기기까지 걸린 시간(초)
계산하기 쉽게 고른 가정값으로 넣어 보면 이렇다. 30 Hz와 200 Hz, 2초와 0.5초는 특정 장치에서 잰 값이 아니다.
| 상황 | 식 | 구독 전 발행 수 |
|---|---|---|
| 30 Hz 카메라, 녹화기를 timer로 2초 늦춤 | 30 × 2.0 | 60 |
| 200 Hz IMU, 같은 2초 | 200 × 2.0 | 400 |
| 카메라와 IMU를 함께 녹화 | (30 + 200) × 2.0 | 460 |
| 카메라, 지연을 0.5초로 줄임 | 30 × 0.5 | 15 |
이 산술은 Python 3.12.3에서 python3 -I example.py로 검산했고 종료 코드 0으로 기대값과 모두 일치했다. 검산 코드는 example.py, 식·기대값·실제값 기록은 verification.json에 있다. 이 검산은 곱셈 확인이지 ROS 실행이 아니며, 녹화기가 timer가 끝나자마자 구독을 마친다고 가정한다. 실제 녹화기에서 그 가정이 얼마나 어긋나는지는 아래 실측에서 본다.

–use-sim-time이면 첫 /clock 전에는 구독도 만들지 않는다
시뮬레이션 시간(simulation time, sim time)은 컴퓨터 시계 대신 /clock 토픽으로 받은 값을 현재 시각으로 쓰는 방식이다. Gazebo 같은 시뮬레이터가 /clock을 발행한다. README는 --use-sim-time을 주면 bag에 쓰는 시각이 마지막으로 받은 /clock 값이 되고, “Until the first /clock message is received, the recorder will not write any messages.”라고 적는다. 그 전 시각은 0이라서, 시각 0인 메시지 뒤에 시각 N인 메시지가 이어지면 bag이 “essentially unplayable”해지기 때문이다(README L94-L101).
소스는 README보다 한 단계 더 이르다. 녹화기의 탐색 함수는 sim time이면 “use_sim_time set, waiting for /clock before starting recording…”을 로그로 남기고, 첫 /clock이 올 때까지 기다린 뒤 “Sim time /clock found, starting recording.”을 찍고 나서야 구독 생성 루프로 들어간다. 주석도 “wait until /clock topic received before even creating subscriptions”다. Humble·Jazzy·Rolling이 같은 구조다(Rolling recorder.cpp L1532-L1548, Jazzy L636-L652, Humble L279-L295). volatile 토픽이라면 첫 /clock 전 메시지는 녹화기에 도착하지도 않는다.
계산은 앞과 같다. 녹화기가 뜬 뒤 첫 /clock이 3초 뒤에 오면 30 Hz 카메라 메시지 30 × 3 = 90개가 bag에 없다. 녹화기를 60초 켜 두었다면 3 / 60 = 0.05, 즉 5%가 담기지 않는다. 이 5%는 bag 중간의 구멍이 아니라 bag 시작이 3초 늦어진 것이다. 첫 /clock이 녹화기와 동시에 오면 30 × 0 = 0이다.
90개라는 답에는 조건이 있다. 카메라가 /clock과 상관없이 이미 30 Hz로 발행 중이어야 한다. sim time을 쓰지 않는 드라이버라면 그럴 수 있다. 시뮬레이터가 센서와 /clock을 함께 내보낸다면 /clock 전에는 카메라 메시지도 없을 가능성이 높고, 그때 잃는 수는 0에 가깝다. 자기 설치본이 이렇게 동작하는지는 ros2 bag record --help의 --use-sim-time 설명에 “Until first /clock message is received, no messages will be written to bag.”이 있는지로 확인한다(Jazzy record.py L166-L168). sim time을 쓰는 다른 노드가 /clock 전 시각 0을 저장할 때 생기는 문제는 ROS 2 use_sim_time 노드가 /clock을 받기 전 시각 0을 저장하면 첫 dt는 몇 초가 될까에서 따로 다뤘다.
/tf_static은 개수보다 내구성 설정이 남길지를 정한다
/tf_static은 로봇 부품 사이의 고정된 위치 관계, 예를 들어 base_link에서 camera_link까지의 변환을 보통 한 번만 발행하는 토픽이다. 한 번뿐이니 주기 × 시간으로 셀 대상이 아니다. 결과는 0개 아니면 1개이고, 그 둘을 가르는 것이 내구성이다.
tf2의 정적 브로드캐스터는 transient local로 발행하고(tf2_ros qos.cpp L53-L57), 보관 개수(depth)의 기본값은 1이다(tf2_ros qos.hpp L60). transient local(트랜지언트 로컬)은 발행자가 보낸 메시지를 보관해 두었다가 늦게 붙은 구독자에게도 주는 내구성이다. ROS 1의 latching과 비슷해 이런 메시지를 latched 메시지라고 부른다. 같은 호환표에서 transient local 발행자와 transient local 구독자는 “New and old messages”, volatile 구독자와 만나면 “New messages only”다.
녹화기는 구독 QoS를 발행자들에 맞춰 고른다. adapt_request_to_offers는 모든 발행자가 transient local을 제공할 때만 transient local로 요청하고, 일부만 그렇다면 volatile로 내려가면서 “Previously-published latched messages will not be retrieved.” 경고를 남긴다(Rolling qos.cpp L389-L402, Jazzy L386-L399). 녹화기가 구독할 때 발행자가 이미 있어야 이 규칙이 적용된다는 점도 기억해 둔다. 발행자가 하나도 없을 때의 결과는 이번에 확인하지 않았다.
여기에 사람이 개입하는 지점이 --qos-profile-overrides-path다. YAML로 토픽별 QoS를 덮어쓰는 옵션인데, README의 예시 프로필은 durability: volatile이다(README L633-L667). 이 예를 /tf_static에 그대로 옮기면 녹화기는 volatile로 구독하고, 늦게 뜬 녹화기는 보관된 1개를 받지 못한다. Rolling README는 다른 옵션(--repeat-transient-local)을 설명하는 자리에서 “the QoS override takes precedence. Ensure the override includes transient_local durability to receive latched messages.”라고 적는다(README L157-L159). 이 문장은 Jazzy·Humble README에는 없다.
검산 기록의 마지막 두 사례가 이 차이다. 녹화기가 transient local로 구독하면 빠지는 수는 1 − 1 = 0, override가 transient_local을 빠뜨리면 1 − 0 = 1이다. 0이 되려면 녹화기가 구독할 때 발행 노드가 살아 있어야 한다. 보관 책임이 발행자에게 있어서(“the publisher becomes responsible for persisting samples”, QoS 문서 L63-L66) 발행 노드가 먼저 끝나면 받아 올 곳이 없다. 늦게 켠 노드가 transient local 메시지를 받을 때 그 메시지가 얼마나 오래된 것인지는 ROS 2 로봇 상태 토픽을 transient local로 남기면 늦게 켠 노드는 몇 초 전 데이터를 받을까에서 이어서 볼 수 있다.
Jazzy 컨테이너에서 네 구성을 5회씩 돌려 계산과 맞춰 봤다
계산이 실제 녹화기에서도 맞는지 보려고 네 가지 launch 구성을 컨테이너에서 실행했다. 실험 스크립트는 사례 하나를 이렇게 처리한다.
- 사례마다 Python launch 파일을 새로 만들어
ros2 launch로 띄운다. - 로봇 역할 스크립트가
/cam(30 Hz)과/imu(200 Hz, A·B만)에 0부터 1씩 늘어나는 일련번호를 실어 보낸다. C를 뺀 세 사례(A·B·D)에서는 노드를 만든 직후, 일련번호를 보내기 전에StaticTransformBroadcaster로/tf_static1개를 보내고 녹화 토픽에도/tf_static을 넣는다. C에는/tf_static발행자도 녹화 대상도 없다. - 발행할 때마다 자기 발행자의 구독 수(
get_subscription_count())를 확인해, 녹화기 구독이 처음 보인 시각과 그때까지 보낸 개수를 남긴다. 시각은 단조 시계(monotonic clock, 시스템 시각을 바꿔도 뒤로 가지 않는 시계)로 잰다. - sim time 사례에서는 스크립트가 가짜
/clock을 직접 보낸다. 녹화기 노드가/clock을 구독한 것을 확인하고 3초 뒤에, sim time 100초 지점부터 보내기 시작한다. - 발행이 끝나면 launch 전체에 SIGINT(Ctrl+C와 같은 종료 신호)를 보낸다.
- rosbag2_py로 bag을 열어 토픽별 개수와 첫 일련번호, 그 뒤에 빠진 번호를 센다.
실행 환경은 ros:jazzy-ros-base 이미지를 digest(이미지 내용을 가리키는 고정 해시)로 고정한 컨테이너이고 네트워크 없음, 1 CPU, 1 GiB 제한이다. 기본 RMW(ROS middleware, ROS 2가 실제 통신 라이브러리를 끼워 쓰는 층)인 rmw_fastrtps_cpp, Python 3.12.3, rclpy 7.1.12를 썼다. 하네스가 같은 스크립트를 5회 실행했고 5회 모두 종료 코드 0이었다. 재현 스크립트와 실행별 원본 출력은 실험 디렉터리에 공개돼 있다. 이 글의 수치를 만든 실행본은 커밋 3be5bba에 고정된 experiment.py와 results.json이고, results.json에는 스크립트의 SHA-256(e4138b37ec03ad7b1d9821384abf88397d3f2806b152366041c9095c5b09d708)이 함께 기록돼 있다.
| 사례 | launch 구성 | 계산(예측) | 실측 5회 |
|---|---|---|---|
| A | 녹화기를 지연 없이 같은 launch에 | 하한 0 | /cam 1개(5회 모두), /imu 5~7개 |
| B | TimerAction(period=2.0) 안에 녹화기 |
/cam 60 이상, /imu 400 이상, 합계 460 이상 |
4회는 /cam 66~72개, /imu 439~475개. 4회차는 /cam 110개, /imu 727개 |
| C | --use-sim-time, 녹화기 노드 확인 3.0초 뒤 첫 /clock |
기다리는 동안의 /cam 90개 기록 안 됨 |
기다리는 동안 발행된 90개 중 bag에 0개(5회 모두) |
| D | /tf_static을 durability: volatile로 override, timer 1.0초 |
/tf_static 1개 누락 |
/tf_static bag에 0개(5회 모두). 같은 /tf_static이 A·B에서는 1개 |
모든 사례, 모든 회차에서 bag에서 빠진 수(missing_from_bag)는 녹화기 구독 전에 발행된 수(published_before_recorder_subscribed)와 같았다. bag 속 첫 일련번호 뒤로 빠진 번호(holes_after_first_seq)는 모두 0이었다. 메시지는 bag 앞쪽에서만 빠졌다.
시각을 보면 차이가 어디서 왔는지 드러난다.
- B의 녹화기 구독은 launch 시작 기준 2.768~2.978초(4회)와 4.227초(4회차)에 생겼다. 로봇 첫 발행 기준으로는 2.193~2.374초(4회)와 3.635초(4회차)다.
- B와 D에서 로봇 스크립트의 첫 발행은 launch 시작 0.573~0.604초 뒤였다. A에서는 1.702~1.735초 뒤였고, 녹화기 구독은 그로부터 0.026~0.033초 뒤에 생겼다.
- C에서 녹화기의
/cam구독은 5회 모두 첫/clock뒤 0.006~0.011초에 생겼다(cam_subscription_existed_before_first_clock: false). 첫/clock전까지 발행된/cam은 93~95개였고 그중 bag에 들어간 것은 0개다. launch 출력에는 “waiting for /clock before starting recording”과 “Sim time /clock found, starting recording”이 5회 모두 찍혔다. bag의 첫/cam기록 시각은 sim time 100.0~100.03초였고 첫/clock값은 100.0초다. - D의
/cam은 timer 1.0초로 센 30.0개보다 많은 36~37개가 빠졌다. “latched messages will not be retrieved” 경고는 5회 모두 launch 출력에 없었다. - SIGINT 뒤 launch는 종료 코드 0으로 끝났지만, launch 로그에는 녹화기가 매번 “process has died … exit code 2″로 찍혔다. 그래도 20개 bag이 모두 mcap(rosbag2가 지원하는 저장 형식 중 하나) 형식으로 정상적으로 열렸다.
1회차와 B가 가장 늦었던 4회차의 요약 줄 원문이다.
{"summary": true, "total_seconds": 19.05, "cases": {"A_same_launch_no_timer": {"missing": {"/cam": 1, "/imu": 6}, "published_before_subscribed": {"/cam": 1, "/imu": 6}, "tf_static_in_bag": 1, "bag_ok": true}, "B_timer_2s": {"missing": {"/cam": 67, "/imu": 441}, "published_before_subscribed": {"/cam": 67, "/imu": 441}, "tf_static_in_bag": 1, "bag_ok": true}, "C_use_sim_time_first_clock_3s": {"missing": {"/cam": 94}, "published_before_subscribed": {"/cam": 94}, "tf_static_in_bag": null, "bag_ok": true}, "D_override_volatile_tf_static": {"missing": {"/cam": 37}, "published_before_subscribed": {"/cam": 37}, "tf_static_in_bag": 0, "bag_ok": true}}}
{"summary": true, "total_seconds": 20.61, "cases": {"A_same_launch_no_timer": {"missing": {"/cam": 1, "/imu": 7}, "published_before_subscribed": {"/cam": 1, "/imu": 7}, "tf_static_in_bag": 1, "bag_ok": true}, "B_timer_2s": {"missing": {"/cam": 110, "/imu": 727}, "published_before_subscribed": {"/cam": 110, "/imu": 727}, "tf_static_in_bag": 1, "bag_ok": true}, "C_use_sim_time_first_clock_3s": {"missing": {"/cam": 93}, "published_before_subscribed": {"/cam": 93}, "tf_static_in_bag": null, "bag_ok": true}, "D_override_volatile_tf_static": {"missing": {"/cam": 36}, "published_before_subscribed": {"/cam": 36}, "tf_static_in_bag": 0, "bag_ok": true}}}
1회차 C의 sim time 기록과 D의 /tf_static·로그 검사·프로세스 종료 기록 원문이다.
"sim_time": {"clock_wait_after_recorder_node_s": 3.0, "recorder_node_alive_s_after_robot_start": 0.113, "first_clock_s_after_robot_start": 3.114, "cam_subscribed_s_after_first_clock": 0.006, "cam_subscription_existed_before_first_clock": false, "clock_msgs_published": 151, "cam_published_while_waiting_for_clock": 90, "of_those_in_bag": 0, "cam_published_before_first_clock": 94, "cam_before_first_clock_in_bag": 0, "rate_x_clock_wait": 90.0, "bag_first_cam_receive_stamp_s": 100.01, "sim_time_of_first_clock_s": 100.0}
"tf_static": {"sent_count": 1, "recorder_subscribed_s_after_send": 1.213, "in_bag": 0}
"log_markers": {"waiting_for_clock": false, "clock_found": false, "latched_warning": false, "all_topics_subscribed": true}
"launch_process_exits": ["python3-1: process has finished cleanly", "ros2-2: process has died [pid 155, exit code 2, cmd 'ros2 bag record -o /tmp/rosbag_launch_exp/D_override_volatile_tf_static/bag --topics /cam /tf_static --qos-profile-overrides-path /tmp/rosbag_launch_exp/D_override_volatile_tf_static/override.yaml']
결과는 실험 전 예측과 같은 방향이었다. 예측표는 B를 “60 이상”으로 적었고 5회 모두 그 하한을 넘었다. 곱셈 모델 자체는 맞았다. 빠진 수는 매번 구독 전 발행 수와 정확히 같았다. 어긋난 것은 식에 넣은 시간이다. timer 2초가 정하는 것은 녹화기 프로세스를 띄우는 시각까지다. 실제 구독까지는 timer가 끝난 뒤 1초가 안 되는 시간이 더 걸렸고, 4회차에는 2초 넘게 더 걸렸다. 로봇 첫 발행부터 녹화기 구독까지 잰 시간에 주기를 곱하면 실측 개수와 1개 안팎으로 맞는다. 미리 계산할 때 쓸 수 있는 값은 timer뿐이므로, 그 결과는 하한으로 읽어야 한다.
A는 지연이 없어도 0이 되지 않았다. 이 환경에서는 녹화기와 로봇 스크립트가 거의 같은 순간에 준비돼 카메라 1개, IMU 몇 개만 빠졌다. A의 로봇 첫 발행이 B·D보다 1초 넘게 늦은 것은 두 프로세스가 CPU 하나를 나눠 쓰며 동시에 뜬 결과로 보이지만, 이 원인을 따로 재지는 않았다. A의 개수는 누가 먼저 준비되느냐로 정해지므로 다른 기계와 다른 노드 구성에서는 달라질 수 있다.
C는 계산과 같았다. 기다리는 동안 보낸 90개는 한 번도 bag에 들어오지 않았고, 구독은 첫 /clock 직후에야 생겼다. README의 “쓰지 않는다”보다 소스의 “구독도 만들지 않는다”가 실제 동작에 가깝다. 첫 /clock 전 전체 손실이 90개보다 몇 개 많은 것은 녹화기 노드 자체가 뜨는 동안의 발행분이다. sim time 녹화에서는 녹화기 기동 시간에 /clock 대기 시간이 더해진다. bag 시각이 첫 /clock 값에서 시작한 것은 README가 설명한 이유, 즉 시각 0 메시지를 남기지 않으려는 설계와 맞는다.
D에서 실무적으로 눈여겨볼 점은 경고가 없었다는 것이다. 발행자 QoS가 섞일 때 나오는 경고 문자열은 override로 volatile을 강제한 경우 launch 출력에 한 번도 찍히지 않았다. override를 쓰면 녹화기가 발행자 QoS에 맞추는 과정을 거치지 않으니 경고가 나올 자리가 없다고 보는 것이 자연스럽지만, 그 코드 경로는 소스로 확인하지 않았다. 확실한 것은 로그만 봐서는 /tf_static이 빠진 줄 모른다는 점이다.
종료 코드 2는 해석에 주의가 필요하다. launch 로그의 “process has died”를 보고 bag 손상을 의심하기 쉽지만, 이번 20개 bag은 모두 열렸고 개수도 셀 수 있었다. 이번 bag은 모두 mcap 형식이었고 sqlite3 저장소에서 같은 결과가 나오는지는 확인하지 않았다.
독자가 같은 실험을 다시 돌리려면 Docker만 있으면 된다. 아래 명령은 커밋 3be5bba의 스크립트를 받아 하네스와 같은 이미지 digest, 같은 네트워크·CPU·메모리 제한으로 실행한다.
mkdir rosbag-launch-exp && cd rosbag-launch-exp
curl -fsSLO https://raw.githubusercontent.com/sungpyo9053/blog/3be5bbac8613d1938fe2cec6e785b4ef22db70ef/experiments/ros2-start-rosbag2-recording-from-launch-file-missed-messages/experiment.py
docker run --rm --network none --cpus 1 --memory 1g -v "$PWD":/work ros@sha256:066420e07f60aa18262f2479981def87ebcfcec42eefb0c0c57c4a46098348ca bash -c "source /opt/ros/jazzy/setup.bash && python3 /work/experiment.py"
정상이라면 사례 네 개의 JSON 줄과 요약 JSON 한 줄이 출력되고 종료 코드는 0이다. 하네스 실행에서는 한 번에 20초 안팎이 걸렸다. 개수는 실행마다 조금씩 달라진다. B의 빠진 수가 timer 계산값(rate_x_timer_delay)보다 크거나 같고, C의 of_those_in_bag가 0이며, D의 tf_static.in_bag이 0이면 이 글과 같은 결론이다. bag을 열지 못하면 결과에 bag_error가 남고 종료 코드는 1이 된다.
내 launch 값으로 다시 세어 보기
자기 구성에서는 다음 순서로 확인한다. HuntLab이 이 절차를 그대로 실행해 본 것은 아니고, 위 공식 자료와 실측에서 끌어낸 독자용 순서다.
ros2 bag record --help로 토픽 지정 문법(--topics유무)과--use-sim-time설명 문구를 확인한다.- 자기 토픽 주기와 launch의 timer 값, 첫
/clock시점을 넣어 주기 × 시간을 계산한다. 이 값은 하한이다. - 짧은 시험 녹화를 하고
ros2 bag info로 토픽별 개수를 본다. 앞부분 손실을 정확히 세려면 실험 스크립트처럼 시험용 발행자에 일련번호를 넣으면 된다. bag의 첫 번호가 곧 구독 전에 빠진 개수다. - sim time이라면 launch 출력에서 “waiting for /clock before starting recording”과 “Sim time /clock found, starting recording” 두 줄 사이 시간을 본다.
계산을 직접 해 볼 질문 세 개를 남긴다.
질문 1. 30 Hz 카메라만 녹화하면서 녹화기 timer를 0.5초로 줄였다. timer 값으로 센 손실은 몇 개이고, 실제 손실은 그보다 많을까 적을까?
30 × 0.5 = 15개가 하한이다. 실측 B처럼 녹화기 기동과 구독 생성 시간이 timer 뒤에 붙으므로 대개 더 많이 빠진다. 반대로 로봇 노드가 늦게 떠서 발행 시작이 늦어지면 그만큼 줄어든다. 손실을 정하는 구간은 “로봇 첫 발행부터 녹화기 구독까지”이기 때문이다.
질문 2. 시뮬레이터가 /clock과 카메라 토픽을 함께 발행하고, 녹화기는 --use-sim-time으로 띄웠다. 첫 /clock이 3초 늦게 나온다면 카메라 메시지 90개를 잃을까?
카메라도 /clock 전에는 발행되지 않는다면 거의 잃지 않는다. 90개는 실측 C처럼 카메라가 /clock과 상관없이 이미 발행 중일 때의 답이다. 계산하기 전에 “첫 /clock 전에 이 토픽이 실제로 나오고 있는가”부터 확인해야 한다.
질문 3. QoS override 파일을 추가한 뒤 ros2 bag info에 /tf_static이 0개로 나온다. 로그에는 경고가 없다. 무엇부터 고칠까?
override의 /tf_static 항목에 durability: transient_local이 들어 있는지 본다. 실측 D처럼 volatile로 덮으면 경고 없이 0개가 된다. 그다음 녹화기가 구독할 때 정적 변환을 보낸 노드가 아직 살아 있는지 확인한다.
녹화기 자리는 무엇을 잃어도 되는지로 정한다
계속 발행되는 센서의 앞부분이 필요하다면 녹화기를 timer로 늦추지 않는다. 늦추면 timer 값 이상으로 잃고, 이번 실측에서는 녹화기가 예상보다 1초 넘게 늦게 구독한 회차도 있었다. 지연 없이 같은 launch에 넣어도 0을 보장하지는 않으므로, 첫 몇 메시지까지 중요하다면 시험 녹화로 첫 일련번호를 확인하는 편이 계산보다 확실하다.
녹화기를 띄우는 방식 자체를 바꾸는 길도 있다. 녹화기를 먼저 띄워 두고 로봇 쪽을 늦추거나, README가 소개한 대로 rosbag2_transport의 녹화 기능을 Node나 컴포넌트로 띄우는 것이다. 뒤쪽은 Python CLI 층을 건너뛰므로 timer 뒤에 붙던 기동 시간이 달라질 수 있다. 두 구성 모두 이번 실측에는 없으니, 바꾼다면 바꾸기 전후로 같은 시험 녹화를 해서 bag의 첫 일련번호를 비교한다.
sim time으로 녹화하면 bag의 출발점은 첫 /clock이 도착한 때다. /clock 전부터 실시간으로 도는 노드의 메시지가 꼭 필요하다면 sim time 녹화로는 그 구간이 남지 않는다는 전제로 시뮬레이터 기동 순서를 정해야 한다. /tf_static 같은 latched 토픽은 개수를 계산하지 말고 override 파일의 durability와 bag info의 개수를 확인한다.
이 결과가 닿는 범위도 분명히 해 둔다. 1 CPU 컨테이너 한 대, 정수 하나짜리 작은 메시지, 스크립트가 보낸 가짜 /clock으로 잰 값이다. 카메라 이미지처럼 큰 메시지, 여러 호스트에 걸친 통신, 실제 시뮬레이터의 /clock 시점, 실물 로봇은 다루지 않았다. 녹화기 자체는 로봇을 움직이지 않지만, 같은 launch로 실물 구동 노드를 함께 띄운다면 그 노드의 안전 조건과 정지 방법은 이 글과 별개로 갖춰야 한다.
자기 토픽 주기와 timer 값으로 하한을 먼저 계산하고, 짧은 시험 녹화의 ros2 bag info 개수와 비교해 보면 자기 환경에서 녹화기가 timer 뒤로 얼마나 더 늦는지 알 수 있다.
이 글에는 제휴·협찬이 없다.
참고 링크
모두 2026-10-19에 커밋을 고정해 확인했다.
- rosbag2 README (Rolling) — Using in launch, 녹화·재생 기능을 노드로 띄우기, Simulation time, Overriding QoS Profiles
- rosbag2_transport recorder.cpp (Jazzy) — 첫 /clock까지 구독 생성 전 대기
- rosbag2_storage qos.cpp (Jazzy) — 발행자 QoS에 맞춘 구독 durability 선택
- ROS 2 문서 — QoS 호환표
- ros2/launch timer_action.py — TimerAction의 벽시계 대기
- HuntLab 산술 검산: example.py, verification.json
- HuntLab 실측(커밋
3be5bba): experiment.py, results.json