방 안 가사를 대신 해주는 휴머노이드 로봇을 만들겠다는 목표로 ROS2 공부를 시작했다. Phase 0(기구학)과 Phase 1(ROS2 코어: 노드/토픽/서비스/액션/파라미터/TF/URDF)을 지나, 이번엔 Phase 2 — 시뮬레이션이다.
Phase 1까지는 RViz2로 로봇 모형을 "그려서" 보는 게 전부였다. Phase 2의 목표는 그 로봇을 실제 물리엔진(Gazebo) 안에 존재하게 만들고, 진짜로 움직여보는 것. 오늘(2-1)은 여기까지 — 2관절 평면팔 하나를 Gazebo에 띄우고 실제로 움직이는 데까지 성공했다. 이 글은 그 과정에서 정리한 용어들과, 생각보다 많았던 삽질 기록이다.
왜 RViz만으론 안 되는가
Phase 1에서 썼던 RViz2와 Phase 2의 Gazebo는 역할이 완전히 다르다.
| RViz2 (Phase 1) | Gazebo (Phase 2) | |
|---|---|---|
| 정체 | 시각화 도구 — 그림만 그림 | 물리 시뮬레이터 — 중력/충돌/관성을 실제로 계산 |
| 관절 각도의 출처 | 코드로 "이 각도다"라고 직접 우겨넣음 | 컨트롤러가 명령 → 물리엔진이 실제로 움직인 결과 |
| URDF에 필요한 정보 | visual만 있으면 됨 |
visual + collision + inertial까지 필요 |
RViz는 "그림판"이고 Gazebo는 "물리 실험실"이라고 생각하면 편하다. 그래서 Phase 1의 URDF를 그대로는 못 쓰고, 질량·관성·충돌 형상을 추가해야 했다.
핵심 용어 정리
이번 단계에서 새로 등장한 용어가 많아서, 이해한 순서대로 정리해본다.
URDF (Unified Robot Description Format)
로봇의 구조 — 링크가 몇 개고 어떻게 연결되는지(joint), 각 링크의 모양(visual/collision)과 질량·관성(inertial)이 뭔지 — 를 기술하는 XML 표준 포맷. RViz, Gazebo, MoveIt 같은 여러 ROS 도구가 이 하나의 "설계도"를 공통으로 읽는다.
Xacro (XML Macro)
URDF를 편하게 짜기 위한 전처리기. 매크로/변수/반복문으로 XML을 간결하게 쓸 수 있게 해주는 게 원래 목적인데, 이번엔 딱 하나의 이유로만 썼다 — Gazebo 플러그인 설정 안에서 $(find 패키지명) 같은 패키지 경로 치환을 쓰려면 xacro 프로세서를 거쳐야 하기 때문. 그래서 확장자만 .urdf → .urdf.xacro로 바꿨다.
<gazebo>
<plugin filename="libgazebo_ros2_control.so" name="gazebo_ros2_control">
<parameters>$(find fk_2link_demo)/config/controllers.yaml</parameters>
</plugin>
</gazebo>
$(find fk_2link_demo)는 "fk_2link_demo라는 패키지가 실제로 설치된 경로가 어디냐"를 xacro가 빌드 시점에 찾아서 절대경로로 바꿔주는 부분이다. 여기서 "패키지"는 공식 ROS2 패키지든 내가 만든 패키지든 차별이 없다.
Gazebo
물리 시뮬레이터. URDF(정확히는 Gazebo 내부 포맷인 SDF로 변환된 것)를 받아서 모델을 만들고, 중력·충돌·마찰·관성을 실제로 계산해 움직인다. 그냥 3D 뷰어가 아니라 물리 엔진이라는 게 핵심이다.
robot_description 토픽 vs /joint_states 토픽
둘 다 robot_state_publisher가 다루는 토픽인데 성격이 정반대다.
- robot_description: URDF 텍스트 그 자체(구조). 정적 — 로봇이 움직여도 안 바뀐다.
robot_state_publisher가 발행하고, RViz의 로봇 모델도 Gazebo의spawn_entity.py도 다 이 토픽을 구독해서 로봇 모양을 알아낸다. - /joint_states: 지금 각 관절이 몇 도인지. 동적 — 계속 바뀐다.
robot_state_publisher는 이걸 반대로 구독해서 TF를 계산한다.
spawn_entity.py
gazebo_ros 패키지에 원래 들어있는 표준 스크립트(내가 짠 코드가 아니다). /robot_description 토픽을 구독해서 URDF를 읽고, Gazebo에게 "이 로봇을 시뮬레이션에 넣어줘" 하고 서비스 요청을 보내는 역할을 한다.
ros2_control / controller_manager
실제 하드웨어(모터)든 시뮬레이션이든 상관없이 "컨트롤러"라는 표준화된 방식으로 로봇을 제어할 수 있게 해주는 프레임워크. controller_manager가 이 프레임워크의 핵심 관리자 노드로, 어떤 컨트롤러를 로드하고 활성화할지 관리한다.
gazebo_ros2_control 플러그인
URDF의 <gazebo><plugin> 태그로 로드되는 플러그인. Gazebo를 "하드웨어"처럼 취급해서 ros2_control과 이어주는 다리 역할을 한다.
joint_state_broadcaster
제어는 전혀 안 하고, 지금 관절 상태(position/velocity)를 읽어서 /joint_states로 내보내기만 하는 "리포터" 컨트롤러. Phase 1의 joint_publisher.py가 하던 역할을, 이번엔 진짜 시뮬레이션 값으로 대신해준다.
joint_trajectory_controller
진짜로 "제어"하는 쪽. FollowJointTrajectory라는 액션으로 목표 궤적(각도 + 도달 시간)을 받으면, 그 시간 동안 부드럽게 보간(interpolation)하면서 매 순간의 목표 각도를 계산해 Gazebo에 명령으로 내려보낸다.
여기서 중요한 구분 — joint_trajectory_controller는 물리 시뮬레이션을 직접 하지 않는다. "지금 몇 도쯤 가있어야 하는지 계산하는 뇌"고, 그걸 실제로 실행하는 "몸"은 Gazebo 물리엔진이다.
액션(Action)이란
ROS2의 통신 방식 중 하나로, 목표(goal)를 보내고 중간 피드백을 받다가 최종 결과(result)를 받는 구조. 오늘 만든 trajectory_demo.py가 액션 클라이언트로 목표를 보내고, joint_trajectory_controller가 액션 서버로 그 목표를 받아서 처리한다.
TF (Transform) — Phase 1 복습
로봇의 여러 좌표 프레임(base_link, upper_arm_link, forearm_link, hand_link) 사이의 위치·자세 관계를 실시간으로 계산하는 시스템. robot_state_publisher가 robot_description(구조) + /joint_states(각도)를 조합해서 계산해준다.
전체 아키텍처 흐름
1. xacro가 URDF 처리 → 최종 URDF 문자열 생성
2. Gazebo(gzserver+gzclient) 실행
3. robot_state_publisher가 URDF를 /robot_description 토픽으로 발행
4. spawn_entity.py가 그걸 읽어서 Gazebo에 로봇 생성 요청
5. Gazebo가 모델 생성하면서 gazebo_ros2_control 플러그인 로드
→ 이때 controller_manager 노드가 새로 생성됨
6. spawner가 controller_manager에게 컨트롤러 활성화 요청
(joint_state_broadcaster, joint_trajectory_controller 순서로)
7. trajectory_demo.py가 joint_trajectory_controller에 궤적 목표 전송
→ Gazebo 물리엔진이 실제로 관절을 움직임
→ joint_state_broadcaster가 결과를 /joint_states로 다시 발행
→ robot_state_publisher가 TF 계산 (Phase 1과 동일한 결말)
오늘 만든 것
기존 Phase 1의 fk_2link_demo 패키지를 새로 만들지 않고 그대로 확장했다.
urdf/two_link_arm.urdf.xacro— Phase 1 URDF에 collision/inertial/ros2_control 블록/gazebo_ros2_control 플러그인 추가config/controllers.yaml— joint_state_broadcaster + joint_trajectory_controller 설정launch/gazebo.launch.py— Gazebo 실행 → 로봇 스폰 → 컨트롤러 순서대로 로드fk_2link_demo/trajectory_demo.py— 0° → (40°, -25°) → 0° 궤적을 실제로 보내는 액션 클라이언트
삽질 기록 — 에러 3종 세트
여기가 사실 오늘 제일 많은 시간을 쓴 부분이다. 각각 원인이 꽤 배울 게 많았다.
① 콜론(:) 하나가 파서를 깨뜨렸다
Gazebo를 켜면 로봇은 스폰되는데, 컨트롤러가 영원히 안 떴다. ros2 node list를 찍어보니 gazebo_ros2_control 노드는 있는데 controller_manager 노드가 없었다. 로그:
[ERROR] [gazebo_ros2_control]: parser error Couldn't parse parameter override rule:
'--param robot_description:=<?xml version="1.0" ?>...'
gazebo_ros2_control 플러그인이 URDF 전체를 --param robot_description:=<XML> 형태의 문자열로 재조립해서 자기 노드에 넘기는데, 이 값을 YAML 파서가 해석한다. xacro 파일에 달아둔 한글 설명 주석 중 "1) <inertial> : 질량 + 관성모멘트." 처럼 "콜론(:) + 공백"이 들어간 부분이 있었는데, YAML에서 이건 "여기서 key: value로 나눠라"는 신호라서 파서가 완전히 깨진 거였다.
고친 방법: 시뮬레이션에는 주석이 필요 없으니, robot_description 문자열을 넘기기 전에 정규식으로 XML 주석을 통째로 잘라내고 줄바꿈도 한 줄로 정리했다.
robot_description = re.sub(r'<!--.*?-->', '', robot_description, flags=re.DOTALL)
robot_description = ' '.join(robot_description.split())
원인이 URDF 구조가 아니라 내가 쓴 주석의 문장부호였다는 게 이 디버깅의 포인트.
② 고정 안 된 로봇 — 자유낙하하는 팔
에러는 없어졌는데, 가만히 있어도 관절 값이 미세하게 계속 흔들리고, 궤적을 실행하면 복귀할 때 갑자기 원점을 넘어가버리는 데다 그 정도가 실행할 때마다 달랐다.
원인은 URDF에 base_link를 어디에도 고정하는 조인트가 없었던 것. Gazebo 입장에서는 로봇 전체가 "중력으로 바닥에 떨어져서 접촉(contact)으로만 버티고 있는 자유물체"였다. 접촉 물리는 원래 재현성이 낮아서, 가만히 있어도 미세하게 재정착하며 떨리고, 팔이 빠르게 움직이면 그 반작용이 매번 다르게 나타난 것.
world라는 가상의 고정 링크를 만들고 fixed 조인트로 base_link를 용접하듯 고정해서 해결했다.
<link name="world"/>
<joint name="world_to_base" type="fixed">
<parent link="world"/>
<child link="base_link"/>
<origin xyz="0 0 0.1" rpy="0 0 0"/>
</joint>
③ 내가 만든 회귀 — 바닥에 파묻힌 팔
②를 고치면서 "이제 떨어질 일 없으니까" 하고 스폰 높이(-z 인자)를 0.05에서 0.0으로 낮췄는데, 그러자 팔(두께가 있는 box 모양 링크)의 중심이 정확히 바닥 높이(z=0)에 놓이면서 두께 절반이 바닥 아래로 파고들었다. Gazebo가 "바닥을 뚫었다"고 감지하고 계속 밀어내는데 밀어내자마자 또 파고드니, 화면에서는 팔이 꺾인 채로 부들부들 떠는 것처럼 보였다.
해결은 스폰 인자가 아니라 world_to_base 조인트의 origin xyz를 0 0 0.1(바닥 위 10cm)로 줘서, 팔 전체가 애초에 바닥과 안 겹치는 높이에서 시작하게 만드는 것.
정리하면: ① 고정 안 된 자유낙하 로봇 → world에 고정, ② 고정하는 과정에서 높이를 잘못 낮춰 바닥에 파묻힘 → 높이 재조정. 한 문제를 고치다 다른 문제를 만든 전형적인 케이스였다.
결과 확인
ros2 launch fk_2link_demo gazebo.launch.py # 터미널 1
ros2 control list_controllers # 터미널 2 — 두 컨트롤러 active 확인
ros2 run fk_2link_demo trajectory_demo # 터미널 3 — 실제로 움직여보기
Gazebo 창에서 팔이 3초 동안 40°/-25° 자세로 움직였다가, 3초 더 있다가 원점으로 부드럽게 돌아오면 성공. ros2 topic echo /joint_states로 값이 실시간으로 바뀌는 걸 봐도 되고, rqt_plot으로 그래프로 그려서 보면 스크롤이 너무 빨라(controller_manager가 100Hz로 도는 중이라 초당 100번 발행됨) 눈으로 못 쫓는 문제도 해결된다.
다음 편 (Phase 2-2) 예고
- RViz2 + Gazebo 동시에 띄워서 TF 비교
- 컨트롤러 종류 이해 (오늘 쓴
position방식 말고velocity,effort방식은 뭐가 다른지) - 방 형태의 커스텀 world 만들어보기 (최종 목표인 "방 안 가사 로봇" 시나리오에 조금씩 가까워지는 중)
'로봇 > ROS2 공부' 카테고리의 다른 글
| [ROS2 공부 Phase 1] 토픽(Topic) 통신으로 로봇 팔 자세 뿌려보기 (0) | 2026.08.25 |
|---|




































