10 min read

Phoenix Controller 직접 추가하기 2nd

Phoenix Controller 직접 추가하기 2nd
Photo by JESHOOTS.COM / Unsplash

한달 전에 Phoenix Controller를 generator 없이 직접 작성하여 목록을 출력하는 것까지 해봤었다.

Phoenix Controller 직접 추가하기
Elixir Phoenix를 접한 이후 지금까지 여러가지 가이드 문서와 튜토리얼들을 탐독하고, phx.gen.html, phx.gen.context와 같은 generator들을 활용하여 phoenix 기반의 REST API server나 웹 어플리케이션 제작 방식에 익숙해지기 위해 노력해왔었다. 그러던 중 우연히 Elixir 개발 관련 개발 철학이나 best practice에 대한 뉴비의 질문에 대한 대답 중 하나가 내 생각을

위의 방법 대로 했을 때 목록과 상세정보 보기까지는 좋았지만, form을 이용한 추가와 편집을 하는 데에는 사용할 수 없었다. 한두 주 동안 시행착오를 겪고 나서 몇 가지 수정을 더했다.

Ecto 추가

Ecto 라이브러리를 최대한 배제하고 defstruct 구조체와 직접 작성한 validator를 사용하려고 했었지만, form을 이용하여 값을 주고 받는 데에는 불편한 점이 많았다. 특히 구조체나 map의 키가 모듈의 내부에서는 보통 atom 타입이지만 form과 주고받을 때는 string 타입이기 때문에 그 사이의 변환이 거추장스러워진다. 그래서 Ecto.Changeset까지는 적용해보기로 했다.

한달 전까지는 아래와 같이 defstruct를 사용했었다.

defmodule Product do
  defstruct [:id, :title, :description, :price, :views]
end

Ecto.Changeset에 기반하여 다음과 같이 수정했다.
lib/hello/product.ex 파일을 생성하여 추가한다. Hello.Product 모듈은 나중에 따로 추가할 것이다.

defmodule Product do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key false
  embedded_schema do
    field :id, :integer
    field :title, :string
    field :description, :string
    field :price, :decimal
    field :views, :integer
  end

  def changeset(product, attrs) do
    product
    |> cast(attrs, [:id, :title, :description, :price, :views])
  end
end

우선 Ecto.SchemaEcto.Changeset 관련 함수들을 사용할 수 있도록 import한다.

멤버 프로퍼티들은 defstruct 대신 embedded_schema로 정의했다. DBMS에 테이블로 저장하는 경우면 schema로 이름까지 추가하여 사용하겠지만, API call 또는 내부 메모리에 구조체로 저장할 것이라서 선택했다.

changeset/2 함수를 정의해서 html form과 값을 주고받을 수 있도록 하였다. Ecto validator도 필요하면 pipe 연산자 통해서 추가하면 된다.

:id를 테이블 인덱스로 Ecto에서 사용하는 대신 직접 정의하여 사용하도록 하기 위해 @primary_key false를 추가했다.

마지막으로 mix.exs에 Ecto에 대한 dependency를 추가한다.

   defp deps do
     [
+      {:phoenix_ecto, "~> 4.4"},
       {:phoenix, "~> 1.7.0"},

번외: MyRepo 추가하기

GenServer 기반 모듈을 별도로 작성하여 Repo 처럼 사용하였다. CRUD 하는 데이터는 메모리에서 관리한다. DBMS를 사용할 경우 Ecto.Repo를 사용하면 되고, 다른 API Server에 call을 해도 된다.

lib/hello/ 밑에 my_repo.ex를 작성한다. 처음부터 데이터 레코드 2개르 들고 있도록 했다.

defmodule Hello.MyRepo do
  use GenServer

  # api/helpers

  def start_link(state \\ []) do
    GenServer.start_link(__MODULE__, state, name: __MODULE__)
  end

  def get_list(), do: GenServer.call(__MODULE__, :get_list)
  def get_info(id), do: GenServer.call(__MODULE__, {:get_info, id})
  def create(product), do: GenServer.call(__MODULE__, {:create, product})
  def delete(id), do: GenServer.call(__MODULE__, {:delete, id})
  def update(id, product), do: GenServer.call(__MODULE__, {:update, id, product})

  # callback functions

  @product_list [
    %Product{ id: 1,
      title: "hello", description: "1st word of \"Hello World\".",
      price: 0, views: 0},
    %Product{ id: 2,
      title: "world", description: "2nd word of \"Hello World\".",
      price: 0, views: 0},
  ]

  def init(state), do: {:ok, state ++ @product_list}

  def handle_call(:get_list, _from, state), do: {:reply, state, state}

  def handle_call({:get_info, id}, _from, state) do
    id = to_int(id)
    product = Enum.find(state, fn p -> p.id == id end)
    product = if product == nil, do: %Product{}, else: product
    {:reply, product, state}
  end

  def handle_call({:create, product}, _from, state), do: {:reply, :ok, [product | state]}

  def handle_call({:delete, id}, _from, state) do
    id = to_int(id)
    new_state = Enum.reject(state, fn p -> p.id == id end)
    {:reply, :ok, new_state}
  end

  def handle_call({:update, id, product}, _from, state) do
    id = to_int(id)
    case Enum.find(state, fn p -> p.id == id end) do
      nil -> {:reply, :not_found, state}
      _ ->
        new_state = Enum.reject(state, fn p -> p.id == id end)
        {:reply, :ok, [product | new_state]}
    end
  end

  defp to_int(i) when is_binary(i), do: String.to_integer i
  defp to_int(i) when is_bitstring(i), do: String.to_integer i
  defp to_int(i) when is_integer(i), do: i
end

to_int/1 함수는 id를 무지성으로 정수로 바꾸기 위한(...) 함수이다.
프로젝트 실행 시에 MyRepo 모듈이 실행될 수 있도록 lib/hello/application.ex에 다음과 같이 수정을 추가한다.

     children = [
       HelloWeb.Telemetry,
       {Phoenix.PubSub, name: Hello.PubSub},
-      HelloWeb.Endpoint
+      HelloWeb.Endpoint,
+      Hello.MyRepo
     ]

Hello.Product 추가

위에서 lib/hello/product.ex에 Product 모듈을 추가했는데, 그 아래에 다음과 같이 Hello.Product 모듈을 추가한다.

defmodule Hello.Product do
  defp get_updated(product, updated) do
    product
    |> get_changeset(updated)
    |> apply_changes()
  end

  def get_changeset(), do: get_changeset(%Product{})

  def get_changeset(%Product{} = product, attrs \\ %{}) do
    Product.changeset(product, attrs)
  end
end

get_changeset 함수는 Product 모듈의 changeset/2 함수를 호출하여 체인지셋을 만들어 반환한다.
get_updated/2 함수는 원 데이터에 변경 사항을 반영한 %Product{} 구조체를 반환한다.

같은 모듈에 계속해서 MyRepo와 인터페이싱하는 함수들을 추가한다.

  alias Hello.MyRepo

  def get_list(), do: MyRepo.get_list

  def get_info(id), do: MyRepo.get_info(id)

  def create(product_params) do
    product = %Product{} |> get_updated(product_params)
    :ok = MyRepo.create(product)
    {:ok, MyRepo.get_info(product.id)}
  end

  def update(id, product_params) do
    product = get_info(id) |> get_updated(product_params)
    :ok = MyRepo.update(id, product)
    {:ok, MyRepo.get_info(product.id)}
  end

  def delete(id), do: MyRepo.delete id
  # => :ok

이 함수들은 HelloWeb.ProductController 모듈에서 호출한다.

HelloWeb.ProductController 수정

이제 내부 모듈의 로직에서 처리하는 값들을 웹 인터페이스를 통해 표출하는 부분을 수정한다. 지난 번에는 index/2 함수만 작성했었다. 이를 다음과 같이 수정한다.

  def index(conn, _params) do
    render(conn, :index, products: Hello.Product.get_list)
  end

저장하고 바로 mix phx.server로 실행한 다음 웹 브라우저에서 http://localhost:4000/products에 접속한다. 바로 아이템 두 개를 볼 수 있다. 지금까지 작성한 바와 같이 HelloWeb.ProductController에서 Hello.Product를 거쳐 MyRepo에서 들고 있는 레코드들을 반환한 것이다.

이제 index 외에 나머지들도 수정한다. router.ex를 아래와 같이 수정한다.

     get "/", PageController, :home
-    resources "/products", ProductController, only: [:index]
+    resources "/products", ProductController

그리고 index/2 외의 나머지 함수들을 추가한다.

  def new(conn, _params) do
    changeset = Hello.Product.get_changeset()
    render(conn, :new, changeset: changeset)
  end

  def create(conn, %{"product" => product_params}) do
    {:ok, product} = Hello.Product.create(product_params)
    conn
    |> put_flash(:info, "Product created successfully.")
    |> redirect(to: ~p"/products/#{product.id}")
  end

  def show(conn, %{"id" => id}) do
    product = Hello.Product.get_info(id)
    render(conn, :show, product: product)
  end

  def edit(conn, %{"id" => id}) do
    product = Hello.Product.get_info(id)
    changeset = Hello.Product.get_changeset(product)
    render(conn, :edit, product: product, changeset: changeset)
  end

  def update(conn, %{"id" => id, "product" => product_params}) do
     {:ok, product} = Hello.Product.update(id, product_params)
    conn
    |> put_flash(:info, "Product updated successfully.")
    |> redirect(to: ~p"/products/#{product.id}")
  end

  def delete(conn, %{"id" => id}) do
    :ok = Hello.Product.delete(id)
    conn
    |> put_flash(:info, "Product deleted successfully.")
    |> redirect(to: ~p"/products")
  end

각 함수들의 몸체를 보면 알 수 있듯이 각각 Hello.Product 모듈의 함수들을 호출하여 받은 결과를 처리한다. 여기서 HelloWeb.ProductControllerHello.Product 모듈은 귀찮고 간단하게 보여주는데 중점을 두었기 때문에, 보완의 여지가 많다. :ok가 아닌 다른 값이 반환됐을 때의 동작이라든가, id 중복 체크 기능 등은 필수로 들어가야 할 기능들이다.

.heex 파일 작성

lib/hello_web/controllers/product_html/ 밑에 위치하는 .heex 파일들을 작성한다. 각 파일들은 다음의 경우에 호출된다.

  • index.html.heex - GET /products에 의한 product list 출력
  • show.html.heex - GET /products/:id로 특정 product의 정보 표시
  • new.html.heex, product_new_form.html.heex - GET /products/new에 의해 새로운 product를 작성하기 위한 form을 출력한다. save 버튼을 누르면 POST /products에 의해 새 product 정보를 등록한다.
  • edit.html.heex, product_edit_form.html.heex - GET /products/:id/edit:id product의 내용을 수정하는 form을 출력한다. save 버튼을 누르면 PUT /products/:id:id product 정보를 갱신한다.

여기서는 이 중 update에 관련한 edit.html.heex 및 product_edit_form.html.heex 파일 내용을 리스트한다.

<%!-- edit.html.heex --%>
<.header>
  Product "<%= @product.title %>"
  <:subtitle>Edit product here.</:subtitle>
</.header>

<.product_edit_form changeset={@changeset} action={~p"/products/#{@product.id}"} />

<.back navigate={~p"/products"}>Back</.back>

@product@changeset 바인딩은 HelloWeb.ProductController의 edit/2 함수의 마지막 render 함수에 의해 전달된다.

    render(conn, :edit, product: product, changeset: changeset)

마지막의 .product_edit_form에 따라 product_edit_form.html.heex 파일도 같이 엮이게 된다. 이 때 @changeset@action 바인딩을 같이 넘기게 된다.

<.simple_form :let={f} for={@changeset} action={@action} method="PUT">

  <.input field={f[:id]} type="number" label="ID" readonly />
  <.input field={f[:title]} type="text" label="Title" />
  <.input field={f[:description]} type="text" label="Description" />
  <.input field={f[:price]} type="number" label="Price" />
  <.input field={f[:views]} type="number" label="Views" />

  <:actions>
    <.button>Save</.button>
  </:actions>
</.simple_form>

method="PUT" 코드는, simple_form의 기본 method가 POST이기 때문에 강제로 PUT으로 바꾸어 사용토록 하기 위해 추가한 것이다.

마치며

초기의 '모든 코드를 직접 작성'하겠다는 취지에서 한 발 물러나서 Ecto 모듈을 받아들였지민 덕분에 Ecto에 대한 이해도도 높아지고 (Ecto 학습을 전제로 해야하지만) 코드를 간결하게 만들 수 있었다. Phoenix를 활용한 개발에 대해서는 이것저것 만져볼 수록 앞으로 해야 할 것이 더 많이 보이지만 그를 위한 기본기를 닦을 수 있는 좋은 경험이 되었다고 본다.

— END OF POST.