본문 바로가기

Android/Tech

@Retention 은 어떻게 동작하는가?

Unsplash, Philippe Gauthier.

동기

DND 8기에 참여하여 프로젝트를 진행했을 때, 팀 동료분께서 먼저 앱 구조를 구축해주셨습니다. 그 때 궁금했던 것이 @Retention Annotation 입니다. CoroutineDispatcher 여러에 대해 서로 다른 Annotation 을 붙여주셔서, 어떤 기준으로 결정하신 건지 여쭤봤었는데 속 시원한 답변을 구하지 못했던 기억이 납니다.

문득 궁금해져, 이번 기회에 이에 관해 학습하고 정리하려 합니다.

 


 

Hilt 사용 시 사용되는 Annotation 에 관해서는 이미 정리해 둔 포스팅이 있습니다. 심지어 이번 포스팅에서 다룰 @Retention 에 관해서도 간략하게 작성했습니다.

 

 

[Dagger-Hilt] Dagger-Hilt 의 대표적인 Annotations

Dependency Injection 의존성 주입(Dependency Injection, DI)은 안드로이드 앱 아키텍처 및 클린 아키텍처 적용시 필수적으로 사용되어야 하는 기술입니다. 대부분의 아키텍처가 테스트 용이성에 포커스를

blothhundr.tistory.com

 

🤔 근데 왜 또 포스팅하나요?

간단한 사용법과 각 항목에 대한 개요만 설명하였을 뿐, 어떻게 동작하는지 학습 및 작성하지 않았기 때문에, 기억도 잘 안 나고 어딘가 찜찜했기 때문입니다.

 


@Retention

Java 또는 Kotlin 에서 annotation class 를 생성할 때 사용할 수 있는 Meta-Annotation 입니다. 즉, 'Annotation 의 Annotation' 이라고 말할 수 있습니다. 사용자 정의 Annotation 을 작성할 때, 해당 Annotation 의 보존 정책을 지정하기 위해 사용됩니다. 

 

@Retention(AnnotationRetention.RUNTIME)
@Qualifier
annotation class DefaultDispatcher

...

 

보존 정책

Annotation 의 정보가 컴파일 후에 얼마나 오래 유지되어야 하는지를 나타내는 것으로, 다음과 같은 세 가지 주요 값이 있습니다.

 

public enum class AnnotationRetention {
    /** Annotation isn't stored in binary output */
    SOURCE,
    /** Annotation is stored in binary output, but invisible for reflection */
    BINARY,
    /** Annotation is stored in binary output and visible for reflection (default retention) */
    RUNTIME
}

 

kotlin.annotation 패키지에 포함된 클래스입니다. 간단한 주석이 포함되어 있는데, 이를 포함하여 다음과 같이 정리할 수 있습니다.

1. SOURCE

해당 Annotation 은 컴파일 중에만 유지됩니다. 컴파일 후에는 Annotation 정보가 사라집니다. 

여기서 '컴파일 후' 란, 컴파일러에 의해 .class 파일이 되었을 때를 의미합니다.

 

2. BINARY or CLASS

해당 Annotation 은 컴파일된 바이너리 코드 (.class 파일) 에 포함되지만, 런타임에는 그 정보에 접근할 수 없습니다.

 

3. RUNTIME

해당 Annotation 은 컴파일된 바이너리 코드에 포함되며, 런타임에 Reflection 을 통해 그 정보에 접근할 수 있습니다. 사용자가 런타임에 Annotation 정보에 접근해야 하는 경우에 사용하면 됩니다.

 

위를 표로 정리하면 다음과 같습니다.

 

 


그래서, 어떻게?

동작 방식을 확인하기 위해, @Retention Annotation 이 붙은 코드를 바이트 코드로 변환해 보았습니다.

 

public final class com/jh/retention/ui/splash/SplashViewModel extends androidx/lifecycle/ViewModel {


  @Ldagger/hilt/android/lifecycle/HiltViewModel;() // invisible

  // access flags 0x12
  private final Lkotlinx/coroutines/CoroutineDispatcher; ioDispatcher

  // access flags 0x1
  public <init>(Lkotlinx/coroutines/CoroutineDispatcher;)V
  @Ljavax/inject/Inject;()
    // annotable parameter count: 1 (visible)
    // annotable parameter count: 1 (invisible)
    @Lorg/jetbrains/annotations/NotNull;() // invisible, parameter 0
   L0
    ALOAD 1
    LDC "ioDispatcher"
    INVOKESTATIC kotlin/jvm/internal/Intrinsics.checkNotNullParameter (Ljava/lang/Object;Ljava/lang/String;)V
   L1
    LINENUMBER 10 L1
    ALOAD 0
    INVOKESPECIAL androidx/lifecycle/ViewModel.<init> ()V
    ALOAD 0
    ALOAD 1
    PUTFIELD com/jh/retention/ui/splash/SplashViewModel.ioDispatcher : Lkotlinx/coroutines/CoroutineDispatcher;
    RETURN
   L2
    LOCALVARIABLE this Lcom/jh/retention/ui/splash/SplashViewModel; L0 L2 0
    LOCALVARIABLE ioDispatcher Lkotlinx/coroutines/CoroutineDispatcher; L0 L2 1
    MAXSTACK = 2
    MAXLOCALS = 2

  @Lkotlin/Metadata;(mv={1, 8, 0}, k=1, d1={"\u0000\u0012\n\u0002\u0018\u0002\n\u0002\u0018\u0002\n\u0000\n\u0002\u0018\u0002\n\u0002\u0008\u0002\u0008\u0007\u0018\u00002\u00020\u0001B\u000f\u0008\u0007\u0012\u0006\u0010\u0002\u001a\u00020\u0003\u00a2\u0006\u0002\u0010\u0004R\u000e\u0010\u0002\u001a\u00020\u0003X\u0082\u0004\u00a2\u0006\u0002\n\u0000\u00a8\u0006\u0005"}, d2={"Lcom/jh/retention/ui/splash/SplashViewModel;", "Landroidx/lifecycle/ViewModel;", "ioDispatcher", "Lkotlinx/coroutines/CoroutineDispatcher;", "(Lkotlinx/coroutines/CoroutineDispatcher;)V", "presentation_debug"})
  // compiled from: SplashViewModel.kt
}

1. SOURCE

  public <init>(Lkotlinx/coroutines/CoroutineDispatcher;)V
  @Ljavax/inject/Inject;()

 

앞서 기술하였듯이, SOURCE 의 경우 소스 코드에만 남아있고, 컴파일되면 지워집니다. 그렇기 때문에, Inject 되었다는 코드만 남아있을 뿐입니다.

2. BINARY

  public <init>(Lkotlinx/coroutines/CoroutineDispatcher;)V
  @Ljavax/inject/Inject;()
    @Lcom/jh/retention/di/coroutine/IoDispatcher;() // invisible, parameter 0

 

BINARY 는 컴파일 후에 .class 파일에는 남지만, 런타임에 Reflection 을 통해 접근할 수는 없습니다. 그러므로, @IoDispatcher 가 남아있지만 invisible 이라는 주석도 함께 작성되어 있습니다. 즉, 런타임에는 invisible 하기 때문에 런타임에는 접근할 수 없다는 것을 의미합니다.

3. RUNTIME

  public <init>(Lkotlinx/coroutines/CoroutineDispatcher;)V
  @Ljavax/inject/Inject;()
    @Lcom/jh/retention/di/coroutine/IoDispatcher;() // parameter 0

 

BINARY 와 거의 같은데, invisible 주석만 삭제 되었습니다. 그러므로, 런타임에 Reflection 으로 접근할 수 있습니다.

 


 

SOURCE 와 나머지 값들 사이의 차이는 알 수 있었습니다. SOURCE 의 경우, 실제로 .class 파일에 해당 Annotation 이 삭제되어 있으니까요. 그러나 BINARY 와 RUNTIME 간에는 주석 차이 밖에 없습니다. 주석은 해석되지 않을테니, @Retention 을 선언한 부분에서 그 답을 찾을 수 있을 것 같습니다.

 

SOURCE 로 구현하고 바이트 코드로 변환했습니다.

  @Lkotlin/annotation/Retention;(value=Lkotlin/annotation/AnnotationRetention;.SOURCE)

  @Ljavax/inject/Qualifier;()

  @Ljava/lang/annotation/Retention;(value=Ljava/lang/annotation/RetentionPolicy;.SOURCE)

 

다음은 BINARY 입니다.

 

  @Lkotlin/annotation/Retention;(value=Lkotlin/annotation/AnnotationRetention;.BINARY)

  @Ljavax/inject/Qualifier;()

  @Ljava/lang/annotation/Retention;(value=Ljava/lang/annotation/RetentionPolicy;.CLASS)

 

마지막으로 RUNTIME 입니다.

 

  @Lkotlin/annotation/Retention;(value=Lkotlin/annotation/AnnotationRetention;.RUNTIME)

  @Ljavax/inject/Qualifier;()

  @Ljava/lang/annotation/Retention;(value=Ljava/lang/annotation/RetentionPolicy;.RUNTIME)

 

즉, 설정해 둔 값에 따라 AnnotationRetentionRetentionPolicy 가 변경됩니다.

 


 

계속해서 가려웠던 부분인데, 이번 기회에 긁어 줄 수 있어 좋았습니다. 물론 여전히 찜찜한 부분이 약간 남았지만요. 

 

동작 방식을 알아내기 힘들 때는 바이트 코드로 변환해서 읽어 보는 편이 방향성에 대한 인지에 도움이 되는 것 같다는 것을 느낍니다.