Published on

Kotlin Arrow Either·IO로 예외·부작용 제거

Authors

서버 애플리케이션을 오래 운영하다 보면, 결국 문제는 두 가지로 수렴합니다.

  • 예외(Exception): 어디서 던져졌는지 추적이 어렵고, 호출자가 처리하지 않으면 런타임에서 터집니다.
  • 부작용(Side effect): DB/HTTP/시간/랜덤/로깅 같은 외부 세계 의존이 코드 전체로 번지면서 테스트가 어려워집니다.

Kotlin은 기본적으로 예외 기반 흐름이 자연스럽고, suspend는 비동기를 쉽게 만들지만 “실패”와 “부작용”을 타입으로 강제하진 않습니다. 이 글에서는 Arrow를 이용해

  • 실패는 Either명시적으로 모델링하고
  • 부작용은 IO지연·격리해서

예외와 부작용이 비즈니스 로직에 섞이지 않게 만드는 패턴을 다룹니다.

운영 관점에서 재시도/백오프 같은 실패 전략은 특히 중요합니다. 예를 들어 외부 API 호출의 429 대응은 코드 구조가 나쁘면 “여기저기 try/catch”로 번지기 쉽습니다. 관련해서는 OpenAI 429/Rate Limit 재시도·백오프 실전 가이드도 함께 참고하면 좋습니다.

try/catch가 커질수록 유지보수가 어려워질까

전형적인 Kotlin 서비스 코드를 보겠습니다.

class UserService(
  private val repo: UserRepository,
  private val http: PaymentClient,
) {
  suspend fun register(cmd: RegisterCommand): UserDto {
    try {
      if (repo.existsByEmail(cmd.email)) {
        throw IllegalArgumentException("email already used")
      }

      val paymentId = http.createCustomer(cmd.email) // 네트워크 실패 가능

      val user = repo.save(User(email = cmd.email, paymentId = paymentId))
      return user.toDto()
    } catch (e: Exception) {
      // 로깅, 매핑, 재시도, 보상 트랜잭션…
      throw e
    }
  }
}

문제는 다음과 같습니다.

  • existsByEmail, createCustomer, save 중 어디서든 예외가 날 수 있고, 함수 시그니처만 봐서는 실패 가능성이 드러나지 않습니다.
  • 예외 타입이 뒤섞이면(예: IOException, SQLException, IllegalArgumentException) 호출자에서 처리 규칙이 불명확해집니다.
  • “이 함수는 DB를 만지는지, HTTP를 호출하는지”가 타입으로 보장되지 않아, 순수한 로직과 I/O가 섞입니다.

Arrow의 목표는 이를 “함수형스럽게” 만들기보다, 실패와 부작용을 타입으로 드러내서 코드 리뷰/리팩터링/테스트 비용을 낮추는 데 있습니다.

Arrow에서 핵심: EitherIO

Either로 실패를 값으로 다루기

Either<E, A>는 “실패 E 또는 성공 A”를 담는 타입입니다.

  • Left(E) = 실패
  • Right(A) = 성공

예외를 던지는 대신, 실패를 Left로 반환하면 호출자는 반드시 처리해야 합니다(무시하면 컴파일은 되더라도 흐름상 명확히 드러남).

IO로 부작용을 지연하고 격리하기

IO<A>는 “실행하면 A를 만들어내는 효과(effect)”를 값으로 담습니다. 즉,

  • 지금 당장 실행하지 않고
  • 조합(map/flatMap)으로 파이프라인을 만든 뒤
  • 마지막에 한 번만 실행

하는 구조로 만들 수 있습니다.

중요한 포인트는 “IO가 있으면 무조건 순수해진다”가 아니라, 부작용이 어디에 있는지 경계가 선명해진다는 점입니다.

의존성 추가와 기본 세팅

프로젝트마다 Arrow 버전이 다르니, 아래는 형태만 참고하세요.

dependencies {
  implementation("io.arrow-kt:arrow-core:1.2.4")
  implementation("io.arrow-kt:arrow-fx-coroutines:1.2.4")
}

Eitherarrow-core, IO는 Arrow 버전에 따라 arrow-fx 계열(혹은 효과 타입이 다르게 제공)일 수 있습니다. 팀에서 쓰는 버전에 맞춰 문서를 확인하세요.

도메인 에러를 먼저 설계하기

예외 제거의 시작은 “무슨 실패가 가능한가”를 도메인 언어로 정의하는 것입니다.

sealed interface RegisterError {
  data object EmailAlreadyUsed : RegisterError
  data class PaymentFailed(val reason: String) : RegisterError
  data class PersistenceFailed(val reason: String) : RegisterError
}

여기서 포인트:

  • 인프라 예외 타입(SQLException)을 그대로 노출하지 않습니다.
  • API/DB 교체 시에도 도메인 에러는 유지됩니다.
  • 로깅/메트릭/알림 라우팅이 쉬워집니다.

Either로 서비스 흐름 재작성

이제 registerEither로 바꿔보겠습니다.

import arrow.core.Either
import arrow.core.left
import arrow.core.right

class UserService(
  private val repo: UserRepository,
  private val payment: PaymentClient,
) {
  suspend fun register(cmd: RegisterCommand): Either<RegisterError, UserDto> {
    if (repo.existsByEmail(cmd.email)) {
      return RegisterError.EmailAlreadyUsed.left()
    }

    val paymentId: String = try {
      payment.createCustomer(cmd.email)
    } catch (e: Exception) {
      return RegisterError.PaymentFailed(e.message ?: "unknown").left()
    }

    val user: User = try {
      repo.save(User(email = cmd.email, paymentId = paymentId))
    } catch (e: Exception) {
      return RegisterError.PersistenceFailed(e.message ?: "unknown").left()
    }

    return user.toDto().right()
  }
}

이미 좋아진 점:

  • 호출자는 Either를 받으므로 성공/실패를 분기해야 합니다.
  • 실패 타입이 RegisterError로 통일됩니다.

하지만 아직 try/catch가 남아 있고, I/O가 로직과 섞여 있습니다. 다음 단계는 부작용을 IO로 감싸고, Either와 조합하는 것입니다.

IO로 부작용을 감싸고, 비즈니스 로직을 순수하게 만들기

핵심 전략은 이겁니다.

  • 외부 세계(DB/HTTP)를 만지는 함수는 IO를 반환
  • 그 결과는 Either로 실패를 표현
  • 서비스는 IO들을 조합만 하고, 실행은 가장 바깥에서

포트(Ports) 정의

import arrow.core.Either

interface UserRepoPort {
  fun existsByEmail(email: String): IO<Either<RegisterError, Boolean>>
  fun save(user: User): IO<Either<RegisterError, User>>
}

interface PaymentPort {
  fun createCustomer(email: String): IO<Either<RegisterError, String>>
}

여기서 시그니처만 봐도 알 수 있습니다.

  • 이 호출은 효과(IO)를 가진다
  • 실패는 RegisterError

어댑터(Adapters)에서 예외를 Either로 변환

class UserRepoAdapter(private val jpa: UserJpaRepository) : UserRepoPort {
  override fun existsByEmail(email: String): IO<Either<RegisterError, Boolean>> =
    IO.attempt {
      jpa.existsByEmail(email)
    }.map { result ->
      result.fold(
        ifLeft = { e -> RegisterError.PersistenceFailed(e.message ?: "db error").left() },
        ifRight = { value -> value.right() }
      )
    }

  override fun save(user: User): IO<Either<RegisterError, User>> =
    IO.attempt {
      jpa.save(user)
    }.map { result ->
      result.fold(
        ifLeft = { e -> RegisterError.PersistenceFailed(e.message ?: "db error").left() },
        ifRight = { saved -> saved.right() }
      )
    }
}
  • IO.attempt { ... }는 예외를 잡아 Either<Throwable, A> 형태로 바꿔줍니다(Arrow 버전에 따라 이름/형태가 다를 수 있음).
  • 어댑터에서만 예외를 다루고, 도메인에는 RegisterError만 올립니다.

결과적으로 “예외는 인프라 경계에서만 존재”하게 됩니다.

Either 조합을 either 블록으로 깔끔하게

Arrow에는 either { ... } 같은 도구가 있어, 중간에 실패하면 자동으로 빠져나오는(early return) 스타일을 제공합니다.

아래는 서비스 레이어에서 IOEither를 조합하는 예시입니다.

import arrow.core.raise.either
import arrow.core.raise.ensure

class RegisterService(
  private val repo: UserRepoPort,
  private val payment: PaymentPort,
) {
  fun register(cmd: RegisterCommand): IO<Either<RegisterError, UserDto>> =
    IO.defer {
      either {
        val exists = repo.existsByEmail(cmd.email).bind().bind()
        ensure(!exists) { RegisterError.EmailAlreadyUsed }

        val paymentId = payment.createCustomer(cmd.email).bind().bind()
        val saved = repo.save(User(cmd.email, paymentId)).bind().bind()

        saved.toDto()
      }
    }
}

설명:

  • repo.existsByEmail(...).bind()IO를 풀어 실행 결과를 가져옵니다.
  • 그 결과가 Either이므로 한 번 더 bind()Right 값을 꺼냅니다.
  • 실패(Left)면 즉시 either 블록에서 빠져나갑니다.

이 패턴이 익숙해지면, 서비스 로직이 “성공 경로 중심”으로 읽히고 실패 처리는 타입으로 강제됩니다.

주의할 점은 bind()가 많아질 수 있다는 것입니다. 팀에서는 보통 다음 중 하나로 정리합니다.

  • IO<Either<E, A>>를 표준으로 두고, 헬퍼 확장함수로 bindIOEither() 같은 유틸을 둠
  • 또는 EitherT(모나드 트랜스포머) 스타일을 도입해 중첩을 줄임(팀 숙련도 필요)

재시도/백오프 같은 “실패 전략”을 깔끔하게 끼워넣기

예외 기반 코드에서는 재시도가 여기저기 흩어지기 쉽습니다. 반면 IO로 효과를 값으로 들고 있으면, “실행 전략”을 조합으로 표현하기가 좋아집니다.

예: 결제 고객 생성이 실패하면 3회 재시도(지수 백오프)한다고 가정합니다.

fun <A> IO<A>.retry(
  maxRetries: Int,
  delayMillis: Long,
  shouldRetry: (Throwable) -> Boolean,
): IO<A> = IO.defer {
  fun loop(n: Int): IO<A> =
    this.handleErrorWith { e ->
      if (n >= maxRetries || !shouldRetry(e)) IO.raiseError(e)
      else IO.sleep(delayMillis) *> loop(n + 1)
    }
  loop(0)
}

위 코드는 Arrow 버전에 따라 연산자(*>)나 sleep 제공 여부가 다를 수 있습니다. 핵심은 “효과를 값으로 들고 있으니 재시도 정책을 함수로 분리”할 수 있다는 점입니다.

이런 패턴은 외부 API 429 같은 케이스에서 특히 유용합니다. 운영에서 자주 만나는 재시도/백오프 설계는 OpenAI 429/Rate Limit 재시도·백오프 실전 가이드와도 사고방식이 통합니다.

컨트롤러/핸들러에서 Either를 HTTP 응답으로 매핑

서비스가 Either를 반환하면, 웹 계층에서는 “에러를 HTTP로 번역”하는 역할만 합니다.

fun RegisterError.toHttp(): Pair<Int, String> = when (this) {
  RegisterError.EmailAlreadyUsed -> 409 to "email already used"
  is RegisterError.PaymentFailed -> 502 to "payment failed: ${this.reason}"
  is RegisterError.PersistenceFailed -> 500 to "db failed: ${this.reason}"
}

suspend fun registerHandler(cmd: RegisterCommand, svc: RegisterService): HttpResponse {
  val result = svc.register(cmd).unsafeRunSync()

  return result.fold(
    ifLeft = { err ->
      val (code, msg) = err.toHttp()
      HttpResponse(code, msg)
    },
    ifRight = { dto ->
      HttpResponse(201, dto)
    }
  )
}
  • unsafeRunSync() 같은 “실행”은 가장 바깥(프레임워크 어댑터)에서만 하도록 규칙을 두는 게 좋습니다.
  • 서비스/도메인 레이어는 실행하지 않고 조합만 합니다.

테스트가 쉬워지는 이유: 부작용이 인터페이스로 분리됨

IO와 포트/어댑터 구조를 쓰면, 테스트에서는 “진짜 DB/HTTP” 대신 “가짜 구현”을 쉽게 주입합니다.

class FakeRepo : UserRepoPort {
  private val emails = mutableSetOf<String>()

  override fun existsByEmail(email: String): IO<Either<RegisterError, Boolean>> =
    IO.pure((email in emails).right())

  override fun save(user: User): IO<Either<RegisterError, User>> =
    IO.pure(
      user.also { emails.add(it.email) }.right()
    )
}

class FakePayment : PaymentPort {
  override fun createCustomer(email: String): IO<Either<RegisterError, String>> =
    IO.pure("pay_${email}".right())
}

테스트는 순수하게 “입력에 대한 출력(Either)”만 검증하면 됩니다.

suspend fun testRegisterDuplicateEmail() {
  val repo = FakeRepo()
  val payment = FakePayment()
  val svc = RegisterService(repo, payment)

  // 첫 등록
  svc.register(RegisterCommand("a@b.com")).unsafeRunSync()

  // 중복 등록
  val result = svc.register(RegisterCommand("a@b.com")).unsafeRunSync()

  assert(result.isLeft())
}

실무 적용 팁: 한 번에 갈아엎지 말고 “경계부터” 바꾸기

Arrow를 도입할 때 가장 흔한 실패는 “전 레이어를 한 번에 함수형으로 바꾸려다” 팀 생산성이 떨어지는 경우입니다.

추천 순서:

  1. 도메인 에러 타입부터 통일 (sealed interface Error)
  2. 인프라 경계(HTTP/DB/메시지큐)에서 예외를 Either로 변환
  3. 서비스 레이어는 Either 조합으로 정리
  4. 필요할 때만 IO로 효과 경계를 강화

운영 장애를 줄이는 관점에서는 “실패를 예측 가능한 형태로 모으는 것”이 특히 중요합니다. 네트워크/인프라 이슈가 원인인 장애를 다룰 때도, 실패를 구조화해두면 원인 파악이 빨라집니다. 예를 들어 DNS나 라우팅 문제는 애플리케이션 레벨에서 보면 “간헐 실패”로만 보이는데, 이를 관측/재시도 정책으로 흡수하기도 합니다. 관련해서는 EKS NodeLocal DNSCache로 DNS 간헐 실패 잡기 같은 글이 운영 관점에서 함께 도움이 됩니다.

흔한 질문과 함정

Either를 쓰면 예외가 완전히 사라지나

아닙니다. JVM/라이브러리 세계는 예외 기반이 기본입니다. 목표는 “예외를 없애기”가 아니라:

  • 예외가 도메인까지 전파되지 않게 경계에서 포착하고
  • 도메인에서는 실패를 Either로만 다루게 만드는 것입니다.

IO를 도입하면 무조건 코드가 복잡해지지 않나

초기에 bind() 중첩이나 실행 지점(unsafeRun...) 규칙 때문에 복잡해질 수 있습니다. 그래서 팀 합의가 중요합니다.

  • IO는 “외부 세계를 만지는 모듈”에서만 사용
  • 도메인/유스케이스는 가능하면 Either 중심
  • 실행은 컨트롤러/스케줄러/컨슈머 같은 가장 바깥에서만

이 3가지만 지켜도 복잡도가 크게 줄어듭니다.

코루틴 suspend만으로는 부족한가

suspend는 비동기 실행 모델이고, Either/IO는 실패/부작용을 타입으로 모델링하는 도구입니다. 둘은 경쟁 관계가 아니라, 목적이 다릅니다.

정리

  • Either는 실패를 예외가 아닌 으로 만들어, 호출자가 처리하도록 강제합니다.
  • IO는 부작용을 지연·격리해, 어디서 외부 세계를 만지는지 경계를 명확히 합니다.
  • 인프라 경계에서 예외를 Either로 변환하고, 서비스는 조합만 하도록 만들면 테스트/리팩터링/운영 대응이 쉬워집니다.

다음 단계로는

  • Either 에러 모델을 더 세분화(검증 오류 vs 인프라 오류)
  • 재시도/타임아웃/서킷브레이커 같은 정책을 IO 조합으로 표준화
  • 관측(로그/메트릭/트레이싱)을 효과 경계에서 일관되게 적용

을 고려해볼 만합니다. 이렇게 쌓아두면 “예외가 터져서 장애가 난다”가 아니라, “실패가 타입으로 흐르고 정책으로 제어된다”에 가까운 구조로 진화합니다.