본문 바로가기
Back-end

Rails ActiveRecord에서 after_*_commit이 의도대로 동작하지 않나요?

by 노아론 2022. 5. 22.

Ruby on Rails의 ActiveRecord는 레코드가 생성, 변경, 제거되었을 때 동작하거나 데이터베이스에 커밋이 일어난 이후에 동작할 수 있는 콜백을 지원한다.
콜백으로 레코드의 라이프 사이클을 편하게 관리할 수 있어 백그라운드 작업을 처리할 때 많이 사용한다.

그런데 after_*_commit 을 여러개 정의하다보면 때로는 개발자의 의도와 다르게 특정 콜백이 동작하지 않는 것을 볼 수 있다.
특정 콜백이 동작하지 않음을 마주했다면 아마도 정의한 콜백이 override되었기 때문이라고 짐작된다.

이 글에서는 after_*_commit을 여러개 정의했을 때 특정 콜백이 동작하지 않은 이유를 설명하고 대안을 함께 소개하고자 한다.

우선은 after_*_commit 에 대해 다루기 전에, 주요 메소드이면서 내부 동작에서 사용되는 after_commit 을 먼저 설명해본다.

아래처럼 ActiveRecord 타입의 Dog 클래스를 정의하였다.

class Dog < ActiveRecord::Base
  after_commit :add_to_index_later, on: :create
  after_commit :update_in_index_later, on: :update
  after_commit :remove_from_index_later, on: :destroy
end

Dog 레코드가 생성되면 콜백에 의해서 add_to_index_later 메소드가 수행이 된다.

업데이트 된다면 update_in_index_later가 수행되고, 제거된다면 remove_from_index_later 메소드가 수행된다.
이 콜백을 on옵션을 붙여서 어떤 동작(생성, 변경, 제거)에서 수행할 것인지 정의할 수 있다.


2015년도 12월에 after_commit 콜백에 대해서 명확하게 표현할 수 있는 alisas 메소드인 after_create_commit, after_update_commit, after_destroy_commit 를 Ruby on Rails 를 만든 David Heinemeier Hansson가 제안했다.

https://github.com/rails/rails/issues/22515



그리고 이슈를 본 불가리아의 한 개발자가 바로 구현해서 풀리퀘스트를 올렸고.. 당일에 리뷰까지 완료되어 바로 머지가 된 메소드다.

after_*_commit 메소드는 Rails 5.0.0 릴리즈부터 사용할 수 있다.
https://github.com/rails/rails/pull/22516

Rails 5.0.0부터는 위의 ActiveRecord 타입의 Dog 클래스를 아래처럼 나타낼 수 있다.

class Dog < ActiveRecord::Base
  after_create_commit :add_to_index_later
  after_update_commit :update_in_index_later
  after_destroy_commit :remove_from_index_later
end

 

after_*_commit은 내부적으로 after_commit 과 동일하게 처리하는데

# ActiveRecord::Transactions::ClassMethods

      def after_commit(*args, &block)
        set_options_for_callbacks!(args)
        set_callback(:commit, :after, *args, &block)
      end

      def after_save_commit(*args, &block)
        set_options_for_callbacks!(args, on: [ :create, :update ])
        set_callback(:commit, :after, *args, &block)
      end

      def after_create_commit(*args, &block)
        set_options_for_callbacks!(args, on: :create)
        set_callback(:commit, :after, *args, &block)
      end

      def after_update_commit(*args, &block)
        set_options_for_callbacks!(args, on: :update)
        set_callback(:commit, :after, *args, &block)
      end

      def after_destroy_commit(*args, &block)
        set_options_for_callbacks!(args, on: :destroy)
        set_callback(:commit, :after, *args, &block)
      end

      private

      def set_options_for_callbacks!(args, enforced_options = {})
        # options = args.last
        # if options.is_a?(Hash) && options[:on]
        # 이전까지 처리되던 방식
        options = args.extract_options!.merge!(enforced_options)
        args << options

        if options[:on]
          fire_on = Array(options[:on])
          assert_valid_transaction_action(fire_on)
          options[:if] = Array(options[:if])
          options[:if] << "transaction_include_any_action?(#{fire_on})"
        end
      end

after_create_commit 기준으로 설명한다면
set_options_for_callbacks private 메소드를 호출 할 때 {on: :create}을 넘긴다.

이전까지는 args의 마지막 요소를 options으로 두었다면, after_*_commit 동작을 위해서 args 에서 options 파라미터를 추출하고 enforced_options 파라미터와 병합을 하는 방식으로 변경되었다.

그리고는 이전과 동일하게 args 내의 Hash 타입의 options 변수에 transaction_include_any_action?(create) 를 붙이도록 처리한다.

이렇게 내부적으론 after_commit으로 처리되다보니 after_*_commit 메소드를 쓸 때 가끔은 의도와 다르게 동작되는 것을 볼 수 있다.

Dog 객체가 생성되거나 변경될 때 bark 를 출력하는 것을 고려해 아래와 같이 작성하였다.

class Dog < ActiveRecord::Base
  after_create_commit :puts_bark
  after_update_commit :puts_bark
end

 

코드만 보았을 때는 create commit과 update commit이 발생할 때 puts_bark 메소드가 호출될 거라고 생각할 수 있다.

하지만 실제 동작은 변경시에만 puts_bark 메소드가 호출되고 생성 시에는 콜백이 일어나지 않는다.

이런 이유는 내부적인 동작에서 이전의 선언을 override 하는 동작으로 처리되기 때문이다.

  set_options_for_callbacks!(args, on: :create)
  set_callback(:commit, :after, *args, &block)

  set_options_for_callbacks!(args, on: :update)
  set_callback(:commit, :after, *args, &block)

 

따라서 이런 동작을 방지하기 위해서는 아래처럼 after_commit을 이용하는 것이 더 직관적일 수 있다.

class Dog < ActiveRecord::Base
  after_commit :puts_bark, on: [:create, :update]
end

 

Dog 객체를 담당하던 개발자는 강아지가  태어나서 2주에서 3주 정도 뒤에 발성을 하는 사실을 알게 되었다.
그래서 이 점을 고려해 조건문을 추가하기로 했다

class Dog < ActiveRecord::Base
  after_create_commit :puts_bark, if -> { weeks > 2 }
  after_update_commit :puts_bark, if -> { weeks > 3 }
end

 

생성할 땐 weeks값이 2보다 커야 하고, 변경할 땐 weeks값이 3보다 커야 puts_bark 메소드가 호출되게 만들고자 한다.
그러나 생성할 때의 콜백을 설정한 내용은 after_update_commit 의 과정에서 override가 되어 동작하지 않는다.

위에서 진행한 방법대로 on옵션을 이용해 after_commit 으로 표현하려고 했지만 이런 내용을 담을 때는 아래처럼 표현할 수 없다.
update할 때 콜백이 수행되는 조건은 weeks 가 3보다 클 때이기 때문이다.

class Dog < ActiveRecord::Base
  after_commit :puts_bark, if -> { weeks > 2 }, on: [:create, :update]
end

 

이러한 경우에선 아래처럼 메소드를 정의해 내부에서 조건 분기 처리를 하여 진행할 수 있다.

class Dog < ActiveRecord::Base
  after_create_commit :puts_bark_with_create
  after_update_commit :puts_bark_with_update


  private

  def puts_bark_with_create
    puts_bark if weeks > 2
  end  

  def puts_bark_with_update
    puts_bark if weeks > 3
  end  
end

댓글