AEGLOSLAB ROS 2 Jazzy 대전

제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).

안전 위험 controller_server velocity_smoother collision_monitor 로봇 감속 또는 정지 센서 직접 구독

핵심: 코스트맵을 거치지 않고 센서를 직접 본다. 코스트맵의 지연·처리 오류와 무관하게 동작하므로 더 신뢰할 수 있다.

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).

필터 마스크 (OccupancyGrid 형식 지도) costmap_filter_info_server 코스트맵 필터 레이어 구역별 동작 KeepoutFilter: 진입 금지 SpeedFilter: 속도 제한
# 필터 정보 서버
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() called 0건. 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_2d 1.3.12)을 수정 없이 그대로. ③ 같은 구성을 navigation2 jazzy 브랜치를 새로 클론해 빌드한 것으로(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 밖의 문제다.

ABORTED 예 SUCCEEDED CANCELED 아니오 NavigateToPose 액션 결과 다음 작업 실패 처리 의도된 중단 재시도? 다른 경로·목표 사람 호출 · 안전 정지

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 안전 계층 설계

이 절이 이 장의 핵심이다.

① 계획 계층 플래너 · 코스트맵 ② 제어 계층 컨트롤러 · 충돌 예측 ③ 감시 계층 collision_monitor ④ 소프트웨어 워치독 cmd_vel timeout ⑤ 하드웨어 안전 비상정지 · 범퍼 · 안전 라이다 로봇 동작
계층제공실패 시
① 계획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후방 충돌제거·범퍼·짧게
3collision_monitor source_timeout 미설정센서 두절 시 진행설정·시험
4정지 영역이 정지 거리보다 작음충돌식으로 역산
5min_points 부적절오정지·미감지노이즈·물체 크기
6세 곳의 속도 한계 불일치예측 불가 동작한 곳에서 정의
7CANCELED를 실패로 처리잘못된 재시도결과 구분(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부의 마지막 — 튜닝·평가·현장 디버깅이다. 지금까지의 요소들을 실제 환경에서 동작하게 만드는 절차와, 증상에서 원인으로 가는 플레이북.