PostgreSQL LISTEN/NOTIFY는 최소한의 설정으로 실시간 대시보드와 알림을 구현할 수 있습니다. 어디에 적합한지, 한계는 무엇인지, 언제 메시지 브로커를 도입해야 하는지 알아보세요.

제품 UI에서 "실시간 업데이트"는 보통 사용자가 새로 고침하지 않아도 무언가가 발생한 직후 화면이 바뀌는 것을 의미합니다. 대시보드의 숫자가 올라가고, 받은편지함에 빨간 배지가 뜨고, 관리자가 새 주문을 보거나 "빌드 완료"나 "결제 실패" 같은 토스트가 팝업됩니다. 핵심은 타이밍입니다: 실제로는 1~2초 걸리더라도 즉각적으로 느껴져야 합니다.
많은 팀이 폴링으로 시작합니다: 브라우저가 몇 초마다 서버에 "새로운 게 있나요?"라고 묻습니다. 폴링은 작동하지만 두 가지 일반적인 단점이 있습니다.
첫째, 다음 폴링 시점까지 사용자는 변경을 보지 못해 지연감이 듭니다.
둘째, 아무것도 바뀌지 않아도 반복적으로 확인하므로 비용이 커집니다. 수천 명의 사용자로 곱해지면 소음이 됩니다.
PostgreSQL의 LISTEN/NOTIFY는 더 단순한 경우를 위해 존재합니다: "무언가 바뀌면 알려줘." 계속 묻는 대신 앱이 기다렸다가 데이터베이스가 작은 신호를 보낼 때 반응할 수 있습니다.
UI에서 살짝 알려주는 정도로 충분할 때 좋은 선택입니다. 예:
대가(트레이드오프)는 단순성 대 보장입니다. LISTEN/NOTIFY는 Postgres에 이미 포함되어 있어 추가하기 쉽지만, 완전한 메시징 시스템은 아닙니다. 알림은 힌트일 뿐이며 내구성이 없습니다. 리스너가 연결이 끊기면 신호를 놓칠 수 있습니다.
실무적인 사용법은: NOTIFY로 앱을 깨운 다음 앱이 테이블에서 진짜 상태를 읽게 하는 것입니다.
PostgreSQL LISTEN/NOTIFY를 데이터베이스에 내장된 간단한 초인종으로 생각하세요. 앱이 초인종이 울릴 때까지 기다릴 수 있고, 시스템의 다른 부분이 무언가 바뀌면 울릴 수 있습니다.
알림은 채널 이름과 선택적 페이로드 두 부분으로 이루어집니다. 채널은 토픽 레이블과 같고(예: orders_changed), 페이로드는 붙일 수 있는 짧은 텍스트 메시지입니다(예: 주문 ID). PostgreSQL은 구조를 강제하지 않으므로 팀은 종종 작은 JSON 문자열을 보냅니다.
알림은 애플리케이션 코드에서 트리거할 수 있고(API 서버가 NOTIFY 실행), 데이터베이스 트리거에서 직접 발생시킬 수도 있습니다(INSERT/UPDATE/DELETE 후 트리거가 NOTIFY 실행).
수신 측에서는 앱 서버가 데이터베이스 연결을 열고 LISTEN channel_name을 실행합니다. 그 연결은 열려 있습니다. NOTIFY channel_name, 'payload'가 발생하면 PostgreSQL은 해당 채널을 듣고 있는 모든 연결에 메시지를 푸시합니다. 그 후 앱은 캐시를 갱신하거나 변경된 행을 조회하고, 브라우저에 WebSocket 이벤트를 푸시하는 등 반응합니다.
NOTIFY는 전달 서비스가 아니라 신호로 이해하는 것이 가장 좋습니다:
이렇게 사용하면 PostgreSQL LISTEN/NOTIFY로 추가 인프라 없이도 실시간 UI 업데이트를 구현할 수 있습니다.
LISTEN/NOTIFY는 UI가 전체 이벤트 스트림이 아니라 "살짝 알려줘"가 필요할 때 빛을 발합니다. "이 위젯을 새로 고침해"나 "새 항목이 있다"는 식이지, "모든 클릭을 순서대로 처리하라"는 용도는 아닙니다.
데이터베이스가 이미 진실의 원천이고 UI가 그와 동기화되길 원할 때 가장 잘 맞습니다. 흔한 패턴은: 행을 쓰고, ID를 포함한 작은 알림을 보낸 다음 UI(또는 API)가 최신 상태를 가져오게 하는 것입니다.
다음 조건 대부분에 해당하면 LISTEN/NOTIFY로 충분한 경우가 많습니다:
구체적 예: 내부 지원 대시보드가 "열린 티켓"과 "새 노트" 배지를 보여줍니다. 상담원이 노트를 추가하면 백엔드는 Postgres에 쓰고 ticket_changed로 티켓 ID를 포함해 NOTIFY합니다. 브라우저는 WebSocket으로 받아 해당 티켓만 재조회합니다. 추가 인프라 없이도 UI가 실시간처럼 느껴집니다.
처음에는 훌륭해 보일 수 있지만, LISTEN/NOTIFY에는 강한 한계가 있습니다. 알림을 메시지 시스템처럼 취급할 때 문제가 발생합니다.
가장 큰 차이는 내구성입니다. NOTIFY는 큐가 아닙니다. 아무도 듣고 있지 않으면 메시지는 사라집니다. 리스너가 연결되어 있더라도 크래시, 배포, 네트워크 문제, 데이터베이스 재시작으로 연결이 끊기면 그 신호를 자동으로 보완해주지 않습니다.
연결 끊김은 사용자 대상 기능에서 특히 아픕니다. 대시보드가 새 주문을 보여줘야 하는데 브라우저 탭이 잠들었다가 WebSocket이 재연결되면 몇 개 이벤트를 놓쳐 UI가 "멈춘" 것처럼 보일 수 있습니다. 이걸 우회하려면 NOTIFY만으로는 부족하고, 상태를 재구성하기 위해 데이터베이스를 조회하는 방식으로 설계해야 합니다.
팬아웃(fan-out)도 흔한 문제입니다. 하나의 이벤트가 수백 혹은 수천의 리스너를 깨울 수 있습니다(많은 앱 서버, 많은 사용자). orders 같은 시끄러운 채널을 사용하면 한 사용자가 관심 있는 경우에도 모든 리스너가 깨어나 부담을 줍니다.
페이로드 크기와 빈도도 함정입니다. NOTIFY 페이로드는 작고, 빈번한 이벤트는 클라이언트가 처리하기 전에 쌓일 수 있습니다.
다음 신호를 주의하세요:
그럴 땐 NOTIFY는 "톡" 정도로만 유지하고 신뢰성은 테이블이나 전용 메시지 브로커로 옮기세요.
신뢰할 수 있는 패턴은 NOTIFY를 힌트로 취급하고 실제 진실은 데이터베이스 행으로 두는 것입니다. 알림은 언제 봐야 할지 알려주고, 데이터는 테이블에서 읽습니다.
변경은 트랜잭션 안에서 수행하고 데이터가 커밋된 후에만 알림을 보내세요. 너무 일찍 알리면 클라이언트가 깨어났을 때 데이터를 찾지 못할 수 있습니다.
일반적인 설정은 INSERT/UPDATE 시 트리거가 실행되어 작은 메시지를 보내도록 하는 것입니다.
NOTIFY dashboard_updates, '{\\"type\\":\\"order_changed\\",\\"order_id\\":123}'::text;
채널 이름은 시스템을 바라보는 방식과 일치할 때 가장 잘 작동합니다. 예: dashboard_updates, user_notifications, 또는 테넌트별로 tenant_42_updates 등.
페이로드는 작게 유지하세요. 식별자와 타입을 넣고 전체 레코드는 넣지 마세요. 유용한 기본 형태:
type (무슨 일이 일어났는지)id (무엇이 바뀌었는지)tenant_id 또는 user_id이렇게 하면 대역폭을 줄이고 알림 로그에 민감한 데이터가 새는 것을 방지할 수 있습니다.
연결은 끊깁니다. 이를 대비하세요.
연결 시 필요한 모든 채널에 대해 LISTEN을 실행하세요. 연결이 끊기면 짧은 백오프 후 재연결하세요. 재연결 후에는 다시 LISTEN하고, 놓쳤을 수 있는 변경을 덮기 위해 최근 변경을 빠르게 다시 조회하세요.
대부분 실시간 UI 업데이트에서는 재조회가 가장 안전합니다: 클라이언트가 {type, id}를 받으면 서버에 최신 상태를 요청합니다.
증분 패치는 더 빠를 수 있지만 순서 뒤섞임이나 부분 실패 때문에 틀리기 쉽습니다. 중간 지점으로는 작은 조각(주문 하나, 티켓 카드 하나, 배지 수 하나)을 재조회하고 무거운 집계는 짧은 타이머로 처리하는 방법이 좋습니다.
관리자 대시보드가 하나에서 여러 사용자가 같은 숫자를 보는 구조로 바뀌면 좋은 습관이 SQL의 영리함보다 중요합니다. LISTEN/NOTIFY는 여전히 잘 작동할 수 있지만 데이터베이스에서 브라우저로 이벤트가 흐르는 방식을 형성해야 합니다.
일반적인 기준선은 각 앱 인스턴스가 하나의 장기 연결을 열어 LISTEN하고 연결된 클라이언트로 업데이트를 푸시하는 것입니다. 앱 서버 수가 적고 간헐적 재연결을 감수할 수 있다면 이 방식이 간단하고 종종 충분합니다.
앱 인스턴스가 많거나 서버리스 워커가 많다면 공유 리스너 서비스를 두는 것이 더 쉽습니다. 하나의 작은 프로세스가 한 번만 리슨하고 다른 스택에 팬아웃하면 배치, 메트릭, 백프레셔를 한 곳에서 관리할 수 있습니다.
브라우저로는 보통 WebSocket(양방향, 인터랙티브 UI에 적합)이나 Server-Sent Events(SSE)(단방향, 대시보드에 단순함)를 사용합니다. 어떤 방식이든 "모두 새로고침"을 보내지 마세요. order 123 changed 같은 컴팩트 신호를 보내 UI가 필요한 것만 다시 조회하게 하세요.
UI가 떨지 않도록 몇 가지 안전장치를 추가하세요:
채널 설계도 중요합니다. 하나의 전역 채널 대신 테넌트, 팀, 기능별로 분할해 클라이언트가 관련 이벤트만 받게 하세요. 예: notify:tenant_42:billing, notify:tenant_42:ops.
LISTEN/NOTIFY는 단순하게 느껴지기 때문에 팀이 빨리 출시하고 프로덕션에서 놀라는 일이 자주 발생합니다. 대부분의 문제는 이를 보장되는 메시지 큐처럼 취급하는 데서 옵니다.
앱이 재연결되면(배포, 네트워크 문제, DB 장애 조치) 그 사이에 발행된 NOTIFY는 사라집니다. 해결책은 알림을 신호로 보고 데이터베이스를 다시 확인하는 것입니다.
실용적 패턴: 실제 이벤트를 테이블에 저장(아이디와 created_at)한 뒤 재연결 시 마지막으로 본 id 이후의 항목을 조회하세요.
LISTEN/NOTIFY 페이로드는 큰 JSON 블롭용이 아닙니다. 큰 페이로드는 파싱 부담을 늘리고 제한에 걸릴 가능성을 높입니다.
페이로드는 order:123 같은 작은 힌트로 사용하세요. 앱이 데이터베이스에서 최신 상태를 읽게 하세요.
일부 팀은 페이로드 내용을 마치 진실의 원천처럼 설계합니다. 그러면 스키마 변경이나 클라이언트 버전 관리가 어려워집니다.
깨끗하게 분리하세요: 무언가 바뀌었다고 알리고, 정상 쿼리로 현재 데이터를 가져오게 하세요.
각 행 변경마다 NOTIFY를 발생시키는 트리거는 바쁜 테이블에서 시스템을 범람시킬 수 있습니다.
의미 있는 전환(예: 상태 변화)만 알리세요. 아주 시끄러운 업데이트가 있으면 배치(트랜잭션당 또는 시간 창당 하나의 notify)하거나 알림 경로에서 제외하세요.
데이터베이스가 알림을 보낼 수 있어도 UI가 버거워질 수 있습니다. 이벤트마다 재렌더링하는 대시보드는 멈출 수 있습니다.
클라이언트에서 업데이트를 디바운스하고, 버스트를 하나의 새로고침으로 합치며, "무조건 모든 델타 적용"보다 "무효화 후 재조회"를 선호하세요. 예: 알림 벨은 즉시 업데이트되지만 드롭다운 목록은 몇 초에 한 번만 새로고침하도록 하세요.
LISTEN/NOTIFY는 앱이 새로운 데이터를 가져오도록 하는 작은 "무언가 바뀜" 신호가 필요할 때 훌륭합니다. 전체 메시징 시스템은 아닙니다.
구축하기 전에 다음 질문에 답하세요:
실용적 규칙: NOTIFY를 페이로드 자체가 아니라 "행을 다시 읽어라"라는 톡으로 취급할 수 있으면 안전지대입니다.
예: 관리자 대시보드가 새 주문을 보여줄 때 알림을 놓쳐도 다음 폴링이나 페이지 새로고침으로 올바른 개수를 보여줄 수 있다면 적합합니다. 반면 "이 카드를 결제하라" 또는 "이 패키지를 발송하라" 같은 중요한 이벤트를 보내면 놓침이 실제 사고로 이어지므로 부적합합니다.
작은 영업 앱을 상상해보세요: 대시보드에 오늘 매출, 총 주문 수, "최근 주문" 목록이 있습니다. 동시에 각 영업 담당자는 자신이 담당한 주문이 결제되거나 발송되면 빠른 알림을 받아야 합니다.
간단한 접근법은 PostgreSQL을 진실의 원천으로 두고 LISTEN/NOTIFY는 "무언가 바뀌었으니 확인해라"라는 탭(톡)으로만 사용하는 것입니다.
주문이 생성되거나 상태가 바뀌면 백엔드는 하나의 요청 안에서 두 가지를 합니다: 행을 쓰거나 업데이트하고, 작은 페이로드(보통 주문 ID와 이벤트 타입)로 NOTIFY를 보냅니다. UI는 NOTIFY 페이로드에 의존해 전체 데이터를 얻지 않습니다.
실무 흐름은 다음과 같습니다:
orders_events로 {\\"type\\":\\"status_changed\\",\\"order_id\\":123} 같은 작은 페이로드로 NOTIFY합니다.이렇게 하면 NOTIFY는 가볍게 유지되고 비싼 쿼리를 줄일 수 있습니다.
트래픽이 늘면 균열이 드러납니다: 이벤트 스파이크가 단일 리스너를 압도하고, 재연결 시 알림이 놓치고, 보장된 전달과 재생이 필요해집니다. 보통 그때 더 신뢰할 수 있는 계층(아웃박스 테이블 + 워커, 필요시 브로커)을 추가하면서 Postgres를 진실의 원천으로 유지합니다.
LISTEN/NOTIFY는 "무언가 바뀌었음"을 빠르게 알려줄 때 훌륭합니다. 전체 메시징 시스템으로 설계된 것은 아닙니다. 이벤트를 진실의 원천으로 의존하기 시작하면 브로커를 추가할 때입니다.
다음 중 하나라도 나타나면 브로커가 문제를 줄여줍니다:
LISTEN/NOTIFY는 메시지를 이후를 위해 저장하지 않습니다. 푸시 신호일 뿐, 영구 로그가 아닙니다. 이는 대시보드 위젯을 새로 고치는 데는 완벽하지만 "청구 처리"나 "배송" 같은 작업에는 위험합니다.
브로커는 진짜 메시지 흐름 모델을 제공합니다: 큐(작업), 토픽(여러 곳에 브로드캐스트), 보존(메시지를 분~일 단위로 보관), 그리고 확인(소비자가 처리 완료를 확인). 이렇게 하면 "데이터베이스가 바뀌었다"는 것과 "그로 인해 발생해야 할 모든 일"을 분리할 수 있습니다.
가장 복잡한 도구를 골라야 할 필요는 없습니다. 사람들은 Redis(pub/sub 또는 streams), NATS, RabbitMQ, Kafka 등을 평가합니다. 어떤 도구가 맞을지는 단순 작업 큐가 필요한지, 많은 서비스로 팬아웃해야 하는지, 또는 이력을 재생해야 하는지에 따라 다릅니다.
큰 리팩 없이 이동할 수 있습니다. 실용적 패턴은 NOTIFY를 웨이크업 신호로 유지하면서 브로커를 전달의 근원으로 만드는 것입니다.
비즈니스 변경과 같은 트랜잭션에서 "이벤트 행"을 쓰고, 워커가 그 이벤트를 브로커로 발행하게 하세요. 전환 기간 동안 NOTIFY는 UI 계층에 "새 이벤트 확인"을 알려주고, 백그라운드 워커는 브로커에서 재시도와 감사가 가능한 방식으로 소비합니다.
이렇게 하면 대시보드는 스냅하게 유지되고, 중요한 워크플로우는 더 이상 베스트이포트 알림에 의존하지 않게 됩니다.
한 화면(대시보드 타일, 배지 수, 새 알림 토스트)을 골라 엔드투엔드로 연결하세요. LISTEN/NOTIFY로 빠르게 유용한 결과를 얻을 수 있지만 범위를 좁게 유지하고 실제 트래픽에서 어떻게 동작하는지 측정하세요.
가장 단순한 신뢰 패턴으로 시작하세요: 행을 쓰고, 커밋한 뒤, 작은 신호를 방출합니다. UI는 그 신호를 받고 최신 상태(또는 필요한 조각)를 가져옵니다. 이렇게 하면 페이로드를 작게 유지하고 메시지가 순서 뒤섞일 때 발생하는 미묘한 버그를 피할 수 있습니다.
초기에는 화려한 도구가 필요 없습니다. 하지만 시스템이 시끄러워질 때 답을 얻을 수 있도록 기본 관찰 가능성은 추가하세요:
약속을 단순하고 문서화해두세요. 채널 이름, 이벤트 이름, 페이로드 형태(심지어 ID만이라도)를 레포에 짧게 적어두면 규약이 흐트러지지 않습니다.
빠르게 구축하고 스택을 단순하게 유지하려면 Koder.ai (koder.ai) 같은 플랫폼이 React UI, Go 백엔드, PostgreSQL로 첫 버전을 빠르게 내고 요구사항이 분명해지면 확장하는 데 도움이 될 수 있습니다.
LISTEN/NOTIFY은 배지 수나 대시보드 타일을 갱신하는 것처럼 어떤 항목이 변경되었는지 빠르게 알려야 할 때 사용하세요. 알림을 데이터 자체로 보지 말고, 테이블에서 실제 데이터를 다시 가져오라는 신호로 취급하세요.
폴링은 정해진 간격으로 서버에 "새로운 것이 있나"를 묻기 때문에 업데이트가 늦게 보이고 아무것도 바뀌지 않아도 요청이 계속 발생합니다. LISTEN/NOTIFY는 변경이 일어났을 때 작은 신호를 푸시하므로 보통 더 빠르게 느껴지고 불필요한 요청을 줄입니다.
아니요. NOTIFY는 최선의 노력을 하는 신호 전달입니다. 리스너가 NOTIFY 발생 시점에 연결되어 있지 않으면 그 신호를 놓칠 수 있습니다.
작게 유지하고 힌트로 사용하세요. 일반적인 기본 형태는 type과 id를 담은 작은 JSON 문자열입니다. 그 뒤 앱이 Postgres에서 현재 상태를 조회하도록 하세요.
일반적인 패턴은 변경이 커밋된 후에 알림을 보내는 것입니다. 너무 일찍 알리면 클라이언트가 깨어나서 새 데이터를 찾지 못할 수 있습니다.
애플리케이션 코드가 보통 이해하고 테스트하기 더 쉽습니다. 여러 작성자가 같은 테이블을 바꿀 때 일관된 동작을 원하면 트리거에서 NOTIFY를 사용하는 것이 유용합니다.
재연결은 정상 동작으로 계획하세요. 재연결 시 필요한 채널에 대해 다시 LISTEN을 실행하고, 오프라인 동안 놓쳤을 수 있는 항목을 보완하기 위해 최근 상태를 빠르게 다시 가져오세요.
브라우저마다 Postgres에 직접 연결하지 마세요. 일반적인 구성은 백엔드 인스턴스당 하나의 장기 연결을 열어 LISTEN하고, 백엔드가 WebSocket이나 SSE로 브라우저에 이벤트를 전달하며 UI가 필요한 데이터를 다시 조회하게 하는 방식입니다.
관련 소비자만 깨어나도록 채널을 좁게 설계하고, 시끄러운 버스트는 배치하세요. 수백 밀리초 단위로 디바운스하고 중복 업데이트를 병합하면 UI와 백엔드의 과부하를 막을 수 있습니다.
지속성, 재시도, 컨슈머 그룹, 순서 보장, 감사/리플레이가 필요하다면 전용 브로커로 이전할 때입니다. 이벤트 하나가 누락되면 실제 사고가 나는 워크플로우(청구, 배송 등)라면 NOTIFY만으로는 부족합니다.