AEGLOSLAB ROS 2 Jazzy 대전

제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 무엇을 검증하는가

커밋 · PR 1. 린트 (가장 빠름) 2. 의존 해석 rosdep 3. 빌드 4. 단위 테스트 5. 통합 테스트 launch_testing 6. 아키텍처 빌드 arm64 7. 심화 검사 새니타이저 · 커버리지

빠른 것부터 배치하는 것이 원칙이다. 린트 실패를 알리는 데 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_pep257Python 스타일✅
ament_cpplint·ament_uncrustifyC++ 스타일✅(합의 후)
ament_lint_cmakeCMake 스타일✅
ament_xmllintXML 문법✅
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, 각각 IP 172.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) + lcovC++컴파일러 계측. 최적화를 끄고 빌드해야 줄 대응이 맞는다
python3-colcon-lcov-resultC++ 결과 집계colcon 확장
python3-colcon-coveragepy-resultPython 결과 집계colcon 확장

실행 확인(2026-08-16): Jazzy 컨테이너의 apt 인덱스에 python3-colcon-lcov-result(0.5.3)·python3-colcon-coveragepy-result·lcov(2.0)가 실재한다. 확인한 것은 패키지 존재까지이며, 위 명령을 실제 워크스페이스에서 실행해 보지는 않았다(확인 필요: 옵션 이름은 확장 버전에 따라 다를 수 있다).

실행 확인(153차) — 위 (확인 필요)를 이어받아 실제로 끝까지 실행했다. lcov 2.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). 이 컨테이너의 lcov 2.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부가 끝난다. 열 장을 하나의 흐름으로 묶으면:

소스 코드 package.xml 4.4 빌드 규칙 4.2 · 4.3 ament 규약 4.1 colcon 4.5 rosdep 4.6 vcstool 4.7 아키텍처별 빌드 4.8 릴리스 4.9 CI 검증 4.10

제4부가 반복한 세 가지:

  1. 클린 환경에서 검증하라. 개발 머신은 이미 오염되어 있어 의존 누락·설치 규칙 오류를 숨긴다(4.1.4·4.4.3·4.9.6).
  2. 선언은 여러 곳에 필요하다. package.xml과 CMakeLists.txt, setup.py와 package.xml의 버전 — 자동 동기화되지 않는 쌍들이 있고, 어긋나면 조용히 실패한다(4.2.2·4.3.6).
  3. 재현성을 아티팩트로 남겨라. 소스 잠금 파일·이미지 태그·빌드 옵션을 릴리스와 함께 보관해야 6개월 뒤에 같은 것을 만들 수 있다(4.7.8·4.9.8).

4.10.11 함정 카탈로그

#함정증상대처
1colcon test-result 생략실패를 통과로 처리반드시 포함(4.5.10)
2도메인 격리 없이 CI작업 간 간섭·간헐 실패ROS_DOMAIN_ID·LOCALHOST
3불안정한 테스트 방치CI 신뢰 붕괴고치거나 격리하거나 제거
4모든 검사를 모든 커밋에CI 병목·우회빠른 것만 항상
5린트를 경고로만위반 누적켜거나 끄거나
6매트릭스 남용자원 소모필요한 차원만
7빌드 캐시로 클린 빌드 상실깨진 것을 못 잡음주기적 무캐시 빌드
8ccache 적중률 미확인캐시가 사실상 무효ccache -s
9새니타이저 오탐 방치노이즈에 묻힘억제 파일
10차단 게이트가 자주 오작동우회 습관 형성신뢰 가능한 것만 차단
11arm64를 매 PR에PR 지연머지·야간으로
12릴리스 전 전체 검증 생략빌드팜 실패태그 게이트(4.10.9)
13UBSAN을 -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 아키텍처부터 시작한다.