C++ 컴파일 과정 | "undefined reference" 에러가 나는 이유 (전처리·링킹 4단계)

C++ 컴파일 과정 | "undefined reference" 에러가 나는 이유 (전처리·링킹 4단계)

이 글의 핵심

C++ 컴파일 과정에 대한 실전 가이드입니다.

[C++ 실전 가이드 #5] C++ 컴파일 과정 분석

이 글을 읽으면 전처리·컴파일·어셈블·링킹 4단계와 정적·동적 라이브러리 차이, undefined reference 해결까지 이해할 수 있습니다.

g++ main.cpp -o main 명령어 하나로 실행 파일이 만들어지지만, 내부적으로는 여러 단계를 거칩니다. 비유하면 요리할 때 “재료 준비 → 손질 → 조리 → 담기”처럼, 한 번에 끝나는 것이 아니라 단계별로 중간 결과물이 만들어지는 것과 같습니다. 이 과정을 이해하면 컴파일 에러를 더 빠르게 해결하고, 빌드 시간을 최적화할 수 있습니다.

이 글에서는 소스 코드가 실행 파일이 되는 과정을 전처리, 컴파일, 어셈블, 링킹 네 단계로 나누어 자세히 설명합니다.

목차

  1. 컴파일 과정 개요
  2. 문제 시나리오: 왜 컴파일 과정을 알아야 하나?
  3. 전처리 (Preprocessing)
  4. 컴파일 (Compilation)
  5. 어셈블 (Assembly)
  6. 링킹 (Linking)
  7. 완전한 컴파일 예제 (다중 파일 프로젝트)
  8. 자주 발생하는 에러와 해결법
  9. 베스트 프랙티스
  10. 컴파일 최적화 팁
  11. 프로덕션 빌드 패턴

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_systemlibboost_system.a 또는 libboost_system.so를 찾는다. 파일 이름에서 lib 접두사와 .a/.so 접미사를 뺀 부분이 -l 뒤에 온다.

시나리오 5: 수정한 코드가 빌드에 반영되지 않는다

상황: 헤더만 수정했는데, make를 다시 돌려도 이전 결과가 나온다.

원인: Makefile이나 빌드 스크립트가 “헤더가 바뀌면 이 .cpp를 다시 컴파일해야 한다”는 의존 관계를 제대로 기술하지 않았다. 또는 빌드 캐시가 오래된 결과를 사용하고 있다.

해결: CMake 등 빌드 시스템이 헤더 의존성을 자동으로 추적하게 하고, make clean 후 재빌드하거나, ccache 등 캐시를 비운 뒤 다시 빌드한다.

시나리오 6: ABI 호환되지 않는 라이브러리

상황: 다른 컴파일러/표준으로 빌드한 라이브러리 링크 시 실행 크래시.

해결: 프로젝트 전체를 같은 컴파일러·같은 C++ 표준으로 빌드. 외부 라이브러리는 extern "C"로 래핑.

시나리오 7: 순환 의존성으로 컴파일 실패

상황: A.hB.h를 include하고, B.hA.h를 include해 “incomplete type” 에러.

원인: A가 완전히 정의되기 전에 BA를 참조해 “incomplete type” 발생.

해결: 전방 선언 사용. A.hclass B;, B.hclass A;만 두고, #include는 .cpp에서만.

시나리오 8: 링크 순서로 인한 undefined reference

상황: -lutils -lmylib는 되는데 -lmylib -lutils 순서로 하면 에러.

원인: 정적 링킹 시 링커가 한 번만 목록을 훑는다. mylibutils를 참조하면 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**는 소스 안의 모든 PI3.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.cpputils.h를 포함해 add를 호출합니다. 컴파일러는 main.cpp를 볼 때 add의 정의를 모르고 선언만 알지만, 링커가 main.outils.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.cpputils.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.은 “현재 디렉터리에서도 라이브러리를 찾아라”, -lutilslibutils.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.olibutils.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.iGREET"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.oELF 오브젝트, nmmain(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: 라이브러리 링크 순서

의존하는 쪽이 나중에 오도록 한다. mylibutils를 사용하면 -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/ClangMSVC
C++ 표준-std=c++17/std:c++17
경고 레벨-Wall -Wextra/W4
헤더 경로-I/path/I path
라이브러리 경로-L/path/LIBPATH:path
라이브러리 링크-lnamename.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 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례