AEGLOSLAB ROS 2 Jazzy 대전

제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 필수 파일 다섯 개

my_py_pkg/ package.xml ROS 메타데이터 setup.py 설치 규칙 · 엔트리포인트 setup.cfg 스크립트 설치 위치 resource/my_py_ pkg 빈 마커 파일 my_py_pkg/__init_ _.py Python 모듈
파일역할빠뜨리면
package.xmlROS 의존·빌드 타입 선언패키지로 인식 안 됨
setup.py설치 규칙·엔트리포인트빌드 실패
setup.cfg실행 파일 설치 위치 지정ros2 run이 못 찾음
resource/<pkg>ament 인덱스 마커(빈 파일)ros2 pkg list에 안 나옴
<pkg>/__init__.pyPython 패키지 표시임포트 실패

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.xmlshare/my_py_pkg/package.xmlros2 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라는 실행 스크립트를 생성한다.

ros2 run my_py_pkg talker lib/my_py_pkg/talker (setuptools가 생성한 래퍼) my_py_pkg.talker 모듈 임포트 main() 호출

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_cmakeament_python
의존 태그<depend>(빌드+실행)<exec_depend>(실행만)
<buildtool_depend>ament_cmake대개 불필요(확인 필요)
빌드 타입ament_cmakeament_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.xmlbuild_type은 ament_cmake
my_mixed_pkg/src/cpp_node.cppC++ 노드
my_mixed_pkg/scripts/py_node.py실행 스크립트 — shebang + 실행 권한
my_mixed_pkg/my_mixed_pkg/__init__.pyPython 모듈 표시
my_mixed_pkg/my_mixed_pkg/helper.pyPython 모듈
#!/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 함정 카탈로그

#함정증상대처
1resource/<pkg> 마커 누락ros2 pkg list에 없음빈 파일 생성 + data_files 등록
2setup.cfg 누락·오타ros2 run이 못 찾음install_scripts 경로 확인
3launch·config를 data_files에 미등록런타임에 파일 없음glob 추가 후 재빌드
4새 파일 추가 후 재빌드 안 함설치본에 없음glob 재평가 필요
5엔트리포인트 변경 후 재빌드 안 함옛 동작 유지재빌드
6--symlink-install 미사용개발 속도 저하개발 시 기본 사용
7심링크 워크스페이스 배포대상에서 깨짐배포본은 별도 빌드
8package.xml·setup.py 버전 불일치릴리스 도구 혼란릴리스 체크리스트
9구독·타이머를 지역 변수로조용히 동작 중단멤버 보관(2.8.8)
10install(FILES)로 스크립트 설치실행 권한 없음install(PROGRAMS)
11shebang 누락스크립트 실행 실패#!/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 키와의 관계.