프로젝트를 진행하며 에러 처리를 하는 모든 곳을 try catch로 진행을 했다.
kotlin Exception 문서를 공부하며 try catch로 에러핸들링을 하는 방법을 공부했고 간편하다고 생각했기 때문이다.
프로젝트의 기능들을 거의 구현하고 나서 앞으로의 공부 방향성과 코드 작성 방법 등 여러가지 조언을 얻기 위해 멘토링을 신청하게 되었다.
멘토님과 여러가지 대화가 오가던 중 "try catch로 에러 핸들링을 하셨던데 어떠한 이유가 있으신가요?" 라는 질문을 하셨고 try catch가 기본적인 에러 핸들링을 하는 방법이라 생각해 사용했다고 답변했다.
멘토님 께서 try catch에서 catch의 깊이가 깊어질수록 코드가 길어지고 가독성이 떨어질 수도 있다는 의견을 주셨다.
유저 입장에서 각 에러마다 어떠한 에러가 났는지 다르게 보여주는게 유저친화적이라 내가 처리할 수 있는 코드는 catch로 잡으려고 했던 것 같다.
override suspend fun acceptGuestUser(postUid: String, userUid: String): Unit {
return try {
// 에러가 일어날 수 있는 코드
} catch (e: FirebaseFirestoreException) {
if(e.code == FirebaseFirestoreException.Code.NOT_FOUND) {
// 에러 처리
}
else // 에러 처리
} catch (e: Exception) {
// 에러처리
}
}
실제로 이런 코드들이 많았고 코드 가독성과 보일러 플레이트가 발생하고 있었다.
에러 처리의 다른 방법을 찾기 위해 다른 개발자님들이 진행하신 프로젝트들의 코드를 뜯어봤다.
그 중 runCatching을 사용하신 분들이 많았고 이를 참고하여 runCathing에 대해 자세히 공부하고 리팩토링을 해볼 생각이 들었다.
runCatching에 앞서 Result 타입에 대해 알아보겠습니다.
@SinceKotlin("1.3")
@JvmInline
public value class Result<out T> @PublishedApi internal constructor(
@PublishedApi
internal val value: Any?
) : Serializable {
public val isSuccess: Boolean get() = value !is Failure
public val isFailure: Boolean get() = value is Failure
public companion object {
@Suppress("INAPPLICABLE_JVM_NAME")
@InlineOnly
@JvmName("success")
public inline fun <T> success(value: T): Result<T> =
Result(value)
@Suppress("INAPPLICABLE_JVM_NAME")
@InlineOnly
@JvmName("failure")
public inline fun <T> failure(exception: Throwable): Result<T> =
Result(createFailure(exception))
}
...
Result 타입은 실행 결과를 성공(Success) 또는 실패(Failure) 상태로 캡슐화하는 일종의 컨테이너입니다.
성공한 경우 성공 값(value)이 담깁니다.
실패한 경우 예외(exception)이 담깁니다.
보통 Result 객체는 직접 생성하기 보다 runCatching 함수를 통해 만들어 집니다.
@InlineOnly
@SinceKotlin("1.3")
public inline fun <T, R> T.runCatching(block: T.() -> R): Result<R> {
return try {
Result.success(block())
} catch (e: Throwable) {
Result.failure(e)
}
}
내부에서 try catch을 이미 사용하고 있으며 성공한다면 Result.sucess(block()) 반환, 실패한다면 Result.failure를 반환합니다.
또한 여러 확장함수들이 존재합니다.
@InlineOnly
@SinceKotlin("1.3")
public inline fun <T> Result<T>.onSuccess(action: (value: T) -> Unit): Result<T> {...}
@InlineOnly
@SinceKotlin("1.3")
public inline fun <T> Result<T>.onFailure(action: (exception: Throwable) -> Unit): Result<T> {...}
@InlineOnly
@SinceKotlin("1.3")
public inline fun <R, T> Result<T>.fold(
onSuccess: (value: T) -> R,
onFailure: (exception: Throwable) -> R
): R {...}
@InlineOnly
@SinceKotlin("1.3")
public inline fun <R, T> Result<T>.mapCatching(transform: (value: T) -> R): Result<R> {...}
@InlineOnly
@SinceKotlin("1.3")
public inline fun <R, T : R> Result<T>.recoverCatching(transform: (exception: Throwable) -> R): Result<R> {
return when (val exception = exceptionOrNull()) {
null -> this
else -> runCatching { transform(exception) }
}
}
Result 타입은 이런 여러 연산자를 사용해 여러 단계의 연산과 에러 처리를 연쇄적으로 구성할 수 있습니다.
이를 통해 try catch 블록을 중첩하는 대신, 함수형 체인 형태로 코드의 가독성을 높일 수 있다고 생각합니다.
(개인적인 의견입니다..ㅎㅎ)
또 내부에서 발생한 예외를 외부러 직접 던지지 않고, Result 객체에 캡슐화 하여 반환할 수 있습니다.
이런 연산자를 사용해서 Domain에서 던져질 에러들을 명시하고 Result에서 이 에러를 매핑시키는 작업을 해보겠습니다.
커스텀 에러를 작성하고 도메인 모듈에서 작성하고 데이터 모듈에서 일어나는 에러를 매핑시켜 에러 관리를 해보려고 합니다.
도메인 모듈(고수준)은 애플리케이션의 비즈니스 로직을 담고 있으므로 다른 모듈들이 도메인에 의존하게 됩니다.
발생할 수 있는 에러를 도메인 모듈에서 캡슐화 하면 하위 모듈에서 발생하는 예외들을 도메인 에러로 변환하여 일관되게 전파할 수 있습니다.
모든 모듈에서 공통으로 사용할 에러 타입을 도메인 계층에 두면, 새로운 에러 유형이 추가되거나 에러 처리 로직이 변경될 때 한 곳만 수정하면 됩니다.
sealed class DomainError : Throwable() {
// normal error
data object UnknownError : DomainError() {
private fun readResolve(): Any = UnknownError
}
data object NetworkError : DomainError() {
private fun readResolve(): Any = NetworkError
}
// firebase error
data object DocumentNotFound : DomainError() {
private fun readResolve(): Any = DocumentNotFound
}
data object PermissionDenied : DomainError() {
private fun readResolve(): Any = PermissionDenied
}
// server error
data class HttpError(val code: Int) : DomainError()
}
여기서 readResolve()는 직렬화 가능한 싱글톤 객체가 역직렬화될 때 고유성을 보장하기 위해 readResolve 메서드를 구현해야 한다는 의미입니다.
Kotlin의 object나 data object는 싱글톤으로 한 번만 생성되도록 보장되지만, Java 직렬화 메커니즘은 역직렬화 시 새로운 인스턴스를 생성할 수 있습니다. 이 경우 readResolve 메서드를 구현해서 역직렬화할 때 항상 기존의 싱글톤 인스턴스를 반환하도록 해야 합니다.
이 내용에 대해서 아직 많이 부족한 것 같아 공부하여 다음 포스팅에 작성해보도록 하겠습니다.
도메인에서 사용할 커스텀 에러를 명시했고 이제 데이터 모듈에서 작성한 에러로 매핑하는 작업을 해보겠습니다.
Result의 확장함수의 기능에는 recover, recoverCatching이 있습니다.
@InlineOnly
@SinceKotlin("1.3")
public inline fun <R, T : R> Result<T>.recover(transform: (exception: Throwable) -> R): Result<R> {
contract {
callsInPlace(transform, InvocationKind.AT_MOST_ONCE)
}
return when (val exception = exceptionOrNull()) {
null -> this
else -> Result.success(transform(exception))
}
}
Result가 실패(Failure) 상태일 때, transform 함수를 호출하여 예외를 다른 값으로 변환한 후 성공(Result.success)으로 반환합니다.
만약 transform 함수 내부에서 또 다른 예외가 발생하면, 그 예외는 그대로 외부로 던져집니다. 즉, transform 함수가 예외를 던지면 recover 자체는 예외를 잡지 못합니다.
그렇기 때문에 transform 함수가 안정적으로 예외를 변환한다고 확신할 수 있을 때 사용합니다.
@InlineOnly
@SinceKotlin("1.3")
public inline fun <R, T : R> Result<T>.recoverCatching(transform: (exception: Throwable) -> R): Result<R> {
return when (val exception = exceptionOrNull()) {
null -> this
else -> runCatching { transform(exception) }
}
}
Result가 실패 상태일 때, transform 함수를 실행하는데, 이 실행 자체를 runCatching으로 감싸 예외가 발생할 경우 이를 다시 Result.failure로 전환합니다.
transform 함수 내부에서 예외가 발생하더라도, recoverCatching이 자동으로 이를 잡아 Result.failure로 처리하므로, 외부로 예외가 새로 던져지지 않습니다.
때문에 transform 함수 내에서 예외가 발생할 가능성이 있다면, recoverCatching을 사용하여 더 안전하게 복구 로직을 작성할 수 있습니다.
혹시 모를 위험을 대비해 recoverCatching으로 에러를 도메인 에러로 변환시켜 전달하려고 합니다.
이 때 모든 함수마다 recoverCatching을 작성하여 도메인 에러를 매핑시키는 작업을 작성하면 중복 코드가 발생하게 됩니다.
그렇기 때문에 에러를 도메인 에러로 변환하는 확장함수를 만들었습니다.
fun <T>Result<T>.mapDomainError(): Result<T> =
recoverCatching { throwable ->
throw when(throwable) {
is IOException -> DomainError.NetworkError
is FirebaseFirestoreException -> {
when(throwable.code) {
FirebaseFirestoreException.Code.PERMISSION_DENIED -> DomainError.PermissionDenied
FirebaseFirestoreException.Code.NOT_FOUND -> DomainError.DocumentNotFound
else -> DomainError.UnknownError
}
}
is NoSuchElementException -> DomainError.DocumentNotFound
is HttpException -> DomainError.HttpError(code = throwable.code())
else -> DomainError.UnknownError
}
}
에러를 -> 도메인 에러로 변환시키는 작업이기 때문에 mapDomainError()로 네이밍을 했습니다.
이제
override suspend fun checkUserNickName(nickName: String): Result<Boolean> = kotlin.runCatching {
val document = // 에러가 일어날 수 있는 코드
document.isEmpty
}.mapDomainError()
기존의 try catch로 작성하던 함수가 요렇게 변하게 되었습니다.
프레젠테이션 모듈에서는
object ErrorUtil {
fun getErrorString(e: Throwable, context: Context): String {
return when(e) {
is DomainError.NetworkError -> context.getString(R.string.networkerror)
is DomainError.PermissionDenied -> context.getString(R.string.notpermission)
is DomainError.DocumentNotFound -> context.getString(R.string.nosuch)
is DomainError.UnknownError -> context.getString(R.string.defaulterror)
is DomainError.HttpError -> context.getString(R.string.httperror)
else -> context.getString(R.string.defaulterror)
}
}
}
각 에러마다 어떤 메시지를 사용자에게 보여줄 지 보여주는 함수를 작성했습니다.
뷰모델에서는
UseCase(userUid = myUid, height = height).fold(
onSuccess = { loadMyData() },
onFailure = { errorHandling(resourceProvider.getString(R.string.defaulterror))}
)
확장함수인 fold()를 사용했습니다.
상황에 따라서 getOrDefault나 getOrThrow, getOrElse로 작성할 수 있습니다.
이번에는 리팩토링을 진행하며 느꼈던 점을 말해보겠습니다.
사실 리팩토링을 하며 try catch을 사용할 때도 도메인 모듈에서 에러타입을 선언하고 catch 부분에 공통으로 쓰일 Throwable()을 도메인 에러로 매핑해주는 로직을 추가해주면 되는거 아닌가? 생각이 들었습니다.
Result를 사용하고 runCatching을 사용했을 때의 이점은 try catch를 사용하지 않아도 되고 여러 확장함수(fold(),getOr 등등..)들을 통해 여러 연산들을 수행할 수 있고 함수형 프로그래밍 방식으로 코드를 작성할 수 있었습니다.
또한 코드의 가독성이 올라간다? 라고 생각합니다.
여러 블로그를 찾아보니 Result는 try catch 보다 상대적으로 성능 저하가 일어날 수 있다라는 글을 보게 되었습니다.
하지만 아주 미세한 차이일 것이라고 생각합니다.
try catch를 사용했을 때도 코드를 잘 작성하면 코드의 가독성과 유지보수 면에서 그리 나쁘지 않겠는데? 라는 생각 또한 들었습니다.
아무생각 없이 catch{} catch{} catch{} 를 한 저를 다시 되돌아보게 된 계기였습니다.
개인프로젝트라면 본인이 원하는 방법을 사용하고 협업 시 프로젝트 고려사항과 팀원 간 협의를 통해 어떤 방법을 선택할지 정하면 될 것 같습니다.
아직 부족한 부분이 많아 잘못된 부분이 있다면 댓글로 알려주시면 감사하겠습니다~