
RabbitMQ 대신 PostgreSQL? 생각보다 많은 팀이 이렇게 쓰고 있어요
백엔드 개발을 하다 보면 "작업 큐(job queue)"가 필요한 순간이 꼭 와요. 이메일 발송, 이미지 리사이즈, 결제 처리 같은 작업을 바로 처리하지 않고 대기열에 넣어뒀다가 나중에 처리하는 패턴이죠. 이런 큐를 구현할 때 보통 RabbitMQ, Kafka, Amazon SQS 같은 전용 메시지 브로커를 쓰는 게 정석인데요, 실제로는 "우리 이미 PostgreSQL 쓰고 있는데, 그냥 DB 테이블로 큐 만들면 안 되나?"라고 생각하는 팀이 꽤 많아요.
그리고 실제로 이 방법이 잘 동작하기도 해요. 특히 트래픽이 엄청나게 많지 않은 서비스라면, 별도의 인프라를 추가하지 않고 PostgreSQL 하나로 데이터 저장과 작업 큐를 동시에 처리할 수 있어서 운영 부담이 확 줄거든요. 하지만 이 방식에는 함정이 있어요. 제대로 관리하지 않으면 큐 테이블이 점점 비대해지면서 전체 데이터베이스 성능을 갉아먹기 시작하는 거예요.
PlanetScale 블로그에서 바로 이 주제를 다뤘는데요, PostgreSQL 큐를 건강하게 유지하는 실전 노하우가 담겨 있어서 자세히 풀어볼게요.
PostgreSQL 큐가 병드는 이유: 테이블 비대화(Table Bloat)
핵심 문제부터 이야기하면, PostgreSQL의 MVCC(다중 버전 동시성 제어) 아키텍처 때문이에요. 이게 뭐냐면, PostgreSQL은 데이터를 업데이트하거나 삭제할 때 실제로 기존 행을 지우지 않아요. 대신 "이 행은 더 이상 유효하지 않아요"라고 표시만 해두고, 새로운 버전의 행을 따로 만들어요. 마치 문서를 수정할 때 원본에 취소선을 긋고 옆에 새로 쓰는 것과 비슷하죠.
이런 "죽은 행(dead tuple)"들은 나중에 VACUUM이라는 정리 작업을 통해 치워지는데요, 큐 테이블은 특성상 INSERT(작업 추가) → UPDATE(상태 변경: 대기중→처리중→완료) → DELETE(완료된 작업 제거)가 엄청나게 빈번하게 일어나요. 그러다 보면 죽은 행이 쌓이는 속도가 VACUUM이 치우는 속도를 넘어서면서 테이블이 계속 커지는 거예요.
테이블이 비대해지면 인덱스 효율이 떨어지고, 시퀀셜 스캔이 느려지고, 디스크 공간을 잡아먹고, 결국 "큐에서 다음 작업 하나 가져오기"라는 단순한 쿼리조차 느려지기 시작해요. 이게 바로 PostgreSQL 큐가 "병드는" 과정이에요.
건강한 큐를 유지하는 실전 전략
VACUUM 설정을 큐 테이블에 맞게 튜닝하기
PostgreSQL에는 autovacuum이라는 자동 정리 기능이 있는데요, 기본 설정은 일반적인 OLTP(트랜잭션 처리) 워크로드에 맞춰져 있어요. 큐 테이블처럼 변경이 극단적으로 빈번한 테이블에는 기본 설정이 너무 느슨할 수 있어요.
테이블별로 autovacuum 설정을 따로 지정할 수 있는데, autovacuum_vacuum_scale_factor를 낮추고 autovacuum_vacuum_cost_delay를 줄여서 더 자주, 더 공격적으로 정리가 돌아가게 하는 게 핵심이에요. 쉽게 말하면, "쓰레기가 좀만 쌓여도 바로바로 치워라"고 설정하는 거죠.
완료된 작업은 빠르게 삭제하거나 아카이브하기
큐 테이블에 완료된 작업을 계속 남겨두는 건 좋지 않아요. "나중에 로그 볼 수 있으니까"라는 이유로 남겨두면 테이블이 금방 수백만 행으로 불어나거든요. 완료된 작업은 별도의 아카이브 테이블로 옮기거나, 일정 기간이 지나면 삭제하는 정책을 세워야 해요.
PostgreSQL의 파티셔닝(partitioning)을 활용하면 더 깔끔해요. 날짜별로 파티션을 나눠두면, 오래된 파티션을 통째로 DROP하는 게 행 하나하나 DELETE하는 것보다 훨씬 빠르고 깨끗하거든요. DELETE는 죽은 행을 만들지만, 파티션 DROP은 테이블 자체를 없애버리니까 bloat가 아예 발생하지 않아요.
SELECT ... FOR UPDATE SKIP LOCKED 활용하기
여러 워커(worker)가 동시에 큐에서 작업을 가져가야 할 때, 가장 흔한 실수가 같은 작업을 여러 워커가 동시에 가져가는 거예요. 이걸 방지하려면 SELECT ... FOR UPDATE SKIP LOCKED 패턴을 쓰는 게 좋아요.
이게 뭐냐면, "잠겨 있지 않은 행 중에서 하나를 가져오면서 동시에 잠그고, 이미 다른 워커가 잠근 행은 건너뛰어라"는 뜻이에요. PostgreSQL 9.5부터 지원되는 기능인데, 이걸 쓰면 워커들끼리 경합(contention)이 확 줄어들어요. 마치 놀이공원에서 줄 서 있을 때 빈 줄을 찾아가는 것과 비슷하죠.
인덱스 전략 잘 세우기
큐 테이블에서 가장 자주 실행되는 쿼리는 "상태가 '대기중'인 행 중 가장 오래된 것"을 가져오는 건데요, 이 쿼리에 최적화된 부분 인덱스(partial index)를 만들면 성능이 크게 좋아져요. 예를 들어 CREATE INDEX ON jobs (created_at) WHERE status = 'pending' 같은 식으로요. 이러면 완료된 수백만 행은 인덱스에 포함되지 않아서 인덱스 크기가 작고 빠르게 유지돼요.
그래서 PostgreSQL 큐를 쓸까, 전용 브로커를 쓸까?
정답은 "상황에 따라 다르다"인데요, 좀 더 구체적으로 기준을 제시하면 이래요.
PostgreSQL 큐가 적합한 경우는, 작업량이 초당 수백 건 이하이고, 이미 PostgreSQL을 메인 DB로 쓰고 있고, 운영할 인프라를 최소화하고 싶을 때예요. 스타트업이나 소규모 팀에서 특히 유용하죠.
반면 Kafka나 RabbitMQ 같은 전용 브로커가 필요한 경우는, 초당 수만 건 이상의 메시지를 처리해야 하거나, 여러 서비스가 같은 메시지를 구독해야 하거나(pub/sub), 메시지 순서 보장이나 재처리(replay)가 중요할 때예요.
최근에는 이 중간 지점을 노리는 솔루션도 많아졌어요. PGMQ는 PostgreSQL 위에 Redis 스타일의 큐 인터페이스를 얹은 확장이고, Graphile Worker는 Node.js 환경에서 PostgreSQL 큐를 편하게 쓸 수 있게 해주는 도구예요. River는 Go 생태계에서 비슷한 역할을 하고요.
한국 개발자에게 주는 시사점
한국 스타트업 중에 PostgreSQL을 메인 DB로 쓰는 팀이 정말 많은데요, 초기에는 큐도 같은 DB로 처리하다가 서비스가 커지면서 문제를 겪는 경우를 종종 봐요. 이 글에서 다룬 VACUUM 튜닝, 파티셔닝, SKIP LOCKED 같은 테크닉은 당장 실무에 적용할 수 있는 것들이에요.
특히 autovacuum 설정을 테이블별로 다르게 가져가는 건 많은 분들이 놓치는 부분인데, 큐 테이블뿐 아니라 로그 테이블처럼 쓰기가 빈번한 테이블이라면 꼭 한번 점검해보세요.
한줄 정리
PostgreSQL을 큐로 쓰는 건 충분히 합리적인 선택이지만, VACUUM 튜닝과 테이블 관리 전략 없이는 시한폭탄을 안고 가는 것과 같아요.
여러분 팀에서는 작업 큐를 어떻게 구현하고 계신가요? PostgreSQL로 직접 구현해본 경험이 있다면 어떤 점이 좋았고 어떤 점이 힘들었는지 이야기 나눠봐요!
🔗 출처: Hacker News
TTJ 코딩클래스 정규반
월급 외 수입,
코딩으로 만들 수 있습니다
17가지 수익 모델을 직접 실습하고, 1,300만원 상당의 자동화 도구와 소스코드를 받아가세요.
"비전공 직장인인데 반년 만에 수익 파이프라인을 여러 개 만들었습니다"
실제 수강생 후기- 비전공자도 6개월이면 첫 수익
- 20년 경력 개발자 직강
- 자동화 프로그램 + 소스코드 제공