TECH 으로 돌아가기
TECH HACKER NEWS 오늘 8분 읽기 27 READS

Zig의 증분 컴파일은 어떻게 밀리초 단위 리빌드를 만드나

Zig의 증분 컴파일은 어떻게 밀리초 단위 리빌드를 만드나
SOURCE IMAGE · HACKER NEWS

Zig 코어 팀이 오랫동안 공들여 온 증분 컴파일(incremental compilation) 기능이 마침내 실무에서 쓸 만한 수준에 도달했다. 증분 컴파일은 프로젝트를 마지막으로 빌드한 이후 어떤 함수나 선언이 바뀌었는지 컴파일러가 스스로 감지해, 바뀐 코드만 다시 컴파일하고 그 결과 바이트를 출력 바이너리에 직접 패치해 넣는 방식이다. 코어 팀의 mlugg는 이 기능이 개념 증명 수준을 넘어 이제 팀원 대부분이 매일 사용하는 도구가 되었다고 설명한다. 실제 데모에서 픽셀 에디터 애플리케이션 Fizzy는 최초 빌드에 약 5초가 걸리지만, 이후 코드를 수정할 때마다 리빌드가 50~70ms 만에 끝난다.

다만 지금 당장 태그된 릴리스로 시험해 보기는 어렵다. Zig 0.16.0에도 증분 컴파일 지원은 들어 있지만, 이후에 구현된 중요한 링커 기능들이 빠져 있어 이 데모는 master 브랜치에서 진행됐다. 정식 릴리스만 쓰는 사용자라면 0.17.0이 나올 때까지 기다려야 할 가능성이 크다. 그럼에도 이 기능이 흥미로운 이유는 단순한 빌드 캐싱이 아니라, 언어 설계 자체를 증분 컴파일에 유리하게 다듬어 온 결과물이기 때문이다.

파일 단위 처리: 쉬운 절반

Zig 컴파일러 파이프라인은 먼저 소스 파일 전체를 단위로 동작한다. 각 소스 파일을 ZIR(Zig Intermediate Representation)이라는 타입이 없는 SSA 형태의 중간 표현으로 변환하는데, 이 과정에서 파일 안의 @import를 발견해 임포트된 파일에도 같은 처리를 반복한다. 이렇게 루프를 돌면 컴파일에 포함된 모든 소스 파일이 결국 ZIR로 변환된다. 이 단계는 파일마다 독립적이라 사실상 완벽하게 병렬화가 가능하고, 공유 상태라고는 이미 처리한 파일 경로를 담는 해시 집합 하나뿐이라 뮤텍스로 간단히 보호할 수 있다.

같은 이유로 이 단계의 증분 처리도 어렵지 않다. 각 소스 파일의 ZIR을 디스크에 캐시해 두고, 파일이 바뀐 경우에만 다시 만들면 된다. 이 두 최적화는 Zig에서 수년째 기본으로 켜져 있으며, 진행 표시에 "AST Lowering"이 뜨는 구간이 바로 여기다. 표준 라이브러리와 compiler_rt까지 처리해야 하는 최초 실행을 빼면 대부분 순식간에 지나간다. 문제는 이 쉬운 절반 다음부터다.

의미 분석: 진짜 어려운 부분

타입 검사와 comptime 평가를 포함하는 의미 분석(semantic analysis)이 증분화가 가장 어려운 지점이다. 핵심 전략은 컴파일 작업을 서로 거의 독립적으로 분석할 수 있는 조각으로 쪼개고, 그 조각들 사이에 존재하는 의존 관계를 의존성 그래프로 명확히 모델링하는 것이다. Zig는 이 조각을 "분석 단위(analysis unit)"라 부르며 네 종류로 나눈다. 함수, 전역 상수, 전역 변수 같은 "컨테이너 수준 선언"이 그 대상이 되는데, Zig에서는 문법상 최상위에 있지 않아도 되기 때문에 다른 언어의 "최상위 선언"과 구분해 부른다.

한 단위를 분석하는 동안 컴파일러는 그 단위가 의존하는 다른 단위들을 수집한다. 예컨대 함수 foo의 본문은 global_0과 global_1의 타입, 그리고 comptime로 알려진 global_1의 값에 의존할 수 있다. 이렇게 하면 해당 타입이나 값이 바뀔 때 foo를 다시 분석해야 한다는 사실이 그래프에 기록된다. 여기서 설계상의 특징이 드러난다. 런타임 함수 본문에는 다른 단위가 의존할 수 없어서, 함수 본문은 그래프에서 나가는 방향의 간선만 가진다. 또한 const 선언의 값에 대한 의존은 오직 comptime 사용 때문에 생기며, 이 언어 기능이 없었다면 값에 대한 의존 자체가 존재하지 않았을 것이다. 즉 comptime 같은 강력한 기능이 증분화의 난이도를 높이는 대가이기도 하다.

소스 해시가 재분석의 방아쇠

단위 사이의 의존성만으로는 사용자가 리빌드를 요청했을 때 무엇부터 다시 볼지 알 수 없다. 그래서 컴파일러는 각 분석 단위가 특정 소스 코드 영역에도 의존하도록 추적한다. ZIR에는 컨테이너 수준 선언마다 그 소스 코드 영역의 해시가 담겨 있고, 단위는 실제로 이 해시에 의존한다. 소스가 바뀌면 해시가 바뀌고, 프론트엔드는 이를 손쉽게 감지해 해당 단위를 "outdated"로 표시한 뒤 재분석한다. inline 함수 호출처럼 호출자 단위에서 피호출자의 코드를 의미상 인라이닝하는 경우에는, 호출자가 피호출자의 소스 해시에 의존하는 더 복잡한 관계가 생긴다.

실제 동작은 이렇다. const lucky_number = 42를 43으로 바꾸면 컴파일러는 새 ZIR을 만들고 선언 이름을 기준으로 옛 ZIR과 매핑한 뒤 소스 해시를 비교한다. lucky_number의 해시만 바뀐 것을 확인하면 그 값을 재분석한다. 만약 공백만 추가한 무의미한 변경이었다면 값이 그대로임을 확인하고 여기서 멈추지만, 값이 실제로 바뀌었으니 그 값에 의존하는 두 단위(testLuck과 getSomething의 본문)로 전파해 재분석한다. 함수 본문에는 의존하는 단위가 없으므로 여기서 루프가 종료된다. 필요한 최소한의 코드만 정확히 다시 건드리는 셈이다.

코드 생성과 실무적 의미

재분석된 함수들은 새 AIR을 만들어내고, 이는 코드 생성 단계로 넘어간다. 코드 생성은 AIR을 기계 명령에 가까운 MIR(Machine Intermediate Representation)로 변환하며 아키텍처별로 별도 구현을 둔다. 인라이닝 같은 함수 간 최적화를 하지 않는 빌드에서는 함수마다 공유 상태가 없어 이 역시 완벽히 병렬화된다. 다만 코드 생성이 의미 분석보다 뒤처지면 대기 중인 AIR이 빠르게 쌓일 수 있어, 큐 크기에 상한을 두어야 한다는 점이 유일한 주의사항이다.

한국 IT 실무자 입장에서 이 사례가 주는 시사점은 두 가지다. 하나는 밀리초 단위 리빌드가 단순히 빠른 캐시가 아니라, 의존성 그래프와 소스 해시라는 세밀한 추적 위에서만 성립한다는 점이다. 다른 하나는 comptime처럼 표현력이 높은 기능이 증분화를 어렵게 만들 수 있어, 언어 설계 단계부터 재컴파일 단위를 어떻게 쪼갤지 고민해야 한다는 사실이다. 링커 지원이 아직 정식 릴리스에 다 반영되지 않은 만큼 현시점에서 곧바로 프로덕션에 적용하기는 이르지만, 빌드 시간을 개발 경험의 일급 문제로 다루려는 팀이라면 눈여겨볼 만한 접근이다.

SOURCE · HACKER NEWS
원문 전체 보기 → https://mlugg.co.uk/posts/incremental-compilation-internals/
SHARE
처리 중...