C++ 컴파일 과정 4단계: 전처리·컴파일·어셈블·링킹과 undefined reference가 나는 이유
C++ 컴파일 과정
g++ main.cpp -o main 명령 하나로 실행 파일이 만들어지지만, 내부에서는 여러 단계를 거치며 단계마다 중간 결과물이 생깁니다. 이 과정을 이해하면 컴파일 에러를 더 빨리 해결할 수 있고, 빌드 시간이 어디서 드는지도 알 수 있습니다.
이 글에서는 소스 코드가 실행 파일이 되는 과정을 전처리, 컴파일, 어셈블, 링킹 네 단계로 나누어 설명합니다.
💡 에러 메시지에
No such file과 헤더 이름이 보이면 전처리,expected ...같은 문법 이야기면 컴파일,undefined reference나LNK2019면 링크 단계부터 보면 됩니다. 단계만 구분해도 원인 찾기가 훨씬 빨라집니다.
소스가 실행 파일이 되는 네 단계
우리가 g++ main.cpp -o main 명령어를 실행하면, 내부적으로는 다음 네 단계를 거쳐 실행 파일이 만들어집니다.
flowchart LR A[.cpp 소스] --> B[전처리] B --> C[.i] C --> D[컴파일] D --> E[.s 어셈블리] E --> F[어셈블] F --> G[.o 오브젝트] G --> H[링커] H --> I[실행파일]
소스 코드 (.cpp)
↓ 1. 전처리기 (Preprocessor)
전처리된 코드 (.i)
↓ 2. 컴파일러 (Compiler)
어셈블리 코드 (.s)
↓ 3. 어셈블러 (Assembler)
오브젝트 파일 (.o)
↓ 4. 링커 (Linker)
실행 파일
각 단계는 역할이 다르고, 단계마다 중간 파일을 만듭니다. 전처리는 #include와 매크로를 펼치는 단계, 컴파일은 문법과 의미를 분석하고 최적화한 뒤 어셈블리 코드를 만드는 단계, 어셈블은 어셈블리를 기계어로 바꿔 오브젝트 파일을 만드는 단계입니다. 마지막으로 링킹은 여러 오브젝트 파일(.o)을 하나의 실행 파일로 묶습니다. 오브젝트 파일은 기계어로 번역은 끝났지만 다른 파일에 있는 함수나 변수와는 아직 연결되지 않은 조각입니다.
예를 들어 “헤더를 찾을 수 없다”는 전처리 단계, “문법 오류”는 컴파일 단계, “정의를 찾을 수 없다”는 링킹 단계 문제인 경우가 많습니다. 에러 메시지에 나오는 단계를 구분할 수 있으면 원인 좁히기가 수월해집니다.
링크 에러로 “undefined reference to 함수이름”이 나오면, 선언은 있지만 정의(구현)가 이번 빌드에 포함되지 않았다는 뜻입니다. 그 함수가 다른 .cpp 파일에 구현되어 있다면 그 파일을 add_executable이나 빌드 명령에 넣었는지 확인하고, 외부 라이브러리에 있다면 -l라이브러리이름이나 CMake의 target_link_libraries로 링크했는지 확인하면 됩니다. 제49-2편 CMake 링크 에러에서 LNK2019 등 자주 나오는 패턴을 정리해 두었습니다.
컴파일 과정을 알아야 풀리는 여덟 가지 상황
”undefined reference to foo()” 에러
상황: main.cpp에서 foo()를 호출하는데, 컴파일은 되지만 링크 시 에러가 납니다.
원인: foo의 선언은 헤더에 있지만, 정의(구현)가 빌드에 포함되지 않았습니다. 전처리·컴파일 단계에서는 “이 함수가 있다”는 선언만 있으면 되고, 링킹 단계에서 “실제 코드가 어디 있는지”를 찾다가 실패하는 것입니다.
해결: foo를 구현한 .cpp 파일을 빌드 대상에 추가하거나, 해당 함수가 들어 있는 라이브러리를 -l 옵션으로 링크합니다.
헤더가 수천 줄로 펼쳐져 빌드가 느립니다
상황: #include <iostream> 하나만 있어도 전처리 결과가 수만 줄이 됩니다. 여러 헤더를 포함하면 컴파일 시간이 급증합니다.
원인: 전처리 단계에서 #include된 모든 내용이 그대로 삽입됩니다. <iostream>은 내부적으로 <ostream>, <istream>, <ios>와 문자열·로케일 관련 헤더를 줄줄이 끌어오므로, .cpp 파일마다 include할 때마다 수만 줄이 다시 파싱됩니다.
해결: 필요한 헤더만 포함하고, C++20 모듈을 사용하면 전처리 결과가 줄어들어 빌드가 빨라집니다. PCH(Precompiled Header)도 활용할 수 있습니다.
”redefinition of class X” 에러
상황: 헤더 여러 개를 include했더니 “redefinition of class X”라는 컴파일 에러가 납니다.
원인: 이 에러는 컴파일 단계에서, 하나의 .cpp(번역 단위) 안에 같은 클래스 정의가 두 번 들어갔을 때 납니다. 예를 들어 main.cpp가 a.h와 b.h를 include하는데 두 헤더가 모두 x.h를 include하고, x.h에 include guard가 없으면 class X 정의가 두 번 펼쳐집니다. 클래스 정의는 원래 헤더에 두고 여러 .cpp에서 include하는 것이 정상이므로, 서로 다른 .cpp에 같은 정의가 있는 것 자체는 문제가 되지 않습니다.
해결: 모든 헤더에 #pragma once나 include guard를 둡니다. 반면 일반 함수나 전역 변수의 정의를 헤더에 넣으면 이번에는 링크 단계에서 “multiple definition” 에러가 나므로, 이런 것은 헤더에 선언만 두고 정의는 .cpp에 둡니다(inline 함수와 템플릿은 예외).
라이브러리 경로를 못 찾습니다
상황: -lboost_system으로 링크했는데 “cannot find -lboost_system” 에러가 납니다.
원인: 링커가 라이브러리 파일(.a, .so)을 찾는 검색 경로에 해당 라이브러리가 없습니다. -L 옵션으로 경로를 지정하지 않았거나, 라이브러리 이름이 잘못되었습니다.
해결: -L/usr/local/lib처럼 라이브러리 디렉터리를 지정합니다. -lboost_system은 libboost_system.a 또는 libboost_system.so를 찾습니다. 파일 이름에서 lib 접두사와 .a/.so 접미사를 뺀 부분이 -l 뒤에 옵니다.
수정한 코드가 빌드에 반영되지 않습니다
상황: 헤더만 수정했는데, make를 다시 돌려도 이전 결과가 나옵니다.
원인: Makefile이나 빌드 스크립트가 “헤더가 바뀌면 이 .cpp를 다시 컴파일해야 한다”는 의존 관계를 제대로 기술하지 않았습니다. 또는 빌드 캐시가 오래된 결과를 사용하고 있습니다.
해결: CMake 등 빌드 시스템이 헤더 의존성을 자동으로 추적하게 하고, make clean 후 재빌드하거나, ccache 등 캐시를 비운 뒤 다시 빌드합니다.
ABI 호환되지 않는 라이브러리
상황: 다른 컴파일러/표준으로 빌드한 라이브러리를 링크하면 실행 중 크래시가 납니다.
해결: 프로젝트 전체를 같은 컴파일러·같은 C++ 표준으로 빌드합니다. 외부 라이브러리는 extern "C"로 래핑합니다.
순환 의존성으로 컴파일 실패
상황: A.h가 B.h를 include하고, B.h가 A.h를 include해 “incomplete type” 에러가 납니다.
원인: A가 완전히 정의되기 전에 B가 A를 참조해 “incomplete type”이 발생합니다.
해결: 전방 선언을 사용합니다. A.h에 class B;, B.h에 class A;만 두고, #include는 .cpp에서만 합니다.
링크 순서로 인한 undefined reference
상황: mylib가 utils의 함수를 쓰는데, -lmylib -lutils는 되고 -lutils -lmylib 순서로 하면 에러가 납니다.
원인: GNU ld는 정적 라이브러리를 명령줄 순서대로 한 번만 훑으면서, 그 시점까지 해결되지 않은 심볼을 채워 줄 오브젝트만 꺼내 옵니다. -lutils를 먼저 처리할 때는 아직 mylib를 보지 않았으므로 utils에서 가져올 것이 없고, 나중에 mylib가 utils의 함수를 요구해도 링커는 뒤로 돌아가지 않습니다.
해결: 다른 라이브러리를 사용하는 쪽을 앞에, 사용되는 쪽을 뒤에 둡니다. 두 라이브러리가 서로를 참조하는 순환 의존이라면 -Wl,--start-group ... -Wl,--end-group으로 묶어 반복 탐색하게 합니다.
1단계: 전처리 (Preprocessing)
전처리기의 역할
전처리기는 컴파일러가 문법을 보기 전에 소스 코드를 텍스트 수준에서 붙여 넣고 치환하는 단계입니다.
#include헤더 파일 삽입#define매크로 확장#ifdef조건부 컴파일
flowchart TD
A[소스 코드] --> B{#include 처리}
B --> C[헤더 내용 삽입]
C --> D{#define 처리}
D --> E[매크로 치환]
E --> F{#ifdef 처리}
F --> G[조건부 코드 포함/제외]
G --> H[전처리된 .i 파일]
g++ -E로 전처리 결과 보기
아래 main.cpp는 전처리기가 하는 일을 보여 주는 최소 예제입니다. #include <iostream> 자리에는 iostream 헤더 전체 내용이 붙여 넣어지고, #define PI 3.14159 때문에 소스 안의 모든 PI가 3.14159로 치환됩니다. 전처리가 끝나면 PI라는 이름은 사라지고 숫자만 남습니다.
main.cpp:
// 복사해 붙여넣은 뒤: g++ main.cpp -o main && ./main
#include <iostream>
#define PI 3.14159
int main() {
std::cout << "PI = " << PI << std::endl;
return 0;
}
실행 결과:
PI = 3.14159
전처리만 하려면 -E 옵션을 씁니다. 컴파일, 어셈블, 링킹은 하지 않고 헤더 삽입과 매크로 치환만 적용한 결과를 main.i로 저장합니다.
전처리 실행:
g++ -E main.cpp -o main.i
main.i를 열어 보면 #include <iostream> 자리에 수만 줄이 들어가 있고, PI는 모두 3.14159로 바뀌어 있습니다. 전처리기는 C++ 문법을 보지 않고 #으로 시작하는 지시문만 처리해 텍스트를 치환하고 붙여 넣습니다.
결과 (main.i) 요약:
// iostream의 수천 줄 코드...
int main() {
std::cout << "PI = " << 3.14159 << std::endl;
return 0;
}
include guard가 중복 포함을 막는 모습
한 .cpp 안에서 같은 헤더가 여러 번 포함되어 정의가 중복되는 것을 막기 위해 include guard를 씁니다.
// config.h - include guard로 중복 포함 방지
#ifndef CONFIG_H
#define CONFIG_H
#define APP_VERSION "1.0.0"
#define MAX_BUFFER_SIZE 4096
#endif // CONFIG_H
주의사항: 매크로 이름이 프로젝트 전역에서 유일해야 합니다. 짧은 CONFIG_H는 서드파티와 충돌할 수 있습니다.
#pragma once를 지원하는 컴파일러에서는 더 간단하게 쓸 수 있습니다:
// config.h
#pragma once
#define APP_VERSION "1.0.0"
2단계: 컴파일 (Compilation)
컴파일러의 역할
컴파일러 내부 처리 파이프라인:
전처리된 코드 (.i)
↓
1. Lexical Analysis (어휘 분석):
입력: int x = 42;
토큰 스트림:
[INT_KEYWORD] [IDENTIFIER:x] [EQUAL] [NUMBER:42] [SEMICOLON]
어휘 오류 감지:
int 123abc = 42; // 에러: 숫자로 시작하는 식별자
2. Syntax Analysis (구문 분석):
토큰 → Parse Tree → AST (Abstract Syntax Tree)
예시:
int x = 42;
AST:
VarDecl (x)
├─ Type: int
└─ InitExpr
└─ IntegerLiteral: 42
복잡한 예시:
int add(int a, int b) {
return a + b;
}
AST:
FunctionDecl (add)
├─ ReturnType: int
├─ Parameters
│ ├─ ParmVarDecl (a, int)
│ └─ ParmVarDecl (b, int)
└─ Body: CompoundStmt
└─ ReturnStmt
└─ BinaryOperator (+)
├─ DeclRefExpr (a)
└─ DeclRefExpr (b)
구문 오류 감지:
int x = ; // 에러: 표현식 누락
if (x) } // 에러: { 누락
3. Semantic Analysis (의미 분석):
타입 검사:
int x = "hello"; // 에러: const char*를 int로 변환 불가
스코프 검사:
int main() {
int x = y; // 에러: y가 선언되지 않음
}
오버로딩 해석:
void foo(int);
void foo(double);
foo(42); → foo(int) 선택
템플릿 인스턴스화:
template<typename T>
T max(T a, T b) { return a > b ? a : b; }
max(3, 5); → max<int> 생성
max(3.14, 2.71); → max<double> 생성
4. IR 생성 (Intermediate Representation):
플랫폼 독립적 중간 코드 생성
LLVM IR 예시:
define i32 @add(i32 %a, i32 %b) {
%1 = add nsw i32 %a, %b
ret i32 %1
}
GCC GIMPLE 예시:
int add (int a, int b) {
int D.1234;
D.1234 = a + b;
return D.1234;
}
5. 최적화 (Optimization):
상수 폴딩 (Constant Folding):
int x = 2 + 3;
→ int x = 5; // 컴파일 타임 계산
데드 코드 제거 (Dead Code Elimination):
if (false) {
printf("never"); // 제거됨
}
인라이닝 (Inlining):
inline int square(int x) { return x * x; }
int y = square(5);
→ int y = 25; // 함수 호출 제거
루프 최적화:
for (int i = 0; i < 1000; i++) {
arr[i] = 0;
}
→ memset(arr, 0, 1000 * sizeof(int));
벡터화 (SIMD):
for (int i = 0; i < 8; i++) { // float 배열
c[i] = a[i] + b[i];
}
→ vaddps ymm0, ymm1, ymm2 // AVX: float 8개 동시 연산
6. 코드 생성 (Code Generation):
IR → 타겟 어셈블리 생성
int add(int a, int b) {
return a + b;
}
x86-64 어셈블리:
add:
lea eax, [rdi+rsi] # eax = rdi (a) + rsi (b)
ret
ARM64 어셈블리:
add:
add w0, w0, w1 # w0 = w0 (a) + w1 (b)
ret
최적화 레벨 (-O0, -O1, -O2, -O3):
-O0 (최적화 없음):
- 디버그 용이
- 빠른 컴파일
- 느린 실행
-O2 (일반 최적화):
- 상수 폴딩, 인라이닝, 루프 최적화
- 컴파일 시간 적당
- 실행 속도 빠름
-O3 (공격적 최적화):
- 벡터화, 루프 언롤링
- 컴파일 시간 길어짐
- 실행 속도 최대
예시 비교:
int sum = 0;
for (int i = 0; i < 1000; i++) {
sum += arr[i];
}
-O0 어셈블리:
mov eax, 0 # sum = 0
mov ecx, 0 # i = 0
.L2:
cmp ecx, 1000 # i < 1000?
jge .L3
add eax, [arr+rcx*4] # sum += arr[i]
inc ecx # i++
jmp .L2
-O3 어셈블리 (벡터화):
pxor xmm0, xmm0 # sum = 0 (SIMD)
mov ecx, 0
.L2:
movdqu xmm1, [arr+rcx] # int 4개 동시 로드
paddd xmm0, xmm1 # 정수 4개 동시 덧셈
add ecx, 16
cmp ecx, 4000
jl .L2
# 최종 합산 (xmm0의 4개 값)
→ 반복 1회에 원소 4개 처리 (이론상 최대 4배, 실제로는 메모리 대역폭과 루프 오버헤드 때문에 그보다 작음)
g++ -S로 어셈블리 생성
전처리된 main.i를 어셈블리 소스(.s)로 바꾸려면 -S 옵션을 씁니다. 이 단계에서 비로소 C++ 문법을 분석하고, 최적화를 적용한 뒤 어셈블리 명령어로 출력합니다.
g++ -S main.i -o main.s
main.s에는 main 함수가 push, mov, call 같은 명령으로 바뀌어 있습니다. push rbp와 mov rbp, rsp는 스택 프레임을 만드는 함수 프롤로그이고, call 뒤의 긴 이름은 std::cout에 문자열을 출력하는 operator<<의 맹글링된 이름입니다. 고수준 C++ 코드가 CPU 명령으로 어떻게 번역되는지 이 파일에서 확인할 수 있습니다.
결과 (main.s) 예시 (Intel 문법으로 보려면 -masm=intel):
main:
push rbp
mov rbp, rsp
mov edi, OFFSET FLAT:_ZSt4cout
call _ZStlsISt11char_traitsIcEERSt13basic_ostreamIcT_ES5_PKc
...
C++ name mangling
C++는 오버로딩과 네임스페이스를 지원하기 위해 함수 이름에 네임스페이스와 매개변수 타입 정보를 인코딩해 저장합니다. 이것을 name mangling이라고 하며, _ZSt4cout은 std::cout을 나타냅니다. nm main.o로 오브젝트 파일의 심볼을 보고, nm -C나 c++filt로 원래 이름을 복원할 수 있습니다. main은 맹글링되지 않는 특별한 함수라서 그대로 보입니다.
nm main.o | grep main
# 0000000000000000 T main
3단계: 어셈블 (Assembly)
어셈블러의 역할
- 어셈블리 코드를 기계어로 변환
- 오브젝트 파일 생성
g++ -c로 오브젝트 파일 만들기
-c 옵션은 “오브젝트 파일까지만 만들고 링크는 하지 마라”는 의미입니다. main.s 같은 어셈블리 파일을 넣으면 어셈블러가 이를 기계어로 바꾼 오브젝트 파일(.o)을 만듭니다. 이 파일은 아직 다른 .o나 라이브러리와 연결되지 않은 조각 상태입니다.
g++ -c main.s -o main.o
file main.o로 확인하면 ELF 64-bit LSB relocatable, x86-64라고 나옵니다. 64비트 x86용 재배치 가능(relocatable) 오브젝트 파일이라는 뜻으로, 함수와 변수의 최종 주소가 아직 정해지지 않아 링커가 나중에 주소를 채워 넣습니다.
오브젝트 파일 확인:
file main.o
출력 예시:
main.o: ELF 64-bit LSB relocatable, x86-64
4단계: 링킹 (Linking)
링커의 역할
- 여러 오브젝트 파일 결합
- 라이브러리 연결
- 심볼 해결
flowchart LR
subgraph inputs[입력]
A[main.o]
B[utils.o]
C[libc.a]
end
subgraph linker[링커]
D[심볼 해결]
E[주소 배치]
F[재배치]
end
subgraph output[출력]
G[실행 파일]
end
A --> D
B --> D
C --> D
D --> E --> F --> G
여러 .o를 하나의 실행 파일로 링크
여러 파일로 나눠 작성한 코드는 각각 오브젝트 파일로 만든 뒤 링커가 하나의 실행 파일로 묶습니다.
utils.h에는 add 함수의 선언만 있고, utils.cpp에 정의가 있으며, main.cpp는 utils.h를 포함해 add를 호출합니다. 컴파일러는 main.cpp를 컴파일할 때 add의 선언만 알고 정의는 모르므로, main.o에는 “여기서 add를 호출한다”는 빈 자리만 남깁니다. 링커가 main.o와 utils.o를 합치면서 이 자리를 add의 실제 주소로 채웁니다.
utils.h:
#pragma once
int add(int a, int b);
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 << "2 + 3 = " << add(2, 3) << std::endl;
return 0;
}
아래 명령은 두 .cpp를 각각 -c로 컴파일해 main.o, utils.o를 만든 뒤, 두 오브젝트 파일을 링크해 myapp을 만듭니다.
컴파일 및 링킹:
# 각각 컴파일
g++ -c main.cpp -o main.o
g++ -c utils.cpp -o utils.o
# 링킹
g++ main.o utils.o -o myapp
# 실행
./myapp
실행 결과:
2 + 3 = 5
정적 라이브러리와 동적 라이브러리
정적 라이브러리 (.a, .lib)
정적 라이브러리는 오브젝트 파일 여러 개를 ar 도구로 하나의 .a 파일로 묶은 아카이브입니다. ar rcs는 아카이브가 없으면 만들고(c), 지정한 .o를 넣거나 교체하며(r), 심볼 인덱스를 만든다(s)는 뜻입니다. -L.은 현재 디렉터리에서도 라이브러리를 찾으라는 옵션, -lutils는 libutils를 링크하라는 옵션입니다. 링크할 때 실제로 사용한 오브젝트의 코드가 실행 파일 안에 복사되므로, 실행할 때는 libutils.a가 없어도 됩니다. 대신 라이브러리를 고치면 실행 파일을 다시 링크해야 합니다.
# 정적 라이브러리 생성
ar rcs libutils.a utils.o
# 링크
g++ main.o -L. -lutils -o myapp
동적 라이브러리 (.so, .dll)
동적 라이브러리는 -shared -fPIC로 만듭니다. -shared는 실행 파일이 아니라 공유 라이브러리를 만들라는 뜻이고, -fPIC는 어느 주소에 로드되어도 동작하는 위치 독립 코드(Position Independent Code)를 만들라는 뜻입니다. 위치 독립 코드여야 코드 페이지를 고치지 않고 여러 프로세스가 같은 .so를 공유할 수 있습니다. 링크할 때 add의 코드는 실행 파일에 복사되지 않고, 실행 시점에 동적 링커가 libutils.so를 찾아 불러옵니다. 그래서 .so가 표준 경로에 없다면 LD_LIBRARY_PATH=.처럼 경로를 알려 주거나 rpath를 넣어야 합니다. 같은 디렉터리에 libutils.a와 libutils.so가 함께 있으면 링커는 기본적으로 .so를 우선합니다.
# 동적 라이브러리 생성
g++ -shared -fPIC utils.cpp -o libutils.so
# 링크
g++ main.o -L. -lutils -o myapp
# 실행 (라이브러리 경로 지정)
LD_LIBRARY_PATH=. ./myapp
여러 파일로 된 계산기 프로젝트를 단계별로 빌드하기
단계별 시각화: hello.cpp → 실행 파일
// hello.cpp
#include <cstdio>
#define GREET "Hello"
#define SQUARE(x) ((x) * (x))
int main() { printf("%s, %d\n", GREET, SQUARE(3)); return 0; }
| 단계 | 명령 | 결과 |
|---|---|---|
| 1. 전처리 | g++ -E hello.cpp -o hello.i | GREET→"Hello", SQUARE(3)→((3)*(3)), stdio.h 삽입 |
| 2. 컴파일 | g++ -S hello.i -o hello.s | 어셈블리 코드 (push, mov, call 등) |
| 3. 어셈블 | g++ -c hello.s -o hello.o | ELF 오브젝트, nm에 main(T), printf(U) |
| 4. 링킹 | g++ hello.o -o hello | 실행 파일 (libc 연결) |
flowchart LR A[hello.cpp] -->|g++ -E| B[hello.i] B -->|g++ -S| C[hello.s] C -->|g++ -c| D[hello.o] D -->|g++| E[hello]
3개 파일 계산기 (calc.h, calc.cpp, main.cpp)
// calc.h
#pragma once
int add(int a, int b);
int subtract(int a, int b);
int multiply(int a, int b);
// calc.cpp - 구현
// main.cpp - add, subtract, multiply 호출
g++ -std=c++17 main.cpp calc.cpp -o calculator
./calculator # 10+5=15, 10-5=5, 10*5=50
정적 라이브러리
g++ -c calc.cpp -o calc.o
ar rcs libcalc.a calc.o
g++ -c main.cpp -o main.o
g++ main.o -L. -lcalc -o calculator
한 번에 빌드
g++ main.cpp utils.cpp -o myapp 한 줄이 내부적으로 전처리→컴파일→어셈블→링킹을 순서대로 수행합니다.
Makefile과 CMake+Ninja가 단계를 다루는 방식
실무에서는 Make, CMake, Ninja 같은 빌드 시스템이 이 단계들을 조율합니다.
Makefile: 의존성 기반 증분 빌드
# Makefile - calc 프로젝트
CXX = g++
CXXFLAGS = -std=c++17 -Wall -Wextra
TARGET = calculator
OBJS = main.o calc.o
$(TARGET): $(OBJS)
$(CXX) $(OBJS) -o $(TARGET)
%.o: %.cpp calc.h
$(CXX) $(CXXFLAGS) -c $< -o $@
clean:
rm -f $(OBJS) $(TARGET)
.PHONY: clean
실행: make, make -j4 (병렬). calc.h를 수정하면 main.o, calc.o만 재컴파일한 뒤 링킹합니다.
CMake + Ninja
# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(Calculator LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
add_executable(calculator main.cpp calc.cpp)
target_include_directories(calculator PRIVATE ${CMAKE_SOURCE_DIR})
mkdir build && cd build
cmake -G Ninja ..
ninja -j$(nproc)
CMake는 컴파일러의 의존성 출력(-MD 등)을 이용해 헤더 의존성을 자동으로 추적합니다. Make, CMake, Ninja 모두 결국 같은 네 단계를 실행하며, 차이는 의존성을 얼마나 정확하게 추적하고 얼마나 빠르게 병렬 실행하느냐, 여러 플랫폼을 지원하느냐에 있습니다.
undefined reference부터 relocation 에러까지 해결법
undefined reference to 함수이름
에러 메시지 예시:
/tmp/ccXYZ123.o: In function `main':
main.cpp:(.text+0x15): undefined reference to `add(int, int)'
collect2: error: ld returned 1 exit status
원인: add의 선언은 있지만 정의가 빌드에 포함되지 않았습니다.
해결:
# ❌ 잘못된 예: main.cpp만 컴파일
g++ main.cpp -o myapp
# ✅ 올바른 예: add가 구현된 utils.cpp도 함께 컴파일
g++ main.cpp utils.cpp -o myapp
multiple definition of 함수이름
에러 메시지 예시:
utils.o: In function `add(int, int)':
utils.cpp:4: multiple definition of `add(int, int)'
main.o:main.cpp:4: first defined here
원인: 헤더 파일에 일반 함수의 정의를 넣고, 이 헤더를 여러 .cpp에서 include했습니다. 각 .cpp가 컴파일될 때마다 같은 정의가 오브젝트에 들어가므로 링킹 시 중복 정의가 됩니다. include guard는 한 .cpp 안의 중복만 막을 뿐 이 문제는 막지 못합니다.
해결: 헤더에는 선언만 두고 정의는 .cpp 한 곳에만 둡니다. 꼭 헤더에 두고 싶다면 inline을 붙입니다.
// ❌ 잘못된 예: utils.h에 정의
// utils.h
#pragma once
int add(int a, int b) { return a + b; } // 정의를 헤더에 넣음
// ✅ 올바른 예: 선언만 헤더에
// utils.h
#pragma once
int add(int a, int b); // 선언만
fatal error: 헤더파일: No such file or directory
에러 메시지 예시:
main.cpp:2:10: fatal error: myheader.h: No such file or directory
2 | #include "myheader.h"
원인: 전처리기가 myheader.h를 찾지 못했습니다. 현재 디렉터리나 -I로 지정한 경로에 없습니다.
해결:
# -I 옵션으로 헤더 검색 경로 추가
g++ -I./include -I/usr/local/include main.cpp -o main
cannot find -l라이브러리이름
에러 메시지 예시:
/usr/bin/ld: cannot find -lboost_system
collect2: error: ld returned 1 exit status
원인: 링커가 libboost_system.a 또는 libboost_system.so를 찾지 못했습니다.
해결:
# -L로 라이브러리 검색 경로 지정
g++ main.o -L/usr/local/lib -lboost_system -o myapp
# 라이브러리 설치 확인 (Linux)
ldconfig -p | grep boost
undefined reference to vtable for 클래스이름
원인: GCC와 Clang은 클래스에서 인라인이 아닌 첫 번째 가상 함수(key function)가 정의된 .cpp에 vtable을 만듭니다. 그 함수를 선언만 하고 정의하지 않았거나, 정의한 .cpp를 빌드 대상에 넣지 않으면 vtable이 어디에도 생기지 않아 이 에러가 납니다. 가상 소멸자를 선언만 하고 정의를 빠뜨린 경우가 흔합니다.
해결: 모든 가상 함수(순수 가상 함수 제외)의 정의를 작성하고, 그 .cpp를 빌드에 포함합니다.
C와 C++ 혼합 시 “undefined reference”
원인: C로 작성된 라이브러리를 C++에서 사용할 때, C++ name mangling 때문에 심볼 이름이 맞지 않습니다.
해결: C 헤더를 extern "C"로 감쌉니다.
// mylib.h
#ifdef __cplusplus
extern "C" {
#endif
void c_function(int x);
#ifdef __cplusplus
}
#endif
실행 시 동적 라이브러리 에러
에러 메시지 예시:
./myapp: error while loading shared libraries: libutils.so: cannot open shared object file: No such file or directory
./myapp: symbol lookup error: ./myapp: undefined symbol: _ZN5Utils3addEii
원인: 첫 번째는 실행 시점에 동적 링커가 .so 파일 자체를 찾지 못한 경우입니다. 두 번째는 .so는 찾았지만 그 안에 필요한 심볼이 없는 경우로, 링크할 때와 다른 버전의 라이브러리가 로드되었을 때 흔히 납니다.
해결:
# 어떤 .so가 로드되는지 확인
ldd ./myapp
# 라이브러리 경로 지정
LD_LIBRARY_PATH=/path/to/libs ./myapp
# 또는 rpath로 실행 파일에 경로 임베드
g++ main.o -L. -lutils -Wl,-rpath,'$ORIGIN' -o myapp
redefinition of ‘struct/class’
원인: include guard 없이 헤더를 여러 번 포함했거나, 같은 클래스 정의가 여러 헤더에 있습니다.
해결: #pragma once 또는 include guard를 추가합니다. 한 클래스는 한 헤더에만 정의합니다.
undefined reference to typeinfo/vtable
원인: 가상 함수가 있는 클래스의 정의 .cpp를 빌드에 넣지 않았거나, RTTI가 필요한데 -fno-rtti로 비활성화했습니다.
해결: 가상 함수를 구현한 .cpp를 포함하고, RTTI가 필요하면 -fno-rtti를 제거합니다.
relocation … can not be used when making a shared object
에러 메시지 예시:
/usr/bin/ld: utils.o: relocation R_X86_64_32 against `.rodata' can not be used when making a shared object; recompile with -fPIC
원인: -fPIC 없이 컴파일한 오브젝트 파일로 동적 라이브러리를 만들려 했습니다. 정적 라이브러리(.a)를 .so에 링크할 때도, 그 .a가 -fPIC 없이 빌드되었다면 같은 에러가 납니다.
해결: .so에 들어갈 모든 오브젝트를 -fPIC로 컴파일합니다. CMake라면 set(CMAKE_POSITION_INDEPENDENT_CODE ON)을 씁니다. 참고로 비슷해 보이는 “relocation truncated to fit”은 주로 2GB를 넘는 정적 데이터처럼 주소가 기본 코드 모델의 범위를 벗어날 때 나는 다른 에러입니다.
템플릿 인스턴스화 에러
원인: 템플릿 정의가 .cpp에만 있고, 사용하는 타입에 대한 인스턴스화가 해당 .cpp에서만 일어났습니다.
해결: 템플릿 정의를 헤더에 두거나, 사용할 타입에 대해 명시적 인스턴스화(template class Foo<int>;)를 합니다.
LTO 관련 에러
원인: -flto로 만든 오브젝트에는 기계어 대신 컴파일러의 중간 표현이 들어 있습니다. 이런 오브젝트를 일반 ar로 정적 라이브러리에 묶거나, 다른 버전의 컴파일러로 만든 LTO 오브젝트를 섞으면 링커가 심볼을 읽지 못해 에러가 납니다.
해결: GCC라면 ar 대신 gcc-ar(Clang은 llvm-ar)를 쓰고, 한 빌드 안에서는 같은 컴파일러 버전을 씁니다. CMake에서는 CMAKE_INTERPROCEDURAL_OPTIMIZATION을 켜면 이런 도구 선택을 대신해 줍니다.
표준 라이브러리 ABI 불일치
원인: GCC 5부터 libstdc++는 std::string과 std::list의 구현을 바꾸면서 새 ABI와 구 ABI를 함께 제공합니다. _GLIBCXX_USE_CXX11_ABI=0으로 빌드된 라이브러리와 기본값(1)으로 빌드한 코드를 섞으면, std::string이 들어간 함수에서 undefined reference가 나거나 실행 중 크래시가 납니다. 컴파일러 종류나 버전이 다른 경우에도 비슷한 문제가 생깁니다.
해결: 라이브러리와 애플리케이션을 같은 컴파일러와 같은 ABI 설정으로 빌드합니다. 에러 메시지의 심볼에 __cxx11이 보이는지로 어느 쪽 ABI인지 구분할 수 있습니다.
Windows min/max 매크로
원인: Windows 헤더의 min/max 매크로가 std::min/std::max를 가립니다. 해결: #define NOMINMAX를 #include <windows.h> 전에 추가합니다.
에러 요약표
| 에러 유형 | 발생 단계 | 대표 원인 | 해결 방향 |
|---|---|---|---|
fatal error: ... No such file | 전처리 | 헤더 경로 없음 | -I 경로 추가 |
syntax error, expected ';' | 컴파일 | 문법 오류 | 코드 수정 |
undefined reference | 링킹 | 정의 없음, .cpp 미포함 | 구현 파일 추가, -l 옵션 |
multiple definition | 링킹 | 헤더에 정의, 중복 링크 | 선언/정의 분리 |
cannot find -lxxx | 링킹 | 라이브러리 경로 없음 | -L 경로 추가 |
vtable/typeinfo undefined | 링킹 | 가상 함수 미구현 | 구현 .cpp 포함 |
recompile with -fPIC | 링킹 | -fPIC 누락 | .so에 들어갈 코드에 -fPIC |
cannot open shared object file | 실행 시 | .so 경로 없음 | LD_LIBRARY_PATH 또는 rpath |
symbol lookup error | 실행 시 | 다른 버전의 .so 로드 | ldd로 로드 경로 확인 |
| LTO 관련 에러 | 링킹 | 일반 ar 사용, 컴파일러 버전 혼용 | gcc-ar, 같은 컴파일러 |
__cxx11 심볼 불일치 | 링킹/실행 시 | libstdc++ 이중 ABI | 같은 ABI 설정으로 빌드 |
min/max 매크로 | 전처리/컴파일 | Windows 헤더 | NOMINMAX 또는 (std::min) |
헤더 설계·경고·링크 순서 원칙
헤더 설계 원칙
- 선언과 정의 분리: 함수와 전역 변수는 헤더에 선언만, .cpp에 정의를 둡니다. 클래스 정의,
inline함수, 템플릿은 헤더에 둡니다. - include guard: 모든 헤더에
#pragma once또는#ifndef/#define/#endif를 둡니다. - 최소 의존성: 필요한 헤더만 include하고, 포인터나 참조만 쓰는 곳은 전방 선언으로 대체합니다.
// ✅ 좋은 예: utils.h
#pragma once
int add(int a, int b); // 선언만
// ✅ 좋은 예: utils.cpp
#include "utils.h"
int add(int a, int b) { return a + b; } // 정의
빌드 시스템 활용
CMake는 여러 플랫폼을 지원하고 헤더 의존성을 자동으로 추적합니다. 라이브러리 간 의존성을 target_link_libraries로 선언해 두면 링크 순서도 CMake가 맞춰 줍니다.
컴파일러 경고 활용
-Wall -Wextra로 흔한 실수를 일찍 발견합니다. CI에서는 -Werror로 경고를 에러로 처리해 품질을 유지합니다.
라이브러리 링크 순서
정적 라이브러리는 사용하는 쪽이 앞에, 사용되는 쪽이 뒤에 오도록 합니다. mylib이 utils를 사용하면 -lmylib -lutils 순서입니다. 순환 의존 시에는 -Wl,--start-group/-Wl,--end-group으로 묶습니다.
디버그/릴리스 분리
디버그 빌드는 -O0 -g로 디버그 정보를 넣고, 릴리스 빌드는 -O2나 -O3을 쓴 뒤 필요하면 strip으로 심볼을 제거합니다.
pkg-config 활용
g++ main.cpp -o main $(pkg-config --cflags --libs openssl)
병렬 컴파일·ccache·PCH로 빌드 시간 줄이기
병렬 컴파일로 빌드 시간 단축
# make -j: CPU 코어 수만큼 병렬 컴파일
make -j$(nproc)
# CMake
cmake --build . -j$(nproc)
ccache로 재컴파일 속도 향상
# ccache 설치 후 g++ 대신 ccache g++ 사용
# 동일한 소스는 캐시에서 바로 반환
export CC="ccache gcc"
export CXX="ccache g++"
Precompiled Header (PCH) 사용
자주 쓰는 헤더를 미리 컴파일해 두면 전처리·파싱 시간을 줄일 수 있습니다.
// pch.h
#pragma once
#include <iostream>
#include <vector>
#include <string>
# PCH 생성
g++ -std=c++17 -x c++-header pch.h -o pch.h.gch
# 사용 (GCC가 자동으로 pch.h.gch 활용)
g++ -std=c++17 -include pch.h main.cpp -o main
불필요한 헤더 제거
// ❌ 나쁜 예: 전체 헤더 포함
#include <iostream> // 필요할 때만
#include <vector> // 사용하지 않으면 제거
// ✅ 좋은 예: 전방 선언으로 헤더 의존성 줄이기
class MyClass; // #include "MyClass.h" 대신
void useMyClass(MyClass& obj);
증분 빌드 활용
CMake, Make 등은 변경된 파일만 다시 컴파일합니다. 헤더 의존성이 제대로 기술되어 있으면, 헤더 수정 시 해당 헤더를 쓰는 .cpp만 재컴파일됩니다.
컴파일러 최적화 레벨
# 디버그: -O0 (빠른 컴파일, 느린 실행)
g++ -O0 -g main.cpp -o main
# 릴리스: -O2 또는 -O3 (느린 컴파일, 빠른 실행)
g++ -O2 main.cpp -o main
CMake·CI·Docker로 재현 가능한 빌드 구성
CMake를 이용한 크로스 플랫폼 빌드
# CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(MyApp LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
add_executable(myapp
main.cpp
calc.cpp
)
target_include_directories(myapp PRIVATE ${CMAKE_SOURCE_DIR})
mkdir build && cd build
cmake ..
cmake --build . -j$(nproc)
릴리스/디버그 빌드 분리
# 디버그 빌드 (심볼 포함, 최적화 없음)
cmake -DCMAKE_BUILD_TYPE=Debug ..
cmake --build .
# 릴리스 빌드 (최적화, 스트립)
cmake -DCMAKE_BUILD_TYPE=Release ..
cmake --build .
strip myapp # 심볼 제거로 실행 파일 크기 감소
CI/CD에서의 빌드
# GitHub Actions 예시
- name: Build
run: |
mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=Release ..
cmake --build . -j$(nproc)
정적 링킹으로 배포 단순화
# 모든 의존성을 실행 파일에 포함 (Linux)
g++ -static main.o utils.o -o myapp
# 주의: glibc 등은 정적 링킹이 제한될 수 있음
빌드 버전 정보 주입
# 컴파일 시 버전 정보를 매크로로 전달
g++ -DVERSION=\"$(git describe --tags)\" -DBUILD_DATE=\"$(date -I)\" main.cpp -o main
// main.cpp
#include <iostream>
#ifndef VERSION
#define VERSION "unknown"
#endif
int main() {
std::cout << "Version: " << VERSION << "\n";
return 0;
}
컴파일러별 플래그 정리
| 목적 | GCC/Clang | MSVC |
|---|---|---|
| C++ 표준 | -std=c++17 | /std:c++17 |
| 경고 레벨 | -Wall -Wextra | /W4 |
| 헤더 경로 | -I/path | /I path |
| 라이브러리 경로 | -L/path | /LIBPATH:path |
| 라이브러리 링크 | -lname | name.lib |
| 디버그 심볼 | -g | /Zi |
| 최적화 | -O2 | /O2 |
단계별 디버깅
에러가 발생했을 때 어느 단계에서 문제인지 확인하는 방법은 다음과 같습니다.
# 1. 전처리만: 헤더·매크로 문제 확인
g++ -E main.cpp -o main.i && head -100 main.i
# 2. 어셈블리까지: 컴파일 단계 확인
g++ -S main.cpp -o main.s && cat main.s
# 3. 오브젝트까지: 링크 전 단계 확인
g++ -c main.cpp -o main.o && nm main.o
# 4. 링크: undefined reference 등 확인
g++ main.o utils.o -o myapp -v # -v로 상세 로그
Unity Build·설치
대형 프로젝트에서는 set(CMAKE_UNITY_BUILD ON)으로 컴파일 단위를 줄여 빌드 시간을 단축합니다. 설치는 install(TARGETS myapp RUNTIME DESTINATION bin) 후 cmake --install .로 합니다.
Docker 기반 재현 가능 빌드
팀 전체가 동일한 환경으로 빌드하려면 Docker를 사용합니다.
FROM ubuntu:22.04
RUN apt-get update && apt-get install -y build-essential cmake ninja-build
WORKDIR /app
COPY . .
RUN mkdir build && cd build && cmake -G Ninja .. && ninja
vcpkg/Conan으로 의존성 관리
# vcpkg: cmake -DCMAKE_TOOLCHAIN_FILE=.../vcpkg.cmake ..
find_package(Boost REQUIRED)
target_link_libraries(myapp PRIVATE Boost::system)
컴파일 병목 분석
# Clang: -ftime-trace / GCC: -ftime-report
g++ -ftime-report -c main.cpp -o main.o
조건부 컴파일
#ifdef _WIN32
#define NOMINMAX
#include <windows.h>
#elif __linux__
#include <unistd.h>
#endif
자주 묻는 질문 (FAQ)
Q. undefined reference가 나면 어디부터 확인하나요?
A. 1) 정의가 있는 .cpp를 빌드 대상에 넣었는지, 2) 외부 라이브러리라면 -l·-L로 링크했는지, 3) C 라이브러리라면 extern "C"로 감쌌는지 확인하세요.
Q. 빌드가 너무 느려요.
A. 병렬 빌드(-j), ccache, PCH, 불필요한 헤더 제거, C++20 모듈 도입을 검토하세요.