기술지원 문의는 로그인 후에 가능합니다.

확인

소식

JetBrains 관련 소식

공지사항

잘 알려지지 않은 Kotlin에서 빠른 컴파일의 비밀

2020-11-05

안녕하세요 JetBrains 한국 총판 단군소프트입니다.

많은 코드를 빠르게 컴파일하는 것은 어려운 문제입니다. 특히, 컴파일러가 제네릭을 사용한 오버로드 확인 및 유형 추론과 같은 복잡한 분석을 수행해야 하는 경우에는 더욱 그렇습니다. 이 게시물에서는 실행-테스트-디버그를 반복하는 일상적인 상황에서 많이 발생하는 비교적 사소한 변경을 훨씬 빠르게 컴파일할 수 있도록 해주는 Kotlin의 엄청난, 그러나 대부분 드러나지 않은 부분에 대해 이야기하려고 합니다.


XKCD 만화 #303로 이야기를 시작해 보겠습니다.
이 게시물은 모든 개발자의 삶에서 매우 중요한 측면을 다룹니다.


https://xkcd.com/303/


'코드를 변경한 후 테스트를 실행하는 데(또는 프로그램의 첫 줄에 도달하는 데) 시간이 얼마나 오래 걸리는가의 문제'를 경험하셨나요? 이것을 종종 테스트 시간이라고 합니다.


이 문제가 왜 중요할까요?
- 테스트까지 시간이 너무 짧으면 커피를 마시거나 잠시 숨을 돌릴 시간조차 나지 않을 겁니다.
- 테스트까지 시간이 너무 길면, 소셜 미디어를 탐색하기 시작하거나 다른 것을 하느라 주의력이 떨어져 변경한 내용이 무엇이었는지 잘 기억나지 않을 수 있습니다.

두 상황 모두 장단점이 있지만, 컴파일러가 지시할 때가 아니라 의식적으로 휴식을 취하는 것이 가장 좋다고 생각합니다. 컴파일러는 똑똑한 소프트웨어이지만 인간의 건강한 작업 일정을 생각할 정도는 아니니까요.

개발자는 생산적이라고 느낄 때 더 행복해지는 경향이 있습니다. 컴파일로 작업이 중단되면 업무 흐름이 깨지고 순조롭던 진행이 막혀 생산성을 잃게 됩니다. 이런 상황을 즐길 사람은 없겠죠.T.T??


컴파일에 그토록 시간이 오래 걸리는 이유는 무엇일까요?
컴파일에 장시간이 걸리는 이유에는 크게 세 가지가 있습니다.

1. 코드 베이스 크기: 1 MLOC를 컴파일하는 데 일반적으로 1 KLOC 이상 걸립니다.
2. 도구 체인이 얼마나 최적화되었는지, 여기에는 컴파일러 자체 외에 사용 중인 빌드 도구가 포함됩니다.
3. 컴파일러의 지능 수준: 사용자를 성가시게 하거나 끊임없이 힌트와 상용구 코드를 요구하지 않고 알아서 일을 처리할 수 있는지 여부

처음 두 가지 요소는 명백하니, 세 번째 요소인 컴파일러의 똑똑함에 대해 이야기해 보겠습니다. 일반적으로 복잡한 상호 보완적인 관계가 존재하지만, 저희는 Kotlin에서 깨끗하고 읽기 쉬우며 유형 안정적인 코드 쪽을 선택했습니다. 이것은 컴파일러가 상당히 똑똑해야 한다는 것을 의미하는데, 그 이유는 다음과 같습니다.


Kotlin의 현 위치
Kotlin은 프로젝트가 오래 지속되고 규모가 점차 커지며 많은 사람들이 참여하는 산업 환경에서 사용하도록 설계되었습니다. 그래서 JetBrains 팀은 버그를 조기에 발견하고 정확한 도구(코드 완성, 리팩토링 및 IDE에서 사용 위치 검색, 정확한 코드 탐색 등)를 얻기 위해 정적인 유형 안전성을 구현하려고 노력하고 있습니다.

그리고, 불필요한 소음이나 양식 없이 깨끗하고 읽기 쉬운 코드를 만들어 코드 전체가 유형으로 가득 찬 상황을 피하고자 노력하고 있습니다. 이것이 바로 람다 및 확장 기능 형식을 지원하는 스마트 유형 추론과 오버로드 해결 알고리즘을 마련한 이유입니다. 스마트 형 변환(흐름 기반 형식 지정) 등도 이러한 맥락에서 나온 것입니다. Kotlin 컴파일러는 코드를 깔끔하고 형식적으로 안전하게 유지하기 위해 자체적으로 많은 것을 파악합니다.


똑똑하면서 빠르기까지 할 수 있을까요?
스마트 컴파일러를 빠르게 실행시키려면 도구 체인의 모든 부분을 최적화해야 합니다. 그래서 현재 버전보다 훨씬 빠르게 실행될 차세대 Kotlin 컴파일러를 개발하는 데 주력하고 있습니다.

하지만 컴파일러가 아무리 빨라도 대형 프로젝트에서는 부족함이 있습니다. 디버깅하는 동안 약간의 변경이 있을 때마다 전체 코드 베이스를 다시 컴파일하는 것은 엄청난 낭비입니다. 그래서 이전 컴파일에서 가능한 한 많은 부분을 재사용하고 꼭 필요한 부분만 컴파일할 수 있게 하려고 합니다.


개별 함수 또는 클래스의 변경 사항을 추적하여 파일보다 훨씬 적은 부분만 다시 컴파일하는 더욱 세밀한 접근 방식을 생각할 수도 있지만, 이러한접근 방식을 산업용 언어에서 실제 구현하는 실용적 방법을 찾기 어려울 뿐만 아니라 필요해 보이지도 않습니다.

그럼 이제 컴파일 회피와 증분 컴파일에 대해 자세히 살펴볼까요???


컴파일 회피


​ABI를 비교하는 방법을 알고 있다면 알고리즘이 다소 간단해집니다. 하지만 그렇지 않으면, 변경 사항의 영향을 받은 모듈을 다시 컴파일합니다. 물론, 아무도 사용하지 않는 모듈의 변경 사항은 ABI에 영향을 미치는 경우 모든 사람이 사용하는 ‘util'모듈의 변경 사항보다 빠르게 컴파일됩니다

ABI 변경 추적
ABI는 Application Binary Interface(응용 프로그램 2진 인터페이스)의 약자이며 API의 일종이지만 2진과 관련됩니다. 기본적으로, ABI는 종속 모듈이 신경 쓰는 유일한 2진 부분입니다.

대략적으로 말해서 Kotlin 2진(JVM 클래스 파일 또는 KLib)에는 선언과 본문이 포함됩니다. 다른 모듈은 선언을 참조할 수 있지만 모든 선언을 참조할 수는 없습니다. 그래서 예를 들어, private 클래스와 멤버는 ABI의 일부가 아닙니다.

▶ 그러면 본문은 ABI의 일부일 수 있을까요?
이 본문이 호출 사이트에서 인라인인 경우에는 그렇습니다. Kotlin에는 인라인 함수와 컴파일 시간 상수(상숫값)가 있습니다. 인라인 함수의 본문 또는 const val의 값이 변경되면, 종속 모듈을 다시 컴파일해야 할 수 있습니다.

따라서 대략적으로 말하면 Kotlin 모듈의 ABI는 선언, 인라인 함수 본문 및 다른 모듈에서 인식할 수 있는 const val 값으로 구성됩니다.



컴파일 회피의 장점과 단점
그럼 컴파일 회피의 장점과 단점을 알아볼까요?

▶ 단순한 컴파일 작업
컴파일 회피의 가장 큰 장점은 상대적으로 단순하다는 것입니다. 이 접근법은 모듈이 작을 때 특히 도움이 되는데, 재컴파일 단위가 전체 모듈이기 때문입니다. 모듈이 크면 다시 컴파일하는 데 시간이 오래 걸립니다.

그래서 컴파일 회피를 위해서는 작은 모듈이 많이 있어야 하지만 개발자로서 이것을 원하거나 원하지 않을 수도 있습니다. 작은 모듈을 꼭 설계가 나쁜 것으로 생각할 필요는 없지만 기계가 아닌 사람을 위해 코드를 구성하는 것이 기본자세가 되어야 합니다.

▶ util 모듈과 같은 프로젝트에 이용
컴파일 회피는 다수의 작은 유용한 기능이 담겨진 ‘util’ 모듈과 같은 것들이 많은 프로젝트에서 이용되고 있습니다. 다른 거의 모든 모듈도 최소한 일시적으로라도 ‘util’에 의존합니다. 만일 코드 베이스 전체에서 세 번 사용되는 다른 작은 유용한 함수를 추가한다고 가정해 보면, 이 함수는 모듈 ABI에 추가되므로 모든 종속 모듈이 영향을 받고, 전체 프로젝트가 다시 컴파일되기 때문에 기다림의 시간이 필요합니다.

하지만 작은 모듈(각 모듈은 다른 여러 모듈에 의존함)이 많다는 것은 각 모듈마다 고유한 종속 요소(소스 및 2진) 집합이 포함되므로 프로젝트의 구성이 거대해질 수 있음을 의미합니다. Gradle에서 각 모듈을 구성하는 데 일반적으로 약 50-100ms가 걸립니다. 대규모 프로젝트에 1000개 이상의 모듈이 포함되는 것은 드문 일이 아니므로 총 구성 시간은 1분 이상 걸릴 수 있습니다. 그리고 모든 빌드에서, 그리고 프로젝트를 IDE로 가져올 때마다(예: 새 종속 요소가 추가될 때) 실행되어야 합니다.

Gradle에는 컴파일 회피의 일부 단점을 완화하는 여러 기능이 있습니다. 예를 들어, 구성을 캐싱 할 수 있습니다. 이 부분에서 아직 개선해야 할 여지가 많으며, 이것이 바로 Kotlin에서 증분 컴파일을 사용하는 이유입니다.


증분 컴파일

증분 컴파일은 컴파일 회피보다 더 세분화되어 모듈이 아닌 개별 파일에서 작동합니다. 이 컴파일 방식은 모듈 크기를 고려하지 않으며, “인기 있는” 모듈의 ABI가 중대하게 변경된 경우가 아니면 전체 프로젝트를 다시 컴파일하지 않습니다. 일반적으로, 이 접근 방식은 사용자를 크게 제한하지 않으며 테스트까지 시간을 단축합니다. 또한 개발자가 검싸움을 할 일이 적어지게 됩니다.

증분 컴파일은 IntelliJ의 내장 빌드 시스템인 JPS에서 지속적으로 지원되고 있습니다. Gradle은 기본적으로 컴파일 회피만 지원합니다. 1.4 버전을 기준으로, Kotlin Gradle 플러그인은 Gradle의 증분 컴파일 구현을 다소 제한하며 여전히 개선의 여지가 많습니다.



두 가지 모두 적어도 Kotlin 증분 컴파일러의 기능 중 일부입니다. 하나씩 살펴보겠습니다.


더티 파일 컴파일
컴파일러는 이전 컴파일 결과의 하위 집합을 사용하여 더티가 아닌 파일의 컴파일을 건너뛰고, 파일에 정의된 기호만 로드하여 더티 파일에 대한 2진을 생성하는 방법을 알고 있습니다. 이것은 증분 기능을 위해서가 아니라면 컴파일러가 꼭 수행해야 하는 작업은 아닙니다. 소스 파일별 작은 2진 대신 모듈에서 하나의 큰 2진을 생성하는 것은 JVM 세계 밖에서는 그렇게 일반적이지 않습니다. 그리고 이것은 Kotlin 언어의 기능이 아니라 증분 컴파일러의 세부적인 구현 내용입니다.

더티 파일의 ABI를 이전 결과와 비교할 때 운이 좋아 더 이상 재컴파일을 반복할 필요가 없는 경우가 있을 수 있습니다. 더티 파일의 재컴파일만 필요한(ABI가 변경되지 않기 때문에) 몇 가지 변경의 예를 살펴보겠습니다.


위에서 알 수 있는 바와 같이 이러한 경우는 코드를 디버깅하고 반복적으로 개선할 때 매우 일반적입니다.


더티 파일 세트 확대
운이 좋지 않고 일부 선언이 변경된 경우, 코드가 한 줄도 변경되지 않았더라도 더티 파일에 의존하는 일부 파일이 재컴파일 시 다른 결과를 생성할 수 있습니다. 그러면 어떻게 해야 할까요?

간단한 방법은 이 시점에서 포기하고 전체 모듈을 다시 컴파일하는 것입니다. 그러면 컴파일 회피와 관련된 모든 문제들이 전면에 대두됩니다. 큰 모듈은 선언을 수정하는 즉시 문제가 되고, 위에서 설명한 것처럼 수많은 작은 모듈이 성능 비용을 초래합니다. 그래서 더 세분화하여 영향을 받는 파일을 찾아서 이 파일을 다시 컴파일해야 합니다.

따라서 실제로 변경된 ABI 부분에 의존하는 파일을 찾아야 합니다. 예를 들어, 사용자가 foo의 이름을 bar로 변경했다면 foo와 bar 이름에 관련이 있는 파일만 다시 컴파일하고, 이 ABI의 일부 다른 부분을 참조하더라도 다른 파일은 그대로 두어야 합니다. 증분 컴파일러는 어떤 파일이 이전 컴파일의 어떤 선언에 의존하는지 기억하므로, 이 데이터를 모듈 종속 요소 그래프처럼 사용할 수 있습니다. 이것도 증분이 아니었다면 컴파일러가 일반적으로 수행하는 작업이 아닙니다.

▶모든 종속 요소를 정확하게 저장하는 것은 어려렵나요?
이상적으로는 모든 파일에 대해 어떤 파일이 여기에 종속되어 있는지, 이 파일에서 ABI의 어떤 부분이 중요하게 관련되는지를 저장해야 합니다. 실제로 모든 종속 요소를 그렇게 정확하게 저장하는 것은 비용이 너무 많이 듭니다. 그리고 많은 경우에 전체 시그니처를 저장하는 것이 의미가 없습니다.

다음 예를 생각해보겠습니다.

파일: dirty.kt 


파일: clean.kt


사용자가 함수 changeMe의 이름을 foo로 바꿨다고 가정해 보겠습니다. clean.kt가 변경되지는 않지만 bar()의 본문은 재컴파일할 때 변경됩니다. 이제 clean.kt의 foo(Any)가 아니라 dirty.kt의 foo(Int)를 호출하고 반환 유형도 변경됩니다. 즉, dirty.kt와 clean.kt를 모두 다시 컴파일해야 한다는 것을 의미합니다. 증분 컴파일러는 이것을 어떻게 알아낼 수 있을까요?



이제 clean.kt가 foo 이름에 의존한다는 것을 알 수 있습니다. 즉, clean.kt와 dirty.kt를 모두 다시 컴파일해야 한다는 것을 의미합니다. 왜 그럴까요? 그 이유는 유형을 신뢰할 수 없기 때문입니다.

증분 컴파일은 모든 소스의 전체 재컴파일과 동일한 결과를 생성해야 합니다. dirty.kt에 새로 나타난 foo의 반환 유형을 살펴보겠습니다. 이 파일은 추론되고, 실제로 파일 간의 순환 종속 요소인 clean.kt의 bar 유형에 의존합니다. 따라서 여기에 clean.kt를 추가하면 반환 유형이 변경될 수 있습니다. 이 경우, 컴파일 오류가 발생하지만 clean.kt를 dirty.kt와 함께 다시 컴파일할 때까지 이 사실을 알 수 없습니다.



​이 모든 것을 저장하는 방법에 일부 최적화가 가능합니다. 예를 들어, 일부 이름은 파일 외부에서 검색되지 않습니다(예: 지역 변수의 이름 및 경우에 따라 지역 함수). 색인에서 이러한 이름을 생략할 수 있습니다. 알고리즘을 더 정확하게 만들기 위해 각 이름을 조회할 때 어떤 파일이 참조되었는지 기록합니다. 그리고 색인을 압축하기 위해 해싱을 사용합니다. 여기에 일부 개선의 여지가 남아 있습니다.

아마도 알아챘겠지만 초기 더티 파일 세트를 여러 번 다시 컴파일해야 합니다. 아쉽게도 이 문제를 해결할 방법은 없습니다. 순환 종속 요소가 있을 수 있으며 영향을 받는 모든 파일을 한꺼번에 컴파일하는 것만으로 올바른 결과를 얻을 수 있습니다. 최악의 경우, 이중 작용이 일어나 증분 컴파일이 컴파일 회피였을 때보다 더 많은 작업을 수행할 수 있으므로 이를 보호하는 경험적 지식이 있어야 합니다.

모듈 경계를 넘는 증분 컴파일
지금까지 가장 큰 문제는 모듈 경계를 넘을 수 있는 증분 컴파일입니다.

하나의 모듈에 더티 파일이 있고 몇 번의 순환을 거쳐 고정점에 도달한다고 가정해 보겠습니다. 이제 이 모듈의 새 ABI를 얻게 되고 종속 모듈에 대해 뭔가를 해야 합니다.

물론, 초기 모듈의 ABI에서 어떤 이름이 영향을 받았는지 알고 있으며 종속된 모듈의 어떤 파일이 이러한 이름을 조회했는지 알고 있습니다. 이제, 본질적으로 동일한 증분 알고리즘을 적용할 수 있지만 더티 파일 세트가 아닌 ABI 변경에서 시작합니다. 한편, 모듈 간에 순환 종속 요소가 없다면 종속 파일만 다시 컴파일하는 것으로 충분합니다. 하지만 해당 ABI가 변경된 경우에는 동일한 모듈의 파일을 더 많이 세트에 추가하고 동일한 파일을 다시 컴파일해야 합니다.

Gradle에서 이를 완전히 구현하는 것은 공개적인 도전입니다. Gradle 아키텍처에 약간의 변경이 필요할 수 있지만 과거 경험을 통해 이것이 가능하고 Gradle 팀에서 기꺼이 환영할 일이라는 것을 알고 있습니다.


Kotlin의 다양한 기능
이번 포스팅의 목표는 여러분에게 Kotlin에서 발휘되는 빠른 컴파일을 엿보는 기회를 제공하는 것입니다. Kotlin에는 이 포스팅에서 다루지 않은 훨씬 더 많은 기능이 있습니다.

빌드 캐시
구성 캐시
작업 구성 회피
■ 증분 컴파일 색인 및 기타 캐시를 디스크에 효율적으로 저장
■ Kotlin+Java 결합 프로젝트에서 증분 컴파일
■ Java 종속 요소를 두 번 읽지 않도록 메모리에서 javac 데이터 구조 재사용
KAPTKSP의 증분 기능
■ 더티 파일을 빠르게 찾기 위한 파일 감시기


Kotlin의 컴파일러는 모든 작업을 수행할 필요가 없도록 의도적으로 덜 똑똑하게 만든 다른 언어의 컴파일러와 다른 스마트한 컴파일러 입니다. 강력한 추상화, 가독성 및 간결한 코드를 동시에 제공해 많은 사용자들에게 사랑받는 기능이 되었습니다.

JetBrains 팀은 Kotlin을 컴파일이 전혀 없이 변경 사항을 즉각적으로 채택하는 인터프리터 언어에 가까워지게 하는 것입니다. 앞으로도 발전해나가는 JetBrains에 많은 관심과 기대 부탁드립니다.
긴 글 읽어주셔서 감사합니다.


이 게시물은 Andrey Breslav가 작성한 The Dark Secrets of Fast Compilation for Kotlin을 번역한 글입니다.

목록