ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [도전] Scala로 REST API 만들려면 어떻게 하나? #2
    기술 관련/Scala 2021. 2. 6. 02:53

    지난번에 Scala로 REST API를 어떻게 만드는지 알아보다가, 여러가지 프레임워크를 비교한 글도 알아보고, 그 중에서 AKKA HTTP로 결정했었는데, AKKA가 뭔지 궁금해서 찾아 봤더니, Actor 시스템에 대한 이야기까지 흘러가 버렸었다. -_-;;

     

    배움에 대한 궁금증의 흐름을 따라가다 보면 뭔가 개미지옥에 빠진 기분이 들기도 하는데, 그리고 AKKA-HTTP는 AKKA Actor와 AKKA Stream을 기반으로 되어 있다고 하지만, 일단 Hello World까지는 했으니, Stream은 좀 이따가 보기로 하고 AKKA HTTP를 다시 보기로 했다. 

     

    doc.akka.io/docs/akka-http/current/introduction.html  

     

    패키지 정보

    AKKA HTTP도 패키지 형태로 제공되며 다음과 같다. 당연하게도 Scala 2.12.12, 2.13.3이 필요하며 JDK의 경우는 Adopt OpenJDK 8, 11을 기준으로 하고 있다.

     

     

     

    AKKA HTTP 프로젝트 만들기

    actor 때 hello world 프로젝트 처럼 Getting Started 페이지에서 예제 프로젝트를 다운로드 받을 수 있다. 프로젝트 받아 압축을 푼 다음 sbt 명령을 실행하여 server를 실행하면, 약간의 준비 과정을 거친 후 sbt shell이 준비된다.

     

    서버가 준비되는 동안 build.sbt 파일의 내용을 보자

    lazy val akkaHttpVersion = "10.2.3"
    lazy val akkaVersion    = "2.6.12"
    
    lazy val root = (project in file(".")).
      settings(
        inThisBuild(List(
          organization    := "com.example",
          scalaVersion    := "2.13.4"
        )),
        name := "akka-http-quickstart-scala",
        libraryDependencies ++= Seq(
          "com.typesafe.akka" %% "akka-http"                % akkaHttpVersion,
          "com.typesafe.akka" %% "akka-http-spray-json"     % akkaHttpVersion,
          "com.typesafe.akka" %% "akka-actor-typed"         % akkaVersion,
          "com.typesafe.akka" %% "akka-stream"              % akkaVersion,
          "ch.qos.logback"    % "logback-classic"           % "1.2.3",
    
          "com.typesafe.akka" %% "akka-http-testkit"        % akkaHttpVersion % Test,
          "com.typesafe.akka" %% "akka-actor-testkit-typed" % akkaVersion     % Test,
          "org.scalatest"     %% "scalatest"                % "3.1.4"         % Test
        )
      )
    

    dependencies에 akka-http와 필요한 정보들이 들어가 있는데, 이 중에 가장 중요한 부분은 akka-http, akka-actor-typed, akka-stream다. 참고로, 모듈중에 akka-http-spray-json에 포함되어 있는 것을 볼 수 있는데, Scala에서 많이 사용하던 HTTP 프레임워크인 Spray가 AKKA-HTTP로 통합 흡수되면서 JSON 관련 부분이 남게된 것으로 보인다. 2015년 이후로 Spray 코드는 릴리즈 되지 않고 있다.

     

    빌드 후 시작

    sbt 쉘에서 reStart 명령을 입력하면 필요한 의존성 모듈을 가져오고 다음과 같은 메시지와 함께 http://127.0.0.1:8080/ 주소로 서버가 실행된다.

    [info] Non-compiled module 'compiler-bridge_2.13' for Scala 2.13.4. Compiling...
    [info]   Compilation completed in 7.451s.
    [info] Application root not yet started
    [info] Starting application root in the background ...
    root Starting com.example.QuickstartApp.main()
    [success] Total time: 24 s, completed 2021. 2. 5. 오후 7:24:24
    root[ERROR] SLF4J: A number (1) of logging calls during the initialization phase have been intercepted and are
    root[ERROR] SLF4J: now being replayed. These are subject to the filtering rules of the underlying logging system.
    root[ERROR] SLF4J: See also http://www.slf4j.org/codes.html#replay
    root [2021-02-05 19:24:25,581] [INFO] [akka.event.slf4j.Slf4jLogger] [HelloAkkaHttpServer-akka.actor.default-dispatcher-3] [] - Slf4jLogger started
    root [2021-02-05 19:24:26,268] [INFO] [akka.actor.typed.ActorSystem] [HelloAkkaHttpServer-akka.actor.default-dispatcher-5] [] - Server online at http://127.0.0.1:8080/

     

    요청에 대해 정상적으로 처리하는 것 같지는 않아 보이지만, 어쨌든 HTTP 서버가 구동된 것을 확인 할 수 있다.

     

    main을 찾아라

    지난번 Hello World 예제와 달리 이 프로젝트에는 App 트레잇을 사용하지 않는다. 그 대신 일반적인 스칼라 앱과 같이 main에서 시작하며 QuickstartApp.scala 파일에 다음과 같이 정의되어 있다.

    //#start-http-server
      def main(args: Array[String]): Unit = {
        //#server-bootstrapping
        val rootBehavior = Behaviors.setup[Nothing] { context =>
          val userRegistryActor = context.spawn(UserRegistry(), "UserRegistryActor")
          context.watch(userRegistryActor)
    
          val routes = new UserRoutes(userRegistryActor)(context.system)
          startHttpServer(routes.userRoutes)(context.system)
    
          Behaviors.empty
        }
        val system = ActorSystem[Nothing](rootBehavior, "HelloAkkaHttpServer")
        //#server-bootstrapping
      }

     

    ActorSystem을 생성할 때 필요한 액터가 없어서 Nothing 이라고 입력된 것을 볼 수 있다. Nothing은 상당히 특이한 존재다. 스칼라에서 Java 프로그래머를 위한 Scala 따라해보기에서 잠깐 나왔던 Unit 과도 좀 다르다. Scala에서는 Nothing을 이렇게 정의하고 있다.

    Nothing is - together with scala.Null - at the bottom of Scala's type hierarchy.
    Nothing is a subtype of every other type (including scala.Null); there exist no instances of this type. Although type Nothing is uninhabited, it is nevertheless useful in several ways. For instance, the Scala library defines a value scala.collection.immutable.Nil of type List[Nothing]. Because lists are covariant in Scala, this makes scala.collection.immutable.Nil an instance of List[T], for any element of type T.
    Another usage for Nothing is the return type for methods which never return normally. One example is method error in scala.sys, which always throws an exception.

    Nothing이 스칼라에 존재하는 모든 타입의 제일 하위에 있다고 하는 말이 무슨 말인지 이해가 가지 않았다. 모든 타입의 최하위라니 -_-? 스칼라의 최상단에는 Any라는 abstract 클래스가 있고, 이 클래스를 확장해 나가는 계층을 갖게 되는데, Nothing은 그 모든 것의 최하위가 된다니 이해가 잘 안갈 수 밖에. 이와 관련된 설명은 백발의 개발자님 블로그 글을 참고하는게 좋을 것 같다. 

     

    암튼, Nothing은 좀 신기한 타입인데, ActorSystem에 Actor가 아닌 Nothing으로 넘겨서 생성하고 있다. 사실 http 서버는 요청에 대한 처리를 해주는게 주 목적인지라 Behavior가 위주가 된다. 이 때 Behavior의 액션 타입도 Nothing으로 되어 있다.

     

    User Registry 액터

    root behavior에서 UserRegistry라는 액터를 child로 생성하고, watch 기능을 Actor 활동을 모니터링하며 문제가 생겨 종료될 때  WatchedActorTerminatedException 이 발생한다.

    val userRegistryActor = context.spawn(UserRegistry(), "UserRegistryActor")
    context.watch(userRegistryActor)

    UserRegistry 액터는 UserRegistry.scala 파일에 다음과 같이 구현되어 있다.

    //#user-case-classes
    final case class User(name: String, age: Int, countryOfResidence: String)
    final case class Users(users: immutable.Seq[User])
    //#user-case-classes
    
    object UserRegistry {
      // actor protocol
      sealed trait Command
      final case class GetUsers(replyTo: ActorRef[Users]) extends Command
      final case class CreateUser(user: User, replyTo: ActorRef[ActionPerformed]) extends Command
      final case class GetUser(name: String, replyTo: ActorRef[GetUserResponse]) extends Command
      final case class DeleteUser(name: String, replyTo: ActorRef[ActionPerformed]) extends Command
    
      final case class GetUserResponse(maybeUser: Option[User])
      final case class ActionPerformed(description: String)
    
      def apply(): Behavior[Command] = registry(Set.empty)
    
      private def registry(users: Set[User]): Behavior[Command] =
        Behaviors.receiveMessage {
          case GetUsers(replyTo) =>
            replyTo ! Users(users.toSeq)
            Behaviors.same
          case CreateUser(user, replyTo) =>
            replyTo ! ActionPerformed(s"User ${user.name} created.")
            registry(users + user)
          case GetUser(name, replyTo) =>
            replyTo ! GetUserResponse(users.find(_.name == name))
            Behaviors.same
          case DeleteUser(name, replyTo) =>
            replyTo ! ActionPerformed(s"User $name deleted.")
            registry(users.filterNot(_.name == name))
        }
    }
    //#user-registry-actor

     

     

    UserRegistry 액터가 생성되면서 빈 User Set을 가진 상태로 생성이되고, Command 트레잇을 확장하는 다음과 같은 케이스 클래스를 메시지로 전달받으면 그에 따라 기능을 수행한다.

    sealed trait Command
    final case class GetUsers(replyTo: ActorRef[Users]) extends Command
    final case class CreateUser(user: User, replyTo: ActorRef[ActionPerformed]) extends Command
    final case class GetUser(name: String, replyTo: ActorRef[GetUserResponse]) extends Command
    final case class DeleteUser(name: String, replyTo: ActorRef[ActionPerformed]) extends Command

     

    예를 들어, CreateUser를 메시지로 받으면 User 케이스 클래스로 사용자 정보가 전달되며, GetUsers를 메시지로 받았다면

    ActionPerformed 타입의 메시지를 replyTo 액션으로 전달한고, 현재 registry의 사용자 리스트인 users에 user를 추가하여 다시 registry를 재구성한다.

     

    그렇다면, 어디선가 User Registry 액터에서 이렇게 메시지를 전달하게 될텐데, 이 예제에서는 사용자의 HTTP 요청을 인식하고 이에 대해  액터에게 적절한 메시지를 보내는 일을 UserRoutes가 담당한다.

    val routes = new UserRoutes(userRegistryActor)(context.system)

     

    User Routes

    UserRoutes 클래스에는 HTTP 요청에 대응하는 method를 정의하게 된는데, 각 메소드는 액션의 ask를 이용하여 Future를 리턴하는 비동기 방식으로 동작한다.

    //#user-routes-class
    class UserRoutes(userRegistry: ActorRef[UserRegistry.Command])(implicit val system: ActorSystem[_]) {
    
      //#user-routes-class
      import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._
      import JsonFormats._
      //#import-json-formats
    
      // If ask takes more time than this to complete the request is failed
      private implicit val timeout = Timeout.create(system.settings.config.getDuration("my-app.routes.ask-timeout"))
    
      def getUsers(): Future[Users] =
        userRegistry.ask(GetUsers)
      def getUser(name: String): Future[GetUserResponse] =
        userRegistry.ask(GetUser(name, _))
      def createUser(user: User): Future[ActionPerformed] =
        userRegistry.ask(CreateUser(user, _))
      def deleteUser(name: String): Future[ActionPerformed] =
        userRegistry.ask(DeleteUser(name, _))
    
      //#all-routes
      //#users-get-post
      //#users-get-delete
      val userRoutes: Route =
        pathPrefix("users") {
          concat(
            //#users-get-delete
            pathEnd {
              concat(
                get {
                  complete(getUsers())
                },
                post {
                  entity(as[User]) { user =>
                    onSuccess(createUser(user)) { performed =>
                      complete((StatusCodes.Created, performed))
                    }
                  }
                })
            },
            //#users-get-delete
            //#users-get-post
            path(Segment) { name =>
              concat(
                get {
                  //#retrieve-user-info
                  rejectEmptyResponse {
                    onSuccess(getUser(name)) { response =>
                      complete(response.maybeUser)
                    }
                  }
                  //#retrieve-user-info
                },
                delete {
                  //#users-delete-logic
                  onSuccess(deleteUser(name)) { performed =>
                    complete((StatusCodes.OK, performed))
                  }
                  //#users-delete-logic
                })
            })
          //#users-get-delete
        }
      //#all-routes

     

    이후 HTTP 요청 자체에 대한 정의를 생성할 때 routing-DSL을 이용하며, 'akka.http.scaladsl.server.Directives' 클래스에 있는 메소드를 이용하여 HTTP 요청 경로를 인식하고 처리한다. pathPrefix, pathEnd 등은 path directive 이며 complete는 route directive 그리고 get과 post, delete 는 method directive , post 메소드를 통해 요청받은 데이터를 처리하는 entity 라는 것은 marshalling directive 로서 각종 다양하게 제공하는 directive는 사전 정의된 directives를 참고할 수 있다.

     

    pathPrefix("users")로 시작하고 pathEnd()로 끝난 경로가 get() 방식인 경우 getUsers()를 호출하여 얻은 Future를 compete로 완료 시킨다. 

     

    다시 요청해보기

     

    http://127.0.0.1:8080/users 를 브라우저에 입력하면 다음과 같이 출력되는 것을 알 수 있다.

     

     

    그럼, post로 사용자를 추가하려면 어떻게 해야 할까? User 케이스 클래스가 "name: String, age: Int, countryOfResidence: String" 과 같은 필드를 가지고 있으므로, 사용자 데이터는 다음과 같이 JSON으로 표현 될 수 있다.

    {
      "name": "Gil-Dong Hong",
      "age": 42,
      "countryOfResidence": "South Korea"
    }

     

    그리고, 이를 다음과 같은 curl 명령으로 호출해 볼 수 있다

    curl -H "Content-Type: application/json" -X POST -d '{"name":"Gil-Dong Hong","age":42,"countryOfResidence":"South Korea"}' http://127.0.0.1:8080/users

    그러면 다음과 같은 응답을 얻게 된다.

    {"description":"User Gil-Dong Hong created."}

     

    실제로 사용자 정보가 등록되었는지, 다시 확인해 보면 추가했던 사용자가 들어가 있는 것을 볼 수 있다.

     

     

    정리

    일단 HTTP API를 시작을 했지만 상당히 low level 로 처리되는 것을 볼 수 있다. 다음 글에는 예제 REST API를 어떻게 정의하고 구성하는지에 대해서 공부해 봐야겠다.

    댓글

Designed by Tistory.