Arrow와 함수형 프로그래밍, 그리고 모나드

목차

  1. 함수형 프로그래밍이란?
  2. 모나드란?
  3. 모나드를 사용해 얻을 수 있는 이점들
  4. Arrow를 써보자

Functional Programming?

스크린샷 2022-12-11 오후 8 37 22

함수형 프로그래밍의 정의 ( By ties van de ven )

  • 컴파일러를 사랑하는 것
  • 명확한 코드와 함께 팀 멤버를 사랑하는 것
  • 함수를 사랑하는 것

시나리오

  • 점심을 만드는 시나리오
  • 점심을 먹기 위해선 아래와 같은 일련의 과정이 필요합니다.

준비물

  • 냉장고에서 음식을 꺼낸다.
  • 자르는 도구를 가져온다.
  • 점심을 만들기 위해 상추를 자른다.

코드 예시

/** model */
object Lettuce
object Knife
object Salad

fun takeFoodFromRefrigerator(): Lettuce = TODO()
fun getKnife(): Knife = TODO()
fun prepare(tool: Knife, ingredient: Lettuce): Salad = TODO()

코틀린의 예외 처리 방법

takeFoodFromRefrigeratorgetKnife 예외가 발생할 때 코틀린은 이를 처리하기 위해 크게 세 가지 방법을 사용할 수 있습니다.

  1. Exception
  2. Nullability
  3. Result Type

Exception

fun takeFoodFromRefrigerator(): Lettuce = throw RuntimeException("You need to go to the store and buy some ingredients")
fun getKnife(): Knife = throw RuntimeException("Your knife needs to be sharpened")
fun prepare(tool: Knife, ingredient: Lettuce): Salad = Salad
  • 구현 중 발생할 수 있는 에러를 코드화하면 위와 같습니다.
  • takeFoodFromRefrigerator()getKnife()를 호출할 때 위와 같은 예외는 함수의 시그니쳐에서 예외를 발생한다는 그 어떠한 단서도 찾을 수 없습니다.

예외로 발생할 수 있는 문제들

  • 예외는 일종의 GOTO문으로 보이기도 합니다.
  • 왜냐하면 예외가 던져질 때 caller를 뛰어넘어 프로그램 흐름을 중단하기 때문인데요.
  • 만약 try-catch 내에서 비동기 코드를 호출할 경우 다른 스레드에서 발생하는 예외를 캐치하지 못할 수도 있습니다.
  • 즉시 로직을 중단하고 다른 영역으로 건너뛰는 이 엄청난 능력으로 인해 핵심 라이브러리에서도 예외는 남용되고 있습니다.
  • 그러다보면 자연스럽게 아래와 같은 끔찍한 try-catch 구문이 만들어지기도 하죠.
try {
  doExceptionalStuff() //throws IllegalArgumentException
} catch (e: Throwable) { 
    // too broad, `Throwable` matches a set of fatal exceptions and errors a 
   // a user may be unable to recover from:
    /*
    VirtualMachineError
    OutOfMemoryError
    ThreadDeath
    LinkageError
    InterruptedException
    ControlThrowable
    NotImplementedError
    */
}
  • 또한 예외 자체는 굉장히 비싼 비용이 듭니다.
  • ThrowablefllInStackTrace()는 스택 추적을 제공하기 위해 모든 스택 정보를 수집하려고 하기 때문입니다.
  • 그렇기에 예외를 생성하는 것은 현재 스레드 스택 크기 만큼 비용이 들게 됩니다.
  • Throwable의 인스턴스화 비용 및 예외 발생에 대한 자세한 정보는 여기서 확인할 수 있습니다.

함수형 프로그래밍과 예외

  • 결국 함수형 프로그래밍에서는 예외는 나쁜 선택이 될 수 밖에 없습니다.
  • 예외는 모델링 할 수 없으며, 직관적이지 못하고, 예측할 수 없습니다.

Nullability

  • 일반적으로 예외를 사용하지 않으면 해볼 수 있는 방법은 Null 입니다.
fun takeFoodFromRefrigerator(): Lettuce? = null
fun getKnife(): Knife? = null
fun prepare(tool: Knife, ingredient: Lettuce): Salad? = Salad
  • 코틀린의 nullabe 타입과 safety call을 사용해서 아래와 같이 예외를 사용하지 않을 수 있습니다.
fun prepareLunch(): Salad? {
  val lettuce = takeFoodFromRefrigerator()
  val knife = getKnife()
  val salad = knife?.let { k -> lettuce?.let { l -> prepare(k, l) } }
  return salad
}

또한 Arrow에서 제공하는 nullable이라는 기능을 사용하면 아래와 같이 표현도 가능합니다.

fun prepareLunch(): Salad? =
  nullable {
    val lettuce = takeFoodFromRefrigerator().bind()
    val knife = getKnife().bind()
    val salad = prepare(knife, lettuce).bind()
    salad
  }

nullable을 사용하여 모델링을 하였고 예외를 사용하지 않을 수 있었습니다만… 문제는 takeFoodFromRefrigeratorgetKnife에서 왜 null을 반환했는지는 알 수 없습니다

Nullable 타입은 또한 예외를 잡을 수 없다는 점이 있는데요, 만약 함수 내부적으로 내부적으로 NPE 예외가 던져졌다면 바깥쪽에서는 여전히 이를 인지할 수 없게 됩니다.

따라서 우리는 Either타입을 통해 이를 caller에서 컨트롤하고자 합니다.

Either Monad

Either를 이야기하기 전에 모나드에 대해서 잠깐 이야기하고 넘어가고자 합니다.

Monad라는 개념 자체가 설명하는게 각자 다 다르고, 정신이 혼미해집니다.

그러나 ArrowEither를 사용하는데 모나드의 개념을 완벽히 이해할 필요는 없습니다.

여기서 우리가 모나드에 대해서 알아야할 것은 단 하나입니다.

모나드는 Return Type을 감싸는 Wrapper 입니다.

Either

Either는 간단합니다. 일단 LeftRight로 구성되어있습니다.

함수형 프로그래밍의 컨벤션에 따르면 Left는 예외가 발생하는 상황, Right는 성공한 값을 의미합니다.

예외가 발생할 수 있는 케이스를 모델링하고, 이를 반환하게 해보겠습니다.

sealed class CookingException {
    object NastyLettuce: CookingException()
    object KnifeIsDull: CookingException()
    data class InsufficientAmountOfLettuce(val quantityInGrams : Int): CookingException()
}
typealias NastyLettuce = CookingException.NastyLettuce
typealias KnifeIsDull = CookingException.KnifeIsDull
typealias InsufficientAmountOfLettuce = CookingException.InsufficientAmountOfLettuce

이러한 유형의 타입들은 함수형 프로그래밍에서 Algebraic Data Type 또는 Sum Type으로 알려져있다고 합니다.

코틀린에서는 sealed 클래스를 사용하여 이를 구현할 수 있습니다.

import arrow.core.Either
import arrow.core.Either.Left
import arrow.core.Either.Right

fun takeFoodFromRefrigerator(): Either<NastyLettuce, Lettuce> = Right(Lettuce(3))
fun getKnife(): Either<KnifeIsDull, Knife> = Right(Knife())
fun lunch(knife: Knife, food: Lettuce): Either<InsufficientAmountOfLettuce, Salad> =
    if(food.size < 5) {
        Left(InsufficientAmountOfLettuce(5))
    } else {
        Right(Salad())
    }

Arrow의 모나드 패턴을 사용하여 얻을 수 있는 이점

  • 컴파일러 단계에서 오류를 체크할 수 있으며, 에러 핸들링이 가능해졌습니다.
  • 직관적인 코드를 통해 구현한 함수의 예외들을 한 눈에 파악할 수 있습니다. ( Readable Code )
  • 예외를 던지지 않기 때문에 성능상 이점이 있습다. ( 실제로 토스에서 예외 던지는 코드를 없앴더니, CPU 사용률이 20% 감소했다는 이야기를 들은 적 있습니다 )
  • 모나드 패턴이 적용된 함수를 사용하는 사용자는 정의된 예외를 처리하는 코드를 반드시 작성해야하기 때문에, 안전한 코드를 만들 수 있습니다.
  • try-catch는 필수가 아닌 선택이지만, ArrowEither 모나드는 이를 강제합니다.

Arrow의 모나드 패턴을 사용할 때 생각해봐야할 점들

  • 예외를 던지지 않기 때문에 스프링의 Transactional에서 롤백이 필요한 케이스를 대응할 필요가 있습니다.

실전 Practice

Service ( BO )

@Service
class GetBookingService(
    val loadBookingPort: LoadBookingPort
) : GetBookingUseCase {

    override fun getBooking(bookingId: Long): Either<BookingException, Booking> {
        val booking = loadBookingPort.loadBooking(bookingId)

        return if (booking == null) {
            Either.Left(BookingException.NotFoundBookingException(bookingId))
        } else {
            Either.Right(booking)
        }
    }
}

Controller

@RestController
@RequestMapping("/")
class BookingGetController(
    val bookingGetBookingUseCase: GetBookingUseCase
) {

    @GetMapping("bookings/{bookingId}")
    fun get(@PathVariable bookingId: Long): ResponseEntity<Booking> {
        return when (val either = bookingGetBookingUseCase.getBooking(bookingId)) {
            is Either.Left -> {
                when (val exception = either.value) {
                    is BookingException.NotFoundBookingException -> {
                        println(exception.bookingId)
                        ResponseEntity.badRequest().build()
                    }

                    is BookingException.InvalidTypeBookingException ->
                        ResponseEntity.internalServerError().build()
                }
            }

            is Either.Right -> {
                ResponseEntity.ok(either.value)
            }
        }
    }


    fun badGetExample(@PathVariable bookingId: Long): ResponseEntity<Booking> {
        return try {
            ...
        } catch (exception: NotFound) {
            ResponseEntity.notFound().build()
        } catch (exception: Throwable) {
            ResponseEntity.internalServerError().build()
        }
    }
}




© 2022. by minkuk

Powered by minkuk