안녕하세요. 스튜디오 킹덤에서 쿠키런: 킹덤의 서버 개발자로 재직하고 있는 전윤재입니다. 이번 글에서는 스칼라로 개발된 쿠키런: 킹덤 서버의 빌드 속도를 개선하기 위해 수행했던 과정과 결과를 소개해 드리려 합니다.
도입부
스칼라는 정교한 타입 시스템, 매크로, implicit과 같은 강력한 기능들을 제공하여, 더 안전하고 생산적인 코드를 작성할 수 있도록 합니다. 이러한 기능들은 프로그램의 안정성을 높이고, 복잡한 로직을 간결하게 표현할 수 있게 해줍니다. 하지만 공짜 점심은 없듯이 프로젝트의 규모가 커질수록 컴파일러에 전가된 많은 작업이 오히려 컴파일 속도를 저하해 개발 생산성을 떨어뜨리는 결과를 초래할 수 있습니다.
쿠키런: 킹덤 서버는 국내에 몇 안 되는 스칼라 및 함수형 프로그래밍 패러다임을 적용한 서버로 Scala2로 작성되어 있으며 sbt로 빌드하고 있습니다. 시간이 흐르며 프로젝트의 규모가 커지다 보니 이에 따라 느려진 컴파일 속도로 인한 불편함을 저희도 체감하게 되었습니다.
컴파일 속도를 저하하는 요인은 다양합니다. 이 글에서는 typeclass 를 활용할 때 빌드 성능 저하를 피하는 방법, 그리고 build pipelining 을 적용하기 위한 과정과 그 과정에서 경험한 트러블슈팅을 담았습니다.
컴파일러
본론으로 들어가기에 앞서 내용 이해를 돕기 위해 컴파일러에 대한 간단한 설명을 드리겠습니다. 컴파일은 여러 단계로 이루어지는데 이를 페이즈(phase)라고 합니다. 스칼라 컴파일러에서 수행되는 여러 페이즈는 네 가지 카테 고리로 분류할 수 있습니다.
- 프론트엔드(frontend) - 소스코드로부터 초기 구조를 생성하는 과정
- parser - 소스 코드를 트리 형태로 변환 (타입 정보는 없음)
- typer - 트리에 타입 정보를 보충
- 그 외에 여러 페이즈 포함
- 피클러(pickler) - 프론트엔드 단계에서 생성된 트리를 직렬화하여 저장하는 단계
- 트랜스폼(transform) - 런타임에 적합한 로우레벨 형태로 변환 (패턴매치 변환, 타입 이레이저 등이 포함)
- 백엔드(backend) - 최종적으로 바이트 코드를 생성하는 단계
이 중 핵심적인 페이즈는 프론트엔드와 백엔드입니다. 프론트엔드 페이즈는 주로 코드를 분석하여 컴파일러가 이해할 수 있는 구조로 변환하는 데 집중하고, 백엔드 페이즈는 이러한 구조를 최종 실행 가능한 코드로 변환합니다.
Typeclass
스칼라를 사용하다 보면 typeclass를 자주 접하게 됩니다. 이때 typeclass의 인스턴스를 어디에 정의하는지에 따라 컴파일 속도에 많은 영향이 있을 수 있습니다. 이는 컴파일러가 인스턴스를 검색할 때, 인스턴스의 정의된 위치에 따라 소요되는 시간이 다르기 때문입니다.
// Show.scala
trait Show[T] {
def show(t: T): String
}
위와 같은 typeclass가 있다 고 할 때 인스턴스를 정의하고 사용하는 방법은 대략 세 가지로 나눌 수 있습니다.
-
Companion 오브젝트에 정의
// Show.scala object Show { implicit val intShow: Show[Int] = _.toString } // Foo.scala // 컴패니언 오브젝트의 인스턴스는 기본적으로 스코프에 포함됨 println(implicitly[Show[Int]].show(3))
-
별도 trait에 정의하고, 이를 extends해서 스코프로 가져오기
// Show.scala trait ShowInstances { implicit val intShow: Show[Int] = _.toString } // Foo.scala object Foo extends ShowInstances { println(implicitly[Show[Int]].show(3)) }
-
별도 object에 정의하고, 이를 import해서 스코프로 가져오기
// Show.scala object ShowInstances { implicit val intShow: Show[Int] = _.toString } // Foo.scala import ShowInstances._ println(implicitly[Show[Int]].show(3))
세 가지 모두 유효한 방법이지만 빌드 속도에 유의미한 차이를 가져옵니다. 그러면 프로파일링 결과를 보면서 어떤 차이가 있는지 살펴보도록 하겠습니다.
아래 프로파일링 결과는 typeclass를 많이 사용하는 서브 프로젝트를 컴파일해서 얻은 결과를 약간 편집한 것입니다. 컴파일 시 -Vstatistics:typer
옵션을 활성화하면 아래와 같은 결과를 얻을 수 있습니다. 왼쪽 결과는 typeclass 인스턴스 정의 시 별도 object나 trait에 정의하는 2,3번 방법을 사용했을 때 결과입니다. 오른쪽 결과는 인스턴스를 companion object에 정의했을 때의 결과입니다. 프로파일링 결과를 보시면 각 페이즈별 소요된 시간 및 페이즈별 상세 스탯을 확인할 수 있습니다. 저희가 관심 있는 부분은 typeclass 검색에 사용된 시간이므로 typer 페이즈 내에서 time spent in implicits
부분을 확인하면 됩니다. -Vstatistiscs
의 더 자세한 사용법은 이 사이트에서 확인할 수 있습니다.
baseline(import + extends) companion object
[info] #total compile time : 1 spans, ()65561.041ms : 1 spans, ()45911.915ms
[info] parser : 1 spans, ()115.791ms (0.2%) : 1 spans, ()128.306ms (0.3%)
...
[info] typer : 1 spans, ()43962.134ms (67.1%) : 1 spans, ()24221.797ms (52.8%)
...
[info] pickler : 1 spans, ()54.552ms (0.1%) : 1 spans, ()54.096ms (0.1%)
...
[info] patmat : 1 spans, ()9903.798ms (15.1%) : 1 spans, ()10066.074ms (21.9%)
[info] uncurry : 1 spans, ()2354.928ms (3.6%) : 1 spans, ()2692.558ms (5.9%)
[info] fields : 1 spans, ()479.04 : 1 spans, ()450.513ms (1.0%)
...
[info] jvm : 1 spans, ()1717.737ms (2.6%) : 1 spans, ()1554.491ms (3.4%)
...
baseline(import + extends) companion object
[info] time spent typechecking : 1 spans, ()43962.129ms : 1 spans, ()24221.792ms
...
[info] time spent in implicits : 55444 spans, ()34748.208ms (79.0%) : 55464 spans, ()15114.999ms (62.4%)
[info] successful in scope : 21527 spans, ()20733.404ms (47.2%) : 10435 spans, ()5559.827ms (23.0%)
[info] failed in scope : 33917 spans, ()15298.409ms (34.8%) : 45029 spans, ()4790.923ms (19.8%)
[info] successful of type : 15548 spans, ()3092.438ms (7.0%) : 26658 spans, ()4990.437ms (20.6%)
[info] failed of type : 18369 spans, ()198.034ms (0.5%) : 18371 spans, ()400.566ms (1.7%)
[info] assembling parts : 5435 spans, ()120.933ms (0.3%) : 12852 spans, ()194.988ms (0.8%)
[info] matchesPT : 3675811 spans, ()3503.863ms (8.0%) : 3678299 spans, ()3527.704ms (14.6%)
[info] time spent in macroExpand : 7172 spans, ()9645.873ms (21.9%) : 7174 spans, ()7884.628ms (32.6%)
...
baseline 대비 companion 오브젝트 사용 시 typer 페이즈에서 소요된 시간은 67.1% → 52.8%로 감소했습니다. 그리고 typer 페이즈 내에서 implicits 검색에 든 시간도 확인 가능한데 79% → 62.4%로 감소했습니다. 즉 typeclass 인스턴스 검색에 걸린 시간이 줄어든 것을 확인할 수 있습니다. 그리고 더 자세히 들여다볼 것은 time spent in implicits
의 세부 항목입니다. 여기서 ~~scope, ~~ of type으로 끝나는 항목이 있는데 각각 현재 스코프 내부에서 검색한 것인지, companion 오브젝트에서 검색한 것인지 나타냅니다. 변경 전 프로파일링 결과와 비교해 보면 successful in scope, failed in scope에서 소모된 시간은 큰 폭으로 감소했고, sucessful of type, failed of type에서 소모된 시간이 소폭 상승한 것을 확인할 수 있습니다. 이로써 스코프 내에서 인스턴스를 찾은 횟수보다 companion 오브젝트에서 검색한 횟수가 증가했고, 전체적으로 검색에 든 시간은 감소했다는 것을 알 수 있습니다. 아래 그래프를 참고 하시면 전후 차이점을 쉽게 파악하실 수 있습니다.
이런 시도를 하게 된 계기는 cats 라이브러리에서 typeclass 인스턴스 정의 위치를 리팩토링했던 경험을 공유한 글을 보고입니다(관련 PR). 해당 글에서 리팩토링 후 예상치 못하게 컴파일 속도가 향상된 경험을 공유하고 있고 이를 보고 저희 프로젝트에도 시도 해보게 되었습니다.
결과적으로 해당 서브 프로젝트의 컴파일 속도를 대략 65초에서 45초로 1.44배 빠르게 할 수 있었습니다.
앞서 typeclass 인스턴스 검색에 사용된 시간은
time spent in implicits
로 확인 가능하 다고 말씀드렸습니다. 하지만 여기에는 한계점이 있습니다. 스칼라에선 typeclass를 네이티브 하게 지원하지 않기 때문입니다. 결국 implicit을 이용해서 typeclass 패턴을 구현하게 되는데 그러기 때문에 프로파일러 레벨에선 어떠한 implicit 검색이 typeclass 인스턴스를 검색한 것인지, 아니면 다른 용도인 것인지 구분할 수 없습니다. 따라서 implicit 검색에 걸린 시간중 typeclass 인스턴스 검색의 비중은 코드베이스에서 typeclass를 얼마나 사용 중인지를 통해 유추해야 합니다. 더 자세히 검증하고 싶다면scala-profiling
을 이용해서 implicit search flamegraph를 확보하는 것입니다. Flamegraph를 확인하면 현재 typeclass 인스턴스 검색에 많은 시간이 소비되고 있는지 쉽게 파악이 가능합니다. 물론 이 역시 정량적으로 확인하기는 힘들지만, 프로파일링 결과만 보는 것보다 더 많은 단서를 얻을 수 있습니다. 아래는 flamegraph의 예시입니다.칸 위에 마우스 포인터를 올리면 어떤 타입을 implicit 하게 검색했고 검색에 든 시간이 나옵니다. 따라서 전체적으로 확인해 보면 implicit 검색이 타입클래스 인스턴스를 많이 검색한 것인지 그 외의 경우인 것인지 파악이 가능합니다.
scala-profiling
의 자세한 사용법이 궁금하신 분을 위해 링크를 첨부했습니다. 추가로scalac-profiling
을 사용하여 Scala 빌드 서버인 bloop을 최적화한 사례를 소개한 글도 상당히 유익합니다. (링크)
Build Pipelining
스칼라 컴파일러는 build pipelining을 제공합니다. 이 기능은 서브 프로젝트 간에 의존성이 있을 때 백엔드 단계까지 실행해야 나오는 결과물을 기다리는 게 아닌, 프론트엔드 단계까지 실행 후 나온 결과물을 이용하여 바로 다음 서브 프로젝트를 컴파일할 수 있도록 하는 기능입니다. 예를 들어 다음과 같은 의존성 그래프를 가진 프로젝트가 있다고 하겠습니다.
이 경우는 C를 컴파일해야 B를 컴파일 할 수 있는 상황입니다. 하지만 pipelining을 사용 시 C의 백엔드 단계까지 기다릴 필요가 없습니다. B를 컴파일 할 때 필요한 정보는 C의 타입 트리로 충분하며 .class
파일까지 필요로 하지 않습니다. Pipelining은 이 점을 이용하여 C의 typer 페이즈가 끝난 후 생성된 AST(Abstract Syntax Tree)를 이용하여 B를 컴파일합니다. 이때 typer에서 나온 트리를 직렬화해서 저장하는 단계를 pickler라고 합니다. 그리고 이때 생성된 결과물은 sbt에서 early output이라고 합니다.
sbt에서 build pipelining을 활성화하는 방법은 간단합니다.
ThisBuild / usePipelining := true
얼핏 보면 간단한 설정 하나만으로 큰 컴파일 속도 개선을 얻을 수 있을 거처럼 보입니다. 하지만 내부적으로는 빌드 과정이 복잡해지는 만큼 신경 써줘야 할 부분이 생기기 마련입니다. 따라서 저희 프로젝트에서 적용하면서 겪은 문제와 그 해결 방안을 공유해드리려 합니다.
pipelining과 매크로
앞서 build pipelining은 pickler 페이 즈까지만 실행 후 나온 결과물만을 이용하여 다음 의존성을 컴파일한다고 하였습니다. 여기서 한 가지 신경 써야 할 것은 매크로입니다. 스칼라는 프로그래머가 매크로를 정의하고 이를 호출하여 컴파일 타임에 코드를 생성할 수 있습니다. 매크로 코드는 컴파일 타임에 실행된다는 특성 때문에, 매크로 확장(macro expand)을 위해서는 실행할 수 있는 단계까지 컴파일된 스칼라 프로그램이 필요합니다. 이 특성으로 인해 매크로는 build pipelining을 방해하는 경우가 있습니다.
먼저 C에 어떤 매크로 코드가 정의되어 있고 B에서 이 매크로를 호출하는 상황을 가정 해보겠습니다. 이럴 경우 C의 pickler 페이즈에서 나온 결과물만 가지고 B를 컴파일 할 수 없습니다. 왜냐하면 B를 컴파일 할 때 매크로 확장을 해야 하기 때문입니다. 다른 말로 하면 B를 컴파일할 때 C에 정의된 매크로 코드를 실행할 수 있어야 하기 때문입니다. 그렇기 때문에 타입 검사 후 얻은 AST 트리만으로는 부족하며 이 경우는 pipelining이 불가능합니다.
반대의 경우도 있습니다. 매크로 확장 시 사용자가 정의한 코드에 따라 동작을 다르게 하고 싶은 상황을 예로 들어보겠습니다. 이 경우 매크로 코드가 실행될 때 사용자가 정의한 코드를 실행할 수 있어야 합니다. 따라서 이 경우 역시 typer 페이즈까지만 실행해서는 정보가 부족합니다.
예를 들어 chimney에서는 라이브러리 사용자가 직접 TransformedNamesComparison
을 정의하여 매크로가 transformer를 생성하는 동작을 제어 가능합니다. 이때 TransformedNamesComparison
의 타입 정보만 가지고는 매크로를 확장할 수 없기 때문에 pipelining이 불가 능합니다.
chimney 예시 코드
// CustomTransformerConfiguration.scala
// chimney에서 변환하려는 메시지간 필드 이름 비교시 사용될 함수를 정의 가능
object CustomTransformerConfiguration {
case object CustomNamesComparison extends TransformedNamesComparison {
def namesMatch(fromName: String, toName: String): Boolean = ???
}
}
// Transformers.scala
object Transformers {
implicit val cfg = TransformerConfiguration.default.enableCustomSubtypeNameComparison(CustomTransformerConfiguration.CustomNamesComparison)
implicit val aToB: Transformer[A, B] = Transformer.derive // 매크로 확장시 CustomNamesComparison 사용
}
위 사례들처럼 pipelining이 불가능한 의존성을 가지고 있을 때 사용하는 옵션이 exportPipelining
입니다. 해당 옵션을 false로 준 서브 프로젝트의 경우 early output을 산출하지 않습니다. 따라서 이 옵션으로, 순차적으로 컴파일해야 하는 부분을 조절할 수 있습니다.
매크로 정의에 의존하는 경우(전자의 경우) sbt가 자동으로 감지하고 순차적으로 컴파일합니다. 이는 암묵적으로 이루어집니다. 따라서 의식하지 못한 매크로 정의로 인해 pipelining을 활용하지 못하는 경우가 발생할 수 있어 주의가 필요합니다. 아래와 같이 sbt 디버그 로그를 활성화하면 매크로로 인해 early output 산출이 안 되는 경우를 확인할 수 있습니다.
// sbt의 로그레벨을 (set logLevel := Level.Debug) Debug로 해야 볼수 있는 메시지 // 그렇기 때문에 매크로 정의로 early output 산출에 실패해도 바로 알아차리기 힘들다 [projectA / compile] early output can't be made because of macros
또 다른 확인 방법은 end-to-end 컴파일 과정을 지켜보는 것입니다.
| => C / Compile / compileIncremental 30s | => B / Compile / compileIncremental 20s | => A / Compile / compileIncremental 10s
Build pipelining으로 인해 병렬적으로 컴파일되고 있다면, 컴파일 과정을 실시간으로 지켜보는 것만으로 바로 알 수 있습니다.
Build pipelining은 typer 페이즈에서 걸리는 시간이 적을수록 많은 이득을 누릴 수 있습니다. 따라서 프로젝트의 페이즈별 시간마다 pipelining의 효과는 다를 수 있습니다. 킹덤 서버의 경우 전체 프로젝트 컴파일 속도를 1.22배 빠르게 할 수 있었습니다.
결론
지금까지 총 두 가지 컴파일 속도 개선 방법을 알아보았습니다. 첫 번째는 typeclass 인스턴스를 companion 오브젝트에 정의함으로써 인스턴스 검색 시간을 줄이는 방법입니다. 두 번째는 build pipelining을 통해 병렬성을 확보하는 것입니다. Pipelining 사용 시 신경 써야 할 점은 두 가지입니다. 첫 번째는 매크로 코드 또는 매크로 확장 시 사용되는 코드는 pipelining이 불가능하다는 것입니다. 따라서 이를 고려하여 서브 프로젝트를 분리하고 exportPipelining을 알맞게 설정하는 것이 중요합니다. 두 번째는 typer phase에서 시간을 많이 쓸수록 pipelining으로 얻을 수 있는 병렬성은 감소 한다는 것입니다. 앞으로도 새로운 방법을 찾으면 공유해드리도록 하겠습니다. 많은 관심 부탁 드립니다.