코틀린 custom exception - koteullin custom exception

TL;DR

  1. Result를 이해한다면, MSA 환경에서 에러가 전파되지 않도록 막을 수 있습니다.
  2. runCatchingResult를 사용하면 에러 핸들링을 클라이언트에게 위임할 수 있습니다.

예제: 로그인 요청을 전달하는 서비스 흐름에서 에러 처리하기

아래와 같은 서비스 호출 흐름이 있다고 가정해보겠습니다.

코틀린 custom exception - koteullin custom exception

Server A 입장에서는 Server B에서 발생하는 에러 처리를 해야하는 고민에 빠집니다.

API를 호출하는 코드에서 API의 에러 응답에 따른 비즈니스 로직을 다르게 가져가고 싶은 경우가 있습니다. 예를 들어 위 사례에서 비밀번호가 틀리거나 이메일 주소가 틀린 경우 이 에러를 캐치해서 다른 메세지를 던지고 싶을 수 있고, 어떤 코드에서는 그 에러를 무시하고 다른 로직을 수행하고 싶을 수 있습니다.

에러 처리를 API Client 단에서 하지 않고 다른 클래스에 위임을 하고 싶은 이런 경우에는 어떤 방법을 사용할 수 있을지 아래 코드 예시로 알아보겠습니다.

// API client @FeignClient internal interface LoginApi { @PostMapping fun login( @RequestBody request: LoginRequestDto ): OtherServiceResponse<LoginResponseDto> } @Component class LoginApiClient internal constructor( private val loginApi: LoginApi ) { fun login(request: LoginRequestDto): LoginResult { return loginApi.login(request).result.toResult() } } @Service class LoginService( private val loginApiClient: LoginApiClient ) { fun login(id: String, pw: String): LoginResult { return try { loginApiClient.login(LoginRequestDto(id, pw)) } catch { // 에러 핸들링 } } }

Code language: Kotlin (kotlin)

이 경우에 아래와 같은 두 케이스를 해결하고 싶어집니다.

  1. 이 API를 사용하는 쪽(ex. LoginService)에서 에러 핸들링을 강제하고 싶습니다.
  2. API 호출 로직마다 에러 핸들링을 다른 방식으로 가져가게 하고 싶습니다.
    • LoginService가 아닌 다른 호출 로직에서는 에러를 다르게 처리하고 싶을 수 있습니다.

위 고민을 해결할 방법이 있습니다. 바로 Result입니다.

@Component class LoginApiClient internal constructor( private val loginApi: LoginApi ) { fun login(request: LoginRequestDto): Result<LoginResult> { return runCatching { loginApi.login(request).result.toResult() } } } @Service class LoginService( private val loginApiClient: LoginApiClient ) { fun login(id: String, pw: String): LoginResult { return loginApiClient.login(LoginRequestDto(id, pw)) .onFailure { // 에러 핸들링 } } }

Code language: Kotlin (kotlin)

코틀린의 runCatching

💡 이미 runCatching을 잘 사용하고 있다면 넘겨도 좋습니다.

위 코드를 이해하기에 앞서서 runCatching을 알아둘 필요가 있습니다. 코틀린은 물론 자바의 try ... catch를 동일하게 지원하지만 이와는 조금 다른 방법으로 에러 핸들링을 할 수도 있습니다.

예제

아래 요구사항이 있다고 가정합시다.

  • LoginApiClient 호출 시 LoginException이 발생했는데,
    • errorCodeINVALID_PASSWORD 인 경우 예외를 발생시키지 않고 null을 반환한다.
  • 그 외 모든 에러 상황에서는 예외를 발생시킨다.

try … catch를 사용했을때

try { loginApiClient.login(request) } catch (e: LoginException) { if (e.errorCode == "INVALID_PASSWORD") { return null } else { throw e } }

Code language: JavaScript (javascript)

Java에서 위와 같이 작성하는 코드를 runCatching을 사용하면 아래처럼 표현할 수 있습니다.

runCatching을 사용했을 때

return runCatching { loginApiClient.login(request) }.onFailure { e -> if (e.errorCode != "INVALID_PASSWORD") throw e }.getOrNull()

Code language: JavaScript (javascript)

kotlin.runCatching

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

Code language: PHP (php)

try..catch 로직을 그대로 사용하지만 Result로 감싸서 반환하는 것을 알 수 있습니다.

  • 에러가 발생하지 않았을 때에는 Result.success 반환
  • 에러가 발생했을 때에는 Result.failure 반환

Result가 뭔가요?

Result가 무엇인지 알아보기 위해서 Kotlin 1.3 표준 라이브러리의 코드를 살펴봅시다.

@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)) } internal class Failure( @JvmField val exception: Throwable ) : Serializable { /* ... */ } }

Code language: Kotlin (kotlin)

즉, Resultvalue

  • 성공일 경우 T를 타입으로 하는 값을 가지게 되고
  • 실패일 경우는 Failure를 wrapper class로 하는 exception을 값으로 가지게 됩니다.

Result가 제공하는 함수들은 다음과 같습니다.

inline fun <T> Result<T>.getOrThrow(): T inline fun <R, T : R> Result<T>.getOrElse( onFailure: (exception: Throwable) -> R ): R inline fun <R, T : R> Result<T>.getOrDefault(defaultValue: R): R inline fun <R, T> Result<T>.fold( onSuccess: (value: T) -> R, onFailure: (exception: Throwable) -> R ): R inline fun <R, T> Result<T>.map(transform: (value: T) -> R): Result<R> fun <R, T> Result<T>.mapCatching(transform: (value: T) -> R): Result<R> inline fun <R, T : R> Result<T>.recover(transform: (exception: Throwable) -> R): Result<R> inline fun <T> Result<T>.onFailure(action: (exception: Throwable) -> Unit): Result<T> inline fun <T> Result<T>.onSuccess(action: (value: T) -> Unit): Result<T>

Code language: Kotlin (kotlin)

Result 사용 예시

runCatchingResult<T>를 반환하게 되는데, Result가 제공하는 함수를 이용해서 다양하게 활용할 수 있습니다.

에러를 무시하고 null 반환

val response = runCatching { login() }.getOrNull()

Code language: Kotlin (kotlin)

기본값 반환

val response = runCatching { login() }.getOrDefault(emptyList())

Code language: Kotlin (kotlin)

에러 발생 시 다른 동작 수행

val response = runCatching { login() }.getOrElse { ex -> logger.warn(ex) { "에러 발생" } // 에러를 던지고 싶다면 throw ex }

Code language: Kotlin (kotlin)

에러가 발생한 경우에만 해당 에러 객체 반환

val exception = runCatching { login() }.exceptionOrNull() // 위에서 받은 에러로 로직 수행 when (exception) { /* ... */ }

Code language: Kotlin (kotlin)

에러가 발생하는지 아닌지만 확인하고 싶을 때에도 유용할 수 있습니다.

val isValidCredential = runCatching { tryLogin() }.exceptionOrNull() != null

Code language: JavaScript (javascript)

성공/에러 시 각각 특정 동작 수행 후 에러 던지기

val response = runCatching { login() }.onSuccess { logger.info("성공!") }.onFailure { logger.info("실패!") }.getOrThrow()

Code language: Kotlin (kotlin)

runCatching으로 try .. finally 구현하기

runCatching { request() }.also { doSomething() }.getOrThrow()

Code language: Kotlin (kotlin)

Result를 사용해서 예외 처리를 다른 클래스에 위임하기

runCatching을 사용하면 Result가 제공하는 다양한 함수의 편의에 기댈 수 있다는 것을 배웠습니다.

Result에 대한 처리를 즉시 하지 않고 함수의 반환 값으로 반환하게 된다면, Result에 대한 핸들링을 다른 클래스에 위임할 수도 있습니다.

LoginApiClient

@Component class LoginApiClient internal constructor( private val loginApi: LoginApi ) { fun login(request: LoginRequestDto): Result<LoginResult> { return runCatching { loginApi.login(request).result.toResult() } } }

Code language: Kotlin (kotlin)

Result를 반환하여 다른 클래스가 에러 핸들링을 하도록 위임합니다.

LoginService

@Service class LoginService( private val loginApiClient: LoginApiClient ) { fun login(id: String, pw: String): LoginResult? { return loginApiClient.login(LoginRequestDto(id, pw)) .getOrNull() } }

Code language: Kotlin (kotlin)

에러가 발생한 경우 에러를 무시하고 기본값으로 null을 반환합니다.

하지만 아래처럼 다른 컴포넌트에서는 에러를 핸들링하고 싶을 수도 있습니다.

PasswordChangeService

@Component class PasswordChangeService( private val loginApiClient: LoginApiClient, private val errorStatusWriter: ErrorStatusWriter, private val passwordChanger: PasswordChanger ) { fun change() { loginApiClient.login(request) .onFailure { exception -> errorStatusWriter.write(exception) // (1) }.onSuccess { loginResult -> passwordChanger.change(loginResult) // (2) }.getOrThrow() // (3) } }

Code language: Kotlin (kotlin)

[1] 에러가 발생한 경우 에러를 기록합니다.

[2] 성공한 경우 해당 값을 받아서 다른 컴포넌트를 호출합니다.

→ [1], [2]번 두 케이스는 배타적이고 동시에 일어날 수 없습니다.

[3] 그리고 에러인 경우 예외를 발생시킵니다.

결론

정리하자면 Result(runCatching)는 다음의 용도에서 사용할 수 있습니다.

  • 외부 서비스에 의존하는 로직이라 예외 발생 가능성이 빈번한 컴포넌트
  • 해당 컴포넌트에서 에러가 발생할 수 있다는 것을 클라이언트에게 알려주고 싶을 때, 에러 핸들링을 다른 컴포넌트에 강제하고 위임하고 싶을 때
  • try … catch를 쓰고 싶지 않을 때