
컴파일러와 인터프리터: 번역가와 통역사의 차이 (대규모 업데이트)
소스 코드를 기계어로 바꾸는 두 가지 전략. C와 Python의 실행 방식 차이와 Java/JavaScript가 사용하는 하이브리드 방식(JIT).

소스 코드를 기계어로 바꾸는 두 가지 전략. C와 Python의 실행 방식 차이와 Java/JavaScript가 사용하는 하이브리드 방식(JIT).
내 서버는 왜 걸핏하면 뻗을까? OS가 한정된 메모리를 쪼개 쓰는 처절한 사투. 단편화(Fragmentation)와의 전쟁.

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

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

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

여러분이 외계인(CPU)에게 "밥 줘"라고 말하고 싶다. 그런데 이 외계인은 "010101"밖에 모른다. 두 가지 방법이 있다.
프로그래밍 언어의 실행 방식은 이 두 가지 철학에서 시작되었다.
대학 시절, C 수업에서는 늘 gcc hello.c -o hello 하고 ./hello 두 단계로 실행했다. 그런데 파이썬 수업에서는 그냥 python hello.py 하면 바로 실행됐다. "이게 무슨 차이지?" 싶었다.
더 혼란스러웠던 건 Java였다. javac Hello.java 하고 java Hello 했는데, 이건 컴파일도 하고 실행도 하는데... 왜 C처럼 실행 파일(.exe)이 안 나오지? .class 파일은 뭐지?
그리고 누가 "JavaScript는 인터프리터 언어야"라고 했는데, V8 엔진 문서를 보니 "JIT Compiler"라는 단어가 잔뜩 나왔다. "대체 컴파일러가 뭐고 인터프리터가 뭔데?"
이 혼란을 정리해본다.
프로그래밍 언어는 결국 "언제 기계어로 바꾸느냐"의 싸움이다.
이 세 가지만 이해하면 모든 언어의 실행 방식을 이해할 수 있다.
이들은 AOT(Ahead-Of-Time) 방식을 사용한다. 실행하기 전에 모든 것을 결정한다.
// main.c
#include <stdio.h>
int main() {
int a = 5;
int b = 10;
int sum = a + b;
printf("Sum: %d\n", sum);
return 0;
}
이 코드를 실행하려면:
# 1. 전처리 (Preprocessing)
# #include를 실제 코드로 치환
gcc -E main.c -o main.i
# 2. 컴파일 (Compilation)
# C 코드를 어셈블리어로 변환
gcc -S main.i -o main.s
# 3. 어셈블 (Assembly)
# 어셈블리를 기계어 오브젝트 파일로 변환
gcc -c main.s -o main.o
# 4. 링킹 (Linking)
# 오브젝트 파일과 라이브러리를 합쳐서 실행 파일 생성
gcc main.o -o main
# 5. 실행
./main
이 과정을 한 번에 하려면 그냥 gcc main.c -o main 하면 된다.
핵심은 실행 전에 이미 기계어로 번역이 완료된다는 것이다. 그래서 실행 속도가 빠르다.
컴파일러는 코드 전체를 보고 최적화할 수 있다.
int compute() {
int x = 10;
int y = 20;
return x + y;
}
컴파일러는 이 코드를 보고 "어? 이거 그냥 30이잖아?" 하고 아예 코드를 이렇게 바꿔버린다:
int compute() {
return 30;
}
이런 걸 상수 폴딩(Constant Folding)이라고 한다. 실행 전에 이미 계산을 끝낸 거다.
대형 프로젝트(크롬, 언리얼 엔진)는 빌드하는 데 몇 시간이 걸린다. 코드 한 줄 고치고 테스트하려면 다시 몇 시간 기다려야 한다. 이게 진짜 고통스럽다.
그래서 Incremental Build(바뀐 부분만 다시 빌드), ccache(빌드 캐시), distcc(분산 빌드) 같은 기술이 발전했다.
인터프리터는 소스 코드를 바이너리로 만들지 않는다. 실행기(Interpreter)가 소스 코드를 직접 읽는다.
# hello.py
a = 5
b = 10
sum = a + b
print(f"Sum: {sum}")
이걸 실행하면:
python hello.py
내부적으로 Python 인터프리터는:
a = 5 한 줄 읽기매번 코드를 해석하기 때문에 느리다. 특히 반복문:
# slow.py
total = 0
for i in range(10_000_000):
total += i
print(total)
이 코드는 total += i를 1000만 번 해석한다. C로 짜면 이미 기계어로 번역되어 있으니 1000만 번 실행만 하면 되는데, Python은 1000만 번 해석+실행을 한다.
실제로 벤치마크 돌려보면:
# C 버전
gcc slow.c -o slow -O3
time ./slow
# 0.03초
# Python 버전
time python slow.py
# 1.2초
# 40배 차이
하지만 인터프리터는 개발이 빠르다.
# test.py
x = 5
print(type(x)) # <class 'int'>
x = "hello"
print(type(x)) # <class 'str'>
같은 변수 x가 처음엔 정수였다가 문자열로 바뀐다. 이게 동적 타이핑(Dynamic Typing)이다. C에서는 불가능하다.
그리고 eval() 같은 마법도 가능하다:
code = "print('Hello from eval')"
eval(code) # Hello from eval
실행 중에 코드를 만들어서 실행한다. 이건 컴파일 언어에서는 불가능하다.
"그럼 인터프리터는 어떻게 코드를 이해할까?"
결국 컴파일러든 인터프리터든, 코드를 트리 구조로 바꿔서 이해한다. 이게 AST(추상 구문 트리)다.
예를 들어 a = b + 1을 만나면:
코드를 토큰(Token)으로 쪼갠다:
[IDENTIFIER: a]
[ASSIGN: =]
[IDENTIFIER: b]
[PLUS: +]
[NUMBER: 1]
토큰을 트리로 조립한다:
(ASSIGN)
/ \
(a) (PLUS)
/ \
(b) (1)
이 트리를 보면 "b랑 1을 더한 결과를 a에 넣으라는 거구나"라고 이해할 수 있다.
Python으로 AST를 직접 볼 수 있다:
import ast
import astpretty
code = "a = b + 1"
tree = ast.parse(code)
astpretty.pprint(tree)
# Output:
# Module(
# body=[
# Assign(
# targets=[Name(id='a', ctx=Store())],
# value=BinOp(
# left=Name(id='b', ctx=Load()),
# op=Add(),
# right=Constant(value=1)
# )
# )
# ]
# )
바로 이 트리를 실행하는 게 인터프리터의 일이다.
"인터프리터는 느리고, 컴파일러는 빌드가 귀찮다. 둘을 섞으면?"
Google V8(JavaScript)와 JVM(Java)이 선택한 방식이 JIT Compiler다.
// hotspot.js
function add(a, b) {
return a + b;
}
let sum = 0;
for (let i = 0; i < 1_000_000; i++) {
sum = add(sum, i);
}
console.log(sum);
V8은 이 코드를 실행할 때:
add() 함수가 100만 번 호출되네?"add() 함수를 기계어로 번역.이 덕분에 JavaScript가 과거보다 수십 배 빨라졌다.
하지만 함정이 있다.
function add(a, b) {
return a + b;
}
// 처음엔 숫자만 들어옴
add(1, 2); // 3
add(5, 10); // 15
// V8: "아, 이 함수는 숫자만 받는구나. 정수 전용 기계어로 최적화!"
// 그런데 갑자기...
add("hello", "world"); // "helloworld"
// V8: "어? 문자열이 들어왔네? 최적화 무효화!"
// (Deoptimization 발생. 느린 인터프리터 모드로 복귀)
그래서 타입을 일정하게 유지하는 게 JavaScript 성능 최적화의 핵심이다.
// Bad (타입이 바뀜)
let x = 5; // 숫자
x = "hello"; // 문자열 (Deoptimization!)
// Good (타입 일정)
let num = 5;
let str = "hello";
이걸 받아들였을 때 내 코드 스타일이 완전히 바뀌었다.
"컴파일러는 어떻게 만들까?"
과거엔 새 언어를 만들려면 컴파일러를 처음부터 다 짜야 했다. x86용, ARM용, MIPS용... 지옥이었다.
그래서 등장한 게 LLVM이다.
Source Code (C/Rust/Swift)
↓
[Frontend] → Parsing → AST → LLVM IR
↓
[Optimizer] → Dead Code Elimination, Inlining, etc.
↓
[Backend] → x86_64 / ARM64 / WASM Machine Code
핵심은 LLVM IR(Intermediate Representation)이라는 중간 언어다.
int add(int a, int b) {
return a + b;
}
이 C 코드를 LLVM IR로 변환하면:
define i32 @add(i32 %a, i32 %b) {
entry:
%sum = add nsw i32 %a, %b
ret i32 %sum
}
이제 어떤 언어든 LLVM IR로만 변환해주면, LLVM이 알아서 모든 CPU용 기계어를 만들어준다.
그래서 Rust, Swift, Kotlin/Native 같은 언어들이 LLVM을 쓴다. 컴파일러를 처음부터 만들 필요가 없어진 거다.
"웹 브라우저에서도 C++ 성능을 내고 싶다."
자바스크립트는 아무리 최적화해도 한계가 있다. 그래서 등장한 게 WebAssembly(Wasm)다.
C++ Code → (수동 변환) → JavaScript → Browser
문제: JavaScript는 동적 타입이라 느리다.
C++/Rust Code → (Compile) → .wasm → Browser
.wasm 파일은 바이너리다. 텍스트가 아니라 이미 컴파일된 기계어에 가까운 형태다.
// add.c
int add(int a, int b) {
return a + b;
}
Emscripten으로 컴파일:
emcc add.c -o add.js -s EXPORTED_FUNCTIONS='["_add"]'
JavaScript에서 사용:
// main.js
const wasmModule = require('./add.js');
wasmModule.onRuntimeInitialized = () => {
const result = wasmModule._add(5, 10);
console.log(result); // 15
};
속도 차이:
실제로 Figma, Adobe Photoshop Web, Google Earth가 Wasm을 쓴다.
"Java 코드에서 Python 라이브러리를 호출할 수 있다면?"
Oracle이 만든 GraalVM은 여러 언어를 하나의 VM에서 섞어 쓸 수 있게 해준다.
// PolyglotExample.java
import org.graalvm.polyglot.*;
public class PolyglotExample {
public static void main(String[] args) {
try (Context context = Context.create()) {
// Java에서 Python 코드 실행
context.eval("python",
"def greet(name):\n" +
" return f'Hello, {name}!'\n" +
"print(greet('GraalVM'))"
);
// Java에서 JavaScript 실행
Value result = context.eval("js",
"const add = (a, b) => a + b; add(5, 10);"
);
System.out.println("JS result: " + result.asInt()); // 15
}
}
}
이게 가능한 이유는 모든 언어가 Truffle Framework라는 공통 인터페이스 위에서 돌기 때문이다.
더 놀라운 건 Native Image다.
# Java 프로그램을 네이티브 바이너리로 컴파일
native-image -jar myapp.jar
# 결과 - JVM 없이 실행되는 단일 실행 파일
./myapp
서버리스(Lambda, Cloud Functions)에서 엄청난 이점이다.
내가 정리한 언어 선택 체크리스트:
| 특성 | AOT (C/C++) | JIT (Java/JS) | Interpreter (Python) |
|---|---|---|---|
| 실행 속도 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
| 시작 속도 | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
| 메모리 사용 | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ |
| 개발 속도 | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 이식성 | ⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 디버깅 | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 빌드 시간 | ⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ (없음) |
| 최적화 수준 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐ |
결국 컴파일러와 인터프리터는 "언제 번역하느냐"의 차이였다.
내가 이 개념을 이해했을 때, 더 이상 "이 언어가 더 빠르다/느리다"라고 단순하게 생각하지 않게 됐다. 각 언어가 어떤 문제를 풀기 위해 어떤 트레이드오프를 선택했는지 보이기 시작했다.
C++의 복잡한 빌드 시스템도, Python의 느린 속도도, JavaScript의 이상한 타입 시스템도, 모두 그들의 선택이 만든 결과였다. 그리고 그 선택이 옳았던 적절한 상황이 있다는 걸 받아들였다.
Imagine you need to tell an alien (your CPU) "Give me food." Problem is, this alien only understands binary: "010101..."
You have two options:
Programming language execution models stem from these two philosophies.
In college, my C class always required two steps: gcc hello.c -o hello then ./hello. But in Python class, I just did python hello.py and it ran immediately. "What's the difference?" I wondered.
Java confused me even more. I had to do javac Hello.java then java Hello. It compiled AND executed, but why didn't it produce an executable file like C's .exe? What was this .class file?
Then someone told me "JavaScript is an interpreted language," but when I read V8 engine documentation, it was full of "JIT Compiler" terminology. "What even IS a compiler versus an interpreter?"
Let me break down what I learned.
Programming languages are fundamentally a battle of "when do you translate to machine code?"
Understanding these three models unlocks how every language works.
These use AOT (Ahead-Of-Time) compilation. Everything is decided before execution.
// main.c
#include <stdio.h>
int main() {
int a = 5;
int b = 10;
int sum = a + b;
printf("Sum: %d\n", sum);
return 0;
}
To execute this code:
# 1. Preprocessing
# Replace #include with actual code
gcc -E main.c -o main.i
# 2. Compilation
# Convert C to Assembly
gcc -S main.i -o main.s
# 3. Assembly
# Convert Assembly to Machine Code object file
gcc -c main.s -o main.o
# 4. Linking
# Combine object files and libraries into executable
gcc main.o -o main
# 5. Execution
./main
Or simply: gcc main.c -o main does it all at once.
The key insight: translation to machine code completes before execution. That's why it's fast.
Compilers see the entire codebase and can optimize globally.
int compute() {
int x = 10;
int y = 20;
return x + y;
}
The compiler sees this and thinks "Wait, this is just 30," and rewrites it as:
int compute() {
return 30;
}
This is called Constant Folding. Computation happens at compile time, not runtime.
Large projects (Chrome, Unreal Engine) take hours to build. Change one line, wait hours to test again. It's genuinely painful.
That's why technologies like Incremental Builds (rebuild only changed parts), ccache (build caching), and distcc (distributed building) evolved.
Interpreters don't convert source code to binary. The interpreter engine reads source code directly.
# hello.py
a = 5
b = 10
sum = a + b
print(f"Sum: {sum}")
When you run:
python hello.py
Internally, the Python interpreter:
a = 5Because it interprets every time, it's slow. Especially in loops:
# slow.py
total = 0
for i in range(10_000_000):
total += i
print(total)
This code interprets total += i ten million times. In C, the machine code is already ready, so you just execute ten million times. In Python, you interpret + execute ten million times.
Actual benchmark:
# C version
gcc slow.c -o slow -O3
time ./slow
# 0.03 seconds
# Python version
time python slow.py
# 1.2 seconds
# 40x slower
But interpreters enable rapid development.
# test.py
x = 5
print(type(x)) # <class 'int'>
x = "hello"
print(type(x)) # <class 'str'>
The same variable x starts as an integer, then becomes a string. This is dynamic typing. Impossible in C.
And magic like eval() works:
code = "print('Hello from eval')"
eval(code) # Hello from eval
Execute code generated at runtime. Impossible in compiled languages.
"How does an interpreter understand code?"
Whether compiler or interpreter, code gets converted into a tree structure for understanding. This is the AST (Abstract Syntax Tree).
For example, a = b + 1:
Break code into tokens:
[IDENTIFIER: a]
[ASSIGN: =]
[IDENTIFIER: b]
[PLUS: +]
[NUMBER: 1]
Assemble tokens into a tree:
(ASSIGN)
/ \
(a) (PLUS)
/ \
(b) (1)
Reading this tree: "Add b and 1, assign the result to a."
You can visualize AST in Python:
import ast
import astpretty
code = "a = b + 1"
tree = ast.parse(code)
astpretty.pprint(tree)
# Output:
# Module(
# body=[
# Assign(
# targets=[Name(id='a', ctx=Store())],
# value=BinOp(
# left=Name(id='b', ctx=Load()),
# op=Add(),
# right=Constant(value=1)
# )
# )
# ]
# )
The interpreter's job is to execute this tree.
"Interpreters are slow, compilers require builds. What if we combine them?"
Google's V8 (JavaScript) and the JVM (Java) chose JIT Compilation.
// hotspot.js
function add(a, b) {
return a + b;
}
let sum = 0;
for (let i = 0; i < 1_000_000; i++) {
sum = add(sum, i);
}
console.log(sum);
V8 executes this by:
add() is called 1 million times?"add() to machine code.This made JavaScript dozens of times faster than before.
But there's a catch.
function add(a, b) {
return a + b;
}
// Initially only numbers
add(1, 2); // 3
add(5, 10); // 15
// V8: "This function only takes numbers. Optimize for integers!"
// But then suddenly...
add("hello", "world"); // "helloworld"
// V8: "Wait, a string? Invalidate optimization!"
// (Deoptimization occurs. Back to slow interpreter mode)
That's why keeping types consistent is crucial for JavaScript performance optimization.
// Bad (type changes)
let x = 5; // number
x = "hello"; // string (Deoptimization!)
// Good (consistent types)
let num = 5;
let str = "hello";
When I understood this, my coding style completely changed.
"How do you build a compiler?"
In the past, creating a new language meant writing a compiler from scratch. One for x86, one for ARM, one for MIPS... absolute hell.
Then LLVM appeared.
Source Code (C/Rust/Swift)
↓
[Frontend] → Parsing → AST → LLVM IR
↓
[Optimizer] → Dead Code Elimination, Inlining, etc.
↓
[Backend] → x86_64 / ARM64 / WASM Machine Code
The key is LLVM IR (Intermediate Representation), an intermediate language.
int add(int a, int b) {
return a + b;
}
This C code converts to LLVM IR as:
define i32 @add(i32 %a, i32 %b) {
entry:
%sum = add nsw i32 %a, %b
ret i32 %sum
}
Now any language that can output LLVM IR gets machine code for all CPUs automatically.
That's why Rust, Swift, Kotlin/Native use LLVM. No need to write a compiler from scratch.
"Can we get C++ performance in web browsers?"
JavaScript, no matter how optimized, has limits. That's why WebAssembly (Wasm) was created.
C++ Code → (Manual Conversion) → JavaScript → Browser
Problem: JavaScript is dynamically typed, hence slow.
C++/Rust Code → (Compile) → .wasm → Browser
.wasm files are binary. Not text, but close to compiled machine code.
// add.c
int add(int a, int b) {
return a + b;
}
Compile with Emscripten:
emcc add.c -o add.js -s EXPORTED_FUNCTIONS='["_add"]'
Use in JavaScript:
// main.js
const wasmModule = require('./add.js');
wasmModule.onRuntimeInitialized = () => {
const result = wasmModule._add(5, 10);
console.log(result); // 15
};
Speed difference:
Real apps using Wasm: Figma, Adobe Photoshop Web, Google Earth.
"What if Java code could call Python libraries?"
Oracle's GraalVM lets you mix multiple languages in a single VM.
// PolyglotExample.java
import org.graalvm.polyglot.*;
public class PolyglotExample {
public static void main(String[] args) {
try (Context context = Context.create()) {
// Execute Python code from Java
context.eval("python",
"def greet(name):\n" +
" return f'Hello, {name}!'\n" +
"print(greet('GraalVM'))"
);
// Execute JavaScript from Java
Value result = context.eval("js",
"const add = (a, b) => a + b; add(5, 10);"
);
System.out.println("JS result: " + result.asInt()); // 15
}
}
}
This works because all languages run on the Truffle Framework, a common interface.
Even more impressive is Native Image.
# Compile Java program to native binary
native-image -jar myapp.jar
# Result: Single executable that runs without JVM
./myapp
Huge advantage for serverless (Lambda, Cloud Functions).
My personal language selection criteria:
| Characteristic | AOT (C/C++) | JIT (Java/JS) | Interpreter (Python) |
|---|---|---|---|
| Execution Speed | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
| Startup Speed | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ |
| Memory Usage | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ |
| Development Speed | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Portability | ⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Debugging | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| Build Time | ⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ (none) |
| Optimization Level | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐ |