이전 장에서 우리는 base_link, lidar_link, camera_link 같은 좌표계를 직접 만들고 TF로 연결했다. 작은 예제에서는 static_transform_publisher 명령을 몇 번 실행하는 것만으로도 충분했다. LiDAR는 앞으로 18cm, 위로 22cm. 카메라는 앞으로 12cm, 위로 32cm. 이런 식으로 하나씩 붙이면 로봇의 좌표계 트리가 만들어졌다.
그런데 실제 로봇은 그렇게 단순하지 않다. 차체가 있고, 바퀴가 있고, 캐스터가 있고, LiDAR와 카메라와 IMU가 붙는다. 나중에는 범퍼, 배터리, 로봇팔, 센서 마스트까지 추가될 수 있다. 이 모든 관계를 명령어로 하나씩 실행한다면 어떻게 될까? 어느 날 누군가 LiDAR 위치를 2cm 앞으로 옮겼는데 URDF는 예전 값이고, TF 명령어는 새 값이며, Gazebo 모델은 또 다른 값을 쓰는 불길한 상황이 생긴다. 로봇은 하나인데, 소프트웨어 안에는 서로 다른 로봇 세 대가 사는 셈이다.
URDF는 이 문제를 해결하기 위한 로봇의 공통 설계도다. URDF 파일 하나에 차체의 크기, 바퀴의 위치, 센서의 장착 위치, 링크와 조인트의 연결 관계를 적어 둔다. 그러면 RViz는 그 설계도를 보고 로봇을 화면에 그릴 수 있고, robot_state_publisher는 같은 설계도를 보고 TF를 발행할 수 있으며, Gazebo는 충돌 형상과 질량 정보를 이용해 물리 시뮬레이션을 할 수 있다. Nav2도 직접 URDF 전체를 읽어 경로를 짜는 것은 아니지만, URDF에 적힌 로봇 크기와 센서 위치가 Nav2의 footprint, costmap, 센서 변환 설정과 일치해야 안전하게 움직일 수 있다.
이 장에서는 바퀴형 이동 로봇 하나를 직접 설계하면서 URDF의 핵심을 익힌다. 목표는 멋진 3D 모델을 만드는 것이 아니다. 로봇 소프트웨어 전체가 같은 로봇을 바라보게 만드는 것이다.

URDF는 로봇을 설명하는 XML 문서다
URDF는 Unified Robot Description Format의 약자다. 이름은 길지만 역할은 단순하다. 로봇이 어떤 부품으로 이루어져 있고, 그 부품들이 어떻게 연결되어 있는지 적는 문서 형식이다. URDF는 XML 문법을 사용한다. XML은 태그로 데이터를 감싸는 형식이다.
가장 작은 URDF는 다음처럼 생겼다.
<?xml version='1.0'?>
<robot name='tiny_robot'>
<link name='base_link'/>
</robot>이 파일은 tiny_robot이라는 로봇에 base_link라는 링크 하나가 있다고 말한다. 아직 바퀴도 없고 센서도 없다. 말하자면 허공에 떠 있는 이름표 하나뿐이다. 여기에 차체, 바퀴, 센서를 링크로 추가하고, 이 링크들을 조인트로 연결하면 로봇의 골격이 만들어진다.
URDF를 읽을 때 기억할 규칙은 세 가지다.
길이 단위는 미터를 사용한다.
회전 단위는 라디안을 사용한다.
로봇 구조는 기본적으로 트리 형태여야 한다.
미터 단위라는 점은 특히 중요하다. 0.1은 10cm이고, 1.0은 1m다. 실수로 센서 위치에 10이라고 쓰면 LiDAR가 로봇 앞 10m에 매달린다. RViz에서 보면 재미있는 그림이 나오겠지만, Nav2 입장에서는 악몽이다.
트리 형태라는 말은 하나의 링크가 여러 부모를 동시에 가질 수 없다는 뜻이다. 사람 몸으로 비유하면 손은 팔에 붙고, 팔은 몸통에 붙는다. 손이 몸통과 팔에 동시에 직접 붙어 있지는 않다. URDF도 마찬가지다. 각 링크는 하나의 부모 조인트를 통해 로봇 구조 안에 들어온다. 복잡한 폐루프 기구를 정확히 표현하기에는 제한이 있지만, 바퀴형 이동 로봇, 센서 장착 구조, 간단한 로봇팔을 표현하기에는 충분히 강력하다.
link와 joint: 부품과 연결부
URDF의 가장 중요한 두 단어는 link와 joint다.
link는 로봇을 이루는 단단한 부품이다. 차체, 왼쪽 바퀴, 오른쪽 바퀴, 캐스터, LiDAR, 카메라가 모두 링크가 될 수 있다. 링크는 자기만의 좌표계를 가진다. base_link라는 링크가 있다면 base_link라는 프레임도 생긴다고 생각하면 쉽다.
joint는 링크와 링크를 연결하는 부분이다. 조인트는 부모 링크와 자식 링크를 정하고, 자식 링크가 부모 링크 기준으로 어디에 붙는지 알려 준다. 움직이지 않는 센서는 fixed 조인트로 붙인다. 회전하는 바퀴는 continuous 조인트로 붙인다.
예를 들어 차체에 LiDAR를 붙이는 관계는 다음처럼 표현할 수 있다.
<link name='base_link'/>
<link name='lidar_link'/>
<joint name='lidar_joint' type='fixed'>
<parent link='base_link'/>
<child link='lidar_link'/>
<origin xyz='0.18 0 0.22' rpy='0 0 0'/>
</joint>이 코드는 이전 장에서 명령어로 만들었던 base_link -> lidar_link 정적 변환과 같은 의미를 가진다. lidar_link는 base_link 기준으로 x 방향 0.18m, y 방향 0m, z 방향 0.22m 위치에 있다. rpy는 roll, pitch, yaw 회전이다. 모두 0이므로 LiDAR가 로봇 정면을 바라보고 있다고 해석할 수 있다.
중요한 점은 부모와 자식의 방향이다. parent가 base_link이고 child가 lidar_link라면, 이 조인트는 'LiDAR가 차체 기준 어디에 붙어 있는가'를 말한다. 이 방향이 뒤집히면 TF 트리도 뒤집힌다. URDF를 작성할 때는 항상 머릿속으로 이렇게 읽어 보자.
부모 링크 기준으로 자식 링크가 어디에 있는가?이 질문이 자연스럽게 답해지면 URDF 구조가 올바른 방향으로 잡히고 있을 가능성이 높다.
차동구동 로봇의 기준 좌표 잡기
이 장에서 만들 로봇은 작은 차동구동 로봇이다. 차동구동은 왼쪽 바퀴와 오른쪽 바퀴의 속도 차이로 움직이는 방식이다. 두 바퀴가 같은 속도로 돌면 앞으로 가고, 왼쪽 바퀴와 오른쪽 바퀴가 반대로 돌면 제자리에서 회전한다. 많은 교육용 이동 로봇과 실내 배송 로봇이 이 구조를 사용한다.
먼저 좌표 방향을 정하자. ROS에서 이동 로봇은 보통 다음 관례를 따른다.
x축: 로봇의 앞쪽
y축: 로봇의 왼쪽
z축: 위쪽이 관례를 따르면 왼쪽 바퀴는 y가 양수인 쪽에 있고, 오른쪽 바퀴는 y가 음수인 쪽에 있다. LiDAR가 로봇 앞쪽에 달려 있다면 x가 양수인 위치에 놓인다. 바닥 위 높이는 z 값으로 표현한다.
이번 예제 로봇의 대략적인 치수는 다음과 같다.
차체 크기: 길이 0.32m, 폭 0.24m, 높이 0.12m
바퀴 반지름: 0.05m, 두께 0.035m
좌우 바퀴 중심 간격: 0.28m
LiDAR 위치: 차체 중심보다 앞쪽 0.10m, 위쪽 0.10m
여기서 차체 중심을 base_link로 두고, 바닥에 닿는 로봇 중심의 투영점을 base_footprint로 둔다. base_link는 로봇 본체의 중심에 가까운 3차원 프레임이고, base_footprint는 바닥 평면에서 로봇의 대표 위치를 나타내는 프레임이다.
두 프레임을 나누면 Nav2와 같은 내비게이션 시스템에서 편리하다. 로봇이 약간 기울어지거나 센서가 높은 곳에 있어도, 경로 계획은 보통 바닥 위에서 이루어진다. 이때 base_footprint는 로봇이 바닥에서 차지하는 위치를 표현하기 좋다. 물론 모든 프로젝트가 반드시 base_footprint를 써야 하는 것은 아니다. 어떤 시스템은 base_link를 바로 로봇 기준 프레임으로 사용한다. 중요한 것은 하나를 정한 뒤 끝까지 일관되게 쓰는 것이다.
description 패키지 만들기
URDF 파일은 보통 description 패키지에 넣는다. 예를 들어 로봇 이름이 my_robot이라면 my_robot_description 패키지를 만든다. 이 패키지는 실행 노드보다 로봇 모델, 메시, RViz 설정, launch 파일 같은 설명 자료를 담는 곳이다.
워크스페이스의 src 디렉터리에서 다음 명령을 실행한다.
cd ~/ros2_ws/src
ros2 pkg create my_robot_description --build-type ament_cmake그리고 URDF와 launch 파일을 넣을 디렉터리를 만든다.
cd ~/ros2_ws/src/my_robot_description
mkdir -p urdf launch rviz패키지 구조는 대략 이렇게 된다.
my_robot_description/
├── CMakeLists.txt
├── package.xml
├── urdf/
│ └── mini_diff_bot.urdf
├── launch/
│ └── display.launch.py
└── rviz/CMakeLists.txt에는 URDF와 launch 디렉터리가 설치되도록 다음 내용을 추가한다.
install(DIRECTORY urdf launch rviz
DESTINATION share/${PROJECT_NAME}
)이 설정을 해 두어야 빌드 후에도 ros2 launch가 패키지의 URDF 파일을 찾을 수 있다. ROS2 패키지는 소스 디렉터리에서 직접 파일을 읽는 것이 아니라, 빌드 후 설치 공간의 share 디렉터리에서 리소스를 찾는 방식으로 동작하는 경우가 많다. 그래서 description 패키지에서도 설치 규칙을 챙겨야 한다.
visual, collision, inertial: 외모, 몸집, 무게
링크 안에는 세 가지 중요한 요소가 들어갈 수 있다. visual, collision, inertial이다. 셋은 비슷해 보이지만 역할이 다르다.
visual은 화면에 어떻게 보일지를 정한다. RViz나 Gazebo에서 로봇을 볼 때 사용하는 모양과 색이다. 멋진 3D 메시 파일을 쓸 수도 있고, 상자, 원기둥, 구 같은 기본 도형을 쓸 수도 있다.
collision은 충돌 판정에 사용하는 형상이다. 물리 시뮬레이터가 벽과 부딪히는지 계산하거나, 로봇 주변 안전 공간을 생각할 때 쓰인다. 충돌 형상은 시각 형상보다 단순하게 만드는 것이 좋다. 눈으로는 복잡한 로봇처럼 보여도 충돌 계산은 단순한 상자 몇 개로 처리하는 편이 빠르고 안정적이다.
inertial은 질량과 관성 정보를 담는다. 질량은 얼마나 무거운지, 관성은 회전하기 얼마나 어려운지를 나타낸다. Gazebo 같은 물리 시뮬레이터에서 로봇이 자연스럽게 움직이려면 이 값이 필요하다. RViz로 모양만 볼 때는 없어도 크게 티가 나지 않지만, 시뮬레이션에서 바퀴가 헛돌거나 로봇이 이상하게 튀면 inertial 값이 엉망인 경우가 많다.
차체 링크는 다음처럼 쓸 수 있다.
<link name='base_link'>
<visual>
<origin xyz='0 0 0' rpy='0 0 0'/>
<geometry>
<box size='0.32 0.24 0.12'/>
</geometry>
<material name='body_blue'/>
</visual>
<collision>
<origin xyz='0 0 0' rpy='0 0 0'/>
<geometry>
<box size='0.32 0.24 0.12'/>
</geometry>
</collision>
<inertial>
<origin xyz='0 0 0' rpy='0 0 0'/>
<mass value='2.0'/>
<inertia ixx='0.018' ixy='0' ixz='0' iyy='0.024' iyz='0' izz='0.035'/>
</inertial>
</link>여기서 box size='0.32 0.24 0.12'는 x 방향 0.32m, y 방향 0.24m, z 방향 0.12m인 상자를 뜻한다. 즉 로봇의 앞뒤 길이가 32cm, 좌우 폭이 24cm, 높이가 12cm다.
초보자가 자주 하는 실수는 visual만 작성하고 collision과 inertial을 잊는 것이다. RViz에서는 로봇이 멀쩡해 보이기 때문에 문제가 없는 것처럼 느껴진다. 하지만 Gazebo로 넘어가면 로봇이 바닥을 통과하거나, 벽과 이상하게 충돌하거나, 질량이 없어 물리 계산이 불안정해질 수 있다. 처음부터 세 요소를 함께 생각하는 습관을 들이면 나중에 시뮬레이션 장에서 훨씬 편해진다.
전체 URDF 예제 작성하기
이제 urdf/mini_diff_bot.urdf 파일을 만들고 다음 내용을 넣어 보자. 조금 길지만 겁먹을 필요는 없다. 구조를 보면 같은 패턴이 반복된다. 링크를 만들고, 조인트로 붙인다. 이것이 전부다.
<?xml version='1.0'?>
<robot name='mini_diff_bot'>
<material name='body_blue'>
<color rgba='0.1 0.35 0.9 1.0'/>
</material>
<material name='dark_gray'>
<color rgba='0.05 0.05 0.05 1.0'/>
</material>
<material name='sensor_black'>
<color rgba='0.0 0.0 0.0 1.0'/>
</material>
<material name='caster_gray'>
<color rgba='0.45 0.45 0.45 1.0'/>
</material>
<!-- 바닥 위의 로봇 기준점 -->
<link name='base_footprint'/>
<joint name='base_footprint_joint' type='fixed'>
<parent link='base_footprint'/>
<child link='base_link'/>
<origin xyz='0 0 0.08' rpy='0 0 0'/>
</joint>
<!-- 차체 -->
<link name='base_link'>
<visual>
<origin xyz='0 0 0' rpy='0 0 0'/>
<geometry>
<box size='0.32 0.24 0.12'/>
</geometry>
<material name='body_blue'/>
</visual>
<collision>
<origin xyz='0 0 0' rpy='0 0 0'/>
<geometry>
<box size='0.32 0.24 0.12'/>
</geometry>
</collision>
<inertial>
<origin xyz='0 0 0' rpy='0 0 0'/>
<mass value='2.0'/>
<inertia ixx='0.018' ixy='0' ixz='0' iyy='0.024' iyz='0' izz='0.035'/>
</inertial>
</link>
<!-- 왼쪽 바퀴 -->
<link name='left_wheel_link'>
<visual>
<origin xyz='0 0 0' rpy='1.5708 0 0'/>
<geometry>
<cylinder radius='0.05' length='0.035'/>
</geometry>
<material name='dark_gray'/>
</visual>
<collision>
<origin xyz='0 0 0' rpy='1.5708 0 0'/>
<geometry>
<cylinder radius='0.05' length='0.035'/>
</geometry>
</collision>
<inertial>
<origin xyz='0 0 0' rpy='0 0 0'/>
<mass value='0.2'/>
<inertia ixx='0.0002' ixy='0' ixz='0' iyy='0.0003' iyz='0' izz='0.0002'/>
</inertial>
</link>
<joint name='left_wheel_joint' type='continuous'>
<parent link='base_link'/>
<child link='left_wheel_link'/>
<origin xyz='0 0.14 -0.03' rpy='0 0 0'/>
<axis xyz='0 1 0'/>
</joint>
<!-- 오른쪽 바퀴 -->
<link name='right_wheel_link'>
<visual>
<origin xyz='0 0 0' rpy='1.5708 0 0'/>
<geometry>
<cylinder radius='0.05' length='0.035'/>
</geometry>
<material name='dark_gray'/>
</visual>
<collision>
<origin xyz='0 0 0' rpy='1.5708 0 0'/>
<geometry>
<cylinder radius='0.05' length='0.035'/>
</geometry>
</collision>
<inertial>
<origin xyz='0 0 0' rpy='0 0 0'/>
<mass value='0.2'/>
<inertia ixx='0.0002' ixy='0' ixz='0' iyy='0.0003' iyz='0' izz='0.0002'/>
</inertial>
</link>
<joint name='right_wheel_joint' type='continuous'>
<parent link='base_link'/>
<child link='right_wheel_link'/>
<origin xyz='0 -0.14 -0.03' rpy='0 0 0'/>
<axis xyz='0 1 0'/>
</joint>
<!-- 뒤쪽 캐스터 -->
<link name='caster_link'>
<visual>
<origin xyz='0 0 0' rpy='0 0 0'/>
<geometry>
<sphere radius='0.025'/>
</geometry>
<material name='caster_gray'/>
</visual>
<collision>
<origin xyz='0 0 0' rpy='0 0 0'/>
<geometry>
<sphere radius='0.025'/>
</geometry>
</collision>
<inertial>
<origin xyz='0 0 0' rpy='0 0 0'/>
<mass value='0.05'/>
<inertia ixx='0.00002' ixy='0' ixz='0' iyy='0.00002' iyz='0' izz='0.00002'/>
</inertial>
</link>
<joint name='caster_joint' type='fixed'>
<parent link='base_link'/>
<child link='caster_link'/>
<origin xyz='-0.12 0 -0.055' rpy='0 0 0'/>
</joint>
<!-- 2D LiDAR -->
<link name='lidar_link'>
<visual>
<origin xyz='0 0 0' rpy='0 0 0'/>
<geometry>
<cylinder radius='0.04' length='0.04'/>
</geometry>
<material name='sensor_black'/>
</visual>
<collision>
<origin xyz='0 0 0' rpy='0 0 0'/>
<geometry>
<cylinder radius='0.04' length='0.04'/>
</geometry>
</collision>
<inertial>
<origin xyz='0 0 0' rpy='0 0 0'/>
<mass value='0.1'/>
<inertia ixx='0.00005' ixy='0' ixz='0' iyy='0.00005' iyz='0' izz='0.00005'/>
</inertial>
</link>
<joint name='lidar_joint' type='fixed'>
<parent link='base_link'/>
<child link='lidar_link'/>
<origin xyz='0.10 0 0.10' rpy='0 0 0'/>
</joint>
</robot>파일을 처음 보면 XML 태그가 많아 복잡해 보인다. 그러나 큰 구조만 보면 다음과 같다.
base_footprint
└── base_link
├── left_wheel_link
├── right_wheel_link
├── caster_link
└── lidar_link이 트리가 바로 로봇의 골격이다. base_footprint에서 base_link로 올라가고, base_link 아래에 바퀴와 캐스터와 LiDAR가 붙는다. 이전 장에서 손으로 만들었던 TF 트리를 이제 URDF 파일 하나로 표현한 것이다.
원기둥 바퀴를 옆으로 눕히기
바퀴 부분에서 눈에 띄는 값이 있다.
<origin xyz='0 0 0' rpy='1.5708 0 0'/>왜 바퀴의 visual과 collision에 이런 회전이 들어갔을까? URDF의 cylinder는 기본적으로 원기둥의 축이 z축 방향이다. 쉽게 말해 원기둥이 세로로 서 있다. 하지만 이동 로봇의 바퀴는 좌우 방향의 축을 중심으로 돌아야 한다. 그래서 원기둥을 90도 눕혀야 한다. 1.5708 라디안은 약 90도다.
조인트에서는 바퀴가 어느 축을 기준으로 회전하는지도 정한다.
<axis xyz='0 1 0'/>이 값은 바퀴가 y축을 중심으로 회전한다는 뜻이다. 로봇 좌표에서 y축은 왼쪽 방향이다. 좌우 바퀴를 잇는 축이 바로 y축이므로, 차동구동 로봇의 바퀴 조인트에는 보통 이런 축 설정이 들어간다.
물론 실제 로봇 드라이버나 Gazebo 플러그인에서 양수 회전 방향을 어떻게 해석하느냐에 따라 왼쪽과 오른쪽 바퀴의 부호를 조정해야 할 수 있다. 하지만 URDF 단계에서는 먼저 기하 구조를 정확히 만드는 것이 중요하다. 바퀴가 로봇 옆에 붙어 있고, 축 방향이 좌우로 향하며, 바닥에 닿는 높이에 있는지 확인하자.
캐스터는 어떻게 표현할까
캐스터는 조금 애매한 부품이다. 실제 캐스터는 바닥과 접촉하면서 방향을 자유롭게 바꾸는 수동 바퀴다. 정교하게 모델링하려면 회전축과 바퀴축을 따로 만들어야 한다. 하지만 처음부터 그렇게 복잡하게 만들 필요는 없다.
이 장에서는 캐스터를 작은 구로 표현하고 fixed 조인트로 차체에 붙였다.
<joint name='caster_joint' type='fixed'>
<parent link='base_link'/>
<child link='caster_link'/>
<origin xyz='-0.12 0 -0.055' rpy='0 0 0'/>
</joint>x=-0.12는 캐스터가 차체 뒤쪽에 있다는 뜻이다. z=-0.055는 base_link 중심보다 아래쪽에 있다는 뜻이다. 앞에서 base_link는 base_footprint보다 0.08m 위에 놓았다. 캐스터 구의 반지름은 0.025m다. 따라서 캐스터 중심의 실제 높이는 대략 0.08 - 0.055 = 0.025m이고, 구의 아래쪽은 바닥 z=0에 닿는다.
이런 숫자 맞추기는 별것 아닌 것 같지만 매우 중요하다. 바퀴 반지름이 0.05m라면 바퀴 중심도 바닥에서 0.05m 높이에 있어야 한다. 차체 아래쪽은 바닥보다 살짝 위에 있어야 한다. 만약 바퀴가 차체 안에 파묻히거나 공중에 떠 있으면, 시각화에서는 어색해 보이고 시뮬레이션에서는 더 큰 문제가 생긴다.
robot_state_publisher는 URDF를 TF로 바꾼다
URDF 파일은 그 자체로는 조용한 문서다. ROS2 시스템의 다른 노드들이 이 문서를 사용하려면 누군가 URDF를 읽고 TF를 발행해야 한다. 그 역할을 하는 대표적인 노드가 robot_state_publisher다.
robot_state_publisher의 동작은 다음처럼 이해할 수 있다.
robot_description파라미터에서 URDF 내용을 읽는다./joint_states토픽에서 움직이는 조인트의 현재 각도나 위치를 받는다.URDF의 링크와 조인트 관계를 이용해 로봇 내부 TF를 계산하고 발행한다.
고정 조인트는 값이 변하지 않는다. 예를 들어 base_link -> lidar_link는 로봇이 움직여도 변하지 않는 관계다. 따라서 robot_state_publisher는 이런 고정 관계를 정적 TF로 발행할 수 있다. 반면 left_wheel_joint와 right_wheel_joint는 continuous 조인트다. 바퀴가 회전하면 조인트 값이 달라지므로 /joint_states 메시지가 필요하다.
중요한 점이 하나 있다. robot_state_publisher는 로봇 내부의 관절 관계를 발행한다. 즉 base_link -> lidar_link, base_link -> left_wheel_link 같은 변환을 만든다. 하지만 로봇이 지도 위에서 어디에 있는지를 나타내는 odom -> base_link나 map -> odom을 마음대로 만들어 주지는 않는다. 이 관계는 오도메트리 노드, 시뮬레이터, SLAM, 위치 추정 노드가 담당한다.
전체 TF 구조는 나중에 대략 이렇게 된다.
map
└── odom
└── base_footprint
└── base_link
├── left_wheel_link
├── right_wheel_link
├── caster_link
└── lidar_link여기서 base_footprint -> base_link와 그 아래 센서, 바퀴 관계는 URDF와 robot_state_publisher가 만든다. odom -> base_footprint는 로봇의 이동을 추정하는 노드가 만든다. map -> odom은 SLAM이나 위치 추정 시스템이 만든다. 서로 담당 영역이 다르므로 같은 TF를 두 노드가 동시에 발행하지 않도록 조심해야 한다.
launch 파일로 모델 표시하기
이제 URDF를 실제로 ROS2 시스템에 올려 보자. launch/display.launch.py 파일을 만든다.
import os
from ament_index_python.packages import get_package_share_directory
from launch import LaunchDescription
from launch_ros.actions import Node
def generate_launch_description():
package_dir = get_package_share_directory('my_robot_description')
urdf_path = os.path.join(package_dir, 'urdf', 'mini_diff_bot.urdf')
with open(urdf_path, 'r') as urdf_file:
robot_description = urdf_file.read()
return LaunchDescription([
Node(
package='robot_state_publisher',
executable='robot_state_publisher',
name='robot_state_publisher',
output='screen',
parameters=[{'robot_description': robot_description}],
),
Node(
package='joint_state_publisher_gui',
executable='joint_state_publisher_gui',
name='joint_state_publisher_gui',
output='screen',
),
])joint_state_publisher_gui는 실제 로봇 없이 조인트 값을 임시로 발행해 주는 도구다. 바퀴 조인트처럼 움직이는 조인트가 있을 때 RViz에서 모델을 확인하기 좋다. 실제 로봇에서는 이 역할을 바퀴 엔코더나 제어기가 대신한다. 예를 들어 모터 컨트롤러가 왼쪽 바퀴와 오른쪽 바퀴의 회전량을 읽어 /joint_states로 발행할 수 있다.
만약 joint_state_publisher_gui가 설치되어 있지 않다면 다음처럼 설치할 수 있다.
sudo apt install ros-$ROS_DISTRO-joint-state-publisher-gui이제 빌드하고 실행한다.
cd ~/ros2_ws
colcon build --packages-select my_robot_description
source install/setup.bash
ros2 launch my_robot_description display.launch.py다른 터미널에서 TF를 확인해 보자.
source ~/ros2_ws/install/setup.bash
ros2 run tf2_ros tf2_echo base_link lidar_link정상이라면 base_link 기준의 lidar_link 위치가 출력된다. 앞에서 URDF에 적은 값처럼 x는 0.10, y는 0, z는 0.10 근처가 되어야 한다.
바퀴도 확인할 수 있다.
ros2 run tf2_ros tf2_echo base_link left_wheel_linkGUI에서 왼쪽 바퀴 조인트 값을 움직이면 바퀴 링크의 회전이 달라진다. 이때 robot_state_publisher는 /joint_states 값을 받아 URDF의 조인트 축과 원점을 적용하고, 그 결과를 TF로 내보낸다. 우리가 직접 left_wheel_link의 TF를 계산하지 않아도 되는 이유가 여기에 있다.
URDF와 RViz, Gazebo, Nav2의 관계
URDF는 여러 도구가 함께 쓰는 공통 언어다. 하지만 각 도구가 URDF의 모든 정보를 같은 방식으로 쓰는 것은 아니다.
RViz는 주로 visual과 TF를 사용한다. 로봇 모델을 화면에 보여 주려면 링크의 모양과 색, 링크 사이의 위치 관계가 필요하다. 그래서 visual이 빠져 있으면 RViz에서 해당 링크가 보이지 않을 수 있다. TF가 끊겨 있으면 링크들이 올바른 위치에 배치되지 않는다.
Gazebo 같은 시뮬레이터는 collision과 inertial을 중요하게 사용한다. 로봇이 바닥에 서 있고 벽과 부딪히며 바퀴가 굴러가려면 충돌 형상과 질량, 관성 정보가 필요하다. 시각적으로 멋진 모델도 충돌 형상이 없으면 물리 세계에서는 유령처럼 벽을 통과할 수 있다.
Nav2는 보통 URDF를 직접 읽어 로봇 footprint를 자동으로 결정하지 않는다. Nav2 설정 파일에는 robot_radius나 footprint 같은 값을 별도로 넣는다. 하지만 이 값은 URDF의 실제 로봇 크기와 맞아야 한다. URDF상 로봇 폭이 24cm인데 Nav2 footprint를 15cm로 설정하면, Nav2는 로봇이 실제보다 날씬하다고 착각한다. 그러면 문틈이나 가구 사이를 지나갈 수 있다고 판단하지만, 실제 로봇은 옆구리를 부딪칠 수 있다.
따라서 URDF는 단순히 예쁜 모델 파일이 아니다. 로봇의 형상과 좌표계를 정하는 기준 문서다. 이 기준과 Nav2 설정이 어긋나면 자율주행은 불안해진다.
로봇 크기와 Nav2 footprint
Nav2의 costmap은 로봇 주변의 장애물을 격자 지도에 표시한다. 장애물이 있는 칸은 위험하고, 장애물 주변도 일정 거리만큼 위험하게 부풀린다. 이때 로봇이 실제로 차지하는 바닥 면적을 알아야 안전한 경로를 만들 수 있다. 이 바닥 면적을 footprint라고 부른다.
이번 예제 로봇의 차체는 길이 0.32m, 폭 0.24m다. 바퀴가 좌우로 조금 튀어나와 있으므로 전체 폭은 바퀴 위치와 두께를 고려해야 한다. 왼쪽 바퀴 중심 y는 0.14m이고, 바퀴 두께는 0.035m다. 그러면 왼쪽 바깥 끝은 대략 0.1575m 근처다. 오른쪽도 마찬가지로 -0.1575m 근처까지 차지한다.
안전 여유를 조금 더해 Nav2 footprint를 다음처럼 잡을 수 있다.
footprint: [[0.18, 0.17], [0.18, -0.17], [-0.18, -0.17], [-0.18, 0.17]]이 값은 로봇 중심 기준으로 앞쪽 18cm, 뒤쪽 18cm, 왼쪽 17cm, 오른쪽 17cm인 사각형을 의미한다. 실제 차체보다 약간 크게 잡은 것이다. 로봇이 벽을 바짝 스치며 지나가는 것보다, 약간 여유 있게 움직이는 편이 실내 자율주행에서는 훨씬 안전하다.
원형에 가까운 로봇이라면 robot_radius를 사용할 수도 있다.
robot_radius: 0.22하지만 차동구동 로봇은 앞뒤 길이와 좌우 폭이 다른 경우가 많다. 이런 경우에는 사각형 footprint가 실제 형상을 더 잘 반영한다. 나중에 Costmap 장에서 자세히 다루겠지만, 여기서는 한 가지만 기억하면 된다. URDF에서 정한 로봇의 크기와 Nav2 footprint는 서로 다른 문서에 있더라도 같은 실제 로봇을 가리켜야 한다.
센서 위치가 costmap에 미치는 영향
LiDAR 위치도 안전성에 직접 영향을 준다. 2D LiDAR는 자기 프레임 기준으로 장애물까지의 거리와 각도를 측정한다. 예를 들어 LiDAR가 정면 1m에서 벽을 보았다고 하자. 이 점을 costmap에 넣으려면 TF를 이용해 lidar_link 기준의 측정값을 base_link, odom, map 같은 프레임으로 변환해야 한다.
만약 URDF에서 LiDAR 위치를 실제보다 10cm 뒤에 적었다면 어떤 일이 생길까? 로봇은 벽이 실제보다 더 가까이 있다고 생각하거나, 반대로 더 멀리 있다고 생각할 수 있다. 장애물 점들이 지도 위에서 흔들리고, 벽이 두껍게 보이며, 좁은 통로에서 로봇이 불필요하게 멈출 수 있다.
반대로 LiDAR의 yaw 회전이 잘못되어 있으면 더 이상한 일이 벌어진다. 실제 LiDAR는 정면을 보고 있는데 URDF에는 180도 돌아간 것으로 적혀 있다면, 앞쪽 장애물이 뒤쪽에 있는 것처럼 해석된다. 로봇 입장에서는 앞은 비어 있고 뒤에 벽이 있는 것처럼 느끼는 셈이다. 이런 상태로 Nav2를 실행하면 경로 계획과 장애물 회피가 모두 불안정해진다.
그래서 센서 조인트는 숫자 몇 개로 끝나는 사소한 설정이 아니다. 로봇이 세상을 어디서, 어느 방향으로 보고 있는지를 정하는 선언이다.
<joint name='lidar_joint' type='fixed'>
<parent link='base_link'/>
<child link='lidar_link'/>
<origin xyz='0.10 0 0.10' rpy='0 0 0'/>
</joint>이 네 줄은 'LiDAR는 차체 중심보다 10cm 앞, 10cm 위에 있고, 로봇과 같은 방향을 본다'는 뜻이다. 나중에 SLAM과 Nav2가 이 정보를 믿고 움직인다. 그러니 실제 로봇에 줄자를 대고 측정한 값과 URDF 값을 맞추는 일은 로봇 개발에서 꽤 현실적이고 중요한 작업이다.
URDF를 확인하는 습관
URDF를 작성한 뒤에는 반드시 확인해야 한다. XML은 태그 하나만 빠져도 전체 파일이 읽히지 않을 수 있다. 좌표값이 틀려도 에러가 나지 않는 경우가 많다. 문법은 맞지만 로봇이 이상한 자세로 나타나는 것이다.
먼저 XML 구조가 깨지지 않았는지 확인한다. 시스템에 check_urdf 도구가 있다면 다음처럼 실행할 수 있다.
check_urdf ~/ros2_ws/src/my_robot_description/urdf/mini_diff_bot.urdf그다음 robot_state_publisher를 실행하고 TF를 확인한다.
ros2 launch my_robot_description display.launch.py
ros2 run tf2_ros tf2_echo base_link lidar_link프레임 트리도 확인한다.
ros2 run tf2_tools view_frames확인할 때는 다음 세 가지를 특히 보자.
링크 이름이 프로젝트 전체에서 일관되는가?
부모 링크와 자식 링크의 방향이 의도한 대로인가?
센서와 바퀴의 위치가 실제 로봇 치수와 맞는가?
RViz에서 모델을 볼 때도 단순히 '보인다'에서 끝내면 안 된다. 로봇 앞쪽이 x축 방향과 맞는지, 왼쪽 바퀴가 y 양수 쪽에 있는지, LiDAR가 차체 앞쪽에 있는지 확인하자. 바퀴가 공중에 떠 있거나, 캐스터가 바닥 아래로 파묻혀 있거나, LiDAR가 뒤를 보고 있으면 나중에 더 큰 문제로 돌아온다.
자주 생기는 실수들
URDF를 처음 작성할 때는 비슷한 실수가 반복된다. 실수 자체는 괜찮다. 문제는 그 실수를 TF, SLAM, Nav2 단계까지 끌고 가는 것이다. 초기에 발견하면 몇 분이면 고칠 일을, 나중에는 하루 종일 디버깅할 수 있다.
가장 흔한 실수는 단위 착각이다. 센서가 15cm 앞에 있으니 15라고 쓰는 경우가 있다. URDF에서 15는 15m다. 반드시 0.15라고 써야 한다.
두 번째는 회전 축 착각이다. 원기둥 바퀴는 기본적으로 z축 방향으로 서 있다. 이를 옆으로 눕히지 않으면 바퀴가 동전처럼 로봇 옆에 붙는 것이 아니라 기둥처럼 서 있을 수 있다. 바퀴 조인트의 axis도 실제 회전축과 맞아야 한다.
세 번째는 TF 중복 발행이다. URDF와 robot_state_publisher가 이미 base_link -> lidar_link를 발행하고 있는데, 별도로 static_transform_publisher로 같은 변환을 또 발행하면 충돌이 생길 수 있다. 같은 부모와 자식 프레임 관계는 한 노드가 책임지는 것이 좋다.
네 번째는 Nav2 설정과 URDF 크기 불일치다. URDF에서는 로봇이 큰데 footprint는 작게 잡으면 로봇이 벽에 가까이 붙는다. 반대로 URDF보다 footprint를 지나치게 크게 잡으면 지나갈 수 있는 길도 못 지나간다고 판단한다. 안전 여유는 필요하지만, 실제 크기와 완전히 동떨어지면 자율주행 성능이 떨어진다.
URDF는 시작이고, xacro는 관리 방법이다
지금 작성한 URDF에는 반복이 많다. 왼쪽 바퀴와 오른쪽 바퀴는 거의 같은 내용인데 y 위치만 다르다. visual과 collision도 같은 도형을 반복해서 적었다. 로봇이 조금만 커져도 URDF 파일은 금방 길어진다.
그래서 실제 프로젝트에서는 URDF를 그대로 길게 쓰기보다 xacro를 많이 사용한다. xacro는 URDF를 더 편하게 작성하기 위한 매크로 도구다. 바퀴 반지름, 차체 크기, 센서 위치를 변수로 만들고, 반복되는 링크 구조를 함수처럼 재사용할 수 있다. 예를 들어 wheel_radius 값을 한 번 바꾸면 왼쪽 바퀴와 오른쪽 바퀴가 함께 바뀌게 만들 수 있다.
하지만 xacro를 배우기 전에 URDF의 기본 구조를 이해해야 한다. xacro가 아무리 편해도 최종적으로 만들어지는 것은 URDF다. 링크와 조인트, visual과 collision과 inertial, robot_state_publisher와 TF의 관계를 모르면 xacro 파일도 그저 복잡한 템플릿처럼 보일 뿐이다.
이 장에서 만든 로봇은 아직 움직이지 않는다. 하지만 중요한 일이 이미 일어났다. 로봇의 몸이 생겼고, 바퀴와 센서가 제자리를 찾았으며, ROS2 시스템이 같은 설계도를 바라볼 준비가 되었다. 다음 장에서는 이 설계도를 더 깔끔하게 관리하기 위해 xacro를 사용한다. 반복을 줄이고, 숫자에 이름을 붙이고, 로봇 모델을 프로젝트답게 키워 갈 차례다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.