
지난주 일요일 새벽 2시쯤 알림이 왔다. 결제 API 쪽에서 prepared statement "S_3" does not exist 에러가 분당 수백 건씩 찍히고 있었다. 그 전날 PgBouncer를 1.25.1로 올린 게 화근이었다. 안 그래도 PG16.11에 PgBouncer 1.25.1 조합에서 prepared statement 관련 버그 리포트가 올라온 게 있었는데, 우리도 그 케이스에 정확히 걸려든 거였다.
이번 글은 그날 새벽 내가 뭘 보고 뭘 했고, 최종적으로 어떻게 마무리됐는지에 대한 기록이다. 깔끔한 해결책 같은 건 아직 없다. 워크어라운드로 일단 막아둔 상태.
배경: 왜 transaction mode + prepared statement를 켰나
작년에 우리 팀은 PgBouncer를 1.21로 올리면서 max_prepared_statements를 200으로 켰다. 그때까지 transaction pooling을 쓰면서 prepared statement는 포기하고 있었는데, 1.21부터 둘이 같이 쓸 수 있게 되면서 결제 API의 P99 레이턴시가 꽤 떨어졌다. 같은 쿼리를 분당 수천 번 날리는 워크로드라 plan caching이 효과가 컸다.
근데 이 기능에 조건이 좀 있다. 우리가 알고 쓴 부분은 두 가지였다.
첫째, protocol-level prepared statement만 지원된다. 즉 클라이언트가 PREPARE foo AS ... 같은 SQL을 직접 날리면 안 된다. JDBC나 libpq의 PQprepare처럼 프로토콜 레벨로 준비해야 PgBouncer가 가로채서 백엔드 세션마다 알아서 재준비해준다. 우리는 백엔드가 Go(pgx), Node(node-postgres), Java(HikariCP + JDBC)였고 다 protocol-level이라 이건 문제가 없었다.
둘째, max_prepared_statements는 PgBouncer 메모리에 캐싱되는 prepared statement 개수다. 클라이언트가 들고 있는 ps 개수가 이 값을 넘어가면 LRU로 쫓겨난다. 200으로 잡았던 건 우리 워크로드가 그 정도면 충분히 들어간다고 봤기 때문이었다.
여기까지는 알고 시작한 거였다. 작년 12월부터 이번 5월까지 6개월 가까이 잘 돌았다.
사고 그날: 무슨 일이 일어났나
5월 16일 토요일에 PgBouncer를 1.21.2에서 1.25.1로 올렸다. CVE 패치 적용이 메인 목적이었고, changelog에 prepared statement 관련 fix들이 몇 개 들어있는 게 보여서 오히려 안정성이 더 좋아질 거라고 생각했다. PG는 16.11 그대로.
배포 후 토요일 낮까지는 멀쩡했다. 트래픽이 평일의 30% 수준이라 그랬던 것 같다. 일요일 밤부터 트래픽이 평일 수준으로 올라오면서 일이 터졌다.
새벽 2시 14분, 첫 알림. 결제 API 5xx 비율이 0.02% → 1.8%로 튀었다. Datadog에서 백엔드 로그를 보니까 죄다 같은 패턴이었다.
ERROR: prepared statement "S_3" does not exist (SQLSTATE 26000)
근데 이상한 게, 모든 호출이 실패하는 게 아니라 산발적으로 1-3% 정도가 실패하고 있었다. 같은 엔드포인트에서 어떤 요청은 성공하고 어떤 요청은 실패했다.
처음에 의심한 것들 (다 틀렸음)
새벽 정신으로 머릿속에 떠오른 가설들을 순서대로 적어보면 이렇다.
처음엔 애플리케이션 측 prepared statement 캐시 문제인 줄 알았다. pgx나 node-postgres가 들고 있는 ps 핸들이 어쩌다 stale해진 줄. 그래서 백엔드 파드를 롤링 재시작했는데 5분 만에 똑같은 에러가 다시 떴다. 패턴이 같았다.
두 번째로 의심한 건 connection churn이었다. PgBouncer가 server_idle_timeout으로 백엔드 세션을 닫는 시점에 클라이언트가 들고 있던 ps 핸들이 무효화되는 거 아닌가. server_idle_timeout을 600초에서 3600초로 늘려봤다. 효과 없음. 에러율은 그대로.
세 번째는 max_prepared_statements 부족. 우리 쿼리 종류가 늘어나서 200으로 부족해진 거 아닌가 싶었다. PgBouncer 콘솔에서 SHOW PREPARED_STATEMENTS 찍어봤는데 평균 60-70개 정도. 한참 여유 있었다.
새벽 3시 40분쯤에야 이게 PgBouncer 버그일 수도 있다는 데에 생각이 미쳤다.
진짜 원인 찾기
GitHub issues에서 "prepared statement does not exist 1.25" 검색해보니까 2026년 2월에 이미 비슷한 리포트가 올라와 있었다. PG 16.11 + PgBouncer 1.25.1 조합에서 특정 race condition이 있다는 거였다. 백엔드 세션이 클라이언트한테 할당되는 순간과 PgBouncer가 ps를 재준비하는 순간 사이에 race가 있어서, 드물게 재준비를 건너뛰는 케이스가 있다는 내용이었다.
해당 이슈에 우리랑 똑같은 증상이 적혀있었다. 산발적, 1-3%대 에러율, 같은 ps 이름 반복.
근데 그 이슈가 아직 fix되지 않은 상태였다. 새벽 4시, 결제 API 5xx가 계속 올라오는 중. 일단 막아야 했다.
워크어라운드: max_prepared_statements = 0
가장 빠른 워크어라운드는 prepared statement 자체를 끄는 거였다. PgBouncer에서 max_prepared_statements = 0으로 바꾸고 reload하면 트랜잭션 풀링은 그대로 두고 ps 기능만 비활성화할 수 있다. 클라이언트는 모든 쿼리를 simple query로 날리게 된다.
[pgbouncer]
pool_mode = transaction
max_prepared_statements = 0
reload 직후 에러는 멈췄다. P99는 18ms에서 26ms로 올라갔지만 5xx는 0%로 떨어졌다.
이 상태로 일단 새벽 4시 30분에 한숨 돌렸다. 그날 아침까지 모니터링하다가 점심때 PgBouncer를 1.24.x로 다운그레이드했고, 거기서 다시 max_prepared_statements = 200을 켰다. 1.24.x에서는 같은 증상 없음.
회고
며칠 지나서 차분히 정리해보니 빠진 게 몇 개 있었다.
스테이징에서 prod 트래픽 미러링을 안 돌렸다. PgBouncer 마이너 업데이트라 만만하게 봤는데, 1.21 → 1.25면 prepared statement 관련 코드만 봐도 변경량이 꽤 된다. 부하 패턴이 다르면 race condition이 안 드러난다. 다음부턴 PgBouncer 같은 데이터 경로의 컴포넌트는 마이너라도 트래픽 미러링 한 번 거치고 올리기로 했다.
알림 임계치도 좀 손봐야 했다. 5xx 비율이 1%를 넘어야 페이저가 울리게 돼 있었는데, 결제 API라면 0.3% 정도부터는 깨워줘야 한다. 이건 SLO 재정의 중.
그리고 PgBouncer 버전 픽스. 우리는 그동안 helm chart의 latest 태그를 어느 정도 신뢰하고 있었는데, 이제는 명시적 minor 픽스로 바꿨다. CVE 패치는 patch level만 받도록.
끝으로
PgBouncer 1.25.x에서 같은 문제 겪은 분들 있을 거 같다. 우리는 일단 1.24.x로 내려갔지만, 1.26 이후에 이 race가 fix되면 다시 올려볼 생각이다. 혹시 다른 워크어라운드 쓰고 계신 분 있으면 댓글로 공유해주시면 좋겠다.
다음 글에서는 이 사고를 계기로 SLO/error budget을 어떻게 재정의했는지 써볼까 한다.
'IT > DB 운영' 카테고리의 다른 글
| Aurora PostgreSQL 14 → 16 Blue/Green 업그레이드에서 삽질한 새벽 이야기 (0) | 2026.05.20 |
|---|---|
| PgBouncer transaction pooling, prepared statement 함정에서 빠져나온 이야기 (0) | 2026.05.14 |
| Redis Cluster slot migration 중에 P99이 4초까지 튄 새벽 (0) | 2026.05.07 |
| Velero 1.15 데이터 무버 마이그레이션 삽질기 (0) | 2026.05.07 |
| PostgreSQL logical replication, 17/18 와서 다시 들여다본 이야기 (0) | 2026.05.04 |