본문으로 건너뛰기
김신건의 로그

[Rails] Active Job 고급: retry, schedule, callback

· 수정 · 📖 약 1분 · 423자/단어 #rails #active-job #background #sidekiq
active job retry, discard_on, ActiveJob callbacks, rails idempotency, queue priority

정의

Active Job 기본 사용은 별도 페이지. 이 문서는 재시도 전략, 콜백, 시리얼라이즈, 멱등성, 우선순위 등 고급 패턴을 다룬다.

Retry / Discard

class MyJob < ApplicationJob
  retry_on Net::OpenTimeout, wait: :exponentially_longer, attempts: 5
  retry_on ActiveRecord::Deadlocked, wait: :polynomially_longer, attempts: 3
  retry_on StandardError, wait: 10.seconds, attempts: 2

  discard_on ActiveJob::DeserializationError    # 영구 실패 (재시도 X)
  discard_on ActiveRecord::RecordNotFound

  def perform(record_id)
    record = Record.find(record_id)
    process(record)
  end
end

Wait 전략

retry_on Net::OpenTimeout,
  wait: :exponentially_longer,    # 5, 25, 125, ...초
  attempts: 5

retry_on RetryableError,
  wait: :polynomially_longer,      # 3, 18, 83, 258, 625초
  attempts: 5

retry_on FlakyError, wait: 30.seconds, attempts: 3

# 커스텀
retry_on MyError, wait: ->(executions) { 2.seconds * executions ** 2 }

무한 재시도 X

discard_on PermanentError

영구 에러는 DLQ 또는 알림. 무한 재시도는 자원 낭비.

Callbacks

class MyJob < ApplicationJob
  before_enqueue :log_enqueue
  after_enqueue :metrics_enqueue
  around_perform :timing

  before_perform :setup
  after_perform :cleanup

  def perform(*args)
    ...
  end

  private

  def log_enqueue
    Rails.logger.info("Enqueueing #{self.class}")
  end

  def timing
    start = Time.current
    yield
    elapsed = Time.current - start
    Metrics.record("job.duration", elapsed, job: self.class.name)
  end
end

Job arguments serialization

MyJob.perform_later(user)    # GlobalID 자동
MyJob.perform_later(user.id)    # primitive 권장

GlobalID로 ActiveRecord 자동 직렬화/역직렬화. 단 워커가 처리할 시점에 record가 존재해야:

class MyJob < ApplicationJob
  discard_on ActiveJob::DeserializationError
end

또는 ID 사용 + 명시적 처리:

def perform(user_id)
  user = User.find_by(id: user_id)
  return if user.nil?    # silently skip
  process(user)
end

멱등성 (Idempotency)

같은 job이 두 번 실행되어도 안전하게.

class SendInvoiceJob < ApplicationJob
  def perform(order_id)
    order = Order.find(order_id)
    return if order.invoice_sent_at.present?    # 이미 처리됨

    Order.transaction do
      InvoiceMailer.send_invoice(order).deliver_now
      order.update!(invoice_sent_at: Time.current)
    end
  end
end

Lock 기반

class CriticalJob < ApplicationJob
  def perform(resource_id)
    key = "lock:#{self.class.name}:#{resource_id}"

    if Redis.current.set(key, "1", nx: true, ex: 5.minutes.to_i)
      begin
        do_work(resource_id)
      ensure
        Redis.current.del(key)
      end
    else
      raise "Already processing"
    end
  end
end

또는 acidic_job gem.

큐 우선순위

class HighPriorityJob < ApplicationJob
  queue_as :high
end

class LowPriorityJob < ApplicationJob
  queue_as :low
end
# Sidekiq config/sidekiq.yml
:queues:
  - [high, 5]
  - [default, 3]
  - [low, 1]

가중치로 처리 비율.

Solid Queue:

production:
  workers:
    - queues: high
      threads: 5
      processes: 2
      polling_interval: 0.1
    - queues: default
      threads: 5
      processes: 1
      polling_interval: 0.5
    - queues: low
      threads: 2
      processes: 1
      polling_interval: 1

스케줄링

# 즉시
MyJob.perform_later(arg)

# 5분 후
MyJob.set(wait: 5.minutes).perform_later(arg)

# 특정 시간에
MyJob.set(wait_until: Time.current.tomorrow.noon).perform_later(arg)

# 우선순위 (Sidekiq Pro 등)
MyJob.set(priority: 10).perform_later(arg)

정기 작업 (cron)

Sidekiq + sidekiq-cron

# config/sidekiq.yml
:schedule:
  daily_report:
    cron: "0 9 * * *"    # 매일 09:00
    class: DailyReportJob

Solid Queue + recurring

# config/recurring.yml
production:
  daily_report:
    class: DailyReportJob
    schedule: every day at 9am
  cleanup:
    class: CleanupJob
    schedule: every 1 hour

3.4+ Sidekiq cron 빌트인. 활용 권장.

Bulk enqueue (8.0+)

ActiveJob.perform_all_later(
  posts.map { |p| IndexPostJob.new(p.id) }
)

100 jobs를 한 번에 enqueue → 워커 RTT 감소.

Worker 모니터링

Mission Control (Rails 8 권장)

gem "mission_control-jobs"
mount MissionControl::Jobs::Engine, at: "/jobs"

웹 UI에서 job 큐, 실패, 재시도 관리.

Sidekiq Web

require "sidekiq/web"
authenticate :user, ->(u) { u.admin? } do
  mount Sidekiq::Web => "/sidekiq"
end

트랜잭션 안전

ActiveRecord::Base.transaction do
  user = User.create!(...)
  WelcomeEmailJob.perform_later(user.id)
end
# 트랜잭션 rollback 시 job은 이미 enqueue → 워커가 user 못 찾음 → 재시도 무한

해결:

after_commit

class User < ApplicationRecord
  after_create_commit -> { WelcomeEmailJob.perform_later(id) }
end

commit 후에만 enqueue.

Solid Queue (DB 트랜잭션 안전)

같은 DB라 트랜잭션 안에서 enqueue OK. rollback 시 job도 사라짐.

Exception 처리

class MyJob < ApplicationJob
  rescue_from(StandardError) do |exception|
    Sentry.capture_exception(exception)
    raise    # 재발생 → retry/discard 규칙 적용
  end

  def perform(*args)
    ...
  end
end

Sentry, Rollbar 등 자동 캡처.

자주 보는 패턴

Webhook 처리

class WebhookProcessJob < ApplicationJob
  queue_as :webhooks

  retry_on StandardError, wait: 30.seconds, attempts: 5

  def perform(event_id)
    event = WebhookEvent.find(event_id)
    return if event.processed?

    WebhookEvent.transaction do
      ExternalAPI.confirm(event.external_id)
      event.update!(processed: true, processed_at: Time.current)
    end
  end
end

Image processing

class ImageProcessJob < ApplicationJob
  queue_as :media

  def perform(image_id)
    image = Image.find(image_id)
    image.variants.each do |size|
      image.process(size)
    end
    image.update!(processed: true)
  end
end

Email digest

class DailyDigestJob < ApplicationJob
  def perform
    User.where(daily_digest: true).find_each(batch_size: 100) do |user|
      DigestMailer.daily(user).deliver_later(queue: :emails)
    end
  end
end

# cron으로 매일 호출

작업 단위를 더 작게 분리 → 워커 효율.

Batch with progress

class ImportJob < ApplicationJob
  def perform(import_id)
    import = Import.find(import_id)

    rows = CSV.read(import.file_path)
    rows.each_with_index do |row, i|
      Record.create!(...)
      if i % 100 == 0
        import.update!(progress: (i.to_f / rows.size * 100).round)
        ActionCable.server.broadcast("import_#{import.id}", progress: import.progress)
      end
    end

    import.update!(completed_at: Time.current)
  end
end

긴 작업 + 실시간 진행률.

함정

1. 큰 인자

MyJob.perform_later(huge_array)    # serialize 비용 + queue 메모리

ID 전달.

2. perform vs perform_now vs perform_later

MyJob.perform(args)         # 클래스 메서드 호출 (NoMethodError)
MyJob.new.perform(args)     # 인스턴스 직접 호출 (이상함)
MyJob.perform_now(args)     # 동기 실행 (테스트, 디버그)
MyJob.perform_later(args)   # 백그라운드

표준은 perform_later.

3. session/request context 누락

class MyJob < ApplicationJob
  def perform(user_id)
    user = User.find(user_id)
    # request.host, current_user 등 없음 (다른 프로세스)
  end
end

URL helper 사용 시 Rails.application.routes.default_url_options[:host] 설정.

4. 비결정적 처리

def perform(date_string)
  date = Date.parse(date_string)
  process(date)
end

문자열 vs Date 객체. 직렬화는 문자열이 안전.

5. 디버깅 어려움

별도 프로세스 → log 의존. perform_now 동기 실행으로 디버거 진입.

Rails.logger.info("Starting #{self.class} with #{arguments.inspect}")

각 단계 로깅.

이 개념을 다룬 위키 페이지 (1)

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기