목차
지금까지 본 것들이 실제로는 명령 한 줄 뒤에 숨어 있다. 그 한 줄을 열어 본다.
그 한 줄이 실제로 하는 일들
build라고 치면 잠깐 기다린 뒤 실행 파일이 하나 나온다. 그 사이에 일어나는 일은 하나가 아니라 줄줄이 이어지는 네 단계다.
빌드 한 줄은 하나의 일이 아니라 줄줄이 도는 네 단계다. 단계마다 파일을 남기고, 증분 빌드는 그 파일을 다시 안 만드는 것이다.
중요한 건 단계 이름이 아니라 단계 사이에 파일이 남는다는 사실이다. 각 단계는 앞 단계가 남긴 파일을 받아 자기 몫을 하고 다음 파일을 내놓는다. 번역 사무실로 치면 초벌 원고, 교정본, 조판 파일이 차례로 쌓이는 것이다.
이 사슬이 이 글의 나머지 전부를 설명한다. 왜 오래 걸리는지도, 왜 두 번째 빌드는 빠른지도, 왜 가끔 다 지우고 다시 해야 하는지도 여기서 나온다.
전처리: 원고를 한 벌로 합친다
첫 단계는 옮기는 일이 아니라 원고를 정리하는 일이다.
소스에는 “다른 파일의 내용을 여기에 끌어와라”, “이 낱말은 저 값으로 바꿔 읽어라” 같은 지시가 섞여 있다. 전처리기는 이 지시대로 파일들을 끌어와 붙이고 낱말을 갈아 끼워, 다음 단계가 파일 하나만 보면 되도록 한 벌로 만든다.
C 계열에서 헤더 파일 하나를 고쳤을 뿐인데 빌드가 한참 도는 이유가 여기 있다. 그 헤더를 끌어다 쓰는 소스가 백 개면, 백 개의 원고가 전부 새로 합쳐져 다음 단계로 넘어간다.
컴파일: 사람 말에서 기계 쪽 말로
합쳐진 원고를 받아 실제로 옮기는 단계다. 이 시리즈에서 지금까지 본 옮기는 일이 여기서 벌어진다.
문법을 확인하고, 이름이 실제로 있는지 보고, 타입이 맞는지 따지고, 더 나은 표현으로 다듬는 일이 전부 이 단계 안에 있다. 우리가 보는 컴파일 에러의 거의 전부가 여기서 나온다. 빌드에서 가장 오래 걸리는 단계도 대개 여기다.
결과물은 아직 기계어가 아니라 어셈블리다. 앞에서 본 그 이름표 붙은 명령들이다.
어셈블: 이름표를 수로 바꾼다
어셈블리를 실제 기계어로 바꾸는 단계다. mov나 add 같은 이름표를 정해진 수로 갈아 끼우면 되니, 네 단계 중 가장 기계적이고 빠르다.
이 단계의 결과물을 목적 파일이라 부른다. C 계열에서 .o나 .obj로 남는 그것이다. 그 안에는 진짜 기계어가 들어 있지만 아직 혼자서는 못 돈다. 앞 글에서 본 빈칸이 그대로 남아 있기 때문이다.
링크: 조각을 한 권으로 묶는다
목적 파일들과 라이브러리를 모아 빈칸을 채우고 실행 파일 하나를 만드는 마지막 단계다. 자세한 이야기는 링크 글에 있다.
여기서 짚을 것은 사슬에서의 위치다. 링크는 늘 마지막이고, 앞 단계 결과물이 하나라도 바뀌면 다시 돈다. 파일 하나만 고쳐도 컴파일은 그 파일만 다시 하면 되지만 링크는 어차피 전부를 다시 묶는다. 그래서 아주 작은 수정에도 빌드가 완전히 공짜가 되지는 않는다.
언어마다 이름만 다르지 뼈대는 같다
이 네 단계는 C 계열에서 가장 또렷하게 보인다. 그런데 다른 언어들도 이름과 산출물만 다를 뿐 뼈대는 같다.
| 옮기는 단계 | 남는 중간물 | 묶는 단계 | |
|---|---|---|---|
| C 계열 | 컴파일·어셈블 | .o 목적 파일 | 링커가 실행 파일로 |
| 자바 | javac가 바이트코드로 | .class 파일 | 묶어서 .jar 하나로 |
| 타입스크립트 | 자바스크립트로 옮김 | 파일별 .js | 번들러가 한 파일로 |
| Go·Rust | 컴파일 | 중간 산출물 | 링크까지 한 명령 안에서 |
가로로 읽으면 전부 같은 줄이다. 옮기고, 중간물이 남고, 묶는다. 도구가 이 단계들을 한 명령 뒤로 숨겨 놨을 뿐이다.
왜 빌드가 느린가
이 사슬을 보면 빌드 시간이 어디서 오는지도 보인다.
- 파일 수만큼 되풀이된다 - 소스가 천 개면 앞의 세 단계가 천 번 돈다.
- 한 파일이 여러 파일을 끌어온다 - 전처리에서 헤더를 끌어와 붙이면, 실제로 옮겨야 할 원고의 양이 소스에 적힌 줄 수보다 훨씬 많아진다.
- 가장 비싼 단계가 가장 자주 돈다 - 시간을 많이 먹는 컴파일이 파일마다 반복된다.
- 마지막에 한 번 더 전부를 훑는다 - 링크는 조각이 아무리 많아도 한 번에 다 모아야 끝난다.
여기에 요즘은 하나가 더 붙는다. 우리가 직접 쓴 코드보다 끌어다 쓰는 의존성이 훨씬 크다. 처음 빌드가 유난히 오래 걸리는 건 대개 남의 코드를 처음 처리하기 때문이다.
증분 빌드는 무엇을 건너뛰나
두 번째 빌드가 빠른 이유는 마법이 아니다. 앞의 그림에서 본 그대로, 단계마다 파일이 남아 있기 때문이다.
빌드 도구는 매번 이걸 묻는다. “이 결과물을 만들 때 쓴 입력이 그 뒤로 바뀌었나.” 안 바뀌었으면 그 단계를 통째로 건너뛰고 이미 있는 파일을 그대로 쓴다. 이것이 증분 빌드다.
바뀌었는지 판단하는 방법은 도구마다 다르다. 파일이 마지막으로 고쳐진 시각을 비교하기도 하고, 내용을 해시로 요약해 비교하기도 한다. 뒤엣쪽이 더 정확한데, 앞엣쪽은 시각만 보므로 내용이 그대로인데 시각만 바뀌어도 다시 돈다.
가끔 다 지우고 처음부터 빌드해야 풀리는 문제도 여기서 나온다. 판단의 근거가 어긋나면 도구는 낡은 산출물을 최신이라고 믿는다. 시계가 어긋난 장비에서 파일을 복사해 왔거나, 의존 관계에 안 잡힌 파일을 고쳤거나, 앞 단계의 산출물만 남고 그걸 만든 입력이 사라진 경우다. 아무리 봐도 코드가 맞는데 옛날처럼 도는 것 같으면, 코드를 더 보기 전에 다 지우고 한 번 돌려 보는 게 빠르다.
실무에서: 빌드 시간을 되찾는 법
빌드가 느리면 하루에 수십 번 그 시간을 낸다. 되찾는 방법은 대부분 **“안 바뀐 것을 다시 만들지 않는다”**는 한 문장에서 나온다.
- 캐시를 여러 층에 둔다 - 내 장비의 빌드 캐시, 팀이 함께 쓰는 원격 캐시, CI 파이프라인이 실행 사이에 물려 두는 캐시가 각각 다른 층이다. CI는 매번 깨끗한 상태에서 시작하므로 캐시를 명시적으로 남겨 주지 않으면 늘 처음부터 빌드한다.
- 잘 안 바뀌는 것을 먼저 놓는다 - 컨테이너 이미지를 만들 때 의존성 설치를 소스 복사보다 앞에 두는 관행이 대표다. 앞 단계가 깨지면 뒤가 전부 깨지므로, 바뀌는 빈도가 낮은 것부터 순서대로 놓으면 캐시가 훨씬 오래 산다. 원리는 방금 본 증분 빌드와 같다.
- 의존성을 줄이면 두 번 이득이다 - 안 쓰는 의존성을 걷어내면 빌드도 빨라지고 앞 글에서 본 이름 충돌도 줄어든다. 무엇이 얼마나 오래 걸리는지는 대개 빌드 도구가 알려 주니, 짐작하지 말고 재고 나서 손대는 게 낫다.
- 깨끗한 빌드도 가끔 돌린다 - 증분 빌드만 믿으면 낡은 산출물에 기대 도는 상태를 못 알아챈다. CI가 늘 처음부터 빌드하는 것은 느려서가 아니라 그 상태를 정기적으로 확인하기 위해서이기도 하다.
정리
- 빌드 한 줄은 하나의 일이 아니라 전처리 - 컴파일 - 어셈블 - 링크로 이어지는 사슬이다.
- 단계마다 파일이 남는다. 이 사실이 증분 빌드를 가능하게 한다.
- 느린 이유는 단순하다. 파일 수만큼 되풀이되고, 가장 비싼 단계가 가장 자주 돈다.
- 증분 빌드는 “입력이 안 바뀌었으면 건너뛴다”이고, 바뀌었는지 판단하는 근거가 어긋나면 낡은 산출물이 남는다.
- 언어마다 도구 이름은 달라도 뼈대는 같다. 옮기고, 중간물이 남고, 묶는다.
여기까지가 소스가 실행 파일이 되기까지다. 이 시리즈는 파일이 만들어지는 데까지를 맡았고, 그 파일을 실행하면 무엇이 되는지 - 자기만의 메모리를 받고 자원을 열고 CPU를 나눠 쓰는 이야기는 프로세스 쪽에서 이어진다.