<?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>Statement_timeout on jyukki's Blog</title><link>https://jyukki.com/tags/statement_timeout/</link><description>Recent content in Statement_timeout on jyukki's Blog</description><generator>Hugo -- 0.147.0</generator><language>ko-KR</language><lastBuildDate>Tue, 08 Sep 2026 10:06:00 +0900</lastBuildDate><atom:link href="https://jyukki.com/tags/statement_timeout/index.xml" rel="self" type="application/rss+xml"/><item><title>백엔드 커리큘럼 심화: PostgreSQL Timeout Budget, 쿼리·락·유휴 트랜잭션을 서로 다른 실패로 다루는 법</title><link>https://jyukki.com/learning/deep-dive/deep-dive-postgresql-timeout-budget-session-guardrails-playbook/</link><pubDate>Tue, 08 Sep 2026 10:06:00 +0900</pubDate><guid>https://jyukki.com/learning/deep-dive/deep-dive-postgresql-timeout-budget-session-guardrails-playbook/</guid><description>PostgreSQL의 statement_timeout, lock_timeout, idle_in_transaction_session_timeout을 하나의 숫자로 뭉개지 않고, 요청 deadline·커넥션 풀·재시도와 연결해 안전한 DB 실행 시간 예산을 설계하는 실무 플레이북입니다.</description><content:encoded><![CDATA[<p>서비스가 느려질 때 DB timeout을 하나 추가하면 안전해 보인다. 하지만 <code>statement_timeout = 500ms</code> 같은 설정 하나는 세 가지 서로 다른 문제를 섞는다. SQL이 CPU·I/O 때문에 500ms 넘게 <strong>실행</strong>되는 경우, 빠른 SQL이 다른 트랜잭션 때문에 500ms <strong>대기</strong>하는 경우, 트랜잭션만 열어 둔 채 애플리케이션이 아무 SQL도 보내지 않는 <strong>유휴</strong> 경우다. 실패 원인이 다르면 사용자에게 돌려줄 오류, 재시도 가능성, 고칠 대상도 다르다.</p>
<p>이 글의 목표는 timeout 값을 외우는 것이 아니라, <strong>요청 전체 deadline에서 DB 예산을 역산하고 실패 유형마다 다른 PostgreSQL guardrail을 배치하는 것</strong>이다. <a href="/learning/deep-dive/deep-dive-timeout-retry-backoff/">Timeout/Retry/Backoff 설계</a>의 상위 deadline 원칙, <a href="/learning/deep-dive/deep-dive-connection-pool-sizing-saturation-playbook/">Connection Pool Sizing과 Saturation</a>의 대기열 관점, <a href="/learning/deep-dive/deep-dive-database-locking-contention-playbook/">Database Locking과 Contention</a>의 lock 진단, <a href="/learning/deep-dive/deep-dive-jpa-transaction-boundaries/">JPA Transaction Boundary</a>의 트랜잭션 범위를 함께 적용한다.</p>
<p>참고한 공식 문서:</p>
<ul>
<li><a href="https://www.postgresql.org/docs/current/runtime-config-client.html">PostgreSQL Client Connection Defaults</a></li>
<li><a href="https://www.postgresql.org/docs/current/sql-set.html">PostgreSQL SET</a></li>
<li><a href="https://www.postgresql.org/docs/current/transaction-iso.html">PostgreSQL Transaction Isolation</a></li>
</ul>
<h2 id="이-글에서-얻는-것">이 글에서 얻는 것</h2>
<ul>
<li><code>statement_timeout</code>, <code>lock_timeout</code>, <code>idle_in_transaction_session_timeout</code>이 각각 어느 실패를 차단하는지 구분합니다.</li>
<li>API deadline, JDBC/ORM timeout, PostgreSQL 세션 설정을 충돌 없이 정렬하는 방법을 익힙니다.</li>
<li>정상적인 긴 batch와 위험한 긴 transaction을 다른 role·다른 connection path로 분리할 수 있습니다.</li>
<li>timeout 취소율, lock wait, connection acquire latency를 이용해 숫자를 조정하는 운영 기준을 만듭니다.</li>
</ul>
<h2 id="핵심-개념이슈">핵심 개념/이슈</h2>
<h3 id="1-timeout은-계층마다-한-개의-시간-예산을-공유해야-한다">1) timeout은 계층마다 한 개의 시간 예산을 공유해야 한다</h3>
<p>사용자가 800ms 안에 응답을 받아야 하는 조회 API를 예로 들어 보자. 여기에 gateway timeout 800ms, 애플리케이션 future timeout 800ms, JDBC socket timeout 800ms, PostgreSQL <code>statement_timeout</code> 800ms를 각각 넣으면 안전망이 네 겹이 되는 것이 아니다. 연결을 빌리는 시간, JSON 직렬화, 네트워크 왕복, fallback 판단을 전혀 남기지 않아 가장 바깥 계층이 먼저 요청을 버리고 DB 작업은 계속될 수 있다.</p>
<p>실무에서는 바깥 deadline에서 안쪽 budget을 역산한다. 다음 수치는 출발점이며 서비스의 p95와 SLO에 맞춰 조정한다.</p>
<table>
  <thead>
      <tr>
          <th>계층</th>
          <th style="text-align: right">800ms API의 예산 예시</th>
          <th>역할</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>ingress/client deadline</td>
          <td style="text-align: right">800ms</td>
          <td>사용자에게 약속한 최종 한계</td>
      </tr>
      <tr>
          <td>앱의 DB 호출 구간</td>
          <td style="text-align: right">600ms</td>
          <td>acquire, SQL, 결과 매핑을 포함한 상한</td>
      </tr>
      <tr>
          <td>pool acquire</td>
          <td style="text-align: right">80~120ms</td>
          <td>포화 상황에서 빠르게 거절할 기준</td>
      </tr>
      <tr>
          <td>PostgreSQL statement</td>
          <td style="text-align: right">450~550ms</td>
          <td>SQL 실행과 서버 내부 대기에 쓰는 예산</td>
      </tr>
      <tr>
          <td>응답 직렬화·오류 처리 여유</td>
          <td style="text-align: right">100~150ms</td>
          <td>timeout을 정상적인 504/503으로 바꿀 시간</td>
      </tr>
  </tbody>
</table>
<p>중요한 관계는 <strong>바깥 timeout &gt; 안쪽 timeout</strong>이다. DB가 500ms에서 취소되고 앱이 600ms 안에 cleanup과 오류 분류를 마치는 편이, 앱이 먼저 500ms에 포기하고 DB가 800ms까지 실행되는 것보다 예측 가능하다. 긴 보고서 다운로드나 월말 정산을 온라인 API의 500ms 예산에 억지로 넣지 말고 비동기 job 또는 별도 read replica·role로 분리한다.</p>
<h3 id="2-세-postgresql-timeout은-관찰-대상이-다르다">2) 세 PostgreSQL timeout은 관찰 대상이 다르다</h3>
<p>PostgreSQL은 모두 세션 또는 트랜잭션 범위로 설정할 수 있지만, 중단하는 상태는 다르다.</p>
<table>
  <thead>
      <tr>
          <th>설정</th>
          <th>멈추는 대상</th>
          <th>먼저 확인할 지표</th>
          <th>흔한 오해</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td><code>statement_timeout</code></td>
          <td>실행 중인 SQL statement 전체</td>
          <td>query p95/p99, rows, buffer read, cancel 수</td>
          <td>느린 실행만 제한한다고 생각함</td>
      </tr>
      <tr>
          <td><code>lock_timeout</code></td>
          <td>lock을 얻기 위한 대기</td>
          <td><code>wait_event_type=Lock</code>, blocker, deadlock</td>
          <td>statement_timeout의 축소판이라고 생각함</td>
      </tr>
      <tr>
          <td><code>idle_in_transaction_session_timeout</code></td>
          <td>transaction을 열고 SQL 없이 idle인 세션</td>
          <td>transaction age, <code>state</code>, xmin, pool 사용량</td>
          <td>긴 batch를 제한하는 값으로 사용함</td>
      </tr>
  </tbody>
</table>
<p><code>statement_timeout</code>은 SQL이 시작된 뒤의 전체 시간을 센다. 따라서 table scan, 느린 네트워크 스토리지, 다른 transaction의 lock 대기 모두 이 값에 닿을 수 있다. 반대로 <code>lock_timeout</code>은 lock을 기다리는 순간에만 의미가 있다. 주문 row를 갱신하는 SQL의 정상 실행은 20ms인데 간헐적으로 3초 wait가 생긴다면, 2초 statement timeout만 두기보다 150~300ms lock timeout으로 빠르게 충돌을 드러내고 blocker를 고치는 쪽이 보통 더 안전하다.</p>
<p><code>idle_in_transaction_session_timeout</code>은 특히 과소평가된다. <code>BEGIN</code> 후 외부 결제 API를 호출하거나 화면 입력을 기다리면, 해당 세션은 SQL을 하지 않아도 snapshot·row lock·connection을 오래 잡을 수 있다. 이 상태는 autovacuum과 pool 모두에 해롭다. 긴 SQL을 10분 실행하는 batch와는 구분해야 한다. 전자는 <code>active</code>, 후자는 <code>idle in transaction</code>이라는 점부터 다르다.</p>
<h3 id="3-값은-전역-하나가-아니라-작업-성격별로-정한다">3) 값은 전역 하나가 아니라 작업 성격별로 정한다</h3>
<p>전역 default를 바꾸는 것은 가장 큰 blast radius를 가진다. 우선은 online read, online write, worker, migration을 나눈다.</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">-- 요청 트랜잭션 안에서만 적용한다. commit/rollback 뒤에는 사라진다.
</span></span></span><span style="display:flex;"><span><span style="color:#6272a4"></span><span style="color:#ff79c6">BEGIN</span>;
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">SET</span> <span style="color:#ff79c6">LOCAL</span> statement_timeout <span style="color:#ff79c6">=</span> <span style="color:#f1fa8c">&#39;500ms&#39;</span>;
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">SET</span> <span style="color:#ff79c6">LOCAL</span> lock_timeout <span style="color:#ff79c6">=</span> <span style="color:#f1fa8c">&#39;200ms&#39;</span>;
</span></span><span style="display:flex;"><span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">SELECT</span> id, status, updated_at
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">FROM</span> orders
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">WHERE</span> customer_id <span style="color:#ff79c6">=</span> $<span style="color:#bd93f9">1</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">ORDER</span> <span style="color:#ff79c6">BY</span> updated_at <span style="color:#ff79c6">DESC</span>
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">LIMIT</span> <span style="color:#bd93f9">50</span>;
</span></span><span style="display:flex;"><span><span style="color:#ff79c6">COMMIT</span>;
</span></span></code></pre></div><p><code>SET LOCAL</code>은 connection pool의 다음 사용자에게 설정이 새지 않게 하는 중요한 장치다. pool checkout 직후 일반 <code>SET statement_timeout</code>을 쓰면 reset 실패나 예외 경로에서 다른 endpoint가 같은 제한을 물려받을 수 있다. 반면 migration·reindex·대량 backfill처럼 일부러 긴 시간이 필요한 작업은 온라인 role의 예산을 올려서 해결하지 않는다. 별도 DB role, 별도 connection string, 명시된 maintenance window, 진행률·rollback 계획을 둔다.</p>
<p>초기 정책을 아래처럼 잡을 수 있다.</p>
<table>
  <thead>
      <tr>
          <th>작업 경로</th>
          <th style="text-align: right">statement timeout</th>
          <th style="text-align: right">lock timeout</th>
          <th style="text-align: right">idle transaction timeout</th>
          <th>승격 조건</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>동기 조회 API</td>
          <td style="text-align: right">450~800ms</td>
          <td style="text-align: right">100~250ms</td>
          <td style="text-align: right">15~30초</td>
          <td>p99·cancel·5xx가 기준선 안</td>
      </tr>
      <tr>
          <td>짧은 쓰기 API</td>
          <td style="text-align: right">700~1,500ms</td>
          <td style="text-align: right">150~400ms</td>
          <td style="text-align: right">15~30초</td>
          <td>멱등 결과 확인과 retry budget 보유</td>
      </tr>
      <tr>
          <td>비동기 worker</td>
          <td style="text-align: right">업무별 5~60초</td>
          <td style="text-align: right">0.5~2초</td>
          <td style="text-align: right">30~60초</td>
          <td>lease·재처리·중단 복구 검증</td>
      </tr>
      <tr>
          <td>migration/ETL</td>
          <td style="text-align: right">명시적 per-job 값</td>
          <td style="text-align: right">명시적 per-job 값</td>
          <td style="text-align: right">운영 정책에 맞춤</td>
          <td>change window·rollback·관측 준비</td>
      </tr>
  </tbody>
</table>
<p>이 숫자는 보편 정답이 아니다. 예를 들어 200ms p99 API에서 1,500ms statement timeout은 사실상 사고를 숨긴다. 반대로 partition maintenance가 필요한 DB에서 500ms 전역 제한은 운영 절차를 깨뜨린다. 그래서 각 값에 owner와 적용 scope를 붙인다.</p>
<h3 id="4-취소는-성공도-실패도-아닌-불확실성을-만들-수-있다">4) 취소는 성공도 실패도 아닌 &lsquo;불확실성&rsquo;을 만들 수 있다</h3>
<p>PostgreSQL이 <code>statement_timeout</code>으로 statement를 취소하면 SQLSTATE <code>57014</code>를 돌려준다. 하지만 애플리케이션이 받은 client-side timeout만으로는 DB가 rollback됐는지, 이미 commit했는지 알 수 없는 경우가 있다. 특히 write 요청을 재시도할 때 &ldquo;응답이 없었으니 실패&quot;라고 가정하면 중복 주문·중복 발송이 생긴다.</p>
<p>다음 순서로 복구 경로를 나눈다.</p>
<ol>
<li><strong>읽기</strong>: 결과가 없어도 재시도가 안전한지, 같은 요청이 cache·replica에 부하를 더하지 않는지 확인한다.</li>
<li><strong>멱등 write</strong>: idempotency key나 unique business key로 결과를 먼저 조회한다. 이미 완료됐으면 이전 결과를 반환한다.</li>
<li><strong>비멱등 write</strong>: 자동 재시도하지 않는다. operation ledger나 수동 검토 상태로 넘긴다.</li>
<li><strong>lock timeout</strong>: 재시도 전 blocker와 lock 순서를 확인한다. 같은 경쟁이 남아 있으면 retry는 대기열을 키운다.</li>
</ol>
<p>이는 <a href="/learning/deep-dive/deep-dive-timeout-retry-backoff/">Timeout/Retry/Backoff 설계</a>에서 말한 retry budget과 같은 원칙이다. timeout 비율이 1%에서 5%로 올랐을 때 재시도 2회를 모두 허용하면 DB 호출량이 얼마나 늘어나는지 계산하지 않으면, 보호 장치가 포화 가속기가 된다.</p>
<h2 id="실무-적용">실무 적용</h2>
<h3 id="1-관측표를-먼저-만들고-값을-바꾼다">1) 관측표를 먼저 만들고 값을 바꾼다</h3>
<p>설정 변경 전에 7일 기준선을 기록한다. query fingerprint마다 <code>calls</code>, p50/p95/p99, rows, shared/local block read, temporary file, SQLSTATE <code>57014</code> 횟수를 수집한다. 동시에 <code>pg_stat_activity</code>에서 <code>state</code>, transaction 시작 시각, <code>wait_event_type</code>, <code>wait_event</code>를 확인하고, pool의 acquire p95와 active/idle connection 수를 같은 시각축에 둔다.</p>
<p>원인별로 첫 액션을 정해 둔다.</p>
<table>
  <thead>
      <tr>
          <th>관측</th>
          <th>먼저 할 일</th>
          <th>timeout 조정 여부</th>
      </tr>
  </thead>
  <tbody>
      <tr>
          <td>특정 SQL의 p99만 상승, Lock wait 없음</td>
          <td>실행 계획·인덱스·row 수·I/O 확인</td>
          <td>원인 수리 전 endpoint canary만</td>
      </tr>
      <tr>
          <td>Lock wait와 blocker가 동시 증가</td>
          <td>긴 transaction·갱신 순서·DDL 분석</td>
          <td>짧은 lock timeout을 우선 검토</td>
      </tr>
      <tr>
          <td><code>idle in transaction</code> 60초 초과가 반복</td>
          <td>코드에서 외부 I/O·사용자 대기 분리</td>
          <td>idle timeout을 role에 적용</td>
      </tr>
      <tr>
          <td>pool acquire p95가 DB statement보다 큼</td>
          <td>pool 크기·동시성·cancel 누수 확인</td>
          <td>statement 값만 낮추지 않음</td>
      </tr>
  </tbody>
</table>
<p><code>pg_cancel_backend</code>를 수동으로 자주 쓰게 된다면 timeout 값이 없어서가 아니라 query ownership과 budget이 문서화되지 않았다는 신호일 수 있다. 취소한 세션 ID, SQL fingerprint, 호출 endpoint, 원인 분류를 남겨야 다음 alert가 같은 문제를 재현한다.</p>
<h3 id="2-10-canary에서-취소율과-사용자-결과를-같이-본다">2) 10% canary에서 취소율과 사용자 결과를 같이 본다</h3>
<p>처음에는 latency-sensitive read endpoint 하나에만 <code>SET LOCAL statement_timeout</code>을 적용한다. traffic의 10%로 30분 이상 관찰하고, 아래 abort 조건 중 하나라도 맞으면 값을 더 낮추지 않고 원인을 분석한다.</p>
<ul>
<li><code>57014</code> cancel 비율이 0.1%를 넘거나 기준선의 2배가 된다.</li>
<li>해당 endpoint의 5xx 또는 fallback 비율이 기준선보다 0.2%p 이상 증가한다.</li>
<li>pool acquire p95가 20% 이상 늘거나 active connection이 지속적으로 80%를 넘는다.</li>
<li>같은 query fingerprint의 p99가 낮아졌는데 retry count가 증가해 총 DB calls가 10% 이상 늘어난다.</li>
</ul>
<p>성공은 평균 latency 하락이 아니다. <strong>p99, cancel, retry, pool, business success가 같이 안정적인 상태</strong>다. 이 다섯 가지가 안정된 뒤에 유사 endpoint로 확장한다. lock timeout은 hot row를 다루는 write에서 따로 canary하고, deadlock·serialization failure와 동일 오류로 집계하지 않는다.</p>
<h3 id="3-코드와-운영-예외를-분리한다">3) 코드와 운영 예외를 분리한다</h3>
<p>Spring/JPA 같은 ORM 환경에서는 transaction interceptor와 connection pool initialization SQL이 실제 설정 범위를 결정한다. request filter에서 deadline을 만들고 service method는 <code>@Transactional</code> 안에서 <code>SET LOCAL</code>을 적용한다. 다만 트랜잭션을 연 뒤 외부 HTTP 호출, 파일 업로드, message publish를 기다리면 idle guardrail을 피하려고 timeout을 올리는 대신 설계가 더 나빠진다. 데이터 변경을 commit한 뒤 outbox·worker로 후속 작업을 넘기거나, 외부 결과를 먼저 얻고 짧은 DB transaction을 연다.</p>
<p>DBA가 전역값을, 애플리케이션이 endpoint 예외를 각각 몰래 관리하면 회귀가 생긴다. 다음 네 가지를 하나의 config registry에 둔다.</p>
<ul>
<li>role/database 기본값과 변경 owner</li>
<li>endpoint 또는 job별 <code>SET LOCAL</code> 값과 요청 deadline</li>
<li>migration/maintenance의 별도 connection path와 만료 시각</li>
<li>alert threshold, rollback 값, 예외 검토 일자</li>
</ul>
<p>이 기록은 timeout을 &ldquo;성능 튜닝 상수&quot;가 아니라 운영 계약으로 만든다.</p>
<h2 id="트레이드오프주의점">트레이드오프/주의점</h2>
<p>첫째, 짧은 timeout은 대기열을 줄일 수 있지만 실제 서비스 용량을 만들지는 않는다. 인덱스 누락, 잘못된 join, hot row, 느린 스토리지가 원인이면 cancel 수만 늘고 사용자는 더 자주 실패한다. <a href="/learning/deep-dive/deep-dive-query-plan-regression-guardrails/">Query Plan Regression Guardrail</a>처럼 계획과 데이터 분포를 먼저 점검해야 한다.</p>
<p>둘째, <code>lock_timeout</code>을 너무 낮추면 정상적인 짧은 경합도 오류가 되어 write 성공률이 떨어질 수 있다. 반대로 너무 길면 lock queue 뒤에 요청이 쌓인다. business operation의 허용 지연, 멱등성, blocker 제거 난이도로 결정한다.</p>
<p>셋째, cancel된 write의 결과는 앱이 모를 수 있다. HTTP 504를 받은 사용자에게 &ldquo;실패&quot;라고 단정하지 말고 idempotency ledger를 조회할 수 있어야 한다. timeout 수치만 바꾸고 결과 확인 경로를 만들지 않으면 신뢰성은 개선되지 않는다.</p>
<p>넷째, managed PostgreSQL 또는 proxy/PgBouncer 환경에서는 parameter 적용 scope와 reset 동작이 다를 수 있다. production 전에 <code>SHOW</code>로 실제 세션 값을 확인하고, transaction pooling에서 <code>SET LOCAL</code>이 commit/rollback 뒤 사라지는지 integration test로 검증한다.</p>
<h2 id="체크리스트-또는-연습">체크리스트 또는 연습</h2>
<ul>
<li><input disabled="" type="checkbox"> 사용자-facing API 하나의 최종 deadline에서 network, pool acquire, SQL, serialization 예산을 역산했다.</li>
<li><input disabled="" type="checkbox"> <code>statement_timeout</code>, <code>lock_timeout</code>, <code>idle_in_transaction_session_timeout</code>의 owner·scope·rollback 값을 문서화했다.</li>
<li><input disabled="" type="checkbox"> query p95/p99, lock wait, <code>57014</code>, transaction age, pool acquire p95를 같은 dashboard에서 본다.</li>
<li><input disabled="" type="checkbox"> online API와 migration/ETL이 다른 role 또는 connection path를 사용한다.</li>
<li><input disabled="" type="checkbox"> timeout write는 idempotency key 또는 결과 ledger로 커밋 여부를 확인한다.</li>
<li><input disabled="" type="checkbox"> 10% canary의 abort 조건과 30분 관측 지표를 배포 전에 합의했다.</li>
</ul>
<p>연습으로 주문 조회 API 하나를 고른다. 현재 p99와 pool acquire p95를 측정한 뒤 800ms 전체 deadline을 각 계층에 나눠 보자. 다음으로 같은 DB에서 <code>idle in transaction</code> 세션을 하나 만들고, 실행 중인 10초 <code>pg_sleep</code> statement와 무엇이 다른지 <code>pg_stat_activity</code>에서 비교한다. 세 상태가 구분돼야 timeout 값도 안전하게 분리할 수 있다.</p>
<h2 id="관련-글">관련 글</h2>
<ul>
<li><a href="/learning/deep-dive/deep-dive-timeout-retry-backoff/">Timeout/Retry/Backoff 설계</a></li>
<li><a href="/learning/deep-dive/deep-dive-connection-pool-sizing-saturation-playbook/">Connection Pool Sizing과 Saturation</a></li>
<li><a href="/learning/deep-dive/deep-dive-database-locking-contention-playbook/">Database Locking과 Contention</a></li>
<li><a href="/learning/deep-dive/deep-dive-jpa-transaction-boundaries/">JPA Transaction Boundary</a></li>
<li><a href="/learning/deep-dive/deep-dive-query-plan-regression-guardrails/">Query Plan Regression Guardrail</a></li>
</ul>
]]></content:encoded></item></channel></rss>