코드에서 기계어까지: 컴파일러 5단계


우리가 에디터에 입력하는 코드는 사실 컴퓨터가 직접 이해하는 언어가 아닙니다. 그저 사람이 읽기 좋은 텍스트일 뿐이죠. if, for, var, 함수 이름 같은 기호들은 우리끼리의 약속이고, CPU는 이런 약속을 모릅니다. CPU가 이해하는 건 결국 0과 1로 이루어진 명령어뿐입니다.

결국 이 간극을 메우는 도구가 바로 컴파일러입니다. 컴파일러는 사람이 쓰는 고수준 코드를 기계가 실행할 수 있는 형태로 단계적으로 번역합니다.

이번 글에서는 특정 언어에 한정하지 않고, 코드가 실행 파일이 되기까지의 여정을 5단계로 풀어보겠습니다.

1단계: 어휘 분석 (Lexical Analysis) - 단어 쪼개기

첫 단계는 소스 코드를 의미 있는 최소 단위인 **토큰(Token)**으로 분리하는 과정입니다. 사람이 문장을 띄어쓰기와 문장부호로 끊어 읽듯, 컴파일러도 문자열을 잘게 나눠 다음 단계로 넘깁니다.

예를 들어 다음과 같은 코드가 있다고 해보겠습니다.

result = a << 2

여기서 중요한 포인트는 <<입니다. 컴파일러는 이 기호를 문맥에 따라 비트 시프트 연산자로 해석해야 하며, 문자 두 개를 따로 읽지 않고 하나의 연산자 토큰으로 처리해야 합니다.

즉, Lexer는 단순히 문자 두 개를 읽는 것이 아니라:

  • <<를 하나의 토큰으로 묶고
  • 이를 SHIFT_LEFT 같은 연산자 토큰으로 분류해
  • 이후 파서가 올바른 구문 규칙에 따라 해석할 수 있게 전달합니다.

이 단계가 흔들리면 이후 모든 단계가 연쇄적으로 무너집니다. 컴파일러의 안정성은 의외로 이 “토큰화”에서 많이 갈립니다.

2단계: 구문 분석 (Syntax Analysis) - 문법 트리 만들기

토큰이 준비되면 이제 문법 규칙에 맞춰 구조를 만듭니다. 그 결과가 바로 **AST(Abstract Syntax Tree)**입니다.

배열 선언을 예로 들어보겠습니다.

int[] nums = {1, 2, 3};

이 코드는 단순 문자열이 아니라 트리로 보면 대략 이런 형태가 됩니다.

VarDecl
├── name: nums
├── type: ArrayType
│   └── elementType: int
└── init: ArrayLiteral
    ├── 1
    ├── 2
    └── 3

파서는 “문장이 문법적으로 맞는가”를 검증하고, 맞다면 이렇게 구조화된 트리를 만듭니다. 이 트리는 이후 단계(의미 분석, IR 생성)의 기준 데이터가 됩니다.

3단계: 의미 분석 (Semantic Analysis) - ‘말’이 되는지 확인하기

문법이 맞다고 해서 프로그램이 올바른 것은 아닙니다. 1 + "hello"처럼 문법은 맞지만 의미가 틀린 코드는 얼마든지 존재합니다.

의미 분석 단계는 주로 다음을 확인합니다.

  • 타입이 일관적인가?
  • 변수가 선언된 범위(스코프) 안에서만 사용되는가?
  • 함수 호출 인자와 시그니처가 맞는가?

try-catch 구문을 예로 들면:

try {
  risky()
} catch (err) {
  print(err)
}

의미 분석기는 이 코드를 해석하면서 다음을 확인합니다.

  • err 심볼이 catch 블록의 지역 스코프에 올바르게 등록되는지
  • 해당 블록 내부에서 err 참조가 타입 규칙에 맞는지
  • 블록 밖에서 err를 사용할 때 스코프 에러를 정확히 보고하는지

즉, 이 단계는 “문법적으로 그럴듯한 코드”를 “실제로 성립하는 코드”로 걸러내는 관문입니다.

4단계: 중간 코드 생성 (Intermediate Representation) - 공통 언어로 변환

의미적으로 검증된 AST는 이제 LLVM IR 같은 중간 표현으로 변환됩니다. 핵심은 아직 특정 CPU 명령어로 내려가지 않는다는 점입니다.

왜 바로 기계어로 가지 않을까요?

  • 한 번의 프런트엔드 구현으로 여러 아키텍처를 지원할 수 있고
  • 최적화 패스를 IR 단계에서 공통으로 적용할 수 있으며
  • 타깃별 백엔드 구현 부담을 크게 줄일 수 있기 때문입니다.

즉 IR은 “언어 구현의 확장성”과 “최적화 효율”을 동시에 확보하는 전략적 중간층입니다.

5단계: 코드 생성 (Code Generation) - 드디어 기계의 언어로

마지막 단계에서 LLVM 백엔드는 IR을 실제 타깃 아키텍처 명령어로 변환합니다. 대상이 x86인지 ARM인지에 따라 결과 바이너리는 달라지지만, 앞단의 언어 의미는 동일하게 보존됩니다.

여기서 드디어 소스 파일은 링크 과정을 거쳐 실행 가능한 파일이 됩니다. 텍스트였던 코드가 운영체제 위에서 실제 프로세스로 살아나는 순간입니다.

마무리: “How IT Works” 시리즈의 시작

이번 글은 “코드가 어떻게 실행되는가”라는 큰 질문의 입구입니다. 앞으로 이 시리즈에서는 추상화 뒤편의 메커니즘을 하나씩 해부해 보려 합니다.

다음 글에서는 이 주제를 이어서, **“메모리 관리: 포인터는 왜 깊게(Deep) 알아야 할까?”**를 다뤄보겠습니다.