컴파일러 파이프라인 한 장 정리: 소스 코드가 실행 가능한 코드가 되기까지
어휘·구문·의미 분석부터 중간 표현, 최적화, 코드 생성까지 각 단계가 무엇을 입력받고 어떤 책임을 지는지 하나의 예제로 연결합니다.
이 글은 개인 학습 아카이브에 보관된 컴파일러 강의 PDF·슬라이드·코드 예제를 교차 검토해 새로 구성한 학습 노트입니다. 오래된 표기, 오탈자, 실행되지 않는 예시는 본문에서 별도로 바로잡았으며 원본 파일이나 내부 경로는 공개하지 않습니다.
먼저 전체 그림
컴파일러는 한 언어로 적힌 프로그램을 의미를 보존하면서 다른 표현으로 바꾸는 번역기다. 보통 입력은 고급 언어의 소스 코드이고 출력은 어셈블리나 기계어지만, Java 바이트코드·WebAssembly·다른 고급 언어를 출력하는 경우도 같은 틀로 이해할 수 있다.
소스 문자
↓ 어휘 분석
토큰
↓ 구문 분석
구문 트리(AST)
↓ 의미 분석
타입과 이름이 확인된 AST
↓ 중간 코드 생성
중간 표현(IR)
↓ 최적화
동등하지만 더 나은 IR
↓ 코드 생성
어셈블리·목적 코드
↓ 어셈블·링크·로딩
실행 프로그램
강의 자료는 이 과정을 front end와 back end의 두 덩어리로 설명한다. 학습을 시작하기에는 유용하지만 현대 컴파일러를 읽을 때는 front end–middle end–back end로 나누는 편이 더 정확하다. 언어에 종속적인 분석은 front end, 여러 언어와 CPU가 공유할 수 있는 IR 최적화는 middle end, 명령어 선택과 레지스터 할당처럼 목표 기계에 종속적인 처리는 back end에 둔다.
한 문장을 단계별로 통과시키기
다음 문장을 생각해 보자.
total = price * count + 10;
| 단계 | 대표 출력 | 이 단계가 답하는 질문 |
|---|---|---|
| 어휘 분석 | ID(total) = ID(price) * ID(count) + INT(10) ; | 어떤 문자 묶음이 하나의 단어인가 |
| 구문 분석 | 대입 노드 아래에 덧셈·곱셈 노드 | 문법에 맞는 구조인가 |
| 의미 분석 | 이름 바인딩과 각 식의 타입 | 선언된 이름인가, 연산 타입이 맞는가 |
| IR 생성 | t1 = price * count; t2 = t1 + 10; total = t2 | 기계와 독립적인 연산으로 어떻게 표현할까 |
| 최적화 | 상수 접기, 공통식 제거 등 | 의미를 유지하면서 비용을 줄일 수 있는가 |
| 코드 생성 | load, multiply, add, store | 목표 CPU에서 어떤 명령과 레지스터를 쓸까 |
각 단계는 앞 단계가 만든 더 구조적인 표현을 받는다. 이 경계 덕분에 “문자 하나를 잘못 읽은 문제”와 “타입이 맞지 않는 문제”, “레지스터가 부족한 문제”를 서로 다른 위치에서 진단할 수 있다.
분석 단계의 세 가지 책임
어휘 분석: 문자를 토큰으로 묶는다
scanner 또는 lexer는 공백과 주석을 건너뛰고 식별자, 숫자, 연산자 같은 토큰을 만든다. 정규 표현식과 유한 오토마타가 이 단계의 핵심 이론이다. count10을 하나의 식별자로 읽을지 count와 10으로 나눌지는 문자만 봐서는 정할 수 없고 토큰 규칙과 최장 일치 원칙이 필요하다.
구문 분석: 토큰 사이의 계층을 만든다
parser는 문맥 자유 문법을 이용해 연산 우선순위와 결합 방향을 복원한다. price * count + 10에서 곱셈이 덧셈보다 먼저라는 사실은 토큰 목록 자체가 아니라 문법 구조에 있다. 출력은 parse tree일 수도 있지만 이후 단계가 쓰기 편하도록 불필요한 구두점을 줄인 AST가 일반적이다.
의미 분석: 문법만으로 말할 수 없는 규칙을 확인한다
이름이 선언되었는지, 함수 인수 개수와 타입이 맞는지, 대입 가능한 타입인지 검사하고 symbol table을 채운다. 단, “실수 값을 정수 변수에 대입하면 언제나 의미 오류”라는 식의 문장은 언어 규칙에 따라 달라진다. 어떤 언어는 금지하고, 어떤 언어는 명시적 cast를 요구하며, 어떤 언어는 암시적으로 변환한다. 컴파일러는 보편적인 취향이 아니라 해당 언어 명세를 집행한다.
합성과 실행 준비
IR은 front end와 back end 사이의 계약이다. SSA, three-address code, control-flow graph처럼 형태는 다르지만 소스 언어의 문법 세부사항을 줄이고 데이터·제어 의존성을 드러내는 것이 목적이다. 최적화기는 도달 불가능 코드 제거, 상수 전파, loop 최적화 등을 적용하되 관찰 가능한 동작을 바꾸면 안 된다.
그 뒤 code generator가 명령어를 선택하고 레지스터를 배정한다. 목적 파일에는 아직 다른 파일이나 라이브러리에 있는 심볼이 남을 수 있다. assembler는 어셈블리를 목적 코드로 바꾸고 linker는 이 참조를 해소해 실행 이미지나 라이브러리를 만든다. loader와 runtime은 주소 공간, 초기화, 동적 라이브러리 같은 실행 환경을 준비한다. 이 도구들은 넓은 “언어 처리 시스템”에 속하지만 모두 컴파일러의 한 phase인 것은 아니다.
phase와 pass는 다르다
phase는 논리적 책임이고 pass는 프로그램 표현을 한 번 순회하는 구현 단위다. 한 pass가 어휘·구문 분석을 함께 수행할 수도 있고, 최적화 한 phase가 수십 pass로 나뉠 수도 있다. 따라서 “6단계 컴파일러이므로 입력을 정확히 6번 읽는다”는 결론은 성립하지 않는다.
오류를 가장 가까운 곳에서 보고하기
- 인식할 수 없는 문자: 어휘 오류
- 괄호나 문장 구조 불일치: 구문 오류
- 미선언 이름·호환되지 않는 타입: 의미 오류
- 목표 기계 제약을 만족하지 못함: 코드 생성 진단
- 외부 심볼을 찾지 못함: 링크 오류
- 0으로 나누기처럼 실제 값에 따라 발생: 주로 런타임 오류
좋은 진단은 종류만 맞히지 않는다. 원본 위치, 기대한 항목, 실제로 만난 항목, 복구 뒤의 연쇄 오류 억제를 함께 다룬다.
bootstrapping을 정확히 이해하기
컴파일러를 자신이 번역하는 언어로 작성하는 것을 self-hosting이라고 한다. 새 컴파일러가 아직 없는데 그 언어로 작성한 컴파일러를 어떻게 처음 빌드할지가 bootstrapping 문제다. 기존 버전, 다른 host 언어로 만든 작은 컴파일러, cross compiler 등을 단계적으로 이용한다. “컴파일러는 반드시 다른 언어로 먼저 작성된다”가 아니라 초기 신뢰 사슬과 실행 가능한 첫 번역기가 필요하다는 뜻이다.
공부할 때 붙잡을 다섯 질문
- 이 단계의 입력과 출력 표현은 무엇인가?
- 언어에 종속적인가, 목표 기계에 종속적인가?
- 어떤 불변조건을 다음 단계에 보장하는가?
- 오류를 이 단계에서 잡는 것이 가장 정확한가?
- 표현을 바꾸면서 프로그램의 관찰 가능한 의미를 어떻게 보존하는가?
이 질문으로 파이프라인을 보면 이후의 DFA, CFG, parser, IR, 최적화가 흩어진 이론이 아니라 하나의 번역 시스템에서 맡은 책임으로 연결된다.