헤더에 다른 토큰을 넣는 동작에 따라 API, Interceptor 분리
이전 글 https://sh1mj1-log.tistory.com/164
이전 글 '안드로이드 레트로핏 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 해주었다.
- 각자의 책임을 명확히 분리하고 구분하는 것이 좋은 객체지향 설계이다.