증분 처리

증분 처리 (Incremental processing)

KSP는 증분 처리를 지원해요. 어떤 파일의 의존성 중 하나라도 바뀌었을 때만 KSP가 그 파일을 다시 처리해요. 불필요한 재처리를 피하니까 컴파일 시간이 줄어들죠.

증분 처리는 기본적으로 켜져 있어요. 문제를 진단하거나 전체 재빌드를 강제해야 할 때는 끌 수 있어요. 끄려면 gradle.properties 파일에 다음 줄을 추가해요.

ksp.incremental=false

출처: Kotlin 공식 문서

본문

더티 파일 (Dirty files)

파일이 개발자에 의해 직접 수정되거나, 다른 더티 파일들의 변경으로 간접적으로 영향을 받았다면 그 파일은 더티(dirty, 다시 처리해야 할)로 간주돼요.

어떤 소스가 더티인지 판단하기 위해 KSP는 프로세서에 의존해요. 프로세서는 생성된 출력을 대응하는 입력 소스와 연결해 주죠. KSP는 이 연결 관계를 이용해 변경이 발생했을 때 다시 처리해야 할 소스를 식별해요.

KSP는 최소한의 루트 소스(root source) 집합만 요구해요. 프로세서는 이 소스들을 코드 구조를 탐색하는 진입점으로 사용해요.

루트 소스란 다음 메서드 중 하나로부터 심볼을 직접 얻는 소스 파일을 말해요.

  • Resolver.getAllFiles()
  • Resolver.getSymbolsWithAnnotation()
  • Resolver.getClassDeclarationByName()
  • Resolver.getDeclarationsFromPackage()

프로세서는 루트 소스에서 정보를 해석해 다른 소스 파일의 추가 심볼을 얻을 수 있어요. KSP는 이런 의존성을 자동으로 추적해요.

출력을 생성할 때 프로세서는 그 출력에 기여하는 루트 소스들을 선언해야 해요. KSP는 그 루트 소스들과 추적된 의존성을 이용해 출력을 언제 다시 생성해야 할지 판단해요.

출력 파일을 만들고 입력과 출력을 연결할 때는 CodeGenerator 인터페이스를 사용해요. 자세한 내용은 소스 코드의 CodeGenerator.kt를 참고해요.

집계 출력과 격리 출력 (Aggregating and isolating outputs)

KSP는 생성된 출력을 집계(aggregating)와 격리(isolating) 두 종류로 분류해요.

Gradle 애노테이션 처리는 프로세서 전체 단위로 분류하는데, KSP는 프로세서가 아니라 개별 출력 단위로 분류를 적용한다는 차이가 있어요.

집계(Aggregating) — 집계 출력은 다른 파일에 영향을 주지 않는 제거(removal)를 제외하면, 어떤 소스 파일의 변경에도 영향을 받을 수 있어요. 입력이 하나라도 바뀌면 모든 집계 출력이 재빌드되고, 그에 대응하는 등록·신규·수정된 모든 소스 파일이 다시 처리돼요. 예를 들어 특정 애노테이션이 달린 모든 심볼을 모아두는 출력은 집계 출력이에요.

격리(Isolating) — 격리 출력은 지정된 소스에만 의존해요. 다른 소스가 바뀌어도 출력에는 영향이 없어요. 여러 소스 파일이 하나의 출력과 연결될 수도 있어요. 예를 들어 어떤 인터페이스에 전념해서 그 인터페이스를 구현하도록 생성된 클래스는 격리 출력이에요.

더티 전파 (Dirtiness propagation)

KSP는 다음 두 방식으로 더티함을 전파해요.

  1. 해석 추적에 의한 전파: 타입 해석이 한 파일에서 다른 파일로 건너가는 유일한 경로예요. 프로세서가 타입 참조를 해석하면(명시적이든 암시적이든), KSP는 그 참조가 들어 있는 파일과 그 해석에 영향을 주는 심볼을 정의한 모든 파일 사이에 의존성이 있다고 봐요. 그 결과 해석된 심볼이 바뀌면 참조하던 파일이 더티로 표시될 수 있어요.

  2. 입출력 대응에 의한 전파: 소스 파일이 바뀌거나 영향을 받으면, 그와 생성된 출력을 공유하는 다른 모든 소스 파일도 영향을 받은 것으로 표시돼요. 이렇게 하면 공유 출력을 기준으로 관련 파일들을 동치 클래스(equivalence class)로 묶게 돼요.

규칙 (1)과 (2)는 서로를 반복해서 촉발할 수 있어요. 예를 들어 규칙 (1)이 규칙 (2)를 촉발하고, 그 규칙 (2)가 다시 규칙 (1)을 촉발할 수 있죠.

구현 (Implementation)

의존성은 입력 파일과 출력 파일 사이의 다대다(many-to-many) 관계로 결정돼요. KSP가 어떤 파일을 다시 처리해야 하는지 판단하는 방식은 이래요.

  • 입력 파일이 바뀌면 항상 다시 처리해요. 왜냐하면? 입력이 바뀌면 새 정보가 들어올 수 있어요. 프로세서가 그 입력을 가지고 다시 실행돼야 해요.

  • 입력 파일이 바뀌었고 어떤 출력과 연결되어 있다면, 같은 출력과 연결된 다른 모든 입력 파일도 다시 처리돼요. 새 더티 파일이 없어질 때까지 이 과정이 반복돼요. 왜냐하면? 출력은 입력들의 집합으로 만들어져요. 프로세서가 출력을 다시 생성하려면 모든 입력이 필요할 수 있어요.

  • 바뀌지 않은 입력 파일이 어떤 집계 출력과도 연결되어 있지 않다면, 다시 처리하지 않아요. 왜냐하면? 이 파일은 바뀌지 않았고 집계 출력과도 연결되어 있지 않으므로 어떤 출력에도 영향을 줄 수 없어요. 위 규칙 중 하나가 적용되지 않는 한 다시 처리되지 않아요.

예를 들어 이런 구조의 프로젝트를 생각해 볼게요.

.
├── src
│   ├── sourceA.kt
│   └── sourceB.kt
└── generated
   ├── outputA
   └── outputB

프로세서가 이런 일을 한다고 해요.

  1. sourceA를 읽어요.
  2. outputA를 생성해요.
  3. sourceB를 읽어요.
  4. outputB를 생성해요.

sourceA가 바뀌었을 때:

  • outputB가 집계 출력이면 KSP는 sourceAsourceB를 둘 다 다시 처리해요.
  • outputB가 격리 출력이면 KSP는 sourceA만 다시 처리해요.

sourceC가 추가됐을 때:

  • outputB가 집계 출력이면 KSP는 sourceCsourceB를 다시 처리해요.
  • outputB가 격리 출력이면 KSP는 sourceC만 다시 처리해요.

sourceAsourceB 중 하나가 제거되면 KSP는 어떤 파일도 다시 처리할 필요가 없어요.

예제 프로세서 (Example processor)

다음 프로젝트에는 AB 클래스가 있는데, AB를 상속해요.

// A.kt
@Interesting
class A : B()

// B.kt
open class B

// Example1Processor.kt
class Example1Processor : SymbolProcessor {
   override fun process(resolver: Resolver) {
       val declA = resolver.getSymbolsWithAnnotation("Interesting").first() as KSClassDeclaration
       val declB = declA.superTypes.first().resolve().declaration
       // B.kt isn't required, because it can be deduced as a dependency by KSP
       val dependencies = Dependencies(aggregating = true, declA.containingFile!!)
       // outputForA.kt
       val outputName = "outputFor${declA.simpleName.asString()}"
       // outputForA depends on A.kt and B.kt
       val output = codeGenerator.createNewFile(dependencies, "com.example", outputName, "kt")
       output.write("// $declA : $declB\n".toByteArray())
       output.close()
   }
   // ...
}

outputForA를 생성하기 위해 프로세서는:

  1. Resolver.getSymbolsWithAnnotation을 호출해 A를 얻어요.
  2. A에서 KSClassDeclaration.superTypes를 호출해 B를 얻어요.

KSP는 해석 추적을 통해 이 관계를 추적해서 BA의 의존성으로 자동 기록해요. 그래서 B.ktoutputForA의 의존성으로 명시적으로 선언할 필요가 없어요.

버그 보고하기 (Reporting bugs)

증분 처리를 켰을 때만 발생하는 오류를 만나면 GitHub 저장소에 이슈를 만들고 관련 로그 파일을 첨부해 주세요.

  1. gradle.properties에 다음 줄을 추가해 증분 처리 로그를 켜요.
ksp.incremental.log=true
  1. 성공적으로 완료되는 클린 빌드(clean build)를 수행해요.
  2. 생성된 로그 파일을 다른 위치로 복사해 저장해요.
  • build/kspCaches/<source set>/logs/kspDirtySet.log
  • build/kspCaches/<source set>/logs/kspSourceToOutputs.log
  1. 문제를 촉발하는 소스 파일을 수정하고 빌드를 다시 실행해요.
  2. 성공한 빌드의 로그 파일과 문제를 재현한 빌드의 로그 파일을 모두 GitHub 이슈에 첨부해요.

심볼 의존성 그래프 시각화하기 (Visualizing the symbol dependency graph)

증분 처리를 디버깅하는 데 도움이 되도록, KSP는 지정된 심볼에서 시작하는 심볼 의존성 그래프를 시각화한 Graphviz DOT 파일을 생성할 수 있어요.

증분 로깅을 켜고 그래프 시각화의 시작점으로 쓸 심볼의 완전한 이름(fully qualified name)을 지정해요.

ksp.incremental.log=true
ksp.incremental.log.graph.origin=<fully-qualified-name>

명령줄에서 KSP를 사용한다면 다음 옵션을 추가해요.

-incremental-log=true -incremental-log.graph.origin=<fully-qualified-name>

DOT 파일은 logs 디렉터리에 생성돼요.