GCC vs Clang vs MSVC: 같은 코드로 비교한 최적화 플래그, C++23/26 지원, 플랫폼 차이

[C++ 실전 가이드 #2] GCC·Clang·MSVC 컴파일러 비교

같은 C++ 코드라도 컴파일러에 따라 에러 메시지, 최적화 결과, 지원하는 표준 기능이 달라집니다. 이 글은 하나의 example.cpp를 GCC·Clang·MSVC로 각각 빌드해 보면서 그 차이가 실제로 어디서 드러나는지, 플랫폼과 프로젝트 조건에 따라 무엇을 고르면 되는지 정리합니다. 링크 에러나 ABI 충돌처럼 여러 컴파일러를 섞을 때 생기는 문제도 함께 다룹니다.


같은 코드를 세 컴파일러로 빌드하기

공통 소스 코드 (example.cpp)

// example.cpp — GCC, Clang, MSVC 모두 동일한 소스
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
int main() {
    std::vector<int> nums = {3, 1, 4, 1, 5, 9, 2, 6};
    std::sort(nums.begin(), nums.end());
    std::string msg = "Sorted: ";
    for (int n : nums) {
        msg += std::to_string(n) + " ";
    }
    std::cout << msg << "\n";
    return 0;
}

GCC로 빌드 및 실행

# GCC (Linux, macOS, MinGW)
g++ -std=c++17 -O2 -Wall -Wextra -o example_gcc example.cpp
./example_gcc
# 출력: Sorted: 1 1 2 3 4 5 6 9

Clang으로 빌드 및 실행

# Clang (Linux, macOS)
clang++ -std=c++17 -O2 -Wall -Wextra -o example_clang example.cpp
./example_clang
# 출력: Sorted: 1 1 2 3 4 5 6 9

MSVC로 빌드 및 실행

REM MSVC (개발자 명령 프롬프트에서)
cl /std:c++17 /O2 /EHsc /W4 example.cpp /Fe:example_msvc.exe
example_msvc.exe
REM 출력: Sorted: 1 1 2 3 4 5 6 9

GCC·Clang과 달리 MSVC는 /EHsc를 주지 않으면 C++ 예외 처리 코드가 완전하게 생성되지 않고 경고 C4530이 나옵니다. 명령줄에서 직접 빌드할 때 가장 자주 빠뜨리는 옵션입니다.

에러 메시지 비교: 의도적 오류 예제

템플릿 안에서 타입 변환 오류를 일으켜 각 컴파일러의 에러 메시지를 비교해 봅니다.

// error_demo.cpp — 의도적 오류
#include <vector>
#include <string>
template<typename T>
void process(const std::vector<T>& v) {
    std::string s = v[0];  // T가 int면 string으로 변환할 수 없음
}
int main() {
    std::vector<int> v = {1, 2, 3};
    process(v);
    return 0;
}

아래 출력은 핵심 줄만 발췌한 것이고, 정확한 문구는 버전마다 조금씩 다릅니다.

GCC

error_demo.cpp: In instantiation of 'void process(const std::vector<T>&) [with T = int]':
error_demo.cpp:10:12:   required from here
error_demo.cpp:6:23: error: conversion from 'const int' to non-scalar type
'std::string' {aka 'std::__cxx11::basic_string<char>'} requested

Clang

error_demo.cpp:6:17: error: no viable conversion from 'const int' to 'std::string' (aka 'basic_string<char>')
    std::string s = v[0];
                ^   ~~~~
error_demo.cpp:10:5: note: in instantiation of function template specialization 'process<int>' requested here
note: candidate constructor not viable: no known conversion from 'const int' to 'const char *' for 1st argument
...

MSVC

error_demo.cpp(6): error C2440: 'initializing': cannot convert from 'const _Ty' to 'std::basic_string<char,...>'
error_demo.cpp(6): note: No constructor could take the source type, or constructor overload resolution was ambiguous
error_demo.cpp(10): note: see reference to function template instantiation 'void process<int>(const std::vector<int,...> &)' being compiled

세 컴파일러 모두 에러 위치와 “어느 인스턴스화에서 생겼는가”를 알려 줍니다. 차이는 부가 정보에 있습니다. Clang은 시도했다가 탈락한 생성자 후보를 하나씩 나열해 왜 변환이 안 되는지 보여 주고, GCC는 {aka ...}로 별칭 뒤의 실제 타입을 보여 줍니다. MSVC는 const _Ty처럼 템플릿 매개변수 이름을 그대로 내보내는 경우가 있어 타입을 짐작해야 할 때가 있습니다. 템플릿이 깊게 중첩될수록 이 차이가 커지므로, 한쪽 메시지가 막막하면 다른 컴파일러로 한 번 더 빌드해 보는 것이 실용적입니다.


GCC vs Clang vs MSVC 상세 비교

개요 비교표

항목GCCClangMSVC
개발 주체GNU 프로젝트LLVM 프로젝트 (Apple, Google 등 참여)Microsoft
라이선스GPLv3 (런타임 라이브러리 예외 포함)Apache 2.0 with LLVM Exception독점 (Visual Studio Community는 조건부 무료)
기본 표준 라이브러리libstdc++Linux는 libstdc++, macOS는 libc++MSVC STL
주요 사용처Linux 배포판, 임베디드macOS/iOS(Apple Clang), Android NDKWindows
지원 아키텍처가장 넓음 (x86, ARM, RISC-V, MIPS, AVR 등)주요 아키텍처 대부분x86, x64, ARM64

GCC (GNU Compiler Collection)

GCC는 대부분의 Linux 배포판이 시스템 컴파일러로 쓰고, 지원하는 CPU 아키텍처가 가장 넓어서 임베디드 툴체인에서도 기본값인 경우가 많습니다. -march·-flto·PGO 같은 최적화 옵션도 오래전부터 갖춰져 있습니다. 템플릿 에러 메시지는 Clang보다 길게 출력되는 편이고, C++20 모듈처럼 새 기능의 구현 속도는 기능마다 컴파일러별로 앞서거니 뒤서거니 합니다. 배포 대상이 Linux 서버나 임베디드 보드라면 대상 시스템과 같은 GCC로 빌드하는 것이 가장 마찰이 적습니다.

# GCC 기본 사용 예
g++ -std=c++20 -O2 -Wall -Wextra -o app main.cpp

Clang (LLVM/Clang)

Clang은 진단 메시지가 읽기 쉽고, clang-tidy·Clang Static Analyzer·clangd 같은 도구가 같은 프런트엔드를 쓰기 때문에 IDE와 정적 분석 연동이 매끄럽습니다. macOS와 iOS에서는 Xcode에 들어 있는 Apple Clang이 사실상 유일한 선택입니다. Linux에서는 기본적으로 GCC의 libstdc++를 사용하므로 GCC로 빌드한 라이브러리와 섞어 써도 대개 문제가 없고, -stdlib=libc++를 명시하면 LLVM의 libc++로 바꿀 수 있습니다. Apple Clang은 오픈소스 LLVM과 버전 번호 체계가 다르므로, 기능 지원 여부를 확인할 때 이 차이를 감안해야 합니다.

# Clang 기본 사용 예
clang++ -std=c++20 -O2 -Wall -Wextra -o app main.cpp

MSVC (Microsoft Visual C++)

MSVC는 Visual Studio의 디버거·프로파일러·IntelliSense와 묶여 있고, Windows SDK·COM·DirectX 헤더가 가장 먼저 검증되는 컴파일러입니다. Windows에서만 동작하며, 같은 Windows 환경에서 Clang을 쓰고 싶다면 MSVC 호환 드라이버인 clang-cl을 쓸 수 있습니다. 과거에는 표준 준수가 늦다는 평이 많았지만 최근에는 /permissive-가 새 프로젝트의 기본값이 되는 등 많이 개선되었습니다. C++20 모듈은 오히려 MSVC가 가장 먼저 실용적인 수준으로 구현했습니다.

REM MSVC (개발자 명령 프롬프트)
cl /std:c++20 /O2 /EHsc main.cpp

최적화 플래그와 성능

기본 최적화 수준 비교

수준GCCClangMSVC용도
없음-O0-O0/Od디버깅
가벼운 최적화-O1-O1대응 옵션 없음디버깅 가능성과 속도의 절충
일반 배포-O2-O2/O2 (속도 우선)대부분의 릴리스 빌드
더 공격적-O3-O3없음 (/O2가 최고 수준)계산 위주 코드
크기 우선-Os-Os, -Oz/O1임베디드, 작은 바이너리

MSVC의 /O1은 GCC의 -O1과 이름만 비슷할 뿐, 의미는 “크기 최소화”라는 점에 주의하세요.

컴파일러별 주요 플래그

GCC

# 디버그 빌드 (최적화 없음)
g++ -O0 -g -Wall main.cpp -o app_debug
# 릴리스 기본
g++ -O2 -DNDEBUG main.cpp -o app
# 최대 성능 (빌드한 머신에서만 실행할 때)
g++ -O3 -march=native -DNDEBUG main.cpp -o app_fast
# LTO (링크 타임 최적화)
g++ -O3 -flto main.cpp -o app_lto
# PGO 1단계: 계측 빌드
g++ -O3 -fprofile-generate main.cpp -o app_pgo
./app_pgo            # 대표 워크로드 실행 → 오브젝트별 .gcda 파일 생성
# PGO 2단계: 프로파일 기반 재빌드
g++ -O3 -fprofile-use main.cpp -o app_pgo_final

Clang

# 기본 릴리스
clang++ -O2 -DNDEBUG main.cpp -o app
# 최대 성능
clang++ -O3 -march=native -DNDEBUG main.cpp -o app_fast
# LTO (ThinLTO는 -flto=thin)
clang++ -O3 -flto main.cpp -o app_lto
# PGO 1단계: 계측 빌드
clang++ -O3 -fprofile-instr-generate main.cpp -o app_pgo
./app_pgo            # default.profraw 생성
llvm-profdata merge -output=default.profdata default.profraw
# PGO 2단계
clang++ -O3 -fprofile-instr-use=default.profdata main.cpp -o app_pgo_final

MSVC

REM 디버그
cl /Od /Zi /EHsc main.cpp
REM 릴리스 기본
cl /O2 /DNDEBUG /EHsc main.cpp
REM 전체 프로그램 최적화 + 링크 타임 코드 생성
cl /O2 /GL /DNDEBUG /EHsc main.cpp /link /LTCG
REM AVX2 명령어 사용
cl /O2 /arch:AVX2 /DNDEBUG /EHsc main.cpp
REM PGO: /GL로 컴파일 → /LTCG /GENPROFILE로 링크 → 실행해 .pgc 수집 → /LTCG /USEPROFILE로 다시 링크
cl /O2 /GL /EHsc /c main.cpp
link /LTCG /GENPROFILE main.obj /OUT:app.exe
app.exe
link /LTCG /USEPROFILE main.obj /OUT:app.exe

최적화 기법별 효과

기법효과가 큰 코드컴파일/링크 시간주의사항
-O2 → -O3캐시에 들어가는 데이터를 다루는 계산 위주 루프늘어남코드 크기 증가로 -O2가 더 나은 경우도 있음
-march=native벡터화 가능한 수치 루프 (AVX2·FMA 활용)거의 동일다른 CPU에서 실행 불가
LTO / LTCG작은 함수가 여러 파일에 흩어져 핫 경로에서 호출되는 코드링크 시간 크게 증가메모리 사용량 증가
PGO분기가 많고 실행 경로가 치우친 코드 (파서, 인터프리터, 서버)2회 빌드 필요프로파일 워크로드가 운영과 달라지면 역효과

효과의 크기는 코드마다 거의 없음부터 크게까지 달라지므로, 표의 “효과가 큰 코드”에 해당하는지 먼저 따져 보고 적용 전후를 측정하세요.

세부 제어 플래그

목적GCCClangMSVC
루프 언롤링-funroll-loops (-O3에도 기본으로는 꺼져 있음)-funroll-loops (-O2부터 기본 활성)별도 스위치 없음
인라인 공격성-finline-limit=N, --param 계열-mllvm -inline-threshold=N/Ob1, /Ob2, /Ob3
벡터화 보고-fopt-info-vec-Rpass=loop-vectorize/Qvec-report:2
경고-Wall -Wextra-Wall -Wextra/W4

C++ 표준 지원 비교

표준별 지원 현황 (2026년 기준, 최신 안정 버전)

표준GCCClangMSVC
C++17까지완전 지원완전 지원완전 지원
C++20대부분 (모듈은 구현이 계속 개선 중)대부분 (모듈은 빌드 시스템 지원 필요)대부분 (모듈 지원이 가장 앞섬)
C++23언어 기능 대부분언어 기능 대부분단계적 지원 (/std:c++latest)

표준 라이브러리 기능은 컴파일러가 아니라 함께 쓰는 libstdc++·libc++·MSVC STL 버전에 따라 지원 여부가 갈립니다. 기능 하나를 쓰기 전에는 cppreference의 Compiler support 표에서 그 기능 단위로 확인하는 것이 가장 정확합니다.

표준 활성화 방법

# GCC / Clang
g++ -std=c++20 main.cpp
clang++ -std=c++23 main.cpp
REM MSVC
cl /std:c++20 main.cpp
REM 최신 초안 기능까지 (안정성 보장 없음)
cl /std:c++latest main.cpp

표준 라이브러리 구현 차이

구현사용처sizeof(std::string) (64비트)SSO 최대 길이
libstdc++GCC, Linux의 Clang32바이트15자
libc++macOS의 Clang, -stdlib=libc++24바이트22자
MSVC STLMSVC, clang-cl32바이트 (릴리스 빌드)15자

SSO(Small String Optimization)는 짧은 문자열을 힙 할당 없이 std::string 객체 안에 저장하는 최적화입니다. 같은 길이 16~22자의 문자열이 libc++에서는 할당 없이, libstdc++와 MSVC STL에서는 힙 할당으로 처리되므로, 짧은 문자열을 대량으로 만드는 코드는 표준 라이브러리에 따라 성능이 달라질 수 있습니다.


플랫폼별 특성

OS별 권장 컴파일러

flowchart TD
  A[프로젝트 타겟] --> B{OS?}
  B -->|Linux| C[GCC 권장]
  B -->|macOS/iOS| D[Apple Clang]
  B -->|Windows| E[MSVC 권장]
  B -->|크로스플랫폼| F[각 OS별 해당 컴파일러]
  C --> G[대안: Clang + libstdc++]
  E --> I[대안: clang-cl]

플랫폼 특화 기능

Linux의 GCC에서는 __attribute__((visibility("hidden")))과 -fvisibility=hidden으로 공유 라이브러리가 내보내는 심볼을 줄이고, -static-libgcc·-static-libstdc++로 런타임을 정적으로 묶어 배포 환경의 libstdc++ 버전 차이를 피할 수 있습니다. macOS의 Clang은 Apple Silicon과 Xcode·Instruments에 통합되어 있고 표준 라이브러리는 libc++가 기본입니다. Windows의 MSVC에서는 __declspec(dllexport)·__declspec(dllimport)로 DLL 경계를 정하고, /MD·/MT로 C 런타임을 동적 또는 정적으로 링크합니다.

크로스 컴파일 예시

# GCC로 ARM64 Linux용 크로스 컴파일 (aarch64 크로스 툴체인 설치 필요)
aarch64-linux-gnu-g++ -std=c++17 -O2 -o app_arm main.cpp
# Clang으로 Windows 타깃 (MinGW-w64 헤더·라이브러리가 설치된 sysroot 필요)
clang++ --target=x86_64-w64-windows-gnu -std=c++17 -O2 -o app.exe main.cpp

Clang은 하나의 바이너리가 여러 타깃을 지원하므로 --target만 바꾸면 되지만, 그 타깃의 헤더와 라이브러리(sysroot)는 따로 준비해야 합니다. GCC는 타깃마다 별도로 빌드된 크로스 컴파일러를 설치합니다.


자주 발생하는 에러와 해결법

표준 라이브러리 ABI 불일치 (undefined reference)

undefined reference to `std::__cxx11::basic_string<char, ...>::~basic_string()'

Linux에서 GCC와 Clang은 기본적으로 같은 libstdc++를 쓰므로, 컴파일러를 섞는 것만으로 이런 에러가 나지는 않습니다. 이 에러의 흔한 원인은 두 가지입니다. 하나는 GCC 5에서 바뀐 std::string·std::list ABI입니다. 오래된 툴체인이나 -D_GLIBCXX_USE_CXX11_ABI=0으로 빌드한 라이브러리는 __cxx11이 붙지 않은 심볼을 내보내므로, 새 ABI로 빌드한 코드와 링크하면 위와 같은 심볼을 찾지 못합니다. 다른 하나는 한쪽만 -stdlib=libc++로 빌드한 경우로, libc++와 libstdc++는 서로 다른 타입이라 링크되지 않습니다.

# 방법 1: 라이브러리와 앱을 같은 표준 라이브러리·같은 ABI 매크로로 빌드
g++ -D_GLIBCXX_USE_CXX11_ABI=0 main.cpp -L. -lmylib -o app   # 라이브러리가 구 ABI라면 맞춤
# 방법 2: Clang에서 libstdc++를 명시 (Linux 기본값이지만 libc++로 바꿔 둔 환경이라면)
clang++ -stdlib=libstdc++ -std=c++17 main.cpp -L. -lmylib -o app

라이브러리를 서로 다른 팀이나 벤더가 빌드한다면, 경계에서 std::string 같은 표준 라이브러리 타입을 주고받지 않고 C 인터페이스로 감싸는 방법이 가장 확실합니다.

// mylib.h — C 인터페이스로 ABI 고정
#include <stddef.h>
#ifdef __cplusplus
extern "C" {
#endif
void mylib_process(const char* input, char* output, size_t len);
#ifdef __cplusplus
}
#endif

링크 순서 문제

undefined reference to `foo'

GNU ld는 명령줄을 왼쪽에서 오른쪽으로 한 번 훑으면서, 정적 라이브러리(.a)에서는 그 시점까지 해결되지 않은 심볼을 제공하는 오브젝트만 꺼내 옵니다. 그래서 foo를 쓰는 쪽이 라이브러리보다 먼저 와야 합니다.

# 잘못된 순서: -lmylib을 처리할 때는 아직 foo가 필요하지 않아 아무것도 꺼내지 않음
g++ -o app -lmylib main.cpp
# 올바른 순서: 사용하는 쪽(main.cpp) 먼저, 제공하는 쪽(-lmylib) 나중
g++ -o app main.cpp -lmylib
# 라이브러리끼리 서로 참조하면 그룹으로 묶어 반복 탐색
g++ -o app main.cpp -Wl,--start-group -lmylib -lother -Wl,--end-group

macOS의 ld64와 LLVM의 lld는 순서에 덜 민감하므로, Linux에서만 이 에러가 나는 경우가 많습니다.

MSVC: 런타임 라이브러리 불일치 (LNK2038)

LNK2038: mismatch detected for 'RuntimeLibrary': value 'MT_StaticRelease' doesn't match value 'MD_DynamicRelease'

MSVC는 정적 CRT(/MT, /MTd)와 DLL CRT(/MD, /MDd), 그리고 디버그·릴리스마다 서로 다른 런타임을 쓰며, 설정이 다른 오브젝트나 라이브러리를 한 바이너리에 섞으면 링커가 LNK2038로 알려 줍니다. 디버그 라이브러리와 릴리스 라이브러리를 섞으면 _ITERATOR_DEBUG_LEVEL 값 불일치로도 같은 에러가 납니다.

REM 프로젝트 전체에서 동일하게
cl /MD /O2 /EHsc main.cpp utils.cpp
REM 또는
cl /MT /O2 /EHsc main.cpp utils.cpp

/MD는 런타임 DLL을 공유하므로 여러 DLL이 메모리와 CRT 상태를 함께 쓸 수 있어 일반적으로 권장되고, /MT는 배포할 파일이 줄어드는 대신 바이너리가 커지고 DLL 경계에서 힙을 공유하지 못합니다. CMake에서는 CMAKE_MSVC_RUNTIME_LIBRARY로 한곳에서 통일하고, vcpkg를 쓴다면 triplet도 맞춰야 합니다.

GCC/Clang: std::filesystem 링크 에러

undefined reference to `std::filesystem::exists(std::filesystem::__cxx11::path const&)'
# GCC 8: filesystem이 별도 라이브러리
g++ -std=c++17 main.cpp -lstdc++fs -o app
# Clang 7~8 + libc++: -lc++fs 필요
# GCC 9+ / Clang 9+ 에서는 추가 링크가 필요 없음

LTO와 정적 라이브러리

-flto로 만든 오브젝트에는 기계어 대신 컴파일러 중간 표현이 들어 있습니다. 그래서 링크할 때도 -flto를 줘야 하고, 정적 라이브러리를 만들 때는 LTO 플러그인을 읽을 수 있는 아카이버를 써야 합니다. 그렇지 않으면 plugin needed to handle lto object 경고와 함께 심볼 테이블이 비어 undefined reference가 납니다.

# 모든 오브젝트를 -flto로 컴파일하고, 링크에도 -flto
g++ -O3 -flto -c a.cpp -o a.o
g++ -O3 -flto -c b.cpp -o b.o
g++ -O3 -flto a.o b.o -o app
# 정적 라이브러리는 플러그인을 로드하는 gcc-ar로 (Clang은 llvm-ar)
g++ -O3 -flto -c mylib.cpp -o mylib.o
gcc-ar rcs libmylib.a mylib.o
g++ -O3 -flto main.o -L. -lmylib -o app

MSVC: std::min/std::max 매크로 충돌

error C2589: '(': illegal token on right side of '::'

windows.h가 min·max를 함수형 매크로로 정의하기 때문에 std::min(a, b)가 매크로로 치환되어 깨집니다.

// 방법 1: windows.h를 포함하기 전에 NOMINMAX 정의
#define NOMINMAX
#include <windows.h>
#include <algorithm>
// 방법 2: 괄호로 감싸 매크로 치환을 막기
int x = (std::min)(a, b);

-march=native 바이너리를 다른 CPU에서 실행할 때

Illegal instruction (core dumped)

-march=native는 빌드한 CPU가 지원하는 모든 명령어(AVX-512 등)를 쓰므로, 그보다 오래되거나 다른 CPU에서 실행하면 지원하지 않는 명령어에서 죽습니다. 배포용 바이너리는 x86-64 마이크로아키텍처 레벨로 하한을 정합니다.

g++ -O3 -march=x86-64-v2 main.cpp -o app
# 또는 AVX2까지 쓸 수 있는 환경이 확실하다면
g++ -O3 -march=x86-64-v3 main.cpp -o app
옵션포함 명령어대략적인 하한 CPU
-march=x86-64SSE2모든 64비트 x86
-march=x86-64-v2SSE4.2, POPCNT 등Intel Nehalem(2008)·AMD Bulldozer 이후
-march=x86-64-v3AVX2, FMA, BMI 등Intel Haswell(2013)·AMD Excavator 이후 (AVX가 없는 일부 저가형 제외)
-march=native빌드 머신 전체빌드한 머신과 같은 CPU

MSVC: UTF-8 소스 인코딩 문제

warning C4819: The file contains a character that cannot be represented in the current code page

BOM 없는 UTF-8 소스를 MSVC는 시스템 코드 페이지(한국어 Windows는 CP949)로 읽기 때문에 한글 문자열이나 주석에서 경고가 나고, 문자열 리터럴이 깨질 수 있습니다. /utf-8은 소스 문자 집합과 실행 문자 집합을 모두 UTF-8로 지정합니다.

cl /utf-8 /std:c++17 /EHsc main.cpp

옵션을 줄 수 없는 환경이라면 소스 파일을 BOM이 있는 UTF-8로 저장해도 MSVC가 UTF-8로 인식합니다.

PGO 프로파일 불일치

소스를 고친 뒤 예전 프로파일로 다시 빌드하면 Clang은 profile data may be out of date 경고(-Wprofile-instr-out-of-date)를, GCC는 coverage mismatch 경고를 냅니다. 바뀐 함수에는 프로파일이 적용되지 않으므로, 소스를 크게 바꿨다면 계측 빌드부터 다시 실행해 프로파일을 새로 모아야 합니다.

# Clang: 여러 실행의 프로파일을 병합해 사용
llvm-profdata merge -output=merged.profdata *.profraw
clang++ -O3 -fprofile-instr-use=merged.profdata main.cpp -o app

직접 해 보는 벤치마크

아래 코드는 직접 빌드해 비교해 볼 수 있는 예제입니다. 결과는 CPU·OS·컴파일러 버전에 따라 크게 달라지므로 수치 대신 결과를 읽는 법을 적었습니다.

벤치마크 1: 벡터 루프 (정수 쓰기)

// benchmark_vector.cpp
#include <vector>
#include <chrono>
#include <iostream>
int main() {
    const size_t SIZE = 10'000'000;
    std::vector<int> data(SIZE);
    auto start = std::chrono::steady_clock::now();
    for (size_t i = 0; i < SIZE; ++i) {
        data[i] = static_cast<int>(i * 2);
    }
    auto end = std::chrono::steady_clock::now();
    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    std::cout << "Time: " << ms << " ms, check=" << data[SIZE / 2] << "\n";
    return 0;
}

-O0와 -O2의 차이는 분명하게 나오지만, -O2·-O3·-march=native 사이의 차이는 작을 가능성이 큽니다. 이 루프는 4바이트씩 40MB를 쓰는 작업이라 계산보다 메모리 대역폭이 병목이고, GCC 12부터는 -O2에서도 이런 단순 루프를 벡터화하기 때문입니다. Clang은 -O2에서도 벡터화를 켜 왔습니다. 결과 일부를 출력하는 것은 컴파일러가 쓰기 결과를 쓸모없다고 보고 루프를 지워 버리는 것을 막기 위해서입니다.

벤치마크 2: 문자열 연결 (std::string)

// benchmark_string.cpp
#include <string>
#include <chrono>
#include <iostream>
int main() {
    const int ITERS = 100'000;
    auto start = std::chrono::steady_clock::now();
    std::string s;
    for (int i = 0; i < ITERS; ++i) {
        s += "hello";
    }
    auto end = std::chrono::steady_clock::now();
    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    std::cout << "Time: " << ms << " ms, size=" << s.size() << "\n";
    return 0;
}

이 코드는 컴파일러보다 표준 라이브러리의 재할당 전략(용량을 몇 배씩 늘리는가)과 할당자 성능을 재는 쪽에 가깝습니다. 같은 Clang이라도 libstdc++와 libc++로 바꿔 빌드하면 결과가 달라질 수 있으므로, 비교할 때는 어떤 표준 라이브러리를 썼는지 함께 기록해야 합니다.

벤치마크 3: 행렬 곱셈

// benchmark_matrix.cpp — 단순 삼중 루프
#include <vector>
#include <chrono>
#include <iostream>
void matmul(const std::vector<double>& A, const std::vector<double>& B,
            std::vector<double>& C, int N) {
    for (int i = 0; i < N; ++i)
        for (int j = 0; j < N; ++j) {
            double sum = 0;
            for (int k = 0; k < N; ++k)
                sum += A[i*N+k] * B[k*N+j];
            C[i*N+j] = sum;
        }
}
int main() {
    const int N = 512;
    std::vector<double> A(N*N, 1.0), B(N*N, 1.0), C(N*N, 0.0);
    auto start = std::chrono::steady_clock::now();
    matmul(A, B, C, N);
    auto end = std::chrono::steady_clock::now();
    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    std::cout << "Time: " << ms << " ms, C[0]=" << C[0] << "\n";
    return 0;
}

가장 안쪽 루프가 B[k*N+j]를 N 간격으로 읽는 데다, sum 누적은 부동소수점 덧셈 순서를 바꿔야 벡터화할 수 있어 -ffast-math 없이는 이 형태 그대로 벡터화되지 않는 경우가 많습니다. 루프 순서를 i-k-j로 바꾸면 안쪽 루프가 C와 B의 연속 원소를 다루게 되어 벡터화가 쉬워지고, 그때 -march=native(/arch:AVX2)의 효과가 드러납니다. 컴파일러 옵션 비교보다 이런 코드 형태가 결과를 더 크게 좌우한다는 점을 확인해 보세요. 결국 “이 컴파일러가 무조건 빠르다”는 결론은 나오지 않으며, 실제 타깃 코드로 직접 측정하는 것이 가장 확실합니다.


프로덕션 패턴과 권장사항

프로젝트 유형별 권장

프로젝트 유형1순위2순위비고
Linux 서버GCCClang배포 환경과 동일하게
macOS/iOSApple Clang—Xcode 기본
Windows 전용MSVCclang-clVS 통합 활용
크로스플랫폼각 OS별 해당—CI에서 모두 테스트
임베디드GCCClang아키텍처 지원 확인
HPC/수치GCC 또는 Clang—-march, LTO, PGO 검토

여러 컴파일러로 함께 빌드하는 CI는 그 자체로 버그를 찾는 도구이기도 합니다. 한 컴파일러에서만 나는 경고나 동작 차이는 대개 미정의 동작이나 구현 정의 동작에 기대고 있다는 신호입니다.

패턴 1: Docker 멀티 스테이지 빌드

# Dockerfile — GCC로 빌드, 최소 런타임 이미지
FROM gcc:13-bookworm AS builder
WORKDIR /app
COPY . .
RUN g++ -std=c++20 -O2 -DNDEBUG -static-libstdc++ -static-libgcc -o app main.cpp
FROM debian:bookworm-slim
COPY --from=builder /app/app /usr/local/bin/
CMD ["app"]

빌드 이미지의 GCC 13이 런타임 이미지(Debian bookworm의 GCC 12 계열)보다 새 libstdc++를 쓰므로, -static-libstdc++ 없이 동적 링크하면 실행 시 GLIBCXX_3.4.xx not found 에러가 날 수 있습니다. 그래서 C++ 런타임을 정적으로 묶었습니다.

패턴 2: GitHub Actions 멀티 컴파일러 CI

# .github/workflows/build.yml
name: Multi-Compiler Build
on: [push]
jobs:
  build:
    strategy:
      matrix:
        include:
          - cxx: g++-13
            pkg: g++-13
          - cxx: clang++-17
            pkg: clang-17
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - name: Install ${{ matrix.pkg }}
        run: sudo apt-get update && sudo apt-get install -y ${{ matrix.pkg }}
      - name: Build
        run: ${{ matrix.cxx }} -std=c++20 -O2 -Wall -Wextra -o app main.cpp
      - name: Test
        run: ./app

패턴 3: CMake 멀티 컴파일러 지원

# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(MyApp LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

add_executable(app main.cpp)

# 경고
if(MSVC)
    target_compile_options(app PRIVATE /W4)
else()
    target_compile_options(app PRIVATE -Wall -Wextra)
endif()

# 배포용 하한 CPU (x86-64에서 GCC·Clang일 때만)
if(CMAKE_SYSTEM_PROCESSOR MATCHES "x86_64|AMD64" AND NOT MSVC)
    target_compile_options(app PRIVATE $<$<CONFIG:Release>:-march=x86-64-v2>)
endif()

최적화 수준은 CMake가 빌드 타입별로 이미 넣어 줍니다(GCC·Clang의 Release는 -O3 -DNDEBUG, MSVC는 /O2). 그래서 여기서는 -O 옵션을 따로 추가하지 않았고, -march는 ARM 머신에서 빌드할 때 오류가 나지 않도록 x86-64일 때만 붙입니다.

릴리스 빌드 스크립트 예시

#!/bin/bash
# release_build.sh — GCC/Clang 공용
set -euo pipefail
CXX=${CXX:-g++}
$CXX -std=c++20 -O2 -DNDEBUG -march=x86-64-v2 \
    -Wall -Wextra -flto \
    -o app_release main.cpp
strip app_release
echo "Built: app_release"

디버그 심볼이 필요하다면 strip 대신 objcopy --only-keep-debug로 심볼을 별도 파일로 떼어 보관해 두면, 운영에서 받은 크래시 덤프를 나중에 분석할 수 있습니다.


전체 시리즈


참고 자료


같이 보면 좋은 글