Skip to content
라프의 실험일지
Go back

[코드가 실행되기까지 1] 코드는 어떻게 컴파일되는가: 토큰화·구문 트리·IR까지

프로그램은 실행 버튼 한 번으로 시작되지만, 내부에서는 긴 변환 과정을 거친다. CPU는 0과 1로 표현된 명령만 이해하고, 사람은 추상적인 언어로 문제를 표현한다. 이 간극을 메우기 위해 프로그래밍 언어와 번역 도구가 발전해왔다.

이번 글에서는 이 흐름의 앞부분, 즉 언어의 등장 배경부터 컴파일 단계까지를 먼저 정리한다. 사람이 쓴 코드가 어떤 과정을 거쳐 대상 파일로 바뀌는지 핵심만 순서대로 따라가보려 한다.

1. 왜 프로그래밍 언어가 필요했을까

컴퓨터의 출발점은 의외로 단순하다. 스위치의 켜짐/꺼짐을 조합하면 복잡한 논리 연산을 만들 수 있고, 이 아이디어가 CPU의 기반이 됐다.

CPU 입장에서 이해 가능한 것은 결국 on/off, 즉 0과 1이다. 문제는 사람이 문제를 해결하는 방식과 CPU가 명령을 해석하는 방식 사이에 큰 간격이 있다는 점이다. 사람은 추상적으로 사고하고, CPU는 매우 구체적인 비트 패턴만 처리한다.

1.1 CPU는 빠르지만 단순하다

CPU가 하는 일 자체는 놀랄 만큼 단순하다. 데이터를 옮기고, 더하고, 비교하고, 분기하는 동작을 반복하는 것이 전부다. 그래서 “하는 일은 단순한데 엄청 빠른 존재”라고 보는 편이 정확하다.

이 단순함은 약점이 아니라 속도의 원천이기도 하다. CPU는 같은 규칙을 매우 빠르게 반복 수행하기 때문에, 단순 연산에서는 사람이 따라갈 수 없다.

1.2 처음에는 사람이 기계에 맞췄다

초기 컴퓨팅 환경에서는 사람이 CPU가 이해할 수 있는 형식으로 직접 명령을 전달해야 했다. 당시에는 천공카드 같은 매체를 통해 프로그램을 입력했고, 명령 표현도 기계 친화적인 형태에 가까웠다.

초기 천공카드 예시(Hollerith card)

즉, 처음의 의사소통은 “기계가 사람의 언어를 이해”한 것이 아니라, “사람이 기계의 언어에 맞춰 말한” 방식에 가까웠다. 이 불편함이 이후 어셈블리어와 고급 언어가 등장하는 출발점이 됐다.

1.3 어셈블리: 0과 1에 이름을 붙이다

사람이 매번 0과 1 비트열로 직접 명령을 쓰는 방식은 너무 비효율적이었다. 그래서 CPU가 실제로 처리하는 동작을 기준으로, 기계어에 사람이 읽을 수 있는 이름을 붙이기 시작했다.

핵심은 단순하다. 기계어 명령 하나와 사람이 읽는 기호 하나를 대응시키는 것이다. 이렇게 해서 mov, add, sub, call, jmp 같은 어셈블리 명령 표현이 자리 잡았다.

move source, target
add left, right
jump label
call function

위 코드는 실제 문법이 아니라 동작 의미를 보여주기 위한 단순 예시다. 핵심은 명령 하나가 작은 동작 하나를 담당한다는 점이다.

이제 프로그래머는 비트 패턴 자체를 외우기보다, 명령의 의미를 중심으로 코드를 작성할 수 있게 됐다. 그리고 이 어셈블리 코드를 다시 CPU가 이해하는 바이너리로 바꾸는 도구가 필요해졌는데, 그 역할을 맡는 프로그램이 어셈블러(assembler)다.

1.4 추상화가 필요한 이유

어셈블리어는 기계어보다 읽기 쉬워졌지만, 여전히 저수준 언어다. 즉, 문제 해결 자체보다 “기계가 동작할 세부 순서”를 직접 설계해야 한다.

예를 들어 “식탁 위의 빨간 사과를 냉장고에 넣어줘” 같은 요청도, 저수준 관점에서는 다음처럼 쪼개야 한다. “사과 위치 확인 -> 사과로 이동 -> 집기 -> 냉장고 위치 이동 -> 문 열기 -> 넣기 -> 문 닫기”

정렬, 탐색, 문자열 처리처럼 복잡한 문제일수록 이런 분해는 더 길고 세밀해진다. 그래서 저수준에서만 개발하면, 문제의 본질보다 구현 디테일에 에너지를 더 많이 쓰게 된다.

이 지점에서 고수준 언어의 의미가 생긴다. 사람이 문제를 더 추상적으로 표현하면, 아래 계층이 이를 구체적인 명령으로 풀어내는 방식이다.

1.5 규칙이 쌓이면서 고급 언어가 시작됐다

고급 언어의 시작점은 “표현의 자유”가 아니라 “해석 가능한 규칙”이었다. 기계는 문맥을 추측하지 못하므로, 같은 의미를 언제나 같은 방식으로 써야 정확히 실행할 수 있다.

예를 들어 “3번 선반 상자를 출고 구역으로 옮겨”라는 요청도, 내부에서는 절차로 분해된다.

  1. 현재 위치 확인
  2. 3번 선반 좌표 계산
  3. 선반까지 이동
  4. 상자 ID 스캔
  5. 상자 집기
  6. 출고 구역 좌표로 이동
  7. 상자 내려놓기
  8. 작업 완료 신호 기록

사람은 한 문장으로 말하지만, 시스템은 이런 규칙화된 절차로 실행한다. 고급 언어는 이 절차를 사람이 다루기 쉬운 문법으로 표현하게 해주고, 실제 하위 명령으로의 변환은 번역 도구가 맡는다.

1.6 규칙은 선택과 반복으로 확장된다

규칙이 실제 프로그램이 되려면 세 가지가 반드시 필요하다. 선택, 반복, 그리고 재사용이다.

선택은 상황에 따라 다른 경로를 고르는 규칙이다.

if (is_fragile(label)) {
  move_to_lane(1);
} else {
  move_to_lane(3);
}

반복은 처리할 대상이 남아 있는 동안 같은 절차를 계속 실행하는 규칙이다.

while (has_next_box()) {
  pick_box();
  scan_label();
  move_to_lane(route_for_current_box());
  drop_box();
}

여기에 재사용이 더해진다. 예를 들어 route_for_current_box()처럼 공통 판단을 함수로 묶으면, 규칙 변경이 생겨도 한 곳만 고쳐서 전체 동작을 맞출 수 있다.

1.7 복잡한 코드를 읽는 기준

코드가 길어지면 if 안에 while, 그 안에 다시 if가 들어가면서 처음에는 구조가 복잡하게 느껴질 수 있다.

if (need_dispatch) {
  while (has_next_order()) {
    if (is_invalid(order)) {
      mark_failed(order);
    } else {
      dispatch(order);
    }
  }
}

하지만 읽는 기준은 단순하다. “어떤 조건에서 어느 블록이 실행되는가”와 “반복이 어디서 멈추는가”만 먼저 보면 된다. 즉, 복잡해 보이는 코드도 선택(if)과 반복(while)의 조합으로 풀어 읽을 수 있다.

1.8 다음 단계로 넘어가기 위한 관점

여기까지가 언어 관점의 큰 그림이다. 사람은 규칙과 구조로 코드를 쓰고, 컴퓨터는 그 규칙을 해석할 수 있어야 실행할 수 있다.

다음 장부터는 이 과정을 컴파일러 내부 단계로 내려가서, 토큰화 -> 문법 분석 -> 의미 분석 -> IR -> 코드 생성 순서로 구체적으로 살펴본다.

1.9 컴파일러: 구조를 실행 순서로 바꾸는 번역기

이제 역할이 분명해진다. 컴파일러는 사람이 작성한 코드를 받아, 구조를 분석하고, 기계가 실행할 형태로 바꾸는 프로그램이다.

핵심은 코드 구조를 따라 실행 순서를 결정하는 것이다. 연산 우선순위, 괄호, 조건 블록 같은 규칙을 반영해 CPU가 실제로 처리할 순서로 정리한다.

예를 들어 x = a + b * c는 대략 이렇게 계산된다.

  1. b * c를 먼저 계산
  2. 그 결과에 a를 더함
  3. 마지막 값을 x에 저장

즉, 컴파일러는 단순히 글자를 바꾸는 도구가 아니라 “코드의 구조를 해석해 올바른 실행 순서를 만드는 번역기”에 가깝다.

1.10 해석형 언어가 나온 배경

컴파일러가 코드를 기계어로 바꿔주더라도, 또 다른 문제가 남는다. CPU 아키텍처가 다르면 같은 실행 파일을 그대로 돌릴 수 없다는 점이다. 예를 들어 x86용 바이너리는 ARM CPU에서 바로 실행되지 않는다.

여기서 나온 아이디어가 “중간 규격”이다. 소스 코드를 특정 CPU 기계어로 바로 바꾸기보다, 공통 형식(바이트코드: CPU 중립 중간 코드)으로 먼저 만든다. 그다음 각 플랫폼에서 이 공통 형식을 읽어 실행해주는 프로그램을 둔다.

이 실행 계층을 보통 가상머신(VM: 공통 코드를 각 CPU에서 실행하게 하는 소프트웨어 계층)이라고 부른다. VM은 바이트코드를 실행할 때 인터프리터(해석기) 방식이나 JIT(Just-In-Time) 컴파일 방식 같은 실행 전략을 사용할 수 있다.

네이티브 실행과 VM 기반 실행 비교

정리하면 목적은 같다. “코드를 가능한 많은 환경에서 다시 작성 없이 실행”하려는 것이다. 대신 실행 성능, 시작 속도, 배포 편의성 사이에서 각 언어가 서로 다른 선택을 하게 된다.

1장 핵심 정리

  1. 고급 언어의 출발점은 “자유로운 문장”이 아니라 “기계가 해석할 수 있는 규칙”이었다.
  2. 코드가 복잡해 보여도, 실제로는 선택/반복/중첩 패턴의 조합으로 설명할 수 있다.
  3. 컴파일러는 텍스트를 구조로 바꾼 뒤 실행 가능한 형태로 번역하는 프로그램이다.
  4. 같은 목표(코드 실행)를 두고도 컴파일 방식과 해석 방식처럼 서로 다른 실행 경로가 존재한다.

2. 컴파일러는 실제로 어떻게 동작할까

컴파일러는 매일 쓰는 도구지만, 내부 과정은 잘 드러나지 않는다. 보통은 실행 버튼을 누르면 끝나기 때문이다. 하지만 이 단순한 버튼 뒤에서 컴파일러는 꽤 많은 단계를 순서대로 처리한다.

컴파일러 전체 파이프라인 개요

2.1 컴파일러는 특별한 기계가 아니라 프로그램이다

컴파일러를 어렵게 느끼는 가장 큰 이유는 이름에서 오는 압박감이다. 실제로 컴파일러는 “고수준 언어 코드를 다른 형태로 번역하는 프로그램”이다. 특별한 존재라기보다, 복잡도가 높은 일반 프로그램에 가깝다.

우리가 작성한 코드는 텍스트 파일로 저장된다. 이 텍스트 파일이 소스 파일(.c, .cpp, .java 등)이고, 컴파일러는 이 파일을 입력으로 받아 분석한 뒤 대상 파일이나 바이트코드 같은 결과물로 변환한다.

핵심만 보면 컴파일러는 두 가지 일을 한다.

  1. 코드를 규칙에 맞게 읽고 이해한다.
  2. 그 의미를 기계가 실행 가능한 형태로 바꾼다.

2.2 첫 단계: 코드를 토큰으로 쪼개기

컴파일러의 출발점은 “분해”다. 코드를 한 번에 이해하려 하지 않고, 의미 있는 조각으로 먼저 나눈다. 이 조각을 토큰(token)이라고 부른다.

int sum = a + 10;

위 한 줄은 대략 이런 토큰으로 나뉜다.

  1. int
  2. sum
  3. =
  4. a
  5. +
  6. 10
  7. ;
코드 한 줄이 토큰으로 분해되는 예시

이 과정이 어휘 분석(lexical analysis)이다. 여기까지 끝나면 컴파일러는 “글자 덩어리”였던 코드를 “의미 단위 목록”으로 바꿔서 다음 단계(문법/구조 분석)로 넘길 수 있다.

2.3 토큰이 전달하려는 의미를 읽기

토큰으로 잘 쪼갰다고 해서 끝난 건 아니다. 이제 컴파일러는 “이 토큰들이 어떤 문장을 만들고 있는가”를 확인해야 한다. 즉, 토큰의 순서와 묶음이 언어 문법에 맞는지 검사한다.

예를 들어 while은 보통 이런 형태를 기대한다.

while (condition) {
  do_work();
}

그래서 아래처럼 괄호나 조건식 위치가 어긋나면 컴파일러는 문법 오류로 처리한다.

while condition) {
  do_work();
}

핵심은 간단하다. 어휘 분석이 “단어를 나누는 단계”라면, 문법 분석은 토큰이 문법 규칙에 맞는지 확인하면서 구조를 함께 만든다. 즉, 문법 검사와 구문 트리 생성은 거의 같은 과정에서 진행된다. 이 글에서는 이 구조 표현을 구문 트리(AST)라는 이름으로 통일해 부르겠다.

while 문이 구문 트리(AST)로 표현되는 예시

2.4 생성된 구문 트리에 이상은 없을까: 의미 분석

문법 분석을 통과했다고 해서 바로 실행 가능한 코드는 아니다. 문장 모양이 맞는 것과, 실제 의미가 맞는 것은 다른 문제다. 이 단계에서 컴파일러는 구문 트리를 따라가며 의미 규칙을 검사한다.

대표적인 검사 대상은 아래와 같다.

  1. 타입 일치 여부: int 변수에 문자열을 넣지는 않았는가
  2. 선언/스코프: 선언되지 않은 변수를 참조하지 않았는가
  3. 함수 호출 규칙: 인자 개수와 타입이 함수 정의와 맞는가

예를 들어 아래 코드는 문법 자체는 맞지만 의미 분석에서 오류가 난다.

int count = "hello";
total = count + 1;

첫 줄은 타입 불일치이고, 둘째 줄은 total이 선언되지 않았다. 즉, 의미 분석은 “문법적으로 가능한 문장”을 “실제로 실행 가능한 문장”으로 걸러내는 단계라고 보면 된다.

문법 분석과 의미 분석의 검사 차이

2.5 구문 트리를 기반으로 중간 코드 생성하기

의미 분석까지 통과하면, 컴파일러는 구문 트리를 더 다루기 쉬운 형태로 바꾼다. 이 결과를 중간 표현(IR, Intermediate Representation)이라고 부른다.

IR은 특정 CPU에 바로 묶이지 않은 공통 표현이라, 최적화와 코드 생성을 분리해서 처리하기 좋다. 예를 들어 상수 계산 단순화(컴파일 시점에 계산 가능한 값 미리 계산) 같은 최적화는 보통 이 단계에서 수행된다.

before (IR)
t1 = 3 * 4
t2 = a + t1

after (optimized IR)
t2 = a + 12

위처럼 실행 전에 미리 줄일 수 있는 계산을 정리해 두면, 뒤 단계의 코드 생성도 더 단순해진다.

소스 코드가 AST와 IR을 거쳐 최적화 후 대상 코드로 변환되는 흐름

핵심은 “사람이 쓴 코드”를 “기계가 만들기 쉬운 구조”로 한 번 정리하는 과정이라고 보면 된다.

2.6 코드 생성

이제 컴파일러는 중간 코드를 바탕으로, 컴퓨터가 실행할 기계 명령 형태로 바꾼다. 쉽게 말해 “사람이 이해하기 쉬운 표현”을 “CPU가 바로 실행할 표현”으로 바꾸는 마지막 번역 단계다.

여기서 한 번 중요한 질문이 생긴다. 지금까지 설명한 단계를 모두 끝내면, 소스 코드는 곧바로 실행 파일이 될까? 실제로는 보통 그렇지 않다.

컴파일이 끝나면 보통 바로 실행 파일이 생기지 않고, 대상 파일(.o/.obj)이 만들어진다. 이 파일에는 기계 명령과, 함수/변수 이름을 나중에 맞춰 붙이기 위한 정보가 함께 들어 있다.

프로젝트는 보통 여러 소스 파일로 구성되기 때문에, 대상 파일들과 라이브러리를 마지막에 하나로 합치는 단계가 필요하다. 이 과정을 링크(link)라고 하고, 이 작업을 수행하는 도구가 링커(linker)다.

마무리

이번 글에서는 CPU와 사람의 표현 방식 사이 간극이 어떻게 프로그래밍 언어와 컴파일러로 이어졌는지 정리했다. 핵심은 코드를 그냥 문자열로 처리하는 것이 아니라, 구조(토큰/구문 트리/의미)를 해석한 뒤 실행 가능한 형태로 바꾼다는 점이다.

다음 글에서는 컴파일 결과물인 대상 파일이 링커를 거쳐 실행 파일이 되는 과정, 그리고 재배치와 가상 메모리까지 이어지는 마지막 단계를 다룰 예정이다.