제4부 빌드·패키징·배포
4.10 CI와 품질 게이트
4.10.1 이 장에서 배우는 것 / 선수 지식
제4부 내내 같은 말이 반복됐다 — "클린 환경에서 검증하라." 의존 누락은 개발 머신에서 안 드러나고(4.4.3), 아키텍처 버그는 arm64에서만 나타나고(4.8.7), 경쟁 조건은 부하가 걸려야 보이고(2.9.6), 빌드팜 실패는 로컬에서 예방할 수 있다(4.9.6).
CI가 그 "클린 환경"이다. 이 장은 ROS 2 프로젝트의 CI를 어떻게 구성하고, 어떤 게이트를 세우며, 어디까지가 값을 하는지를 다룬다.
배우는 것:
- ROS 2 CI의 기본 구조 — 무엇을 어떤 순서로 검증하는가.
- GitHub Actions 구성 — 실제로 동작하는 워크플로.
- 린트 게이트 —
ament_lint를 CI에서. - 테스트 층위 — 단위·통합·HIL의 CI 적합성.
- 새니타이저 — ASAN·TSAN·UBSAN을 언제 실행하는가.
- 빌드 매트릭스 — 배포판 × RMW × 아키텍처, 그리고 그 비용.
- 캐시 전략 — CI 시간을 줄이는 법.
- 게이트 설계 — 무엇을 막고 무엇을 경고할 것인가.
- 커버리지 — 측정하는 법과, 그 숫자를 어디까지 믿을 것인가(4.10.13).
선수 지식: 제4부 전체(4.1~4.9), 2.9.6(TSAN), 4.2.7(린트 매크로). 테스트 작성 자체는 14.5·14.6에서 다룬다.
정직: CI 도구(GitHub Actions·
industrial_ci·action-ros-ci)는 버전이 계속 바뀐다. 아래 워크플로는 구조를 보이기 위한 것이며 액션 버전·옵션은 현재 문서로 확인해야 한다(확인 필요). 이 환경에서 실행 검증하지 않았다(목차 3).검증 상태(추가): 132번째 회차(153차 갭 점검)까지 이 장은 패키지 존재 확인 1건뿐이었다. 132번째 회차가 이 장의 가장 강조된 주장(
colcon test-result --verbose가 없으면 실패가 통과로 처리된다는 "★" 표시 문장)을 실제 gtest·린트 두 시나리오로 각각 재현해 (실행 확인) 표기를 본문에 추가했고, 커버리지 측정(4.10.13.1)도 처음으로 끝까지 실행해 확인했다 — 4.10.3·4.10.4·4.10.11·4.10.13을 보라. 133번째 회차가 새니타이저 세 종(4.10.6)을 실제 버그로 전부 실행 검증했고(ASAN·TSAN은 책의 설명과 일치, UBSAN은 기본 설정으로는 실패를 통과 처리한다는 중요한 정정을 찾았다 — → 정정),ROS_DOMAIN_ID격리(4.10.5)도 두 개의 동시 프로세스로 직접 확인했다. GitHub Actions 워크플로 자체(실제 GitHub 러너 필요)와 빌드 매트릭스·캐시 전략은 여전히 미검증이다.
4.10.2 무엇을 검증하는가
빠른 것부터 배치하는 것이 원칙이다. 린트 실패를 알리는 데 30분을 쓸 이유가 없다.
| 단계 | 소요 | 실행 빈도 |
|---|---|---|
| 1 린트 | 초~분 | 모든 커밋 |
| 2·3 의존·빌드 | 분~수십 분 | 모든 PR |
| 4 단위 테스트 | 분 | 모든 PR |
| 5 통합 테스트 | 수십 분 | PR·머지 |
| 6 arm64 | 수십 분~시간(에뮬레이션 — 4.8) | 머지·야간 |
| 7 새니타이저·커버리지 | 시간 | 야간·주간 |
견해: 모든 것을 모든 커밋에서 실행하려 하면 CI가 병목이 된다. 개발자가 결과를 30분 기다려야 하면 CI를 우회하기 시작한다. 빠른 게이트는 항상, 느린 게이트는 주기적으로가 실용적인 균형이다.
4.10.3 GitHub Actions 구성
기본 워크플로:
name: CI
on:
push:
branches: [main, jazzy]
pull_request:
schedule:
- cron: '0 2 * * *' # 야간 빌드
jobs:
lint:
runs-on: ubuntu-24.04
container: ros:jazzy-ros-base
steps:
- uses: actions/checkout@v4
- name: 린트 실행
run: |
. /opt/ros/jazzy/setup.sh
cd $GITHUB_WORKSPACE
# 린트만 빠르게
colcon build --packages-select-regex '.*' --cmake-args -DBUILD_TESTING=ON
colcon test --ctest-args -R '.*lint.*'
colcon test-result --verbose
build-and-test:
runs-on: ubuntu-24.04
container: ros:jazzy-ros-base
steps:
- uses: actions/checkout@v4
with:
path: src/my_repo
- name: 의존 설치
run: |
apt-get update
rosdep update
rosdep install --from-paths src -y --ignore-src
- name: 빌드
run: |
. /opt/ros/jazzy/setup.sh
colcon build --merge-install \
--cmake-args -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=ON
- name: 테스트
run: |
. /opt/ros/jazzy/setup.sh
colcon test --merge-install
colcon test-result --verbose # ★ 이것이 없으면 실패를 놓친다
- name: 테스트 결과 업로드
if: always()
uses: actions/upload-artifact@v4
with:
name: test-results
path: log/latest_test/
colcon test-result --verbose가 핵심이다(4.5.10). 이것을 빠뜨리면 테스트가 실패해도 CI가 통과한다 — 있으나 마나 한 CI가 되는 대표적 실수다.
실행 확인(153차, Jazzy) — 이 장에서 가장 강조된 주장(★)을 정확히 재현했다. 일부러 실패하는 gtest 하나(
EXPECT_EQ(1, 2))와 통과하는 gtest 하나를 가진 패키지를 만들어colcon build→colcon test(맨 것) →colcon test-result --verbose순서로 실행했다.BUILD_EXIT=0 COLCON_TEST_EXIT=0 ← 실패한 테스트가 있는데도 성공으로 보고 TEST_RESULT_EXIT=1 ← colcon test-result --verbose는 정확히 검출함
colcon test(맨 것) 자체의 종료 코드는 실패한 테스트가 섞여 있어도 0(성공)이었다 — CI 스텝이colcon test의 종료 코드만 보고 통과·실패를 판단한다면, 이 실패는 그대로 통과 처리된다.colcon test-result --verbose는 종료 코드 1로 정확히 실패를 보고했고, 어느 테스트가 왜 실패했는지("Expected equality of these values: 1 / 2")까지 상세히 출력했다. 같은 패턴을 린트 시나리오로도 독립 재현했다(4.10.4를 보라) —colcon test자체는 여기서도 0을 반환했고colcon test-result --verbose만 1을 반환하며 실패를 검출해냈다. 원시 로그는tools/gap_harness/gap153/logs/ci_colcon_test_bare.log·ci_colcon_test_result_verbose.log. 목차 3.
action-ros-ci 사용 (더 간결):
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: ros-tooling/[email protected]
with:
required-ros-distributions: jazzy
- uses: ros-tooling/[email protected]
with:
package-name: my_robot_driver
target-ros2-distro: jazzy
colcon-defaults: |
{
"build": { "mixin": ["release"] }
}
정직: 액션 버전(
@v0.7·@v0.3)은 예시이며 현재 버전을 확인해야 한다(확인 필요). 액션이 유지보수되고 있는지도 확인 대상이다.
industrial_ci 사용:
jobs:
industrial_ci:
strategy:
matrix:
env:
- {ROS_DISTRO: jazzy, ROS_REPO: main}
- {ROS_DISTRO: jazzy, ROS_REPO: testing}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ros-industrial/industrial_ci@master
env: ${{ matrix.env }}
industrial_ci는 ROS 1 시절부터 쓰인 성숙한 도구로, 의존 설치·빌드·테스트·린트를 한 번에 처리한다. ROS_REPO: testing은 아직 정식 배포되지 않은 패키지로도 시험한다 — 다음 동기화에서 깨질 것을 미리 검출한다.
4.10.4 린트 게이트
4.2.7에서 본 ament_lint를 CI에서 강제한다.
if(BUILD_TESTING)
find_package(ament_lint_auto REQUIRED)
ament_lint_auto_find_test_dependencies()
endif()
린터별 성격:
| 린터 | 검사 | 게이트로 적합? |
|---|---|---|
ament_copyright | 라이선스 헤더 | ✅ 명확 |
ament_flake8·ament_pep257 | Python 스타일 | ✅ |
ament_cpplint·ament_uncrustify | C++ 스타일 | ✅(합의 후) |
ament_lint_cmake | CMake 스타일 | ✅ |
ament_xmllint | XML 문법 | ✅ |
ament_cppcheck | 정적 분석 | ⚠️ 오탐 가능 |
견해: 스타일 린터는 팀 합의 후 켜고, 켰으면 게이트로 강제하라. "경고만 보고 무시"는 최악이다 — 경고가 쌓여 아무도 안 보게 되고, 실제 문제가 묻힌다. 켜거나 끄거나 둘 중 하나이며, 특정 규칙에 이견이 있으면 그 규칙만 비활성화한다.
# 특정 린터 제외 (4.2.7)
set(ament_cmake_uncrustify_FOUND TRUE)
ament_lint_auto_find_test_dependencies()
자동 수정 도구를 함께 제공하면 마찰이 줄어든다:
# 개발자용 사전 검사 스크립트
ament_uncrustify --reformat src/
ament_flake8 src/
ament_copyright --add-missing "My Company" apache2
pre-commit 훅으로 로컬에서 먼저 검출하면 CI 왕복이 줄어든다.
실행 확인(153차, Jazzy) — 위 린터 표의 세 항목(
ament_copyright·ament_cpplint·ament_uncrustify)이 실제 위반을 정확히 검출해내는지 확인했다. 라이선스 헤더가 없고(Copyright ...줄 없음) 스타일도 어긋난(if(x==1){...}형태).cpp파일 하나로 패키지를 만들어ament_lint_auto_find_test_dependencies()를 켜고colcon test를 실행했다. 세 린터 전부 실제로 걸렸다 —ament_copyright:"could not find copyright notice",ament_cpplint:legal/copyright(저작권 없음)와whitespace/operators(==앞뒤 공백 없음) 두 건,ament_uncrustify: 코드 스타일 diff까지 출력(int main(){...}→int main() {...}). 여기서도colcon test자체는 종료 코드 0이었고colcon test-result --verbose만 1을 반환했다 — 4.10.3의 ★ 주장이 린트 게이트에서도 그대로 성립함을 독립적으로 확인했다. 원시 로그는tools/gap_harness/gap153/logs/lint_test_result_verbose.log. 목차 3.
4.10.5 테스트 층위와 CI 적합성
| 층위 | 내용 | CI 적합성 | 관련 장 |
|---|---|---|---|
| 단위 테스트 | 순수 로직(gtest·pytest) | ✅ 최적 | 14.5 |
| 노드 테스트 | 노드 생성·콜백 동작 | ✅ 좋음 | 14.5 |
| 통합 테스트 | 여러 노드(launch_testing) | ⚠️ 느리고 불안정할 수 있음 | 14.6 |
| 시뮬레이션 테스트 | Gazebo 등 | ⚠️ 매우 느림·자원 필요 | 10.1 |
| HIL | 실제 하드웨어 | ❌ CI 부적합(전용 러너 필요) | 14.6 |
통합 테스트의 불안정성(flakiness)이 실무 문제다. 노드 여러 개가 기동되고 디스커버리를 기다리는 테스트는 CI 머신의 부하에 따라 간헐적으로 실패한다.
| 원인 | 대처 |
|---|---|
| 디스커버리 지연(3.3.3) | 명시적 wait_for_* + 넉넉한 타임아웃 |
| CI 머신 부하 | 병렬 테스트 수 제한 |
| 포트 충돌 | 테스트마다 다른 ROS_DOMAIN_ID |
| 좀비 프로세스 | 정리 로직·타임아웃 |
# 테스트 격리
env:
ROS_DOMAIN_ID: ${{ github.run_number }} # 실행마다 다른 도메인
ROS_AUTOMATIC_DISCOVERY_RANGE: LOCALHOST # 외부 격리 (3.3.7)
견해:
ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST를 CI에 설정하는 것을 강하게 권한다. 공유 CI 러너에서 여러 작업이 동시에 실행되면 서로의 노드를 발견해 테스트가 뒤섞인다. 이 한 줄이 재현 불가능한 CI 실패의 상당 부분을 없앤다.실행 확인(154차, Jazzy) —
ROS_DOMAIN_ID격리를 두 개의 동시 프로세스로 직접 확인했다. 같은 컨테이너 안에서 같은 토픽 이름(iso_test)으로 퍼블리셔 둘을 동시에 실행하되 서로 다른 도메인(ROS_DOMAIN_ID=20과=21)에 두고,ROS_DOMAIN_ID=20의 구독자로 6초간 관찰했다. 구독자는 31개 메시지를 받았고, 전부 도메인 20 퍼블리셔("A-dom20")에서 온 것이었다 — 도메인 21 퍼블리셔("B-dom21")의 메시지는 단 하나도 섞이지 않았다(수신 목록의 고유 발신자 집합이{"hello from A-dom20"}하나뿐임을 직접 확인).ROS_DOMAIN_ID가 한 호스트에서 동시에 실행되는 프로세스들을 실제로 격리한다는 이 절의 핵심 주장이 정확히 확인됐다 — 공유 CI 러너에서 여러 작업이 동시에 실행돼도 서로 다른ROS_DOMAIN_ID를 쓰면 뒤섞이지 않는다. 원시 데이터는tools/gap_harness/gap154/data/domain_isolation_listener_out.json.ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST(위 견해)까지 별도로 대조하지는 않았다(확인 필요)(→ 156차 정정: 별도로 실행 확인했다 — 바로 아래 참고). 목차 3.
실행 확인(156차, Jazzy) —
ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST가 실제로 다른 호스트의 발견을 막는지, 서로 다른 네트워크 네임스페이스를 가진 두 컨테이너로 직접 확인했다. 같은 브리지 네트워크(gap156net, 각각 IP172.30.0.2·172.30.0.3로 서로 다른 "호스트"에 해당)에 컨테이너 둘(gap156a·gap156b)을 기동하고, 같은ROS_DOMAIN_ID=42로 세 조건을 비교했다. 조건 A(기본값,ROS_AUTOMATIC_DISCOVERY_RANGE미설정) —gap156a의 퍼블리셔를gap156b의 구독자가 정상 수신했다(18/18개, ~3.6초). 즉 이 환경변수가 없으면 서로 다른 호스트끼리도 기본적으로 서로 발견한다. 조건 B(gap156a의 퍼블리셔에만ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST설정) —gap156b의 구독자는 8초 관찰 동안 단 한 개도 받지 못했다(0/0). 조건 C(대조군 — LOCALHOST를 설정한 채로 퍼블리셔·구독자를 둘 다gap156a안에 둠) — 같은 호스트 안에서는 정상 수신했다(29개, ~5.8초) — 이 설정이 발견을 완전히 무력화하는 것이 아니라 정확히 "다른 호스트"만 막는다는 것을 확인했다. 4.10.5의 견해("강하게 권한다")가 정량적으로 뒷받침됐다 — 133차가 확인한ROS_DOMAIN_ID격리(같은 호스트 안 프로세스 간)와 이 실측(다른 호스트 간)을 합치면, CI 러너 격리에 필요한 두 축이 모두 실측으로 닫혔다. 원시 데이터는tools/gap_harness/gap156/data/hostb_listener_default.json·hostb_listener_localhost.json·hosta_listener_local_control.json. 목차 3.
함정: 불안정한 테스트를 방치하면 CI 전체의 신뢰가 무너진다. "가끔 실패하니까 재실행"이 습관이 되면 실제 회귀도 그렇게 넘기게 된다. 불안정한 테스트는 고치거나 격리하거나 제거하라 — 셋 중 하나이며, 방치는 선택지가 아니다.
4.10.6 새니타이저
2.9.6에서 TSAN을 다뤘다. CI에서의 운용이다.
sanitizers:
runs-on: ubuntu-24.04
container: ros:jazzy-ros-base
strategy:
matrix:
sanitizer: [address, thread, undefined]
steps:
- uses: actions/checkout@v4
with: {path: src/my_repo}
- run: |
apt-get update && rosdep update
rosdep install --from-paths src -y --ignore-src
- run: |
. /opt/ros/jazzy/setup.sh
colcon build --cmake-args \
-DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_CXX_FLAGS="-fsanitize=${{ matrix.sanitizer }} -g -O1" \
-DCMAKE_EXE_LINKER_FLAGS="-fsanitize=${{ matrix.sanitizer }}"
colcon test
colcon test-result --verbose
| 새니타이저 | 검출하는 것 | 비용 |
|---|---|---|
| ASAN(address) | 메모리 오류·누수·버퍼 오버런 | 2~3배 느림 |
| TSAN(thread) | 경쟁 조건(2.9) | 5~15배 느림 |
| UBSAN(undefined) | 미정의 동작(정수 오버플로 등) | 상대적으로 경량 |
견해: 멀티스레드 executor를 쓰는 패키지에는 TSAN을 야간 CI로 두라(2.3.11·2.9.6). 경쟁 조건은 테스트에서 통과하고 현장에서 드러나며, 로봇에서는 그 한 번이 물리적 사고가 될 수 있다. 비용이 크므로 매 PR이 아니라 야간에 실행한다.
함정: 새니타이저는 서드파티 라이브러리에서 오탐을 낼 수 있다. 억제 파일(suppression)로 알려진 오탐을 걸러야 실용적이다. 그렇게 하지 않으면 노이즈에 묻혀 실제 문제를 못 본다.
export TSAN_OPTIONS="suppressions=$PWD/.tsan-suppressions"
export ASAN_OPTIONS="detect_leaks=1:suppressions=$PWD/.asan-suppressions"
실행 확인(154차, Jazzy) — 세 새니타이저 각각에 정확히 맞는 실제 버그(힙 use-after-free·데이터 경쟁·부호있는 정수 오버플로)를 심어 위
matrix.sanitizer플래그 그대로 빌드·테스트했다. 결과는 셋이 서로 다르게 갈렸다.ASAN — 책의 설명과 정확히 일치했다.
-fsanitize=address -g -O1로 빌드하니 힙 use-after-free를 즉시 검출해, 할당·해제·읽기 세 지점의 스택 트레이스를 전부 포함한 상세 보고서를 내고 프로세스가 abort됐다(==NNNN==ABORTING).colcon test-result --verbose가 정확히 실패로 보고했다.TSAN — 진단은 책과 일치했지만, 이 환경에서 처음엔 전혀 실행되지 않았다(→ 정정 대상 발견).
-fsanitize=thread -g -O1로 빌드한 뒤 처음 실행하니 데이터 경쟁이 아니라FATAL: ThreadSanitizer: unexpected memory mapping ...(종료 코드 66)로 테스트 자체가 시작도 못 하고 종료됐다. 원인은 TSAN이 고정된 섀도 메모리 레이아웃을 요구하는데, 이 컨테이너(Ubuntu 24.04 커널)의 기본 ASLR 엔트로피(vm.mmap_rnd_bits=32)가 TSAN이 기대하는 범위보다 넓었기 때문이다 — Docker·최신 커널에서 TSAN이 흔히 겪는, 문서화된 호환성 문제다.sysctl vm.mmap_rnd_bits=28(이 컨테이너가--privileged라CAP_SYS_ADMIN을 갖고 있어 성공)로 낮추니 정확히 경쟁을 검출했다 —WARNING: ThreadSanitizer: data race, 두 스레드의 읽기·쓰기 스택 트레이스와 경쟁이 일어난 전역 변수 이름(shared_counter)까지 정확히 보고했고,colcon test-result --verbose도 실패로 정확히 검출했다. 이 절은 TSAN이 일부 컨테이너·커널 조합에서 전혀 기동되지 못할 수 있다는 것을 언급하지 않는다 — 새니타이저 CI 잡을 처음 켤 때 "경쟁이 없다"가 아니라 "새니타이저가 종료됐다"일 수 있음을 확인해야 한다.UBSAN — 실제 정정이 나왔다: 기본 설정으로는 실패를 통과 처리한다. 이 절의 예시 그대로(
-fsanitize=undefined -g -O1,-fno-sanitize-recover없음) 빌드하니, 부호있는 정수 오버플로를 정확히 진단은 했다(runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int') — 그러나 프로세스가 멈추지 않고 계속 실행돼[ OK ]·[ PASSED ]로 끝났고 종료 코드는 0이었다.colcon test-result --verbose는 이 경우 실패를 전혀 보고하지 않는다 — 4.10.3의 ★ 경고("colcon test-result가 없으면 실패를 놓친다")와 같은 부류의 함정이 UBSAN 자체의 기본 동작에도 있는 셈이다.-fno-sanitize-recover=undefined를 CXX_FLAGS·LINKER_FLAGS에 추가해 다시 빌드하니, 같은 진단 메시지를 낸 직후 프로세스가 종료 코드 1로 즉시 중단됐다([ OK ]조차 못 출력함) — 이렇게 해야 CI 게이트가 실제로 막는다. 이 절의 매트릭스 예시(-fsanitize=${{ matrix.sanitizer }}를 세 새니타이저에 동일하게 적용)는 UBSAN에 한해 불완전하다 —undefined일 때만-fno-sanitize-recover=undefined를 추가해야 진단이 실제 CI 실패로 이어진다.원시 로그는
tools/gap_harness/gap154/logs/asan_test_result.log·tsan_test_result_FAILED_env.log·tsan_test_result_after_fix.log·ubsan_test_result_default.log·ubsan_with_no_recover_build.log, 요약은tools/gap_harness/gap154/data/findings_summary.txt. 목차 3.
4.10.7 빌드 매트릭스와 그 비용
strategy:
fail-fast: false
matrix:
ros_distro: [jazzy, rolling]
rmw: [rmw_fastrtps_cpp, rmw_cyclonedds_cpp]
arch: [amd64, arm64]
2 × 2 × 2 = 8개 작업. 각각 수십 분이면 CI 자원이 빠르게 소모된다.
차원별 가치 판단:
| 차원 | 언제 필요 | 비용 |
|---|---|---|
| 배포판 | 여러 배포판 지원 시 | 중간 |
| RMW | 벤더 중립을 표방할 때(3.5.4) | 중간 |
| 아키텍처 | arm64 배포 시 | 높음(에뮬레이션 — 4.8) |
| 빌드 타입 | Release/Debug 차이 검증 | 낮음 |
견해: 매트릭스는 필요한 차원만 켜라. 단일 배포판·단일 RMW·단일 아키텍처로 배포한다면 매트릭스가 필요 없다. arm64가 가장 비용이 크므로, 매 PR이 아니라 머지·야간으로 미루는 것이 실용적이다.
arm64-build:
if: github.event_name != 'pull_request' # PR에서는 생략
runs-on: ubuntu-24.04
steps:
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- run: docker buildx build --platform linux/arm64 -t test:arm64 .
rolling을 매트릭스에 넣는 것의 가치: 다음 배포판에서 깨질 것을 미리 안다(15.2). 다만 Rolling은 자주 바뀌므로 실패해도 머지를 막지 않는 경고 게이트로 두는 편이 좋다.
rolling-check:
continue-on-error: true # 실패해도 CI 전체는 통과
4.10.8 캐시 전략
CI 시간의 대부분은 의존 설치와 컴파일이다.
① 의존 설치 이미지(4.6.6·4.8.5)
jobs:
build:
container: registry.example.com/my_robot_deps:jazzy # 의존이 이미 설치됨
주기적으로(주 1회·의존 변경 시) 이 이미지를 갱신한다. 가장 효과가 크다.
② ccache
- uses: actions/cache@v4
with:
path: ~/.ccache
key: ccache-${{ matrix.ros_distro }}-${{ github.sha }}
restore-keys: ccache-${{ matrix.ros_distro }}-
- run: |
export PATH="/usr/lib/ccache:$PATH"
colcon build --mixin release ccache
- run: ccache -s # 적중률 확인
ccache -s로 적중률을 확인하는 것이 중요하다. 캐시 키가 잘못되면 매번 미스가 나는데, 그것을 모르고 캐시를 쓴다고 믿게 된다.
③ colcon 빌드 디렉터리 캐시
- uses: actions/cache@v4
with:
path: |
build
install
key: colcon-${{ github.sha }}
restore-keys: colcon-
함정: 빌드 디렉터리 캐시는 위험할 수 있다. 옛 산출물이 남아 클린 환경 검증이라는 CI의 목적을 훼손한다(4.5.11). 최소한 머지 후 빌드나 야간 빌드는 캐시 없이 실행해 실제 클린 빌드를 확인해야 한다.
견해: ①이 안전하고 효과적이다. 의존은 자주 안 바뀌므로 이미지로 고정하고, 소스 빌드는 매번 클린 빌드로 한다. 이러면 속도와 정확성을 모두 얻는다.
실행 확인(156차, Jazzy) — ②(ccache 적중률)와 ③의 함정(빌드 디렉터리 캐시의 옛 산출물)을 각각 실제로 재현했다.
rclcpp노드 3개(node1~node3)로 된 최소 패키지에ccache 4.9.1을 설치하고 이 절의 명령 그대로(export PATH="/usr/lib/ccache:$PATH",colcon build --mixin release ccache) 실행했다.② ccache — 냉간 빌드는 0% 적중,
build/install을 지우고 다시 빌드해도(캐시 자체는 유지) 100% 적중했다. 첫 빌드(ccache -z직후)는Cacheable calls: 5/18, Hits: 0/5(0.00%)(전부 미스, 당연히 실제 컴파일)였고 벽시계 시간은real 0m3.357s였다.build·install·log전체를 지우고(CI의 "클린 체크아웃, 캐시만 복원" 상황을 흉내)ccache -z로 통계를 0으로 리셋한 뒤 다시 빌드하니Hits: 5/5(100.0%)로 완전 적중했고 벽시계 시간은real 0m1.209s(2.8배 단축, 이 패키지가 워낙 작아 절대 시간은 작지만 비율은 뚜렷했다).ccache -s가 적중률을 정확히 보고한다는 서술이 확인됐다.③ — 함정 서술이 실제로, 그것도 정확히 "동작하는 유령 실행 파일"의 형태로 재현됐다.
node3를 소스(node3.cpp삭제)와CMakeLists.txt(add_executable·install(TARGETS...)에서 제거) 양쪽에서 완전히 없앤 뒤,build/install을 지우지 않고(actions/cache@v4로 복원한 상황과 동일)colcon build를 다시 실행했다. 빌드는node3를 전혀 언급하지 않고 성공했지만(Finished <<< ccache_test_pkg [0.51s]),install/ccache_test_pkg/lib/ccache_test_pkg/에node3실행 파일이 그대로 남아 있었다 —node1·node2와는 다른 고유 md5(0ccbe587...)를 가진, 실제 예전 빌드의 산출물이었다. 더 중요한 것은 이 유령 파일이 깨진 잔해가 아니라 완전히 정상 동작하는 노드라는 점이다 —node3 --ros-args -r __node:=node3_stale_check를 실행하면[INFO] ... node3 running, marker=INITIAL을 출력하고 정상 종료한다. 소스 트리에는 흔적조차 없는 컴포넌트를, 캐시를 복원한 CI 파이프라인은 여전히 시험·배포할 수 있다는 뜻이다 — 아무 경고도 오류도 없이. 4.5.11이 가리키는 "클린 환경 검증 훼손"이 추상적 우려가 아니라 구체적으로 이런 모습임을 확인했다. 원시 로그는tools/gap_harness/gap156/logs/ccache_build1.log·ccache_build2.log·ccache_build3_isolated.log·ccache_stats_*.log·stale_node3_evidence.log. 목차 3.
4.10.9 게이트 설계
무엇을 "막고" 무엇을 "경고"할 것인가.
| 검사 | 게이트 수준 | 근거 |
|---|---|---|
| 빌드 실패 | 🔴 차단 | 명백 |
| 단위 테스트 실패 | 🔴 차단 | 회귀 |
| 린트 위반 | 🔴 차단 | 일관성(합의 후) |
의존 선언 누락(rosdep check) | 🔴 차단 | 빌드팜 실패 예방(4.9.6) |
| 통합 테스트 실패 | 🟡 차단(안정적이면) | 불안정하면 경고 |
| 커버리지 하락 | 🟡 경고 | 수치 강제는 역효과 가능 |
| arm64 빌드 실패 | 🟡 머지 후 차단 | PR 지연 방지 |
| Rolling 빌드 실패 | 🟢 경고 | 상류 변동 |
| 새니타이저 발견 | 🟡 야간 → 이슈 생성 | 비용 |
# 필수 검사 지정 (GitHub 브랜치 보호 규칙)
# Settings → Branches → Require status checks:
# - lint
# - build-and-test
견해: 게이트는 적고 확실한 것이 많고 흐릿한 것보다 낫다. 게이트가 자주 잘못 실패하면 사람들은 우회 방법을 찾는다(
--no-verify·재실행 반복·관리자 병합). 차단 게이트는 신뢰할 수 있는 것만 두고, 나머지는 경고로 두어 정보를 제공하는 편이 지속 가능하다.
릴리스 전 추가 게이트(4.9.8):
pre-release:
if: startsWith(github.ref, 'refs/tags/')
steps:
- name: 깨끗한 환경 전체 검증
run: |
docker run --rm -v $PWD:/ws -w /ws ros:jazzy bash -c '
apt-get update &&
rosdep install --from-paths src -y --ignore-src &&
colcon build --merge-install --mixin release &&
colcon test --merge-install &&
colcon test-result --verbose'
- name: arm64 빌드
run: docker buildx build --platform linux/arm64 .
4.10.13 커버리지 — 측정하는 법과, 숫자를 믿지 않는 법
번호에 대하여: 이 절은 나중에 추가되었다. 규약(CLD-03)에 따라 번호는 끝에 붙이고, 읽는 흐름을 위해 마무리 절 앞에 놓았다.
4.10.9의 게이트 표에 "커버리지 하락"이 들어 있고, REP 2004의 품질 수준도 커버리지를 요구한다(14.7.2). 그런데 그 값을 어떻게 만드나.
4.10.13.1 C++ — 계측 빌드 후 수집
컴파일러 계측을 켜고 빌드한 뒤, 시험을 실행하고, 결과를 모은다.
colcon build --packages-select my_pkg \
--cmake-args -DCMAKE_BUILD_TYPE=Debug \
-DCMAKE_CXX_FLAGS="--coverage -O0 -g" \
-DCMAKE_EXE_LINKER_FLAGS="--coverage"
colcon test --packages-select my_pkg
colcon lcov-result --packages-select my_pkg # colcon-lcov-result 확장
Python 패키지는 다른 확장을 쓴다.
colcon test --packages-select my_py_pkg --pytest-with-coverage
colcon coveragepy-result --packages-select my_py_pkg
| 도구 | 대상 | 비고 |
|---|---|---|
--coverage(gcov) + lcov | C++ | 컴파일러 계측. 최적화를 끄고 빌드해야 줄 대응이 맞는다 |
python3-colcon-lcov-result | C++ 결과 집계 | colcon 확장 |
python3-colcon-coveragepy-result | Python 결과 집계 | colcon 확장 |
실행 확인(2026-08-16): Jazzy 컨테이너의 apt 인덱스에
python3-colcon-lcov-result(0.5.3)·python3-colcon-coveragepy-result·lcov(2.0)가 실재한다. 확인한 것은 패키지 존재까지이며, 위 명령을 실제 워크스페이스에서 실행해 보지는 않았다(확인 필요: 옵션 이름은 확장 버전에 따라 다를 수 있다).실행 확인(153차) — 위
(확인 필요)를 이어받아 실제로 끝까지 실행했다.lcov2.0의 옵션 이름 문제가 정말로 있었다(→ 정정).add()(테스트함)와subtract_never_tested()(테스트 안 함) 두 함수를 가진 패키지를--coverage -O0 -g플래그로 빌드하고colcon test후colcon lcov-result를 그대로(옵션 없이) 실행하니 실패했다(LCOV_EXIT=1) —geninfo: ERROR: mismatched end line ...(use "geninfo --ignore-errors mismatch ..." to bypass this error). 이 컨테이너의lcov2.0이 GCC 13의 계측 출력에 대해 더 엄격한 검증을 하면서 생기는, 이 절이 언급하지 않는 실제 버전 호환성 문제였다.colcon lcov-result --lcov-args --ignore-errors mismatch로 우회하니 정상 완료됐다(LCOV_EXIT=0) — 결과coverage.info를 직접 열어 보니FNDA:1,...3addEii(테스트한add는 1회 호출로 기록)와FNDA:0,...21subtract_never_testedEii(테스트 안 한 함수는 0회)로 정확히 구분됐다 — 4.10.13.2의 "안 지나간 곳을 읽는다"는 조언이 실제 데이터로 확인됐다. 원시 데이터는tools/gap_harness/gap153/data/coverage.info, 원시 로그는tools/gap_harness/gap153/logs/coverage_lcov_result_FAILED.log·coverage_lcov_result_with_workaround.log. 목차 3.
함정: 커버리지 빌드는 별도 빌드다. 계측 플래그로 빌드한 산출물은 느리고, 그 상태로 성능을 측정하면 안 된다(11.7.8). 새니타이저와 같은 취급 — 전용 잡(job)으로 분리하라.
함정: 커버리지 측정은 시험을 느리게 만든다. 매 PR에 넣으면 피드백 시간이 늘어난다. 4.10.2의 계층 원칙대로 야간 잡이 대개 맞는 자리다.
4.10.13.2 숫자를 어떻게 쓸 것인가
견해: 커버리지는 "낮으면 불리하다"는 알려 주지만 "높으면 좋다"는 알려 주지 않는다. 20%면 시험이 부족한 것이 거의 확실하다. 그러나 90%가 품질을 보증하지는 않는다 — 모든 줄을 지나가되 아무것도 단언하지 않는 시험을 쓰기는 쉽다. 그래서 이 책은 절대 수치 목표를 권하지 않는다. 임계를 강제하면 사람들은 임계를 만족시키는 시험을 쓰게 되고, 그것이 곧 목적의 전치다.
쓸모 있는 사용법은 세 가지다.
| 사용법 | 이유 |
|---|---|
| 추세를 본다 | "이번 PR에서 커버리지가 떨어졌다"는 새 코드에 시험이 없다는 신호다 — 절대값보다 의미가 크다 |
| 안 지나간 곳을 읽는다 | 숫자가 아니라 리포트의 빨간 줄이 정보다. 오류 처리 경로·복구 분기 전체가 미실행이면(8.7) 그것이 발견이다 |
| 위험한 코드에 집중한다 | 하드웨어 인터페이스(6.2)·안전 관련 로직·상태기계(1.12)를 우선 채운다. 전체 평균을 높이는 것은 목표가 아니다 |
견해: 로봇 소프트웨어에서 커버리지의 한계는 특히 크다. 실제 위험은 대개 타이밍·동시성·하드웨어 상호작용에 있는데(2.9·6.6), 이것들은 줄 커버리지에 집계되지 않는다. 커버리지 100%인 코드가 경쟁 조건으로 종료될 수 있다. 그래서 이 책은 커버리지보다 시험 층위의 균형(14.5.2)과 HIL·시뮬 회귀(14.6)를 앞에 둔다.
게이트로 쓸 때: 4.10.9의 방침대로 경고(🟡)까지가 적당하다. 커버리지 하락으로 병합을 막으면, 급한 수정에서 형식적 시험이 추가되는 부작용이 온다.
4.10.10 제4부를 마치며
이 장으로 제4부가 끝난다. 열 장을 하나의 흐름으로 묶으면:
제4부가 반복한 세 가지:
- 클린 환경에서 검증하라. 개발 머신은 이미 오염되어 있어 의존 누락·설치 규칙 오류를 숨긴다(4.1.4·4.4.3·4.9.6).
- 선언은 여러 곳에 필요하다.
package.xml과CMakeLists.txt,setup.py와package.xml의 버전 — 자동 동기화되지 않는 쌍들이 있고, 어긋나면 조용히 실패한다(4.2.2·4.3.6). - 재현성을 아티팩트로 남겨라. 소스 잠금 파일·이미지 태그·빌드 옵션을 릴리스와 함께 보관해야 6개월 뒤에 같은 것을 만들 수 있다(4.7.8·4.9.8).
4.10.11 함정 카탈로그
| # | 함정 | 증상 | 대처 |
|---|---|---|---|
| 1 | colcon test-result 생략 | 실패를 통과로 처리 | 반드시 포함(4.5.10) |
| 2 | 도메인 격리 없이 CI | 작업 간 간섭·간헐 실패 | ROS_DOMAIN_ID·LOCALHOST |
| 3 | 불안정한 테스트 방치 | CI 신뢰 붕괴 | 고치거나 격리하거나 제거 |
| 4 | 모든 검사를 모든 커밋에 | CI 병목·우회 | 빠른 것만 항상 |
| 5 | 린트를 경고로만 | 위반 누적 | 켜거나 끄거나 |
| 6 | 매트릭스 남용 | 자원 소모 | 필요한 차원만 |
| 7 | 빌드 캐시로 클린 빌드 상실 | 깨진 것을 못 잡음 | 주기적 무캐시 빌드 |
| 8 | ccache 적중률 미확인 | 캐시가 사실상 무효 | ccache -s |
| 9 | 새니타이저 오탐 방치 | 노이즈에 묻힘 | 억제 파일 |
| 10 | 차단 게이트가 자주 오작동 | 우회 습관 형성 | 신뢰 가능한 것만 차단 |
| 11 | arm64를 매 PR에 | PR 지연 | 머지·야간으로 |
| 12 | 릴리스 전 전체 검증 생략 | 빌드팜 실패 | 태그 게이트(4.10.9) |
| 13 | UBSAN을 -fno-sanitize-recover 없이 CI에 넣음(154차 실행 확인) | 진단은 출력되지만 테스트는 통과 처리·CI는 안 막힘 | -fsanitize=undefined에 -fno-sanitize-recover=undefined를 반드시 같이 |
| 14 | 컨테이너에서 TSAN이 시작부터 종료(154차 실행 확인) | FATAL: ThreadSanitizer: unexpected memory mapping(경쟁 진단이 아님) | 커널 ASLR 엔트로피 확인(sysctl vm.mmap_rnd_bits=28 등) |
4.10.12 체크포인트
- CI 검증 단계를 빠른 것부터 순서대로 말할 수 있는가. 왜 그 순서인가.
colcon test-result를 빠뜨리면 어떤 일이 생기는가. 이것이 왜 최악의 CI 실수인가.- CI에서 도메인을 격리해야 하는 이유와, 권장 환경변수 두 가지는?
- 린트를 "경고로만" 두면 안 되는 이유는?
- 테스트 층위 다섯 가지의 CI 적합성을 비교할 수 있는가. 통합 테스트가 불안정한 원인 네 가지와 대처는?
- 불안정한 테스트에 대한 세 가지 선택지는 무엇이며, 방치가 왜 선택지가 아닌가.
- 세 가지 새니타이저가 각각 무엇을 검출하는가. TSAN을 야간에 두는 이유는?
- 빌드 매트릭스의 각 차원이 언제 필요한가. 가장 비용이 큰 차원은 무엇이며 어떻게 다루는가.
rolling을 경고 게이트로 두는 이유는?- 캐시 전략 세 가지 중 가장 안전하고 효과적인 것은 무엇이며 왜인가. 빌드 디렉터리 캐시의 위험은?
- 차단 게이트와 경고 게이트를 구분하는 원칙은 무엇인가. 게이트가 자주 오작동하면 무슨 일이 생기는가.
- 제4부가 반복한 세 가지 원칙을 말할 수 있는가.
- 커버리지를 측정하는 절차(C++·Python)와, 그것을 별도 잡으로 분리해야 하는 이유는?
- 커버리지 숫자를 게이트로 강제하면 생기는 부작용은 무엇이며, 대신 무엇을 보아야 하는가.
여기까지가 제4부 빌드·패키징·배포다. 제0~3부가 "ROS 2가 어떻게 동작하는가"였다면, 제4부는 "그것을 어떻게 만들고 내보내는가"였다 — ament 규약(4.1), C++·Python 빌드(4.2·4.3), 메타데이터(4.4), colcon(4.5), 의존(4.6), 소스 관리(4.7), 아키텍처(4.8), 릴리스(4.9), 그리고 검증(4.10).
이제 도구는 갖춰졌다. 다음부터는 로봇 자체로 들어간다. 로봇은 공간 안의 물체이고, 그 물체는 여러 부분으로 이루어져 각자 움직인다. "카메라가 본 점이 로봇 발밑 어디인가" 를 답하려면 좌표계의 관계를 다룰 체계가 필요하다 — 그것이 제5부 좌표·모델(5.1~5.7) 의 주제다. tf2 아키텍처부터 시작한다.