행 잠금은 작업을 지켜 주지 않습니다 — SKIP LOCKED 와 시간 리스

정기창·

Go 로 짜인 서버를 TypeScript 로 다시 짜며 다시 공부한 정석을 적는 연재의 네 번째 글입니다. 앞의 두 글이 "같은 것을 두 번 받아도 한 번만"이었다면, 이번에는 받는 쪽이 여럿일 때의 이야기입니다. 요구는 이랬습니다. 여러 일꾼이 같은 작업 목록에서 일을 나눠 가져가되, 한 작업은 한 일꾼만 하고, 일꾼이 멈추면 그 작업을 다른 일꾼이 이어받아야 합니다.

여기서 일꾼은 워커(worker)를 말합니다. 워커는 화면 없이 뒤에서 쌓인 작업을 하나씩 처리하는 프로그램입니다. 예를 들어 사용자가 올린 이미지를 여러 크기로 변환하는 작업 목록이 있고, 워커 여러 대가 그 목록을 나눠 처리한다고 생각하면 됩니다.

예전에 비슷한 일을 할 때는 Redis 의 큐와 분산 락으로 풀었습니다. 이번에는 Redis 없이, 이미 쓰고 있는 관계형 데이터베이스만으로 같은 성질을 만들어 보기로 했습니다.

여러 일꾼이 한 목록을 나눌 때는 "먼저 잡은 사람"을 정해야 합니다

가장 단순한 방법은 워커가 "대기 중인 작업 하나"를 조회해서 처리하는 것입니다. 그런데 워커 두 대가 같은 순간에 조회하면 둘 다 같은 작업을 받습니다. 같은 이미지를 두 번 변환하는 것은 낭비로 끝나지만, 한 번만 일어나야 하는 작업이라면 사고가 됩니다.

그래서 조회하면서 그 행에 표시를 해 둡니다. 행 잠금은 한 트랜잭션이 끝날 때까지 다른 트랜잭션이 그 행을 건드리지 못하게 하는 표시입니다. 트랜잭션은 여러 명령을 한 덩어리로 묶어 전부 반영하거나 전부 취소하는 단위입니다. MySQL 에서는 조회할 때 FOR UPDATE 를 붙여 잠급니다. 잠금과 트랜잭션의 원리는 RDBMS 딥다이브의 락 편에 자세히 적어 두었습니다.

문제는 잠긴 행을 만난 두 번째 워커가 기다린다는 점입니다. 첫 번째 워커가 끝날 때까지 두 번째 워커는 아무 일도 못 합니다. 워커를 늘려도 한 줄로 서서 기다리는 셈입니다.

SKIP LOCKED 는 잠긴 행을 기다리지 않고 건너뜁니다

MySQL 8.0 부터는 FOR UPDATE SKIP LOCKED 를 쓸 수 있습니다. 누가 잠가 둔 행은 기다리지 않고 건너뛰고, 잠기지 않은 다음 행을 가져옵니다. 작업 목록처럼 "아무 작업이나 하나 가져가면 되는" 표에 맞는 기능입니다. 반대로 MySQL 문서가 말하듯, 건너뛴 행만큼 결과가 빠지므로 일반적인 조회에는 맞지 않습니다.

SELECT id FROM job
WHERE status = 'READY'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED;

워커 A 와 B 를 연결 두 개로 흉내 내 확인했습니다.

  170ms A  집은 일 = 1
  179ms B  집은 일 = 2   (1번은 A 가 잠가서 건너뜀)
 1186ms B  SKIP LOCKED 없이 1번을 잠그려 하면 -> ER_LOCK_WAIT_TIMEOUT

B 는 기다리지 않고 2번을 가져갔습니다. SKIP LOCKED 를 빼고 1번을 직접 잠그려 하자, 잠금 대기 시간(이 실험에서는 1초)을 채운 뒤 에러를 받았습니다.

그런데 잠금은 트랜잭션이 끝나면 사라집니다

여기까지면 해결된 것처럼 보입니다. 그런데 이미지 변환처럼 오래 걸리는 작업을 트랜잭션을 연 채로 하면 곤란합니다. 트랜잭션이 길어질수록 잠금과 데이터베이스 연결을 오래 붙잡고, 데이터베이스가 되돌리기 위해 보관해야 할 기록도 쌓입니다. 그래서 보통은 작업을 집은 뒤 트랜잭션을 끝내고, 실제 일은 트랜잭션 밖에서 합니다.

그 순간 잠금은 사라집니다. 실험에서 A 가 커밋한 뒤 트랜잭션 밖에서 1번 일을 계속한다고 가정하자, B 가 1번을 다시 집었습니다.

 1189ms A  커밋하고 트랜잭션 밖에서 1번 일을 계속한다고 가정
 1194ms B  집은 일 = 1   ← A 가 아직 하고 있는 일을 B 도 집었다

행 잠금은 "지금 이 트랜잭션이 이 행을 쓰는 중"이라는 표시일 뿐, "이 작업을 누가 맡았다"는 기록이 아니었습니다. 잠금의 수명은 트랜잭션이고, 작업의 수명은 그보다 깁니다. 이 둘을 같은 것으로 본 것이 착각이었다는 생각이 들었습니다.

그래서 "누가, 언제까지"를 행에 직접 적습니다

작업을 맡았다는 사실은 잠금이 아니라 데이터로 남겨야 합니다. 그리고 맡은 워커가 멈출 수 있으니, 그 점유에는 기한이 있어야 합니다. 이렇게 정해진 시간 동안만 유효한 점유 표시를 리스(lease)라고 부릅니다. 도서관 좌석 예약과 비슷합니다. 자리를 맡아 두고 일정 시간 안에 돌아오지 않으면 다른 사람이 앉을 수 있습니다.

// 작업 집기: 짧은 트랜잭션 안에서 "누가·언제까지"를 적고 바로 끝낸다
await conn.beginTransaction();
const [[row]] = await conn.query(
  `SELECT id FROM job
   WHERE status = 'READY' AND (lease_until IS NULL OR lease_until < NOW(3))
   ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED`,
);
if (row) {
  await conn.query(
    'UPDATE job SET owner = ?, lease_until = NOW(3) + INTERVAL ? MICROSECOND WHERE id = ?',
    [workerName, leaseMs * 1000, row.id],
  );
}
await conn.commit();

// 작업 끝내기: 아직 내가 주인일 때만 반영한다
const [result] = await conn.query(
  "UPDATE job SET status = 'DONE', lease_until = NULL WHERE id = ? AND owner = ? AND status = 'READY'",
  [row.id, workerName],
);
const stillMine = result.affectedRows === 1;

SKIP LOCKED 는 집는 순간의 경쟁만 정리하고, 그 뒤의 점유는 owner 와 lease_until 두 칸이 맡습니다. 역할이 나뉘는 것입니다.

멈춘 일꾼의 작업을 리스가 끝난 뒤 다른 일꾼이 이어받았습니다

워커 A 가 1번 작업을 1.5초 리스로 집고 멈추는 상황을 만들었습니다. 프로세스가 죽은 경우를 흉내 낸 것입니다.

 1245ms A  일 1 을 1.5초 리스로 집었다. 그리고 멈춘다
 1271ms B  일 2 를 집고 끝냈다 -> true
 1295ms B  일 3 을 집고 끝냈다 -> true
 1300ms B  지금 집을 수 있는 일이 없다 (1번은 A 의 리스가 살아 있음)
 2920ms B  A 의 리스가 끝나 일 1 을 다시 집었다
 2923ms B  일 1 완료 -> true
 2926ms A  뒤늦게 깨어나 일 1 을 완료하려 함 -> false

B 는 A 의 리스가 살아 있는 동안 1번을 건드리지 않았고, 기한이 지나자 1번을 이어받아 끝냈습니다. 마지막 줄이 중요합니다. 뒤늦게 깨어난 A 가 완료를 기록하려 했지만, 주인이 이미 B 로 바뀌어 있어서 반영되지 않았습니다.

늦게 돌아온 일꾼의 결과는 버려야 합니다

멈췄던 워커는 자기가 멈췄다는 사실을 모릅니다. 긴 멈춤에서 깨어나면 자기가 아직 주인인 줄 알고 결과를 쓰려 합니다. 그래서 "끝내기" 쪽에 주인 확인을 넣어, 주인이 아니면 결과가 반영되지 않게 했습니다.

이 발상을 더 엄격하게 만든 것이 펜싱 토큰(fencing token)입니다. Martin Kleppmann 은 분산 락에 관한 글에서, 점유할 때마다 커지는 번호를 발급하고 쓰기를 받는 쪽이 더 작은 번호를 거절하라고 설명합니다. 이번 구현의 주인 확인은 그 가벼운 형태입니다. 같은 워커 이름이 다시 쓰일 수 있는 환경이라면 이름 대신 점유할 때마다 새로 받는 번호를 쓰는 편이 안전합니다.

리스 길이는 작업 시간과 복구 시간 사이에서 고릅니다

  • 너무 짧으면 워커가 아직 일하는 중인데 리스가 끝나, 다른 워커가 같은 작업을 시작합니다. 오래 걸리는 작업이라면 중간중간 리스를 연장하는 신호(하트비트)를 보내야 합니다.
  • 너무 길면 워커가 죽었을 때 그 작업이 오래 방치됩니다.
  • 기한 판정은 시계 하나로 합니다. 위 쿼리는 리스를 적을 때와 확인할 때 모두 데이터베이스의 NOW(3) 를 씁니다. 워커마다 자기 컴퓨터 시계로 판정하면 시계 차이만큼 판정이 어긋납니다. 시계가 둘일 때 생기는 일은 이 연재의 다른 글에서 따로 다루겠습니다.

정리하며

  • SKIP LOCKED 는 집는 순간의 경쟁을 정리합니다. 잠긴 행을 기다리지 않고 건너뛰어 워커가 한 줄로 서지 않게 합니다.
  • 잠금은 작업을 대표하지 못합니다. 잠금은 트랜잭션과 함께 사라지므로, 작업을 맡았다는 사실은 행에 데이터로 적습니다.
  • 점유에는 기한과 주인 확인이 함께 있어야 합니다. 기한이 멈춘 워커의 작업을 풀어 주고, 주인 확인이 늦게 돌아온 워커의 결과를 막습니다.

예전에 Redis 로 풀던 문제를 데이터베이스 두 칸으로 다시 풀어 보니, 도구가 달라도 결국 정해야 하는 것은 "누가, 언제까지, 늦으면 어떻게"라는 같은 질문이었다는 생각이 들었습니다.

실험 환경: MySQL 8.4.11(로컬 Docker 컨테이너, innodb_lock_wait_timeout 1초로 조정) · Node.js v26.5.0 · mysql2 3.24.4 · 2026-09-29. 시각은 스크립트 시작 기준 경과 시간이며, 표와 작업은 설명을 위해 새로 만든 예시입니다.

MySQLSKIP LOCKED작업 큐리스펜싱 토큰동시성