OSSCA 견문록 - 1편 HTTP 서버

notJoon·2023년 7월 10일
0

OSSCA

목록 보기
1/2

#1

어쩌다 이름도 긴 오픈소스 컨트리뷰터 아카데미(OSSCA)에 참여하게 되었는데 (참여 이유도 이름이 맘에 들어서 그냥 등록했었다. 타입 세이프 하다는데 이걸 어떻게 참음?), 참여하게 된 프로젝트는 ZIO를 가지고 뭔가하는 그런 프로젝트이다.

미리 밝히자면 스칼라는 이름만 들어봤고 ZIO는 더더욱 처음 들어봤다. 그래서 여기서 뭘 하고 이걸로는 또 뭘 하는건지 알려면 발대식에 참가했어야 했지만, 시원하게 첫날부터 지각을 한 덕분에 프로젝트에 대한 얘기는 못 나눴다(그래도 굿즈는 챙겼다. 파이콘을 제외하고 최근에 참여한 개발 관련 행사 중 가장 일상생활에서 쓰기 좋은 굿즈였다). 그래도 다음 날에 디스코드로 다음에 뭘 할건지에 대한 자료는 받아서 벼락치기로 스칼라 코드와 ZIO를 만져보게 되었고, 이건 그 벼락치기의 기록이다.

#2

ZIO 101

아무튼 첨부된 레포에는 간단한 프로젝트들이 있었다. 다음주 까지 코드를 읽고 서로 어땠는지 얘기를 나눈다거나 미완성된 기능을 추가(*옵션임) 해봐도 된다는 말과 함께였다. 그나마 익숙한건 HTTP 서버여서 이 코드 부터 읽기로 했다. 이전에 엘릭서나 OCaml을 만져봤기 때문에 읽는거 자체는 무난했다.

#3

HTTP 서버의 코드는 스칼라를 몰라도 HTTP를 만져봤음 “대충 이렇게 저렇게 되겠구나”라는 생각이 들 정도로 직관적이였다. 요청을 처리하는 app과 각 경로로 들어오는 요청을 처리하고 응답을 하는 구조였다.

object Main extends ZIOAppDefault {

  val app =
    Http.collectZIO[Request] {
      case Method.GET -> Root / "text" =>
        for {
          _ <- zio.Console.printLine("/text endpoint!")
          res <- ZIO.succeed(Response.text("Hello World!"))
        } yield res
      case Method.GET -> Root / "apple" =>
        for {
          _ <- zio.Console.printLine("/apple endpoint!")
          res <- ZIO.succeed(Response.text("APPLE!"))
        } yield res
      case Method.GET -> Root =>
        for {
          _ <- zio.Console.printLine("root endpoint!")
          url = URL.decode("http://localhost:13333/apple").toOption.get
          res <- ZClient.request(Request.get(url))
        } yield res
    }

  override val run =
    Server
      .serve(app.withDefaultErrorResponse)
      .provideLayer(Server.defaultWithPort(13333) ++ Client.default)
}

그래서 이 HTTP 서버에 미완성된 부분을 추가하기로 했는데, 나중에 알았지만 미완성된 부분은 이게 아니라 파일 전송 프로젝트 폴더였다. 하지만 이 코드도 충분히 개선할 부분은 있으니까 상관 없지 않을까 싶다.

#404

수정을 하겠다고는 했는데, 이건 꽤나 막연한 일이다. 그래서 어떤걸 추가하면 좋을까 하면서 이것저것 만져보다 404 페이지가 따로 없다는걸 알았다. 그래서 잘못된 경로를 요청했을때 404 페이지로 보내는걸 추가하기로 했다.

404 페이지 추가는 간단하다. app에서 각 요청과 링크를 패턴 매칭으로 처리했기 때문에, 저 링크들 이외의 링크는 전부 404로 보내면 그만이라 다음과 같은 와일드카드를 추가했다.

val app =
	Http.collectZIO[Request] {
	  // 생략
      case _ =>
		for {
          current <- getCurrentTime
          _ <- zio.Console.printLine(s"not found! [${req.method}] [$current]")
          res <- ZIO.succeed(Response.text("404 page not found!"))
    } yield res

그럼 이렇게 예쁘게 404 페이지가 나온다.

404 page

#5

이렇게 기능 하나를 추가했다. 하지만 페이지에 접속할 때 마다 터미널에 단순히 문자열만 출력되니까 뭔가 느낌이 안 살았다. 그 영화 같은거 보면 알록달록한 문자들이 촤라락 하면서 나오는 그런게 있어야 뭔가 하는 맛이 나듯이 터미널에 로그를 보강하기로 했다.

메시지에 색을 입히려면 ANSI 같은걸 쓰면 되는데, 이건 귀찮아서 요청 시간과 HTTP 메서드의 종류만 출력하도록 만들었다.

대충 메시지 [시간] [HTTP 메서드]

그러면 각 요청에 대한 메서드 종류와 시간을 로깅(logging)해야 하는데, 찾아보니까 ZIO에는 zio-logging이라는 라이브러리가 있어서 이걸 사용하기로 했다.

먼저 build.sbt에 종속성을 추가했고 시간과 날짜는 깔끔하게 자바의 API를 사용했다.

lazy val `http-server` = project
  .settings(sharedSettings)
  .settings(
    libraryDependencies ++= Seq(
      "dev.zio" %% "zio-http" % "3.0.0-RC2",
      "dev.zio" %% "zio-logging" % "2.1.13",   // +
    )
  )

이제 로그 메시지를 출력하도록 수정했다

import zio._
import zio.logging._
import zio.http.{Http, Request, Response, Status, _}
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter

object Main extends ZIOAppDefault {

  val app =
    Http.collectZIO[Request] {
      case req @ Method.GET -> Root =>
        for {
          current <- ZIO.effectTotal(ZonedDateTime.now().format(DateTimeFormatter.ISO_INSTANT))
          _ <- log.info(s"Time: $current - Method: ${req.method} - /")
          res <- ZIO.succeed(Response.text("Available endpoints: /text, /apple"))
        } yield res

      /*
          나머지도 비슷
      */

      case _ =>
        for {
          current <- ZIO.effectTotal(ZonedDateTime.now().format(DateTimeFormatter.ISO_INSTANT))
          _ <- log.info(s"Time: $current - Unknown method and endpoint")
          res <- ZIO.succeed(Response.text("404 page not found!"))
        } yield res
    }

  override val run =
    Server
      .serve(app.withDefaultErrorResponse)
      .provideCustomLayer(Server.defaultWithPort(13333) ++ Client.default)
}

여기서 req는 현재 요청에 대한 정보를 포함하는 Request 객체이다. 여기에 패턴 매칭 문법을 사용하면 HTTP 메소드와 URI를 분해하고, 이 정보를 사용하여 적절하게 응답을 처리할 수 있다.

예를 들어, 다음과 같은 요청은

case req @ Method.GET -> Root / "apple" => 

HTTP 메소드가 GET이고 URI가 /apple인 요청에 해당하는 경우를 나타낸다.

req @는 해당 요청 전체를 req 변수에 할당한다. 이러면 메소드나 경로 외에도 요청의 다른 부분 (예: 헤더, 쿼리 매개변수 등)에 접근할 수 있고, 이걸 이용해 다른 기능들을 추가하기도 수월해진다.

이걸로도 뭔가 있어보이는 로그 메시지를 출력하지만, 자세히 보면 current가 중복된다. 그래서 저 부분을 함수로 추출했다.

object Main extends ZIOAppDefault {

	def getCurrentTime: ZIO[Any, Nothing, String] =
    ZIO.succeed(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))

  val app =
    Http.collectZIO[Request] {
      case req @ Method.GET -> Root =>
        for {
          current <- getCurrentTime
          _ <- log.info(s"Time: $current - Method: ${req.method} - /")
          res <- ZIO.succeed(Response.text("Available endpoints: /text, /apple"))
        } yield res

      // ...
    }
}

이제 로그 메시지는 아래와 같이 나온다.

root endpoint! [GET] [2023-07-10 19:31:16]
/text endpoint! [GET] [2023-07-10 19:31:19]
/apple endpoint! [GET] [2023-07-10 19:31:22]

#6

어느 링크에 접속할 수 있는지 메인 페이지에 보여주면 좋을 것 같다는 생각이 들었다. 그리고 메인 페이지는 HTML을 이용해서 각 페이지로 접속할 수 있는 링크를 두면 이것저것 테스트 하기 좋겠다는 느낌도 들었다.

스칼라 코드에 HTML을 넣으려면 기존의

res <- ZIO.succeed(Response.text("text"))

에서 Response.textResponse.html로 변경하면 된다. 그래서 맨 첫 페이지, 그러니까 로컬호스트에 접속하면 가장 먼저 보이는 페이지를 보여주는 부분을 수정했다.

object Main extends ZIOAppDefault {

  val app =
    Http.collectZIO[Request] {
      case req @ Method.GET -> Root =>
        for {
          current <- getCurrentTime
          htmlForm =
            """
            <html>
            <head>
              <title>Front Page</title>
            </head>
            <body>
              <h1>Available links</h1>
              <ul>
                <li><a href="/text">/text</a></li>
                <li><a href="/apple">/apple</a></li>
                <li><a href="/form">/form</a></li>
              </ul>
            </body>
            """
          _ <- zio.Console.printLine(s"root endpoint! [${req.method}] [$current]")
          res <- ZIO.succeed(Response.html(htmlForm))
        } yield res

			// ...
		}
}

HTML은 그냥 접속 가능한 페이지를 리스트로 보여주는 간단한 동작을 한다.

front page

#7

뭐 이정도만 해도 충분하지만 딱 하나 아쉬웠던건 HTTP 메서드가 GET만 보이지, POST가 없었다는 것이다. 종 다양성이 보장되어야 생태계가 건강하듯이, 로그 메시지도 이것저것 보여야 질리지 않는 법이다.

그래서 어떤 메시지를 보내면 해당 메시지를 받았다고 처리하는 기능을 추가했다. 하지만 구현을 간단하게 하기 위해 /form을 통해 메시지를 전송하면, /submit 페이지에서 받았다는 응답을 보내는 식으로 동작하게 했다. 이 두 요소는 패턴 매칭 문에 추가하면 된다.

object Main extends ZIOAppDefault {
	val app =
    Http.collectZIO[Request] {
	  case req @ Method.POST -> Root / "submit" =>
        for {
          current <- getCurrentTime
          _ <- zio.Console.printLine(s"root endpoint! [${req.method}] [$current]")
          res <- ZIO.succeed(Response.text("Received POST request!"))
        } yield res
      case req @ Method.GET -> Root / "form" =>
        for {
          current <- getCurrentTime
          htmlForm =
            """
            |<form action="/submit" method="post">
            |  <label for="message">Message:</label><br>
            |  <input type="text" id="message" name="message"><br>
            |  <input type="submit" value="Submit">
            |</form>
            """.stripMargin
            res <- ZIO.succeed(Response.html(htmlForm))
        } yield res
	}
}

/form 페이지는 다음과 같이 랜더링 되고,

form page with submit button

메시지를 작성한뒤 전송하면 /submit 페이지로 이동해 메시지를 받았다는 페이지를 보여준다.

submit page, with "Received POST request!" message

그러면 로그 창엔 이렇게 표시된다.

root endpoint! [GET] [2023-07-10 19:38:52]
root endpoint! [POST] [2023-07-10 19:45:51]

#8

아무튼 요렇게 스칼라로 HTTP 서버 코드를 개선시켜봤다. 기왕 하는거 파일 전송 코드를 건들였음 더 좋았겠지만, 벼락치기로 달리고 있기 때문에 그런 고차원적인 프로토콜은 건들 수가 없었다. 하지만 수정을 하면서 스칼라와 ZIO에 대충 익숙해지긴 한거 같다. 그래서 결론은 “패턴 매칭은 최고다”이다.

전체 코드

import zio._
import zio.http.{Http, Request, Response, Status, _}
import zio.logging._
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter

object Main extends ZIOAppDefault {

  def getCurrentTime: ZIO[Any, Nothing, String] =
    ZIO.succeed(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))

  val app =
    Http.collectZIO[Request] {
      case req @ Method.GET -> Root =>
        for {
          current <- getCurrentTime
          htmlForm =
            """
            <html>
            <head>
              <title>Front Page</title>
            </head>
            <body>
              <h1>Available links</h1>
              <ul>
                <li><a href="/text">/text</a></li>
                <li><a href="/apple">/apple</a></li>
                <li><a href="/form">/form</a></li>
              </ul>
            </body>
            """
          _ <- zio.Console.printLine(s"root endpoint! [${req.method}] [$current]")
          res <- ZIO.succeed(Response.html(htmlForm))
        } yield res
      case req @ Method.GET -> Root / "text" =>
        for {
          current <- getCurrentTime
          _ <- zio.Console.printLine(s"/text endpoint! [${req.method}] [$current]")
          res <- ZIO.succeed(Response.text("Hello World!"))
        } yield res
      case req @ Method.GET -> Root / "apple" =>
        for {
          current <- getCurrentTime
          _ <- zio.Console.printLine(s"/apple endpoint! [${req.method}] [$current]")
          res <- ZIO.succeed(Response.text("APPLE!"))
        } yield res
      case req @ Method.GET -> Root =>
        for {
          current <- getCurrentTime
          _ <- zio.Console.printLine(s"root endpoint! [${req.method}] [$current]")
          url = URL.decode("http://localhost:13333/apple").toOption.get
          res <- ZClient.request(Request.get(url))
        } yield res
      case req @ Method.POST -> Root / "submit" =>
        for {
          current <- getCurrentTime
          _ <- zio.Console.printLine(s"root endpoint! [${req.method}] [$current]")
          res <- ZIO.succeed(Response.text("Received POST request!"))
        } yield res
      case req @ Method.GET -> Root / "form" =>
        for {
          current <- getCurrentTime
          htmlForm =
            """
            |<form action="/submit" method="post">
            |  <label for="message">Message:</label><br>
            |  <input type="text" id="message" name="message"><br>
            |  <input type="submit" value="Submit">
            |</form>
            """.stripMargin
            res <- ZIO.succeed(Response.html(htmlForm))
        } yield res
      case req @ _ =>
        for {
          current <- getCurrentTime
          _ <- zio.Console.printLine(s"not found! [${req.method}] [$current]")
          res <- ZIO.succeed(Response.text("404 page not found!"))
        } yield res
    }

  override val run =
    Server
      .serve(app.withDefaultErrorResponse)
      .provideLayer(Server.defaultWithPort(13333) ++ Client.default)
}
profile
Uncertified Quasi-polyglot pseudo dev

0개의 댓글