View

출처: blogspot.com
최근 개발 커뮤니티에서는 "그냥 Postgres를 써라(Just Use Postgres)"라는 말이 반복적으로 등장한다. 인프라 복잡도를 낮추기 위해 Redis, RabbitMQ, SQS를 걷어내고 postgres 큐 하나로 통합하는 팀이 늘어나고 있다는 이야기다. 다만 실제로 쓸만한지, 운영할 때 어떤 문제가 터지는지에 대해서는 대체로 가볍게 다뤄진다. 반년 동안 실서비스에 투입해 운영해 본 결과 장점도 분명했고 삽질 포인트도 꽤 드러났다.
이하에서는 SKIP LOCKED 같은 기술 원리부터 시작해 실제 테이블 설계와 enqueue/dequeue 쿼리를 구성해 보고, 마지막으로 bloat과 vacuum처럼 운영 중 반드시 마주치게 되는 지뢰들까지 풀어낼 예정이다. 2000단어를 넘는 글이니 커피 한 잔을 곁에 두기를 권한다.
Postgres가 큐로 사용된다는 것이 사실인가
결론부터 말하면 사실이다. Figma, GitLab, Notion 같은 곳도 작업 큐의 상당 부분을 Postgres로 운영하고 있으며, River Queue(Go), graphile-worker(Node.js), Oban(Elixir) 같은 라이브러리들이 모두 Postgres를 백엔드로 사용한다. Brandur Leach가 Stripe 재직 시절에 작성한 "Postgres Queues" 글이 레퍼런스처럼 인용되는 상황이다.
왜일까? 이유는 단순하다. Redis는 휘발성이라 영속성에 대한 고민이 필요하고, RabbitMQ나 Kafka는 별도의 운영 인력을 요구하며, SQS는 벤더 락인과 가시성(visibility) 디버깅의 피로가 크다. 반면 Postgres는 이미 사용 중인 DB이며, ACID 트랜잭션 안에서 큐 작업과 비즈니스 데이터를 한 번에 커밋할 수 있다. 이것이 실질적인 차이다. 트랜잭셔널 아웃박스(Transactional Outbox) 패턴을 구현할 때 다른 큐 시스템은 2-phase commit에 가까운 처리를 요구하지만, Postgres는 같은 트랜잭션에 INSERT INTO jobs를 추가하면 끝난다.
이미 운영 중인 회사들의 사례
GitLab은 Sidekiq이 담당하던 일부 워커를 Postgres 기반으로 이관했고, Figma는 내부 작업 큐를 Postgres로 운영하면서 초당 수천 건을 처리하고 있다. Notion 역시 영속성이 중요한 작업은 Postgres, ephemeral한 초고속 작업만 Redis로 분리한다고 공개한 바 있다. 규모가 극단적으로 크지 않은 한 postgres 메시지 큐로 충분하다는 의미이다. 오히려 별도 인프라를 운영하지 않는 쪽이 운영 부담 측면에서 훨씬 유리하다.
Postgres 큐의 핵심 기술들
SELECT ... FOR UPDATE SKIP LOCKED
이것이 가장 중요하다. postgres skip locked는 PostgreSQL 9.5부터 도입된 기능으로, 여러 워커가 같은 큐 테이블에서 동시에 job을 가져가려 할 때 락 충돌 없이 각자 다른 row를 획득하도록 해 준다.
SELECT id, payload
FROM jobs
WHERE status = 'pending'
AND run_at <= NOW()
ORDER BY run_at
FOR UPDATE SKIP LOCKED
LIMIT 10;
이 쿼리를 워커 10개가 동시에 실행해도 각자 서로 다른 row를 10개씩 가져간다. FOR UPDATE만 존재할 경우 이후에 들어온 쿼리가 대기(block)하지만, SKIP LOCKED를 붙이면 이미 잠긴 row는 건너뛰고 다음 row를 잡는다. 동시성 확보가 이 한 줄로 해결되는 것이다.
이 기능이 없던 시절에는 advisory lock을 걸거나 status 컬럼으로 낙관적 락을 흉내 내면서 재시도 루프를 도는 방식이 사용되었으나 이제는 불필요해졌다. 사실상 게임 체인저였다.
LISTEN / NOTIFY
워커가 지속적으로 폴링(polling)하면 DB 부하가 커지므로 pub/sub처럼 사용할 수 있는 기능이 제공된다. 이를 postgres listen notify라 부른다.
-- 워커 쪽
LISTEN new_job;
-- producer 쪽 (트리거 또는 애플리케이션에서)
NOTIFY new_job, 'job_id_123';
다만 한계도 존재한다. 페이로드가 최대 8KB로 제한되어 큰 데이터를 실어 보내면 실패하고, 트랜잭션 커밋 이후에만 실제로 발송되기 때문에 롤백되면 알림도 전달되지 않는다(이는 오히려 장점이다). 또한 구독자가 없으면 그대로 소멸한다. durable하지 않으므로 워커가 다운되어 있던 동안 발생한 NOTIFY는 받을 방법이 없다.
따라서 NOTIFY는 "새 job이 생겼다"는 신호용으로만 사용하고, 실제 job을 가져가는 것은 여전히 SKIP LOCKED 쿼리로 처리해야 한다. 폴링 주기를 1초에서 30초로 늘려 두고 NOTIFY를 받으면 즉시 깨어나도록 구성한 하이브리드 방식이 가장 안정적이다.
pg_cron, pgmq 같은 확장
매번 테이블과 쿼리를 직접 작성하기가 번거롭다면 pgmq라는 확장을 사용하면 된다. Tembo가 개발한 것으로 SQS와 API를 유사하게 맞춰 두어 익숙하게 다룰 수 있다. pgmq.send(), pgmq.read(), pgmq.delete() 같은 함수 호출만으로 처리가 끝난다.
-- 큐 생성
SELECT pgmq.create('email_queue');
-- 메시지 전송
SELECT pgmq.send('email_queue', '{"to": "user@example.com"}');
-- 가시성 타임아웃 30초로 읽기
SELECT * FROM pgmq.read('email_queue', 30, 1);
Supabase, Neon, RDS에서도 사용 가능하며 관리형 환경에서도 부담이 적다. pg_cron은 DB 내부 스케줄러이므로 주기적으로 dead message를 정리하거나 통계를 기록할 때 함께 쓰면 궁합이 좋다.

실제로 큐 테이블을 구성해 보기
테이블 스키마 설계
직접 구성한다고 가정하면 다음 수준의 스키마가 무난하다.
CREATE TABLE jobs (
id BIGSERIAL PRIMARY KEY,
queue TEXT NOT NULL DEFAULT 'default',
payload JSONB NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 5,
run_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
locked_by TEXT,
locked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
) WITH (fillfactor = 80);
CREATE INDEX idx_jobs_fetch ON jobs (queue, status, run_at)
WHERE status = 'pending';
몇 가지 포인트를 짚어 보자. fillfactor = 80은 페이지에 20%의 빈 공간을 남겨 두는 설정이다. 큐 테이블은 UPDATE가 빈번하여 HOT update 가능성을 높여야 하고, 이것이 bloat을 줄이는 1차 방어선이 된다. partial index는 WHERE status = 'pending'을 걸어 처리 완료된 row는 인덱스에 포함되지 않도록 구성했다. 수억 row가 쌓여도 조회 속도가 일정하게 유지된다. payload는 JSONB로 지정해 두어 나중에 필드가 변경되어도 스키마 마이그레이션이 불필요하다는 소소한 이점도 있다.
enqueue / dequeue 쿼리 예제
Enqueue는 단순한 INSERT 한 줄이다.
INSERT INTO jobs (queue, payload)
VALUES ('email', '{"to": "user@example.com", "template": "welcome"}')
RETURNING id;
Dequeue는 앞서 살펴본 SKIP LOCKED 패턴에 상태 업데이트를 더하면 된다.
WITH next_job AS (
SELECT id FROM jobs
WHERE queue = 'email'
AND status = 'pending'
AND run_at <= NOW()
ORDER BY run_at
FOR UPDATE SKIP LOCKED
LIMIT 1
)
UPDATE jobs
SET status = 'running',
locked_by = 'worker-1',
locked_at = NOW(),
attempts = attempts + 1
FROM next_job
WHERE jobs.id = next_job.id
RETURNING jobs.id, jobs.payload;
CTE로 한 번에 묶어 race condition을 제거한다. 워커가 이 쿼리로 job을 획득해 처리에 성공하면 DELETE 또는 status = 'completed'로 변경해 주면 된다.
재시도, 백오프, dead letter 처리
실패 처리 또한 SQL 한 번으로 해결된다.
-- 실패 + 재시도 예정
UPDATE jobs
SET status = 'pending',
run_at = NOW() + (attempts * INTERVAL '30 seconds'),
locked_by = NULL,
locked_at = NULL
WHERE id = $1 AND attempts < max_attempts;
-- 최대 재시도 초과 → dead letter
UPDATE jobs
SET status = 'failed'
WHERE id = $1 AND attempts >= max_attempts;
백오프는 run_at에 시간을 더해 미루면 된다. 지수 백오프로 가려면 POWER(2, attempts) 형태로 계산해 주면 충분하다. dead letter 큐는 별도 테이블로 분리하는 편이 깔끔하다. 큐 테이블에 실패 row가 계속 쌓이면 bloat 문제가 의외로 빠르게 확대된다.
Postgres 큐를 건강하게 유지하는 방법
여기서부터가 본론에 가깝다. 사용법 관련 글은 흔한데 운영에 대한 한국어 자료는 드물어, 이 섹션이 본 글의 핵심이다.
dead tuple과 bloat 관리
큐 테이블은 UPDATE/DELETE 빈도가 압도적으로 높다. Postgres는 MVCC 구조이므로 UPDATE가 발생할 때마다 기존 row는 dead tuple로 남고 새 버전이 생성된다. 이것을 제때 정리하지 않으면 테이블 크기가 실데이터의 5배, 10배까지 팽창한다. 이것이 table bloat이다. 우리 팀도 초기에 이를 방치했다가 jobs 테이블이 800MB까지 불어 vacuum full을 한 번 돌리고 나서야 문제를 인식한 적이 있다.
autovacuum 튜닝을 하지 않으면 큐 테이블은 반드시 터진다. 기본값은 "전체 row의 20%가 변경되었을 때 vacuum"인데, 큐 테이블은 하루에 수십 번 전체 row가 회전한다. 따라서 다음과 같이 공격적으로 조정해야 한다.
ALTER TABLE jobs SET (
autovacuum_vacuum_scale_factor = 0.01,
autovacuum_vacuum_threshold = 100,
autovacuum_vacuum_cost_limit = 2000,
autovacuum_naptime = 10
);
scale_factor 0.01은 "1%만 변경되어도 vacuum을 돌리라"는 의미이다. cost_limit을 올려 vacuum이 빠르게 종료되도록 한다.
추가로 처리 완료된 row는 즉시 DELETE하는 편이 낫다. status = 'completed'로 적재해 두면 partial index 덕분에 조회는 빠르겠으나 테이블의 물리적 크기는 계속 증가한다. 감사 로그가 필요하다면 별도의 jobs_archive 테이블로 이관하고 원본은 삭제하는 식으로 분리하는 편이 낫다.
long-running transaction 감시
워커가 하나의 job을 처리하는 데 10분이 걸린다고 가정하면, 그 10분 동안 트랜잭션은 열려 있다. Postgres는 열려 있는 트랜잭션보다 오래된 dead tuple은 vacuum할 수 없다. 이 하나만으로 전체 DB의 bloat이 폭증한다.
감시용 쿼리는 숙지해 둘 필요가 있다.
SELECT pid, usename, state, query_start,
NOW() - xact_start AS xact_age,
LEFT(query, 100) AS query
FROM pg_stat_activity
WHERE state IN ('active', 'idle in transaction')
ORDER BY xact_start
LIMIT 20;
xact_age가 수 분을 초과하는 커넥션이 존재하면 즉시 의심해야 한다. 특히 idle in transaction은 거의 확실한 버그이다. 애플리케이션 단에서 lock 타임아웃과 statement 타임아웃을 걸어 두면 안전장치 역할을 한다.
SET statement_timeout = '30s';
SET lock_timeout = '5s';
SET idle_in_transaction_session_timeout = '60s';
큐 워커는 job 처리를 트랜잭션 외부에서 수행하는 것이 원칙이다. 트랜잭션을 열어 둔 상태에서 외부 API를 호출하는 구성은 명백한 지뢰이다. job lock만 획득하고 커밋한 뒤, 실제 작업을 수행하고, 종료 후 새 트랜잭션에서 DELETE를 실행하는 방식으로 분리해야 한다.
XID wraparound 주의
Postgres의 트랜잭션 ID(XID)는 32비트로, 약 20억 개를 소모하면 한 바퀴를 돈다. 이를 관리하지 않으면 DB가 셧다운 모드로 진입한다. 큐 워크로드는 TPS가 높아 특히 위험하다.
SELECT datname, age(datfrozenxid) AS age
FROM pg_database
ORDER BY age DESC;
age가 2억을 넘으면 주의, 10억을 넘으면 비상 상황이다. autovacuum_freeze_max_age의 기본값이 2억이므로 큐 테이블이 많은 환경이라면 모니터링 대시보드에 반드시 포함시켜야 한다.

Postgres 큐를 사용하지 말아야 할 때
"Postgres 만능론" 류의 글이 지나치게 많으므로 한계도 솔직히 짚자면, 초당 수만 건을 처리해야 하는 상황에서는 Postgres가 불리하다. 튜닝을 거치면 초당 1~2만 건까지는 버티지만 그 이상으로 올라가면 SKIP LOCKED 경합으로 인한 스케일 리밋이 발생하며, 그 시점부터는 SQS나 Kafka가 유리하다. 멀티 리전 fan-out도 Postgres replication이 비동기이기 때문에 리전 간 큐로 사용하기에는 애매하다. 이는 Kafka나 NATS가 담당해야 할 영역이다.
이미 Kafka를 운영하고 있다면 굳이 Postgres 큐를 추가할 이유가 없다. 오히려 Outbox 패턴으로 Postgres에서 Kafka로 브릿지하는 쪽이 정답에 가깝다. 스트리밍이나 실시간 분석 역시 큐라기보다는 스트림 처리 영역이므로 Postgres는 부적합하다.
redis vs postgres queue 비교도 자주 등장하지만 결론은 분명하다. 초당 10만 건 수준의 캐시성 작업은 Redis, 영속성이 필요한 비즈니스 로직 작업은 Postgres이다. 양자는 경쟁 관계가 아니라 역할 분담이다.
pgmq, River, graphile-worker 비교
직접 구현하지 않고 라이브러리를 사용하고자 할 때의 선택지들이다.
| 라이브러리 | 언어 | 특징 | 쓸만한 경우 |
|---|---|---|---|
| pgmq | 확장 (SQL) | SQS 호환 API, 가시성 타임아웃 | 언어 중립, 폴리글랏 환경 |
| River | Go | 타입 안전, 관찰성 우수, 공식 대시보드 | Go 백엔드, 프로덕션 레디 |
| graphile-worker | Node.js | LISTEN/NOTIFY 기반 저지연 | Node 환경, 낮은 레이턴시 |
| Oban | Elixir | BEAM VM 통합, 클러스터링 | Phoenix/Elixir 앱 |
| que | Ruby | advisory lock 기반 | Rails 레거시 |
River가 관찰성(observability) 측면에서 특히 잘 만들어진 편이다. job 상태 시각화, 메트릭 노출, 재시도 UI까지 모두 제공된다. graphile-worker는 초저지연이 강점이지만 LISTEN/NOTIFY 특성상 워커가 수백 개를 초과하면 관리가 어려워진다. pgmq는 언어 중립이어서 마이크로서비스 환경에 유리하다. Python, Go, Node가 동일한 큐를 공유할 수 있다.
Postgres 큐, 결국 언제 사용하는 것이 옳은가
정리하면 다음과 같다. 초당 수천 건 이하의 규모에서는 충분히 쓸만하다. 인프라 하나를 제거해도 기능은 거의 동일하며 운영 비용도 감소한다. 트랜잭셔널 일관성이 중요한 비즈니스 로직, 즉 결제, 주문, 이메일 발송 같은 영역이라면 오히려 postgres 큐가 Redis보다 우수하다. 같은 트랜잭션으로 묶을 수 있다는 점이 생각보다 큰 이점이다.
운영 포인트는 세 줄로 요약된다. fillfactor를 낮추고 autovacuum을 공격적으로 튜닝할 것, long-running transaction 모니터링을 잊지 말 것, 완료된 row는 즉시 삭제하거나 아카이브 테이블로 이관할 것. 이 세 가지만 지켜도 bloat 지옥은 피할 수 있다.
참고 문서로는 PostgreSQL 공식 SELECT FOR UPDATE 문서, Brandur Leach의 Postgres Queues 포스트, pgmq GitHub 이 세 가지만 읽어도 80%는 커버된다.
'Database' 카테고리의 다른 글
| Postgres는 테이블 락만 가능한가? 아니면 행 단위 락도 가능한가? 결론부터 정리한다 (0) | 2026.04.28 |
|---|---|
| StarRocks는 무엇이며 왜 이렇게 주목받는가? 토스와 네이버까지 도입한 이유 정리 (0) | 2026.04.28 |
| AI가 Postgres에 UUID만 박아주는 이유, 그것은 진정 괜찮은가 (0) | 2026.04.18 |
| MySQL Boolean 컬럼의 진실과 주의점 (0) | 2023.08.07 |
| MySQL 파티션을 왜 사용하는가?! (0) | 2023.07.19 |
