AEGLOSLAB ROS 2 Jazzy 대전

제0부 개관과 출발

0.6 첫 패키지 워크스루 — C++와 Python

0.6.1 이 장에서 배우는 것 / 선수 지식

이 장은 튜토리얼처럼 보이지만 목적이 다르다. "따라 하면 동작한다"가 아니라 "파일 하나하나가 왜 있고 무엇을 결정하는가" 를 해부한다. 뒤에 나오는 빌드 시스템 심층(제4부)·컴포지션(1.13)·테스트(14.5)가 모두 이 구조 위에 구축되므로, 여기서 뼈대를 정확히 세워 두면 나중이 쉽다.

C++ 패키지와 Python 패키지를 나란히 놓고 대조한다. 두 언어의 파일 구조·설치 규칙·실행 경로가 다르며, 그 차이가 나중에 성능·배포·디버깅 방식의 차이로 이어지기 때문이다.

배우는 것:

  • 패키지의 최소 구성 — package.xml이 왜 유일한 필수 파일인가.
  • ament_cmake 패키지 해부 — CMakeLists.txt의 각 줄이 하는 일, install(TARGETS … DESTINATION lib/${PROJECT_NAME})이 왜 그 경로여야 하는가.
  • ament_python 패키지 해부 — setup.py의 entry_points·data_files, resource/ 마커 파일의 정체.
  • 최소 발행 노드 코드 — C++·Python 양쪽. 각 줄의 의미와 흔한 실수.
  • 빌드·실행·검사의 한 사이클과, 각 단계에서 무엇을 확인해야 하나.
  • C++와 Python 중 무엇을 언제 쓰나 — ①②③ 판단 축.
  • 첫날부터 지켜야 할 구조 원칙 — 인터페이스 패키지 분리, 로직과 노드의 분리.

선수 지식: 0.5(워크스페이스·오버레이). C++ 또는 Python 기본 문법.

정직: 아래 코드는 Jazzy 기준 관용구로 작성했으나 이 환경에서 컴파일·실행 검증하지 않았다(목차 3). 특히 CMake의 의존성 연결 관용구는 배포판을 거치며 권장 방식이 바뀌는 중이므로(0.6.4), 팀 표준을 정하기 전에 현행 문서를 확인하기 바란다. 143차(122번째 회차)가 이 장의 명령·코드를 ros:jazzy-ros-base·ros:humble-ros-base에서 한 줄도 안 빠뜨리고 그대로 실행 확인했다 — 패키지 생성부터 빌드·실행·topic echo까지 전 사이클이 문서 그대로 동작했다. 아래 각 절의 실행 확인 블록을 보라.

0.6.2 패키지란 무엇인가 — 최소 정의

ROS 2에서 패키지는 package.xml을 가진 디렉터리다. 그것이 전부다. 빌드 시스템(ament_cmake·ament_python)은 package.xml의 <export><build_type> 태그로 결정된다.

package.xml (유일한 필수 파일) build_type 선언 ament_cmake → CMakeLists.txt 필요 ament_python → setup.py 필요 ament_cmake_ python 등 혼합·기타

패키지를 나누는 기준(첫날부터 중요) — 이 결정은 나중에 되돌리기 비용이 크다.

원칙이유
인터페이스(.msg·.srv·.action)는 별도 패키지로인터페이스 패키지에 규모가 큰 의존성이 붙으면, 그 인터페이스를 쓰는 모든 곳이 그 의존성을 끌고 온다. 재빌드 범위도 커진다(1.2)
알고리즘 로직과 ROS 노드를 분리로직을 순수 라이브러리로 두면 단위 테스트가 쉽고(14.5), 나중에 컴포넌트·다른 프레임워크로 옮기기 쉽다
하드웨어 드라이버는 독립 패키지하드웨어 없이 나머지를 빌드·테스트할 수 있어야 한다
launch·설정은 별도 _bringup 패키지시스템 구성이 코드와 분리되어 배포 단위가 명확해진다(14.4)

0.6.3 패키지 생성 — 두 언어 대조

cd ~/ros2_ws/src

# C++ 패키지
ros2 pkg create --build-type ament_cmake --license Apache-2.0 \
  --dependencies rclcpp std_msgs -- my_cpp_pkg

# Python 패키지
ros2 pkg create --build-type ament_python --license Apache-2.0 \
  --dependencies rclpy std_msgs -- my_py_pkg

--license는 최근 배포판에서 생략 시 경고가 난다 (확인 필요 — Jazzy에서의 정확한 동작)(실행 확인, 143차 — Humble·Jazzy 양쪽에서 --license 없이 ros2 pkg create를 실행하면 정확히 이 경고가 출력된다: [WARNING]: Unknown license 'TODO: License declaration'. This has been set in the package.xml, but no LICENSE file has been created. 이어서 권장 라이선스 식별자 목록을 나열한다 — 빌드 자체는 막지 않고 경고만 하며, package.xml에는 TODO: License declaration이라는 자리표시자가 그대로 들어간다). 조직 표준 라이선스를 처음부터 넣는 습관이 좋다(14.7).

생성되는 구조 대조:

C++ (ament_cmake)Python (ament_python)역할
package.xmlpackage.xml메타데이터·의존성 선언
CMakeLists.txtsetup.py빌드·설치 규칙
—setup.cfg스크립트 설치 경로 지정
src/*.cppmy_py_pkg/*.py소스
include/my_cpp_pkg/*.hpp—공개 헤더
—resource/my_py_pkgament index 마커(빈 파일)
test/test/테스트

함정: Python 패키지의 resource/<패키지명>은 내용이 빈 파일이지만 필수다. 이 파일이 share/ament_index/resource_index/packages/에 설치되어야 ROS 2가 그 패키지를 인식한다(0.5.5). 수동으로 패키지를 만들다가 이것을 빠뜨리면 "빌드는 되는데 ros2 run이 패키지를 못 찾는" 상태가 된다.

0.6.4 CMakeLists.txt 해부

cmake_minimum_required(VERSION 3.8)
project(my_cpp_pkg)

if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
  add_compile_options(-Wall -Wextra -Wpedantic)
endif()

find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(std_msgs REQUIRED)

add_executable(talker src/talker.cpp)
target_link_libraries(talker
  rclcpp::rclcpp
  ${std_msgs_TARGETS}
)

install(TARGETS talker
  DESTINATION lib/${PROJECT_NAME})

if(BUILD_TESTING)
  find_package(ament_lint_auto REQUIRED)
  ament_lint_auto_find_test_dependencies()
endif()

ament_package()
줄하는 일주의
find_package(ament_cmake REQUIRED)ament의 CMake 매크로를 불러온다없으면 ament_package()가 없다
find_package(rclcpp REQUIRED)의존 패키지의 타깃·경로를 노출package.xml에도 반드시 같이 선언해야 한다(4.4)
add_executable실행 파일 정의—
target_link_libraries(… rclcpp::rclcpp)현대 CMake 방식 — 네임스페이스 타깃 연결예전 코드는 ament_target_dependencies(talker rclcpp std_msgs)를 쓴다. 두 방식이 혼재하며, 최신 문서는 타깃 기반을 권장한다 (확인 필요 — 배포판별 권장 방식)
${std_msgs_TARGETS}메시지 패키지가 내보내는 타깃 묶음인터페이스 패키지에 쓰는 관용구(1.2)
install(TARGETS … DESTINATION lib/${PROJECT_NAME})ros2 run이 찾는 경로에 설치이 경로가 아니면 ros2 run이 실행 파일을 못 찾는다(0.5.5)
ament_package()ament index 마커·환경 훅·설정 파일 생성. 반드시 마지막이 줄 뒤에 오는 CMake 코드는 무시되거나 오작동한다

실행 확인(143차, Humble·Jazzy 양쪽). 이 절의 CMakeLists.txt를 한 글자도 안 고치고(rclcpp::rclcpp + ${std_msgs_TARGETS} 그대로) talker.cpp와 함께 빌드했다 — 양쪽 배포판에서 경고 없이 컴파일됐고, 만든 실행 파일도 정상 동작했다(ros2 run으로 publishing: 'hello N'이 500ms 간격으로 출력됨). "배포판별 권장 방식이 다를 수 있다"는 우려와 달리, 이 조합(현대 타깃 기반 CMake)은 Humble·Jazzy에서 동일하게 동작한다 — 적어도 이 예제 규모에서는 배포판 분기가 필요 없다. ament_target_dependencies 구식 방식과의 직접 비교는 이번 회차에 하지 않았다. 목차 3.

추가 설치 규칙 — launch·설정 파일:

install(DIRECTORY launch config
  DESTINATION share/${PROJECT_NAME})

share/<패키지>/는 런타임 데이터의 표준 위치다. launch 파일에서 FindPackageShare로 이 경로를 찾는다(14.3).

0.6.5 C++ 최소 발행 노드

// src/talker.cpp
#include <chrono>
#include <memory>
#include <string>

#include "rclcpp/rclcpp.hpp"
#include "std_msgs/msg/string.hpp"

using namespace std::chrono_literals;

class Talker : public rclcpp::Node
{
public:
  Talker()
  : Node("talker"), count_(0)
  {
    publisher_ = create_publisher<std_msgs::msg::String>("chatter", 10);
    timer_ = create_wall_timer(500ms, std::bind(&Talker::on_timer, this));
  }

private:
  void on_timer()
  {
    auto message = std_msgs::msg::String();
    message.data = "hello " + std::to_string(count_++);
    RCLCPP_INFO(get_logger(), "publishing: '%s'", message.data.c_str());
    publisher_->publish(message);
  }

  rclcpp::TimerBase::SharedPtr timer_;
  rclcpp::Publisher<std_msgs::msg::String>::SharedPtr publisher_;
  size_t count_;
};

int main(int argc, char ** argv)
{
  rclcpp::init(argc, argv);
  rclcpp::spin(std::make_shared<Talker>());
  rclcpp::shutdown();
  return 0;
}

각 요소가 뜻하는 것:

  • rclcpp::init — 컨텍스트를 만들고 --ros-args 명령행 인자(리매핑·파라미터·로그 설정)를 파싱한다(1.1).
  • Node("talker") — 노드 이름. 실행 파일 이름과 무관하다. 그래프에 보이는 것은 이 이름이며, 런타임에 리매핑으로 바꿀 수 있다(1.1).
  • create_publisher<T>("chatter", 10) — 세 번째 인자 10은 "큐 깊이"가 아니라 QoS 프로파일의 축약형이다(기본 프로파일에 depth=10). QoS를 이해하지 못한 채 이 숫자만 키우는 것이 대표적 오해다(1.5).
  • create_wall_timer — wall clock 기반이다. 시뮬레이션 시간(use_sim_time)을 따르지 않는다. 시뮬에서 타이머가 어긋나는 원인 1위이며, 대안은 1.10에서 다룬다.
  • std::bind — 람다로 써도 된다([this]() { on_timer(); }). 취향 문제지만, 캡처 수명 문제를 피하려면 this의 생존 범위를 항상 의식해야 한다.
  • rclcpp::spin(node) — SingleThreadedExecutor를 만들어 콜백을 처리한다. 이 한 줄에 실행 모델 전체가 숨어 있다(2.2·2.3).
  • RCLCPP_INFO — 매크로다. printf 스타일 포맷을 쓰며, 로거는 노드 이름을 딴다(1.11).

함정: publisher_->publish(message)에 값(복사) 을 넘기고 있다. 대용량 메시지에서는 std::unique_ptr를 넘겨 intra-process 경로에서 복사를 없애는 편이 낫다(2.7). 작은 메시지에서는 차이가 없다.

0.6.6 Python 패키지 해부와 최소 노드

setup.py:

from setuptools import find_packages, setup

package_name = 'my_py_pkg'

setup(
    name=package_name,
    version='0.0.1',
    packages=find_packages(exclude=['test']),
    data_files=[
        ('share/ament_index/resource_index/packages',
         ['resource/' + package_name]),
        ('share/' + package_name, ['package.xml']),
        # launch·설정을 설치하려면 여기에 추가한다
        # ('share/' + package_name + '/launch', glob('launch/*.launch.py')),
    ],
    install_requires=['setuptools'],
    zip_safe=True,
    maintainer='name',
    maintainer_email='[email protected]',
    description='minimal example',
    license='Apache-2.0',
    tests_require=['pytest'],
    entry_points={
        'console_scripts': [
            'talker = my_py_pkg.talker:main',
        ],
    },
)
항목역할함정
data_files의 첫 항목ament index 마커 설치빠뜨리면 패키지가 인식되지 않는다
entry_points.console_scriptsros2 run my_py_pkg talker의 talker를 정의여기 이름과 노드 이름은 별개다
packages=find_packages(...)파이썬 모듈 탐색my_py_pkg/__init__.py가 있어야 패키지로 인식된다
launch·YAML 설치data_files에 명시해야 함CMake와 달리 자동이 아니다. 파일을 추가할 때마다 setup.py를 고쳐야 한다(4.3)

최소 노드:

# my_py_pkg/talker.py
import rclpy
from rclpy.node import Node
from std_msgs.msg import String


class Talker(Node):
    def __init__(self) -> None:
        super().__init__('talker')
        self.publisher = self.create_publisher(String, 'chatter', 10)
        self.timer = self.create_timer(0.5, self.on_timer)
        self.count = 0

    def on_timer(self) -> None:
        msg = String()
        msg.data = f'hello {self.count}'
        self.count += 1
        self.get_logger().info(f"publishing: '{msg.data}'")
        self.publisher.publish(msg)


def main(args=None) -> None:
    rclpy.init(args=args)
    node = Talker()
    try:
        rclpy.spin(node)
    except KeyboardInterrupt:
        pass
    finally:
        node.destroy_node()
        rclpy.try_shutdown()

C++과 다른 점:

  • create_timer는 ROS 시계 기반이다(use_sim_time을 따른다). C++의 create_wall_timer와 대응이 다르다 — 이 비대칭이 언어를 오가는 사람을 자주 넘어뜨린다(1.10).
  • 종료 처리: Ctrl+C로 KeyboardInterrupt가 발생하며, 이미 shutdown된 상태에서 rclpy.shutdown()을 호출하면 예외가 날 수 있어 try_shutdown()이 안전하다 (확인 필요 — 배포판별 예외 동작)(실행 확인, 143차 — Humble·Jazzy 양쪽에서 동일하게 재현: 이미 shutdown된 컨텍스트에 rclpy.shutdown()을 두 번째로 호출하면 RuntimeError: Context must be initialized before it can be shutdown이 발생하지만, rclpy.try_shutdown()은 몇 번을 불러도 예외 없이 조용히 넘어간다 — 배포판 간 차이는 없었다).
  • 타이머 콜백은 GIL을 획득한다. Python 노드에서 부하가 큰 계산을 콜백에 넣으면 같은 프로세스의 다른 콜백이 전부 막힌다(2.8).

0.6.7 빌드·실행·검사 한 사이클

cd ~/ros2_ws
colcon build --symlink-install --packages-select my_cpp_pkg my_py_pkg
source install/setup.bash          # 새 패키지이므로 재source 필요 (0.5.7)

ros2 run my_cpp_pkg talker         # 터미널 1
ros2 topic echo /chatter           # 터미널 2

각 단계에서 확인할 것:

colcon build 실패 시 log/latest_ build/ 패키지별 stderr 확인 source install/ setup.bash ros2 pkg prefix my_ cpp_pkg 경로가 맞는가 ros2 run 노드 이름이 그래프에 보이는가 ros2 node list ros2 topic echo 안 보이면 QoS·이름 확인 ros2 topic info -v / chatter
  • colcon build가 실패하면 콘솔 요약보다 log/latest_build/<패키지>/stderr.log 가 정확하다.
  • ros2 run에서 "executable not found"면 설치 경로(lib/<패키지>) 또는 entry_points를 의심한다.
  • ros2 topic echo에 아무것도 안 나오면 ① 토픽 이름(네임스페이스·리매핑, 1.1), ② QoS 호환(1.5) 순으로 본다. ros2 topic info -v /chatter가 발행자·구독자의 QoS를 함께 보여준다.

실행 확인(143차, Humble·Jazzy 양쪽). 이 절의 사이클(colcon build → source install/setup.bash → ros2 run → ros2 topic echo)을 이 장의 코드로 그대로 밟았다 — C++·Python 두 패키지 다 빌드→실행→ros2 topic echo --once로 data: hello N을 정상 수신했고, ros2 node list에도 /talker가 정확히 나타났다. 문서에 적힌 그대로 한 줄도 어긋나지 않았다. 목차 3.

0.6.8 C++인가 Python인가 — 판단 축

두 언어는 같은 의미론(rcl 공유)이지만 다른 실행 특성을 갖는다(0.1.7).

① C++(rclcpp)

  • 강점: 성능·메모리 통제, 실시간 친화 경로(무할당·loaned message, 2.6), 컴포넌트로 프로세스 내 결합·zero-copy(1.13·2.7), 멀티스레드 executor를 실질적으로 활용 가능.
  • 약점: 개발 속도, 빌드 시간, 진입 장벽.
  • 맞는 곳: 드라이버, 제어 루프, 대용량 인지 파이프라인, 항상 켜져 있는 코어 노드.

② Python(rclpy)

  • 강점: 개발 속도, 실험·프로토타이핑, 데이터 분석·ML 생태계 연결, launch·도구 스크립트.
  • 약점: GIL로 인한 병렬성 제약(2.8), 대용량 메시지의 직렬화·복사 비용, 지터가 크다, intra-process zero-copy 이점 제한.
  • 맞는 곳: 상위 상태기계·오케스트레이션, 실험 스크립트, 테스트 하네스, 저주기(≤10 Hz) 노드.

③ 혼합(현실적 기본값)

  • 코어는 C++, 상위 로직·도구는 Python. 인터페이스 패키지를 공유하면 두 언어가 자연스럽게 연동된다.
  • 대가: 팀이 두 툴체인을 다 관리해야 하고, 디버깅 방식이 이원화된다.

견해: 처음부터 "전부 C++"은 개발 속도를 크게 깎고, "전부 Python"은 나중에 성능 벽에 부딪힌다. 데이터 경로(센서→인지→제어)는 C++, 결정·조율 경로는 Python**이라는 분업이 대부분의 팀에서 잘 작동한다. 근거는 2.8(GIL)·7.8(대용량 파이프라인)·12.4(지연 예산)에서 수치적으로 다시 다룬다.

0.6.9 첫날부터 지킬 구조 원칙

나중에 되돌리기 비용이 큰 것들만 골랐다.

  1. 인터페이스는 별도 패키지. my_robot_interfaces 같은 이름으로 분리하고, 의존성을 최소로 유지한다(1.2).
  2. 로직은 순수 라이브러리, 노드는 얇게. 알고리즘 클래스는 ROS를 모르게 만들고, 노드는 "구독 → 로직 호출 → 발행"만 한다. 단위 테스트(14.5)와 컴포넌트화(1.13)가 추가 비용 없이 따라온다.
  3. main에 로직을 두지 않는다. 컴포넌트로 만들 수 있게 노드 클래스로 감싼다.
  4. 토픽 이름을 하드코딩하지 않는다. 상대 이름을 쓰고 launch에서 리매핑한다(1.1).
  5. QoS를 명시적으로 쓴다. 10 같은 숫자를 무심코 쓰지 말고, 의도한 프로파일을 코드에서 드러낸다(1.6).
  6. 린트·테스트를 처음부터 켠다. ament_lint_auto는 생성 시 기본 포함이며, 끄지 말고 유지하는 편이 낫다.

0.6.11 함정 카탈로그

첫 패키지에서 실제로 막히는 자리들이다. 대부분 오류 메시지가 원인을 직접 말해 주지 않는다.

#함정증상대처
1resource/<패키지명> 누락(Python)빌드는 되는데 Package not found빈 파일이지만 필수 — ament index의 마커다(0.6.6·0.5.5)
2빌드 후 재 source 안 함새 실행 파일이 안 보임install/setup.bash 다시(0.5.7)
3워크스페이스 루트가 아닌 곳에서 colcon build패키지를 못 찾거나 잘못된 곳에 빌드src/의 부모에서 실행(0.5.2)
4패키지 이름과 실행 파일 이름 혼동ros2 run 이 실패ros2 pkg executables <패키지> 로 확인
5package.xml 의존과 CMakeLists.txt 의존 불일치남의 머신에서만 빌드 실패둘 다 적는다(0.6.4·4.4)
6값 복사로 발행대용량에서 성능 손해std::unique_ptr 로 넘긴다(0.6.5·2.7.13.2)
7노드 이름과 파일 이름을 같게 두고 혼동리매핑·파라미터가 안 먹음노드 이름이 그래프의 이름이다(1.1)
8Python 패키지에 entry_points 누락ros2 run 이 실행 파일을 못 찾음setup.py 의 console_scripts 확인(0.6.6)

1번이 압도적으로 흔하다. 빌드가 성공했는데 패키지를 못 찾으면 거의 항상 ament 마커 문제다. ls install/<패키지>/share/ament_index/resource_index/packages/ 를 보면 1초에 끝난다.

실행 확인(143차)이 1번의 정확한 발동 조건을 좁혔다. resource/<패키지명>이 소스에서부터 없는 상태로 클린 빌드하면, 이 표의 서술("빌드는 되는데 못 찾음")과 달리 빌드 자체가 그 자리에서 실패한다(Jazzy 확인): error: can't copy 'resource/<패키지명>': doesn't exist or not a regular file(setup.py의 data_files가 그 경로를 참조하기 때문 — 못 찾을 이유가 명확히 출력된다). 표가 말하는 "빌드는 되는데 ros2 run만 실패"하는 정확한 상황은, 이미 성공적으로 빌드된 install/ 트리에서 마커 파일만(소스가 아니라) 나중에 사라졌을 때다 — 이 경우 재빌드 없이 ros2 run을 실행하면 정확히 Package '<이름>' not found로 실패한다(실제로 재현). 실무에서는 부분 복사·손상된 이미지 레이어·수동 삭제 등으로 이런 상태가 생길 수 있다. 목차 3.

0.6.10 체크포인트

  • 패키지의 유일한 필수 파일은 무엇이며, 빌드 타입은 어디서 결정되는가.
  • install(TARGETS … DESTINATION lib/${PROJECT_NAME})의 경로가 왜 그래야 하는지, 다른 경로에 설치하면 무엇이 깨지는지 설명할 수 있는가.
  • ament_package()가 마지막에 와야 하는 이유를 말할 수 있는가.
  • Python 패키지의 resource/<패키지명> 파일이 하는 일은? 빠뜨리면 어떤 증상이 나타나는가.
  • create_publisher(..., 10)의 10이 정확히 무엇인지 설명할 수 있는가.
  • C++의 create_wall_timer와 Python의 create_timer의 차이가 시뮬레이션에서 어떤 문제로 나타나는가.
  • 노드 이름·실행 파일 이름·패키지 이름 셋을 구분해 말할 수 있는가. ros2 node list에 나오는 것은 어느 것인가.
  • 지금 만들려는 시스템에서 "로직 라이브러리 / 노드 래퍼 / 인터페이스 패키지"를 어떻게 나눌지 종이에 그려 보라.

다음 장 0.7은 방금 쓴 ros2 run·ros2 topic 같은 CLI 자체를 해부한다 — 이 명령들이 내부적으로 무엇을 하고, 왜 가끔 거짓말을 하는지.