
빌드 과정: 소스 코드가 실행 파일이 되기까지
전처리(Preprocessing), 컴파일(Process), 어셈블리(Assembly), 링킹(Linking)의 4단계를 해부한다. 정적 라이브러리와 동적 라이브러리의 차이까지.

전처리(Preprocessing), 컴파일(Process), 어셈블리(Assembly), 링킹(Linking)의 4단계를 해부한다. 정적 라이브러리와 동적 라이브러리의 차이까지.
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

미로를 탈출하는 두 가지 방법. 넓게 퍼져나갈 것인가(BFS), 한 우물만 팔 것인가(DFS). 최단 경로는 누가 찾을까?

이름부터 빠릅니다. 피벗(Pivot)을 기준으로 나누고 또 나누는 분할 정복 알고리즘. 왜 최악엔 느린데도 가장 많이 쓰일까요?

매번 3-Way Handshake 하느라 지쳤나요? 한 번 맺은 인연(TCP 연결)을 소중히 유지하는 법. HTTP 최적화의 기본.

처음 C 프로젝트를 만들다가 이런 에러를 봤다.
undefined reference to 'calculate_sum'
collect2: error: ld returned 1 exit status
분명 함수를 만들었는데 "정의되지 않았다"는 게 무슨 소리지? 코드 실행도 안 돼서 결과를 볼 수가 없었다. 컴파일러가 뭐라고 하는지 하나도 모르겠고, gcc main.c라고 치면 되는 줄 알았는데 왜 안 되는지 알 수 없었다.
나중에 알고 보니 이건 컴파일 에러가 아니라 링킹 에러였다. 그리고 이 차이를 이해하려면 소스 코드가 실행 파일이 되는 전체 과정을 알아야 한다는 걸 깨달았다. 컴파일이랑 빌드가 같은 거라고 생각했던 과거의 나에게 이 글을 바친다.
많은 초보 개발자들이 이렇게 생각한다. "컴파일하면 실행 파일 나오는 거 아니야?" 틀렸다. 컴파일은 빌드 과정의 일부일 뿐이다.
내가 이해한 방식으로 정리해본다. 빌드는 자동차 공장과 같다. 자동차 한 대를 만들기 위해서는:
우리가 gcc main.c라고 치는 순간, 이 4단계가 자동으로 일어난다. 하지만 각 단계를 분리해서 볼 수 있다. 그리고 에러가 어느 단계에서 났는지 알면 문제를 빠르게 찾을 수 있다.
결국 이거였다. 빌드는 하나의 작업이 아니라 4개의 독립적인 프로그램이 차례로 실행되는 파이프라인이다. 이걸 이해하고 나니까 모든 게 명확해졌다.
main.c → [Preprocessor] → main.i → [Compiler] → main.s
↓
main.s → [Assembler] → main.o → [Linker] → a.out (실행 파일)
↓
라이브러리 (.a, .so)
ASCII로 표현하면 이렇다:
┌──────────┐ ┌──────────────┐ ┌────────┐
│ main.c │ --> │ cpp (전처리) │ --> │ main.i │
│ (소스) │ │ #include 처리 │ │ (순수C) │
└──────────┘ └──────────────┘ └────────┘
│
▼
┌──────────────┐ ┌────────┐
│ cc1 (컴파일) │ <-- │ main.i │
│ C -> ASM │ └────────┘
└──────────────┘
│
▼
┌────────┐ ┌──────────────┐
│ main.s │ --> │ as (어셈블) │
│ (어셈) │ │ ASM -> 기계어 │
└────────┘ └──────────────┘
│
▼
┌────────┐ ┌──────────────┐
│ main.o │ --> │ ld (링커) │
│ (목적) │ │ Object 합체 │
└────────┘ └──────────────┘
│ │
┌────────┴──────┐ │
│ lib.o, libc.so│ ------┘
└───────────────┘
│
▼
┌────────┐
│ a.out │
│ (실행) │
└────────┘
이제 각 단계를 직접 실행하면서 무슨 일이 일어나는지 확인해보자.
간단한 C 프로그램으로 실험해보자.
// main.c
#include <stdio.h>
#define MAX_VALUE 100
int main() {
int x = MAX_VALUE;
printf("Value: %d\n", x);
return 0;
}
gcc -E main.c -o main.i
이 명령어는 전처리만 수행한다. main.i를 열어보면:
// 앞에 stdio.h의 내용이 몽땅 들어간다 (약 800줄)
// ... (생략) ...
typedef unsigned long size_t;
extern int printf(const char *, ...);
// ... (생략) ...
int main() {
int x = 100; // MAX_VALUE가 100으로 치환됨
printf("Value: %d\n", x);
return 0;
}
무슨 일이 일어났나?
#include <stdio.h> → stdio.h 파일 내용 전체를 복사해서 붙여넣기#define MAX_VALUE 100 → 코드에서 MAX_VALUE를 모두 100으로 치환#ifdef, #ifndef)결과적으로 순수한 C 코드가 된다. 더 이상 #으로 시작하는 전처리 지시자가 없다. 하지만 파일 크기가 10줄에서 1000줄이 넘게 커진다. 왜? stdio.h가 엄청 크기 때문이다.
와닿았던 비유: 전처리기는 복사기다. #include는 "이 파일을 복사해서 여기에 붙여넣어"라는 명령일 뿐이다.
gcc -S main.c -o main.s
이 명령어는 전처리 + 컴파일까지만 수행한다. main.s를 열어보면:
.file "main.c"
.section .rodata
.LC0:
.string "Value: %d\n"
.text
.globl main
.type main, @function
main:
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl $100, -4(%rbp) # x = 100
movl -4(%rbp), %eax
movl %eax, %esi
leaq .LC0(%rip), %rdi # printf 문자열 로드
movl $0, %eax
call printf@PLT # printf 호출
movl $0, %eax
leave
ret
무슨 일이 일어났나?
movl, pushq, call 등이 단계에서 문법 에러(Syntax Error)가 잡힌다. 세미콜론을 빼먹거나 변수 이름을 잘못 쓰면 여기서 에러가 난다.
gcc -c main.c -o main.o
이 명령어는 어셈블까지 수행한다 (전처리 + 컴파일 + 어셈블). main.o는 바이너리 파일이라 텍스트 에디터로 열면 깨진 문자가 보인다.
hexdump -C main.o | head
00000000 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 |.ELF............|
00000010 01 00 3e 00 01 00 00 00 00 00 00 00 00 00 00 00 |..>.............|
무슨 일이 일어났나?
.text (코드), .data (데이터), .bss (초기화 안 된 변수) 섹션으로 나뉨중요: 이 파일은 아직 실행 불가능하다! 왜? printf 함수가 어디 있는지 모르기 때문이다. call printf라는 명령어는 있지만, printf의 실제 메모리 주소는 0x00000000으로 비어있다. 이걸 채워주는 게 다음 단계다.
gcc main.o -o main
또는 링커를 직접 호출:
ld -o main main.o -lc -dynamic-linker /lib64/ld-linux-x86-64.so.2
무슨 일이 일어났나?
main.o에서 printf를 호출하는데, 정의가 없다libc.so)에서 printf를 찾는다printf의 실제 주소를 연결한다.o 파일을 하나의 메모리 주소 공간으로 합친다_start 함수, 나중에 main을 호출함)결과적으로 main (또는 a.out) 실행 파일이 생성되고, 이제 실행 가능하다:
./main
# 출력: Value: 100
받아들였던 깨달음: 컴파일러는 파일 단위로 일한다. 링커는 프로젝트 전체를 본다. 그래서 여러 파일로 나뉜 프로젝트에서는 링커가 필수다.
처음에 이 개념을 받아들이기 어려웠다. "라이브러리를 연결한다"는 게 도대체 무슨 뜻일까?
도서관 비유로 이해했다:
정적 라이브러리 (.a, .lib): 도서관에서 필요한 책을 복사해서 내 가방에 넣는다. 집에 가져가도 읽을 수 있다. 하지만 가방이 무겁다. 그리고 같은 책을 100명이 복사하면 종이 낭비다.
동적 라이브러리 (.so, .dll): 도서관에서 도서관 카드만 받는다. 집에서 책을 읽으려면 도서관이 열려있어야 한다. 가방은 가볍지만, 도서관이 문을 닫으면 못 읽는다 (dll not found 에러).
# 정적 라이브러리 만들기
gcc -c mylib.c -o mylib.o
ar rcs libmylib.a mylib.o
# 정적 링킹
gcc main.c -L. -lmylib -static -o main_static
# 파일 크기 확인
ls -lh main_static
# 결과 - 약 800KB (libc가 통째로 들어감)
장점: 이 파일만 있으면 어디서든 실행된다. 의존성 걱정 없음. 단점: 파일 크기가 크다. 라이브러리 버그를 고쳐도 실행 파일을 다시 빌드해야 한다.
# 동적 라이브러리 만들기
gcc -shared -fPIC mylib.c -o libmylib.so
# 동적 링킹
gcc main.c -L. -lmylib -o main_dynamic
# 파일 크기 확인
ls -lh main_dynamic
# 결과 - 약 8KB (참조만 저장)
# 실행 시 필요한 라이브러리 확인
ldd main_dynamic
# linux-vdso.so.1
# libmylib.so => ./libmylib.so
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
장점: 파일 작다. 여러 프로그램이 하나의 .so를 공유해서 메모리 절약.
단점: 실행 시 라이브러리가 없으면 프로그램이 안 돌아간다. 윈도우의 "DLL Hell" 문제.
리눅스에서는 /lib, /usr/lib에 공통 라이브러리가 저장되고, LD_LIBRARY_PATH로 경로를 지정할 수 있다.
파일이 10개면 gcc a.c b.c c.c ... j.c라고 칠 수 있다. 파일이 1000개면? 손가락이 부러진다.
더 큰 문제는 증분 빌드(Incremental Build)다. a.c 하나만 수정했는데 1000개를 다 다시 컴파일하면 시간이 너무 오래 걸린다. Chrome 소스 코드는 컴파일하는 데 2시간 걸린다.
Make는 이 문제를 해결한다:
# 컴파일러 설정
CC = gcc
CFLAGS = -Wall -Wextra -O2 -g
LDFLAGS = -lm
# 타겟 설정
TARGET = my_app
OBJS = main.o utils.o calculator.o
HEADERS = utils.h calculator.h
# 기본 타겟 ($ make)
all: $(TARGET)
# 링킹 단계 - 모든 .o 파일을 합쳐서 실행 파일 생성
# $@ = 타겟 이름 (my_app)
# $^ = 모든 의존성 (main.o utils.o calculator.o)
$(TARGET): $(OBJS)
@echo "Linking $@..."
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
@echo "Build successful!"
# 컴파일 단계 - .c 파일을 .o 파일로 변환
# $< = 첫 번째 의존성 (*.c)
# $@ = 타겟 이름 (*.o)
%.o: %.c $(HEADERS)
@echo "Compiling $<..."
$(CC) $(CFLAGS) -c $< -o $@
# 청소 ($ make clean)
clean:
@echo "Cleaning up..."
rm -f $(OBJS) $(TARGET)
@echo "Clean done!"
# 전체 재빌드 ($ make rebuild)
rebuild: clean all
# 디버그 빌드 ($ make debug)
debug: CFLAGS += -DDEBUG -O0
debug: clean all
# 릴리즈 빌드 ($ make release)
release: CFLAGS += -O3 -DNDEBUG
release: clean all
# Phony 타겟 (파일이 아닌 명령어)
.PHONY: all clean rebuild debug release
동작 원리:
make를 치면 all 타겟 실행all은 $(TARGET)에 의존 → my_app이 필요my_app은 $(OBJS)에 의존 → main.o, utils.o, calculator.o가 필요.o 파일의 타임스탬프를 확인:
main.c가 main.o보다 최신이면 → 다시 컴파일main.o가 최신이면 → 스킵.o가 준비되면 링킹 수행$ make
Compiling main.c...
Compiling utils.c...
Compiling calculator.c...
Linking my_app...
Build successful!
# utils.c만 수정하고 다시 빌드
$ make
Compiling utils.c... # 이것만 다시 컴파일됨
Linking my_app...
Build successful!
정리해본다. Makefile은 의존성 그래프(Dependency Graph)를 정의한다. Make는 이 그래프를 보고 최소한의 작업만 수행한다. 이게 엔지니어링의 생산성이다.
Makefile의 문제점: 플랫폼마다 다르다. 리눅스 Makefile은 윈도우에서 안 돌아간다.
CMake는 Makefile을 자동으로 생성해주는 도구다:
# CMakeLists.txt
cmake_minimum_required(VERSION 3.10)
project(MyApp)
set(CMAKE_C_STANDARD 11)
set(CMAKE_BUILD_TYPE Release)
add_executable(my_app main.c utils.c calculator.c)
target_link_libraries(my_app m) # -lm
사용법:
mkdir build && cd build
cmake ..
make
CMake가 현재 OS를 감지해서 적절한 Makefile (또는 Visual Studio 프로젝트, Xcode 프로젝트)을 생성한다. 크로스 플랫폼 개발의 표준이다.
gcc -O3를 붙이면 왜 빨라질까? 컴파일러는 코드의 의미를 바꾸지 않으면서 더 빠른 코드로 변환한다.
// 원본 코드
int sum = 0;
for (int i = 0; i < 4; i++) {
sum += i;
}
-O0 (최적화 없음)
movl $0, -8(%rbp) # sum = 0
movl $0, -4(%rbp) # i = 0
jmp .L2
.L3:
movl -4(%rbp), %eax
addl %eax, -8(%rbp) # sum += i
addl $1, -4(%rbp) # i++
.L2:
cmpl $3, -4(%rbp) # i < 4?
jle .L3 # 루프 반복
-O2 (중간 최적화)
movl $0, %eax # sum = 0
addl $0, %eax # sum += 0
addl $1, %eax # sum += 1
addl $2, %eax # sum += 2
addl $3, %eax # sum += 3
-O3 (최대 최적화)
movl $6, %eax # sum = 6 (컴파일러가 미리 계산)
컴파일러가 0 + 1 + 2 + 3 = 6을 미리 계산해버렸다!
일반적으로 컴파일러는 파일 단위로만 최적화한다. a.c를 컴파일할 때는 b.c를 모른다.
// utils.c
int add(int a, int b) {
return a + b;
}
// main.c
extern int add(int, int);
int main() {
return add(3, 4);
}
일반 컴파일:
gcc -O2 -c utils.c -o utils.o
gcc -O2 -c main.c -o main.o
gcc utils.o main.o -o main
이 경우 main.c에서 add 함수를 인라인할 수 없다. 컴파일 시점에 add의 구현을 모르기 때문이다.
gcc -O2 -flto -c utils.c -o utils.o
gcc -O2 -flto -c main.c -o main.o
gcc -flto utils.o main.o -o main
링킹 단계에서 모든 코드를 다시 분석해서 최적화한다. add 함수가 인라인되어:
main:
movl $7, %eax # return 7 (컴파일러가 미리 계산)
ret
대형 프로젝트에서 10-20% 성능 향상을 볼 수 있다. 단점: 링킹 시간이 매우 길어진다.
1단계: 프로파일링 빌드
gcc -fprofile-generate -O2 main.c -o main
./main # 실행하면 main.gcda 생성 (프로파일 데이터)
2단계: 프로파일 기반 최적화 빌드
gcc -fprofile-use -O2 main.c -o main_optimized
컴파일러가 "어떤 함수가 자주 호출되는지", "어떤 분기가 자주 선택되는지" 알고 최적화한다. Chrome, Firefox 같은 브라우저가 이렇게 빌드된다.
내 맥북(ARM64)에서 라즈베리 파이(ARM32)용 프로그램을 빌드하려면?
# ARM32용 크로스 컴파일러 설치
brew install arm-linux-gnueabihf-gcc
# 크로스 컴파일
arm-linux-gnueabihf-gcc -o hello_arm hello.c
# 파일 확인
file hello_arm
# hello_arm: ELF 32-bit LSB executable, ARM
이 파일은 맥북에서 실행 안 되지만, 라즈베리 파이로 복사해서 실행하면 된다.
안드로이드 NDK도 크로스 컴파일러다. 내 PC(x86)에서 안드로이드 폰(ARM)용 네이티브 코드를 빌드한다.
# Android NDK 사용
$NDK/toolchains/llvm/prebuilt/darwin-x86_64/bin/aarch64-linux-android21-clang \
-o app.so app.c -shared
C/C++ 빌드는 복잡하다. 그래서 현대 언어들은 빌드 시스템을 언어에 통합했다.
자바스크립트는 인터프리터 언어지만, 웹팩은 번들링(Bundling)을 수행한다:
npx webpack
.js 파일을 하나로 합친다 (C의 링킹과 비슷)C와의 차이: 컴파일 단계가 없다. 소스 코드 그대로 실행되기 때문.
cargo build --release
cargo build --target aarch64-unknown-linux-gnu)C와의 차이: 빌드 시스템이 표준화되어 있다. Makefile 안 써도 됨.
go build main.go
C와의 차이: 링킹이 간단하다. 모든 것을 하나의 바이너리로 만든다.
정리해본다. 현대 언어들은 C의 빌드 복잡성을 보고 "이건 언어 차원에서 해결하자"고 결정했다. 그래서 빌드 시스템이 언어 툴체인에 통합되어 있다.
리눅스 실행 파일(a.out)은 ELF (Executable and Linkable Format) 포맷을 따른다.
readelf -h a.out
ELF Header:
Magic: 7f 45 4c 46 02 01 01 00
Class: ELF64
Data: 2's complement, little endian
Type: EXEC (Executable file)
Machine: Advanced Micro Devices X86-64
Entry point address: 0x401040
ELF 파일 구조:
┌─────────────────┐
│ ELF Header │ <- 파일 메타데이터
├─────────────────┤
│ Program Header │ <- 메모리 로딩 정보
├─────────────────┤
│ .text │ <- 실행 코드 (READ-ONLY)
├─────────────────┤
│ .rodata │ <- 문자열 리터럴 (READ-ONLY)
├─────────────────┤
│ .data │ <- 초기화된 전역 변수
├─────────────────┤
│ .bss │ <- 초기화 안 된 전역 변수 (파일에 공간 안 차지)
├─────────────────┤
│ .symtab │ <- 심볼 테이블 (디버깅용)
└─────────────────┘
섹션별 역할:
.text: 기계어 코드. 읽기 전용. 여기에 쓰려고 하면 Segfault..rodata: "Hello, World!\n" 같은 문자열. 읽기 전용..data: int count = 10; 같은 초기화된 전역 변수..bss: int buffer[1000]; 같은 초기화 안 된 변수. 파일에는 크기만 기록되고, 실행 시 0으로 채워짐.size a.out
text data bss dec hex filename
1234 200 8000 9434 24da a.out
흥미로운 점: .bss 섹션은 파일 크기를 차지하지 않는다. 그래서 int arr[1000000]을 선언해도 실행 파일이 커지지 않는다. 실행 시 메모리만 할당된다.
undefined reference to 'foo'
원인: 링킹 에러. 함수 선언은 있지만 정의가 없다.
해결:
.c 파일을 빌드에 포함했는지 확인-lm, -lpthread)multiple definition of 'count'
원인: 같은 심볼(함수 또는 전역 변수)이 여러 곳에서 정의됨.
해결:
extern 사용Segmentation fault (core dumped)
원인: 잘못된 메모리 접근. 문자열 리터럴 수정, 널 포인터 역참조 등.
디버깅:
gcc -g main.c -o main # 디버그 심볼 포함
gdb ./main
(gdb) run
(gdb) backtrace # 에러 발생 위치 확인
빌드 과정을 받아들인 방식으로 정리해본다:
#include를 파일 내용으로 치환. #define을 값으로 치환..o 파일 생성..o 파일과 라이브러리를 합쳐서 실행 파일 생성.결국 이거였다: 빌드는 4개의 독립적인 프로그램이 파이프라인으로 연결된 과정이다. 각 단계의 입력과 출력을 이해하면 에러를 빠르게 해결할 수 있다.
정적 라이브러리는 도서관에서 책을 복사하는 것, 동적 라이브러리는 도서관 카드를 받는 것. Makefile은 의존성 그래프를 정의해서 최소한의 재컴파일만 수행한다. 컴파일러 최적화는 코드의 의미를 바꾸지 않으면서 더 빠른 기계어를 생성한다.
이 모든 걸 이해하고 나니, undefined reference 에러가 더 이상 무섭지 않다. 그냥 링커가 심볼을 못 찾았다는 뜻이니까. 빌드 과정을 이해하는 게 좋은 엔지니어의 시작이라고 받아들였다.