컴파일러 원리 입문
서론
"실행" 버튼을 누르면 코드는 어떻게 화면의 결과가 될까요? 여러분이 작성하는 모든 코드 줄은 사실 컴퓨터가 "이해하지 못합니다" — 컴퓨터는 0과 1만 인식합니다. 컴파일러는 인간의 언어를 기계어로 번역하는 "통역사"입니다. 컴파일러 원리를 이해하면 에러 메시지가 어디서 오는지, 왜 어떤 언어는 빠르고 어떤 언어는 느린지, 그리고 코드 최적화의 근본 원리를 이해할 수 있습니다.
이 글에서 무엇을 배우게 되나요?
이 장을 마치면 다음을 얻게 됩니다:
- 전체적 시야: 소스 코드에서 실행 가능한 프로그램까지의 완전한 컴파일 파이프라인 파악
- 어휘 분석: 컴파일러가 코드를 토큰으로 나누는 방법 이해
- 구문 분석: AST(추상 구문 트리)의 구성 과정 이해
- AST 시각화: 코드의 트리 구조를 직관적으로 확인
- 의미 분석과 최적화: 타입 검사와 코드 최적화의 원리 이해
- 최적화 기법 실전: 상수 폴딩, 데드 코드 제거 등 핵심 최적화 기법 습득
- 실행 모델: 컴파일형, 인터프리터형, JIT 세 가지 실행 방식 구분
| 장 | 내용 | 핵심 개념 |
|---|---|---|
| 제1장 | 컴파일러란 무엇인가 | 통역사 비유, 컴파일 파이프라인 |
| 제2장 | 어휘 분석 | 토큰, 어휘 규칙 |
| 제3장 | 구문 분석 | AST, 구문 트리, 우선순위 |
| 제4장 | AST 시각화 | 대화형 구문 트리, 노드 타입 |
| 제5장 | 의미 분석과 최적화 | 타입 검사, 상수 폴딩, 데드 코드 제거 |
| 제6장 | 최적화 기법 실전 | 함수 인라이닝, 루프 불변식 이동, 상수 전파 |
| 제7장 | 컴파일형 vs 인터프리터형 vs JIT | 세 가지 실행 모델 비교 |
0. 전경도: 코드의 "번역 여행"
여러분이 중국어 소설을 영어로 번역하는 통역사라고 상상해 보세요. 단어 하나하나 직역하는 것이 아니라:
- 단어 식별 — 문장을 개별 단어로 나눕니다 (어휘 분석)
- 구문 이해 — 문장 구조가 올바른지 판단합니다 (구문 분석)
- 의미 이해 — 뜻이 자연스럽고 모순이 없는지 확인합니다 (의미 분석)
- 다듬기 — 번역문을 더 자연스럽게 만듭니다 (코드 최적화)
- 번역물 출력 — 최종 영어 버전을 작성합니다 (코드 생성)
컴파일러가 하는 일도 완전히 같습니다. 다만 번역하는 대상이 프로그래밍 언어라는 점만 다릅니다.
int age = 25;1. 컴파일러의 6단계 파이프라인
컴파일러의 작업은 공장 조립 라인처럼 6단계로 나눌 수 있으며, 각 단계가 끝나면 다음 단계로 전달됩니다.
int x = 10 + 5;
→ [int] [x] [=] [10] [+] [5] [;]
keyword identifier operator number operator number separator컴파일 파이프라인
- 어휘 분석(Lexical Analysis): 소스 코드를 토큰(단어)으로 나눕니다
- 구문 분석(Syntax Analysis): 토큰을 구문 트리(AST)로 구성합니다
- 의미 분석(Semantic Analysis): 타입이 올바른지, 변수가 선언되었는지 확인합니다
- 중간 코드 생성(IR Generation): 플랫폼 독립적인 중간 표현을 생성합니다
- 코드 최적화(Optimization): 중간 코드를 더 효율적으로 만듭니다
- 코드 생성(Code Generation): 대상 플랫폼의 기계어를 생성합니다
| 단계 | 입력 | 출력 | 비유 |
|---|---|---|---|
| 어휘 분석 | 소스 코드 문자 스트림 | 토큰 스트림 | 문장을 단어로 나누기 |
| 구문 분석 | 토큰 스트림 | AST(구문 트리) | 문장 구조 분석 |
| 의미 분석 | AST | 타입이 포함된 AST | 의미가 자연스러운지 확인 |
| 중간 코드 | 타입이 포함된 AST | IR | 초안 작성 |
| 코드 최적화 | IR | 최적화된 IR | 다듬고 삭제 |
| 코드 생성 | 최적화된 IR | 기계어 | 최종본 출력 |
2. 어휘 분석: 코드를 "단어"로 나누기
어휘 분석은 컴파일의 첫 단계입니다. 컴파일러는 소스 코드의 각 문자를 왼쪽에서 오른쪽으로 스캔하여 의미 있는 토큰(Token)으로 조합합니다.
🔤 Lexer: Split Code into Tokens
Enter a line of code and see lexical analysis results in real time
영어 문장을 읽을 때 뇌가 자동으로 글자를 단어로 조합하는 것처럼, 어휘 분석기는 문자를 토큰으로 조합합니다:
소스 코드: let x = 10 + 5;
토큰 스트림:
[let] → 키워드(언어 예약어)
[x] → 식별자(변수명)
[=] → 연산자(할당)
[10] → 숫자 리터럴
[+] → 연산자(덧셈)
[5] → 숫자 리터럴
[;] → 구분자(문장 끝)토큰의 5가지 유형
- 키워드: 언어가 예약한 특수 단어, 예:
let,if,return,function - 식별자: 프로그래머가 정의한 이름, 예: 변수명, 함수명
- 리터럴: 코드에 직접 쓰인 값, 예: 숫자
42, 문자열"hello" - 연산자: 연산을 수행하는 기호, 예:
+,-,=,=== - 구분자: 코드 구조를 분리하는 기호, 예:
;,,,(,)
3. 구문 분석: 구문 트리(AST) 구성
어휘 분석은 코드를 토큰으로 나눴지만, 토큰은 고립된 "단어"일 뿐입니다. 구문 분석의 임무는 이 토큰들을 구문 규칙에 따라 추상 구문 트리(Abstract Syntax Tree, AST)로 구성하는 것입니다. AST는 코드의 구조와 연산 우선순위를 반영합니다.
표현식: 1 + 2 * 3
구문 트리: 왜 이렇게 되나요?
+ *의 우선순위가
/ \ +보다 높기 때문에
1 * 2 * 3이 먼저
/ \ 하나의 서브트리로
2 3 결합됩니다AST의 중요성
AST는 컴파일러의 "핵심 자료구조"로, 이후의 의미 분석, 최적화, 코드 생성이 모두 이를 기반으로 진행됩니다. 현대 개발 도구도 AST를 광범위하게 사용합니다:
- ESLint: 코드를 AST로 파싱하여 규칙 위반을 검사
- Prettier: AST로 파싱한 후 다시 포맷팅하여 출력
- Babel: AST 파싱 → 변환 → 호환 코드 생성
- IDE 리팩토링: AST 기반으로 안전한 변수 이름 변경, 함수 추출 수행
| 구문 구조 | 토큰 시퀀스 | AST 노드 |
|---|---|---|
| 변수 선언 | let x = 10 | VariableDeclaration → Identifier + Literal |
| 함수 호출 | add ( 1 , 2 ) | CallExpression → Identifier + Arguments |
| 조건문 | if ( a > b ) | IfStatement → BinaryExpression + Block |
4. AST 시각화: 코드의 "뼈대" 보기
위에서 AST의 구조를 글로 설명했지만, "보는 것"이 "읽는 것"보다 더 직관적입니다. 아래의 대화형 컴포넌트로 다양한 표현식을 선택하고 구문 트리의 모습을 실시간으로 관찰해 보세요.
🌳 AST Visualizer: See the Skeleton of Code
Choose an expression and inspect its abstract syntax tree
시각화를 통해 AST의 핵심 규칙은 사실 매우 단순하다는 것을 알 수 있습니다:
| 코드 구조 | AST 루트 노드 | 자식 노드 |
|---|---|---|
1 + 2 * 3 | BinaryExpression (+) | 왼쪽: NumericLiteral(1), 오른쪽: BinaryExpression(*) |
let x = 10 | VariableDeclaration | VariableDeclarator → Identifier(x) + NumericLiteral(10) |
add(a, b) | CallExpression | Identifier(add) + Arguments(a, b) |
일상 개발에서 AST의 활용
직접 컴파일러를 작성해본 적은 없겠지만, 매일 AST 기반 도구를 사용하고 있습니다:
- ESLint / Prettier: 코드를 AST로 파싱하여 규칙 검사 또는 재포맷팅
- Babel / SWC: AST 파싱 → 구문 변환 → 호환 코드 생성
- IDE 리팩토링: AST 기반 안전한 이름 변경, 함수 추출
- Tree-shaking: AST의 import/export 분석하여 미사용 코드 삭제
5. 의미 분석과 코드 최적화
구문 분석은 코드의 "구조가 올바른지"를 보장하지만, 구조가 올바르다고 해서 "의미가 올바른" 것은 아닙니다. 의미 분석은 코드의 의미가 합법적인지 검사하고, 코드 최적화는 프로그램이 더 빠르게 실행되도록 합니다.
4.1 의미 분석: "의미"가 올바른지 검사
| 검사 내용 | 예시 | 결과 |
|---|---|---|
| 타입 검사 | int x = "hello" | ❌ 타입 불일치 |
| 스코프 검사 | 선언되지 않은 변수 y 사용 | ❌ 변수가 존재하지 않음 |
| 타입 추론 | 1 + 2.0 | ✅ 결과가 float로 추론됨 |
| 매개변수 검사 | 함수가 2개 매개변수만 받는데 add(1, 2, 3) 호출 | ❌ 매개변수 수 불일치 |
여러분이 본 에러 메시지의 대부분은 의미 분석에서 나옵니다
TypeError: Cannot read properties of undefined— 타입 검사ReferenceError: x is not defined— 스코프 검사Expected 2 arguments, but got 3— 매개변수 검사
4.2 코드 최적화: 프로그램을 더 빠르게
컴파일러는 최종 코드를 생성하기 전에 중간 코드에 다양한 최적화를 수행합니다. 이러한 최적화는 프로그래머에게 투명하지만, 성능을 크게 향상시킬 수 있습니다.
| 최적화 기법 | 최적화 전 | 최적화 후 | 원리 |
|---|---|---|---|
| 상수 폴딩 | x = 10 + 5 | x = 15 | 컴파일 시 바로 결과 계산 |
| 데드 코드 제거 | if (false) { ... } | 바로 삭제 | 절대 실행되지 않는 코드 |
| 상수 전파 | x = 15; y = x * 2 | y = 30 | 알려진 값으로 직접 교체 |
| 루프 불변식 이동 | 루프 내에서 len = arr.length 반복 계산 | 루프 밖으로 이동 | 반복 계산 방지 |
6. 최적화 기법 실전: 컴파일러가 코드를 빠르게 만드는 방법
위에서 몇 가지 최적화 기법의 이름을 언급했습니다. 이제 컴파일러가 구체적으로 어떻게 하는지 자세히 살펴보겠습니다. 아래의 대화형 컴포넌트는 5가지 가장 일반적인 컴파일러 최적화를 보여주며, 최적화 전후의 코드 차이를 직관적으로 비교할 수 있습니다.
⚡ Compiler Optimization: Make Code Faster Automatically
Choose an optimization technique and see how the compiler improves code
const width = 10 const height = 20 const area = width * height // computed at runtime console.log(area)
const area = 200 // computed during compilation console.log(200)
현대 컴파일러와 JIT 엔진(예: V8, GCC, LLVM)은 수십 가지 최적화를 자동으로 적용합니다. 개발자로서 이를 수동으로 할 필요는 없지만, 이해하면 다음에 도움이 됩니다:
- 최적화하기 쉬운 코드 작성: 예를 들어
let대신const를 사용하면 컴파일러가 상수 폴딩을 수행하기 더 쉽습니다 - 성능 차이 이해: 왜 작은 함수가 큰 함수보다 빠른가요? 컴파일러가 인라이닝할 수 있기 때문입니다
- "역최적화" 방지:
eval()이나with같은 특정 패턴은 컴파일러 최적화를 방해합니다
| 최적화 기법 | 발동 조건 | 성능 영향 | 개발자가 할 수 있는 것 |
|---|---|---|---|
| 상수 폴딩 | 표현식이 모두 상수 | 런타임 계산 제거 | const 선언 적극 활용 |
| 데드 코드 제거 | 코드가 도달 불가능하거나 결과가 미사용 | 코드 크기 감소 | 미사용 코드 정리 |
| 루프 불변식 이동 | 루프 내에 변하지 않는 계산 | 반복 계산 감소 | 수동 추출도 좋은 습관 |
| 함수 인라이닝 | 작은 함수가 빈번히 호출 | 호출 오버헤드 제거 | 함수를 작고 집중적으로 유지 |
| 상수 전파 | 컴파일 시 변수 값을 확정 가능 | 전체 계산 체인 제거 | 매직 넘버 대신 상수 사용 |
7. 컴파일형 vs 인터프리터형 vs JIT
코드를 작성한 후 실행하는 방법에는 세 가지 "번역 방식"이 있습니다. 이 세 가지 방식은 각각 장단점이 있으며, 언어의 성능 특성과 사용 시나리오를 직접적으로 결정합니다.
🔄 Compiled vs Interpreted vs JIT
Click an execution mode to see how code moves from source to running program
| 차원 | 컴파일형 | 인터프리터형 | JIT 즉시 컴파일 |
|---|---|---|---|
| 과정 | 먼저 전체 기계어로 컴파일 후 실행 | 읽으면서 실행, 한 줄씩 번역 | 먼저 인터프리트 후 핫스팟 코드 컴파일 |
| 실행 속도 | 가장 빠름 | 가장 느림 | 중간 (핫스팿은 컴파일형에 근접) |
| 시작 속도 | 느림(컴파일 필요) | 빠름(바로 실행) | 중간(워밍업 필요) |
| 크로스 플랫폼 | 재컴파일 필요 | 자연스러운 크로스 플랫폼 | 크로스 플랫폼 |
| 대표 언어 | C, Rust, Go | Python, Ruby | JavaScript (V8), Java |
왜 JavaScript가 이렇게 빠른가요?
V8 엔진의 JIT 컴파일러는 어떤 코드가 자주 실행되는지(핫스팿 코드) 모니터링한 다음, 이를 고도로 최적화된 기계어로 컴파일합니다. 따라서 JavaScript는 "인터프리터형 언어"이지만 V8에서는 컴파일형 언어에 근접한 성능을 낼 수 있습니다. 이것이 Node.js가 서버 사이드에서 사용될 수 있는 근거입니다.
요약
컴파일러 원리는 컴파일러 개발자만 알아야 하는 지식이 아닙니다. 컴파일 과정을 이해하면 에러 메시지를 더 잘 이해하고, 적절한 언어를 선택하며, 더 효율적인 코드를 작성할 수 있습니다.
이 장의 핵심 요점을 돌아보겠습니다:
- 컴파일러는 통역사: 인간이 읽을 수 있는 코드를 기계가 실행할 수 있는 명령어로 번역
- 6단계 파이프라인: 어휘 분석 → 구문 분석 → 의미 분석 → 중간 코드 → 최적화 → 코드 생성
- 어휘 분석으로 토큰 분리: 문자 스트림을 키워드, 식별자, 연산자 등 의미 있는 단위로 나눔
- 구문 분석으로 AST 구성: 구문 규칙에 따라 토큰을 트리 구조로 구성하여 연산 우선순위 반영
- 의미 분석으로 정확성 보장: 타입 검사, 스코프 검사 — 여러분이 본 대부분의 에러는 여기서 발생
- 컴파일러 자동 최적화: 상수 폴딩, 데드 코드 제거, 함수 인라이닝 등으로 코드가 자동으로 빨라짐
- 세 가지 실행 모델: 컴파일형이 가장 빠르고, 인터프리터형이 가장 유연하며, JIT이 둘의 장점을 결합
추가 읽기
- AST Explorer - 온라인에서 코드의 AST 구조 확인
- Crafting Interpreters - 프로그래밍 언어를 처음부터 구현하기 (무료 온라인 도서)
- The Super Tiny Compiler - JavaScript로 구현한 초소형 컴파일러
- V8 Blog - V8 엔진의 JIT 컴파일 기술 블로그
- LLVM 공식 사이트 - 가장 인기 있는 컴파일러 인프라