본문 바로가기

Project/기록

Batch 서버가 여러대가 띄워져 있고, 돈복사 버그 방지하기위해 분산락을 끼얹으면?

현재 우리 시스템은 매일 아침 5시, 크리에이터들의 토큰 거래 볼륨을 정산하는 배치 서버 1대가 돌고 있습니다. 대상자가 적을 때는 문제가 없었지만, 서비스가 성장하며 수만 명의 정산 데이터를 처리해야 하는 시점이 다가오고 있다고 가정을 해보겠습니다.

자연스럽게 "배치 Worker 서버를 여러 대로 늘려서 병렬 처리하면 되겠지?"라고 생각할 것이고, 저 역시 그랬습니다. 하지만 현재 아키텍처에서는 서버를 10대로 늘리더라도 처리 속도는 단 1초도 줄어들지 않는다는 결론에 도달했습니다. 그 원인은 제가 중복 방지를 위해 걸어두었던 'Redis 분산 락(Distributed Lock)' 때문이었습니다.

작업 분배에 Redis 분산 락을 쓰면 안 되는 이유

Redis Distribution

일반적으로 분산 환경에서 동시성을 제어할 때 Redis의 SETNX나 Redlock을 가장 먼저 떠올립니다. 아침 5시에 10대의 서버가 동시에 깨어났을 때, 똑같은 유저에게 정산이 두 번 도는 참사를 막기 위해 Redis 락을 거는 방식이죠.

하지만 이 방식은 '단일 작업의 상호 배제(Mutual Exclusion)'에는 완벽하지만, 수만 건의 작업을 쪼개어 처리해야 하는 '작업 분배(Task Distribution)' 시나리오에서는 최악의 병목을 유발합니다.

  • Scale-out의 무력화: 10,000건의 데이터를 처리할 때 워커를 10대로 늘려도, 락을 획득한 1대의 워커가 루프를 돌며 모든 작업을 마칠 때까지 나머지 9대는 대기(Spin Lock)하며 놀게 됩니다.
  • 시스템 직렬화: 결국 전체 시스템의 처리량(Throughput)은 가장 느린 서버 1대의 성능에 갇혀버립니다.

"누가 배치를 시작할 것인가?"에는 Redis 락이 맞지만, "수만 개의 데이터를 어떻게 안 겹치게 나눠서 가져갈 것인가?"에는 전혀 다른 접근이 필요했습니다. 분산 큐에서의 접근이 저희의 상황에는 가깝습니다.

PostgreSQL SKIP LOCKED의 논블로킹 마법

별도의 메시지 브로커(Kafka, RabbitMQ)라는 거대한 인프라를 추가하지 않고, 기존 RDBMS 환경에서 이 문제를 가장 우아하게 풀 수 있는 방법은 바로 PostgreSQL의 SKIP LOCKED 절입니다.

기존의 락(SELECT ... FOR UPDATE)은 내가 읽으려는 행을 남이 쥐고 있으면 락이 풀릴 때까지 '무한 대기(Blocking)'합니다. 하지만 SKIP LOCKED는 이름 그대로 효율적으로 동작합니다.

SKIP LOCKED



"잠긴 행을 만나면 대기하지 않고, 쿨하게 건너뛴(Skip) 뒤 다음 가용한 행을 즉시 가져온다."

10대의 배치 서버가 동시에 LIMIT 100으로 쿼리를 던져도, 1번 서버가 1-100번을 쥐면 2번 서버는 대기 없이 즉시 101-200번을 가져갑니다. 서버 대수를 늘리는 족족 락 경합 없이 처리량이 선형적으로 증가하는 진정한 병렬 큐(Queue)가 탄생하는 순간입니다.

DB는 어떻게 '건너뛸 행'을 알까? (MVCC와 튜플 헤더)

그렇다면 DB 엔진은 도대체 어떻게 "이 행은 누가 쓰고 있네?" 하고 스킵할 수 있을까요? 이 마법의 핵심은 PostgreSQL의 MVCC(Multi-Version Concurrency Control) 아키텍처에 있습니다.

PostgreSQL에 저장된 데이터의 헤더에는 눈에 보이지 않는 메타데이터가 존재합니다. 그중 가장 중요한 것이 XMAX라는 필드입니다.

누군가 행에 락을 걸거나 업데이트를 시작하면, DB 엔진은 해당 행의 XMAX에 '작업 중인 트랜잭션 ID'를 고이 적어둡니다.

  • 일반 락의 동작
    • 쿼리가 데이터에 접근 ➔ XMAX에 남의 ID가 적혀있음 ➔ "끝날 때까지 여기서 대기해!"
  • SKIP LOCKED의 동작
    • 쿼리가 데이터에 접근 ➔ XMAX에 남의 ID가 적혀있음 ➔ "바쁘네? 넌 패스(Skip)!" ➔ XMAX가 비어있는 깨끗한 다음 행을 즉시 반환.

애플리케이션 레벨에서는 어떠한 대기도, 통신도 필요 없습니다. DB 커널 레벨에서 메타데이터를 보고 포인터를 넘겨버리기 때문입니다.

Proof of Concept

이론이 아무리 완벽해도 눈으로 확인하지 않으면 안됩니다. 로컬 DBeaver 환경에서 직접 물리적으로 완벽히 독립된 커넥션 3개(트랜잭션 창 3개) 락 경합 상태를 재현해 보았습니다.

주의: 테스트 시 DB 툴의 Autocommit 기능을 반드시 '수동(Manual)'으로 변경해야 락 유지 상태를 관측할 수 있습니다. 저 역시 Autocommit의 함정에 빠져 락이 풀려버리는 삽질을 겪어버렸습니다.
  • Session 1 (워커 A): SELECT ... FOR NO KEY UPDATE SKIP LOCKED LIMIT 10; ➔ 결과: 1~10번 유저 즉시 선점 성공! (아직 트랜잭션 커밋 안 함)

  • Session 2 (워커 B): 동일한 쿼리 실행 ➔ 결과: 1밀리초의 대기도 없이, 1~10번을 건너뛰고 11~20번 유저를 즉시 반환!

  • Session 3 (안티 패턴): SKIP LOCKED를 빼고 일반 락으로 실행 ➔ 결과: 실행 즉시 무한 대기(Hang) 상태에 빠짐. (실제 운영 환경이었다면 커넥션 풀을 말라붙게 만들 주범입니다.)

Session 1과 2는 서로 다른 행에 granted = true로 평화롭게 락을 획득했고, Session 3은 granted = false로 처량하게 대기 중인 것을 OS 레벨에서 명확히 확인할 수 있었습니다.

쿼리 디테일: FOR NO KEY UPDATE

SKIP LOCKED의 위력을 확인했으니 실제 정산 파이프라인에 적용할 쿼리를 설계할 차례입니다. 여기서 쿼리의 디테일이 한 번 더 들어갑니다.

BEGIN;

WITH queued_targets AS (
    SELECT "userAddress"
    FROM "TokenTradeVolume"
    WHERE "rewardStatus" = 'ELIGIBLE'
    LIMIT 100 -- 트랜잭션 길이를 짧게 가져가기 위한 청크 분할
    FOR NO KEY UPDATE SKIP LOCKED -- 💡 핵심 디테일
)
UPDATE "TokenTradeVolume"
SET "rewardStatus" = 'PROCESSING', "updatedAt" = NOW()
WHERE "userAddress" IN (SELECT "userAddress" FROM queued_targets)
RETURNING "userAddress";

COMMIT;

단순히 FOR UPDATE를 쓰지 않고 FOR NO KEY UPDATE를 쓴 이유가 있습니다. 우리는 기본키(userAddress)를 변경하지 않고 상태값(rewardStatus)만 변경할 것입니다. 이 옵션을 주면 인덱스 페이지의 갱신을 막고 힙 내에서만 업데이트를 처리하는 HOT(Heap-Only Tuple) 업데이트 최적화를 온전히 보존할 수 있습니다.

반드시 알아야 할 Trade-off 3가지

이 강력한 기능도 잘못 쓰면 시스템을 파괴합니다. 도입 전 반드시 숙지해야 할 3가지 엣지 케이스입니다.

  1. 데이터의 '전체성'이 필요한 곳엔 절대 금지 (리포팅 금지): SKIP LOCKED는 잠긴 데이터를 결과에서 아예 누락시킵니다. "오늘 정산 대상자 총 몇 명이야?" 하고 COUNT()를 때리는 쿼리에 이 구문을 쓰면, 남이 작업 중인 데이터는 카운트에서 쏙 빠져버리는 치명적인 데이터 정합성 오류가 발생합니다. 오직 '작업 큐 선점' 용도로만 써야 합니다.
  2. OFFSET과 결합 시 끔찍한 잠금 누수(Lock Leak): 워커가 10개씩 가져가게 하겠다고 OFFSET 1000 LIMIT 10을 쓰면 대참사가 일어납니다. DB는 건너뛰어야 할 앞의 1000개 행을 순차 스캔하며 모두 락을 걸어버립니다. 페이징이 아닌, 철저하게 인덱스를 타는 상태값(WHERE status = 'PENDING') 필터링을 써야 합니다.
  3. ORDER BY 정렬 이상 현상 (Anomaly): READ COMMITTED 격리 수준에서는 먼저 정렬을 하고 락을 겁니다. 만약 누군가 대기 중에 정렬 기준값을 바꿔버리면 큐의 순서가 뒤바뀔 수 있습니다. 순서가 목숨처럼 중요하다면 격리 수준을 REPEATABLE READ로 올리고 앱 레벨의 재시도 로직을 구현해야 합니다.

마치며

이번 설계 과정을 통해, 단순히 "서버를 늘린다"가 스케일 아웃의 전부가 아님을 배웠습니다.

Redis 분산 락은 '단일 실행 통제'에 최고의 무기이지만, 대용량 데이터를 다루는 '병렬 분산 큐'에서는 독이 될 수 있습니다. "내 비즈니스 워크로드가 '상호 배제'를 원하는가, '작업 분배'를 원하는가?" 이 질문에 대한 답을 내리고, DB 엔진의 튜플 구조(XMAX)와 락 메커니즘을 정확히 통제할 줄 알아야 함을 배웠습니다.