
지난주 새벽에 또 깼다. 4시쯤 핸드폰이 미친 듯이 울리는데, 화면을 보니 prepared statement "S_42" does not exist 에러가 분당 수천 건씩 쌓이고 있었다. 결제 API 쪽이었는데, 그날따라 더 짜증났던 건 — 두 시간 전에 내가 직접 머지한 PR 때문이라는 게 거의 확실했기 때문이다.
결론부터 말하면 pgbouncer transaction pooling 모드 + 새 JDBC 드라이버 조합이 문제였다. 근데 거기까지 가는 과정이 정말 길었다.
새벽 4시, 일단 롤백부터
운영 룰은 단순하다. 새벽에 깨면 일단 의심되는 배포부터 롤백. 두 시간 전 머지한 PR이 두 개 있었다 — 하나는 pgbouncer 버전 업(1.18 → 1.23), 다른 하나는 백엔드 서비스의 PostgreSQL 드라이버 업그레이드(pgjdbc 42.7.x). 둘 다 한 사이클에 같이 갔다는 것 자체가 일단 자책 포인트였다. 분리해서 갔어야 했다.
pgbouncer 먼저 1.18로 내렸다. 에러 안 사라짐. 그래서 드라이버도 같이 내렸다. 사라졌다. 그래서 두 시간을 뺏긴 게 화가 났지만 일단 운영은 살아났다.
문제는 그 다음이었다. 드라이버 업데이트는 보안 패치 때문에 어쨌든 올려야 했다. CVE 이슈가 걸려 있었고, 보안팀에서 이미 데드라인이 잡혀 있는 상태였다.
다음 날, 도대체 뭐가 문제였나
복기해보면 사실 단서는 충분했다. pgbouncer 1.21에서 transaction mode에서도 prepared statement를 지원하기 시작했고, 우리는 1.23으로 올라간 상태였다. 근데 정작 max_prepared_statements를 명시적으로 설정한 적이 없었다. 기본값은 0, 즉 prepared statement 추적을 안 한다.
이게 왜 문제냐면 — 새 pgjdbc는 기본적으로 protocol-level prepared statement를 적극적으로 사용한다. 옛날 드라이버는 어느 정도 threshold가 있어서 쿼리가 5번 이상 실행되면 그제서야 prepare를 시도하는 식이었는데, 최근 버전은 그게 더 공격적이다. 그래서 pgbouncer 입장에서는 처음 보는 named statement가 계속 날아오는데, 트랜잭션이 끝나서 백엔드 커넥션이 다른 클라이언트한테 넘어가버리면 그 statement는 사라진다. 그래서 다음에 같은 statement를 실행하려고 하면 does not exist가 터진다.
옛 버전(1.18)에서는? prepared statement를 아예 지원 안 했기 때문에 우리 드라이버가 자동으로 fallback 모드로 동작했다. 알아서 평문 쿼리로 보내고 있었다. 그래서 깨지지 않았던 거다.
해결 — 사실 한 줄
# pgbouncer.ini
max_prepared_statements = 200
이거 한 줄이다. 200은 그냥 시작값이고, 실제로는 트래픽 보고 더 늘려야 할 수도 있다. pgbouncer가 LRU 캐시로 statement를 추적하면서 백엔드 커넥션이 바뀔 때마다 알아서 re-prepare를 해준다. 1.21부터 들어간 기능인데, 지금은 production에서 거의 쓸 만한 수준까지 안정화된 것 같다.
값을 정할 때 주의할 점은, 이게 백엔드 커넥션 1개당 캐시 크기라는 거다. default_pool_size에 곱해서 메모리를 가늠해야 한다. 우리는 풀 사이즈가 100이라 100 * 200 = 20000개의 statement가 메모리에 떠 있을 수 있다는 얘긴데, 한 statement당 수십 KB는 잡으니까 단순 계산해도 수백 MB는 잡아먹는다. 모니터링이 필수다.
한 가지 더 — JDBC 쪽 prepare threshold
JDBC 쪽도 손볼 게 있었다. pgjdbc는 prepareThreshold 파라미터가 있는데, 기본값은 5다. 즉 같은 쿼리가 5번 실행되면 그제서야 server-side prepare로 전환한다. 우리는 이걸 명시적으로 1로 내려서 더 적극적으로 prepare를 쓰고 있었다 (이전 누군가가 성능 튜닝한답시고 넣어둔 듯).
근데 사실 pgbouncer가 잘 받쳐주면 1이든 5든 안전한 게 맞긴 한데, 만약 max_prepared_statements가 부족해서 캐시 미스가 나기 시작하면 prepare 비용이 오히려 더 커진다. 그래서 잠깐 디버깅하는 동안에는 일부러 prepareThreshold=0으로 설정해서 prepare를 아예 끄고, pgbouncer 캐시가 안정될 때까지 평문 쿼리로 우회시켰다. 며칠 후에 다시 5로 올렸다.
다음에 또 안 깨려면
배포 회고에서 가장 욕먹은 건 두 가지 변경을 같은 윈도우에 같이 올린 거였다. 사실 보안 데드라인 압박이 있어서 그랬는데, 그게 변명이 안 된다는 건 팀원들도 알고 나도 안다. 보안 패치가 급해도 인프라 컴포넌트 버전업이랑 같이 가는 건 무조건 분리해야 한다는 걸 다시 한번 배웠다.
그리고 staging에서 transaction-pool 모드를 실제 부하 수준으로 한 번도 못 돌려봤다는 것도 문제였다. staging은 트래픽이 거의 없어서 prepared statement 누수 같은 게 안 드러난다. 부하 테스트를 신설하기로 했고, 이건 또 다른 길고 지루한 작업이 될 것 같다.
혹시 비슷한 환경이라 transaction mode 쓰면서 max_prepared_statements 안 만지신 분 있으면 한번 들여다보시길. 평소엔 멀쩡하다가 어느 날 갑자기 터지는 종류의 문제다.
'IT > DB 운영' 카테고리의 다른 글
| RDS PostgreSQL 16→17 업그레이드 새벽 작업기 — replication slot에 또 당했다 (0) | 2026.06.10 |
|---|---|
| Postgres에서 한 줄 설정으로 막을 수 있는 idle 사고 (0) | 2026.06.05 |
| PgBouncer transaction mode에 prepared statement 켰다가 새벽에 깬 이야기 (0) | 2026.05.25 |
| Aurora PostgreSQL 14 → 16 Blue/Green 업그레이드에서 삽질한 새벽 이야기 (0) | 2026.05.20 |
| PgBouncer transaction pooling, prepared statement 함정에서 빠져나온 이야기 (0) | 2026.05.14 |