◦ Runtime - kotlin reflection ◦ Compile time - KSP(Kotlin Symbol Processing) Kotlin에서는 Runtime은 Kotlin reflection을 활용할 수 있고(Java reflection을 보통 사용합니다.(하지만 코틀린 관련 문법을 위해 Kotlin Reflection 사용이 좋습니다.)) Compile time에는 KSP를 활용할 수 있습니다.
@Suppress : 컴파일 경고 억제 • @Deprecated(message = “message”) : 코드가 지워질 수 있음을 경고 • @Deprecated( message = "moved to var", replaceWith = ReplaceWith(expression = "run { this.protocols = protocols }"), level = DeprecationLevel.ERROR ) : 코드가 지워질 수 있음을 경고하고 새로운 사용법 안내 많이 사용되는 어노테이션 몇 개 나열해 보았습니다.
TYPE_PARAMETER : Parameter에 정의 • PROPERTY : val/var property에 정의 • FIELD : 프로퍼티에 의해 생성되는 필드에 정의 • CONSTRUCTOR : 생성자에 정의 • FUNCTION : 함수에 정의 • PROPERTY_GETTER : Getter Property에 정의 • PROPERTY_SETTER : Setter Property에 정의 • FILE : File에 정의 Target은 Class, Property, function, file 등 거의 대부분에 지정해 줄 수 있습니다. 예를 들면 @Suppress 어노테이션은 거의 대부분에서 활용하도록 지정되어 있습니다.(문서 참고)
활용하고, 흔적이 남지 않음 • BINARY : build 시에 활용하고, 흔적이 남지만 reflection에서 보이지 않음 • RUNTIME : reflection에서 활용할 수 있고, 흔적이 남음 유지 조건은 3가지가 있습니다. 3개의 동작 방식이 다르기 때문에 Source는 build에서만 활용하고, 흔적이 남지 않죠. Binary는 지정하지 않으면 기본값으로 동작하는데, build에서 사용하고, 흔적 역시 남습니다. 디컴파일 시 차이는 뒤에서 살펴보고, Runtime은 Reflection에서 활용합니다.
BinaryTest @BinaryTest class TestBinary @BinaryTest public final class TestBinary {} @BinaryTest를 붙인 상태로 디컴파일 결과물을 살펴보면 어노테이션이 붙어있는 것을 알 수 있습니다. 하지만 Runtime에서 이를 확인할 순 없습니다. 그냥 디컴파일 시 위치를 확인할 수 있는 것이지, 리플렉션에서 활용할 순 없습니다.
RuntimeTest @RuntimeTest class TestRuntime @RuntimeTest public final class TestRuntime {} Binary와 차이가 없지만 명확하게 Runtime인 리플렉션에서 확인이 가능합니다. 추가로 발표에서는 언급 안 했는데, Runtime을 적용하면 Compile 단계에서도 이를 확인할 수 있습니다. 만약 양쪽 다 필요로 한다면 @Runtime을 적어주시는 게 좋겠죠.
정보를 담는다.(Boolean, String, Type 등(Any를 가질 순 없다.)) • annotation class AnnotationName(val args: Boolean, list: List<String>) Annotation에 파라미터 적용이 가능합니다. Annotation 이름 뒤에 생성자 위치에 파라미터를 정의하시면 됩니다. 단, Any 타입을 제외한 primitive types에 대한 적용이 가능합니다. 검색할 때는 Any로 들어오는데, 이를 컨버팅하는 과정을 거쳐야 합니다.
동일(ex. 1.7.10)) ◦ implementation("org.jetbrains.kotlin:kotlin-reflect:1.7.10") • Class reference ◦ val c = MyClass::class • Callable references ◦ fun isOdd(x: Int) = x % 2 != 0 val numbers = listOf(1, 2, 3) println(numbers.filter(::isOdd)) Runtime - kotlin reflection kotlin-reflect를 implementation 추가하고 사용할 수 있습니다. reflection이 별다른 건 없고 흔하게 객체화되기 전의 Class를 reference 형태로 사용할 수 있습니다. 또는 Callable references 활용도 가능합니다. 흔하게 람다 표현식으로 다뤄본 적이 있을 겁니다.
확인할 수 있다. • 단점 ◦ Reflection을 활용하기 때문에 프로가드 룰에서 제외 필요 • 주의 ◦ 자주 호출되어지기 때문에 cache 등록도 미리 해두어야 한다. ▪ Class, function, property 등에 대한 cache 등록 이런 reflection의 장단점도 있는데, 당연히 runtime에서 class의 function/property 정보를 확인할 수 있습니다. 안드로이드에서는 프로가드 룰에서 이를 제외해 줘야 합니다.(어노테이션 정보) 그리고 runtime이다 보니 자주 호출 됩니다. 미리 cache에 저장해두고 reflection의 효율성을 증대시켜줄 필요도 있습니다. 또는 이미 어떠한 결과물에 대한 처리를 저장해두고, 종료를 하는 예를 들 수 있겠네요.
property, nullable 상태 등) • KCallable : Function or Property의 호출(call 함수를 이용한) 가능한 entity 정보를 담는다. • KFunction : 함수의 파라미터 정보와 리턴 정보 등을 담는다.(suspend function 정보 등) • KProperty : val, var로 선언한 property 정보를 담는다. KClass는 class 내의 정보를 담고 있고, 여기엔 KCallable도 포함합니다. 결국 어떠한 작업을 실행할 수 있어야 하니 KCallable을 활용할 수 있습니다. KFunction과 KProperty 정보도 알 수 있습니다. 결국 클래스 내의 함수 정보와 프로퍼티 정보를 모두 확인할 수 있고, 이들을 실행할 수 있는 건 KCallable입니다.
property, nullable 상태 등) • KCallable : Function or Property의 호출(call 함수를 이용한) 가능한 entity 정보를 담는다. • KFunction : 함수의 파라미터 정보와 리턴 정보 등을 담는다.(suspend function 정보 등) • KProperty : val, var로 선언한 property 정보를 담는다. 도식화하면 아래 그림과 같습니다.
때문에 rebuild가 필요치 않는 부분에서 적용해야 이용성이 좋다. • 기존 코드에 새로운 코드 블록을 삽입/삭제할 수 있는 것은 아니다. Compile time - KSP(Kotlin Symbol Processing - Google Open source project) Compile time은 KSP를 이용할 수 있습니다. 이 오픈 소스는 젯브레인이 아닌 Google이 관리하고 배포하고 있습니다. 버전 정보가 코틀린 버전 - KSP 버전을 함께 명시하도록 되어있습니다. KSP는 build 과정에서 코드가 생성되기 때문에 당연히 rebuild가 필요치 않는 부분에 대한 코드 적용이 중요합니다. 이 부분을 매우 심도 있게 고민해 주셔야 실제 적용했을 때 높은 사용성과 효과를 볼 수 있습니다.
◦ Build 시에 코드를 추가한 파일 생성(기존 코드에 추가/제거하는 건 불가) • 단점 ◦ Build 시간이 길어질 수 있다.(기존 자바 용 보다는 2배 빠르다) ◦ Default value 정보를 가져올 수 없다. • 주의 ◦ Compile 과정에서 활용되기 때문에 UnitTest를 잘 작성해둬야 한다.(형태가 벗어나지 않도록) Compile time의 장/단점 Compile time 상태에서 class annotation 등의 정보를 확인할 수 있습니다. Build 시에 코드를 생성하고, 이를 포함하게 됩니다. 다만 기존 코드를 수정하거나 제거하는 건 불가능합니다. 새로운 파일을 만들어 결과를 추가하게 됩니다. 대신 설계를 잘해야 build 시간을 줄일 수 있습니다. 또 다른 단점은 코틀린에서 명시한 defualt value를 가져올 수 없습니다. 그리고 검증도 잘해줘야 합니다.
◦ 리빌드 없이, 프로그램 실행 시에만 필요로 하는 코드 부분을 찾아 자동화한다. ◦ ex) DI와 같은 형태의 코드 • 실 업무에서 자동화할 부분이 많을까? ◦ 실제 업무에서는 생각보다 자동화할 부분이 많지는 않음 ◦ 보일러 플레이트를 줄여줄 수 있는 형태의 코드를 찾아 자동화(Runtime도 고려 가능) • 자동화되었을 때의 검증을 할 수 있는 방법도 함께 고려 ◦ Unit test 작성을 통해 자동으로 만들어지는 코드에 대한 검증 필수 앞에서도 언급했지만 KSP를 적용하는 건 생각보다 쉽지만 이걸 어디에 어떻게 효율적으로 적용할지가 중요합니다. 제가 작업하는 프로젝트에서는 DI 부분까지 모두 코드를 생성하고, 이를 상위의 APP까지 모두 자동으로 연결하는 구조를 가지고 있습니다. 리빌드 과정 없이 이 부분을 모두 한 번에 처리하고 있는데, 이렇게 작업하지 않으면 매우 비효율적일 수밖에 없습니다. 그러니 잘 고민하고 적용해야 합니다.
DataStore에 대한 interface 정의만 하면, 필요한 코드를 자동으로 붙여준다. 오늘 살펴볼 예제는 제가 오픈소스로 관리하고 있는 DataStore 활용을 돕는 KSP 코드를 살펴보려고 합니다. Interface 정의만 하면, 필요한 Impl 코드는 모두 자동으로 생성해 주게 됩니다.
Flow<Int> @ClearValues suspend fun clearAll() companion object { private const val KEY_INT = "key-int" } } 우선 사용법은 위와 같습니다. @UsefulPreferences를 명시하고, fun에 해당하는 값을 GetValue(), SetValue를 지정합니다. @ClearValues도 포함합니다. 이렇게 정의만 해두면 KSP를 통해 이 코드를 분석하는 작업 후 Impl 코드를 만들게 됩니다.
UsefulSecurity , private val preferencesStore : DataStore <Preferences>, ) : SecurityPreferences { public override fun getInt(): Flow<Int> = preferencesStore .data .mapDecrypt <Int>(usefulSecurity , Int::class) { it[SecurityPreferencesKeys .KEY_INT] } public override suspend fun setInt(`value`: Int): Unit { preferencesStore .editEncrypt (usefulSecurity , value) { preferences , encrypted -> preferences [SecurityPreferencesKeys .KEY_INT] = encrypted } } public override suspend fun clearAll(): Unit { preferencesStore .edit { it.clear() } } } KSP를 잘 다루는 것도 역시 기존 코드 결과물이 있어야 가능합니다. 이런 코드를 작성하던 반복 부분을 KSP로 만든다!라고 했을 때 도움이 될 수 있죠. 위와 같이 코드 작성해두고, 이 결과물을 KSP를 통해 생성하게 됩니다.
과정 중 process 과정에서 resolver를 이용하여 annotation 정보를 가져온다. b. Annotation 정보가 class에 포함되어 있으므로 KSClassDeclaration 정보로 변경한다. 그럼 1단계로 UsefulPreferences를 Compile time에 서칭하는 과정을 진행합니다. SymbolProcessor의 내부 함수 중 process를 override 해야 합니다. 이 함수에서 annotation을 검색하고, 클래스 내부의 구조를 파악하는 게 1단계입니다.(이 발표에서는 전부를 소개하지는 않습니다.)
.filter { ksAnnotated -> ksAnnotated is KSClassDeclaration } .map { ksAnnotated -> ksAnnotated as KSClassDeclaration } return emptyList() } process의 일부분 코드입니다. 위와 같이 검색하고, 이를 필요한 데이터구조화 작업을 진행하게 됩니다.
과정 중 process 과정에서 resolver를 이용하여 annotation 정보를 가져온다. b. Annotation 정보가 class에 포함되어 있으므로 KSClassDeclaration 정보로 변경한다. 2. 찾은 KSClassDeclaration 정보를 기반으로 Impl 클래스를 구현한다. a. file은 kt로 만든다. b. 코드 구조화를 쉽게 할 수 있도록 "com.squareup:kotlinpoet을 함께 활용하면 좋다. 찾은 KSClassDeclaration 정보를 기반으로 Impl 클래스를 구현할 것인데, 여기서는 squareup에서 제공하는 kotlinpoet를 활용합니다. kotlinpoet는 왼쪽 이미지처럼 코드 블록을 그대로 적고, 이를 파일로 출력할 수 있는데, 매우 쉽게 코드 작성이 가능하여 사용하고 있습니다.
codeGenerator, logger = logger, ) targetList.clear() } 다음 과정은 finish() 함수에서 진행하고 있습니다. 이러한 과정은 실제 적용하시면 증분 빌드를 고민할 필요가 있습니다. KSP는 기본 증분 빌드를 진행하기 때문에 증분에 따라 결과물에 문제가 생길 수 있으니 주의하셔야 합니다.
String, packageName: String, ): ClassName { return ClassName(packageName = packageName, newClassName) } 먼저 generateKeyClass 코드입니다. 필요한 정보를 넘겨주고 있고, 저는 이 KeysClass의 packageName과 ClassName을 다시 활용해야 해서 ClassName 정보에 return 시켜주고 있습니다.
FileSpec.builder(packageName = packageName, fileName = newClassName) .addImport(DataStoreConst.PREF_PREFERENCES.packageName, DataStoreConst.PREF_PREFERENCES.simpleName) .addImport(DataStoreConst.PREF_PREFERENCES_KEY.packageName, DataStoreConst.PREF_PREFERENCES_KEY.simpleName) val classSpec = TypeSpec.objectBuilder(name = newClassName) .addModifiers(KModifier.INTERNAL 새로운 파일 스펙을 정의해야 하고, 이 안에 클래스 스펙을 정의해야 합니다. 파일에는 import 구문도 함께 포함되게 되니 파일에 import 정보도 포함합니다. Class 정보는 TypeSpec. 안에 class, object, abstract class, interface를 모두 만들 수 있습니다. 이를 활용해 classSpec도 함께 구성합니다.
= key.createProperty(String::class.qualifiedName ?: "", DataStoreConst.PREF_GENERATE_STRING, fileSpec) classSpec.addProperty(propertySpec.build()) } process 과정에서 검색해둔 property 정보를 새로운 class에 정보로 담기 위한 코드 부분입니다.(전체 코드는 제 github을 참고 부탁드립니다.) N 개의 변수가 있을 수 있으니 이에 대한 정보를 차례대로 classSpec에 property 정보로 담습니다.
codeGenerator, packageName = packageName, fileName = newClassName, ) 마지막으로 fileSpec에 앞에서 추가한 Property를 포함한 정보를 build 해서 추가해 줍니다. 이런 add는 코틀린에서 여러 개도 가능합니다. 함수 추가도 가능하고요. 마지막으로 실제 파일을 쓰게 됩니다. writeTo를 이용해서 파일에 쓰게 되는 과정을 거치면 됩니다.
fileName: String, ) { val outputStream = codeGenerator.createNewFile( Dependencies.ALL_FILES, packageName, fileName ) OutputStreamWriter(outputStream, "UTF-8").use { writeTo(it) } } writeTo는 제가 만든 확장 함수라서 실제론 위와 같습니다. codeGenerator를 이용해 최종 파일로 저장합니다. 파일 저장하는 방법은 OutputStreamWriter로 저장합니다.
수 있다. • 리빌드 필요로 하지 않는 부분에 이를 활용해야 효과를 극대화할 수 있다. ◦ 앞에 코드에 DI를 위한 Module도 자동으로 만든다면 사용성이 더 좋아진다. ◦ 그리고 이렇게 만든 Module을 app에서도 자동으로 붙인다면 더 효과가 좋다. • 리빌드 없이 사용되어야 개발에 영향을 미치지 않는다. ◦ 결국 사용할 곳이 많지는 않다. ◦ DI에서 이를 활용하도록 만들어야 효과적 마지막으로 정리를 해보죠. Annotation을 활용할 수 있는 방법은 Runtime, Compile time 모두에서 가능합니다. KSP의 경우 정말 효율 높게 사용하려면 rebuild 과정이 없도록 만들어야 효과적으로 활용할 수 있습니다. 단순히 결과물만 만들어 효율을 높일 수 있진 않습니다. 예를 들면 startActivity를 자동으로 만들기 위해 rebuild 과정을 거치는 것보단 그냥 코드 작성하는 게 더 효율적일 수 있습니다. 결국 리빌드 없이 만들어야 하는데 이 부분을 고려치 않으면 오히려 개발 과정이 귀찮아질 수 있습니다.