C++ 컴파일 과정 | "undefined reference" 에러가 나는 이유 (전처리·링킹 4단계)
이 글의 핵심
C++ 컴파일 과정에 대한 실전 가이드입니다.
[C++ 실전 가이드 #5] C++ 컴파일 과정 분석
이 글을 읽으면 전처리·컴파일·어셈블·링킹 4단계와 정적·동적 라이브러리 차이, undefined reference 해결까지 이해할 수 있습니다.
g++ main.cpp -o main 명령어 하나로 실행 파일이 만들어지지만, 내부적으로는 여러 단계를 거칩니다. 비유하면 요리할 때 “재료 준비 → 손질 → 조리 → 담기”처럼, 한 번에 끝나는 것이 아니라 단계별로 중간 결과물이 만들어지는 것과 같습니다. 이 과정을 이해하면 컴파일 에러를 더 빠르게 해결하고, 빌드 시간을 최적화할 수 있습니다.
이 글에서는 소스 코드가 실행 파일이 되는 과정을 전처리, 컴파일, 어셈블, 링킹 네 단계로 나누어 자세히 설명합니다.
목차
- 컴파일 과정 개요
- 문제 시나리오: 왜 컴파일 과정을 알아야 하나?
- 전처리 (Preprocessing)
- 컴파일 (Compilation)
- 어셈블 (Assembly)
- 링킹 (Linking)
- 완전한 컴파일 예제 (다중 파일 프로젝트)
- 자주 발생하는 에러와 해결법
- 베스트 프랙티스
- 컴파일 최적화 팁
- 프로덕션 빌드 패턴
1. 컴파일 과정 개요
우리가 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·매크로를 펼치는 것, 컴파일은 문법 분석 후 중간 표현으로 만드는 것, 어셈블은 CPU 명령어로 바꾸는 것, 링킹은 여러 오브젝트 파일(.o—컴파일·어셈블까지 거친 중간 결과물로, 아직 다른 파일과 연결되지 않은 상태. 비유하면 조립 전 부품 한 덩어리)을 한 실행 파일로 묶는 것입니다. 이 과정을 이해하면 컴파일 에러가 발생했을 때 어느 단계에서 문제가 생긴 것인지 빠르게 파악할 수 있습니다.
예를 들어 “헤더를 찾을 수 없다”는 전처리 단계, “문법 오류”는 컴파일 단계, “정의를 찾을 수 없다”는 링킹 단계 문제인 경우가 많습니다. 에러 메시지에 나오는 단계를 구분할 수 있으면 원인 좁히기가 수월해집니다.
링크 에러가 났을 때: “undefined reference to 함수이름”이 나오면 선언은 있지만 정의(구현)가 이번 빌드에 포함되지 않았다는 뜻입니다. 그 함수가 다른 .cpp 파일에 구현되어 있다면 그 파일을 add_executable이나 빌드 명령에 넣었는지 확인하고, 외부 라이브러리에 있다면 -l라이브러리이름 또는 CMake의 target_link_libraries로 링크했는지 확인하면 됩니다. 제49-2편 CMake 링크 에러에서 LNK2019 등 자주 나오는 패턴을 정리해 두었습니다.
실무 팁: g++ -E main.cpp로 전처리만 수행해 보면 #include가 어떻게 펼쳐지는지 확인할 수 있고, 링크 에러가 날 때는 “undefined reference to …” 메시지로 선언은 있지만 정의(구현)가 없다는 것을 알 수 있습니다. 정의가 다른 .cpp에 있다면 그 파일을 빌드 대상에 포함했는지, 또는 라이브러리로 링크했는지 확인하면 됩니다.
2. 문제 시나리오: 왜 컴파일 과정을 알아야 하나?
시나리오 1: “undefined reference to foo()” 에러
상황: main.cpp에서 foo()를 호출하는데, 컴파일은 되지만 링크 시 에러가 난다.
원인: foo의 선언은 헤더에 있지만, 정의(구현)가 빌드에 포함되지 않았다. 전처리·컴파일 단계에서는 “이 함수가 있다”는 선언만 있으면 되고, 링킹 단계에서 “실제 코드가 어디 있는지”를 찾다가 실패하는 것이다.
해결: foo를 구현한 .cpp 파일을 빌드 대상에 추가하거나, 해당 함수가 들어 있는 라이브러리를 -l 옵션으로 링크한다.
시나리오 2: 헤더가 수천 줄로 펼쳐져 빌드가 느리다
상황: #include <iostream> 하나만 있어도 전처리 결과가 수만 줄이 된다. 여러 헤더를 포함하면 컴파일 시간이 급증한다.
원인: 전처리 단계에서 #include된 모든 내용이 그대로 삽입된다. <iostream>은 내부적으로 <string>, <vector> 등 많은 헤더를 끌어오므로, 한 번 include할 때마다 수천 줄이 복사된다.
해결: 필요한 헤더만 포함하고, C++20 모듈을 사용하면 전처리 결과가 줄어들어 빌드가 빨라진다. PCH(Precompiled Header)도 활용할 수 있다.
시나리오 3: “redefinition of class X” 에러
상황: 헤더 파일을 여러 .cpp에서 include했더니 “클래스 X가 중복 정의되었다”는 에러가 난다.
원인: 헤더에 클래스 정의가 있고, 이 헤더를 A.cpp와 B.cpp에서 모두 include하면, 링킹 시 같은 심볼이 두 번 정의된 것으로 처리된다.
해결: 헤더에는 선언만 두고, 정의는 .cpp에 둔다. 클래스·함수 템플릿은 예외적으로 헤더에 정의해도 된다. #pragma once 또는 include guard로 중복 include는 막을 수 있다.
시나리오 4: 라이브러리 경로를 못 찾는다
상황: -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 뒤에 온다.
시나리오 5: 수정한 코드가 빌드에 반영되지 않는다
상황: 헤더만 수정했는데, make를 다시 돌려도 이전 결과가 나온다.
원인: Makefile이나 빌드 스크립트가 “헤더가 바뀌면 이 .cpp를 다시 컴파일해야 한다”는 의존 관계를 제대로 기술하지 않았다. 또는 빌드 캐시가 오래된 결과를 사용하고 있다.
해결: CMake 등 빌드 시스템이 헤더 의존성을 자동으로 추적하게 하고, make clean 후 재빌드하거나, ccache 등 캐시를 비운 뒤 다시 빌드한다.
시나리오 6: ABI 호환되지 않는 라이브러리
상황: 다른 컴파일러/표준으로 빌드한 라이브러리 링크 시 실행 크래시.
해결: 프로젝트 전체를 같은 컴파일러·같은 C++ 표준으로 빌드. 외부 라이브러리는 extern "C"로 래핑.
시나리오 7: 순환 의존성으로 컴파일 실패
상황: 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에서만.
시나리오 8: 링크 순서로 인한 undefined reference
상황: -lutils -lmylib는 되는데 -lmylib -lutils 순서로 하면 에러.
원인: 정적 링킹 시 링커가 한 번만 목록을 훑는다. mylib가 utils를 참조하면 utils가 아직 링크되지 않은 시점에 실패한다.
해결: 의존하는 쪽이 나중에 오도록 링크 순서 조정. 순환 의존 시 --start-group/--end-group 사용.
3. 전처리 (Preprocessing)
전처리기의 역할
쉽게 말해 전처리기는 “컴파일러가 본격적으로 문법을 보기 전에, 소스 코드를 정리·붙여 넣기·치환하는 단계”입니다.
#include헤더 파일 삽입#define매크로 확장#ifdef조건부 컴파일
flowchart TD
A[소스 코드] --> B{#include 처리}
B --> C[헤더 내용 삽입]
C --> D{#define 처리}
D --> E[매크로 치환]
E --> F{#ifdef 처리}
F --> G[조건부 코드 포함/제외]
G --> H[전처리된 .i 파일]
예제
아래 main.cpp는 전처리기가 무엇을 하는지 보여주는 최소 예제입니다. **#include <iostream>은 컴파일 전에 iostream 헤더 전체 내용이 그 자리에 붙여 넣어지고, #define PI 3.14159**는 소스 안의 모든 PI가 3.14159로 치환됩니다. 그래서 전처리 후에는 PI라는 이름이 사라지고 숫자만 남습니다.
main.cpp (복사해 붙여넣은 뒤 g++ main.cpp -o main && ./main 로 실행 가능):
// 복사해 붙여넣은 뒤: 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> 자리에는 iostream의 수천 줄이 들어가 있고, PI는 모두 3.14159로 바뀌어 있습니다. 즉 전처리 단계에서는 “문법”을 보지 않고, 지시문(#으로 시작하는 줄)만 처리해 텍스트를 치환·붙여 넣는 역할을 합니다.
결과 (main.i) 요약:
// iostream의 수천 줄 코드...
int main() {
std::cout << "PI = " << 3.14159 << std::endl;
return 0;
}
include guard 예제
헤더가 여러 번 포함될 때 중복 정의를 막기 위해 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"
4. 컴파일 (Compilation)
컴파일러의 역할
- 문법 검사
- 최적화
- 어셈블리 코드 생성
예제
전처리된 main.i를 어셈블리 소스(.s)로 바꾸려면 -S 옵션을 씁니다. 이 단계에서 비로소 C++ 문법을 분석하고, 최적화를 적용한 뒤, CPU가 이해하는 어셈블리 명령어 형태로 출력합니다.
g++ -S main.i -o main.s
main.s에는 main 함수가 push, mov, call 같은 어셈블리 명령으로 바뀌어 있습니다. push rbp·mov rbp, rsp는 함수 프롤로그(스택 프레임 설정), call 뒤의 긴 이름은 std::cout으로 출력할 때 부르는 내부 함수입니다. 즉 “고수준 C++ 코드가 저수준 CPU 명령으로 어떻게 번역되는지”를 이 파일에서 확인할 수 있습니다.
결과 (main.s) 예시:
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 main.o | grep main
# 0000000000000000 T main
5. 어셈블 (Assembly)
어셈블러의 역할
- 어셈블리 코드를 기계어로 변환
- 오브젝트 파일 생성
예제
-c 옵션은 “컴파일만 하고 링크는 하지 마라”는 의미입니다. main.s 같은 어셈블리 파일을 넣으면, 어셈블러가 이를 기계어(바이너리)로 바꾼 오브젝트 파일(.o)을 만듭니다. 이 .o 파일은 아직 다른 .o나 라이브러리와 연결되지 않은 “조각” 상태입니다.
g++ -c main.s -o main.o
file main.o로 확인하면 ELF 64-bit LSB relocatable, x86-64라고 나옵니다. 즉 “64비트 x86용, 재배치 가능한 오브젝트 파일”이라는 뜻으로, 링커가 나중에 여러 .o를 합쳐 실행 파일을 만들 때 사용합니다.
오브젝트 파일 확인:
file main.o
출력 예시:
main.o: ELF 64-bit LSB relocatable, x86-64
6. 링킹 (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 함수의 선언만 있습니다. #pragma once는 이 헤더가 한 번만 포함되도록 하는 지시문입니다. utils.cpp에는 add의 정의(구현)가 있고, main.cpp는 utils.h를 포함해 add를 호출합니다. 컴파일러는 main.cpp를 볼 때 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;
}
아래 명령은 1·2번에서 main.cpp와 utils.cpp를 각각 -c로 컴파일해 main.o, utils.o를 만들고, 3번에서 두 오브젝트 파일을 링크해 myapp 실행 파일을 만듭니다. 4번으로 실행하면 2 + 3 = 5가 출력됩니다.
컴파일 및 링킹:
# 각각 컴파일
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
정적 vs 동적 라이브러리
정적 라이브러리 (.a, .lib)
- 실행 파일에 포함
- 크기가 큼
- 독립 실행 가능
정적 라이브러리는 오브젝트 파일(.o)들을 ar 도구로 하나의 .a 파일로 묶은 것입니다. ar rcs는 “새 아카이브를 만들고(필요하면), 지정한 .o를 추가한다”는 의미입니다. -L.은 “현재 디렉터리에서도 라이브러리를 찾아라”, -lutils는 libutils.a를 링크하라는 뜻입니다. 이렇게 링크하면 add 등 사용한 코드가 실행 파일 안에 복사되어 들어가므로, 나중에 libutils.a 없이도 myapp만으로 실행할 수 있습니다.
# 정적 라이브러리 생성
ar rcs libutils.a utils.o
# 링크
g++ main.o -L. -lutils -o myapp
동적 라이브러리 (.so, .dll)
- 실행 시 로드
- 크기가 작음
- 라이브러리 파일 필요
동적 라이브러리는 -shared -fPIC로 만듭니다. -shared는 “실행 파일이 아니라 공유 라이브러리로 만들어라”, -fPIC는 “위치 독립 코드”로 만들어 여러 프로세스가 같은 .so를 메모리에 공유할 수 있게 합니다. 링크할 때는 main.o와 libutils.so를 연결하지만, add 코드는 실행 파일에 복사되지 않고, 실행 시점에 libutils.so를 불러와 사용합니다. 그래서 실행할 때 LD_LIBRARY_PATH=.처럼 .so가 있는 경로를 알려줘야 할 수 있습니다.
# 동적 라이브러리 생성
g++ -shared -fPIC utils.cpp -o libutils.so
# 링크
g++ main.o -L. -lutils -o myapp
# 실행 (라이브러리 경로 지정)
LD_LIBRARY_PATH=. ./myapp
7. 완전한 컴파일 예제 (다중 파일 프로젝트)
단계별 시각화: 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]
예제 1: 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
예제 2: 정적 라이브러리
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
예제 3: 한 번에 빌드
g++ main.cpp utils.cpp -o myapp 한 줄이 내부적으로 전처리→컴파일→어셈블→링킹을 순서대로 수행한다.
예제 4: 빌드 시스템별 컴파일 과정
실무에서는 Make, CMake, Ninja 같은 빌드 시스템을 사용합니다. 각 도구가 4단계(전처리·컴파일·어셈블·링킹)를 어떻게 조율하는지 살펴봅니다.
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는 헤더 의존성을 자동 추적합니다. 정리: Make·CMake·Ninja 모두 동일한 4단계를 사용하며, 차이는 의존성 추적·병렬 속도·크로스 플랫폼 지원입니다.
8. 자주 발생하는 에러와 해결법
에러 1: 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
에러 2: 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가 컴파일될 때마다 같은 정의가 오브젝트에 들어가, 링킹 시 중복 정의로 처리된다.
해결: 헤더에는 선언만 두고, 정의는 .cpp 한 곳에만 둔다.
// ❌ 잘못된 예: 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); // 선언만
에러 3: 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
에러 4: 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
에러 5: undefined reference to vtable for 클래스이름
원인: 가상 함수를 선언만 하고 정의(구현)를 하지 않았다. 또는 해당 .cpp를 빌드 대상에 넣지 않았다.
해결: 가상 함수의 정의를 구현한 .cpp를 빌드에 포함한다.
에러 6: 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
에러 7: symbol lookup error (동적 라이브러리)
에러 메시지 예시:
./myapp: symbol lookup error: ./myapp: undefined symbol: _ZN5Utils3addEii
원인: 실행 시점에 동적 라이브러리(.so)를 찾지 못했거나, 라이브러리 버전이 맞지 않다.
해결:
# 라이브러리 경로 지정
LD_LIBRARY_PATH=/path/to/libs ./myapp
# 또는 rpath로 실행 파일에 경로 임베드
g++ main.o -L. -lutils -Wl,-rpath,'$ORIGIN' -o myapp
에러 8: redefinition of ‘struct/class’
원인: include guard 없이 헤더를 여러 번 포함했거나, 같은 클래스 정의가 여러 헤더에 있다.
해결: #pragma once 또는 include guard 추가. 한 클래스는 한 헤더에만 정의한다.
에러 9: undefined reference to typeinfo/vtable
원인: 가상 함수가 있는 클래스의 정의 .cpp를 빌드에 넣지 않았거나, RTTI가 필요한데 -fno-rtti로 비활성화했다.
해결: 가상 함수를 구현한 .cpp를 포함하고, RTTI가 필요하면 -fno-rtti를 제거한다.
에러 10: relocation truncated to fit
원인: -fPIC 없이 동적 라이브러리를 만들려 했다.
해결: 동적 라이브러리 빌드 시 -fPIC를 추가한다.
에러 11: 템플릿 인스턴스화 에러
원인: 템플릿 정의가 .cpp에만 있고, 사용하는 타입에 대한 인스턴스화가 해당 .cpp에서만 일어났다.
해결: 템플릿 정의를 헤더에 두거나, 사용할 타입에 대해 명시적 인스턴스화(template class Foo<int>;)를 한다.
에러 12: LTO 관련 에러
원인: -flto 사용 시 Fortran 런타임 등 일부 라이브러리가 LTO와 비호환. 해결: -fno-lto 또는 호환 라이브러리만 링크.
에러 13: C++ 표준 불일치
원인: -std=c++14 라이브러리를 -std=c++17 프로젝트에서 링크 시 ABI 불일치. 해결: set(CMAKE_CXX_STANDARD 17)로 통일.
에러 14: 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 포함 |
relocation truncated | 링킹 | -fPIC 누락 | 동적 라이브러리에 -fPIC |
symbol lookup error | 실행 시 | .so 경로 없음 | LD_LIBRARY_PATH 또는 rpath |
LTO plugin failed | 링킹 | LTO 비호환 라이브러리 | -fno-lto 또는 호환 라이브러리 |
C++ 표준 불일치 | 실행 시 | ABI 불일치 | 동일 -std로 통일 |
min/max 매크로 | 전처리/컴파일 | Windows 헤더 | NOMINMAX 또는 (std::min) |
9. 베스트 프랙티스 (Best Practices)
BP1: 헤더 설계 원칙
- 선언과 정의 분리: 헤더에는 선언만, .cpp에는 정의. 인라인·템플릿은 예외.
- Include guard 필수:
#pragma once또는#ifndef/#define/#endif로 중복 포함 방지. - 최소 의존성: 필요한 헤더만 include. 전방 선언으로 대체 가능하면 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; } // 정의
BP2: 빌드 시스템 활용
- CMake 권장: 크로스 플랫폼, 헤더 의존성 자동 추적.
- 의존성 명시:
target_link_libraries로 링크 순서·의존성 명확히.
BP3: 컴파일러 경고 활용
-Wall -Wextra로 흔한 실수 조기 발견. CI에서는 -Werror로 경고를 에러로 처리해 품질 유지.
BP4: 라이브러리 링크 순서
의존하는 쪽이 나중에 오도록 한다. mylib이 utils를 사용하면 -lutils -lmylib 순서. 순환 의존 시 -Wl,--start-group/--end-group 사용.
BP5: 디버그/릴리스 분리
- 디버그:
-O0 -g, 심볼 포함. 릴리스:-O2또는-O3,strip으로 심볼 제거.
BP6: 단계별 디버깅
g++ -E main.cpp -o main.i # 전처리만
g++ -S main.cpp -o main.s # 어셈블리까지
g++ -c main.cpp -o main.o && nm main.o # 심볼 확인
g++ main.o utils.o -o myapp -v # 링크 상세 로그
BP7: pkg-config 활용
g++ main.cpp -o main $(pkg-config --cflags --libs openssl)
10. 컴파일 최적화 팁
팁 1: 병렬 컴파일로 빌드 시간 단축
# make -j: CPU 코어 수만큼 병렬 컴파일
make -j$(nproc)
# CMake
cmake --build . -j$(nproc)
팁 2: ccache로 재컴파일 속도 향상
# ccache 설치 후 g++ 대신 ccache g++ 사용
# 동일한 소스는 캐시에서 바로 반환
export CC="ccache gcc"
export CXX="ccache g++"
팁 3: 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
팁 4: 불필요한 헤더 제거
// ❌ 나쁜 예: 전체 헤더 포함
#include <iostream> // 필요할 때만
#include <vector> // 사용하지 않으면 제거
// ✅ 좋은 예: 전방 선언으로 헤더 의존성 줄이기
class MyClass; // #include "MyClass.h" 대신
void useMyClass(MyClass& obj);
팁 5: 증분 빌드 활용
CMake, Make 등은 변경된 파일만 다시 컴파일한다. 헤더 의존성이 제대로 기술되어 있으면, 헤더 수정 시 해당 헤더를 쓰는 .cpp만 재컴파일된다.
팁 6: 컴파일러 최적화 레벨
# 디버그: -O0 (빠른 컴파일, 느린 실행)
g++ -O0 -g main.cpp -o main
# 릴리스: -O2 또는 -O3 (느린 컴파일, 빠른 실행)
g++ -O2 main.cpp -o main
11. 프로덕션 빌드 패턴
패턴 1: 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)
패턴 2: 릴리스/디버그 빌드 분리
# 디버그 빌드 (심볼 포함, 최적화 없음)
cmake -DCMAKE_BUILD_TYPE=Debug ..
cmake --build .
# 릴리스 빌드 (최적화, 스트립)
cmake -DCMAKE_BUILD_TYPE=Release ..
cmake --build .
strip myapp # 심볼 제거로 실행 파일 크기 감소
패턴 3: CI/CD에서의 빌드
# GitHub Actions 예시
- name: Build
run: |
mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=Release ..
cmake --build . -j$(nproc)
패턴 4: 정적 링킹으로 배포 단순화
# 모든 의존성을 실행 파일에 포함 (Linux)
g++ -static main.o utils.o -o myapp
# 주의: glibc 등은 정적 링킹이 제한될 수 있음
패턴 5: 빌드 버전 정보 주입
# 컴파일 시 버전 정보를 매크로로 전달
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;
}
패턴 6: 컴파일러별 플래그 정리
| 목적 | 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 |
패턴 7: 단계별 디버깅
에러가 발생했을 때 어느 단계에서 문제인지 확인하는 방법:
# 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로 상세 로그
패턴 8: Unity Build·설치
대형 프로젝트에서 set(CMAKE_UNITY_BUILD ON)으로 컴파일 단위를 줄여 빌드 시간을 단축. 설치: install(TARGETS myapp RUNTIME DESTINATION bin) 후 cmake --install ..
패턴 9: 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
패턴 10: vcpkg/Conan으로 의존성 관리
# vcpkg: cmake -DCMAKE_TOOLCHAIN_FILE=.../vcpkg.cmake ..
find_package(Boost REQUIRED)
target_link_libraries(myapp PRIVATE Boost::system)
패턴 11: 컴파일 병목 분석
# Clang: -ftime-trace / GCC: -ftime-report
g++ -ftime-report -c main.cpp -o main.o
패턴 12: 조건부 컴파일
#ifdef _WIN32
#define NOMINMAX
#include <windows.h>
#elif __linux__
#include <unistd.h>
#endif
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- CMake 입문 | 수십 개 파일 컴파일할 때 필요한 빌드 자동화 (CMakeLists.txt 기초)
- C++ CMake 고급 | 멀티 타겟·외부 라이브러리 관리 (대규모 프로젝트 빌드)
- C++ LNK2019 | “unresolved external symbol” 링커 에러 원인 5가지와 해결법
이 글에서 다루는 키워드 (관련 검색어)
C++ 컴파일 과정, 전처리 컴파일 링킹, 오브젝트 파일, undefined reference, 정적 라이브러리 동적 라이브러리, 링크 에러, 빌드 4단계, 헤더 파일 include 등으로 검색하시면 이 글이 도움이 됩니다.
마무리
핵심 요약
✅ 전처리: #include, #define 처리. ✅ 컴파일: AST → 어셈블리. ✅ 어셈블: 기계어 → .o. ✅ 링킹: .o + 라이브러리 → 실행 파일.
실용성: “undefined reference”는 링킹, “syntax error”는 컴파일, “No such file”은 전처리 단계 문제. 단계별로 원인을 좁히면 해결이 빠르다.
구현 체크리스트
-
-E,-S,-c로 단계별 출력 확인 - include guard, 헤더에는 선언·.cpp에는 정의
-
undefined reference시 정의·링크 대상 확인 - CMake/Make로 증분 빌드 설정
자주 묻는 질문 (FAQ)
Q. undefined reference가 나면 어디부터 확인하나요?
A. 1) 정의가 있는 .cpp를 빌드 대상에 넣었는지, 2) 외부 라이브러리라면 -l·-L로 링크했는지, 3) C 라이브러리라면 extern "C"로 감쌌는지 확인하세요.
Q. 빌드가 너무 느려요.
A. 병렬 빌드(-j), ccache, PCH, 불필요한 헤더 제거, C++20 모듈 도입을 검토하세요.
관련 글
- C++ 전처리기 완벽 가이드 | #define·#ifdef
- C++ GDB 기초 완벽 가이드 | 브레이크포인트·워치포인트
- C++ LLDB 기초 완벽 가이드 | macOS·브레이크포인트
- CMake 입문 | 수십 개 파일 컴파일할 때 필요한 빌드 자동화 (CMakeLists.txt 기초)
- C++ 스택 vs 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례