Published on

Aurora PostgreSQL remaining connection slots are reserved로 서비스가 멈출 때 RDS Proxy와 pgBouncer와 max_connections 튜닝으로 커넥션 폭주를 영구 차단하는 실전 체크리스트

Authors

서버가 멀쩡한데 API가 전부 타임아웃으로 무너지고, DB 로그에는 remaining connection slots are reserved for non-replication superuser connections만 반복될 때가 있습니다. Aurora PostgreSQL에서 커넥션이 한계치에 도달하면 신규 연결이 막히면서 애플리케이션이 재시도 폭주를 일으키고, 그 재시도가 다시 커넥션을 더 만들며 장애가 증폭됩니다.

이 글은 “일단 재시작” 같은 응급처치가 아니라, RDS Proxy + pgBouncer + max_connections/풀 튜닝으로 커넥션 폭주를 구조적으로 봉쇄하는 체크리스트를 제공합니다. (Aurora PostgreSQL 기준이지만 일반 RDS PostgreSQL에도 대부분 동일하게 적용됩니다.)

에러 메시지의 의미와 실제로 벌어지는 일

PostgreSQL은 max_connections까지 커넥션을 허용하되, 일부 슬롯을 슈퍼유저/내부용으로 예약합니다. 그래서 일반 유저는 최대치에 도달하기 전에 아래 오류를 만나게 됩니다.

  • remaining connection slots are reserved for non-replication superuser connections

장애 시 흔한 패턴은 다음과 같습니다.

  1. 트래픽 급증 또는 배치/크론 폭주
  2. 애플리케이션 커넥션 풀이 커지거나(혹은 풀 없이 매 요청 연결) 커넥션 누수 발생
  3. DB 커넥션이 max_connections 근처까지 상승
  4. 신규 연결 실패 → 애플리케이션 타임아웃/재시도 증가
  5. 재시도가 더 많은 연결 시도 → 커넥션 스톰

핵심은 “DB가 느려서”가 아니라 연결 관리가 무너져서 서비스가 멈춘다는 점입니다.

1단계 장애 중 즉시 확인할 것 10분 체크

1) 현재 커넥션이 어디서 오는지

-- 전체 커넥션 수
select count(*) from pg_stat_activity;

-- 유저/DB별
select usename, datname, count(*)
from pg_stat_activity
group by 1,2
order by 3 desc;

-- 애플리케이션/클라이언트별(설정돼 있다면 application_name이 매우 유용)
select application_name, client_addr, state, count(*)
from pg_stat_activity
group by 1,2,3
order by 4 desc;

여기서 application_name이 비어있으면, 이후 재발 방지를 위해 반드시 넣는 것을 권장합니다(아래 Best Practice 참고).

2) 유휴 커넥션이 쌓였는지(Idle, Idle in transaction)

select state, count(*)
from pg_stat_activity
group by 1;

-- 특히 위험한 idle in transaction
select pid, usename, application_name, client_addr, xact_start, query_start, state, query
from pg_stat_activity
where state = 'idle in transaction'
order by xact_start asc
limit 20;
  • idle이 많으면 풀/프록시가 커넥션을 “잡고만” 있는 상태일 수 있습니다.
  • idle in transaction은 더 심각합니다. 락을 잡고 있을 가능성이 높고, 커넥션도 오래 점유합니다.

3) 긴 쿼리/락으로 인해 풀이 고갈되는지

-- 오래 실행 중인 쿼리
select pid, now() - query_start as runtime, usename, application_name, state, query
from pg_stat_activity
where state <> 'idle'
order by runtime desc
limit 20;

-- 락 대기
select a.pid, a.application_name, a.query, l.locktype, l.mode, l.granted
from pg_stat_activity a
join pg_locks l on l.pid = a.pid
where a.datname = current_database()
order by l.granted, a.pid;

락/슬로우 쿼리가 원인이면 커넥션 수를 늘려도 해결되지 않습니다. 오히려 더 많은 커넥션이 락 대기에 합류해서 장애가 커집니다.

4) 당장 서비스 복구가 필요할 때(최소한의 안전 조치)

  • 문제 클라이언트/배치의 트래픽을 차단하거나 스케일 인
  • 정말 불가피하면 특정 세션 종료(주의: 트랜잭션 롤백/부작용 가능)
-- 특정 조건의 세션 종료 예시(신중히)
select pg_terminate_backend(pid)
from pg_stat_activity
where application_name = 'batch-worker'
  and state = 'idle';

2단계 근본 원인 분류 커넥션 폭주가 생기는 5가지 전형

  1. 요청당 새 연결(풀 미사용) 또는 서버리스/짧은 실행 프로세스가 다수
  2. 커넥션 풀 설정 과대(예: pod 200개 × 풀 50 = 10,000 커넥션 시도)
  3. 커넥션 누수(예외 경로에서 close 미호출)
  4. 재시도 폭탄(타임아웃이 짧고, 지수 백오프 없이 즉시 재시도)
  5. Idle in transaction(ORM/트랜잭션 경계 실수)

특히 4번은 DB뿐 아니라 외부 API에서도 동일한 장애 증폭 패턴을 만듭니다. 재시도 설계를 체계화하려면 OpenAI API 429 폭탄 대응 실전 가이드 지수 백오프 큐잉 토큰 버짓으로 비용과 지연을 함께 줄이기의 백오프/큐잉 전략을 그대로 응용할 수 있습니다.

3단계 영구 차단 아키텍처 RDS Proxy와 pgBouncer를 어떻게 조합할까

결론부터 권장 조합

  • 대부분의 웹 서비스: App → RDS Proxy → Aurora PostgreSQL
  • 초고밀도 워커/배치/서버리스가 많고 커넥션이 매우 짧고 빈번: App → pgBouncer(트랜잭션 풀링) → RDS Proxy → Aurora

둘 다 “풀”이지만 역할이 다릅니다.

RDS Proxy의 강점

  • AWS 관리형, 장애조치/비밀관리/인증 통합이 편함
  • DB 커넥션을 재사용하여 앱 커넥션 폭주를 흡수
  • IAM auth, Secrets Manager 연동
  • 읽기/쓰기 엔드포인트 분리 구성에 유리

pgBouncer의 강점

  • **트랜잭션 풀링(transaction pooling)**으로 커넥션 수를 극적으로 줄일 수 있음
  • 매우 많은 클라이언트(수천~수만)를 적은 서버 커넥션으로 수용 가능

단, pgBouncer 트랜잭션 풀링은 세션 상태를 유지하지 않기 때문에 아래 기능을 쓰면 주의가 필요합니다.

  • 세션 단위 임시테이블, SET LOCAL/세션 변수 의존, prepared statement/세션 고정이 필요한 워크로드

4단계 max_connections 튜닝의 함정과 올바른 접근

장애를 겪고 나면 가장 먼저 max_connections를 올리고 싶어집니다. 하지만 PostgreSQL에서 커넥션은 “공짜”가 아닙니다.

  • 커넥션이 늘면 프로세스/메모리 사용량 증가
  • 컨텍스트 스위칭 증가
  • 락 경합/캐시 효율 악화

실무 접근 순서

  1. 애플리케이션 풀의 총합을 계산해서 DB가 감당 가능한 수준으로 제한
  2. RDS Proxy/pgBouncer로 커넥션 재사용률을 올림
  3. 그래도 부족하면 그때 max_connections를 올리되, 메모리/워크로드와 함께 조정

계산 예시(가장 흔한 실수 방지)

  • Kubernetes: replicas 80 × pool_max 20 = 최대 1600
  • 여기에 배치 200개가 동시에 뜨고 각자 5개 연결 = +1000
  • 합계 2600 커넥션 시도 → Aurora 기본/권장 범위를 쉽게 초과

이때 DB의 max_connections를 3000으로 올리는 건 대개 악수입니다. 대신 아래가 정답입니다.

  • 앱 풀을 pool_max=5로 낮추고
  • RDS Proxy로 재사용
  • 배치/워커는 큐 기반으로 동시성 제한

5단계 RDS Proxy 실전 설정 체크리스트

1) Proxy 엔드포인트로 애플리케이션 연결 강제

  • 기존 DB 엔드포인트를 직접 쓰는 코드/환경변수를 전부 제거
  • 마이그레이션/관리툴만 예외적으로 DB 직접 연결(운영 정책으로 제한)

2) Connection borrowing timeout 설정

  • 너무 길면 앱이 오래 대기하다가 타임아웃 폭탄
  • 너무 짧으면 불필요한 실패 증가

애플리케이션의 요청 타임아웃과 정합성을 맞추세요. 예: API 타임아웃 3초면 borrowing timeout을 1~2초 수준으로.

3) Max connections percent / idle client timeout 점검

  • Proxy가 DB로 여는 커넥션 상한을 정해 “DB 보호막” 역할을 하게 해야 합니다.
  • Idle client timeout을 통해 유휴 클라이언트 연결을 정리하여 폭주 시 회복력을 높입니다.

4) 모니터링 지표

  • DatabaseConnections(Aurora)
  • ProxyDatabaseConnections / ProxyClientConnections(RDS Proxy)
  • Borrowing latency/timeout 관련 지표

핵심은 ClientConnections는 많아도 DatabaseConnections는 안정적으로 유지되는지입니다.

6단계 pgBouncer 트랜잭션 풀링 적용 체크리스트

1) pool_mode는 보통 transaction

session은 효과가 제한적이고, 커넥션 절감 목적이면 transaction이 핵심입니다.

2) server_reset_query와 세션 상태

트랜잭션 풀링에서는 커넥션이 사용자에게 “빌려졌다가” 반환되므로, 반환 시 상태 초기화가 중요합니다.

예시 설정(개념용):

[pgbouncer]
pool_mode = transaction
max_client_conn = 5000
default_pool_size = 50
reserve_pool_size = 20
reserve_pool_timeout = 2
server_reset_query = DISCARD ALL
server_idle_timeout = 60
query_wait_timeout = 5
  • DISCARD ALL은 안전하지만 비용이 있습니다. 워크로드에 맞춰 최적화하세요.
  • query_wait_timeout으로 무한 대기를 막아 장애 증폭을 줄입니다.

3) prepared statement/ORM 호환성 점검

  • 일부 드라이버는 prepared statement 캐시를 세션에 강하게 결합합니다.
  • 트랜잭션 풀링에서는 prepared statement를 끄거나, 드라이버 옵션으로 호환 모드가 필요할 수 있습니다.

7단계 애플리케이션 커넥션 풀 튜닝 Best Practice

1) 풀 크기는 “인스턴스당”이 아니라 “전체 합”으로 설계

  • 목표: DB에 실제로 열리는 커넥션을 예측 가능하게 제한
  • 권장: 서비스별로 상한선을 정하고, HPA/오토스케일 시에도 총합이 폭발하지 않게 설계

2) 타임아웃 3종 세트를 반드시 맞추기

  • connect timeout
  • query/statement timeout
  • pool acquire timeout

이 셋이 서로 모순되면 재시도 폭탄이 생깁니다.

3) 재시도는 지수 백오프 + 지터 + 상한

DB 연결 실패 시 즉시 재시도는 장애를 키웁니다. 백오프/큐잉 패턴은 외부 API뿐 아니라 DB에도 동일하게 적용됩니다. 설계 원리는 위에서 언급한 글을 참고해도 좋습니다: OpenAI API 429 폭탄 대응 실전 가이드 지수 백오프 큐잉 토큰 버짓으로 비용과 지연을 함께 줄이기

4) application_name을 반드시 박아라(추적 비용 0, 효과 큼)

예시:

  • Java JDBC: ApplicationName=my-api
  • psql: PGAPPNAME=my-admin
  • 대부분의 ORM/드라이버는 DSN 파라미터 또는 연결 옵션으로 지원

장애 시 pg_stat_activity에서 범인을 30초 만에 찾을 수 있습니다.

5) idle in transaction 방지 가드레일

DB 레벨:

-- 트랜잭션 유휴 제한(초)
alter database mydb set idle_in_transaction_session_timeout = '30s';

-- 쿼리 시간 제한(워크로드에 맞게)
alter database mydb set statement_timeout = '5s';

애플리케이션 레벨:

  • 트랜잭션 범위를 최소화
  • 요청 처리 전체를 트랜잭션으로 감싸지 않기
  • 예외 경로에서도 commit/rollback 보장

비동기 작업에서 태스크가 정리되지 않아 연결이 회수되지 않는 유형도 종종 있습니다. 파이썬 asyncio 기반 워커라면 Python asyncio Task was destroyed but it is pending 경고 원인 5가지와 완벽 해결법처럼 “종료되지 않은 작업”이 커넥션을 물고 있는지 점검하세요.

8단계 트러블슈팅 자주 겪는 함정과 해결

함정 1 max_connections만 올렸더니 더 자주 죽는다

  • 증상: 커넥션은 늘었는데 CPU가 치솟고 락 대기가 늘며 지연이 폭증
  • 해결: 커넥션 상한을 낮추고(앱/프록시), 슬로우 쿼리/락을 먼저 해결

함정 2 RDS Proxy를 붙였는데도 커넥션이 줄지 않는다

  • 원인: 앱이 여전히 DB 엔드포인트로 직접 연결하거나, Proxy 설정에서 DB 커넥션 상한이 너무 높음
  • 해결: 엔드포인트 강제, 보안그룹/파라미터로 직접 접근 차단, Proxy Max connections percent 재설정

함정 3 pgBouncer 트랜잭션 풀링 후 오류가 늘었다

  • 원인: 세션 상태 의존(임시테이블, SET, prepared statement)
  • 해결: 해당 기능 사용 지점 제거/대체, 드라이버 옵션 조정, 필요 시 특정 워크로드만 session 풀링으로 분리

함정 4 커넥션은 정상인데도 간헐적 타임아웃

  • 원인: 풀 acquire 대기열이 길어짐(커넥션은 제한했지만 처리량이 부족)
  • 해결: DB 튜닝(인덱스/쿼리), 캐시 도입, 워커 동시성 제한, 배치 분산

결론 커넥션 폭주는 튜닝이 아니라 설계로 막는다

remaining connection slots are reserved는 DB가 보내는 “연결을 더 받으면 죽는다”는 명확한 신호입니다. 해결의 핵심은 단순히 max_connections를 올리는 것이 아니라 다음 3가지를 동시에 갖추는 것입니다.

  1. 프록시/풀 계층(RDS Proxy, 필요 시 pgBouncer)로 DB를 보호
  2. 애플리케이션 풀과 재시도를 통제해서 커넥션 상한을 예측 가능하게 만들기
  3. DB 레벨 타임아웃(statement/idle in transaction)으로 최악의 세션을 자동 정리

오늘 할 일은 간단합니다. pg_stat_activity로 상위 커넥션 소스를 식별하고, 앱 풀 총합을 계산한 뒤, RDS Proxy를 “단일 진입점”으로 강제하세요. 그 다음 트래픽/워커 특성에 따라 pgBouncer 트랜잭션 풀링을 추가하면, 커넥션 폭주로 서비스가 멈추는 장애를 반복하지 않게 됩니다.