AEGLOSLAB ROS 2 Jazzy 대전

제15부 이행·확장

15.1 Humble→Jazzy 마이그레이션 실무

15.1.1 이 장에서 배우는 것 / 선수 지식

배포판 이행은 미룰수록 비용이 커지는 작업이다. Humble의 지원이 끝나면 보안 패치가 멈추고(13.4.6), 새 패키지는 Jazzy 기준으로 나오고, 커뮤니티의 도움을 받기도 어려워진다. 그런데 이행 자체는 위험하고 시간이 든다 — 그래서 미루게 되고, 미루면 격차가 커져 더 어려워진다.

이 장은 그 작업의 절차와 판단을 다룬다. 결론을 먼저 말하면 — 한 번에 옮기지 말고, 되돌릴 수 있게 하고, 무엇이 바뀌는지 목록으로 만들고 시작하라.

배우는 것:

  • 이행 판단 — 언제 옮기는가.
  • 변경 범위 — 무엇이 바뀌는가.
  • 절차 — 단계적 이행.
  • 흔한 문제 — 실제로 걸리는 것들.
  • 병행 운영 — 두 배포판을 동시에.
  • 검증 — 무엇을 확인하는가.
  • 되돌리기 — 실패했을 때.

선수 지식: 0.3(배포판·Tier), 14.7(의존성 위생), 4.5(빌드), 14.5·14.6(시험), 14.8(배포).

정직: Humble과 Jazzy 사이의 구체적 변경 목록은 각 패키지의 릴리스 노트·마이그레이션 문서가 정본이다. 이 책은 이행 방법론과 확인할 축을 다루며 개별 API 변경을 망라하지 않는다 — 전수 목록은 빠르게 낡기 때문이다. 다만 "구체적으로 어떤 종류의 변경이 오는가"에 대한 규모를 가늠하게 하기 위해, 15.1.3·15.1.5는 공식 Jazzy 릴리스 노트(ros2_documentation 저장소의 Release-Jazzy-Jalisco.rst, 2026년 확인)에 실제로 적힌 변경 몇 건을 표본으로 인용한다 — "이런 성격의 변경이 이런 형태로 온다"는 개략적 이해가 목적이며, 이 표본이 Humble→Jazzy의 전체 변경 목록은 아니다(확인 필요: 전체 목록). Ubuntu 22.04→24.04 전환에 따른 시스템 라이브러리 변경도 함께 확인해야 한다. 15.1.3의 API diff 다섯 건과 15.1.5의 --exclude-regex 사례는 104차 갭 점검(2026-08-19)이 ros:jazzy-ros-base 컨테이너에서 실제로 컴파일·실행해 재현 검증했다(목차 3) — 넷은 릴리스 노트대로 재현됐고, 하나(비-const 구독 콜백 제거)는 릴리스 노트와 실물 헤더가 어긋남을 발견해 정정했다(15.1.3 참고). 이 장의 다른 코드·명령(15.1.4 이하의 절차·병행 운영 예시 등)은 이번에도 재현 실행하지 않았다 — 공식 문서의 서술을 옮긴 것일 뿐이다.

15.1.2 이행 판단

언제 옮길 것인가.

충분 아니오 임박 지남 예 이행 검토 EOL까지 여유? 계획적 이행 기능 개발과 병행 ★ 우선순위 상향 보안 위험 · 즉시 필요 패키지가 새 배포판에 있나? 대기 · 직접 이행 기여 진행
요인고려
EOL 시점Humble의 지원 종료 — 2027년 5월(REP 2000 기준. Jazzy는 2029년 5월까지)
의존 패키지 준비우리가 쓰는 것들이 Jazzy에 있는가
OS 전환Ubuntu 22.04 → 24.04
하드웨어 드라이버벤더 지원 여부
팀 여력다른 일정과의 경합
운영 로봇 대수배포 위험(14.9)
새 기능 필요Jazzy에만 있는 것

견해: "의존 패키지가 준비되었는가"가 가장 실질적인 게이트다. 핵심 드라이버나 프레임워크가 아직 Jazzy를 지원하지 않으면 이행이 막힌다. 이행 계획의 첫 작업은 의존성 목록(14.7.5)을 놓고 각각의 Jazzy 지원을 확인하는 것이다.

견해: EOL이 임박해서 시작하면 선택지가 없다. 문제가 생겨도 되돌릴 시간이 없고, 급하게 하다 품질을 잃는다. 여유가 있을 때 시작해 천천히 진행하는 것이 결과적으로 비용이 낮다.

정직: 각 배포판의 정확한 EOL 날짜는 REP 2000(ROS 2 Releases and Target Platforms)이 정본이다. 위 두 날짜는 REP 2000을 2026년에 직접 확인한 값이며, 정책은 바뀔 수 있으므로 계획을 세울 때는 반드시 원문을 다시 확인하라. REP 2000의 지원 기간 규칙 자체는 단순하다 — LTS는 Ubuntu LTS와 같은 5년, 비LTS는 1.5년이며, ROS 2 릴리스는 12개월 주기로 짝수 해엔 LTS(Ubuntu LTS 출시 한 달 뒤, 대개 5월), 홀수 해엔 비LTS가 나온다. 이 리듬을 알면 "다음 배포판이 언제 나오고 언제까지 살지"를 예측할 수 있다 — 15.2.2에서 이어서 다룬다.

15.1.3 변경 범위

어떤 축에서 변화가 오는지 알면 확인할 곳이 정해진다.

이행 시 변경 축 ① OS · 시스템 라이브러리 Ubuntu 24.04 ② 컴파일러 · 표준 C++ · Python 버전 ③ ROS 코어 API rclcpp · rclpy ④ 미들웨어 · 기본값 RMW · QoS ⑤ 도구 · 빌드 colcon · ament ⑥ 상위 스택 Nav2 · MoveIt · ros2_control ⑦ 폐기 · 제거 deprecated API
축확인 방법
① OS시스템 라이브러리 버전·패키지명 변경
② 컴파일러새 경고·엄격해진 검사
③ 코어 API마이그레이션 문서(확인 필요)
④ 기본값조용히 동작이 바뀌는 축
⑤ 도구빌드 옵션·CLI
⑥ 상위 스택각 프로젝트의 릴리스 노트
⑦ 폐기경고 → 오류로 승격

견해: ④(기본값 변경)가 가장 위험한 축이다. 빌드도 되고 실행도 되는데 동작이 다른 경우가 여기서 나온다. 기본 RMW, 기본 QoS, 디스커버리 동작, 로깅 설정 같은 것들이 바뀌면 증상이 미묘하고 진단이 어렵다. 릴리스 노트에서 "기본값 변경" 항목을 특히 주의 깊게 읽어야 한다.

견해: ⑥(상위 스택)이 작업량의 대부분을 차지하는 경우가 많다. 코어 API 변경은 문서화가 잘 되어 있지만, Nav2·MoveIt·ros2_control의 파라미터·플러그인 이름·동작 변화는 각 프로젝트를 개별적으로 확인해야 한다. 우리가 쓰는 상위 스택 목록을 만들고 각각의 마이그레이션 문서를 확인하는 것이 실무의 핵심 작업이다.

함정: ②(컴파일러)에서 기존 코드가 새 경고를 대량 출력할 수 있다. 특히 -Werror를 쓰고 있으면 빌드가 실패한다. 이행 초기에는 -Werror를 잠시 끄고 진행한 뒤, 정리 후 다시 켜는 것이 실용적이다 — 다만 끈 것을 되돌리는 것을 잊지 말아야 한다.

실제 사례로 개략 파악하기. 15.1.1이 밝혔듯 이 책은 개별 API 변경을 전수 나열하지 않지만, "④ 조용한 기본값 변경"과 "③ 코어 API 변경"이 실제로 어떤 형태로 오는지는 표본 하나 없이는 개략이 파악되지 않는다. 아래는 공식 Jazzy 릴리스 노트에 실제로 기록된 변경 다섯 건이다 — Humble부터 Jazzy까지(중간의 Iron 포함) 축적된 변경이며, 각각 15.1.3의 축 중 하나에 해당한다.

변경해당 축무엇이 바뀌었나
구독 콜백 시그니처 폐기분 제거 — 릴리스 노트와 실물이 어긋남(실행 확인)③ 코어 API공식 릴리스 노트는 void callback(std::shared_ptr<MessageT>) 형태(비-const)가 Humble에서 폐기 예고되고 Jazzy에서 완전히 제거됐다고 적는다. 그러나 실제로는 제거되지 않았다 — 아래 정직 문단 참고
tf2_* 헤더 확장자 정리(실행 확인)③ 코어 APItf2_kdl/tf2_kdl.h 등 확장자 없는 헤더가 Humble에서 폐기 예고되고 Jazzy에서 제거, .hpp 헤더로 대체 — .h는 "No such file or directory"로 컴파일 실패, .hpp는 성공함을 실제 컴파일로 확인
rclcpp/qos_event.hpp 제거(실행 확인)③ 코어 APIIron에서 rclcpp/event_handler.hpp로 이름이 바뀌며 폐기 예고, Jazzy에서 옛 헤더 완전 제거 — qos_event.hpp는 "No such file or directory", event_handler.hpp는 컴파일 성공을 실제로 확인
executor 콜백 순서 보장 상실 — 실행 확인(108차)④ 기본값·거동기본 Single/MultiThreaded executor 내부 구현이 static executor 방식으로 바뀌며 "같은 엔티티 안에서도 콜백 순서가 더는 일관되게 보장되지 않는다"(공식 노트 표현) — 컴파일도 되고 실행도 되는데 동작 순서가 달라질 수 있는 전형적 사례. 108차가 ros:jazzy-ros-base·ros:humble-ros-base 양쪽에서 직접 재현했다 — 한 노드에 같은 타입(구독) 엔티티 여러 개를 동시에 ready로 만들면, Jazzy는 등록 순서와 다른 순서로 콜백이 실행되고 그 순서 자체가 프로세스 실행마다 소수 확률로 바뀌는 반면, Humble은 25/25·15/15 예외 없이 등록 순서 그대로 실행된다 — 아래 정직 문단 참고
rclcpp::get_typesupport_handle 폐기(실행 확인)③ 코어 APIrclcpp::get_message_typesupport_handle로 대체 예고(Jazzy에서는 아직 경고 단계) — 헤더에 [[deprecated("Use \get_message_typesupport_handle\ instead")]]가 실제로 붙어 있고, 호출하면 정확히 그 문구로 컴파일 경고가 출력되면서도 컴파일은 통과함을 확인
// Humble 스타일 — 릴리스 노트는 "Jazzy에서 컴파일 오류"라고 적지만,
// 실제로는 `ros-jazzy-rclcpp` 28.1.21(2026-06-15 빌드)에서 경고 없이 컴파일된다(실행 확인, 아래 정직 문단)
void on_scan(std::shared_ptr<sensor_msgs::msg::LaserScan> msg);  // 비-const
sub_ = create_subscription<sensor_msgs::msg::LaserScan>(
  "scan", 10, std::bind(&Node::on_scan, this, std::placeholders::_1));

// 권장 형태 — 메시지가 const로 전달된다. 이쪽도 물론 컴파일된다(둘 다 지원)
void on_scan(std::shared_ptr<const sensor_msgs::msg::LaserScan> msg);
// Humble — 확장자 없는 tf2 헤더 (Jazzy에서 파일을 찾지 못해 컴파일 실패 — 실행 확인)
#include <tf2_geometry_msgs/tf2_geometry_msgs.h>
#include <tf2_kdl/tf2_kdl.h>

// Jazzy — .hpp로 교체 (컴파일 성공 — 실행 확인)
#include <tf2_geometry_msgs/tf2_geometry_msgs.hpp>
#include <tf2_kdl/tf2_kdl.hpp>

견해: 위 표에서 진단이 가장 어려운 것은 "executor 콜백 순서" 한 줄이다. 나머지 넷은 (실제로 컴파일 오류가 나는 것과 나지 않는 것을 가리지 않고 말하면) 정적으로 드러나지만, 이것은 빌드도 되고 실행도 되며 대부분의 경우 문제없이 지나간다 — 콜백 순서에 암묵적으로 의존한 코드(예: "이 타이머는 항상 이 구독보다 먼저 등록됐으니 먼저 실행되겠지" 같은 가정)에서만 드러난다. 15.2.5의 "미정의 동작에 의존하지 말라"가 정확히 이런 사례를 가리킨다.

정직: 104차 갭 점검(2026-08-19)이 위 다섯 건을 ros:jazzy-ros-base 컨테이너(ros-jazzy-rclcpp 28.1.21, 2026-06-15 빌드)에서 실제로 컴파일해 재현 검증했다. tf2_* 헤더 제거·qos_event.hpp 제거·get_typesupport_handle 폐기 경고 셋은 릴리스 노트 그대로 재현됐다. 그런데 첫 번째 항목(비-const 구독 콜백)은 릴리스 노트와 실물이 어긋났다 — 릴리스 노트(ros2_documentation jazzy 브랜치의 Release-Jazzy-Jalisco.rst)는 "In Jazzy, these subscription signatures have been removed"라고 명시하는데, 실제로는 -Wall -Wextra -Wdeprecated-declarations를 전부 켜도 경고 하나 없이 그대로 컴파일된다(멤버 함수를 std::bind로 묶은 경우·람다 양쪽 확인). 원인은 헤더에서 직접 확인된다 — rclcpp/detail/subscription_callback_type_helper.hpp에 std::function<void(std::shared_ptr<MessageT>)>(비-const)를 받아들이는 템플릿 특수화가 Jazzy 헤더에도 그대로 남아 있다. "완전히 제거됐다"는 문서 서술과 실제로 설치되는 패키지의 헤더가 어긋나는 사례다 — 105차 갭 점검(2026-08-19)이 ros2/rclcpp·ros2_documentation의 커밋·PR·이슈 이력을 GitHub API로 추적해 원인을 확정했다. 2021-08 PR #1713이 비-const 콜백에 [[deprecated]] 경고를 붙이는 set_deprecated() 우회 경로를 any_subscription_callback.hpp에 도입했다. Jazzy 릴리스 3개월 전인 2024-02-23, PR #2431("Remove the set_deprecated signatures in any_subscription_callback")이 바로 그 set_deprecated()와 그것을 호출하던 is_deprecated 분기를 삭제하고 callback_variant_ = static_cast<...>(callback);로 대체했다 — 삭제된 것은 경고를 내는 코드였지, 시그니처를 받아들이는 코드가 아니었다. 콜백 형태를 실제로 판별하는 detail::SubscriptionCallbackTypeHelper(별도 헤더 subscription_callback_type_helper.hpp)는 2021년 생성 이후 단 한 번도 바뀌지 않았고, 지금도 명시 특수화(const 버전들)에 안 걸리는 콜백은 1차 템플릿이 그대로 통과시켜 비-const 시그니처를 계속 받아들인다. 같은 날 병합된 문서 PR #4185(같은 작성자)가 PR #2431의 효과를 "시그니처가 제거됐다"로 요약했는데, PR 제목의 "Remove the set_deprecated signatures"를 "경고 코드 제거"가 아니라 "시그니처 자체 제거"로 오독한 것으로 보인다 — 효과는 정반대였다: 경고가 사라지고 시그니처는 그대로 남았다. 이 불일치는 이 책이 처음 찾은 것이 아니다 — ros2/rclcpp 이슈 #2972("Deprecated subscription callback signatures are still compiling", 2025-10-30)가 정확히 같은 증상을 독립적으로 보고했고, 그 이슈를 닫은 PR #2975(실제 static_assert 거부를 추가, 2025-12-26 병합)는 rolling 브랜치 대상이라 이미 배포된 LTS인 Jazzy에는 역이식되지 않는다 — 이 책이 검증에 쓴 rclcpp 28.1.21(2026-06-15 빌드, PR #2975 병합보다 6개월 뒤)이 지금도 경고 없이 컴파일되는 것과 정확히 들어맞는다. 정리하면 "제거됐다"는 릴리스 노트가 Jazzy 출시 3개월 전에 앞서 적은 착오였고, 실제 제거(정확히는 static_assert를 통한 컴파일 거부)는 Jazzy 릴리스로부터 19개월 뒤 rolling에만 들어갔으며 Jazzy에는 오지 않는다. 실무적 결론은 바뀌지 않는다 — 비-const 시그니처는 여전히 폐기 대상이므로 새 코드에서 쓰지 말아야 하지만, "Jazzy로 옮기면 컴파일이 깨진다"는 이 표의 원래 서술은 이 패키지 버전 기준으로는 사실이 아니다. executor 콜백 순서 한 줄은 104~107차에 걸쳐 재현하지 못한 채 (확인 필요: 직접 재현)로 남아 있었다 — 108차 갭 점검(2026-08-19)이 실제로 재현하고 소스 수준 원인까지 확정했다. Iron을 건너뛰고 Humble에서 바로 Jazzy로 가는 조직은 Iron에서만 폐기 예고되고 Jazzy에서 사라진 것들(qos_event.hpp 등)도 한꺼번에 만나게 된다는 점을 유의하라 — 중간 배포판을 건너뛰면 두 번의 변경이 한 번에 몰려온다(15.2.6의 "건너뛰기" 위험과 같은 이유).

정직: *108차 갭 점검이 "executor 콜백 순서" 한 줄을 실행으로 재현했다 — 다만 지금까지 1.8.13·2.3.14가 실측해 온 것(서로 다른 엔티티 종류 사이의 순서, 예: TIMER→SUB→SRV→CLIENT→WAITABLE)과는 다른 축이다. 릴리스 노트의 "같은 엔티티 안에서도(even within the same entity)"라는 표현은 같은 타입 엔티티 여러 개끼리의 순서*를 말하는데, 이것은 이 책이 이제까지 한 번도 측정하지 않은 조건이었다. ros:jazzy-ros-base(ros-jazzy-rclcpp 28.1.21)에 노드 하나·구독 N개(각각 다른 토픽, TRANSIENT_LOCAL로 미리 발행해 스핀 전에 전부 ready 상태로 만듦)를 등록하고 SingleThreadedExecutor::spin_some()을 한 번 불러 첫 콜백 실행 순서를 기록하는 방식으로, 프로세스를 새로 기동해 반복했다(비교를 위해 ros:humble-ros-base도 pull해 같은 코드로 나란히 측정했다).

조건배포판반복결과
N=6, 정방향 등록(0→5)Jazzy255,4,1,2,0,3이 24/25, 나머지 1회는 4,1,5,2,0,3(앞 세 자리만 다름) — 등록 순서와 무관하고, 실행 사이에도 완전히 고정은 아니다
N=6, 역방향 등록(5→0)Jazzy150,1,4,3,5,2이 14/15, 나머지 1회는 1,4,0,3,5,2
N=10, 정방향 등록Jazzy209,5,4,3,2,8,1,7,0,6이 18/20, 나머지 2회는 서로 다른 순서(9,6,8,4,3,7,2,5,1,0·7,5,4,8,3,2,9,1,0,6) — 엔티티 수가 늘수록 이탈 빈도도 늘었다(4%→10%)
N=6, 정·역방향Humble25+15=4040/40 전부 등록 순서 그대로(정방향은 0,1,2,3,4,5, 역방향은 5,4,3,2,1,0) — 이탈 0건

Humble은 등록 순서를 예외 없이 지켰고(40/40), Jazzy는 매 실행 "거의 같지만 완전히 같지는 않은" 순서를 냈다(56/60은 세 조건 각각의 최빈 순서, 4/60=6.7%는 그마저 벗어난 제3의 순서). 이 결과 자체가 릴리스 노트의 "더는 일관되게 보장되지 않는다"를 정확히 보여 준다 — Jazzy의 순서는 등록 순서도 아니고, 완전한 난수도 아니고, "대개 이렇지만 가끔 달라지는" 중간 상태다.

원인은 rclcpp 헤더에서 바로 확인된다. /opt/ros/jazzy/include/rclcpp/rclcpp/executors/executor_entities_collection.hpp의 EntityCollection은 public std::unordered_map<const EntityKeyType *, CollectionEntry<EntityValueType>>이다 — 구독·타이머·서비스·클라이언트·waitable 다섯 컬렉션 전부 rcl 핸들 원시 포인터를 키로 쓰는 해시맵이다(build_entities_collection()이 subscription->get_subscription_handle().get()을 키로 insert()). Executor::collect_entities()(executor.cpp)는 이 해시맵을 current_collection_.subscriptions.update(...)로 순회하며 wait_set_.add_subscription(...)을 호출하는데, 이 호출 순서가 그대로 rcl_wait_set의 배열 위치가 되고, ready_executables()가 그 배열을 앞에서부터 순회해 실행 큐를 만든다 — 즉 최종 콜백 순서는 포인터 값의 해시 버킷 순서이지 등록 순서가 아니다. 이 구조는 ros2/rclcpp PR #2142("make the default Single/Multithreaded executors work like the static single threaded executor")가 넣었다 — 매 스핀마다 rcl_wait_set 전체를 새로 만들던 것을 캐시된 엔티티 컬렉션으로 바꿔 재구성 비용을 줄이는 대신(2.2가 이미 다루는 wait set 재구성 비용 $O(N)$과 같은 문제의식), 그 캐시 컨테이너로 std::vector 대신 std::unordered_map을 골랐다 — 이 트레이드오프가 부수적으로 순서 보장을 깼다. ros2/rclcpp 이슈 #2532가 독립적으로 같은 증상(구독·서비스·타이머 콜백이 더는 등록 순서로 실행되지 않는다)을 보고했다. Humble에는 이 파일 자체가 없다(find /opt/ros/humble -iname '*entities_collection*'이 아무것도 반환하지 않음, 실행 확인) — 기본 executor가 AllocatorMemoryStrategy 기반의 다른(그리고 벡터에 가까운 순서 보존) 경로를 쓰기 때문에, Humble의 "등록 순서 = 실행 순서"가 40/40으로 완벽했던 것과 정확히 들어맞는다.

1.8.13·2.3.14가 이미 확정한 "서로 다른 엔티티 종류 사이의 순서"(예: 항상 TIMER가 먼저)와는 메커니즘이 다르다는 점을 분명히 한다 — 그것은 ready_executables()가 타이머·구독·서비스·클라이언트·waitable 다섯 컬렉션을 정해진 순서로 차례로 순회하는 것(코드에 하드코딩된 처리 순서)이었고, 이번에 확인한 것은 같은 컬렉션 안에서 여러 엔티티가 어느 자리에 놓이는가이다. 둘 다 궁극적으로 "실행 순서에 의존하지 말라"는 같은 결론으로 수렴하지만, 전자는 결정적(코드가 명시하는 종류 간 우선순위)이고 후자는 해시 함수가 정하는 사실상의 비결정성이라는 점에서 다르다.

확인하지 못한 것을 명시한다. ① 이번 실험은 구독으로만 했다 — 헤더의 EntityCollection 템플릿은 타이머·서비스·클라이언트·waitable 컬렉션에도 동일하게 적용되므로 소스 근거로는 같은 현상을 예상하지만, 실측은 구독뿐이다(확인 필요). ② MultiThreadedExecutor로도 측정하지 않았다 — 이 절 집필 당시엔 멀티스레드에서 또 다른 비결정성("실제 동시 실행 경합")이 겹친다고 봤지만, 119차가 그 실험 계열에 실제 동시 실행이 없었음을 확인했다(2.3.14) — 그래도 선택·재구성 쪽의 별개 비결정성이 겹칠 수 있다는 점에서 분리해서 다시 측정할 필요는 여전하다. ③ 60회 중 4회(6.7%) 나온 "제3의 순서"가 ASLR(주소 공간 배치 난수화) 때문인지 직접 확인하지 못했다 — setarch -R로 ASLR을 꺼 보려 했으나 이 도커 환경의 seccomp 프로파일이 personality() 시스템 콜을 막아(Operation not permitted) 시도가 막혔다. 115차가 docker run --privileged로 이 차단을 풀고 실제로 껐다 — 서비스(다른 실험 조건이지만 같은 해시 버킷 메커니즘)로 측정해 보니 ASLR을 꺼도 이탈률이 0%가 되지 않아(30.0%, 켬은 40.0%), ASLR이 유일한 원인은 아니라는 것이 이 절이 아닌 2.3.14에서 확정됐다. 116차가 그 잔여 이탈의 원인을 핸들 주소 직접 관찰로 좁혔다 — 서비스만 만드는 최소 시나리오는 ASLR을 꺼도 10/10회 완전히 결정적이었지만, 클라이언트 노드의 실제 디스커버리 매칭을 더하면 ASLR을 꺼도 40회 중 5회(12.5%)는 주소가(16바이트 단위로) 달라졌다 — 백그라운드 RMW 스레드와의 힙 할당 경합 타이밍으로 추정된다(견해). 구독의 6.7% 자체를 ASLR 끄고 재현한 것은 아니다. 117차가 ASLR 켬·끔 비교를 300회로 완결해 최종적으로 무관하다고 결론지었다($p=0.369$) — 같은 회차가 핸들 주소와 디스패치 순서를 동시에 재 완전한 1:1 상관을 확인했으므로, 6.7%의 정체는 (ASLR 여부와 무관한) 프로세스마다 달라지는 힙 주소·해시 버킷 배치로 사실상 설명된다고 봐도 된다(확인 필요: 이 절의 원 실험 자체로 직접 재현한 것은 아님). ④ std::hash<void*>가 정확히 어떤 비트를 어떻게 섞는지, 포인터 크기순(오름차순 등)과 상관이 있는지는 조사하지 않았다(단발 실험에서 오름차순 포인터 순서 0,1,2,3,4,5와 실제 FIRE_ORDER 5,2,1,3,0,4가 달랐던 것으로 보아 최소한 "포인터 값이 큰 순"처럼 단순하지는 않다).

읽기 전에: 아래 단락의 "실제 동시 실행"·"스레드 경합"이라는 표현은 문단 끝의 119차 정정 대상이다 — MultiThreadedExecutor 실험은 전부 단일 기본 MutuallyExclusive 그룹을 썼기 때문에 실제로는 한 번도 동시에 실행되지 않았다(동시성 비율 0.0000, 119~120차 실측). 이탈률 수치는 유효하다.

109차가 ①②를 처리했다. ExecutorEntitiesCollection이 timers·subscriptions·clients·services·guard_conditions·waitables 여섯 개의 독립된 EntityCollection 인스턴스를 갖는다는 것을 소스로 확인한 뒤, 같은 실험을 타이머·서비스·클라이언트·waitable로 확장했다 — 다섯 종류 전부 Jazzy에서는 등록 순서를 지키지 않고 Humble에서는 지켰지만, 이탈률은 타이머·waitable 0%부터 서비스 45%까지 종류마다 크게 갈렸다(서비스·클라이언트는 실제 DDS 왕복 타이밍에 의존해 실험 통제 수준이 구독·타이머·waitable보다 낮다는 것도 함께 밝힌다). MultiThreadedExecutor::spin()의 실제 동시 실행(구독, 스레드 2·4개)에서는 이탈률이 20~53%로 상승했는데, std::unordered_map이 없는 Humble도 20~30% 이탈했다 — 즉 MultiThreadedExecutor의 순서 비결정성은 이 절이 다루는 해시맵 문제와는 별개의 원인(스레드 경합)이다. 112차가 이 별개 원인을 통계로 확정하고 범위를 넓혔다 — 구독·스레드4의 Jazzy·Humble 이탈률(각 60회 표본, 45.0% 대 8.3%)을 두-비율 z-검정하니 $p=6\times10^{-6}$로 유의했고, MultiThreadedExecutor를 타이머·서비스·클라이언트·guard condition waitable로 확장하니 waitable은 Jazzy·Humble 양쪽에서 스레드 2개 이상이면 80회 전부 100% 유실돼(해시맵 유무와 무관한, 두 배포판이 공유하는 코드의 결함) 이 절의 해시맵 문제와 스레드 경합 문제가 서로 다른 축임이 더 뚜렷해졌다. 또한 미들웨어 리스너 콜백으로 도착 순서를 executor와 독립적으로 측정해 보니, 서비스는 도착이 완벽히 결정적인데도 디스패치는 80% 이탈해 109차가 분리하지 못했던 "해시 순서 대 DDS 잡음"이 순수 해시 순서 쪽으로 확정됐다. 113차가 이 통계 검정을 타이머·서비스로 확대하니 결과가 갈렸다 — 구독은 유의했지만(위) 타이머($p=0.65$)·서비스($p=0.22$, 정+역 합산도 $p=0.072$)는 각 60회 표본으로도 유의하지 않았다. 타이머의 스레드 수 비단조 패턴(112차, 스레드 4에서 0%)도 표본을 50회로 늘리자 사라졌다(전 구간 $p>0.29$) — 둘 다 소표본 우연이었다. Humble 서비스 콜백이 종종 두 번 반복 호출되는 112차의 관찰은 원인이 되는 rclcpp의 2단계 등록 패턴(Jazzy와 동일)까지는 소스로 확인했으나 타이밍이 실제 증상과 맞지 않아 배제됐고, 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$는 표본 부족(각 60회)이 만든 경계선이었을 뿐 실제로는 유의한 차이였다(90회 단독 재검정도 $p=1.57\times10^{-4}$로 독립 재현). 이중 호출 원인 추적은 다른 엔티티를 먼저 실행하는 조건까지 시도했지만 이번에도 재현되지 않아(정방향 30회·역방향 15회 무사) 누적 111회 실패로 남았다(확인 필요). 115차가 타이머도 같은 방식으로 재검정하니 서비스와 똑같이 뒤집혔다 — Jazzy 8.7% 대 Humble 26.7%(각 150회), $p=4.36\times10^{-5}$. 150회급까지 늘린 세 엔티티(구독·서비스·타이머) 전부 Jazzy·Humble 차이가 유의했지만 방향은 엔티티마다 다르다(구독은 Jazzy가, 서비스·타이머는 Humble이 더 나쁨) — 원인은 소스로 추적하지 않았다(확인 필요). 같은 회차가 --privileged 컨테이너로 108차부터 막혀 있던 ASLR을 실제로 껐다 — 껐을 때도 서비스 디스패치 이탈률이 30.0%로 0%가 되지 않아 "ASLR이 유일한 원인"이라는 가설을 반증했다(켬은 40.0%, $p=0.251$로 이 표본에서는 ASLR의 추가 기여도 확정하지 못했다). 116차가 Humble rclcpp 소스(AllocatorMemoryStrategy::collect_entities())를 읽어, 콜백 그룹이 하나일 때는 엔티티 수집이 해시·정렬 없이 순수 등록 순서 std::vector임을 확인했다 — Humble의 MultiThreadedExecutor 이탈은 이 절이 다루는 해시맵 문제와 무관하며, 96차가 Jazzy에서 확정한 것과 같은 스레드 스케줄링 문제다(다만 Humble이 더 불리한 정도의 이유는 소스만으로는 못 좁혔다). 같은 회차가 이중 호출을 누적 101회 재현 시도와 환경 포렌식 끝에 재현 불가로 명시적으로 종결했다(→ 126차 정정: 이 종결은 틀렸다 — 서버 사전 스핀 실험 도중 우연히 다시 나타났다, 아래 참고). 117차가 CHG-122 계열의 stress-ng 통제 실험을 실제로 실행해 스레드 경합 가설을 정량 확인했다 — CPU 부하를 코어 수(20)까지 높이면 Jazzy의 구독·서비스 이탈률이 극적으로 유의하게 뛰지만(구독 35.0%→72.5%, $p=0.00077$), 같은 조작에 Humble의 구독은 전혀 반응하지 않았다(4.0%→4.0%) — 서비스·타이머는 Humble이 더 불리하고 구독은 Jazzy가 CPU 경합에 더 민감하다는, 방향이 엇갈리는 두 종류의 배포판 비대칭이 이 절에 새로 쌓였다(원인은 소스로 추적하지 않음). 118차가 Jazzy·Humble rclcpp 소스를 나란히 읽어 이 비대칭의 실마리를 찾고, "재구성 비용" 가설은 반증했다 — MultiThreadedExecutor의 스레드 풀 구조 자체는 거의 동일하지만 "다음 실행 대상 선택" 방식이 다르다(Jazzy: 캐시된 스냅숏 순회, Humble: 벡터 순회+erase+별도 뮤텍스). stress-ng 임계값을 12·16워커로 메워 16~20워커 사이로 좁혔고, 같은 실험을 Humble 서비스·타이머로 확장하니 Jazzy와 정반대로 부하가 이탈률을 유의하게 낮췄다(서비스 44.0%→4.0%, $p=0.00093$). wait_for_work()에 실제 타임스탬프 계측을 넣어 측정해 보니 Jazzy의 호출당 평균 비용이 Humble보다 1.3~1.8배 높아, "Humble의 매 사이클 재구성이 더 비용이 크다"는 가설은 반증됐다 — Humble이 서비스·타이머에서 불리한 실제 이유는 여전히 열려 있다. 119차가 중대한 정정을 냈다 — execute_any_executable()에 직접 계측을 넣어 측정해 보니, 91~98차의 MultiThreadedExecutor 실험(gap109~118)은 실제 동시 실행을 단 한 번도 만들어 내지 못했다(동시성 비율 0.0000, Jazzy·Humble·전 엔티티 종류·부하 유무 무관). N개 엔티티가 명시적 그룹 지정 없이 한 노드에 등록돼 기본 MutuallyExclusive 그룹에 들어갔기 때문이다 — 이 그룹은 한 번에 콜백 하나만 허용하므로 스레드가 몇 개든 병렬 실행이 애초에 불가능했다. 측정된 이탈률 수치 자체는 유효하지만, "스레드 스케줄링 경쟁"으로 설명해 온 메커니즘은 이 테스트 계열에는 적용되지 않는다 — 선택 자체의 비결정성이 실제 원인이어야 한다. 120차가 이 선택 비결정성의 새 후보(재구성-대기 연쇄 — 바쁜 그룹에 막힌 스레드가 wait_mutex_를 쥔 채 블로킹되고 깨어나면 재구성된 목록을 받는다)를 직접 계측했다 — 락이 블로킹 대기 전체를 감싼다는 것은 480회 실측(겹침 비율 0.0000)으로 확정했고 Humble은 이 재구성이 항상(100%) 일어나는 반면 Jazzy는 조건부(45~74%)라는 배포판 차이도 확인했지만, 이 빈도가 이탈률을 결정하는 인과적 손잡이라는 것은 확정하지 못했다(엔티티 종류를 고정하면 상관이 거의 사라진다) — Humble이 서비스·타이머에서 불리한 실제 이유는 여전히 열려 있다. 121차가 세 갈래를 더 조사했다 — rcl wait.c를 읽어 구독·서비스가 완전히 같은 방식(rmw_wait() 후 널 검사)으로 처리됨을 확인해 rcl 계층 구조 차이 가설을 반증했고, Jazzy의 재구성을 완전히 억제해도 서비스 이탈률이 줄지 않아(오히려 소폭 증가, 비유의) 재구성 반복 자체는 이탈의 필요조건이 아님을 확인했으며, 계측을 구독으로 확장하다가 117차의 "Humble 구독은 CPU 부하에 무반응"이라는 결론이 이번 세션에서는 재현되지 않는다는 것(오히려 유의하게 감소, 순정 빌드로도 재현해 계측 부작용이 아님을 확인)을 발견했다 — 세션 간 재현성 문제로 명시하고 다음 회차로 이월한다. 122차가 그 재현성 문제를 직접 검증했다 — 36개 무관한 컨테이너가 함께 실행 중인 이 호스트의 배경 부하가 원인일 것이라는 가설을 /proc/loadavg 동시 기록(자연 변동 상관 $r=-0.0149$)과 통제된 저강도 stress-ng 스윕(0~20워커, 매끄러운 용량-반응 없음)으로 검증했지만 지지되지 않았다. rmw_fastrtps_shared_cpp까지 내려가도(네 계층째) 구독·서비스의 코드 경로 차이는 없었지만, 서비스가 요청+응답 DDS 엔티티 쌍을, 구독은 리더 하나만 갖는다는 구조적 비대칭을 새 후보로 찾았다. 121차의 "재구성 억제가 이탈을 늘린다"는 경향도 n=100에서는 사라졌다($p=0.88$). 123차가 이 "엔티티 두 배" 가설을 클라이언트 재분석과 직접 조작 둘 다로 검증했지만, 둘 다 가설에 불리했다 — 클라이언트도 엔티티 둘(요청 라이터+응답 리더)을 갖지만 이 책의 네 엔티티 종류 중 가장 안정적이었고, 구독의 로컬 퍼블리셔(라이터)를 별도 프로세스로 옮겨 제거해도 이탈률 감소가 통계적으로 유의하지 않았다(Jazzy 65.0%→57.5%, $p=0.49$; Humble 42.5%→32.5%, $p=0.36$) — "DDS 엔티티 개수"가 서비스·구독 비대칭의 주된 설명은 아닐 가능성이 크다. 124차가 다섯 계층째(rclcpp execute_service·execute_client)도 코드가 같음을 확인한 뒤, 112차의 도착-시각 계측 도구로 실제 도착 시각을 재 새 후보를 찾았다 — 클라이언트의 응답 도착 시간 폭이 서비스의 요청 도착 시간 폭보다 약 5배 넓다(중앙값 서비스 12~14µs 대 클라이언트 61~64µs, 두 배포판 일관) — 서버가 상시 스핀하며 순차 응답하는 클라이언트 테스트 구조가 원인으로 보인다. Humble 구독의 디스커버리 타이밍 가설도 직접 계측했지만(도착 시각 폭 대 이탈 상관 $r=0.07$) 기각됐다 — 오히려 ARRIVAL_ORDER(32.0% 최빈)가 FIRE_ORDER(62.0% 최빈)보다 불안정해, executor 디스패치가 어수선한 도착을 부분적으로 정돈하고 있다는 뜻밖의 사실이 드러났다. 엔티티 개수 조작 실험을 n=100으로 늘리니 Jazzy는 유의해졌지만(71.0%→54.0%, $p=0.013$) Humble은 방향이 뒤집혔다(34.7%→42.0%, $p=0.29$) — 클라이언트라는 반례가 여전히 있어 엔티티 개수는 전체 그림의 일부일 뿐이다. 125차가 이 도착 시간 폭 가설을 직접 조작해 반증했다 — 서비스 테스트의 요청 사이 지연을 0~200µs로 늘려도(클라이언트의 자연 도착 창보다 3배 넓게) 이탈률이 유의하게 바뀌지 않았고 Jazzy는 오히려 (비유의하게) 늘었다 — 103~104차의 상관관계는 인과관계가 아니었을 가능성이 크다. Humble의 "정돈" 메커니즘도 소스에서 정확히 찾았다 — AllocatorMemoryStrategy::remove_null_handles()가 매 rcl_wait() 이후 준비 안 된 항목을 걷어내 남은 벡터를 등록 순서 그대로 유지하고, get_next_subscription()은 항상 begin()부터 순회한다 — N개가 같은 배치에서 준비되면 도착 순서와 무관하게 항상 등록 순서로 나간다. "엔티티 개수"와 "도착 시간 폭"의 관계도 직접 측정했다 — 라이터가 있으면 도착 시간 폭이 90배 가까이 넓었지만(균일한 확장이 아니라 일부가 ~70ms 늦는 지연 샘플 패턴) 도착 시간 폭 자체는 이탈률을 바꾸지 않는다는 것을 방금 보였으므로, 둘은 같은 효과의 두 이름이 아니라 라이터 유무가 만드는 별개의 두 현상으로 보인다. 126차가 서버 사전 스핀을 직접 조작해 Humble의 이탈을 완전히(82.1%→0.0%, $p\approx 0$) 지웠다 — 이 실험 전체에서 가장 큰 단일 효과다(Jazzy는 무변화). 그러다 116차의 "이중 호출 재현 불가 종결"이 틀렸다는 것을 우연히 발견했다 — 사전 스핀 도중 이중 호출이 다시 나타났다(Humble 기준 22.0%→사전 스핀 8.0%, Jazzy는 반대로 0%→44.0%, 113차의 "Jazzy 무호출" 확정과도 배치). execute_service()가 execute_client()보다 4.7배 더 걸린다는 것도 실측했고(103차 견해가 확인됨), "지연 샘플" 패킷을 tcpdump로 캡처하니 재전송이 안 보여 네트워크 수준이 아님도 확인했다. 127차가 사전 스핀 메커니즘의 두 가설(캐스케이드 횟수, 스레드 지역성)을 실측으로 반증했다 — 총 호출 수·스레드 전환율 모두 두 조건에서 거의 같은데(28.0 대 27.8 호출, 97.0% 대 84.0% 전환율) 이탈률은 3.3% 대 73.3%로 극단적으로 다르다. Jazzy 이중 호출의 원래 후보 원인도 전제 자체가 틀렸다는 것을 확인했고(위 정정 참고), 새 후보 원인(캐시된 wait_result_ + 전진 전용 반복자의 타이밍 경합)를 소스에서 찾았지만 계측으로 확정하지 못했다. "지연 샘플" 패턴은 FastDDS 경고 로그로도 침묵을 확인해 배제 목록이 늘었다. 128차가 마침내 사전 스핀 메커니즘의 실제 차이를 사건 단위로 찾았다 — 재구성 총량(집계)은 두 조건이 거의 같지만, 마지막 도착부터 마지막 디스패치까지의 구간에 재구성이 몇 번 몰리는지를 사건 단위 타임스탬프로 측정하니 사전 스핀 7~11회 대 기준 23~28회로 분포가 전혀 겹치지 않았다($t=49.19$) — 기준 조건은 스핀 시작 순간 스레드 4개가 이미 준비된 데이터를 두고 뮤텍스를 경합하며 짧은 창에 몰아치는 반면, 사전 스핀은 그 다툼을 400ms에 걸쳐 미리 분산해 소화한다(조건 내부에서는 이 개수가 개별 이탈 여부를 예측하지 못해 인과 경로는 못 닫았다)(→ 130차 정정: "400ms에 걸쳐 분산"은 부정확했다 — 실제로는 시작 직후 ~935µs 안에 몰아치고 그 뒤론 한 번의 긴 블록이었다, 아래 참고). Jazzy 이중 호출의 새 후보 원인(wait_result_ 경합 창)를 계측하려 세 가지 도구로 총 240회를 사전 스핀 조건으로 실행했지만 이번 세션엔 단 한 번도 재현되지 않았다 — 127차의 44.0%(22/50)와 대비되는, 113·116차와 같은 종류의 세션 의존성이다. "지연 샘플" 패턴은 gdb 반복 attach(1회 평균 ~295ms로, 현상(~70ms)보다 프로브 자체가 더 크다는 것을 확인, 이 스케일엔 부적합)를 버리고 /proc/<pid>/task/<tid>/{wchan,stack} 저오버헤드 폴링으로 세 번째 "배제"(지속적 CPU 활동 없음)를 확인했다. 129차가 세 항목 모두에서 실질적 진전을 냈다. 재구성-몰림의 정확한 경로를 찾았다 — Humble의 wait_for_work()·get_next_ready_executable_from_map()이 재구성·필터링·선택을 서로 다른 세 번의 별개 mutex_ 획득으로 처리하고, 특히 rcl_wait()가 락 밖(무잠금)에서 블로킹되는 동안 다른 스레드가 같은 공유 wait_set_을 자유롭게 다시 짓밟을 수 있다는 것을 소스로 확인했다. 사건 단위 계측(재구성 시작/끝·rcl_wait 반환·필터링·선택·실행 시작/끝)으로 측정하니 이탈 실행 22/22 전부에서 등록 순서가 처음 깨지는 순간에 다른 스레드의 재구성 또는 실행 창이 겹쳐 있었다 — 128차의 "재구성 몰림" 관찰에 마침내 인과 메커니즘이 확인됐다. Jazzy 이중 호출은 86차(CHG-131)식 결정론적 결함 주입(콜백 그룹 busy 표시와 실제 take 사이의 창을 최대 800배까지 인위적으로 넓힘)으로도 총 140회 전부 재현되지 않아, 127차가 제시한 구체적 경합 창 가설에 불리한 증거가 쌓였다(다만 이 세션 자체가 무주입 조건에서도 안 됐던 세션 의존성이 있어 완전한 반증으로 단정하지는 않는다). "지연 샘플" 패턴은 strace -T로 futex 호출 인자를 직접 봐 정체가 드러났다 — 어떤 스레드가 절대 시각 데드라인이 있는 타임드 웨이트(FUTEX_WAIT_BITSET_PRIVATE)로 블로킹된 뒤 다른 어떤 스레드의 웨이크업 신호도 못 받고 자기 데드라인(~62~64ms, 관측된 갭 ~69ms와 거의 일치)이 만료될 때까지 단순히 기다렸다가 ETIMEDOUT으로 깨어난다 — 126~128차의 세 배제 판정과 정확히 들어맞는, 웨이크업 경로가 연결 안 된 타임드 폴백이었다. 130차는 정정 하나와 진전 둘을 냈다. 129차 원시 로그를 재분석해 사전 스핀의 idle 구간이 "400ms 분산"이 아니라 "시작 직후 ~935µs 몰아치기 + 이후 399ms 단일 rcl_wait() 블록"이었음을 발견했지만(위 정정), 이를 반영한 "첫 PICK 시점 스레드 위상 폭" 지표 자체는 사전 스핀·기준 간 유의한 차이를 못 냈다(Welch $t=-1.93$, 방향도 가설과 안 맞았다). Jazzy 이중 호출은 busy 플래그가 선택 시점부터 처리 완료까지 전 구간을 감싼다는 소스 구조를 확인해 129차의 지연 주입이 왜 효과가 없었는지 설명했고, 더 이른 지점(busy 세우기 직전)으로 옮겨도(누적 220회) 재현이 안 돼 127차 가설이 사실상 배제됐다 — 새 후보는 rmw의 unread-count 플래그 지연이다. "지연 샘플" futex는 strace -k로 완전히 심볼화됐다 — FastDDS 라이터 쪽 흐름 제어 스레드 FlowControllerImpl<FlowControllerSyncPublishMode, FlowControllerFifoSchedule>::run()이 ~152.6ms 블로킹돼 있다가 깨어난 직후 지연 샘플이 도착했다(ResourceEvent::event_service()도 1.8ms 뒤 함께 깨어났다) — 주소만 있던 129차와 달리 정확한 클래스·함수명을 확보했다(→ 131차 정정: "흐름 제어·페이싱"이라는 표현은 오해의 소지가 있다 — 실제로는 고정 주기가 없는 순수 무기한 대기다, 아래 참고). 131차는 락 A 충돌 지표를 재설계해 마침내 명확한 신호를 얻었다 — "구간이 실제로 겹치는 횟수"(뮤텍스로 원천 불가능)를 "재구성-끝부터 선택까지 다른 스레드의 재구성-시작이 몇 번 개입하는가"로 바꾸니 사전 스핀 30회 전부 0회 대 기준 평균 4.00회(최대 18회)로 완벽하게 갈렸다(Welch $t=4.708$) — 129차 메커니즘을 직접 지표로 확인했다(다만 락 A 개입이 유일한 이탈 경로는 아니었다, 개입 0인데도 이탈한 실행이 있었다). Jazzy 이중 호출은 rmw_fastrtps_shared_cpp 소스로 unread-count가 캐시가 아니라 request_reader_->get_unread_count(true)의 실시간 조회임을 확인해 129차 가설이 이 형태로는 성립하지 않는다는 것을 알았고, 스레드 16개 스트레스 테스트(N=6, 사전 스핀 50회)로도 재현하지 못해(누적 270회 0건) 남은 창이 매우 좁거나 특정 호스트 조건 의존일 가능성만 남았다(→ 132차 정정: "실시간 조회일 뿐"이라는 결론은 rmw 호출부만 본 것이었다 — 그 답 자체는 RTPSReader::total_unread_라는 유지되는 카운터였다, 아래 참고). "지연 샘플"은 FlowControllerImpl.hpp 소스를 직접 읽어 "~150ms 페이싱 주기"라는 해석이 틀렸다는 것을 확인했다 — FlowControllerAsyncPublishMode::wait()는 cv.wait(lock)(타임아웃 없음)이고, 깨우는 지점 enqueue_new_sample_impl()의 주석이 "Sync delivery failed. Try to store for asynchronous delivery."라고 정확히 설명한다 — 관측된 ~62~150ms는 고정 상수가 아니라 이 스레드가 마지막 활동 이후 우연히 유휴했던 시간이었다(→ 135차 정정: 이 함수에 실제로 카운터를 심으니 40회(지연 샘플 17건 포함) 전부 0번 불렸다 — "동기 실패 → 이 함수가 깨움"이라는 인과관계 자체가 우리 테스트에서는 성립하지 않았다, 아래 참고). 132차는 세 항목 모두에서 소스를 더 깊이 조사했다. 104차의 "배치 분할"을 129차 원시 로그로 재분석하니 사전 스핀(5.93/6)·기준(5.17/6) 둘 다 N에 가까워 독립 변수가 아니라 락 A 현상의 증상이었음이 드러났고(오히려 무이탈 조건인 사전 스핀 쪽이 더 높았다), 락 A 개입=0인 기준 실행의 64%가 여전히 이탈해 잔차가 남았다. Jazzy unread-count는 rmw 계층을 넘어 RTPSReader까지 내려가니 total_unread_라는 유지되는 카운터였고(131차 정정), 리스너 경로(get_last_notified() 기준)와 실제 take 경로(change_read_by_user())가 서로 다른 기준으로 같은 카운터를 독립적으로 갱신하는 구조적 경합 여지를 찾았다(다만 정확한 어긋남 시나리오는 확정 못함)(→ 133차 정정: 직접 읽으니 "드문 경합"이 아니라 "매 알림마다 결정론적으로 미리 당겨지는" 설계였다 — 드문 이중 호출과 안 맞아 원인일 가능성이 오히려 낮아졌다, 아래 참고). "지연 샘플"의 동기 전달 실패는 StatefulReader::processDataMsg()의 반환값으로 정체가 좁혀졌고 KeepLast(1) 관련 후보 분기(can_change_be_added_nts())까지 찾았지만, 기반 클래스엔 깊이 제한이 구현돼 있지 않아 실제 구현(DataReaderHistory)까지는 못 내려갔다. 133차는 세 항목 모두에서 한 걸음씩 더 좁혔다. 132차의 잔차(락 A 개입 없는 이탈 7건)를 개별 조사하니 전부 "등록 순서 안 어떤 연속 구간의 마지막 원소가 그 구간 맨 앞으로 이동하고 나머지는 정확히 원래 순서를 유지하는" 국소 창 회전이었다 — 무작위 뒤섞임이 하나도 없어 103·124차의 도착 미세 순서 어긋남과 같은 원인으로 보이는 독립된 새 경로를 확인했다. Jazzy의 get_last_notified()를 직접 읽으니 StatefulReader::NotifyChanges()가 "Update state before notifying"라는 주석 그대로 개별 변경을 알리기 전에 워터마크를 최신 변경까지 미리 당긴다 — 드문 경합이 아니라 매 알림마다 결정론적으로 일어나는 순서라, 드문 이중 호출(누적 270회 0건)과 안 맞아 이 카운터가 실제 원인일 가능성은 오히려 낮아졌다(**→ 134차 정정: rmw_wait.cpp를 다시 읽으니 rcl_wait()의 진짜 준비 판정 게이트는 애초에 total_unread_가 아니라 완전히 별개인 get_first_untaken_info()였다 — 아래 참고**). DataReaderHistory::can_change_be_added_nts()·received_change_keep_last()를 직접 읽으니 KeepLast(depth) 제한은 같은 인스턴스에 이미 depth개가 차 있을 때만 발동하는데, 이 책의 지연 샘플 테스트는 토픽마다 한 번만 발행해 그 분기 자체가 발동할 일이 없다 — KeepLast(1) 가설은 소스로 배제됐다. 134차는 133차의 세 갈래를 더 추적했다. "국소 창 회전"이 도착 미세 순서 어긋남과 같은 원인이라는 133차의 추정을, 미들웨어 리스너 콜백으로 도착 시각을 executor와 독립적으로 측정한 직접 계측으로 검증하니 이탈 실행 28/28(사전 스핀 1 + 기준 27) 전부 도착 순서가 완벽한 등록 순서였다 — 그 가설은 반증됐고 이탈은 순수 디스패치 쪽에서만 만들어진다는 것이 확정됐다. rmw_wait.cpp(__rmw_wait())를 다시 읽어, 서비스·구독·클라이언트가 대기 전·후 두 번 부르는 실제 준비 판정 함수가 DataReader::get_first_untaken_info()임을 확정했다 — 이 함수는 total_unread_를 전혀 조작하지 않고, received_change()가 채우고 실제 애플리케이션 take가 완전히 비웠을 때만 지우는 data_available_instances_라는 별개의 맵을 순회한다. 즉 129~133차가 판 total_unread_ 경로는 애초에 rcl_wait() 준비 판정과 무관하고, 리스너 콜백이 유저 콜백을 부를지만 결정하는 별개 경로였다. processDataMsg()의 후보 분기(can_change_be_added_nts·reserve_cache·get_payload·change_received)도 재빌드 없이 GDB로 직접 계측해(최적화 빌드의 "동일 코드 접기"로 소스 줄번호 중단점이 여러 분기를 뒤섞는 함정을 발견하고 우회했다) sub_arrival_colocated_gdb를 29회·1566회의 실제 동기 전달로 실행했는데 단 한 번도 실패하지 않았다 — 132·133차가 나열한 후보들에 대한 신뢰가 크게 낮아졌다(다만 이 세션엔 "지연 샘플" 자체가 재현되지 않아 GDB 오버헤드가 그 창을 억눌렀을 가능성이 있다). 135차가 세 갈래 모두를 실제로 추적했다. 134차가 찾은 게이트(get_first_untaken_info()/data_available_instances_)가 실제로 이중 준비 신호를 낼 수 있는지 add_to_instance()(수신 스레드)와 check_and_remove_instance()(take 스레드)의 타이밍을 결함 주입(erase 직전 지연 0~50ms)으로 어긋나게 해 검증했는데, 디스커버리 트래픽까지 포함한 9084건의 add/remove 사건에서 개입한 경우가 단 한 번도 없었다 — 두 함수가 같은 리더 뮤텍스로 완전히 직렬화돼 있어 구조적으로 불가능했다. GDB를 걷어내고 processDataMsg()에 원자적 카운터(재빌드, printf 없음)를 심어 재계측하니 "지연 샘플" 재현율이 134차의 0%에서 40%대(16/40·17/40)로 상승했다 — GDB 오버헤드가 억누르고 있었다는 우려가 확인됐다. 재현된 33건에서도 후보 분기는 여전히 안 실패했지만, 131차가 "동기 실패 시에만 불린다"고 지목한 enqueue_new_sample_impl()에 카운터를 심으니 40회 전부 0번이었다 — 대신 무관한 재전송 함수(add_old_sample_impl(), 디스커버리 관련으로 보임)가 지연 샘플 유무와 무관하게 매 실행 정확히 13회 불려, 129·130차가 strace로 찾은 FlowControllerImpl::run() 스레드의 정체가 "동기 실패 처리"가 아니라 "정기 재전송 유지보수"였을 가능성이 크다는 것을 발견했다(→ 136차 정정: 사건 단위로 겹쳐 보니 이 "우연" 판단 자체가 틀렸다 — 지연 샘플 16/16건 전부에서 재전송이 그 갭 창 안에 정확히 들어와 있었다, 아래 참고). 개별 스왑을 락 A 개입과 사건 단위로 대조하니 스왑(44건)의 79.5%가 개입과 동반됐다(정상 픽 136건은 6.6%만) — 108·110차의 회차-단위 상관을 사건 단위로 재확인했다. 136차가 세 갈래 모두를 더 추적했다. 재전송(add_old_sample_impl())에 사건 단위 타임스탬프를 남겨 지연 샘플 도착 시각(테스트 프로세스 자신의 T0_ABS_NS로 절대 시각 환산)과 직접 겹쳐 보니, 지연 샘플 16/16건 전부에서 재전송 사건이 그 ~70ms 갭 창 안에 2~8회씩 정확히 들어와 있었다(우연이라면 실행당 약 0.3회 기대인데 실제로는 평균 5.4회, 약 18배) — 135차의 "우연" 판단은 정정됐지만 인과 방향(재전송이 지연을 유발하는지, 공통 원인의 별개 증상인지)은 여전히 열려 있다(→ 137차 정정: 결함 주입으로 완전히 밝혀졌다 — 재전송을 억제하니 지연 샘플이 아니라 메시지 전부가 사라졌다, 재전송이 유일한 전달 경로였다, 아래 참고). 락 A 개입 없는 잔차 스왑(9/44)을 표본을 60회로 늘려 개별 재검토하니, 75건의 스왑 중 잔차 28건 전부에서 그 픽을 만든 스레드가 자기 자신의 REBUILD_START/END 사이클을 이 실행 안에서 단 한 번도 기록하지 않았다 — Executor::get_next_executable()이 wait_for_work()(락 A 재구성)보다 get_next_ready_executable()을 먼저 시도하기 때문에, 다른 스레드가 이미 채워 둔 벡터가 남아 있으면 자기 재구성 없이 곧바로 선택할 수 있는 "빠른 경로"였다 — 락 A(47건)와 빠른 경로(28건)를 합치면 관측된 스왑의 100%가 설명된다. 픽 순번으로 나눠 스왑률을 다시 보니 60.0%(픽#0)→36.7%(픽#1)→25.0%(픽#2)→3.3%(픽#3)→0%(픽#4·5)로 단조 감소했고, 빠른 경로 스왑 28건 전부가 픽#0~2에만 몰려 있었다(각 스레드가 최소 한 번씩 재구성을 거치고 나면 이 경로 자체가 더는 발동할 수 없기 때문) — 첫 픽의 스왑은 락 A 경합이 가장 심하다는 것과 빠른 경로가 존재할 수 있는 유일한 구간이라는 두 가지가 겹치는 스핀-시작 특유의 현상이었다. 137차가 세 갈래 모두를 더 추적했다. 재전송(add_old_sample_impl())을 GAP137_SUPPRESS_RESEND 환경변수로 실제로 억제하는 결함 주입을 넣고 정상·억제 각 40회를 실행하니, 정상은 40/40 모두 6개 메시지 전부 수신했지만 억제는 40/40 모두 단 하나도 수신하지 못했다 — 이 테스트가 구독 생성 전에 발행하는 TRANSIENT_LOCAL 구조라, 재전송이 지연 샘플 하나만의 지연이 아니라 6개 샘플 전부의 유일한 전달 경로였다는 것이 완전히 밝혀졌다. "빠른 경로" 픽에도 "마지막 재구성자" 스레드를 사건 단위로 남겨 보니 빠른 경로 29건 전부(100%)에서 선택한 스레드가 아니라 다른 스레드의 재구성 상태를 물려받았다(그 외 픽은 23.6%만) — "자기 재구성 없이 선택한다"에서 "왜 남의 상태인가"까지 완전히 닫혔다. Jazzy의 선택 경로(get_next_ready_executable())를 처음으로 직접 읽고 계측해 Humble과 같은 세션에서 비교하니, Jazzy는 공유 wait_result_를 매번 reset()한 뒤 다시 채우는 구조로 Humble의 점진적으로 걸러지는 벡터와 다르다는 것을 확인했다 — 같은 세션 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%)과 방향이 반대라 이번 계측 자체가 오버헤드로 흔들렸을 가능성이 크다(→ 138차 정정: 계측 없이 다시 재도 Jazzy는 여전히 60/60이었다 — 역전은 계측 오버헤드가 아니었다, 아래 참고) — 다만 Jazzy 스왑의 94.2%가 Humble의 두 메커니즘(락 A·빠른 경로) 어느 쪽으로도 안 설명된다는 구조적 사실은 유효해, 108차의 해시 순서 가설이 스레드 경합과 무관한 별개 메커니즘일 가능성을 사건 단위로 뒷받침했다. 138차가 세 갈래 모두를 마무리했다. service_prespin_evt를 어떤 오버레이도 없이 시스템 rclcpp에 그대로 빌드해 같은 세션에서 측정해 보니 Jazzy는 계측을 걷어내도 여전히 60/60(100%) 이탈이었다(Humble은 73.3%→50.0%로 실제로 바뀌었지만 그래도 방향은 안 바뀐다) — 121·122차의 세션 간 변동성으로 보는 것이 타당하다는 결론이다. Jazzy의 선택 순서가 해시 버킷 순서라는 가설은 Executor::collect_entities()→ExecutorEntitiesCollection::ServiceCollection(std::unordered_map<const rcl_service_t*, ...> 상속)→update_entities()의 update_from.begin()~end() 순회까지 함수·줄 단위로 확정했고, 137차가 모은 Jazzy 60회 로그를 재분석하니 61.7%(37/60)가 정확히 같은 FIRE_ORDER 하나(5,3,2,1,0,4)로 수렴했다 — 720가지 순열 중 하나가 이렇게 반복되는 것은 순수 레이스가 아니라 결정론적 해시 편향의 증거다. processDataMsg()에 GUID 엔티티ID를 남겨 134·135차 측정(1566회, 0건 실패)의 실제 구성을 확인하니 우리 6개 테스트 토픽은 19.7%(360/1830)뿐이었고 80.3%는 배경 트래픽(엔티티ID 하나가 73.8%)이었다 — 다만 우리 자신의 360회 역시 0건 실패로 재확인됐다. 139차가 이 두 항목을 마무리하고, 코디네이터의 판단대로 실무 지침 집필로 무게중심을 옮겼다. 토픽 이름까지 남기니 배경 트래픽의 정체가 정확히 ros_discovery_info(73.8%)·rt/parameter_events(6.6%)로 확정됐다. Jazzy의 "비지배적" 순서 23/60을 재분석하니 순수 잡음이 아니라 4개의 추가 안정적 순서가 81.7%(49/60)를 차지했다 — 소수의 안정적 해시 버킷 순서 후보군이 있다는 뜻이다. 32회차(108~139차)에 걸친 executor 디스패치 조사 전체를 2.3.15에 실무 지침으로 정리했다 — 같은 타입 엔티티의 순서는 두 배포판 다 보장되지 않는다는 것, Humble은 사전 스핀으로 이탈이 82.1%→0.0%까지 완화되지만 Jazzy는(경합이 원인이 아니므로) 안 된다는 것, TRANSIENT_LOCAL 선발행 데이터는 별도의 비동기 지연 경로를 거친다는 것을 증명된 사실로 못 박고, 이중 호출의 정확한 원인·ASLR의 부분적 역할·Humble이 더 불리한 정량적 이유는 여전히 미해결로 명시했다(15.1.5·15.1.9에도 요약을 반영했다). 상세 수치·해석·재현 코드는 2.3.14·2.3.15, 하네스는 tools/gap_harness/gap109/·tools/gap_harness/gap112/·tools/gap_harness/gap113/·tools/gap_harness/gap114/·tools/gap_harness/gap115/·tools/gap_harness/gap116/·tools/gap_harness/gap117/·tools/gap_harness/gap118/·tools/gap_harness/gap119/·tools/gap_harness/gap120/·tools/gap_harness/gap121/·tools/gap_harness/gap122/·tools/gap_harness/gap123/·tools/gap_harness/gap124/·tools/gap_harness/gap125/·tools/gap_harness/gap126/·tools/gap_harness/gap127/·tools/gap_harness/gap128/·tools/gap_harness/gap129/·tools/gap_harness/gap130/·tools/gap_harness/gap131/·tools/gap_harness/gap132/·tools/gap_harness/gap133/·tools/gap_harness/gap134/·tools/gap_harness/gap135/·tools/gap_harness/gap136/·tools/gap_harness/gap137/·tools/gap_harness/gap138/·tools/gap_harness/gap139/.

새 하네스는 tools/gap_harness/gap108/(order_test.cpp·CMakeLists.txt·package.xml·README.md)에 남겼다.

15.1.4 절차

1. 준비: 의존성 조사 · 계획 2. 환경 구축 병행 개발 환경 3. 빌드 통과 컴파일 오류 해소 4. 기본 동작 노드 기동 · 통신 5. 시험 통과 단위 · 통합 (14.5 · 14.6) 6. 시뮬 검증 7. 실기 검증 · 성능 비교 8. 단계적 배포 카나리 (14.8.7)
단계산출
1 준비의존성 현황표·계획·되돌리기 방안
2 환경Jazzy 개발 환경(컨테이너 권장)
3 빌드컴파일 오류 0
4 기본노드가 기동되고 토픽이 흐름
5 시험기존 시험 통과
6 시뮬동작 검증(14.6.4)
7 실기성능·동작 비교
8 배포단계적 롤아웃

견해: 컨테이너가 이행 작업에서 특히 유용하다(14.8.5). Humble과 Jazzy 환경을 같은 머신에서 오가며 비교할 수 있고, 개발자마다 환경을 새로 만들 필요가 없다. 이행 기간에는 두 환경을 병행하게 되므로 이 격리가 값을 한다.

견해: "빌드가 통과했다"와 "동작한다"는 멀다. 컴파일 오류를 다 검출한 시점이 작업의 약 절반이고, 나머지 절반은 기본값·동작 변화를 찾는 일이다(15.1.3의 ④). 여기서 시간을 과소평가하면 일정이 무너진다.

견해: 기존 시험이 이행의 안전망이다(14.5·14.6). 시험이 부실하면 "무엇이 깨졌는지" 알 방법이 실기 시험뿐이다. 이행 전에 시험을 보강하는 것이 결국 빠른 경로일 수 있다.

3단계(빌드)를 구체화하면:

# 폐기 경고를 미리 찾아 3단계를 예측 가능하게 만든다 — 아직 Humble에서 빌드가 되는 시점에 실행
colcon build --cmake-args -DCMAKE_CXX_FLAGS="-Wdeprecated-declarations" 2>&1 | grep -i deprecated

# 소스에서 확장자 없는 tf2 헤더 같은 "알려진 제거 대상"을 미리 찾는다(15.1.3의 표)
grep -rn '#include <tf2_[a-z_]*/tf2_[a-z_]*\.h>' src/   # .hpp가 아닌 것만 걸린다
grep -rn 'std::shared_ptr<[A-Za-z:]*Msg' src/            # 비-const 구독 콜백 시그니처 후보

견해: -Wdeprecated-declarations를 Humble 환경에서 먼저 켜 보는 것이 3단계를 훨씬 예측 가능하게 만든다. Jazzy로 옮기기 전에, 아직 Humble에서 이 플래그로 우리 코드가 어떤 폐기 API를 쓰고 있는지 확인할 수 있다 — 15.1.3 표의 항목들은 대부분 Humble 시점에 이미 폐기 경고를 냈기 때문이다. 경고가 하나도 없는 상태로 이행을 시작하면 3단계가 훨씬 짧아진다.

15.1.5 흔한 문제

실제로 걸리는 것들의 유형이다.

유형증상접근
폐기 API 제거컴파일 오류마이그레이션 문서
헤더·네임스페이스 이동못 찾음새 경로 확인
시그니처 변경오류문서
기본값 변화동작이 다름릴리스 노트·명시 설정
파라미터 이름 변경미적용(1.9.7)상위 스택 문서
플러그인 이름 변경로드 실패설정 갱신
메시지 정의 변경필드 불일치(1.2.8)확인
시스템 라이브러리링크·런타임 오류OS 전환 영향
Python 버전문법·모듈코드 갱신
빌드 도구 옵션경고·실패갱신
같은 타입 엔티티의 콜백 순서 가정컴파일도 되고 대부분 통과하는데 가끔 순서가 다름순서 비의존 설계(2.3.15)

견해: "파라미터·플러그인 이름 변경"이 가장 발견이 늦는 문제다. 컴파일 오류가 안 나고, 노드도 기동되고, 로그에 경고가 있어도 놓치기 쉽다. 이행 후 ros2 param dump로 실제 값을 확인하고(14.3.8), 로그에서 "unknown parameter" 류 경고를 검색하는 것이 확실하다.

함정: 경고를 무시하면 나중에 문제가 된다. 폐기 경고는 다음 배포판에서 오류가 되므로, 이행 시점에 정리하는 것이 다음 이행을 쉽게 만든다. 미루면 격차가 누적된다.

견해: 한 번에 하나씩 고치고 자주 빌드하라(11.1.7의 원칙과 같다). 여러 파일을 동시에 고치면 어느 변경이 무엇을 깼는지 모른다. 작은 단위로 커밋하면 되돌리기도 쉽다.

정직: "같은 타입 엔티티의 콜백 순서 가정"은 이행 표에서 가장 눈에 안 띄는 항목이다 — 컴파일 오류도, unknown parameter 경고도 안 나기 때문이다. 108~139차(32회차)에 걸친 이 책의 executor 디스패치 조사가 확인한 것은: 같은 타입 엔티티(서비스 여러 개 등)의 콜백 실행 순서는 Humble·Jazzy 둘 다 등록 순서를 보장하지 않고, 그것도 서로 다른 메커니즘으로 깨진다 — Humble은 스레드 경합(사전 스핀으로 크게 완화됨), Jazzy는 std::unordered_map의 해시 버킷 순서(경합과 무관해 사전 스핀이 안 통함)다. Humble에서 "우연히 등록 순서대로 실행되는 것처럼 보이던" 코드가 Jazzy로 옮기면 다른(그러나 마찬가지로 보장 안 되는) 순서로 실행되는 식으로 드러난다. 상세 메커니즘·근거는 2.3.15.

빌드 도구 옵션 변경의 실제 사례. Jazzy 릴리스 노트에는 ros2 bag record의 --exclude 옵션이 --exclude-regex로 이름이 바뀐 사례가 있다 — 정확히 이 절의 "흔한 문제" 표가 말하는 유형(빌드 도구 옵션 변경)이다. 104차 갭 점검이 ros2 bag record --help로 실행 확인 — --exclude는 옵션 목록에 없고 --exclude-regex만 있으며, 옛 이름을 그대로 쓰면 즉시 usage: 오류로 중단된다(조용한 무시가 아니다).

# Humble
ros2 bag record --exclude "/rosout" --all

# Jazzy — 옵션 이름이 바뀌었다. 낡은 이름은 조용히 무시되지 않고 CLI가 즉시 오류를 내므로
# 이 특정 사례는 실은 발견이 쉬운 축에 속한다 — 모든 CLI 옵션 변경이 이렇게 친절한 것은 아니다
ros2 bag record --exclude-regex "/rosout" --all

시뮬레이션을 쓴다면 Gazebo 전환도 이 축에 걸린다. Jazzy부터 ROS 2 배포판마다 "권장 Gazebo 버전"이 정해지며 Jazzy의 권장판은 Gazebo Harmonic이다(10.2 계열). 패키지 의존도 gz_math_vendor 같은 새 벤더 패키지 경로로 바뀐다:

<!-- package.xml -->
<depend>gz_math_vendor</depend>
# CMakeLists.txt
find_package(gz_math_vendor REQUIRED)
find_package(gz-math)
target_link_libraries(my_executable gz-math::core)

정직: 위 세 예시는 Jazzy 공식 릴리스 노트에 실제로 기록된 항목을 그대로 인용한 것이다. ros2 bag record --exclude-regex는 104차 갭 점검이 --help와 실제 실행으로 확인했다(위 실행 확인 표기). Gazebo(gz_math_vendor) 빌드 예시는 이번에도 직접 실행해 검증하지 않았다(확인 필요: gz_math_vendor 빌드).

15.1.6 병행 운영

이행 기간에는 두 배포판을 동시에 다루게 된다.

상황대응
운영은 Humble·개발은 Jazzy두 브랜치 유지
버그 수정을 양쪽에백포트 부담
로봇마다 다른 배포판플릿 관리 복잡(14.9)
통신 상호운용배포판 간 DDS 통신(확인 필요)
CI 이중화두 환경 빌드·시험

견해: 병행 기간을 짧게 유지하는 것이 부담을 줄인다. 두 브랜치를 오래 유지하면 백포트 비용이 누적되고 코드가 갈라진다. 집중해서 이행하고 빨리 단일화하는 편이 대체로 낫다.

정직: 다른 배포판 간 DDS 통신이 되는지는 실험으로 확인해야 하며, "표준 메시지도 배포판마다 바뀔 수 있다"는 것은 가정이 아니라 실제 사례가 있다. Jazzy 릴리스 노트에는 geometry_msgs/Polygon·PolygonStamped에 ID 필드가 추가된 변경이 있다(Iron 대비) — common_interfaces처럼 "안 바뀌겠지"라고 여기기 쉬운 표준 메시지도 필드가 늘 수 있다는 뜻이다. 이론적으로 같은 RMW·같은 타입 해시면 통신할 수 있지만, 메시지 정의가 바뀌면 타입 해시가 달라져(1.3) 엄격한 타입 검사 하에서는 호환되지 않을 수 있다(확인 필요: Jazzy의 타입 해시 검사 강도). 혼합 운영을 계획한다면 실제로 쓰는 인터페이스들을 배포판 릴리스 노트에서 하나씩 대조하고, 반드시 실험으로 확인하라.

견해: CI를 두 환경에서 실행하는 것이 병행 기간의 안전망이다(4.10). Humble에서 깨지는 변경을 조기에 검출할 수 있고, Jazzy 이행 진척도 보인다. 비용은 들지만 병행 기간의 사고를 막는다.

코드에서의 조건부 처리:

견해: 배포판별 #ifdef 분기는 최소화하라. 코드가 지저분해지고 양쪽 모두 시험해야 한다. 가능하면 양쪽에서 동작하는 표현을 찾고, 불가피한 곳만 분기하되 이행 완료 후 정리할 목록으로 남긴다.

15.1.7 검증

"이행이 성공했다"를 어떻게 판정하는가.

확인방법
빌드·시험CI 전부 통과(4.10)
기능 동등성같은 시나리오·같은 결과
성능 비교지연·CPU·대역(11.7)
규약tf·프레임·단위(14.6.3)
장시간 안정성메모리·주기(11.7.6)
이상 대응고장 시 거동
배포·기동systemd·컨테이너(14.8)

견해: 성능 비교를 반드시 하라(11.7.7). 배포판 변경으로 성능이 달라질 수 있는데, 기능만 확인하면 이것을 놓친다. 이행 전 Humble에서 측정한 값과 비교할 수 있도록, 이행 시작 전에 기준값을 측정해 두는 것이 중요하다.

견해: 장시간 시험을 빠뜨리지 말라. 메모리 누수나 서서히 누적되는 문제는 짧은 시험에서 안 보인다(11.7.6). 며칠 연속 운전해 보는 것이 배포 전 마지막 검증 단계다.

견해: "이행 전 기준값"을 남기는 것이 이 작업의 숨은 핵심이다. 이행 후 무언가 비정상이어도 "원래 그랬는지"를 알 수 없으면 판단이 불가능하다. 성능·동작·로그를 이행 전에 기록해 두라.

15.1.8 되돌리기

실패에 대비한다.

준비내용
이전 이미지 보존즉시 복귀(14.8.2)
A/B 배포자동 롤백(14.8.7)
브랜치 유지코드 복귀
카나리소수 먼저(14.8.7)
데이터 호환성지도·설정·bag
판단 기준언제 되돌리는가

함정: 데이터 형식이 바뀌면 되돌리기가 복잡해진다. 새 버전이 저장한 지도·설정을 옛 버전이 못 읽으면, 롤백해도 동작하지 않는다. 데이터 호환성을 이행 계획에 포함시켜야 한다.

견해: "언제 되돌리는가"를 미리 정하라. 문제가 생겼을 때 그 자리에서 판단하면 "조금만 더 고쳐 보자"가 되어 시간이 간다. "카나리에서 N일 내 심각한 문제가 나오면 롤백" 같은 기준을 정해 두면 결정이 빠르다.

15.1.9 이행을 쉽게 만드는 습관

다음 이행을 위해 지금 할 수 있는 것들이다.

습관효과
폐기 경고 즉시 정리격차 누적 방지
의존성 최소화확인할 것이 적어짐(14.7)
표준 API 사용안정적
시험 커버리지이행의 안전망
기본값에 의존하지 않음명시적 설정
버전 고정 관리재현 가능(14.7.5)
CI 유지조기 발견
문서화왜 그렇게 했는지
콜백 순서 비의존 설계Humble·Jazzy(그리고 다음 배포판) 다 안전

견해: "기본값에 의존하지 않는다"가 특히 효과적이다. QoS·RMW·파라미터를 명시적으로 설정해 두면, 배포판이 기본값을 바꿔도 우리 시스템은 영향을 받지 않는다. 15.1.3의 ④ 위험을 구조적으로 없애는 방법이다.

견해: "콜백 순서 비의존 설계"도 같은 종류의 방어다. 같은 타입 엔티티 여러 개의 실행 순서를 코드가 은연중에 가정하면, 그 가정은 오늘의 Humble·Jazzy 사이에서도 이미 깨져 있고(2.3.15) 다음 배포판이 또 다른 순서로 바꿀 수도 있다. 순서가 중요하면 요청·메시지 안에 명시적 시퀀스 번호를 넣고 애플리케이션 계층에서 정렬하라 — executor의 우연한 동작에 기대지 마라.

견해: Rolling에서 CI를 함께 실행하는 것도 방법이다(15.2). 다음 배포판의 변경을 미리 만나므로 놀랄 일이 준다. 비용이 있지만, 큰 시스템에서는 이행 충격을 크게 줄인다.

견해: 이행 경험을 문서로 남기라. 무엇이 걸렸고 어떻게 풀었는지 기록하면, 다음 이행에서 같은 길을 다시 시행착오를 겪지 않는다. 팀의 이행 역량은 이 기록에서 나온다.

15.1.10 함정 카탈로그

#함정증상대처
1EOL 임박해 시작선택지 없음여유 있게
2의존성 확인 없이 착수중도 막힘사전 조사
3기본값 변경 간과조용한 동작 차이릴리스 노트·명시 설정
4빌드 통과를 완료로 오인일정 붕괴절반으로 인식
5시험 부실무엇이 깨졌는지 모름사전 보강
6이행 전 기준값 미측정비교 불가사전 측정
7파라미터·플러그인 이름 변경 놓침조용한 미적용param dump·경고 검색
8경고 무시다음 이행에 누적즉시 정리
9여러 변경 동시원인 불명하나씩·자주 커밋
10병행 기간 장기화백포트 부담집중·단일화
11배포판 간 통신 가정미작동실험 확인
12성능 비교 생략저하 미발견필수 측정
13장시간 시험 생략누수·열화며칠 운전
14되돌리기 미준비대응 불가이미지·A/B
15데이터 호환성 미고려롤백 무효계획에 포함
16-Werror 되돌리기 망각품질 저하정리 후 복원
17콜백 실행 순서 가정에 의존조용한 오작동(executor 내부 구현 변경)명시적 순서 보장 수단만 사용(2.4)
18중간 배포판 건너뛰기두 배포판分 변경이 한꺼번에폐기 경고를 먼저 배포판에서 확인

15.1.11 체크포인트

  • 이행 시점 판단의 일곱 요인과, 가장 실질적인 게이트는?
  • 변경 범위의 일곱 축을 말할 수 있는가.
  • "가장 위험한 축"과 그 이유는? 어떻게 대비하는가.
  • 작업량의 대부분을 차지하는 축과, 그에 대한 실무 작업은?
  • 이행 절차 여덟 단계와 각 산출물은?
  • 컨테이너가 이행에서 유용한 이유는?
  • "빌드 통과 = 절반"의 의미는?
  • 흔한 문제 열 유형 중, 발견이 가장 늦는 것과 확인 방법은?
  • 경고를 즉시 정리해야 하는 이유는?
  • 병행 운영의 다섯 상황과, 기간을 짧게 유지하라는 근거는?
  • 배포판 간 통신에 대한 정직한 입장은?
  • 검증 일곱 항목과, "이행 전 기준값"이 숨은 핵심인 이유는?
  • 되돌리기 준비 여섯 가지와, 데이터 호환성이 왜 문제인가.
  • "언제 되돌리는가"를 미리 정하라는 조언의 근거는?
  • 다음 이행을 쉽게 만드는 습관 여덟 가지와, 특히 효과적인 것은?
  • Humble→Jazzy에서 실제로 있었던 변경 표본 다섯 가지 중, 컴파일 오류로 드러나지 않는 것은 무엇이고 왜 진단이 어려운가?
  • -Wdeprecated-declarations를 이행 전에 켜 보라는 조언의 근거는?
  • 배포판 간 표준 메시지가 안 바뀔 것이라는 가정이 왜 위험한가?

다음 장 15.2는 그다음 배포판 — Kilted·Rolling을 대비하는 방법을 다룬다.