- Published on
Scala ZIO로 Effect 타입·리소스 안전 패턴 7
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 애플리케이션에서 장애의 대부분은 두 부류로 수렴합니다. 하나는 실패를 제대로 타입으로 모델링하지 못해 예외가 새어나가는 문제, 다른 하나는 커넥션·파일·락 같은 리소스가 누수되거나 잘못된 생명주기로 사용되는 문제입니다. Scala ZIO는 ZIO[R, E, A]라는 Effect 타입과 Scope 중심의 리소스 모델을 통해 이 두 문제를 구조적으로 해결합니다.
이 글에서는 ZIO를 “그냥 Future 대체”로 쓰는 수준을 넘어, Effect 타입과 리소스 안전을 일관되게 설계하는 7가지 패턴을 실전 관점에서 정리합니다.
준비: ZIO Effect 타입을 한 문장으로 정리
ZIO의 핵심은 다음 한 줄로 요약됩니다.
ZIO[R, E, A]는R환경을 필요로 하고,E로 실패할 수 있으며, 성공 시A를 만든다
즉, 의존성 주입, 에러 모델링, 비동기/동시성, 자원 생명주기를 모두 Effect 안에서 통제할 수 있습니다.
아래 예제들은 ZIO 2.x 기준이며, 빌드 환경에 따라 패키지 임포트는 달라질 수 있습니다.
import zio.*
import zio.Duration.*
패턴 1) 도메인 에러를 E에 고정하고 예외를 격리하기
가장 흔한 안티패턴은 ZIO[Any, Throwable, A]가 코드베이스 전반으로 퍼지는 것입니다. 이러면 실패가 도메인 규칙이 아니라 “잡다한 예외”로 퉁쳐져, 호출자에서 케이스 분기가 불가능해집니다.
권장 패턴은 다음입니다.
- 도메인 에러 ADT를 만든다
- 외부 라이브러리 예외는 경계에서만
mapError로 변환한다
sealed trait AppError extends Product with Serializable
object AppError {
final case class NotFound(id: String) extends AppError
final case class Validation(msg: String) extends AppError
final case class Upstream(msg: String, cause: Throwable) extends AppError
}
def parseId(raw: String): IO[AppError, Long] =
ZIO
.attempt(raw.toLong)
.mapError(_ => AppError.Validation(s"invalid id: $raw"))
def callUpstream: IO[AppError, String] =
ZIO
.attempt(throw new RuntimeException("boom"))
.mapError(t => AppError.Upstream("upstream failed", t))
이렇게 하면 서비스 계층은 AppError만 다루고, 예외는 “경계”에서만 존재합니다. 운영에서 장애 원인 추적도 쉬워집니다.
패턴 2) ZIO.serviceWithZIO로 환경 의존성을 명시하고 테스트 가능하게 만들기
ZIO의 R은 단순한 DI 컨테이너가 아니라 “필요한 능력(capability)”의 타입 시그니처입니다. R이 명시되면 테스트에서 대체 구현을 넣기 쉬워지고, 런타임에서 필요한 것만 조립하게 됩니다.
trait UserRepo {
def find(id: Long): IO[AppError, Option[String]]
}
object UserRepo {
def find(id: Long): ZIO[UserRepo, AppError, Option[String]] =
ZIO.serviceWithZIO[UserRepo](_.find(id))
}
def getUserName(id: Long): ZIO[UserRepo, AppError, String] =
UserRepo.find(id).someOrFail(AppError.NotFound(id.toString))
테스트에서는 ZLayer.succeed로 쉽게 대체합니다.
val testLayer: ULayer[UserRepo] = ZLayer.succeed(
new UserRepo {
def find(id: Long): IO[AppError, Option[String]] = ZIO.succeed(Some("alice"))
}
)
val program: ZIO[Any, AppError, String] = getUserName(1).provide(testLayer)
패턴 3) 리소스는 acquireRelease 또는 ZIO.scoped로만 열고 닫기
리소스 누수는 대부분 “성공 경로에서는 닫는데 실패 경로에서 안 닫는” 형태로 발생합니다. ZIO는 이 문제를 Scope로 해결합니다.
핵심 규칙:
- 리소스 획득과 해제를 한 덩어리로 묶는다
- 사용 구간을
scoped로 제한한다
final class FakeConn {
def query(sql: String): Task[String] = ZIO.succeed(s"result for: $sql")
def close(): UIO[Unit] = ZIO.succeed(())
}
def openConn: Task[FakeConn] = ZIO.succeed(new FakeConn)
def connResource: ZIO[Scope, Throwable, FakeConn] =
ZIO.acquireRelease(openConn)(_.close().orDie)
val program: Task[String] =
ZIO.scoped {
for {
conn <- connResource
res <- conn.query("select 1")
} yield res
}
이 패턴을 따르면 중간에 실패하거나 fiber가 인터럽트되어도 해제가 보장됩니다.
패턴 4) “리소스 생명주기”는 Scope로 전파하고, 장수 작업은 forkScoped로 묶기
백그라운드 워커나 스트림 소비자처럼 장수하는 fiber를 띄울 때, 스코프와 분리되면 종료 시점에 정리가 되지 않거나, 반대로 너무 일찍 죽는 문제가 생깁니다.
forkScoped를 사용하면 “부모 스코프가 닫힐 때 같이 정리”되는 구조를 만들 수 있습니다.
def worker: UIO[Unit] =
(ZIO.sleep(1.second) *> ZIO.logInfo("tick")).forever
val runApp: ZIO[Scope, Nothing, Unit] =
for {
_ <- worker.forkScoped
_ <- ZIO.sleep(3.seconds)
} yield ()
val main: UIO[Unit] = ZIO.scoped(runApp)
운영 환경에서 graceful shutdown을 설계할 때 특히 유용합니다.
패턴 5) 재시도는 “무한 루프”가 아니라 Schedule로 정책화하기
재시도 로직을 while 루프로 직접 짜면, 백오프·최대 횟수·특정 에러만 재시도 같은 정책이 분산됩니다. ZIO는 Schedule로 재시도 정책을 값으로 모델링합니다.
def flakyCall: IO[AppError, String] =
ZIO.fail(AppError.Upstream("429", new RuntimeException("rate limit")))
val retryPolicy =
Schedule.exponential(100.millis) && Schedule.recurs(5)
val program: IO[AppError, String] =
flakyCall.retry(retryPolicy)
HTTP 429 같은 레이트 리밋 백오프는 운영에서 매우 흔한 주제입니다. 재시도/백오프 설계를 더 깊게 보고 싶다면 OpenAI API 429·Rate limit 실전 백오프 패턴도 함께 참고하면 ZIO의 Schedule을 어디에 어떻게 적용할지 감이 빨리 잡힙니다.
패턴 6) 타임아웃과 취소(인터럽트)를 “의미 있게” 결합하기
타임아웃은 단순히 오래 걸리면 실패시키는 기능이 아니라, 취소 전파와 리소스 해제가 함께 동작해야 진짜 안전합니다.
timeoutFail로 도메인 에러를 유지한다- 타임아웃으로 인터럽트가 발생해도
acquireRelease는 해제를 보장한다
def slow: IO[AppError, String] =
ZIO.sleep(3.seconds) *> ZIO.succeed("ok")
val program: IO[AppError, String] =
slow.timeoutFail(AppError.Upstream("timeout", new RuntimeException("timeout")))(1.second)
여기에 패턴 3의 리소스 스코프를 결합하면, “타임아웃으로 중단되었는데 커넥션이 남아있는” 류의 장애를 크게 줄일 수 있습니다.
패턴 7) 동시성은 fork보다 구조적 동시성(zipPar, foreachPar, Scope)으로 제한하기
ZIO는 fiber 기반 동시성이 강력하지만, 무분별한 fork는 추적 불가능한 백그라운드 작업을 양산합니다. 권장 접근은 다음입니다.
- “같이 시작하고 같이 끝나는” 작업은
zipPar또는collectAllPar - 컬렉션 병렬 처리는
foreachPar - 장수 fiber는 패턴 4의
forkScoped
def fetchA: UIO[String] = ZIO.succeed("A")
def fetchB: UIO[String] = ZIO.succeed("B")
val combined: UIO[(String, String)] = fetchA.zipPar(fetchB)
val ids = List(1, 2, 3, 4)
val processed: UIO[List[String]] =
ZIO.foreachPar(ids)(id => ZIO.succeed(s"done:$id"))
이렇게 구조적으로 묶으면, 실패 시 다른 작업을 어떻게 취소할지, 완료를 어디서 기다릴지 같은 문제가 API 레벨에서 정리됩니다.
실전 조합 예시: 리소스 안전 + 도메인 에러 + 재시도 + 타임아웃
위 패턴들이 각각 따로가 아니라 “한 덩어리”로 합쳐질 때 운영 안정성이 올라갑니다.
sealed trait ApiError extends Product with Serializable
object ApiError {
case object Timeout extends ApiError
final case class Transport(cause: Throwable) extends ApiError
}
final class HttpClient {
def get(path: String): Task[String] = ZIO.succeed(s"ok:$path")
def shutdown(): UIO[Unit] = ZIO.unit
}
def makeClient: Task[HttpClient] = ZIO.succeed(new HttpClient)
def client: ZIO[Scope, Throwable, HttpClient] =
ZIO.acquireRelease(makeClient)(_.shutdown().orDie)
val policy = Schedule.exponential(50.millis) && Schedule.recurs(3)
def call(path: String): ZIO[Scope, ApiError, String] =
ZIO.scoped {
for {
c <- client.mapError(ApiError.Transport.apply)
r <- c.get(path)
.mapError(ApiError.Transport.apply)
.timeoutFail(ApiError.Timeout)(800.millis)
.retry(policy)
} yield r
}
- 예외는
ApiError.Transport로 경계에서 격리 - 타임아웃은
ApiError.Timeout으로 도메인화 - 재시도는
Schedule로 정책화 - 클라이언트는
Scope로 안전하게 종료
운영 관점 체크리스트
ZIO를 도입할 때 아래 항목을 팀 규칙으로 고정하면, 시간이 지날수록 코드베이스 품질이 좋아집니다.
Throwable을 서비스 레이어 시그니처에서 금지하고 도메인 에러 ADT를 사용한다- 외부 I/O 경계에서만
attempt를 사용하고 즉시mapError로 변환한다 - 리소스는 반드시
acquireRelease또는ZIO.scoped로 관리한다 - 장수 fiber는
forkScoped로 스코프에 귀속시킨다 - 재시도는
Schedule로만 구현하고 최대 횟수와 백오프를 명시한다 - 타임아웃은
timeoutFail로 도메인 에러를 유지한다 - 동시성은 구조적으로 묶고, 고아 fiber를 만들지 않는다
관련해서 “운영 장애를 빠르게 좁히는 관점”은 JVM/서버 공통으로 중요합니다. 메모리 압박과 장애 분석 흐름이 궁금하다면 Spring Boot OOM 원인추적과 힙덤프 분석 실전도 함께 보면, ZIO 애플리케이션을 운영할 때 어떤 지표와 증상을 먼저 볼지 힌트를 얻을 수 있습니다.
마무리
ZIO의 강점은 단순히 비동기를 편하게 만드는 것이 아니라, 실패와 리소스를 “타입과 스코프”로 강제해 운영 리스크를 줄이는 데 있습니다. 위 7가지 패턴을 기준으로 코드를 정리하면, 예외가 새어나가거나 커넥션이 누수되는 문제를 구조적으로 예방할 수 있고, 재시도·타임아웃·동시성 정책도 일관되게 유지할 수 있습니다.
다음 단계로는 ZLayer로 애플리케이션을 조립하는 방식, ZIO.log와 구조화 로깅, ZIO Test에서 시간 제어(TestClock)를 활용한 재시도/타임아웃 테스트까지 확장하면 ZIO의 효과를 더 크게 체감할 수 있습니다.