Android 개발/Kotlin

runCatching이 코루틴 취소까지 삼킨 날, 코틀린 에러 처리를 다시 짰습니다

stackD 2026. 10. 15. 18:00
반응형

얼마 전에 코드 리뷰를 하다가 몇 달 전 제가 짠 catch (e: Exception) 아래 // TODO: 나중에 처리 를 발견했어요. 근데 그 "나중" 은 끝내 오지 않았습니다. 실패를 감싸기만 하고 눈감아온 흔적이더라고요.

 

이건 실패를 없앤 이야기가 아니라, 실패를 눈에 보이게 만든 이야기입니다. 예외로 던질지 값으로 돌려줄지 매번 멈칫하던 자리를, 3주 동안 코틀린 Result 에러 처리와 sealed 타입으로 다시 짜본 회고예요.

 

try-catch가 겹겹이 쌓여 실패 흐름을 놓친 순간

문제의 함수는 반환 타입이 String 이었어요. 근데 실제로는 네트워크 실패, 파싱 실패, 검증 실패 세 갈래로 던지고 있었습니다. 시그니처만 봐선 이게 실패할 수 있는 함수인지조차 알 수가 없더라고요.

 

그러니까 호출하는 쪽은 무슨 예외가 올지 몰라서 방어 코드를 겹겹이 붙이게 됩니다. try-catch가 서너 겹 중첩되니까 정작 정상 로직이 catch 사이에 파묻혀서 안 보였어요.

 

근본 원인은 하나였습니다. 예외는 타입 시스템 바깥에 있어서, 컴파일러가 "너 이거 처리했니?" 를 검사해주지 못한다는 점입니다. 던지는 쪽과 받는 쪽의 약속이 코드가 아니라 머릿속에만 있었던 거죠.

 

코틀린 Result와 runCatching으로 실패를 값으로 다루기

그래서 runCatching 으로 갈아엎었습니다. try-catch를 걷어내고 성공·실패를 분기하니까 호출부가 확 짧아지긴 했는데, api.fetchUser 처럼 suspend 함수를 감쌀 땐 CancellationException 까지 같이 삼켜버린다는 걸 뒤늦게 알았습니다. 그래서 취소만 다시 위로 던져주는 래퍼를 하나 끼워뒀어요.

 

inline fun <T> runCatchingCancellable(block: () -> T): Result<T> =
    runCatching(block).onFailure { if (it is CancellationException) throw it }

val result = runCatchingCancellable {
    api.fetchUser(id).name
}.getOrDefault("알 수 없음")

 

깔끔한 코드라고 좋아하다가 며칠 뒤에 화면이 조용히 멈추는 버그를 만나고서야, runCatching 이 모든 Throwable 을 삼킨다는 문제를 제대로 실감했어요.

 

코루틴에서 이게 진짜 지뢰입니다. 코루틴은 정상 취소될 때 CancellationException 을 던져서 위로 전파하는데, runCatching 이 이걸 잡아버리거든요. ViewModel 스코프가 취소돼도 안에서 실패 처리 코드가 계속 굴러가는 상황이 생기는 겁니다. 화면은 떠났는데 코드는 살아있는 셈이죠.

 

한 가지 더 걸린 게 있었어요. 표준 kotlin.Result 는 에러 타입이 Throwable 로 고정돼 있다는 점입니다. "타입에 담자" 면서 정작 실패의 종류는 못 좁힌다는 얘기가 됩니다. 커스텀 에러를 non-Exception으로 표현하고 싶으면 michaelbull의 kotlin-result 나 Arrow의 Either 를 따로 가져와야 하더라고요.

 

sealed interface로 도메인 에러 분기를 강제하기

그래서 결국 실패의 종류를 직접 타입으로 그렸습니다. sealed로 모델링하면 실패마다 데이터를 실어 나를 수 있어요.

 

sealed interface UserError {
    data class Network(val code: Int) : UserError
    data object NotFound : UserError
    data class Validation(val field: String) : UserError
}

 

이렇게 해두면 원인별로 UI 메시지를 다르게 띄울 수 있습니다. 근데 sealed의 진짜 힘은 딴 데 있었어요. 바로 when 이 모든 케이스를 다 처리하도록 컴파일러가 강제한다는 점입니다.

 

val message = when (error) {
    is UserError.Network -> "네트워크 오류 (${error.code})"
    UserError.NotFound -> "사용자를 찾을 수 없어요"
    is UserError.Validation -> "${error.field} 값을 확인해주세요"
}

 

여기서 제가 배운 게 하나 있어요. 습관적으로 else -> {} 를 붙이는 순간 이 안전장치가 통째로 꺼진다는 겁니다. 나중에 UserError.Timeout 같은 케이스를 추가해도, else 가 있으면 컴파일러가 조용히 넘어가버리거든요. else 를 안 쓰는 게 sealed를 쓰는 이유였던 거죠.

 

이 접근은 러스트(Rust)를 보면 더 선명해집니다. 러스트엔 예외가 아예 없어요. 복구 가능한 실패는 전부 Result<T, E> 로 돌려주고, 미처리하면 #[must_use] 로 컴파일러가 경고를 던진다고 합니다. 오류를 "예외적 사건" 이 아니라 처음부터 예상된 결과값으로 취급하는 방식인데, 이 생각을 코틀린으로 옮겨온 셈입니다.

 

예외로 던질지 값으로 돌려줄지 가르는 기준

3주 뒤에 제가 내린 판단선은 딱 한 문장으로 줄더라고요. "호출자가 복구 가능하고, 예상 가능한 실패인가." 이걸 기준으로 세 갈래로 정리했습니다.

 

  1. 프로그래머 실수는 던집니다. 널 참조, 계약 위반처럼 코드가 잘못 짜인 경우는 값으로 감싸지 말고 예외로 던져서 빠르게 죽이는 게 나아요. 숨기면 더 늦게, 더 크게 터집니다.
  2. 도메인상 정상 실패는 값으로 돌려줍니다. 입력 검증 실패, 찾는 리소스가 없는 경우는 충분히 예상되는 결과라 타입에 담습니다.
  3. 경계에서는 변환합니다. 스프링(Spring) 같은 프레임워크는 예외 기반이라, 어느 지점에선 Result와 예외를 서로 바꿔주는 코드가 생겨요. 이 경계를 어디에 둘지는 팀 규칙으로 못박아두는 게 좋았습니다.

 

성능은 케이스마다 다릅니다. 예외는 스택 트레이스를 만드는 비용이 있어서 흐름 제어에 남용하면 불리하다는 얘기가 많은데요. 반대로 실패가 워낙 드물고 스택 추적이 꼭 필요한 자리라면 예외가 더 적절할 수 있어요. 그래서 "값 반환이 무조건 빠르다" 는 건 과장이라고 봅니다.

 

Result를 남용하지 않으려고 그은 선

값으로 돌려주는 게 무조건 선은 아니었어요. 모든 함수가 Result 나 Either 를 반환하기 시작하면 호출부마다 언랩이 붙어서 오히려 코드가 장황해집니다.

 

전염성도 무섭더라고요. 한 함수가 Result를 반환하면 그걸 부르는 상위 함수들이 줄줄이 물듭니다. 리팩터링 한 번에 파장이 계층을 타고 올라가는 거예요. 그래서 저는 도입 범위를 도메인 계층으로만 한정하고, 단순 스크립트나 프로토타입엔 그냥 예외를 씁니다. 그게 더 간결하고 빠르니까요.

 

가독성도 결국 팀 숙련도에 달렸습니다. flatMap 체인으로 성공 경로만 쭉 이어붙이는 방식이, 명령형에 익숙한 동료에겐 오히려 읽기 어려운 난독 코드로 보이기도 했어요. 그래서 팀에 이걸 들일 땐 "값이라서 좋은 게 아니라 컴파일러가 실패 처리를 강제해서 좋은 거다" 를 먼저 합의하는 게 중요했습니다.

 

지난 3주를 정리하면 세 줄입니다. 예상되는 실패는 타입에 담고 프로그래머 실수는 예외로 던진다는 것, runCatching 은 코루틴에서 취소까지 삼키니 조심해야 한다는 것, sealed의 힘은 else 를 안 쓰는 데서 나온다는 것.

 

저는 이제 두 갈래 앞에서 예전처럼 오래 멈칫하지 않아요. 실패를 감싸서 catch 아래에 묻어두는 길과, 반환 타입에 그 실패의 이름을 하나씩 적어두는 길. 저는 후자를 골랐습니다. 다음번에 catch (e: Exception) 아래 // TODO 를 또 만나게 될 저는, 컴파일러가 "이거 아직 안 봤는데?" 라고 대신 물어봐주는 코드 앞에 앉아있을 겁니다.

 

반응형
LIST