- Published on
Scala 3에서 EitherT·IO로 에러 누수 막기
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 사이드 Scala를 운영하다 보면 “에러가 새는(leak)” 순간이 반복됩니다. 예외가 throw로 튀어나가 상위 레이어에서 500으로 뭉개지거나, Future/콜백에서 실패가 누락되어 타임아웃으로 바뀌거나, 외부 API의 실패가 애매한 문자열 메시지로만 남는 식입니다.
Scala 3 + Cats Effect에서는 IO가 “효과의 경계”를 제공하고, EitherT[IO, E, A]가 “실패의 타입”을 고정해줍니다. 이 둘을 제대로 조합하면 다음을 달성할 수 있습니다.
- 예외가 런타임으로 튀지 않고, 도메인 에러 타입으로 수렴
- 서비스 계층의 모든 함수가 동일한 에러 시그니처를 유지
- 리소스(
Resource)와 함께 써도 정리(finalizer) 누락 없이 안전 - 외부 시스템(HTTP/DB/Queue)의 실패를 정책적으로 매핑 가능
아래는 Scala 3 기준으로 “에러 누수”를 막는 핵심 패턴을 단계별로 정리합니다.
에러 누수의 전형적인 형태
1) IO 안에서 예외를 던지고 그대로 방치
IO는 예외를 캡처할 수 있지만, 캡처한 예외를 도메인 에러로 바꾸지 않으면 결국 상위에서 Throwable로 처리하게 됩니다. 그러면 API 응답은 500으로 뭉개지고, 어떤 실패인지 분류가 어려워집니다.
2) Either와 IO를 따로 놀게 만들기
예를 들어 IO[Either[E, A]]를 여기저기 반환하면, 호출부에서 flatMap/map 중첩이 늘어나고 “어느 지점에서 Left를 처리했는지”가 흐려집니다. 그 사이에 raiseError나 throw가 섞이면 에러 경로가 더 복잡해집니다.
3) 외부 호출 실패를 문자열로만 남기기
외부 API가 429/5xx를 주거나 타임아웃이 나는데 이를 Left("failed")처럼 뭉개면 재시도/서킷브레이커/알림 정책을 계층적으로 적용하기 어렵습니다.
레이트리밋과 재시도 설계 관점은 Gemini API 429 쿼터·레이트리밋 재시도 설계도 함께 참고하면 좋습니다.
목표: “실패는 타입으로, 효과는 IO로”
권장하는 기본 규칙은 다음입니다.
- 효과는
IO로만 표현한다 (Future혼용 최소화) - 예상 가능한 실패는
AppError같은 ADT로 표현한다 - 서비스 계층의 시그니처는 가능하면
EitherT[IO, AppError, A]로 통일한다 - “정말 예외적인” 버그/불변식 위반만
IO.raiseError로 남긴다
이렇게 하면 관찰 가능한 실패는 모두 Left(AppError)로 수렴하고, 진짜 버그만 Throwable로 남습니다.
도메인 에러 ADT 설계
먼저 실패를 분류할 ADT를 만듭니다. 핵심은 “나중에 정책을 달 수 있을 정도로” 구조화하는 것입니다.
// Scala 3
sealed trait AppError derives CanEqual:
def message: String
object AppError:
final case class Validation(message: String) extends AppError
final case class NotFound(message: String) extends AppError
final case class Conflict(message: String) extends AppError
// 외부 시스템/인프라 계층
final case class Upstream(
service: String,
status: Option[Int],
message: String
) extends AppError
final case class Timeout(service: String, message: String) extends AppError
final case class Unexpected(message: String, cause: Throwable) extends AppError
포인트:
Upstream에service,status를 넣어두면 429/503 등 정책을 붙이기 쉽습니다.Unexpected는 “잡았지만 도메인으로 분류 불가”한 예외를 담습니다. 운영에서는 이 케이스를 줄이는 게 목표입니다.
EitherT를 서비스 기본 타입으로 고정
Cats의 EitherT는 F[Either[E, A]]를 감싸는 모나드 트랜스포머입니다. 이 글에서는 F를 IO로 고정합니다.
import cats.data.EitherT
import cats.effect.IO
type AppIO[A] = EitherT[IO, AppError, A]
object AppIO:
def ok[A](a: A): AppIO[A] = EitherT.rightT(a)
def fail[A](e: AppError): AppIO[A] = EitherT.leftT(e)
def fromIO[A](ioa: IO[A]): AppIO[A] = EitherT.liftF(ioa)
이제 서비스 함수는 기본적으로 AppIO[A]를 반환합니다.
“예외 캡처”를 경계에서 강제하기
에러 누수를 막는 핵심은 “외부/불신 경계”에서 Throwable을 잡아 AppError로 매핑하는 것입니다.
IO 예외를 AppError로 변환하는 헬퍼
import cats.syntax.all.*
extension [A](ioa: IO[A])
def toAppIO(mapper: Throwable => AppError): AppIO[A] =
EitherT(
ioa.attempt.map {
case Right(a) => Right(a)
case Left(t) => Left(mapper(t))
}
)
이제 “예외가 날 수 있는 IO”는 반드시 toAppIO를 거쳐 서비스 레이어로 들어오게 할 수 있습니다.
예시: 외부 HTTP 호출을 EitherT로 감싸기
HTTP 클라이언트로 sttp를 쓴다고 가정해보겠습니다. (라이브러리는 예시이며 패턴이 핵심입니다.)
import cats.data.EitherT
import cats.effect.IO
import cats.syntax.all.*
final case class User(id: String, name: String)
trait UserClient:
def fetchUser(id: String): AppIO[User]
final class LiveUserClient(/* http backend 등 */) extends UserClient:
private def mapThrowable(t: Throwable): AppError =
// 타임아웃/네트워크/디코딩 등 구체 매핑 권장
AppError.Unexpected("http call failed", t)
def fetchUser(id: String): AppIO[User] =
val io: IO[User] =
// 실제로는 http 요청 + JSON decode
IO.pure(User(id, "alice"))
io.toAppIO(mapThrowable)
여기서 중요한 점은 fetchUser가 IO[User]를 절대 그대로 반환하지 않고, 무조건 AppIO[User]로 “실패 타입을 고정”한다는 것입니다.
HTTP 상태코드 기반으로 실패를 분류하기
실전에서는 “상태코드가 있는 실패”를 더 많이 다룹니다. 예를 들어 404는 NotFound, 409는 Conflict, 429는 Upstream으로 분류하는 식입니다.
final case class HttpResponse(status: Int, body: String)
def decodeUser(body: String): Either[AppError, User] =
// JSON decode 실패를 Validation/Upstream 등으로 분류
Right(User("1", "alice"))
def fetchUserHttp(id: String): IO[HttpResponse] =
IO.pure(HttpResponse(200, "{...}"))
def fetchUser(id: String): AppIO[User] =
EitherT(
fetchUserHttp(id).attempt.map {
case Left(t) => Left(AppError.Unexpected("network failure", t))
case Right(resp) =>
resp.status match
case 200 => decodeUser(resp.body)
case 404 => Left(AppError.NotFound(s"user ${id} not found"))
case 409 => Left(AppError.Conflict("conflict"))
case 429 => Left(AppError.Upstream("user-service", Some(429), "rate limited"))
case s => Left(AppError.Upstream("user-service", Some(s), s"unexpected status: ${s}"))
}
)
이렇게 하면 상위 서비스는 “429인지, 404인지”를 타입/값으로 분기할 수 있고, 관측/재시도/알림 정책도 계층적으로 적용됩니다.
서비스 조합: for-comprehension에서 에러가 자동 전파
EitherT의 장점은 for에서 Left가 나오면 자동으로 단락(short-circuit)된다는 점입니다.
final case class Order(id: String, userId: String)
trait OrderRepo:
def find(id: String): AppIO[Order]
final class OrderService(userClient: UserClient, orderRepo: OrderRepo):
def getOrderView(orderId: String): AppIO[(Order, User)] =
for
order <- orderRepo.find(orderId)
user <- userClient.fetchUser(order.userId)
yield (order, user)
여기서 orderRepo.find가 Left(NotFound)를 내면 fetchUser는 호출되지 않습니다. 즉, “실패 경로가 암묵적으로 누락”되는 대신 “실패가 타입으로 전파”됩니다.
경계 규칙: 컨트롤러/엔드포인트에서만 HTTP로 변환
에러 누수를 막으려면 변환 책임을 명확히 해야 합니다.
- 도메인/서비스:
AppIO[A] - 프레젠테이션(HTTP):
AppError를 HTTP 응답으로 변환
예시로 AppError를 상태코드로 매핑합니다.
final case class HttpOut(status: Int, body: String)
def toHttpOut(e: AppError): HttpOut = e match
case AppError.Validation(m) => HttpOut(400, m)
case AppError.NotFound(m) => HttpOut(404, m)
case AppError.Conflict(m) => HttpOut(409, m)
case AppError.Timeout(s, m) => HttpOut(504, s"${s}: ${m}")
case AppError.Upstream(s, st, m) =>
// 업스트림 실패를 그대로 노출할지, 502로 통일할지 정책화
HttpOut(502, s"upstream=${s} status=${st.getOrElse(0)} msg=${m}")
case AppError.Unexpected(m, _) => HttpOut(500, m)
def handle[A](app: AppIO[A])(encode: A => String): IO[HttpOut] =
app.value.map {
case Right(a) => HttpOut(200, encode(a))
case Left(e) => toHttpOut(e)
}
이 구조를 지키면 “서비스가 HTTP를 모른다”는 장점도 생깁니다. 테스트도 쉬워지고, gRPC/CLI 등 다른 인터페이스로 확장할 때 변환 레이어만 바꾸면 됩니다.
타임아웃이 상위에서 어떻게 관측되고 장애로 번지는지는 Go gRPC DEADLINE_EXCEEDED 원인별 해결 7가지처럼 “원인별로 분류하고 정책화”하는 접근과 결이 같습니다.
리소스 안전성: Resource + EitherT에서 누수 막기
에러 누수는 종종 “커넥션/파일 핸들 정리 누락” 형태로도 나타납니다. Cats Effect의 Resource를 쓰면 성공/실패/취소 모두에서 안전하게 정리됩니다.
핵심은 Resource를 가장 바깥에서 확보하고, 내부에서는 EitherT로 실패를 표현하는 것입니다.
import cats.effect.{IO, Resource}
import cats.data.EitherT
final class DbConn:
def queryOrder(id: String): IO[Option[Order]] = IO.pure(None)
def dbResource: Resource[IO, DbConn] =
Resource.make(IO(new DbConn))(_ => IO.unit)
final class LiveOrderRepo(conn: DbConn) extends OrderRepo:
def find(id: String): AppIO[Order] =
EitherT.liftF(conn.queryOrder(id)).flatMap {
case Some(o) => AppIO.ok(o)
case None => AppIO.fail(AppError.NotFound(s"order ${id} not found"))
}
def program(orderId: String): IO[HttpOut] =
dbResource.use { conn =>
val repo = LiveOrderRepo(conn)
val userClient = new LiveUserClient()
val svc = OrderService(userClient, repo)
handle(svc.getOrderView(orderId)) { case (o, u) => s"${o.id}:${u.name}" }
}
이 패턴에서 DB 커넥션은 어떤 실패가 발생해도 use 블록이 끝날 때 정리됩니다. 즉, “에러가 났는데 커넥션이 남아버리는 누수”를 구조적으로 차단합니다.
실전 팁: EitherT를 과용하지 않는 지점
EitherT는 강력하지만 모든 곳에 무조건 쓰는 것이 정답은 아닙니다.
- 순수 로직:
Either[AppError, A]가 더 간단합니다. (효과가 없으므로) - 정말 예외적인 버그:
IO.raiseError로 올려서 크래시/알람 대상으로 삼는 편이 낫습니다. - 병렬 처리:
EitherT로 병렬을 만들면 단락 시점/에러 합성 정책이 애매해질 수 있습니다. 이때는IO레벨에서parTraverse후 결과를Either로 수렴시키는 등 정책을 명확히 하세요.
체크리스트: “에러 누수”를 막는 규칙 7가지
- 서비스 레이어 반환 타입을
EitherT[IO, AppError, A]로 통일한다 - 외부 경계(HTTP/DB/파일/서드파티 SDK)에서
Throwable을 반드시AppError로 매핑한다 AppError는 상태코드/서비스명/원인 등 운영에 필요한 필드를 포함한다- 컨트롤러에서만
AppError를 HTTP(또는 gRPC)로 변환한다 Resource를 바깥에서 확보하고 내부는EitherT로 실패를 표현한다- 문자열로 실패를 뭉개지 말고(예:
Left("failed")) 분류 가능한 ADT로 만든다 - “재시도/레이트리밋/타임아웃” 같은 정책은
AppError분류를 기반으로 적용한다
마무리
Scala 3에서 IO는 “효과가 새지 않게” 만들고, EitherT는 “실패가 새지 않게” 만듭니다. 둘을 합치면 예외가 컨트롤러 밖으로 튀거나, 실패가 누락되어 타임아웃으로 변질되는 전형적인 운영 문제를 크게 줄일 수 있습니다.
핵심은 기술 자체보다도 규칙입니다. 어떤 계층에서 무엇을 변환할지(예외 Throwable을 언제 AppError로 바꿀지), 어떤 형태로 실패를 유지할지(서비스는 AppIO로 고정)를 팀 규약으로 만들면, 코드베이스가 커질수록 효과가 누적됩니다.