Skip to main content

Command Palette

Search for a command to run...

LiveView Multi-Tenancy: Plug 와 Hook 으로 Tenant Context 관리하기

Published
•5 min read•View as Markdown

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

결과: 개선 효과

항목BeforeAfter
코드 중복모든 LiveView에서 반복Plug/Hook에서 한 번만 구현
권한 검사수동, 일관성 없음자동, 일관된 처리
에러 처리각각 다름통일된 메시지와 리다이렉트
유지보수성변경 시 모든 파일 수정한 곳만 수정

핵심 포인트

  1. 일관성 보장: 모든 Club 관련 페이지에서 동일한 방식으로 처리

  2. 자동화 처리: 개발자가 실수할 여지를 최소화

  3. 확장성 개선: 새로운 Club 관련 LiveView 추가 시 router 에서 적절한 위치만 선택하면, 추가적인 작업이 불필요

More from this blog

Ash Typescript

TL;DRElixir의 Ash Framework로 정의한 리소스를 TypeScript와 바로 연결해 주는 툴.백엔드 스펙을 따로 문서화하지 않아도 타입 안정성과 동기화를 자동으로 보장한다. 왜 사용하는가? Elixir로 백엔드를, TypeScript로 프런트엔드를 작성하다 보면 변경되는 API 스펙을 항상 동기화된 상태로 관리하기 쉽지 않다.결국 동기화를 위해서는 Graphql 을 사용하거나, 별도의 툴이 필요하다.AshTypescript ...

Sep 28, 20253 min read

Ash Reactor

TLDR; Reactor는 복잡한 워크플로우를 단계별로 정의하고 실행하는 라이브러리다. 각 단계가 독립적으로 돌아가며, 병렬 처리와 에러 핸들링도 쉽게 할 수 있다. 왜 쓸까? 프로젝트에서 "데이터 조회 → 가공 → 외부 API 호출 → 저장" 같은 복잡한 로직을 처리할 때가 있다.이런걸 그냥 함수로 쭉 연결하면 코드가 복잡해지고 에러 처리도 어려워진다. Reactor를 쓰면 각 단계를 명확히 분리하고 선언적으로 워크플로우를 관리할 수 있다. ...

Sep 21, 20254 min read

Ash StateMachine

TLDR; Ash StateMachine 은 상태 변경이 가능한 케이스를 명시하고, 미리 선언된 상태 변경만 가능하도록 한다. 왜 쓸까? 예를 들면, “새로 생성 → 진행 중 → 완료” 처럼 정해진 workflow 가 있다면, 로직으로 if/else 를 사용하는 것보다 상태머신으로 선언해 두는 게 안전하고 읽기 쉽다. 예제: Task 요구사항: 기본 상태는 :new :start 액션이 :new → :in_progress :finish ...

Sep 14, 20252 min read

Ash Oban

최근 작업중에 이메일 발송을 처리해야 했다.after_action 에 이마일 발송을 고려했는데 최종적으로는 AshOban 을 사용했다. AshOban이 뭔가? Ash Framework와 Oban을 이어주는 라이브러리다. 백그라운드 job을 Ash 스타일로 쓸 수 있게 해준다. 멤버 초대 이메일 보내기 클럽에 새 멤버를 초대하면 이메일을 보내야 하는 상황이다. 이걸 어떻게 처리할까? Action Hook으로 하면: 사용자가 서버에서 이메일 발송...

Aug 31, 20252 min read

kkbz.hop

14 posts