DB 폴링과 작별할 시간을 아는 기술
DB 폴링만으로 버티던 서비스가 커넥션 풀 고갈과 작업 지연 누적이라는 벽에 부딪히는 순간이 온다. 이 글은 그 신호를 감지하는 구체적인 기준과 함께, Redis/BullMQ 등 메시지 큐 도입 시 실제로 발생하는 consumer 확장·재시도·데드레터 관리 같은 운영 부담을 생략 없이 다룬다. 큐 도입은 해결책이자 새 책임이며, '정말 필요한가'를 판단하는 실용적인 기준을 제시한다.
DevInsight 편집팀 발행
AI 보조 초안과 편집 검수를 거쳐 발행했습니다.
DB 폴링이 먹통이 되는 순간은 생각보다 갑작스럽다. 어느 날 오후, 커넥션 풀이 전부 소진되면서 ER_CON_COUNT_ERROR가 쏟아지고, "5초마다 한 번"이었던 폴링 쿼리가 어느새 30초를 넘기고 있다. 그제야 SELECT ... WHERE processed_at IS NULL이 더는 견딜 수 있는 부하가 아님을 깨닫는다.
DB를 작업 큐로 쓰는 방식은 작을 때는 완벽하게 작동한다. 하루 수백 건의 백그라운드 작업이라면 created_at 정렬에 LIMIT 10 FOR UPDATE SKIP LOCKED 하나로도 문제없다. 인프라는 단순하고, 미들웨어는 늘지 않으며, 장애 지점도 적다. 이 상태에서는 큐 시스템을 도입하는 게 오버엔지니어링이다.
한계는 연결 수와 쿼리 지연의 곱으로 결정된다.
폴링 간격이 5초고 처리량이 1초에 10건이면 DB는 초당 최소 (1 + 10)회의 조회+업데이트를 받는다. 여기에 트랜잭션 격리 수준까지 더해지면 InnoDB의 row lock 경합이 시작된다. SKIP LOCKED가 등장하기 전 MySQL 5.7에서는 같은 조건의 레코드를 여러 컨슈머가 동시에 읽으려다 데드락이 발생하는 것도 드문 일이 아니었다. 이 지점에서 많은 팀이 "큐를 도입할까?"를 고민하기 시작한다.
하지만 여기서 결정을 내리려면 먼저 폴링이 실패하는 패턴을 세 가지로 나눠볼 필요가 있다.
첫째, 폴링 간격보다 작업 시간이 길어지는 경우다. 처리 시간이 3초인데 폴링 간격이 2초라면 큐에 작업이 쌓일수록 뒤처리가 밀린다. DB에는 처리되지 않은 레코드가 계속 쌓이고, 폴링은 그걸 다시 읽어오느라 점점 느려진다. 커넥션 풀은 10~20개가 전부인데, 20개 커넥션이 전부 3초짜리 쿼리를 기다리는 동안 다른 요청은 접속 자체를 못 한다. 이 패턴은 별도 워커 프로세스를 추가해도 해결이 어렵다. 폴링 자체가 동기 blocking 호출이기 때문이다.
둘째, 중복 실행과 멱등성 보장의 어려움이다. DB 폴링 기반에서는 "이 작업은 한 번만 실행한다"는 약속이 깨지기 쉽다. 커밋 시점과 폴링 시점의 미세한 차이로 같은 레코드를 두 워커가 동시에 가져갈 수 있다. FOR UPDATE로 락을 걸어도 nowait 없이 대기하도록 설정하면 데드락 가능성이 생긴다. 결국 애플리케이션 레벨에서 멱등키를 별도로 관리해야 하는데, 그 멱등키를 조회하는 쿼리가 다시 부하가 된다.
셋째, 지연에 민감한 작업을 폴링으로 처리할 수 없는 문제다. 5초 폴링은 이벤트 발생 후 최대 5초의 지연을 허용하겠다는 선언이다. 사용자에게 "변경 사항이 반영되었습니다"라고 보여준 후 실제 반영까지 5초가 걸려도 괜찮은가? 실시간 알림, 결제 정산, 재고 차감 같은 작업은 이 지연이 서비스 신뢰도를 직접 깎아먹는다.
이쯤 되면 Redis나 RabbitMQ 같은 전용 큐를 도입해야 한다는 결론에 가까워진다. 하지만 큐 도입은 해결책이면서 동시에 새로운 책임이다.
메시지 큐를 운영해본 팀은 안다. consumer가 메시지를 받았지만 ack를 보내기 전에 죽었다, 메시지는 잘 쌓이는데 consumer가 처리하지 못하고 쌓이는 backlog, 재시도가 무한히 반복되면서 데드레터 큐가 폭발했다 같은 일상과 맞닥뜨린다. BullMQ로 Redis 리스트를 큐처럼 쓰는 순간, Redis 메모리 사용량과 MAXMEMORY 정책(특히 noeviction과 allkeys-lru의 차이)은 예상보다 빠르게 다가온다.
RabbitMQ를 선택한다면 이야기가 조금 다르다. 메시지가 브로커에 도착했을 때 consumer가 없으면 Exchange→Queue 바인딩이 제대로 되었는지, Publisher Confirm이 켜져 있는지부터 확인해야 한다. 여기서 실수하면 메시지는 아무 경고 없이 증발한다. RabbitMQ는 메모리와 디스크 임계치를 넘을 때 publisher 연결을 차단하는 flow control이라는 독특한 방어 기제를 가졌는데, 이게 작동하면 갑자기 프로듀서 쪽이 멈춰서 "왜 메시지가 안 들어가지?" 하는 혼란을 유발한다.
BullMQ의 경우는 비교적 간단하다. Redis 하나만 있으면 되고, Queue, Worker, QueueScheduler 세 가지 클래스만 알면 기본 사용은 가능하다. 문제는 실전에서 발생한다. 재시도 전략을 backoff 없이 attempts: 5만 설정하면 첫 실패 후 0.5초 간격으로 5번 재시도하고 끝난다. 네트워크 일시 장애 상황에서도 같은 속도로 5번을 때리므로 5번 모두 실패할 확률이 높다. 여기에 지수 백오프와 지터를 추가하지 않으면 재시도 자체가 장애의 원인이 된다.
그리고 정말 중요한 건 consumer 확장이다. 큐 시스템을 도입해도 consumer를 단순히 늘리기만 해서는 해결되지 않는 문제들이 있다. 예를 들어 처리 순서가 중요한 작업(주문 취소 후 환불)이라면 파티셔닝 전략이 필수다. RabbitMQ의 단일 큐는 여러 consumer가 붙어도 기본적으로 round-robin으로 메시지를 분배하는데, 순서를 보장해야 한다면 consistent hash exchange나 큐를 작업 타입별로 분리해야 한다. BullMQ도 group이나 deduplication을 지원하지 않으므로 애플리케이션 레벨에서 순서 제어를 직접 구현해야 한다.
큐 도입이 주는 가장 큰 이점은 "작업 생산자와 소비자의 분리"에 있다. HTTP 요청 핸들러에서 더 이상 3초짜리 PDF 생성 작업을 기다리지 않아도 되고, 실패한 작업은 재시도 큐에서 자동으로 다시 시도된다. 하지만 동시에 다음 책임들이 따라온다.
- 데드레터 큐 모니터링: 실패한 메시지가 쌓이는 속도를 감시하고, 알림을 설정해야 한다.
- 백로그 경보: 큐 깊이가 임계치를 넘었을 때 consumer 수를 늘리거나 처리 속도를 높일 조치가 필요하다.
- 메시지 스키마 관리: 메시지 payload가 변경되면 이미 큐에 쌓인 구버전 메시지와의 호환성을 고려해야 한다.
- 정확히 한 번 처리의 환상: 큐 시스템도
at-least-once delivery(적어도 한 번 전달)를 보장할 뿐, 중복은 막아주지 않는다. 멱등성 처리는 결국 애플리케이션 몫이다.
그렇다면 큐를 언제까지 미룰 수 있을까?
한 가지 기준은 폴링 쿼리가 애플리케이션 지연(APDEX)에 미치는 영향이다. SELECT ... FOR UPDATE SKIP LOCKED 하나가 데이터베이스 CPU를 30% 이상 사용한다면 이미 늦었다. 커넥션 풀 대기 시간이 평균 100ms를 넘기 시작했다면 더 늦기 전에 전환을 준비해야 한다. 반대로 하루 작업 건수가 수천 건 이하이고, DB CPU가 10% 미만이며, 폴링 간격을 서비스 요구사항보다 충분히 짧게 유지할 수 있다면 굳이 큐를 도입할 이유가 없다.
두 번째 기준은 작업의 생명주기 관리 필요성이다. "실패하면 어떻게 할 것인가"에 대한 답이 "다시 시도한다"로 충분하다면 폴링으로도 된다. 하지만 "실패 이유에 따라 재시도 방식을 다르게 해야 한다", "3회 실패 시 사람이 개입해야 한다", "특정 작업은 24시간 내에 반드시 처리되어야 한다" 같은 요구사항이 생기면 큐 시스템의 dead letter, scheduling, priority 기능이 필수가 된다.
서드파티 API 호출이 많은 서비스라면 큐 도입을 더 적극적으로 고려해야 한다. 외부 API는 일시적 장애, rate limit, 타임아웃이 빈번하다. DB 폴링으로 이걸 처리하면 실패한 작업을 재시도하기 위해 별도 테이블과 상태 컬럼을 관리해야 한다. 결국 큐를 흉내 낸 테이블이 생긴다. 그 시점에는 진짜 큐를 쓰는 게 낫다.
큐 선택 기준도 중요하다.
BullMQ + Redis는 가벼운 시작과 JavaScript/Node.js 생태계와의 궁합이 좋다. 멱등성을 보장하지 않고, 메시지가 Redis 메모리에 저장되므로 디스크 영속성이 필요한 작업에는 부적합하다. 반면 RabbitMQ는 AMQP 프로토콜 기반으로 메시지 라우팅이 정교하고, 디스크에 메시지를 저장할 수 있어 내구성이 좋다. 대신 운영 복잡도가 높아서 RabbitMQ 클러스터를 직접 운영하는 건 상당한 노하우가 필요하다.
SQS는 관리형 서비스의 편리함을 제공하지만 AWS 종속성과 256KB 메시지 크기 제한, 최대 14일 보존 기간 같은 제약이 있다. 메시지 크기가 크거나 긴 지연이 필요한 작업이라면 S3 페이로드와 조합하거나 다른 옵션을 검토해야 한다.
결국 "폴링에서 큐로" 이전은 기술적 문제라기보다 운영 역량의 문제에 가깝다. 큐 시스템을 도입하면 모니터링 대시보드가 하나 늘고, 장애 발생 시 확인해야 할 지점이 하나 늘며, 배포 파이프라인에 큐 consumer 롤링 재시작 전략이 추가된다. 이 부담을 감당할 준비가 되었다면 큐는 좋은 선택이다. 하지만 지금 당장 3일 안에 알림 기능이 필요하다면, 기존 DB 폴링에 백오프 로직만 추가해도 충분히 버틸 수 있다.
폴링이 느리다고 느껴진다면 타이밍을 먼저 측정하라. 폴링 간격, 커넥션 풀 사용률, 백로그 증가율. 이 세 수치가 임계치 아래라면 큐 도입은 미룰 수 있다. 큐는 부하가 아닌 복잡성을 먼저 늘린다. 그 복잡성을 감당할 수 있을 때 움직이는 게 옳다.
댓글
댓글을 읽어오는 중입니다.
같이 읽으면 좋은 글
방금 읽은 주제와 이어지는 글을 골랐습니다.
1년 동안 DB 테이블로 큐 대신 쓰면서 내가 놓친 것들
DB 폴링 기반 작업 처리로 버틸 수 있는 한계와 Redis·메시지 큐 도입을 결정해야 하는 실전 신호, 그리고 큐를 도입할 때 따라오는 운영 부담을 사례 중심으로 정리한다. DB 테이블 하나로 작업 큐를 대신한 지 1년이 지났다. 처음엔 "언젠가 Redis나 RabbitMQ를 도입해야지"라고 생각했지만, 그 언젠가는 오지 않았다.
앱에선 안 되는데 SQL 에디터에선 되는 RLS의 반전
Supabase에서 RLS를 활성화하면 클라이언트 요청과 SQL 에디터 직접 조회의 결과가 달라지는 이유를 권한 체계부터 추적한다. anon 키와 service_role 키의 차이, auth.uid()가 기대한 사용자 ID를 반환하지 않는 조건, 그리고 정책이 충돌할 때 PostgreSQL이 선택하는 동작 방식을 실제 사례 중심으로 분석한다. 마지막으로 RLS 정책을 로컬과 프로덕션에서 안전하게 테스트하는 절차를 정리했다.
당신의 RLS 정책은 조용히 거짓말을 하고 있다
Supabase에서 RLS를 활성화한 순간 쿼리는 에러 없이 빈 배열을 반환하기 시작한다. service_role 키와 anon 키를 혼동할 때 벌어지는 일, SQL 에디터 테스트가 주는 환상, auth.uid()가 null을 뱉는 이유 등 실제 운영에서 마주치는 RLS 실수 패턴을 진단 쿼리와 함께 파헤친다.
이전 글
INFO, WARN, ERROR만으로는 부족하다
다음 글
next/image sizes 한 줄이 LCP를 0.5초 당긴다
DevInsight Digest
새 글이 쌓이면, 피드에서 바로 이어 읽으세요.
과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.