C++ 정적 분석 도구 통합: Clang-Tidy와 Cppcheck로 코드 퀄리티 강제하기

들어가며: 실행 전에 미리 잡기

디버거·로깅이 이미 일어난 버그를 추적하는 도구라면, 이번 글은 버그가 실행되기 전에 막는 방법을 다룹니다. 정적 분석(static analysis)은 프로그램을 실행하지 않고 소스 코드만 보고 패턴·규칙 위반·잠재적 버그를 찾아냅니다. Clang-Tidy는 Clang의 AST 위에서 동작하는 린터로, 모던 C++ 스타일·성능·버그 패턴 체크를 수백 개 제공하고 Clang Static Analyzer 체크(clang-analyzer-*)도 함께 실행할 수 있습니다. Cppcheck는 컴파일러와 독립적인 자체 파서로 동작하며 메모리 누수·널 역참조·경계 오류 같은 흐름 분석에 강합니다. 둘 다 CI에 넣고 에디터와도 연동하면 커밋 전부터 문제를 볼 수 있습니다.

Clang-Tidy의 .clang-tidy 설정과 자동 수정, Cppcheck의 옵션과 suppression을 예제 프로젝트로 살펴보고, GitHub Actions에서 PR마다 검사를 돌려 실패 시 병합을 막는 방법, SonarQube Quality Gate 연동, 대규모 코드베이스에 점진적으로 도입하는 방법을 다룹니다.


배포 후 크래시, move 후 사용, 수천 개 경고: 정적 분석이 필요한 상황

널 포인터 역참조, use-after-free, 배열 경계 오류는 특정 실행 경로에서만 터지기 때문에 테스트가 그 경로를 지나지 않으면 배포 후에야 드러납니다. 정적 분석은 코드의 모든 경로를 기호적으로 따라가며 “이 조건에서 널이 될 수 있다”는 식의 경고를 냅니다. new 후 예외로 delete가 건너뛰어지는 누수나 malloc 후 free 누락은 Cppcheck의 누수 검사와 Clang-Tidy가 실행하는 clang-analyzer-cplusplus.NewDeleteLeaks·clang-analyzer-unix.Malloc 체크가 잡습니다. 다만 shared_ptr 순환 참조처럼 런타임 소유 관계에 달린 누수는 두 도구 모두 잘 찾지 못하므로 LeakSanitizer 같은 런타임 도구가 필요합니다.

std::move로 옮긴 객체를 다시 쓰는 버그도 흔합니다. 표준 라이브러리 타입은 이동 후 “유효하지만 지정되지 않은 상태”가 되므로 다시 대입하기 전까지 값에 기대면 안 되는데, bugprone-use-after-move가 이 패턴을 정확히 찾아 줍니다. for (auto x : vec)처럼 범위 기반 for에서 원소를 값으로 받아 큰 객체를 매번 복사하는 실수는 performance-for-range-copy가, 의미를 알 수 없는 상수(if (x > 1024))는 readability-magic-numbers가 경고합니다. 0 대신 nullptr를 쓰자는 식의 규칙 논쟁도 modernize-use-nullptr 같은 체크로 CI에서 강제하면 리뷰에서 반복되지 않습니다. 들여쓰기 폭 같은 서식 문제는 Clang-Tidy가 아니라 clang-format의 영역입니다.

레거시 코드베이스에 처음 돌렸을 때

수십만 줄짜리 레거시 프로젝트에 Clang-Tidy를 처음 돌리면 경고가 수천 개 나오는 것이 정상입니다. 한 번에 다 고칠 수는 없고, CI를 바로 fail로 걸면 아무도 머지를 못 합니다. 이 숫자에 압도돼 도입을 포기하거나, 반대로 --fix로 한꺼번에 고쳐 리뷰할 수 없는 거대한 diff를 만드는 것이 흔한 실패입니다. 현실적인 방향은 “기존 코드는 당장 건드리지 않고, 새로 쓰거나 고친 코드만 규칙을 지킨다”로 범위를 바꾸는 것입니다. 도구 쪽에서는 변경 파일만 검사하는 CI(CI 통합), NOLINT와 --line-filter(문제 5), SonarQube의 신규 코드(New Code) 기준 Quality Gate(SonarQube 연동)가 이 전략을 받쳐 줍니다.

flowchart LR
  subgraph before["수동 리뷰 (Before)"]
    B1[개발자] --> B2[컴파일]
    B2 --> B3[테스트]
    B3 --> B4[리뷰]
    B4 -.->|논쟁·누락| B5[merge]
  end
  subgraph after["정적 분석 (After)"]
    A1[개발자] --> A2[컴파일]
    A2 --> A3[Clang-Tidy]
    A2 --> A4[Cppcheck]
    A3 --> A5[병합 차단]
    A4 --> A5
    A5 --> A6[테스트]
    A6 --> A7[merge]
  end

Clang-Tidy

설치

# Ubuntu/Debian
sudo apt install clang-tidy
# macOS (Homebrew)
brew install llvm
# clang-tidy는 llvm에 포함됨: /opt/homebrew/opt/llvm/bin/clang-tidy
# Windows (vcpkg)
vcpkg install clang-tools

Clang-Tidy는 Clang 버전에 묶여 있어서 체크 목록과 지원하는 C++ 표준이 버전마다 다릅니다. C++20 코드를 분석하려면 가능한 한 최신 버전을 쓰고, CI와 로컬에서 같은 버전을 쓰도록 clang-tidy --version으로 확인합니다.

설정 파일(.clang-tidy)

프로젝트 루트에 .clang-tidy 파일을 두면, 해당 디렉터리와 하위에서 자동으로 적용됩니다.

# .clang-tidy 예시
Checks: >
  bugprone-*,
  modernize-use-nullptr,
  modernize-use-auto,
  performance-for-range-copy,
  performance-unnecessary-copy-initialization,
  readability-magic-numbers,
  readability-simplify-boolean-expr
WarningsAsErrors: ''
HeaderFilterRegex: '.*'
CheckOptions:
  - key: readability-magic-numbers.IgnoredValues
    value: '0;1;2;3;4;5;6;7;8;9;10;16;32;64;128;256;512;1024;2048;4096;8192'
  • Checks: 켤 체크 목록입니다. bugprone-*는 잠재적 버그, modernize-*는 모던 C++ 스타일, performance-*는 성능 관련 체크입니다.
  • WarningsAsErrors: 빈 문자열이면 경고만 내고 종료 코드는 0입니다. 안정되면 bugprone-use-after-move 등을 넣어 에러로 승격합니다.
  • HeaderFilterRegex: '.*'는 모든 헤더에서도 진단합니다. 이 값은 LLVM 정규식(POSIX ERE 계열)이라 (?!...) 같은 부정 전방탐색을 쓸 수 없습니다. 서드파티를 빼려면 '.*/(src|include)/.*'처럼 포함할 경로를 적거나, Clang-Tidy 19 이상에서는 ExcludeHeaderFilterRegex를 씁니다
  • CheckOptions: readability-magic-numbers에서 허용할 값 목록입니다. 0과 1처럼 의미가 분명한 값은 기본으로도 허용됩니다.

compile_commands.json 연동

Clang-Tidy는 include 경로와 매크로 정의를 알아야 정확히 분석합니다. CMake에서 -DCMAKE_EXPORT_COMPILE_COMMANDS=ON으로 빌드하면 compile_commands.json이 생성됩니다.

# CMake로 compile_commands.json 생성
cmake -B build -S . -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
# build 디렉터리 기준으로 clang-tidy 실행
find src -name '*.cpp' | xargs clang-tidy -p build

-p build는 build/compile_commands.json을 읽어 각 소스 파일의 컴파일 옵션을 적용합니다. bash에서 src/**/*.cpp 같은 재귀 글롭은 shopt -s globstar를 켜야 동작하므로, 스크립트에서는 find나 run-clang-tidy를 쓰는 편이 안전합니다.

자주 쓰는 체크

체크설명
bugprone-use-after-movestd::move 후 객체 사용
bugprone-narrowing-conversions축소 변환 (잘림 위험)
modernize-use-nullptr0 대신 nullptr
modernize-use-auto타입 명시 대신 auto
performance-for-range-copyfor-range에서 불필요한 복사
performance-unnecessary-copy-initialization불필요한 복사 초기화
readability-magic-numbers매직 넘버 경고

자동 수정(—fix)

run-clang-tidy -p build -fix src/

자동 수정 가능한 항목(예: 0 → nullptr)을 한 번에 고칩니다. 여러 번역 단위가 같은 헤더를 고치려 할 때 clang-tidy를 파일마다 따로 --fix로 돌리면 수정이 겹쳐 깨질 수 있으므로, 수정 사항을 모아 한 번에 적용하는 run-clang-tidy -fix가 안전합니다. 적용 후에는 빌드·테스트로 회귀가 없는지 확인합니다.

체크별 Before/After 예시

bugprone-use-after-move:

// ❌ Before: 이동 후 사용
std::vector<int> vec = get_data();
std::vector<int> other = std::move(vec);
vec.push_back(42);  // Clang-Tidy: use-after-move
// ✅ After: 이동 후 사용하지 않음
std::vector<int> vec = get_data();
std::vector<int> other = std::move(vec);
other.push_back(42);

performance-for-range-copy:

// ❌ Before: 요소 복사
for (auto item : large_vector) {
    process(item);  // item이 복사됨
}
// ✅ After: 참조로 순회
for (const auto& item : large_vector) {
    process(item);
}

modernize-use-nullptr:

// ❌ Before
int* p = 0;
if (ptr == NULL) { }
// ✅ After (--fix로 자동 수정 가능)
int* p = nullptr;
if (ptr == nullptr) { }

실행 가능 예제

// demo_clang_tidy.cpp: clang-tidy로 검사해 보세요
// clang-tidy demo_clang_tidy.cpp -- -std=c++17 -I.
#include <iostream>
#include <vector>
#include <memory>
int main() {
    int* p = 0;  // modernize-use-nullptr 권장
    (void)p;
    std::vector<int> vec = {1, 2, 3};
    for (auto x : vec) {  // performance-for-range-copy: 복사 대신 const auto&
        std::cout << x << std::endl;
    }
    std::unique_ptr<int> ptr = std::make_unique<int>(42);
    std::cout << "clang-tidy로 이 파일을 검사해 보세요.\n";
    return 0;
}
# 단일 파일 검사 (compile_commands 없이)
clang-tidy demo_clang_tidy.cpp -- -std=c++17 -I.

Cppcheck

컴파일러 독립 정적 분석

Cppcheck는 컴파일하지 않고 소스만 분석합니다. 메모리 누수, 널 포인터 역참조, 배열 경계, 미초기화 변수 등에 강하며, Clang-Tidy와 보완적 관계입니다.

설치

# Ubuntu/Debian
sudo apt install cppcheck
# macOS
brew install cppcheck
# Windows (vcpkg)
vcpkg install cppcheck

기본 실행

# 기본 검사 (error 수준: 누수, 널 역참조, 경계 오류 등)
cppcheck src/
# style·performance·portability 등 추가 검사까지
cppcheck --enable=all src/
# include 경로 지정
cppcheck --enable=all -I./include -I./third_party src/
# 시스템 헤더 누락 경고 무시
cppcheck --enable=all --suppress=missingIncludeSystem -I./include src/

주요 옵션

옵션설명
--enable=all기본 error 검사에 warning·style·performance·portability·information·unusedFunction 등을 추가
--enable=warningwarning 수준 검사 추가
--error-exitcode=1진단이 하나라도 보고되면 종료 코드 1 (CI용)
-j N병렬 분석 (N = CPU 코어 수)
--suppress=ID특정 경고 무시
-I pathinclude 경로

Suppression

오탐이 확실할 때만 사용하며, 가능하면 코드를 고치는 쪽이 좋습니다. 인라인 주석 억제는 --inline-suppr 옵션을 줘야 동작한다는 점을 자주 놓칩니다.

// cppcheck --inline-suppr src/ 로 실행해야 적용됨
void foo(const std::vector<int>& v, std::size_t i) {
    // cppcheck-suppress containerOutOfBounds
    int x = v[i];  // 호출자가 i < v.size()를 보장하는 경우
    (void)x;
}
# --suppress로 파일/라인별 무시
cppcheck --suppress=missingInclude:external.h --suppress=unusedFunction:util.cpp src/

고급 옵션: 플랫폼·표준·XML 리포트

Cppcheck는 컴파일러를 거치지 않기 때문에, 대상 플랫폼과 언어 표준을 직접 알려 줘야 판단이 정확해집니다. 예를 들어 long의 크기는 Linux 64비트(LP64)에서 8바이트, Windows 64비트(LLP64)에서 4바이트라서, 플랫폼을 잘못 주면 오버플로·축소 변환 경고가 틀리게 나오거나 빠집니다.

# 대상 플랫폼 지정 (unix64, win64, win32A 등)
cppcheck --platform=win64 --enable=all src/
# 언어 표준 지정
cppcheck --std=c++17 --enable=all src/
# 결과를 XML(버전 2)로 저장: 다른 도구(SonarQube 등)가 읽는 형식
cppcheck --enable=all --suppress=missingIncludeSystem \
  -I./include --xml --xml-version=2 -j 4 \
  src/ 2> cppcheck-report.xml

XML은 표준 에러(stderr) 로 나오므로 2>로 리다이렉트해야 한다는 점을 자주 놓칩니다. >로 받으면 진행 메시지만 담긴 파일이 생깁니다. 파일로 바로 쓰고 싶다면 --output-file=cppcheck-report.xml을 써도 됩니다.

프로젝트 파일로 설정 고정

명령줄 옵션이 길어지면 CI 스크립트·pre-commit 훅·로컬 실행이 서로 조금씩 달라지기 쉽습니다. Cppcheck는 두 가지 방법으로 설정을 한곳에 모을 수 있습니다.

  1. --project=build/compile_commands.json: Clang-Tidy와 같은 파일을 읽어 include 경로와 매크로를 그대로 가져옵니다. CMake 프로젝트라면 이쪽이 가장 덜 어긋납니다.
  2. Cppcheck 프로젝트 파일(*.cppcheck): Cppcheck GUI가 만드는 XML 형식이며, 제외 경로와 suppression까지 함께 담을 수 있습니다. 파일 이름이 정해져 있거나 자동으로 읽히는 것은 아니고, --project=로 명시해야 합니다.
<?xml version="1.0" encoding="UTF-8"?>
<!-- static-analysis.cppcheck -->
<project version="1">
  <root name="."/>
  <includedir>
    <dir name="include/"/>
    <dir name="third_party/optional/"/>
  </includedir>
  <paths>
    <dir name="src/"/>
  </paths>
  <exclude>
    <path name="third_party/legacy/"/>
    <path name="build/"/>
  </exclude>
  <suppressions>
    <suppression>missingIncludeSystem</suppression>
  </suppressions>
</project>
cppcheck --project=static-analysis.cppcheck --enable=all --error-exitcode=1

.cfg 확장자 파일은 Cppcheck에서 라이브러리 설정(어떤 함수가 메모리를 할당·해제하는지, 어떤 인자가 널이면 안 되는지 등)을 뜻하는 별도 형식이라, 프로젝트 설정을 cppcheck.cfg라는 이름으로 두면 헷갈리기 쉽습니다. 사내 할당 함수(pool_alloc/pool_free 같은)를 누수 검사에 넣고 싶을 때가 --library=mylib.cfg를 쓰는 경우입니다.

실행 가능 예제

// demo_cppcheck.cpp: cppcheck로 검사해 보세요
// cppcheck --enable=all demo_cppcheck.cpp
#include <iostream>
#include <vector>
int main() {
    int arr[10];
    arr[10] = 0;  // Cppcheck: arrayIndexOutOfBounds
    std::vector<int> vec(10);
    vec[10] = 0;  // Cppcheck: containerOutOfBounds
    int x;  // Cppcheck: uninitvar (미초기화)
    std::cout << x << std::endl;
    return 0;
}
cppcheck --enable=all demo_cppcheck.cpp

Clang-Tidy vs Cppcheck 비교

항목Clang-TidyCppcheck
의존성Clang/LLVM 필요독립 실행
강점모던 C++, 성능, 스타일메모리, 널, 경계
compile_commands사실상 필요 (없으면 -- 뒤에 옵션 직접 지정)선택 (--project로 읽으면 정확도 향상)
자동 수정—fix 지원미지원
파서Clang 프런트엔드자체 파서

CMake + .clang-tidy + Cppcheck 예제 프로젝트

프로젝트 구조

static-analysis-demo/
├── CMakeLists.txt
├── .clang-tidy
├── static-analysis.cppcheck
├── src/
│   ├── main.cpp
│   └── util.cpp
└── include/
    └── util.h

CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
project(StaticAnalysisDemo LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
add_executable(demo
    src/main.cpp
    src/util.cpp
)
target_include_directories(demo PRIVATE ${CMAKE_SOURCE_DIR}/include)

.clang-tidy

Checks: 'bugprone-*,modernize-use-nullptr,performance-*,readability-magic-numbers'
WarningsAsErrors: ''
HeaderFilterRegex: '.*'

static-analysis.cppcheck (선택)

<?xml version="1.0" encoding="UTF-8"?>
<project version="1">
  <includedir>
    <dir name="include/"/>
  </includedir>
  <paths>
    <dir name="src/"/>
  </paths>
</project>

CMake로 compile_commands.json을 만들고 있으니 이 파일 대신 --project=build/compile_commands.json을 써도 됩니다(Cppcheck 절의 프로젝트 파일 참고).

src/main.cpp

#include <iostream>
#include <vector>
#include <memory>
#include "util.h"
int main() {
    int* p = 0;  // Clang-Tidy: modernize-use-nullptr
    (void)p;
    std::vector<int> data = {1, 2, 3, 4, 5};
    for (auto x : data) {  // Clang-Tidy: performance-for-range-copy
        std::cout << x << " ";
    }
    std::cout << "\n";
    if (process(data) > 1024) {  // Clang-Tidy: readability-magic-numbers
        std::cout << "Large result\n";
    }
    return 0;
}

src/util.cpp

#include "util.h"
#include <numeric>
int process(const std::vector<int>& vec) {
    return std::accumulate(vec.begin(), vec.end(), 0);
}

include/util.h

#pragma once
#include <vector>
int process(const std::vector<int>& vec);

실행 스크립트

#!/bin/bash
# run-static-analysis.sh
set -e
# 1. 빌드 (compile_commands.json 생성)
cmake -B build -S . -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
cmake --build build
# 2. Clang-Tidy
echo "=== Clang-Tidy ==="
clang-tidy -p build src/*.cpp
# 3. Cppcheck
echo "=== Cppcheck ==="
cppcheck --enable=all --suppress=missingIncludeSystem \
    -I./include --error-exitcode=1 src/

run-clang-tidy로 전체 프로젝트 검사

# run-clang-tidy (LLVM 배포판에 포함, 배포판에 따라 run-clang-tidy-18처럼 버전 접미사가 붙음)
# 병렬로 전체 소스 검사 (마지막 인자는 파일 경로에 대한 정규식)
run-clang-tidy -p build -j 4 -header-filter='.*' src/
# 변경된 파일만 검사 (incremental)
git diff --name-only main | grep '\.cpp$' | xargs clang-tidy -p build

line-filter로 특정 라인만 검사

# PR에서 변경된 라인만 검사 (대규모 프로젝트용)
clang-tidy -p build src/main.cpp --line-filter='[{"name":"src/main.cpp","lines":[[10,20],[30,40]]}]'

compile_commands.json 없음, 헤더 못 찾음, 경고 폭탄 같은 문제

문제 1: “clang-tidy: Could not find compile_commands.json”

원인: -p build 또는 -p .로 지정한 디렉터리에 compile_commands.json이 없음. 해결법:

# CMake로 compile_commands.json 생성
cmake -B build -S . -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
# 또는 프로젝트 루트에 심볼릭 링크
ln -sf build/compile_commands.json compile_commands.json

문제 2: “clang-tidy: ‘vector’ file not found”

원인: include 경로가 없음. compile_commands.json이 있으면 자동으로 적용되지만, 단일 파일 검사 시에는 -- 뒤에 옵션을 줘야 함. 해결법:

clang-tidy src/main.cpp -- -std=c++17 -I./include -I/usr/include

문제 3: “Cppcheck: Too many #ifdef configurations”

원인: #ifdef가 많아서 조합이 폭발적으로 늘어남. 해결법:

# --max-configs로 제한
cppcheck --enable=all --max-configs=8 src/

문제 4: “Cppcheck: missingIncludeSystem”

원인: 시스템 헤더(<iostream> 등)를 찾지 못함. 해결법:

cppcheck --suppress=missingIncludeSystem -I./include src/

문제 5: “Clang-Tidy: 수백 개 경고가 한 번에 나와요”

원인: 기존 코드베이스에 정적 분석을 처음 도입했을 때. 해결법:

  1. WarningsAsErrors를 빈 문자열로 두고 경고만 수집
  2. NOLINT로 우선 무시하며, 새 코드부터 규칙 적용
  3. 점진적으로 // NOLINT 제거 및 수정
int* p = 0;  // NOLINT(modernize-use-nullptr) - 레거시 코드, 추후 수정

문제 6: “run-clang-tidy가 없어요”

원인: run-clang-tidy는 Clang/LLVM 빌드 시 함께 포함되는데, 일부 패키지에는 없음. 해결법:

# run-clang-tidy.py 사용 (LLVM 소스에 포함)
# 또는 수동으로 clang-tidy 실행
find src -name "*.cpp" | xargs -I {} clang-tidy --config-file=.clang-tidy -p build {}

문제 7: “Cppcheck: 오탐(false positive)이 많아요”

원인: 특정 패턴에서 Cppcheck가 잘못된 경고를 냄. 해결법:

// --inline-suppr와 함께 실행할 때만 적용됨
void* ptr = get_address_from_hardware();
// cppcheck-suppress cstyleCast
int* p = (int*)ptr;

문제 8: “Clang-Tidy: 특정 체크만 끄고 싶어요”

해결법:

# .clang-tidy에서 Checks에서 제외
Checks: 'bugprone-*,-bugprone-easily-swappable-parameters,modernize-*'

- 접두사로 해당 체크를 비활성화합니다.

문제 9: “CI에서 clang-tidy가 너무 오래 걸려요”

해결법:

PR에서는 git diff 기반으로 변경된 파일만 검사하고, 전체 검사는 run-clang-tidy -j N으로 병렬화해 야간 작업으로 돌리는 구성이 흔합니다. Clang-Tidy와 Cppcheck를 별도 job으로 나누면 서로 기다리지 않고 동시에 실행됩니다.

문제 10: “third_party에서 경고가 많이 나와요”

해결법:

# .clang-tidy
# 진단할 헤더만 포함 (LLVM 정규식은 (?!...) 전방탐색을 지원하지 않음)
HeaderFilterRegex: '.*/(src|include)/.*'
# Clang-Tidy 19 이상이면 제외 규칙을 따로 줄 수 있음
ExcludeHeaderFilterRegex: '.*/(third_party|vcpkg_installed)/.*'

인터넷에 흔히 돌아다니는 '(?!.*/third_party/)' 형태는 Clang-Tidy가 쓰는 LLVM 정규식 엔진에서 지원하지 않는 문법이라, 기대한 대로 서드파티를 걸러 주지 못합니다. 필터가 먹지 않는 것 같으면 이 부분부터 의심해 볼 만합니다.


CI 통합

GitHub Actions

# .github/workflows/static-analysis.yml
name: Static Analysis
on:
  pull_request:
    branches: [main, develop]
  push:
    branches: [main]
jobs:
  clang-tidy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install dependencies
        run: |
          sudo apt-get update
          sudo apt-get install -y clang-tidy cmake build-essential
      - name: Configure
        run: cmake -B build -S . -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
      - name: Run Clang-Tidy
        # while 루프로 돌리면 마지막 파일의 결과만 종료 코드에 반영되므로 xargs 사용
        # (xargs는 실행한 명령이 하나라도 실패하면 123을 반환)
        run: |
          find src -name "*.cpp" -print0 | xargs -0 clang-tidy -p build --warnings-as-errors="*"
  cppcheck:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Install Cppcheck
        run: sudo apt-get update && sudo apt-get install -y cppcheck
      - name: Run Cppcheck
        run: |
          cppcheck --enable=all --suppress=missingIncludeSystem \
            -I./include --error-exitcode=1 -j 4 src/

GitLab CI

# .gitlab-ci.yml
static-analysis:
  stage: test
  image: ubuntu:22.04
  before_script:
    - apt-get update && apt-get install -y clang-tidy cppcheck cmake g++
    - cmake -B build -S . -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
  script:
    # -exec ... \; 는 명령 실패를 find 종료 코드에 반영하지 않으므로 + 형식 사용
    - find src -name "*.cpp" -exec clang-tidy -p build {} +
    - cppcheck --enable=all --suppress=missingIncludeSystem -I./include --error-exitcode=1 src/

run-clang-tidy.py 사용 (병렬)

run-clang-tidy -p build -j 4 src/

브랜치 보호 규칙

GitHub의 브랜치 보호 규칙에서 위 job들을 필수 상태 검사(required status checks)로 지정하면, 리뷰어가 승인해도 정적 분석이 실패한 PR은 main에 병합할 수 없습니다.

변경된 파일만 검사하는 CI (빠른 피드백)

# .github/workflows/static-analysis-incremental.yml
name: Static Analysis (Incremental)
on:
  pull_request:
    branches: [main]
jobs:
  incremental:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # main과 diff 위해
      - name: Install tools
        run: sudo apt-get update && sudo apt-get install -y clang-tidy cppcheck cmake g++
      - name: Configure
        run: cmake -B build -S . -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
      - name: Analyze changed files
        run: |
          FILES=$(git diff --name-only --diff-filter=ACM origin/main...HEAD | grep '\.cpp$' || true)
          if [ -z "$FILES" ]; then echo "no C++ changes"; exit 0; fi
          status=0
          clang-tidy -p build --warnings-as-errors="*" $FILES || status=1
          cppcheck --enable=all --suppress=missingIncludeSystem \
            -I./include --error-exitcode=1 $FILES || status=1
          exit $status

처음 버전처럼 각 명령 뒤에 || true를 붙이면 검사가 실패해도 job이 항상 성공해 게이트 역할을 못 하므로, 종료 코드를 모아 마지막에 돌려줍니다. 파일 이름에 공백이 없다는 전제의 단순화된 예입니다.

CMake 타겟으로 정적 분석 실행

# CMakeLists.txt에 추가
find_program(CLANG_TIDY_BIN clang-tidy)
find_program(CPPCHECK_BIN cppcheck)
# COMMAND 인자의 *.cpp는 셸 없이 실행되는 생성기(Windows 등)에서 펼쳐지지 않으므로 CMake에서 글롭
file(GLOB_RECURSE ANALYZE_SOURCES CONFIGURE_DEPENDS ${CMAKE_SOURCE_DIR}/src/*.cpp)
if(CLANG_TIDY_BIN)
  add_custom_target(clang-tidy
    COMMAND ${CLANG_TIDY_BIN} -p ${CMAKE_BINARY_DIR} ${ANALYZE_SOURCES}
    WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
    COMMENT "Running clang-tidy"
  )
endif()
if(CPPCHECK_BIN)
  add_custom_target(cppcheck
    COMMAND ${CPPCHECK_BIN} --enable=all --suppress=missingIncludeSystem
      -I${CMAKE_SOURCE_DIR}/include --error-exitcode=1 ${CMAKE_SOURCE_DIR}/src/
    WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
    COMMENT "Running cppcheck"
  )
endif()
# 사용: cmake --build build --target clang-tidy

SonarQube 연동: 결과를 쌓고 Quality Gate로 막기

왜 CI 로그만으로는 부족해지는가

Clang-Tidy와 Cppcheck는 실행한 그 순간의 결과만 보여 줍니다. CI를 fail로 걸면 “경고 1개라도 있으면 실패”라는 거친 기준밖에 없고, 경고가 이번 달에 늘었는지 줄었는지는 로그를 뒤져도 알기 어렵습니다. 팀이 커지면 다음 같은 요구가 생깁니다.

  • 경고 수의 추세를 보고 싶다 (리팩터링이 실제로 효과가 있었는지)
  • “새 버그는 막되, 스타일 문제는 통과”처럼 심각도별로 다른 정책을 걸고 싶다
  • 레거시 경고는 두고 이번 PR이 새로 만든 문제만 막고 싶다

SonarQube는 이런 결과를 저장·집계·시각화하고, Quality Gate 조건으로 PR 통과 여부를 판정하는 서버입니다. Clang-Tidy·Cppcheck를 대체하는 게 아니라, 두 도구의 결과를 받아 관리하는 층이라고 보면 됩니다. 소규모 프로젝트라면 앞 장까지의 CI 구성으로 충분하고, 운영할 서버가 하나 늘어나는 비용이 있으니 위 요구가 실제로 생겼을 때 붙이는 것을 권합니다.

flowchart LR
  T1[Clang-Tidy 텍스트 로그] --> S[SonarQube]
  T2[Cppcheck XML] --> S
  S --> D[대시보드·추세]
  S --> Q{Quality Gate}
  Q -->|통과| M[병합]
  Q -->|실패| B[병합 차단]

Docker로 로컬에서 띄워 보기

# Linux 호스트에서는 내장 Elasticsearch 때문에 먼저 필요
sudo sysctl -w vm.max_map_count=524288
docker run -d --name sonarqube -p 9000:9000 sonarqube:community
# http://localhost:9000 접속, 최초 admin/admin 로그인 후 비밀번호 변경

컨테이너가 뜨자마자 죽는다면 대부분 vm.max_map_count가 부족한 경우입니다. docker logs sonarqube에 max virtual memory areas vm.max_map_count ... is too low가 찍힙니다. 팀에서 운영할 때는 기본 내장 DB 대신 PostgreSQL을 붙이는 것이 권장 구성입니다.

C++ 결과를 SonarQube에 넣는 세 가지 경로

여기서 가장 많이 헷갈리는 부분이 있습니다. SonarQube Community 버전에는 C/C++ 분석기가 들어 있지 않습니다. 따라서 경로를 먼저 골라야 합니다.

경로방법특징
상용 에디션Developer Edition 이상에 내장된 C/C++ 분석기 사용자체 규칙으로 분석, Clang-Tidy 결과를 따로 넣을 필요가 줄어듦
sonar-cxx 플러그인커뮤니티 플러그인 jar를 GitHub 릴리스에서 받아 extensions/plugins에 넣음Clang-Tidy 로그·Cppcheck XML을 직접 읽음. SonarQube 버전과의 호환표를 먼저 확인해야 함
Generic Issue Import리포트를 SonarQube 공통 JSON 형식으로 변환해 sonar.externalIssuesReportPaths로 전달플러그인 없이 가능하지만 규칙 설명·분류는 직접 채워야 함

sonar-cxx를 쓰는 경우의 sonar-project.properties 예시입니다.

# sonar-project.properties (프로젝트 루트)
sonar.projectKey=my-cpp-project
sonar.projectName=My C++ Project
sonar.sources=src,include
sonar.exclusions=**/third_party/**,**/build/**
# sonar-cxx: Clang-Tidy는 콘솔 텍스트 출력, Cppcheck는 XML v2
sonar.cxx.clangtidy.reportPaths=build/reports/clang-tidy.txt
sonar.cxx.cppcheck.reportPaths=build/reports/cppcheck.xml
# Generic Issue Import를 쓰는 경우
# sonar.externalIssuesReportPaths=build/reports/sonar-issues.json

Clang-Tidy에는 JSON 출력 옵션이 없습니다(--export-fixes는 자동 수정 정보를 YAML로 내보내는 용도입니다). 그래서 sonar-cxx도 Generic Import도 콘솔에 찍히는 파일:줄:열: warning: 메시지 [체크이름] 형식의 텍스트를 출발점으로 삼습니다.

리포트 변환 스크립트 (Generic Issue Import)

플러그인 없이 가는 경우, Clang-Tidy 텍스트 로그를 SonarQube 공통 형식으로 바꾸는 짧은 스크립트면 충분합니다.

#!/usr/bin/env python3
"""clang-tidy 콘솔 출력 -> SonarQube Generic Issue JSON"""
import json, os, re, sys

LINE = re.compile(r"^(?P<file>[^:\s][^:]*):(?P<line>\d+):(?P<col>\d+): "
                  r"(?P<level>warning|error): (?P<msg>.*?) \[(?P<rule>[\w.,-]+)\]$")

def convert(log_path, out_path, root="."):
    issues = []
    with open(log_path, encoding="utf-8", errors="replace") as f:
        for raw in f:
            m = LINE.match(raw.rstrip())
            if not m:
                continue  # 코드 인용 줄, 캐럿(^) 줄 등은 건너뜀
            issues.append({
                "engineId": "clang-tidy",
                "ruleId": m["rule"],
                "severity": "MAJOR" if m["level"] == "error" else "MINOR",
                "type": "BUG" if m["rule"].startswith("bugprone-") else "CODE_SMELL",
                "primaryLocation": {
                    "message": m["msg"],
                    "filePath": os.path.relpath(m["file"], root),
                    "textRange": {"startLine": int(m["line"])},
                },
            })
    with open(out_path, "w", encoding="utf-8") as f:
        json.dump({"issues": issues}, f, indent=2, ensure_ascii=False)

if __name__ == "__main__":
    convert(sys.argv[1], sys.argv[2])
clang-tidy -p build src/*.cpp > build/reports/clang-tidy.txt 2>/dev/null || true
python3 scripts/clang_tidy_to_sonar.py build/reports/clang-tidy.txt build/reports/sonar-issues.json

위 형식(이슈마다 engineId·severity·type을 넣는 방식)은 SonarQube 10.3부터 rules 배열을 따로 두는 새 형식으로 대체되어 deprecated 상태이지만 아직 읽힙니다. 새로 만든다면 사용하는 서버 버전의 문서에서 형식을 확인하는 편이 좋습니다. filePath는 SonarQube가 인덱싱한 파일과 경로가 맞아야 이슈가 붙습니다. sonar.sources에 들어가지 않은 헤더나 빌드 디렉터리의 생성 파일을 가리키는 이슈는 조용히 버려지므로, “리포트는 만들었는데 이슈가 0개”라면 경로부터 확인합니다. 헤더 경고가 같은 줄에 여러 번 찍히는 경우도 있으니 필요하면 (file, line, rule)로 중복을 제거해도 됩니다.

Quality Gate 설계

Quality Gate는 “이 조건을 넘으면 실패”의 묶음입니다. 기본 제공 게이트(Sonar way)는 신규 코드(New Code)에 대한 조건만 검사하는데, 이 설계 덕분에 레거시 경고가 수천 개여도 이번 PR이 새 문제를 만들지 않았다면 통과합니다. 앞에서 다룬 레거시 도입 문제에 대한 가장 현실적인 답이 이 기준입니다.

전략조건 예시어울리는 곳
엄격신규 버그 0, 신규 취약점 0, 신규 코드 유지보수 등급 A핵심 라이브러리·보안 민감 코드
완화신규 버그 0만일반 서비스 코드
점진적신규 코드에만 적용, 전체 코드 조건 없음레거시 도입 초기

“신규 코드”의 기준(직전 버전 대비인지, 최근 N일인지, 기준 브랜치 대비인지)은 프로젝트 설정에서 정합니다. PR 분석에서는 대상 브랜치 대비 변경분이 신규 코드가 됩니다.

CI에서 스캔하고 게이트 결과로 막기

# .github/workflows/static-analysis.yml 에 job 추가
  sonarqube:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0   # 신규 코드 판정에 git 이력이 필요
      - name: Install tools
        run: sudo apt-get update && sudo apt-get install -y clang-tidy cppcheck cmake g++
      - name: Generate reports
        run: |
          mkdir -p build/reports
          cmake -B build -S . -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
          clang-tidy -p build src/*.cpp > build/reports/clang-tidy.txt 2>/dev/null || true
          cppcheck --enable=all --suppress=missingIncludeSystem -I./include \
            --xml --xml-version=2 src/ 2> build/reports/cppcheck.xml || true
      - name: SonarQube Scan
        uses: SonarSource/sonarqube-scan-action@v5
        env:
          SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
          SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
        with:
          args: -Dsonar.qualitygate.wait=true

리포트 생성 단계에 || true를 붙인 것은 의도적입니다. 여기서는 판정을 SonarQube에 맡기기 때문에, 경고가 있다는 이유로 스캔 전에 job이 멈추면 안 됩니다. 대신 sonar.qualitygate.wait=true로 스캐너가 게이트 결과를 기다렸다가 실패 시 비정상 종료하게 해서, 브랜치 보호 규칙에서 이 job을 필수로 걸면 됩니다. fetch-depth: 0을 빼면 얕은 클론 때문에 blame 정보가 없어 신규 코드 판정이 부정확해진다는 경고가 뜹니다.

SonarQube에서 자주 막히는 곳

리포트를 인식하지 못함: 먼저 어느 경로(3가지 중)를 쓰는지 확인합니다. sonar-cxx 없이 sonar.cxx.* 속성만 적어 두면 아무 일도 일어나지 않습니다. 스캐너 로그에서 리포트 파일을 읽었다는 줄이 있는지, 경고가 “file not indexed”류로 버려지지 않았는지를 봅니다. Cppcheck XML은 버전 2(--xml-version=2)여야 합니다.

Quality Gate가 너무 엄격함: 기본 게이트는 수정할 수 없으니 복제한 뒤 팀 상황에 맞게 조건을 조정하고 프로젝트에 연결합니다. 이때 “전체 코드” 조건을 추가하기보다 신규 코드 조건만 남기는 편이 레거시 프로젝트에서 반발이 적습니다.


점진적 도입, 서드파티 제외, pre-commit, NOLINT 정책

패턴 1: 점진적 도입

flowchart LR
  A[1단계: 경고만] --> B[2단계: 신규 코드]
  B --> C[3단계: WarningsAsErrors]
  C --> D[4단계: 전체 적용]
  1. 1단계: WarningsAsErrors: ''로 경고만 수집, 팀에 공유
  2. 2단계: 새로 추가되는 코드에만 적용 (--line-filter 또는 경로 필터)
  3. 3단계: 핵심 체크(예: bugprone-use-after-move)를 WarningsAsErrors에 추가
  4. 4단계: 전체 프로젝트에 적용, 레거시 코드 점진적 수정

패턴 2: 체크 그룹별 분리

# .clang-tidy - 팀별로 다른 설정
# 예: 스타일은 경고, 버그는 에러
Checks: 'bugprone-*,modernize-*,performance-*,readability-*'
WarningsAsErrors: 'bugprone-use-after-move,bugprone-narrowing-conversions'

패턴 3: 서드파티 제외

# .clang-tidy
HeaderFilterRegex: '.*/(src|include)/.*'
ExcludeHeaderFilterRegex: '.*/(third_party|external)/.*'   # Clang-Tidy 19+

우리 코드가 있는 src/·include/ 헤더만 진단하고, third_party/·external/ 아래 헤더는 건너뜁니다. 구버전 Clang-Tidy라면 HeaderFilterRegex의 포함 패턴만으로 범위를 좁혀야 합니다(문제 10 참고).

패턴 4: pre-commit 훅

커밋할 때마다 돌기 때문에 스테이징된 파일만 검사하고, 느린 --enable=all(특히 파일 단위로는 오탐이 나는 unusedFunction)은 빼는 편이 실용적입니다.

#!/bin/bash
# .git/hooks/pre-commit
set -e
FILES=$(git diff --cached --name-only --diff-filter=ACM | grep '\.cpp$' || true)
[ -z "$FILES" ] && exit 0
[ -f build/compile_commands.json ] || cmake -B build -S . -DCMAKE_EXPORT_COMPILE_COMMANDS=ON >/dev/null
clang-tidy -p build --warnings-as-errors="*" $FILES
cppcheck --enable=warning,performance --suppress=missingIncludeSystem \
  -I./include --error-exitcode=1 $FILES

패턴 5: 대규모 프로젝트

  • incremental: 변경된 파일만 검사 (git diff 기반)
  • 캐시: CI에서 compile_commands.json 캐시
  • 병렬: run-clang-tidy -j 8 또는 cppcheck -j 8

패턴 6: 에디터 연동

에디터방법
VS Codeclangd 확장 (clang-tidy 내장), Cppcheck 확장
CLionClang-Tidy, Cppcheck 플러그인
VimALE, coc-clangd
Emacsflycheck-clang-tidy

clangd는 compile_commands.json을 읽어 저장 시 자동으로 Clang-Tidy를 실행합니다.

패턴 7: Cppcheck 설정을 한 파일로 표준화

CI·pre-commit·로컬에서 Cppcheck 옵션이 제각각이면 “내 PC에선 경고가 없었다”는 말이 나옵니다. Cppcheck 절의 프로젝트 파일(static-analysis.cppcheck)이나 compile_commands.json을 저장소에 두고, 모든 실행 경로가 --project=로 같은 파일을 읽게 하면 이 차이가 사라집니다.

# 세 곳 모두 같은 명령
cppcheck --project=static-analysis.cppcheck --enable=all --error-exitcode=1

패턴 8: NOLINT 정책

레거시 코드에서 일시적으로 무시할 때:

// NOLINT: 전체 라인 무시
int* p = 0;  // NOLINT
// NOLINT(체크이름): 특정 체크만 무시
int* p = 0;  // NOLINT(modernize-use-nullptr)
// NOLINTNEXTLINE: 다음 라인 무시
// NOLINTNEXTLINE(modernize-use-nullptr)
int* p = 0;

정책: NOLINT는 임시로만 사용하며, 이슈를 생성해 추후 제거하는 것을 권장합니다.

같이 보면 좋은 글


다음 글: [안정성 확보 #41-2] 런타임 검증: AddressSanitizer와 ThreadSanitizer로 메모리/경합 버그 잡기 이전 글: [DevOps for C++ #40-3] 컨테이너 기반 개발: Docker로 빌드 환경 표준화 및 배포 이미지 최적화