C++ 멀티 컴파일러 전략과 CI/CD 파이프라인 구축 | 실무 가이드

들어가며: “우리 PC에서는 되는데요?”

“우리 PC에서는 되는데요?” — 이 한마디 뒤에는 보통 컴파일러·OS·라이브러리 버전 차이가 숨어 있습니다. 한 컴파일러에서만 통과하는 비표준 확장, 특정 플랫폼에서만 나오는 경고, MSVC에서만 발생하는 링크 에러 같은 문제들은 배포 직전이나 고객 환경에서 터져 나올 때 비용이 큽니다. 멀티 컴파일러 전략은 GCC·Clang·MSVC 중 두 개 이상으로 빌드해 보는 습관으로, 이식성 문제를 초기에 발견하는 방법입니다.

flowchart LR
  subgraph single[단일 컴파일러]
    S1[코드 작성] --> S2[GCC로만 빌드]
    S2 --> S3[배포]
    S3 -.->|나중에 MSVC에서 에러!| S4[긴급 수정]
  end
  subgraph multi[멀티 컴파일러]
    M1[코드 작성] --> M2[GCC·Clang·MSVC 빌드]
    M2 --> M3[경고·에러 조기 발견]
    M3 --> M4[안전한 배포]
  end

이 글을 읽으면 멀티 컴파일러 전략, 경고 옵션(-Wall·-Werror), LTO·PGO·Sanitizer 같은 고급 최적화, CI/CD 파이프라인으로 이식성과 품질을 챙기는 방법을 알 수 있습니다.

실무에서 겪는 문제 시나리오

시나리오증상원인해결 방향
배포 직전 빌드 실패Linux에서만 되던 코드가 Windows 빌드에서 링크 에러inline 함수 정의 누락, extern "C" 불일치멀티 컴파일러로 사전 검증
프로덕션 메모리 오류특정 입력에서만 크래시, 재현 어려움힙 버퍼 오버플로우, use-after-freeAddressSanitizer로 디버그 빌드
릴리스 빌드 성능 저하-O2로 빌드했는데 핫 루프가 느림컴파일러가 어느 분기가 자주 실행되는지 모른 채 코드 배치·인라인을 결정PGO로 프로파일 기반 최적화
CI에서만 실패로컬 GCC 12는 되는데 CI의 GCC 9에서 실패C++17/20 기능 미지원컴파일러 버전 고정, Docker 이미지 명시
경고 폭발-Werror 도입 시 수백 개 경고레거시 코드에 암시적 변환 다수단계적 도입, -Wno-error=conversion
컴파일러별 다른 결과GCC와 Clang에서 부동소수점 결과 미세 차이최적화·연산 순서 차이epsilon 비교, -ffast-math 주의

개인 프로젝트에서는 하나의 컴파일러만 사용해도 충분하지만, 실무에서는 여러 컴파일러로 테스트하는 것이 중요합니다. 한 컴파일러에서만 통과하는 비표준 확장이나 플랫폼 가정을 쓰고 있으면, 나중에 이식할 때 큰 비용이 듭니다. 초기에 GCC·Clang·MSVC 중 두 개 이상으로 빌드해 보는 습관이 그런 문제를 줄여 줍니다. 실무에서는 로컬에서 두 컴파일러로만 실행해 보는 것도 도움이 되고, GitHub Actions 등 CI에서 OS·컴파일러 매트릭스로 빌드해 두면 PR마다 이식성과 경고를 자동으로 점검할 수 있습니다. 💡 당장 코딩부터 하고 싶다면 — → #3 VS Code 개발 환경으로 넘어가도 됩니다.


멀티 컴파일러 전략

여러 컴파일러로 빌드하는 것은 처음에는 번거롭지만, 이식성 문제를 늦게 발견할 때의 비용을 생각하면 충분히 값어치가 있습니다.

멀티 컴파일러 전략이 중요한 이유

각 컴파일러가 다른 경고를 발생시킵니다
GCC는 찾지 못한 잠재적 버그를 Clang이 경고로 알려줄 수 있습니다. 컴파일러마다 정적 분석 알고리즘이 다르기 때문입니다.

이식성 문제를 조기에 발견합니다
한 컴파일러에서만 동작하는 비표준 코드를 다른 컴파일러가 거부하면서 문제를 발견할 수 있습니다.

컴파일러 버그를 우회할 수 있습니다
드물지만 컴파일러에도 버그가 있습니다. 한 컴파일러에서 문제가 생기면 다른 컴파일러로 우회할 수 있습니다.

전반적인 코드 품질이 향상됩니다
여러 컴파일러의 경고를 모두 해결하다 보면 자연스럽게 더 표준에 가깝고 안전한 코드를 작성하게 됩니다.

-Werror 사용 시 참고
-Werror는 “경고를 모두 에러로 취급”하므로, 경고 하나만 나와도 빌드가 실패합니다. 새 프로젝트에서는 처음부터 -Werror를 켜 두면 깔끔하게 유지하기 좋지만, 레거시 프로젝트에 갑자기 적용하면 수백 개의 경고 때문에 빌드가 안 되는 상황이 됩니다. 그럴 때는 경고를 하나씩 줄이면서 구간별로 -Werror를 도입하거나, 특정 경고만 에러로 바꾸는 -Werror=unused-parameter 같은 옵션을 쓰는 방법이 있습니다.

실전 예제: 한 컴파일러에서만 통과하는 코드

대표적인 예가 가변 길이 배열(VLA)입니다. VLA는 C99 기능이고 C++ 표준에는 없지만, GCC와 Clang은 확장으로 받아들입니다. -Wall -Wextra로도 경고가 나오지 않고 -Wpedantic이나 -Wvla를 켜야 경고가 나옵니다. 반면 MSVC는 이 코드를 에러(C2131, 식이 상수로 계산되지 않음)로 거부합니다. GCC로만 개발하면 Windows 빌드를 처음 돌리는 날에야 이 문제를 알게 됩니다.

#include <iostream>
int main(int argc, char*[]) {
    int n = argc + 4;
    int arr[n];  // VLA: GCC·Clang은 확장으로 허용, MSVC는 에러 C2131
    for (int i = 0; i < n; ++i) {
        arr[i] = i;
        std::cout << arr[i] << " ";
    }
    return 0;
}

각 컴파일러로 테스트:

g++ -Wall -Wextra main.cpp              # 경고 없이 통과
g++ -Wall -Wextra -Wpedantic main.cpp   # warning: ISO C++ forbids variable length array
clang++ -Wall -Wextra main.cpp          # 경고 없이 통과(최근 버전은 -Wvla-cxx-extension 경고)
cl /W4 main.cpp                         # error C2131

고칠 때는 std::vector<int> arr(n);처럼 표준 컨테이너를 씁니다.

로컬 개발 워크플로우

로컬에서 GCC와 Clang 둘 다로 빌드해 보면 한쪽에서만 나오는 경고나 비표준 확장 사용을 바로 확인할 수 있습니다. 아래 스크립트는 -Wall -Wextra로 흔한 경고를 켜고, -Werror로 “경고도 에러로 취급”해 빌드합니다. app_gcc, app_clang 두 개의 실행 파일이 생기므로, 필요하면 둘 다 실행해 보며 동작이 같은지 확인할 수 있습니다. 실무에서는 이런 스크립트를 scripts/ 에 두고 CI에서도 비슷한 명령으로 멀티 컴파일러 빌드를 돌리는 경우가 많습니다.

#!/bin/bash
# build_all.sh - 모든 컴파일러로 빌드
echo "Building with GCC..."
g++ -Wall -Wextra -Werror main.cpp -o app_gcc
echo "Building with Clang..."
clang++ -Wall -Wextra -Werror main.cpp -o app_clang
echo "All builds successful!"

컴파일러별 특징 요약

컴파일러강점약점주 사용처
GCC이식성, 최적화, 무료Windows 지원 제한Linux 서버, 임베디드
Clang빠른 컴파일, 명확한 에러 메시지일부 플랫폼에서 라이브러리 호환macOS, 크로스 컴파일
MSVCWindows 네이티브, Visual Studio 통합/permissive-를 지정하지 않으면 비표준 확장 허용(C++20 모드와 새 VS 프로젝트는 기본 지정)Windows 데스크톱, 게임

멀티 컴파일러 도입 시점

신규 프로젝트에서는 처음부터 멀티 컴파일러를 설정하는 것이 좋습니다. CMake나 빌드 스크립트를 작성할 때 GCC·Clang·MSVC를 모두 고려하면 설계 단계에서 플랫폼 종속 코드를 줄일 수 있습니다. 레거시 프로젝트에서는 단계적으로 도입합니다. 먼저 로컬에서 두 번째 컴파일러로 빌드해 보며, 경고와 에러를 수정한 뒤 CI에 추가하는 순서가 안전합니다.

flowchart LR
    A[신규: 처음부터] --> B[CMake 멀티 설정]
    C[레거시: 단계적] --> D[로컬 2nd 컴파일러]
    D --> E[경고 수정]
    E --> F[CI 추가]

컴파일러 경고 활용

모든 경고를 에러로 취급

# GCC/Clang
g++ -Wall -Wextra -Werror main.cpp
# MSVC
cl /W4 /WX main.cpp

-Wall의 함정
-Wall은 “모든 경고”가 아니라 흔한 경고 일부만 켭니다. -Wextra를 함께 써야 더 많은 경고를 볼 수 있습니다.

추천 경고 옵션

GCC/Clang 권장 옵션(각 옵션의 의미는 아래 표 참고):

g++ -Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wsign-conversion \
    -Wcast-qual -Wold-style-cast -Werror main.cpp

MSVC 권장 옵션(/W4 레벨 4 경고, /WX 경고를 에러로, /permissive- 표준 준수 모드):

cl /W4 /WX /permissive- main.cpp

경고로 발견할 수 있는 버그

// 1. 변수 섀도잉 (Wshadow)
int calculate(int value) {
    int result = value * 2;
    if (value > 10) {
        int result = value * 3;  // 경고: 변수 섀도잉
        return result;
    }
    return result;
}
// 2. 부호 변환 (Wsign-conversion, C++에서는 -Wconversion에 포함되지 않음)
void process(unsigned int size) {
    int index = size - 1;  // 경고: unsigned -> signed 변환, size == 0이면 index는 -1
}
// 3. 사용하지 않는 변수 (Wunused)
int main() {
    int unused_var = 42;  // 경고: 사용하지 않는 변수
    return 0;
}

셋 다 컴파일은 되지만 버그로 이어지기 쉬운 코드입니다. 경고를 켜 두면 이런 코드가 리뷰 전에 드러납니다.

경고 옵션 적용 순서 (레거시 프로젝트)

레거시 프로젝트에 경고를 도입할 때는 단계적으로 진행하는 것이 안전합니다.

flowchart TD
    A[1단계: -Wall만 적용] --> B[경고 개수 확인]
    B --> C[2단계: 경고 하나씩 수정]
    C --> D[3단계: -Wextra 추가]
    D --> E[4단계: -Werror 도입]
    E --> F[5단계: 특수 경고 추가]
  1. 1단계: -Wall만 적용하고 경고 개수를 파악합니다.
  2. 2단계: 중요한 경고부터 하나씩 수정합니다.
  3. 3단계: -Wextra를 추가하고 동일하게 수정합니다.
  4. 4단계: 경고가 0이 되면 -Werror를 도입합니다.
  5. 5단계: -Wshadow, -Wconversion 등 특수 경고를 선택적으로 추가합니다.

주요 경고 옵션 상세

옵션발견하는 문제권장도
-Wunused-variable사용하지 않는 변수필수
-Wshadow바깥 스코프 변수 가리기권장
-Wconversion암시적 정수/부동소수 변환권장
-Wsign-conversion부호 있는/없는 변환(C++에서는 별도로 켜야 함)선택
-Wold-style-castC 스타일 캐스트 (int)x권장
-Wcast-qualconst/volatile 제거 캐스트선택
-WpedanticISO C++ 표준 위반선택

-Wconversion 주의: 이 옵션은 매우 엄격해서, double을 int로 받거나 size_t를 int로 받는 등 흔한 패턴에서도 경고를 냅니다. 레거시 코드에 처음 적용할 때는 경고가 폭발할 수 있으므로, 새 코드에만 적용하거나 -Wno-error=conversion으로 에러 전환만 피하는 방법을 고려합니다.


플랫폼 종속 코드 처리

컴파일러 감지 매크로

// compiler_detect.h
#pragma once
// 컴파일러 감지: Clang을 가장 먼저 확인
#if defined(__clang__)
    #define COMPILER_CLANG   // clang-cl도 여기로 옴 (_MSC_VER도 정의함)
#elif defined(_MSC_VER)
    #define COMPILER_MSVC
#elif defined(__GNUC__)
    #define COMPILER_GCC
#endif
// 플랫폼 감지
#if defined(_WIN32) || defined(_WIN64)
    #define PLATFORM_WINDOWS
#elif defined(__APPLE__)
    #define PLATFORM_MACOS
#elif defined(__linux__)
    #define PLATFORM_LINUX
#endif

__clang__을 가장 먼저 확인해야 합니다. Clang은 GCC 호환을 위해 __GNUC__를 정의하고, Windows의 clang-cl은 MSVC 호환을 위해 _MSC_VER까지 정의하기 때문입니다. _WIN32는 64비트 Windows에서도 정의되므로 _WIN64를 따로 확인할 필요는 없습니다.

플랫폼별 코드 작성

#include "compiler_detect.h"
// DLL 내보내기/가져오기: 컴파일러가 아니라 대상 OS 기준 (MinGW도 dllexport 필요)
#if defined(_WIN32)
    #define EXPORT __declspec(dllexport)
    #define IMPORT __declspec(dllimport)
#else
    #define EXPORT __attribute__((visibility("default")))
    #define IMPORT
#endif
// 라이브러리를 빌드할 때는 MYLIB_BUILDING, 정적 라이브러리면 MYLIB_STATIC을 정의
#if defined(MYLIB_STATIC)
    #define MYLIB_API
#elif defined(MYLIB_BUILDING)
    #define MYLIB_API EXPORT
#else
    #define MYLIB_API IMPORT
#endif
MYLIB_API void myFunction();

표준 라이브러리 동작 차이

#include <string>
#include <iostream>
void testSSO() {
    std::string s = "Hello";
    // Small String Optimization(SSO) 버퍼 크기는 컴파일러가 아니라 표준 라이브러리 구현이 정함 (64비트 기준)
    // libstdc++(GCC 기본): 15자까지 객체 내부 버퍼에 저장
    // libc++(macOS Clang 기본): 22자까지
    // MSVC STL: 15자까지
    // 같은 Clang이라도 Linux에서 libstdc++를 쓰면 15자
    std::cout << "String: " << s << std::endl;
    std::cout << "Capacity: " << s.capacity() << std::endl;
}

부동소수점 연산 정확도

#include <cmath>
#include <iostream>
void testFloatingPoint() {
    double result = 0.1 + 0.2;
    // IEEE 754 double로 계산하면 0.30000000000000004 (기본 출력 정밀도에서는 0.3으로 보임)
    // 결과가 컴파일러마다 달라지는 것은 x87 확장 정밀도, FMA 축약(-ffp-contract),
    // -ffast-math 같은 설정 차이 때문
    std::cout << "0.1 + 0.2 = " << result << std::endl;
    // 정확한 비교가 필요하면 epsilon 사용
    const double EPSILON = 1e-9;
    if (std::abs(result - 0.3) < EPSILON) {
        std::cout << "Equal to 0.3" << std::endl;
    }
}

플랫폼별 sizeof 차이

long, size_t, 포인터 크기는 플랫폼마다 다를 수 있습니다. 64비트 Linux에서는 long이 8바이트이지만, 64비트 Windows에서는 4바이트입니다. 크기에 의존하는 코드는 int32_t, int64_t, size_t 등 고정 크기 타입을 사용하는 것이 안전합니다.

#include <cstdint>
#include <iostream>
void checkSizes() {
    // 플랫폼 독립적인 크기
    std::cout << "int32_t: " << sizeof(int32_t) << std::endl;  // 항상 4
    std::cout << "int64_t: " << sizeof(int64_t) << std::endl;  // 항상 8
    std::cout << "size_t:  " << sizeof(size_t) << std::endl;   // 포인터 크기
    // 플랫폼 의존적 (주의)
    std::cout << "long:    " << sizeof(long) << std::endl;     // Linux 8, Windows 4
}

매크로 분기를 한 곳으로 모으기

플랫폼 감지는 전처리기로 해야 하지만, 감지 결과를 constexpr 상수로 한 번만 옮겨 두면 나머지 코드는 일반 C++ 식으로 분기할 수 있습니다. 타입 검사를 받고 디버거에서도 보이므로 #ifdef를 코드 곳곳에 흩어 놓는 것보다 안전합니다.

// platform.h
#pragma once
#if defined(_WIN32)
inline constexpr bool kIsWindows = true;
#else
inline constexpr bool kIsWindows = false;
#endif
inline constexpr char kPathSeparator = kIsWindows ? '\\' : '/';

if constexpr (kIsWindows)도 쓸 수 있지만, 템플릿 밖에서는 버려지는 분기도 컴파일되므로 <windows.h>의 함수처럼 다른 플랫폼에 없는 API를 부르는 코드는 여전히 #if로 감싸야 합니다.

CI/CD 파이프라인

GitHub Actions 예제

# .github/workflows/build.yml
name: Multi-Compiler Build
on: [push, pull_request]
jobs:
  build:
    strategy:
      fail-fast: false
      matrix:
        include:
          - { os: ubuntu-latest,  cxx: g++ }
          - { os: ubuntu-latest,  cxx: clang++ }
          - { os: macos-latest,   cxx: clang++ }
          - { os: windows-latest, cxx: cl }
    runs-on: ${{ matrix.os }}
    steps:
      - uses: actions/checkout@v4
      - name: Configure (Linux/macOS)
        if: runner.os != 'Windows'
        run: cmake -S . -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_COMPILER=${{ matrix.cxx }}
      - name: Configure (Windows, Visual Studio generator = MSVC)
        if: runner.os == 'Windows'
        run: cmake -S . -B build
      - name: Build
        run: cmake --build build --config Release
      - name: Test
        run: ctest --test-dir build -C Release --output-on-failure

GitHub의 Ubuntu 러너에는 GCC와 Clang이 미리 설치되어 있고, Windows 러너에는 Visual Studio가 있어 기본 생성기로 MSVC가 선택됩니다. 셸 명령을 직접 쓰지 않고 CMake로 구성하면 Windows의 기본 셸(PowerShell)과 Linux의 bash 문법 차이도 신경 쓸 필요가 없습니다. macOS의 g++는 Clang을 가리키는 별칭이므로 진짜 GCC가 필요하면 Homebrew로 gcc를 설치하고 g++-14처럼 버전이 붙은 이름을 써야 합니다.

CI 빌드 매트릭스 시각화

flowchart TB
    subgraph Linux
        L1[GCC]
        L2[Clang]
    end
    subgraph macOS
        M2[Clang]
    end
    subgraph Windows
        W1[MSVC]
    end
    PR[Pull Request] --> Linux
    PR --> macOS
    PR --> Windows

CMake 멀티 컴파일러 설정

# CMakeLists.txt
cmake_minimum_required(VERSION 3.10)
project(MyProject)
set(CMAKE_CXX_STANDARD 17)
# 컴파일러별 옵션
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU")
    add_compile_options(-Wall -Wextra -Wpedantic -Werror)
elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang")
    add_compile_options(-Wall -Wextra -Wpedantic -Werror)
elseif(CMAKE_CXX_COMPILER_ID MATCHES "MSVC")
    add_compile_options(/W4 /WX /permissive-)
endif()
add_executable(myapp main.cpp)

CMake Presets로 멀티 컴파일러 쉽게 전환

CMake 3.19부터 도입된 Presets를 사용하면 컴파일러를 쉽게 전환할 수 있습니다(아래 "version": 3 형식은 CMake 3.21 이상 필요). CMakePresets.json에 GCC, Clang, MSVC 프리셋을 정의해 두며, --preset gcc처럼 지정해 빌드합니다.

{
  "version": 3,
  "configurePresets": [
    {
      "name": "gcc",
      "generator": "Ninja",
      "binaryDir": "${sourceDir}/build/gcc",
      "cacheVariables": {
        "CMAKE_C_COMPILER": "gcc",
        "CMAKE_CXX_COMPILER": "g++"
      }
    },
    {
      "name": "clang",
      "generator": "Ninja",
      "binaryDir": "${sourceDir}/build/clang",
      "cacheVariables": {
        "CMAKE_C_COMPILER": "clang",
        "CMAKE_CXX_COMPILER": "clang++"
      }
    }
  ]
}

사용 예:

cmake --preset gcc
cmake --build build/gcc
cmake --preset clang
cmake --build build/clang

CI 캐시 활용으로 빌드 시간 단축

C++ CI에서 시간이 가장 많이 드는 부분은 컴파일 자체이므로, 컴파일 결과를 캐시하는 ccache를 붙이는 것이 효과적입니다. 의존성을 vcpkg로 받는다면 바이너리 캐시도 함께 설정합니다.

- name: Setup ccache
  uses: hendrikmuhs/[email protected]
- name: Configure
  run: cmake -S . -B build -DCMAKE_CXX_COMPILER_LAUNCHER=ccache

build 디렉터리 전체를 캐시하는 방식은 CMake 설정이 바뀌어도 오래된 상태가 남을 수 있어 권하지 않습니다.

고급 컴파일 최적화

LTO는 링크 단계에서 여러 오브젝트 파일을 통합해 최적화하는 방식입니다. 컴파일 단계에서는 다른 TU(Translation Unit)의 코드를 볼 수 없어 인라인·상수 전파·데드 코드 제거 등이 제한되는데, LTO를 켜면 링크 시점에 전체를 보고 최적화할 수 있습니다. 향상 폭은 코드에 따라 크게 다르며, 헤더에 선언만 있고 구현이 다른 cpp에 있는 작은 함수가 자주 호출되는 코드에서 효과가 큽니다. 이미 대부분의 핫 코드가 헤더에 있는 템플릿 위주 코드라면 차이가 작을 수 있으니 적용 전후를 측정합니다.

# GCC/Clang: LTO 활성화
g++ -O3 -flto main.cpp utils.cpp -o app
clang++ -O3 -flto main.cpp utils.cpp -o app
# CMake: LTO 활성화
# -DCMAKE_INTERPROCEDURAL_OPTIMIZATION=ON
# CMakeLists.txt에서 LTO 설정 (컴파일러별 플래그를 CMake가 알아서 붙임)
include(CheckIPOSupported)
check_ipo_supported(RESULT ipo_ok)
if(ipo_ok)
    set(CMAKE_INTERPROCEDURAL_OPTIMIZATION_RELEASE TRUE)
endif()

주의: LTO는 빌드 시간을 크게 늘리고 (특히 링크 단계), 메모리 사용량이 증가합니다. CI에서는 릴리스 빌드에만 적용하는 것이 좋습니다.

PGO (Profile-Guided Optimization)

PGO는 실제 실행 프로파일을 수집한 뒤, 그 데이터를 바탕으로 다시 컴파일해 최적화하는 방식입니다. 컴파일러가 “어느 분기가 자주 실행되는지” 알 수 있어 분기 예측·인라인·코드 배치를 더 잘 할 수 있습니다.

# 1단계: 프로파일 수집용 빌드 (-fprofile-generate)
g++ -O3 -fprofile-generate main.cpp -o app_generate
# 2단계: 대표 워크로드로 실행 (프로파일 생성)
./app_generate < typical_input.txt
# 3단계: 프로파일 기반 최적화 빌드 (-fprofile-use)
g++ -O3 -fprofile-use main.cpp -o app_optimized
# CMakeLists.txt에서 PGO 설정
option(USE_PGO "Enable Profile-Guided Optimization" OFF)
if(USE_PGO)
    add_compile_options(-fprofile-generate)
    add_link_options(-fprofile-generate)
endif()
# 프로파일 수집 후: -fprofile-generate → -fprofile-use 로 변경

프로덕션과 유사한 입력 데이터로 프로파일을 수집해야 합니다. 실제와 다른 워크로드로 수집한 프로파일은 드문 경로를 자주 실행되는 것으로 착각하게 만들어 오히려 성능을 떨어뜨릴 수 있습니다. Clang도 -fprofile-generate/-fprofile-use를 지원하지만, 수집된 .profraw 파일을 llvm-profdata merge로 .profdata로 합치는 단계가 추가로 필요합니다.

Sanitizer (메모리·동작 검사)

Sanitizer발견하는 문제오버헤드사용 시점
AddressSanitizer (ASan)힙·스택 버퍼 오버플로우, use-after-free약 2배디버그·CI
UndefinedBehaviorSanitizer (UBSan)부호 있는 정수 오버플로우, null 역참조, 잘못된 시프트낮음디버그·CI
ThreadSanitizer (TSan)데이터 레이스약 5~15배동시성 테스트
MemorySanitizer (MSan, Clang 전용)초기화되지 않은 메모리 읽기약 3배디버그

오버헤드는 Clang 공식 문서에 적힌 전형적인 수치이며, 프로그램에 따라 다릅니다. ASan과 TSan은 한 바이너리에 함께 켤 수 없으므로 CI에서는 별도 job으로 나눕니다.

# AddressSanitizer: 힙 오버플로우, use-after-free
g++ -g -fsanitize=address -fno-omit-frame-pointer main.cpp -o app_asan
./app_asan  # 에러 시 정확한 위치와 스택 출력
# UndefinedBehaviorSanitizer: 정수 오버플로우, null 역참조 등
g++ -g -fsanitize=undefined main.cpp -o app_ubsan
# ThreadSanitizer: 데이터 레이스
g++ -g -fsanitize=thread main.cpp -o app_tsan
// AddressSanitizer로 발견되는 전형적인 버그
#include <vector>
#include <iostream>
void buggy_code() {
    std::vector<int> v = {1, 2, 3};
    v[10] = 42;  // ASan: heap-buffer-overflow
}
int use_after_free() {
    int* p = new int(42);
    delete p;
    return *p;  // ASan: heap-use-after-free (해제된 메모리를 읽는 순간 검출)
}
# CMake: Sanitizer 빌드 타입
set(CMAKE_CXX_FLAGS_ASAN "-g -fsanitize=address -fno-omit-frame-pointer")
set(CMAKE_EXE_LINKER_FLAGS_ASAN "-fsanitize=address")
# 단일 구성 생성기: cmake -DCMAKE_BUILD_TYPE=Asan ..
# 다중 구성 생성기: cmake --build . --config Asan

주의: ASan은 프로덕션에 넣지 않습니다. CI나 로컬 디버그 빌드에서만 사용합니다.

커스텀 최적화 플래그

# GCC/Clang 주요 최적화 옵션 예시
g++ -O3 -march=native -funroll-loops -ffunction-sections -fdata-sections \
    main.cpp -Wl,--gc-sections -o app

-O3는 가장 공격적인 최적화 수준이고, -march=native는 빌드하는 머신의 CPU 명령어를 모두 사용하며 튜닝도 그 CPU에 맞춥니다. -funroll-loops는 루프 언롤링, -ffunction-sections/-fdata-sections는 함수·데이터마다 섹션을 나눠 링커의 --gc-sections가 쓰지 않는 코드를 지울 수 있게 합니다.

플래그효과주의
-march=native현재 CPU 명령어 활용다른 머신에서는 호환성 문제
-ffast-math부동소수점 연산 가속수치 정확도·재현성 저하
-ffunction-sections함수별 섹션 분리-Wl,--gc-sections와 함께 써야 미사용 코드가 제거됨
# CMake: CPU별 최적화 (선택)
if(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64")
    add_compile_options(-march=x86-64-v3)  # AVX2 등 (GCC 11+, Clang 12+; 이 수준을 지원하지 않는 CPU에서는 실행 불가)
endif()

Compiler Explorer

Compiler Explorer (https://godbolt.org/)는 브라우저에서 코드를 컴파일하고 어셈블리를 바로 확인할 수 있는 도구입니다. GCC·Clang·MSVC 여러 버전을 선택해 -O0와 -O3의 어셈블리 차이, 인라인·루프 최적화 적용 여부, constexpr·const의 최적화 영향을 빠르게 비교할 수 있습니다.


자주 발생하는 문제와 해결법

문제 1: “undefined reference to” 링크 에러

어떤 빌드에서는 되는데 다른 컴파일러나 다른 최적화 수준에서 링크 에러가 나는 경우입니다. inline 함수를 헤더에 선언만 하고 정의는 한 cpp 파일에 두면, 그 정의가 보이지 않는 번역 단위에서 호출할 때 심볼이 없어 링크 에러가 납니다(표준상 진단이 필요 없는 잘못된 프로그램이라 컴파일러·최적화 수준에 따라 우연히 통과하기도 합니다). C 라이브러리 헤더를 extern "C" 없이 포함해 이름 맹글링이 어긋나는 것도 흔한 원인입니다.

// ❌ 잘못된 예: 헤더에 선언만 있고 정의가 다른 TU에 있음
// header.h
inline void process(int x);
// ✅ 올바른 예: 인라인 함수는 헤더에 정의
// header.h
inline void process(int x) {
    // 구현
}

문제 2: MSVC에서는 되는데 GCC·Clang에서 “cannot bind non-const lvalue reference” 에러

MSVC는 /permissive- 없이 빌드하면 임시 객체를 non-const 참조에 바인딩하는 비표준 확장을 허용합니다(경고 C4239만 냄). 같은 코드를 GCC·Clang이나 /permissive-를 켠 MSVC로 빌드하면 에러(MSVC는 C2664)가 납니다.

void foo(std::string& s);
foo(std::string("hello"));  // ❌ 임시 객체를 non-const 참조에 바인딩
// ✅ 수정하지 않는 인자라면 const 참조로
void bar(const std::string& s);
bar(std::string("hello"));

문제 3: -Werror 적용 후 빌드 실패

-Werror를 켜니 기존에 있던 경고 때문에 빌드가 막히는 경우, 특정 경고만 에러로 바꾸거나 일시적으로 해당 경고를 비활성화합니다.

# 특정 경고만 에러로
g++ -Wall -Wextra -Werror=unused-parameter -Wno-error=sign-conversion main.cpp
# 또는 경고를 무시 (임시 방편)
g++ -Wall -Wextra -Werror -Wno-unused-parameter main.cpp

문제 4: macOS에서 Clang이 GCC로 인식됨

__GNUC__가 정의되어 있어 GCC 전용 코드가 선택되는데 실제 컴파일러는 Clang인 경우입니다. Clang은 플랫폼과 관계없이 GCC 호환을 위해 __GNUC__를 정의하므로, 항상 __clang__을 먼저 확인합니다.

// 컴파일러 확인 순서: Clang 먼저
#if defined(__clang__)
    // Clang 전용 코드 (clang-cl 포함)
#elif defined(_MSC_VER)
    // MSVC 전용 코드
#elif defined(__GNUC__)
    // GCC 전용 코드
#endif

문제 5: Windows에서 g++ 명령을 찾을 수 없음

GitHub Actions Windows 러너에서 g++를 찾지 못하면, Windows에서는 MSVC를 쓰거나 MSYS2로 MinGW-w64 GCC를 설치하는 step을 추가합니다. MSYS2로 설치한 컴파일러는 이후 step에서 shell: msys2 {0}으로 실행해야 PATH가 잡힙니다.

# Windows에서 MinGW 사용 시
- name: Setup MinGW
  if: matrix.os == 'windows-latest' && matrix.compiler == 'gcc'
  uses: msys2/setup-msys2@v2
  with:
    msystem: UCRT64
    update: true
    install: mingw-w64-ucrt-x86_64-gcc

문제 6: Docker 빌드에서 컴파일러 버전 불일치

로컬에서는 되는데 Docker 빌드에서 C++17·20 기능이 없다는 에러가 나면, 대개 베이스 이미지의 기본 컴파일러가 로컬보다 오래된 버전입니다. Dockerfile에서 컴파일러 버전을 명시합니다.

# ❌ 버전 미지정: 베이스 이미지가 바뀌면 컴파일러 버전도 바뀜
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y g++
# ✅ 버전 명시
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y g++-12
ENV CXX=g++-12

문제 7: Clang에서 “argument unused during compilation” 경고

MinGW GCC용 어셈블러 옵션 -Wa,-mbig-obj 같은 플래그를 Clang에 그대로 넘기면, Clang은 이를 지원하지 않는 인자로 보고 경고나 에러를 냅니다. -Werror와 함께라면 빌드가 실패하므로 컴파일러별로 옵션을 분리합니다.

if(CMAKE_CXX_COMPILER_ID MATCHES "GNU")
    add_compile_options(-Wa,-mbig-obj)
endif()
# Clang에는 전달하지 않음

문제 8: MSVC에서 “C2039: ‘X’ is not a member of ‘std’” 에러

GCC·Clang에서는 std::optional, std::filesystem이 되는데 MSVC에서 안 된다면, MSVC의 기본 언어 모드가 C++14라 /std:c++17 이상을 지정하지 않았거나 MSVC 버전이 낮은 경우입니다(std::filesystem은 VS 2017 15.7부터). CMake에서 표준을 명시하면 MSVC에도 /std:c++17이 자동으로 붙습니다.

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

문제 9: AddressSanitizer “ASan does not support” 에러

-fsanitize=address로 빌드한 실행 파일에 -static을 함께 쓰면 ASan 런타임이 정적 링크 실행 파일을 지원하지 않아 실패합니다. -static을 빼고, ASan 런타임만 정적으로 넣고 싶다면 GCC는 -static-libasan, Clang은 -static-libsan을 씁니다.

문제 10: LTO 빌드 시 “undefined reference” 또는 링크 타임아웃

-flto를 추가한 뒤 링크 에러가 나거나 링크가 지나치게 오래 걸리는 경우입니다. 컴파일 단계에만 -flto를 넣고 링크 단계에 빠뜨리면 LTO용 중간 표현만 든 오브젝트를 일반 링크로 처리하게 되므로, 컴파일과 링크 모두에 -flto를 지정합니다. 정적 라이브러리를 만들 때는 ar 대신 LTO 플러그인을 쓰는 gcc-ar(Clang은 llvm-ar)를 써야 심볼이 제대로 보입니다. 링크 시간이 문제라면 -flto=auto(GCC)나 ThinLTO(-flto=thin, Clang)로 병렬화합니다.

실전 시나리오: 레거시 프로젝트에 멀티 컴파일러 도입하기

상황: 10만 줄 규모의 C++ 프로젝트가 GCC로만 빌드되고 있습니다. Clang으로 빌드해 보려고 합니다. 1단계: 먼저 에러 없이 컴파일되는지 확인합니다.

clang++ -Wall -Wextra -std=c++17 -I./include -c src/*.cpp

2단계: 링크 에러가 나면 extern "C" 누락, 인라인 정의 누락 등을 확인합니다. 3단계: 경고가 나오면 우선순위를 정해 하나씩 수정합니다. -Werror는 아직 넣지 않습니다. 4단계: Clang 빌드가 성공하면 CI에 Clang job을 추가합니다. 5단계: 경고가 0이 되면 -Werror를 도입합니다.


실무 핵심 원칙

컴파일러 버전 고정

# Dockerfile
FROM ubuntu:22.04
# 특정 버전 설치
RUN apt-get update && apt-get install -y \
    g++-11 \
    clang-14
# 기본 컴파일러 설정
RUN update-alternatives --install /usr/bin/g++ g++ /usr/bin/g++-11 100
RUN update-alternatives --install /usr/bin/clang++ clang++ /usr/bin/clang++-14 100

빌드 설정 문서화

지원하는 컴파일러 버전과 플랫폼별 빌드 명령을 저장소에 적어 두면 새 팀원과 CI가 같은 기준을 씁니다.

# BUILD.md
## 지원 컴파일러

- GCC 11 이상
- Clang 14 이상
- MSVC 2022 이상
## 빌드 방법
### Linux (GCC)
g++ -O2 -Wall -Wextra main.cpp -o myapp
### macOS (Clang)
clang++ -O2 -Wall -Wextra main.cpp -o myapp
### Windows (MSVC)
cl /O2 /W4 main.cpp

컴파일러 특화 코드 최소화

// 좋은 예: 표준 C++ 사용
#include <filesystem>
#include <iostream>
#include <string>
void listFiles(const std::string& path) {
    for (const auto& entry : std::filesystem::directory_iterator(path)) {
        std::cout << entry.path() << std::endl;
    }
}
// 나쁜 예: 플랫폼 종속 코드
#ifdef _WIN32
    #include <windows.h>
    void listFiles(const std::string& path) {
        WIN32_FIND_DATAA findData;
        HANDLE hFind = FindFirstFileA((path + "\\*").c_str(), &findData);
        // ...
    }
#else
    #include <dirent.h>
    void listFiles(const std::string& path) {
        DIR* dir = opendir(path.c_str());
        // ...
    }
#endif

컴파일러 업그레이드는 의도적으로

컴파일러를 올리면 새 경고와 최적화 변화가 함께 들어옵니다. CI 이미지의 버전을 고정해 두고, 업그레이드는 별도 PR에서 전체 매트릭스를 돌려 확인한 뒤 반영하는 편이 안전합니다.

컴파일러 버전 확인 스크립트

빌드 전에 컴파일러 버전을 확인하는 스크립트를 두면, “내 PC에서는 되는데” 문제를 줄일 수 있습니다.

#!/bin/bash
# check_compilers.sh
echo "=== Compiler Versions ==="
echo -n "GCC:   "; g++ --version 2>/dev/null | head -1 || echo "Not found"
echo -n "Clang: "; clang++ --version 2>/dev/null | head -1 || echo "Not found"
echo -n "MSVC:  "; cl 2>&1 | head -1 || echo "Not found"
echo ""
echo "=== C++ Standard Support ==="
echo -n "GCC C++17: "; g++ -std=c++17 -x c++ - -E < /dev/null 2>/dev/null && echo "OK" || echo "N/A"
echo -n "Clang C++17: "; clang++ -std=c++17 -x c++ - -E < /dev/null 2>/dev/null && echo "OK" || echo "N/A"

경고 무시가 필요한 경우

외부 라이브러리 헤더에서 경고가 나올 때 가장 간단한 방법은 그 경로를 -isystem(CMake에서는 target_include_directories(... SYSTEM ...))으로 지정하는 것입니다. 특정 include만 막아야 한다면 전후로 pragma를 둘 수 있습니다. 프로젝트 자체 코드의 경고는 가능한 한 수정합니다. 아래 GCC pragma는 Clang에서도 동작합니다.

// 외부 라이브러리에서 나오는 경고만 무시
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
#include <legacy_header.h>
#pragma GCC diagnostic pop

MSVC에서는:

#pragma warning(push)
#pragma warning(disable : 4996)  // deprecated 경고
#include <legacy_header.h>
#pragma warning(pop)

정적 분석 도구와의 조합

컴파일러 경고에 더해 Clang-Tidy, Cppcheck 같은 정적 분석 도구를 CI에 넣으면 품질을 더 높일 수 있습니다.

- name: Run Clang-Tidy
  run: |
    cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -B build .
    run-clang-tidy -p build -header-filter='.*'

같이 보면 좋은 글