News

Kotlin의 증분 컴파일에 대한 새로운 접근 방식

Read this post in other languages:

Kotlin 1.7.0에서는 모듈 간 종속성의 프로젝트 변경 사항에 대한 증분 컴파일을 새롭게 개선했습니다. 이 새로운 접근 방식 덕분에 증분 컴파일에 대한 이전의 한계가 해소되었습니다. 이제 Kotlin이 아닌 종속 모듈 내에서 변경이 있는 경우에 지원이 되며 Gradle 빌드 캐시와 호환됩니다. 컴파일이 되지 않은 경우에 대한 지원도 향상되었습니다. 이러한 모든 개선 사항에 힘입어, 요구되는 전체 모듈 및 파일 재컴파일 횟수가 줄어 전체 컴파일 시간이 단축됩니다.

증분 컴파일에 대한 새로운 방식은 현재 실험적 단계이며 Gradle 빌드 시스템에서 JVM 백엔드만 지원합니다.

새로운 접근 방식 사용해보기

벤치마크

Gradle 빌드 캐시를 사용하거나 Kotlin이 아닌 Gradle 모듈을 자주 변경하는 경우에 새로운 접근 방식에서 체감하는 개선 효과가 가장 클 것입니다. 다음은 kotlin-gradle-plugin 모듈에서 Kotlin 프로젝트에 대해 측정한 몇 가지 벤치마크 결과입니다.

활성화하는 방법

증분 컴파일에 이 새로운 접근 방식을 사용하려면 gradle.properties 파일에서 다음 옵션을 설정하세요.

kotlin.incremental.useClasspathSnapshot=true

증분 컴파일을 안정되고 신뢰할 수 있는 상태로 만드는 일은 매우 중요합니다. 따라서 이 컴파일 방식을 사용하면서 발생하는 문제나 비정상적인 동작이 있을 경우 꼭 저희에게 알려주시기를 부탁드립니다.

증분 컴파일 문제는 발생 후 여러 라운드가 지난 후에야 나타나는 경우가 있으므로 빌드 보고서를 사용하여 변경 및 컴파일 기록을 추적하는 것이 좋을 수 있습니다. 그러면 재현 가능한 버그 보고서를 제공하는 데 도움이 될 수 있습니다.

내부에 숨겨진 기능

컴파일 회피 및 증분 컴파일

Kotlin의 빠른 컴파일에 숨겨진 비밀, Gradle에서 증분 Java 컴파일Gradle의 컴파일 회피에 익숙하다면 이 섹션을 건너뛰셔도 됩니다.

빠른 컴파일의 핵심 요소 중 하나는 Application Binary Interface(ABI)입니다. 두 클래스는 컴파일 클래스 경로로 사용될 때 상호 교환이 가능하다면, ABI가 동일합니다.

다음 샘플 Java 클래스를 살펴보겠습니다:

이러한 클래스에는 동일한 ABI가 있습니다. private1()private2() 메서드는 모두 컴파일하는 동안 다른 클래스에서 보이지 않습니다. 메서드 본문은 다른 클래스의 컴파일에도 영향을 주지 않습니다. 따라서 이러한 버전의 Java 클래스는 컴파일 중에 상호 교환이 가능합니다.

Gradle은 Java ABI의 변경 사항을 추적할 수 있습니다. 이것이 종속성 변경이 ABI에 영향을 미치지 않는 경우, 순수 Java 컴파일 작업의 상태가 최신으로 유지되는 이유입니다. 컴파일 회피라고 하는 이 기능은 Gradle 3.4에서 도입되었고 성능을 극적으로 높이는 결과로 이어졌습니다.

Kotlin ABI에는 더 많은 정보(예: 인라인 함수의 본문)가 포함되어 있으므로 현재 Gradle에 구현된 ABI 비교에 의존할 수 없습니다. 따라서 종속성이 변경될 때마다 Kotlin 컴파일러를 시작해야 합니다.

컴파일 속도를 높이는 또 다른 방법은 영향을 받는 파일만 다시 컴파일하는 것입니다. 이 개념을 증분 컴파일이라고 합니다. 컴파일 클래스 경로에 일부 ABI 변경이 있다고 가정해 보겠습니다. 이 상황을 처리하는 가장 좋은 방법은 무엇일까요? 일반적으로, 클래스 경로의 ABI 변경은 모듈에 있는 파일의 일부분에만 영향을 미칩니다. Kotlin 컴파일러는 컴파일되는 클래스 간의 종속성을 저장합니다. 따라서 후속 컴파일 중에 ABI 변경의 영향을 받는 클래스만 찾아서 재컴파일할 수 있습니다.

재컴파일된 클래스의 ABI도 변경된 경우, ABI에서 새로운 변경의 영향을 받는 클래스를 찾아 컴파일을 반복할 수 있습니다. 이 작업은 조금 더 복잡합니다. 일부 파일 또는 클래스는 항상 함께 컴파일해야 합니다(예: 여러 파일 클래스 또는 sealed 인터페이스 및 해당 상속자). 컴파일 시에 계산되는 상수도 추적해야 하지만 이 주제는 이 글의 범위를 벗어납니다.

모듈 간 종속성의 변경 추적

모듈 간 종속성에서 ABI 변경을 추적하는 방법을 설명하기 위해 다음 샘플을 살펴보겠습니다. 여기에서 모듈 B는 모듈 A에 종속됩니다. 첫 번째 전체 빌드는 리비전 1에서 호출됩니다. 리비전 2를 적용한 후, 모듈 B의 컴파일이 호출됩니다. 이 작업은 리비전 3에서 반복됩니다.

기록 파일

먼저, 현재의 기본적 접근 방식을 살펴보겠습니다. Kotlin 컴파일러는 ABI의 변경 사항을 저장하고 클래스 파일을 생성할 수 있습니다. 빌드 디렉터리에서 보았을 수 있는 ‘build-history.bin’ 파일이 바로 그것입니다. 

리비전 1에서는 다음 작업이 수행됩니다.

  • 모듈 A는 이전 상태가 없으므로 완전히 빌드되었습니다.
  • 모듈 A의 기록 파일에 정보가 저장됩니다.
  • 모듈 B는 이전 상태가 없으므로 완전히 빌드되었습니다.
  • 컴파일된 클래스 간의 모든 종속성은 모듈 B에 저장됩니다.

물론 다른 단계도 있습니다. 예를 들어, 기록 파일은 모듈 B에 대해 저장되지만 명확성을 위해 이러한 단계를 생략합니다.

리비전 2의 작업:

  • 모듈 A는 점진적으로 빌드됩니다.
  • ABI에 모듈 A의 변경 내용이 없다는 정보는 모듈 A의 기록 파일에 저장됩니다. 메서드 본문은 ABI에 영향을 미치지 않습니다.
  • 모듈 B에 대한 종속성의 변경 내용이 수집됩니다. ABI가 변경되지 않았을 가능성도 있습니다.
  • 재컴파일할 파일이 없으므로 모듈 B에 대한 컴파일 작업이 완료되었습니다.

리비전 3의 작업:

  • 모듈 A는 점진적으로 빌드됩니다.
  • A.doA의 변경 내용이 해당 기록 파일에 추가됩니다.
  • 모듈 B에 대한 종속성 변경을 분석하여 변경된 메서드 A.doA를 찾습니다.
  • 클래스 B는 내부적으로 저장된 종속성 맵에 명시된 대로 재컴파일 대상으로 표시됩니다.

이점

  • 매우 효율적입니다. 컴파일 클래스 경로를 저장하거나 클래스 경로를 비교할 필요가 없기 때문이죠.

단점

  • 리비전 2에서 Gradle은 입력의 최신 상태를 처리하지 않았습니다. Kotlin 컴파일러를 시작하는 데 다소 시간이 걸렸습니다.
  • 리비전을 재배치 가능하게 만드는 데 상당한 비용이 들어갑니다. 증분 컴파일이 Gradle 빌드 캐시와 호환되지 않는 이유가 여기에 있습니다.
  • 모듈 A가 기록 파일을 생성하지 않는 경우에는 이 접근 방식을 적용할 수 없습니다(예: 외부 라이브러리의 경우).

빌드 보고서를 사용하는 사용자라면, 다시 빌드할 때 DEP_CHANGE_HISTORY_IS_NOT_FOUNDDEP_CHANGE_HISTORY_NO_KNOWN_BUILDS와 같은 원인을 봤을 수도 있습니다. 그러한 원인이 표시되는 데에는 이러한 단점이 관련이 있습니다.

컴파일 클래스 경로 추적

이제 우리가 취한 대체 접근 방식에서는 Kotlin 컴파일러 호출이 있을 때마다 컴파일 클래스 경로의 ABI를 저장해야 합니다. 이 접근 방식은 또한 모든 컴파일 시 교차 경로를 비교해야 합니다. 이러한 작업은 부하가 상당히 크며 수용 가능한 성능을 얻으려면 최대한 최적화해야 합니다.

가능한 최적화 방법

(a) 컴파일 중에 실제로 사용된 클래스 경로 ABI 부분만 보존합니다.

(b) 클래스 경로에서 컴파일러가 사용할 수 있는 ABI 부분만 추출합니다.

(c) 클래스 파일과 함께 생산자 측에서 ABI를 생성합니다.

(d) 추출된 ABI를 캐시합니다.

이러한 옵션 중 일부는 서로 호환되지 않습니다. 예를 들어, (b)(c) 옵션은 동시에 구현할 수 없습니다. 경우에 따라 최적의 접근 방식은 빌드 시스템에서 제공하는 기능에 따라 다릅니다. 또한 사용 사례에 따라서도 크게 다릅니다.

  • 큰 라이브러리에 종속성을 추가하고 하나의 모듈에서 하나의 클래스만 사용하는 경우, 옵션 (b)(아래 왼쪽 그림)를 사용하는 것이 더 효율적입니다.
  • 많은 모듈에 유사한 종속성을 추가하고 그 중 많은 모듈에서 종속성의 거의 모든 클래스를 사용하는 경우 (d) 옵션을 사용하고 공통된 종속성의 계산된 ABI를 캐시하는 것이 더 효율적입니다.

여러 오픈 소스 프로젝트에 대해 우리가 내린 추정에 따르면 가장 효율적인 접근 방식은 모든 종속성에 대해 단일 ABI 추출을 수행하고 실행 결과를 캐시하는 것입니다.

새로운 접근 방식에서는 ABI 추출을 위해 Gradle 아티팩트 변환을 이용합니다. 그러면 결과를 캐시할 수 있고 완전히 재배치할 수 있습니다. 원격 빌드 캐시를 사용하는 경우, 라이브러리 종속성에서 ABI를 추출하는 과중한 작업이 자신의 시스템에서 수행되지 않을 가능성이 높으며, 단순히 이 아티팩트가 다운로드됩니다.

이제 이전 샘플의 세 가지 리비전이 새로운 접근 방식에서 어떻게 컴파일되는지 살펴보세요.

리비전 1에서는 이전 상태가 없기 때문에 두 모듈 전체가 빌드됩니다. 여기서 변경되는 것은 아무것도 없습니다. 이전 방식과 달리 모듈 B에 대한 컴파일 클래스 경로의 스냅샷도 저장합니다.

리비전 2에서 Kotlin 컴파일러는 모듈 A에 대해 다른 바이트 코드를 생성하지만 아티팩트 변환은 동일한 결과를 생성합니다. Gradle은 모듈 B의 모든 입력을 ‘UP-TO-DATE’로 표시합니다. 추가 작업이 필요하지 않습니다. 빌드 체인이 중단되고 결과를 더 빨리 얻습니다.

리비전 3에서 모듈 A는 다른 출력을 생성하고, 아티팩트 변환은 다른 ABI를 생성하므로 모듈 B의 컴파일이 트리거됩니다. 모듈 B를 컴파일하는 동안 다음 단계가 필요합니다.

  • 이전에 저장된 클래스 경로 스냅샷을 새 스냅샷과 비교하여 변경된 ABI 목록을 생성합니다. 유일한 차이점은 A.doA 메서드입니다.
  • 클래스 B는 내부적으로 저장된 종속성 맵에 명시된 대로 재컴파일 대상으로 표시됩니다.
  • 모듈 B에 대한 컴파일 클래스 경로의 스냅샷이 저장됩니다.
  • 모듈 B의 클래스 B는 그 타입이 변경된 A.doA에 종속되기 때문에 다시 컴파일됩니다.

다음 단계

앞으로 이 접근 방식을 안정화하고 다른 백엔드(예: JS) 및 빌드 시스템에 대한 지원을 구현할 계획입니다.

의견을 남겨주세요

여러분의 프로젝트에 새로운 증분 컴파일을 사용해 보시기 바랍니다. 피드백이 있거나 문제가 발생하는 경우, 이슈 트래커에 보고해 주세요. 감사합니다!

감사의 말

Ivan Gavrilovic, Hung Nguyen, Cédric Champeau 등 큰 도움을 주신 외부 기여자분들께 깊은 감사의 말을 전합니다.

게시물 원문 작성자

Jessie Cho

Andrey Uskov

image description

Discover more