액티브 잡 기본 사항

이 가이드는 백그라운드 작업을 생성, 대기열에 넣기 및 실행하는 데 필요한 모든 것을 제공합니다.

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

  • 작업을 생성하는 방법
  • 작업을 대기열에 넣는 방법
  • 백그라운드에서 작업을 실행하는 방법
  • 애플리케이션에서 비동기적으로 이메일을 보내는 방법

액티브 잡이란 무엇인가?

액티브 잡은 다양한 대기열 백엔드에서 작업을 선언하고 실행할 수 있는 프레임워크입니다. 이러한 작업은 정기적인 정리, 청구 요금, 메일링 등 다양할 수 있습니다. 작은 단위의 작업으로 나눌 수 있고 병렬로 실행할 수 있는 모든 것이 해당됩니다.

액티브 잡의 목적

주요 목적은 모든 Rails 애플리케이션에 작업 인프라를 갖추도록 하는 것입니다. 그러면 프레임워크 기능과 다른 젬들이 API 차이에 대해 걱정하지 않고 그 위에 구축할 수 있습니다. 큐잉 백엔드를 선택하는 것은 운영상의 문제가 됩니다. 그리고 작업을 다시 작성하지 않고도 백엔드를 전환할 수 있습니다.

참고: Rails는 기본적으로 프로세스 내부 스레드 풀을 사용하여 작업을 비동기적으로 실행하는 구현을 제공합니다. 작업은 비동기적으로 실행되지만, 대기열의 작업은 재시작 시 삭제됩니다.

작업 생성 및 대기열에 넣기

이 섹션에서는 작업을 생성하고 대기열에 넣는 단계별 가이드를 제공합니다.

작업 생성하기

액티브 잡은 작업을 생성하기 위한 Rails 생성기를 제공합니다. 다음 명령은 app/jobs에 작업을 생성하고 (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

특정 대기열에서 실행되는 작업을 생성할 수도 있습니다:

$ bin/rails generate job guests_cleanup --queue urgent

생성기를 사용하지 않으려면 app/jobs 내에 직접 파일을 만들면 됩니다. 단, ApplicationJob을 상속해야 합니다.

작업의 모습은 다음과 같습니다:

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

작업 대기열에 넣기

perform_later와 선택적으로 set을 사용하여 작업을 대기열에 넣습니다. 다음과 같이 사용합니다:

# 큐잉 시스템을 사용할 수 있게 되면 즉시 실행되도록 작업을 대기열에 넣습니다.
GuestsCleanupJob.perform_later guest
# 내일 정오에 실행되도록 작업을 대기열에 넣습니다.
GuestsCleanupJob.set(wait_until: Date.tomorrow.noon).perform_later(guest)
# 1주일 후에 실행되도록 작업을 대기열에 넣습니다.
GuestsCleanupJob.set(wait: 1.week).perform_later(guest)
# `perform_now`와 `perform_later`는 내부적으로 `perform`을 호출하므로 `perform`에 정의된 만큼의 인수를 전달할 수 있습니다.
GuestsCleanupJob.perform_later(guest1, guest2, filter: 'some_filter')

끝!

대량으로 작업 대기열에 넣기

perform_all_later를 사용하여 여러 작업을 한 번에 대기열에 넣을 수 있습니다. 자세한 내용은 대량 대기열 넣기를 참조하세요.

작업 실행

프로덕션에서 작업을 대기열에 넣고 실행하려면 대기열 백엔드를 설정해야 합니다. 즉, Rails에서 사용할 3rd-party 대기열 라이브러리를 선택해야 합니다. Rails 자체는 프로세스 내부 대기열 시스템만 제공하며, 이는 작업을 RAM에만 보관합니다. 프로세스가 충돌하거나 머신이 재설정되면 기본 async 백엔드의 모든 미처리 작업이 손실됩니다. 이는 작은 애플리케이션이나 중요하지 않은 작업에는 괜찮을 수 있지만, 대부분의 프로덕션 애플리케이션에서는 지속성 있는 백엔드를 선택해야 합니다.

백엔드

액티브 잡은 여러 대기열 백엔드(Sidekiq, Resque, Delayed Job 등)에 대한 내장 어댑터를 제공합니다. 최신 어댑터 목록은 ActiveJob::QueueAdapters API 문서를 참조하세요.

백엔드 설정하기

config.active_job.queue_adapter를 사용하여 대기열 백엔드를 쉽게 설정할 수 있습니다:

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

작업별로 백엔드를 구성할 수도 있습니다:

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

# 이제 이 작업은 `config.active_job.queue_adapter`에서 설정한 것을 재정의하여 `resque`를 백엔드 대기열 어댑터로 사용합니다.

백엔드 시작하기

작업은 Rails 애플리케이션과 병렬로 실행되므로 대부분의 대기열 라이브러리에서는 작업 처리를 위해 라이브러리 특정 대기열 서비스를 시작해야 합니다(Rails 앱을 시작하는 것 외에). 라이브러리 문서를 참조하여 큐 백엔드를 시작하는 방법을 확인하세요.

다음은 문서 목록입니다:

대기열

대부분의 어댑터는 여러 대기열을 지원합니다. 액티브 잡에서는 queue_as를 사용하여 특정 대기열에서 작업을 실행하도록 예약할 수 있습니다:

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

application.rb에서 config.active_job.queue_name_prefix를 사용하여 모든 작업의 대기열 이름에 접두사를 붙일 수 있습니다:

# config/application.rb
module YourApp
  class Application < Rails::Application
    config.active_job.queue_name_prefix = Rails.env
  end
end
# app/jobs/guests_cleanup_job.rb
class GuestsCleanupJob < ApplicationJob
  queue_as :low_priority
  # ...
end

# 이제 프로덕션 환경에서는 production_low_priority 대기열에서, 스테이징 환경에서는 staging_low_priority 대기열에서 작업이 실행됩니다.

작업별로 접두사를 구성할 수도 있습니다.

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

# 이제 작업의 대기열에 접두사가 붙지 않습니다. `config.active_job.queue_name_prefix`에서 설정한 것을 재정의합니다.

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

# config/application.rb
module YourApp
  class Application < Rails::Application
    config.active_job.queue_name_prefix = Rails.env
    config.active_job.queue_name_delimiter = '.'
  end
end
# app/jobs/guests_cleanup_job.rb
class GuestsCleanupJob < ApplicationJob
  queue_as :low_priority
  # ...
end

# 이제 프로덕션 환경에서는 production.low_priority 대기열에서, 스테이징 환경에서는 staging.low_priority 대기열에서 작업이 실행됩니다.

작업 수준에서 대기열을 제어하려면 queue_as 블록을 전달할 수 있습니다. 블록은 작업 컨텍스트에서 실행되므로(self.arguments 접근 가능) 대기열 이름을 반환해야 합니다:

class ProcessVideoJob < ApplicationJob
  queue_as do
    video = self.arguments.first
    if video.owner.premium?
      :premium_videojobs
    else
      :videojobs
    end
  end

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

작업이 실행될 대기열을 더 세밀하게 제어하려면 set:queue 옵션을 전달할 수 있습니다:

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

참고: 대기열 백엔드가 대기열 이름을 “듣고” 있는지 확인하세요. 일부 백엔드에서는 청취할 대기열을 지정해야 합니다.

우선순위

일부 어댑터는 작업 계속해서 한국어 번역문은 다음과 같습니다:

우선순위

일부 어댑터는 작업 수준에서 우선순위를 지원하여 대기열 내부 또는 전체 대기열에서 작업을 상대적으로 우선순위화할 수 있습니다.

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

class GuestsCleanupJob < ApplicationJob
  queue_with_priority 10
  # ...
end

이 기능은 우선순위를 지원하지 않는 어댑터에서는 효과가 없습니다.

queue_as와 마찬가지로 queue_with_priority에 작업 컨텍스트에서 평가되는 블록을 전달할 수도 있습니다:

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)

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

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

콜백

액티브 잡은 작업 수명 주기 동안 로직을 트리거할 수 있는 훅을 제공합니다. Rails의 다른 콜백과 마찬가지로, 일반 메서드로 구현하고 매크로 스타일 클래스 메서드를 사용하여 콜백으로 등록할 수 있습니다:

class GuestsCleanupJob < ApplicationJob
  queue_as :default

  around_perform :around_cleanup

  def perform
    # 나중에 무언가를 합니다
  end

  private
    def around_cleanup
      # 수행 전에 무언가를 합니다
      yield
      # 수행 후에 무언가를 합니다
    end
end

매크로 스타일 클래스 메서드는 블록을 받을 수도 있습니다. 블록 내부의 코드가 한 줄로 충분할 경우 이 스타일을 고려해 볼 수 있습니다. 예를 들어, 모든 작업이 대기열에 추가될 때마다 메트릭을 보낼 수 있습니다:

class ApplicationJob < ActiveJob::Base
  before_enqueue { |job| $statsd.increment "#{job.class.name.underscore}.enqueue" }
end

사용 가능한 콜백

perform_all_later를 사용하여 작업을 대량으로 대기열에 넣을 때는 around_enqueue와 같은 콜백이 개별 작업에 트리거되지 않습니다. 대량 대기열 넣기 콜백을 참조하세요.

대량 대기열 넣기

perform_all_later를 사용하여 여러 작업을 한 번에 대기열에 넣을 수 있습니다. 대량 대기열 넣기는 대기열 데이터 저장소(Redis 또는 데이터베이스)에 대한 왕복 횟수를 줄여 개별적으로 동일한 작업을 대기열에 넣는 것보다 더 효율적인 작업입니다.

perform_all_later는 액티브 잡의 최상위 API입니다. 인스턴스화된 작업을 인수로 받습니다(이는 perform_later와 다릅니다). perform_all_later는 내부적으로 perform을 호출합니다. new에 전달된 인수는 perform이 실행될 때 전달됩니다.

GuestCleanupJob 인스턴스를 perform_all_later에 전달하는 예시는 다음과 같습니다:

# `GuestsCleanupJob` 인스턴스를 생성하여 `perform_all_later`에 전달합니다.
# `new`의 인수는 `perform`으로 전달됩니다.
guest_cleanup_jobs = Guest.all.map { |guest| GuestsCleanupJob.new(guest) }

# `GuestsCleanupJob` 인스턴스별로 별도의 작업을 대기열에 넣습니다.
ActiveJob.perform_all_later(guest_cleanup_jobs)

# `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는 성공적으로 대기열에 넣은 작업 수를 기록합니다. 예를 들어 위의 Guest.all.map이 3개의 guest_cleanup_jobs를 생성한 경우 “Async에 3개의 작업을 대기열에 넣었습니다(3개의 GuestsCleanupJob)"와 같이 기록합니다(모두 대기열에 넣어졌다고 가정).

perform_all_later의 반환 값은 nil입니다. 이는 perform_later가 대기열에 넣은 작업 클래스의 인스턴스를 반환하는 것과 다릅니다.

다양한 액티브 잡 클래스 대기열에 넣기

perform_all_later를 사용하면 서로 다른 액티브 잡 클래스 인스턴스를 동일한 호출에서 대기열에 넣을 수 있습니다. 예:

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

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

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

# 여러 클래스의 작업 인스턴스를 한 번에 대기열에 넣습니다.
ActiveJob.perform_all_later(cleanup_job, export_job, notify_job)

대량 대기열 넣기 콜백

perform_all_later를 사용하여 작업을 대량으로 대기열에 넣을 때는 around_enqueue와 같은 콜백이 개별 작업에 트리거되지 않습니다. 이 동작은 다른 Active Record 대량 메서드와 일치합니다. 콜백은 개별 작업에서 실행되므로 이 메서드의 대량 특성을 활용할 수 없습니다.

그러나 perform_all_later 메서드는 ActiveSupport::Notifications를 사용하여 구독할 수 있는 enqueue_all.active_job 이벤트를 발생시킵니다.

successfully_enqueued? 메서드를 사용하여 특정 작업이 성공적으로 대기열에 넣어졌는지 확인할 수 있습니다.

대기열 백엔드 지원

perform_all_later의 경우 대량 대기열 넣기는 대기열 백엔드에 의해 지원되어야 합니다.

예를 들어, Sidekiq에는 Redis에 많은 작업을 밀어넣고 왕복 네트워크 지연 시간을 방지할 수 있는 push_bulk 메서드가 있습니다. GoodJob도 GoodJob::Bulk.enqueue 메서드를 통해 대량 대기열 넣기를 지원합니다. 새로운 대기열 백엔드 Solid Queue도 대량 대기열 넣기 지원을 추가했습니다.

대기열 백엔드가 대량 대기열 넣기를 지원하지 않는 경우 perform_all_later는 작업을 하나씩 대기열에 넣습니다.

액션 메일러

현대 웹 애플리케이션에서 가장 일반적인 작업 중 하나는 요청-응답 주기 외부에서 이메일을 보내는 것이므로 사용자가 기다릴 필요가 없습니다. 액티브 잡은 액션 메일러와 통합되어 쉽게 비동기적으로 이메일을 보낼 수 있습니다:

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

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

참고: Rake 작업(예: .deliver_later를 사용하여 이메일 보내기)에서 비동기 대기열을 사용하면 일반적으로 작동하지 않습니다. Rake는 .deliver_later 이메일이 모두 처리되기 전에 종료될 수 있기 때문입니다. 이 문제를 해결하려면 .deliver_now를 사용하거나 개발 환경에서 지속적인 대기열을 실행하세요.

국제화

각 작업은 작업이 생성될 때 설정된 I18n.locale을 사용합니다. 이는 비동기적으로 이메일을 보내는 경우 유용합니다:

I18n.locale = :eo

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

인수에 대한 지원 유형

액티브 잡은 기본적으로 다음과 같은 유형의 인수를 지원합니다:

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

GlobalID

액티브 잡은 GlobalID를 인수로 지원합니다. 이를 통해 클래스/ID 쌍 대신 실제 Active Record 객체를 작업에 전달할 수 있으므로 수동으로 역직렬화할 필요가 없습니다. 이전에는 작업이 다음과 같이 보였습니다:

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 클래스)에 적용됩니다계속해서 한국어 번역문은 다음과 같습니다:

직렬화기

지원되는 인수 유형 목록을 확장할 수 있습니다. 자신만의 직렬화기를 정의하면 됩니다:

# app/serializers/money_serializer.rb
class MoneySerializer < ActiveJob::Serializers::ObjectSerializer
  # 인수가 이 직렬화기에 의해 직렬화되어야 하는지 확인합니다.
  def serialize?(argument)
    argument.is_a? Money
  end

  # 지원되는 객체 유형을 사용하여 객체를 더 단순한 대표로 변환합니다.
  # 권장되는 대표는 특정 키가 있는 해시입니다. 키는 기본 유형만 가능합니다.
  # `super`를 호출하여 사용자 정의 직렬화기 유형을 해시에 추가해야 합니다.
  def serialize(money)
    super(
      "amount" => money.amount,
      "currency" => money.currency
    )
  end

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

그리고 이 직렬화기를 목록에 추가합니다:

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

초기화 중에 재로드 가능한 코드를 자동 로드하는 것은 지원되지 않습니다. 따라서 직렬화기를 한 번만 로드되도록 설정하는 것이 좋습니다. 예를 들어 config/application.rb를 다음과 같이 수정할 수 있습니다:

# config/application.rb
module YourApp
  class Application < Rails::Application
    config.autoload_once_paths << Rails.root.join('app', 'serializers')
  end
end

예외

작업 실행 중 발생한 예외는 rescue_from으로 처리할 수 있습니다:

class GuestsCleanupJob < ApplicationJob
  queue_as :default

  rescue_from(ActiveRecord::RecordNotFound) do |exception|
    # 예외로 무언가를 합니다
  end

  def perform
    # 나중에 무언가를 합니다
  end
end

작업에서 예외가 처리되지 않으면 "실패한” 것으로 간주됩니다.

실패한 작업 재시도 또는 삭제

실패한 작업은 별도로 구성하지 않는 한 재시도되지 않습니다.

retry_on 또는 discard_on을 사용하여 실패한 작업을 재시도하거나 삭제할 수 있습니다. 예:

class RemoteServiceJob < ApplicationJob
  retry_on CustomAppException # 기본값은 3초 대기, 5회 시도

  discard_on ActiveJob::DeserializationError

  def perform(*args)
    # CustomAppException 또는 ActiveJob::DeserializationError 발생 가능
  end
end

역직렬화

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

작업이 대기열에 넣어진 후 #perform 메서드가 호출되기 전에 전달된 레코드가 삭제된 경우 액티브 잡은 ActiveJob::DeserializationError 예외를 발생시킵니다.

작업 테스트

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

디버깅

작업이 어디에서 오는지 파악하는 데 도움이 필요한 경우 자세한 로깅을 활성화할 수 있습니다.