PgQue 도입기 – Kafka 없이 PostgreSQL만으로 큐 시스템 구축하기
배경: 큐는 필요한데 Kafka는 과하다
나는 주로 MySQL을 사용해온 개발자인데, 최근에는 PostgreSQL를 추천해주는 경우가 많아 개인 프로젝트에서도 PostgreSQL을 사용해보게 되었다.
현재 진행하고 있는 개인 프로젝트에서는 LLM API를 사용하는 부분이 있는데 LLM API는 생각보다 다양한 이유로 실패를 하게 된다. 나의 quota limit만 신경 쓴다고 되는 것이 아니라, 서버 상황에 따라 해당 모델만 사용을 못하게 되거나 서버 상태 자체가 불안정한 경우도 많았다. 게다가 응답 시간이 오래 걸리는 것도 문제였다. 그래서 이 처리를 비동기로 넘기고, 실패 시 재시도할 수 있는 큐 시스템이 필요해졌다.
큐 시스템으로 가장 먼저 떠오르는 것은 Kafka였다. 하지만 Kafka를 사용하려면 별도의 브로커 클러스터를 운영해야 하고, 사이드 프로젝트 규모에서는 과한 선택이다. 그 외의 다양한 해결책들이 있겠지만 역시 별도 인프라가 필요하였다.
별도의 인프라 없이 위와 같은 니즈를 해결할 수 있는 방법은 없을까?
PgQue
그러던 와중 GeekNews(긱뉴스)의 기사를 통해 PgQue 를 알게 되었다.
SQL 파일 하나로 설치하고, C 확장 없이 순수 PL/pgSQL로 동작한다는 점이 눈에 띄었다.
PgQue는 Skype에서 10년 넘게 사용된 PgQ의 현대화 버전이다. 검증된 아키텍처를 그대로 가져오면서, 현대 클라우드 환경에서의 제약사항을 해결한 프로젝트라는 부분이 마음에 들었다.
단순한 PostgreSQL 큐의 한계
PostgreSQL로 큐를 직접 구현하는 가장 단순한 방법은 SELECT ... FOR UPDATE SKIP LOCKED + DELETE 패턴이다. 구현 자체는 간단하고 기본적인 병렬 소비도 지원한다. 이벤트를 처리할 때마다 행을 삭제한다. 이러한 상황에서 발생되는 문제가 bloat 문제이다.
Bloat란
PostgreSQL은 MVCC(Multi-Version Concurrency Control)를 사용한다. 행을 UPDATE하거나 DELETE하면 기존 행이 즉시 사라지는 것이 아니라, 이전 버전이 dead tuple로 남게 된다. 이는 동시 실행 중인 다른 트랜잭션이 이전 버전을 참조할 수 있어야 하기 때문이다. 이 dead tuple은 VACUUM이 정리해줄 때까지 디스크 공간을 계속 차지하며, 테이블과 인덱스를 비대하게 만든다. 이 현상을 bloat라고 한다.
일반적인 OLTP 워크로드에서는 VACUUM이 적절히 처리해주지만, 큐처럼 INSERT와 DELETE가 끊임없이 반복되는 워크로드에서는 bloat가 특히 심각해진다. 공격적인 VACUUM 설정을 해도 소비 속도가 생산 속도를 따라가지 못하면 테이블이 계속 커지게 된다.
그리고 PgQ / PgQue 는 이러한 문제를 해결한 솔루션이다.
PgQ의 역사: Skype에서 10년 넘게 검증된 큐 시스템
PgQue를 이해하려면 원조인 PgQ의 역사를 알아야 한다.
PgQ는 2007년부터 Skype의 SkyTools 프로젝트의 일부로 공개된 PostgreSQL 큐 시스템이다. Skype는 대규모 메시징 서비스를 PostgreSQL 위에서 운영했고, 비동기 이벤트 처리와 데이터 복제를 위해 PgQ를 개발하여 사용했다.
PgQ의 핵심 설계는 다음과 같다.
- 트랜잭션 지원: 이벤트 생성이 비즈니스 로직과 함께 같은 트랜잭션으로 묶인다.
- 배치(Batch) 처리: 이벤트를 개별로 처리하는 대신 배치 단위로 묶어 오버헤드를 낮춘다.
- 테이블 회전(Rotation): 큐 테이블을 여러 개 두고 회전시키면서, 소비가 끝난 테이블은 TRUNCATE로 한 번에 정리한다. 이를 통해
DELETE+VACUUM이 아닌TRUNCATE를 사용할 수 있어 dead tuple 문제를 근본적으로 해결한다. - 티커(Ticker): 주기적으로 ‘틱(Tick)’을 생성하여 이벤트를 배치로 묶고 큐 관리를 수행하는 핵심 데몬이다.
- 다중 소비자: 하나의 큐에 여러 소비자가 독립적으로 구독할 수 있다.
별도 인프라 없이 동일한 트랜잭션 내에서 이벤트를 생성할 수 있고, 데이터베이스가 단일 진실 공급원(Single source of truth)이 된다.
PgQ vs PgQue: 무엇이 다른가
PgQ의 아키텍처는 검증되었지만, 현대 클라우드 환경에서 사용하기에는 제약이 있었다. PgQue는 이 제약을 해결한다.
| PgQ (원조) | PgQue | |
|---|---|---|
| 구현 방식 | C extension (CREATE EXTENSION pgq) | 순수 PL/pgSQL |
| Ticker | 외부 데몬 (pgqd) | pg_cron 또는 애플리케이션 스케줄러 |
| 설치 | 서버에 C extension 빌드/설치 필요 | SQL 파일 하나 실행 |
| 관리형 DB | 사용 불가 (RDS, Aurora 등) | 사용 가능 |
AWS RDS, Azure Database for PostgreSQL, GCP Cloud SQL 같은 관리형 서비스에서는 커스텀 C 확장 프로그램의 설치를 제한한다. PgQ의 핵심 로직은 대부분 PL/pgSQL로 작성되어 있었지만, 일부 트리거 함수와 성능 최적화 부분이 C로 작성되어 있었고, 이벤트 처리를 위해 pgqd라는 별도 데몬도 필요했다.
PgQue는 이러한 C 함수들을 PL/pgSQL로 대체하고, pgqd 데몬 없이도 동작하도록 재설계된 것이다. 약간의 성능 오버헤드가 있을 수 있지만, 소~중규모 작업 큐에서는 충분한 성능을 보여준다.
PgQue의 동작 방식
PgQue의 전체 흐름을 그림으로 나타내면 다음과 같다.
[Producer] ── send(queue, type, payload) ──> [이벤트 테이블에 INSERT]
│
[Ticker] (1초마다 실행)
│
배치 경계 생성 (snapshot 기반)
│
[Consumer] <── receive(queue, consumer, limit) ── 배치 단위로 이벤트 조회
│
├── 성공 → ack(batch_id) → 커서 전진
└── 실패 → nack(batch_id, msg) → retry_queue로 이동, 일정 시간 후 재배달
앞서 설명한 PgQ의 테이블 회전 설계를 그대로 계승하고 있기 때문에, PgQue는 이벤트 테이블에 DELETE가 발생하지 않아 bloat 없이 동작한다.
핵심 개념 (Kafka와 비교)
Kafka에 익숙한 독자를 위해 개념을 매핑해보면 다음과 같다.
| PgQue | Kafka | 설명 |
|---|---|---|
| queue name | topic | 메시지가 쌓이는 채널 |
| consumer name | consumer group id | 독립적 소비 커서 단위 |
| ticker | - | 배치를 생성하는 주기적 작업 (1초) |
| batch | poll 결과 | ticker가 만든 시간 구간 단위 메시지 묶음 |
| ack | commit offset | 배치 처리 완료, 커서 전진 |
| nack | - | 개별 메시지 재시도 등록 (retry_queue로 이동) |
차이점도 있다. Kafka는 같은 consumer group 내에서 파티션 단위 병렬 소비가 가능하지만, PgQue는 하나의 consumer name에 대해 단일 커서로 순차 처리한다. 또한 PgQue의 batch는 offset이 아닌 트랜잭션 스냅샷 기반이며, 배달 지연은 ticker 간격에 의존하여 보통 1~2초 정도 된다.
주요 SQL API
-- 이벤트 발행
SELECT pgque.send('sentiment_analysis', 'analyze_sentiment', '{"mappingId":42}');
-- 이벤트 수신 (batch 단위)
SELECT * FROM pgque.receive('sentiment_analysis', 'sentiment_worker', 100);
-- 배치 완료 (커서 전진)
SELECT pgque.ack(batch_id);
-- 개별 메시지 재시도 (60초 후 재배달)
SELECT pgque.nack(batch_id, msg, '60 seconds'::interval, 'reason');
-- 수동 tick (디버깅용)
SELECT pgque.ticker();
단점: 주기적인 ticker 호출 설정 필요.
PgQue는 ticker를 주기적으로 호출해야 동작한다. ticker가 실행되어야 이벤트가 배치로 묶이고, 소비자가 조회할 수 있게 된다. PgQue에서는 이를 위해 pg_cron 사용을 권장한다.
pg_cron이란
pg_cron은 PostgreSQL 내부에서 주기적 작업을 실행할 수 있게 해주는 확장이다. cron 문법으로 스케줄을 등록하면 데이터베이스 내에서 SQL을 자동 실행해준다.
-- pg_cron으로 ticker 등록하는 경우
SELECT cron.schedule('pgque-ticker', '1 second', 'SELECT pgque.ticker()');
SELECT cron.schedule('pgque-maint', '30 seconds', 'SELECT pgque.maint()');
pg_cron 없이 해결하기
pg_cron는 별도로 설치해야 하는 PostgreSQL 확장이다. PgQue를 선택한 이유가 “추가 설치 없이 SQL 파일 하나로 쓸 수 있다”는 점이었는데, ticker를 위해 또 다른 확장을 설치하는 것은 취지에 맞지 않았다.
그래서 나의 경우에는 이미 Spring Boot를 사용하고 있으니 @Scheduled 어노테이션으로 충분히 대체할 수 있었다. 스케줄링 로직이 애플리케이션 레이어에 있으면 모니터링과 디버깅도 더 쉽다.
@Component
public class PgqueTickerScheduler {
private final PgqueService pgqueService;
@Scheduled(fixedRate = 1000) // 1초마다
public void tick() {
pgqueService.ticker();
}
@Scheduled(fixedRate = 30000) // 30초마다
public void maintain() {
pgqueService.maint();
}
}
ticker()는 1초마다 호출하여 이벤트를 배치로 묶고, maint()는 30초마다 호출하여 파티션 회전 등 유지보수 작업을 수행한다.
성능으로는 pg_cron 을 쓰는것이 더 좋았겠지만 그렇게 빠르게 처리될 필요까지는 없는 작업이기때문에 충분할 것으로 생각되었다.
Java/Spring 구현
주요 클래스
| 클래스 | 역할 |
|---|---|
PgqueService | JdbcTemplate으로 PgQue SQL 함수 호출 (send/receive/ack/nack) |
PgqueTickerScheduler | 1초마다 pgque.ticker(), 30초마다 pgque.maint() 호출 |
QueueProducer | 큐에 이벤트 발행 |
QueueConsumer | 2초마다 polling, 이벤트 처리 후 ack/nack |
PgqueEventRecord | receive 결과 DTO |
트랜잭션 보장
pgqueService.send()는 JdbcTemplate을 통해 호출되므로 Spring의 @Transactional에 자동 참여한다. 즉, 데이터 저장과 이벤트 발행이 같은 트랜잭션 안에서 원자적으로 동작한다.
@Transactional
public void createSomething(...) {
// 데이터 저장
repository.save(entity);
// 같은 트랜잭션 안에서 큐에 발행 (저장 실패 시 이벤트도 롤백)
queueProducer.enqueue(entity.getId());
}
이것이 외부 큐 시스템(Kafka, Redis)과의 가장 큰 차이점이다. 외부 시스템을 사용하면 “DB에는 저장됐는데 큐에는 발행 안 됨” 같은 불일치 상황이 발생할 수 있지만, PgQue는 같은 PostgreSQL 안에서 동작하므로 트랜잭션에 자연스럽게 참여하고, 이런 불일치 문제가 원천적으로 없다.
에러 처리
에러 처리 흐름은 다음과 같다.
- 메시지 처리 실패 →
nack()호출 → retry_queue에 등록 → 60초 후 재배달 - 최대 재시도 초과 (기본 5회) → dead_letter 테이블로 이동
- ack 실패 → 다음 ticker에서 같은 배치 재배달 (at-least-once 보장)
- Consumer 중단 → 미처리 배치는 다음 시작 시 재배달
모니터링은 SQL 쿼리로 직접 확인할 수 있다.
-- 큐 상태
SELECT * FROM pgque.get_queue_info();
-- 컨슈머 lag 확인
SELECT * FROM pgque.get_consumer_info('sentiment_analysis', 'sentiment_worker');
-- dead letter 확인 및 재처리
SELECT * FROM pgque.dlq_inspect('sentiment_analysis', 10);
SELECT pgque.dlq_replay(dl_id);
설정
app:
pgque:
consumer:
poll-interval-ms: 2000 # 폴링 간격
batch-size: 1 # 한번에 처리할 메시지 수
Kafka와의 비교
| 비교 | Kafka | PgQue |
|---|---|---|
| 인프라 | 별도 클러스터 필요 | 기존 PostgreSQL 사용 |
| 설치 | Broker + ZooKeeper/KRaft | SQL 파일 하나 |
| 운영 복잡도 | 높음 | 낮음 (DB 운영에 포함) |
| 처리량 | 수백만 events/sec | 소~중규모에 적합 |
| 지연 시간 | 밀리초 단위 | 1~2초 (ticker 간격에 의존) |
| 트랜잭션 보장 | 별도 구현 필요 | PostgreSQL 트랜잭션에 참여 |
| 순서 보장 | 파티션 단위 | 큐 단위 |
| 적합 규모 | 대규모 스트리밍 | 소~중규모 작업 큐 |
PgQue는 Kafka의 대체가 아니다. 대규모 실시간 스트리밍이 필요하다면 Kafka가 맞는 선택이다. 하지만 사이드 프로젝트처럼 추가 인프라 없이 안정적인 비동기 처리가 필요한 경우, PgQue는 매우 합리적인 선택지다.
마무리
PgQue를 도입하면서 가장 만족스러웠던 점은 기술 스택을 단순하게 유지할 수 있었다는 것이다. PostgreSQL 하나로 데이터 저장과 이벤트 큐를 모두 해결하니, 운영 부담이 거의 없다.
PgQue는 아직 v0.1 단계(Apache 2.0 라이센스)이지만, 그 아키텍처 자체는 Skype에서 10년 넘게 검증된 PgQ를 그대로 계승한 것이다. 구현체는 새롭지만 설계는 충분히 성숙하다고 볼 수 있다.
추가 인프라 도입 없이 PostgreSQL만으로 큐 시스템을 구축하고 싶다면, PgQue는 좋은 선택지가 될 것이다.