-->

Phase 4-1에서 그리퍼+MoveIt2를 붙여 open→pre_reach→reach_target까지는 완전히 성공했는데, 마지막 close(붙잡기) 단계에서 원기둥이 중심을 못 잡고 옆으로 빠져나가는 문제가 남아있었다. IK나 오프셋 문제가 아니라 "완전 대칭 position-control 그리퍼가 둥근 표면을 잡을 때 두 손가락이 완벽히 동시에 닿기 어렵다"는 물리적 한계였다. 오늘은 이 문제를 정면 돌파(force/compliance control)하지 않고, 물체 형태를 바꿔서 우회하는 방향으로 진행했다.

① 왜 모양만 바꾸는 걸로는 부족한가

처음엔 "원기둥 → 박스"로 SDF의 geometry 태그만 바꾸면 될 거라 생각했는데, 그렇게 간단하지 않았다.

이 팔은 관절 2개(어깨/팔꿈치)가 전부 Z축 회전인 2자유도 평면팔이라, 방향(orientation)을 따로 제어할 수 없는 position-only IK를 쓴다. 그 말은 그리퍼가 최종적으로 향하는 각도가 shoulder + elbow 합으로 자동 결정된다는 뜻 — 손가락이 열고 닫히는 축(폐쇄축)도 이 각도에 그대로 종속된다.

Phase 4-1 최종 reach_target(shoulder=-0.4081, elbow=1.9018) 기준으로 폐쇄축 각도를 구하면:

접근축 각도 = -0.4081 + 1.9018 = 1.4937 rad (≈85.6°)
폐쇄축 각도 = 1.4937 + π/2 = 3.0645 rad (≈175.6°)

박스를 아무 각도로나 놔두면 손가락이 모서리나 꼭짓점을 때리게 되고, 그건 원기둥이랑 별 차이가 없다. 손가락이 정말 "면"으로 닿으려면 박스의 평평한 면이 폐쇄축과 정면으로 마주보게 미리 돌려놔야 한다.

② yaw 계산 + 뜻밖의 대칭

정사각 단면(0.06m × 0.06m) 박스로 정하고, 폐쇄축(3.0645rad)에 한쪽 면이 정렬되도록 yaw를 계산했다.

yaw = 폐쇄축 각도 mod (π/2), 최소 절댓값으로 정규화
    = 3.0645 - π
    = -0.0771 rad (약 -4.4°)

여기서 재미있는 우연이 하나 있었다. 이 팔은 접근축과 폐쇄축이 항상 서로 수직이기 때문에, 정사각 단면 박스를 폐쇄축에 맞춰 정렬하면 접근축 방향 반너비도 자동으로 폐쇄축 방향 반너비와 똑같이 0.03m가 된다. 즉 이전 원기둥의 반지름(0.03m, 모든 방향에서 동일)과 새 박스의 두 방향 반너비가 정확히 일치한다.

그 덕분에 hand_link 구 충돌 여유 계산(pre_reach/reach_target IK 값)이나 그리퍼 open(0.05m)/closed(0.032m) 값을 하나도 다시 계산할 필요가 없었다 — worlds/room.world의 geometry 태그와 pose의 yaw 값, 그리고 관성값(inertia, 원기둥→박스 공식이 달라서 재계산)만 바꾸면 끝나는 수정이었다.

③ 실행 검증 — 이번엔 진짜 잡혔다

WSL2에서 moveit_demo.launch.py를 재실행해서 박스가 받침대 위에 올바른 위치·각도로 뜨는 걸 확인한 뒤, 순서대로 실행했다.

ros2 run fk_2link_demo gripper_demo open
ros2 run fk_2link_demo gripper_demo reach   # pre_reach → reach_target
ros2 run fk_2link_demo gripper_demo close

원기둥 때 미끄러져 빠지던 지점에서, 이번엔 박스가 중심을 잃지 않고 안정적으로 집혔다.

여기서 그냥 넘어가기 아쉬워서 home까지 추가로 보내봤다 — 팔을 완전히 편 자세로 되돌리는 명령인데, close 상태를 그대로 유지한 채였다.

ros2 run fk_2link_demo gripper_demo home

결과는 기대 이상이었다. 박스가 그리퍼에서 떨어지지 않고 hand_link에 붙은 채로 home 위치까지 함께 이동했다. 원래 있던 받침대 자리는 빈 채로 남았다.

(close 직후 — 박스가 그리퍼에 안정적으로 물려있음)

(home 이동 후 — 박스가 그리퍼에 붙은 채 따라옴, 받침대는 비어있음)

close 직후의 정적인 상태뿐 아니라 팔이 크게 움직이는 동안에도 그립이 유지된다는 걸 확인했다는 점에서, close 스크린샷 한 장보다 훨씬 설득력 있는 검증이었다. force/compliance control을 도입하지 않고 물체 형태 교체만으로 Phase 4-1의 그립 불안정 문제를 해결한 셈이다.

④ (보너스) RViz Plan/Execute랑 gripper_demo.py는 뭐가 다른가

이번에 검증하면서 궁금해진 게 있었다. Phase 4-1까지는 RViz의 MoveIt2 패널에서 매번 Plan 누르고 확인하고 Execute 누르는 식으로 힘들게 움직였는데, 이번엔 ros2 run fk_2link_demo gripper_demo open 한 줄로 끝났다. 원리를 정리해봤다.

RViz Plan/Execute는 move_group이 OMPL로 충돌 검사를 하면서 현재 자세→목표 자세까지 여러 waypoint로 된 경로를 매번 새로 계산(Plan)한 뒤, 그 결과를 컨트롤러의 FollowJointTrajectory 액션으로 보낸다(Execute). 반면 gripper_demo.py는 이 계획 단계를 통째로 건너뛰고, 점(waypoint) 하나짜리 trajectory("이 관절값으로 N초 안에 가라")를 컨트롤러 액션에 바로 쏜다. 목표 관절값도 그때그때 계산하는 게 아니라 이전 세션에 IK로 미리 구해서 코드 상수(REACH_TARGET_SHOULDER 등)로 박아둔 값이다. 그래서 planning 시간 없이 CLI 한 줄로 재현 가능한 것.

여기서 하나 걸리는 부분을 발견했다. OMPL은 원래 "충돌 객체로 등록된" 장애물만 피해간다 — 그런데 target_bottle은 Gazebo world 파일에만 있는 모델이고, MoveIt2 planning scene에 충돌 객체로 등록돼 있지 않다. OMPL 입장에선 이 물체가 아예 존재하지 않는 셈이라, 경로가 그 위를 그냥 통과해버릴 수 있다. 실제로 Phase 4-1에서 reach_target으로 한 번에 보냈다가 팔이 물체를 스치고 지나간 게 이 문제였고, 그래서 pre_reach라는 중간 지점을 사람이 손으로 만들어 우회시킨 거였다. "장애물을 알아서 피해서 최적 경로를 계산해준다"는 건 planning scene에 등록된 물체에 한해서만 성립하는 이야기였다 — 이건 다음에 제대로 하려면 target_bottle을 충돌 객체로도 등록해야 하는, 아직 안 끝난 숙제로 남겨둔다.

다음 편 예고

각진 물체 + yaw 정렬로 그립 안정성 문제는 해결됐다. 다만 Phase 4-1부터 남아있는 물리적 한계는 그대로다 — 관절 2개가 전부 Z축 회전이라 팔이 항상 같은 높이에서 수평 이동만 가능하고, 위로 "들어 올리는" 진짜 pick-and-place는 여전히 못 한다. 관절을 추가해서 이 한계를 정면 돌파할지, 아니면 이 상태를 인정하고 로드맵대로 Phase 5(이동/SLAM/Nav2)로 넘어갈지 다음 세션에서 결정할 예정이다.

Phase 3-1/3-2까지 하고 나서 숙제가 하나 남아있었다 — YOLO 인식 파이프라인은 만들어놨는데, room.world 안의 물체(정육면체 "table")가 COCO 80종 클래스 어디에도 없어서 실제로 탐지 성공을 본 적이 없었다. 게다가 그 물체는 팔 최대 도달거리(0.55m)보다 훨씬 먼 곳(거리 1.7m)에 있어서 어차피 물리적으로 닿을 수도 없었다. 마침 Phase 4(MoveIt2 조작)를 시작하려면 "잡을 수 있는 물체"가 필요했으니, 두 문제를 한 번에 묶어서 처리하기로 했다.

준비: 물체 교체 + 그리퍼 추가 + MoveIt2 설정

정육면체 "table"을 target_bottle로 바꿨다. 반지름 0.025m, 높이 0.2m짜리 초록 원기둥, 위치 (0.3, 0.2) — 원점에서 거리 0.36m로 도달범위 안이고, COCO에 "bottle" 클래스가 있으니 YOLO가 원리적으로 잡을 수 있는 모양이다.

hand_link 끝에 평행 그리퍼(손가락 2개, prismatic 관절)를 추가했다. 관절값이 곧바로 "중심에서 손가락까지 거리"가 되도록 설계해서, 둘 다 0.015면 닫힘, 둘 다 0.05면 열림 — 직관적으로 명령할 수 있게 했다.

MoveIt2 설정(SRDF, kinematics.yaml, joint_limits.yaml, ompl_planning.yaml, controllers.yaml)은 moveit_setup_assistant GUI를 이 세션에서 못 띄워서 전부 손으로 작성했다. kinematics.yamlposition_only_ik: true를 줬는데, 이유는 자유도 2개(어깨/팔꿈치, 둘 다 Z축 회전)로는 3D 자세(위치+방향)를 풀 수 없어서다. "손끝 위치만 맞추면 됨"으로 문제를 줄이면 z는 관절 구조상 항상 고정이라 실질적으로 (x,y) 2개 vs 관절 2개 — 딱 풀리는 문제가 된다. Phase 3-1에서 손으로 풀었던 평면 2관절 역기구학과 원리가 같고, 이번엔 KDL이 수치적으로 대신 풀어준다.

여기까지 하고 나니 팔 관절 2개가 다 Z축 회전이라는 구조적 한계가 눈에 들어왔다 — 팔은 항상 같은 높이에서 수평으로만 움직인다. 그리퍼로 붙잡아 밀거나 끌 수는 있어도, 위로 들어 올리는 진짜 pick-and-place는 아직 불가능하다(수직 자유도가 없어서). 이번 단계 목표는 딱 "그리퍼가 실제로 열리고 닫히고, MoveIt2로 물체 위치까지 팔을 계획해서 움직이는 것"까지로 낮춰 잡았다.

여기까지는 클라우드 세션에서 문법 검사(XML/YAML 파싱, py_compile)만 하고, 실제 실행은 WSL2/Gazebo에서 직접 해봐야 하는 상황이었다. 그리고 실행해보자마자 버그가 하나씩 튀어나오기 시작했다 — 이번 편은 사실상 그 버그 사냥 기록이다.

위: 처음 썼던 가늘고 긴 원기둥(반지름0.025m×높이0.2m, 넘어지기 쉬웠음)  /  아래: 짧고 굵게 바꾸고 받침대에 올린 최종 형태

① 팔을 뻗었더니 물체를 뚫고 지나가며 쳐서 넘어뜨림

RViz MotionPlanning으로 arm을 reach_target까지 Plan/Execute했더니, 손이 target_bottle을 뚫고 지나가며 쳐서 넘어뜨렸다.

원인: reach_target 각도를 계산할 때 hand_link 원점을 병 중심에 그대로 맞췄는데, 실제 파지 기준점(손가락 장착점)은 hand_link 원점보다 0.025m 앞이다. 손목이 목표까지 다 가면 손가락 장착점은 병 중심을 이미 지나쳐서, 손목~손이 병을 정면으로 들이받는 구조였다.

고침: 손가락 오프셋(0.025m)을 유효 2번째 링크 길이에 더해서(L2_eff = 0.25 + 0.025 = 0.275) IK를 다시 계산했다. 3번째 가상 링크가 있는 것처럼 취급하면 되는 방식이다.

교훈: hand_link 끝에 뭔가(그리퍼 등)를 더 붙였으면, IK 목표는 링크 자체 좌표가 아니라 "실제 접촉이 일어나는 지점"(tool center point) 기준으로 잡아야 한다 — 둘 사이 오프셋만큼 항상 어긋난다.

② 리셋해도 병이 저절로 넘어져서 굴러감

IK를 고치고 다시 시도했는데, /reset_world로 리셋해도 로봇이 건드리기도 전에 원기둥이 저절로 쓰러지고 옆으로 굴러갔다.

원인: 두 가지가 겹쳐 있었다. 반지름 0.025m × 높이 0.2m는 받침 폭 대비 키가 너무 커서(가로세로비 8:1) 물리엔진의 미세한 수치 오차만으로도 넘어지는 구조였고(로봇 없이 스폰만 해도 무너질 정도), 일단 눕고 나면 원기둥이라 굴러가는 걸 막을 구름 저항이 없어서 계속 굴러갔다. 접촉 마찰(mu)은 미끄러짐만 막지 구르는 회전 자체는 못 막는다.

고침: 원기둥을 반지름 0.03m × 높이 0.08m(가로세로비 2.7:1)로 짧고 굵게 바꾸고, 팔 높이(z=0.1m)에 맞추려고 바닥에 static 받침대(8×8×6cm 박스)를 두고 그 위에 얹었다.

교훈: 세워두는 물체는 "받침 폭 대비 높이" 비율이 안정성의 핵심이다 — 실물이면 안 넘어질 비율도 수치 시뮬레이션에선 오차에 예민해서 넘어질 수 있다. 둥근 물체는 한번 쓰러지면 넓은 받침이나 각진 단면 같은 별도 장치 없이는 계속 굴러갈 수 있다는 것도 챙겨야 한다.

③ IK는 맞았는데 그리퍼를 안 열고 가서 또 밀어냄

①에서 손가락 오프셋을 반영해 IK를 고쳤는데도, 다시 시도하니 여전히 target_bottle이 받침대에서 밀려났다(이번엔 쓰러지진 않았다 — ②의 안정성 수정은 유효했다).

원인: 그리퍼를 열지 않은 기본 상태(사실상 다물어진 상태)에서 팔만 reach_target으로 보냈기 때문이었다. IK 목표점은 "손가락이 벌어져 물체를 감쌀 것"을 전제로 계산했는데, 다물어진 손가락 뭉치가 그대로 전진하면서 물체 정면을 들이받은 것 — 오프셋 계산 자체는 맞았지만, "그리퍼를 먼저 열어야 한다"는 동작 순서를 강제하지 않은 게 진짜 원인이었다.

고침: "그리퍼 open → 팔 reach_target → 그리퍼 close" 순서를 README에 명시하고, closed 상태 값도 0.015m → 0.032m로 상향했다(②에서 병 반지름이 0.03m로 바뀌었는데 closed 값이 그 이전 기준에 머물러 있었다).

교훈: 그리퍼 달린 매니퓰레이터는 "팔 궤적"과 "그리퍼 상태"가 서로 다른 MoveIt2 그룹이라 독립적으로 움직인다 — IK가 정확해도 접근 순서(열고 → 다가가고 → 닫고)를 지키지 않으면 그대로 충돌한다.

④ 그리퍼 열고 갔는데도 경로 중간에 스침 — 이번엔 내가 원인을 짚었다

③까지 고친 뒤에도 이동 도중 그리퍼가 target_bottle을 살짝 스쳐서 밀어냈다. 직접 관찰해보니 "지금 움직이는 경로가 최적 경로는 맞는데, 어쩔 수 없이 부딪힐 수밖에 없는 경로인 것 같다"는 느낌이 들어서 그대로 이야기했다.

원인: 정확한 지적이었다. home(완전히 편 자세)에서 reach_target(많이 굽힌 자세)로 한 번에 OMPL(RRTConnect)에게 맡기면, target_bottle이 MoveIt2 planning scene에 충돌 객체로 등록돼 있지 않아서(Gazebo 전용 모델이라) 플래너는 "물체가 있다는 걸 아예 모르는 채" 자유공간 최단경로만 찾는다. 그 결과 관절이 필요 이상 꺾였다가 되돌아오는 경로가 나올 수 있고, 그 경로가 물체 자리를 스치고 지나간 것이다.

고침: pre_reach라는 중간 경유 자세를 추가해서 접근을 2단계로 나눴다 — reach_target 방향으로 0.08m 뒤에 waypoint를 하나 두고, 1단계(home→pre_reach)는 물체와 8cm+ 떨어진 안전한 곳에서 끝나고, 2단계(pre_reach→reach_target)는 관절 변화가 작아서 경로가 거의 일직선에 가깝게 나온다. 실제 pick-and-place 파이프라인에서도 흔히 쓰는 pre-grasp waypoint 패턴이다.

교훈: 모션 플래너는 충돌 객체로 등록된 장애물만 피한다 — 시뮬레이터에만 있고 planning scene에 안 알려진 물체는 플래너 입장에선 없는 셈이라 경로가 그 위를 그냥 지나갈 수 있다. 물체가 하나뿐이고 위치가 고정이면 pre-grasp waypoint로 나누는 것도 실용적인 우회법이다. (여담: 이 과정에서 링크 길이를 URDF 확인 없이 착각할 뻔했다 — 상수는 기억하지 말고 매번 소스에서 직접 확인할 것.)

⑤ /reset_world 했는데 팔은 안 돌아옴

④를 고친 뒤 다시 시도하려고 /reset_world를 호출했더니, target_bottle은 스폰 자세로 잘 돌아갔는데 팔은 그대로였다.

원인: /reset_world는 Gazebo 물리 엔진에게 모델을 스폰 자세로 되돌리라고 시키는 것뿐이다. target_bottle처럼 컨트롤러가 안 붙은 물체는 그걸로 끝이지만, 팔은 joint_trajectory_controller가 붙어있어서 "마지막으로 받은 목표 관절값을 계속 유지"하도록 매 제어 주기마다 다시 명령을 내린다. 리셋이 순간적으로 관절을 되돌려도 바로 다음 주기에 컨트롤러가 이전 목표로 다시 밀어넣어서 리셋이 안 먹힌 것처럼 보인 것 — 물리 리셋과 컨트롤러가 싸우면 컨트롤러가 항상 이긴다.

고침: 팔을 (shoulder=0, elbow=0)으로, 그리퍼를 open으로 되돌리는 새 목표를 명시적으로 보내는 home 커맨드를 추가했다.

교훈: ros2_control 컨트롤러가 붙은 관절은 시뮬레이터 차원 리셋만으론 안 돌아온다 — "마지막 목표를 유지"하는 게 컨트롤러의 정상 동작(그래야 아무 명령 없이도 자세가 처지지 않고 유지된다)이라, 리셋하려면 컨트롤러한테 새 목표를 명시적으로 보내야 한다.

⑥ "chomp_planner ... Start state violates joint limits"로 실패 — 그리고 한 번 더 실패

pre_reach까지는 정상 이동했는데, RViz에서 reach_target을 Plan/Execute하니 아래 에러로 실패했다.

[chomp_planner]: Start state violates joint limits
[move_group_interface]: Plan and Execute request aborted

로그에 ompl_interface가 아니라 chomp_planner가 찍혀 있었다. OMPL(RRTConnect)만 쓰도록 설정했는데 실제론 CHOMP가 호출되고 있었던 것.

1차 진단(틀렸음): launch 파일에서 ompl_planning.yaml을 move_group에 넘길 때 {'move_group': {...}}처럼 감싸서 넘긴 게 문제라고 보고, 감싸기를 아예 없애고 flat하게 넘기도록 고쳤다. 재실행해보니 여전히 CHOMP가 뜨고, 이번엔 joint_trajectory_controller가 "Time between points 0 and 1 is not strictly increasing"라며 궤적 자체를 거부했다.

2차 진단(진짜 원인): MoveIt2 공식 launch 생성기(moveit_configs_utils)의 소스코드와 공식 예제(moveit_resources의 panda 설정)를 직접 뜯어보고 나서야 정확히 잡혔다. 문제가 두 개 겹쳐 있었다 — (1) move_group이 기대하는 구조는 planning_pipelines: ['ompl'] + default_planning_pipeline: 'ompl' + ompl: {...}(파이프라인 이름 "ompl"로 감싸기)였음. (2) ompl_planning.yaml 자체에도 오타가 있었음 — planning_plugins:(복수형+리스트)로 썼는데 MoveIt2가 실제로 읽는 키는 planning_plugin(단수형+문자열)이었음. 이 둘이 겹쳐서 OMPL이 애초에 구성되지 않았고 CHOMP로 대체됐던 것.

고침: launch 파일에 세 키를 갖춰서 전달하고, yaml의 키 이름을 단수형으로 수정했다.

교훈: MoveIt2 파라미터 구조(감싸는 위치, 키 이름)는 추측하지 말고 공식 예제나 공식 launch 생성기 소스를 직접 대조해서 정확히 맞춰야 한다. 키 이름 하나, 감싸는 위치 하나만 틀려도 에러 메시지 없이 조용히 다른 플래너로 넘어가버려서 원인이 한참 뒤에야 드러난다.

⑦ OMPL은 이제 잘 뜨는데, 도착하자마자 손목의 둥근 몸통이 병을 밀어버림

⑥을 고치고 다시 켰더니 이번엔 chomp_planner 에러 없이 reach_target까지 잘 도착했다 — 그런데 도착하자마자 팔이 target_bottle을 정면으로 밀어버렸다. "그리퍼가 조금 더 앞에 있어야 될 것 같다"는 직감이 정확히 맞았다.

원인: hand_link가 반지름 0.025m짜리 구(sphere) 모양 충돌체라는 걸 그동안 계산에서 완전히 빠뜨리고 있었다. 지금까지 IK는 "손가락 장착점(hand_link 원점에서 0.025m 앞)이 물체 중심에 닿으면 된다"고만 계산했는데, 하필 그 오프셋(0.025m)이 hand_link 구 자신의 반지름과 정확히 같은 값이었다. 손가락 장착점이 물체 중심에 도착하는 순간, hand_link 구 중심과 물체 중심 사이 거리는 겨우 0.025m뿐이라 필요한 최소 거리(구 반지름 0.025 + 물체 반지름 0.03 = 0.055m)에 한참 못 미쳤다.

고침: 손가락 장착 오프셋을 0.025m → 0.07m로 늘렸다. hand_link 구가 물체 중심에서 0.07m 떨어지게 되니 필요한 최소 거리(0.055m)보다 0.015m 여유가 생긴다.

교훈: 손끝에 뭔가를 붙일 때 "장착 오프셋"만 챙기고 그 오프셋이 속한 부모 링크 자체의 충돌 형상(부피)은 놓치기 쉽다 — 특히 단순화를 위해 "구"로 표현한 링크는 반지름만큼 사방으로 부피가 있다는 걸 매번 의식해야 한다.

⑧ 로봇이 아예 안 뜸 — Gazebo가 준비되기 전에 스폰 스크립트가 먼저 죽어버림

⑦을 고치고 다시 켰는데, 이번엔 RViz에도 Gazebo에도 로봇 팔이 아예 안 보였다. 로그를 보니:

[spawn_entity.py]: Service /spawn_entity unavailable. Was Gazebo started with GazeboRosFactory?
[spawn_entity.py]: Spawn service failed. Exiting.

원인: ps aux로 확인해보니 gzserver/gzclient 자체는 멀쩡히 떠 있었다 — "프로세스가 살아있다"와 "필요한 서비스를 다 준비했다"는 다른 얘기였다. gzserver가 /spawn_entity 서비스를 완전히 띄우기까지 시간이 좀 걸리는데(WSL2라 특히 느림), spawn_entity.py의 기본 대기시간이 30초뿐이라 그 안에 서비스가 준비 안 되면 재시도 없이 그냥 죽어버렸다.

고침: 스폰 대기시간을 30초 → 90초로 늘렸다.

교훈: 자식 프로세스가 타임아웃으로 죽으면 부모 프로세스가 멀쩡해도 다시 살아나지 않는다. WSL2처럼 기본 환경보다 느릴 수 있는 곳에서는 짧게 잡힌 기본 타임아웃 값들을 의심해볼 만하다.

grasp_attempt_final_compressed.mp4
0.81MB

최종 결과: 접근·개폐는 성공, 중심을 못 잡아 원기둥이 빠져나감

⑧까지 고치고 나니 이번엔 로봇이 정상적으로 떴고, open → pre_reach → reach_target까지 hand_link 공이 더 이상 물체를 밀어내지 않고 깔끔하게 도착했다(⑦ 수정 유효 확인). 여기까지는 완전히 성공. (위 파일이 실제 마지막 시도 영상이다 — 다운로드해서 재생하면 접근부터 놓치는 순간까지 볼 수 있다.)

마지막 단계 — 그리퍼로 원기둥을 닫아 쥐는 순간 — 에서 중심을 정확히 못 잡아서 원기둥이 옆으로 빠져나갔다. 접근 경로도 IK도 다 맞았는데, 정작 "붙잡기" 자체가 매끄럽지 않았던 것.

원인(추정): 지금 그리퍼는 양쪽 손가락에 동일한 목표값을 동시에 명령하는 완전 대칭 구조인데, 실제 물리 시뮬레이션에서는 두 손가락이 완벽히 동시에 닿기가 어렵다. 게다가 대상이 원기둥이라 접촉면이 둥글어서, 한쪽이 먼저 닿는 순간 반대쪽으로 밀려날 힘이 쉽게 생긴다(평평한 상자였다면 면 접촉이라 더 안정적이었을 것). 힘/컴플라이언스 제어 없이 단순히 "이 위치로 가라"고만 명령하는 position control이라, 살짝 어긋나면 그 힘이 그대로 밀어내는 방향으로 남는다.

여기서 Phase 4-1을 마무리하기로 했다. 애초 목표 — "그리퍼가 실제로 열리고 닫히고, MoveIt2로 물체 위치까지 팔을 계획해서 움직이는 것" — 는 충분히 달성했다. 접근 경로, 충돌 회피, 그리퍼 개폐, 물체 접촉까지 전부 확인했으니까. 그리퍼가 물체를 안정적으로 붙잡아 유지하는 것(파지 안정성)과, 애초에 이 팔(관절 2개 다 Z축 회전)로는 "들어 올리는" 진짜 pick-and-place가 구조적으로 불가능하다는 점은 남은 과제로 남겨둔다.

정리 — Phase 4-1에서 겪은 9번의 버그

이번 편 전체를 관통한 디버그 루프를 한 줄씩 정리하면: ① hand_link 원점 기준 IK로 병을 뚫고 지나감 → 손가락 오프셋 반영, ② 물체가 저절로 넘어지고 굴러감 → 짧고 굵게 + 받침대, ③ 그리퍼 안 열고 접근해서 밀어냄 → 순서 강제, ④ OMPL 경로가 물체를 스침 → pre_reach 경유 자세, ⑤ /reset_world가 팔은 안 돌림 → home 커맨드, ⑥·⑦(이번 편) chomp_planner로 잘못 대체됨 → MoveIt2 파라미터 구조 정정, ⑧(이번 편) hand_link 구가 물체를 밀어냄 → 손가락 오프셋 재조정, ⑨(이번 편) 스폰 타임아웃 → 90초로 상향. 아홉 번 모두 "왜 실패했는가"가 한 번씩은 꽤 미묘했고, 그중 몇 번은 사용자가 직접 로그나 화면을 보고 원인 방향을 먼저 짚어준 게 결정적이었다.

다음 편 예고

Phase 4-1은 "팔이 계획대로 움직이고 그리퍼가 열리고 닫힌다"까지를 목표로 삼고 여기서 마무리한다. 다음 Phase 4-2에서는 그립 안정성 문제를 정면 돌파하는 대신 물체 형태를 바꿔서 우회하기로 했다 — 원기둥을 각진 박스로 바꾸고, 그리퍼가 닫히는 축에 박스 면이 정면으로 마주보도록 미리 각도를 맞춰서(재미있게도 이 팔의 기하학적 대칭 덕분에 기존 IK 값은 하나도 안 바꿔도 됐다) 면 접촉으로 안정적으로 잡히게 만드는 시도다. 다음 편에서 실제로 잘 잡히는지 확인해보고, 이후에는 로드맵상 Phase 5(SLAM/Nav2, 이동)로 넘어갈 예정이다.

Phase 2-2까지 하고 로드맵을 다시 봤더니, Phase 2 카드에 "센서(RGB-D) 시뮬레이션 · ROS2 ↔ 시뮬레이터 브릿지"가 아직 안 끝난 채로 남아있었다. 굳이 되돌아가지 않고, 어차피 Phase 3(인지)를 시작하려면 제일 먼저 필요한 게 카메라라서 이번 세션 첫 단계로 자연스럽게 흡수시켰다. 오늘 한 세션에서 카메라 브릿지(3-1)랑 YOLO 인식(3-2)까지 이어서 했다.

① 손목에 카메라 달기 (eye-in-hand)

카메라를 세상에 고정시키는 대신, 팔 끝(hand_link)에 고정 조인트로 바로 붙였다. 실제 로봇팔이 물건을 집을 때 흔히 쓰는 방식이라고 한다 — 팔이 어디로 움직이든 "손이 보는 시야"를 카메라가 그대로 보게 된다.

Gazebo의 sensor type="depth" + libgazebo_ros_camera.so 플러그인 조합을 썼다. 예전 Gazebo9의 Kinect 스타일 플러그인(libgazebo_ros_openni_kinect.so) 역할을 ROS2 포트에서는 이 플러그인이 흡수했다고 한다. 컬러/깊이/포인트클라우드가 한 번에 나온다.

/hand_camera/image_raw          컬러 이미지
/hand_camera/depth/image_raw    깊이 이미지
/hand_camera/depth/points       포인트클라우드

camera_bridge_check라는 노드를 만들어서, 이 이미지가 진짜 계속 들어오는지(shape/평균 밝기)를 확인했다. 검정 화면도 랜덤 노이즈도 아니고 안정적인 값이 계속 찍혀서, 카메라 → ROS2 브릿지가 살아있다는 걸 확인했다.

② RViz2엔 왜 방/테이블이 안 보이나

처음엔 팔의 기본 자세(관절 0°, 0°)에서 카메라가 가까운 벽만 보고 있어서 화면이 회색 두 칸(위: 벽, 아래: 바닥)으로만 나왔다. 그 김에 헷갈렸던 걸 하나 정리했다 — RViz2 메인 3D 뷰에는 방 벽/테이블이 아예 안 보인다. room.world에 넣은 벽/테이블은 Gazebo 월드(SDF)에 직접 넣은 static 모델이라 /robot_description(URDF)에는 없고, RViz2는 ROS2로 발행되는 것만 그리지 .world 파일 자체는 읽지 않기 때문이다. 반대로 카메라 이미지엔 벽/테이블이 나오는데, 이건 그 이미지가 RViz가 그리는 게 아니라 Gazebo 렌더러가 실제 월드를 찍어서 토픽으로 보내주기 때문이다.

③ 팔 각도 계산해서 테이블 조준하기

room.world의 테이블이 정확히 (x=1.2, y=1.2)에 있어서, 원점(팔 회전축)에서 보면 딱 45° 대각선 방향이다. 두 관절(어깨/팔꿈치)이 전부 Z축 회전이라 팔꿈치를 0°로 펴두면, 어깨만 45°로 돌렸을 때 곧게 뻗은 팔+카메라가 정확히 그 대각선 위에 놓여서 테이블을 정면으로 보게 된다.

ros2 topic pub --once /joint_trajectory_controller/joint_trajectory \
  trajectory_msgs/msg/JointTrajectory \
  "{joint_names: ['shoulder_joint','elbow_joint'], points: [{positions: [0.785398, 0.0], time_from_start: {sec: 3}}]}"

계산대로 화면 정중앙에 테이블이 잡혔다.

 

(참고로 두 관절 다 Z축 회전이라, 카메라는 항상 바닥에서 약 0.1m 높이에서 순수 수평으로만 본다. 테이블이 마침 바닥~0.4m 높이라 그 안에 걸쳐 보인 거고, 카메라를 위아래로 기울이려면 팔에 다른 축의 회전 관절이 있어야 한다.)

④ YOLO 인식 노드 붙이기

여기서부터 3-2. camera_bridge_check의 콜백(이미지 받아서 shape만 로그로 찍던 부분)을 YOLOv8n(ultralytics, COCO 80종 사전학습) 추론으로 바꿨다. 결과는 두 가지로 발행한다.

  • /hand_camera/detections: JSON 문자열로 [{"class": "cup", "confidence": 0.83, "bbox": [...]}] 형태
  • /hand_camera/detections_image: 바운딩박스 그려진 디버그 이미지 (RViz2에 새 디스플레이로 추가)

표준 메시지 타입인 vision_msgs 대신 std_msgs/String(JSON)을 쓴 건, vision_msgs의 필드 구성이 버전(3.x/4.x)마다 꽤 달라서 지금 설치된 버전을 확인 없이 맞추면 빌드가 깨질 위험이 있었기 때문이다. 우선 확실히 동작하는 방식으로 가고, 나중에(Phase 4에서 좌표를 더 정교하게 다뤄야 할 때) 정식 타입으로 바꾸는 걸 고려하기로 했다.

⑤ numpy 버전 충돌 두 번 겪기

pip install ultralytics를 하고 나니 cv_bridge가 AttributeError: _ARRAY_API not found로 깨졌다. ultralytics가 numpy를 2.x로 업그레이드시켰는데, ROS2 Humble의 cv_bridge는 numpy 1.x C API에 맞춰 컴파일된 바이너리라서 생긴 문제였다.

pip install "numpy<2"

numpy를 1.26.4로 내려서 해결했는데, 이번엔 반대 방향 충돌이 났다. ultralytics가 같이 설치한 pip판 opencv-python이 numpy>=2를 요구한다는 경고가 뜬 것. 시스템에 이미 ROS(apt)용 python3-opencv가 cv_bridge와 궁합 맞게 깔려있어서, pip판을 지우고 시스템 버전을 쓰도록 정리했다.

pip uninstall -y opencv-python opencv-python-headless

교훈: 같은 파이썬 환경에 ROS(apt)가 설치한 라이브러리와 pip이 설치한 라이브러리가 섞이면 numpy 버전 기대치가 어긋나기 쉽다. "먼저 있었고 잘 동작하던 쪽"을 기준으로 잡고, 나중에 들어온 pip 패키지 쪽을 정리하는 방향이 안전했다.

⑥ Gazebo 상자는 왜 안 잡히나 — 모델 자체를 따로 검증

문제를 다 해결하고 실행했는데, room.world의 테이블(밋밋한 갈색 박스)엔 계속 "탐지된 물체 없음"만 떴다. 이게 아직 남은 설치 문제인지, 아니면 "상자가 실제 사진처럼 안 생겨서"인지 구분하려고, ROS2/Gazebo와 완전히 분리된 검증 스크립트(yolo_sanity_check.py, colcon build 불필요)를 만들어서 ultralytics 표준 샘플 사진(버스+사람들)에 같은 모델을 돌려봤다.

버스(0.87), 사람 4명(0.87/0.85/0.83/0.26), 정지 표지판(0.26)까지 정확히 잡아냈다 — 잘 보이는 건 확신 높게, 가려지거나 애매한 건 확신 낮게 잡는 전형적인 YOLO 패턴 그대로였다. 즉 모델/설치는 완전히 정상이고, Gazebo 상자가 안 잡히는 건 버그가 아니라 실제 사진으로 학습된 모델 입장에서 그 상자가 COCO의 어떤 물체와도 안 닮아서 생기는, 예상됐던 결과(sim-to-real 격차)였다.

덤으로 이 실행에서 CUDA initialization: driver too old 경고를 봤다. GPU 드라이버가 오래돼서 가속이 안 되고 CPU로 도는 중이고, 프레임당 추론에 10초 가까이 걸렸다. 지금 단계엔 문제없지만 나중에 실시간 반응이 필요해지면 짚어야 할 부분으로 남겨뒀다.

다음 편 (Phase 4) 예고

인지(카메라 + YOLO) 기본기는 여기까지. 다음은 MoveIt2로 실제 조작(그리핑) 단계로 넘어간다.

Phase 2-1에서 2관절 평면팔을 Gazebo 물리 시뮬레이터 안에서 실제로 움직이는 데까지 성공했다. Phase 3(인지)로 바로 넘어가지 않고 Phase 2를 조금 더 파보기로 했다 — 오늘(2-2)은 세 가지를 정하고 시작했는데, 하다 보니 두 가지가 더 붙었다.

① RViz2 + Gazebo, 같이 띄워서 눈으로 확인

Phase 1~2-1을 거치면서 "RViz2는 그림판, Gazebo는 물리 실험실"이라고 개념으로는 알고 있었는데, 둘을 동시에 띄워서 직접 비교해봤다.

핵심은: Gazebo가 유일하게 물리를 계산하는 주체(source of truth)고, RViz2는 그 결과(TF)를 구독만 하는 "거울"이라는 것. 그래서 둘을 같이 켜도 물리 계산이 두 번 일어나는 게 아니라, RViz2는 그냥 Gazebo가 계산한 결과를 그대로 비추기만 한다.

② 컨트롤러 종류: position vs velocity vs effort

지금까지 계속 position(목표 각도) 명령만 썼는데, ros2_control에는 세 가지 명령 인터페이스가 있다는 걸 정리했다.

  position velocity effort
명령 내용 목표 각도 목표 각속도 목표 토크(힘)
실제로 쓰이는 곳 산업용 로봇팔(오늘까지 쓴 것) 바퀴 기반 이동 로봇(Nav2) 힘 조절이 필요한 그리퍼, 두 발 밸런싱

오늘 쓴 팔 예제는 "이 각도로 가라"만 명령하면 충분해서 position이 딱 맞았다. 최종 목표(방 안 가사 로봇)로 갈수록 바퀴/다리로 이동하는 부분엔 velocity, 물건을 쥐는 힘 조절이나 두 발로 서서 균형 잡는 부분엔 effort가 필요해진다 — position/velocity만으로는 "얼마나 세게 쥘지"를 표현할 방법이 없기 때문이다.

가속도는 이 셋과는 다른 축이라는 것도 짚고 넘어갔다. 궤적의 각 경유점(JointTrajectoryPoint)에 positions뿐 아니라 velocities, accelerations까지 채워주면, 컨트롤러가 그 지점에서 멈추지 않고 부드럽게 지나가는 궤적을 계산해준다 — command_interface를 바꾸는 문제가 아니라 궤적을 얼마나 자세히 지정해주느냐의 문제였다.

③ 방 형태의 커스텀 world

최종 목표가 "방 안 가사를 해주는 로봇"이다 보니, 빈 허공 대신 방 안에서 팔을 움직여보고 싶었다. worlds/room.world를 SDF로 새로 작성했다 — 4m×4m 방, 벽 4개(남쪽에 폭 1m 출입구를 남겨서 나중 Nav2 이동 테스트 때 문 통과 시나리오로 쓸 계획), 테이블 역할의 박스 하나(나중 조작 단계에서 팔이 다룰 물체 자리).

④ launch 파일 견고화

Phase 2-1의 launch 파일은 TimerAction(period=4.0)처럼 "4초 있으면 컨트롤러가 다 뜨겠지"라고 고정된 시간을 추측해서 다음 단계를 실행했다. 컴퓨터가 그날따라 느리면 깨질 수 있는 방식이라, RegisterEventHandler(OnProcessExit(...))로 바꿨다. "몇 초"가 아니라 "앞 프로세스가 실제로 끝나는 이벤트"를 감지해서 다음 걸 실행하는 방식이라, 속도에 상관없이 안정적으로 동작한다.

spawn_entity 종료 → joint_state_broadcaster 로드
                        ↓ 종료
                                        joint_trajectory_controller 로드

⑤ (덤) 계속 반복 이동시키기

여기까지 하고 나니 "왕복 한 번 하고 끝나는 게 아쉽다, 계속 움직이게 할 순 없냐"는 질문이 나왔다. trajectory_loop_demo.py를 추가했다 — 궤적을 보내는 액션 자체는 그대로 두고, 같은 목표를 Ctrl+C할 때까지 while 루프 안에서 계속 재전송하는 구조다.

⑥ (덤) 터미널 하나로 합치기

Gazebo 켜는 터미널과 반복 이동을 실행하는 터미널이 두 개로 나뉘는 게 불편해서, launch 파일에 run_demo:=true 옵션을 추가했다. ④에서 쓴 이벤트 체이닝을 그대로 재사용해서, 컨트롤러가 활성화되는 순간 반복 이동까지 같은 launch 프로세스 안에서 자동으로 시작되게 만들었다.

ros2 launch fk_2link_demo gazebo_room.launch.py run_demo:=true

터미널 하나, Ctrl+C 한 번으로 Gazebo/RViz2/컨트롤러/반복이동이 전부 함께 뜨고 함께 정리된다. 실제로 돌아가는 모습을 짧게 녹화해봤다.

rviz_demo.mp4
3.43MB

통신 개념 복습

이 김에 ROS2 통신 방식 세 가지(토픽/서비스/액션)와 DDS도 다시 정리했다.

  • 토픽: 발행자가 채널에 계속 뿌리고, 구독자는 콜백 걸어놓고 받는 끝없는 스트림.
  • 서비스: 짧고 즉각적인 요청 → 응답. 중간 피드백이나 취소는 없음.
  • 액션: "오래 걸리는 작업"을 위한 방식. 목표 요청 → 수락/거부(서비스처럼) → 진행 중 피드백(토픽처럼) → 최종 결과, 순서로 동작하고 중간에 취소도 가능하다. 결국 서비스+토픽을 조합한 상위 개념인 셈.
  • DDS: 이 모든 통신의 밑바닥 규격. ROS1의 roscore 같은 중앙 서버 없이, 노드가 뜨는 순간 자동으로 서로를 찾아 연결된다(디스커버리). 그래서 터미널을 여러 개 띄워도 IP 설정 없이 알아서 통신되는 것.

다음 편 (Phase 3) 예고

인지(CV/YOLO) — 카메라로 방 안의 물체를 인식하는 단계로 넘어간다. 오늘 만든 테이블 박스가 그 첫 대상이 될 것 같다.

방 안 가사를 대신 해주는 휴머노이드 로봇을 만들겠다는 목표로 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_publisherrobot_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 xyz0 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 만들어보기 (최종 목표인 "방 안 가사 로봇" 시나리오에 조금씩 가까워지는 중)

오늘 한 일

지난번엔 2관절 평면팔의 순기구학(FK)을 파이썬으로 직접 계산했었다 (fk_2link_arm.py). 동차변환 행렬을 손으로 곱해서 손끝 좌표를 구하는 방식이었는데, 이번 Phase 1에서는 같은 걸 ROS2 방식으로 다시 만들어봤다. 목표는 간단하다 — 파이썬으로 계산했던 값(θ1=40°, θ2=-25°)을 그대로 ROS2 노드가 발행하고, RViz2가 알아서 순기구학을 계산해서 그려주는지 확인하는 것.

패키지 이름은 fk_2link_demo.

ROS2의 핵심 통신 방식: 토픽(Topic)

ROS2 노드끼리 데이터를 주고받는 방법 중 가장 기본이 되는 게 토픽(Topic)이다. 발행자(Publisher)가 특정 이름의 채널에 메시지를 계속 쏘고, 구독자(Subscriber)는 그 채널을 구독해두면 메시지가 올 때마다 자동으로 콜백이 호출되는 구조다. 발행자와 구독자는 서로의 존재를 몰라도 되고, 토픽 이름과 메시지 타입만 맞으면 통신이 성립한다.

이번에 만든 두 노드가 정확히 이 관계다.

1) joint_publisher.py — 관절 각도를 계속 쏘는 쪽

self.publisher_ = self.create_publisher(JointState, 'joint_states', 10)
self.timer = self.create_timer(0.1, self.publish_joint_states)

self.theta1 = math.radians(40)
self.theta2 = math.radians(-25)

def publish_joint_states(self):
    msg = JointState()
    msg.header.stamp = self.get_clock().now().to_msg()
    msg.name = ['shoulder_joint', 'elbow_joint']
    msg.position = [self.theta1, self.theta2]
    self.publisher_.publish(msg)

/joint_states라는 토픽에 10Hz로 같은 자세(40°/-25°)를 계속 발행한다. msg.name은 URDF에 정의된 조인트 이름과 반드시 일치해야 한다는 게 포인트 — 이름이 다르면 아무리 값을 보내도 로봇은 그 값을 자기 관절과 연결짓지 못한다.

2) joint_state_listener.py — 받아서 찍는 쪽

self.subscription = self.create_subscription(
    JointState,
    'joint_states',
    self.listener_callback,
    10,
)

def listener_callback(self, msg):
    angles_deg = [math.degrees(p) for p in msg.position]
    pairs = ", ".join(f'{name}={deg:.1f}deg' for name, deg in zip(msg.name, angles_deg))
    self.get_logger().info(f'받음: {pairs}')

여기서 신기했던 부분은, listener_callback을 내가 직접 호출하는 코드가 코드 어디에도 없다는 것. create_subscription으로 등록만 해두면 메시지가 도착할 때마다 ROS2 런타임이 알아서 이 함수를 불러준다. 콜백 기반 구조를 코드로 직접 체감한 첫 순간이었다.

robot_state_publisher가 대신 계산해주는 것

/joint_states를 발행하기만 하면, robot_state_publisher가 URDF에 적힌 링크 구조(base_link → upper_arm_link → forearm_link → hand_link)를 보고 TF(좌표 변환)를 자동으로 계산해서 뿌려준다. 즉 예전에 내가 손으로 짰던 동차변환 행렬 곱셈 체인

T_base_shoulder · T_shoulder_forearm · T_forearm_hand

을, URDF + /joint_states만 주면 ROS2가 대신 해주는 셈이다.

검증: 파이썬 계산값이랑 실제로 맞는지 확인

RViz2를 띄우면 팔이 40°/-25° 자세로 서 있는 게 보이는데, 눈으로 보는 것 말고 숫자로도 확인해봤다.

ros2 run tf2_ros tf2_echo base_link hand_link

결과로 나온 Translation의 x, y 값이 예전 파이썬 스크립트 결과인 x=0.4713, y=0.2575와 거의 일치했다. 손으로 짠 동차변환 체인 곱셈과 ROS2의 URDF+TF 계산이 같은 답을 낸다는 걸 직접 검증한 셈이라 확실히 이해가 됐다.

전체 TF 트리는 이렇게 뽑아볼 수도 있다.

ros2 run tf2_tools view_frames

오늘 정리

  • ROS2 토픽 통신 = Publisher/Subscriber 패턴, 이름과 메시지 타입만 맞으면 서로 몰라도 통신 성립
  • create_publisher / create_subscription + 콜백 자동 호출 구조
  • robot_state_publisher가 URDF + /joint_states만으로 TF(순기구학)를 자동 계산
  • 파이썬으로 손으로 계산했던 FK 결과와 ROS2가 계산한 결과가 일치하는 걸 tf2_echo로 직접 검증

다음 Phase 2에서는 이 팔을 RViz2 위의 "그림"이 아니라 Gazebo 물리 시뮬레이터 안에 실제로 존재하게 만들고, 컨트롤러로 진짜 움직여볼 예정이다.

- 레벨 블루프린트를 사용한 오브젝트 사이의 통신

레벨에 존재하는 액터 선택후 레벨 블루프린트에서 우클릭하면 레퍼런스를 얻을 수 있다.

 

- 블루프린트 안에서 컴포넌트 사이의 통신

블루프린트에서 다른 블루프린트 오브젝트 레퍼런스를 프로퍼티로 저장하고 디테일 패널에서 할당

 

- 블루프린트 인터페이스를 사용하여 서로 다른 타입의 오브젝트 간에 원활하게 통신하기

 

블루프린트에서 블루프린트 인터페이스 함수를 호출 하는 것은 메세지 호출과 같다.

구현이 안되어 있으면 아무 일도 일어나지 않는다.

 

여러 항목이 한 이벤트를 처리해야 할 경우 Event Dispatcher를 사용

예) 보스가 죽었을 때 미니언이 같이 죽게 만들기

 

Event Dispatcher = C++에서 델리게이트 역할을 한다

GameMode 블루프린트에 두어 item Updated 델리게이트로 사용할 수 있다.

 

게임모드 내의 이벤트 (런타임) 에서 맵에 존재하는 액터의 레퍼런스를 얻고 싶은 경우

GetActor 류 함수를 사용 (Tag, Class 사용), 태그를 사용하는 경우 액터 태그와 컴포넌트 태그는 별개이므로 주의해야 한다.

런타임에서 액터를 찾는 함수는 느리므로 자주 사용하면 안된다. (Tick 내에서 호출 X)

 

'언리얼 5 > 에픽 게임즈 공식 강의' 카테고리의 다른 글

Gameplay  (0) 2023.02.21
Programming 2  (0) 2023.02.21
Enhanced Input Binding with Gameplay Tags C++  (0) 2023.02.21
Geometry Script  (0) 2023.02.20
Engine Structure  (0) 2023.02.20

- Game Instance

프로그램 시작 ~ 종료 내내 유지되는 오브젝트

리플리케이트되지 않음. 클라이언트와 서버 각각에 독립적으로 존재

레벨을 옮겨도 유지하고싶은 것들을 여기에 배치 (SaveGame 시스템 같은 것들)

 

- Game Mode

맵 로드시 가장 먼저 생기는 매니저 액터

서버에만 존재

멀티플레이어 게임에서 이미 만들어진 세션에 새로운 플레이어가 참여하면 GameMode가 폰 컨트롤러와 State를 만들어준다. 

 

- Game State

Player State 배열을 갖는다.

게임 상태에 대한 정보 저장

서버와 클라이언트에 리플리케이트된다.

 

- Player State

특정 플레이어에 대한 정보 저장

서버와 클라이언트에 리플리케이트된다.

 

캐릭터, 컨트롤러, 폰

- Physics

Pawns은 Kinetic 액터이기 때문에 무한한 질량을 가진다.

dynamic interaction에서 interactor만 영향을 받게된다.

Pawn에게도 영향을 주려면 추가 로직이 필요하다. (HitResult 정보를 사용하기)

 

- Default Pawn

Character의 형제 클래스

Default Pawn : 스태틱 메시 컴포넌트, Sphere 콜리전, Floating Pawn Movement 컴포넌트

Movement가 리플리케이트 되지 않음

 

- Spectator Pawn

Default Pawn의 서브 클래스

콜리전 X, Static Mesh X, 투명 카메라만 존재하는 Pawn

 

 

- Character Movement Component

네트워크에서 레플리케이트 됨

다양한 Movement Mode 보유 (walking jumping falling flying swimming

physics Interaction : physics가 movement에 어떻게 영향을 줄 것인가

 

- Movement Capability 

길찾기 관련 옵션 파라미터 (Non Player Controller의 경우)

캐릭터의 능력 (앉기, 점프, 수영하기) 활성화 여부

 

 

- Player Controller

Player Controller는 클라이언트에서 서버로 레플리케이트된다.

유저 Input과 일반적인 player pawn 관련 logic을 관리한다.

Input Latency를 최소화하기 위해 Pawn보다 먼저 Tick 된다. 

 

- AI Controller 

서버에만 존재한다. 

 

- MassEntity System프레임워크

효율적으로 대량의 데이터를 처리하게 해줌 

 

- Navigation

모바일 캐릭터에 다이나믹 NavMesh를 사용하는 것은 퍼포먼스 문제가 있다.

Pathfinding True 설정하고 AI 캐릭터에게 특정 장소로 이동하게 시키면 Navmesh를 사용해 길을 찾아서 이동한다.

 

- HUD 

리플리케이트되지 않음

플레이어 컨트롤러가 HUD 액터를 스폰함

'언리얼 5 > 에픽 게임즈 공식 강의' 카테고리의 다른 글

Blueprint Communication  (0) 2023.02.22
Programming 2  (0) 2023.02.21
Enhanced Input Binding with Gameplay Tags C++  (0) 2023.02.21
Geometry Script  (0) 2023.02.20
Engine Structure  (0) 2023.02.20

+ Recent posts