본문으로 건너뛰기
Previous
Next
C++ 컴파일 과정 | 'undefined reference" 에러가 나는 이유 (전처리·링킹 4단계)

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

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

이 글의 핵심

C++ 컴파일 과정의 C++, 컴파일, "undefined, C++ 실전 가이드 5 C++ 컴파일 과정 분석를 실전 예제와 함께 상세히 설명합니다.

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

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

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

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

💡 초보자를 위한 암기 카드: 에러 메시지에 No such file·헤더 이름이 보이면 → 전처리 쪽, expected·문법 이야기면 → 컴파일, undefined reference·LNK2019면 → 링크 쪽을 먼저 보면 됩니다. 한 단계씩만 줄여 가도 원인 찾기가 훨씬 빨라집니다.

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 헤더 전체 내용이 그 자리에 붙여 넣어지고, #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의 수천 줄이 들어가 있고, 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)

컴파일러의 역할

컴파일러 내부 처리 파이프라인:

전처리된 코드 (.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++) {
       c[i] = a[i] + b[i];
   }
   → vaddps xmm0, xmm1, xmm2  // 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 어셈블리 (벡터화):
  xorps xmm0, xmm0     # sum = 0 (SIMD)
  mov ecx, 0
.L2:
  movups xmm1, [arr+rcx]  # 4개 원소 동시 로드
  addps xmm0, xmm1        # 4개 동시 덧셈
  add ecx, 16
  cmp ecx, 4000
  jl .L2
  # 최종 합산 (xmm0의 4개 값)

→ 4배 빠름!
  • 문법 검사
  • 최적화
  • 어셈블리 코드 생성

예제

전처리된 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

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.


이 글에서 다루는 키워드 (관련 검색어)

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로 증분 빌드 설정

초보자를 위한 한 줄 정리

터미널에서 g++ 한 방에 되는 것처럼 보여도, 안에서는 전처리 → 컴파일 → 어셈블 → 링크가 순서대로 돕니다. “어느 단계에서 멈췄는지”만 구분해도 같은 로그라도 고칠 파일과 옵션이 바로 정해져서 시간을 아낍니다.

💡 초보자 팁: g++ -E, -S, -c로 중간 결과를 한 번씩만 떠보면, 이후에 CMake나 CI 로그를 읽을 때 훨씬 덜 무섭게 느껴집니다.


자주 묻는 질문 (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 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례

심화 부록: 구현·운영 관점

이 부록은 앞선 본문에서 다룬 주제(「C++ 컴파일 과정 | ‘undefined reference” 에러가 나는 이유 (전처리·링킹 4단계)」)를 구현·런타임·운영 관점에서 다시 압축합니다. 도메인별 세부 구현은 글마다 다르지만, 입력 검증 → 핵심 연산 → 부작용(I/O·네트워크·동시성) → 관측의 흐름으로 장애를 나누면 원인 추적이 빨라집니다.

내부 동작과 핵심 메커니즘

flowchart TD
  A[입력·요청·이벤트] --> B[파싱·검증·디코딩]
  B --> C[핵심 연산·상태 전이]
  C --> D[부작용: I/O·네트워크·동시성]
  D --> E[결과·관측·저장]
sequenceDiagram
  participant C as 클라이언트/호출자
  participant B as 경계(런타임·게이트웨이·프로세스)
  participant D as 의존성(API·DB·큐·파일)
  C->>B: 요청/이벤트
  B->>D: 조회·쓰기·RPC
  D-->>B: 지연·부분 실패·재시도 가능
  B-->>C: 응답 또는 오류(코드·상관 ID)
  • 불변 조건(Invariant): 버퍼 경계, 프로토콜 상태, 트랜잭션 격리, FD 상한 등 단계별로 문장으로 적어 두면 디버깅 비용이 줄어듭니다.
  • 결정성: 순수 층과 시간·네트워크·스케줄에 의존하는 층을 분리해야 테스트와 장애 분석이 쉬워집니다.
  • 경계 비용: 직렬화, 인코딩, syscall 횟수, 락 경합, 할당·GC, 캐시 미스를 의심 목록에 둡니다.
  • 백프레셔: 생산자가 소비자보다 빠를 때 버퍼·큐·스트림에서 속도를 줄이는 신호를 어디에 둘지 정의합니다.

프로덕션 운영 패턴

영역운영 관점 질문
관측성요청 단위 상관 ID, 에러율·지연 p95/p99, 의존성 타임아웃·재시도가 대시보드에 보이는가
안전성입력 검증·권한·비밀·감사 로그가 코드 경로마다 일관적인가
신뢰성재시도는 멱등 연산에만 적용되는가, 서킷 브레이커·백오프·DLQ가 있는가
성능캐시·배치 크기·커넥션 풀·인덱스·백프레셔가 데이터 규모에 맞는가
배포롤백 룬북, 카나리/블루그린, 마이그레이션·피처 플래그가 문서화되어 있는가
용량피크 트래픽·디스크·FD·스레드 풀 상한을 주기적으로 검증하는가

스테이징은 데이터 양·네트워크 RTT·동시성을 프로덕션에 가깝게 맞출수록 재현율이 올라갑니다.

확장 예시: 엔드투엔드 미니 시나리오

앞선 본문 주제(「C++ 컴파일 과정 | ‘undefined reference” 에러가 나는 이유 (전처리·링킹 4단계)」)를 배포·운영 흐름에 맞춰 옮긴 체크리스트입니다. 도메인에 맞게 단계 이름만 바꿔 적용할 수 있습니다.

  1. 입력 계약 고정: 스키마·버전·최대 페이로드·타임아웃·에러 코드를 경계에 둔다.
  2. 핵심 경로 계측: 요청 ID, 단계별 지연, 외부 호출 결과 코드를 로그·메트릭·트레이스에서 한 흐름으로 본다.
  3. 실패 주입: 의존성 타임아웃·5xx·부분 데이터·락 대기를 스테이징에서 재현한다.
  4. 호환·롤백: 설정/마이그레이션/클라이언트 버전을 되돌릴 수 있는지 확인한다.
  5. 부하 후 검증: 피크 대비 p95/p99, 에러율, 리소스 상한, 알림 임계값을 점검한다.
handle(request):
  ctx = newCorrelationId()
  validated = validateSchema(request)
  authorize(validated, ctx)
  result = domainCore(validated)
  persistOrEmit(result, idempotentKey)
  recordMetrics(ctx, latency, outcome)
  return result

문제 해결(Troubleshooting)

증상가능 원인조치
간헐적 실패레이스, 타임아웃, 외부 의존성, DNS최소 재현 스크립트, 분산 트레이스·로그 상관관계, 재시도·서킷 설정 점검
성능 저하N+1, 동기 I/O, 락 경합, 과도한 직렬화, 캐시 미스프로파일러·APM으로 핫스팟 확인 후 한 가지씩 제거
메모리 증가캐시 무제한, 구독/리스너 누수, 대용량 버퍼, 커넥션 미반납상한·TTL·힙/FD 스냅샷 비교
빌드·배포만 실패환경 변수, 권한, 플랫폼 차이, lockfileCI 로그와 로컬 diff, 런타임·이미지 버전 핀
설정 불일치프로필·시크릿·기본값, 리전스키마 검증된 설정 단일 소스와 배포 매트릭스 표준화
데이터 불일치비멱등 재시도, 부분 쓰기, 캐시 무효화 누락멱등 키·아웃박스·트랜잭션 경계 재검토

권장 순서: (1) 최소 재현 (2) 최근 변경 범위 축소 (3) 환경·의존성 차이 (4) 관측으로 가설 검증 (5) 수정 후 회귀·부하 테스트.

배포 전에는 git addgit commitgit pushnpm run deploy 순서를 권장합니다.