Do you want to pick up from where you left of?
Take me there

어소시에이션

이 단원에서는 Ecto로 스키마 사이의 어소시에이션(Associations)을 정의하는 방법을 배워보겠습니다.

준비

이전 단원의 데모 앱 Friends에서 시작하겠습니다. 준비를 위해 빠르게 복습이 필요하면 여기를 참고하세요.

어소시에이션들의 타입

스키마 간에 정의할 수 있는 어소시에이션의 종류는 3가지입니다. 각 관계가 무엇이고 어떻게 구현하는지 살펴보겠습니다.

종속(Belongs To) 관계/일대다(Has Many) 관계

즐겨보는 영화의 카탈로그를 만들 수 있도록 Friends 앱의 도메인 모델에 몇 가지 새로운 엔티티를 추가합니다. 우선 MovieCharacter 두 스키마를 추가합니다. 이 스키마들 사이에 “일대다/종속” 관계를 구현할 것인데, 영화(movie)는 복수의 등장인물(character)을 가지며 등장인물 하나는 한 영화에 종속되도록 합니다.

일대다 관계 마이그레이션

Movie의 마이그레이션을 생성합니다.

mix ecto.gen.migration create_movies

새로 생성된 마이그레이션 파일을 열고 change 함수를 선언하여 몇몇 속성을 가진 movies 테이블을 만들도록 합니다.

# priv/repo/migrations/*_create_movies.exs
defmodule Friends.Repo.Migrations.CreateMovies do
  use Ecto.Migration

  def change do
    create table(:movies) do
      add :title, :string
      add :tagline, :string
    end
  end
end

일대다 관계 스키마

영화와 등장인물 사이에 “일대다” 관계를 지정하는 스키마를 추가합니다.

# lib/friends/movie.ex
defmodule Friends.Movie do
  use Ecto.Schema

  schema "movies" do
    field :title, :string
    field :tagline, :string
    has_many :characters, Friends.Character
  end
end

has_many/3 매크로는 데이터베이스 자체에 아무것도 추가하지 않습니다. 그저 연관된 스키마인 characters의 외래키를 이용해 영화에 연관된 등장인물을 이용할 수 있게 합니다. 즉 movie.characters처럼 사용할 수 있습니다.

종속 관계 마이그레이션

이제 Character 마이그레이션과 스키마를 만들 준비가 되었습니다. 한 등장인물은 한 영화에 종속되므로 이 관계를 나타내는 마이그레이션과 스키마를 정의합니다.

먼저 마이그레이션을 생성합니다.

mix ecto.gen.migration create_characters

영화에 종속되는 등장인물을 정의하기 위해서는, movie_id 컬럼을 가지는 characters 테이블이 필요합니다. 이 컬럼은 외래 키로써 동작해야 합니다. 이를 위해 create table/1 함수에 다음 한 줄을 추가하면 됩니다.

add :movie_id, references(:movies)

그러면 마이그레이션이 다음과 같습니다.

# priv/migrations/*_create_characters.exs
defmodule Friends.Repo.Migrations.CreateCharacters do
  use Ecto.Migration

  def change do
    create table(:characters) do
      add :name, :string
      add :movie_id, references(:movies)
    end
  end
end

종속 관계 스키마

마찬가지로 스키마는 등장인물과 영화 사이의 “종속” 관계를 정의해야 합니다.

# lib/friends/character.ex

defmodule Friends.Character do
  use Ecto.Schema

  schema "characters" do
    field :name, :string
    belongs_to :movie, Friends.Movie
  end
end

belongs_to/3 매크로가 무엇을 하는지 자세히 보겠습니다. 스키마에 외래 키 movie_id를 추가 하는 것 외에도, 이 매크로는 characters를 통해 연관된 movies 스키마에 접근하는 기능을 제공합니다. 외래 키를 사용하여 등장인물을 쿼리할 때 등장인물과 관련된 영화를 이용할 수 있습니다. 즉 character.movie 처럼 사용할 수 있습니다.

이제 마이그레이션을 실행할 준비가 되었네요.

mix ecto.migrate

종속 관계/일대일(Has One) 관계

한 영화에 한 배급사가 있다고 가정해보겠습니다. 예를 들어 Netflix는 “Bright” 영화의 배급사입니다.

“종속” 관계로 Distributor 마이그레이션 및 스키마를 정의할 것입니다. 우선 마이그레이션을 생성해 보겠습니다.

mix ecto.gen.migration create_distributors

생성한 distributors 테이블 마이그레이션에 외래 키 movie_id와 영화의 배급사가 1명인 것을 나타내는 유니크 인덱스를 추가할 필요가 있습니다.

# priv/repo/migrations/*_create_distributors.exs

defmodule Friends.Repo.Migrations.CreateDistributors do
  use Ecto.Migration

  def change do
    create table(:distributors) do
      add :name, :string
      add :movie_id, references(:movies)
    end
    
    create unique_index(:distributors, [:movie_id])
  end
end

그리고 Distributor 스키마는 belongs_to/3 매크로를 사용하여 distributor.movie를 호출했을 때 위에서의 외래 키를 가지고 한 배급사와 관련된 영화를 조회할 수 있도록 해야 합니다.

# lib/friends/distributor.ex

defmodule Friends.Distributor do
  use Ecto.Schema

  schema "distributors" do
    field :name, :string
    belongs_to :movie, Friends.Movie
  end
end

다음으로 Movie 스키마에 “일대일” 관계를 추가하겠습니다.

# lib/friends/movie.ex

defmodule Friends.Movie do
  use Ecto.Schema

  schema "movies" do
    field :title, :string
    field :tagline, :string
    has_many :characters, Friends.Character
    has_one :distributor, Friends.Distributor # I'm new!
  end
end

has_one/3 매크로는 has_many/3와 유사한 기능입니다. 연관 스키마의 외래키로 해당 영화의 배급사를 찾아서 보여줍니다. movie.distributor처럼 쓸 수 있습니다.

마이그레이션을 실행해봅시다.

mix ecto.migrate

다대다 관계

한 영화가 많은 배우를 가지고 있고 한 배우는 여러 영화에 속해있다고 해봅시다. 이 관계를 구현하려면 영화와 배우를 둘 다 참조하는 조인 테이블을 만들어야 합니다.

우선 Actors 마이그레이션을 만듭니다.

mix ecto.gen.migration create_actors

마이그레이션을 정의합니다.

# priv/migrations/*_create_actors.ex

defmodule Friends.Repo.Migrations.CreateActors do
  use Ecto.Migration

  def change do
    create table(:actors) do
      add :name, :string
    end
  end
end

조인 테이블 마이그레이션을 생성합시다.

mix ecto.gen.migration create_movies_actors

마이그레이션에 두 개의 외래키를 가진 테이블을 정의했습니다. 또한 유니크 인덱스를 추가해서 한 배우는 한 영화와 하나의 쌍으로만 묶이도록 합니다.

# priv/migrations/*_create_movies_actors.ex

defmodule Friends.Repo.Migrations.CreateMoviesActors do
  use Ecto.Migration

  def change do
    create table(:movies_actors) do
      add :movie_id, references(:movies)
      add :actor_id, references(:actors)
    end

    create unique_index(:movies_actors, [:movie_id, :actor_id])
  end
end

그 다음 many_to_many 매크로를 Movie 스키마에 추가합시다.

# lib/friends/movie.ex

defmodule Friends.Movie do
  use Ecto.Schema

  schema "movies" do
    field :title, :string
    field :tagline, :string
    has_many :characters, Friends.Character
    has_one :distributor, Friends.Distributor
    many_to_many :actors, Friends.Actor, join_through: "movies_actors" # I'm new!
  end
end

마지막으로 Actor 스키마에도 역시 many_to_many 매크로를 정의합니다.

# lib/friends/actor.ex

defmodule Friends.Actor do
  use Ecto.Schema

  schema "actors" do
    field :name, :string
    many_to_many :movies, Friends.Movie, join_through: "movies_actors"
  end
end

마이그레이션을 실행합니다.

mix ecto.migrate

연관 데이터 저장

어떤 레코드를 연관 데이터와 함께 저장하는 방법은 어떤 관계 속성이냐에 달려있습니다. “종속 관계/일대다 관계”부터 시작해봅시다.

종속 관계

Ecto.build_assoc/3 이용한 저장

“종속 관계”에서는 Ecto의 build_assoc/3 함수를 사용할 수 있습니다.

build_assoc/3는 다음 3가지 인자를 받습니다.

영화 하나와 연관된 등장인물을 저장해봅시다. 먼저 영화 레코드를 하나 생성합니다.

iex> alias Friends.{Movie, Character, Repo}
iex> movie = %Movie{title: "Ready Player One", tagline: "Something about video games"}

%Friends.Movie{
  __meta__: %Ecto.Schema.Metadata<:built, "movies">,
  actors: %Ecto.Association.NotLoaded<association :actors is not loaded>,
  characters: %Ecto.Association.NotLoaded<association :characters is not loaded>,
  distributor: %Ecto.Association.NotLoaded<association :distributor is not loaded>,
  id: nil,
  tagline: "Something about video games",
  title: "Ready Player One"
}

iex> movie = Repo.insert!(movie)

이제 연관된 등장인물을 만들고 데이터베이스에 삽입합니다.

iex> character = Ecto.build_assoc(movie, :characters, %{name: "Wade Watts"})
%Friends.Character{
  __meta__: %Ecto.Schema.Metadata<:built, "characters">,
  id: nil,
  movie: %Ecto.Association.NotLoaded<association :movie is not loaded>,
  movie_id: 1,
  name: "Wade Watts"
}
iex> Repo.insert!(character)
%Friends.Character{
  __meta__: %Ecto.Schema.Metadata<:loaded, "characters">,
  id: 1,
  movie: %Ecto.Association.NotLoaded<association :movie is not loaded>,
  movie_id: 1,
  name: "Wade Watts"
}

Movie 스키마의 has_many/3 매크로가 한 영화가 여러 :characters를 가지고 있다고 명시하기 때문에 build_assoc/3의 두 번째 인자로 :characters를 넘긴다는걸 기억하세요. 생성한 등장인물이 그와 연관된 영화의 ID인 move_id가 올바르게 설정되었음을 알 수 있습니다.

한 영화의 연관 배급사를 build_assoc/3 으로 저장하기 위해 영화의 배급사 관계 이름을 두 번째 인자로 넘깁니다.

iex> distributor = Ecto.build_assoc(movie, :distributor, %{name: "Netflix"})
%Friends.Distributor{
  __meta__: %Ecto.Schema.Metadata<:built, "distributors">,
  id: nil,
  movie: %Ecto.Association.NotLoaded<association :movie is not loaded>,
  movie_id: 1,
  name: "Netflix"
}
iex> Repo.insert!(distributor)
%Friends.Distributor{
  __meta__: %Ecto.Schema.Metadata<:loaded, "distributors">,
  id: 1,
  movie: %Ecto.Association.NotLoaded<association :movie is not loaded>,
  movie_id: 1,
  name: "Netflix"
}

다대다 관계

Ecto.Changeset.put_assoc/4 이용한 저장

다대다 관계에는 build_assoc/3를 쓰지 않습니다. 영화나 배우 테이블 둘 다 외래키가 없기 떄문입니다. 그 대신, Ecto Changeset과 put_assoc/4 함수를 이용하겠습니다.

위에서 생성한 영화 레코드를 사용한다고 가정하고, 배우 레코드를 생성합시다.

iex> alias Friends.Actor
iex> actor = %Actor{name: "Tyler Sheridan"}
%Friends.Actor{
  __meta__: %Ecto.Schema.Metadata<:built, "actors">,
  id: nil,
  movies: %Ecto.Association.NotLoaded<association :movies is not loaded>,
  name: "Tyler Sheridan"
}
iex> actor = Repo.insert!(actor)
%Friends.Actor{
  __meta__: %Ecto.Schema.Metadata<:loaded, "actors">,
  id: 1,
  movies: %Ecto.Association.NotLoaded<association :movies is not loaded>,
  name: "Tyler Sheridan"
}

이제 영화를 조인 테이블을 통해 배우와 연결할 준비가 됐습니다.

우선 유의해야 할 점은 체인지셋을 사용하려면 movie 구조체가 연관 데이터를 프리로드한 상태여야 합니다. 데이터를 프리로드하는 건 추후에 자세히 다뤄보겠습니다. 일단 지금은 다음과 같이 어소시에이션들을 프리로드할 수 있다는 것만 알아두세요.

iex> movie = Repo.preload(movie, [:distributor, :characters, :actors])
%Friends.Movie{
 __meta__: #Ecto.Schema.Metadata<:loaded, "movies">,
  actors: [],
  characters: [
    %Friends.Character{
      __meta__: #Ecto.Schema.Metadata<:loaded, "characters">,
      id: 1,
      movie: #Ecto.Association.NotLoaded<association :movie is not loaded>,
      movie_id: 1,
      name: "Wade Watts"
    }
  ],
  distributor: %Friends.Distributor{
    __meta__: #Ecto.Schema.Metadata<:loaded, "distributors">,
    id: 1,
    movie: #Ecto.Association.NotLoaded<association :movie is not loaded>,
    movie_id: 1,
    name: "Netflix"
  },
  id: 1,
  tagline: "Something about video game",
  title: "Ready Player One"
}

다음은 영화 레코드의 체인지셋을 생성합니다.

iex> movie_changeset = Ecto.Changeset.change(movie)
%Ecto.Changeset<action: nil, changes: %{}, errors: [], data: %Friends.Movie<>,
 valid?: true>

이제 Ecto.Changeset.put_assoc/4에 그 체인지셋을 첫번째 인자로 전달합니다.

iex> movie_actors_changeset = movie_changeset |> Ecto.Changeset.put_assoc(:actors, [actor])
%Ecto.Changeset<
  action: nil,
  changes: %{
    actors: [
      %Ecto.Changeset<action: :update, changes: %{}, errors: [],
       data: %Friends.Actor<>, valid?: true>
    ]
  },
  errors: [],
  data: %Friends.Movie<>,
  valid?: true
>

이것은 새로운 체인지셋을 반환하는데, 주어진 영화 레코드의 actors 필드에 배우 목록을 추가하는 변경사항을 보여줍니다.

마지막으로 주어진 영화와 배우 레코드를 최신 체인지셋을 사용해 업데이트합니다.

iex> Repo.update!(movie_actors_changeset)
%Friends.Movie{
  __meta__: #Ecto.Schema.Metadata<:loaded, "movies">,
  actors: [
    %Friends.Actor{
      __meta__: #Ecto.Schema.Metadata<:loaded, "actors">,
      id: 1,
      movies: #Ecto.Association.NotLoaded<association :movies is not loaded>,
      name: "Tyler Sheridan"
    }
  ],
  characters: [
    %Friends.Character{
      __meta__: #Ecto.Schema.Metadata<:loaded, "characters">,
      id: 1,
      movie: #Ecto.Association.NotLoaded<association :movie is not loaded>,
      movie_id: 1,
      name: "Wade Watts"
    }
  ],
  distributor: %Friends.Distributor{
    __meta__: #Ecto.Schema.Metadata<:loaded, "distributors">,
    id: 1,
    movie: #Ecto.Association.NotLoaded<association :movie is not loaded>,
    movie_id: 1,
    name: "Netflix"
  },
  id: 1,
  tagline: "Something about video game",
  title: "Ready Player One"
}

이렇게 하면 영화 레코드에 새 배우 레코드가 적절히 연결되고 movie.actors에 프리로드된 상태로 보여집니다.

같은 방식으로 주어진 영화에 연결된 또 다른 배우를 생성 할 수 있습니다. 미리 저장된 배우를 put_assoc/4에 넘기는 대신, 생성하고자 하는 새 배우의 속성들이 들어있는 맵을 전달해도 됩니다.

iex> changeset = movie_changeset |> Ecto.Changeset.put_assoc(:actors, [%{name: "Gary"}])
%Ecto.Changeset<
  action: nil,
  changes: %{
    actors: [
      %Ecto.Changeset<
        action: :insert,
        changes: %{name: "Gary"},
        errors: [],
        data: %Friends.Actor<>,
        valid?: true
      >
    ]
  },
  errors: [],
  data: %Friends.Movie<>,
  valid?: true
>
iex>  Repo.update!(changeset)
%Friends.Movie{
  __meta__: %Ecto.Schema.Metadata<:loaded, "movies">,
  actors: [
    %Friends.Actor{
      __meta__: %Ecto.Schema.Metadata<:loaded, "actors">,
      id: 2,
      movies: %Ecto.Association.NotLoaded<association :movies is not loaded>,
      name: "Gary"
    }
  ],
  characters: [],
  distributor: nil,
  id: 1,
  tagline: "Something about video games",
  title: "Ready Player One"
}

새 배우가 ID “2”로 생성되었고 지정했던 속성들이 잘 들어있습니다.

다음 섹션에서는 연관된 레코드를 어떻게 쿼리하는지 배워보겠습니다.

강의에 실수가 있거나 기여하고 싶은 부분이 있으신가요? GitHub에서 이 강의를 수정해보세요!