에러 핸들링을 다른 클래스에게 위임하기 (Kotlin 100% 활용)

에러 핸들링을 다른 클래스에게 위임하기 (Kotlin 100% 활용)

한규주 · 토스페이먼츠 Server Developer
2022년 5월 14일

TL;DR

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

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

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

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 {
      // 에러 핸들링
    }
  }
}

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

  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 {
        // 에러 핸들링
      }
  }
}

코틀린의 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
  }
}

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

runCatching을 사용했을 때

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

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)
  }
}

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 {
    /* ... */
  }
}

즉, 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

Result 사용 예시

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

에러를 무시하고 null 반환

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

기본값 반환

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

에러 발생 시 다른 동작 수행

val response = runCatching {
  login()
}.getOrElse { ex ->
  logger.warn(ex) { "에러 발생" }

  // 에러를 던지고 싶다면
  throw ex
}

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

val exception = runCatching {
  login()
}.exceptionOrNull()

// 위에서 받은 에러로 로직 수행
when (exception) {
  /* ... */
}

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

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

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

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

runCatching으로 try .. finally 구현하기

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

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()
    }
  }
}

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

LoginService

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

에러가 발생한 경우 에러를 무시하고 기본값으로 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)
  }
}

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

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

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

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

결론

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

  • 외부 서비스에 의존하는 로직이라 예외 발생 가능성이 빈번한 컴포넌트
  • 해당 컴포넌트에서 에러가 발생할 수 있다는 것을 클라이언트에게 알려주고 싶을 때, 에러 핸들링을 다른 컴포넌트에 강제하고 위임하고 싶을 때
  • try ... catch를 쓰고 싶지 않을 때
댓글 0댓글 관련 문의: toss-tech@toss.im
㈜비바리퍼블리카 Copyright © Viva Republica, Inc. All Rights Reserved.