Usage of Ecto Multi (1)

posted by donghyun

4 min read

태그

Elixir로 서버개발 시작한지 한달.. 요즘은 Ecto와 씨름하는중이다.

Usage of Ecto Multi (1)

웹 요청이 들어왔을 때 한 트랜잭션 안에 수행되어야 할 것들이 있다. 예를 들어 Post와 Comment가 one-to-many 관계로 있다면 Post를 삭제할 때 연관된 Comment들도 같이 삭제되는 식이다. 예시는 간단하지만 실제 비즈니스 로직은 꽤 복잡한 경우가 허다하다.

요즘 주로 고민하는건 트랜잭션 처리에서 조건이 들어가는 경우이다. 예를 들어 User가 Post들을 가지고 있다고 했을 때 User 스키마에 대표 Post의 title 필드를 캐싱하는 display_post 필드란 녀석이 들어가 있다. 근데 그 Post을 변경하는 컨텍스트 함수가 있다. 이 때 그냥 post의 description을 바꾸는건 update하나면 충분하고, title 컬럼을 바꾸면 연관된 User를 찾아 display_post 필드를 변경해주는게 한 트랜잭션으로 묶여야 한다. 나는 일단 먼저 다음과 같이 풀었다.

defmodule Test.MyContext do
  # .. some ceremony code

  def update_post(post, params) do
    uc = Post.update_changeset(post, params)

    Multi.new()
    |> Multi.update(:update_post, uc, post.author_id)
    |> update_related_user(uc, user)
    # ...
    |> Repo.transaction()
    |> case do
      {:ok, %{update_post: post}} -> {:ok, post}
      {:error, _, _, _} -> {:error, "fail to update post"}
    end
  end

  # title이 변경되는 changeset이라면
  defp update_related_user(multi, Ecto.Changeset{changes: %{title: title}} = changeset, user_id) do
    user = Repo.get(User, user_id)
    user_changeset = User.update_changeset(user, %{display_post: title})
    Multi.update(:update_user, user_changeset)
  end

  # 그 외엔 그냥 그대로 반환
  defp update_related_user(multi, _, _), do: multi
end

Multi.t()를 받아 Multi.t()를 반환하는 내부 함수를 만들어서, changeset을 인자로 받아서 changeset의 변경사항 changes 에 title이 포함되어 있으면 추가 multi를 수행하고, 아니라면 그대로 반환하는 것이다. params를 받지않고 굳이 changeset을 받은 이유는 title이 변경될 경우에만 user update를 수행하기 위해서다.

현재 내 수준에서 그렇게 나쁘게 보이진 않지만 못내 불만족스럽다. changeset을 불필요하게 건네주는것같기도 하고 defp 함수들이 그렇게 깔끔하게 잘 나오지 않는 느낌이다. 이게 괜찮은 방법인지 확신이 서질 않는다. 근데 하긴 요즘 짜는 엘릭서 코드들은 다 그런 생각이 들긴 한다. 본격적으로 엘릭서 코드짠지는 두달도 안됐으니..

다르게 풀면 어떻게 풀수 있을까? 다른 방식을 생각했던건 multi를 계속 받아서 넘기는게 아니라 multi자체를 만들어서 결합하는 방식이다. Ecto.Multi에는 append/2, prepend/2, merge/2 같은 함수들이 있다. 만들어진 multi들을 손쉽게 결합 가능하다.

대충 다음과 같지 않을까 싶다.

def update_complex_function(struct, params) do
  changeset = update_changeset(struct, params)
  update_post_multi = update_post_multi(changeset)
  user_update_multi = update_user_by_post_multi(changeset)
  do_something = do_something_multi()
  # ...

  Multi.new()
  |> Multi.append(update_a_multi)
  |> Multi.append(some_extra_update)
  |> Multi.append(do_something)
  |> Repo.transaction()
end

defp update_post_multi(changeset) do
  Multi.new()
  |> Multi.update(:update_post, changeset)
end

좀더 깔끔한거 같기도? 이제 조건문을 어떻게 넣을것인가.. 똑같은 고민이 발생했다. 결국 내부 함수로 풀어줘야 할거같다.

defp update_user_by_post_multi(Ecto.Changeset{data: %Post{}, changes: %{title: title}} = changeset) do
  user_changeset = #...
  Multi.new()
  |> Multi.update(:update_user, user_changeset)
end

defp update_user_by_post_multi(_), do: Multi.new()

굳이 multi를 반환할 필요 없을지도, 그냥 nil을 반환하고 걸러줘도 될것같다. 어차피 Enum.reduce를 사용할 것이다. update_complex_function을 고쳐보자

def update_complex_function(struct, params) do
  changeset = update_changeset(struct, params)
  update_post_multi = update_post_multi(changeset)
  user_update_multi = update_user_by_post_multi(changeset)
  do_something_multi = do_something_multi()
  # ...

  # 위 함수들이 nil을 반환하게 한다면 Enum.reject로 먼저 걸러줘도 될듯
  Enum.reduce([
      update_post_multi,
      user_update_multi,
      do_something_multi
    ], Multi.new(), &Multi.append(&2, &1))
  |> Repo.transaction()
end

뭐 역시 나쁘지는 않는데, 가독성, 방어적인 코드 측면에서 이 방법이 맞는지는 역시 아직도 경험 부족이다. 그냥 다양한 방법이 있고 지금은 시행착오를 겪는 시기인듯 싶다. 지금 비즈니스 로직은 그냥 첫번째 방법을 주로 써서 만들었는데, 빨리 좀 깔끔하고 마음에 드는 방법을 찾을 수 있으면 좋겠다.