Devin.KR

정리와 다음 단계 - 실제 로봇·시뮬레이터로

개발자KR 조회 8

이 장에서 배우는 것

지금까지 두리의 소프트웨어는 노드 하나에서 시작해 토픽으로 값을 흘려보내고, 서비스로 요청을 주고받고, 파라미터와 launch 파일로 여러 노드를 한꺼번에 다루는 수준까지 커졌다. 이 마지막 장에서는 새 기능을 추가하지 않는다. 대신 지금까지 만든 것을 실제 로봇이나 물리 시뮬레이터로 옮기려 할 때 무엇이 그대로 통하고 무엇을 다시 준비해야 하는지 정리하고, 패키지를 넘기기 전에 점검할 목록을 만든다. 그리고 이 책에서는 다루지 않은 QoS, tf2, 액션 같은 주제를 왜 알아야 하는지, 어디서부터 읽으면 되는지 짚어본다.

  • turtlesim과 Gazebo, 실제 로봇 사이에서 어떤 코드가 그대로 재사용되고 어떤 부분을 새로 준비해야 하는지 구분한다
  • 패키지를 실제 환경으로 옮기기 전에 점검해야 할 항목을 목록으로 정리한다
  • 패키지 구조를 자동으로 점검하는 파이썬 스크립트를 작성한다
  • QoS, tf2, 액션이 어떤 문제를 풀어주는 도구인지 파악하고 더 읽을 자료를 찾는다

문제 상황

두리 팀은 turtlesim으로 배달 경로를 따라가는 로직을 완성했다. 팀장은 이제 이 로직을 실제 배달 로봇에 올려보자고 한다. 그런데 코드를 그대로 로봇에 옮겨 전원을 넣으려니 몇 가지가 걸린다. 바닥과 바퀴 사이의 마찰이나 로봇의 무게 때문에 명령한 속도와 실제로 움직이는 속도가 다를 수 있는데, turtlesim에서는 이런 물리적인 어긋남을 한 번도 겪어본 적이 없다. 게다가 두리 소프트웨어 패키지를 급하게 여러 버전으로 나누다 보니, 어떤 버전은 launch 파일이 없어서 노드를 하나씩 손으로 켜야 하고, 어떤 버전은 파라미터 설정 파일이 빠져서 속도값이 코드에 그대로 박혀 있다. 신입 팀원이 이 패키지를 받아서 실행해보려 해도 README가 없어 어디서부터 시작해야 할지 모른다. 실제 로봇에 함부로 명령을 내리기 전에 안전하게 검증할 방법과, 패키지를 넘기기 전에 확인할 목록이 필요한 상황이다.

Gazebo로 넘어가기 전에 알아둘 것

turtlesim은 화면 위에 점 하나를 그려놓고 속도 명령에 따라 위치만 갱신하는 가벼운 도구였다. 바닥과의 마찰도, 로봇의 무게도, 충돌도 계산하지 않는다. 실제 로봇에 명령을 내리기 전에 이런 물리적인 요소까지 흉내 내 보고 싶다면 Gazebo 같은 3차원 물리 시뮬레이터를 쓴다. Gazebo는 로봇의 생김새와 무게, 바퀴의 마찰 계수 같은 값을 모델로 기술해두면, 물리 엔진이 그 모델에 중력과 마찰, 충돌을 계산해 로봇이 실제로 어떻게 움직일지 미리 보여준다. 카메라나 라이다를 달아두면 그 센서가 가상의 장면에서 무엇을 찍을지도 계산해준다.

중요한 점은 노드와 토픽, 서비스로 짜인 코드 자체는 바뀌지 않는다는 것이다. 두리가 속도 명령을 받는 토픽, 배달 요청을 받는 서비스 코드는 turtlesim에서 쓰던 것과 Gazebo에서 쓰는 것, 실제 로봇에서 쓰는 것이 같은 인터페이스를 쓴다. 달라지는 부분은 그 명령을 받아서 실제로(또는 가상으로) 바퀴를 굴리는 가장 아래쪽 층이다. turtlesim에서는 이 층이 화면에 점을 옮기는 코드였고, Gazebo에서는 시뮬레이터의 플러그인이, 실제 로봇에서는 모터 드라이버가 그 역할을 맡는다.

노드·토픽·서비스 코드는 세 환경에서 그대로 쓰이고 아래쪽 구동부만 단계마다 바뀐다
turtlesim·Gazebo·실제 두리가 다루는 것
항목turtlesimGazebo실제 두리
표현 방식2차원 화면의 점과 화살표3차원 물리 공간의 모델실제 하드웨어
물리 계산없음, 위치만 갱신충돌·마찰·관성 계산실제 물리 법칙 그대로
센서 값없음카메라·라이다 잡음까지 흉내실제 센서에서 읽은 값
실패 시 위험없음없음(가상 환경)충돌·부상·장비 손상 가능

이 책에서는 Gazebo를 설치하고 두리 모델을 실제로 띄우는 과정까지는 다루지 않는다. 여기서는 Gazebo가 어떤 문제를 풀어주는 도구인지, 그리고 코드를 어떻게 나눠 놓아야 나중에 옮기기 쉬운지만 짚어둔다. 설치와 모델 작성은 Gazebo 공식 문서를 참고한다.

패키지 구조 점검표

turtlesim에서만 돌리던 패키지를 다른 사람에게 넘기거나 실제 로봇에 옮기려 할 때 자주 걸리는 문제는 대부분 코드 로직이 아니라 패키지 구조에 있다. 워크스페이스와 패키지를 다룬 장에서 만든 패키지 뼈대, launch 파일을 다룬 장에서 만든 실행 스크립트, 파라미터를 다룬 장에서 만든 설정값이 패키지 안에 제대로 자리잡고 있는지 확인하는 것만으로 실수를 크게 줄일 수 있다.

config 디렉터리와 README 파일이 실무에서 가장 자주 빠지는 두 항목이다
패키지를 넘기기 전에 확인할 항목
항목확인 방법빠지면 생기는 문제
package.xml의 실행 의존성파일을 열어 rclpy 등이 의존성으로 적혀 있는지 확인다른 컴퓨터에 배포할 때 필요한 패키지 설치가 빠짐
setup.py의 entry_pointsconsole_scripts에 노드 이름이 등록되어 있는지 확인ros2 run으로 노드를 찾지 못함
resource 마커 파일resource 폴더 안에 패키지 이름과 같은 파일이 있는지 확인패키지가 색인에 안 잡혀 ros2 pkg list에 안 뜸
launch 디렉터리launch 폴더와 .launch.py 파일이 있는지 확인노드를 하나씩 손으로 켜야 함
config 디렉터리파라미터 값을 담은 yaml 파일이 있는지 확인속도 같은 값이 코드에 그대로 박혀 매번 다시 빌드해야 함
README.md최소 실행 명령이 적혀 있는지 확인다른 사람이 패키지를 어떻게 켜야 할지 모름

이 점검을 매번 손으로 하지 않도록, 패키지 폴더를 훑어보고 항목별로 통과 여부를 알려주는 작은 스크립트를 만들어본다. 이 스크립트는 ROS를 설치하지 않은 컴퓨터에서도 순수 파이썬만으로 실행된다.

이 책 다음에 볼 만한 것 - QoS·tf2·액션

QoS(서비스 품질, Quality of Service) — 토픽을 다룬 장에서는 발행자와 구독자를 기본 설정 그대로 연결했다. 실제로는 메시지를 몇 개까지 쌓아둘지, 느리게 구독하는 노드를 기다려줄지 같은 값을 따로 정할 수 있다. 배터리 경고처럼 하나라도 놓치면 안 되는 토픽과 카메라 영상처럼 최신 값만 중요한 토픽은 이 설정이 달라야 한다. 자세한 옵션은 ROS 2 공식 문서의 QoS 설명에서 확인할 수 있다.

tf2 — 시간과 좌표를 맛본 장에서 좌표계 사이의 관계를 잠깐 다뤘다. 두리에는 몸통, 바퀴, 카메라마다 각자의 좌표계가 있고, tf2는 이 좌표계 사이의 관계를 시간에 따라 계속 추적해준다. 카메라가 찾은 물체의 위치를 몸통 기준으로, 또는 지도 기준으로 바꾸는 계산을 tf2가 대신 해준다. 공식 tf2 튜토리얼에서 좌표계를 선언하고 조회하는 방법을 다룬다.

액션(action) — 서비스를 다룬 장에서는 요청을 보내고 응답이 올 때까지 기다리는 방식을 배웠다. 그런데 "3미터 앞으로 이동해라"처럼 몇 초에서 몇 분씩 걸리고, 도중에 얼마나 왔는지 알려주거나 중간에 취소할 수 있어야 하는 작업에는 서비스가 맞지 않는다. 액션은 요청(goal), 중간 보고(feedback), 최종 결과(result)를 갖춘 통신 방식으로 이런 긴 작업에 알맞다. 공식 문서의 액션 개념 설명에서 구조를 확인할 수 있다.

완성 코드

파일명: check_package.py

앞서 정리한 점검표를 실제로 실행해 보는 스크립트다. ROS가 설치되어 있지 않아도 표준 라이브러리만으로 동작하며, 인자 없이 실행하면 임시 폴더에 예시 패키지를 만들어 스스로 점검한다. 실제 패키지를 점검하려면 그 패키지의 경로를 인자로 넘긴다.

import os
import shutil
import sys
import tempfile


def make_sample_package(root):
    pkg_dir = os.path.join(root, 'dooli_driver')
    os.makedirs(os.path.join(pkg_dir, 'dooli_driver'), exist_ok=True)
    os.makedirs(os.path.join(pkg_dir, 'resource'), exist_ok=True)
    os.makedirs(os.path.join(pkg_dir, 'launch'), exist_ok=True)

    with open(os.path.join(pkg_dir, 'package.xml'), 'w') as f:
        f.write(
            '<package format="3">\n'
            '  <name>dooli_driver</name>\n'
            '  <exec_depend>rclpy</exec_depend>\n'
            '</package>\n'
        )

    with open(os.path.join(pkg_dir, 'setup.py'), 'w') as f:
        f.write(
            "from setuptools import setup\n\n"
            "setup(\n"
            "    name='dooli_driver',\n"
            "    entry_points={'console_scripts': [\n"
            "        'dooli_node = dooli_driver.dooli_node:main',\n"
            "    ]},\n"
            ")\n"
        )

    with open(os.path.join(pkg_dir, 'resource', 'dooli_driver'), 'w') as f:
        f.write('')

    with open(os.path.join(pkg_dir, 'dooli_driver', 'dooli_node.py'), 'w') as f:
        f.write('def main():\n    pass\n')

    with open(os.path.join(pkg_dir, 'launch', 'dooli.launch.py'), 'w') as f:
        f.write('# 실행용 launch 파일 자리\n')

    return pkg_dir


def has_package_xml(pkg_dir):
    return os.path.isfile(os.path.join(pkg_dir, 'package.xml'))


def has_rclpy_depend(pkg_dir):
    path = os.path.join(pkg_dir, 'package.xml')
    if not os.path.isfile(path):
        return False
    with open(path) as f:
        return 'rclpy' in f.read()


def has_setup_py(pkg_dir):
    return os.path.isfile(os.path.join(pkg_dir, 'setup.py'))


def has_entry_point(pkg_dir):
    path = os.path.join(pkg_dir, 'setup.py')
    if not os.path.isfile(path):
        return False
    with open(path) as f:
        return 'console_scripts' in f.read()


def has_resource_marker(pkg_dir):
    name = os.path.basename(pkg_dir)
    return os.path.isfile(os.path.join(pkg_dir, 'resource', name))


def has_launch_dir(pkg_dir):
    return os.path.isdir(os.path.join(pkg_dir, 'launch'))


def has_config_dir(pkg_dir):
    return os.path.isdir(os.path.join(pkg_dir, 'config'))


def has_readme(pkg_dir):
    return os.path.isfile(os.path.join(pkg_dir, 'README.md'))


CHECKLIST = [
    ('package.xml 존재', has_package_xml),
    ('package.xml에 rclpy 의존성 명시', has_rclpy_depend),
    ('setup.py 존재', has_setup_py),
    ('entry_points에 노드 등록', has_entry_point),
    ('resource 마커 파일 존재', has_resource_marker),
    ('launch 디렉터리 존재', has_launch_dir),
    ('config 디렉터리 존재', has_config_dir),
    ('README.md 존재', has_readme),
]


def run_checklist(pkg_dir):
    return [(label, check(pkg_dir)) for label, check in CHECKLIST]


def print_report(results):
    passed = 0
    for label, ok in results:
        mark = 'OK' if ok else 'MISS'
        if ok:
            passed += 1
        print(f'[{mark:>4}] {label}')
    print(f'--- {passed}/{len(results)} 항목 통과 ---')


def main():
    target = sys.argv[1] if len(sys.argv) > 1 else None
    tmp_root = None
    if target is None:
        tmp_root = tempfile.mkdtemp(prefix='dooli_ws_')
        target = make_sample_package(tmp_root)
        print('샘플 패키지를 임시 폴더에 만들고 점검한다.\n')

    results = run_checklist(target)
    print_report(results)

    if tmp_root is not None:
        shutil.rmtree(tmp_root)


if __name__ == '__main__':
    main()

줄별 해설

  • make_sample_package는 점검 대상이 없을 때 쓸 예시 패키지를 임시 폴더 안에 만든다. package.xml에는 rclpy 의존성을 적어 넣고, setup.py에는 console_scripts 항목을 넣지만, config 폴더와 README.md는 일부러 만들지 않는다.
  • has_package_xml부터 has_readme까지 여덕 개 함수는 각각 패키지 폴더 하나(pkg_dir)를 받아 항목 하나의 통과 여부를 True나 False로 돌려준다. 함수 하나가 항목 하나만 책임지므로 새 항목을 추가할 때 기존 함수를 건드리지 않아도 된다.
  • CHECKLIST는 화면에 보여줄 설명 문구와 그 항목을 검사하는 함수를 짝지어 놓은 목록이다. 항목을 늘리거나 줄일 때 이 목록만 고치면 된다.
  • run_checklist는 CHECKLIST를 돌면서 각 검사 함수를 실행하고, print_report는 그 결과를 사람이 읽을 수 있는 형태로 출력하면서 통과한 개수를 센다.
  • main은 커맨드라인 인자로 경로가 주어졌으면 그 경로를 점검하고, 없으면 임시 폴더에 예시 패키지를 만들어 점검한 뒤 임시 폴더를 지운다.

실행 결과

python3 check_package.py를 실행하면 다음과 같이 나온다.

샘플 패키지를 임시 폴더에 만들고 점검한다.

[  OK] package.xml 존재
[  OK] package.xml에 rclpy 의존성 명시
[  OK] setup.py 존재
[  OK] entry_points에 노드 등록
[  OK] resource 마커 파일 존재
[  OK] launch 디렉터리 존재
[MISS] config 디렉터리 존재
[MISS] README.md 존재
--- 6/8 항목 통과 ---

실제 ROS 2 패키지에 대해 python3 check_package.py ~/ros2_ws/src/dooli_driver 처럼 경로를 넘기면, 그 패키지를 그대로 점검한 결과를 같은 형식으로 보여준다.

실무에서 자주 틀리는 것

resource 마커를 폴더로 만들어 패키지가 안 잡히는 경우

resource 안에 있어야 할 마커는 내용이 없는 파일이어야 한다. 폴더로 만들면 패키지 색인이 이 패키지를 찾지 못한다.

틀린 코드

os.makedirs('resource/dooli_driver')

고친 코드

os.makedirs('resource', exist_ok=True)
open('resource/dooli_driver', 'w').close()

package.xml에 실행 의존성을 적지 않는 경우

같은 컴퓨터에서는 rclpy가 이미 설치돼 있어서 문제가 안 드러나지만, 다른 컴퓨터나 컨테이너로 패키지를 옮기면 필요한 패키지가 설치 목록에서 빠진다.

틀린 코드

<package format="3">
  <name>dooli_driver</name>
  <buildtool_depend>ament_python</buildtool_depend>
</package>

고친 코드

<package format="3">
  <name>dooli_driver</name>
  <buildtool_depend>ament_python</buildtool_depend>
  <exec_depend>rclpy</exec_depend>
  <exec_depend>std_msgs</exec_depend>
</package>

시뮬레이터와 실제 로봇 설정을 한 launch 파일에 섞는 경우

실제 하드웨어 포트를 launch 파일에 그대로 박아 넣으면, 같은 파일을 Gazebo에서 켤 때 존재하지 않는 포트를 열려다 오류가 난다.

틀린 코드

from launch import LaunchDescription
from launch_ros.actions import Node


def generate_launch_description():
    return LaunchDescription([
        Node(
            package='dooli_driver',
            executable='dooli_node',
            parameters=[{'wheel_port': '/dev/ttyUSB0'}],
        ),
    ])

고친 코드

from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument
from launch.substitutions import LaunchConfiguration
from launch_ros.actions import Node


def generate_launch_description():
    use_sim = DeclareLaunchArgument('use_sim', default_value='false')

    return LaunchDescription([
        use_sim,
        Node(
            package='dooli_driver',
            executable='dooli_node',
            parameters=[{
                'wheel_port': '/dev/ttyUSB0',
                'use_sim': LaunchConfiguration('use_sim'),
            }],
        ),
    ])

파라미터를 config 파일 대신 코드에 하드코딩하는 경우

속도값을 코드에 그대로 적으면 값을 바꿀 때마다 다시 빌드해야 한다. 파라미터로 선언해두면 config 폴더의 yaml 파일만 바꿔서 동작을 조정할 수 있다.

틀린 코드

class DooliNode(Node):
    def __init__(self):
        super().__init__('dooli_node')
        self.max_speed = 0.8

고친 코드

class DooliNode(Node):
    def __init__(self):
        super().__init__('dooli_node')
        self.declare_parameter('max_speed', 0.8)
        self.max_speed = self.get_parameter('max_speed').value

한눈에 보기

이 책 다음에 볼 만한 주제
주제이 책에서 다룬 정도실제로 필요해지는 상황
QoS기본값만 사용느린 네트워크나 절대 놓치면 안 되는 알림을 다룰 때
tf2개념만 소개카메라·라이다처럼 좌표계가 다른 센서를 합칠 때
액션다루지 않음이동처럼 오래 걸리고 중간에 취소할 수 있어야 하는 작업
Gazebo개념 소개만실제 로봇에 태우기 전에 위험 없이 미리 검증할 때

연습 문제

  1. 두리의 주행 코드를 turtlesim에서 Gazebo로 옮길 때, 그대로 유지되는 부분과 새로 준비해야 하는 부분을 각각 한 가지씩 써라.
  2. package.xml에 exec_depend를 빠뜨렸을 때 colcon build는 성공하는데 왜 실행 시점에 문제가 생길 수 있는지 설명하라.
  3. 두리가 "3미터 앞으로 이동하라"는 명령을 받았을 때, 이 작업을 서비스가 아니라 액션으로 구현하는 것이 더 알맞은 이유를 서비스와 액션의 차이를 들어 설명하라.
  4. check_package.py에 launch 폴더 안에 파일이 최소 1개 있는지까지 확인하는 항목을 추가한다면 어떤 함수를 추가해야 할지 설명하라.

정답과 해설

1. 그대로 유지되는 부분은 토픽과 서비스 인터페이스, 그리고 목적지까지 속도를 계산하는 노드 로직이다. 새로 준비해야 하는 부분은 물리 법칙을 반영한 로봇 모델과 그 모델을 구동하는 시뮬레이터 플러그인이다.

2. colcon build는 소스 코드의 문법과 빌드 규칙만 확인하고, rclpy가 실제로 설치되어 있는지는 확인하지 않는다. 같은 워크스페이스에 이미 설치돼 있으면 문제가 드러나지 않는다. exec_depend가 없으면 이 패키지를 다른 컴퓨터나 컨테이너로 옮길 때 필요한 의존 패키지를 설치 목록에서 빠뜨리게 되고, 실행 시점에 모듈을 찾지 못하는 오류가 난다.

3. 서비스는 요청을 보내고 응답이 올 때까지 기다리는 짧은 작업에 알맞은 반면, 이동은 몇 초에서 몇 분씩 걸리고 중간에 얼마나 왔는지 알려주거나 도중에 취소할 수 있어야 하는데 서비스 인터페이스에는 이런 중간 보고나 취소 개념이 없다. 액션은 요청, 중간 보고, 최종 결과를 갖춘 구조라 이런 작업에 더 알맞다.

4. has_launch_dir처럼 폴더 존재만 보는 대신, launch 폴더 안의 파일 목록을 확인해 그 안에 파일이 하나 이상 있는지 True나 False로 돌려주는 함수를 추가하면 된다. 그 함수를 CHECKLIST 목록에 튜플로 추가하기만 하면 나머지 로직은 그대로 재사용된다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.