[Android] - Retrofit으로 에러 메시지 처리하기
프로젝트 관련 글
이번 프로젝트를 진행하면서 서버와 안드로이드를 동시에 개발하고 있는데요,
사실 안드로이드 개발만 경험해 본 상황에서 가볍게 서버의 코드를 작성하고 있는 상황입니다.
과거에 서버 개발자분들에게 무리하게 API를 요구했던 제 자신을 반성하게 되는 것 같습니다.
이번글은 오류 처리에 관한 글인데요,
이번 프로젝트를 하면서 api를 처음 연결한 상황에서 에러 처리를 명확하게 짚고 넘어가고 싶어서 초반부에 코드를 굉장히 많이 수정했던 것 같습니다.
우선 제가 고려했던 점은 일괄된 에러처리를 경험해 보자였습니다.
서버개발자와 협업이었다면, 조금 더 수월하게 진행할 수 있었지만, 혼자 했기에 실수할만한 부분을 빠르게 해결했던 것 같습니다.
우선 서버의 응답은 다음과 같습니다.
API 성공 시
API 실패 시
성공과 실패에 대해서 첫 번째 분기점으로 200이냐 아니냐였습니다.
그를 위해 서버에서는 CustomError를 만들어서 클라이언트에게 보내줘야 하는데요,
안드로이드를 하고 있는 저에겐 조금 어려운 일이었습니다.
회원가입을 예로 들면, 네이버 자동로그인에 성공했다면 유저의 정보와 AccessToken, RefreshToken을 모두 Dto에 담아서 서버에 전송합니다. 서버는 전달받은 Dto를 바탕으로 Mysql에 저장합니다. 하지만 여기서 만약 하나의 정보라도 빠진다면 저장이 되면 안 됩니다.
제가 그렇게 시나리오를 계획했기 때문입니다.
하지만 이러한 에러 처리를 안드로이드에서 하는 것보단 서버에서 하는 것이 맞다고 느꼈고, 커스텀 에러를 만들어서 서버에서 핸들링해 봤습니다.
CustomErrorCode
public enum CustomErrorCode {
INVALID_DATA_FORMAT(ErrorMessage.INVALID_DATA_FORMAT, HttpStatus.BAD_REQUEST, 400);
private final String message;
private final HttpStatus httpStatus;
private final int statusCode;
CustomErrorCode(String message, HttpStatus httpStatus, int statusCode) {
this.message = message;
this.httpStatus = httpStatus;
this.statusCode = statusCode;
}
public String getMessage() {
return message;
}
public HttpStatus getHttpStatus() {
return httpStatus;
}
public int getStatusCode() {
return statusCode;
}
}
enum class로 CustomErrorCode에 대해서 정의했습니다.
CustomErrorcode는 message와, httpStatus, statusCode를 생성자로 가집니다.
이 경우 제가 생각했던 시나리오에 맞춰서 ErrorMessage와 httpStatus, statusCode를 작성해서 커스텀 에러 코드를 만들어줍니다.
만약 또 다른 에러를 추가해야 할 상황이 생긴다면
INVALID_VIDEO_ID(ErrorMessage.INVLIAD_VIDEO_ID,HttpStatus.BAD_REQUEST,400);
이런 식으로 추가할 계획입니다.
CustomException
public class CustomException extends RuntimeException {
private final CustomErrorCode customErrorCode;
private final String errorMessage;
public CustomException(CustomErrorCode customErrorCode) {
this.customErrorCode = customErrorCode;
this.errorMessage = customErrorCode.getMessage();
}
public CustomErrorCode getCustomErrorCode() {
return customErrorCode;
}
public String getErrorMessage() {
return errorMessage;
}
}
이제 실제로 제가 생각한 시나리오의 에러가 발생한다면 던져야 할 Exception class가 필요합니다.
저는 이를 CustomException으로 정했으며, 위에서 구현한 customErrorCode와 errorMessage의 정보를 가집니다.
CustomExceptionHandler
@RestControllerAdvice
@Slf4j
public class CustomExceptionHandler {
@ExceptionHandler(CustomException.class)
public ResponseEntity<ApiErrorResult<?>> handleException(CustomException e, HttpServletRequest request) {
log.error("errorCode : {}, errorMessage : {}, url : {}", e.getCustomErrorCode(), e.getErrorMessage(), request.getRequestURL());
ApiErrorResult<?> errorResult = new ApiErrorResult<>(e.getCustomErrorCode().getStatusCode(), e.getErrorMessage());
return ResponseEntity.status(e.getCustomErrorCode().getHttpStatus()).body(errorResult);
}
}
이제 만약 서버 로직에서 customException이 발생되고, 이를 위 handler에서 처리하게 됩니다.
저 같은 경우는 ResponseEntity에다가 body로 담아서 보냈습니다.
그러면 다음과 같은 body와 httpStatus라는 결과가 나옵니다.
이제 이를 Android에서 처리해줘야 합니다.
처리 로직 말고는 간단하게 넘어가겠습니다.
SignUpApi
@POST("signUp")
suspend fun signUp(
@Body userRequest: UserRequest,
): Response<UserSignUpResponse>
서버 코드에 맞는 UserSignUpResponse라는 구현체를 만들어서 서버와 통신을 진행합니다.
UserSignUpResponse
data class UserSignUpResponse(
val data: Boolean? = null,
val statusCode: String? = null,
val message: String? = null,
)
서버에서 오는 코드는 data만을 담는 200Ok, statusCode와 message를 담는 나머지 에러들입니다.
이에 대응하기 위해 nullable로 변수를 열어두고, 진행했습니다.
UserRepositoryImpl
override fun signUp(userRequest: UserRequest): Flow<ApiResponse<Boolean>> = flow {
runCatching {
val response = userRemoteDataSource.signUp(userRequest)
Timber.d("mini-moment $response ${response.body()}")
response
}.onSuccess { response ->
response.body()?.let { body ->
body.data?.let { flag ->
localDataSource.saveAccessToken(userRequest.accessToken)
localDataSource.saveRefreshToken(userRequest.refreshToken)
emit(ApiResponse.Success(data = flag))
} ?: run {
emit(
ApiResponse.Error(
errorCode = body.statusCode?.toInt() ?: 0,
errorMessage = body.message ?: "",
),
)
}
}
}.onFailure { throwable -> // 다른 예외가 발생한 경우 -> 서버가 닫힌 경우
emit(ApiResponse.Error(errorMessage = throwable.message ?: ""))
}
}
Repository에서 회원가입에 대한 처리로직입니다.
통상적인 에러 처리와 다르게 저는 실패와 성공 모두 바디를 담아서 보냈습니다.
어떤 점이 다르냐?
- 통상적인 서버 통신은 실패일 경우 body를 비워서 보냅니다, 반대로 성공한 경우는 body에 요청 데이터를 담습니다.
- 하지만 저는 실패와 성공 모두 body를 담아서 보냈습니다.
왜 모두 body를 담아서 보냈는가에 대해서 설명해 드리겠습니다.
사실 실패 시 에러 처리를 클라이언트에서 statusCode에 따라서 하면 정말 편리합니다.
예를 들어 회원가입 API에 대한 에러코드로 400이 온다면? 데이터 형식이 잘못되었다~~라고 처리를 하면 되는 것처럼요.
하지만 더욱 복잡한 API와 서버 로직이 있을 경우, 이러한 예외 처리를 Android에서 한다면 매우 복잡할 것이라고 느껴졌습니다.
(제가 서버 개발을 같이 진행하면서 느꼈던 점입니다)
따라서 서버에서 오류가 발생했다면 어떤 오류가 발생했는지 body에 담아서 클라이언트에서 body의 내용을 그대로 전달만 하는 구조로 만들기 위해 성공 실패 모두 body의 값이 있는 것입니다.
회원가입 요청에 대해 성공할 경우 다음과 같은 결과를 얻을 수 있습니다.
우선 OkHttpClient Log입니다.
회원가입이라는 post 요청에 대해 body로 data 값이 true라는 결괏값을 받을 수 있었습니다.
이는 postman의 결과와 일치한 걸 확인할 수 있었습니다.
클라이언트에서도 body의 data값이 있기 때문에 Success로 처리함을 알 수 있었습니다.
다음은 실패 처리입니다.
실패 처리에 대한 코드는 간단하게 회원가입의 정보중 하나를 null로 만들고 요청을 보냈습니다.
OkHttpClientLog에는 제가 의도란 대로 실패 처리와 body가 정확하게 들어온 것을 확인할 수 있었습니다.
하지만 다른 부분에서 결과와 다르게 동작했습니다.
body가 null로 찍혔습니다. 분명히 log에는 body가 들어온 것을 확인할 수 있었고, PostMan으로 실행한 결과 body가 있었기 때문에 정말 당황스러웠습니다.
우선 자세하게 살펴보기 위해 디버그를 해봤습니다.
response의 속성 중 body는 null로 찍혀있고, errorBody를 찾을 수 있었습니다.
errorBody안에는 많은 속성이 있지만, 실제로 body로 들어와야 할 데이터들이 담겨있었습니다.
retrofit을 사용하면서 code가 200이 아니라면 원래 들어와야 할 body가 errorBody로 간다는 것을 처음 알게 된 것 같습니다..
errorBody의 asResponseBody를 변환해줘야 합니다. 왜냐하면 저 형태 그대로는 사용하지 못하기 때문입니다.
그래서 ErrorResponse라는 새로운 클래스를 만들어줬습니다.
data class ErrorResponse(
val statusCode: Int? = null,
val message: String? = null,
)
이제 errorBody의 저 부분을 ErrorResponse로 변환해야 합니다.
저는 retrofit에서 사용했던 Gson 라이브러리를 통해서 해당 데이터를 ErrorResponse로 변환시켰습니다.
val errorData = Gson().fromJson(result.errorBody()?.string(), ErrorResponse::class.java)
이제 response의 body가 null인 경우는 에러이기 때문에 다음과 같이 코드를 작성해 봤습니다.
runCatching {
val response = userRemoteDataSource.signUp(userRequest)
Timber.d("mini-moment response : $response")
Timber.d("mini-moment response body : ${response.body()}")
response
}.onSuccess { response ->
val errorBody =
Gson().fromJson(response.errorBody()?.string(), ErrorResponse::class.java)
Timber.d("mini-moment errorBody : $errorBody$")
response.body()?.let { body ->
body.data?.let { flag ->
localDataSource.saveAccessToken(userRequest.accessToken)
localDataSource.saveRefreshToken(userRequest.refreshToken)
emit(ApiResponse.Success(data = flag))
} ?: run {
emit(
ApiResponse.Error(
errorCode = body.statusCode?.toInt() ?: 0,
errorMessage = body.message ?: "",
),
)
}
} ?: run {
emit(
ApiResponse.Error(
errorCode = errorBody.statusCode ?: 0,
errorMessage = errorBody.message ?: "",
),
)
}
}.onFailure { throwable -> // 다른 예외가 발생한 경우 -> 서버가 닫힌 경우,서버 url이 잘못된 경우
emit(ApiResponse.Error(errorMessage = throwable.message ?: ""))
}
성공적으로 에러가 난 경우의 body를 꺼내서 Error 객체에 담을 수 있었습니다.
다음으로는 최적화입니다.
항상 Api 호출의 결과를 핸들링하기 위해서 위와 같은 로직이 반복될게 자명했습니다.
서버와의 통신도 일괄적인 형태로 진행될 것이기 때문에 분기 처리의 조건은 다음과 같았습니다.
- body가 null이 아닐 경우 성공, null일 경우 에러
- body의 data가 null이 아닐 경우 성공, null일 경우 에러
이러한 조건을 일반화해서 함수로 처리할 수 없을까를 고민했습니다.
우선 필요한 정보는 response 객체와 errorBody입니다.
response의 타입을 T로 일반화시켜서 모든 타입에 대해서 대응하고자 하는 것이 목적입니다.
errorBody는 있는 경우 에러 처리를 위해 사용될 것입니다.
이러한 정보를 바탕으로 다음과 같은 코드를 작성해 봤습니다.
suspend fun <T> apiHandler(
apiResponse: suspend () -> Response<T>,
): ApiResponse<T> {
runCatching {
val response = apiResponse.invoke()
val errorData =
Gson().fromJson(response.errorBody()?.string(), ErrorResponse::class.java)
Timber.d("mini-moment errorData : $errorData")
if (response.isSuccessful) {
response.body()?.let { body ->
return ApiResponse.Success(body)
}
} else {
return ApiResponse.Error(
errorCode = errorData.statusCode ?: 0,
errorMessage = errorData.message ?: "",
)
}
}.onFailure {
return ApiResponse.Error(errorMessage = it.message ?: "")
}
return ApiResponse.Failure
}
log를 자세히 보시면 errorData가 null이고, 현재 Response의 타입의 ErrorBody를 ErrorResponse로 바꿀 수가 없었습니다.
고민을 하다가 apiHandler에 errorData를 response와 같이 전달했습니다.
suspend fun <T> apiHandler(
apiResponse: suspend () -> Pair<Response<T>, ErrorResponse?>,
): ApiResponse<T> {
runCatching {
val action = apiResponse.invoke()
val response = action.first
if (response.isSuccessful) {
response.body()?.let { body ->
return ApiResponse.Success(body)
}
} else {
return ApiResponse.Error(
errorCode = action.second?.statusCode ?: 0,
errorMessage = action.second?.message ?: "",
)
}
}.onFailure {
return ApiResponse.Error(errorMessage = it.message ?: "")
}
return ApiResponse.Failure
}
밖에서 타입이 명시된 상황에서 ErrorResponse로의 변환은 가능했고, body가 있다면 성공, 없다면 errorBody의 code와 message를 Error로 담아서 return 합니다.
runCatching 밖의 블록은 네트워크 통신이 들어가기 전 실패한 것으로 서버 url 주소가 잘못된 경우가 대표적인 예입니다.
최종적인 UserRepositoryImpl signUp 코드입니다.
override fun signUp(userRequest: UserRequest): Flow<ApiResponse<Boolean>> = flow {
val response = apiHandler {
val result = userRemoteDataSource.signUp(userRequest)
val errorData = Gson().fromJson(result.errorBody()?.string(), ErrorResponse::class.java)
Pair(result, errorData)
}
when (response) {
is ApiResponse.Success -> {
localDataSource.saveAccessToken(userRequest.accessToken)
localDataSource.saveRefreshToken(userRequest.refreshToken)
emit(ApiResponse.Success(data = response.data.data ?: false))
}
is ApiResponse.Error -> {
emit(
ApiResponse.Error(
errorCode = response.errorCode,
errorMessage = response.errorMessage,
),
)
}
else -> {}
}
}
apiHandler를 통해서 복잡한 runCatching 로직을 함수로 처리하고, response의 타입에 따라 핸들링하는 직관적인 코드가 탄생했습니다!
완성하고 보니 읽기도 편하고, 로직도 간단해 보여서 정말 만족했던 코드였던 것 같습니다.
200이 아닌 경우 body값이 errorBody로 넘어가는 등,, 몰랐던 개념을 많이 배운 것 같습니다.
그래도 에러 처리에 대해 깔끔하게 한 것 같아서 뿌듯하네요 ㅎ