코드 인터페이스로 간결하게 액션 호출하기

Ash 리소스의 액션을 사용하려면 다음과 같이 호출할 수 있었다.

iex> Helpdesk.Support.Ticket
    |> Ash.Changeset.for_create(:open, %{subject: "Issue Ticket"})
    |> Ash.create!()

하지만 코드가 장황하고 읽기 어려운 감이 있다. 매번 Ash 모듈을 참조하는 것도 좋아보이지 않는다.

다음과 같이 사용할 수 있으면 좋을 것 같다.

iex> Helpdesk.Support.open_ticket(subject)

Ash 에서는 코드 인터페이스를 통해 보다 간결하게 액션을 호출할 수 있는 기능을 제공한다. 공식 문서의 내용을 따라가며 코드 인터페이스의 동작과 활용 가능성에 대해 살펴 보자.

도메인에서 코드 인터페이스 정의

다음과 같은 액션이 정의되어 있다고 가정 하자.

defmodule Helpdesk.Support.Ticket do
  ...

  actions do
    defaults [:read]

    create :open do
      accept [:subject]
      change relate_actor(:owner)
    end

    ...
  end
end

다음과 같이 도메인 정의에서 코드 인터페이스를 추가하면

resources do
  resource Ticket do
    define :open_ticket, args: [:subject], action: :open
  end
end

액션을 호출하는 곳 에서는 다음과 같이 사용할 수 있다.

# 기본 사용
iex> Helpdesk.Support.open_ticket("버그 수정")
{:ok, %Ticket{...}}

# ! 버전은 결과를 직접 반환
iex> Helpdesk.Support.open_ticket!("버그 수정")
%Ticket{...}

# 옵션과 함께 사용
iex> Helpdesk.Support.open_ticket("긴급 이슈", actor: current_user)

리소스에서 코드 인터페이스 정의

리소스 코드 에서도 코드 인터페이스를 정의할 수 있다.

code_interface do
  # 함수 이름과 일치하므로 open 액션은 생략 가능
  define :open, args: [:subject]
end

다음과 같이 호출할 수 있다.

iex> Helpdesk.Support.Ticket.open(subject)

이렇게 인터페이스로 정의된 함수는 h 명령어를 통해 사용 방법에 대한 문서도 제공된다.

iex> h Helpdesk.Support.open_ticket

              def open_ticket(subject, params \\ nil, opts \\ nil)              

Calls the open action on Helpdesk.Support.Ticket.

# Arguments

  • subject
## Options

  • :upsert? (t:boolean/0) - If a conflict is found based on the primary
    key, the record is updated in the database (requires upsert support) The
    default value is false.
  • :return_skipped_upsert? (t:boolean/0) - If true, and a record was not
    upserted because its filter prevented the upsert, the original record
    (which was not upserted) will be returned. The default value is false.
  ...

코드 인터페이스 사용하기

액션 타입에 따른 인수

코드 인터페이스로 정의된 함수는 액션 타입에 따라 자동으로 필요한 인수를 받을 수 있도록 생성된다.

업데이트/삭제 액션: 액션의 대상이 필요하므로 기본적으로 첫 번째 인수로 레코드 또는 체인지셋을 받는다.

# 업데이트
iex> ticket = Helpdesk.Support.get_ticket!(ticket_id)
iex> Helpdesk.Support.update_ticket(ticket, %{status: :closed})

# 삭제
iex> Helpdesk.Support.delete_ticket(ticket)

읽기 액션: 액션의 대상이 필요하지는 않지만 조회 대상을 필터링 할 수 있도록 `query` 옵션을 통해 쿼리를 전달할 수 있다.

# 쿼리 없이 전체 조회
iex> Helpdesk.Support.list_tickets()

# Ash.Query 객체로 필터링
iex> query = Ticket |> Ash.Query.filter(status == :open)
iex> Helpdesk.Support.list_tickets(query: query)

# 키워드 리스트로 필터링
iex> Helpdesk.Support.list_tickets(
  query: [
    filter: [status: :open],
    sort: [created_at: :desc]
  ]
)

선택적 인수

필수 인수 외에도 선택적으로 옵션과 액션 입력을 전달할 수 있다. 옵션은 Ash가 제공하는 실행 방식 제어(actor, authorize? 등)를 위한 것이고, 액션 입력은 액션에 정의된 argument를 전달하기 위한 것이다.

# 옵션만
Helpdesk.Support.open_ticket("버그", actor: current_user)

# 액션 입력만
Accounts.register_user(username, password, %{referral_code: "ABC"})

# 둘 다
Accounts.register_user(
  username, 
  password,
  %{referral_code: "ABC"},  # 액션 입력 (맵)
  tenant: "org_22"          # 옵션 (키워드 리스트)
)

키워드 리스트는 옵션으로, 맵은 액션 입력으로 자동 구분된다.

get_by 함수

코드 인터페이스를 정의하는 define 매크로에는 get_by 라는 옵션이 있다. 먼저 get_by 옵션을 사용하지 않은 코드 인터페이스 정의를 다시 살펴보자.

resources do
  resource Ticket do
    define :read_ticket, action: :read
  end
end

이 인터페이스는 Helpdesk.Support.read_ticket() 으로 사용할 수 있는데 기본적으로 목록을 반환하는 인터페이스이다. 이 상황에서 단일 레코드를 획득하고 싶다면 query 를 사용해서 필터링 하거나 구분자를 인자로 받는 액션을 별도로 정의해서 코드 인터페이스로 연결해야 한다. 혹은 코드 인터페이스를 사용하지 않고 Ash.get!(id) 등을 사용해 단일 레코드를 획득할 수도 있지만 컨트롤러나 LiveView에서 Ash.get!을 직접 사용하는 것은 Ecto에서 컨텍스트 모듈 밖에서 Repo.getRepo.preload를 직접 사용하는 것과 같은 안티패턴이므로 피해야 할 패턴이라고 한다.

# ❌ 이렇게 하지 마세요
defmodule HelpdeskWeb.TicketController do
  def show(conn, %{"id" => id}) do
    ticket =
      Helpdesk.Support.Ticket
      |> Ash.get!(id)
      |> Ash.load!(comments: [:author])
    
    render(conn, :show, ticket: ticket)
  end
end

따라서 코드 인터페이스를 통해 단일 레코드를 획득하는 함수를 정의하려면 get_by 옵션으로 함수를 정의하는 것을 권장한다.

resources do
  resource Ticket do
    define :get_ticket_by_id, action: :read, get_by: [:id]
  end
end

이제 다음과 같이 사용할 수 있다.

# 컨트롤러에서
defmodule HelpdeskWeb.TicketController do
  def show(conn, %{"id" => id}) do
    ticket = Helpdesk.Support.get_ticket_by_id!(id)
    render(conn, :show, ticket: ticket)
  end
end

update/destroy 액션에서의 get_by

update와 destroy 액션은 기본적으로 레코드 인스턴스를 첫 번째 인자로 받는다. 따라서 코드 인터페이스를 사용하더라도 먼저 레코드를 조회한 후 업데이트나 삭제를 수행해야 한다.

code_interface do
  define :update_ticket, action: :assign
  define :delete_ticket, action: :destroy
end

이 경우 다음과 같이 사용해야 한다.

# 먼저 레코드 조회
ticket = Helpdesk.Support.get_ticket_by_id!(ticket_id)

# 그 다음 업데이트
Helpdesk.Support.update_ticket(ticket, %{representative_id: rep_id})

# 또는 삭제
Helpdesk.Support.delete_ticket(ticket)

레코드를 먼저 조회한 후 업데이트나 삭제를 수행하는 두 단계의 코드를 작성해야 하므로, 컨트롤러나 LiveView에서 코드가 길어지고 중간 변수를 관리해야 하는 번거로움이 있다. 이런 상황에서 get_by 옵션을 사용하면 레코드 조회와 업데이트/삭제를 하나의 함수 호출로 처리할 수 있다.

code_interface do
  define :update_ticket, action: :assign, get_by: [:id]
  define :delete_ticket, action: :destroy, get_by: [:id]
end

get_by 옵션을 사용하면 record 인자가 지정한 구분자 값으로 대체되어, 구분자 값 만으로 직접 업데이트나 삭제를 수행할 수 있다.

# 한 번의 호출로 업데이트
Helpdesk.Support.update_ticket(ticket_id, %{representative_id: rep_id})

# 한 번의 호출로 삭제
Helpdesk.Support.delete_ticket(ticket_id)

get_by 옵션을 사용해 update/destory 과정을 단일 함수 호출로 훨씬 간결하게 표현할 수 있게 되었다.

언제 액션을 사용하고 언제 코드 인터페이스를 사용할 것인가

  • 액션은 리소스에서 가능한 작업을 정의
  • 코드 인터페이스는 해당 액션을 함수로 호출 가능하게 만든다
  • 액션은 코드 인터페이스를 반드시 필요로 하는 것은 아니며 코드 인터페이스가 없어도 AshJsonApi 같은 extension 에서 사용될 수 있다

이 패턴은 도메인 로직을 리소스에 유지하고, 웹 레이어는 간결하게 유지하여 관심사의 분리를 명확히 한다.

Calculations

Calcuation 은 define_calculation 으로 코드 인터페이스를 만들 수 있다.

calculations do
  calculate :full_name, :string, expr(first_name <> ^arg(:separator) <> last_name) do
    argument :separator, :string do
      allow_nil? false
      default " "
    end
  end
end

# 도메인에서
resource User do
  define_calculation :full_name, args: [:first_name, :last_name, {:optional, :separator}]
  # 또는 레코드를 인자로 받고 싶다면
  define_calculation :full_name, args: [:_record]
end


# 다음과 같이 사용
Accounts.full_name("Jessie", "James", "-")
# 또는 레코드를 인자로 사용
Accounts.full_name(user)

define_calculation 매크로의 첫번째 인자는 필수이며 function 의 이름이 된다.


공식 문서를 따라가며 코드 인터페이스에 대해 살펴 봤다. 실제 리소스의 작업 정의는 액션이고 코드 인터페이스는 액션을 사용하기 쉽게끔 만들어주는 보조 도구라고 보면 될 것 같다. 다만 애플리케이션에서 액션을 사용할 때의 코드 패턴 상 코드 인터페이스는 사실상 필수로 사용해야 할 도구라는 생각이 든다.

댓글 남기기