회사별, 프로젝트별로 네트워크 통신 방식이 다들 다르겠지만 대체로 서버에서는 JWT로 로그인 관리를 하고 안드로이드 클라이언트 측에서는 retrofit을 이용해서 서버와 통신하고 로컬에 저장하는 방식을 많이 사용할 것이다. 그래서 이번 프로젝트에서 적용한 로그인 방식과 토큰 재발급 방식을 서버와 함께 어떤식으로 리펙토링을 하였고 어떤 잠재적 문제가 있었고 어느부분에서 개선이 있었는지 정리 하려고 한다.
이전 프로젝트는 서버에서 JWT를 이용해서 로그인하면 토큰을 발급받고 해당 토큰을 api 통신시 header에 삽입해서 호출을 하였다. 그럼 서버는 해당 토큰을 확인하여 만료 여부를 체크하고 문제가 없다면 호출에 대한 응답을 클라이언트로 JSON형식으로 보내준다. 그럼 해당 JSON을 gson을 이용해 serializable data class의 각 타입에 맞게 풀어서 받는다. 그리고 호출이나 응답에 문제가 있는 경우 http 응답 코드가 200이 아닌 경우는 자동으로 에러로 분류, 응답 코드는 200이지만 서버 내부에서 오류로 판단하는 경우가 있다. 내부 오류도 판단 되는 케이스는Request의 Parameter가 잘못되거나 토큰 만료 등등이 있고 여기서 중요하게 여겨야하는 건 토큰 만료 부분이다. 토큰 만료는 응답 코드 200에 errors라는 이름에 서버에서 미리 정한 에러 코드를 반환해서 준다. 남의 코드라 최대한 간단하게 적었지만 이정도만 봐도 대략적인 흐름을 이해할 수 있을 것이다.
첫번째 문제는 발생 보다는 이미 알고 있지만 개발 기한에 밀려 처리를 하지 못한 부분이다. 위의 프로젝트 설명 마지막 부분에서 토큰이 만료 되면 응답 코드 200에 errors라는 객체에 에러 코드를 반환한다고 말했다. 그래서 200이지만 에러가 발생한 경우 notSuccess로 받는게 원래 의도였지만 급하게 코드를 마무리 하니라 200 호출에 대한 응답을 notSuccess로 받지 못하고 onSuccess로 받아 errors의 값을 항상 체크해야 하는 문제가 발생했다. 이 문제는 겉으로는 들어나지 않지만 모든 response에 errors를 체크해서 처리하는 보일러 플레이트가 발생 했고 거기에 notSuccess상태는 들어오지도 않지만 선언이 되어 있기에 빈 코드를 만드는 이상한 상황이 되었다. 두번째 문제는 비동기를 사용하기 힘들다는 점이다. 우선 이전 만료 토큰 에러가 발생하면 로컬에 암호화로 저장된 유저 정보를 통해서 다시 재로그인 api를 호출하고 응답으로 토큰을 재발급 받아서 다시 이전에 호출한 api를 재호출을 하는 로직을 개발하여 사용했는데 이부분이 코루틴이 아닌 그냥 호출과 Queue를 이용한 이전 호출 저장 및 재호출을 콜백으로 처리하여 사용되고 있는데 이부분을 모든 통신 함수에서 사용하면서 코루틴의 기능을 모두 사용하지 못하고 있었다.
이제 문제를 정리하면,
우선 수정하고자 하는 방향을 설명하면, response에 대한 wrapper 클래스를 만듭니다. 그리고 각 response를 실제적으로 컨트롤 할 수 있는 handler를 만듭니다.
request에 대한 response를 상황에 맞게 분류해서 정리하기 위해 3개의 상태를 만들었다.
sealed class ApiResult<out T> {
data class Success<out T>(val data: T) : ApiResult<T>()
data class Error(val errors: List<ApiError>) : ApiResult<Nothing>()
data class Exception(val exception: Throwable) : ApiResult<Nothing>()
companion object {
fun <T> success(data: T): ApiResult<T> = Success(data)
fun <T> error(errors: List<ApiError>): ApiResult<T> = Error(errors)
fun <T> exception(exception: Throwable): ApiResult<T> = Exception(exception)
}
}
sealed class를 이용해서 3가지 상태를 모두 같은 객체로 받을 수 있도록 감싼 후 각 가능한 결과에 대해 ApiResult의 인스턴스를 생성하는 팩토리 메서드가 포함합니다. 이제 기존에 받던 notSuccess부분을 Error로 받을 계획이고 실제적인 문제 해결 로직은 handler에서 작동하게 된다.
object ApiResponseHandler {
fun <T : CustomResponse> handleResponse(response: Response<T>): ApiResponse<T> {
if (!response.isSuccessful) {
return onCallBackFailure(response)
} else {
ApiResponse.Exception(response.e)
}
val body = response.body() ?: return ApiResponse.Error(listOf(ApiResult.ApiError("Response body is null", false)))
if (body.errors.isNullOrEmpty()) {
return ApiResponse.Success(body)
} else {
return ApiResponse.Error(body.errors.map { error -> ApiResult.ApiError(error, isHandled = false) })
}
}
private fun <T : CustomResponse> onCallBackFailure(response: Response<T>): ApiResponse<T> {
val errorBody = response.errorBody()?.string()
val error = if (errorBody != null) {
ServiceClient.getErrorResponse(errorBody)
} else {
ErrorResponse("Unknown error", RetrofitCallback.INTERNAL_SERVER_ERROR)
}
return ApiResponse.Error(listOf(ApiResult.ApiError(error, false)))
}
fun shouldLogout(error: ErrorResponse): Boolean {
return error.clazz == RetrofitCallback.WITHDRAWN_USER_EXCEPTION
}
// logics....
}
handleResponse를 보면 처음 reponse의 성공 여부를 체크합니다. 그 후 성공이 아니면 exception이 발견 된것 이므로 exception 상태를 받환 해 주고 아니면 success를 반환합니다. 그리고 둘 다 아니면 error를 확인하고 에러의 이유를 체크한 후 약속된 에러(토크 만료)가 오면 Error로 보낸다. 이렇게 개발을 하면 위에서 말한 문제 3가지가 해결이 된다. 이제 해당 로직을 api 통신시 suspend fun으로 호출하기만 하면 모든 문제 완료다.
간략한 예시를 만들어서 비동기 문제를 어떻게 해결 하는지 보여주고자 한다.
ServiceClient.createService(ApiInterface::class.java).api(
requestData
).enqueue(object : CustomCallback<ResponseData> (onSuccess, onFailure) {
override fun onResponse(
call: Call<ResponseData>,
response: Response<ResponseData>
) {
super.onResponse(call, response)
}
override fun onFailure(call: Call<ResponseData>, t: Throwable) {
super.onFailure(call, t)
}
})
기존 호출방식은 이런식으로 상당히 불필요한 코드가 많았지만