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 상세 비교
개요 비교표
| 항목 | GCC | Clang | MSVC |
|---|---|---|---|
| 개발 주체 | 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 NDK | Windows |
| 지원 아키텍처 | 가장 넓음 (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
최적화 플래그와 성능
기본 최적화 수준 비교
| 수준 | GCC | Clang | MSVC | 용도 |
|---|---|---|---|---|
| 없음 | -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회 빌드 필요 | 프로파일 워크로드가 운영과 달라지면 역효과 |
효과의 크기는 코드마다 거의 없음부터 크게까지 달라지므로, 표의 “효과가 큰 코드”에 해당하는지 먼저 따져 보고 적용 전후를 측정하세요.
세부 제어 플래그
| 목적 | GCC | Clang | MSVC |
|---|---|---|---|
| 루프 언롤링 | -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년 기준, 최신 안정 버전)
| 표준 | GCC | Clang | MSVC |
|---|---|---|---|
| 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의 Clang | 32바이트 | 15자 |
| libc++ | macOS의 Clang, -stdlib=libc++ | 24바이트 | 22자 |
| MSVC STL | MSVC, clang-cl | 32바이트 (릴리스 빌드) | 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-64 | SSE2 | 모든 64비트 x86 |
-march=x86-64-v2 | SSE4.2, POPCNT 등 | Intel Nehalem(2008)·AMD Bulldozer 이후 |
-march=x86-64-v3 | AVX2, 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 서버 | GCC | Clang | 배포 환경과 동일하게 |
| macOS/iOS | Apple Clang | — | Xcode 기본 |
| Windows 전용 | MSVC | clang-cl | VS 통합 활용 |
| 크로스플랫폼 | 각 OS별 해당 | — | CI에서 모두 테스트 |
| 임베디드 | GCC | Clang | 아키텍처 지원 확인 |
| 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로 심볼을 별도 파일로 떼어 보관해 두면, 운영에서 받은 크래시 덤프를 나중에 분석할 수 있습니다.
전체 시리즈
- #0: C++이란? 역사·현황·용도·장단점
- #1: 개발 환경 구축
- #2: 컴파일러 비교 (현재 글)
- #3: VS Code 개발 환경 설정
- #4: CMake 입문
- #5: 컴파일 과정 분석