왜 ‘Max connections exceeded’ 오류가 발생할까?
‘Max connections exceeded’ 또는 ‘Too many connections’ 오류는 데이터베이스(DB)가 허용하는 동시 연결 수를 애플리케이션이 넘겼을 때 발생합니다. 대개 다음과 같은 상황에서 나타납니다.
- 트래픽 피크 시간대에 연결 풀이 급증
- 커넥션 누수(획득 후 반환되지 않음)
- 장시간 유지되는 트랜잭션 또는 느린 쿼리로 연결이 점유됨
- 서버리스/마이크로서비스 환경에서 서비스별 풀 합이 전체 제한을 초과
- 잘못된 풀 설정(과도한 max size, 무제한 idle 유지)
- DB 프록시/레플리카/캐시 부재로 원본 DB로 연결이 몰림
이 글에서는 연결이 어떻게 동작하는지 이해하고, 애플리케이션/DB/인프라 레벨에서 실질적으로 적용 가능한 최적화 방법과 예시 코드를 통해 이 오류를 예방하는 실전 가이드를 제공합니다.
연결의 기본 개념: 숫자로 이해하는 Max connections
- DB는 동시 연결 수를 제한합니다. 예: MySQL의 max_connections, PostgreSQL의 max_connections.
- 애플리케이션은 통상 커넥션 풀을 사용해 연결을 재사용합니다. 각 프로세스/인스턴스의 풀 크기를 합산하면 총 연결 수가 됩니다.
- 핵심 공식:
- 총 연결 수 = Σ(서비스 인스턴스 수 × 각 인스턴스의 풀 최대값) + 운영/백그라운드/관리용 연결
- DB 제한을 넘지 않도록 설계하고, 여유 버퍼(10~20%)를 반드시 남깁니다.
예시:
- DB max_connections = 500
- 관리용/모니터링/관리자 세션 예약 = 40
- 사용 가능 연결 = 460
- 서비스 A: 10 인스턴스 × 풀 25 = 250
- 서비스 B: 8 인스턴스 × 풀 20 = 160
- 배치/ETL: 5 인스턴스 × 풀 10 = 50
- 합계 = 460 → 간신히 맞춤. 피크 시 여유가 없습니다. 풀이 포화되면 지수적 대기/타임아웃 발생과 함께 오류가 터질 수 있습니다.
- 개선: 서비스 A 풀 20, B 풀 16 등으로 줄이거나, DB 프록시 도입, 읽기 부하를 레플리카로 분산.
빠른 진단 체크리스트
- 현재 연결 상태 보기
- PostgreSQL:
- SELECT count(*) FROM pg_stat_activity;
- SELECT state, count(*) FROM pg_stat_activity GROUP BY 1;
- SELECT pid, state, wait_event, query FROM pg_stat_activity ORDER BY state;
- MySQL:
- SHOW STATUS LIKE 'Threads_connected';
- SHOW FULL PROCESSLIST;
- SELECT * FROM information_schema.PROCESSLIST;
- PostgreSQL:
- 연결 풀 대기 시간 및 포화 여부
- 애플리케이션 로그/메트릭: connection acquire time, pool saturation, wait timeout
- 느린 쿼리/긴 트랜잭션 확인
- PostgreSQL: pg_stat_activity에서 state = 'idle in transaction' 검색, pg_locks 확인
- MySQL: slow query log, performance_schema
- 커넥션 누수 탐지
- 풀 라이브러리 leak detection 옵션 활성화, 장시간 점유 스택트레이스 확인
- 인프라 변화 이력
- 배포/스케일 아웃/서버리스 동시성 증가, DB 파라미터 변경, 네트워크 장애
용량 계획: 풀 사이징 원칙
- 데이터베이스 한계 먼저 확인
- max_connections, CPU/메모리, IOPS, 연결당 메모리 비용
- 관리용 연결 최소 5~10% 예약
- 서비스별 풀 규모 계산
- 인스턴스당 풀 크기 = floor(허용 연결 / 인스턴스 수)
- 단, DB가 처리 가능한 동시 쿼리 수(코어 수 × 2~4 정도)를 초과하지 않도록 조정
- 웹 요청-쿼리 비율, 평균/피크 TPS, 쿼리 시간 분포 고려
- 보수적으로 시작 후 관측 기반 조정
- 작은 풀 + 적절한 대기시간/타임아웃 → 포화시 초과 트래픽을 자연스럽게 조절
- 과도한 풀은 DB 스케줄러를 압박하고 전체 지연을 늘립니다.
애플리케이션 레벨 최적화
1) 커넥션 풀 기본 설정 전략
핵심 파라미터:
- max pool size: 인스턴스당 최대 연결 수. 전체 합계를 DB 한도 내로.
- min idle / min size: 너무 높게 설정하면 불필요한 유휴 연결이 늘어납니다.
- max lifetime: 연결을 주기적으로 교체해 누수/서버 측 keepalive 문제 예방. DB의 wait_timeout/idle_in_transaction_session_timeout보다 짧게.
- idle timeout: 유휴 연결을 회수해 리소스 절약.
- acquire timeout: 풀 포화 시 너무 오래 기다리지 않고 빠르게 실패/우회.
권장 기본값(출발점):
- max size: 10~30 (서비스 규모/쿼리 특성에 따라 조절)
- max lifetime: 30m~2h
- idle timeout: 5m~10m
- acquire timeout: 100ms~2s (사용자 체감 허용치에 맞춤)
2) 언어별 예시 설정
Java (HikariCP):
HikariConfig config = new HikariConfig();
config.setJdbcUrl(System.getenv("JDBC_URL"));
config.setUsername(System.getenv("DB_USER"));
config.setPassword(System.getenv("DB_PASS"));
config.setMaximumPoolSize(20);
config.setMinimumIdle(2);
config.setMaxLifetime(30 * 60 * 1000); // 30분
config.setIdleTimeout(5 * 60 * 1000); // 5분
config.setConnectionTimeout(1000); // 1초
config.setLeakDetectionThreshold(5000); // 5초 이상 점유 시 경고
HikariDataSource ds = new HikariDataSource(config);
Node.js (pg):
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 20, // 최대 커넥션
idleTimeoutMillis: 300000, // 5분
connectionTimeoutMillis: 1000 // 1초
});
// 사용 패턴: 반드시 release 호출
async function getUser(id) {
const client = await pool.connect();
try {
const res = await client.query('SELECT * FROM users WHERE id = $1', [id]);
return res.rows[0];
} finally {
client.release();
}
}
Python (SQLAlchemy):
from sqlalchemy import create_engine
from sqlalchemy.pool import QueuePool
engine = create_engine(
"postgresql+psycopg2://user:pass@host/db",
poolclass=QueuePool,
pool_size=20,
max_overflow=0,
pool_timeout=1, # 1초
pool_recycle=1800, # 30분
pool_pre_ping=True # 연결 상태 확인
)
with engine.connect() as conn:
result = conn.execute("SELECT 1")
Go (database/sql):
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil { log.Fatal(err) }
// 동시성/DB 용량에 맞게 설정
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(2)
db.SetConnMaxLifetime(30 * time.Minute)
db.SetConnMaxIdleTime(5 * time.Minute)
Ruby (ActiveRecord):
# config/database.yml
production:
adapter: postgresql
pool: 20
timeout: 1000
reaping_frequency: 600
반드시 지켜야 할 점:
- 커넥션을 얻었으면 try/finally (defer, ensure)로 반드시 반환
- ORM에서 lazy loading으로 N+1 쿼리 유발하지 않도록 preload/includes 사용
- 트랜잭션 범위를 최소화하고, 블로킹 I/O나 외부 호출을 트랜잭션 내부에 넣지 않기
3) 연결 누수 방지와 탐지
- 라이브러리의 leak detection 기능 활성화 (HikariCP의 LeakDetectionThreshold 등)
- 요청 컨텍스트에 연결/트랜잭션을 저장하고 스코프 종료 시 자동 반환
- 장시간 점유 쿼리 스택 트레이스 수집
- 테스트에서 커넥션 카운트 변화 감시: 요청 반복 후 active connections가 줄지 않으면 누수 의심
4) 백프레셔와 동시성 제한
DB가 처리 가능한 동시 쿼리보다 많은 요청이 풀에 몰리면, 풀 내부 대기가 길어지고 결국 타임아웃/오류가 발생합니다. 애플리케이션 측에서 추가로 동시성 제한을 걸어 트래픽을 평준화하세요.
Node.js 예시 (세마포어):
const { Semaphore } = require('async-mutex');
const dbGate = new Semaphore(50); // 최대 50개 동시 DB 작업
async function safeQuery(fn) {
const [value, release] = await dbGate.acquire();
try {
return await fn();
} finally {
release();
}
}
Go 예시 (버퍼 채널):
var gate = make(chan struct{}, 50)
func withDBGate(fn func() error) error {
gate <- struct{}{}
defer func() { <-gate }()
return fn()
}
효과:
- 급격한 동시성 급증 시 애플리케이션 층에서 자연스러운 대기/완만한 실패 유도
- DB로의 과도한 연결/쿼리 폭주 예방
5) 재시도, 타임아웃, 회로 차단기
- 연결 획득/쿼리에 타임아웃 설정은 필수
- 재시도는 멱등한 읽기 작업에 한정하고, 지수 백오프와 재시도 예산(예: 최대 2~3회) 적용
- 회로 차단기(Circuit Breaker)로 지속적인 실패 시 빠르게 거절하고 복구를 기다림
데이터베이스 레벨 최적화
1) max_connections 조정은 신중하게
- 단순히 값을 크게 키우는 것은 CPU/메모리 및 컨텍스트 스위칭 증가로 오히려 성능 저하
- 기본은 애플리케이션 풀을 줄이거나 쿼리를 최적화하고, 불가피할 때만 소폭 상향
- 관리용 연결 여유를 남김 (PostgreSQL: superuser_reserved_connections)
2) 커넥션 풀러/프록시 활용
- PostgreSQL: PgBouncer (transaction pooling 권장), Patroni/pgpool-II와 용도 분리
- MySQL: ProxySQL, MariaDB MaxScale
- 장점:
- 수천 개 애플리케이션 커넥션을 수십 개의 DB 세션으로 멀티플렉싱
- 서버리스/스파이크 트래픽 완충
- 주의:
- 세션 상태(temporary tables, prepared statements, session variables)를 세션 풀링에 의존하는 경우, transaction pooling 모드에서는 사용 제약
- 애플리케이션이 세션 상태 의존도를 낮추도록 설계
PgBouncer 예시 (transaction pooling):
[databases]
mydb = host=10.0.0.5 port=5432 dbname=mydb
[pgbouncer]
pool_mode = transaction
max_client_conn = 2000
default_pool_size = 50
reserve_pool_size = 10
server_idle_timeout = 300
3) 대기/유휴 세션 정리
- PostgreSQL:
- idle_in_transaction_session_timeout 설정으로 트랜잭션 방치 세션 종료
- statement_timeout으로 장기 실행 쿼리 제한
- MySQL:
- wait_timeout, interactive_timeout 조절
- show processlist로 장기 idle / sleep 세션 정리 (KILL 명령은 신중히)
4) 사용자/역할별 연결 제한
- PostgreSQL:
- ALTER ROLE app_user CONNECTION LIMIT 100;
- MySQL:
- MySQL 8.0는 per-account resource group/세션 한계 관리 조합 고려
5) 성능 튜닝으로 연결 점유 시간 줄이기
- 인덱스 최적화: 스캔 범위 축소
- 적절한 쿼리 계획 유도: ANALYZE/통계 갱신
- 배치 작업은 오프피크에, 작은 배치로 분할
- 큰 쓰기 트랜잭션은 청크로 커밋해 잠금 유지 시간 단축
- 불필요한 SELECT N+1 제거, 필요한 컬럼만 조회, LIMIT 사용
인프라/아키텍처 레벨 전략
1) 읽기 레플리카 활용
- 읽기 트래픽을 레플리카로 분산해 원본의 연결/CPU 부담 낮추기
- 애플리케이션에서 읽기/쓰기 라우팅 구현 또는 프록시 이용
- 주의: 일관성 요구 수준에 따라 읽기 지연(Replication lag) 고려
2) 캐시 도입
- Redis/Memcached 레이어로 핫 키/리스트/카운팅 등 캐싱
- 캐시 만료 정책과 장애 시 레이어드 폴백 설계
- 캐시 적중률 80%→ DB 쿼리 5분의 1로 감소 가능
3) 파티셔닝/샤딩
- 테이블 파티셔닝으로 스캔 범위를 줄여 쿼리 시간 단축 → 연결 점유시간 단축
- 수평 샤딩으로 연결 자체를 분산 (단, 애플리케이션 복잡도 증가)
4) 서버리스/클라우드 환경의 연결 폭증 대책
- AWS: RDS Proxy, Aurora Serverless v2, Lambda 동시성 제한
- GCP: Cloud SQL Auth Proxy + pgbouncer/Cloud SQL Connector with IAM DB Authn
- Azure: Flexible Server + PgBouncer/유연한 연결 핸들링
- 컨테이너/오토스케일 시: 인스턴스당 풀 크기 상한을 엄격하게, Horizontal Pod Autoscaler의 최대치를 고려한 합계로 계획
5) OS/네트워크 설정
- 파일 디스크립터/소켓 제한 확인: ulimit -n, sysctl fs.file-max
- TIME_WAIT/포트 고갈: net.ipv4.ip_local_port_range 조정, tcp_tw_reuse 관련 정책은 신중히
- TCP keepalive로 유령 연결 감지 간격 최적화
모니터링과 알림: 문제를 미리 감지하기
중요 메트릭:
- DB
- active/idle connections, waiting count
- lock wait, deadlocks
- 쿼리 지연 p50/p95/p99, TPS/QPS
- slow queries count, replication lag
- CPU/메모리/IOPS, 체크포인트, autovacuum 상태(Postgres)
- 애플리케이션
- pool in-use/idle/max, acquire wait time, timeout rate
- 오류율(Too many connections, connection refused)
- 요청 지연/스루풋
- 프록시/풀러
- client connections, server connections, pool hit/miss, queue length
대시보드 팁:
- 동일 화면에 애플리케이션 풀 포화 그래프와 DB active connections를 나란히 배치
- 오류 스파이크와 연결 포화/쿼리 지연 상관관계 확인
- 알람 임계치: pool acquire time p95 > 200ms, active connections > 80% 지속 5분, replication lag > 지정값 등
사고 대응: ‘Max connections exceeded’ 발생 시 단계별 복구
- 증상 완화
- 트래픽 차단/저하: 레이트리미팅, WAF, 큐잉
- 애플리케이션 인스턴스의 풀 사이즈 임시 축소, 배치/백그라운드 작업 중단
- 필요 시 PgBouncer 도입/증설로 즉각 완충
- 원인 제거
- 장시간 실행 쿼리/idle in transaction 세션 종료
- 커넥션 누수 의심 서비스 롤백/재시작
- 느린 쿼리 인덱스 추가/플랜 수정
- 재발 방지 조치
- 풀 파라미터 재설정, 세마포어 도입
- 읽기 레플리카/캐시 적용
- 알람 및 대시보드 보강
- 스테이징에서 낮은 max_connections로 카오스 테스트 수행
실전 예시: 마이크로서비스 환경 최적화
상황:
- 6개 서비스가 동일 Postgres에 연결
- Kubernetes에서 HPA로 각 서비스 5~20개 파드로 오토스케일
- DB max_connections = 600, 관리 예약 60 → 사용 가능 540
문제:
- 피크에 파드 합계 80개
- 각 파드 pool max = 15 → 잠재 연결 1200 > 540 → 주기적으로 연결 초과
해결:
- 서비스별 tiering:
- 핵심 쓰기 서비스: pool 10
- 읽기 중심 서비스: pool 6 + 읽기 레플리카로 라우팅
- 배치 서비스: pool 4 + 야간 실행
- PgBouncer(transaction pooling) 도입: default_pool_size 80으로 서버 연결 상한 제어
- 앱 레벨 세마포어: 동시 DB 작업 상한
- 쿼리 정비: 상위 10개 느린 쿼리 인덱스 보강, N+1 제거
- 결과: peak active connections 480로 안정화, p95 응답시간 35% 개선
쿼리/트랜잭션 습관 개선으로 연결 점유 줄이기
- 트랜잭션은 짧게: 비즈니스 로직/외부 API 호출은 트랜잭션 밖에서
- SELECT ... FOR UPDATE는 꼭 필요한 최소 범위에만
- Batch insert/update는 100~1000 단위로 청크 처리
- Prepared statement 재사용으로 파싱/플랜 비용 감소
- 필요한 컬럼만 선택하기(SELECT *) 금지
- 페이징은 OFFSET 대신 keyset pagination을 고려해 깊은 페이지 성능 확보
- 인덱스는 쓰기 비용과의 균형 유지
테스트와 검증: 스테이징에서의 연결 카오스 엔지니어링
- 스테이징 DB의 max_connections를 절반으로 낮춘 상태에서 부하 테스트
- 풀 acquire timeout을 의도적으로 낮추고, 실패 시 백오프/폴백이 정상 동작하는지 확인
- 느린 쿼리 삽입, 네트워크 지연/패킷 손실 시뮬레이션으로 회복 탄력성 검증
- 목표: 사용자-facing 에러 없이 대기/리밋으로 흡수되도록
자주 하는 실수와 방지법
- 실수: 인스턴스 수를 늘리면서 풀 사이즈는 그대로 유지 → 연결 총량 폭증
- 방지: HPA 최대치 기준으로 합계 연결 상한 계산 후 per-pod 풀 제한
- 실수: 오류가 나자 max_connections를 크게 올림
- 방지: 근본 원인(느린 쿼리/누수/풀 과대) 해결 후 소폭 조정
- 실수: ORM 기본 설정을 그대로 사용
- 방지: 풀/프리페치/지연 로딩 전략을 서비스 특성에 맞게 조정
- 실수: 장시간 idle 세션 방치
- 방지: 서버측 timeout 설정과 max lifetime 적용
실용적인 체크리스트
애플리케이션
- 각 인스턴스 풀 max 설정이 보수적이며 합산이 DB 한도 내인가?
- acquire timeout, idle timeout, max lifetime이 적절한가?
- 커넥션 반환이 보장되는 코딩 패턴(try/finally, defer)을 사용하고 있는가?
- 세마포어/큐로 동시 DB 작업 상한을 두었는가?
- 재시도는 멱등 작업에만, 지수 백오프로 제한했는가?
데이터베이스/프록시
- pg_stat_activity/processlist로 활동/대기/idle in transaction을 상시 모니터링하는가?
- idle_in_transaction_session_timeout / wait_timeout이 설정되었는가?
- PgBouncer/ProxySQL 등으로 클라이언트 연결을 멀티플렉싱하는가?
- 관리/운영용 연결을 위한 여유를 남겼는가?
성능/쿼리
- 상위 느린 쿼리에 인덱스가 적절히 적용되었는가?
- 대용량 쓰기는 청크로 분할했는가?
- N+1 쿼리를 제거했는가?
- 캐시/읽기 레플리카로 읽기 부하를 분산했는가?
운영/모니터링
- active/idle connections, pool wait time, timeout rate에 대한 대시보드와 알람이 있는가?
- 배포/오토스케일 시 풀 사이즈와 합계 연결이 자동으로 재계산되는가?
- 스테이징에서 낮은 max_connections로 부하/장애 시나리오를 테스트했는가?
FAQ
Q1. max_connections를 늘리면 해결되나요?
- 단기적으로는 증상 완화가 가능하지만, CPU/메모리/컨텍스트 스위칭 오버헤드가 커져 성능이 악화될 수 있습니다. 먼저 풀 축소, 프록시 도입, 쿼리 최적화, 읽기 분산을 권장합니다.
Q2. PgBouncer transaction pooling을 쓰면 무엇이 달라지나요?
- 각 클라이언트 요청(트랜잭션)을 서버 연결로 짧게 매핑해 서버측 연결 수를 크게 줄일 수 있습니다. 다만 세션 상태(temporary table, session 변수)에 의존하는 코드는 재검토해야 합니다.
Q3. 서버리스(Lambda 등)에서 연결이 폭증합니다. 어떻게 해야 하나요?
- RDS Proxy, PgBouncer, Cloud SQL Proxy 같은 커넥션 풀러/프록시를 필수적으로 사용하고, 함수 동시성을 제한하세요. ORM을 사용 중이면 커넥션 풀 크기를 낮추고, 함수 콜드스타트 시 연결 생성을 지연시키는 전략을 적용하세요.
Q4. 풀 acquire timeout을 늘리는 게 좋을까요?
- 일반적으로 짧게 가져가 빠르게 실패하고 상위 레이어에서 폴백/재시도를 하는 편이 사용자 체감과 시스템 안정성에 유리합니다. 지나치게 길면 폭주 시 전체 지연이 커지고 장애가 장기화됩니다.
Q5. 연결이 자주 끊기는 네트워크 환경에서의 팁은?
- pool_pre_ping(헬스 체크) 활성화, max lifetime/idle timeout 조정, TCP keepalive 설정, 프록시를 통한 연결 안정화가 도움이 됩니다.
결론: 연결은 “많이”가 아니라 “적절히”
‘Max connections exceeded’ 오류를 근본적으로 예방하는 핵심은 연결 수를 무작정 늘리는 것이 아니라, 다음의 균형을 맞추는 데 있습니다.
- 보수적인 커넥션 풀 사이징과 합계 관리
- 프록시/풀러로 연결 멀티플렉싱
- 쿼리/트랜잭션 최적화로 연결 점유 시간 축소
- 애플리케이션의 백프레셔, 타임아웃, 재시도 전략
- 모니터링과 사전 경보, 스테이징에서의 카오스 테스트
위의 원칙과 예시 설정을 바탕으로 시스템을 점진적으로 다듬어 나가면, 트래픽 피크와 예기치 못한 장애 상황에서도 안정적으로 DB 연결을 유지하고 ‘Max connections exceeded’ 오류를 확실히 방지할 수 있습니다.