rubyonrails.org에서 더 보기:

Active Job 기초

이 가이드는 background job을 생성하고, enqueuing하고 실행하는 데 필요한 모든 것을 제공합니다.

이 가이드를 읽으면 다음을 알 수 있습니다:

  • job을 생성하는 방법
  • job을 enqueuing하는 방법
  • background에서 job을 실행하는 방법
  • 애플리케이션에서 비동기적으로 이메일을 보내는 방법

1 Active Job이란?

Active Job은 job을 선언하고 다양한 queuing backend에서 실행할 수 있게 해주는 프레임워크입니다. 이러한 job들은 정기적인 정리 작업부터 결제, 메일 발송까지 모든 것이 될 수 있습니다. 작은 작업 단위로 나누어 병렬로 실행할 수 있는 모든 것이 가능합니다.

2 Active Job의 목적

주요 목적은 모든 Rails 앱이 job 인프라를 갖추도록 하는 것입니다. 이를 통해 프레임워크 기능과 다른 gem들이 Delayed Job이나 Resque와 같은 다양한 job runner 간의 API 차이를 걱정하지 않고 그 위에 구축될 수 있습니다. queuing backend 선택은 운영상의 문제가 됩니다. 그리고 job을 다시 작성할 필요 없이 backend를 전환할 수 있습니다.

Rails는 기본적으로 in-process thread pool로 job을 실행하는 비동기 queuing 구현을 제공합니다. job은 비동기적으로 실행되지만, 재시작 시 queue에 있는 모든 job은 삭제됩니다.

3 Job 생성 및 Enqueuing

이 섹션에서는 job을 생성하고 enqueuing하는 단계별 가이드를 제공합니다.

3.1 Job 생성하기

Active Job는 job을 생성하기 위한 Rails generator를 제공합니다. 다음과 같이 실행하면 app/jobs 디렉토리에 job이 생성됩니다(그리고 test/jobs 디렉토리에 관련 테스트 케이스도 함께 생성됩니다):

$ bin/rails generate job guests_cleanup
invoke  test_unit
create    test/jobs/guests_cleanup_job_test.rb
create  app/jobs/guests_cleanup_job.rb

특정 queue에서 실행될 job을 생성할 수도 있습니다.

$ bin/rails generate job guests_cleanup --queue urgent

만약 generator를 사용하고 싶지 않다면, app/jobs 안에 직접 파일을 만들 수 있습니다. 단, ApplicationJob을 상속받도록 해야 합니다.

job은 다음과 같은 형태입니다:

class GuestsCleanupJob < ApplicationJob
  queue_as :default

  def perform(*guests)
    # 나중에 실행할 작업
  end
end

perform을 원하는 만큼의 인자와 함께 정의할 수 있습니다.

이미 추상 클래스가 있고 그 이름이 ApplicationJob과 다를 경우, --parent 옵션을 전달하여 다른 추상 클래스를 사용할 수 있습니다:

$ bin/rails generate job process_payment --parent=payment_job
class ProcessPaymentJob < PaymentJob
  queue_as :default

  def perform(*args)
    # 나중에 수행할 작업
  end
end

3.2 Job 등록하기

perform_later와 선택적으로 set을 사용하여 job을 등록할 수 있습니다. 다음과 같이 사용합니다:

# 큐잉 시스템이 사용 가능해지는 즉시 실행될 job을
# 큐에 추가합니다.
GuestsCleanupJob.perform_later guest
# 내일 정오에 실행될 job을 enqueue 합니다.
GuestsCleanupJob.set(wait_until: Date.tomorrow.noon).perform_later(guest)
# 1주일 후에 수행될 Job을 enqueue 합니다.
GuestsCleanupJob.set(wait: 1.week).perform_later(guest)
# `perform_now`와 `perform_later`는 내부적으로 `perform`을 호출하므로 
# 정의된 만큼의 인수를 전달할 수 있습니다.  
GuestsCleanupJob.perform_later(guest1, guest2, filter: "some_filter")

이것이 전부입니다!

3.3 Bulk로 Job 대기열에 추가하기

perform_all_later를 사용하여 여러 job을 한 번에 대기열에 추가할 수 있습니다. 자세한 내용은 Bulk Enqueuing을 참조하세요.

4 Job 실행

production 환경에서 job을 대기열에 추가하고 실행하려면 queuing backend를 설정해야 합니다. 즉, Rails가 사용할 서드파티 queuing 라이브러리를 선택해야 합니다. Rails 자체는 in-process queuing 시스템만 제공하며, 이는 job을 RAM에만 보관합니다. 프로세스가 중단되거나 머신이 재시작되면 기본 async backend의 모든 대기 중인 job들이 손실됩니다. 작은 규모의 앱이나 중요도가 낮은 job의 경우에는 괜찮을 수 있지만, 대부분의 production 앱은 영구적인 backend를 선택해야 할 것입니다.

4.1 Backends

Active Job은 여러 queuing backend(Sidekiq, Resque, Delayed Job 등)를 위한 내장 adapter를 가지고 있습니다. 최신 adapter 목록을 확인하려면 ActiveJob::QueueAdapters의 API 문서를 참조하세요.

4.2 Backend 설정하기

config.active_job.queue_adapter를 사용하여 큐잉 backend를 쉽게 설정할 수 있습니다:

# config/application.rb
module YourApp
  class Application < Rails::Application
    # adapter의 gem이 Gemfile에 있는지 확인하고 
    # adapter의 특정 설치 및 배포 지침을 
    # 따르도록 하세요.
    config.active_job.queue_adapter = :sidekiq
  end
end

job 별로 backend를 설정할 수도 있습니다.

class GuestsCleanupJob < ApplicationJob
  self.queue_adapter = :resque
  # ...
end

# 이제 `config.active_job.queue_adapter`에 설정된 내용을 재정의하여
# `resque`를 backend queue adapter로 사용하게 됩니다.

4.3 Backend 시작하기

job은 Rails 애플리케이션과 병렬로 실행되기 때문에, 대부분의 queuing library는 job 처리를 위해 (Rails 앱을 시작하는 것 외에) library에 특화된 queuing service를 시작해야 합니다. queue backend를 시작하는 방법은 해당 library의 문서를 참고하세요.

다음은 문서 목록의 일부입니다:

5 Queues

대부분의 adapter는 다중 queue를 지원합니다. Active Job에서는 queue_as를 사용하여 특정 queue에서 job이 실행되도록 예약할 수 있습니다:

class GuestsCleanupJob < ApplicationJob
  queue_as :low_priority
  # ...
end

application.rb에서 config.active_job.queue_name_prefix를 사용하여 모든 job의 queue 이름에 prefix를 추가할 수 있습니다:

# config/application.rb
module YourApp
  class Application < Rails::Application
    # 큐 이름 앞에 Rails 환경을 접두사로 추가합니다
    config.active_job.queue_name_prefix = Rails.env
  end
end
# app/jobs/guests_cleanup_job.rb  
class GuestsCleanupJob < ApplicationJob
  queue_as :low_priority
  # ...
end

# 이제 job은 production 환경에서는 production_low_priority queue에서,
# staging 환경에서는 staging_low_priority queue에서 실행됩니다

job별로 prefix를 구성할 수도 있습니다.

class GuestsCleanupJob < ApplicationJob
  queue_as :low_priority
  self.queue_name_prefix = nil
  # ...
end

# 이제 job의 queue 이름에 prefix가 붙지 않습니다. 
# `config.active_job.queue_name_prefix`에 설정된 값을 override합니다.

기본 queue 이름 접두사 구분자는 '_'입니다. 이는 application.rb에서 config.active_job.queue_name_delimiter를 설정하여 변경할 수 있습니다:

# config/application.rb
module YourApp
  class Application < Rails::Application
    # Queue 이름 앞에 Rails environment를 붙입니다
    config.active_job.queue_name_prefix = Rails.env
    # Queue 이름과 delimiter 사이에 "." 구분자를 설정합니다 
    config.active_job.queue_name_delimiter = "."
  end
end
# app/jobs/guests_cleanup_job.rb
class GuestsCleanupJob < ApplicationJob
  queue_as :low_priority
  # ...
end

# 이제 당신의 job은 production 환경에서는 production.low_priority queue에서,
# staging 환경에서는 staging.low_priority queue에서 실행될 것입니다

job 레벨에서 queue를 제어하려면 queue_as에 block을 전달할 수 있습니다. block은 job context 내에서 실행되며(따라서 self.arguments에 접근할 수 있습니다), queue 이름을 반환해야 합니다.

class ProcessVideoJob < ApplicationJob
  queue_as do 
    video = self.arguments.first
    if video.owner.premium?
      :premium_videojobs  # premium 사용자의 경우
    else
      :videojobs         # 일반 사용자의 경우
    end
  end

  def perform(video)
    # 비디오 처리 작업 수행
  end
end
ProcessVideoJob.perform_later(Video.last)

변환하려는 시점이 아닌, 대기열에 있는 동안(later) 마지막 video를 처리하도록 합니다.

작업이 실행될 queue를 더 세밀하게 제어하고 싶다면 set:queue 옵션을 전달할 수 있습니다.

MyJob.set(queue: :another_queue).perform_later(record)

queuing backend이 queue name을 "수신"하고 있는지 확인하세요. 일부 backend의 경우 수신할 queue를 지정해야 합니다.

6 우선순위

일부 adapter는 job 수준에서 우선순위를 지원하므로, queue 내 또는 전체 queue에서 다른 job들과 비교하여 우선순위를 정할 수 있습니다.

queue_with_priority를 사용하여 특정 우선순위로 job을 예약할 수 있습니다:

class GuestsCleanupJob < ApplicationJob
  queue_with_priority 10
  # ...
end

이는 priority를 지원하지 않는 adapter에서는 아무런 효과가 없습니다.

queue_as와 유사하게, queue_with_priority에도 job context 내에서 평가될 블록을 전달할 수 있습니다:

class ProcessVideoJob < ApplicationJob
  queue_with_priority do
    video = self.arguments.first
    if video.owner.premium?
      0
    else
      10
    end
  end

  def perform(video)
    # 비디오 처리 
  end
end
ProcessVideoJob.perform_later(Video.last)

나중에 마지막 Video record를 처리하는 job을 실행합니다.

또한 set:priority 옵션을 전달할 수 있습니다:

MyJob.set(priority: 50).perform_later(record)

priority를 50으로 설정하여 job을 실행합니다.

낮은 우선순위 번호가 높은 우선순위 번호보다 먼저 또는 나중에 실행되는지는 adapter 구현에 따라 달라집니다. 자세한 내용은 사용 중인 backend의 문서를 참조하세요. Adapter 작성자는 낮은 번호를 더 중요하게 처리하는 것이 권장됩니다.

7 Callbacks

Active Job은 job의 생명주기 동안 로직을 실행할 수 있는 hook을 제공합니다. Rails의 다른 callback들처럼, callback을 일반 메서드로 구현하고 macro-style 클래스 메서드를 사용하여 callback으로 등록할 수 있습니다:

class GuestsCleanupJob < ApplicationJob
  queue_as :default

  around_perform :around_cleanup

  def perform
    # 나중에 실행할 작업 
  end

  private
    def around_cleanup
      # perform 메서드 실행 전에 할 작업
      yield
      # perform 메서드 실행 후에 할 작업 
    end
end

매크로 스타일의 클래스 메소드는 block을 받을 수도 있습니다. block 내부의 코드가 한 줄에 들어갈 만큼 짧다면 이 스타일을 사용하는 것을 고려해보세요. 예를 들어, 대기열에 들어가는 모든 job에 대해 metrics를 보낼 수 있습니다:

class ApplicationJob < ActiveJob::Base
  before_enqueue { |job| $statsd.increment "#{job.class.name.underscore}.enqueue" }
  # job이 enqueue되기 전에 해당 job 클래스의 underscore된 이름으로 statsd 메트릭을 증가시킵니다
end

7.1 사용 가능한 Callbacks

perform_all_later를 사용하여 job을 일괄 등록할 때는 around_enqueue와 같은 callback이 개별 job에서 트리거되지 않는다는 점에 주의하세요. Bulk Enqueuing Callbacks를 참조하세요.

8 Bulk Enqueuing

perform_all_later를 사용하여 여러 job을 한 번에 등록할 수 있습니다. Bulk enqueuing은 queue 데이터 저장소(Redis나 데이터베이스 같은)와의 왕복 횟수를 줄여주어, 동일한 job들을 개별적으로 등록하는 것보다 더 효율적인 작업이 됩니다.

perform_all_later는 Active Job의 최상위 API입니다. 인스턴스화된 job들을 인자로 받습니다(perform_later와는 다른 점입니다). perform_all_later는 내부적으로 perform을 호출합니다. new에 전달된 인자들은 최종적으로 호출될 때 perform으로 전달됩니다.

다음은 GuestCleanupJob 인스턴스로 perform_all_later를 호출하는 예시입니다:

# `perform_all_later`에 전달할 job들을 생성합니다.
# `new`에 전달된 인자들은 `perform`으로 전달됩니다
guest_cleanup_jobs = Guest.all.map { |guest| GuestsCleanupJob.new(guest) }

# `GuestsCleanupJob`의 각 인스턴스에 대해 별도의 job을 큐에 넣습니다
ActiveJob.perform_all_later(guest_cleanup_jobs)

# job들을 일괄 큐잉하기 전에 `set` 메서드를 사용하여 옵션을 구성할 수도 있습니다.
guest_cleanup_jobs = Guest.all.map { |guest| GuestsCleanupJob.new(guest).set(wait: 1.day) }

ActiveJob.perform_all_later(guest_cleanup_jobs)

perform_all_later는 성공적으로 큐에 추가된 job의 수를 로깅합니다. 예를 들어 위의 Guest.all.map이 3개의 guest_cleanup_jobs를 생성했다면, Enqueued 3 jobs to Async (3 GuestsCleanupJob)라고 로깅할 것입니다 (모두 큐에 추가되었다고 가정할 때).

perform_all_later의 반환값은 nil입니다. 이는 큐에 추가된 job 클래스의 인스턴스를 반환하는 perform_later와는 다른 점입니다.

8.1 복수의 Active Job 클래스 Enqueue하기

perform_all_later를 사용하면 같은 호출로 서로 다른 Active Job 클래스의 인스턴스를 enqueue할 수 있습니다. 예를 들면:

class ExportDataJob < ApplicationJob
  def perform(*args)
    # 데이터 내보내기
  end
end

class NotifyGuestsJob < ApplicationJob
  def perform(*guests)
    # 게스트 이메일 보내기  
  end
end

# Job 인스턴스 생성
cleanup_job = GuestsCleanupJob.new(guest) 
export_job = ExportDataJob.new(data)
notify_job = NotifyGuestsJob.new(guest)

# 여러 클래스의 job 인스턴스들을 한번에 대기열에 추가
ActiveJob.perform_all_later(cleanup_job, export_job, notify_job)

8.2 Bulk Enqueue Callbacks

perform_all_later를 사용하여 job을 일괄적으로 enqueue할 때, around_enqueue와 같은 callback은 개별 job에서 실행되지 않습니다. 이것은 다른 Active Record bulk 메서드와 일관된 동작입니다. callback은 개별 job에서 실행되므로, 이 메서드의 일괄 처리 특성을 활용할 수 없습니다.

하지만 perform_all_later 메서드는 enqueue_all.active_job 이벤트를 발생시키며, 이는 ActiveSupport::Notifications를 사용하여 구독할 수 있습니다.

successfully_enqueued? 메서드를 사용하여 주어진 job이 성공적으로 enqueue되었는지 확인할 수 있습니다.

8.3 Queue Backend Support

perform_all_later를 위한 대량 enqueuing은 queue backend의 지원이 필요합니다.

예를 들어, Sidekiq은 Redis에 많은 수의 job을 푸시하고 네트워크 지연 시간을 방지할 수 있는 push_bulk 메서드를 가지고 있습니다. GoodJob 또한 GoodJob::Bulk.enqueue 메서드를 통해 대량 enqueuing을 지원합니다. 새로운 queue backend인 Solid Queue도 대량 enqueuing 지원을 추가했습니다.

만약 queue backend가 대량 enqueuing을 지원하지 않는 경우, perform_all_later는 job을 하나씩 enqueue할 것입니다.

9 Action Mailer

현대 웹 애플리케이션에서 가장 일반적인 job 중 하나는 request-response 사이클 외부에서 이메일을 보내는 것입니다. 이를 통해 사용자가 기다릴 필요가 없습니다. Active Job은 Action Mailer와 통합되어 있어 쉽게 비동기로 이메일을 보낼 수 있습니다:

# 지금 바로 이메일을 보내려면 #deliver_now를 사용하세요
UserMailer.welcome(@user).deliver_now

# Active Job을 통해 이메일을 보내려면 #deliver_later를 사용하세요 
UserMailer.welcome(@user).deliver_later

Rake task에서 비동기 대기열을 사용하는 경우(예: .deliver_later를 사용하여 이메일을 보내는 경우), Rake가 종료되면서 in-process thread pool이 삭제되어 .deliver_later 이메일이 처리되기 전에 작업이 중단될 수 있기 때문에 일반적으로 작동하지 않습니다. 이 문제를 피하려면 .deliver_now를 사용하거나 development 환경에서 영구 대기열을 실행하세요.

10 Internationalization

각 job은 생성될 때 설정된 I18n.locale을 사용합니다. 이는 비동기로 이메일을 보낼 때 유용합니다:

I18n.locale = :eo

UserMailer.welcome(@user).deliver_later # 이메일이 에스페란토어로 현지화됩니다.

11 Arguments에 대해 지원되는 타입

ActiveJob은 기본적으로 다음과 같은 타입의 arguments를 지원합니다:

  • 기본 타입들 (NilClass, String, Integer, Float, BigDecimal, TrueClass, FalseClass)
  • Symbol
  • Date
  • Time
  • DateTime
  • ActiveSupport::TimeWithZone
  • ActiveSupport::Duration
  • Hash (키는 String 또는 Symbol 타입이어야 함)
  • ActiveSupport::HashWithIndifferentAccess
  • Array
  • Range
  • Module
  • Class

11.1 GlobalID

Active Job는 파라미터에 대해 GlobalID를 지원합니다. 이를 통해 class/id 쌍을 전달하고 수동으로 역직렬화하는 대신, 실제 Active Record 객체를 job에 전달할 수 있습니다. 이전에는 job이 다음과 같았습니다:

class TrashableCleanupJob < ApplicationJob
  def perform(trashable_class, trashable_id, depth)
    trashable = trashable_class.constantize.find(trashable_id)
    trashable.cleanup(depth) 
  end
end

이제 다음과 같이 간단하게 할 수 있습니다:

class TrashableCleanupJob < ApplicationJob
  def perform(trashable, depth)
    trashable.cleanup(depth)
  end
end

GlobalID::Identification을 믹스인하는 모든 클래스에서 동작하며, 이는 기본적으로 Active Record 클래스들에 이미 믹스인되어 있습니다.

11.2 Serializers

지원되는 인자 타입의 목록을 확장할 수 있습니다. 단지 자신만의 serializer를 정의하면 됩니다:

# app/serializers/money_serializer.rb
class MoneySerializer < ActiveJob::Serializers::ObjectSerializer
  # 지원되는 객체 타입을 사용하여 객체를 더 단순한 형태로 변환합니다.
  # 권장되는 형태는 특정 키를 가진 Hash입니다. 키는 기본 타입만 사용할 수 있습니다.
  # custom serializer 타입을 hash에 추가하기 위해 `super`를 호출해야 합니다.
  def serialize(money)
    super(
      "amount" => money.amount,
      "currency" => money.currency
    )
  end

  # 직렬화된 값을 적절한 객체로 변환합니다.
  def deserialize(hash)
    Money.new(hash["amount"], hash["currency"])
  end

  private
    # 인자가 이 serializer로 직렬화되어야 하는지 확인합니다.
    def klass
      Money
    end
end

그리고 이 serializer를 목록에 추가하세요:

# config/initializers/custom_serializers.rb
Rails.application.config.active_job.custom_serializers << MoneySerializer

초기화 중에 reloadable한 코드를 autoloading하는 것은 지원되지 않습니다. 따라서 serializer는 한 번만 로드되도록 설정하는 것이 권장됩니다. 예를 들어 config/application.rb를 다음과 같이 수정하면 됩니다:

# config/application.rb 
module YourApp
  class Application < Rails::Application
    config.autoload_once_paths << "#{root}/app/serializers"
  end
end

12 Exceptions

job 실행 중 발생하는 exception들은 rescue_from을 사용하여 처리할 수 있습니다:

class GuestsCleanupJob < ApplicationJob
  queue_as :default

  rescue_from(ActiveRecord::RecordNotFound) do |exception| 
    # 예외 발생 시 처리할 내용 
  end

  def perform
    # 나중에 실행할 내용
  end
end

작업에서 발생한 예외가 rescue되지 않으면 해당 작업은 "failed" 상태로 표시됩니다.

12.1 실패한 Job의 재시도 또는 폐기

실패한 job은 별도로 설정하지 않는 한 재시도되지 않습니다.

retry_on 또는 discard_on을 사용하여 실패한 job을 재시도하거나 폐기할 수 있습니다. 예를 들어:

class RemoteServiceJob < ApplicationJob
  retry_on CustomAppException # 기본값으로 3초 대기, 5번 시도

  discard_on ActiveJob::DeserializationError

  def perform(*args)
    # CustomAppException 또는 ActiveJob::DeserializationError를 발생시킬 수 있음
  end
end

12.2 Deserialization

GlobalID는 #perform에 전달된 전체 Active Record 객체를 직렬화할 수 있게 해줍니다.

전달된 레코드가 job이 enqueue된 후 #perform 메서드가 호출되기 전에 삭제된 경우, Active Job은 ActiveJob::DeserializationError 예외를 발생시킵니다.

13 Job 테스트

job을 테스트하는 방법에 대한 자세한 설명은 테스팅 가이드에서 확인할 수 있습니다.

14 디버깅

job이 어디서 오는지 파악하는데 도움이 필요하다면, verbose 로깅을 활성화할 수 있습니다.



맨 위로