💡 Intro
https://krrong.tistory.com/entry/예외처리
위 글을 작성하며 프로젝트에서 예외처리를 어떻게 하면 잘 할 수 있을까 고민을 많이 했고 제이슨의 도움으로 이론적인 해결의 실마리를 찾았다. 이번 글에서는 어떤 고민을 하면서 프로젝트에 적용했는지 적어보려 한다. 항상 생각하고 있는 것이지만 이론적인 것을 아는 것과 그 이론을 프로젝트에 적용하고 코드로 녹여내는 것은 정말 다른 영역인 것 같다.
❓ Before
지금도 예외처리를 안하고 있는 것은 아니다. 그 처리가 체계적이지 않고 어떤 예외인지 한 눈에 알 수 없는 형태일뿐이다. 코드를 실행했을 때 예외의 메시지를 통해 어떤 예외인지는 알 수는 있다. 하지만 구현하는 동안은 어떤 예외가 발생할 수 있을지 생각해보고 이에 맞도록 구현하기 위해서는 어떤 예외를 가져다 사용해야 하는지 헷갈렸다.
백문이 불여일견. 코드를 보자.
sealed class NaagaThrowable(override val message: String?) : Throwable() {
class ClientError(val code: Int, message: String) : NaagaThrowable(message)
class BackEndError() : NaagaThrowable("500에러")
class ServerConnectFailure() :
NaagaThrowable("서버통신에 실패했습니다. onFailure called")
class NaagaUnknownError(errorMessage: String) : NaagaThrowable(errorMessage)
}
Throwable
을 상속받는 NaagaThrowable
을 sealed class로 만들었다. 그리고 그 안에 예상 가능한 예외를 넣어두었다. 세세하게 나눠둔 것이 아니라 http 상태코드가 400번대면 ClientError
를, 500번대면 BackEndError
를 사용하도록 했기 때문에 적절한 처리가 어렵다.
fun <T> Call<T>.fetchNaagaResponse(
onSuccess: (T) -> Unit,
onFailure: (NaagaThrowable) -> Unit,
) {
enqueue(
object : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
if (response.isSuccessful) {
val body = response.body()
if (body == null) {
onFailure(NaagaUnknownError("response body가 null입니다."))
return
}
onSuccess(body)
} else {
if (response.isFailure400()) {
val failureDto = response.getFailureDto()
onFailure(NaagaThrowable.ClientError(failureDto.code, failureDto.message))
return
}
if (response.isFailure500()) {
onFailure(NaagaThrowable.BackEndError())
return
}
onFailure(NaagaUnknownError(ERROR_NOT_400_500))
}
}
override fun onFailure(call: Call<T>, t: Throwable) {
onFailure(ServerConnectFailure())
}
},
)
}
Call의 확장함수를 만들어 모든 API통신에서 사용할 수 있도록 했다. 그리고 앞에서 이야기한대로 http 상태코드가 400, 500인 경우 각각 ClientError
, BackEndError
를 반환하도록 했다.
추가로 통신에는 성공했지만 바디가 비어있는 경우 UnknownError
를, 통신 자체를 실패한 경우 ServerConnectFailure
를 반환하도록 했다.
이렇게 함으로써 통신에서 예외가 발생하지 않도록 할 수는 있었다. 그런데 지속적인 개발 과정에서 위와같은 예외처리가 굉장히 불편하게 작용했다.
서버에서는 http 상태코드가 400혹은 500인 경우 서버 자체의 예외 코드와 메시지를 함께 내려준다. 이를 사용해서 각각의 뷰에서 핸들링하고 싶은 예외만을 핸들링하도록 구현할 수 있었다. 하지만 우리는 모두 하나의 예외로 퉁쳐버리고 있고 내가 구현한 뷰가 아니라면 어떤 예외를 핸들링하고 싶은 것인지 알기가 어려웠다.(내가 구현한 뷰도 보기 어려웠음ㅋㅋ) 가독성이 매우 구렸다.
그래서 조금 더 명시적인 예외로 구분할 필요를 느꼈다. 그리고 UI에서 예외를 핸들링하고 싶은 경우에도(사용자에게 보여주고 싶은 예외인 경우 ex) 로그인 정보가 만료되었다) 서버에서 내려주는 메시지가 아니라 우리가 사용자에게 보여주고 싶은 메시지를 보여줄 필요를 느꼈다.
❗️ After
1️⃣ NaagaThrowable to DataThrowable
원래는 이전 글에서 작성한 것처럼 데이터 레이어로 들어온 예외를 적절하게 프레젠테이션 레이어의 예외로 변경해주기 위해 도메인 레이어에도 예외를 추가해야 하나라는 생각을 하기도 했다. 이를 도입하기 위해서는 설계를 더 견고히 해야 구현이 가능할 것 같다는 판단을 했는데 현재 우리 상황은 마감 기한이 정해져있었다. 그래서 DataThrowable
을 먼저 만들어두고 위에 언급한 내용은 추후에 고민해보기로 했다.
sealed class DataThrowable(val code: Int, message: String) : Throwable(message) {
// 100번대 인증 관련 예외
class AuthorizationThrowable(code: Int, message: String) : DataThrowable(code, message)
// 200번대 보편적인 예외
class UniversalThrowable(code: Int, message: String) : DataThrowable(code, message)
// 300번대 플레이어 관련 예외
class PlayerThrowable(code: Int, message: String) : DataThrowable(code, message)
// 400번대 게임 관련 예외
class GameThrowable(code: Int, message: String) : DataThrowable(code, message)
// 500번대 장소 관련 예외
class PlaceThrowable(code: Int, message: String) : DataThrowable(code, message)
// http 응답코드 500번대, body가 null일 때의 예외
class IllegalStateThrowable : DataThrowable(ILLEGAL_STATE_THROWABLE_CODE, ILLEGAL_STATE_THROWABLE_MESSAGE)
companion object {
const val ILLEGAL_STATE_THROWABLE_CODE = 900
const val ILLEGAL_STATE_THROWABLE_MESSAGE = "잘못된 값입니다."
val hintThrowable = GameThrowable(455, "사용할 수 있는 힌트를 모두 소진했습니다.")
}
}
서버에서 내려주는 예외 코드들은 가장 앞자리를 기준으로 의미가 있는 숫자들이며 위 코드의 주석으로 적혀있는 것과 같다.
1XX : 인증 관련 예외 AuthorizationThrowable
2XX : 보편 예외 UniversalThrowable
3XX : 플레이어 관련 예외 PlayerThrowable
4XX : 게임 관련 예외 GameThrowable
5XX : 장소 관련 예외 PlaceThrowable
그리고 팀원이 요청에 성공해도 바디가 null인 경우가 있었다라고 했다. 엥? 나보다 개발 경험이 많고 신뢰가 가는 팀원이기 때문에 믿고 null인 경우도 처리를 하기 위해 IllegalStateThrowable
을 추가해 해당 상황에서 사용하기로 했다.
그리고 다른 영역을 공부하다가 알게 된 내용으로 보충해보면 위 코드는 retrofit2의 코드다. 여기서도 body가 null인 것을 판단하여 KotlinNullPointerException을 반환하는 것을 보면(코루틴을 사용하기 때문에 정확히 반환한다의 개념은 아니지만 코루틴에 대한 내용을 다루는 것이 아니기 때문에 간단하게 알고 넘어가자) 아 이런 일이 생길 수도 있구나 생각할 수 있겠다.
2️⃣ fetchResponse
각설하고 원래 내용으로 돌아와서 앞에서 언급했던 Call의 확장함수를 아래와 같이 변경했다.
fun <T> Response<T>.codeIn400s(): Boolean {
return this.code() in 400..499
}
fun <T> Response<T>.codeIn500s(): Boolean {
return this.code() in 500..599
}
fun <T> Call<T>.fetchResponse(
onSuccess: (T) -> Unit,
onFailure: (Throwable) -> Unit,
) {
enqueue(
object : Callback<T> {
override fun onResponse(call: Call<T>, response: Response<T>) {
if (response.isSuccessful) {
val body = response.body() ?: return onFailure(IllegalStateThrowable())
onSuccess(body)
}
if (response.codeIn500s()) {
return onFailure(IllegalStateThrowable())
}
if (response.codeIn400s()) {
val errorResponse = response.errorBody()?.string()
val jsonObject = errorResponse?.let { JSONObject(it) }
val code = jsonObject?.getInt("code") ?: 0
val message = jsonObject?.getString("message") ?: ""
when (code) {
in 100..199 -> { onFailure(AuthorizationThrowable(code, message)) }
in 200..299 -> { onFailure(UniversalThrowable(code, message)) }
in 300..399 -> { onFailure(PlayerThrowable(code, message)) }
in 400..499 -> { onFailure(GameThrowable(code, message)) }
in 500..599 -> { onFailure(PlaceThrowable(code, message)) }
}
}
}
override fun onFailure(call: Call<T>, t: Throwable) {
onFailure(IllegalStateThrowable())
}
},
)
}
http 상태 코드에 따라 예외를 퉁쳐서 반환하는 것이 아니라 서버에서 내려주는 예외 코드를 확인하여 더 명시적인 예외로 변경하여 반환해주도록 작성했다.
http 상태 코드가 400인 경우 서서버의 예외 코드를 적용했고, http 상태 코드가 500인 경우에는 IllegalStateThrowable
이라는 예외를 반환한다.
이렇게 함으로써 해당 메서드를 사용하는 곳에서 처리하고 싶은 예외만 처리하는 것이 수월해진다.
예외를 사용하는 뷰모델을 하나의 예시로 확인해보자.
class AdventureHistoryViewModel(private val adventureRepository: AdventureRepository) : ViewModel() {
private val _adventureResults = MutableLiveData<List<AdventureResult>>()
val adventureResults: LiveData<List<AdventureResult>> = _adventureResults
private val _errorMessage = MutableLiveData<String>()
val errorMessage: LiveData<String> = _errorMessage
fun fetchHistories() {
adventureRepository.fetchMyAdventureResults(SortType.TIME, OrderType.DESCENDING) { result ->
result
.onSuccess { _adventureResults.value = it }
.onFailure { setErrorMessage(it as DataThrowable) }
}
}
private fun setErrorMessage(throwable: DataThrowable) {
when (throwable) {
is PlayerThrowable -> { _errorMessage.value = throwable.message }
else -> {}
}
}
adventureRepository로부터 지금까지의 게임 결과를 불러온다. adventureRepository는 API 통신을 하기 때문에 요청을 하는 과정에서 성공했을 때, 실패했을 때 각각 어떤 행동을 할 것인지를 람다를 통해 넘겨주고 있다.
만약 API 요청에서 실패하게 되면 DataThrowable
중 하나가 넘어올 것이다. API 요청에서 실패한 경우 그러니까 앞에서 작성했던 Call 확장함수에서 넘어오는 것은 DataThrowable
이라고 생각했기 때문에 setErrorMessage
메서드에 throwable을 넘겨줄 때 DataThrowable
로 캐스팅해서 넘겨주었다. DataThrowable
은 sealed 클래스이기 때문에 when
분기문에서는 DataThrowable
을 상속하는 클래스들의 분기를 처리해주면 된다.
그러면 해당 뷰모델에서 어떠한 예외가 발생할 수 있는지 조금 더 명시적으로 확인할 수 있고, throwable을 구독하고 있는 뷰에서는 서버의 예외 코드를 사용하여 더 세세한 처리를 할 수 있다.
3️⃣ Callback에서 Coroutine으로
네트워크 작업을 콜백 형식이 아니라 코루틴으로 변경하면서 작성해두었던 Call의 확장함수 또한 변경해야 했다.
private fun <T> Response<T>.codeIn400s(): Boolean {
return this.code() in 400..499
}
private fun <T> Response<T>.codeIn500s(): Boolean {
return this.code() in 500..599
}
fun <T> Response<T>.getValueOrThrow(): T {
if (this.isSuccessful) {
return this.body() ?: throw DataThrowable.IllegalStateThrowable()
}
if (codeIn500s()) {
throw DataThrowable.IllegalStateThrowable()
}
if (codeIn400s()) {
val errorResponse = errorBody()?.string()
val jsonObject = errorResponse?.let { JSONObject(it) }
val code = jsonObject?.getInt("code") ?: 0
val message = jsonObject?.getString("message") ?: ""
when (code) {
in 100..199 -> { throw DataThrowable.AuthorizationThrowable(code, message) }
in 200..299 -> { throw DataThrowable.UniversalThrowable(code, message) }
in 300..399 -> { throw DataThrowable.PlayerThrowable(code, message) }
in 400..499 -> { throw DataThrowable.GameThrowable(code, message) }
in 500..599 -> { throw DataThrowable.PlaceThrowable(code, message) }
}
}
throw DataThrowable.IllegalStateThrowable()
}
Call의 확장함수였던 fetchResponse
메서드를 Response의 확장함수 getValueOrThrow
로 변경했다.
사실 내부적으로 하는 일은 거의 비슷하며 변경된 코드는 많지 않다. 그럼에도 이 것을 추가한 이유는 예외처리 관련 내용이 다음에도 이어질 예정인데 그 글부터는 코루틴으로 리팩터링한 뒤에 발견한 문제에 대한 내용이기에 이어지도록 하기 위해 작성했다.
참고