제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> 태그로 결정된다.
패키지를 나누는 기준(첫날부터 중요) — 이 결정은 나중에 되돌리기 비용이 크다.
| 원칙 | 이유 |
|---|---|
인터페이스(.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.xml | package.xml | 메타데이터·의존성 선언 |
CMakeLists.txt | setup.py | 빌드·설치 규칙 |
| — | setup.cfg | 스크립트 설치 경로 지정 |
src/*.cpp | my_py_pkg/*.py | 소스 |
include/my_cpp_pkg/*.hpp | — | 공개 헤더 |
| — | resource/my_py_pkg | ament 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_scripts | ros2 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.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 첫날부터 지킬 구조 원칙
나중에 되돌리기 비용이 큰 것들만 골랐다.
- 인터페이스는 별도 패키지.
my_robot_interfaces같은 이름으로 분리하고, 의존성을 최소로 유지한다(1.2). - 로직은 순수 라이브러리, 노드는 얇게. 알고리즘 클래스는 ROS를 모르게 만들고, 노드는 "구독 → 로직 호출 → 발행"만 한다. 단위 테스트(14.5)와 컴포넌트화(1.13)가 추가 비용 없이 따라온다.
main에 로직을 두지 않는다. 컴포넌트로 만들 수 있게 노드 클래스로 감싼다.- 토픽 이름을 하드코딩하지 않는다. 상대 이름을 쓰고 launch에서 리매핑한다(1.1).
- QoS를 명시적으로 쓴다.
10같은 숫자를 무심코 쓰지 말고, 의도한 프로파일을 코드에서 드러낸다(1.6). - 린트·테스트를 처음부터 켠다.
ament_lint_auto는 생성 시 기본 포함이며, 끄지 말고 유지하는 편이 낫다.
0.6.11 함정 카탈로그
첫 패키지에서 실제로 막히는 자리들이다. 대부분 오류 메시지가 원인을 직접 말해 주지 않는다.
| # | 함정 | 증상 | 대처 |
|---|---|---|---|
| 1 | resource/<패키지명> 누락(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 <패키지> 로 확인 |
| 5 | package.xml 의존과 CMakeLists.txt 의존 불일치 | 남의 머신에서만 빌드 실패 | 둘 다 적는다(0.6.4·4.4) |
| 6 | 값 복사로 발행 | 대용량에서 성능 손해 | std::unique_ptr 로 넘긴다(0.6.5·2.7.13.2) |
| 7 | 노드 이름과 파일 이름을 같게 두고 혼동 | 리매핑·파라미터가 안 먹음 | 노드 이름이 그래프의 이름이다(1.1) |
| 8 | Python 패키지에 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 자체를 해부한다 — 이 명령들이 내부적으로 무엇을 하고, 왜 가끔 거짓말을 하는지.