[Background]
서버와 통신중일 때 에러가 나는 건 정말로 자주 일어나는 일이다. 데이터를 잘못 올려서 400 에러, 없는 API에 요청해서 404 아니면 그냥 서버 쪽에 에러가 발생해서 500 에러가 뜰 수도 있다.
에러가 발생하면 사용자한테 올바른 에러 메시지를 보여줘야 한다, 가벼우면 토스트 메시지, 심각하면 다이얼로그. 물론 안드로이드 코드에 어떤 방식으로 메시지를 보여줄지 미리 정해놓을 수 있지만 메시지나 방식을 바꾸려면 배포를 다시 해야 하는 문제점이 있다.
이걸 해결하기 위해 우리는 서버 에러와 관련된 정보를 다 서버에서 받아오는 방식으로 해결할 수 있다.
이번 글에서 사용된 프로젝트는 클린 아키텍처를 적용한 상태입니다.
[기존 방식]
정말로 많은 방식이 있습니다. when문으로 일일이 코드와 메시지를 매칭시킬 수도 있지만 enum을 이용할 수도 있습니다.
enum class ApiErrorType(
val code: Int?,
val uiMessage: String,
val errorType: ErrorType
) {
SERVER_ERROR(
500,
"Server error. Please try again later.",
ErrorType.DIALOG
),
UNKNOWN(
null,
"Unknown error. Please try again later.",
ErrorType.TOAST
);
}
500이면 "Server error. Plesse try again later"라고 다이어로그가 뜬다고 미리 정의해 놓았습니다.
그런데 이 메시지는 "Server not responding"에 토스트를 보여주는 방식으로 바꾸고 싶다면 이 enum을 바꾸고 다시 배포를 해야 합니다.
[서버의 Response으로 에러 표시하기]
제가 생각하기에 서버에서 에러 메시지 데이터를 받을 때 가장 적합한 형식은 이와 같습니다.
{
"code": 400,
"message": "api.typeError",
"timestamp": "2023-07-10T01:15:51.752+00:00",
"data": {
"uiMessage": "nice to meet you",
"type": "TOAST"
}
}
code: Http 상태 코드를 쓰고 있지만 원한다면 백엔드와 상의해서 앱에 맞는 코드를 사용하면 됩니다.
message: 이 메시지는 사용자한테 보여주는 것이 아닌 나중에 디버깅할 때 개발자들이 보게 될 에러 메시지입니다.
timestamp: 에러가 발생한 시간, 디버깅용
uiMessage: 사용자한테 보여줄 에러 메시지
type: 사용자한테 메시지를 보여줄 방식
원한다면 더 추가할 수 있지만 이 게시글에서는 이 정도 스펙으로 충분합니다.
어떻게 적용하는지 보여 드리겠습니다.
Map안에 Map이 있는 만큼 data class을 두 개 만들어줍니다.
data class BaseResponse<T>(
@SerializedName("code")
val code: Int,
@SerializedName("data")
val data: T,
@SerializedName("message")
val message: String,
@SerializedName("timestamp")
val timestamp: String
)
data class ErrorDTO(
val message: String?,
val type: String?
)
BaseResponse에 제너릭을 넣어준 이유는 Error뿐만 아니라 성공했을 때도 저 데이터들은 제공해야 하기 때문에 다른 data class가 올 수 있습니다.
interface TestAPI {
@GET("error/test")
suspend fun getTest(): Response<BaseResponse<String>>
}
성공하면 BaseResponse 안에는 String이 들어가게 됩니다.
그리고 이 요청이 성공적인지 파단하는 class을 한 개 만들어줍니다.
abstract class NetworkResponse {
protected fun <T, R : BaseResponse<T>> changeResult(response: Response<R>): T {
if (response.isSuccessful) {
return response.body()!!.data
} else {
throw errorHandler(response)
}
}
private fun <R> errorHandler(response: Response<R>): TestException {
val errorBody = response.errorBody()?.string()
return try {
val errorResponse: BaseResponse<*> =
Gson().fromJson(errorBody, BaseResponse::class.java)
val errorMsg = errorResponse.data?.toString() ?: "{}"
val body = errorMsg.getErrorMsg()
val code = errorResponse.code
val message = body.message ?: errorResponse.message
val errorType = ErrorType.fromString(body.type ?: "NONE")
TestException(code, message, errorType).addErrorMsg()
} catch (e: Exception) {
TestException(response.code(), null).addErrorMsg()
}
}
private fun String.getErrorMsg(): ErrorDTO {
if (this.isEmpty()) {
// Handle the case of an empty error string
return ErrorDTO(null, null)
}
val keyValuePairs = this
.substring(1, this.length - 1) // Remove curly braces
.split(", ")
.map { it.split("=") }
.filter { it.size == 2 } // Filter out pairs that don't have exactly one "="
.associate { it[0] to it[1] }
return ErrorDTO(keyValuePairs["uiMessage"] ?: "", keyValuePairs["type"] ?: "")
}
}
성공하면 그대로 Api Service에 정의된 타입으로 반환되지만, 실패하면 ErrorDTO 형식으로 파싱이 돼서 Throw 되게 됩니다.
errorMsg는 "{uiMessage=nice to meet you, type=TOAST}" 이 형식으로 내려오기 때문에 getErrorMsg을 통해 파싱을 해줍니다.
class TestException(
val code: Int,
override var message: String?,
var errorType: ErrorType = ErrorType.NONE,
) : Exception() {
fun addErrorMsg(): TestException {
val errorType = findApiErrorType()
if (this.message.isNullOrEmpty()) {
this.message = errorType.uiMessage
this.errorType = errorType.errorType
}
return this
}
private fun findApiErrorType(): ApiErrorType {
return ApiErrorType.values().find { it.code == code }
?: ApiErrorType.UNKNOWN
}
}
서버에서 무조건 맞는 에러 데이터가 내려오는 것도 아니고, 서버가 아예 터져서 에러 데이터를 못 내려줄 수 있습니다.
그럴 때를 대비해서 미리 정의는 해 줘야 합니다.
방금 보여드린 enum class인데, 500 같이 서버에서 데이터가 안 내려올 때 뭐라도 보여줘야 해서 default value 정의를 해줍니다.
enum class ApiErrorType(
val code: Int?,
val uiMessage: String,
val errorType: ErrorType
) {
SERVER_ERROR(
500,
"Server error. Please try again later.",
ErrorType.DIALOG
),
UNKNOWN(
null,
"Unknown error. Please try again later.",
ErrorType.TOAST
);
}
이제 NetworkResponse을 DataSource에 적용해 줍니다.
class TestDataSourceImpl @Inject constructor(
private val api: TestAPI
) : TestDataSource, NetworkResponse() {
override fun getTest(): Flow<String> = flow {
emit(changeResult(api.getTest()))
}
}
그리고 ViewModel에서 성공했을 때와 Exception이 터졌을 때를 대비해 줍니다.
@HiltViewModel
class HomeViewModel @Inject constructor(
private val repository: QuestionRepository
) : BaseViewModel()
fun getTest() {
viewModelScope.launch {
repository.getTest()
.catch {
_result.value = handleException(it)
}
.collect { content ->
_result.emit(UiState.Success(content))
}
}
}
}
요청이 실패하면 Exception을 터트리도록 했기 때문에 catch에서만 에러 메시지를 처리를 해줍니다.
open class BaseViewModel : ViewModel() {
protected fun handleException(e: Throwable): UiState.Error<*> {
return when (e) {
is TestException -> UiState.Error(e)
else -> UiState.Error("Error")
}
}
}
handleException 함수는 모든 viewModel에서 중복으로 쓰이기 때문에 BaseViewModel로 빼주었고
굳이 Exception 타입을 구분하는 이유는 예를 들어 인터넷이 안 터져서 SocketTimeoutException이나 UnknownHostException 같은 경우에는 Api Service에서 Datasource으로 반환되기 전에 Exception이 터져서 내가 원하는 형식으로 변환을 못 해줍니다.
그래서 when문에 따로 정의를 해줘야 합니다.
이제 _result을 Fragment이나 Activity에서 collect 해줍니다.
@AndroidEntryPoint
class HomeFragment : BaseFragment<FragmentHomeBinding>(R.layout.fragment_home) {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.result.collect {
render(it)
}
}
}
}
private fun render(uiState: UiState) {
when (uiState) {
UiState.Loading -> {
binding.pbHome.visibility = View.VISIBLE
}
is UiState.Success<*> -> {
context.toast("${uiState.data}")
binding.pbHome.visibility = View.GONE
}
is UiState.Error<*> -> {
binding.pbHome.visibility = View.GONE
displayException(uiState)
}
}
}
}
displayException도 모든 fragement에서 쓰이기 때문에 BaseFragment으로 빼주었습니다.
abstract class BaseFragment<T : ViewDataBinding>(
@LayoutRes private val layoutRes: Int,
) : Fragment() {
protected fun displayException(uiState: UiState.Error<*>) {
val exception = uiState.message
if (exception is TestException) {
when (exception.errorType) {
ErrorType.TOAST -> {
context.toast(exception.message.toString())
}
ErrorType.DIALOG -> {
showOKDialog(
requireContext(),
getString(R.string.error_title),
exception.message.toString()
)
}
ErrorType.DIRECT_AND_DIALOG -> {
showOKDialog(
requireContext(),
getString(R.string.error_title),
exception.message.toString(),
false,
) { d, _ ->
d.dismiss()
// 다른 화면으로 이동하는 코드
(requireActivity() as ToFlowNavigatable).navigateToFlow(
NavigationFlow.SettingFlow(
""
)
)
}
}
ErrorType.NONE -> {
// do nothing
}
}
} else {
context.toast(exception.toString())
}
}
}
다 적용했으면 한번 테스트해 보겠습니다.
테스트를 할 때 저는 https://www.mockable.io/ 이 사이트를 많이 사용합니다. 손쉽게 API를 만들 수 있어서 좋습니다.
일단 성공할 때
{
"code": 200,
"message": "api.typeError",
"timestamp": "2023-07-10T01:15:51.752+00:00",
"data": "안드로이드 앱의 유연한 에러 메시징"
}
성공한 요청이기 때문에 ErrorDTO로 파싱 안되고 string으로 내려옵니다.
{
"code": 400,
"message": "api.typeError",
"timestamp": "2023-07-10T01:15:51.752+00:00",
"data": {
"uiMessage": "실패한 요청입니다.",
"type": "DIALOG"
}
}
{
"code": 400,
"message": "api.typeError",
"timestamp": "2023-07-10T01:15:51.752+00:00",
"data": {
"uiMessage": "Please try again later",
"type": "TOAST"
}
}
type 하고 uiMessage이 바뀐 대로 메시지와 표시하는 방법이 실시간으로 달라지는 것을 볼 수 있었습니다.
[마무리]
지금 하는 방식이 무조건 Best Practice라고 할 수는 없지만
제가 생각했을 때 간단하면서 금방 적용할 수 있는 방식이라 한번 관련 글을 써봤습니다
전체 코드는 여기서 확인 가능합니다.
https://github.com/flash159483/multi-module-navigation