제14부 시스템 설계·품질·운영
14.1 노드 분할 설계 — 경계 긋기
14.1.1 이 장에서 배우는 것 / 선수 지식
"이 기능을 새 노드로 만들까, 기존 노드에 넣을까?" — ROS 2 개발에서 가장 자주 마주치는 설계 질문이자, 가장 자주 추측으로 결정되는 질문이다.
이 결정이 중요한 이유는 되돌리기 어렵기 때문이다. 노드 경계는 토픽 인터페이스가 되고, 인터페이스는 다른 팀·다른 패키지가 의존하며, 한 번 굳으면 바꾸는 데 협상이 필요하다. 반면 잘못 그은 경계는 계속 비용을 청구한다 — 불필요한 직렬화(12.4.5), 상태 동기화, 디버깅 난이도.
이 장은 경계를 긋는 기준을 세운다. 정답은 없지만 근거는 있다.
배우는 것:
- 분할의 이유와 비용 — 무엇을 얻고 무엇을 잃는가.
- 경계의 기준 — 여섯 가지 축.
- 응집과 결합 — 고전적 원칙의 적용.
- 인터페이스 설계 — 경계에서 무엇을 주고받는가.
- 흔한 분할 패턴 — 그리고 안티패턴.
- 재검토 — 경계를 바꾸는 방법.
선수 지식: 1.1(노드·토픽), 1.2(메시지 설계), 1.12(생애주기), 1.13(컴포지션), 12.4.5(체인 비용), 4.2(패키지 구성).
정직: 이 장은 설계 판단을 다루며 유일한 정답을 제시하지 않는다(book 스킬의 의사결정 규약). 방식을 펼치고 트레이드오프를 제시하되, 견해는 근거와 함께 라벨링한다. 이 환경에서 검증하지 않았다(목차 3).(→ 180차 확인: 이 장의 유일한 독립 검증 가능 기술 주장 — 14.1.7의 "논리적 분할은 자유롭게, 물리적 배치는 나중에" — 을 실제 컴포넌트로 실측했다. 아래 실행 확인(180차) 참고. → 185차 확인: 180차가 "근본 원인 미상"으로 남긴 배치 A의 거대한 꼬리 지연 스파이크도 이후 원인을 찾았다 — Fast DDS 특유의 디스커버리·매칭 시작 시점 현상이었다. 아래 실행 확인(185차) 참고. → 186차 확인: 185차의 설명을 QoS 이력 깊이를 실제로 바꿔 가며 인과적으로 검증하고, 설명의 정밀도도 한 단계 높였다. 아래 실행 확인(186차) 참고. → 187차 확인: 186차가 우연으로 추정했던 깊이 20의 편차를 5회 반복으로 재확인해 우연이 아니라 체계적 현상임을 확정했다(원인은 미상). 아래 실행 확인(187차) 참고. → 199차 확인: 187차가 원인 미상으로 남긴 깊이 20의 편차를 발행 주기 스윕으로 재조사해, "고정 샘플 개수" 문제가 아니라 "~160~190ms의 실시간 시간 예산" 문제였음을 확정했다 — 부수적으로 KEEP_LAST(depth)가 매칭 전 대기 시간이 길어지면 깊이를 넘는 버스트를 전달하는 사례(37.5%, n=8)도 발견했다. 아래 실행 확인(199차) 참고.)
14.1.2 분할의 이유와 비용
| 얻는 것 | 설명 |
|---|---|
| 독립 개발 | 팀·일정 분리 |
| 장애 격리 | 하나가 종료돼도 나머지(14.1.6) |
| 재사용 | 다른 로봇·프로젝트 |
| 언어 선택 | C++·Python 혼용 |
| 자원 분리 | 별도 프로세스·기기 |
| 교체 가능성 | 구현 바꾸기(8.4의 측위 교체 등) |
| 시험 용이 | 경계에서 모의 가능(14.5) |
| 잃는 것 | 설명 |
|---|---|
| 지연 | 직렬화·전달·실행기 대기 |
| 인터페이스 고정 | 바꾸기 어려움(1.2.8) |
| 상태 일관성 | 두 곳에 흩어짐 |
| 디버깅 복잡도 | 흐름 추적 |
| 배포·기동 복잡 | 순서·의존(14.2) |
| 자원 오버헤드 | 프로세스·스레드 |
견해: "장애 격리"와 "지연"이 가장 자주 충돌하는 두 축이다. 나누면 견고해지지만 느려지고, 합치면 빨라지지만 하나가 종료될 때 함께 종료된다. 컴포넌트(2.7)가 이 절충의 중간항이다 — 코드는 분리하되 프로세스는 합칠 수 있다.
견해: 분할의 실제 비용은 인터페이스 유지다. 노드를 만드는 것은 쉽지만, 그 토픽 인터페이스는 계약이 되어 오래 남는다. "이 인터페이스를 3년간 유지할 각오가 있는가" 가 좋은 질문이다.
14.1.3 경계의 기준
여섯 가지 축으로 판단한다.
| 축 | 분할 신호 |
|---|---|
| ① 변경 이유 | 서로 다른 이유로 바뀐다 |
| ② 주기·타이밍 | 1 kHz 제어 vs 1 Hz 계획 |
| ③ 재사용 | 다른 시스템에서도 쓸 것 |
| ④ 장애 격리 | 종료돼도 나머지가 살아야 |
| ⑤ 자원·위치 | 다른 기기·GPU·권한 |
| ⑥ 조직 | 다른 팀이 소유 |
견해: ①(변경 이유)이 가장 강한 기준이다. 함께 바뀌는 것은 함께 두고, 따로 바뀌는 것은 나눈다. 센서 드라이버와 인지 알고리즘은 바뀌는 이유가 다르므로(하드웨어 교체 vs 알고리즘 개선) 나누는 것이 자연스럽다.
견해: ②(주기 차이)가 ROS 2에서 특히 실질적이다. 1 kHz 제어와 1 Hz 계획을 한 노드에 두면 실행기 설계가 복잡해지고(2.3), 실시간 요구도 섞인다(12.1.7). 주기가 자릿수로 다르면 나누는 쪽이 대개 낫다.
견해: ⑥(조직)을 무시하지 말라. 기술적으로는 합치는 것이 나아도, 두 팀이 한 노드를 공동 수정하면 충돌과 대기가 생긴다. 콘웨이의 법칙은 로봇에서도 작동한다 — 다만 조직 때문에 나눈 경계는 조직이 바뀌면 재검토해야 한다.
나누지 말아야 할 신호:
| 신호 | 설명 |
|---|---|
| 강한 상태 공유 | 같은 상태를 계속 주고받음 |
| 지연이 결정적 | 종단 예산이 빠듯(12.4) |
| 항상 함께 변경 | 응집이 높음 |
| 인터페이스가 불안정 | 아직 정해지지 않음 |
| 경계가 자의적 | "별다른 이유 없이 커져서" |
함정: "노드가 커져서 나눈다"는 그 자체로 문제인 이유는 아니지만 위험하다. 크기가 아니라 책임이 여럿인지로 판단해야 한다. 크지만 하나의 책임이면 그대로 두는 것이 낫고, 작아도 책임이 둘이면 나눌 만하다.
14.1.4 응집과 결합
고전적 원칙이 ROS 2에서 어떻게 나타나는가.
| 원칙 | ROS 2에서의 형태 |
|---|---|
| 높은 응집 | 한 노드가 하나의 명확한 책임 |
| 낮은 결합 | 노드 간 인터페이스가 좁고 안정 |
| 단일 책임 | "이 노드는 무엇을 하는가"에 한 문장 답 |
| 의존 방향 | 상위→하위, 순환 없음 |
결합의 징후:
| 징후 | 문제 |
|---|---|
| 토픽이 매우 많음 | 경계가 잘못됨 |
| 한쪽을 고치면 다른 쪽도 | 응집이 흩어짐 |
| 순환 의존 | A→B→A 토픽 흐름 |
| 내부 상태를 토픽으로 노출 | 캡슐화 실패 |
| 상대의 구현을 알아야 함 | 추상화 실패 |
견해: "두 노드 사이에 토픽이 열 개"라면 경계가 잘못 그어진 것이다. 한 책임을 억지로 나눈 결과일 가능성이 높다. 경계는 통신이 적은 곳에 긋는다 — 이것이 지연 비용(12.4.5)과도 일치한다.
함정: 순환 의존(A가 B에 발행하고 B가 A에 발행)이 반드시 문제인 것은 아니다 — 제어 루프는 본질적으로 순환이다. 문제는 초기화 순환(서로를 기다림)과 책임 불명확이다. 1.12의 생애주기로 초기화 순서를 명시하면 완화된다.
견해: "내부 상태를 토픽으로 노출"이 흔한 실수다. 디버깅을 위해 내부 변수를 발행하기 시작하면, 다른 노드가 그것에 의존하고 결국 내부 구현을 바꿀 수 없게 된다. 디버그용 토픽은 명확히 구분(이름·네임스페이스)하고, 의존하지 않는다는 규약을 두라.
14.1.5 인터페이스 설계
경계에서 무엇을 주고받는가.
| 선택 | 언제 |
|---|---|
| 토픽 | 지속적 데이터 흐름(1.4) |
| 서비스 | 즉답 요청·설정 변경(1.8) |
| 액션 | 장시간 작업·피드백·취소(1.8) |
| 파라미터 | 설정(1.9) |
| 생애주기 | 상태 전이(1.12) |
설계 원칙:
| 원칙 | 설명 |
|---|---|
| 표준 타입 우선 | 상호운용(1.2.6) |
| 의미를 명확히 | 필드 이름·단위·프레임(7.1.6) |
| 좁게 | 필요한 것만 |
| 안정적으로 | 자주 안 바뀔 구조(1.2.8) |
| 버전 고려 | 확장 여지 |
| 규약 명시 | 문서·주석 |
견해: "경계를 그으면 인터페이스를 문서화하라"가 실질적 규율이다. 토픽 이름·타입·주기·프레임·단위·QoS·에러 처리를 적어 두면, 다른 사람이 그 노드를 쓸 수 있고 나중에 교체도 가능하다. 문서 없는 경계는 경계가 아니라 단순한 분리다.
견해: 경계 인터페이스는 "구현이 아니라 계약"으로 설계하라. "이 노드가 현재 발행하는 것"이 아니라 "이 역할이 제공해야 하는 것"으로 정의하면, 구현을 교체할 수 있다(8.4에서 측위를 바꿔 끼울 수 있는 이유).
함정: QoS도 인터페이스의 일부다(1.6). 발행자가 QoS를 바꾸면 구독자가 조용히 끊긴다(1.5.4). 인터페이스 문서에 QoS를 포함시켜야 한다.
14.1.6 흔한 분할 패턴
| 패턴 | 경계 |
|---|---|
| 드라이버 / 처리 | 하드웨어 의존 ↔ 알고리즘 |
| 인지 / 계획 / 제어 | 주기·책임 차이 |
| 센서별 노드 | 재사용·독립 실패 |
| 좌표 변환 발행자 | tf 책임 집중(5.1.4) |
| 조율자(orchestrator) | 상태·모드 관리(14.2) |
| 브리지·어댑터 | 외부 시스템 경계 |
| 안전 감시 | 독립 실행(8.7) |
견해: "드라이버와 처리를 나누라"가 가장 보편적으로 유용한 분할이다. 하드웨어가 바뀌어도 처리 로직이 그대로고, 처리를 개선해도 드라이버를 안 변경한다. mock 하드웨어로 시험(6.2.8)하는 것도 이 경계 덕분에 가능하다.
견해: 안전 감시 노드를 독립시키는 것은 기술적 판단을 넘어선다(8.7.7·13.1.4). 다른 노드가 폭주하거나 종료돼도 감시가 동작해야 하므로, 별도 프로세스가 필수다 — 컴포넌트로 합치면 안 되는 드문 경우다.
안티패턴:
| 안티패턴 | 문제 |
|---|---|
| 과다 책임 노드 | 책임 다수·시험 불가 |
| 과도한 분할 | 경계 비용 누적(12.4.5) |
| 계층 무시 통신 | 추적 불가 |
| 상태를 여러 곳에 | 불일치 |
| 노드마다 tf 발행 | 충돌(5.1.4) |
| 디버그 토픽 의존 | 캡슐화 파괴 |
견해: "과도한 분할"이 최근 더 흔한 실수다. 마이크로서비스 사고를 그대로 가져와 노드를 잘게 분할하면, 로봇에서는 지연과 디버깅 비용이 크게 는다. 로봇은 실시간 제약이 있는 단일 기계이지 분산 웹 서비스가 아니다.
14.1.7 프로세스 배치
노드를 나눈 다음, 프로세스를 어떻게 묶을 것인가는 별개 결정이다.
| 배치 | 특성 |
|---|---|
| 모두 독립 프로세스 | 격리 최대·지연 최대 |
| 하나의 컨테이너 | 지연 최소·격리 없음 |
| 그룹별 컨테이너 | 실무적 절충 |
그룹 짓는 기준:
| 기준 | 예 |
|---|---|
| 데이터 흐름이 밀접 | 인지 파이프라인 |
| 실패 영향이 같음 | 함께 종료돼도 됨 |
| 실시간 요구가 같음 | 제어 그룹 |
| 보안 권한이 같음 | enclave(13.2.4) |
| 생애주기가 같음 | 함께 켜고 끔 |
견해: "논리적 분할은 자유롭게, 물리적 배치는 나중에"가 좋은 순서다. 컴포넌트로 작성해 두면(2.7) 배치를 launch에서 바꿀 수 있으므로, 설계 시점에 성능을 걱정하며 노드를 합칠 필요가 없다.
실행 확인(180차) — 이 주장을 문자 그대로 실측했다: 소스 코드 변경 없이, 같은 빌드 산출물을 두 가지 배치로 실제로 실행했다.
rclcpp_components_register_node()로 등록한 컴포넌트 둘(발행자·구독자)을 만들었는데, 이 CMake 매크로 자체가 같은 컴파일 결과물에서 ①component_container에 로드 가능한 플러그인 라이브러리와 ② 독립 실행 파일을 둘 다 자동으로 만들어 준다 — 소스를 복제하지 않고도 "논리적으로는 하나, 물리적 배치는 나중에 선택"이 실제로 가능함을 그 자체로 확인했다(ros2 pkg executables에talker_node·processor_node가,ros2 component types에rj113::TalkerComponent·rj113::ProcessorComponent가 동시에 나타남).배치 A(독립 프로세스 둘, 일반 프로세스 간 DDS): 10ms마다 타임스탬프를 보내는 발행자와, 400개를 받아 지연을 기록하는 구독자를 각각 별도 프로세스로 실행했다. 두 번 반복 측정(발행자 시작 후 2초·6초 뒤에 측정 시작)했는데 —
평균 1302~2088µs,p50 174~180µs,p99 60,626~73,272µs,최대 90,511~283,131µs로 드물지만 매우 큰 지연 스파이크가 두 번의 독립 측정 모두에서 재현됐다(웜업 시간을 늘려도 줄지 않아 단순 디스커버리 초기화 지연이 아니었다 — 근본 원인은 이번엔 규명하지 못했다)(→ 185차 확인: 근본 원인을 찾았다 — "웜업을 늘려도 안 줄었다"는 관찰은 맞았지만 그 해석("디스커버리 지연이 아니다")은 틀렸다. 실제 원인은 구독자 자신이 매번 새로 시작할 때 치르는 디스커버리·매칭 시간(발행자가 얼마나 오래 실행 중이었는지와 무관)이었다 — 발행자 웜업 시간을 늘려 봤자 이 변수를 변경하지 못해 효과가 없었던 것이다. 아래 실행 확인(185차) 참고).배치 B(같은 컴포넌트 컨테이너,
use_intra_process_comms=True): 소스 코드를 전혀 바꾸지 않고 launch 파일만 바꿔 두 컴포넌트를 하나의component_container_mt에 로드했다. 결과:평균 101µs,p50 97µs,p99 194µs,최대 605µs— 모든 지표에서 더 빠르고, 특히 배치 A에서 재현된 거대한 꼬리 지연 스파이크가 완전히 사라졌다.정직하게 덧붙인다 — 평균만 보면 과장된 인상을 준다. 평균은 약 13배(1302→101µs), p99는 약 310~370배, 최대는 약 150~470배 개선됐지만, 일반적인 경우(p50)의 차이는 약 1.8배(174~180→97µs)로 훨씬 작다. 즉 컴포넌트 컨테이너의 실제 이점은 "전형적인 메시지가 300배 빨라진다"가 아니라 "드물지만 거대한 지연 스파이크를 없앤다"는 것이다 — 11.7.3의 "평균이 아니라 꼬리를 보라"는 원칙이 여기서는 거꾸로도 성립한다: 평균값이 꼬리에 끌려가 전형적인 경우의 이득을 과장하고 있었다. 원본:
tools/gap_harness/gap180/boundary_test/(talker_component.*·processor_component.*·CMakeLists.txt·launch/intra_process.launch.py·scenario_a_separate_processes.log·scenario_a_separate_processes_warm.log·scenario_b_intra_process.log).
실행 확인(185차) — 180차가 "근본 원인은 규명하지 못했다"고 남긴 배치 A의 거대한 꼬리 지연 스파이크를, 세 회차(161·162·163차)의 유예 끝에 정면으로 조사해 원인을 찾았다. 같은 발행자·구독자 설계를 재사용하되, 이번엔 매 메시지의 원시 지연을 CSV로 전부 저장해(180차는 집계 통계만 봤다) 표본도 400→3000개로 늘렸다. 결정적 단서는 CSV의 맨 앞 몇 줄에 있었다 — Fast DDS(Jazzy 기본 RMW)로 배치 A를 실행하니 구독자가 받은 첫 9개 메시지의 지연이
90732.20 → 80794.00 → 70754.50 → 60758.10 → 50768.70 → 40771.90 → 30782.50 → 20753.20 → 10816.70µs로 정확히 10ms(발행 주기)씩 줄어드는 완벽한 계단 형태였고, 이 9개의 실제 수신 시각은 단 99마이크로초 안에 몰려 있었다(한꺼번에 도착) — 10번째 메시지부터는 곧바로840µs, 11번째부터는~200µs안팎의 정상 상태로 떨어졌다. 해석: 구독자 프로세스가 새로 시작해 발행자와 디스커버리·매칭을 마치는 데 이 환경에서 약 90ms가 걸렸고, 그동안 발행자는 계속 10ms마다 발행해KEEP_LAST(10)쓰기 이력 캐시에 최근 9~10개가 쌓였다 — 매칭이 완료되는 순간 RELIABLE 프로토콜이 그 캐시를 구독자에게 한꺼번에 전달하면서, 각 메시지가 원래 보낸 시각을 그대로 갖고 있어 지연이 계단 형태로 기록된 것이다(→ 186차 정정: "디스커버리가 약 90ms 걸렸다"는 서술은 정밀하지 않았다 — 발행자가 구독자보다 3초 이상 먼저 실행 중이어서KEEP_LAST(10)캐시는 실제 매칭이 언제 일어나든 이미 훨씬 전에 가득 차 있었다. 즉 90ms는 "디스커버리에 걸린 실제 시간"이 아니라 "깊이 10인 캐시가 가득 차기까지 걸리는 시간"(=(10−1)×10ms)이 우연히 관측값과 일치한 것이었다 — 실제 디스커버리 시간은 이 방법으론 하한선(적어도 (깊이−1)×10ms 이상)만 알 수 있을 뿐 정확히 측정할 수 없다. 아래 실행 확인(186차) 참고). 두 번째 독립 실행(같은 Fast DDS)에서도 거의 동일한 계단이 재현됐다(최대 90658.90µs로 첫 실행의 90732.20µs와 0.1% 이내 일치) — 우연이 아니라 결정론적 메커니즘임을 확인. 같은 조건에서 RMW만 Cyclone DDS로 바꾸니 이 계단 전체가 사라졌다(최대 3144.98µs로 Fast DDS의 약 29분의 1, 첫 메시지부터 곧바로 정상 범위) — 이는 이 초대형 꼬리가 Fast DDS의 디스커버리·매칭·이력 재전송 특성에 국한된 현상이지, ROS 2·OS·도커 컨테이너 일반의 문제가 아님을 시사한다(단, Fast DDS 내부를 패킷 캡처·디버그 로그로 직접 확인하진 않아 추론의 근거는 간접적이다). 180차의 "웜업을 늘려도 안 줄었다"는 관찰도 이걸로 설명된다 — 180차는 발행자의 웜업 시간을 2초·6초로 늘렸는데, 계단을 만드는 변수는 구독자 자신이 제 시작 이후 매칭을 마치는 데 걸리는 시간이라 발행자 웜업과는 무관했던 것이다. 정직하게 덧붙인다: 별도로, 두 RMW 모두에서 정상 구간 중에도 0.5~3ms대의 더 작은 지연 스파이크가 실행 내내 산발적으로(약 100~300개 메시지마다 한 번꼴) 나타났다 — 이것은 계단 메커니즘과 무관한 것으로 보이는 별개의 미확인 현상이며 이번 회차엔 원인을 조사하지 않았다(확인 필요, 이월). 원본:tools/gap_harness/gap185/tail_latency_test/(pkg/·latency_fastdds.csv·latency_fastdds_run2.csv·latency_cyclone.csv·findings_summary.txt).
실행 확인(186차) — 185차의 "캐시 플러시" 설명을 상관관계에서 인과관계로 격상시켰다: QoS
KEEP_LAST이력 깊이를 직접 바꿔 가며 계단 길이·크기가 예측대로 변하는지 시험했다. 발행자에qos_depth파라미터를 추가해 깊이를 1·3·5·10·20으로 바꿔 가며(매번 발행자·구독자를 새로 실행해 매칭을 새로 치르게 함) 배치 A를 재현했다. 결과가 거의 완벽하게 선형이었다 — 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만 예측(190,000µs)보다 7.4% 낮게 나왔는데, 계단의 마지막 구간 간격만 유독 짧아(~6.2ms, 다른 구간은 전부 ~10ms) 매칭 시점이 발행 주기 중간에 걸렸을 가능성으로 추정하나 반복 실행으로 재확인하지는 않았다)(→ 187차 확인: 이 "우연한 정렬" 추정은 틀렸다 — 깊이 20을 5회 더 독립 반복하니 6번(이번 5회+186차 원래 1회) 전부 돌출 메시지 19개가 아니라 18개로 정확히 재현됐고, 최대 지연도 매번 175,702~182,429µs(평균 179,200.5µs, 예측 190,000µs 대비 평균 −5.7%) 범위로 6번 모두 같은 방향·비슷한 크기로 미달했다 — 무작위였다면 약 절반은 19개·예측치 근접으로 나왔어야 하는데 6/6이 똑같이 어긋났으므로, 이는 우연한 타이밍 정렬이 아니라 깊이 20(더 큰 깊이)에서 재현되는 체계적 현상이다. 정확한 메커니즘(캐시가 다 찬 시점과 실제 이력 전달 사이의 고정 오버헤드로 발행 주기 하나가 더 지연되는 것 등 추정)은 이번에도 규명하지 못했다. 아래 실행 확인(187차) 참고). 이 결과는 185차의 메커니즘 설명을 인과적으로 확증한다 — 깊이를 바꾸면 계단이 정확히 예측대로 바뀌므로, "캐시가 가득 차 있다가 매칭 시 한꺼번에 나온다"는 설명이 상관관계 수준을 넘어선다. 동시에 185차 서술의 정밀도를 한 단계 높였다(위 정정 포인터 참고) — "디스커버리가 약 90ms 걸렸다"는 표현은 사실 깊이 10인 캐시가 채워지는 데 걸리는 시간((10−1)×10ms=90ms)이 관측값과 우연히 일치한 것일 뿐, 실제 디스커버리 소요 시간을 측정한 것이 아니었다 — 발행자가 구독자보다 3초 넘게 먼저 실행 중이어서 어떤 깊이를 쓰든 캐시는 매칭 훨씬 전에 이미 가득 차 있었기 때문에, 이 방법으로는 실제 디스커버리 시간의 하한선만 알 수 있다(적어도(깊이−1)×10ms이상이라는 것). 원본:tools/gap_harness/gap186/qos_depth_sweep/(pkg/·run_sweep.sh·results/·findings_summary.txt).
실행 확인(187차) — 186차가 "우연한 타이밍 정렬"로 추정하고 넘어간 깊이 20의 7.4% 편차를 5회 더 독립 반복해, 우연이 아니라 체계적 현상임을 확정했다. 같은 조건(깊이 20, 웜업 3초)으로 발행자·구독자를 매번 새로 매칭시키며 5회 반복했더니 돌출 메시지 개수가 5회 전부 18개(예측 19개가 아님), 최대 지연도 175,702~182,429µs 범위(186차의 원래 1회분 175,999µs를 더하면 총 6회 전부 이 좁은 범위 안)로 나왔다 — 평균 179,200.5µs, 표준편차 2,711.6µs, 예측치(190,000µs) 대비 평균 −5.7%. 6번의 독립 실행이 전부 같은 방향·비슷한 크기로 어긋났다는 것 자체가 "우연"이라는 설명을 반증한다 — 무작위 정렬 문제였다면 19개인 실행도 약 절반 나왔어야 한다. 정직한 결론: 이 미달은 깊이 20(적어도 이 정도로 큰 깊이)에서 재현되는 체계적 효과이지만, 정확한 메커니즘은 이번에도 규명하지 못했다 — 캐시가 가득 찬 시점과 실제 이력이 전달되는 시점 사이에 고정된 오버헤드가 있어 그사이 발행 주기 하나가 더 지나가며 최고령 샘플이 축출된다는 것이 그럴듯한 추정이지만 직접 확인하지 않았다. 이 결과는 깊이 1·3·5·10에서 성립한 선형 모델(
(깊이−1)×10ms)이 여전히 훌륭한 1차 근사임을 해치지 않는다 — 다만 큰 깊이에서는 알려진(그러나 원인 미상인) 보정이 필요하다는 것을 이번에 명확히 했다. 원본:tools/gap_harness/gap187/depth20_repeat/(run_depth20_repeat.sh·results/·findings_summary.txt).(→ 199차 확인: "18개(예측 19개 아님)"라는 이 편차를, 깊이 대신 발행 주기를 스윕해 재조사했다 — 근본 원인이 "고정된 샘플 개수"가 아니라 "고정된 실시간 시간 예산"이었음을 확정했다. 아래 실행 확인(199차) 참고.)
실행 확인(199차) — 186/187차가 "정확한 메커니즘은 규명하지 못했다"고 남긴 깊이 20의 "18개(예측 19개 아님)" 편차를, 깊이 대신 발행 주기를 스윕해 재조사했다. 186/187차는 항상 주기 10ms로 고정하고 깊이만 바꿨다. 이번엔 반대로 깊이를 20으로 고정하고 발행 주기를 5·10·20·40ms로 바꿔 가며(주기당 3회, 가장 흥미로운 10ms는 5회 더 반복해 총 8회) 버스트에 담기는 메시지 개수를 세었다. 결과: 주기가 길어질수록 버스트 개수가 반비례해서 줄었다 — 5ms에서는 20개(깊이 상한에 막힘), 10ms에서는 18~19개, 20ms에서는 9개, 40ms에서는 5개. 버스트가 덮는 시간 폭(=(개수−1)×주기)을 계산하면 20ms·40ms 둘 다 정확히 160ms로 일치하고 10ms의 단일 창 사례도 170~190ms로 근접한다 — "18개"는 깊이에서 나온 고정 개수가 아니라, 주기 10ms에서 우연히 ~170~190ms라는 실시간 예산과 맞아떨어진 결과였다. 5ms만 예외인 것은 이때는 시간 예산을 다 채우기 전에 깊이 20이라는 샘플 개수 상한이 먼저 걸렸기 때문이다. 더 크게 놀라운 부수 발견 — 주기 10ms를 8회 반복하니 3/8회(37.5%)는 버스트가 33~34개로 깊이(20)를 훌쩍 넘었다(시간 폭 320~330ms, 단일 창의 거의 정확히 2배). CSV 원본을 직접 확인해 이 33~34개가 끊김 없이 매끈하게 10ms씩 감소하는 하나의 계단임을 확인했다 — 별개의 두 버스트가 아니라 매칭 자체가 평소보다 약 2배 늦게 일어나 그만큼 더 쌓인 뒤 한꺼번에 전달된 것으로 보인다. 정직한 결론: 원래 질문("왜 18개인가")에는 답했다 — 근본 원인이 "샘플 개수"에서 "~160~190ms의 실시간 시간 예산"으로 재구성됐다. 그런데 이 시간 예산 자체가 양자화돼(대략 160~190ms 또는 그 2배) 있고 2배가 나오는 빈도(37.5%)가 결코 드물지 않다는 것, 그리고 깊이=20을 명시했는데도 Fast DDS가 33~34개를 전달한 것은 "KEEP_LAST(depth)가 새 구독자에게 전달될 과거 샘플 수의 하드 캡"이라는 이 책이 186/187차까지 암묵적으로 전제해 온 가정을 적어도 이 조건에서는 반증한다 — 정확히 어떤 DDS/RTPS 타이머(하트비트 주기·초기 공지 주기 등)가 ~160~190ms를 만드는지, 그리고 KEEP_LAST 축출이 매칭 전후로 다르게 동작하는지는 이번에도 규명하지 못했다(Fast DDS 소스 레벨 계측이 필요, 미시도). 원본:
tools/gap_harness/gap198/qos_depth20_period_sweep/(pkg/·run_period_sweep.sh·analyze.py·results/·findings_summary.txt).
함정: 컨테이너에 묶으면 하나가 종료될 때 함께 종료된다. 격리가 필요한 것(안전 감시·드라이버)은 묶지 말라. 그리고 보안 enclave가 병합된다(13.2.4)는 점도 고려해야 한다.
14.1.8 경계 재검토
경계는 영원하지 않다.
| 재검토 신호 | 의미 |
|---|---|
| 토픽이 계속 늘어남 | 경계가 잘못됨 |
| 두 노드를 항상 함께 수정 | 합칠 때 |
| 지연 예산 초과 | 합치거나 컨테이너화 |
| 한 노드가 계속 커짐 | 나눌 때 |
| 재사용이 안 일어남 | 분할 이유 소멸 |
| 조직 변화 | ⑥ 기준 변경 |
변경 방법:
| 방법 | 설명 |
|---|---|
| 컨테이너화 | 코드 변경 없이 지연 개선(2.7) |
| 인터페이스 유지하며 내부 통합 | 점진적 |
| 새 인터페이스 병행 운영 | 이행 기간(15.1) |
| 폐기 예고 | 사용자에게 시간 |
견해: 경계를 바꿀 때 인터페이스를 먼저 안정화하라. 내부 구조를 바꾸면서 인터페이스도 바꾸면 문제가 겹쳐 진단이 어렵다. 한 번에 하나씩(11.1.7의 원칙과 같다).
견해: "컴포넌트화로 먼저 시도"가 위험이 가장 낮다. 노드를 합치는 것은 코드 변경이지만, 컨테이너에 넣는 것은 launch 변경이다. 지연 문제라면 이것부터 해 보고, 그래도 부족하면 구조를 바꾼다.
14.1.9 함정 카탈로그
| # | 함정 | 증상 | 대처 |
|---|---|---|---|
| 1 | 크기로 분할 판단 | 자의적 경계 | 책임으로 판단 |
| 2 | 과도한 분할 | 지연·디버깅 비용 | 경계 비용 인식 |
| 3 | 과다 책임 노드 | 시험·변경 어려움 | 책임 분리 |
| 4 | 경계에 토픽 다수 | 잘못된 경계 | 재검토 |
| 5 | 내부 상태를 토픽으로 | 캡슐화 파괴 | 디버그 구분 |
| 6 | 인터페이스 미문서화 | 사용·교체 불가 | 계약 문서화 |
| 7 | QoS를 인터페이스에서 누락 | 조용한 미연결 | 포함 |
| 8 | 초기화 순환 | 서로 대기 | 생애주기(1.12) |
| 9 | tf를 여러 노드가 발행 | 충돌 | 단일 발행자 |
| 10 | 안전 감시를 컨테이너에 | 격리 상실 | 독립 프로세스 |
| 11 | 지연 위해 성급히 합침 | 구조 훼손 | 컨테이너화 먼저 |
| 12 | 조직 경계를 영구 고정 | 부적합 잔존 | 재검토 |
| 13 | 컨테이너화로 enclave 병합 간과 | 권한 확대 | 보안 고려 |
14.1.10 체크포인트
- 노드 분할로 얻는 것 일곱 가지와 잃는 것 여섯 가지를 말할 수 있는가.
- 가장 자주 충돌하는 두 축과, 그 절충의 중간항은?
- "분할의 실제 비용"은 무엇인가.
- 경계 판단의 여섯 축과, 가장 강한 기준은?
- 주기 차이가 ROS 2에서 특히 실질적인 이유는?
- 조직 기준으로 나눈 경계를 다룰 때의 주의는?
- 나누지 말아야 할 신호 다섯 가지를 들 수 있는가.
- 결합의 징후 다섯 가지와, "토픽이 열 개"가 의미하는 것은?
- 순환 의존이 언제 문제이고 언제 아닌가.
- "내부 상태를 토픽으로 노출"이 왜 위험한가.
- 인터페이스 선택 흐름과, 설계 원칙 여섯 가지는?
- "구현이 아니라 계약으로 설계하라"의 의미와 이점은?
- 흔한 분할 패턴 일곱 가지와, 가장 보편적으로 유용한 것은?
- 안전 감시 노드를 독립시켜야 하는 이유는?
- "과도한 분할"이 로봇에서 특히 문제인 이유는?
- 논리적 분할과 물리적 배치를 분리하라는 조언의 이점은?
- 그룹 짓는 다섯 기준을 말할 수 있는가.
- 경계 재검토 신호 여섯 가지와, 위험이 가장 낮은 변경 방법은?
다음 장 14.2는 나눈 노드들을 하나의 시스템으로 움직이게 하는 문제 — 모드·부팅·감시를 다룬다.