Android/프로젝트

헤더에 다른 토큰을 넣는 동작에 따라 API, Interceptor 분리

sh1mj1 2023. 10. 3. 18:52

이전 글 https://sh1mj1-log.tistory.com/164

 

안드로이드 레트로핏 api 호출하는 인터페이스에서 @Header 중복을 제거하기

지난 4개월 전쯤 친구를 통해서 여러 다른 분들과 플레이스토어에 바바-BABA 라는 앱을 출시했습니다. 작성했던 코드들을 천천히 보면서 리팩토링 및 버그 픽스를 하며 공부를 더 해보려고 합니

sh1mj1-log.tistory.com

이전 글 '안드로이드 레트로핏 api ... 인터페이스에서 @Header 제거하기' 글에서 이어집니다.

 

이전 글에서는 api 중에서 헤더에 access token을 넣는 api call 인터페이스와 헤더에 엑세스 토큰을 넣지 않는 인터페이스가 있었습니다.

그런데 이제는 헤더에 sign token 을 넣는 api call 이 추가되었습니다.

 

그에 따라 동작을 추가한 코드에 대해서 구조를 더 수정해보려고 합니다.

 

기존 레트로핏 서비스 인터페이스

기존에는 `MemberApi` 라는 API 서비스 인터페이스에서 크게 두가지의 api 를 요청했었습니다.

 

`MemberApi`

interface MemberApi {
    @GET("...")
    suspend fun getMe(@Header("accessToken") accessToken: String): Response<MemberModel>

    @POST("...")
    suspend fun signUpWithBabiesInfo(
        @Header("Authorization")
        signToken: String,
        ...
    ): Response<TokenResponse>

    @POST("... ")
    suspend fun signUpWithInviteCode(
        @Header("Authorization")
        signToken: String,
        ...
    ): Response<TokenResponse>
}

함수의 시그니처를 보면 바로 알 수 있을 텐데 `getMe` 함수는 헤더에 access token 을 넣는 api call 함수이고, 나머지 두 메서드는 헤더에 sign token 을 넣는 api call 함수입니다.

그리고 동시에 `signUp ...` 으로 시작하는 함수는 회원 가입과 관련있는 동작임을 바로 알 수 있죠. `getMe` 함수는 사용자 본인의 정보를 요청하는 api 입니다.

 

그래서 이 두 종류의 함수를 서로 다른 레트로핏 API 인터페이스에 넣는 것이 맞다고 판단했습니다. 그래서 `SignupApi` 라는 서비스 인터페이스를 추가했습니다.

 

`SignUpApi`

interface SignUpApi {
    @POST("...")
    suspend fun signUpWithBabiesInfo(
        @Header("Authorization")
        signToken: String,
        ...
    ): Response<TokenResponse>

    @POST("...")
    suspend fun signUpWithInviteCode(
        @Header("Authorization")
        signToken: String,
        ...
    ): Response<TokenResponse>
}

 

그에 따라 관련 `DataSource`, `Repository` 등도 분리했습니다.

 

그런데 이전 글에서는 '@Header("Authorization")` 부분과 패러미터를 서비스 인터페이스에서 제거했었습니다.

`networkMoudle` 에 정의되어 있는 Interceptor 가 중간에 헤더에 access token 을 넣어주는 역할을 담당해주었기 때문이죠.

그런데 이 부분에서 항상 로컬에 저장되어 있는 access token 만 헤더에 넣어주고, sign token 관련 작업은 해주지 않았습니다.

바로 아래처럼 말이죠.

 

기존 `provideAuthorizationInterceptor` 코드

@Singleton
@Provides
fun provideAuthorizationInterceptor() = Interceptor { chain ->
    val request = chain.request().newBuilder()
    val accessToken = EncryptedPrefs.getString(PrefsKey.ACCESS_TOKEN_KEY)
    request.header("Authorization", "Bearer $accessToken")

    chain.proceed(request.build())
}

그렇기 때문에 이 Interceptor 에 대한 행동을 정의해주는 코드에서 분기를 해주는 식의 동작을 추가로 구현해 주어야 할 것 같습니다. 

 

하지만 문제가 되는 부분은 이 Interceptor 는 어떤 서비스 인터페이스에서 메서드를 호출했는지에 대해서 알 수 있는 방법이 없습니다.

`chain.request().` 를 통해서 url 이나 메서드 문자열에 대해서는 가져올 수 있기는 합니다.

하지만 url 을 통해 분기문을 작성하기에는 코드가 조금 번잡해지며, 애초에 서버에서 정한 url 도 `signup/...` 으로 시작하지 않습니다.

그래서 url 로 분기문을 작성했을 때의 코드를 누군가 보았을 때 '회원 가입 관련 동작에서는 헤더에 sign token 을 넣어주는 구나~' 라고 한 번에 알아 차리기 어려울 것 같습니다.

 

그래서 url 로 분기 처리하는 것은 적절치 않다고 판단했습니다.

 

제가 선택한 방법은 `SignUpAuthInterceptor` 라는 인터셉터를 하나 더 만들어서 회원 가입 관련 동작, 즉, 헤더에 signup 토큰이 들어가는 동작에 대해서만 그 인터셉터가 관여하도록 만드는 것입니다.

이 과정에서 저는  `javax.inject`패키지의 `Qualifier` 과 기능 `kotlin.annotation` 패키지의 `Retention` 을 사용했습니다.

 

javax.inject 패키지의 @Qualifier 

`javax.inject` 패키지는 의존성 주입(DI) 를 지원하기 위해 자바 표준 애노테이션 제공합니다. 그 중 `@Qualifer` 애노테이션은 주로 DI 시에 특정 구현체를 선택하기 위해서 사용됩니다.

 

예를 들어서 여러 구현체를 가지고 있는 인터페이스를 주입할 때 `@Qualifer` 애노테이션을 사용하여 어떤 구현체를 주입할지 명시적으로 지정할 수 있습니다. 

이렇게 DI 컨테이너는 어떤 구현체를 사용해야 하는지를 판단할 수 있습니다.

// example - 여러 구현체를 가진 인터페이스를 주입할 때 @Qualifier 를 사용
@Inject
@Qualifier("implementation1")
MyInterface myInterface;

위 코드는 MyInterface 타입의 여러 구현체 중에서 `implementation1` 로 지정된 구현체를 주입한다는 의미입니다.

 

그렇다면 Retention 은 무엇일까요?

 

kotlin.annotation 패키지의 Retention

`kotlin.annotation` 패키지는 코틀린에서 애노테이션을 정의하고 사용하기 위한 패키지입니다.

그 중에서도 `@Retention` 애노테이션은 애노테이션이 언제까지 유지될지를 지정하는데 사용됩니다.

`@Retention` 애노테이션은 크게 아래 세 가지  종류의 `RetentionPolicy` 를 가질 수 있습니다.

 

  • SOURCE : 컴파일된 바이트 코드에 저장되지 않음. 즉, 소스 코드에만 존재하고 컴파일 시에 주로 검사, 경고를 위해서 사용되며, 컴파일된 바이트 코드에는 존재하지 않고 런타임에 아무 영향을 끼치지 않음.
  • BINARY : 컴파일된 클래스 파일에는 유지되지만 런타임 시에는 제거됨. 주로 Reflection 을 사용하지 않는 경우 유용함.
    즉, 해당 애노테이션이 컴파일된 바이트 코드인 `*.class` 에 존재하기 때문에 바이트 코드에서 직접 작동하는 기능으로 사용할 수 있지만, Reflection 을 통해 런타임에 사용할 수 없다.
  • RUNTIME : 런타임 시에도 유지됨. Reflection 을 통해 애노테이션 정보에 접근 가능. 주로 런타임 정보나 런타임 시의 동작을 제어하는 데 사용됨.

 

예를 들어서 아래처럼 작성할 수 있죠.

@Retention(AnnotationRetention.BINARY)
annotation class MyAnnotation

이렇게 해서 MyAnnotation 을 애노이션으로 만들고 이 애노테이션이 언제까지 유지될지를 명시할 수 있습니다.

 

그렇다면 우리는 인터셉터를 DI 할 것이기 때문에 런타임에도 기능이 작동하도록`RetentionPolicy` 를 `BINARY`혹은 `RUNTIME` 으로  해야 겠네요. 또, 따로 Reflection 기능을 사용하지 않는 단순한 구조이기 때문에 `BINARY` 로 하면 될 것 같습니다.

 

결론적으로 코드는 아래처럼 작성했습니다.

 

변경한 구조

애노테이션 생성

    @Qualifier
    @Retention(AnnotationRetention.BINARY)
    annotation class NormalAuthInterceptor

    @Qualifier
    @Retention(AnnotationRetention.BINARY)
    annotation class SignUpAuthInterceptor

 

각 인터셉터 생성

@NormalAuthInterceptor
@Singleton
@Provides
fun provideNormalAuthorizationInterceptor() = Interceptor { chain ->
    val request = chain.request().newBuilder()
    val accessToken = EncryptedPrefs.getString(PrefsKey.ACCESS_TOKEN_KEY)
    request.header("Authorization", "Bearer $accessToken")
    chain.proceed(request.build())
}

@SignUpAuthInterceptor
@Singleton
@Provides
fun provideSignUpAuthorizationInterceptor() = Interceptor { chain ->
    val request = chain.request().newBuilder()
    val signToken = EncryptedPrefs.getString(PrefsKey.SIGN_TOKEN_KEY)
    request.header("Authorization", "Bearer $signToken")
    chain.proceed(request.build())
}

 

인터셉터를 DI 해주는 부분

@BabaClient
@Singleton
@Provides
fun provideBabaClient(
    @NormalAuthInterceptor
    authorizationInterceptor: Interceptor,
    tokenAuthenticator: Authenticator
): OkHttpClient {
    ...
    builder.apply {
        addInterceptor(authorizationInterceptor)
    }
    builder.authenticator(tokenAuthenticator)
    return builder.build()
}

@SignUpClient
@Singleton
@Provides
fun provideSignUpClient(
    @SignUpAuthInterceptor
    authorizationInterceptor: Interceptor
): OkHttpClient {
    ...
    builder.apply {
        addInterceptor(authorizationInterceptor)
    }
    return builder.build()
}

 

SignUpAuthInterceptor 를 사용하는 SignUpClient 를 분리해주기

@SignUpClient
@Singleton
@Provides
fun provideSignUpClient(
    @SignUpAuthInterceptor
    authorizationInterceptor: Interceptor
): OkHttpClient {
    ...
    builder.apply {
        addInterceptor(authorizationInterceptor)
    }
    return builder.build()
}

이렇게 변경을 마쳤습니다.

 

물론 아예 기존에 사용했던 코드를 쓸 수도 있습니다. 

각 서비스 인터페이스에서 헤더에 각자 직접 토큰을 넣어주는 방식 말입니다. 이 때의 코드는 아래와 같습니다.

// access token 을 사용하는 레트로핏 api 서비스 인터페이스

@GET("...")
suspend fun loadMyPageGroup(
    @Header("Authorization") token: String = EncryptedPrefs.getString(PrefsKey.ACCESS_TOKEN_KEY)
): Response<GroupResponse>

@GET("...")
suspend fun loadBabyProfile(
    @Header("Authorization") token: String = EncryptedPrefs.getString(PrefsKey.ACCESS_TOKEN_KEY),
    ...
): Response<BabyProfileResponse>

// sign token 을 사용하는 레트로핏 api 서비스 인터페이스
@POST("...")
suspend fun signUpWithBabiesInfo(
    @Header("Authorization")
    signToken: String,
    ...
): Response<TokenResponse>
// 이후에 이 api 함수를 호출하는 부분에서 signToken 을 넣어주어야 함.

위처럼 서비스 인터페이스에서 패러미터로 헤더를 직접 넣어줍니다. 이 인터페이스에서 넣어줄 수도 있고, 이 api 를 호출하는 다른 클라이언트 코드에서 넣어줄 수도 있겠죠.

 

그리고 나서 아래처럼 인자로 받은 토큰을 앞에 Bearer 을 붙여주는 역할을 하는 것입니다.

    @Authorization
    @Singleton
    @Provides
    fun providesAuthorizationInterceptor() = Interceptor { chain ->
        val request = chain.request().newBuilder()
        val hasAuthorization = chain.request().headers.names().contains("Authorization")

        if (hasAuthorization) {
            val accessToken = chain.request().header("Authorization")
            request.header("Authorization", "Bearer $accessToken")
        }
        chain.proceed(request.build())
    }

 

하지만 제 생각에는 인터셉터가 헤더에 각 api 마다 올바른 토큰을 넣어주는 책임과 역할을 담당하는 것이 적절하다고 보입니다.

물론, 싱글톤 인터셉터 객체가 하나 더 생기는 문제가 있지만, 다른 모든 레트로핏 서비스 인터페이스에서 헤더에 대해 신경쓸 필요가 없어지는 것이 더 좋은 설계라고 생각합니다. 각 책임을 명확히 분리하고 구분하는 것이 좋은 객체지향 설계인 것이죠!

 

나중에 api 가 추가되게 된다면, 이것이 헤더에 access token 을 넣는 api 인지, sign token 을 넣어야 하는 api 인지만 판단한 후에, 해당 API 에 클라이언트(`BabaClient` 혹은 `SignUpClient`)만 연결해주면, 헤더에 대해서 신경쓸 필요가 없어지는 것이죠!!

이후 변경에서 아래 코드 하나만 추가해주면 되는 것입니다.

    @Singleton
    @Provides
    fun provideNewApi(
        @BabaRetrofit retrofit: Retrofit
    ): SignUpApi = retrofit.create(SignUpApi::class.java)

 

 

 정리

  • `javax.inject 패키지의 @Qualifier 를 이용해서 특정 구현체를 DI 할 수 있다.
  • `kotlin.annotation` 패키지의 Retention 을 사용해서 애노테이션을 정의할 수 있다.
    RetentionPolicy 에는 SOURCE, BINARY, RUNTIME 세가지가 있다.
  • 위 둘과 DI 라이브러리(hilt) 를 이용해서 인터셉터를 두 개로 분리해서 각각에 맞는 인터셉터를 DI 해주었다.
  • 각자의 책임을 명확히 분리하고 구분하는 것이 좋은 객체지향 설계이다.