Android

안드로이드 [Kotlin] - SharedPreferences 를 이용해 Retrofit2 Header에 JWT 담기

🤖 Play with Android 🤖 2022. 7. 9. 23:38
728x90


안드로이드에서 데이터를 저장하는 방법으로 여러 가지가 있다.

 

파일 I/O (내부 또는 외부 저장소)

  • 접근 권한을 획득하고 파일을 열었다 닫았다 하는 수고가 필요함

 

관계형 데이터베이스

  • SQLite 등을 이용해 복잡한 관계형 데이터를 저장할 수 있음
  • 간단한 데이터를 저장할 거라면 얻는 데이터에 비해 구축과 관리에 많은 노력이 필요함

 

SharedPreferences

  • Key/Value 형태로 이용함
  • 내부적으로는 XML 파일로 저장됨
  • 파일을 열고 닫을 필요가 없이 핸들러를 만들어서 간편하게 사용 가능함

https://developer.android.com/reference/android/content/SharedPreferences

 

SharedPreferences  |  Android Developers

android.net.wifi.hotspot2.omadm

developer.android.com

 

즉 앱을 개발하다 보면 다양한 데이터를 저장하여 관리하여야 하는 상황이 존재하는데 이때 간단한 설정 값이나 문자열 같은 데이터를 저장하기 위해서는 서버나 DB를 사용하기에는 부담스럽기 때문에 SharedPreferences를 사용하는 것이 적합하다.

 

이 포스팅에서는 로그인을 위한 JWT를 서버에서 받아와 이를 SharedPreferences에 저장해 로그인 성공 이후에 모든 API 요청에서 JWT를 Request Header에 담아 보내 이 사용자가 올바른 사용자임을 인증할 것이다.

 

따라서 우선 Retrofit2에서 Header에 정보를 추가하는 방법부터 알아보자

 

 

📌  Retrofit2에서 Header에 정보를 추가하는 방법

헤더를 추가하는 방법에는 두 가지가 있다.

 

  1. 첫 번째는 API 인터페이스에 직접 @Header 어노테이션을 붙인 파라미터를 추가하는 방법
  2. 두 번째는 Interceptor를 사용하는 방법

 

 직접 @Header 어노테이션을 붙인 파라미터를 추가하는 방법

interface APIService {

    @GET("issues")
    suspend fun getIssues(
    @Header("authorization") accessToken: String
    ): IssueDTO   
    
    @GET("labels")
    suspend fun getLabels(
    @Header("authorization") accessToken: String
    ): LabelDTO     
}

다른 파라미터를 넘기는 것과 비슷한 방식으로 @Header 어노테이션만 붙여주면 되는 간단한 방법이다.

 

 

Interceptor 활용

JWT과 같은 인증정보처럼 모든 요청의 헤더에 포함되어 있어야 하는 정보들은 @Header 어노테이션으로 모든 API에 일일이 토큰 헤더를 붙이는 것은 코드의 중복도 심하고 관리하기도 번거롭다.

 

okHttp3의 Intercepotor를 활용하면 API마다 일일이 헤더를 직접 추가해주지 않아도 모든 Request에 자동으로 헤더를 추가한 다음 요청을 보내게 된다.

object ApiClient {
    private const val BASE_URL = "(your url)"
    fun getApiClient(): Retrofit {
        return Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(provideOkHttpClient(AppInterceptor()))
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }

    fun okHttpClient(interceptor: AppInterceptor): OkHttpClient {
        return OkHttpClient.Builder()
            .addInterceptor(interceptor)
            .addInterceptor(HttpLoggingInterceptor().apply {
                level = HttpLoggingInterceptor.Level.BODY
            }).build()
    }

    class AppInterceptor : Interceptor {
        @Throws(IOException::class)
        override fun intercept(chain: Interceptor.Chain) : Response = with(chain) {
            val newRequest = request().newBuilder()
                .addHeader("(header Key)", "(header Value)")
                .build()
            proceed(newRequest)
        }
    }
}

.addHeader("(header Key)", "(header Value)") 에 지정된 헤더 정보가 모든 요청에 자동적으로 추가된다.

 

 

📌  SharedPreferences 사용하기

SharedPreference를 변수로 가지고 있는 PreferenceUtil 클래스를 생성한다. getString()은 데이터를 가져오는 메서드이고 setString()은 데이터를 저장하는 메서드이다.

 

PreferenceUtil.kt

import android.content.Context
import android.content.SharedPreferences

class PreferenceUtil(context: Context) {
    private val prefs: SharedPreferences =
        context.getSharedPreferences("prefs_name", Context.MODE_PRIVATE)

    fun getString(key: String, defValue: String): String {
        return prefs.getString(key, defValue).toString()
    }

    fun setString(key: String, str: String) {
        prefs.edit().putString(key, str).apply()
    }
}

여기서 context.getSharedPreferences의 두 번째 인자에 MODE가 들어가게 되는데, MODE의 종류는 다음과 같다.

  • MODE_PRIVATE : 생성한 Application에서만 사용 가능하다.
  • MODE_WORLD_READABLE : 외부 App에서 사용 가능, 하지만 읽기만 가능하다
  • MODE_WORLD_WRITEABLE : 외부 App에서 사용 가능, 읽기/쓰기 모두 가능하다

 

SharedPreferences는 앱의 어디에서든 전역적으로 사용할 것이기 때문에 싱글턴 패턴을 이용하는 것이 좋다.

 

SharedPreferences 클래스는 앱에 있는 다른 Class보다 먼저 생성되어야 다른 곳에 데이터를 넘겨줄 수 있다. 그러기 위해서 Application에 해당하는 클래스를 생성한 뒤, 전역 변수로 SharedPreferences를 가지고 있어야 한다. Application()을 상속받는 클래스를 생성하여, onCreate() 보다 먼저 prefs를 초기화해준다. 필자는 Hilt를 사용하기 때문에 미리 생성되어 있는 MainApplication에서 이 작업을 수행하였다.

 

MainApplication.kt

@HiltAndroidApp
class MainApplication : Application() {
    companion object {
        lateinit var prefs: PreferenceUtil
    }

    override fun onCreate() {
        super.onCreate()
        prefs = PreferenceUtil(applicationContext)
    }
}

 

 

 

AndroidManifest.xml

MainApplication 클래스를 생성하였다면 Manifest에 다음과 같이 등록해준다. 경로는 다를 수 있다.

 <application
        android:name=".ui.common.MainApplication"
        ...
        ... >

 

 

 

 

이제 AccessToken을 가지고 있는 ViewModel에서 setString()을 통해서 SharedPreferences에 JWT를 저장한다.

 

LoginViewModel.kt

@HiltViewModel
class LoginViewModel @Inject constructor(private val loginRepository: LoginRepository): ViewModel() {

    ...
    ...

    private val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
        _error.value = CoroutineException.checkThrowable(throwable)
    }

    fun requestGitHubLogin(gitHubOAuthRequest: GitHubOAuthRequest) {
        viewModelScope.launch(exceptionHandler) {
            val response = loginRepository.requestGitHubLogin(gitHubOAuthRequest)
            val accessToken =  response.accessToken.token
           
            // SharedPreference 에 accessToken 저장
            MainApplication.prefs.setString("accessToken", accessToken)
        }
    }
}

 

 

 

 

마지막으로 okHttp와 Retrofit 객체를 선언하고 있는 RetrofitObject로 가서 getString()으로 JWT AccessToken을 꺼내 인터셉터에 추가해 헤더에 넣어준다.

 

RetrofitObject.kt

@Module
@InstallIn(SingletonComponent::class)
object RetrofitObject {
    private const val BASE_URL = "..."

    @Provides
    @Singleton
    fun okHttpClient(interceptor: AppInterceptor): OkHttpClient {
        return OkHttpClient.Builder()
            .addInterceptor(interceptor) // okHttp에 인터셉터 추가
            .addInterceptor(HttpLoggingInterceptor().apply {
                level = HttpLoggingInterceptor.Level.BODY
            }).build()
    }

    @Provides
    @Singleton
    fun retrofit(): APIService {
        return Retrofit.Builder()
            .baseUrl(BASE_URL)
            .addConverterFactory(GsonConverterFactory.create())
            .client(okHttpClient(AppInterceptor())) // okHttpClient를 Retrofit 빌더에 추가
            .build()
            .create(APIService::class.java)
    }

    class AppInterceptor : Interceptor {
        @Throws(IOException::class)
        override fun intercept(chain: Interceptor.Chain) : Response = with(chain) {
            val accessToken = MainApplication.prefs.getString("accessToken", "") // ViewModel에서 지정한 key로 JWT 토큰을 가져온다.
            val newRequest = request().newBuilder()
                .addHeader("authorization", accessToken) // 헤더에 authorization라는 key로 JWT 를 넣어준다.
                .build()
            proceed(newRequest)
        }
    }
}

이렇게 되면 로그인 이후 유저의 인증정보가 필요한 API들은 모두 헤더의 authorization라는 key로 JWT가 들어가 요청을 보내게 된다.