제8부 항법
8.7 복구·상태관리·안전
8.7.1 이 장에서 배우는 것 / 선수 지식
8.1.9에서 "Nav2를 쓰면 안전하다는 오해가 위험하다" 고 했다. 이 장이 그 문장을 구체화한다.
Nav2는 안전을 위한 여러 장치를 제공한다 — 복구 동작(8.6.5), collision_monitor, 속도 평활기, 속도 제한 필터. 이것들은 유용하고, 잘 쓰면 시스템이 훨씬 견고해진다. 그러나 이것들은 "센서가 본 것을, 소프트웨어가 정상 동작할 때, 최선을 다해" 피하는 장치이며, 기능 안전(functional safety) 요구를 만족시키지 않는다.
이 장은 Nav2가 제공하는 것을 잘 쓰는 법과 그것으로 부족한 지점을 함께 다룬다.
배우는 것:
- 복구 동작 카탈로그 — 각각의 용도와 위험.
collision_monitor— Nav2의 안전 계층.- 속도 평활기 — 왜 필요한가.
- 속도 제한·금지 구역 — 코스트맵 필터.
- 상태 관리 — 항법 실패를 시스템이 어떻게 다루는가.
- 안전 계층 설계 — 무엇을 어디에 둘 것인가.
- 실패 모드 카탈로그 — 무엇이 어떻게 잘못되는가.
선수 지식: 8.6(BT·복구), 8.4(코스트맵), 8.5(컨트롤러), 6.5.6(cmd_vel_timeout), 6.6(실시간), 6.9(진단). 안전 표준은 14.10에서 이어받는다.
정직:
collision_monitor·속도 필터의 파라미터는 배포판 의존이며(확인 필요), 일부 기능(도킹 등)은 비교적 최근 추가되었다. 안전 관련 서술은 특히 신중히 읽기 바란다 — 이 책은 안전 인증에 관한 조언을 제공하지 않으며, 인증이 필요한 시스템은 해당 표준과 전문가의 판단을 따라야 한다(14.10).
검증 상태(추가): 129번째 회차(150차 갭 점검)까지 이 장은 실행 검증이 전혀 없었다(목차 3). 150차가
nav2_collision_monitor·nav2_velocity_smoother를 실제로 lifecycle 기동해stop·slowdown·source_timeout·가속·감속·velocity_timeout워치독을 실측했다 — 8.7.3·8.7.4를 보라. 그 밖의 절(복구 동작 카탈로그·코스트맵 필터·상태 관리·안전 계층 설계·실패 모드 카탈로그·운영 관행)은 여전히 미검증이며 위 고지가 그대로 적용된다.
8.7.2 복구 동작 카탈로그
behavior_server가 제공하는 동작들이다(8.1.2).
| 동작 | 액션 | 용도 | 위험 |
|---|---|---|---|
Spin | 제자리 회전 | 센서 사각 해소·측위 개선 | 주변에 사람·물체 |
BackUp | 후진 | 막다른 곳 탈출 | 후방 미관측 |
Wait | 대기 | 동적 장애물 통과 | 무한 대기 |
DriveOnHeading | 지정 방향 직진 | 정밀 기동 | 경로 무시 |
AssistedTeleop | 보조 수동 조작 | 사람 개입 | — |
behavior_server:
ros__parameters:
behavior_plugins: ["spin", "backup", "drive_on_heading", "wait"]
spin:
plugin: "nav2_behaviors::Spin"
backup:
plugin: "nav2_behaviors::BackUp"
wait:
plugin: "nav2_behaviors::Wait"
local_frame: odom # ★ odom 기준(5.1.7)
global_frame: map
robot_base_frame: base_link
simulate_ahead_time: 2.0 # 충돌 예측 시간
max_rotational_vel: 1.0
min_rotational_vel: 0.4
rotational_acc_lim: 3.2
복구 동작도 충돌 검사를 한다 — simulate_ahead_time 동안의 궤적을 코스트맵에서 검사해 충돌이 예상되면 중단한다.
함정: 복구 동작의 충돌 검사는 코스트맵에 의존한다(8.4). 후방에 센서가 없으면 코스트맵의 뒤쪽은 미지이거나 오래된 정보이며,
BackUp이 안전하다고 판단해도 실제로는 아닐 수 있다.
견해: 후방 센서가 없는 로봇에서
BackUp을 복구에 넣을지는 신중히 판단하라. 넣는다면 거리를 매우 짧게(0.1~0.2 m), 속도를 느리게 하고, 범퍼 같은 접촉 센서를 함께 두는 것이 안전하다. 뒤에 사람이 있을 수 있는 환경이라면 빼는 편이 낫다.
견해:
Spin도 무해하지 않다. 좁은 곳에서 길쭉한 로봇이 회전하면 주변을 칠 수 있고(8.4.8의 풋프린트 문제), 사람 근처에서 갑자기 회전하는 것은 놀라게 한다. 회전 속도를 낮추고, 사람 밀집 환경에서는Wait를 우선하는 것을 검토한다.
8.7.3 collision_monitor
Nav2의 최종 안전 계층이다. /cmd_vel이 로봇에 도달하기 직전에 개입한다(8.1.3).
핵심: 코스트맵을 거치지 않고 센서를 직접 본다. 코스트맵의 지연·처리 오류와 무관하게 동작하므로 더 신뢰할 수 있다.
collision_monitor:
ros__parameters:
base_frame_id: "base_footprint"
odom_frame_id: "odom"
cmd_vel_in_topic: "cmd_vel_smoothed"
cmd_vel_out_topic: "cmd_vel"
state_topic: "collision_monitor_state"
transform_tolerance: 0.2
source_timeout: 1.0
stop_pub_timeout: 2.0
polygons: ["PolygonStop", "PolygonSlow"]
PolygonStop:
type: "polygon"
points: "[[0.4, 0.3], [0.4, -0.3], [0.0, -0.3], [0.0, 0.3]]"
action_type: "stop"
min_points: 4
visualize: true
polygon_pub_topic: "polygon_stop"
PolygonSlow:
type: "polygon"
points: "[[0.8, 0.4], [0.8, -0.4], [0.0, -0.4], [0.0, 0.4]]"
action_type: "slowdown"
slowdown_ratio: 0.3
min_points: 4
visualize: true
observation_sources: ["scan"]
scan:
type: "scan"
topic: "/scan"
enabled: true
세 가지 동작 유형:
action_type | 동작 |
|---|---|
stop | 즉시 정지 |
slowdown | 속도를 비율로 감소 |
limit | 절대 속도 상한 |
approach | 충돌까지 시간 기반(동적) |
approach 방식이 흥미롭다 — 고정 영역이 아니라 현재 속도로 진행할 때 충돌까지의 시간을 계산해 판단한다. 빠를 때는 멀리, 느릴 때는 가까이 볼 수 있다.
견해:
stop영역과slowdown영역을 겹쳐 두는 것이 좋은 구성이다. 멀리서 감속하고 가까우면 정지하므로, 급정지가 줄고 승차감·안정성이 좋아진다. 영역 크기는 정지 거리에서 역산한다.
$$d_{\text{stop}} \geq \frac{v_{\max}^2}{2a_{\max}} + v_{\max} \cdot t_{\text{지연}} + \text{여유}$$
t_지연에 센서·처리·통신 지연이 들어간다(7.7.8). 1 m/s, 감속 1 m/s², 지연 0.3초면 $0.5 + 0.3 + \alpha \approx 1$ m 내외다.
함정:
min_points가 너무 작으면 노이즈에 정지하고, 크면 얇은 장애물을 놓친다. 라이다 노이즈 특성과 감지하려는 물체 크기에 맞춰 정한다.
함정:
source_timeout이 중요하다. 센서가 끊기면 아무것도 안 보이므로 "안전하다"고 판단할 수 있다. 이 타임아웃이 있으면 센서 두절 시 정지한다 — 반드시 설정하고 동작을 시험하라.
견해:
collision_monitor는 Nav2에서 가장 값어치 있는 안전 장치다. 코스트맵·플래너·컨트롤러가 모두 실패해도 마지막에 막아 준다. 다만 이것도 소프트웨어이며, 노드가 종료되면 동작하지 않는다 — 8.7.7의 계층 설계가 필요한 이유다.
실행 확인(150차, Jazzy) —
stop·slowdown·source_timeout세 동작을 하나의 연속 시나리오로 실제 기동해 확인했다. 이 장은 129번째 회차까지 실행 검증이 전혀 없던 장이었다(목차 3).nav2_collision_monitor(collision_monitor실행 파일)를PolygonStop([0.0, 0.4]×[-0.3, 0.3],min_points: 1)·PolygonSlow([0.0, 0.8]×[-0.4, 0.4],slowdown_ratio: 0.3)로 실제 lifecycle 기동하고,base_footprint프레임으로 직접 발행한 합성LaserScan(TF 변환 없이 스캔 프레임을 코스트맵 프레임과 일치시켜 최소 구성)과cmd_vel_in(0.5 m/s 고정)을 입력해cmd_vel_out·collision_monitor_state를 26초간 연속 기록했다.
- t≈0~7s(장애물 없음):
cmd_vel_out=cmd_vel_in= 0.5(그대로 통과) — 문서화된 통과 동작 확인.- t=7.4s: 전방 0.2m에 점을 넣어
PolygonStop에 진입 →collision_monitor_state에action_type=STOP, polygon_name=PolygonStop이 즉시 발행됐고, 다음 스캔 주기부터cmd_vel_out이 정확히 0.0으로 떨어졌다.- t=13.8s: 점을 0.6m로 옮겨
PolygonStop을 벗어나고PolygonSlow만 걸리게 함 →action_type=SLOWDOWN, polygon_name=PolygonSlow발행,cmd_vel_out= 0.15 —cmd_vel_in(0.5) ×slowdown_ratio(0.3)와 소수점까지 정확히 일치했다. "속도를 비율로 감소"라는 본문 서술이 정량적으로 확인됐다.- t≈17s부터 스캔 발행 노드를 완전히 강제 종료해 센서 두절을 흉내 냈다 →
source_timeout: 2.0설정에서 약 4초 뒤(t=21.1s)action_type=STOP, polygon_name="invalid source"가 발행되고cmd_vel_out이 다시 0.0이 됐다 — "센서가 끊기면 정지한다"는 위 함정 서술이 실측으로 확인됐다. (정확히 2.0초가 아니라 약 4초 걸린 이유는 마지막 유효 스캔의 헤더 타임스탬프 기준 노화 판정과 20Hz 처리 주기가 겹친 것으로 추정되며, 소스 수준까지 확정하지는 않았다(확인 필요).)부수 발견 —
stop_pub_timeout이 "얼마나 오래 정지 명령을 재발행하는가"를 결정하며, 그 이후엔cmd_vel_out을 전혀 발행하지 않는다(소스 확인).collision_monitor_node.cpp의publishVelocity()를 읽으니, 요청 속도가 0이고 직전 사이클도 이미 0이었다면this->now() - stop_stamp_ > stop_pub_timeout_을 검사해 초과 시return으로 발행 자체를 건너뛴다 — 즉 정지 상태가stop_pub_timeout(이번 설정 2.0초)보다 오래 지속되면 더 이상 0 속도 명령조차 다시 보내지 않는다. 실측(별도 시험)에서도 정지 진입 후 약 0.7~2초 구간만cmd_vel_out이 나오고 이후 완전히 잠잠해지는 것을 확인했다. 이는 8.7.4의 "이중 워치독" 조언이 왜 필요한지의 정확한 메커니즘이다 —collision_monitor가 정지를 계속 재확인해 주지 않으므로, 하류(컨트롤러·ros2_control)가 자체 타임아웃 없이 "마지막으로 받은 명령을 무한정 유지"하는 방식이면 이론상 위험이 있다(이번 시험에서는 마지막 명령이 우연히 0이었으므로 실제로는 안전했다).collision_monitor_state는 TRANSIENT_LOCAL이 아니라 상태 전환 시에만 1회 발행되는 일반 QoS(depth 1)임도 소스로 확인했다 — 149차가costmap_raw에서 찾은 "늦은 구독자가 캐시된 과거값을 받는" 문제와는 반대로, 여기서는 늦게 구독하면 과거 전환 이벤트를 완전히 놓친다(재전송이 없다). 원시 데이터는tools/gap_harness/gap150/data/cm_events_scenario.json. 목차 3.
8.7.4 속도 평활기
급격한 속도 변화를 완만하게 만든다.
velocity_smoother:
ros__parameters:
smoothing_frequency: 20.0
scale_velocities: false
feedback: "OPEN_LOOP" # 또는 "CLOSED_LOOP"(odom 사용)
max_velocity: [0.5, 0.0, 2.0] # [vx, vy, wz]
min_velocity: [-0.5, 0.0, -2.0]
max_accel: [2.5, 0.0, 3.2]
max_decel: [-2.5, 0.0, -3.2]
odom_topic: "odom"
odom_duration: 0.1
velocity_timeout: 1.0
필요한 이유:
| 문제 | 평활기가 해결 |
|---|---|
| 컨트롤러 출력이 급변한다 | 가속 한계 적용 |
| 급정지로 짐이 넘어짐 | 감속 제한 |
| 모터·기구 부담 | 부드러운 변화 |
| 승차감(사람 탑승) | 동일 |
함정:
velocity_timeout이 워치독이다(1.4.8). 명령이 끊기면 정지한다.diff_drive_controller의cmd_vel_timeout(6.5.6)과 중복되지만 둘 다 있는 것이 안전하다 — 계층이 다르므로.
함정: 평활기의 한계와 컨트롤러(8.5)·ros2_control(6.5)의 한계가 일관되어야 한다. 세 곳에서 다른 값을 쓰면 어디서 잘리는지 예측하기 어렵다. 한 곳에서 정하고 나머지는 그보다 느슨하게 두는 것이 명확하다.
견해:
feedback: CLOSED_LOOP는 실제 속도를 반영해 더 정확하지만 오도메트리에 의존한다. 오도메트리가 부정확하면(7.6) 오히려 악화될 수 있으므로,OPEN_LOOP로 시작하고 필요할 때 전환하는 편이 안전하다.
실행 확인(150차, Jazzy) — 가속·감속 제한과
velocity_timeout워치독을 실제로 기동해 확인했다.nav2_velocity_smoother(OPEN_LOOP,max_accel: [2.5, 0, 3.2],max_decel: [-2.5, 0, -3.2],velocity_timeout: 1.0,smoothing_frequency: 20.0)를 lifecycle 기동하고,/cmd_vel에 0.5 m/s를 6초간 발행한 뒤 완전히 끊어(발행 노드 자체를 강제 종료함)/cmd_vel_smoothed를 12초간 연속 기록했다.
- 가속 램프: 0에서 0.5 m/s까지 0.25초 만에 도달(
0.125 → 0.5m/s로 관측) —max_accel: 2.5m/s²에서 이론값 0.2초와 오차 범위 내로 일치.- 입력이 끊긴 뒤에도
cmd_vel_smoothed는 즉시 0이 되지 않고, 마지막 값(0.5)을 계속 유지했다 — 정확히 약 0.97초 뒤(velocity_timeout: 1.0과 거의 일치)부터 값이 떨어지기 시작했다.- 감속도 즉시 끊는 것이 아니라
max_decel램프를 그대로 썼다 — 0.5 → 0.375 → 0.25 → 0.125 → 0.0으로 0.15초에 걸쳐 매끈하게 내려갔다(max_decel: -2.5m/s²에서 이론값 0.2초와 근사).즉, "명령이 끊기면 정지한다"는 위 함정의 설명은 정확하지만, 정지 방식이 "즉시 0으로 끊기"가 아니라 "
velocity_timeout만큼 마지막 명령을 유지 → 이후max_decel램프로 매끈하게 감속"이라는 점은 본문에 없던 세부 사항이다 — 급정지에 의한 짐 넘어짐(위 표의 두 번째 문제)을 워치독 발동 시점에도 계속 방지하도록 설계돼 있다. 원시 데이터는tools/gap_harness/gap150/data/vs_events.json. 목차 3.
8.7.5 코스트맵 필터 — 속도 제한·금지 구역
지도 기반으로 구역별 규칙을 적용한다(8.4.3).
# 필터 정보 서버
costmap_filter_info_server:
ros__parameters:
type: 1 # 0=keepout, 1=speed(비율), 2=speed(절대)
filter_info_topic: "costmap_filter_info"
mask_topic: "speed_filter_mask"
base: 100.0
multiplier: -1.0 # (→ 155차 정정: 이 값 그대로면 마스크 100(완전 제한) 셀에서 SpeedFilter가 절대 발행하지 않는다 — 아래 실행 확인 참고)
# 마스크를 발행하는 map_server
filter_mask_server:
ros__parameters:
yaml_filename: "speed_mask.yaml"
# 코스트맵에 레이어 추가
global_costmap:
global_costmap:
ros__parameters:
plugins: ["static_layer", "obstacle_layer", "inflation_layer"]
filters: ["speed_filter"]
speed_filter:
plugin: "nav2_costmap_2d::SpeedFilter"
filter_info_topic: "costmap_filter_info"
speed_limit_topic: "speed_limit"
| 필터 | 용도 |
|---|---|
KeepoutFilter | 진입 금지 구역(위험 지역·사유지) |
SpeedFilter | 구역별 속도 제한(사람 통행로·좁은 곳) |
BinaryFilter | 이진 상태 전환(구역 진입 알림 등)(확인 필요) |
마스크는 지도와 같은 형식의 이미지다. 이미지 편집기로 구역을 칠해 만든다(8.2.2).
견해: 속도 제한 구역이 실무에서 매우 유용하다. 사람이 많은 복도, 교차로, 좁은 문을 저속 구역으로 지정하면 안전성이 크게 오른다. 로봇을 전체적으로 느리게 하는 것보다 구역별로 조절하는 편이 효율과 안전을 함께 얻는다.
함정: 금지 구역은 "가지 않는다"이지 "못 간다"가 아니다. 로봇이 이미 그 안에 있거나 밀려 들어가면 나올 방법이 필요하다. 탈출 로직을 고려해야 한다.
실행 확인(155차, Jazzy) —
KeepoutFilter는 마스크→치명적 장애물 변환이 셀 단위로 정확히 확인됐고,SpeedFilter는 이 절 예제의multiplier: -1.0이 실제로는 절대 발행되지 않는 값임이 실측으로 드러났다. 이 절은 154번째 회차까지 실행 검증이 전혀 없었다(목차 3).filter_mask_server(nav2_map_server::MapServer)·costmap_filter_info_server·global_costmap(nav2_costmap_2d노드)을 실제 lifecycle로 기동해KeepoutFilter·SpeedFilter를 각각 검증했다.
KeepoutFilter: 20×20셀(400셀) 마스크 구역이 코스트맵에서 정확히LETHAL_OBSTACLE(254)로 표시됐다 — 좌표 단위로 400/400셀 일치, 오차 0, 구역 밖 오탐 0.(→ 161차 정정: 이 결과 자체는 저장된 원시 데이터로 재확인해 사실이다. 다만 161차가 이 절의 스택 시험을 위해 같은 구성을 그대로(바이트 단위로) 재현하려 하자 다섯 차례 모두 실패했다 — 재현성 문제를 발견했다. 아래 실행 확인 참고.)- 함정(문서화되지 않음) — 코스트맵 노드를 네임스페이스로 실행하면(
-r __ns:=/global_costmap등) 필터의 상대 토픽 이름이 그 네임스페이스 아래로 resolve된다.filter_info_topic: "costmap_filter_info"(상대 이름)를 그대로 쓰면costmap_filter_info_server(비-네임스페이스)가 발행하는/costmap_filter_info가 아니라/global_costmap/costmap_filter_info를 구독하게 되어 아무것도 받지 못한다(ros2 topic info --verbose로 한쪽은 발행자 1/구독자 0, 다른 쪽은 발행자 0/구독자 1임을 직접 확인). 앞에 슬래시를 붙인 절대 경로(/costmap_filter_info)로 쓰면 해결된다.SpeedFilter: 위 예제 값(base: 100.0, multiplier: -1.0) 그대로는 마스크 값 100(완전 제한) 셀에서 절대 발행되지 않는다.filter_values.hpp의NO_SPEED_LIMIT = 0.0(미발행을 뜻하는 sentinel)과speed_limit_prev_의 초기값이 같은 0.0이고,speed_filter.cpp의process()는speed_limit_ = mask×multiplier+base를 계산해speed_limit_prev_와 다를 때만 발행한다. $100\times(-1.0)+100.0 = 0.0$이 정확히 sentinel과 같아speed_limit_ != speed_limit_prev_비교가 항상 거짓이 된다. 실측: 482회의process()호출 모두speed_mask_data=100이었지만 발행은 0건(speed_limit_events_final.json=[]).multiplier: -0.5로만 바꾸면(speed_limit_=50.0, sentinel과 다름) 동일 마스크·동일 시나리오에서SpeedLimit{percentage:true, speed_limit:50.0}이 정확히 계산대로 1회 발행됐다./speed_limit은 TRANSIENT_LOCAL이 아니라 VOLATILE(ros2 topic info --verbose로 확인)에, 상태 전환 시 1회만 발행되는 edge-triggered 토픽이다 — 149·150차가costmap_raw·collision_monitor_state에서 찾은 것과 같은 계열의 함정으로, 구독자가 발행 시점보다 늦게 참여하면 그 발행을 영구히 놓친다(재전송 없음). 실제로 첫 시도에서 이 때문에 "발행이 전혀 없다"는 오진단을 했었고, 구독 시작을 활성화 이전으로 옮기고서야 실제 원인(아래)과 이 결과가 둘 다 검출됐다.- 부수 발견(가장 깊은 원인) — 코스트맵 필터는
filters:목록에 넣는 것만으로 실행이 보장되지 않는다.layered_costmap.cpp의LayeredCostmap::updateMap()은 모든 플러그인·필터의updateBounds()가 누적한 갱신 범위(x0,xn,y0,yn)가 비어 있거나 뒤집혀 있으면(xn < x0 || yn < y0) 플러그인·필터 갱신 전체를 건너뛰고 즉시 반환한다. 그런데CostmapFilter::updateBounds()(KeepoutFilter/SpeedFilter가 상속하는 베이스 구현,costmap_filter.cpp)는 갱신 범위를 전혀 변경하지 않는다 — 로봇 pose만 기록할 뿐이다. 즉plugins:에 매 주기 갱신 범위를 실제로 넓히는 다른 레이어(예:footprint_clearing_enabled: true인ObstacleLayer— 매 주기 로봇 발자국 주변을 무조건 touch한다)가 없으면, 필터의process()는 지도 최초 로드 이후 다시는 호출되지 않는다.plugins: ["static_layer"]만으로 직접 계측해 확인: 71주기(≈8초, 5Hz) 연속으로x0=99 xn=1 y0=99 yn=1(범위 역전) →process() called0건.obstacle_layer를 추가한 뒤에는x0=64 xn=66 y0=64 yn=66(정상 범위)로 바뀌고process()가 매 주기 정상 호출됐다. 이 절의 예제는plugins: ["static_layer", "obstacle_layer", "inflation_layer"]로 이미obstacle_layer를 포함해 이 문제를 우연히 피해 가지만, 최소 재현을 위해static_layer만 남기고 필터를 시험하면(자연스러운 단순화) 필터가 조용히 전혀 동작하지 않는 상황에 빠진다 — 소스까지 내려가지 않으면 원인을 찾기 매우 어려운 함정이다. 원시 로그·계측 소스는tools/gap_harness/gap155/(src_instrumented/layered_costmap.instrumented.cpp·logs/bg_costmap_speed.log·logs/final_costmap_speed.log·data/speed_limit_events_positive.json). 목차 3.실행 확인(161차, Jazzy) —
KeepoutFilter+SpeedFilter동시 스택을 시험하려다, 155차의 단일KeepoutFilter결과 자체가 재현되지 않는다는 더 근본적인 문제를 발견했다. 두 개의 서로 다른 마스크 구역(키프아웃 60-79,60-79 / 스피드 20-39,20-39)과 두 개의costmap_filter_info_server(각각type=0·type=1)로 두 필터를 같은filters: [...]목록에 넣어 동시에 실행하는 구성을 만들었다.
- 구성 자체의 버그 둘을 먼저 찾아 고쳤다. ① 구동 스크립트가
wait(인자 없이)로 끝나 있었는데,switch_after=9999로 무한히 실행되는moving_tf_publisher.py까지 포함해 모든 백그라운드 작업을 기다리는 바람에 실측 자체는 이미 끝났는데도 스크립트가 영원히 멈춰 있었다 — 특정 PID만wait하도록 고쳤다(방법론 교훈, 아래). ②static_layer의map_topic: "base_map"(상대 이름)이 코스트맵의-r __ns:=/global_costmap아래로 resolve돼map_server_base가 실제로 발행하는/base_map과 어긋나 있었다 — 155차가 필터 토픽에서 고친 것과 같은 종류의 함정이 스태틱 레이어의 맵 토픽에도 있었던 것이다. 절대 경로(/base_map)로 고치니 "Can't update static costmap layer, no map received" 경고가 사라졌다.- 두 버그를 고친 뒤에도 코스트맵이 계속 전부 0(빈 셀)이었다 — 키프아웃 구역도 254로 표시되지 않았고,
/speed_limit도 0건 발행됐다. 12회 연속 다중 샘플링(12초)으로 확인해 일회성 캐시 문제가 아님을 배제했다.- 다섯 가지 독립적인 재현 시도를 했고, 전부 똑같이 실패했다. ① 이번 회차의 2-필터 구성. ② 155차의 원본
speed_params.yaml(단일 필터, apt 패키지nav2_costmap_2d1.3.12)을 수정 없이 그대로. ③ 같은 구성을navigation2jazzy브랜치를 새로 클론해 빌드한 것으로(155차가 실제로 썼던 것과 같은 소스 취득 방법, 패키지 버전 회귀 가능성을 배제) — 12회 연속 0. ④ 155차의keepout_params.yaml과run_keepout.sh를 바이트 단위로 그대로 재생 — 로그에 "no map received"도 "filter mask was not received"도 없이 맵·마스크·필터 정보 전부 정상 수신됐다고 출력됐는데도 코스트맵은 여전히 전부 0이었다. ⑤ 같은 바이트 단위 재생에obstacle_layer를 추가(이 절 바로 위 "가장 깊은 원인" 발견의 그 수정)해도 6회 연속 0 — bounds-gating 가설로도 설명이 안 됐다.- 노드 자체는 종료되지 않았다.
ros2 topic hz /global_costmap/published_footprint가 정확히 5.000Hz를 꾸준히 보고했고ros2 lifecycle get도active [3]을 반환했다 — 메인 루프는 정상 작동 중인데updateMap()의 실제 갱신 내용만 계속 비어 있었다.- 정직한 결론 — 155차의 결과 자체는 사실이다(원시 데이터
tools/gap_harness/gap155/data/keepout_dump.json을 이번 회차에 다시 읽어 400/400을 직접 재확인했다), 하지만 재현되지 않는다. 다섯 번의 독립 시도(패키지 소스·git 소스·바이트 단위 재생·재생+ObstacleLayer 포함) 모두 이번 회차의 컨테이너에서는 실패해, 정확히 무엇이 성공/실패를 가르는지(타이밍·스케줄링·컨테이너별 차이 등으로 추정되나 확정하지 못함) 규명하지 못했다(확인 필요, 다음 회차 후보)(→ 162차 정정: 15회 반복 시험으로 빈도를 측정했다 — 15/15 실패, 아래 실행 확인 참고). 이 절의 KeepoutFilter·SpeedFilter 개별 결과를 이 책에서 폐기하지는 않는다(원시 데이터가 사실이므로) — 다만 이 시험 절차가 안정적으로 재현 가능하다고 더는 단정하지 않는다.- 부수적 방법론 교훈 — 백그라운드 작업이 있는 스크립트에서 인자 없는
wait는 위험하다. 장시간 실행되는(또는 무한히 실행되는) 백그라운드 프로세스가 하나라도 있으면 스크립트가 실측을 다 마치고도 멈춰 버린다. 실측에 필요한 특정 PID만wait $PID로 기다리고, 나머지는 명시적으로kill하는 편이 안전하다.원시 데이터·로그는
tools/gap_harness/gap161/(data/reproducibility_comparison.txt(5개 시도 비교)·data/stacked2_costmap_dump.json·data/ctrl_costmap_dump.json·data/exact_keepout_dump.json·data/obs_dump.json,logs/(40개),scripts/(구동 스크립트·설정 18종)). 목차 3.실행 확인(162차, Jazzy) — 161차가 남긴 "재현 실패가 흔한가 드문가"라는 질문에 반복 시험으로 답했다: 흔치 않은 정도가 아니라, 20번 시도 중 단 한 번도 재현되지 않았다. 155차의
keepout_params.yaml을 완전히 무수정으로, 매번 새ROS_DOMAIN_ID(201~215)와 새 프로세스 집합으로 15회 반복 실행했다.
- 결과: 15/15 전부 실패(키프아웃 구역 0/400, 코스트맵 전체 0/10000 셀 — 예외 없음). 161차의 독립적인 5회 실패(다른 컨테이너, 다른 날)와 합치면 20/20 — 두 회차, 최소 세 개의 서로 다른 컨테이너에 걸쳐 155차의 성공이 단 한 번도 재현되지 않았다.
- 새로운 진단 단서 — 이번엔 완전히 "조용한" 실패였다. 15회 전부 로그에
"Resizing costmap to 100 X 100..."(StaticLayer가 수신한 지도 메시지를 실제로 처리했다는 표시)이 단 한 번도 나타나지 않았다 — 그런데 160차/161차가 찾았던"Can't update static costmap layer, no map received"경고 역시 단 한 번도 나타나지 않았다(grep -l 'no map received'0/15).map_server_base는 매번 지도를 정상적으로 읽고 발행했다고 로그에 남겼고,KeepoutFilter도 매번 필터 정보·마스크를 정상 수신했다고 로그에 남겼다 — 모든 구성 요소가 "성공"을 보고하는데,static_layer의 지도 콜백만 아무 흔적 없이 호출되지 않는다.- 범위를 의도적으로 좁혔다. 코디네이터의 조언대로 이번 회차는 정확한 근본 원인(
LayeredCostmap::updateMap()재계측 등)을 더 조사하지 않고, 빈도 통계로 질문에 답하는 데서 멈췄다 — 161차가 이미 ObstacleLayer 추가·git 소스 재빌드 등 여러 가설을 시도해 전부 반증했으므로, 추가 가설 없이 반복만 하는 것이 이번 회차의 가장 효율적인 기여라고 판단했다.- 정직한 결론 — 재현 실패는 드문 우연이 아니라 사실상 항상 일어나는 현상으로 보인다. 근본 원인은 여전히 열려 있지만
(확인 필요), 이 절의 시험 절차를 "때때로 실패할 수 있다"가 아니라 "현재 알려진 방법으로는 사실상 재현되지 않는다"로 서술을 바꾸는 것이 정직하다.원시 데이터·로그는
tools/gap_harness/gap162/(data/frequency_analysis.txt·data/trial_results.txt(15회 원시 결과)·data/trial*_dump.json(15개),logs/(90개, 트라이얼당 5개 노드 로그)). 목차 3.
8.7.6 상태 관리 — 실패를 시스템이 다루기
Nav2가 실패했을 때 무엇을 할 것인가는 Nav2 밖의 문제다.
1.8.5에서 ABORTED와 CANCELED를 구분하라고 한 것이 여기서 중요하다.
| 결과 | 의미 | 대응 |
|---|---|---|
SUCCEEDED | 도착 | 다음 작업 |
ABORTED | 항법 실패 | 재시도·대안·사람 |
CANCELED | 취소됨(선점 포함 — 8.1.5) | 실패로 처리하지 말 것 |
실패 원인 파악:
# 결과 코드로 원인 구분 (확인 필요: 필드·값)
result = future.result()
if result.status == GoalStatus.STATUS_ABORTED:
# error_code 로 원인 확인
self.get_logger().error(f'항법 실패: {result.result.error_code}')
견해: 상위 시스템이 항법 실패에 대한 정책을 가져야 한다(14.2). "3회 실패하면 다른 경로", "5회 실패하면 사람 호출" 같은 규칙이 없으면 로봇이 같은 곳에서 무한히 시도한다. Nav2의 BT 재시도(8.6.4)는 짧은 시간의 국소 복구이고, 그 위의 정책은 별개다.
진행 감시:
| 지표 | 활용 |
|---|---|
distance_remaining | 진전 여부 |
number_of_recoveries | 어려움 정도(8.6.8) |
navigation_time | 예상 대비 지연 |
| 측위 신뢰도 | 근본 원인(8.3.8) |
견해: 이 지표들을 진단으로 내보내고 임계를 두라(6.9). "평소 30초 걸리는 경로가 3분째"는 문제 신호이며, 사람이 개입할 근거가 된다.
8.7.7 안전 계층 설계
이 절이 이 장의 핵심이다.
| 계층 | 제공 | 실패 시 |
|---|---|---|
| ① 계획 | Nav2 | 부적절한 경로 |
| ② 제어 | Nav2 | 충돌 시도 |
| ③ 감시 | Nav2(collision_monitor) | 감속·정지 안 함 |
| ④ 워치독 | ros2_control·평활기 | 명령 유지 |
| ⑤ 하드웨어 | 직접 구축 | 최후 방어선 |
①~④가 전부 소프트웨어다. 노드가 종료되거나, OS가 멈추거나, 전원이 불안정하면 동작하지 않는다.
견해: ⑤ 하드웨어 계층이 없는 시스템은 안전 논증을 세울 수 없다. 비상 정지 버튼이 소프트웨어를 거치면 소프트웨어가 종료됐을 때 무용하다 — 모터 전원을 직접 차단하는 회로여야 한다. 범퍼·안전 라이다도 마찬가지로 인증된 하드웨어 경로를 갖는 제품이 있다.
각 계층이 다루는 실패:
| 실패 | 막는 계층 |
|---|---|
| 부적절한 경로 계획 | ② 컨트롤러 |
| 코스트맵 오류(유령·누락) | ③ 감시 |
| 컨트롤러 버그 | ③ 감시 |
| 감시 노드 크래시 | ④ 워치독(명령 끊김 → 정지) |
| 전체 소프트웨어 정지 | ⑤ 하드웨어 |
| 전원·통신 이상 | ⑤ 하드웨어 |
| 센서가 못 보는 것(유리·계단·낮은 물체) | ⑤ 하드웨어(범퍼·낭떠러지 센서) |
마지막 항목이 중요하다. 라이다가 유리벽을 통과하고, 낮은 장애물을 놓치고, 계단 아래를 못 본다. 센서 구성으로 못 보는 것이 무엇인지 명시하고, 그것을 다른 수단으로 덮어야 한다.
견해: "우리 센서가 못 보는 것"의 목록을 만들라. 유리·거울·검은 물체·낮은 장애물·계단·머리 높이 장애물·움직이는 사람의 다리. 각각에 대해 대응(추가 센서·범퍼·환경 개선·운영 제약)을 정하는 것이 안전 설계의 실질이다.
8.7.8 실패 모드 카탈로그
무엇이 어떻게 잘못되는가 — 설계 검토용 목록이다.
| 실패 모드 | 원인 | 완화 |
|---|---|---|
| 유령 장애물로 갇힘 | 코스트맵 지우기 실패 | 8.4.7·복구에 초기화 |
| 측위 상실 후 배회 | AMCL 발산 | 신뢰도 감시·정지(8.3.8) |
| 좁은 통로 통과 실패 | 팽창 과대 | 8.4.6 |
| 진동·왕복 | 컨트롤러 튜닝 | 8.5.8 |
| 후진 중 충돌 | 후방 미관측 | 복구에서 제거·범퍼 |
| 목표 근처에서 헤맴 | 허용오차·측위 | 8.5.6 |
| 동적 장애물과 충돌 | 반응 지연 | collision_monitor·속도 제한 |
| 사람을 놀라게 함 | 급기동·근접 | 속도 구역·Wait 우선 |
| 센서 두절 후 진행 | 타임아웃 없음 | source_timeout·워치독 |
| 지도와 현실 불일치 | 환경 변화 | 재매핑·지역 코스트맵 의존 |
| 경사·낭떠러지 | 2D 센서 한계 | 3D 센서·낭떠러지 센서 |
| 유리·거울 | 라이다 통과 | 초음파·지도 수동 표시 |
| 통신 두절(원격 명령) | 무선 | 워치독·자율 정지 |
| 소프트웨어 크래시 | 버그·자원 | 하드웨어 안전(⑤) |
견해: 이 표를 시스템 설계 검토에 쓰라. 각 항목에 대해 "우리 시스템은 어떻게 대응하는가"를 답할 수 없다면, 그것이 미완성 부분이다. FMEA(고장 모드 영향 분석) 같은 체계적 방법을 쓰면 더 좋고, 규제 대상 시스템에서는 요구된다(14.10).
8.7.9 운영 안전 관행
설계 외에 운영에서 지킬 것들이다.
| 관행 | 이유 |
|---|---|
| 속도 제한을 보수적으로 시작 | 문제가 드러날 때 피해가 작다 |
| 사람 있는 구역 저속 | 8.7.5의 속도 필터 |
| 초기 운용 시 감시자 배치 | 예상 못 한 동작 대응 |
| 비상 정지 접근성 | 손 닿는 곳·여러 위치 |
| 정기 검증 | 센서·측위·오도메트리 열화(7.6.7) |
| 로그·bag 상시 기록 | 사고 분석(11.3) |
| 변경 후 재검증 | 파라미터 하나가 행동을 바꾼다 |
| 진단 감시 | 조기 경보(6.9) |
견해: "속도를 낮추는 것"이 가장 비용이 낮고 효과적인 안전 조치다. 정지 거리가 속도의 제곱에 비례하므로(8.7.3), 속도를 절반으로 하면 정지 거리가 4분의 1이 된다. 성능 요구가 명확하지 않다면 느리게 시작하라.
견해: 사고나 아차 사고(near-miss)를 기록하고 분석하는 절차를 두라. 로봇이 무언가에 부딪힐 뻔한 순간이 실제 사고보다 훨씬 많고, 그것을 분석하면 사고를 예방할 수 있다. bag 기록이 있으면 재현·분석이 가능하다(11.3).
8.7.10 함정 카탈로그
| # | 함정 | 증상 | 대처 |
|---|---|---|---|
| 1 | "Nav2 = 안전"으로 믿음 | 안전 논증 불가 | 계층 설계(8.7.7) |
| 2 | 후방 센서 없이 BackUp | 후방 충돌 | 제거·범퍼·짧게 |
| 3 | collision_monitor source_timeout 미설정 | 센서 두절 시 진행 | 설정·시험 |
| 4 | 정지 영역이 정지 거리보다 작음 | 충돌 | 식으로 역산 |
| 5 | min_points 부적절 | 오정지·미감지 | 노이즈·물체 크기 |
| 6 | 세 곳의 속도 한계 불일치 | 예측 불가 동작 | 한 곳에서 정의 |
| 7 | CANCELED를 실패로 처리 | 잘못된 재시도 | 결과 구분(1.8) |
| 8 | 상위 실패 정책 없음 | 무한 재시도 | 정책 정의(14.2) |
| 9 | 하드웨어 비상정지 없음 | 최후 방어선 부재 | 전원 직접 차단 |
| 10 | 센서 사각을 문서화 안 함 | 예상 못 한 충돌 | "못 보는 것" 목록 |
| 11 | 금지 구역 탈출 로직 없음 | 갇힘 | 탈출 고려 |
| 12 | 변경 후 재검증 생략 | 회귀 | 검증 절차 |
| 13 | 초기 운용에 감시자 없음 | 사고 위험 | 배치 |
| 14 | 아차 사고 미기록 | 예방 기회 상실 | 기록·분석 |
8.7.11 체크포인트
- 복구 동작 다섯 가지와 각각의 위험을 말할 수 있는가. 후방 센서가 없을 때
BackUp을 어떻게 다뤄야 하는가. collision_monitor가 코스트맵을 거치지 않는 것이 왜 중요한가.stop·slowdown영역을 겹쳐 두는 구성의 이점은? 정지 영역 크기를 식으로 산정할 수 있는가.source_timeout이 없으면 무슨 일이 생기는가.- 속도 평활기가 해결하는 문제 네 가지와, 세 곳의 속도 한계를 어떻게 관리해야 하는가.
- 코스트맵 필터 두 종류의 용도와, 속도 제한 구역이 유용한 이유는?
ABORTED와CANCELED를 구분해야 하는 이유는? 상위 시스템이 가져야 할 정책은?- 안전 계층 다섯 단계를 말하고, ①~④가 공통으로 갖는 한계는 무엇인가.
- 하드웨어 비상정지가 소프트웨어를 거치면 안 되는 이유는?
- "센서가 못 보는 것" 목록에 들어갈 항목을 다섯 가지 이상 들 수 있는가.
- 실패 모드 카탈로그를 설계 검토에 어떻게 쓰는가.
- 가장 비용이 낮고 효과적인 안전 조치는 무엇이며 왜인가.
- 아차 사고를 기록·분석해야 하는 이유는?
다음 장 8.8는 제8부의 마지막 — 튜닝·평가·현장 디버깅이다. 지금까지의 요소들을 실제 환경에서 동작하게 만드는 절차와, 증상에서 원인으로 가는 플레이북.