컴파일 과정 — 소스코드에서 실행파일까지
이 토픽을 마치면
컴파일러와 인터프리터의 차이를 설명할 수 있고, 소스코드가 기계어로 변환되는 단계를 이해하며, Python/JavaScript/C가 각각 어떤 방식으로 실행되는지 알게 됩니다.
컴퓨터는 소스코드를 읽을 수 없다
우리가 작성하는 코드는 사람을 위한 텍스트입니다.
print("Hello, World!")CPU가 이해하는 것은 기계어 — 0과 1로 된 명령어입니다.
10110000 01001000 (mov al, 'H')
11001101 00100001 (int 21h)소스코드 → 기계어 변환이 필요합니다. 이 변환을 하는 프로그램이 컴파일러와 인터프리터입니다.
컴파일러 vs 인터프리터
| 컴파일러 | 인터프리터 | |
|---|---|---|
| 변환 시점 | 실행 전에 전체를 한 번에 변환 | 실행하면서 한 줄씩 변환 |
| 결과물 | 실행파일 (바이너리) | 없음 (즉시 실행) |
| 에러 발견 | 컴파일 시점에 전체 에러 표시 | 해당 줄 실행 시점에 에러 |
| 실행 속도 | 빠름 (이미 변환 완료) | 상대적으로 느림 |
| 대표 언어 | C, C++, Rust, Go | Python, JavaScript, Ruby |
비유하면: 컴파일러는 책 번역 (전체를 먼저 번역한 뒤 출판), 인터프리터는 동시통역 (말하는 즉시 번역)입니다.
컴파일 단계 — C 언어의 경우
C 코드가 실행파일이 되는 과정:
소스코드 (.c)
↓ 전처리 (Preprocessing)
전처리된 코드
↓ 컴파일 (Compilation)
어셈블리 코드 (.s)
↓ 어셈블 (Assembly)
목적 파일 (.o)
↓ 링크 (Linking)
실행파일 (a.out / .exe)1. 전처리 (Preprocessing)
#include <stdio.h>
#define MAX 100
int main() {
printf("Max is %d\n", MAX);
}#include를 실제 헤더 파일 내용으로 교체하고, #define을 값으로 치환합니다. 이 단계는 텍스트 치환입니다.
2. 컴파일 (Compilation)
전처리된 코드를 분석해서 어셈블리 코드로 변환합니다. 문법 오류가 여기서 발견됩니다.
mov edi, OFFSET FLAT:.LC0
mov esi, 100
call printf3. 어셈블 (Assembly)
어셈블리 코드를 기계어(이진 명령어)로 변환합니다. 결과물은 목적 파일(.o)입니다.
4. 링크 (Linking)
여러 목적 파일과 라이브러리를 하나의 실행파일로 합칩니다. printf의 실제 구현은 C 표준 라이브러리에 있으므로, 링커가 이것을 연결합니다.
# GCC가 이 모든 단계를 한 번에 수행gcc hello.c -o hello./helloPython은 어떻게 실행되는가
Python은 컴파일 + 인터프리터의 하이브리드입니다.
소스코드 (.py)
↓ Python 컴파일러
바이트코드 (.pyc)
↓ Python 가상머신 (PVM)
실행 결과# 바이트코드 확인import dis
def add(a, b): return a + b
dis.dis(add)# LOAD_FAST 0 (a)# LOAD_FAST 1 (b)# BINARY_ADD# RETURN_VALUE바이트코드는 기계어가 아닙니다. Python 가상머신(PVM)이라는 인터프리터가 바이트코드를 한 줄씩 실행합니다. C처럼 CPU가 직접 실행하는 것보다 느리지만, OS에 관계없이 실행할 수 있습니다.
__pycache__/ 폴더에 .pyc 파일이 생기는 것을 본 적이 있을 것입니다. 이것이 바이트코드 캐시입니다. 소스가 변경되지 않으면 재컴파일 없이 캐시를 사용합니다.
JavaScript는 어떻게 실행되는가
JavaScript 엔진(V8, SpiderMonkey)은 JIT(Just-In-Time) 컴파일을 사용합니다.
소스코드 (.js)
↓ 파싱
AST (추상 구문 트리)
↓ 인터프리터
바이트코드 실행 (느림)
↓ JIT 컴파일러 (자주 실행되는 코드 감지)
기계어로 변환 (빠름)처음에는 인터프리터로 빠르게 실행을 시작하고, "이 함수가 1,000번 호출되었네?"라고 감지하면 해당 부분만 기계어로 컴파일합니다. 실행 중에 컴파일하므로 "Just-In-Time"입니다.
Java — 컴파일과 인터프리트의 정석 하이브리드
Java는 Python과 비슷하지만 좀 더 복잡합니다.
소스코드 (.java)
↓ javac (컴파일)
바이트코드 (.class)
↓ JVM (Java Virtual Machine)
├── 인터프리터 (처음)
└── JIT 컴파일러 (자주 실행되는 코드)
↓
기계어 실행// Hello.java
public class Hello {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}javac Hello.java # 바이트코드 (.class) 생성java Hello # JVM이 바이트코드 실행"Write once, run anywhere" — Java 바이트코드는 JVM이 있는 어디서든 실행됩니다. Windows에서 컴파일한 .class 파일이 Linux JVM에서도 동작합니다.
정적 타입 vs 동적 타입
컴파일과 밀접한 개념이 타입 시스템입니다.
// C: 정적 타입 — 컴파일 시 타입 확인
int x = 42;
x = "hello"; // 컴파일 에러!# Python: 동적 타입 — 실행 시 타입 확인x = 42x = "hello" # OK — 실행 중에 타입이 바뀜| 정적 타입 | 동적 타입 | |
|---|---|---|
| 타입 확인 시점 | 컴파일 시 | 실행 시 |
| 에러 발견 | 실행 전 | 실행 중 |
| 타입 명시 | 필수 (int x) | 불필요 (x = 42) |
| 실행 속도 | 빠름 | 상대적으로 느림 |
| 대표 언어 | C, Java, TypeScript | Python, JavaScript, Ruby |
실전에서의 의미
왜 Python이 C보다 느린가?
→ C: CPU가 기계어를 직접 실행
→ Python: 가상머신이 바이트코드를 한 줄씩 해석하며 실행
왜 TypeScript를 쓰는가?
→ JavaScript에 정적 타입을 추가 → 실행 전에 에러를 잡음
→ TypeScript → (tsc 컴파일) → JavaScript → (V8 실행)
왜 Docker 이미지가 OS별로 다른가?
→ 컴파일된 바이너리는 CPU 아키텍처(x86, ARM)에 종속
→ macOS용 바이너리는 Linux에서 실행 불가핵심 정리
| 개념 | 정리 |
|---|---|
| 컴파일러 | 전체 소스를 미리 기계어로 변환. 빠른 실행 |
| 인터프리터 | 한 줄씩 해석하며 실행. 빠른 개발 |
| 바이트코드 | 중간 형태. 가상머신이 실행 (Python, Java) |
| JIT | 실행 중에 자주 쓰는 부분만 기계어로 컴파일 (JavaScript) |
| 정적 타입 | 컴파일 시 타입 확인 (C, TypeScript) |
| 동적 타입 | 실행 시 타입 확인 (Python, JavaScript) |
빌드 도구와 트랜스파일러
현대 개발에서는 "순수한" 컴파일/인터프리트 외에도 다양한 변환 도구가 있습니다.
TypeScript → (tsc) → JavaScript → (V8) → 실행
JSX/React → (Babel) → JavaScript → (V8) → 실행
Sass/SCSS → (sass) → CSS → 브라우저 렌더링트랜스파일러는 같은 수준의 언어로 변환합니다. 컴파일러가 고수준→저수준 변환이라면, 트랜스파일러는 고수준→고수준 변환입니다. TypeScript→JavaScript, ES6→ES5가 대표적입니다.
"Python은 인터프리터 언어"는 반만 맞습니다 — 바이트코드로 컴파일한 뒤 인터프리트합니다. "JavaScript는 인터프리터 언어"도 반만 맞습니다 — V8은 JIT 컴파일을 합니다. 현대 언어들은 컴파일과 인터프리트를 혼합해서 개발 편의성과 실행 성능을 모두 추구합니다.