SKIP LOCKED 로 만드는 작업 큐

DB 테이블로 만든 작업 큐에 워커를 다섯으로 늘렸는데, 처리량은 한 대일 때와 똑같아요. 모두 맨 앞 작업 하나에 줄을 서기 때문이에요. 잠긴 행은 건너뛰는 방식으로 푸는 법을 정리했어요.

메시지 브로커를 따로 두기엔 규모가 작아서 DB 테이블로 작업 큐를 만들었어요. 상태 컬럼을 두고 워커가 대기 중인 것을 하나 집어가는 방식이요.

워커를 하나 띄웠을 때는 잘 돌았어요. 처리량을 늘리려고 워커를 다섯으로 늘리니 문제가 생겨요. 같은 작업을 두 워커가 동시에 처리하거나, 반대로 서로 락을 기다리며 다섯이 한 대만큼도 일을 못 해요.

FOR UPDATE 를 붙이면 중복은 사라지는데 이번엔 줄을 서요. 첫 워커가 맨 앞 작업을 잡으면 나머지 넷이 그 작업이 끝나기를 기다려요.

워커를 늘린 의미가 없어져요. 다섯 대를 띄웠는데 처리량은 한 대와 똑같고, 서버 비용만 다섯 배가 됩니다.

왜 그런가

일반적인 FOR UPDATE 는 "이 행이 풀릴 때까지 기다린다"예요. 그래서 여러 워커가 같은 쿼리로 맨 앞 작업을 집으려 하면 전부 같은 행에서 대기해요.

ORDER BY id LIMIT 1 로 맨 앞을 고르는 순간, 모든 워커가 같은 행을 노리게 되거든요. 결국 직렬화돼요.

SKIP LOCKED 는 여기서 규칙을 바꿔요. 이미 잠긴 행은 기다리지 않고 건너뛰어요. 첫 워커가 1번을 잡으면 둘째 워커는 1번을 건너뛰고 2번을 잡아요. 대기가 사라지고 워커 수만큼 처리량이 늘어납니다.

중요한 건 잠금이 트랜잭션과 묶여 있다는 점이에요. 워커가 작업을 잡고 처리하다가 죽으면, 트랜잭션이 끊기면서 락이 자동으로 풀려요. 다른 워커가 그 작업을 다시 집어갑니다. 별도의 타임아웃 관리 없이 장애 복구가 되는 거예요.

이게 상태 컬럼만으로 만든 큐와의 결정적 차이예요. "처리 중"으로 표시해 두고 워커가 죽으면 그 작업은 영원히 처리 중으로 남아요. 되살리려면 별도의 좀비 청소 배치가 필요하고요.

코드로 보면

잠긴 건 기다리지 말고 건너뛴다:

코드
 SELECT * FROM jobs WHERE status='ready'
 ORDER BY id LIMIT 1 FOR UPDATE;
 -- 워커 다섯이 전부 같은 행에서 대기

 BEGIN;
 SELECT * FROM jobs
 WHERE status = 'ready'
 ORDER BY id
 LIMIT 1
 FOR UPDATE SKIP LOCKED;      -- 잠긴 건 건너뛴다
 ... 처리 ...
 UPDATE jobs SET status='done' WHERE id = :id;
 COMMIT;

· 한 번에 여러 건을 집으려면 LIMIT 을 늘린다
· 대기 중인 것만 인덱싱하면 큐가 커져도 안 느려진다
 CREATE INDEX idx_jobs_ready ON jobs (id)
 WHERE status = 'ready';

✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.

실전에서는

DB 큐가 브로커보다 나은 경우가 분명히 있어요.

가장 큰 이유는 트랜잭션 원자성이에요. 작업을 넣는 일과 비즈니스 데이터를 저장하는 일이 같은 트랜잭션에 들어가요. 앞서 본 아웃박스 패턴이 정확히 이 구조고요. 브로커를 쓰면 DB 커밋과 메시지 발행이 따로라 어긋날 수 있어요.

운영 부담도 적어요. 이미 있는 DB 를 쓰니 새 컴포넌트가 없고, 작업 내용을 SQL 로 조회할 수 있고, 백업과 복구가 DB 와 함께 가요. 규모가 작을 때는 이 이점이 큽니다.

한계도 분명해요. 처리량이 DB 의 쓰기 능력에 묶여요. 초당 수만 건이면 브로커가 맞아요. 그리고 큐 테이블은 넣고 지우는 일이 계속되니 팽창이 심해요 — 앞서 본 청소 문제가 여기서 가장 두드러집니다.

폴링도 비용이에요. 워커가 계속 물어보는 구조라 작업이 없을 때도 쿼리가 돌아요. 폴링 간격을 늘리면 지연이 커지고 줄이면 부하가 커져요. 알림 기능으로 깨우는 방식을 섞으면 둘 다 개선할 수 있어요.

그리고 처리한 작업을 어떻게 할지 정해야 해요. 지우면 테이블이 작게 유지되지만 이력이 사라지고, 남기면 이력은 남지만 큐 조회가 점점 느려져요. 완료된 것을 별도 테이블로 옮기거나 파티션으로 나누는 게 실무적인 답입니다.

우선순위와 재시도도 설계 대상이에요. 우선순위를 넣으면 ORDER BY 가 복잡해지고 인덱스도 맞춰야 해요. 재시도는 시도 횟수 컬럼과 다음 실행 시각 컬럼으로 표현하는 게 일반적이고요.

처방

  1. 잠금은 SKIP LOCKED 로 하세요. 이 한 줄이 워커 확장을 가능하게 합니다.
  2. 대기 중인 작업만 담는 부분 인덱스를 만드세요. 큐 테이블은 처리된 것이 쌓이는데, 조회는 대기 중인 것만 보거든요. 인덱스가 작게 유지돼야 큐가 커져도 안 느려져요.
  3. 작업 처리를 트랜잭션 안에서 끝내되 짧게 하세요. 처리가 오래 걸리면 락도 오래 잡혀요. 무거운 작업이면 "잡았다"를 먼저 커밋하고 처리는 밖에서 하되, 그 경우 좀비 처리를 직접 관리해야 합니다.
  4. 완료된 작업을 정기적으로 옮기거나 지우세요. 파티션으로 나누면 오래된 것을 떼어내는 게 쉬워요.
  5. 재시도를 데이터로 표현하세요. 시도 횟수와 다음 실행 시각 컬럼을 두고, 실패하면 횟수를 올리고 시각을 미루세요. 일정 횟수를 넘으면 실패 큐로 옮기고요.
  6. 폴링 간격과 알림을 함께 쓰세요. 평소엔 알림으로 즉시 깨우고, 놓친 경우를 대비해 느린 폴링을 백업으로 둡니다.
  7. 큐 깊이와 가장 오래된 대기 작업의 나이를 지표로 보세요. 워커가 죽었는지 뒤처졌는지가 이 둘로 드러납니다.

지금 확인해 보세요

큐가 건강한가:

코드
· 큐 깊이와 가장 오래된 대기 작업
SELECT status, count(*),
       max(now() - created_at) AS oldest
  FROM jobs GROUP BY status;
 ready 의 oldest 가 계속 늘면 워커가 못 따라가는 것

· 워커들이 실제로 병렬로 잡고 있나
SELECT count(*) FROM pg_stat_activity
 WHERE query ILIKE '%FOR UPDATE SKIP LOCKED%'
   AND state = 'active';

· 큐 테이블의 팽창 — 여기가 제일 빨리 부푼다
SELECT n_live_tup, n_dead_tup, last_autovacuum
  FROM pg_stat_user_tables WHERE relname = 'jobs';

· 부분 인덱스가 실제로 쓰이는지
 EXPLAIN 으로 Index Scan using idx_jobs_ready 확인

핵심 한 줄줄을 서지 말고 비어 있는 다음 것을 잡아라.

브루니가 적어요

색을 바꾸며 숨는 카멜레온이에요. 운영에서 실제로 터지는 문제를 한 편씩 적어요. 같은 실수를 두 번 하지 않으려고 쓰는 기록이에요.

트랜잭션·락·DB 운영 글 21편 모두 보기 →