도입
규모가 작을 때는 Postgres를 작업 큐로 사용하는 것이 전혀 문제없으며, 오히려 올바른 선택이라고 생각합니다. 관리해야 할 요소가 줄어들고, 시스템 하나를 덜 관리해도 되며, 작업에 대해 ACID 보장을 받을 수 있기 때문입니다. 이보다 더 좋을 순 없겠죠?
문제는 이 '작은 규모'라는 기준에 한계가 있으며, 그 한계가 대부분의 사람들이 예상하는 것보다 낮다는 점입니다. 수천 개의 동시 작업자(Worker)가 SELECT ... FOR UPDATE SKIP LOCKED를 사용해 작업 테이블을 몰아치기 시작하면, 애플리케이션 계층에서는 명확히 보이지 않는 방식으로 시스템이 작동하기 시작합니다. CPU 사용량이 서서히 올라가고, 오토진공(Autovacuum)이 속도를 따라가지 못하는 경우가 발생합니다. 마지막으로 대기 이벤트 통계에서 여러 백엔드에 걸쳐 LWLock:MultiXactSLRU와 같은 불길한 항목들이 쌓이는 것을 보게 됩니다.
이러한 패턴은 많은 팀을 곤란하게 만들었으며, 대개 비슷한 양상으로 전개됩니다. 개발 및 스테이징 환경에서는 잘 작동하다가 동시성이 실제 상황이 되는 운영 환경에서는 갑자기 성능이 급락하는 식입니다. 왜 이런 일이 발생하는지, 그리고 대안은 무엇인지 자세히 살펴보겠습니다.
전형적인 패턴
Postgres를 작업 큐로 사용할 때 일반적인 접근 방식은 다음과 같습니다.
CREATE TABLE job_queue (
id bigserial PRIMARY KEY,
status text NOT NULL DEFAULT 'pending',
payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
locked_by text,
locked_at timestamptz
);
CREATE INDEX idx_job_queue_status ON job_queue (status) WHERE status = 'pending';
작업자는 다음과 같이 작업을 가져옵니다.
UPDATE job_queue
SET status = 'processing',
locked_by = 'worker-42',
locked_at = now()
WHERE id = (
SELECT id FROM job_queue
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED
)
RETURNING *;
그런 다음 완료 표시를 합니다.
UPDATE job_queue SET status = 'completed' WHERE id = $1;
어떤 사용자들은 행을 완전히 DELETE하기도 합니다. 어느 쪽이든 수명 주기는 '삽입(Insert) - 잠금 및 업데이트(Lock-and-Update) - 업데이트 또는 삭제(Update-or-Delete)'로 이어지며, 이 과정이 초당 수천 번 반복됩니다.
동시성이 낮을 때는 이 방식이 매우 매끄럽게 작동합니다. SKIP LOCKED 덕분에 작업자들은 동일한 행을 기다리느라 서로를 차단(Block)하지 않습니다. Postgres가 잠금, 가시성, 순서를 모두 처리해주니 매우 우아한 방식이죠.
그렇다면 어디서 문제가 발생하는 걸까요?
MultiXact SLRU 문제
여러 트랜잭션이 동일한 행에 잠금을 보유할 때, Postgres는 잠금 보유자들의 집합을 MultiXact ID로 저장합니다. 이는 pg_multixact/ 아래의 사이드 구조체를 가리키는 포인터입니다.
SELECT ... FOR UPDATE SKIP LOCKED를 사용할 때, 사용자들은 SKIP LOCKED가 경합을 피하기 위한 것이므로 MultiXact가 관여하지 않을 것이라고 생각할 수 있습니다. 하지만 실제로는 수많은 작업자가 행을 잠그기 위해 경쟁하는 과정에서, 한 트랜잭션이 '승리'하고 나머지가 건너뛰기 직전에 여러 트랜잭션이 동일한 행을 참조하는 짧은 찰나의 순간들이 발생합니다. 여기에 외래 키 체크로 인해 암시적으로 생성되는 FOR SHARE 또는 FOR KEY SHARE 잠금이 결합되면 MultiXact ID가 빠르게 누적되기 시작합니다.
MultiXact 데이터는 SLRU(Simple Least Recently Used) 버퍼에 상주하는데, 이는 공유 메모리 내의 작고 고정된 크기의 캐시입니다. 백엔드가 MultiXact 데이터를 읽거나 써야 할 때, 이 버퍼에 접근하기 위해 LWLock을 획득해야 합니다. 고성능 환경에서는 이것이 병목 구간이 됩니다.
wait_event_type | wait_event
-----------------+-------------------
LWLock | MultiXactMemberSLRU
LWLock | MultiXactOffsetSLRU
수십 또는 수백 개의 백엔드가 이러한 대기 이벤트에 쌓여 있는 것을 보게 될 것입니다. SLRU 캐시는 설계상 크기가 작으며(공유 메모리의 고정된 페이지 수), MultiXact 조회의 작업 집합이 캐시 크기를 초과하면 지속적인 축출(Eviction)과 디스크 재읽기가 발생합니다. 작업 행에 대한 모든 잠금 획득 및 해제는 잠재적으로 MultiXact SLRU 조회를 트리거하며, 수천 개의 동시 세션에서 이러한 조회는 LWLock에서 직렬화됩니다.
그 결과, 쿼리 자체가 비싸서가 아니라 잠금 인프라 자체가 과부하되어 CPU 사용량은 치솟고, 처리량은 급감하며, 지연 시간(Latency)은 급증하게 됩니다.
블로트(Bloat): 조용한 살인자
또 다른 문제는 테이블과 인덱스의 블로트(Bloat, 불필요한 공간 확장)입니다. 모든 작업 행은 여러 번의 업데이트(및 잠재적인 삭제)를 거치며, 이러한 각 작업은 힙(Heap)에 새로운 튜플 버전을 생성합니다. 오래된 버전은 VACUUM이 정리할 때까지 남아 있습니다.
바쁜 작업 큐 테이블에서는 다음과 같은 일이 벌어집니다.
- 데드 튜플(Dead tuple)이 오토진공이 청소할 수 있는 속도보다 더 빠르게 쌓입니다. 오토진공이 한 바퀴를 돌 때쯤이면 이미 수만 개의 새로운 데드 튜플이 생겨나 있습니다. 테이블은 계속해서 커집니다.
- 인덱스 블로트가 문제를 가중시킵니다. 테이블의 모든 인덱스에도 데드 엔트리가 쌓입니다. 특히
status = 'pending'인 부분 인덱스(Partial index)는 행이 끊임없이 해당 조건을 넘나들기 때문에 매우 심한 부하를 받습니다. - 순차 스캔(Sequential scan)이 느려집니다. 테이블이 비대해지면 힙 페이지가 듬성듬성 채워져 인덱스 스캔조차 더 많은 I/O를 유발합니다. 진공 작업은 테이블 끝에 있는 공간은 회수할 수 있지만, 페이지가 완전히 비어 있지 않는 한 중간에 있는 공간은 회수할 수 없습니다.
실제 '살아있는' 데이터는 몇 메가바이트에 불과함에도 작업 큐 테이블은 수십 기가바이트까지 커질 수 있습니다. 이는 스캔, 진공 작업, 심지어 pg_dump까지 모든 것을 느리게 만듭니다.
오토진공을 더 공격적으로 설정하거나(낮은 autovacuum_vacuum_scale_factor, 높은 autovacuum_vacuum_cost_limit), 테이블을 파티셔닝하여 오래된 파티션을 삭제함으로써 이를 완화할 수 있습니다. 하지만 어느 시점에서는 MVCC의 설계 목적과 작업 큐의 쓰기 패턴 사이의 근본적인 불일치와 싸우게 됩니다.
CPU 및 잠금 오버헤드
SLRU 경합과 블로트 외에도, 본질적으로 FIFO(선입선출) 디스패치 작업에 Postgres의 완전한 트랜잭션 메커니즘을 사용하는 데 따르는 순수 오버헤드가 존재합니다.
- 모든 잠금/해제는 전체 WAL 로그를 남기는 트랜잭션입니다. 작업을 가져오는 것도, 완료 표시를 하는 것도, 삭제하는 것도 모두 WAL을 씁니다. 초당 수천 개의 작업을 처리하는 시스템에서는 작업 큐 하나에서 발생하는 WAL 볼륨만으로도
wal_writer와 체크포인트 프로세스를 포화 상태로 만들 수 있습니다. SKIP LOCKED는 여전히 행을 건드려야 합니다. 이름은 행을 건너뛴다고 하지만, Postgres는 여전히 행을 찾아서 잠금 상태를 확인하고 다음으로 넘어가야 합니다. 동시성이 높으면 많은 작업자가 자신이 소유할 수 있는 행을 찾기 전까지 동일한 잠긴 행들을 스캔하며 CPU를 낭비하게 됩니다.- 스냅샷 관리 오버헤드 또한 문제가 됩니다. 각 트랜잭션은 일관된 스냅샷이 필요하며, 수천 개의 동시 트랜잭션이 발생하면 활성 트랜잭션을 추적하는 구조체인 ProcArray 자체가 경합 지점이 됩니다. MultiXact 대기와 함께
LWLock:ProcArrayLock대기가 나타날 수 있습니다. - 진공 작업 경합. 진공 작업이 데드 튜플을 청소하는 동안에도 잠금이 필요합니다. 지속적인 쓰기 압박을 받는 테이블에서 진공 작업은 작업자와 서로 간섭할 수 있습니다. 작업 큐 테이블에서 오토진공을 비활성화했을 때 단기적으로 처리량이 향상되는 사례도 보았습니다.
더 나은 대안들
그렇다면 대신 무엇을 사용해야 할까요? 요구 사항에 따라 다르지만, Postgres 테이블보다 고성능 작업 디스패치를 더 우아하게 처리하는 여러 옵션이 있습니다.
권고 잠금 (Advisory Locks - Postgres 유지)
Postgres 내에 머물면서 인프라 추가를 피하고 싶다면, 특정 큐 패턴에 대해 권고 잠금(Advisory Locks)을 고려해 볼 가치가 있습니다. 행을 잠그는 대신 추상적인 숫자 키에 잠금을 거는 방식입니다.
-- 작업자가 작업 ID에 대해 잠금 획득 시도
SELECT pg_try_advisory_lock(id) FROM job_queue
WHERE status = 'pending'
ORDER BY created_at
LIMIT 1;
권고 잠금은 가볍습니다. 힙을 건드리지 않고, MultiXact 엔트리를 생성하지 않으며, 데드 튜플을 만들지도 않습니다. 오직 공유 메모리에서만 작동합니다. 단점은 FOR UPDATE SKIP LOCKED의 원자성을 잃는다는 것입니다. 잠금은 획득했지만 작업 처리에 실패하는 경우를 직접 처리해야 하며, 잠금을 명시적으로 해제해야 합니다(또는 세션 종료 시 정리에 의존해야 합니다).
이 접근 방식은 큐의 깊이가 관리 가능한 수준이고 MVCC 오버헤드를 피하고 싶을 때 잘 작동합니다. 하지만 여전히 Postgres이므로 연결 제한, ProcArray 오버헤드 및 매우 높은 세션 수에서의 일반적인 리소스 경합 문제에서는 자유롭지 못합니다.
pgq (Skytools)
pgq는 정확히 이 문제를 해결하기 위해 제작되었습니다. Postgres 내부에서 작동하지만 행 수준의 잠금 및 MVCC 함정을 대부분 피하는 배치(Batching) 모델을 사용하는 큐 구현체입니다. 이벤트는 큐 테이블에 기록되지만, 소비자는 이를 배치 단위로 읽고 큐 유지 관리는 로테이션을 관리하는 티커(Ticker) 프로세스를 통해 수행됩니다.
주요 장점:
- 행 수준의 경합이 없음. 소비자가 개별 행을 잠그지 않습니다.
- 기본 내장된 배치 처리. 이벤트를 덩어리 단위로 소비하여 트랜잭션 오버헤드를 줄입니다.
- 효율적인 정리. 오래된 이벤트는 행 단위로 진공 처리되지 않고 로테이션을 통해 제거됩니다.
단점은 pgq가 이전만큼 활발하게 유지 관리되지 않으며 티커 데몬, 소비자 등록 등 운영 복잡성이 추가된다는 점입니다. 하지만 이미 Postgres 생태계를 깊게 사용 중인 팀에게는 검증된 옵션입니다.
Redis
많은 팀에게 Redis는 작업 큐를 위한 자연스러운 선택입니다. Redis 리스트(BRPOPLPUSH 또는 Streams API)를 사용하면 다음과 같은 이점을 얻을 수 있습니다.
- 밀리초 미만의 디스패치 지연 시간. 디스크 I/O, MVCC, 진공 작업이 없습니다.
- 원자적 팝(Pop) 작업. 작업자가 잠금 프로토콜 없이 작업을 가져옵니다.
- 간단한 확장성. Redis는 수천 개의 동시 소비자를 가볍게 처리합니다.
단점은 내구성입니다. Redis는 디스크에 영속화할 수 있지만 ACID를 완벽히 따르지는 않습니다. 팝 작업과 작업 완료 사이에 Redis가 다운되면 작업을 잃거나 중복될 위험이 있습니다(물론 Redis Streams의 소비자 그룹이 이를 상당히 완화해 줍니다). 대부분의 작업 큐 사용 사례에서는 '최소 한 번 전달(at-least-once delivery)' 방식이면 충분하며, Redis는 이를 잘 수행합니다.
Kafka
진정으로 높은 처리량이 필요한 분산 워크로드의 경우, Apache Kafka가 강력한 대안입니다. Kafka 파티션은 파티션당 순서 보장과 함께 병렬 소비, 내구성이 뛰어난 저장소 및 재생(Replay) 기능을 제공합니다. 다음과 같은 경우에 적합합니다.
- 초당 수천 개 이상의 이벤트를 처리해야 할 때
- 여러 소비자 그룹이 동일한 이벤트를 읽어야 할 때
- 이벤트 재생이나 감사 추적(Audit trails)이 필요할 때
- 이미 이벤트 기반 아키텍처를 사용 중일 때
운영 오버헤드는 ZooKeeper(또는 KRaft), 브로커, 토픽 관리, 소비자 그룹 조정 등으로 인해 적지 않습니다. 하지만 다른 목적으로 이미 Kafka를 운영 중인 팀에게 작업 큐 토픽을 추가하는 것은 거의 비용이 들지 않는 일입니다.
올바른 도구 선택하기
간단한 결정 가이드는 다음과 같습니다.
- 동시 작업자 100개 미만, 단순한 작업: **Postgres +
SKIP LOCKED**로 충분함 - 적당한 동시성, Postgres 유지 희망: 권고 잠금(Advisory locks) 또는 pgq
- 높은 처리량, 낮은 지연 시간: Redis (Lists 또는 Streams)
- 거대한 규모, 분산 환경, 이벤트 재생 필요: Kafka
처음에 Postgres로 시작한 많은 팀이 (합리적으로) 확장성 문제에 직면하면, 워크로드가 도구의 한계를 넘어섰음을 인정하기보다 Postgres를 수정하려고 애씁니다. 오토진공 워커를 늘리고, max_connections를 높이고, 커넥션 풀러를 추가합니다. 이러한 조치들이 약간의 도움은 되지만, 근본적인 문제는 해결하지 못합니다. Postgres의 MVCC와 잠금 장치는 고성능 동시 작업 큐 패턴을 위해 설계된 것이 아니기 때문입니다.
결론
Postgres는 훌륭하지만, 모든 작업에 최적인 도구일 수는 없습니다. 규모가 작을 때 Postgres를 작업 큐로 사용하는 것은 완벽하게 유효한 선택입니다. 하지만 수천 명의 동시 작업자를 운영하게 되면 MultiXact SLRU 경합, 힙 블로트, 진공 압박, 잠금 오버헤드 등이 결합되어 결국 목적에 맞게 설계된 솔루션으로 눈을 돌리게 될 것입니다.
다행인 점은 모든 것을 한꺼번에 갈아엎을 필요는 없다는 것입니다. 권고 잠금은 인프라 추가 없이 성능 여유를 확보해 줄 수 있습니다. Redis는 데이터 소유권을 Postgres가 유지하는 동안 디스패치를 담당할 수 있습니다. 이미 Kafka를 사용 중이라면 작업 토픽을 만드는 것은 매우 자연스러운 흐름입니다. 여러분의 상황에 맞는 최선의 선택지를 고르시기 바랍니다!