CS · Compile · Backend

빌드 - 그 한 줄이 실제로 하는 일들

빌드 명령 한 줄 뒤에는 여러 단계가 줄지어 있고, 단계마다 파일이 하나씩 남는다. 그 파일들이 남는다는 사실이 증분 빌드를 가능하게 하고, 동시에 가끔 clean을 해야 하는 이유도 만든다.

·돌아가기 전에 벌어지는 일 6편
목차
  1. 그 한 줄이 실제로 하는 일들
  2. 전처리: 원고를 한 벌로 합친다
  3. 컴파일: 사람 말에서 기계 쪽 말로
  4. 어셈블: 이름표를 수로 바꾼다
  5. 링크: 조각을 한 권으로 묶는다
  6. 언어마다 이름만 다르지 뼈대는 같다
  7. 왜 빌드가 느린가
  8. 증분 빌드는 무엇을 건너뛰나
  9. 실무에서: 빌드 시간을 되찾는 법
  10. 정리

지금까지 본 것들이 실제로는 명령 한 줄 뒤에 숨어 있다. 그 한 줄을 열어 본다.

그 한 줄이 실제로 하는 일들

build라고 치면 잠깐 기다린 뒤 실행 파일이 하나 나온다. 그 사이에 일어나는 일은 하나가 아니라 줄줄이 이어지는 네 단계다.

$ build
main.c내가 쓴 것
전처리
main.i합쳐진 원고
컴파일
main.s이름표 명령
어셈블
main.o기계어 조각
링크
app실행 파일
파란 칸은 디스크에 실제로 남는 파일이다 - 그대로면 그 앞 단계를 다시 안 돈다

빌드 한 줄은 하나의 일이 아니라 줄줄이 도는 네 단계다. 단계마다 파일을 남기고, 증분 빌드는 그 파일을 다시 안 만드는 것이다.

중요한 건 단계 이름이 아니라 단계 사이에 파일이 남는다는 사실이다. 각 단계는 앞 단계가 남긴 파일을 받아 자기 몫을 하고 다음 파일을 내놓는다. 번역 사무실로 치면 초벌 원고, 교정본, 조판 파일이 차례로 쌓이는 것이다.

이 사슬이 이 글의 나머지 전부를 설명한다. 왜 오래 걸리는지도, 왜 두 번째 빌드는 빠른지도, 왜 가끔 다 지우고 다시 해야 하는지도 여기서 나온다.

전처리: 원고를 한 벌로 합친다

첫 단계는 옮기는 일이 아니라 원고를 정리하는 일이다.

소스에는 “다른 파일의 내용을 여기에 끌어와라”, “이 낱말은 저 값으로 바꿔 읽어라” 같은 지시가 섞여 있다. 전처리기는 이 지시대로 파일들을 끌어와 붙이고 낱말을 갈아 끼워, 다음 단계가 파일 하나만 보면 되도록 한 벌로 만든다.

C 계열에서 헤더 파일 하나를 고쳤을 뿐인데 빌드가 한참 도는 이유가 여기 있다. 그 헤더를 끌어다 쓰는 소스가 백 개면, 백 개의 원고가 전부 새로 합쳐져 다음 단계로 넘어간다.

컴파일: 사람 말에서 기계 쪽 말로

합쳐진 원고를 받아 실제로 옮기는 단계다. 이 시리즈에서 지금까지 본 옮기는 일이 여기서 벌어진다.

문법을 확인하고, 이름이 실제로 있는지 보고, 타입이 맞는지 따지고, 더 나은 표현으로 다듬는 일이 전부 이 단계 안에 있다. 우리가 보는 컴파일 에러의 거의 전부가 여기서 나온다. 빌드에서 가장 오래 걸리는 단계도 대개 여기다.

결과물은 아직 기계어가 아니라 어셈블리다. 앞에서 본 그 이름표 붙은 명령들이다.

어셈블: 이름표를 수로 바꾼다

어셈블리를 실제 기계어로 바꾸는 단계다. mov나 add 같은 이름표를 정해진 수로 갈아 끼우면 되니, 네 단계 중 가장 기계적이고 빠르다.

이 단계의 결과물을 목적 파일이라 부른다. C 계열에서 .o나 .obj로 남는 그것이다. 그 안에는 진짜 기계어가 들어 있지만 아직 혼자서는 못 돈다. 앞 글에서 본 빈칸이 그대로 남아 있기 때문이다.

링크: 조각을 한 권으로 묶는다

목적 파일들과 라이브러리를 모아 빈칸을 채우고 실행 파일 하나를 만드는 마지막 단계다. 자세한 이야기는 링크 글에 있다.

여기서 짚을 것은 사슬에서의 위치다. 링크는 늘 마지막이고, 앞 단계 결과물이 하나라도 바뀌면 다시 돈다. 파일 하나만 고쳐도 컴파일은 그 파일만 다시 하면 되지만 링크는 어차피 전부를 다시 묶는다. 그래서 아주 작은 수정에도 빌드가 완전히 공짜가 되지는 않는다.

언어마다 이름만 다르지 뼈대는 같다

이 네 단계는 C 계열에서 가장 또렷하게 보인다. 그런데 다른 언어들도 이름과 산출물만 다를 뿐 뼈대는 같다.

옮기는 단계남는 중간물묶는 단계
C 계열컴파일·어셈블.o 목적 파일링커가 실행 파일로
자바javac가 바이트코드로.class 파일묶어서 .jar 하나로
타입스크립트자바스크립트로 옮김파일별 .js번들러가 한 파일로
Go·Rust컴파일중간 산출물링크까지 한 명령 안에서

가로로 읽으면 전부 같은 줄이다. 옮기고, 중간물이 남고, 묶는다. 도구가 이 단계들을 한 명령 뒤로 숨겨 놨을 뿐이다.

왜 빌드가 느린가

이 사슬을 보면 빌드 시간이 어디서 오는지도 보인다.

  • 파일 수만큼 되풀이된다 - 소스가 천 개면 앞의 세 단계가 천 번 돈다.
  • 한 파일이 여러 파일을 끌어온다 - 전처리에서 헤더를 끌어와 붙이면, 실제로 옮겨야 할 원고의 양이 소스에 적힌 줄 수보다 훨씬 많아진다.
  • 가장 비싼 단계가 가장 자주 돈다 - 시간을 많이 먹는 컴파일이 파일마다 반복된다.
  • 마지막에 한 번 더 전부를 훑는다 - 링크는 조각이 아무리 많아도 한 번에 다 모아야 끝난다.

여기에 요즘은 하나가 더 붙는다. 우리가 직접 쓴 코드보다 끌어다 쓰는 의존성이 훨씬 크다. 처음 빌드가 유난히 오래 걸리는 건 대개 남의 코드를 처음 처리하기 때문이다.

증분 빌드는 무엇을 건너뛰나

두 번째 빌드가 빠른 이유는 마법이 아니다. 앞의 그림에서 본 그대로, 단계마다 파일이 남아 있기 때문이다.

빌드 도구는 매번 이걸 묻는다. “이 결과물을 만들 때 쓴 입력이 그 뒤로 바뀌었나.” 안 바뀌었으면 그 단계를 통째로 건너뛰고 이미 있는 파일을 그대로 쓴다. 이것이 증분 빌드다.

바뀌었는지 판단하는 방법은 도구마다 다르다. 파일이 마지막으로 고쳐진 시각을 비교하기도 하고, 내용을 해시로 요약해 비교하기도 한다. 뒤엣쪽이 더 정확한데, 앞엣쪽은 시각만 보므로 내용이 그대로인데 시각만 바뀌어도 다시 돈다.

가끔 다 지우고 처음부터 빌드해야 풀리는 문제도 여기서 나온다. 판단의 근거가 어긋나면 도구는 낡은 산출물을 최신이라고 믿는다. 시계가 어긋난 장비에서 파일을 복사해 왔거나, 의존 관계에 안 잡힌 파일을 고쳤거나, 앞 단계의 산출물만 남고 그걸 만든 입력이 사라진 경우다. 아무리 봐도 코드가 맞는데 옛날처럼 도는 것 같으면, 코드를 더 보기 전에 다 지우고 한 번 돌려 보는 게 빠르다.

실무에서: 빌드 시간을 되찾는 법

빌드가 느리면 하루에 수십 번 그 시간을 낸다. 되찾는 방법은 대부분 **“안 바뀐 것을 다시 만들지 않는다”**는 한 문장에서 나온다.

  • 캐시를 여러 층에 둔다 - 내 장비의 빌드 캐시, 팀이 함께 쓰는 원격 캐시, CI 파이프라인이 실행 사이에 물려 두는 캐시가 각각 다른 층이다. CI는 매번 깨끗한 상태에서 시작하므로 캐시를 명시적으로 남겨 주지 않으면 늘 처음부터 빌드한다.
  • 잘 안 바뀌는 것을 먼저 놓는다 - 컨테이너 이미지를 만들 때 의존성 설치를 소스 복사보다 앞에 두는 관행이 대표다. 앞 단계가 깨지면 뒤가 전부 깨지므로, 바뀌는 빈도가 낮은 것부터 순서대로 놓으면 캐시가 훨씬 오래 산다. 원리는 방금 본 증분 빌드와 같다.
  • 의존성을 줄이면 두 번 이득이다 - 안 쓰는 의존성을 걷어내면 빌드도 빨라지고 앞 글에서 본 이름 충돌도 줄어든다. 무엇이 얼마나 오래 걸리는지는 대개 빌드 도구가 알려 주니, 짐작하지 말고 재고 나서 손대는 게 낫다.
  • 깨끗한 빌드도 가끔 돌린다 - 증분 빌드만 믿으면 낡은 산출물에 기대 도는 상태를 못 알아챈다. CI가 늘 처음부터 빌드하는 것은 느려서가 아니라 그 상태를 정기적으로 확인하기 위해서이기도 하다.

정리

  • 빌드 한 줄은 하나의 일이 아니라 전처리 - 컴파일 - 어셈블 - 링크로 이어지는 사슬이다.
  • 단계마다 파일이 남는다. 이 사실이 증분 빌드를 가능하게 한다.
  • 느린 이유는 단순하다. 파일 수만큼 되풀이되고, 가장 비싼 단계가 가장 자주 돈다.
  • 증분 빌드는 “입력이 안 바뀌었으면 건너뛴다”이고, 바뀌었는지 판단하는 근거가 어긋나면 낡은 산출물이 남는다.
  • 언어마다 도구 이름은 달라도 뼈대는 같다. 옮기고, 중간물이 남고, 묶는다.

여기까지가 소스가 실행 파일이 되기까지다. 이 시리즈는 파일이 만들어지는 데까지를 맡았고, 그 파일을 실행하면 무엇이 되는지 - 자기만의 메모리를 받고 자원을 열고 CPU를 나눠 쓰는 이야기는 프로세스 쪽에서 이어진다.