수천 줄의 코드 작성을 줄인 KSP 도입 과정

KSP를 사용하면서 고민한 포인트들

Dean • Android Developer

  • 테크 인사이트

안녕하세요! 채널톡 안드로이드 엔지니어 딘입니다.

이번 글에서는 저희 팀에서 KSP를 활용하여 화면 전환 시의 데이터 전달의 안정성을 높이고 수천 줄의 보일러 플레이트 코드를 줄여내기까지의 고민과 도입 과정을 다뤄보겠습니다.


기본적인 전달 방식의 문제점

Android에서 Intent를 통해 Activity를 전환할 때는 아래와 같이 코드를 작성할 수 있습니다.

Plaintext
class MainActivity : Activity() {  
    fun onCreate() {
        val newIntent = Intent(this, SubActivity::class.java)
        newIntent.putExtra(SubActivity.EXTRA_USER_ID, userId)

        startActivity(newIntent)
    }
}

class SubActivity : Activity() {  
    var userId: Int = 0

    fun onCreate() {
        userId = intent.getIntExtra(EXTRA_USER_ID, 0)
    }

    companion object {
        const val EXTRA_USER_ID = "EXTRA_USER_ID"
    }
}

이러한 사용 방법에는 3가지 문제점이 있습니다.

  1. 올바른 Key와 Type을 사용했는지 확인해야 합니다.

  2. extra의 개수가 늘어날수록 TargetActivity에 어떤 extra를 전달해야 할지 파악하기 어려워 값을 누락하기 쉽습니다.

  3. Java base의 코드이므로 int 같은 primitive 타입은 NPE의 가능성을 갖고 있습니다. 그래서 항상 기본값을 넣도록 설계되어 있기 때문에 의도를 알기 어려운 상수를 넣어주어야만 합니다.


기본적인 전달 방식의 개선 방법

총 3단계의 개선 과정을 순차적으로 소개드리겠습니다.

1) TargetActivity에서 Key값을 관리하기

Plaintext
class MainActivity : Activity() {  
    override fun onCreate(savedInstanceState: Bundle?) {
        val newIntent = SubActivity.getIntent(this, userId)

        startActivity(newIntent)
    }
}

class SubActivity : Activity() {  
    var userId: Int = 0

    override fun onCreate(savedInstanceState: Bundle?) {
        userId = intent.getIntExtra(EXTRA_USER_ID, 0)
    }

    companion object {
        private const val EXTRA_USER_ID = "EXTRA_USER_ID"

        fun getIntent(context: Context, userId: Int): Intent {
            val newIntent = Intent(context, SubActivity::class.java)
            newIntent.putExtra(EXTRA_USER_ID, userId)

            return newIntent
        }
    }
}

Extra를 SubActivity에서만 관리하도록 리팩토링했습니다.

이렇게 되면 어떤 Extra를 전달해야 할지 파악하기 쉬워졌지만, 범위는 줄었어도

여전히 Key와 Type에 신경을 써야 하며 의도를 알 수 없는 상수를 정의해야 하는 문제가 남아있습니다.

2) ActivityBuilder, 채널톡 프로젝트에서 사용하던 방법

앞서 제기된 문제들을 해결하기 위해서

채널톡 프로젝트에서는 ActivityBuilder라는 시스템으로 Extra를 관리했습니다.

다음 코드는 핵심 코드의 일부입니다.

Plaintext
class MainActivity : Activity() {  
    override fun onCreate(savedInstanceState: Bundle?) {
        SubActivityBuilder(this)
            .setUserId(userId)
            .start()
    }
}

class SubActivity : BaseActivity() {  
    override val model = SubActivityBuilderModel()
    val userId: Int
        get() = model.userId
}

abstract class ActivityBuilder<T : Class<Activity>, M : BaseBuilderModel>(context) {  
    protected val intent = Intent(context, T::class.java)

    fun start() {
        context.startActivity(intent)
    }
}

class SubActivityBuilder(  
    context: Context,
) : ActivityBuilder<SubActivity, SubActivityBuilderModel>(context, SubActivityBuilderModel()) {
    fun setUserId(userId: Int): SubActivityBuilder {
        model.setUserId(userId)
        return this
    }
}

class SubActivityBuilderModel: BaseBuilderModel {  
    var userId: Int? = null

    override fun applyExtras() {
        intent.putExtra("extra_user_id", userId)
    }

    override fun getExtras() {
        if (intent.hasExtra("extra_user_id")) {
            userId = intent.getIntExtra("extra_user_id", 0)                
        }
    }
}

예시 코드의 setUserId 함수를 정의하는 것처럼 ActivityBuilder 시스템은 내부에서 필요한 로직이 모두 처리되기 때문에 Activity에서는 위에서 나열한 문제들을 해결할 수 있게 됐습니다.

하지만, Activity가 추가될 때마다 너무 많은 양의 보일러플레이트 코드가 유사한 형태로 늘어나게 됐습니다.

이 상황을 해결하기 위해 채널톡 안드로이드 팀은 ActivityBuilder를 직접 구현하지 않고 자동으로 만들어준다면 팀 생산성을 향상 시킬 수 있다고 판단하여 리서치를 시작했습니다.

3) KSP를 활용한 코드 자동 생성 ✅

https://kotlinlang.org/docs/ksp-overview.html

반복되는 ActivityBuilder를 자동으로 생성해 주기 위해 KSP(Kotlin Symbol Processing)를 활용하기로 결정했습니다.

KSP는 Kotlin 기반의 컴파일러 플러그인으로 Java의 Annotation Processor와 유사한 듯 보이지만 KSP는 Annotation만 읽을 수 있는 것이 아닌 Kotlin으로 이루어진 구문을 직접 해석할 수 있습니다.

이 특징으로 인해 Java Annotation Processor 기반으로 만들어진 kapt는 Processor가 Kotlin 코드를 읽는데에 큰 비용을 지불해야하는 반면, KSP는 좀 더 적은 비용으로 빠른 성능을 갖고 있으며 Kotlin 친화적인 기능을 제공하므로 저희 팀의 사례에 적합하다고 생각했습니다.

필수적으로 사용해야 하는 건 아니지만 Kotlin 파일을 쉽게 생성할 수 있도록 도와주는 KotlinPoet 라이브러리를 함께 사용했습니다.

KSP와 KotlinPoet 모두 문서화가 매우 잘 되어 있어서 참고하기 정말 좋았습니다.


KSP 청사진

우선 어떤 형태로 ActivityBuilder를 대체할 수 있을지 구상을 해보았습니다.

결과물은 다음 3가지를 전제했습니다.

  • KSP 모듈은 App 모듈을 알지 못합니다.

  • extra를 넘길 때 올바른 key와 type인지 신경 쓰지 않을 수 있어야 합니다.

  • extra의 Nullability를 보장합니다.

팀원들과의 몇몇 논의 끝에 API는 다음과 같이 결정했습니다.

Plaintext
class MainActivity : Activity() {  
    override fun onCreate(savedInstanceState: Bundle?) {
        SubActivityLauncher.create(baseEntity)
            .launch(userId) { optionalExtras ->
                optionalExtras.userName = "userName"
            }
    }
}

@LauncherActivity
class SubActivity : BaseActivity() {  
    @LauncherExtra
    var userId: Int = 0

    @OptionalLauncherExtra
    var userName: String? = null
}

@BaseLauncherActivity
abstract class BaseActivity : Activity() {  
    @BaseLauncherExtra
    lateinit var baseEntity: String
}

KSP는 Annotation을 기반으로 코드를 자동 생성하여, Property의 타입에 맞게 Launcher 함수의 매개변수에 자동으로 포함시킵니다.

위 예시에서는 MainActivity에서 SubActivityLauncher를 사용하여 SubActivity를 시작합니다.

SubActivity는 userId와 선택적으로 userName을 매개변수로 받습니다.

BaseActivity에서는 baseEntity를 공통 속성으로 사용합니다.

이러한 KSP의 자동 생성 기능 덕분에, 이 모든 과정이 간편하고 런타임 에러 없이 이루어질 수 있습니다.

각각의 Annotation은 다음과 같은 의미를 갖습니다.

  • BaseLauncherActivity

    • Base가 되는 Activity임을 마크하여 분석할 클래스 계층이라는 것을 KSP에 알립니다.

  • BaseLauncherExtra

    • Launcher.create 함수에 포함될 매개변수로 지정합니다.

  • LauncherActivity

    • ActivityLauncher를 생성할 대상을 지정합니다.

  • LauncherExtra

    • 생성된 ActivityLauncher를 launch할 때 포함될 매개변수로 지정합니다.

  • OptionalLauncherExtra

    • 생성된 ActivityLauncher를 launch할 때 선택적으로 포함될 매개변수로 지정합니다.


생성될 코드 정의

KSP로 생성되길 바라는 목표 코드를 먼저 정의해보았습니다. 주요 클래스는 총 3가지입니다.

  • ActivityLauncher

    • Intent 객체를 생성합니다.

    • 생성된 Intent 객체에 BaseExtras를 적용합니다.

    • ActivityLauncherWrapper를 생성합니다.

  • ActivityLauncherWrapper

    • 필요한 Extras를 받은 뒤 startActivity를 실행합니다.

  • ActivityExtraDelegator

    • intent에 있는 Extras를 꺼내서 Activity의 property에 적용합니다.

세부 구현은 조금 더 복잡하지만 간략화하면 다음과 같습니다.

Plaintext
object SomeActivityLauncher {  
    fun create(context: Context, baseExtra: String): SomeActivityLauncherWrapper {
        val intent = android.content.Intent(context, SomeActivity::class.java)

        intent.putExtra("baseExtra", baseExtra)

        return SomeActivityLauncherWrapper(
            context,
            intent,
        )
    }
}

class SomeActivityLauncherWrapper(  
    override val context: Context,
    override val intent: Intent,
) : ActivityLauncherWrapper { // 이 interface는 하단에서 설명합니다.
    fun launch(userName: String): Unit {
        intent.putExtra("userName", userName)

        context.startActivity(intent)
    }
}

// BaseActivity.onCreate에서 ExtraDelegator를 관리하는 객체를 통해 setup함수를 호출합니다.
class SomeActivityExtraDelegator {  
    fun setup(activity: SomeActivity): Unit {
        activity.baseExtra = activity.intent.getStringExtra("baseExtra")
        activity.userName = activity.intent.getStringExtra("userName")
    }
}

SymbolProcessor

KSP를 사용하기 위해서는 SymbolProcessorProvider의 존재를 Compiler에게 알려야 합니다.

먼저 하단의 예시 코드와 같이 SymbolProcessorProvider를 상속받는 클래스를 작성합니다.

그다음 작성한 클래스를 하단에 명시된 경로에 파일을 생성한 후 QualifiedName을 작성하면 Compiler는 SymbolProcessorProvider를 인식할 수 있게 됩니다.

Plaintext
class LauncherSymbolProcessorProvider : SymbolProcessorProvider {  
    override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor {
        return LauncherSymbolProcessor(
            environment.codeGenerator,
        )
    }
}

class LauncherSymbolProcessor(  
    private val codeGenerator: CodeGenerator,
) : SymbolProcessor {
    override fun process(resolver: Resolver): List<KSAnnotated> {
        // process 함수는 여러 번에 걸쳐서 호출될 수 있습니다.
        // 이 함수에서의 return은 Resolver가 가져온 정보를
        // 현재 회차에서 consume하지 않고 이후 호출로 연기하는 것을 의미합니다.
        // https://kotlinlang.org/docs/ksp-multi-round.html
        return emptyList()
    }

    override fun finish() {
        // process가 모두 성공하면 호출됩니다.
        // exception이 발생하면 이 함수가 아닌 onError 함수가 호출됩니다.
    }
}

SymbolProcessor의 세부 구현은 기존 코드에서 어노테이션을 가져오는 Scan 과정과 그 정보를 통해 직접 코드를 생성하는 Generate 과정으로 분류하여 구현하기로 결정했습니다.

  • LauncherScanner

  • LauncherGenerator


모델링

정의된 시스템대로 동작하게 하기 위해 두 가지 모델을 정의했습니다.

  • LauncherStructure

    • 생성될 클래스의 기반이 될 클래스의 정보를 담습니다.

    • 기반 클래스의 정보와 ExtraProperty 모델을 갖습니다.

    • 어떤 부모로부터 파생되었는지 알기 위해 baseLauncherStructure를 포함합니다.

  • BaseLauncherStructure

    • 현재 계층이 갖고 있는 모든 baseEntityExtra를 포함합니다.

    • 기반 클래스의 정보와 ExtraProperty 모델을 갖습니다.

    • 같은 계층에 있는 경우 baseEntityExtra를 넘기는 코드를 생략할 수 있도록 하기 위해 상속 hierarchy를 포함합니다.

어떤 순서로 KSP의 모델을 해석하여 LauncherStructure를 생성하는지 표현한 그림입니다. Prefix에 KS가 있는 것은 KSP의 모델이고, 주황색 글씨로 표현된 부분은 필요한 정보를 정의한 모델에 담는 부분입니다.

Extra의 타입을 올바르게 전달할 수 있도록 Property관련 모델도 정의했습니다.

  • ExtraProperty

    • 값을 전달할 프로퍼티의 정보를 담습니다.

    • 기반 프로퍼티의 정보와 TypeProperty 모델을 갖습니다.

  • TypeProperty

    • Extra의 Type 정보를 담습니다.


LauncherScanner

Annotation을 기반으로 symbol을 찾아오기 위해서는 Resolver.getSymbolsWithAnntation(annotationName: String)함수를 사용할 수 있습니다.

찾아온 BaseLauncherActivityLauncherActivity 모두 class target이므로 symbol 들을 KSClassDeclaration 타입으로 필터링했습니다.

Plaintext
fun scanBaseLauncherStructure(resolver: Resolver): List<BaseLauncherStructure> {  
    return resolver.getSymbolsWithAnnotation(BaseLauncherActivity::class.java.canonicalName)
        .filterIsInstance<KSClassDeclaration>()
        .map(::createBaseLauncherStructure)
        .toList()
}

fun scanLauncherStructure(resolver: Resolver, baseLauncherStructures: List<BaseLauncherStructure>): List<LauncherStructure> {  
    return resolver.getSymbolsWithAnnotation(LauncherActivity::class.java.canonicalName)
        .filterIsInstance<KSClassDeclaration>()
        .map { createLauncherStructure(it, baseLauncherStructures) }
        .toList()
}

KSClassDeclaration 타입은 class에 관련된 정보를 가져올 수 있습니다.

저희가 만들 ActivityLauncher 시스템에서는 property만 사용하도록 API를 구성했기 때문에 getAllProperties() 함수를 통해 프로퍼티를 가져왔습니다.

일반적으로 의도하지 않은 대로 사용할 일은 거의 없겠지만 팀원들의 실수를 방지하기 위해서는 몇 가지 정책을 수립할 필요가 있었습니다.

실수할 만한 부분이 무엇이 있을지 고민하여 다음 3가지 제약을 정의했습니다.

제약을 어길 시 exception을 throw해서 build-time에 문제를 파악할 수 있도록 했습니다.

  • LauncherExtra와 OptionalLauncherExtra는 동시에 적용될 수 없습니다.

  • OptionalLauncherExtra는 매개변수를 넘겨받지 못할 가능성이 있기 때문에 UninitializedException가 발생할 수 있어 런타임 안정성을 위해 OptionalLauncherExtra는 lateinit 키워드를 붙일 수 없습니다.

  • 값을 주입받기 위해서 Extra는 무조건 public var 여야만 합니다.

Plaintext
private fun createLauncherStructure(  
    declaration: KSClassDeclaration,
    baseLauncherStructures: List<BaseLauncherStructure>,
): LauncherStructure {
    // ...
    declaration.getAllProperties()
        .forEach { property ->
            val isRequired = property.annotations.contains(LauncherExtra::class)
            val isOptional = property.annotations.contains(OptionalLauncherExtra::class)

            if (isRequired && isOptional) {
                throw LauncherException("Only one Extra annotation can be applied to property\n${property.qualifiedName?.asString()}")
            }

            if (isOptional && property.modifiers.contains(Modifier.LATEINIT)) {
                throw LauncherException("OptionalExtra is not allow lateinit modifier\n${property.qualifiedName?.asString()}")
            }

            if ((isRequired || isOptional) && !(property.isPublic() && property.isMutable)) {
                throw LauncherException("Extra should be mutable public property\n${property.qualifiedName?.asString()}")
            }

            // ...
        }

    // ...

    return LauncherStructure(/**/)
}

LauncherGenerator

Plaintext
interface LauncherGenerator {  
    fun makeFile(launcherStructure: LauncherStructure): FileSpec?
}

하나의 class는 하나의 File만을 생성하도록 interface를 정의했습니다.

또한, 만들 필요가 없는 파일이 있는 경우에는 파일을 생성하지 않게 할 수 있도록 return type을 nullable로 정의했습니다.

가독성을 위해 내부 함수를 실제 class를 작성하듯 property와 function을 generate하는 함수를 모두 분리하고 postfix로 어떤 유형의 코드가 generate되는지 표현해 두었습니다.

Plaintext
class ActivityLauncherOptionalExtraGenerator : LauncherGenerator {  
    override fun makeFile(launcherStructure: LauncherStructure): FileSpec? {
        if (launcherStructure.optionalExtras.isEmpty()) {
            return null
        }

        val generatedClassName = LauncherNameUtil.getOptionalExtraName(launcherStructure.className)
        val launcherPackageName = LauncherNameUtil.getLauncherPackageName(launcherStructure.packageName)

        return FileSpec.builder(launcherPackageName, generatedClassName)
            .addOptionalLauncherExtraClass(launcherStructure)
            .build()
        }

    private fun FileSpec.Builder.addOptionalLauncherExtraClass(launcherStructure: LauncherStructure): FileSpec.Builder {
        val optionalLauncherExtra = TypeSpec.classBuilder(LauncherNameUtil.getOptionalExtraName(launcherStructure.className))
            .addOptionalProperties(launcherStructure)
            .addPutOptionalExtrasFunction(launcherStructure)
            .build()

        return this.addType(optionalLauncherExtra)
    }

    private fun TypeSpec.Builder.addPutOptionalExtrasFunction(launcherStructure: LauncherStructure): TypeSpec.Builder {
        val putOptionalExtras = FunSpec.builder("putOptionalExtras")
            .addParameter("intent", ClassName.bestGuess(AndroidName.INTENT))
            .apply {
                launcherStructure.optionalExtras.forEach {
                // ...
                }
            }
            .build()

        return this.addFunction(putOptionalExtras)
    }
}

패키지나 클래스 이름은 재사용되므로 관리하기에 용이하도록 별도 파일에 정의해두었습니다. KSP 모듈은 순수 Kotlin 모듈이므로 Android 클래스를 쉽게 사용할 수 있게 AndroidName도 함께 정의해두었습니다.

Plaintext
internal object LauncherNameUtil {  
    fun getActivityLauncherName(className: String): String {
        return "${className}Launcher"
    }

    // ...
}

object AndroidName {  
    const val CONTEXT = "android.content.Context"
    const val ACTIVITY = "android.app.Activity"
    // ...
}

내부적으로는 Intent를 통해 값을 넘기므로 Intent에서 제공하는 함수들을 모두 정의했습니다.

intent에서 정의되지 않은 타입을 LauncherExtra로 정의한 경우 에러가 발생하도록 했습니다.

Plaintext
fun ExtraProperty.parseGetExtraFunction(): String {  
    if (type.qualifiedName == "kotlin.collections.ArrayList") {
        // typeParameters는 Generics 목록으로, KSType의 arguments를 통해 확인할 수 있습니다.
        // ArrayList의 경우 한 개가 무조건 존재하므로 first()를 통해 Generic Type을 가져오도록 했습니다.
        val typeParametersQualifiedName = typeParameters
            .first()
            .classHierarchy
            .map { it.qualifiedName?.asString() }

        val typeName = typeParameters.first().qualifiedName

        return when {
            typeParametersQualifiedName.contains("android.os.Parcelable") -> "getParcelableArrayListExtraInternal(\"$name\", $typeName::class.java)"
            typeParametersQualifiedName.contains("kotlin.Int") -> "getIntegerArrayListExtra(\"$name\")"
            // ...

            else -> throw LauncherException("Unsupported Extra Type!! $name: ${type.qualifiedName}")
        }
    }

    val typeQualifiedNames = type.classHierarchy.map { it.qualifiedName?.asString() }

    return when {
        type.isEnum || typeQualifiedNames.contains("java.io.Serializable") -> "getSerializableExtraInternal(\"$name\", ${type.qualifiedName}::class.java)"
        typeQualifiedNames.contains("android.os.Parcelable") -> "getParcelableExtraInternal(\"$name\", ${type.qualifiedName}::class.java)"
        typeQualifiedNames.contains("kotlin.String") -> "getStringExtra(\"$name\")"
        typeQualifiedNames.contains("kotlin.Int") -> "getIntExtra(\"$name\", 0)"
        // ...

        else -> throw LauncherException("Unsupported Extra Type!! $name: ${type.qualifiedName}")
    }
}

기능 확장

구현한 Launcher 모듈은 변경에는 닫혀있고 확장에는 열려있도록 설계했습니다.

이를 달성하기 위해서 기능을 확장할 수 있는 두 가지 방법을 제공했습니다.

1) ActivityLauncher.Interceptor

Application에서 Interceptor를 추가할 수 있는 코드를 구현했습니다.

이를 통해 화면을 전환할 때 항상 동작해야 하는 로직을 추가할 수 있게 되었습니다.

Plaintext
// module(:app)
class ChannelApplication : Application() {  
    override fun onCreate() {
        // ...
        ActivityLauncher.addInterceptor(ChannelActivityLauncherInterceptor())
    }
}

// generated by ksp
object ActivityLauncher {  
    private val _interceptors = ArrayList<Interceptor>()
    val interceptors: List<Interceptor>
        get() = _interceptors

    fun addInterceptor(interceptor: Interceptor) {
        _interceptors.add(interceptor)
    }

    interface Interceptor {
        fun intercept(element: LauncherElement)
    }
}

data class LauncherElement(  
    val context: android.content.Context,
    val intent: android.content.Intent,
)

// some activity launcher
public object SomeActivityLauncher {  
  public fun create(context: Context): SomeActivityLauncherWrapper {
    val intent = android.content.Intent(context, SomeActivity::class.java)

    ActivityLauncher.interceptors.forEach {
      it.intercept(LauncherElement(context, intent))
    }

    return SomeActivityLauncherWrapper(
        context,
        intent,
    )
  }
}

2) ActivityLauncherWrapper

Intent를 갖고 있는 단계인 ActivityLauncherWrapper interface를 추가했습니다.

아래 예시의 코드처럼 intent에 추가적인 정보를 넣고 싶을 때는 이 interface에 확장 함수를 추가해서 기능을 확장할 수 있도록 했습니다.

Plaintext
// module(:app)
fun <T : ActivityLauncherWrapper> T.setFlag(flag: Int): T {  
    intent.flags = flag
    return this
}

fun <T : ActivityLauncherWrapper> T.setAnimation(animation: Animation): T {  
    // set animation
    return this
}

class MainActivity : Activity() {  
    override fun onCreate(savedInstanceState: Bundle?) {
        // 확장함수는 이런 형태로 사용할 수 있게 됩니다.
        SomeActivityLauncher.create(context)
            .setFlag(flag)
            .setAnimation(animation)
            .launch(extras)
    }
}

// generated by ksp
public interface ActivityLauncherWrapper {  
    public val context: Context
    public val intent: Intent
}

LauncherSymbolProcessor

위의 내용들을 종합한 SymbolProcessor는 다음과 같은 형태가 되었습니다.

Plaintext
class LauncherSymbolProcessor(  
    private val codeGenerator: CodeGenerator,
) : SymbolProcessor {

    private val annotationScanner = AnnotationScanner()
    private val launcherGenerators = listOf(
        ActivityLauncherGenerator(),
        // ...
    )

    override fun process(resolver: Resolver): List<KSAnnotated> {
        val baseLauncherStructures = annotationScanner.scanBaseLauncherStructure(resolver)
        val launcherStructures = annotationScanner.scanLauncherStructure(resolver, baseLauncherStructures)

        launcherStructures.forEach { structure ->
            launcherGenerators.forEach { generator ->
                val file = generator.makeFile(structure)

                codeGenerator.createNewFile(file)
            }
        }

        return emptyList()
    }

    override fun finish() {
        // process는 여러 번 호출될 가능성을 갖고 있으므로
        // 모든 프로세스가 끝난 이후 생성할 파일은 이쪽에서 생성했습니다.
    }
}

fun CodeGenerator.createNewFile(fileSpec: FileSpec?) {  
    fileSpec ?: return

    createNewFile(
        Dependencies.ALL_FILES,
        fileSpec.packageName,
        fileSpec.name,
    ).use { output ->
        OutputStreamWriter(output).use { writer ->
            fileSpec.writeTo(writer)
        }
    }
}

Launcher 모듈이 추가되면서 화면 전환을 위해서 개발자가 직접 작성하는 코드가 없어지게 됐습니다.

또한 Extra를 관리할 상수도 정의할 필요가 없고 넘겨야 할 타입조차 신경 쓰지 않게 되었습니다.

build-time에 코드가 생성되고 논리적으로 맞지 않는다면 에러가 발생하기 때문에 값을 넘기는 과정에서는 절대로 Runtime 에러가 발생하지 않게 되어 앱 안정성도 향상시킬 수 있었습니다.


마무리하며

이 포스팅을 통해 KSP를 사용하면서 고민한 포인트들을 정리해 보았는데요

위 과정들로 수천 줄의 코드를 자동 생성하여 개발 생산성을 향상 시킬 수 있었습니다.

KSP를 설계하거나 도입을 고려하는 분들께 사람들에게 도움이 되었으면 좋겠습니다.

긴 글 읽어주셔서 감사합니다.

We Make a Future Classic Product

채널팀과 함께 성장하고 싶은 분을 기다립니다

사이트에 무료로 채널톡을 붙이세요.

써보면서 이해하는게 가장 빠릅니다

회사 이메일을 입력해주세요