← 문제로

12. 분산 스케줄러의 조정 장치 부재로 인한 배치 중복 실행

난이도 상
내 리뷰 · C#
해설 · C#

해설 — 분산 스케줄러의 조정 장치 부재로 인한 배치 중복 실행

난이도: 상

요약

RunIfDue 는 (A) 에서 이 노드 자신의 로컬 메모리(_lastRunDate) 만으로 "오늘 배치를 실행했는가"를 판단한다. 클러스터의 다른 노드가 이미 오늘 배치를 실행했는지 알 방법이 전혀 없다. (B) 에서 각 노드는 독립적으로 자신의 로컬 시계가 자정을 넘겼다고 판단하는 순간 곧바로 배치를 실행하는데, "내가 이 배치를 담당해도 되는가"를 클러스터 차원에서 확인하는 절차(리더 선출·분산 락)가 전혀 없다. 노드가 2대 이상이면 각 노드가 거의 동시에(초 단위 오차 내로) 자정을 감지해 모두 배치를 실행하므로, 온라인 플레이어 전원이 접속 보상을 노드 수만큼 중복 수령하게 된다.

문제점

(A)(B) 클러스터 차원의 조정 장치 부재 — 다중 노드 동시 실행

  • 증상: 노드가 N 대 떠 있으면 접속 보상이 최대 N 배로 중복 지급된다.
  • 재현 조건: 노드 2대 이상이 각자 DailyRewardScheduler 를 기동한 채 자정을 맞이함.
    1. 노드 1, 노드 2 모두 로컬 시계로 자정을 감지, 각자 _lastRunDate < today 로 판단.
    2. 둘 다 (B) 에서 RunDailyRewardBatch 를 독립적으로 실행 — 서로의 존재를 전혀 모른 채.
  • 근본 원인: "오늘 배치를 실행했는가"라는 상태가 노드 로컬에만 존재하고, 클러스터 전체가 공유하는 단일 진실 공급원(single source of truth)이 없다. 분산 환경에서 "정확히 한 번(exactly-once)"을 보장하려면 클러스터 차원의 조정(coordination)이 필수인데 이 코드엔 전무하다.

수정안 (정확한 코드)

외부 저장소(Redis 등)에 "오늘 날짜" 를 키로 하는 분산 락(원자적 SETNX + TTL) 을 도입해, 락 획득에 성공한 노드만 배치를 실행하게 한다. 배치 실행 시간이 길 수 있으므로 락은 배치가 끝날 때까지 유지(하트비트 갱신)해야 한다.

using System;
using System.Threading;
using System.Threading.Tasks;

public interface IDistributedLockClient
{
    // key 에 대해 SET key value NX PX ttlMs 와 동등한 원자적 락 획득.
    Task<bool> TryAcquireAsync(string key, string ownerToken, int ttlMs);
    // 소유자 토큰이 일치할 때만 TTL 을 연장(하트비트).
    Task<bool> TryExtendAsync(string key, string ownerToken, int ttlMs);
    Task ReleaseAsync(string key, string ownerToken);
}

class DailyRewardScheduler
{
    private readonly IDistributedLockClient _lockClient;
    private readonly string _nodeId = Guid.NewGuid().ToString(); // 이 노드를 식별하는 소유자 토큰
    private DateTime _lastRunDate = DateTime.MinValue;
    private readonly Timer _timer;

    public DailyRewardScheduler(IDistributedLockClient lockClient)
    {
        _lockClient = lockClient;
        _timer = new Timer(async _ => await RunIfDueAsync(), null, TimeSpan.Zero, TimeSpan.FromMinutes(1));
    }

    private async Task RunIfDueAsync()
    {
        var today = DateTime.UtcNow.Date;
        if (_lastRunDate >= today)
            return;

        string lockKey = $"daily_reward_batch:{today:yyyy-MM-dd}";

        // 클러스터 전체에서 단 한 노드만 이 락 획득에 성공한다(원자적 SETNX).
        bool acquired = await _lockClient.TryAcquireAsync(lockKey, _nodeId, ttlMs: 30_000);
        if (!acquired)
        {
            // 이미 다른 노드가 오늘 배치를 담당 중이거나 완료함 — 이 노드는 스킵.
            _lastRunDate = today;
            return;
        }

        _lastRunDate = today;

        // 배치가 수 분 걸릴 수 있으므로, 별도 하트비트 타이머로 락을 주기적으로 연장한다.
        using var heartbeat = new Timer(async _ =>
            await _lockClient.TryExtendAsync(lockKey, _nodeId, ttlMs: 30_000),
            null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));

        try
        {
            await RunDailyRewardBatchAsync();
        }
        finally
        {
            await _lockClient.ReleaseAsync(lockKey, _nodeId);
        }
    }

    private async Task RunDailyRewardBatchAsync()
    {
        foreach (var player in GetOnlinePlayers())
        {
            // 멱등 마커(player, date)를 함께 기록해, 락이 실수로 만료돼 두 노드가
            // 겹쳐 실행되더라도 개별 플레이어 단위에서 중복 지급을 한 번 더 차단한다.
            await GrantLoginRewardIdempotentAsync(player, today: DateTime.UtcNow.Date);
        }
    }

    private System.Collections.Generic.IEnumerable<int> GetOnlinePlayers() => Array.Empty<int>();
    private Task GrantLoginRewardIdempotentAsync(int playerId, DateTime today) => Task.CompletedTask;
}

핵심: ① 원자적 분산 락(SETNX 류)으로 클러스터 전체에서 단 한 노드만 배치를 시작할 수 있게 한다. ② 배치가 오래 걸릴 수 있으므로 하트비트로 락을 연장해, 실행 도중 TTL 만료로 다른 노드가 끼어드는 것을 막는다. ③ 락만으로는 완벽하지 않을 수 있으므로(네트워크 지연 등으로 락이 만료된 뒤 원래 소유자가 뒤늦게 계속 실행하는 경우), 플레이어별 멱등 마커로 이중 방어선을 둔다.

더 나은 설계 (+ 트레이드오프)

  • 리더 선출(Leader Election): 락을 매번 짧게 얻고 놓는 대신, 클러스터에서 장기적으로 "리더" 노드 하나를 선출(예: ZooKeeper/etcd/Redis 기반)해 모든 스케줄 작업을 리더만 실행하게 하면 스케줄러 로직 자체가 단순해진다. 대신 리더 장애 감지·failover 지연이라는 별도 복잡도가 생긴다.
  • 펜싱 토큰(fencing token): 락 만료 후 원래 소유자가 뒤늦게 배치 작업(예: DB 쓰기)을 계속하는 것을 막으려면, 락 획득 시 단조 증가하는 토큰을 받아 모든 부수효과(DB 쓰기)에 그 토큰을 함께 검증해야 한다(오래된 토큰의 쓰기는 거부). concurrency_memory/problem12(분산 락 펜싱/만료)와 연계.
  • 중앙 스케줄러(외부 cron 서비스): Kubernetes CronJob 이나 별도 스케줄링 서비스가 "이 배치를 실행하라"는 명령을 단 한 번 발행하고, 그 명령을 받은 노드만 실행하는 구조로 바꾸면 노드들이 스스로 시각을 감지할 필요가 없어진다. 인프라 의존성이 늘어나지만 조정 로직이 스케줄러 밖으로 빠져 코드가 단순해진다.
  • 트레이드오프: 분산 락 방식은 구현이 비교적 간단하고 기존 Redis 인프라를 재사용할 수 있지만, 락 TTL·하트비트 튜닝을 잘못하면(이번 결함처럼) 여전히 중복 실행 위험이 남는다 — 리더 선출이나 중앙 스케줄러는 초기 구축 비용이 크지만 장기적으로 더 견고하다.

면접 포인트

  1. 여러 노드가 각자 독립적으로 "지금이 실행할 때다"를 판단하는 구조는 분산 환경에서 근본적으로 중복 실행을 막을 수 없다 — 클러스터 차원의 조정(분산 락/리더 선출)이 반드시 필요하다.
  2. 분산 락은 "짧게 얻고 바로 실행"이 아니라, 작업 시간이 길 경우 하트비트로 갱신하는 설계가 필요하다 — TTL 을 작업 예상 시간보다 짧게 잡으면 락이 만료돼 안전장치가 무력화된다.
  3. 락만으로 exactly-once 를 100% 보장하기 어려운 엣지 케이스(네트워크 지연, GC 정지 등)에 대비해 펜싱 토큰이나 멱등 마커로 이중 방어선을 두는 것이 실무적으로 안전하다.