
ROS2(Humble) + Gazebo(Harmonic)로 더블 펜듈럼을 세우는 프로젝트를 진행하면서
Phase 1에서 URDF/Xacro로 로봇 모델을 직접 작성했다. 이와 관련하여 URDF의 핵심 개념을 정리해본다.
Unified Robot Description Format. 로봇의 기구학(kinematic)
구조를 XML로 기술하는 표준 포맷이다.
<robot name="my_robot">
...
</robot>
<robot> 태그는 URDF 파일 전체를 감싸는 최상위 루트 요소다. 이 태그
안에 로봇을 이루는 모든 <link>와 <joint>가 들어간다.
URDF에서 로봇은 link(강체)와 joint(관절)가 번갈아 이어진 그래프로
표현된다. 이 그래프는 반드시 트리(tree) 구조여야 한다. 즉 닫힌
루프(closed kinematic chain)를 표현할 수 없다.
박스(link1~link4)가 강체, 파란 타원(joint1~joint3)이 관절이다. link1
하나에서 joint1/joint2 두 갈래로 뻗어나가고, 각 갈래가 다시 한 줄로 이어지는
트리 구조이다. 한 link가 여러 자식을 가질 수는 있지만(link1처럼),
자식이 부모 여러 개를 갖거나 경로가 다시 합쳐지는 경우는 없다.
<link> — 강체 하나를 정의하는 태그하나의 강체(rigid body)는 보통 3가지 요소로 기술한다.
<link name="LINK_NAME">
<visual>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry><!-- box / cylinder / sphere / mesh --></geometry>
<material name="..."/>
</visual>
<collision>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry><!-- 보통 visual보다 단순한 형상 --></geometry>
</collision>
<inertial>
<origin xyz="0 0 0" rpy="0 0 0"/>
<mass value="..."/>
<inertia ixx="..." ixy="..." ixz="..." iyy="..." iyz="..." izz="..."/>
</inertial>
</link>
<visual>: 화면에 렌더링되는 형상. geometry(box/cylinder/sphere/mesh)<collision>: 충돌 계산에 쓰이는 형상. 실제 로봇은 렌더링용 메시가<inertial>: mass, origin(무게중심 위치), <inertia>ixx iyy izz ixy ixz iyz) — 물리 시뮬레이션에 필요한 질량<joint> — 두 link를 연결하는 태그<joint name="JOINT_NAME" type="revolute">
<parent link="PARENT_LINK"/>
<child link="CHILD_LINK"/>
<origin xyz="0 0 0" rpy="0 0 0"/>
<axis xyz="0 0 1"/>
<limit lower="..." upper="..." effort="..." velocity="..."/>
<dynamics damping="..." friction="..."/>
</joint>
<parent> / <child>: 이 조인트가 어떤 link를 어떤 link에<origin>: parent link 좌표계 기준으로 이 조인트(= child link의revolute(각도 제한 있음), continuous(제한 없이 무한 회전),prismatic(직동), fixed(고정), floating/planar(자유도 여러 개) 등<axis>: 회전/이동 축 방향<limit>: effort(최대 힘/토크), velocity(최대 속도) 등 동작<dynamics>: damping(감쇠), friction(마찰) — 관절의 물리적위 개념들을 개인 프로젝트(doublePendulum_sim)에
실제로 어떻게 적용되었는지를 정리한다(이중진자 모델). 2-link, fully actuated, revolute joint
두 개짜리 구조를 만들면서 xacro로 작성한 실제 코드를 그대로 가져왔다.
기둥 역할을 하는 base_link는 2장에서 본 <link> 구조를 그대로 채운
가장 단순한 형태다:
<link name="base_link">
<visual>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry><cylinder radius="0.06" length="0.06"/></geometry>
<material name="base_grey"/>
</visual>
<collision>
<origin xyz="0 0 0" rpy="0 0 0"/>
<geometry><cylinder radius="0.06" length="0.06"/></geometry>
</collision>
<inertial>
<mass value="1.0"/>
<inertia ixx="0.001" ixy="0" ixz="0" iyy="0.001" iyz="0" izz="0.001"/>
</inertial>
</link>
앞서 설명했듯, <visual>에서 시각화, <collision>에서 실제 물리 엔진이 관여하는 모양 정의, <inertial>에서 질량과 관성 텐서가 정의된 것을 볼 수 있다.
이 이중 진자 모델에서 실제로 움직이는 부분인 link1/link2는 매크로로 정의한 균일 막대(uniform rod)다 (매크로 설명은 3-3에서):
<xacro:macro name="rod_link" params="name mass length radius material_name">
<link name="${name}">
<visual>
<origin xyz="0 0 ${length/2}" rpy="0 0 0"/>
<geometry><cylinder radius="${radius}" length="${length}"/></geometry>
<material name="${material_name}"/>
</visual>
<collision>
<origin xyz="0 0 ${length/2}" rpy="0 0 0"/>
<geometry><cylinder radius="${radius}" length="${length}"/></geometry>
</collision>
<inertial>
<origin xyz="0 0 ${length/2}" rpy="0 0 0"/>
<mass value="${mass}"/>
<inertia
ixx="${mass*(3*radius*radius+length*length)/12}" ixy="0" ixz="0"
iyy="${mass*(3*radius*radius+length*length)/12}" iyz="0"
izz="${mass*radius*radius/2}"/>
</inertial>
</link>
</xacro:macro>
link1/link2의 <inertia> 값은 물리 공식으로 직접 계산해서 넣었다.
관성은 무게중심 기준으로 줘야하기 때문에 <inertial><origin>을 length/2만큼 띄워서 COM 위치를 명시했다.
cylinder의 관성 공식은:
가늘고 긴 막대()로 근사하면 로, 균일 막대 공식이 된다.
실제 값 대입(m=1.0kg, L=1.0m,r=0.02m):
ixx = iyy = 1.0 * (3*0.02² + 1.0²) / 12 ≈ 0.0834
izz = 1.0 * 0.02² / 2 ≈ 0.0002
<joint name="joint1" type="revolute">
<parent link="base_link"/>
<child link="link1"/>
<origin xyz="0 0 0" rpy="0 0 0"/>
<axis xyz="0 1 0"/>
<limit lower="-1000" upper="1000" effort="60.0" velocity="50.0"/>
<dynamics damping="0.05" friction="0.0"/>
</joint>

revolute: continuous(무한 회전)와 달리<limit>에 lower/upper 각도 제한을 반드시 명시해야 하는 회전관절lower="-1000" upper="1000"으로 사실상 거의revolute와 continuous의 차이다.<axis xyz="0 1 0">: Y축 회전 → X-Z 평면에서 스윙한다는 뜻. 위<limit>: effort(최대 토크), velocity(최대 각속도) — 컨트롤러joint1은 tau1_max=60.0,joint2는 tau2_max=30.0으로 서로 다르게 줬다 (아래팔쪽이 더 가벼운<dynamics damping="0.05">: 0으로 두면 시뮬레이션이 비현실적으로Xacro(XML Macro)는 URDF를 짤 때 반복되는 XML을 변수·매크로로 줄여주는 전처리기다. 순수 URDF는 값이 없는 정적 XML이라서, 같은 숫자를 여러 군데에 직접 박아 넣어야 하는 상황이 자주 생긴다. .urdf.xacro 파일을 xacro 명령으로 처리하면, 아래 나올 변수/매크로가 전부 실제 값으로 치환된 순수 .urdf(표준 XML)가 만들어진다.
<xacro:property name="m1" value="1.0"/>
<xacro:property name="m2" value="1.0"/>
<xacro:property name="L1" value="1.0"/>
<xacro:property name="L2" value="1.0"/>
<xacro:property name="rod_radius" value="0.02"/>
<xacro:property name="joint_damping1" value="0.05"/>
<xacro:property name="joint_damping2" value="0.05"/>
<xacro:property name="tau1_max" value="60.0"/>
<xacro:property name="tau2_max" value="30.0"/>
<xacro:property name="qd_max" value="50.0"/>
질량/길이부터 관절 감쇠, 토크·속도 제한까지 전부 상수로 뽑아뒀다. 이렇게 변수화해두면 나중에 막대 길이를 바꿔서 재실험하는 것과 같은 작업을 간단히 끝낼 수 있다.
3-1에서 본 것처럼 link 하나를 정의하려면 <visual>/<collision>/<inertial>을 다 채운 꽤 긴 <link> 블록이 필요하다. link1과 link2는 질량·길이·색깔만 다를 뿐, 그 안의 구조는 완전히 똑같다.
이렇게 반복적으로 나오는 코드는 매크로로 지정해놓으면 편리하다.
3-1에서 본 rod_link 매크로를 만들고, 이름/질량/길이/반지름/색깔 5개 값만 넣어 호출하면 매크로에서 정의해둔 <link> 블록 전체(visual + collision + inertial 다 포함)가 그 값으로 채워져서 그대로 찍혀 나온다. 아래 두 줄이 바로 그 "호출부"다:
<xacro:rod_link name="link1" mass="${m1}" length="${L1}"
radius="${rod_radius}" material_name="link1_blue"/>
<xacro:rod_link name="link2" mass="${m2}" length="${L2}"
radius="${rod_radius}" material_name="link2_orange"/>
이 두 줄이 xacro 처리를 거치면, 3-1에서 본 rod_link 매크로 정의를 기준으로 각각 완전한 <link name="link1">...</link>, <link name="link2">...</link> 블록으로 전개된다. 매크로가 없었다면 link1/link2 각각 <visual>/<collision>/<inertial>을 통째로 복붙해야 했을 것이다. 두 막대가 구조적으로 완전히 동일하다는 걸 코드 레벨에서도 드러낼 수 있다.
URDF만 놓고 보면 "로봇의 생김새와 관절 구조"까지만 정의한다. 실제로 Gazebo에서 이 로봇을 움직이려면 어떤 물리엔진 플러그인을 붙일지, 각 관절을 토크로 제어할지 위치로 제어할지 같은 정보가 더 필요하다.
URDF 표준 자체에는 없지만, <gazebo> 태그로 시뮬레이터 전용 설정을 얹을 수 있다.
<gazebo>
<plugin filename="gz_ros2_control-system"
name="gz_ros2_control::GazeboSimROS2ControlPlugin">
<parameters>$(find double_pendulum_control)/config/controllers.yaml</parameters>
</plugin>
</gazebo>
ros2_control 태그로 이 로봇이 어떤 인터페이스로 명령을 받고 상태를
내보내는지 정의한다:
<ros2_control name="double_pendulum_system" type="system">
<hardware>
<plugin>gz_ros2_control/GazeboSimSystem</plugin>
</hardware>
<joint name="joint1">
<command_interface name="effort">
<param name="min">-60.0</param>
<param name="max">60.0</param>
</command_interface>
<state_interface name="position"/>
<state_interface name="velocity"/>
<state_interface name="effort"/>
</joint>
<!-- joint2도 동일한 패턴 -->
</ros2_control>
이 태그들이 실제로 어떤 노드로 떠서 서로 연결되는지 rqt_graph로 찍어보면:

gz_ros2_control(URDF의 <plugin>이 로드하는 것) → controller_manager →effort_controller로 이어지고, effort_controller가/effort_controller/commands 토픽으로 명령을 받는걸 볼 수 있다.
위 xacro의 <command_interface name="effort">가 실제로는 이 토픽으로 연결된다는 뜻이다.
지금까지 본 double_pendulum.urdf.xacro는 로봇의 구조를 적어둔 파일일 뿐이다. 이 파일 하나로는 화면에 로봇을 띄울 수는 없고, 아래 4단계를 거쳐야 한다.
1단계. xacro → URDF 변환
.urdf.xacro 안에는 ${m1}, ${L1} 같은 변수와 <xacro:rod_link .../> 같은 매크로 호출이 그대로 남아있다. 완성된 XML로 만들어주려면 xacro 명령을 실행해줘야한다. 이 명령을 사용하면 변수/매크로가 전부 실제 숫자와 태그로 풀려서 완전한 순수 XML(표준 URDF)이 나온다:
xacro double_pendulum.urdf.xacro > double_pendulum.urdf
이렇게 뽑아낸 double_pendulum.urdf가 매크로/변수 없이 완성된 진짜 설계도다.
2단계. robot_state_publisher가 좌표 계산
robot_state_publisher는 ROS2에 기본으로 포함된 표준 패키지다.
1단계에서 나온 완성된 URDF를 읽어서 좌표 변환(TF)을 실시간으로 계산해서 계속 발행해주는 역할을 한다.(ex: base_link 기준으로 link1은 어디에 있고, link1 기준으로 link2는 어디에 있는지 등을 계산) RViz, Gazebo, 컨트롤러 코드 모두 이 TF 정보를 보고 각 link가 3D 공간 어디에 있는지를 알아낸다.
3단계. Gazebo spawn 노드가 실제로 로봇을 만들어 넣음
ros_gz_sim create 같은 노드가 robot_description 토픽(완성된 URDF가 포함되어있음)을 구독하고 있다가, 그 내용을 읽어서 진짜로 Gazebo 월드 안에 로봇 엔티티를 생성한다.
4단계. 이 순서를 묶어주는 launch 파일
위 1~3단계(+ 컨트롤러 매니저)를 정해진 순서대로 실행시켜주는 게 launch 파일이다. spawn.launch.py가 이 파이프라인 전체를 묶는 역할을 한다.
요약: URDF는 설계도. 그 설계도를 실제로 읽어서 Gazebo에 로봇을 띄우는 파이프라인(xacro 변환 → TF 발행 → spawn)은 double_pendulum_description 패키지 안에 구현해뒀다.
double_pendulum_description 패키지 실제 구조:
double_pendulum_description/
├── package.xml
├── CMakeLists.txt
├── urdf/
│ └── double_pendulum.urdf.xacro ← 1단계 원본 (매크로/변수 포함)
├── launch/
│ ├── spawn.launch.py ← 4단계: 1~3단계를 순서대로 실행
│ └── empty_world.sdf ← Gazebo 월드 정의
└── meshes/ ← 비어있음 (아래 참고)
meshes/가 비어있는 이유: 이중진자 시뮬레이터에는 정밀 3D 메시 없이 <geometry><cylinder> 같은 primitive 도형만으로도 충분했다.spawn.launch.py가 xacro 커맨드로 .urdf.xacro를 런타임에 robot_description 파라미터로 전개해서 robot_state_publisher / Gazebo spawn 양쪽에 넘기는 구조 (URDF 파일을 직접 읽는 게 아니라, launch 시점에 Xacro → URDF XML 문자열로 변환)