author
postNo
status
thumbnail
description
category
tags
createdAt
updatedAt
시작하기에 앞서일반적인 FOR UPDATE만 사용하면 어떤 일이 생길까?SKIP LOCKED는 기다리지 않고 다음 작업을 가져온다작업 큐에 필요한 상태를 먼저 정의한다작업을 가져가는 트랜잭션은 짧아야 한다성공 처리는 현재 소유자만 할 수 있어야 한다worker가 작업 도중 종료되면 lease로 복구한다재시도는 즉시 반복하지 않는다비즈니스 트랜잭션과 작업 생성을 함께 commit할 수 있다인덱스가 없으면 row-level lock이어도 넓게 잠글 수 있다엄격한 FIFO는 보장하지 않는다복제 환경도 확인해야 한다polling도 비용이다어떤 지표를 봐야 할까?완료된 작업은 계속 쌓아두지 않는다MySQL 작업 큐가 잘 맞는 경우전용 메시지 큐가 더 나은 경우정리참고
시작하기에 앞서
이전 글인 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만 사용하면 어떤 일이 생길까?
먼저 아래처럼 단순한 작업 테이블이 있다고 생각해보자.
worker는 처리할 작업 하나를 가져오기 위해 다음 쿼리를 실행할 수 있다.
FOR UPDATE는 조회한 row에 배타 락을 건다. 첫 번째 worker가 가장 오래된 작업을 잠근 상태라면 두 번째 worker는 같은 row의 락이 풀릴 때까지 기다린다.뒤에 처리 가능한 작업이 많이 남아 있어도 worker들이 첫 번째 row 앞에서 줄을 서게 된다. worker 수를 늘렸지만 실제 처리량은 크게 늘지 않는 상황이 생길 수 있다.
SKIP LOCKED는 기다리지 않고 다음 작업을 가져온다
SKIP LOCKED를 붙이면 이미 다른 트랜잭션이 잠근 row를 결과에서 제외한다.이번에는 worker가 서로 다른 작업을 가져갈 수 있다.

MySQL 공식 문서에 따르면
SKIP LOCKED는 row lock을 기다리지 않고 잠긴 row를 결과에서 제거한다. 이 결과는 일관된 데이터 뷰가 아니므로 일반적인 트랜잭션 작업에는 적합하지 않다. 다만 여러 세션이 접근하는 queue-like table에서는 락 경합을 줄이는 데 사용할 수 있다.이때 두 가지 제약을 함께 봐야 한다.
SKIP LOCKED는 row-level lock에만 적용된다.
- 잠긴 row를 건너뛰므로 엄격한 순서를 보장하지 않는다.
즉, 처리 가능한 아무 작업이나 가져가도 되는 큐에는 잘 맞지만 모든 작업을 생성 순서대로 처리해야 하는 큐에는 그대로 적용하기 어렵다.
작업 큐에 필요한 상태를 먼저 정의한다
실제 큐에는
pending과 completed만으로 부족하다. 누가 작업을 가져갔는지, 언제까지 소유하는지, 몇 번 실패했는지 알아야 한다.각 컬럼의 역할은 다음과 같다.
status: 작업의 현재 상태
available_at: 지연 실행과 재시도 시점
locked_by: 작업을 가져간 worker
claim_token: 현재 작업 소유권을 구분하는 값
lease_expires_at: worker가 작업을 끝내야 하는 시각
attempts: 지금까지 시도한 횟수
max_attempts: 무한 재시도를 막는 한계
last_error: 마지막 실패 원인
여기서
claim_token이 중요하다. 같은 worker 이름을 다시 사용할 수도 있고, lease가 만료된 뒤 과거 worker가 늦게 응답할 수도 있다. claim할 때마다 새로운 token을 만들면 완료 요청이 현재 소유자의 요청인지 확인할 수 있다.
작업을 가져가는 트랜잭션은 짧아야 한다
worker는 작업을 다음 순서로 처리한다.
- 작업의 소유권을 가져온다.
- 실제 작업을 처리한다.
이 둘을 하나의 긴 트랜잭션으로 묶으면 안 된다. 이메일 발송이나 외부 API 호출이 끝날 때까지 row lock과 DB connection을 계속 점유하기 때문이다.
작업을 claim하고 즉시 commit한 뒤, 실제 처리는 트랜잭션 밖에서 수행한다.
claim_token은 애플리케이션에서 batch마다 새로 만든 UUID라고 가정한다. commit이 끝나면 해당 token으로 작업을 읽어서 처리한다.전체 흐름은 다음과 같다.
트랜잭션이 rollback되면 선택한 row의 lock과 상태 변경도 함께 사라진다. 다른 worker가 다시 해당 작업을 가져갈 수 있다.
반대로 claim을 commit한 뒤 worker가 종료되면 작업은
processing에 남는다. 이 상태에 남은 작업을 복구하는 방법도 별도로 설계해야 한다.성공 처리는 현재 소유자만 할 수 있어야 한다
작업이 성공한 뒤에는
id만 보고 완료 처리하지 않는다. claim할 때 발급한 token까지 함께 확인한다.영향받은 row가 0개라면 이미 lease가 만료됐거나 다른 worker가 작업을 다시 가져간 것이다. 과거 worker가 늦게 끝났더라도 현재 작업 상태를 덮어쓰지 못한다.
이런 token은 데이터베이스 안의 상태 전이를 보호한다. 하지만 외부 시스템에 이미 보낸 요청까지 되돌리지는 못한다.
예를 들어 과거 worker가 결제 요청을 보낸 뒤 응답을 받지 못했다고 하자. 그사이 lease가 만료되면 새로운 worker가 같은 요청을 다시 보낼 수 있다. 이때
claim_token만으로는 결제 중복을 막을 수 없다.그래서 외부 부작용을 만드는 작업에는 별도의 idempotency key가 필요하다.
소비자 쪽에서는 같은 작업이 여러 번 실행되어도 최종 결과를 한 번만 반영하도록 보장해야 한다. 결제, 쿠폰 발급, 알림 저장 모두 마찬가지다.
worker가 작업 도중 종료되면 lease로 복구한다
worker는 일정 시간 동안만 작업의 소유권을 빌린다. 이 lease로 작업이
processing 상태에 영원히 남는 것을 막는다.lease가 만료된 작업은 주기적으로 다시
pending으로 돌린다.최대 시도 횟수를 넘긴 작업은 더 이상 자동으로 실행하지 않고
failed로 보낸다.작업 시간이 lease보다 길어질 수 있다면 heartbeat로 만료 시간을 연장할 수 있다.
다만 lease는 정확히 한 번 실행을 보장하는 장치가 아니다.
기존 worker가 살아 있지만 네트워크 문제로 heartbeat만 실패할 수도 있다. 복구 worker가 같은 작업을 다시 가져가면 두 worker가 잠시 동시에 실행된다. 따라서 이 구조의 전달 보장은 현실적으로 at-least-once에 가깝다.
중복 실행을 전제로 만들고 부작용은 멱등하게 처리해야 한다.
재시도는 즉시 반복하지 않는다
외부 API 장애나 일시적인 네트워크 오류가 발생했을 때 같은 작업을 바로 반복하면 장애가 난 시스템에 부하를 더할 수 있다.
재시도 시점은 애플리케이션에서 계산해
available_at에 넣는다.worker는
available_at <= NOW(6)인 작업만 가져가므로 별도의 스케줄러 없이 지연 재시도를 구현할 수 있다.여기서 첫 번째
status는 남은 시도 횟수에 따라 pending 또는 failed가 된다.모든 오류를 재시도해서도 안 된다.
- 타임아웃, 일시적인 5xx처럼 회복 가능한 오류는 재시도한다.
- 입력값 오류나 권한 오류처럼 다시 실행해도 같은 결과가 나오는 오류는 바로 실패시킨다.
- 재시도 간격에는 jitter를 넣어 여러 작업이 동시에 다시 몰리지 않게 한다.
- 최종 실패한 작업은 원인과 payload를 확인한 뒤 수동 재처리할 수 있어야 한다.
비즈니스 트랜잭션과 작업 생성을 함께 commit할 수 있다
MySQL 작업 큐를 쓰면 이미 사용 중인 비즈니스 데이터와 작업 생성을 하나의 트랜잭션에 넣을 수 있다. 이 구조가 MySQL 작업 큐의 가장 큰 장점이다.
주문 생성과 이메일 큐 등록을 예로 들어보자.
이 구조에서는 주문만 생성되고 작업 등록이 실패하는 상태나, 주문이 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 쿼리의 조건과 정렬 순서에 맞춘 인덱스가 필요하다.
운영에 넣기 전에는 반드시 실행 계획을 확인한다.
확인할 항목은 다음과 같다.
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가 만료되는 작업, 계속 실패하는 작업은 뒤의 작업보다 늦게 끝날 수 있다.
대부분의 이메일, 이미지 처리, 통계 집계에서는 문제가 되지 않을 수 있다. 하지만 계좌 상태 변경이나 동일 주문의 이벤트처럼 처리 순서가 의미를 갖는다면 다른 방법이 필요하다.
- 같은 aggregate key의 작업을 하나의 worker가 순차 처리한다.
- key별로 queue를 나누고 각 queue의 동시성을 1로 제한한다.
- 순서를 기본 모델로 제공하는 Kafka partition 같은 도구를 사용한다.
- 후속 작업이 이전 version을 확인하고 오래된 이벤트를 거절하게 만든다.
SKIP LOCKED를 선택하면 처리량을 위해 엄격한 순서를 일부 포기하게 된다.복제 환경도 확인해야 한다
MySQL 공식 문서는
NOWAIT와 SKIP LOCKED를 사용하는 statement가 statement-based replication에서 안전하지 않다고 명시한다. 잠금 상태는 source와 replica에서 같지 않을 수 있으므로, 각 서버가 서로 다른 row를 선택할 수 있기 때문이다.MySQL 8.4의 기본 binary logging format은 row-based지만, 기존 환경이나 관리형 데이터베이스의 설정을 가정해서는 안 된다.
SKIP LOCKED 기반 큐를 사용한다면 다음을 확인한다.- claim과 상태 변경은 읽기 replica가 아니라 writer에서 실행한다.
binlog_format이ROW인지 확인한다.
- replica lag가 큐 처리 판단에 영향을 주지 않게 한다.
- failover 뒤에도 lease와 worker 상태를 기준으로 안전하게 재처리할 수 있어야 한다.
특히 replica에서
pending 작업을 읽고 writer에서 상태를 바꾸는 구조는 피하는 것이 좋다. 복제 지연 때문에 이미 처리된 작업을 다시 보거나 방금 생성된 작업을 놓칠 수 있다.polling도 비용이다
전용 메시지 브로커에서는 새 메시지가 들어오면 consumer가 이어서 처리할 수 있다. 반면 MySQL 큐에서는 worker가 처리할 작업이 있는지 반복해서 조회하기 쉽다. 이런 조회 방식이 polling이다.
빈 큐를 수십 개의 worker가 짧은 간격으로 계속 조회하면 쓸모없는 쿼리가 쌓인다.
고정된 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 정리 지연
대기 중인 작업 수만 보면 충분하지 않다. 작업 수는 적어도 가장 오래된 작업이 오랫동안 처리되지 않을 수 있다.
락 문제가 의심된다면
performance_schema.data_locks와 data_lock_waits에서 현재 보유한 락과 대기 관계를 확인할 수 있다.완료된 작업은 계속 쌓아두지 않는다
작업이 끝난 뒤에는
completed row도 관리해야 한다. 계속 쌓아두면 인덱스가 커지고 백업과 DDL 비용도 함께 증가한다.보존 기간을 정한 뒤 작은 batch로 삭제하거나 별도 이력 테이블로 옮긴다.
한 번에 많은 row를 삭제하면 undo log, replication lag와 lock 부담이 커질 수 있다. 삭제 작업도 짧은 batch로 반복하고 지표를 보면서 실행한다.
감사나 재처리를 위해 긴 이력이 필요하다면 실행 큐와 이력 저장소를 분리하는 편이 낫다.

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