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 객체 생성하기
before_validation
after_validation
before_save
around_save
before_create
around_create
after_create
after_save
after_commit
/after_rollback
이 두 콜백을 사용하는 예제는 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들은 기존 레코드가 데이터베이스에 영속화(즉, "저장")될 때마다 트리거됩니다. 이들은 객체가 업데이트되기 전, 후, 그리고 업데이트 진행 중에 호출됩니다.
before_validation
after_validation
before_save
around_save
before_update
around_update
after_update
after_save
after_commit
/after_rollback
after_save
callback은 create와 update 작업 모두에서 트리거됩니다. 하지만 매크로 호출이 이루어진 순서와 관계없이 항상 더 구체적인 callback인 after_create
와 after_update
이후에 실행됩니다. 마찬가지로 before와 around save callback도 같은 규칙을 따릅니다: before_save
는 create/update 전에 실행되고, around_save
는 create/update 작업을 감싸서 실행됩니다. save callback은 항상 더 구체적인 create/update callback의 before/around/after보다 먼저 실행된다는 점을 기억하는 것이 중요합니다.
이미 validation과 save 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_create
와 after_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_initialize
와 after_find
Active Record 객체가 new
를 통해 직접 인스턴스화되거나 데이터베이스에서 레코드가 로드될 때마다 after_initialize
콜백이 호출됩니다. 이는 Active Record의 initialize
메서드를 직접 오버라이드할 필요성을 피하는 데 유용할 수 있습니다.
데이터베이스에서 레코드를 로드할 때 after_find
콜백이 호출됩니다. 두 콜백이 모두 정의되어 있다면 after_find
가 after_initialize
이전에 호출됩니다.
after_initialize
와 after_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
와 :unless
를 Symbol
과 함께 사용하기
: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
와 :unless
를 Proc
객체와 연결할 수 있습니다. 이 옵션은 짧은 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을 건너뛸 수 있습니다:
decrement!
decrement_counter
delete
delete_all
delete_by
increment!
increment_counter
insert
insert!
insert_all
insert_all!
touch_all
update_column
update_columns
update_all
update_counters
upsert
upsert_all
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 # "가격은 음수가 될 수 없습니다" 에러 발생
이는 create
나 save
같은 메소드가 예외를 발생시킬 것이라 예상하지 못한 코드를 예기치 않게 중단시킵니다.
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_commit
와 after_rollback
데이터베이스 트랜잭션의 완료로 인해 두 가지 추가 콜백이 트리거됩니다: after_commit
와 after_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_commit
은 after_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_commit
과 after_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
콜백을 생성, 업데이트 또는 삭제 시에만 사용하는 것은 매우 일반적입니다.
때로는 create
와 update
모두에 대해 단일 콜백을 사용하고 싶을 수도 있습니다. 다음은 이러한 작업에 대한 일반적인 별칭들입니다:
몇 가지 예시를 살펴보겠습니다:
아래와 같이 삭제에 대해 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_commit
과 after_update_commit
에도 동일하게 적용됩니다.
하지만 동일한 메소드 이름으로 after_create_commit
과 after_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을 선언할 수 있습니다.