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를 통해서 링크가 어떻게 연결되었는지 볼 수 있다.
*_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