Intro

SUWIKI에서 서버 통신을 위한 구성을 어떻게 했는지 포스팅합니다.

네트워킹을 위해 사용한 기술은 Retrofit2 입니다.

API는 REST API를 따릅니다. (이에 대한 내용은 백엔드와 관련된 내용이므로 자세히 설명하지 않습니다)

이하 내용에서는 편한 말투로 진행하겠습니다 😉


Dependencies 추가

/* Retrofit */
def retrofit_version = "2.9.0"
implementation "com.squareup.retrofit2:retrofit:$retrofit_version"
implementation "com.squareup.retrofit2:converter-gson:$retrofit_version"
implementation "com.squareup.okhttp3:logging-interceptor:4.9.3"
implementation "com.jakewharton.retrofit:retrofit2-kotlin-coroutines-adapter:0.9.2"
implementation "com.squareup.retrofit2:converter-scalars:2.9.0"

build.gradle (app)

네트워크 통신을 위한 Retrofit2와 JSON 데이터 응답 값을 객체로 변환하기 위한 Gson Converter, 통신 중간에 가로채는 Loggin Intercepter, 네트워크 통신을 Coroutine을 사용해 비동기 처리하기 위한 Coroutine adatper 등을 추가했다.

Retrofit 인터페이스

IRtrofit.kt IRetrofit

위 사진을 보면 @POST@GET을 확인할 수 있다. API 명세에 따라 POST 방식을 사용할 것인지, GET 방식을 사용할 것인지 구분한다. 주로 서버로부터 데이터를 내려 받을 때는 GET을 사용하고, 데이터를 확인하거나 요청 내용을 숨기기 위해 POST를 사용했다. GET 방식을 사용하면 요청 쿼리가 주소창에 노출되기 때문이다.

사진에서도 알 수 있듯이 POST 방식은 Body에 필요한 파라미터를 넣는다.

다음으로는 각 함수 앞에 붙은 suspend 키워드를 볼 수 있다. suspend 키워드는 Coroutine을 사용하기 위해 붙은 키워드이다. 네트워크 통신은 상당히 비용이 큰 작업이다. 만약 안드로이드의 메인 스레드인 UI 스레드에서 IO 작업을 하게 되면 작업이 진행되는 동안 사용자가 앱을 사용할 수 없어서 불편할 뿐만 아니라 5초가 지나게 되면 ANR(Application Not Responding)이 발생하면서 앱이 죽게 된다. 이러한 이유로 네트워킹과 같은 IO 작업은 비동기로 이루어지며 주로 RxJavaCoroutine을 사용해서 비동기 작업을 하게 된다.

Constants.kt constant 다음으로 알 수 있는 것은 요청 파라미터를 SIGN_UP과 같은 방식으로 상수로 관리했다. 이렇게 하면 API 요청 파라미터가 변경되었을 때 빠르게 찾아서 변경할 수 있고, 의존성을 낮출 수 있다. 또한 캡슐화를 통해 어떠한 기능인지 더 명확히 알기 쉽고 세부 내용을 감출 수 있다.

응답은 Response로 감싸서 받는다. Response로 감싸면 응답 코드와 메시지, 통신 성공 여부를 알 수 있다. 통신에 실패했을 때 사용자에게 적절히 알리고 예외 처리하기 위해 Response를 사용했다.

인스턴스 만들기

이 부분은 별도로 분리해서 만들 수도 있지만 이 프로젝트에서는 IRetrofit.kt에 인터페이스와 같이 작성했다. companion object에 작성되었다.

API 요청을 할 때마다 Retrofit의 인스턴스를 새로 만드는 것은 굉장히 큰 비용이 소모되기 때문에 주로 한 번 만든 인스턴스를 재활용하는 싱글톤 방식으로 사용한다.

SUWIKI에서는 2개의 인스턴스를 만들었다. 하나는 JWT 토큰이 필요 없는 API 작업에 사용하는 인스턴스이고, 다른 하나는 API 요청 시 헤더에 JWT 토큰을 요구할 때 사용하는 인스턴스이다.

예를 들어 공지사항 확인과 같이 로그인이 필요 없는 요청을 할 때와 시험 정보를 포인트로 구매 하는 등 인증 및 인가가 필요한 작업이 서로 다른 인스턴스를 사용하게 된다.

물론 하나의 인스턴스를 만들어서 JWT 토큰이 필요한 경우에 위에서 작성한 인터페이스에 직접 넣어줘도 되지만 대부분의 API 요청이 토큰을 요구하므로 별도의 인스턴스를 만들게 되었다.

인스턴스 생성

IRetrofit.kt

companion object{
    private var retrofitService: IRetrofit? = null
        private var retrofitServiceWithNoToken: IRetrofit? = null

        fun getInstance(): IRetrofit {
            if (retrofitService == null) {
                val client = getClient()
                retrofitService = client.create(IRetrofit::class.java)
            }
            return retrofitService!!
        }

        fun getInstanceWithNoToken(): IRetrofit {
            if (retrofitServiceWithNoToken == null) {
                val client = getClientWithNoToken()
                retrofitServiceWithNoToken = client.create(IRetrofit::class.java)
            }
            return retrofitServiceWithNoToken!!
        }

    // ...
}

토큰을 필요로 하지 않는 retrofitServiceWithNoToken와 토큰을 헤더에 넣어주는 retrofitService에 인스턴스를 생성하는 코드이다. 두 부분의 차이라고 한다면 client 부분이다.

Retrofit 객체 생성

IRetrofit.kt

companion object {
    // ...

    private fun getClient(): Retrofit {
        return Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(getOkHttpClient(TokenAuthenticator()))
            .addConverterFactory(gsonConverterFactory())
            .build()
    }

    private fun getClientWithNoToken(): Retrofit {
        return Retrofit.Builder()
            .baseUrl(BASE_URL)
            .client(getOkHttpClient(null))
            .addConverterFactory(gsonConverterFactory())
            .build()
    }

    // ...
}

이 부분에서는 API의 베이스 URL을 넣어서 Retrofit 객체를 생성한다. client에는 OkHttpClient 객체를 넣어줬고, ConverterFactoryGson Converter를 사용했다. 서버에서 내려준 JSON 타입의 데이터를 자바 객체로 변환해주는 컨버터이다. (서버에서 내려주는 타입이 JSON 타입이 아닌 경우 ScalarsConverter를 사용했는데 내가 작성한 부분이 아니라 제외했다)

getClient()getClientWithNoToken()의 차이는 client를 추가하는 부분에서 getOkHttpClient() 파라미터가 null인지 아닌지 차이가 있다.

하나씩 살펴보도록 하자.

OkHttpClient

서버와의 통신에서 발생하는 request, response 로그를 가로채서 확인할 수 있도록 OkHttpClientHttpLoggingInterceptor를 사용했다.

IRetrofit.kt

companion object {
    // ...

    private fun getOkHttpClient(
            authenticator: TokenAuthenticator?
    ): OkHttpClient {
        val loggingInterceptor = HttpLoggingInterceptor { message ->
            when {
                message.isJsonObject() ->
                    Log.d(TAG, JSONObject(message).toString(4))
                message.isJsonArray() ->
                    Log.d(TAG, JSONArray(message).toString(4))
                else ->
                    Log.d(TAG, "CONNECTION INFO -> $message")
            }
        }
        loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY

        val client = OkHttpClient.Builder().addInterceptor(loggingInterceptor)
        authenticator?.apply {
            client
                .addInterceptor(AuthenticationInterceptor())
                .authenticator(this)
        }

        return client.build()
    }

    //..
}

로그 메시지가 JSON 객체 형태일 때와 JSON 배열 형태일 때 각자의 타입에 맞게 출력하고, 그 외의 정보는 그냥 출력한다.

가로챈 로그는 문자열 타입인데 이를 어떤 형태인지 구분하기 위해 Kotlin의 확장 함수 기능을 사용해서 판별했다. 확장 함수를 사용한 부분은 다음과 같다.

Extention.kt

// 문자열 -> json 형태인지 json 배열 형태인지
fun String?.isJsonObject(): Boolean = this?.startsWith("{") == true && this.endsWith("}")
fun String?.isJsonArray(): Boolean = this?.startsWith("[") == true && this.endsWith("]")

로그 log

위와 같은 로그를 받아볼 수 있다. 위 로그는 현재 로그인 한 유저의 정보를 받아오는 API 요청에 대한 응답이다. 응답 코드로 200번을 받았고, 중간에 intercept() called 라는 부분이 확인할 수 있다. 그리고 JSON 객체 타입은 탭(4개의 Space) 되어서 찍히고, 그 외의 데이터는 평문으로 찍힌다.

이렇게 로그를 자세히 확인할 수 있는 것은 LoggingInterceptor 레벨을 BODY로 설정했기 때문이다.

여기까지 LoggingInterceptor를 만드는 과정이었고, OkHttpClient.Builder()를 사용해 OkHttpClient객체를 만들고 앞서 만든 LoggingInterceptor를 추가한다.

그리고 위에서 getClient()getClientWithNoToken()의 차이가 getOkHttpClient() 메소드의 매개 변수(authenticator)로 null의 유무였다.

authenticator를 매개 변수로 받았다면 매개 변수로 받은 authenticatorAuthenticationInterceptor의 값을 추가로 OkHttpClient.Builder()에 추가한다.

TokenAuthenticator(authenticator)와 AuthenticationInterceptor는 바로 밑에서 살펴보도록 하자.

헤더에 Access 토큰 삽입

AuthenticationInterceptor 클래스는 OkHttp3Interceptor 클래스를 상속한다.

이 클래스는 API 요청을 가로채서 JWT의 액세스 토큰을 헤더에 삽입해서 요청을 보내주는 역할을 한다. 앞서 getInstance()getInstanceWithNoToken()의 차이가 바로 여기서 발생하는 것이다.

IRetrofit.kt

class AuthenticationInterceptor : Interceptor {
    override fun intercept(chain: Interceptor.Chain): okhttp3.Response {
        val accessToken = TimeTableSelPref.encryptedPrefs.getAccessToken() ?: ""
        val request = chain.request().newBuilder()
            .addHeader(AUTH_HEADER, accessToken).build()
        Log.d(
            TAG,
            "AuthenticationInterceptor - intercept() called / request header: ${request.headers}"
        )
        return chain.proceed(request)
    }
}

이 프로젝트에서 JWT는 EncyptedSharedPreference를 사용해 저장되어 있다. 여기에 필요한 ContextApplicationContext를 사용했기 때문에 이 클래스에 Context를 별도로 주입하지 않고 사용할 수 있었다.

Access 토큰 갱신

TokenAuthenticator 클래스는 OkHttp3Authenticator 클래스를 상속한다.

이 클래스는 헤더에 토큰을 싣고 요청했을 때 해당 토큰이 만료된 상태라면 Refresh 토큰을 사용해 서버로부터 Access 토큰을 재발급 받고, 재발급 된 토큰을 가지고 기존에 중단됐던 요청을 이어나갈 수 있는 역할을 한다.

IRetrofit.kt

class TokenAuthenticator : Authenticator {
    override fun authenticate(route: Route?, response: okhttp3.Response): Request? {

        val refresh = TimeTableSelPref.encryptedPrefs.getRefreshToken() ?: ""
        Log.d(TAG, "TokenAuthenticator - authenticate() called / 토큰 만료. 토큰 Refresh 요청: $refresh")
        val tokenResponse =
            IRetrofit.getInstanceWithNoToken().requestRefresh(refresh).execute()

        return if (handleResponse(tokenResponse)) {
            Log.d(TAG, "TokenAuthenticator - authenticate() called / 중단된 API 재요청")
            response.request
                .newBuilder()
                .removeHeader(AUTH_HEADER)
                .header(AUTH_HEADER, TimeTableSelPref.encryptedPrefs.getAccessToken() ?: "")
                .build()
        } else {
            null
        }
    }

    private fun handleResponse(tokenResponse: Response<Token>) =
        if (tokenResponse.isSuccessful && tokenResponse.body() != null) {
            TimeTableSelPref.encryptedPrefs.saveAccessToken(tokenResponse.body()!!.accessToken)
            TimeTableSelPref.encryptedPrefs.saveRefreshToken(tokenResponse.body()!!.refreshToken)
            true
        } else {
            User.logout()
            Log.d(TAG, "TokenAuthenticator - handleResponse() called / 리프레시 토큰이 만료되어 로그 아웃 되었습니다.")
            false
        }

    companion object {
        const val AUTH_HEADER = "Authorization"
    }
}

액세스 토큰이 만료된 경우 REST API에 따라 401 응답 코드를 내려준다. 응답 코드로 401을 받으면 TokenAuthenticator가 동작한다.

EncryptedSharedPreference에 저장된 Refresh 토큰을 가져와서 Access 토큰을 갱신하는 작업을 한 후에 중단되었던 API 요청을 재개한다.

만약 Refresh 토큰마저 만료되었다면 로그인 된 사용자를 로그 아웃 시키고, 중단된 작업을 재개하지 않는다.

토큰 만료 token expired

중단된 요청 재수행 resume api request

LocalDateTime 다루기

Retrofit 객체 생성 부분ConverterFactory를 추가하는 부분에서 gsonConverterFactory() 라는 메소드를 사용했다.

이 부분은 원래 다음과 같이 작성되었었다.

IRetrofit

companion object {
    //...

    private fun getClient(): Retrofit {
        return Retrofit.Builder()
            .baseUrl( /* ... */ )
            .client( /* ... */ )
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }
    // ...
}

만약 서버의 응답 값으로 LocalDateTime을 받게 된다면 위의 방법으로는 GsonString 타입으로 변환해버려서 LocalDateTime으로 사용할 수 없게 된다.

Error LocalDateTime_Type_Error

이를 해결하기 위해 별도의 메소드로 추출하게 되었다.

IRetrofit

companion object {
    // ...

    private fun getClient(): Retrofit {
        return Retrofit.Builder()
            .baseUrl( /* ... */ )
            .client( /* ... */ )
            .addConverterFactory(GsonConverterFactory.create())
            .build()
    }

    private fun gsonConverterFactory(): GsonConverterFactory {
        val gson = GsonBuilder()
            .registerTypeAdapter(LocalDateTime::class.java, object: JsonDeserializer<LocalDateTime> {
                override fun deserialize(
                    json: JsonElement?,
                    typeOfT: Type?,
                    context: JsonDeserializationContext?
                ): LocalDateTime {
                    return LocalDateTime.parse(json?.asString, DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"))
                }
            })
            .create()
        return GsonConverterFactory.create(gson)
    }
}

위 과정을 통해 LocalDateTimeString 타입으로 변한 것을 역 직렬화(Deserialize)하여 다시 LocalDateTime 타입으로 사용할 수 있다.

직렬화: 자바 객체를 JSON 타입으로 변환

역 직렬화: JSON 타입 데이터를 자바 객체로 변환. 어떤 타입으로 변환할 지 명시해야 한다.


API 호출 예제 (시험 정보 구매 이력 확인)

Retrofit 인터페이스 선언

IRetrofit.kt

interface IRetrofit {
    @GET(PURCHASE_HISTORY) // PURCHASE_HISTORY에는 Base Url 이후의 주소가 들어감
    suspend fun getPurchaseHistory(): Response<PurchaseHistoryDto>
}

DTO

PurchaseHistoryDto.kt

import java.io.Serializable
import java.time.LocalDateTime

data class PurchaseHistoryDto(
    val data: List<PurchaseHistory>
) : Serializable

data class PurchaseHistory(
    val id: Long,
    val lectureName: String,
    val professor: String,
    val majorType: String,
    val createDate: LocalDateTime
) : Serializable

API 명세서 api spec

API 명세서에 맞게 Data Class를 작성해야 한다. 위 명세서에서는 data라는 key 값으로 JSON 배열 형태의 value를 내려주는데 배열의 각 아이템을 이루는 id, lectureName, professor, majorType, createDate 에 맞춰서 PurchaseHistory라는 Data Class를 작성했다.

그리고 PurchaseHistoryDto라는 이름의 PurchaseHistory 리스트를 가지는 Data Class를 만들어 PurchaseHistoryDto로 응답을 받게 된다.

ViewModel

PurchaseHistoryViewModel.kt

class PurchaseHistoryViewModel(private val repository: MyPostRepository) : ViewModel() {

    // ...
    private val _historyList = MutableLiveData<List<PurchaseHistory>>()
    val historyList: LiveData<List<PurchaseHistory>> get() = _historyList

    init {
        getHistory()
    }

    private fun getHistory() {
        viewModelScope.launch {
            val response = repository.getPurchaseHistory()
            if (response.isSuccessful) {
                if (response.body()?.data?.isEmpty() == true) {
                    // 데이터가 없을 때 처리
                }
                response.body()?.data?.let { _historyList.postValue(response.body()?.data) }
            } else {
                // 통신 에러 처리
            }
        }
    }
}

IO 작업을 비동기로 처리하기 위해 Coroutine을 사용했다. ViewModel에서는 ViewModel 생명 주기에 맞춰서 동작하는 viewModelScope를 사용할 수 있다.

MyPostRepository에 getPurchaseHistory()를 호출하는 부분을 확인할 수 있다. 응답은 Response로 감싼 값을 받으므로 response.isSuccessful로 통신 성공 여부를 확인할 수 있고, PurchaseHistoryDtodata 키 값으로 데이터를 확인할 수 있다.

받아온 데이터가 있다면 라이브데이터에 받아온 데이터를 담는다.

액티비티나 프래그먼트에서 historyList의 값을 관찰하고 있다가 값이 변경되면 RecyclerView Adapter의 submitList로 데이터를 변경해주는 방식으로 처리한다.

Repository

데이터는 지금처럼 네트워크 통신을 통해 가져올 수도 있고, SQLite DB에서 가져올 수도 있고, 내부 Asset 파일에서 가져올 수도 있고, 다양한 데이터 소스가 존재할 수 있다.

ViewModel에서는 비즈니스 로직을 수행하지만 데이터가 어디서 왔는지는 몰라도 되도록 Repository 패턴을 사용한다.

구글 개발자 문서 repository_pattern

MyPostRepository

class MyPostRepository(private val dataSource: MyPostRemoteDataSource) {
    // ...
    suspend fun getPurchaseHistory() = withContext(Dispatchers.IO) { dataSource.getPurchaseHistory() }
}

MyPostRepository에서 매개 변수로 dataSource를 주입 받는다. MyPostRemoteDateSource에서는 실질적으로 네트워크 통신을 통해 데이터를 받아오는 과정이 수행된다.

Data Source

MyPostRemoteDataSource

class MyPostRemoteDataSource(private val apiService: IRetrofit): MyPostDataSource {
    // ...
    override suspend fun getPurchaseHistory() = apiService.getPurchaseHistory()
}

DataSource에서 매개 변수로 apiService를 주입 받는다. IRetrofit은 위에서 한참 설명했던 바로 그 인스턴스이다.

위 코드를 보면 MyPostDataSource를 상속하는 것을 볼 수 있다. MyPostDataSource는 다양한 데이터 소스를 통일화하기 위해 메서드를 강제하는 인터페이스이다.

MyPostDataSource

interface MyPostDataSource {
    // ...
    suspend fun getPurchaseHistory(): Response<PurchaseHistoryDto>
}

의존성 주입

A 클래스 내부에서 B 클래스의 객체가 필요할 때 A 클래스 내부에서 직접 B 클래스의 객체를 생성하면 두 클래스 간의 결합도가 올라가게 된다. 객체 지향 설계에서 클래스 간의 결합도를 낮추고 응집도를 높이는 설계는 중요하다.

이렇게 결합도가 높아지는 것을 방지하기 위해 생성자 주입 방식, 필드 주입 방식, 수정자 주입 방식 등 다양한 방법이 고안되었다.

Dagger Hilt와 같은 라이브러리도 많이 사용하는 걸로 알고 있다. (아직 Hilt에 대해 학습하지 않은 상태라 프로젝트에 적용되지는 않았다. 현재 프로젝트는 생성자 주입 방식을 채택했다)

현재 프로젝트에서 ViewModel을 사용할 때 ViewModel에 생성자로 apiService를 주입하기 위해서 ViewModelFactory를 사용해서 생성해주는 방식을 사용하고 있다.

ViewModelFactory

@Suppress("UNCHECKED_CAST")
class ViewModelFactory : ViewModelProvider.Factory {

    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        return when {
            // ...
            modelClass.isAssignableFrom(PurchaseHistoryViewModel::class.java) -> {
                val apiService = IRetrofit.getInstance()
                val repository = MyPostRepository(MyPostRemoteDataSource(apiService))
                PurchaseHistoryViewModel(repository) as T
            }
        }
    }
}

전체 구조

networking_structure