LiveView Multi-Tenancy: Plug 와 Hook 으로 Tenant Context 관리하기
Phoenix LiveView 로 Multi-Tenant 애플리케이션을 작업할 때 대부분의 Tenant 관련 liveview 에서는 현재 Club Context 를 추출해야 한다. 나의 경우에는 Club Context 를 foo.com/clubs/:club_id URI 패턴에서 :club_id 를 가져와서 판단한다.
이 때 매번 Liveview 의 mount 에서 Club 을 조회하는 처리도 가능하지만, Plug 와 LiveView Hook 을 사용하면 일관성 있는 깔끔한 처리가 가능하다. Plug 와 Hook 을 설정하는 방법에 대해서 알아보자.
현재 프로젝트 구조
사용자는 여러 클럽에 속할 수 있음
클럽별로 데이터가 격리되어야 함
클럽 페이지에 접근할 때 권한 검사 필요
기존 LiveView 구현
# 모든 LiveView에서 이런 코드가 반복됨
defmodule MyAppWeb.ClubLive.Show do
use MyAppWeb, :live_view
def mount(%{"club_id" => club_id}, _session, socket) do
current_user = socket.assigns.current_user
# 1. 매번 tenant를 명시해야 함
user_with_clubs = current_user |> Ash.load!([:clubs],
actor: current_user,
tenant: club_id # 이걸 빼먹으면 에러!
)
# 2. 권한 검사 로직을 매번 작성
club = user_with_clubs.clubs |> Enum.find(&(&1.id == club_id))
case club do
nil ->
# 3. 에러 처리도 매번 다르게 구현
{:halt, Phoenix.LiveView.redirect(socket, to: "/clubs")}
club ->
# 4. 테넌트 설정도 수동으로
socket = assign(socket, :current_tenant, club)
{:ok, assign(socket, :club, club)}
end
end
end
Phoenix LiveView Multi-Tenant 애플리케이션: Plug와 Hook으로 Club Context 관리하기
Phoenix LiveView로 Multi-Tenant 애플리케이션을 작업할 때 대부분의 Tenant 관련 LiveView에서는 현재 Club Context를 추출해야 한다. 나의 경우에는 Club Context를 foo.com/clubs/:club_id URI 패턴에서 :club_id를 가져와서 판단한다.
이 때 매번 LiveView의 mount에서 Club을 조회하는 처리도 가능하지만, Plug와 LiveView Hook을 사용하면 일관성 있는 깔끔한 처리가 가능하다. Plug와 Hook을 설정하는 방법에 대해서 알아보자.
현재 프로젝트 구조
사용자는 여러 클럽에 속할 수 있음
클럽별로 데이터가 격리되어야 함
클럽 페이지에 접근할 때 권한 검사 필요
기존 LiveView 구현
# 모든 LiveView에서 이런 코드가 반복됨
defmodule MyAppWeb.ClubLive.Show do
use MyAppWeb, :live_view
def mount(%{"club_id" => club_id}, _session, socket) do
current_user = socket.assigns.current_user
# 1. 매번 tenant를 명시해야 함
user_with_clubs = current_user |> Ash.load!([:clubs],
actor: current_user,
tenant: club_id # 이걸 빼먹으면 에러!
)
# 2. 권한 검사 로직을 매번 작성
club = user_with_clubs.clubs |> Enum.find(&(&1.id == club_id))
case club do
nil ->
# 3. 에러 처리도 매번 다르게 구현
{:halt, Phoenix.LiveView.redirect(socket, to: "/clubs")}
club ->
# 4. 테넌트 설정도 수동으로
socket = assign(socket, :current_tenant, club)
{:ok, assign(socket, :club, club)}
end
end
end
기존 구현의 문제점
1. 코드 중복과 유지보수성 문제
모든 Club 관련 LiveView에서 동일한 패턴이 반복됨
권한 검사 로직이 변경되면 모든 LiveView를 수정해야 함
2. 일관성 없는 에러 처리
각 LiveView마다 다른 방식으로 권한 오류를 처리
리다이렉트 경로나 에러 메시지가 제각각
3. UI 와 관련없는 로직의 혼재
LiveView의 본래 역할인 UI 로직에 집중하지 못함
Club 조회와 권한 검사가 비즈니스 로직과 섞여있음
해결책: Plug와 Hook을 활용한 자동화
1단계: Club Context 자동 설정 Plug
# lib/my_app_web/plugs/set_club_context.ex
defmodule MyAppWeb.Plugs.SetClubContext do
@moduledoc """
URL의 club_id에서 Club Context를 자동으로 설정하는 Plug
"""
@behaviour Plug
import Plug.Conn
import Phoenix.Controller, only: [put_flash: 3, redirect: 2]
def init(opts), do: opts
def call(conn, _opts) when is_struct(conn.assigns.current_user) do
club_id = conn.path_params["club_id"]
if club_id do
load_club_context(conn, club_id)
else
assign(conn, :current_club, nil)
end
end
def call(conn, _opts) do
assign(conn, :current_club, nil)
end
defp load_club_context(conn, club_id) do
current_user = conn.assigns.current_user
# 사용자의 Club 목록을 tenant 컨텍스트에서 로드
user_with_clubs = current_user
|> Ash.load!([:clubs], actor: current_user, tenant: club_id)
# 해당 Club에 대한 권한 검사
club = user_with_clubs.clubs |> Enum.find(&(&1.id == club_id))
case club do
nil ->
# 권한이 없는 경우
conn
|> put_flash(:error, "해당 클럽에 접근할 권한이 없습니다.")
|> redirect(to: "/clubs")
|> halt()
club ->
# 권한이 있는 경우 Context 설정
conn
|> assign(:current_club, club)
|> Ash.PlugHelpers.set_tenant(club)
end
end
end
2단계: LiveView Hook으로 connection 의 정보를 session 에도 저장
# lib/my_app_web/hooks/club_context_hook.ex
defmodule MyAppWeb.Hooks.ClubContextHook do
@moduledoc """
LiveView에서 Club Context 검증을 위한 Hook
"""
import Phoenix.LiveView
def on_mount(:require_club_context, _params, _session, socket) do
case socket.assigns[:current_club] do
nil ->
# Club Context가 없는 경우
socket = put_flash(socket, :error, "클럽을 선택해주세요.")
{:halt, redirect(socket, to: "/clubs")}
club ->
# Club Context가 있는 경우
{:cont, assign(socket, :current_club, club)}
end
end
def on_mount(:optional_club_context, _params, _session, socket) do
# Club Context가 선택적인 경우 (예: 메인 페이지)
{:cont, socket}
end
end
3단계: Router에서 Pipeline 구성
# lib/my_app_web/router.ex
defmodule MyAppWeb.Router do
use MyAppWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug :load_from_session # 사용자 인증
end
pipeline :require_club_context do
plug MyAppWeb.Plugs.SetClubContext # Club Context 자동 설정
end
# 일반 페이지 (Club Context 불필요)
scope "/", MyAppWeb do
pipe_through :browser
get "/", PageController, :home
live_session :default, on_mount: [{MyAppWeb.LiveUserAuth, :live_user_required}] do
live "/clubs", ClubLive.Index, :index
live "/clubs/new", ClubLive.Form, :new
end
end
# Club 관련 페이지 (Club Context 필요)
scope "/", MyAppWeb do
pipe_through [:browser, :require_club_context]
live_session :club_context,
on_mount: [
{MyAppWeb.LiveUserAuth, :live_user_required},
{MyAppWeb.Hooks.ClubContextHook, :require_club_context}
] do
live "/clubs/:club_id", ClubLive.Show, :show
live "/clubs/:club_id/edit", ClubLive.Form, :edit
live "/clubs/:club_id/members", ClubLive.Members, :index
live "/clubs/:club_id/events", EventLive.Index, :index
end
end
end
4단계: ToTenant 프로토콜 구현
Ash에서 Club을 tenant로 사용하기 위해 필요한 프로토콜을 구현한다:
# lib/my_app/clubs/club.ex
defmodule MyApp.Clubs.Club do
use Ash.Resource
# Club을 tenant로 사용하기 위한 프로토콜 구현
defimpl Ash.ToTenant do
def to_tenant(club, _opts) do
club.id
end
end
# ... 나머지 Club 리소스 정의
end
개선된 LiveView 구현
위 코드를 전부 적용하면 LiveView 의 코드가 상당히 깔끔해진다.
# Before: 복잡하고 반복적인 코드 (25줄)
defmodule MyAppWeb.ClubLive.Show do
use MyAppWeb, :live_view
def mount(%{"club_id" => club_id}, _session, socket) do
current_user = socket.assigns.current_user
user_with_clubs = current_user |> Ash.load!([:clubs], tenant: club_id)
club = user_with_clubs.clubs |> Enum.find(&(&1.id == club_id))
case club do
nil -> {:halt, Phoenix.LiveView.redirect(socket, to: "/clubs")}
club -> {:ok, assign(socket, :club, club)}
end
end
end
# After: 간단하고 명확한 코드 (8줄)
defmodule MyAppWeb.ClubLive.Show do
use MyAppWeb, :live_view
def mount(_params, _session, socket) do
# current_user와 current_club이 자동으로 설정됨
# 권한 검사도 이미 완료된 상태
club = socket.assigns.current_club
{:ok, assign(socket, :club, club)}
end
end
결과: 개선 효과
| 항목 | Before | After |
| 코드 중복 | 모든 LiveView에서 반복 | Plug/Hook에서 한 번만 구현 |
| 권한 검사 | 수동, 일관성 없음 | 자동, 일관된 처리 |
| 에러 처리 | 각각 다름 | 통일된 메시지와 리다이렉트 |
| 유지보수성 | 변경 시 모든 파일 수정 | 한 곳만 수정 |
핵심 포인트
일관성 보장: 모든 Club 관련 페이지에서 동일한 방식으로 처리
자동화 처리: 개발자가 실수할 여지를 최소화
확장성 개선: 새로운 Club 관련 LiveView 추가 시 router 에서 적절한 위치만 선택하면, 추가적인 작업이 불필요