1년 동안 DB 테이블로 큐 대신 쓰면서 내가 놓친 것들
DB 폴링 기반 작업 처리로 버틸 수 있는 한계와 Redis·메시지 큐 도입을 결정해야 하는 실전 신호, 그리고 큐를 도입할 때 따라오는 운영 부담을 사례 중심으로 정리한다. DB 테이블 하나로 작업 큐를 대신한 지 1년이 지났다. 처음엔 "언젠가 Redis나 RabbitMQ를 도입해야지"라고 생각했지만, 그 언젠가는 오지 않았다.
DevInsight 편집팀 발행
AI 보조 초안과 편집 검수를 거쳐 발행했습니다.
DB 테이블 하나로 작업 큐를 대신한 지 1년이 지났다. 처음엔 "언젠가 Redis나 RabbitMQ를 도입해야지"라고 생각했지만, 그 언젠가는 오지 않았다. 초당 5건 남짓한 이메일 발송, 야간 배치 집계, 사용자 알림 전송 정도는 SELECT ... FOR UPDATE SKIP LOCKED 한 줄로 충분했다. 문제는 이 단순한 구조가 생각보다 오래 버틴다는 점이다. 그리고 그게 오히려 함정이다.
SKIP LOCKED가 일으킨 착각
PostgreSQL 9.5부터 도입된 SKIP LOCKED는 정말 편리하다. 여러 워커가 동시에 SELECT를 날려도 같은 작업을 중복해서 가져가지 않고, 트랜잭션을 걸어 실패 시 롤백하면 해당 행이 자연스럽게 다시 큐로 돌아온다. Advisory Lock과 조합하면 특정 작업 타입별로 동시 실행을 제한하는 것도 가능하다.
WITH job AS ( SELECT id FROM job_queue WHERE status = 'pending' AND scheduled_at <= now() ORDER BY priority DESC, created_at ASC LIMIT 1 FOR UPDATE SKIP LOCKED ) UPDATE job_queue SET status = 'processing', started_at = now() FROM job WHERE job_queue.id = job.id RETURNING job_queue.*;
이 쿼리 하나로 1년 가까이 별 탈 없이 돌아갔다. 재시도 로직도 UPDATE job_queue SET status = 'pending', retry_count = retry_count + 1 WHERE id = $1 AND retry_count < 3 정도면 끝이다. 모니터링도 SELECT COUNT(*) FROM job_queue WHERE status = 'pending' AND created_at < now() - interval '5 minutes' 한 줄이면 5분 이상 쌓인 지연 작업을 감지할 수 있다. 인프라가 단순하다는 건 장애 포인트가 적다는 뜻이고, 로컬 개발 환경과 프로덕션이 똑같다는 건 디버깅 비용을 낮춘다. 이 지점까지는 누가 뭐라 해도 합리적인 선택이다.
500밀리초라는 벽
초당 100건을 넘기 시작하면서 상황이 달라졌다. 폴링 주기를 500ms로 줄였더니 DB CPU 사용률이 15%에서 40%로 뛰었다. 워커 5개가 초당 10회씩 SELECT ... FOR UPDATE를 날리는 동안 PostgreSQL은 락 경합을 처리하느라 생각보다 많은 리소스를 소모한다. 여기에 작업 처리 자체의 DB 트랜잭션이 더해지면 커넥션 풀 소진이라는 또 다른 문제가 기다린다.
더 큰 문제는 우선순위 큐였다. ORDER BY priority DESC, created_at ASC에 복합 인덱스를 걸었지만, 작업 종류가 7개로 늘어나고 각각 우선순위 규칙이 달라지면서 쿼리 플래너가 통계를 따라잡지 못하는 현상이 발생했다. 하루에 한 번쯤 Seq Scan이 발생하고, 그게 3초 넘게 지속되면 대기열 전체가 밀렸다.
지연 작업 처리도 폴링 모델의 약점이다. scheduled_at <= now() 조건으로 미래 실행을 예약하는 방식은 특정 시간대에 수천 건이 한꺼번에 몰리는 패턴에 취약하다. 새벽 4시 리포트 발송을 3천 명에게 예약해두면, 4시 정각에 모든 워커가 한꺼번에 깨어나 DB를 두들기고, 그 와중에 실패한 작업은 재시도 큐에 쌓이고, 그걸 또 폴링으로 가져가려고 하며 지연이 2차로 증폭된다.
백프레셔라는 걸 몰랐다
DB 테이블을 큐로 쓸 때 가장 모르기 쉬운 개념이 백프레셔다. 폴링 모델에서는 생산자가 INSERT를 날리는 속도와 소비자가 SELECT ... FOR UPDATE로 가져가는 속도 사이에 아무런 조율 장치가 없다. 생산자는 그냥 행을 계속 삽입하고, 소비자는 할 수 있는 만큼 처리한다. 중간에 버퍼가 끼어 있지 않다.
메시지 큐는 이 지점에서 근본적으로 다르다. RabbitMQ는 소비자 확인 응답이 오기 전까지 prefetch 카운트만큼만 메시지를 전달하고, 소비자가 느리면 큐에 메시지가 쌓인다. 생산자는 여전히 빠르게 넣을 수 있지만, 적어도 소비자와 생산자 사이의 속도 차이가 큐 자체를 깨뜨리지는 않는다. DB 폴링 모델에서 이걸 흉내 내려면 LIMIT과 sleep을 조합한 반제어 루프를 직접 구현해야 하는데, 이미 SQL 100줄을 넘기고 있다면 그때부터는 큐를 쓰는 게 코드만 봐도 명백한 선택이다.
워커 오토스케일링도 비슷한 맥락이다. 폴링 모델에서 워커를 두 배로 늘리면 DB 부하도 같이 두 배가 된다. 메시지 큐 기반에서는 워커 증설이 큐 인프라에 미치는 영향이 훨씬 적다. BullMQ를 예로 들면, Redis에 LPUSH/BRPOPLPUSH 수준의 명령만 추가로 발생할 뿐이고, 이건 Redis가 본래 잘하는 일이다.
Redis를 큐로 쓴다는 것의 실체
"일단 Redis로 가자"는 결정은 대부분 BullMQ 덕분에 무난하게 성공한다. 재시도, 지연 작업, 우선순위, 진행률 추적까지 라이브러리가 다 해준다. Board라는 대시보드도 제공돼서 쌓인 작업을 눈으로 확인할 수 있다. 여기까지는 좋다.
간과하는 건 두 가지다. 첫째, Redis의 내구성 한계다. RDB 스냅샷은 기본 설정 기준 60초/1000건 변경마다 저장되므로, 그 사이에 서버가 재시작되면 최대 60초 분량의 메시지가 사라진다. AOF는 더 촘촘하게 기록하지만, 대량의 작업을 다루는 큐에서는 디스크 I/O가 새로운 병목이 된다. "Redis니까 메모리일 텐데 왜 디스크 때문에 느려지지?"라는 질문을 하게 되는 순간이 반드시 온다. 이때 AOF rewrite가 트리거되는 조건과 appendfsync 설정을 미리 이해하지 못하면 예상치 못한 지연 스파이크를 맞는다.
둘째, Redis 장애 시의 폴백이다. DB 폴링 모델로 돌리고 있던 시절에는 PostgreSQL이 죽으면 그냥 서비스 전체가 멈췄다. 큐를 분리한 뒤로는 "DB는 살아 있는데 큐만 죽은" 상황이 새로 생긴다. 워커가 작업을 못 가져가서 API는 정상 응답하는데 이메일만 안 나가는 상태가 30분간 지속될 수 있다. 이걸 감지하려면 큐 전용 헬스체크와 알림 채널을 따로 구축해야 하고, 이건 '그냥 DB 테이블 하나 더 쓸 걸'이라던 때에는 없던 일이다.
로컬 개발 환경도 복잡해진다. Docker Compose에 redis 서비스가 추가되고, CI에도 redis 컨테이너가 필요해진다. 신규 개발자가 합류하면 "Redis는 redis://localhost:6379로 붙으면 됩니다"라는 문서 한 줄이 추가된다. 이 모든 게 비용이다. 작게 보이는 비용이지만, 서비스가 커질수록 예상치 못한 조합으로 터진다. CI의 Redis 컨테이너가 특정 GitHub Actions 러너에서만 느리게 올라와서 테스트가 간헐적으로 실패하는 버그를 추적하는 데만 사흘이 걸린 사례도 있다.
결정을 내리기 전에
DB 폴링을 당장 걷어낼 이유는 없다. 오히려 다음 세 가지 지표를 2주 이상 모니터링한 뒤에 결정해도 늦지 않다.
폴링 쿼리의 p99 응답 시간. PostgreSQL이 10ms 이내로 응답하는 동안은 대부분 문제가 없다. 50ms를 넘어가기 시작하면 락 경합이나 통계 갱신 주기를 의심해볼 시점이다. 폴링 주기 자체의 한계다. 500ms 미만으로 내려가야 할 요구사항이 생겼다면, 그건 이미 설계를 갈아엎어야 한다는 신호다. 큐를 도입하지 않고 폴링 간격만 줄이는 건 DB를 향한 자해 행위에 가깝다.
대기열 깊이의 시간별 추이. 특정 시간대에만 작업이 몰리는 패턴이 반복된다면 지연 큐 구조를 다시 설계할 필요가 있다. 스케줄링을 큐 레벨이 아니라 별도 스케줄러에서 분리하는 아키텍처를 먼저 검토해보는 게 낫다.
재시도 비율과 데드레터 누적 속도. 재시도를 거쳐도 실패율이 5%를 넘고, 실패한 작업을 사람이 수동으로 재처리하는 일이 주 3회 이상 발생한다면, 그건 코드 레벨의 예외 처리로 커버할 수 있는 선을 넘은 것이다.
이 세 지표가 모두 경계선을 넘을 때쯤이면 큐 도입의 근거는 충분하다. 다만 도입한다면 반드시 "큐가 죽었을 때 API는 어떻게 동작해야 하는가"라는 질문에 먼저 답을 내야 한다. 메시지를 최대한 보존할 것인지, 처리를 멈추고 재시작을 기다릴 것인지, 아니면 폴백으로 DB에 직접 적재할 것인지. 이 결정 없이 큐를 붙이면, 해결하려던 문제보다 운영 부담이 먼저 도착한다.
댓글
댓글을 읽어오는 중입니다.
같이 읽으면 좋은 글
방금 읽은 주제와 이어지는 글을 골랐습니다.
DB 폴링과 작별할 시간을 아는 기술
DB 폴링만으로 버티던 서비스가 커넥션 풀 고갈과 작업 지연 누적이라는 벽에 부딪히는 순간이 온다. 이 글은 그 신호를 감지하는 구체적인 기준과 함께, Redis/BullMQ 등 메시지 큐 도입 시 실제로 발생하는 consumer 확장·재시도·데드레터 관리 같은 운영 부담을 생략 없이 다룬다. 큐 도입은 해결책이자 새 책임이며, '정말 필요한가'를 판단하는 실용적인 기준을 제시한다.
앱에선 안 되는데 SQL 에디터에선 되는 RLS의 반전
Supabase에서 RLS를 활성화하면 클라이언트 요청과 SQL 에디터 직접 조회의 결과가 달라지는 이유를 권한 체계부터 추적한다. anon 키와 service_role 키의 차이, auth.uid()가 기대한 사용자 ID를 반환하지 않는 조건, 그리고 정책이 충돌할 때 PostgreSQL이 선택하는 동작 방식을 실제 사례 중심으로 분석한다. 마지막으로 RLS 정책을 로컬과 프로덕션에서 안전하게 테스트하는 절차를 정리했다.
당신의 RLS 정책은 조용히 거짓말을 하고 있다
Supabase에서 RLS를 활성화한 순간 쿼리는 에러 없이 빈 배열을 반환하기 시작한다. service_role 키와 anon 키를 혼동할 때 벌어지는 일, SQL 에디터 테스트가 주는 환상, auth.uid()가 null을 뱉는 이유 등 실제 운영에서 마주치는 RLS 실수 패턴을 진단 쿼리와 함께 파헤친다.
이전 글
Next.js SEO, 아무도 에러를 내지 않는 실패들
다음 글
ESLint Flat Config 마이그레이션 실패 일지와 살아남는 체크리스트
DevInsight Digest
새 글이 쌓이면, 피드에서 바로 이어 읽으세요.
과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.