C++ 컴파일러 비교 | GCC vs Clang vs MSVC, 어떤 걸 써야 할까?
이 글의 핵심
C++ 컴파일러 비교에 대한 실전 가이드입니다. GCC vs Clang vs MSVC, 어떤 걸 써야 할까? 등을 예제와 함께 설명합니다.
[C++ 실전 가이드 #2-1] C++ 컴파일러 기초
”같은 코드인데 왜 빌드 결과가 다를까?”
컴파일러(소스 코드를 기계어·실행 파일로 바꿔 주는 도구)는 C++ 개발의 핵심입니다. 흥미롭게도, 같은 C++ 코드라도 어떤 컴파일러로 빌드하느냐에 따라 실행 속도가 최대 30% 차이날 수 있습니다. “Linux에서는 잘 되는데 Windows에서 느려요”, “에러 메시지가 너무 짧아서 원인을 못 찾겠어요” 같은 고민은 대부분 컴파일러 선택과 옵션 설정에서 비롯됩니다.
이 글을 읽으면 GCC, Clang, MSVC의 역할과 선택 기준을 이해하고, 프로젝트에 맞는 컴파일러를 고를 수 있습니다. 컴파일 4단계를 알면 에러가 났을 때 “전처리 문제인지, 링크 문제인지”를 빠르게 구분할 수 있어 디버깅 시간이 줄어듭니다.
💡 당장 코딩부터 하고 싶다면 — 컴파일러 상세는 나중에 봐도 됩니다. → #3 VS Code 개발 환경으로 넘어가면 빌드·디버깅을 바로 시작할 수 있습니다.
실무에서 겪는 문제 시나리오
컴파일러 기초를 알아두면 아래와 같은 상황에서 원인을 빠르게 찾을 수 있습니다.
시나리오 1: “팀원 PC에서는 되는데 제 PC에서만 안 돼요”
동일한 소스인데 빌드 결과가 다를 때, 컴파일러 버전·최적화 옵션(-O0 vs -O2) 차이일 가능성이 큽니다. g++ --version, clang++ --version으로 버전을 맞추고, CMake나 Makefile에 -std=c++17 같은 표준을 명시하면 재현이 쉬워집니다.
시나리오 2: “헤더를 찾을 수 없다고 하는데, 파일은 있는데요”
#include "my_header.h"에서 No such file or directory가 나면 전처리 단계의 경로 문제입니다. -I include/로 헤더 검색 경로를 추가하거나, 상대 경로를 확인해야 합니다.
시나리오 3: “컴파일은 되는데 링크에서 undefined reference가 나요”
컴파일은 성공했지만 링커가 함수·변수 정의를 찾지 못하는 경우입니다. 선언만 있고 정의가 없거나, 다른 .cpp에 정의가 있는데 링크 대상에 포함하지 않았을 때 발생합니다. g++ main.cpp utils.cpp -o app처럼 필요한 .cpp를 모두 링크해야 합니다.
시나리오 4: “디버깅할 때 변수 값이 optimized out으로만 보여요”
최적화(-O2, -O3)가 켜져 있으면 사용하지 않는 변수가 제거되거나 인라인되어 디버거에서 값을 볼 수 없습니다. 디버깅 시에는 -O0 -g로 빌드해야 합니다.
시나리오 5: “한 파일만 수정했는데 전체가 다시 컴파일돼요”
컴파일 단계(소스 → 오브젝트)와 링크 단계(오브젝트 → 실행 파일)를 분리하지 않으면, 빌드 시스템이 변경된 파일만 재컴파일하지 못합니다. -c로 오브젝트를 따로 만들고 링커로 합치는 방식이면 증분 빌드가 가능합니다.
시나리오 6: “Linux에서는 되는데 Windows에서만 크래시해요”
미정의 동작(undefined behavior)이나 구현 정의(implementation-defined) 동작에 의존했을 때, GCC/Clang과 MSVC가 다른 코드를 생성해 플랫폼별로 동작이 달라질 수 있습니다. -Wall -Wextra -pedantic로 경고를 켜고, 여러 컴파일러로 빌드해 보면 이식성 문제를 줄일 수 있습니다.
목차
1. 컴파일러의 역할
컴파일러는 우리가 작성한 C++ 소스 코드를 컴퓨터가 실행할 수 있는 기계어로 변환하는 도구입니다. 이 과정은 크게 네 단계로 이루어집니다. 비유하자면 컴파일러는 원고(소스)를 검토·정리·번역해 최종 책(실행 파일)으로 만드는 편집자와 같고, 각 단계는 원고 정리(전처리), 문법 검사(구문 분석), 문장 다듬기(최적화), 최종 인쇄용 파일 만들기(코드 생성)에 대응합니다.
에러가 날 때 “전처리 문제인지, 문법 문제인지, 링크 문제인지”를 구분할 수 있으면 원인을 찾기 훨씬 수월해지므로, 각 단계가 무엇을 하는지 한 번쯤 짚고 넘어가는 것이 좋습니다.
컴파일 과정 시각화
flowchart TB
subgraph step1["1단계: 전처리"]
A[.cpp 소스] --> B["#include 확장"]
B --> C["#define 매크로 치환"]
C --> D[전처리된 .i 파일]
end
subgraph step2["2단계: 구문 분석"]
D --> E[문법 검사]
E --> F[AST 생성]
end
subgraph step3["3단계: 최적화"]
F --> G[불필요 코드 제거]
G --> H[인라인·벡터화]
end
subgraph step4["4단계: 코드 생성"]
H --> I[기계어 생성]
I --> J[.o 오브젝트]
end
J --> K[링커]
K --> L[실행 파일]
1단계: 전처리 (Preprocessing)
소스 코드를 본격적으로 컴파일하기 전에 준비 작업을 수행합니다. 쉽게 말해 “원고에 붙어 있는 메모(#include, #define)를 실제 내용으로 바꾸고, 조건에 따라 넣을 부분만 골라 넣는” 단계입니다.
#include지시문을 처리하여 헤더 파일의 내용을 삽입합니다.#define으로 정의한 매크로를 실제 값으로 확장합니다.#ifdef,#ifndef같은 조건부 컴파일 지시문을 처리합니다.
전처리 결과 확인하기: -E 옵션으로 전처리만 수행한 결과를 볼 수 있습니다. #include가 어떻게 펼쳐지는지, 매크로가 어떻게 치환되는지 확인할 때 유용합니다.
// preprocess_demo.cpp - 전처리 동작 확인용
#define PI 3.14159
#define SQUARE(x) ((x) * (x))
#include <iostream>
int main() {
std::cout << "PI = " << PI << std::endl;
std::cout << "SQUARE(5) = " << SQUARE(5) << std::endl;
return 0;
}
# 전처리만 수행 (결과가 길어지므로 head로 일부만 확인)
g++ -E preprocess_demo.cpp -o preprocess_demo.ii
head -50 preprocess_demo.ii
전처리 결과 예시 (매크로 치환 확인): #include <iostream>가 수천 줄로 확장되므로, 아래는 #define 치환만 보여주는 간단한 예입니다.
// 매크로만 사용하는 minimal.cpp
#define PI 3.14159
#define SQUARE(x) ((x) * (x))
int main() { return SQUARE(3); }
g++ -E minimal.cpp 2>/dev/null | tail -5
# 3 "minimal.cpp"
int main() { return ((3) * (3)); }
SQUARE(3)이 ((3) * (3))로 치환된 것을 확인할 수 있습니다.
2단계: 구문 분석 (Parsing)
전처리된 코드를 분석합니다. 정의를 풀어 쓰면 “구문 분석”은 “문장 구조가 문법에 맞는지 확인하고, 나무 형태의 구조(AST)로 만드는 것”입니다.
- 문법 오류를 검사합니다.
- 추상 구문 트리(AST, Abstract Syntax Tree—코드를 나무 구조로 표현한 것으로, 예를 들면 “이 줄은 함수 호출이고, 인자는 저 식이다”처럼 계층적으로 파악할 수 있게 해 줍니다)를 생성하여 코드 구조를 파악합니다.
3단계: 최적화 (Optimization)
이 단계에서 컴파일러마다 가장 큰 차이가 발생합니다. 비유하면 “번역문을 읽기 쉽고 빠르게 다듬는 작업”인데, 어떤 문장을 줄이고 어떤 표현을 바꿀지는 컴파일러마다 다릅니다.
- 불필요한 코드를 제거합니다.
- 루프 언롤링으로 반복문 성능을 향상시킵니다.
- 자주 호출되는 작은 함수를 인라인화합니다.
- 변수 할당을 최적화합니다.
4단계: 코드 생성 (Code Generation)
최종적으로 기계어를 생성합니다. 정의를 풀어 쓰면 “기계어”는 “CPU가 직접 실행하는 0과 1의 명령 집합”입니다.
- CPU 아키텍처에 맞는 명령어로 변환합니다.
- 특정 CPU의 고급 명령어(SIMD(Single Instruction Multiple Data—한 번의 명령으로 여러 데이터를 동시에 처리하는 방식으로, 예를 들면 4개 float를 한 번에 더하는 것) 등)를 활용합니다.
에러가 났을 때: “전처리 오류”는 #include 경로나 매크로 확장 문제일 때, “구문 오류”는 문법이 잘못되었을 때, “링크 오류(undefined reference)“는 선언만 있고 정의가 없거나 다른 번역 단위에 있을 때 자주 납니다. 에러 메시지에 “undefined reference”가 보이면 제5편 컴파일 과정에서 다루는 링커 단계 문제일 가능성이 큽니다.
컴파일러마다 결과가 다른 이유
flowchart LR
subgraph gcc["GCC"]
G1[안정적 최적화]
G2[예측 가능]
end
subgraph clang["Clang"]
C1[공격적 최적화]
C2[친절한 에러]
end
subgraph msvc["MSVC"]
M1[Windows 특화]
M2[API 최적화]
end
최적화 알고리즘의 차이:
- GCC는 안정적이고 예측 가능한 최적화를 수행합니다.
- Clang은 모던한 알고리즘으로 공격적인 최적화를 시도합니다.
- MSVC는 Windows 플랫폼에 특화된 최적화를 수행합니다.
표준 라이브러리 구현 차이:
std::vector,std::string같은 표준 라이브러리의 내부 구현이 컴파일러마다 다릅니다.- 예를 들어, 작은 문자열 최적화(SSO, Small String Optimization—짧은 문자열은 힙 할당 대신 객체 안에 직접 넣어 메모리·속도를 아끼는 기법)의 임계값이 GCC는 15바이트, Clang은 22바이트입니다.
에러 메시지의 차이:
- Clang은 매우 친절하고 자세한 에러 메시지를 제공합니다.
- GCC는 간결하지만 충분한 정보를 제공합니다.
- MSVC는 Windows 스타일의 에러 형식을 사용합니다.
실무에서는 한 플랫폼에서만 쓴다면 해당 OS 권장 컴파일러(Windows면 MSVC, Linux면 GCC 등)를 쓰고, 여러 플랫폼을 지원해야 하면 CI(Continuous Integration, 지속적 통합—코드를 저장소에 올릴 때마다 자동으로 빌드·테스트를 돌려 문제를 미리 찾는 방식)에서 각 컴파일러로 한 번씩 빌드해 보는 것이 버그를 줄이는 데 도움이 됩니다.
한 줄 요약: 디버깅할 때는 최적화를 끈 -O0로, 배포용은 보통 -O2로 빌드하는 경우가 많고, “어느 컴파일러로 빌드했는지”를 문서나 CI에 남겨 두면 나중에 이식성·버그 재현이 수월해집니다.
에러 단계별 구분 요약
에러 메시지를 보고 어느 단계에서 문제가 났는지 빠르게 파악하는 요령입니다:
| 에러 유형 | 키워드 예시 | 대응 |
|---|---|---|
| 전처리 | No such file, #include | 헤더 경로(-I), 매크로 정의 확인 |
| 구문 분석 | expected, syntax error, was not declared | 문법·선언·헤더 포함 확인 |
| 링크 | undefined reference, multiple definition | 정의 위치, 링크 대상 .cpp 확인 |
이 구분을 알면 “헤더를 찾을 수 없다”는 전처리 문제와 “함수 정의가 없다”는 링크 문제를 혼동하지 않게 됩니다.
컴파일 단계별 실습: 전처리 → 오브젝트 → 링크
실제로 각 단계를 따로 실행해 보면 컴파일 과정을 이해하기 쉽습니다. 아래 예제는 main.cpp와 utils.cpp 두 파일로 나누어, 전처리·컴파일·링크를 단계별로 수행합니다.
1단계: 소스 파일 준비
// utils.h - 유틸리티 함수 선언
#ifndef UTILS_H
#define UTILS_H
int add(int a, int b);
#endif
// utils.cpp - 유틸리티 함수 정의
#include "utils.h"
int add(int a, int b) {
return a + b;
}
// main.cpp - 메인 진입점
#include <iostream>
#include "utils.h"
int main() {
std::cout << "3 + 5 = " << add(3, 5) << std::endl;
return 0;
}
2단계: 전처리만 수행 (-E)
전처리 결과를 파일로 저장해 #include 확장과 매크로 치환을 확인할 수 있습니다.
# main.cpp 전처리 결과 (일부만 확인)
g++ -E main.cpp -o main.ii
wc -l main.ii # iostream 포함 시 수천 줄로 늘어남
head -30 main.ii
3단계: 컴파일만 수행 (-c) — 오브젝트 파일 생성
각 .cpp를 기계어 오브젝트(.o)로 변환합니다. 이 단계에서는 링크를 하지 않으므로 다른 파일에 있는 함수 정의를 찾지 않아도 됩니다.
# main.cpp → main.o, utils.cpp → utils.o
g++ -std=c++17 -c main.cpp -o main.o
g++ -std=c++17 -c utils.cpp -o utils.o
# 생성된 오브젝트 파일 확인
ls -la main.o utils.o
file main.o # ELF 64-bit LSB relocatable, x86-64
4단계: 링크 — 오브젝트를 실행 파일로 합치기
여러 오브젝트 파일과 표준 라이브러리를 링커가 하나의 실행 파일로 합칩니다. 이때 add 함수의 정의를 utils.o에서 찾습니다.
# 오브젝트 파일들을 링크하여 실행 파일 생성
g++ main.o utils.o -o myapp
# 실행
./myapp # 출력: 3 + 5 = 8
한 번에 빌드할 때 컴파일러는 내부적으로 위 단계를 모두 수행합니다. g++ main.cpp utils.cpp -o myapp은 각 .cpp를 오브젝트로 컴파일한 뒤 링크합니다.
# 한 줄로 전체 빌드 (내부적으로 -c + 링크 수행)
g++ -std=c++17 main.cpp utils.cpp -o myapp
오브젝트 파일을 건너뛰고 링크하면?
utils.cpp를 링크 대상에서 빼면 add의 정의를 찾지 못해 undefined reference to ‘add(int, int)’ 에러가 발생합니다. 이는 컴파일 단계가 아니라 링크 단계에서 발생하는 전형적인 오류입니다.
# ❌ utils.o를 빼면 링크 에러
g++ main.o -o myapp
# /usr/bin/ld: main.o: in function `main': undefined reference to `add(int, int)'
컴파일 파이프라인 요약
| 단계 | 명령 예시 | 입력 | 출력 |
|---|---|---|---|
| 전처리 | g++ -E main.cpp -o main.ii | .cpp | .ii (전처리된 소스) |
| 컴파일 | g++ -c main.cpp -o main.o | .cpp | .o (오브젝트) |
| 링크 | g++ main.o utils.o -o myapp | .o | 실행 파일 |
대규모 프로젝트에서는 증분 빌드를 위해 오브젝트를 캐시해 두고, 수정된 .cpp만 다시 컴파일한 뒤 링크합니다. Make, CMake, Ninja 같은 빌드 도구가 이 과정을 자동화합니다.
2. GCC (GNU Compiler Collection)
GCC는 1987년부터 개발되어 온 역사 깊은 컴파일러로, Linux 시스템의 표준 컴파일러입니다. 오픈소스이며 무료로 사용할 수 있습니다.
GCC의 주요 특징
뛰어난 표준 준수: GCC는 C++ 표준을 빠르게 지원하는 것으로 유명합니다. C++23 최신 기능까지 적극적으로 구현하고 있습니다.
광범위한 플랫폼 지원: x86, ARM, MIPS, RISC-V 등 거의 모든 CPU 아키텍처를 지원합니다. 임베디드 시스템부터 슈퍼컴퓨터까지 다양한 환경에서 사용됩니다.
검증된 안정성: 35년 이상의 역사를 가진 성숙한 컴파일러로, 신뢰성이 매우 높습니다.
GCC 설치 및 버전 확인
Linux에서는 대부분 기본 설치되어 있습니다. macOS는 Xcode Command Line Tools 또는 Homebrew로 설치할 수 있습니다.
# Linux (Ubuntu/Debian)
sudo apt install build-essential
# macOS (Homebrew)
brew install gcc
# 버전 확인
g++ --version
# g++ (Ubuntu 11.4.0) 11.4.0
최신 C++ 표준을 쓰려면 -std=c++17 또는 -std=c++20을 명시합니다. 기본값은 프로젝트에 따라 다를 수 있으므로, CMakeLists.txt나 Makefile에 명시하는 것이 좋습니다.
g++ -std=c++17 main.cpp -o main
최적화 옵션 이해하기
GCC는 다양한 최적화 수준을 제공합니다. -O 뒤에 붙는 숫자가 클수록 더 공격적으로 최적화하고, 컴파일 시간과 코드 크기도 늘어납니다. 디버깅할 때는 -O0로 최적화를 끄고, 실무 배포용으로는 보통 -O2를 씁니다.
아래는 터미널에서 사용하는 GCC 최적화 옵션 예시입니다. 첫 줄은 최적화를 완전히 끄고 빌드해 디버깅 시 단계 실행을 따라가기 쉽게 하고, 두 번째 줄은 가벼운 최적화로 컴파일 속도를 우선합니다. 세 번째 줄 -O2는 실무에서 가장 많이 쓰는 수준이며, 네 번째 줄 -O3는 속도를 극대화할 때만 고려합니다. 마지막 -Os는 실행 파일 크기를 줄이고 싶을 때(예: 임베디드) 사용합니다.
g++ -O0 main.cpp # 최적화 없음 (디버깅용)
g++ -O1 main.cpp # 기본 최적화 (빠른 컴파일)
g++ -O2 main.cpp # 권장 최적화 (실무 표준)
g++ -O3 main.cpp # 공격적 최적화 (최대 성능)
g++ -Os main.cpp # 크기 최적화 (임베디드 시스템용)
-O2 vs -O3: 어떤 것을 선택해야 할까?
실무에서 가장 많이 고민하는 부분입니다. 각각의 특징을 비교해보겠습니다.
| 항목 | -O2 | -O3 |
|---|---|---|
| 컴파일 시간 | 보통 | 더 김 |
| 실행 속도 | 양호 | 최대 (대부분) |
| 코드 크기 | 적당 | 20-30% 증가 가능 |
| 디버깅 | 가능 | 어려움 |
| 권장 상황 | 대부분 | 벤치마크·수치 연산 |
-O2 (대부분의 경우 권장):
- 컴파일 시간과 실행 성능의 균형이 좋습니다.
- 코드 크기 증가가 적당합니다.
- 디버깅이 어느 정도 가능합니다.
- 대부분의 상황에서 최적의 선택입니다.
-O3 (최대 성능이 필요할 때):
- 더 공격적인 함수 인라이닝을 수행합니다.
- SIMD 명령어를 활용한 벡터화(Vectorization)를 적극 시도합니다.
- 루프 언롤링을 더 많이 수행합니다.
- 코드 크기가 20-30% 증가할 수 있습니다.
- 디버깅이 매우 어려워집니다.
- 일부 코드에서는 -O2보다 느릴 수도 있습니다. (캐시 미스 증가)
왜 -O3가 때로 더 느릴 수 있을까? -O3는 루프를 더 공격적으로 펼치고(loop unrolling) 인라인을 많이 적용합니다. 그 결과 코드 크기가 커지고, CPU 캐시에 명령어가 덜 들어가면서 캐시 미스가 늘어날 수 있습니다. 그래서 “벤치마크에서는 -O3가 빠른데, 실제 워크로드에서는 -O2가 나을 때”가 있습니다. 가능하면 목표 환경에서 직접 측정해 보는 것이 좋습니다.
실제 성능 차이
아래 코드는 1000만 개 원소를 가진 vector에 루프로 값을 채우는 단순 벤치마크입니다. -O2와 -O3로 각각 빌드해 실행 시간을 재면, 같은 소스라도 최적화 수준에 따라 수십 ms 차이가 날 수 있습니다.
코드 구조를 보면, **std::vector<int> data(SIZE)로 1000만 개 정수를 담을 벡터를 만들고, high_resolution_clock::now()로 루프 시작 전·후 시각을 잰 뒤, duration_cast**로 경과 시간을 밀리초 단위로 바꿔 출력합니다. 즉 “반복문이 실제로 얼마나 걸리는지”만 측정하는 최소한의 예제라서, 최적화 옵션의 영향이 잘 드러납니다.
// 복사해 붙여넣은 뒤: g++ -std=c++17 -O2 -o bench bench.cpp && ./bench
#include <iostream>
#include <vector>
#include <chrono>
int main() {
const int SIZE = 10000000;
std::vector<int> data(SIZE);
auto start = std::chrono::high_resolution_clock::now();
for (int i = 0; i < SIZE; ++i) {
data[i] = i * 2 + 1;
}
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << "처리 시간: " << duration.count() << "ms" << std::endl;
return 0;
}
이 예제를 -O2와 -O3로 각각 빌드한 뒤 실행해 보면, 예를 들어 아래처럼 나올 수 있습니다. 숫자는 환경에 따라 다르지만, -O3일 때 수십 ms 정도 단축되는 경우가 많습니다.
g++ -std=c++17 -O2 benchmark.cpp -o bench_o2
./bench_o2 # 예: 45ms
g++ -std=c++17 -O3 benchmark.cpp -o bench_o3
./bench_o3 # 예: 32ms (약 29% 향상)
3. Clang/LLVM
Clang은 Apple이 주도하여 개발한 모던 컴파일러입니다. LLVM 프로젝트의 일부로, 모듈화된 아키텍처와 뛰어난 개발자 경험으로 인기를 얻고 있습니다.
Clang의 주요 특징
LLVM 기반의 모듈화된 아키텍처: Clang은 LLVM(Low Level Virtual Machine) 프레임워크를 기반으로 만들어졌습니다. 이 구조 덕분에 다른 언어 컴파일러들과 최적화 엔진을 공유할 수 있습니다.
친절한 에러 메시지: Clang의 가장 큰 장점 중 하나입니다. 에러가 발생한 정확한 위치를 화살표로 표시하고, 문제의 원인을 자세히 설명해줍니다.
빠른 컴파일 속도: GCC보다 20-30% 빠른 컴파일 속도를 보입니다. 대규모 프로젝트에서 개발 생산성이 크게 향상됩니다.
내장 정적 분석 도구: 코드를 컴파일하면서 동시에 잠재적 버그를 자동으로 감지합니다.
Clang 설치 및 기본 사용
macOS에서는 Xcode 또는 xcode-select --install로 설치됩니다. Linux에서는 패키지 매니저로, Windows에서는 LLVM 공식 사이트에서 받을 수 있습니다.
# Ubuntu/Debian
sudo apt install clang
# macOS (이미 Xcode에 포함)
xcode-select --install
# 버전 확인
clang++ --version
GCC와 거의 동일한 옵션을 사용하므로, g++를 clang++로 바꾸기만 해도 대부분 그대로 빌드됩니다.
clang++ -std=c++17 -O2 main.cpp -o main
LLVM 아키텍처 이해하기
Clang의 컴파일 과정은 다음과 같이 진행됩니다:
flowchart TB
A["C++ 소스 코드"] --> B["Clang Frontendbr/(구문 분석, AST 생성)"]
B --> C["LLVM IRbr/(중간 표현 - 플랫폼 독립적)"]
C --> D["LLVM Optimizerbr/(최적화 수행)"]
D --> E["LLVM Backendbr/(타겟 CPU용 기계어 생성)"]
E --> F["실행 파일"]
LLVM IR(Intermediate Representation)은 플랫폼 독립적인 중간 표현입니다. 이 덕분에 한 번 최적화를 수행하면 모든 플랫폼에 적용할 수 있습니다. Swift, Rust 같은 다른 언어 컴파일러도 LLVM을 백엔드로 사용해, 같은 최적화 엔진을 공유합니다.
에러 메시지 비교: Clang이 더 친절한 이유
같은 오류에 대해 GCC와 Clang이 어떻게 다르게 알려주는지 비교해보겠습니다. 아래 코드는 std::vector<int>에 std::string을 넣으려 해서 타입 불일치가 발생합니다.
// error_comparison.cpp - GCC vs Clang 에러 메시지 비교용
#include <vector>
#include <string>
int main() {
std::vector<int> vec;
std::string str = "hello";
vec.push_back(str); // ❌ int 벡터에 string 전달
return 0;
}
GCC의 에러 메시지:
error: no matching function for call to 'push_back'
Clang의 에러 메시지:
error: no matching member function for call to 'push_back'
vec.push_back(str);
^~~~~~~~~
note: candidate function not viable: no known conversion from 'std::string' to 'int'
Clang은 다음과 같은 추가 정보를 제공합니다:
- 문제가 발생한 정확한 위치를 화살표(
^)로 표시합니다. - 왜 오류가 발생했는지 구체적으로 설명합니다. (이 경우 std::string을 int로 변환할 수 없음)
- 해결 방법에 대한 힌트를 제공합니다.
이런 친절한 에러 메시지 덕분에 초보자도 문제를 빠르게 파악하고 해결할 수 있습니다.
기본 사용법
Clang으로 빌드할 때도 GCC와 비슷하게 -O2, -O3 옵션을 씁니다. 여기에 **--analyze**를 붙이면 컴파일과 동시에 정적 분석을 수행해, 실행하지 않아도 메모리 오류·논리 오류 가능성을 경고해 줍니다. 대규모 프로젝트에서는 이 옵션을 CI에 넣어 두는 경우가 많습니다.
clang++ -O2 main.cpp
clang++ -O3 main.cpp
clang++ --analyze main.cpp # 정적 분석
정적 분석 예제
지역 배열의 주소를 반환하면, 함수가 끝난 뒤 그 메모리는 이미 무효이므로 댕글링 포인터(이미 해제되었거나 무효해진 메모리를 가리키는 포인터)가 됩니다. Clang의 --analyze는 이런 위험을 컴파일 시점에 짚어 주며, 아래처럼 “스택 메모리 주소를 반환했다”는 경고를 냅니다.
// 버그가 있는 코드 - 정적 분석으로 감지됨
int* createArray() {
int arr[10];
return arr; // 스택 메모리 반환 - 위험!
}
clang++ --analyze -Xanalyzer -analyzer-output=text dangling.cpp
Clang 분석 결과:
warning: Address of stack memory associated with local variable 'arr' returned
올바른 수정 예시:
// ✅ 힙에 할당하거나, 호출자가 버퍼를 제공
std::vector<int> createArray() {
std::vector<int> arr(10);
return arr; // 이동 또는 RVO
}
4. MSVC (Microsoft Visual C++)
MSVC는 Microsoft가 개발한 Windows 전용 컴파일러입니다. Visual Studio와 완벽하게 통합되어 있어 Windows 개발에 최적화되어 있습니다.
MSVC의 주요 특징
Windows API 최적화: MSVC는 Windows 플랫폼에 특화된 최적화를 수행합니다. Windows API 함수 호출 패턴을 인식하고 최적화하여, Windows 전용 프로그램에서는 다른 컴파일러보다 나은 성능을 보일 수 있습니다.
Visual Studio 완벽 통합: 강력한 디버거, 프로파일러, 코드 분석 도구가 모두 통합되어 있습니다. 특히 디버거는 업계 최고 수준으로 평가받습니다.
COM 및 Windows 기술 지원: COM(Component Object Model), ATL, MFC 같은 Windows 고유 기술을 완벽하게 지원합니다.
최적화 옵션
MSVC의 최적화 옵션은 GCC/Clang과 슬래시(/)를 쓰고, 번호 체계도 조금 다릅니다. **/O1은 실행 파일 크기를 줄이는 쪽으로 최적화하고, /O2는 실행 속도를 높이는 일반적인 권장 옵션입니다. /Ox**는 속도 극대화용으로, GCC의 -O3에 가깝습니다. 실무에서는 대부분 **/O2**를 사용합니다.
cl /O1 main.cpp # 크기 최적화
cl /O2 main.cpp # 속도 최적화 (권장)
cl /Ox main.cpp # 최대 속도 최적화
GCC/Clang과 옵션 대응표:
| GCC/Clang | MSVC | 용도 |
|---|---|---|
| -O0 | /Od | 디버깅 |
| -O1 | /O1 | 크기 최적화 |
| -O2 | /O2 | 속도 최적화 (권장) |
| -O3 | /Ox | 최대 속도 |
| -Os | /O1 | 크기 우선 |
Windows API 최적화 예시
CreateFile 같은 Windows API 호출은 MSVC가 자주 쓰는 패턴으로 인식해, 불필요한 검사 제거나 인라인화를 적용할 수 있습니다. 아래처럼 표준적인 형태로 호출하면 그 혜택을 받기 쉽습니다.
예제에서 **CreateFileW**는 유니코드 파일 이름으로 파일을 여는 Windows API입니다. 인자는 순서대로 열 파일 경로, 읽기/쓰기 권한(GENERIC_READ는 읽기 전용), 다른 프로세스와의 공유 방식, 보안 속성(기본값은 NULL), 기존 파일만 열기(OPEN_EXISTING), 파일 속성, 템플릿 파일 핸들(사용 안 함이면 NULL)을 의미합니다. 이렇게 자주 쓰는 형태로 호출하면 MSVC가 해당 패턴을 인식해 최적화합니다.
#include <windows.h>
HANDLE hFile = CreateFileW(
L"data.txt",
GENERIC_READ,
FILE_SHARE_READ,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL
);
MSVC는 이런 Windows API 호출 패턴을 인식하고, 불필요한 검사를 제거하거나 인라인 최적화를 수행합니다.
MSVC의 단점
모든 도구가 그렇듯 MSVC에도 단점이 있습니다:
Windows 전용: Linux나 macOS에서는 사용할 수 없습니다. 크로스 플랫폼 프로젝트에서는 불편할 수 있습니다.
표준 지원이 느림: GCC나 Clang에 비해 최신 C++ 표준 지원이 늦는 편입니다. C++20, C++23의 일부 기능이 아직 완전히 구현되지 않았습니다.
이식성 문제: MSVC로 작성한 코드를 Linux나 macOS로 이식할 때 수정이 필요할 수 있습니다.
5. 컴파일러 선택 가이드
성능 비교 요약
| 컴파일러 | 실행 속도 | 컴파일 속도 | 에러 메시지 | 플랫폼 |
|---|---|---|---|---|
| GCC | ⭐⭐⭐ | ⭐⭐ | ⭐⭐ | 모든 OS |
| Clang | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 모든 OS |
| MSVC | ⭐⭐ | ⭐⭐ | ⭐⭐⭐ | Windows |
상황별 추천
flowchart TD
A[프로젝트 타겟] --> B{OS?}
B -->|Linux| C[GCC -O2]
B -->|Windows 전용| D[MSVC /O2]
B -->|크로스 플랫폼| E[Clang -O2/-O3]
A --> F{임베디드?}
F -->|예| G[GCC -Os]
F -->|아니오| B
Linux 서버:
- 추천: GCC
- 이유: 시스템 호환성, 안정성
- 옵션:
-O2
크로스 플랫폼:
- 추천: Clang
- 이유: 빠른 컴파일, 친절한 에러
- 옵션:
-O3
Windows 전용:
- 추천: MSVC
- 이유: Windows API 최적화
- 옵션:
/O2
임베디드:
- 추천: GCC
- 이유: 다양한 아키텍처 지원
- 옵션:
-Os
게임 개발:
- 추천: Clang (개발) + 플랫폼별 (배포)
- 이유: 빠른 개발, 플랫폼 최적화
- 옵션: 개발
-O0 -g, 배포-O3
실전 벤치마크 참고
동일한 벤치마크 코드를 GCC, Clang, MSVC로 각각 -O2(또는 /O2)로 빌드했을 때, 워크로드에 따라 5–15% 정도 차이가 나는 경우가 많습니다. 수치 연산·루프가 많은 코드에서는 Clang이, Windows API를 많이 쓰는 코드에서는 MSVC가 유리한 경우가 있습니다. 실제 프로젝트에서는 목표 환경에서 직접 측정하는 것이 가장 정확합니다.
일반적인 실수
-
디버깅할 때 -O2/-O3 사용: 최적화가 켜져 있으면 변수 값이 “optimized out”으로 보이거나, 단계 실행이 직관적이지 않을 수 있습니다. 디버깅 시에는
-O0 -g를 사용하세요. -
컴파일러 버전을 문서에 안 남김: “이 버전에서만 재현돼요” 같은 버그를 추적할 때, 어떤 컴파일러·버전을 썼는지가 중요합니다. README나 CI 설정에 명시해야 합니다.
-
한 컴파일러로만 빌드: 크로스 플랫폼 프로젝트에서는 GCC와 Clang 둘 다로 빌드해 보면 이식성 문제를 미리 잡을 수 있습니다.
6. 자주 발생하는 에러와 해결법
문제 1: “undefined reference” 링크 에러
원인: 함수나 변수를 선언만 하고 정의하지 않았거나, 다른 .cpp 파일에 정의가 있는데 링크하지 않았을 때 발생합니다.
해결법:
// ❌ 잘못된 예: 선언만 있고 정의 없음
void foo(); // 선언
int main() {
foo(); // undefined reference to 'foo()'
return 0;
}
// ✅ 올바른 예: 정의 추가
void foo() { /* 구현 */ }
int main() {
foo();
return 0;
}
여러 파일로 나뉘어 있을 때는 모든 .cpp를 링크해야 합니다:
g++ main.cpp utils.cpp -o myapp # utils.cpp에 foo 함수 정의
문제 2: “fatal error: … No such file or directory” 전처리 에러
원인: #include 헤더 경로를 찾지 못할 때 발생합니다.
해결법:
# -I 옵션으로 헤더 경로 추가
g++ -I include/ -I /usr/local/include main.cpp -o main
문제 3: “error: ‘xxx’ was not declared in this scope”
원인: 변수나 함수를 사용하기 전에 선언하지 않았거나, 필요한 헤더를 포함하지 않았을 때 발생합니다.
해결법:
// ❌ 잘못된 예
int main() {
cout << "Hello"; // cout 선언 없음
return 0;
}
// ✅ 올바른 예
#include <iostream>
int main() {
std::cout << "Hello";
return 0;
}
문제 4: -O2/-O3에서만 발생하는 버그
원인: 최적화로 인해 “사용하지 않는” 코드가 제거되거나, 미정의 동작(undefined behavior)이 드러날 때 발생합니다.
해결법: -O0로 빌드해 디버깅하고, -Wall -Wextra로 경고를 켜서 잠재적 문제를 찾습니다. AddressSanitizer를 사용하면 메모리 오류를 쉽게 찾을 수 있습니다.
g++ -O0 -g -Wall -Wextra -fsanitize=address main.cpp -o main
문제 5: “multiple definition” 링크 에러
원인: 헤더 파일에 함수나 변수를 정의해 두고 여러 .cpp에서 #include할 때 발생합니다. 헤더에는 선언만 두고, 정의는 .cpp 한 곳에만 있어야 합니다.
해결법:
// ❌ 잘못된 예: utils.h에 정의가 있음
// utils.h
int add(int a, int b) { return a + b; } // 여러 .cpp에서 include하면 중복 정의!
// ✅ 올바른 예: utils.h에는 선언만
// utils.h
int add(int a, int b);
// utils.cpp
int add(int a, int b) { return a + b; }
인라인 함수나 템플릿은 예외적으로 헤더에 둘 수 있습니다. inline 키워드나 template을 쓰면 링커가 중복을 허용합니다.
문제 6: 컴파일러마다 동작이 다를 때
원인: 미정의 동작(undefined behavior)이나 구현 정의(implementation-defined) 동작을 의존했을 때, 컴파일러마다 다른 결과가 나올 수 있습니다.
해결법: 표준에 명시된 동작만 의존하고, -Wall -Wextra -pedantic로 경고를 최대한 켜서 이식성 문제를 미리 잡습니다. CI에서 GCC와 Clang 둘 다로 빌드해 보는 것이 좋습니다.
# GCC/Clang 공통: 엄격한 경고
g++ -std=c++17 -Wall -Wextra -pedantic main.cpp -o main
문제 7: “relocation truncated to fit” 링크 에러
원인: 32비트 환경에서 큰 전역 배열이나 긴 점프를 사용할 때, 오브젝트 코드의 재배치(relocation) 정보가 목표 주소 범위를 넘어설 때 발생합니다.
해결법: 64비트로 빌드(-m64)하거나, 큰 데이터를 동적 할당(std::vector, new)으로 바꿉니다.
# 64비트 명시 (대부분 기본값)
g++ -m64 main.cpp -o main
문제 8: “undefined reference to __gxx_personality_v0”
원인: C++ 예외 처리나 RTTI를 사용하는 코드를 C 링커(gcc 또는 ld)로 링크했을 때 발생합니다. C++ 코드는 반드시 g++/clang++로 링크해야 합니다.
해결법: gcc 대신 g++를 사용합니다.
# ❌ C 링커 사용 시
gcc main.o utils.o -o myapp # 에러
# ✅ C++ 링커 사용
g++ main.o utils.o -o myapp
문제 9: “symbol lookup error” 또는 “version `GLIBCXX_3.4.30’ not found”
원인: 빌드 환경과 실행 환경의 libstdc++ 버전이 다를 때 발생합니다. 새 GCC로 빌드한 바이너리를 오래된 시스템에서 실행하면 이런 에러가 납니다.
해결법: 목표 환경과 동일한(또는 호환되는) libstdc++를 사용하거나, 정적 링크(-static-libstdc++)를 고려합니다. Docker로 목표 환경을 맞추는 것도 방법입니다.
# 정적 링크 (실행 파일 크기 증가)
g++ -static-libstdc++ main.cpp -o main
문제 10: 매크로 확장 오류 (SQUARE(x+1) 등)
원인: 매크로 인자에 괄호를 치지 않으면 연산자 우선순위로 인해 잘못된 결과가 나옵니다.
해결법: 매크로 본문과 인자에 괄호를 반드시 씁니다.
// ❌ 잘못된 예: SQUARE(x+1) → x+1*x+1 = x + x + 1
#define SQUARE(x) x * x
// ✅ 올바른 예
#define SQUARE(x) ((x) * (x))
7. 컴파일러 사용 모범 사례
1. C++ 표준 명시
프로젝트마다 -std=c++17 또는 -std=c++20을 명시해, 컴파일러 기본값에 의존하지 않습니다.
# CMakeLists.txt
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
2. 경고를 에러로 취급
개발 단계에서는 -Werror로 경고를 에러처럼 처리해, 잠재적 버그를 방치하지 않습니다.
g++ -std=c++17 -Wall -Wextra -Werror main.cpp -o main
3. 헤더 가드 또는 #pragma once
헤더 중복 포함을 막기 위해 #ifndef/#define/#endif 또는 #pragma once를 사용합니다.
// my_header.h
#pragma once
// 또는
#ifndef MY_HEADER_H
#define MY_HEADER_H
// ...
#endif
4. 증분 빌드 활용
큰 프로젝트에서는 -c로 오브젝트를 생성하고, Make/CMake가 변경된 파일만 재컴파일하도록 구성합니다.
5. 디버그/릴리스 분리
디버깅 시 -O0 -g, 배포 시 -O2 또는 -O3를 사용하고, 빌드 스크립트나 CMake에서 Debug/Release 구성을 분리합니다.
8. 프로덕션 빌드 패턴
패턴 1: 멀티 컴파일러 CI
크로스 플랫폼 프로젝트에서는 GCC와 Clang으로 각각 빌드해 이식성 문제를 조기에 발견합니다.
# .github/workflows/build.yml
- name: Build with GCC
run: g++ -std=c++17 -O2 -Wall -Wextra src/*.cpp -o app_gcc
- name: Build with Clang
run: clang++ -std=c++17 -O2 -Wall -Wextra src/*.cpp -o app_clang
패턴 2: 릴리스 빌드 플래그 세트
배포용 빌드에서는 최적화, NDEBUG, 릴리스 정의를 함께 사용합니다.
g++ -std=c++17 -O2 -DNDEBUG -DRELEASE main.cpp -o main
패턴 3: 컴파일러 버전 문서화
README나 CI 설정에 사용 컴파일러와 버전을 명시해, “어떤 환경에서 빌드했는지”를 추적할 수 있게 합니다.
## 빌드 환경
- GCC 11.4.0 또는 Clang 14.0
- C++17
- CMake 3.20+
패턴 4: 정적 분석 통합
CI에서 Clang 정적 분석(--analyze)이나 cppcheck를 실행해, 런타임 전에 잠재적 버그를 탐지합니다.
clang++ --analyze -Xanalyzer -analyzer-output=text src/*.cpp
패턴 5: LTO(Link Time Optimization) 활용
배포용 최종 빌드에서 LTO를 켜면 링크 시점에 추가 최적화가 적용됩니다. 컴파일·링크 시간이 늘어나므로 개발 시에는 끄고, 릴리스 빌드에서만 사용합니다.
g++ -std=c++17 -O3 -flto main.cpp utils.cpp -o main
실무 활용 팁
CI에서 멀티 컴파일러 빌드
GitHub Actions, GitLab CI 등에서 여러 컴파일러로 빌드하면 이식성 문제를 조기에 발견할 수 있습니다.
# GitHub Actions 예시 (요약) - GCC와 Clang으로 각각 빌드
jobs:
build:
strategy:
matrix:
include:
- compiler: gcc
cxx: g++-11
- compiler: clang
cxx: clang++-14
steps:
- uses: actions/checkout@v4
- run: ${{ matrix.cxx }} -std=c++17 -O2 -Wall -Wextra main.cpp -o main
디버그 vs 릴리스 빌드 분리
실무에서는 보통 두 가지 빌드 구성을 유지합니다:
| 구분 | 디버그 | 릴리스 |
|---|---|---|
| 최적화 | -O0 (GCC/Clang) / /Od (MSVC) | -O2 또는 -O3 |
| 디버그 정보 | -g | 없거나 최소 |
| 용도 | 단계 실행, 변수 확인 | 배포, 성능 측정 |
CMake 사용 시:
# CMakeLists.txt
set(CMAKE_BUILD_TYPE Release) # 또는 Debug
# Release: -O3 -DNDEBUG
# Debug: -O0 -g
컴파일러 버전 확인
문제 재현 시 “어떤 컴파일러·버전”을 썼는지 명시하면 도움이 됩니다.
g++ --version
clang++ --version
cl # MSVC: Visual Studio 개발자 명령 프롬프트에서
9. 프로젝트별 체크리스트
컴파일러 선택 체크리스트
- 타겟 OS 확인 (Linux / Windows / macOS / 크로스 플랫폼)
- C++ 표준 버전 결정 (
-std=c++17등) - 디버그 빌드:
-O0 -g(GCC/Clang) 또는/Od /Zi(MSVC) - 릴리스 빌드:
-O2(GCC/Clang) 또는/O2(MSVC) - CI에서 여러 컴파일러로 빌드 (GCC + Clang 최소)
- 빌드 문서에 사용 컴파일러·버전·옵션 명시
최적화 옵션 체크리스트
- 디버깅 시
-O0사용 - 일반 배포 시
-O2사용 - 벤치마크·수치 연산 시
-O3고려 (실측 후 결정) - 임베디드·제한된 메모리 시
-Os고려
모범 사례·프로덕션 체크리스트
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ 컴파일러 뭘 쓸까? GCC vs Clang vs MSVC 차이·선택 가이드
- C++ 개발 환경 구축 | “C++ 어디서 시작하죠?” 컴파일러 설치부터 Hello World까지
- C++ 컴파일러 최적화 | PGO·LTO로 “느린 프로그램” 성능 30% 향상시키기
이 글에서 다루는 키워드 (관련 검색어)
C++ 컴파일러, GCC Clang MSVC 비교, O2 O3 최적화, 컴파일 4단계, 전처리 구문분석, LLVM, 컴파일러 선택, Linux Windows macOS C++ 컴파일러, 에러 메시지 비교 등으로 검색하시면 이 글이 도움이 됩니다.
마무리
핵심 요약
이 글에서 배운 내용을 정리하겠습니다:
✅ GCC: Linux 서버 환경에 최적화되어 있으며, C++ 표준을 잘 준수하는 안정적인 컴파일러입니다.
✅ Clang: 크로스 플랫폼 개발에 적합하며, 빠른 컴파일 속도와 친절한 에러 메시지가 장점입니다.
✅ MSVC: Windows 전용 컴파일러로, Windows API 사용 시 최고의 성능을 제공합니다.
✅ 최적화 옵션: 기본적으로 -O2를 사용하고, 최대 성능이 필요할 때만 -O3를 고려합니다.
컴파일러 선택 가이드
- Linux 서버 개발: GCC를 사용하세요.
- 크로스 플랫폼 개발: Clang을 사용하세요.
- Windows 전용 개발: MSVC를 사용하세요.
- 임베디드 시스템: GCC의
-Os옵션을 사용하세요.
다음 글
컴파일러의 기본을 이해했다면, 이제 고급 최적화 기법을 배울 차례입니다.
자주 묻는 질문 (FAQ)
Q. 이 내용을 실무에서 언제 쓰나요?
A. GCC, Clang, MSVC 컴파일러의 특징과 성능 비교. 컴파일 4단계(전처리·구문분석·최적화·코드생성) 원리, -O0/-O2/-O3 최적화 옵션 차이, 에러 메시지 비교, 프로젝트별 컴파일러 선택 기준을 실전에서 활용합니다. 새 프로젝트 시작 시 컴파일러 선택, CI 설정, 배포 빌드 옵션 결정 시 참고하세요.
Q. -O3가 -O2보다 항상 빠른가요?
A. 아닙니다. -O3는 코드 크기를 늘려 캐시 미스가 증가할 수 있어, 일부 워크로드에서는 -O2가 더 빠를 수 있습니다. 목표 환경에서 직접 벤치마크해 보는 것이 좋습니다.
Q. Clang을 Linux에서 쓸 때 GCC 표준 라이브러리를 써도 되나요?
A. 됩니다. clang++는 기본적으로 시스템의 libstdc++(GCC 표준 라이브러리)를 사용합니다. -stdlib=libc++를 주면 LLVM의 libc++를 쓸 수 있습니다. 대부분의 경우 기본값으로 충분합니다.
Q. Windows에서 GCC나 Clang을 쓰려면?
A. MinGW-w64나 MSYS2로 GCC를 설치하거나, LLVM 공식 사이트에서 Clang Windows 빌드를 받을 수 있습니다. Visual Studio와 함께 설치된 MSVC를 쓰는 것이 Windows 네이티브 개발에는 가장 편합니다.
Q. 선행으로 읽으면 좋은 글은?
A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.
Q. 더 깊이 공부하려면?
A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.
한 줄 요약: GCC·Clang·MSVC 차이와 -O0/-O2/-O3 옵션을 알면 프로젝트에 맞는 컴파일러를 고를 수 있습니다. 다음으로 컴파일러 최적화(#2-2)를 읽어보면 좋습니다.
다음 글: C++ 실전 가이드 #2-2: 컴파일러 최적화 심화 - PGO, LTO, 벤치마크 등 고급 최적화 기법을 실전 예제와 함께 설명합니다.
참고 자료
- GCC 공식 문서 — Linux의 표준 컴파일러
- Clang 공식 문서 — 빠른 컴파일과 친절한 에러 메시지
- MSVC 컴파일러 옵션 — Windows 플랫폼 최적화
관련 글
- C++ 컴파일러 뭘 쓸까? GCC vs Clang vs MSVC 차이·선택 가이드
- C++ 컴파일러 최적화 | PGO·LTO로
- C++ 개발 환경 구축 |
- C++ 멀티 컴파일러 전략과 CI/CD 파이프라인 구축 | 실무 가이드
- VS Code C++ 설정 | IntelliSense·빌드·디버깅