Ash Framework 튜토리얼 따라하기 (6)

계속해서 Ash Framework 에서 리소스간 관계를 설정하고 다루는 방법에 대해 알아 본다.

새 리소스를 만든다. Representative 로 담당자 정보에 대한 리소스이다. 티켓은 한 담당자에 속해 있을 수 있고 한 담당자는 여러 개의 티켓을 가질 수 있다. 1 (담당자) : 다 (티켓) 구조 이다. 먼저 담당자 리소스를 추가로 선언한다.

# lib/helpdesk/support/representative.ex

defmodule Helpdesk.Support.Representative do
  use Ash.Resource,
    domain: Helpdesk.Support,
    data_layer: Ash.DataLayer.Ets

  actions do
    defaults [:read]

    create :create do
      accept [:name]
    end
  end

  attributes do
    uuid_primary_key :id

    attribute :name, :string do
      public? true
    end
  end

  relationships do
    has_many :tickets, Helpdesk.Support.Ticket
  end
end

다른 선언은 Ticket 예제와 크게 다르지 않다. relationships 선언이 추가되었다. has_many 를 선언함으로 이 리소스는 여러개의 Ticket 을 가질 수 있음을 선언한다. 일대다 관계에서는 두 리소스를 연결할 기준 필드가 필요한데 Ash 에서는 source_attribute, destination_attribute 라 부른다. 기본적으로 source_attribute:id로 가정되며, destination_attribute<모듈명의_마지막_부분을_스네이크케이스로_변환>_id 형태로 기본값이 설정된다. 예제 코드에서는 source_attribute -> :id, destination_attribute -> :representative_id 인 것이다.

Ticket 에는 Ticket이 Representative 에 속한다는 것을 선언 한다. Ticket 에도 relationships 를 추가 한다.

# lib/helpdesk/support/ticket.ex

relationships do
  belongs_to :representative, Helpdesk.Support.Representative
end

belongs_to 도 has_many 와 마찬가지로 source_attribute, destination_attribute 가 있다. 기본 값 규칙은 다음과 같다.

belongs_to :owner, MyApp.User do
  # 기본값 :<relationship_name>_id (i.e. :owner_id)
  source_attribute :custom_attribute_name # 직접 설정할 수 도 있다

  # 기본값 :id
  destination_attribute :custom_attribute_name # 직접 설정할 수 도 있다
end

예제 코드는 관계명을 :representative 로 하였으므로 source_attribute 는 :representative_id destination_attribute 는 :id 가 된다.

마지막으로 추가된 Representative 리소스를 Support 도메인에 추가 한다.

# lib/helpdesk/support.ex

resources do
 ...
 resource Helpdesk.Support.Representative
end

Representative 가 Support 도메인의 리소스로 등록되고 Representative 와 Ticket 은 일대다 관계가 설정되었다. 관계가 제대로 설정되었는지 어떻게 다루는지 코드로 확인해 보자.

Representative 와 Ticket 이 관계를 맺는 것은 티켓에 담당자를 할당하는 액션과 같다. Ticket 에 :assign 액션을 추가 하자.

# lib/helpdesk/support/ticket.ex

actions do
  ...
  update :assign do
    accept [:representative_id]
  end
end

이제 코드를 통해 관계를 설정하고 조회하면서 어떻게 동작하는지 확인해 보자.

iex> ticket = (
  Helpdesk.Support.Ticket
  |> Ash.Changeset.for_create(:open, %{subject: "I can't find my hand!"})
  |> Ash.create!()
)

%Helpdesk.Support.Ticket{
  id: "5f8e4bf2-851b-4d42-af02-77e90848afa7",
  subject: "I can't find my hand!",
  status: :open,
  representative_id: nil,
  representative: #Ash.NotLoaded<:relationship, field: :representative>,
  __meta__: #Ecto.Schema.Metadata<:loaded>
}

Ticket 리소스를 만들었다. representative_id 필드가 있고 nil 인 것을 확인할 수 있다.

iex> ticket2 = (
  Helpdesk.Support.Ticket
  |> Ash.Changeset.for_create(:open, %{subject: "Please fix it."})
  |> Ash.create!()
)

%Helpdesk.Support.Ticket{
  id: "442d09a4-f13d-47bf-9bce-1ab5db148696",
  subject: "Please fix it.",
  status: :open,
  representative_id: nil,
  representative: #Ash.NotLoaded<:relationship, field: :representative>,
  __meta__: #Ecto.Schema.Metadata<:loaded>
}

또 다른 Ticket 리소스. 한 담당자에게 두 개의 티켓을 할당 할 것이다.

iex> representative = (
  Helpdesk.Support.Representative
  |> Ash.Changeset.for_create(:create, %{name: "Joe Armstrong"})
  |> Ash.create!()
)

%Helpdesk.Support.Representative{
  id: "16209c46-5df7-4aa7-9ec7-2b599d786f96",
  name: "Joe Armstrong",
  tickets: #Ash.NotLoaded<:relationship, field: :tickets>,
  __meta__: #Ecto.Schema.Metadata<:loaded>
}

Representative 리소스. tickets 필드가 있고 지연로드 때문에 데이터가 보이지 않는다. 지금 시점에선 로드한다 하더라도 티켓을 할당하지 않았기 때문에 빈 리스트 일 것이다.

iex> ticket
|> Ash.Changeset.for_update(:assign, %{representative_id: representative.id})
|> Ash.update!()

%Helpdesk.Support.Ticket{
  id: "5f8e4bf2-851b-4d42-af02-77e90848afa7",
  subject: "I can't find my hand!",
  status: :open,
  representative_id: "16209c46-5df7-4aa7-9ec7-2b599d786f96",
  representative: #Ash.NotLoaded<:relationship, field: :representative>,
  __meta__: #Ecto.Schema.Metadata<:loaded>
}

iex> ticket2
|> Ash.Changeset.for_update(:assign, %{representative_id: representative.id})
|> Ash.update!()

%Helpdesk.Support.Ticket{
  id: "442d09a4-f13d-47bf-9bce-1ab5db148696",
  subject: "Please fix it.",
  status: :open,
  representative_id: "16209c46-5df7-4aa7-9ec7-2b599d786f96",
  representative: #Ash.NotLoaded<:relationship, field: :representative>,
  __meta__: #Ecto.Schema.Metadata<:loaded>
}

두 티켓 리소스에 같은 담당자를 할당 했다. 둘 다 같은 representative_id 가 할당 된 것을 확인할 수 있다.

iex> Helpdesk.Support.Representative |> Ash.Query.load(:tickets) |> Ash.read_one!()

%Helpdesk.Support.Representative{
  id: "16209c46-5df7-4aa7-9ec7-2b599d786f96",
  name: "Joe Armstrong",
  tickets: [
    %Helpdesk.Support.Ticket{
      id: "442d09a4-f13d-47bf-9bce-1ab5db148696",
      subject: "Please fix it.",
      status: :open,
      representative_id: "16209c46-5df7-4aa7-9ec7-2b599d786f96",
      representative: #Ash.NotLoaded<:relationship, field: :representative>,
      __meta__: #Ecto.Schema.Metadata<:loaded>
    },
    %Helpdesk.Support.Ticket{
      id: "5f8e4bf2-851b-4d42-af02-77e90848afa7",
      subject: "I can't find my hand!",
      status: :open,
      representative_id: "16209c46-5df7-4aa7-9ec7-2b599d786f96",
      representative: #Ash.NotLoaded<:relationship, field: :representative>,
      __meta__: #Ecto.Schema.Metadata<:loaded>
    }
  ],
  __meta__: #Ecto.Schema.Metadata<:loaded>
}

Representative 를 그냥 로드하면 relationships 필드는 즉시 로딩되지 않는다. 명시적으로 해당 필드를 로드(Ash.Query.load(:tickets))해야 데이터를 가져오게 된다. 담당자 데이터에 할당 된 티켓 목록을 확인할 수 있다.

공식 튜토리얼의 예제는 여기까지지만 지금까지의 내용은 정말 기초에 불과하고 살펴봐야 할 내용과 기능이 많은 것 같다. 앞으로 계속 살펴보고 배워두면 Elixir/Phoenix 환경에서의 개발에 도움이 많이 될 것 같다.

Ash Framework 튜토리얼 따라하기 (5)

지난 글에 이어 Ash Framework 에서 영속성에 대해 다루는 방법을 알아 본다.

지난 글 에서도 잠깐 언급이 있었지만 리소스에 data_layer 를 추가하면 해당 레이어에 데이터가 저장 된다. 예제에서는 ETS 를 data layer 로 사용 한다. ETS 는 Erlang/Elixir 환경의 인메모리 데이터베이스이다. Ticket 리소스에 다음 코드를 추가 한다.

# lib/helpdesk/support/ticket.ex

use Ash.Resource,
  domain: Helpdesk.Support,
  data_layer: Ash.DataLayer.Ets

Ash 에서 어떤 데이터 레이어를 지원하는지 궁금할 수 있다. Ash 에서는 데이터 레이어를 익스텐션 형태로 제공하는데 기본으로 제공하는 것은 ETS, Mnesia, Simple 이 정도 인 것 같고 다른 것은 추가로 익스텐션을 설치해야 하는 것 같다. 대표적인 익스텐션으로 AshPostgres 가 있다.

데이터 레이어를 추가하고 다음 코드를 다시 실행해 보자. 이전 글 과는 달리 리소스에서 데이터 레이어를 지정했기 때문에 따로 set_data 를 통해 데이터를 제공하지 않아도 된다.


require Ash.Query

for i <- 0..5 do
  ticket =
    Helpdesk.Support.Ticket
    |> Ash.Changeset.for_create(:open, %{subject: "Issue #{i}"})
    |> Ash.create!()

  if rem(i, 2) == 0 do
    ticket
    |> Ash.Changeset.for_update(:close)
    |> Ash.update!()
  end
end

# 제목에 "2"를 포함한 티켓
Helpdesk.Support.Ticket
|> Ash.Query.filter(contains(subject, "2"))
|> Ash.read!()

# :closed 이면서 제목에 "4"를 포함하지 않은 티켓
Helpdesk.Support.Ticket
|> Ash.Query.filter(status == :closed and not(contains(subject, "4")))
|> Ash.read!()

비교적 쉽게 리소스를 데이터 레이어에 저장할 수 있게 되었다. 그러나 데이터 레이어는 보통 외부 데이터 저장소를 사용하고 저장소마다 특성이 있기 때문에 그에 맞는 익스텐션 사용법을 추가로 확인해야 할 것 같다. 범용 데이터베이스인 PostgreSQL 의 익스텐션이 있긴 하나 MySQL 익스텐션은 현 시점 아직 알파 버전 이다. 감안하고 사용해야 할 것 같다.

다음 글 에서는 리소스 간 관계 설정에 대해 알아 보겠다.

Ash Framework 튜토리얼 따라하기 (4)

지난 글에 이어서 Ash 프레임워크에서 데이터를 쿼리하는 방법을 알아본다.

먼저 Ticket 리소스 전체 목록을 가져오는 코드는 다음과 같다.

Ash.read!(Helpdesk.Support.Ticket)

그러나 지금 상황에서 코드를 바로 실행하면 다음과 같은 오류가 발생한다.

iex> Ash.read!(Helpdesk.Support.Ticket)
** (Ash.Error.Framework) 
Bread Crumbs:
  > Error returned from: Helpdesk.Support.Ticket.read

Framework Error

* No data provided to resource Elixir.Helpdesk.Support.Ticket. Perhaps you are missing a call to `Ash.DataLayer.Simple.set_data/2`?.

Another common cause of this is failing to add a data layer for a resource. You can add a data layer like so:

`use Ash.Resource, data_layer: Ash.DataLayer.Ets`

Ticket 리소스에 대해 데이터 레이어가 없다는 얘기다. 지금까지 리소스를 정의만 했지 어디에 저장할지는 정의하지 않았다. 리소스를 어디에 저장할 지 데이터 레이어를 지정하면 해결되지만 그 전에 데이터 레이어가 없는 경우에는 어떻게 다룰 수 있는지 알아 보자.

데이터 레이어가 없는 리소스는 암시적으로 Ash.DataLayer.Simple 를 사용한다. 이 데이터는 어디에도 영속화 되지 않으며 쿼리가 실행될 때 같이 제공되어야 한다.

조회를 시험해 볼 데이터를 먼저 만든다.

iex> tickets =
  for i <- 0..5 do
    ticket =
      Helpdesk.Support.Ticket
      |> Ash.Changeset.for_create(:open, %{subject: "Issue #{i}"})
      |> Ash.create!()

    if rem(i, 2) == 0 do
      ticket
      |> Ash.Changeset.for_update(:close)
      |> Ash.update!()
    else
      ticket
    end
  end

[
  %Helpdesk.Support.Ticket{
    id: "d2c428d4-d3c6-474a-a353-3ecdaeef3080",
    subject: "Issue 0",
    status: :closed,
    __meta__: #Ecto.Schema.Metadata<:built, "">
  },
  %Helpdesk.Support.Ticket{
    id: "34844252-aeeb-4c4f-9d1b-9526f9ac9813",
    subject: "Issue 1",
    status: :open,
    __meta__: #Ecto.Schema.Metadata<:built, "">
  },
  %Helpdesk.Support.Ticket{
    id: "4dd6b7b4-a3c5-4630-b26f-d85550b53858",
    subject: "Issue 2",
    status: :closed,
    __meta__: #Ecto.Schema.Metadata<:built, "">
  },
  %Helpdesk.Support.Ticket{
    id: "6def5674-8504-4062-a1d6-b5cc1c2c1b06",
    subject: "Issue 3",
    status: :open,
    __meta__: #Ecto.Schema.Metadata<:built, "">
  },
  %Helpdesk.Support.Ticket{
    id: "edc86cc1-0455-41ab-8f6e-2d3b00351bb5",
    subject: "Issue 4",
    status: :closed,
    __meta__: #Ecto.Schema.Metadata<:built, "">
  },
  %Helpdesk.Support.Ticket{
    id: "e537457b-59fa-4d41-bf6f-54ac019ed76c",
    subject: "Issue 5",
    status: :open,
    __meta__: #Ecto.Schema.Metadata<:built, "">
  }
]

tickets 에 임의의 리소스 목록이 만들어 졌다. 영속화 되지 않은 데이터이다. 이 데이터를 Ash 를 통해 조회하려면 어떻게 할 수 있는지 알아 보자. 제목에 “2” 를 포함한 리소스만 읽는 쿼리는 다음과 같다.


# Ash.Query는 매크로이므로 require 해야 함
iex> require Ash.Query 

Ash.Query

iex> Helpdesk.Support.Ticket |> Ash.Query.filter(contains(subject, "2")) |> Ash.DataLayer.Simple.set_data(tickets) |> Ash.read!()

[
  %Helpdesk.Support.Ticket{
    id: "4dd6b7b4-a3c5-4630-b26f-d85550b53858",
    subject: "Issue 2",
    status: :closed,
    __meta__: #Ecto.Schema.Metadata<:built, "">
  }
]

Ash.read!() 전에 Ash.DataLayer.Simple.set_data(tickets) 를 통해 파이프라인에 tickets 데이터를 제공한 것을 확인할 수 있다. Ash.Query.filter(contains(subject, "2")) 는 일종의 where 절 이라고 볼 수 있다. 예제 코드의 순서는 read 직전에 set_data 를 지정했지만 set_data 부터 하고 이후에 filter 를 해도 결과는 같았다.

다음은 :closed 티켓 중 제목에 “4” 가 포함되지 않은 티켓의 목록을 얻는 코드이다.

Helpdesk.Support.Ticket
|> Ash.Query.filter(status == :closed and not(contains(subject, "4")))
|> Ash.DataLayer.Simple.set_data(tickets)
|> Ash.read!()

다음 글 에서는 데이터 영속화에 대해 알아 본다.

Ash Framework 튜토리얼 따라하기 (3)

https://hexdocs.pm/ash/get-started.html 를 따라가며 Ash 프레임워크를 살펴 본다. 이전 글에 이어서 Ticket 리소스를 업데이트 하는 방법을 알아 본다.

Ticket 을 종료하는 기능이 필요하다. 티켓이 가지고 있는 status 를 :closed 로 바꾸면 된다. 이미 :closed status 인 경우 값을 변경하지 않고 적절한 에러 메시지를 안내한다.

Ticket 리소스에서 다음과 같이 선언을 변경한다.

# lib/helpdesk/support/ticket.ex

actions do
  ...
  update :close do
    accept []

    validate attribute_does_not_equal(:status, :closed) do
      message "Ticket is already closed"
    end

    change set_attribute(:status, :closed)
  end
end

액션의 형태는 update 이고 이름은 :close 이다. 파라미터가 필요하다면 accept 를 줄 수 있다. 이 예제는 티켓을 종료할 때 특별히 전달 받을 데이터가 없으므로 빈 리스트로 지정한다.

validate 는 값의 유효성을 검사하기 위해 사용한다. 그대로 읽어 보자면 :status attribute_does_not_equal :closed -> :status 속성은 :closed 가 아니어야 한다 로 읽을 수 있을 것 같다. 따라오는 do ~ end 는 이 조건에 해당하지 않으면(즉, invalidate 하다면) 처리할 내용이다. attribute_does_not_equal 은 Built-in function 이다. https://hexdocs.pm/ash/validations.html 페이지에 사용할 수 있는 validation built-in function 목록이 있다. custom validation function 도 만들 수 있다.

change 는 값을 변경할 때 사용한다. 예제 코드는 :status attribute 를 :closed 로 변경한다. validate 와 마찬가지로 Built-in function 들이 준비되어 있고 custom change function 도 만들 수 있다.

다음은 실행 결과이다.

% iex -S mix           

iex(1)> ticket = Helpdesk.Support.Ticket |> Ash.Changeset.for_create(:open, %{subject: "My mouse won't click!"}) |> Ash.create!()

%Helpdesk.Support.Ticket{
  id: "054e9700-03e4-4ec4-8b6b-7964a258e775",
  subject: "My mouse won't click!",
  status: :open,
  __meta__: #Ecto.Schema.Metadata<:built, "">
}

iex(2)> ticket = ticket |> Ash.Changeset.for_update(:close) |> Ash.update!()

%Helpdesk.Support.Ticket{
  id: "054e9700-03e4-4ec4-8b6b-7964a258e775",
  subject: "My mouse won't click!",
  status: :closed,
  __meta__: #Ecto.Schema.Metadata<:built, "">
}

iex(3)> ticket |> Ash.Changeset.for_update(:close) |> Ash.update!()

** (Ash.Error.Invalid) 
Invalid Error

* Invalid value provided for status: Ticket is already closed.

Value: :closed

update 액션이기 때문에 Ash.Changeset.for_update(:close) 로 호출했다. 이어지는 함수 호출도 create!() 가 아닌 Ash.update!() 이다. 이미 :closed 로 변경된 ticket 을 다시 :close 액션을 호출하는 경우 에러가 발생하는 것을 확인할 수 있다.

다음 글 에서는 리소스를 조회하는 방법에 대해 알아보겠다.

Ash Framework 튜토리얼 따라하기 (2)

https://hexdocs.pm/ash/get-started.html 를 따라가며 Ash 프레임워크를 살펴 본다. 이전 글에 이어서 Ticket 리소스를 개선한다.

Helpdesk.Support.Ticket |> Ash.Changeset.for_create(:create) |> Ash.create!() 를 통해 Ticket 데이터를 만들 수 는 있었지만 subject 를 입력할 수도 상태를 지정할 수 도 없었다. 보통 create 함수에 처음 세팅할 값을 전달하거나 create 함수 내부에서 기본 값을 설정하는 등 일정 로직이 필요하다.

먼저 Ticket 리소스의 attribute 들에 몇가지 속성을 추가하자.

# lib/helpdesk/support/ticket.ex

attributes do
  ...
  attribute :subject, :string do
    allow_nil? false

    public? true
  end

  attribute :status, :atom do
    constraints [one_of: [:open, :closed]]

    default :open

    allow_nil? false
  end
end

subject attribute에는 nil 을 허용하지 않는 속성이 추가되었고 public 속성이 이 true 가 되었다. 여기서 public/private 은 REST/GraphQL 등 외부 API로 노출되었을때에 대한 가시성을 의미한다. public 이면 나중에 API 로 접근할 경우 필드로 나타날 것이다. public? 으로 명시하지 않으면 기본적으로 모두 private 이다. 내부적 접근 (예를들어 subject = ticket.subject 같은) 과는 상관이 없다.

status attribute 가 추가되었고 자료형은 atom, 값에 대한 제약조건이 :open, :closed 중 한개로 제약 조건이 붙었다. 기본 값은 :open 이다.

attribute 를 보완했으니 create 액션도 보완할 차례다.

# lib/helpdesk/support/ticket.ex

actions do
  ...
  create :open do
    accept [:subject]
  end
end

create :create 에서 create :open 으로 변경되었다. create 액션의 이름이 :open 로 변경된 것이다. Ash.Changeset.for_create 호출 파라미터도 :open 으로 바뀌어야 한다. accept 은 값을 인자로 전달받을 attribute 를 지정한다. attribute 로 지정되어 있지 않은 이름을 atom 으로 작성하면 거절(컴파일 오류) 된다.

변경된 Ticket 리소스의 형태는 다음과 같다.

defmodule Helpdesk.Support.Ticket do
  use Ash.Resource, domain: Helpdesk.Support

  actions do
    defaults [:read]

    create :open do
      accept [:subject]
    end
  end

  attributes do
    uuid_primary_key :id

    attribute :subject, :string do
      allow_nil? false

      public? false
    end

    attribute :status, :atom do
      constraints one_of: [:open, :closed]

      default :open

      allow_nil? false
    end
  end
end

iex 를 재실행하거나 recompile() 해서 코드를 업데이트 하고 다음과 같이 값을 전달하여 Ticket을 만든다.

iex> recompile()

iex> Helpdesk.Support.Ticket
|> Ash.Changeset.for_create(:open, %{subject: "My mouse won't click!"})
|> Ash.create!()

%Helpdesk.Support.Ticket{
  id: "d29d5348-ff98-4cc2-adbd-bffe29cef64f",
  subject: "My mouse won't click!",
  status: :open,
  __meta__: #Ecto.Schema.Metadata<:built, "">
}

입력 값이 잘못되거나 액션 이름이 잘못되었다면 에러가 발생한다.

iex> ticket = Helpdesk.Support.Ticket|> Ash.Changeset.for_create(:open, %{subject1: "My mouse won't click!"})|> Ash.create!()
** (Ash.Error.Invalid) 
Bread Crumbs:
  > Error returned from: Helpdesk.Support.Ticket.open

Invalid Error

* No such input `subject1` for action Helpdesk.Support.Ticket.open

No such attribute on Helpdesk.Support.Ticket, or argument on Helpdesk.Support.Ticket.open

Did you mean:

* subject


Valid Inputs:

* subject
iex> ticket = Helpdesk.Support.Ticket|> Ash.Changeset.for_create(:open)|> Ash.create!()
** (Ash.Error.Invalid) 
Bread Crumbs:
  > Error returned from: Helpdesk.Support.Ticket.open

Invalid Error

* attribute subject is required

다음 글에서는 레코드의 변경과 검증에 대해 알아 보자.

Ash Framework 튜토리얼 따라하기 (1)

https://hexdocs.pm/ash/get-started.html 을 따라해 보며 Ash 프레임워크의 기본 기능을 배워 본다.

시나리오

간소화된 HelpDesk 시스템을 가정해 보자.

시스템에는 두 개의 리소스가 있다.

  • Helpdesk.Support.Ticket (티켓)
  • Helpdesk.Support.Representative (담당자)

리소스에는 다음과 같은 액션을 할 수 있다.

  • 티켓을 열 수 있다
  • 티켓을 닫을 수 있다
  • 담당자에게 티켓을 배정할 수 있다

Ash 를 사용하여 구현

새 프로젝트 생성

mix 프로젝트가 없다면 새로 만들 수 있다. Ash 만 사용하는 프로젝트도 만들 수 있으나 향후 JSON API 까지 고려해 Phoenix도 같이 설치 한다.

# install the archive
mix archive.install hex phx_new
mix archive.install hex igniter_new

# use the `--with` flag to generate the project with phx.new and add Ash
mix igniter.new helpdesk --install ash,ash_phoenix --with phx.new && cd helpdesk

igniter 는 Elixir 로 만들어진 코드 생성기 이다. 지금 처럼 프레임워크 초반 세팅이나 버전 업그레이드를 할 때 사용할 수 있다.

첫 번째 도메인

먼저 도메인 폴더와 파일을 만든다.

mkdir -p lib/helpdesk/support && touch $_/ticket.ex && touch lib/helpdesk/support.ex

다음과 같은 폴더 구조일 것이다. lib/helpdesk 내부에 support 도메인이 만들어졌다.

lib/helpdesk/support.ex 파일에 다음 코드를 작성한다.

# lib/helpdesk/support.ex

defmodule Helpdesk.Support do
  use Ash.Domain

  resources do
    resource Helpdesk.Support.Ticket
  end
end

Helpdesk.Support 는 Ash.Domain 이고 Helpdesk.Support.Ticket 이라는 Resource 를 가지는 것으로 선언한다.

defmodule Helpdesk.Support.Ticket do
  # 이것은 이 모듈을 리소스로 만듭니다
  use Ash.Resource, domain: Helpdesk.Support

  actions do
    # :read 액션의 기본 구현을 사용합니다
    defaults [:read]
    # 그리고 나중에 커스터마이징할 create 액션
    create :create
  end

  # 속성들은 리소스에 존재하는 간단한 데이터 조각들입니다
  attributes do
    # `:id`라는 자동 생성되는 UUID 기본 키를 추가합니다
    uuid_primary_key :id
    # `:subject`라는 문자열 타입 속성을 추가합니다
    attribute :subject, :string
  end
end

Helpdesk.Support.Ticket 은 Ash.Resource 이고 Helpdesk.Support 도메인에 속한다. 이 리소스를 다루는 몇가지 action 들을 선언할 수 있고 속성 데이터도 정의할 수 있다.

실행

% iex -S mix

iex(1)> Helpdesk.Support.Ticket|> Ash.Changeset.for_create(:create)|> Ash.create!()

Helpdesk.Support.Ticket 리소스를 한 개 만들어 내기 위해 먼저 Changeset 을 만들고 Changeset 을 가지고 Ash.create! 함수를 통해 레코드를 만든다. Ecto.Changeset의 역할을 Ash 에선 Ash.Changeset이 담당하는 것 같다.

warning: Domain Helpdesk.Support is not present in

    config :helpdesk, ash_domains: [].


To resolve this warning, do one of the following.

1. Add the domain to your configured domain modules. The following snippet can be used.

    config :helpdesk, ash_domains: [Helpdesk.Support]

2. Add the option `validate_config_inclusion?: false` to `use Ash.Domain`

3. Configure all domains not to warn, with `config :ash, :validate_domain_config_inclusion?, false`

warning 발생해도 결과는 얻어지지만 warning 을 없애기 위해 다음 코드를 config.exs 에 추가한다.

# in config/config.exs
import Config

config :helpdesk, :ash_domains, [Helpdesk.Support] # 추가
% iex -S mix

iex(1)> Helpdesk.Support.Ticket|> Ash.Changeset.for_create(:create)|> Ash.create!()

%Helpdesk.Support.Ticket{
  id: "7d3d0639-5201-4eb3-9532-a7c837c98eaa",
  subject: nil,
  __meta__: #Ecto.Schema.Metadata<:built, "">
}

도메인과 리소스를 정의하고 몇가지 선언만 추가해도 기본 뼈대가 되는 기능을 얻을 수 있다.

다음 글에서 계속…

여러 SDK 버전을 선택할 수 있는 mise

개발 환경을 갖출 때 필수 과정이 언어의 SDK 를 설치하는 것이다. 보통 환경 구축 시점의 최신 버전을 설치하거나 프로젝트에서 필요한 버전을 선택해 설치한다. 이 때 시스템에 각기 다른 버전의 SDK 를 충돌 없이 변경해 가며 사용하고 싶을 수 있다. 이럴 때 mise 같은 도구를 사용한다.

설치

# 직접 설치
curl https://mise.run | sh

# brew 로 설치
brew install mise

그 외 환경의 설치는 https://mise.jdx.dev/installing-mise.html 를 참고한다.

활성화

설치를 마쳤으면 mise exec 명령어를 통해 프로그램을 실행할 수 도 있지만 쉘에 자동으로 mise 를 활성화 하는 것이 더 편하다. 쉘이 뜰 때마다 activate 를 하도록 쉘 시동 스크립트에 활성화 명령어를 추가한다. 사용하는 쉘 별로 입력해야 할 명령어와 파일이 다르다.

# 직접 설치했다면
echo 'eval "$(~/.local/bin/mise activate zsh)"' >> ~/.zshrc

# brew 로 설치 했다면, 그리고 bash 쉘을 사용한다면
echo 'eval "$(mise activate bash)"' >> ~/.bashrc

실행

다음과 같은 시나리오를 예를 들어 보자.

  • elixir_latest: 현재 개발 환경. elixir 최신 버전. (현 시점 1.18.4)
  • elixir_beta: 앞으로 출시될 버전의 베타 버전. (현 시점 1.19.0-rc.0)

첫번째 개발 환경 부터 만든다.

% mkdir elixir_latest
% cd elixir_latest
% mise use erlang elixir

mise 2025.8.12 by @jdx – install ✓ installed                                    mise ~/elixir_latest/mise.toml tools: erlang@28.0.2, elixir@1.18.4-otp-28

% elixir -v

Erlang/OTP 28 [erts-16.0.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit]

Elixir 1.18.4 (compiled with Erlang/OTP 28)

폴더를 만들고 mise use 명령어를 이용해 필요한 SDK 를 선택한다. 이 때 버전을 명시하지 않으면 latest 버전이 선택된다.

% cat mise.toml 

[tools]
elixir = "latest"
erlang = "latest"

mise use 명령어를 실행하면 mise.toml 파일이 만들어 지는데 이 파일이 mise 문맥을 결정하는 것으로 보인다.

또 다른 개발 환경을 만들자.

% mkdir elixir_beta
% cd elixir_beta
% mise use erlang elixir@1.19.0-rc.0

mise 2025.8.12 by @jdx – install ✓ installed                                    mise ~/elixir_beta/mise.toml tools: erlang@28.0.2, elixir@1.19.0-rc.0

% elixir -v

Erlang/OTP 28 [erts-16.0.2] [source] [64-bit] [smp:8:8] [ds:8:8:10] [async-threads:1] [jit]

Elixir 1.19.0-rc.0 (2a9a4f2) (compiled with Erlang/OTP 26)

% cat mise.toml 

[tools]
elixir = "1.19.0-rc.0"
erlang = "latest"

이 폴더에서 실행되는 elixir 버전은 1.19.0-rc.0 버전이고 mise.toml 에 버전이 명시되어 있음을 확인할 수 있다.


mise 를 사용하면 개발 환경 및 SDK 버전 관리를 쉽게 할 수 있을 것 같다. 본문의 예는 매우 기초적인 사례지만 https://mise.jdx.dev/ 홈페이지를 살펴보면 개발 환경 별 환경변수 관리, lockfile 을 사용한 버전 고정 등 개발 환경 버저닝을 위한 다양한 기능을 제공하고 있으니 한번 살펴보면 좋을 것 같다.

다른 모듈의 기능을 흡수(?) 하는 use

Elixir로 만들어진 프레임워크를 가지고 개발을 할 때 다음과 같이 use 구문을 자주 보게 된다.

defmodule MyAppWeb.DemoLive do
  use Phoenix.LiveView

  ...
end

구문이 익숙하지 않더라도 뭔가 모듈에 해당 프레임워크 기능을 추가? 사용? 한다는 느낌이 든다. use 키워드가 어떤 기능을 하는지 알아 보자.

Elixir School 에서는 use 키워드를 다음과 같이 설명하고 있다.

use 매크로로 현재 모듈의 정의를 다른 모듈이 수정할 수 있게 합니다. 코드에서 use를 호출하면 실제로 제공된 모듈에 의해 정의된 __using__/1 콜백을 호출합니다. __using__/1 매크로의 결과는 모듈 정의의 일부가 됩니다.

사용하는 모듈 입장에서는 use 구문을 통해 외부 모듈이 만들어 놓은 코드(기능)를 내 모듈로 가져오는 효과가 있다. Elixir School 의 예제 코드를 살펴 보자.

defmodule Hello do
  defmacro __using__(_opts) do
    quote do
      def hello(name), do: "Hi, #{name}"
    end
  end
end

어떤 외부 모듈이 미리 만들어 매크로로 hello 라는 기능(함수)을 정의해 놨다.

defmodule Example do
  use Hello
end

Example 모듈에서 Hello 모듈이 만들어 놓은 기능을 사용할 수 있도록 use 구문을 사용했다.

iex> Example.hello("Sean")
"Hi, Sean"

Example 모듈에서 마치 본인 모듈의 기능인 것 처럼 함수를 호출할 수 있다.


use 키워드를 살펴보면서 자바/스프링에서 어노테이션이 자연스럽게 떠올랐다. 자바/스프링 개발 경험이 있다면 데이터 클래스에 @Data 를 붙이거나 컨트롤러에 @RestController 를 붙이는 예를 생각하면 이해가 쉬울 것 같다. 개발자가 프레임워크를 쓰는 이유는 “이미 잘 만들어진 걸 가져다 쓰자”인데, Elixir 에서는 use 키워드가 그걸 가능하게 해 준다.

Elixir: alias vs import

Elixir 에서 모듈이 외부의 다른 모듈 또는 함수를 참조하는 키워드로 aliasimport 가 있다.

defmodule MyApp.Context do
  alias MyApp.Repo
  alias MyApp.Users.User
  
  import Ecto.Query, only: [from: 2, where: 3]
  
  def list_active_users do
    from(u in User, where: u.active == true)
    |> Repo.all()  
  end
end

둘 다 외부 모듈을 참조할수 있게 하는 키워드인 것 같은데 언제 alias 를 쓰고 언제 import 를 쓸까?

alias?

alias 는 이름과 같이 모듈에 별칭을 지어줄 수 있다. 다음은 Elixir School 에서 예로 든 alias 코드이다.

defmodule Sayings.Greetings do
  def basic(name), do: "Hi, #{name}"
end

defmodule Example do
  alias Sayings.Greetings

  def greeting(name), do: Greetings.basic(name)
end

# 별칭을 사용하지 않는 경우

defmodule Example do
  def greeting(name), do: Sayings.Greetings.basic(name)
end

별칭을 사용하지 않으면 모듈의 Namespace 를 정확히 지정(Sayings.Greetings)해야 하지만 alias 를 사용하면 Greetings 로도 해당 모듈에 접근할 수 있다.

아래와 같이 아예 다른 별명을 붙여 사용할 수도 있다.

defmodule Example do
  alias Sayings.Greetings, as: Hi

  def print_message(name), do: Hi.basic(name)
end

import?

import 는 기본적으로 모듈의 모든 함수와 매크로를 불러오지만 일부만 불러올 수 도 있다.

iex> last([1, 2, 3])
** (CompileError) iex:9: undefined function last/1
iex> import List
nil
iex> last([1, 2, 3])
3
iex> import List, only: [last: 1]
iex> first([1, 2, 3])
** (CompileError) iex:13: undefined function first/1
iex> last([1, 2, 3])
3
iex> import List, except: [last: 1]
nil
iex> first([1, 2, 3])
1
iex> last([1, 2, 3])
** (CompileError) iex:3: undefined function last/1

alias 와는 다르게 모듈 이름을 지정하지 않고 함수를 바로 사용할 수 있다.


import 가 모듈 기능을 다 불러올 수 있으면 import 만 쓰면 되지 않나? 하는 의문이 들 수 있다. 그 의문에 대해 Claude Opus 4.1 이 대답 해준 내용이 깔끔해서 일부를 첨부한다.

핵심 차이점

alias: 모듈 이름을 단축하거나 변경

  • 참조시 별칭을 붙여 모듈 이름을 바꿀 수 있음
  • 함수 호출 시 모듈명 필요 (A.function())

import: 함수/매크로를 현재 네임스페이스로 가져옴

  • 모듈명 없이 직접 함수 호출 가능 (function())
  • 컴파일러가 함수 이름을 원본 모듈로 자동 연결

분리한 이유

1. 네임스페이스 오염 방지

# import를 남용하면
import Module1
import Module2
import Module3

some_function() # 어느 모듈의 함수인지 불명확

2. 코드 가독성과 추적성

# alias 사용 - 출처가 명확
alias MyApp.Users.UserService, as: Users
Users.create_user(params)  # 모듈이 명시되어 있음

# import 사용 - 출처가 불명확
import MyApp.Users.UserService
create_user(params)  # 어디서 온 함수인지 즉시 알기 어려움

3. 선택적 가져오기의 유연성

# Ecto.Query의 경우 - DSL처럼 사용하고 싶을 때
import Ecto.Query, only: [from: 2, where: 3]

# 일반 모듈은 alias로 충분
alias MyApp.Accounts
Accounts.get_user(id)

4. 충돌 관리

defmodule MyModule do
  # 두 모듈에 같은 이름의 함수가 있을 때
  alias ModuleA
  alias ModuleB
  
  ModuleA.process(data)  # 명확함
  ModuleB.process(data)  # 명확함
  
  # import 하면 충돌 발생
  # import ModuleA
  # import ModuleB  # process/1 충돌!
end

실무 관례

일반적으로:

  • alias: 대부분의 모듈 참조에 사용
  • import: DSL 스타일 API (Ecto.Query 등)나 자주 사용하는 헬퍼 함수에만 제한적 사용
defmodule MyApp.Context do
  # 모듈은 alias
  alias MyApp.Repo
  alias MyApp.Users.User
  
  # DSL은 import
  import Ecto.Query, only: [from: 2, where: 3]
  
  def list_active_users do
    from(u in User, where: u.active == true)
    |> Repo.all()  # Repo는 alias로 참조
  end
end

alias 는 사용처에서 이름도 직접 지정할 수 있고 기본적으로 모듈 이름을 명시적으로 사용해야 하는 반면 import 는 참조에 있어서 더 유연한 기능을 제공하지만 모듈 이름을 사용하지 않기 때문에 alias 에 비해 좀 더 암묵적인 특성이 있다. 기본적으로 alias 를 주로 사용하겠지만 상황에 따라 DSL 같이 함수 이름 자체가 특정 모듈을 확정할 수 있다면 import 를 통해 코드 축약을 기대할 수 있을 것 같다.