<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>For Engineering</title>
    <link>https://forcloud.tistory.com/</link>
    <description>초보 엔지니어의  흔적</description>
    <language>ko</language>
    <pubDate>Sat, 25 Jul 2026 09:35:07 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>jykim23</managingEditor>
    <image>
      <title>For Engineering</title>
      <url>https://tistory1.daumcdn.net/tistory/2948131/attach/f640e76421194dc7bcc3dc8f1e5b302f</url>
      <link>https://forcloud.tistory.com</link>
    </image>
    <item>
      <title>[비동기] 스트리밍 도중 클라이언트 끊김 진행 작업 shield 복구</title>
      <link>https://forcloud.tistory.com/496</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;streaming-disconnect-shield-recovery.png&quot; data-origin-width=&quot;2752&quot; data-origin-height=&quot;1536&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nC2Dg/dJMcaf8qDzh/CijKBUklMt73NPKxS8PSKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nC2Dg/dJMcaf8qDzh/CijKBUklMt73NPKxS8PSKk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nC2Dg/dJMcaf8qDzh/CijKBUklMt73NPKxS8PSKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnC2Dg%2FdJMcaf8qDzh%2FCijKBUklMt73NPKxS8PSKk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; alt=&quot;스트리밍 클라이언트 끊김 asyncio shield 백그라운드 복구 체크포인트 재개&quot; loading=&quot;lazy&quot; width=&quot;2752&quot; height=&quot;1536&quot; data-filename=&quot;streaming-disconnect-shield-recovery.png&quot; data-origin-width=&quot;2752&quot; data-origin-height=&quot;1536&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;부제: 사용자가 응답 도중 앱을 닫아도, 생성 중이던 답을 잃지 않게 한 이야기&lt;/p&gt;
&lt;p&gt;챗봇은 답을 스트리밍(SSE)으로 한 조각씩 흘려보낸다.&lt;br&gt;그런데 사용자가 답이 끝나기 전에 앱을 닫거나 화면을 나가면 연결이 끊긴다.&lt;br&gt;문제는 그 순간 서버가 무엇을 하느냐였다.&lt;/p&gt;
&lt;h2&gt;끊기면 진행 중이던 답이 사라진다&lt;/h2&gt;
&lt;p&gt;연결이 끊기면 서버(uvicorn)는 그 요청에 딸린 작업들을 통째로 취소한다.&lt;br&gt;비동기에서 요청 하나는 여러 작업이 묶인 나무 같은 구조라, 요청이 끝나면 그 나무 전체가 취소된다.&lt;/p&gt;
&lt;p&gt;그런데 하필 그 시점에 LLM은 아직 답을 생성하는 중일 수 있다.&lt;br&gt;저장도 후처리도 남아 있다.&lt;/p&gt;
&lt;p&gt;그냥 두면 생성 중이던 턴이 통째로 사라진다.&lt;br&gt;사용자가 다시 들어와도 그 답이 없고, DB는 반쯤 쓰다 만 상태가 된다.&lt;br&gt;사용자가 잠깐 끊었을 뿐인데, 진행 중이던 일이 전부 날아가는 것이다.&lt;/p&gt;
&lt;h2&gt;이미 시작한 일은 마저 끝낸다&lt;/h2&gt;
&lt;p&gt;우리가 원한 건 이거였다.&lt;br&gt;사용자가 끊어도, &lt;strong&gt;이미 생성 중이던 답은 끝까지 만들어 저장해 두자.&lt;/strong&gt;&lt;br&gt;다시 들어오면 이어 볼 수 있게.&lt;/p&gt;
&lt;p&gt;그래서 끊김(취소)을 잡아, 남은 일을 백그라운드로 넘겼다.&lt;br&gt;진행 중이던 대화를 체크포인트에서 이어 완성하고, 후처리를 마치는 작업이다.&lt;/p&gt;
&lt;p&gt;핵심은 이 백그라운드 작업을 &lt;code&gt;asyncio.shield&lt;/code&gt;로 감싼 것이다.&lt;br&gt;shield는 서버의 취소가 이 작업까지 전파되는 걸 막는다.&lt;br&gt;요청 나무가 취소돼도, 감싼 작업만은 취소되지 않고 끝까지 돈다.&lt;/p&gt;
&lt;p&gt;작은 함정도 있었다.&lt;br&gt;&lt;code&gt;shield()&lt;/code&gt;는 Future를 돌려주는데, 이건 백그라운드 태스크 생성에 바로 넘길 수 없다.&lt;br&gt;그래서 얇은 코루틴으로 한 번 감싸는 헬퍼를 뒀다.&lt;/p&gt;
&lt;p&gt;이제 사용자가 떠나도, 생성 중이던 턴은 체크포인트에서 재개돼 완성되고 저장된다.&lt;/p&gt;
&lt;h2&gt;정리&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;스트리밍 중 클라이언트가 끊기면 서버(uvicorn)가 요청에 딸린 작업 나무를 통째로 취소한다&lt;/li&gt;&lt;li&gt;그러면 생성 중이던 LLM 턴·저장·후처리가 함께 날아가 버린다&lt;/li&gt;&lt;li&gt;끊김을 잡아, 진행 중이던 그래프를 체크포인트에서 이어 완성하는 작업을 백그라운드로 넘겼다&lt;/li&gt;&lt;li&gt;그 작업을 &lt;code&gt;asyncio.shield&lt;/code&gt;로 감싸 취소 전파로부터 보호 — 사용자가 떠나도 완주&lt;/li&gt;&lt;li&gt;shield는 Future를 반환해 태스크에 직접 못 넘기니, 얇은 코루틴 헬퍼로 감쌌다&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;스트리밍에서 클라이언트가 끊기면 서버는 관련 작업을 취소한다.&lt;br&gt;하지만 &quot;이미 시작한 일은 마저 끝내야&quot; 하는 게 있다 — 생성 중이던 답, 그 저장.&lt;br&gt;그런 건 취소로부터 shield로 지켜 백그라운드에서 완주시킨다.&lt;br&gt;사용자의 이탈이 진행 중인 일을 죽이면 안 된다.&lt;/p&gt;</description>
      <category>AI</category>
      <category>asyncio</category>
      <category>shield</category>
      <category>SSE</category>
      <category>개발</category>
      <category>백엔드</category>
      <category>비동기</category>
      <category>스트리밍</category>
      <category>체크포인트</category>
      <author>jykim23</author>
      <guid isPermaLink="true">https://forcloud.tistory.com/496</guid>
      <comments>https://forcloud.tistory.com/496#entry496comment</comments>
      <pubDate>Thu, 23 Jul 2026 22:02:36 +0900</pubDate>
    </item>
    <item>
      <title>[LLM] 대화 요약 대신 정보 추출 화자 귀속 편향 방지</title>
      <link>https://forcloud.tistory.com/495</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;conversation-extraction-over-summary.png&quot; data-origin-width=&quot;2752&quot; data-origin-height=&quot;1536&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b0p5Pp/dJMcai49edw/qUwz0zsLnuILFpQApkOVi0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b0p5Pp/dJMcai49edw/qUwz0zsLnuILFpQApkOVi0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b0p5Pp/dJMcai49edw/qUwz0zsLnuILFpQApkOVi0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb0p5Pp%2FdJMcai49edw%2FqUwz0zsLnuILFpQApkOVi0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; alt=&quot;대화 요약 정보 추출 화자 귀속 편향 사용자 상태 추출 출처 태깅&quot; loading=&quot;lazy&quot; width=&quot;2752&quot; height=&quot;1536&quot; data-filename=&quot;conversation-extraction-over-summary.png&quot; data-origin-width=&quot;2752&quot; data-origin-height=&quot;1536&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p&gt;부제: 요약이 AI의 일시적 입장을 확정 사실로 박제하던 문제를 추출로 바꾼 이야기&lt;/p&gt;
&lt;p&gt;대화가 길어지면 요약해서 다음 대화에 넣는다.&lt;br&gt;매번 전체 기록을 다 넣을 순 없으니, 압축해 두는 것이다.&lt;br&gt;그런데 LLM에게 대화를 요약시키니 이상한 일이 났다.&lt;/p&gt;
&lt;h2&gt;요약이 AI의 말을 사실로 박제했다&lt;/h2&gt;
&lt;p&gt;첫 번째 문제는 요약이 누구 말인지 안 가린다는 거였다.&lt;br&gt;AI가 한 말과 사용자가 한 말을 뭉뚱그려 요약하면서, AI의 일시적 입장을 사용자 사실처럼 굳혔다.&lt;/p&gt;
&lt;p&gt;구체적인 사례가 있었다.&lt;br&gt;AI가 &quot;러닝을 중단하고 진료부터 받으세요&quot;라고 조언한 적이 있는데,&lt;br&gt;요약이 이걸 &quot;이 사용자는 러닝 중단·진료 우선&quot;이라는 확정 사실로 박제했다.&lt;br&gt;사용자가 그렇게 정한 게 아니라, AI가 그 순간 건넨 조언이었을 뿐인데.&lt;/p&gt;
&lt;p&gt;그 요약이 매 턴 다시 주입되니, AI가 자기가 했던 임시 조언을 계속 강화했다.&lt;br&gt;부상을 반영한 훈련 플랜을 만들어 달라고 해도 &quot;중단해야 한다&quot;는 방향으로만 쏠렸다.&lt;/p&gt;
&lt;h2&gt;범인은 원문이 아니라 요약이었다&lt;/h2&gt;
&lt;p&gt;격리 실험으로 확인했다.&lt;br&gt;거부하는 서사가 든 요약을 주면 부상 반영 플랜 생성이 0/6,&lt;br&gt;사실만 담은 중립 요약은 3/6, 요약을 아예 안 주면 7/8이었다.&lt;br&gt;원문 대화가 아니라 &quot;요약 내용&quot;이 응답을 망치고 있었다.&lt;/p&gt;
&lt;p&gt;논문을 찾아보니 이건 우연이 아니었다.&lt;br&gt;대화 요약에서 가장 안 풀린 문제가 화자 귀속·중요도·사실성이라고 한다.&lt;br&gt;그리고 에이전트(AI)의 입장은 기껏해야 그때의 일화일 뿐,&lt;br&gt;사용자에 대한 사실로 올려서는 안 되는 것이었다.&lt;br&gt;압축은 권위를 만든다 — 뭉뚱그려 요약되면 그게 확정된 사실인 양 힘을 얻는다.&lt;/p&gt;
&lt;h2&gt;요약기가 아니라 추출기로&lt;/h2&gt;
&lt;p&gt;그래서 방향을 바꿨다.&lt;br&gt;&quot;대화 요약기&quot;를 버리고 &quot;사용자 상태 추출기&quot;로 다시 설계했다.&lt;/p&gt;
&lt;ul&gt;&lt;li&gt;사실은 &lt;strong&gt;사용자 턴에서만&lt;/strong&gt; 추출한다. AI가 한 말은 사용자 사실로 올리지 않는다.&lt;/li&gt;&lt;li&gt;각 정보에 출처를 태깅한다 — 사용자가 말했나, AI가 말했나, 추론인가.&lt;/li&gt;&lt;li&gt;충돌하면 최신 사용자 진술을 우선한다.&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;대화를 압축한 서사가 아니라, 검증된 사용자 사실만 남기는 것이다.&lt;/p&gt;
&lt;h2&gt;정리&lt;/h2&gt;
&lt;ul&gt;&lt;li&gt;대화를 통째로 요약하니 화자를 안 가려, AI의 일시적 조언이 사용자 확정 사실로 박제됐다&lt;/li&gt;&lt;li&gt;그 요약이 매 턴 재주입되며 AI가 자기 입장을 강화(self-prime) — 부상 플랜 생성이 쏠렸다&lt;/li&gt;&lt;li&gt;실험: 거부 서사 요약 0/6 · 중립 요약 3/6 · 무요약 7/8 → 범인은 원문이 아니라 요약 내용&lt;/li&gt;&lt;li&gt;&quot;요약&quot;을 버리고 &quot;추출&quot;로 — 사용자 턴에서만, 출처를 태깅해, 충돌 시 최신 사용자 진술 우선&lt;/li&gt;&lt;/ul&gt;
&lt;p&gt;대화를 통째로 요약해 메모리에 넣으면, AI의 일시적 말이 압축되며 권위를 얻어 사용자 사실로 승격된다.&lt;br&gt;필요한 건 요약이 아니라 추출이다. 그것도 사용자 말에서, 누가 말했는지를 붙여서.&lt;/p&gt;</description>
      <category>AI</category>
      <category>AI</category>
      <category>llm</category>
      <category>Sycophancy</category>
      <category>대화</category>
      <category>메모리</category>
      <category>백엔드</category>
      <category>요약</category>
      <category>정보추출</category>
      <author>jykim23</author>
      <guid isPermaLink="true">https://forcloud.tistory.com/495</guid>
      <comments>https://forcloud.tistory.com/495#entry495comment</comments>
      <pubDate>Thu, 23 Jul 2026 22:01:59 +0900</pubDate>
    </item>
    <item>
      <title>[PostgreSQL] work_mem와 정렬 spill &amp;mdash; 큰 ORDER BY가 디스크로 새는 순간 잡아내기</title>
      <link>https://forcloud.tistory.com/494</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;설치·접속: &lt;a href=&quot;https://forcloud.tistory.com/420&quot;&gt;PostgreSQL 설치와 접속&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;부제: ORDER BY·GROUP BY·DISTINCT가 유독 느린데, EXPLAIN에 &amp;quot;external merge Disk&amp;quot;가 찍혀 있을 때&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- 병렬 제거해 정렬 방식만 또렷이 본다
SET max_parallel_workers_per_gather = 0;

SET work_mem = &amp;#39;4MB&amp;#39;;                 -- 기본값
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM m5_orders ORDER BY amount;

SET work_mem = &amp;#39;256MB&amp;#39;;               -- 넉넉히
EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM m5_orders ORDER BY amount;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;-- work_mem=4MB : 디스크로 샌다
 Sort  (actual time=743..956  rows=2000000)
   Sort Method: external merge  Disk: 84592kB           ← 82MB를 디스크에 씀
   Buffers: ... temp read=21142 written=21182           ← temp = 임시파일 I/O
   Execution Time: 1011.516 ms

-- work_mem=256MB : 메모리 안에서 끝
 Sort  (actual time=633..875  rows=2000000)
   Sort Method: quicksort  Memory: 160862kB             ← 통째로 메모리 정렬
   Execution Time: 927.463 ms&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;work_mem&lt;/code&gt;은 &lt;strong&gt;정렬·해시 한 건이 쓸 수 있는 메모리 한도&lt;/strong&gt;다. 정렬 대상이 이 한도를 넘으면 PostgreSQL은 데이터를 조각내 디스크 임시파일에 쓰고 나중에 병합한다 — 이게 &lt;code&gt;Sort Method: external merge Disk&lt;/code&gt;이고, &lt;code&gt;temp read/written&lt;/code&gt; 블록이 그 증거다. 82MB짜리 정렬을 4MB 한도로 돌리니 디스크를 21,000블록이나 왕복한다. work_mem을 키우면 &lt;code&gt;quicksort Memory&lt;/code&gt;로 바뀌며 임시파일이 사라진다. &lt;strong&gt;주의: work_mem은 커넥션·연산 단위&lt;/strong&gt;라 &lt;code&gt;100커넥션 × 정렬 2개 × 256MB = 50GB&lt;/code&gt;가 될 수 있다. 그래서 서버 전역으로 크게 잡지 말고, &lt;strong&gt;무거운 배치 쿼리에서만 세션 단위로&lt;/strong&gt; 올린다(&lt;code&gt;SET work_mem&lt;/code&gt;). 진단 순서는 명확하다 — 느린 정렬을 만나면 EXPLAIN ANALYZE에서 &lt;code&gt;Disk:&lt;/code&gt;와 &lt;code&gt;temp&lt;/code&gt;를 먼저 확인한다.&lt;/p&gt;
&lt;h2&gt;이렇게도 쓴다&lt;/h2&gt;
&lt;p&gt;정렬 컬럼에 인덱스를 주면 정렬 자체가 사라진다(Index Scan이 순서대로 반환). (조합: 정렬 회피)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE INDEX idx_m5_amount ON m5_orders(amount);
-- ORDER BY amount 가 Sort 없이 Index Scan&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;해시 집계(GROUP BY)도 같은 한도를 쓴다 — Batches가 늘면 해시가 샌 것. (조합: HashAggregate)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;EXPLAIN (ANALYZE) SELECT user_id, count(*) FROM m5_orders GROUP BY user_id;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;임시파일이 실제로 만들어지는지 로그로 잡는다. (조합: log_temp_files)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SET log_temp_files = 0;   -- 0 이상 크기의 temp 파일을 모두 로그&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;상위 N건만 필요하면 LIMIT으로 top-N heapsort를 유도해 메모리를 아낀다. (조합: LIMIT)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;EXPLAIN (ANALYZE) SELECT * FROM m5_orders ORDER BY amount LIMIT 100;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;세션에서만 올리고 쿼리 끝나면 되돌린다 — 전역 확대는 피한다. (원칙: 세션 단위)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SET work_mem=&amp;#39;256MB&amp;#39;;  -- 무거운 배치 직전
RESET work_mem;        -- 끝나면 원복&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;정렬 방식과 사용 메모리를 한 줄로 확인한다. (조합: EXPLAIN ANALYZE)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;EXPLAIN (ANALYZE) SELECT DISTINCT amount FROM m5_orders;&lt;/code&gt;&lt;/pre&gt;</description>
      <category>명령어/DB</category>
      <category>db</category>
      <category>PostgreSQL</category>
      <category>work-mem-sort</category>
      <category>work_mem</category>
      <category>명령어</category>
      <category>성능</category>
      <author>jykim23</author>
      <guid isPermaLink="true">https://forcloud.tistory.com/494</guid>
      <comments>https://forcloud.tistory.com/494#entry494comment</comments>
      <pubDate>Thu, 23 Jul 2026 22:00:27 +0900</pubDate>
    </item>
    <item>
      <title>[PostgreSQL] 윈도우 프레임 ROWS/RANGE BETWEEN으로 이동평균&amp;middot;누적합을 낸다</title>
      <link>https://forcloud.tistory.com/493</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;설치·접속: &lt;a href=&quot;https://forcloud.tistory.com/420&quot;&gt;PostgreSQL 설치와 접속&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;부제: 일별 매출에서 &amp;quot;최근 3일 이동평균&amp;quot;과 &amp;quot;그날까지 누적합&amp;quot;을 GROUP BY 없이 각 행 옆에 나란히 붙이고 싶을 때&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- ROWS 프레임으로 &amp;quot;현재 행 기준 앞뒤 몇 행&amp;quot;을 집계 범위로 지정한다
SELECT day, amount,
  round(avg(amount) OVER (ORDER BY day
        ROWS BETWEEN 2 PRECEDING AND CURRENT ROW), 1)        AS ma3,
  sum(amount) OVER (ORDER BY day
        ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)    AS running
FROM m10_sales ORDER BY day LIMIT 8;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;    day     | amount |  ma3  | running 
------------+--------+-------+---------
 2026-03-01 |    100 | 100.0 |     100
 2026-03-02 |    114 | 107.0 |     214
 2026-03-03 |    125 | 113.0 |     339
 2026-03-04 |    130 | 123.0 |     469
 2026-03-05 |    127 | 127.3 |     596
 2026-03-06 |    118 | 125.0 |     714
 2026-03-07 |    104 | 116.3 |     818
 2026-03-08 |     89 | 103.7 |     907
(8 rows)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;윈도우 함수의 &lt;code&gt;OVER&lt;/code&gt; 절 안에서 프레임(&lt;code&gt;ROWS&lt;/code&gt;/&lt;code&gt;RANGE BETWEEN ...&lt;/code&gt;)은 &amp;quot;현재 행에서 어디까지를 계산 범위로 볼지&amp;quot;를 정한다. &lt;code&gt;2 PRECEDING AND CURRENT ROW&lt;/code&gt;는 나 포함 앞 3개라서 3일 이동평균이 되고, &lt;code&gt;UNBOUNDED PRECEDING AND CURRENT ROW&lt;/code&gt;는 맨 처음부터 지금까지라서 누적합이 된다. &lt;code&gt;ROWS&lt;/code&gt;는 물리적 행 개수로 세고, &lt;code&gt;RANGE&lt;/code&gt;는 정렬 값(날짜·숫자) 자체로 범위를 잡는다는 게 핵심 차이다. GROUP BY와 달리 원본 행을 그대로 두고 계산 결과만 옆에 붙이므로, 리포트·차트용 파생 컬럼을 한 번에 만든다.&lt;/p&gt;
&lt;h2&gt;이렇게도 쓴다&lt;/h2&gt;
&lt;p&gt;앞뒤로 걸쳐 중심 이동평균을 낸다. (조합: PRECEDING AND FOLLOWING)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT day, amount,
  round(avg(amount) OVER (ORDER BY day
        ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING), 1) AS center3
FROM m10_sales ORDER BY day;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;행 개수가 아니라 날짜 간격으로 &amp;quot;최근 7일&amp;quot;을 잡는다. (조합: RANGE + interval)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT day,
  sum(amount) OVER (ORDER BY day
        RANGE BETWEEN interval &amp;#39;6 days&amp;#39; PRECEDING AND CURRENT ROW) AS last7
FROM m10_sales ORDER BY day;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;프레임을 생략하면 파티션 전체가 대상이 된다(전체 대비 비율). (조합: OVER ())&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT day, amount,
  round(100.0 * amount / sum(amount) OVER (), 1) AS pct
FROM m10_sales ORDER BY day;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;동점(같은 정렬키)에서 ROWS와 RANGE는 다르게 동작한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;WITH t(k,v) AS (VALUES (1,10),(1,20),(2,30))
SELECT k, v,
  sum(v) OVER (ORDER BY k ROWS  UNBOUNDED PRECEDING) AS by_rows,
  sum(v) OVER (ORDER BY k RANGE UNBOUNDED PRECEDING) AS by_range
FROM t;
--  k=1 두 행: by_rows=10,30(한 행씩)  by_range=30,30(동점 묶어 합산)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;그룹별로 프레임을 리셋한다. (조합: PARTITION BY)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT user_id, id,
  sum(qty) OVER (PARTITION BY user_id ORDER BY id
        ROWS UNBOUNDED PRECEDING) AS user_running
FROM orders ORDER BY user_id, id;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;직전 값과 비교해 증감을 낸다. (조합: lag)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT day, amount, amount - lag(amount) OVER (ORDER BY day) AS diff
FROM m10_sales ORDER BY day;&lt;/code&gt;&lt;/pre&gt;</description>
      <category>명령어/DB</category>
      <category>db</category>
      <category>PostgreSQL</category>
      <category>window function</category>
      <category>명령어</category>
      <category>이동평균</category>
      <author>jykim23</author>
      <guid isPermaLink="true">https://forcloud.tistory.com/493</guid>
      <comments>https://forcloud.tistory.com/493#entry493comment</comments>
      <pubDate>Thu, 23 Jul 2026 21:59:52 +0900</pubDate>
    </item>
    <item>
      <title>[PostgreSQL] FOR UPDATE SKIP LOCKED 여러 워커가 같은 큐에서 안 겹치게 하나씩 집어간다</title>
      <link>https://forcloud.tistory.com/492</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;설치·접속: &lt;a href=&quot;https://forcloud.tistory.com/420&quot;&gt;PostgreSQL 설치와 접속&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;부제: 워커 여러 대가 같은 작업 큐 테이블을 동시에 폴링할 때, 서로 잡은 행은 건너뛰고 각자 다른 행만 집어가게 한다&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- 각 워커가 실행: 남이 잠근 행은 건너뛰고 안 잠긴 것만 3개 집는다
BEGIN;
SELECT id, payload FROM jobs
WHERE status = &amp;#39;pending&amp;#39;
ORDER BY id
FOR UPDATE SKIP LOCKED
LIMIT 3;
-- ... 처리 후 status 갱신 ...
COMMIT;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;=== Worker B (A가 id 1~3을 잡고 있는 중) ===
BEGIN
 id | payload
----+---------
  4 | job-4
  5 | job-5
  6 | job-6
(3 rows)

=== Worker A got ===
 id | payload
----+---------
  1 | job-1
  2 | job-2
  3 | job-3&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;Worker A가 트랜잭션 안에서 id 1&lt;del&gt;3에 행 락을 걸고 아직 커밋하지 않은 사이, Worker B가 같은 쿼리를 던지면 &lt;code&gt;SKIP LOCKED&lt;/code&gt; 덕분에 잠긴 1&lt;/del&gt;3을 그냥 지나쳐 4~6을 가져온다. 락 대기로 멈추지도, 같은 작업을 중복 처리하지도 않는다. &lt;code&gt;SKIP LOCKED&lt;/code&gt;가 없으면 B는 A의 커밋까지 블로킹되고, 그냥 &lt;code&gt;LIMIT&lt;/code&gt;만 쓰면 둘 다 같은 행을 읽어 이중 처리된다. 큐에 워커를 여러 대 붙여 수평 확장할 때의 정석이다.&lt;/p&gt;
&lt;h2&gt;이렇게도 쓴다&lt;/h2&gt;
&lt;p&gt;집는 즉시 상태를 바꿔 다른 워커 눈에서 치운다. (조합: CTE + UPDATE ... RETURNING)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;WITH picked AS (
  SELECT id FROM jobs WHERE status=&amp;#39;pending&amp;#39;
  ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1
)
UPDATE jobs j SET status=&amp;#39;running&amp;#39;
FROM picked WHERE j.id = picked.id
RETURNING j.id, j.payload;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;한 건씩 집어 처리하는 단일 워커 루프.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT id FROM jobs WHERE status=&amp;#39;pending&amp;#39;
ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;건너뛰지 말고 &amp;quot;잠겨 있으면 바로 에러&amp;quot;로 실패시킨다. (조합: NOWAIT)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT id FROM jobs WHERE id = 5 FOR UPDATE NOWAIT;
-- ERROR: could not obtain lock on row in relation &amp;quot;jobs&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;우선순위 큐: 급한 것부터 집는다. (조합: ORDER BY priority)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT id FROM jobs WHERE status=&amp;#39;pending&amp;#39;
ORDER BY priority DESC, id FOR UPDATE SKIP LOCKED LIMIT 1;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;특정 행만 잠그고 조인 상대는 잠그지 않는다. (조합: OF 테이블명)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT j.id FROM jobs j JOIN users u ON u.id = j.owner_id
WHERE j.status=&amp;#39;pending&amp;#39;
FOR UPDATE OF j SKIP LOCKED LIMIT 1;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;예약 시각이 된 작업만 골라 집는다. (조합: run_at 필터)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT id FROM jobs WHERE status=&amp;#39;pending&amp;#39; AND run_at &amp;lt;= now()
ORDER BY run_at FOR UPDATE SKIP LOCKED LIMIT 1;&lt;/code&gt;&lt;/pre&gt;</description>
      <category>명령어/DB</category>
      <category>db</category>
      <category>PostgreSQL</category>
      <category>SKIP LOCKED</category>
      <category>동시성</category>
      <category>명령어</category>
      <category>작업큐</category>
      <author>jykim23</author>
      <guid isPermaLink="true">https://forcloud.tistory.com/492</guid>
      <comments>https://forcloud.tistory.com/492#entry492comment</comments>
      <pubDate>Thu, 23 Jul 2026 21:59:32 +0900</pubDate>
    </item>
    <item>
      <title>[PostgreSQL] SERIALIZABLE 동시 갱신이 서로를 덮어써 lost update가 날 때</title>
      <link>https://forcloud.tistory.com/491</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;설치·접속: &lt;a href=&quot;https://forcloud.tistory.com/420&quot;&gt;PostgreSQL 설치와 접속&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;부제: 두 요청이 잔액을 각각 읽어 +50씩 더하는데, 하나가 다른 하나의 갱신을 덮어써 결과가 틀어지는 상황&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- 흔한 read-modify-write 패턴 (앱이 읽은 값으로 다시 쓴다)
BEGIN ISOLATION LEVEL READ COMMITTED;
SELECT balance FROM m7_acct WHERE id=1;        -- 둘 다 100을 읽음
-- 앱에서 100+50 계산
UPDATE m7_acct SET balance = 150 WHERE id=1;   -- 읽은 값 기준으로 덮어씀
COMMIT;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;########## LOST UPDATE (READ COMMITTED) — 둘 다 +50 하려는데 ##########
B: 읽은 잔액=100  -&amp;gt; +50 계산
A: 읽은 잔액=100  -&amp;gt; +50 계산
A: 커밋 (150 기록)
B: 커밋 (150 기록)
 최종 잔액 (기대 200)
----------------------
                  150   &amp;lt;- 한쪽 +50이 통째로 사라짐

########## SERIALIZABLE — 같은 시나리오 ##########
B: 읽은 잔액=100
A: 읽은 잔액=100
A: 커밋 성공
B: 커밋
ERROR:  could not serialize access due to concurrent update   &amp;lt;- B는 실패 → 앱이 재시도&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;두 트랜잭션이 각자 100을 읽고 각자 150을 써서 커밋하면, 나중에 커밋한 쪽이 앞선 갱신을 통째로 덮어쓴다. +50이 두 번이었는데 결과는 150. 이게 &lt;strong&gt;lost update&lt;/strong&gt;다. &lt;code&gt;UPDATE ... SET balance = balance + 50&lt;/code&gt;처럼 DB 안에서 계산하면 행 락으로 직렬화되어 안전하지만, 앱이 읽은 값을 다시 써넣는 패턴에서는 &lt;code&gt;READ COMMITTED&lt;/code&gt;가 이걸 못 막는다. &lt;code&gt;SERIALIZABLE&lt;/code&gt;로 올리면 PostgreSQL이 두 트랜잭션이 사실상 충돌한다는 걸 감지해 나중 커밋을 &lt;code&gt;40001&lt;/code&gt;(serialization failure)로 거절한다. 그래서 SERIALIZABLE을 쓸 땐 &lt;strong&gt;앱에 재시도 루프가 필수&lt;/strong&gt;다. 실패한 트랜잭션을 다시 돌리면 이번엔 150을 읽어 200을 쓴다.&lt;/p&gt;
&lt;h2&gt;이렇게도 쓴다&lt;/h2&gt;
&lt;p&gt;DB 안에서 계산하면 SERIALIZABLE 없이도 lost update가 안 난다. (조합: 원자적 UPDATE)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE m7_acct SET balance = balance + 50 WHERE id=1;  -- 행 락으로 직렬화&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;읽는 시점에 행을 잠가 다른 세션을 대기시킨다. (조합: SELECT FOR UPDATE)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT balance FROM m7_acct WHERE id=1 FOR UPDATE;     -- 비관적 잠금&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;버전 컬럼으로 낙관적 잠금을 건다(안 바뀌었을 때만 성공).&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;UPDATE m7_acct SET balance=150, version=version+1
WHERE id=1 AND version = 7;   -- 0 rows면 남이 먼저 바꾼 것 → 재시도&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;직렬화 실패인지 코드로 구분해 재시도한다. (조합: SQLSTATE 40001)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- 애플리케이션: SQLSTATE &amp;#39;40001&amp;#39;(serialization_failure) 잡으면 트랜잭션 재실행&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;읽기만 하는 리포트는 예측 못한 직렬화 실패를 줄인다. (조합: DEFERRABLE)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;BEGIN ISOLATION LEVEL SERIALIZABLE READ ONLY DEFERRABLE;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;직렬화 충돌이 실제로 얼마나 나는지 카운터로 본다. (조합: pg_stat_database)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT datname, xact_commit, xact_rollback FROM pg_stat_database WHERE datname=&amp;#39;shop&amp;#39;;&lt;/code&gt;&lt;/pre&gt;</description>
      <category>명령어/DB</category>
      <category>db</category>
      <category>PostgreSQL</category>
      <category>Serializable</category>
      <category>명령어</category>
      <category>트랜잭션</category>
      <author>jykim23</author>
      <guid isPermaLink="true">https://forcloud.tistory.com/491</guid>
      <comments>https://forcloud.tistory.com/491#entry491comment</comments>
      <pubDate>Thu, 23 Jul 2026 21:59:13 +0900</pubDate>
    </item>
    <item>
      <title>[PostgreSQL] SELECT FOR UPDATE / NOWAIT / FOR SHARE 행을 잠그고 안전하게 갱신한다</title>
      <link>https://forcloud.tistory.com/490</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;설치·접속: &lt;a href=&quot;https://forcloud.tistory.com/420&quot;&gt;PostgreSQL 설치와 접속&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;부제: 재고 1개를 두 요청이 동시에 빼가지 못하게, 읽는 순간 그 행을 잠그고 나만 갱신하게 하는 상황&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- 세션 A: 읽으면서 행을 잠근다. 커밋 전까지 다른 세션의 갱신을 막는다
BEGIN;
SELECT qty FROM m7_stock WHERE id=1 FOR UPDATE;   -- 이 행에 배타 락
-- ... 재고 확인 후 ...
UPDATE m7_stock SET qty = qty - 1 WHERE id=1;
COMMIT;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;########## A가 FOR UPDATE로 행을 잠근 사이 B가 접근 ##########
 A: FOR UPDATE로 잠금
----------------------
                    5

--- B: NOWAIT (즉시 실패) ---
ERROR:  could not obtain lock on row in relation &amp;quot;m7_stock&amp;quot;

--- B: 그냥 FOR UPDATE (A 커밋까지 대기했다가 최신값) ---
A: 재고 차감 후 커밋
 B: 대기 후 읽은 최신 재고
---------------------------
                         4   &amp;lt;- A가 뺀 결과를 반영한 값

########## FOR SHARE는 여러 세션이 공유 가능, 갱신은 막음 ##########
 B: FOR SHARE 통과
-------------------
                 5   &amp;lt;- 공유 락끼리는 서로 통과
--- C: UPDATE 시도 (A의 공유 락 때문에 NOWAIT면 실패) ---
ERROR:  could not obtain lock on row in relation &amp;quot;m7_stock&amp;quot;&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;&lt;code&gt;FOR UPDATE&lt;/code&gt;는 SELECT한 행에 배타적 행 락을 걸어, 커밋할 때까지 다른 세션이 그 행을 갱신·삭제·재잠금하지 못하게 한다. 두 번째 세션이 그냥 &lt;code&gt;FOR UPDATE&lt;/code&gt;로 오면 A의 커밋까지 &lt;strong&gt;블로킹&lt;/strong&gt;됐다가, 풀리면 A가 갱신한 &lt;strong&gt;최신 값&lt;/strong&gt;(4)을 다시 읽는다. 이게 재고 차감·좌석 예약의 정석 흐름이다. 기다리고 싶지 않으면 &lt;code&gt;NOWAIT&lt;/code&gt;로 즉시 에러를 받아 &amp;quot;지금 처리 중&amp;quot;이라고 응답하면 된다. &lt;code&gt;FOR SHARE&lt;/code&gt;는 공유 락이라 여러 세션이 동시에 걸 수 있지만(읽는 동안 이 행이 사라지지 않음을 보장), 그 행을 실제로 갱신하려는 쪽은 막는다. 부모 행이 유지된다는 전제로 자식을 넣을 때 유용하다.&lt;/p&gt;
&lt;h2&gt;이렇게도 쓴다&lt;/h2&gt;
&lt;p&gt;기다리지 말고 잠겨 있으면 바로 에러. (조합: NOWAIT)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT qty FROM m7_stock WHERE id=1 FOR UPDATE NOWAIT;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;남이 잠근 행은 건너뛰고 안 잠긴 것만 집는다(작업 큐). (조합: SKIP LOCKED)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT id FROM m7_stock WHERE qty&amp;gt;0 ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;갱신 안 할 거면 약한 락으로 참조 무결성만 지킨다. (조합: FOR SHARE)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT id FROM m7_stock WHERE id=1 FOR SHARE;   -- 이 행 삭제/변경만 차단&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;조인에서 특정 테이블 행만 잠근다. (조합: OF 테이블명)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT * FROM m7_stock s JOIN orders o ON o.pid=s.id
WHERE s.id=1 FOR UPDATE OF s;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;락 대기 시간을 초 단위로 제한한다. (조합: lock_timeout)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SET lock_timeout = &amp;#39;3s&amp;#39;;   -- 3초 안에 못 잡으면 에러
SELECT * FROM m7_stock WHERE id=1 FOR UPDATE;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;지금 어떤 행 락 때문에 누가 막혀 있는지 본다. (조합: pg_locks)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT pid, mode, granted FROM pg_locks WHERE relation=&amp;#39;m7_stock&amp;#39;::regclass;&lt;/code&gt;&lt;/pre&gt;</description>
      <category>명령어/DB</category>
      <category>db</category>
      <category>PostgreSQL</category>
      <category>select for update</category>
      <category>명령어</category>
      <category>트랜잭션</category>
      <author>jykim23</author>
      <guid isPermaLink="true">https://forcloud.tistory.com/490</guid>
      <comments>https://forcloud.tistory.com/490#entry490comment</comments>
      <pubDate>Thu, 23 Jul 2026 21:58:53 +0900</pubDate>
    </item>
    <item>
      <title>[PostgreSQL] 파티셔닝 큰 테이블을 기간별로 쪼개 관리한다</title>
      <link>https://forcloud.tistory.com/489</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;설치·접속: &lt;a href=&quot;https://forcloud.tistory.com/420&quot;&gt;PostgreSQL 설치와 접속&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;부제: 로그·이벤트처럼 계속 쌓이는 테이블을 월 단위로 나눠, 넣을 땐 자동으로 알맞은 조각에 들어가고 조회할 땐 필요한 조각만 스캔하고 싶을 때&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- 부모는 껍데기. 실제 데이터는 날짜 범위별 자식 테이블에 저장된다
CREATE TABLE m10_events (
  id bigint GENERATED ALWAYS AS IDENTITY,
  occurred_at date NOT NULL,
  payload text
) PARTITION BY RANGE (occurred_at);

CREATE TABLE m10_events_2026_01 PARTITION OF m10_events
  FOR VALUES FROM (&amp;#39;2026-01-01&amp;#39;) TO (&amp;#39;2026-02-01&amp;#39;);
CREATE TABLE m10_events_2026_02 PARTITION OF m10_events
  FOR VALUES FROM (&amp;#39;2026-02-01&amp;#39;) TO (&amp;#39;2026-03-01&amp;#39;);
CREATE TABLE m10_events_2026_03 PARTITION OF m10_events
  FOR VALUES FROM (&amp;#39;2026-03-01&amp;#39;) TO (&amp;#39;2026-04-01&amp;#39;);

-- 부모에 넣으면 occurred_at을 보고 알맞은 자식으로 자동 라우팅
INSERT INTO m10_events (occurred_at, payload)
SELECT d, &amp;#39;evt&amp;#39; FROM generate_series(&amp;#39;2026-01-05&amp;#39;::date, &amp;#39;2026-03-25&amp;#39;::date, &amp;#39;5 days&amp;#39;) d;

SELECT tableoid::regclass AS partition, count(*)
FROM m10_events GROUP BY 1 ORDER BY 1;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;     partition      | count 
--------------------+-------
 m10_events_2026_01 |     6
 m10_events_2026_02 |     5
 m10_events_2026_03 |     5
(3 rows)&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;파티셔닝은 하나의 논리 테이블을 여러 물리 테이블(파티션)로 쪼갠다. &lt;code&gt;INSERT&lt;/code&gt;는 파티션 키(&lt;code&gt;occurred_at&lt;/code&gt;)를 보고 자동으로 맞는 조각에 넣어 주므로, 애플리케이션 코드는 부모 테이블만 바라보면 된다. 진짜 이득은 조회할 때 나온다. &lt;code&gt;WHERE occurred_at&lt;/code&gt;에 범위 조건을 걸면 옵티마이저가 관계없는 파티션을 아예 건너뛰는데(파티션 프루닝), 수억 행 테이블에서도 한 달치만 스캔한다. 오래된 데이터를 지울 때도 &lt;code&gt;DELETE&lt;/code&gt; 대신 파티션을 통째로 &lt;code&gt;DROP&lt;/code&gt;하면 순식간에 끝난다.&lt;/p&gt;
&lt;h2&gt;이렇게도 쓴다&lt;/h2&gt;
&lt;p&gt;특정 기간 조건을 걸면 그 파티션만 스캔한다(파티션 프루닝). (조합: EXPLAIN)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;EXPLAIN (COSTS OFF)
SELECT count(*) FROM m10_events
WHERE occurred_at &amp;gt;= &amp;#39;2026-02-01&amp;#39; AND occurred_at &amp;lt; &amp;#39;2026-03-01&amp;#39;;
--  -&amp;gt;  Seq Scan on m10_events_2026_02   (2026-02 파티션 하나만 탄다)&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;범위가 아니라 값 목록으로 쪼갠다(지역·상태 코드 등). (조합: PARTITION BY LIST)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE TABLE m10_by_region (id int, region text) PARTITION BY LIST (region);
CREATE TABLE m10_reg_kr PARTITION OF m10_by_region FOR VALUES IN (&amp;#39;KR&amp;#39;,&amp;#39;JP&amp;#39;);
CREATE TABLE m10_reg_us PARTITION OF m10_by_region FOR VALUES IN (&amp;#39;US&amp;#39;);
CREATE TABLE m10_reg_etc PARTITION OF m10_by_region DEFAULT;  -- 나머지는 여기로&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;어느 파티션에도 안 맞는 값을 받아 주는 기본 파티션을 둔다. (조합: DEFAULT)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;INSERT INTO m10_by_region VALUES (4,&amp;#39;FR&amp;#39;);   -- KR/US 아닌 값 → m10_reg_etc&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;오래된 파티션을 통째로 버려 즉시 정리한다(DELETE보다 빠름). (조합: DROP/DETACH)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;DROP TABLE m10_events_2026_01;                       -- 1월치 전부 제거
ALTER TABLE m10_events DETACH PARTITION m10_events_2026_02;  -- 떼어내 보관&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;부모에 인덱스를 걸면 모든 파티션에 자동 전파된다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE INDEX ON m10_events (occurred_at);   -- 자식마다 개별 인덱스 생성&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;기존 일반 테이블을 파티션으로 편입한다. (조합: ATTACH PARTITION)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;ALTER TABLE m10_events ATTACH PARTITION m10_events_2026_04
  FOR VALUES FROM (&amp;#39;2026-04-01&amp;#39;) TO (&amp;#39;2026-05-01&amp;#39;);&lt;/code&gt;&lt;/pre&gt;</description>
      <category>명령어/DB</category>
      <category>db</category>
      <category>Partitioning</category>
      <category>PostgreSQL</category>
      <category>명령어</category>
      <category>파티셔닝</category>
      <author>jykim23</author>
      <guid isPermaLink="true">https://forcloud.tistory.com/489</guid>
      <comments>https://forcloud.tistory.com/489#entry489comment</comments>
      <pubDate>Thu, 23 Jul 2026 21:58:30 +0900</pubDate>
    </item>
    <item>
      <title>[PostgreSQL] 부분 인덱스(partial index)로 소수 행만 인덱싱한다</title>
      <link>https://forcloud.tistory.com/488</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;설치·접속: &lt;a href=&quot;https://forcloud.tistory.com/420&quot;&gt;PostgreSQL 설치와 접속&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;부제: 주문 50만 건 중 status=&amp;#39;pending&amp;#39;은 5%뿐인데, 그 대기 건만 자주 조회할 때&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- pending 행만, created_at 기준으로 인덱스를 만든다
CREATE INDEX idx_partial_pending
  ON demo_orders(created_at) WHERE status=&amp;#39;pending&amp;#39;;

SELECT * FROM demo_orders WHERE status=&amp;#39;pending&amp;#39;
  ORDER BY created_at DESC LIMIT 20;&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt;-- 인덱스 크기: 전체 status 인덱스 3408kB  vs  부분 인덱스 568kB
 Limit (actual time=0.011..0.024 rows=20 loops=1)
   Buffers: shared hit=20 read=2
   -&amp;gt;  Index Scan Backward using idx_partial_pending on demo_orders (actual time=0.010..0.022 rows=20 loops=1)
 Execution Time: 0.029 ms
-- 인덱스 없이 같은 조건: Parallel Seq Scan, 3712 buffers, 14.4 ms&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;전체 50만 건 중 pending은 2.5만 건(5%)뿐인데 나머지 95%까지 인덱스에 담을 이유가 없다. &lt;code&gt;WHERE status=&amp;#39;pending&amp;#39;&lt;/code&gt; 조건을 붙이면 그 행만 인덱스에 들어가 크기가 3408kB → 568kB로 6분의 1이 된다. 작아진 만큼 캐시에 잘 얹히고 갱신 비용도 준다. 조회할 때 쿼리의 &lt;code&gt;WHERE&lt;/code&gt;가 인덱스의 조건을 포함(implies)하면 플래너가 이 인덱스를 골라 14ms짜리 seq scan을 0.03ms로 끝낸다. 함정은 인덱스 조건과 쿼리 조건이 어긋나면(예: &lt;code&gt;status=&amp;#39;shipped&amp;#39;&lt;/code&gt;) 이 인덱스를 못 쓴다는 것. 조건 컬럼은 값이 거의 안 바뀌는 상태 플래그(활성/삭제/대기)에 잘 맞는다.&lt;/p&gt;
&lt;h2&gt;이렇게도 쓴다&lt;/h2&gt;
&lt;p&gt;soft delete 환경에서 살아있는 행만 인덱싱한다. (deleted_at IS NULL)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE INDEX idx_users_active ON users(email) WHERE deleted_at IS NULL;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;부분 인덱스로 부분 UNIQUE 제약을 건다. 취소되지 않은 주문만 유니크. (조합: UNIQUE)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE UNIQUE INDEX uq_active_slug ON products(name) WHERE price IS NOT NULL;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;여러 상태를 IN으로 묶어 미처리 건만 인덱싱한다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE INDEX idx_todo ON demo_orders(created_at)
  WHERE status IN (&amp;#39;pending&amp;#39;,&amp;#39;shipped&amp;#39;);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;부분 + 다중 컬럼을 결합해 특정 세그먼트 조회를 좁힌다. (조합: 복합 인덱스)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE INDEX idx_pending_by_user ON demo_orders(user_id, created_at)
  WHERE status=&amp;#39;pending&amp;#39;;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;플래너가 실제로 부분 인덱스를 고르는지 확인한다. (조합: EXPLAIN)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;EXPLAIN (COSTS OFF) SELECT * FROM demo_orders
  WHERE status=&amp;#39;pending&amp;#39; ORDER BY created_at DESC LIMIT 20;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;만든 인덱스 크기를 사람이 읽는 단위로 확인한다. (조합: pg_relation_size)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT pg_size_pretty(pg_relation_size(&amp;#39;idx_partial_pending&amp;#39;));&lt;/code&gt;&lt;/pre&gt;</description>
      <category>명령어/DB</category>
      <category>db</category>
      <category>partial-index</category>
      <category>PostgreSQL</category>
      <category>명령어</category>
      <category>성능</category>
      <category>인덱스</category>
      <author>jykim23</author>
      <guid isPermaLink="true">https://forcloud.tistory.com/488</guid>
      <comments>https://forcloud.tistory.com/488#entry488comment</comments>
      <pubDate>Thu, 23 Jul 2026 21:58:11 +0900</pubDate>
    </item>
    <item>
      <title>[PostgreSQL] REFRESH MATERIALIZED VIEW CONCURRENTLY 갱신 중에도 조회를 막지 않는다</title>
      <link>https://forcloud.tistory.com/487</link>
      <description>&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;&lt;p&gt;설치·접속: &lt;a href=&quot;https://forcloud.tistory.com/420&quot;&gt;PostgreSQL 설치와 접속&lt;/a&gt;&lt;/p&gt;
&lt;/span&gt;&lt;/p&gt;&lt;/blockquote&gt;&lt;p&gt;부제: 대시보드가 계속 읽는 집계 뷰를 새로 굽고 싶은데, 일반 REFRESH는 갱신 내내 조회를 통째로 막아 버려서 서비스 중단 없이 최신화하고 싶을 때&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- 전제조건: 행을 유일하게 식별하는 UNIQUE 인덱스가 반드시 있어야 한다
CREATE UNIQUE INDEX m10_mv_pk ON m10_mv(id);

-- [세션 A] 대용량 뷰를 CONCURRENTLY로 갱신 (약 1.9초 소요)
REFRESH MATERIALIZED VIEW CONCURRENTLY m10_big;   -- Time: 1889.277 ms&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;-- [세션 B] 세션 A가 한창 갱신하는 중에 조회 → 차단되지 않고 즉답
SELECT grp, c FROM m10_big ORDER BY grp LIMIT 3;   -- Time: 5.795 ms&lt;/code&gt;&lt;/pre&gt;
&lt;pre&gt;&lt;code&gt; grp |  c   
-----+------
   0 | 3000        &amp;lt;- 갱신 진행 중엔 아직 &amp;#39;예전&amp;#39; 결과가 보인다
   1 | 3000
   2 | 3000
(3 rows)   -- 갱신 완료 후 다시 조회하면 c = 30000&lt;/code&gt;&lt;/pre&gt;&lt;p&gt;일반 &lt;code&gt;REFRESH MATERIALIZED VIEW&lt;/code&gt;는 뷰에 &lt;code&gt;AccessExclusiveLock&lt;/code&gt;을 걸어, 다시 굽는 동안 모든 &lt;code&gt;SELECT&lt;/code&gt;를 대기시킨다. 뷰가 크면 그 시간 내내 대시보드가 멈춘다. &lt;code&gt;CONCURRENTLY&lt;/code&gt;를 붙이면 갱신은 새 데이터를 임시로 만든 뒤 변경분만 반영하는 방식이라, 조회를 막는 강한 잠금을 잡지 않는다. 위에서 세션 A가 1.9초간 갱신하는 동안 세션 B의 조회는 5.8ms 만에 &lt;strong&gt;예전 스냅샷&lt;/strong&gt;을 반환했고, 갱신이 끝난 뒤에야 새 값(30000)이 보였다. 읽는 쪽은 한순간도 끊기지 않는다. 대신 각 행을 유일하게 식별하는 &lt;code&gt;UNIQUE&lt;/code&gt; 인덱스가 없으면 이 방식을 쓸 수 없다.&lt;/p&gt;
&lt;h2&gt;이렇게도 쓴다&lt;/h2&gt;
&lt;p&gt;UNIQUE 인덱스가 없으면 CONCURRENTLY는 거부된다(반드시 먼저 만든다).&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;REFRESH MATERIALIZED VIEW CONCURRENTLY m10_mv;
-- ERROR: cannot refresh materialized view &amp;quot;public.m10_mv&amp;quot; concurrently
-- HINT:  Create a unique index with no WHERE clause ...&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;읽기가 잠깐 멈춰도 되는 소규모 뷰는 그냥 일반 갱신이 더 빠르다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;REFRESH MATERIALIZED VIEW m10_mv;   -- 짧게 잠그고 통째로 다시 굽기&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;크론으로 주기 갱신을 자동화한다. (조합: 셸 + psql)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-bash&quot;&gt;# 매시 정각, 조회 안 끊고 새로고침
0 * * * * psql -d shop -c &amp;quot;REFRESH MATERIALIZED VIEW CONCURRENTLY m10_big&amp;quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;정의만 먼저 만들고 데이터는 첫 REFRESH 때 채운다. (조합: WITH NO DATA)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE MATERIALIZED VIEW m10_mv2 AS SELECT ... WITH NO DATA;
REFRESH MATERIALIZED VIEW m10_mv2;   -- 이때 처음 채워짐&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;채워졌는지 시스템 카탈로그로 확인한다. (조합: pg_matviews)&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;SELECT matviewname, ispopulated FROM pg_matviews WHERE matviewname = &amp;#39;m10_big&amp;#39;;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;조회 성능을 위해 뷰 위에 추가 인덱스를 건다.&lt;/p&gt;
&lt;pre&gt;&lt;code class=&quot;language-sql&quot;&gt;CREATE INDEX ON m10_big(c DESC);   -- CONCURRENTLY 갱신은 인덱스를 자동 반영&lt;/code&gt;&lt;/pre&gt;</description>
      <category>명령어/DB</category>
      <category>Concurrently</category>
      <category>db</category>
      <category>materialized view</category>
      <category>PostgreSQL</category>
      <category>명령어</category>
      <author>jykim23</author>
      <guid isPermaLink="true">https://forcloud.tistory.com/487</guid>
      <comments>https://forcloud.tistory.com/487#entry487comment</comments>
      <pubDate>Thu, 23 Jul 2026 21:57:51 +0900</pubDate>
    </item>
  </channel>
</rss>