PostgreSQL LISTEN/NOTIFY와 Bun WebSocket으로 외부 브로커 없이 실시간 브로드캐스트를 붙여본 기록
몇 달 전 팀에서 "주문 상태가 바뀌면 어드민 화면에 실시간으로 보여주자"는 요구가 들어왔다. 자연스럽게 Redis Pub/Sub을 꺼내려다 잠깐 멈췄다. 스택에 이미 PostgreSQL이 있는데, 굳이 브로커를 추가해야 할까? 찾아보니 PostgreSQL에 LISTEN/NOTIFY라는 내장 pub/sub 메커니즘이 있었다. 나는 PostgreSQL을 몇 년 쓰면서도 이 기능을 제대로 써본 적이 없었다.
이 글은 PostgreSQL의 LISTEN/NOTIFY를 Bun의 내장 WebSocket API와 연결해, Redis 같은 별도 브로커 없이 실시간 브로드캐스트를 붙였던 이야기다. 실제로 도입해보니 아주 얇은 코드로 잘 돌아가는 케이스가 있었고, 반대로 "이건 애초에 PostgreSQL로 해결할 문제가 아니었네" 싶은 지점도 분명하게 보였다. 그 경계를 함께 정리해두려고 한다.
PostgreSQL 안에 이미 pub/sub이 있다
LISTEN/NOTIFY의 핵심 동작
LISTEN/NOTIFY는 PostgreSQL에 내장된 비동기 pub/sub 메커니즘이다. 채널을 미리 생성할 필요 없이 임의 문자열로 즉시 사용할 수 있다.
-- 구독 (클라이언트 측 세션에서)
LISTEN order_updates;
-- 발행 (다른 세션 또는 트리거에서)
NOTIFY order_updates, '{"id": 42, "status": "shipped"}';
-- 함수 형태로도 동일 (트리거 내부에서 자주 씀)
SELECT pg_notify('order_updates', '{"id": 42, "status": "shipped"}');여기서 내가 가장 마음에 들었던 특성 하나. NOTIFY는 트랜잭션이 커밋된 직후에만 전달된다. 트랜잭션이 롤백되면 알림도 발행되지 않는다. 단순해 보이지만 실무에서 상당한 장점이다. "데이터는 저장됐는데 이벤트는 안 간" 불일치가 구조적으로 생기지 않는다.
페이로드 한도는 8,000 바이트로 제한된다. 대용량 JSON을 직접 담기보다는 레코드 ID만 보내고 수신 측에서 실제 데이터를 SELECT로 재조회하는 "thin notification" 패턴이 안전하다.
전체 아키텍처
수많은 WebSocket 클라이언트 각각이 PostgreSQL에 직접 LISTEN 연결을 맺는 건 좋은 방법이 아니다. 대신 Bun 프로세스 하나가 PostgreSQL에 단일 전용 연결로 LISTEN하고, 같은 프로세스 안에서 WebSocket 클라이언트에게 팬아웃하는 구조를 쓴다.
한 가지 주의할 포인트. Bun 프로세스가 재시작하거나 네트워크가 끊어지면 LISTEN 등록이 초기화된다. 재연결 시 LISTEN을 다시 등록하는 로직이 필수다. 이걸 빠뜨리면 연결은 살아있어도 이벤트가 조용히 사라지기 시작한다. 나도 처음에 이걸 놓쳐서 "왜 이벤트가 안 오지?"로 꽤 오래 삽질했다.
주문 상태 변경을 실시간으로 흘려보내기
PostgreSQL 트리거로 이벤트 발행
주문 상태가 변경될 때 자동으로 이벤트를 발행하는 트리거다. WHEN 절로 실제 값이 달라졌을 때만 발동하게 제한해두면 불필요한 NOTIFY 남발을 막을 수 있다.
CREATE OR REPLACE FUNCTION notify_order_update()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify(
'order_updates',
json_build_object(
'id', NEW.id,
'status', NEW.status
)::text
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER order_status_trigger
AFTER UPDATE ON orders
FOR EACH ROW
WHEN (OLD.status IS DISTINCT FROM NEW.status)
EXECUTE FUNCTION notify_order_update();pg-listen으로 안정적인 LISTEN 연결
pg 드라이버의 단일 클라이언트로도 LISTEN이 가능하지만 재연결 로직을 직접 구현하면 코드가 금세 지저분해진다. pg-listen은 자동 재연결과 오류 이벤트를 내장해서 이 부분을 깔끔하게 해결해준다.
채널명을 하드코딩하지 않고 파라미터로 받는 팩토리 형태로 감싸면 다른 도메인에서도 재사용할 수 있다.
// listener.ts
import createSubscriber, { type Subscriber } from "pg-listen";
type NotificationHandler = (channel: string, payload: unknown) => void;
export function createPgListener(
channels: readonly string[],
onNotification: NotificationHandler
) {
const subscriber: Subscriber = createSubscriber({
connectionString: process.env.DATABASE_URL,
});
for (const channel of channels) {
subscriber.notifications.on(channel, (payload) => {
onNotification(channel, payload);
});
}
// pg-listen은 재연결을 계속 시도하지만, EventEmitter의 'error' 이벤트에
// 리스너가 없으면 Node/Bun 프로세스가 크래시한다. 반드시 하나는 붙여둔다.
subscriber.events.on("error", (err) => {
console.error("PostgreSQL 리스너 오류:", err);
});
subscriber.events.on("connected", () => {
console.log("PostgreSQL 리스너 연결됨");
});
return {
async start() {
await subscriber.connect();
for (const channel of channels) {
await subscriber.listenTo(channel);
}
},
async stop() {
await subscriber.close();
},
};
}pg-listen은 내부적으로 지수 백오프로 재연결을 반복 시도한다. 그럼에도 DB가 오래 죽어 있어 재시도를 다 소진하는 극단 상황에서는 애플리케이션이 어떤 상태로 갈지 프로세스 오케스트레이터(systemd, PM2, Kubernetes) 수준에서 정해두는 편이 안전하다. 나는 "error"가 일정 횟수 이상 반복되면 프로세스를 종료해 오케스트레이터가 새로 띄우게 하는 정책을 쓴다.
Bun WebSocket 서버와 연결
Bun은 ws.subscribe(topic) / server.publish(topic, data) / ws.unsubscribe(topic) 를 기본 제공한다. 별도 라이브러리 없이 토픽 기반 팬아웃이 가능한 게 Bun WebSocket의 강점이다.
// server.ts
import { createPgListener } from "./listener";
const CHANNELS = ["order_updates"] as const;
const server = Bun.serve({
port: Number(process.env.PORT) || 3000,
fetch(req, server) {
const url = new URL(req.url);
if (url.pathname === "/ws") {
const upgraded = server.upgrade(req);
if (!upgraded) {
return new Response("WebSocket 업그레이드 실패", { status: 400 });
}
return;
}
return new Response("Not Found", { status: 404 });
},
websocket: {
open(ws) {
ws.subscribe("order_updates");
ws.send(JSON.stringify({ type: "connected" }));
},
message(ws, message) {
// 필요하면 클라이언트 메시지 처리
},
close(ws) {
ws.unsubscribe("order_updates");
},
},
});
const pgListener = createPgListener(CHANNELS, (channel, payload) => {
server.publish(channel, JSON.stringify({ type: "update", data: payload }));
});
await pgListener.start();
console.log(`서버 시작: http://localhost:${server.port}`);이벤트가 실제로 어떻게 흐르는가
멀티테넌트로 확장할 때 반드시 필요한 인증
SaaS라면 테넌트별 채널로 격리하는 패턴이 자연스럽다. 그런데 여기에 함정이 있다. 쿼리 파라미터로 받은 tenantId를 그대로 신뢰해 채널 구독을 붙이면, 누구든 ?tenantId=other_company로 남의 테넌트 이벤트를 받아갈 수 있다. 인증 토큰에서 검증된 테넌트 ID만 채널 이름에 쓰는 것이 유일한 정답이다.
아래는 JWT에서 테넌트 ID를 추출하는 개념적 예시다. 실제 프로덕션에서는 라이브러리(예: jose)로 서명 검증까지 반드시 수행해야 한다.
// verifyToken은 서명 검증 후 { tenantId } 를 돌려주는 함수라고 가정
async function authenticate(req: Request): Promise<{ tenantId: string } | null> {
const token = new URL(req.url).searchParams.get("token");
if (!token) return null;
try {
return await verifyToken(token); // 서명 검증 실패 시 throw
} catch {
return null;
}
}
const server = Bun.serve<{ tenantId: string }>({
async fetch(req, server) {
const url = new URL(req.url);
if (url.pathname !== "/ws") {
return new Response("Not Found", { status: 404 });
}
const auth = await authenticate(req);
if (!auth) return new Response("Unauthorized", { status: 401 });
const upgraded = server.upgrade(req, { data: { tenantId: auth.tenantId } });
if (!upgraded) return new Response("WebSocket 업그레이드 실패", { status: 400 });
return;
},
websocket: {
open(ws) {
ws.subscribe(`tenant_${ws.data.tenantId}_events`);
},
close(ws) {
ws.unsubscribe(`tenant_${ws.data.tenantId}_events`);
},
message() {},
},
});DB 쪽에서는 검증된 테넌트 ID를 그대로 채널명 세그먼트로 사용한다.
PERFORM pg_notify(
'tenant_' || NEW.tenant_id || '_events',
json_build_object('type', 'order_update', 'id', NEW.id)::text
);알아둬야 할 한계
장단점 비교
| 항목 | PostgreSQL LISTEN/NOTIFY | Redis Pub/Sub |
|---|---|---|
| 별도 인프라 | 불필요 | Redis 서버 필요 |
| 트랜잭션 안전성 | 커밋 시에만 발행 | 별도 보장 없음 |
| 메시지 지속성 | 없음 (구독자 오프라인 시 소실) | 없음 |
| 페이로드 한도 | 8,000 바이트 | 실질적 제한 없음 |
| 연결 수 확장성 | 고연결 환경에서 오버헤드 (PG 19에서 완화 예정) | 상대적으로 강함 |
| 크로스 DB/클러스터 | 불가 | 가능 |
| 커넥션 풀러(트랜잭션 모드) | LISTEN 불가, 전용 연결 필요 | 해당 없음 |
| 다중 앱 인스턴스 팬아웃 | 각 인스턴스가 독립 구독 (아래 절 참고) | 자연스러움 |
언제 이 조합으로 충분하고, 언제 다른 도구가 필요한가
"메시지 보장 전달"이 첫 번째 분기인 이유는 명확하다. LISTEN/NOTIFY는 구독자가 오프라인이면 알림이 그냥 사라진다. 재연결해도 그 사이 누락된 이벤트를 복구할 방법이 없다. 이 특성을 허용할 수 없는 시스템이라면 Kafka나 pg_notify + 별도 이벤트 테이블(폴링 또는 outbox 패턴) 조합을 고려해야 한다.
수평 확장에서 반드시 마주치는 벽
이 글에서 가장 정직하게 짚고 넘어가야 하는 지점이다. Bun 인스턴스가 2대 이상 뜨는 순간 각 인스턴스는 자기만의 LISTEN 연결과 자기 프로세스 안에서만 유효한 server.publish() 스코프를 갖는다. PostgreSQL은 모든 인스턴스에 NOTIFY를 전달해주므로 이벤트 자체는 각 인스턴스에 도착한다. 즉, 여러 인스턴스에 흩어져 접속한 클라이언트에게도 결과적으로 브로드캐스트가 도달한다.
문제는 여기서 인스턴스 간에 파생 상태를 공유하려는 순간에 생긴다. 이벤트별 카운팅, presence(누가 접속 중인지) 정보, 인스턴스 간 릴레이가 필요한 커스텀 이벤트 같은 것은 각 프로세스 로컬 메모리에 갇힌다. 또 하나, NOTIFY가 인스턴스 수만큼 중복 처리되므로 "이 이벤트를 받은 서버 중 하나만 후속 작업(예: 이메일 발송)을 수행"하는 식의 워크 큐가 필요하면 그때부터는 브로커가 있어야 한다.
정리하면 이렇다.
- 순수한 브로드캐스트(같은 이벤트를 모든 구독자가 수신) → 인스턴스가 여러 대여도 LISTEN/NOTIFY로 커버된다.
- 중복 없이 정확히 한 번 처리해야 하는 워크로드 → LISTEN/NOTIFY로는 부족하다.
- 인스턴스를 넘나드는 상태 공유 → Redis 같은 공유 저장소가 필요하다.
실무에서 자주 빠지는 함정
PgBouncer 트랜잭션 모드에서 LISTEN 시도
PgBouncer의 트랜잭션 모드로는 LISTEN이 정상 동작하지 않는다. LISTEN용 연결은 반드시 풀을 우회한 전용 장기 연결을 사용해야 한다. pg-listen이 자체 연결을 관리하는 이유도 여기에 있다.
재연결 후 LISTEN 재등록 누락
연결이 끊긴 뒤 재연결됐을 때 LISTEN을 다시 실행하지 않으면 연결은 살아있어도 구독은 해제된 상태가 된다. pg-listen은 재연결 후 원래 등록했던 채널들을 다시 붙여주므로 이 문제를 신경 쓰지 않아도 된다.
페이로드에 전체 레코드 전송
8,000 바이트 한도는 생각보다 빨리 찬다. ID와 최소 식별자만 보내고 수신 측에서 SELECT로 재조회하는 패턴을 습관화하는 편이 안전하다.
PostgreSQL 19에서 달라지는 점(2026년 기준)
기존 NOTIFY는 발행 시 해당 채널을 구독하지 않는 백엔드 프로세스까지 깨우는 방식이었다. 연결 수가 많은 환경에서 이게 눈에 띄는 오버헤드였고, Simon Willison 같은 관찰자들이 지적해온 지점이기도 하다. 2026년 기준으로 PostgreSQL 19에서는 이 부분을 개선해 실제로 채널을 구독 중인 백엔드만 선택적으로 깨우도록 하는 변경이 진행 중이다.
한 가지 오해하지 말아야 할 점은, 이 개선이 NOTIFY의 원시 메시지 처리량을 올리는 게 아니라 고연결 환경의 CPU 오버헤드를 줄이는 성격이라는 것이다. 초당 수천 건 규모의 순수 처리량이 필요하다면 이 개선만으로 브로커를 대체할 수 있다고 판단하기는 이르다. 정확한 릴리즈 시점과 최종 변경 내역은 배포 시점의 릴리즈 노트로 확인하는 편이 안전하다.
다시 그 주문 화면 이야기로
처음 요구사항으로 돌아와 보면, 어드민 화면 몇 개에서 주문 상태 변경을 실시간으로 보여주는 게 목적이었다. 동시 접속자는 팀 규모에 비례하는 수준, 이벤트 빈도는 초당 수 건 남짓, 브로드캐스트만 필요했고 중복 처리 걱정도 없었다. 이 조건에서 트리거 한 개, pg-listen 한 파일, Bun 서버 한 파일로 어드민이 실시간으로 갱신되기 시작했다. Redis 컨테이너를 추가하지 않았고, 인프라 다이어그램에 새 박스가 그려지지 않았고, 장애 시 확인해야 할 컴포넌트가 하나 늘지 않았다. 그게 이 조합에서 가장 크게 얻은 이득이었다.
반대로 알림 재전송 요구가 붙었을 때는 outbox 테이블과 폴링 워커를 앞에 두고 LISTEN은 폴링 즉시성 보조로만 쓰는 구조로 바꿨다. 결국 도구는 상황이 정해준다. 스택에 이미 있는 것을 먼저 활용해보고, 부족한 부분이 명확해질 때 그 부분만 브로커로 채우는 순서는 대체로 배신하지 않았다.