Bazel로 LINE의 iOS 앱 빌드 속도를 2배 빠르게!

들어가며

최근 몇 년 동안 LINE 앱의 iOS 소스 트리는 지속적으로 성장해 수백 개의 모듈로 늘어났습니다. iOS 버전의 소스 코드는 2019년 말 기준으로 140만 줄을 넘어섰으며, 이러한 증가세는 멈출 기미가 보이지 않습니다. 그 결과 LINE iOS 버전의 빌드 시간이 크게 증가했습니다. 또한 프로젝트의 규모가 커지면서 로컬 환경에서는 문제 없이 실행되는 빌드가 CI에서는 실행되지 않거나 혹은 그 반대의 경우가 발생하는 것과 같은 재현 불가능한 문제점도 늘어났습니다. 그래서 저희는 잠시 한 발자국 뒤로 물러나 빌드 성능을 개선하고 문제의 재현 가능성을 높일 수 있는 방법에 대해 고민해 보았습니다. 

 

의존성 관리 개선

저희 팀에서는 2012년 말에 의존성 관리 도구인 CocoaPods를 도입했습니다. CocoaPods는 프로젝트를 클린 빌드할 때마다 모든 pod 라이브러리를 다시 빌드해야 하므로 많은 시간이 소요되는 단점이 있지만(대부분의 라이브러리가 Objective-C로 구현되었다면 큰 문제가 아닐 수 있습니다), Xcode와 잘 연동되고 외부 라이브러리의 소스 코드를 마치 내 소스 코드처럼 보고 디버깅할 수 있는 훌륭한 오픈 소스 소프트웨어입니다. 이후 저희 팀과 프로젝트가 확대되었고, Swift의 시대가 도래했습니다. 저희 팀은 버전 1.0 시절부터 Swift를 사용했는데요. 처음 3년 동안엔 Swift 의존성을 사용하지 않고 버틸 수 있었습니다만, 결국 한계에 도달해서 어쩔 수 없이 Swift 의존성 관리 도구인 Carthage를 도입해 CocoaPods와 함께 사용했습니다. CocoaPods와는 달리, Carthage에는 설정이 거의 없었습니다. Carthage는 프레임워크처럼 의존성을 사전에 빌드만 해 줄 뿐, 프로젝트에 통합시키는 것은 개발자의 몫이었기 때문입니다. 

의존성 빌드는 크게 두 가지 방법으로 진행할 수 있습니다. Carthage를 통해 사전에 빌드된 아티팩트(artifact)를 GitHub에서 다운로드하거나 로컬에서 빌드하는 것입니다. GitHub에서 임의의 바이너리 파일을 다운로드하는 것은 보안상 지양하고 있고, 앱 성능도 고려하여 저희는 모든 의존성을 정적 프레임워크로 빌드하는 방법을 선택했습니다. 이에 따라 개발자들은 모든 의존성을 본인의 로컬 작업 환경에서 빌드하게 되었습니다. Carthage는 보이지 않는 곳에서 Xcode를 실행시켜 모든 의존성을 팻(fat) 바이너리로 빌드하는데요. 이때 지원하는 모든 아키텍처에 대해 이 과정을 반복합니다. 만약 현재까지 iOS 10을 지원하고 있다면, 4개의 아키텍처를 위해 각 의존성을 총 4번 빌드해야 합니다. 또한 Xcode의 아카이브 액션은 자체 설계에 따라 항상 완전한, 클린 빌드를 하도록 되어 있습니다. 이런 이유로 빌드 작업은 꽤 오랜 시간이 걸렸습니다. 몇 개의 의존성을 업데이트하느냐에 따라 달라졌는데 대략 15분에서 20분 정도 소요되었습니다. 만약 모든 사람이 위와 같이 동일한 코드를 각각 반복적으로 빌드해야 한다면 엄청난 자원이 낭비될 텐데요. 다행히 Carthage의 빌드 아티팩트를 빌드 간 또는 기기 간 캐싱할 수 있는 방법이 있었습니다.

 

빌드 캐싱 적용

빌드 캐싱에는 오픈 소스 도구인 Rome을 사용했습니다. 도입 당시에 Rome은 사용자의 로컬 디렉터리에 캐싱하거나 원격으로 AWS S3에 캐싱하는 방법을 지원했습니다. 또한 S3 이외에도, LINE 서버 인프라에서 제공하는 서비스와 같이 S3와 호환되는 오브젝트 스토리지 서비스와도 잘 호환되었습니다. 덕분에 S3와 같은 외부 서비스를 사용해 원격 캐시 작업을 하는 대신 자체 인프라를 사용해 더 나은 결과를 얻을 수 있었습니다. Carthage와 Rome은 자기 역할을 잘 해냈는데요. 캐시의 정합성을 검증할 수 있는 방법이 없다는 점이 걱정이었습니다. 그래서 저희는 QA 테스트 및 릴리스 빌드가 캐시 포이즈닝(cache poisoning)과 같은 공격에 노출되는 것을 막기 위해 모든 것을 처음부터 재빌드하기로 결정했습니다.

 

남아있는 문제

의존성 관리가 전부는 아닙니다. 불필요하게 의존성 빌드를 반복하는 것은 피할 수 있게 되었지만, 여전히 자체 코드는 빌드해야 합니다. 의존성을 제외한 대부분의 코드는 Xcode로 빌드하는데요. Xcode는 종종 변경사항이 많지 않아도 모든 것을 다시 빌드합니다. 또한 많은 개발자들이 최종 수정을 반영하고 코드를 빌드하다가 문제가 발생하면 가장 먼저 클리닝을 시도하는데요. 클리닝하면 결과적으로 거의 바뀌지 않는 빌드 아티팩트가 타깃에서 제거되어 버립니다. 장기적인 관점에서의 궁극적인 목표는 코드의 어느 부분이든 불필요한 재빌드는 하지 않는 것입니다. 이를 위해 우리는 코드 베이스를 모듈로 분리해서 빌드하고 캐싱하는 방법을 적용하기로 했는데요. Carthage로는 다음과 같은 이유 때문에 적용하기 어려웠습니다.

  • 로컬 타깃을 별도의 저장소로 분리하고, 변경이 될 때마다 버전을 관리하며, 원래 저장소에서 참조하는 버전을 업데이트해야 합니다.
  • 핵심 프로젝트와 로컬 타깃이 사전에 빌드된 바이너리 형태로 존재하기 때문에 디버깅할 수 없습니다.

작업을 이런 식으로 진행해야 한다면 개발 생산성이 크게 퇴보할 수 밖에 없습니다. Carthage와 Rome으로 외부 의존성 문제는 어느 정도 해결했지만, 내부 개발 코드의 문제가 아직 남아있는 것입니다. LINE에서는 거의 모든 코드를 내부에서 자체적으로 개발합니다. 코드 일부가 사전 빌드되어 디버깅할 수 없는 상황을 감수하겠다면 가능하겠지만, 이런 캐싱 방식은 확장성이 제한될 수 밖에 없습니다.

 

Bazel 도입

Bazel은 고급 캐싱 기능으로 잘 알려진 오픈 소스 도구 중 하나인데요. 처음에는 iOS 빌드와 관련해서 그다지 강력한 인상을 남기지는 못했습니다. 애초에 대규모 모노리포(monorepo) 빌드를 목적으로 개발되어서, 대부분의 기업들의 요구 사항에 잘 부합하지 않았기 때문입니다. 특히, 일반적으로 Apple 플랫폼에서 중요하게 생각하는 헤더 맵(header map)과 Clang 모듈, 다중 언어(mixed-language) 타깃 등의 기능을 지원하지 않았습니다. 하지만 다행히도 Bazel은 뛰어난 확장성을 지니고 있었습니다. Bazel 자체는 Java와 C 계열 언어로 만들어졌지만, 빌드 규칙(build rules)를 작성하면 어떤 언어라도 빌드 가능하도록 확장시킬 수 있습니다.

저희는 Starlark을 이용해 Bazel의 공식 빌드 룰을 확장, 위에서 언급한 몇몇 제약 사항을 해결할 수 있었습니다. 그 중에서도 가장 큰 도전 과제는 Bazel에서 다중 언어 타깃을 사용할 수 있게 하는 것이었습니다. 처음에는 MyModule이라는 모듈을 이용해 다음과 같은 시도를 해봤습니다.

  • 하위 Objective-C 모듈을 MyModuleObjc라는 이름의 모듈로 컴파일
  • Swift 모듈을 MyModule이라는 모듈로 컴파일한 후, 이 모듈에서 Swift의 자체 @_exported 속성을 이용해 Objective-C 모듈을 내보내기(export) 수행
ExportObjcModule.swift

 @_exported import MyModuleObjc 

이 방법은 Objective-C 코드와 Swift 코드가 서로의 선언을 import할 필요가 없는 경우에는 대부분의 유틸리티 모듈에서 문제 없이 실행되었지만, 그렇지 않은 경우에는 작동하지 않았습니다.

저희는 한동안 Xcode로 다중 언어 프레임워크 타깃을 빌드해 왔습니다. Xcode로 다중 언어 프레임워크 타깃을 빌드하려면 우선 하위 Objective-C 모듈의 모듈 맵을 제공해야 하고, 타깃의 MODULEMAP_FILE 빌드 설정에 할당해야 합니다.

FoundationLineUtils.modulemap

 framework module FoundationLineUtils {
    requires objc
    umbrella "Headers"
    exclude header "FoundationLineUtils-Swift.h"
} 

Xcode는 먼저 제공된 Objective-C 모듈을 가져와서 Swift 모듈을 컴파일하고, 컴파일된 Swift 모듈과 함께 Objective-C 모듈을 확장하고 컴파일합니다.

module.modulemap

 framework module FoundationLineUtils {
    requires objc
    umbrella "Headers"
    exclude header "FoundationLineUtils-Swift.h"
}

module FoundationLineUtils.Swift {
    requires objc
    header "FoundationLineUtils-Swift.h"
} 

모듈의 중복 정의를 피하기 위해 Xcode가 사용하는 트릭이 있습니다. Swift를 컴파일할 때 VFS 오버레이(overlay)를 이용해 하위 Objective-C 모듈과 최종 모듈 인터페이스를 숨길 수 있습니다. 컴파일이 끝나면 이 오버레이는 더 이상 사용되지 않고 하나의 모듈만 남습니다.

unextended-module-overlay.yaml

 {
  'version': 0,
  'case-sensitive': 'false',
  'roots': [{
    'type': 'directory',
    'name': "<REDACTED>/Products/Debug-iphonesimulator/FoundationLineUtils.framework/Modules"
    'contents': [{
      'type': 'file',
      'name': "module.modulemap",
      'external-contents': "<REDACTED>/FoundationLineUtils.build/unextended-module.modulemap",
    }]
  }]
} 

이러한 컴파일 방법을 Bazel에서 그대로 구현하는 건 쉽지 않았습니다. 우선 Bazel은 타깃을 프레임워크로 빌드하지 않고 정적 라이브러리로 빌드하기 때문에 프레임워크 검색 경로로 모듈을 가져오지 않습니다. -fmodule-map-file 플래그를 이용해 각 모듈의 모듈 맵이 어디에 정의되어 있는지 직접 지정해 줘야 합니다. 또한, Xcode에서처럼 VFS 오버레이를 이용해 하위 Objective-C 모듈을 숨기려면 오버레이가 맵의 절대 경로를 알아야 하기 때문에 이 방법도 쓸 수 없고, 머신마다 클론된 소스의 트리와 경로가 다르기 때문에 원격 캐싱에도 적합하지 않습니다. 이러한 점들을 감안하여 Bazel에 맞춘 컴파일 순서는 아래와 같습니다.

  1. 하위 Objective-C 모듈의 모듈 맵을 생성합니다.
  2. 생성한 모듈 맵과 함께 Swift 코드를 빌드하기 위해서 swift_library 타깃을 인스턴스화합니다. 생성된 모듈 맵은 타깃의 swiftc_inputs에 추가하고 의존성 리스트에는 추가하지 않습니다.
  3. 마지막으로 Objective-C 코드를 빌드하기 위해서 objc_library 타깃을 인스턴스화합니다. 이 타깃은 swift_library 타깃에 의존해야 합니다. 이 다중 언어 타깃에 의존하는 모든 타깃은 최종 objc_library 타깃만 참조하면 됩니다.

여기에서 중요한 부분은, Objective-C 모듈 맵을 Swift 컴파일 대상 입력 값으로 선언하고 모듈 의존성을 선언하지 않았다는 점입니다. 이렇게 하면 해당 모듈을 컴파일할 때만 하위 모듈이 사용되고, 의존성 그래프에는 포함되지 않습니다.

참고. 만약 여러 언어의 코드가 혼합된 프로젝트를 진행하고 있는데 Bazel을 프로젝트에 도입하고 싶다면, LINE’s Apple rules for Bazel에 있는 apple_library 및 mixed_static_framework 룰을 참고하시기 바랍니다.

Bazel을 사용하면 SDK 개발자들을 대상으로 iOS 모듈을 정적 프레임워크(static framework)로 빌드해서 제3자 배포에 활용할 수 있습니다. Carthage를 대체해 Bazel 도입을 고민하고 있을 때 이런 특징이 도움이 되었습니다. 자체 정의한 빌드 룰을 바탕으로 Bazel을 이용해 외부 의존성을 정적 프레임워크로 사전 빌드한 뒤 Xcode 프로젝트에 수동으로 통합시켰습니다. Carthage로 작업하는 것과 비슷한 워크 플로우지만, 아래와 같은 두 가지 장점이 있습니다.

  • 원하는 부분만 빌드할 수 있는 유연성: 당시 사용하고 있던 Carthage는 각 의존성에 사전 정의된 빌드 스킴을 모두 빌드하기 때문에 첫 번째 빌드에는 매우 긴 시간이 걸렸습니다.
  • Bazel의 원격 캐시 기능: 원격 캐시 서버의 간단한 설정만으로 빌드 간 및 머신 간에 빌드 캐시를 공유할 수 있었습니다.

저희는 내부 모듈 일부를 Bazel로 빌드하기 시작했습니다. 종종 모듈이 수정될 수 있기 때문에, 수정된 모듈의 재빌드 여부를 결정하는 스크립트를 Xcode 프로젝트에 포함시켰습니다. 이 스크립트는 Xcode가 프레임워크를 찾을 수 있도록 사전에 정의한 디렉터리로 모듈을 복사합니다. 이 방법으로 빌드를 할 수 있게 되었지만, 어떤 파일을 업데이트하거나 하지 말아야 할지 매우 신중하게 정해야만 Xcode가 불필요한 빌드를 하지 않게 만들 수 있습니다. 최악의 경우, 변경된 내용이 다시 빌드되지 않을 수도 있습니다. 여기까지 진행했을 때, 이런 방법은 자체적으로 빌드 시스템을 구현하는 것과 다를 바 없다는 것을 깨닫게 되었습니다. 빌드 그래프에서 상위 타깃의 빌드를 Bazel로 변경하기 시작하면서 싱크를 맞추기가 점점 더 어려워졌습니다. 그래서 이런 방식의 마이그레이션을 멈추고, Bazel로 전체 앱을 빌드하는 방법에 집중하기로 결정했습니다.

 

장점만 취합

마이그레이션 과정에서 어려운 부분을 이미 해결했기 때문에, 전체 프로젝트를 Bazel로 전환하기 위해서는 남아있는 타깃을 위한 BUILD 파일만 작성하면 됩니다. 다음과 같은 규칙에 따라 프로젝트를 미리 정리했기 때문에 며칠 만에 작업을 끝낼 수 있었습니다. 

  • 타깃당 하나의 Xcode 프로젝트: 기존 프로젝트는 XcodeGen으로 생성했기 때문에 타깃당 하나의 project.yml 파일이 존재합니다. 다시 말해, 각 project.yml을 BUILD 파일로 변환하면 됩니다.
  • 딱 세 종류의 타깃: Objective-C 계열 타깃(일부 타깃은 C, C++ 또는 Objective-C++ 포함), Swift 타깃, Objective-C 및 Swift가 혼재하는 다중 언어 타깃.
  • 모듈명을 디렉터리명으로 사용하고 디렉터리당 하나의 타깃: 이 규칙을 적용하여 이후에 혜택을 볼 수 있었는데요. 예를 들어, 다중 언어 타깃에서 #import <Module/Module-Swift.h>를 덤으로 사용할 수 있었습니다.

이제 개발자들이 새로운 타깃을 추가할 때는 project.yml와 함께 신규 타깃을 선언하는 BUILD 파일을 생성해야 합니다. 이 프로세스가 자동화되지는 않았지만, 사전 정의한 XcodeGen 템플릿과 Bazel 룰을 이용하면 project.yml과 BUILD 파일을 아주 간단하게 생성할 수 있습니다. 대부분의 경우 모듈 이름에만 신경쓰면 됩니다.

풀 리퀘스트(pull request) 확인과 같은 CI 빌드에서도 원격 캐시를 활용하기 위해서 CI 빌드도 Bazel로 전환했습니다. 만약 누군가 BUILD 파일만 변경하고 동일한 타깃의 project.yml에 해당 변경 사항을 업데이트하지 않으면 Xcode 빌드는 실패하지만, CI 빌드를 Xcode와 Bazel 모두에서 검증하면 문제를 해결할 수 있습니다. 하지만, 이렇게 하면 PR이 병합되는 데 걸리는 시간이 줄어들지 않고, 각 빌드에 필요한 자원이 두 배로 늘어납니다. 이 문제는 Bazel의 의존성 그래프가 항상 정확하기 때문에 BUILD 파일은 항상 옳다고 믿을 수 있다는 특징에서 착안, 각 BUILD 파일에서 선언한 의존성을 해당 project.yml 파일로 싱크하는 스트립트를 작성하는 것으로 해결했습니다. 이 스크립트는 git pre-commit hook 시점에 실행되도록 설정해서, BUILD 파일이 변경되었다면 project.yml을 자동으로 업데이트합니다.

 

도입 결과

Bazel로 전환한 이후 아래와 같이 빌드 시간이 많이 줄어들었습니다.

또한 빌드 시간이 줄어든 덕분에 QA 기간 중 처리하는 시간(turnaround time) 역시 상당히 개선되었습니다. 이제 저희는 새로운 빌드를 QA 테스터에게 배포하기 위해 한 시간씩 기다릴 필요가 없어졌습니다.

베타 빌드 시간XCODEBAZEL
최소28.404.40
최대35.4226.53
평균30.9614.53

 

마치며

Bazel 덕분에 빌드 성능이 개선되어 매우 기쁩니다. 물론 아직 해결해야 할 과제가 남아 있습니다. 원격 캐싱의 도움에도 불구하고 여전히 빌드 그래프에는 몇몇 크기가 큰 타깃들에서 병목 현상이 발생하고 있습니다. 또한 원격 캐시를 더욱 잘 활용하기 위해서 모듈화 작업에 더욱 매진해야 할 필요도 있습니다. 향후 이런 부분을 개선할 계획입니다.

Related Post