Cappuccino 양조: LLVM's IR 없이 컴파일러 작성
Source: Dev.to
Introduction
컴파일러는 언제나 나에게 마법처럼 느껴졌다. 복잡하면서도 단순해 보인다—그냥 코드를 한 형태에서 다른 형태로 변환하는 프로그램일 뿐이다. 나는 clang이 생성한 어셈블리를 수없이 살펴보며 C가 어떻게 머신 코드로 변환되는지 관찰했다. 당연히 궁금해졌고 어떻게 컴파일러를 직접 만들 수 있는지 알고 싶었다.
So how did I do it?
내가 처음 한 일은 “내가 직접 프로그래밍 언어를 만드는 방법”을 구글에 검색한 것이었다. 그 결과 이 기사를 찾게 되었고, 여기서 컴파일 파이프라인에 대한 대략적인 아이디어를 얻었다. 하지만 저자는 자신의 코드를 C++로 변환하는 트랜스파일러를 만들었다. 나는 그 접근법에 만족하지 못했다.
더 많은 웹사이트, 기사, GitHub 레포지토리를 파헤친 뒤(이때는 ChatGPT가 아직 대중화되지 않았음), 나는 모든 컴파일러에 공통적인 핵심 구성 요소를 정리했다:
- Tokenizer – 소스 파일을 읽어 토큰이라는 가장 작은 의미 단위(lexeme)를 생성한다.
- Abstract Syntax Tree (AST) – 프로그램 구조를 트리 형태로 표현한다.
- Parser – 토큰 스트림을 AST로 변환한다.
보통은 LLVM IR로 트랜스파일한 뒤 LLVM이 바이너리를 생성하도록 하는 것이 일반적인 지름길이다. 나는 그 방법이 가장 재미있는 부분인 직접 어셈블리를 생성하는 과정을 가려버린다고 생각해서 마음에 들지 않았다. LLVM IR은 강력하지만 언어를 특정 플랫폼에 묶어버리고 “외부 의존성 없이” 프로젝트를 진행하려는 내 목표와는 맞지 않았다.
이미 어셈블리에 익숙했기 때문에, 나는 백엔드를 직접 구현하기로 결심했다.
The part where it got messy
토크나이저, 파서, AST가 모두 견고하고 일관된 구조를 가져야 함을 금방 깨달았다. 그렇지 않으면 전체 시스템이 무너진다.
초기 AST 설계는 이 기사를 대강 참고한 것이었다. 작은 예제에서는 동작했지만, 어셈블리를 생성하려고 시도하자 코드 유지보수가 고통스러워졌다. 내가 구현하려던 언어는 C였다. 나는 C를 사랑하고 (예: anishell) 같은 프로젝트를 작성해 본 경험도 있다. 하지만 C에서 동적 배열과 가비지 컬렉션을 다루는 것은 번거로웠다. AST를 여러 번 다시 작성하고 각 노드 타입마다 구조체를 만들다 결국 C++로 전환했다—신선한 공기 같은 선택이었다.
C++를 도입한 뒤, 올바른 파싱 기법을 조사했고 **재귀 하강 파서(recursive‑descent parser)**를 채택했다. 이는 단순 계산기 예제 수준을 넘어선 유연성을 제공했다.
Actually Generating the Assembly
개념적으로 어셈블리 생성은 간단하다: 스택‑머신 모델을 사용해 스택에서 피연산자를 꺼내고, 결과를 계산한 뒤 다시 스택에 푸시한다. 실제 어려움은 사소한 버그에 있다. 변수 순서가 하나만 틀려도 프로그램 전체가 크게 실패할 수 있다.
C 구현이 계속 실패하면서 난관에 부딪혔고, 프로젝트를 잠시 멈췄다. 이후 C++로 전환하고 AI 도우미(Gemini)를 활용해 디버깅하면서 문제를 해결했다. 작은 표준 라이브러리를 추가하고 마침내 프로젝트에 Cappuccino라는 이름을 붙였다.
What did I learn?
- 아직 배워야 할 것이 많으며, 나쁜 설계에 몇 년을 허비하기 전에 해결책을 먼저 조사해야 한다.
- 프로젝트 이름을 Cappuccino라고 정한 것은 Java의 커피‑테마 네이밍과 내가 커피를 사랑한다는 개인적인 이유에서 영감을 받았다.
전체 소스 코드는 GitHub에서 확인할 수 있다: https://github.com/AnirudhMathur12/cappuccino.
읽어 주셔서 감사합니다. 이 글이 흥미로웠다면 저장소에 ⭐️를 눌러 주세요.