다중 인스턴스 배치 중복 실행을 방어하는 Redis 분산 락(Distributed Lock)과 펜싱 토큰 설계

다중 인스턴스 배치 중복 실행을 방어하는 Redis 분산 락(Distributed Lock)과 펜싱 토큰 설계
다중 인스턴스 배치 중복 실행을 방어하는 Redis 분산 락(Distributed Lock)과 펜싱 토큰 설계

Kubernetes 클러스터 환경이나 다중 서버(EC2, VM) 구조에서 Celery Beat, CronJob, Spring Batch 등의 정기 배치 스케줄러를 운영하다 보면, 오토스케일링(HPA)이나 일시적인 네트워크 지연으로 인해 동일한 배치 작업이 2개 이상의 인스턴스에서 동시에 실행되는 동시성 사고가 발생할 수 있습니다.

배치 프로세스가 중복 트리거되면 정산 데이터의 중복 지급, 결제 배치 중복 처리, 혹은 데이터베이스 교착 상태(Deadlock)로 이어져 운영에 심각한 차질을 빚게 됩니다. 단일 서버 내부의 파일 락(File Lock)이나 뮤텍스(Mutex)는 다중 서버 환경에서 동작하지 않으므로, 클러스터 전역에서 단 하나의 워커만 작업을 점유할 수 있도록 보장하는 분산 락(Distributed Lock)이 필수적입니다.

이번 글에서는 Redis 창시자 Salvatore Sanfilippo(Antirez)의 분산 락 공식 사양과 Martin Kleppmann의 펜싱 토큰(Fencing Token) 분석을 바탕으로, 단순 SETNX의 한계를 짚고 실무에서 즉시 적용 가능한 원자적 락 획득, Lua 스크립트 해제, 펜싱 토큰 기반 늦은 쓰기(Stale Write) 방어 설계법을 정리합니다.

1. 단순 SETNX와 DEL 명령어가 유발하는 레이스 컨디션

초기 분산 락 구현에서 흔히 범하는 실수는 SETNX로 락을 걸고 DEL로 락을 푸는 단순한 구조를 사용하는 것입니다. 이 방식에는 치명적인 두 가지 레이스 컨디션이 존재합니다:

  • TTL 만료 후 타인의 락을 해제하는 문제: 워커 A가 30초 만료 락을 획득했으나 가비지 컬렉션(GC)이나 DB I/O 지연으로 작업이 35초간 지속되면 락이 자동 만료됩니다. 이때 대기 중이던 워커 B가 새 락을 획득합니다. 이후 워커 A가 작업을 마치고 단순 DEL을 실행하면, 워커 A가 워커 B의 락을 삭제해 버려 제3의 워커 C까지 진입하는 연쇄 동시성 붕괴가 일어납니다.
  • 원자성 없는 SETNX + EXPIRE 분리: SETNX 성공 직후 서버가 다운되거나 네트워크가 끊겨 EXPIRE를 호출하지 못하면 락이 영구히 해제되지 않는 데드락(Deadlock)에 빠집니다.

2. Redis 2.6.12+ 원자적 락 획득과 Lua 스크립트 해제

Redis 공식 문서에서는 위의 문제를 방지하기 위해 다음 두 가지 표준 메커니즘을 강제합니다:

  1. 원자적 단일 커맨드(Atomic SET with NX and PX): SET resource_name unique_random_token NX PX 30000을 호출하여 키 존재 여부 검사와 밀리초 단위 TTL 설정을 단일 원자적 명령으로 수행합니다.
  2. Lua 스크립트 기반 원자적 해제: 락을 해제할 때는 Redis 서버에 원자적 Lua 스크립트를 전송하여, 현재 키에 저장된 값과 내가 생성한 고유 토큰(UUID)이 일치할 때만 DEL을 실행합니다. 이를 통해 만료 후 타인의 락을 삭제하는 사고를 원천 차단합니다.

3. 파이썬 컨텍스트 매니저 기반 분산 락 구현

다음은 배치 스크립트 진입점에 손쉽게 적용할 수 있는 파이썬 redis-py 기반 분산 락 컨텍스트 매니저 구현 예시입니다.

import uuid
import time
import logging
from contextlib import contextmanager
import redis

logger = logging.getLogger(__name__)

# 안전한 락 해제를 위한 원자적 Lua 스크립트
# 락 소유자(랜덤 토큰)가 일치할 때만 DEL 명령을 실행하여 타인의 락 삭제를 방지
LUA_RELEASE_LOCK_SCRIPT = """
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

"""

class RedisDistributedLock:
    def __init__(self, redis_client: redis.Redis, lock_key: str, expire_ms: int = 30000):
        self.redis = redis_client
        self.lock_key = f"lock:{lock_key}"
        self.expire_ms = expire_ms
        self.token = str(uuid.uuid4())
        self._release_script = self.redis.register_script(LUA_RELEASE_LOCK_SCRIPT)

    def acquire(self) -> bool:
        # SET key value NX PX expire_ms: 원자적 락 획득
        acquired = self.redis.set(
            self.lock_key, 
            self.token, 
            nx=True, 
            px=self.expire_ms
        )
        return bool(acquired)

    def release(self) -> bool:
        # Lua 스크립트를 통한 안전한 원자적 해제
        result = self._release_script(keys=[self.lock_key], args=[self.token])
        return bool(result)

@contextmanager
def batch_distributed_lock(redis_client: redis.Redis, job_name: str, timeout_seconds: int = 60):
    lock = RedisDistributedLock(redis_client, lock_key=job_name, expire_ms=timeout_seconds * 1000)
    if not lock.acquire():
        logger.warning(f"이미 다른 워커에서 배치 작업 '{job_name}'을(를) 실행 중입니다. 이번 스케줄은 안전하게 스킵합니다.")
        yield False
        return

    logger.info(f"배치 작업 '{job_name}' 분산 락 획득 성공 (Token: {lock.token})")
    try:
        yield True
    finally:
        released = lock.release()
        if released:
            logger.info(f"배치 작업 '{job_name}' 분산 락 정상 해제 완료.")
        else:
            logger.error(f"배치 작업 '{job_name}' 락 해제 실패: 타임아웃으로 이미 락이 만료되었거나 다른 프로세스에 의해 점유됨.")

4. 극한의 데이터 정합성을 위한 펜싱 토큰(Fencing Token)

Martin Kleppmann이 지적했듯, 분산 락은 만료 시간(TTL)에 의존하므로 워커 프로세스가 긴 GC Pause나 네트워크 단절을 겪으면 락이 만료된 상태에서 스토리지 쓰기를 시도하는 늦은 쓰기(Stale Write) 문제가 발생할 수 있습니다.

  • 단조 증가 토큰(Monotonically Increasing Token): 락을 획득할 때마다 1씩 증가하는 카운터(예: Redis INCR)를 펜싱 토큰으로 발급받습니다.
  • 스토리지 레벨 버전 검증: 타깃 데이터베이스나 스토리지에 데이터를 쓸 때 UPDATE account SET balance = 100, last_token = 42 WHERE last_token < 42와 같이 펜싱 토큰을 함께 검증합니다. 이미 더 높은 토큰(43)을 가진 후속 워커가 기록한 상태라면 구버전 워커의 늦은 쓰기는 자동으로 기각(Rollback)됩니다.
  • 배치 감사 모니터링 연동: 락 획득 실패로 배치가 스킵된 이력을 메트릭으로 기록하여, 특정 배치가 정상 지연인지 락 경합으로 인한 지속적 스킵인지 실시간으로 파악해야 합니다.

Similar Posts

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다