rubyonrails.org에서 더 보기:

다음은 한국어 번역입니다:

이 파일을 GITHUB에서 읽지 마세요. 가이드는 https://guides.rubyonrails.org 에서 제공됩니다.

Active Record Callbacks

이 가이드는 Active Record 객체의 생명주기에 연결하는 방법을 알려줍니다.

이 가이드를 읽은 후에는 다음을 알 수 있습니다:

  • Active Record 객체의 생명주기 동안 특정 이벤트가 발생하는 시점
  • 이러한 이벤트에 응답하는 callback을 등록, 실행 및 건너뛰는 방법
  • 관계형, 연관, 조건부 및 트랜잭션 callback을 만드는 방법
  • callback에 대한 공통 동작을 캡슐화하는 재사용 가능한 객체를 만드는 방법

1 객체 생명주기

Rails 애플리케이션의 일반적인 동작 중에 객체는 생성, 수정 및 삭제될 수 있습니다. Active Record는 이러한 객체 생명주기에 대한 hook을 제공하여 애플리케이션과 데이터를 제어할 수 있게 합니다.

Callback을 사용하면 객체의 상태 변경 전후에 로직을 실행할 수 있습니다. 이는 객체의 생명주기의 특정 시점에 호출되는 메서드입니다. Callback을 통해 Active Record 객체가 초기화, 생성, 저장, 업데이트, 삭제, 유효성 검사되거나 데이터베이스에서 로드될 때마다 실행되는 코드를 작성할 수 있습니다.

class BirthdayCake < ApplicationRecord
  after_create -> { Rails.logger.info("축하합니다, callback이 실행되었습니다!") }
end
irb> BirthdayCake.create
축하합니다, callback이 실행되었습니다!

보시다시피 많은 라이프사이클 이벤트가 있으며, 이러한 이벤트의 전(before), 후(after), 또는 이벤트를 감싸는(around) 여러 가지 훅 옵션이 있습니다.

2 Callback 등록

사용 가능한 callback을 활용하려면, 이를 구현하고 등록해야 합니다. 구현은 일반 method, block, proc을 사용하거나, class나 module을 사용하여 custom callback object를 정의하는 등 다양한 방법으로 할 수 있습니다. 이러한 각각의 구현 기법들을 살펴보겠습니다.

일반 method를 호출하는 macro-style class method를 사용하여 callback을 등록할 수 있습니다.

class User < ApplicationRecord
  validates :username, :email, presence: true

  before_validation :ensure_username_has_value

  private
    def ensure_username_has_value
      if username.blank? 
        self.username = email
      end
    end
end

위 예제에서, username이 비어있으면 validation이 실행되기 전에 email 값을 username으로 복사합니다. 이로써 validation이 실행될 때 username이 존재하게 됩니다.

macro-style 클래스 메서드는 블록을 받을 수도 있습니다. 블록 내부의 코드가 한 줄에 들어갈 만큼 짧은 경우 이 스타일을 사용하는 것을 고려해보세요.

class User < ApplicationRecord
  validates :username, :email, presence: true

  # validation 이전에 실행
  before_validation do
    # username이 비어있으면 email 값을 username에 할당
    self.username = email if username.blank?
  end
end

또는 트리거될 콜백에 proc을 전달할 수 있습니다.

class User < ApplicationRecord
  validates :username, :email, presence: true

  # username이 비어있는 경우, validation 전에 username을 email로 설정
  before_validation ->(user) { user.username = user.email if user.username.blank? }
end

마지막으로, 아래와 같이 custom callback object를 정의할 수 있습니다. 이에 대해서는 나중에 자세히 다룰 것입니다.

class User < ApplicationRecord 
  validates :username, :email, presence: true

  before_validation AddUsername 
end

class AddUsername
  def self.before_validation(record)
    if record.username.blank? 
      record.username = record.email
    end
  end
end

상기 코드에서는 AddUsername class를 before_validation 콜백으로 사용합니다. 만약 username 항목이 없다면 email을 username으로 설정합니다. callback 클래스는 콜백 메서드로 동일한 이름을 가진 클래스 메서드를 구현해야 합니다.

2.1 생명주기 이벤트에서 발생하는 Callback 등록하기

Callback은 특정 생명주기 이벤트에서만 발생하도록 등록할 수 있습니다. 이는 :on 옵션을 사용하여 수행할 수 있으며, callback이 언제 어떤 context에서 실행될지 완전히 제어할 수 있습니다.

Context는 특정 validation을 적용하고자 하는 카테고리나 시나리오와 같습니다. ActiveRecord 모델을 validation할 때, validation을 그룹화하기 위한 context를 지정할 수 있습니다. 이를 통해 서로 다른 상황에서 적용되는 다양한 validation 세트를 가질 수 있습니다. Rails에서는 :create, :update, :save와 같은 validation을 위한 기본 context가 있습니다.

class User < ApplicationRecord
  validates :username, :email, presence: true

  before_validation :ensure_username_has_value, on: :create

  # :on은 배열도 받을 수 있습니다  
  after_validation :set_location, on: [ :create, :update ]

  private
    def ensure_username_has_value
      if username.blank?
        self.username = email
      end
    end

    def set_location
      self.location = LocationService.query(self)
    end
end

callback 메서드를 private로 선언하는 것이 좋은 관행으로 여겨집니다. public으로 두면 모델 외부에서 호출될 수 있으며 객체 캡슐화 원칙을 위반하게 됩니다.

callback 메서드 내에서 update, save 또는 객체에 부작용을 일으키는 다른 메서드들의 사용을 자제하세요.

예를 들어, callback 내에서 update(attribute: "value")를 호출하는 것을 피하세요. 이러한 방식은 모델의 상태를 수정하고 커밋 중에 예상치 못한 부작용을 초래할 수 있습니다.

대신 더 안전한 접근 방식으로 before_create, before_update 또는 이전 callback에서 값을 직접 할당할 수 있습니다(예: self.attribute = "value").

3 Available Callbacks

다음은 각각의 작업 중에 호출되는 순서대로 나열된 모든 사용 가능한 Active Record callback의 목록입니다:

3.1 객체 생성하기

이 두 콜백을 사용하는 예제는 after_commit / after_rollback 섹션을 참조하세요.

아래에 이러한 콜백을 사용하는 방법을 보여주는 예제들이 있습니다. 관련된 작업별로 그룹화했으며, 마지막으로 이들을 조합해서 사용하는 방법을 보여줍니다.

3.1.1 Validation 콜백

Validation 콜백은 레코드가 valid?(또는 그의 별칭인 validate)나 invalid?를 통해 직접 유효성 검사될 때마다 실행됩니다.

method를 통해 직접 호출하거나 create, update, save를 통해 간접적으로 호출할 수 있습니다. validation 단계의 전과 후에 호출됩니다.

class User < ApplicationRecord
  validates :name, presence: true
  before_validation :titleize_name
  after_validation :log_errors

  private
    def titleize_name
      self.name = name.downcase.titleize if name.present?
      Rails.logger.info("이름이 #{name}으로 타이틀케이스화 되었습니다")
    end

    def log_errors
      if errors.any?
        Rails.logger.error("유효성 검사 실패: #{errors.full_messages.join(', ')}")
      end
    end
end
irb> user = User.new(name: "", email: "john.doe@example.com", password: "abc123456") 
=> #<User id: nil, email: "john.doe@example.com", created_at: nil, updated_at: nil, name: "">

irb> user.valid?
Name titleized to
유효성 검사 실패: Name은 비워둘 수 없습니다
=> false

3.1.2 Save Callbacks

Save callback은 레코드가 create, update, save 메서드를 통해 기반 database에 persist(즉, "저장")될 때마다 트리거됩니다. 이들은 객체가 저장되기 전, 후, 그리고 저장되는 동안 호출됩니다.

class User < ApplicationRecord
  before_save :hash_password # 저장하기 전
  around_save :log_saving # 저장하는 동안 
  after_save :update_cache # 저장한 후

  private
    def hash_password
      self.password_digest = BCrypt::Password.create(password)
      Rails.logger.info("이메일이 #{email}인 사용자의 비밀번호가 해시되었습니다")
    end

    def log_saving
      Rails.logger.info("이메일이 #{email}인 사용자 저장 중")
      yield
      Rails.logger.info("이메일이 #{email}인 사용자가 저장되었습니다") 
    end

    def update_cache
      Rails.cache.write(["user_data", self], attributes)
      Rails.logger.info("캐시 업데이트")
    end
end
irb> user = User.create(name: "Jane Doe", password: "password", email: "jane.doe@example.com")

jane.doe@example.com 이메일을 가진 사용자의 비밀번호가 해시되었습니다
jane.doe@example.com 이메일을 가진 사용자를 저장합니다
jane.doe@example.com 이메일을 가진 사용자가 저장되었습니다
캐시 업데이트
=> #<User id: 1, email: "jane.doe@example.com", created_at: "2024-03-20 16:02:43.685500000 +0000", updated_at: "2024-03-20 16:02:43.685500000 +0000", name: "Jane Doe">

3.1.3 Create Callbacks

Create callback은 record가 처음으로 기저 database에 persist(즉, "저장")될 때마다 트리거됩니다. 다시 말해, create 또는 save 메소드를 통해 새로운 record를 저장할 때 발생합니다. 이들은 객체가 생성되기 전, 후, 그리고 생성되는 동안 호출됩니다.

class User < ApplicationRecord
  before_create :set_default_role
  around_create :log_creation
  after_create :send_welcome_email

  private
    def set_default_role
      self.role = "user"
      Rails.logger.info("사용자 역할이 기본값으로 설정됨: user") 
    end

    def log_creation
      Rails.logger.info("이메일로 사용자 생성 중: #{email}")
      yield 
      Rails.logger.info("이메일로 사용자가 생성됨: #{email}")
    end

    def send_welcome_email
      UserMailer.welcome_email(self).deliver_later
      Rails.logger.info("환영 이메일이 다음 주소로 전송됨: #{email}")
    end
end
irb> user = User.create(name: "John Doe", email: "john.doe@example.com")

사용자 역할이 기본값으로 설정됨: user
이메일로 사용자 생성 중: john.doe@example.com  
이메일로 사용자 생성됨: john.doe@example.com
사용자 환영 이메일 발송됨: john.doe@example.com
=> #<User id: 10, email: "john.doe@example.com", created_at: "2024-03-20 16:19:52.405195000 +0000", updated_at: "2024-03-20 16:19:52.405195000 +0000", name: "John Doe">

3.2 객체 업데이트하기

Update callback들은 기존 레코드가 데이터베이스에 영속화(즉, "저장")될 때마다 트리거됩니다. 이들은 객체가 업데이트되기 전, 후, 그리고 업데이트 진행 중에 호출됩니다.

after_save callback은 create와 update 작업 모두에서 트리거됩니다. 하지만 매크로 호출이 이루어진 순서와 관계없이 항상 더 구체적인 callback인 after_createafter_update 이후에 실행됩니다. 마찬가지로 before와 around save callback도 같은 규칙을 따릅니다: before_save는 create/update 전에 실행되고, around_save는 create/update 작업을 감싸서 실행됩니다. save callback은 항상 더 구체적인 create/update callback의 before/around/after보다 먼저 실행된다는 점을 기억하는 것이 중요합니다.

이미 validationsave callback들에 대해 다뤘습니다. 이 두 callback을 사용하는 예제는 after_commit / after_rollback 섹션을 참조하세요.

3.2.1 Update Callbacks

class User < ApplicationRecord
  before_update :check_role_change
  around_update :log_updating
  after_update :send_update_email

  private
    def check_role_change
      if role_changed?
        Rails.logger.info("사용자 역할이 #{role}로 변경되었습니다") 
      end
    end

    def log_updating
      Rails.logger.info("이메일이 #{email}인 사용자를 업데이트하는 중")
      yield
      Rails.logger.info("이메일이 #{email}인 사용자 업데이트 완료")
    end

    def send_update_email
      UserMailer.update_email(self).deliver_later
      Rails.logger.info("#{email}로 업데이트 이메일 전송됨")
    end
end
irb> user = User.find(1)
=> #<User id: 1, email: "john.doe@example.com", created_at: "2024-03-20 16:19:52.405195000 +0000", updated_at: "2024-03-20 16:19:52.405195000 +0000", name: "John Doe", role: "user" >

irb> user.update(role: "admin") 
사용자 역할이 admin으로 변경됨
이메일이 john.doe@example.com인 사용자 업데이트 중
이메일이 john.doe@example.com인 사용자 업데이트 완료
업데이트 이메일이 다음 주소로 전송됨: john.doe@example.com

3.2.2 콜백 조합 사용하기

종종 원하는 동작을 구현하기 위해 콜백들을 조합해서 사용해야 할 필요가 있습니다. 예를 들어, 사용자가 생성된 후에 확인 이메일을 보내되, 새로운 사용자일 때만 보내고 사용자 정보가 업데이트될 때는 보내지 않기를 원할 수 있습니다. 사용자 정보가 업데이트될 때는 중요한 정보가 변경된 경우에만 관리자에게 알림을 보내고 싶을 수 있습니다. 이런 경우에는 after_createafter_update 콜백을 함께 사용할 수 있습니다.

class User < ApplicationRecord
  after_create :send_confirmation_email
  after_update :notify_admin_if_critical_info_updated

  private
    def send_confirmation_email
      UserMailer.confirmation_email(self).deliver_later
      Rails.logger.info("확인 이메일이 다음 주소로 전송되었습니다: #{email}")
    end

    def notify_admin_if_critical_info_updated
      if saved_change_to_email? || saved_change_to_phone_number?
        AdminMailer.user_critical_info_updated(self).deliver_later
        Rails.logger.info("관리자에게 다음 사용자의 중요 정보 업데이트 알림이 전송되었습니다: #{email}")
      end
    end
end
irb> user = User.create(name: "John Doe", email: "john.doe@example.com") 
확인 이메일이 다음 주소로 전송되었습니다: john.doe@example.com
=> #<User id: 1, email: "john.doe@example.com", ...>

irb> user.update(email: "john.doe.new@example.com")
중요 정보 업데이트에 대한 알림이 관리자에게 전송되었습니다: john.doe.new@example.com  
=> true

3.3 객체 삭제하기

Destroy 콜백은 레코드가 destroy될 때마다 트리거되지만, 레코드가 delete될 때는 무시됩니다. 이 콜백들은 객체가 destroy되기 전, 후, 그리고 그 도중에 호출됩니다.

after_commit/after_rollback 사용 예시를 확인하세요.

3.3.1 Destroy 콜백

class User < ApplicationRecord
  before_destroy :check_admin_count
  around_destroy :log_destroy_operation 
  after_destroy :notify_users

  private
    def check_admin_count
      if admin? && User.where(role: "admin").count == 1
        throw :abort
      end
      Rails.logger.info("관리자 수를 확인함")
    end

    def log_destroy_operation
      Rails.logger.info("ID가 #{id}인 사용자를 삭제하려고 함") 
      yield
      Rails.logger.info("ID가 #{id}인 사용자가 성공적으로 삭제됨")
    end

    def notify_users
      UserMailer.deletion_email(self).deliver_later
      Rails.logger.info("다른 사용자들에게 사용자 삭제에 대한 알림을 보냄")
    end
end
irb> user = User.find(1)
=> #<User id: 1, email: "john.doe@example.com", created_at: "2024-03-20 16:19:52.405195000 +0000", updated_at: "2024-03-20 16:19:52.405195000 +0000", name: "John Doe", role: "admin">

irb> user.destroy
관리자 수 확인됨
ID 1번 사용자를 삭제하려고 합니다
ID 1번 사용자가 성공적으로 삭제되었습니다
사용자 삭제에 대해 다른 사용자들에게 알림이 전송되었습니다

3.4 after_initializeafter_find

Active Record 객체가 new를 통해 직접 인스턴스화되거나 데이터베이스에서 레코드가 로드될 때마다 after_initialize 콜백이 호출됩니다. 이는 Active Record의 initialize 메서드를 직접 오버라이드할 필요성을 피하는 데 유용할 수 있습니다.

데이터베이스에서 레코드를 로드할 때 after_find 콜백이 호출됩니다. 두 콜백이 모두 정의되어 있다면 after_findafter_initialize 이전에 호출됩니다.

after_initializeafter_find 콜백에는 before_*에 해당하는 콜백이 없습니다.

이러한 콜백들은 다른 Active Record 콜백들과 마찬가지로 등록할 수 있습니다.

class User < ApplicationRecord
  after_initialize do |user|
    Rails.logger.info("객체가 초기화되었습니다!")
  end

  after_find do |user|
    Rails.logger.info("객체를 찾았습니다!")
  end
end
irb> User.new
객체를 초기화했습니다!
=> #<User id: nil>

irb> User.first
객체를 찾았습니다!
객체를 초기화했습니다!
=> #<User id: 1>

3.5 after_touch

[after_touch][] 콜백은 Active Record 객체가 touch될 때마다 호출됩니다. touch에 대해서는 API 문서에서 자세히 알아볼 수 있습니다.

class User < ApplicationRecord
  after_touch do |user|
    Rails.logger.info("객체를 touch 했습니다")
  end
end
irb> user = User.create(name: "Kuldeep")
=> #<User id: 1, name: "Kuldeep", created_at: "2013-11-25 12:17:49", updated_at: "2013-11-25 12:17:49">

irb> user.touch
객체를 터치했습니다 
=> true

belongs_to와 함께 사용할 수 있습니다:

class Book < ApplicationRecord
  belongs_to :library, touch: true
  after_touch do
    Rails.logger.info("Book이 touch 되었습니다")
  end
end

class Library < ApplicationRecord
  has_many :books
  after_touch :log_when_books_or_library_touched

  private
    def log_when_books_or_library_touched
      Rails.logger.info("Book/Library가 touch 되었습니다") 
    end
end
irb> book = Book.last
=> #<Book id: 1, library_id: 1, created_at: "2013-11-25 17:04:22", updated_at: "2013-11-25 17:05:05">

irb> book.touch # book.library.touch를 트리거함
A Book was touched 
Book/Library was touched
=> true

4 Callback 실행하기

다음 메소드들은 callback을 트리거합니다:

  • create
  • create!
  • destroy
  • destroy!
  • destroy_all
  • destroy_by
  • save
  • save!
  • save(validate: false)
  • save!(validate: false)
  • toggle!
  • touch
  • update_attribute
  • update_attribute!
  • update
  • update!
  • valid?
  • validate

추가로, after_find callback은 다음 finder 메소드들에 의해 트리거됩니다:

  • all
  • first
  • find
  • find_by
  • find_by!
  • find_by_*
  • find_by_*!
  • find_by_sql
  • last
  • sole
  • take

after_initialize callback은 클래스의 새로운 객체가 초기화될 때마다 트리거됩니다.

find_by_*find_by_*! 메소드들은 각 속성에 대해 자동으로 생성되는 동적 finder입니다. 이에 대해 더 자세히 알아보려면 Dynamic finders 섹션을 참조하세요.

5 Conditional Callbacks

validations와 마찬가지로, 주어진 조건이 만족될 때만 callback 메서드가 호출되도록 할 수 있습니다. 이는 :if:unless 옵션을 사용하여 구현할 수 있으며, 이 옵션들은 symbol, Proc 또는 Array를 인자로 받을 수 있습니다.

callback이 호출되어야 하는 조건을 지정하고 싶을 때는 :if 옵션을 사용할 수 있습니다. callback이 호출되지 말아야 하는 조건을 지정하고 싶을 때는 :unless 옵션을 사용할 수 있습니다.

5.1 :if:unlessSymbol과 함께 사용하기

:if:unless 옵션에 callback 직전에 호출되는 predicate 메서드의 이름에 해당하는 symbol을 연결할 수 있습니다.

:if 옵션을 사용할 때, predicate 메서드가 false를 반환하면 callback이 실행되지 않습니다. :unless 옵션을 사용할 때, predicate 메서드가 true를 반환하면 callback이 실행되지 않습니다. 이것이 가장 일반적인 옵션입니다.

class Order < ApplicationRecord
  before_save :normalize_card_number, if: :paid_with_card?
end

이 코드에서는 card로 결제한 경우에만 카드번호를 정규화하도록 설정합니다.

이러한 등록 방식을 사용하면 콜백의 실행 여부를 확인하기 위해 호출되어야 하는 여러 다른 predicate를 등록할 수 있습니다. 이는 Multiple Callback Conditions 섹션에서 다룰 것입니다.

5.2 Proc와 함께 :if:unless 사용하기

:if:unlessProc 객체와 연결할 수 있습니다. 이 옵션은 짧은 validation 메서드, 특히 한 줄짜리 메서드를 작성할 때 가장 적합합니다:

class Order < ApplicationRecord
  before_save :normalize_card_number,
    if: ->(order) { order.paid_with_card? }
end

카드로 결제된 주문에 대해서만 카드 번호를 정규화하는 예시입니다.

proc가 객체의 컨텍스트 내에서 평가되기 때문에, 다음과 같이 작성하는 것도 가능합니다:

class Order < ApplicationRecord
  before_save :normalize_card_number, if: -> { paid_with_card? }
end

카드로 결제된 경우에만 카드번호를 정규화합니다.

5.3 여러 Callback 조건

:if:unless 옵션은 proc이나 메서드명의 심볼(symbol)로 구성된 배열도 받을 수 있습니다:

class Comment < ApplicationRecord
  before_save :filter_content,
    if: [:subject_to_parental_control?, :untrusted_author?] # 자녀 보호가 필요하고 신뢰되지 않은 작성자인 경우에만 콘텐츠를 필터링
end

list of conditions에 proc를 쉽게 포함할 수 있습니다:

class Comment < ApplicationRecord
  before_save :filter_content,
    if: [:subject_to_parental_control?, -> { untrusted_author? }]
    # 안전성 검사 및 미인증 작성자 여부가 true일 때 content를 필터링합니다
end

5.4 :if:unless 함께 사용하기

Callback은 동일한 선언에서 :if:unless를 함께 사용할 수 있습니다:

class Comment < ApplicationRecord
  before_save :filter_content,
    if: -> { forum.parental_control? },
    unless: -> { author.trusted? } # author가 trusted가 아닌 경우에만 
                                  # forum이 parental control을 사용하면 content를 필터링
end

callback은 모든 :if 조건이 true이고 어떤 :unless 조건도 true가 아닐 때만 실행됩니다.

6 Callbacks 건너뛰기

validations와 마찬가지로, 다음 메서드들을 사용하여 callbacks을 건너뛸 수 있습니다:

before_save callback이 사용자의 이메일 주소 변경을 로깅하는 User 모델을 살펴보겠습니다:

class User < ApplicationRecord
  before_save :log_email_change

  private
    def log_email_change
      if email_changed?
        Rails.logger.info("이메일이 #{email_was}에서 #{email}로 변경되었습니다")
      end
    end
end

이제 before_save 콜백이 이메일 변경을 로깅하도록 트리거하지 않으면서 사용자의 이메일 주소를 업데이트하고 싶은 시나리오가 있다고 가정해봅시다. 이러한 목적으로 update_columns 메서드를 사용할 수 있습니다:

irb> user = User.find(1)
irb> user.update_columns(email: 'new_email@example.com')

위 코드는 before_save 콜백을 트리거하지 않고 사용자의 이메일 주소를 업데이트할 것입니다.

이러한 메서드들은 주의해서 사용해야 합니다. 콜백에는 우회하고 싶지 않은 중요한 비즈니스 규칙과 애플리케이션 로직이 있을 수 있기 때문입니다. 잠재적 영향을 이해하지 못한 채 이를 우회하면 잘못된 데이터가 발생할 수 있습니다.

7 저장 억제하기

특정 시나리오에서는 레코드가 일시적으로 저장되는 것을 방지해야 할 수 있습니다.

콜백 내에서 저장됩니다. 복잡한 중첩 관계를 가진 레코드가 있고, 콜백을 영구적으로 비활성화하거나 복잡한 조건부 로직을 도입하지 않고도 특정 작업 중에 특정 레코드의 저장을 건너뛰고 싶을 때 유용할 수 있습니다.

Rails는 ActiveRecord::Suppressor 모듈을 사용하여 레코드 저장을 방지하는 메커니즘을 제공합니다. 이 모듈을 사용하면, 코드 블록에 의해 저장될 수 있는 특정 타입의 레코드를 저장하지 않으려는 코드 블록을 감쌀 수 있습니다.

사용자가 여러 개의 notifications를 가지는 시나리오를 살펴보겠습니다. User를 생성하면 자동으로 Notification 레코드도 함께 생성됩니다.

class User < ApplicationRecord
  has_many :notifications

  after_create :create_welcome_notification

  def create_welcome_notification
    notifications.create(event: "sign_up") 
  end
end

class Notification < ApplicationRecord
  belongs_to :user
end

위 코드는 다음을 보여줍니다: - User 모델은 여러 개의 notifications를 가질 수 있습니다 - 새로운 User가 생성되면 after_create callback이 실행되어 환영 notification을 생성합니다 - Notification 모델은 하나의 user에 속합니다

User notification을 생성하지 않고 user를 생성하기 위해서는, ActiveRecord::Suppressor 모듈을 다음과 같이 사용할 수 있습니다:

Notification.suppress 내에서는
  User.create(name: "Jane", email: "jane@example.com")
end

notification이 일시적으로 비활성화됩니다.

위 코드에서 Notification.suppress 블록은 "Jane" 유저를 생성하는 동안 Notification이 저장되지 않도록 보장합니다.

Active Record Suppressor를 사용하면 복잡성과 예기치 않은 동작이 발생할 수 있습니다. 저장을 억제하면 애플리케이션의 의도된 흐름이 모호해져 시간이 지남에 따라 코드베이스를 이해하고 유지보수하는 데 어려움이 생길 수 있습니다. 의도하지 않은 부작용과 테스트 실패의 위험을 완화하기 위해 철저한 문서화와 신중한 테스트를 수행하며 suppressor 사용의 영향을 신중히 고려하세요.

8 실행 중단하기

모델에 새로운 callback을 등록하기 시작하면, 실행을 위해 큐에 추가됩니다. 이 큐에는 모델의 모든 validation, 등록된 callback, 그리고 실행될 데이터베이스 작업이 포함됩니다.

전체 callback 체인은 트랜잭션으로 래핑됩니다. 어떤 callback이라도 예외를 발생시키면, 실행 체인이 중단되고 rollback이 발행되며, 에러가 다시 발생됩니다.

class Product < ActiveRecord::Base
  before_validation do
    raise "가격은 음수가 될 수 없습니다" if total_price < 0
  end
end

Product.create # "가격은 음수가 될 수 없습니다" 에러 발생

이는 createsave 같은 메소드가 예외를 발생시킬 것이라 예상하지 못한 코드를 예기치 않게 중단시킵니다.

callback 체인 중에 예외가 발생하면, Rails는 ActiveRecord::Rollback이나 ActiveRecord::RecordInvalid 예외가 아닌 한 해당 예외를 다시 발생시킵니다. 대신에 의도적으로 체인을 중단하려면 throw :abort를 사용해야 합니다. 어떤 callback이라도 :abort를 throw하면, 프로세스가 중단되고 create는 false를 반환할 것입니다.

class Product < ActiveRecord::Base
  before_validation do
    total_price가 0보다 작으면 throw :abort 실행
  end
end

Product.create # => false

하지만 create!를 호출할 때는 ActiveRecord::RecordNotSaved가 발생합니다. 이 예외는 callback의 중단으로 인해 record가 저장되지 않았음을 나타냅니다.

User.create! # => ActiveRecord::RecordNotSaved 예외를 발생시킴

어떤 destroy 콜백에서든 throw :abort가 호출되면, destroy는 false를 반환합니다:

class User < ActiveRecord::Base
  before_destroy do
    아직 활성화된 상태면 :abort를 throw하여 종료
    throw :abort if still_active?  
  end
end

User.first.destroy # => false

그러나 destroy!를 호출할 때는 ActiveRecord::RecordNotDestroyed가 발생합니다.

User.first.destroy! # => ActiveRecord::RecordNotDestroyed 에러를 발생시킴

9 Association Callbacks

Association callback은 일반적인 callback과 비슷하지만, 연관된 컬렉션의 생명주기 이벤트에 의해 트리거됩니다. 사용 가능한 4가지 association callback이 있습니다:

  • before_add
  • after_add
  • before_remove
  • after_remove

Association에 옵션을 추가하여 association callback을 정의할 수 있습니다.

예를 들어 author가 여러 book을 가질 수 있는 예시가 있다고 가정해보겠습니다. 하지만 author의 book 컬렉션에 book을 추가하기 전에, author가 book 제한에 도달하지 않았는지 확인하고 싶습니다. before_add callback을 추가하여 제한을 확인할 수 있습니다.

class Author < ApplicationRecord
  has_many :books, before_add: :check_limit

  private
    def check_limit(_book)
      if books.count >= 5
        errors.add(:base, "이 저자는 5권 이상의 책을 추가할 수 없습니다")
        throw(:abort)
      end
    end
end

before_add 콜백이 :abort를 던지면, 해당 객체는 collection에 추가되지 않습니다.

때로는 연관된 객체에 대해 여러 작업을 수행하고 싶을 수 있습니다. 이 경우, 콜백들을 배열로 전달하여 하나의 이벤트에 콜백을 쌓을 수 있습니다. 또한, Rails는 추가되거나 제거되는 객체를 콜백에 전달하여 사용할 수 있게 해줍니다.

class Author < ApplicationRecord
  has_many :books, before_add: [:check_limit, :calculate_shipping_charges]

  def check_limit(_book)
    if books.count >= 5
      errors.add(:base, "이 저자는 5권 이상의 책을 추가할 수 없습니다")
      throw(:abort)
    end
  end

  def calculate_shipping_charges(book)
    weight_in_pounds = book.weight_in_pounds || 1  
    shipping_charges = weight_in_pounds * 2

    shipping_charges
  end
end

마찬가지로 before_remove callback이 :abort를 발생시키면, object는 collection에서 제거되지 않습니다.

이러한 callback들은 관련 object들이 association collection을 통해 추가되거나 제거될 때만 호출됩니다.

# `before_add` 콜백을 발생시킴
author.books << book
author.books = [book, book2]

# `before_add` 콜백을 발생시키지 않음
book.update(author_id: 1)

10 연속적인 Association 콜백

연관 객체가 변경될 때 콜백이 수행될 수 있습니다. 이는 model association을 통해 작동하며, lifecycle 이벤트가 association을 통해 연속적으로 발생하고 콜백을 실행할 수 있습니다.

예를 들어 user가 많은 article을 가지고 있다고 가정해보겠습니다. user가 삭제되면 해당 user의 article도 삭제되어야 합니다. Article model과의 association을 통해 User model에 after_destroy 콜백을 추가해보겠습니다:

class User < ApplicationRecord
  has_many :articles, dependent: :destroy # User가 삭제되면 연관된 articles도 함께 삭제됨
end

class Article < ApplicationRecord
  after_destroy :log_destroy_action # Article이 삭제된 후에 log_destroy_action 메서드 실행 

  def log_destroy_action
    Rails.logger.info("Article destroyed") # Article이 삭제되었다는 로그 메시지를 기록
  end
end
irb> user = User.first
=> #<User id: 1>
irb> user.articles.create!
=> #<Article id: 1, user_id: 1>
irb> user.destroy
Article이 삭제됨
=> #<User id: 1>

경고: before_destroy 콜백을 사용할 때는 dependent: :destroy associations 앞에 위치해야 합니다(또는 prepend: true 옵션을 사용하세요). 이는 dependent: :destroy에 의해 레코드가 삭제되기 전에 콜백이 실행되도록 보장하기 위해서입니다.

11 Transaction Callbacks

11.1 after_commitafter_rollback

데이터베이스 트랜잭션의 완료로 인해 두 가지 추가 콜백이 트리거됩니다: after_commitafter_rollback. 이 콜백들은 after_save 콜백과 매우 유사하지만, 데이터베이스 변경사항이 commit되거나 rollback될 때까지 실행되지 않는다는 점이 다릅니다. 이 콜백들은 Active Record 모델이 데이터베이스 트랜잭션의 일부가 아닌 외부 시스템과 상호작용해야 할 때 가장 유용합니다.

해당하는 레코드가 삭제된 후에 파일을 삭제해야 하는 PictureFile 모델을 예로 들어보겠습니다.

class PictureFile < ApplicationRecord
  after_destroy :delete_picture_file_from_disk

  def delete_picture_file_from_disk
    if File.exist?(filepath) 
      File.delete(filepath)
    end
  end
end

여기서는 PictureFile 모델에서 사진 파일이 데이터베이스에서 삭제될 때 디스크에서도 실제 파일을 삭제하고 있습니다. after_destroy callback을 사용하면 레코드가 삭제된 후 자동으로 파일 시스템에서도 파일을 제거합니다.

after_destroy 콜백이 호출된 후에 예외가 발생하여 트랜잭션이 롤백되면, 파일은 이미 삭제된 상태이고 모델은 일관성이 없는 상태로 남게 됩니다. 예를 들어, 아래 코드에서 picture_file_2가 유효하지 않고 save! 메서드가 에러를 발생시킨다고 가정해 봅시다.

PictureFile.transaction do
  picture_file_1.destroy
  picture_file_2.save!
end

transaction 블록 내에서, picture_file_1을 삭제하고 picture_file_2를 저장합니다.

after_commit callback을 사용하면 이러한 경우를 처리할 수 있습니다.

class PictureFile < ApplicationRecord
  after_commit :delete_picture_file_from_disk, on: :destroy

  def delete_picture_file_from_disk
    if File.exist?(filepath) # 파일이 존재하면
      File.delete(filepath) # 디스크에서 파일 삭제
    end
  end
end

:on 옵션은 callback이 실행될 시점을 지정합니다. :on 옵션을 제공하지 않으면 callback은 모든 life cycle 이벤트에서 실행됩니다. :on에 대해 더 알아보기

트랜잭션이 완료되면, 그 트랜잭션 내에서 생성, 수정 또는 삭제된 모든 모델에 대해 after_commit 또는 after_rollback callback이 호출됩니다. 하지만 이러한 callback 중 하나에서 예외가 발생하면, 예외가 상위로 전파되고 남은 after_commit 또는 after_rollback 메서드는 실행되지 않습니다.

class User < ActiveRecord::Base
  after_commit { raise "Intentional Error" }
  after_commit {
    # 이전 after_commit이 예외를 발생시키기 때문에 이것은 호출되지 않습니다
    Rails.logger.info("This will not be logged")
  }
end

주의. callback 코드에서 exception이 발생하는 경우, 다른 callback이 실행될 수 있도록 exception을 rescue하고 callback 내에서 처리해야 합니다.

after_commitafter_save, after_update, after_destroy와는 매우 다른 보장을 제공합니다. 예를 들어, after_save에서 exception이 발생하면 transaction이 롤백되고 데이터는 저장되지 않습니다.

class User < ActiveRecord::Base
  after_save do
    # 이 작업이 실패하면 user가 저장되지 않습니다.
    EventLog.create!(event: "user_saved")
  end
end

하지만 after_commit 동안에는 데이터가 이미 database에 영구 저장되어 있기 때문에, 어떤 예외가 발생하더라도 더 이상 롤백되지 않습니다.

class User < ActiveRecord::Base
  after_commit do
    # 만약 실패하더라도 user는 이미 저장된 상태입니다.
    EventLog.create!(event: "user_saved")
  end
end

after_commit 또는 after_rollback callback 내에서 실행되는 코드는 그 자체로 transaction 내에 포함되어 있지 않습니다.

하나의 transaction 컨텍스트에서 데이터베이스의 동일한 레코드를 표현할 때, after_commitafter_rollback callback에서 주목해야 할 중요한 동작이 있습니다. 이러한 callback들은 transaction 내에서 변경되는 특정 레코드의 첫 번째 객체에 대해서만 트리거됩니다. 동일한 데이터베이스 레코드를 나타내는 다른 로드된 객체들은 각각의 after_commit 또는 after_rollback callback이 트리거되지 않습니다.

class User < ApplicationRecord
  after_commit :log_user_saved_to_db, on: :update

  private
    def log_user_saved_to_db
      Rails.logger.info("사용자가 데이터베이스에 저장되었습니다")
    end
end
irb> user = User.create
irb> User.transaction { user.save; user.save }
# User가 데이터베이스에 저장되었습니다

경고: 이러한 미묘한 동작은 동일한 데이터베이스 레코드와 연결된 각 객체에 대해 독립적인 callback 실행을 기대하는 시나리오에서 특히 큰 영향을 미칩니다. transaction 이후 callback 순서의 흐름과 예측 가능성에 영향을 주어 애플리케이션 로직의 잠재적 불일치를 초래할 수 있습니다.

11.2 after_commit의 별칭

after_commit 콜백을 생성, 업데이트 또는 삭제 시에만 사용하는 것은 매우 일반적입니다. 때로는 createupdate 모두에 대해 단일 콜백을 사용하고 싶을 수도 있습니다. 다음은 이러한 작업에 대한 일반적인 별칭들입니다:

몇 가지 예시를 살펴보겠습니다:

아래와 같이 삭제에 대해 on 옵션과 함께 after_commit을 사용하는 대신:

class PictureFile < ApplicationRecord
  after_commit :delete_picture_file_from_disk, on: :destroy

  def delete_picture_file_from_disk
    if File.exist?(filepath) 
      File.delete(filepath)
    end
  end 
end

레코드가 삭제될 때 파일시스템에서 실제 파일이 삭제되도록 보장하기 위해 after_commit 콜백을 사용합니다.

after_destroy_commit을 대신 사용할 수 있습니다.

class PictureFile < ApplicationRecord
  after_destroy_commit :delete_picture_file_from_disk

  def delete_picture_file_from_disk
    if File.exist?(filepath)
      File.delete(filepath) 
    end
  end
end
PictureFile record가 삭제된 후 저장소의 실제 파일도 삭제하려면 상기 코드와 같이 after_destroy_commit callback을 사용합니다.

after_create_commitafter_update_commit에도 동일하게 적용됩니다.

하지만 동일한 메소드 이름으로 after_create_commitafter_update_commit 콜백을 사용하면, 마지막으로 정의된 콜백만 적용됩니다. 이는 두 콜백 모두 내부적으로 after_commit으로 별칭이 지정되며, 같은 메소드 이름으로 이전에 정의된 콜백을 덮어쓰기 때문입니다.

class User < ApplicationRecord
  after_create_commit :log_user_saved_to_db
  after_update_commit :log_user_saved_to_db

  private
    def log_user_saved_to_db
      # 이는 한 번만 호출됩니다
      Rails.logger.info("User was saved to database")
    end
end
irb> user = User.create # 출력 없음

irb> user.save # @user 업데이트
User was saved to database

이 경우에는 create와 update 모두에 대한 after_commit callback의 별칭인 after_save_commit을 사용하는 것이 더 좋습니다:

class User < ApplicationRecord
  after_save_commit :log_user_saved_to_db

  private
    def log_user_saved_to_db
      Rails.logger.info("User가 데이터베이스에 저장되었습니다") 
    end
end
irb> user = User.create # User 생성하기 
User was saved to database

irb> user.save # user 업데이트하기
User was saved to database

11.3 트랜잭션 Callback 순서

기본적으로(Rails 7.1부터), 트랜잭션 callback은 정의된 순서대로 실행됩니다.

class User < ActiveRecord::Base
  after_commit { Rails.logger.info("이것이 먼저 호출됩니다") }
  after_commit { Rails.logger.info("이것이 두 번째로 호출됩니다") }
end

하지만 이전 Rails 버전에서는 여러 개의 트랜잭션 after_ 콜백들(after_commit, after_rollback 등)을 정의할 때, 콜백들이 실행되는 순서가 반대로 되어 있었습니다.

만약 어떤 이유로 여전히 반대 순서로 실행되기를 원한다면, 다음 설정을 false로 지정할 수 있습니다. 그러면 콜백들이 반대 순서로 실행될 것입니다. 자세한 내용은 Active Record configuration options를 참조하세요.

config.active_record.run_after_transaction_callbacks_in_order_defined = false

after_commit/after_rollback callback을 정의된 순서대로 실행할지 여부를 설정합니다. false로 설정하면 callback은 역순으로 실행됩니다.

기본값은 false 입니다.

이는 after_destroy_commit과 같은 모든 after_*_commit 변형에도 적용됩니다.

12 Callback Objects

때로는 작성하게 될 callback 메서드가 다른 model에서도 재사용할 만큼 유용할 수 있습니다. Active Record는 callback 메서드를 캡슐화하는 클래스를 생성하여 재사용할 수 있도록 해줍니다.

다음은 파일 시스템에서 삭제된 파일을 정리하기 위한 after_commit callback 클래스의 예시입니다. 이 동작은 PictureFile model에만 국한되지 않을 수 있으며 공유하고 싶을 수 있으므로, 이를 별도의 클래스로 캡슐화하는 것이 좋은 방법입니다. 이렇게 하면 해당 동작을 테스트하고 변경하는 것이 훨씬 쉬워집니다.

class FileDestroyerCallback
  def after_commit(file)
    if File.exist?(file.filepath)
      File.delete(file.filepath)
    end
  end
end

이 코드는 파일이 존재하는지 확인하고 filepath에 해당하는 파일을 삭제하는 after_commit callback을 구현합니다.

위와 같이 클래스 내부에 선언되면, callback 메서드는 model 객체를 파라미터로 받게 됩니다. 이는 해당 클래스를 사용하는 모든 model에서 작동할 것입니다:

class PictureFile < ApplicationRecord
  after_commit FileDestroyerCallback.new 
end

콜백을 인스턴스 메서드로 선언했기 때문에 새로운 FileDestroyerCallback 객체를 인스턴스화해야 했다는 점에 주목하세요. 이는 콜백이 인스턴스화된 객체의 상태를 사용하는 경우에 특히 유용합니다. 하지만 대부분의 경우에는 콜백을 클래스 메서드로 선언하는 것이 더 합리적일 것입니다:

class FileDestroyerCallback
  def self.after_commit(file)
    if File.exist?(file.filepath)
      File.delete(file.filepath)
    end
  end
end

파일이 존재할 경우 after_commit callback이 파일을 삭제합니다.

콜백 메서드를 이런 방식으로 선언하면, 우리의 모델에서 새로운 FileDestroyerCallback 객체를 인스턴스화할 필요가 없습니다.

class PictureFile < ApplicationRecord
  after_commit FileDestroyerCallback
end

당신은 callback 객체 내에서 원하는 만큼의 callback을 선언할 수 있습니다.



맨 위로