Android/프로젝트

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

sh1mj1 2023. 9. 22. 17:47

지난 4개월 전쯤 친구를 통해서 여러 다른 분들과 플레이스토어에 바바-BABA 라는 앱을 출시했습니다.

작성했던 코드들을 천천히 보면서 리팩토링 및 버그 픽스를 하며 공부를 더 해보려고 합니다.

 

일단 오늘 볼 부분은 서버와 api 통신을 하는 부분인데요. 이 프로젝트에서는 Retrofit 을 사용했고, Di 는 Hilt 를 사용하였습니다.

 

서버와 api 통신을 하는 전체 구조

먼저 간단한 UML 을 통해 기존에 어떻게 서버 통신을 진행하고 있는지를 간단히 표현하자면 아래와 같습니다.

 

클라이언트에서 AlbumApi 를 호출할 때 Retrofit 객체를 만들어 주고, 또 OkHttpClient 객체를 만들어 주는 부분은 Hilt 로 구현되어 있습니다.

 

화살표가 UML 의 표준으로 그린 것도 아니고 실제로 Hilt 가 작동하는 방식은 더 복잡하지만 간단하게 한눈에 보이도록 하기 위해서 위 그림처럼 표현했습니다.

 

provideAlbumApi

provideAlbumApi 를 통해 retrofit 을 생성해줍니다.

@Singleton
@Provides
fun provideAlbumApi(
    @BabaRetrofit retrofit: Retrofit
): AlbumApi = retrofit.create(AlbumApi::class.java)

 

Retrofit 은 간단히 말하면 선언된 메서드에 주석을 사용하여 요청하는 방법을 정의함으로써 Java 인터페이스를 HTTP 호출에 맞게 조정해주는 것입니다.

 

provideBabaRetrofit

아래 provideBabaRetrofit 함수에서 위 provideAlbumApi 함수의 파라미터인 retrofit 을 만들어줍니다.

@BabaRetrofit
@Singleton
@Provides
fun provideBabaRetrofit(
    @NetworkModule.BabaClient
    okHttpClient: OkHttpClient,
    gsonConverterFactory: GsonConverterFactory
): Retrofit = Retrofit.Builder()
    .baseUrl(BuildConfig.BASE_URL)
    .client(okHttpClient)
    .addConverterFactory(gsonConverterFactory)
    .build()

이 함수에서는 패러미터로 okHttpClient 객체와 GsonConverterFactory 를 가집니다.

 

OkHttpREST API, HTTP 통신을 간편하게 구현할 수 있도록 다양한 기능을 제공해주는 Java 라이브러리이고, OkHttp 요청을 전송할 때는 OkHttpClient 객체가 필요합니다.

 

GsonConverterFactory 는 통신 시 JSON 과 GSON 사이 직렬화, 역직렬화해주는데 필요한 것이라고 보면 됩니다.

 

provideBabaClient

아래 provideBabaClient 함수가 위 provideBabaRetrofit 함수에 OkHttpClient 객체를 만들어줍니다.

@BabaClient
@Singleton
@Provides
fun provideBabaClient(
    authorizationInterceptor: Interceptor,
    tokenAuthenticator: Authenticator
): OkHttpClient {
    val builder = OkHttpClient.Builder()
    val loggingInterceptor = HttpLoggingInterceptor()
    loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY
    builder.apply {
        addInterceptor(loggingInterceptor)
        addInterceptor(authorizationInterceptor)
    }
    builder.authenticator(tokenAuthenticator)
    return builder.build()
}

여기서는 Logging 을 수행해주는 인터셉터(loggingInterceptor)의 로깅 레벨을 정해주고, 통신 시 인증에 관련된 인터셉터(authorizationInterceptor), 그리고 토큰의 유효기간에 대해 검사해주는 Authenticator(tokenAuthenticator) 를 파라미터로 갖습니다.

 

일단은 Authenticator 는 제처두고 authorizationInterceptor 에 대해서 봅시다.

 

provideAuthorizationInterceptor()

@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())
}

여기서는 http 요청에 대해서 헤더에 Authorization 이라는 이름을 가지고 있는지 검사합니다.

만약 가지고 있다면 요청의 헤더에 있는 Authorization 이라는 이름으로 가진 값을 찾아서 Bearer $accessToken 의 형태로 다시 넣어주고 있습니다.

 

참고 - Bearer 는 왜 붙이는 거지?

일반적으로 토큰은 요청 헤더의 Authorization 필드에 담아져 보내집니다.

Authorization: <type> <credentials>


Bearer는 위 형식에서 type에 해당합니다. 토큰에는 많은 종류가 있고 서버는 다양한 종류의 토큰을 처리하기 위해 전송받은 type에 따라 토큰을 다르게 처리합니다.

Bearer 라는 키워드는 JWT 혹은 OAuth에 대한 토큰을 사용한다. (RFC 6750) 라는 의미입니다.

 

이제 위 코드에서 AlbumApiprovideAuthorizationInterceptor() 에 집중해서 봅시다.

 

불필요하게 반복되는 @Header

이 프로젝트에서 AlbumApi 뿐 아니라 다양한 레트로핏 Api 인터페이스가 있습니다.

그리고 대부분의 api call 에서 헤더에 access token 을 담아서 통신합니다. 바로 Authorization 이라는 이름을 가지는 값에 access token 을 담고 있습니다.

물론 그렇지 않은 api call 도 있습니다.

 

AlbumApi 인터페이스를 자세히 보면 반복되는 코드가 보이는 것을 알 수 있습니다.

interface AlbumApi {

    @GET("baby/{babyId}/album")
    suspend fun getAlbum(
        @Header("Authorization") token: String 
            = EncryptedPrefs.getString(PrefsKey.ACCESS_TOKEN_KEY) ,
        @Path("babyId") ...,
        @Query("year") ...
    ): Response<AlbumResponse>

    @GET("baby/{babyId}/album/{albumId}")
    suspend fun gatOneAlbum(
        @Header("Authorization") token: String = ...,
        ...
    ): Response<Album>

    //성장 앨범 추가
    @Multipart
    @POST("baby/{babyId}/album")
    suspend fun postAlbum(
        @Header("Authorization") accessToken: String = ...,
        ...
    ): Response<PostAlbumResponse>

    @DELETE("baby/{babyId}/album/{contentId}")
    suspend fun deleteAlbum(
        @Header("Authorization") token: String = ...,
        ...
    ): Response<Unit>

    ....
}

 

@Header("Authorization") token: String = EncryptedPrefs.getString(PrefsKey.ACCESS_TOKEN_KEY),

라는 코드가 계속해서 반복됩니다.

 

AlbumApi 라는 인터페이스 말고도 많은 Api 에서 헤더에 access token 을 가지고 통신하기 때문에 사실은 더 많은 코드가 중복되고 있다는 것을 알 수 있습니다.

 

우리는 이제 위에서 보았던 AuthorizationInterceptor 을 이용해서 이 @Header 라인을 제거할 것입니다.

 

AuthorizationInterceptor 변경

@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())
}

 

기존에는 위와 같던 코드를 아래와 같이 변경했습니다.

@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())
}

 

기존에는 레트로핏 api 호출 인터페이스에서 Authorization 이라는 헤더를 가진 api call 만 잡아서 그 헤더가 가지는 access Token 을 통신 타입에 맞게 Bearer 라는 키워드를 앞에 붙이도록 가공하였습니다.

 

하지만 이제는 모든 api call 에 대해서 헤더에 Bearer $accessToken 을 가지도록 만들고 있습니다.

 

이렇게 하면 결과적으로 레트로핏 api 호출 인터페이스에서 모든 @Header ... 라인을 제거할 수 있습니다. 아래처럼 말이죠.

interface AlbumApi {

    //성장 앨범 메인
    @GET("baby/{babyId}/album")
    suspend fun getAlbum(
        @Path("babyId") ...,
        @Query("year") ...
    ): Response<AlbumResponse>

    @GET("baby/{babyId}/album/{albumId}")
    suspend fun gatOneAlbum(
        @Path("babyId") ...
    ): Response<Album>

    //성장 앨범 추가
    @Multipart
    @POST("baby/{babyId}/album")
    suspend fun postAlbum(
        ...
    ): Response<PostAlbumResponse>

    @DELETE("baby/{babyId}/album/{contentId}")
    suspend fun deleteAlbum(
        ...
    ): Response<Unit>

단순해 보이지만 다른 많은 레트로핏 api 호출 인터페이스의 모든 함수에 @Header ... 라인을 제거할 수 있다고 생각하면 적지 않은 중복 코드를 줄일 수 있습니다.

 

그러면 이렇게 하는 게 과연 정답일까요?

 

수정에 따른 비용 문제

앞에서 잠깐 언급했지만 '대부분'의 api call 함수에서 헤더에 access token 을 넣는다고 했지, '모든' api call 함수에서 헤더에 access token 을 넣는다고 하지 않았습니다.

 

그런데 수정한 코드대로 라면 헤더에 access token 이 필요하지 않은 api call 에도 똑같이 access token 을 넣어 줄 수 밖에 없습니다.

 

🙋🙋🙋🙋 그러면 api 중에 헤더에 엑세스 토큰이 필요 없는 api 에 대해서도
이와 같은 동작을 수행하게 되어서 서버와 통신할 때 불필요한 동작이 추가되는 거 아님??

그리고 access token 이 필요없는 통신 동작에서 헤더에 access token 을 포함해서 통신하면 보안에도 안 좋지 않음??

 

결론부터 이야기하면 맞는 말입니다. 하지만 그 동시에 틀린 말이기도 하죠..

 

???? 🤔🤔🤔🤔🤔

 

먼저 간단하게 앱에서 통신, 사용자 식별을 하는 방법에 대해 간단히 알아봅시다.

 

이 앱은 jwt 토큰을 이용하여 통신, 사용자를 식별하고 있다.

일단 이 앱에서는 jwt 토큰을 이용하여, access token 과 refresh token 을 이용해서 통신합니다.

 

서버와 통신할 때 처음에 서버에서 토큰을 요청하여 access token, refresh token 을 받아서 로컬에 저장해 놓습니다. 그래서 처음에 로그인할 때 로그인이 성공하면 토큰을 받는 것이죠.

 

그리고 그 access token 을 헤더에 담아 서버에 보냅니다. 서버로부터 인증을 성공하면 정상적인 응답을 받는 것이죠.

만약 서버로 보낸 access token 이 유효기간이 지났으면 refresh token 을 통해 서버에서 access token 을 갱신하고 다시 통신을 하는 것입니다.

 

즉, access token 이 필요 없는 작업은 보통 가장 처음에 토큰을 발급받는 시점입니다.

이 때는 로컬에 토큰이 저장되어 있지 않는 시점이기도 합니다.

 

그러면 뭔가 이상하죠?

 

토큰이 없는데 토큰을 헤더에 넣는다?

우리는 provideAuthorizationInterceptor() 함수에 있던 분기문을 제거했습니다.

즉, 로그인할 때(토큰이 로컬에 없을 때)도 헤더에 엑세스 토큰을 넣는 작업을 한다는 것이죠.

 

🙋🙋🙋그렇다면 NPE(Null Pointer Exception) 이 일어나지 않을까요?

 

따로 처리를 해주지 않으면 그럴지도 모릅니다. 우리 팀은 이것에 대해 미리 처리를 해놓았습니다.

로컬에 있는 access token 을 가져오려고 시도할 때 만약 null 이면 “” 을 리턴하도록 설정해 놓았습니다.

 

object EncryptedPrefs {
  private var prefs: SharedPreferences? = null

    fun initSharedPreferences (...) { ... }

    fun getString(key: String): String {
        return prefs?.getString(key, "") ?: ""
    }
        ...
}

이런 식으로 말이죠.

결론적으로 nullPointerException 도 일어나지 않습니다.

 

그리고 로그인 시점에 보내는 요청을 로그로 찍어보면 아래와 같이 헤더에 공백 값을 보냅니다.

Request{method=POST, url=https://... 생략 ...., headers=[Authorization:Bearer], ... 생략 ...}

 

이제 헤더 안에 공백 값을 보내므로 보안 상의 우려도 없으며 코드의 통일성을 가져가면서 불필요한 @Header ... 라인 을 제거할 수 있습니다.

 

하지만 여전히, access token 이 필요없는 시점에 api call 에서도 Interceptor 에서 헤더에 공백 값을 넣는 불필요한 행위를 수행하기는 합니다.

 

하지만 이 행위의 비용은 얼마 들지 않고 드는 비용에 비해서 가져가는 이점이 더 큽니다.

 

🙋 불필요하게 헤더에 넣는 행위도 안 하고 싶은데??

물론 그렇게 만들 수 있는 방법도 있습니다.

현재 api call 은 두 가지로 나뉩니다.

 

  • 헤더에 access token 을 보내는 api call
  • 헤더에 access token 을 보내지 않는 api call

 

그렇다면 api call 에 대한 계층을 분리할 수 있다는 것이겠죠. 아래 그림처럼 말입니다.

 

 

그리고 인터셉터도 두 가지 객체가 생겨야 할 것입니다.

 

이런 식으로 분리한 후에 외부에서 어떤 타입의 api 를 호출할지에 대해 Di 를 해주면 될 것입니다.

하지만 아쉽게도 실제로 그렇게 구현은 지금 해보지는 않을 거에요

 

모든 설계는 트레이드 오프 활동이다.

제가 최근에 '오브젝트: 코드로 이해하는 객체지향 설계' 라는 책을 읽었습니다.

그 책에서 나오는 말 중에 기억에 남는 간단한 구절이었습니다.

 

바로 위에서 설명한 대로 api call 인터페이스 계층을 나누어서 설계를 하면 문제는 해결되기는 합니다.

하지만 현재 api call 인터페이스는 도메인마다 나뉘어져 있는 상황입니다.

AlbumApi, BabyApi, MemberApi ... 이런 식으로 나뉘어져 있죠.

 

그런데 이렇게 나뉘어져 있는 api 를 헤더에 엑세스 토큰을 넣어야 하는 api 와 넣지 않아도 되는 api 로 나누는 것은 가독성을 해치는 일로 보입니다.

 

게다가 현재 Hilt 를 사용하여 인터셉터, Authenticator, OkHttpClient, Retrofit 등을 provide 하고 있는 코드에 또 다른 인터셉터 객체를 만들고, api 에 따라 다른 인터셉터를 사용하도록 변경하는 것 또한 코드 가독성을 해치고, 복잡성을 늘리는 길입니다.

 

보안 문제가 없으며 비용도 많이 들지 않고 코드 통일성을 유지하고 있는 설계를 단지 헤더에 엑세스 토큰을 넣지 않아도 되는 api call 몇 개 때문에 인터페이스 계층을 변경하고 DI 를 추가해가면서 코드를 변경하는 것은 오버 엔지니어링 으로 보입니다.

 

특히, 일관성을 해친다는 것이 가장 큰 걸림돌인 것 같네요..

물론, 공부하는 입장에서 변경해보는 것도 좋아보이기는 하니.. 시간이 되면 시도해보려구요 😁

 

단순히 레트로핏 api call 하는 인터페이스의 함수에서 @Header 를 인터셉터를 이용하여 제거하는 내용인데 글이 꽤 많았네요

 

아직 jwt 토큰을 사용하여 인증하고, 토큰을 갱신하는 이 부분에 리팩토링하고 버그 필스할 부분이 꽤 보여서 관련 내용을 더 올릴 것 같습니다.