Arrow와 함수형 프로그래밍, 그리고 모나드
in Open Source on Open Source
목차
- 함수형 프로그래밍이란?
- 모나드란?
- 모나드를 사용해 얻을 수 있는 이점들
- Arrow를 써보자
Functional Programming?
함수형 프로그래밍의 정의 ( 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()
코틀린의 예외 처리 방법
takeFoodFromRefrigerator
나 getKnife
예외가 발생할 때 코틀린은 이를 처리하기 위해 크게 세 가지 방법을 사용할 수 있습니다.
- Exception
- Nullability
- 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
*/
}
- 또한 예외 자체는 굉장히 비싼 비용이 듭니다.
Throwable
의fllInStackTrace()
는 스택 추적을 제공하기 위해 모든 스택 정보를 수집하려고 하기 때문입니다.- 그렇기에 예외를 생성하는 것은 현재 스레드 스택 크기 만큼 비용이 들게 됩니다.
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
을 사용하여 모델링을 하였고 예외를 사용하지 않을 수 있었습니다만… 문제는 takeFoodFromRefrigerator
와 getKnife
에서 왜 null을 반환했는지는 알 수 없습니다
Nullable
타입은 또한 예외를 잡을 수 없다는 점이 있는데요, 만약 함수 내부적으로 내부적으로 NPE 예외가 던져졌다면 바깥쪽에서는 여전히 이를 인지할 수 없게 됩니다.
따라서 우리는 Either
타입을 통해 이를 caller
에서 컨트롤하고자 합니다.
Either Monad
Either
를 이야기하기 전에 모나드에 대해서 잠깐 이야기하고 넘어가고자 합니다.
Monad
라는 개념 자체가 설명하는게 각자 다 다르고, 정신이 혼미해집니다.
그러나 Arrow
의 Either
를 사용하는데 모나드의 개념을 완벽히 이해할 필요는 없습니다.
여기서 우리가 모나드에 대해서 알아야할 것은 단 하나입니다.
모나드는 Return Type을 감싸는 Wrapper
입니다.
Either
Either는 간단합니다. 일단 Left
와 Right
로 구성되어있습니다.
함수형 프로그래밍의 컨벤션에 따르면 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
는 필수가 아닌 선택이지만,Arrow
의Either
모나드는 이를 강제합니다.
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()
}
}
}