에러 핸들링을 다른 클래스에게 위임하기 (Kotlin 100% 활용)
TL;DR
Result
를 이해한다면, MSA 환경에서 에러가 전파되지 않도록 막을 수 있습니다.runCatching
과Result
를 사용하면 에러 핸들링을 클라이언트에게 위임할 수 있습니다.
예제: 로그인 요청을 전달하는 서비스 흐름에서 에러 처리하기
아래와 같은 서비스 호출 흐름이 있다고 가정해보겠습니다.
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 {
// 에러 핸들링
}
}
}
이 경우에 아래와 같은 두 케이스를 해결하고 싶어집니다.
- 이 API를 사용하는 쪽(ex. LoginService)에서 에러 핸들링을 강제하고 싶습니다.
- 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
이 발생했는데,errorCode
가INVALID_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 {
/* ... */
}
}
즉, Result
의 value
는
- 성공일 경우
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 사용 예시
runCatching
은 Result<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를 쓰고 싶지 않을 때