메트로놈을 얹은 초록색 발행 장치 옆 선반에 표본 블록 10개가 놓여 있고, 유리 칸막이 앞의 진한 블록 5개는 남고 뒤쪽 흐린 블록 5개는 금이 가 버려지는 모습을 늦게 도착한 작은 로봇이 바라보는 개념 일러스트

ROS 2 로봇 상태 토픽을 transient local로 남기면 늦게 켠 노드는 몇 초 전 데이터를 받을까: depth·발행 주기·lifespan으로 표본 나이 계산하기


Read in English

ROS 2의 로그 토픽 /rosout은 기본 QoS부터 과거 로그를 남기도록 정해져 있다. 내구성은 transient local, 보관 개수는 keep last 1000, 수명(lifespan)은 10초다. 개수 한도와 나이 한도를 한꺼번에 건 설정이라서, 나중에 붙은 구독자가 먼저 받는 과거 로그의 양은 둘 중 더 좁은 쪽이 정한다. ROS 2 로봇 상태 토픽을 직접 transient local로 남길 때 늦게 켠 노드가 몇 초 전 데이터를 받는지도 이 두 창으로 계산한다.

10 Hz로 발행하는 상태 토픽에 keep last depth 10을 걸고 발행자와 구독자를 모두 transient local로 두면, 늦게 켠 모니터링 노드가 처음 받는 과거 표본은 최대 10개이고 가장 오래된 것은 약 0.9초 전 상태다. 발행자 QoS에 lifespan 0.45초를 더하면 5개만 남는다. 더 긴 이력을 보려고 depth를 100으로 올려도 남는 것은 5개 그대로다. 이 값들은 발행 간격이 정확하고 통신 지연이 없다고 둔 산술 모델에서 나왔고, ROS 2를 실행해 잰 전달 개수는 아니다. 토픽에서 발행자와 구독자가 메시지를 주고받는다는 정도만 알면 따라올 수 있고, 다 읽으면 자기 토픽의 발행 빈도·양쪽 depth·lifespan으로 두 숫자를 설정 전에 구한 뒤 lifespan을 어느 쪽 QoS에 적을지 정할 수 있다.

depth는 개수로 과거를 남기고, 몇 초 전까지인지는 발행 주기가 정한다

토픽(topic)은 노드들이 메시지를 주고받는 이름 붙은 통로다. 메시지를 쓰는 쪽이 발행자(publisher), 읽는 쪽이 구독자(subscription)다. QoS(Quality of Service, 서비스 품질 정책)는 이 통로에서 메시지를 몇 개 남길지, 잃어도 되는지, 늦게 온 노드에게 과거 메시지를 줄지 정하는 정책 묶음이다.

여기서 먼저 필요한 정책은 둘이다. 히스토리(history)를 keep last로 두면 최근 N개만 저장하고, 그 N이 깊이(depth)다. keep last가 아닐 때 depth는 무시된다. 내구성(durability)을 transient local로 두면 발행자가 늦게 합류한 구독(late-joining subscription)을 위해 메시지를 보관하고, volatile로 두면 보관하지 않는다(ROS 2 QoS 문서). 이 글에서는 발행된 메시지 한 건을 표본(sample)이라 부르고, 표본의 나이는 그 메시지가 발행된 시각부터 구독자가 받은 시각까지 흐른 초로 잰다.

그림으로는 발행자 옆에 칸이 10개인 선반을 떠올리면 된다. 0.1초마다 새 상태가 한 칸에 놓이고, 칸이 다 차면 가장 오래된 것이 밀려난다. 모니터링 노드가 늦게 도착하면 선반에 남은 것을 먼저 받고 그다음부터 새로 놓이는 것을 받는다. ROS 1의 latching(래칭)을 써 봤다면 transient local과 depth의 조합이 그 역할에 가깝다.

선반이 어디에 있는지는 정확히 알아 둘 필요가 있다. ROS 2는 실제 통신을 RMW(ROS middleware interface, ROS 2 코드와 통신 제품을 잇는 어댑터 계층)에 맡기고, 그 아래의 대표 표준이 DDS(Data Distribution Service, 데이터 배포 서비스)다. DDS는 노드끼리 서로를 찾고 메시지를 실어 나르는 일을 맡는, 여러 벤더가 구현하는 산업 표준이다. Fast DDS와 Cyclone DDS가 그 구현 제품이고, ROS 2의 기본 RMW는 Fast DDS를 쓴다. DDS 명세는 transient local 데이터를 그 데이터를 쓴 발행자 쪽 개체의 메모리에만 두면 되고, 그 개체보다 오래 살아남을 필요가 없다고 적는다. 선반은 공용 창고가 아니라 발행 노드 안에 있다. 발행 노드가 이미 종료된 뒤에 켠 노드는 과거 표본을 기대할 근거가 없다.

이제 몇 초 전인지를 계산한다. 주기(period) T는 1을 발행 빈도(Hz)로 나눈 값이라 10 Hz면 0.1초다. 발행 간격이 정확히 일정하고, 지연이 없고, 모니터링 노드가 가장 최근 발행 직후에 합류했다고 두면 선반에 남은 10개의 나이는 0, 0.1, 0.2 … 0.9초다.

가장 오래된 표본의 나이 = (depth − 1) × T
10 Hz, depth 10 → (10 − 1) × 0.1 s = 0.9 s

1을 빼는 이유는 가장 최신 표본의 나이가 0에서 시작하기 때문이다. 칸 10개는 간격 9개만 덮는다.

같은 depth 10을 50 Hz 토픽에 걸면 가장 오래된 표본은 몇 초 전일까. T = 1/50 = 0.02초이므로 (10 − 1) × 0.02 = 0.18초다. 10 Hz일 때의 5분의 1 시간만 덮는다. depth는 초가 아니라 개수 단위라서, “최근 몇 초를 보고 싶다”는 요구는 그 초에 Hz를 곱해 개수로 바꾼 다음 depth와 비교해야 한다.

0.9초가 로봇에게 어떤 의미인지는 속도를 곱하면 보인다. 0.5 m/s로 이동 중인 로봇의 0.9초 전 상태는 0.5 × 0.9 = 0.45 m 뒤의 위치다. 측정한 오차가 아니라 곱셈 결과지만, 늦게 켠 노드가 처음 그리는 로봇 위치는 현재 위치가 아니라는 뜻이다. 화면에 그리거나 판단에 쓸 때는 메시지 헤더의 시각을 현재 시각과 비교해야 하고, 이렇게 받은 과거 표본을 실물 로봇의 제어나 정지 판단 입력으로 그대로 쓰는 절차는 이 계산이 다루는 범위가 아니다.

lifespan은 나이로 자른다: 0.45초면 10개 중 5개만 남는다

depth가 “몇 개를 남기나”를 정한다면 수명(lifespan)은 “얼마나 오래된 것까지 유효한가”를 정한다. ROS 2 문서는 lifespan을 발행에서 수신까지 허용되는 최대 시간으로 정의하고, 이 시간을 넘긴 메시지는 만료된 것으로 보고 조용히 버린다고 적는다. 기본값은 “지정하지 않음”이고 미들웨어는 보통 무한대로 해석한다.

앞과 같은 가정에 lifespan L을 더한다. L이 주기 T의 정수배가 아니면 나이가 L 이하인 표본의 개수는 다음과 같다.

lifespan으로 남는 개수 = floor(L / T) + 1
L = 0.45 s, T = 0.1 s → floor(4.5) + 1 = 5

나이 0, 0.1, 0.2, 0.3, 0.4초의 5개가 남고 0.5초부터 0.9초까지 5개가 버려진다. 가장 오래된 표본은 0.4초 전으로 당겨진다.

이력을 길게 보려고 depth를 100으로 올리면 어떻게 될까. 선반 칸은 100개가 되지만 0.45초보다 오래된 표본은 만료되므로 남는 수는 여전히 5개다. 버려지는 수만 5개에서 95개로 늘어난다. 늦게 켠 노드가 받는 양은 두 창 가운데 좁은 쪽에 맞춰지고, 이 조합에서 좁은 쪽은 lifespan이다.

10 Hz 표본 10개를 표본 나이 0~0.9초 축에 놓고 depth 10 창과 lifespan 0.45초 창을 겹친 이상적 산술 모델 타임라인: 0~0.4초 5개는 받고 0.5~0.9초 5개는 만료로 버려진다

구독자 쪽 설정이 받는 양을 한 번 더 줄인다

지금까지는 발행자 선반만 봤다. 선반에 남은 표본이 실제로 구독자에게 넘어가려면 양쪽 durability 조합부터 맞아야 한다. ROS 2 QoS 문서의 호환성 표에서 이 질문에 필요한 줄만 옮기면 다음과 같다.

발행자 durability 구독자 durability 늦게 켠 구독자가 받는 것
transient local transient local 새 메시지와 과거 메시지
transient local volatile 연결되지만 새 메시지만
volatile transient local 호환되지 않아 통신 없음

reliability까지 포함해 두 노드가 애초에 연결되는지를 판정하는 방법은 ROS 2 QoS 호환성을 먼저 계산하는 글에서 따로 다뤘다. 여기서는 첫 줄처럼 연결된 뒤 몇 개가 넘어오는지만 본다.

발행자는 transient local·depth 10이고 구독자만 volatile이라면 늦게 켠 노드는 과거 표본을 몇 개 받을까. 표의 둘째 줄에 해당하므로 0개다. 연결은 되지만 새 메시지만 받는다. 연결이 된다는 사실과 과거 표본을 받는다는 사실은 따로 확인해야 한다.

양쪽이 transient local이지만 구독자 depth가 3이라면 어떨까. 구독자에게도 자기 큐가 따로 있어서, 선반에서 10개가 넘어와도 구독자 큐에는 최신 3개(나이 0, 0.1, 0.2초)만 남는다고 보는 것이 이 모델의 가정이다. 가장 오래된 표본은 (3 − 1) × 0.1 = 0.2초 전이다. 세 제한을 한 줄로 모으면 다음과 같다.

받는 개수 N = min(발행자 depth, 구독자 depth, floor(L / T) + 1)
가장 오래된 표본의 나이 = (N − 1) × T
(발행자와 구독자가 모두 transient local이고, lifespan은 발행자 QoS에 건 경우)

신뢰성(reliability)도 한 번 볼 필요가 있다. reliable은 재전송으로 전달을 보장하려는 방식이고 best effort는 손실을 감수하는 방식이다. ROS 2 문서의 호환성 표는 reliable 발행자와 best effort 구독자를 호환으로 분류한다. 그런데 Fast DDS 문서는 구독자 쪽이 과거 표본을 받으려면 durability와 별개로 RELIABLE이어야 한다고 적는다. 늦게 켠 모니터링 노드가 과거 표본을 받게 하려면 구독자를 reliable과 transient local로 두는 편이 안전하다. 이것은 벤더 문서를 대조한 해석이고, RMW마다 확인한 결론은 아니다.

합류가 0.05초를 넘겨 늦어지면 5개가 4개가 된다

앞의 숫자는 모두 “가장 최근 발행 직후에 합류”라는 가정 위에 있다. 실제 구독자는 두 발행 사이 아무 때나 붙는다. 최근 발행 뒤 φ초(0 ≤ φ < T)에 합류하면 표본의 나이는 φ, φ + 0.1, φ + 0.2 …로 모두 φ만큼 밀린다.

  • lifespan이 없으면 depth 10의 가장 오래된 나이는 0.9초 이상 1.0초 미만이다. φ = 0.09초면 0.99초다.
  • lifespan 0.45초면 φ가 0.05초보다 작을 때 5개가 남는다. φ = 0.04초면 5개이고 가장 오래된 것은 0.44초 전이다. φ가 0.05초보다 크면 4개가 되며, φ = 0.09초면 가장 오래된 것은 0.39초 전이다.
  • φ가 정확히 0.05초면 다섯 번째 표본의 나이가 lifespan과 똑같은 0.45초가 된다. 나이가 lifespan과 같은 표본을 남기는지 버리는지는 확인한 원문에 정의돼 있지 않다. 예제의 lifespan을 0.45초로 고른 것도 φ = 0일 때 표본 나이와 겹치는 경계를 피하려는 선택이었다.

그래서 처음 질문의 답은 “최대 10개, 가장 오래된 것은 약 0.9~1.0초 전”이고, 발행자 lifespan 0.45초를 걸면 “5개, 합류 시점에 따라 4개”라고 적는 편이 정확하다. 이 모델에서는 하나 적은 개수도 정상 범위다.

lifespan을 구독자에만 적으면 RMW에 따라 결과가 갈린다

지금까지는 lifespan을 발행자 QoS에 걸었다고 가정했다. ROS 2 공식 문서는 lifespan을 발행과 수신 사이의 최대 시간으로만 정의하고, 발행자와 구독자 중 어느 쪽 설정이 적용되는지는 밝히지 않는다. 그 답은 DDS 쪽 자료에서 찾아야 한다.

DDS 명세는 ROS 2 발행자에 해당하는 개체를 DataWriter(데이터 쓰기 개체), 구독자에 해당하는 개체를 DataReader(데이터 읽기 개체)라고 부른다. ROS 2 발행자를 만들면 RMW가 DataWriter를, 구독을 만들면 DataReader를 만든다고 생각하면 된다. 자료를 대조하면 lifespan이 어느 쪽에 적용되는지에서 해석이 갈린다.

  • OMG DDS 1.4 명세의 QoS 표는 LIFESPAN의 적용 대상을 Topic과 DataWriter로 적고 DataReader는 넣지 않는다. 만료 시각은 source timestamp(발행 측이 메시지에 붙인 시각)에 lifespan을 더한 값이다. 그래서 발행자와 구독자가 다른 컴퓨터에 있으면 결과가 두 시계의 동기화에 기댄다. use_sim_time을 켠 시뮬레이션 환경에서 이 기준이 어떻게 바뀌는지는 확인하지 않았다.
  • ROS 2 설계 문서는 양쪽에 의미를 준다. 발행자 쪽은 만료된 메시지를 토픽 history에서 지워 더 이상 보내지 않고, 구독자 쪽은 만료 뒤에 받지 않는다. 2019년 9월에 쓴 제안 문서라 현재 동작의 보증이라기보다 설계 의도로 읽어야 한다.
  • Fast DDS 문서는 LifespanQosPolicy가 Topic, DataReader, DataWriter 모두에 적용된다고 적고, DataReader가 source timestamp 대신 수신 시각을 쓰도록 허용한다.

ROS 2 쪽 코드는 구독자 값을 막지 않는다. rolling 브랜치의 rmw_fastrtps와 rmw_cyclonedds를 읽어 보면 두 RMW 모두 구독자 QoS에 적은 lifespan도 DataReader QoS까지 옮긴다. 차이는 그 뒤에 DDS 구현이 DataReader 쪽 값을 반영하느냐에서 생긴다.

그 차이는 공개 이슈에서 실제 동작 차이로 드러났다. rmw_cyclonedds #473에는 구독자에만 lifespan 2초를 걸었을 때 rmw_fastrtps_cpp(Iron)에서는 2초 뒤 합류한 구독자에게 래치된 메시지가 오지 않았다는 ROS 2 메인테이너의 재현 보고가 있고, Cyclone DDS에서는 콜백이 항상 호출됐다는 보고자의 확인이 함께 있다. Cyclone DDS 메인테이너는 cyclonedds #1835에서 Cyclone이 명세에 따라 lifespan을 DataWriter와 Topic에만 적용한다고 답했다. 두 이슈 모두 2026년 9월 26일 확인 시점에 열려 있으며, 이 글에서 재현한 결과가 아니라 공개된 보고다. rmw_zenoh 같은 다른 RMW의 동작은 조사하지 않았다.

그래서 판단은 발행자 쪽으로 기운다. 구독자에만 lifespan을 걸면 같은 코드라도 RMW를 Fast DDS에서 Cyclone DDS로 바꾸는 순간 받는 개수가 달라질 수 있다. 발행자에 걸면 DDS 명세, Fast DDS 문서, Cyclone DDS 메인테이너의 답변이 모두 적용 대상으로 인정한다. rclpy에서는 QoSProfile 생성자의 lifespan 인자에 Duration을 넘기고, durability에는 DurabilityPolicy.TRANSIENT_LOCAL을 쓴다. 이 이름은 rclpy 소스에서 확인했다.

/rosout에 대 보면 초당 100개에서 두 창이 자리를 바꾼다

도입의 /rosout으로 돌아가기 전에, QoS를 따로 정하지 않은 토픽부터 보자. 기본 프로필은 keep last 10, reliable, volatile, lifespan 미지정이다(rmw qos_profiles.h). depth가 10이어도 volatile이라서 늦게 합류한 구독자가 받는 과거 표본은 0개다. 기본 depth가 10이라는 사실을 과거 10개를 받는다는 뜻으로 읽으면 안 된다.

sensor data 프로필은 공식 문서에 “더 작은 queue size”라고만 적혀 있다. rmw 헤더의 실제 값은 keep last 5, best effort, volatile이고 Humble·Jazzy·Kilted·Rolling 브랜치에서 모두 같다. 이 프로필을 그대로 쓰면 역시 과거 표본은 0개다. 이 글의 10 Hz·depth 10 예제는 sensor data 기본값이 아니다.

/rosout 기본 QoS는 keep last 1000, reliable, transient local, lifespan 10초다. Humble·Jazzy는 rcl의 logging_rosout.h에, Kilted·Rolling은 rmw의 qos_profiles.h에 이 값이 있다. 같은 모델에서 두 창이 만나는 발행률은 1000 ÷ 10 = 초당 100개다. 로그가 초당 100개보다 적게 나오면 lifespan 10초가 먼저 자르고, 더 많이 나오면 depth 1000이 먼저 자른다. 초당 20개라면 lifespan이 제한해 201개다. 10초가 0.05초의 정확한 배수라서 경계의 표본까지 포함한다고 가정한 값이다. 초당 500개라면 depth가 제한해 1000개다. 실제 로그는 일정한 주기로 나오지 않으므로 이 숫자는 두 창의 관계를 보여 주는 설명용 산술이다.

이 글의 숫자는 두 방법으로 다시 계산했다. 10 Hz 주기부터 volatile 구독자, 0.45 m까지의 주요 값 9개(0.1, 0.9, 0.18, 5, 5, 95, 0.2, 0, 0.45)는 이 글을 위해 만든 검산 스크립트 example.py의 사례이고, 기대값은 검산 기록 verification.json에 있다. 2026년 9월 26일 Linux의 Python 3.12.3에서 표준 라이브러리만으로 python3 -I example.py를 다시 실행했을 때 종료 코드 0이었고, 9개 출력이 기대값과 모두 일치했다. 합류 시점 φ에 따른 5개·4개와 0.44·0.39·0.99초, /rosout의 초당 100개 경계와 201개·1000개는 fractions.Fraction으로 같은 모델을 한 번 더 계산해 확인했으며, 이 교차 검산은 파일로 남기지 않았다. 둘 다 산술 검산이고, ROS 2 노드나 DDS를 실행해 얻은 값이 아니다.

QoS를 적는 자리: lifespan은 발행자에, depth와 durability는 양쪽에

늦게 켠 노드에게 최근 상태를 보여 주려는 토픽이라면 설정 위치는 이렇게 나뉜다. 발행자에는 transient local, keep last depth, 그리고 lifespan을 적는다. 구독자에는 transient local과 reliable, 그리고 받고 싶은 개수 이상의 depth를 적는다. lifespan을 구독자 QoS에만 적는 선택은 RMW를 바꾸는 순간 결과가 달라질 수 있으므로 피한다. 이력이 모자라 보이면 먼저 min 식의 세 항 중 무엇이 가장 작은지 본다. lifespan 항이 가장 작은데 depth만 올리면 버려지는 표본만 늘어난다.

반대로 과거 상태가 화면에 잠깐이라도 섞이면 곤란한 용도라면, 구독자를 volatile로 두어 과거 표본을 아예 받지 않는 편이 단순하다.

이 판단을 바꿀 수 있는 미확인은 하나다. 발행자에 건 lifespan이 실제 RMW의 transient local 보관소에서 만료된 표본을 늦게 합류한 구독자에게 다시 보내기 전에 빼는지다. 설계 문서와 Fast DDS 문서는 만료 표본을 history에서 제거한다고 적지만, 모든 RMW에서 그렇게 동작하는지는 직접 확인하지 못했다. 또 이 계산은 키로 구분되는 데이터 묶음인 인스턴스(instance)가 하나라고 가정했다. DDS 명세에서 depth는 인스턴스마다 적용되고, 키가 없는 토픽은 전체가 한 인스턴스다. 인스턴스가 여러 개인 토픽은 이 식으로 다루지 않았다.

직접 돌려 보니: lifespan 0.45초에서 5개가 아니라 4개였다

위 숫자는 모두 산술 모델에서 나왔다. 실제 ROS 2에서도 그렇게 나오는지 2026년 10월 3일에 같은 설정으로 돌려 봤다. 발행자는 10 Hz로 보내고, 구독자는 3초 늦게 붙였다. 발행자와 구독자 모두 transient local, reliable, keep last다. 설정마다 5번씩 반복했다.

설정 글의 계산 실측 (5회)
depth 10, lifespan 없음 10개, 가장 오래된 것 0.9초 전 10개, 0.9초 전 (5회 모두)
depth 10, 발행자 lifespan 0.45초 5개, 합류 시점에 따라 4개 4개 4회, 3개 1회
depth 100, 발행자 lifespan 0.45초 5개 4개 (5회 모두)

lifespan이 없을 때는 계산과 정확히 맞았다. lifespan을 걸자 5개는 한 번도 나오지 않았다.

차이는 도착 시각에서 보인다. 과거 표본은 구독자를 만든 순간이 아니라 약 0.075초 뒤에 한꺼번에 들어왔다. 3개만 받은 회차는 이 간격이 0.132초였다. 합류 순간에 0.4초였던 표본은 도착할 때 이미 0.475초가 돼 lifespan 0.45초를 넘는다. 그래서 실제 창은 lifespan에서 발행자와 구독자가 서로를 찾는 데 걸린 시간을 뺀 만큼으로 좁아진다고 해석했다. 측정값은 이 해석과 맞지만, DDS 내부에서 표본을 지우는 시점을 직접 확인하지는 않았다.

실무에서 얻을 결론은 하나다. lifespan으로 표본 개수를 맞출 때는 계산값에 발견 지연을 더해야 한다. 이번 환경에서는 그 지연이 0.04~0.13초였고, 몇 초로 잡아야 하는지는 자기 RMW와 네트워크에서 같은 방식으로 재 보는 수밖에 없다.

측정 환경과 한계. ROS 2 Jazzy 공식 컨테이너(ros:jazzy-ros-base), 기본 RMW인 Fast DDS(rmw_fastrtps_cpp), Python 3.12.3. 발행자와 구독자를 한 프로세스, 한 머신에 두고 네트워크를 끈 컨테이너에서 실행했다. 머신 사이 통신이나 Cyclone DDS 같은 다른 RMW에서는 발견 지연이 달라 개수도 달라질 수 있다.

측정 스크립트의 핵심은 구독자를 만든 시각(joined)보다 먼저 발행된 표본만 세는 것이다.

history = [(rx, sent) for rx, sent in received if sent < joined]
oldest_age_at_join = joined - min(sent for _, sent in history)
arrival_after_join = min(rx for rx, _ in history) - joined

참고 링크

모두 2026년 9월 26일(KST)에 확인했다. 이 글에는 제휴·협찬 링크가 없다.

비슷한 글

답글 남기기

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