DevInsight

개발하며 겪은 판단을 적어요

Backend
조회 7약 4분 읽기

1년 동안 DB 테이블로 큐 대신 쓰면서 내가 놓친 것들

DB 폴링 기반 작업 처리로 버틸 수 있는 한계와 Redis·메시지 큐 도입을 결정해야 하는 실전 신호, 그리고 큐를 도입할 때 따라오는 운영 부담을 사례 중심으로 정리한다. DB 테이블 하나로 작업 큐를 대신한 지 1년이 지났다. 처음엔 "언젠가 Redis나 RabbitMQ를 도입해야지"라고 생각했지만, 그 언젠가는 오지 않았다.

DevInsight에서 발행해요

#메시지 큐#Redis#BullMQ#DB 폴링#백엔드 아키텍처#백그라운드 작업#PostgreSQL#운영 부담#시스템 설계#비동기 처리

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 폴링 모델에서 이걸 흉내 내려면 LIMITsleep을 조합한 반제어 루프를 직접 구현해야 하는데, 이미 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에 직접 적재할 것인지. 이 결정 없이 큐를 붙이면, 해결하려던 문제보다 운영 부담이 먼저 도착한다.

댓글

댓글을 읽어오는 중입니다.

같이 읽으면 좋은 글

방금 읽은 주제와 이어지는 글을 골랐습니다.

Backend 전체 보기
Backend

작은 서비스도 버틸 수 있는가, 폴링 한계와 큐 도입 시점의 판단 기준

작은 서비스에서 DB 폴링으로 작업을 처리하다 보면 스루풋이 급격히 떨어지고 레이턴시가 늘어나는 신호가 나옵니다. 이 글에서는 폴링 기반 처리가 감당해야 하는 한도를 분석하고, 큐 시스템 도입이 필요한 시점의 구체적 판단 기준을 제시합니다. 단순히 '트래픽이 늘었다'는 증상이 아니라 시스템 자체의 포화 상태를 어떻게 인지하고 대응할지, 도입 후 새겨야 하는 운영 체크포인트까지 함께 다룹니다.

#메시지 큐#폴링#백엔드 아키텍처#Redis
Backend

웹훅은 믿지 말고, 시그니처로 검증하라

웹훅 수신부는 외부의 불신을 전제로 설계해야 한다. 재전송과 처리 지연, 심지어 위조 시도까지 뒤따르는 불안정한 요청을 믿으면 안 되기에, 시그니처 검증과 멱등 처리, 재전송 ID 추적, 지연 시 큐잉 판단 등 수신부 설계에서 빼먹기 쉬운 요소를 모두 챙겨야 비로소 안정적 수신이 시작된다. 특히 재전송은 멱등키 없이 또다시 도착해도 중복 처리되지 않도록, 지연이 길어지면 즉시 큐에 넣어 처리를 미루어야 한다는 점을 잊지 말자

#webhook#signature-verification#idempotency#retry-handling

이전 글

Vercel Functions 크론 잡, 매번 딱 한 건만 처리하도록 설계한 이유

다음 글

ESLint Flat Config 마이그레이션 실패 일지와 살아남는 체크리스트

DevInsight Digest

새 글이 쌓이면, 피드에서 바로 이어 읽으세요.

과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.

RSS 구독