AEGLOSLAB ROS 2 Jazzy 대전

AeglosLab

ROS 2 Jazzy 대전(大全)

ROS 2 Jazzy Jalisco를 원리 수준에서 파고드는 심화 종합서다. ros2 run 한 줄이 rclcpp → rcl → rmw → DDS → RTPS 패킷까지 어떻게 내려가는지, 어디서 지연·유실·데드락이 생기는지, 그리고 그 위에 실제 로봇 시스템을 어떻게 설계·배포·디버깅하는지까지 한 권에서 연결해 설명한다.

16부 · 133장 · 갱신 2026-11-03

0.1 ROS 2란 무엇인가 — 미들웨어·프레임워크·생태계부터 읽기

이 책은 무엇인가

ROS 2 Jazzy Jalisco를 원리 수준에서 파고드는 심화 종합서다. ros2 run 한 줄이 rclcpp → rcl → rmw → DDS → RTPS 패킷까지 어떻게 내려가는지, 어디서 지연·유실·데드락이 생기는지, 그리고 그 위에 실제 로봇 시스템(제어·인지·항법·매니퓰레이션·시뮬레이션·운영·보안)을 어떻게 설계·배포·디버깅하는지까지 한 권에서 연결해 설명한다.

기준 배포판은 Jazzy Jalisco — 2024년 5월 릴리스, LTS, 2029년 5월 EOL, Tier 1 플랫폼 Ubuntu 24.04 Noble, 기본 RMW rmw_fastrtps_cpp. 자세한 정의·독자·범위는 BOOK.md.

읽는 법

  • 순서대로 읽는 경우 — 제0부(개관) → 제1부(그래프 코어) → 제2부(실행 모델) → 제3부(미들웨어)까지가 이 책의 뼈대다. 여기까지 읽으면 나머지 부는 어느 순서로 읽어도 된다. 제4부 이후는 응용·운영 영역이라 필요에 따라 골라 읽는다.
  • 문제부터 들어오는 경우 — "토픽이 끊긴다"면 1.5·1.6(QoS)과 3.3(디스커버리), "콜백이 실행되지 않는다"면 2.2~2.4(executor·콜백 그룹), "느리다"면 11.7·12.4·12.6(측정·지연 예산·안티패턴), "실기에서만 안 된다"면 3.9(네트워크)·10.6(Sim2Real)·11.1(디버깅 플레이북).
  • 검증된 사실로 진입 — 목차 6(주제별 실측 색인) 에서 "무엇이 Jazzy 실물로 확인됐나"를 주제별로 찾을 수 있다. 재현 방법은 11.7.13.
  • 역할별 진입 — 부록 A.6(역할별 학습 로드맵)에서 아홉 트랙의 최단 경로를 제시한다: 입문자·제어/하드웨어·인지/센서·항법·매니퓰레이션·시스템 아키텍트·DevOps·ROS 2 위에 만드는 개발자(미들웨어·드라이버·프레임워크)·연구실/대학원생(연구 코드를 시스템으로).
  • 용어·명령이 막히면 — 부록 A.1(CLI 레퍼런스), A.2(QoS·환경변수), A.4(용어집·약어).
  • 각 장은 첫 절에서 배우는 것·선수 지식을, 마지막에 체크포인트를 둔다. 다른 장 참조는 RJ-NN 또는 RJ-NN-MM 단독 표기.

정직성 고지 — 이 책의 코드·명령은 어디까지 믿을 수 있나

이 책은 ROS 2가 설치되지 않은 환경에서, 공식 문서(docs.ros.org)·REP·소스 저장소를 근거로 집필되었다. 즉 대부분의 서술은 실행으로 검증된 것이 아니다.

다만 2026-08-14부터 23까지 178차례(누적, 아래 표)에 걸쳐 이 책의 서술을 점검했다. 이 178차례가 전부 "컨테이너를 기동해 확인"한 것은 아니다 — 164차례("N차 검증")는 실제로 컨테이너를 실행·측정까지 했고, 나머지 14차례("N차 점검"·"N차 정리")는 컨테이너 실행 없이 목차·본문·카탈로그를 대조하거나 기존 실측을 정리했다. 구분 규칙은 표 아래(104차가 확정)를 따른다. 확인된 범위는 다음과 같다 — 이만큼만 점검되었고, 나머지는 아니다.

실행 확인한 것결과
1차 검증(2026-08-14 · CHG-21) — 최초 실행 검증(아래 6행)이하 표 전체가 이번 회차 결과
ros2 명령 트리(책에 등장하는 115종)1건(ros2 bag echo) 오류 — 수정함. 나머지 일치
기본 QoS 프로파일 4종(기본·센서·서비스·파라미터)전부 일치(A.2.3)
기본 RMW·Python·OSrmw_fastrtps_cpp · 3.12 · Ubuntu 24.04
ROS_LOCALHOST_ONLY 폐기, 디스커버리 범위 값확인(3.3.7·A.2)
type hash(RIHS01_…)와 ros2 topic info -v 출력확인 — History (Depth)는 UNKNOWN(3.3.2)
sensor_msgs/msg/Imu 정의·공분산 규약, 라이프사이클 상태 집합일치(7.1·1.12)
2차 검증(2026-08-15 · CHG-22) — 책에 등장하는 인터페이스 타입 29종전부 실재 확인
rclcpp 주요 API(발행·구독·QoS·콜백 그룹·타이머·파라미터·서비스·시간·로깅·라이프사이클·executor 4종)컴파일 검증 통과
launch·launch_ros 클래스 28종, rclpy 심볼 13종전부 실재 확인
ros2 CLI 플래그 38쌍, 노드 --ros-args 인자 9종전부 수용 확인
환경변수 24종21종 실재, 3종은 존재하지 않음 — 정정함(1.11·3.7·3.8)
내장 QoS 프로파일 8종 전 필드헤더와 일치. BEST_AVAILABLE 누락 발견 — 보강함(1.5.3.8)
ros2 topic echo의 QoS 거동책 서술이 Jazzy와 어긋남 — 정정함(0.2·0.7·1.5)
3차 검증(2026-08-16 · CHG-23) — 갭 점검으로 드러난 누락 기능 5종아래 전부 컴파일·실행 확인 후 집필
rclcpp::TypeAdapter·adapt_type<>::as<>사용자 타입 발행→구독 콜백 왕복 실행 확인(1.3.12)
~/get_type_description 서비스기본 활성(파라미터 start_type_description_service=True), 정의·원본 .msg 반환까지 확인(1.3.13)
내용 필터(content filtered topic)Fast DDS는 실제로 걸러내고, Cyclone DDS는 조용히 무시(15건 발행 → 7건 vs 15건 수신) — 1.5.10
matched·incompatible_type 이벤트콜백 실재·matched 발화 확인, 타입 충돌의 경계 실측(1.4.6)
내장 토픽 통계(enable_topic_statistics)/statistics 발행·MetricsMessage 필드·헤더 없는 타입의 NaN 확인(11.7.12)
4차 검증(2026-08-16 · CHG-25) — 파라미터 콜백 3종순서(pre→on→post)·거부 시 post 미호출·연동 변경까지 함께 롤백 확인(1.9.6.1)
5차 검증(2026-08-16 · CHG-26) — ros2_control 스택 설치 후 실제 구동아래 — 생태계 패키지 중 처음으로 실행 검증한 영역
ros2 control 서브커맨드책의 9종 전부 실재 + 책에 없는 3종 발견(view_controller_chains·cleanup_controller·reload_controller_libraries) — 보강함(6.3.6·6.4.8)
컨트롤러·브로드캐스터 18종전부 실재(6.5)
하드웨어 컴포넌트 골격(6.2.3)컴파일 통과. 단 export_*는 deprecated이고 순수가상은 read·write 둘뿐 — 정정·보강함(6.2.5.1)
controller_manager 실제 구동(mock 하드웨어)URDF <ros2_control> → 로드·초기화·활성화 성공. 인터페이스 이름 joint1/position·[available]/[unclaimed] 표기 일치(6.3)
비동기 하드웨어 컴포넌트책이 세 번 언급만 하고 설명하지 않던 기능 — <ros2_control is_async rw_rate> 속성으로 실측 후 신설(6.2.13)
6차 검증(2026-08-16 · CHG-27) — Nav2 1.3.12 설치 후 플러그인·BT 노드 전수 대조아래
Nav2 플러그인 표기pkg/Class 슬래시 표기 6곳이 오류 — pkg::Class로 정정(8.1·8.5·8.7). 실물 XML에 name= 속성이 없어 슬래시는 조회 실패한다
코스트맵 레이어RangeLayer → 실물은 RangeSensorLayer — 정정. 미다룸 레이어 3종 확인
BT 노드 이름 13종전부 일치. 단 루트 태그에 BTCPP_format="4" 누락 — 정정(BT.CPP 4.9.0)
내장 BT 조건 노드책이 직접 만들라던 배터리 조건이 IsBatteryLow로 이미 실재 — 보강(8.6.7)
nav2_route 서브시스템제8부가 전혀 다루지 않음 — 경계 명시로 신설(8.1.13)
7차 검증(2026-08-16 · CHG-28) — MoveIt 2.12.4 설치 후 대조아래
MoveIt 헤더 경로.h → .hpp 전면 이동 — 책의 include 2곳이 obsolete 경고를 냄(실측). 정정(9.1.5·9.3.2)
OMPL 플래너·Pilz·STOMP·CHOMP전부 실재(geometric::RRTConnect 등) — 9.2의 (확인 필요) 해소
moveit_msgs 액션·서비스MoveGroup·ExecuteTrajectory·GetPlanningScene·ApplyPlanningScene·GetMotionPlan 실재
moveit_py·MTC둘 다 실재(from moveit.planning import MoveItPy import 성공, MTC는 0.1.5) — (확인 필요) 2건 해소
MoveIt 플러그인 표기Nav2와 반대로 슬래시 조회 이름을 유지 — 8.5의 함정이 과일반화되지 않도록 단서 추가
8차 검증(2026-08-16 · CHG-29) — ros_gz 1.0.22 · gz_ros2_control 1.2.19 설치 후 대조아래 — 미검증 생태계 장이 이로써 0이 되었다
ros_gz_bridge YAML 설정책의 예제를 그대로 적용해 브리지 기동 성공 — 키 5종·GZ_TO_ROS/ROS_TO_GZ 전부 수용
브리지 방향 기호 @·[·]--help 원문과 일치
lazy 옵션책에 없던 기능 — 실측 후 보강(10.2.4.1)
Jazzy↔Harmonic 페어링gz-sim 8이 벤더 패키지 16종으로 함께 설치됨 — (확인 필요) 해소
gz_ros2_control 이름 3종전부 일치. ign_ros2_control/IgnitionSystem 하위호환 이름도 확인(10.4.1)
시뮬레이터 구동(물리·렌더링·RTF)검증 못 함 — 헤드리스 컨테이너에 GPU·디스플레이 없음
9차 검증(2026-08-16 · CHG-32) — 시뮬 시간과 타이머/clock을 0.5배속 발행하고 두 타이머를 나란히 실행 — create_wall_timer는 실시간 1초 고정, 노드 시계 타이머는 시뮬 1초(=실시간 2초). rclcpp::Rate 기본 시계는 RCL_SYSTEM_TIME(헤더 확인). (확인 필요) 해소(1.10.3.1)
10차 검증(2026-08-16 · CHG-33) — 로깅·이벤트아래
로거 레벨 서비스기본 비활성 — enable_logger_service가 rclcpp·rclpy 모두 false. 노드를 별도 설정 없이 실행하면 서비스가 없다(실측). 책이 "노출한다"고 단정하던 것을 정정(1.11.4·2.10.2)
rcl_interfaces/msg/Log 상수DEBUG=10·INFO=20·WARN=30·ERROR=40·FATAL=50 일치 — (확인 필요) 해소
로깅 관련 --ros-args 플래그--disable-rosout-logs·--disable-stdout-logs·--disable-external-lib-logs 전부 수용 — (확인 필요) 해소
rmw 이벤트 타입12종 전수 확인. DDS의 sample_lost·sample_rejected는 노출되지 않음 — 1.4.4 정정
11차 검증(2026-08-16 · CHG-34) — 실행 모델아래
타이머 정책TimerInfo로 실측 — 절대 기준 + 건너뛰기. 3주기를 놓쳐도 발화는 한 번이고 예정 시각 격자는 유지된다. (확인 필요) 2건 해소(2.5.12)
rclcpp::TimerInfo0.3.4가 "2.5에서 다룬다"고 약속했으나 2.5에 없던 것 — 실측과 함께 신설
노드 ↔ DDS 참가자한 프로세스의 노드 4개가 GUID prefix를 공유 = 프로세스당 참가자 1개. 세 곳의 (확인 필요) 동시 해소(1.13.2·2.1.4·3.3.5)
12차 검증(2026-08-16 · CHG-35) — intra-process 제약TRANSIENT_LOCAL이 동작하고(늦은 구독자가 과거 메시지 수신) matched 이벤트도 발생. 다만 subscription.hpp 주석은 여전히 "비호환이라 예외를 던진다"고 적고 있어 문서와 동작이 어긋난다 — 그 사실을 그대로 기록(2.7.5.1)
loaned message기본 구성에서 can_loan_messages() = false — 설정 없이 쓰면 조용히 힙 메시지가 된다(2.6.5)
13차 검증(2026-08-16 · CHG-40) — 생태계 4스택 실물 대조(tf2·image_transport·SLAM Toolbox·PCL)아래 — 한 번도 검증한 적 없던 장 6개(5.1·6.9·7.2·7.4·8.2)를 대상으로
tf2가 쓰는 QoS 4종tf2_ros/qos.hpp 확인 — 넷 다 depth 100, 정적 쪽 둘만 transient_local(). /tf QoS (확인 필요) 해소(5.1.13.1)
tf2 버퍼 기본 캐시 시간10초(BUFFER_CORE_DEFAULT_CACHE_TIME) — (확인 필요) 해소(5.1.5)
static_transform_publisher 인자 형식옛 위치 인자도 동작하되 deprecated 경고. 순서는 x y z yaw pitch roll(4번째가 yaw) — (확인 필요) 해소(5.1.13.2)
StaticTransformBroadcaster 중복 호출다른 프레임이면 누적, 같은 프레임이면 대체(실측) — (확인 필요) 해소(5.1.6)
image_transport 플러그인단독 설치 시 raw뿐. compressed·compressedDepth·theora·zstd는 image_transport_plugins(→perception→desktop)와 함께 설치되고 ros-base에는 없다. 책이 "compressed는 대개 있다"고 완화하던 것을 정정(7.2.12)
republish 도구인자가 파라미터 방식(in_transport·out_transport)으로 바뀜. lazy임을 실측 — 출력 구독자가 없으면 입력조차 구독하지 않는다(7.2.12.4)
SLAM Toolbox 파라미터 20종기본 config와 한 줄씩 대조 — 이름 20종 전부 실재, 기본값 19종 일치. base_frame만 다름(실물 기본값은 base_footprint) — 함정으로 보강(8.2.5)
SLAM Toolbox 실행 파일여섯 개 — 책의 5개 모드 외에 map_and_localization·merge_maps_kinematic 발견, 신설(8.2.13)
map yaml modetrinary·scale·raw 실재(nav2_map_server의 MapMode) + 각 동작 확인 — (확인 필요) 해소(8.2.13.3)
diagnostic_updater 주기 우선순위컴파일·실행 — setPeriod() > diagnostic_updater.period 파라미터 > 생성자 인자 > 기본 1.0초. 생성자 인자가 파라미터에 진다는 것이 함정(6.9.12.1)
diagnostic_updater.use_fqn기본 false → 진단 이름이 probe: 배터리(네임스페이스 없음). 다중 로봇에서 이름이 충돌한다 — 신설(6.9.12.2)
PCL 커스텀 포인트 타입POINT_CLOUD_REGISTER_POINT_STRUCT 형태를 컴파일·실행 확인. point_step=32인데 유효 데이터는 18바이트 — 44%가 정렬 패딩. (확인 필요) 해소(7.4.13)
PCL 스택 버전PCL 1.14.0 · pcl_conversions/pcl_ros 2.6.4 — (확인 필요) 해소(7.4.1)
14차 검증(2026-08-16 · CHG-41) — 도구 계층 전수 대조(CLI·colcon·xacro)아래 — 레퍼런스 성격이라 틀리면 독자가 바로 막히는 장들
ros2 명령 트리 전수entry point로 조회 — A.1이 다루던 54종은 전부 실재. 부록에 없던 동사 15종 발견(그중 10종은 책 전체에 없던 것) — 신설(A.1.14)
ros2 doctor hello다중 호스트 통신 진단 — /canyouhearme 토픽과 멀티캐스트 225.0.0.1:49150을 동시에 보여 준다. "멀티캐스트는 되는데 DDS가 연결되지 않는다"를 바로 가른다
ros2 bag burst · bag list · plugin list · service echo·info전부 실재·동작 확인. bag list storage → mcap·sqlite3
A.1의 (확인 필요) 10건topic delay·echo --field·daemon status·param delete·interface proto·bag convert·reindex·pkg xml·doctor·multicast — 전부 실재 확인
wtf가 doctor 별칭인가맞다 — 같은 모듈의 WtfCommand(실행 확인)
colcon 확장 구성colcon-common-extensions가 끌어오는 21종 확인. graph는 기본 포함(package-information 제공) — 책이 python3-colcon-graph를 별도 설치로 적던 것을 정정. mixin·clean은 별도(4.5.15)
--packages-* 옵션20종 전수 확인 — --packages-select-regex 실재((확인 필요: 옵션명) 해소)
xacro 지시자 전수11종 확정(arg·attribute·call·element·eval·if·include·insert_block·macro·property·unless). 반복 지시자는 없다 — (확인 필요: 리스트 순회) 해소(5.4.12.1)
xacro:if의 문자열 조건값 8종으로 실측 — $(arg)를 조건에 직접 써도 property 경유와 결과가 같고, "false"는 정확히 거짓. 잘못된 값(yes)은 조용히 참이 되는 것이 아니라 오류. 책의 함정 서술이 실제와 반대여서 정정(5.4.12.2)
params 네 형태a·a:=기본값·*블록·**딕셔너리 전부 동작 — (확인 필요) 해소(5.4.12.3)
15차 검증(2026-08-16 · CHG-42) — 보안·launch·센서 규약아래 — 실물을 만들어 보고 값을 흔들어 확인
SROS 2 키스토어 실제 생성ros2 security create_keystore·create_enclave 실행 — 디렉터리·파일 이름 전부 확정(identity_ca/permissions_ca 분리 포함). (확인 필요) 다수 해소(13.2.12)
기본 권한 파일의 내용create_enclave가 만드는 permissions.xml은 rt/* 와일드카드 전부 허용 — "enclave를 만들면 권한이 좁혀진다"는 오해를 정정. 도메인은 0으로 고정되어 ROS_DOMAIN_ID를 바꾸면 통신이 막힌다. 유효기간 10년(13.2.12.2)
DDS 토픽 접두어정책 파일이 그대로 드러낸다 — rt/(토픽)·rq/…Request·rr/…Reply·_action/…. 정책을 직접 좁힐 때 필요(13.2.12.3)
보안 환경 변수 실측ENABLE=true+Enforce+enclave 없음 → 거부, Permissive → 통과. ENABLE=1·True는 보안을 조용히 끈다(오류 없이 정상 실행) — 사고로 이어지는 자리. enclave는 노드 이름이 아니라 컨텍스트로 찾는다(13.2.12.5)
ros2 security list_keys폐기 예정(FutureWarning) — list_enclaves로 대체. A.1.14의 나열도 함께 정정
launch substitution 전수launch 22종 · launch_ros 5종 조회 — 책이 다루지 않던 EqualsSubstitution·IfElseSubstitution·AllSubstitution·FileContent 등을 보강(14.3.12.1)
LaunchConfigurationEquals·NotEquals폐기 예정 확정(UserWarning) — IfCondition(EqualsSubstitution(...))로 대체(14.3.12.3)
IfCondition의 참 문자열소문자화 후 true·1만 참, false·0만 거짓. yes·빈 문자열은 오류로 중단(조용한 오판 아님). xacro의 xacro:if와 규칙이 일치함을 확인(14.3.12.2)
sensor_msgs 단위 규약.msg 주석이 정본 — 온도 섭씨, NavSatFix 위경도 도·고도 m(WGS 84 타원체), Imu 가속도 m/s²("not in g's"), MagneticField Tesla(7.1.12.1)
Imu 공분산의 "모름"전부 0 = 공분산 미상(값은 유효) / 첫 원소 -1 = 그 추정값 자체가 없음 — 둘이 다른 뜻이다. 이 장의 핵심 (확인 필요) 해소(7.1.12.2)
16차 검증(2026-08-16 · CHG-43) — DDS 아닌 미들웨어와 배포아래 — 통신까지 실제로 시켜 본 첫 대안 RMW 검증
rmw_zenoh_cpp 라우터 요구라우터 없이 노드는 기동되고 발행도 계속하지만 아무도 받지 못한다(경고 2줄만). 라우터(rmw_zenohd)를 기동하면 정상 통신 — 기본 모드가 peer인데도 그렇다. (확인 필요) 해소(3.10.12.1)
zenoh 기본 전송·엔드포인트tcp/localhost:7447 — TCP 확정, 라우터가 같은 머신에 있다고 가정. 재시도 기본 1회(ZENOH_ROUTER_CHECK_ATTEMPTS로 조절)
zenoh 환경 변수7종 전수(ZENOH_SESSION_CONFIG_URI·ZENOH_ROUTER_CONFIG_URI·ZENOH_CONFIG_OVERRIDE·ZENOH_ROUTER_CHECK_ATTEMPTS·SHM 2종·버퍼 풀) — (확인 필요) 해소
zenoh의 QoS 매핑TRANSIENT_LOCAL 동작 확인(늦은 구독자가 과거 3건 수신). DEADLINE·LIVELINESS는 설정은 수용하되 이벤트 등록 시 UnsupportedEventTypeError — 감시가 없다. incompatible_qos·matched는 지원(3.10.12.3)
zenoh 보안ros-jazzy-zenoh-security-tools의 generate_configs가 SROS 2 정책·enclave를 입력으로 zenoh 설정을 생성 — 정책 자산을 이어 쓴다(3.10.12.4)
ROS 2 노드의 시그널 처리rclcpp가 SIGTERM과 SIGINT를 같은 핸들러로 처리(로그 문구가 signal_handler(SIGINT/SIGTERM)). KillSignal=SIGINT로 바꾸라던 조언을 정정. SIGHUP은 처리하지 않아 정리 없이 종료된다(14.8.12)
17차 검증(2026-08-16 · CHG-44) — 보안 거버넌스와 트레이싱아래 — 20차에 만든 키스토어를 이어 쓰고, 트레이싱은 실제로 켜서 확인
DDS-Security 거버넌스 실물create_keystore가 만드는 governance.xml 전문 확인 — 기본값이 엄격하다(미인증 참가자 거부·모든 토픽 접근 제어·데이터/메타데이터 ENCRYPT). (확인 필요: 정확한 스키마) 해소(13.3.11.1)
rtps_protection_kind만 SIGN나머지 넷은 ENCRYPT인데 RTPS 계층만 서명이다 — 헤더를 열어 두어야 라우팅·진단이 되기 때문(13.3.11.1)
보호 수준 열거값governance.xsd 확인 — ProtectionKind 5종(NONE·SIGN·ENCRYPT·SIGN_WITH_ORIGIN_AUTHENTICATION·ENCRYPT_WITH_ORIGIN_AUTHENTICATION), data_protection_kind만 BasicProtectionKind 3종(origin authentication 불가)(13.3.11.2)
로깅·데이터 태깅 플러그인SROS 2 경로에 없다 — governance.xsd에 항목 자체가 없고 생성 파일에도 나타나지 않는다. (확인 필요: 지원) 2건 해소(13.3.11.3)
ros2_tracing 계측점 35종실제로 트레이싱을 켜서 전수 확보 — rmw 4 · rcl 9 · rclcpp 17 · 콜백 2 · 라이프사이클 2. (확인 필요: 정확한 이름·목록) 해소(11.6.11.2)
계측점이 드러내는 계층rclcpp_publish → rcl_publish → rmw_publish(1.4.2)와 rmw_take → rcl_take → rclcpp_take → callback_start/end(1.4.3)가 이름으로 그대로 존재. intra-process에는 ring buffer 이벤트가 4종
UST vs 커널 트레이싱컨테이너에서 UST는 동작(35 events), 커널은 비활성 — ROS 2 계층 인과 추적은 비특권 환경에서도 된다. 커널이 필요한 것은 CPU 경합·선점을 볼 때(11.6.11.1)
ros2 trace 명령 형태동사 4종(start·stop·pause·resume) + -s 옵션 방식도 동작. 기본 경로 ~/.ros/tracing/<세션> — 책 표기가 맞았음을 확인. 패키지 4종 실재
18차 검증(2026-08-16 · CHG-45) — RT 스케줄링과 micro-ROS 가용성아래 — 확인 가능한 부분과 불가능한 부분을 나눠 기록
RT 스레드 상속chrt -f 50으로 노드를 실행하면 15개 스레드 전부가 SCHED_FIFO 50을 상속 — DDS 송수신(dds.udp.*·dds.shm.*)·트레이싱 스레드까지. "제어 루프만 RT"가 되지 않는다. (확인 필요) 해소(12.2.11.2)
RT 스로틀링 기본값sched_rt_runtime_us 950000 / sched_rt_period_us 1000000 = 95%. 책 서술과 일치 확인(12.2.11)
스케줄링 정책·우선순위 범위chrt -m — SCHED_FIFO/RR 1~99, 나머지 0/0. SCHED_DEADLINE은 실재하되 우선순위가 아닌 주기·실행시간·데드라인으로 지정(12.2.11.3)
RT 권한ulimit -r 기본 0 → 일반 사용자는 Operation not permitted. limits.conf의 rtprio는 로그인 셸에만 반영(su -), systemd는 LimitRTPRIO=가 따로 필요. memlock 기본 8192KB(12.2.11.3)
PREEMPT_RT 판별/sys/kernel/realtime 파일 존재 여부가 확실한 기준. 이 환경은 PREEMPT_DYNAMIC(RT 아님) — uname | grep -i rt로는 오독한다(12.2.11.1)
micro-ROS 에이전트micro_ros_agent가 apt에 없다(소스 빌드 필요) — 이 장 실행 예시의 선행 단계가 빠져 있었다. 반면 micro_ros_msgs·micro_ros_diagnostic_*·rclc 4종은 정식 배포(6.8.13)
micro_ros_msgs 구성Entity·Node·Graph 셋뿐 — MCU 노드를 ROS 2 그래프에 드러내기 위한 것. DDS 참가자는 에이전트이지 MCU가 아니다(6.8.13.3)
확인하지 못한 것(명시)RT 커널의 지연 특성·부트 파라미터 효과(PREEMPT_RT 아님), micro-ROS의 실제 통신·시간 동기·메모리 설정(보드 없음) — 해당 (확인 필요) 표기는 그대로 두었다
19차 정리(2026-08-16 · CHG-47) — 장 사이 정합성실행 검증이 아니라, 한 장에서 확정한 사실이 다른 장에 닿았는지 점검
공분산 0의 두 얼굴Imu.msg 규약은 "모름", 그런데 소비자가 그대로 쓰면 분산 0 = 절대 신뢰. 7.1.7의 (확인 필요) 2건을 실측으로 해소하고 7.1·7.6 양쪽에 규약↔구현 관계를 명시(7.1.7·7.1.12.2·7.6.9)
chrt -p 의 한계메인 스레드만 보여 준다 — 6.6의 RT 검증 절차를 /proc/<PID>/task 순회로 보강(12.2.11.2와 연결)
pgrep -f 함정책의 진단 예시 18곳이 -f를 쓴다. 자기 셸·편집기까지 매칭하고 pkill -f면 셸이 종료된다 — 안전한 형태 셋과 함께 신설(11.1.13)
image_transport 전제 전파7.8의 파이프라인 예시에 플러그인 설치 전제 명시(7.2.12와 연결)
20차 검증(2026-08-16 · CHG-48) — 기록·제어 스택장 단위로 (확인 필요)를 한꺼번에 처리하는 방식으로 전환
rosbag2 기본 저장소mcap(metadata.yaml의 storage_identifier, version: 9). 옵션 전수 확인 — --max-cache-size·--storage-preset-profile {none,fastwrite,zstd_fast,zstd_small}·압축 zstd 하나뿐. 오래된 조각을 지우는 순환 기록은 없다(분할만 한다)(11.2.13)
ros2 bag convert·reindex둘 다 동작 확인 — sqlite3→mcap 변환 성공, metadata.yaml을 지운 뒤 복구 성공. 비정상 종료된 bag을 되살리는 수단(11.2.13.3)
rosbag2 QoS 오버라이드발행자보다 강한 조건을 요구하면 비호환으로 구독이 매칭되지 않고 bag이 빈다 — 명령은 성공하므로 ros2 bag info로 메시지 수를 확인해야 한다(11.2.13.4)
diff_drive_controller의 구독 타입Jazzy는 TwistStamped만 받는다 — use_stamped_vel 파라미터가 없어졌다. Twist를 보내면 에러 없이 로봇이 안 움직인다. 8.1·1.2 함정에도 전파(6.5.12.1)
컨트롤러 워치독diff_drive_controller는 cmd_vel_timeout 0.5초, forward_command_controller는 타임아웃 자체가 없다 — 관절이 마지막 값을 유지한다(6.5.12.3)
21차 검증(2026-08-16 · CHG-49) — RSP·intra-process아래
/robot_description파라미터이면서 토픽이다 — 파라미터는 입력, 토픽은 출력(std_msgs/String · RELIABLE · TRANSIENT_LOCAL). 기본 QoS로 구독하면 콜백이 영영 안 온다(5.5.13.2)
robot_state_publisher 파라미터publish_frequency 기본 20 Hz(관절 상태가 100 Hz여도 tf는 20 Hz) · frame_prefix·ignore_timestamp 실재. /tf_static만 durability 오버라이드가 없다 — TRANSIENT_LOCAL 고정(5.5.13)
intra-process의 경계IntraProcessManager에 add_publisher·add_subscription뿐 — 서비스·액션은 대상이 아니다. 컴포지션해도 서비스는 직렬화·왕복이 그대로다. 엔티티별 Enable/Disable 혼용은 가능(2.7.12)
22차 검증(2026-08-16 · CHG-50) — <mimic> 소비자URDF 경로의 소비자 셋 다 지원 — 파서(check_urdf)·ros2_control(MimicJoint 구조체)·MoveIt(getMimic() 계열). SDF 변환에서의 보존은 미확인(Gazebo 없음)(5.6.11)
23차 검증(2026-08-16 · CHG-51) — 규약·도구 정본아래
왜곡 모델 이름sensor_msgs/distortion_models.hpp가 정본 — plumb_bob·rational_polynomial·equidistant 셋뿐이다. fisheye는 ROS 상수가 아니다(OpenCV 표현). 자유 문자열이라 오타가 조용히 통과한다(7.3.11)
LaserScan의 range 0.0range_min 미만이므로 버려야 할 값 — .msg가 "values < range_min … should be discarded"로 명시. 라이다 3종 패키지 apt 실재(7.5.3)
Nav2 BT 등록 노드nav2_tree_nodes.xml이 정본 — 제어 노드 셋(PipelineSequence·RecoveryNode·RoundRobin)·데코레이터 일곱·오류 코드 포트 3종. groot·groot2는 apt에 없다(8.6.12)
Jazzy 툴체인C++17(GCC 13.3.0 기본이 __cplusplus == 201703L) · CMake 3.28.3 · rmw_zenoh_cpp 0.2.9 — BK-05의 기준을 실물로 확인(0.3.4)
ros2 topic delay헤더 없는 타입은 거부한다(msg does not have header) — 스탬프 없는 메시지는 진단 도구조차 쓸 수 없다는 대가(11.7.6)
24차 검증(2026-08-17 · CHG-67) — 플레이북 자체를 실행해 봄QoS 불일치·도메인 불일치를 일부러 만들고 11.1의 절차대로 진단 — ros2 topic info -v 한 번으로 BEST_EFFORT 발행자가 드러나고, 도메인을 바꿔 재조회하면 숨은 노드가 보인다. 책의 진단 절차가 실제로 답을 준다(11.1.14)
25차 검증(2026-08-17 · CHG-68) — 플레이북 시나리오 셋 추가파라미터 이름 불일치는 조용히 무시(오류·경고 없이 기본값), ros2 pkg prefix가 실제 쓰이는 경로를 그대로 알려 줌, *컨테이너 안에서는 멀티캐스트가 정상*(막히는 것은 컨테이너 사이·호스트↔컨테이너) — 다섯 시나리오 모두 플레이북대로 진단됨(11.1.14)
26차 검증(2026-08-17 · CHG-69) — 컨테이너 경계·시각 오차컨테이너 통념을 정정했다: 같은 Docker 네트워크의 컨테이너끼리는 기본 브리지로도 디스커버리가 된다(talker→echo 수신까지 확인). 막히는 것은 다른 네트워크·다른 호스트이고, 호스트↔컨테이너는 차단이 아니라 가입 인터페이스 문제(3.9.12). ros2 doctor hello의 표 두 개가 층을 가른다(도메인 불일치 시 멀티캐스트 표만 채워짐, 11.1.15). tf 시각 오차는 오류 메시지의 두 숫자 차가 곧 오차 크기이고, 타임아웃을 다 쓰고 실패하면 시계(5.2.11)
27차 검증(2026-08-17 · CHG-70) — "묶으면 빠르다"의 경계6 MB 메시지로 지연·CPU·버퍼 주소를 측정했다. 조건이 맞으면 지연 38배·CPU 3배 이득이지만, const&로 발행하면 프로세스를 나눈 것보다 느리다(CPU 2.3배). 2.7.4의 N−1 복사 규칙이 정확히 재현됐고, TRANSIENT_LOCAL도 intra-process 경로를 쓴다(주소 30/30 일치)—단 지연 60배. 주소 비교는 할당자 재사용으로 거짓 일치가 난다는 측정 함정도 함께 확인(2.7.13)
28차 검증(2026-08-17 · CHG-71) — 유실 주입(tc netem)RELIABLE의 대가는 처리량이 아니라 꼬리 지연이었다(p99가 BEST_EFFORT의 100~200배). 실제 급변 지점은 메시지 크기 — 유실 5%에서 1 KB는 92% 도달, 256 KB는 0%이고 조각 수가 지수에 오르는 $(1-p)^{\lceil S/M \rceil}$ 모델이 실측을 설명한다. 큰 메시지에는 BEST_EFFORT도 답이 아니다(둘 다 0%). 3.9.3의 대처 순서 1번(양 줄이기)이 옳음을 확인(3.9.13)
29차 검증(2026-08-17 · CHG-72) — 도구 하나로 여러 장 닫기장이 아니라 도구 단위로 묶어 (확인 필요) 8건을 한 환경에서 닫았다. controller_manager의 RT 파라미터는 thread_priority·기본 50이고 권한이 없으면 경고만 내고 격하된다. IRQ 친화도는 16진 마스크와 smp_affinity_list 둘 다 있고, intel_pstate의 거버너는 둘뿐. rclpy는 콜백 예외가 spin() 밖으로 전파돼 노드가 종료된다(다중 스레드도 동일) — read_points_numpy·get_effective_level은 본문 그대로 실재(12.2.12·2.8.11)
30차 검증(2026-08-17 · CHG-73) — Nav2 1.3.12 · MoveIt 2.12.4 실물같은 배포판 안에서 두 대표 스택의 기본값이 어긋나 있다 — Nav2는 cmd_vel을 Twist로 내는데 Jazzy diff_drive_controller는 TwistStamped만 받는다(오류 없이 로봇이 안 움직임, 8.1.14.1). BT 틱은 10~20 Hz가 아니라 약 100 Hz(bt_loop_duration은 밀리초)이고 전역 계획 주기는 파라미터가 아니라 BT XML에 있다. 라이프사이클 상수에 CONFIGURE·CLEANUP이 더 있고, MoveItCpp는 moveit_ros_planning 제공이며 .h는 폐기 예정(8.1.14·9.1.12)
31차 검증(2026-08-17 · CHG-74) — MoveIt 2.12.4 플러그인 전수계획 어댑터가 요청·응답으로 갈리고 이름 전체가 바뀌었다(FixStartStateBounds→CheckStartStateBounds 등) — 다만 설명문은 여전히 "Fixes"라 동작은 유지된다. 저크 제한은 기본이 꺼져 있고(has_jerk_limits: false) 설정 위치는 joint_limits.yaml. CHOMP·STOMP·Pilz 모두 2.12.4로 실재하되 Pilz만 패키지·클래스 이름 규칙이 다르다. MoveItErrorCodes 전체 확정 — FAILURE=99999만 양수라 val < 0 판정은 새어 나간다(9.2.12)
32차 점검(2026-08-17 · CHG-75) — 구조·독자 경로실측이 아니라 책의 뼈대를 봤다. 부 일곱 개가 다리 없이 끝나고 있었고(체크포인트 뒤 곧장 새 부), 역할별 로드맵(A.6)이 13개 장에 닿지 못했다 — 1.12(라이프사이클)·1.13(컴포지션)·4.8(arm64)·13.2(보안)처럼 1차 독자가 반드시 쓰는 장들이었다. 둘 다 메우고 자동 점검 ⑯⑰로 상설화했다. 장→장 forward 참조는 0건 오류로 온전했다
33차 점검(2026-08-17 · CHG-77) — 함정 카탈로그 전수 대조123개 장의 함정 행 1,623개를 추출해 서로 대조했다. 같은 함정에 다른 대처를 권하는 곳 3건을 고쳤다 — 특히 4.5는 본문에서 --mixin이 기본 설치에 없다고 적고도 카탈로그에서는 그것을 대처로 권하는 자기모순이었다. 무선 대용량 RELIABLE의 대처 순서를 48차 실측에 맞춰 통일하고, 4개 장이 반복하는데 부록 색인에 없던 함정(헤더 미전파 등)을 A.2에 연결했다
34차 검증(2026-08-17 · CHG-78) — 표준 QoS 프로파일 런타임구조 축 다섯을 점검해 모두 건강함을 확인한 뒤(실측→카탈로그 전파·부 구성·목차 제목 133건 등), 여러 트랙이 핵심으로 꼽는데 실측이 없는 장 넷을 찾아 그중 1.6을 측정했다. 프로파일 표는 전부 일치했고 ClockQoS가 두 표 어디에도 없었다 — /clock은 BEST_EFFORT·depth 1·VOLATILE이라 유실되고 이력이 없다(늦게 기동된 노드가 시각을 못 받는 이유). .deadline() 체이닝은 기반 프로파일을 보존한다(1.6.11)
35차 검증(2026-08-17 · CHG-79) — message_filters 동기 정책책의 slop 공식이 틀렸다. 기준은 느린 쪽이 아니라 빠른 쪽 주기의 절반이다 — 네 조합의 실측 임계값이 $\tfrac{1}{2}T_{fast}$ 와 모두 일치했고, 100 Hz + 10 Hz에 권하던 50 ms는 실제 필요치(5 ms)의 열 배였다. 반면 큐 크기 공식은 임계값을 정확히 맞혔고(50/100/200/500 ms → q≥2/3/6/15), 큐가 하나 모자라면 성공률이 1~3%로 급락한다(완만한 저하가 아니라 급격한 붕괴, 7.7.12)
36차 검증(2026-08-17 · CHG-80) — 계층을 링커로 확인사용자 실행 파일은 librclcpp.so만 직접 링크하고 fastrtps는 0회 나온다 — "벤더를 바꿔도 재컴파일이 필요 없다"의 물리적 근거. 계층도에 없던 층 rmw_implementation이 실물에 있고 rcpputils::SharedLibrary로 런타임 로드한다(RMW_IMPLEMENTATION 오타가 조용히 실패하는 이유). 벤더 결합은 런타임에 두 곳 — rmw 구현과 typesupport. rclcpp가 rcl을 건너뛰고 rmw를 직접 링크하는 것도 확인(2.1.12)
37차 검증(2026-08-17 · CHG-81) — 할당 후크 실측책이 개념만 적어 둔 operator new 후킹을 실제로 적용했다. rclcpp::spin_some(node) 자유 함수는 회당 143회 할당(매번 executor 생성) — 지속 executor의 9회보다 16배다. 그래도 완전 무할당은 아니다(회당 7~9회 잔존) — "초기화 뒤 무할당"은 사용자 코드 원칙이지 rclcpp 전체 보장이 아니다. 무복사(unique_ptr)와 무할당(const& 재사용)은 동시에 만족되지 않는다(12.3.12)
38차 검증(2026-08-17 · CHG-82) — 타이머 드리프트·지터 수치10 ms · 1500회로 측정하니 누적 드리프트가 0.000%였다 — "장시간 어긋남"은 타이머가 아니라 시계 동기를 봐야 한다는 뜻. 세 executor의 p50은 같고 꼬리만 다르다 — Static(+0.125 ms) < Single(+0.528) < Multi(+1.161)로, 타이머 하나에 MultiThreadedExecutor는 손해다. 콜백이 주기의 50%%를 써도 무영향이지만 120%%면 주기가 곧 콜백 시간이 된다(2.5.13)
39차 검증(2026-08-17 · CHG-83) — 콜백 그룹·데드락 재현동시성 매트릭스 일곱 행 전부 재현. Reentrant는 같은 콜백을 4개 동시 실행해 처리량이 4배(63→249회)지만 같은 상태를 넷이 동시에 조작한다. 데드락 처방에 조건이 하나 빠져 있었다 — 서버가 같은 executor에 있으면 클라이언트만 분리해도 풀리지 않고, 응답을 만드는 쪽까지 빼야 한다. 컴포지션이 이 조건을 조용히 깬다(2.4.12)
40차 검증(2026-08-17 · CHG-84) — 스레드 수 대 호출 깊이"호출 깊이 + 여유만큼 스레드"가 틀렸다 — 연쇄 깊이 1~6 전 구간에서 3개면 충분했고(깊이 6도 3개), 확실한 것은 1개면 반드시 멈춘다는 것뿐이다. 실험 도중 콜백 그룹을 보관하지 않으면 콜백이 영영 실행되지 않는다는 조용한 함정을 발견했다 — 서비스는 정상 발견되고 요청도 나가지만 서버 콜백이 실행되지 않는다(2.4.13)
41차 검증(2026-08-17 · CHG-85) — 경쟁 조건 재현·TSAN같은 코드가 그룹 한 줄 차이로 증가의 54.7%%를 잃었다 (MutuallyExclusive 0.00%% ↔ Reentrant 54.7%%, 오류 없이). TSAN이 정확히 검출했다 — 문제 구성에서 경고 5건, 카운터와 불변식 두 줄을 모두 지목했고 정상 구성에서는 0건(오탐 없음). 부하는 이 작업부하에서 약 2배로, 책이 적은 5~15배보다 훨씬 부하가 작았다(2.9.10)
42차 정리(2026-08-17 · CHG-86) — 계측 도구를 책으로방향을 뒤집어 실측에 쓴 도구를 독자가 재현할 수 있게 모았다. 11.7.13에 계측 도구 다섯(할당 후크·겹침 계측·주기 분포·readelf 링크 확인·netem 유실 주입)을 각각 "쓰면서 걸린 함정"과 함께, 14.5.11에 그것을 회귀 단언 넷으로 굳히는 법을 넣었다. "놀라운 결과가 나오면 측정 장치를 먼저 의심하라"를 공통 원칙으로 명시
43차 정리(2026-08-17 · CHG-87) — 주제별 실측 색인검증표가 회차순이라 주제로 찾을 수 없던 문제를 풀었다. 목차 6에 41개 실측 절을 10개 주제로 다시 걸고 각 항목에 확정한 것을 한 줄씩 붙였다. 자동 점검 ⑲로 색인이 뒤처지지 않게 고정
44차 검증(2026-08-17 · CHG-88) — 디스커버리 규모참가자 5~40에서 디스커버리 시간이 N에 따라 늘지 않았다 — 값이 200/460/530 ms에 뭉치는 것이 알림 주기 대기임을 보인다($O(N^2)$가 아파지는 지점은 못 찾음). 대신 다른 벽에 먼저 부딪혔다 — 참가자당 /dev/shm 약 0.65 MB, Docker 기본은 64 MB. 비정상 종료는 세그먼트를 남기고(정상 종료는 정리됨), 가득 차면 오류 로그만 내고 UDP로 격하된다(3.3.12)
45차 검증(2026-08-17 · CHG-89) — Fast DDS XML을 실제로 먹임책이 "적용해 보지 않았다"고 적어 둔 장을 실측했다. rclcpp가 아는 정책은 전부 코드가 우선한다 — 코드가 명시하지 않은 durability조차 rclcpp 기본값이 XML을 덮었다. 예외는 depth 하나로 max(코드, XML)(3은 코드가, 20은 XML이 이김). 실패는 전부 조용하다 — 기본 프로파일 미표시·파일 경로 오타·깨진 XML·모르는 요소 넷 다 stderr 0줄. get_actual_qos()가 유일한 확인 절차(3.7.12)
46차 검증(2026-08-17 · CHG-90) — Cyclone에 같은 실험설정 오류에 대한 반응이 두 벤더 정반대다. Fast DDS가 조용히 무시한 네 가지를 Cyclone은 전부 노드 생성 실패로 처리하며 경로·줄 번호·요소 이름·값까지 출력한다. 더 조용한 차이도 나왔다 — SystemDefaultsQoS()의 기본 내구성이 Fast DDS TRANSIENT_LOCAL, Cyclone VOLATILE로 갈려 벤더를 바꾸면 늦은 구독자 동작이 달라진다(코드는 그대로). 이 과정에서 45차에 측정 없이 추론해 적은 값 1건을 정정했다(3.8.12)
47차 검증(2026-08-17 · CHG-91) — RTPS 패킷 캡처tcpdump로 포트 계산식을 전수 대조했다 — 도메인 0·1·7·33에서 $PB+DG\cdot D$ 가 정확히 맞고(7400/7650/9150/15650) 참가자 게인 2까지 확인(15660·15662). 도메인이 바뀌어도 멀티캐스트 그룹은 239.255.0.1 하나 — "도메인별 그룹 분리"는 불가능하고 포트 규칙만 갈린다. 조각 크기는 상수가 아니라 경로 MTU 의존(이더넷 1416 B ↔ loopback 13.5 KB)이라 3.9.13.3의 1400 B 가정을 한정했다(3.2.12)
48차 검증(2026-08-17 · CHG-92) — 책이 권한 시험을 실제로14.5.11의 회귀 단언 셋을 실제 패키지로 만들어 colcon test로 실행했다. 정상 구성에서 셋 다 통과했고 값이 앞선 회차와 일치(할당 9.00 · 최대 동시 1 · p99 10.061 ms · 드리프트 −0.0006%). 일부러 어기니 의도한 이유로 실패했다(Reentrant → 최대 동시 4, 자유 함수 → 회당 125). 한 번은 실패 이유가 잘못됐고(무관한 예외) 그것을 검출해 고친 과정을 남겼다(14.5.12)
49차 검증(2026-08-17 · CHG-93) — 파라미터 병합 의미론책이 (확인 필요)로 남긴 항목을 닫았다. 부분 덮어쓰기는 딥 머지로 기대대로 동작한다(B가 sub.y만 줘도 sub.x 유지). 그러나 "뒤가 앞을 덮는다"에 예외가 있다 — 지정자의 구체성이 순서를 이겨서, 가장 나중 /**: 파일도 앞선 /노드이름:을 못 덮고(3회 동일) 명령줄 -p조차 진다. "급하니 -p로"가 조용히 무시되는 조건이 밝혀졌다(14.4.11)
50차 검증(2026-08-17 · CHG-94) — 라이프사이클 전이 실패반환값 표를 실물로 확인했다 — FAILURE는 on_error를 거치지 않고 원래 상태로, ERROR만 ErrorProcessing을 지나며, on_error가 FAILURE면 finalized다. on_activate 실패는 inactive가 아니라 unconfigured로 떨어져 설정 자원까지 잃는다. 그리고 finalized 노드는 전이를 하나도 못 받으면서 그래프에 남아 있어 프로세스 감시로는 감지하지 못한다(1.12.10)
51차 검증(2026-08-17 · CHG-95) — 라이프사이클 관리자nav2_lifecycle_manager의 세 주장을 갈랐다. 순서는 정확(configure 전부 → activate 전부). 그러나 실패해도 되돌리지 않는다 — activate 실패 시 앞선 노드가 active로 남아 부분 활성 시스템이 된다(Aborting이라 적혀 있는데도). bond는 노드 쪽 지원이 필요해 표준 라이프사이클 노드를 물리면 "may be misconfigured"로 부팅이 실패한다(1.12.11)
52차 검증(2026-08-17 · CHG-96) — 종료 코드와 재시작 정책rclcpp가 SIGTERM·SIGINT를 처리해 코드 0으로 종료한다 — systemctl stop에는 옳지만, 밖에서 온 kill도 똑같이 0이라 Restart=on-failure가 되살리지 않는다. systemd가 감지하는 것은 SIGKILL(137)·콜백 예외(134)·SIGHUP(129)뿐이고, finalized 노드와 부분 활성 시스템은 프로세스가 정상이어서 구조적으로 볼 수 없다 — 프로세스 감시와 상태 감시는 다른 층이다(14.8.13)
53차 검증(2026-08-17 · CHG-97) — 진단을 워치독으로52차가 요구한 상태 감시를 실제로 만들었다. 집계기가 침묵한 노드를 Stale로, finalized를 보고하면 Error로 최상위까지 전파한다 — systemd의 감시 공백이 메워진다. 단 finalized 노드는 계속 발행하므로 라이프사이클 상태를 진단에 실어야만 검출된다. 함정 둘: 개별 항목은 종료돼도 마지막 값이 남고, 분석기 매칭이 실패하면 빈 분석기가 Stale이 되어 전체를 끌어내린다(6.9.14)
54차 점검(2026-08-17 · CHG-98) — 범위 선언의 타당성"다루지 않는다"고 적은 자리들을 검토했다. BK-04의 포함·제외 목록은 그대로 타당했고, 낡은 진술 1건을 찾아 고쳤다(3.3.5가 "1.13에서 다루지 않은 이점"이라 했는데 1.13.2가 이미 다루고 있었다). 8.1.13이 남긴 (확인 필요)도 닫았다 — nav2_route는 Jazzy에 실재하고(1.3.12), 책이 적은 이름은 전부 정확했으며 비용 함수 3종·동작 1종이 더 있었다(8.1.15)
55차 점검(2026-08-17 · CHG-99) — 교차 주장의 적중률"RJ-NN에서 다룬다" 형태의 명시적 주장 24건은 전수 확인 결과 모두 정확했다. 다만 다른 장의 내용을 단정하는 문장은 낡을 수 있어 1건을 정정했다 — 1.13이 "TRANSIENT_LOCAL이 지원되지 않을 수 있다"고 (확인 필요)까지 달고 있었는데 12·47차 실측이 동작함을 확정해 둔 상태였다. 자동화를 시도했으나 37건 중 실제 1건이라 정밀도가 낮아 폐기 목록에 기록
56차 점검(2026-08-17 · CHG-100) — 요약 문서의 뒤처짐133장을 대신 설명하는 문서(BOOK.md·목차 3)가 24회차만큼 낡아 있었다 — "서른한 차례"·"66장"·"570건"이 실제로는 55차·75장·607건이었다. 갱신하며 왜 (확인 필요)가 늘었는지를 처음 설명했다: 한 항목을 확인하면 그 옆의 미확인이 드러나기 때문이며, 각 실측 절 끝의 "확인하지 못한 것"이 그 출처다
57차 검증(2026-08-17 · CHG-101) — 로깅의 비용과 API억제된 로그는 사실상 무비용(0.053 µs·할당 0)지만 출력되는 로그는 평균 7.5 µs·최대 188 µs로 1 kHz 예산의 19%를 점유한다 — 1.11.7의 경고가 숫자로 확인됐다. {time}은 벽시계라 use_sim_time을 켜도 안 바뀌고, 시뮬에서 로그 시각과 메시지 스탬프가 어긋난다. rclcpp::Logger에는 is_enabled_for가 없다(rclpy엔 있다) — 포맷 토큰 8종은 전부 동작하고 모르는 토큰은 그대로 출력된다(1.11.12)
58차 검증(2026-08-18 · CHG-102) — C++/Python 비대칭두 언어를 나란히 측정한 항목을 한자리에 모았다. 로깅이 억제 13배·출력 17배 비용이 크고 최대는 8 ms(C++ 188 µs). 결정적 차이는 throttle — C++은 매크로가 먼저 걸러 0.128 µs인데 Python은 인자라 억제돼도 128 µs로 성능 대책이 아니다. 억제된 로그의 인자가 200/200회 평가되는 것도 카운터로 확인(2.8.12)
59차 검증(2026-08-18 · CHG-103) — rclpy executor·GIL 통념"GIL 때문에 MultiThreadedExecutor가 무의미하다"를 작업 종류로 갈랐다 — 계산만 하면 오히려 16% 손해(483→408회)지만 대기가 있으면 4배(146→590회, 최대 동시 4). C++의 배수와 거의 같다(2.4.12.2의 4배). 그룹을 안 바꾸면 아무 일도 안 일어나는 것도 동일. 타이머는 드리프트 ≈0으로 C++과 같고 꼬리는 약 5배 넓다(2.8.12.7·2.8.12.8)
60차 검증(2026-08-18 · CHG-104) — Python의 실제 비용같은 프로그램을 두 언어로 만들어 나란히 측정했다. 비용은 발행이 아니라 메시지 생성에 있다 — Imu()가 C++ 0.075 µs 대 Python 17.7 µs로 237배이고, publish 자체는 3.8배뿐이다(직렬화는 양쪽 다 C 확장). spin_once가 328 µs라 Python으로 1 kHz 제어 루프는 성립하지 않는다(주기의 33%). 메시지 재사용이 Python에서 2.3배 이득(C++은 1.2배). 측정 중 tracemalloc 스냅숏 차이가 할당량이 아님을 확인해 11.7.13에 남겼다(2.8.12.9)
61차 검증(2026-08-18 · CHG-105) — QoS 축을 닫으며 만난 것2.8.12가 남겨 둔 QoS 비대칭을 측정했다. 값은 차이가 없다(같은 rmw 상수를 읽는다) — 차이는 이름뿐으로 ClockQoS·RosoutQoS가 Python에 없고, QoSPresetProfiles는 .name이 별칭으로 접혀 틀린 이름을 돌려준다(순회 7개 · 실제 9개). 확인 중 더 큰 것이 걸렸다 — system_default 엔드포인트는 TRANSIENT_LOCAL로 보고되는데 늦은 구독자가 아무것도 못 받는다(대조군은 전부 받았다). 진단 명령의 QoS 줄을 믿을 수 없는 자리라 11.1의 단정에 예외를 달았다(1.6.12·2.8.12.10)
62차 검증(2026-08-18 · CHG-106) — 진단 명령을 검증했다61차가 topic info -v의 어긋남을 찾았으니 나머지 진단 명령도 같은가를 대조군을 두고 시험했다. ros2 param get은 저장값이지 적용값이 아니다 — 콜백 없는 노드는 Set parameter successful 뒤에도 옛 값으로 실행된다(거부는 정직하게 보고된다). 강제 종료된 노드는 node list에 22초 남고(정상 종료는 0초), 대안이라던 --no-daemon은 실행 중인 노드를 10회 중 2회 놓친다 — 거짓 양성과 거짓 음성의 교환이다. ros2 doctor는 노드가 서로 안 보이는 상태에서도 All 5 checks passed. 공통 결론: 명령이 틀린 것이 아니라 독자가 묻는 것과 다른 것을 답한다(0.7.11)
63차 검증(2026-08-18 · CHG-107) — hz는 어디까지 믿나62차가 명령을 검증했으니 이번엔 수치다. 0.7.10의 체크포인트가 묻는데 책에 답이 없던 항목이다. 발행률을 스스로 세는 C++ 발행자로 대조하니 정상 상태에서는 1 kHz까지 오차가 사실상 0이었다 — "Python이라 못 믿는다"는 통념은 틀렸다. 실제 오차원은 창이 메시지 단위(기본 10,000개)라는 것 — 100 Hz→10 Hz 급락 10초 뒤 기본 창은 66.8 Hz, --window 10은 9.999 Hz였다. 창이 기억하는 시간 = 10,000/발행률이므로 느린 토픽일수록 기억이 길다(10 Hz면 17분). 발행이 완전히 멈추면 0 Hz도 경고도 없이 출력만 끊긴다 — 화면에는 마지막 값이 남는다. 부하 조언은 근거가 없었다(4코어 점유해도 결과 동일)(0.7.12)
64차 검증(2026-08-18 · CHG-108) — 짐작으로 적은 원인 넷63차의 제안대로 때문이다가 연결됐는데 그 장에 실행 확인이 없는 문장을 기계로 추출해(109개 중 35개) 검증 가능한 넷을 측정했다. 하나는 결론이 틀렸다 — __ 예약은 리매핑 규칙에서만 강제되고 Node("__weird")·create_publisher("__weird")는 그대로 만들어진다(1.1.12). 하나는 파라미터 이름이 존재하지 않았다 — 서비스 introspection은 파라미터가 아니라 configure_introspection() 메서드이며 이벤트는 /_service_event 토픽이다(1.7). 하나는 맞았고 숫자를 얻었다 — 도메인 ID 경계는 232(3.1.13). 하나는 정확했다 — 액션 = 서비스 3 + 토픽 2(1.2)
65차 검증(2026-08-18 · CHG-109) — 조각화 모형을 두 번째 유실률로64차의 제안대로 3.9의 인과 문장을 겨눴는데, 이미 5% 한 열로 측정해 본 주장이었다(3.9가 실행 확인 표기를 안 써서 후보 목록에 잘못 포함됐다 — 점검 ㉑ 신설로 이어졌다). 그래서 모형 검증으로 방향을 바꿨다. tcpdump로 조각을 세니 조각당 1,480 B(책의 가정 1,400보다 크다 — 예측이 낙관적이었다). 유실 1%를 새로 측정하니 $(1-p)^N$이 네 칸 모두 몇 %p 안에 들었다 — 인과가 우연이 아님이 확인됐다. 다만 5%·64 KB에서는 모형 10%인데 실측 0% — 모형은 상한으로 써야 한다. 가장 쓸모 있는 것은 RELIABLE이 포기하는 지점: 16 KB에선 60→82%로 일하고 64 KB에선 2%다(3.9.14)
66차 점검(2026-08-18 · CHG-110) — 표기 규약과 실제의 대조65차가 "표기가 곧 상태다"를 드러냈으므로 목차 5가 선언한 규약과 본문 133장의 실제를 기계로 대조했다. 대체로 지켜지고 있었다 — 인터페이스 표기(패키지/msg/타입) 위반 0건, 명령·환경변수 코드체 위반 0건, (확인 필요) 618건 중 낱말이 다른 것 2건뿐. 라벨 형태는 > 견해: 1,042 대 **견해: 5건이라 통일했다. 실제 공백은 규약에 없던 것이었다 — 실측 절 60개 중 5개가 측정 환경을 안 밝혀 독자가 수치를 옮길 수 있는지 판단할 수 없었다. 규약 2줄을 신설하고(실행 확인 낱말 의무 · 환경 명시) 점검 ㉒를 더했다. "견해에 근거를 적었나"는 기계로 못 가른다는 것을 확인하고 폐기 검사 (h)로 기록했다
67차 검증(2026-08-18 · CHG-111) — (확인 필요) 여섯 건 해소618건의 미확인 목록에서 컨테이너 한 번이면 답이 나오는 것을 기계로 골라(87건 후보) 여섯을 해소했다. 하나는 책이 거꾸로였다 — rosdep 소스 목록은 "큰 번호가 덮어쓴다"가 아니라 먼저 선언한 쪽이 우선한다(apt 방식). 10-·30- 두 파일로 확인하고 대조군까지 뒀다(4.6.11). <build_type> 기본값은 catkin이고, 빠뜨리면 "CMakeLists.txt가 없다"는 잘못된 오류로 실패한다(4.4). Discovery Server CLI는 Jazzy에 fastdds·fast-discovery-server·ros-discovery 셋 다 설치된다(3.4·3.1). 실시간 도구 패키지는 realtime_tools(1.11·2.6), SubscriptionOptions에 message_memory_strategy 필드는 없다(2.6)
68차 검증(2026-08-18 · CHG-112) — 미확인 여덟 건을 얕고 넓게67차의 제안대로 계속 줄여 나갔다. API 예시 둘이 틀렸다 — ExecutorOptions는 체이닝이 아니라 평범한 구조체(opts.context = ctx)이고, guard condition은 executor가 아니라 WaitSet에 넣는다. Waitable 예시가 쓰는 서명 셋은 Jazzy에서 [[deprecated]]다(포인터→참조·비-const→const). NodeInterfaces는 템플릿 헬퍼로 실재한다. ros2 control 서브커맨드 열둘을 전수해 세 장의 미확인을 한 번에 닫았고(책이 쓰던 이름은 전부 맞았다), fake_components는 Jazzy에 없다(mock_components/GenericSystem뿐). time-sync.target은 이름이 맞았고 time-set.target과 다르다는 것을 더했다(2.10.11·6.3.11)
69차 검증(2026-08-18 · CHG-113) — 코드 예시를 실제로 컴파일했다68차의 제안대로 rclcpp를 쓰는 C++ 코드펜스 122개를 추출해 컴파일했다(생략기호 없는 것만, -fsyntax-only -Wdeprecated-declarations). 조각이라 온전히 통과한 것은 19개뿐이지만, 읽어서는 안 걸리는 결함 넷이 검출됐다. create_jump_callback은 인자가 셋인데 책은 둘만 넘겼다 — threshold를 선언해 놓고 안 넘겨 세 곳 모두 컴파일 실패(1.10.11). create_service/create_client에 rmw_qos_profile_t를 넘기는 오버로드는 [[deprecated]] — 네 곳을 rclcpp::ServicesQoS()로(2.4·1.7). realtime_tools의 .h 헤더는 #warning으로 폐기를 알린다 — .hpp로(6.4). 89차에 검출한 ExecutorOptions 오류도 컴파일러가 독립적으로 확인해 줬다
70차 검증(2026-08-18 · CHG-114) — Python 예시는 문제가 없었다69차의 제안대로 컴파일러가 없는 쪽을 봤다. ast로 import·열거 멤버·생성자 키워드를 추출해 런타임에 대조하니 68 / 4 / 4 전부 실재했고, 문법 오류도 폐기 형태도 0건이었다(인자가 하나인 declare_parameter는 Jazzy가 경고하는데 책엔 없다). C++ 10곳 대 Python 0곳이라는 비대칭이 남았다 — 인과는 확인하지 못했다. 이름만 보는 방법의 한계(인자 개수·순서를 못 본다, self가 무엇인지 모른다, 실행하지 않았다)를 함께 적었고, 덤으로 rclpy의 폐기 별칭 오타를 찾았다 — LivelinessPolicy에 RMW_QOS_POLICY_DURABILITY_UNKNOWN이 붙어 있고 …LIVELINESS_UNKNOWN은 없다(2.8.13)
71차 검증(2026-08-18 · CHG-115) — 명령과 타입 이름 전수70차의 제안대로 CLI 표면을 봤다. 133개 장의 코드펜스에서 ros2·colcon 명령 조합 81종과 롱플래그 31종을 추출해 --help로 대조하고, 본문·코드의 인터페이스 타입 63종(219회)을 ros2 interface list와 맞췄다. 전부 실재했다 — 남은 10종은 책이 일부러 지어낸 예시 이름이고, nav2_msgs·moveit_msgs 9종은 패키지를 설치해 확인했다(이름 오류와 미설치를 가르지 않으면 오판한다). 걸린 것은 하나 — --ros-args가 ros2 run --help에 없다. 노드로 넘기는 인자라 없는 것이 정상이지만, "--help가 정본" 원칙의 예외라 명시했다. 69~71차를 묶으면 결론이 하나다: 이름은 안 틀리고 의미가 틀린다(A.1.15·목차 3)
72차 검증(2026-08-18 · CHG-116) — 숫자·순서 단정 문장 6건71차의 제안대로 의미 쪽을 봤다. "먼저"·"우선"·"기본값은"·"이긴다" 류 표현을 가진 문장 중 실행 확인이 없는 것을 골라 여섯을 측정했다. 다섯은 책이 맞았다 — __ns 리매핑이 순서와 무관하게 먼저 반영(1.1.12.4), 이름 충돌 시 둘 다 생존(1.1.12.5), ROS_AUTOMATIC_DISCOVERY_RANGE 기본값 SUBNET(0.4.12), 타이머가 구독보다 25/25회 먼저 실행(2.3.14). 하나는 틀렸다 — launch의 LaunchConfiguration은 기본값이 없어도 "빈 문자열이 되고 종료되는" 게 아니라 참조 즉시 명시적 예외로 종료된다(14.3.12.4). 가장 값진 것은 오버레이 우선순위의 숨은 안전판 — 표준 절차로 빌드했다면 이후 source 순서를 거꾸로 해도 오버레이가 계속 우선한다(0.5.3.1)
73차 검증(2026-08-18 · CHG-117) — 2.3.14가 남긴 미확인 두 건을 마저 닫음 + 회차 번호 이중화 해소72차의 세 제안 중 ①(번호 이중화)을 먼저 처리했다 — 이 표의 71개 행 전부에 실제 CHG-NNN을 병기해, 표의 "N차"와 CHANGES.md의 "갭 점검 N차"가 다른 수라는 71차의 발견(목차 3 하단 각주)을 표 자체에서 바로 찾을 수 있게 했다(매칭은 RJ 참조·특징 수치 대조로 자동화, 72(표)+24(비실행)=96(CHG-21~116) 산수로 교차검증). ②(숫자·순서 단정 계속 검증) — 2.3.14의 "확인하지 못한 것" 두 가지를 마저 측정했다: rclcpp(C++)도 타이머가 25/25 먼저(rclpy와 동일), 구독이 서비스보다 25/25 먼저(TIMER → SUB → SRV 확정, 12.5.4·2.2.9에 전파). 덤으로 액션 result_timeout 기본값이 rclcpp_action은 10초, rclpy는 900초로 90배 다름을 발견(1.8.8 신설)
74차 검증(2026-08-18 · CHG-118) — 클라이언트·waitable 포함 5단계 전체 + MultiThreadedExecutor73차가 닫은 TIMER→SUB→SRV에 클라이언트·waitable을 더해 colcon build로 실제 실행 파일을 빌드했다(문법 검사가 아니라 링크·실행) — SingleThreadedExecutor에서 TIMER→SUB→SRV→CLIENT→WAITABLE 5단계 전 순서가 25/25(정·역방향 등록) 재현됐다. MultiThreadedExecutor는 함정이 있었다 — spin_some()을 부르면 multi_threaded_executor.hpp가 spin()만 오버라이드하는 탓에 스레드 풀이 전혀 관여하지 않아 순서가 그대로 보이지만(25/25), 실제 spin() 동시 실행에서는 SUB·SRV·CLIENT 순서가 16~47% 확률로 깨졌다(TIMER만 40/40 항상 1번). 커스텀 waitable(guard condition)은 SingleThreadedExecutor에서 매번 실행되지만 MultiThreadedExecutor.spin()에서는 9~36%만 실행되고 나머지는 완전히 유실됐다. 덤으로 액션 피드백이 목표 수락 직후 발행되면 순서가 안 바뀌고 55번 중 최대 35/35(정상 사용 패턴에선 1/20) 확률로 조용히 유실됨을 발견(GoalHandle이 응답 콜백 안에서야 등록되는 구조, 1.8.13 신설). 14.3·A.2의 파라미터 순서 (확인 필요) 2건은 49차(14.4.11)가 이미 닫은 사실을 못 옮겨 적은 교차참조 갭이었음을 발견해 연결
75차 검증(2026-08-19 · CHG-119) — 두 유실의 정확한 메커니즘: 소스로 확정74차가 남긴 두 (확인 필요)를 rclcpp·rclcpp_action 소스(GitHub Jazzy 브랜치)와 독립 재현으로 닫았다. 액션 피드백 유실: 원인은 2.3.14의 최상위 SUB>CLIENT 우선순위가 아니었다(95차의 추정을 정정) — 액션 클라이언트는 rclcpp::Waitable 객체 하나로 등록되고, 그 내부 ClientBase::is_ready()가 피드백을 응답보다 항상 먼저 보고하도록 하드코딩돼 있다(client.cpp). 경계도 좁혔다 — 클라이언트 쪽엔 안전한 지연이 없다(스핀 시작을 늦출수록 25%→99% 유실로 악화, "정상 사용 5%"라던 95차 수치는 저표본이었고 유휴 시간을 두면 87% 이상으로 상승한다). 서버가 1 ms만 늦춰도 없앨 수 있지만 클라이언트가 이미 스핀 중일 때만 유효하다(post 모드에선 1000 ms를 줘도 100% 그대로). MultiThreadedExecutor waitable 유실: executor.cpp의 get_next_ready_executable()이 구독·서비스·클라이언트·waitable을 전진 전용 반복자로 스캔하는데, 구독 등은 데이터가 DDS reader에 남아 다음 주기에 다시 포착되지만 guard condition은 엣지 트리거라 한 번 수집되면 트리거가 사라져 다시는 포착되지 않는다. 최소 재현(타이머+waitable)으로 같은 MutuallyExclusive 그룹을 공유하고 스레드 2개 이상이면 100% 유실, 전용 그룹이면 스레드 수와 무관하게 100% 성공이라는 이진 스위치를 확인했다 — 95차의 9~36%라는 중간 확률은 5개 엔티티가 뒤섞인 원 실험에서 이 스위치가 타이밍에 따라 갈린 결과로 설명된다
76차 검증(2026-08-19 · CHG-120) — EventsExecutor·Reentrant·원격 네트워크: 75차가 남긴 세 제안을 전부 닫음75차(CHG-119) 제안 셋을 우선순위대로 재현했다. EventsExecutor 순서: 다섯 엔티티 실험을 EventsExecutor로 재실행하니 SUB→CLIENT→SRV→TIMER로 타이머가 맨 뒤(14/14) — wait set executor의 TIMER가 항상 1번인 것과 정반대다(2.3.14). EventsExecutor의 커스텀 waitable: 2.10.11.3의 폴링형 waitable을 그대로 등록하면 add_node()에서 runtime_error로 즉시 실패한다(기반 클래스가 set_on_ready_callback 미구현을 던짐) — set_on_ready_callback·take_data_by_entity_id까지 구현하면 80/80 성공. Reentrant 그룹: MultiThreadedExecutor의 waitable 유실(96차, 0/35)이 Reentrant로 바꾸면 45/45 성공으로 사라진다(95차의 견해가 옳았음을 확인) — 다만 공유 상태에 보호 없이 접근하면 11~14%의 실제 손실 업데이트가 새로 생긴다(2.10.11.3). 액션 피드백의 EventsExecutor 면역: 로컬호스트에서 오프셋 0~500 ms 전부 55/55 무유실(2.3.14의 원인 분석과 일치 — 피드백이 별도 엔티티 ID로 독립 처리됨) — 그러나 "warm" 반복 전송 조건에서는 executor와 무관하게 유실(0/20·0/15). 두 컨테이너 + netem 지연(~100 ms RTT): 로컬에서 안전하던 오프셋 0 ms 조건이 약 50% 유실로 악화되고, EventsExecutor의 로컬 무유실(55/55)도 RTT에서는 약 51% 유실로 수렴 — 실제 네트워크의 패킷 재정렬이 EventsExecutor의 FIFO 안전성을 무너뜨린다는 가설(1.8.13, (확인 필요))
77차 검증(2026-08-19 · CHG-121) — EventsExecutor "warm" 모드 정정 + 패킷 재정렬 실측 확정76차(CHG-120)가 남긴 제안 ①②를 진행했다. "warm" 모드 재검증: 액션 클라이언트의 GoalHandle 등록과 무관하게 피드백 도착을 직접 확인하는 "그림자" rclcpp::Subscription을 더한 새 하네스로 재요청 조건을 다시 측정하니, 76차의 "executor 무관 전부 유실(0/20·0/15)"이 재현되지 않았다 — EventsExecutor는 warm 모드에서도 85/85(100%) 안전했고, SingleThreadedExecutor도 5~8%(95차의 원래 "정상 사용 5%"에 더 가까움)로 나왔다. 손실 5건 전부 그림자 구독의 피드백 도착이 응답 콜백보다 먼저였다는 점은 96차가 확정한 is_ready() 하드코딩과 정확히 일치한다. 76차 하네스 소스가 남아 있지 않아 정확히 무엇이 달랐는지는 특정하지 못했다(확인 필요). 패킷 재정렬 확정: 두 컨테이너 + tc netem(~100 ms RTT) 환경을 tcpdump/tshark로 캡처해, RTPS writerSeqNumber로 재전송과 새 샘플을 구분한 뒤 응답·피드백 패킷 쌍 하나를 특정했다 — 서버는 응답을 피드백보다 0.105 ms 먼저 만들었지만(인과 순서 정상), 클라이언트 NIC에는 피드백이 응답보다 1.404 ms 먼저 도착했다 — 패킷 재정렬이 실제로 일어남을 패킷 수준에서 직접 확인했다(76차가 추론으로 남긴 가설을 실측으로 승격, 1.8.13)
78차 검증(2026-08-19 · CHG-122) — 커널 타임스탬프로 로컬 warm 손실 확정 + rclpy 언어 비대칭77차(CHG-121)가 남긴 제안 ①②를 진행했다. 커널 타임스탬프: Fast DDS의 기본 SHM 전송을 UDPv4만으로 강제한 뒤(그렇지 않으면 tcpdump가 아무것도 못 본다 — 첫 함정) 클라이언트 프로세스와 tcpdump -i lo를 나란히 실행해 240회(단일/gap0 100·단일/gap300 40·EventsExecutor/gap0 100)를 측정했다 — 커널이 실제로 응답 패킷을 받은 시각은 240회 전부(8건의 손실 포함) 피드백보다 먼저였다. 로컬 warm 손실은 네트워크 도착 순서 문제가 아니라 순수하게 is_ready()의 하드코딩된 폴링 우선순위 문제임을 앱과 독립된 커널 캡처로 확정했다(96차 소스 분석과 정합). 트라이얼 인덱스 분포로 97·98차가 갈린 이유의 세 후보(워밍업·컨테이너 시작 잡음·실제 확률적 경쟁)를 좁혔다 — 단일 프로세스 100회·순차 신선 컨테이너 15개×15회(225회) 모두 첫 7~12회는 예외 없이 안전했고(컨테이너 시작 잡음 가설은 배제) 그 뒤로는 특정 패턴 없이 산발적으로 났다(실제 확률적 경쟁에 가장 부합) — 초기 무사 구간의 원인 자체는 못 밝힘. 부수 관측: 컨테이너를 병렬로(12개 동시) 기동하면 마이크로초 경쟁이 아니라 CPU 경합에 의한 실제 스케줄링 기아(피드백 최대 689개 적체, 1초 이상 지연)라는 별개 현상이 나옴을 확인, 순차 실행으로 전환. rclpy 언어 비대칭: rclpy.executors엔 EventsExecutor가 없고 rclpy.experimental.events_executor에 숨어 있음을 확인. C++ 최악 조건(cold offset 100 ms, C++ 99~100% 유실)을 포함해 240회를 rclpy로 재현하니 손실 0건 — 소스로 확인한 원인은 send_goal_async()가 클라이언트 생성 UUID로 feedback_callback을 요청 전송 전에 미리 등록하는 구조(rclcpp_action의 GoalHandle은 응답 콜백 안에서야 등록됨)여서, 이 손실 계열 자체가 rclcpp_action의 구현 선택이지 ROS 2 액션 프로토콜의 결함이 아님을 보임(1.8.13)
79차 점검(2026-08-19 · CHG-123) — 실행 순서·유실 계열 8회차(93~99차) 대조 — 새 실측 없이 정리93~99차(CHG-116~122)에 걸쳐 1.8.13·2.2.9·2.3.6·2.3.14·2.10.11.3·12.5.4 여섯 곳에 조금씩 쌓인 실측을 전부 다시 읽고 회차 간 모순·낡은 서술·교차참조 오류를 대조했다. 새 컨테이너 실험은 하지 않았다. 낡은 서술 세 곳을 찾아 고쳤다 — 12.5.4·2.2.9가 "EventsExecutor에서도 같은지 확인하지 않았다"고 적어 둔 자리는 97차(CHG-120)가 이미 실측했다(순서가 정반대, SUB→CLIENT→SRV→TIMER) — 두 장 모두 그 확정을 반영하지 않은 채 남아 있었다. 2.3.6이 "rclpy의 EventsExecutor는 존재만 확인, 거동은 안 잼"이라 적어 둔 자리도 99차(CHG-122)가 이미 240회 실측해(이 손실 계열 완전 면역) 낡아 있었다. 1.8.13·2.3.14·2.10.11.3은 8회차 내내 상세하게 갱신돼 왔고 대조 결과 모순을 찾지 못했다 — 1.8.13은 매 회차의 정정을 인용구(> 정정)로 그대로 남겨 두는 방식으로 역사를 그대로 보여 준다. 부수적으로 목차 3 자체의 서두 문장("76차례")이 이미 두 회차만큼 뒤처져 있던 것도 발견해 고쳤다. 다섯 갭 축을 간략히 점검했다 — tools/book_check.py 23종 0건, pitfall_scan.py 이전과 동일. 미확인 표기 총량은 오히려 이번 회차 자체의 서술 때문에 소폭 늘었다(새 미확인 실측은 없음 — 정확한 값과 이유는 목차 3.1 참조)
80차 검증(2026-08-19 · CHG-124) — "초기 무사 구간"을 좁히려다 재현 실패를 확인 + C++/Python 열 번째 축 정식화100차(CHG-123)가 재확인한 99차의 제안 — 초기 7~12회가 왜 안전한지 perf stat//proc으로 좁히기 — 를 시도했다. perf stat은 이 환경에서 전혀 못 썼다(perf_event_paranoid=4 + 호스트 커널 7.0.0-28-generic에 맞는 linux-tools 부재, 둘 다 컨테이너가 아니라 호스트 수준 제약이라 우회 불가 — 한계로 명시). /proc·/sys 기반 새 도구(monitor_freq.py)로 CPU 주파수·페이지폴트·컨텍스트 스위치를 표본화했으나 손실 타이밍과의 뚜렷한 상관은 못 찾았다(클라이언트 코어는 거의 항상 idle 최저 800 MHz). 더 중요한 발견: 클라이언트만 재시작(20×15회)·서버+클라이언트 재시작(15×15회)·78차와 동일한 docker run --rm(10×15회) 세 방식, 총 45회(675 트라이얼)로 재현을 시도했더니 78차의 "idx 0~11 예외 없이 안전"이 오늘은 재현되지 않았다 — 손실 난 16개 반복 중 10개(62.5%)가 idx≤6에서 첫 손실, idx=1 손실도 두 번 났다(메커니즘은 96~99차와 동일, 새 버그 아님). 호스트 부하(오늘 컨테이너 수십 개 동시 실행)가 유력한 교란 후보이나 78차 시점 부하 기록이 없어 대조하지 못했다(확인 필요). 부차 작업: CHG-122(99차)가 "다음 회차 제안"으로만 남겨 뒀던 C++/Python 열 번째 비대칭 축(액션 피드백 유실에 대한 rclpy의 완전 면역)을 2.8.12.11로 정식 추가하고, 목차 6·A.2·A.6의 "아홉 축" 표기를 "열 축"으로 갱신했다
81차 점검(2026-08-19 · CHG-125) — 커버리지·깊이 갭 정면 점검 — 새 실측 없이 5개 부 전수 대조93~101차(9회차)가 실행 순서·유실 계열의 세부만 파고드는 동안 수정하지 않고 남겨 둔 커버리지·깊이 갭 축(book-gap 스킬 다섯 축 중 둘)을 정면으로 다뤘다. 16개 부 전체를 BOOK.md BK-04(범위)와 대조하고, 특히 93~101차가 한 번도 다루지 않았던 제5부(좌표·모델)·제9부(매니퓰레이션)·제11부(도구·관측성)·제13부(보안)·제15부(이행·확장)의 헤더 구조(## RJ-NN-MM)를 목차의 "다루는 내용"과 대조했다. 확실한 누락은 찾지 못했다 — BK-04가 선언한 범위가 전부 특정 장에 매핑돼 있었고, 133장 전수 라인 수 스캔에서도 이 5개 부엔 스텁(제목만 있고 본문이 빈 절)이 없었다. 부분 커버 2건을 찾아 바로 고쳤다 — 목차 자신의 제9부 소개 문장이 실재하는 장(9.5 픽앤플레이스 통합)을 언급하지 않았고, 제11부 소개 문장도 11.3(기록·재생 전략)을 언급하지 않았다(둘 다 BOOK.md의 BK-06에는 있었으나 목차-목차.md 자체의 요약 문장에서만 빠져 있었다 — 목차 4.10·목차 4.12). 상황에 따라 항목 하나: 제15부 다섯 장(15.1~15.5)이 책 평균(402줄)보다 짧고(222~259줄) 표·견해 위주인데, 골격(학습목표→개념→트레이드오프→함정카탈로그→체크포인트)은 다른 장과 동일하게 완비돼 있어 스텁은 아니다 — 산문·예시를 늘릴지는 편집 판단이 필요해 보류(CHG-125 참조). 부수적으로 101차의 "아홉→열 축" 전파 완결성(목차 6·A.2·A.6 확인, 잔존 "아홉" 언급 2건은 올바른 용법으로 확인)과 (확인 필요) 627건 집계(재현 스크립트로 재확인, 정확히 일치)를 재검증했다. tools/book_check.py 23종 0건, pitfall_scan.py 68쌍 중 4건 표본 재확인(전부 문맥이 다른 정당한 변주, 전수 재검토는 못 함)
82차 점검(2026-08-19 · CHG-126) — 81차가 다루지 않은 나머지 11개 부 헤더 대조 + pitfall_scan.py 68쌍 전수 재검토81차가 지시로 지목된 5개 부(5·9·11·13·15)만 상세 대조하고 나머지 11개 부(0~4·6~8·10·12·14·부록)는 BK-04 대조만 간략히 했다고 남긴 것을 이어, 이번엔 그 11개 부의 헤더(## RJ-NN-MM)를 목차·BOOK.md BK-06의 부 소개 문장과 대조했다. 81차와 같은 유형의 부분 커버 7건을 더 찾아 즉시 고쳤다 — 제3부(rmw_zenoh 누락)·제4부(크로스 컴파일·릴리스 누락)·제7부(이미지 파이프라인·IMU·오도메트리 누락)·제8부(SLAM Toolbox·AMCL·복구 누락)·제10부(Isaac Sim 등 대안 시뮬레이터 누락)·제12부(지연 예산 설계 누락)의 소개 문장에 실재하는 장을 보강했고, 부록은 다른 16개 부와 달리 소개 문장 자체가 전혀 없던 것을 발견해 신설했다(목차 4.17). 모두 장 자체는 처음부터 있었고 목차 요약 문장만 낡아 있었다 — 81차가 찾은 것과 동일한 패턴이다. pitfall_scan.py의 68쌍을 81차가 표본 4쌍만 보고 남긴 나머지 64쌍까지 전부(68/68) 읽었다 — 각 쌍의 함정·증상·대처 3열 전체와 카탈로그 주변 문맥까지 확인한 결과 새로운 모순은 0건이었다. 68쌍은 세 유형으로 갈렸다: ① 같은 함정을 서로 참조하며 동일한 대처를 반복 서술(예: 1.2.11↔4.4.10, 둘 다 1.2.5를 가리킴), ② 다른 소비자·다른 층위에 맞춰 정당하게 달라진 대처(예: rosbag2의 QoS 불일치는 "오버라이드", RViz2는 "디스플레이 QoS 설정" — 둘 다 1.5.4 RxO 원칙에서 파생), ③ Jaccard 유사도가 짧은 함정 문구의 우연한 낱말 중복만 검출한 오탐(예: "단위 혼동"이 URDF의 mm·g, NavSatFix의 도·라디안, 깊이 이미지의 mm·m를 각각 가리키는데 문구만 비슷함). 이전 회차(54차)가 찾은 3건의 실제 모순(--mixin release 단서·무선 RELIABLE 순서·YAML 노드 이름 불일치)은 지금도 고쳐진 채였다.
83차 검증(2026-08-19 · CHG-128) — 15.1의 API diff 5건을 실제 컴파일로 재현82차(CHG-126)가 다음 회차 제안 ②로 남긴 "15.1의 API diff 예시를 실제 Jazzy 헤더로 컴파일해 재현 검증"을 실행했다(CHG-127이 사용자 결정 대기로 미뤄 뒀던 항목이기도 하다). ros:jazzy-ros-base(ros-jazzy-rclcpp 28.1.21, 2026-06-15 빌드)에 colcon 워크스페이스를 만들어 15.1.3의 다섯 항목을 각각 최소 재현 코드로 컴파일했다. 넷은 릴리스 노트 그대로 재현됐다 — tf2_geometry_msgs/tf2_geometry_msgs.h·tf2_kdl/tf2_kdl.h(확장자 없는 구형)는 No such file or directory로 실패하고 .hpp는 성공, rclcpp/qos_event.hpp도 같은 방식으로 실패·event_handler.hpp는 성공, rclcpp::get_typesupport_handle은 정확히 "Use get_message_typesupport_handle instead" 문구로 -Wdeprecated-declarations 경고를 내며 컴파일은 통과, ros2 bag record --exclude는 CLI가 즉시 usage: 오류를 내고 --exclude-regex만 인식됨을 실행으로 확인. 다섯 번째(비-const shared_ptr<MessageT> 구독 콜백)는 릴리스 노트와 실물이 어긋났다 — 릴리스 노트는 "Jazzy에서 완전히 제거"라고 적지만, 멤버 함수(std::bind)와 람다 양쪽으로 만든 최소 재현 코드가 -Wall -Wextra -Wdeprecated-declarations를 다 켜도 경고 하나 없이 컴파일된다. 원인은 rclcpp/detail/subscription_callback_type_helper.hpp에서 직접 확인된다 — 비-const std::function<void(std::shared_ptr<MessageT>)> 템플릿 특수화가 Jazzy 헤더에도 그대로 남아 있다. 정확한 원인(릴리스 노트가 계획을 앞서 적었는지, 제거 PR이 되돌려졌는지, 더 이후 배포판으로 미뤄졌는지)은 ros2/rclcpp 커밋 이력 추적이 필요해 확정하지 않았다(확인 필요: 정확한 원인). 15.1을 정정했다. 부수적으로 이번 회차가 "장 보강형" 회차(book-gap이 아니라 book 스킬 성격 — 예: CHG-127)를 이 표의 N차 순번에서 공식적으로 배제하는 규칙을 확정했다(아래 범례 참고) — CHG-127은 그 규칙에 따라 번호를 받지 않고, 실제 컴파일 검증을 한 이번 회차가 83차 자리를 채운다.
84차 검증(2026-08-19 · CHG-129) — 제2부 소개 보강 + 15.1 불일치의 원인 추적 + rclpy 5-엔티티 순서·EventsExecutor 크래시104차(CHG-128)가 남긴 세 제안을 전부 다뤘다. ① 제2부 소개 문장(목차 4.3)이 2.1~2.10 열 장 중 넷(2.1 계층 해부·2.8 rclpy 심층·2.9 스레드 안전성·2.10 NodeOptions)을 언급하지 않던 것을 확인하고 즉시 보강했다 — 열 장 전부가 소개 문장에 들어가도록 고쳤다. ② 15.1의 불일치 원인을 ros2/rclcpp·ros2_documentation의 커밋·PR·이슈 이력(GitHub API)으로 추적해 확정했다 — 2024-02-23 PR #2431("Remove the set_deprecated signatures")이 비-const 콜백에 붙던 [[deprecated]] 경고 코드만 지웠을 뿐 시그니처를 받아들이는 별도 헤더(subscription_callback_type_helper.hpp)는 변경하지 않았는데, 같은 날 병합된 문서 PR #4185가 이를 "시그니처가 제거됐다"로 오독해 릴리스 노트에 적었다. 이 불일치는 이 책이 처음 찾은 것이 아니다 — ros2/rclcpp 이슈 #2972(2025-10-30)가 독립적으로 같은 증상을 보고했고, 그 이슈를 닫은 PR #2975(실제 static_assert 거부, 2025-12-26 병합)는 rolling 대상이라 이미 배포된 LTS인 Jazzy에는 오지 않는다. 15.1을 이 원인으로 갱신했다. ③ CHG-122 계열에서 "rclpy의 EventsExecutor로 다섯 엔티티 순서·waitable 유실 실험 재현"을 골라 ros:jazzy-ros-base에서 새 rclpy 하네스로 실행했다(tools/gap_harness/gap105/) — 커스텀 Waitable이 SingleThreadedExecutor에서 항상 가장 먼저 실행된다(WAITABLE→TIMER→SUB→SRV→CLIENT, 25/25), rclcpp가 항상 가장 나중인 것(2.3.14)과 정반대다(소스 근거: rclpy/executors.py가 커스텀 Waitable을 별도 루프에서 먼저 yield). EventsExecutor도 이 방식의 waitable을 거부하지만 실패 시점이 다르다 — rclcpp는 add_node()에서 즉시 실패하는데(2.10.11.3), rclpy는 등록은 성공하고 첫 spin_once()에서야 RuntimeError: Guard conditions not supported가 난다. 그 예외를 포착해도 표준 버퍼링 실행에서는 5/5 재현되는 Fatal Python error: Aborted 크래시가 나고 -u(비버퍼링) 실행에서는 크래시가 사라지는 타이밍 민감성도 발견했다 — 정확한 트리거는 확정하지 않았다(확인 필요). Waitable을 뺀 4-엔티티 EventsExecutor 순서도 SUB→TIMER→SRV→CLIENT로 rclcpp의 SUB→CLIENT→SRV→TIMER(타이머가 맨 뒤)와 달랐다(rclpy는 타이머가 두 번째). 2.8.12.12(신설, C++/Python 비대칭 열한 번째 축)와 2.10.11.3에 반영. tools/book_check.py 23종 편집 전후 모두 0건. tools/pitfall_scan.py는 68쌍(103차 기준) → 69쌍으로 늘어 있었다 — 이번 회차가 수정한 절과 관련된 7쌍은 확인해 전부 기존 행이거나 정당한 변주였으나, 69쌍 전체 재검토는 시간상 못 했다(확인 필요)
85차 검증(2026-08-19 · CHG-130) — EventsExecutor 크래시 원인을 소스로 확정 + (확인 필요) 카운터 재실측 + 함정 쌍 7건 재확인106차 갭 점검. 105차가 (확인 필요: 정확한 트리거)로 남긴 EventsExecutor + 커스텀 Waitable 크래시의 정확한 원인을 확정했다. 배포된 _rclpy_pybind11...so는 완전히 스트립돼 있어 apt-get source ros-jazzy-rclpy로 설치된 버전(7.1.11)의 소스를 받아 colcon build --cmake-args -DCMAKE_BUILD_TYPE=RelWithDebInfo로 디버그 심볼을 포함해 재빌드하고, gdb로 크래시 스레드의 전체 콜스택을 확보했다 — 크래시는 rclpy 자신의 RclCallbackManager::~RclCallbackManager()(rcl_support.cpp:35-43)가 "소멸 시점에 아직 등록된 RCL 콜백이 남아 있으면 조용히 넘어가느니 abort()한다"고 의도적으로 적어 둔 안전장치였다. stderr 계측 빌드로 정확한 메커니즘을 확인했다 — 아무 것도 안 만든 Node도 기본 파라미터 서비스 7종에 딸린 EventHandler형 Waitable을 2개 갖고 있어(probe_waitables.py), 커스텀 Waitable을 더하면 노드는 Waitable 3개를 갖는다. EventsExecutor::UpdateEntitySet()이 이들을 Python set(순회 순서가 실행마다 달라짐)으로 순회하다 guard-condition Waitable에서 던지면, 그 전에 이미 성공적으로 등록된 다른 Waitable의 콜백은 추적 목록에 기록되지 못한 채 owned_cbs_에 고아로 남는다 — UpdateEntitySet()이 부분 실패를 롤백하지 않는 구조적 결함이다. 105차의 "표준 버퍼링 5/5 크래시, -u 5/5 무사" 관찰은 표본을 12회씩 늘려 재확인한 결과 버퍼링 11/12·-u 9/12로 재현되지 않았다 — -u가 원인을 가린 것이 아니라 작은 표본이 우연히 갈렸던 것으로 정정했다. 2.8.12.13(신설)에 반영, GitHub 이슈 검색으로는 이 결함을 보고한 기존 이슈를 찾지 못했다(비인증 API 한계일 수 있음)(확인 필요). 하네스는 tools/gap_harness/gap106/. 여유 시간에 (확인 필요) 카운터를 tools/book_check.py와 동일한 방식(여는 괄호 뒤 "확인 필요"로 시작하는 문자열, 전체 134개 파일 대상)으로 직접 세어 재실측했고(과정은 목차 3.1 참조), pitfall_scan.py 69쌍 중 2.8·2.10·15.1을 다루는 7쌍(105차가 표본 확인한 것과 동일 집합)을 원문까지 대조해 재확인했다 — 전부 ①동일 대처 반복 서술(2.8.9↔4.3.9, 서로를 인용) ②층위가 달라 정당하게 갈린 대처(1.7 "비동기 콜백" vs 2.8 "그룹 분리+MT" — 2.8 본문이 이미 둘 다 예시로 들고 있다; 1.13 launch extra_arguments vs 2.10 C++ NodeOptions API — 같은 옵션의 다른 설정 지점) 중 하나였고 새로운 모순은 0건이었다.
86차 검증(2026-08-19 · CHG-131) — UpdateEntitySet() 결함의 일반성을 결정론적 결함 주입으로 확정 + py::set 순서 50회 재표집107차 갭 점검. 106차가 남긴 두 제안(①결함이 Waitable 외 다른 엔티티 종류에서도 재현되는가, ②py::set 순회 확률분포 재검증)을 우선순위대로 처리했다. ① ros:jazzy-ros-base에 106차와 동일한 방식(rclpy 7.1.11 소스, colcon --cmake-args -DCMAKE_BUILD_TYPE=RelWithDebInfo)으로 재빌드하되, 이번엔 events_executor.cpp에 결정론적 결함 주입을 추가했다 — HandleAddedSubscription/HandleAddedService의 프로세스 내 두 번째 호출이 인위적으로 예외를 던지게 해, "1번째는 항상 실제 RCL 등록 성공, 2번째는 항상 실패"를 우연 없이 매번 재현했다. 구독 2개를 등록한 노드(4/4회)와 커스텀 서비스 없이 기본 파라미터 서비스 7종만 있는 맨 노드(1회, 그 자체로 충분)에서 모두 106차와 정확히 같은 패턴 — 1번째 항목의 콜백이 owned_cbs_에 고아로 남고 소멸자가 abort() — 을 확인했다. UpdateEntitySet()의 결함은 구조적으로 다섯 엔티티 종류 전부의 것이다. 다만 "왜 유독 Waitable만 실전에서 걸리는가"도 소스로 확정했다 — HandleAddedWaitable()은 함수 맨 앞에서 num_guard_conditions != 0이면 무조건 던지지만, 구독·서비스·클라이언트가 던지는 유일한 경로는 rcl_*_set_on_*_callback()의 실제 RCL 실패뿐이라 정상적인 단일 스레드 사용에서는 사실상 일어나지 않는다 — 2.8.12.13의 "드문 엣지케이스" 톤은 유지하되 그 근거를 견해에서 소스 확인으로 격상했다(신설 2.8.12.14). ② waitable_events_crash.py를 결함 주입 없는 순정 계측 빌드로 50회 반복하니 크래시 36/50(무사 28.0%)로, 106차의 12회 표본(무사 8.3%)보다 균등분포 기대치(33.3%)에 훨씬 가까워졌다 — z-검정으로 12회·50회·합산 62회 모두 균등분포 귀무가설을 유의수준 0.05에서 기각하지 못했다. 타이머·클라이언트는 이번 회차도 테스트하지 않았다(확인 필요). 하네스는 tools/gap_harness/gap107/(결함 주입 소스·통계 표 포함). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로(전수 재검토는 안 함).
87차 검증(2026-08-19 · CHG-132) — 15.1.3 "executor 콜백 순서 보장 상실"을 104~107차 미해결 항목에서 실행 확인으로 격상108차 갭 점검. CHG-131이 남긴 제안 중 ③(104차부터 이월된 미완 항목)을 최우선으로 처리했다. ros:jazzy-ros-base·ros:humble-ros-base(로컬에 없어 새로 pull) 양쪽에 같은 ament_cmake 패키지를 빌드해, 노드 하나에 같은 타입 구독 여러 개(각각 다른 토픽, TRANSIENT_LOCAL로 미리 발행해 스핀 전에 전부 ready로 만듦)를 등록하고 SingleThreadedExecutor::spin_some() 한 번으로 첫 콜백 순서를 측정하는 프로그램을 프로세스를 새로 기동하며 반복 실행했다. Jazzy는 N=6 정방향 24/25·역방향 14/15·N=10 정방향 18/20이 각각의 최빈 순서를 냈지만 나머지(60회 중 4회, 6.7%)는 그마저 다른 순서로 바뀌었고, 최빈 순서 자체도 등록 순서와 무관했다. Humble은 40/40 예외 없이 등록 순서 그대로였다. 원인은 헤더에서 바로 확인된다 — Jazzy의 rclcpp/executors/executor_entities_collection.hpp의 EntityCollection이 rcl 핸들 원시 포인터를 키로 쓰는 std::unordered_map이라(구독·타이머·서비스·클라이언트·waitable 다섯 컬렉션 전부), Executor::collect_entities()가 이 해시맵을 순회하며 wait_set_.add_*()를 호출하는 순서 = rcl_wait_set 배열 위치 = 최종 콜백 순서가 되어 등록 순서와 무관해진다. 이 구조는 ros2/rclcpp PR #2142(기본 executor를 정적 executor처럼 캐시된 엔티티 컬렉션으로 바꾸는 최적화)가 넣었고, 이슈 #2532가 독립적으로 같은 증상을 보고했다. Humble에는 이 파일 자체가 없다(실행 확인) — 등록 순서 보존이 Jazzy에서 새로 깨진 것이라는 릴리스 노트의 주장과 일치한다. 이 결과는 1.8.13·2.3.14가 이미 확정한 "서로 다른 엔티티 종류 사이의(하드코딩된) 순서"와는 다른 축(같은 타입 엔티티끼리의 해시 기반 순서)이라는 점을 명확히 구분해 반영했다. 15.1.3·2.3.14·2.2.7에 반영, 하네스는 tools/gap_harness/gap108/. ASLR을 꺼서 6.7% 이탈의 원인을 좁히려 했으나 이 도커 환경의 seccomp가 personality()를 막아 시도하지 못했다(확인 필요). 타이머·서비스·클라이언트·waitable의 같은-타입 순서, MultiThreadedExecutor에서의 재현은 이번에도 하지 않았다(확인 필요). 여유 시간에 2.2·2.8·2.10·12.5·1.8·2.3·15.1의 서두 정직 블록을 빠르게 대조했다 — 2.2.7의 "최신 배포판이 재구성 비용을 줄이는 최적화를 도입했다"는 서술이 정확히 이번 실측의 대상이었음을 확인하고 소스·수치를 보강했을 뿐, 다른 장은 기존 서술과 어긋나지 않았다. tools/book_check.py 23종 편집 전후 모두 0건.
88차 검증(2026-08-19 · CHG-133) — 같은-타입 순서를 다섯 엔티티 종류 전부로, MultiThreadedExecutor의 실제 동시 실행으로 확장109차 갭 점검. 108차가 남긴 제안 ①②(최우선)를 처리했다. 소스로 범위부터 확인했다 — ExecutorEntitiesCollection은 타이머·구독·클라이언트·서비스·guard condition·waitable 여섯 개의 독립된 EntityCollection 인스턴스를 갖는다(같은 해시맵을 공유하는 것이 아니라 같은 템플릿을 여섯 번 인스턴스화한 것). gap108과 같은 방식을 타이머·waitable·서비스·클라이언트로 확장해(tools/gap_harness/gap109/) ros:jazzy-ros-base·ros:humble-ros-base에서 반복 실행했다. 다섯 종류 전부 Jazzy에서는 등록 순서를 지키지 않고 Humble에서는 지켰지만(각 10~40회 전부 등록 순서), 이탈률은 종류마다 크게 갈렸다 — 타이머·waitable은 표본(N=6·N=10, 각 조건 15~20회)에서 이탈 0건인 반면 서비스는 최대 45%, 클라이언트는 10~20%였다(서비스·클라이언트는 실제 DDS 왕복 타이밍에 의존해 구독·타이머·waitable보다 실험 통제 수준이 낮다는 것도 함께 밝혔다). 주력 과제인 MultiThreadedExecutor의 실제 동시 실행(구독 N=6, spin()을 백그라운드 스레드로 실행하고 cancel())에서는 이탈률이 20~53.3%로 상승했는데, std::unordered_map이 전혀 없는 Humble도 스레드 4개에서 20~30% 이탈했다 — SingleThreadedExecutor에서는 Humble이 다섯 종류 전부 0% 이탈이었던 것과 대조적이며, MultiThreadedExecutor의 순서 비결정성이 해시맵 문제와는 별개의 원인(스레드 경합, wait_mutex_ 직렬화 이후의 실제 병렬 실행)임을 보여 준다. 2.3.14(전체 두 표)·15.1.3·2.2.7·목차 6에 반영. 확인하지 못한 것: 서비스·클라이언트 이탈률 중 해시 순서분과 DDS 타이밍 잡음분을 분리하지 못했다, MultiThreadedExecutor 실험은 구독·스레드 2·4개에만 했다, ASLR 관련 108차의 미확인은 이번에도 다루지 않았다. tools/book_check.py 23종 편집 전후 모두 0건.
89차 검증(2026-08-19 · CHG-134) — UpdateEntitySet() 결함 주입을 타이머·클라이언트로 완결 + py::set 순서 212회 재표집 — CHG-131 이월 항목을 3회차 만에 닫음110차 갭 점검. CHG-131(107차)이 남기고 108·109차가 다루지 않아 세 회차째 이월된 두 항목을 우선 처리했다. ① gap107의 결정론적 결함 주입 기법(HandleAddedX()의 프로세스 내 2번째 호출이 인위적으로 던짐)을 HandleAddedTimer·HandleAddedClient에 적용했다. 클라이언트는 예상대로 구독·서비스와 동일한 패턴(6/6회 owned_cbs_ 고아 → abort()) — 클라이언트도 RclCallbackManager를 거치기 때문이다. 타이머는 예상 밖의 결과였다 — HandleAddedTimer()는 RclCallbackManager가 아니라 완전히 별도의 TimersManager/RclTimersManager(timers_manager.cpp)로 위임하는데, 이 클래스들의 소멸자에는 "남은 것이 있으면 abort()"하는 안전장치가 없다. 결함 주입 20회 반복에서 abort()는 0/20이었고, 대신 17/20(85%)은 조용한 누수(소멸자 로그에 고아 항목만 남고 정상 종료), 3/20(15%)은 세그폴트였다. gdb로 세그폴트 콜스택을 확보해 정확한 메커니즘을 확정했다 — 인터프리터 종료 시 ~ClockManager()가 이미 무효화됐을 수 있는 rcl_timer_t*로 rcl_timer_set_on_reset_callback()을 호출하는 use-after-free다. 다섯 엔티티 종류 전부의 실패 모드가 이제 실측으로 채워졌다 — RclCallbackManager를 거치는 넷(Waitable·구독·서비스·클라이언트)은 abort(), 별도 경로인 타이머는 대부분 조용한 누수에 드물게 세그폴트. 책의 실무 결론(guard condition Waitable을 EventsExecutor에 등록하지 않는다)은 바뀌지 않지만, "저절로 걸리면 뭐가 일어나는가"에 타이머의 다른 답이 추가됐다. ② py::set 순회 순서 표본을 62 → 212회로 확대(150회 추가) — 무사 비율 31.3%(150회 단독)·29.2%(합산 212회)로, 표본이 커질수록 균등분포 기대치(33.3%)에 점점 가까워지는 추세를 확인했다(z-검정은 네 표본 크기 모두 균등분포 귀무가설을 기각하지 못함). 부수적으로 이 표의 총 횟수 표기가 CHG-133(88차) 반영 후 갱신되지 않아 "87차례"로 한 회차 뒤처져 있던 것을 발견해 이번 회차 자신의 행 추가와 함께 바로잡았다(89차례 · 검증 77 · 점검·정리 12). 2.8.12.15(신설)에 반영, 하네스는 tools/gap_harness/gap110/(gdb 콜스택 전문 포함). tools/book_check.py 23종 편집 전후 모두 0건. 확인하지 못한 것: 타이머 세그폴트의 정확한 메모리 재사용 타이밍, "저절로" 걸리는 실제 시나리오(다섯 종류 전부), py::set의 완전한 균등성 증명, CHG-133이 남긴 제안(①②③⑤)은 이번에도 다루지 않았다.
90차 검증(2026-08-19 · CHG-135) — 타이머 use-after-free를 ASan·valgrind로 추적해 해제·재접근 지점을 소스 줄 번호까지 확정111차 갭 점검. 89차(CHG-134)가 (확인 필요)로 남긴 "타이머 UAF의 정확한 메모리 재사용 타이밍"을 최우선으로 처리했다. ASan으로 rclpy 전체를 -fsanitize=address로 재빌드하는 데 성공했다(python3가 C 바이너리라 libstdc++를 preload 순서에 넣고 verify_asan_link_order=0을 줘야 하는 초기화 순서 문제를 넘었다) — 결과 20/20회 크래시(네이티브의 15%에서 100%로 증폭)했지만, 리포트는 기대한 "해제 vs 재할당" 페어링이 있는 heap-use-after-free가 아니라 AddressSanitizer: SEGV였다 — 이 버그가 timer->impl을 먼저 읽고 그 값을 다시 역참조하는 다단계 포인터 체인이라 ASan의 단일-블록 redzone 탐지와 형태가 안 맞았다(gdb 결합으로 rdi가 원래 timer 포인터와 일치함을 확인, 두 번째 역참조에서 종료된다는 것만 확정). valgrind(memcheck)가 이 버그엔 훨씬 잘 맞았다 — sanitizer 없는 별도 빌드로 4/4회 완전히 동일한 주소(0x78)로 재현됐고, Invalid read·Invalid write 리포트가 해제 지점과 재접근 지점을 소스 줄 번호까지 보여줬다: node.destroy_node() → Timer::destroy()(timer.cpp:33-34)가 정상적으로 rcl_timer_t(rcl_timer_fini()가 impl을 free+NULL, rcl timer.c:249-250)와 그 rcl_clock_t를 둘 다 해제하는데, 고아로 남은 TimersManager/ClockManager가 원시 포인터로 이 둘을 계속 들고 있다가 인터프리터 종료 시 이미 해제된 두 블록에 순서대로 접근하고, rcl의 rcl_timer_set_on_reset_callback()이 timer->impl의 NULL 여부를 검사하지 않아(형제 함수들과 달리) 그 NULL을 그대로 역참조해 종료된다. 결론: 이것은 malloc 재사용 확률 게임이 아니라 결정론적 이중 해제-후-미검사 역참조였다 — 네이티브 빌드의 85%(조용)/15%(세그폴트) 차이는 glibc tcache가 이 작은 블록을 인터프리터 종료 시퀀스 중 재사용하는지 여부로 설명된다(견해, 미계측)(확인 필요). (정상 사용 중 우연히 vs 결함 주입 필요) 재판단은 변화 없음 — 여전히 UpdateEntitySet()의 부분 실패가 선행돼야 해 실무 톤은 그대로 유지, 다만 "걸리면 무엇이 일어나는가"의 정밀도가 소스 줄 번호 수준으로 높아졌다. 2.8.12.15(확장, 제목 갱신)에 반영, 하네스는 tools/gap_harness/gap111/(ASan·valgrind 전체 로그·gdb 레지스터 덤프 포함). tools/book_check.py 23종 편집 전후 모두 0건. 확인하지 못한 것: ASan에서 관찰된 크래시 주소가 실행마다 18바이트씩 증가하는 정확한 이유, glibc tcache 재사용 가설의 직접 계측, rcl_timer_set_on_reset_callback()의 NULL 미검사를 상류에 보고하지 않음, CHG-134가 남긴 CHG-133 계열 제안은 이번에도 다루지 않았다.
91차 검증(2026-08-19 · CHG-136) — 도착 순서 대 디스패치 순서 분리 + MultiThreadedExecutor를 다섯 엔티티·스레드 2/4/8로 확장 + Jazzy·Humble 이탈률 차이 통계 검정112차 갭 점검. 90차(CHG-135)까지 3회차째 이월된 CHG-133의 최우선 제안 셋을 전부 처리했다. ① rclcpp::ServiceBase::set_on_new_request_callback()·ClientBase::set_on_new_response_callback()(미들웨어 리스너에서 직접 호출, executor 스핀과 무관)로 ARRIVAL_ORDER를 독립 기록해 FIRE_ORDER(디스패치)와 비교했다 — 서비스는 도착이 40/40·25/25 완벽히 결정적인데도 디스패치는 80%·60% 이탈(16종의 서로 다른 순서, FIRE=ARRIVAL 0/40)해, 109차가 분리하지 못한 이탈이 도착 잡음이 아니라 순수 디스패치(해시 버킷) 쪽임을 확정했다. 표본을 20→40회로 늘리자 이탈률(45%→80%)·서로 다른 순서 가짓수(5→16종)도 함께 커져, 매 프로세스마다 사실상 새로 선택되는 순서에 가깝다는 것을 보여준다(ASLR 추정, 미계측). 클라이언트는 응답 자체가 서버 쪽 디스패치 비결정성을 이미 안고 있어 서비스만큼 명확한 분리가 안 됐다(도착 48~50% 이탈, 디스패치는 오히려 15~20%로 더 안정). ② MultiThreadedExecutor를 타이머·서비스·클라이언트·waitable·스레드 2/4/8로 확장했다(109차는 구독·스레드 2/4뿐) — guard condition waitable은 같은 종류끼리만 그룹을 공유해도 스레드 2개 이상이면 Jazzy·Humble 양쪽에서 80회 전부(54+26) 100% 유실됐다(Humble엔 EntityCollection이 없는데도 재현 — 96차의 유실 메커니즘이 Jazzy 전용 캐시가 아니라 두 배포판이 공유하는 코드에 있다는 뜻). 서비스는 두 배포판 다 53~80%로 가장 불안정, 클라이언트는 6.7~13.3%(Humble 4스레드는 0%)로 가장 안정적이었다. ③ 구독·스레드4·정방향을 각 60회 새로 표집해 두-비율 z-검정한 결과 Jazzy 45.0% 대 Humble 8.3%, $z=4.541$, $p=6\times10^{-6}$로 통계적으로 유의함을 확정했다(역방향 합산 n=80도 $p=7\times10^{-8}$로 견고). 타이머·서비스의 Jazzy·Humble 비교는 표본이 작아(8~15회) 잠정적이다. 2.3.14(주 반영처, 표 셋 신규)·15.1.3·2.2.7·2.10.11.3·목차 6에 반영, 하네스는 tools/gap_harness/gap112/(gap112_order/ 패키지 + 원시 로그 2종). tools/book_check.py 23종 편집 전후 모두 0건. 확인하지 못한 것: Humble set_on_new_request_callback이 서비스에서 종종(20회 중 14회) 콜백을 두 번 반복 호출하는 원인 미상, ASLR 가설 미계측(도커 seccomp가 여전히 personality()를 막음), 타이머의 스레드 수에 대한 비단조 패턴 미해석, 타이머·서비스의 Jazzy·Humble 차이 통계 검정은 표본 부족, CHG-122 계열(다중 호스트·stress-ng)은 이번에도 다루지 않음.
92차 검증(2026-08-19 · CHG-137) — Humble 서비스 콜백 이중 호출 원인 추적(재현 실패) + 타이머 스레드 패턴·타이머·서비스 통계 검정을 구독 수준 표본으로 마무리113차 갭 점검. CHG-136이 남긴 다음 회차 제안 셋을 순서대로 처리했다. ① rclcpp::ServiceBase::set_on_new_request_callback()이 콜백 등록마다 rmw 계층 함수를 의도적으로 두 번 부르는 "2단계 등록" 패턴(콜백 교체 중 낡은 포인터 참조를 막는 설계)을 Jazzy·Humble 양쪽 소스로 확인했다 — 그러나 이 등록은 서비스 생성 직후 ~30마이크로초 간격으로 일어나는 반면 112차가 본 이중 도착은 첫·둘째 배치 사이가 ~140~210밀리초였다(4~5자릿수 차이). 결정적으로 Jazzy도 같은 2단계 등록 코드를 쓰는데 Jazzy 서비스는 65회 전부 이중 도착이 없었다 — 이 메커니즘은 실재하지만 증상의 원인이 아니다. 락 없는 링 버퍼로 계측해 순정·계측·stress-ng --cpu 16 부하 세 조건에서 재현을 시도했으나 66회 전부 이중 도착이 재현되지 않았다(확인 필요). ② 타이머의 MultiThreadedExecutor 스레드 수 비단조 패턴(112차, 스레드 4에서 0%)을 각 조건 50회로 재표집하니 패턴이 사라졌다(2/4/8스레드 정방향 6.0%/12.0%/8.0%, 인접 쌍 전부 $p>0.29$) — 소표본 우연이었다. ③ 타이머·서비스의 Jazzy·Humble MultiThreadedExecutor 이탈률 차이를 구독과 같은 표본(정방향 각 60회, 역방향 각 20회)으로 검정하니 구독과 달리 둘 다 유의하지 않았다(타이머 $p=0.65$, 서비스 $p=0.22$·정+역 합산 $p=0.072$) — 특히 타이머는 112차의 소표본 "명목상 유의"($p=0.024$)가 완전히 뒤집혔다. 타이머·서비스는 스레드 경합이 워낙 압도적이라(타이머 6~19%, 서비스 68~85%) 해시맵이 보태는 차이가 통계로 드러날 만큼 크지 않은 것으로 보인다(견해). 2.3.14(주 반영처)·15.1.3·2.2.7·목차 6에 반영, 하네스는 tools/gap_harness/gap113/(계측 헤더·등록 트레이스·원시 로그, multi_timer_order_test·multi_service_order_test는 gap112 실행 파일 무수정 재사용). tools/book_check.py 23종 편집 전후 모두 0건. 확인하지 못한 것: Humble 서비스 콜백 이중 호출의 정확한 트리거(66회 재시도 전부 실패), 서비스 차이의 $p=0.072$가 표본을 더 늘리면 유의해질지 여부, 타이머·서비스의 해시맵 대 스레드 경합 비중 분해, py::set 500회 확대·CHG-122 계열·ASLR 측정은 이번에도 다루지 않음.
93차 검증(2026-08-20 · CHG-138) — 서비스 통계 검정을 150회로 확대해 유의성 확정 + Humble 이중 호출 재현 111회째 실패 + py::set 순서 500회 완결(4회차 이월 항목 처리)114차 갭 점검. CHG-137이 남긴 다음 회차 제안 셋을 전부 처리했다. ① 서비스 Jazzy·Humble MultiThreadedExecutor 이탈률(113차, 정+역 합산 $p=0.072$로 경계선)을 각 150회(113차의 60회 + 90회 추가, 원시 순서를 합쳐 재집계)로 늘려 재검정하니 Jazzy 54.0% 대 Humble 74.7%, $z=-3.736$, $p=1.87\times10^{-4}$로 유의수준 0.001에서 귀무가설을 기각했다 — 90회 새 표본만 단독 검정해도 $p=1.57\times10^{-4}$로 독립 재현됐다. ② Humble 서비스 콜백 이중 호출을 "다른 엔티티 종류(타이머·waitable·클라이언트·구독 45회)를 먼저 실행한 뒤 서비스 테스트"라는 새 조건으로 재시도했으나 정방향 30회·역방향 15회 전부 재현되지 않았다 — 게다가 112차의 원래 배치를 다시 대조해 보니 그때도 서비스 테스트가 배치 맨 처음이었다는 것을 발견해, 113차 자신이 세운 "고립이 원인" 가설의 전제가 틀렸음도 확인했다. 순정·계측·stress-ng 부하·엔티티 선행 실행 네 조건, 누적 111회가 전부 실패로 남았다(확인 필요). ③ CHG-134(110차) 이후 네 회차째(111~113차) 이월된 py::set 순회 순서 표본을 212 → 500회로 완결했다(288회 추가, 크래시 207·무사 81) — 500회 누적 표본이 처음으로 균등분포(1/3) 귀무가설을 기각했다($z=-2.245$, $p=0.0248$) — 무사 비율(28.6%)이 106차부터 계속 33.3%보다 낮았던 것이 표본이 커지며 통계적으로 유의한 격차로 확정됐다. 2.3.14(주 반영처)·15.1.3·2.2.7·2.8.12.15·목차 6에 반영, 하네스는 tools/gap_harness/gap114/(원시 로그·분석 출력, multi_service_order_test는 gap112 무수정 재사용, py::set은 gap106 계측 소스로 재빌드). tools/book_check.py 23종 편집 전후 모두 0건. 확인하지 못한 것: Humble 이중 호출의 트리거는 111회째도 미상, py::set 비균등의 정확한 메커니즘(힙 할당·ASLR·id() 해시 상호작용)은 견해, 타이머는 이번 회차 재검정하지 않음, CHG-122 계열·ASLR 측정은 이번에도 다루지 않음.
94차 검증(2026-08-20 · CHG-139) — 타이머 통계 검정도 150회로 뒤집힘 확정 + ASLR을 6회차 만에 --privileged로 실제로 꺼서 반증 + Humble 이중 호출 환경 포렌식115차 갭 점검. CHG-138이 남긴 세 제안을 전부 처리했다. ① 타이머 Jazzy·Humble MultiThreadedExecutor 비교(113차, 각 50~60회, $p=0.65$)를 서비스와 같은 방식으로 각 150회로 재검정하니 Jazzy 8.7% 대 Humble 26.7%, $z=-4.087$, $p=4.36\times10^{-5}$로 유의했다(정+역 합산 300회씩은 $p=1.22\times10^{-8}$) — 113차 이후 추가한 표본만 봐도 Humble이 34.4%로 원래 추정치(15.0%)보다 훨씬 높았다. 150회급까지 늘린 구독·서비스·타이머 셋 다 유의했지만 방향은 갈렸다(구독은 Jazzy가, 서비스·타이머는 Humble이 더 나쁨) — 원인은 소스로 추적하지 않았다(확인 필요). ② Humble 서비스 콜백 이중 호출은 재현 시도(누적 111회 실패) 대신 환경 포렌식으로 접근했다 — 저장소 파일 mtime·docker ps -a·journalctl 타임스탬프를 대조해, 112차(이중 호출 관측) 배치 실행 창(약 22:50~23:07)에 무관한 컨테이너 스택("sobo")의 실제 기동 이벤트(sobo-api-rs-1 23:00:25 생성, sobo-bootstrap-1 23:00:42 네트워크 join)가 겹쳐 있었고, 113~115차의 재현 시도 창에는 그런 이벤트가 전혀 없었음을 확인했다 — 유일하게 찾아낸 구체적 환경 차이이지만, 배경 소음 지표(도커 로그 줄 수)는 오히려 반대 방향을 가리켜 인과관계를 주장하지 못한다(확인 필요). ③ docker run --privileged가 108차부터 이 환경의 seccomp가 막던 personality()를 실제로 풀어 준다는 것을 확인했다(setarch -R로 힙 주소가 완전히 동일해짐 — 3회 반복 확인) — 6회차 만에 처음으로 ASLR을 실제로 껐다. 서비스 디스패치 이탈률을 ASLR 끔·켬 각 60회로 측정하니 끔 30.0%, 켬 40.0%($z=-1.148$, $p=0.251$) — ASLR을 완전히 꺼도 이탈률이 0%로 떨어지지 않아, "ASLR이 유일한 원인"이라는 108차 이후의 가설이 반증됐다(ASLR이 일부 보태는 효과가 있는지는 이 표본으로는 확정 못함). "실제 다중 호스트" 실험은 이 환경이 단일 도커 호스트라 근본적으로 불가능함을 재확인했다. 2.3.14(주 반영처)·15.1.3·2.2.7·목차 6에 반영, 하네스는 tools/gap_harness/gap115/(타이머·ASLR 토글 원시 로그, multi_timer_order_test·service_arrival_test는 gap112 실행 파일 무수정 재사용). tools/book_check.py 23종 편집 전후 모두 0건. 확인하지 못한 것: 서비스·타이머에서 Humble이 더 불리한 이유, Humble 이중 호출의 실제 트리거(환경 포렌식도 인과관계는 못 밝힘), ASLR의 추가 기여 여부($p=0.251$), ASLR 꺼도 남는 30% 이탈의 정확한 원인, stress-ng 통제 실험은 이번에도 다루지 않음.
95차 검증(2026-08-20 · CHG-140) — ASLR 표본 확대(유의성 미확정) + 할당 순서 비결정성을 주소로 직접 확인 + Humble 소스로 이탈 메커니즘 좁힘 + 이중 호출을 재현 불가로 종결116차 갭 점검. CHG-139가 남긴 네 항목(⑤ 제외)을 전부 처리했다. ① ASLR 켬·끔 비교(115차, 각 60회, $p=0.251$)를 각 150회로 확대하니 끔 40.0% 대 켬 50.0%, $z=-1.741$, $p=0.082$ — 유의성에 가까워졌으나(방향은 계속 일관) 여전히 0.05를 넘지 못했다. ② ASLR 꺼도 남는 이탈의 원인을 핸들 주소를 직접 출력해 검증했다 — 서비스 6개만 만드는 최소 프로그램(addr_probe)은 ASLR을 꺼도 10/10회 완전히 동일한 주소를 냈지만, service_arrival_test와 같은 구조(클라이언트 노드 + 실제 wait_for_service() 디스커버리 매칭)를 더한 addr_probe_full은 ASLR을 꺼도 40회 중 5회(12.5%)는 핸들 주소가(16바이트 단위로) 달라졌다 — 백그라운드 RMW 스레드가 메인 스레드의 할당과 힙에서 경합하는 타이밍이 실행마다 다르기 때문으로 추정된다. 다만 이 12.5%가 실제 디스패치 이탈률(30~40%)을 전부 설명하는지는 분리하지 못했다(확인 필요). ③ Humble rclcpp 16.0.19 소스(AllocatorMemoryStrategy::collect_entities())를 읽어, 콜백 그룹이 하나일 때는 엔티티 수집이 해시·정렬 없이 순수 등록 순서 std::vector(CallbackGroup::service_ptrs_)임을 확인했다 — Humble의 MultiThreadedExecutor 이탈이 데이터 구조 문제가 아니라 96차가 Jazzy에서 이미 확정한 것과 같은 순수 스레드 스케줄링 문제임을 확정했다(다만 Humble이 Jazzy보다 이탈률이 높은 정도의 이유는 못 좁힘 — 매 사이클 전체 재구성 비용 가설, 미계측). ④ Humble 서비스 콜백 이중 호출은 112차 관측 이후 113~115차가 네 조건(누적 101회)과 환경 포렌식을 전부 소진했고, 116차가 새로 낼 수 있는 조건은 기존의 변형뿐이라 다섯 번째 재현 시도 대신 "재현 불가"로 명시적으로 종결했다. 2.3.14(주 반영처)·15.1.3·2.2.7·목차 6에 반영, 하네스는 tools/gap_harness/gap116/(gap116_order/ 패키지 — addr_probe.cpp·addr_probe_full.cpp, ASLR·주소 원시 로그). tools/book_check.py 23종 편집 전후 모두 0건. 확인하지 못한 것: ASLR 켬·끔 차이의 최종 유의성($p=0.082$), 주소 변동과 디스패치 이탈률의 정확한 관계, Humble이 더 불리한 정도의 이유, stress-ng 통제 실험은 이번에도 다루지 못함.
96차 검증(2026-08-20 · CHG-141) — ASLR 최종 결론(무관, 방향 역전) + 주소-디스패치 완전 상관 확인 + stress-ng 통제 실험으로 스레드 경합 가설 첫 정량 확인117차 갭 점검. CHG-140이 남긴 항목을 순서대로(③을 최우선으로) 처리했다. ① ASLR 켬·끔 비교(116차, 각 150회, $p=0.082$)를 각 300회로 늘리니 끔 49.3% 대 켬 45.7%, $z=0.899$, $p=0.369$로 방향이 뒤집혔다 — 60→150회에 걸쳐 "켬이 더 불안정"해 보였던 추세는 300회의 완전한 표본에서 사라졌다. ASLR은 이 이탈률과 무관하다는 것으로 최종 결론짓는다. ② 새 프로그램(addr_probe_dispatch)으로 핸들 주소와 FIRE_ORDER를 같은 프로세스 안에서 동시에 측정하니 60회 전부 예외 없이 완전한 1:1 대응이었다(같은 주소 패턴 → 항상 같은 디스패치 순서) — 116차가 별도 세션에서 집계량만 비교해 남긴 "12.5% 대 30~40%"라는 겉보기 불일치가 착시였음을 확인했다. ③(최우선) CHG-122 계열의 마지막 하위 항목인 stress-ng 통제 실험을 실제로 설계·실행했다 — 호스트(20코어)에 stress-ng --cpu N(N=0·8·20·40)을 실행한 채 구독·서비스의 MultiThreadedExecutor 이탈률을 측정했다. 워커 수가 코어 수(20)에 도달·초과하는 순간부터 이탈률이 극적으로, 강하게 유의하게 상승했다(구독 35.0%→72.5%, $p=0.00077$; 서비스 20.0%→86.7%, $p=2.3\times10^{-7}$; 0 대 8워커는 둘 다 유의하지 않음) — 96차 이후 반복 확인해 온 "스레드 경합이 동적 원인"이라는 가설을 원인 변수(CPU 경합)를 직접 조작해 처음 정량 확인했다. Humble 대조군(구독, 0·20워커 각 25회)은 전혀 반응하지 않아(4.0%→4.0%, $p=1.0$) 서비스·타이머(Humble이 더 나쁨)와는 반대 방향의 배포판 비대칭이 새로 드러났다(확인 필요: 원인). 2.3.14(주 반영처)·15.1.3·2.2.7·목차 6에 반영, 하네스는 tools/gap_harness/gap117/(gap117_order/ 패키지 — addr_probe_dispatch.cpp, ASLR·stress-ng 원시 로그). tools/book_check.py 23종 편집 전후 모두 0건. 확인하지 못한 것: 구독에서 Jazzy가 CPU 경합에 훨씬 민감하고 Humble은 무반응인 이유, 서비스·타이머의 Humble-나쁨과 구독의 Jazzy-민감함이 같은 원인인지, 8워커와 20워커 사이의 정확한 임계값, Humble 서비스·타이머의 stress-ng 반응(구독만 재봄)은 다루지 못함.
97차 검증(2026-08-20 · CHG-142) — Jazzy·Humble executor 소스 대조 + stress-ng 임계값 확정(16~20워커) + Humble 서비스·타이머는 부하에 정반대 반응(개선) + "재구성 비용" 가설 반증118차 갭 점검. CHG-141이 남긴 항목을 순서대로 처리했다. ① rclcpp 28.1.21(Jazzy)·16.0.19(Humble)의 executor.cpp·multi_threaded_executor.cpp를 나란히 읽었다 — 스레드 풀·wait_mutex_ 직렬화 구조는 거의 동일하지만, "다음 실행 대상 선택"이 Jazzy는 wait_for_work()가 한 번에 만든 스냅숏을 적은 비용으로 순회하는 반면 Humble은 매번 std::vector를 순회해 erase()하며 wait_mutex_와 별개인 두 번째 뮤텍스(mutex_)를 추가로 포착한다 — 두 배포판 다 "선택 자체는 완전히 결정적"이라는 95·116차의 결론은 재확인됐다. ② stress-ng 워커 수를 12·16으로 메우니 Jazzy 구독의 임계값이 정확히 16과 20워커(코어 수) 사이로 좁혀졌다(12 대 20워커 $p=1.1\times10^{-5}$, 8~16워커는 서로 유의한 차이 없음). Humble 서비스·타이머에 같은 실험을 하니 Jazzy와 완전히 반대 방향이었다 — 부하가 이탈률을 유의하게 낮췄다(서비스 44.0%→4.0%, $p=0.00093$; 타이머 24.0%→4.0%, $p=0.042$). Humble의 선택 순서가 항상 등록 순서이므로 CPU 경합으로 병렬 실행 창이 좁아지면(직렬화가 강해지면) 기록 순서가 등록 순서에 더 수렴하는 것으로 보인다(견해). ③ wait_for_work()에 std::chrono 타이밍 계측을 넣어 rclcpp를 재빌드하고(별도 오버레이, 기존 실행 파일은 무수정 재사용) "Humble의 매 사이클 재구성이 더 비용이 크다"는 95차 가설을 직접 검증하니 정반대로 Jazzy의 호출당 평균 비용이 Humble보다 1.3~1.8배 높았다(서비스 44,454ns 대 24,988ns; 타이머 71,984ns 대 55,838ns) — Jazzy도 캐시 무효화 플래그가 이 테스트에서 68~70%나 자주 걸려 캐싱 이점이 줄어드는 것으로 보인다. 이 가설은 반증됐다 — Humble이 서비스·타이머에서 불리한 실제 이유는 여전히 열려 있다(확인 필요). 2.3.14(주 반영처)·15.1.3·2.2.7·목차 6에 반영, 하네스는 tools/gap_harness/gap118/(계측된 executor.cpp 2종, stress-ng 원시 로그, README.md). tools/book_check.py 23종 편집 전후 모두 0건. 확인하지 못한 것: Humble의 CPU 부하 반응(무반응·개선)에 대한 해석은 견해, Humble이 서비스·타이머에서 불리한 실제 이유, Jazzy의 캐시 무효화 플래그가 왜 이렇게 자주 걸리는지, 17~19워커 등 더 세밀한 임계값은 다루지 못함.
98차 검증(2026-08-20 · CHG-143) — 중대한 정정119차 갭 점검. CHG-142가 남긴 항목을 순서대로 처리했다(② 원인 추적, ③ 직렬화 가설 검증). ② entities_need_rebuild_가 68~70%나 자주 걸리는 이유를 완전히 확정했다 — Executor 생성자가 notify_waitable_에 interrupt_guard_condition_을 등록해 두고, MultiThreadedExecutor::run()이 MutuallyExclusive 그룹 콜백이 끝날 때마다 이 guard condition을 트리거해 다음 wait_for_work()에서 재구성을 강제한다 — 우리 테스트(N개 엔티티가 전부 기본 MutuallyExclusive 그룹)에서는 콜백이 끝날 때마다 거의 매번 이 트리거가 걸린다. ③(중대한 정정) "CPU 부하가 직렬화를 강제한다"는 118차의 가설을 검증하려고 rclcpp::Executor::execute_any_executable()에 [시작,끝] 구간 기록 계측을 넣어(기존 gap112_order 바이너리 무수정 재사용) 실제 동시성 비율을 직접 측정했다 — Jazzy·Humble 양쪽, 구독·서비스·타이머 전부, stress-ng 부하 유무와 무관하게 동시성 비율이 예외 없이 0.0000이었다(독립된 두 번째 계측 방법으로도 동일). 원인은 명백하다 — gap109~gap118의 multi_*_order_test는 N개 엔티티를 명시적 그룹 지정 없이 한 노드에 등록해 기본 MutuallyExclusive 콜백 그룹에 들어가는데, 이 그룹의 정의 자체가 "한 번에 하나만 실행"이므로 스레드가 몇 개든 병렬 실행이 애초에 불가능했다. 91~98차가 "실제 동시 실행"이라 불러 온 것에서 동시 실행이 단 한 번도 없었다는 뜻이다 — 96차 이후 반복해 온 "스레드 스케줄링 경쟁" 메커니즘 설명은 이 테스트 계열에 적용되지 않는다(측정된 FIRE_ORDER 이탈률 수치 자체는 유효하다 — 바뀌는 것은 그 수치를 설명하는 메커니즘이다). ①은 이 발견으로 전제가 바뀌어, 다음 회차는 "바쁜 그룹에 막혀 wait_for_work()를 재호출하는 스레드가 wait_mutex_를 획득한 채 블로킹되고, 다른 스레드의 트리거로 깨어나면 완전히 새로 재구성된 목록을 받는다"는 재구성-대기 연쇄를 새 후보로 추적한다. 2.3.14(주 반영처)·15.1.3·2.2.7·목차 6에 반영, 하네스는 tools/gap_harness/gap119/(gap119_order/ 패키지 — overlap_probe.cpp, 계측된 executor.cpp 2종, 원시 로그, README.md). tools/book_check.py 23종 편집 전후 모두 0건. 확인하지 못한 것: 재구성-대기 연쇄 가설을 직접 계측으로 증명하지 못함, Humble이 서비스·타이머에서 불리한 실제 이유는 여전히 열려 있음, 과거 회차 서술 중 "동시 실행" 표현의 전면 재검토는 시작만 함.
99차 검증(2026-08-20 · CHG-144)120차 갭 점검. CHG-143이 남긴 항목을 순서대로 처리했다(① 최우선: 재구성-대기 연쇄 직접 계측, ② Jazzy와의 비교, ③ 2.3.14 등의 "동시 실행" 언어 전면 재검토 완결). ① 바쁜 그룹에 막힌 스레드가 wait_for_work()의 블로킹 대기 내내 wait_mutex_를 쥐고 있다는 것을 소스(MultiThreadedExecutor::run()의 락 스코프가 get_next_executable() 전체를 감쌈)와 480회 실측(13,397개 [잠금,해제] 구간, 스윕 라인 겹침 비율 예외 없이 0.0000) 둘 다로 확정했다. Humble은 wait_for_work()가 호출될 때마다 무조건 전체 재구성을 하는 반면(240회 전부 rebuild_per_wfw=100.0%), Jazzy는 entities_need_rebuild_ 플래그에 따라 조건부(45.1~74.1%)다. 다만 이 연쇄의 빈도(busy_skip_rate)가 이탈률을 결정하는 인과적 손잡이라는 것은 확정하지 못했다 — 12개 설정(서비스·타이머 × 스레드 2/4/8 × Jazzy·Humble, 각 40회) 전체 상관은 $r=0.9617$로 강하지만, 엔티티 종류를 고정하고 스레드 수만 바꾸면 상관이 거의 사라진다(서비스만 $r=-0.31$, 타이머만 $r=+0.31$) — 대부분이 "서비스는 원래 busy_skip도 이탈도 높고 타이머는 둘 다 낮다"는 엔티티 종류 자체의 교란변수 효과였다. 배포판 간 직접 비교(서비스·스레드4, Jazzy 45.0% 대 Humble 65.0%)는 $p=0.072$로 113차의 경계선과 비슷한 값을 n=40에서 대략 재현했지만 새로 유의성을 확정하지는 못했다. ② 같은 락 스코프·겹침 검사를 Jazzy에도 적용해 연쇄 자체가 두 배포판이 공유하는 구조임을 확인했지만(조건부 대 무조건이라는 차이만 있음), 힙 주소·해시 버킷 불안정성(116~117차)과의 정량적 기여도 분리는 이번에도 하지 못했다(확인 필요). ③ 2.3.14 서두에 "읽기 전에" 정정 고지를 신설해 95~118차 전체의 "동시 실행"·"병렬 실행"·"스레드 경합" 서술이 119~120차 정정 대상임을 앞에서 미리 밝혔고, 가장 상세한 개별 주장 4곳(95차 waitable 유실, 109차 Humble 동시 실행 설명, 116차 "실행은 병렬" 확정 문구, 117차 "스레드 경합이 동적 원인" 문구)에 인라인 정정을 추가했다. 15.1.3·2.2.7도 같은 단락 서두에 짧은 고지를 추가하고 문단 끝의 119차 정정 뒤에 120차 결과를 이어 붙였다. 2.10.11.3은 이번 재검토 전까지 정정이 전혀 없었던 것을 발견해 새로 추가했다(95차 waitable 실험이 같은 기본 그룹을 썼을 가능성을 언급, 소스 미보존으로 완전히 확정하지는 못함(확인 필요)). 2.3.14(주 반영처)·15.1.3·2.2.7·2.10.11.3·목차 6에 반영, 하네스는 tools/gap_harness/gap120/(계측된 executor.cpp·multi_threaded_executor.cpp 각 2종, run_batch.sh, analyze.py, 원시 로그 12개 설정 × 40회, README.md). tools/book_check.py 23종 편집 전후 모두 0건. 확인하지 못한 것: 연쇄 빈도와 이탈률의 같은-엔티티-내 인과 관계, Jazzy에서 연쇄와 힙 주소 효과의 정량 분리, Humble이 서비스·타이머에서 불리한 실제 이유는 여전히 열려 있음.
100차 검증(2026-08-20 · CHG-145)121차 갭 점검(이 책의 검증 이력 100회째). CHG-144가 남긴 항목을 순서대로 처리했다(① rcl 계층 비교, ② 재구성-대기 연쇄와 힙 주소 효과 분리, ③ 구독으로 계측 확장). ① rcl 9.2.11의 wait.c를 읽어 rcl_wait()가 구독·guard condition·클라이언트·서비스·이벤트 다섯 종류를 rmw_wait() 한 번 + 완전히 동일한 널 검사 패턴으로 처리함을 확인했다 — 구독과 서비스 사이에 rcl 계층 구조적 차이는 없다(유일한 예외는 rcl_timer_is_ready()로 별도 판정되는 타이머이지만, 이는 "서비스·타이머 대 구독·waitable"이라는 이 책의 실측 축과 맞지 않는다). 119차(rclcpp 엔티티 컬렉션)·CHG-143(선택 함수)에 이어 이번(rcl 대기집합)까지 세 계층 전부 서비스·구독을 구조적으로 구분하지 못했다(확인 필요). ② Jazzy의 재구성 조건을 entities_need_rebuild_ 무시·최초 1회만 수집으로 바꾼 실험용 빌드(rebuild_per_wfw=0.0% 확인)로 서비스(N=6, 스레드4)를 ASLR 켬·끔 2×2로 각 30회 측정했다 — 재구성을 완전히 억제해도 이탈률은 줄지 않았다(23.3~30.0% → 36.7~40.0%, 통계적으로 유의하지 않지만 방향은 오히려 증가). 반복되는 재구성 자체는 이탈의 필요조건이 아니며, 최초 1회 수집 시점의 힙 주소·해시 버킷 배치(116~117차)가 이미 이탈에 충분함을 시사한다. ③(뜻밖의 발견) 같은 계측을 multi_sub_order_test로 확장해 stress-ng --cpu 0·20을 양쪽 배포판 25회씩 측정했는데, Humble 구독이 117차의 "CPU 부하에 완전히 무반응"과 달리 이번 세션에서는 유의하게 반응했다(36.0%→4.0%, $z=2.828$, $p=0.0047$) — 계측 없는 순정 빌드로도 재현해(24.0%→0.0%, $p=0.0090$) 계측 부작용이 아님을 확인했지만, 117차의 기준값(4.0%)과 이번 세션 기준값(24~36%)이 6~9배 차이 나는 이유는 밝히지 못했다(확인 필요) — "Humble 구독 무반응"이라는 117차의 구체적 결론을 세션 간 재현성 문제로 명시하고 다음 회차로 이월한다. 2.3.14(주 반영처)·15.1.3·2.2.7·목차 6에 반영, 하네스는 tools/gap_harness/gap121/(재구성 억제 변형 executor.cpp, run_batch2.sh, 원시 로그, README.md). tools/book_check.py 23종 편집 전후 모두 0건.
101차 검증(2026-08-20 · CHG-146)122차 갭 점검. CHG-145가 남긴 항목을 순서대로 처리했다(① 호스트 부하 가설 검증, ② rmw/DDS 계층 비교, ③ 재구성 억제 효과를 n=100으로 확대). ① 이 호스트에 36개 무관한 컨테이너(다른 개발 스택들)가 함께 실행 중이다는 것을 확인하고, 이것이 117차·121차의 기준값 불일치(4.0% 대 24~36%) 원인일 것이라는 가설을 두 방법으로 검증했다 — Humble 구독(부하 없음) 60회를 매 반복 직전 /proc/loadavg 기록과 함께 측정하니(6.85~11.30 사이 자연 변동) 이탈 19회 평균(8.791)과 비이탈 41회 평균(8.832)이 사실상 같았고(점이연 상관 $r=-0.0149$), 통제된 저강도 stress-ng 스윕(0·2·4·8·20워커, 각 20회)도 40.0%·40.0%·40.0%·20.0%·30.0%로 매끄러운 용량-반응이 아니었으며 같은 세션의 바로 앞 배치(121차, 부하0 36.0%·부하20 4.0%)와도 재현되지 않았다. 호스트 부하 가설은 지지되지 않는다 — 단순 CPU 경합량으로는 이 불안정성이 설명되지 않는다(확인 필요). ② rmw_fastrtps_shared_cpp 8.4.4를 읽어 구독 리스너(CustomDataReaderListener)와 서비스 리스너(ServiceListener)가 둘 다 on_data_available()에서 뮤텍스+get_unread_count(true)+콜백이라는 동일한 패턴을 씀을 확인했고, rmw_wait.cpp의 has_triggered_condition()·wait-set 부착도 구독·클라이언트·서비스가 완전히 같은 코드(get_first_untaken_info()·get_statuscondition())였다 — 119차(rclcpp 컬렉션)·CHG-143(선택 함수)·121차(rcl 대기집합)에 이어 네 계층째 구조적 차이를 찾지 못했다. 대신 서비스가 요청+응답 DDS 엔티티 쌍(구독은 리더 하나)을 갖는다는 구조적 비대칭을 새 후보로 찾았다(확인 필요, 검증은 다음 회차). ③ 121차가 n=30에서 본 "재구성 억제가 이탈을 소폭 늘린다"는 경향을 서비스(N=6, 스레드4) n=100으로 측정하니 표준 34.0% 대 재구성 억제 33.0%, $z=0.150$·$p=0.8809$로 완전히 사라졌다 — 소표본 우연이었음이 확정됐다. 2.3.14(주 반영처)·15.1.3·2.2.7·목차 6에 반영, 하네스는 tools/gap_harness/gap122/(run_batch3.sh — loadavg 동시 기록, 원시 로그, README.md). tools/book_check.py 23종 편집 전후 모두 0건.
102차 검증(2026-08-20 · CHG-147)123차 갭 점검. CHG-146이 남긴 항목을 순서대로 처리했다(③ 클라이언트 기존 자료 재분석을 먼저, ① 엔티티 개수 직접 조작, ② Humble 구독 불안정성 관찰). ③(먼저 확인) 클라이언트도 서비스처럼 DDS 엔티티 둘(요청 라이터+응답 리더)을 갖지만, 109·112차 자료를 다시 보니 이 책이 측정한 네 엔티티 종류 중 가장 안정적이었다(MultiThreadedExecutor: Jazzy 6.7~13.3%·Humble 0% — 서비스의 53~80%·75~80%와 정반대). multi_service_order_test.cpp·multi_client_order_test.cpp 소스를 나란히 읽으니 엔티티 구조도 "N개가 스핀 전 이미 준비되게" 만드는 설계 의도도 대칭인데 결과는 극단적으로 다르다 — 클라이언트라는 반례가 이미 "엔티티 두 배" 가설을 반증하고 있었다. ① 그럼에도 CHG-146의 요청대로 엔티티 개수를 직접 조작하는 실험도 했다 — 지금까지 구독 테스트는 전부 같은 참가자 안에 로컬 퍼블리셔(라이터)를 N개 함께 갖고 있었다는 것을 확인하고, 그 라이터를 별도 프로세스(publisher_helper)로 옮겨 측정 대상 노드(subscriber_only)엔 리더만 남겼다 — 이탈률이 방향은 가설과 일치하게 줄었지만(Jazzy 65.0%→57.5%, Humble 42.5%→32.5%) 어느 쪽도 통계적으로 유의하지 않았다($p=0.49$·$0.36$). 결론: "DDS 엔티티 개수"는 서비스·구독 비대칭의 주된 설명이 아닐 가능성이 크다(확인 필요) — 다음 후보는 엔티티 종류별 rmw/DDS 구현 세부다. ② Humble 구독의 세션 간 불안정성은 이번 회차도 새 기준값(Jazzy 65.0%·Humble 42.5%, 121~122차와 또 다름)만 추가됐을 뿐 새 메커니즘은 계측하지 못했다 — 시간을 ①·③에 우선 배정했기 때문이며, 다음 회차로 명시적으로 이월한다(확인 필요). 2.3.14(주 반영처)·15.1.3·2.2.7·목차 6에 반영, 하네스는 tools/gap_harness/gap123/(gap123_order/ 패키지 — publisher_helper.cpp·subscriber_only.cpp, run_batch4.sh, 원시 로그, README.md). tools/book_check.py 23종 편집 전후 모두 0건.
103차 검증(2026-08-20 · CHG-148)124차 갭 점검. CHG-147이 남긴 항목을 순서대로 처리했다(① 서비스·클라이언트 비대칭 추적(최우선), ② Humble 구독 불안정성 직접 계측, ③ 엔티티 개수 조작을 n=100으로 확대). ① execute_service()·execute_client()(rclcpp)를 나란히 읽어 요청 헤더 생성부터 take_and_do_error_handling() 호출까지 완전히 동일함을 확인했다 — rclcpp 엔티티 컬렉션·선택 함수·rcl 대기집합·rmw 리스너에 이은 다섯 번째로 배제된 계층이다. 대신 112차의 service_arrival_test·client_arrival_test(미들웨어 리스너로 executor와 무관하게 도착 시각을 측정하는 도구)로 도착 시각 자체를 30회씩 측정하니 클라이언트의 응답 도착 시간 폭이 서비스의 요청 도착 시간 폭보다 약 5배 넓었다(중앙값: 서비스 Jazzy 13,875ns·Humble 11,652ns 대 클라이언트 Jazzy 63,846ns·Humble 60,872ns). 원인은 테스트 구조다 — 서비스 테스트는 서버가 스핀 전에 N개 요청을 반복문으로 한꺼번에 쏘지만, 클라이언트 테스트는 서버가 이미 SingleThreadedExecutor로 상시 스핀 중이라 요청을 하나씩 순차 처리·응답해 응답이 시간에 걸쳐 퍼진다. 좁은 도착 창이 wait-set 수준의 "실제 동시 준비" 상태를 늘려 이탈을 키우는 것으로 보인다(다만 이는 엔티티 종류 자체가 아니라 테스트 설계의 산물이라는 점을 명시, 검증은 다음 회차)(확인 필요). ② sub_arrival_test(gap123 두-프로세스 패턴 + 112차 미들웨어 리스너 기법)로 Humble 구독의 도착 시각을 50회 재 "디스커버리 타이밍" 가설을 직접 검증했다 — 도착 시각 폭과 이탈 여부의 점이연 상관은 $r=0.0712$로 무시할 수준이어서 가설이 기각됐다. 대신 ARRIVAL_ORDER(32.0% 최빈, 20종의 서로 다른 패턴)가 FIRE_ORDER(62.0% 최빈, 13종)보다 훨씬 불안정하다는 뜻밖의 사실을 발견했다 — executor 디스패치가 어수선한 도착 순서를 그대로 물려받는 것이 아니라 오히려 부분적으로 정돈하고 있다는 뜻이다(둘이 완전히 일치한 회차는 50회 중 8회뿐). 남은 이탈은 도착 이후 단계에서 와야 한다(확인 필요). ③ 123차의 엔티티 개수 조작 실험(구독의 로컬 퍼블리셔를 별도 프로세스로 옮겨 리더만 남김)을 n=100으로 확대하니 Jazzy는 통계적으로 유의해졌다(71.0%→54.0%, $z=2.483$·$p=0.0130$) — "엔티티 개수" 가설이 Jazzy에 한해 실재하는 부분 효과로 승격됐다. Humble은 방향이 뒤집혔다(34.7%→42.0%, $p=0.29$, n=98) — 세션 간 불안정성이 이 실험에도 나타났다. 클라이언트라는 반례가 여전히 있어 엔티티 개수는 전체 그림의 일부일 뿐이다. 2.3.14(주 반영처)·15.1.3·2.2.7·목차 6에 반영, 하네스는 tools/gap_harness/gap124/(gap124_order/ 패키지 — sub_arrival_test.cpp, run_batch5.sh, 원시 로그, README.md). tools/book_check.py 23종 편집 전후 모두 0건.
104차 검증(2026-08-20 · CHG-149)125차 갭 점검. CHG-148이 남긴 항목을 순서대로 처리했다(① 도착 시간 폭 가설 직접 조작(최우선), ② Humble "정돈" 메커니즘을 소스에서 확인, ③ 엔티티 개수와 도착 시간 폭이 같은 효과인지 직접 연결). ①(최우선) service_spread_test(gap112 multi_service_order_test에 요청 사이 인위적 지연을 추가한 변형)로 도착 창을 0·10·50·200µs로 늘려 봤다(N=6, 스레드4, 각 30회) — 어느 조건도 통계적으로 유의하지 않았고, Jazzy는 지연을 늘릴수록 이탈이 오히려 (비유의하게) 늘었다(43.3%→58.6%, $p=0.24$). 200µs는 클라이언트의 자연 도착 창(61~64µs)보다 3배 넓은데도 이탈이 줄지 않았다 — 103~104차의 상관관계는 인과관계가 아니었을 가능성이 크다. ② Humble rclcpp 16.0.19의 AllocatorMemoryStrategy를 읽어 "정돈" 메커니즘을 정확히 찾았다 — remove_null_handles()가 매 rcl_wait() 이후 subscription_handles_(std::vector)에서 이번에 준비되지 않은 항목을 걷어내고, get_next_subscription()은 항상 begin()부터 순회한다 — N개가 전부 같은 rcl_wait() 배치에서 준비되면 디스패치 순서는 도착 순서와 무관하게 항상 등록 순서가 된다. 이것이 124차가 발견한 "정돈" 효과(ARRIVAL_ORDER 32% 대 FIRE_ORDER 62% 최빈 비율)의 정체다 — 잔여 이탈은 N개가 여러 rcl_wait() 배치로 분할돼 도착할 때만 생긴다. ③ sub_arrival_colocated(로컬 퍼블리셔 있음)와 124차의 sub_arrival_test(별도 프로세스, 라이터 없음)로 도착 시간 폭을 직접 비교하니 라이터가 있으면 도착 시간 폭이 90배 가까이 넓었다(Jazzy 중앙값 36ms 대 0.4ms) — 방향은 "라이터 있음=높은 이탈률"이라는 상관과 들어맞지만, 균일한 확장이 아니라 N개 중 일부가 ~70ms 늦게 도착하는 "지연 샘플" 패턴이었다(Jazzy 40회 중 20회, Humble은 40회 전부). ①이 도착 시간 폭 자체를 조작해도 이탈률이 바뀌지 않는다는 것을 이미 보였으므로, "엔티티 개수"와 "도착 시간 폭"이 같은 효과의 두 이름이라고 결론지을 수 없다 — 라이터 유무가 이탈률과 도착 시간 폭 둘 다에 개별적으로 영향을 주는 제3의 원인일 가능성이 있지만 완전히 분리하지 못했다(확인 필요). 2.3.14(주 반영처)·15.1.3·2.2.7·목차 6에 반영, 하네스는 tools/gap_harness/gap125/(gap125_order/ 패키지 — service_spread_test.cpp·sub_arrival_colocated.cpp, 원시 로그, README.md). tools/book_check.py 23종 편집 전후 모두 0건.
105차 검증(2026-08-20 · CHG-150) — 중대한 정정 포함126차 갭 점검. CHG-149가 남긴 항목을 순서대로 처리했다(① 서버 사전 스핀 직접 조작(최우선), ② execute_service·execute_client 소요 시간 직접 계측, ③ "지연 샘플" 패턴을 tcpdump로 조사). ①(최우선) service_prespin_test(측정 대상 MultiThreadedExecutor를 요청 전송 전에 미리 스핀시키는 변형)로 서버 사전 스핀 여부를 조작했다(N=6, 스레드4, 각 50회) — Humble에서 사전 스핀이 이탈을 완전히(82.1%→0.0%, $z=7.780$, $p\approx 0$) 지웠다, 이 실험 전체에서 가장 큰 단일 효과다(Jazzy는 64.0%→67.9%로 무변화). 뜻밖의 재발견: 이 실험 도중 fired가 6이 아닌 경우가 반복돼 확인해 보니 116차가 101회 실패 끝에 "재현 불가로 명시적으로 종결"한 Humble 서비스 콜백 이중 호출이었다(Humble 기준 22.0%→사전 스핀 8.0%, $p=0.0499$; Jazzy는 반대로 0%→44.0%, 113차의 "Jazzy 무호출" 확정과 배치) — 116차의 종결은 틀렸다는 것이 의도치 않게 드러났다. ② execute_service()·execute_client()에 std::chrono 타이밍 계측을 넣어 재빌드하고 기존 테스트 바이너리를 무수정 재사용해 각 20회 측정했다 — execute_service()가 execute_client()보다 약 4.7배 더 걸린다(중앙값 서비스 Jazzy 62,832ns·Humble 60,363ns 대 클라이언트 Jazzy 12,992ns·Humble 12,921ns, 두 배포판 거의 동일) — 103차의 견해("서비스는 응답 데이터를 채워 반환")가 실측으로 확인됐다. ③ 125차의 sub_arrival_colocated를 tcpdump -i lo(포트 제한 없음) 아래서 실행해 ~70ms 지연 샘플이 나온 실행을 캡처했다 — 캡처된 패킷 6개가 전부 100ms 간격 SPDP 참가자 발견 공지였고, 지연 샘플 도착 전후로 하트비트·ACKNACK 재전송은 전혀 관측되지 않았다 — 같은 참가자 안의 배달이라 루프백 인터페이스 자체를 안 거칠 가능성이 있으며, ~70ms 지연은 네트워크·프로토콜 수준이 아니라 DDS 미들웨어 내부에서 와야 한다(확인 필요). 2.3.14(주 반영처)·15.1.3·2.2.7에 반영, 하네스는 tools/gap_harness/gap126/(gap126_order/ 패키지 — service_prespin_test.cpp, 계측된 executor.cpp 2종, 원시 로그, README.md). tools/book_check.py 23종 편집 전후 모두 0건.
106차 검증(2026-08-20 · CHG-151)127차 갭 점검. CHG-150이 남긴 항목을 순서대로 처리했다(① 사전 스핀 메커니즘 추적(최우선), ② Jazzy 이중 호출을 2단계 등록 경로에서 추적, ③ "지연 샘플" 패턴을 FastDDS 내부 로그로 계속 조사). ①(최우선) 120차의 캐스케이드 계측(total_gne·wait_for_work 호출 수)을 재사용해 "사전 스핀은 재구성-대기 캐스케이드 횟수를 줄여서 효과를 낸다"는 가설을 직접 측정했다(N=6, 스레드4, 각 30회, Humble) — 총 호출 수가 두 조건에서 거의 같은데도(사전 스핀 total_gne 28.0 대 기준 27.8) 이탈률은 3.3% 대 73.3%로 극단적으로 달라, 가설은 실측으로 기각됐다. 이어 "사전 스핀은 항상 같은 스레드가 반복 디스패치하게 만든다(스레드 지역성)"는 두 번째 가설도 execute_service() 시작 시점마다 스레드 ID를 해시해 기록하는 새 계측(각 20회)으로 측정했다 — 정반대로 사전 스핀 쪽 스레드 전환율이 더 높아(97.0% 대 84.0%) 이 가설도 기각됐다. 캐스케이드 횟수도 스레드 전환 패턴도 두 조건에서 근본적으로 다르지 않은데 이탈률만 극단적으로 다르다는 뜻이라, 실제 메커니즘은 더 미세한 시점 수준에 있는 것으로 보이며 이번 회차는 거기까지 계측을 넓히지 못했다(확인 필요, 다음 회차 최우선). ② 113차가 조사했던 "2단계 set_on_new_request_callback 등록" 경로를 Jazzy service.hpp에서 다시 확인하니, 이 2단계 등록은 그 API를 명시적으로 호출할 때만 일어나는데 이번 이중 호출을 낸 도구(multi_service_order_test·service_prespin_test)는 이 API를 전혀 쓰지 않는다 — 113차의 반증은 애초에 다른 도구를 겨냥한 것이었다는 것이 드러났다(113차 원문은 보존, 정정 포인터만 추가). 대신 소스에서 새 후보 원인을 찾았다 — 캐시된 wait_result_의 전진 전용 반복자가 서비스를 "선택한" 직후 콜백 그룹을 바쁨으로 표시하지만, 실제 DDS take는 락 밖 execute_service() 안에서(~60µs 소요, 126차 실측) 나중에 일어난다 — 그 사이 다른 스레드의 재구성이 새 wait_result_를 만들면 아직 안 치워진 데이터가 다시 "준비됨"으로 보일 여지가 소스 구조상 있지만, 이번 회차는 직접 계측으로 확정하지 못했다(확인 필요, 다음 회차 후보). ③ sub_arrival_colocated(125차)에 eprosima::fastdds::dds::Log::SetVerbosity(Info)를 추가해 FastDDS 내부 로그를 켜고 ~70ms 지연 샘플이 나온 실행의 로그를 시간순으로 봤다 — 모든 경고가 프로세스 시작 직후 ~8ms 안에 끝났고, 지연 샘플 도착 구간(약 2.4~70.4ms)에는 로그가 단 한 줄도 없었다 — 126차 tcpdump에 이어 FastDDS 자체 로그도 이 구간에서 "배제"로 확인됐다(Info 수준의 더 상세한 로그는 이 배포판 릴리스 빌드에서 컴파일 타임에 빠졌을 가능성이 커, 디버그 빌드나 gdb 스택 샘플링이 다음 후보다)(확인 필요). 2.3.14(주 반영처)·15.1.3·2.2.7에 반영, 하네스는 tools/gap_harness/gap127/(gap127_order/ 패키지 — sub_arrival_fdlog.cpp, 계측된 executor.cpp(캐스케이드+스레드 추적), 원시 로그, README.md). tools/book_check.py 23종 편집 전후 모두 0건.
107차 검증(2026-08-20 · CHG-152)128차 갭 점검. CHG-151이 남긴 항목을 순서대로 처리했다(① 사전 스핀 메커니즘을 사건 단위로 추적(최우선), ② wait_result_ 경합 창 직접 계측, ③ "지연 샘플" 패턴을 gdb나 FastDDS 디버그 빌드로 계속 조사). ①(최우선) 집계가 아니라 사건 단위 타임스탬프로 재기로 했다 — 계측된 executor.cpp가 wait_for_work()(Humble은 매 호출이 곧 재구성)마다 ns 타임스탬프를, 테스트 도구가 set_on_new_request_callback()(순수 측정용)으로 요청 도착 시각과 실제 디스패치 시각을 같은 프로세스·같은 steady_clock로 남기게 했다(Humble, 사전 스핀·기준 각 30회). "마지막 도착~마지막 디스패치" 구간에 재구성이 몇 번 몰리는지를 측정하니 사전 스핀 7~11회 대 기준 23~28회로 분포가 전혀 겹치지 않았다(Welch $t=49.19$, $n=30+30$) — 총량(~27회)은 두 조건이 거의 같지만(126·127차가 이미 확인), 기준 조건은 스핀 시작 순간 스레드 4개가 이미 준비된 데이터를 두고 뮤텍스를 경합하며 ~1.3~1.8ms 안에 25회 안팎을 몰아치는 반면, 사전 스핀은 그 다툼을 400ms에 걸쳐 미리(~16회) 분산해 소화해 실제 디스패치는 ~10회로 차분하게 끝난다. "재구성이 얼마나 자주"가 아니라 "얼마나 몰려서(경합해서) 일어나는가"가 핵심이었다 — 다만 조건 내부에서는 재구성 개수가 개별 실행의 이탈 여부를 예측하지 못해(사전 스핀의 유일한 이탈 사례는 오히려 평균보다 적은 8회였다), 몰림이 정확히 어떻게 등록 순서를 깨뜨리는 인과 경로로 이어지는지는 이번 회차도 완전히 닫지 못했다(확인 필요, 다음 회차 후보). ② 127차의 새 후보 원인(wait_result_ 경합 창)를 계측하려고 service_prespin_evt(40회)·service_prespin_pure(2단계 등록 API 없음, 계측 rclcpp 50회+순정 rclcpp 50회+계측 rclcpp 추가 100회) — 총 240회를 사전 스핀 조건으로 실행했지만 이중 호출이 단 한 번도 재현되지 않았다(FIRE_ORDER 중복 0/240) — 바로 앞 127차가 44.0%(22/50)를 본 것과 대비된다. 113·116차의 반복된 재현율 요동과 같은 세션 의존성으로 보이며, 경합 창 가설은 이번 회차에 확정도 반증도 못했다 — 표본 자체가 없었다(확인 필요, 표본 확보가 급선무). ③ 먼저 gdb -p <pid> -batch 반복 attach를 시도했지만, 한 번의 attach+backtrace+detach가 평균 ~295ms 걸린다는 것을 실측했다 — 관측 대상(~70ms)보다 프로브 자체가 4배 더 크다, 이 스케일엔 방법론적으로 부적합하다는 것이 부수적 발견이다. 대신 /proc/<pid>/task/<tid>/{wchan,stack}를 반복 읽는 경량 폴러(proc_sample.py, ptrace로 안 멈춰 프로브 효과가 훨씬 작다)를 새로 만들어 지연 샘플이 나온 4회 실행을 ~200µs 간격(실행당 5,700~27,000개 표본)으로 샀다 — 어느 실행에서도 어느 스레드도 지연 샘플 구간 내내 지속적으로 실행 중(CPU 사용 중)이지 않았다(전부 futex_do_wait류 대기, 실행 중 표본은 전체의 ~1.5%뿐이고 200µs 미만 일회성 블립). 126차 tcpdump·127차 FastDDS 로그에 이어 세 가지 독립된 방법 전부 이 구간에서 활동을 못 찾았다 — 정확히 어느 타이머·조건변수가 이 ~70ms를 만드는지는 이번에도 확정하지 못했다(확인 필요, 다음 회차 후보 — strace -T나 eBPF/bpftrace). 2.3.14(주 반영처)·15.1.3·2.2.7에 반영, 하네스는 tools/gap_harness/gap128/(gap128_order/ 패키지 — service_prespin_evt.cpp·service_prespin_pure.cpp·sub_arrival_colocated_gdb.cpp, 계측된 executor.cpp(사건 단위 REBUILD·EXEC_SVC 타임스탬프), /proc 폴러, 원시 로그, README.md). tools/book_check.py 23종 편집 전후 모두 0건.
108차 검증(2026-08-20 · CHG-153)129차 갭 점검. CHG-152가 남긴 항목을 순서대로 처리했다(① 재구성-몰림이 등록 순서를 깨는 정확한 경로를 사건 단위로 추적(최우선), ② wait_result_ 경합 창을 결정론적 결함 주입으로 검증, ③ "지연 샘플" 패턴을 strace나 bpftrace로 계속 조사). ①(최우선) Humble 소스(executor.cpp·allocator_memory_strategy.hpp)를 다시 읽어, wait_for_work()·get_next_ready_executable_from_map()이 재구성·필터링·선택을 서로 다른 세 번의 별개 mutex_ 획득으로 처리하고, 무잠금 상태로 블로킹되는 rcl_wait() 도중 다른 스레드가 같은 공유 wait_set_을 자유롭게 다시 짓밟을 수 있다는 것을 확인했다. 계측된 executor.cpp로 REBUILD_START/END·RCLWAIT_RETURN·REMOVE_NULL·PICK(선택된 서비스 ID)·EXEC_SVC_START/END를 사건 단위로 남기고 Humble N=6·스레드4·사전 스핀/기준 각 30회를 실행했다 — 기준 조건의 이탈 실행 22/22 전부(100%)에서, 등록 순서가 처음 깨지는 그 PICK 순간에 다른 스레드의 재구성 또는 실행 창이 겹쳐 있었다. 실측 사례를 보면 스핀 시작 직후 스레드 3~4개가 REBUILD를 반복하면서도 한동안 아무도 PICK에 성공하지 못하다가, 마침내 성공한 첫 PICK이 등록 순서 0이 아니라 3이었다 — 한 스레드가 방금 끝낸 필터링(락 B) 결과를, 다른 스레드의 clear_handles()(락 A)가 곧바로 지워 버리는 것과 정확히 들어맞는다. 128차의 "재구성 몰림" 관찰에 마침내 구체적 인과 경로가 확인됐다 — 다만 사전 스핀이 이 경합 자체를 왜 원천적으로 막는지는 이번에도 완전히 분리하지 못했다(확인 필요, 다음 회차 후보). ② 127차의 wait_result_ 경합 창 가설을 86차(CHG-131)식 결정론적 결함 주입으로 검증했다 — Jazzy execute_service() 맨 앞에 환경변수(GAP129_DELAY_US)로 제어하는 인위적 지연을 넣어 자연 발생 창(~60µs)을 최대 800배(50ms)까지 넓혔다. 주입 없음(30회)·5ms 주입(30회)·50ms 주입(50회), 총 140회 전부 이중 호출이 재현되지 않았다 — 지연 자체는 time으로 실제 걸림을 확인했다(50ms 조건에서 무주입 대비 ~430ms 더 걸렸다). 극단적으로 창을 넓혀도 재현이 안 된다는 것은 127차가 제시한 구체적 경합 창 가설에 불리한 증거다 — 다만 이 세션 자체가 무주입 조건에서도 이중 호출을 전혀 재현하지 못했으므로(128차의 44%와 대조), 오늘의 호스트 조건 자체가 이 버그를 유발하는 어떤 요인을 결여했을 가능성도 배제할 수 없어 조건부 반증으로 정직하게 남긴다(확인 필요, 다음 회차 후보). ③ strace -f -tt -T -e trace=futex로 sub_arrival_colocated_gdb(125·128차)를 처음부터 끝까지 추적해(attach가 아니라 실행 시작부터 감싸 gdb의 ~295ms 프로브 문제를 피했다), 지연 샘플이 나온 독립된 두 실행에서 futex 호출 인자를 직접 봤다 — 두 실행 모두 어떤 스레드가 절대 시각 데드라인이 있는 FUTEX_WAIT_BITSET_PRIVATE(CLOCK_MONOTONIC 기준 타임드 웨이트)로 블로킹된 뒤, 다른 어떤 스레드의 FUTEX_WAKE도 받지 못한 채 자기 데드라인이 만료될 때까지 기다렸다가(64.391ms·62.237ms, 관측된 도착 갭 ~69ms와 거의 일치) ETIMEDOUT으로 깨어났다. "지연 샘플"은 데이터가 늦게 도착하는 것이 아니라, 그 요청을 처리할 스레드가 자기 자신의 타임드 웨이트가 만료되기를 단순히 기다리는 것이었다 — 126~128차의 세 배제 판정(네트워크·로그·CPU 활동 없음)과 완전히 들어맞는다. 정확히 어느 코드 경로가 이 futex를 "웨이크업 연결 없이 타임드로만" 대기시키는지는 futex 주소를 심볼로 역추적하지 못해 확정하지 못했다(확인 필요, 다음 회차 후보). 2.3.14(주 반영처)·15.1.3·2.2.7에 반영, 하네스는 tools/gap_harness/gap129/(gap129_order/ 패키지 — service_prespin_pure.cpp(mode 지원)·sub_arrival_colocated_gdb.cpp, 계측된 executor.cpp 2종(Humble 세 단계 잠금 사건 추적, Jazzy 결정론적 지연 주입), strace 원시 로그, README.md). tools/book_check.py 23종 편집 전후 모두 0건.
109차 검증(2026-08-20 · CHG-154)130차 갭 점검. CHG-153이 남긴 항목을 순서대로 처리했다(① 사전 스핀이 세 단계 락 경합을 피하는 이유를 직접 계측(최우선), ② wait_result_ 경합 창을 다른 지점에 결함 주입, ③ "지연 샘플" futex를 addr2line이나 bpftrace로 심볼화). ①(최우선) 129차가 이미 수집한 원시 [GAP129-EVT] 로그(사전 스핀·기준 각 30회)를 재분석했다 — 새 컨테이너 실행 없이 기존 실측 데이터로 답했다. 뜻밖의 발견: 사전 스핀의 400ms idle 구간이 "계속 순환"이 아니었다 — 스핀 시작 직후 ~935µs 안에 17회를 몰아치고, 그 뒤로는 각 스레드가 단 한 번의 긴 rcl_wait() 블록에 들어가 남은 ~399ms 동안 재구성이 전혀 없다(127·128차의 "400ms에 걸쳐 분산" 서술을 정정한다, 위 정정 포인터 참고). 이를 반영해 "첫 PICK 시점 스레드별 위상 폭"(그 순간까지 각 스레드가 자기 재구성 주기의 어느 지점에 있었는가의 스레드 간 최대-최소 차)을 측정하니 사전 스핀 271.4µs 대 기준 361.0µs로 통계적으로 유의하지 않았다(Welch $t=-1.93$) — 방향도 가설과 안 맞았고(기준이 오히려 크고 변동성도 3배), 관측 가능한 스레드 수라는 교란변수도 있었다. 이 지표로는 129차의 메커니즘을 직접 확인하지 못했다(확인 필요, 다음 회차 후보). ② Jazzy get_next_ready_executable() 소스를 다시 읽어, busy 플래그(can_be_taken_from())가 execute_any_executable()이 execute_service()(실제 take 포함)를 완전히 마친 뒤에야 풀린다는 것을 확인했다 — 129차가 지연을 넣은 지점(execute_service() 진입)은 이미 busy로 보호된 구간 안이었다는 뜻이다. 선택 반복문 안, busy를 세우기 직전으로 지연 지점을 옮겨 재실험했지만(무주입 30회·50ms 주입 50회) 다시 0/80 — 이 세션 누적 220회 사전 스핀 시도에서 이중 호출 0건이 됐다. 소스로 보면 당연한 결과다 — busy 플래그가 선택 시점부터 처리 완료까지 전 구간을 감싸므로 그 안 어디에 지연을 넣어도 재차 선택은 원천적으로 막힌다. 127차의 wait_result_ 경합 창 가설은 이 세션의 실험 범위에서 사실상 배제됐다 — 새 후보는 rmw/DDS 계층의 unread-count 플래그 자체가 잠깐 오래된 상태로 남는 것이다(확인 필요, 다음 회차 후보). ③ bpftrace로 futex 호출부의 사용자 스택을 포착하려 했으나 이 컨테이너 커널의 BTF 타입 정보를 트레이스포인트 인자 파싱에 쓰지 못해 실패했다(kprobe 우회도 struct pt_regs 미해석으로 실패) — 대신 strace -f -k -tt -e trace=futex(라이브러리 unwind 기반 스택, BTF 불필요)로 처음부터 끝까지 감싸 완전히 성공했다. 지연 샘플이 나온 실행(도착 갭 ~159ms)에서 갭 직후, eprosima::fastrtps::rtps::FlowControllerImpl<FlowControllerSyncPublishMode, FlowControllerFifoSchedule>::run()(FastDDS 라이터 쪽 흐름 제어·전송 페이싱 스레드)이 ~152.6ms 동안 블로킹돼 있던 무기한 대기에서 뮤텍스 언락으로 깨어났고, 1.8ms 뒤 ResourceEvent::event_service()(FastDDS 범용 내부 타이머 스레드)도 깨어난 직후 지연 샘플 데이터가 실제로 도착했다. "지연 샘플"은 소켓도 rclcpp의 wait-set도 아니라, FastDDS 자신의 라이터 쪽 흐름 제어 스레드가 큐에 쌓인 샘플을 실제로 내보낼 자기 차례를 몇십~백여 밀리초 동안 기다리는 것이었다 — 같은 로그의 다른 지점에서 DataWriterImpl::write()→create_new_change()→unsent_change_added_to_history()→FlowControllerImpl::run()이라는 발행 경로 전체도 확인해, 같은 참가자 안의 배달(intra-process)도 이 흐름 제어 큐를 거친다는 것을 확정했다. FlowControllerImpl::run()이 왜 하필 ~150ms를 블로킹돼 있었는지는 이번 회차도 소스까지 완전히 추적하지 못했지만, 주소만 있던 129차와 달리 정확한 클래스·함수명까지 확보한 것 자체가 진전이다(확인 필요, 다음 회차 후보). 2.3.14(주 반영처)·15.1.3·2.2.7에 반영, 하네스는 tools/gap_harness/gap130/(129차 원시 로그 재분석 스크립트 2종, 계측된 Jazzy executor.cpp(새 지연 주입 지점), strace -k 완전 심볼화 원시 로그, README.md). tools/book_check.py 23종 편집 전후 모두 0건.
110차 검증(2026-08-20 · CHG-155)131차 갭 점검. CHG-154가 남긴 항목을 순서대로 처리했다(① 락 A(재구성) 충돌을 직접 세는 지표 설계(최우선), ② rmw 리스너의 unread-count 갱신 타이밍 직접 계측, ③ FlowControllerImpl·FlowControllerSyncPublishMode 소스를 직접 읽어 대기 조건변수와 페이싱 간격 확인). ①(최우선) 첫 시도("두 스레드의 [REBUILD_START, REBUILD_END] 구간이 실제로 겹치는 횟수")는 사전 스핀·기준 둘 다 예외 없이 0이었다 — 당연한 결과였다, 그 구간 자체가 mutex_로 보호돼 원천적으로 겹칠 수 없다(방법론적 실패, 정직하게 기록). 지표를 "어떤 스레드가 자기 재구성(락 B, 필터링)을 마친 뒤 실제로 선택(PICK)에 성공하기까지 다른 스레드의 재구성 시작(락 A)이 몇 번 개입하는가"로 바꿨다 — 사전 스핀 30회 전부 예외 없이 개입 0회, 기준은 평균 4.00회(중앙값 3, 최대 18회)로 완벽하게 갈렸다(Welch $t=4.708$, 개별 구간 단위로는 기준의 16.4%가 개입을 겪었다, $t=5.755$) — 129차의 세 단계 락 경합 메커니즘이 사전 스핀에서 정말로 사라진다는 것을 직접적인 지표로 확인했다. 기준 조건 안에서 개입 횟수와 실제 FIRE_ORDER 이탈을 대조하니 이탈 실행의 평균 개입(4.77)가 무이탈(1.88)보다 컸지만 완전한 1:1 대응은 아니었다(개입 0인데도 이탈한 실행 7/12, 개입이 있는데 무이탈 3/18) — 락 A 개입은 이탈의 유력한 경로 중 하나이지 유일한 경로는 아니다(104차의 "여러 rcl_wait() 배치로 분할돼 도착"하는 별개 경로가 개입 없이도 이탈을 낼 가능성)(확인 필요, 다음 회차 후보). ② Jazzy rmw_fastrtps_shared_cpp 8.4.4 소스(custom_service_info.hpp)를 읽었다 — ServiceListener::get_unread_resquests()는 info_->request_reader_->get_unread_count(true)를 그때그때 직접 호출한다, rmw 계층이 별도로 캐시해 두는 "읽지 않음" 플래그가 전혀 없다. 129차가 제시한 "unread-count 플래그가 잠깐 오래된 상태로 남는다"는 가설은, 적어도 이 특정 형태(rmw 계층의 캐시)로는 성립하지 않는다. 실제 창(busy가 다시 true로 풀린 이후, FastDDS 자신의 내부 상태가 방금 끝난 take를 완전히 반영했는지)을 직접 여는 결함 주입은 FastDDS 내부를 패치해야 해서 이번 회차 범위를 벗어난다고 판단하고, 대신 스레드 수를 극단적으로 늘린(N=6에 스레드 16개, 2.7배 과잉 구독) 스트레스 테스트를 사전 스핀 조건 50회 했다 — 여전히 0/50, 127~131차 누적 270회 사전 스핀 시도에서 이중 호출 0건이다. 콘텐션을 극단적으로 늘려도 안 걸린다는 것은 이 창이 있다 해도 매우 좁거나, 106차가 최초로 우연히 관측한 특정 호스트 조건에만 의존하는 다른 요인일 수 있다는 뜻이다(확인 필요, 다음 회차 후보). ③ Humble fastrtps 2.6.12 소스(src/cpp/rtps/flowcontrol/FlowControllerImpl.hpp)를 직접 읽었다 — FlowControllerSyncPublishMode는 FlowControllerPureSyncPublishMode와 FlowControllerAsyncPublishMode 둘 다를 상속하는데, 129·130차가 검출한 futex는 후자의 wait()이고 그 구현은 cv.wait(lock) — 타임아웃이 전혀 없는 순수 무기한 대기다(고정 "페이싱 주기"는 대역폭 제한 모드에만 있고 period_ms - lapse로 cv.wait_for()를 쓴다 — 이 테스트는 그 모드를 쓰지 않는다). 130차가 관측한 ~62~150ms는 FastDDS의 고정 상수가 전혀 아니다 — 그저 이 스레드가 마지막으로 할 일을 마친 뒤 다음 알림을 받기까지 우연히 걸린 유휴 시간일 뿐이다(→ 130차 정정). 깨우는 지점(enqueue_new_sample_impl())의 주석이 정확히 설명한다 — // Sync delivery failed. Try to store for asynchronous delivery. 즉 이 스레드는 "동기 전달이 실패한" 샘플이 큐에 들어올 때만 깨어난다 — 지연 샘플 샘플은 동기 전달이 (아직 밝히지 못한 이유로) 실패해 이 비동기 재시도 큐로 떨어진 것이고, 관측된 시간은 "실패 감지부터 알림까지"가 아니라 "이 스레드의 마지막 활동 이후 유휴 시간"이다 — 실제 남은 질문은 "동기 전달이 왜 그 샘플에서만 실패했는가"로 완전히 바뀐다(확인 필요, 다음 회차 후보). 2.3.14(주 반영처)·15.1.3·2.2.7에 반영, 하네스는 tools/gap_harness/gap131/(129차 원시 로그 재분석 스크립트 2종, Jazzy 스트레스 테스트 원시 로그, README.md — ③은 순수 소스 조사). tools/book_check.py 23종 편집 전후 모두 0건.
111차 검증(2026-08-20 · CHG-156)132차 갭 점검. CHG-155가 남긴 항목을 순서대로 처리했다(① 104차의 "배치 분할" 경로를 직접 계측(최우선), ② FastDDS DataReaderHistory·get_unread_count() 소스를 위에서 먼저 읽기, ③ RTPSWriter의 동기 전달 경로에서 실패 조건 확인). ①(최우선) 129차 원시 로그를 재분석해 "N개가 한 rcl_wait() 배치로 도착하는가, 여러 배치로 분할되는가"를 사건 단위로 셌다 — 배치 수가 사전 스핀(평균 5.93/6)·기준(평균 5.17/6) 둘 다 N에 가까웠고, 오히려 이탈이 없는 사전 스핀 쪽이 더 높았다 — 104차의 "한 배치=정돈됨" 그림과 방향이 안 맞는다. 한 스레드가 자기 재구성 사이클 안에서 2개 이상을 연달아 선택한 경우는 이 데이터셋에서 사실상 없었다 — "배치 분할"은 두 조건을 가르는 독립 변수가 아니라, 두 조건 모두에서 거의 항상 최댓값(N에 가까움)으로 나타나는 상수에 가까운 현상이었다. 131차의 락 A 개입과 2×2로 대조하니 배치>1&개입>0은 이탈 79%, 배치>1&개입=0은 이탈 64%였다 — 배치≤1인 실행이 이 데이터에 하나도 없어 "잔차(둘 다 없는데 이탈)"는 표본 자체가 없었다(정직하게 구분: "0건"이 곧 "설명 완결"을 뜻하지 않는다). 실질적 잔차는 "개입=0인데도 이탈률이 64%"라는 것 — 락 A 개입 하나만으로는 기준 이탈의 상당 부분을 설명하지 못한다(확인 필요, 다음 회차 후보). ② Jazzy fastrtps 2.14.6 소스를 rmw 호출부(request_reader_->get_unread_count(true))부터 DataReader→DataReaderImpl→DataReaderHistory→RTPSReader까지 위에서 아래로 따라갔다 — 131차의 "실시간 조회일 뿐 캐시가 아니다"는 rmw 계층만 본 결론이었다. 그 답 자체는 RTPSReader::total_unread_라는 유지되는 카운터였다(RTPSReader.h의 멤버 변수). 이 카운터를 갱신하는 지점이 최소 네 곳이었는데, get_unread_count(mark_as_read=true)(rmw 리스너 경로)가 get_last_notified() 기준으로 직접 isRead를 세우는 것과, change_read_by_user()(end_sample_access_nts()를 통한 실제 애플리케이션 take 경로)가 독자적으로 같은 카운터를 갱신하는 것이 서로 다른, 독립된 두 경로였다 — 경합 창이 존재할 구조적 여지는 실재하지만, get_last_notified()가 정확히 무엇을 추적하는지와 두 경로가 실제로 어긋나는 시나리오는 이번 회차에 완전히 추적하지 못했다(확인 필요, 다음 회차 후보). ③ deliver_sample_to_intraprocesses() → intraprocess_delivery()(그대로 reader->processDataMsg(change)의 반환값을 전달) → StatefulReader::processDataMsg()가 false를 반환하는 것이 "동기 전달 실패"의 정체다. 실패 분기 중 유력한 후보는 !mp_history->can_change_be_added_nts(...)와 !change_pool_->reserve_cache(...)(둘 다 "이 리더의 QoS가 허용하는 최대 샘플 수에 도달"이라는 같은 로그 메시지) — 이 책의 지연 샘플 재현 테스트가 KeepLast(1)을 쓰므로 유력해 보였지만, 기반 클래스 ReaderHistory::can_change_be_added_nts()(rtps/history/ReaderHistory.cpp)를 실제로 읽으니 깊이 제한 자체는 구현돼 있지 않고 페이로드 크기 검사만 했다 — 실제 깊이 제한은 찾지 못한 파생 클래스(DataReaderHistory)에 있을 것으로 보인다(확인 필요, 다음 회차 후보). 2.3.14(주 반영처)·15.1.3·2.2.7에 반영, 하네스는 tools/gap_harness/gap132/(129차 원시 로그 재분석 스크립트, README.md — ②③은 순수 소스 조사). tools/book_check.py 23종 편집 전후 모두 0건.
112차 검증(2026-08-20 · CHG-157)133차 갭 점검. CHG-156이 남긴 항목을 순서대로 처리했다(① 잔차(락 A 개입=0인데도 이탈)의 정체를 개별 조사(최우선), ② RTPSReader::get_last_notified()의 실제 구현 확인 후 결함 주입 설계, ③ DataReaderHistory::can_change_be_added_nts() 실제 오버라이드 확인). ①(최우선) 132차가 남긴 잔차(락 A 개입=0인데도 이탈한 기준 실행 7건)를 개별적으로 다시 봤다 — 7건 전부가 등록 순서 안의 어떤 연속 구간(창)에서, 그 창의 마지막 원소가 창의 맨 앞으로 이동하고, 나머지는(창 안이든 밖이든) 정확히 원래 상대 순서를 유지하는 순수한 회전이었다. 무작위로 뒤섞인 경우는 하나도 없었다(예: [3,0,1,2,4,5]는 창 [0,1,2,3]이 [3,0,1,2]로 회전한 것뿐이고, [0,5,1,2,3,4]는 0은 그대로 두고 창 [1,2,3,4,5]가 [5,1,2,3,4]로 회전한 것). 해석: 이 창은 그 시점까지 실제로 도착해 있던 엔티티들의 부분집합이고, 창의 마지막(가장 늦게 등록된) 원소가 먼저 준비된 것으로 보이는 것은 103·124차가 이미 확립한 "동시에 보내도 실제 도착 순서는 등록 순서와 미세하게 어긋난다"는 현상의 재현으로 보인다 — 락 A 개입(129·131차)와 이 "도착 미세 순서 어긋남"(103·124차, 이번에 재확인)은 서로 다른 두 개의 독립된 이탈 경로였다(확인 필요, 다음 회차 후보 — 이 가설을 직접 검증하려면 도착 타임스탬프까지 함께 찍는 계측이 필요하다). ② Jazzy fastrtps 2.14.6 소스에서 RTPSReader::update_last_notified()의 호출부를 찾았다 — StatefulReader::NotifyChanges()가 정확한 지점이다. "notified" 워터마크(get_last_notified()가 읽는 값)는 개별 변경을 리스너에게 알리기 전에 이미 max_seq(그 라이터가 갖고 있는 가장 최신 변경)까지 미리 당겨진다("Update state before notifying"라는 주석이 그대로 있다). 이것은 이례적인 타이밍 경합이 아니라 코드가 항상 그렇게 짜여 있는 것이다 — 모든 알림에서 매번 결정론적으로 일어나는 순서다. 이 발견은 오히려 129~132차의 경합 창 가설에 불리한 증거다 — 이 순서가 이중 호출의 원인이라면 항상 일어나야 하는데, 실제 이중 호출은 드물다(127~132차 누적 270회 사전 스핀 시도 중 0건) — total_unread_/isRead가 실제로 rcl_wait()의 준비 판정을 좌우하는 값이 아닐 가능성이 크다 — 122차가 이미 언급한 get_statuscondition()·has_triggered_condition() 쪽이 실제 게이트일 가능성이 남았다(확인 필요, 다음 회차 후보). ③ Humble fastrtps 2.6.12 소스에서 DataReaderHistory::can_change_be_added_nts()를 직접 읽었다 — 이것은 resource_limited_qos_.max_samples(전체 리소스 한도)만 검사한다. KeepLast(depth)의 실제 강제는 여기가 아니라 received_change_keep_last()에 있었다 — 같은 인스턴스에 이미 depth개의 미소비 샘플이 있을 때만 "교체 시도" 분기가 발동한다. 결정적으로: 이 책의 지연 샘플 재현 테스트(sub_arrival_colocated)는 토픽마다 정확히 한 번만 발행한다 — 즉 각 인스턴스의 instance_changes.size()는 그 한 번의 발행이 도착하는 순간 항상 0이었을 것이므로, add=true 분기로 바로 들어가 이 "교체" 로직 자체가 발동할 일이 없다. KeepLast(1) 깊이 제한 가설은 이 테스트 구조에는 애초에 적용되지 않는다는 것이 소스로 확인됐다 — 111·132차가 유력 후보로 지목한 것은 배제됐다. "동기 전달 실패"의 실제 원인은 여전히 열려 있다(확인 필요, 다음 회차 후보). 2.3.14(주 반영처)·15.1.3·2.2.7에 반영, 하네스는 tools/gap_harness/gap133/(129차 원시 로그 재분석 스크립트, README.md — ②③은 순수 소스 조사). tools/book_check.py 23종 편집 전후 모두 0건.
113차 검증(2026-08-20 · CHG-158)134차 갭 점검. CHG-157이 남긴 항목을 순서대로 처리했다(① 잔차 가설(도착 미세 순서 어긋남)을 도착 시각 직접 계측으로 검증(최우선), ② has_statuscondition()/has_triggered_condition()의 실제 구현 확인, ③ processDataMsg() 후보 분기 GDB 직접 계측). ①(최우선) 128차의 service_prespin_evt(set_on_new_request_callback()으로 executor 디스패치와 완전히 독립적으로 도착 시각을 측정하는 도구)를 gap129의 계측된 executor.cpp 위에서 다시 빌드해 사전 스핀·기준 각 30회를 새로 실행했다 — 이탈 실행 28건(사전 스핀 1 + 기준 27) 전부에서 ARRIVAL_ORDER(미들웨어 리스너가 독립적으로 측정한 도착 순서)가 예외 없이 완벽한 등록 순서였다, FIRE_ORDER가 [5,4,3,0,1,2]처럼 크게 뒤섞인 경우조차 그랬다. 133차가 "추정"만 했던 "국소 창 회전=도착 미세 순서 어긋남" 가설은 이 직접 계측으로 반증됐다 — 이탈은 도착 잡음이 전혀 아니고 데이터가 이미 다 정상적으로 도착한 이후 executor의 선택/재구성 경합(락 A) 안에서만 만들어진다. 다만 이번 세션의 기준 이탈률(90.0%)은 133차의 잔차 모집단보다 훨씬 높고 회전 형태도 복잡해(블록 반전에 가까움), "국소 창 회전"이라는 형태론적 서술 자체를 재검증한 것은 아니다 — 세션 간 재현율 변동(121·122차 기존 패턴)으로 보인다(확인 필요, 다음 회차 후보). ② rmw_wait.cpp(122차가 이미 읽었으나 133차가 실재하지 않는 이름을 표적으로 잘못 지목했다)를 다시 읽어 확정한다 — 서비스·구독·클라이언트가 대기 전·후 두 번 부르는 실제 준비 판정 함수는 DataReader::get_first_untaken_info()다. DataReaderHistory::get_first_untaken_info()는 129~133차가 판 total_unread_ 카운터를 전혀 갱신하지 않고, data_available_instances_라는 별도 맵을 순회한다 — 이 맵은 received_change()가 새 변경을 받을 때 채워지고 실제 애플리케이션 take로 그 인스턴스의 캐시가 완전히 비었을 때만(check_and_remove_instance()) 비워진다. 즉 rcl_wait()의 준비 판정은 total_unread_/get_last_notified()와 완전히 별개의, DDS 의미론에 훨씬 가까운 자료구조로 결정된다 — 129~133차가 판 경로는 리스너 콜백이 유저 콜백을 부를지만 결정할 뿐 대기집합 준비 판정과는 무관하다. 122차가 이미 확인한 대로 이 경로도 구독·클라이언트·서비스 셋 다 같은 코드라 구독-서비스 비대칭은 여전히 설명 못 한다(확인 필요, 다음 회차 후보). ③ ros-humble-fastrtps-dbgsym으로 재빌드 없이 디버그 심볼만 확보해 StatefulReader::processDataMsg()의 각 실패 분기에 어셈블리 수준 중단점을 설정했다(최적화 빌드의 "동일 코드 접기"로 소스 줄번호 기준 중단점이 여러 분기를 뒤섞는 함정을 발견하고, 호출 직후 고유 오프셋 주소로 우회했다). sub_arrival_colocated_gdb를 29회 실행해 총 1769회의 processDataMsg() 호출을 관찰했다 — 132·133차가 후보로 지목한 분기 전부(can_change_be_added_nts·reserve_cache·get_payload·change_received·필터·datasharing)가 실제 동기 전달 1566회에서 단 한 번도 실패하지 않았다. 유일한 실패는 acceptMsgFrom()(87회, 우리 테스트 토픽과 무관한 다른 참가자 트래픽)뿐이었다. 다만 이 세션에서는 "지연 샘플" 자체가 재현되지 않아 GDB 오버헤드가 그 경합 창을 억눌렀을 가능성이 있다 — "정상 배달은 안 실패한다"까지는 확정했지만 "지연 샘플이 나는 순간에도 안 실패한다"까지는 확정 못 했다(확인 필요, 다음 회차 후보). 2.3.14(주 반영처)·15.1.3·2.2.7에 반영, 하네스는 tools/gap_harness/gap134/(service_prespin_evt.cpp 재빌드·재실행, branch_instrument_gdb.py, README.md). tools/book_check.py 23종 편집 전후 모두 0건.
114차 검증(2026-08-20 · CHG-159)135차 갭 점검. CHG-158이 남긴 항목을 순서대로 처리했다(① get_first_untaken_info()의 이중 준비 신호 결함 주입(최우선), ② GDB를 벗어나 원자적 카운터로 재계측, ③ 도착-시각 도구와 [GAP129-EVT]를 사건 단위로 대조). ①(최우선) data_available_instances_를 채우는 add_to_instance()(수신 스레드)와 비우는 check_and_remove_instance()(take 스레드)의 타이밍을 GAP135_REMOVE_DELAY_US(erase 직전 지연)로 어긋나게 해 fastrtps 2.6.12를 실제로 재빌드하고 결함 주입했다 — 지연 0·1ms·10ms·50ms 각 15회 + 지연 0 추가 30회, 디스커버리 트래픽까지 포함해 총 9084건의 add/remove 사건을 관찰했다. 50ms까지 지연을 넓혀도(자연 창의 약 700배) 다른 add_to_instance()가 지연된 check_and_remove_instance()의 실제 erase 창 안으로 개입한 경우는 단 한 번도 없었다 — 두 함수가 같은 리더-레벨 RecursiveTimedMutex(reader_->getMutex()/mp_mutex) 아래서 호출돼 애초에 완전히 직렬화돼 있기 때문이다(소스로 확인). 134차가 찾은 게이트가 실재하는 것과 그 게이트가 이중 호출을 낼 수 있는 것은 별개의 질문이었는데, 이번 회차로 후자는 구조적으로도 실측으로도 부정됐다. ② StatefulReader::processDataMsg()에 std::atomic 카운터(printf 없음)를 심어 재빌드했다 — GDB 없이 sub_arrival_colocated_gdb를 40+40회 실행하니 "지연 샘플" 재현율이 134차의 GDB 세션 0%(0/29)에서 40.0%(16/40)·42.5%(17/40)로 상승했다, GDB 오버헤드가 경합 창을 억누르고 있었다는 134차의 우려가 확인됐다. 재현된 스트래글러 33건을 포함해도 processDataMsg()의 후보 분기는 여전히 단 한 번도 실패하지 않았다. 더 깊이 조사해, 131차가 "동기 전달 실패" 시에만 불린다고 지목한 enqueue_new_sample_impl()에 카운터를 심으니 40회(지연 샘플 17건 포함) 전부 0이었다 — 대신 add_old_sample_impl()(기존 변경 재전송, 디스커버리 관련으로 보임)이 지연 샘플 유무와 무관하게 매 실행 정확히 13회 불렸다. 즉 129·130차가 strace로 찾은 FlowControllerImpl::run() 스레드는 우리 샘플의 동기 실패를 처리하느라 깨어난 것이 아니라 정기적인 구식 샘플 재전송을 처리하고 있었다 — 이 스레드가 지연 샘플 시점 근처에서 관측된 것은 인과관계가 아니라 우연한 시간적 겹침일 가능성이 크다(확인 필요, 다음 회차 후보). ③ 128차의 도착 시각 도구를 gap129의 계측 executor.cpp 위에서 다시 빌드해 기준 조건 30회를 새로 실행하고, 각 PICK 사건마다 "이 스레드 자신의 직전 REBUILD_END부터 이 PICK까지 다른 스레드의 REBUILD_START가 몇 번 개입했는가"를 사건 단위로 계산했다 — 스왑된 픽(44건)의 79.5%(35건)는 실제로 다른 스레드의 재구성 시작이 그 순간에 개입해 있었고, 정상 순서로 선택된 픽(136건)은 6.6%(9건)만 그랬다(약 12배 차이). 108·110차의 회차-단위 상관을 개별 스왑 하나하나에 대해 직접 확인했다. 나머지 9/44(20.5%)의 스왑은 개입 없이 일어나 여전히 설명되지 않은 채 남는다(확인 필요, 다음 회차 후보). 2.3.14(주 반영처)·15.1.3·2.2.7에 반영, 하네스는 tools/gap_harness/gap135/(DataReaderHistory.cpp·StatefulReader.cpp·FlowControllerImpl.hpp 계측 사본, 분석 스크립트 4종, README.md). tools/book_check.py 23종 편집 전후 모두 0건.
115차 검증(2026-08-20 · CHG-160)136차 갭 점검. CHG-159가 남긴 항목을 순서대로 처리했다(① 재전송 사건 단위 타임스탬프로 지연 샘플 겹침 확인(최우선), ② 잔차 스왑 개별 재검토, ③ 픽 위치별 분리). ①(최우선) FlowControllerImpl.hpp에 사건 단위(steady_clock ns) 로그를 추가하고 테스트 프로세스의 T0_ABS_NS로 도착 시각을 절대 시각으로 환산해 직접 겹쳐 봤다 — 45회 실행(지연 샘플 16건, 35.6%) 중 지연 샘플 16/16건 전부에서 add_old_sample_impl()의 재전송 사건이 그 ~70ms 갭 창 안에 2~8회씩 정확히 들어와 있었다(총 86회, 우연 기대치인 실행당 약 0.3회의 약 18배). 135차의 "우연한 시간적 겹침일 가능성이 크다"는 판단은 정정한다 — 재전송과 지연 샘플은 실제로 강하게 동시에 일어난다. 다만 인과 방향(재전송 폭주가 지연을 유발하는지, 둘이 같은 상위 원인을 공유하는 별개 증상인지)은 이번 회차로도 가리지 못했다(확인 필요, 다음 회차 후보). ② 60회 표본으로 늘려 락 A 개입=0인 스왑마다 "그 스레드 자신의 직전 REBUILD_END가 로그에 있는지"를 추가로 확인했다 — 75건의 스왑 중 잔차(개입=0) 28건 전부에서 그 픽을 만든 스레드는 이 실행 안에서 자기 REBUILD_START/END 사이클을 단 한 번도 기록하지 않았다(개입 있던 47건과 정상 픽 285건은 예외 없이 자기 REBUILD_END가 있었다). 소스(executor.cpp)를 다시 읽어 이유를 확인했다 — Executor::get_next_executable()은 wait_for_work()(락 A 재구성)보다 get_next_ready_executable()을 먼저 시도한다: 다른 스레드가 이미 채워 둔 service_handles_에 안 선택된 항목이 남아 있으면 이 스레드는 자기 재구성을 한 번도 안 거치고 곧바로 선택할 수 있다 — 이것이 락 A 개입 지표가 애초에 적용될 수 없는(비교 기준점인 "자기 REBUILD_END"가 없는) "빠른 경로" 픽이다. 락 A(47건)와 빠른 경로(28건)를 합치면 관측된 스왑의 100%(75/75)가 설명된다. ③ 같은 60회 데이터를 픽 순번(k=0~5)으로 나눠 스왑률을 다시 셌다 — 스왑률이 60.0%(픽#0)→36.7%(픽#1)→25.0%(픽#2)→3.3%(픽#3)→0%(픽#4)→0%(픽#5)로 단조 감소했다. 게다가 ②의 "빠른 경로" 스왑 28건은 전부 픽#0~2에만 몰려 있고 픽#3~5에는 단 하나도 없다 — 각 스레드가 최소 한 번씩 wait_for_work()를 거치고 나면(대개 처음 한두 픽 사이클 안에 전부 거친다) 이 경로 자체가 더는 발동할 수 없기 때문이다. 결론: 첫 픽의 스왑은 중간 픽 스왑의 극단값이 아니라, 락 A 경합이 가장 심하다는 것과 빠른 경로가 존재할 수 있는 유일한 구간이라는 두 가지가 정확히 겹치는 스핀-시작 특유의 현상이다. 2.3.14(주 반영처)·15.1.3·2.2.7에 반영, 하네스는 tools/gap_harness/gap136/(FlowControllerImpl.hpp·sub_arrival_colocated_gdb.cpp 계측 사본, 분석 스크립트 2종, README.md). tools/book_check.py 23종 편집 전후 모두 0건.
116차 검증(2026-08-20 · CHG-161)137차 갭 점검. CHG-160이 남긴 항목을 순서대로 처리했다(① 재전송-지연 샘플 인과 방향 결함 주입(최우선), ② 빠른 경로 픽의 상속 상태 계측, ③ Humble·Jazzy 근본 원인 — 코디네이터 제안대로 후자로 무게중심을 옮김). ①(최우선) add_old_sample_impl()에 GAP137_SUPPRESS_RESEND 환경변수로 실제 재전송을 건너뛰는 결함 주입을 넣고 정상·억제 각 40회를 실행했다 — 정상은 40/40 모두 6개 메시지 전부 수신(fired=6)했지만, 억제는 40/40 모두 단 하나도 수신하지 못했다(fired=0). 이유는 테스트 구조에 있었다 — sub_arrival_colocated_gdb는 TRANSIENT_LOCAL QoS로 구독을 만들기 전에 6개 메시지를 전부 발행한다 — 발행 시점엔 매칭된 리더가 없어 동기 전달 시도 자체가 무의미하고, 나중에 매칭된 구독에게 라이터 히스토리의 오래된 샘플을 채워 주는 add_old_sample_impl() 경로가 이 테스트의 유일한 전달 수단이었다. 136차의 "사건 단위 겹침"은 우연도 부작용도 아니라, 지연 샘플이 바로 그 재전송 경로를 타는 6개 샘플 중 비동기 스레드(FlowControllerImpl::run())의 스케줄링 지연을 물려받은 하나였다는 뜻이다. 134·135차의 "processDataMsg() 1566회 중 0건 실패" 측정 자체는 정확했지만, 그 호출 대부분이 디스커버리 트래픽이었을 뿐 우리 6개 테스트 토픽의 실제 전달은 동기 경로가 아니라 이 비동기 재전송 경로였다는 것이 드러났다(확인 필요, 다음 회차 후보 — processDataMsg() 호출마다 토픽 식별자를 남겨 실제 비율을 확정한다). ② wait_for_work()의 REBUILD_END마다 "마지막 재구성자" 스레드를 전역 원자값에 기록하고 매 PICK마다 함께 남겼다([GAP137-META]) — 빠른 경로 픽 29건 전부(100%)에서 선택한 스레드가 아니라 다른 스레드가 마지막으로 재구성한 상태를 물려받았다(그 외 픽은 331건 중 78건, 23.6%만). 136차의 메커니즘을 사건 단위로 완전히 확인했다 — 다만 빠른 경로 픽이 항상 스왑을 만드는 것은 아니다(29건 중 19건, 65.5%만 스왑). "자기 재구성 없이 선택한다"에서 "왜 남의 상태인가"까지 100% 닫혔다. ③ 코디네이터의 제안대로 스왑 메커니즘(이미 100% 커버)보다 오래된 미해결 질문(Humble이 서비스·타이머에서 Jazzy보다 불리한 이유)으로 무게중심을 옮겼다 — Jazzy rclcpp 28.1.21의 get_next_ready_executable()을 처음 직접 읽으니 공유 std::optional 멤버 wait_result_를 wait_for_work() 맨 앞에서 매번 reset()한 뒤 다시 채우는 구조로, Humble의 점진적으로 걸러지는 벡터(service_handles_, 결코 빈 상태로 리셋 안 됨)와 다르다는 것을 확인했다. Humble과 같은 형식의 계측을 Jazzy에도 심어 같은 세션에서 60회씩 나란히 실행했다 — Humble 44/60(73.3%) 이탈·스왑 66건(락 A 48·빠른 경로 18) 대 Jazzy 60/60(100%) 이탈·스왑 242건(락 A 14·구조가 다른 잔차 228). 정직하게 밝힌다 — 이 절대 수치는 113·114차의 기존 기준(Jazzy 54.0% 대 Humble 74.7%, Humble이 더 나쁨)과 방향이 반대다 — 이번 회차가 처음으로 Jazzy의 선택 경로 자체에 사건 단위 계측을 심었다는 것이 유력한 원인으로 보여(113·114차는 계측 없는 순정 빌드), 이 절대 비교는 기존 결론을 뒤집는 근거로 못 쓴다. 유효한 것은 Jazzy 스왑의 94.2%(228/242)가 Humble에서 확립한 두 메커니즘(락 A·빠른 경로) 어느 쪽으로도 안 설명된다는 구조적 사실뿐이다 — 108차가 원래 지목한 std::unordered_map 해시 순서가 스레드 경합과 거의 무관하게 결정론적으로 순서를 흩뜨리는, Humble과는 다른 근본 메커니즘일 가능성을 사건 단위로 뒷받침한다(확인 필요, 다음 회차 후보 — 계측 없는 순정 빌드로 같은 세션 비교를 다시 해 오늘의 역전이 계측 오버헤드 때문인지 확정한다). 2.3.14(주 반영처)·15.1.3·2.2.7에 반영, 하네스는 tools/gap_harness/gap137/(FlowControllerImpl.hpp·humble_executor.cpp·jazzy_executor.cpp 계측 사본, 분석 스크립트 2종, README.md). tools/book_check.py 23종 편집 전후 모두 0건.
117차 검증(2026-08-20 · CHG-162)138차 갭 점검. CHG-161이 남긴 항목을 순서대로 처리했다(① 계측 없는 Humble·Jazzy 재비교(최우선), ② Jazzy 선택 순서의 정체, ③ processDataMsg() 토픽 구성). ①(최우선) service_prespin_evt를 어떤 오버레이도 없이 시스템 rclcpp(Humble 16.0.19·Jazzy 28.1.21)에 그대로 빌드해 같은 세션에서 기준 조건 60회씩 실행했다 — Jazzy는 계측을 걷어내도 여전히 60/60(100%) 이탈이었다(137차 계측 세션과 동일). Humble은 계측 유무에 따라 실제로 바뀌었다(73.3%→50.0%) — 하지만 그래도 113·114차 기존 기준(Jazzy 54.0% 대 Humble 74.7%)과는 방향이 다르다. 결론: 오늘 세션의 역전은 계측 오버헤드가 아니라 121·122차가 이미 확립한 세션 간 변동성으로 보는 것이 타당하다 — Jazzy가 이 호스트·이 시각에 100%에 가깝게 수렴하는 이유 자체는 ②가 직접 설명한다. ② next_ready_service()(rclcpp/wait_result.hpp)는 rcl_wait_set.services[]를 단순 선형 스캔한다 — 순서는 이 배열이 채워지는 순서에서 온다. Executor::collect_entities()를 추적하니 current_collection_.services.update(...)가 ExecutorEntitiesCollection::ServiceCollection(std::unordered_map<const rcl_service_t*, ...>를 상속)의 update_entities()를 부르고, 이 함수는 update_from.begin()부터 end()까지 순회한다 — 이것이 바로 해시 버킷 순회 순서다. 108차의 원래 가설이 함수·줄 단위로 완전히 확정됐다. 137차가 이미 모은 Jazzy 60회 원시 로그로 직접 검증하니 60회 중 37회(61.7%)가 정확히 같은 FIRE_ORDER(5,3,2,1,0,4) 하나로 수렴했다 — 720가지 가능한 순열 중 하나가 이렇게 압도적으로 반복되는 것은 순수 레이스로는 설명이 안 되고, 거의 항상 같은 해시 버킷 순서로 귀결되는 결정론적 메커니즘과 정확히 들어맞는다. Jazzy의 실패 모드는 Humble과 근본적으로 다르다 — Humble은 스레드가 서로를 밟는 동적·확률적 경합이고, Jazzy는 거의 항상 같은 순서로 스크램블되는 정적·결정론적 편향이다. ③ StatefulReader::processDataMsg()의 맨 앞에서 이 리더의 GUID 엔티티ID를 사건 단위로 남겼다. sub_arrival_colocated_gdb(6개 토픽, TRANSIENT_LOCAL)를 30회 실행하니 — 우리 6개 테스트 토픽은 전체 호출(1830회)의 19.7%(360회, 토픽당 실행마다 정확히 2회)뿐이었고, 나머지 80.3%(특히 엔티티ID 하나가 73.8%)는 테스트와 무관한 트래픽이었다. 낮은 엔티티ID는 빌트인 SPDP/SEDP 종류 코드가 아니라 일반 사용자 리더 종류 코드를 써서, rmw_fastrtps의 ROS 그래프 상태 전파용 일반 DDS 토픽(ros_discovery_info류)일 가능성이 크다(확인 필요, 다음 회차 후보 — 토픽 이름까지 직접 로그에 남겨 확정한다). 결론: 134·135차의 "1566회 중 0건 실패" 측정 자체는 정확했지만, 그 대부분은 우리 테스트가 아니라 배경 트래픽이었다는 것이 이번 회차로 정량 확인됐다 — 우리 자신의 6개 토픽 360회 역시 0건 실패였다는 것도 재확인했다. 2.3.14(주 반영처)·15.1.3·2.2.7에 반영, 하네스는 tools/gap_harness/gap138/(StatefulReader.cpp 계측 사본, 분석 스크립트 2종, README.md). tools/book_check.py 23종 편집 전후 모두 0건.
118차 검증(2026-08-20 · CHG-163)139차 갭 점검. CHG-162가 남긴 항목①②를 처리한 뒤(③은 코디네이터의 판단대로 실무 지침 집필로 무게중심을 옮김), 32회차(108~139차)에 걸친 executor 디스패치 조사를 정리했다. ① DataReaderHistory(원래 private)의 topic_name_에 공개 접근자를 추가해 processDataMsg()에서 토픽 이름까지 함께 남겼다 — 138차가 73.8%로 지목한 배경 트래픽의 정체가 정확히 ros_discovery_info로 확정됐다(122차가 이미 언급한, rmw_fastrtps의 ROS 그래프 상태 전파용 일반 DDS 토픽). 6.6%였던 두 번째 배경 트래픽은 rt/parameter_events로 확인됐다. 우리 6개 토픽은 예상대로 실행당 정확히 2회씩이었다. ② 137차의 Jazzy 60회 로그를 재분석하니(새 컨테이너 실행 없음), 4개의 서로 다른 순서가 반복적으로 나타나 전체의 81.7%(49/60)를 차지했다(1위 5,3,2,1,0,4 37회, 2위 2,1,5,0,4,3 7회, 3위 5,4,3,2,1,0 3회, 4위 5,3,4,2,1,0 2회) — "지배적 순서 하나 + 순수 잡음"이라는 138차의 단순화를 다듬어, 소수의 안정적인 해시 버킷 순서 후보군이 있고 실행마다(ASLR에 따른 힙 배치 차이로 추정) 그중 하나로 수렴하는 것으로 보인다. 반복 순서와 단발 순서(11/60, 18.3%)를 REBUILD_START 총 횟수로 갈라 보려 했지만 명확하게 갈리지 않아, 단발 순서가 순수 락 A 잡음인지 그 자체도 안정적인데 표본이 작아 한 번씩만 걸린 것인지는 가르지 못했다(확인 필요, 다음 회차 후보). ③ 코디네이터의 판단대로 — 양쪽 배포판의 핵심 메커니즘(Humble: 락 A 경합+빠른 경로, Jazzy: 해시 버킷 순서)이 이미 소스·실측 둘 다로 충분히 규명됐다고 보고 — 32회차(108~139차)에 걸친 조사 전체를 실무 지침으로 정리했다. 2.3에 신규 절 2.3.15(실무 지침)를 추가해 증명된 것(같은 타입 엔티티 순서는 두 배포판 다 등록 순서를 안 지킨다, Humble은 사전 스핀으로 82.1%→0.0%까지 완화되지만 Jazzy는 경합이 원인이 아니라 안 통한다, TRANSIENT_LOCAL 선발행 데이터는 비동기 재전송이라는 별도 지연 경로를 거친다)과 증명되지 않은 것(이중 호출의 정확한 원인은 26개 이상의 회차에 걸친 결함 주입으로도 특정 못함, ASLR은 부분적 역할만 함, Humble이 Jazzy보다 정량적으로 얼마나/왜 불리한지는 여전히 열려 있음)을 정직하게 갈랐다. 2.3.11(마이그레이션 체크리스트)·2.3.13(체크포인트)·15.1.5(흔한 문제)·15.1.9(이행 습관)에도 실무 지침을 요약 반영했다. 2.3.14·2.3.15(주 반영처)·15.1.3·15.1.5·15.1.9·2.2.7에 반영, 하네스는 tools/gap_harness/gap139/(DataReaderHistory.hpp·StatefulReader.cpp 계측 사본, 분석 스크립트 1종, README.md). tools/book_check.py 23종 편집 전후 모두 0건.
119차 검증(2026-08-20 · CHG-164)140차 갭 점검. CHG-163의 제안대로 32회차(108~139차) executor 디스패치 조사를 마무리하고 한 번도 실행 검증되지 않은 다른 장으로 이동했다 — 1.7(서비스 — 요청·응답의 실제와 데드락)를 골랐다(그동안 쌓은 executor·콜백 그룹 지식을 재활용할 수 있고, "가장 비용이 큰 실수"라는 책 스스로의 표현대로 구체적이고 반증 가능한 주장이 많다). gap140_srv 패키지(실행 파일 9개)를 만들어 Humble·Jazzy 양쪽에서 재현했다. ① 1.7.3의 "증상" 서술을 정정한다 — 책의 "틀린 코드"(콜백 안에서 프리 함수 spin_until_future_complete 무한 대기)를 그대로 실행하면 "CPU 0%·로그 없음"의 조용한 정지가 아니라 처리되지 않은 예외로 즉시 크래시한다(Node '...' has already been added to an executor., 5회 재현 100%). 자유 함수가 아니라 이미 실행되는 executor 자신에 대한 멤버 함수 호출로 바꿔도 결과는 같다(다른 메시지 spin_until_future_complete() called while already spinning로 역시 즉시 크래시, 양쪽 배포판). 반면 "다른 얼굴들" ②(future.wait()/.get() 직접 호출)는 실제로 예외 없이 조용히 멈춘다는 것도 확인했다 — 책이 "증상"으로 묘사한 동작은 ①이 아니라 ②에 해당한다. ② 1.7.4의 해법 세 가지·main()의 안전성은 전부 실행 확인됐다(양쪽 배포판, 4개 실행 전부 exit 0). ③ 1.7.5의 wait_for_service() 무한 대기 함정도 확인됐다(외부 timeout으로 강제 종료할 때까지 절대 반환하지 않음). ④ 1.7.10 함정#14("클라이언트만 분리해도 데드락")를 세 배치(전부 기본 그룹/클라이언트 핸들만 분리/블로킹 콜백까지 분리)로 정밀 재현해 정확한 의미를 확정했다 — 블로킹을 실행하는 콜백 자신이 아니라 클라이언트 핸들만 별도 그룹에 두면(=책이 경고하는 그 구성) 여전히 데드락하고, 블로킹 콜백까지 함께 옮기면(해법②와 동일 구성) 성공한다. 1.7(주 반영처)에 반영, 하네스는 tools/gap_harness/gap140/(gap140_srv 패키지 소스 9종, README.md). tools/book_check.py 23종 편집 전후 모두 0건. 확인하지 못한 것: MultiThreadedExecutor + 멤버 함수 형태의 조합(책 원문이 언급하는 "그룹이 MutuallyExclusive면 여전히 멈춘다"에 해당할 조합), 1.7.6의 지연 응답 API 정확한 형태, rclpy 쪽 동일 패턴들.
120차 검증(2026-08-20 · CHG-165)141차 갭 점검. CHG-164의 제안대로 ① 1.7의 마지막 미확인 항목을 먼저 닫고, ② 3.5(RMW 계층)·3.6(미들웨어 벤더 비교) — 둘 다 실행 확인 0건이던 장 — 을 실행 검증했다. ① MultiThreadedExecutor(4스레드)에 클라이언트·타이머를 모두 기본 그룹에 두고, 이미 스핀 중인 그 executor 인스턴스 자신에 멤버 함수 형태의 spin_until_future_complete를 호출해도(=책 원문이 언급하는 조합) 조용한 정지가 아니라 똑같이 즉시 크래시했다(spin_until_future_complete() called while already spinning, Humble·Jazzy 재현) — "이미 스핀 중" 검사는 executor 타입과 무관하게 인스턴스 단위로 걸리며, 멤버 함수 형태는 어떤 조합으로도 조용히 멈추지 않는다는 것을 확정했다. 1.7.3의 "증상" 정정을 이걸로 완결한다. ② apt-cache search rmw-로 3.6.2가 나열한 다섯 벤더(Fast DDS·Cyclone DDS·Connext·GurumDDS·zenoh) 전부가 Jazzy·Humble apt 저장소에 있음을 확인했다 — GurumDDS의 "Jazzy 지원 상태" (확인 필요)를 이걸로 닫는다. --dry-run으로 GurumDDS·zenoh의 클린 설치 가능성을, Connext는 RTI Connext DDS 런타임(rti-connext-dds-6.0.1)이 ROS apt 저장소에서 직접 패키징돼 있음을 확인했다(라이선스 종류는 미확인). ③ rmw_cyclonedds_cpp를 실제로 설치해 3.5.3의 교체(env var로 재빌드 없이 전환)와 미설치 시 실패(dlopen 오류, 조용한 폴백 아님) 둘 다 재현했다. ④(핵심 발견) 3.5.4의 RMW 혼용 실험을 실제로 했다 — 같은 컨테이너에서 Fast DDS talker + Cyclone DDS 구독자는 실제로 통신됐다(토픽 데이터 정상 수신, 다만 Cyclone 쪽에 "type hash 파싱 실패" 경고가 그래프 전체에 반복). 반면 Fast DDS로 실행한 서비스에 대해 Cyclone DDS 클라이언트는 ros2 service list로 발견은 되지만 실제 호출은 응답 없이 멈췄다(Humble·Jazzy 둘 다) — 이 절의 "토픽은 되고 서비스는 위험하다" 표를 정확히 실측으로 확인했다. 1.7·3.5(주 반영처)·3.6(주 반영처)에 반영, 하네스는 tools/gap_harness/gap140/(mte_member_default.cpp 추가)·tools/gap_harness/gap141/(README.md). tools/book_check.py 23종 편집 전후 모두 0건. 확인하지 못한 것: Connext 라이선스 종류, GurumDDS·zenoh·Connext 실제 설치·실행(dry-run만 함), 벤더 ID 패킷 캡처, 3.6.3 REP 2000 티어, 3.5.5·3.5.6의 QoS·성능 재검증 항목들.
121차 검증(2026-08-20 · CHG-166)142차 갭 점검. 제13부 보안의 남은 세 장 — 13.1(위협 모델)·13.4(운영 보안)·13.5(보안의 비용), 전부 실행 확인 0건 — 을 검증했다(13.2·13.3의 기존 키스토어·enclave 인프라 재사용). ①(핵심 발견) 13.5.2가 스스로 "성능 수치를 제시하지 않는다"고 선언하며 제시한 "측정 방법"(시작 시간: 그래프 준비까지)을 그대로 따라 이 환경에서 실제로 측정했다 — demo_nodes_cpp talker+listener를 SROS2 보안 off/on으로 각각 반복 launch해 프로세스 시작부터 첫 수신까지의 시간을 비교했다. Humble은 off(평균 420 ms) 대 on(평균 1149 ms)이 거의 완전히 갈리는 두 분포였고(Welch t≈30), Jazzy도 방향은 같지만(off 687 ms 대 on 1020 ms) 분산이 커서 신호가 덜 명확했다(t≈4.5). demo_nodes_cpp의 1 Hz 발행 주기가 만드는 양자화 잡음이 두 조건에 동일하게 섞여 있어 "핸드셰이크 순수 비용"은 아니지만, 관측된 수백 ms 차이 자체는 실재하는 신호로 판단했다 — 단일 호스트·소형 메시지 조건에서의 값이며 일반화하지 않는다. ② 13.1.2("노드 이름 중복 방지 없음")를 재현했다 — 같은 이름의 노드 둘을 실행하면 ros2 node list가 경고를 내지만 어느 쪽도 차단되지 않고 둘 다 정상 동작했다(탐지는 있지만 강제는 없다). ③ 13.4.2의 ROS_AUTOMATIC_DISCOVERY_RANGE를 세 값으로 재현했다 — OFF는 같은 호스트 안에서도 디스커버리를 완전히 막았고, LOCALHOST·SUBNET은 정상 통신됐다 — "범위 제한"이라는 서술이 정확함을 확인했다(다만 OFF는 "축소"가 아니라 "전면 차단"에 가깝다). 13.1·13.4·13.5(모두 주 반영처)에 반영, 하네스는 tools/gap_harness/gap142/(sec_cost_trial.sh·README.md). tools/book_check.py 23종 편집 전후 모두 0건. 확인하지 못한 것: 13.5.2의 나머지 비용 축(암호화·서명 CPU, 메시지 크기, 제로 카피 제약), 핸드셰이크를 발행 잡음과 분리한 정밀 측정, 13.1.9의 명령 출처 확인 수단.
122차 검증(2026-08-20 · CHG-167)143차 갭 점검. ① CHG-166이 남긴 13.5.2의 나머지 비용 축(대용량 메시지 암호화 CPU)을 측정하고, ② 실행 확인 0건이던 0.6(첫 패키지 워크스루)을 리터럴로 재현했다. ① 자체 발행자·구독자로 1 MB 메시지를 10 Hz로 발행하며 CPU 시간(/usr/bin/time -v)·종단 지연을 보안 off/on 반복 측정했다 — 양쪽 배포판·양쪽 프로세스에서 일관되게 보안 켬이 CPU 시간을 2~3배, 지연을 1.7~1.9배로 늘렸다(Jazzy 발행자 0.057→0.150s·2.65배, Humble 발행자 0.050→0.160s·3.20배 등). 초기 실험에서 발행 직후 종료하면 대용량 프래그먼트 메시지가 유실되는 방법론적 함정을 발견해 고쳤다(구독 매칭 확인 후 발행 시작 + 종료 전 유예). ② 0.6의 명령·코드를 한 줄도 안 고치고 그대로 실행했다 — --license 생략 경고, 현대 CMake 타깃(rclcpp::rclcpp+${std_msgs_TARGETS}, Humble·Jazzy 둘 다 무경고 컴파일·정상 실행, 배포판 분기 없음), Python 패키지 전체, rclpy.shutdown() 이중 호출 시 예외(RuntimeError) 대 try_shutdown()의 안전함(배포판 차이 없음), 전체 빌드·실행·topic echo 사이클 — 전부 문서 그대로 재현됐다. 함정#1(resource/<패키지명> 누락)의 정확한 발동 조건도 좁혔다 — 소스에서부터 없으면 빌드 자체가 즉시 실패하고(error: can't copy ...), 표가 말하는 "빌드는 되는데 못 찾음"은 이미 빌드된 install/에서 마커만 나중에 사라졌을 때(Package '...' not found) 재현된다. 0.6·13.5(둘 다 주 반영처)에 반영, 하네스는 tools/gap_harness/gap143/(gap143_secpub 소스·big_sec_trial.sh·rj06_files/·README.md). tools/book_check.py 23종 편집 전후 모두 0건. 확인하지 못한 것: 서명 전용 조건 분리 측정, ament_target_dependencies 구식 방식과의 직접 비교, 0.6.5의 값 복사 대 unique_ptr 성능 차이 실측.
123차 검증(2026-08-20 · CHG-168)144차 갭 점검. 실행 확인 0건이던 두 장 — ① 0.1(ROS2 개론), ② 4.7(소스 빌드·vcstool) — 을 검증했다. ① std_msgs::Int32 시퀀스를 속도 제한 없이 발행해 부하를 가한 결과 역전(out-of-order)은 0건으로 "동일 발행자→동일 구독자 순서 유지" 주장이 확인됐다 — 다만 유실률이 71~87%로 높았고, 큐 깊이 1000·쉬지 않는 구독자 조건에서도 이 정도 유실이 나 "발행자 폭주도 구독자 지연과 같은 유실을 낸다"는 새 관찰을 얻었다. 종료된 노드 감지 지연은 "수 초 단위"라는 서술을 정정한다 — 컴파일된 바이너리를 직접 kill -9한 뒤 ros2 node list에서 사라질 때까지 Jazzy 19231·19224 ms, Humble 19800 ms로 3회 다 19~20초였다(자릿수가 다름). 과정에서 ros2 run이 자식 프로세스를 fork하는 래퍼라 그 PID를 강제 종료하면 실제 노드는 종료되지 않는다는 방법론적 함정도 확인했다. ② vcstool 명령 전체(import·status·branch·custom·pull·export·export --exact)를 로컬 git 저장소로 재현해 문서 그대로 동작함을 확인했고(--exact가 실제로 브랜치명을 40자 SHA-1로 바꿈), ros2.repos의 URL 패턴(.../ros2/ros2/<배포판>/ros2.repos)이 Jazzy·Humble 둘 다에서 예측 가능함을 확인했다(파일명 고정, 브랜치=배포판 이름). 0.1·4.7(둘 다 주 반영처)에 반영, 하네스는 tools/gap_harness/gap144/(gap144_order 소스·death_detect.sh·README.md). tools/book_check.py 23종 편집 전후 모두 0건. 확인하지 못한 것: 대용량 유실의 정확한 메커니즘, 종료된 노드 감지 지연의 소스 수준 근거, rosdep install --skip-keys 정확성과 전체 ROS 2 코어 소스 빌드.
124차 검증(2026-08-20 · CHG-169)145차 갭 점검. ① 144차가 남긴 0.1.8의 대용량 유실 메커니즘을 마저 규명하고, ② 실행 확인 0건이던 4.1(ament 구조)·4.2(ament_cmake 심층)·4.3(ament_python·엔트리포인트)을 검증했다. ① 큐 깊이(10~10000)만 바꿔 봤지만 수신율과 뚜렷한 상관이 없었고(42%~55%, 비단조), 같은 깊이 반복도 회차마다 크게 흔들렸다 — 반면 같은 최소 깊이(10)에서 발행 사이 200µs 페이싱만 주니 3회 전부 무손실(5000/5000)이었다. 유실의 실질 원인은 큐 깊이가 아니라 순수 발행 속도(burst rate)로 좁혀졌다. ②(핵심 발견) ament_package() 위치 함정("뒤에 오는 규칙이 조용히 무시된다")을 재현했더니 매크로마다 결과가 갈렸다(Humble·Jazzy 동일) — install(TARGETS...)은 뒤에 둬도 무시되지 않고 정상 동작했지만, ament_export_targets·ament_export_dependencies는 조용히 무시되는 대신 원인이 정확히 적힌 CMake 오류로 즉시 실패했다 — "조용히 무시된다"는 서술은 시험한 어느 경우에도 안 맞았다. 4.1.4의 DESTINATION bin 함정은 정확히 재현됐다. 4.3.5의 --symlink-install 표는 8행 중 7행이 확인됐지만 "새 Python 파일 추가는 재빌드 필요"는 반증됐다 — ament_python이 develop 모드(.egg-link)로 설치해 소스 디렉터리를 실시간 스캔하므로, 새 .py 파일이 재빌드 없이 즉시 임포트됐다(반면 data_files 기반 launch·config 새 파일은 여전히 재빌드가 필요했다). 0.1·4.1·4.2·4.3에 반영, 하네스는 tools/gap_harness/gap145/(gap145_order 소스·ament_tests/·README.md). tools/book_check.py 23종 편집 전후 모두 0건. 확인하지 못한 것: 라이터 리소스 한도의 정확한 병목 발행 속도, 4.2.4 내보내기 6단계 중 나머지(헤더 설치·BUILD_INTERFACE 누락)의 실패 증상, pluginlib·ament_cmake_auto·pytest 테스트.
125차 검증(2026-08-20 · CHG-170)146차 갭 점검. 여러 회차째 설정 비용을 이유로 미뤄 온 Nav2/MoveIt/시뮬레이션 계열 중 실행 확인 0건이던 8.3(위치추정 — AMCL 튜닝)에 착수했다. 8.1·8.2·8.4~8.6·9.1·9.2는 이미 이전 회차들이 설치 패키지의 파일·XML·이름을 실물과 대조해 실행 확인해 뒀지만, 이번 회차는 지도·합성 센서 데이터를 갖춘 노드를 실제로 lifecycle 기동해 런타임 동작을 관찰한 첫 사례다 — Gazebo·디스플레이 없이도 검증 가능한 AMCL 자체의 동작 규칙에 집중해, nav2_amcl·nav2_map_server·nav2_lifecycle_manager만으로 실제 AMCL을 기동했다(4m×4m 정사각형 방 지도를 직접 생성, 지도와 기하학적으로 일치하는 합성 /scan을 발행해 근거 있는 수렴 조건을 만들었다). ① /tf 원시 메시지를 직접 확인해 AMCL이 발행하는 것이 정확히 frame_id: map·child_frame_id: odom임을 확인했다(map → base_link가 아니다) — tf2_echo map base_link도 성공하지만 이는 map→odom→base_link 합성이었다. ② 정지 시 갱신 없음·이동 시 갱신 재개를 양방향 다 확인했다 — odom→base_link를 고정한 채 8초간 스캔 40개를 입력해도 /amcl_pose는 초기 1건 이후 0건이었고, update_min_d(0.25m)보다 큰 0.3m로 순간 이동을 흉내 내니 정확히 새 /amcl_pose 1건이 발행됐다(재정지 후 갱신도 재중단). ③ /reinitialize_global_localization 서비스가 정상 응답했고, AMCL 로그의 Initializing with uniform distribution → Global initialisation done!으로 실제 파티클 재분산까지 확인했다. 8.3(주 반영처)에 반영, 하네스는 tools/gap_harness/gap146/(make_map.py·fake_scan.py·nav2_params.yaml·README.md, 이 부에서 재사용 가능한 첫 라이브 런타임 테스트 인프라). tools/book_check.py 23종 편집 전후 모두 0건. 확인하지 못한 것: 알파 파라미터의 정량적 효과, z_hit+z_rand 정규화 여부, 실제 수렴 정확도, 이 부의 나머지 장들(8.4~8.8 등), Gazebo·MoveIt·RViz2(디스플레이·물리 시뮬레이션 필요).
126차 검증(2026-08-20 · CHG-171)147차 갭 점검. 125차의 정적 지도 인프라를 재사용해 8.4(코스트맵 2D)을 검증했다 — 이번 회차부터 원시 로그·데이터를 전부 파일로 저장했다(tools/gap_harness/gap147/logs·data). ①(핵심 발견) 팽창 비용 공식 $c(d)=(253-1)e^{-f(d-r_i)}$을 실측과 대조했다 — 5m×5m 지도 한가운데 점 장애물 하나만 있는 지도로 nav2_costmap_2d(StaticLayer+InflationLayer)를 실제 기동하고 /global_costmap/costmap_raw(원시 0~255 배열) 전체를 JSON 덤프해 거리별 값을 읽었다 — 0.05~0.55m 구간에서 실측·공식이 1~5(252 중 약 2%)로 거의 일치했지만, "비용이 0이 되는 거리"는 inflation_radius(0.55m)가 아니라 격자 한 칸 바깥인 0.60m였다(이산화 반올림으로 추정, 소스 미확정). ② cost_scaling_factor를 3.0→10.0으로 바꾸니 감소가 뚜렷하게 급해짐을 정량 확인했다(0.20m 지점 164→61). 다만 이 비교는 재기동으로 얻은 것 — 활성 상태에서 ros2 param set+clear_entirely_costmap을 호출해도 발행되는 코스트맵은 안 바뀌었고, 재기동 후에만 반영됐다(런타임 재구성 불가로 관찰됨). ③ plugins 순서를 뒤집어(InflationLayer를 먼저) 기동하니 장애물 셀 자체는 정상(254)이지만 주변 전체가 비용 0 — 팽창이 전혀 안 됨을 원시 데이터로 확인했다. 8.4(주 반영처)에 반영, 하네스는 tools/gap_harness/gap147/(make_map_dot.py·dump_costmap.py·원시 로그·JSON 덤프·README.md). tools/book_check.py 23종 편집 전후 모두 0건. 확인하지 못한 것: 이산화 경계의 소스 수준 근거, 런타임 재구성 불가가 다른 파라미터·레이어에도 적용되는지, 8.4.5·8.4.7·8.4.8(센서 통합·유령 장애물·풋프린트).
127차 검증(2026-08-20 · CHG-172)148차 갭 점검. 126차의 코스트맵 인프라에 합성 LaserScan을 붙여 8.4.5(센서 통합)·8.4.7(유령 장애물)을 검증했다 — 하나는 확실히 확인했고, 하나는 정직하게 미해결로 남긴다. ① 방법론 함정(테스트 설정 실수로 360빔 전부가 obstacle_max_range보다 짧은 거리를 보고)을 밟는 과정에서 obstacle_max_range가 표시를 정확히 게이팅한다는 것을 뜻밖에 정밀하게 확인했다 — 반경 2.0m 설정에 실측 평균 1.996m(270개 셀, 1.971~2.026m)로 거의 정확히 일치하는 완전한 원형 마킹이 나왔다. ② 지우기(raytrace 클리어링)는 이 절의 예시 값 그대로(obstacle_max_range: 2.5·raytrace_max_range: 3.0) 재현을 시도했으나 실패했다 — 정면 1빔이 1.0m 장애물을 보고하다 2.8m(표시 안 됨+지우기는 돼야 할 구간)로 전환된 뒤 13초 넘게 지나도 마킹된 셀이 지워지지 않았다. 타이밍/경쟁 오류·costmap_raw 토픽 전용 발행 지연·raytrace_max_range 초과·observation_persistence 캐싱 네 가설을 각각 배제했지만 원인을 찾지 못해 미해결로 명시했다. 8.4(주 반영처)에 반영, 하네스는 tools/gap_harness/gap148/(controllable_scan.py·원시 로그·JSON 덤프·README.md). tools/book_check.py 23종 편집 전후 모두 0건. 확인하지 못한 것: 지우기 미재현의 정확한 원인, marking:true·clearing:false 및 raytrace_max_range < obstacle_max_range 대조 실험(기준이 재현 안 돼 실행하지 않음), inf_is_valid, 8.4.8(풋프린트).
128차 검증(2026-08-20 · CHG-173)149차 갭 점검. 127차가 미해결로 남긴 지우기(raytrace 클리어링) 미재현을 navigation2·laser_geometry 소스 계측으로 완전히 규명했다 — 코디네이터 제안대로 소스 직독 후, RCLCPP_WARN 로그를 심어 재빌드한 nav2_costmap_2d를 오버레이로 적용해 재현했다. ① range_max와 정확히 같은 판독은 laser_geometry::LaserProjection::projectLaser_()의 강한 부등호(range < range_cutoff) 필터에 걸려 포인트클라우드에서 완전히 빠진다 — 광선 자체가 안 나간다(클리어링 클라우드 포인트 수 0개를 로그로 확인). ② 127차가 실제로 겪은 것은 이것과 다른 원인이었다 — 정면(0°) 근처 빔의 브레젠험 이산화 결함. 유한 지우기값(2.8m)에서는 광선이 매 주기 올바른 방향으로 나가고 마스터 코스트맵 합성(updateWithMax)도 정상 작동함을 확인했지만, ObstacleLayer 자신의 내부 버퍼(costmap_)에서 유령 셀이 254로 고정된 채 안 바뀌었다 — 표시(짧은 거리, 직접 반올림)와 지우기(긴 거리, 브레젠험 이산화)의 두 경로가 정면에 가까운 얕은 기울기에서는 같은 셀에서 만난다는 보장이 없다. 45°(축에서 벗어난 각도) 대조군에서는 레이어 버퍼·마스터 모두 정상적으로 지워졌다. ③ 별개로, 127차가 쓴 재사용 도구(dump_costmap_local.py)에 잠복 결함을 찾았다 — TRANSIENT_LOCAL 토픽을 첫 메시지 한 개만 받고 종료하는 방식이라, 그 첫 메시지가 구독 이전의 캐시된 값일 수 있다(45° 대조군 연속 구독에서 [254,0,0,0,0,0] — 첫 샘플만 오래된 값). ②의 결론 자체는 다중 샘플(monitor_costmap_cell.py)과 버퍼 직접 로그로 독립 확인해 이 결함의 영향을 받지 않는다. 8.4(주 반영처)에 반영, 하네스는 tools/gap_harness/gap149/(obstacle_layer.cpp·costmap_layer.cpp·laser_geometry.cpp 소스 사본, 계측 로그, rangemax_scan.py·angled_scan.py·monitor_costmap_cell.py, README.md). tools/book_check.py 23종 편집 전후 모두 0건. 확인하지 못한 것: 브레젠험 이산화 결함이 정확히 어느 각도 범위에서 나타나는지의 경계값, 실제 다중 빔·연속 스캔에서 이 결함이 자기 교정되는 정도(별도 검증 필요), dump_costmap_local.py 결함이 다른 과거 회차 결론에 실제로 영향을 줬는지.
129차 검증(2026-08-20 · CHG-174)150차 갭 점검(밀리스톤 회차). 코디네이터가 제시한 세 갈래(① 128차 각도 경계값 마저 규명, ② 8.7 복구·상태관리, ③ 4.8~4.9) 중 ①을 빠르게 마무리하고 ②를 본 작업으로 삼았다. ① 0~45° 사이 16개 각도를 촘촘히 스윕(1.0m→2.8m 지우기, 각도마다 격리된 코스트맵 재기동)한 결과, 고착 각도가 {0°, 17°, 20°, 30°, 40°}(5/16=31%)로 산발적이었다 — 128차가 45° 하나만 대조군으로 써서 준 "정면에 가까울수록 위험" 인상은 부정확했다(→ 128차 정정, 8.4). ②(핵심) 129번째 회차까지 실행 검증이 전무했던 8.7(복구·상태관리·안전)에 nav2_collision_monitor·nav2_velocity_smoother를 실제 lifecycle 기동해 처음으로 실행 확인을 채웠다 — PolygonStop(0.2m 장애물)이 cmd_vel_out을 정확히 0으로, PolygonSlow(0.6m 장애물)가 slowdown_ratio(0.3) 그대로(0.5→0.15) 감속시키는 것을 하나의 26초 연속 시나리오로 확인했고, 스캔 노드를 강제 종료해 source_timeout이 실제로 정지를 유발함도 확인했다(설정 2.0초, 실측 약 4초 후). 소스(collision_monitor_node.cpp)를 읽어 stop_pub_timeout 경과 후 cmd_vel_out 재발행 자체가 멈추는 메커니즘을 확정해 8.7.4의 "이중 워치독" 조언의 근거를 구체화했다. velocity_smoother는 가속 램프(0.25초, max_accel 이론값과 근사)와, 입력이 끊긴 뒤 velocity_timeout(1.0초)만큼 마지막 값을 유지하다 max_decel 램프로 매끈하게 0까지 내려가는 것을 실측했다(즉시 절단이 아님 — 본문에 없던 세부). 8.7(주 반영처)·8.4(정정)에 반영, 하네스는 tools/gap_harness/gap150/(angle_sweep.sh·angle_sweep2.sh·dump_costmap_multi.py·cm_scenario.sh·cm_scan_pub.py·cm_cmdvel_pub.py·cm_monitor.py·vs_cmdvel_pub.py·vs_monitor.py·logs/·data/). tools/book_check.py 23종 편집 전후 모두 0건(단, 실행 확인 장 수 102→103 갱신 필요했음 — 8.7이 처음으로 실행 확인 장이 됨). 확인하지 못한 것: 각도 고착 패턴이 표시·지우기 거리 조합을 바꿔도 유지되는지, source_timeout 실측값이 설정값의 2배 가까이 걸린 정확한 이유, collision_monitor의 approach·limit·velocity_polygon 세 동작 유형과 코스트맵 필터(KeepoutFilter·SpeedFilter)는 다루지 않음, 8.7의 나머지 절(복구 동작·상태 관리·안전 계층·실패 모드 카탈로그·운영 관행)은 여전히 미검증.
130차 검증(2026-08-20 · CHG-175)151차 갭 점검. 코디네이터가 여러 회차째 미룬 4.8(크로스 컴파일·arm64)·4.9(릴리스 — bloom·rosdistro·빌드팜)를 처음으로 실행 검증했다 — 129번째 회차까지 두 장 모두 실행 확인 0건이었다. ① 4.9(bloom) — GitHub 인증·rosdistro PR 없이 되는 부분을 끝까지 실행했다. 이 절의 예시 이름 그대로 my_robot_driver 패키지를 만들어 bloom-generate rosdebian을 실행하니 패키지 이름 변환(밑줄→하이픈)과 rosdep 의존 변환(rclcpp→ros-jazzy-rclcpp 등)이 본문 서술과 정확히 일치했다. fakeroot debian/rules binary로 실제 .deb(10.6KB)를 빌드하고 apt-get install로 설치하니 ros2 pkg list에 패키지가 나타났고 ros2 run my_robot_driver driver_node가 실제로 기동해 로그를 냈다 — 패키징 생성부터 설치·실행까지 전 과정이 실측으로 확인됐다. ② 4.8(크로스 컴파일) — 이 장이 권장하는 ②·④(QEMU 에뮬레이션·Docker buildx)를 실제 arm64 빌드로 끝까지 확인했다. tonistiigi/binfmt --install arm64와 docker buildx가 이 샌드박스 환경에서도 정상 동작했고, 이 절의 Dockerfile 구조 그대로 rclcpp 노드를 --platform linux/arm64로 빌드하니 colcon build가 QEMU 위에서 58.7초간 실제로 컴파일했고, 결과 바이너리는 ELF 헤더 직접 확인(file 명령이 없는 최소 이미지라 오프셋 18~19바이트 e_machine을 읽음)으로 EM_AARCH64(실제 arm64)임을 확인했으며 에뮬레이션에서 실제로 기동해 로그를 냈다. 뜻밖의 반례도 하나 찾았다 — 이 절의 "--push 없이 다중 플랫폼 빌드는 --load가 안 된다"는 함정 서술이, containerd 이미지 스토어가 켜진 이 환경(Docker 29.5.2)에서는 재현되지 않았다 — --platform linux/amd64,linux/arm64 --load가 성공해 하나의 로컬 이미지 태그가 두 아키텍처를 정확히 구분해 서빙했다(버전·설정에 따라 갈리는 것으로 보이며, 책의 서술이 완전히 틀린 것은 아니다). ③(크로스 툴체인)은 부분 진행에 그쳤다 — 호스트 툴체인 설치는 쉬웠지만 arm64 sysroot 구성에서 실제 장애물 두 가지(archive.ubuntu.com엔 arm64가 없음 — ports.ubuntu.com 필요; 여러 ROS 2 Python 보조 패키지가 Architecture: all·Multi-Arch: no라 크로스 아키텍처 의존 해석에 실패)에 부딪혀, 이 장이 지목한 핵심 문제(rosidl 코드 생성이 호스트/타깃 어디서 실행되는가)까지는 도달하지 못하고 정직하게 미해결로 남겼다. 4.9(주 반영처)·4.8(주 반영처, 정정 1건 포함)에 반영, 하네스는 tools/gap_harness/gap151/(buildx_test_pkg/(Dockerfile·소스)·logs/·data/). tools/book_check.py는 편집 도중 서두 모순 2건·장 수 불일치 2건(103→105) 발생 후 모두 해소, 최종 23종 0건. 확인하지 못한 것: 크로스 툴체인에서 rosidl 코드 생성이 실제로 어떻게 실패하는지, --load 다중 플랫폼 반례가 classic(비-containerd) 이미지 스토어에서도 책 서술대로 실패하는지, ldd·colcon test(4.8.7의 ②·④), rosdistro PR·빌드팜(4.9.5·4.9.6, 실제 GitHub 권한 필요).
131차 검증(2026-08-20 · CHG-176)152차 갭 점검. 130차가 미해결로 남긴 4.8.3 ③(크로스 툴체인)의 rosidl 코드 생성 문제를 끝까지 조사했다 — "①이 핵심 문제다"는 이 장의 중심 주장을 상당 부분 재구성하는 결과가 나왔다. ② 우회는 단순했다 — apt-get install(조인트 설치 가능성 검증) 대신 apt-get download(이름으로만 받아오기, 검증 안 함)를 쓰니 130차가 막혔던 Multi-Arch: no 문제가 별도 조치 없이 사라졌다. apt-cache depends --recurse로 추출한 필요 패키지 289개를 전부 개별 다운로드해 100% 성공(실패 0건)했고, dpkg-deb -x로 /opt/sysroot-arm64(503MB)에 풀었다. 이 절의 예시 그대로 CMake 툴체인 파일을 쓰고, .msg 정의가 있는 커스텀 메시지 패키지로 colcon build --cmake-args -DCMAKE_TOOLCHAIN_FILE=...을 실행하니 — 첫 시도에 그대로 성공했다(Finished my_msgs [3.61s], 별도의 "호스트용 도구 따로 빌드" 조치 없음). 원인을 소스로 확인했다 — rosidl_generator_c 등 코드 생성기의 실제 진입점이 컴파일된 실행 파일이 아니라 파이썬 스크립트였다(file 확인). 이 절 예시의 CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER가 CMake로 하여금 호스트에 이미 있는 python3와 호스트 ROS 2 설치의 생성기 스크립트를 그대로 쓰게 만들어, "타깃용으로 빌드된 도구는 호스트에서 실행할 수 없다"는 문제 자체가 애초에 발생하지 않았다. 생성된 라이브러리 8종은 ELF 헤더로 실제 arm64(EM_AARCH64)임을 확인했다. 정리: 실제 마찰은 코드 생성 실행이 아니라 sysroot 조립(①·②, 130차가 이미 확인)이었다 — "④가 대부분의 팀에 정답"이라는 이 장의 결론 자체는 그대로 유지되지만, "왜 ③이 어려운가"의 정확한 지점이 이 장의 설명과는 다르게 밝혀졌다. 4.8(주 반영처, 130차의 미해결·151차 정정과 연결 3곳)에 반영, 하네스는 tools/gap_harness/gap152/(my_msgs_test_pkg/·toolchain-arm64.cmake·logs/·data/, 다운로드 성공 289개 목록 포함). tools/book_check.py 23종 편집 전후 모두 0건. 확인하지 못한 것: 이 결과가 커스텀 메시지가 아닌 더 복잡한 패키지(예: 액션·서비스 다수 포함, 또는 rosidl_typesupport_connextdds 등 이번에 안 다룬 RMW)에서도 유지되는지, sysroot 289개 다운로드가 매번 이렇게 문제없이 성공하는지(의존 목록이 바뀌면 재현성 다를 수 있음), 4.8.6(배포 전략)·4.8.8(함정 카탈로그)은 다루지 않음.
132차 검증(2026-08-20 · CHG-177)153차 갭 점검. 지금까지 패키지 존재 확인 1건뿐이던 4.10(CI·품질 게이트)을 처음으로 실행 검증했다. ① 이 장에서 가장 강조된 주장(★ "colcon test-result --verbose가 없으면 실패를 놓친다")을 두 시나리오로 각각 재현했다 — 일부러 실패하는 gtest, 그리고 라이선스 헤더 누락·스타일 위반이 있는 소스 각각에 대해 colcon build → colcon test(맨 것) → colcon test-result --verbose 순서로 실행하니, 두 경우 모두 colcon test 자체의 종료 코드는 0(성공)이었지만 colcon test-result --verbose는 정확히 1(실패)을 반환하며 상세 실패 내역을 출력했다. 린트 시나리오에서는 ament_copyright("could not find copyright notice")·ament_cpplint(저작권·공백 위반 2건)·ament_uncrustify(스타일 diff)가 각각 실제로 걸리는 것도 함께 확인했다. ② 커버리지 측정(4.10.13.1)이 남긴 (확인 필요)도 끝까지 실행해 닫았다 — --coverage -O0 -g로 빌드하고 colcon lcov-result를 그대로 실행하니 lcov 2.0이 GCC 13 계측 출력에 대해 "mismatched end line" 오류로 실패했다(이 절이 언급 안 한 실제 버전 호환성 문제) — --lcov-args --ignore-errors mismatch로 우회하니 정상 완료됐고, 생성된 coverage.info에서 테스트한 함수(FNDA:1)와 테스트 안 한 함수(FNDA:0)가 정확히 구분되는 것을 직접 확인했다. 4.10(주 반영처)에 반영, 하네스는 tools/gap_harness/gap153/(test_packages/(gtest·커버리지·린트 세 패키지)·logs/·data/coverage.info). tools/book_check.py는 편집 도중 서두 모순 1건(정정 후 실행 확인 문구 누락) 발생 후 해소, 최종 23종 0건. 확인하지 못한 것: GitHub Actions 워크플로 자체(실제 GitHub 러너 필요), ROS_DOMAIN_ID·ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST(4.10.5)의 CI 격리 효과, 새니타이저(4.10.6)·빌드 매트릭스(4.10.7)·캐시 전략(4.10.8)은 이번 회차도 다루지 못함.
133차 검증(2026-08-20 · CHG-178)154차 갭 점검. 132차가 남긴 두 갈래(ROS_DOMAIN_ID 격리·새니타이저)를 모두 실측했다. ① 새니타이저 세 종에 정확히 맞는 실제 버그(힙 use-after-free·데이터 경쟁·부호있는 정수 오버플로)를 심어 matrix.sanitizer 예시 플래그 그대로 빌드·테스트했다. ASAN은 책 설명과 정확히 일치(할당·해제·읽기 스택 트레이스 전부 포함한 상세 보고 후 abort). TSAN은 처음엔 데이터 경쟁이 아니라 FATAL: ThreadSanitizer: unexpected memory mapping으로 전혀 실행조차 안 됐다 — 원인은 이 컨테이너(Ubuntu 24.04 커널)의 ASLR 엔트로피(vm.mmap_rnd_bits=32)가 TSAN이 요구하는 고정 섀도 메모리 레이아웃과 안 맞아서였고, sysctl vm.mmap_rnd_bits=28(컨테이너가 --privileged라 가능)로 낮추니 정확히 데이터 경쟁을 검출했다(스레드별 읽기·쓰기 스택 트레이스와 경쟁 변수 이름까지). UBSAN은 실제 정정을 냈다 — 이 절의 예시 그대로(-fno-sanitize-recover 없이)는 부호있는 정수 오버플로를 진단만 하고(runtime error: signed integer overflow ...) 프로세스는 계속 실행돼 [ OK ]·[ PASSED ]로 끝나며 종료 코드 0 — colcon test-result --verbose가 이 경우 실패를 전혀 검출하지 못한다. -fno-sanitize-recover=undefined를 추가하니 진단 직후 종료 코드 1로 즉시 중단되는 것을 대조 확인했다 — 4.10.6의 매트릭스 예시는 UBSAN에 한해 이 플래그가 빠지면 CI 게이트로 작동하지 않는다. ② ROS_DOMAIN_ID 격리도 직접 확인했다 — 같은 토픽 이름으로 퍼블리셔 둘을 도메인 20·21에 동시에 실행하고 도메인 20 구독자로 관찰하니, 수신한 31개 메시지 전부가 도메인 20 발신자였고 도메인 21 메시지는 하나도 안 섞였다. 4.10(주 반영처, 함정 카탈로그 2행 신규)에 반영, 하네스는 tools/gap_harness/gap154/(test_packages/san_test_pkg/·iso_talker.py·iso_listener.py·logs/·data/). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 확인하지 못한 것: GitHub Actions 워크플로 자체, ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST는 도메인 격리와 별도로 대조하지 않음, 빌드 매트릭스(4.10.7)·캐시 전략(4.10.8)·colcon coveragepy-result는 여전히 미검증.
134차 검증(2026-08-21 · CHG-179)155차 갭 점검. 133차의 세 갈래 제안 중 ① 8.7의 코스트맵 필터(KeepoutFilter·SpeedFilter)를 149~154차 여섯 차례 미룬 끝에 처음 실행 검증했다. filter_mask_server·costmap_filter_info_server·global_costmap을 실제 lifecycle로 기동해 KeepoutFilter는 20×20셀(400셀) 마스크 구역이 코스트맵에서 정확히 LETHAL_OBSTACLE로 표시되는 것을 오차 0으로 확인했다. 그 과정에서 네임스페이스 붙은 코스트맵 노드에서 필터의 상대 토픽 이름이 다른 곳으로 resolve되는 함정(절대 경로로 해결)을 찾았다. SpeedFilter는 이 절 예제 값(base:100.0, multiplier:-1.0) 그대로가 실제로는 절대 발행되지 않음을 실측으로 드러냈다 — NO_SPEED_LIMIT sentinel(0.0)과 계산값(100×(-1.0)+100=0.0)이 정확히 충돌해 482회의 process() 호출 모두 발행 0건(speed_limit_events_final.json=[])이었고, multiplier:-0.5로 바꾸자 SpeedLimit{percentage:true, speed_limit:50.0}이 계산대로 정확히 1회 발행됐다. 가장 깊은 발견은 소스 직접 계측으로 나왔다 — LayeredCostmap::updateMap()은 플러그인·필터의 갱신 범위가 비어 있으면(xn<x0) 전체를 건너뛰는데, CostmapFilter::updateBounds()는 범위를 전혀 넓히지 않으므로 ObstacleLayer처럼 매 주기 범위를 넓히는 다른 레이어가 없으면 필터의 process()는 지도 최초 로드 이후 다시는 호출되지 않는다(plugins:["static_layer"]만으로 71주기 연속 x0=99 xn=1(범위 역전) 재현, obstacle_layer 추가 후 정상화 확인). 8.7(주 반영처, 8.7.5)에 반영, 하네스는 tools/gap_harness/gap155/(data/·logs/·src_instrumented/layered_costmap.instrumented.cpp·speed_filter.instrumented.cpp). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 확인하지 못한 것: BinaryFilter, 두 필터 동시 스택, RViz 시각 확인, 실제 센서 데이터(ObstacleLayer는 범위 확장 목적으로만 씀, 관측 소스는 발행 없는 더미 토픽).
135차 검증(2026-08-21 · CHG-180)156차 갭 점검. 132차부터 세 차례 이월된 4.10의 나머지를 처리했다. ① ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST가 다른 호스트의 발견을 실제로 막는지, 서로 다른 네트워크 네임스페이스를 가진 컨테이너 둘(gap156a·gap156b, 같은 브리지 네트워크의 서로 다른 IP)로 세 조건을 비교했다. 기본값(미설정)에서는 다른 컨테이너의 퍼블리셔를 정상 수신(18/18개)했지만, 퍼블리셔에 LOCALHOST를 설정하니 8초 동안 단 하나도 수신하지 못했다(0/0). 같은 설정으로 퍼블리셔·구독자를 같은 컨테이너에 두는 대조군에서는 정상 수신(29개)해, 이 설정이 발견을 완전히 무력화하는 것이 아니라 정확히 "다른 호스트"만 막는다는 것을 확인했다 — 133차의 ROS_DOMAIN_ID 격리(같은 호스트 내)와 합쳐 CI 러너 격리에 필요한 두 축이 모두 실측으로 닫혔다. ② ccache 적중률과 ③ 빌드 디렉터리 캐시의 함정을 각각 재현했다. ccache는 냉간 빌드 0% 적중(real 0m3.357s)에서 build/install을 지우고 캐시만 유지한 채 다시 빌드하니 100% 적중(real 0m1.209s, 2.8배 단축)으로 바뀌어 ccache -s 서술을 확인했다. 빌드 디렉터리 캐시 함정은 "동작하는 유령 실행 파일" 형태로 재현됐다 — 소스·CMakeLists.txt에서 완전히 제거한 노드(node3)가, build/install을 지우지 않고 재빌드하자 설치 디렉터리에 그대로 남았을 뿐 아니라 정상적으로 기동해 로그를 출력하는 완전히 작동하는 노드였다(md5로 실제 옛 산출물임을 확인). 4.10(주 반영처, 4.10.5·4.10.8)에 반영, 하네스는 tools/gap_harness/gap156/(scripts/·logs/·data/). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 확인하지 못한 것: 4.10.7(빌드 매트릭스 — arm64 비용·rolling 경고 게이트 등, 실제 GitHub Actions 러너 필요), 캐시 전략 ①(의존 설치 이미지 — 운영·레지스트리 성격이라 단발 컨테이너로 측정 불가), sccache.
136차 검증(2026-08-21 · CHG-181)157차 갭 점검. 4.10·8.7이 최근 여러 차례 다뤄져 두꺼워진 데 비해, 완전히 새로운 장을 열었다 — 7.6(IMU·오도메트리·robot_localization)는 133개 장 중 실행 확인이 전무하던 28개 장 중 하나였다. robot_localization 3.8.3을 Jazzy apt로 설치해 ekf_node를 직접 기동했다. ① 이 장에서 가장 강조된 원칙("위치가 아니라 속도를 쓰라")을 회귀 분석으로 정량 확인했다 — 참운동은 등속(vx=0.5m/s)인데 휠 오도메트리의 pose.position에만 인위적 드리프트(+0.2m/s 상당)를 섞어, 위치를 쓰는 융합 구성과 속도를 쓰는 구성에 각각 입력했다. 위치 구성의 기울기는 0.6994(기댓값 0.7과 0.09% 오차, 드리프트 100% 유입), 속도 구성의 기울기는 0.5000(기댓값과 소수점 넷째 자리까지 정확히 일치, 드리프트 0% 유입) — 이 장의 핵심 견해가 정확한 숫자로 확인됐다. ② 이중 인스턴스 패턴("ekf_map은 map→odom만 발행하고 map→base_link는 발행하지 않는다")을 /tf 원시 메시지로 확인했다 — ekf_odom·ekf_map 두 인스턴스를 동시 기동해 8초간 관측한 (frame_id, child_frame_id) 쌍은 정확히 map→odom(161회)·odom→base_link(160회) 둘뿐이었고 map→base_link는 0회였다. ③ 함정 카탈로그의 "공분산 0 → 그 센서만 신뢰"도 재현했다 — 정상 오도메트리와 공분산 전부 0인 "버그 있는" 소스(잘못된 상수값 50.0)를 동시에 넣으니 융합 결과가 47 근처(참값 0~5와 무관)로 완전히 지배당했다(다만 즉시 정확히 50.0으로 스냅되지는 않아, 구현이 정확히 0을 무한 신뢰로 처리하지는 않을 가능성을 정직하게 남겼다). 7.6(주 반영처, 7.6.3·7.6.4·7.6.5, 서두 정직 고지에 정정 포인터)에 반영, 하네스는 tools/gap_harness/gap157/(scripts/·logs/·data/(회귀분석 포함)). tools/book_check.py는 편집 도중 서두 모순 1건(정정 문구에 "실행 확인" 리터럴 누락) 발생 후 해소, 최종 23종 0건. 실행 확인 장 수 105→106(7.6가 처음으로 실행 확인 장이 됨), 미확인 표시는 851→852건(순증 1, 공분산-0의 정확한 내부 메커니즘을 새 미확인으로 남김). pitfall_scan.py 69쌍 그대로. 확인하지 못한 것: navsat_transform_node 실제 기동(합성 절대 위치 퍼블리셔로만 대신함), IMU 가속도계 이중 적분 드리프트·자력계 교란 등 센서별 특성(7.6.2) 개별 실측, differential·relative 파라미터 조합의 정확한 의미론, UKF(ukf_node는 시험 안 함).
137차 검증(2026-08-21 · CHG-182)158차 갭 점검. 136차가 합성 스탠드인으로만 대신했던 navsat_transform_node를 실제로 기동해 오류 하나와 문서화되지 않은 함정 하나를 찾고, 셋을 정량 확인했다. 오류(문서 정정) — 이 절의 입출력 표가 IMU 구독 토픽을 /imu/data라고 적었지만 소스(navsat_transform.cpp)는 상대 이름 "imu"(→ /imu)를 구독한다. 표대로 /imu/data에 발행하면 에러 없이 /odometry/gps가 10초 내내 0건 발행됐다(topic hz·echo --once로 직접 확인), topic list로 /imu(구독자만)·/imu/data(발행자만) 불일치를 봤다. 문서화되지 않은 함정 — base_link → gps_frame_id tf가 없으면 결과가 조용히 원점(0,0,0)에 고정된다. 소스에 GAP158-DEBUG 계측을 넣어 추적하니 원시 UTM 좌표는 정상적으로 자라는데 최종 결과는 계속 0이었다 — getRobotOriginWorldPose()가 tf 조회 실패 시 setIdentity()로 조용히 반환하는 코드가 원인이었고, 유일한 단서는 5초 간격 ERROR_THROTTLE 로그뿐이었다. static_transform_publisher로 항등 변환을 발행해야 실제 값이 나온다. 좌표 변환 정확도(정북 1.0m/s 참운동에 대해 북 성분 기울기 0.9971, 동 성분 0.00000003), 자기 편각(0.1745rad=10° 설정 시 역산 회전각이 정확히 10.00°로 일치 — "수 도~수십 도 틀린다"는 서술을 정량 확인), delay(3.0초 설정 시 노드 로그가 정확히 3.000초 간격으로 "Delaying"→"Delay elapsed" 기록) 세 가지도 확인했다. 7.6(주 반영처, 7.6.6)에 반영, 하네스는 tools/gap_harness/gap158/(scripts/·logs/·data/(회귀분석 포함)·src_instrumented/navsat_transform.instrumented.cpp). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수·미확인 표시 건수 모두 그대로였다(7.6는 이미 실행 확인 장, 새 미확인 순증 없음). 확인하지 못한 것: differential·relative 의미론(7.6.4 자신의 미확인, 두 차례 이월), ukf_node, broadcast_utm_transform·use_odometry_yaw·명시적 datum 경로, 실제 자력계 교란(하드웨어 없음).
138차 검증(2026-08-21 · CHG-183)159차 갭 점검. 132차부터 세 차례 미뤄 온 differential·relative의 정확한 의미론을 소스(ros_filter.cpp)로 규명하고 정량 확인해 7.6를 사실상 마무리했다. differential은 연속 측정의 차이를 시간차로 나눠 위치 측정을 속도 측정으로 바꿔치기하고, relative는 첫 측정값을 정확히 한 번만 저장해 이후 모든 측정에서 빼는 1회성 재조정임을 소스로 확인했다 — 둘 다 켜면 파라미터 로딩 시점에 RCLCPP_ERROR_STREAM으로 경고하고 differential이 강제로 우선한다(소스+실측 둘 다 확인, 동시 켬 구성의 기울기 0.5000이 differential만 켠 구성과 사실상 동일). 핵심 발견 — 상수 오프셋(+5.0)은 relative·differential 둘 다 완전히 제거하지만(시작값이 6.44→0.94/0.89로 낮아짐), 선형으로 자라는 드리프트(+0.2×t)는 둘 다 막지 못한다(기본 0.6991, relative 0.6993, differential 0.7000 — 사실상 동일하게 드리프트가 그대로 샌다). "두 파라미터는 드리프트 제거기가 아니라 관측 시작 시점의 고정 오프셋 제거기"라는 정확한 의미론이 확정됐다. 부수적으로 ukf_node도 시험했는데, 136차가 ekf_node에서 명확히 확인한 속도 전용 시나리오를 그대로 입력하자 vx는 정확했지만 적분된 위치의 기울기가 0.3886으로 지속적으로 처지는 현상을 발견했고, 원인은 로그의 "Critical Error, NaNs were detected..."가 프로세스 생존 내내(약 56초) 1257회(→ 160차 정정: 저장 로그 기준 1333회) 출력되고 있었기 때문이었다 — ekf_node에서 안정적인 최소 설정이 ukf_node에서는 지속적으로 NaN 감지·복구 경로를 타는 것으로, 정확한 내부 원인(시그마 포인트 공분산 콜레스키 분해 실패 추정)은 소스까지 확인하지 못했다. 7.6(주 반영처, 7.6.4)에 반영, 하네스는 tools/gap_harness/gap159/(scripts/·logs/·data/(회귀분석 포함)). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 106개 그대로, 미확인 표시는 852→853건(순증 1, UKF NaN 원인을 새 미확인으로 남김). 확인하지 못한 것: UKF NaN의 정확한 내부 메커니즘, imu0·pose0 소스에서의 differential/relative(코드 경로는 동일하나 odom0만 실측), navsat_transform_node의 broadcast_utm_transform·명시적 datum 경로(158차부터 이월).
139차 검증(2026-08-21 · CHG-184)160차 갭 점검. 시작 전에 159차의 "1257회" 오기재를 정정했다(코디네이터가 발견 — 저장된 로그 파일 기준 실제로는 1333회, 카운트 시점과 kill 시점 사이에도 프로세스가 계속 실패하며 로그가 늘어난 것이 원인). 본 조사는 처음엔 159차의 "NaN이 지연을 설명한다"는 인과관계를 뒤집는 것처럼 보였다가, 자체 재검토로 더 정확한 그림을 찾았다. 신선한 컨테이너에서 짧은(10~17초) 관찰 창으로는 다섯 번 모두 NaN이 0건이어서 한때 "재현 안 됨"으로 결론지었으나, 그중 둘을 정리하지 않고 살려 두었더니 최종 로그에 NaN이 대량으로 기록돼 있었다 — 타임스탬프를 프로세스 시작 시각과 맞춰 보니 NaN은 사라진 것이 아니라 기동 후 약 30초(28.8초·33.6초) 뒤에야 나타나 그 뒤로 계속됐다. 반면 위치 적분 지연은 t≈1초부터 이미 시작돼 있어(국소 기울기 0.4523→0.3324로 단조 감소, vx는 계속 정확) NaN보다 훨씬 먼저 나타난다 — NaN이 지연의 원인일 수는 없다는 점에서 159차 서술은 틀렸지만, "NaN이 재현 안 된다"는 것도 틀렸다(단지 지연 발생형이었을 뿐). 소스에서 두 가지 수치적 취약점을 확인했다 — ① 기본 initial_estimate_covariance가 대각원소 1e-9(주석은 "큰 값"이라 하지만 코드는 반대), ② 기본 alpha=0.001에서 시그마 포인트 가중치가 중심 약 -1,000,000·나머지 30개 각 약 +33,333이라는 극단적 크기(합은 정확히 1.0) — 둘 다 "즉시 드러나지 않고 시간이 지나며 정밀도가 누적 손실되는" 관찰과 방향이 맞는다. 하지만 두 가설에서 나온 "고치는 법"은 둘 다 실측으로 반증됐다 — 공식 예제 공분산 값을 명시하니 지연이 오히려 악화됐고(0.3885→0.2302, 관찰 창 안에서 NaN 없음이나 30초 이상 지켜보지는 못함), 극단적 가중치를 없애는 alpha=1.0은 NaN을 기본 설정보다 훨씬 빨리(10초 관찰 창 안에) 나타나게 했다(최종 로그 692회, 마지막 샘플이 문자 그대로 nan). ekf_node 대조군은 같은 컨테이너·같은 스크립트로 여전히 정상이었다(0.4999). 정직한 결론: 정확한 발산 메커니즘은 이번에도 완전히 규명하지 못했지만, ukf_node가 이 절의 최소 설정에서 ekf_node와 수치적으로 동등하지 않고(지연은 즉시, NaN은 30초 안팎 뒤 반드시) 자연스러운 수정 시도가 오히려 악화시킨다는 것은 확실히 닫혔다. 부수적으로 kill -9가 파일로 리다이렉션된 로그 전체를 유실시킬 수 있다는 것과, 관찰 창이 짧으면 "지연 발생형" 실패를 놓치고 성급히 결론 낼 수 있다는 것 둘 다 방법론적 교훈으로 확인했다(stdbuf -oL -eL로 로그 유실은 해결, 향후 회차에 적용 권고). 7.6(주 반영처, 7.6.4, 정정 포인터 포함)에 반영, 하네스는 tools/gap_harness/gap160/(scripts/·logs/(36종)·data/(회귀분석·시그마 가중치 분석·NaN 발생 시각 대조 포함)·src_instrumented/(참조 소스 3종)). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 106개 그대로, 미확인 표시는 853→855건(순증 2 — 정확한 발산 메커니즘, 명시적 공분산 구성의 장기 안정성 미확인 두 건을 새 미확인으로 남김). 확인하지 못한 것: NaN이 정확히 어느 부동소수점 단계에서 처음 나타나는지(주기별 상태·공분산 덤프 필요, 미시도), kappa·beta 개별 튜닝 효과, two_d_mode 아닌 완전 3D 구성에서도 지연이 있는지, 명시적 공분산 구성을 30초 이상 관찰해도 NaN이 없는지, 이번에 발견한 로그 유실 문제가 이전 159개 회차의 다른 하네스에도 있었는지(감사하지 않음).
140차 검증(2026-08-21 · CHG-185)161차 갭 점검. KeepoutFilter+SpeedFilter 동시 스택을 시험하려다, 155차의 단일 KeepoutFilter 결과 자체가 재현되지 않는다는 더 근본적인 문제를 발견했다. 먼저 구성 버그 둘을 찾아 고쳤다 — ① 구동 스크립트의 인자 없는 wait가 무한히 실행되는 moving_tf_publisher.py까지 기다려 실측이 끝났는데도 스크립트가 멈춰 있었던 것(특정 PID만 wait하도록 수정), ② static_layer의 map_topic이 상대 이름이라 코스트맵 네임스페이스 아래로 잘못 resolve된 것(155차가 필터 토픽에서 고친 것과 같은 종류, 절대 경로로 수정). 두 버그를 고친 뒤에도 코스트맵이 계속 전부 0이었다 — 키프아웃 구역도 254로 안 나왔고 /speed_limit도 0건이었다(12회 연속 다중 샘플링으로 일회성 캐시 문제 배제). 다섯 가지 독립 재현 시도(자체 2-필터 구성, 155차 원본 speed_params.yaml 그대로, navigation2 jazzy 브랜치를 새로 클론해 빌드한 것, 155차의 keepout_params.yaml+run_keepout.sh를 바이트 단위로 재생, 같은 재생에 obstacle_layer 추가)이 전부 똑같이 실패했다 — 맵·마스크·필터 정보가 전부 정상 수신됐다고 로그에 출력됐는데도 코스트맵은 항상 0이었다. 노드 자체는 종료되지 않았다(published_footprint 5.000Hz 유지, lifecycle get도 active). 155차의 원시 데이터(gap155/data/keepout_dump.json)를 다시 읽어 400/400이 사실임은 재확인했다 — 하지만 왜 이번 회차의 다섯 시도가 전부 실패했는지는 규명하지 못했다. 8.7(주 반영처, 8.7.5, 정정 포인터 포함)에 반영, 하네스는 tools/gap_harness/gap161/(scripts/(18종)·logs/(40종)·data/(14종, 재현성 비교 포함)). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 106개 그대로, 미확인 표시는 855→856건(순증 1, 재현 실패 원인을 새 미확인으로 남김). 확인하지 못한 것: 정확한 성공/실패 분기 요인(타이밍·스케줄링 추정, 미확정), BinaryFilter(같은 메커니즘 의존이라 시도 안 함), 이 재현성 문제가 다른 회차의 코스트맵 하네스에도 있는지(감사 안 함).
141차 검증(2026-08-21 · CHG-186)162차 갭 점검. 161차가 남긴 "재현 실패가 흔한가 드문가"라는 질문에 반복 시험으로 답했다 — 20번 시도 중 단 한 번도 재현되지 않았다. 155차의 keepout_params.yaml을 완전히 무수정으로, 매번 새 ROS_DOMAIN_ID와 새 프로세스 집합으로 15회 반복 실행하니 15/15 전부 실패(키프아웃 0/400, 코스트맵 전체 0/10000). 161차의 독립적인 5회 실패와 합치면 20/20 — 두 회차·최소 세 컨테이너에 걸쳐 155차의 성공이 단 한 번도 재현되지 않았다. 새로운 진단 단서도 얻었다 — 이번엔 15회 전부 "Resizing costmap..."(StaticLayer의 지도 콜백이 실제로 호출됐다는 표시)도, "no map received" 경고도 둘 다 한 번도 나타나지 않았다 — 모든 구성 요소가 로그에 "성공"을 남기는데 static_layer의 지도 콜백만 아무 흔적 없이 호출되지 않는, 완전히 조용한 실패였다. 코디네이터의 조언대로 근본 원인을 더 조사하지 않고 빈도 통계로 질문에 답하는 데서 범위를 의도적으로 좁혔다. 8.7(주 반영처, 8.7.5, 정정 포인터 포함)에 반영, 하네스는 tools/gap_harness/gap162/(data/frequency_analysis.txt·data/trial_results.txt·data/trial*_dump.json(15개), logs/(90개)). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 106개 그대로, 미확인 표시는 856→857건(순증 1). 확인하지 못한 것: 여전히 정확한 근본 원인, 20회보다 훨씬 많은 시도로도 성공이 안 나오는지(미시도), 다른 회차 코스트맵 하네스에도 같은 문제가 있는지(감사 안 함).
142차 검증(2026-08-21 · CHG-187)163차 갭 점검. 141차의 코스트맵 필터 재현성 조사를 세 회차(139~141차)에 걸쳐 정직하게 미해결로 남긴 뒤, 코디네이터의 권고대로 완전히 새 영역으로 옮겼다 — 11.3(기록·재생 전략)은 이번에 처음으로 실행 확인을 받는 장이다. ros:jazzy-ros-base에 ros-jazzy-rosbag2·ros-jazzy-rosbag2-storage-mcap을 설치해 실제로 기록·재생을 반복 기동했다. ① ros2 bag record --help/ros2 bag play --help로 이 장이 언급한 플래그가 전부 실재함을 확인했다 — --snapshot-mode는 책이 Jazzy 지원 여부를 미확인으로 남겨 뒀던 항목을 닫는다. ② --rate 2.0·--rate 0.5가 9.6초 bag을 각각 5.206초·19.611초에 재생해(기대 ~4.8초·~19.2초) 기대치에 근접했고, --start-offset 5 --playback-duration 2는 정확히 카운터 값 31~40, 0.2초 간격으로 재생됐다. ③(정교화) TRANSIENT_LOCAL 지각 구독자 함정을 3조건(이른 구독·재생 5초 경과 후 구독·재생 프로세스 종료 후 구독)으로 나눠 실측한 결과, 원래 문언("재생이 구독자보다 먼저 시작되면 놓친다")이 부정확함이 드러났다 — 실제 트리거는 재생 프로세스 자체가 완전히 종료되는 것이지, 구독이 재생 시작보다 늦다는 사실 자체가 아니다(재생 프로세스가 실행 중인 동안엔 5초 늦게 구독해도 받았다). ④ --clock은 /clock 384개(sim_time 구간 9.575초, bag 녹화 구간 9.6초와 일치)를, 미사용 시 0개를 발행해 표가 정확함을 확인했다. ⑤(핵심, 신규 발견) --snapshot-mode를 기능 시험했다 — 트리거 전엔 디스크에 0바이트, 서비스 호출 즉시 실데이터가 쓰였다(문서 설명과 일치). 그런데 스냅숏을 한 번도 트리거하지 않고 정상 종료(SIGINT)하면 bag이 완전히 비어 있었다(2회 재현, Messages: 0, 퍼블리셔는 5.001Hz로 실발행 중이었음을 별도 확인) — "종료 시 자동으로 남은 캐시를 저장해 준다"는 기대가 틀렸음을 확인했다. 트리거 후 종료한 경우엔 데이터가 저장됐지만 발행량 대비 약 절반만 캡처됐고(9.6초·5Hz 구간에 22개만, 기대 ~48개), --max-cache-size 기본값(100MiB)으로는 설명되지 않는 이 손실의 정확한 메커니즘은 규명하지 못했다. SIGINT 종료 자체도 3회 중 1회 15초 넘게 멈춰 kill -9가 필요했던 비결정적 사례가 있었다(재현 조건은 못 좁힘). 11.3(주 반영처, 11.3.1·11.3.3·11.3.4·11.3.5)에 반영, 하네스는 tools/gap_harness/gap163/(raw_bag_test/, findings_summary.txt). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 106→107(11.3이 처음으로 실행 확인 장이 됨), 미확인 표시는 857건 그대로(원문 보존+정정 포인터 방식이라 기존 미확인 표기를 지우지 않았고, 새 미확인은 스냅숏 캐시의 절반 손실 메커니즘·SIGINT 비결정적 지연 두 건을 본문에 남겼지만 괄호 표기로는 달지 않았다). 확인하지 못한 것: 스냅숏 모드의 절반 손실 정확한 메커니즘, SIGINT 종료 지연의 재현 조건, --loop 시간 점프·--topics 필터링(미시도), 저장 관리·업로드(조직 인프라 의존, 여전히 미검증).
143차 검증(2026-08-21 · CHG-188)164차 갭 점검. 시작 전에 코디네이터가 163차의 방법론상 결함을 지적했다 — "트리거 없이 종료한 스냅숏 bag의 Messages: 0"을 화면으로만 확인해 원시 로그에 안 남겼다는 것(133차 UBSAN 오류 텍스트 누락과 같은 유형의 반복 지적). 이번엔 ros2 bag info를 포함한 모든 명령 출력을 예외 없이 파일로 리다이렉트했다 — 재현하니 이번에도 Messages: 0이었고(퍼블리셔 5Hz 실발행은 별도 재확인), 이번엔 그 근거가 baginfo_snap0.log에 그대로 남았다(163차 2회 + 164차 1회, 3회 연속 재현). 163차 제안 ②를 따라 11.3의 나머지 중 구체적으로 실측 가능한 두 축(--loop·--topics)을 마저 닫았다 — 코디네이터가 저장 관리(11.3.7)·회귀 데이터셋(11.3.8)처럼 조직 인프라에 의존하는 절보다, 컨테이너에서 직접 측정할 수 있는 것을 우선하도록 권했다. ① --loop의 시간 역행을 정량 확인했다 — 3.2초(17개 메시지) bag을 --clock --loop로 9초 재생하며 /clock 343개를 기록하니 역방향 점프가 정확히 2회(3회 반복의 경계와 일치) 나타났고, 각각 약 3.275초 뒤로 점프해 bag 녹화 구간(3.2015초)과 근사적으로 맞았다. 같은 창에서 /counter 값을 기록하니 [6..22, 6..22, 6..15]로 정확히 같은 시퀀스가 반복돼, 시간만이 아니라 메시지 자체도 되풀이 재생됨을 확인했다. ② --topics 필터링도 확인했다 — /counter·/other_counter를 동시 녹화해 ros2 bag info로 둘 다 23개씩 실제로 녹화됐음을 먼저 확인한 뒤, --topics /counter로 재생하며 두 토픽을 독립 구독자로 동시 감시하니 /counter는 23개 전부, /other_counter는 0개였다. 이번 회차는 163차가 정립한 다섯 가지 테스트 하네스 교훈(도메인 ID 상한·timeout --signal=INT·백그라운드 실행·QoS 명시·pkill 자기매칭 회피)을 그대로 재사용해 새로운 하네스 버그가 없었다. 11.3(주 반영처, 11.3.3·11.3.5·11.3.6)에 반영, 하네스는 tools/gap_harness/gap164/(raw_bag_test/, findings_summary.txt). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 107개 그대로(11.3 기존 실행 확인 장을 확장), 미확인 표시는 857건 그대로(이번 회차는 확인·재확인뿐이라 새로 열거나 닫은 미확인이 없다). 확인하지 못한 것: 스냅숏 모드의 절반 손실 메커니즘·SIGINT 종료 지연(여전히 이월), 11.3.7·11.3.8(저장 관리·회귀 데이터셋, 조직 인프라 의존이라 이 환경에서 검증 불가), --start-paused 단계 재생(미시도).
144차 검증(2026-08-21 · CHG-189)165차 갭 점검. 164차 제안 ①("27개→26개 남은 실행 확인 0건 장 중 하나로 새 영역 열기")을 이번 회차의 최우선 권고로 코디네이터가 명시했다. 11.3의 실측 가능한 축이 두 회차(163~164차)에 걸쳐 충분히 닫혔으므로, 5.3(URDF 심층)을 골랐다 — 미확인 표시가 넷 있는데 전부 이 환경에서 저비용으로 결정적으로 답할 수 있는 성격이었다(mimic 처리·.obj 지원·<transmission> 위치·동적 재구성). ros:jazzy-ros-base에 liburdfdom-tools·ros-jazzy-xacro·ros-jazzy-robot-state-publisher·ros-jazzy-ros2-control·ros-jazzy-ros2-controllers를 설치했다. ① check_urdf가 확인하는 것/못하는 것을 7개의 의도적으로 깨진 URDF로 결정적으로 확인했다 — 필수 속성(<limit> 누락)·참조 무결성(존재하지 않는 자식 링크)·트리 구조(루트 둘·순환)는 전부 실패(EXIT 255)했고, 관성 삼각부등식 위반·질량 음수·존재하지 않는 메시 참조는 전부 조용히 통과(EXIT 0)했다 — 책의 두 표(확인하는 것/못하는 것) 7항목 전부가 실측과 정확히 일치했다. ② <mimic> 관절을 두 층위에서 확인했다 — robot_state_publisher는 구동 관절(left_finger_joint=0.03)만 발행해도 mimic 관절(right_finger_joint, multiplier=-1.0)의 tf를 -0.030으로 정확히 계산했다(mimic 관절 자신의 JointState는 한 번도 발행하지 않음). ros2_control은 hardware_interface/hardware_info.hpp의 MimicJoint 구조체와 공식 테스트 픽스처의 <joint mimic="true"> 속성으로 소스 레벨의 1급 구조적 지원을 확인했다(단, 실제 controller_manager 런타임 기동은 시도하지 않음). ③ <transmission>의 Jazzy 위치를 확인했다 — ros-jazzy-transmission-interface가 실재하고, 공식 테스트 픽스처에서 <transmission>이 <ros2_control> 블록 안에 중첩되어 SimpleTransmission·DifferentialTransmission·FourBarLinkageTransmission 플러그인으로 쓰이는 실제 예제를 확인했다 — 책의 "구형 방식"이라는 표현을 "폐기가 아니라 이전·재설계"로 정정했다. ④(정직하게 미해결) "동적 재구성"(런타임 robot_description 교체)은 ros2 param set으로 시도했으나, robot_state_publisher가 libsdformat_urdf_plugin.so 로드 중 libsdformat14.so.14 누락으로 실패해 결론을 못 냈다 — 이 실패가 기능 자체의 한계인지 이 최소 컨테이너의 의존성 누락 때문인지 분리하지 못해 미확인 표시를 닫지 않고 그대로 남겼다. 5.3(주 반영처, 5.3.1·5.3.3·5.3.6·5.3.7·5.3.8·5.3.9)에 반영, 하네스는 tools/gap_harness/gap165/(urdf_test/, findings_summary.txt). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 107→108(5.3이 처음으로 실행 확인 장이 됨), 미확인 표시는 857건 그대로(넷 중 셋을 실측으로 닫았지만 원문 보존 방식이라 문구는 남았고, 나머지 하나는 시도했으나 미해결로 남아 순증 0). 확인하지 못한 것: 동적 재구성의 정확한 지원 여부(환경 제약으로 미분리), ros2_control mimic의 실제 런타임 동작(소스만 확인), 관성 계산·xacro·시뮬레이터별 확장 태그 처리(5.3의 나머지, 이번 회차 범위 밖).
145차 검증(2026-08-21 · CHG-190)166차 갭 점검. 코디네이터가 165차의 "다음 회차 제안" 셋을 모두 제시하며 "①②를 ③과 결합해도 좋다"고 명시했다 — 셋 다 처리했다. ros:jazzy-ros-base에 liburdfdom-tools·ros-jazzy-xacro·ros-jazzy-robot-state-publisher·ros-jazzy-ros2-control·ros-jazzy-ros2-controllers와 OSRF Gazebo apt 저장소(libsdformat14용)를 설치했다. ① ros2_control mimic 런타임 — 결정적으로 닫음. ros2_control_test_assets 그리퍼 픽스처(구동 right_finger_joint+mimic left_finger_joint, multiplier=2 offset=0.01)에 mock_components/GenericSystem을 붙여 실제 controller_manager를 기동했다(부수 발견: ros2_control_node는 파라미터만으로는 초기화 안 되고 /robot_description 토픽을 별도로 발행하는 robot_state_publisher가 함께 실행 중이어야 함을 확인). joint_state_broadcaster+forward_position_controller를 스폰해 구동 관절에 0.1을 커맨드하니 /joint_states의 mimic 관절이 정확히 0.21000000000000002($2\times0.1+0.01$, 부동소수 오차만)로 반영됐다 — 165차가 "소스만 확인, 런타임 미시도"로 남긴 이월 항목을 실측으로 닫았다. ② "동적 재구성" — 원인 분리로 결정적으로 닫음. libsdformat14(14.9.0-1~noble)를 설치한 뒤 ros2 param set /robot_state_publisher robot_description <새 URDF>를 재시도하니 즉시 성공했고, 로그에 "Robot initialized"가 두 번(재파싱이 실제로 일어났다는 뜻) 출력됐으며 tf2_echo로 새 링크의 tf가 실제로 생겼음을 확인했다 — 165차의 실패는 기능 미지원이 아니라 최소 컨테이너의 libsdformat14 누락이라는 순수한 환경 문제였음이 확정됐다. ③ 5.7(로봇 모델 품질 검증) 신규 실행 확인. "아래 검증 스크립트는 개념적 구현이며 실행 검증하지 않았다"는 전면 정직 고지가 달린 핵심 스크립트 넷(check_physics.py·check_meshes.py·check_collision_complexity·check_semantics)을 책의 코드 그대로 옮겨 총 10개 테스트 URDF에 실제로 실행했다 — 넷 다 첫 실행에서 버그 없이 책의 오류/경고 표와 정확히 일치했다(삼각부등식 위반·질량 음수·<inertial> 없음·비대각 지배 4종, 메시 파일 존재·패키지 해석 3종, 충돌 기하 복잡도 집계, 축 비단위벡터·한계 역전·이름 규약 위반 5종 — 전부 정확히 검출, 오탐 0건). 5.3(5.3.1·5.3.3·5.3.8 갱신)·5.7(주 반영처, 5.7.1·5.7.4·5.7.5·5.7.6·5.7.7)에 반영, 하네스는 tools/gap_harness/gap166/(r2c_test/·dynreconfig_test/·rj57_test/, findings_summary.txt). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 108→109(5.7이 처음으로 실행 확인 장이 됨), 미확인 표시는 857건 그대로(모두 실측으로 닫혔지만 원문 보존 방식이라 문구는 남았고, 새로 연 미확인은 없다). 확인하지 못한 것: 5.7.10(CI 통합 워크플로 자체는 GitHub Actions 환경이 필요해 미시도), joint_state_publisher_gui 기반 축 방향 수동 검증(GUI 필요, 헤드리스 환경 제약), 5.7.8의 launch_testing 기반 tf 트리 자동 테스트(미시도).
146차 검증(2026-08-21 · CHG-191)167차 갭 점검. 145차가 미시도로 남긴 5.7.8(launch_testing 기반 tf 트리 자동 테스트)을 먼저 처리했는데, launch_testing이 필요해 자연스럽게 14.6(테스트 II — launch_testing·통합·HIL) 신규 실행 확인으로 이어졌다. 5.7.10(CI 워크플로 근사)은 act 도구가 Docker-in-Docker를 요구하는데 이 컨테이너에 Docker가 없어 정직하게 보류하고, 그 시간을 launch_testing 자체를 깊이 조사하는 데 썼다. ① 5.7.8 tf 트리 완전성 테스트. 책의 함수 본문을 무수정으로 옮기고 generate_test_description·ReadyToTest 골격만 추가해 실행했다 — 5개 프레임 전부 있는 URDF는 통과(EXIT 0), front_left_wheel만 뺀 URDF는 정확히 그 프레임만 실패 메시지에 지목(EXIT 1) — 양성·음성 둘 다 결정적으로 확인. ② 14.6.2의 post_shutdown_test+assertExitCodes. 장기 실행 노드 둘로 "시험 통과 후 종료 시 크래시"(조용한 크래시)를 실측했다 — SIGINT를 정상적으로 처리하는 노드는 통과, SIGINT 핸들러 자체가 크래시하는 노드는 능동 시험은 통과했지만 post_shutdown_test가 정확히 검출했다(exited with code 1). 부수 발견 — assertExitCodes의 기본 allowable_exit_codes는 소스로 확인한 결과 [0]뿐이라 SIGINT 종료 노드는 스스로 exit(0)을 호출해야 기본값으로 통과한다는 것과, 시험 대상이 능동 시험 구간 중에 스스로 끝나면 전체 실행이 "Processes under test stopped before tests completed"로 중단된다는 것을 확인했다. ③(신규 발견) assertWaitFor(출력 매칭)의 문서화되지 않은 함정. print()(stdout) 마커를 기다리는 시험이 계속 타임아웃돼 io_handler.py 소스를 읽어 원인을 확정했다 — 기본값이 stream='stderr'다(stdout 아님). stream='stdout'을 명시하니 즉시 통과했다 — rclpy get_logger()의 stderr 관례와는 맞지만 일반 print()와는 조용히 어긋나는 실제 함정이라 14.6.9 함정 카탈로그에 17번으로 신설했다. ④ demo_nodes_cpp의 실제 talker/listener로 get_node_names() 기반 노드 존재 확인 패턴도 실측했다(2 tests, OK). 5.7(5.7.1·5.7.8 갱신)·14.6(주 반영처, 14.6.1·14.6.2·14.6.3·14.6.9)에 반영, 하네스는 tools/gap_harness/gap167/(rj57_08_test/·rj118_test/, findings_summary.txt). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 109→110(14.6이 처음으로 실행 확인 장이 됨), 미확인 표시는 857건 그대로. 확인하지 못한 것: 5.7.10·14.6의 시뮬 기반 시험·bag 회귀·HIL·현장 승인 시험(14.6.4~14.6.7, 조직·하드웨어 의존이라 이 환경에서 검증 불가), ReadyToTest 타이밍 함정 자체의 직접 재현(미시도).
147차 검증(2026-08-21 · CHG-192)168차 갭 점검. 코디네이터의 세 제안 중 ③(11.4 등 교차반영)을 먼저 grep으로 확인했는데, 11.4을 포함해 어떤 장도 assertWaitFor/stream을 직접 코드로 쓰지 않아(단순 상호 참조뿐) 교차반영할 대상이 없었다 — 정직하게 종결. ②(저비용)를 처리한 뒤 ①(최우선 권고)로 6.6(실시간 루프와 ros2_control의 제약)을 열었다. ① 14.6.2의 ReadyToTest 타이밍 함정을 실제로 재현했다 — 3초 지연 후에야 발행을 시작하는 slow_starter.py를 만들어, ReadyToTest 직후 1초만 기다리는 시험은 정확히 실패(AssertionError: [] is not true : 1.0초 안에 메시지를 받지 못함)했고, 5초 기다리는 시험은 정확히 통과했다 — 양성·음성 둘 다 결정적으로 확인. ② RealtimeBox의 Jazzy API 형태를 확인해 미확인 표시를 닫았다. realtime_box.hpp 소스로 확인한 결과 RealtimeBox는 Jazzy에서 폐기(deprecated)됐고(#pragma message 경고), 내부적으로 RealtimeBox<T> = RealtimeThreadSafeBox<T, std::mutex>다 — 현재 권장 이름은 RealtimeThreadSafeBox. RealtimeServerGoalHandle도 실재하며 폐기 표시가 없음을 확인했다. ③ RealtimeBuffer의 비블로킹·값 완결성을 실제 컴파일·실행으로 정량 확인. writeFromNonRT/readFromRT 패턴의 C++ 프로그램을 2초간 동시 실행하니 쓰기 13,152회, 읽기 23,771,005회(초당 약 1,200만 회), 찢어지거나 역전된 값 0건 — "절대 블로킹되지 않는다"·"항상 최신 완결된 값"이라는 두 핵심 주장이 정량으로 확인됐다. ④ 지터 측정 방법론(6.6.6)을 실제 ros2_control_node로 실측. mock_components/GenericSystem으로 실행한 노드는 스레드 37개(책이 인용한 "실측 사례 15개"보다도 많음), chrt -p <메인PID>는 정확히 1개 스레드만 보여 위 함정을 재현했다. ros2 topic hz /joint_states는 평균 99.99Hz로 설정된 update_rate: 100과 정확히 일치했다. ⑤(정정 발견) pgrep -x가 이 절 자신의 예시(ros2_control_node, 17자)에서 실패한다 — 리눅스 커널 comm 필드가 15자로 잘려(ros2_control_no) pgrep -x가 조용히 매칭 0건이 된다(pgrep 자신도 경고 출력). pidof나 잘린 이름, 대괄호 트릭이 대안이며, "정확히는 pgrep -x가 안전하다"는 조언에 정정 포인터를 달고 함정 카탈로그 16번을 신설했다. 14.6(14.6.2 확장)·6.6(주 반영처, 6.6.1·6.6.4·6.6.6·6.6.9)에 반영, 하네스는 tools/gap_harness/gap168/(readytotest_test/·rt_tools_test/·rt_buffer_test/·thread_test/, findings_summary.txt). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 110→111(6.6이 처음으로 실행 확인 장이 됨), 미확인 표시는 857건 그대로. 확인하지 못한 것: ros2_control_node의 RT 스케줄링 CLI 지원 자체(12.2 영역, 미시도), RealtimePublisher의 실제 컴파일·실행 확인(헤더만 확인, 미시도), cyclictest 실측(12.2 영역).
148차 검증(2026-08-21 · CHG-193)169차 갭 점검. 168차 제안 중 "①을 ②③ 중 하나와 결합해도 좋다"는 코디네이터 권고에 따라, ①(RealtimePublisher 실제 컴파일·실행)을 ③(12.2)과 결합했다. 12.2은 이미 이전 회차들에서 광범위하게 실행 확인된 장(12.2.11·12.2.12에 실측 섹션 기존 보유)이라, 이번엔 그 안에 남은 구체적 미확인 항목(PI 뮤텍스 정확한 사용법)을 마저 닫는 데 집중했다. ros:jazzy-ros-base에 --cap-add SYS_NICE --ulimit rtprio=99를 줘 컨테이너에서도 실제 SCHED_FIFO 스케줄링을 확보했다. ① 6.6.4.2 RealtimePublisher를 ament_cmake 패키지로 실제 빌드·실행했다. 책의 trylock/unlockAndPublish 패턴을 실제 rclcpp::Node+SingleThreadedExecutor로 3초간 실행한 결과 trylock 성공 11,728~11,845회, 실패 13~31회(성공률 99.7~99.9%)였고, 독립적으로 ros2 topic hz로 확인한 결과 평균 3880.249Hz로 실제 DDS 발행이 확인됐다. ② 12.2.4 PI 뮤텍스 API를 컴파일·실행해 확인했다 — 책의 코드가 전부 정상 동작했고 getprotocol로 PTHREAD_PRIO_INHERIT 설정을 확인했다. ③(핵심 발견) 실제 우선순위 역전 완화 효과를 정량 재현했다. 저(10)·중(50)·고(90) 세 SCHED_FIFO 스레드를 CPU 0에 고정해, 저가 뮤텍스를 획득하고 50ms 임계구역을 실행하는 동안 중이 300ms 바쁜 루프로 CPU를 독점하게 만들고 고의 뮤텍스 대기 시간을 측정했다 — 일반 뮤텍스: 305.159/305.231/305.214ms(3회, 교과서적 우선순위 역전) 대 PI 뮤텍스: 39.837/39.844/39.823ms(3회) — 약 7.7배 개선이 두 조건 모두 3/3 반복에서 0.1ms 이내로 수렴할 만큼 재현성 높게 확인됐다. 이 재현은 PREEMPT_RT가 아닌 일반 커널에서도 가능했다(PI는 POSIX 스레드 기능). 6.6(6.6.1·0.4.2 확장)·12.2(주 반영처, 12.2.1·12.2.4)에 반영, 하네스는 tools/gap_harness/gap169/(rt_pub_test/·pi_mutex_test/·pi_demo/, findings_summary.txt). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 111개 그대로(6.6·12.2 둘 다 이미 실행 확인 장), 미확인 표시는 857건 그대로(닫힌 것은 원문 보존 방식이라 문구가 남았고, 새로 연 미확인은 없다). 확인하지 못한 것: ros2_control_node의 RT 스케줄링 CLI 지원(12.2 영역, 이월), cyclictest 실측(12.2 영역, 이월), 우선순위 천장(priority ceiling) 프로토콜의 실측(미시도).
149차 검증(2026-08-21 · CHG-194)170차 갭 점검. 169차 제안 중 "결합해도 좋다"는 권고에 따라 ②(우선순위 천장 프로토콜)와 ③(12.3)을 결합했다. 12.3도 12.2처럼 이미 광범위하게 실측된 장(12.3.12에 operator new 후킹 실측 섹션 기존 보유)이라, 그 안에 남은 구체적 미확인 항목들을 마저 닫는 데 집중했다. --cap-add SYS_NICE --cap-add IPC_LOCK --ulimit rtprio=99 --ulimit memlock=-1로 실제 RT 스케줄링·메모리 잠금을 확보했다. ① 12.2.4 우선순위 천장(PTHREAD_PRIO_PROTECT) 재현. 169차와 같은 저·중·고 3-스레드 설계에 천장(=90)을 적용하니 H 대기 39.873/39.865/39.879ms(3회)로 169차의 PI 결과(39.837/39.844/39.823ms)와 사실상 동일 — 이 시나리오에서 두 프로토콜이 동등하게 효과적임을 확인했다. ②(핵심, 신규 발견) 프리폴트의 컴파일러 최적화 위험을 발견했다. mlockall 없이 malloc→memset→free만 있는 격리된 벤치마크를 GCC -O2로 빌드하면 minflt 증가가 0이었다(디스어셈블로 호출 자체가 사라짐을 확인, -O0으로는 정상적으로 25,602). 이 장의 실제 configure_memory() 코드는 안전함을 재확인했다 — mlockall이 실제 부작용을 가지므로 컴파일러가 프리폴트를 지우지 못해, -O2에서도 minflt 25,695(-O0의 25,697과 일치)로 정상 동작했다. ③ 커스텀 할당자 API를 실제 컴파일했다 — PublisherOptionsWithAllocator·SubscriptionOptionsWithAllocator는 즉시 컴파일됐지만 AllocatorMemoryStrategy는 처음엔 실패했다(네임스페이스는 정확했으나 rclcpp/strategies/allocator_memory_strategy.hpp가 umbrella 헤더에 없어 별도 include가 필요했다). ④ can_loan_messages()로 대여 메시지 조건을 확인했다 — 고정 크기(Float64)는 =1(참), 가변 크기(Float64MultiArray)는 =0(거짓)으로 깔끔히 대비됐다. ⑤ TLSF가 ros-jazzy-tlsf-cpp 패키지로 실재하고 실행 예제(tlsf_allocator_example)가 정상 동작함을 확인했다. 12.2(12.2.4 확장)·12.3(주 반영처, 12.3.1·12.3.3·12.3.4·12.3.5·12.3.7·12.3.10)에 반영, 하네스는 tools/gap_harness/gap170/(pc_demo/·mlockall_test/·loan_alloc_test/, findings_summary.txt). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 111개 그대로(12.2·12.3 둘 다 이미 실행 확인 장), 미확인 표시는 857건 그대로. 확인하지 못한 것: 다른 RMW 구현(Cyclone DDS 등)에서의 대여 메시지·제로 카피 재확인(미시도), TLSF를 실제 rclcpp 할당자로 주입해 12.3.12의 할당 후크 숫자가 어떻게 변하는지(미시도), mlockall 적용 전후 major fault 수(12.3.12.6부터 이월).
150차 검증(2026-08-21 · CHG-195) — 6.7 신규171차 갭 점검. 168~170차 세 회차째 RT/메모리 스레드가 이어져, 코디네이터의 권고대로 완전히 새 영역을 열었다. 6.7(드라이버 작성 원칙 — 시리얼·CAN·EtherCAT)는 실제 하드웨어 없이도 vcan(가상 CAN)·socat(가상 시리얼)로 책의 코드 예제 다수를 있는 그대로 컴파일·실행할 수 있어 선택했다. ip link add dev vcan0 type vcan으로 가상 CAN 버스를 만들었다(참고: modprobe vcan은 컨테이너 자체 /lib/modules에 vcan.ko가 없어 실패했지만 ip link add ... type vcan은 호스트 커널이 이미 갖고 있어 성공 — 컨테이너의 /lib/modules 부재가 실제 모듈 가용성과 다를 수 있다). ① open_can()의 CAN_RAW 필터를 실측했다 — ID 0x100·0x200·0x300·0x123 네 프레임을 전부 버스에 보냈는데, 필터({0x100,...}·{0x200,...})가 걸린 수신자는 정확히 0x100·0x200 둘만 수신했다(total received=2) — "커널이 걸러 준다"는 주석을 실측으로 확인. ② parse_frame() CRC/프레이밍 코드를 5개 테스트 벡터로 검증했다 — 정상 프레임(파싱 성공)·CRC 1비트 손상(거부, crc_errors_ 카운터 정확히 1 증가)·시작 바이트 어긋남(거부)·길이 부족(거부)·최소 길이 미달(거부) 5개 전부 책의 설계와 정확히 일치했다. ③ attempt_reconnect() 지수 백오프의 배가 수열을 확인했다 — 100→200→400→800→1600→3200ms로 정확히 2배씩 늘었고 상한(kMaxRetryDelay=3200ms)에서 정확히 캡핑됐다. ④ open_serial() termios 코드를 socat 가상 포트 쌍으로 실측했다 — "HELLO_SERIAL_TEST_42"(20바이트)를 정확히 20바이트 그대로 수신했다. ⑤ ros2_canopen의 Jazzy 지원을 포괄적으로 확인했다 — ros-jazzy-canopen 메타패키지와 하위 23개 이상 패키지(402-driver·base-driver·master-driver·ros2-control 연동·fake-slaves 등)가 전부 실재했다. ⑥(정직 보고) ethercat_driver_ros2는 표준 apt에서 못 찾았다 — soem·ethercat 등 여러 검색어로도 0건, 소스 빌드 경로는 시도하지 않아 "존재하지 않는다"고 단정하지 않는다. 부수 발견 — 책의 코드 스니펫 둘에서 누락된 include를 찾아 고쳤다(CAN 예제엔 <string>, 백오프 예제엔 <algorithm> 필요). 6.7(주 반영처, 6.7.1·6.7.2·6.7.3·0.3.2·0.3.4·0.3.5)에 신규 실행 확인 반영, 하네스는 tools/gap_harness/gap171/(can_test/·frame_test/·backoff_test/·serial_test/, findings_summary.txt). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 111→112(6.7가 처음으로 실행 확인 장이 됨), 미확인 표시는 857건 그대로. 확인하지 못한 것: EtherCAT 자체(실제 슬레이브·마스터 스택 필요, 미시도), USB-시리얼 지연 타이머(실제 FTDI 칩 필요, 미시도), CAN 버스 오프·restart-ms(가상 CAN엔 실제 전기적 오류가 없어 미시도), udev 규칙의 실제 핫플러그 동작(컨테이너 제약).
151차 검증(2026-08-21 · CHG-196) — 7.8 신규172차 갭 점검. 171차 제안 중 "①(새 장)이 최선, ③(udev 시뮬레이션)을 6.7의 저비용 마무리로 먼저 해도 좋다"는 권고에 따라 ③을 먼저 처리한 뒤 ①로 7.8(대용량 데이터 파이프라인 설계)를 열었다. ②(EtherCAT 소스 빌드)는 코디네이터가 명시적으로 비권장해 시도하지 않았다. 방법론 수정 — 코디네이터가 171차 backoff_test/에 실행 로그가 빠져 있던 것을 지적했다. 확인해 보니 로그 자체는 존재했지만 gap171/backoff_test/가 아니라 gap171/(최상위)에 잘못 저장돼 있었다 — 이번 라운드 착수 직전에 frame_parse_result.log도 같은 문제를 발견해 함께 바로잡았고, 172차부터는 실행 결과를 먼저 올바른 하위 폴더로 정리한 뒤 책 본문에 경로를 인용하는 순서로 이 문제를 예방했다. ① 6.7.7 udev 규칙을 udevadm으로 부분 확인했다 — udevadm verify로 책의 규칙 문법이 정확함을 확인했고("Success: 1 Fail: 0"), udevadm test가 표준 udev 규칙 디렉터리(/etc/udev/rules.d/)만 읽는다는 것을 실측으로 확인했다(임의 경로에 두면 무시됨). 완전한 ATTRS 매칭(심볼릭 링크 생성)은 실제 USB-시리얼 하드웨어가 없어 확인하지 못했다 — 정직하게 부분 확인으로 남겼다. ② 7.8.5 topic_tools throttle의 실재를 확인하고 예상 밖의 정량화 동작을 발견했다(핵심 발견). throttle이 delay·demux·drop·mux·relay·relay_field·transform과 함께 실재했고, 정확한 호출 문법이 위치 인자(throttle messages <토픽> <Hz>)임을 확인했다. 5Hz 입력에 2.0Hz 요청 → 실제 측정 속도는 1.667~1.670Hz(두 번의 독립 반복 측정에서 일관) — 5Hz와 정확히 나누어떨어지지 않는 요청은 "마지막 통과 후 요청 간격 이상 지난 다음 도착 메시지를 통과"시키는 방식 때문에 입력의 이산적 도착 시각에 양자화됨을 확인했다(5Hz÷3=1.667Hz). 1.0Hz 요청(이론상 나누어떨어짐)도 간격이 1.001~1.203초로 완전히 안정적이지는 않았다. ③ 드롭-if-busy 원자적 교환 패턴을 실제 동시성 하에서 검증했다. 100Hz로 도착하는 프레임을 각각 별도 스레드로 처리하되 33ms(30Hz 카메라가 못 따라가는 상황)가 걸리게 하니 처리 25건, 폐기 75건, 처리+폐기=100건(총 프레임 수와 정확히 일치) — 실제 동시 접근 아래서도 유실·이중 처리·집계 오류가 없었다. 6.7(6.7.7 확장)·7.8(주 반영처, 7.8.1·7.8.5)에 반영, 하네스는 tools/gap_harness/gap172/(udev_test/·throttle_test/·drop_test/, findings_summary.txt). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 112→113(7.8가 처음으로 실행 확인 장이 됨), 미확인 표시는 857건 그대로. 확인하지 못한 것: udev 규칙의 완전한 ATTRS 매칭(실제 하드웨어 필요, 이월), 다른 throttle 모드(bytes)·다른 입력 속도 조합에서의 양자화 패턴 일반화(미시도), topic_tools drop(격 프레임 스킵) 등 다른 topic_tools 실행 파일(미시도).
152차 검증(2026-08-21 · CHG-197) — 9.3 신규173차 갭 점검. 172차의 top recommendation("20개 남은 실행 확인 0건 장 중 하나로 새 영역을 열라")에 따라 9.3(Planning Scene과 충돌 처리)를 새로 열었다. MoveIt 2.12.4(ros-jazzy-moveit + moveit-resources-panda-moveit-config)를 설치하고, 공식 demo.launch.py에서 RViz2·MongoDB만 제거한 헤드리스 launch 파일을 직접 작성해(GPU·디스플레이 불필요) 실제 move_group을 기동했다 — 컨트롤러·joint_state_broadcaster 전부 정상 활성화, /joint_states 실제 발행 확인. /apply_planning_scene이 실제 동기임을 실측으로 확인했다 — 서비스 호출 직전엔 빈 목록, 호출이 반환된 직후 곧바로 조회하니 이미 반영돼 있었다. /collision_object 토픽(비동기 경로)의 미반영 경합은 이번 시험 조건(사이에 낀 서비스 왕복 자체가 시간을 줌)에서는 재현하지 못했다 — 정직하게 부분 확인으로 남겼다. /clear_octomap(서비스 이름 확정) 직접 호출로 정상 응답을 받았다. GetPlanningScene의 components 비트값을 실측으로 확정하고, 책의 원래 예시가 틀렸음을 발견했다 — ros2 interface show로 SCENE_SETTINGS=1·ROBOT_STATE=2·ROBOT_STATE_ATTACHED_OBJECTS=4·WORLD_OBJECT_NAMES=8·WORLD_OBJECT_GEOMETRY=16·OCTOMAP=32·TRANSFORMS=64·ALLOWED_COLLISION_MATRIX=128·LINK_PADDING_AND_SCALING=256·OBJECT_COLORS=512를 확인했고, 실제 호출로 "components: 1(SCENE_SETTINGS)은 물체 목록을 채우지 못하고 components: 8(WORLD_OBJECT_NAMES)이라야 채워진다"는 것을 확인해 "알려진 물체 목록" 예시의 값(1)을 8로 정정했다. attachObject의 touch_links 반영도 raw diff로 확인했으나(부착 직후 attached_collision_objects[].touch_links가 요청한 세 링크와 일치), 그 서비스 호출 자체는 success=False를 반환하는 미상의 anomaly를 발견해 정직하게 원인 미조사로 남겼다(분리는 success=True로 명확했다). 9.3(주 반영처, 신규 실행 확인 장 — 9.3.1 서두 정정 포인터, 9.3.3·9.3.4·9.3.5·9.3.10 실행 확인(173차) 블록 신설)에 반영, 하네스는 tools/gap_harness/gap173/(scene_test/, findings_summary.txt, install_log.txt). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 113→114(9.3가 처음으로 실행 확인 장이 됨), 미확인 표시는 857건 그대로(닫힌 두 마커는 원문 보존 방식이라 문구가 남았다). 확인하지 못한 것: Octomap 자체(깊이 센서 기반 갱신, 이번엔 3D 센서 플러그인 미설정), 실제 충돌 계획 성공·실패(플래닝 자체는 이번 범위 밖), ACM의 getAllowedCollisionMatrixNonConst() C++ API(원격 rclpy 프로브로는 접근 불가), 패딩 런타임 변경 API(미시도), attach 시 success=False anomaly의 근본 원인(미조사).
153차 검증(2026-08-21 · CHG-198) — 9.4 신규174차 갭 점검. 173차의 top recommendation("ros-jazzy-moveit-servo가 이미 설치 가능한 이 회차의 헤드리스 MoveIt 스택을 그대로 재사용할 수 있다")에 따라 9.4(MoveIt Servo와 실시간 제어)을 새로 열었다. ros-jazzy-moveit-servo를 설치하고 공식 demo_twist.launch.py(RViz 제외)를 먼저 실행하니, demo_twist가 실은 외부 명령 없이 스스로 손끝 속도를 만들어 특이 자세로 접근하는 자기구동 데모임을 발견했다("Moving closer to a singularity, decelerating" 경고가 실제로 반복 출력 — 9.4.4의 감속 메커니즘이 실제로 발동함을 확인). 책이 설명하는, 외부에서 명령을 계속 받는 지속형 노드는 demo_ros_api.launch.py의 servo_node(standalone 모드)였다 — 이를 헤드리스로 다시 기동했다. ① 토픽 이름을 실측 확인했다 — /servo_node/delta_twist_cmds·/servo_node/delta_joint_cmds·/servo_node/status 전부 책과 정확히 일치했고, 책이 다루지 않는 세 번째 입력(/servo_node/pose_target_cmds, 손끝 목표 자세)도 발견했다. 처음엔 명령을 발행해도 아무 반응이 없었는데, servo_node 로그가 "Command type has not been set, cannot accept input"을 반복 출력했다 — 책에 전혀 없는 필수 선행 단계(/servo_node/switch_command_type으로 입력 종류를 먼저 선언)를 발견했다. ② 속도→관절 명령 변환을 실측했다 — JointJog(0.1 rad/s)를 1초 발행하니 /panda_arm_controller/joint_trajectory에 정확히 100개(발행 주기 100 Hz와 일치) 메시지가 나왔고 속도값이 요청과 거의 정확히 일치(0.0999...)했다. TwistStamped(0.05 m/s)로는 여러 관절이 동시에 변해 자코비안 역변환을 직접 확인했다. ③ pause_servo(SetBool)가 실제로 정지·재개를 정확히 수행함을 확인했다 — 정지 중 1초간 발행해도 출력 0개, 재개 후 1초는 다시 정확히 100개. 책이 주장한 /servo_node/start_servo·/servo_node/stop_servo(Trigger 둘)는 이 버전에 존재하지 않고, pause_servo 하나로 통합돼 있음을 발견해 정정했다. ④ 파라미터 이름을 실측 대조했다 — 특이점 임계값 세 이름(lower_singularity_threshold 등)은 정확했다. 충돌 파라미터는 4개(check_collisions 등)는 정확했지만 collision_distance_safety_factor·min_allowable_collision_distance는 이 버전의 파라미터 목록(160여 개) 어디에도 없었다. joint_limit_margin(단수)은 실제로 joint_limit_margins(복수, 목록 — 실제 값 [0.12] 확인)였고, low_latency_mode는 존재하지 않았다. 9.4(주 반영처, 신규 실행 확인 장 — 9.4.1 서두·9.4.3·9.4.4·9.4.5·9.4.6(2곳)·9.4.9 실행 확인(174차) 블록 신설)에 반영, 하네스는 tools/gap_harness/gap174/(servo_test/, findings_summary.txt, install_log.txt). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 114→115(9.4이 처음으로 실행 확인 장이 됨), 미확인 표시는 857건 그대로(닫힌 다섯 마커는 원문 보존 방식이라 문구가 남았다). 확인하지 못한 것: 하드 스톱(정지, 감속이 아닌) 특이점 이벤트를 직접 트리거하지 못함(데모의 자연 발생만 관측), 충돌 근접 감속·정지(9.4.5, Planning Scene에 장애물을 넣어야 함, 미시도), pose_target_cmds(세 번째 입력 모드, 발견만 하고 미시도), move_group+Servo 동시 사용 충돌 시나리오(미시도), 실행기·스레드 우선순위(9.4.7, 미시도).
154차 검증(2026-08-21 · CHG-199) — 9.4.5 확장175차 갭 점검. 174차의 top recommendation "② 9.4의 남은 '충돌 근접 감속·정지' — 9.3의 Planning Scene 하네스를 그대로 재사용할 수 있는 저비용 후보"를 채택해, move_group+servo_node를 함께 헤드리스로 실행하고 실제 장애물로 9.4.5(충돌 근접 감속·정지)를 재현했다. 첫 시도는 실패했다 — /apply_planning_scene으로 장애물을 정확히 반영시켰는데도(/get_planning_scene으로 확인) 팔이 그 위치를 그대로 통과했다. 원인을 추적한 결과 책에 없는 통합 함정을 발견했다 — servo_node의 monitored_planning_scene_topic 기본값은 /planning_scene인데, move_group이 보조 모니터에게 실제로 장면을 방송하는 토픽은 /monitored_planning_scene으로 서로 다르다(ros2 topic list로 확인). 기본값 그대로면 servo_node의 내부 충돌 검사기가 move_group에 적용된 장애물을 영영 받아 보지 못한다. 이 파라미터를 명시적으로 재설정하니 즉시 해결됐다. 수정 후 결과가 이 절의 핵심 주장을 정확히 재현했다 — 장애물 없이는 자유롭게 움직였지만(속도 크기 ~0.31~0.32), 손 앞 약 7.5cm 지점에 장애물을 두고 같은 명령을 4초간 보내니 처음 0.5초는 정상 가속하다가 속도가 정확히 0.0으로 떨어져 나머지 약 3.4초 내내 유지됐다(부분 감속이 아니라 완전한 정지) — 손끝은 4초간 0.030m만 이동했다(장애물 없었다면 이론상 0.32m). /servo_node/status가 code:0("No warnings")에서 code:4, "Close to a collision, decelerating"로 전환됨을 실측 확인 — 174차에 확인한 특이점 코드(code:1)와 명확히 구분되는 실제 충돌 코드값이다. 9.4(9.4.5·9.4.9 확장, 실행 확인(175차) 블록 신설)에 반영, 하네스는 tools/gap_harness/gap175/(servo_collision_test/, findings_summary.txt, install_log.txt). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 115개 그대로(9.4은 이미 실행 확인 장), 미확인 표시는 857건 그대로. 확인하지 못한 것: 장애물 제거·회피 후 재개(복구) 동작, 정지가 트리거된 정확한 거리(스레숄드 0.02m 경계의 정밀 측정), 자기 충돌(로봇-로봇) 감속(이번엔 장면(로봇-세계) 충돌만 시험), 이 monitored_planning_scene_topic 불일치가 Servo 외 다른 보조 모니터 패턴에도 영향을 주는지(미조사, Servo 한정 발견으로만 남김).
155차 검증(2026-08-21 · CHG-200) — 9.5 신규176차 갭 점검. 175차의 top recommendation("19개 남은 실행 확인 0건 장 중 하나로 새 영역을 열라 — 9.3·9.4이 3회차 연속 다뤄졌다")에 따라 9.5(픽앤플레이스 통합)을 새로 열었다. 직전 세 회차의 헤드리스 MoveIt+panda 인프라를 그대로 재사용했다. ros-jazzy-moveit-task-constructor-core·-demo를 설치하고 공식 데모(RViz 제외 헤드리스, move_group에 ExecuteTaskSolutionCapability 탑재)로 실제 픽앤플레이스 과제를 끝까지 계획+실행시켰다(이 회차의 핵심 발견). 기본 설정으로는 "Planning succeded" 후 "Execution disabled"(계획만, 76회의 ValidateSolution 호출로 여러 후보 해 평가 확인)였으나, 설정 파일에 execute: true를 추가해 재실행하니 "Planning succeded" → "Executing solution trajectory" → 약 41초의 실제 실행 → "Execution complete"가 순서대로 출력됐고, panda_hand_controller가 실제 GripperCommand 액션 목표를 여러 차례 수신·수락했다. 결정적으로, 실행 후 /get_planning_scene으로 물체의 최종 위치를 직접 측정했다 — 원통 물체가 원래 픽 위치(0.5, -0.25, 0.0)에서 배치 설정값(place_pose: 0.6, -0.15, 0.0)과 정확히 일치하는 (0.600, -0.150, 0.125)로 실제 이동했음을 확인했다 — 로그 메시지가 아니라 물리적 위치 변화로 확인한 것이다. 부수적으로 moveit_grasps(책이 명시한 패키지명)는 Jazzy 표준 apt에 없음을 확인했다(대신 grasping_msgs·simple_grasping은 존재) — 정직한 부분 부정. 9.5(주 반영처, 신규 실행 확인 장 — 9.5.1 서두 정정 포인터, 9.5.7 실행 확인(176차) 블록 신설 + 시각화·성숙도 마커 정정)에 반영, 하네스는 tools/gap_harness/gap176/(mtc_test/·pkg_check/, findings_summary.txt, install_log.txt). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 115→116(9.5이 처음으로 실행 확인 장이 됨), 미확인 표시는 857건 그대로(닫힌 다섯 마커는 원문 보존 방식이라 문구가 남았다). 확인하지 못한 것: computeCartesianPath() C++ API를 별도 노드로 격리 측정하지 못함(MTC 내부 접근·이탈 단계로 간접 확인만), moveit_py의 PlanningComponent에 compute_cartesian_path가 없음을 확인했으나 더 깊이 조사하지 않음, allow_stalling·그리퍼 파지 확인 로직 자체는 격리 시험하지 않음, 인지·캘리브레이션 정밀도 예산(9.5.9)은 실제 카메라가 없어 미시도.
156차 검증(2026-08-21 · CHG-201) — 9.5.4 확장177차 갭 점검. 176차의 top recommendation "③ computeCartesianPath()를 전용 C++ 노드로 직접 시험 — 176차가 MTC 내부 단계로만 간접 확인했던 것을 저비용으로 마무리"를 채택했다. 이 투자 전체에서 처음으로 moveit_ros_planning_interface를 직접 링크한 독립 C++ 노드를 작성·컴파일했다(그동안은 rclpy 프로브나 패키지 데모 실행 파일만 사용). 컴파일 시점 발견 — 책의 정확한 4-인자 computeCartesianPath(waypoints, eef_step, jump_threshold, traj) 시그니처가 Jazzy 2.12.4에서 "Drop jump_threshold" 폐기 경고와 함께 컴파일됨(에러 아님, 여전히 동작). 시나리오 A(도달 가능, 현재 자세에서 곧장 아래로 5cm): fraction = 1.000000(정확히 1.0), execute() 성공(결과 코드 1=SUCCESS, 9.2.12의 "FAILURE=99999만 양수"와 교차 확인). 실행 전후 실제 자세를 측정하니 z가 0.590 → 정확히 0.540으로, 명령한 5cm 하강과 정확히 일치 — 로그가 아니라 물리적 이동으로 확인했다. 시나리오 B(도달 불가, 현재 자세에서 x로 +3m — panda 팔 길이 약 0.85m를 훨씬 초과): fraction = 0.570715(57%), 책의 fraction < 0.99 분기가 정확히 발동해 "접근 경로 57% — 이 파지 후보 포기"가 그대로 출력됨 — 책의 경고 문구·조건·포기 로직 전부가 실측과 정확히 일치했다. 9.5(9.5.4 확장, 실행 확인(177차) 블록 신설)에 반영, 하네스는 tools/gap_harness/gap177/(cartesian_test/, findings_summary.txt, install_log.txt). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 116개 그대로(9.5은 이미 실행 확인 장), 미확인 표시는 857건 그대로. 확인하지 못한 것: moveit_py의 compute_cartesian_path 부재는 이번에도 더 조사하지 않음, avoid_collisions 플래그·MoveItErrorCodes* out-파라미터 등 다른 인자 조합은 시도하지 않음, 9.5.6·9.5.9는 여전히 이월.
157차 검증(2026-08-21 · CHG-202) — 12.4 신규178차 갭 점검. 177차의 top recommendation("18개 남은 실행 확인 0건 장 중 하나로 새 영역을 열라 — 9.5이 두 회차 다뤄졌다")에 따라 12.4(엔드투엔드 지연 예산 설계)를 새로 열었다. MoveIt 없이 순수 rclcpp만으로 두 핵심 실행 가능 주장을 컴파일·실행했다(156차의 재사용 가능한 C++ 노드 빌드 패턴을 여기서도 활용). ① 12.4.6 콜백 그룹 격리 패턴 — 20ms 긴급 타이머와 100ms마다 150ms씩 잠드는 부하가 큰 콜백을 같은 노드에 뒀다. 격리 없이 하나의 실행기가 둘 다 처리하니 긴급 타이머 평균 주기가 요청한 20ms가 아니라 221.76ms(최대 302.90ms)까지 급증했다. 책의 코드 그대로 전용 MutuallyExclusive 그룹+전용 실행기(별도 스레드)로 격리하니 정확히 20.00ms(지터 1ms 이내)로 완전히 회복됨 — 약 11배 개선을 실측했다. ②(핵심 발견) 12.4.5 주기 구동의 "절반 지연" 주장을 실측하다가 중요한 미묘함을 발견했다. 처음엔 발행 20ms·폴링 100ms로 시험했는데 측정된 추가 지연이 11.0ms로 "폴링 주기의 절반"(50ms)과 전혀 안 맞았다. 원인 추적 결과 — 도착이 폴링보다 훨씬 잦으면 추가 지연은 폴링 주기가 아니라 도착 주기의 절반에 수렴한다(측정 11.0ms ≈ 예측 "도착 주기의 절반" 10.0ms). 반대로 도착을 300ms·폴링을 20ms로 뒤집으니 추가 지연이 10.4ms로 이번엔 "폴링 주기의 절반"(10ms)과 정확히 일치했다. "평균 주기의 절반 추가"는 참이지만, 그 주기는 폴링 자신이 아니라 폴링·도착 중 더 드문(긴) 쪽이라는 것을 두 실험으로 정밀하게 확인했다 — 센서가 처리 루프보다 빠르게 발행하는 흔한 구성에서는 책의 문자 그대로의 "폴링 주기의 절반" 표현이 오도할 수 있음을 발견했다. 12.4(주 반영처, 신규 실행 확인 장 — 12.4.1 서두 정정 포인터, 12.4.5·12.4.6 실행 확인(178차) 블록 신설)에 반영, 하네스는 tools/gap_harness/gap178/(isolation_test/·periodic_test/, findings_summary.txt). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 116→117(12.4가 처음으로 실행 확인 장이 됨), 미확인 표시는 857건 그대로(이 장은 원래 미확인 표시 마커가 없었다). 확인하지 못한 것: 12.4.2의 정지 거리 식(순수 물리 공식, 검증 대상 아님), 12.4.4의 message_filters 근사 동기화 지연 특성(미시도), 12.4.7의 QoS 깊이·신뢰성 지연-처리량 절충 직접 측정(미시도, 앞선 QoS 회차들로 부분 커버).
158차 검증(2026-08-21 · CHG-203) — 12.4.4 확장179차 갭 점검. 178차의 top recommendation "② message_filters의 근사 동기화 '가장 늦은 센서를 기다린다' 주장(12.4.4) — 저비용, 직전 회차의 순수 rclcpp 인프라를 그대로 재사용"을 채택했다. message_filters::Synchronizer<ApproximateTime<Imu,Imu>>로 10 Hz "라이다"와 30 Hz "카메라" 발행자를 실제로 동기화해 정확히 이 조합(책의 예시 그대로)으로 재현했다. 측정된 동기화 콜백 속도는 정확히 10.1 Hz(평균 간격 99.46ms)로, 30 Hz(카메라) 예측이 아니라 10 Hz(라이다) 예측과 거의 정확히 일치했다. 라이다 메시지는 100%가 매칭에 쓰였지만 카메라 메시지는 33.4%만 쓰였다(발행 비율 1:3과 정확히 일치 — 카메라 메시지의 약 3분의 2는 구조적으로 버려진다). 매칭된 쌍의 실제 시각 차는 평균 6.8ms·최대 17.5ms로 슬롭 윈도(20ms) 안에 들어 실제 매칭임을 확인했다. 컴파일 과정에서 부수적으로 API 함정을 발견했다 — Synchronizer<ApproximateTime<M0,M1>>::registerCallback()에 람다나 std::function을 직접 넘기면 이 버전(Jazzy, message_filters 4.11.17)에서 컴파일이 실패한다(wrong number of template arguments (9, should be 1) — 내부 Signal9 구현이 항상 9개 슬롯을 전제해 일반 콜러블에 대한 오버로드 해석이 깨진다). 멤버 함수 포인터 형태(registerCallback(&Class::method, this))로 바꾸니 정상 컴파일됐다 — 책에는 없는, 실무에서 바로 부딪힐 수 있는 함정이다. 12.4(12.4.4 확장, 실행 확인(179차) 블록 신설)에 반영, 하네스는 tools/gap_harness/gap179/(sync_test/, findings_summary.txt). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 117개 그대로(12.4는 이미 실행 확인 장), 미확인 표시는 857건 그대로. 확인하지 못한 것: 정확 동기화(TimeSynchronizer)와의 비교(미시도), 3개 이상 입력 토픽 조합(미시도), 슬롭 파라미터를 지터보다 타이트하게 좁혔을 때의 매칭률 저하(미시도).
159차 검증(2026-08-21 · CHG-204) — 14.1 신규180차 갭 점검. 179차의 top recommendation("17개 남은 실행 확인 0건 장 중 하나 — 14.1(노드 분할 설계)이 저위험 후보로 특별히 지목됨")에 따라 14.1(노드 분할 설계 경계 긋기)을 새로 열었다. 이 장은 대부분 설계 판단·트레이드오프를 다루는 견해 중심 장이라 독립 검증 가능한 기술 주장이 정확히 하나였다 — 14.1.7의 "논리적 분할은 자유롭게, 물리적 배치는 나중에". rclcpp_components_register_node()로 컴포넌트 둘(발행자·구독자)을 등록했는데, 이 CMake 매크로 자체가 같은 컴파일 결과물에서 컨테이너 로드용 플러그인과 독립 실행 파일을 둘 다 자동 생성함을 ros2 pkg executables·ros2 component types로 실측 확인했다(소스 복제 없음). 배치 A(독립 프로세스 둘): 두 번 반복 측정(웜업 2초·6초)한 결과 평균 1302~2088µs·p50 174~180µs·p99 60,626~73,272µs·최대 90,511~283,131µs로, 드물지만 거대한 지연 스파이크가 두 번 모두 재현됐다(웜업을 늘려도 안 줄어 디스커버리 냉시작 가설은 반증, 근본 원인은 미규명). 배치 B(같은 컨테이너, use_intra_process_comms=True, 소스 변경 없이 launch만 교체): 평균 101µs·p50 97µs·p99 194µs·최대 605µs — 모든 지표에서 개선, 특히 거대한 꼬리 스파이크가 완전히 사라짐. 정직하게 덧붙였다 — 평균은 약 13배, p99는 약 310~370배, 최대는 약 150~470배 개선됐지만 전형적인 경우(p50)의 차이는 약 1.8배로 훨씬 작다 — 컨테이너의 실제 이점은 "전형적으로 300배 빠름"이 아니라 "드문 거대 지연 스파이크 제거"이며, 평균값이 꼬리에 끌려 전형적 이득을 과장하고 있었다는 것을 정직히 밝혔다(11.7.3의 "평균이 아니라 꼬리를 보라"가 여기선 반대 방향으로도 적용됨). 14.1(주 반영처, 신규 실행 확인 장 — 14.1.1 서두 정정 포인터, 14.1.7 실행 확인(180차) 블록 신설)에 반영, 하네스는 tools/gap_harness/gap180/(boundary_test/, findings_summary.txt). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 117→118(14.1이 처음으로 실행 확인 장이 됨), 미확인 표시는 857건 그대로(이 장은 원래 미확인 표시 마커가 없었다). 확인하지 못한 것: 배치 A 꼬리 스파이크의 근본 원인(SHM 버퍼 경합·OS 스케줄링·DDS 큐 등 후보만 나열, 미규명), 조직·안전 감시 격리·보안 enclave 병합 등 나머지 14.1 주장(설계 판단이라 검증 대상 아님), 토픽 개수가 경계 신호가 된다는 휴리스틱(미측정).
160차 검증(2026-08-22 · CHG-205) — 10.3 신규181차 갭 점검. 180차의 top recommendation("16개 남은 실행 확인 0건 장 중 하나")에 따라 10.3(센서 시뮬레이션과 플러그인)을 새로 열었다 — 10.2의 기존 서술이 "시뮬레이터 구동은 GPU·디스플레이가 없어 미검증"이라 남겨 둔 것을, IMU가 상태 기반 센서라 렌더링이 필요 없다는 이 장 자신의 서술(10.2.8)에서 착안해 재도전했다. 핵심 발견 — 렌더링 시스템 플러그인(gz-sim-sensors-system)을 처음부터 빼고 물리+Imu 시스템만 넣은 최소 SDF 월드로 Gazebo Harmonic 8.11.0을 헤드리스(gz sim -s -r) 구동하니 GPU 없이 완전히 성공했다 — DART 물리 엔진 정상 로드, /imu 토픽 실재 확인. 이전 회차(90차)의 GPU 제약은 카메라·라이다 등 렌더링 필요 센서에 한정된 것이었음이 밝혀졌다. ① IMU 중력 포함 여부(10.3.8): 낙하한 상자가 착지해 정지한 뒤 linear_acceleration.z = 9.8000(3개 연속 샘플)을 확인해 기본 <imu> 설정이 중력을 포함함을 실측(책의 "z≈9.81"과 0.1% 이내 일치 — gz-sim 기본 중력 상수는 9.8). ② 낙하 물리 정밀 확인(부수 발견): t=0부터 재캡처하니 자유낙하 구간(t=0.10~0.42초)엔 z=0.0000(정확한 무중력 가속도계 물리), 착지 시점(t≈0.44초)에 정확히 9.8000으로 전환 — 0.9m 낙하의 이론적 자유낙하 시간(0.4286초)과 시뮬레이션 스텝 오차 이내로 일치, 물리 엔진이 실제 동역학을 계산함을 재확인. ③ 접촉 센서 메시지 타입(10.3.2): 책이 추측한 ContactsState계열이 아니라 실제로는 ros_gz_interfaces/msg/Contacts(복수형, Contact[] 포함)임을 ros2 interface list·show로 확인·정정. 10.3(주 반영처, 신규 실행 확인 장 — 10.3.1 서두 정정 포인터, 접촉 센서·IMU 중력 마커 정정, 실행 확인(181차) 블록 2개 신설)에 반영, 하네스는 tools/gap_harness/gap181/(imu_sim_test/, findings_summary.txt). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 118→119(10.3이 처음으로 실행 확인 장이 됨), 미확인 표시는 857건 그대로(닫힌 세 마커는 원문 보존 방식이라 문구가 남았다). 확인하지 못한 것: 카메라·라이다 등 렌더링 필요 센서는 이번에도 시도하지 않음(GPU 제약이 실제로 적용되는지는 여전히 미검증), always_on:false 동작(10.3.5), optical_frame_id(카메라 전용, 10.3.3), URDF+xacro <gazebo> 확장 태그를 통한 frame_id 불일치(10.3.6, 이번엔 순수 SDF 월드로 시험), IMU 바이어스 모델링(미시도).
161차 검증(2026-08-22 · CHG-206) — 10.3 확장182차 갭 점검. 181차의 top recommendation "② 10.3의 남은 비-렌더링 항목(always_on:false·IMU 바이어스) — 저비용, 이번 회차가 확립한 '렌더링 플러그인 제외' 인프라를 그대로 재사용"을 채택했다. ① IMU 바이어스 모델링(10.3.3) — 깔끔하게 확인. 책의 노이즈 블록 패턴 그대로(mean·stddev·bias_mean·bias_stddev, 가속도·각속도 세 축) bias_mean을 가속도 0.3·각속도 0.05로 설정한 월드와 bias_mean=0인 대조 월드를 각 200개 표본으로 비교하니, 바이어스 있는 쪽의 평균 오프셋이 가속도 3축 모두 약 ±0.300, 각속도 3축 모두 약 ±0.050으로 설정값과 1% 이내로 정확히 일치했다(노이즈 표준편차로는 설명 안 되는 크기 — 실제 지속적 바이어스). 정직하게 덧붙였다 — 축마다 부호가 다르게 나왔다(X·Z는 +, Y는 −, 가속도·각속도 동일 패턴)(→ 183차 정정: 원시 로그를 재대조하니 가속도는 X+·Y−·Z−, 각속도는 X+·Y−·Z+로 Z축에서만 갈린다 — "둘 다 동일한 패턴"은 오독이었다. 이상 현상 자체와 원인 미상은 유효) — 무작위로 설명되지 않는 재현 가능한 패턴이지만 원인은 조사하지 않고 미상으로 남겼다. ② always_on:false(10.3.5) — 정직하게 결론을 내지 못했다. 구독자를 전혀 연결하지 않은 채 10초씩 always_on=true·false 월드를 실행해 /proc/<pid>/stat으로 CPU 사용량을 비교하니 3.28 대 3.20 CPU초(약 2.4% 차이)로 유의미한 차이를 보지 못했다 — IMU 자체가 "거의 무비용"이라 매 스텝 실행되는 물리 계산이 CPU 사용량을 지배했을 가능성이 크고, 이 설계로는 센서 고유의 절약 효과를 분리하지 못했다. 첫 구독 연결 시점의 메시지 타이밍도 두 경우가 비슷해 뚜렷한 차이를 못 찾았다. 경량 상태 기반 센서에서는 이 설정의 효과를 명확히 관측하지 못했다는 것을 정직하게 보고한다 — 렌더링 필요 센서에서는 다를 수 있으나 이번엔 시도하지 않았다(새 미확인 표시로 이월). 10.3(10.3.3·10.3.5 확장, 실행 확인(182차) 블록 2개 신설)에 반영, 하네스는 tools/gap_harness/gap182/(bias_test/·always_on_test/, findings_summary.txt). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 119개 그대로(10.3은 이미 실행 확인 장), 미확인 표시는 857→858건(카메라·라이다 렌더링 센서에서의 always_on 재시험이라는 새 항목 하나가 정직하게 추가됨 — 닫힌 것은 없음). 확인하지 못한 것: 바이어스의 축별 부호 패턴 원인(미조사), always_on의 렌더링 센서(카메라·라이다)에서의 효과(미시도), CPU 비교의 반복 시행을 통한 잡음 감소(미시도).
162차 검증(2026-08-22 · CHG-207) — 10.2·10.3 확장 + 161차 정정183차 갭 점검. 먼저 정정부터 처리했다 — 코디네이터가 161차(182차 갭 점검) 자신의 IMU 바이어스 축별 부호 서술("X·Z는 +, Y는 −, 가속도·각속도 둘 다 동일한 패턴")을 원시 로그로 재계산해 틀렸음을 발견했다: 실제로는 가속도 X+·Y−·Z−, 각속도 X+·Y−·Z+로 Z축에서만 갈린다(가속도·각속도가 "둘 다 동일"이 아니었다). 10.3(10.3.3 182차 블록)·목차 3(161차 행)·BOOK.md(161번째 회차)·CHANGES.md(CHG-206-02) 네 곳 모두 원문은 보존하고 "(→ 183차 정정: ...)" 포인터로 바로잡았다 — 이상 현상 자체와 원인 미상이라는 결론은 유효, 축별 패턴 서술만 정정. 정정 후 182차의 top recommendation "② always_on을 렌더링 센서(카메라·라이다)로 재시험"을 이어갔는데, 도중에 훨씬 큰 발견을 했다 — 카메라 센서(렌더링 필요)가 GPU도 Xvfb도 없이 완전히 동작함을 실측했다. 지면+상자+320×240 카메라(10Hz) 월드에 렌더링 시스템(gz-sim-sensors-system, 181차가 일부러 뺐던 바로 그것)을 포함해 gz sim -s -r(추가 플래그 없음)로 실행하니, 로그에 "Unable to open display... Trying headless mode"가 출력되고 아무 문제 없이 계속 진행됐다 — Ogre2가 디스플레이 부재를 스스로 감지해 소프트웨어 렌더링(Mesa/EGL)으로 자동 전환한 것이다. /camera_info가 설정한 FOV·해상도로부터 정확히 계산된 내부 파라미터($f_x=f_y=277.19$, $c_x=160$, $c_y=120$)를 발행했고, /camera엔 step=960(=320×3, R8G8B8과 일치)의 실제 픽셀 데이터(메시지 921,849바이트)가 흘렀다. RTF는 ~1.00으로 책의 "GPU 없으면 매우 느려진다"는 서술과 이 간단한 장면에서는 맞지 않았다(장면 복잡도에 따라 달라질 수 있어 일반화하지 않음). 10.2(2026-08-16 실행 확인)·10.3(181차)이 남긴 "GPU 없이는 렌더링 센서 미검증"이라는 한계가 이걸로 마저 닫혔다. always_on을 카메라로 재시험한 결과는 또 정직하게 미해결 — true 3.33 대 false 3.27 CPU초(약 1.8% 차이)로 IMU 때(2.4%)와 비슷하게 잡음 범위였다(Ogre2 렌더 컨텍스트 초기화가 always_on·구독자 여부와 무관하게 시스템 로드 시 이미 일어난다는 것을 로그로 확인 — 매 프레임 렌더 호출의 게이팅 효과가 있더라도 이 간단한 장면·10초 창으로는 검출하지 못했을 가능성). 10.2(10.2.1·10.2.9 확장, 실행 확인(183차) 블록 신설)·10.3(10.3.1·10.3.5 확장, 실행 확인(183차) 블록 신설, 182차가 연 미확인 마커를 이 실측으로 닫음)에 반영, 하네스는 tools/gap_harness/gap183/(camera_test/, findings_summary.txt). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 119개 그대로(10.2·10.3 둘 다 이미 실행 확인 장), 미확인 표시는 858건 그대로(닫힌 마커는 원문 보존 방식이라 문구가 남았고, 새로 연 미확인은 없다). 확인하지 못한 것: 더 복잡한 장면(질감·고해상도·다중 카메라)에서 소프트웨어 렌더링의 RTF 저하 여부(미시도), GPU 라이다(gpu_lidar) 자체는 이번에도 시도하지 않음(카메라와 같은 렌더링 경로일 가능성이 크나 독립 확인 안 함), always_on이 더 복잡한 장면에서 신호를 보이는지(미시도), 159차의 배치 A 꼬리 지연 근본 원인(이번 회차엔 시간·토큰을 정정과 카메라 발견에 씀).
163차 검증(2026-08-22 · CHG-208) — 10.2·10.3 확장(GPU 라이다)184차 갭 점검. 162차의 top recommendation "② GPU 라이다(gpu_lidar) 시험 — 저비용, '렌더링은 GPU 없이 된다'는 발견이 카메라 밖으로도 일반화되는지 확인"을 채택했다. 162차의 카메라 발견은 라이다까지 이어지지 않았다 — 이번 회차의 핵심은 정직한 부정적 발견이다. 카메라와 같은 헤드리스·소프트웨어 렌더링 조건에서 gpu_lidar 센서를 두 가지 독립된 배치로 시험했다: ① 360포인트 스캔·벽까지 2m, ② 8포인트 스캔·벽까지 0.5m(가로 10m·높이 3m 대형 벽, 지오메트리상 놓칠 수 없는 배치). 두 경우 모두 /scan 토픽은 정상 발행됐지만 전체 거리값이 inf(무응답)였다(360/360, 8/8) — gz model --list로 벽 모델이 실제로 로드돼 있음을 확인했음에도 레이가 아무것도 맞히지 못했다. 로그에 라이다 관련 오류·경고가 전혀 없어 근본 원인은 확인하지 못했다(추정: GPU 컴퓨트/셰이더 기반 레이 렌더링을 llvmpipe 소프트웨어 래스터라이저가 지원하지 못할 가능성 — 미확인). 부수 발견: 구세대 CPU 표기인 type="lidar"도 시험했는데, 이번엔 GPU와 무관하게 엔진이 [Wrn] Sensor type LIDAR not supported yet. Try using a GPU LIDAR instead. 경고를 내며 센서 자체를 만들지 않았다 — /scan 토픽이 전혀 생기지 않았다(Gazebo Harmonic 8.11.0이 type="lidar"를 아직 구현하지 않은 것, 10.2.2 표가 신세대 표기 예로 gpu_lidar만 든 것과 정합적). 정직한 결론: "렌더링 기반 센서는 GPU 없이도 동작한다"(162차)는 카메라에 한정한다 — 같은 조건에서 gpu_lidar는 시스템은 기동해도 실제 거리 데이터를 만들지 못했다. 10.3(10.3.1·10.3.2 확장, 실행 확인(184차) 블록 신설)·10.2(2026-08-16 실행확인·10.2.9 확장, 실행 확인(184차) 블록 신설)에 반영, 하네스는 tools/gap_harness/gap184/(lidar_test/, findings_summary.txt). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 119개 그대로(10.2·10.3 둘 다 이미 실행 확인 장), 미확인 표시는 858건 그대로(이번 회차는 기존 문장에 정정·확장 포인터만 덧붙였고 새 미확인 마커를 열거나 닫지 않았다). 정직하게 밝히는 절차상 흠: Test 1·2의 백그라운드 실행 로그(lidar_launch.log·lidar_launch_v2.log)는 세션 중 확인했으나(라이다 관련 오류 없음을 확인) 컨테이너 제거 전 호스트로 복사해 두지 않아 유실됐다 — 책의 원본 인용은 실제로 남아 있는 파일만 가리킨다. 확인하지 못한 것: gpu_lidar all-inf의 근본 원인(로그가 조용해 GPU/렌더링 파이프라인 내부를 직접 관측하지 못함), 다른 해상도·자세·Gazebo/Mesa 버전에서도 재현되는지(미시도), type="lidar"의 첫 백그라운드 시도가 로그 0바이트였던 정확한 메커니즘(버퍼링 추정, 미확인), 159차의 배치 A 꼬리 지연 근본 원인(계속 이월).
164차 검증(2026-08-22 · CHG-209) — 14.1 확장(159차 꼬리 지연 근본 원인 규명)185차 갭 점검. 163차의 top recommendation ③을 채택했다 — 159차(180차 갭 점검)가 배치 A(독립 프로세스)에서 관측한 거대한 꼬리 지연 스파이크(p99 60,626~73,272µs)의 근본 원인을, 161~163차 세 회차 연속 유예 끝에 정면으로 조사했다. 원인을 찾았다. 같은 발행자·구독자 설계를 재사용하되 매 메시지의 원시 지연을 CSV로 저장하고 표본을 400→3000개로 늘리니, 구독자가 받은 첫 9개 메시지의 지연이 정확히 10ms(발행 주기)씩 줄어드는 계단 형태(90732→10817µs)로 나타났고 이 9개의 실제 수신 시각은 단 99마이크로초 안에 몰려 있었다 — 구독자 프로세스가 새로 시작해 발행자와 디스커버리·매칭을 마치는 데 걸린 약 90ms 동안 발행자의 KEEP_LAST(10) 쓰기 이력에 쌓인 메시지들이 매칭 완료 순간 한꺼번에 전달된 것이다. 두 번째 독립 실행에서도 거의 동일한 계단(최대 90659µs, 0.1% 이내 일치)이 재현돼 결정론적 메커니즘임을 확인했다. RMW를 Cyclone DDS로 바꾸니 이 계단 전체가 사라졌다(최대 3145µs, Fast DDS의 약 29분의 1) — 이 초대형 꼬리가 Fast DDS 특유의 디스커버리·매칭·이력 재전송 현상이지 ROS 2·OS·컨테이너 일반의 문제가 아님을 시사한다(내부를 직접 추적하진 않아 추론 수준). 159차의 "웜업을 늘려도 안 줄었다"는 관찰도 이걸로 설명된다 — 159차는 발행자의 웜업을 늘렸지만 계단을 만드는 변수는 구독자 자신의 매칭 시간이라 무관했던 것이다. 정직하게 덧붙였다: 두 RMW 모두에서 정상 구간에도 0.5~3ms대의 더 작은 지연 스파이크가 산발적으로 나타났다 — 계단 메커니즘과는 별개의 미확인 현상으로, 이번엔 원인을 조사하지 않고 새 미확인 항목으로 남겼다. 14.1(14.1.1 서두 포인터 확장, 14.1.7의 180차 블록에 정정 포인터 + 실행 확인(185차) 블록 신설)에 반영, 하네스는 tools/gap_harness/gap185/(tail_latency_test/, findings_summary.txt). 컨테이너 정리 전 모든 로그·CSV를 호스트로 복사해 두는 것을 163차의 교훈대로 철저히 지켰다. tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 119개 그대로(14.1은 이미 실행 확인 장), 미확인 표시는 858→859건(정상 구간의 작은 지연 스파이크라는 새 미확인 항목 하나가 정직하게 추가됨 — 닫힌 것은 없음). 확인하지 못한 것: 정상 구간의 0.5~3ms대 산발적 스파이크의 원인(미조사, 새로 이월), Fast DDS 내부의 정확한 디스커버리·매칭 타이밍 메커니즘(패킷 캡처·디버그 로그 미사용, 추론 수준), QoS 이력 깊이를 바꾸면 계단 크기가 달라지는지(미시도).
165차 검증(2026-08-22 · CHG-210) — 14.1 확장(QoS 깊이 스윕으로 인과 확증)186차 갭 점검. 164차의 top recommendation ③(QoS 이력 깊이 스윕)을 채택했다 — 164차가 "설득력 있는 상관관계"로 남긴 계단 메커니즘 설명을 "입증된 인과관계"로 격상시키는 저비용·고가치 시험이었다. 발행자에 qos_depth 파라미터를 추가해 깊이 1·3·5·10·20을 매번 새로 매칭시켜 가며 시험했다. 결과가 거의 완벽하게 선형이었다 — 5000µs 넘는 돌출 메시지 개수가 깊이 1·3·5·10·20에서 각각 0·2·4·9·18개(깊이 3·5·10에서는 정확히 깊이−1), 최대 지연은 각각 243.5·20505.4·40566.7·90624.8·175999.0µs로 (깊이−1)×10ms 예측과 깊이 10 이하에서 오차 2.5% 이내로 정확히 일치했다(깊이 20만 예측보다 7.4% 낮았는데, 계단 마지막 구간만 유독 짧아(~6.2ms, 나머지는 전부 ~10ms) 매칭 시점이 발행 주기 중간에 걸렸을 가능성으로 추정하나 반복 확인은 안 함). 깊이를 바꾸면 계단이 예측대로 정확히 바뀌므로, 164차의 "캐시 플러시" 설명이 상관관계를 넘어 인과적으로 확증됐다. 그리고 이 과정에서 164차 서술의 정밀도도 한 단계 높였다 — "디스커버리가 약 90ms 걸렸다"는 표현은 사실 깊이 10인 캐시가 채워지는 데 걸리는 시간((10−1)×10ms=90ms)이 관측값과 우연히 일치한 것일 뿐이었다: 발행자가 구독자보다 3초 넘게 먼저 실행 중이어서 어떤 깊이를 쓰든 캐시는 매칭 훨씬 전에 이미 가득 차 있었으므로, 이 방법으로는 실제 디스커버리 시간의 하한선만 알 수 있지 정확한 값은 측정할 수 없다는 것을 정직히 밝혔다. 14.1(14.1.1 서두 포인터 확장, 14.1.7의 185차 블록에 정정 포인터 + 실행 확인(186차) 블록 신설)에 반영, 하네스는 tools/gap_harness/gap186/(qos_depth_sweep/, findings_summary.txt). 컨테이너 정리 전 모든 로그·CSV를 호스트로 복사해 두는 관행을 이번에도 지켰다. tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 119개 그대로(14.1은 이미 실행 확인 장), 미확인 표시는 859건 그대로(이번 회차는 정정·확장 포인터만 추가, 새 마커를 열거나 닫지 않았다). 확인하지 못한 것: 깊이 20의 7.4% 편차 원인(추정만 하고 반복 실행으로 재확인 안 함), 실제 디스커버리·매칭 소요 시간의 정확한 값(하한선만 확인), Cyclone DDS에서도 깊이 스윕을 재현했는지(미시도, 이미 계단이 없어 효과가 없을 것으로 추정되나 실측 안 함), 웜업 시간을 깊이보다 짧게 줄이면 캐시가 덜 채워진 채로 매칭되는지(미시도).
166차 검증(2026-08-22 · CHG-211) — 14.1 마무리(깊이20 반복) + 12.6 신규(빌드 타입)187차 갭 점검. 두 부분으로 나눠 진행했다. ① 165차의 top recommendation ③(저비용 마무리) — 깊이 20의 7.4% 편차가 우연인지 재확인하려 같은 조건으로 5회 더 독립 반복했다. 결과: 6회(이번 5회+165차 원본 1회) 전부 돌출 메시지 18개(예측 19개 아님), 최대 지연 175,702~182,429µs(평균 179,200.5µs, 예측 190,000µs 대비 평균 −5.7%)로 방향·크기가 일관됐다 — 무작위 정렬이었다면 약 절반은 19개에 가깝게 나왔어야 하므로, 165차의 "우연"이라는 추정을 반증하고 깊이 20(적어도 이만큼 큰 깊이)에서 재현되는 체계적 현상임을 확정했다(정확한 메커니즘은 이번에도 미상). ② 165차의 top recommendation ①(새 영역) — 15개 남은 실행 확인 0건 장 중 12.6(성능 안티패턴 카탈로그)을 열었다. 이 장은 대부분 다른 장을 가리키는 색인이라(그 장들은 이미 개별 검증됨) 새로 측정할 것이 적었지만, 장 스스로 "성능 수치는 제시하지 않는다"고 선언한 항목 하나(12.6.8의 9·10번, 빌드 타입)를 골라 실측했다. CPU 위주 350×350 순수 삼중 루프 행렬곱 벤치마크를 CMAKE_BUILD_TYPE 미지정·Debug·Release로 별도 워크스페이스에서 각각 빌드해 실제 컴파일 플래그를 flags.make로 직접 확인하니 — 미지정은 -std=gnu++17뿐(최적화 플래그 전무), Debug는 -g -std=gnu++17(역시 최적화 없음), Release는 -O3 -DNDEBUG -std=gnu++17 — 미지정이 사실상 Debug와 동일한 컴파일 결과물임을 소스로 확인했다(4.2.10·4.5.13의 함정과 정합). 실행 결과: 미지정 301.796ms, Debug 299.717ms(0.7% 이내로 사실상 동일), Release 47.730ms — Release가 약 6.3배 빠름. 이 수치는 이 특정 CPU 위주 벤치마크에 한정되며 일반화하지 않는다고 명시했다. 14.1(14.1.1·14.1.7 확장, 실행 확인(187차) 블록 신설)·12.6(12.6.1·12.6.8 확장, 신규 실행 확인 장 — 실행 확인(187차) 블록 신설)에 반영, 하네스는 tools/gap_harness/gap187/(depth20_repeat/·build_type_bench/, 각각 findings_summary.txt). 컨테이너 정리 전 모든 로그·CSV를 호스트로 복사하는 관행을 계속 지켰다. 부수적으로 목차 3.1 요약표의 "표기가 없는 장" 수가 오래전부터 133−119=14가 아니라 20으로 잘못 적혀 있던 것을 이번에 발견해 13(=133−120)으로 바로잡았다(이번 회차가 만든 오류가 아니라 이전부터 방치돼 있던 산술 오류). tools/book_check.py 23종은 편집 도중 일시적으로 "실행 확인 장 수 119→120 불일치" 2건을 냈다가(목차 3·BOOK.md의 카운터를 갱신하기 전 상태), 카운터를 120으로 갱신한 뒤 다시 0건으로 통과했다 — pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 119→120개(12.6이 처음으로 실행 확인 장이 됨), 미확인 표시는 859건 그대로(정정·확장 포인터만 추가, 새 마커 개폐 없음). 확인하지 못한 것: 깊이 20 편차의 정확한 메커니즘(체계적임은 확정했으나 원인은 미상), 다른 큰 깊이(15 등)에서도 비슷한 편차가 점진적으로 나타나는지(미시도), 빌드 타입 벤치마크를 다른 워크로드(I/O 위주 등)로 재현했을 때도 비슷한 배율이 나오는지(미시도, 일반화하지 않는다고 명시).
167차 검증(2026-08-22 · CHG-212) — 11.4 신규(RViz2 헤드리스·디스플레이 메커니즘)188차 갭 점검. 166차의 top recommendation ①에 따라 14개 남은 실행 확인 0건 장 중 11.4(RViz2와 플러그인 작성)을 열었다 — 이 장의 미확인 마커 세 개 중 둘이 구체적·독립 검증 가능했고, 특히 "GPU·디스플레이 없이 헤드리스 렌더링"이라는 이 세션의 기존 발견(162차, Gazebo 카메라)을 완전히 다른 렌더링 스택(Qt+Ogre 1.x)으로 확장 시험할 좋은 기회였다. ① RViz2 헤드리스 실행 — Gazebo와 달리 자동 전환이 안 되지만, Xvfb만 있으면 완전히 동작한다. DISPLAY 없이 rviz2를 실행하면 Qt 레이어에서부터 즉시 종료되고(xcb 플러그인 연결 실패), QT_QPA_PLATFORM=offscreen으로 Qt는 통과시켜도 이번엔 Ogre 1.x/GLX 렌더러가 "Couldn't open X display" 예외로 종료된다 — Gazebo Harmonic의 Ogre2(162차)와 달리 디스플레이 부재를 스스로 감지해 소프트웨어 렌더링으로 자동 전환하는 기능이 없다. 그러나 Xvfb로 가상 디스플레이를 주면 "OpenGl version: 4.5 (GLSL 4.5)"를 출력하며 완전히 정상 기동했고, xwd+ImageMagick으로 캡처한 화면에서 메뉴바·전체 툴바·Displays/Views 패널·실제로 격자를 그리는 3D 뷰포트·31 fps까지 실제로 작동하는 GUI를 시각적으로 확인했다. "헤드리스 렌더링이 된다"(162차)는 카메라에 이어 RViz2에도 적용되지만, 조건(Xvfb 필요)이 다르다는 것을 명확히 했다. ② RobotModel·Image 디스플레이 마커를 실물 소스로 확인·정정. apt-get source ros-jazzy-rviz-default-plugins(14.1.22)를 받아 직접 읽으니 — RobotModel은 EnumProperty에 "Topic"(기본, TRANSIENT_LOCAL QoS)과 "File" 단 둘만 등록돼 있고 파라미터 옵션은 없다(헤더의 낡은 주석만 "ros-param"을 언급). Image 디스플레이는 ImageTransportDisplay를 상속해 image_transport::SubscriberFilter를 실제로 사용하고 토픽 이름에서 전송 방식을 읽어(getTransportFromTopic()) 압축 등 트랜스포트 플러그인을 로드한다 — 책의 "image_transport 미지원"은 틀린 서술이었다. 11.4(11.4.1·11.4.2·11.4.4 확장, 신규 실행 확인 장 — 실행 확인(188차) 블록 2개 신설)에 반영, 하네스는 tools/gap_harness/gap188/(rviz_test/, findings_summary.txt). 컨테이너 정리 전 스크린샷·소스 발췌 전부 호스트로 복사했다. tools/book_check.py는 편집 도중 일시적으로 "실행 확인 장 수 120→121 불일치" 2건을 냈다가(카운터 갱신 전 상태) 갱신 후 다시 0건 통과, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 120→121개(11.4이 처음으로 실행 확인 장이 됨), 미확인 표시는 859건 그대로(정정·확장 포인터만 추가, 새 마커 개폐 없음). 확인하지 못한 것: 11.4.1의 "플러그인 API가 배포판마다 다르다"는 마커 자체(버전 간 API diff는 미시도), RobotModel의 "File" 옵션·Image의 압축 트랜스포트를 실제로 동작시켜 데이터가 표시되는지의 종단 확인(소스 검증에 그침, 실제 URDF·압축 이미지 발행 후 렌더 확인은 안 함), 이 버전(14.1.22) 외 다른 Jazzy 마이너 릴리스에서도 같은지(미시도).
168차 검증(2026-08-22 · CHG-213) — 11.4 마무리(플러그인 API 버전 의존성, Jazzy vs Humble)189차 갭 점검. 167차의 top recommendation ②를 채택했다 — 11.4.1의 "플러그인 API는 버전 의존"이라는 마지막 남은 마커를, 83·84차의 API-diff 방식(그때는 rclcpp)을 재사용해 Jazzy(rviz-common·rviz-default-plugins 14.1.22)와 Humble(11.2.28) 실물 헤더 전수 대조로 확인했다. 정량 결과: rviz_common은 공통 헤더 84개 중 17개(20.2%)가 서로 다르고(Jazzy 전용 신규 1개, regex_filter_property.hpp), rviz_default_plugins는 공통 헤더 97개 중 38개(39.2%)가 달라 변화율이 거의 두 배였다(Jazzy 전용 신규 3개 — camera_info_display.hpp는 완전히 새로운 디스플레이 타입, 나머지 둘은 PointCloud2도 Image(188차)처럼 image_transport형 전송을 얻었음을 시사). 가장 크게 바뀐 파일(ros_image_texture.hpp)을 분석해 보니 실제로 깨지는 시그니처 변경이었다 — ImageData 생성자가 Humble의 (std::string encoding, uint8_t*, size_t)(3인자)에서 Jazzy의 (Ogre::PixelFormat, uint8_t*, size_t, bool take_ownership)(4인자, 타입 자체가 문자열→열거형)로 완전히 바뀌었고, 접근자 메서드도 비-const→const로 바뀌어 Humble용 플러그인 코드는 이 API를 그대로 쓰면 Jazzy에서 컴파일이 안 된다. 반면 가장 기본이 되는 Display 기반 클래스 자체는 종료된 ROS1 코드(#if 0) 제거뿐으로 사실상 안정적이었다 — 최소 플러그인은 이식성이 높지만 내장 디스플레이 세부(이미지 텍스처·인터랙티브 마커·TF·맵)를 다루는 플러그인일수록 깨질 위험이 크다는 뜻이다. 정직한 한계: 헤더만 전수 대조했고 .cpp 구현부는 안 봤다 — 헤더가 그대로인데 구현만 바뀐 경우는 검출하지 못한다. 실제 컴파일 실패 재현은 하지 않았다(헤더 시그니처 대조만으로 판단). 11.4(11.4.1 마지막 마커 종결, 실행 확인(189차) 블록 신설)에 반영, 하네스는 tools/gap_harness/gap189/(rviz_api_diff/, findings_summary.txt). 컨테이너 정리 전 헤더 트리·diff 결과 전부 호스트로 복사했다. tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 121개 그대로(11.4은 이미 실행 확인 장), 미확인 표시는 859건 그대로(마지막 마커를 닫는 포인터만 추가, 마커 원문은 보존). 확인하지 못한 것: .cpp 구현부 변경(헤더 불변·구현만 변경된 경우는 놓쳤을 수 있음), rviz_rendering·rviz_ogre_vendor·rviz2 자체는 미대조, Jazzy·Humble 사이의 중간 배포판(Iron)이나 Jazzy 자체의 마이너 버전 드리프트는 미시도, 실제 플러그인을 두 버전에 컴파일해 실패를 직접 재현하지는 않음(헤더 대조에 근거).
169차 점검(2026-08-22 · CHG-214) — 12.6 카탈로그 정합성 점검(컨테이너 실행 없음)190차 갭 점검, 79·81·82차와 같은 유형(book-gap의 커버리지·일관성 점검, 새 실측 없음). 168차의 top recommendation ②를 채택했다 — 166·168차 두 회차 연속 미뤄졌던 "12.6 카탈로그가 인용하는 장들의 실제 내용과 정합하는가"를 전수 점검했다. 12.6이 인용하는 고유 ID 약 50여 개 중 대표 표본(이미 실행 확인된 장에 걸린 인용, 이 세션에서 새로 갱신된 장에 걸린 인용 위주)을 대조해 실제 오류 하나와 더 정확한 인용으로 바꿔야 할 것 하나를 찾았고, 잘 근거된 누락 인용 둘을 추가했다. ① 실제 오류: 12.6.7의 11번("GPU·CPU 왕복 과다")이 인용한 7.8.5는 실제로는 역압·큐 설계(드롭-if-busy)를 다루는 절이라 GPU·CPU 왕복과 무관했다 — 7.8.1이 "GPU 가속 파이프라인은 자매서 nvidia가 다룬다"고 명시적으로 범위 밖에 뒀으므로 이 책 안에는 애초에 맞는 절이 없었다(복붙 오류로 추정). ② 더 정확한 인용: 12.6.8의 4번("온보드에서 GUI")이 인용한 11.4.5(RViz2가 느려지는 원인 목록)보다, 이 항목과 정확히 일치하는 "개발 PC에서 실행 + 네트워크로 토픽(권장)" 권고표가 있는 11.4.6이 더 정확한 출처였다. ③ 근거 있는 누락 인용 추가: 12.6.3의 2번("인트라프로세스 미사용")과 12.6.6의 6번("노드를 과도하게 분할")이 2.7.5·12.4.5만 인용했는데, 이 세션이 180·185~187차에 걸쳐 확립한 14.1.7(컴포넌트 컨테이너가 별도 프로세스 대비 p99 지연을 약 310~370배 줄인다는, 이 책에서 가장 구체적인 실측 근거)이 빠져 있었다 — 12.6 두 곳과 12.4.5 자체에 추가했다. 확인 결과 문제없었던 것: 12.3.5(제로 카피)·12.4.6(실행기 격리, 178차 11배 실측과 일치)·3.9.13(무선 RELIABLE 꼬리 지연)·11.4.6(RViz2 구독 대역)·1.6.3.1(센서 BEST_EFFORT)·12.4.4(큐 깊이) 등 표본 대조한 나머지는 모두 정합적이었다. 162~184차의 Gazebo/RViz2 헤드리스 렌더링 발견은 12.6.8의 3·4번(운영 중 대역·온보드 CPU 부담이라는 다른 관심사)과 맥락이 달라 억지로 인용을 추가하지 않기로 판단했다. 12.6(12.6.1 서두 포인터 확장, 12.6.3·12.6.6·12.6.7·12.6.8 네 항목에 190차 점검 포인터 신설)·12.4(12.4.5에 14.1.7 인용 추가)에 반영. 컨테이너 실행이 없는 문서 대조 회차라 하네스 없음, 편집 전 초안에서 "실행 확인(190차)"라는 표현을 썼다가 실제로는 실행이 없었음을 깨닫고 "190차 점검"으로 전부 고쳤다(정직성 자기 점검). tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 121개 그대로(새 실행 확인 장·마커 개폐 없음, 정합성 정정·보강 포인터만 추가), 미확인 표시는 859건 그대로. 확인하지 못한 것: 나머지 약 45개 인용 ID는 전수 대조하지 않고 대표 표본만 확인했다(우선순위: 이미 실행 확인된 장, 이 세션에서 갱신된 장) — 완전한 전수 대조는 여전히 남아 있다.
170차 검증(2026-08-22 · CHG-215) — 8.8 신규(Nav2 실제 기동·내비게이션)191차 갭 점검. 169차의 top recommendation ①을 채택했다 — 13개 남은 실행 확인 0건 장 중 8.8(Nav2 튜닝·평가·현장 디버깅)를 열었다. TurtleBot3 + nav2_loopback_sim(Jazzy 공식 경량 시뮬레이터, 물리 엔진 없이 명령을 되돌려주는 방식)으로 실제 Nav2 스택을 기동하고 실제 내비게이션 목표를 보냈다. 과정에서 세 단계의 실패를 실제로 겪고 원인을 규명했다. ① nav2_loopback_sim 패키지가 nav2-bringup에 포함되지 않아 첫 런치가 PackageNotFoundError로 실패 — 별도 설치(ros-jazzy-nav2-loopback-sim)로 해결. ② 설치 후에도 global_costmap이 "transform from base_link to map did not become available"로 부트업이 영구 실패 — 소스(loopback_simulator.py)를 직접 읽어 이 시뮬레이터가 RViz2의 "2D Pose Estimate"와 같은 /initialpose 메시지를 받기 전까진 map→odom tf를 전혀 안 낸다는 것을 확인했다. 헤드리스(RViz2 없이) 실행하면 이 메시지가 영원히 안 오고, 부트업 타임아웃(약 10초)이 지나면 재시도 없이 영구 실패한다(실패 20초 뒤 뒤늦게 /initialpose를 보내도 복구 안 됨을 직접 확인). 런치와 동시에 /initialpose를 5 Hz로 반복 발행하는 프로세스를 같이 기동해 해결했다. ③ 부트업 성공 후 임의로 고른 초기 위치(0,0)에서 목표를 보내니 "GridBased plugin failed to plan ... Failed to create plan with tolerance of: 0.500000"가 반복되며 15회 복구 끝에 ABORTED(error_code: 208) — 8.8.3.1 흐름도의 "플래너 실패" 갈래를 정확히 재현했다. 원인은 (0,0)이 지도상 벽 근처였을 가능성 — nav2_minimal_tb3_sim의 spawn_tb3.launch.py가 정의한 실제 기본 스폰 위치(x_pose=-2.00, y_pose=-0.50)를 확인하지 않은 것이 실수였다. 올바른 시작 위치로 재시도하니 계획이 즉시 성공했고, 로봇이 실제로 목표를 향해 이동해 5회의 컨트롤러 복구("Failed to make progress") 끝에 55.1초 만에 SUCCEEDED(error_code: 0)로 도착했다 — 실패와 성공 양쪽 다 흐름도의 예측과 정확히 일치했다. 부수 확인: `ros2 action listgrep navigate·/plan·/cmd_vel·/cmd_vel_smoothed는 문서 그대로 동작했지만, ros2 control list_controllers는 ros2controlcli 패키지 미설치로 곧바로 실패했고 /controller_manager 노드 자체가 없어 표준 시뮬레이션 TurtleBot3에는 이 진단 단계가 애초에 안 맞았다. /particle_cloud·/amcl_pose도 이 경량 시뮬레이터가 측위를 대체해 존재하지 않아, 8.8.4의 AMCL 관련 표가 이 배치엔 적용 안 됨을 확인했다. 8.8(8.8.1 서두 포인터, 8.8.3.1의 흐름도·명령·ros2 control 캐비엇, 8.8.4의 particle_cloud 캐비엇 — 신규 실행 확인 장, 실행 확인(191차) 블록 4개 신설)에 반영, 하네스는 tools/gap_harness/gap190/(nav2_test/, findings_summary.txt). 컨테이너 정리 전 모든 로그를 호스트로 복사했다(일부 대용량 — 반복 발행·--feedback 스트리밍으로 최대 2.6MB). tools/book_check.py는 편집 도중 일시적으로 "실행 확인 장 수 121→122 불일치" 2건을 냈다가 카운터 갱신 후 재통과, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 121→**122개**(8.8가 처음으로 실행 확인 장이 됨), 미확인 표시는 859건 그대로. 확인하지 못한 것: /collision_monitor_state가 이번엔 상태 전이가 없어 메시지를 못 받음(버그인지 정상인지 미확정), 두 번의 "Failed to make progress"의 정확한 지점·원인(미조사), 물리 기반(Gazebo) TurtleBot3 시뮬레이션에서도 같은 /initialpose` 타이밍 문제·같은 항법 결과가 나오는지(미시도, AMCL이 실제로 돌아 그림이 달라질 수 있음).
171차 검증(2026-08-22 · CHG-216) — 9.6 신규(핸드아이 캘리브레이션 도구 유무)192차 갭 점검. 170차의 top recommendation ①을 채택했다 — 12개 남은 실행 확인 0건 장 중 9.6(매니퓰레이터 배치 실무)을 열었다. 이 장의 동일한 마커 두 개("핸드아이 캘리브레이션 도구·패키지의 Jazzy 지원은 확인이 필요하다")를 공식 ROS 2 Jazzy apt 저장소 직접 조회로 확인했다. 가장 유력한 후보 4개(ros-jazzy-easy-handeye2·ros-jazzy-moveit-calibration·ros-jazzy-handeye-calibration·ros-jazzy-visp-hand2eye-calibration, 책이 직접 언급하는 "MoveIt 캘리브레이션 플러그인" 이름 포함) 전부 apt-cache policy에서 완전히 빈 결과였다(존재하지만 미설치인 패키지라면 최소한 "Installed: (none)"·"Candidate: <버전>"이 나오는데 그마저 없어, 저장소 색인 자체에 이름이 없음을 뜻한다) — 전용 핸드아이 캘리브레이션 패키지는 Jazzy 공식 채널에 없다. 반면 조립용 부품은 실재했다 — 마커 검출 ros-jazzy-apriltag-ros(3.4.0)·ros-jazzy-aruco-ros(5.0.5), 자체 핸드아이 알고리즘을 가진 ros-jazzy-visp(3.5.0, libvisp-* 전 계열도 존재). 책이 언급한 "OpenCV의 관련 함수"도 직접 확인했다 — python3-opencv(Ubuntu Noble 표준 4.6.0)를 설치해 Python에서 hasattr(cv2, 'calibrateHandEye')가 True임을 확인했다. 정직한 결론: "MoveIt 캘리브레이션 플러그인이나 별도 패키지가 있으며"는 이 배포판에서는 아니었고, "없으면 OpenCV의 관련 함수로 직접 구현"이 정확한 현실이다. 9.6(9.6.1·9.6.4 확장 — 신규 실행 확인 장, 실행 확인(192차) 블록 신설, 두 동일 마커 모두 종결)에 반영, 하네스는 tools/gap_harness/gap191/(handeye_test/, findings_summary.txt). 컨테이너 정리 전 조회 로그를 호스트로 복사했다. tools/book_check.py는 편집 도중 일시적으로 "실행 확인 장 수 122→123 불일치" 2건을 냈다가 카운터 갱신 후 재통과, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 122→123개(9.6이 처음으로 실행 확인 장이 됨), 미확인 표시는 859건 그대로(마커 원문 보존, 포인터만 추가 — 신규 마커 개폐 없음). 확인하지 못한 것: 공식 apt 채널 외 GitHub 전용 커뮤니티 포트의 존재 여부(미확인), 실제 카메라·로봇으로 종단 캘리브레이션 수행(미시도, 도구 유무만 확인), 네 개 후보 이름 외 다른 이름의 패키지 가능성(미전수).
172차 점검(2026-08-22 · CHG-217) — 12.6 카탈로그 정합성 점검 마무리(컨테이너 실행 없음)193차 갭 점검, 169·170·171차에서 세 회차 연속 미뤄진 항목을 마침내 마무리했다 — 169차(190차 점검)가 표본(~12개)만 대조하고 남긴 나머지 인용 약 49개를 전수 대조했다. 같은 유형(잘못된 절을 가리키는 오인용)의 문제를 6곳 더 찾아 총 8개 셀을 고쳤다: ① 12.6.4의 2·3번("콜백에서 블로킹 대기"·"서비스 동기 호출" → 교착)이 가리키던 1.9.7(YAML 파일 문법, 무관)을 1.7.3("데드락 — ROS 2에서 가장 비용이 큰 실수")로 정정 — 15.3가 같은 주장에 이미 정확히 인용하고 있어 교차 확인됨. ② 12.6.3의 3번(shared_ptr 발행)이 가리키던 2.7.5(알려진 제약, 무관)를 2.7.4("복사 0의 정확한 조건" — "규칙 1번: unique_ptr로 발행하라"가 정확히 이 절에 있음)로 정정. ③ 12.6.6의 1·2번(tf 고빈도 발행·정적 변환 반복)이 가리키던 5.1.5(시간 축과 보간, 무관)를 각각 5.1.9("성능 고려")·5.1.6("정적 변환 — 왜 별도인가")로 정정. ④ 12.6.6의 3번과 12.6.7의 5번(tf·좌표 조회 반복 → 캐시)이 가리키던 5.2.3(Time(0)의 의미, 무관)를 둘 다 5.1.9("조회 결과 캐싱" 완화책이 정확히 표에 있음)로 정정. ⑤ 12.6.5의 8번(데드라인·라이브라인 남용)이 가리키던 1.6.4(큐 깊이 설계, 무관)를 1.6.6("시스템 차원의 QoS 관리", deadline() 체이닝 실사용 예 있음)으로 정정. 새로 놓친 인용은 찾지 못했다 — 이번 회차는 전부 "잘못된 절을 가리킴" 유형이었고, 확인한 나머지 약 39개(7.2.7·2.3.4·2.3.3·2.4·2.2.6·2.2.4·2.5.8·12.3.6·1.4.4·1.6.3·1.6.3.3·1.5.4·2.7.3·3.3.6·3.4·3.10.4·1.8.3·7.4.5·8.3.8·12.1.3·12.3·11.7.6·12.2.7·11.2.6·1.11.5·1.11.4·11.6.7·11.7.8·4.5.6·1.10.9·12.1.4·11.7.3·10.6.2·11.7.7 등)는 정합적이었다. 12.6(12.6.1 서두 포인터 확장, 12.6.3·12.6.4·12.6.5·12.6.6·12.6.7의 8개 셀에 193차 정정 포인터 신설)에 반영. 부수적으로 A.1의 CLI 레퍼런스 표에서도 같은 유형의 오류 둘을 발견해 함께 고쳤다 — "1.8서비스" 행이 실제로는 1.7(서비스 장은 1.7, 1.8는 액션 장)를 가리켜야 했고, "1.9.7콜백 내 동기 호출 교착" 행도 12.6과 똑같은 오인용(정확한 출처는 1.7.3)이었다. 컨테이너 실행이 없는 문서 대조 회차라 하네스 없음. tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 123개 그대로(새 실행 확인 장·마커 개폐 없음, 정합성 정정 포인터만 추가), 미확인 표시는 859건 그대로(정정 포인터만 추가, 새 마커 개폐 없음). 확인하지 못한 것: 12.6.7의 "무제한 계획 시간" 인용(9.2.8)이 정확한 하위 절까지는 못 짚음(9.2의 OMPL 관련 절 근처로 추정, 미확정), 12.6.3의 7번(7.4.4)이 7.4.7(대역 예산)이 더 나은 적합일 가능성(확신 낮아 정정 안 함), 12.6.6의 9번(1.9.6)에 더 정확한 절이 있는지 못 찾음(그대로 둠).
173차 검증(2026-08-22 · CHG-218) — 15.2 신규(REP 2000 재확인 + 차기 배포판 이름 발견)194차 갭 점검. 172차의 top recommendation ①을 채택했다 — 11개 남은 실행 확인 0건 장 중 15.2(Kilted·Rolling 대비)를 열었다. 이 장의 두 마커는 로컬 소프트웨어가 아니라 외부 실시간 인프라(REP 문서·실제 GitHub 저장소)를 가리켜, git clone으로 원본을 직접 확인하는 방식(83·84·167·168·172차의 소스 직접 대조와 같은 계열)을 썼다. ① REP 2000 재확인 — 마커가 여전히 정확했다. github.com/ros-infrastructure/rep을 클론해 rep-2000.rst의 배포판 절 제목을 순서대로 추출하니 Kilted Kaiju(2025-05~2026-11) 다음이 곧바로 Rolling Ridley로 끝났다 — 후속 LTS가 여전히 미등재였다. 이 저장소 HEAD 커밋(11ca24a)은 2025-10-24로, 그 뒤로 갱신이 없었다. ② 그런데 REP 2000 밖에서 실제 이름을 찾아냈다. Nav2(ros-navigation/navigation2)를 클론하니 "lyrical"이 네 갈래 독립 증거로 확인됐다 — CI 빌드 매트릭스 [jazzy, lyrical], 전용 의존성 파일 tools/underlay.lyrical.repos 실재, update_ci_image.yaml의 브랜치 트리거 [main, lyrical, jazzy, humble], 그리고 git ls-remote로 확인한 실제 lyrical 브랜치(humble·jazzy·kilted와 나란히 존재). ROS 2의 순차 알파벳 명명 규칙(...Humble·Iron·Jazzy·Kilted 다음 L)과도 정확히 들어맞는다 — 공식 등재 전에 생태계 인프라에서 먼저 실명을 확인한 사례. ③ 부수 발견 — 같은 조사에서 ros2/rclcpp를 클론해 보니 GitHub Actions 워크플로 자체가 없었다(핵심 클라이언트 라이브러리는 ROS 빌드팜에서 CI를 실행함, Nav2 같은 생태계 프로젝트만 GitHub Actions 사용) — 15.2.3의 "정확한 워크플로는 각 저장소가 정본" 조언이 예상보다 더 근본적으로 맞았다(CI 체계 자체가 계층마다 다름). 15.2(15.2.1·15.2.2·15.2.3 확장 — 신규 실행 확인 장, 실행 확인(194차) 블록 신설)에 반영, 하네스는 tools/gap_harness/gap193/(rep2000_check/, findings_summary.txt). 컨테이너 정리 전 조회 로그·워크플로 파일을 호스트로 복사했다. tools/book_check.py는 편집 도중 일시적으로 "실행 확인 장 수 123→124 불일치" 2건을 냈다가 카운터 갱신 후 재통과, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 123→124개(15.2가 처음으로 실행 확인 장이 됨), 미확인 표시는 859건 그대로(마커 원문 보존, 포인터만 추가 — 신규 마커 개폐 없음). 확인하지 못한 것: "lyrical"이 REP에 공식 등재되기 전 이름이 바뀐 선례가 있는지(미확인, 정황 증거로만 보고), lyrical 브랜치 자체의 커밋·package.xml 버전은 안 봄(존재만 확인), rclcpp·Nav2 두 저장소 외 다른 생태계 저장소의 CI 체계는 전수 조사 안 함.
174차 검증(2026-08-22 · CHG-219) — 15.5 신규(거버넌스 구조 확인 + 포럼 도메인 이전 발견)195차 갭 점검. 173차의 top recommendation ①을 채택했다 — 10개 남은 실행 확인 0건 장 중 15.5(기여·거버넌스·생태계)을 열었다. 173차(15.2)와 같은 계열로, ros2_documentation을 git clone해 Governance.rst·Contributing.rst를 원문과 직접 대조하고 ros2 doctor 명령도 실행했다. ① ros2 doctor — 실재하고 유용함을 확인. "All 5 checks passed"를 실제로 출력했고, --report는 네트워크 설정과 설치된 모든 패키지의 로컬·최신 버전 전체 목록을 담은 실제 진단 보고서를 냈다 — 이 절이 권하는 "환경 정보 첨부"에 정확히 맞는 실제 출력이었다. ② 15.5.3의 거버넌스 구조 — 한 글자도 안 틀렸다. "2024년부터 OSRA 관리"·TGC 구성(유급 구성원·프로젝트 리더·OSRF 리더·능력 기반 구성원)·ROS PMC 구성(프로젝트 리더+투표권 있는 PMC 구성원+지원 개인 대표+TGC 의장)·"매주 화요일 17:00 UTC Zoom 회의"까지 Governance.rst 원문과 정확히 일치했다. ③ 15.5.6의 Contributing 네 원칙도 원문과 정확히 일치("Engage Open Robotics as early as possible" 등), help wanted·good first issue 라벨 안내도 실재했다. ④ 그런데 포럼 주소가 낡아 있었다. 이 장과 15.2가 함께 쓰는 discourse.ros.org는 ros2_documentation 저장소 전체에서 정확히 3번만 나오는데, 전부 2020~2023년 릴리스 노트(Foxy·Galactic·Iron)가 그 시절 논의 링크를 연결한 것이었다 — 포럼이 그사이 이사했다. 현재 문서(Governance.rst·Contributing.rst·Contact.rst·Roadmap.rst·2024~2025년 게시물 전부)는 예외 없이 discourse.openrobotics.org(27회 등장)를 쓴다. 15.5(15.5.1·15.5.3·15.5.5·15.5.6 확장 — 신규 실행 확인 장, 실행 확인(195차) 블록 4개 신설)·15.2(15.2.4의 같은 도메인 오류 1건 정정, 173차에 이미 실행 확인 장이라 마커 개폐 없음)에 반영, 하네스는 tools/gap_harness/gap194/(governance_check/, findings_summary.txt). 컨테이너 정리 전 원본 문서·조회 로그를 호스트로 복사했다. tools/book_check.py는 편집 도중 일시적으로 "실행 확인 장 수 124→125 불일치" 2건을 냈다가 카운터 갱신 후 재통과, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 124→125개(15.5이 처음으로 실행 확인 장이 됨), 미확인 표시는 859건 그대로(마커 원문 보존, 포인터·정정만 추가 — 신규 마커 개폐 없음). 확인하지 못한 것: 포럼 도메인이 정확히 언제 이전했는지(미확인, Iron 2023 이후~현재 사이로만 좁힘), discourse.ros.org가 지금도 리다이렉트로 동작하는지(실제 HTTP 요청 안 함, 정적 텍스트 대조만), 15.5.3.1이 언급하는 구체적 조직명 목록(KUKA 등)은 이번에도 재확인 안 함(장 자신의 방침과 일치).
175차 검증(2026-08-22 · CHG-220) — 8.8 확장(Gazebo 물리+실제 AMCL로 170차 재현)196차 갭 점검. 174차의 top recommendation ①(신규 영역)과 170차부터 4회차째 이월된 옵션(Gazebo 물리 기반 재현)을 함께 채택했다 — 170차(191차 점검, 8.8)가 "확인하지 못한 것"으로 남긴 질문, 물리 기반(Gazebo) TurtleBot3에서도 같은 /initialpose 타이밍 문제가 나오는지에 답했다. nav2_bringup의 tb3_simulation_launch.py(loopback이 아닌 실제 Gazebo 물리+실제 AMCL 경로 — 처음엔 마침표 앵커 정규식으로 못 찾았다가 앵커 없는 재조회로 발견, 순수 내 실수라 책엔 반영 안 함)로 헤드리스 기동하고, ros-jazzy-turtlebot3-gazebo·-navigation2를 추가 설치했다. 170차와 같은 스폰 좌표(-2.0, -0.5)를 이번엔 지연 발행해 부트업 복원력을 시험했다. 경고 문구는 170차와 동일했다 — amcl의 "AMCL cannot publish a pose..."(0.5초 간격, 로그에서 14회), global_costmap의 "Timed out waiting for transform..."(같은 간격, 64회). 그러나 실패 거동은 결정적으로 달랐다 — 로그 타임스탬프를 정밀 계산하니, 런치 후 35.376초에 지연 발행한 /initialpose가 amcl에 반영됐고 3.35초 뒤(총 38.728초) lifecycle_manager_navigation이 "Managed nodes are active"로 정상화됐다 — "Aborting bringup" 문구는 74,000줄 로그 전체 어디에도 없었다(전체 검색으로 재확인; abort 4건은 전부 이후 실제 주행 중 controller_server의 정상적 recovery 트리거였다). 이후 170차와 동일한 목표(x:0.0, y:0.5)로 SUCCEEDED(error_code:0)까지 완주했고, number_of_recoveries 진행값(0→1→3→4→5, 5회)이 170차의 loopback_sim 결과와 완전히 같았다(navigation_time 53.028초 vs 170차 55.1초, 최종 distance_remaining 0.250m vs 0.246m — 근접). 정직한 결론: "/initialpose 없이 headless 부트업이 진행 안 된다"는 170차의 경고는 두 경로 모두 재확인되지만, "~10초 만에 재시도 없이 영구 실패"는 nav2_loopback_sim 고유의 구현 특성이었다 — 실제 Gazebo+AMCL 경로는 최소 35초 이상 포기 없이 재시도하며 늦게 온 초기 포즈도 정상 수용한다(상한은 미확인). recoveries 진행값 일치가 우연인지 이 시작·목표 조합의 결정론적 결과인지는 각 1회 실행만으로 단정하지 않는다. 8.8(8.8.1 서두, 8.8.3.1 — 신규 실행 확인 장·마커 개폐 없음, 190차 실행 확인 블록에 이어 붙이는 실행 확인(196차) 블록 신설)에 반영, 하네스는 tools/gap_harness/gap195/nav2_gazebo_test/(logs/, findings_summary.txt). 컨테이너 정리 전 4개 로그 파일(apt_install{,2}.log·nav2_gz_launch.log·nav_goal_gz.log, 최대 2.5MB)을 호스트로 복사했다. tools/book_check.py 23종 편집 전후 모두 0건(8.8는 170차에 이미 실행 확인 장이라 전환 불일치 없음), pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 125개 그대로(새 실행 확인 장 아님, 기존 장 확장), 미확인 표시는 859건 그대로(신규 마커 개폐 없음, 정정·확장 포인터만 추가). 확인하지 못한 것: Gazebo+AMCL 경로의 재시도 상한이 실제 무한인지(35초보다 훨씬 긴 지연은 미시도), lifecycle_manager_navigation 자체에 별도 bond_timeout 등 타임아웃 파라미터가 있는지 소스는 확인 안 함(로그 관찰만), recoveries=5 일치의 재현성(표본 각 1회뿐).
176차 검증(2026-08-22 · CHG-221) — 12.1 신규(realtime_tools API·공식 실시간 데모 pendulum_control 실측)197차 갭 점검. 174차의 top recommendation ①(8개 남은 실행 확인 0건 장 중 새 영역 — CHG-220에서 "9개"로 잘못 적었던 것을 자체 정정)을 채택해 12.1(실시간이란 무엇인가 — ROS 2에서의 한계)를 열었다. 12.3(대여 메시지·커스텀 할당자, 이미 170차 갭점검에서 실행 확인)·12.5(실행기별 순서, 93~97차에 이미 실행 확인)과 겹치지 않도록, 12.1 고유의 미확인 영역(realtime_tools API·공식 데모·컨테이너 권한 요구사항)에 집중했다. ① realtime_tools(3.11.0)의 RT 헬퍼 API가 실재하고 정확히 동작함을 확인했다 — configure_sched_fifo·lock_memory·has_realtime_kernel·set_thread_affinity로 소형 테스트 프로그램을 컴파일·실행하니, has_realtime_kernel()이 이 환경(호스트 커널 7.0.0-29-generic)에서 정확히 false를 보고했다. ②(핵심 발견) 컨테이너 권한 조합 3단계 대조로 SCHED_FIFO와 mlockall이 서로 다른 capability를 요구함을 확인했다 — 권한 없음: 둘 다 실패, --cap-add=sys_nice만: configure_sched_fifo만 성공(lock_memory는 여전히 실패), --cap-add=sys_nice --cap-add=ipc_lock --ulimit memlock=-1:-1: 둘 다 성공. ③ ROS 2 공식 실시간 데모 pendulum_control이 desktop-full에 기본 포함돼 있었고(package.xml에서 tlsf_cpp·rttest 의존 확인), 두 권한 조건에서 각 5회씩 반복 실행하니 메모리 잠금 성공 조건에서는 실행 중 Minor pagefault가 5/5 모두 정확히 0(잠금 실패 조건은 5/5 모두 4~5개 발생) — mlockall의 문자 그대로의 효과는 완벽히 재현됐다. 그러나 평균 지연은 두 조건이 사실상 동일했고(5,165 ns vs 5,180 ns), 최댓값 범위는 오히려 메모리 잠금 조건에서 다소 높게 나타났다(평균 최댓값 38,854 ns vs 55,853 ns) — RT 커널·CPU 격리 없이 메모리 계층 하나만 고쳐서는 지연이 개선되지 않는다는 것을 실측으로 보여 줬다(12.1.4 "가장 취약한 층이 전체를 결정한다"와 정합). 지연 수치는 3.6.1 방침대로 이 컨테이너 환경의 예시로만 인용했다. 12.1(12.1.1 서두, 12.1.4(12.5 인용 포인터로 해결)·12.1.5의 표 2개·본문 — 신규 실행 확인 장, 실행 확인(197차) 블록 신설)에 반영, 하네스는 tools/gap_harness/gap196/rt_test/(rt_helpers_test.cpp, logs/, findings_summary.txt). 컨테이너·스냅숏 이미지 정리 전 모든 로그를 호스트로 복사했다. tools/book_check.py는 편집 도중 일시적으로 "실행 확인 장 수 125→126 불일치" 2건을 냈다가 카운터 갱신 후 재통과, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 125→126개(12.1가 처음으로 실행 확인 장이 됨), 미확인 표시는 859건 그대로(정정·확장 포인터만 추가, 새 마커 개폐 없음). 확인하지 못한 것: 실제 PREEMPT_RT 커널로는 대조하지 못함(메모리 잠금 단독의 한계만 확인), CPU 격리(isolcpus)는 적용 안 함, 표본이 조건당 5회뿐이라 통계적 유의성 검정은 안 함, pendulum_demo의 커맨드라인 옵션(우선순위·반복 횟수)은 기본값만 사용.
177차 검증(2026-08-23 · CHG-222) — 10.1 신규(시뮬 테스트 불안정성 실측, headless:=True가 RViz2를 안 끈다는 발견)198차 갭 점검. 175차의 top recommendation ①을 채택해 10.1(시뮬레이션 전략 — 무엇을 검증하는가)를 열었다. 부수로 코디네이터가 제시한 옵션 ③(PREEMPT_RT 커널로 176차 재실험)은 빠르게 타당성만 점검했다 — apt-cache search linux-image-rt 0건(표준 Ubuntu 24.04엔 RT 커널 없음, Ubuntu Pro 필요), systemd-detect-virt가 "none"(베어메탈, 일회용 VM 아님) — 재부팅이 필요한 실제 개발 머신의 커널 교체는 시도하지 않고 접었다. 10.1.7의 "시뮬 테스트는 불안정하기 쉽다" 주장을 TurtleBot3+Gazebo+Nav2로 실측했다. ①(핵심 발견, 소스로 확정) headless:=True는 RViz2를 끄지 않는다 — tb3_simulation_launch.py 소스를 직접 읽으니 headless는 오직 gzclient(Gazebo GUI)만 제어하고, RViz2는 완전히 별도의 use_rviz 인자(기본값 True)로 항상 포함된다. headless:=True만 준 4회 연속 실행 4/4 모두 RViz2가 크래시(SIGABRT)하고 곧바로 nav2_container가 RCLError로 종료돼 60초 안에 부트업 실패했다. use_rviz:=False를 추가하니 5/5 모두 8~11초 만에 깔끔히 성공 — 명확한 A/B 대조. 175차(gap195)는 같은 패키지 버전(1.3.12-1noble.20260616.082701, 확인)으로 headless:=True만으로 성공했었는데 그 로그엔 "rviz"가 한 번도 안 나와("항상 크래시"가 아니라 "크래시할 수 있고 크래시하면 전체를 무너뜨릴 수 있다"로 정리, 175차와의 차이는 규명 못 함). ② 부트업 안정화 후에도 내비게이션 결과가 갈렸다 — use_rviz:=False로 고정, 175차와 동일 시작 포즈·목표로 5회 반복하니 1회만 SUCCEEDED(13.2초, 무복구), 4회는 ABORTED(로그상 amcl의 "Failed to transform initial pose in time" 반복 — /initialpose 5Hz 발행과 tf 버퍼 성장 간 타이밍 경쟁으로 추정). 정직한 교란 변수를 스스로 찾아 명시했다: 실패 트라이얼엔 FastRTPS 공유메모리 포트 에러가 10~15건 있었지만 성공 트라이얼엔 0건 — 같은 컨테이너를 짧은 간격(2~3초)으로 재기동해 DDS 포트 반납 전 재사용 경쟁이 있었을 가능성이 있어, 이번 "5회 중 1회 성공"은 일반화하지 않는다(하네스 자체의 교란일 수 있음). 이는 이 절 자신의 "통과 기준을 통계적으로, 단일 실행으로 판정하지 말라"는 조언을 스스로 증명한 메타적 사례다. 10.1(10.1.1 서두, 10.1.7 함정 문단 확장 — 신규 실행 확인 장, 실행 확인(198차) 블록 신설)에 반영, 하네스는 tools/gap_harness/gap197/sim_flakiness/(run_trial.sh, logs/trial_logs/, findings_summary.txt). 컨테이너 정리 전 9회 트라이얼 전체 로그를 호스트로 복사했다. tools/book_check.py는 편집 도중 일시적으로 "실행 확인 장 수 126→127 불일치" 2건을 냈다가 카운터 갱신 후 재통과, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 126→127개(10.1가 처음으로 실행 확인 장이 됨), 미확인 표시는 859건 그대로(정정·확장 포인터만 추가, 새 마커 개폐 없음). 확인하지 못한 것: 매번 새 컨테이너로 완전히 격리했다면 실패율이 달라졌을지(시간 비용으로 미시도), 초기 포즈 타이밍 경쟁을 없애는 수정 후 재검증(미시도), 175차와의 RViz2 로그 차이의 정확한 원인, error_code 102·0의 정확한 의미.
178차 검증(2026-08-23 · CHG-223) — 14.1 확장(QoS 깊이 20 편차의 근본 원인 재구성)199차 갭 점검. 177차의 top recommendation ①에 따라 먼저 10.6(Sim2Real 간극과 검증)·14.10(안전 표준과 ROS 2의 위치) 전문을 스코핑했다 — 둘 다 이 환경(GPU 없음, 실물 로봇 없음)에서 실행 검증 가능한 구체적 주장이 없었다. 10.6는 스스로 "방법론이며 특정 수치를 제시하지 않는다"고 선언하고 본문 주장이 전부 시뮬-실물 비교라 하드웨어 없이는 검증 불가능했고, 14.10는 순수 규제·정책 개관으로 확인 가능한 API·명령·수치가 하나도 없었다(코디네이터가 사전에 예상한 그대로). 둘 다 막혀 제안 ②(165/166차의 QoS 깊이 20 편차)로 넘어갔다. 165/166차(CHG-210/211)는 발행 주기를 10ms로 고정하고 깊이만 스윕해 "깊이 20에서만 19개 예측 대비 18개"라는 편차를 체계적 현상으로 확정했지만 메커니즘은 미상으로 남겼다. 이번엔 반대로 깊이를 20으로 고정하고 발행 주기를 5·10·20·40ms로 스윕했다(주기당 3회, 가장 흥미로운 10ms는 5회 더 반복해 총 8회). 핵심 발견 1: 버스트 개수가 주기에 반비례했다 — 5ms에서 20개(깊이 상한에 막힘), 10ms에서 18~19개, 20ms에서 9개, 40ms에서 5개. 버스트가 덮는 시간 폭((개수−1)×주기)을 계산하면 20ms·40ms 둘 다 정확히 160ms로 일치했다 — "18개"는 고정 샘플 개수가 아니라, 주기 10ms에서 우연히 ~160~190ms의 실시간 시간 예산과 맞아떨어진 결과였다(164차가 "90ms 디스커버리"를 캐시 채움 시간과의 우연한 일치로 정정했던 것과 같은 구조). 핵심 발견 2(더 크다): 주기 10ms를 8회 반복하니 3/8회(37.5%)는 버스트가 33~34개로 깊이(20) 자체를 넘었다(시간 폭 320~330ms, 단일 창의 거의 정확히 2배) — CSV 원본으로 이 33~34개가 끊김 없는 하나의 계단임을 확인해, 별개의 두 버스트가 아니라 매칭 자체가 약 2배 늦게 일어난 것으로 해석했다. 정직한 결론: 원래 질문("왜 18개인가")에는 답했다 — 근본 원인이 "샘플 개수"에서 "~160~190ms의 실시간 시간 예산"으로 재구성됐다. 그런데 이 시간 예산 자체가 양자화돼(대략 1배 또는 2배) 있고, 깊이=20을 명시했는데도 33~34개가 전달된 것은 "KEEP_LAST(depth)가 새 구독자에게 전달될 과거 샘플 수의 하드 캡"이라는 이 책이 186/187차까지 암묵적으로 전제해 온 가정을 적어도 이 조건에서는 반증한다. 정확히 어떤 DDS/RTPS 타이머가 ~160~190ms를 만드는지, KEEP_LAST 축출이 매칭 전후로 다르게 동작하는지는 이번에도 규명하지 못했다(Fast DDS 소스 레벨 계측 필요, 미시도). 14.1(14.1.1 서두 포인터 확장, 14.1.7의 187차 블록에 정정 포인터 + 실행 확인(199차) 블록 신설)에 반영, 하네스는 tools/gap_harness/gap198/qos_depth20_period_sweep/(pkg/·run_period_sweep.sh·analyze.py·results/·findings_summary.txt). 컨테이너 정리 전 모든 CSV·로그를 호스트로 복사했다. tools/book_check.py 23종 편집 전후 모두 0건, pitfall_scan.py 69쌍 그대로. 실행 확인 장 수 127개 그대로(14.1은 이미 실행 확인 장), 미확인 표시는 859건 그대로(정정·확장 포인터만 추가, 새 마커 개폐 없음). 확인하지 못한 것: ~160~190ms 시간 예산의 정확한 DDS/RTPS 메커니즘, 더 작은 깊이(예: 5)에서도 33~34개류의 초과 버스트가 나오는지(미시도 — 나오면 "축출은 매칭 후에만 적용"이라는 가설이 강해짐), 2배 창의 발생 확률(37.5%, n=8)이 표본이 커져도 유지되는지·시스템 부하와 상관 있는지.

확인하지 않은 것이 훨씬 많다 — 성능·지연 특성, Nav2·MoveIt 2·ros2_control·Gazebo의 동작과 파라미터, 벤더별 RMW 거동, 실기 통합, 그리고 코어 API 중에서도 위 목록 밖의 것들. 다음 원칙으로 읽기 바란다.

번호 체계가 둘이라는 것(72차가 발견) — 94차에 병기로 해소. 이 표의 "N차"는 CHANGES.md의 CHG 항목 제목("갭 점검 N차")과 같은 수가 아니다 — 두 카운터가 원래부터 서로 다른 것을 세고 있어 하나로 합칠 수 없다. 94차에 위 표의 71개 행 전부에 실제 CHG 번호를 병기했다 — 각 행의 **N차 검증(날짜 · CHG-NNN)**에서 CHG-NNN을 그대로 CHANGES.md에서 찾으면 된다. 병기는 RJ 참조 번호·특징적 수치(예: "41개 절"·"54.7%")로 각 행을 해당 CHG 본문과 대조해 확정했으며, 96개 CHG(CHG-21~116) 중 정확히 72개가 표에 매칭되고 나머지 24개(CHG-24·30·31·36~39·46·52~66·76)는 표에 오르지 않은 회차임을 확인했다 — 72(표) + 24(표 밖) = 96(CHG-21~116) 산수가 정확히 맞는다.

이 표의 "N차"가 정확히 무엇을 세는가(102~104차 세 회차 이월 끝에 104차가 확정). 위 문단이 처음 썼던 설명("N차는 실행·실측을 동반한 회차만 센다")은 부정확했다 — 위 표를 다시 보면 79·81·82차처럼 "점검"이라는 낱말이 붙고 본문에 "새 실측 없이"라고 스스로 적은 행이 이미 여러 개 있다(104차 기준 9개 "점검" + 3개 "정리" = 12개, 나머지 71개가 "검증"). 즉 표는 처음부터 "실행 검증"과 "실행 없는 점검·정리"를 같은 번호열에 함께 세고 있었고, 그 구분은 각 행 제목의 낱말(검증/점검/정리)에만 있었을 뿐 문서 어디에도 규칙으로 적힌 적이 없었다. 104차가 이것을 세 갈래로 공식화한다:

유형낱말뜻표에 행이 생기나현재 행 수
실행 검증"N차 검증"컨테이너를 실제로 기동해 뭔가를 실행·측정·컴파일했다그렇다72
커버리지·깊이 점검"N차 점검"컨테이너 실행 없이 목차·본문·함정 카탈로그를 대조했다(book-gap의 커버리지·일관성 갭 점검)그렇다9
정리"N차 정리"컨테이너 실행 없이 이미 확정된 실측 결과를 재배열·색인·도구화했다(새 사실 확정은 없음)그렇다3
장 보강형(번호 없음)book-gap이 아니라 book 스킬 성격 — 장의 골격은 그대로 둔 채 절 안의 산문·예시·코드를 채운다(최초 사례 CHG-127)아니다0

"검증"·"점검"·"정리" 셋은 모두 같은 N차 번호열을 공유한다 — "N차"는 "실행 여부"가 아니라 "이 회차가 표 한 행으로 요약할 수 있는, 책의 서술을 갱신한 회차인가"를 센다(실행이 그 조건 중 하나일 뿐, 전부는 아니다). 반대로 "장 보강형"은 번호를 받지 않는다 — 표의 각 행은 "한 회차 = 한 주제의 대조·실측 결과 한 뭉치" 형식인데, 장 보강형은 여러 장을 전면 개정하는 성격이라 이 형식에 맞지 않기 때문이다(CHG-127-03이 처음 내린 판단을 104차가 일반 규칙으로 승격했다). 장 보강형 회차는 CHANGES.md에 CHG 번호로 기록하고 목차 3.1에 산문으로 요약한다 — 그 회차 중 부수적으로 컨테이너 실행이 있었던 부분(CHG-127의 rmw.h·ros1_bridge 확인처럼)은 "실행 확인 장 수" 집계에는 반영되지만 N차 순번에는 포함되지 않는다. "회차 순번(N차)"과 "실행 확인 장 수"는 서로 다른 두 집계라는 뜻이다. 이 규칙에 따라 CHG-127은 82차 다음 번호를 받지 않았고, 실제 컴파일 검증을 한 이번 104차(CHG-128)가 83차 자리를 채운다 — BOOK.md BK-03이 한때 CHG-127을 "83번째 회차"라 부르던 것은 이 규칙이 확정되기 전의 표현이었다(104차가 정정).

2차 검증이 말해 주는 것. 실행해 보니 환경변수 3종이 실존하지 않았고, ros2 topic echo에 관한 서술은 Jazzy에서 이미 틀린 이야기였다. 둘 다 "공식 문서 기준으로는 맞았던" 서술이 배포판을 거치며 어긋난 경우다. 미검증 영역에도 같은 밀도의 오류가 남아 있다고 보는 편이 안전하다 — 특히 생태계 패키지(Nav2·MoveIt 2·Gazebo) 장들은 실행 검증이 전혀 없다.

3차 검증이 말해 주는 것 — 오류보다 누락이 문제다. 3차(2026-08-16)는 틀린 서술을 찾는 대신 "이 책이 완전히 누락한 것" 을 찾았고, 실제로 다섯 가지가 나왔다(위 표). 그중 셋은 책 스스로 "저 장에서 다룬다"고 적어 놓고 다루지 않은 것이었다. 동시에 참조 오지정 75건을 고쳤다 — 대부분 파라미터를 1.7(서비스)로, 액션을 1.9(파라미터)으로, 디스커버리를 1.5(QoS)로 가리키던 같은 원인의 혼동이며, ID가 실재하므로 링크 검사로는 검출되지 않는 종류다. 이 두 가지 — 누락과 오지정 — 는 정오표로 드러나지 않으므로, 독자가 어떤 절을 근거로 삼기 전에 그 절이 실제로 그 주제를 다루는지 확인하기를 권한다.

  • 원리·아키텍처 서술 — 문서·소스에 근거하며 배포판이 바뀌어도 대체로 유효하다.
  • API 시그니처·기본값 — Jazzy 시점 기준. 패치 릴리스로 바뀔 수 있어 흔들릴 만한 곳은 (확인 필요)를 붙였다.
  • 성능 수치 — 하드웨어·커널·네트워크·DDS 설정에 따라 자릿수가 바뀐다. 이 책은 측정 방법과 지배 요인을 가르치며, 절대 수치는 조건과 함께 예시로만 쓴다.
  • 생태계 패키지(Nav2·MoveIt 2·ros2_control·Gazebo) — 별도 릴리스 주기를 갖는다. 아키텍처·판단 기준까지 다루고, 파라미터 전수는 각 프로젝트 문서를 가리킨다.

장별 검증 상태 — 지금 읽는 장이 확인됐는지 보는 법

위 표는 회차별 기록이라 "19차에 무엇을 했나"는 알려 주지만 "내가 지금 펼친 장이 검증됐나"는 알기 어렵다. 그 질문에 답하는 요약을 둔다(2026-08-16 기준).

구분장 수
본문에 실행 확인 표기가 있는 장127개 / 133개(95%)
표기가 없는 장6개
남아 있는 (확인 필요)859건(2026-08-23, 199차 실측 — 이 수는 검증이 진행될수록 늘 수도 있다, 아래 설명 참조)

가장 확실한 방법은 장을 펼쳐 보는 것이다. 두 가지 표기가 그 장의 상태를 말한다:

본문에서 보이는 것뜻
(실행 확인)그 문장·표·수치를 Jazzy 실물에서 직접 확인했다
(확인 필요)확인하지 못했다 — 근거는 문서·소스이며 실행으로 검증되지 않았다
둘 다 없음원리·설계·판단 서술. 애초에 실행으로 검증할 성질이 아니다

검증이 특히 두꺼운 장(실행 확인 표기 수 기준):

장
A.1실행 확인 15곳
11.2실행 확인 14곳
13.2실행 확인 14곳
5.5실행 확인 13곳
6.5실행 확인 13곳
7.2실행 확인 10곳
12.2실행 확인 10곳
13.3실행 확인 9곳

(확인 필요)가 621건 남아 있다는 사실을 감추지 않는다. 그리고 이 수는 검증이 진행되며 단조롭게 줄지 않았다 — 2026-08-16에 570건이던 것이 66차 점검 시점(618건, 목차 3.1 위 표)까지 늘었다가, 이후 회차에서 여러 건을 해소하며 614건(94·95·96차)·613건(97차)·615건(98차)에 이르렀고, 99차는 커널 타임스탬프로 미확인 하나("도착 순서 대 executor 우선순위")를 닫으면서도 새 미확인("초기 7~12회가 왜 안전한가", "97·98차가 갈린 이유의 나머지", "rclpy의 EventsExecutor가 다른 실험에서도 같은가")을 여럿 남겨 순증했다(자동 재계산 기준 — 아래 설명 참조). 본문(1.8.13)에서는 97차(CHG-120)가 추론으로 남긴 "패킷 재정렬" 가설을 98차가 tcpdump·tshark로 실측 확정했고, 99차는 로컬 warm 손실이 네트워크 도착 순서와 무관함(커널 타임스탬프로 확정)과 rclpy가 이 손실 계열 전체에 면역임(240회 + 소스)을 새로 확정했다 — 그런데도 총량은 줄지 않았다. 97·98·99차를 이으면 이 실험 계열에서만 하네스 관련 의심 또는 재확인이 네 차례(CHG-120-05 두 건 + 98차 정정 + 99차의 병렬 컨테이너 부수 관측) 나온 셈이며, "놀라운 결과는 측정 장치부터 의심하라"는 이 책의 원칙이 실측 축 자체에도 반복 적용돼야 함을 보여 준다. tools/book_check.py의 자동 집계는 본문의 남아 있는 미확인 표기와 이 역사 서술을 구분하지 않고 문자열 그대로 센다. 숫자를 예쁘게 만들려고 서술을 바꾸지 않고, 도구가 실제로 세는 값을 그대로 적는다. 모순이 아니라 구조다: 한 항목을 확인하면 그 옆의 미확인이 드러나기 때문이다. 예컨대 타이머 지터를 측정하고 나니 "RT 커널에서는 어떤가"가 새로 생겼고(2.5.13.5), Fast DDS의 XML을 확인하니 "전송·메모리 정책은 어떻게 확인하나"가 남았다(3.7.12.4). 각 실측 절 끝의 "확인하지 못한 것"이 그 출처이며, 그것을 적지 않는 편이 숫자는 예뻐도 정직하지 않다. 그중 상당수는 없애면 안 되는 것이다 — 조직 관행·판단, 성능 수치(이 책은 절대 수치를 제시하지 않는다 — 3.6.1), 하드웨어·커널이 있어야만 확인되는 것이 그렇다.

100차(CHG-123)는 새 실측을 하지 않고, 93~99차가 남긴 실측을 대조만 했는데도 총량은 소폭 늘었다(위 표의 값). 신규 컨테이너 실험이 없었으니 실측 결과가 새로 바뀐 것은 없다. 늘어난 몇 건은 이번 회차가 이 문서(목차 3)에 스스로 남긴 서술 안에 있다 — 위 표의 새 행과 이 문단이 "무엇이 낡았고 무엇을 고쳤는지"를 설명하며 (확인 필요)라는 문자열 자체를 몇 차례 인용했고, tools/book_check.py는 그 인용도 남아 있는 미확인 표기와 구분하지 않고 그대로 센다. 본문의 낡은 서술 세 곳(12.5.4·2.2.9·2.3.6)을 고친 것은 이 증가와 무관하다 — 그 세 곳은 이미 몇 회차 전에 실측으로 닫힌 사실을 반영하지 않은 채 "확인 안 함"으로만 적혀 있던 것이라, 고쳐도 각 자리의 (확인 필요) 태그 자체는 하나씩 그대로 남겨 두었으므로 개수에는 영향이 없다 — 개수는 그대로인데 내용이 실측과 어긋나 있었을 뿐이었다. 정리하면 이번 회차의 증가는 지금까지 반복 관찰된 "확인하면 옆의 미확인이 드러난다"는 패턴과는 다른, 더 사소하지만 같은 원인의 사례다 — 자동 집계는 뜻이 아니라 문자열을 센다는 것을 이 문단 자신이 실시간으로 증명한다.

101차(CHG-124)는 새 실험을 했는데도(675 트라이얼) 순수하게 새로 연 (확인 필요)는 1건뿐이다(621→627 중 나머지 5건은 이 문서 자신의 서술이 그 문자열을 인용한 결과 — 100차가 CHG-123-05에서 이미 설명한 패턴과 같은 원인이다) — 실험 대부분이 "무엇을 확정했다"가 아니라 "78차의 관찰이 재현되지 않았다"로 끝났기 때문이다. CPU 주파수·/proc 스케줄링 정보로 "초기 무사 구간의 원인"을 좁히려 했으나 답을 얻지 못했고, 대신 그 구간 자체가 오늘은(같은 하네스·같은 방법으로) 재현되지 않는다는 것을 45회 반복으로 보였다 — 새로 연 (확인 필요)는 "78차와 오늘의 유일한 확인된 차이인 호스트 부하가 원인인지" 하나뿐이다(1.8.13). 부차적으로 CHG-122(99차)가 견해로만 남겨 뒀던 C++/Python 열 번째 비대칭 축(2.8.12.11, rclpy의 액션 피드백 면역)을 정식 절로 승격했는데, 이것은 이미 확정돼 있던 수치를 옮긴 것이라 (확인 필요)에 영향이 없다.

102차(CHG-125)는 커버리지·깊이 갭 점검이라 실측을 하지 않았고, (확인 필요) 627건도 그대로다. 다만 이번 회차 자신이 수동으로 grep을 여러 방식으로 시도하며 599건·572건·521건 등 627과 다른 값을 여럿 얻었다 — 원인은 집계 스크립트가 tools/book_check.py의 re.findall(r'\(확인 필요', s)를 목차-목차.md 자신을 포함한 전체 134개 파일에 실행하는 반면(목차 자신의 회차별 서술이 그 문자열을 여러 차례 인용하므로 55건이 여기서 나온다), 수작업 점검은 통상 "장 본문만" 세려고 목차-목차.md를 빼거나 정규식을 약간 바꾸기 때문이다. tools/book_check.py의 정확한 정규식으로 다시 실행하자 627로 정확히 일치했다 — 수치 자체는 정확했고, 집계 방식의 차이가 혼동을 낳을 뻔한 사례로 남긴다.

103차(CHG-126)도 순수 문서 점검이라 실측을 하지 않았고, (확인 필요) 627건·실행 확인 86장 모두 그대로다. 102차가 지목한 다음 회차 제안 ①(나머지 11개 부 헤더 대조)과 ②(pitfall_scan.py 잔여 64쌍 재검토)를 이어받아 처리했다 — 새 실측 없이 목차·본문 대조와 함정 카탈로그 재독만으로 진행했으므로 (확인 필요) 카운트에 영향이 없다(정확히 82차 표 행과 함께 tools/book_check.py로 재확인).

실행 확인 86장 → 88장(2026-08-19, CHG-127) — 102차가 사용자 결정 대기로 남긴 제15부 보강. 81차(CHG-125)가 발견하고 보류한 "제15부 다섯 장이 산문·예시가 얕다"는 항목을 이번에 보강했다(15.1~15.5). 이 작업은 위 표에 새 번호("N차 검증/점검")로 집계하지 않았다 — 다섯 장 전체를 고쳐 쓰는 보강이라 위 표의 한 행으로 요약하기 어렵고, 각 장이 무엇을 얻었는지는 CHANGES.md의 CHG-127이 장별로 적는다. 다만 그 과정에서 ros:jazzy-ros-base 컨테이너에 실제로 들어가 /opt/ros/jazzy/include/rmw/rmw/rmw.h를 읽어 공개 함수 71개·헤더 45개를 세고(15.4.6), apt-cache search ros1-bridge가 빈 결과를 낸다는 것을 실행해 확인했다(15.3.1·15.3.3) — 둘 다 이 책의 실행 검증 관례(목차 3)를 그대로 따른 것이라 해당 두 장에 실행 확인 표기가 새로 생겼다. tools/book_check.py가 이 불일치(86 vs 실제 88)를 자동으로 검출해 이 문단과 위 요약 표·BOOK.md BK-03을 함께 정정했다. 이 회차는 CHG-127의 방식 자체가 옳았음을 사후에 보여 준 사례이기도 하다 — "장 보강형은 표에 행을 안 만든다"는 판단을 104차가 위 범례로 공식화했다.

실행 확인 88장 → 89장 + (확인 필요) 627 → 640건(2026-08-19, CHG-128, 104차) — 15.1의 API diff 5건을 실제로 컴파일. 82차(CHG-126)와 CHG-127이 각각 남긴 "15.1을 실제로 컴파일해 재현 검증하라"는 제안을 실행했다. ros:jazzy-ros-base(ros-jazzy-rclcpp 28.1.21)에서 다섯 항목을 각각 최소 재현 코드로 컴파일한 결과 — 넷은 릴리스 노트와 일치(tf2_*.h→.hpp, qos_event.hpp 제거, get_typesupport_handle 폐기 경고, ros2 bag record --exclude→--exclude-regex), 하나는 릴리스 노트와 실물이 어긋남(비-const shared_ptr<MessageT> 구독 콜백이 "Jazzy에서 완전히 제거"라는 문서와 달리 실제로는 경고 없이 컴파일됨 — 원인은 헤더에 남은 템플릿 특수화로 소스 수준까지 확인했으나, "왜 릴리스 노트와 어긋나는지"는 미확정으로 남겼다). 15.1을 정정했고, 15.1에 실행 확인 표기가 새로 생겨 88 → 89장이 됐다. (확인 필요)는 627 → 640건으로 늘었는데, 두 겹의 이유가 섞여 있다. ① 15.1 본문 편집으로 633건이 됐다 — 새로 연 미확인("정확한 원인" 1건)과 기존 태그를 더 구체적으로 다시 쓰며 생긴 변화가 섞여 있어(예: 일반형 (확인 필요) 하나를 (확인 필요: gz_math_vendor 빌드)로 구체화) 정확한 순증감의 원인별 내역은 세지 않았다(확인 필요: 정확한 집계). ② 그 뒤 이 문서(목차 3) 자신이 위 범례·이 문단에서 이 미확인 표기 문자열 자체를 여러 차례 인용하며 633 → 640건으로 더 늘었다 — 100차(CHG-123)가 처음 짚은 "확인하면 옆의 미확인이 드러난다"·"자동 집계는 문자열을 그대로 센다"는 패턴이 이번에도 실시간으로 재현된 것이다. 이 두 번째 증가는 책의 실제 서술이 미확인해진 것이 아니라 이 요약 문서가 스스로를 설명하는 과정에서 생긴 자기지시적 증가이므로, tools/book_check.py가 실제로 세는 값을 그대로 적었다(예쁘게 보이려고 숫자를 다듬지 않았다 — 이 문단을 쓰는 동안에도 값이 한 번 더 움직였을 수 있다는 것 자체가 이 현상의 증거다). 부수적으로 이 회차가 목차 3의 "N차" 카운터 정의를 확정했다 — 위 범례("검증·점검·정리·장 보강형") 참고. tools/book_check.py 23종은 편집 전 ⑳ 2건(88 vs 89 불일치) → 편집 후 0건.

실행 확인 89장 유지, (확인 필요) 640건 유지(순증감 0으로 측정, 2026-08-19, CHG-129, 105차) — 15.1의 원인 해소와 2.8의 신규 미확인이 상쇄됐다. 이번 회차가 15.1에서 "정확한 원인" 미확인 1건을 실제로 해소(원인을 ros2/rclcpp·ros2_documentation 커밋 이력으로 확정)했고, 동시에 2.8.12.12(신설)에서 rclpy EventsExecutor 크래시·바인딩 순서의 미확인 2건을 새로 열었다 — 편집 단위로는 -1, +2 = 순증 +1이 산수상 맞다. 그런데 tools/book_check.py로 편집 전후를 각각 측정해 보니 둘 다 640건으로 나왔다 — 즉 이번 회차 편집 전(세션 시작 시점)의 실제 값이 이미 640이 아니라 639였을 가능성이 있다(104차가 문서에 적어 둔 "640"과 1건 어긋난다). 정확한 지점(104차가 640을 잘못 셌는지, 그 뒤 이 문서 자신의 다른 자기지시적 인용이 이미 한 번 더 움직였는지)은 추적하지 않았다 — 100차(CHG-123) 이후 반복 관찰된 "자동 집계는 문자열을 그대로 세고, 이 요약 문서 자신도 그 문자열을 인용해 스스로를 움직인다"는 패턴과 같은 원인으로 보고, tools/book_check.py가 실제로 세는 현재값(640)을 그대로 적는다. 이번 회차가 만든 것은 목차 4.3(제2부 소개, 누락 4개 장 보강)·15.1(원인 확정)·2.8.12.12(신설, C++/Python 열한 번째 축)·2.10.11.3(교차참조 1문단)이고, 새 실험 하네스는 tools/gap_harness/gap105/에 남겼다. tools/book_check.py 23종은 편집 전후 모두 0건.

(확인 필요) 카운터를 직접 세어 실제 값으로 통일했다(2026-08-19, CHG-130, 106차) — 639 대 640 불일치는 정확한 지점을 못 찾은 채 넘어간다. 105차가 "편집 전후 둘 다 640으로 나왔는데 세션 시작 시점의 실제값이 이미 639였을 수도 있다"고만 적고 추적하지 않은 것을 이어받아, 106차가 이 문단을 쓰기 전 시점에 tools/book_check.py와 동일한 방식(전체 134개 파일에서 여는 괄호 뒤 "확인 필요"로 시작하는 문자열 수)으로 다시 세니 643건이었다 — 104·105차가 문서에 적어 둔 "640"과도, 105차가 추정한 "639"와도 다르다. 정확히 어디서 얼마나 어긋났는지는 이번에도 추적하지 못했다(639→640→643 사이 몇 건이 106차 이전의 다른 편집에서 조용히 늘었는지, 애초에 104차의 "640" 자체가 부정확했는지 — 100차 이후 반복 확인된 자기지시적 잡음과 같은 성격으로 보인다)(확인 필요). 매 회차 정확한 델타를 재구성하기보다 tools/book_check.py로 그때그때 실측하는 것이 유일하게 신뢰할 수 있는 방법이라는 결론에 106차도 도달했다. 이 문단·아래 요약 표를 쓰는 동안 106차 자신의 편집(새 행·새 문단)이 카운터를 다시 움직였으므로, 아래 표의 최종 수치는 이번 CHG-130 편집이 전부 끝난 뒤 tools/book_check.py로 마지막에 한 번 더 측정한 값이다 — 이 문단에 적은 "643"은 그 편집 이전의 중간값이며 최종값과 다를 수 있다.

107차(CHG-131)도 같은 방법을 그대로 썼다. 2.8.12.14(신설)·이 표의 86차 행·2.8.12/2.10.11 색인 갱신을 전부 마친 뒤 tools/book_check.py와 동일한 방식으로 마지막에 한 번 더 세니 652건이었다 — 106차가 최종적으로 남긴 값(이 표에는 "649"로 적혀 있었다)보다 3건 늘었는데, 이번 회차가 새로 연 미확인(타이머·클라이언트 미실험 1건, py::set 순서가 여전히 완전히 균등하다고 증명하지는 못했다는 것 1건)과 (확인 필요)라는 문자열 자체를 인용하는 이 문단들이 스스로를 얼마나 움직였는지가 뒤섞여 있어 정확한 델타 분해는 이번에도 하지 않았다 — 100차 이후 반복 확인된 자기지시적 잡음과 같은 성격으로 본다. tools/book_check.py의 ⑳ 검사(허용 오차 15건)는 이번에도 통과했다.

108차(CHG-132)는 새 미확인을 여는 대신 오래된 미확인 하나를 닫았다 — 15.1.3의 (확인 필요: 직접 재현)(104차부터 이월)를 실제 재현으로 해소했다. 대신 이번 실험 자체가 연 새 미확인(타이머·서비스·클라이언트·waitable의 같은-타입 순서 미실험, MultiThreadedExecutor 미실험, 6.7% 이탈의 ASLR 여부 미확인 — 이 도커 환경이 personality()를 막아 시도조차 못함)이 최소 3건이라 순증가할 것으로 예상했지만, tools/book_check.py와 동일한 방식으로 이 문단·표 편집을 전부 마친 뒤 마지막에 측정하니 657건으로 107차의 652건보다 5건 늘었다 — 대략 예상과 부합한다(하나를 닫고 몇을 새로 열면 순증한다는 통상 패턴). 이번 회차도 정확한 델타 분해(닫은 1건·새로 연 3~4건·이 문서 자신의 자기지시적 인용이 얼마나 움직였는지)는 하지 않았다. tools/book_check.py의 ⑳ 검사는 이번에도 통과했다.

109차(CHG-133)는 108차가 연 미확인 두 건(타이머·서비스·클라이언트·waitable의 같은-타입 순서, MultiThreadedExecutor에서의 재현)을 닫으면서, 동시에 새 미확인도 열었다 — 서비스·클라이언트의 높은 이탈률 중 해시 순서분과 DDS 왕복 타이밍 잡음분을 분리하지 못한 것, MultiThreadedExecutor 실험을 구독·스레드 2·4개로만 한정한 것 두 가지다. tools/book_check.py와 동일한 방식으로 이 문단·표·2.3.14·15.1.3·2.2.7 편집을 전부 마친 뒤 마지막에 측정하니 659건으로 108차의 657건보다 2건 늘었다 — 대략 예상과 부합한다(두 건을 닫고 두 건을 새로 열면 상쇄에 가까울 것으로 예상했는데, 그 자체는 대체로 맞았다). 이번 회차도 정확한 델타 분해(닫은 2건·새로 연 2건·이 문서 자신의 자기지시적 인용이 얼마나 움직였는지)는 하지 않았다 — 100차 이후 반복 관찰된 패턴과 같은 원인으로 본다. tools/book_check.py의 ⑳ 검사는 이번에도 통과했다.

110차(CHG-134)는 107차부터 세 회차째 이월됐던 미확인 하나(타이머·클라이언트 미실험)를 실측으로 닫으면서, 동시에 새 미확인 하나를 열었다(py::set 순서가 212회로도 완전한 균등분포임을 증명하지는 못했다는 것 — 62회 표본 때도 이미 있던 것과 같은 종류의 미확인이라 사실상 이월에 가깝다). 편집을 전부 마친 뒤 같은 방식으로 측정하니 659건으로 109차와 정확히 같았다 — 하나를 닫고 하나를 열어 순증감이 0이 되는, 이 표가 처음 보인 우연한 상쇄 사례다. 부수적으로 이 절 자체의 서두("87차례")가 88차(CHG-133) 행 추가 때 갱신되지 않아 한 회차만큼 뒤처져 있던 것을 발견해, 이번 회차 자신의 행 추가와 함께 "89차례"로 바로잡았다(목차 3 서두). tools/book_check.py의 ⑳ 검사는 이번에도 통과했다.

111차(CHG-135)는 89차(CHG-134)가 이월했던 미확인 하나("타이머 세그폴트의 정확한 메모리 재사용 타이밍")를 ASan·valgrind 추적으로 닫으면서, 동시에 새 미확인 두 건을 열었다 — ASan에서 관찰된 크래시 주소가 실행마다 18바이트씩 증가하는 정확한 이유, glibc tcache가 이 작은 블록을 재사용해 85%/15% 비율을 만든다는 설명이 추론일 뿐 직접 계측하지 않았다는 것이다. 편집(2.8.12.15 확장, 이 표의 90차 행, 목차 6 색인)을 전부 마친 뒤 tools/book_check.py와 동일한 방식으로 마지막에 측정하니 664건으로 89차의 659건보다 5건 늘었다 — 하나를 닫고 둘을 열면서, 이 문단·표 자신이 그 미확인 태그 문자열을 인용하는 자기지시적 증가도 섞였다(100차 이후 반복 관찰된 패턴과 같은 원인). 정확한 델타 분해(닫은 1건·새로 연 2건·자기지시적 증가분)는 이번에도 하지 않았다. tools/book_check.py의 ⑳ 검사는 이번에도 통과했다.

112차(CHG-136)는 CHG-133이 세 회차째(CHG-133→134→135) 이월했던 세 항목(도착-대-디스패치 분리, MultiThreadedExecutor 확장, 통계 검정)을 전부 닫으면서 새 미확인도 열었다 — Humble의 set_on_new_request_callback이 서비스에서 종종 콜백을 두 번 반복 호출하는 원인 미상, 타이머의 스레드 수에 대한 비단조 패턴 미해석 두 가지다(ASLR 관련 미확인은 108차부터 이어지는 기존 항목이라 새로 연 것으로 세지 않았다). 편집(2.3.14 확장, 15.1.3·2.2.7·2.10.11.3 정직 문단, 이 표의 91차 행, 목차 6 색인)을 전부 마친 뒤 tools/book_check.py와 동일한 방식으로 마지막에 측정하니 667건으로 90차의 664건보다 3건 늘었다 — 세 항목을 닫고 둘을 새로 연 것치고는 순증이 작은데, 이는 세 항목 중 둘(②③)이 "미확인을 닫는다"기보다 기존에 이미 표본이 작다고 명시했던 관측을 큰 표본으로 확정하는 성격이라 애초에 미확인 표기를 달고 있지 않았기 때문이다(①만 명시적 표기가 달려 있었다). 정확한 델타 분해는 이번에도 하지 않았다. tools/book_check.py의 ⑳ 검사는 이번에도 통과했다(실행 확인 장 수 89개는 변화 없음 — 이미 실행 확인 표기가 있던 장만 편집).

113차(CHG-137)는 112차가 남긴 세 제안을 전부 처리했는데, 그중 둘(②③)은 미확인을 닫는 대신 기존 소표본 관측을 뒤집는 성격이었다 — 타이머의 스레드 수 비단조 패턴, 타이머·서비스의 Jazzy·Humble 통계 검정 "잠정적" 판단 모두 큰 표본(각 50~60회)으로 측정해 보니 근거가 사라지거나(비단조 패턴) 유의성이 없다고 확정됐다(타이머·서비스 통계). 새로 연 미확인은 ①(Humble 서비스 콜백 이중 호출)뿐이다 — 원인 후보(rclcpp의 2단계 등록 패턴)를 소스로 찾았지만 타이밍이 맞지 않아 배제했고, 66회의 재현 시도도 전부 실패해 미확인 자체는 닫지 못한 채 원인 후보 하나만 배제한 상태로 남았다. 편집(2.3.14 확장, 15.1.3·2.2.7 정직 문단, 이 표의 92차 행, 목차 6 색인)을 전부 마친 뒤 tools/book_check.py와 동일한 방식으로 마지막에 측정하니 672건으로 91차의 667건보다 5건 늘었다 — 미확인을 새로 연 것은 사실상 하나뿐인데 순증이 5인 것은, 이 문단·표 자신이 여러 차례 그 표기 문자열을 인용하는 자기지시적 증가가 대부분을 차지하는 것으로 보인다(100차 이후 반복 관찰된 패턴과 같은 원인, 정확한 델타 분해는 이번에도 하지 않았다). tools/book_check.py의 ⑳ 검사는 이번에도 통과했다(실행 확인 장 수 89개는 변화 없음).

114차(CHG-138)는 113차가 남긴 세 제안을 전부 처리했고, 그중 둘(①③)이 이전 판단을 뒤집었다 — 서비스 통계 검정은 "유의하지 않다"(113차, $p=0.072$)에서 "유의하다"(114차, $p=1.87\times10^{-4}$)로, py::set 균등성은 "기각하지 못한다"(106~110차 내내)에서 "기각한다"(114차, $p=0.0248$)로 바뀌었다 — 둘 다 표본을 충분히 늘리자 이전 회차들의 소표본 판단이 유효하지 않았음이 드러난 사례다. Humble 이중 호출(②)은 새 조건(엔티티 선행 실행)을 시도했지만 111회째도 재현되지 않아 미확인 자체는 닫히지 않았다. 편집(2.3.14 확장, 15.1.3·2.2.7·2.8.12.15 정직 문단, 이 표의 93차 행, 목차 6 색인)을 전부 마친 뒤 tools/book_check.py와 동일한 방식으로 마지막에 측정하니 675건으로 92차의 672건보다 3건 늘었다 — 정확한 델타 분해는 이번에도 하지 않았다(100차 이후 반복 관찰된 자기지시적 증가와 같은 원인으로 본다). tools/book_check.py의 ⑳ 검사는 이번에도 통과했다(실행 확인 장 수 89개는 변화 없음).

115차(CHG-139)는 114차가 남긴 세 제안을 전부 처리했다 — ①은 세 번째로 같은 패턴(소표본 "유의하지 않다"가 표본 확대로 뒤집힘)을 재현했고, ③은 108차부터 여섯 회차 이월된 ASLR 차단을 마침내 풀었다. 타이머 통계 검정을 150회로 늘리니 서비스와 똑같이 뒤집혔다(Jazzy 8.7% 대 Humble 26.7%, $p=4.36\times10^{-5}$). docker run --privileged가 이 환경의 personality() 차단을 실제로 풀어 준다는 것을 확인해 ASLR을 껐다 — 껐어도 이탈률이 0%가 되지 않아(30.0%) "ASLR이 유일한 원인"이라는 가설을 반증하는, 미확인을 닫는 방향의 결과를 얻었다. Humble 이중 호출은 재현 대신 환경 포렌식으로 접근해 새 단서(112차 관측 창에만 겹친 무관한 컨테이너 기동)를 찾았지만 인과관계는 증명하지 못해 미확인 자체는 열린 채로 남았다. 편집(2.3.14 확장, 15.1.3·2.2.7 정직 문단, 이 표의 94차 행, 목차 6 색인)을 전부 마친 뒤 tools/book_check.py와 동일한 방식으로 마지막에 측정하니 679건으로 93차의 675건보다 4건 늘었다 — 정확한 델타 분해는 이번에도 하지 않았다(100차 이후 반복 관찰된 자기지시적 증가와 같은 원인으로 본다). tools/book_check.py의 ⑳ 검사는 이번에도 통과했다(실행 확인 장 수 89개는 변화 없음).

116차(CHG-140)는 115차가 남긴 항목을 전부 처리했고, 그중 하나(Humble 이중 호출)는 사용자 판단에 따라 미확인을 "열어 둔 채" 마무리하는 대신 재현 불가로 명시적으로 종결했다. ASLR 켬·끔 비교는 150회로도 유의성을 확정하지 못했지만($p=0.082$) 방향은 계속 일관됐다. 할당 순서 비결정성 가설은 핸들 주소를 직접 출력해 처음으로 실증했다 — 최소 시나리오는 완전히 결정적이었지만 실제 디스커버리 매칭을 더하면 ASLR을 꺼도 12.5%는 주소가 달라졌다. Humble 소스 추적은 엔티티 수집 자체(콜백 그룹 하나일 때)가 결정적임을 확인해 Humble의 이탈 원인을 데이터 구조가 아니라 스레드 스케줄링으로 좁혔다 — 미확인 하나(정도 차이의 이유)를 새로 열었다기보다 기존 견해를 소스로 뒷받침한 것에 가깝다. 편집(2.3.14 확장, 15.1.3·2.2.7 정직 문단, 이 표의 95차 행, 목차 6 색인)을 전부 마친 뒤 tools/book_check.py와 동일한 방식으로 마지막에 측정하니 683건으로 94차의 679건보다 4건 늘었다 — 정확한 델타 분해는 이번에도 하지 않았다. tools/book_check.py의 ⑳ 검사는 이번에도 통과했다(실행 확인 장 수 89개는 변화 없음).

117차(CHG-141)는 116차가 남긴 세 항목을 전부 닫는 방향으로 처리했다 — 미확인을 새로 열기보다 기존 것을 닫거나 정리한 회차다. ASLR 켬·끔 비교는 300회로 완결해 "무관하다"로 최종 결론지었다(60→150회의 추세는 착시였다) — 이 절이 오래 들고 있던 미확인 하나가 닫혔다. 주소 변동과 디스패치 이탈률의 관계도 같은 프로세스에서 동시 측정해 완전한 상관을 확인함으로써 닫혔다. CHG-122 계열의 stress-ng 통제 실험은 이 책에서 가장 오래 이월된 갭이었는데, 이번 회차가 실제로 설계·실행해 스레드 경합 가설을 정량 확인하면서 닫혔다 — 다만 그 과정에서 Humble 구독의 무반응이라는 새 관찰(원인 미상)이 하나 열렸다. 편집(2.3.14 확장, 15.1.3·2.2.7 정직 문단, 이 표의 96차 행, 목차 6 색인)을 전부 마친 뒤 tools/book_check.py와 동일한 방식으로 마지막에 측정하니 684건으로 95차의 683건보다 1건 늘었다 — 세 미확인을 닫고 하나를 새로 열었으니 산수로는 순감이 예상됐지만, 이 문단·표 자신의 자기지시적 인용이 상쇄한 것으로 보인다(정확한 델타 분해는 이번에도 하지 않았다). tools/book_check.py의 ⑳ 검사는 이번에도 통과했다(실행 확인 장 수 89개는 변화 없음).

118차(CHG-142)는 96차의 유력한 가설(Humble의 매 사이클 재구성 비용) 하나를 실제 계측으로 반증했다 — 가설을 세우는 것과 반증하는 것 둘 다 이 표의 역할이다. 소스 대조·stress-ng 임계값 확정·재구성 비용 계측 셋 다 실행했고, 그중 재구성 비용 계측이 예상과 정반대(Jazzy가 더 비쌈)로 나와 기존 가설을 닫으면서 "그럼 실제 이유는 무엇인가"라는 새 미확인을 열었다. stress-ng를 Humble 서비스·타이머로 확장한 것도 예상과 반대 방향(부하가 이탈률을 낮춤)이라는 새 관찰을 하나 더했다. 편집(2.3.14 확장, 15.1.3·2.2.7 정직 문단, 이 표의 97차 행, 목차 6 색인)을 전부 마친 뒤 tools/book_check.py와 동일한 방식으로 마지막에 측정하니 686건으로 96차의 684건보다 2건 늘었다 — 정확한 델타 분해는 이번에도 하지 않았다. tools/book_check.py의 ⑳ 검사는 이번에도 통과했다(실행 확인 장 수 89개는 변화 없음).

119차(CHG-143)는 이 표 전체에서 가장 중대한 정정 중 하나를 냈다 — 미확인 하나를 닫는 수준이 아니라, 91~98차(gap109~118) 실험 전체의 메커니즘 설명이 틀렸다는 것을 발견했다. MultiThreadedExecutor의 "실제 동시 실행"이라 불러 온 것이 사실은 단 한 번도 일어나지 않았다(모든 테스트 엔티티가 기본 MutuallyExclusive 콜백 그룹에 있어 병렬 실행이 애초에 불가능했다) — 직접 계측(동시성 비율 0.0000, 전 조건 예외 없음)으로 확인했다. 측정된 이탈률 수치 자체는 유효하지만 "스레드 스케줄링 경쟁"이라는 설명은 이 테스트 계열에 적용되지 않는다. 이런 종류의 정정은 이 표가 처음 다루는 것이 아니다(ASLR·재구성 비용 가설도 비슷하게 반증됐다) — 다만 이번은 단일 실험이 아니라 여러 회차에 걸쳐 쌓인 해석의 틀 자체가 대상이라는 점에서 규모가 다르다. 부수적으로 entities_need_rebuild_가 68~70%나 걸리는 이유도 완전히 확정했다(interrupt_guard_condition_ 트리거 경로). 편집(2.3.14 확장, 15.1.3·2.2.7 정직 문단, 이 표의 98차 행, 목차 6 색인)을 전부 마친 뒤 tools/book_check.py와 동일한 방식으로 마지막에 측정하니 687건으로 97차의 686건보다 1건 늘었다 — 정확한 델타 분해는 이번에도 하지 않았다. tools/book_check.py의 ⑳ 검사는 이번에도 통과했다(실행 확인 장 수 89개는 변화 없음). 이 정정이 2.3.14의 91~98차 서술 전체(특히 "실제 동시 실행"·"병렬 실행" 표현이 반복 등장하는 문단들)에 미치는 영향을 전면적으로 재검토·재작성하는 일은 이번 회차에서 시작만 했다 — 다음 회차 이후로 이월한다.

120차(CHG-144)는 두 가지를 했다 — 119차가 남긴 "재구성-대기 연쇄" 가설의 직접 계측, 그리고 위에서 "시작만 했다"고 적은 언어 재검토의 완결이다. 계측 쪽: MultiThreadedExecutor::run()의 wait_mutex_ 락이 wait_for_work()의 블로킹 대기 전체를 감싼다는 것을 소스와 480회 실측(13,397개 구간, 겹침 비율 예외 없이 0.0000) 둘 다로 확정했고, Humble은 이 재구성이 항상(100%) 일어나는 반면 Jazzy는 조건부(45~74%)라는 배포판 차이도 확인했다 — 다만 이 연쇄의 빈도가 이탈률을 결정하는 인과적 손잡이라는 것은 확정하지 못했다(12개 설정 전체 상관 $r=0.96$의 대부분이 엔티티 종류 자체의 교란변수 효과였다, 엔티티를 고정하면 상관이 거의 사라진다). 언어 재검토 쪽: 2.3.14 서두에 "읽기 전에" 정정 고지를 신설하고 가장 상세한 개별 주장 넷에 인라인 정정을 추가했으며, 15.1.3·2.2.7도 문단 서두에 같은 고지를 추가했다. 2.10.11.3은 이번까지 정정이 전혀 없었다는 것을 새로 발견해 처음으로 추가했다 — 이 절이 놓쳤던 곳이다. 편집을 마친 뒤 측정하니 (확인 필요)는 687 → 694건으로 순증 8이었다(2.3.14·2.10.11.3을 비롯해 언어 재검토 과정에서 여러 절에 새로 단 정정 태그들의 합 — 정확한 절별 분해는 하지 않았다). tools/book_check.py는 편집 전후 모두 0건, 실행 확인 장 수 89개도 변화 없었다. 상세는 CHANGES.md CHG-144.

121차(CHG-145)는 이 책의 검증 이력 100번째 회차다. 세 갈래를 조사했다. rcl wait.c를 읽어 구독·서비스가 rcl_wait() 안에서 완전히 동일하게 처리됨을 확인해 "rcl 계층에 구조적 차이가 있다"는 가설을 반증했다(119차·CHG-143에 이어 세 번째 계층에서도 차이를 못 찾은 셈이다) — 남은 후보는 rmw/DDS 계층이다. Jazzy의 재구성을 완전히 억제하는 실험용 빌드로 서비스 이탈률을 측정하니 줄지 않았다(오히려 소폭 증가, 비유의) — 120차가 확인하지 못했던 "연쇄 빈도가 인과적 손잡이인가"라는 질문에, 이번엔 "빈도 0이어도 이탈이 유지된다"는 한 걸음 더 나아간 답을 냈다. 그러다 뜻밖의 것을 발견했다 — 같은 계측을 구독으로 확장하는 과정에서 117차의 "Humble 구독은 CPU 부하에 완전히 무반응"이라는 결론이 이번 세션에서는 재현되지 않았다(오히려 유의하게 감소, $p=0.0047$, 계측 없는 순정 빌드로도 같은 방향을 재현해 계측 부작용은 배제했다). 다만 117차의 기준값(4.0%)과 이번 세션의 기준값(24~36%) 자체가 6~9배 차이 나는 이유는 밝히지 못해, 이 구체적 결론을 세션 간 재현성 문제로 명시하고 다음 회차로 이월한다. 편집 후 측정하니 (확인 필요)는 694 → 699건으로 순증 5였다. tools/book_check.py는 편집 전후 모두 0건, 실행 확인 장 수 89개도 변화 없었다. 상세는 CHANGES.md CHG-145.

122차(CHG-146)는 121차가 세션 간 재현성 문제로 이월한 것을 직접 검증했다. 이 호스트에는 이 순간에도 36개의 무관한 컨테이너(다른 개발 스택들)가 실행 중이다 — 이것이 원인일 것이라는 가설을 /proc/loadavg 자연 변동 상관($r=-0.0149$, 사실상 0)과 통제된 저강도 stress-ng 스윕(0·2·4·8·20워커, 매끄러운 용량-반응 없이 40.0%·40.0%·40.0%·20.0%·30.0%)으로 검증했지만 지지되지 않았다 — 게다가 이 스윕 결과 자체가 같은 세션의 바로 앞 배치(121차)와도 재현되지 않아, 문제가 단순 호스트 부하보다 더 미묘하다는 것만 확인했다. rmw_fastrtps_shared_cpp까지 내려간 것(네 계층째, 이전 세 계층은 119·CHG-143·121차)도 구독·서비스의 코드 경로가 완전히 같다는 같은 결론을 냈다 — 대신 서비스가 요청+응답 DDS 엔티티 쌍을 갖는다는(구독은 하나) 구조적 비대칭을 새 후보로 찾았다. 121차의 "재구성 억제가 이탈을 늘린다"는 경향도 n=100(34.0% 대 33.0%, $p=0.88$)에서 완전히 사라져 소표본 우연으로 확정됐다. 편집 후 측정하니 (확인 필요)는 699 → 705건으로 순증 6이었다. tools/book_check.py는 편집 전후 모두 0건, 실행 확인 장 수 89개도 변화 없었다. 상세는 CHANGES.md CHG-146.

123차(CHG-147)는 122차가 새로 낸 "서비스는 구독의 두 배 DDS 엔티티" 가설을 검증했다 — 기존 자료 재분석과 직접 조작 둘 다로, 둘 다 가설에 불리하게. 클라이언트도 서비스처럼 엔티티 둘(요청 라이터+응답 리더)을 갖지만, 109·112차 자료를 다시 보니 이 책이 측정한 네 엔티티 종류 중 가장 안정적이었다(서비스 53~80%·75~80% 대 클라이언트 6.7~13.3%·0%) — 소스를 나란히 읽어도 두 테스트는 구조·설계 의도가 대칭인데 결과는 정반대다. 새 실험도 했다 — 구독의 로컬 퍼블리셔를 별도 프로세스로 옮겨 측정 대상 노드엔 리더만 남겼더니 이탈률이 방향은 가설과 일치하게 줄었지만(Jazzy 65.0%→57.5%, Humble 42.5%→32.5%) 어느 쪽도 유의하지 않았다($p=0.49$·$0.36$). "DDS 엔티티 개수"는 주된 설명이 아닐 가능성이 크다 — 서비스가 왜 유독 불안정한지는 여전히 열려 있다. Humble 구독의 세션 간 불안정성은 이번에도 새 기준값(65.0%·42.5%, 또 다른 값)만 추가됐을 뿐 메커니즘 조사는 시간 부족으로 다음 회차로 미뤘다. 편집 후 측정하니 (확인 필요)는 705 → 711건으로 순증 6이었다. tools/book_check.py는 편집 전후 모두 0건, 실행 확인 장 수 89개도 변화 없었다. 상세는 CHANGES.md CHG-147.

124차(CHG-148)는 세 갈래 모두에서 real한 진전을 냈다 — 다섯 번째로 배제된 계층, 디스커버리 타이밍 기각과 뜻밖의 발견, 그리고 Jazzy에서 처음 유의해진 엔티티 개수 효과. execute_service()·execute_client()(rclcpp)가 다섯 계층째도 완전히 같은 코드임을 확인한 뒤, 112차의 도착-시각 계측 도구로 실제 도착 시각을 재 클라이언트의 응답 도착 시간 폭이 서비스의 요청 도착 시간 폭보다 약 5배 넓다는 새 후보를 찾았다(서버가 상시 스핀하며 순차 응답하는 클라이언트 테스트 구조가 원인으로 보인다 — 엔티티 종류 자체의 속성은 아니라는 점을 명시). Humble 구독의 세션 간 불안정성은 두 회차째 관찰만 하다가 이번에 처음 직접 계측했다 — 디스커버리 타이밍 가설(도착 시각 폭이 이탈과 상관될 것)은 $r=0.07$로 기각됐지만, ARRIVAL_ORDER가 FIRE_ORDER보다 오히려 더 불안정하다(32.0% 대 62.0% 최빈 비율)는 뜻밖의 사실을 발견했다 — executor 디스패치가 어수선한 도착을 부분적으로 정돈하고 있다는 뜻이다. 123차의 엔티티 개수 조작 실험을 n=100으로 늘리니 Jazzy는 통계적으로 유의해졌다(71.0%→54.0%, $p=0.013$) — 이 가설이 Jazzy에 한해 실재하는 부분 효과로 승격됐지만, Humble은 방향이 뒤집혀(34.7%→42.0%, $p=0.29$) 세션 간 불안정성이 여기도 나타났다. 편집 후 측정하니 (확인 필요)는 711 → 717건으로 순증 6이었다. tools/book_check.py는 편집 전후 모두 0건, 실행 확인 장 수 89개도 변화 없었다. 상세는 CHANGES.md CHG-148.

125차(CHG-149)는 124차의 새 가설(도착 시간 폭)을 직접 조작해 반증하고, "정돈" 메커니즘은 소스에서 정확히 찾았다. service_spread_test(요청 사이 지연 0~200µs)로 서비스 테스트의 도착 창을 클라이언트보다도 3배 넓게 벌려 봤지만 이탈률은 유의하게 바뀌지 않았다(Jazzy는 오히려 비유의하게 증가) — 103~104차가 쌓아 온 상관관계가 인과관계로 이어지지 않는다는 것을 직접 조작으로 확인한 셈이다. 반면 Humble의 "정돈" 메커니즘(ARRIVAL_ORDER보다 FIRE_ORDER가 더 안정적인 이유)은 AllocatorMemoryStrategy::remove_null_handles()가 매 rcl_wait() 후 벡터를 준비된 항목만 등록 순서로 남기고 get_next_subscription()이 항상 처음부터 순회한다는 것으로 소스에서 명확히 설명됐다 — 회차를 더 쓸 필요가 없었다. "엔티티 개수"와 "도착 시간 폭"의 관계도 직접 측정해 봤다 — 라이터가 있으면 도착 시간 폭이 90배 가까이 넓었지만(균일한 확장이 아니라 일부가 ~70ms 늦는 지연 샘플 패턴) 방금 도착 시간 폭 자체가 이탈률을 안 바꾼다는 것을 보였으므로, 둘은 "같은 효과의 두 이름"이 아니라 라이터 유무가 만드는 별개의 두 현상으로 남았다. 편집 후 측정하니 (확인 필요)는 717 → 722건으로 순증 5였다. tools/book_check.py는 편집 전후 모두 0건, 실행 확인 장 수 89개도 변화 없었다. 상세는 CHANGES.md CHG-149.

126차(CHG-150)는 이 실험 계열 전체에서 가장 큰 단일 효과를 냈고, 그 과정에서 이 책 스스로의 "종결" 판정이 틀렸다는 것을 우연히 발견했다. 서버 사전 스핀을 직접 조작하니(측정 대상 MultiThreadedExecutor를 요청 전송 전에 미리 스핀) Humble의 이탈률이 82.1%에서 0.0%로 완전히 사라졌다($p\approx 0$) — Jazzy는 무변화. 이 실험을 실행하다 fired≠6인 경우가 반복돼 확인해 보니, 116차가 101회의 재현 실패 끝에 "재현 불가로 명시적으로 종결"했던 Humble 서비스 콜백 이중 호출이었다 — 사전 스핀은 이 이중 호출도 줄였지만(Humble 22.0%→8.0%) Jazzy에서는 오히려 0%에서 44.0%로 만들어 내, 113차의 "Jazzy는 이중 호출이 없다"는 확정과도 배치된다. 의도적으로 쫓은 것이 아니라 우연히 마주친 것이며, 정직하게 "종결"을 재개봉한다 — CLD-03에 따라 116차의 문장 자체는 지우지 않고 그 옆에 정정 포인터만 남겼다. 덧붙여 execute_service()가 execute_client()보다 4.7배 더 걸린다는 것을 실측했고(103차의 견해가 확인됨), "지연 샘플" 패턴(125차)을 tcpdump로 캡처하니 하트비트·재전송이 전혀 안 보여 네트워크 수준이 아니라는 것도 확인했다. 편집 후 측정하니 (확인 필요)는 722 → 729건으로 순증 7이었다. tools/book_check.py는 편집 전후 모두 0건, 실행 확인 장 수 89개도 변화 없었다. 상세는 CHANGES.md CHG-150.

127차(CHG-151)는 126차가 남긴 최우선 질문(사전 스핀이 왜 효과를 내는가)에 대한 두 가설을 실측으로 기각하고, 113차의 조사 전제 자체가 틀렸다는 것을 밝혔다. 사전 스핀의 메커니즘을 "재구성-대기 캐스케이드 횟수 차이" 가설로 측정했지만 두 조건의 캐스케이드 횟수가 거의 같아(28.0 대 27.8) 이탈률 차이(3.3% 대 73.3%)를 설명하지 못해 기각됐고, "스레드 지역성" 가설도 정반대 방향(사전 스핀 쪽이 스레드 전환율 97.0%로 더 높음)이 나와 기각됐다 — 가장 큰 단일 효과(126차)의 실제 원인은 두 회차 연속 못 찾았다. 113차가 Jazzy 이중 호출과 무관한 것으로 배제했던 "2단계 등록" 경로를 다시 확인하니, 그 조사가 애초에 이번 이중 호출을 낸 도구가 쓰지 않는 API를 겨냥한 것이었다는 전제 오류가 드러났다(113차 원문은 보존, 정정 포인터만 추가) — 대신 캐시된 wait_result_의 전진 전용 반복자와 락 밖 DDS take 사이의 미세한 타이밍 간극을 새 후보 원인으로 소스에서 찾았지만 계측으로 확정하지 못했다. "지연 샘플" 패턴은 FastDDS 내부 경고 로그로도 지연 구간에 활동이 전혀 없어 126차 tcpdump에 이어 두 번째 "배제" 판정을 받았다. 편집 후 측정하니 (확인 필요)는 729 → 739건으로 순증 10이었다 — 이번에도 이 문서 자신이 위 표·문단에서 그 표기 문자열을 여러 차례 인용하며 실제 편집(주로 새 절의 미확인 태그 3건) 이상으로 늘어난, 100차 이후 반복 관찰된 자기지시적 증가로 보인다. tools/book_check.py는 편집 전후 모두 0건, 실행 확인 장 수 89개도 변화 없었다. 상세는 CHANGES.md CHG-151.

128차(CHG-152)는 127차가 두 회차 연속 못 찾던 사전 스핀 메커니즘을 마침내 사건 단위 타임스탬프로 찾아냈다. 집계(총 재구성 횟수)가 아니라 재구성 각각의 발생 시각과 요청 도착·디스패치 시각을 같은 프로세스·같은 시계로 측정하니, "마지막 도착~마지막 디스패치" 구간에 재구성이 몇 번 몰리는지가 사전 스핀 7~11회 대 기준 23~28회로 완전히 갈렸다(Welch $t=49.19$) — 총량은 거의 같지만(126·127차가 이미 확인) 그 분포가 완전히 다르다는 것이 핵심이었다. 기준 조건은 스핀 시작 순간 스레드 4개가 이미 준비된 데이터를 두고 뮤텍스를 경합하며 짧은 창에 몰아치고, 사전 스핀은 그 다툼을 400ms에 걸쳐 미리 분산해 소화한다 — 다만 조건 내부에서는 이 개수가 개별 이탈을 예측하지 못해 인과 경로까지는 못 닫았다. 127차의 새 후보 원인(Jazzy wait_result_ 경합 창)를 계측하려 총 240회를 사전 스핀 조건으로 실행했지만 이번 세션엔 이중 호출이 단 한 번도 재현되지 않아, 바로 앞 회차의 44.0%와 대비되는 세션 의존성만 재확인했다. "지연 샘플" 패턴은 gdb 반복 attach가 1회 평균 ~295ms(현상보다 4배 큰 프로브 효과)임을 실측으로 확인해 이 스케일엔 부적합하다고 판단하고, 대신 /proc 스레드 상태 폴링(저오버헤드, ptrace로 안 멈춤)으로 갈아타 세 번째 "배제"(지속적 CPU 활동 없음)를 확인했다 — 방법론 자체의 개선도 이번 회차의 성과다. 편집 후 측정하니 (확인 필요)는 739 → 749건으로 순증 10이었다 — 이번 회차도 실제 새 미확인 태그(①②③ 각 절 끝 + 이 문단·표의 인용)보다 이 문서 자신의 자기지시적 인용이 더해진 값으로 보이며, 정확한 분해는 이번에도 하지 않았다(100차 이후 반복 관찰된 패턴과 같은 원인). tools/book_check.py는 편집 전후 모두 0건, 실행 확인 장 수 89개도 변화 없었다. 상세는 CHANGES.md CHG-152.

129차(CHG-153)는 세 항목 모두에서 실질적 진전을 냈다 — 128차의 "재구성 몰림" 관찰에 마침내 인과 경로가 확인됐다. Humble 소스를 다시 읽어 wait_for_work()·get_next_ready_executable_from_map()이 재구성·필터링·선택을 서로 다른 세 번의 별개 mutex_ 획득으로 처리하고, 무잠금 상태로 블로킹되는 rcl_wait() 도중 다른 스레드가 같은 공유 wait_set_을 자유롭게 다시 짓밟을 수 있다는 것을 확인했다 — 사건 단위 계측으로 측정하니 기준 조건의 이탈 실행 22/22 전부(100%)에서 등록 순서가 처음 깨지는 순간에 다른 스레드의 재구성 또는 실행 창이 겹쳐 있었다(실측 사례: 첫 선택이 등록 순서 0이 아니라 3). 다만 사전 스핀이 이 경합 자체를 왜 원천적으로 막는지는 이번에도 완전히 분리하지 못했다. Jazzy 이중 호출은 86차식 결정론적 결함 주입(경합 창을 최대 800배까지 확대)으로 검증했지만 총 140회 전부 재현되지 않아 127차의 구체적 가설에 불리한 증거가 쌓였다(이 세션 자체의 무주입 재현율도 0%였다는 조건부 단서는 남긴다). "지연 샘플" 패턴은 strace -T로 futex 호출 인자를 직접 봐 정체가 드러났다 — 어떤 스레드가 절대 시각 데드라인이 있는 타임드 웨이트로 블로킹된 뒤 다른 스레드의 웨이크업 신호를 못 받고 자기 데드라인(~62~64ms, 관측된 갭과 거의 일치)이 만료될 때까지 기다렸다가 ETIMEDOUT으로 깨어났다 — 126~128차의 세 배제 판정과 정확히 들어맞는, 웨이크업 경로가 연결 안 된 타임드 폴백이었다. 편집 후 측정하니 (확인 필요)는 749 → 757건으로 순증 8이었다(이번에도 이 문서 자신의 자기지시적 인용이 섞인 값으로 보인다, 100차 이후 반복 관찰된 패턴). tools/book_check.py는 편집 전후 모두 0건, 실행 확인 장 수 89개도 변화 없었다. 상세는 CHANGES.md CHG-153.

130차(CHG-154)는 뜻밖의 정정 하나와 확실한 진전 하나를 냈다. 129차 원시 로그를 재분석해(새 컨테이너 실행 없이) 사전 스핀의 400ms idle 구간이 "계속 순환"이 아니라 "시작 직후 ~935µs 몰아치기 + 이후 399ms 단일 rcl_wait() 블록"이었음을 발견해 127·128차 서술을 정정했다 — 다만 이를 반영한 "첫 PICK 시점 스레드 위상 폭" 지표 자체는 사전 스핀·기준 간 유의한 차이를 못 냈다(Welch $t=-1.93$, 방향도 가설과 안 맞았다) — 129차의 메커니즘을 이 지표로는 확인하지 못했다. Jazzy 이중 호출은 소스를 다시 읽어 busy 플래그가 선택 시점부터 처리 완료까지 전 구간을 감싼다는 것을 확인해, 129차의 지연 지점이 왜 효과가 없었는지 설명이 됐다 — 더 이른 지점(busy 세우기 직전)으로 옮겨도(누적 220회) 재현이 안 돼 127차 가설이 사실상 배제됐다. "지연 샘플" futex는 bpftrace가 BTF 파싱 실패로 막히자 strace -k(라이브러리 unwind, BTF 불필요)로 갈아타 완전히 심볼화됐다 — FastDDS 라이터 쪽 흐름 제어 스레드 FlowControllerImpl::run()이 ~152.6ms 블로킹돼 있다가 깨어난 직후 지연 샘플이 도착했다, 주소만 있던 129차와 달리 정확한 클래스·함수명을 확보한 것 자체가 진전이다. 편집 후 측정하니 (확인 필요)는 757 → 767건으로 순증 10이었다(이번에도 이 문서 자신의 자기지시적 인용이 섞인 값으로 보인다, 100차 이후 반복 관찰된 패턴). tools/book_check.py는 편집 전후 모두 0건, 실행 확인 장 수 89개도 변화 없었다. 상세는 CHANGES.md CHG-154.

131차(CHG-155)는 방법론적 실패 하나를 정직하게 거쳐 락 A 충돌의 명확한 신호를 얻었다. 첫 시도(구간이 실제로 겹치는 횟수)는 뮤텍스로 원천 불가능해 예외 없이 0이었다 — 지표를 "재구성-끝부터 선택까지 다른 스레드의 재구성-시작이 몇 번 개입하는가"로 바꾸니 사전 스핀 30회 전부 0회 대 기준 평균 4.00회(최대 18회)로 완벽하게 갈렸다(Welch $t=4.708$) — 129차의 세 단계 락 경합 메커니즘이 사전 스핀에서 사라진다는 것을 마침내 직접 지표로 확인했다(다만 락 A 개입이 이탈의 유일한 경로는 아니었다, 개입 0인데도 이탈한 실행이 있었다). Jazzy 이중 호출은 rmw_fastrtps_shared_cpp 소스로 unread-count가 캐시가 아니라 실시간 조회임을 확인해 129차 가설이 이 형태로는 성립하지 않는다는 것을 알았고, 스레드 16개 스트레스 테스트(N=6 대비 2.7배 과잉)로도 재현하지 못해(127~131차 누적 270회 0건) 남은 창이 매우 좁거나 특정 호스트 조건 의존일 가능성만 남겼다. "지연 샘플"은 FlowControllerImpl.hpp 소스를 직접 읽어 130차의 "~150ms 페이싱 주기" 해석이 틀렸음을 확인했다 — 실제로는 고정 주기가 전혀 없는 순수 무기한 대기이고, "동기 전달이 실패한" 샘플이 큐에 들어올 때만 깨어난다(→ 130차 정정) — 실제 질문은 "동기 전달이 왜 그 샘플에서만 실패했는가"로 완전히 바뀌었다. 편집 후 측정하니 (확인 필요)는 767 → 777건으로 순증 10이었다(이번에도 이 문서 자신의 자기지시적 인용이 섞인 값으로 보인다, 100차 이후 반복 관찰된 패턴). tools/book_check.py는 편집 전후 모두 0건, 실행 확인 장 수 89개도 변화 없었다. 상세는 CHANGES.md CHG-155.

132차(CHG-156)는 세 항목 모두에서 소스를 더 깊이 조사해 앞선 회차의 결론을 다듬었다. 104차의 "배치 분할"을 129차 원시 로그로 재분석하니 사전 스핀(5.93/6)·기준(5.17/6) 둘 다 N에 가까워 독립 변수가 아니라 락 A 현상의 증상이었음이 드러났다(오히려 무이탈 조건인 사전 스핀 쪽이 더 높았다) — 락 A 개입=0인 기준 실행의 64%가 여전히 이탈해, 락 A 하나만으로는 못 설명하는 잔차가 남았다. Jazzy의 unread-count는 rmw 계층에서만 보면 실시간 조회였지만 더 깊이(RTPSReader까지) 내려가니 total_unread_라는 유지되는 카운터였고(131차 정정), 리스너 경로(get_last_notified())와 실제 take 경로(change_read_by_user())가 서로 다른 기준으로 같은 카운터를 독립적으로 갱신한다는 구조적 경합 여지를 찾았다. "지연 샘플"의 동기 전달 실패는 StatefulReader::processDataMsg()의 반환값으로 정체가 좁혀졌고, KeepLast(1) QoS와 관련된 후보 분기까지 찾았지만 실제 깊이 제한 구현(DataReaderHistory)까지는 내려가지 못했다. 편집 후 측정하니 (확인 필요)는 777 → 787건으로 순증 10이었다(이번에도 이 문서 자신의 자기지시적 인용이 섞인 값으로 보인다, 100차 이후 반복 관찰된 패턴). tools/book_check.py는 편집 전후 모두 0건, 실행 확인 장 수 89개도 변화 없었다. 상세는 CHANGES.md CHG-156.

112차(CHG-157)는 132차가 남긴 세 항목을 순서대로 처리했다. ①(최우선) 132차의 잔차(락 A 개입=0인데도 이탈한 기준 실행 7건)를 129차 원시 로그로 개별 재검토하니 무작위 뒤섞임이 아니라, 등록 순서 안의 어떤 연속 구간(창)에서 그 창의 마지막 원소가 맨 앞으로 이동하고 나머지는 정확히 원래 상대 순서를 유지하는 "국소 창 회전"이었다 — 7건 전부가 이 형태였다. 103·124차가 이미 확립한 "동시에 보내도 실제 도착 순서는 등록 순서와 미세하게 어긋난다"는 현상의 재현으로 보이며, 락 A 개입과는 서로 다른 독립된 이탈 경로로 보인다. ② Jazzy RTPSReader::update_last_notified()의 호출부(StatefulReader::NotifyChanges())를 직접 읽으니 "notified" 워터마크는 개별 변경을 리스너에게 알리기 전에 이미 max_seq까지 미리 당겨지는 결정론적 순서였다 — "드문 경합 창"이 아니라 매 알림마다 항상 일어나는 일이라, 오히려 129~132차의 경합 창 가설에 불리한 증거로 작용했다(결정론적 원인이면 이중 호출도 항상 일어나야 하는데 실제로는 드물다). ③ DataReaderHistory::can_change_be_added_nts()는 전체 리소스 한도만 검사하고, KeepLast(depth)의 실제 강제는 received_change_keep_last()에 있으며 같은 인스턴스에 이미 depth개의 미소비 샘플이 있을 때만 발동한다 — 이 책의 지연 샘플 재현 테스트는 토픽마다 정확히 한 번만 발행하므로 이 분기가 구조적으로 발동할 수 없다는 것이 소스로 확인됐다. 111·132차가 유력 후보로 지목한 KeepLast(1) 깊이 제한 가설은 이로써 배제됐다. "동기 전달 실패"의 실제 원인은 여전히 열려 있다. 편집 후 측정하니 (확인 필요)는 787 → 797건으로 순증 10이었다. tools/book_check.py는 편집 전후 모두 0건. 상세는 CHANGES.md CHG-157.

113차(CHG-158)는 132차가 남긴 세 항목을 직접 계측으로 추적했다. ①(최우선) 133차가 "추정"만 했던 "국소 창 회전=도착 미세 순서 어긋남" 가설을, 128차의 리스너 콜백 기반 도착 시각 계측 도구로 직접 검증했다 — 이탈 실행 28/28(사전 스핀 1 + 기준 27) 전부에서 도착 순서가 완벽한 등록 순서였다(FIRE_ORDER가 크게 뒤섞인 경우조차). 가설은 반증됐고, 이탈은 도착 잡음이 아니라 순수 executor 선택/재구성 경합(락 A)에서만 만들어진다는 것이 확정됐다. ② rmw_wait.cpp를 다시 읽어 rcl_wait()의 실제 준비 판정 게이트가 129~133차가 판 total_unread_와는 완전히 별개인 DataReader::get_first_untaken_info()(data_available_instances_, 실제 애플리케이션 take로만 비워짐)임을 확정했다 — total_unread_ 경로는 리스너 콜백 트리거만 결정할 뿐 대기집합 준비 판정과는 무관했다. ③ processDataMsg()의 후보 분기들을 ros-humble-fastrtps-dbgsym으로 재빌드 없이 GDB 직접 계측했다(최적화 빌드의 "동일 코드 접기"로 소스 줄번호 중단점이 여러 분기를 뒤섞는 함정을 발견하고 우회했다) — 29회·1566회의 실제 동기 전달에서 132·133차가 나열한 후보 전부(can_change_be_added_nts·reserve_cache·get_payload·change_received)가 단 한 번도 실패하지 않았다(다만 이 세션엔 "지연 샘플" 자체가 재현되지 않았다). 편집 후 측정하니 (확인 필요)는 797 → 805건으로 순증 8이었다. tools/book_check.py는 편집 전후 모두 0건. 상세는 CHANGES.md CHG-158.

114차(CHG-159)는 133차가 남긴 세 항목을 결함 주입과 GDB 없는 재계측으로 추적했다. ①(최우선) get_first_untaken_info()의 게이트(data_available_instances_)가 이중 준비 신호를 낼 수 있는지 add_to_instance()·check_and_remove_instance()의 타이밍을 결함 주입(erase 직전 지연 최대 50ms)으로 시험했다 — fastrtps 2.6.12를 실제로 재빌드해 디스커버리 트래픽 포함 9084건의 add/remove 사건을 관찰했는데 개입한 경우가 0건이었다(두 함수가 같은 리더 뮤텍스로 완전히 직렬화돼 구조적으로 불가능). ② processDataMsg()에 GDB 대신 원자적 카운터를 심어 재빌드했다 — "지연 샘플" 재현율이 GDB 세션의 0%에서 40%대(16/40·17/40)로 상승해, GDB 오버헤드가 억누르고 있었다는 우려가 확인됐다. 재현된 33건에서도 후보 분기는 여전히 안 실패했지만, 131차가 지목한 enqueue_new_sample_impl()(동기 실패 시에만 불림)이 40회 전부 0번이었고 대신 무관한 재전송 함수(add_old_sample_impl())가 지연 샘플 유무와 무관하게 매번 정확히 13회 불려, FlowControllerImpl::run() 스레드의 정체가 "동기 실패 처리"가 아니라 "정기 재전송"이었을 가능성을 열었다. ③ 도착-시각 도구와 [GAP129-EVT] 재구성/선택 로그를 사건 단위로 대조하니 스왑(44건)의 79.5%가 락 A 개입과 동반됐다(정상 픽 136건은 6.6%) — 108·110차의 회차-단위 상관을 개별 스왑 하나하나로 확인했다. 편집 후 측정하니 (확인 필요)는 805 → 812건으로 순증 7이었다. tools/book_check.py는 편집 전후 모두 0건. 상세는 CHANGES.md CHG-159.

115차(CHG-160)는 134차가 남긴 세 항목을 결함 주입·개별 재검토·픽 위치 분리로 추적했다. ①(최우선) 재전송(add_old_sample_impl())에 사건 단위 타임스탬프를 남겨 지연 샘플 도착 시각과 직접 겹쳐 보니, "우연"이라던 이전 회차 판단과 달리 지연 샘플 16/16건 전부에서 재전송이 갭 창 안에 2~8회씩 정확히 들어와 있었다(우연 기대치의 약 18배) — 다만 인과 방향은 여전히 열려 있다. ② 락 A 개입 없는 잔차 스왑(9/44)을 60회 표본으로 재검토하니 잔차 스왑 28건(75건 중) 전부에서 그 스레드가 자기 재구성 사이클을 이 실행 안에서 한 번도 기록하지 않았다 — Executor::get_next_executable()이 wait_for_work()(락 A 재구성)보다 get_next_ready_executable()을 먼저 시도하기 때문에 다른 스레드가 남긴 벡터를 자기 재구성 없이 바로 선택하는 "빠른 경로"였다(락 A와 합치면 스왑의 100%가 설명된다). ③ 픽 순번으로 나누니 스왑률이 60.0%(픽#0)→36.7%→25.0%→3.3%→0%→0%로 단조 감소했고, 빠른 경로 스왑 28건 전부가 픽#0~2에만 몰려 있었다 — 첫 픽의 스왑은 락 A 경합과 빠른 경로가 겹치는 스핀-시작 특유의 현상이었다. 편집 후 측정하니 (확인 필요)는 812 → 816건으로 순증 4이었다. tools/book_check.py는 편집 전후 모두 0건. 상세는 CHANGES.md CHG-160.

116차(CHG-161)는 135차가 남긴 세 항목을 처리했다(③은 코디네이터의 제안대로 스왑 메커니즘 대신 Humble-Jazzy 근본 원인으로 무게중심을 옮겼다). ①(최우선) 재전송(add_old_sample_impl())을 실제로 억제하는 결함 주입을 넣어 정상·억제 각 40회를 실행했다 — 정상 40/40은 6개 메시지 전부 수신했지만 억제 40/40은 단 하나도 수신하지 못했다. TRANSIENT_LOCAL 구독이 발행보다 늦게 생기는 이 테스트 구조상, 재전송이 지연 샘플 하나만의 지연이 아니라 6개 메시지 전부의 유일한 전달 경로였다는 것이 결함 주입으로 완전히 확인됐다 — 136차의 "우연 아닌 겹침"을 넘어 인과가 닫혔다. ② "빠른 경로" 픽에 "마지막 재구성자" 스레드를 사건 단위로 남기니 29건 전부(100%)에서 선택한 스레드가 아니라 다른 스레드의 재구성 상태를 물려받았다(그 외 픽은 23.6%만) — "왜 남의 상태인가"까지 완전히 닫혔다. ③ Jazzy의 선택 경로(get_next_ready_executable())를 처음 직접 읽고 계측해 Humble과 같은 세션에서 비교했다 — Jazzy는 공유 wait_result_를 매번 reset()하는 구조로 Humble의 점진적 벡터와 다르다는 것을 확인했지만, 같은 세션 절대 이탈률(Humble 73.3% 대 Jazzy 100%)은 113·114차 기존 기준과 방향이 반대라 계측 오버헤드 의심으로 신뢰하지 않는다 — 다만 Jazzy 스왑의 94.2%가 Humble의 두 메커니즘(락 A·빠른 경로) 어느 쪽에도 안 맞는다는 구조적 사실은 108차의 해시 순서 가설이 별개 메커니즘일 가능성을 뒷받침한다. 편집 후 측정하니 (확인 필요)는 816 → 822건으로 순증 6이었다. tools/book_check.py는 편집 전후 모두 0건. 상세는 CHANGES.md CHG-161.

117차(CHG-162)는 136차가 남긴 세 항목을 처리했다. ①(최우선) service_prespin_evt를 계측 없는 시스템 rclcpp에 그대로 빌드해 같은 세션에서 재비교하니 Jazzy는 여전히 60/60(100%) 이탈이었다(Humble은 73.3%→50.0%로 실제 바뀌었지만 방향은 안 바뀐다) — 116차의 "계측 오버헤드" 추정은 정정한다, 역전은 121·122차의 세션 변동성이었다. ② Jazzy의 선택 순서 정체를 소스로 완전히 확정했다 — collect_entities()→ExecutorEntitiesCollection::ServiceCollection(std::unordered_map<const rcl_service_t*, ...> 상속)→update_entities()의 begin()~end() 순회, 즉 해시 버킷 순서다(108차의 원래 가설). 116차의 Jazzy 60회 로그를 재분석하니 61.7%(37/60)가 정확히 같은 FIRE_ORDER 하나(5,3,2,1,0,4)로 수렴했다 — 순수 레이스로는 설명 안 되는 결정론적 편향의 증거다. ③ processDataMsg()에 GUID 엔티티ID를 남겨 114·115차 측정(1566회, 0건 실패)의 실제 구성을 확인하니 우리 6개 테스트 토픽은 19.7%(360/1830)뿐이었고 80.3%는 배경 트래픽(엔티티ID 하나가 73.8%)이었다 — 우리 자신의 360회는 0건 실패로 재확인됐다. 편집 후 측정하니 (확인 필요)는 822 → 826건으로 순증 4이었다. tools/book_check.py는 편집 전후 모두 0건. 상세는 CHANGES.md CHG-162.

118차(CHG-163)는 137차가 남긴 항목①②를 처리한 뒤(③은 코디네이터의 판단대로 실무 지침 집필로 전환), 32회차(108~139차) executor 디스패치 조사를 정리했다. ① DataReaderHistory의 topic_name_에 공개 접근자를 추가해 배경 트래픽의 정체를 토픽 이름까지 확정했다 — ros_discovery_info(73.8%)·rt/parameter_events(6.6%)였다. ② Jazzy 60회 로그를 재분석하니 4개의 서로 다른 순서가 반복적으로 나타나 전체의 81.7%(49/60)를 차지했다 — "지배적 순서 하나 + 잡음"이 아니라 소수의 안정적인 해시 버킷 순서 후보군이 있는 것으로 보인다(단발 순서 11/60이 잡음인지 표본 부족인지는 못 갈랐다). ③ 코디네이터의 판단대로 두 배포판의 핵심 메커니즘(Humble: 락 A+빠른 경로, Jazzy: 해시 버킷 순서)이 이미 충분히 규명됐다고 보고, 2.3에 신규 절 2.3.15(실무 지침)를 추가해 증명된 것(같은 타입 엔티티 순서 비보장, Humble의 사전 스핀 완화 82.1%→0.0%, TRANSIENT_LOCAL 선발행의 별도 지연 경로)과 증명되지 않은 것(이중 호출 원인·ASLR 부분적 역할·Humble이 더 불리한 정량적 이유)을 정리했다. 2.3.11·2.3.13·15.1.5·15.1.9에도 요약을 반영했다. 편집 후 측정하니 (확인 필요)는 826 → 830건으로 순증 4이었다. tools/book_check.py는 편집 전후 모두 0건. 상세는 CHANGES.md CHG-163.

C++ 예시는 컴파일로 한 번 걸렀다(2026-08-18, 69차). rclcpp를 쓰면서 생략기호가 없는 코드펜스 122개를 추출해 Jazzy 헤더로 -fsyntax-only -Wdeprecated-declarations 컴파일했다. 한계가 분명하다 — 대부분이 문맥에서 떼어낸 조각이라 주변 변수(node_·timer_ 등)가 없어 온전히 통과한 것은 19개이고, 나머지는 그 잡음에 묻힌다. 그래도 잡음과 무관한 신호 넷이 걸렸다: 인자 개수가 틀린 호출 세 곳(1.10.11), 폐기 예정 오버로드 네 곳(2.4·1.7), 폐기 예정 헤더 두 줄(6.4), 그리고 존재하지 않는 메서드 하나(2.10.11.2). 전부 읽어서는 걸러지지 않는 종류이며, 컴파일러만이 인자 개수와 폐기 표시를 센다. 여기 없는 장의 예시는 아직 컴파일해 보지 않았다(확인 필요).

Python 예시는 이름을 런타임과 대조했다(2026-08-18, 70차). 컴파일러가 없으므로 ast로 import·열거 멤버·생성자 키워드를 추출해 실물에 물었다 — 68개 import 전부, 열거 4종, 키워드 4종이 실재했고 문법 오류도 폐기 형태도 없었다. 다만 이름만 본다 — 인자 개수·순서는 못 보므로 C++에서 포착된 종류의 결함은 이 방법으로 걸러지지 않는다. 이름이 있어도 동작이 기대와 다른 경우가 있다는 것도 별개다(2.8.12.10). 방법과 한계는 2.8.13에 적었다.

이름은 잘 맞고, 틀리는 것은 의미다(2026-08-18, 69~71차). 세 차례에 걸쳐 기계로 대조할 수 있는 이름을 전수 확인했다 — C++ import에 해당하는 헤더·타입, Python import·열거·키워드 76개, ros2·colcon 명령 조합 81종과 롱플래그 31종, 인터페이스 타입 53종. 이름에서 나온 결함은 0이다. 반면 같은 기간 의미·서명에서는 꾸준히 나왔다 — 인자 개수, 폐기된 오버로드, 체이닝 가능 여부, 우선순위 방향, 보고값과 실제 동작의 불일치. 이름은 문서의 가장 눈에 띄는 부분이라 옮겨 적어도 잘 맞고, 의미는 문서에 한 줄로만 있어 옮기는 과정에서 사라진다. 그래서 이 책의 남은 확인 노력은 이름이 아니라 의미 쪽에 쓴다.

견해: 이 책을 근거로 무언가를 결정하기 전에, 그 문장에 (실행 확인)이 붙어 있는지 보라. 붙어 있으면 Jazzy에서 그대로 재현된다. 없으면 --help·헤더·ros2 interface show로 5분 안에 확인할 수 있는 경우가 대부분이고, 이 책은 각 장에 그 확인 방법을 함께 적어 두었다.

장 인덱스

상태 범례: ✅ 작성완료 · 🚧 작성중 · ⬜ 미착수

제0부 개관과 출발 (0.1~0.7)

ROS 2가 무엇이고 왜 이렇게 생겼는지를 세운다. 설치하고 첫 패키지를 실행하는 데까지가 목표다. — 처음 시작한다면 여기부터, 순서대로.

  • ✅ 0.1 — ROS 2란 무엇인가: 미들웨어·프레임워크·생태계
  • ✅ 0.2 — ROS 1에서 ROS 2로: 설계 결정과 그 대가
  • ✅ 0.3 — Jazzy Jalisco의 정체: 배포 주기·플랫폼·수명
  • ✅ 0.4 — 설치와 환경: apt·소스·Docker·환경변수
  • ✅ 0.5 — 워크스페이스·오버레이·언더레이의 원리
  • ✅ 0.6 — 첫 패키지 워크스루: C++와 Python
  • ✅ 0.7 — ROS 2 CLI 체계와 데몬의 내부

제1부 계산 그래프의 코어 (1.1~1.13)

노드·토픽·서비스·액션·파라미터 — 모든 ROS 2 코드가 딛고 서는 층이다. QoS와 생애주기도 여기 있다. — 현업에서 무엇을 하든 이 부는 전부 필요하다.

  • ✅ 1.1 — 노드와 그래프: 이름·네임스페이스·리매핑
  • ✅ 1.2 — 인터페이스 정의: msg·srv·action과 IDL
  • ✅ 1.3 — 타입 지원과 직렬화: rosidl·CDR·type hash
  • ✅ 1.4 — 토픽: 발행과 구독의 전 경로
  • ✅ 1.5 — QoS 심층 I: 정책과 호환성 매트릭스
  • ✅ 1.6 — QoS 심층 II: 프로파일 설계와 함정
  • ✅ 1.7 — 서비스: 요청·응답의 실제와 데드락
  • ✅ 1.8 — 액션: 목표·피드백·취소의 상태기계
  • ✅ 1.9 — 파라미터: 선언·타입·콜백·이벤트
  • ✅ 1.10 — 시간: Clock·ROS Time·sim time·Rate
  • ✅ 1.11 — 로깅: 로거 계층·구성·rosout
  • ✅ 1.12 — 라이프사이클 노드: 관리형 상태기계
  • ✅ 1.13 — 컴포지션: 컴포넌트 노드와 프로세스 내 결합

제2부 실행 모델·클라이언트 라이브러리 내부 (2.1~2.10)

콜백이 언제·어느 스레드에서 실행되는가. rclcpp·rcl·rmw 계층 해부부터 executor·콜백 그룹·타이머·메모리·intra-process, 그리고 rclpy의 GIL·비동기·스레드 안전성·NodeOptions 확장까지 다룬다. — "왜 콜백이 안 불리나"·"왜 느린가"를 쫓을 때.

  • ✅ 2.1 — 계층 해부: rclcpp·rcl·rmw·rosidl·DDS
  • ✅ 2.2 — wait set과 executor의 원리
  • ✅ 2.3 — Executor 종류와 선택
  • ✅ 2.4 — 콜백 그룹·동시성·재진입
  • ✅ 2.5 — 타이머의 정밀도와 드리프트
  • ✅ 2.6 — 메모리 모델: allocator·loaned message
  • ✅ 2.7 — intra-process 통신과 zero-copy
  • ✅ 2.8 — rclpy 심층: GIL·executor·비동기
  • ✅ 2.9 — 스레드 안전성과 흔한 경쟁 조건
  • ✅ 2.10 — NodeOptions·노드 인터페이스·확장 포인트

제3부 미들웨어: DDS·RTPS·RMW (3.1~3.10)

ROS 2 아래의 미들웨어 — DDS 개념, RTPS 와이어, RMW 교체, 디스커버리 비용, 네트워크 문제, DDS 아닌 대안 rmw_zenoh. — 여러 대를 추가하거나 통신이 이상할 때 반드시 온다.

  • ✅ 3.1 — DDS 개념: DCPS·도메인·엔티티
  • ✅ 3.2 — RTPS 와이어 프로토콜
  • ✅ 3.3 — 디스커버리: SPDP·SEDP와 그 비용
  • ✅ 3.4 — Discovery Server와 대규모 그래프
  • ✅ 3.5 — RMW 계층: API와 구현 교체
  • ✅ 3.6 — 미들웨어 벤더 비교
  • ✅ 3.7 — Fast DDS 튜닝: XML 프로파일·전송
  • ✅ 3.8 — Cyclone DDS 튜닝
  • ✅ 3.9 — 네트워크: 멀티캐스트·무선·컨테이너
  • ✅ 3.10 — rmw_zenoh: DDS 아닌 대안

제4부 빌드·패키징·배포 (4.1~4.10)

빌드·패키징·배포 — ament·colcon·rosdep·워크스페이스 전략·크로스 컴파일·릴리스(bloom)·CI. — 혼자 쓰던 코드를 팀·제품으로 옮기는 단계.

  • ✅ 4.1 — ament 빌드 시스템의 구조
  • ✅ 4.2 — ament_cmake 심층
  • ✅ 4.3 — ament_python과 엔트리포인트
  • ✅ 4.4 — package.xml format 3
  • ✅ 4.5 — colcon 심층: 그래프·mixin·확장
  • ✅ 4.6 — rosdep과 시스템 의존성
  • ✅ 4.7 — 소스 빌드·vcstool·워크스페이스 전략
  • ✅ 4.8 — 크로스 컴파일과 arm64
  • ✅ 4.9 — 릴리스: bloom·rosdistro·빌드팜
  • ✅ 4.10 — CI와 품질 게이트

제5부 좌표·모델 (5.1~5.7)

좌표와 로봇 모델 — tf2, URDF/xacro, SDF. 로봇의 "몸"을 소프트웨어에 기술하는 층이다. — 센서를 장착하거나 팔을 움직이기 전에.

  • ✅ 5.1 — tf2 아키텍처: 버퍼·리스너·브로드캐스터
  • ✅ 5.2 — tf2 실전: 시간·외삽·정적 변환의 함정
  • ✅ 5.3 — URDF 심층
  • ✅ 5.4 — xacro: 매크로와 조립
  • ✅ 5.5 — robot_state_publisher와 관절 상태
  • ✅ 5.6 — SDF와 URDF: 기술 형식의 이중성
  • ✅ 5.7 — 로봇 모델 품질 검증

제6부 하드웨어 인터페이스 (6.1~6.9)

실제 하드웨어와 잇는다 — ros2_control 전반, 드라이버 작성, micro-ROS, 진단. — 모터가 회전하는 로봇을 만들 때.

  • ✅ 6.1 — ros2_control 아키텍처
  • ✅ 6.2 — 하드웨어 컴포넌트 작성
  • ✅ 6.3 — Resource Manager·Controller Manager 내부
  • ✅ 6.4 — 컨트롤러 작성과 체이닝
  • ✅ 6.5 — 표준 컨트롤러 카탈로그
  • ✅ 6.6 — 실시간 루프와 ros2_control의 제약
  • ✅ 6.7 — 드라이버 작성 원칙: 시리얼·CAN·EtherCAT
  • ✅ 6.8 — micro-ROS: MCU와 ROS 2
  • ✅ 6.9 — 진단: diagnostic_updater와 상태 보고

제7부 센서·인지 파이프라인 (7.1~7.8)

센서 데이터의 규약과 파이프라인 — 메시지 규약, 이미지 파이프라인·카메라·라이다·포인트클라우드, IMU·오도메트리 융합, 시간 동기, 대용량 설계. — 인지 스택을 구축하기 전에.

  • ✅ 7.1 — 센서 메시지 규약: REP 103·105·145
  • ✅ 7.2 — 이미지 파이프라인: image_transport·cv_bridge
  • ✅ 7.3 — 카메라 모델·캘리브레이션·CameraInfo
  • ✅ 7.4 — 포인트클라우드: PointCloud2·PCL
  • ✅ 7.5 — LiDAR·LaserScan 처리
  • ✅ 7.6 — IMU·오도메트리·robot_localization
  • ✅ 7.7 — 시간 동기화: message_filters·PTP
  • ✅ 7.8 — 대용량 데이터 파이프라인 설계

제8부 항법 (8.1~8.8)

Nav2 전반 — 아키텍처·SLAM Toolbox·AMCL·코스트맵·플래너·행동 트리·복구·튜닝. — 이동 로봇을 자율 주행시킬 때.

  • ✅ 8.1 — Nav2 아키텍처 전경
  • ✅ 8.2 — 지도와 SLAM Toolbox
  • ✅ 8.3 — 위치추정: AMCL 튜닝
  • ✅ 8.4 — 코스트맵 2D: 레이어와 센서 통합
  • ✅ 8.5 — 플래너·컨트롤러 플러그인
  • ✅ 8.6 — 행동 트리와 BT Navigator
  • ✅ 8.7 — 복구·상태관리·안전
  • ✅ 8.8 — Nav2 튜닝·평가·현장 디버깅

제9부 매니퓰레이션 (9.1~9.6)

MoveIt 2 전반 — 아키텍처·계획 파이프라인·Planning Scene·Servo·픽앤플레이스·배치 실무. — 팔을 계획해 움직일 때.

  • ✅ 9.1 — MoveIt 2 아키텍처
  • ✅ 9.2 — 계획 파이프라인: OMPL·Pilz·STOMP
  • ✅ 9.3 — Planning Scene과 충돌 처리
  • ✅ 9.4 — MoveIt Servo와 실시간 제어
  • ✅ 9.5 — 픽앤플레이스 통합
  • ✅ 9.6 — 매니퓰레이터 배치 실무

제10부 시뮬레이션 (10.1~10.6)

시뮬레이션 — 시뮬 전략, Gazebo 연동, 센서 시뮬, gz_ros2_control, Isaac Sim 등 대안 시뮬레이터, Sim2Real 간극. — 하드웨어 없이 개발하거나 회귀 시험을 만들 때.

  • ✅ 10.1 — 시뮬레이션 전략: 무엇을 검증하는가
  • ✅ 10.2 — Gazebo Harmonic과 ros_gz
  • ✅ 10.3 — 센서 시뮬레이션과 플러그인
  • ✅ 10.4 — gz_ros2_control과 시뮬 하드웨어
  • ✅ 10.5 — Isaac Sim 등 대안 시뮬레이터
  • ✅ 10.6 — Sim2Real 간극과 검증

제11부 도구·관측성 (11.2~11.1)

도구와 관측성 — rosbag2, 기록·재생 전략, RViz2, rqt, 트레이싱, 성능 측정, 현장 디버깅. — 문제가 생겼을 때 펼치는 부. 11.1이 그 입구다.

  • ✅ 11.2 — rosbag2 심층: MCAP·저장소·성능
  • ✅ 11.3 — 기록·재생 전략
  • ✅ 11.4 — RViz2와 플러그인 작성
  • ✅ 11.5 — rqt·PlotJuggler·시각 분석
  • ✅ 11.6 — 트레이싱: ros2_tracing·LTTng
  • ✅ 11.7 — 성능 측정: 지연·지터·처리량
  • ✅ 11.1 — 현장 디버깅 플레이북

제12부 실시간·성능 (12.1~12.6)

실시간과 성능 — 결정성, RT 커널·스케줄링, 무할당 경로, 엔드투엔드 지연 예산, 안티패턴. — 주기를 지켜야 하는 제어 루프를 다룰 때.

  • ✅ 12.1 — 실시간이란 무엇인가: ROS 2에서의 한계
  • ✅ 12.2 — RT 커널·스케줄링·CPU 격리
  • ✅ 12.3 — 메모리: 잠금·풀·무할당 경로
  • ✅ 12.4 — 엔드투엔드 지연 예산 설계
  • ✅ 12.5 — 결정성과 실행 순서 제어
  • ✅ 12.6 — 성능 안티패턴 카탈로그

제13부 보안 (13.1~13.5)

보안 — 위협 모델, SROS 2, DDS-Security, 운영 보안, 비용·트레이드오프. — 로봇이 네트워크에 연결되는 순간부터 필요하다.

  • ✅ 13.1 — 위협 모델: ROS 2 그래프의 공격면
  • ✅ 13.2 — SROS2: 키스토어·enclave·정책
  • ✅ 13.3 — DDS-Security 플러그인 상세
  • ✅ 13.4 — 운영 보안: 격리·업데이트·비밀 관리
  • ✅ 13.5 — 보안의 비용: 성능·운영 트레이드오프

제14부 시스템 설계·품질·운영 (14.1~14.10)

시스템 설계·품질·운영 — 노드 분할, 오케스트레이션, launch 심층, 테스트, 품질 수준, 배포·OTA, 플릿, 안전 표준. — 제품으로 만들고 유지할 때.

  • ✅ 14.1 — 노드 분할 설계: 경계 긋기
  • ✅ 14.2 — 시스템 오케스트레이션: 모드·부팅·감시
  • ✅ 14.3 — launch 심층 I: 구조와 substitution
  • ✅ 14.4 — launch 심층 II: 대규모 구성
  • ✅ 14.5 — 테스트 I: 단위·모의 그래프
  • ✅ 14.6 — 테스트 II: launch_testing·통합·HIL
  • ✅ 14.7 — 품질 수준·REP 2004·의존성 위생
  • ✅ 14.8 — 배포: systemd·Docker·OTA
  • ✅ 14.9 — 다중 로봇·플릿 운영
  • ✅ 14.10 — 안전 표준과 ROS 2의 위치

제15부 이행·확장 (15.1~15.5)

이행과 확장 — ROS 1에서 넘어오기, 배포판 이동, 커스텀 RMW, 기여·거버넌스. — 기존 자산을 옮기거나 생태계에 참여할 때.

  • ✅ 15.1 — Humble→Jazzy 마이그레이션 실무
  • ✅ 15.2 — Kilted·Rolling 대비
  • ✅ 15.3 — ROS 1 상호운용: ros1_bridge
  • ✅ 15.4 — 커스텀 rmw와 확장 포인트
  • ✅ 15.5 — 기여·거버넌스·생태계

부록 (A.1~A.6)

찾아보는 참고 자료 — CLI·QoS·환경변수 레퍼런스, REP 색인, 용어집·약어, 참고자료·출처, 역할별 학습 로드맵. — 본문을 읽다 막히거나, 실무 중 빠르게 찾아볼 때.

  • ✅ A.1 — CLI 레퍼런스
  • ✅ A.2 — QoS·환경변수·설정 레퍼런스
  • ✅ A.3 — REP 색인
  • ✅ A.4 — 용어집·약어
  • ✅ A.5 — 참고자료·출처
  • ✅ A.6 — 역할별 학습 로드맵

주제별 실측 색인 — 무엇이 확인됐고 어디에 있나

목차 3의 검증표는 회차순이라 "타이머 지터 수치가 어디 있더라"를 찾기 어렵다. 이 절은 같은 내용을 주제로 다시 연결한다. 현재 70개 절(제목이 — 실측으로 끝나는 절, 2026-08-19·101차 기준 — 신설 없이 기존 절 확장)이 있고, 아래 표는 그중 71개 항목을 다룬다(일부 절은 확정한 것이 둘 이상이라 행이 더 많다).

이 색인의 항목은 전부 Jazzy 실물에서 확인한 것이다. 표기 규약은 목차 5, 검증 범위와 한계는 목차 3을 보라. 재현 방법은 11.7.13(계측 도구 다섯)에 있다.

QoS·통신 경로

절확정한 것
1.6.11표준 프로파일 값 전수. ClockQoS는 BEST_EFFORT·depth 1 — /clock은 유실되고 이력이 없다
1.6.12system_default는 보고값과 동작이 어긋난다 — topic info -v가 TRANSIENT_LOCAL이라 하는데 늦은 구독자는 못 받는다
2.7.12intra-process의 대상은 토픽뿐이다. 엔티티마다 켜고 끌 수 있다
2.7.13"묶으면 빨라진다"는 조건부 — const& 발행이면 나눈 것보다 불리하다. N−1 복사 규칙 재현
5.1.13tf2가 쓰는 QoS 네 종류, static_transform_publisher의 두 인자 방식
0.1.8동일 발행자→동일 구독자 순서 보장 확인(부하 걸어도 역전 0건). 다만 발행자 폭주만으로도 71~87% 유실(느린 구독자가 아니어도). 종료된 노드 감지는 "수 초"가 아니라 19~20초(144차). 유실 원인은 큐 깊이가 아니라 순수 발행 속도로 좁혀졌다(같은 최소 깊이에서 페이싱만 주면 3회 전부 무손실)(145차)

실행 모델·동시성·메모리

절확정한 것
2.4.12동시성 매트릭스 7행 전수 재현. 데드락 처방에 다섯째 조건(응답을 만드는 쪽도 빼야 한다)
2.8.12C++/Python 비대칭 열한 축(105차까지) — 로깅 13~17배·throttle은 비용이 낮지 않다·GIL 통념은 절반만 맞다·메시지 생성 237배·spin 328 µs(1 kHz 불가)·QoS는 값이 같고 이름이 다르다·액션 피드백 유실엔 rclpy가 완전히 면역(240/240)·커스텀 Waitable은 SingleThreadedExecutor에서 rclpy는 항상 먼저, rclcpp는 항상 나중(105차, 2.8.12.12)·EventsExecutor 크래시의 원인은 rclpy 자신의 의도된 안전장치였다 — 기본 EventHandler Waitable이 Python set 순회 순서상 먼저 처리되면 그 콜백이 고아로 남고, 소멸자가 이를 감지해 abort()한다. -u는 원인이 아니었다(106차, 2.8.12.13)·UpdateEntitySet() 결함은 구조적으로 다섯 엔티티 종류(구독·타이머·클라이언트·서비스·Waitable) 전부의 것이지만, 결정론적으로 저절로 걸리는 것은 Waitable(guard condition)뿐이다 — 결함 주입으로 구독·서비스에서도 같은 고아화·abort() 패턴을 확정(107차, 2.8.12.14)·결함 주입을 타이머·클라이언트로 완결 — 클라이언트는 구독·서비스와 같은 abort() 패턴(6/6)이지만, 타이머는 RclCallbackManager를 안 거치는 별도 경로라 abort() 안전장치가 없어 85%는 조용한 누수, 15%는 use-after-free 세그폴트로 종료된다(gdb 콜스택으로 확정, 110차, 2.8.12.15)·타이머 UAF의 정확한 해제·재접근 지점을 valgrind로 소스 줄 번호까지 확정 — node.destroy_node()가 이미 rcl_timer_fini()로 NULL을 남긴 rcl_timer_t를, 고아로 남은 ClockManager가 나중에 rcl의 미검사 역참조(rcl_timer_set_on_reset_callback())로 다시 밟는 결정론적 이중 해제였다 — malloc 재사용 확률 게임이 아니었다. ASan은 빌드는 됐지만(15%→100% 크래시로 증폭) 이 다단계 포인터 체인엔 교과서적 UAF 리포트를 주지 못한 한계도 함께 확인(111차, 2.8.12.15)
1.12.10FAILURE는 on_error를 안 거치고 복귀, ERROR만 거친다. on_activate 실패는 unconfigured까지 되돌리고, finalized 노드는 살아서 아무것도 안 한다
1.12.11nav2_lifecycle_manager는 중단만 하고 되돌리지 않는다(부분 활성이 남음). bond는 노드가 만들어 줘야 하며, 없으면 부팅이 실패로 판정된다
2.4.13스레드 요구는 호출 깊이에 비례하지 않는다(깊이 6도 3개). 콜백 그룹을 보관 안 하면 콜백이 영영 실행되지 않는다
2.5.13누적 드리프트 0.000%. executor별 꼬리 — Static < Single < Multi
2.8.11rclpy 콜백 예외는 spin() 밖으로 전파된다(다중 스레드도 동일). read_points_numpy 실재
2.9.10Reentrant에서 락 없는 증가의 54.7% 유실. TSAN이 정확히 지목(정상 구성 경고 0건)
12.3.3mlockall 없이 malloc→memset→free만 있는 격리된 프리폴트 벤치마크는 GCC -O2가 전부 제거(minflt 0). 책의 실제 코드는 mlockall의 부작용 덕에 안전(minflt 25,695, -O0과 일치)(170차)
12.3.4커스텀 할당자 API 컴파일 확인 — AllocatorMemoryStrategy는 별도 include 필요(누락 시 컴파일 실패). TLSF는 ros-jazzy-tlsf-cpp로 실재, 실행 예제 정상 동작(170차)
12.3.5can_loan_messages()로 대여 메시지 조건 확인 — 고정 크기(Float64) 참, 가변 크기(Float64MultiArray) 거짓(170차)
12.3.12spin_some 자유 함수 회당 143회 할당. 무복사와 무할당은 동시에 안 된다
2.10.11확장 포인트 API 실물 대조 — Waitable의 예시 서명 셋이 폐기 예정, ExecutorOptions는 체이닝이 아니라 멤버, guard condition은 executor가 아니라 wait set. 커스텀 waitable을 실제로 등록해 실행해 보니 SingleThreadedExecutor에선 항상 실행되지만 MultiThreadedExecutor.spin()에서는 9~36%만 실행되고 나머지는 완전히 유실(95차) — 96차가 원인을 확정: 다른 엔티티와 같은 MutuallyExclusive 그룹을 공유하면(스레드 2개↑) 100% 유실, 전용 그룹이면 100% 성공인 이진 스위치다(2.3.14). 97차: Reentrant 그룹(공유·전용 모두)은 이 유실을 100% 막지만(45/45+20/20 성공) 보호 없는 공유 상태에는 11~14%의 실제 손실 업데이트를 새로 남긴다. EventsExecutor에 이 폴링형 waitable을 그대로 등록하면 add_node()에서 runtime_error로 즉시 실패하고, 콜백형 API(set_on_ready_callback 등)까지 구현하면 80/80 성공한다. 105차: 같은 방식의 waitable을 rclpy로도 만들어 재현하니 SingleThreadedExecutor에서 항상 가장 먼저 실행됐다(rclcpp는 항상 가장 나중 — 정반대). rclpy의 EventsExecutor도 이 waitable을 거부하지만 add_node()가 아니라 첫 spin_once()에서 RuntimeError: Guard conditions not supported가 나고, 그 예외를 포착해도 표준 버퍼링 실행에서는 프로세스가 Fatal Python error: Aborted로 종료되는 크래시를 5/5 재현했다(2.8.12.12). 106차: 디버그 재빌드로 정확한 원인을 확정했다 — 아무 것도 안 만든 Node도 기본 파라미터 서비스에 딸린 EventHandler형 Waitable을 2개 갖고 있어, EventsExecutor가 Python set(순회 순서 비결정적) 순서로 이들을 처리하다 guard-condition Waitable에서 던지면 그 전에 이미 등록된 다른 Waitable의 콜백이 추적 목록에는 기록되지 못한 채 고아로 남는다(UpdateEntitySet()의 부분 실패 롤백 부재). 소멸자의 abort()는 rclpy 자신이 심어 둔 의도된 안전장치였다. -u는 원인이 아니었다 — 표본을 12회씩 늘려 측정해 보니 버퍼링 11/12·-u 9/12로 둘 다 높은 비율로 크래시했다(2.8.12.13). 107차: 이 결함이 Waitable만의 것인지 확인하려고 events_executor.cpp에 결정론적 결함 주입(프로세스 내 두 번째 HandleAddedSubscription/HandleAddedService 호출이 인위적으로 예외)을 넣어 재빌드했다 — 구독 2개를 가진 노드(4/4회), 커스텀 서비스 없이 기본 파라미터 서비스 7종만 있는 맨 노드(1회) 모두 106차와 정확히 같은 고아화·abort() 패턴을 보였다. 결함 자체는 다섯 종류가 공유하는 UpdateEntitySet()에 구조적으로 있지만, 정상적인 단일 스레드 사용에서 저절로 걸리는 경로는 여전히 Waitable(guard condition)뿐이다 — 구독·서비스·클라이언트가 던지는 유일한 경로(rcl_*_set_on_*_callback()의 실제 RCL 실패)는 정상 사용에서 사실상 일어나지 않기 때문이다. py::set 순회 순서를 50회 더 표집하니(합산 62회) 무사 비율이 균등분포 기대치(1/3)에 통계적으로 부합했다(2.8.12.14). 110차: 같은 결함 주입을 타이머·클라이언트로 확장해 다섯 엔티티 종류 전부를 채웠다 — 클라이언트는 구독·서비스와 동일한 abort() 패턴(6/6)이었지만, 타이머는 RclCallbackManager가 아닌 별도의 TimersManager로 등록돼 abort() 안전장치가 없다 — 20회 중 abort() 0회, 조용한 누수 17회(85%), gdb로 콜스택을 확보해 확정한 use-after-free 세그폴트 3회(15%)였다. py::set 순서는 150회 더 표집(합산 212회)해도 균등분포 귀무가설을 기각하지 못했다(2.8.12.15). 112차: waitable 여섯 개끼리만(다른 엔티티 없이) MultiThreadedExecutor로 실행해도 스레드 2·4·8·정역방향·Jazzy·Humble 80회 전부 100% 유실됐다 — Humble엔 EntityCollection이 없는데도 재현돼, 96차가 확정한 유실 메커니즘이 Jazzy 전용이 아니라 두 배포판이 공유하는 코드에 있음을 보였다(2.3.14). 114차: py::set 순서 표본을 212 → 500회로 완결하니(288회 추가) 처음으로 균등분포(1/3) 귀무가설을 기각했다($p=0.0248$) — 무사 비율이 106차부터 계속 33.3%보다 낮았고 표본이 커질수록 28~29%에서 안정화돼, "안전한" 순서가 균등 확률보다 유의하게 낮게 나온다는 결론으로 정정됐다(2.8.12.15)
2.8.13Python 예시 이름 감사 — import 68개·열거·키워드 전부 실재, 폐기 형태 0건. 덤으로 rclpy 자신의 별칭 오타를 찾았다(LivelinessPolicy에 …DURABILITY_UNKNOWN이 붙어 있다)
2.3.14SingleThreadedExecutor는 다섯 엔티티가 동시에 준비되면 TIMER → SUB → SRV → CLIENT → WAITABLE 순으로 전부 실행된다(등록 순서 무관). MultiThreadedExecutor는 다르다 — spin_some()으로는 스레드 풀이 전혀 관여하지 않아 순서가 그대로 보이지만, 실제 spin() 동시 실행에서는 SUB·SRV·CLIENT 순서가 16~47% 확률로 깨진다(TIMER만 항상 1번). 97차: EventsExecutor는 정반대다 — 같은 실험에서 SUB → CLIENT → SRV → TIMER(타이머가 맨 뒤, 14/14 재현)가 나온다. 이미 준비된 구독·서비스·클라이언트는 콜백 등록 즉시 이벤트가 큐에 삽입되지만, 타이머는 실제로 주기가 지나야 큐에 들어가기 때문이다. 108차: 위는 전부 서로 다른 엔티티 종류 사이의 순서였다 — 같은 타입 엔티티 여러 개(구독 6~10개)끼리는 SingleThreadedExecutor에서도 등록 순서와 무관한 순서가 나오고, 그 순서조차 60회 중 4회(6.7%)는 실행마다 바뀐다. 원인은 Jazzy의 EntityCollection이 rcl 핸들 포인터를 키로 쓰는 std::unordered_map이기 때문(PR #2142가 도입, 이슈 #2532가 독립 보고) — Humble엔 이 파일 자체가 없고 40/40 등록 순서를 지켰다(15.1.3). 109차: 같은-타입 순서를 타이머·서비스·클라이언트·waitable로 확장하니 다섯 종류 전부 Jazzy에서는 등록 순서를 잃고 Humble에서는 지켰지만, 이탈률은 타이머·waitable 0%부터 서비스 45%까지 종류마다 크게 갈렸다(서비스·클라이언트는 DDS 왕복 타이밍이 섞여 통제 수준이 낮다). MultiThreadedExecutor의 실제 동시 실행(구독)에서는 이탈률이 20~53%로 더 커졌는데, std::unordered_map이 없는 Humble도 20~30% 이탈해 — 해시 버킷 순서와는 별개인 스레드 경합 기반 비결정성이 있음을 보였다. 112차: 미들웨어 리스너 콜백으로 도착 순서를 executor와 독립적으로 측정해 보니, 서비스는 도착이 40/40·25/25 완벽히 결정적인데도 디스패치는 80%·60% 이탈해(16종의 서로 다른 순서) 이탈이 도착 잡음이 아니라 순수 디스패치(해시 순서) 쪽임을 확정했다. MultiThreadedExecutor를 타이머·서비스·클라이언트·waitable·스레드 2/4/8로 확장하니 waitable은 Jazzy·Humble 양쪽에서 스레드 2개 이상이면 80회 전부 100% 유실됐고 서비스가 두 배포판 다 53~80%로 가장 불안정, 클라이언트가 가장 안정적이었다. 구독·스레드4의 Jazzy·Humble 이탈률(45.0% 대 8.3%, 각 60회)을 두-비율 z-검정하니 $p=6\times10^{-6}$로 통계적으로 유의했다. 113차: 같은 검정을 타이머·서비스로 확대하니 구독과 달리 유의하지 않았다(각 60회, $p=0.65$·$0.22$) — 타이머의 스레드 수 비단조 패턴도 표본을 50회로 늘리자 사라져(전 구간 $p>0.29$) 둘 다 소표본 우연으로 확정됐다. Humble 서비스 콜백이 종종 두 번 반복 호출되는 112차의 관찰은 rclcpp의 2단계 콜백 등록 패턴(Jazzy와 동일한 코드)까지는 소스로 확인했지만 타이밍이 실제 증상(~150ms 지연)과 4~5자릿수 어긋나 원인에서 배제됐고, 66회의 재현 시도도 전부 실패했다(→ 127차 정정: 이 2단계 등록은 set_on_new_request_callback()을 명시적으로 호출할 때만 일어나는데, 126차 이중 호출을 낸 도구는 이 API를 안 쓴다 — 처음부터 다른 경로를 조사한 것이었다). 114차: 서비스 통계 검정을 150회로 늘리니 결론이 뒤집혔다 — Jazzy 54.0% 대 Humble 74.7%, $p=1.87\times10^{-4}$로 유의했다(113차의 $p=0.072$는 표본 부족이 만든 경계선이었다). 이중 호출 재현은 "다른 엔티티를 먼저 실행"하는 새 조건까지 시도했지만 또 실패해 누적 111회가 됐다. 115차: 타이머도 150회로 재검정하니 서비스와 똑같이 뒤집혔다 — Jazzy 8.7% 대 Humble 26.7%, $p=4.36\times10^{-5}$(구독·서비스·타이머 셋 다 유의하지만 방향은 갈렸다 — 구독은 Jazzy가, 서비스·타이머는 Humble이 더 나쁨). --privileged로 108차부터 막혔던 ASLR을 실제로 껐더니 이탈률이 0%가 되지 않아(30.0%, 켬은 40.0%) ASLR 단독 원인설이 반증됐다. 이중 호출은 재현 대신 환경 포렌식으로 접근해 112차 관측 창에만 겹친 무관한 컨테이너 기동 이벤트를 찾았지만 인과관계는 증명하지 못했다. 116차: 최소 시나리오(서비스 6개만)는 ASLR을 꺼도 10/10회 완전히 결정적이었지만, 실제 디스커버리 매칭을 더하면 ASLR을 꺼도 40회 중 5회(12.5%)는 핸들 주소가 달라졌다 — 할당 순서 비결정성 가설이 방향은 맞다는 것을 직접 확인했다(다만 이탈률 30~40%를 전부 설명하는지는 미확정). Humble rclcpp 소스로 엔티티 수집(콜백 그룹 하나일 때)이 해시·정렬 없는 순수 등록 순서 벡터임을 확인해, Humble의 이탈이 96차가 Jazzy에서 확정한 것과 같은 스레드 스케줄링 문제임을 좁혔다. Humble 이중 호출은 누적 101회 재현 시도와 환경 포렌식 끝에 재현 불가로 명시적으로 종결했다(→ 126차 정정: 틀렸다 — 사전 스핀 실험 도중 우연히 다시 나타났다). 117차: ASLR 켬·끔 비교를 300회로 완결하니 방향이 뒤집혀 무관함이 최종 확정됐다($p=0.369$, 60→150회의 "켬이 더 불안정" 추세는 착시였다). 핸들 주소와 FIRE_ORDER를 같은 프로세스에서 동시에 재 완전한(60/60 예외 없는) 1:1 상관을 확인했다. CHG-122 계열의 마지막 항목인 stress-ng 통제 실험을 실행해 — 워커 수가 코어 수(20)에 도달하면 Jazzy 구독·서비스 이탈률이 극적으로 유의하게 뛰지만(구독 35.0%→72.5%, $p=0.00077$) Humble의 구독은 전혀 반응하지 않았다(4.0%→4.0%) — 96차 이후의 스레드 경합 가설을 원인 변수(CPU 경합) 직접 조작으로 처음 정량 확인했다. 118차: Jazzy·Humble executor 소스를 대조하니 "선택"(다음 실행 대상 선택)는 둘 다 결정적이지만 방식이 다르다(Jazzy: 캐시된 스냅숏 순회, Humble: 벡터 순회+erase+별도 뮤텍스). stress-ng 임계값을 12·16워커로 메워 16~20워커(코어 수) 사이로 좁혔다. Humble 서비스·타이머에 같은 실험을 하니 Jazzy와 정반대로 부하가 이탈률을 유의하게 낮췄다(서비스 44.0%→4.0%, $p=0.00093$; 타이머 24.0%→4.0%, $p=0.042$). wait_for_work()에 실제 타임스탬프 계측을 넣어 "Humble의 매 사이클 재구성이 더 비용이 크다"는 95차 가설을 검증하니 정반대로 Jazzy가 1.3~1.8배 더 비용이 컸다 — 이 가설은 반증됐고 Humble이 서비스·타이머에서 불리한 실제 이유는 여전히 열려 있다. 119차(중대한 정정): execute_any_executable()에 직접 계측을 넣어 측정해 보니 91~98차(gap109~118)의 MultiThreadedExecutor 실험은 실제 동시 실행을 단 한 번도 만들지 못했다(동시성 비율 0.0000, Jazzy·Humble·구독·서비스·타이머·부하 유무 전 조건). N개 엔티티가 명시적 그룹 지정 없이 노드 하나의 기본 MutuallyExclusive 콜백 그룹에 들어가 있어 병렬 실행이 애초에 불가능했다 — 측정된 이탈률 수치는 유효하지만 "스레드 스케줄링 경쟁"이라는 설명 메커니즘은 이 테스트 계열에 적용되지 않는다. 부수적으로 entities_need_rebuild_가 68~70%나 자주 걸리는 이유도 확정했다(MutuallyExclusive 콜백이 끝날 때마다 interrupt_guard_condition_이 트리거돼 다음 사이클을 재구성시킨다). 120차: 119차가 남긴 "재구성-대기 연쇄" 가설(바쁜 그룹에 막힌 스레드가 wait_mutex_를 쥔 채 블로킹되고 깨어나면 재구성된 목록을 받는다)을 직접 계측했다 — 락이 블로킹 대기 전체를 감싼다는 것은 480회 실측(13,397개 구간, 겹침 비율 예외 없이 0.0000)으로 확정했고, Humble은 이 재구성이 항상(100%) 일어나는 반면 Jazzy는 조건부(45~74%)라는 배포판 차이도 확인했다. 다만 이 빈도가 이탈률을 결정하는 인과적 손잡이라는 것은 확정하지 못했다 — 12개 설정 전체 상관은 $r=0.96$으로 강하지만 엔티티 종류(서비스 대 타이머)를 고정하면 상관이 거의 사라진다(서비스만 $r=-0.31$, 타이머만 $r=+0.31$) — 대부분이 엔티티 종류 자체의 차이를 반영하는 교란변수였다. Humble이 서비스·타이머에서 불리한 실제 이유는 여전히 열려 있다. 121차: rcl wait.c를 읽어 구독·서비스가 완전히 같은 방식(rmw_wait() 후 널 검사)으로 처리됨을 확인해 rcl 계층 구조 차이 가설을 반증했다(유일하게 다른 것은 타이머 — rcl_timer_is_ready()로 별도 판정 — 이지만 이는 이 책의 "서비스·타이머 대 구독·waitable" 축과 맞지 않는다). Jazzy의 재구성을 완전히 억제하는 실험용 빌드(entities_need_rebuild_ 무시)로 서비스 이탈률을 측정하니 줄지 않았다(오히려 소폭 증가, 비유의) — 재구성 반복 자체는 이탈의 필요조건이 아니다. 계측을 구독으로 확장하다가 117차의 "Humble 구독은 CPU 부하에 완전히 무반응"이라는 결론이 이번 세션에서는 재현되지 않는다는 것(오히려 유의하게 감소, $p=0.0047$)을 발견했다 — 순정 빌드로도 재현해 계측 부작용이 아님을 확인했지만, 117차의 기준값(4.0%)과 이번 세션 기준값(24~36%)의 차이 자체는 설명하지 못해 세션 간 재현성 문제로 이월한다. 122차: 그 재현성 문제를 직접 검증했다 — 호스트 배경 부하(무관한 컨테이너 36개) 가설을 /proc/loadavg 자연 변동 상관($r=-0.0149$, 거의 0)과 통제된 저강도 stress-ng 스윕(0·2·4·8·20워커, 40.0%·40.0%·40.0%·20.0%·30.0%로 매끄러운 용량-반응 없음, 같은 세션의 바로 앞 배치와도 재현 안 됨)으로 검증했지만 지지되지 않았다 — 단순 CPU 경합량으로는 설명되지 않는다. rmw_fastrtps_shared_cpp까지 내려가도(네 계층째, 119차 rclcpp 컬렉션·CHG-143 선택 함수·121차 rcl 대기집합에 이어) 구독·서비스 리스너·wait 로직이 완전히 같은 코드(뮤텍스+get_unread_count+콜백, get_first_untaken_info+get_statuscondition)였다 — 코드 경로 차이 가설은 사실상 소진됐다. 대신 서비스가 요청+응답이라는 별개의 DDS 엔티티 쌍을 갖는 반면 구독은 리더 하나뿐이라는 구조적 비대칭을 새 후보로 찾았다(확인 필요). 121차의 "재구성 억제가 이탈을 늘린다"는 경향도 n=100(표준 34.0% 대 억제 33.0%, $p=0.88$)에서는 완전히 사라져 소표본 우연으로 확정됐다. 123차: "엔티티 두 배" 가설을 클라이언트 재분석(먼저)과 직접 조작(엔티티 수를 실제로 바꿈) 둘 다로 검증했지만, 둘 다 가설에 불리했다 — 클라이언트도 엔티티 둘(요청 라이터+응답 리더)을 갖지만 109·112차 자료에서 이 책이 측정한 네 엔티티 종류 중 가장 안정적이었다(MultiThreadedExecutor: Jazzy 6.7~13.3%·Humble 0%, 서비스의 53~80%·75~80%와 극명히 대조). 두 테스트 소스를 나란히 읽으면 구조도 "N개가 스핀 전 이미 준비되게" 만드는 설계 의도도 대칭인데 결과는 정반대다. 직접 조작도 했다 — 구독의 로컬 퍼블리셔(라이터)를 전부 별도 프로세스로 옮겨 측정 대상 노드엔 리더만 남겼더니, 이탈률이 방향은 가설과 일치하게 줄었지만(Jazzy 65.0%→57.5%, Humble 42.5%→32.5%) 어느 쪽도 통계적으로 유의하지 않았다($p=0.49$·$0.36$, 이 실험 계열의 세션 간 자연 변동 폭 안에 있다). "DDS 엔티티 개수"는 서비스·구독 비대칭의 주된 설명이 아닐 가능성이 크다(확인 필요) — 서비스가 왜 유독 불안정한지는 여전히 열려 있다. 124차: execute_service()·execute_client()(rclcpp)를 나란히 읽어 다섯 계층째도 코드가 동일함을 확인한 뒤, 112차의 도착-시각 계측 도구로 실제 도착 시각을 재 새 후보를 찾았다 — 클라이언트의 응답 도착 시간 폭이 서비스의 요청 도착 시간 폭보다 약 5배 넓다(중앙값 서비스 12~14µs 대 클라이언트 61~64µs, 두 배포판 일관) — 서버가 상시 스핀하며 요청을 순차 처리·응답하는 클라이언트 테스트 구조가 원인으로 보인다(엔티티 종류 자체의 속성이 아니라 테스트 설계의 산물이라는 점은 명시). Humble 구독의 디스커버리 타이밍 가설도 직접 계측했다 — 도착 시각 폭과 이탈 여부의 상관은 $r=0.0712$로 무시할 수준이었지만, ARRIVAL_ORDER(32.0% 최빈)가 FIRE_ORDER(62.0% 최빈)보다 훨씬 불안정하다는 뜻밖의 사실을 발견했다 — executor 디스패치가 어수선한 도착 순서를 그대로 반영하는 것이 아니라 오히려 정돈하고 있다는 뜻이다(둘이 완전히 일치한 회차는 50회 중 8회뿐). 엔티티 개수 조작(123차)을 n=100으로 늘리니 Jazzy는 유의해졌지만(71.0%→54.0%, $p=0.013$) Humble은 방향이 뒤집혔다(34.7%→42.0%, $p=0.29$) — 클라이언트라는 반례가 여전히 있어 엔티티 개수는 전체 그림의 일부일 뿐이다. 125차: 124차의 도착 시간 폭 가설을 service_spread_test(요청 사이 인위적 지연 0·10·50·200µs)로 직접 조작했다 — 어느 조건도 유의하지 않았고 Jazzy는 지연을 늘릴수록 이탈이 오히려 (비유의하게) 늘었다(43.3%→58.6%) — 200µs는 클라이언트의 자연 도착 창(61~64µs)보다 3배 넓은데도 이탈이 줄지 않아, 103~104차의 상관관계는 인과관계가 아니었을 가능성이 크다. Humble의 "정돈" 메커니즘은 소스에서 정확히 찾았다 — AllocatorMemoryStrategy::remove_null_handles()가 매 rcl_wait() 이후 subscription_handles_(std::vector)에서 준비 안 된 항목을 걷어내 등록 순서 그대로 남기고, get_next_subscription()은 항상 begin()부터 순회한다 — N개가 같은 rcl_wait() 배치에서 준비되면 도착 순서와 무관하게 항상 등록 순서가 나가는 것이 124차가 찾은 "정돈" 효과의 정체다. 엔티티 개수와 도착 시간 폭의 관계도 직접 측정해 봤다 — 라이터가 있는(같은 참가자) 구독은 없는(별도 프로세스) 구독보다 도착 시간 폭이 90배 가까이 넓었지만(Jazzy 중앙값 36ms 대 0.4ms, 균일한 확장이 아니라 일부가 ~70ms 늦는 "지연 샘플" 패턴 — Humble은 40회 전부에서 관찰) 도착 시간 폭 자체는 이탈률을 바꾸지 않는다는 것을 이미 보였으므로, "엔티티 개수"와 "도착 시간 폭"은 같은 효과의 두 이름이 아니라 라이터 유무가 만드는 별개의 두 현상으로 보인다(확인 필요). 126차: 서버 사전 스핀을 service_prespin_test(측정 대상 MultiThreadedExecutor를 요청 전송 전에 미리 스핀)로 직접 조작했다(N=6, 스레드4, 각 50회) — Humble에서 이탈이 완전히(82.1%→0.0%, $z=7.780$, $p\approx 0$) 사라졌다, 이 실험 전체를 통틀어 가장 큰 단일 효과다(Jazzy는 64.0%→67.9%로 무변화). 그러다 뜻밖의 것과 마주쳤다 — 이 실험 도중 fired가 6이 아닌 경우가 반복돼 확인해 보니 116차가 101회 실패 끝에 "재현 불가로 명시적으로 종결"한 Humble 서비스 콜백 이중 호출이었다. Humble은 기준(22.0%)보다 사전 스핀(8.0%, $p=0.0499$)에서 이중 호출도 줄어(FIRE_ORDER 이탈이 지워진 것과 같은 방향) 두 현상이 연결됐을 가능성을 보였지만, Jazzy는 정반대로 사전 스핀이 이중 호출을 0%에서 44.0%로 만들어 내 113차의 "Jazzy는 이중 호출 없음" 확정과 배치된다 — 116차의 종결은 틀렸다. execute_service()·execute_client()에 타이밍 계측을 넣어 측정해 보니 execute_service()가 execute_client()보다 약 4.7배 더 걸렸다(중앙값 서비스 Jazzy 62,832ns·Humble 60,363ns 대 클라이언트 Jazzy 12,992ns·Humble 12,921ns, 두 배포판 거의 동일) — 103차가 견해로 남긴 "서비스는 응답 데이터를 채워 반환" 차이가 실측으로 확인됐다. "지연 샘플" 패턴(125차)을 tcpdump로 캡처하니 하트비트·ACKNACK 재전송이 전혀 관측되지 않아(캡처된 6개 패킷 전부 100ms 간격 SPDP 공지) 네트워크·프로토콜 수준이 아니라는 것도 확인했다(확인 필요). 127차: 사전 스핀 효과의 메커니즘을 두 가설로 직접 측정했지만 둘 다 반증됐다 — 재구성-대기 캐스케이드 횟수(total_gne)는 사전 스핀·기준 두 조건이 거의 같은데도(28.0 대 27.8) 이탈률은 3.3% 대 73.3%로 극단적으로 달랐고, 스레드 지역성도 정반대로 사전 스핀 쪽 스레드 전환율이 더 높았다(97.0% 대 84.0%) — 실제 메커니즘은 여전히 못 찾았다(확인 필요). 113차의 "2단계 등록" 조사는 애초에 이번 이중 호출을 낸 도구와 무관한 API(set_on_new_request_callback())를 겨냥한 것이었음을 확인했고, 대신 캐시된 wait_result_의 전진 전용 반복자와 실제 DDS take 사이의 미세한 타이밍 간극을 새 후보 원인으로 제시했지만 직접 계측으로 확정하지 못했다(확인 필요). "지연 샘플" 패턴은 FastDDS 내부 경고 로그(Log::SetVerbosity(Info))로도 지연 구간에 활동이 전혀 없어 126차 tcpdump에 이어 두 번째로 "배제"가 확인됐다(확인 필요). 128차: 마침내 사전 스핀 메커니즘을 사건 단위 타임스탬프로 찾았다 — 재구성 총량(집계)은 두 조건이 거의 같지만, 마지막 도착부터 마지막 디스패치까지의 "디스패치 구간"에 재구성이 몇 번 몰리는지를 측정하니 사전 스핀 7~11회 대 기준 23~28회로 분포가 전혀 겹치지 않았다(Welch $t=49.19$) — 기준 조건은 스핀 시작 순간 스레드 4개가 이미 준비된 데이터를 두고 뮤텍스를 경합하며 ~1.3~1.8ms 안에 몰아치는 반면, 사전 스핀은 그 다툼을 400ms에 걸쳐 미리(~16회) 분산해 소화해 실제 디스패치는 ~10회로 차분하다 — "얼마나 자주"가 아니라 "얼마나 몰려서"가 핵심이었다(다만 조건 내부에서는 이 개수가 개별 이탈 여부를 예측하지 못해 인과 경로는 못 닫았다)(확인 필요)(→ 130차 정정: "400ms에 걸쳐 분산"은 부정확했다 — 시작 직후 ~935µs 안에 몰아치고 그 뒤론 한 번의 긴 블록이었다, 아래 참고). Jazzy 이중 호출의 새 후보 원인(wait_result_ 경합 창)를 계측하려고 세 가지 도구로 총 240회를 사전 스핀 조건으로 실행했지만 이번 세션엔 단 한 번도 재현되지 않았다(127차의 44.0%와 대비, 113·116차와 같은 세션 의존성)(확인 필요, 표본 확보가 급선무). "지연 샘플" 패턴은 gdb 반복 attach(1회 평균 ~295ms로 현상(~70ms)보다 프로브 자체가 더 크다는 것을 실측, 이 스케일엔 부적합함을 확인)를 버리고 /proc/<pid>/task/<tid>/{wchan,stack} 저오버헤드 폴링(ptrace로 안 멈춘다)으로 지연 샘플 4회를 ~200µs 간격 샘플링해, 어느 스레드도 지속적으로 실행 중이지 않음(전부 futex_do_wait류)을 확인했다 — 126차 tcpdump·127차 FastDDS 로그에 이어 세 번째 "배제"다(확인 필요). 129차: 세 항목 모두에서 실질적 진전을 냈다. Humble 소스를 다시 읽어 wait_for_work()·get_next_ready_executable_from_map()이 재구성·필터링·선택을 서로 다른 세 번의 별개 mutex_ 획득으로 처리하고, 특히 무잠금 상태로 블로킹되는 rcl_wait() 도중 다른 스레드가 같은 공유 wait_set_을 자유롭게 다시 짓밟을 수 있다는 것을 확인했다 — 사건 단위 계측(재구성 시작/끝·rcl_wait 반환·필터링·선택·실행 시작/끝)으로 측정하니 기준 조건의 이탈 실행 22/22 전부(100%)에서 등록 순서가 처음 깨지는 그 선택 순간에 다른 스레드의 재구성 또는 실행 창이 겹쳐 있었다 — 128차의 "재구성 몰림" 관찰에 마침내 구체적 인과 경로가 확인됐다(실측 사례: 첫 선택이 등록 순서 0이 아니라 3이었다). Jazzy 이중 호출은 86차(CHG-131)식 결정론적 결함 주입(execute_service() 맨 앞에 환경변수 제어 지연을 넣어 콜백 그룹 busy 표시와 실제 take 사이의 창을 최대 800배(50ms)까지 확대)으로 검증했지만 주입 없음·5ms·50ms 세 조건 총 140회 전부 재현되지 않아, 127차의 구체적 경합 창 가설에 불리한 증거가 쌓였다(다만 이 세션 자체가 무주입 조건에서도 재현이 안 됐던 세션 의존성이 있어 완전한 반증으로 단정하지는 않는다). "지연 샘플" 패턴은 strace -f -tt -T -e trace=futex로 처음부터 끝까지 감싸(gdb의 ~295ms attach 프로브 문제를 피했다) futex 호출 인자를 직접 봤다 — 지연 샘플이 나온 독립된 두 실행 모두 어떤 스레드가 절대 시각 데드라인이 있는 FUTEX_WAIT_BITSET_PRIVATE(CLOCK_MONOTONIC 기준 타임드 웨이트)로 블로킹된 뒤 다른 어떤 스레드의 FUTEX_WAKE도 못 받고 자기 데드라인이 만료될 때까지 기다렸다가(64.4ms·62.2ms, 관측된 도착 갭 ~69ms와 거의 일치) ETIMEDOUT으로 깨어났다 — 126~128차의 세 배제 판정과 정확히 들어맞는, 웨이크업 경로가 연결 안 된 타임드 폴백이었다는 뜻이다. 정확히 어느 코드가 이 futex를 그렇게 대기시키는지는 futex 주소를 심볼로 역추적하지 못해 확정하지 못했다(확인 필요, 다음 회차 후보). 130차: 129차 원시 로그를 재분석해 사전 스핀의 idle 구간이 "400ms 분산"이 아니라 "시작 직후 ~935µs 몰아치기 + 이후 399ms 단일 rcl_wait() 블록"이었음을 발견해 127·128차 서술을 정정했지만(위 포인터), 이를 반영한 "첫 PICK 시점 스레드 위상 폭" 지표 자체는 사전 스핀 271.4µs 대 기준 361.0µs로 유의한 차이를 못 냈다(Welch $t=-1.93$, 방향도 가설과 안 맞았다)(확인 필요, 다음 회차 후보). Jazzy 이중 호출은 Jazzy get_next_ready_executable() 소스를 다시 읽어, busy 플래그(can_be_taken_from())가 execute_any_executable()이 execute_service()(실제 take 포함)를 완전히 마친 뒤에야 풀린다는 것을 확인했다 — 129차의 지연 지점(execute_service() 진입)은 이미 busy로 보호된 구간이었다는 뜻이다. 선택 반복문 안, busy를 세우기 직전으로 지연 지점을 옮겨 재실험했지만(무주입 30회·50ms 주입 50회) 다시 0/80 — 이 세션 누적 220회에서 이중 호출 0건이 됐다. 소스로 보면 당연한 결과였다 — busy 플래그가 선택 시점부터 처리 완료까지 전 구간을 감싸므로 그 안 어디에 지연을 넣어도 재차 선택은 막힌다. 127차의 경합 창 가설은 이 세션 실험 범위에서 사실상 배제됐고, 새 후보는 rmw/DDS 계층의 unread-count 플래그 지연이다(확인 필요, 다음 회차 후보). "지연 샘플" 패턴은 bpftrace(이 컨테이너 커널의 BTF 타입 파싱 실패로 폐기)를 대신해 strace -f -k -tt -e trace=futex(라이브러리 unwind 기반, BTF 불필요)로 완전히 심볼화했다 — 지연 샘플 갭(~159ms) 직후 eprosima::fastrtps::rtps::FlowControllerImpl<FlowControllerSyncPublishMode, FlowControllerFifoSchedule>::run()(FastDDS 라이터 쪽 흐름 제어·전송 페이싱 스레드)이 ~152.6ms 블로킹돼 있던 무기한 대기에서 뮤텍스 언락으로 깨어났고, 1.8ms 뒤 ResourceEvent::event_service()(FastDDS 범용 내부 타이머 스레드)도 깨어난 직후 지연 샘플 데이터가 도착했다 — 같은 로그에서 DataWriterImpl::write()→...→FlowControllerImpl::run()이라는 발행 경로 전체도 확인해, 같은 참가자 안의 배달도 이 흐름 제어 큐를 거친다는 것을 확정했다. FlowControllerImpl::run()이 왜 하필 ~150ms를 블로킹돼 있었는지는 소스까지 완전히 추적하지 못했지만, 주소만 있던 129차와 달리 정확한 클래스·함수명까지 확보한 것 자체가 진전이다(확인 필요, 다음 회차 후보)(→ 131차 정정: "흐름 제어·페이싱"이라는 표현은 오해의 소지가 있다 — 실제로는 고정 주기가 없는 순수 무기한 대기다, 아래 참고). 131차: 락 A 충돌 지표를 재설계해 마침내 명확한 신호를 얻었다 — 첫 시도("구간이 실제로 겹치는 횟수")는 뮤텍스로 원천 불가능해 예외 없이 0이었다(방법론적 실패, 정직하게 기록). 지표를 "재구성-끝(락 B)부터 선택(PICK) 성공까지 다른 스레드의 재구성-시작(락 A)이 몇 번 개입하는가"로 바꾸니 사전 스핀 30회 전부 개입 0회, 기준은 평균 4.00회(최대 18회)로 완벽하게 갈렸다(Welch $t=4.708$, 구간 단위로도 기준 16.4% 대 사전 스핀 0%, $t=5.755$) — 129차 메커니즘을 마침내 직접 지표로 확인했다. 다만 기준 안에서 개입과 실제 이탈을 대조하니 방향은 맞지만(이탈 평균 4.77 대 무이탈 1.88) 완전한 1:1은 아니었다(개입 0인데도 이탈한 실행 7/12) — 락 A 개입은 유력한 경로 중 하나이지 유일한 경로는 아니다(확인 필요, 다음 회차 후보). Jazzy 이중 호출은 rmw_fastrtps_shared_cpp 소스로 ServiceListener::get_unread_resquests()가 request_reader_->get_unread_count(true)를 실시간 조회한다는 것을 확인해 — rmw 계층이 캐시하는 "읽지 않음" 플래그 자체가 없다, 129차 가설은 이 형태로는 성립하지 않는다. 스레드 16개(N=6 대비 2.7배 과잉) 스트레스 테스트를 사전 스핀 50회 했지만 여전히 0/50 — 127~131차 누적 270회 사전 스핀 시도에서 이중 호출 0건이다(확인 필요, 다음 회차 후보)(→ 132차 정정: "실시간 조회일 뿐 캐시가 아니다"는 rmw 호출부만 본 결론이었다 — 그 답 자체는 RTPSReader::total_unread_라는 유지되는 카운터였다, 아래 참고). "지연 샘플" futex는 Humble fastrtps 2.6.12 소스(FlowControllerImpl.hpp)를 직접 읽어 정체를 완결했다 — FlowControllerSyncPublishMode가 상속하는 FlowControllerAsyncPublishMode::wait()는 cv.wait(lock)(타임아웃 전혀 없는 순수 무기한 대기, 고정 페이싱 주기는 대역폭 제한 모드에만 있다), 깨우는 지점 enqueue_new_sample_impl()의 주석이 // Sync delivery failed. Try to store for asynchronous delivery.라고 정확히 설명한다 — 관측된 ~62~150ms는 FastDDS의 고정 상수가 아니라 이 스레드가 마지막 활동 뒤 우연히 유휴했던 시간이었다, 실제 질문은 "동기 전달이 왜 그 샘플에서만 실패했는가"로 바뀐다(확인 필요, 다음 회차 후보). 132차: 104차의 "배치 분할"을 129차 원시 로그로 재분석하니 사전 스핀(5.93/6)·기준(5.17/6) 둘 다 N에 가까워 독립 변수가 아니라 락 A 현상의 증상이었다(오히려 무이탈 조건인 사전 스핀 쪽이 더 높았다) — 락 A 개입=0인 기준 실행의 64%가 여전히 이탈해 잔차가 남았다(확인 필요, 다음 회차 후보). Jazzy unread-count는 rmw 계층을 넘어 RTPSReader까지 내려가니 total_unread_라는 유지되는 카운터였고, 리스너 경로(get_last_notified() 기준)와 실제 take 경로(change_read_by_user())가 서로 다른 기준으로 같은 카운터를 독립적으로 갱신하는 구조적 경합 여지를 찾았다(정확한 어긋남 시나리오는 확정 못함)(확인 필요, 다음 회차 후보)(→ 133차 정정: 직접 읽으니 "드문 경합"이 아니라 "매 알림마다 결정론적으로 미리 당겨지는" 설계였다 — 드문 이중 호출과 안 맞아 원인일 가능성이 오히려 낮아졌다, 아래 참고). "지연 샘플"의 동기 전달 실패는 StatefulReader::processDataMsg()의 반환값으로 정체가 좁혀졌고 KeepLast(1) 관련 후보 분기(can_change_be_added_nts())까지 찾았지만, 기반 클래스엔 깊이 제한이 구현돼 있지 않아 실제 구현(DataReaderHistory)까지는 못 내려갔다(확인 필요, 다음 회차 후보). 133차: 132차의 잔차(락 A 개입 없는 이탈 7건)를 개별 조사하니 전부 등록 순서 안 어떤 연속 구간의 마지막 원소가 그 구간 맨 앞으로 이동하고 나머지는 정확히 원래 순서를 유지하는 "국소 창 회전"이었다 — 무작위 뒤섞임은 하나도 없었다(예: [3,0,1,2,4,5]는 창 [0,1,2,3]의 회전). 103·124차가 이미 확립한 "동시에 보내도 실제 도착 순서는 등록 순서와 미세하게 어긋난다"는 현상의 재현으로 보이며, 락 A 개입과는 서로 다른 독립된 이탈 경로였다(확인 필요, 다음 회차 후보). Jazzy get_last_notified()의 실제 갱신 지점(StatefulReader::NotifyChanges())을 찾으니, "notified" 워터마크가 개별 변경을 리스너에게 알리기 전에 이미 그 라이터의 최신 변경까지 미리 당겨진다("Update state before notifying"라는 주석 그대로) — 드문 타이밍 경합이 아니라 모든 알림에서 매번 결정론적으로 일어나는 순서다. 이 순서가 이중 호출의 원인이라면 항상 일어나야 하는데 실제 이중 호출은 드물다(누적 270회 0건) — total_unread_가 실제 원인이 아닐 가능성이 커졌다(확인 필요, 다음 회차 후보)(→ 134차 정정: rmw_wait.cpp를 다시 읽으니 실제 게이트는 애초에 total_unread_와 무관한 get_first_untaken_info()였다, 아래 참고). DataReaderHistory::can_change_be_added_nts()는 전체 리소스 한도만 검사했고, KeepLast(depth)의 실제 강제는 received_change_keep_last()에 있었다 — 같은 인스턴스(토픽)에 이미 depth개가 차 있을 때만 발동하는데, 이 책의 지연 샘플 테스트는 토픽마다 한 번만 발행하므로 이 분기 자체가 발동할 일이 없다 — KeepLast(1) 깊이 제한 가설은 소스로 배제됐다(확인 필요, 다음 회차 후보). 134차: 133차의 세 갈래를 직접 계측으로 추적했다 — "국소 창 회전"이 도착 미세 순서 어긋남이라는 추정을 리스너 콜백 기반 도착 시각 직접 계측으로 검증하니 이탈 실행 28/28 전부 도착 순서가 완벽한 등록 순서였다(가설 반증, 이탈은 순수 디스패치 쪽). rmw_wait.cpp를 다시 읽어 rcl_wait()의 실제 준비 판정 게이트가 total_unread_와 무관한 get_first_untaken_info()(data_available_instances_, 실제 애플리케이션 take로만 비워짐)임을 확정했다 — 129~133차가 판 total_unread_ 경로는 리스너 콜백 트리거만 결정할 뿐 대기집합 준비 판정과는 무관했다. processDataMsg()의 후보 분기(can_change_be_added_nts·reserve_cache·get_payload·change_received)도 GDB로 재빌드 없이 직접 계측해 1566회의 실제 동기 전달에서 단 한 번도 실패하지 않았다(다만 이 세션엔 "지연 샘플" 자체가 재현되지 않아 GDB 오버헤드가 그 창을 억눌렀을 가능성이 있다)(확인 필요, 다음 회차 후보)(→ 135차: 결함 주입과 GDB 없는 재계측으로 세 갈래 모두 직접 검증했다, 아래 참고). 135차: get_first_untaken_info()가 이중 준비 신호를 낼 수 있는지 add_to_instance()·check_and_remove_instance()의 타이밍을 결함 주입(erase 직전 지연 최대 50ms)으로 시험했다 — 디스커버리 트래픽 포함 9084건의 add/remove 사건에서 개입한 경우가 0건이었다(두 함수가 같은 리더 뮤텍스로 완전히 직렬화돼 구조적으로 불가능). GDB를 걷어내고 processDataMsg()에 원자적 카운터(재빌드, printf 없음)를 심어 재계측하니 "지연 샘플" 재현율이 0%(134차 GDB 세션)에서 40%대(16/40·17/40)로 상승했다 — GDB 오버헤드가 억누르고 있었다는 우려가 확인됐다. 재현된 33건에서도 분기는 여전히 안 실패했지만, 131차가 "동기 실패 시에만 불린다"고 지목한 enqueue_new_sample_impl()이 40회 전부 0번 불렸다 — 대신 무관한 재전송 함수(add_old_sample_impl())가 지연 샘플 유무와 무관하게 매 실행 정확히 13회 불려, FlowControllerImpl::run() 스레드가 실은 정기 재전송 유지보수를 처리하고 있었을 가능성을 열었다(확인 필요, 다음 회차 후보)(→ 136차 정정: "우연"이 아니었다 — 지연 샘플 16/16건 전부 재전송이 갭 창 안에 정확히 겹쳤다, 아래 참고). 개별 스왑을 락 A 개입과 사건 단위로 대조하니 스왑(44건)의 79.5%가 개입과 동반됐다(정상 픽 136건은 6.6%) — 108·110차의 회차-단위 상관을 사건 단위로 재확인했다. 나머지 9/44(20.5%)의 스왑은 여전히 설명 안 됐다(확인 필요, 다음 회차 후보)(→ 136차: 100% 설명했다 — 자기 재구성 없는 빠른 경로 픽이었다, 아래 참고). 136차: 재전송(add_old_sample_impl())에 사건 단위 타임스탬프를 남겨 지연 샘플 도착 시각과 직접 겹쳐 보니 지연 샘플 16/16건 전부에서 재전송이 갭 창 안에 2~8회씩 정확히 들어와 있었다(우연 기대치의 약 18배) — 인과 방향은 여전히 열려 있다(확인 필요, 다음 회차 후보)(→ 137차: 결함 주입으로 인과가 완전히 밝혀졌다 — 재전송 억제 시 지연 샘플이 아니라 메시지 전부가 사라졌다, 아래 참고). 락 A 개입 없는 잔차 스왑(9/44)을 60회 표본으로 재검토하니 잔차 28건(75건 중) 전부에서 그 스레드가 자기 REBUILD_START/END 사이클을 이 실행 안에서 한 번도 안 거쳤다 — get_next_ready_executable()이 wait_for_work()보다 먼저 시도돼 다른 스레드가 남긴 벡터를 자기 재구성 없이 바로 선택하는 "빠른 경로"였다(락 A와 합치면 스왑의 100% 설명). 픽 순번별로는 스왑률이 60.0%(픽#0)→36.7%→25.0%→3.3%→0%→0%로 단조 감소했고, 빠른 경로 스왑 28건 전부가 픽#0~2에만 몰려 있었다. 137차: 재전송(add_old_sample_impl())을 GAP137_SUPPRESS_RESEND로 실제로 억제하는 결함 주입을 넣어 정상·억제 각 40회를 실행했다 — 정상 40/40은 6개 메시지 전부 수신했지만 억제 40/40은 단 하나도 수신 못 했다(TRANSIENT_LOCAL 구독이 발행보다 늦게 생기는 구조라 재전송이 유일한 전달 경로였다). "빠른 경로" 픽에 "마지막 재구성자"를 사건 단위로 남기니 29건 전부(100%)가 남의 재구성 상태를 물려받았다(그 외 픽은 23.6%만). Jazzy의 선택 경로를 처음 직접 계측해 Humble과 같은 세션에서 비교하니(Humble 44/60·66스왑 대 Jazzy 60/60·242스왑, 이 절대 수치는 계측 오버헤드로 113·114차 기존 기준과 방향이 반대라 신뢰 못 함(확인 필요, 다음 회차 후보))(→ 138차 정정: 계측 없이 다시 재도 Jazzy는 여전히 60/60이었다 — 오버헤드가 아니라 세션 변동성이었다, 아래 참고), Jazzy 스왑의 94.2%가 Humble의 두 메커니즘 어느 쪽으로도 안 설명돼 108차의 해시 순서 가설이 별개 메커니즘일 가능성을 뒷받침했다. 138차: 계측 없는 시스템 rclcpp로 같은 세션 재비교하니 Jazzy는 여전히 60/60(100%) 이탈(Humble은 73.3%→50.0%로 바뀌었지만 방향은 안 바뀜) — 121·122차의 세션 변동성으로 보는 것이 타당하다. 해시 순서 가설을 collect_entities()→ExecutorEntitiesCollection::ServiceCollection(std::unordered_map<const rcl_service_t*, ...> 상속)→update_entities()의 begin()~end() 순회까지 함수·줄 단위로 확정했고, 137차의 Jazzy 60회 로그를 재분석하니 61.7%(37/60)가 정확히 같은 FIRE_ORDER 하나(5,3,2,1,0,4)로 수렴했다(순수 레이스가 아닌 결정론적 편향의 증거). processDataMsg()에 GUID 엔티티ID를 남기니 134·135차 측정(1566회)의 80.3%가 우리 테스트와 무관한 배경 트래픽(엔티티ID 하나가 73.8%)이었고, 우리 6개 토픽(19.7%)은 0건 실패로 재확인됐다(확인 필요, 다음 회차 후보 — 그 배경 트래픽의 정확한 토픽 이름 확정)(→ 139차: 확정했다 — ros_discovery_info·rt/parameter_events였다, 아래 참고). 139차: 토픽 이름까지 남기니 배경 트래픽이 정확히 ros_discovery_info(73.8%)·rt/parameter_events(6.6%)로 확정됐다. Jazzy의 "비지배적" 순서 23/60을 재분석하니 순수 잡음이 아니라 4개의 추가 안정적 순서가 81.7%(49/60)를 차지했다(1위 37회·2위 7회·3위 3회·4위 2회) — 소수의 안정적 해시 버킷 순서 후보군이 있다는 뜻이다(단발 순서 11/60이 순수 잡음인지 표본 부족인지는 못 갈랐다(확인 필요, 다음 회차 후보)). 코디네이터의 판단대로, 두 배포판의 핵심 메커니즘이 이미 충분히 규명됐다고 보고 32회차(108~139차) 조사 전체를 실무 지침으로 정리하는 데 무게중심을 옮겼다 — 새 절 2.3.15에 증명된 것(같은 타입 엔티티 순서 비보장, Humble의 사전 스핀 완화, TRANSIENT_LOCAL 선발행의 별도 지연 경로)과 증명되지 않은 것(이중 호출 원인·ASLR 부분적 역할·Humble이 더 불리한 정량적 이유)을 정직하게 갈라 정리했다.
1.8.8액션 결과 보관 시간(result_timeout) 기본값은 언어마다 다르다 — rclcpp_action은 rcl_action 기본값을 그대로 써서 10초, rclpy의 ActionServer는 900초(15분)로 자체 오버라이드한다
1.8.13목표 수락 직후 발행된 첫 피드백이 조용히 유실된다(순서가 뒤바뀌는 것이 아니라 사라짐) — 원인은 액션 클라이언트가 rclcpp::Waitable 하나로 묶여 있고 그 내부 is_ready()가 피드백을 응답보다 항상 먼저 보고하는 하드코딩 때문(96차, 2.3.14의 최상위 우선순위와는 무관 — 95차의 추정을 정정). 클라이언트 쪽엔 안전한 지연이 없다(스핀 시작을 늦출수록 유실이 25%→100%로 악화) — 서버가 1 ms만 늦춰도 없앨 수 있지만 클라이언트가 이미 스핀 중일 때만 유효하다. 97차: EventsExecutor는 로컬호스트에서 이 유실을 완전히 없앤다(오프셋 0~500 ms, 55/55 무유실 — 다섯 엔티티가 독립 콜백으로 등록돼 is_ready() 경로 자체가 없다). 98차가 정정: 97차가 "반복 전송(warm) 조건에서는 executor와 무관하게 유실"이라 적은 부분은 그림자 구독을 더한 새 하네스로 재현되지 않았다 — EventsExecutor는 warm 모드에서도 85/85(100%) 안전했고 SingleThreadedExecutor도 5~8%였다(97차 하네스 소스가 남지 않아 정확한 차이는 (확인 필요)). 반면 두 컨테이너 + netem(~100 ms RTT)에서 면역이 약 51%로 수렴한다는 부분은 98차가 tcpdump/tshark로 패킷 재정렬을 직접 확인해 추론에서 실측으로 승격됐다 — 로컬에서 안전하던 오프셋 0 ms 조건도 RTT에서는 약 50~60%로 악화된다. 99차: 로컬 warm 손실을 커널 타임스탬프(tcpdump)로 측정해 보니 240회 전부(손실 8건 포함) 커널 도착은 응답이 항상 먼저였다 — 로컬 손실은 네트워크 도착 순서와 무관하게 순수히 executor 폴링 우선순위 문제임을 확정. 트라이얼 인덱스로 보면 손실은 첫 7~12회엔 한 번도 안 났고(컨테이너 시작 잡음 가설 배제) 이후 산발적으로 났다(실제 확률적 경쟁에 부합, 원인은 미확정). rclpy는 이 손실 계열 전체에 면역이다(C++ 최악 조건 포함 240회 무손실) — send_goal_async()가 클라이언트 UUID로 feedback_callback을 요청 전송 전에 미리 등록하는 구조 때문(소스 확인). rclpy.executors엔 EventsExecutor가 없고 rclpy.experimental.events_executor에 있음도 확인. 101차: 99차가 발견한 "첫 7~12회는 항상 안전"이라는 초기 무사 구간을 perf stat(이 환경에서 사용 불가로 확인)·/proc 스케줄링 표본화로 좁히려 했으나, 대신 그 구간 자체가 오늘은(같은 하네스·docker run --rm 포함 세 방식, 총 45회 675 트라이얼) 재현되지 않는다는 것을 확인했다 — 손실 난 반복의 62.5%가 idx≤6에서 첫 손실을 냈다. 원인은 못 밝혔고 호스트 부하 차이가 유력 후보로만 남는다(확인 필요)
1.7.3"틀린 코드"(콜백 안 spin_until_future_complete 무한 대기)를 그대로 실행하면 책이 묘사한 조용한 CPU 0% 정지가 아니라, 처리되지 않은 예외로 즉시 크래시한다(...has already been added to an executor. — 프리 함수가 이미 executor에 속한 노드를 새 임시 executor에 다시 추가하려다 던짐, 멤버 함수 형태도 다른 메시지로 동일하게 크래시). 반면 future.wait()/.get() 직접 호출은 실제로 조용히 멈춘다(119차). MultiThreadedExecutor + 멤버 함수 조합도 같은 예외로 즉시 크래시한다는 것을 120차가 마저 확인해, "이미 스핀 중" 검사가 executor 타입과 무관한 인스턴스 단위임을 확정했다
1.7.4해법 세 가지(완전 비동기·별도 콜백 그룹·별도 노드)와 main()의 안전성 전부 실행 확인(Humble·Jazzy 양쪽 exit 0, 119차)
1.7.10함정#14 "클라이언트만 분리해도 데드락"의 정확한 의미를 세 배치로 확정 — 블로킹을 실행하는 콜백 자신이 아니라 클라이언트 핸들만 분리하면 여전히 데드락하고, 블로킹 콜백까지 함께 옮기면(해법②와 동일) 성공한다(119차)
12.4.6콜백 그룹 격리 패턴(책의 코드 그대로) 실측 — 격리 없이는 20ms 긴급 타이머가 부하가 큰 콜백에 막혀 평균 221.76ms(최대 302.90ms)로 급증했고, 전용 그룹+전용 실행기(별도 스레드)로 격리하니 정확히 20.00ms(지터 1ms 이내)로 회복(약 11배 개선)(178차)
12.4.5"주기 구동은 평균 주기의 절반 추가"를 실측하다 미묘함 발견 — 그 "주기"는 폴링 자신이 아니라 폴링·도착 주기 중 더 드문(긴) 쪽. 발행 20ms·폴링 100ms(도착이 잦음)에서 추가 지연 11.0ms≈도착 주기의 절반(10ms), 발행 300ms·폴링 20ms(도착이 드묾)에서 10.4ms≈폴링 주기의 절반(10ms) — 두 실험으로 정밀 확정(178차)
12.4.4message_filters의 "가장 늦은 센서를 기다린다"를 책 예시 그대로(10Hz·30Hz) 실측 — 동기화 속도 정확히 10.1Hz(라이다와 일치, 카메라의 30Hz 아님), 라이다 100%·카메라 33.4%만 매칭 사용(1:3 비율과 정확히 일치). registerCallback()에 람다·std::function 직접 전달 시 컴파일 실패하는 API 함정 발견 — 멤버 함수 포인터 형태로 해결(179차)
14.1.7"논리적 분할은 자유롭게, 물리적 배치는 나중에"를 rclcpp_components_register_node()로 실측 — 같은 빌드 산출물로 독립 프로세스·컴포넌트 컨테이너 배치를 소스 변경 없이 둘 다 실행. 독립 프로세스는 평균 1302~2088µs·p99 60,626~73,272µs(거대한 꼬리 스파이크 재현), 컨테이너(intra-process)는 평균 101µs·p99 194µs로 스파이크 소멸. 다만 전형적인 경우(p50)의 차이는 1.8배로 훨씬 작아, 평균값이 이득을 과장한다는 것도 정직히 밝힘(180차)
14.1.7(185차 추가) 위 꼬리 스파이크의 근본 원인을 규명 — 매 메시지 원시 지연을 CSV로 저장해 보니 구독자가 받은 첫 9개 메시지 지연이 정확히 10ms씩 줄어드는 계단 형태(90732→10817µs)로 99마이크로초 안에 몰려 도착 — 구독자 자신의 디스커버리·매칭 시간(약 90ms) 동안 발행자의 KEEP_LAST(10) 이력에 쌓인 메시지가 매칭 완료 순간 한꺼번에 전달된 것. 두 번 재현(최대 90732µs·90659µs, 0.1% 이내 일치)해 결정론적임을 확인. RMW를 Cyclone DDS로 바꾸면 이 계단 전체가 사라짐(최대 3145µs)— Fast DDS 특유의 현상으로 좁혀짐. 별개로 정상 구간의 0.5~3ms대 산발적 스파이크는 미확인으로 남음(185차)
14.1.7(186차 추가) 위 캐시 플러시 설명을 QoS KEEP_LAST 깊이 1·3·5·10·20 스윕으로 인과 확증 — 돌출 메시지 개수 0·2·4·9·18개(깊이 3·5·10에서 정확히 깊이−1), 최대 지연이 (깊이−1)×10ms 예측과 깊이 10 이하 오차 2.5% 이내로 일치(깊이 20만 7.4% 편차, 원인 추정만 함). 부수적으로 185차의 "디스커버리 약 90ms" 서술을 정밀화 — 그것은 실제 디스커버리 시간이 아니라 깊이 10 캐시가 채워지는 시간과 우연히 일치한 값이었고, 이 방법으론 디스커버리 시간의 하한선만 알 수 있다는 것을 밝힘(186차)
14.1.7(187차 추가) 186차가 "우연"으로 추정한 깊이 20의 7.4% 편차를 5회 반복해 재확인 — 6회(이번 5회+186차 원본) 전부 돌출 메시지 18개(예측 19개 아님), 최대 지연 175,702~182,429µs(평균 179,200.5µs, 예측 대비 −5.7%)로 방향·크기가 일관 — 우연이 아니라 깊이 20에서 재현되는 체계적 현상임을 확정(원인은 미상)(187차)
14.1.7(199차 추가) 187차가 원인 미상으로 남긴 깊이 20의 "18개" 편차를, 깊이 대신 발행 주기(5·10·20·40ms)를 스윕해 재조사 — 버스트 개수가 주기에 반비례(20·18~19·9·5개)했고 버스트 시간 폭이 20ms·40ms 둘 다 정확히 160ms로 일치, "18개"는 고정 샘플 개수가 아니라 ~160~190ms의 실시간 시간 예산이 주기 10ms와 우연히 맞아떨어진 결과였음을 확정. 부수 발견 — 주기 10ms를 8회 반복하니 3/8회(37.5%)는 버스트가 33~34개로 깊이(20) 자체를 넘었다(시간 폭 320~330ms, 단일 창의 약 2배) — KEEP_LAST(depth)가 새 구독자에게 전달되는 과거 샘플 수의 하드 캡이라는 전제를 반증하는 사례(정확한 DDS 메커니즘은 미상)(199차)
12.6.8(187차 신규) "디버그 빌드로 운영"·"최적화 플래그 미설정"에 이 장이 원칙상 생략하는 성능 수치를 이 항목 하나에 한해 실측 — CMAKE_BUILD_TYPE 미지정/Debug/Release의 실제 컴파일 플래그를 flags.make로 확인하니 미지정은 -std=gnu++17뿐(최적화 없음), Debug는 -g만 추가(역시 최적화 없음), Release만 -O3 -DNDEBUG — CPU 위주 350×350 행렬곱 벤치마크에서 미지정 301.796ms·Debug 299.717ms(사실상 동일)·Release 47.730ms로 약 6.3배 차이(이 특정 벤치마크에 한정, 일반화 안 함)(187차)
12.6 카탈로그 정합성(190차 점검, 컨테이너 실행 없음) 인용 표본 대조로 실제 오류 1건(12.6.7의 11번 GPU·CPU 왕복 → 7.8.5는 무관한 절, 이 책엔 해당 절 자체가 없음) 발견·정정, 부정확한 인용 1건(12.6.8의 4번 온보드 GUI → 11.4.5보다 11.4.6이 정확) 정정. 14.1.7(컴포넌트 컨테이너, p99 약 310~370배 개선)을 12.6.3/12.6.6과 12.4.5에 근거 있는 누락 인용으로 추가. 나머지 표본(12.3.5·12.4.4·12.4.5·12.4.6·3.9.13·11.4.6·1.6.3.1)은 모두 정합적이었음(190차)
12.6 카탈로그 정합성(193차 점검, 컨테이너 실행 없음) 190차가 표본만 보고 남긴 나머지 약 49개 인용을 전수 대조 — 같은 유형(잘못된 절 인용)의 오류 6곳·8개 셀 추가 발견·정정(1.9.7→1.7.3, 2.7.5→2.7.4, 5.1.5→5.1.9/5.1.6, 5.2.3→5.1.9×2, 1.6.4→1.6.6). 부수적으로 A.1의 CLI 표에서도 같은 유형 오류 2건(서비스 장 1.8→1.7, 1.9.7→1.7.3) 발견·정정. 새로 놓친 인용은 못 찾음, 나머지 약 39개는 정합적이었음(193차)

계층·미들웨어

절확정한 것
2.1.12사용자 실행 파일의 벤더 링크 0회. 계층도에 없던 rmw_implementation, 벤더 결합은 런타임에 두 곳
3.10.12zenoh는 라우터 없이도 노드가 기동되고 아무도 못 받는다. QoS 지원 범위가 갈린다
3.3.12이 규모의 디스커버리 시간은 N이 아니라 알림 주기가 지배. 비정상 종료가 /dev/shm을 채우면 SHM이 조용히 UDP로 격하
3.7.12XML은 SystemDefaultsQoS()가 아니면 QoS를 못 바꾼다(예외: depth는 max). 네 가지 실패가 전부 오류 0줄 — get_actual_qos()가 유일한 확인
3.8.12설정 오류 반응이 벤더마다 정반대 — Cyclone은 넷 다 노드가 기동되지 않는다(줄 번호까지). SystemDefaultsQoS() 기본 내구성도 다르다(Fast DDS 래치·Cyclone 아님)
3.2.12포트 계산식 $PB+DG\cdot D+d+PG\cdot P$ 전수 확인. 도메인이 바뀌어도 멀티캐스트 그룹은 하나 — 포트로만 갈린다. 조각 크기는 경로 MTU 의존(1416 B ↔ 13.5 KB)
3.1.13도메인 ID 상한은 232 — 233부터 "Calculated port number is too high"로 기동 실패(Fast DDS)
1.1.12__ 예약은 리매핑 규칙에서만 강제된다 — 이름으로는 __weird도 그대로 만들어진다. __ns는 커맨드라인 순서와 무관하게 항상 먼저 반영되고(1.1.12.4), 이름 충돌 시 ros2 node list가 경고와 함께 둘 다 보여 준다(1.1.12.5)
3.5.3RMW_IMPLEMENTATION 교체는 재빌드 없이 실제로 동작한다. 미설치 시 조용한 폴백이 아니라 즉시 dlopen 오류로 실패(Humble·Jazzy 141차)
3.5.4RMW 혼용 실측 — 토픽은 실제로 통신됐다(Fast DDS↔Cyclone DDS, type hash 경고는 나지만 데이터는 옴). 서비스는 발견은 되지만 실제 호출은 응답 없이 멈춘다 — 이 절의 "위험" 경고가 정확히 재현됐다(141차)
3.6.2다섯 벤더(Fast DDS·Cyclone DDS·Connext·GurumDDS·zenoh) 전부 Jazzy·Humble apt 저장소에 있다(141차) — GurumDDS의 Jazzy 지원 여부를 이걸로 닫았다. Connext는 RTI 런타임 자체가 apt로 함께 설치된다(라이선스 종류는 미확인)

네트워크·배포·디버깅

절확정한 것
3.9.12같은 Docker 네트워크면 디스커버리가 된다. 호스트↔컨테이너는 차단이 아니라 가입 인터페이스 문제
3.9.13RELIABLE의 대가는 꼬리 지연(p99 100~200배). 크기가 급변 지점 — 유실 5%에서 256 KB는 0%
11.1.15ros2 doctor hello의 두 표가 층을 가른다(멀티캐스트만 차면 ROS 설정 문제)
14.8.12SIGTERM·SIGINT·SIGHUP을 노드가 각각 어떻게 처리하는가
14.8.13SIGTERM·SIGINT 종료는 코드 0이라 Restart=on-failure가 검출하지 않는다. finalized·부분 활성은 프로세스가 정상이어서 systemd 시야 밖
0.7.11진단 명령의 보고값 대조 — param get은 저장값(적용값 아님)·node list는 강제 종료 후 22초 유령·--no-daemon은 10회 중 2회 누락·ros2 doctor는 통신이 끊겨도 전부 통과
0.7.12ros2 topic hz 정확도 — 작은 메시지는 1 kHz까지 오차 ≈0, 대용량은 1~3% 낮게. 실제 오차원은 창(기본 메시지 10,000개) — 10 Hz 토픽이면 17분을 기억한다. 발행이 멈추면 아무 말도 안 한다
3.9.14조각화 모형 검증 — 조각 하나에 1,480 B, 조각 수 = 크기÷1480. $(1-p)^N$이 유실 1%에서 네 칸 모두 적중하되 낙관 방향으로 틀린다. RELIABLE은 16 KB에선 60→82%로 일하고 64 KB에선 포기한다(2%)
0.4.12ROS_AUTOMATIC_DISCOVERY_RANGE 기본값은 SUBNET(rcl 헤더 주석과 일치). 값에 오타를 내면 오류 없이 LOCALHOST로 격하 — 다른 로봇과 조용히 안 보이게 된다

좌표·모델·시간

절확정한 것
5.2.11외삽 오류 두 숫자의 차가 곧 시계 오차. 타임아웃을 다 쓰고 실패하면 시계 문제
5.4.12xacro 지시자 열한 개. 조건 평가에서 문자열은 안전, 인자 형식 네 가지 전부 동작
5.5.13/robot_description은 파라미터이면서 토픽. publish_frequency 기본 20 Hz
5.6.11<mimic>을 URDF 쪽 소비자 셋은 전부 지원. gz 명령은 별도 설치
5.3.3<mimic> 관절 — robot_state_publisher는 구동 관절만 발행해도 mimic 관절 tf를 공식대로($-1.0\times0.03+0.0=-0.030$) 계산. ros2_control은 MimicJoint 구조체·mimic="true" 속성으로 소스 레벨 1급 지원, mock_components/GenericSystem 실제 런타임으로도 확정($2\times0.1+0.01=0.21$)(165~166차)
5.3.7<transmission>의 Jazzy 위치 확인 — "구형 방식"이 아니라 <ros2_control> 블록 안으로 이전·재설계됨(SimpleTransmission·DifferentialTransmission·FourBarLinkageTransmission 플러그인)(165차)
5.3.8"동적 재구성"(런타임 robot_description 교체) — libsdformat14 설치 후 재시도해 지원됨을 확정. 165차의 실패는 기능 미지원이 아니라 최소 컨테이너의 의존성 누락이었음을 원인 분리로 확인(166차)
5.3.9check_urdf가 확인하는 것/못하는 것 7항목 전부 실측 일치 — 필수 속성·참조 무결성·트리 구조는 검사, 관성 타당성·질량 현실성·메시 존재는 미검사(165차)
5.7.4check_physics.py(개념 구현)를 실제로 5개 URDF에 실행 — 버그 없이 오류/경고 표와 정확히 일치(삼각부등식 위반·질량 음수·<inertial> 없음·비대각 지배 전부 정확 검출)(166차)
5.7.5check_meshes를 실제로 3개 URDF에 실행 — 실재 파일(오류 0)·존재하는 패키지의 없는 파일(오류 1)·존재하지 않는 패키지(오류 1) 전부 정확(166차)
5.7.7check_semantics를 5가지 결함(축 비단위벡터·effort/velocity=0·한계 역전·이름 앞 슬래시·이름 공백)을 심은 URDF에 실행 — 5개 전부 정확히 검출, 정상 관절 오탐 0건(166차)
5.7.8tf 트리 완전성 테스트를 실제 launch_testing으로 양성·음성 둘 다 실행 — 5개 프레임 전부면 통과, 하나 빼면 정확히 그 프레임만 실패 메시지에 지목(167차)
14.6.2post_shutdown_test+assertExitCodes의 "조용한 크래시" 탐지 양성·음성 확인. assertWaitFor(출력 매칭)의 기본값이 stdout이 아니라 stderr라는 문서화되지 않은 함정 발견(167차)
14.6.3get_node_names() 기반 노드 존재 확인 패턴을 실제 demo_nodes_cpp talker/listener로 실측(167차)
14.6.2(168차 추가)ReadyToTest 타이밍 함정을 3초 지연 노드로 재현 — 1초만 기다리면 실패, 5초 기다리면 통과(168차)
6.6.4RealtimeBox는 Jazzy에서 폐기(현재 이름 RealtimeThreadSafeBox), RealtimeServerGoalHandle은 실재·폐기 아님. RealtimeBuffer 컴파일·실행 — 2,300만 회 넘는 동시 읽기에서 찢어진 값 0건(168차). RealtimePublisher도 실제 빌드·실행 — trylock 성공률 99.7~99.9%, 독립 측정 3880Hz 발행 확인(169차)
6.6.6실제 ros2_control_node 스레드 수 37개(chrt는 1개만 표시). pgrep -x가 이 절 예시(17자 프로세스명)에서 조용히 실패함을 발견 — 커널 comm 15자 제한 때문(168차)
7.7.12slop 기준은 빠른 쪽 주기의 절반(책의 공식 정정). 큐 공식은 정확했고 실패는 급격한 붕괴
7.8.5topic_tools throttle 실재 확인 — 호출 문법은 위치 인자. 요청 속도가 실제 속도가 아님을 발견 — 5Hz 입력에 2Hz 요청 시 실제 1.667Hz(입력 도착 시각에 양자화). 드롭-if-busy 패턴은 동시성 하에서 정확(100=25+75)(172차)
1.10.11create_jump_callback은 인자가 셋 — 임계값은 기본값이 없어 생략 불가. 책의 예시 세 곳이 컴파일되지 않았다(1.10·5.2 ×2)

하드웨어·제어·실시간

절확정한 것
6.5.12diff_drive_controller는 TwistStamped만 받는다. 워치독은 컨트롤러마다 다르다
6.7.2vcan(가상 CAN)·socat(가상 시리얼)로 하드웨어 없이 검증. 지수 백오프 attempt_reconnect() 배가 수열(100→...→3200ms) 정확히 확인(171차)
6.7.3open_serial() termios 코드를 가상 포트로 실측 — 바이트 정확히 왕복. parse_frame() CRC/프레이밍 5개 테스트 벡터 전부 정확(171차)
6.7.4open_can()의 CAN_RAW 필터가 실제 커널 레벨에서 동작 — 필터 안 걸린 프레임은 수신자에 전혀 안 들어옴. ros2_canopen Jazzy에 23개 이상 패키지로 포괄적 실재(171차)
6.7.5ethercat_driver_ros2는 표준 apt 저장소에서 못 찾음(소스 빌드 경로는 미시도, "없다"고 단정하지 않음)(171차)
6.7.7udev 규칙 — udevadm verify로 문법 확인, udevadm test는 표준 규칙 디렉터리(/etc/udev/rules.d/)만 읽음을 실측. 완전한 ATTRS 매칭은 실제 하드웨어 없어 미확인(172차)
6.8.13micro-ROS 에이전트는 apt에 없다. rclc는 정식 배포
6.9.12Updater 갱신 주기는 네 곳에서 정해진다. 진단 이름에 네임스페이스가 안 붙는다
6.9.14진단이 워치독이 된다 — 침묵은 Stale, finalized는 노드가 보고해야 Error. 개별 항목은 종료돼도 마지막 값이 남는다(그룹으로 판정)
12.2.4PI 뮤텍스 API 컴파일·실행 확인. 실제 우선순위 역전 완화 효과를 정량 재현 — 일반 뮤텍스 H 대기 ~305ms 대 PI 뮤텍스 ~40ms(약 7.7배 개선, 3/3 반복 모두 0.1ms 이내로 수렴)(169차). 우선순위 천장(PTHREAD_PRIO_PROTECT)도 재현 — H 대기 ~40ms로 PI와 사실상 동일(170차)
12.2.11스레드는 스케줄링 정책을 전부 상속한다 — 그리고 그것이 문제다
12.2.12controller_manager의 thread_priority 기본 50, 권한 없으면 경고만 내고 격하. IRQ·거버너·cyclictest 확정
6.3.11ros2 control 서브커맨드 열둘 — 책이 쓰던 이름은 전부 맞았고 view_controller_chains(체인 구성도 PDF)가 새로 눈에 띈다. Jazzy 플러그인은 mock_components뿐
12.1.5realtime_tools의 configure_sched_fifo·lock_memory·has_realtime_kernel API 실측 — 이 환경에서 has_realtime_kernel()=false. 컨테이너 권한 3단계 대조: 권한 없음(둘 다 실패) → --cap-add=sys_nice만(스케줄링만 성공) → +ipc_lock+memlock=-1(둘 다 성공) — SCHED_FIFO와 mlockall이 다른 capability를 요구함을 확인. 공식 실시간 데모 pendulum_control(desktop-full 기본 포함, tlsf_cpp·rttest 의존)을 5회씩 반복 실행 — 메모리 잠금은 pagefault를 5/5 모두 0으로 없앴지만 지연 자체는 개선되지 않음(평균 거의 동일, 최댓값은 오히려 다소 높음) — RT 커널·CPU 격리 없인 한 층만 고쳐도 소용없다는 장 자신의 주장과 정합(197차)

센서·인지

절확정한 것
7.1.12.msg 파일이 규약의 정본이다. 공분산의 "모름"은 두 가지
7.2.12image_transport만 설치하면 raw뿐이다. 플러그인은 따로 온다
7.3.11왜곡 모델 정본은 distortion_models.hpp — fisheye는 ROS 상수가 아니다
7.4.13커스텀 포인트 타입 등록 매크로. 타입 하나가 32바이트
7.6.4"위치가 아니라 속도를 쓰라" 원칙을 회귀 분석으로 정량 확인 — 인위적 위치 드리프트가 위치 구성엔 100%(기울기 0.6994≈0.7), 속도 구성엔 0%(기울기 0.5000=참값) 유입(157차)
7.6.5이중 인스턴스 패턴 — ekf_map이 map→odom만 발행하고 map→base_link는 발행하지 않음을 /tf 원시 메시지로 확인(161회 대 0회)(157차)
7.6.3·7.6.9함정 "공분산 0 → 그 센서만 신뢰"를 재현 — 정상 오도메트리와 공분산 0인 버그 소스를 동시에 주면 융합 결과가 버그 소스 쪽으로 완전히 지배당함(157차)
7.6.6navsat_transform_node 실제 기동 — IMU 구독 토픽이 문서(/imu/data)와 달리 실제로는 /imu임을 소스로 확인·정정. base_link→gps_frame_id tf 없으면 결과가 조용히 원점 고정되는 미문서화 함정을 소스 계측으로 규명. 좌표 변환(북 기울기 0.9971≈1.0)·자기 편각(10° 설정 시 역산 회전각 정확히 10.00°)·delay(정확히 3.000초) 정량 확인(158차)
7.6.4differential·relative의 정확한 의미론을 소스(ros_filter.cpp)로 규명 — 상수 오프셋은 둘 다 제거하지만 선형 드리프트는 둘 다 못 막음(회귀 기울기 기본 0.6991·relative 0.6993·differential 0.7000, 사실상 동일). 둘 다 켜면 파라미터 로딩 시 RCLCPP_ERROR_STREAM으로 경고하고 differential이 강제로 승리함을 실측으로 확인. 부수적으로 ukf_node가 ekf_node와 같은 최소 설정에서 지속적인 NaN 감지·복구(1333회, 저장 로그 기준)로 위치 적분이 처지는 것을 발견(159차)(→ 160차 정정: NaN과 지연은 무관한 별개 현상이었다 — 신선한 재현 5회 모두 NaN 0건이었지만 지연은 그대로였음. 아래 참고)
7.6.4(160차 추가) ukf_node NaN의 인과관계를 재조사 — 짧은 관찰 창(10~17초)에서는 NaN 0건이라 처음엔 "재현 안 됨"으로 오판했으나, 방치한 프로세스의 최종 로그와 타임스탬프를 대조해 NaN이 기동 후 약 30초 뒤 지연 발생형으로 반드시 나타남을 재확인. 위치 적분 지연은 t≈1초부터 시작돼 NaN보다 먼저이므로 "NaN이 지연을 유발한다"는 159차 서술은 인과관계가 틀렸음도 확정. 소스로 확인한 두 수치적 취약점(기본 initial_estimate_covariance=1e-9, 기본 alpha=0.001에서 시그마 가중치가 -1,000,000/+33,333 극단값)에서 나온 두 수정안은 각각 실측으로 반증됨 — 명시적 공분산은 지연을 악화(0.3885→0.2302), alpha=1.0은 NaN을 더 빨리(10초 안에) 나타나게 함(최종 692회). 부수적으로 kill -9가 파일로 리다이렉션된 로그 전체를 유실시킬 수 있다는 것과 짧은 관찰 창이 결론을 뒤집을 수 있다는 방법론 교훈 둘 다 확인(stdbuf -oL -eL로 로그 유실은 해결)(160차)
8.2.13SLAM Toolbox 실행 파일 여섯 개. mode 두 개가 서로 다른 것을 가리킨다
10.1.7"시뮬 테스트는 불안정하기 쉽다"를 TurtleBot3+Gazebo+Nav2로 실측(두 층위) — ① tb3_simulation_launch.py의 headless:=True는 Gazebo GUI만 끄고 RViz2는 끄지 않음을 소스로 확정, RViz2 없는 컨테이너에서 headless:=True만 주면 4/4 크래시(SIGABRT)로 부트업 실패, use_rviz:=False 추가 시 5/5 성공. ② 부트업 안정화 후에도 동일 시작·목표 5회 반복 중 1회만 SUCCEEDED, 4회 ABORTED(/initialpose 5Hz 발행과 tf 버퍼 성장 간 타이밍 경쟁으로 추정) — 단 실패 트라이얼에만 FastRTPS 공유메모리 포트 에러가 동반돼 하네스 자체(짧은 재기동 간격)의 교란일 가능성을 정직하게 남기고 "5회 중 1회"를 일반화하지 않음(198차)
10.3.1렌더링이 필요 없는 센서(IMU)에 한해 Gazebo Harmonic 시뮬레이터 자체를 헤드리스로 구동 성공 — 렌더링 시스템 플러그인을 빼고 물리+Imu 시스템만 넣으니 GPU 없이 정상 기동. 10.2의 "GPU 없어 시뮬레이터 미검증"이 카메라·라이다 등 렌더링 필요 센서에 한정된 것이었음을 확인(181차)
10.3.8기본 <imu> 설정이 중력을 포함함을 실측(정지 시 z=9.8000, 책의 "≈9.81"과 일치). 자유낙하 구간은 z=0.0000(정확한 무중력 물리), 착지 시점(t≈0.44초)이 이론적 자유낙하 시간(0.4286초)과 정밀 일치 — 물리 엔진의 실제 동역학 계산을 확인(181차)
10.3.2접촉 센서 메시지 타입 확정 — 책의 추측(ContactsState계열)이 아니라 실제로는 ros_gz_interfaces/msg/Contacts(복수형)임을 확인·정정(181차)
10.3.3bias_mean·bias_stddev가 노이즈와 구분되는 실제 지속적 오프셋을 만듦을 실측(설정값과 1% 이내 일치, 가속도·각속도 6채널 전부). 축별 부호가 다르게 나오는 재현 가능한 패턴을 발견했으나 원인은 미조사로 정직하게 남김(182차)
10.3.5always_on:false의 CPU 절약 효과를 시도했으나 정직하게 결론 내지 못함 — true 3.28 대 false 3.20 CPU초(10초 무구독 창), 약 2.4% 차이로 유의미한 신호 없음. 경량 상태 기반 센서에서는 효과를 분리하지 못했다고 정직 보고(182차). 카메라로 재시험해도 다시 미해결(true 3.33 대 false 3.27 CPU초, 약 1.8% 차이)(183차)
10.2.9카메라 센서(렌더링 필요)가 GPU·Xvfb 없이 완전히 동작함을 실측 — gz-sim-sensors-system 포함, 플래그 추가 없이 gz sim -s -r로 실행하니 Ogre2가 디스플레이 부재를 자동 감지해 소프트웨어 렌더링으로 전환. /camera_info 내부 파라미터가 설정값과 정확히 일치, /camera에 실제 픽셀 데이터 확인, RTF≈1.00(간단한 장면 한정). "GPU 없이는 렌더링 센서 미검증"이라는 10.2·10.3의 표준 제약을 갱신(183차)
10.3.2gpu_lidar는 카메라와 달리 헤드리스·소프트웨어 렌더링에서 실제 거리 데이터를 만들지 못함을 실측(정직한 부정적 발견) — 두 독립 배치(360포인트·벽 2m / 8포인트·대형 벽 0.5m, 지오메트리상 놓칠 수 없는 배치) 모두 /scan 토픽은 정상 발행되지만 전체 거리값이 inf. 근본 원인은 미상(로그에 오류 없음). 별도로 type="lidar"(CPU 방식)는 [Wrn] Sensor type LIDAR not supported yet로 이 버전(gz-sim 8.11.0)에서 전혀 구현되지 않음을 확인(184차)
10.2.9(184차 추가) 위 10.3.2와 같은 시험을 10.2 쪽에도 반영 — "GPU 없이 렌더링 센서가 동작한다"(183차)는 카메라에 한정하고, gpu_lidar까지 일반화하지 않는다는 경계를 명시(184차)
11.4.2RViz2 헤드리스 실행 — Gazebo Ogre2(162차)와 달리 디스플레이 부재를 자동 감지하지 않아 DISPLAY 없이·QT_QPA_PLATFORM=offscreen으로도 종료되지만, Xvfb를 주면 완전히 동작(OpenGl version 4.5, 31 fps 실제 렌더링을 스크린샷으로 확인). "GPU·디스플레이 없이 헤드리스가 된다"가 도구마다 조건이 다르다는 것을 확장 확인(188차)
11.4.4RobotModel 디스플레이는 토픽(기본, TRANSIENT_LOCAL)·파일 두 방식뿐이고 파라미터 방식은 없음(rviz-default-plugins 14.1.22 소스로 확인, 헤더 주석만 낡아 "ros-param" 언급). Image 디스플레이의 "image_transport 미지원"은 틀린 서술 — 실제로는 image_transport::SubscriberFilter를 써서 토픽 이름으로 전송 방식을 판별·지원함을 소스로 확인·정정(188차)
11.4.1"플러그인 API는 버전 의존" — Jazzy·Humble 헤더 전수 대조로 정량 확인. rviz_common 84개 중 17개(20.2%)·rviz_default_plugins 97개 중 38개(39.2%) 공통 헤더가 다름. ROSImageTexture의 ImageData 생성자가 3인자(문자열 인코딩)→4인자(Ogre::PixelFormat)로 완전히 바뀌는 등 실제로 깨지는 시그니처 변경을 확인. 반면 가장 기본인 Display 클래스 자체는 사실상 안정적(189차)

항법·매니퓰레이션

절확정한 것
8.1.14Nav2와 컨트롤러의 cmd_vel 기본값이 어긋나 있다. bt_loop_duration은 밀리초(BT 틱 약 100 Hz)
8.1.15이 책이 안 다루는 nav2_route의 실물 — 비용 함수 8종·구간 동작 5종·GeoJSON 양방향. 간선 단위 비용이 자유 공간 계획과 갈리는 지점
8.6.12nav2_tree_nodes.xml이 등록 노드의 정본. Groot은 apt로 오지 않는다
9.1.12MoveItCpp는 moveit_ros_planning 제공. .h는 폐기 예정
9.2.12계획 어댑터가 요청·응답으로 갈리고 이름이 바뀌었다. 저크 제한은 기본 꺼짐. FAILURE=99999만 양수
9.3.3applyCollisionObjects(→/apply_planning_scene)의 동기 의미론을 실측 확인 — 서비스 반환 직후 곧바로 조회하니 이미 반영됨. /collision_object 토픽(비동기)의 미반영 경합은 이번 시험 조건에서 재현 못함(부분 확인)(173차)
9.3.4attachObject의 touch_links가 attached_collision_objects[].touch_links에 정확히 반영됨을 실측. raw diff 부착 호출이 효과는 내면서 success=False를 반환하는 anomaly 발견(원인 미조사), 분리는 success=True로 명확(173차)
9.3.5/clear_octomap 서비스 이름·타입(std_srvs/srv/Empty) 확정 — 직접 호출해 정상 응답 확인(173차)
9.3.10GetPlanningScene의 components 비트값 전수 확정(SCENE_SETTINGS=1~OBJECT_COLORS=512). 책의 "알려진 물체 목록" 예시가 틀렸음을 발견 — components:1은 물체 목록을 못 채우고 components:8(WORLD_OBJECT_NAMES)이라야 채워짐, components:0은 전체 반환(173차)
9.4.3/servo_node/delta_twist_cmds·/delta_joint_cmds·/status 토픽 이름 실측 확인. 책에 없는 필수 선행 단계 발견 — /servo_node/switch_command_type으로 입력 종류를 먼저 선언하지 않으면 모든 명령을 무시함. JointJog·TwistStamped→관절 명령 변환 실측(100 Hz, 자코비안 다관절 분배 확인)(174차)
9.4.4특이점 파라미터 이름(lower/hard_stop_singularity_threshold 등) 전부 정확. demo_twist 자기구동 데모에서 "decelerating" 경고가 실제로 반복 발생함을 확인 — 감속 메커니즘이 실제로 발동(174차)
9.4.5충돌 파라미터 4개(check_collisions 등)는 정확, collision_distance_safety_factor·min_allowable_collision_distance는 이 버전에 없음을 실측 확인(174차). 실제 장애물로 감속·정지 재현 — 속도가 정확히 0.0으로 떨어져 유지되는 완전한 정지, code:4("Close to a collision")를 실측 확인. servo_node의 monitored_planning_scene_topic 기본값이 move_group의 실제 방송 토픽(/monitored_planning_scene)과 다른 통합 함정을 발견(175차)
9.4.6start_servo·stop_servo(Trigger)는 없고 pause_servo(SetBool) 하나로 통합돼 있음을 실측 정정 — 정지·재개 각각 정확히 0개/100개로 확인. joint_limit_margin(단수)은 실제로 joint_limit_margins(복수, 목록), low_latency_mode는 존재하지 않음(174차)
9.4.9/servo_node/status 타입(moveit_msgs/msg/ServoStatus, code+message) 실측 확인, 정상 상태에서 code:0, "No warnings" 발행 확인(174차)
9.5.7MTC(moveit_task_constructor_core·-demo) Jazzy 정식 패키징 확인. 실제 픽앤플레이스 과제를 계획+실행까지 완주 — 물체가 원래 픽 위치에서 배치 설정값과 정확히 일치하는 (0.600,-0.150,0.125)로 실제 이동함을 /get_planning_scene으로 직접 측정(176차)
9.5.4computeCartesianPath()를 독립 C++ 노드로 직접 시험(이 투자 최초의 MoveGroupInterface 링크 노드) — 도달 가능 경로는 fraction=1.0+실제 5cm 이동 확인, 도달 불가 경로는 fraction=0.571로 책의 "포기" 분기가 정확히 발동. 4-인자 시그니처는 Jazzy 2.12.4에서 폐기 경고(동작은 함)(177차)
9.6.4"핸드아이 캘리브레이션 도구·패키지의 Jazzy 지원"을 apt 저장소 직접 조회로 확인 — 유력 후보 4개(easy_handeye2·moveit_calibration·handeye_calibration·visp_hand2eye_calibration) 전부 저장소 색인에 없음(전용 패키지 없음). 마커 검출 부품(apriltag_ros·aruco_ros)과 cv2.calibrateHandEye()(OpenCV 4.6.0에 실재 확인)는 있어, 책의 "없으면 OpenCV로 직접 구현"이 정확한 현실임을 확정(192차)
8.8.3.1TurtleBot3+nav2_loopback_sim으로 실제 Nav2 스택 기동·내비게이션 목표 실행 — 진단 명령(ros2 action list | grep navigate 등)은 문서 그대로 동작. 헤드리스 기동 시 /initialpose(RViz2 "2D Pose Estimate"와 동일 토픽)가 없으면 map→odom tf가 영원히 안 나와 부트업이 재시도 없이 영구 실패함을 소스로 규명(런치와 동시에 5 Hz 반복 발행으로 해결). 임의 초기 위치(0,0)로는 플래너가 계속 실패해 15회 복구 끝에 ABORTED(흐름도의 "플래너 실패" 갈래 정확히 재현) — 지도의 실제 기본 스폰 위치(-2.00,-0.50)로 바꾸니 계획 성공, 5회 컨트롤러 복구 끝에 55.1초 만에 SUCCEEDED. ros2 control list_controllers는 패키지 미설치+/controller_manager 부재로 표준 시뮬레이션 TurtleBot3엔 안 맞음을 확인(191차). 후속(196차): 실제 Gazebo 물리+실제 AMCL 경로(tb3_simulation_launch.py)로 재현하니 같은 /initialpose 경고는 출력되지만 부트업이 영구 실패하지 않고 35초 이상 재시도 후 정상 복구("Aborting bringup" 로그 전체에 없음) — "~10초 만에 영구 실패"는 nav2_loopback_sim 고유 특성이었음을 확인. 내비게이션 결과(5회 복구·SUCCEEDED)는 191차와 거의 동일
8.8.4RViz2 디스플레이 표의 /particle_cloud(AMCL) 항목이 nav2_loopback_sim 배치엔 적용 안 됨을 확인 — 이 경량 시뮬레이터가 측위 자체를 대체해 /particle_cloud·/amcl_pose가 전혀 존재하지 않음(191차)
8.4.6팽창 비용 공식을 실측 코스트맵과 대조 — 0.05~0.55m 구간은 공식과 2% 이내로 일치, 단 "비용 0" 경계는 inflation_radius가 아니라 격자 한 칸 바깥. cost_scaling_factor는 런타임 재구성 불가(재기동 필요)(147차)
8.4.3InflationLayer가 마지막이 아니면 팽창이 전혀 적용되지 않는다 — 순서를 뒤집어 실측 확인(147차)
8.4.5obstacle_max_range가 표시를 정확히 게이팅함을 확인(원형 마킹 실측, 148차). 지우기(raytrace 클리어링) 미재현의 원인을 소스 계측으로 규명 — range_max 정확 일치 시 광선 없음, 특정 각도의 브레젠험 이산화 결함, 재사용 덤프 도구의 TRANSIENT_LOCAL 한 번 구독 결함(149차). 16개 각도 스윕으로 "정면 근처만 위험" 프레이밍을 정정 — 고착 각도는 0°·17°·20°·30°·40°(31%)로 산발적(150차)
8.7.3nav2_collision_monitor 실제 기동 — PolygonStop이 cmd_vel_out을 정확히 0으로, PolygonSlow가 slowdown_ratio(0.3) 그대로 감속(0.5→0.15), source_timeout이 실제로 정지 유발. 소스로 stop_pub_timeout 경과 후 재발행 중단·collision_monitor_state가 TRANSIENT_LOCAL이 아님을 확정(150차)
8.7.4nav2_velocity_smoother 실제 기동 — 가속 램프 실측(max_accel 이론값과 근사), 입력 끊김 후 velocity_timeout만큼 마지막 값 유지 후 max_decel 램프로 매끈하게 정지(즉시 절단 아님, 본문에 없던 세부)(150차)
8.7.5KeepoutFilter(20×20셀 마스크→LETHAL_OBSTACLE 400/400 정확 일치)·SpeedFilter(이 절 예제 multiplier:-1.0이 NO_SPEED_LIMIT sentinel과 충돌해 절대 미발행 — 실측 함정, -0.5로는 정상 발행) 실제 기동 확인. 네임스페이스 붙은 코스트맵의 상대 토픽 resolve 함정, LayeredCostmap::updateMap()이 CostmapFilter::updateBounds()의 빈 갱신 범위 때문에 다른 레이어(예: ObstacleLayer) 없이는 필터를 전혀 호출하지 않는 구조를 소스 계측으로 규명(155차). 161차 — KeepoutFilter+SpeedFilter 동시 스택을 시험하다 155차의 단일 KeepoutFilter 결과 자체가 재현되지 않음을 발견 — 다섯 가지 독립 재현 시도(자체 구성·155차 원본 그대로·git 소스 재빌드·바이트 단위 재생·재생+ObstacleLayer)가 전부 실패, 155차 원시 데이터는 재확인으로 사실임을 확인했으나 성공/실패를 가르는 요인은 규명 못함(161차). 162차 — 155차 원본 구성을 15회 반복 실행해 빈도를 측정 — 15/15 실패(161차의 독립 5회 실패와 합쳐 20/20). 새 진단 단서: "Resizing costmap..."도 "no map received" 경고도 15회 전부 나타나지 않는 완전히 조용한 실패로, static_layer의 지도 콜백만 흔적 없이 호출되지 않음(162차)
4.8.4·4.8.7QEMU 에뮬레이션+Docker buildx로 rclcpp 노드를 실제 arm64로 빌드(58.7초, 스킵 안 됨) — ELF 헤더로 EM_AARCH64 확인, 에뮬레이션에서 실제 기동. 다중 플랫폼 --load 불가 함정에 반례(containerd 이미지 스토어 환경에서는 성공)(151차)
4.8.3크로스 툴체인 sysroot 구성에서 실제 장애물 2건 확인(ports.ubuntu.com 필요, ROS 2 Python 보조 패키지의 멀티아크 메타데이터 문제) — rosidl 코드 생성 문제 자체까지는 미도달, 부분 검증(151차)
4.9.4·4.9.7bloom-generate rosdebian으로 패키지 이름·rosdep 의존 변환을 실측 확인, fakeroot debian/rules binary+apt install로 실제 설치·실행까지 전 과정 확인(151차)
4.8.2·4.8.3rosidl 코드 생성 크로스 컴파일을 실제로 끝까지 실행해 "①이 핵심 문제다" 서술을 정정 — 생성기 진입점이 컴파일 실행 파일이 아니라 파이썬 스크립트라 별도 호스트 빌드 없이 첫 시도에 성공. Multi-Arch: no 우회는 apt-get download(289개 전부 성공)로 단순 해결 — 실제 마찰은 sysroot 조립뿐이었음(152차)
4.10.3·4.10.4이 장의 ★ 주장(colcon test-result --verbose 없으면 실패를 놓침)을 gtest·린트 두 시나리오로 재현 — colcon test 자체는 실패해도 종료 코드 0, --verbose만 1. ament_copyright·ament_cpplint·ament_uncrustify 실제 위반 검출도 확인(153차)
4.10.13.1커버리지 측정을 실제로 끝까지 실행 — lcov 2.0이 GCC 13 계측에 대해 "mismatched end line"으로 실패, --lcov-args --ignore-errors mismatch로 우회 확인. 테스트한/안 한 함수가 coverage.info에서 FNDA:1/FNDA:0으로 정확히 구분됨(153차)
4.10.6새니타이저 3종을 실제 버그로 검증 — ASAN 정확 일치. TSAN은 컨테이너 커널 ASLR 설정 때문에 처음엔 전혀 안 뜸(vm.mmap_rnd_bits 조정으로 해결), 이후 경쟁 정확히 검출. UBSAN은 -fno-sanitize-recover 없이는 진단만 하고 테스트가 통과 처리됨(정정)(154차)
4.10.5ROS_DOMAIN_ID 격리를 두 동시 프로세스로 직접 확인 — 다른 도메인 발신자의 메시지가 전혀 안 섞임(31/31 같은 도메인)(154차). ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST도 서로 다른 네트워크 네임스페이스(컨테이너 둘)로 확인 — 기본값은 다른 호스트끼리도 발견(18/18), LOCALHOST 설정 시 다른 호스트는 완전 차단(0/0)되지만 같은 호스트는 정상(29개)(156차)
4.10.8ccache 적중률 실측 — 냉간 0%(3.357s) → build/install 삭제 후 캐시만 유지 시 100%(1.209s, 2.8배). 빌드 디렉터리 캐시 함정을 "동작하는 유령 실행 파일"로 재현 — 소스·CMakeLists.txt에서 제거한 노드가 build/install 미삭제 재빌드 시 설치 디렉터리에 그대로 남아 정상 기동함을 확인(156차)

빌드·도구·관측성

절확정한 것
15.2.2REP 2000을 git clone으로 직접 재확인 — Kilted Kaiju 다음 LTS는 여전히 미등재(REP 저장소 HEAD 2025-10-24 이후 갱신 없음). 대신 Nav2 저장소에서 차기 배포판 실명 "lyrical"을 네 갈래 독립 증거(CI 매트릭스·전용 의존성 파일·브랜치 트리거·실제 git 브랜치)로 발견 — 순차 명명 규칙과도 일치. 부수로 ros2/rclcpp엔 GitHub Actions 자체가 없고 ROS 빌드팜을 씀을 확인(194차)
15.5.3·15.5.6ros2_documentation을 git clone으로 직접 대조 — 거버넌스 구조(OSRA·TGC·ROS PMC 구성, 화요일 17:00 UTC 회의)와 Contributing 네 원칙은 원문과 한 글자도 안 틀림. ros2 doctor(+--report)는 실재하고 네트워크·패키지 버전 진단을 실제로 냄. 다만 책이 쓰는 포럼 주소 discourse.ros.org는 저장소 전체에서 2020~2023년 릴리스 노트 3곳에만 남은 옛 도메인 — 현재는 discourse.openrobotics.org(27회 등장)로 이전됨을 확인·정정(195차)
4.5.15colcon-common-extensions가 끌어오는 확장 21종 — mixin은 없다
1.11.12억제된 로그는 0.05 µs·할당 0, 출력은 평균 7.5·최대 188 µs. {time}은 벽시계(시뮬 시간을 안 따른다). rclcpp엔 is_enabled_for가 없다
14.4.11파라미터 병합은 딥 머지(중첩 부분 덮어쓰기 동작). 그러나 지정자의 구체성이 순서에 우선한다 — 나중 /**:도, 명령줄 -p도 /노드이름:을 못 덮는다
14.5.1214.5.11의 회귀 단언을 colcon test로 실제 실행 — 정상 통과, 위반 시 의도한 이유로 실패. 값이 앞선 실측과 일치
11.2.13rosbag2 기본 저장소는 mcap. convert·reindex는 실제로 동작
11.3.3--snapshot-mode Jazzy에서 실재·동작 — 트리거 전 0바이트, 서비스 호출 즉시 데이터 기록. 트리거 없이 종료하면 bag이 완전히 빈다(퍼블리셔는 실발행 중이었는데도 Messages: 0, 3회 재현·163차 2회+164차 1회) — "종료 시 자동 저장"은 틀렸다(163·164차)
11.3.4TRANSIENT_LOCAL 지각 구독자 함정 정교화 — 트리거는 "재생이 구독자보다 먼저 시작"이 아니라 재생 프로세스 자체의 종료다(프로세스 생존 중엔 5초 늦은 구독도 수신). --rate·--start-offset+--playback-duration 정밀도 실측(163차)
11.3.5--clock/use_sim_time 3자 관계 실측 확인 — /clock 384개(sim_time 구간이 bag 녹화 구간과 일치), 미사용 시 0개(163차). --loop의 시간 역행도 정량 확인 — 9초 재생에 역방향 점프 정확히 2회(3회 반복 경계와 일치), /counter 값도 동일 시퀀스로 반복 재생됨을 확인(164차)
11.3.6--topics 필터링 실측 — 두 토픽을 동시 녹화(각 23개)해 두고 --topics /counter로만 재생하니 /counter 23개 전부, /other_counter 0개(164차)
11.6.11컨테이너에서도 UST 트레이싱은 된다. 계측점 35종이 이 책의 계층 그림과 일치
14.3.12substitution·condition 전수. /*는 루트 직속만, /**는 모든 깊이. 93차 정정(14.3.12.4): 기본값 없는 LaunchConfiguration은 빈 문자열이 되는 것이 아니라 참조하는 순간 즉시 예외로 종료된다
4.6.11rosdep 소스 목록은 작은 번호가 우선한다(먼저 선언한 쪽이 우선한다) — 책이 거꾸로 적고 있었다. 사내 목록을 30-으로 두면 조용히 무시된다
0.5.3.1"오버레이가 우선한다"에 안전판이 있다 — 표준 절차(언더레이 source 후 오버레이 빌드)로 만들었다면, 이후 source 순서를 거꾸로 해도 오버레이가 계속 우선한다(빌드 시점에 체인이 굽혀지고 dedup이 순서를 안 바꾼다). 실제 위험은 언더레이 없이 빌드하는 것이다
A.1.15책 전체의 명령·타입 이름 전수 대조 — 명령 조합 81 · 롱플래그 31 · 인터페이스 타입 53 전부 실재. --ros-args는 ros2 run --help에 없는 것이 정상(노드로 넘기는 인자)
0.6.4현대 CMake 타깃 방식(rclcpp::rclcpp+${std_msgs_TARGETS}) — Humble·Jazzy 둘 다 무경고 컴파일·정상 실행, 배포판 분기 없음(143차)
0.6.11함정#1(resource/<패키지명> 누락)의 정확한 발동 조건 — 소스에 없으면 빌드가 즉시 실패, 이미 빌드된 install/에서 마커만 사라지면 ros2 run이 Package not found(143차)
4.7.3vcstool 명령 전체(import·status·branch·custom·pull·export·export --exact) 문서 그대로 동작 — --exact가 실제로 브랜치명을 40자 SHA-1로 교체(144차)
4.7.7ros2.repos URL 패턴이 Jazzy·Humble 둘 다에서 예측 가능(파일명 고정, 브랜치=배포판 이름)(144차)
4.1.4DESTINATION bin 함정 확인 — ros2 run은 못 찾지만 직접 경로 실행은 된다(145차)
4.1.10·4.2.10ament_package() 위치 함정 정정 — install(TARGETS...)는 뒤에 둬도 안 무시됨(정상 동작), ament_export_targets·ament_export_dependencies는 조용히 무시가 아니라 명확한 CMake 오류로 즉시 실패(Humble·Jazzy 동일)(145차)
4.3.5--symlink-install 표 8행 중 7행 확인, "새 Python 파일 추가는 재빌드 필요"는 반증 — ament_python의 develop 모드(.egg-link) 설치가 소스 디렉터리를 실시간 스캔해 재빌드 없이 즉시 임포트됨(145차)

보안

절확정한 것
13.2.12키스토어 명령 여덟 개(하나는 폐기 예정). 기본 권한 파일은 "전부 허용"
13.3.11기본 거버넌스 실물. 보호 수준 다섯 단계(한 항목만 세 단계)
13.5.2SROS2 보안 켬의 "시작 시간" 비용 실측 — Humble은 off 평균 420ms 대 on 1149ms로 거의 안 겹치는 두 분포(Welch t≈30), Jazzy도 같은 방향(687→1020ms, t≈4.5)(142차). 대용량(1MB) 메시지의 정상 상태 비용도 실측 — CPU 시간 2~3배, 종단 지연 1.7~1.9배(143차)
13.1.2"노드 이름 중복 방지 없음" 확인 — ros2 node list가 경고는 내지만 차단은 안 한다(142차)
13.4.2ROS_AUTOMATIC_DISCOVERY_RANGE=OFF는 같은 호스트에서도 디스커버리를 완전히 막는다. LOCALHOST·SUBNET은 정상 통신(142차)

이 색인을 쓰는 법

  • 주제로 들어와 절 번호를 얻은 뒤 본문으로 간다. 각 실측 절에는 확인하지 못한 것이 함께 적혀 있으므로, 인용 전에 그 부분을 읽으라.
  • 여기 없는 주제는 검증되지 않았다는 뜻이다. 133장 중 실측 절이 붙은 장은 일부이며, 장별 현황은 목차 3.1에 있다.
  • 값이 낡을 수 있다. 버전 의존 항목(패키지 버전·기본값·API)은 패치 릴리스로 바뀐다. 11.7.13의 도구로 자기 환경에서 다시 측정하는 것이 이 색인을 쓰는 가장 좋은 방법이다.

표기 규약

  • 패키지·명령·타입·환경변수는 원어 그대로 — rclcpp, ros2 topic echo, RMW_IMPLEMENTATION. 한국어 문장 안에서도 코드체를 유지한다.
  • 인터페이스는 패키지/msg/타입 형태로 적는다 — sensor_msgs/msg/PointCloud2, geometry_msgs/msg/Twist. 축약형(sensor_msgs/PointCloud2)은 CLI 인자에서만 쓴다.
  • 계층을 가리킬 때 — 사용자 코드 → rclcpp·rclpy(클라이언트 라이브러리) → rcl(공통 C 계층) → rmw(추상 인터페이스) → rmw_*_cpp(구현) → DDS·RTPS 순으로 부른다(2.1).
  • 좌표·단위 — REP 103(SI·오른손 z-up), REP 105(map·odom·base_link), REP 145(IMU)를 따른다(7.1).
  • (확인 필요) — 버전·환경에 따라 달라질 수 있어 독자가 자신의 환경에서 확인해야 하는 값·동작.
  • "견해" — 저자의 판단이며 사실이 아닌 문장. 근거를 함께 적는다. 라벨은 인용 블록의 > 견해: 로 통일한다.
  • — 실측으로 끝나는 절 — 이 환경에서 직접 재거나 실물을 연 결과. 본문에 실행 확인이라는 낱말을 반드시 쓰고(도구가 그것으로 검증 여부를 센다 — 86차), 측정 환경(배포판·rmw·컨테이너·해당되면 패키지 버전)을 절 첫머리에 밝힌다. 환경이 없으면 독자가 그 수치를 자기 시스템에 옮길 수 있는지 판단할 수 없다(87차).