Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Sidekiq에서 중복 작업을 피하는 세 가지 방법

Ruby 코드를 작성하고 있다면 아마 백그라운드 작업 처리에 Sidekiq을 사용하고 있을 가능성이 높습니다. ActiveJob 환경에서 오신 분들도 계속 읽어보세요. 이 글에서 다루는 팁 중 일부는 그곳에도 그대로 적용할 수 있습니다.

개발자들은 다양한 목적으로 Sidekiq 같은 백그라운드 잡(job)을 활용합니다. 어떤 이들은 숫자를 집계하고, 어떤 이들은 사용자에게 환영 이메일을 발송하고, 또 어떤 이들은 데이터 동기화를 예약합니다. 용도가 무엇이든 언젠가는 중복 작업을 방지해야 하는 요구사항에 직면하게 됩니다. 여기서 말하는 중복 작업이란 정확히 동일한 일을 수행하는 두 개의 잡을 의미합니다. 지금부터 하나씩 살펴보겠습니다.

왜 잡을 중복 제거(De-Duplicate)해야 할까?

다음과 같은 잡이 있다고 상상해 보세요.

class BookSalesWorker
  include Sidekiq::Worker
 
  def perform(book_id)
    crunch_some_numbers(book_id)
 
    upload_to_s3
  end
 
  ...
end

BookSalesWorker는 항상 똑같은 일을 합니다 — book_id로 DB에서 책을 조회하고, 최신 판매 데이터를 가져와 지표를 계산한 뒤, 그 결과를 스토리지 서비스에 업로드합니다. 웹사이트에서 책이 팔릴 때마다 이 잡이 큐에 등록된다는 점에 유의하세요.

그렇다면 한 번에 100건의 판매가 발생하면 어떻게 될까요? 정확히 같은 일을 하는 100개의 잡이 생깁니다. S3 쓰기 비용이 크게 부담되지 않고 큐가 혼잡하지 않아 부하를 감당할 수 있다면 괜찮다고 느낄 수도 있습니다. 하지만 "확장성은 있을까요?"™️

솔직히 답은 '아니오'입니다. 더 많은 책의 판매가 몰리기 시작하면 큐에는 불필요한 작업이 순식간에 쌓입니다. 한 권의 책에 대해 동일한 일을 하는 잡이 100개 있고, 10권의 책이 동시에 팔리고 있다면 실제로는 책당 하나씩 10개의 잡만 있으면 되는 상황에서 큐에 1,000개의 잡이 쌓이게 됩니다.

이제 큐에 중복 작업이 쌓이는 것을 막을 수 있는 몇 가지 방법을 알아보겠습니다.

1. DIY 방식

외부 의존성이나 복잡한 로직을 선호하지 않는다면 코드베이스에 직접 커스텀 솔루션을 구현할 수 있습니다. 예제를 직접 실행해 볼 수 있도록 샘플 저장소를 만들어 두었으며, 각 방법마다 해당 예제로 연결되는 링크를 제공합니다.

1.1 플래그 하나 사용하기

잡을 큐에 넣을지 말지를 결정하는 플래그 하나를 추가하는 방법입니다. 예를 들어 Book 테이블에 sales_enqueued_at 컬럼을 두고 관리할 수 있습니다.

module BookSalesService
  def schedule_with_one_flag(book)
    # 마지막으로 잡이 등록된 지 10분이 지났는지 확인
    if book.sales_enqueued_at < 10.minutes.ago
      book.update(sales_enqueued_at: Time.current)
 
      BookSalesWorker.perform_async(book.id)
    end
  end
end

즉, 마지막 잡이 등록된 시점으로부터 10분이 지나기 전에는 새로운 잡이 등록되지 않습니다. 10분이 지난 후에야 sales_enqueued_at을 갱신하고 새 잡을 큐에 넣습니다.

또 다른 방법으로 불리언 플래그 하나를 사용할 수도 있습니다. 예를 들어 crunching_sales 플래그를 두고, 첫 번째 잡을 등록하기 전에 true로 설정한 뒤 잡이 완료되면 false로 되돌립니다. crunching_sales가 false가 될 때까지 다른 잡의 등록 시도는 모두 거부됩니다.

이 방법은 예제 저장소에서 직접 확인해 볼 수 있습니다.

1.2 플래그 두 개 사용하기

잡을 10분간 '잠그는' 방식이 부담스럽지만, 코드에 플래그를 추가로 두는 것은 괜찮다면 다음 방법이 적합할 수 있습니다.

기존의 sales_enqueued_at에 더해 sales_calculated_at이라는 플래그를 하나 더 추가합니다. 코드는 대략 다음과 같아집니다.

module BookSalesService
  def schedule_with_two_flags(book)
    # 지금 판매 데이터를 계산 중인지 확인
    if book.sales_enqueued_at <= book.sales_calculated_at
      book.update(sales_enqueued_at: Time.current)
 
      BookSalesWorker.perform_async(book.id)
    end
  end
end
 
class BookSalesWorker
  include Sidekiq::Worker
 
  def perform(book_id)
    crunch_some_numbers(book_id)
 
    upload_to_s3
 
    # 새로 추가된 부분
    book.update(sales_calculated_at: Time.current)
  end
 
  ...
end

예제 저장소의 안내에 따라 직접 테스트해 볼 수 있습니다.

이제 잡이 등록된 시점부터 완료될 때까지의 구간을 통제할 수 있습니다. 이 구간 동안에는 어떤 잡도 등록될 수 없습니다. 잡이 실행되는 동안에는 sales_enqueued_atsales_calculated_at보다 최신이고, 잡이 완료되면 sales_calculated_at이 더 최신이 되어 새로운 잡이 등록될 수 있습니다.

두 개의 플래그를 사용하면 UI에 판매 지표가 마지막으로 갱신된 시각을 보여줄 수도 있다는 장점이 있습니다. 데이터를 보는 사용자가 정보의 신선도를 파악할 수 있으니 일석이조입니다.

플래그 방식 정리

급한 상황에서 이런 솔루션을 만들고 싶은 유혹이 들 수 있지만, 다소 투박해 보이고 오버헤드도 추가됩니다. 사용 사례가 단순하다면 괜찮지만, 로직이 복잡해지거나 부족함이 드러나는 순간에는 다른 옵션을 고려해 보시길 권합니다.

플래그 방식의 가장 큰 단점은 그 10분 사이에 등록을 시도했던 모든 잡을 잃어버린다는 것입니다. 반대로 가장 큰 장점은 외부 의존성을 도입하지 않아도 되고, 큐의 잡 수를 빠르게 줄일 수 있다는 점입니다.

1.3 큐 직접 탐색하기

또 다른 접근법은 커스텀 락킹 메커니즘을 만들어 동일한 잡이 중복 등록되는 것을 막는 것입니다. 관심 있는 Sidekiq 큐를 순회하면서 해당 워커의 잡이 이미 존재하는지 확인하는 방식입니다. 코드는 대략 다음과 같습니다.

module BookSalesService
  def schedule_unique_across_queue(book)
    queue = Sidekiq::Queue.new('default')
 
    queue.each do |job|
      return if job.klass == BookSalesWorker.to_s &&
        job.args == [book.id]
    end
 
    BookSalesWorker.perform_async(book.id)
  end
end
 
class BookSalesWorker
  include Sidekiq::Worker
 
  def perform(book_id)
    crunch_some_numbers(book_id)
 
    upload_to_s3
  end
 
  ...
end

위 예제에서는 'default' 큐에 BookSalesWorker 클래스 이름을 가진 잡이 있는지 확인하고, 잡의 인자가 해당 book ID와 일치하는지도 검사합니다. 동일한 Book ID를 가진 BookSalesWorker 잡이 이미 큐에 있다면 조기에 반환하여 새 잡을 등록하지 않습니다.

단, 잡을 매우 빠른 속도로 연속 등록하면 큐가 아직 비어 있는 상태에서 일부 잡이 중복으로 등록될 수 있습니다. 실제로 로컬에서 다음과 같이 테스트할 때 같은 현상을 경험했습니다.

100.times { BookSalesService.schedule_unique_across_queue(book) }

예제 저장소에서 직접 확인해 볼 수 있습니다.

이 방법의 장점은 필요하다면 모든 큐를 순회하며 기존 잡을 검색할 수 있다는 것입니다. 단점은 큐가 비어 있는 상태에서 잡을 대량으로 한꺼번에 등록하면 여전히 중복이 발생할 수 있다는 점, 그리고 잡을 하나 등록하기 전에 큐의 모든 잡을 순회해야 하므로 큐 크기에 따라 비용이 클 수 있다는 점입니다.

2. Sidekiq Enterprise로 업그레이드

여러분이나 회사에 여유 예산이 있다면 Sidekiq Enterprise 버전으로 업그레이드할 수 있습니다. 월 $179부터 시작하며, 중복 작업을 방지하는 데 도움이 되는 강력한 기능을 제공합니다. 안타깝게도 필자는 Sidekiq Enterprise를 사용해 보지 못했지만, 공식 문서만으로도 충분히 이해할 수 있을 것입니다. 다음과 같은 코드로 손쉽게 유니크(중복 없는) 잡을 만들 수 있습니다.

class BookSalesWorker
  include Sidekiq::Worker
  sidekiq_options unique_for: 10.minutes
 
  def perform(book_id)
    crunch_some_numbers(book_id)
 
    upload_to_s3
  end
 
  ...
end

끝입니다. 앞서 소개한 '플래그 하나 사용하기' 섹션과 유사한 동작을 하는 잡 구현체가 완성됩니다. 잡이 10분간 유니크하게 유지되므로, 그 기간 동안 동일한 인자를 가진 다른 잡은 등록될 수 없습니다.

한 줄로 해결되니 참 깔끔하죠? Sidekiq Enterprise를 쓰고 계셨는데 이 기능을 이제야 알게 되셨다면, 이 글이 도움이 되었기를 진심으로 바랍니다. 하지만 대부분은 사용하지 않으실 테니 다음 솔루션으로 넘어가겠습니다.

3. sidekiq-unique-jobs 젬으로 해결하기

네, 젬(gem) 이야기를 하려는 것을 압니다. 그리고 그 안에 일부 사람들을 선뜻 받아들이기 어렵게 만드는 Lua 파일이 포함되어 있다는 사실도요. 그래도 끝까지 읽어주세요. 정말 훌륭한 선택지입니다. sidekiq-unique-jobs 젬은 다양한 락킹 옵션과 설정 기능을 제공합니다 — 필요한 것보다 많을 정도입니다.

빠르게 시작하려면 Gemfile에 sidekiq-unique-jobs를 추가하고 bundle을 실행한 뒤, 워커를 다음과 같이 설정하면 됩니다.

class UniqueBookSalesWorker
  include Sidekiq::Worker
 
  sidekiq_options lock: :until_executed,
                  on_conflict: :reject
 
  def perform(book_id)
    book = Book.find(book_id)
 
    logger.info "I am a Sidekiq Book Sales worker - I started"
    sleep 2
    logger.info "I am a Sidekiq Book Sales worker - I finished"
 
    book.update(sales_calculated_at: Time.current)
    book.update(crunching_sales: false)
  end
end

설정할 수 있는 옵션이 매우 많지만, 저는 다음처럼 단순화해서 사용하기로 했습니다.

sidekiq_options lock: :until_executed, on_conflict: :reject

lock: :until_executed는 첫 번째 UniqueBookSalesWorker 잡을 실행이 완료될 때까지 잠급니다. 그리고 on_conflict: :reject를 통해 충돌하는 다른 잡들은 모두 거부되어 dead 큐로 보내집니다. 여기서 달성한 결과는 앞서 소개한 DIY 예제들과 유사합니다.

DIY 예제 대비 나아진 점은 무슨 일이 일어났는지에 대한 기록이 남는다는 것입니다. 실제 모습을 확인해 보기 위해 다음을 실행해 보세요.

5.times { UniqueBookSalesWorker.perform_async(Book.last.id) }

단 하나의 잡만 완전히 실행되고, 나머지 네 개의 잡은 dead 큐로 전송되어 나중에 재시도할 수 있습니다. 중복 잡을 그냥 무시했던 앞선 예제들과 차별화되는 부분입니다.

락킹과 충돌 처리와 관련해서는 선택 가능한 옵션이 매우 다양합니다. 자세한 내용은 젬의 공식 문서를 참고하여 여러분의 사용 사례에 맞는 설정을 찾아보시길 권합니다.

강력한 인사이트 기능

이 젬의 훌륭한 점은 현재 걸려 있는 락과 큐에서 벌어진 이력을 웹 UI에서 직접 확인할 수 있다는 것입니다. config/routes.rb에 다음 라인을 추가하기만 하면 됩니다.

# config/routes.rb
require 'sidekiq_unique_jobs/web'

Rails.application.routes.draw do
  mount Sidekiq::Web, at: '/sidekiq'
end

기존의 Sidekiq Web UI에 더해, 잡 락을 보여주는 페이지와 변경 이력(changelog) 페이지 두 개가 추가됩니다. 실제 화면은 다음과 같습니다.

'Locks'와 'Changelogs'라는 두 개의 새로운 페이지가 생긴 것을 확인할 수 있습니다. 정말 유용한 기능입니다.

젬이 설치되어 바로 사용할 수 있는 예제 프로젝트에서 이 모든 것을 직접 체험해 보세요.

왜 Lua인가?

먼저 밝히자면, 저는 이 젬의 저자가 아니기 때문에 어디까지나 추측입니다. 처음 이 젬을 접했을 때 'Ruby 젬 안에 왜 Lua를 사용하지?'라고 궁금했습니다. 언뜻 이상해 보일 수 있지만, Redis는 Lua 스크립트 실행을 지원합니다. 아마 젬 저자는 이 점에 주목해 더 민첩한 로직을 Lua로 구현하고 싶었던 것 같습니다.

젬 저장소의 Lua 파일들을 살펴보면 생각보다 복잡하지 않습니다. 모든 Lua 스크립트는 이후 Ruby 코드의 SidekiqUniqueJobs::Script::Caller에서 호출됩니다. 소스 코드를 직접 읽어보면 내부 동작 원리를 이해하는 데 큰 재미가 있습니다.

대안 젬

ActiveJob을 폭넓게 사용하고 있다면 active-job-uniqueness 젬을 시도해 볼 수 있습니다. 아이디어는 비슷하지만 커스텀 Lua 스크립트 대신 Redlock을 사용해 Redis의 항목을 잠급니다.

이 젬으로 유니크한 잡을 만들려면 다음과 같은 잡을 상상해 볼 수 있습니다.

class BookSalesJob < ActiveJob::Base
  unique :until_executed
 
  def perform
    ...
  end
end

문법이 더 간결하지만 sidekiq-unique-jobs 젬과 매우 유사합니다. ActiveJob에 크게 의존하는 프로젝트라면 이 젬이 딱 맞을 수 있습니다.

마치며

애플리케이션에서 중복 잡을 다루는 방법에 대한 유용한 지식을 얻어가셨기를 바랍니다. 다양한 솔루션을 리서치하고 실험해 보는 과정이 정말 즐거웠습니다. 원하는 것을 찾지 못하셨더라도, 이 글의 예제들이 여러분만의 솔루션을 만드는 영감이 되기를 희망합니다.

모든 코드 스니펫이 담긴 예제 프로젝트도 함께 확인해 보세요.

다음 글에서 다시 만나요. 감사합니다.

P.S. Ruby Magic의 글을 발행 즉시 읽고 싶으시다면 Ruby Magic 뉴스레터를 구독하세요. 어떤 글도 놓치지 않을 수 있습니다!