12. 분산 스케줄러의 조정 장치 부재로 인한 배치 중복 실행
난이도 상해설 — 분산 스케줄러의 조정 장치 부재로 인한 배치 중복 실행
난이도: 상
요약
RunIfDue 는 (A) 에서 이 노드 자신의 로컬 메모리(_lastRunDate) 만으로 "오늘 배치를 실행했는가"를 판단한다. 클러스터의 다른 노드가 이미 오늘 배치를 실행했는지 알 방법이 전혀 없다. (B) 에서 각 노드는 독립적으로 자신의 로컬 시계가 자정을 넘겼다고 판단하는 순간 곧바로 배치를 실행하는데, "내가 이 배치를 담당해도 되는가"를 클러스터 차원에서 확인하는 절차(리더 선출·분산 락)가 전혀 없다. 노드가 2대 이상이면 각 노드가 거의 동시에(초 단위 오차 내로) 자정을 감지해 모두 배치를 실행하므로, 온라인 플레이어 전원이 접속 보상을 노드 수만큼 중복 수령하게 된다.
문제점
(A)(B) 클러스터 차원의 조정 장치 부재 — 다중 노드 동시 실행
- 증상: 노드가 N 대 떠 있으면 접속 보상이 최대 N 배로 중복 지급된다.
- 재현 조건: 노드 2대 이상이 각자
DailyRewardScheduler를 기동한 채 자정을 맞이함.- 노드 1, 노드 2 모두 로컬 시계로 자정을 감지, 각자
_lastRunDate < today로 판단. - 둘 다 (B) 에서
RunDailyRewardBatch를 독립적으로 실행 — 서로의 존재를 전혀 모른 채.
- 노드 1, 노드 2 모두 로컬 시계로 자정을 감지, 각자
- 근본 원인: "오늘 배치를 실행했는가"라는 상태가 노드 로컬에만 존재하고, 클러스터 전체가 공유하는 단일 진실 공급원(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·하트비트 튜닝을 잘못하면(이번 결함처럼) 여전히 중복 실행 위험이 남는다 — 리더 선출이나 중앙 스케줄러는 초기 구축 비용이 크지만 장기적으로 더 견고하다.
면접 포인트
- 여러 노드가 각자 독립적으로 "지금이 실행할 때다"를 판단하는 구조는 분산 환경에서 근본적으로 중복 실행을 막을 수 없다 — 클러스터 차원의 조정(분산 락/리더 선출)이 반드시 필요하다.
- 분산 락은 "짧게 얻고 바로 실행"이 아니라, 작업 시간이 길 경우 하트비트로 갱신하는 설계가 필요하다 — TTL 을 작업 예상 시간보다 짧게 잡으면 락이 만료돼 안전장치가 무력화된다.
- 락만으로 exactly-once 를 100% 보장하기 어려운 엣지 케이스(네트워크 지연, GC 정지 등)에 대비해 펜싱 토큰이나 멱등 마커로 이중 방어선을 두는 것이 실무적으로 안전하다.
해설 — 분산 락의 TTL 미갱신으로 인한 배치 중복 실행 (C++)
난이도: 상
요약
runIfDue 는 (A) 에서 분산 락(TryAcquire)을 도입해 "다른 노드가 이미 담당 중이면 스킵"하려는 시도는 했지만, 락 TTL(5초)이 실제 배치 실행 시간(수 분)보다 훨씬 짧고, 락을 유지하기 위한 갱신(하트비트) 절차가 전혀 없다. (B) 에서 배치를 실행하는 동안 락은 TTL 이 지나면 자동 만료되어, 그동안 배치가 끝나기를 기다리던(혹은 뒤늦게 재시도하던) 다른 노드가 같은 날짜 키로 새로 락을 획득할 수 있게 된다. 그 결과 원래 노드가 배치를 절반쯤 진행 중인데 다른 노드가 처음부터 같은 배치를 시작해, 온라인 플레이어들이 접속 보상을 중복 수령한다.
문제점
(A)(B) 락 TTL 이 작업 시간보다 짧고 하트비트 갱신 부재 — 실행 중 락 만료로 중복 시작
- 증상: 노드 1 이 배치를 시작해 3분째 진행 중인데(수만 명 순회), 5초 뒤 락이 자동 만료되어 노드 2 가 같은
todayKey로 새 락을 획득하고 배치를 처음부터 다시 실행한다. 두 노드 모두 온라인 플레이어에게 보상을 지급하므로 전체적으로 이중 지급이 발생한다. - 재현 조건: 배치 실행 시간(수 분) > 락 TTL(5초), 그리고 락 획득 후 별도 갱신 로직 부재.
- 노드 1 이 (A) 에서 TTL 5초로 락 획득 성공, (B) 에서 배치 시작.
- 5초 경과, 락 자동 만료(노드 1 은 여전히 배치 진행 중).
- 노드 2 가 같은 키로 (A) 를 재시도해 락 획득 성공, (B) 에서 배치를 새로 시작.
- 결과: 두 노드가 동시에 같은 플레이어들에게 보상을 지급 — 중복.
- 근본 원인: 락의 "유효 기간"과 "보호해야 할 작업의 실제 소요 시간"이 맞물려 있지 않다. TTL 을 짧게 잡아 죽은 노드가 락을 영원히 들고 있는 상황(데드락)은 방지했지만, 그 대가로 살아있는 노드가 작업 중인데도 락이 만료되는 정반대의 위험을 만들었다. 배치 진행 중 락을 갱신하는 절차가 없어 이 위험이 그대로 드러난다.
수정안 (정확한 코드)
락 획득 시 소유자 토큰을 함께 저장하고, 배치 실행 중 별도 스레드(또는 주기 콜백)로 하트비트를 보내 락을 갱신한다. 배치가 끝나면(또는 소유권을 잃으면) 안전하게 종료한다.
#include <string>
#include <vector>
#include <atomic>
#include <thread>
#include <chrono>
class IDistributedLockClient {
public:
virtual ~IDistributedLockClient() = default;
// 소유자 토큰과 함께 원자적으로 락 획득. 성공 시 true.
virtual bool TryAcquire(const std::string& key, const std::string& ownerToken, int ttlMs) = 0;
// 소유자 토큰이 일치할 때만 TTL 연장(하트비트). 이미 다른 소유자로 넘어갔으면 false.
virtual bool TryExtend(const std::string& key, const std::string& ownerToken, int ttlMs) = 0;
virtual void Release(const std::string& key, const std::string& ownerToken) = 0;
};
class DailyRewardScheduler {
IDistributedLockClient& lockClient_;
std::string nodeId_; // 이 노드를 식별하는 소유자 토큰(프로세스 시작 시 생성)
std::string lastRunDate_;
public:
DailyRewardScheduler(IDistributedLockClient& lockClient, std::string nodeId)
: lockClient_(lockClient), nodeId_(std::move(nodeId)) {}
void runIfDue(const std::string& todayKey) {
if (lastRunDate_ == todayKey)
return;
constexpr int kTtlMs = 30000; // 하트비트 주기보다 넉넉한 TTL
constexpr int kHeartbeatMs = 10000; // TTL 의 1/3 주기로 갱신
if (!lockClient_.TryAcquire(todayKey, nodeId_, kTtlMs))
return; // 다른 노드가 이미 담당 중이거나 오늘 이미 처리됨
lastRunDate_ = todayKey;
// 배치 실행 중 락을 주기적으로 갱신하는 하트비트 스레드.
std::atomic<bool> stopHeartbeat{false};
std::thread heartbeatThread([&] {
while (!stopHeartbeat.load(std::memory_order_relaxed)) {
std::this_thread::sleep_for(std::chrono::milliseconds(kHeartbeatMs));
if (stopHeartbeat.load(std::memory_order_relaxed)) break;
if (!lockClient_.TryExtend(todayKey, nodeId_, kTtlMs)) {
// 소유권을 잃었다(다른 노드가 가져갔거나 이미 만료됨) — 더 이상
// 이 프로세스가 배치를 계속할 권한이 없다는 신호.
break;
}
}
});
runDailyRewardBatch();
stopHeartbeat.store(true, std::memory_order_relaxed);
heartbeatThread.join();
lockClient_.Release(todayKey, nodeId_);
}
private:
void runDailyRewardBatch() {
for (int playerId : getOnlinePlayers()) {
// 플레이어별 멱등 마커(player, date)를 함께 남겨, 하트비트 실패로 두 노드가
// 겹쳐 실행되더라도 개별 지급 단위에서 중복을 한 번 더 차단한다.
grantLoginRewardIdempotent(playerId);
}
}
std::vector<int> getOnlinePlayers() { return {}; }
void grantLoginRewardIdempotent(int /*playerId*/) { /* 멱등 지급 */ }
};
핵심: ① TTL 을 넉넉히 잡고 그 1/3 수준의 주기로 하트비트를 보내 배치 실행 중 락이 만료되지 않게 한다. ② 하트비트 갱신이 실패하면(소유권을 잃었다는 뜻) 배치를 계속하지 않고 중단해야 한다(위 스니펫은 단순화를 위해 갱신 실패 시 루프만 빠져나오지만, 실무에서는 배치 본체도 협조적으로 중단 신호를 확인해야 한다). ③ 플레이어별 멱등 마커로, 드물게 두 노드가 겹치더라도 개별 지급 단위에서 중복을 막는 이중 방어선을 둔다.
더 나은 설계 (+ 트레이드오프)
- 리더 선출: 매 배치마다 짧게 락을 얻고 놓는 대신 클러스터의 리더 노드를 장기간 선출해두면, 스케줄 작업 자체를 "리더만 실행"으로 단순화할 수 있다. 리더 장애 감지·failover 로직이 추가로 필요하다.
- 펜싱 토큰: 하트비트 갱신 실패를 감지하더라도 이미 진행 중인 DB 쓰기가 롤백되지 않을 수 있다 — 락 획득 시 단조 증가 토큰을 받아 모든 지급 쓰기에 그 토큰을 검증하면, 소유권을 잃은 뒤의 늦은 쓰기를 거부할 수 있다(concurrency_memory/problem12 참고).
- 작업을 작은 청크로 분할: 배치를 "플레이어 1000명 단위" 로 쪼개 각 청크마다 락을 짧게 재획득하면 하트비트 없이도 TTL 을 짧게 유지할 수 있다. 대신 청크 경계에서 진행 상태를 영속화해야 재개가 가능하다.
- 트레이드오프: 하트비트 방식은 기존 분산 락 인프라를 그대로 재사용할 수 있어 도입이 쉽지만, 갱신 스레드 관리·갱신 실패 시 작업 중단 로직 등 구현 복잡도가 늘어난다. 리더 선출은 초기 구축 비용이 크지만 장기적으로 스케줄러 로직이 단순해진다.
면접 포인트
- 분산 락의 TTL 은 "죽은 소유자를 얼마나 빨리 회수할 것인가"와 "살아있는 소유자가 작업을 마칠 때까지 얼마나 오래 보호할 것인가" 사이의 트레이드오프다 — 작업 시간이 가변적이거나 길면 반드시 하트비트 갱신이 필요하다.
- 하트비트 갱신 실패는 "소유권을 잃었다"는 신호이므로, 그 이후에도 부수효과(DB 쓰기 등)를 계속하면 펜싱 토큰 없이는 안전하지 않다.
- 분산 락만으로 exactly-once 를 완벽히 보장하기 어려운 엣지 케이스에 대비해 멱등 마커 등 애플리케이션 레벨 이중 방어선을 두는 것이 실무적으로 안전하다.