MySQL SKIP LOCKED를 작업 큐로 사용해도 될까?
author
postNo
status
thumbnail
description
category
tags
createdAt
updatedAt

시작하기에 앞서

이전 글인 Shopify 재고 예약 시스템은 어떻게 Redis를 MySQL로 옮겼을까?에서 MySQL의 SELECT ... FOR UPDATE SKIP LOCKED를 살펴봤다.
여러 요청이 같은 row를 기다리지 않고 이미 잠긴 row를 건너뛰어 서로 다른 재고 단위를 가져가는 방식이었다. 이 구조를 보고 이런 의문이 들었다.
💡
MySQL 테이블과 SKIP LOCKED를 이용하면 별도의 메시지 큐 없이 작업 큐를 만들 수 있을까?
결론부터 말하면 가능하다.
 
처리량이 아주 크지 않고 비즈니스 데이터 변경과 작업 생성을 하나의 트랜잭션으로 묶는 일이 중요하다면, MySQL 작업 큐는 꽤 실용적인 선택이다.
신뢰할 수 있는 큐를 만들려면 SKIP LOCKED 외에도 살펴볼 것이 있다. worker가 작업 도중 종료될 수 있고, 동일한 작업이 다시 실행될 수 있으며, 오래된 작업이 계속 뒤로 밀릴 수도 있다. 큐를 운영하려면 claim 이후의 상태 전이, lease, 재시도, 멱등성, 모니터링까지 함께 설계해야 한다.
 
SKIP LOCKED는 큐가 아니라 여러 worker가 충돌하지 않고 작업을 가져가게 해주는 도구다.

일반적인 FOR UPDATE만 사용하면 어떤 일이 생길까?

먼저 아래처럼 단순한 작업 테이블이 있다고 생각해보자.
CREATE TABLE job_queue (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  payload JSON NOT NULL,
  status VARCHAR(20) NOT NULL,
  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  PRIMARY KEY (id)
) ENGINE = InnoDB;
worker는 처리할 작업 하나를 가져오기 위해 다음 쿼리를 실행할 수 있다.
START TRANSACTION;

SELECT id, payload
FROM job_queue
WHERE status = 'pending'
ORDER BY id
LIMIT 1
FOR UPDATE;

UPDATE job_queue
SET status = 'processing'
WHERE id = ?;

COMMIT;
FOR UPDATE는 조회한 row에 배타 락을 건다. 첫 번째 worker가 가장 오래된 작업을 잠근 상태라면 두 번째 worker는 같은 row의 락이 풀릴 때까지 기다린다.
Worker A -> job 1 lock -> 처리 대기
Worker B -> job 1 lock을 기다림
Worker C -> job 1 lock을 기다림
뒤에 처리 가능한 작업이 많이 남아 있어도 worker들이 첫 번째 row 앞에서 줄을 서게 된다. worker 수를 늘렸지만 실제 처리량은 크게 늘지 않는 상황이 생길 수 있다.

SKIP LOCKED는 기다리지 않고 다음 작업을 가져온다

SKIP LOCKED를 붙이면 이미 다른 트랜잭션이 잠근 row를 결과에서 제외한다.
START TRANSACTION;

SELECT id, payload
FROM job_queue
WHERE status = 'pending'
ORDER BY id
LIMIT 1
FOR UPDATE SKIP LOCKED;

UPDATE job_queue
SET status = 'processing'
WHERE id = ?;

COMMIT;
이번에는 worker가 서로 다른 작업을 가져갈 수 있다.
Worker A -> job 1 lock
Worker B -> job 1은 잠겨 있으므로 건너뛰고 job 2 lock
Worker C -> job 1, 2를 건너뛰고 job 3 lock
여러 worker가 잠긴 작업을 건너뛰고 서로 다른 작업을 claim하는 흐름
여러 worker가 잠긴 작업을 건너뛰고 서로 다른 작업을 claim하는 흐름
MySQL 공식 문서에 따르면 SKIP LOCKED는 row lock을 기다리지 않고 잠긴 row를 결과에서 제거한다. 이 결과는 일관된 데이터 뷰가 아니므로 일반적인 트랜잭션 작업에는 적합하지 않다. 다만 여러 세션이 접근하는 queue-like table에서는 락 경합을 줄이는 데 사용할 수 있다.
이때 두 가지 제약을 함께 봐야 한다.
  • SKIP LOCKED는 row-level lock에만 적용된다.
  • 잠긴 row를 건너뛰므로 엄격한 순서를 보장하지 않는다.
즉, 처리 가능한 아무 작업이나 가져가도 되는 큐에는 잘 맞지만 모든 작업을 생성 순서대로 처리해야 하는 큐에는 그대로 적용하기 어렵다.
 

작업 큐에 필요한 상태를 먼저 정의한다

실제 큐에는 pendingcompleted만으로 부족하다. 누가 작업을 가져갔는지, 언제까지 소유하는지, 몇 번 실패했는지 알아야 한다.
CREATE TABLE job_queue (
  id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  queue_name VARCHAR(64) NOT NULL DEFAULT 'default',
  payload JSON NOT NULL,

  status ENUM(
    'pending',
    'processing',
    'completed',
    'failed'
  ) NOT NULL DEFAULT 'pending',

  available_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  locked_by VARCHAR(128) NULL,
  claim_token CHAR(36) NULL,
  lease_expires_at DATETIME(6) NULL,

  attempts INT UNSIGNED NOT NULL DEFAULT 0,
  max_attempts INT UNSIGNED NOT NULL DEFAULT 5,
  last_error TEXT NULL,

  created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  updated_at DATETIME(6) NOT NULL
    DEFAULT CURRENT_TIMESTAMP(6)
    ON UPDATE CURRENT_TIMESTAMP(6),

  PRIMARY KEY (id),
  KEY idx_job_claim (
    queue_name,
    status,
    available_at,
    id
  ),
  KEY idx_job_recover (
    status,
    lease_expires_at
  )
) ENGINE = InnoDB;
각 컬럼의 역할은 다음과 같다.
  • status: 작업의 현재 상태
  • available_at: 지연 실행과 재시도 시점
  • locked_by: 작업을 가져간 worker
  • claim_token: 현재 작업 소유권을 구분하는 값
  • lease_expires_at: worker가 작업을 끝내야 하는 시각
  • attempts: 지금까지 시도한 횟수
  • max_attempts: 무한 재시도를 막는 한계
  • last_error: 마지막 실패 원인
여기서 claim_token이 중요하다. 같은 worker 이름을 다시 사용할 수도 있고, lease가 만료된 뒤 과거 worker가 늦게 응답할 수도 있다. claim할 때마다 새로운 token을 만들면 완료 요청이 현재 소유자의 요청인지 확인할 수 있다.
pending에서 processing으로 claim하고 성공, 재시도 또는 실패로 전이되는 작업 상태와 lease 흐름
pending에서 processing으로 claim하고 성공, 재시도 또는 실패로 전이되는 작업 상태와 lease 흐름
 

작업을 가져가는 트랜잭션은 짧아야 한다

worker는 작업을 다음 순서로 처리한다.
  1. 작업의 소유권을 가져온다.
  1. 실제 작업을 처리한다.
이 둘을 하나의 긴 트랜잭션으로 묶으면 안 된다. 이메일 발송이나 외부 API 호출이 끝날 때까지 row lock과 DB connection을 계속 점유하기 때문이다.
작업을 claim하고 즉시 commit한 뒤, 실제 처리는 트랜잭션 밖에서 수행한다.
START TRANSACTION;

SELECT id
FROM job_queue
WHERE queue_name = ?
  AND status = 'pending'
  AND available_at <= NOW(6)
ORDER BY available_at, id
LIMIT 10
FOR UPDATE SKIP LOCKED;

UPDATE job_queue
SET status = 'processing',
    locked_by = ?,
    claim_token = ?,
    lease_expires_at = DATE_ADD(NOW(6), INTERVAL 5 MINUTE),
    attempts = attempts + 1
WHERE id IN (?, ?, ...)
  AND status = 'pending';

COMMIT;
claim_token은 애플리케이션에서 batch마다 새로 만든 UUID라고 가정한다. commit이 끝나면 해당 token으로 작업을 읽어서 처리한다.
SELECT id, payload, attempts, max_attempts
FROM job_queue
WHERE claim_token = ?
  AND status = 'processing'
ORDER BY id;
전체 흐름은 다음과 같다.
짧은 트랜잭션
  -> 처리할 row 선택
  -> processing 상태와 lease 기록
  -> commit

트랜잭션 밖
  -> 외부 API 호출 또는 실제 작업 수행
  -> 성공하면 completed
  -> 실패하면 재시도 또는 failed
트랜잭션이 rollback되면 선택한 row의 lock과 상태 변경도 함께 사라진다. 다른 worker가 다시 해당 작업을 가져갈 수 있다.
반대로 claim을 commit한 뒤 worker가 종료되면 작업은 processing에 남는다. 이 상태에 남은 작업을 복구하는 방법도 별도로 설계해야 한다.
 

성공 처리는 현재 소유자만 할 수 있어야 한다

작업이 성공한 뒤에는 id만 보고 완료 처리하지 않는다. claim할 때 발급한 token까지 함께 확인한다.
UPDATE job_queue
SET status = 'completed',
    locked_by = NULL,
    claim_token = NULL,
    lease_expires_at = NULL
WHERE id = ?
  AND status = 'processing'
  AND claim_token = ?;
영향받은 row가 0개라면 이미 lease가 만료됐거나 다른 worker가 작업을 다시 가져간 것이다. 과거 worker가 늦게 끝났더라도 현재 작업 상태를 덮어쓰지 못한다.
이런 token은 데이터베이스 안의 상태 전이를 보호한다. 하지만 외부 시스템에 이미 보낸 요청까지 되돌리지는 못한다.
예를 들어 과거 worker가 결제 요청을 보낸 뒤 응답을 받지 못했다고 하자. 그사이 lease가 만료되면 새로운 worker가 같은 요청을 다시 보낼 수 있다. 이때 claim_token만으로는 결제 중복을 막을 수 없다.
그래서 외부 부작용을 만드는 작업에는 별도의 idempotency key가 필요하다.
queue job id: 101
payment idempotency key: payment-job-101
소비자 쪽에서는 같은 작업이 여러 번 실행되어도 최종 결과를 한 번만 반영하도록 보장해야 한다. 결제, 쿠폰 발급, 알림 저장 모두 마찬가지다.
 

worker가 작업 도중 종료되면 lease로 복구한다

worker는 일정 시간 동안만 작업의 소유권을 빌린다. 이 lease로 작업이 processing 상태에 영원히 남는 것을 막는다.
lease가 만료된 작업은 주기적으로 다시 pending으로 돌린다.
UPDATE job_queue
SET status = 'pending',
    available_at = NOW(6),
    locked_by = NULL,
    claim_token = NULL,
    lease_expires_at = NULL
WHERE status = 'processing'
  AND lease_expires_at < NOW(6)
  AND attempts < max_attempts;
최대 시도 횟수를 넘긴 작업은 더 이상 자동으로 실행하지 않고 failed로 보낸다.
UPDATE job_queue
SET status = 'failed',
    locked_by = NULL,
    claim_token = NULL,
    lease_expires_at = NULL
WHERE status = 'processing'
  AND lease_expires_at < NOW(6)
  AND attempts >= max_attempts;
작업 시간이 lease보다 길어질 수 있다면 heartbeat로 만료 시간을 연장할 수 있다.
UPDATE job_queue
SET lease_expires_at = DATE_ADD(NOW(6), INTERVAL 5 MINUTE)
WHERE id = ?
  AND status = 'processing'
  AND claim_token = ?;
다만 lease는 정확히 한 번 실행을 보장하는 장치가 아니다.
기존 worker가 살아 있지만 네트워크 문제로 heartbeat만 실패할 수도 있다. 복구 worker가 같은 작업을 다시 가져가면 두 worker가 잠시 동시에 실행된다. 따라서 이 구조의 전달 보장은 현실적으로 at-least-once에 가깝다.
중복 실행을 전제로 만들고 부작용은 멱등하게 처리해야 한다.
 

재시도는 즉시 반복하지 않는다

외부 API 장애나 일시적인 네트워크 오류가 발생했을 때 같은 작업을 바로 반복하면 장애가 난 시스템에 부하를 더할 수 있다.
재시도 시점은 애플리케이션에서 계산해 available_at에 넣는다.
1번째 실패 -> 5초 뒤
2번째 실패 -> 15초 뒤
3번째 실패 -> 60초 뒤
4번째 실패 -> 5분 뒤
worker는 available_at <= NOW(6)인 작업만 가져가므로 별도의 스케줄러 없이 지연 재시도를 구현할 수 있다.
UPDATE job_queue
SET status = ?,
    available_at = ?,
    locked_by = NULL,
    claim_token = NULL,
    lease_expires_at = NULL,
    last_error = ?
WHERE id = ?
  AND status = 'processing'
  AND claim_token = ?;
여기서 첫 번째 status는 남은 시도 횟수에 따라 pending 또는 failed가 된다.
모든 오류를 재시도해서도 안 된다.
  • 타임아웃, 일시적인 5xx처럼 회복 가능한 오류는 재시도한다.
  • 입력값 오류나 권한 오류처럼 다시 실행해도 같은 결과가 나오는 오류는 바로 실패시킨다.
  • 재시도 간격에는 jitter를 넣어 여러 작업이 동시에 다시 몰리지 않게 한다.
  • 최종 실패한 작업은 원인과 payload를 확인한 뒤 수동 재처리할 수 있어야 한다.
 

비즈니스 트랜잭션과 작업 생성을 함께 commit할 수 있다

MySQL 작업 큐를 쓰면 이미 사용 중인 비즈니스 데이터와 작업 생성을 하나의 트랜잭션에 넣을 수 있다. 이 구조가 MySQL 작업 큐의 가장 큰 장점이다.
주문 생성과 이메일 큐 등록을 예로 들어보자.
START TRANSACTION;

INSERT INTO orders (
  order_no,
  user_id,
  status
) VALUES (
  ?,
  ?,
  'created'
);

INSERT INTO job_queue (
  queue_name,
  payload,
  status,
  available_at
) VALUES (
  'order-email',
  JSON_OBJECT('orderId', LAST_INSERT_ID()),
  'pending',
  NOW(6)
);

COMMIT;
이 구조에서는 주문만 생성되고 작업 등록이 실패하는 상태나, 주문이 rollback됐는데 이메일 작업만 남는 상태를 피할 수 있다.
Kafka나 Redis에 직접 publish하면 MySQL commit과 메시지 publish를 하나의 로컬 트랜잭션으로 묶을 수 없다. 그래서 별도의 outbox pattern이나 CDC가 필요해진다.
작업 큐 테이블 자체를 outbox처럼 사용한다면 구현은 단순해진다. 대신 worker의 polling 부하, 오래된 row 정리, 테이블 증가량과 장애 복구를 직접 책임져야 한다.
 

인덱스가 없으면 row-level lock이어도 넓게 잠글 수 있다

FOR UPDATE는 조건에 맞는 결과 row에만 락을 거는 것처럼 보인다. 하지만 실행 계획과 검색에 사용한 인덱스에 따라 실제 잠금 범위가 달라진다.
MySQL 공식 문서에 따르면 적절한 인덱스가 없어 전체 테이블을 스캔하면 statement가 살펴본 모든 row가 잠길 수 있다. SKIP LOCKED를 사용해도 접근 경로는 여전히 비효율적일 수 있다.
그래서 claim 쿼리의 조건과 정렬 순서에 맞춘 인덱스가 필요하다.
KEY idx_job_claim (
  queue_name,
  status,
  available_at,
  id
)
운영에 넣기 전에는 반드시 실행 계획을 확인한다.
EXPLAIN
SELECT id
FROM job_queue
WHERE queue_name = 'order-email'
  AND status = 'pending'
  AND available_at <= NOW(6)
ORDER BY available_at, id
LIMIT 10
FOR UPDATE SKIP LOCKED;
확인할 항목은 다음과 같다.
  • idx_job_claim을 실제로 사용하는가
  • 예상보다 많은 row를 읽고 있지 않은가
  • filesort나 넓은 range scan이 발생하지 않는가
  • 큐 이름과 상태별 데이터 분포가 크게 치우치지 않았는가
MySQL의 기본 격리 수준인 REPEATABLE READ에서는 검색 조건에 따라 next-key lock과 gap lock도 고려해야 한다. READ COMMITTED에서는 일반적인 검색과 인덱스 스캔의 gap locking이 줄어들지만, 격리 수준만 바꾼다고 모든 문제가 해결되는 것은 아니다.
격리 수준, 인덱스, 실제 실행 계획과 lock 범위를 한 세트로 확인해야 한다.
 

엄격한 FIFO는 보장하지 않는다

쿼리에 ORDER BY available_at, id가 있어도 SKIP LOCKED는 엄격한 FIFO를 보장하지 않는다.
가장 오래된 작업이 다른 worker에게 잠겨 있으면 그 작업을 건너뛰고 다음 작업을 가져오기 때문이다. 오래 걸리는 작업, 반복적으로 lease가 만료되는 작업, 계속 실패하는 작업은 뒤의 작업보다 늦게 끝날 수 있다.
job 1 -> Worker A가 오래 처리 중
job 2 -> Worker B가 먼저 완료
job 3 -> Worker C가 먼저 완료
대부분의 이메일, 이미지 처리, 통계 집계에서는 문제가 되지 않을 수 있다. 하지만 계좌 상태 변경이나 동일 주문의 이벤트처럼 처리 순서가 의미를 갖는다면 다른 방법이 필요하다.
  • 같은 aggregate key의 작업을 하나의 worker가 순차 처리한다.
  • key별로 queue를 나누고 각 queue의 동시성을 1로 제한한다.
  • 순서를 기본 모델로 제공하는 Kafka partition 같은 도구를 사용한다.
  • 후속 작업이 이전 version을 확인하고 오래된 이벤트를 거절하게 만든다.
SKIP LOCKED를 선택하면 처리량을 위해 엄격한 순서를 일부 포기하게 된다.
 

복제 환경도 확인해야 한다

MySQL 공식 문서는 NOWAITSKIP LOCKED를 사용하는 statement가 statement-based replication에서 안전하지 않다고 명시한다. 잠금 상태는 source와 replica에서 같지 않을 수 있으므로, 각 서버가 서로 다른 row를 선택할 수 있기 때문이다.
MySQL 8.4의 기본 binary logging format은 row-based지만, 기존 환경이나 관리형 데이터베이스의 설정을 가정해서는 안 된다.
SHOW VARIABLES LIKE 'binlog_format';
SKIP LOCKED 기반 큐를 사용한다면 다음을 확인한다.
  • claim과 상태 변경은 읽기 replica가 아니라 writer에서 실행한다.
  • binlog_formatROW인지 확인한다.
  • replica lag가 큐 처리 판단에 영향을 주지 않게 한다.
  • failover 뒤에도 lease와 worker 상태를 기준으로 안전하게 재처리할 수 있어야 한다.
특히 replica에서 pending 작업을 읽고 writer에서 상태를 바꾸는 구조는 피하는 것이 좋다. 복제 지연 때문에 이미 처리된 작업을 다시 보거나 방금 생성된 작업을 놓칠 수 있다.
 

polling도 비용이다

전용 메시지 브로커에서는 새 메시지가 들어오면 consumer가 이어서 처리할 수 있다. 반면 MySQL 큐에서는 worker가 처리할 작업이 있는지 반복해서 조회하기 쉽다. 이런 조회 방식이 polling이다.
빈 큐를 수십 개의 worker가 짧은 간격으로 계속 조회하면 쓸모없는 쿼리가 쌓인다.
작업을 가져옴 -> 즉시 다음 batch 조회
빈 결과 1회 -> 짧게 대기
빈 결과 반복 -> 대기 시간을 점차 늘림
새 작업 발견 -> 대기 시간 초기화
고정된 10ms 간격보다 exponential backoff와 jitter를 사용하는 편이 낫다. 지연 시간 요구가 낮다면 polling 간격을 늘려 DB 부하를 줄인다.
batch 크기도 무조건 키우지 않는다. 한 worker가 너무 많은 작업을 claim하면 다른 worker가 가져갈 수 있는 작업이 줄어든다. 그 worker가 종료되면 lease도 한꺼번에 만료된다.
작업 처리 시간과 worker 수를 관찰하면서 batch 크기와 lease 시간을 조정해야 한다.
 

어떤 지표를 봐야 할까?

큐가 동작하는지 확인하는 데서 그치지 않고 작업이 얼마나 밀리고 어디에서 실패하는지도 볼 수 있어야 한다.
최소한 다음 지표를 수집하는 것이 좋다.
  • pending이면서 실행 가능한 작업 수
  • 가장 오래된 pending 작업의 대기 시간
  • processing 작업 수와 lease 만료 건수
  • 분당 claim, 완료, 재시도와 최종 실패 수
  • 작업 종류별 처리 시간
  • 빈 polling 비율
  • worker별 처리량과 오류율
  • deadlock과 lock wait 수
  • 큐 테이블 크기와 완료 row 정리 지연
대기 중인 작업 수만 보면 충분하지 않다. 작업 수는 적어도 가장 오래된 작업이 오랫동안 처리되지 않을 수 있다.
SELECT
  queue_name,
  COUNT(*) AS ready_jobs,
  TIMESTAMPDIFF(
    SECOND,
    MIN(available_at),
    NOW(6)
  ) AS oldest_ready_age_seconds
FROM job_queue
WHERE status = 'pending'
  AND available_at <= NOW(6)
GROUP BY queue_name;
락 문제가 의심된다면 performance_schema.data_locksdata_lock_waits에서 현재 보유한 락과 대기 관계를 확인할 수 있다.
 

완료된 작업은 계속 쌓아두지 않는다

작업이 끝난 뒤에는 completed row도 관리해야 한다. 계속 쌓아두면 인덱스가 커지고 백업과 DDL 비용도 함께 증가한다.
보존 기간을 정한 뒤 작은 batch로 삭제하거나 별도 이력 테이블로 옮긴다.
DELETE FROM job_queue
WHERE status = 'completed'
  AND updated_at < NOW() - INTERVAL 30 DAY
ORDER BY id
LIMIT 1000;
한 번에 많은 row를 삭제하면 undo log, replication lag와 lock 부담이 커질 수 있다. 삭제 작업도 짧은 batch로 반복하고 지표를 보면서 실행한다.
감사나 재처리를 위해 긴 이력이 필요하다면 실행 큐와 이력 저장소를 분리하는 편이 낫다.
원자적 enqueue와 단순한 운영에 강한 MySQL 큐, 높은 처리량과 fan-out 및 replay에 강한 메시지 브로커 비교
원자적 enqueue와 단순한 운영에 강한 MySQL 큐, 높은 처리량과 fan-out 및 replay에 강한 메시지 브로커 비교
 

MySQL 작업 큐가 잘 맞는 경우

다음 조건이라면 MySQL 큐를 먼저 고려할 수 있다.
  • 이미 MySQL을 운영하고 있다.
  • 처리량이 감당 가능한 범위다.
  • producer와 consumer 수가 많지 않다.
  • 비즈니스 변경과 작업 생성을 하나의 트랜잭션으로 묶고 싶다.
  • 수 초 이하의 아주 낮은 전달 지연이 필요하지 않다.
  • 순서가 조금 바뀌어도 괜찮다.
  • 애플리케이션에서 재시도, lease와 멱등성을 구현할 수 있다.
  • 별도 메시지 브로커의 운영 비용이 더 크다.
예를 들어 주문 생성 후 이메일 발송, 정산 집계 예약, 이미지 후처리, webhook 재시도 같은 작업은 좋은 후보가 될 수 있다.
 

전용 메시지 큐가 더 나은 경우

반대로 다음 요구사항이 중요하면 Kafka, BullMQ, SQS 같은 전용 도구가 더 자연스럽다.
  • 매우 높은 처리량과 많은 consumer가 필요하다.
  • 하나의 이벤트를 여러 consumer가 각각 처리해야 한다.
  • partition key별 순서 보장이 중요하다.
  • 이벤트를 장기간 보관하고 다시 읽어야 한다.
  • consumer group과 offset 관리가 필요하다.
  • 서비스와 팀 사이의 독립적인 이벤트 계약이 필요하다.
  • DB polling 부하를 업무 트랜잭션과 분리해야 한다.
  • DLQ, 지연 작업, 우선순위 같은 기능을 직접 만들고 싶지 않다.
전용 메시지 브로커가 항상 더 좋은 것도 아니다. 큐 하나를 위해 새로운 클러스터, 모니터링과 장애 대응 체계를 추가하면 문제보다 운영 비용이 더 커질 수 있다.
어떤 도구가 더 유명한지보다 우리가 어떤 실패와 운영 책임을 감당할 수 있는지 따져보고 선택해야 한다.
 

정리

MySQL의 SKIP LOCKED를 사용하면 여러 worker가 서로 기다리지 않고 다른 작업을 가져갈 수 있다. 적절한 규모와 요구사항에서는 별도 메시지 브로커 없이도 충분히 실용적인 작업 큐를 만들 수 있다.
하지만 운영 가능한 큐에는 SKIP LOCKED보다 더 많은 것이 필요하다.
  1. 작업을 가져가는 트랜잭션을 짧게 유지한다.
  1. 실제 처리는 commit 이후에 실행한다.
  1. lease와 claim token으로 worker 종료와 오래된 응답을 처리한다.
  1. at-least-once 실행을 전제로 소비자를 멱등하게 만든다.
  1. 재시도 횟수와 backoff를 제한한다.
  1. claim 조건에 맞는 인덱스와 실행 계획을 확인한다.
  1. 엄격한 FIFO를 보장하지 않는다는 점을 받아들인다.
  1. row-based replication과 writer 연결을 확인한다.
  1. 대기 시간, lease 만료와 최종 실패를 관측한다.
  1. 처리량과 기능이 커지면 전용 메시지 큐로 옮길 기준을 정한다.
개인적으로 MySQL 작업 큐의 가장 큰 장점은 단순함보다 비즈니스 상태와 작업 생성을 같은 트랜잭션 경계에 둘 수 있다는 점이라고 생각한다.
그리고 가장 큰 함정은 테이블과 SKIP LOCKED만 만들면 메시지 큐가 완성됐다고 생각하는 것이다.
SKIP LOCKED는 작업을 안전하게 가져오는 출발점이다. 큐의 신뢰성은 그 뒤의 상태 전이와 복구 설계에서 결정된다.
 

참고