KSP 맛보기

KSP를 이용한 어노테이션 프로세싱 살펴보기

Myungpyo Shim
20 min readJan 4, 2022
BG from unsplash.com

우리는 개발을 진행 하면서 다양한 오픈소스 라이브러리를 사용합니다. 이들 중에는 특별한 어노테이션을 제공한 뒤 컴파일 타임에 어노테이션이 사용된 코드를 탐색하여 적절한 소스 코드를 추가로 생성해 내는 것들도 있는데, 보통은 라이브러리를 사용하는 개발자가 유사한 코드 패턴을 반복적으로 작성해야 할 경우 개발자의 수고를 덜어주고 실수를 미연에 방지하기 위해서 이런 편의를 제공합니다.

대표적인 예로는 안드로이드 개발자라면 익숙한 Dagger, Room, Retrofit, Glide 등이 이러한 컴파일 타임 코드 생성을 수행합니다. 이들 중 Dagger에 대해서만 간단히 살펴보면 표준 어노테이션인 @Inject 나 자체 정의한 @Module, @Bind, @Provide 등의 어노테이션을 제공하여 의존 오브젝트 간의 관계 및 생명주기를 어노테이션 기반으로 분석하여 의존성 트리를 구성하고, 의존 오브젝트 주입이 필요한 곳으로 마킹 된 곳에 오브젝트를 주입하는 등의 코드를 생성해 냅니다.

이러한 작업을 코드 실행 시점(Runtime)에 리플렉션을 통해 수행할 수도 있겠지만 성능면에서 약간의 불이익이 있고, 무엇보다 어노테이션이 잘못 사용되거나 라이브러리 자체적으로 코드 접근에 버그가 있는 경우 실행 시점이 되어서야 문제를 확인할 수 있고 디버깅도 어렵다는 문제가 있습니다. 하지만 코드 생성 방식도 코드량이 늘어남으로써 컴파일 타임을 증가시키고 빌드 된 라이브러리 크기를 증가시키는 단점도 있으니 어떤 것이 적합한지 잘 판단하여 상용하면 좋을 것 같습니다.

얼마전까지는 코틀린의 자바 호환성을 등에 엎고 자바에서 사용하던 어노테이션 프로세서를 코틀린에서도 사용하였습니다. 오픈소스 라이브러리, 특히 어노테이션을 통한 코드 생성을 지원하는 라이브러리 사용 시 build.gradle 의존성 정의 부분에 implementation 또는 api 로 라이브러리 의존성을 추가하고, kapt 로 해당 라이브러리에서 제공하는 어노테이션 프로세서에 대한 의존성도 추가해 보았을 것 입니다. 이 자바 기반의 어노테이션 프로세서는 코틀린 기반의 코드에서 사용 시 자바 심볼 관련 정보를 유지하기 위해 자바 코드 변환이 발생해 컴파일 시간을 느리게 만들고, 코틀린만의 심볼들(Ex> extension functions, declaration-site variance, local functions, …)을 코드 생성 시 사용하는데 제약이 있습니다.

그리하여 KSP(Kotlin Symbol Processing)가 탄생하게 되었고 기존 자바 기반의 어노테이션 프로세서를 대체하여 더 빠른 코드 생성(약 25%라고 하네요! 😍)을 통한 컴파일 시간 단축과 앞서 언급한 코틀린 전용 심볼들도 코드 생성을 시 지원할 수 있게 되었습니다. 또한 JVM과 약한 의존성을 갖는 API 제공으로 컴파일러 업데이트에 따른 프로세서 서스테이닝이 용이합니다.

이번엔 KSP를 이용하여 컴파일 타임에 코드를 생성하는 간단한 라이브러리를 제작해 보면서 KSP를 찍먹해 보겠습니다 (글을 읽고 더 관심이 생기시는 분은 가이드를 보며 부먹해 보셔도 좋을 것 같습니다) 😁.

지금부터 작성하는 샘플 코드는 이곳에서 확인하실 수 있습니다.
또한 사용 가능한 라이브러리 배포된 코드는 이곳에서 확인하실 수 있으며, 메이븐(Maven central) 배포 되어있기 때문에 여러분 각자의 프로젝트에서도 의존성을 추가하여 사용해 보실 수 있습니다.

이번에는 KSP를 활용해 보는 것이 목적이므로 아주 간단한 기능만 수행하는 라이브러리를 만들어 보겠습니다. 안드로이드 개발 시 액티비티나 프래그먼트에서 메모리가 부족하거나 환경 구성이 변경(Config changes)되면 실행중인 UI 컴포넌트들은 상태를 저장한 뒤 제거되며 이후 적절한 시점에 재생성되고 저장된 상태를 이용하여 복원됩니다. 이 경우 UI 컴포넌트에서는 onSaveInstanceState(bundle) 함수를 오버라이드하여 유지하고 싶은 상태 값(프로퍼티)들을 저장하고, 재생성 시 생성 라이프사이클 콜백에서 입력 파라미터로 제공되는 savedInstanceState 에서 저장된 값을 읽어 최근 상태로 복원할 수 있습니다. 다음은 이를 수행하는 간단한 예제 코드 입니다.

위 예제 코드에서는 문자열 프로퍼티(stringProp)와 정수 프로퍼티(intProp) 두 개를 onCreate() 에서 복원하고 onSaveInstanceState() 에서 저장하고 있습니다.

우리는 KSP를 이용하여 이를 어느정도 자동화 해보려고 합니다. 결과적으로 다음과 같이 MainActivityStateBinding 클래스가 생성되도록 할 것이며 MainActivity에서 이를 이용하여 상태들이 저장 / 복원 되도록 할 것 입니다.

위 코드에서 stateStore에 put / get 시 사용하는 키는 코드 생성 시 MainActivity에 정의된 프로퍼티 이름에 “key” 프리픽스를 붙인 것 입니다. 이렇게 생성한 MainActivityStateBinding 은 MainActivity에서 상태 저장이 필요한 시점에 save() 함수 호출을 통해 상태들을 stateStore에 저장하고, restore() 함수 호출을 통해 복원 할 것입니다. 다음은 최종적으로MainActivityStateBinding 을 사용하는 MainActivity 코드 입니다.

@StickyState 어노테이션이 붙은 프로퍼티들은 MainActivityStateBinding을 이용하여 원하는 시점에 save() , restore() 함수를 통해 저장 / 복원 할 수 있습니다.

이제 본격적으로 앞서 살펴본 KSP 샘플 프로젝트를 구성해 봅시다.
안드로이드 스튜디오에서 원하는 패키지 명으로 새로운 프로젝트를 생성하고 기본적으로 생성되는 app 모듈은 sample로 이름을 변경하고, 다음과 같이 두 개의 자바 라이브러리 모듈 coreprocessor를 추가합니다.
(패키지와 모듈들의 이름은 원하시는 것으로 해도 괜찮습니다.)

먼저 root build.gradle.kts 에 gradle plugin 버전을 확인합니다. 저는 현재 gradle plugin 1.6.10을 사용 중 입니다 (필요 시 원하는 버전으로 업그레이드 합니다).

현재 만들고 있는 라이브러리는 core 모듈과 processor 모듈로 구성될 것이며 보통 어노테이션 프로세서를 활용하는 프로젝트의 경우 이러한 방식으로 라이브러리 동작에 필요한 대부분의 기능이 구현되는 core 모듈과 어노테이션 프로세싱을 위한 processor 모듈을 분리하며 다른 환경 지원을 위한 확장 모듈이 추가되기도 합니다.

다만, 이번 경우에는 컴파일 타임에 수행하는 코드 생성이 라이브러리의 대부분의 기능이기 때문에 core 모듈에는 @StickyState 라는노테이션만 정의할 것 입니다. 따라서 core 모듈의 build.gradle.kts는 다음과 같이 간단하게 플러그인 설정만 추가 합니다.

plugins {
kotlin("jvm")
}

그리고 다음과 같이 StickyState 어노테이션을 정의합니다. 이 어노테이션은 클래스 내부의 프로퍼티들에만 적용 될 것이므로 Target 은 AnnotationTarget.PROPERTY로 설정합니다.

이제 라이브러리의 주요 모듈인 processor 모듈에 어노테이션 프로세서를 구현해 봅시다. 먼저 processor 모듈의 build.gradle.kts 에 다음과 같이 com.google.devtools.ksp:symbol-processing-api 의존성을 추가합니다. 이 때, 의존성 버전이 일반적인 형식과 달리 두 가지 버전이 합쳐진 모습인데 앞 버전은 gradle plugin 버전이고, 뒤 버전은 KSP 버전 입니다.
(최신 버전은 이곳에서 확인 가능합니다.)

KSP를 이요하여 어노테이션 프로세서를 구현하기 위해서는 다음 두 가지 인터페이스를 구현해야 합니다.
com.google.devtools.ksp.processing.SymbolProcessor
com.google.devtools.ksp.processing.SymbolProcessorProvider

먼저 어노테이션 프로세서를 생성해 봅시다. StickerStateProcessor 라는 이름의 프로세서를 선언하고 다음과 같이 SymbolProcessor 인터페이스를 구현하도록 합니다.
(com.google.devtools.ksp.processing.SymbolProcessor )

생성자에 전달되는 파라미터들은 바로 다음에 구현 할 SymbolProcessorProvider 에서 어노테이션 프로세서 생성 시 전달하는 값들로 해당 부분에서 설명하겠습니다.

SymbolProcessor implementation

이제 StickerStateProcessorProvider 라는 이름의 프로세서를 선언하고 다음과 같이SymbolProcessorProvider 인터페이스를 구현하도록 합니다.
(com.google.devtools.ksp.processing.SymbolProcessorProvider )

SymbolProcessorProvider implementation

위 StickerStateProcessorProvider에서 생성하는 StickerStateProcessor를 살펴보면 Provider로 전달 된 environment에서 어노테이션 프로세싱에 필요한 객체들을 전달하고 있음을 알 수 있습니다. 각각의 기능은 다음과 같습니다.

  • codeGenerator : 컴파일 타임에 어노테이션을 분석하여 작성되는 코드가 저장 될 파일의 생성, 관리 기능을 제공합니다. 또한 파일간 의존 관계 설정을 통해 Incremental processing을 가능하게 합니다.
  • logger : 어노테이션 프로세싱 간 발생하는 이벤트들은 이 로거를 사용하여 로깅하면 빌드 상태 윈도우에서 KSP 섹션의 로그들로 확인 가능합니다.
  • options : KSP를 이용한 심볼 프로세싱 수행 시 여러가지 프로세싱 옵션을 전달하고 싶은 경우에는 아래와 같이 빌드 스크립트에 맵 형식으로 옵션들을 전달하고 SymbolProcessorEnvironment의 options를 통해 접근 할 수 있습니다.
ksp {   
arg("option1", "value1")
arg("option2", "value2")
...
}

이렇게 생성한 StickerStateProcessorProvider는 다음의 경로에 Fully-qualified-name으로 서비스 등록이 되어야 합니다.
파일 경로 :
projectRoot/processor/src/resources/META-INF/services/com.google.devtools.ksp.processing.SymbolProcessorProvider
파일 내용 :
io.github.myungpyo.simplekspsample.processor.StickyStateProcessorProvider

이제 앞서 생성 한 StickerStateProcessor의 process 함수를 구현해 봅시다. 먼저 다음과 같이 프로세싱 과정 동안 내부적으로 사용 할 상수 값들을 정의합니다.

companion object {
val STICKY_ANNOTATION_NAME = StickyState::class.java.name
const val STATE_HOLDER = "stateHolder"
const val STATE_STORE = "stateStore"
}

STICKY_ANNOTATION_NAME 은 앞서 core 모듈에 정의한 어노테이션 이름이고 STATE_HOLDER는 저장 할 상태 값들을 소유한 클래스의 참조명인데 보통 Activity나 Fragment가 될 것 입니다. STATE_STORE는 상태 값들이 저장 된 저장소의 참조명이고 현재는 Bundle 타입만 사용됩니다.

StickyStateProcessor process() 함수는 다음과 같이 구현을 시작합니다.

어노테이션 프로세싱을 위해서 먼저 우리가 관심을 두고 있는 앞서 @StickyState 어노테이션이 사용된 프로퍼티들을 찾아야 합니다. 이를 위해서는 process() 함수에 전달 된 resolver를 이용하면 됩니다. resolver를 이용하여
+ (3번 라인) @StickyState 어노테이션이 붙은 요소들을 찾고,
+ (4번 라인) 프로세싱 대상을 프로퍼티 만으로 필터링하고,
+ (5번 ~ 10번 라인) 프로퍼티를 소유한 클래스 이름과 대상 프로퍼티 정의들을 값으로 하는 맵으로 변환 합니다.
symbolMap =. Map<String, List<KSPropertyDeclation>>
(만일 프로퍼티를 소유한 클래스 이름(ownerJvmClassName)을 알 수 없을 경우 logger 를 이용해 오류 메시지를 남기고 프로세싱을 종료합니다).

여기서 KSPropertyDeclation이라는 타입이 나왔습니다. 만일 여러분이 작성하려는 어노테이션 프로세서의 타겟이 되는 요소가 클래스나 인터페이스라면 KSClassDeclaration 타입으로 필터링해야 합니다.
KSP는 컴파일 타임에 각각의 파일들과 파일 내부에 정의된 요소들을 KSNode의 형태로 바라봅니다. 관련해서 더 자세한 내용은 이곳을 참조 바랍니다. 또한 각각의 KSNode 요소들이 어떤 관계를 갖는지는 SVG 포멧의 다이어그램을 참조 바랍니다.

이제 symbolMap 을 가지고 각 클래스 별 바인딩 클래스를 생성할 것 입니다. 다시 말해서 MainActivity의 프로퍼티 중 @StickyState 어노테이션이 붙은 프로퍼티가 있다면 MainActivityStickyStateBinding 이라는 클래스가 컴파일 타임에 생성되도록 할 것 입니다.

다음은 StickyStateProcessor의 process() 함수의 이어지는 구현 입니다.

process() 함수의 반환 값은 현재 프로세싱 단계에서 처리가 어려운 심볼 리스트 입니다. 7번 라인에서는 각 클래스 별로 generateBinding() 함수를 호출하고 있는데 이 함수의 반환 값도 현 단계에서 처리 불가능한 심볼 리스트이므로 flatMap을 통해 각 클래스의 generateBinding() 호출 결과를 병합하여 반환하고 있습니다.

이제 실제 코드 생성을 하는 generateBinding() 함수를 살펴봅시다.

KotlinPoet과 같은 라이브러리를 사용하여 더 구조적이고 유지보수가 용이하게 코드 생성 로직을 작성할 수 있겠지만 이번 예제에서는 문자열로 한줄 한줄 코드를 작성해 나가는 방식을 취하겠습니다.

generateBinding() 함수는 @StickyState 어노테이션이 붙은 프로퍼티들을 소유한 클래스 이름인 ownerClassName과 해당 프로퍼티 정의 리스트인 propertyDeclarations, 그리고 해당 클래스가 의존하는 파일들을 나타내는 sourceFiles를 파라미터로 갖습니다.

중요한 부분은 13번 라인인데, 프로퍼티 정의(propertyDeclaration)마다 accept() 함수를 호출하면서 StickyStateVisitor를 전달하고 있습니다. PropertyDeclaration을 포함한 KSNode를 구현하는 모든 KSP 노드들은 KSNode에 정의 된 accept(visitor) 함수를 구현하고 있으며, 이 Visitor Pattern을 이용하여 코드 생성 로직(StickyStateVisitor)과 같은 각 노드에 적용되어야 하는 비지니스 로직들을 노드 구성으로부터 분리하고 있습니다.

곧 구현 할 StickyStateVisitor는 대상 프로퍼티에 대해서 bundle에 저장(save)하는 코드와 복원(restore)하는 코드를 문자열로 생성한 뒤 전달 받은 saveCodeListListCodeList에 각각 추가합니다 (파일 스트림을 전달해서 Visitor에서 코드를 직접 파일에 쓰도록 작성할 수도 있습니다).

16번 라인부터는 codeGenerator를 이용하여 StickyStateBinding 클래스 파일을 생성하는 부분이며, 17~20번 라인은 Incremental Processing에 관한 설정입니다. KSP는 기본적으로 Incremental Processing을 지원하며 어노테이션 프로세싱에 연관된 코드 이외의 변경에 대해서는 프로세싱을 다시 수행하지 않도록 할 수 있습니다. 19번 라인에서 aggregating=false, sources=[OwnerFile] 로 의존성을 설정함으로써 해당 클래스 파일이 변경 된 경우에만 어노테이션 프로세싱이 다시 수행되도록 지정하고 있습니다. 이제 앞서 작성한 의존성 정보와 패키지 이름, 파일 이름을 이용하여 생성된 코드를 저장 할 OutputStream을 생성합니다.

의존성 정보에 대해서 조금 더 자세하게 알아보자면 KSP에서는 Aggregating Mode와 Isolating Mode를 지원하는데 Aggregating Mode는 임의의 소스 파일이 생성, 변경 될 경우 어노테이션 프로세싱 전 과정이 처음부터 다시 수행되는 모드이며, Isolating Mode는 의존 파일로 지정된 소스 파일의 변경이 있는 경우에만 어노테이션 프로세싱이 다시 수행되는 모드 입니다.
(자세한 내용은 이곳을 참고하세요).

이제 생성된 OutputStream을 이용하여 실제 코드를 써 내려가 봅시다.

제일 위부터 살펴보면 우선 package를 지정하고, 코드에 필요한 import 구문들을 삽입합니다. 이후에 class MainActivityStickyStateBinding { 으로 클래스를 시작한 후 앞서 StickyStateVisitor를 이용하여 생성한 프로퍼티의 저장 코드 조각들 (saveCodeList)과 복원 코드 조각들(restoreCodeList)을 이용하여 생성중인 MainActivityStickyStateBinding 클래스의 save() 함수와 restore() 함수를 작성합니다.

10번 라인에 CodeLoom이라는 클래스를 사용하고 있는데 코드 개행 및 탭을 관리하기 위해 임시로 만든 유틸리티 클래스이며 기본 기능은 다음과 같습니다. (코드)
- write()
: 텍스트를 그대로 입력
- writeWithOpenBracket()
: 텍스트를 입력하고 마지막에 중괄호 { 를 삽입하고 텍스트 인덴트 레벨을 1 증가 시킴
(탭 + 1)
- closeBracket()
: 중괄호 } 를 삽입하고 텍스트 인덴트 레벨은 1 감소 시킴
( 탭 - 1)
- lineWrap(n) : n 만큼 개행

StickyStateVisitor 클래스를 살펴보기 전에 Visitor에 대해서 간략히 알아봅시다. 앞서 살펴보았던 것 처럼 KSP는 Kotlin 문법의 각 요소들을 노드의 형태로 정의하며 노드들은 계층 구조를 이룹니다. 각각의 심볼 프로세서는 Visitor를 구현하여 프로세서가 관심있는 노드(심볼)에 대해서만 코드 생성 로직을 구현하면 됩니다. 이를 간단히 이미지로 나타내보면 다음과 같습니다.

위 예제에서 어노테이션 @Foo는 클래스와 함수를 타겟으로 하고, 어노테이션 @Bar는 프로퍼티를 타겟으로 합니다. 이 둘은 서로 다른 역할을 하는 어노테이션 이라고 가정합시다. 이들 어노테이션 붙어있는 요소들에 대한 코드 생성을 위해서 각각 FooVisitor와 BarVisitor를 생성하였습니다. Visitor들은 KSVisitor<D, R>를 구현해야 하는데 20여가지의 코틀린 요소들에 대한 visit 함수들이 정의되어 있습니다. 이들 전부를 구현할 것이 아니므로 미리 정의 된 KSDefaultVisitor를 사용하거나 현재와 같이 데이터(D)와 반환값(R)이 없는 경우에는 KSVisitorVoid를 사용하면 됩니다.

이제 StickyStateVisitor를 구현해 봅시다. StickyStateVisitor 데이터를 전달 받지 않을 것이고 반환도 하지 않을 것이므로 KSVisitorVoid를 상속하도록 하겠습니다. 그리고 @StickyState 어노테이션이 붙은 프로퍼티를 위한 코드 생성을 수행할 것이므로 visitPropertyDeclaration() 함수를 오버라이드 합니다.

먼저 KSPropertyDeclaration 객체인 property에서 프로퍼티 이름을 가져와서 Bundle에 저장/복원 시 사용할 키 값으로 사용하고, 프로퍼티들이 정의 된 OwnerClass(MainActivity)에 다시 Set 할 때 접근자로도 사용합니다. 그리고 property.type.resolve()를 통해 프로퍼티의 타입 정보인 KSType을 얻습니다. 이 정보를 가지고 프로퍼티가 nullable인지 실제 정의된 타입 클래스는 무엇인지와 같은 부가 정보를 확인합니다.

9번 라인에서 프로퍼티의 타입 정보를 가져오기위 위해서 property.type.resolve() 함수를 사용하였으며 다음과 같은 과정으로 실제 타입 정보를 가져오게 됩니다.

이와 같이 KSPropertyDeclaration은 프로퍼티에 대한 다양한 정보를 갖고 있지만 실제 타입을 확인하려면 KSTypeReference를 통해 타입 정보 매핑(resolve) 작업이 필요합니다. 만약 클래스에 val testValue: String 이라는 프로퍼티가 정의되어 있었다면 KSPropertyDeclaration의 simpleName은 testValue 일 것이고, 이 프로퍼티의 KSTypeReference를 통해 가져온 타입 정보의 KSDeclaration은 KSClassDeclaration으로 simpleName은 String 일 것 입니다.

공식 문서에서도 언급하고 있지만 KSTypeReference.resolve() 호출은 KSP 과정 중 제일 부하가 큰 작업이므로 resolve 과정 없이 각 Declaration에서 제공하는 정보만으로 충분하다면 불필요한 호출은 지양해야 합니다.

이렇게 생성한 StickyStateVisitor는 save() / restore() 함수 내부에서 프로퍼티 데이터를 Bundle에 저장 / 복원하는 일부 코드 조각을 생성하여 전달 된 리스트에 추가합니다.

이제 sample 모듈로 돌아와서 build.gradle.kts에 다음과 같이 KSP 플러그인 설정 및 core 모듈과 processor 모듈의 의존성을 추가합니다.

현재 KSP에서 생성된 코드를 IDE에서는 인식하지 못하는 제약이 있기 때문에 위와 같이 sourceSets 설정도 해 주어야 sample 앱의 MainActivity에서 접근 가능합니다.

이제 처음에 살펴보았던 MainActivity 예제를 다음과 같이 수정하고 빌드해 봅시다.

빌드 후 sample 모듈의 아래 경로를에 생성된 클래스를 열어 확인해보면 우리가 의도한대로 코드가 생성되었음을 확인할 수 있습니다.
./build/generated/ksp/debug/kotlin/[YOUR_PACKAGE]/MainActivityStateBinding

아래는 생성된 코드 입니다.

지금까지 KSP를 활용한 컴파일 타임 코드 생성을 간단히 수행해 보았습니다. 어떻게 보면 라이브러리 개발자가 아닌 이상 코틀린의 다른 기능들에 비해서 실무 사용빈도가 낮은 기능일 수 있지만 개념을 알아두면 팀원들과 함께 사용하는 공통 모듈 제작 시 보다 사용성 좋은 모듈을 제작할 수 있도록 돕는 기능이라고 생각합니다.

끝. 😊

--

--