Home 18. 빌드 시스템과 빌드 철학
Post
Cancel

18. 빌드 시스템과 빌드 철학

구글 엔지니어들에게 구글에서 일하면서 무엇을 가장 좋아하는지 설문조사한 결과에서 빌드 시스템이 4위에 올랐다. 구글은 엔지니어가 빠르게 안정적으로 빌드할 수 있도록 설립 초기부터 지금까지 자체 빌드 시스템을 구축하는 데 엄청나게 투자했다. 그리고 빌드 시스템의 핵심 구성요소인 Blaze는 2015년에 Bazel이라는 이름의 오픈 소스로 세상에 공개되었다.

18.1 빌드 시스템의 목적

빌드 시스템의 목적은 엔지니어들이 작성한 소스 코드를 기계가 읽을 수 있는 바이너리로 변환하는 것이다. 훌륭한 빌드 시스템은 일반적으로 다음의 두 가지 중요 속성을 최적화한다.

  • 속도 - 개발자가 명령 하나로 빌드를 수행하고 몇 초 안에 결과 바이너리를 얻을 수 있어야 한다.
  • 정확성 - 소스 파일과 기타 입력 데이터가 같다면 모든 개발자가 어떤 컴퓨터에서 빌드하더라도 항상 동일한 결과를 내어줘야 한다.

빌드 시스템은 사람만 이용하는 게 아니다. 테스트 목적으로 혹은 프로덕션에 릴리스하기 위해 머신들이 자동으로 빌드를 수행하기도 한다. 자동 빌드 시스템이 워크플로에 도움을 주는 예로는 다음과 같이 있다.

  • 사람의 개입 없이 코드가 자동으로 빌드되고 테스트된 후 프로덕션에 배포된다.
  • 개발자 변경사항은 자동 테스트되어 코드 리뷰용으로 전달된다. 빌드나 테스트에서 문제가 생기면 변경 작성자와 리뷰어 모두 즉시 알 수 있다.
  • 변경이 트렁크에 병합되기 전 다시 테스트되어 파괴적인 변경이 스며들기 어렵다.
  • 엔지니어들이 한 번에 수만 개의 소스 파일을 건드리는 대규모 변경을 생성하면서도 안전하게 서브밋하고 테스트할 수 있다.

18.2 빌드 시스템이 없다면?

18.2.1 컴파일러로 충부한지 않나?

처음에는 빌드 시스템이 절실하지 않을 수 있다. 명령줄에서 명령어를 사용해서 컴파일러를 직접 실행하거나 IDE의 메뉴를 이용해서 컴파일할 수 있다. 하지만 코드가 살짝만 늘어나도 상황이 복잡해진다. javac도 임포트한 파일이 현재 디렉터리의 하위 디렉터리에 있기만 하면 알아서 잘 찾아낸다. 하지만 다른 프로젝트와 공유하는 라이브러리처럼 파일 시스템의 다른 곳에 저장된 코드까지 찾아주지는 못한다.

그리고 커다란 시스템은 각 조각을 서로 다른 프로그래밍 언어로 작성하는 경우가 드물지 않는데 조각들끼리 의존성이 거미줄처럼 얽혀서 특정 언어용 컴파일러 하나만으로는 시스템 전체를 빌드하기 어렵다.

또 컴파일러는 외부 의존성을 다루는 방법을 전혀 모른다. 빌드 시스템 없이 해결하려면 필요한 라이브러리를 인터넷에서 내려받아 lib 디렉터리에 넣고 컴파일러가 이 디렉터리에서 라이브러리를 읽어가도록 설정하는 방법이 최선일 것이다. 하지만 시간이 오래 지나면 lib 디렉터리에 어떤 라이브러리들을 넣어놨는지 어디서 가져왔는지 여전히 사용중인지 잊어버리게 된다.

18.2.2 셸 스크립트가 충돌한다면?

  • 지루해진다. 코딩에 투자하는 시간만큼 빌드 스크립트에 쏟아야 한다.
  • 느리다. 옛 버전 라이브러리에 의존하게 만드는 실수를 막으려면 빌드 스크립트가 의존성들을 매번 정확한 순서로 빌드하도록 해야 한다.
  • 릴리스할 시간이 되어 최종 빌드를 만들기 위해 jar 명령에 건넬 인수를 모두 파악해 정리해야하고, 결과를 업로드하고, 중앙 리포지터리에도 추가해야 한다. ==> 새로짜야할 스크립트가 한 무더기가 된다.
  • 문제가 생겨 하드 드라이브가 깨졌다면? 소스코드의 버전 전부를 관리하고 있다고 해도 라이브러리들, 환경세팅 등을 똑같이 다시 하기 힘들다.
  • 새로운 개발자가 팀에 합류할 때, 사람마다 시스템이 미묘하게 다르다. 한 사람의 컴퓨터에서는 잘 동작하던 것이 다른 사람의 컴퓨터에서는 그렇지 못한 일이 자주 생긴다.
  • 프로젝트가 커지면서 빌드가 느려진다.

18.3 모던 빌드 시스템

18.3.1 핵심은 의존성이다

본인이 작성한 코드를 관리하는 건 아주 간단하지만 외부 의존성 관리는 훨씬 어렵다. 의존성에는 여러 종류가 있다. 하지만 어떤 경우든 빌드 시스템을 구축하는 데는 이걸 하려면 저게 필요해 패턴이 반복되며, 이러한 의존성을 관리하는 일이 빌드 시스템 구축에서 가장 기본이 되는 작업일 것이다.

18.3.2 태스크 기반 빌드 시스템

셸 스크립트가 기본적인 태스크 기반 빌드 시스템이다.

대부분의 모던 빌드 시스템은 셸 스크립트 대신 파일을 이용한다. 빌드 파일은 수행 방법을 기술한 파일로 대부분 엔지니어가 작성한다. 표현 방식만 다를 뿐 빌드 파일은 빌드 스크리트와 본질적으로 크게 다르지 않다.

  1. 빌드 파일들을 서로 다른 디렉터리에 만든 후 연결할 수 있다.
  2. 기존 태스크에 의존하는 새로운 태스크들을 임의의 복잡한 방식으로 쉽게 추가할 수 있다.

태스크 기반 빌드 시스템의 어두운 면

태스크 기반 빌드 시스템은 빌드 스트립트가 커져서 복잡해질수록 다루기가 어려워진다. 시스템은 스크립트가 무얼 하는지 알 수 없으므로 각각의 빌드 단계를 매우 보수적으로 실행할 수밖에 없고, 결국 성능 문제로 이어진다. 또 시스템은 각 스크립트가 할 일을 올바르게 수행하고 있는지 확인할 방법이 없다.

  1. 빌드 단계들을 병렬로 실행하기 어렵다 - 태스크 기반 시스템에서는 병렬로 실행해도 문제될 게 없어 보이는 태스크들마저 그렇게 하지 못하는 경우가 허다하다. 따라서 충돌의 위험을 안고 가든가, 아니면 전체 빌드를 프로세서 하나에서 스레드 하나로 수행하도록 제한해야 한다.
  2. 증분 빌드를 수행하기 어렵다 - 좋은 빌드 시스템은 작은 변경으로 전체 코드베이스를 처음부터 다시 빌드하지 않도록 증분 빌드를 수행해준다. 태스크는 무슨 일이든 할 수 있으므로 일반적으로 이미 실행됐는지를 확인할 방법이 없다. 따라서 확실하게 하려면 시스템은 빌드 때마다 모든 태스크를 다시 실행해야 한다.
  3. 스크립트를 유지보수하고 디버깅하기 어렵다 - 태스크 기반 빌드 시스템에 따라오는 빌드 스크립트 자체가 관리하기 어렵다. 태스크 기반 프레임워크에서는 성능, 정확성, 유지보수성 문제를 한꺼번에 해결할 수 있는 방법이 없다.

18.3.3 아티팩트 기반 빌드 시스템

더 나은 빌드 시스템을 설계하려면 한 걸음 떨어져서 바라볼 필요가 있다. 이전 시스템들의 문제는 엔지니어에게 자신의 태스크를 정의할 수 있게 하는 너무 큰 힘을 부여한게 원인이었다. 이를 해결하기 위해서는 엔지니어는 여전히 시스템에게 무엇을 빌드할지 정해줄 수 있지만, 어떻게는 시스템이 알아서 하도록 맡기도록 하는 것이다.

이 길이 바로 Blaze와 Blaze에서 파생된 다른 아티팩트 기반 빌드 시스템들이 선택한 길이다. 엔지니어가 빌드할 대상들을 명시하여 명령줄에 Blaze를 실행하면 Blaze는 나머지 컴파일 단계들을 설정, 실행, 스케줄링 한다.

기능적 관점

아트팩트 기반 빌드 시스템과 함수형 프로그래밍은 비슷한 점이 많다. 함수형 언어에서는 수행할 계산을 설명하지만 그 계산을 정확히 언제 어떻게 수행할지에 관한 상세 내용은 컴파일러에 맡긴다. 함수형 언어로는 문제들을 병렬화하기가 아주 쉽고 정확성을 보장해준다. 빌드 시스템은 실질적으로 소스 파일을 입력으로 받아서 바이너리를 출력해주는 커다란 수학 함수와 같다.

예시) Bazel의 빌드파일 492~495p

빌드 프로세스를 태스크 중심에서 아티팩트 중심으로 재구성하는 일은 미묘하지만 강력하다. 프로그래머 입장에서는 유연성이 줄어드는 대신 빌드의 각 단계에서 무슨 일이 이루어지는지를 빌드 시스템이 알게 된다. 빌드 시스템은 이 지식을 활용하여 빌드 프로세스를 병렬화하고 최대한 많은 것을 재사용하여 효율을 극대화시킨다.

Bazel의 또 다른 멋진 묘수들

도구도 의존성으로 취급하기

[빌드가 로컬 컴퓨터에 설치된 도구에 의존하는 문제]

  1. 같은 도구라도 컴퓨터에 따라 설치된 위치와 버전이 다를 수 있어 문제가 될 수 있다.
    • 도구 역시 각 타깃에서 정의해야 하는 의존성으로 다뤄서 해결한다. 빌드에 필요한 도구를 선언하도록 해서 언제 어느 시스템에서 빌드하든 정확한 도구들이 먼저 갖춰지도록 한다.
  2. 플랫폼마다 다른 도구를 사용해야 하는 언어로 프로젝트를 진행 중이라면 문제가 심각해진다.
    • 툴체인을 이용해 해결한다. 툴체인은 특정 플랫폼에서 각 타깃 유형을 빌드하는 데 이용하는 도구들과 속성들의 모음이다.

빌드 시스템 확장하기

Bazel은 커스텀 규칙을 추가하여 타깃 종류를 확장하는 길을 열어두었다. 규칙을 정의하려면 작성자는 규칙이 요구하는 입력들과 규칙이 생성해야하는 결과물들을 선언해야 한다. 또 규칙이 생성할 액션들도 선언한다. 각 액션은 필요한 입력과 출력을 통해 다른 액션과 연결할 수 있다.

환경 격리하기

액션들끼리도 같은 파일을 써서 서로 충돌할 수도 있을거 같다. 하지만 Bazel은 샌드박싱 기술로 이런 충돌을 원천봉쇄했다. 이 기술을 지원하는 시스템에서는 파일시스템 샌드박스를 통해 모든 액션이 다른 액션들과 격리된다. 액션은 입력으로 선언하지 않은 파일은 읽을 수 없고, 출력으로 선언하지 않은 파일에 쓰면 액션 종료 즉시 버려진다. 심지어 액션들이 네트워크로도 서로 통신하지 못하게 막는다. 그렇기 때문에 액션끼리의 충돌은 불가능하다.

외부 의존성 명확히 드러내기

필요한 의존성을 직접 빌드하지 않고 외부에서 다운로드해야 하는 일이 많다. 현 워크스페이스 바깥의 파일에 의존하는 건 위험한 일이다.

  1. 이 파일들은 언제든 변경될 수 있어 빌드 시스템은 계속해서 최신 파일인지 확인해야한다.
  2. 외부 파일의 변경에 워크스페이스 내의 소스코드가 적절히 대응하지 못했다면 빌드 문제를 재현할 수 없게 된다.
  3. 외부 의존성의 소유자가 서드파티라면 잠재적으로 심각한 보안 위험에 노출된다.

의존성 변경은 의식적으로 진행해야 하지만 중앙에서 한 번만 이루어져야 한다. 개별 엔지니어가 관리하거나 시스템에 의해 자동으로 이뤄지게 두면 안된다.

Bazel을 포함한 일부 빌드 시스템은 외부 의존성 각각의 암호화 해시를 워크스페이스 차원의 매니페스트 파일에 기록하게 하여 이 문제를 해결했다. 이 해시를 통해 전체 파일을 소스 관리하에 두지 않고도 고유하게 식별할 수 있는 것이다. 워크스페이스에 새로운 외부 의존성이 추가될 때마다 해당 의존 파일의 해시가 매니페스트 파일에 수동으로든 자동으로든 추가된다. Bazel은 빌드가 실행되면 캐시해둔 의존 파일들의 실제 해시와 매니페스트에 정의된 예상 해시를 비교하여 둘이 다른 경우에만 파일을 다시 다운로드한다.

18.3.4 분산 빌드

분산 빌드란 단위 작업들을 여러 컴퓨터에 뿌려 빌드한 후 취합해 최종 결과를 만들어주는 기술이다. 빌드 단위를 충분시 작게 쪼갤 수 있다면 아무리 큰 빌드라도 원하는 시간 내에 끝마칠 수 있다.

원격 캐싱

가장 단순한 분산 빌드는 원격 캐시만 이용하는 형태이다. 빌드를 수행하는 모든 시스템은 원격 캐시 서비스를 참조하는 모양새이다. 빌드 시스템은 우선 원격 캐시에 해당 아티팩트가 이미 존재하는지 확인하고, 존재한다면 새로 빌드하는 대신 다운로드하고, 존재하지 않으면 직접 빌드한 후 캐시에 추가한다.

원격 캐시 시스템이 제역할을 하려면 빌드 시스템이 완벽하게 재현할 수 있어야 한다. 즉 모든 타깃에 대해서 필요한 입력 집합을 결정할 수 있고, 같은 입력이 주어지면 어떤 머신에서 빌드하더라도 정확하게 같은 결과가 나와야 한다. Bazel은 이를 보장하여 원격 캐시를 지원한다.

아티팩트를 다운로드하는 시간이 새로 빌드할 때보다 빨라야 원격 캐시가 의미가 있다.

원격 실행

원격 캐시는 진정한 분산 빌드는 아니다. 캐시가 사라지거나 전체에 영향을 주는 저수준 라이브러리를 변경한다면 여전히 모든 빌드를 로컬 컴퓨터에서 수행해야 한다. 그래서 최종 목표는 원격 실행이다. 원격 실행은 빌드를 하는 실제 작업들을 여러 워커에 나눠 수행하는 기술이다. 각 사용자의 컴퓨터에서 구동되는 빌드 도구가 중앙 빌드 마스터에 요청을 보내는 구조이다. 빌드 마스터는 요청 받은 빌드를 구성하는 액션들을 스케줄링한다.

500~502p

18.3.5 시간, 규모, 트레이드 오프

빌드 시스템의 역할은 세월이 흐르고 규모가 커져도 코드를 쉽게 다룰 수 있게 해주는 것이다. 어떤 형태의 빌드 시스템을 이용하느냐에 따른 트레이드오프가 존재한다.

쉘 스크립트응 이용하거나 도구를 직접 호출하는 DIY 방식은 코드를 오래 안고 가지 않아도 되는 가장 작은 프로젝트에나 적합하다. DIY 스크립트를 버리고 태스크 기반 빌드 시스템으로 옮겨가면 복잡한 빌드를 자동화하고 다른 컴퓨터에서 빌드를 재현하기 쉬워 프로젝트의 확장성이 극적으로 좋아진다. 이때 트레이드오프틑 빌드의 구조를 더 깊게 고민해야 하며 빌드 파일을 직접 작성해야 한다는 것이다.

프로젝트가 커지면 태스크 기반 빌드 시스템의 문제가 드러나기 시작한다. 이 문제는 아티팩트 기반 빌드 시스템으로 해결할 수 있다. 아티팩트 기반 빌드 시스템은 거대한 빌드를 여러 컴퓨터에 분산할 수 있게 하여 프로젝트의 규모를 또 다른 차원으로 키워준다. 이때의 트레이트오프는 유연성이다.

18.4 모듈과 의존성 다루기

아티팩트 기반 빌드 시스템을 이용하는 프로젝트는 여러 개의 모듈로 나눠지며 각 모듈은 다른 모듈과의 의존 관계를 BUILD 파일에 기술하게 된다. 모듈과 의존성을 어떻게 구성하느냐가 빌드 시스템의 성능과 감당할 수 있는 작업량에 지대한 영향을 준다.

18.4.1 작은 모듈 사용과 1:1:1 규칙

자바 같은 언어는 언어 차원에서 견고한 패키징 개념을 지원하여 각 디렉터리가 보통 하나의 패키지, 타깃, BUILD 파일을 갖는다. Pants에서는 이를 1:1:1 규칙이라 부른다.

프로젝트 규모가 커지면 작은 빌드 카깃의 효과가 나타나기 시작한다. 분산 빌드가 더 빨라지며 타깃을 다시 빌드하는 빈도는 줄어든다. 테스트까지 고려하면 장점이 더욱 커진다.

18.4.2 모듈 가시성 최소화

가시성이란 자신에게 의존할 수 있는 타깃의 범위를 지정하는 속성이다. 가시성이 public으로 지정된 타깃은 워크스페이스 내의 모든 타깃이 참조할 수 있다. 가시성이 private이면 같은 BUILD 파일에 정의된 타깃과 허용목록에 명시된 타깃만이 참조할 수 있다.

대부분의 프로그래밍 언어에서와 마찬가지로 가시 범위는 가능한 한 좁히는 게 좋다.

18.4.3 의존성 관리

내부 의존성

큰 프로젝트를 작은 모듈들로 나누면 의존성 대부분이 내부 모듈 사이에서 만들어진다. 즉 의존하는 타깃 대부분이 같은 소스 리포지터리에서 정의되고 빌드된다. 또 하나의 타깃과 그 타깃이 만들어내는 모든 내부 의존성은 언제나 같은 커밋/리비전에서 빌드된다. (내부 의존성에는 버전이란 개념이 없다는 것을 의미)

내부 의존성과 관련하여 주의할 점으로 전이 의존성 을 어떻게 취급하느냐이다. 예) A->B->C 에서 A는 C의 정의된 심볼을 모두 볼 수 있다.

Blaze도 이를 허용했었으나 구글이 성장하며 문제가 생기기 시작했다. B를 리팩터링하여 C에 대한 의존성을 제거하는 경우 A가 동작하지 않게 된다. 그래서 구글은 엄격한 전이 의존성 모드를 도입하여 이 문제를 해결했다. 직접 의존하지 않는 심볼을 참조하는 타깃이 검출되면 빌드가 실패하게된다.

외부 의존성

외부 의존성은 빌드 시스템 바깥에서 빌드되고 저장되어 있는 아티팩트를 말한다. 외부 의존성은 소스코드로부터 빌드하는 대신 아티팩트 리포지터리에서 직접 가져와 그대로 이용한다. 내부 의존성과의 차이는 버전이 있고, 버전이 소스코드와 독립적으로 매겨진다는 점이다.

자동 vs 수동 의존성 관리

빌드 시스템에서 외부 의존성을 수동으로 관리할 경우 아티팩트 리포지터리에서 다운로드할 버전을 빌드 파일에 명시해야 한다. 자동으로 관리할 때는 소스 파일에 호환 버전의 범위를 명시하고, 빌드 시스템이 범위 안에서 가장 최신 버전을 다운로드해준다.

자동 의존성 관리는 작은 프로젝트에서는 편리하지만 큰 프로젝트에서는 위험하다. 자동 관리의 문제는 의존성의 버전이 언제 업데이트되는지를 통제할 수 없다는 것이다. 이와 달리 수동으로 관리할 때는 변경을 버전관리 할 수 있다.

원-버전 규칙

같은 라이브러리라도 버전이 다르면 일반적으로 다른 아티팩트로 표현한다. 여러 버전을 허용할 때의 가장 큰 문제는 다이아몬드 의존 관계가 만들어진다는 것이다. 원-버전 규칙은 이러한 충돌을 원천봉쇄하고 타깃에 서드파티 라이브러리를 의존성으로 추가해도 기존의 의존성 모두 같은 버전을 가리키기 때문에 평화롭게 공존할 수 있다.

전이 외부 의존성

여러 아티팩트 리포지터리에서는 아티팩트가 리포지터리 내의 다른 아티팩트의 특정 버전에 의존하는 걸 허용한다. Maven, Gradle 같은 빌드 도구는 기본적으로 전이 의존성을 재귀적으로 다운로드한다. 프로젝트에 추가한 의존성 하나가 수십개의 아티팩트를 다운로드하게 만들 수 있는 것이다. 하지만 단점으로는 둘 이상의 라이브러리가 똑같은 서드파티 라이드러리의 상이한 버전을 사용할 가능성이 있다. 필연적으로 원-버전 규칙을 위배하여 다이아몬드 의존성 문제를 낳을 수 있는 방식이다.

이런 이유로 Bazel은 전이 의존성을 자동으로 다운로드하지 않는다. Bazel은 전역 파일 하나에 리포지터리의 외부 의존성 모두와 그 각각이 이용하는 의존성의 정확한 버전을 전부 기록하게 한다. 그리고 Maven 아티팩트들의 전이 의존성을 추적하여 이 파일을 자동으로 생성해주는 도구를 제공한다. 이 도구로 프로젝트 초기 워크스페이스 파일을 한 번 생성해두고, 이후로는 수작업으로 각 의존성 버전을 조정하면 된다.

외부 의존성을 이용해 빌드 결과 캐시하기

외부 의존성이라 하면 보통 서드파티에서 제공하는 안정된 버전의 라이브러리이다. 조직에 따라 직접 개발한 코드 일부를 아티팩트 형태로 공유하여 마치 서드파티 라이브러리처럼 사용하기도 한다. 아티팩트를 직접 빌드하기보다 다운로드하는 게 빠르다면 빌드 속도를 높여줄 수 있는 방법이다.

빌드가 오래걸리는 아티팩트들은 원격 캐시를 지원하는 빌드 시스템을 이용해 대처하는 게 더 낫다.

외부 의존성의 보안과 안정성

[서드파티 아티팩트의 위험성]

  1. 가용성 위험 - 서드파티 아티팩트 리포지터리에 접속할 수 없게 되면 외부 의존성을 다운로드할 수 없어 빌드 전체가 멈춘다.
  2. 보안 위험 - 공격자가 서드파티 시스템을 점령하면 우리 빌드 결과에 악성 코드를 심을 수 있다.

이 문제를 해결하는 방법으로는 필요한 모든 아티팩트를 통제할 수 있는 서버에 미러링해놓고, 서드파티 아티팩트 리포지터리를 이용하지 못하게 하면 된다. 또는 프로젝트에 필요한 의존성을 복사하여 프로젝트에 포함시키는 방법도 있다.

18.5 마치며

때로는 엔지니어의 힘과 유연성을 제한해야 생산성을 높일 수 있다. 구글에서 선택한 방법은 엔지니어로부터 빌드 수행 방식을 정의할 수 있는 자유를 빼앗는 것이다. 개인의 선택을 제한하고 가장 중요한 결정을 자동화 도구에 위임했다.

그리고 태스크 기반 빌드 시스템과 대조되는 아티팩트 기반 빌드 시스템을 만들어내어 빌드를 구글 규모의 조직으로까지 확장할 수 있었다.

아티팩트 세계에서 의존성을 관리하는 방법은 작은 모듈 방식이 굵직한 모듈 방식보다 잘 확장됨 이다.그리고 의존성의 버전 관리가 얼마나 어려운지 깨닫고 원-버전 규칙을 지켜 모든 의존성의 버전을 수동으로 명시해야한다는 결론이 도달했다.

18.6 핵심 정리

  • 조직이 성장해도 개발자들의 생산성을 유지하려면 제대로된 빌드 시스템이 반드시 필요하다.
  • 빌드 시스템에 적절한 제한을 두면 개발자가 더 편하게 일할 수 있다.
  • 아티팩트 중심으로 구성된 빌드 시스템은 확장성과 안정성이 모두 뛰어나다.
  • 아티팩트와 의존성을 정의할 때 모듈은 작게 나누는 게 유리하다.(병렬빌드와 증분 빌드의 이점을 더 잘 활용한다.)
  • 외부 의존성의 버전도 명확하게 버전관리 해야한다.
This post is licensed under CC BY 4.0 by the author.

17. Code Search

19. Critique 구글의 코드 리뷰 도구