AEGLOSLAB ROS 2 Jazzy 대전

제4부 빌드·패키징·배포

4.2 ament_cmake 심층

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

ament_cmake는 ROS 2 C++ 패키지의 뼈대다. 그런데 이 영역은 ROS 2 안에서 관행이 가장 활발하게 바뀌어 온 곳이기도 하다 — 초기의 ament_target_dependencies 중심 스타일에서 현대 CMake 타깃 기반 스타일로 이행 중이며, 인터넷의 예제 상당수가 옛 방식이다.

이 장은 두 가지를 함께 다룬다. ① 실제로 무슨 일이 일어나는가(의존 전파의 메커니즘), ② 지금 어떻게 쓰는 것이 맞는가.

배우는 것:

  • 의존 전파의 원리 — find_package가 무엇을 가져오는가.
  • 두 가지 스타일 — ament_target_dependencies vs 현대 CMake 타깃.
  • 라이브러리 내보내기 — 다른 패키지가 내 라이브러리를 쓰게 하는 법.
  • 심볼 가시성 — COMPOSITION_BUILDING_DLL의 정체.
  • pluginlib — 플러그인을 등록·발견하는 구조.
  • 테스트·린트 매크로 — ament_lint_auto·ament_add_gtest.
  • ament_cmake_auto — 보일러플레이트를 줄이는 선택지.

선수 지식: 4.1(ament 구조·설치 레이아웃), 0.6(첫 패키지), CMake 기본(타깃·find_package·target_link_libraries).

정직: ament_target_dependencies의 권장 여부는 배포판을 거치며 변해 왔다. Jazzy에서 여전히 동작하지만 현대 CMake 스타일이 권장되는 방향이며, 정확한 폐기(deprecation) 상태는 ament_cmake 문서와 릴리스 노트를 확인해야 한다(확인 필요). 아래 두 스타일을 모두 보이고 판단 근거를 제시한다. 이 환경에서 빌드 검증하지 않았다(목차 3). 145차가 ament_package() 위치 함정(4.2.10 6번)을 실행 확인해 서술을 정정했다 — 아래 4.2.10을 보라.

4.2.2 find_package가 실제로 하는 일

find_package(rclcpp REQUIRED)

이 한 줄이 무엇을 가져오는가.

find_package(rclcpp REQUIRED) CMAKE_PREFIX_PATH에서 rclcppConfig.cmake 탐색 share/rclcpp/cmake/ rclcppConfig.cmake 임포트 타깃 정의 rclcpp::rclcpp 구식 변수 정의 rclcpp_INCLUDE_ DIRS rclcpp_LIBRARIES 전이 의존 자동 로드 rcl · rmw · rcutils …
산출물설명
임포트 타깃(rclcpp::rclcpp)헤더 경로·라이브러리·컴파일 옵션을 함께 들고 다니는 객체
구식 변수(rclcpp_INCLUDE_DIRS 등)하위 호환용
전이 의존rclcpp가 의존하는 것들이 자동으로 함께 로드된다

임포트 타깃이 현대 CMake의 핵심 개념이다. 타깃에 링크하면 그 타깃이 요구하는 include 디렉터리·컴파일 정의·링크 라이브러리가 자동으로 전파된다. 변수를 수동으로 나열할 필요가 없다.

CMAKE_PREFIX_PATH가 탐색 경로다 — 워크스페이스를 소싱하면 설정된다(0.5·4.1.5).

echo $CMAKE_PREFIX_PATH | tr ':' '\n'

함정: find_package가 실패하면 대개 소싱 문제다. "패키지를 설치했는데 CMake가 못 찾는다"면 CMAKE_PREFIX_PATH를 먼저 본다. package.xml에 의존을 선언했어도 CMake는 그것을 읽지 않는다 — package.xml과 CMakeLists.txt에 각각 선언해야 한다(4.4).

4.2.3 두 가지 스타일

4.2.3.1 구식 — ament_target_dependencies

find_package(rclcpp REQUIRED)
find_package(sensor_msgs REQUIRED)

add_executable(my_node src/my_node.cpp)
ament_target_dependencies(my_node rclcpp sensor_msgs)

하는 일: 지정한 패키지들의 include·라이브러리·정의를 타깃에 붙인다. 편리하고 오랫동안 표준이었다.

문제점:

문제설명
PUBLIC/PRIVATE 구분 없음모든 의존이 사실상 공개로 전파되어, 내 라이브러리를 쓰는 쪽이 불필요한 의존을 부담한다
구식 변수 기반임포트 타깃의 이점을 온전히 못 쓴다
비ROS 라이브러리와 혼용 시 어색target_link_libraries와 섞어 쓰게 된다

4.2.3.2 현대 CMake — 타깃 링크

find_package(rclcpp REQUIRED)
find_package(sensor_msgs REQUIRED)

add_executable(my_node src/my_node.cpp)
target_link_libraries(my_node PRIVATE
  rclcpp::rclcpp
  ${sensor_msgs_TARGETS}          # 인터페이스 패키지는 형태가 다르다
)

이점:

  • PUBLIC·PRIVATE·INTERFACE로 전파 범위를 통제한다.
  • 비ROS 라이브러리(Eigen·OpenCV·Boost)와 일관되게 다룬다.
  • CMake 생태계의 표준 방식이라 학습·이전이 쉽다.

정직: 인터페이스 패키지(msg·srv)의 타깃 이름은 일반 라이브러리와 다르다. ${sensor_msgs_TARGETS} 같은 변수를 쓰거나, 자기 패키지의 타입은 rosidl_get_typesupport_target()으로 얻는다(1.2.5). 정확한 형태는 배포판에 따라 다를 수 있다(확인 필요) — find_package 후 사용 가능한 타깃을 확인하는 것이 확실하다.

전파 범위 결정:

키워드의미언제
PRIVATE이 타깃만 사용. 밖으로 전파 안 됨구현에만 쓰는 의존(기본으로 삼을 것)
PUBLIC이 타깃과 이것을 쓰는 쪽 모두헤더에 등장하는 의존
INTERFACE이것을 쓰는 쪽만헤더 전용 라이브러리

판단 기준은 단순하다: 내 공개 헤더에 그 타입이 나오면 PUBLIC, 아니면 PRIVATE.

// my_lib.hpp — 공개 헤더
#include "rclcpp/rclcpp.hpp"                 // → rclcpp는 PUBLIC
class MyLib { rclcpp::Node::SharedPtr node_; };

// my_lib.cpp — 구현
#include <opencv2/opencv.hpp>                // → OpenCV는 PRIVATE

견해: 새 코드는 현대 CMake 스타일로 쓰라. ament_target_dependencies는 당분간 동작하겠지만, PUBLIC/PRIVATE 구분이 없어 의존 그래프가 실제보다 부풀어 오른다 — 대규모 워크스페이스에서 빌드 시간과 재빌드 범위에 영향을 준다(4.5). 기존 코드를 급히 바꿀 필요는 없지만, 새로 쓰는 것은 새 방식으로.

4.2.4 라이브러리를 내보내기

실행 파일만 만드는 패키지는 간단하지만, 라이브러리를 제공하는 패키지는 규칙이 많다.

cmake_minimum_required(VERSION 3.8)
project(my_lib)

find_package(ament_cmake REQUIRED)
find_package(rclcpp REQUIRED)
find_package(sensor_msgs REQUIRED)

# 1. 라이브러리 생성
add_library(my_lib SHARED
  src/my_lib.cpp
)

# 2. include 경로 — 빌드 시와 설치 후가 다르다
target_include_directories(my_lib PUBLIC
  $<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/include>
  $<INSTALL_INTERFACE:include/${PROJECT_NAME}>
)

target_compile_features(my_lib PUBLIC cxx_std_17)

# 3. 의존 링크
target_link_libraries(my_lib PUBLIC
  rclcpp::rclcpp
  ${sensor_msgs_TARGETS}
)

# 4. 헤더 설치
install(DIRECTORY include/
  DESTINATION include/${PROJECT_NAME}
)

# 5. 라이브러리 설치 + 내보내기 세트 등록
install(TARGETS my_lib
  EXPORT export_my_lib
  ARCHIVE DESTINATION lib
  LIBRARY DESTINATION lib
  RUNTIME DESTINATION bin
)

# 6. 다른 패키지가 쓸 수 있게 내보내기
ament_export_targets(export_my_lib HAS_LIBRARY_TARGET)
ament_export_dependencies(rclcpp sensor_msgs)

ament_package()      # 반드시 마지막 (4.1.10)

각 단계가 왜 필요한가:

#없으면
2 (BUILD_INTERFACE/INSTALL_INTERFACE)설치 후 헤더 경로가 틀려 의존 패키지가 컴파일 실패
4헤더가 설치되지 않아 의존 패키지가 못 찾음
5 EXPORT내보내기 세트에 등록되지 않음
6 ament_export_targets다른 패키지의 find_package가 타깃을 못 봄
6 ament_export_dependencies의존의 의존이 전파되지 않아 링크 실패

ament_export_dependencies가 자주 누락된다. 내 라이브러리가 rclcpp를 PUBLIC으로 쓰면, 내 라이브러리를 쓰는 패키지도 rclcpp가 필요하다. 이것을 선언하지 않으면 그쪽에서 "왜 rclcpp를 못 찾지" 오류가 난다.

함정: $<BUILD_INTERFACE:...> / $<INSTALL_INTERFACE:...> 를 빠뜨리고 절대 경로를 쓰면, 빌드한 머신에서는 되고 설치본을 다른 곳에 옮기면 깨진다. 크로스 컴파일·배포에서 드러난다(4.8).

4.2.5 심볼 가시성

1.13.3에서 본 COMPOSITION_BUILDING_DLL의 정체다.

target_compile_definitions(my_component PRIVATE "MY_PKG_BUILDING_LIBRARY")
// visibility_control.hpp
#ifndef MY_PKG__VISIBILITY_CONTROL_HPP_
#define MY_PKG__VISIBILITY_CONTROL_HPP_

#if defined _WIN32 || defined __CYGWIN__
  #ifdef __GNUC__
    #define MY_PKG_EXPORT __attribute__ ((dllexport))
    #define MY_PKG_IMPORT __attribute__ ((dllimport))
  #else
    #define MY_PKG_EXPORT __declspec(dllexport)
    #define MY_PKG_IMPORT __declspec(dllimport)
  #endif
  #ifdef MY_PKG_BUILDING_LIBRARY
    #define MY_PKG_PUBLIC MY_PKG_EXPORT
  #else
    #define MY_PKG_PUBLIC MY_PKG_IMPORT
  #endif
#else
  #define MY_PKG_PUBLIC __attribute__ ((visibility("default")))
#endif

#endif

왜 필요한가:

플랫폼문제
Windows명시하지 않으면 심볼이 내보내지지 않는다 — dllexport 필수
Linux(gcc)기본은 모두 공개. -fvisibility=hidden을 쓰면 명시 필요

리눅스만 쓴다면 없어도 동작한다. 그래서 많은 프로젝트가 생략하는데, 크로스 플랫폼 배포나 심볼 가시성 최적화를 고려하면 필요하다.

견해: 심볼 가시성을 좁히는 것은 리눅스에서도 이득이 있다 — 라이브러리 로딩이 빨라지고, 심볼 충돌이 줄고, 바이너리가 작아진다. 다만 보일러플레이트가 늘어나므로, 공개 배포 라이브러리에는 넣고 내부 전용 패키지는 생략하는 절충이 흔하다.

4.2.6 pluginlib

Nav2 플래너(8.5)·ros2_control 컨트롤러(6.4)·RViz2 플러그인(11.4)이 모두 이 구조를 쓴다.

find_package(pluginlib REQUIRED)

add_library(my_plugins SHARED src/my_planner.cpp)
target_link_libraries(my_plugins PUBLIC nav2_core::nav2_core)

pluginlib_export_plugin_description_file(nav2_core plugins.xml)
<!-- plugins.xml -->
<library path="my_plugins">
  <class name="my_pkg/MyPlanner"
         type="my_pkg::MyPlanner"
         base_class_type="nav2_core::GlobalPlanner">
    <description>커스텀 전역 플래너</description>
  </class>
</library>
#include "pluginlib/class_list_macros.hpp"
PLUGINLIB_EXPORT_CLASS(my_pkg::MyPlanner, nav2_core::GlobalPlanner)

동작 원리: pluginlib_export_plugin_description_file이 ament 인덱스(4.1.3)에 등록하고, 런타임에 pluginlib가 그 인덱스를 읽어 dlopen한다.

# 등록된 플러그인 확인
ros2 pkg xml my_pkg | grep -A3 plugin
find /opt/ros/jazzy/share/ament_index/resource_index -name 'nav2_core' -exec cat {} \;

흔한 실패:

증상원인
플러그인을 못 찾음plugins.xml 미설치 또는 인덱스 미등록
dlopen 실패라이브러리 이름 불일치(<library path=>)
심볼 없음PLUGINLIB_EXPORT_CLASS 누락
기반 클래스 불일치base_class_type 오타

함정: <library path="my_plugins">의 이름은 lib 접두어와 .so 확장자를 뺀 것이다. libmy_plugins.so면 my_plugins. 이것을 틀리면 런타임에만 드러난다.

4.2.7 테스트와 린트

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

  find_package(ament_cmake_gtest REQUIRED)
  ament_add_gtest(test_my_lib test/test_my_lib.cpp)
  target_link_libraries(test_my_lib my_lib)

  # launch 통합 테스트 (14.6)
  find_package(launch_testing_ament_cmake REQUIRED)
  add_launch_test(test/test_integration.py)
endif()
<!-- package.xml -->
<test_depend>ament_lint_auto</test_depend>
<test_depend>ament_lint_common</test_depend>
<test_depend>ament_cmake_gtest</test_depend>

ament_lint_auto가 자동으로 실행하는 린터들:

린터검사
ament_cpplint·ament_uncrustifyC++ 스타일
ament_cppcheck정적 분석
ament_flake8·ament_pep257Python 스타일
ament_copyright라이선스 헤더
ament_xmllintXML 문법
ament_lint_cmakeCMake 스타일
colcon test --packages-select my_pkg
colcon test-result --verbose

견해: 린트를 초기에 켜라. 나중에 켜면 수백 개의 경고가 한꺼번에 대량 출력돼 결국 끄게 된다. 스타일에 이견이 있으면 특정 린터만 비활성화하는 편이 전부 끄는 것보다 낫다.

# 특정 린터만 제외
set(ament_cmake_uncrustify_FOUND TRUE)     # uncrustify 건너뛰기
ament_lint_auto_find_test_dependencies()

4.2.8 ament_cmake_auto

보일러플레이트를 줄이는 선택지다.

find_package(ament_cmake_auto REQUIRED)
ament_auto_find_build_dependencies()        # package.xml의 의존을 자동 find_package

ament_auto_add_library(my_lib SHARED src/my_lib.cpp)
ament_auto_add_executable(my_node src/my_node.cpp)

ament_auto_package(INSTALL_TO_SHARE launch config)
축수동ament_cmake_auto
코드 길이길다짧다
package.xml과 동기화수동(불일치 가능)자동
세밀한 제어완전제한적
PUBLIC/PRIVATE 구분가능어렵다
학습 곡선CMake 지식 필요낮다

견해: 소규모·단순 패키지에는 편리하고, 라이브러리를 내보내는 패키지에는 부족하다. 특히 PUBLIC/PRIVATE 구분(4.2.3)을 제대로 하려면 수동 방식이 필요하다. 팀 안에서 방식을 통일하는 것이 개별 선택보다 중요하다 — 두 스타일이 섞인 워크스페이스는 읽기 어렵다.

4.2.9 자주 쓰는 CMake 조각

컴파일 옵션:

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

# 최적화 (Release가 기본이 아니다)
if(NOT CMAKE_BUILD_TYPE)
  set(CMAKE_BUILD_TYPE Release)
endif()

함정: CMAKE_BUILD_TYPE을 지정하지 않으면 최적화가 꺼진 채 빌드된다. "왜 이렇게 느리지"의 원인이 이것인 경우가 있다.

colcon build --cmake-args -DCMAKE_BUILD_TYPE=Release

컴포넌트 등록(1.13.3):

find_package(rclcpp_components REQUIRED)
add_library(my_component SHARED src/my_component.cpp)
target_link_libraries(my_component PRIVATE rclcpp::rclcpp rclcpp_components::component)
rclcpp_components_register_nodes(my_component "my_pkg::MyComponent")

자기 패키지의 인터페이스 사용(1.2.5):

rosidl_generate_interfaces(${PROJECT_NAME} "msg/MyMsg.msg" DEPENDENCIES std_msgs)
rosidl_get_typesupport_target(cpp_typesupport ${PROJECT_NAME} rosidl_typesupport_cpp)
target_link_libraries(my_node PRIVATE "${cpp_typesupport}")

설치 묶음:

install(TARGETS my_node DESTINATION lib/${PROJECT_NAME})
install(DIRECTORY launch config urdf DESTINATION share/${PROJECT_NAME})
install(PROGRAMS scripts/helper.py DESTINATION lib/${PROJECT_NAME})

install(PROGRAMS ...)는 실행 권한을 유지한다 — Python 스크립트를 ros2 run으로 실행하려면 이것을 써야 한다(FILES는 권한을 잃는다).

4.2.10 함정 카탈로그

#함정증상대처
1package.xml에만 의존 선언CMake가 못 찾음find_package도 필요(4.4)
2ament_export_dependencies 누락의존 패키지에서 링크 실패공개 의존 선언
3헤더 설치 누락의존 패키지 컴파일 실패install(DIRECTORY include/)
4BUILD_INTERFACE/INSTALL_INTERFACE 누락설치본 이동 시 깨짐제너레이터 표현식 사용
5모든 의존을 PUBLIC의존 그래프 부풀림·재빌드 확대헤더 등장 여부로 판단
6ament_package() 위치 오류이후 규칙 무시마지막에(4.1.10)
7pluginlib <library path> 오타런타임 로드 실패lib·.so 제외한 이름
8PLUGINLIB_EXPORT_CLASS 누락심볼 없음매크로 추가
9CMAKE_BUILD_TYPE 미지정최적화 없이 빌드Release 지정
10install(FILES)로 스크립트 설치실행 권한 없음install(PROGRAMS)
11린트를 나중에 켬경고 폭증 → 포기초기부터
12두 스타일 혼용읽기 어려움팀 내 통일

6번, 145차 실행 확인. "이후 규칙 무시"는 매크로마다 다르다는 것이 밝혀졌다 — install(TARGETS ...)을 ament_package() 뒤에 둬도 실제로는 무시되지 않고 정상 빌드·실행됐지만(Humble·Jazzy 둘 다), ament_export_targets()·ament_export_dependencies()를 뒤에 두면 조용히 무시되는 대신 그 자리에서 명확한 CMake 오류(... must be called before ament_package())로 즉시 실패했다. "마지막에"라는 대처 자체는 여전히 옳지만, 증상 칸의 "이후 규칙 무시"는 시험한 두 경우 어느 쪽에도 정확히 들어맞지 않았다. 상세는 4.1.10을 보라.

4.2.11 체크포인트

  • find_package(rclcpp REQUIRED)가 가져오는 것 세 가지를 말할 수 있는가. 탐색 경로는 무엇이 정하는가.
  • ament_target_dependencies와 현대 CMake 스타일의 차이와, 후자를 권하는 이유를 설명할 수 있는가.
  • PUBLIC과 PRIVATE을 판단하는 기준을 한 문장으로 말할 수 있는가.
  • 라이브러리를 내보내는 여섯 단계를 나열하고, 각각이 없을 때의 증상을 댈 수 있는가.
  • ament_export_dependencies가 누락되면 무슨 일이 생기는가.
  • $<BUILD_INTERFACE:> / $<INSTALL_INTERFACE:>가 없으면 언제 문제가 드러나는가.
  • 심볼 가시성 매크로가 어느 플랫폼에서 필수이며, 리눅스에서도 쓰면 어떤 이득이 있는가.
  • pluginlib의 동작 원리를 ament 인덱스와 연결해 설명할 수 있는가. 흔한 실패 네 가지는?
  • ament_cmake_auto의 이점과 한계는? 언제 쓰지 않는 편이 나은가.
  • CMAKE_BUILD_TYPE을 지정하지 않으면 무슨 일이 생기는가.
  • install(FILES)와 install(PROGRAMS)의 차이가 왜 중요한가.

다음 장 4.3은 Python 쪽 — ament_python의 setup.py·엔트리포인트·리소스 설치, 그리고 --symlink-install이 왜 rclpy 개발에서 사실상 필수인가.