C++ 컴파일러 비교 | GCC vs Clang vs MSVC, 어떤 걸 써야 할까?
[C++ 실전 가이드 #2-1] C++ 컴파일러 기초
”같은 코드인데 왜 빌드 결과가 다를까?”
컴파일러(소스 코드를 기계어·실행 파일로 바꿔 주는 도구)는 C++ 개발의 핵심입니다. 흥미롭게도 같은 C++ 코드라도 어떤 컴파일러로 빌드하느냐에 따라 실행 속도가 눈에 띄게 달라질 수 있습니다. 인라이닝·벡터화 판단이 컴파일러마다 다르기 때문입니다. “Linux에서는 잘 되는데 Windows에서 느려요”, “에러 메시지가 너무 짧아서 원인을 못 찾겠어요” 같은 고민은 대부분 컴파일러 선택과 옵션 설정에서 비롯됩니다.
이 글을 읽으면 GCC, Clang, MSVC의 역할과 선택 기준을 이해하고, 프로젝트에 맞는 컴파일러를 고를 수 있습니다. 컴파일 4단계를 알면 에러가 났을 때 “전처리 문제인지, 링크 문제인지”를 빠르게 구분할 수 있어 디버깅 시간이 줄어듭니다.
💡 핵심 개념: 컴파일러가 여러 개 있어 헷갈린다면, 우선 운영체제에 맞는 컴파일러 하나만 설치하면 됩니다. Windows → MSVC, macOS → Clang, Linux → GCC. 나머지는 나중에 천천히 배워도 됩니다.
💡 당장 코딩부터 하고 싶다면 — 컴파일러 상세는 나중에 봐도 됩니다. → #3 VS Code 개발 환경으로 넘어가면 빌드·디버깅을 바로 시작할 수 있습니다.
팀원 PC에서만 빌드가 되는 상황들
컴파일러 기초를 알아두면 아래와 같은 상황에서 원인을 빠르게 찾을 수 있습니다.
”팀원 PC에서는 되는데 제 PC에서만 안 돼요”
동일한 소스인데 빌드 결과가 다를 때는 컴파일러 버전·최적화 옵션(-O0 vs -O2) 차이일 가능성이 큽니다. g++ --version, clang++ --version으로 버전을 맞추고, CMake나 Makefile에 -std=c++17 같은 표준을 명시하면 재현이 쉬워집니다.
”헤더를 찾을 수 없다고 하는데, 파일은 있는데요”
#include "my_header.h"에서 No such file or directory가 나면 전처리 단계의 경로 문제입니다. -I include/로 헤더 검색 경로를 추가하거나, 상대 경로를 확인해야 합니다.
”컴파일은 되는데 링크에서 undefined reference가 나요”
컴파일은 성공했지만 링커가 함수·변수 정의를 찾지 못하는 경우입니다. 선언만 있고 정의가 없거나, 다른 .cpp에 정의가 있는데 링크 대상에 포함하지 않았을 때 발생합니다. g++ main.cpp utils.cpp -o app처럼 필요한 .cpp를 모두 링크해야 합니다.
”디버깅할 때 변수 값이 optimized out으로만 보여요”
최적화(-O2, -O3)가 켜져 있으면 사용하지 않는 변수가 제거되거나 인라인되어 디버거에서 값을 볼 수 없습니다. 디버깅할 때는 -O0 -g로 빌드해야 합니다.
”한 파일만 수정했는데 전체가 다시 컴파일돼요”
컴파일 단계(소스 → 오브젝트)와 링크 단계(오브젝트 → 실행 파일)를 분리하지 않으면, 빌드 시스템이 변경된 파일만 재컴파일하지 못합니다. -c로 오브젝트를 따로 만들고 링커로 합치는 방식이면 증분 빌드가 가능합니다.
”Linux에서는 되는데 Windows에서만 크래시해요”
미정의 동작(undefined behavior)이나 구현 정의(implementation-defined) 동작에 의존했을 때, GCC/Clang과 MSVC가 다른 코드를 생성해 플랫폼별로 동작이 달라질 수 있습니다. -Wall -Wextra -pedantic로 경고를 켜고 여러 컴파일러로 빌드해 보면 이식성 문제를 줄일 수 있습니다.
소스 코드가 실행 파일이 되기까지
컴파일러는 우리가 작성한 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편 컴파일 과정에서 다루는 링커 단계 문제일 가능성이 큽니다.
컴파일러마다 결과가 다른 이유
최적화기의 차이
세 컴파일러는 최적화 파이프라인이 서로 다른 코드베이스입니다(GCC는 GIMPLE/RTL, Clang은 LLVM IR, MSVC는 자체 백엔드). 그래서 같은 -O2라도 어떤 함수를 인라인할지, 어떤 루프를 벡터화할지에 대한 휴리스틱이 다르고, 어느 쪽이 더 빠른 코드를 만드는지는 코드마다 달라집니다. “어느 컴파일러가 더 공격적이다”라는 일반화보다는, 성능이 중요한 코드라면 후보 컴파일러로 직접 빌드해 비교하는 것이 정확합니다.
표준 라이브러리 구현 차이
std::vector, std::string 같은 표준 라이브러리는 컴파일러와 별개의 구현(GCC의 libstdc++, LLVM의 libc++, Microsoft STL)이고 내부 구조가 서로 다릅니다. 예를 들어 짧은 문자열을 힙 할당 없이 객체 안에 담는 작은 문자열 최적화(SSO, Small String Optimization)의 최대 길이는 64비트 환경에서 libstdc++와 Microsoft STL이 15자, libc++가 22자입니다. 그래서 sizeof(std::string)이나 할당 횟수가 플랫폼마다 다르게 나옵니다.
에러 메시지의 차이
Clang은 초기부터 에러 위치 표시와 읽기 쉬운 진단 메시지를 강점으로 내세웠고, GCC도 버전 8 무렵부터 캐럿 표시·수정 제안·색상 출력을 크게 개선해 지금은 차이가 예전보다 작습니다. MSVC는 C2664 같은 에러 코드와 함께 메시지를 출력하므로 코드로 문서를 검색하기 쉽습니다.
실무에서는 한 플랫폼에서만 쓴다면 해당 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 같은 빌드 도구가 이 과정을 자동화합니다.
GCC: 리눅스 서버의 기본 컴파일러
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 |
|---|---|---|
| 컴파일 시간 | 보통 | 더 김 |
| 실행 속도 | 양호 | 최대 (대부분) |
| 코드 크기 | 적당 | 눈에 띄게 증가 가능 |
| 디버깅 | 가능 | 어려움 |
| 권장 상황 | 대부분 | 벤치마크·수치 연산 |
-O2 (대부분의 경우 권장)
- 컴파일 시간과 실행 성능의 균형이 좋습니다.
- 코드 크기 증가가 적당합니다.
- 디버깅이 어느 정도 가능합니다.
- 대부분의 상황에서 최적의 선택입니다.
-O3 (최대 성능이 필요할 때)
- 더 공격적인 함수 인라이닝을 수행합니다.
- SIMD 명령어를 활용한 벡터화(Vectorization)를 적극적으로 시도합니다.
- 루프 언롤링을 더 많이 수행합니다.
- 코드 크기가 커질 수 있습니다(증가 폭은 인라인·언롤 대상 코드가 얼마나 되느냐에 따라 다름).
- 디버깅이 매우 어려워집니다.
- 일부 코드에서는 -O2보다 느릴 수도 있습니다. (캐시 미스 증가)
왜 -O3가 때로 더 느릴 수 있을까?
-O3는 루프를 더 공격적으로 펼치고(loop unrolling) 인라인을 많이 적용합니다. 그 결과 코드 크기가 커지고, CPU 캐시에 명령어가 덜 들어가면서 캐시 미스가 늘어날 수 있습니다. 따라서 “벤치마크에서는 -O3가 빠른데, 실제 워크로드에서는 -O2가 나은” 경우가 있습니다. 가능하면 목표 환경에서 직접 측정해 보는 것이 좋습니다.
직접 비교해 보기
아래 코드는 1000만 개 원소를 가진 vector에 루프로 값을 채우는 단순한 측정 예제입니다. std::vector<int> data(SIZE)로 벡터를 만들고, steady_clock::now()로 루프 전후 시각을 잰 뒤 duration_cast로 밀리초 단위로 바꿔 출력합니다. 마지막에 원소 하나를 출력하는 것은, 결과를 아무도 읽지 않으면 최적화기가 루프 자체를 없앨 수 있기 때문입니다.
// benchmark.cpp
#include <iostream>
#include <vector>
#include <chrono>
int main() {
const int SIZE = 10000000;
std::vector<int> data(SIZE);
auto start = std::chrono::steady_clock::now();
for (int i = 0; i < SIZE; ++i) {
data[i] = i * 2 + 1;
}
auto end = std::chrono::steady_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
std::cout << "처리 시간: " << duration.count() << "ms" << std::endl;
std::cout << "data[SIZE/2] = " << data[SIZE / 2] << std::endl; // 결과 사용
return 0;
}
이 예제를 -O2와 -O3로 각각 빌드해 실행 시간을 비교해 보세요.
g++ -std=c++17 -O2 benchmark.cpp -o bench_o2
./bench_o2
g++ -std=c++17 -O3 benchmark.cpp -o bench_o3
./bench_o3
결과는 생각보다 비슷하게 나올 가능성이 큽니다. GCC 12부터는 -O2에서도 비용이 낮은 벡터화를 켜고, 이런 단순 채우기 루프는 계산보다 메모리에 쓰는 속도가 병목이라 최적화 수준을 올려도 줄어들 여지가 적기 때문입니다. -O3의 이득은 데이터가 캐시에 들어가는 크기에서 계산이 많은 루프일 때 더 잘 드러납니다. 둘의 차이가 측정 오차 수준이라면 -O2를 유지하는 것이 합리적입니다.
Clang/LLVM: 친절한 에러 메시지와 모듈식 구조
Clang은 2007년 Apple이 시작해 지금은 LLVM 프로젝트의 일부로 여러 회사와 개인이 함께 개발하는 컴파일러 프런트엔드입니다. 라이브러리 형태의 모듈화된 구조 덕분에 clangd, clang-tidy, clang-format 같은 도구의 기반이 되기도 합니다.
Clang의 주요 특징
LLVM 기반의 모듈화된 아키텍처
Clang은 LLVM 프레임워크를 기반으로 만들어졌습니다(LLVM은 원래 Low Level Virtual Machine의 약자였지만 지금은 약어가 아닌 프로젝트 이름으로 씁니다). 이 구조 덕분에 다른 언어 컴파일러들과 최적화 엔진을 공유할 수 있습니다.
친절한 에러 메시지
Clang의 가장 큰 장점 중 하나입니다. 에러가 발생한 정확한 위치를 화살표로 표시하고, 문제의 원인을 자세히 설명해 줍니다.
빠른 컴파일 속도
전통적으로 GCC보다 컴파일이 빠르다는 평가를 받아 왔지만, 최근 버전에서는 프로젝트와 최적화 수준에 따라 차이가 작거나 뒤집히기도 합니다. 빌드 시간이 중요하다면 두 컴파일러로 실제 프로젝트를 빌드해 비교해 보세요.
내장 정적 분석 도구
코드를 컴파일하면서 동시에 잠재적 버그를 자동으로 감지합니다.
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 Frontend<br/>(구문 분석, AST 생성)"]
B --> C["LLVM IR<br/>(중간 표현)"]
C --> D["LLVM Optimizer<br/>(최적화 수행)"]
D --> E["LLVM Backend<br/>(타겟 CPU용 기계어 생성)"]
E --> F["실행 파일"]
LLVM IR(Intermediate Representation)은 특정 CPU 명령어에 묶이지 않은 중간 표현입니다(다만 C++ 프런트엔드가 만든 IR에는 타입 크기나 호출 규약 같은 대상 플랫폼 정보가 이미 반영되어 있습니다). 이 덕분에 대부분의 최적화 패스를 한 번 구현하면 여러 CPU 백엔드에서 함께 쓸 수 있습니다. 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 'std::vector<int>::push_back(std::string&)'
note: candidate: 'void std::vector<_Tp, _Alloc>::push_back(const value_type&) [with ...]'
note: no known conversion for argument 1 from 'std::string' to 'const value_type&' {aka 'const int&'}
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 'const value_type' (aka 'const int') for 1st argument
두 컴파일러 모두 후보 함수와 “string을 int로 변환할 수 없다”는 원인을 알려 줍니다. 정확한 문구와 note의 개수는 버전마다 다르지만, Clang은 템플릿 인자를 aka로 풀어 보여 주고 메시지를 짧게 정리하는 편이고, GCC는 후보의 전체 시그니처를 길게 보여 주는 편입니다. 템플릿이 깊게 얽힌 에러에서 한쪽 메시지가 이해하기 어렵다면 다른 컴파일러로 한 번 빌드해 보는 것도 방법입니다.
clang++ 기본 사용법
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 분석 결과(일반 컴파일에서도 -Wreturn-stack-address 경고로 잡힙니다):
warning: Address of stack memory associated with local variable 'arr' returned
올바른 수정 예시
// ✅ 힙에 할당하거나, 호출자가 버퍼를 제공
std::vector<int> createArray() {
std::vector<int> arr(10);
return arr; // 이동 또는 RVO
}
MSVC: Windows 개발의 표준
MSVC는 Microsoft가 개발하는 Windows용 컴파일러로, Visual Studio에 통합되어 있습니다.
MSVC의 주요 특징
Windows 생태계와의 통합
Windows SDK, COM, ATL/MFC 같은 Windows 고유 기술과 함께 쓰도록 만들어졌고, Visual Studio의 디버거·프로파일러·정적 분석(/analyze)과 바로 연결됩니다. Windows 시스템 DLL과 같은 ABI를 쓰므로, 다른 MSVC 빌드 라이브러리와 섞어 쓰기 쉽습니다. 참고로 CreateFileW 같은 Windows API는 DLL에서 가져오는 함수라 MSVC라고 해서 호출 자체를 인라인하거나 특별히 최적화하지는 않습니다.
표준 지원
기능별로 지원 시점이 컴파일러마다 다릅니다. MSVC는 C++20의 핵심 기능(모듈 포함)을 비교적 일찍 완성한 편이지만, 일부 C++23 기능은 GCC·Clang보다 늦게 들어오기도 합니다. 특정 기능이 필요하다면 cppreference의 “Compiler support” 표나 Microsoft 문서에서 버전별 지원 여부를 확인하는 것이 정확합니다. 또한 최신 표준을 쓰려면 /std:c++20이나 /std:c++latest를 명시해야 합니다.
최적화 옵션
MSVC의 옵션은 GCC/Clang과 달리 슬래시(/)로 시작하고, 번호 체계도 다릅니다. /O1은 크기 최소화, /O2는 속도 최대화이며 실무 릴리스 빌드는 대부분 /O2를 씁니다. /Ox는 /O2의 일부 옵션만 켠 조합이라 Microsoft 문서도 /O2를 권장하며, GCC의 -O3에 정확히 대응하는 단계는 없습니다.
cl /Od main.cpp # 최적화 끔 (디버깅)
cl /O1 main.cpp # 크기 최소화
cl /O2 main.cpp # 속도 최대화 (권장)
GCC/Clang과 대략적인 대응 관계는 다음과 같습니다.
| GCC/Clang | MSVC | 용도 |
|---|---|---|
| -O0 | /Od | 디버깅 |
| -Os | /O1 | 크기 최소화 |
| -O2 | /O2 | 속도 최적화 (권장) |
| -O3 | 정확한 대응 없음 (/O2가 최고 수준) | 최대 속도 |
MSVC의 제약
MSVC 자체는 Windows에서만 실행되므로 Linux나 macOS 빌드에는 쓸 수 없습니다. 또 MSVC에서만 허용되던 비표준 동작(예전 기본값이던 /permissive)에 기대 작성한 코드는 다른 컴파일러에서 에러가 날 수 있으므로, /permissive-(Visual Studio 2017 이후 새 프로젝트 기본값)로 표준 준수 모드를 켜 두는 것이 이식성에 유리합니다.
상황별 컴파일러 선택
컴파일러 선택은 대부분 대상 플랫폼이 결정합니다. Linux 서버와 배포판 패키지는 GCC로 빌드되는 경우가 많아 시스템 라이브러리와의 호환성 면에서 GCC가 무난하고, macOS와 iOS는 Apple Clang이 사실상 기본입니다. Windows 전용 애플리케이션은 Windows SDK와 Visual Studio 도구 체인이 맞물린 MSVC가 가장 수월합니다. 여러 플랫폼을 지원한다면 한 컴파일러로 통일하기보다 각 플랫폼의 기본 컴파일러로 빌드하되, CI에서 GCC와 Clang(가능하면 MSVC까지)으로 모두 빌드해 한 컴파일러만 허용하는 비표준 코드를 일찍 찾는 방식이 흔합니다. 임베디드 분야는 대상 칩 제조사가 제공하는 도구 체인(대개 GCC 또는 Clang 기반)을 따르고, 메모리가 작다면 -Os로 크기를 우선합니다.
실행 속도에 대해서는 “이 컴파일러가 항상 빠르다”는 일반화가 성립하지 않습니다. 같은 코드를 GCC, Clang, MSVC로 -O2(또는 /O2) 빌드했을 때 어느 쪽이 앞서는지는 특정 루프를 누가 벡터화하는지, 어떤 함수를 인라인하는지 같은 개별 판단에 따라 워크로드마다 바뀝니다. 성능이 중요한 프로젝트라면 대표 워크로드로 후보 컴파일러를 직접 비교하는 것이 가장 정확합니다.
컴파일러를 쓸 때 흔히 하는 실수
- 디버깅할 때 -O2/-O3 사용: 최적화가 켜져 있으면 변수 값이 “optimized out”으로 보이거나, 단계 실행이 직관적이지 않을 수 있습니다. 디버깅 시에는
-O0 -g를 사용하세요. - 컴파일러 버전을 기록하지 않음: “이 버전에서만 재현돼요” 같은 버그를 추적할 때, 어떤 컴파일러·버전을 썼는지가 중요합니다. README나 CI 설정에 명시해야 합니다.
- 한 컴파일러로만 빌드: 크로스 플랫폼 프로젝트에서는 GCC와 Clang 둘 다로 빌드해 보면 이식성 문제를 미리 잡을 수 있습니다.
단계별로 만나는 컴파일·링크 에러
”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 함수 정의
“fatal error: … No such file or directory” 전처리 에러
원인: #include 헤더 경로를 찾지 못할 때 발생합니다.
해결법:
# -I 옵션으로 헤더 경로 추가
g++ -I include/ -I /usr/local/include main.cpp -o main
“error: ‘xxx’ was not declared in this scope”
원인: 변수나 함수를 사용하기 전에 선언하지 않았거나, 필요한 헤더를 포함하지 않았을 때 발생합니다. 해결법:
// ❌ 잘못된 예
int main() {
cout << "Hello"; // cout 선언 없음
return 0;
}
// ✅ 올바른 예
#include <iostream>
int main() {
std::cout << "Hello";
return 0;
}
-O2/-O3에서만 발생하는 버그
원인: 최적화로 인해 “사용하지 않는” 코드가 제거되거나, 미정의 동작(undefined behavior)이 드러날 때 발생합니다.
해결법: -O0로 빌드해 디버깅하며, -Wall -Wextra로 경고를 켜서 잠재적 문제를 찾습니다. AddressSanitizer를 사용하면 메모리 오류를 쉽게 찾을 수 있습니다.
g++ -O0 -g -Wall -Wextra -fsanitize=address main.cpp -o main
”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을 쓰면 링커가 중복을 허용합니다.
컴파일러마다 동작이 다를 때
원인: 미정의 동작(undefined behavior)이나 구현 정의(implementation-defined) 동작을 의존했을 때, 컴파일러마다 다른 결과가 나올 수 있습니다.
해결법: 표준에 명시된 동작만 의존하며, -Wall -Wextra -pedantic로 경고를 최대한 켜서 이식성 문제를 미리 잡습니다. CI에서 GCC와 Clang 둘 다로 빌드해 보는 것이 좋습니다.
# GCC/Clang 공통: 엄격한 경고
g++ -std=c++17 -Wall -Wextra -pedantic main.cpp -o main
”relocation truncated to fit” 링크 에러
원인: 재배치(relocation) 대상 주소가 명령어에 담을 수 있는 범위를 넘을 때 발생합니다. x86-64 Linux에서 흔한 경우는 정적 데이터(큰 전역 배열 등)가 2GB를 넘는 경우인데, 기본 코드 모델(-mcmodel=small)은 코드와 정적 데이터가 하위 2GB 안에 있다고 가정하고 32비트 오프셋을 쓰기 때문입니다.
해결법: 큰 데이터를 동적 할당(std::vector, new)으로 바꾸는 것이 가장 간단하고, 정적 배열을 유지해야 한다면 -mcmodel=medium으로 빌드합니다.
# 큰 정적 데이터를 허용하는 코드 모델
g++ -mcmodel=medium main.cpp -o main
”undefined reference to __gxx_personality_v0”
원인: C++ 코드를 gcc나 ld로 링크하면 C++ 표준 라이브러리(libstdc++)가 자동으로 링크되지 않아, 예외 처리에 필요한 __gxx_personality_v0 같은 런타임 심볼을 찾지 못합니다. C++ 코드는 g++/clang++로 링크해야 합니다.
해결법: gcc 대신 g++를 사용합니다.
# ❌ C 링커 사용 시
gcc main.o utils.o -o myapp # 에러
# ✅ C++ 링커 사용
g++ main.o utils.o -o myapp
”symbol lookup error” 또는 “version `GLIBCXX_3.4.30’ not found”
원인: 빌드 환경과 실행 환경의 libstdc++ 버전이 다를 때 발생합니다. 새 GCC로 빌드한 바이너리를 오래된 시스템에서 실행하면 이런 에러가 납니다.
해결법: 목표 환경과 동일한(또는 호환되는) libstdc++를 사용하거나, 정적 링크(-static-libstdc++)를 고려합니다. Docker로 목표 환경을 맞추는 것도 방법입니다.
# 정적 링크 (실행 파일 크기 증가)
g++ -static-libstdc++ main.cpp -o main
매크로 확장 오류 (SQUARE(x+1) 등)
원인: 매크로 인자에 괄호를 치지 않으면 연산자 우선순위로 인해 잘못된 결과가 나옵니다. 해결법: 매크로 본문과 인자에 괄호를 반드시 씁니다.
// ❌ 잘못된 예: SQUARE(x+1) → x+1*x+1 = x + x + 1
#define SQUARE(x) x * x
// ✅ 올바른 예
#define SQUARE(x) ((x) * (x))
컴파일러 사용 원칙
C++ 표준 명시
프로젝트마다 -std=c++17 또는 -std=c++20을 명시해, 컴파일러 기본값에 의존하지 않습니다.
# CMakeLists.txt
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
경고를 에러로 취급
개발 단계에서는 -Werror로 경고를 에러처럼 처리해, 잠재적 버그를 방치하지 않습니다.
g++ -std=c++17 -Wall -Wextra -Werror main.cpp -o main
헤더 가드 또는 #pragma once
헤더 중복 포함을 막기 위해 #ifndef/#define/#endif 또는 #pragma once를 사용합니다.
// my_header.h
#pragma once
// 또는
#ifndef MY_HEADER_H
#define MY_HEADER_H
// ...
#endif
증분 빌드 활용
큰 프로젝트에서는 -c로 오브젝트를 생성하며, Make/CMake가 변경된 파일만 재컴파일하도록 구성합니다.
디버그/릴리스 분리
디버깅 시 -O0 -g, 배포 시 -O2 또는 -O3를 사용하며, 빌드 스크립트나 CMake에서 Debug/Release 구성을 분리합니다.
배포 빌드에서 쓰는 패턴
GCC와 Clang으로 모두 빌드하는 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
릴리스 빌드 플래그 세트
배포용 빌드에서는 최적화와 함께 NDEBUG를 정의해 assert를 끕니다.
g++ -std=c++17 -O2 -DNDEBUG main.cpp -o main
컴파일러 버전 문서화
README나 CI 설정에 사용 컴파일러와 버전을 명시해, “어떤 환경에서 빌드했는지”를 추적할 수 있게 합니다.
## 빌드 환경
- GCC 11.4.0 또는 Clang 14.0
- C++17
- CMake 3.20+
정적 분석 통합
CI에서 Clang 정적 분석(--analyze)이나 cppcheck를 실행해, 런타임 전에 잠재적 버그를 탐지합니다.
clang++ --analyze -Xanalyzer -analyzer-output=text src/*.cpp
LTO(Link Time Optimization) 활용
배포용 최종 빌드에서 LTO를 켜면 링크 시점에 추가 최적화가 적용됩니다. 컴파일·링크 시간이 늘어나므로 개발 시에는 끄고, 릴리스 빌드에서만 사용합니다.
g++ -std=c++17 -O3 -flto main.cpp utils.cpp -o main
GitHub Actions matrix로 컴파일러별 빌드
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
디버그와 릴리스 빌드 옵션 비교
실무에서는 보통 두 가지 빌드 구성을 유지합니다:
| 구분 | 디버그 | 릴리스 |
|---|---|---|
| 최적화 | -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 개발자 명령 프롬프트에서