기존의 스케줄링 방식은 주기적으로 업데이트가 필요한 사용자들을 일괄적으로 쿼리하여 대량의 동기화 작업을 큐에 주입하는 형태였습니다. 이는 데이터베이스에 순간적인 부하를 가하고, 오토스케일러가 불필요하게 많은 인스턴스를 생성하게 하여 비용 비효율을 야기했습니다. 이러한 문제를 해결하기 위해 Ulik은 시뮬레이션 기반의 접근 방식을 도입했습니다. 먼저, 실제 작업 소요 시간 분포(APM 데이터를 통한 백분위수)를 반영하여 가짜 작업 지속 시간을 생성하는 시뮬레이터를 구축했습니다. 이 과정에서 컴퓨터 공학 지식이 부족했음에도 불구하고, LLM(ChatGPT)의 도움을 받아 ‘역변환 샘플링(Inverse Transform Sampling)’이라는 통계 기법을 활용하여 실제와 유사한 작업 시간 분포를 모방할 수 있었습니다.
핵심 해결책은 ‘슬롯 스케줄링’입니다. 이는 Ruby의 표준 라이브러리에 포함된 Mersenne Twister
라는 예측 가능한 난수 생성기를 활용합니다. 사용자 ID(UUID)를 시드(seed)로 사용하여 각 사용자에게 고유하고 예측 가능한 지연 시간을 할당함으로써, 전체 사용자 기반의 작업을 시간 축에 고르게 분산시킵니다. 또한, 사용자 기반을 여러 개의 ‘슬롯(buckets)’으로 나누어 각 슬롯에 해당하는 사용자들의 작업만 특정 시점에 큐에 주입하는 방식을 도입했습니다. 이 슬롯의 개수는 주기 시간과 바이트의 가능한 값의 개수(256)를 이용하여 Ruby의 gcd
(최대 공약수) 메서드로 계산하여 최적화했습니다. 예를 들어, 8시간 주기로 작업을 처리하고 싶다면, gcd
를 통해 적절한 슬롯 수를 결정합니다.
작업 실행은 세 단계로 나뉩니다: 전체 사이클을 관리하는 ‘부트스트랩’ 작업, 각 슬롯별 작업을 지연시켜 실행하는 ‘슬롯별’ 작업, 그리고 최종적으로 각 사용자별 동기화 작업을 큐에 넣는 ‘동기화’ 작업입니다. 이 방식은 대량의 작업이 한꺼번에 큐에 유입되는 것을 방지하여 큐 시스템과 데이터베이스의 부하를 크게 줄입니다. 더 나아가, 이 슬롯 스케줄링은 서로 데이터 잠금(lock)이 필요한 ‘결제 승인’과 ‘리포트 생성’과 같은 작업들이 동시에 실행되어 충돌하는 것을 방지하는 데도 활용될 수 있습니다. 즉, 한 작업이 슬롯 0에 할당되면, 다른 충돌 가능성이 있는 작업은 슬롯 4(반대편)에 할당하는 방식으로 동일 계정에 대한 잠금 충돌을 효과적으로 회피합니다. PostgreSQL의 UUID 마지막 바이트를 활용한 사용자 슬롯 할당 기법도 소개되었습니다.