C++ 프로젝트 CI 구성: GitHub Actions 멀티 OS·컴파일러 매트릭스, vcpkg 캐시, 테스트

들어가며: 푸시하면 빌드·테스트까지

40-1에서 vcpkg와 Conan으로 의존성을 관리했다면, 다음 단계는 그 환경을 푸시와 PR마다 자동으로 빌드하고 테스트하는 CI(지속적 통합)입니다. GitHub Actions는 워크플로를 YAML로 정의하고, push나 pull_request 같은 저장소 이벤트에 맞춰 Ubuntu, Windows, macOS 러너에서 job을 실행합니다. C++ 프로젝트에서는 CMake와 컴파일러 조합을 job마다 정의하고, 의존성 빌드 결과를 캐시해 시간을 줄이는 것이 핵심입니다.

이 글에서는 워크플로의 trigger·job·matrix·cache 구조를 짚고, 멀티 OS·멀티 컴파일러 매트릭스에 vcpkg·Conan을 연동하는 예제와 자주 만나는 에러를 다룹니다. 테스트 작성은 Google Test·ctest, 커버리지는 코드 커버리지 글과 함께 보면 좋고, 다른 언어의 CI 구성은 Node.js GitHub Actions·Go go test와 비교해 볼 수 있습니다.


로컬에서는 되는데 CI에서만 실패할 때

“로컬 Windows에서는 빌드되는데 GitHub Actions의 Ubuntu에서만 실패한다”, “vcpkg로 설치한 라이브러리를 CI에서는 찾지 못한다” 같은 문제는 대개 다음 중 하나에서 옵니다.

  • 로컬에만 설치된 라이브러리나 툴체인에 의존합니다. 특히 vcpkg를 클래식 모드로 전역 설치해 쓰면 그 패키지는 CI 러너에 없습니다.
  • Windows, Linux, macOS는 경로 규칙, 기본 컴파일러, 표준 라이브러리 구현이 다르므로, 한 플랫폼에서만 통과하는 코드(예: 헤더를 간접 include에 기대는 코드)가 생깁니다.
  • 캐시 키가 의존성 변경을 반영하지 않아 오래된 캐시를 복원합니다.
  • 의존성을 처음부터 빌드하느라 job 시간이 길어집니다.
flowchart LR
  subgraph before["CI 없음"]
    B1["개발자 A (Windows)"] --> B2[로컬만 빌드]
    B3["개발자 B (macOS)"] --> B4[로컬만 빌드]
    B5[병합] --> B6["다른 OS에서 빌드 실패"]
  end
  subgraph after["CI 있음"]
    A1[push/PR] --> A2[GitHub Actions]
    A2 --> A3[Ubuntu 빌드]
    A2 --> A4[Windows 빌드]
    A2 --> A5[macOS 빌드]
    A3 --> A6["모든 OS 통과 후 병합"]
    A4 --> A6
    A5 --> A6
  end

의존성을 매니페스트(vcpkg.json)나 conanfile로 저장소에 선언하고, 같은 워크플로로 모든 OS에서 빌드하면 이런 차이를 병합 전에 잡을 수 있습니다.


GitHub Actions 기본

.github/workflows/*.yml에 워크플로 파일을 두면 지정한 이벤트(push, pull_request 등)에 따라 실행됩니다. job은 하나의 러너에서 실행되는 단위로 runs-on으로 OS를 지정하고, job 안의 steps는 uses로 기존 액션을 쓰거나 run으로 셸 명령을 실행합니다. 어느 step이든 실패하면 job이 실패로 끝나고, 서로 다른 job은 서로 다른 머신에서 돌기 때문에 파일 시스템을 공유하지 않습니다.

sequenceDiagram
  participant Dev as 개발자
  participant GH as GitHub
  participant Runner as Actions Runner
  Dev->>GH: push / pull_request
  GH->>Runner: 워크플로 트리거
  Runner->>Runner: checkout, CMake 설정, 빌드, 테스트
  Runner->>GH: 결과 보고
  GH->>Dev: PR에 통과/실패 표시

최소 동작 예제

my-cpp-app/
├── .github/
│   └── workflows/
│       └── build.yml
├── CMakeLists.txt
└── src/
    └── main.cpp
# CMakeLists.txt
cmake_minimum_required(VERSION 3.20)
project(my-cpp-app LANGUAGES CXX)
add_executable(my-app src/main.cpp)
target_compile_features(my-app PRIVATE cxx_std_17)
enable_testing()
add_test(NAME my-app-test COMMAND my-app)  # 종료 코드 0이면 통과
// src/main.cpp
#include <iostream>
int main() {
    std::cout << "CI에서 빌드·테스트됩니다.\n";
    return 0;
}
# .github/workflows/build.yml
name: Build and Test
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Configure
        run: cmake -B build -DCMAKE_BUILD_TYPE=Release
      - name: Build
        run: cmake --build build
      - name: Test
        run: ctest --test-dir build --output-on-failure

이 세 파일을 커밋해 main에 푸시하면 첫 워크플로가 돕니다. ctest --test-dir는 CMake 3.20 이상에서 쓸 수 있습니다.


멀티 OS·컴파일러 매트릭스

strategy.matrix에 값 목록을 두면 조합 수만큼 job이 병렬로 생깁니다. OS마다 다른 값은 matrix.include로 조합을 직접 나열하고, fail-fast: false로 두면 한 조합이 실패해도 나머지는 끝까지 실행되어 어느 플랫폼이 문제인지 한 번에 볼 수 있습니다.

# .github/workflows/matrix-build.yml
jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: ubuntu-latest
            compiler: gcc
            cc: gcc
            cxx: g++
          - os: ubuntu-latest
            compiler: clang
            cc: clang
            cxx: clang++
          - os: windows-latest
            compiler: msvc
            cc: ''
            cxx: ''
          - os: macos-latest
            compiler: apple-clang
            cc: clang
            cxx: clang++
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - name: Configure
        shell: bash
        run: |
          if [ -n "${{ matrix.cc }}" ]; then
            cmake -B build -DCMAKE_BUILD_TYPE=Release \
              -DCMAKE_C_COMPILER=${{ matrix.cc }} \
              -DCMAKE_CXX_COMPILER=${{ matrix.cxx }}
          else
            cmake -B build -DCMAKE_BUILD_TYPE=Release
          fi
      - name: Build
        run: cmake --build build --config Release
      - name: Test
        run: ctest --test-dir build --output-on-failure -C Release

Windows에서 shell: bash는 Git Bash를 쓰며, cc가 빈 문자열이라 컴파일러를 지정하지 않는 분기를 탑니다. 그러면 CMake는 기본적으로 Visual Studio 생성기와 MSVC를 고릅니다. Visual Studio 생성기는 여러 구성(Debug/Release)을 한 빌드 트리에 담는 멀티 구성 생성기라서 CMAKE_BUILD_TYPE이 무시되고, 빌드와 테스트에 --config Release/-C Release를 줘야 합니다. 단일 구성 생성기(Makefile, Ninja)에서는 이 옵션이 무시되므로 모든 OS에 함께 써도 됩니다.

Ninja 생성기로 MSVC를 쓰고 싶다면 cl.exe가 PATH에 있어야 하므로, 개발자 명령 프롬프트 환경을 설정하는 ilammy/msvc-dev-cmd 같은 액션을 먼저 실행합니다. microsoft/setup-msbuild는 MSBuild만 PATH에 추가하며 cl.exe 환경을 만들어 주지는 않습니다.

- uses: ilammy/msvc-dev-cmd@v1
  with:
    arch: x64
- run: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release

캐시와 테스트

vcpkg 캐시

vcpkg는 패키지를 빌드하면 그 결과를 바이너리 캐시(zip 아카이브)로 저장하고, 같은 패키지·버전·트리플렛·컴파일러 조합을 다시 요청받으면 빌드 대신 이 캐시를 복원합니다. 이 디렉터리를 actions/cache로 보존하면 두 번째 실행부터 의존성 빌드를 건너뛸 수 있습니다. buildtrees나 packages 같은 중간 산출물을 캐시하는 것보다 크기가 작고 정확합니다.

env:
  VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}/.vcpkg-cache
steps:
  - uses: actions/checkout@v4
    with:
      submodules: recursive
  - name: Create binary cache dir
    shell: bash
    run: mkdir -p "$VCPKG_DEFAULT_BINARY_CACHE"
  - name: Cache vcpkg binaries
    uses: actions/cache@v4
    with:
      path: ${{ github.workspace }}/.vcpkg-cache
      key: vcpkg-${{ runner.os }}-${{ matrix.triplet }}-${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json') }}
      restore-keys: |
        vcpkg-${{ runner.os }}-${{ matrix.triplet }}-

vcpkg에는 별도의 lock 파일이 없고, 매니페스트의 builtin-baseline(또는 vcpkg-configuration.json의 baseline)이 사용할 포트 버전을 고정합니다. 그래서 캐시 키에는 vcpkg.json과 vcpkg-configuration.json의 해시를 넣습니다. restore-keys는 의존성이 바뀌어 정확히 맞는 캐시가 없을 때 가장 최근의 비슷한 캐시를 복원해, 바뀌지 않은 패키지는 재사용하게 해 줍니다.

Conan 캐시

- name: Cache Conan
  uses: actions/cache@v4
  with:
    path: ~/.conan2/p
    key: conan-${{ runner.os }}-${{ hashFiles('conanfile.txt', 'conan.lock') }}
    restore-keys: |
      conan-${{ runner.os }}-

Conan 2는 패키지를 ~/.conan2/p에 저장합니다. conan lock create로 만든 conan.lock을 커밋해 두면 버전이 고정되고 캐시 키로도 쓸 수 있습니다.

테스트와 실패 알림

ctest --output-on-failure는 실패한 테스트의 출력만 보여 줍니다. CTest 3.21 이상에서는 --output-junit result.xml로 JUnit 형식 결과를 남겨 테스트 리포트 액션에 넘길 수 있습니다. 실패 시 알림은 if: failure() step으로 붙이고, 저장소 설정의 브랜치 보호 규칙에서 “Require status checks to pass”를 켜면 CI가 통과하지 않은 PR은 병합할 수 없습니다.

- name: Notify on failure
  if: failure()
  run: echo "Build failed"  # 실제로는 Slack webhook 호출 등

vcpkg·Conan 멀티 OS 워크플로 예제

vcpkg 매니페스트 모드 + 멀티 OS

vcpkg를 서브모듈로 두고 vcpkg.json으로 의존성을 선언한 경우입니다.

my-app/
├── .github/workflows/ci.yml
├── CMakeLists.txt
├── vcpkg.json
├── vcpkg-configuration.json   # baseline (선택)
├── vcpkg/                     # git submodule
└── src/main.cpp
# .github/workflows/ci.yml
name: CI (vcpkg)
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        include:
          - os: ubuntu-latest
            triplet: x64-linux
          - os: windows-latest
            triplet: x64-windows
          - os: macos-latest
            triplet: arm64-osx   # macos-latest는 Apple Silicon 러너
    runs-on: ${{ matrix.os }}
    env:
      VCPKG_DEFAULT_BINARY_CACHE: ${{ github.workspace }}/.vcpkg-cache
    steps:
      - uses: actions/checkout@v4
        with:
          submodules: recursive
      - name: Create binary cache dir
        shell: bash
        run: mkdir -p "$VCPKG_DEFAULT_BINARY_CACHE"
      - name: Cache vcpkg binaries
        uses: actions/cache@v4
        with:
          path: ${{ github.workspace }}/.vcpkg-cache
          key: vcpkg-${{ matrix.triplet }}-${{ hashFiles('vcpkg.json', 'vcpkg-configuration.json') }}
          restore-keys: vcpkg-${{ matrix.triplet }}-
      - name: Configure
        shell: bash
        run: |
          cmake -B build -S . \
            -DCMAKE_TOOLCHAIN_FILE="${{ github.workspace }}/vcpkg/scripts/buildsystems/vcpkg.cmake" \
            -DVCPKG_TARGET_TRIPLET=${{ matrix.triplet }} \
            -DCMAKE_BUILD_TYPE=Release
      - name: Build
        run: cmake --build build --config Release
      - name: Test
        run: ctest --test-dir build --output-on-failure -C Release

툴체인 파일을 지정하면 CMake 설정 단계에서 vcpkg가 vcpkg.json의 의존성을 설치하고, 그 뒤 find_package가 설치된 패키지를 찾습니다.

Conan 2 + 멀티 OS

# .github/workflows/ci.yml
name: CI (Conan)
on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, windows-latest, macos-latest]
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: '3.12'
      - name: Install Conan
        run: pip install conan
      - name: Detect profile
        run: conan profile detect --force
      - name: Cache Conan
        uses: actions/cache@v4
        with:
          path: ~/.conan2/p
          key: conan-${{ runner.os }}-${{ hashFiles('conanfile.txt', 'conan.lock') }}
          restore-keys: conan-${{ runner.os }}-
      - name: Configure
        shell: bash
        run: |
          conan install . --output-folder=build --build=missing -s build_type=Release
          cmake -B build -S . \
            -DCMAKE_TOOLCHAIN_FILE="${{ github.workspace }}/build/conan_toolchain.cmake" \
            -DCMAKE_BUILD_TYPE=Release
      - name: Build
        run: cmake --build build --config Release
      - name: Test
        run: ctest --test-dir build --output-on-failure -C Release

새 러너에는 Conan 기본 프로필이 없으므로 conan profile detect로 컴파일러와 OS를 감지해 만들어야 합니다. 이 step을 빼면 conan install이 “default profile doesn’t exist” 에러로 실패합니다.


자주 만나는 에러

”Could not find a package configuration file provided by …”

CMake Error at CMakeLists.txt:5 (find_package):
  Could not find a package configuration file provided by "spdlog"

vcpkg나 Conan의 툴체인 파일을 CMake에 넘기지 않았거나, 로컬에서 클래식 모드로 설치한 패키지에 기대고 있는 경우입니다. vcpkg는 -DCMAKE_TOOLCHAIN_FILE=.../vcpkg/scripts/buildsystems/vcpkg.cmake를, Conan은 conan install 후 생성된 conan_toolchain.cmake를 지정합니다. 툴체인 파일은 처음 설정할 때만 적용되므로, 이미 만들어진 빌드 디렉터리에 나중에 지정하면 무시됩니다.

vcpkg 서브모듈이 비어 있음

vcpkg를 서브모듈로 쓰는데 checkout에서 submodules: recursive를 빠뜨리면 vcpkg/ 디렉터리가 비어 툴체인 파일을 찾지 못합니다. checkout step에 이 옵션을 추가합니다.

Windows 잡만 컴파일러를 못 찾음

Windows 러너 이미지에는 CMake와 Visual Studio가 설치되어 있지만, cl.exe는 개발자 명령 프롬프트 환경이 설정되어야 PATH에 잡힙니다. Visual Studio 생성기(CMake 기본값)를 쓰면 CMake가 알아서 MSVC를 찾으므로 문제가 없고, Ninja나 Makefile 생성기로 MSVC를 쓸 때만 ilammy/msvc-dev-cmd 같은 액션이 필요합니다. 또 매트릭스의 빈 cc 값을 -DCMAKE_C_COMPILER=처럼 그대로 넘기면 CMake가 컴파일러를 찾지 못하므로, 앞의 예제처럼 값이 있을 때만 옵션을 붙입니다.

캐시가 너무 커짐

GitHub Actions 캐시는 저장소당 총량 한도(기본 10GB)가 있고, 넘으면 오래 쓰지 않은 캐시부터 지워집니다. 에러로 실패하지는 않지만 캐시가 계속 밀려나 히트율이 떨어집니다. vcpkg는 중간 산출물 대신 바이너리 캐시만, 빌드 디렉터리는 꼭 필요할 때만 캐시하고, 매트릭스 조합마다 캐시를 따로 만들면 총량이 빠르게 늘어난다는 점을 고려합니다.

job 시간 초과

GitHub 호스팅 러너의 job은 최대 6시간(360분)까지 실행됩니다. 의존성이 많은 프로젝트에서 캐시 없이 처음 빌드하면 이 한도에 가까워질 수 있습니다. 바이너리 캐시를 설정하고, 쓰지 않는 의존성을 정리하고, timeout-minutes로 job별 상한을 짧게 걸어 멈춘 job이 분을 낭비하지 않게 합니다. 더 긴 빌드가 필요하면 self-hosted 러너를 고려합니다.

macOS에서 Xcode 관련 에러

GitHub의 macOS 러너에는 여러 버전의 Xcode가 설치되어 있습니다. 특정 버전이 필요하거나 “invalid active developer path” 같은 에러가 나면 maxim-lobanov/setup-xcode 액션이나 sudo xcode-select -s /Applications/Xcode_<버전>.app으로 사용할 Xcode를 지정합니다. xcode-select --install은 대화형 설치 창을 띄우는 명령이라 CI에서는 쓸 수 없습니다.


CI 시간 줄이기

캐시 키 전략

캐시 키에는 결과에 영향을 주는 것만 넣습니다. OS, 트리플렛이나 컴파일러, 의존성 선언 파일의 해시가 그 대상입니다. restore-keys로 접두사가 같은 이전 캐시를 대체로 복원하게 하면, 의존성 하나를 바꿔도 나머지는 재사용됩니다.

ccache로 컴파일 가속

- name: Setup ccache
  uses: hendrikmuhs/[email protected]
  with:
    key: ${{ matrix.os }}-${{ matrix.compiler }}
- name: Configure
  run: cmake -B build -DCMAKE_CXX_COMPILER_LAUNCHER=ccache
- name: Build
  run: cmake --build build

ccache는 소스와 컴파일 옵션의 해시로 결과를 찾으므로, 캐시 키에 소스 파일 해시를 넣을 필요가 없습니다. 오히려 소스 해시를 키에 넣으면 커밋마다 키가 바뀌어 이전 캐시를 정확히 복원하지 못합니다. OS와 컴파일러처럼 안정적인 값을 키로 쓰면 액션이 실행 끝에 갱신된 캐시를 저장합니다.

불필요한 실행 줄이기

문서만 바뀐 PR에서는 빌드할 필요가 없습니다. 워크플로 트리거에 경로 조건을 걸면 해당 파일이 바뀐 경우에만 실행됩니다.

on:
  push:
    branches: [main]
    paths:
      - 'src/**'
      - 'CMakeLists.txt'
      - 'vcpkg.json'
      - '.github/workflows/**'
  pull_request:
    branches: [main]
    paths-ignore:
      - 'docs/**'
      - '**.md'

브랜치 보호에서 필수 체크로 지정한 워크플로를 경로 조건으로 건너뛰면, 체크가 “대기 중”으로 남아 PR을 병합할 수 없게 될 수 있습니다. 이 경우 워크플로는 항상 실행하고 job 안에서 dorny/paths-filter로 무거운 step만 건너뛰는 방식이 낫습니다. strategy.max-parallel로 동시에 실행할 매트릭스 job 수를 제한할 수도 있습니다.


확장 패턴

job 사이에 빌드 결과 넘기기

job은 서로 다른 러너에서 실행되므로, 빌드 job의 build/ 디렉터리를 테스트 job이 그대로 쓸 수 없습니다. 빌드와 테스트를 같은 job에 두는 것이 가장 단순하고, 나눠야 한다면 아티팩트로 넘깁니다.

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build
      - uses: actions/upload-artifact@v4
        with:
          name: build-linux
          path: build/
  test:
    needs: build
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/download-artifact@v4
        with:
          name: build-linux
          path: build/
      - run: chmod +x build/my-app && ctest --test-dir build --output-on-failure
  deploy:
    needs: test
    if: github.ref == 'refs/heads/main' && github.event_name == 'push'
    runs-on: ubuntu-latest
    environment: production
    steps:
      - run: echo "Deploy"

아티팩트로 옮기면 실행 권한이 사라지므로 chmod +x가 필요하고, CTest가 빌드 디렉터리의 절대 경로를 기록하기 때문에 같은 경로(같은 러너 종류와 같은 체크아웃 위치)에서 실행해야 합니다. 배포 자격 증명은 저장소 Secrets에 두고 ${{ secrets.DEPLOY_KEY }}로 참조하며, environment를 지정하면 환경별 비밀과 승인 규칙을 따로 둘 수 있습니다.

포맷과 정적 분석

- name: Check formatting
  run: find src \( -name '*.cpp' -o -name '*.h' \) -print0 | xargs -0 clang-format --dry-run -Werror
- name: Run clang-tidy
  run: |
    cmake -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
    run-clang-tidy -p build

러너에 설치된 clang-format 버전에 따라 결과가 달라질 수 있으므로, 팀이 쓰는 버전을 명시적으로 설치해 고정하는 편이 좋습니다.

커버리지 업로드

- name: Build with coverage
  run: |
    cmake -B build -DCMAKE_BUILD_TYPE=Debug -DCMAKE_CXX_FLAGS="--coverage"
    cmake --build build
- name: Run tests
  run: ctest --test-dir build
- name: Collect coverage
  run: |
    sudo apt-get install -y lcov
    lcov --capture --directory build --output-file coverage.info
    lcov --remove coverage.info '/usr/*' --output-file coverage.info
- name: Upload coverage
  uses: codecov/codecov-action@v5
  with:
    token: ${{ secrets.CODECOV_TOKEN }}
    files: ./coverage.info

--coverage로 빌드하고 테스트를 실행하면 .gcda 파일이 생길 뿐이므로, lcov 같은 도구로 리포트 파일을 만든 뒤 업로드해야 합니다. 자세한 내용은 코드 커버리지 글을 참고하십시오.

Self-hosted 러너

빌드가 매우 무겁거나 특수한 하드웨어가 필요하면 자체 서버에 러너를 설치하고 runs-on: [self-hosted, linux, x64]처럼 라벨로 지정합니다. 공개 저장소에서 self-hosted 러너를 쓰면 외부 PR의 코드가 자기 서버에서 실행될 수 있으므로, 공개 저장소에는 권장되지 않습니다.


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 매트릭스 빌드에서 Windows 잡만 cmake나 컴파일러를 못 찾는 이유는 무엇인가요?

A. Windows 러너는 Linux·macOS와 셸과 경로 규칙이 다르고, MSVC는 개발자 명령 프롬프트 환경이 설정되어야 cl.exe가 PATH에 잡힙니다. 그래서 Linux 기준으로 작성한 cc/cxx 환경 변수나 bash 전용 스크립트를 그대로 쓰면 Windows 잡에서만 실패하기 쉽습니다. matrix.include로 OS별 컴파일러 설정을 따로 두고, Windows에서는 CMake의 Visual Studio 생성기를 쓰거나 MSVC 환경을 잡아 주는 액션을 쓰는 방식으로 분리하는 것이 좋습니다.

Q. vcpkg와 Conan 중 어떤 걸 CI에 쓰나요?

A. 40-1에서 선택한 패키지 매니저를 그대로 CI에 연동하면 됩니다. vcpkg는 매니페스트 모드와 서브모듈(또는 러너에 설치된 vcpkg), Conan은 pip install conan과 conan profile detect로 CI를 구성합니다. 둘 다 멀티 OS 빌드와 바이너리 캐시를 지원합니다.

이전 글: DevOps for C++ #40-1: vcpkg·Conan

다음 글: [DevOps for C++ #40-3] 컨테이너 기반 개발: Docker로 빌드 환경 표준화 및 배포 이미지 최적화