제4부 빌드·패키징·배포
4.3 ament_python과 엔트리포인트
4.3.1 이 장에서 배우는 것 / 선수 지식
Python 패키지는 C++보다 쉬울 것 같지만 함정은 다르다. 컴파일이 없으니 "저장하면 바로 반영"을 기대하는데, ROS 2는 설치 단계를 거치므로 그렇지 않다(4.1.8). 그리고 setup.py의 몇 줄을 빠뜨리면 패키지가 인식조차 안 된다.
이 장은 ament_python의 구조를 정확히 세우고, rclpy 개발에서 실제로 부딪히는 문제들을 다룬다.
배우는 것:
- 필수 파일 다섯 개 — 하나라도 빠지면 안 되는 것들과 각각의 역할.
- 엔트리포인트 —
ros2 run이 Python 노드를 실행하는 메커니즘. - 리소스 설치 — launch·config를
data_files로 넣는 법. --symlink-install— 무엇이 반영되고 무엇이 안 되는가.- 혼합 패키지 —
ament_cmake_python이 필요한 경우. - 테스트·린트 — pytest 기반 구성.
- 패키징 함정 — Python 특유의 실수들.
선수 지식: 4.1(ament 구조·설치 레이아웃), 2.8(rclpy), 0.6(첫 패키지).
정직: setuptools 생태계는
setup.py에서pyproject.toml중심으로 이행 중이며, ROS 2가 어느 시점에 어떻게 따라갈지는 배포판 의존이다(확인 필요). Jazzy(Ubuntu 24.04, Python 3.12) 기준으로setup.py방식을 서술한다. setuptools 버전에 따라 경고가 나올 수 있다. 이 환경에서 빌드 검증하지 않았다(목차 3). 145차가 4.3.5의--symlink-install표(재빌드 필요/불필요 여덟 항목)를 한 줄씩 실행 확인했다 — 대부분 맞았지만 "새 Python 파일 추가"는 서술과 반대로 재빌드 없이 즉시 반영됐다. 아래 4.3.5를 보라.
4.3.2 필수 파일 다섯 개
| 파일 | 역할 | 빠뜨리면 |
|---|---|---|
package.xml | ROS 의존·빌드 타입 선언 | 패키지로 인식 안 됨 |
setup.py | 설치 규칙·엔트리포인트 | 빌드 실패 |
setup.cfg | 실행 파일 설치 위치 지정 | ros2 run이 못 찾음 |
resource/<pkg> | ament 인덱스 마커(빈 파일) | ros2 pkg list에 안 나옴 |
<pkg>/__init__.py | Python 패키지 표시 | 임포트 실패 |
resource/<pkg> 빈 파일이 가장 자주 잊히는 것이다. 이것이 ament 인덱스에 설치되어 패키지 등록이 된다(4.1.3).
mkdir -p resource && touch resource/my_py_pkg
4.3.3 setup.py 해부
import os
from glob import glob
from setuptools import find_packages, setup
package_name = 'my_py_pkg'
setup(
name=package_name,
version='0.1.0',
packages=find_packages(exclude=['test']),
data_files=[
# ① ament 인덱스 등록 — 필수
('share/ament_index/resource_index/packages',
['resource/' + package_name]),
# ② package.xml 설치 — 필수
('share/' + package_name, ['package.xml']),
# ③ 런타임 리소스 — 필요한 만큼
(os.path.join('share', package_name, 'launch'),
glob(os.path.join('launch', '*launch.[pxy][yma]*'))),
(os.path.join('share', package_name, 'config'),
glob(os.path.join('config', '*.yaml'))),
],
install_requires=['setuptools'],
zip_safe=True,
maintainer='Your Name',
maintainer_email='[email protected]',
description='패키지 설명',
license='Apache-2.0',
tests_require=['pytest'],
entry_points={
'console_scripts': [
'talker = my_py_pkg.talker:main',
'listener = my_py_pkg.listener:main',
],
},
)
data_files의 세 항목이 각각 하는 일:
| 항목 | 결과 | 없으면 |
|---|---|---|
| ① 인덱스 마커 | share/ament_index/resource_index/packages/my_py_pkg | 패키지 미등록 |
② package.xml | share/my_py_pkg/package.xml | ros2 pkg xml 실패, 의존 정보 없음 |
| ③ 리소스 | share/my_py_pkg/launch/... | launch 파일을 런타임에 못 찾음(4.1.4) |
launch 파일 glob 패턴이 특이하다:
glob(os.path.join('launch', '*launch.[pxy][yma]*'))
이것은 *.launch.py·*.launch.xml·*.launch.yaml을 모두 검출하기 위한 관용구다. 단순하게 쓰려면:
(os.path.join('share', package_name, 'launch'), glob('launch/*.py')),
함정:
glob은 빌드 시점에 평가된다. launch 파일을 새로 추가하면 재빌드해야 설치된다.--symlink-install을 써도 마찬가지다(4.3.5).
4.3.4 엔트리포인트 — ros2 run이 실행하는 것
entry_points={
'console_scripts': [
'talker = my_py_pkg.talker:main',
],
},
해석: my_py_pkg/talker.py의 main 함수를 호출하는 talker라는 실행 스크립트를 생성한다.
setup.cfg가 그 위치를 정한다:
[develop]
script_dir=$base/lib/my_py_pkg
[install]
install_scripts=$base/lib/my_py_pkg
이 설정이 없으면 스크립트가 bin/에 설치되어 ros2 run이 못 찾는다(4.1.4의 C++ 사례와 같은 문제).
# 확인
ls install/my_py_pkg/lib/my_py_pkg/
ros2 pkg executables my_py_pkg
노드 코드의 표준 형태(2.8.8):
# my_py_pkg/talker.py
import rclpy
from rclpy.node import Node
from std_msgs.msg import String
class Talker(Node):
def __init__(self):
super().__init__('talker')
self.pub = self.create_publisher(String, 'chatter', 10)
self.timer = self.create_timer(1.0, self.on_timer) # 멤버로 보관 필수
self.count = 0
def on_timer(self):
msg = String()
msg.data = f'hello {self.count}'
self.pub.publish(msg)
self.count += 1
def main(args=None):
rclpy.init(args=args)
node = Talker()
try:
rclpy.spin(node)
except KeyboardInterrupt:
pass
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()
함정: 엔트리포인트를 추가·변경하면 반드시 재빌드해야 한다.
--symlink-install로도 반영되지 않는다 — 래퍼 스크립트가 빌드 시점에 생성되기 때문이다.
직접 실행 스크립트(엔트리포인트 대신):
# setup.py
scripts=['scripts/my_tool.py'],
또는 ament_cmake_python 패키지에서는 install(PROGRAMS ...)(4.2.9). 엔트리포인트 방식이 권장된다 — 임포트 경로가 정확하고 의존 관리가 명확하다.
4.3.5 --symlink-install — 무엇이 반영되고 무엇이 안 되나
colcon build --symlink-install
| 변경 | 재빌드 필요? |
|---|---|
Python 모듈 코드(.py) 수정 | ❌ 불필요 — 즉시 반영 |
| launch 파일 내용 수정 | ❌ 불필요 |
| config YAML 내용 수정 | ❌ 불필요 |
| 새 Python 파일 추가 | ✅ 필요(→ 145차 정정: 실제로는 불필요 — 아래 실행 확인 참고) |
| 새 launch·config 파일 추가 | ✅ 필요(glob 재평가) |
setup.py 변경 | ✅ 필요 |
| 엔트리포인트 추가·변경 | ✅ 필요 |
package.xml 변경 | ✅ 필요 |
"파일 내용은 즉시, 파일 목록·구조는 재빌드"로 기억하면 된다. (→ 145차 정정: 이 요약 문장도 "새 Python 파일"에는 안 맞는다 — 새 파일 추가인데도 즉시 반영됐다. 아래 참고.)
실행 확인(145차, Jazzy) — 표를 한 줄씩 재현했다.
--symlink-install로 빌드한 Python 패키지에서 각 변경을 재현했다.
- 모듈 코드·launch 내용·config 내용 수정(위 세 줄): 전부 확인됨 — 재빌드 없이
ros2 run·설치된 launch/config 파일 내용이 즉시 바뀌었다.- "새 Python 파일 추가" — 표와 반대로 재현됐다. 기존 패키지 디렉터리 안에
newmod.py를 새로 만들고 재빌드하지 않은 채python3 -c "from symtest_pkg.newmod import hello; print(hello())"를 실행하니 정상 임포트되고 실행됐다. 원인은ament_python이--symlink-install에서 패키지 전체를 setuptools의 "develop 모드"(.egg-link)로 설치하기 때문이다 —site-packages에 개별 파일 심링크가 아니라symtest-pkg.egg-link라는 한 줄로 된 텍스트 파일이 소스 디렉터리 자체를 가리키고 있어서, Python의 import가 그 디렉터리를 실시간으로 다시 스캔한다. 즉 "파일 목록"이라도 이미 임포트 가능한 패키지 디렉터리 안에 놓이는.py모듈이라면 재빌드가 필요 없다 — 이 표의 요약 규칙("파일 내용은 즉시, 파일 목록·구조는 재빌드")이 이 경우엔 반례다.- "새 launch·config 파일 추가" — 표대로 확인됨. 새
second.launch.py·second.yaml을 추가해도 재빌드 전에는 설치된share/<pkg>/launch·config/에 나타나지 않았다(data_files의glob()이setup.py실행 시점에 한 번 평가되기 때문). 재빌드하니 나타났다 — 새 파일이 패키지 소스 디렉터리 밖의 별도data_files항목일 때만 재평가가 필요하다는 뜻으로 좁혀진다.- 엔트리포인트 추가 — 표대로 확인됨.
setup.py에 새console_scripts항목을 추가해도 재빌드 전에는ros2 run이No executable found였고, 재빌드 후에야 동작했다 — 래퍼 스크립트 자체가 빌드 시점에 생성되는 별도 파일이기 때문으로 보인다.정리: "파일 내용 대 파일 목록"이 아니라 "이미 임포트 가능한 패키지 디렉터리 안이냐(즉시 반영), 아니면
data_files로 별도 등록해야 하는 대상이냐(재빌드 필요)"가 더 정확한 구분선이다. 목차 3,tools/gap_harness/gap145.
견해: rclpy 개발에서
--symlink-install은 사실상 필수다(2.8.8). 없으면 한 줄 고칠 때마다colcon build를 실행해야 해서 개발 속도가 크게 떨어진다. 팀의 개발 지침에 이 플래그를 기본으로 명시하는 편이 좋다.
함정: 심링크된 워크스페이스는 배포하면 안 된다(4.1.8). 심링크가 소스 디렉터리를 가리키므로, 소스가 없는 대상에서 깨진다. 배포본은
--symlink-install없이 빌드한다.
# 개발
colcon build --symlink-install
# 배포본
rm -rf build install && colcon build --merge-install
4.3.6 package.xml
<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd"
schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name>my_py_pkg</name>
<version>0.1.0</version>
<description>패키지 설명</description>
<maintainer email="[email protected]">Your Name</maintainer>
<license>Apache-2.0</license>
<exec_depend>rclpy</exec_depend>
<exec_depend>std_msgs</exec_depend>
<test_depend>ament_copyright</test_depend>
<test_depend>ament_flake8</test_depend>
<test_depend>ament_pep257</test_depend>
<test_depend>python3-pytest</test_depend>
<export>
<build_type>ament_python</build_type>
</export>
</package>
C++ 패키지와의 차이:
| 항목 | ament_cmake | ament_python |
|---|---|---|
| 의존 태그 | <depend>(빌드+실행) | <exec_depend>(실행만) |
<buildtool_depend> | ament_cmake | 대개 불필요(확인 필요) |
| 빌드 타입 | ament_cmake | ament_python |
Python은 컴파일이 없으므로 <exec_depend>가 맞다. <depend>를 써도 동작하지만 의미가 부정확하다(4.4).
함정:
package.xml의 버전과setup.py의version이 불일치하는 경우가 흔하다. 릴리스 도구(bloom — 4.9)가 혼란스러워하므로 맞춰야 한다. 자동 동기화 방법이 없으므로 릴리스 체크리스트에 넣는다.
4.3.7 혼합 패키지 — ament_cmake_python
C++ 노드와 Python 모듈을 한 패키지에 둘 때.
<export>
<build_type>ament_cmake</build_type>
</export>
find_package(ament_cmake REQUIRED)
find_package(ament_cmake_python REQUIRED)
find_package(rclcpp REQUIRED)
find_package(rclpy REQUIRED)
# C++ 부분
add_executable(cpp_node src/cpp_node.cpp)
target_link_libraries(cpp_node PRIVATE rclcpp::rclcpp)
install(TARGETS cpp_node DESTINATION lib/${PROJECT_NAME})
# Python 모듈 설치
ament_python_install_package(${PROJECT_NAME})
# Python 실행 스크립트
install(PROGRAMS
scripts/py_node.py
DESTINATION lib/${PROJECT_NAME}
)
ament_package()
| 경로 | 내용 |
|---|---|
my_mixed_pkg/CMakeLists.txt | 빌드 규칙(C++ + Python 설치) |
my_mixed_pkg/package.xml | build_type은 ament_cmake |
my_mixed_pkg/src/cpp_node.cpp | C++ 노드 |
my_mixed_pkg/scripts/py_node.py | 실행 스크립트 — shebang + 실행 권한 |
my_mixed_pkg/my_mixed_pkg/__init__.py | Python 모듈 표시 |
my_mixed_pkg/my_mixed_pkg/helper.py | Python 모듈 |
#!/usr/bin/env python3
# scripts/py_node.py — shebang 필수
import rclpy
...
함정:
install(PROGRAMS)를 써야 실행 권한이 유지된다(4.2.9).install(FILES)를 쓰면 실행이 안 된다.
견해: 혼합 패키지는 되도록 피하라(4.1.6). 두 빌드 체계의 함정을 모두 감수하고, 오류 메시지가 어느 쪽에서 왔는지 파악하기 어렵다. C++ 패키지와 Python 패키지를 나누는 편이 대개 낫다. 정말 필요한 경우는 "C++ 컴포넌트와 그것을 구성하는 Python 도구가 강하게 결합"된 상황 정도다.
4.3.8 테스트와 린트
테스트는 my_py_pkg/test/ 아래에 둔다 — 린터 테스트(test_copyright.py·test_flake8.py·test_pep257.py)와 실제 로직 테스트(test_my_logic.py)를 함께.
# test/test_flake8.py — 표준 형태
from ament_flake8.main import main_with_errors
import pytest
@pytest.mark.flake8
@pytest.mark.linter
def test_flake8():
rc, errors = main_with_errors(argv=[])
assert rc == 0, \
'Found %d code style errors / warnings:\n' % len(errors) + \
'\n'.join(errors)
# test/test_my_logic.py — 실제 로직 테스트
import pytest
from my_py_pkg.helper import compute_something
def test_compute():
assert compute_something(2) == 4
def test_invalid_input():
with pytest.raises(ValueError):
compute_something(-1)
colcon test --packages-select my_py_pkg
colcon test-result --verbose
# pytest 직접 실행 (빠른 반복)
python3 -m pytest test/test_my_logic.py -v
노드를 테스트하려면 rclpy 초기화가 필요하다.
import rclpy
import pytest
from my_py_pkg.talker import Talker
@pytest.fixture
def ros_context():
rclpy.init()
yield
rclpy.shutdown()
def test_talker_creates(ros_context):
node = Talker()
assert node.get_name() == 'talker'
node.destroy_node()
노드 간 통신을 테스트하려면 launch_testing을 쓴다(14.6).
견해: 로직을 노드에서 분리해 순수 함수로 두면 테스트가 훨씬 쉬워진다. 노드 클래스는 ROS 배선(구독·발행·타이머)만 담당하고, 계산은 별도 모듈에 두는 구조다. 이 분리는 테스트뿐 아니라 재사용·이해에도 유리하며, 14.1(노드 분할 설계)에서 다시 다룬다.
4.3.9 함정 카탈로그
| # | 함정 | 증상 | 대처 |
|---|---|---|---|
| 1 | resource/<pkg> 마커 누락 | ros2 pkg list에 없음 | 빈 파일 생성 + data_files 등록 |
| 2 | setup.cfg 누락·오타 | ros2 run이 못 찾음 | install_scripts 경로 확인 |
| 3 | launch·config를 data_files에 미등록 | 런타임에 파일 없음 | glob 추가 후 재빌드 |
| 4 | 새 파일 추가 후 재빌드 안 함 | 설치본에 없음 | glob 재평가 필요 |
| 5 | 엔트리포인트 변경 후 재빌드 안 함 | 옛 동작 유지 | 재빌드 |
| 6 | --symlink-install 미사용 | 개발 속도 저하 | 개발 시 기본 사용 |
| 7 | 심링크 워크스페이스 배포 | 대상에서 깨짐 | 배포본은 별도 빌드 |
| 8 | package.xml·setup.py 버전 불일치 | 릴리스 도구 혼란 | 릴리스 체크리스트 |
| 9 | 구독·타이머를 지역 변수로 | 조용히 동작 중단 | 멤버 보관(2.8.8) |
| 10 | install(FILES)로 스크립트 설치 | 실행 권한 없음 | install(PROGRAMS) |
| 11 | shebang 누락 | 스크립트 실행 실패 | #!/usr/bin/env python3 |
| 12 | <depend>를 Python 패키지에 사용 | 의미 부정확 | <exec_depend> |
| 13 | 혼합 패키지 남용 | 빌드 복잡·진단 어려움 | 패키지 분리 |
| 14 | 로직이 노드 클래스에 묶임 | 테스트 어려움 | 순수 함수로 분리 |
4.3.10 체크포인트
ament_python패키지의 필수 파일 다섯 개와 각각의 역할을 말할 수 있는가. 가장 자주 잊히는 것은?setup.py의data_files세 항목이 각각 무엇을 설치하며, 없으면 어떤 증상이 나타나는가.- 엔트리포인트 문법을 해석하고,
ros2 run이 실행하는 실제 파일이 어디에 생기는지 말할 수 있는가. setup.cfg가 없으면 무슨 일이 생기는가. C++ 쪽의 어떤 문제와 같은 성질인가.--symlink-install에서 재빌드가 필요한 변경과 불필요한 변경을 구분할 수 있는가. 기억하는 규칙은?- 심링크 워크스페이스를 배포하면 안 되는 이유는?
- Python 패키지에서
<depend>대신<exec_depend>를 쓰는 이유는? - 혼합 패키지를 만들 때
install(PROGRAMS)를 써야 하는 이유는? 혼합 패키지를 피하라는 조언의 근거는? - 노드 로직을 순수 함수로 분리하면 얻는 이점 세 가지를 들 수 있는가.
다음 장 4.4는 지금까지 계속 등장한 package.xml을 정면으로 다룬다 — format 3 스키마, 의존 태그 일곱 종류의 정확한 의미, 조건부 의존, 그리고 rosdep 키와의 관계.