이 시리즈의 이전 글에서 우리는 백그라운드 작업 처리 시스템이 필요하다면 Solid Queue가 훌륭한 선택이라는 사실을 확인했습니다. 모든 작업을 데이터베이스에 저장함으로써 외부 의존성을 최소화한다는 점 — Redis가 전혀 필요 없다는 점! — 이 인상적이면서도, 놀랍도록 뛰어난 성능까지 갖추고 있습니다.
하지만 프로덕션 환경에서 사용할 준비가 된 백그라운드 작업 시스템에는 뛰어난 성능만으로는 충분하지 않습니다. 오랜 세월 동안 Rails 개발자들은 훨씬 많은 것을 기대해왔습니다. 단순히 작업을 백그라운드에서 실행되도록 큐에 넣는 것에서 나아가, 작업을 특정 시점에 예약하거나, 주기적으로 반복 실행하고, 심지어 동시에 실행될 수 있는 작업 수까지 제한하고 싶어집니다. 더 많은 기능이 필요한 것이죠!
놀랍게도 Solid Queue는 이런 모든 기능을 별도 설정 없이 기본적으로 제공합니다. 지금부터 Solid Queue를 한층 깊이 파고들며, 이것이 어떻게 가능한지 함께 살펴보겠습니다!
Solid Queue로 작업 예약하기
먼저 간단히 복습해 보겠습니다. Solid Queue는 작업 데이터를 저장할 때 오직 여러분의 데이터베이스만 사용합니다. 내부적으로 하는 모든 일은 하나의 데이터베이스 테이블 위에서 이루어집니다. 작업 예약 — 즉 미래의 특정 시점에 실행되도록 작업을 지정하는 것 — 역시 예외가 아닙니다. 예약된 모든 작업은 solid_queue_scheduled_executions 테이블에 저장됩니다.
이 테이블은 solid_queue_ready_executions 테이블과 거의 동일합니다. 유일한 차이점은 예약된 작업이 언제 실행되어야 하는지를 알려주는 scheduled_at 컬럼이 추가되었다는 점입니다. 실제로 작업을 예약했을 때 어떤 일이 일어나는지 확인해 보겠습니다.
특별히 놀라운 부분은 없습니다. Solid Queue는 solid_queue_scheduled_executions 테이블에 새로운 행을 추가하며, 그 안에는 우리가 예상하는 데이터가 담겨 있습니다. 그렇다면 이 레코드가 존재하는 것에서 실제로 올바른 시점에 작업을 실행하기까지는 어떤 과정을 거칠까요?
여기에는 solid_queue_scheduled_executions 테이블을 지속적으로 폴링(polling)하는 프로세스가 필요합니다. 바로 디스패처(Dispatcher)입니다. 디스패처는 예약된 작업을 제시간에 실행하는 역할을 담당합니다. Solid Queue가 시작될 때 함께 시작되므로 추가 설정은 필요 없습니다. 다만 필요하다면 특정 설정으로 Solid Queue를 실행해 디스패처 프로세스만 따로 구동할 수도 있습니다.
디스패처 프로세스가 어떻게 감독되는지 궁금하실 수 있는데, 그 역할은 이름 그대로 Supervisor가 담당합니다. Supervisor는 Solid Queue 내부에서 실행 중인 워커 프로세스와 디스패처를 포함한 모든 프로세스를 추적 관리합니다.
그렇다면 디스패처는 실제로 어떻게 동작할까요? 디스패처는 루프 안에서 호출되는 poll 메서드를 정의하여 예약된 작업을 계속 확인합니다. 폴링 코드는 여러 클래스와 모듈에 걸쳐 있지만, 크게 단순화하면 다음과 같습니다.
'실행 준비가 된' 예약 작업을 조회하는 쿼리는 아주 단순합니다.
즉, scheduled_at이 과거인 예약 작업은 모두 디스패치 대상이 됩니다. 이 시리즈 1부에서 다룬 것처럼, Solid Queue가 작업을 디스패치하면 ReadyExecution 레코드를 생성하고 해당 ScheduledExecution 레코드는 삭제합니다. 생성된 ReadyExecution 레코드는 일반 워커 프로세스가 가져가서 실제 작업을 실행하게 됩니다.
여기까지는 아주 순조롭습니다. 예약 작업은 생각보다 복잡하지 않네요! 이번에는 좀 더 복잡한 주제, 바로 반복 작업(Recurring Tasks)을 살펴보겠습니다.
반복 작업(Recurring Tasks)
반복 작업은 백그라운드 잡 프로세서에게 늘 요청되던 기능입니다. 말 그대로 정해진 주기에 따라 반복 실행되어야 하는 백그라운드 작업을 의미합니다. Cron 작업과 비슷하게 '5분마다', '매일 정오' 같은 스케줄을 정의하면 그 시점에 작업이 수행됩니다.
Solid Queue에서는 config/recurring.yml 파일을 통해 반복 작업을 설정합니다. 예를 들어 매일 정오에 CleanupData 작업을 실행하고 싶다면 다음과 같이 작성하면 됩니다.
Solid Queue는 Fugit 라이브러리를 사용해 스케줄 표현식을 파싱하기 때문에 'every day at noon' 같은 사람이 읽기 쉬운 표현도 허용됩니다. 반복 작업을 설정할 때는 실행할 잡 클래스와 잡 인수(arguments)를 지정합니다. 자세한 내용은 SolidQueue 공식 문서의 recurring tasks ReadMe를 참고하세요. 우리는 동작 원리를 배우는 것이 목적이니, 이제 내부를 들여다보겠습니다.
반복 작업은 RecurringTask 모델로 표현되며, 이 모델은 solid_queue_recurring_tasks 테이블에 대응합니다. 테이블의 컬럼들은 설정 파일에서 사용할 수 있는 필드들과 일치합니다.
Solid Queue를 시작하면 반복 작업 설정 파일에 따라 RecurringTask 레코드가 생성됩니다. 그리고 적절한 시점에 잡을 생성하기 위해 또 하나의 프로세스가 필요한데, 이번엔 스케줄러(Scheduler)라고 부릅니다. 스케줄러는 앞서 살펴본 디스패처의 형제 격입니다. 거의 같은 방식으로 동작합니다. Solid Queue가 시작될 때 새 프로세스가 만들어지고, 이 프로세스는 무한 루프를 돕니다. 스케줄러와 디스패처의 차이는 그 루프 안에서 벌어지는 일입니다. 디스패처가 solid_queue_scheduled_executions 테이블을 조회한다면, 스케줄러는 solid_queue_recurring_tasks를 조회해 알맞은 시점에 잡을 예약합니다. 그렇다면 스케줄러는 정확히 어떻게 '올바른 시간'을 판단하고 알맞은 잡을 예약할까요?
이 질문에 답하려면 구현 코드를 자세히 들여다봐야 합니다. 스케줄러 클래스는 schedule 메서드를 정의하는 새로운 RecurringSchedule 객체를 생성하고, 이 메서드가 각 예약된 태스크마다 반복적으로 호출됩니다. 단순화한 버전은 다음과 같습니다.
이 코드를 하나씩 풀어봅시다. Solid Queue는 concurrent-ruby 라이브러리의 Concurrent::ScheduledTask를 사용해 새 스레드를 생성합니다. 그 스레드는 반복 작업의 스케줄이 지정하는 시점에 실행되도록 예약됩니다. 해당 스레드가 실행되면 먼저 재귀적으로 또 다른 스레드를 생성해 다음 반복 작업을 예약한 뒤, '현재' 예약된 잡을 큐에 넣습니다.
감을 잡기 위해 간단한 반복 작업의 예를 살펴보겠습니다.
8시 30분에 Solid Queue를 시작했다고 가정하면, schedule 메서드 안의 변수들은 다음과 같은 값으로 할당됩니다. 물론 그대로는 아니며, 상당히 단순화한 것임을 감안해 주세요.
그러면 백그라운드 스레드는 30분 후, 즉 9시 00분에 실행되도록 예약됩니다. 그 시각이 되면 백그라운드 스레드가 실행되고 thread_task.enqueue(at: 9:00)를 호출해 CleanupData 인스턴스가 실행 대기열에 들어갑니다. 동시에 thread_schedule.schedule을 통해 자기 자신을 재귀적으로 호출합니다. 이제 시간은 9시이므로 이번 호출의 변수 값은 달라집니다.
결국 백그라운드 스레드는 다시 10시 00분에 실행되도록 예약되고, 이 주기가 계속 이어집니다. 혹시 스케줄링 스레드가 죽으면 어떻게 될지 궁금할 수 있습니다. 예를 들어 재배포 중이거나 시스템 장애가 발생했을 때요. 스케줄이 깨지는 것 아닐까요? 다행히 그렇지 않습니다. Cron 스케줄은 정적이기 때문입니다. 'Every Hour' 같은 표현식은 Solid Queue가 언제 시작되었든 항상 10시, 11시, 12시로 해석됩니다. 스케줄링 스레드에 어떤 중단이 발생해도 이는 변하지 않습니다.
알아두면 좋은 몇 가지 구현 세부 사항이 더 있습니다. 첫째, 반복 작업의 다음 발생 시점을 실행 전에 먼저 예약하는 이 패턴은 GoodJob에서 영감을 받은 것입니다. 둘째, RecurringTask.enqueue는 예상과 달리 새로운 Job과 ReadyExecution 레코드를 만들지 않습니다. 대신 또 다른 레코드, 즉 RecurringExecution을 생성합니다.
이 레코드는 오직 반복 작업이 여러 번 실행되는 것을 방지하기 위해서만 존재합니다. 그 목적을 위해 task_key와 run_at 컬럼에 고유 제약 조건(unique constraint)이 걸린 인덱스가 있습니다. 동일한 시간에 동일한 잡에 대한 RecurringExecution이 없을 때만 RecurringTask가 큐에 등록됩니다.
눈썰미 좋은 독자라면 이 코드 조각에서 Solid Queue의 한계를 발견했을 것입니다. Solid Queue를 cron 스타일 태스크 실행용 백엔드로 사용하지 않는 경우 — 그렇게 사용하는 것도 가능합니다 — Solid Queue는 반복 작업이 단 한 번만 등록된다는 것을 보장할 수 없습니다. 이런 상황에 처하게 된다면 그 점을 인지하고 있어야 합니다.
또한 스케줄러 프로세스가 죽거나 종료되면 어떻게 되는지 궁금할 수 있습니다. 예를 들어 배포 도중에 프로세스가 종료되면, 반복이 스레드로 관리되는데 스레드를 죽이면 스케줄이 깨지지 않을까요? 다행히 이 경우에도 답은 '아니요'입니다.
동시성 제어(Concurrency Controls)
Solid Queue의 마지막 기능으로 동시성 제어를 살펴보겠습니다. 때로는 특정 종류의 작업이 동시에 실행될 수 있는 개수를 제한하고 싶을 때가 있습니다. Solid Queue에서는 limits_concurrency 옵션으로 이를 구현할 수 있습니다.
이 코드는 사용자별로 MyJob 인스턴스가 최대 하나씩만 실행되도록 Solid Queue에 지시하는 것입니다. 설정 옵션을 좀 더 자세히 살펴보겠습니다.
to: 동시에 실행 허용할 최대 작업 수입니다.key: 어떤 작업들을 묶어서 제한할지 지정하는 필수 인수입니다. 앞선 예제에서는 동일한 User ID를 가진 작업들이 동시에 하나만 실행되도록 제한됩니다. 잡 인수는 무엇이든key로 사용할 수 있으며, 문자열이나 심볼 같은 상수도 허용됩니다.duration: 작업이 큐에 등록된 후 Solid Queue가 동시성을 보장할 수 있는 최대 시간입니다. 작업이 이 시간보다 오래 실행되면 동시성 제어가 적용되지 않아 작업이 겹칠 수 있습니다. 그 이유는 뒤에서 설명합니다!group: 이 옵션을 사용하면 서로 다른 잡 클래스 간에도 동시성을 제한할 수 있습니다.
더 알고 싶으시다면 동시성 제어 공식 문서를 참고하시기 바랍니다. 동시성 제어는 손색없이 Solid Queue에서 가장 정교한 기능입니다. 예약 작업만으로도 머리가 어질어질 했다면, 이 기능의 동작 방식을 배우면 확실히 머리가 더 복잡해질 겁니다.
기본부터 시작해 보겠습니다. 다른 Solid Queue 기능과 마찬가지로 동시성 제어도 여러 모델과 그에 대응하는 데이터베이스 테이블에 기반합니다. 특히 주목해야 할 두 가지는 Semaphore와 BlockedExecution입니다.
먼저 Semaphore부터 살펴보겠습니다. 이름에서 짐작할 수 있듯, 이것은 카운팅 세마포어(counting semaphore) 패턴의 구현체입니다. Solid Queue는 limits_concurrency가 설정된 잡을 큐에 등록할 때마다 먼저 동시성 키(concurrency key) 기반의 세마포어 락 획득을 시도합니다. 이 동시성 키는 limits_concurrency에 전달된 인수들, 즉 잡 클래스, 키, 그리고 있다면 그룹 이름을 기반으로 만들어집니다. 세마포어를 획득할 수 있으면 잡이 큐에 등록되고, 획득할 수 없으면 대신 BlockedExecution 레코드가 생성됩니다.
세마포어는 여러 잡의 동시 실행을 지원하기 위해 value 값을 가집니다. 이것을 세마포어의 남은 용량이라고 생각하면 됩니다. 세마포어를 획득한다는 것은 그 값을 1 감소시키는 것이고, 해제한다는 것은 1 증가시키는 것입니다. 값이 0이 되면 세마포어는 사용 불가능한 상태로 간주됩니다. 간단한 잡에서 이 락 메커니즘이 어떻게 동작하는지 예를 살펴보겠습니다.
이 잡을 연속해서 여러 번 큐에 넣으려고 하면 무슨 일이 일어나는지 보겠습니다.
MyJob의 첫 번째 인스턴스가 큐에 등록됩니다. 아직 세마포어가 없으므로 새로 생성됩니다. 초기값은limit - 1입니다. limit이 3이므로 세마포어의 초기값은 2입니다.MyJob의 두 번째 인스턴스가 큐에 등록됩니다. Solid Queue는 해당 잡에 대한 락 획득을 시도합니다. 값이 0보다 큰 2이므로 잡은 큐에 등록됩니다. 세마포어 값은 1로 감소합니다.- 잡의 세 번째 인스턴스가 큐에 등록됩니다. 앞서와 동일한 절차를 반복합니다. 이제 세마포어 값은 0입니다.
MyJob의 네 번째 인스턴스가 큐에 등록됩니다. 값이 0이므로 세마포어 획득에 실패하고, 해당 잡에 대한BlockedExecution레코드가 생성됩니다.- 첫 번째 잡 인스턴스가 완료됩니다. 완료되면서 세마포어를 해제하므로 세마포어 값이 다시 1이 됩니다.
- 첫 번째 잡 인스턴스는 완료와 동시에 막혀 있던 잡들을 해제하는 메서드도 호출합니다.
- 네 번째
MyJob인스턴스가 해제되고 다시 락 획득을 시도합니다. 세마포어 값이 1이므로 락을 획득할 수 있고, 막혀 있던 잡이 큐에 등록됩니다. 세마포어 값은 다시 0이 됩니다.
잡이 완료될 때 세마포어를 해제하는 코드는 아주 단순합니다.
아직 다루지 않은 세부 사항이 하나 더 있습니다. 왜 세마포어에 만료일이 존재하고, limits_concurrency 사용 시 duration을 지정해야 하는 걸까요?
잡이 세마포어를 해제하지 못한 채 크래시되면 어떻게 되는지 생각해 봅시다. 예를 들어 해당 잡을 처리하던 워커가 죽는 경우입니다. 세마포어를 정리하는 메커니즘이 없다면, 그 잡이 보유한 락은 영원히 유지됩니다. 최악의 경우 다른 모든 잡들이 영구적으로 처리되지 못하고 막히게 됩니다.
이런 상황을 피하기 위해 세마포어는 잡 정의에 지정된 duration에 해당하는 만료 시간을 가집니다. 세마포어가 만료되면 — 즉 새 잡이 등록되지 않은 상태로 시간이 지나면 — 세마포어는 삭제됩니다. 그 역할을 담당하는 프로세스는 이미 알고 있습니다. 바로 우리의 친구 Dispatcher입니다. 디스패처는 ConcurrencyMaintenance 클래스를 인스턴스화하는데, 이 클래스는 두 가지 일을 합니다.
- 첫째, 만료된 세마포어를 모두 제거합니다.
- 둘째, 막혀 있는 잡이 있는지 확인하고 해제합니다.
잡들은 하나씩 해제되기 때문에 동시성 제한은 여전히 유지됩니다. 다만 잡이 지정된 duration보다 오래 실행되면 어떻게 되는지 생각해 보세요. 이 경우 잡은 여전히 실행 중인데도 세마포어는 정리됩니다. 그 후 다른 잡이 큐에 등록되면 두 잡이 서로 겹쳐 실행될 수 있습니다.
AppSignal로 Rails의 Solid Queue 모니터링하기
지금까지 살펴본 것처럼 Solid Queue는 정말 많은 일을 할 수 있습니다. 하지만 움직이는 부품이 많아진 만큼 모니터링이 매우 중요해집니다. 다행히 AppSignal은 Solid Queue를 기본 지원하며, 잡 실행 시간, 처리량(throughput), 실패율을 위한 대시보드를 미리 제공합니다. Rails 애플리케이션에 AppSignal을 설치하기만 하면 바로 사용할 수 있습니다.
AppSignal은 Solid Queue 사용 여부를 자동으로 감지해 에러율과 처리량 같은 중요 지표의 그래프가 포함된 Active Job 대시보드를 생성해 줍니다.

느리게 실행되거나 에러가 너무 많은 등 문제를 일으키는 잡을 발견하면, 상태(status)와 담당자(assignee)를 지정해 체계적으로 문제를 해결할 수 있습니다.

물론 문제가 있는지 파악하려고 하루 종일 대시보드를 들여다볼 필요는 없습니다. AppSignal Alerts가 여러분을 지켜줍니다. 실패율이나 잡 실행 시간 같은 잡 지표에 대한 새 알림을 만들어 두기만 하면 모든 준비가 끝납니다.

Solid Queue는 복잡한 설정 없이 강력한 잡 처리 기능을 애플리케이션에 추가할 수 있는 훌륭한 도구입니다. 모니터링에 있어서도 AppSignal이 똑같은 역할을 해줍니다!
마무리
이번 글에서는 Solid Queue의 고급 기능들을 폭넓게 살펴보았습니다. 예약 작업부터 복잡한 의존성 체인까지, 모든 기능은 1부에서 논의한 탄탄한 기반 위에 쌓여 있습니다. 보셨듯이 잡 처리 백엔드를 만드는 일은 결코 쉽지 않습니다. 하지만 Solid Queue의 소스 코드와 내부 동작 원리를 깊이 들여다보면서, 그 과정에 담긴 어려움에 대한 이해와 함께 경외심마저 얻게 되었습니다.
어쨌든 Solid Queue는 뛰어난 데이터베이스 설계와 프로세스 조정 덕분에 Rails 생태계에 환영받는 귀중한 존재입니다. 외부 의존성 없이 단순함과 신뢰성이라는 핵심 약속을 지키면서도, 여러분에게 필요한 도구들을 모두 제공합니다.
즐거운 코딩 되세요!