고성능 타이머 기반의 원형 지연 큐를 이용한 초 단위 정밀 스케줄링 구현

현대적인 서비스 아키텍처에서는 특정 시간에 실행되어야 하는 작업, 즉 타이밍 기반 작업을 처리할 수 있는 효율적인 메커니즘이 필수적이다. 예를 들어, 이벤트 알림 발송, 주문 만료 처리, 자동 상태 전이 등의 시나리오에서 일정 시간 후에 비동기적으로 동작해야 하는 로직이 흔하게 등장한다. 이러한 요구사항을 해결하기 위해 데이터베이스 폴링이나 외부 스케줄러(예: Quartz.NET)에 의존하는 방식은 과도한 리소스 소모와 복잡성을 유발할 수 있다. 이 글에서는 가벼우면서도 정밀한 제어가 가능한 "원형 지연 큐(Circular Delay Queue)" 패턴을 .NET 환경에서 직접 구현하는 방법을 소개한다.

해당 패턴은 고정된 크기의 배열을 사용하며, 타이머가 주기적으로 이 배열을 순회하면서 각 슬롯에 저장된 작업들을 조건에 따라 실행하거나 다음 사이클로 넘기는 방식으로 동작한다. 이를 통해 불필요한 DB 접근 없이 메모리 상에서 정확한 지연 실행을 가능하게 한다.

핵심 개념: 슬롯과 사이클 기반의 지연 계산

60개의 슬롯으로 구성된 배열을 예로 들자. 각 슬롯은 1초 간격으로 한 번씩 검사되며, 전체 순회 주기는 정확히 60초다. 만약 어떤 작업이 현재 시점으로부터 75초 후에 실행되어야 한다면, 다음과 같이 두 가지 값을 계산한다:

  • 사이클 수 (Cycle Count): 전체 슬롯 수(60)을 기준으로 몇 바퀴를 돌아야 하는지 — 75 / 60 = 1
  • 슬롯 위치 (Slot Index): 현재 인덱스로부터의 상대적 위치 — (75 + current_index) % 60

이렇게 결정된 좌표에 작업을 삽입하면, 타이머가 해당 슬롯을 도달했을 때 사이클 카운터를 감소시키고, 0이 되는 순간 콜백을 트리거한다.

기반 클래스 설계

모든 지연 작업은 공통의 파라미터 구조를 가져야 하며, 내부적으로 슬롯 위치와 남은 사이클 정보를 유지한다:

public abstract class ScheduledTask
{
    internal int Slot { get; set; }
    internal int RemainingCycles { get; set; }
    public Action<object> Execute { get; set; }
}

지연 큐 구현

제네릭 타입을 사용하여 다양한 작업 유형을 지원하는 큐 클래스를 작성한다. 생성 시 슬롯 크기를 지정하며, 기본값으로 60(초)을 사용할 수 있다.

public class TimeWheel<T> where T : ScheduledTask
{
    private readonly List<T>[] _slots;
    private int _currentIndex = 0;

    public TimeWheel(int size = 60)
    {
        _slots = new List<T>[size];
        for (int i = 0; i < size; i++)
            _slots[i] = new List<T>();
    }

    public void Schedule(T task, DateTime triggerTime)
    {
        var delaySeconds = Math.Max(0, (int)(triggerTime - DateTime.Now).TotalSeconds);
        var totalSlots = _slots.Length;

        task.RemainingCycles = delaySeconds / totalSlots;
        task.Slot = (delaySeconds + _currentIndex) % totalSlots;

        _slots[task.Slot].Add(task);
    }

    public void Tick()
    {
        var currentBucket = _slots[_currentIndex];
        var toExecute = new List<T>();

        foreach (var task in currentBucket)
        {
            if (task.RemainingCycles <= 0)
                toExecute.Add(task);
            else
                task.RemainingCycles--;
        }

        foreach (var task in toExecute)
        {
            Task.Run(() => task.Execute(task));
            currentBucket.Remove(task);
        }

        _currentIndex = (_currentIndex + 1) % _slots.Length;
    }
}

실제 사용 예시

특정 마케팅 캠페인 알림을 지정된 시간에 발송하는 시나리오를 가정하자. 먼저 비즈니스 특화된 작업 클래스를 정의한다:

public class CampaignNotification : ScheduledTask
{
    public Guid CampaignId { get; set; }
    public int BatchSize { get; set; }
    public string MessageTemplate { get; set; }
}

정적 매니저를 통해 큐 인스턴스를 싱글톤처럼 관리하고, 작업 등록 및 틱 처리를 캡슐화한다:

public static class CampaignScheduler
{
    private static readonly TimeWheel<CampaignNotification> _wheel 
        = new TimeWheel<CampaignNotification>(60);

    public static void Enqueue(CampaignNotification notification, DateTime sendAt)
    {
        notification.Execute = obj =>
        {
            var noti = (CampaignNotification)obj;
            // 실제 알림 발송 로직
            Console.WriteLine($"[알림] 캠페인 {noti.CampaignId} - {noti.BatchSize}건 전송");
        };

        _wheel.Schedule(notification, sendAt);
    }

    public static void ProcessTick() => _wheel.Tick();
}

타이머 기반 실행

1초마다 ProcessTick() 메서드를 호출하는 백그라운드 타이머를 설정한다. 여기서는 FluentScheduler 라이브러리를 활용한 예를 보인다:

public class SchedulerJob : IJob
{
    public void Execute() => CampaignScheduler.ProcessTick();
}

public class SchedulerRegistry : Registry
{
    public SchedulerRegistry()
    {
        Schedule<SchedulerJob>().ToRunEvery(1).Seconds();
    }
}

// 초기화
JobManager.Initialize(new SchedulerRegistry());

생산 코드 적용 예

var plan = new CampaignNotification
{
    CampaignId = Guid.NewGuid(),
    BatchSize = 500,
    MessageTemplate = "이벤트 참여 안내"
};

CampaignScheduler.Enqueue(plan, DateTime.Now.AddSeconds(45));

보완 및 운영 고려사항

메모리 기반 구조의 단점으로 인해 애플리케이션 재시작 시 미처리 작업이 유실될 수 있다. 이를 완화하기 위해 다음과 같은 전략을 추가할 수 있다:

  • 작업 등록 시 DB에 상태를 기록 ("대기 중")
  • 서비스 시작 시 DB에서 미완료 작업을 조회하여 큐에 재삽입
  • 작업 실행 완료 시 DB 상태를 "완료"로 업데이트

또한 다중 서버 환경에서는 분산 잠금 또는 메시지 브로커와 결합하여 중복 실행을 방지해야 한다.

결론

원형 지연 큐는 비교적 단순한 구조임에도 불구하고, 초 단위 정밀도를 요구하는 타이머 작업에 매우 효과적인 솔루션을 제공한다. DB 부하를 줄이고 실시간성과 응답성을 확보할 수 있으며, .NET 환경에서도 쉽게 구현 가능하다. 성능과 신뢰성 사이의 균형을 고려한다면, 이 패턴은 중규모 시스템에서 정기 작업 처리를 위한 합리적인 선택이 될 수 있다.

태그: Timer Scheduling Circular Queue Delay Queue .NET

5월 23일 15:54에 게시됨