8 min read

Phoenix Contexts Guide의 이해 #3

Phoenix Contexts Guide의 이해 #3
Photo by davide ragusa / Unsplash
Phoenix Contexts Guide의 이해 #1
Elixir를 비롯해 Phoenix, LiveView에 대해 공부하기 위해 이런저런 블로그나 튜토리얼, 유튜브 등등 검색해서 나오는 것이면 이것저것 찾아봤는데 그 중에서 제일 좋은 자료는 hexdocs에 있는 가이드 문서들이었다. 개발자의 공식 문서가 제일 좋은 자료라는 금언은 진리였던 것이다. (...) Phoenix 가이드 문서는 처음 Overview부터 차근차근 답파하기를 권한다. 나도 이렇게 읽어나가고 있는데 Contexts 가이드에서 막혔다.
Phoenix Contexts Guide의 이해 #2
Phoenix Contexts Guide의 이해 #1Elixir를 비롯해 Phoenix, LiveView에 대해 공부하기 위해 이런저런 블로그나 튜토리얼, 유튜브 등등 검색해서 나오는 것이면 이것저것 찾아봤는데 그 중에서 제일 좋은 자료는 hexdocs에 있는 가이드 문서들이었다. 개발자의 공식 문서가 제일 좋은 자료라는 금언은 진리였던 것이다. (...) Phoenix 가이드 문서는 처음 Overview부터 차근차근 답파하기를 권한다. 나도 이렇게 읽어나가고

context 가이드 정리의 마지막이다. 여기서는 order 관련한 코드들을 추가한다. 다음 그림은 이번 포스트에서 생성할 테이블과 스키마, 컨텍스트 및 컨트롤러 모듈들을 나타낸다.

스키마 및 테이블 생성

generator로 주문 관련 코드를 생성한다. LineItemOrder 스키마 모두 Orders 컨텍스트를 사용할 것이므로 LineItemmix phx.gen.context generator를 사용한다.

$ mix phx.gen.html Orders Order orders user_uuid:uuid total_price:decimal
$ mix phx.gen.context Orders LineItem order_line_items \
  price:decimal \
  quantity:integer \
  order_id:references:orders \
  product_id:references:products

마이그레이션을 실행하기 전에 DB에 적용할 스키마 모듈을 일부 수정한다. lib/hello/orders/order.ex 파일을 열어 다음과 같이 has_many 코드를 추가한다. Order와 LineItem은 1:n의 관계를 가지게 되고, Order와 Product는 LineItem을 통해서 1:n 관계를 가진다.

     field :user_uuid, Ecto.UUID
+    has_many :line_items, Hello.Orders.LineItem
+    has_many :products, through: [:line_items, :product]

     timestamps()

그리고 lib/hello/orders/line_item.ex 파일을 열어 다음과 같이 수정한다. LineItem 입장에서는 Order와 belongs_to 관계가 맞지만, Product하고도 belongs_to 관계를 명시해야 하는 지는 고민이 필요한 부분이다.

     field :quantity, :integer
-    field :order_id, :id
-    field :product_id, :id
+
+    belongs_to :order, Hello.Orders.Order
+    belongs_to :product, Hello.Catalog.Product

     timestamps()

OrderController 컨트롤러도 같이 만들어졌을테니 여기로 연결하는 route를 lib/hello_web/router.ex에 추가한다.

     put "/cart", CartController, :update
+    resources "/orders", OrderController, only: [:create, :show]
   end

마이그레이션을 진행한다.

$ mix ecto.migrate

context 모듈의 수정

lib/hello/orders.ex 파일의 get_order!/1을 사용자 ID도 함께 받도록 인수를 확장하고 상품 정보도 함께 로드할 수 있도록 수정한다.

-  def get_order!(id), do: Repo.get!(Order, id)
+  def get_order!(user_uuid, id) do
+    Order
+    |> Repo.get_by!(id: id, user_uuid: user_uuid)
+    |> Repo.preload([line_items: [:product]])
+  end

같은 모듈에서 complete_order/1 함수를 새로 추가한다.

  alias Hello.ShoppingCart
  alias Hello.Orders.LineItem

  def complete_order(%ShoppingCart.Cart{} = cart) do
    line_items =
      Enum.map(cart.items, fn item ->
        %{product_id: item.product_id, price: item.product.price, quantity: item.quantity}
      end)

    order =
      Ecto.Changeset.change(%Order{},
        user_uuid: cart.user_uuid,
        total_price: ShoppingCart.total_cart_price(cart),
        line_items: line_items
      )

    Ecto.Multi.new()
    |> Ecto.Multi.insert(:order, order)
    |> Ecto.Multi.run(:prune_cart, fn _repo, _changes ->
      ShoppingCart.prune_cart_items(cart)
    end)
    |> Repo.transaction()
    |> case do
      {:ok, %{order: order}} -> {:ok, order}
      {:error, name, value, _changes_so_far} -> {:error, {name, value}}
    end
  end

controller 모듈의 수정

이제 이 수정한 함수들을 OrderController 모듈에 적용한다. lib/hello_web/controllers/order_controller.ex 파일에서 먼저 create/2 함수를 다음과 같이 수정한다.

-  def create(conn, %{"order" => order_params}) do
-    case Orders.create_order(order_params) do
+  def create(conn, _) do
+    case Orders.complete_order(conn.assigns.cart) do
       {:ok, order} ->
         conn
         |> put_flash(:info, "Order created successfully.")
         |> redirect(to: ~p"/orders/#{order}")

+      {:error, _reason} ->
+        conn
+        |> put_flash(:error, "There was an error processing your order")
+        |> redirect(to: ~p"/cart")
-      {:error, %Ecto.Changeset{} = changeset} ->
-        render(conn, :new, changeset: changeset)
     end
   end

show/2 함수에서는 get_order!/1 함수를 사용하고 있으니 이를 수정한다.

   def show(conn, %{"id" => id}) do
-    order = Orders.get_order!(id)
+    order = Orders.get_order!(conn.assigns.current_uuid, id)
     render(conn, :show, order: order)
   end

이외에도 edit/2, update/2, delete/2에서도 get_order!/1 함수를 사용하고 있지만, 위에서 추가한 route 코드에서도 볼 수 있듯이 여기 예제에서는 사용하지 않는다. 그대로 둬도 되고 찜찜하면 삭제한다.

또한 complete_order/1에서 호출하는 함수 중 ShoppingCart.prune_cart_items/1도 새로 추가된 함수이니 lib/hello/shopping_cart.ex 파일에 추가한다.

  def prune_cart_items(%Cart{} = cart) do
    {_, _} = Repo.delete_all(from(i in CartItem, where: i.cart_id == ^cart.id))
    {:ok, reload_cart(cart)}
  end

HTML view 수정

lib/hello_web/controllers/order_html.ex 파일에서 볼 수 있는 것처럼, lib/hello_web/controllers/order_html/ 밑에 .heex 템플릿 파일들을 읽어들여 뷰로 사용한다.

defmodule HelloWeb.OrderHTML do
  use HelloWeb, :html

  embed_templates "order_html/*"
  ...
end

lib/hello_web/controllers/order_html/show.html.heex 파일을 아래와 같이 작성한다. 가이드 문서에서와 달리 기존에 생성된 .heex 파일을 기반으로 수정한 것인데, 브라우저 상에서도 보기 좋다 (...)

<.header>
  Thank you for your order!
  <:subtitle>User ID: <%= @order.user_uuid %></:subtitle>
</.header>

<.table id="line_items" rows={@order.line_items}>
  <:col :let={item} label="Title"><%= item.product.title %></:col>
  <:col :let={item} label="Quantity"><%= item.quantity %></:col>
  <:col :let={item} label="Price"><%= HelloWeb.CartHTML.currency_to_str(item.price) %></:col>
</.table>

<strong>Total Price:</strong>
<%= HelloWeb.CartHTML.currency_to_str(@order.total_price) %>

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

그리고 lib/hello_web/controllers/cart_html/show.html.heex 파일에 위 UI를 호출할 코드를 추가한다.

+  <.link href={~p"/orders"} method="post">complete order</.link>
 <% end %>

http://localhost:4000/products UI에서 상품을 카트에 담으면 아래와 같은 UI가 나타날 것이다. 수량도 적당히 업데이트해본다.

여기서 'Complete Order'를 누르면 아래와 같이 `Thank you for your odrder!' 화면이 나오는 것을 볼 수 있다.

이로써 가이드에 있는 내용은 대부분 다루었다.

마무리

세 부분에 걸쳐 phoenix의 contexts 가이드를 내 나름대로 이해한 내용을 설명했다. 사실 아직 나는 급한 경사도의 학습 곡선 중에서 거의 바닥에 있는 편이다.

모든 내용을 이해한건 아니다. many-to-many나 one-to-many 연관관계를 만들기 위해 수정한 부분도 좀더 이해가 필요한 상황이고, 언제 mix phnx.gen.html을 사용해야 할 지, 대신 mix phx.gen.context를 사용해야 할 지 판단하는 방법도 파악해야 한다. .heex 템플릿 작성 방법도 언젠간 숙지해야 한다. 지금까지 포스팅한 내용 중에 갱신해야 할 내용에 대해서는 추가 포스팅을 하도록 하겠다.

— END OF POST.