멈춰 있는 지도와 움직이는 현실 사이
지난 장에서 우리는 저장된 지도 위에서 목표 지점을 찍고, Nav2가 전역 경로를 만들고, 로봇이 그 경로를 따라 이동하는 과정을 확인했다. RViz 화면에 초록색 또는 파란색 경로가 나타나고, 로봇이 천천히 방향을 틀어 목표 지점으로 가는 모습은 꽤 그럴듯하다. 이쯤 되면 "이제 자율주행이 되는구나"라는 생각이 든다.
하지만 실제 환경은 그렇게 얌전하지 않다. 복도에는 갑자기 사람이 지나가고, 지도에는 없던 의자가 놓이고, 누군가 박스를 바닥에 내려놓는다. 청소 로봇 앞에는 양말이 나타나고, 물류 로봇 앞에는 다른 카트가 끼어든다. 지도는 어제의 약속이고, 로봇이 마주하는 세계는 지금 이 순간의 사건이다.
이번 장의 목표는 이 차이를 실험으로 확인하는 것이다. 전역 경로 위에 장애물을 놓고, 로봇이 그 장애물을 어떻게 감지하는지, local costmap에 어떻게 반영되는지, 그리고 controller가 어떤 /cmd_vel을 선택하는지 관찰한다. 여기서 중요한 점은 로봇이 항상 전역 경로를 즉시 새로 만드는 것은 아니라는 사실이다. 가까운 장애물에 대한 1차 대응은 대개 local costmap과 local controller가 맡는다.
전역 경로는 "큰길 안내"에 가깝고, 로컬 회피는 "눈앞의 사람을 피해 한 걸음 비켜 걷기"에 가깝다. 길 전체를 다시 계산하지 않아도 우리는 앞사람을 살짝 피해 지나갈 수 있다. Nav2의 local costmap과 controller가 바로 그 역할을 한다.

장애물이 보이는 곳은 지도인가, 센서인가
먼저 정적 지도와 동적 장애물을 구분해야 한다. 정적 지도는 SLAM으로 만들고 저장한 map.yaml, map.pgm 또는 map.png 같은 파일에서 온다. 벽, 기둥, 큰 구조물처럼 대체로 변하지 않는 것들이 여기에 들어 있다. 반면 동적 장애물은 센서가 지금 보고 있는 물체다. 사람, 상자, 의자, 카트, 반쯤 열린 문처럼 지도에는 없거나 위치가 바뀐 대상이다.
Nav2의 costmap은 여러 레이어를 겹쳐 하나의 위험 지도를 만든다. 대표적으로 static layer는 저장된 지도에서 장애물을 가져오고, obstacle layer는 LiDAR나 Depth Camera 같은 센서 관측으로 장애물을 추가하며, inflation layer는 장애물 주변에 여유 공간을 만든다. Nav2 문서에서도 costmap은 planner와 controller가 충돌 가능성이나 높은 비용 영역을 판단하는 데 사용하는 2차원 격자 표현이라고 설명한다.1
이 구조를 로봇의 시야에 비유하면 이해하기 쉽다. static layer는 로봇이 들고 있는 건물 도면이고, obstacle layer는 로봇의 현재 눈이며, inflation layer는 "이 물체에는 너무 바짝 붙지 말자"라는 안전 거리 감각이다. 좋은 로봇은 도면만 믿지 않고, 눈앞의 변화를 계속 반영한다.
이번 장에서 주로 볼 것은 local_costmap이다. global_costmap은 전체 지도 위에서 큰 경로를 계획하는 데 중요하고, local_costmap은 로봇 주변 몇 미터 안에서 충돌을 피하는 데 중요하다. 보통 local costmap은 rolling_window: true로 설정한다. rolling window는 로봇을 중심으로 따라다니는 작은 창이다. 로봇이 움직이면 costmap도 함께 이동하면서 주변 장애물을 갱신한다.
실험 환경 준비하기
실험은 Gazebo 시뮬레이션에서 해도 되고, 실제 TurtleBot이나 자체 제작 로봇에서 해도 된다. 처음에는 Gazebo를 권장한다. 실패해도 벽이 긁히지 않고, 로봇이 사람 발목을 치지 않으며, 같은 장면을 반복해서 만들 수 있기 때문이다.
기본 상태는 지난 장과 같다. 지도와 AMCL 위치 추정이 정상이고, Nav2가 실행되어 있으며, RViz에서 로봇 모델, TF, 지도, 전역 경로, local costmap을 볼 수 있어야 한다.
시뮬레이션이라면 다음과 같은 흐름으로 시작할 수 있다.
export TURTLEBOT3_MODEL=waffle
ros2 launch turtlebot3_gazebo turtlebot3_world.launch.py다른 터미널에서는 저장한 지도를 사용하는 Nav2 bringup을 실행한다. 프로젝트마다 launch 파일 이름은 다르지만, 일반적인 형태는 다음과 비슷하다.
ros2 launch my_robot_bringup navigation.launch.py \
use_sim_time:=true \
map:=/home/user/maps/office.yamlRViz에서는 Map, Global Costmap, Local Costmap, LaserScan, RobotModel, TF, Path 표시를 켠다. 특히 이번 장에서는 Local Costmap을 잘 봐야 한다. 장애물이 로봇 앞에 들어왔을 때, 지도에는 없던 점이나 색칠된 셀이 local costmap에 생기는지 확인한다.
실제 로봇이라면 시작 전에 속도를 낮추고 넓은 공간에서 실험해야 한다. 사람을 장애물로 삼아 바로 앞을 가로막는 실험은 위험하다. 처음에는 가벼운 박스나 스펀지 기둥처럼 부딪혀도 피해가 적은 물체를 사용한다. 로봇을 언제든 멈출 수 있도록 비상 정지 버튼이나 /cmd_vel 차단 방법도 준비해 둔다.
RViz에서 local costmap 관찰하기
목표 지점을 하나 찍어 보자. 로봇 앞쪽으로 3~5미터 떨어진 지점이면 충분하다. 전역 경로가 만들어지고 로봇이 움직이기 시작하면, 경로 위에 장애물을 하나 놓는다. Gazebo에서는 상자 모델을 로봇 앞에 배치하거나, 이미 있는 물체를 끌어다 놓을 수 있다. 실제 환경에서는 박스를 경로 위에 살짝 밀어 넣으면 된다.
이때 RViz에서 세 가지를 동시에 본다. 첫째, /scan 또는 Depth Camera의 point cloud가 장애물을 감지하는가. 둘째, local costmap에 장애물 셀이 생기는가. 셋째, 로봇의 속도 명령이 변하는가.
LiDAR를 사용한다면 다음 명령으로 스캔이 들어오는지 확인할 수 있다.
ros2 topic hz /scan
ros2 topic echo /scan --onceDepth Camera를 사용한다면 point cloud 토픽을 확인한다.
ros2 topic list | grep points
ros2 topic hz /camera/depth/points토픽이 살아 있는데 local costmap에 장애물이 나타나지 않는다면, 문제는 대개 costmap 설정, TF, frame_id, 센서 범위, QoS, lifecycle 상태 중 하나에 있다. 특히 센서 메시지의 header.frame_id가 TF 트리에 연결되어 있어야 한다. 예를 들어 /scan의 frame이 base_scan인데 base_link → base_scan 변환이 없다면, costmap은 센서 데이터를 로봇 주변 격자에 올바르게 놓을 수 없다.
확인은 익숙한 명령으로 한다.
ros2 run tf2_ros tf2_echo base_link base_scan
ros2 run tf2_tools view_framesRViz에서 local costmap이 보이지 않을 때는 Fixed Frame도 확인한다. Nav2에서 local costmap의 global_frame을 보통 odom으로 두기 때문에, RViz Fixed Frame이 map이어도 TF가 정상이라면 보일 수 있다. 하지만 map → odom → base_link → sensor_frame 연결이 끊겨 있으면 costmap, scan, 로봇 모델이 서로 다른 세계에 떠 있는 것처럼 보인다.
obstacle layer는 장애물을 어떻게 찍고 지울까
local costmap에서 동적 장애물을 담당하는 핵심은 obstacle layer다. obstacle layer는 센서 관측을 받아 costmap 셀을 장애물로 표시한다. 이 과정을 흔히 marking이라고 부른다. 말 그대로 "여기에 물체가 있다"고 칠하는 일이다.
그런데 장애물 회피에서 marking만큼 중요한 것이 clearing이다. 장애물이 사라졌는데 costmap에 계속 남아 있다면 로봇은 유령을 피하느라 멈춰 선다. 방금 전까지 사람이 서 있던 자리를 사람이 지나간 뒤에도 계속 막힌 공간으로 믿으면, 로봇은 갈 수 있는 길을 갈 수 없다고 판단한다.
Nav2 obstacle layer는 2D raycasting을 사용해 센서가 지나간 빈 공간을 지울 수 있다. 쉽게 말해 LiDAR에서 특정 방향으로 광선이 나갔고, 그 방향의 더 먼 곳에서 물체가 보였다면, 센서와 물체 사이의 공간은 비어 있다고 추정할 수 있다. 이 빈 공간을 costmap에서 clearing한다. obstacle layer 설정에는 marking, clearing, obstacle_max_range, raytrace_max_range, observation_persistence 같은 항목이 있으며, 공식 문서에서도 LaserScan 또는 PointCloud2 데이터 소스를 이용해 장애물 표시와 raytrace clearing을 설정할 수 있음을 설명한다.2
간단한 local costmap 예시는 다음과 같다.
local_costmap:
local_costmap:
ros__parameters:
use_sim_time: true
global_frame: odom
robot_base_frame: base_link
rolling_window: true
width: 4
height: 4
resolution: 0.05
update_frequency: 10.0
publish_frequency: 5.0
robot_radius: 0.22
plugins: ["obstacle_layer", "inflation_layer"]
obstacle_layer:
plugin: "nav2_costmap_2d::ObstacleLayer"
enabled: true
observation_sources: scan
footprint_clearing_enabled: true
scan:
topic: /scan
data_type: "LaserScan"
marking: true
clearing: true
obstacle_max_range: 2.5
obstacle_min_range: 0.0
raytrace_max_range: 3.0
raytrace_min_range: 0.0
observation_persistence: 0.0
inf_is_valid: false
inflation_layer:
plugin: "nav2_costmap_2d::InflationLayer"
inflation_radius: 0.45
cost_scaling_factor: 3.0여기서 obstacle_max_range는 장애물로 표시할 최대 거리다. 예를 들어 2.5미터로 설정하면, LiDAR가 그보다 먼 물체를 보더라도 obstacle layer는 장애물로 찍지 않을 수 있다. 반면 raytrace_max_range는 빈 공간을 지울 때 사용할 최대 거리다. 보통 raytrace_max_range를 obstacle_max_range보다 약간 크게 둔다. 그래야 센서가 더 멀리까지 "이 방향은 비어 있다"고 알려 주면서 이전 장애물 흔적을 지우기 쉽다.
observation_persistence는 관측을 얼마나 오래 기억할지 정한다. 0.0이면 새 관측이 들어오는 흐름에 맞춰 빠르게 갱신된다. 값을 크게 주면 센서가 잠깐 장애물을 놓쳐도 costmap에 조금 더 오래 남는다. 이것은 장단점이 있다. 사람 다리가 LiDAR 빔 사이로 순간적으로 사라지는 상황에서는 약간의 persistence가 안정감을 줄 수 있다. 하지만 너무 크면 이미 지나간 사람의 흔적이 계속 남아 로봇이 불필요하게 멈춘다.
실험해 보자. 장애물을 로봇 앞에 놓았다가 치운다. RViz에서 local costmap의 장애물 셀이 생겼다가 사라지는지 본다. 사라지지 않는다면 다음 순서로 확인한다.
clearing: true인지 확인한다.raytrace_max_range가 너무 짧지 않은지 확인한다.LiDAR의 무한대 값 처리가 필요한 센서라면
inf_is_valid설정을 검토한다.센서 frame이 TF에 연결되어 있는지 확인한다.
local costmap이 lifecycle active 상태인지 확인한다.
여기서 가장 답답한 버그는 "센서는 보고 있는데 costmap이 못 지우는" 경우다. 로봇 눈에는 빈 복도가 보이지만, costmap 안에는 과거의 장애물이 남아 있다. 이때 로봇은 겁 많은 초보 운전자처럼 앞으로 가지 못하고 제자리에서 머뭇거린다.
Depth Camera와 voxel layer를 사용할 때
2D LiDAR는 특정 높이의 평면만 본다. 바닥에 낮게 놓인 물체, 테이블 상판 아래쪽, 유리벽, 얇은 의자 다리 같은 대상은 상황에 따라 잘 보이지 않을 수 있다. Depth Camera를 사용하면 3차원 점군으로 주변을 볼 수 있어 이런 문제를 일부 보완할 수 있다.
Depth Camera의 point cloud를 costmap에 넣을 때는 obstacle layer 대신 voxel layer를 쓰는 경우가 많다. voxel은 3차원 격자 칸을 뜻한다. 2D costmap이 바둑판이라면 voxel layer는 작은 큐브를 쌓은 공간이라고 생각하면 된다. Nav2 costmap 설정 예시에서도 LaserScan 기반 obstacle layer와 PointCloud2 기반 voxel layer를 함께 사용하는 구성을 볼 수 있다.1
예시는 다음과 같다.
local_costmap:
local_costmap:
ros__parameters:
global_frame: odom
robot_base_frame: base_link
rolling_window: true
width: 4
height: 4
resolution: 0.05
plugins: ["voxel_layer", "inflation_layer"]
voxel_layer:
plugin: "nav2_costmap_2d::VoxelLayer"
enabled: true
publish_voxel_map: true
origin_z: 0.0
z_resolution: 0.05
z_voxels: 16
max_obstacle_height: 1.5
mark_threshold: 0
observation_sources: depth_points
depth_points:
topic: /camera/depth/points
data_type: "PointCloud2"
marking: true
clearing: true
obstacle_max_range: 2.0
raytrace_max_range: 2.5
min_obstacle_height: 0.05
max_obstacle_height: 1.5
inflation_layer:
plugin: "nav2_costmap_2d::InflationLayer"
inflation_radius: 0.45
cost_scaling_factor: 3.0Depth Camera를 쓸 때는 높이 범위가 중요하다. min_obstacle_height를 너무 낮게 두면 바닥 노이즈가 장애물처럼 찍힐 수 있고, 너무 높게 두면 낮은 물체를 놓칠 수 있다. max_obstacle_height를 너무 낮게 두면 실제 장애물이 잘리고, 너무 높게 두면 천장이나 먼 배경의 노이즈가 들어올 수 있다.
또 하나의 현실적인 문제는 계산량이다. PointCloud2는 LaserScan보다 데이터가 무겁다. 카메라 해상도, point cloud 변환 주기, costmap update frequency를 무리하게 높이면 CPU 사용량이 치솟고, costmap 갱신이 늦어질 수 있다. 장애물을 잘 보려고 설정을 과하게 올렸는데 오히려 반응이 느려지는 역설이 생긴다.
DWB 컨트롤러는 경로를 바꾸는 대신 속도를 고른다
이제 로봇이 장애물을 봤다고 하자. local costmap에는 장애물이 찍혔다. 그러면 controller는 무엇을 할까?
많은 독자가 여기서 "전역 경로를 다시 만들겠지"라고 생각한다. 때로는 맞지만, 매 순간 그런 것은 아니다. DWB 계열 로컬 컨트롤러의 기본 아이디어는 여러 속도 후보를 짧은 시간 동안 시뮬레이션해 보고, 그중 가장 좋은 속도 명령을 고르는 것이다. 속도 후보란 전진 속도 vx, 좌우 속도 vy, 회전 속도 vtheta의 조합이다. 일반적인 차동 구동 로봇은 옆으로 미끄러지듯 움직일 수 없으므로 vy는 0으로 둔다.
DWB는 Dynamic Window Approach 계열의 컨트롤러다. 짧은 미래 동안 "이 속도로 움직이면 로봇이 어떤 궤적을 그릴까?"를 계산하고, 그 궤적이 장애물과 충돌하는지, 전역 경로와 얼마나 잘 맞는지, 목표에 가까워지는지 등을 평가한다. DWB 구현을 설명한 자료에서도 computeVelocityCommands가 전역 계획을 준비하고 costmap을 기준으로 후보 궤적을 평가해 가장 좋은 명령을 선택하는 구조를 설명한다.3
예를 들어 로봇 앞에 박스가 놓였다고 하자. DWB는 대략 이런 후보들을 상상한다.
그대로 직진한다.
조금 왼쪽으로 틀며 전진한다.
조금 오른쪽으로 틀며 전진한다.
속도를 줄인다.
제자리에서 회전한다.
그리고 각 후보에 점수를 매긴다. 장애물에 부딪힐 후보는 매우 나쁜 점수를 받거나 탈락한다. 전역 경로에서 너무 멀어지는 후보도 불리하다. 목표 방향과 전혀 다른 쪽으로 도는 후보도 불리하다. 반대로 장애물을 피하면서 경로와 목표를 적당히 따르는 후보가 선택된다.
여기서 critic이라는 단어가 나온다. critic은 후보 궤적을 평가하는 채점자다. BaseObstacle critic은 장애물과의 충돌 위험을 보고, PathDist나 PathAlign은 전역 경로와의 거리나 정렬을 보고, GoalDist나 GoalAlign은 목표와의 관계를 본다. 마치 여러 심사위원이 한 후보 궤적을 보고 점수를 주는 것과 같다.
DWB 설정 일부는 다음과 비슷하다.
controller_server:
ros__parameters:
use_sim_time: true
controller_frequency: 20.0
controller_plugins: ["FollowPath"]
FollowPath:
plugin: "dwb_core::DWBLocalPlanner"
debug_trajectory_details: true
min_vel_x: 0.0
max_vel_x: 0.26
max_vel_theta: 1.0
min_speed_xy: 0.0
max_speed_xy: 0.26
min_speed_theta: 0.0
acc_lim_x: 1.0
decel_lim_x: -1.0
acc_lim_theta: 2.0
decel_lim_theta: -2.0
vx_samples: 20
vtheta_samples: 20
sim_time: 1.7
linear_granularity: 0.05
angular_granularity: 0.025
critics: ["RotateToGoal", "Oscillation", "BaseObstacle", "GoalAlign", "PathAlign", "PathDist", "GoalDist"]
BaseObstacle.scale: 0.05
PathAlign.scale: 24.0
PathDist.scale: 24.0
GoalAlign.scale: 16.0
GoalDist.scale: 16.0
RotateToGoal.scale: 24.0debug_trajectory_details: true를 켜면 디버깅 정보가 늘어난다. 모든 상황에서 켜 둘 필요는 없지만, 튜닝할 때는 도움이 된다. RViz에서 local plan, global path, local costmap을 함께 보면 로봇이 왜 왼쪽으로 돌았는지, 왜 멈췄는지, 왜 장애물 가까이 붙었는지 감을 잡을 수 있다.
장애물 가까이 지나갈 때 조정할 것들
로봇이 장애물을 피하긴 피하는데 너무 아슬아슬하게 지나간다면 무엇을 봐야 할까? 가장 먼저 확인할 것은 footprint 또는 robot_radius와 inflation radius다.
robot_radius는 로봇을 원으로 단순화했을 때의 반지름이다. 로봇이 원형에 가깝다면 이 설정이 편하다. 하지만 직사각형 베이스, 돌출된 센서, 범퍼가 있다면 footprint를 다각형으로 지정하는 편이 더 정확하다. 공식 costmap 문서에서도 footprint를 지정하면 로봇 형상에 따른 충돌 검사를 할 수 있고, 지정하지 않으면 robot radius 기반의 원형 충돌 검사를 사용한다고 설명한다.1
예를 들어 가로 0.36미터, 세로 0.30미터 정도의 로봇이라면 다음처럼 footprint를 줄 수 있다.
local_costmap:
local_costmap:
ros__parameters:
footprint: "[[0.18, 0.15], [0.18, -0.15], [-0.18, -0.15], [-0.18, 0.15]]"
footprint_padding: 0.02장애물과 너무 가까이 지나가면 inflation_radius를 키워 본다. inflation radius는 장애물 주변에 비용이 높은 완충지대를 만드는 거리다. 이 값이 커지면 로봇은 장애물에서 더 멀리 떨어진 경로를 선호한다. 하지만 너무 크게 키우면 좁은 복도 전체가 위험 영역처럼 보일 수 있다. 그러면 로봇은 갈 수 있는 길도 못 간다고 판단한다.
cost_scaling_factor도 함께 봐야 한다. 이 값은 장애물에서 멀어질수록 비용이 얼마나 빨리 낮아지는지에 영향을 준다. 값의 효과는 로봇 크기와 해상도, 환경 구조에 따라 다르게 느껴질 수 있으므로, 한 번에 크게 바꾸기보다 조금씩 바꿔 RViz에서 색 변화를 확인하는 편이 좋다.
튜닝은 손잡이를 하나씩 돌리는 일과 같다. 여러 값을 동시에 바꾸면 무엇 때문에 좋아졌고 무엇 때문에 나빠졌는지 알 수 없다. 먼저 footprint를 실제 로봇 크기에 맞춘다. 그다음 inflation radius를 조정한다. 그 후 DWB의 obstacle critic과 path critic 비중을 조정한다.
로봇이 너무 자주 멈출 때
반대로 로봇이 너무 겁이 많을 수도 있다. 장애물이 조금만 보여도 멈추고, 빈 공간이 있는데도 전진하지 못하고, 회전하다가 다시 멈춘다. 이럴 때는 costmap이 지나치게 보수적인지, controller가 선택할 수 있는 속도 후보가 부족한지 확인해야 한다.
먼저 local costmap의 크기를 본다. width와 height가 너무 작으면 로봇은 장애물을 너무 늦게 본다. 반대로 너무 크면 계산량이 늘고, 먼 장애물까지 신경 쓰느라 둔해질 수 있다. 실내 소형 로봇은 3~5미터 정도의 local costmap으로 시작하는 경우가 많다. 빠른 로봇이라면 더 넓게 봐야 한다.
다음으로 update_frequency를 확인한다. costmap이 1Hz로 갱신된다면 로봇은 1초 전 세계를 보고 움직일 수 있다. 천천히 움직이는 로봇이라면 버틸 수 있지만, 동적 장애물이 있는 환경에서는 반응이 늦다. local costmap은 보통 global costmap보다 더 빠르게 갱신한다. 예를 들어 local은 5~10Hz, global은 1Hz 안팎에서 시작할 수 있다. 물론 센서 주기와 CPU 성능이 받쳐 줘야 한다.
controller frequency도 중요하다. controller_frequency가 낮으면 /cmd_vel 갱신이 드물어지고, 회피 동작이 뚝뚝 끊긴다. 너무 높이면 계산 부담이 커진다. DWB는 후보 속도를 여러 개 샘플링하고 궤적을 평가하므로, vx_samples, vtheta_samples, sim_time을 크게 잡을수록 더 많은 계산이 필요하다.
sim_time은 후보 속도로 얼마나 먼 미래까지 궤적을 그려 볼지 정한다. 값이 짧으면 좁은 공간에서 민첩하게 반응할 수 있지만, 빠르게 다가오는 장애물을 늦게 피할 수 있다. 값이 길면 미리 위험을 볼 수 있지만, 좁고 복잡한 환경에서 가능한 후보가 너무 많이 탈락해 로봇이 멈출 수 있다.
정리하면, 로봇이 과도하게 멈출 때는 다음을 차례로 의심한다.
inflation radius가 환경에 비해 너무 큰가?
footprint나 robot_radius가 실제보다 크게 잡혔는가?
local costmap에 오래된 장애물 흔적이 남아 있는가?
observation_persistence가 너무 큰가?controller의 속도, 가속도, 샘플 수, sim_time 조합이 너무 보수적인가?
progress checker가 너무 빨리 실패를 선언하는가?
특히 네 번째 항목은 동적 환경에서 자주 만난다. 사람이 지나간 흔적이 costmap에 남아 있고, 로봇은 그 흔적을 실제 장애물처럼 대한다. 이 경우 observation_persistence, clearing, raytrace_max_range, 센서 주기, TF 지연을 함께 봐야 한다.
갑작스러운 회피 명령을 부드럽게 만들기
시뮬레이션에서는 로봇이 순간적으로 회전 속도를 바꿔도 그럭저럭 움직인다. 하지만 실제 로봇은 다르다. 바퀴에는 관성이 있고, 바닥은 미끄러우며, 모터 제어기는 명령을 따라가는 데 시간이 걸린다. controller가 갑자기 "왼쪽으로 확 돌아!"라고 명령하면 실제 로봇은 덜컥거리거나 미끄러지거나 진동할 수 있다.
그래서 속도 제한과 가속도 제한이 중요하다. DWB 설정의 max_vel_x, max_vel_theta, acc_lim_x, decel_lim_x, acc_lim_theta, decel_lim_theta는 로봇이 낼 수 있는 속도와 속도 변화량의 한계를 나타낸다. 여기서 욕심을 내면 로봇은 과격해진다. 특히 좁은 실내에서 max_vel_theta가 너무 크면 장애물을 피하려다 제자리에서 급회전하며 흔들릴 수 있다.
Nav2에는 velocity smoother를 함께 사용하는 구성이 많다. velocity smoother는 controller가 만든 속도 명령을 실제 로봇에 보내기 전에 조금 더 부드럽게 다듬는 역할을 한다. 수도꼭지를 갑자기 확 여는 대신 천천히 여는 것과 비슷하다. 급가속, 급감속, 급회전을 줄이면 로봇의 미끄러짐과 진동, 기계적 부담을 줄일 수 있다.
개념적인 구성은 다음과 같다.
velocity_smoother:
ros__parameters:
use_sim_time: true
smoothing_frequency: 20.0
scale_velocities: false
feedback: "OPEN_LOOP"
max_velocity: [0.26, 0.0, 1.0]
min_velocity: [0.0, 0.0, -1.0]
max_accel: [0.8, 0.0, 1.5]
max_decel: [-0.8, 0.0, -1.5]
odom_topic: /odom
odom_duration: 0.1
velocity_timeout: 1.0프로젝트에 따라 /cmd_vel 흐름은 달라질 수 있다. 어떤 구성에서는 controller가 /cmd_vel_nav로 내보내고, velocity smoother가 이를 받아 /cmd_vel로 publish한다. 어떤 구성에서는 remapping으로 연결한다. 중요한 것은 최종적으로 모터 제어 노드가 smoother를 거친 명령을 받는지 확인하는 것이다.
토픽 흐름은 다음처럼 확인할 수 있다.
ros2 topic list | grep cmd_vel
ros2 topic echo /cmd_vel
ros2 topic hz /cmd_vel장애물 앞에서 로봇이 급정지한다면 감속 제한을 완화하는 것이 아니라, 먼저 속도를 낮추고 센서 감지 거리와 inflation을 조정해야 한다. 너무 늦게 장애물을 보면 부드럽게 멈출 방법이 없다. 자동차도 1미터 앞에서 갑자기 벽을 발견하면 부드러운 제동이 어렵다. 로봇도 마찬가지다.
움직이는 사람을 피하는 실험
이제 조금 더 현실적인 장면을 만들어 보자. 로봇이 목표 지점으로 이동하는 동안, 장애물이 전역 경로를 가로질러 지나가게 한다. Gazebo에서는 움직이는 모델을 쓰거나, 수동으로 물체를 이동시킬 수 있다. 실제 환경에서는 사람이 직접 로봇 앞을 빠르게 지나가는 대신, 충분한 거리를 두고 천천히 박스를 움직이는 방식으로 시작한다.
관찰 포인트는 네 가지다.
장애물이 LiDAR 또는 Depth Camera에 잡히는가?
local costmap에 장애물이 생겼다가 이동하는가?
로봇이 감속, 회전, 정지 중 어떤 행동을 선택하는가?
장애물이 사라진 뒤 로봇이 다시 경로를 따라가는가?
잘 설정된 경우 로봇은 장애물이 가까워지면 속도를 줄이거나 살짝 돌아간다. 장애물이 전역 경로를 완전히 막으면 잠시 멈출 수 있다. 장애물이 사라지면 clearing이 일어나고, controller가 다시 전진 가능한 속도 후보를 선택한다.
하지만 여기서 중요한 한계를 이해해야 한다. 기본 costmap 기반 회피는 대체로 "지금 보이는 장애물"을 costmap에 표시하고 피한다. 사람이 앞으로 어디에 있을지, 카트가 어떤 속도로 다가오는지, 두 물체가 언제 교차할지를 명시적으로 예측하는 고급 동적 장애물 모델은 기본 구성만으로 충분히 제공되지 않는다. Nav2 커뮤니티에서도 사람, 카트, 동물, 차량 같은 동적 장애물을 더 잘 다루기 위해 위치와 속도 정보를 costmap layer나 planner/controller에 통합하는 연구와 개발 과제를 논의해 왔다.4
따라서 기본 Nav2에서 사람처럼 움직이는 장애물을 만났을 때 로봇이 완벽하게 "양보의 의도"를 이해한다고 기대하면 안 된다. 로봇은 눈앞의 costmap과 속도 후보 평가를 바탕으로 반응한다. 사람의 미래 위치를 예측하고 사회적으로 자연스러운 경로를 만드는 것은 별도의 인식, 추적, 예측, planning 확장이 필요한 주제다.
멈춤, 회전 반복, 경로 포기 상태에서 복구하기
동적 환경 실험을 하다 보면 로봇이 곤란한 상태에 빠진다. 장애물이 사라졌는데도 멈춰 있거나, 왼쪽 오른쪽으로 번갈아 회전하거나, 조금 가다 멈추기를 반복하거나, 결국 목표 이동 실패를 반환한다. 이때 당황하지 말고 상태를 나누어 보자.
첫 번째는 costmap 문제다. RViz에서 local costmap에 유령 장애물이 남아 있는지 확인한다. 남아 있다면 clearing 설정, raytrace range, observation persistence, 센서 토픽, TF를 본다. 당장 실험을 이어 가야 한다면 costmap clear 서비스를 호출할 수 있다.
ros2 service list | grep costmap
ros2 service call /local_costmap/clear_entirely_local_costmap std_srvs/srv/Empty {}프로젝트나 Nav2 버전에 따라 서비스 이름은 조금 다를 수 있다. ros2 service list | grep clear로 실제 이름을 확인하자.
두 번째는 controller 문제다. local costmap에는 길이 있어 보이는데 로봇이 전진하지 못한다면 DWB 후보 궤적이 대부분 탈락하고 있을 수 있다. inflation이 너무 크거나, footprint가 너무 크거나, sim_time이 환경에 맞지 않거나, path critic 비중이 너무 강해서 전역 경로에서 조금 벗어나는 후보를 싫어하는 상황일 수 있다.
세 번째는 전역 경로 문제다. 장애물이 전역 경로를 오래 막고 있거나, local 회피만으로 돌아가기 어려운 구조라면 전역 재계획이 필요하다. Behavior Tree 구성에 따라 Nav2는 일정 주기나 조건에서 경로를 다시 계산할 수 있다. 지난 장에서 본 것처럼 BT는 planner, controller, recovery behavior를 엮어 "계속 따라갈지, 다시 계획할지, costmap을 지울지, 회전하거나 후진할지"를 결정한다.
네 번째는 목표 자체가 나쁜 경우다. 목표 지점이 장애물 안에 있거나, 너무 좁은 곳에 있거나, 로봇 footprint가 들어갈 수 없는 곳이라면 아무리 튜닝해도 도착하기 어렵다. RViz에서 goal pose 주변의 global costmap과 local costmap을 확인한다. 목표를 30cm만 옮겨도 문제가 사라지는 경우가 많다.
복구 절차는 다음처럼 단순한 순서로 가져가면 좋다.
RViz에서 local costmap과 global costmap을 확인한다.
센서 토픽과 TF가 정상인지 확인한다.
local costmap clear를 호출해 유령 장애물인지 확인한다.
목표를 조금 옮겨 다시 보낸다.
같은 문제가 반복되면 inflation, footprint, obstacle layer, controller 파라미터를 하나씩 조정한다.
장애물이 통로를 구조적으로 막는다면 전역 재계획 또는 다른 목표를 사용한다.
로봇 디버깅에서 중요한 것은 "왜 안 가지?"라는 질문을 "무엇을 보고 안 간다고 판단했지?"로 바꾸는 것이다. 로봇은 고집을 부리는 것이 아니다. costmap과 controller 점수와 BT 조건에 따라 그렇게 행동할 뿐이다.
튜닝 실험표 만들기
장애물 회피 튜닝은 감으로만 하면 오래 걸린다. 작은 실험표를 만들어 하나씩 바꾸면 훨씬 빨라진다. 예를 들어 같은 복도, 같은 목표, 같은 장애물 위치에서 다음 항목을 기록한다.
| 실험 | 바꾼 값 | 관찰 결과 | 판단 |
|---|---|---|---|
| A | inflation_radius 0.35 | 장애물에 너무 가까움 | 반경 증가 필요 |
| B | inflation_radius 0.50 | 안전하지만 좁은 구간에서 멈춤 | 중간값 검토 |
| C | update_frequency 5Hz | 움직이는 장애물 반영이 늦음 | 10Hz 시도 |
| D | controller_frequency 20Hz | 회피가 부드러움 | CPU 사용률 확인 |
| E | observation_persistence 1.0 | 장애물 흔적이 오래 남음 | 0.2 또는 0.0 검토 |
이 표는 단순하지만 강력하다. 특히 팀 프로젝트에서는 "왠지 좋아진 것 같다"는 말보다 "inflation을 0.45에서 0.55로 올리니 최소 장애물 거리가 늘었지만 좁은 문에서 실패했다"는 기록이 훨씬 유용하다.
가능하다면 rosbag도 함께 기록한다.
ros2 bag record /scan /tf /tf_static /odom /cmd_vel \
/local_costmap/costmap /global_costmap/costmap나중에 bag을 재생하면 같은 상황에서 파라미터만 바꿔 비교할 수 있다. 실제 로봇으로 매번 같은 속도로 박스를 움직이는 것은 어렵지만, bag 데이터는 반복 실험을 가능하게 해 준다. 20장에서 배운 rosbag이 여기서 다시 힘을 발휘한다.
좋은 회피는 용감함과 조심성의 균형이다
장애물 회피 튜닝에는 정답 하나가 없다. 병원 로봇은 사람 가까이에서 매우 조심스럽게 움직여야 한다. 물류 창고 로봇은 효율이 중요하지만, 그래도 작업자와 충돌하면 안 된다. 집 안의 청소 로봇은 좁은 의자 다리 사이를 지나가야 하지만, 가구를 계속 치고 다니면 곤란하다.
그래서 좋은 설정은 로봇의 몸, 센서, 속도, 환경, 임무에 따라 달라진다. 작은 TurtleBot 설정을 그대로 큰 배송 로봇에 넣으면 위험하다. 반대로 큰 로봇의 보수적인 설정을 작은 교육용 로봇에 넣으면 로봇이 어디도 가지 못할 수 있다.
이번 장에서 배운 핵심은 다음과 같다. local costmap은 센서 관측으로 지금 주변의 장애물을 표현한다. obstacle layer는 marking으로 장애물을 찍고, raytracing 기반 clearing으로 사라진 장애물을 지운다. DWB 계열 controller는 전역 경로 자체를 매 순간 바꾸기보다 여러 속도 후보를 시뮬레이션하고 critic 점수로 가장 나은 /cmd_vel을 선택한다. 이 모든 과정은 update frequency, inflation radius, footprint, 센서 범위, controller frequency, 속도와 가속도 제한에 크게 영향을 받는다.
그리고 가장 중요한 사실이 남는다. 기본 costmap 기반 회피는 현재 관측에 강하지만, 사람의 미래 움직임을 깊이 예측하는 능력은 제한적이다. 그러므로 실제 서비스 로봇을 만들 때는 속도를 낮추고, 안전 거리를 충분히 두고, recovery behavior와 costmap clearing, 전역 재계획, 목표 재설정 절차를 함께 설계해야 한다.
자율주행 로봇은 한 번 경로를 만든 뒤 고집스럽게 돌진하는 기계가 아니다. 좋은 로봇은 계속 본다. 조금 의심한다. 필요하면 멈춘다. 길이 비면 다시 간다. 이번 장의 실험은 바로 그 감각을 로봇에게 심어 주는 과정이다.
다음 장에서는 지금까지 만든 모든 조각을 하나로 묶는다. 센서 노드, 위치 추정, 지도, costmap, planner, controller, behavior tree, launch 구조를 다시 연결해 하나의 자율주행 로봇 시스템으로 정리할 것이다. 이제 우리는 단순히 로봇을 움직이는 것이 아니라, 변하는 환경 속에서 판단하고 복구하는 시스템을 만들 준비가 되었다.
참고
1Costmap 2D — Nav2 documentation
2Obstacle Layer Parameters — Nav2 documentation source
3Obstacle Avoidance and DWB Controller
4Navigation Dynamic Obstacle Integration — Nav2 project notes
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.