Arrow-kt 입문 — Option, Either, Raise

Kotlin 표준 라이브러리는 Result<T>를 제공하지만, 에러 타입을 지정할 수 없다 (Throwable만 가능). Arrow-kt는 FP에서 자주 쓰이는 Option, Either, Raise 등을 Kotlin에 맞게 구현한 라이브러리다.


프로젝트 설정

// build.gradle.kts
dependencies {
    implementation("io.arrow-kt:arrow-core:1.2.4")
    implementation("io.arrow-kt:arrow-fx-coroutines:1.2.4")  // 코루틴 지원
}

Option — null의 타입 안전한 대안

Option<A>는 값이 있거나(Some<A>) 없거나(None)를 명시적으로 표현한다.

import arrow.core.Option
import arrow.core.Some
import arrow.core.None
import arrow.core.toOption
 
// 직접 생성
val found: Option<User> = Some(User(id = 1L, name = "김철수"))
val notFound: Option<User> = None
 
// Nullable에서 변환
val user: User? = userRepository.findByEmail("test@example.com")
val option: Option<User> = user.toOption()
 
// 처리
val name: String = option
    .map { it.name }
    .getOrElse { "알 수 없는 사용자" }

Nullable vs Option 선택 기준

// Nullable — 간단한 경우, 내부 로직
fun findUser(id: Long): User? = repository.findById(id)
 
// Option — 변환 체인이 길거나 명시성이 중요한 경우
fun findActiveUser(id: Long): Option<User> =
    repository.findById(id)
        .toOption()
        .filter { it.isActive }

실무에서는 Nullable을 주로 쓰고, 변환 체인이 복잡해질 때 Option으로 전환하는 것이 자연스럽다. Either가 더 자주 쓰인다.


Either — 성공 또는 실패

Either<A, B>는 두 가지 상태를 가진다.

  • Left<A> — 보통 실패/에러
  • Right<B> — 보통 성공/값
import arrow.core.Either
import arrow.core.left
import arrow.core.right
 
sealed class UserError {
    data class NotFound(val id: Long) : UserError()
    data class InvalidEmail(val email: String) : UserError()
    object Inactive : UserError()
}
 
fun findUser(id: Long): Either<UserError, User> {
    val user = repository.findById(id)
        ?: return UserError.NotFound(id).left()  // Left — 실패
    if (!user.isActive) return UserError.Inactive.left()
    return user.right()  // Right — 성공
}

map / flatMap 체인

fun getUserProfile(id: Long): Either<UserError, UserProfile> =
    findUser(id)
        .map { user -> user.toProfile() }  // Right면 변환, Left면 그대로 통과
 
fun getOrderHistory(id: Long): Either<UserError, List<Order>> =
    findUser(id)
        .flatMap { user ->                 // Right면 다음 Either 계산
            orderRepository.findByUserId(user.id)
                .right()
        }

여러 단계 체인

fun processOrder(command: CreateOrderCommand): Either<OrderError, Order> =
    validateCommand(command)          // Either<OrderError, ValidCommand>
        .flatMap { valid -> findUser(valid.userId) }   // Either<OrderError, User>
        .flatMap { user -> checkStock(command.items) } // Either<OrderError, StockResult>
        .flatMap { stock -> createOrder(command, stock) }
        .map { order -> orderRepository.save(order) }

체인 중 어느 단계에서든 Left가 반환되면 이후 단계는 건너뛰고 최종 Left가 된다. 예외 던지기 없이 실패 흐름이 자연스럽게 전파된다.


Raise DSL — Either를 더 읽기 쉽게

Arrow 1.2부터 Raise<E> DSL이 권장 방식이다. flatMap 체인 대신 raise()와 일반 코드처럼 작성할 수 있다.

import arrow.core.raise.either
import arrow.core.raise.ensure
import arrow.core.raise.Raise
 
// Raise DSL 사용
fun processOrder(command: CreateOrderCommand): Either<OrderError, Order> = either {
    // ensure — 조건 불만족 시 자동으로 raise (Left 반환)
    ensure(command.items.isNotEmpty()) { OrderError.EmptyItems }
 
    val user = findUser(command.userId).bind()  // Left면 여기서 중단
    val stock = checkStock(command.items).bind()
 
    ensure(stock.isAvailable) {
        OrderError.InsufficientStock(stock.productId, stock.requested, stock.available)
    }
 
    val order = Order.create(command, user)
    orderRepository.save(order)  // 마지막 값이 Right로 래핑됨
}

bind()Either<E, A>에서 A를 꺼낸다. Left면 즉시 either 블록을 탈출해 해당 Left를 반환한다.

Raise를 함수 시그니처에 직접 표현

// Raise<E>를 컨텍스트 수신자로 사용
context(Raise<OrderError>)
fun validateItems(items: List<OrderItem>): List<ValidItem> {
    ensure(items.isNotEmpty()) { OrderError.EmptyItems }
    return items.map { item ->
        ensure(item.quantity > 0) { OrderError.InvalidQuantity(item.productId) }
        ValidItem(item.productId, item.quantity)
    }
}
 
// 호출 시
val result: Either<OrderError, Order> = either {
    val validItems = validateItems(command.items)  // Raise 컨텍스트 자동 전달
    // ...
}

fold — Either 최종 처리

컨트롤러 레이어에서 Either를 HTTP 응답으로 변환할 때 fold를 쓴다.

@RestController
class OrderController(private val orderService: OrderService) {
 
    @PostMapping("/api/orders")
    fun createOrder(@RequestBody command: CreateOrderCommand): ResponseEntity<*> =
        orderService.processOrder(command).fold(
            ifLeft = { error ->
                when (error) {
                    is OrderError.NotFound        -> ResponseEntity.notFound().build()
                    is OrderError.EmptyItems      -> ResponseEntity.badRequest().body(error)
                    is OrderError.InsufficientStock -> ResponseEntity.unprocessableEntity().body(error)
                    OrderError.PaymentFailed      -> ResponseEntity.status(502).body(error)
                }
            },
            ifRight = { order ->
                ResponseEntity.status(201).body(order)
            }
        )
}

표준 Result와 비교

kotlin.Result<T>Either<E, A>
에러 타입Throwable커스텀 sealed class
에러 정보스택 트레이스도메인 에러 값
체이닝map, mapCatchingmap, flatMap, bind()
패턴 매칭isSuccess/isFailurefold, when (error)

도메인 에러를 타입으로 다루고 싶다면 Either가 훨씬 적합하다.


정리

  • Option — null을 명시적으로 표현. 단순한 경우 Nullable을 써도 됨.
  • Either — 실패 가능한 연산. 에러 타입을 sealed class로 지정.
  • Raise DSL — either { ... } 블록 안에서 일반 코드처럼 작성.
  • bind() — Either에서 값을 꺼내고, Left면 즉시 탈출.
  • fold — Either를 최종적으로 HTTP 응답 등으로 변환.

다음 글에서는 서비스 레이어 전체를 Either로 구성하는 방법을 다룬다.