안녕하세요. 강남언니에서 안드로이드 앱 개발을 담당하는 Thomas 입니다.

Android 개발을 하면서, 수많은 Annotation 을 아무렇지 않게 사용하고있었는데, 도대체 Annotation 뒤에서 무슨 일이 일어나고있는 것인지 궁금증이 생겼습니다. 그래서 리햅기간을 이용해 Annotation 을 한번 파보고, 간단히 데이터모델의 필드 유효성 판단을 하는 Annotation 을 만들어보려 합니다.

이전 글에서 Annotation 에 대한 대략적인 설명과, Reflection 을 이용하는 Annotation 을 만들어보았습니다.
Reflection 을 이용해 Custom Annotation 을 만들경우, 새로운 Annotation 을 추가할 때마다 복잡한 Reflection 관련 코드를 작성해야 하고 validating 로직을 이해하기도 어렵고, 성능면에서도 떨어지기 때문에 이번에는 Code Generation 을 이용해 Annotation Processing 을 해보려 합니다.


모듈 만들기

file → new → new module →Java or Kotlin Libary 로
datavalidation-annotation 과 datavalidation-processor 모듈을 만들어줍니다.

datavalidation-annotation 모듈

datavalidation-annotation 모듈은 annotation 클래스 들을 정의해 놓은 모듈 입니다.

먼저 build.gradle 파일은 아래와 같습니다.

apply plugin: 'kotlin'

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
}

sourceCompatibility = "7"
targetCompatibility = "7"

아래는 validation 용도의 annotation 들을 정의한 파일입니다.

package com.april21dev.datavalidation_annotation.annotation

/**
 * Model Class Annotation
 * Any classes that need validation should be annotated with this
 */
@Target(AnnotationTarget.CLASS)
annotation class DataValidation

/**
 * Nested field
 * Nested model fields should be annotated with this. If not, ignored
 */
@Target(AnnotationTarget.FIELD)
annotation class Nested

/**
 * Not null constraint
 */
@Target(AnnotationTarget.FIELD)
annotation class NotNull(
    val tag: String
)

/**
 * String field minimum length constraint
 */
@Target(AnnotationTarget.FIELD)
annotation class MinLength(
    val length: Int,
    val tag: String
)

/**
 * String field maximum length constraint
 */
@Target(AnnotationTarget.FIELD)
annotation class MaxLength(
    val length: Int,
    val tag: String
)

/**
 * String field regex match constraint
 */
@Target(AnnotationTarget.FIELD)
annotation class Regex(
    val regex: String,
    val tag: String
)

/**
 * Number minimum value
 */
@Target(AnnotationTarget.FIELD)
annotation class MinValue(
    val value: Long,
    val tag: String
)

/**
 * Number maximum value
 */
@Target(AnnotationTarget.FIELD)
annotation class MaxValue(
    val value: Long,
    val tag: String
)

DataValidation 은 타겟이 CLASS 인 annotation 으로, validation 작업을 할 모델 클래스에 달아주는 용도입니다.
Nested 는 모델의 필드로 또 다른 모델을 갖고 있고 해당 모델 역시 validation 이 필요할 경우에 달아주는 용도입니다.
나머지 annotation 들은 의도가 자명하다고 판단되어 설명하지 않아도 될것 같아, 생략하겠습니다.

아래는 모델에 validation 작업을 했을 때 리턴할 모델 입니다.

data class ValidationResult(
    var isValid: Boolean = true,
    val invalidFieldNameAndTags: MutableList<FieldNameAndTag> = mutableListOf()
)
data class FieldNameAndTag(
    val fieldName: String,
    val tag: String
)

isValid 필드는 모든 필드가 유효한지 여부를 체크하는 용도이고, fieldName 과 tag 는 혹시 필요할 까 해서 만든 필드로, 유효하지 않은 필드명과 사용자가 annotation 에 파라미터로 넣은 부가 설명용 필드입니다.

datavalidation-processor 모듈

datavalidation-processor 모듈은 빌드시 annotation 이 달린 클래스를 찾아 code generation 을 해주기 위한 모듈입니다.

build.gradle 파일은 아래와 같습니다.

apply plugin: 'kotlin'
apply plugin: 'kotlin-kapt'

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation project(':datavalidation-annotation')
    implementation 'com.squareup:kotlinpoet:1.5.0'
    implementation "com.google.auto.service:auto-service:1.0-rc6"
    kapt "com.google.auto.service:auto-service:1.0-rc6"
}

sourceCompatibility = "7"
targetCompatibility = "7"

custom annotation 이 담긴 datavalidation-annotation 모듈을 참조해주었습니다.
kotlinpoet 은 kotlin 파일을 편하게 만들어주기 위한 라이브러리 입니다.
auto-service 는 어노테이션 프로세서를 컴파일러에 등록하는 작업을 자동으로 해주기 위한 라이브러리입니다. (해당 라이브러리 없이 하려면 META-INF 파일을 만들어 몇가지 설정을 해줘야 한다는데, 굳이 시도는 안해봤습니다)

아래는 실제 Processor 코드입니다.

@AutoService(Processor::class)
class Processor : AbstractProcessor() {

    companion object {
        const val KAPT_KOTLIN_GENERATED_OPTION_NAME = "kapt.kotlin.generated"
        val fileBuilder =
            FileSpec.builder("com.datavalidation.generated", "DataValidationExtension")
    }

    override fun getSupportedAnnotationTypes(): MutableSet<String> {
        return mutableSetOf(
            DataValidation::class.java.name,
            Nested::class.java.name,
            NotNull::class.java.name,
            MinLength::class.java.name,
            MaxLength::class.java.name,
            MinValue::class.java.name,
            MaxValue::class.java.name,
            Regex::class.java.name
        )
    }

    override fun getSupportedSourceVersion(): SourceVersion {
        return SourceVersion.latestSupported()
    }

    //called twice or more
    override fun process(
        annotations: MutableSet<out TypeElement>?,
        roundEnv: RoundEnvironment
    ): Boolean {

        val classElements = roundEnv.getElementsAnnotatedWith(DataValidation::class.java)

        if (!checkElementType(ElementKind.CLASS, classElements)) return false

        classElements.forEach { fileBuilder.addFunction(makeValidateFunction(it)) }

        fileBuilder.addImport(FieldNameAndTag::class.java, "")
        val kaptKotlinGeneratedDir = processingEnv.options[KAPT_KOTLIN_GENERATED_OPTION_NAME]
        fileBuilder.build().writeTo(File(kaptKotlinGeneratedDir))
        return true
    }

    private fun makeValidateFunction(classElement: Element): FunSpec {
        val validateFunSpec = FunSpec.builder("validate")
            .receiver(classElement.asType().asTypeName())
            .returns(ValidationResult::class)
            .addStatement("val result = %T()", ValidationResult::class.java)

        val fieldElement = classElement.enclosedElements
        fieldElement.forEach {
            val nonNull = it.getAnnotation(NotNull::class.java)
            val minLength = it.getAnnotation(MinLength::class.java)
            val maxLength = it.getAnnotation(MaxLength::class.java)
            val minValue = it.getAnnotation(MinValue::class.java)
            val maxValue = it.getAnnotation(MaxValue::class.java)
            val regex = it.getAnnotation(Regex::class.java)
            val nested = it.getAnnotation(Nested::class.java)

            nonNull?.let { anno ->
                validateFunSpec.addComment("NonNull Check")
                validateFunSpec.beginControlFlow("if(${it.simpleName} == null)")
                validateFunSpec.addStatement("result.isValid = false")
                validateFunSpec.addStatement("result.invalidFieldNameAndTags.add(${createFieldNameAndTag(it.simpleName, anno.tag)})")
                validateFunSpec.endControlFlow()
            }

            minLength?.let { anno ->
                validateFunSpec.addComment("Minimum Length Check")
                validateFunSpec.beginControlFlow("if(${it.simpleName} == null || ${it.simpleName}.length < ${anno.length})")
                validateFunSpec.addStatement("result.isValid = false")
                validateFunSpec.addStatement("result.invalidFieldNameAndTags.add(${createFieldNameAndTag(it.simpleName, anno.tag)})")
                validateFunSpec.endControlFlow()
            }

            maxLength?.let { anno ->
                validateFunSpec.addComment("Maximum Length Check")
                validateFunSpec.beginControlFlow("if(${it.simpleName} == null || ${it.simpleName}.length > ${anno.length})")
                validateFunSpec.addStatement("result.isValid = false")
                validateFunSpec.addStatement("result.invalidFieldNameAndTags.add(${createFieldNameAndTag(it.simpleName, anno.tag)})")
                validateFunSpec.endControlFlow()
            }

            minValue?.let { anno ->
                validateFunSpec.addComment("Minimum Value Check")
                validateFunSpec.beginControlFlow("if(${it.simpleName} == null || ${it.simpleName}.toLong() < ${anno.value})")
                validateFunSpec.addStatement("result.isValid = false")
                validateFunSpec.addStatement("result.invalidFieldNameAndTags.add(${createFieldNameAndTag(it.simpleName, anno.tag)})")
                validateFunSpec.endControlFlow()
            }

            maxValue?.let { anno ->
                validateFunSpec.addComment("Minimum Value Check")
                validateFunSpec.beginControlFlow("if(${it.simpleName} == null || ${it.simpleName}.toLong() > ${anno.value})")
                validateFunSpec.addStatement("result.isValid = false")
                validateFunSpec.addStatement("result.invalidFieldNameAndTags.add(${createFieldNameAndTag(it.simpleName, anno.tag)})")
                validateFunSpec.endControlFlow()
            }

            regex?.let { anno ->
                validateFunSpec.addComment("Regex Match Check")
                validateFunSpec.beginControlFlow("if(${it.simpleName} == null || !%S.toRegex().matches(${it.simpleName}))", anno.regex)
                validateFunSpec.addStatement("result.isValid = false")
                validateFunSpec.addStatement("result.invalidFieldNameAndTags.add(${createFieldNameAndTag(it.simpleName, anno.tag)})")
                validateFunSpec.endControlFlow()
            }

            nested?.let { _ ->
                validateFunSpec.addComment("Nested Check")
                validateFunSpec.beginControlFlow("if(${it.simpleName} != null)")
                validateFunSpec.addStatement("val nestedValidation = ${it.simpleName}.validate()")
                validateFunSpec.beginControlFlow("if(!nestedValidation.isValid)")
                validateFunSpec.addStatement("result.isValid = false")
                validateFunSpec.addStatement("result.invalidFieldNameAndTags.addAll(nestedValidation.invalidFieldNameAndTags)")
                validateFunSpec.endControlFlow()
                validateFunSpec.endControlFlow()
            }
        }

        return validateFunSpec.addStatement("return result").build()
    }

    private fun checkElementType(kind: ElementKind, elements: Set<Element>): Boolean {
        if (elements.isEmpty()) return false

        elements.forEach {
            if (it.kind != kind) {
                printMessage(
                    Diagnostic.Kind.ERROR, "Only ${kind.name} Are Supported", it
                )
                return false
            }
        }
        return true
    }

    private fun printMessage(kind: Diagnostic.Kind, message: String, element: Element) {
        processingEnv.messager.printMessage(kind, message, element)
    }

    private fun createFieldNameAndTag(fieldName: Name, tag: String): String {
        return "FieldNameAndTag(\"$fieldName\", \"$tag\")"
    }
}

먼저 AbstractProcessor 를 상속받고 필수 메소드들을 오버라이딩 해주었습니다.

@AutoService(Processor::class) 는 위에서 말씀드린 auto-service 라이브러리의 기능으로 해당 어노테이션 하나만으로 자동으로 컴파일러에 프로세서를 등록해줍니다.

kapt.kotlin.generatedapp\build\generated\source\kaptKotlin 과 대치되는 키로 파일을 만들면 저장할 위치를 나타냅니다.

getSupportedAnnotationTypes() 메소드 에서 해당 Processor 가 처리할 annotation 들을 알려줍니다.

getSupportedSourceVersion() 메소드에서는 지원할 소스코드 버전을 알려줍니다.

process() 는 코드를 만드는 로직들이 들어갈 곳입니다.
RoundEnvironment 를 통해 원하는 엘리먼트 들을 추출할 수 있습니다.

전체적인 flow 는

  • DataValidation 이 달린 클래스 엘리먼트들을 추출하고
  • 각 엘리먼트에 대해 fun class.validate() 함수들을 생성할 파일에 작성해주었습니다.

app 모듈

앱 모듈에서는 어노테이션을 사용해보려 합니다.

build.gradle 파일은 아래와 같습니다.

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'

android {
    compileSdkVersion 29
    buildToolsVersion "29.0.2"

    defaultConfig {
        applicationId "com.april21dev.datavalidationkotlin"
        minSdkVersion 21
        targetSdkVersion 29
        versionCode 1
        versionName "1.0"

        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }

}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation project(':datavalidation-annotation')
    kapt project(':datavalidation-processor')
    implementation 'androidx.appcompat:appcompat:1.1.0'
    implementation 'androidx.core:core-ktx:1.2.0'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'androidx.test.ext:junit:1.1.1'
    androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
}

어노테이션을 사용해야 하기 때문에 datavalidation-annotation 을 참조해주고

datavalidation-processor 는 kapt 로 프로세싱 하도록 설정해주었습니다.

아래는 데이터 모델들입니다.

@DataValidation
data class Book(
    @MinLength(10, "title length minimum is 10")
    @MaxLength(50, "title length maximum is 50")
    val title: String,
    @MinValue(1, "book is not free")
    @MaxValue(100000, "book is too expensive")
    val price: Int,
    @MaxLength(10, "author name is too long")
    val authorName: String,
    @Regex("^[\\w!#$%&'*+/=?`{|}~^-]+(?:\\.[\\w!#$%&'*+/=?`{|}~^-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,6}$", "authorEmail is invalid")
    val authorEmail: String,
    @NotNull("publisher should is null")
    @Nested
    val publisher: Publisher
)

@DataValidation
data class Publisher(
    @MaxLength(10, "publisher name is too long")
    val name: String
)

간단히 Book 클래스와 Publisher 클래스를 만들어주고 각 필드에 대해 annotation 들을 달아주었습니다.

그 후 빌드를 하면 아래와 같이 DataValidationExtension.kt 파일이 자동으로 생성됩니다.

DataValidationExtension.kt 파일 안에는 Book 과 Publisher 에 대한 validate() 함수가 쓰여있습니다.

fun Book.validate(): ValidationResult {
  val result = ValidationResult()
  // Minimum Length Check
  if(title == null || title.length < 10) {
    result.isValid = false
    result.invalidFieldNameAndTags.add(FieldNameAndTag("title", "title length minimum is 10"))
  }
  // Maximum Length Check
  if(title == null || title.length > 50) {
    result.isValid = false
    result.invalidFieldNameAndTags.add(FieldNameAndTag("title", "title length maximum is 50"))
  }
  // Minimum Value Check
  if(price == null || price.toLong() < 1) {
    result.isValid = false
    result.invalidFieldNameAndTags.add(FieldNameAndTag("price", "book is not free"))
  }
  // Minimum Value Check
  if(price == null || price.toLong() > 100000) {
    result.isValid = false
    result.invalidFieldNameAndTags.add(FieldNameAndTag("price", "book is too expensive"))
  }
  // Maximum Length Check
  if(authorName == null || authorName.length > 10) {
    result.isValid = false
    result.invalidFieldNameAndTags.add(FieldNameAndTag("authorName", "author name is too long"))
  }
  // Regex Match Check
  if(authorEmail == null ||
      !"^[\\w!#${'$'}%&'*+/=?`{|}~^-]+(?:\\.[\\w!#${'$'}%&'*+/=?`{|}~^-]+)*@(?:[a-zA-Z0-9-]+\\.)+[a-zA-Z]{2,6}${'$'}".toRegex().matches(authorEmail)) {
    result.isValid = false
    result.invalidFieldNameAndTags.add(FieldNameAndTag("authorEmail", "authorEmail is invalid"))
  }
  // NonNull Check
  if(publisher == null) {
    result.isValid = false
    result.invalidFieldNameAndTags.add(FieldNameAndTag("publisher", "publisher should is null"))
  }
  // Nested Check
  if(publisher != null) {
    val nestedValidation = publisher.validate()
    if(!nestedValidation.isValid) {
      result.isValid = false
      result.invalidFieldNameAndTags.addAll(nestedValidation.invalidFieldNameAndTags)
    }
  }
  return result
}

fun Publisher.validate(): ValidationResult {
  val result = ValidationResult()
  // Maximum Length Check
  if(name == null || name.length > 10) {
    result.isValid = false
    result.invalidFieldNameAndTags.add(FieldNameAndTag("name", "publisher name is too long"))
  }
  return result
}

아래는 실제 validate() 함수를 사용하는 예시와 그 결과 입니다.

						val book = Book(
                "힐링페이퍼 인재상", 0, "Thomas", "thomas@healingpaper/com",
                Publisher("높은 기준을 추구합니다. 소신있게 반대하고 헌신합니다. 틀릴 수도 있다고 생각합니다.")
            )

            val validationResult = book.validate()
            Log.v("Validation",
                StringBuilder()
                    .appendln("유효성: ${validationResult.isValid}")
                    .appendln("잘못된 필드: ${validationResult.invalidFieldNameAndTags.joinToString(", ", transform = {it.fieldName})}")
                    .appendln("메시지: ${validationResult.invalidFieldNameAndTags.joinToString(" & ", transform = { it.tag })}")
                    .toString()
            )
		유효성: false
    잘못된 필드: price, authorEmail, name
    메시지: book is not free & authorEmail is invalid & publisher name is too long

지금까지 간단히 데이터 모델의 유효성을 판단하는 로직을 annotation 을 통해 생성해 보았습니다.

이 외에도 annotation 을 활용하면 내가 원하는 다양한 기능들을 boilerplate 코드 없이 간결하게 구현해 생산성을 많이 높여줄수 있을거라 생각합니다.

끝까지 읽어주셔서 감사합니다.

GitHub: https://github.com/april21dev/DataValidationKotlin

Thomas
Android Developer
강남언니에서 어떻게 하면 사용자분들이 더욱 편하게 앱을 사용할 수 있을까를 꾸준히 고민하는 안드로이드 개발자입니다.