Kotlin의 runCatching과 Result 타입 이해하기

2025년 12월 04일

kotlin

# Kotlin# Result# runCatching# ErrorHandling

runCatching이란?

Kotlin 표준 라이브러리에는 runCatching이라는 함수가 있습니다. 이 함수는 예외가 발생할 수 있는 코드를 실행하고, 그 결과를 Result<T> 타입으로 감싸서 반환합니다.

public inline fun <R> runCatching(block: () -> R): Result<R> {
    return try {
        Result.success(block())
    } catch (e: Throwable) {
        Result.failure(e)
    }
}

내부적으로는 try-catch를 사용하지만, 예외를 던지는 대신 Result 타입으로 감싸서 반환합니다.

Result 타입

Result<T>는 Kotlin 1.3에서 도입된 인라인 클래스(value class)입니다. 성공 또는 실패를 나타내는 두 가지 상태를 가집니다.

@JvmInline
public value class Result<out T> @PublishedApi internal constructor(
    @PublishedApi internal val value: Any?
) : Serializable

value class로 구현되어 있어서 런타임에 추가적인 객체 생성 오버헤드가 없습니다.

주요 속성과 메서드

val result: Result<String> = runCatching {
    fetchUserName()
}

// 성공/실패 여부 확인
result.isSuccess  // Boolean
result.isFailure  // Boolean

// 값 추출
result.getOrNull()              // 성공 시 값, 실패 시 null
result.getOrDefault("default")  // 성공 시 값, 실패 시 기본값
result.getOrElse { e -> "error: ${e.message}" }  // 실패 시 람다 실행
result.getOrThrow()             // 성공 시 값, 실패 시 예외 던짐

// 예외 추출
result.exceptionOrNull()  // 실패 시 Throwable, 성공 시 null

try-catch와 무엇이 다른가?

1. 예외를 값으로 다룬다

기존 try-catch는 예외를 제어 흐름으로 처리합니다. 예외가 발생하면 catch 블록으로 점프합니다.

// try-catch: 예외를 제어 흐름으로 처리
fun getUserName(): String {
    return try {
        fetchFromApi()
    } catch (e: Exception) {
        "Unknown"
    }
}

반면 runCatching은 예외를 으로 다룹니다. 성공이든 실패든 Result 타입으로 반환되어 이후 처리를 이어갈 수 있습니다.

// runCatching: 예외를 값으로 처리
fun getUserName(): Result<String> {
    return runCatching { fetchFromApi() }
}

2. 함수형 스타일의 체이닝이 가능하다

Result는 map, mapCatching, recover, recoverCatching, fold 등의 메서드를 제공합니다.

val displayName = runCatching { fetchUserName() }
    .map { name -> name.uppercase() }           // 성공 시 변환
    .recover { e -> "Guest" }                   // 실패 시 복구
    .getOrThrow()

try-catch로 동일한 로직을 작성하면 중첩이 깊어집니다.

val displayName = try {
    try {
        fetchUserName().uppercase()
    } catch (e: Exception) {
        "Guest"
    }
} catch (e: Exception) {
    throw e
}

3. 에러 전파 방식이 명확하다

함수 시그니처에 Result<T>를 반환한다고 명시하면, 호출하는 쪽에서 에러 처리를 강제할 수 있습니다.

// 호출자가 에러를 처리해야 함을 명시
fun parseJson(json: String): Result<User>

// 사용처
val user = parseJson(jsonString)
    .getOrElse { return Response.error(400, "Invalid JSON") }

반면 일반 함수는 어떤 예외가 발생할 수 있는지 시그니처만 보고는 알 수 없습니다.

fold로 성공/실패 한 번에 처리하기

fold는 성공과 실패 케이스를 한 번에 처리할 수 있는 메서드입니다.

val message = runCatching { fetchUser(id) }
    .fold(
        onSuccess = { user -> "Hello, ${user.name}!" },
        onFailure = { e -> "Failed to load user: ${e.message}" }
    )

when 표현식처럼 모든 케이스를 처리해야 하므로, 실수로 에러 케이스를 빠뜨리는 일을 방지할 수 있습니다.

실무에서 언제 사용하면 좋은가?

적합한 경우

  1. 외부 시스템 호출: API 호출, 파일 I/O, DB 쿼리 등 실패 가능성이 있는 작업
  2. 파싱/변환 작업: JSON 파싱, 타입 변환 등 입력값에 따라 실패할 수 있는 작업
  3. 결과를 체이닝해서 처리할 때: map, recover 등으로 변환/복구 로직을 연결할 때
fun fetchAndProcessUser(id: String): Result<UserDto> {
    return runCatching { userApi.fetch(id) }
        .mapCatching { user -> userMapper.toDto(user) }
        .recover { e ->
            logger.warn("Failed to fetch user: $id", e)
            UserDto.UNKNOWN
        }
}

적합하지 않은 경우

  1. 복구 불가능한 오류: 시스템 오류, 프로그래밍 오류는 그냥 예외를 던지는 게 낫습니다
  2. 단순한 null 체크: nullable 타입이나 ?.let 등이 더 간단합니다
  3. 성능이 극도로 중요한 경우: value class이긴 하지만, 제네릭 사용 시 박싱이 발생할 수 있습니다

주의사항

CancellationException 처리

코루틴에서 runCatching을 사용할 때는 주의가 필요합니다. CancellationException까지 잡아버리면 코루틴 취소가 제대로 전파되지 않습니다.

// 잘못된 사용: CancellationException도 잡아버림
suspend fun fetchData(): Result<Data> = runCatching {
    api.fetch()
}

// 권장: CancellationException은 다시 던지기
suspend fun fetchData(): Result<Data> = runCatching {
    api.fetch()
}.onFailure { e ->
    if (e is CancellationException) throw e
}

또는 Arrow 라이브러리의 EitherRaise DSL을 사용하면 이 문제를 더 우아하게 해결할 수 있습니다.

함수형 프로그래밍 관점에서 바라보기

Result 타입은 단순한 유틸리티가 아니라, 함수형 프로그래밍의 핵심 개념들을 담고 있습니다.

참조 투명성 (Referential Transparency)

함수형 프로그래밍에서 참조 투명성이란 “같은 입력에 대해 항상 같은 출력을 반환하고, 부수 효과가 없는 것”을 의미합니다.

예외를 던지는 함수는 참조 투명성을 깨뜨립니다.

// 참조 투명성이 깨진 함수
fun divide(a: Int, b: Int): Int {
    if (b == 0) throw ArithmeticException("Division by zero")
    return a / b
}

// divide(10, 0)을 어디서 호출하든 "값"이 아닌 "예외"가 발생
// 이 표현식을 다른 곳에 대입할 수 없음

Result를 반환하면 참조 투명성을 유지할 수 있습니다.

// 참조 투명한 함수
fun divide(a: Int, b: Int): Result<Int> {
    return if (b == 0) {
        Result.failure(ArithmeticException("Division by zero"))
    } else {
        Result.success(a / b)
    }
}

// divide(10, 0)은 항상 Result.failure(...)라는 "값"을 반환
// 이 값을 변수에 저장하고, 전달하고, 조합할 수 있음

Monad 패턴

Result는 함수형 프로그래밍의 Monad 패턴을 따릅니다. Monad는 값을 감싸는 컨테이너로, 다음 두 가지 연산을 제공합니다.

  1. unit (return): 값을 컨테이너에 넣기 → Result.success(value)
  2. flatMap (bind): 컨테이너 안의 값에 함수를 적용하고 결과를 평탄화 → Result에서는 직접 제공하지 않지만 mapCatching이 유사한 역할
// map: 성공 값을 변환, 컨테이너는 유지
Result.success(5).map { it * 2 }  // Result.success(10)

// 실패 시 map은 무시됨
Result.failure<Int>(error).map { it * 2 }  // Result.failure(error)

이 패턴 덕분에 성공/실패를 매번 체크하지 않고도 연산을 체이닝할 수 있습니다.

Either 타입과의 관계

함수형 프로그래밍에서 흔히 사용되는 Either<L, R> 타입은 “왼쪽(Left) 또는 오른쪽(Right) 값 중 하나”를 담습니다. 관례적으로 Left는 에러, Right는 성공을 나타냅니다.

Either<Error, Success>  ≈  Result<Success>
Left(error)             ≈  Result.failure(error)
Right(value)            ≈  Result.success(value)

Kotlin의 Result는 Either의 특수한 형태로, Left 타입이 Throwable로 고정된 것입니다.

// Arrow 라이브러리의 Either
Either<NetworkError, User>  // 에러 타입을 명시적으로 지정

// Kotlin stdlib의 Result
Result<User>  // 에러는 항상 Throwable

에러 타입을 세분화하고 싶다면 Arrow의 Either나 sealed class를 사용하는 것이 좋습니다.

다른 언어와의 비교: Go, Rust

예외 대신 에러를 값으로 반환하는 철학은 여러 언어에서 찾아볼 수 있습니다.

Go

// Go: 다중 반환값으로 에러 처리
func divide(a, b int) (int, error) {
    if b == 0 {
        return 0, errors.New("division by zero")
    }
    return a / b, nil
}

// 사용
result, err := divide(10, 0)
if err != nil {
    // 에러 처리
}

Rust

// Rust: Result<T, E> 열거형으로 에러 처리
fn divide(a: i32, b: i32) -> Result<i32, String> {
    if b == 0 {
        return Err(String::from("division by zero"));
    }
    Ok(a / b)
}

// 사용 1: match로 처리
match divide(10, 0) {
    Ok(result) => println!("Result: {}", result),
    Err(e) => println!("Error: {}", e),
}

// 사용 2: ? 연산자로 에러 전파
fn calculate() -> Result<i32, String> {
    let result = divide(10, 2)?;  // 실패 시 즉시 Err 반환
    Ok(result * 2)
}

// 사용 3: 체이닝
divide(10, 2)
    .map(|v| v * 2)
    .unwrap_or(0)

Kotlin

// Kotlin Result: 단일 타입으로 에러 처리
fun divide(a: Int, b: Int): Result<Int> {
    if (b == 0) return Result.failure(ArithmeticException("division by zero"))
    return Result.success(a / b)
}

// 사용
divide(10, 0)
    .onSuccess { result -> /* 성공 처리 */ }
    .onFailure { error -> /* 에러 처리 */ }

비교표

구분 Go Rust Kotlin Result
반환 방식 (value, error) 튜플 Result<T, E> 열거형 Result<T> 단일 타입
에러 타입 error 인터페이스 제네릭 E (자유롭게 지정) Throwable 고정
에러 전파 매번 if err != nil ? 연산자 mapCatching 체이닝
체이닝 어려움 map, and_then 등 지원 map, recover 지원
에러 무시 가능성 _로 무시 가능 컴파일러 경고 의도적으로 추출해야 함

Rust의 Result<T, E>가 가장 강력합니다. 에러 타입을 제네릭으로 지정할 수 있고, ? 연산자로 에러 전파가 간결합니다. Kotlin의 Result<T>는 에러 타입이 Throwable로 고정되어 있어 유연성은 떨어지지만, 기존 예외 시스템과의 호환성이 좋습니다.

세 언어 모두 “예외는 예외적인 상황에만, 일반적인 에러는 값으로” 라는 함수형 프로그래밍의 철학을 공유합니다.

Railway Oriented Programming

Result를 사용한 에러 처리는 Railway Oriented Programming(ROP) 패턴과 일치합니다.

철도에 두 개의 트랙이 있다고 상상해보세요:

  • Success 트랙: 정상적인 흐름
  • Failure 트랙: 에러 흐름
[입력] → [검증] → [변환] → [저장] → [출력]
           ↓         ↓         ↓
        [실패] ← [실패] ← [실패] → [에러 응답]

각 단계에서 성공하면 Success 트랙을 따라가고, 실패하면 Failure 트랙으로 전환됩니다. 한번 Failure 트랙에 들어서면 이후 map 연산은 무시되고 바로 끝까지 흘러갑니다.

fun processOrder(orderId: String): Result<Receipt> {
    return validateOrderId(orderId)        // 검증 실패 시 → Failure 트랙
        .mapCatching { findOrder(it) }     // 조회 실패 시 → Failure 트랙
        .mapCatching { calculatePrice(it) } // 계산 실패 시 → Failure 트랙
        .mapCatching { createReceipt(it) }  // 생성 실패 시 → Failure 트랙
}

// 어느 단계에서 실패하든 최종 결과는 Result.failure(...)
// 모든 단계가 성공해야 Result.success(receipt)

이 패턴의 장점은 에러 처리 로직이 비즈니스 로직과 분리된다는 것입니다. 각 함수는 자신의 역할만 수행하고, 에러 전파는 Result가 알아서 처리합니다.

정리

구분 try-catch runCatching + Result
처리 방식 제어 흐름(점프) 값으로 반환
함수형 체이닝 어려움 map, recover, fold 등 지원
에러 전파 암묵적 시그니처에 명시 가능
사용 복잡도 단순 체이닝 시 강력함

runCatchingResult는 예외를 값으로 다루는 함수형 프로그래밍 스타일을 Kotlin에서 쉽게 사용할 수 있게 해줍니다. 복잡한 에러 처리 로직이 필요할 때 특히 유용합니다.

참고 자료

© 2025, 미나리와 함께 만들었음