Do you want to pick up from where you left of?
Take me there

Plug

Ruby를 잘 알고 계신다면 Plug는 여러 부분에서 Sinatra의 영향을 받은 Rack이라고 생각해도 좋습니다. Plug는 Web 애플리케이션 컴포넌트를 위한 명세와 Web 서버를 위한 어댑터를 제공합니다. Plug는 Elixir 코어의 일부는 아니지만, Elixir의 공식 프로젝트입니다.

PlugCowboy 라이브러리를 이용해 간단한 HTTP 서버를 밑바닥부터 만드는 것으로 시작해봅시다. Cowboy는 Erlang으로 된 간단한 웹서버이며 Plug는 해당 웹서버에 대한 커넥션 어댑터를 제공해줍니다.

기본적인 웹 애플리케이션을 준비하고 난 뒤, Plug의 라우터와 웹 애플리케이션 하나에 여러 plug를 사용하는 법을 배웁니다

시작하기 전에

이 튜토리얼에서는 Elixir 1.5 버전 이상과 mix가 설치되어 있다고 가정합니다.

슈퍼비전 트리가 있는 새 OTP 프로젝트를 만드는 것으로 시작해 봅시다.

mix new example --sup
cd example

슈퍼바이저를 이용해서 Cowboy2 웹서버를 시작하고 실행할 것이기 때문에 슈퍼비전 트리를 포함한 Elixir 앱이 필요합니다.

의존성

의존성은 mix를 사용하여 간단하게 추가할 수 있습니다. Plug를 Cowboy2의 어댑터 인터페이스로 사용하기 위해서는 PlugCowboy 패키지를 설치해야 합니다.

다음과 같이 mix.exs 파일에 추가해주세요.

def deps do
  [
    {:plug_cowboy, "~> 2.0"},
  ]
end

커맨드 라인에서 다음과 같은 mix 테스크를 실행해 새로운 의존성을 가져옵니다.

mix deps.get

Plug 명세

Plug를 만들기 위해서는 Plug의 명세를 알고 그것을 올바르게 따를 필요가 있습니다. 다행스럽게도 필요한 것은 단 두 개의 함수, init/1call/2 뿐입니다.

다음은 “Hello World!”를 돌려주는 간단한 Plug입니다.

defmodule Example.HelloWorldPlug do
  import Plug.Conn

  def init(options), do: options

  def call(conn, _opts) do
    conn
    |> put_resp_content_type("text/plain")
    |> send_resp(200, "Hello World!\n")
  end
end

파일을 lib/example/hello_world_plug.ex에 저장합니다.

init/1 함수는 Plug의 옵션을 초기화할 때 사용됩니다. 이는 슈퍼바이저 트리에 의해서 호출되는데, 이에 대해서는 다음 섹션에서 설명합니다. 일단 지금은 빈 리스트이므로 무시합시다.

init/1에 의해서 반환되는 값은 최종적으로 call/2의 두 번째 인자로 넘겨집니다.

call/2 함수는 Cowboy로부터 넘어온 모든 새로운 요청에 대해서 각각 호출됩니다. Cowboy는 %Plug.Conn{} 커넥션 구조체를 첫 번째 인자로 받으며, %Plug.Conn{} 커넥션 구조체를 반환해야 합니다.

프로젝트의 애플리케이션 모듈 설정하기

애플리케이션이 시작될 때 Cowboy 웹 서버를 시작하고 모니터링하도록 해야 합니다.

이는 Plug.Cowboy.child_spec/1 함수를 사용해서 할 수 있습니다.

이 함수는 다음 3가지 옵션을 받습니다.

lib/example/application.ex 파일은 start/2 함수에서 위 child spec을 구현해야 합니다.

defmodule Example.Application do
  use Application
  require Logger

  def start(_type, _args) do
    children = [
      {Plug.Cowboy, scheme: :http, plug: Example.HelloWorldPlug, options: [port: 8080]}
    ]
    opts = [strategy: :one_for_one, name: Example.Supervisor]

    Logger.info("Starting application...")

    Supervisor.start_link(children, opts)
  end
end

참고: 이 프로세스를 시작하는 슈퍼바이저가 호출할 것이기 때문에, child_spec 을 여기서 직접 호출할 필요는 없습니다. 그저 child spec을 만들려는 모듈과 그에 필요한 3개의 옵션으로 묶인 튜플을 넘깁니다.

이렇게 슈퍼비전 트리 아래에 Cowboy2 서버를 실행시킵니다. 지정한 포트 8080과 HTTP 스키마(HTTPS를 지정할 수도 있음)로 Cowboy를 실행하고, Example.HelloWorldPlug를 들어오는 모든 웹 요청을 담당하는 인터페이스로 지정합니다.

이제 앱을 실행하고 웹 요청을 보낼 준비가 되었습니다! OTP 앱을 --sup 플래그로 생성했으니, application 함수 덕분에 Example 애플리케이션이 자동으로 실행되는 점을 유의하세요.

mix.exs를 열면 아래와 같은 내용을 볼 수 있습니다.

def application do
  [
    extra_applications: [:logger],
    mod: {Example.Application, []}
  ]
end

이제 최소한의 Plug 기반 웹 서버를 사용해 볼 준비가 되었습니다. 커맨드 라인에서 다음을 실행하십시오.

mix run --no-halt

일단 모든 것이 컴파일이 끝나고[info] Started app가 나타나면, 웹 브라우저에서 http://127.0.0.1:8080을 여세요. 다음 내용이 보일 것입니다.

Hello World!

Plug.Router

웹 사이트 또는 REST API와 같은 대부분의 애플리케이션의 경우처럼 한 라우터가 서로 다른 경로들과 서로 다른 HTTP 메소드에 대한 요청을 각각 다른 처리기들로 라우팅 해야 할 것입니다. Plug는 이런 일을 할 수 있는 라우터를 제공합니다. 봐서 알 수 있듯이, Elixir에서는 Plug만으로 Sinatra가 하던 일을 할 수 때문에 Sinatra와 같은 프레임워크가 필요하지 않습니다.

시작해봅시다. lib/example/router.ex 파일을 만들어 다음 내용을 안에 넣으세요.

defmodule Example.Router do
  use Plug.Router

  plug :match
  plug :dispatch

  get "/" do
    send_resp(conn, 200, "Welcome")
  end

  match _ do
    send_resp(conn, 404, "Oops!")
  end
end

이것은 굉장히 최소한의 라우터이지만 코드는 꽤 자명합니다. use plug.Router를 통해 매크로를 넣어 :match:dispatch라는 내장 Plug를 설정했습니다. 루트에 대한 GET 요청을 처리하는 최상위 라우트와 다른 모든 요청과 매치해 404 메시지를 반환하는 두 번째 라우트가 정의되어 있습니다.

다시 lib/example/application.ex로 돌아가서, Example.Router를 웹 서버 슈퍼바이저 트리에 넣어 주어야 합니다. Example.HelloWorldPlug plug를 새로운 라우터로 교체해 봅시다.

def start(_type, _args) do
  children = [
    {Plug.Cowboy, scheme: :http, plug: Example.Router, options: [port: 8080]}
  ]
  opts = [strategy: :one_for_one, name: Example.Supervisor]

  Logger.info("Starting application...")

  Supervisor.start_link(children, opts)
end

서버를 재시작해 봅시다. 이전 서버가 실행중이라면 (Ctrl + C를 두 번 눌러) 중지하세요.

이제 췝브라우저에서 http://127.0.0.1:8080로 이동하세요. Welcome이 출력될 것 입니다. 그런 다음 http://127.0.0.1:8080/waldo 또는 다른 경로로 이동하십시오. 404 응답으로Oops!를 출력될 것 입니다.

다른 Plug 추가하기

일반적으로 웹 애플리케이션에서는 여러 개의 Plug를 사용하고, Plug에는 각자 담당하는 역할이 있습니다. 이를테면 라우팅을 처리하는 Plug, 들어오는 웹 요청이 유효한지 검증하는 Plug, 들어오는 요청을 인증하는 Plug 등이 있을 수 있습니다. 이 섹션에서는 들어오는 요청 속 매개변수가 유효한지를 검사하는 Plug를 정의하고, 애플리케이션이 라우터 Plug와 유효성 검사 Plug를 모두 사용하도록 해보겠습니다.

요청에 필요한 매개 변수가 있는지 확인하기 위한 Plug를 만들고자 합니다. Plug 안에서 유효성 검증을 구현하면 유효한 요청만 애플리케이션에 전달될 수 있습니다. Plug는 :paths:fields 옵션으로 초기화 되어야 합니다. 이것은 로직을 적용할 경로(paths)와 필요한 필드(fields)를 나타냅니다.

참고 : Plug는 모든 요청에 적용되므로 요청을 필터링해 일부에만 로직을 적용할 필요가 있습니다. 요청을 무시하려면 그냥 연결을 그대로 넘겨주면 됩니다.

완성된 Plug를 보고 어떻게 작동하는지 설명하겠습니다. lib/example/plug/verify_request.ex에 만들겠습니다.

defmodule Example.Plug.VerifyRequest do
  defmodule IncompleteRequestError do
    @moduledoc """
    필요한 필드가 발견되지 않은 경우에 발생시킬 에러.
    """

    defexception message: ""
  end

  def init(options), do: options

  def call(%Plug.Conn{request_path: path} = conn, opts) do
    if path in opts[:paths], do: verify_request!(conn.params, opts[:fields])
    conn
  end

  defp verify_request!(params, fields) do
    verified =
      params
      |> Map.keys()
      |> contains_fields?(fields)

    unless verified, do: raise(IncompleteRequestError)
  end

  defp contains_fields?(keys, fields), do: Enum.all?(fields, &(&1 in keys))
end

우선 주목해야 할 부분은 유효하지 않은 요청의 경우에 발생시킬 새로운 예외 IncompleteRequestError를 정의한다는 점입니다.

그다음은 call/2 함수로, 이는 검증 로직을 적용할지 말지를 결정하는 곳입니다. 요청 경로가 :paths 옵션에 포함되는 경우에만 verify_request!/2를 호출합니다.

마지막으로는 비공개 함수인 verify_request!/2로 필요한 :fields가 전부 존재하고 있는지를 확인합니다. 부족한 필드가 있는 경우에는 Incompleterequesterror를 발생시킵니다.

/upload에 대한 모든 요청에 "content""mimetype" 둘 다 있는지 확인하기 위해 Plug를 설정했습니다. 확인된 경우에만 라우트 코드가 실행됩니다.

그런 다음, 라우터에게 새 Plug를 알려줄 필요가 있습니다. lib/example/router.ex를 열어 다음과 같이 수정합니다.

defmodule Example.Router do
  use Plug.Router

  alias Example.Plug.VerifyRequest

  plug Plug.Parsers, parsers: [:urlencoded, :multipart]
  plug VerifyRequest, fields: ["content", "mimetype"], paths: ["/upload"]
  plug :match
  plug :dispatch

  get "/" do
    send_resp(conn, 200, "Welcome")
  end

  get "/upload" do
    send_resp(conn, 201, "Uploaded")
  end

  match _ do
    send_resp(conn, 404, "Oops!")
  end
end

이 코드에서는 애플리케이션에게 router안의 다른 코드 실행 전에 VerifyRequest plug로 요청을 전달하도록 하고 있습니다.

다음 함수를 통해서,

plug VerifyRequest, fields: ["content", "mimetype"], paths: ["/upload"]

자동으로 VerifyRequest.init(fields: ["content", "mimetype"], paths: ["/upload"])을 호출합니다. 이것은 차례로 VerifyRequest.call(conn, opts) 함수에 주어진 옵션을 전달합니다.

이제 plug가 동작하는 것을 보겠습니다. 로컬 서버를 강제 종료시킵니다. (‘ctrl + c’ 두 번 눌러서 종료가 된다는 점을 기억해주세요). 그다음, 서버를 재시작합니다. (mix run --no-halt). 이제 브라우저에서 http://127.0.0.1:8080/upload로 가보면 해당 페이지는 동작하지 않습니다. 브라우저에서 제공하는 디폴트 에러 페이지를 보게 될 것입니다.

이제 필요한 파라미터를 추가해서 http://127.0.0.1:8080/upload?content=thing1&mimetype=thing2로 가봅시다. ‘Uploaded’ 메시지를 보게 될 것입니다.

오류가 발생했을 때 아무런 페이지도 표시되지 않는 것은 좋지 않습니다. Plug를 사용하여 오류를 처리하는 방법은 나중에 살펴보겠습니다.

HTTP 포트 설정하기

Example 모듈과 어플리케이션을 정의했을 때, HTTP 포트는 모듈에 하드 코딩되어 있습니다. 설정 파일에 포트를 설정하여 포트를 구성 할 수 있도록 하는 것이 모범 사례로 생각됩니다.

config/config.exs 안에 애플리케이션 환경 변수 하나를 설정할 것입니다.

import Config

config :example, cowboy_port: 8080

그다음, lib/example/application.ex를 업데이트해서 port 설정값을 읽고 Cowboy로 그것을 보내도록 합니다. private 함수를 정의해서 이 책임을 감싸도록 하겠습니다.

defmodule Example.Application do
  use Application
  require Logger

  def start(_type, _args) do
    children = [
      {Plug.Cowboy, scheme: :http, plug: Example.Router, options: [port: cowboy_port()]}
    ]
    opts = [strategy: :one_for_one, name: Example.Supervisor]

    Logger.info("Starting application...")

    Supervisor.start_link(children, opts)
  end

  defp cowboy_port, do: Application.get_env(:example, :cowboy_port, 8080)
end

Application.get_env의 세 번째 인자는 설정 지시자가 정의되지 않은 경우의 기본값입니다.

이걸로 애플리케이션을 실행하기 위한 명령을 사용할 수 있습니다.

mix run --no-halt

Plug의 테스트

Plug의 테스트는 Plug.Test 덕분에 무척 간단합니다. 테스트를 간편하게 만들어주는 편리한 함수가 다수 포함되어 있습니다.

라우터의 테스트를 이해할 수 있는지 확인해보세요.

defmodule Example.RouterTest do
  use ExUnit.Case
  use Plug.Test

  alias Example.Router

  @content "<html><body>Hi!</body></html>"
  @mimetype "text/html"

  @opts Router.init([])

  test "returns welcome" do
    conn =
      :get
      |> conn("/", "")
      |> Router.call(@opts)

    assert conn.state == :sent
    assert conn.status == 200
  end

  test "returns uploaded" do
    conn =
      :get
      |> conn("/upload?content=#{@content}&mimetype=#{@mimetype}")
      |> Router.call(@opts)

    assert conn.state == :sent
    assert conn.status == 201
  end

  test "returns 404" do
    conn =
      :get
      |> conn("/missing", "")
      |> Router.call(@opts)

    assert conn.state == :sent
    assert conn.status == 404
  end
end

테스트는 다음 처럼 실행해 볼 수 있습니다.

mix test test/example/router_test.exs

Plug.ErrorHandler

앞에서 http://127.0.0.1:8080/upload경로에 필요한 파라미터 없이 가는 경우, 친숙한 에러 페이지나 합리적인 HTTP 상태코드를 받지 못한다고 했습니다. 그저 브라우저의 디폴트 에러 페이지와 500 Internal Server Error 메시지 뿐이었죠.

Plug.ErrorHandler를 통해 이것을 고쳐 봅시다.

먼저, lib/example/router.ex 를 열고 다음과 같이 적어봅시다.

defmodule Example.Router do
  use Plug.Router
  use Plug.ErrorHandler

  alias Example.Plug.VerifyRequest

  plug Plug.Parsers, parsers: [:urlencoded, :multipart]
  plug VerifyRequest, fields: ["content", "mimetype"], paths: ["/upload"]
  plug :match
  plug :dispatch

  get "/" do
    send_resp(conn, 200, "Welcome")
  end

  get "/upload" do
    send_resp(conn, 201, "Uploaded")
  end

  match _ do
    send_resp(conn, 404, "Oops!")
  end

  defp handle_errors(conn, %{kind: kind, reason: reason, stack: stack}) do
    IO.inspect(kind, label: :kind)
    IO.inspect(reason, label: :reason)
    IO.inspect(stack, label: :stack)
    send_resp(conn, conn.status, "Something went wrong")
  end
end

가장 위에 use Plug.ErrorHandler를 추가했다는 것을 알아차릴 것입니다.

이 플러그는 어떤 에러든 잡아서, handle_errors/2 함수를 찾아 호출해 그것을 처리하도록 합니다.

handle_errors/2conn을 첫 번째 파라미터, 3개 아이템(:kind, :reason, :stack)이 들어간 map을 2번째 파라미터로 받습니다.

무슨 일이 벌어지는지 살펴보기 위해 매우 간단한 handle_errors/2 함수를 정의했습니다. 앱을 중단하고 다시 시작해서 이것이 동작하는지 봅시다!

이제 http://127.0.0.1:8080/upload로 가보면, 친숙한 에러 메시지를 볼 수 있습니다.

터미널을 보면 다음과 같은 메시지를 보게 될 겁니다.

kind: :error
reason: %Example.Plug.VerifyRequest.IncompleteRequestError{message: ""}
stack: [
  {Example.Plug.VerifyRequest, :verify_request!, 2,
   [file: 'lib/example/plug/verify_request.ex', line: 23]},
  {Example.Plug.VerifyRequest, :call, 2,
   [file: 'lib/example/plug/verify_request.ex', line: 13]},
  {Example.Router, :plug_builder_call, 2,
   [file: 'lib/example/router.ex', line: 1]},
  {Example.Router, :call, 2, [file: 'lib/plug/error_handler.ex', line: 64]},
  {Plug.Cowboy.Handler, :init, 2,
   [file: 'lib/plug/cowboy/handler.ex', line: 12]},
  {:cowboy_handler, :execute, 2,
   [
     file: '/path/to/project/example/deps/cowboy/src/cowboy_handler.erl',
     line: 41
   ]},
  {:cowboy_stream_h, :execute, 3,
   [
     file: '/path/to/project/example/deps/cowboy/src/cowboy_stream_h.erl',
     line: 293
   ]},
  {:cowboy_stream_h, :request_process, 3,
   [
     file: '/path/to/project/example/deps/cowboy/src/cowboy_stream_h.erl',
     line: 271
   ]}
]

아직 500 Internal Server Error 에러 메시지를 보내고 있습니다. 예외 모듈에 :plug_status 필드를 추가하면 상태 코드를 변경할 수 있습니다. lib/example/plug/verify_request.ex 파일을 열고 다음을 추가하세요.

defmodule IncompleteRequestError do
  defexception message: "", plug_status: 400
end

서버를 재시작하고 새로고침하면, 이제 404 Bad Request 메시지를 볼 수 있게 됩니다.

이 plug를 사용하면 개발자가 문제를 해결하는 데 필요한 유용한 정보를 쉽게 파악할 수 있을 뿐만 아니라 최종 사용자에게 멋진 페이지를 제공하여 앱이 완전히 망가진 것처럼은 보이지 않도록 할 수 있습니다!

사용 가능한 Plug

많은 Plug들을 어려운 설정 없이 사용할 수 있습니다. 전체 목록은 여기의 Plug 문서에서 찾을 수 있습니다.

강의에 실수가 있거나 기여하고 싶은 부분이 있으신가요? GitHub에서 이 강의를 수정해보세요!