LISTENNOTIFY는 PostgreSQL만으로 비동기 처리를 시작할 때 매우 매력적으로 보입니다. 업무 트랜잭션이 커밋되면 worker가 거의 즉시 깨어나고, 별도 broker를 운영하지 않아도 됩니다. 하지만 이 기능을 ‘메시지가 정확히 한 번 전달되는 큐’라고 해석하면 금방 위험해집니다. PostgreSQL 공식 문서가 권하는 기본 모양도 payload에 모든 내용을 싣는 방식이 아니라 테이블에서 바뀐 사실을 알리고 수신자가 다시 조회하는 방식입니다.

이 글의 결론은 단순합니다. 작업의 사실과 상태는 내구성 있는 테이블에, NOTIFY는 그 테이블을 지금 확인하라는 저지연 wake-up에 둡니다. 이 구조는 PostgreSQL SKIP LOCKED 작업 큐, Transactional Outbox + CDC, 커넥션 풀 크기와 포화, Event Schema Registry 호환성을 연결하는 작은 비동기 패턴입니다. 여기서 NOTIFY는 정확성 계층이 아니라 지연을 줄이는 계층입니다.

참고한 공식 문서:

이 글에서 얻는 것

  • NOTIFY가 커밋, 중복 접힘, broadcast, 수신 시점에서 실제로 보장하는 범위를 구분합니다.
  • notification을 전달 원장이 아닌 깨우기 힌트로 두는 DB 작업 구조를 설계합니다.
  • LISTEN 등록 시의 race, 재접속, PgBouncer transaction pooling, 긴 트랜잭션 문제를 피하는 방법을 익힙니다.
  • fallback scan·claim·queue usage·reconnect를 숫자로 운영하는 기준을 만듭니다.

핵심 개념/이슈

1) NOTIFY는 커밋과 함께 보이는 신호이지, 소비 이력이 아니다

NOTIFY channel, payload는 같은 데이터베이스에서 해당 channel을 LISTEN 중인 모든 세션에 비동기 신호를 보냅니다. 중요한 트랜잭션 규칙은 두 가지입니다.

  1. 발신 트랜잭션이 commit되어야 신호가 전달됩니다. rollback된 업무 변경이 worker를 깨우는 일은 없습니다.
  2. listener가 트랜잭션 안에 있으면, 클라이언트는 그 트랜잭션이 끝난 뒤에 신호를 받습니다. 실시간성이 필요한 listener는 긴 조회 트랜잭션을 열어 두면 안 됩니다.

여기에 세 가지 제한을 더해야 합니다.

성질설계에 미치는 영향
같은 트랜잭션의 동일 channel·동일 payload는 접힐 수 있음받은 notification 수로 생성된 작업 수를 세면 안 됩니다.
모든 listener가 같은 신호를 받음경쟁 worker 분배가 아니라 backlog scan을 깨우는 fan-out입니다.
기본 payload는 8,000 bytes보다 짧아야 함큰 JSON·파일·개인정보를 넣지 말고, 원장은 테이블에 둡니다.

따라서 NOTIFY billing_work, 'job-392'는 “392번 작업을 반드시 한 번만 처리하라”가 아닙니다. 안전한 해석은 **“billing 작업 상태가 바뀌었을 수 있으니, ready row를 다시 claim해 보라”**입니다. 같은 신호를 두 번 받거나 한 번 못 받아도, 다음 scan이 작업을 찾아야 합니다.

2) 내구성은 jobs/outbox 테이블이, 저지연은 NOTIFY가 담당한다

안전한 구조에서는 업무 쓰기와 작업 생성이 한 트랜잭션에 들어갑니다. 예를 들어 결제가 승인됐다는 사실과 영수증 발송 작업은 함께 commit하고, 같은 트랜잭션 끝에서 worker를 깨웁니다.

BEGIN;

UPDATE payments
SET status = 'approved', approved_at = now()
WHERE id = :payment_id
  AND status = 'pending';

INSERT INTO jobs (job_type, payload, status, run_at, created_at)
VALUES ('send_receipt', jsonb_build_object('payment_id', :payment_id), 'ready', now(), now());

-- payload는 업무 원문이 아니라 짧은 힌트다.
SELECT pg_notify('billing_work_available', 'ready');

COMMIT;

이때 job row가 진짜 원장입니다. worker가 죽거나 네트워크가 잠깐 끊겨도 jobs.status = 'ready'가 남고, 재시작 worker가 다시 집을 수 있습니다. 알림을 받지 못한 것은 지연 문제일 뿐 유실 문제가 아닙니다. 업무 변경과 작업 의도를 함께 기록해야 한다는 이유는 Transactional Outbox + CDC의 이중 쓰기 문제와 같습니다.

반대로 아래는 피합니다.

나쁜 구조: API → COMMIT 업무 변경 → NOTIFY에 전체 명령 JSON → listener가 수신 건수만큼 처리
결과: listener 재시작·중복 접힘·payload 제한·중복 실행에서 복구 근거가 사라짐

좋은 구조: API → COMMIT 업무 변경 + jobs/outbox row + NOTIFY 힌트
          worker → notification 또는 주기 scan → 원자적 claim → 실제 작업 → 결과 기록

3) 시작 순서가 중요하다: LISTEN commit → 초기 scan → 이후 notification

처음 listener를 붙일 때는 미세한 race가 있습니다. LISTEN이 아직 commit되지 않은 동안 다른 트랜잭션이 job을 commit하면 그 신호를 보지 못할 수 있습니다. PostgreSQL 문서의 권장 순서를 그대로 적용합니다.

  1. 전용 연결에서 LISTEN billing_work_available을 실행하고 commit한다.
  2. 새 짧은 트랜잭션에서 ready backlog를 모두 또는 제한 batch로 scan·claim한다.
  3. 그 다음부터 notification을 받아 debounce된 scan을 실행한다.

초기 scan은 중복처럼 보여도 안전합니다. 이미 본 작업을 다시 보더라도 atomic claim이 한 worker만 소유하게 하기 때문입니다. 반대로 notification만 기다리면 배포 직전·재접속 직전의 작업이 영원히 ready에 남을 수 있습니다.

worker의 claim은 PostgreSQL SKIP LOCKED 작업 큐처럼 짧은 트랜잭션으로 끝냅니다.

WITH picked AS (
  SELECT id
  FROM jobs
  WHERE status = 'ready'
    AND run_at <= now()
  ORDER BY run_at, id
  FOR UPDATE SKIP LOCKED
  LIMIT 10
)
UPDATE jobs j
SET status = 'running',
    locked_by = :worker_id,
    locked_until = now() + interval '2 minutes',
    attempts = attempts + 1
FROM picked
WHERE j.id = picked.id
RETURNING j.id, j.job_type, j.payload;

실제 이메일 발송·HTTP 호출·파일 변환은 commit 후에 실행합니다. 5분짜리 외부 API 호출을 listener 연결의 트랜잭션에 넣으면 신호 수신이 지연되고 DB connection과 lock까지 오래 점유합니다.

4) LISTEN은 세션 상태다: 일반 요청 풀과 섞지 않는다

LISTEN 등록은 애플리케이션 객체가 아니라 PostgreSQL server session에 붙습니다. 그래서 HTTP 요청처럼 연결을 빌렸다 반납하는 일반 커넥션 풀에 listener를 넣으면 안 됩니다. 특히 PgBouncer의 transaction pooling은 각 트랜잭션마다 다른 backend session을 쓸 수 있어, 한 요청에서 만든 LISTEN 상태를 다음 요청이 이어받는다고 보장하지 않습니다.

운영 선택지는 명확합니다.

선택적합한 경우주의점
worker 전용 direct connectionworker 수가 작고 운영 단순성이 우선reconnect, TLS, DB failover를 앱에서 처리
PgBouncer session poolingpooler 표준화를 유지해야 함listener 수만큼 장기 backend connection을 예산에 반영
일반 transaction poolingHTTP API·짧은 SQLLISTEN 용도로는 부적합

listener 수는 작게 시작합니다. 예를 들어 worker Pod가 20개라면 listener도 보통 Pod당 1개면 충분합니다. notify를 받을 때마다 즉시 SQL을 20번 날리기보다 100~300ms debounce 후 batch 10개를 claim합니다. DB의 현재 사용량, worker 처리 시간, 일반 API pool 여유를 함께 보고 전용 listener connection을 산정해야 하며, 이 판단은 커넥션 풀 크기와 포화의 saturation 기준을 따릅니다.

5) 알림 큐도 관측 대상이다

PostgreSQL에는 아직 모든 listener가 처리하지 않은 notification을 위한 공유 queue가 있습니다. 장시간 트랜잭션에 갇힌 listener 하나가 cleanup을 막을 수 있고, queue가 가득 차면 NOTIFY를 호출한 트랜잭션은 commit에서 실패합니다. 즉 notification을 ‘부가 기능’으로 다루더라도, 쓰기 경로의 실패 원인이 될 수 있습니다.

최소 지표는 다음 네 가지입니다.

-- 0.0~1.0 사이의 notification queue 사용률
SELECT pg_notification_queue_usage();

-- LISTEN 세션이 긴 transaction을 열고 있는지 확인
SELECT pid, usename, state, xact_start, query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
  AND now() - xact_start > interval '30 seconds';

처음 운영할 때의 보수적 기준은 다음처럼 둘 수 있습니다.

  • pg_notification_queue_usage() >= 0.10가 10분 지속되면 경고, >= 0.30이면 listener 긴 트랜잭션과 누적 원인을 즉시 조사
  • listener reconnect 이후 initial scan 완료까지 p95 60초 이하
  • notification 없이도 oldest ready job age가 60초 이하가 되도록 fallback scan을 15~60초 간격으로 설정
  • listener transaction은 p99 5초 이하, claim transaction은 p99 100ms 이하를 출발점으로 설정
  • notification wake-up 뒤 claim 결과가 빈 경우는 오류가 아니라 정상(다른 worker가 먼저 claim했거나 신호가 합쳐졌을 수 있음)

절대 수치는 서비스의 SLO와 작업 비용에 맞춰 조정해야 합니다. 중요한 것은 notification 수신률이 아니라 oldest_ready_job_age, reconnect_recovery_seconds, queue_usage, claim_success_rate를 같이 보는 것입니다.

실무 적용

1) worker loop를 ‘알림 + 복구 scan’으로 만든다

다음 의사코드는 어떤 언어에서도 유지할 수 있는 핵심 계약입니다.

connect_listener_with_backoff()
LISTEN billing_work_available; COMMIT
drain_ready_jobs(reason = "startup")

loop:
  wait up to 30 seconds for notification
  if notification arrived:
    debounce 200 milliseconds
    drain_ready_jobs(reason = "notify")
  else:
    drain_ready_jobs(reason = "fallback_scan")

  if connection closed or protocol error:
    record reconnect metric
    reconnect_listener_with_backoff()
    LISTEN; COMMIT
    drain_ready_jobs(reason = "reconnect")

여기서 drain_ready_jobs는 queue가 완전히 비거나, 한 loop에 허용한 DB/worker budget에 도달할 때까지만 반복합니다. job이 10만 건 쌓였다고 listener 한 개가 무한 loop를 돌면 일반 DB 요청까지 밀어낼 수 있습니다. 예를 들어 한 worker는 한 번에 10개 claim, 30초 동안 최대 200개만 claim하고 다시 부하를 확인하는 식의 상한을 둡니다.

2) channel과 payload도 작은 API 계약으로 관리한다

channel 이름은 우연한 문자열이 아니라 producer와 consumer가 공유하는 계약입니다. all_events 하나에 모든 도메인을 몰아넣거나 customer_12345처럼 테넌트마다 channel을 만들면 권한·운영·cardinality를 관리하기 어렵습니다.

권장 시작점은 아래 정도입니다.

항목출발 기준
channelbilling_work_available, search_index_available처럼 bounded domain 단위
payloadready, orders, 작은 numeric watermark 등 128 bytes 이하의 힌트
금지 데이터access token, 이메일, 전화번호, 원문 request/response, 대형 JSON
소유자channel별 producer·consumer·fallback scan owner 명시
변경payload 뜻을 바꿀 때 schema version 또는 새 channel로 병행 전환

payload가 작아야 한다는 이유는 8KB 제한만이 아닙니다. NOTIFY는 같은 DB의 listener에게 보이고 로그나 드라이버 진단에도 남을 수 있습니다. 민감 정보와 업무 원문은 jobs/outbox row에 접근 제어를 둔 채 보관하고, notification은 그 row를 찾을 수 있는 최소한의 변화 신호로 제한합니다.

3) 장애 drill은 신호가 아닌 원장을 검증한다

테스트에서 notification을 받았다는 사실만 확인하면 약합니다. 아래 순서로 작업이 결국 처리되는지를 봅니다.

  1. listener를 종료한 상태에서 job을 100개 commit한다.
  2. listener를 재시작하고 LISTEN commit → initial scan으로 100개가 claim되는지 확인한다.
  3. claim 후 worker를 강제 종료해 lease 만료와 재처리가 일어나는지 확인한다.
  4. listener 연결을 끊었다 붙이고, reconnect scan이 중복 없이 backlog를 회복하는지 확인한다.
  5. 긴 read transaction을 의도적으로 만들어 queue usage·alert·runbook이 작동하는지 확인한 뒤 종료한다.

성공 기준은 ‘100개의 알림을 받음’이 아니라 100개 작업의 최종 상태와 외부 효과가 정의한 멱등성 계약에 맞음입니다. 외부 이메일·결제·webhook이라면 job id와 별도의 idempotency key 또는 결과 ledger도 필요합니다.

트레이드오프/주의점

  1. broker를 없애는 것이 아니라 요구사항을 줄이는 선택입니다. PostgreSQL 기반 wake-up은 작은 범위에서 운영 부품을 줄여 주지만, partition ordering·consumer group·장기 replay가 필요하면 전문 broker가 더 단순해집니다.
  2. broadcast는 의도된 동작입니다. 50개 worker가 모두 깨어나는 비용이 문제라면 debounce·작은 claim batch로 완화할 수는 있지만, notification을 work sharding으로 바꾸면 안 됩니다.
  3. NOTIFY는 2PC와 함께 쓸 수 없습니다. NOTIFY 또는 LISTEN을 실행한 트랜잭션은 prepared transaction이 될 수 없으므로, 이미 2PC를 요구하는 경로라면 다른 설계를 택해야 합니다.
  4. payload에 job id가 있어도 scan은 남겨야 합니다. job id 직행 조회는 빠른 경로일 수 있으나, 정확성 경로는 언제나 ready backlog scan이어야 합니다.
  5. 장기 transaction은 실시간성을 무너뜨립니다. listener가 수 분짜리 분석 query를 겸하면 notification 처리와 queue cleanup이 모두 늦어집니다. 연결 역할을 분리합니다.

체크리스트 또는 연습

체크리스트

  • 업무 변경, jobs/outbox insert, pg_notify()가 같은 commit에 묶여 있다.
  • worker는 LISTEN commit 뒤에 initial scan을 수행하고 15~60초 fallback scan을 유지한다.
  • notification 수를 작업 수나 소비 성공 수로 사용하지 않는다.
  • LISTEN 연결은 일반 HTTP request pool과 분리되어 있고 transaction pooling에 의존하지 않는다.
  • claim은 원자적이며 실제 외부 작업은 DB transaction 밖에서 실행한다.
  • pg_notification_queue_usage, longest transaction, listener reconnect, oldest ready job age를 관측한다.
  • channel/payload에 민감정보가 없고 owner·스키마·변경 절차가 문서화되어 있다.

연습 과제

현재 서비스의 비동기 후속 작업 하나를 골라 보세요. 먼저 jobs 또는 outbox에 어떤 필드가 남아야 재처리가 가능한지 적고, 그 뒤 NOTIFY payload를 128 bytes 이내의 힌트로 축소해 보세요. listener를 1분 중단한 뒤 재시작했을 때도 oldest ready job age가 목표 SLO 안으로 회복되는지 측정하면, 신호와 원장의 경계가 실제로 지켜지는지 확인할 수 있습니다.