관리 메뉴

나만을 위한 블로그

[Kotlin] 에러 처리 Best Practices 본문

개인 공부/Kotlin

[Kotlin] 에러 처리 Best Practices

참깨빵위에참깨빵_ 2023. 9. 8. 00:14
728x90
반응형

이 포스팅은 아래 글을 바탕으로 작성했다.

 

https://medium.com/simform-engineering/best-practices-for-error-handling-in-kotlin-37a58cb63293

 

Best practices for error handling in Kotlin

Code confidently with Kotlin

medium.com

 

 

코틀린에는 null safety, let, 엘비스 연산자, 지연 초기화, "as?" 연산자를 통한 안전한 캐스팅(형 변환)과 여러 오류 처리 기능이 있다. 이제부터 코틀린에서 에러를 처리하는 몇 가지 기술을 나열한다.

 

코루틴의 예외

 

코루틴은 실패하면 부모 코루틴에 예외를 전달한다. 그 후 부모 코루틴은

 

  1. 스스로를 취소한다
  2. 남아있는 자식 코루틴을 취소한다
  3. 예외를 부모 코루틴에 전파한다. 즉 부모 코루틴의 부모 코루틴을 말한다

 

CoroutineScope가 시작한 모든 코루틴은 예외가 계층 구조의 최상위에 도달하면 취소된다.

 

스스로를 취소한다

 

import kotlinx.coroutines.*

fun main() = runBlocking {
    val parentJob = GlobalScope.launch {
        val childJob = launch {
            throw RuntimeException("자식 코루틴에서 예외 발생")
        }
        try {
            childJob.join()
            println("자식 코루틴 실행 완료")
        } catch (e: Exception) {
            println("부모 코루틴에서 예외 확인 : ${e.message}")
        }
    }

    parentJob.join()
    println("부모 코루틴 실행 완료")
}

 

하위 코루틴(childJob)을 시작하는 상위 코루틴(parentJob)이 있고, 의도적으로 RuntimeException을 발생시켜 실패시키는 예제다.

이 예제를 실행하면 곧바로 catch 블록이 실행되며 에러를 출력한다. 이후 예외가 발생한 다음 자식 코루틴에서 예외 발생 로그가 출력되고 부모 코루틴 실행이 완료된다. 이 과정에서 자식 코루틴이 완료되는 일은 없다.

 

남아있는 자식 코루틴을 취소한다

 

import kotlinx.coroutines.*

fun main() = runBlocking {
    val parentJob = GlobalScope.launch {
        val childJob1 = launch {
            delay(100)
            throw RuntimeException("자식 코루틴 1에서 예외 발생")
        }
        val childJob2 = launch {
            delay(200)
            println("자식 코루틴 2 실행 성공")
        }
        val childJob3 = launch {
            delay(300)
            println("자식 코루틴 3 실행 성공")
        }
        try {
            childJob1.join()
        } catch (e: Exception) {
            println("부모 코루틴에서 예외 확인 : ${e.message}")
        }
    }

    parentJob.join()
    println("부모 코루틴 완료")
}

 

3개의 자식 코루틴을 실행하는 부모 코루틴이 있다. 1번째 자식 코루틴은 첫 예제와 동일하게 RuntimeException을 발생시킨다.

그래서 catch 블록이 호출된 다음 자식 코루틴 1에서 예외 발생이 출력되고 곧바로 부모 코루틴 완료 로그가 출력된다. 이 과정에서 자식 코루틴 2, 3이 성공하는 일은 없다.

 

예외를 부모 코루틴에 전파한다

 

import kotlinx.coroutines.*

fun main() = runBlocking {
    val parentJob = GlobalScope.launch {
        val childJob = launch {
            throw RuntimeException("자식 코루틴에서 예외 발생")
        }
        try {
            childJob.join()
        } catch (e: Exception) {
            println("부모 코루틴에서 예외 확인 : ${e.message}")
            throw e // Rethrow the exception
        }
    }

    try {
        parentJob.join()
    } catch (e: Exception) {
        println("최상위 코루틴에서 예외 확인 : ${e.message}")
    }

    println("코루틴 종료")
}

 

이 예제에서 상위 코루틴은 의도적으로 예외를 발생시키는 하위 코루틴을 시작한다. 하위 코루틴에서 예외가 발생하면 이 예외를 부모 코루틴으로 전파한다.

이걸 실행하면 1번째 catch 블록이 호출되고 부모 코루틴에서 예외를 확인한다. 이후 자식 코루틴에서 예외 발생 로그가 출력되고 코루틴 종료가 출력되며 프로그램이 종료된다.

 

1번 예제 '스스로 취소한다'는 join()의 효과로 부모 코루틴은 자식 코루틴의 완료를 기다렸다가 예외가 발생하면 join()을 호출한 부모 코루틴으로 전파되는 흐름으로 작동하는 예제다. 이는 자식 코루틴에서 예외가 발생할 경우 부모 코루틴에서 처리할 수 있다는 걸 보여주는 예제다.

2번 예제 '남아있는 자식 코루틴을 취소한다'는 childJob에서 발생한 예외로 childJob2, 3이 취소되어 실행되지 않고, 마찬가지로 join()의 효과로 자식 코루틴(childJob1)에서 예외가 발생하면 부모 코루틴과 다른 자식 코루틴들도 같이 취소될 수 있다는 걸 보여주는 예제다.

3번 예제 '예외를 부모 코루틴에 전파한다'도 1, 2번 예제와 같은 맥락이다. 그러나 실제 코루틴을 사용한 예외 처리 로직을 구현할 때 GlobalScope는 권장되지 않는다. 실제로 IDE에서 GlobalScope를 사용하면 노란 줄이 표시되면서 다른 걸로 바꾸라고 권장한다.

안드로이드에선 lifecycleScope, viewModelScope, CoroutineScope를 주로 사용하는데 GlobalScope 대신 이것들 중 하나를 쓰는 게 낫다.

 

예외 처리에 Sealed Class 사용

 

sealed class는 코틀린에서 예외 클래스를 모델링할 수 있게 하는 클래스다. 앱에서 발생할 수 있는 모든 예외를 나타내는 sealed class를 정의하면 예외를 간결하게 처리할 수 있다.

글의 코드와 내 포스팅의 코드가 크게 다르지 않기 때문에 내 포스팅으로 본문 내용을 대체한다. 궁금하면 미디엄의 원문 글을 확인한다.

 

https://onlyfor-me-blog.tistory.com/753

 

[Kotlin] Sealed Class와 Sealed Interface란?

sealed class에 대해선 이전에 data class와 같이 포스팅을 작성한 적이 있다. https://onlyfor-me-blog.tistory.com/454 [Kotlin] 코틀린에서 제공하는 특수 클래스(Data Class, Sealed Class) 코틀린에는 자바와 달리 특수

onlyfor-me-blog.tistory.com

 

함수형 오류 처리

 

함수형 오류 처리는 고차 함수를 사용하는 에러 처리법이다. 에러 처리를 다른 부분에 입력으로 보내서 중첩되는 if-else 문을 제거할 수 있다.

 

fun main() {
    loadData().onError { e -> Log.e("TAG", e.message) }
}

fun <T> Result<T>.onError(action: (Throwable) -> Unit): Result<T> {
    if (isFailure) {
        exceptionOrNull()?.let {
            action(it)
        }
    }

    return this
}

fun loadData(): Result<Data> {
    return Result.success(Data())
}

 

원문에선 exceptionOrNull() 뒤에 let을 사용하지 않지만 인텔리제이에서 예제를 작성했더니 컴파일 에러가 발생해서 저렇게 바꿨다.

onError는 실패 시 기본 작업으로 에러를 처리하기 위해 코드에 정의돼 있다. 데이터 불러오기에 성공하면 결과인 Data 객체가 리턴된다. 데이터 불러오기 도중 예외가 발생하면 위 예제에선 로그를 표시한다.

이 예제는 함수형 프로그래밍에서 언급되는 개념인 고차 함수를 써서 오류 처리를 어떻게 하는지 보여주기 위한 예제로, 에러 처리 로직을 명시적이고 간결하게 구성하는 방법을 보여준다. if-else를 쓰지 않아도 에러 처리가 가능하다.

 

포착되지 않은 에러(Uncaught Exception) 처리

 

포착되지 않은 에러는 애플리케이션 실행 중 발생하는 에러인데 try-catch로 처리되지 않은 에러를 말한다. 안드로이드에선 이런 타입의 에러가 발생하면 기본적으로 앱이 다운되면서 로그캣에 IllegalArgumentException 같은 예외와 뭐 때문에 에러가 발생했다는 내용이 표시된다.

만약 포착되지 않은 에러가 발생했을 때 이 에러를 기록하거나 사용자에게 메시지를 표시하고 재시도할지 이전 화면으로 이동할지 등을 선택하게 한다면 UX적으로 나쁘지는 않을 것이다.

포착되지 않은 에러 처리 로직 구성의 간단한 구성 예시는 아래와 같다.

 

Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
    Log.e(TAG, "Uncaught Error 발생 : $throwable")
    // 이 이후에 다이얼로그 표시나 재시도 등 필요한 로직 추가 
}

 

Thread.setDefaultUncaughtExceptionHandler를 써서 에러 처리기를 생성하고, 포착되지 않은 에러가 발생하면 로그를 통해 발생한 에러의 세부 내용을 표시한다.

이것은 여러 방법 중의 하나일 뿐이다. runCatching {}을 사용하는 것도 방법이고, try-catch를 써서 에러를 핸들링해도 상관없다. 그러나 에러를 처리하는 방어 로직을 구성하는 것보다 중요한 것은 에러의 핵심 원인을 찾아내 제거해서 충돌할 상황을 가급적이면 만들지 않는 것이다.

 

레트로핏 사용 시 에러 처리

 

고유한 error converter를 만들면 HTTP 에러 코드, 네트워크 문제를 좀 더 체계적으로 처리할 수 있게 된다. 아래는 그 예시다.

 

class NetworkException(message: String, cause: Throwable? = null) : Exception(message, cause)

interface MyApiService {
    @GET("posts")
    suspend fun getPosts(): List<Post>
}

val retrofit = Retrofit.Builder()
    .baseUrl(BASE_URL)
    .addConverterFactory(GsonConverterFactory.create())
    .addCallAdapterFactory(CoroutineCallAdapterFactory())
    .build()

val apiService = retrofit.create(MyApiService::class.java)

try {
    val posts = apiService.getPosts()
    // 가져온 데이터 처리 로직
} catch (e: HttpException) {
    // Handle specific HTTP error codes
    when (e.code()) {
        404 -> {
            // 서버에서 가져온 값이 없을 경우 로직 추가
        }
        // 그 외 다른 HTTP 에러 코드에 대한 처리
    }
} catch (e: IOException) {
    // 네트워크 에러 처리
    throw NetworkException("네트워크 오류 발생", e)
} catch (e: Exception) {
    // 기타 일반적인 오류에 대한 처리
}

 

 

NetworkException은 코드 최상단을 보면 알겠지만 커스텀해서 만든 예외 클래스다. 이를 통해 레트로핏으로 API 통신 시 try-catch의 catch 안에서 IOException 발생 시 커스텀 예외 클래스를 사용해 에러를 처리하고 있다.

또한 HttpException이 발생해 HTTP 에러 코드를 받게 될 경우 코드 별로 처리하기 위해 when을 사용해 코드 별로 블록을 만들 수 있다. 에러 코드를 그대로 쓰는 대신 상수를 활용할 수도 있을 것이다.

그러나 위 코드는 오류를 처리하고는 있지만 앱이 다운되는 현상에 대한 예외처리는 되어 있지 않다. 즉 catch 블록이 호출되면 앱은 다운된다. 그래서 어떤 에러가 발생하면 유저에게 알릴 방법을 정의해 두거나, 유저를 어떤 화면으로 이동시켜서 어떻게 행동하도록 유도할지 등의 에러 정책을 정해둬야 한다.

모든 에러에 개별적으로 일일이 대응하는 것은 사실상 불가능하지만 그렇더라도 가능한 많은 경우의 수를 고려해서, 예외가 발생해도 앱이 다운되지 않게 하는 게 중요하다. 앱이 다운되는 것은 UX에서 가장 치명적인 결함이다. 당장 본인이 자주 쓰는 앱이 갑자기 다운되서 못 쓰게 되거나 작성하던 게 다 날라간다면 어떤 기분일지 생각해 보자.

 

코루틴을 사용한 에러 처리

 

코루틴을 쓰면 일시 정지된 작업을 실행하고 runCatching {}을 써서 예외를 처리할 수 있다. 이것은 코드 구조를 간소화해서 더 쉽게 예외를 수집, 처리할 수 있게 도와준다.

 

fun main() = runBlocking {
    val result = fetchData()
    when (result) {
        is Result.Success -> {
            // 성공 시 처리
        }
        is Result.Error -> {
            // 에러 시 처리
        }
    }
}

suspend fun fetchData(): Result<Data> = coroutineScope {
    runCatching {
        // 비동기 처리 시작 -> 요청에 성공했을 경우
        Result.Success(data)
    }.getOrElse { exception ->
        // 에러를 받으면 수행할 로직
        Result.Error(exception.localizedMessage)
    }
}

 

일시 정지 함수인 fetchData()는 코루틴을 써서 비동기 작업을 수행한다. 이 과정에서 발생할 수 있는 예외를 처리하기 위해 runCatching을 사용하고 성공이나 오류 메시지를 각각 Result.Success, Result.Error 형태로 리턴한다.

그리고 함수 호출부에서는 Success 또는 Error 형태 중 어떤 타입의 값을 받았느냐에 따라 다른 처리를 수행한다. 성공했다면 받은 값을 활용하고 에러가 발생했다면 토스트나 다이얼로그를 띄우는 등 처리를 수행할 수 있다.

 

이 다음 내용은 RxJava를 사용할 경우 에러 처리를 다루는 내용인데, RxJava는 다루지 않은지 오래됐고 사실상 안드로이드에선 코루틴이 RxJava보다 비동기 처리 도구의 대표주자로 자리잡은 느낌이라 생략한다.

 

앱 뿐 아니라 웹이든 서버든 에러 발생은 피할 수 없다. 또한 아무리 개발을 잘하더라도 에러에서 완전히 자유로울 수는 없다. 그러나 최대한 현명하게 에러를 처리하려고 노력하면 사용자에게 전하고자 하는 가치를 최대한 온전히 전달할 수 있을 것이다.

이 포스팅에서 다룬 방법들이 전부는 아니다. 분명 더 많은 방법이 있을 것이다. 직접 찾아보고 실행해 보면서 에러 처리에 대한 시야를 넓히고 경험치를 쌓는 게 중요하다고 생각한다.

반응형
Comments