코틀린에서 있어보이게 try-catch 사용하기

최혜성·2024년 2월 13일
1

try-catch는 너무 안이뻐

java에서는 늘상 exception이 발생하는 메소드, 클래스라면 try-catch문을 사용한다.

try {
	ImageIO.read(~~~);
}
catch(Exception e) {
	e.printStackTrance();
}

해당 구문은 이젠 설명하지 않더라도 다들 알고있는, 그런 문법이다.

하지만, 뭔가 너무 구문이 이쁘지 않다.
이를 kotlin에선 하나의 flow처럼 사용할 수 있게 만들어놨다.

runCatching {
	ImageIO.read(~~)
}
.onSuccess {
	println("Success!")
}
.onFailure {
	println("Failed..")
}

기존 try-catch문 보다 구문은 좀더 길어졌지만, 성공과 실패를 명확하게 나눠 처리할 수 있게 되었다.
또한, .getOrNull등을 이용해서 성공시 결과값을, 실패시 null을 반환받을 수 있어, 기존 try-catch문과 달리 결과값 핸들링이 훨씬 편해졌다.

val divideResult = runCatching {
	7 / 3
}.getOrNull()

그 외에도 getOrThrow와 같이 여러가지 처리를 도와주는 함수가 있다.

개인적인 생각

이러한 runCatching에 대한 글은 꽤 많이 올라와있다. 하지만, 나는 해당 클래스의 내부 구조에 대해 말하고 싶다.

runCatching함수는 다음과 같이 구성되어 있다.

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

기존 try-catch를 이용해서 exception (throwable)이 발생하지 않는 경우 block의 실행 결과를 담은 Result<R>을 반환, 실패시 Exception을 래핑한 Result를 반환한다.

Result.failure / Failure

해당 Result.failure는 Throwable을 래핑하는 Result의 형태로 만들어진다.

public inline fun <T> failure(exception: Throwable): Result<T> =
            Result(createFailure(exception))

이때 createFailure 함수는 다음과 같다.

internal fun createFailure(exception: Throwable): Any =
    Result.Failure(exception)
    
internal class Failure(
        @JvmField
        val exception: Throwable
    )

createFailure 함수가 exception을 래핑하는 Failure 클래스를 반환하고, 이를 Any로 바꾼뒤, Result에 담겨진다.

값 가져오기

그러면 값을 가져오는건 어떻게 할까? 성공했을때야 제네릭으로 받아와서 하면 되지만, 실패했을때는?

public val isSuccess: Boolean get() = value !is Failure
public val isFailure: Boolean get() = value is Failure

public inline fun getOrNull(): T? =
        when {
            isFailure -> null
            else -> value as T
        }

해당 부분은 isSuccess와 isFailure 프로퍼티를 이용해서 value값이 Failure 인스턴스인경우 null을, 아닌경우 제네릭값인 T로 캐스팅하여 반환한다.

위 isSuccess와 isFailure를 이용하여 onSuccess와 onFailure도 구현된다.

public inline fun <T> Result<T>.onFailure(action: (exception: Throwable) -> Unit): Result<T> {
    contract {
        callsInPlace(action, InvocationKind.AT_MOST_ONCE)
    }
    exceptionOrNull()?.let { action(it) }
    return this
}

exceptionOrNull은 Failure 객체일경우 내부에 담긴 exception인 value를 가져와 반환한다.

래핑..?

이러한 과정을 결과값의 래핑이라는 관점에서 봤을때는 명확하고 좋지만, 내부에서 타입 캐스팅이 너무 자주 발생한다.

실제 값이 담기는 프로퍼티를 value : T가 아닌, value : Any로 선언한뒤, 제네릭을 getOrNull과 같이 결과값을 받는 함수에다 적용하여 최종적으로 캐스팅하여 반환하는 방식이다. (getOrNull 함수 참조)

Thinking..🤔

이점에서 나는 굳이 Failure로 결과를 래핑하지 않고, 제네릭을 좀더 명확하게 사용할 수 있도록 exception 프로퍼티를 Result에 넣는 방안을 생각했다.

class Result<T> (
	private val value : T?,
    private val exception : Exception?
)

만약 결과가 성공했을경우 value에는 값이 담길것이고, exception은 null이 될것이다.
반대로, 결과가 실패했을때는 value는 null이 되고 exception에는 예외가 들어갈것이다.

이 구조대로라면 타입 캐스팅과 검사 필요없이 프로퍼티를 체크하고, 그대로 반환할 수 있다.

public val isSuccess: Boolean get() = value != null && exception == null
public val isFailure: Boolean get() = value == null && exception != null

public inline fun getOrNull(): T? = value

기존에 있던 캐스팅이 사라지고 바로 value를 반환할 수 있게 되었다.

public inline fun <T> Result<T>.onFailure(action: (exception: Throwable) -> Unit): Result<T> {
    contract {
        callsInPlace(action, InvocationKind.AT_MOST_ONCE)
    }
    exception?.let { action(it) }
    return this
}

기존 Failure 인스턴스인지 체크할 필요 없이 바로 프로퍼티에 접근하여 사용할 수 있다.

이런 부분을 들어 Result에 exception property가 들어가면 좀더 좋지 않을까.. 라는 생각을 해봤다.

안돼 안바꿔줘 돌아가

이런거 물어보고 의견 나눌 수 있는 사람이라도 있었으면ㅠㅠ

나의 스승님 ChatGPT에게 의견을 물어봤다.
GPT 3.5는 만족스러운 답변을 내 주지 못했다.
(Result클래스를 내가 짠 코드인마냥 해석해서 바꿔도 좋아요~ 라는 식으로 답변함)

GPT 4에게 물어봤다.

코틀린의 Result 클래스는 실패한 경우 예외를 포장해서 사용하게 설계되어 있습니다. 그래서 is 연산자를 사용해서 오류가 발생했는지 여부를 체크하는 것이 일반적인 사용법입니다.

그런데 질문에서 제안하신 것처럼 Exception 프로퍼티를 Result 클래스에 추가하는 방법도 생각해 볼 수 있습니다. 이렇게 하면 is 연산자를 사용하지 않고도 오류를 확인할 수 있겠죠. 하지만 이 방법은 몇 가지 고려해야 할 사항이 있습니다.
Result 클래스는 성공이든 실패든 둘 중 하나의 상태만 갖습니다. 만약 Exception 프로퍼티를 추가하게 되면, 성공한 경우에도 이 프로퍼티가 null인 상태를 유지해야 합니다. 이를 통해 불필요한 메모리 할당이 발생할 수 있습니다.

Exception 프로퍼티를 추가하면, 성공과 실패 상태를 동시에 가질 수 있는 문제가 발생할 수 있습니다. 이는 결과의 상태를 파악하는데 혼란을 줄 수 있습니다.
is 연산자를 사용하는 대신 Exception 프로퍼티를 체크하는 방식은 코드의 가독성을 떨어뜨릴 수 있습니다. 코드를 읽는 사람이 Result의 상태를 명확하게 이해하기 어렵게 만들 수 있습니다.
따라서, Exception 프로퍼티를 추가하는 것보다는 기존의 방식을 따르는 것이 더 좋은 방법일 수 있습니다. 이는 코틀린의 설계 철학 중 하나인 '명확성'을 더 잘 반영하는 방식이라고 볼 수 있습니다.

그러나 이러한 설명이 고정된 정답은 아닙니다. 사용하는 상황과 요구사항에 따라 가장 적합한 방식을 선택하는 것이 중요합니다. 이에 대해 더 궁금한 점이 있으시다면 언제든지 말씀해주세요!

원하는 답변을 얻을 수 있어서 너무 좋았다.

내가 생각한 방법에서 크게 간과한점이 있었다. Result는 '결과'를 나타내는 클래스라 성공인지 실패인지 명확한 '결과'를 보여줬어야 했다.

하지만 내가 변경한 코드는 명확성이 너무 떨어졌다. 물론 flow상으로는 성공시 value에 값이 들어가고 exception은 null이 되고.. 등등의 성공과 실패 여부를 보여줄 수 있다.

그렇지만, 해당 코드를 사용하는 사람 입장에서는 성공의 여부를 믿을 수 없었다.
이럴 일은 거의 없겠지만, 만약 value에도 값이 있고, exception에도 값이 들어가있으면 이 결과는 성공인가 실패일까?

추가로, 성공시 exception 프로퍼티에 null을, 실패시 value 프로퍼티에 null을 할당해야 하는데, 이 부분에 있어서도 불필요한 자원이 소모될 수 있다고 한다.

그래서 타입 캐스팅을 쓰더라도, Any로 성공과 실패에 대한 응답을 받아서, 이를 적절하게 핸들링 한뒤, value를 '직접'접근하는게 아닌 getOrNull과 같은 편의 메소드를 거쳐 사용함으로써 사용자에게 '명확성'을 보증해줄 수 있다는점이였다.

-> 따라서 Result가 갖고 있는 value값은 항상 null이 아니다. 성공하면 response고 실패하면 exception이 담긴 Failure다. 이 점에서 가장 큰 강점을 가진게 아닐까?

Conclusion

'은 탄환은 없다 (No Silver Bullet)'라는 말이 있다. (Magical Silver Bullet)
이는 '어떠한 문제를 해결할 수 있는 만병통치약은 없다' 라는 뜻으로 볼 수 있다.
아무리 해당 문제를 해결할 좋은 방법이 주어지더라도, 그 방법에 대한 단점 또한 항상 존재한다.

MSA를 예로 들자면 기존 모놀리식 구조에 비해 더 나은 트래픽 처리와, 부하 분산, 서킷 브레이커, 훨씬 나아진 확장성과 같은 매우 큰 장점이 있지만 엔티티의 join 처리시 모놀리식 구조에서는 그냥 조인만 하면 끝나지만 MSA는 모든 서비스를 순회하며 다 요청해야 하는 등 몇가지 단점이 있다.

그래서, 기존 kotlin Result 클래스는 Any의 사용, 타입 캐스팅을 통해 제네릭이라는 구조를 잘 활용하지 못했지만, 결과값을 확실하게 보여줄 수 있는 명확성과, 그에 대한 대비책 (value의 internal 선언, getOrNull, getOrThrow등의 함수 지원)등을 통해 대비책 또한 세워 두었다.

요번 포스트를 통해 항상 완벽한 해답은 없다 라는것도 알게 되었고, 확실히 프로퍼티에 보여주기만 급급했던 방식에서 벗어나 좀더 명확하고, 알기 쉬운 코드를 작성해보겠다고 생각했다.

profile
KRW 채굴기

0개의 댓글