<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>NOTIFY on jyukki's Blog</title><link>https://jyukki.com/tags/notify/</link><description>Recent content in NOTIFY on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-KR</language><lastBuildDate>Tue, 25 Aug 2026 10:06:00 +0900</lastBuildDate><atom:link href="https://jyukki.com/tags/notify/index.xml" rel="self" type="application/rss+xml"/><item><title>백엔드 커리큘럼 심화: PostgreSQL LISTEN/NOTIFY, 내구성 큐가 아닌 깨우기 신호로 안전하게 쓰는 법</title><link>https://jyukki.com/learning/deep-dive/deep-dive-postgresql-listen-notify-wakeup-durable-work-playbook/</link><pubDate>Tue, 25 Aug 2026 10:06:00 +0900</pubDate><guid>https://jyukki.com/learning/deep-dive/deep-dive-postgresql-listen-notify-wakeup-durable-work-playbook/</guid><description>PostgreSQL LISTEN/NOTIFY를 메시지 브로커나 작업 큐로 오해하지 않고, 커밋과 함께 전달되는 저지연 wake-up 신호로 사용하는 설계·복구·커넥션 풀·관측 기준을 정리합니다.</description><content:encoded><![CDATA[<p><code>LISTEN</code>과 <code>NOTIFY</code>는 PostgreSQL만으로 비동기 처리를 시작할 때 매우 매력적으로 보입니다. 업무 트랜잭션이 커밋되면 worker가 거의 즉시 깨어나고, 별도 broker를 운영하지 않아도 됩니다. 하지만 이 기능을 ‘메시지가 정확히 한 번 전달되는 큐’라고 해석하면 금방 위험해집니다. PostgreSQL 공식 문서가 권하는 기본 모양도 payload에 모든 내용을 싣는 방식이 아니라 <strong>테이블에서 바뀐 사실을 알리고 수신자가 다시 조회하는 방식</strong>입니다.</p>
<p>이 글의 결론은 단순합니다. <strong>작업의 사실과 상태는 내구성 있는 테이블에, NOTIFY는 그 테이블을 지금 확인하라는 저지연 wake-up에 둡니다.</strong> 이 구조는 <a href="/learning/deep-dive/deep-dive-postgresql-skip-locked-job-queue-lease-playbook/">PostgreSQL SKIP LOCKED 작업 큐</a>, <a href="/learning/deep-dive/deep-dive-transactional-outbox-cdc/">Transactional Outbox + CDC</a>, <a href="/learning/deep-dive/deep-dive-connection-pool-sizing-saturation-playbook/">커넥션 풀 크기와 포화</a>, <a href="/learning/deep-dive/deep-dive-event-schema-registry-compatibility-playbook/">Event Schema Registry 호환성</a>을 연결하는 작은 비동기 패턴입니다. 여기서 <code>NOTIFY</code>는 정확성 계층이 아니라 지연을 줄이는 계층입니다.</p>
<p>참고한 공식 문서:</p>
<ul>
<li><a href="https://www.postgresql.org/docs/current/sql-notify.html">PostgreSQL NOTIFY</a></li>
<li><a href="https://www.postgresql.org/docs/current/sql-listen.html">PostgreSQL LISTEN</a></li>
</ul>
<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li><code>NOTIFY</code>가 커밋, 중복 접힘, broadcast, 수신 시점에서 실제로 보장하는 범위를 구분합니다.</li>
<li>notification을 전달 원장이 아닌 <strong>깨우기 힌트</strong>로 두는 DB 작업 구조를 설계합니다.</li>
<li><code>LISTEN</code> 등록 시의 race, 재접속, PgBouncer transaction pooling, 긴 트랜잭션 문제를 피하는 방법을 익힙니다.</li>
<li>fallback scan·claim·queue usage·reconnect를 숫자로 운영하는 기준을 만듭니다.</li>
</ul>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-notify는-커밋과-함께-보이는-신호이지-소비-이력이-아니다">1) NOTIFY는 커밋과 함께 보이는 신호이지, 소비 이력이 아니다</h3>
<p><code>NOTIFY channel, payload</code>는 같은 데이터베이스에서 해당 channel을 <code>LISTEN</code> 중인 <strong>모든 세션</strong>에 비동기 신호를 보냅니다. 중요한 트랜잭션 규칙은 두 가지입니다.</p>
<ol>
<li>발신 트랜잭션이 commit되어야 신호가 전달됩니다. rollback된 업무 변경이 worker를 깨우는 일은 없습니다.</li>
<li>listener가 트랜잭션 안에 있으면, 클라이언트는 그 트랜잭션이 끝난 뒤에 신호를 받습니다. 실시간성이 필요한 listener는 긴 조회 트랜잭션을 열어 두면 안 됩니다.</li>
</ol>
<p>여기에 세 가지 제한을 더해야 합니다.</p>
<table>
  <thead>
      <tr>
          <th>성질</th>
          <th>설계에 미치는 영향</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>같은 트랜잭션의 동일 channel·동일 payload는 접힐 수 있음</td>
          <td>받은 notification 수로 생성된 작업 수를 세면 안 됩니다.</td>
      </tr>
      <tr>
          <td>모든 listener가 같은 신호를 받음</td>
          <td>경쟁 worker 분배가 아니라 backlog scan을 깨우는 fan-out입니다.</td>
      </tr>
      <tr>
          <td>기본 payload는 8,000 bytes보다 짧아야 함</td>
          <td>큰 JSON·파일·개인정보를 넣지 말고, 원장은 테이블에 둡니다.</td>
      </tr>
  </tbody>
</table>
<p>따라서 <code>NOTIFY billing_work, 'job-392'</code>는 “392번 작업을 반드시 한 번만 처리하라”가 아닙니다. 안전한 해석은 **“billing 작업 상태가 바뀌었을 수 있으니, ready row를 다시 claim해 보라”**입니다. 같은 신호를 두 번 받거나 한 번 못 받아도, 다음 scan이 작업을 찾아야 합니다.</p>
<h3 id="2-내구성은-jobsoutbox-테이블이-저지연은-notify가-담당한다">2) 내구성은 jobs/outbox 테이블이, 저지연은 NOTIFY가 담당한다</h3>
<p>안전한 구조에서는 업무 쓰기와 작업 생성이 한 트랜잭션에 들어갑니다. 예를 들어 결제가 승인됐다는 사실과 영수증 발송 작업은 함께 commit하고, 같은 트랜잭션 끝에서 worker를 깨웁니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff79c6">BEGIN</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">UPDATE</span> payments
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">SET</span> status <span style="color:#ff79c6">=</span> <span style="color:#f1fa8c">&#39;approved&#39;</span>, approved_at <span style="color:#ff79c6">=</span> now()
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">WHERE</span> id <span style="color:#ff79c6">=</span> :payment_id
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">AND</span> status <span style="color:#ff79c6">=</span> <span style="color:#f1fa8c">&#39;pending&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">INSERT</span> <span style="color:#ff79c6">INTO</span> jobs (job_type, payload, status, run_at, created_at)
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">VALUES</span> (<span style="color:#f1fa8c">&#39;send_receipt&#39;</span>, jsonb_build_object(<span style="color:#f1fa8c">&#39;payment_id&#39;</span>, :payment_id), <span style="color:#f1fa8c">&#39;ready&#39;</span>, now(), now());
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">-- payload는 업무 원문이 아니라 짧은 힌트다.
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span><span style="color:#ff79c6">SELECT</span> pg_notify(<span style="color:#f1fa8c">&#39;billing_work_available&#39;</span>, <span style="color:#f1fa8c">&#39;ready&#39;</span>);
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">COMMIT</span>;
</span></span></code></pre></div><p>이때 job row가 진짜 원장입니다. worker가 죽거나 네트워크가 잠깐 끊겨도 <code>jobs.status = 'ready'</code>가 남고, 재시작 worker가 다시 집을 수 있습니다. 알림을 받지 못한 것은 지연 문제일 뿐 유실 문제가 아닙니다. 업무 변경과 작업 의도를 함께 기록해야 한다는 이유는 <a href="/learning/deep-dive/deep-dive-transactional-outbox-cdc/">Transactional Outbox + CDC</a>의 이중 쓰기 문제와 같습니다.</p>
<p>반대로 아래는 피합니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>나쁜 구조: API → COMMIT 업무 변경 → NOTIFY에 전체 명령 JSON → listener가 수신 건수만큼 처리
</span></span><span style="display:flex;"><span>결과: listener 재시작·중복 접힘·payload 제한·중복 실행에서 복구 근거가 사라짐
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>좋은 구조: API → COMMIT 업무 변경 + jobs/outbox row + NOTIFY 힌트
</span></span><span style="display:flex;"><span>          worker → notification 또는 주기 scan → 원자적 claim → 실제 작업 → 결과 기록
</span></span></code></pre></div><h3 id="3-시작-순서가-중요하다-listen-commit--초기-scan--이후-notification">3) 시작 순서가 중요하다: LISTEN commit → 초기 scan → 이후 notification</h3>
<p>처음 listener를 붙일 때는 미세한 race가 있습니다. <code>LISTEN</code>이 아직 commit되지 않은 동안 다른 트랜잭션이 job을 commit하면 그 신호를 보지 못할 수 있습니다. PostgreSQL 문서의 권장 순서를 그대로 적용합니다.</p>
<ol>
<li>전용 연결에서 <code>LISTEN billing_work_available</code>을 실행하고 <strong>commit</strong>한다.</li>
<li>새 짧은 트랜잭션에서 <code>ready</code> backlog를 모두 또는 제한 batch로 scan·claim한다.</li>
<li>그 다음부터 notification을 받아 debounce된 scan을 실행한다.</li>
</ol>
<p>초기 scan은 중복처럼 보여도 안전합니다. 이미 본 작업을 다시 보더라도 atomic claim이 한 worker만 소유하게 하기 때문입니다. 반대로 notification만 기다리면 배포 직전·재접속 직전의 작업이 영원히 <code>ready</code>에 남을 수 있습니다.</p>
<p>worker의 claim은 <a href="/learning/deep-dive/deep-dive-postgresql-skip-locked-job-queue-lease-playbook/">PostgreSQL SKIP LOCKED 작업 큐</a>처럼 짧은 트랜잭션으로 끝냅니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#ff79c6">WITH</span> picked <span style="color:#ff79c6">AS</span> (
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">SELECT</span> id
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">FROM</span> jobs
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">WHERE</span> status <span style="color:#ff79c6">=</span> <span style="color:#f1fa8c">&#39;ready&#39;</span>
</span></span><span style="display:flex;"><span>    <span style="color:#ff79c6">AND</span> run_at <span style="color:#ff79c6">&lt;=</span> now()
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">ORDER</span> <span style="color:#ff79c6">BY</span> run_at, id
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">FOR</span> <span style="color:#ff79c6">UPDATE</span> SKIP LOCKED
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">LIMIT</span> <span style="color:#bd93f9">10</span>
</span></span><span style="display:flex;"><span>)
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">UPDATE</span> jobs j
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">SET</span> status <span style="color:#ff79c6">=</span> <span style="color:#f1fa8c">&#39;running&#39;</span>,
</span></span><span style="display:flex;"><span>    locked_by <span style="color:#ff79c6">=</span> :worker_id,
</span></span><span style="display:flex;"><span>    locked_until <span style="color:#ff79c6">=</span> now() <span style="color:#ff79c6">+</span> <span style="color:#8be9fd;font-style:italic">interval</span> <span style="color:#f1fa8c">&#39;2 minutes&#39;</span>,
</span></span><span style="display:flex;"><span>    attempts <span style="color:#ff79c6">=</span> attempts <span style="color:#ff79c6">+</span> <span style="color:#bd93f9">1</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">FROM</span> picked
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">WHERE</span> j.id <span style="color:#ff79c6">=</span> picked.id
</span></span><span style="display:flex;"><span>RETURNING j.id, j.job_type, j.payload;
</span></span></code></pre></div><p>실제 이메일 발송·HTTP 호출·파일 변환은 commit 후에 실행합니다. 5분짜리 외부 API 호출을 listener 연결의 트랜잭션에 넣으면 신호 수신이 지연되고 DB connection과 lock까지 오래 점유합니다.</p>
<h3 id="4-listen은-세션-상태다-일반-요청-풀과-섞지-않는다">4) LISTEN은 세션 상태다: 일반 요청 풀과 섞지 않는다</h3>
<p><code>LISTEN</code> 등록은 애플리케이션 객체가 아니라 PostgreSQL <strong>server session</strong>에 붙습니다. 그래서 HTTP 요청처럼 연결을 빌렸다 반납하는 일반 커넥션 풀에 listener를 넣으면 안 됩니다. 특히 PgBouncer의 transaction pooling은 각 트랜잭션마다 다른 backend session을 쓸 수 있어, 한 요청에서 만든 LISTEN 상태를 다음 요청이 이어받는다고 보장하지 않습니다.</p>
<p>운영 선택지는 명확합니다.</p>
<table>
  <thead>
      <tr>
          <th>선택</th>
          <th>적합한 경우</th>
          <th>주의점</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>worker 전용 direct connection</td>
          <td>worker 수가 작고 운영 단순성이 우선</td>
          <td>reconnect, TLS, DB failover를 앱에서 처리</td>
      </tr>
      <tr>
          <td>PgBouncer session pooling</td>
          <td>pooler 표준화를 유지해야 함</td>
          <td>listener 수만큼 장기 backend connection을 예산에 반영</td>
      </tr>
      <tr>
          <td>일반 transaction pooling</td>
          <td>HTTP API·짧은 SQL</td>
          <td>LISTEN 용도로는 부적합</td>
      </tr>
  </tbody>
</table>
<p>listener 수는 작게 시작합니다. 예를 들어 worker Pod가 20개라면 listener도 보통 Pod당 1개면 충분합니다. notify를 받을 때마다 즉시 SQL을 20번 날리기보다 100~300ms debounce 후 batch 10개를 claim합니다. DB의 현재 사용량, worker 처리 시간, 일반 API pool 여유를 함께 보고 전용 listener connection을 산정해야 하며, 이 판단은 <a href="/learning/deep-dive/deep-dive-connection-pool-sizing-saturation-playbook/">커넥션 풀 크기와 포화</a>의 saturation 기준을 따릅니다.</p>
<h3 id="5-알림-큐도-관측-대상이다">5) 알림 큐도 관측 대상이다</h3>
<p>PostgreSQL에는 아직 모든 listener가 처리하지 않은 notification을 위한 공유 queue가 있습니다. 장시간 트랜잭션에 갇힌 listener 하나가 cleanup을 막을 수 있고, queue가 가득 차면 <code>NOTIFY</code>를 호출한 트랜잭션은 commit에서 실패합니다. 즉 notification을 ‘부가 기능’으로 다루더라도, 쓰기 경로의 실패 원인이 될 수 있습니다.</p>
<p>최소 지표는 다음 네 가지입니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-sql" data-lang="sql"><span style="display:flex;"><span><span style="color:#6272a4">-- 0.0~1.0 사이의 notification queue 사용률
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span><span style="color:#ff79c6">SELECT</span> pg_notification_queue_usage();
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#6272a4">-- LISTEN 세션이 긴 transaction을 열고 있는지 확인
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span><span style="color:#ff79c6">SELECT</span> pid, usename, <span style="color:#ff79c6">state</span>, xact_start, query
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">FROM</span> pg_stat_activity
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">WHERE</span> xact_start <span style="color:#ff79c6">IS</span> <span style="color:#ff79c6">NOT</span> <span style="color:#ff79c6">NULL</span>
</span></span><span style="display:flex;"><span>  <span style="color:#ff79c6">AND</span> now() <span style="color:#ff79c6">-</span> xact_start <span style="color:#ff79c6">&gt;</span> <span style="color:#8be9fd;font-style:italic">interval</span> <span style="color:#f1fa8c">&#39;30 seconds&#39;</span>;
</span></span></code></pre></div><p>처음 운영할 때의 보수적 기준은 다음처럼 둘 수 있습니다.</p>
<ul>
<li><code>pg_notification_queue_usage() &gt;= 0.10</code>가 10분 지속되면 경고, <code>&gt;= 0.30</code>이면 listener 긴 트랜잭션과 누적 원인을 즉시 조사</li>
<li>listener reconnect 이후 initial scan 완료까지 p95 <strong>60초 이하</strong></li>
<li>notification 없이도 oldest ready job age가 <strong>60초 이하</strong>가 되도록 fallback scan을 <strong>15~60초</strong> 간격으로 설정</li>
<li>listener transaction은 p99 <strong>5초 이하</strong>, claim transaction은 p99 <strong>100ms 이하</strong>를 출발점으로 설정</li>
<li>notification wake-up 뒤 claim 결과가 빈 경우는 오류가 아니라 정상(다른 worker가 먼저 claim했거나 신호가 합쳐졌을 수 있음)</li>
</ul>
<p>절대 수치는 서비스의 SLO와 작업 비용에 맞춰 조정해야 합니다. 중요한 것은 notification 수신률이 아니라 <code>oldest_ready_job_age</code>, <code>reconnect_recovery_seconds</code>, <code>queue_usage</code>, <code>claim_success_rate</code>를 같이 보는 것입니다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-worker-loop를-알림--복구-scan으로-만든다">1) worker loop를 ‘알림 + 복구 scan’으로 만든다</h3>
<p>다음 의사코드는 어떤 언어에서도 유지할 수 있는 핵심 계약입니다.</p>
<div class="highlight"><pre tabindex="0" style="color:#f8f8f2;background-color:#282a36;-moz-tab-size:4;-o-tab-size:4;tab-size:4;"><code class="language-text" data-lang="text"><span style="display:flex;"><span>connect_listener_with_backoff()
</span></span><span style="display:flex;"><span>LISTEN billing_work_available; COMMIT
</span></span><span style="display:flex;"><span>drain_ready_jobs(reason = &#34;startup&#34;)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>loop:
</span></span><span style="display:flex;"><span>  wait up to 30 seconds for notification
</span></span><span style="display:flex;"><span>  if notification arrived:
</span></span><span style="display:flex;"><span>    debounce 200 milliseconds
</span></span><span style="display:flex;"><span>    drain_ready_jobs(reason = &#34;notify&#34;)
</span></span><span style="display:flex;"><span>  else:
</span></span><span style="display:flex;"><span>    drain_ready_jobs(reason = &#34;fallback_scan&#34;)
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span>  if connection closed or protocol error:
</span></span><span style="display:flex;"><span>    record reconnect metric
</span></span><span style="display:flex;"><span>    reconnect_listener_with_backoff()
</span></span><span style="display:flex;"><span>    LISTEN; COMMIT
</span></span><span style="display:flex;"><span>    drain_ready_jobs(reason = &#34;reconnect&#34;)
</span></span></code></pre></div><p>여기서 <code>drain_ready_jobs</code>는 queue가 완전히 비거나, 한 loop에 허용한 DB/worker budget에 도달할 때까지만 반복합니다. job이 10만 건 쌓였다고 listener 한 개가 무한 loop를 돌면 일반 DB 요청까지 밀어낼 수 있습니다. 예를 들어 한 worker는 한 번에 10개 claim, 30초 동안 최대 200개만 claim하고 다시 부하를 확인하는 식의 상한을 둡니다.</p>
<h3 id="2-channel과-payload도-작은-api-계약으로-관리한다">2) channel과 payload도 작은 API 계약으로 관리한다</h3>
<p>channel 이름은 우연한 문자열이 아니라 producer와 consumer가 공유하는 계약입니다. <code>all_events</code> 하나에 모든 도메인을 몰아넣거나 <code>customer_12345</code>처럼 테넌트마다 channel을 만들면 권한·운영·cardinality를 관리하기 어렵습니다.</p>
<p>권장 시작점은 아래 정도입니다.</p>
<table>
  <thead>
      <tr>
          <th>항목</th>
          <th>출발 기준</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>channel</td>
          <td><code>billing_work_available</code>, <code>search_index_available</code>처럼 bounded domain 단위</td>
      </tr>
      <tr>
          <td>payload</td>
          <td><code>ready</code>, <code>orders</code>, 작은 numeric watermark 등 128 bytes 이하의 힌트</td>
      </tr>
      <tr>
          <td>금지 데이터</td>
          <td>access token, 이메일, 전화번호, 원문 request/response, 대형 JSON</td>
      </tr>
      <tr>
          <td>소유자</td>
          <td>channel별 producer·consumer·fallback scan owner 명시</td>
      </tr>
      <tr>
          <td>변경</td>
          <td>payload 뜻을 바꿀 때 schema version 또는 새 channel로 병행 전환</td>
      </tr>
  </tbody>
</table>
<p>payload가 작아야 한다는 이유는 8KB 제한만이 아닙니다. <code>NOTIFY</code>는 같은 DB의 listener에게 보이고 로그나 드라이버 진단에도 남을 수 있습니다. 민감 정보와 업무 원문은 jobs/outbox row에 접근 제어를 둔 채 보관하고, notification은 그 row를 찾을 수 있는 최소한의 변화 신호로 제한합니다.</p>
<h3 id="3-장애-drill은-신호가-아닌-원장을-검증한다">3) 장애 drill은 신호가 아닌 원장을 검증한다</h3>
<p>테스트에서 notification을 받았다는 사실만 확인하면 약합니다. 아래 순서로 작업이 결국 처리되는지를 봅니다.</p>
<ol>
<li>listener를 종료한 상태에서 job을 100개 commit한다.</li>
<li>listener를 재시작하고 <code>LISTEN commit → initial scan</code>으로 100개가 claim되는지 확인한다.</li>
<li>claim 후 worker를 강제 종료해 lease 만료와 재처리가 일어나는지 확인한다.</li>
<li>listener 연결을 끊었다 붙이고, reconnect scan이 중복 없이 backlog를 회복하는지 확인한다.</li>
<li>긴 read transaction을 의도적으로 만들어 queue usage·alert·runbook이 작동하는지 확인한 뒤 종료한다.</li>
</ol>
<p>성공 기준은 ‘100개의 알림을 받음’이 아니라 <strong>100개 작업의 최종 상태와 외부 효과가 정의한 멱등성 계약에 맞음</strong>입니다. 외부 이메일·결제·webhook이라면 <code>job id</code>와 별도의 idempotency key 또는 결과 ledger도 필요합니다.</p>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<ol>
<li><strong>broker를 없애는 것이 아니라 요구사항을 줄이는 선택입니다.</strong> PostgreSQL 기반 wake-up은 작은 범위에서 운영 부품을 줄여 주지만, partition ordering·consumer group·장기 replay가 필요하면 전문 broker가 더 단순해집니다.</li>
<li><strong>broadcast는 의도된 동작입니다.</strong> 50개 worker가 모두 깨어나는 비용이 문제라면 debounce·작은 claim batch로 완화할 수는 있지만, notification을 work sharding으로 바꾸면 안 됩니다.</li>
<li><strong>NOTIFY는 2PC와 함께 쓸 수 없습니다.</strong> <code>NOTIFY</code> 또는 <code>LISTEN</code>을 실행한 트랜잭션은 prepared transaction이 될 수 없으므로, 이미 2PC를 요구하는 경로라면 다른 설계를 택해야 합니다.</li>
<li><strong>payload에 job id가 있어도 scan은 남겨야 합니다.</strong> job id 직행 조회는 빠른 경로일 수 있으나, 정확성 경로는 언제나 ready backlog scan이어야 합니다.</li>
<li><strong>장기 transaction은 실시간성을 무너뜨립니다.</strong> listener가 수 분짜리 분석 query를 겸하면 notification 처리와 queue cleanup이 모두 늦어집니다. 연결 역할을 분리합니다.</li>
</ol>
<h2 id="체크리스트-또는-연습">체크리스트 또는 연습</h2>
<h3 id="체크리스트">체크리스트</h3>
<ul>
<li><input disabled="" type="checkbox"> 업무 변경, jobs/outbox insert, <code>pg_notify()</code>가 같은 commit에 묶여 있다.</li>
<li><input disabled="" type="checkbox"> worker는 <code>LISTEN</code> commit 뒤에 initial scan을 수행하고 15~60초 fallback scan을 유지한다.</li>
<li><input disabled="" type="checkbox"> notification 수를 작업 수나 소비 성공 수로 사용하지 않는다.</li>
<li><input disabled="" type="checkbox"> <code>LISTEN</code> 연결은 일반 HTTP request pool과 분리되어 있고 transaction pooling에 의존하지 않는다.</li>
<li><input disabled="" type="checkbox"> claim은 원자적이며 실제 외부 작업은 DB transaction 밖에서 실행한다.</li>
<li><input disabled="" type="checkbox"> <code>pg_notification_queue_usage</code>, longest transaction, listener reconnect, oldest ready job age를 관측한다.</li>
<li><input disabled="" type="checkbox"> channel/payload에 민감정보가 없고 owner·스키마·변경 절차가 문서화되어 있다.</li>
</ul>
<h3 id="연습-과제">연습 과제</h3>
<p>현재 서비스의 비동기 후속 작업 하나를 골라 보세요. 먼저 <code>jobs</code> 또는 <code>outbox</code>에 어떤 필드가 남아야 재처리가 가능한지 적고, 그 뒤 <code>NOTIFY</code> payload를 128 bytes 이내의 힌트로 축소해 보세요. listener를 1분 중단한 뒤 재시작했을 때도 oldest ready job age가 목표 SLO 안으로 회복되는지 측정하면, 신호와 원장의 경계가 실제로 지켜지는지 확인할 수 있습니다.</p>
]]></content:encoded></item></channel></rss>