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

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


Annotation 이란?

Android 개발을 하면서 @Deprecated, @SuppressWarnings, @IntRange 등을 많이 보셨을 겁니다. 이러한 것들을 Annotation 이라고 합니다.
Annotation 단어의 뜻은 주석을 뜻하지만 우리가 알고있는 그 주석보다 훨씬 강력한 녀석입니다. 주석과 다르게 특정 코드에 달아 어떤 의미를 부여하거나 기능을 주입할 수 있습니다.


Annotation 의 종류

Annotation 은 크게 3가지로 나누어 보려 합니다.

  1. Kotlin/Android 에 내장되어있는 built in annotation
  2. Annotation 에 대한 정보를 나타내기 위한 어노테이션인 meta annotation
  3. 개발자가 직접 만드는 custom annotation
    1. Using Reflection
    2. Using Code Generation

Kotlin/Android Built In Annotation

Kotlin/Android 에서 자주 사용하는 builtin annotation 을 몇 가지 살펴보겠습니다.

		@Deprecated("It is deprecated")
    fun sum(a: Int, b: Int): Int {
        return a + b
    }

    fun test() {
        Logger.v(~~sum~~(1, 2))
    }

우선 @Deprecated Annotation 입니다. 특정 함수, 클래스, 필드, 생성자 등에 달아 더이상 사용하지 말라는 경고를 주기 위한 용도입니다. 해당 Annotation 이 달린 코드를 사용할 경우 보통 IDE 에서 취소선으로 Deprecated 되었다는 표시를 해줍니다.

		fun sum(@IntRange(from = 0, to = 100) a: Int, @IntRange(from = 0, to = 100) b: Int): Int {
        return a + b
    }

    fun test() {
        Logger.v(sum(1, 101))
    }

@IntRange Annotation 입니다. 특정 함수, 파라미터, 필드 등에 달아 Int value 의 범위를 제한해 줍니다.
해당 범위 밖의 값을 넣으려 하면 컴파일러가 에러를 뱉어냅니다.

class CheckBoxSearchTextView @JvmOverloads constructor(
        context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr) {
.......
}

@JvmOverloads Annotation 입니다. 함수 또는 생성자 파라미터에 default value 가 설정되어 있을 경우 Kotlin Compiler 가 default value 만큼의 오버로딩 함수를 만들어주는 Annotation 입니다.
위의 예에서는
CheckBoxSearchTextView(context: Context)
CheckBoxSearchTextView(context: Context, attrs: AttributeSet?)
CheckBoxSearchTextView(context: Context, attrs: AttributeSet?, defStyleAttr: Int)
세가지 생성자가 자동으로 생성되게 됩니다.

대부분 익숙한 Annotation 들이라 여기에서 줄이겠습니다.


Annotation 을 위한 Annotation 인 Meta Annotation

Annotation 을 선언할 때 Kotlin 에서는 아래와 같이 class 앞에 annotation 을 붙여줍니다.

annotation class TestAnnotation

이 한 줄로 바로 다른 코드에서 @TestAnnotation 을 붙여 사용할 수 있습니다.
그리고, 이 Annotation 사용에 제한을 거는 Meta Annotation 들이 있습니다.

@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Repeatable
@MustBeDocumented
annotation class TestAnnotation

@Target

Annotation 이 어디에 사용 가능한지를 제한하는 데 사용됩니다. 주로 사용되는 파라미터는 아래와 같습니다.
(전체 목록은 여기를 참조)

  • CLASS: class, interface, object, annotation class 에 사용 가능하도록 제한
  • FUNCTION: 생성자를 제외한 함수들에 사용 가능하도록 제한
  • FIELD: backing field 를 포함한 field 들에만 사용 가능하도록 제한
  • TYPE: 모든곳에 사용 가능하도록 제한

위 TestAnnotation 은 @Target 으로 FUNCTION 과 CLASS 만을 가지고 있으므로, field 에 달으려 하면 컴파일러가 에러를 뱉습니다.

@Retention

Annotation 의 Scope 를 제한하는데 사용되고 파라미터에는 3가지가 있습니다.

  • SOURCE
    compile time 에만 유용하며 빌드된 binary 에는 포함되지 않습니다.
    개발중에 warning 이 뜨는 걸 보이지 않도록 하는 @suppress 와 같이 개발 중에만 유용하고, binary 에 포함될 필요는 없는 경우에 사용합니다.
  • BINARY
    compile time 과 binary 에도 포함되지만 reflection 을 통해 접근할 수는 없습니다.
  • RUNTIME: compile time 과 binary 에도 포함되고, reflection 을 통해 접근 가능합니다.
    Custom Annotation 에 @Retention 을 표시해주지 않을경우, 디폴트로 RUNTIME 이 됩니다.

@Repeatable

한 요소에 Annotation 이 중복으로 사용될 수 있는지를 나타냅니다.

@MustBeDocumented

Generated Documentation 에 해당 Annotation 도 포함될 수 있는지를 나타냅니다.
주로 Library 를 만들때 사용합니다.


Custom Annotation (Using Reflection)

이제 Custom Annotation 을 만들어보려 합니다.

Custom Annotation 을 만들때 Reflection 을 활용하는 방법이 있고, Code Generation 을 활용하는 방법이 있습니다.

Reflection 이란 런타임에 코드 구조를 변경해버리는 강력한 기능이지만 그만큼 위험하기 때문에 유의하여 사용해야합니다.

Reflection 은 어플리케이션 성능저하를 초래하기 때문에 개인적으로 선호하진 않습니다.
예로 Reflection 은 한 함수를 호출할 때마다 파라미터의 숫자가 맞는지, 파라미터의 타입은 정확한지 등을 확인하는 작업이 들어갑니다. 따라서 JIT compiler 가 한 번만 할 작업을 런타임에 매번 하기 때문에 성능저하를 만듭니다. 물론 지금은 어느정도 빨라졌다고는 하지만, 그럼에도 남용은 추천드리지 않습니다.

Kotlin 에서 Reflection 을 사용하는 가장 기본적인 방법은 KClass 를 이용하는 것입니다.

그냥 KClass 를 사용할 경우 런타임에 크래시를 발생기키기 때문에 먼저 kotlin-reflection 라이브러리를 추가해줘야 합니다.

implementation "org.jetbrains.kotlin:kotlin-reflect:$kotlin_version"

그리고 아래는 KClass 를 이용해 할수있는 작업들의 예시 입니다.

		@Deprecated("")
    class A {
        var field1 = ""
        var field2 = 0
        fun function1() {}
        fun function2() {}
    }

    fun kClassTest() {
        val receiverA = A()
        val kClass = A::class

        //Class Meta Data
        Logger.v(TAG, kClass.simpleName!!)
        Logger.v(TAG, kClass.isData)
        Logger.v(TAG, kClass.isAbstract)
        Logger.v(TAG, kClass.isCompanion)
        Logger.v(TAG, kClass.isFinal)
        Logger.v(TAG, kClass.isInner)
        Logger.v(TAG, kClass.isOpen)
        Logger.v(TAG, kClass.isSealed)

        //Create Instance
        Logger.v(TAG, kClass.createInstance())

        //Constructors
        Logger.v(TAG, kClass.constructors.map { it.name })
        Logger.v(TAG, kClass.constructors.map { it.parameters })
        Logger.v(TAG, kClass.constructors.map { it.call() })

        //Annotations
        Logger.v(TAG, kClass.annotations.map { it.annotationClass.simpleName })

        //Fields
        val kProperty = kClass.memberProperties.find { it.name == "field1" }
        (kProperty as KMutableProperty<String>).setter.call(receiverA, "Changed Field1 Value")
        Logger.v(TAG, receiverA.field1)

        //Functions
        Logger.v(TAG, kClass.memberFunctions.map { it.name })
        Logger.v(TAG, kClass.memberFunctions.find { it.name == "function1" }?.call(receiverA) ?: "")
    }

결과는 아래와 같습니다.

A
false
false
false
true
false
false
false

com.test.Test$A@dbc59aa

[<init>]
[[]]
[com.test.Test$A@299b]

[Deprecated]

Changed Field1 Value

[function1, function2, equals, hashCode, toString]
kotlin.Unit

그럼 이제 간단히 데이터 모델의 String Field Validation 을 하는 기능을 만들어보려 합니다.

@Target(AnnotationTarget.PROPERTY)
annotation class StringConstraint(
    val minLength: Int,
    val maxLength: Int
)

먼저 StringConstraint 라는 Annotation 으로 String Field 의 최소길이, 최대길이를 설정할 수 있도록 합니다.

data class Data(
    @StringConstraint(10, 50)
    val title: String,
    @StringConstraint(100, 500)
    val contents: String
)

Data 클래스의 title 필드는 10자~50자, contents 필드는 100자~500자로 제한하려고 합니다.

object FieldValidator {
    data class ValidationResult(
        var isValid: Boolean = true,
        val invalidFieldNames: MutableList<String> = mutableListOf()
    )

    fun validate(data: Any): ValidationResult {
        val result = ValidationResult()

        data::class.declaredMemberProperties.forEach {
            val field = it
            val annotation = it.findAnnotation<StringConstraint>()

            if (annotation != null && field.getter.visibility == KVisibility.PUBLIC) {
                val fieldValue = field.getter.call(data) as String
                if (fieldValue.length !in annotation.minLength..annotation.maxLength) {
                    result.isValid = false
                    result.invalidFieldNames.add(field.name)
                }
            }
        }

        return result
    }
}

Validator 입니다. validate() 함수는 파라미터로 받은 인스턴스의 필드들을 전부 조사하여 @StringAnnotation 이 달려있는지 확인해서 (minLength .. maxLength) 범위에 있지 않은 필드가 있다면 해당 필드명들을 리턴합니다.

fun test() {
        val data = Data(
            "극도의 투명함",
            "투명함은 단순히 결과가 잘 공유되는 것 이상을 의미합니다. 우리는 결과뿐 아니라 의도와 맥락, 과정까지도 알 수 있을 만큼 공유하는 것을 투명하다고 합니다. 우리가 추구하는 극도의 솔직함은 투명함이 전제되어야만 발현된다고 믿습니다."
        )

        val validationResult = FieldValidator.validate(data)
        Logger.e("유효성: ${validationResult.isValid}  유효하지않은 Field: ${validationResult.invalidFieldNames}")
    }

실제 Validation 을 실행해봅니다.

유효성: false  유효하지않은 Field: [title]

title 필드가 10자 미만으로, validation 에 실패했습니다.


지금까지 Reflection 을 이용해 Field validation 을 하는 Annotation 을 만들어 보았습니다. 다음 글에서는 Compile Time 에 Code Generation 을 활용해 Annotation 에 로직을 삽입하는 방법으로 찾아 뵙겠습니다.

2편 Code Generation 보러가기

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