2. Basic

앞으로
mix phx.new --umbrella --database=postgres 기준으로 작성하겠다.

umbrella는 관리적인면이 포함된 모노레포와 비슷하다고 보면 된다.

umbrella 프로젝트 내의 config/config.exs 기본적으로 컴파일 시점에 평가된다.
config/runtime.exs는 컴파일단에서 런타임 시점에 정의되거나 실행할 것들을 정의할 수 있다.

확장자가 .exs면 호출시 매번 해석하고주로 최상단, 테스트에서 사용되어짐.

umbrella 프로젝트의 mix.exs를 보면

  def project do
    [
      apps_path: "apps",
      version: "0.1.0",
      start_permanent: Mix.env() == :prod,
      deps: deps(),
      aliases: aliases()
    ]
  end

처럼 되어 있는데

여기서의 deps()에 해당하는 의존성 관리를 umbrella에서 할 수 있게 해준다.

PROJ_umbrella 폴더가 만들어지고, 해당 내에
apps/APP_web/controller/page_controller.ex 를 보면

defmodule DutchpayWeb.PageController do
  use DutchpayWeb, :controller

  def home(conn, _params) do
    # The home page is often custom made,
    # so skip the default app layout.
    render(conn, :home, layout: false)
  end
end

처럼 되어 있는 것을 볼 수 있는데, 이는 dead view라 한다.
liveview가 아닌 고정된 페이지를 보여주고 있다 .

router.ex에서 해당 경로를 controller로 연결함을 볼 수 있다.

defmodule DutchpayWeb.Router do
  use DutchpayWeb, :router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {DutchpayWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
  end

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/", DutchpayWeb do
    pipe_through :browser

    get "/", PageController, :home
  end

  # Other scopes may use custom stacks.
  # scope "/api", DutchpayWeb do
  #   pipe_through :api
  # end

  # Enable LiveDashboard and Swoosh mailbox preview in development
  if Application.compile_env(:dutchpay_web, :dev_routes) do
    # If you want to use the LiveDashboard in production, you should put
    # it behind authentication and allow only admins to access it.
    # If your application does not have an admins-only section yet,
    # you can use Plug.BasicAuth to set up some basic authentication
    # as long as you are also using SSL (which you should anyway).
    import Phoenix.LiveDashboard.Router

    scope "/dev" do
      pipe_through :browser

      live_dashboard "/dashboard", metrics: DutchpayWeb.Telemetry
      forward "/mailbox", Plug.Swoosh.MailboxPreview
    end
  end
end

mix phx.routes를 통해서 링크가 어떻게 연결되었는지 볼 수 있다.

CleanShot 2025-01-15 at 17.14.58@2x.jpg

*_web 프로젝트로 가서
mix phx.gen.schema Chat.Room rooms name:text topic:text 처럼 입력해서 ecto schema를 생성할 수 있다.
위의 명령어 내용은 다음과 같다.

Context Chat, Chat.Room
Table rooms
Columns name, topic

저기서 Chat.Room은 각각의 Chat - Room context이고 rooms는 테이블, name, topic은 column이다.

해당 테이블을 수정시에는 mix ecto.* 관련 명령어를 사용해서 가능하다.
mix#Phoenix

iex -S mix 콘솔에서 확인해볼 수 있다.

iex> %Dutchpay.Chat.Room{name: "the-shire", topic: "Bilbo's birthday party"}

%Dutchpay.Chat.Room{
  __meta__: #Ecto.Schema.Metadata<:built, "rooms">,
  id: nil,
  name: "the-shire",
  topic: "Bilbo's birthday party",
  inserted_at: nil,
  updated_at: nil
}

Repo.insert, Repo.get을 통해서
iex 상에서 데이터를 넣은 후 확인할 수도 있다

iex> Dutchpay.Repo.insert!(%Dutchpay.Chat.Room{name: "room1", topic: "birthday party"})

[debug] QUERY OK source="rooms" db=5.3ms queue=1.3ms idle=1045.2ms
INSERT INTO "rooms" ("name","topic","inserted_at","updated_at") VALUES ($1,$2,$3,$4) RETURNING "id" ["my room party", "birthday party", ~N[2025-01-19 09:39:26], ~N[2025-01-19 09:39:26]]
↳ :elixir.eval_external_handler/3, at: src/elixir.erl:386
%Dutchpay.Chat.Room{
  __meta__: #Ecto.Schema.Metadata<:loaded, "rooms">,
  id: 1,
  name: "room1",
  topic: "birthday party",
  inserted_at: ~N[2025-01-19 09:39:26],
  updated_at: ~N[2025-01-19 09:39:26]
}


iex> Dutchpay.Repo.get(Dutchpay.Chat.Room, 1)
[debug] QUERY OK source="rooms" db=1.2ms queue=2.0ms idle=1091.4ms
SELECT r0."id", r0."name", r0."topic", r0."inserted_at", r0."updated_at" FROM "rooms" AS r0 WHERE (r0."id" = $1) [1]
↳ :elixir.eval_external_handler/3, at: src/elixir.erl:386
%Dutchpay.Chat.Room{
  __meta__: #Ecto.Schema.Metadata<:loaded, "rooms">,
  id: 1,
  name: "room1",
  topic: "birthday party",
  inserted_at: ~N[2025-01-19 09:39:26],
  updated_at: ~N[2025-01-19 09:39:26]
}

get_by로 검색할 수도 있다.


iex> Dutchpay.Repo.get_by(Dutchpay.Chat.Room, name: "room1")
[debug] QUERY OK source="rooms" db=1.1ms queue=2.1ms idle=1741.0ms
SELECT r0."id", r0."name", r0."topic", r0."inserted_at", r0."updated_at" FROM "rooms" AS r0 WHERE (r0."name" = $1) ["room1"]
↳ :elixir.eval_external_handler/3, at: src/elixir.erl:386
%Dutchpay.Chat.Room{
  __meta__: #Ecto.Schema.Metadata<:loaded, "rooms">,
  id: 1,
  name: "room1",
  topic: "birthday party",
  inserted_at: ~N[2025-01-19 09:39:26],
  updated_at: ~N[2025-01-19 09:39:26]
}

Repo의 인자들의 마지막은 키워드 인자로써, 스킵시 디폴트 값은 []이다.

Repo.all로 전체를 확인할 수도 있다.

liveView에서 mount, render이 2번씩 호출되는 것을 유의해야한다.
가능한 빠른 렌더링을 위해 먼저 렌더링 되고, 웹 소켓이 연결 되면 다시 렌더링하기 때문에 만약 연결된 이후나 연결 이전의 단 1번만 실행되어야 하는 로직이 있다면 connected?를 호출하여 분기처리해야 한다.

def mount(_params, _session, socket){
	if connected?(socket) do 
		IO.puts("mounting (connected)") 
	else 
		IO.puts("mounting (not connected)") 
	end
}

코드가 변경된 후 iex에서 모듈을 다시 반영시키려면 아래처럼 하면 된다.

iex> r(Dutchpay.Chat)

스키마 제약 사항 추가

dutchpay/chat/room/schema.ex

defmodule Dutchpay.Chat.Room.Schema do
  @moduledoc false
  use Ecto.Schema

  import Ecto.Changeset

  schema "rooms" do
    field :name, :string
    field :topic, :string

    timestamps()
  end

  @doc false
  # 제약 조건 추가
  def changeset(room, attrs) do
    room
    |> cast(attrs, [:name, :topic])
    |> validate_required(:name, message: "방 이름을 입력해주세요.")
    |> validate_length(:name, min: 3, max: 80, message: "최소 3자, 최대 80자 입력가능합니다")
    |> validate_format(:name, ~r/\A[ㄱ-ㅎA-z0-9-_]+\z/, message: "한글, 영문, 숫자, -, _만 포함할 수 있습니다.")
    |> validate_length(:topic, max: 200, message: "최대 200자까지 입력 가능합니다.")
  end
end

dutchpay/chat/context.ex

defmodule Dutchpay.Chat do
  @moduledoc false
  import Ecto.Query

  alias Dutchpay.Chat.Room
  alias Dutchpay.Repo

  @doc false
  def list_rooms do
    Repo.all(from(Room.Schema, order_by: [desc: :updated_at]))
  end

  def create_room(attrs) do
    %Room.Schema{}
    # 제약 조건 검사
    |> Room.Schema.changeset(attrs)
    |> Repo.insert()
  end
end

패턴 매칭을 통해 { :ok, ... } 일때는 insert되고 { :error, ... } 일때는 insert되지 않는다.


iex(5)> Chat.create_room(%{name: "myr1", topic: "test1"})
[debug] QUERY OK source="rooms" db=1.4ms queue=0.6ms idle=1012.1ms
INSERT INTO "rooms" ("name","topic","inserted_at","updated_at") VALUES ($1,$2,$3,$4) RETURNING "id" ["myr1", "test1", ~N[2025-01-21 09:14:15], ~N[2025-01-21 09:14:15]]
↳ :elixir.eval_external_handler/3, at: src/elixir.erl:386
{:ok,
 %Dutchpay.Chat.Room.Schema{
   __meta__: #Ecto.Schema.Metadata<:loaded, "rooms">,
   id: 3,
   name: "myr1",
   topic: "test1",
   inserted_at: ~N[2025-01-21 09:14:15],
   updated_at: ~N[2025-01-21 09:14:15]
 }}
iex(6)> Chat.create_room(%{name: "", topic: "test1"})
{:error,
 #Ecto.Changeset<
   action: :insert,
   changes: %{topic: "test1"},
   errors: [name: {"can't be blank", [validation: :required]}],
   data: #Dutchpay.Chat.Room.Schema<>,
   valid?: false,
   ...
 >}

해당 스키마 제약사항을 통해 phoenix의 클라이언트단 form에 에러를 표시해보자.
Phoenix.Component.to_form을 통해서 changeset을 form에 적합한 구조로 바꿀 수 있다.
다만 유의해야할 점은, 해당 changeset에 액션이 명시되어야만 에러를 표기한다.
iex를 통해서 확인해보자.

iex(1)> changeset = %Dutchpay.Chat.Room.Schema{} |> Dutchpay.Chat.change_room(%{name: ""})
#Ecto.Changeset<
  action: nil,
  changes: %{},
  errors: [name: {"방 이름을 입력해주세요.", [validation: :required]}],
  data: #Dutchpay.Chat.Room.Schema<>,
  valid?: false,
  ..>
iex(2)> changeset.errors
[name: {"방 이름을 입력해주세요.", [validation: :required]}]


iex(3)> form = Phoenix.Component.to_form(changeset, as: "room-form")
%Phoenix.HTML.Form{
  source: #Ecto.Changeset<
    action: nil,
    changes: %{},
    errors: [
      name: {"방 이름을 입력해주세요.", [validation: :required]}
    ],
    data: #Dutchpay.Chat.Room.Schema<>,
    valid?: false,
    ...
  >,
  impl: Phoenix.HTML.FormData.Ecto.Changeset,
  id: "room-form",
  name: "room-form",
  data: %Dutchpay.Chat.Room.Schema{
    __meta__: #Ecto.Schema.Metadata<:built, "rooms">,
    id: nil,
    name: nil,
    topic: nil,
    inserted_at: nil,
    updated_at: nil
  },
  action: nil,
  hidden: [],
  params: %{"name" => ""},
  errors: [],
  options: [method: "post"],
  index: nil
}
iex(4)> form.errors
[]

위에서 보는 것처럼 form.errors는 빈 리스트로 넘어온다.
하지만 Repo에서 changeset을 넣고 action을 취하고나면 폼에 에러가 추가된다.

iex(5)> changeset 
|> Dutchpay.Repo.update() 
|> case do {:error, v} -> v; other -> other end 
|> Phoenix.Component.to_form(as: "room-form") 
|> Map.get(:errors)

[name: {"방 이름을 입력해주세요.", [validation: :required]}

여기서의 핵심은 to_form에서 액션이 있냐 없냐일때 에러로 추가하냐 마냐로 따지기 때문에, Repo에 액션을 취하지 않으면서 검증을 하기 위해서는 임의로 액션 값을 넣어주면 해결된다.

iex(6)> changeset 
|> Map.put(:action, :validate) 
|> case do {:error, v} -> v; other -> other end 
|> Phoenix.Component.to_form(as: "room-form") 
|> Map.get(:errors)

[name: {"방 이름을 입력해주세요.", [validation: :required]}]

이렇게 구현된 이유는, 맨 처음에 방 제목이 없을 경우에도 에러를 표기하는 것과 같은 문제를 방지하기 위함이다.

추가적으로 Ecto 의 예시들을 참고하면 좋다.

인증 추가

pheonix에는 로그인, 회원가입 관련 인증 로직을 통째로 generate 해주는 기능이 있다.

mix phx.gen.auth Accounts User users

를 하면 users, users_tokens 테이블을 생성하고 Accounts.User를 스키마로 만든다.
dutchpay_web/lib/dutchpay_web/user_auth.ex 를 보면 인증 관련 로직들이 쭉 생성되어 있다.

router.ex를 보면 로그인, 회원가입 페이지까지 통째로 만들어져 있으니 확인한다.
그리고 상단 :browser 파이프라인에 :fetch_current_user가 plug로 잘 들어가 있는지 확인한다. 해당 함수의 로직은 user_auth.ex에 있다.

pipeline :browser do
	...
	plug(:fetch_current_user)
	...
end