Validated — 여러 에러 동시 수집

Eitherfail-fast 방식이다. 첫 번째 에러를 만나면 즉시 중단하고 그 에러를 반환한다. 폼 검증처럼 여러 필드를 동시에 검사해서 모든 에러를 한꺼번에 보여줘야 할 때는 적합하지 않다.

// Either — 이름 에러 발견 즉시 중단 → 이메일 에러는 모름
either {
    ensure(name.isNotBlank()) { "이름 필수" }   // 실패 → 여기서 중단
    ensure(email.contains("@")) { "이메일 형식 오류" }  // 실행 안 됨
}

Arrow-kt의 Validated (또는 accumulate DSL)를 쓰면 모든 검사를 실행하고 에러를 모아서 반환한다.


NonEmptyList — 에러 목록 타입

에러가 1개 이상 있음을 타입으로 보장한다.

import arrow.core.NonEmptyList
import arrow.core.nonEmptyListOf
 
val errors: NonEmptyList<String> = nonEmptyListOf("이름 필수", "이메일 형식 오류")

validatedNel — 기본 사용법

Either<E, A> 대신 ValidatedNel<E, A> = Validated<NonEmptyList<E>, A> 를 사용한다.

import arrow.core.ValidatedNel
import arrow.core.invalidNel
import arrow.core.validNel
import arrow.core.zip
 
fun validateName(name: String): ValidatedNel<String, String> =
    if (name.isNotBlank()) name.validNel()
    else "이름은 필수입니다".invalidNel()
 
fun validateEmail(email: String): ValidatedNel<String, String> =
    if (email.contains("@") && email.contains(".")) email.validNel()
    else "유효하지 않은 이메일 형식입니다".invalidNel()
 
fun validateAge(age: Int): ValidatedNel<String, Int> =
    if (age in 1..150) age.validNel()
    else "나이는 1~150 사이여야 합니다".invalidNel()
 
// 세 가지 검증을 동시에 실행 — zip으로 결합
fun validateCreateUserCommand(command: CreateUserCommand): ValidatedNel<String, ValidUser> =
    validateName(command.name).zip(
        validateEmail(command.email),
        validateAge(command.age)
    ) { name, email, age ->
        ValidUser(name, email, age)  // 모두 성공하면 결합
    }
// 결과 처리
when (val result = validateCreateUserCommand(command)) {
    is Valid   -> createUser(result.value)
    is Invalid -> ResponseEntity.badRequest().body(result.error.toList())
}

accumulate DSL (Arrow 1.2+)

zip보다 더 읽기 쉬운 방식이다.

import arrow.core.raise.either
import arrow.core.raise.zipOrAccumulate
 
fun validateCreateUserCommand(command: CreateUserCommand): Either<NonEmptyList<String>, ValidUser> =
    either {
        zipOrAccumulate(
            { ensure(command.name.isNotBlank()) { "이름은 필수입니다" } },
            { ensure(command.email.contains("@")) { "유효하지 않은 이메일" } },
            { ensure(command.age in 1..150) { "나이는 1~150 사이여야 합니다" } },
            { ensure(command.password.length >= 8) { "비밀번호는 최소 8자 이상이어야 합니다" } },
        ) { _, _, _, _ -> ValidUser(command.name, command.email, command.age) }
    }

zipOrAccumulate는 인자로 받은 모든 검증을 실행하고, 에러가 있으면 모두 수집해서 반환한다.


실전: DTO 검증 파이프라인

data class CreateOrderCommand(
    val userId: Long,
    val items: List<OrderItemCommand>,
    val shippingAddress: AddressCommand,
    val couponCode: String?,
)
 
sealed class ValidationError {
    data class FieldError(val field: String, val message: String) : ValidationError()
    data class BusinessError(val message: String) : ValidationError()
}
 
fun validateCreateOrderCommand(
    command: CreateOrderCommand
): Either<NonEmptyList<ValidationError>, ValidatedOrderCommand> = either {
    zipOrAccumulate(
        {
            ensure(command.userId > 0) {
                ValidationError.FieldError("userId", "유효하지 않은 사용자 ID")
            }
        },
        {
            ensure(command.items.isNotEmpty()) {
                ValidationError.FieldError("items", "주문 항목은 최소 1개 이상이어야 합니다")
            }
            command.items.forEachIndexed { index, item ->
                ensure(item.quantity > 0) {
                    ValidationError.FieldError("items[$index].quantity", "수량은 1 이상이어야 합니다")
                }
                ensure(item.quantity <= 100) {
                    ValidationError.FieldError("items[$index].quantity", "수량은 최대 100개입니다")
                }
            }
        },
        {
            ensure(command.shippingAddress.zipCode.matches(Regex("\\d{5}"))) {
                ValidationError.FieldError("shippingAddress.zipCode", "우편번호는 5자리 숫자입니다")
            }
            ensure(command.shippingAddress.street.isNotBlank()) {
                ValidationError.FieldError("shippingAddress.street", "도로명 주소는 필수입니다")
            }
        },
    ) { _, _, _ ->
        ValidatedOrderCommand(command)
    }
}

Bean Validation과 비교

Spring의 @Valid + @NotBlank 방식과 비교해보자.

// Bean Validation 방식
data class CreateUserRequest(
    @field:NotBlank(message = "이름은 필수입니다")
    val name: String,
 
    @field:Email(message = "유효하지 않은 이메일")
    val email: String,
 
    @field:Min(1) @field:Max(150)
    val age: Int,
)
 
// Controller
@PostMapping("/users")
fun create(@RequestBody @Valid request: CreateUserRequest): ResponseEntity<*> { ... }
// → MethodArgumentNotValidException → @ExceptionHandler에서 처리
Bean Validation (@Valid)Arrow Validated
설정 방식어노테이션코드
에러 수집기본적으로 모두 수집모두 수집 (zipOrAccumulate)
비즈니스 로직 검증어렵다 (커스텀 Validator 필요)자유롭게 가능
타입 안전부분적 (RuntimeException 기반)완전히 타입 안전
재사용성어노테이션 재사용함수 재사용

단순한 형식 검증 (빈 값, 이메일 형식, 숫자 범위)은 Bean Validation이 더 간결하다. 비즈니스 규칙과 얽힌 검증 (다른 필드와의 관계, DB 조회 필요)은 Arrow가 더 적합하다.


두 방식을 조합

실제 프로젝트에서 두 방식을 레이어별로 나눠 쓰는 것이 현실적이다.

// Controller — Bean Validation으로 형식 검증
@PostMapping("/orders")
fun createOrder(@RequestBody @Valid request: CreateOrderRequest): ResponseEntity<*> {
    val command = request.toCommand()
    return orderService.createOrder(command).fold(
        ifLeft = { errors -> ResponseEntity.badRequest().body(errors.toList()) },
        ifRight = { order -> ResponseEntity.status(201).body(order) }
    )
}
 
// Service — Arrow로 비즈니스 검증
fun createOrder(command: CreateOrderCommand): Either<NonEmptyList<ValidationError>, Order> = either {
    // 비즈니스 규칙 검증
    val validated = zipOrAccumulate(
        { ensureUserExists(command.userId) },
        { ensureStockAvailable(command.items) },
    ) { user, _ -> user }
 
    val user = validated.bind()
    // 이후 로직...
}

정리

  • Either — fail-fast. 첫 에러에서 중단. 순차 처리.
  • Validated / zipOrAccumulate — 에러 수집. 모든 검증 실행.
  • 폼/DTO 검증처럼 여러 에러를 한 번에 보여줘야 할 때 zipOrAccumulate.
  • Bean Validation과 역할을 나눠서 쓰는 것이 현실적.