[ Android Essential ] 진격의 컴파일러

malcongmalcom·2025년 6월 28일

Android Essential

목록 보기
1/5
post-thumbnail

컴파일러라는 박스에 대하여

박스 이론

우리가 코드를 작성한다고 말할 때, 실제로는 무엇을 하고 있는 걸까? 겉으로 보이는 것은 var a = new A.init(); 같은 코드 한 줄이다. 하지만 이건 말하자면 겉껍데기, 단지 눈에 보이는 출력일 뿐이다. 실질적으로 벌어지는 일은 훨씬 더 깊은 차원에서 일어난다. 그건 바로 비트들의 배열이 바뀌는 일이다.

우리는 키보드를 두드린다. 이 단순한 행위가 시작점이다. 키보드로 입력된 문자열은 일련의 처리를 거쳐 컴파일러라는 박스에 전달된다. 이 박스는 우리가 쓴 문자열을 컴퓨터가 이해할 수 있는 비트 수준의 언어로 바꿔준다. 그 결과, 어떤 전기 신호 — 혹은 비트 — 가 메모리 상에 놓인다. 다시 말해, 우리가 쓰는 코드는 결국 개발자 친화적인 문자열일 뿐이다. 그리고 이 문자열을 컴퓨터가 이해할 수 있도록 변환하고 실행하는 과정에서 비트가 바뀐다. 우리가 진짜로 조작하는 건 이 비트들이다.

우리가 할 수 있는 일은 박스 안에 무언가를 넣는 것이다. 그 수단이 키보드이든, 번역기이든, 심지어 우리의 뇌이든 간에, 핵심은 박스에 무언가를 전달하고, 그 박스가 사이드 이펙트를 낸다는 것이다. 예를 들어보자. 코드를 입력하면 편집기라는 박스가 그걸 보여준다. 영어 문장을 읽으면, 우리의 뇌라는 박스가 그 의미를 이해하고 행동으로 이어지게 한다. 인공지능이라는 박스에 질문을 넣으면, 답변이라는 사이드 이펙트가 나온다. 결국 모든 일은 박스 간의 상호작용이다. 박스는 각각 다른 방식으로 데이터를 받고, 처리하며, 그 결과를 낸다. 그리고 이 모든 흐름은 결국 비트의 상태 변화를 일으킨다.

자료구조란 무엇일까? 자료구조는 말하자면 비트를 다루는 히든 가이드라인이다. 어떻게 비트를 저장하고, 연결하고, 꺼내 쓸지를 구조적으로 정의한 방식이다. 트리든, 해시든, 결국은 비트를 어떤 방식으로 배열할 것인가에 대한 이야기다. 알고리즘은 그 위에서 동작하는 규칙이다. 하지만 더 중요한 건 그 알고리즘을 어디에 두느냐, 즉 어떤 박스에 어떤 용도로 넣느냐이다. 잘 동작하는 알고리즘을 넘어서, 더 큰 박스를 설계하고 활용할 줄 아는 능력이 지금 시대엔 더 중요해졌다.

이 모든 구조는 언어와도 닮아있다. 프로그래밍 언어는 결국 인간이 만든 기호 체계다. 그리고 컴파일러는 그 기호를 시니피에로 번역하는 기계다. 서로 다른 언어로 쓰인 코드라도 같은 시니피에를 향하고 있다면, 그건 결국 같은 의미를 가리키는 것이다. 이는 인공지능, 번역기, 프로그래머 모두에게 적용된다. 중요한 건 표현이 아니라, 그 표현이 가리키는 의미다.

결론적으로 우리는 비트를 바꾸는 손가락이다. 가장 낮은 수준으로 가면, 우리가 하는 일은 손가락을 움직여 키보드를 누르는 일이다. 그 단순한 입력이 거대한 변화를 유도한다. 반도체의 상태가 바뀌고, 기계가 작동하며, 세상이 바뀐다. 코딩이란 단지 문자열을 입력하는 일이 아니라, 세계의 상태를 비트 수준에서 바꾸는 일이다. 그러니 박스를 잘 설계하자. 그리고 그 박스들을 자유롭게 넘나드는, 더 큰 시야를 갖자. 그게 진짜 ‘잘 작동하는 알고리즘’보다 더 중요한 태도다.

쓸데없는 얘기는 이쯤하고 본론으로 들어가보자.

기호를 구조로, 구조를 신호로

당신은 컴파일러가 무엇인지 정확히 알고 있는가? 사실 컴파일러는 특별한 무언가가 아니다. 그저 다른 프로그램과 다를 바 없는 하나의 실행 파일일 뿐이다. 예를 들어, 다음 명령어를 보자.

javac HelloWorld.java

여기서 javac는 자바 컴파일러를 실행시키는 실행 파일 이름이고, HelloWorld.java는 우리가 만든 자바 소스 코드다. javac는 .java라는 텍스트 파일 하나를 받아서 .class라는 바이트코드 파일 하나를 출력한다. 아, 그리고 참고로 이 javac는 운영체제가 찾아서 실행시켜주는 것이다.

which javac
ls -l $(which javac)

이런 명령어를 실행해보면, javac가 어디에 있는지, 심볼릭 링크인지, 진짜 실행 파일인지 확인할 수 있다. 심볼릭 링크라면 그 안에는 실제 파일의 경로 문자열이 들어 있다. 결국 운영체제가 링크를 따라가서 원본 파일을 찾아 실행시켜주는 구조인 것이다.

컴파일러는 .java, .kt, .cpp 같은 문자 파일을 입력으로 받는다. 그 파일 안에 들어 있는 건 그냥 문자열이다. 사람이 읽을 수 있는 코드, 즉 텍스트다.

컴파일러는 이 문자열을 입력으로 받아서 무언가 계산을 한다. 그리고 그 계산의 결과로 파일 하나를 출력한다. 예를 들어 .class, .exe, .wasm 같은, 기계가 이해할 수 있는 코드 파일을 만든다. 즉, 컴파일러란 문자열 → 비트 조작 → 기계어 출력이라는 과정을 거치는 하나의 거대한 계산 박스일 뿐이다.

여기서 중요한 점이 등장한다. 컴파일러는 입력 문자열을 받아서 바로 기계어로 바꾸지 않는다. 중간에 구조적 해석, 즉 파싱(parsing) 과정을 거친다. 이때 만들어지는 것이 바로 AST (Abstract Syntax Tree), 추상 구문 트리다.

‘추상적’이라는 건 무슨 뜻일까? ‘Abstract’는 구체적인 것에서 본질만 뽑아낸 것이다. 우리는 var a = new A.init(); 라는 코드를 보면 자연스럽게 의미를 이해한다. 하지만 컴퓨터는 문자열을 하나하나 토큰(token)으로 쪼개고, 그걸 문법 규칙에 맞춰 구조화해야만 이해할 수 있다. 그렇게 쪼개고 구조화한 것을 트리 형태로 저장한다. 그 트리가 바로 AST다.

왜 트리 구조일까? 모든 문법 요소는 다른 요소를 포함하거나 포함되거나 하기 때문이다. 즉, 문법은 계층적이고, 중첩된다. 그래서 자연스럽게 트리로 표현할 수밖에 없다.

AST가 왜 필요할까? 컴파일러가 x + y * 3 같은 식을 문자열 그대로 보면 연산 순서를 알 수 없다. 하지만 AST로 변환하면 연산자 우선순위에 따라 구조가 명확해진다.

     +
    / \
   x   *
      / \
     y   3

이렇게 구조화되어 있어야 컴파일러가 순서대로 처리할 수 있다.

그렇다면 AST 구조를 탐색한다는 건 무엇일까? AST는 트리다. 트리는 여러 탐색 방식이 있다: 전위(pre-order), 중위(in-order), 후위(post-order), 레벨 순서(level-order) 탐색 등이 있다. 탐색 방식에 따라 어떤 연산을 언제 수행할지, 어떤 최적화를 적용할지, 어떤 에러를 발견할지가 결정된다.

여기서 또 중요한 개념이 등장한다. 심볼 테이블(symbol table)이다. 이것은 프로그램 안에서 사용된 이름(변수명, 함수명, 클래스명 등)을 컴파일러가 기억하기 위한 자료구조다. 보통 해시 테이블로 구현된다.

예를 들어 int x = 3; 이라는 코드에서 컴파일러는 "x"라는 이름을 테이블에 등록하고, 그 타입이나 위치 같은 정보를 저장한다. 후에 x = x + 1; 같은 코드를 보면, 다시 "x"를 테이블에서 찾아야 한다. 이때 빠르게 찾기 위해 해시 테이블이 쓰인다.

정리해보자.

우리가 키보드로 코드를 입력할 때 눈에 보이는 건 문자열이다. 하지만 이 문자열은 컴파일러 안에서 구조화되고 계산되어, 결국 기계가 이해할 수 있는 비트 수준의 코드로 변환된다. 겉으로 보이는 텍스트는 문자 인코딩을 거친 이진수, 즉 비트들의 조합일 뿐이며, 컴파일러는 이 비트들을 다루기 위해 먼저 코드를 토큰으로 쪼개고, 이를 추상 구문 트리(AST) 같은 구조로 해석한 다음, 최적화와 변환 과정을 거쳐 다시 기계어 비트열로 환원한다.

결국 컴파일은 문자열로 표현된 의미를 해석하고, 그 의미를 비트로 재구성하는 계층적 과정이며, 이 모든 과정의 최종 실체는 0과 1로 이루어진 전기 신호, 즉 비트의 변화다.

컴파일러라는 거인에 대하여

지금까지 파악한 핵심은, 컴파일러도 결국 하나의 프로그램이라는 점이다. 이것이 현재 우리가 가진 기본적인 지식이다. 하지만 여기서 멈추지 않고, 더 깊은 이해를 위해 어떤 지식을 쌓아야 할지 고민해볼 필요가 있다. 앞으로는 그 지식들이 실제 문제 해결에 어떻게 도움이 되고, 어떻게 활용될 수 있을지 구체적으로 탐색해보려 한다.

컴파일러는 본질적으로 문제를 해결하는 알고리즘의 집합이다. 즉, ‘어떤 순서로 문제를 해결할 것인가’에 관한 논의다. 이 관점에서 많은 인사이트를 얻을 수 있다. 컴파일러에 대해 설명하는 수많은 문서들도 결국 “그래서 그 문제를 어떻게 해결했는가?”에 대한 답을 제시한다. 그 답에서 아이디어를 얻는다면 큰 도움이 될 것이다.

특히 우리가 직접 사용하는 코틀린 컴파일러에 대해 더 알아두는 것은 매우 중요하다. 우리가 직접 사용하는 프로그램이기에, 안드로이드 앱 개발 과정에서 그 구조와 동작 방식을 이해하는 것은 필수적이다. 내부 구조를 이해하면 오류가 발생해도 대처 방법을 찾기 쉽고, 더 나아가 다양한 메타 지식도 습득할 수 있다. 이렇게 차근차근 탐구하며 지식을 넓혀 나갈 계획이다.

거인이 세상을 바라보는 시선

컴파일러에서 말하는 프론트엔드(Frontend)와 백엔드(Backend)는 우리가 흔히 아는 웹 개발의 프론트엔드, 백엔드와는 다르다. 먼저 프론트엔드는 소스 코드를 읽어 구조적 의미로 바꾸는 부분을 의미한다. 쉽게 말해, 소스 코드 → 토큰(token) → AST(Abstract Syntax Tree) 생성까지가 프론트엔드의 영역이다.

프론트엔드의 주요 단계는 다음과 같다.

  • 렉싱(Lexing): 어휘 분석 단계로, 예를 들어 int x = 1;이라는 코드가 [“int”, “x”, “=”, “1”, “;”]와 같은 토큰 리스트로 변환된다.
  • 파싱(Parsing): 구문 분석 단계로, 문법 구조를 확인하고 AST를 생성한다.
  • 의미 분석(Semantic Analysis): 타입 검사, 변수 이름 확인, 스코프 처리 등을 수행하며, 심볼 테이블 구축도 이 과정에 포함된다.

즉, 프론트엔드는 문법과 의미를 이해하는 부분이라고 생각하면 된다.

이를 인간에 비유하자면, 프론트엔드는 ‘눈’과 같다. 눈은 외부 세상을 관찰하는 기관으로, 뇌와 밀접하게 연결되어 초점을 맞추고 정보를 받아들인다. 조금 더 확장해서 이야기하자면, 눈 자체도 일종의 뇌의 연장선처럼 동작한다. 안구에는 초점을 맞추는 기관이 있고, 이는 뇌의 기능과도 연결되어 있다. 심리학에서 ‘부주의맹’이라는 실험을 통해 확인된 바 있듯, “목표가 바뀌면, 보이는 것도 바뀐다.”

즉, 컴파일러를 우리 뇌라고 생각해 보면, 프론트엔드 역시 뒤따르는 미들엔드와 백엔드에 따라 그 모습이 달라질 수 있다는 의미다. 컴파일러 개발 이론에서 흔히 말하는 수직적 개발 방식도 이러한 맥락과 궤를 같이한다. 어려우면 이 한 문장만 기억해도 충분하다. 프론트엔드는 문법과 의미를 이해하여 사람이 쓴 코드를 컴퓨터가 이해할 수 있는 구조화된 트리(AST)로 바꾸는 단계다. 마치 사람이 특정 대상에 초점을 맞춰 사물을 인지하듯, 컴파일러는 AST라는 구조화된 트리를 통해 외부 세상에 ‘눈’을 뜨는 셈이다. 결국, AST를 만드는 프론트엔드 부분은 컴파일러가 세상을 인식하는 하나의 ‘눈’이라 할 수 있다.

거인의 망막에서 일어나는 첫 번째 시선

프론트엔드를 '눈'이라 비유했다면, 그중에서도 문법 분석과 토큰화는 마치 망막에 해당한다. 이 망막은 세상을 단순히 '보는' 것이 아니라, 패턴과 구조를 감지하고 분해하는 능동적인 기관이다. 컴파일러 역시, 눈앞에 펼쳐진 코드들을 단순한 문자열로 바라보지 않는다. 먼저 그것을 토큰(token) 단위로 쪼개고, 다시 문법 구조로 조립하여 트리 형태의 의미망을 만들어낸다.

예컨대 다음과 같은 코드가 있다고 해보자.

val x = 1 + 2

이 문장은 7줄짜리 미니 파서의 망막을 통과하며 다음과 같은 과정을 거친다:

tokens = lex(source)
ast = parse(tokens)

간단하지만 강력한 이 두 줄 안에는, 컴파일러의 첫 '시선'이 담겨 있다. lex는 세상을 픽셀처럼 분해하듯 코드를 토큰 단위로 나누고, parse는 그 토큰들을 결합하여 문장의 구조를 이해한다. 이 단계를 통해 만들어지는 AST는 거인의 시야 속 경계선이 명확한 객체 인식과도 비슷하다.

실제로 Kastree를 통해 Kotlin 코드를 파싱하면 다음과 같이 AST 노드가 만들어진다.

val file = Parser.parseFile("""
    fun sum(a: Int, b: Int): Int {
        return a + b
    }
""".trimIndent())

println(file.decls.first()) // FunDecl(name=sum, ...)

이처럼 단순한 문자열이 FunDecl이라는 의미 단위 노드로 정제된다. 거인의 망막은 단순히 '보는 것'에서 멈추지 않는다. 그것은 코드의 모양을 구조화된 인식 체계로 바꾸는 첫 관문이며, 이후 뇌(미들엔드)와 운동신경(백엔드)으로 이어지는 모든 판단과 행동의 토대가 된다.

거인의 홍채를 통과해 시신경으로

문법 분석과 토큰화를 통해 거인은 망막에서 세상을 본다. 하지만 세상을 인식하기 위해서는, 단지 보는 것만으로 부족하다. 시야에 들어온 정보를 더 정밀하게 해석하고, 그것을 뇌로 전달할 필요가 있다. 이 과정이 바로 AST에서 PSI를 거쳐 IR로 이행하는 단계이며, 이는 거인의 '홍채'와 '시신경'이 작동하는 방식에 비유할 수 있다.

AST(Abstract Syntax Tree)는 컴파일러가 본 코드의 구조화된 형태다. 즉, 망막이 감지한 패턴을 트리 구조로 표현한 것이며, 코드를 의미 단위로 나눈다. 하지만 AST는 구조만을 다룰 뿐, 프로그램의 전체 맥락이나 세부 정보는 담고 있지 않다. 예를 들어, sum(a: Int, b: Int): Int라는 함수 정의가 있다면 AST는 이것을 FunDecl, Parameter, ReturnType 등의 노드로 정리해줄 뿐이다. 이건 마치 사물을 윤곽선만으로 구분한 수준의 시각 정보와 같다.

PSI(Program Structure Interface)는 AST보다 더 풍부한 정보를 담는 표현이다. Kotlin의 경우 IntelliJ 기반 플랫폼에서 사용되며, 코드의 구조뿐 아니라 편집 가능한 컨텍스트, 파일 위치, 참조 가능성 등 IDE 친화적인 정보를 모두 포함한다. PSI는 AST에 생명을 불어넣는다고 말할 수 있다. 즉, 거인의 홍채가 빛의 굴절을 조절하여, 더 선명한 이미지를 뇌에 전달하는 것처럼, PSI는 코드에 대한 더 정밀한 인식 결과를 만들어낸다.

즉 PSI는 눈이 뇌로 정보를 전달하기 전, 마지막으로 정보를 재가공하는 '홍채'라고 볼 수 있다.

IR(Intermediate Representation)은 이제 시야에서 뇌로 넘어가는 신호, 즉 실제로 컴파일러 내부에서 분석과 최적화를 수행하기 위한 중간 표현이다. IR은 고수준 코드를 추상화하되, 명령어 수준으로 가까워진다. 예를 들어 Kotlin의 1 + 2 같은 표현은, 이 단계에서 Add(Const(1), Const(2)) 같은 형식으로 바뀌며, 실제 연산에 필요한 정보를 갖춘다.

Kotlin 컴파일러의 경우, 다음과 같은 IR 계층이 존재한다:

  • Frontend IR (FIR): Kotlin 1.4 이후 도입된 레이어. 기존의 BindingContext와 Type 정보를 통합하여, 컴파일 초반에 더 나은 성능과 구조화를 제공.
  • IR Tree: JVM, JS, Native 등 다양한 백엔드를 공통으로 처리할 수 있도록 만든 내부 표현.

이것은 마치 거인의 시신경이 시각 정보를 전기 신호로 바꾸어 뇌로 전달하는 과정과 같다.

거시적으로 정리해보면, 다음과 같이 이해할 수 있다.

컴파일러 단계생물학적 비유핵심 역할 및 책임
AST망막코드의 구문 구조를 트리 형태로 파악함
PSI홍채문맥, 참조, 위치 정보까지 포함해 구조를 정밀화함
IR시신경내부 분석과 최적화를 위한 중간 표현으로 전달함

거인의 시각피질

거인의 눈은 세상을 본다. 망막을 지나 홍채와 시신경을 거쳐, 정보는 IR이라는 전기 신호가 되어 뇌로 전달된다. 그러나 단순히 전달되었다고 해서 그것이 곧 ‘이해’되는 것은 아니다. 눈으로 본다고 곧장 그 사물이 무엇인지 아는 게 아니듯, IR을 만들어냈다고 곧장 컴파일이 끝나는 것도 아니다.

거인이 진짜로 코드를 이해하려면, 그 정보들이 뇌 안에서 정확히 ‘해석’되어야 한다.

이 단계에서 거인의 시각피질이 등장한다. 시각피질은 수많은 신경망이 얽혀 있는 해석 기관이다. 이곳에서 거인은 시야 속의 이미지가 나무인지 사람인지, 혹은 허상인지 아닌지를 판단한다. 컴파일러에서 이와 같은 역할을 하는 것이 바로 심볼 테이블과 의미 분석(Semantic Analysis) 이다.

의미 분석이 시작되기 위해서는 먼저, 코드 안의 수많은 이름들이 무슨 의미를 가지는지를 알아야 한다. sum, a, b 같은 이름은 단순한 문자열이 아니다. 그것이 함수인지 변수인지, 어느 스코프에 있는지, 어떤 타입을 갖는지와 같은 맥락(Context) 이 반드시 함께 해석되어야 한다. 이를 가능하게 해주는 것이 바로 심볼 테이블(Symbol Table) 이다.

심볼 테이블은 마치 거인의 시각피질에 퍼져 있는 신경세포들처럼, 수많은 이름들을 기억하고 그 의미를 연결한다. ‘sum’이라는 이름을 보면, 이 심볼은 함수 선언이고, 두 개의 Int를 받아 Int를 반환하며, 현재 모듈 안에 정의되어 있다는 정보가 붙어 있다. 이러한 정보가 없다면, 거인은 무엇을 보고 있는지 끝내 알 수 없다.

심볼 테이블이 마련되면, 이제 거인은 자신이 보고 있는 것에 대해 하나씩 의미를 부여하기 시작한다. 이 변수가 선언되었는지, 이 연산이 타입에 맞는지, 이 함수 호출이 유효한지, 모든 것을 따져본다.

예를 들어 a + b 라는 표현이 있다면, 컴파일러는 먼저 a와 b가 어떤 타입인지 확인한다. 만약 둘 다 Int라면 아무 문제가 없다. 그러나 하나가 Int, 하나가 String이라면? 이건 마치 거인이 나무와 사람을 같은 것으로 착각하는 것과 같다. 오류가 발생한다.

이런 과정 속에서 거인은 스스로를 교정하기도 한다. 정의되지 않은 변수를 사용할 경우, "이건 내가 모르는 이름인데?" 하고 알려준다. 또, 조건문 속에서 항상 거짓인 조건을 만나면, "여기 코드는 실행되지 않겠구나" 라며 무시한다. 시각피질이 시야 속 노이즈를 제거하듯, 의미 분석은 프로그램 속 불필요하거나 잘못된 코드들을 걸러낸다.

이 모든 작업은 단일한 테이블 하나로는 어렵다. 실제로 컴파일러는 Environment라고 불리는 구조를 이용해, 다양한 심볼과 타입, 선언 정보를 스코프 단위로 관리한다. 함수 안에 새로운 변수가 선언되면, 그에 맞는 새로운 스코프가 만들어지고, 그 안에서만 유효한 정보들이 저장된다.

이것은 마치 뇌의 ‘작업 기억’처럼 작동한다. 한 장면을 해석하는 동안, 거인은 그 안의 문맥을 단기적으로 기억하고, 해석이 끝나면 그 정보를 지운다. 그래야 다음 코드를 분석할 때 과거의 문맥에 끌려가지 않기 때문이다.

진짜 ‘이해’는 이 시점에서 완성된다. 이 과정을 거치고 나서야, 컴파일러는 자신이 처리하는 코드가 ‘무엇을 하려는 것인지’를 이해하게 된다. 단지 구문적으로 올바른 코드가 아니라, 의미적으로도 타당한 코드임을 판단한 것이다. 그리고 이 이해를 바탕으로, 거인의 뇌는 곧 미들엔드 단계로 진입하여 최적화와 변형을 수행하게 된다.

눈에 보이는 세계를 해석하는 데는 단지 눈만으로는 부족하다. 그 정보들이 뇌 속에서 어떻게 연결되고 해석되는지가 진짜 이해다. 컴파일러의 세계에서도 마찬가지다. 심볼 테이블과 의미 분석은, 그 코드가 무엇인지, 어떤 의미를 갖는지를 해석해주는 시각피질이다.

거인의 눈에서 터져 나오는 경고 신호

IR까지 조립해 준 시각피질이 있다 해도, 거인은 여전히 불완전하다. 나무와 사람을 구분해 놓고도, 실제 숲길을 걸을 때 발밑의 돌을 못 보고 넘어진다면 아무 소용이 없다. 이 돌멩이를 미리 알려 주는 감각이 바로 진단·오류 메시지(Diagnostics) 다. 눈은 사물을 보는 동시에, 위험할 때 우리에게 미세한 경고 신호를 보낸다. 동공이 수축하고, 시야 주변부가 번쩍이며, “조심해!” 하고 외친다. 컴파일러 역시 코드를 읽는 즉시 ― 가능한 한 이른 순간에 ― 개발자에게 같은 신호를 보내야 한다.

좋은 오류 메시지는 단순한 경고음이 아니다. 거인의 눈이 뇌와 근육을 동시에 깨우듯, 메시지는 어디가 문제인지를 정확히 가리키고, 왜 문제인지를 설명하며, 어떻게 고칠 수 있는지까지 암시해야 한다.

Fernando Borretti가 Austral 컴파일러를 만들면서 택한 방법은 “오류를 값으로 표현”하는 것이었다. 모듈 이름, 소스 스팬, 주변 문맥, 오류 종류, 그리고 사람이 읽을 수 있는 텍스트를 하나의 레코드에 담는다. 필요할 때마다 상위 호출부가 이 레코드에 위치 정보나 문맥을 덧붙여 다시 던져 주는 구조다. 이 덕분에 하위 모듈은 ‘여기가 틀렸다’ 라는 최소 정보만 던지고, 상위 모듈이 ‘바로 이 줄, 이 열에서, 이런 이유로 틀렸다’ 라는 풍부한 메시지로 가다듬어 낼 수 있다.

또 하나 배울 점은, 컴파일러 내부 어디서든 동일한 Error 타입을 사용하도록 만든다는 것이다. 그러면 오류 출력기(print, IDE, LSP, 웹 콘솔)가 어떤 환경이든 같은 데이터를 받아, 단순 텍스트, 컬러 콘솔, HTML, 심지어 번역된 자연어 메시지로도 자유롭게 변환할 수 있다. 결국 오류 메시지가 데이터로 남아 있기에 가능한 일이다.

Fernando Borretti는 좋은 오류 메시지를 만들기 위한 구체적인 원칙들도 함께 제시한다.

  • 오류는 단순 문자열이 아니라 구조화된 데이터여야 한다. 위치 정보(파일명, 줄과 열 번호), 오류 종류, 코드 스니펫, 메시지 본문 등을 포함해야 한다.
  • 오류 메시지에는 파일명, 줄 번호, 열 번호, 코드 일부가 반드시 포함되어 어디서 문제가 생겼는지 명확히 알려야 한다.
  • 메시지는 사용자가 문제를 이해하고 어떻게 고칠 수 있는지 친절하게 안내하는 역할을 해야 한다.
  • 동일한 입력에 대해 항상 같은 결과를 출력하여 테스트 가능성을 확보해야 한다.
  • 메시지는 키-값 쌍으로 관리되어 국제화가 가능해야 한다.

이러한 기준은 단지 컴파일러 내부에서 끝나는 이야기가 아니다. 우리 같은 안드로이드 개발자들도 앱을 만들 때, 단순히 오류를 표시하는 것을 넘어서서 UI와 UX 설계 차원에서 사용자가 문제 상황을 이해하고 자연스럽게 해결할 수 있도록 안내하는 경험을 만들어야 한다. 예를 들어 입력 오류가 있을 때는 잘못된 부분을 시각적으로 강조하고, 구체적인 해결책을 제시하며, 네트워크 문제나 인증 실패 시에는 단순한 “오류 발생” 메시지가 아니라 상황에 맞는 친절한 설명과 재시도 가이드를 제공해야 한다.

이처럼 오류 신호가 신속하고 정확하게 전달될 때, 개발자는 거인이 넘어지기 전에 ‘여기가 돌멩이구나’ 하고 인지하여 편히 길을 고칠 수 있다. 파싱 단계에서 잡을 수 있는 문법 오류는 그 자리에서, 타입 불일치나 초기화 누락은 시각피질(의미 분석)에서, 더 복잡한 제네릭 제약이나 라이프사이클 문제는 뇌(미들엔드)에서 각 단계가 자신이 볼 수 있는 범위에서 즉시 신호를 내보내는 것이다.

이렇게 눈이 보내는 경고 신호가 정교해질수록, 거인은 더 멀리, 더 안전하게, 더 빠르게 달릴 수 있다.

거인의 눈에서 뇌로, 신경전달의 시작

거인의 눈, 즉 컴파일러의 프론트엔드가 코드를 읽고 의미를 분석해낸 결과는 단지 시각 정보에 머무르지 않는다. 이 신호들은 거인의 뇌, 즉 미들엔드로 넘어가야만 진정한 힘을 발휘한다. 이 신경전달 과정의 첫걸음이 바로 IR(중간 표현, Intermediate Representation) 생성이다. IR은 거인의 눈과 뇌 사이를 잇는 시냅스 같은 역할을 한다. 눈이 사물을 인지하고 그 정보를 뇌로 보내는 신경 신호처럼, 프론트엔드는 구문과 의미 정보를 정교하게 구조화된 IR로 바꿔 미들엔드에 전달한다.

이 인터페이스는 단순한 데이터 전달이 아니라, 서로 다른 ‘언어’를 사용하는 두 영역 간의 정확하고 효율적인 소통을 위해 정교하게 설계되어야 한다. LLVM 프로젝트에서 제시하는 SIL(Static Intermediate Language)은 바로 이 지점에서 눈과 뇌의 연결 고리를 보여주는 좋은 예다. SIL은 Swift 언어 프론트엔드가 분석한 구문과 타입 정보를 고수준 IR로 포착한다. 예를 들어, SIL의 헤더는 함수 시그니처, 변수 타입, 메모리 관리 정보를 포함해 미들엔드가 최적화를 수행하기에 충분한 ‘뇌가 이해할 수 있는’ 데이터로 구성된다.

마찬가지로 Kotlin 컴파일러는 프론트엔드가 생성한 PSI(Program Structure Interface)와 분석 결과를 토대로 IR을 만든다. Kotlin IR 헤더는 함수 이름, 파라미터 타입, 반환 타입, 심지어 inline 함수 여부 등 미들엔드 최적화를 위한 상세 정보를 담는다. 이 IR은 마치 신경말단에서 신경전달물질이 방출되듯, 의미가 응축된 형태로 다음 단계로 전달되어 최적화, 코드 생성 같은 복잡한 처리를 가능하게 한다.

이처럼 IR 생성 프롤로그는 단순한 변환 과정이 아니다. 그것은 거인의 눈에서 수집한 시각 정보를 신경 신호로 변환해 뇌에 전하는 ‘첫 시냅스’다. 이 시냅스가 튼튼해야 미들엔드의 뇌가 제대로 작동하고, 거인의 온몸을 움직이는 운동 신경(백엔드)으로 명확한 명령이 전달될 수 있다.

또한 IR은 확장성과 호환성 측면에서 중요한 의미를 갖는다. IR 설계가 잘 되어 있으면 새로운 언어나 최적화 기법이 추가되어도, 프론트엔드와 미들엔드 간의 연결을 유지하며 컴파일러 전체의 진화를 돕는다. 즉, 눈과 뇌 사이에 강력한 ‘공용 신경망’ 같은 역할을 하는 것이다.

결론적으로, 컴파일러 프론트엔드가 코드를 ‘보고’ 해석한 결과는 IR이라는 중간 신호로 변환되어야만, 거인의 뇌가 이를 인지하고 판단해 효과적인 행동으로 연결할 수 있다. 이 과정이 없다면, 눈은 아무리 뛰어나도 몸을 제대로 움직이지 못하는 거인일 뿐이다. IR 생성은 컴파일러라는 거인 체계의 생명선이며, 눈과 뇌를 연결하는 시냅스 그 자체라 할 수 있다.

거인의 시력 점검

컴파일러라는 거인이 제대로 세상을 인지하고 행동하려면, 눈과 뇌 사이의 신경전달뿐 아니라 그 신경망이 올바르게 작동하는지 철저히 검증하는 단계가 반드시 필요하다. 이것이 바로 ‘테스트’와 ‘부트스트랩’ 과정이다. 눈이 제대로 사물을 인지하는지, 뇌가 그 신호를 정확히 이해하는지, 그리고 운동 신경이 명령을 올바르게 수행하는지 모두 확인해야만 거인은 온전한 힘을 발휘할 수 있다.

테스트 과정은 컴파일러 개발에서 눈의 시력검안에 비유할 수 있다. 사람의 눈이 시력을 점검할 때 다양한 크기와 모양의 글자를 보며 제대로 인지하는지를 확인하듯, 컴파일러도 자신이 처리할 소스 코드의 여러 형태를 가지고 ‘자기 눈’의 정확성을 시험한다. 여기서 중요한 점은 단순히 구문이 맞는지 확인하는 것을 넘어, 의미가 올바르게 해석되고, IR이 정확히 생성되며, 최종 산출물이 기대한 대로 동작하는지 ‘end-to-end 테스트(e2e 테스트)’를 수행한다는 것이다.

e2e 테스트는 컴파일러가 입력을 받아 최종 실행 가능한 프로그램을 만들어내는 전체 경로를 검증한다. 이는 마치 눈에서부터 뇌, 운동 신경을 거쳐 손가락까지 연결된 신경망이 모두 정확히 작동하는지 확인하는 셈이다. 만약 어느 한 단계라도 오류가 있다면, 거인의 행동은 왜곡되거나 멈출 수밖에 없다. 이런 테스트 덕분에 컴파일러는 복잡한 코드와 다양한 환경에서도 믿을 수 있는 동작을 보장한다.

부트스트랩(bootstrap)은 컴파일러가 자기 자신을 컴파일하는 과정을 뜻한다. 이 역시 거인의 자가진단과 같다. 눈, 뇌, 운동 신경이 모두 자신에게서 나온 것임을 확인하는 ‘자가 인식’의 순간이다. 부트스트랩 전략은 컴파일러가 초기 버전으로 자기 자신을 컴파일해 완성된 컴파일러를 만들어내고, 다시 그 컴파일러로 자기 자신을 또 컴파일하는 과정을 반복한다. 이 과정은 컴파일러가 스스로의 정의를 충실히 따르고 있다는 ‘증명’이자, 성숙한 시스템임을 입증하는 강력한 수단이다.

또한 부트스트랩은 새로운 기능이나 최적화가 도입될 때 안정성을 확보하는 데 필수적이다. 예를 들어, 컴파일러가 자신의 IR 생성 로직이나 최적화 코드를 바꿨다면, 자기 자신을 컴파일하는 과정을 통해 이 변화가 문제 없이 적용되는지를 직접 검증한다. 이것은 마치 거인이 자기 눈과 뇌를 다시 한번 테스트하는 것과 같다.

최근 컴파일러 개발에서는 이런 테스트와 부트스트랩 전략을 체계적으로 관리하기 위해 다양한 자동화 도구와 CI/CD 파이프라인이 도입되고 있다. 이렇게 되면 거인의 눈과 뇌, 운동 신경이 언제나 최상의 상태를 유지하도록 지속적인 건강검진을 실시하는 것과 같다.

결국, 컴파일러라는 거인의 눈이 단순히 사물을 보는 데 그치지 않고, 신뢰할 수 있는 판단과 행동으로 이어지려면, 테스트와 부트스트랩이라는 ‘자가 검안’과 ‘자가 진단’ 과정이 필수다. 이것이 있어야만 거인은 자신 있게 움직이고, 새로운 도전에도 흔들림 없이 맞설 수 있다. 컴파일러의 프론트엔드, 미들엔드, 백엔드가 서로 연결된 이 거대한 신경망이 튼튼하게 작동하는 기반이 바로 이 과정에 놓여 있다.

거인의 시니피에

다음으로, 미들엔드(Middle-end)의 정의는 이렇다.

미들엔드는 중간 표현(IR, Intermediate Representation) 위에서 최적화와 같은 작업을 수행하는 부분이다. 프론트엔드에서 만든 AST를 보다 단순하고 추상적인 중간 언어로 변환하는 과정이기도 하다.

즉, 미들엔드는 ‘변환’의 영역이다.

철학적으로 비유하자면, 우리는 세상에 있는 실제 사물이 아니라, 그 사물에서 반사되어 들어오는 ‘빛’을 본다. 그 빛은 우리 눈의 망막세포에 닿고, 망막세포는 빛 신호를 전기 신호로 바꿔 신경계를 통해 뇌로 전달한다. 고등학교 생물 시간에 배웠듯, 뉴런은 신호를 받아들이고 전달하는 데 한계가 있다. 어떤 신호는 약하게 전달되고, 어떤 신호는 강하게 강화된다. 또 신경계의 구조에 따라 반응도 다르다. 도파민 분비량도 달라지면서 뇌의 활동에 영향을 준다.

컴파일러의 ‘뇌’에 해당하는 미들엔드도 마찬가지다.

미들엔드는 AST를 IR로 변환하고, 데드 코드 제거, 인라이닝, 루프 최적화, 상수 전파 등 다양한 최적화 작업을 수행한다. 우리도 뇌에서 어떤 일이 일어나는지 완전히 알지 못하듯, 컴파일러 내부의 모든 과정 역시 완벽히 이해하기 어렵다. 그래서 계속 공부하는 것이다. IR 단계에서는 논리적으로 같은 코드를 더 빠르고 효율적으로 실행되도록 바꿀 수 있다. LLVM, Swift SIL, Cranelift IR 같은 용어는 모두 이러한 중간 표현(IR)의 대표적인 예다.

거인의 모국어

거인의 뇌, 즉 미들엔드에서 가장 핵심적인 부분 중 하나는 바로 ‘중간 표현(IR, Intermediate Representation)’이다. IR은 프론트엔드에서 파싱된 복잡하고 구체적인 구문 트리(AST)를 좀 더 단순하고 추상화된 형태로 바꾼 것으로, 컴파일러가 코드를 효율적으로 분석하고 변환할 수 있게 하는 공통의 언어라 할 수 있다.

IR 설계의 철학을 이해하려면, 먼저 ‘플랫폼 독립성’이라는 개념을 떠올려야 한다. IR은 특정 하드웨어나 운영체제에 종속되지 않는 중립적인 코드 표현을 지향한다. 이는 IR이 다양한 최적화와 분석을 거쳐 여러 플랫폼에 맞게 최종 코드를 생성할 수 있도록 하기 위한 필수 조건이다. 즉, IR은 거인이 여러 환경에서 유연하게 사고하고 판단하는 ‘뇌’의 역할을 한다고 보면 된다.

고수준 IR의 대표적인 예로는 Swift 컴파일러의 SIL(Static Intermediate Language)와 Kotlin의 FIR(Frontend IR)이 있다. 이들 IR은 공통적으로 ‘고수준 추상화’에 초점을 맞추면서도, 언어의 특징을 잘 담아내는 표현력을 갖춘다. 예컨대, SIL은 Swift의 메모리 모델과 ARC(Automatic Reference Counting)를 표현할 수 있는 특화된 구조를 가지고 있으며, FIR은 Kotlin의 널 안정성(null safety)과 같은 언어 특성을 반영하도록 설계되어 있다.

두 IR 모두 LLVM의 IR과 달리, 단순한 기계어와 가까운 저수준 표현에 앞서, 언어의 의미와 구조를 풍부하게 유지하며 ‘플랫폼 독립적’인 상태에서 다양한 최적화를 지원한다는 점에서 유사하다. 이렇게 고수준 IR은 컴파일러가 프로그램의 의미를 더 깊이 이해하고, 효과적인 변환을 가능하게 한다.

플랫폼 독립성은 IR 설계에서 매우 중요한 키워드다. IR이 플랫폼에 구애받지 않고 유연하게 동작할 수 있어야, 같은 IR 위에서 다양한 백엔드가 하드웨어별 최적화를 수행하고, 새로운 아키텍처가 등장해도 컴파일러의 핵심 로직을 크게 바꾸지 않고도 지원이 가능하다. 거인의 뇌가 다양한 감각과 운동을 조율하듯, IR은 여러 최적화 패스와 변환들을 조율하는 중추적 역할을 담당한다.

요약하면, IR 설계 철학은 ‘언어의 의미를 풍부하게 유지하면서도, 하드웨어 독립적인 추상화된 표현을 제공해 다양한 최적화와 플랫폼 지원을 가능하게 한다’는 데 있다. 고수준 IR인 SIL과 FIR은 각각의 언어 특성을 살리면서도, 플랫폼 독립성을 확보하여 미들엔드가 거인의 뇌로서 효율적이고 강력한 변환을 수행할 수 있도록 한다.

거인의 신경 회로망

거인의 뇌, 즉 컴파일러 미들엔드의 또 다른 중요한 역할은 코드의 내부 구조를 명확하고 일관되게 표현하는 일이다. 이 과정에서 핵심 기법 중 하나가 바로 SSA(Static Single Assignment) 변환이다. SSA는 변수들이 프로그램 내에서 단 한 번만 값을 할당받도록 만드는 특별한 중간 표현 방식이다.

SSA 변환의 원리를 이해하려면, 먼저 변수 재할당이 없는 상태를 상상해보자. 일반적인 프로그래밍 언어에서는 같은 변수 이름에 여러 차례 값을 덮어쓰지만, SSA에서는 각 변수 할당이 고유한 ‘이름’을 갖는다. 이는 마치 뇌의 뉴런이 각기 다른 시점에 발생시키는 ‘스파이크’처럼, 하나의 변수 이름이 한 번의 고유한 신호를 나타내는 것과 같다. 이런 ‘스파이크’는 명확한 시점과 값을 가지기에, 컴파일러가 프로그램의 상태 변화를 더 직관적으로 추적할 수 있게 만든다.

SSA 변환 과정에서 자주 등장하는 개념은 바로 ‘Phi 함수’다. Phi 함수는 여러 제어 흐름이 합쳐지는 지점에서 서로 다른 변수 버전을 하나로 ‘통합’하는 역할을 한다. 예를 들어, 조건문 양쪽에서 각각 다른 값을 갖는 변수가, 조건문 종료 후에는 Phi 함수를 통해 어느 값이 선택되었는지 명확하게 표현된다. 이를 거인의 뇌에 비유하면, Phi 함수는 서로 다른 신경 신호 경로가 하나의 신경 세포에서 만나 통합되어 다음 신호를 내보내는 ‘뉴런의 시냅스’ 역할을 한다고 볼 수 있다.

SSA 변환은 모든 변수 할당이 고유한 이름을 갖도록 하고, 제어 흐름에 따라 값이 어떻게 전파되는지를 명확히 함으로써, 최적화 과정에서 불필요한 변수 사용이나 중복 계산을 줄일 수 있게 돕는다. 특히 ‘변수의 정의-사용 관계’가 명확해져서, 거인의 뇌가 신호의 흐름을 훨씬 더 정밀하게 분석하는 것과 같다.

아래는 간단한 예시 코드와 이를 SSA 형태로 변환한 모습을 보여준다:

x = 1
if (cond) {
  x = 2
}
y = x + 1

// SSA 변환 후
x1 = 1
if (cond) {
  x2 = 2
}
x3 = phi(x1, x2)
y1 = x3 + 1

여기서 phi(x1, x2)는 조건문 흐름에 따라 x1 혹은 x2 중 하나의 값을 선택하는 함수다. 이처럼 SSA는 변수 값의 흐름을 명확히 나타내는 ‘통일된 모델’을 제공하며, 이는 복잡한 코드에서도 일관된 분석과 변환을 가능하게 한다.

SSA의 또 다른 흥미로운 점은 이를 뇌의 ‘스파이크’ 신호와 연결해 생각할 수 있다는 것이다. 거인의 뇌에서 뉴런 하나하나가 전기 신호인 스파이크를 내보내듯, SSA의 각 변수 버전은 고유한 값 신호로 프로그램 흐름을 자극한다. Phi 함수는 이러한 스파이크들이 합쳐져 새로운 신호를 만들고, 이는 다음 단계로 전달된다. 이러한 비유는 SSA가 단순한 코드 표현을 넘어서, 신호 처리와 정보 통합이라는 본질적인 역할을 수행한다는 점을 시사한다.

결국 SSA 변환은 미들엔드가 프로그램의 상태와 흐름을 ‘뇌 신경망’처럼 정교하게 관리할 수 있게 하는 중요한 설계 원리다. 거인의 뇌가 복잡한 정보를 통합하고, 신경 신호를 정확히 전달해 최적의 판단을 내리듯, SSA는 코드 내 변수와 값의 흐름을 명확하게 정의하여 강력한 최적화와 변환을 지원한다. 이를 통해 미들엔드는 거인의 뇌로서 더욱 정교하고 효율적인 동작을 수행할 수 있게 된다.

추억하는 거인

거인의 뇌, 즉 컴파일러 미들엔드가 SSA 변환을 통해 변수의 흐름을 명확히 다룬다면, 그 다음 단계인 ‘데이터·형식 분석’은 이 흐름에 의미를 부여하는 ‘기억 형성’ 과정이라 할 수 있다. 이 과정은 단순히 코드의 구조를 이해하는 데 그치지 않고, 각 변수와 표현식이 어떤 ‘형식’을 갖는지 판별함으로써, 프로그램이 올바르게 동작하도록 돕는 역할을 한다.

이것을 거인의 기억 형성에 비유해보자. 거인이 눈(프론트엔드)으로 본 세상을 뇌(미들엔드)에서 정교하게 분석하고, 운동 신경(백엔드)으로 명령을 내리려면, 무엇보다 ‘기억’을 통해 얻은 정보가 반드시 필요하다. 여기서 기억이란, 환경에 대한 정보를 저장하고 참조하는 능력이며, 컴파일러에서는 이 기억을 ‘환경(Environment) API’가 담당한다.

환경은 일종의 기억 공간으로, 각 변수와 식별자가 현재 어떤 형식(타입)을 갖는지 저장한다. 이 저장소를 통해 컴파일러는 어떤 변수가 어떤 값의 종류를 지니고 있는지, 그리고 그 값들이 올바른 문맥에서 쓰이는지를 지속적으로 확인할 수 있다. 예를 들어, 변수 x가 정수형인지, 실수형인지, 혹은 함수형인지 아는 것은 이후 연산이나 변환 과정에서 필수적이다.

이 과정을 통해 이루어지는 ‘형식 분석’(타입 체크)은 단순한 오류 검출을 넘어서, 컴파일러가 프로그램 내부에 대한 깊은 ‘이해’를 쌓아가는 기억 형성 과정과 같다. 기억 속 환경은 끊임없이 갱신되고, 새로운 정보가 입력되며, 때로는 스코프가 끝나면서 오래된 기억이 잊혀지기도 한다. 이는 마치 뇌가 새로운 경험을 받아들이고, 필요 없는 기억은 삭제하는 신경가소성(neuroplasticity)과 닮았다.

간단한 스케치를 보자면, 컴파일러 내부의 환경 API는 다음과 같이 동작할 수 있다:

interface Environment {
  fun getType(name: String): Type?
  fun setType(name: String, type: Type)
  fun enterScope()
  fun exitScope()
}
  • getType과 setType은 각각 변수 이름에 대한 형식 정보를 조회하고 저장하는 역할을 한다.
  • enterScope와 exitScope는 블록 구조나 함수 호출 등에서 환경의 범위를 관리하며, 기억이 필요한 순간에 새로운 저장 공간을 만들고, 필요 없을 때 제거한다.

이 기억 형성 메커니즘 덕분에 컴파일러는 표현식 하나하나가 어떤 형식을 가졌는지 판단할 수 있고, 타입이 맞지 않는 연산을 걸러내 오류를 사전에 방지한다.

예를 들어, 아래 코드에서:

val x: Int = 10
val y: String = "hello"
val z = x + y  // 오류 발생

컴파일러는 환경에 저장된 타입 정보를 참조해 x가 정수형, y가 문자열임을 기억하고 있다. 이 두 값을 더하는 연산은 불가능하므로, 타입 불일치 오류를 보고한다. 이 과정은 기억 속 환경이 잘못된 명령을 미리 걸러내는 ‘안전 장치’와 같다.

더 나아가, 환경 API는 추론된 타입 정보를 기반으로 더 복잡한 최적화나 변환에도 활용된다. 거인이 경험과 기억을 통해 더 나은 판단을 내리듯, 컴파일러도 데이터와 형식에 대한 분석을 통해 더욱 정교한 코드를 생성한다.

결국, 데이터·형식 분석 단계는 미들엔드가 거인의 뇌에서 ‘기억’을 쌓아가는 과정이다. SSA 변환으로 정교해진 신경 신호의 흐름을 바탕으로, 각 변수와 표현식에 대한 의미를 더하며, 환경이라는 기억 장치를 통해 올바르고 일관된 판단을 가능하게 한다. 이러한 기억 형성은 컴파일러가 단순한 코드 변환기를 넘어, 뇌처럼 복잡한 정보를 저장하고 활용하는 ‘지능적 존재’로 거듭나게 하는 중요한 과정이다.

고뇌하는 거인

거인의 뇌, 즉 미들엔드가 변수 흐름과 형식을 꼼꼼히 기억한 뒤, 이제는 그 기억을 토대로 스스로 ‘생각하고 판단하는’ 단계로 나아간다. 바로 최적화 프레임워크의 역할이다.

최적화 프레임워크는 거인의 뇌 속에 촘촘히 연결된 ‘연합 뉴런망’과 같다. 이 신경망은 단순히 개별 신호를 따로따로 처리하지 않고, 서로 얽혀있는 신호들을 한꺼번에 바라보며 최선의 판단을 내린다.

이때 사용하는 핵심 도구가 바로 ‘e-graph’, 즉 연합 그래프다. e-graph는 여러 코드 표현과 그 변형들을 하나의 그래프 안에 모아둔다. 이렇게 하면 서로 다른 최적화 규칙들이 충돌하거나 겹치는 부분을 자연스럽게 묶어내며, 다양한 변환을 병렬적으로 적용할 수 있다.

예를 들어, 다음과 같은 간단한 산술식을 생각해보자.

a + 0

전통적인 최적화 패스는 ‘0을 더하는 것은 의미가 없다’는 규칙을 적용해 a + 0을 a로 바꾸는 식으로 최적화를 한다. 그런데 여기서 또 다른 규칙이 있다면?

0 + a  → a
a + a  → 2 * a

이런 규칙들이 여러 개가 있을 때, 순서에 따라 결과가 달라질 수 있다. 패스를 여러 번 돌려야 하고, 충돌 가능성도 커진다.

하지만 e-graph는 이 모든 가능한 변환을 한꺼번에 그래프 안에 담아, 동등한 여러 표현을 함께 관리한다. 덕분에

a + 0  = a
0 + a  = a
a + a  = 2 * a

이 세 가지 표현이 모두 연결된 ‘동등 클래스’(equivalence class)로 묶인다.

이 구조 안에서 최적화 규칙을 적용하면, 컴파일러는 여러 규칙을 병렬적·포괄적으로 탐색해 가장 적합한 변환 결과를 찾아낸다. 복잡한 코드에서도 이 원리가 똑같이 작동한다.

더불어, Cranelift 프로젝트에서 만든 ISLE라는 도메인 특화 언어(DSL)는 이런 최적화 규칙을 사람이 이해하기 쉽게 표현하도록 돕는다.

rule add_zero(x) {
  (add x 0) => x
}

위처럼 ISLE 문법으로 규칙을 명확히 정의하면, 이 규칙들은 자동으로 e-graph에 적용되어 다양한 최적화를 가능하게 한다. 마치 거인이 뇌의 뉴런들에게 명확한 신호를 보내는 것과 같다.

결국, 최적화 프레임워크는 거인의 뇌 속 연합 뉴런망이다. e-graph라는 자료구조와 ISLE 같은 전문 언어를 활용해 복잡한 코드 변환을 통합 관리하며, 컴파일러가 단순한 기계적 변환기를 넘어 스스로 판단하고 최적의 코드를 만드는 ‘지능적 존재’가 될 수 있도록 한다.

거인의 메타인지

거인의 뇌, 미들엔드가 최적화라는 복잡한 사고를 마친 뒤, 이제는 그 결과가 ‘정확한지’를 검증하는 새로운 단계를 맞이한다. 이 단계가 바로 ‘검증과 증명’이다. 최적화가 마치 뇌 속에서 다양한 생각을 펼치고 선택하는 과정이었다면, 검증과 증명은 거인이 스스로에게 “내가 한 결정이 옳은가?”를 묻고 확신하는 단계다.

현대 컴파일러에서는 이런 검증 작업에 강력한 도구로 SMT(Satisfiability Modulo Theories) 검증기가 사용된다. SMT 검증기는 복잡한 수학적 논리와 이론을 바탕으로, 프로그램 변환 전후의 의미가 일치하는지, 즉 ‘동등함’을 기계적으로 증명한다. 이 과정은 사람의 눈이나 경험에만 의존하던 전통적인 검증 방식과 달리, 오류 가능성을 현저히 줄이고 자동화할 수 있다는 점에서 혁신적이다.

Cranelift 프로젝트에서 제안된 SMT 검증 계획을 보면, SMT 검증기를 단순한 도구가 아니라, 일종의 ‘오류 방지 신경망’으로 바라볼 수 있다. 거인의 뇌가 최적화 규칙을 수없이 탐색하며 복잡한 신호망을 이루듯, SMT 검증기는 변환의 모든 가능성을 논리적으로 해석해 ‘잘못된 변환’을 잡아낸다. 마치 거인의 뇌 속에 심어 놓은 감시자처럼, 작은 오류라도 놓치지 않고 감지하여 안전한 코드를 보장한다.

이 검증 단계는 다음과 같은 흐름으로 진행된다.

1. 변환 전후의 프로그램 표현 수집

미들엔드가 최적화한 코드와 최적화 이전 코드를 SMT 검증기로 전달한다.

2. 논리식으로의 변환

두 코드의 의미가 동등한지 판단하기 위해, SMT 검증기는 프로그램 표현을 수학적 논리식으로 변환한다. 이 과정에서 변수의 의미, 연산 규칙, 제어 흐름 등이 논리 이론에 맞게 정밀하게 표현된다.

3. 증명 시도 및 결과 도출

SMT 검증기는 해당 논리식이 ‘항상 참’임을 증명하거나, 만약 거짓이라면 구체적인 반례를 제시한다. 이는 곧 ‘변환이 안전하다’ 혹은 ‘변환이 잘못되었다’는 판단과 같다.

이 과정을 코드 스니펫으로 단순화하면 다음과 같다.

let original = parse_program("a + 0");
let optimized = parse_program("a");

let formula = encode_equivalence(original, optimized);
let result = smt_solver.check(formula);

match result {
    SatResult::Unsat => println!("변환이 안전합니다."),
    SatResult::Sat => println!("변환 오류 발견! 반례:", smt_solver.get_model()),
    _ => println!("검증 중 오류 발생"),
}

여기서 중요한 점은 SMT 검증기가 단순히 ‘맞다/틀리다’만 알려주는 것이 아니라, 오류가 있을 때는 왜 틀렸는지, 어떤 조건에서 문제가 생겼는지 구체적으로 알려준다는 것이다. 이는 마치 거인이 스스로를 감시하며, 뇌의 신경망에 오류 신호를 보내 수정하도록 돕는 것과 같다.

또한, SMT 검증기를 ‘오류 방지 신경망’에 비유하는 이유는, 이 검증기가 수많은 가능성과 복잡한 상황을 논리적으로 처리하면서도 빠르고 정확하게 오류를 잡아내기 때문이다. 마치 인간 뇌의 일부 영역이 위험 신호를 감지하고 즉각 반응하는 것처럼, SMT 검증기는 프로그램 변환에서 발생할 수 있는 미묘한 오류까지 감지한다.

결국, 검증과 증명 단계는 거인의 뇌가 스스로 판단한 결과를 다시 한 번 ‘철저히 검증’하여 신뢰할 수 있도록 만드는 과정이다. 이 과정이 없다면, 아무리 똑똑한 최적화도 예상치 못한 오류를 품을 수 있다. SMT 검증기를 통한 ‘오류 방지 신경망’ 덕분에, 컴파일러는 더욱 안전하고 견고한 코드를 생산하며, 인간 개발자와 사용자 모두에게 믿음을 준다.

거인의 뇌가 스스로를 감시하고, 똑똑하게 오류를 잡아내는 모습. 그것이 바로 컴파일러 미들엔드의 검증과 증명이 품은 의미다.

클래식 패스 카탈로그

거인의 뇌, 미들엔드는 이미 복잡한 사고를 통해 최적화를 수행했고, 자신이 낸 결과가 옳은지 검증하는 단계를 지나 이제는 ‘패턴의 압축’이라는 마지막 미들엔드의 비밀 작업에 들어간다. 이 작업을 나는 ‘클래식 패스 카탈로그’라 부르고 싶다. 거인의 뇌가 주변에서 들어오는 수많은 자극을 단순한 정보 덩어리로 묶고, 중복된 신호를 하나로 합쳐 뇌의 효율을 극대화하는 과정과 닮았다.

컴파일러가 수행하는 최적화 중 GVN(Global Value Numbering), LICM(Loop Invariant Code Motion), 그리고 CSE(Common Subexpression Elimination)은 겉으로 보면 각각 다른 역할을 한다. 하지만 근본적으로는 모두 ‘중복되는 계산’을 찾아내고, 이를 하나의 값 혹은 하나의 작업으로 압축해 반복적인 일을 줄이는 과정이다. 거인의 뇌가 수많은 감각 정보를 효율적으로 처리하기 위해 패턴을 인식하고 기억을 압축하는 과정과 다르지 않다.

GVN은 프로그램 내에서 계산되는 값들이 실제로는 같은 결과를 내는지 찾아내고, 그 값에 같은 ‘번호’를 부여한다. 이렇게 하면 같은 계산을 여러 번 하지 않고, 번호가 같은 값으로 재사용할 수 있다.

예를 들어, 아래 코드에서 두 번 나온 a + b 연산을 하나의 값으로 묶는다고 생각해 보자.

let x = a + b;
let y = a + b;

GVN은 이 두 연산이 같은 결과임을 인식하고, 두 번째 계산을 없애고 x를 재사용하게 한다.

LICM은 반복문 안에서 매 반복마다 계산될 필요가 없는 값을 찾아내, 반복문 밖으로 꺼내는 최적화다. 거인의 뇌가 자주 반복되는 행동 중 불필요한 동작을 줄이려고 루틴을 단순화하는 모습과 유사하다.

예를 들어, 루프 안에서 항상 같은 값 c를 더하는 계산이 있다면, LICM은 그 계산을 루프 밖으로 빼내어 반복할 때마다 다시 계산하지 않도록 만든다.

CSE는 프로그램 곳곳에 중복된 표현식을 찾아 하나의 계산 결과로 대체하는 작업이다. 이는 거인의 뇌가 같은 시각 신호나 청각 신호가 반복될 때 이를 압축해 기억하는 원리와 같다.

이 세 가지는 각각 다른 시점과 대상에 대해 최적화를 하지만, 공통된 목표는 ‘불필요한 중복 계산을 줄이고, 계산 결과를 재사용해 효율성을 극대화’하는 것이다. 이것은 마치 거인의 뇌가 복잡한 신경망 신호 중 반복되고 중복되는 패턴을 찾아내어 이를 하나로 묶고 압축하는 과정과 같다.

‘패스’는 미들엔드에서 수행되는 최적화 작업의 단위를 의미한다. 이들 최적화 패스들은 ‘클래식’이라 불릴 만큼 오랫동안 연구되고 널리 사용되어 왔다. GVN, LICM, CSE는 컴파일러 최적화의 고전적인 삼총사이며, 그만큼 기본적이면서도 효과적인 ‘패턴 압축기’ 역할을 해왔다.

이들은 마치 거인의 뇌가 복잡한 신경망 정보를 압축해 ‘패턴 카탈로그’를 만들어 두는 것과 같다. 새로운 입력이 들어오면 이 카탈로그를 참고해 비슷하거나 같은 패턴을 빠르게 인식하고, 불필요한 중복 작업을 줄인다.

fn example(a: i32, b: i32) -> i32 {
    let x = a + b;  // 값 번호 1 할당
    let y = a + b;  // 값 번호 1 재사용, 중복 계산 제거
    x + y
}

미들엔드의 ‘클래식 패스 카탈로그’는 거인의 뇌가 수많은 신경 신호 속에서 중복된 패턴을 찾아 기억과 연산을 압축하는 과정과 닮았다. GVN은 같은 계산 결과를 하나의 번호로 묶어 재사용하며, LICM은 반복문 안의 변하지 않는 계산을 밖으로 빼내고, CSE는 중복된 표현식을 찾아 하나로 합친다.

이 최적화들은 뇌가 복잡한 정보를 효율적으로 처리하는 패턴 인식과 압축의 메커니즘을 닮아, 컴파일러가 효율적인 코드를 생산하도록 돕는다. 거인의 뇌가 반복되는 신호를 압축하여 에너지를 아끼고, 빠르게 반응하는 것처럼, 미들엔드는 중복된 계산을 압축해 최적화의 효율과 속도를 높인다.

이것이 바로 미들엔드가 마지막으로 보여주는 ‘클래식 패스 카탈로그’라는, 거인의 뇌 속 깊은 곳에 자리한 신비로운 최적화의 비밀이다.

거인의 사회 생활

마지막 챕터는 백엔드(Backend)다.

백엔드 자체는 ‘운동 신경’에 비유할 수 있다. 백엔드는 최적화된 IR을 실제 기계어 코드로 변환하는 부분이다. 간단하게 말하면, 우리도 생각을 마치고 나서야 행동에 옮기지 않는가? 나 역시 지금 백엔드를 거쳐 손가락을 움직이고 있다. 우리 몸에서 최종 출력이 손가락이라면, 컴파일러의 최종 출력은 기계어, 어셈블리, 바이트코드 같은 실행 가능한 코드다. x86, ARM 등 다양한 아키텍처에 맞는 기계어를 생성하고, 플랫폼 특화된 코드를 작성하는 과정이다.

이를 ‘사회화’에 비유할 수 있다. 우리도 상황과 맥락에 따라 행동을 달리하는 것처럼, 컴파일러도 각 플랫폼에 맞춰 적절한 응답을 내놓아야 한다. 백엔드에서는 레지스터 할당, 명령어 선택 등 실제 하드웨어와 맞물려 작동하는 작업들이 포함된다. 한마디로, 백엔드는 ‘코드 생성기’이다. “오케이, 판단 끝! 이제 실행 가능한 바이너리를 만들자!” 이것이 바로 백엔드의 역할인 것이다.

거인의 운동 신경

백엔드는 거인의 운동 신경과 같다. 결국 머릿속에서 내린 판단을 실제 행동으로 옮기는 단계다. 컴파일러가 최종적으로 내놓는 실행 가능한 코드를 만들어내는 과정, 즉 ‘코드 생성’이 바로 이곳에서 이뤄진다.

이 과정은 여러 층위로 나눌 수 있다. 흔히 보는 LLVM 컴파일러 인프라에서는 Swift 컴파일러의 SIL(High-Level Swift Intermediate Language) 같은 고수준 중간 표현이 LLVM IR이라는 저수준 중간 표현으로 바뀌는 일이 여기서 일어난다. SIL은 소스코드의 의미와 흐름을 꽤 자세히 담고 있지만, LLVM IR은 하드웨어 명령어와 좀 더 가까워서 결국 기계어로 번역하기 위한 준비 단계다.

예를 들어, SIL에서의 함수 호출은 이렇게 표현된다.

call swiftcc void @doSomething()

이를 LLVM IR로 변환하면 좀 더 단순해지고, 기계어에 가까운 형태가 된다.

call void @doSomething()

SIL 단계에서는 함수 호출에 관련된 다양한 컨벤션과 메모리 관리 정보가 풍부하게 담겨 있지만, LLVM IR 단계에서는 이 정보들이 구체적인 명령어와 레지스터 할당으로 바뀌는 과정이 진행된다.

한편, 일부 언어들은 SIL이나 LLVM IR과 같은 저수준 IR 대신, 가상 머신에서 실행할 수 있는 바이트코드를 생성하기도 한다. 바이트코드는 CPU 명령어는 아니지만 가상 머신이 이해하고 해석할 수 있는 저수준 명령어 집합이다. 이 바이트코드는 가상 머신에서 인터프리터가 해석하여 실행하거나, JIT 컴파일러가 실시간으로 기계어로 변환하기도 한다.

아래는 파이썬 함수의 바이트코드를 보여주는 간단한 예이다.

import dis

def add(x, y):
    return x + y

dis.dis(add)

출력 결과는 다음과 같이 함수 호출과 연산이 바이트코드 명령어로 표현된다.

  2           0 LOAD_FAST                0 (x)
              2 LOAD_FAST                1 (y)
              4 BINARY_ADD
              6 RETURN_VALUE

LOAD_FAST는 변수를 불러오는 명령어이고, BINARY_ADD는 두 값을 더하는 명령어다. 이 명령어들은 인터프리터에 의해 하나씩 실행된다.

이처럼, SIL→LLVM IR 변환과 바이트코드→인터프리터 실행은 목적과 성격이 조금 다르다. SIL→LLVM IR은 고수준 의미를 저수준 명령어로 옮겨 기계어를 생성하는 과정이고, 바이트코드→인터프리터 방식은 플랫폼 독립적인 코드를 가상 머신에서 해석하거나 JIT로 기계어로 바꾸는 방식이다.

이를 비교하면 다음과 같다.

  • SIL→LLVM IR은 매우 높은 최적화가 가능하고, 최종적으로 네이티브 기계어를 생성하기 때문에 성능이 매우 뛰어나다.
  • 바이트코드와 인터프리터 방식은 개발 속도가 빠르고 유연성이 좋으며 다양한 플랫폼에서 실행할 수 있지만, 기본적으로 실행 속도는 느리다.

즉, 거인의 운동 신경은 ‘생각’을 ‘행동’으로 바꾸는 과정이며, 이 과정에서 SIL이나 LLVM IR 같은 저수준 표현이 다뤄지고, 바이트코드와 인터프리터는 이와는 조금 다른 맥락에서 ‘행동’을 유연하게 만드는 역할을 한다. 결국 이 모든 과정은 거인이 상황에 맞게 몸을 움직이고 적절히 반응하도록 돕는 필수 단계인 셈이다.

다음에는 이 운동 신경이 구체적으로 어떻게 기계어를 만들고 레지스터를 할당하는지, 더 깊게 다뤄보기로 하자.

거인의 혈관

거인이 움직이기 위해선 근육이 살아 있어야 한다. 근육이 살아 있으려면 산소가 필요하고, 그 산소는 혈관을 통해 전달된다. 이 혈관이 바로 레지스터(register)다. 기계어 수준에서 모든 연산은 CPU의 레지스터를 중심으로 이루어진다. 연산 대상, 연산 결과 모두가 레지스터에 있어야만 CPU는 움직일 수 있다.

하지만 문제는 간단하지 않다. 레지스터는 극도로 한정된 자원이다. x86 아키텍처에는 일반적으로 16개 남짓, ARM에서는 그보다 적은 경우도 많다. 그런데 우리가 작성하는 프로그램은 수천 수만 개의 값들을 사용한다. 모든 값을 동시에 레지스터에 담는 건 불가능하다.

그래서 컴파일러는 어떤 값이 어떤 시점에 레지스터에 있어야 하는지를 계산한다. 이것이 바로 레지스터 할당이다. 이건 단순한 자원 배분이 아니다. 피가 부족한 곳에 산소를 보내듯, CPU가 연산을 수행해야 하는 정확한 순간에 값을 넣고 빼야 한다. 잘못하면 레지스터가 부족해서 연산 속도가 느려지고, 값이 스택에 잠시 저장되었다가 다시 불려오는 비용이 발생한다.

Cranelift 프로젝트는 이 레지스터 할당기의 구조를 바꾸면서 평균 10~20%의 성능 향상을 끌어냈다. 기존에는 linear scan 방식으로 값을 차례차례 넣는 단순한 전략이 쓰였다면, 이제는 보다 정교한 backtracking allocator가 쓰인다. 값이 실제로 필요한 구간을 분석하고, 가능한 한 효율적으로 레지스터를 재활용하는 방식이다.

예를 들어 다음과 같은 연산이 있다고 하자.

int a = x + y;
int b = a * 2;
int c = b - z;

이 코드에서 a는 b가 계산되고 나면 더 이상 쓰이지 않는다. b 역시 c가 계산되고 나면 버려진다. 따라서 a, b, c는 전부 서로 다른 시간에만 필요하다면 하나의 레지스터를 공유할 수도 있다. 이런 식의 분석이 바로 레지스터 할당기의 핵심이다.

변수생존 구간이후 사용 여부
a1행❌ (2행 이후 x)
b2행❌ (3행 이후 x)
c3행✅ (결과값)

생존 구간이 겹치지 않으면, 같은 레지스터를 돌려쓰는 것이 가능하다. 이걸 못하면 불필요하게 스택에 값을 spill하고 다시 load하는 일이 반복된다. spill은 말 그대로 값이 레지스터에 머무를 수 없어 흘러 넘쳐서 메모리에 저장되는 상황이다. 아주 흔하지만, 성능에는 치명적이다.

Cranelift의 개선된 레지스터 할당기는 바로 이 spill을 최소화하는 데에 집중했다. 연산 타이밍을 면밀히 계산하고, 굳이 레지스터에 오래 붙들고 있을 필요 없는 값은 빠르게 메모리로 밀어내지 않는다. 반대로, 꼭 레지스터에 있어야만 하는 값은 끝까지 지키며 계산에 쓰인다. 그 결과, 이전보다 훨씬 적은 spill/fill이 발생했고, 실제로 많은 벤치마크에서 눈에 띄는 속도 향상이 보고되었다.

이런 작업은 눈에 잘 보이지 않지만, 거인이 더 빠르게 움직이기 위해 꼭 필요한 과정이다. 산소가 제때 공급되지 않으면 근육은 경직되고, 동작은 느려진다. 하지만 혈관망이 최적화되면, 거인은 이전보다 훨씬 가볍고 빠르게 반응한다.

컴파일러의 레지스터 할당이란, 결국 거인의 몸 전체에 산소가 가장 필요할 때, 가장 효율적인 경로로 공급되도록 만드는 일이다. 그 보이지 않는 정교함 덕분에 우리는 매끄럽고 빠르게 작동하는 프로그램을 만날 수 있다.

거인의 근육

레지스터를 할당해 피가 돌기 시작하면, 이제는 어떤 근육을 어떤 방식으로 수축시켜야 거인이 정확히 움직일 수 있을지 결정해야 한다. 이 과정이 바로 명령어 선택이다. 고수준 IR을 실제 CPU 명령으로 바꾸는 이 단계는, 겉보기엔 단순한 변환 같지만, 실제론 수많은 선택지 사이에서 가장 효율적인 ‘근육 섬유’를 고르는 일에 가깝다.

최근 Cranelift 프로젝트는 이 작업을 보다 선언적이고 체계적으로 다룰 수 있도록 ISLE(Instruction Selection and Lowering Expressions)이라는 DSL을 도입했다. 이전에는 매번 복잡한 조건문을 직접 작성해서 IR 노드를 분해하고 거기에 대응되는 명령어를 골라야 했지만, 이제는 하나의 규칙(rule)만 선언하면 된다. 마치 근육 섬유 하나하나를 카탈로그로 정리하듯, ISLE은 IR 트리 패턴에 대해 어떤 기계 명령으로 내릴지 결정하는 규칙을 짧고 명료하게 기술할 수 있게 해준다.

예를 들어, 다음과 같은 규칙을 보자:

(rule (lower (iadd (imul x y) z))
      (madd x y z))

이 한 줄은 “x와 y를 곱하고 거기에 z를 더하는” 패턴을 발견했을 때, 이를 곧바로 세 개의 값을 동시에 처리하는 madd 명령으로 바꾸라는 뜻이다. 패턴이 맞지 않으면 다음 규칙으로 넘어가게 되어 있어, 개발자는 단순히 이상적인 조합만을 고민하면 된다. 이것은 근육이 자동으로 최적의 수축 방향을 스스로 찾아가는 것과도 같다.

이러한 방식은 정확성과 유지보수성 모두에서 큰 장점을 갖는다. ISLE 자체가 메타 컴파일러를 통해 Rust 코드로 변환되기 때문에, 중복된 규칙이나 도달할 수 없는 규칙은 컴파일 시점에서 잡아낼 수 있다. 또 새 아키텍처나 명령어 확장이 생겨도 기존 DSL 파일에 선언 몇 줄만 추가하면 끝난다. 이는 거인의 몸을 새로 배선하지 않고도, 기존 구조 안에서 새로운 동작을 배워나가는 방식이다.

이처럼 명령어 선택 DSL은 성능 향상에도 기여했다. ISLE 룰셋은 내부적으로 트라이(trie) 구조로 정리되어 탐색 속도가 빠르고, 백엔드에서 최적화된 형태로 코드가 생성된다. 특히 RISC-V 64, AArch64 등 다양한 아키텍처에서 고성능 패턴을 쉽게 주입할 수 있어, 여러 벤치마크에서 실행 시간이 단축되었다는 보고가 있다.

단순히 코드를 줄이고 유지보수를 쉽게 만들었다는 점만이 아니다. 연구자들은 이제 이 패턴들이 실제로 의미적으로 안전한지 SMT 기반 검증 도구를 통해 분석하기 시작했다. 규칙 간 충돌이나 불완전성을 수학적으로 확인할 수 있다는 건, 거인이 자신의 모든 움직임을 재활의학적 관점에서 진단받고 있다는 뜻이기도 하다.

이렇게 ISLE을 도입함으로써 Cranelift의 명령어 선택기는 근육 섬유 하나하나를 체계적으로 구성하게 되었고, 거인은 더는 불필요한 힘을 쓰지 않는다. 과거에는 같은 동작을 위해 여러 단계를 거쳐야 했다면, 이제는 정확한 타이밍에 필요한 섬유만 수축시킨다. 움직임은 유연하고, 반응은 빠르며, 코드의 구조는 단단해졌다. 거인의 몸은 그렇게, DSL을 통해 다시 다듬어지고 있다.

거인의 반사 신경

레지스터를 할당하고 근육 섬유를 정리한 거인은 이제 움직일 준비가 끝났다. 그러나 세상은 생각보다 복잡하고, 모든 움직임이 예측한 대로 흘러가지는 않는다. 이때 필요한 건 바로 반사 신경이다. 잘못된 예측을 즉시 감지하고, 본능적으로 몸을 빼는 그 반사. 컴파일러 세계에서 이 역할은 Just‑In‑Time 컴파일과 Speculative 컴파일, 그리고 OSR(온스택 교체)이 맡는다.

예를 들어 자바스크립트 엔진인 WebKit의 JavaScriptCore는 코드 실행 중에 수집한 통계 정보를 바탕으로 “이 값은 대부분 정수겠지”라고 가정한다. 그 가정이 맞으면 기계어 수준의 빠른 코드를 실행하지만, 만약 정수가 아니었다면? 그 순간, 컴파일된 코드는 즉시 중단되고 OSR exit가 발동한다. 이건 마치 거인이 움직이다가 뭔가 잘못되었다는 신호를 느끼고, 생각할 틈도 없이 반사적으로 몸을 피하는 장면과 닮아 있다.

OSR exit는 단순히 에러를 피하는 도구가 아니다. 컴파일러는 이 메커니즘을 통해 ‘가설 기반의 실행’을 가능하게 만든다. "여긴 분명히 정수만 들어올 거야"라고 믿고 최적화된 기계어를 미리 만들어둔다. 그러다 실제로 정수가 아닌 값이 들어오면, 이미 준비되어 있던 백업 코드 — 예전 바이트코드 위치 — 로 점프하고, 해당 스택 프레임을 그에 맞게 재구성해 곧바로 실행을 이어간다.

코드로 보면 이렇다.

function sum(a, b) {
    speculate(isInt32(a));
    speculate(isInt32(b));
    return a + b;
exit_osr:
    // 바이트코드로 복귀
}

speculate는 가드다. 예상이 빗나가면 exit로 빠져나간다. 놀라운 건, 이 흐름이 개발자 눈에는 잘 드러나지 않는다는 점이다. 런타임은 가드를 통과했는지 여부를 기억해두고, exit가 반복되면 그 가정이 틀렸다고 판단해 더 이상 같은 방식으로 컴파일하지 않는다. 이는 학습이기도 하다. 거인의 몸이 같은 동작에서 계속 통증을 느끼면, 움직임 자체를 바꾸는 셈이다.

이렇게 작동하는 JIT 계층은 계단처럼 구성되어 있다. 가장 아래는 인터프리터(LLInt)이고, 그 위에 Baseline JIT, DFG JIT, FTL JIT이 쌓인다. 코드가 자주 실행될수록, 거인은 더 높은 단계의 반사 신경을 쓰기 시작한다. 인터프리터는 거의 아무 판단 없이 실행만 하고, DFG나 FTL은 공격적으로 예측하고 반사한다. 예측이 맞으면 놀라울 정도로 빠르지만, 틀리면 재빨리 후퇴한다.

이 구조는 실행 속도와 안전성의 균형을 절묘하게 유지하게 해준다. 정적 컴파일러는 어떤 가정도 할 수 없기 때문에 항상 모든 가능성을 커버해야 하지만, JIT 컴파일러는 다르다. 지금까지의 실행 패턴을 보고 “대부분 이렇더라”는 직감을 바탕으로 움직인다. 가정이 맞을 때는 최적화된 근육이 수축되고, 틀릴 땐 몸을 재구성해 다음 움직임에 반영한다.

이런 반사 신경은 단순한 속도 향상을 넘어, 전체 시스템의 구조를 유연하고 탄력적으로 만든다. 마치 거인이 어떤 동작을 할 때마다 매번 근육 전체를 쓸 필요 없이, 꼭 필요한 부위만 미세하게 조절하는 것처럼. 그리고 이 모든 조절은, 실시간으로 — 그러나 보이지 않게 — 일어난다.

결국 컴파일러의 JIT과 speculative 컴파일은 예측, 실패, 회복의 삼단계를 통해 거인을 더 빠르고 더 똑똑하게 만든다. 실패하지 않는 것이 아니라, 실패를 감당할 수 있는 구조. 그 구조 안에서 거인은 생각보다 훨씬 민첩하게 움직인다. OSR exit는 그 반사의 정점이다. 실패를 감지하는 순간, 반사적으로 몸을 빼고, 다시 태세를 고쳐잡는다. 마치 진짜 살아 있는 생물처럼.

거인의 사회 생활

거인은 하나의 몸을 가진 존재지만, 그가 서는 장소에 따라 완전히 다른 모습으로 행동한다. 집에 있을 때와 회사에 있을 때, 그리고 운동장에 있을 때, 거인의 옷차림과 움직임, 심지어 말투까지 달라진다. 마찬가지로 컴파일러 백엔드는 프로그램이라는 거인이 다양한 ‘장소’—즉, 여러 ISA 환경—에 적응하도록 맞춤형 행동 양식을 만든다.

x86‑64라는 장소는 거인의 ‘회사’와 같다. 여기서는 격식을 차리고, 무거운 서류 뭉치를 다루듯 묵직하고 강력한 연산을 수행한다. AArch64라는 장소는 ‘운동장’이다. 거인은 여기서 경쾌하고 빠른 움직임으로 신속하게 작업을 처리한다. IBM s390x는 ‘산업 현장’처럼 고중량 장비가 가득한 곳이고, RISC‑V는 ‘공방’ 같은 실험적이고 유연한 공간이다.

이렇게 장소가 달라지면 거인의 몸에 맞는 옷과 도구, 동작 방식이 바뀌듯이, 컴파일러 백엔드는 각 ISA가 요구하는 명령어 세트와 최적화 방식에 맞춰 기계어를 생성한다. Cranelift는 이런 다양한 ‘장소’에서 거인이 자연스럽고 효율적으로 행동할 수 있도록 돕는 ‘스타일리스트’이자 ‘트레이너’ 역할을 한다.

ISLE이라는 설계 도구는 마치 거인이 새로운 장소에 맞는 복장을 빠르게 입을 수 있게 해 주는 맞춤형 옷장이다. 예를 들어 CLIF 명령인 iadd_imm을 RISC‑V가 선호하는 ‘운동복’ 스타일인 addi 명령으로 바꾸는 것은 이 옷장에서 간단한 맞춤 조정을 거치는 것과 같다.

(iadd_imm x, c) => (addi x, c)

뿐만 아니라, Cranelift의 공통 운동 매뉴얼인 VCode는 거인이 어떤 장소에서든 기본 동작을 익히고, 장소별로 필요한 특수 동작만 덧붙이도록 만든다. 이 덕분에 새로운 장소—새 ISA—가 추가되어도 전체 행동 패턴을 크게 바꾸지 않고 자연스럽게 적응할 수 있다.

SIMD 같은 기능은 거인이 여러 사람과 동시에 협력하는 ‘단체 운동’과 같다. x86‑64, AArch64, s390x는 이미 이 단체 운동을 완벽히 소화하고 있으며, RISC‑V도 조만간 참여할 준비를 마쳤다.

use cranelift::{codegen::isa, settings};

fn select_place(target: &str) -> anyhow::Result<Box<dyn isa::TargetIsa>> {
    let builder = isa::lookup_by_name(target)?;
    let mut flags = settings::builder();
    flags.enable("has_simd")?;
    Ok(builder.finish(flags)?)
}

이 함수는 거인이 ‘회사’에서 ‘운동장’으로, 혹은 ‘공방’으로 장소를 바꾸고, 그에 맞는 복장과 동작으로 즉시 전환하도록 돕는다.

앞으로도 거인의 활동 장소는 계속 늘어날 것이다. LoongArch, PowerPC 같은 새로운 장소가 사회에 합류하고 싶어 한다. ISLE라는 옷장 덕분에, 새로운 장소가 와도 거인은 곧바로 그곳에 맞는 복장과 행동을 취할 수 있다.

결국, 거인의 진짜 힘은 다양한 장소에 맞춰 변신하고 적응하는 능력에 있다. 컴파일러 백엔드는 이 변신을 매끄럽게 만들어 거인이 어떤 환경에서든 최적의 모습을 보여줄 수 있게 한다.

거인의 균형 감각

거인의 운동 신경, 즉 백엔드가 다양한 장소에 적응해 몸을 움직이는 동안, 가장 미묘하고도 중요한 균형점이 있다. 바로 ‘성능과 보안’ 사이의 트레이드오프다. 이 부분은 마치 거인이 빠르게 움직이려다 불시에 찔릴 위험을 감수하는 것과 같다.

현대 컴퓨터 환경에서 ‘사이드-채널 공격’은, 거인이 아무리 튼튼해도 미묘한 움직임이나 숨결 같은 ‘틈’에서 적이 침투하는 것과 같다. 이 공격들은 명령어의 실행 시간, 캐시 접근 패턴, 분기 예측의 결과 등 눈에 보이지 않는 정보들로부터 비밀을 알아내려 한다. 즉, 거인의 눈에 보이지 않는 작은 신경 흐름까지 엿보는 셈이다.

자바스크립트 엔진인 JavaScriptCore 팀이 겪은 이야기에서 출발해 보자. 이들은 예측 실행(speculation execution)을 통해 성능을 극대화하지만, 이로 인해 생기는 사이드-채널 취약점을 완화하는 데 고민이 많았다. 예측 실행은 거인이 빠르게 움직이기 위해 순간적으로 행동을 미리 결정하는 것과 같다. 하지만 이 순간적인 결정이 틀렸을 때, 거인의 흔적이 바깥에 드러나 적에게 노출될 위험이 있다.

이 상황에서 컴파일러 백엔드는 두 가지 선택지를 가진다.

1. 성능을 최대한 살리는 길

  • 거인이 눈 깜짝할 사이에 움직이고, 모든 근육을 최대로 사용한다.
  • 장점: 빠른 실행, 사용자 체감 성능 증가
  • 단점: 작은 흔적이 외부에 노출될 위험이 있다.

2.보안을 최우선으로 하는 길

  • 거인이 신중하게, 모든 움직임을 하나하나 점검하며 조심스럽게 걷는다.
  • 장점: 사이드-채널 공격 가능성 최소화
  • 단점: 움직임이 느리고, 성능 저하가 발생

컴파일러 백엔드가 할 일은 이 두 극단 사이에서 최적의 균형을 찾는 것이다. 이를 ‘성능·보안 트레이드오프’라 부른다. 백엔드는 특정 환경, 하드웨어, 실행 목적에 맞춰 이 균형을 조율한다.

예를 들어, 예측 실행으로 인한 사이드-채널 공격 위험이 큰 환경에서는 컴파일러가 다음과 같이 ‘취약한’ 코드를 제한하거나 변경할 수 있다.

// 예시: 안전한 분기문 사용 예
if condition {
    safe_function();
} else {
    safe_function();
}
// 조건 분기 대신 양쪽 모두 실행해 분기 예측 흔적을 줄임

이 코드는 실제로는 분기가 필요하지만, 보안을 위해 분기 예측 흔적을 없애려는 의도로 작성된 코드이다.

이때 사용되는 핵심 기법 중 하나가 ‘사이드-채널 완화(mitigation)’다. 이는 거인의 신경 신호를 외부로 새어나가지 않도록 ‘절연’하거나 ‘흐름을 조절’하는 것이다. JavaScriptCore에서는 다음과 같은 방법들이 동원된다.

  • 타임 측정 불가능하게 만들기: 명령어 실행 시간을 균일하게 맞춰 정보 누출을 줄임
  • 메모리 접근 패턴 숨기기: 캐시 접근을 의도적으로 분산하거나 일정하게 유지
  • 분기 예측 완화: 조건 분기를 제거하거나, 안전한 분기로 대체

간단한 표로 보면,

전략성능 영향보안 향상 수준설명
예측 실행 제한높음보통예측 실행을 줄여 공격 가능성 감소
균일한 실행 시간 보장중간높음타이밍 공격 완화
메모리 접근 패턴 은폐낮음매우 높음캐시 공격 대비
분기 예측 완화중간~높음높음분기 관련 공격 위험 감소

이 균형점을 맞추는 것은 결국 거인의 ‘의식’과 ‘반사 신경’ 사이의 싸움과 같다. 너무 빠르면 실수하고 노출되지만, 너무 느리면 경쟁에서 뒤처진다. 컴파일러 백엔드는 이 사이에서, 시스템과 사용자 요구에 맞는 적절한 운동 신경을 선택해 거인을 보호한다.

요약하자면, 성능과 보안은 서로를 상쇄하는 두 축이다. 백엔드는 사이드-채널 완화라는 ‘안전장치’를 마련하되, 지나친 완화는 성능 저하로 돌아오기에 신중하게 그 선을 지킨다. 마치 거인이 빠르면서도 부상을 피하기 위해 조심스레 움직이는 것처럼, 컴파일러 백엔드는 그 섬세한 조화를 만들어 내는 운동 신경이다.

진격의 컴파일러

여기까지 컴파일러에 대해서 정말 상세히 알아보는 시간을 가졌다.

어떤가, 좀 이해가 가는가?

필자는 이 글을 쓰면서 정말 힘들었다.

몇 날 며칠 밤을 새우기도 했고,

수많은 자료를 읽고 이해하며 정리하는 데에 많은 시간을 쏟았다.

하지만 거인을 이해한다는 것은 단순히 컴파일러 한 가지를 아는 걸 넘어서,

복잡한 시스템을 통합적인 시선으로 바라볼 수 있다는 뜻이기도 하다.

눈과 뇌, 신경이 조화를 이뤄 움직이는 거인처럼,

컴파일러 역시 여러 단계와 역할이 함께 어우러져 우리의 코드를 기계어로,

그리고 실행 가능한 현실로 변환한다.

그 거대한 흐름 속에 나도, 그리고 여러분도 함께 서 있다.

앞으로 코드를 짤 때,

컴파일러라는 거대한 존재가 어떻게 움직이는지 떠올려 보자.

그것만으로도 코딩의 깊이와 재미가 한층 달라질 것이다.

진격하라, 그리고 그 거인을 마음껏 활용하라.

안드로이드 개발자 화이팅.

profile
안녕하세요. 날씨가 참 덥네요.

0개의 댓글