LY Corporation Tech Blog

LY Corporation과 LY Corporation Group(LINE Plus, LINE Taiwan and LINE Vietnam)의 기술과 개발 문화를 알립니다.

This post is also available in the following languages. Japanese, English

코드 품질 개선 기법 23편: 반환의 끝이 에지 케이스의 끝

이 글은 2024년 4월 25일에 일본어로 먼저 발행된 기사를 번역한 글입니다.

LY Corporation은 높은 개발 생산성을 유지하기 위해 코드 품질 및 개발 문화 개선에 힘쓰고 있습니다. 이를 위해 다양한 노력을 하고 있는데요. 그중 하나가 Review Committee 활동입니다.

Review Committee에서는 머지된 코드를 다시 리뷰해 리뷰어와 작성자에게 피드백을 주고, 리뷰하면서 얻은 지식과 인사이트를 Weekly Report라는 이름으로 매주 공유하고 있습니다. 이 Weekly Report 중 일반적으로 널리 적용할 수 있는 주제를 골라 블로그에 코드 품질 개선 기법 시리즈를 연재하고 있습니다.

이번에 블로그로 공유할 Weekly Report의 제목은 '반환(return)의 끝이 에지 케이스(edge case)의 끝'입니다.

반환의 끝이 에지 케이스의 끝

조기 반환은 코드의 작동 흐름을 명확히 만들기 위해 사용하는 중요한 기법입니다. 조기 반환을 사용하면 에러 케이스를 먼저 배제할 수 있어 '함수의 주요 목적'에 초점을 맞춘 코드를 작성하기 쉽습니다. 또한 에러 조건과 해당 처리 로직 간의 관계도 더 쉽게 이해할 수 있다는 장점이 있습니다.

다음은 사용자 ID 목록을 받아 사용자 이름 목록을 반환하는 코드입니다. 여기에도 조기 반환이 적용돼 있는데요. 무슨 문제가 있을까요?

fun getUserNames(userIds: List<UserId>): List<String> {
    if (userIds.isEmpty()) {
        return emptyList()
    }
    if (userIds.size == 1) {
        val userData = repository.getUserData(userIds[0])
        return listOf(userData.name)
    }

    return userIds.asSequence()
        .map(repository::getUserData)
        .map(UserData::name)
        .toList()
}

배제하지 말고 포함하자

조기 반환은 무조건 적용한다고 좋은 것은 아닙니다. 조기 반환 적용 여부는 에러 케이스와 정상 케이스에서의 처리가 어느 정도 다른지에 따라 달라져야 합니다. 만약 에러 케이스와 정상 케이스의 처리가 동일하다면 해당 '에러'는 에러 케이스로 처리하지 않고 정상 케이스로 처리하는 것이 코드를 단순화하기 쉽습니다. 이번과 같은 경우는 userIds.isEmpty() 또는 userIds.size == 1을 정상 케이스로 간주하면 처리를 통합할 수 있습니다. 

fun getUserNames(userIds: List<UserId>): List<String> =
    userIds.asSequence()
        .map(repository::getUserData)
        .map(UserData::name)
        .toList()

단, 위 코드는 size == 0 또는 size == 1인 경우 성능이 떨어집니다. 두 경우 모두 Sequence 인스턴스를 생성하고, Sequence.toList에서도 새로운 List 인스턴스를 생성하기 때문입니다.

이제 정상 케이스로 처리하는 것이 더 나을 수도 있는 경우를 소개하겠습니다.

빈 컬렉션 순회

map, filter, forEach 등 컬렉션을 순회하는 고차 함수는 많은 언어와 라이브러리에서 컬렉션이 비어 있을 때에도 잘 작동합니다. 또한 sum이나 reduce와 같은 폴딩을 수행하는 함수도 컬렉션이 비어 있을 때에도 자연스럽게 작동하는 경우가 많습니다.

val empty: List<Int> = emptyListOf()
empty.all { false } // => true
empty.any { true } // => false
empty.sum() // 0

null

언어에 따라서는 세이프 콜 연산자 ?.라고 불리는 연산자가 있는데요. null(nil, undefined)을 정상 케이스로 취급하고자 할 때 이 연산자가 유용합니다. 예를 들어 다음과 같은 == null의 조기 반환은 ?.를 사용해 정상 케이스에 포함시킬 수 있습니다.

// Before
fun function(foo: Foo?): Bar? {
    if (value == null) {
        return null
    }
    return value.toBar()
}

// After
fun function(foo: Foo?): Bar? =
    value?.toBar()

또한 null을 기본값으로 되돌리기 위해 엘비스 연산자 ?: 또는 null 결합 연산자 ??를 사용할 수 있는 언어도 있습니다. ?: 0이나 orEmpty 등 기본값으로 폴백(fall back)하는 것도 좋은 선택지 중 하나이지만, 이 경우 한 번 변환한 기본 파라미터로 다시 케이스를 구분하지 않도록 주의해야 합니다(참고: 코드 품질 개선 기법 16편: 불이 'null'인 굴뚝에 연기가 'null'이 아닐 수 없다).

범위를 벗어난 배열이나 리스트

인덱스의 범위를 조사하는 코드가 있다면 범위를 벗어났을 때 조기 반환하는 것보다 getOrNull이나 getOrElse와 같은 함수를 사용하는 게 좋습니다. getOrNull을 사용하면 위의 null 문제로 귀결될 수 있습니다.

val fooList: List<Foo?> = ...

// Before
fun function(index: Int): Foo? {
    if (index < 0 || fooList.size <= index) {
        return null
    }
    return fooList[index]
}

// After
fun function(index: Int): Foo? =
    fooList.getOrNull(index)

표준 라이브러리 등에서 getOrNull이나 getOrElse에 해당하는 것이 없는 경우는 범용적인 함수로 정의하는 것도 좋습니다.

다른 속성에 의존하는 속성

'어떤 속성이 특정 값일 때만 다른 속성이 의미를 갖는' 상황이 있습니다. 예를 들어 textView라는 UI 요소가 있고 textView.isVisible이 표시 여부를 나타내며, textView.text가 텍스트 내용을 나타낸다고 가정해 봅시다. 이 경우 text가 의미를 갖는 것은 isVisibletrue일 때뿐입니다.

다음 코드에서는 someText가 빈 문자열인 경우 textView를 숨기고 이때에는 text를 업데이트하지 않습니다.

if (someText.isEmpty()) {
    textView.isVisible = false
    return
}
textView.isVisible = true
textView.text = someText

이처럼 무의미한 대입을 배제하기 위한 조기 반환은 제거해도 무방한 경우도 있습니다. isVisiblefalse일 때는 text가 어떤 값이든 문제 없을 수도 있습니다. 이 경우 다음과 같이 truefalse 처리를 통합할 수 있습니다.

textView.isVisible = someText.isNotEmpty()
textView.text = someText

연속 함수 호출 중 예외

함수 중 여러 곳에서 예외가 발생하는 경우 조기 반환을 적용하려고 하면 코드가 복잡해질 수 있습니다. 예를 들어 다음 코드에서는 someData -> anotherData -> yetAnotherData로 변환해 가는데 중간에 예외가 발생할 수 있고 이 경우에는 return합니다.

sealed class FooResult {
    class Success(val fooData: FooData): FooResult()
    class Error(val errorType: ErrorType): FooResult()
}
enum class ErrorType { SOME, ANOTHER, YET_ANOTHER }

fun getFooData(): FooResult {
    val someData = try {
        apiClient.getSomeData()
    } catch (_: SomeException) {
        return Error(ErrorType.SOME)
    }
    val anotherData = try {
        unreliableRepository.getAnotherData(someData)
    } catch (_: AnotherException) {
        return Error(ErrorType.ANOTHER)
    }

    return try {
        unreliableRepository.getYetAnotherData(anotherData)
    } catch (_: YetAnotherException) {
        Error(ErrorType.YET_ANOTHER)
    }
}

이런 경우에는 Success에 대해서만 작동하는 함수인 flatMap을 이용하면 더 명확하게 만들 수 있습니다. 우선 FooResultflatMap을 정의합니다.

sealed class FooResult<T> {
    class Success<T>(val value: T): FooResult<T>()
    class Error<T>(val errorType: ErrorType): FooResult<T>()

    @Suppress("UNCHECKED_CAST") // ...
    fun <U> flatMap(action: (T) -> FooResult<U>): FooResult<U> = when (this) {
        is Success -> action(value)
        is Error -> this as FooResult<U>
    }
}

그리고 예외를 FooResult로 변환하는 getSomeData와 같은 보조 함수를 정의합니다.

private fun getSomeData(): FooResult<SomeData> = try {
    FooResult.Success(apiClient.getSomeData())
} catch (_: SomeException) {
    FooResult.Error(ErrorType.SOME)
}

이렇게 하면 예외에서 변환된 FooResult.Error도 정상 케이스와 같이 처리할 수 있으므로 함수의 흐름이 명확해집니다.

fun getFooData(): FooResult = getSomeData()
    .flatMap(::toAnotherData)
    .flatMap(::toYetAnotherData)

한 줄 요약: 조기 반환을 사용하기 전에 에러 케이스와 정상 케이스를 통합할 수 없는지 생각한다.

키워드: early return, error case, function flow