Cats Effect 1

Tags
Published
Author
외부 API에서 데이터를 가져오는 작업을 구현하는 경우 데이터를 가져오다 보면 다양한 문제가 발생한다. 이 때 Effect Type 를 사용해서 우아하게 처리하는 방법을 알아보자.
예제에서는 Scala + cats-effect 를 사용한다. https 요청에 대한 에러를 처리하는 로직을 확인하기 위해 jsonplaceholder 를 사용한다.
네트워크 요청을 try - catch 로 처리하면 코드가 지저분해진다. 특히 여러건을 동시에 진행하는 경우 실패하는 케이스와 성공하는 케이스가 섞일 때 자유롭게 로직을 구사하기는 더 어렵게 된다.
아래는 effect-type 을 도입했을 때의 예시이다.
package com.example import cats.effect.{IO, IOApp} import org.http4s.ember.client.EmberClientBuilder import org.http4s.circe.CirceEntityDecoder._ import io.circe.generic.auto._ import org.http4s._ import scala.concurrent.duration._ import cats.syntax.traverse._ object Main extends IOApp.Simple: private case class Todo(userId: Int, id: Int, title: String, completed: Boolean) private sealed trait FetchError private case class NetworkError(message: String) extends FetchError private case class NotFoundError(id: Int) extends FetchError private case class TimeoutError(id: Int) extends FetchError private case class JsonError(id: Int, message: String) extends FetchError def run: IO[Unit] = EmberClientBuilder.default[IO].build.use { client => def fetchTodo(id: Int): IO[Either[FetchError, Todo]] = val request = Request[IO]( Method.GET, Uri.unsafeFromString(s"https://jsonplaceholder.typicode.com/todos/$id") ) client.expect[Todo](request) .timeout(2.milliseconds) .map(Right(_)) .handleErrorWith { case _: java.util.concurrent.TimeoutException => IO.pure(Left(TimeoutError(id))) case _: org.http4s.client.UnexpectedStatus => IO.pure(Left(NotFoundError(id))) case e: org.http4s.DecodeFailure => IO.pure(Left(JsonError(id, e.getMessage))) case e => IO.pure(Left(NetworkError(e.getMessage))) } def processTodo(result: Either[FetchError, Todo]): IO[String] = result match case Right(todo) => IO.pure(s"Successfully processed todo: ${todo.title}") case Left(TimeoutError(id)) => IO.pure(s"Todo $id timed out, will retry later") case Left(NotFoundError(id)) => IO.pure(s"Todo $id not found, skipping") case Left(JsonError(id, msg)) => IO.pure(s"Invalid todo data for $id: $msg") case Left(NetworkError(msg)) => IO.pure(s"Network error: $msg") def fetchWithRetry(id: Int, retriesLeft: Int): IO[Either[FetchError, Todo]] = fetchTodo(id).flatMap { case Left(TimeoutError(_)) if retriesLeft > 0 => IO.println(s"Retrying todo $id, $retriesLeft retries left") >> IO.sleep(1.second) >> fetchWithRetry(id, retriesLeft - 1) case result => IO.pure(result) } val program = for _ <- IO.println("Starting to fetch todos...") // 999 는 404 에러를 발생시킨다. results <- List(1, 999, 2, 3).traverse { id => fetchWithRetry(id, 2).flatMap { result => processTodo(result).flatMap { message => IO.println(message) >> IO.pure(result) } } } successfulTodos = results.collect { case Right(todo) => todo } _ <- IO.println("\nSummary:") _ <- IO.println(s"Successfully fetched: ${successfulTodos.length}") _ <- IO.println(s"Failed: ${results.length - successfulTodos.length}") completedCount = successfulTodos.count(_.completed) _ <- IO.println(s"Completed todos: $completedCount") yield () program.handleErrorWith { error => IO.println(s"Fatal error: ${error.getMessage}") } }
scala 언어는 크게 신경쓰지 말고 program 에 해당하는 로직만 대강 눈으로 보면 된다.

장점

  1. 모든 에러가 타입으로 정의된다.
  1. 컴파일 타임에 에러 핸들링이 강제된다.
  1. retry 로직이 단순해진다.
  1. 실패하는 경우를 포함하는 모나드를 반환하므로 여러 네트워크 요청을 전부 처리한 뒤 실패와 성공케이스를 별도로 다룰 수 있다.
 
4번의 장점이 상당히 유익하다고 생각한다. 해당 예제는 가독성을 위해 단순한 로직을 구현했지만, 네트워크 요청 이후 추가로 처리하는 복잡한 로직이 있는 경우 이 장점은 더 의미가 있을 것으로 보인다.