[Kotlin] 고차함수와 UseCase

Android Weekly 보다가 흥미로운 시리즈가 연재되고 있길래, 일단 발행된 두 편을 묶어서 정리해보았습니다.
재밌더라구요.


Igniting High-Order Thinking: Empowering Code with High-Order Functions


👉 원본 링크

  • High order thinking? High order function?
    • 고차 함수: 다른 함수를 인자로 받을 수도 있고, 함수 실행 결과로도 반환할 수 있는 타입의 함수
    • High order thinking: 문제를 작은 부분으로 나누고, 각 부분들끼리의 연관성을 파악하여 복잡한 문제를 효과적으로 해결할 수 있는 방식
  • 둘이 합하면?
    • 복잡한 문제 해결: 문제를 작은 고차 함수로 만들어 해결할 수 있음
    • 분명한 의도: 의도를 분명하게, 이해를 쉽게하여 코어 로직에 집중 가능
    • 반복 감소: 일반적인 작업들을 캡슐화하고 코드를 더 유지보수가 쉽도록 함
    • 코드의 유연함: 여러 상황에 대해 대응 가능
    • 구조화된 코드 작성 가능

아래의 경우, calculator를 인자로 받는 고차 함수 calculatorTotal를 사용하면 sum 외에도 다양한 상황에서 calculatetTotal 를 사용할 수 있게 된다.

예를 들어, 합이 아닌 곱을 구한다고 하면 calculatorTotal에 곱을 구하는 calculator를 전달하면 된다.

fun calculateTotal(
    prices: List<Double>, 
    calculator: (List<Double>) -> Double
): Double {
    return calculator(prices)
}

val sumCalculator: (List<Double>) -> Double = { prices ->
    prices.sum()
}

val prices = listOf(12.99, 8.75, 24.50, 10.0)
val totalAmount = calculateTotal(prices, sumCalculator)
println("Total amount: \$${totalAmount}")



Recreating UseCase: Embracing a Fluent and Fun Approach


👉 원본 링크

고차함수를 이용해 UseCase를 개선한다.

이를 위한 기준은

  1. 함수 우선 개발(일급) 클래스 개발보다 함수 작성이 먼저다.

  2. 순수 함수

    함수형 프로그래밍 자체가, 문제를 작게작게 쪼개어 순수 함수를 만들어 문제를 해결하는 것 순수 함수는 사이드 이펙트가 없기 때문에, 결과를 예측할 수 있고 믿을 수 있다.

  3. 고차함수의 유틸화

    다양한 상황에 동적으로 유연하게 대처할 수 있다.

  4. 함수 구성하기

    함수를 섞어서 새로운 함수를 만들어낸다. 복잡한 동작을 하는 함수도 이렇게 작은 블록을 조립하듯이 만든다.


일반적인 UseCase 를 개선해보자.

fun placeOrderUseCase(orderRepository: OrderRepository) {
    if (orderRepository.isLoggedIn()) {
        val cart = orderRepository.getCart()
        if (cart.isNotEmpty()) {
            if (orderRepository.hasEnoughFunds(getTotalPrice())) {
                orderRepository.updateProductStock()
                orderRepository.clearCart()
            } else {
                throw InsufficientFundsException(
                    "Not enough funds in the wallet."
                )
            }
        } else {
            throw EmptyCartException("The cart is empty.")
        }
    } else {
        throw NotLoggedInException("User is not logged in.")
    }
}

코드를 살펴보면 if가 그득그득하다.


  1. 먼저 isLogin 조건을 캡슐화하여 고차 함수로 분리한다.
fun placeOrderUseCase(orderRepository: OrderRepository) {
    executeWhenUserLoggedIn(orderRepository) {
        val cart = orderRepository.getCart()
        if (cart.isNotEmpty()) {
            if (orderRepository.hasEnoughFunds(getTotalPrice())) {
                orderRepository.updateProductStock()
                orderRepository.clearCart()
            } else {
                throw InsufficientFundsException(
                    "Not enough funds in the wallet."
                )
            }
        } else {
            throw EmptyCartException("The cart is empty.")
        }
    }
}

private fun executeWhenUserLoggedIn(
    orderRepository: OrderRepository,
    executeFunction: () -> Unit
) {
    if (orderRepository.isLoggedIn()) executeFunction()
    else throw NotLoggedInException("User is not logged in.")
}

executeWhenUserLoggedIn 에 전달되는 함수에, 실행하고 싶은 코드를 넣어서 이름 그대로 유저가 로그인했을 때 동작을 수행한다는 것을 쉽게 알 수 있다.

  1. 이번에는 cart 조건 캡슐화
fun placeOrderUseCase(orderRepository: OrderRepository) {
    executeWhenUserLoggedIn(orderRepository) {
        executeWhenCartNotEmpty(orderRepository) {
            if (orderRepository.hasEnoughFunds(getTotalPrice())) {
                orderRepository.updateProductStock()
                orderRepository.clearCart()
            } else {
                throw InsufficientFundsException(
                    "Not enough funds in the wallet."
                )
            }
        }
    }
}

private fun executeWhenCartNotEmpty(
    orderRepository: OrderRepository,
    executeFunction: () -> Unit
) {
    val cart = orderRepository.getCart()
    if (cart.isNotEmpty()) executeFunction()
    else throw EmptyCartException("The cart is empty.")
}


  1. 마지막 조건 캡슐화
fun placeOrderUseCase(orderRepository: OrderRepository) {
    executeWhenUserLoggedIn(orderRepository) {
        executeWhenCartNotEmpty(orderRepository) {
            executeWithSufficientFunds(orderRepository) {
                orderRepository.updateProductStock()
                orderRepository.clearCart()
            }
        }
    }
}

private fun executeWithSufficientFunds(
    orderRepository: OrderRepository,
    executeFunction: () -> Unit
) {
    val totalPrice = getTotalPrice()
    if (orderRepository.hasEnoughFunds(totalPrice))
        executeFunction()
    else throw InsufficientFundsException(
        "Not enough funds in the wallet."
    )
}

아직도 뭔가 더 고치고 싶다면! orderRepository 확장함수를 이용한다!

요렇게

OrderUseCase.kt

fun OrderRepository.placeOrderUseCase() {
    executeWhenUserLoggedIn {
        executeWhenCartNotEmpty {
            executeWithSufficientFunds {
                updateProductStock()
                clearCart()
            }
        }
    }
}

private fun OrderRepository.executeWhenUserLoggedIn(
    executeFunction: () -> Unit
) {
    if (isLoggedIn()) executeFunction()
    else throw NotLoggedInException("User is not logged in.")
}

private fun OrderRepository.executeWhenCartNotEmpty(
    executeFunction: () -> Unit
) {
    val cart = getCart()
    if (cart.isNotEmpty()) executeFunction()
    else throw EmptyCartException("The cart is empty.")
}

private fun OrderRepository.executeWithSufficientFunds(
    executeFunction: () -> Unit
) {
    val totalPrice = getTotalPrice()
    if (hasEnoughFunds(totalPrice))
        executeFunction()
    else throw InsufficientFundsException(
        "Not enough funds in the wallet."
    )
}

3겹의 if 조건들이 잘게 잘게 쪼개진 작은 함수가 되었다.

이런 방식의 이점

  • 모듈성: 각각의 함수가 하나의 동작만 하기 때문에 코드 관리가 쉬워짐
  • 재사용성: 다양한 상황, 앱 내 여러 곳에서 재사용 가능
  • 가독성: 함수 이름을 통해 코드 이해도가 높아짐.
  • 에러 핸들링: 오류가 중앙에서 관리되므로, 에러 메세지가 일관적 <- ?..
  • 용이한 테스트



예시가 비교적 간단한 케이스라, 고차 함수로 쪼개는 과정이 어렵지 않습니다. 깔끔하기도 하고, placeOrderUseCase 내부 로직이 훨씬 잘 읽힙니다.

그러나, 개인적으로 너무 복잡한 로직의 경우는 고민을 많이 해봐야 할 것 같더라구요. 고차 함수로 적절하게 쪼개는 조건을 잘 정의해야 할 듯 합니다. 너무 잘게 쪼개면 오히려 따라가기가 번거로울 것 같아요. (개인적으로 들여쓰기 단계가 많은 것은 불호..)

Comments