- Published on
Kotlin Arrow Either·IO로 예외·부작용 제거
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 애플리케이션을 오래 운영하다 보면, 결국 문제는 두 가지로 수렴합니다.
- 예외(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에서 핵심: Either와 IO
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")
}
Either는 arrow-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로 서비스 흐름 재작성
이제 register를 Either로 바꿔보겠습니다.
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) 스타일을 제공합니다.
아래는 서비스 레이어에서 IO와 Either를 조합하는 예시입니다.
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를 도입할 때 가장 흔한 실패는 “전 레이어를 한 번에 함수형으로 바꾸려다” 팀 생산성이 떨어지는 경우입니다.
추천 순서:
- 도메인 에러 타입부터 통일 (
sealed interface Error) - 인프라 경계(HTTP/DB/메시지큐)에서 예외를
Either로 변환 - 서비스 레이어는
Either조합으로 정리 - 필요할 때만
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조합으로 표준화 - 관측(로그/메트릭/트레이싱)을 효과 경계에서 일관되게 적용
을 고려해볼 만합니다. 이렇게 쌓아두면 “예외가 터져서 장애가 난다”가 아니라, “실패가 타입으로 흐르고 정책으로 제어된다”에 가까운 구조로 진화합니다.