본문 바로가기
Study OR Book/Book

[이펙티브 코틀린] 아이템7: 결과 부족이 발생할 경우 null 또는 Failure를 사용하라

by Baest 2024. 12. 18.

 

핵심 개념

문제 상황

우리가 구현한 함수가 항상 원하는 결과를 준다면 좋겠지만 그렇지 않은 경우도 있을 것이다.
책에서 들었던 몇가지 예시는 아래와 같다.

  • 서버에서 데이터를 가져오려고 했지만 실패
  • 조건에 맞는 데이터를 찾지 못했거나 형식이 맞지 않음

이 경우를 처리하는 방법은 두 가지가 있다.

결과 부족을 처리하는 두 가지 방법

1. null 또는 Failure 반환

  • null: 결과가 없음을 나타내기 위해 간단하게 null을 리턴한다.
  • Failure: sealed class를 사용해 실패 상태를 명확하게 표현한다.
    예를 들어 Success와 Failure 클래스를 사용하여 성공과 실패를 구분한다.
  • 장점: Failure를 사용하면 실패 원인을 담을 수 있고, when을 통해 명확하게 상태를 분리하여 처리할 수 있다.
  • 단점: null은 명시적이지 않으며 null 체크를 강제하지 않아 NPE(Null Pointer Exception)의 위험이 있다.
inline fun <reified T> String.readObjectOrNull(): T? {
    if (incorrectSign) return null
    return result
}

inline fun <reified T> String.readObject(): Result<T> {
    if (incorrectSign) return Failure(JsonParsingException())
    return Success(result)
}

 

 

 

2. 예외를 throw

  • 예외 처리를 사용해 잘못된 상황을 나타낸다.
    예를 들어 JSON 파싱 실패 시 throw JsonParsingException()을 사용한다.
  • 예외를 명확하게 사용할 때는 오류가 예상치 못한 상황이라는 점을 강조한다.
  • 장점: 예외를 던지면 호출자에게 오류가 즉시 전달되며 처리가 강제된다.
  • 단점: 예외는 프로그램 흐름을 방해하고, 무분별하게 사용하면 코드의 가독성과 성능이 저하될 수 있다.
fun parseObject(input: String): Person {
    if (input.isEmpty()) {
        throw IllegalArgumentException("Input cannot be empty")
    }
    return parse(input)
}

 

정리: 언제 어떤 방법을 사용해야 할까?

    1. null이나 Failure 반환
      • 예상 가능한 상황(예: 데이터가 없을 때)을 처리할 때 사용한다.
      • Failure를 사용하면 실패 원인과 추가 정보를 명확하게 담을 수 있어 더 유용하다.
    2. 예외를 throw
      • 예상치 못한 상황이나 심각한 오류를 처리할 때 사용한다.
      • 예외는 성능 비용이 크므로 일반적인 실패 상황에서는 사용하지 않는 것이 좋다.

 

+ 왜 sealed class 를 사용하는가?

sealed class는 유한한 하위 클래스를 허용하며, 상태를 명확하게 표현할 수 있다.
예를 들어

  • Success: 성공 결과를 담는다.
  • Failure: 실패 원인과 추가 정보를 포함한다.

1. 데이터 로드 실패

fun fetchData(): Result<Data> {
    return if (networkError) {
        Failure(NetworkException("Unable to fetch data"))
    } else {
        Success(Data("Sample Data"))
    }
}

 

2. 안전한 호출 처리

- 기존 null 방식

val data = fetchDataOrNull()
val result = data?.process() ?: "default value"

 

- Result를 사용할 경우

val result = when (val data = fetchData()) {
    is Success -> data.result.process()
    is Failure -> "default value"
}

 

 

실제 코드에서의 사용 사례

  private suspend fun <T : Any, R> executePostRequest(
        endpoint: String,
        requestBody: T,
        requestSerializer: SerializationStrategy<T>,
        responseDeserializer: DeserializationStrategy<R>,
    ): Result<R> {
        val requestBodyJson = json.encodeToString(requestSerializer, requestBody)

        val response =
            try {
                decisionWebClient.post()
                    .uri("$url/$endpoint")
                    .accept(MediaType.APPLICATION_JSON)
                    .header("Content-Type", "application/json")
                    .body(BodyInserters.fromValue(requestBodyJson))
                    .retrieve()
                    .bodyToMono(String::class.java)
                    .retryWhen(Retry.backoff(1, Duration.ofSeconds(1)))
                    .awaitSingle()
            } catch (e: Exception) {
                return Result.failure(e)
            }

        return try {
            val cleanedJsonString = response.replace("\\n", "").replace("\\\"", "\"")
            val parsedResponse = json.decodeFromString(responseDeserializer, cleanedJsonString)
            Result.success(parsedResponse)
        } catch (e: SerializationException) {
            handleErrorResponse(response, endpoint)
        }
    }

    private fun <R> handleErrorResponse(
        response: String,
        endpoint: String,
    ): Result<R> {
        return try {
            val errorResponse = json.decodeFromString<ErrorResponseDto>(response)
            val errorMessage = errorResponse.reason?.ifBlank { errorResponse.message }
            Result.failure(IllegalStateException("$endpoint API error: $errorMessage"))
        } catch (serializationException: SerializationException) {
            Result.failure(serializationException)
        }
    }