PostgreSQL LISTEN/NOTIFY — Redis 없이 DB 하나로 실시간 이벤트를 클라이언트까지 전달하는 법
얼마 전 팀에서 실시간 주문 상태 알림 기능을 붙여야 했는데, 처음엔 당연하다는 듯 Redis Pub/Sub 도입을 검토했습니다. 그런데 막상 따져보니 Redis 클러스터 운영, 장애 대응, 모니터링 셋업까지 생각하면 기능 하나를 위해 인프라가 통째로 하나 더 생기는 셈이었습니다. 그때 "잠깐, PostgreSQL에 이미 Pub/Sub이 있지 않나?" 싶어서 파고들기 시작했고, 결론적으로 Redis를 추가하지 않았습니다.
PostgreSQL LISTEN/NOTIFY는 최근 "You Don't Need Redis, Postgres Already Has Pub/Sub"이라는 흐름과 함께 재조명받고 있습니다. 별도 메시지 브로커 없이 DB 안에서 Pub/Sub 채널을 구현하고, 트랜잭션이 롤백되면 이벤트도 함께 취소된다는 점이 핵심 차별점입니다. 이 글에서는 LISTEN/NOTIFY가 어떻게 동작하는지, 어떤 패턴으로 SSE나 WebSocket에 붙이는지, 그리고 솔직히 어디까지가 한계인지를 다뤄보려 합니다.
핵심 개념
세 줄로 이해하는 동작 원리
LISTEN/NOTIFY는 단순합니다. 크게 세 단계로 나뉩니다.
LISTEN channel— 클라이언트(애플리케이션)가 특정 채널명을 구독합니다. 해당 DB 연결은 알림이 올 때까지 대기 상태로 유지됩니다.NOTIFY channel, 'payload'— 발신자(다른 트랜잭션, 트리거, 혹은 다른 애플리케이션)가 채널에 메시지를 보냅니다. 중요한 건 트랜잭션이 커밋될 때만 실제로 전달된다는 것입니다. 롤백되면 NOTIFY도 없던 일이 됩니다.pg_notify('channel', 'payload')— NOTIFY의 함수형 버전입니다. 트리거 내부에서 동적으로 채널명을 조립하거나 JSON 페이로드를 구성할 때 필수적으로 씁니다.
"트랜잭션 커밋 시에만 전달"이라는 부분이 처음엔 제약처럼 보였는데, 이게 오히려 강점입니다. Redis Pub/Sub은 DB 트랜잭션 결과와 이벤트 전달을 독립적으로 처리하기 때문에, DB 롤백이 일어나도 메시지가 이미 나가버리는 상황이 생길 수 있거든요. LISTEN/NOTIFY는 롤백 시 이벤트 자체가 발행되지 않으므로, 잘못된 상태에 대한 알림이 클라이언트에 도달하는 일이 없습니다.
트랜잭션과 이벤트 타이밍
커밋하면 알림이 전달되고, 롤백하면 알림이 취소되는 두 시나리오를 한눈에 볼 수 있습니다.
트리거로 자동화하기
애플리케이션 코드를 건드리지 않고, DB 트리거만으로 이벤트를 자동 발행할 수 있습니다. 이 패턴이 실무에서 가장 깔끔합니다.
-- INSERT/UPDATE에만 적용되므로 NEW가 항상 존재합니다.
-- DELETE 이벤트를 추가하려면 NEW 대신 OLD를 참조해야 합니다.
CREATE OR REPLACE FUNCTION notify_order_change() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify(
'orders_channel',
json_build_object(
'op', TG_OP,
'id', NEW.id,
'status', NEW.status
)::text
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER orders_after_change
AFTER INSERT OR UPDATE ON orders
FOR EACH ROW EXECUTE FUNCTION notify_order_change();페이로드에는 ID와 최소 메타데이터만 넣는 게 좋습니다. 하드 리밋이 8,000바이트이기 때문에, 전체 레코드를 페이로드에 담으려 하면 금방 한계에 부딪힙니다. 실무에서는 "이 레코드가 바뀌었다"는 신호와 ID만 보내고, 실제 데이터는 구독자가 직접 DB에서 다시 조회하는 방식을 쓰는 경우가 많습니다.
언어별 주요 라이브러리
| 언어 | 라이브러리 | 특징 |
|---|---|---|
| Node.js | pg-listen |
자동 재연결, 타입 안전 이벤트 핸들러 |
| Node.js | pg (node-postgres) |
기본 내장, client.on('notification', ...) |
| Python | asyncpg |
async/await 기반, 고성능, 네이티브 지원 |
| Python | asyncpg-listen |
asyncpg 래퍼, ListenPolicy로 최신 알림만 수신 |
| Python | psycopg2 + asyncio |
select()로 non-blocking 수신 |
| Go | pgx + pgxlisten |
pgx 위에 구축된 고수준 LISTEN 툴링 |
| Go | pgln |
재연결 중 알림 유실 방지, 상태 재구축 콜백 제공 |
실전 적용
아키텍처 큰 그림 먼저
코드를 보기 전에 전체 흐름을 파악해두면 훨씬 이해가 빠릅니다.
백엔드가 PostgreSQL과 전용 연결 하나를 별도로 유지하면서 LISTEN 대기 상태로 있고, 이벤트를 받으면 연결된 클라이언트에 SSE나 WebSocket으로 내려보내는 구조입니다. 연결 풀과 반드시 분리해야 한다는 점이 이 아키텍처의 핵심입니다. 풀에서 빌려온 연결에 LISTEN을 걸어두면, 연결이 반납됐다 재사용될 때 구독 상태가 엉킵니다.
Python + FastAPI + SSE 예시
가장 많이 쓰이는 조합입니다. asyncpg로 LISTEN 대기를 하고, FastAPI의 StreamingResponse로 SSE를 내려보냅니다.
import asyncio
import asyncpg
import json
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
app = FastAPI()
DATABASE_URL = "postgresql://user:password@localhost/mydb"
async def get_listen_connection():
return await asyncpg.connect(DATABASE_URL)
async def event_generator(order_id: int):
conn = await get_listen_connection()
queue: asyncio.Queue = asyncio.Queue()
# 외부 스코프의 conn과 구분하기 위해 _conn으로 명명
async def handle_notification(_conn, pid, channel, payload):
data = json.loads(payload)
if data.get("id") == order_id:
await queue.put(payload)
await conn.add_listener("orders_channel", handle_notification)
try:
while True:
try:
payload = await asyncio.wait_for(queue.get(), timeout=30.0)
yield f"data: {payload}\n\n"
except asyncio.TimeoutError:
# SSE keep-alive: 연결 유지를 위한 ping
yield ": ping\n\n"
finally:
await conn.remove_listener("orders_channel", handle_notification)
await conn.close()
@app.get("/orders/{order_id}/events")
async def order_events(order_id: int):
return StreamingResponse(
event_generator(order_id),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
# Nginx 전용 헤더. Caddy, HAProxy, AWS ALB 등 다른 프록시는
# 각자의 SSE 버퍼링 비활성화 설정을 별도로 확인해야 합니다.
"X-Accel-Buffering": "no",
}
)try/except TimeoutError가 while True 안에 있어야 한다는 점을 반드시 확인하세요. 바깥에 있으면 30초마다 ping 하나를 보내고 제너레이터가 종료되어 SSE 스트림이 주기적으로 끊기는 버그가 생깁니다.
Node.js + pg-listen 예시
자동 재연결이 필요하다면 pg-listen이 편합니다.
import createSubscriber from "pg-listen";
import { Pool } from "pg";
const databaseUrl = process.env.DATABASE_URL;
// LISTEN 전용 연결 - 일반 풀과 분리
const subscriber = createSubscriber({ connectionString: databaseUrl });
// WebSocket 클라이언트를 Set으로 추적
const wsClients = new Set();
subscriber.notifications.on("orders_channel", (payload) => {
console.log("주문 변경 감지:", payload);
for (const client of wsClients) {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(payload));
}
}
});
subscriber.events.on("error", (error) => {
console.error("LISTEN 연결 오류:", error);
// pg-listen이 자동으로 재연결 시도
});
await subscriber.connect();
await subscriber.listenTo("orders_channel");
// 일반 쿼리용 풀은 별도로 유지
const pool = new Pool({ connectionString: databaseUrl });멀티테넌트 패턴
SaaS에서 테넌트별로 이벤트를 격리하고 싶을 때 채널명에 테넌트 ID를 붙이는 패턴입니다.
CREATE OR REPLACE FUNCTION notify_tenant_event() RETURNS trigger AS $$
BEGIN
PERFORM pg_notify(
'tenant_' || NEW.tenant_id::text,
json_build_object(
'event', TG_OP,
'table', TG_TABLE_NAME,
'id', NEW.id
)::text
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;channel_name = f"tenant_{tenant_id}"
await conn.add_listener(channel_name, handle_notification)단, 한 가지 긴장을 인식해야 합니다. PostgreSQL은 채널 수와 무관하게 NOTIFY가 발생하면 해당 인스턴스의 모든 LISTEN 구독자를 깨웁니다. 테넌트 수가 수천 개로 늘어나면 테넌트별 채널 패턴은 이 비효율을 그대로 증폭시킵니다. 테넌트 규모가 클 것으로 예상된다면 채널 격리보다는 단일 채널에 페이로드 필터링을 조합하거나, 후술할 확장성 한계를 먼저 확인한 뒤 결정하는 것을 권장합니다.
아웃박스 패턴으로 유실 방지하기
LISTEN/NOTIFY는 메시지가 영속화되지 않습니다. 구독자가 재배포 중이거나 연결이 끊긴 사이에 날아온 알림은 그냥 사라집니다. 이 비영속성이 문제가 되는 환경에서 현실적인 해결 방법이 아웃박스 패턴입니다.
CREATE TABLE outbox (
id BIGSERIAL PRIMARY KEY,
channel TEXT NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
processed_at TIMESTAMPTZ
);
-- NOTIFY는 "신호"로만 쓰고, 실제 데이터는 아웃박스에 저장
CREATE OR REPLACE FUNCTION notify_with_outbox() RETURNS trigger AS $$
DECLARE
payload JSONB;
BEGIN
payload := json_build_object('op', TG_OP, 'id', NEW.id);
INSERT INTO outbox (channel, payload) VALUES ('orders_channel', payload);
PERFORM pg_notify('orders_channel', '{"type":"new_event"}');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;신호를 받은 구독자는 아웃박스에서 미처리 이벤트를 꺼내 처리합니다.
async def process_outbox(conn):
# SKIP LOCKED로 다중 인스턴스 환경에서의 중복 처리 방지
rows = await conn.fetch("""
SELECT id, channel, payload
FROM outbox
WHERE processed_at IS NULL
ORDER BY id
FOR UPDATE SKIP LOCKED
""")
for row in rows:
await handle_event(row["channel"], row["payload"])
await conn.execute(
"UPDATE outbox SET processed_at = NOW() WHERE id = $1",
row["id"]
)
async def handle_notification(_conn, pid, channel, payload):
async with pool.acquire() as conn:
await process_outbox(conn)이렇게 하면 NOTIFY는 "새 이벤트가 있다"는 신호 역할만 하고, 구독자는 아웃박스 테이블에서 실제 이벤트를 조회해 처리합니다. 재연결 후에도 밀린 이벤트를 꺼낼 수 있어서 유실 걱정을 크게 줄일 수 있습니다.
장단점 분석
한눈에 보는 비교
| 항목 | PostgreSQL LISTEN/NOTIFY | Redis Pub/Sub | Kafka |
|---|---|---|---|
| 추가 인프라 | 없음 | Redis 서버 | Kafka + Zookeeper |
| 트랜잭션 일관성 | 롤백 시 이벤트 미발행 | 없음 | 없음 |
| 메시지 영속성 | 없음 | 없음 | 있음 |
| 처리량 | 초당 수백~수천 수준 | 매우 높음 | 매우 높음 |
| 수평 확장 | 불가 (Primary만) | 가능 | 가능 |
| 페이로드 한계 | 8,000바이트 | 512MB | 설정 가능 |
| 운영 난이도 | 낮음 | 중간 | 높음 |
처리량 수치는 워크로드와 하드웨어 환경에 따라 크게 달라지므로, 정확한 판단이 필요하다면 실제 환경에서 직접 측정하는 것을 권장합니다.
장점을 명확하게
- 롤백 시 이벤트 미발행: 실패한 작업에 대한 알림이 절대 발생하지 않습니다. Redis Pub/Sub은 DB 트랜잭션과 이벤트 전달이 독립적이기 때문에 구조적으로 줄 수 없는 보장입니다.
- 제로 추가 인프라: PostgreSQL 하나로 Pub/Sub까지 커버됩니다. 운영 복잡도와 비용이 체감상 상당히 줄어듭니다.
- 낮은 지연: 메모리 기반 처리라 디스크 I/O가 없습니다. 주기적 폴링 방식과 비교하면 응답 시간 차이가 큽니다.
- 구현 단순함: SQL 두 줄과 기존 DB 드라이버로 당장 시작할 수 있습니다.
한계도 명확하게
| 제약 | 구체적 내용 |
|---|---|
| 비영속성 | 구독자 오프라인 중 발행된 메시지는 영구 소실, 재전달 없음 |
| 8,000바이트 하드 리밋 | 큰 페이로드 불가, ID + 최소 메타데이터만 전송 권장 |
| 전용 연결 점유 | LISTEN은 연결 풀과 별도로 전용 연결을 장시간 유지해야 함 |
| 글로벌 락 | 고부하 NOTIFY 시 인스턴스 전체에 전역 락 발생, 고빈도 쓰기 환경에서 병목 |
| 수평 확장 불가 | Read Replica에 전파 안 됨, Primary 단일 노드에서만 동작 |
| 메시지 히스토리 없음 | 과거 알림 재생 불가, 감사 로그가 필요하면 별도 테이블 필요 |
언제 쓰면 안 되는가
Hacker News에서 "Postgres LISTEN/NOTIFY does not scale"이라는 글이 수백 개의 댓글을 달며 대규모로 토론된 바 있습니다. Recall.ai와 PgDog 팀의 분석에서 드러난 핵심 문제는 NOTIFY 큐 삽입 시 전역 락이 발생해 고부하 쓰기 환경에서 병목이 생긴다는 것입니다. 또한 채널 수와 무관하게 관심 없는 구독자까지 전부 깨우는 비효율도 지적됐습니다.
결국 적합성을 가르는 기준은 세 가지입니다.
실무에서 자주 보이는 실수
연결 풀에 LISTEN 연결을 섞는 경우: 풀에서 빌려온 연결에 LISTEN을 걸어두면, 연결이 반납됐다 재사용될 때 구독 상태가 엉킵니다. LISTEN 전용 연결은 반드시 풀 바깥에서 따로 관리해야 합니다.
페이로드에 전체 레코드를 넣는 경우: 8,000바이트 리밋을 무시하고 큰 JSON을 페이로드에 담으면 ERROR: payload string too long이 뜹니다. 에러가 바로 나기 때문에 금방 알아차리긴 하지만, 초반에 설계를 잘못 잡으면 나중에 고치기 귀찮습니다.
프록시 버퍼링을 빠뜨리는 경우: SSE를 내려보낼 때 리버스 프록시가 응답을 버퍼에 쌓아두면 실시간성이 완전히 망가집니다. Nginx라면 X-Accel-Buffering: no를 응답 헤더에 추가해야 하고, 다른 프록시(Caddy, HAProxy, AWS ALB 등)를 쓴다면 해당 프록시의 스트리밍 버퍼링 비활성화 설정을 별도로 확인해야 합니다.
마치며
PostgreSQL LISTEN/NOTIFY는 초당 수백 건 이하의 이벤트, 리스너 수백 개 이내, 일부 메시지 유실이 허용되는 상황에서 가장 적합한 선택입니다. Redis를 추가하지 않아도 되고, 트랜잭션 롤백 시 이벤트가 발행되지 않는다는 보장은 다른 Pub/Sub 솔루션이 구조적으로 줄 수 없는 차별점입니다.
반대로 초당 수천 건 이상의 이벤트, 메시지 내구성 보장, 수천 개의 동시 리스너가 필요한 상황이라면 Kafka나 NATS를 검토하는 게 맞습니다. LISTEN/NOTIFY를 억지로 끌어다 쓰다가 글로벌 락 문제를 만나면 그때 가서 마이그레이션하는 게 훨씬 힘듭니다.
참고 자료
- PostgreSQL 공식 문서 — NOTIFY
- PostgreSQL 공식 문서 — LISTEN
- PostgreSQL 공식 문서 — libpq 비동기 알림
- How to Use Listen/Notify for Real-Time Updates in PostgreSQL — OneUptime (2026.01)
- I Replaced Redis with PostgreSQL (And It's Faster) — DEV Community
- You Don't Need Redis, Postgres Already Has Pub/Sub — Prisma Blog
- Postgres LISTEN/NOTIFY does not scale — Recall.ai
- Postgres LISTEN/NOTIFY does not scale — Hacker News 토론 (2025.07)
- Scaling Postgres LISTEN/NOTIFY — PgDog
- PostgreSQL LISTEN/NOTIFY for Real-Time Multi-Tenant Events — Ugur Aslim
- Use Postgres LISTEN/NOTIFY + Server-Sent Events — Atomic Object
- Building Real-Time Log Streaming with PostgreSQL LISTEN/NOTIFY — DEV Community
- Real-Time Communication with PostgreSQL LISTEN/NOTIFY and FastAPI — Medium
- pg-listen — GitHub (Node.js)
- pgxlisten — GitHub (Go)
- asyncpg-listen — PyPI (Python)
- Neon Guides — Pub/Sub with LISTEN/NOTIFY
- LISTEN/NOTIFY: Automatic Client Notification — Cybertec PostgreSQL
- All the Ways to Do Change Data Capture in Postgres — Sequin Blog
- Building a Notification Engine with PostgreSQL Listen/Notify and Next.js — DoHost (2026.06)