go, rust 언어와 같은 경우, 예외를 다루는 try-catch문은 없고 항상 함수의 결과로 실패를 명시적으로 다루게 만든다.
코틀린에서도 예외를 함수형처럼 다룰수있는 Result가 존재한다.
종종 우리는 domain 레이어에서 다음과 같은 코드를 본다.
interface UserRepository{
suspend fun getUser(): Result<User>
suspend fun updateUser(params:UpdateUserParams): Result<Unit>
}
이와 같은 처리는 다음과 같은 장점이 있다.
1. 예외가 발생가능하다는 것을 명시적으로 표시하는 계약으로 작동
2. 해당 프로젝트의 표준 패턴으로, 예외처리 누락을 명시적으로 막아 ANR 가능성을 낮춤
하지만 코틀린 코루틴에서는 적용되지 않는 특별한 점이 있다.
일반적인 예외가 아니라 코루틴을 취소하기 위한 특별한 예외인 CancellationException로 인해,
suspend 함수에서 리턴값을 Result로 하는 것은 권장되지 않는다.
코루틴은 해당 예외가 발생하면 전파(rethrow) 되어야 취소가 정상적으로 처리된다.
Result.failure(CancellationException(...))로 감싸면 코루틴 취소가 되지 않고 무시될 수 있어, 메모리 누수나 예기치 않은 동작이 발생할 수 있다.
즉, 취소된 줄 알았는데 계속 돌고 있음이 생길 수 있다.
물론 이에 대한 해결책은 간단하다.
다음과 같이 runCatching을 래핑한 헬퍼 함수를 만들어 CancellationException은 무조건 rethrow 하도록 할 수 있다.
inline fun <T> runSuspendCatching(block: () -> T): Result<T> {
return try {
Result.success(block())
} catch (e: Throwable) {
if (e is CancellationException) throw e
Result.failure(e)
}
}
하지만 코루틴에서 구조적인 예외 처리를 제대로 하려면 suspend 함수에서 Result<T>를 반환하지 않는 것이 일반적으로 더 좋다.
코루틴의 핵심 철학은 예외를 구조적으로 전파하고, 취소(Cancellation)을 명확히 반영하는 것이다.
그런데 Result로 감싸버리면 아래의 문제가 생긴다.
1. 예외가 Result.failure로 감싸지면, 코루틴 스코프에서 CoroutineExceptionHandler나 supervisorScope 등 구조적 예외 관리가 작동하지 않음
2. CancellationException이 Result.failure에 들어가면 코루틴이 취소되지 않음 (가장 치명적)
3. 매번 result.isSuccess, result.getOrElse 같은 분기처리가 필요해짐(try-catch는 happy-path 기반)
즉 고민거리는 다음과 같을 것이다.
"viewModel이랑, repository랑 거리가 멀어졌을때, 이 Repository나 이 함수는 과연 예외를 던질까?
여기서 항상 try-catch를 해야해서 불안하지 않을까?"
코드베이스가 커질수록 흔히 겪는 고민이자, Result 기반을 선호하는 이유 중 하나이기도 하다.
하지만 예외 기반 아키텍처에서도 이 문제는 이미 잘 해결되고 있고,
오히려 Result 기반은 구조적 예외/취소 전파를 해치면서도 문제를 완전히 해결하진 못한다.
예외 기반은 어떻게 이걸 해결하나?
바로 “예외 발생 가능성은 예상되는 계층에서만 처리” 라는 원칙이다.
예외를 계층 간 계약 으로 본다.
suspend fun getUser(): User
→ 이 시그니처는 “성공하면 반드시 User를 반환한다. 실패하면 예외가 던져진다”를 의미
따라서, ViewModel 같은 상위 계층에서만 try-catch 하면 충분함
Kotlin 표준 라이브러리도 예외 기반
- File.readText() → 실패 시 IOException 던짐
- Json.decodeFromString<T>(...) → 실패 시 JsonEncodingException 던짐
코드베이스가 커지고, 관련자가 늘어날수록, "Result에서 CancellationException를 감싸지 않는다"는 지켜지지 않을 확률이 높아진다.
또한, 팀 내부에서만 공유되는 정보가 되고 비표준 함수로 자리잡을 확률이 높다.
catch에서 CancellationException만 다시 던져야 함 같은 룰은
- 문서화돼 있어도 잘 안 읽힘
- IDE나 컴파일러가 강제하지 못함
- 실수 시 증상은 비동기 환경에서 매우, 매우, 매우, 불규칙하게 발생 (디버깅 어려움)
- 결국 묻힌 채 누적됨
- 결국 잘 안 쓰고 Result.failure(e)만 남음
try-catch는 대규모 코드베이스, Android에서 구닥다리 안티패턴인가?
결론: 걱정할 필요 없음
Result를 쓰든 try-catch를 쓰든 CancellationException을 삼키면(swallow) 안 된다는 원칙은 동일하게 적용된다.
Result를 사용하면 예외를 값으로 변환해야 하므로 runSuspendCatching 같은 래퍼 함수가 항상 강제된다. 이 래퍼 안에서는 CancellationException을 다시 던지는 로직이 반드시 들어가야 한다.
'Java & Kotlin' 카테고리의 다른 글
[Kotlin] 코틀린 코루틴과 Flow (0) | 2025.04.12 |
---|