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