14 min read

Phoenix Contexts Guide의 이해 #1

Phoenix Contexts Guide의 이해 #1
Photo by The Australian National Maritime Museum / Unsplash

Elixir를 비롯해 Phoenix, LiveView에 대해 공부하기 위해 이런저런 블로그나 튜토리얼, 유튜브 등등 검색해서 나오는 것이면 이것저것 찾아봤는데 그 중에서 제일 좋은 자료는 hexdocs에 있는 가이드 문서들이었다. 개발자의 공식 문서가 제일 좋은 자료라는 금언은 진리였던 것이다. (...)

Phoenix 가이드 문서는 처음 Overview부터 차근차근 답파하기를 권한다. 나도 이렇게 읽어나가고 있는데 Contexts 가이드에서 막혔다. context의 개념이 생소했기도 하고, 가이드 문서 중에서 분량도 많고 여러가지 내용을 한 문서 안에서 설명하고 있어서 절반 정도 읽을 때 쯤이면 앞 부분의 내용이 어땠는지 기억도 잘 안나고 지금 읽는 내용의 이해도도 떨어져서 방향을 잃기 십상이었다.

어려운 내용을 이해하기 위한 제일 좋은 방법은 남에게 설명하는 것이라고 했다. 이 포스트의 나머지 내용은 나만의 새로운 정보를 담고 있지 않다. Contexts 가이드 문서를 이해하기 위해 본인 나름대로 정리하고 설명을 추가한 것일 뿐이다. 그 점에 유념하여 나머지 내용을 읽어주셨으면 한다 (그러니까 지금이라도 늦지 않았으니 별로다 싶으면 얼른 뒤로가기 해주시기를...).

미리 보는 결과물

Context 가이드를 끝까지 읽고 따라하면 아래와 같은 ERD를 가진 자료 구조를 구축하게 될 것이다. 아래 그림은 DBeaver에서 생성한 다이어그램이다.

data structure ERD (schema_migrations 제외)

여기서 불필요한 테이블과 필드들을 제외하고 코딩으로 추가되는 elixir 모듈들을 포함한 전체 그림은 다음과 같이 된다.

가이드 내용을 따라가기에 앞서 context 모듈에 대해 잠깐 짚고 넘어간다. 본 가이드 문서를 보다 집중해서 본 이유도 애초에 context란 개념을 잘 이해하지 못해서였기 때문이다.

Context의 이해

context는 application을 구성하는 '주요 모듈'이라고 이해하면 될 것이다. application의 다른 부분에 기능을 제공하는 인터페이스 모듈의 의미를 가진다. 가이드에서는 Logger 모듈을 예로 들어 설명했다.

Logger 문서에서는 다음 모듈에서의 함수에 대한 reference 문서를 제공한다. 즉 공식으로 오픈한 함수 인터페이스라는 뜻이다.

  • Logger
  • Logger.Backends.Console
  • Logger.Formatter
  • Logger.Translator

하지만 elixir 레포지토리 안에 logger 소스를 보면 다음 모듈들이 더 존재한다.

  • Logger.Utils
  • Logger.Backends.Config
  • Logger.Backends.Handler
  • Logger.Backends.Internal
  • Logger.Backends.Supervisor
  • Logger.Backends.Watcher

즉 이 모듈들은 같이 포함된 application의 다른 영역에 공개되지 않고 Logger 모듈에 숨겨진 내부 모듈이 되는 것이다. 하지만 elixir logger라고 하면 application 내부에서 Logger(와 추가 3개) 모듈이 logger 기능을 제공하는 경계 역할을 하는 것이다.

context라고 해서 특별한 모듈이 아니라 그냥 defmodule로 정의되는 '평범한' 모듈이다. lib/*_web/controllers/*_controller.ex 파일들이 controller이지만 역시 평범한 모듈이듯이 말이다.

Generating hello project

먼저 context 가이드에서 작성할 ecommerce 프로젝트의 골격을 만든다. Ecto를 이용한 database를 사용하는데, phoenix에서는 postgresql을 기본으로 사용한다. 여기서는 편의성을 위해 sqlite3로 바꾼다. postgresql을 그대로 사용한다면 --database 옵션은 필요 없다.

$ mix phx.new hello --database sqlite3

실행하면 마지막에 [Yn] 프롬프트가 나오는데 y를 선택하면 다음 명령도 자동으로 실행된다.

$ mix deps.get
$ mix assets.setup
$ mix deps.compile

그 후 다음까지 실행한다.

$ cd hello
$ mix ecto.create

context와 controller, view 추가하기

여기서는 mix phx.gen.html generator를 이용해서 context, controller와 HTML view 템플릿을 추가한다. context는 스키마를 포함하고, controller는 route로 연결된 URL에 대응한다. 웹 페이지를 추가하고 여기에 기능을 연결하는 전형적인 방법이라 할 수 있다.

아래 generator 명령은 카탈로그에 등록할 제품의 이름, 설명, 가격, 조회수 등을 제품의 정보로 정의한다.

$ mix phx.gen.html Catalog Product products \
  title:string description:string price:decimal views:integer
  • Catalog: 컨텍스트 모듈
  • Product: 스키마 모듈
  • products: DB 테이블 이름

이 명령으로 추가 또는 수정되는 주요 파일은 다음과 같다.

  • lib/hello/
    • catalog.ex, catalog/product.ex - 컨텍스트 및 스키마
  • lib/hello_web/controllers/
    • product_controller.ex - 컨트롤러
    • product_html.ex, product_html/*.html.heex - HTML 뷰 및 템플릿
  • priv/repo/migrations/*_create_products.exs - 마이그레이션 스크립트

다음으로 lib/hello_web/router.exProductController에 연결할 route를 추가한다.

   scope "/", HelloWeb do
     pipe_through :browser

     get "/", PageController, :index
+    resources "/products", ProductController
   end

그리고 migration을 실행한다.

$ mix ecto.migrate

그 결과 DB 파일에 생성된 실제 products 테이블은 다음과 같다. idinserted_at, updated_at 속성은 Ecto에서 자동으로 추가한 것이다.

'products' table

mix phx.gen.html에 함께 입력한 필드들은 Product 스키마 모듈과 DB의 products 테이블의 속성으로 정의된다. DB 쪽은 사용하는 DB에 따라 테이블의 내용이 달라질 수도 있다.

다음으로 mix phx.server를 실행하고, 브라우저에서 http://localhost:4000/products를 입력하면 다음과 같은 UI를 볼 수 있다.

Products list view

'New Product' 버튼을 누르면 mix phx.gen.html에서 정의한 필드 대로 입력 UI가 표시되는 것을 확인할 수 있다.

Edit view for new product
After adding first product – http://localhost:4000/products/1

http://localhost:4000/products/1로 바로 전에 등록한 Product 정보를 확인할 수 있다.

아래 그림은 지금까지 작업한 내용을 도식화한 것이다. mix phx.gen.html generator와 약간의 route 코드 추가, ecto.migrate 실행만으로 제품의 이름, 설명, 가격, 조회수를 입력해서 저장하고 조회하는 기능이 구현된 것이다. /products 또는 /products/:id HTTP 요청이 오면 ProductController를 중심으로 view는 ProductHTML 모듈에서, 데이터 처리는 Catalog 모듈에서 처리해주는 구조가 완성됐다.

code architecture for 'Products'

컨텍스트 함수 추가

앞에서 컨텍스트는 application의 다른 부분에 인터페이스를 제공하는 모듈이라고 했다. 그 인터페이스에 기능을 추가해보자.

추가하고자 하는 기능 자체는 간단하다. 이를테면 GET /products/1 HTTP request에 ProductControllershow 함수가 실행될 때마다 해당 제품의 view 카운트를 증가하는 것이다. 이 기능은 DB 테이블에서 제품 레코드의 view 카운트를 하나 증가한 값으로 UPDATE 하는 것으로 구현하고, Catalog 컨텍스트에서 inc_page_views/1이라는 함수로 추가한다.

lib/hello/catalog.ex 마지막에 다음 코드를 추가한다. 코드 중 Repo.update_all/1 함수가 sql 'UPDATE' query 하나로 view count를 하나 증가시키므로 race condition이 발생하지 않는다.

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

+  def inc_page_views(%Product{} = product) do
+    {1, [%Product{views: views}]} =
+      from(p in Product, where: p.id == ^product.id, select: [:views])
+      |> Repo.update_all(inc: [views: 1])
+
+    put_in(product.views, views)
+  end

lib/hello_web/controllers/product_controller.ex에서 show/2 함수에서 inc_page_views/1 함수를 호출하도록 아래와 같이 수정한다.

   def show(conn, %{"id" => id}) do
-    product = Catalog.get_product!(id)
+    product =
+      id
+      |> Catalog.get_product!()
+      |> Catalog.inc_page_views()
+
     render(conn, :show, product: product)
   end

그 후 http://localhost:4000/products/1 링크를 열면 아래와 같이 'Views' 옆의 값이 하나 증가했음을 볼 수 있다. 값은 페이지를 리프레시할 때마다 증가한다.

After refresh 'http://localhost:4000/products/1'

category 추가하기

여기서는 같은 컨텍스트 내에 여러 개의 스키마를 추가하고 서로 연관관계를 추가하는 방법을 다룬다.

CategoryProduct 사이에 n:n 연관관계를 추가하는데, 각 카테고리는 여러 개의 제품에 연결될 수 있고, 하나의 제품은 여러 개의 카테고리를 포함한다는 뜻이다.

mix phx.gen.context generator를 사용해서 Product와 같은 Catalog 컨텍스트에 Category 스키마를 추가할 것이다. phx.gen.html과 달리 별도의 HTML view를 만들지 않는다.

$ mix phx.gen.context Catalog Category categories title:string:unique

Product와 n:n 관계를 추가하기 위해 중간에 products와 categories 테이블에 대한 FK로 구성된 테이블을 만든다.

$ mix ecto.gen.migration create_product_categories

위 명령으로 priv/repo/migrations/*_create_product_categories.exs이 만들어지는데 이 파일의 내용을 편집한다.

 defmodule Hello.Repo.Migrations.CreateProductCategories do
   use Ecto.Migration

   def change do
+    create table(:product_categories, primary_key: false) do
+      add :product_id, references(:products, on_delete: :delete_all)
+      add :category_id, references(:categories, on_delete: :delete_all)
+    end
+
+    create index(:product_categories, [:product_id])
+    create unique_index(:product_categories, [:category_id, :product_id])
   end
 end

mix ecto.migrate 명령을 실행하면 아래 그림과 같이 categories 테이블과 product_categories 테이블이 생성되고 products와 함께 연관관계가 만들어진다.

category 추가에 따른 테이블 연관 관계

product_categories 테이블을 매개로 products와 categories 사이에 n:n 연관관계를 생성하기 위한 코드를 추가한다. lib/hello/catalog/product.ex 파일을 아래와 같이 수정한다.

+alias Hello.Catalog.Category

 schema "products" do
 ...
   field :views, :integer
   
+  many_to_many :categories, Category, join_through: "product_categories", on_replace: :delete
   
   timestamps()

이제 제품을 조회하면 같이 연결된 카테고리를 보여주어야 하고, 제품을 추가할 때에도 선택한 카테고리 정보를 함께 저장해야 한다. 이를 위해 Catalog 컨텍스트 모듈인 lib/hello/catalog.ex를 수정한다.

get_product!/1 함수는 Repo.preload/2 호출을 추가하여 연결된 카테고리 정보를 같이 반환하도록 한다. create_product/1update_product/2 함수는 change_product/2을 통해 Product와 Category를 포함한 changeset을 만들도록 한다. list_categories_by_id/1은 카테고리 ID의 리스트를 넘기면 각 카테고리의 이름을 반환한다.

+alias Hello.Catalog.Category
...

-  def get_product!(id), do: Repo.get!(Product, id)
+  def get_product!(id) do
+    Product |> Repo.get!(id) |> Repo.preload(:categories)
+  end
...

   def create_product(attrs \\ %{}) do
     %Product{}
-    |> Product.changeset(attrs)
+    |> change_product(attrs)
     |> Repo.insert()
   end
...

   def update_product(%Product{} = product, attrs) do
     product
-    |> Product.changeset(attrs)
+    |> change_product(attrs)
     |> Repo.update()
   end
...

   def change_product(%Product{} = product, attrs \\ %{}) do
-    Product.changeset(product, attrs)
+    categories = list_categories_by_id(attrs["category_ids"])
+
+    product
+    |> Repo.preload(:categories)
+    |> Product.changeset(attrs)
+    |> Ecto.Changeset.put_assoc(:categories, categories)
   end
+
+  def list_categories_by_id(nil), do: []
+  def list_categories_by_id(category_ids) do
+    Repo.all(from c in Category, where: c.id in ^category_ids)
+  end

막간: 초기 데이터 추가하기

더 진행하기 전에, 위에서 만든 categories 테이블에 초기 데이터(seed)를 만들어서 넣어보자. 카테고리를 입력하는 UI도 만들고 관리하면 좋긴 한데, 요구사항이나 설계 상 굳이 지금 단계에서 필요한게 아니다 싶어서 건너뛰지만 카테고리 정보 자체는 제품 데이터 입력 시 필요한 것이니까.

먼저 priv/repo/seeds.exs 파일을 열어 아래 내용을 추가한다. Catalog 컨텍스트의 create_category/1 함수로 4개 카테고리를 추가하는 코드이다.

for title <- ["Home Improvement", "Power Tools", "Gardening", "Books"] do
  {:ok, _} = Hello.Catalog.create_category(%{title: title})
end

seeds.exs 스크립트를 실행한다.

$ mix run priv/repo/seeds.exs

카테고리 관련 HTML 뷰 추가

이제 Category 스키마도 넣었고, Product를 추가할 때 카테고리를 연동하는 기능도 넣었으니 이를 HTML 뷰로 노출시켜보자. 먼저 lib/hello_web/controllers/product_html/product_form.html.heex 파일에 카테고리를 선택하는 UI 요소를 추가한다.

   <.input field={f[:views]} type="number" label="Views" />
+  <%= category_select f, @changeset %>
   <:actions>
     <.button>Save Product</.button>

위 코드 중에 category_select는 카테고리에 대한 multiselect UI를 화면에 표시하는 함수로, lib/hello_web/controllers/product_html.ex 코드에 다음 함수를 추가한다.

   use HelloWeb, :html
+  import Phoenix.HTML.Form
+
+  def category_select(f, changeset) do
+    existing_ids =
+      changeset
+      |> Ecto.Changeset.get_change(:categories, [])
+      |> Enum.map(& &1.data.id)
+
+    category_opts =
+      for cat <- Hello.Catalog.list_categories(),
+          do: [key: cat.title, value: cat.id, selected: cat.id in existing_ids]
+
+    multiple_select(f, :category_ids, category_opts)
+  end

   embed_templates "product_html/*"

마지막으로 등록이 완료된 후의 product 조회 페이지에서 카테고리 정보를 표시하는 UI도 추가한다. lib/hello_web/controllers/product_html/show.html.heex 파일을 다음과 같이 수정한다.

   <:item title="Views"><%= @product.views %></:item>
+  <:item title="Categories">
+    <%= for cat <- @product.categories do %>
+      <%= cat.title %>
+      <br/>
+    <% end %>
+  </:item>
 </.list>

이제 카테고리를 포함한 product를 등록해보자. http://localhost:4000/products에서 New Product 버튼을 눌러 나오는 입력 화면에서 하단에 카테고리 선택 UI가 추가된 것을 볼 수 있다 (mac에서는 Cmd 키와 같이 클릭하면 여러 개를 선택할 수 있다).

등록 후 화면에서도 마지막에 선택한 카테고리가 그대로 표시되는 것을 볼 수 있다.

중간 마무리

본 포스트에서는 전체 구조에 대한 설명과, 컨텍스트를 추가하고 동일 컨텍스트 안에서 여러 개의 스키마를 연결했고 그에 따른 UI 수정까지 해봤다. 여기까지가 원래 Phoenix context 가이드 문서의 절반 정도에 해당한다. 원래 전체 내용을 한 포스트 내에 다 설명하고자 했는데 예상보다 내용이 길어졌기도 하고, 앞으로 설명할 내용은 호흡도 길어서 지루해지기 쉽다. 그래서 여기서 한 번 끊어서 쉬고 다음 포스트에서 이어서 설명하고자 한다.

— END OF POST.