BlueByte
53300Fixed

PostgreSQL: 연결 거절 — sorry, too many clients already

작성 Haneul Seo2026년 9월 16일 업데이트12 min

안녕하세요, BlueByte입니다. 한 시간 전까지 멀쩡하던 앱이 이제 새 연결마다 FATAL: sorry, too many clients already로 죽습니다. 깨진 것은 없습니다 — 이미 열려 있는 세션은 그대로 동작하고, PostgreSQL은 그저 내줄 빈 슬롯이 없을 뿐입니다. 오늘은 이 문구와 그 변형이 뜻하는 것, 지금 무엇이 슬롯을 붙잡고 있는지 세는 법, 원인별 해결, 그리고 다시 차오르지 않게 하는 방법까지 하나씩 짚어보겠습니다.

Postgres가 클라이언트를 돌려보낼 때 FATAL 줄이 뜻하는 것

거절은 쿼리가 실행되기 전, 접속 시점에 일어납니다:

psql: error: connection to server at "db.internal" (10.0.3.12), port 5432 failed:
FATAL:  sorry, too many clients already

다른 문구가 보일 수도 있습니다:

FATAL:  remaining connection slots are reserved for non-replication superuser connections
FATAL:  remaining connection slots are reserved for roles with the SUPERUSER attribute
FATAL:  remaining connection slots are reserved for roles with privileges of the "pg_use_reserved_connections" role

모두 SQLSTATE 53300(too_many_connections)을 달고 있고, 앱 입장에서 뜻은 같습니다 — 내줄 슬롯이 없습니다. 문구는 메이저 버전을 따릅니다. PostgreSQL 16이 reserved_connections 설정과 pg_use_reserved_connections 역할을 추가하면서 이전의 한 줄짜리 메시지를 두 개로 나눴습니다. 드라이버가 SQLSTATE만 노출한다면 53300으로 매칭하세요.

연결 슬롯이 실제로 어디로 가는가

max_connections는 단단한 상한이고, 보통 100이며 initdb가 더 빡빡한 커널 한도를 만났다면 그보다 낮습니다 — 그리고 앱이 쓸 수 있는 수는 아닙니다. 예약분이 위에서 먼저 떨어져 나가기 때문입니다. superuser_reserved_connections는 기본 3이고, 16 이상에서 reserved_connections는 기본 0이라 일반 역할에는 97슬롯이 남습니다.

이 슬롯을 먹는 것은 네 가지입니다:

  • 풀 × 레플리카. 앱 프로세스마다 자기 풀을 유지합니다. 풀 10짜리 pod 10개면 사이트가 한가해도 연결 100개입니다.
  • 열린 트랜잭션에 멈춰 선 세션. 트랜잭션을 열고 외부 API를 기다리는 요청은 그동안 내내 슬롯을 붙잡습니다.
  • 새는 연결 — 마이그레이션 잡, 잊고 켜둔 psql, BI 대시보드, 소켓이 아직 정리되지 않은 죽은 워커.
  • 끊는 속도보다 빨리 다시 붙는 단명 클라이언트 — cron 잡, 서버리스 함수, 호출마다 연결을 여는 헬스체크.

설정을 바꾸기 전에 무엇이 붙어 있는지부터 셉니다

반대쪽에 무엇이 있는지 알기 전에는 max_connections를 올리지 마세요. 슈퍼유저로 접속해서 — 예약분은 바로 이걸 위해 있습니다 — 물어봅니다:

SHOW max_connections;
SHOW superuser_reserved_connections;
 
SELECT state, count(*) FROM pg_stat_activity
 WHERE backend_type = 'client backend'
 GROUP BY state ORDER BY count DESC;
        state        | count
---------------------+-------
 idle                |    71
 idle in transaction |    19
 active              |     7

이 모양이 곧 진단입니다. 대부분 idle이면 풀이 과하게 큽니다. idle in transaction이 많으면 앱이 트랜잭션을 열어둔 채 느린 무언가를 기다리는 중입니다. 대부분 active면 실제로 용량 한계이고 풀러나 더 큰 서버가 필요합니다. 그다음 주인을 찾습니다:

SELECT usename, application_name, client_addr, count(*)
FROM pg_stat_activity WHERE backend_type = 'client backend'
GROUP BY 1, 2, 3 ORDER BY 4 DESC LIMIT 10;

idle 트랜잭션이 붙잡은 슬롯 회수하기

idle in transaction이 대부분이면 그 세션들은 슬롯을 태우면서 동시에 vacuum도 막습니다. 건드리기 전에 가장 나쁜 것부터 나열합니다:

SELECT pid, usename, now() - state_change AS idle_for, left(query, 50) AS last_query
FROM pg_stat_activity
WHERE state = 'idle in transaction' AND now() - state_change > interval '5 minutes'
ORDER BY idle_for DESC;

하나를 종료하면 열린 트랜잭션이 롤백되고 소켓이 닫히며, 정상적인 클라이언트는 다시 붙습니다. idle 세션에서는 안전합니다 — 실행 중인 것이 없으니까요. 겁먹지 않아도 되지만 last_query는 먼저 읽으세요. 멈춰 있는 마이그레이션은 누수와 똑같아 보입니다:

SELECT pg_terminate_backend(pid) FROM pg_stat_activity
WHERE state = 'idle in transaction' AND now() - state_change > interval '15 minutes';

그다음 서버가 강제하게 합니다. 이건 reload로 적용되고 재시작이 필요 없습니다:

ALTER SYSTEM SET idle_in_transaction_session_timeout = '60s';
SELECT pg_reload_conf();

문서는 이유를 분명히 말합니다. 열린 트랜잭션은 "prevents vacuuming away recently-dead tuples", 즉 오래 idle이면 슬롯에 더해 테이블 bloat까지 치릅니다. 형제 설정인 idle_session_timeout은 트랜잭션 밖에서 idle인 세션을 다루는데, 문서가 "be wary of enforcing this timeout on connections made through connection-pooling software"라고 경고하니 앞단에 풀러가 있으면 켜지 마세요.

상한을 올리는 대신 클라이언트 쪽을 조입니다

오래 가는 해결은 대개 슬롯을 늘리는 쪽이 아니라 연결을 줄이는 쪽입니다. 예산을 잡으세요. 모든 레플리카의 풀 크기 + 마이그레이션 + 사람이, max_connections에서 예약분을 뺀 값 아래에 있어야 합니다. 그 산수가 맞지 않으면 앞에 풀러를 두어 많은 클라이언트가 적은 서버 연결을 나눠 쓰게 합니다:

[databases]
app = host=10.0.3.12 port=5432 dbname=app
 
[pgbouncer]
listen_port = 6432
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20

앱을 6432 포트로 보내면 클라이언트 연결 1,000개가 서버 슬롯 20개를 타고 갑니다. transaction 풀링이 그 비율을 만들어 주는 대신 동작도 바꿉니다. 트랜잭션이 아니라 세션에 묶인 것 — LISTEN, advisory lock, 트랜잭션을 넘겨 유지하는 prepared statement — 은 전환 전에 먼저 확인하세요.

max_connections를 올리는 것이 맞는 경우와 그 대가

트래픽이 진짜여서 100이 그냥 작을 때도 있습니다. 올리되 대가를 아세요. 문서는 PostgreSQL이 "sizes certain resources based directly on the value of max_connections"라고 적고 있고 여기엔 공유 메모리가 포함되므로, 크게 올리면 클라이언트가 한 명도 오기 전에 메모리 사용이 늘어납니다.

ALTER SYSTEM SET max_connections = 200;
sudo systemctl restart postgresql
psql -Atc 'SHOW max_connections;'
200

재시작은 선택이 아닙니다 — 문서가 이 파라미터는 "can only be set at server start"라고 못 박고 있어서, 그냥 reload하면 ALTER SYSTEM이 새 값을 조용히 기록해 둔 채 실행 중인 값은 그대로입니다. 관리형 서비스에서는 이 값이 인스턴스 크기에서 파생된 파라미터 그룹에서 옵니다. postgresql.conf가 아니라 거기서 바꾸고, 100이라고 가정하지 말고 제공자의 기본값을 직접 확인해 보세요.

실제 사례: 워커 수를 세 배로 늘린 배포

금요일 오후에 API가 pod 4개에서 12개로 늘었습니다. pod마다 드라이버가 풀 10을 유지하니 max_connections = 100을 상대로 연결 120개를 원한 셈입니다. 이미 붙어 있던 pod는 계속 서비스하는데 새 로그인만 sorry, too many clients already로 실패해서 간헐적인 문제처럼 보였습니다. pg_stat_activity에는 client backend 96개, 그중 88개가 idle이었고 application_name이 전부 같았습니다. 누수는 없었고 새 레플리카 수에 비해 풀이 너무 컸을 뿐입니다. pod당 풀을 5로 낮추자 정상 상태가 60 근처로 내려왔고 연결은 몇 초 만에 회복됐습니다.

고쳐졌는지 확인하고 다시 차오르지 않게 하기

다음 장애를 기다리지 말고 여유분을 지켜봅니다:

SELECT count(*) AS used,
       current_setting('max_connections')::int AS ceiling,
       round(100.0 * count(*) / current_setting('max_connections')::int, 1) AS pct
FROM pg_stat_activity;

피크에 80% 아래면 편안합니다. 그 비율에 알림을 걸고, idle_in_transaction_session_timeout을 유지하고, 배치 작업에는 ALTER ROLE etl CONNECTION LIMIT 5로 자기 역할을 주어 폭주하는 스크립트 하나가 서버를 통째로 끌어내리지 못하게 하세요.

역할별·데이터베이스별 한도와 무엇이 다른가

SQLSTATE 53300을 함께 쓰지만 max_connections와는 상관없는 이웃이 둘 있습니다. FATAL: too many connections for role "app"은 ALTER ROLE ... CONNECTION LIMIT으로 건 역할별 상한에서 옵니다. SELECT rolconnlimit FROM pg_roles WHERE rolname = 'app'으로 확인하세요. FATAL: too many connections for database "app"은 데이터베이스 단위의 같은 개념이고 pg_database의 datconnlimit에 저장됩니다. 두 경우 모두 서버 전체는 거의 비어 있을 수 있어서 max_connections를 올려도 달라지지 않습니다. 메시지가 예약 슬롯을 말한다면 상한에서 예약분을 뺀 지점에 와 있는 것입니다 — 일반 역할은 막히고 슈퍼유저는 여전히 들어갈 수 있는데, 예약분은 정확히 그러라고 있는 것입니다. 다음에 이 오류를 만나면 이 순서를 거꾸로 따라가 보세요.

관련 질문

앱을 재시작하면 한동안 오류가 사라집니다. 왜 그런가요?

재시작하면 옛 풀이 쥐고 있던 연결이 전부 끊겨 슬롯이 즉시 비고, 새 풀이 데워지면서 다시 찹니다. 해결이 아니라 풀이 과하게 크다는 확인으로 보세요 — 같은 트래픽에서 다시 돌아옵니다.

재시작 없이 max_connections를 올릴 수 있나요?

없습니다. 문서는 이 파라미터가 server start에서만 설정된다고 적고 있습니다. ALTER SYSTEM은 새 값을 기록하지만 실행 중인 서버는 재시작 전까지 옛 값을 유지하므로, 재시작 뒤 SHOW max_connections로 반영을 확인하세요.

idle in transaction 세션에 pg_terminate_backend를 써도 안전한가요?

정말로 idle인 세션이면 안전합니다 — 실행 중인 것이 없고, 열린 트랜잭션은 롤백되며, 클라이언트는 닫힌 연결을 받습니다. 다만 last_query를 먼저 읽으세요. 트랜잭션 중간에 멈춘 마이그레이션은 누수와 똑같아 보입니다.

프레임워크가 이미 풀링을 합니다. 그래도 PgBouncer가 필요한가요?

프레임워크 풀은 프로세스 단위라, 실제 숫자는 풀 크기 × 레플리카 × 워커입니다. 그 합이 max_connections에서 예약분을 뺀 값을 넘으면, transaction 모드 풀러가 그 숫자를 서버가 감당할 크기로 접어 줍니다.

슈퍼유저는 접속되는데 앱 역할만 막힙니다. 무엇이 막고 있나요?

기본값 3인 superuser_reserved_connections가 관리자가 들어와 조사할 수 있도록 슬롯을 남겨 둡니다. 지금은 max_connections에서 예약분을 뺀 지점이며, 슬롯이 빌 때까지 앱은 계속 실패합니다.

참고 자료

Haneul Seo

Infrastructure engineer · 10+ years running Linux fleets

같은 카테고리 다른 글

detected dubious ownershipFixed

Git: fatal: detected dubious ownership in repository

Git 2.35.2의 CVE-2022-24765 수정 이후, Git은 작업 트리나 .git 디렉터리의 소유자가 명령을 실행한 사용자와 다르면 저장소를 읽지 않습니다. 컨테이너, CI 작업, sudo 세션, 공유 드라이브에서 주로 나타납니다. 저장소가 내 것이어야 한다면 소유권을 바로잡고, 아니라면 global 설정의 safe.directory에 정확한 경로를 추가하세요 — Git은 이 설정을 저장소 자신의 설정에서는 무시합니다.

Git
failed calling webhookFixed

Kubernetes: Internal error occurred: failed calling webhook

admission webhook이 쓰기 요청 앞에 서 있는데 API server가 응답을 받지 못했고, 기본값인 failurePolicy: Fail이 그 침묵을 거부로 바꾼 것입니다. 메시지 끝부분이 곧 진단입니다 — context deadline exceeded는 호출이 도달하지 못한 것, no endpoints available은 떠 있는 게 없는 것, x509 줄은 API server가 webhook 인증서를 신뢰하지 않는 것입니다. 원인마다 해결이 다르고, 어느 것도 매니페스트 문제가 아닙니다.

Kubernetes
1205Fixed

MySQL: ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction

다른 트랜잭션이 쥐고 있는 행 잠금을 innodb_lock_wait_timeout 만큼 기다리다 포기한 것입니다. sys.innodb_lock_waits 가 막고 있는 세션을 지목하고 KILL 문까지 만들어 주며, blocking_query 가 NULL 이면 블로커가 열린 트랜잭션 위에서 놀고 있다는 뜻입니다. 재시도 로직이 가장 자주 틀리는 지점은 따로 있습니다. 기본값에서는 타임아웃된 문장 하나만 롤백되므로, 트랜잭션은 그대로 열린 채 앞서 잡은 잠금을 전부 쥐고 있습니다.

MySQL
MISCONFFixed

Redis: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk

읽기는 되는데 모든 쓰기가 거부됩니다. 마지막 백그라운드 저장이 실패했고 stop-writes-on-bgsave-error 기본값이 yes 이기 때문입니다. 진짜 원인은 로그에 적혀 있습니다 — 디스크 공간 부족, redis 사용자가 쓸 수 없는 dir, rename 시점의 read-only 마운트, 또는 fork 가 Cannot allocate memory 로 실패하는 경우입니다. 원인을 고치고 BGSAVE 한 번만 돌리면 재시작 없이 쓰기가 돌아옵니다. rdb_last_bgsave_status 가 err 에서 ok 로 바뀝니다. stop-writes-on-bgsave-error no 는 쓰기를 즉시 되살리지만 스냅샷은 여전히 실패한 상태로 두므로, 해결이 아니라 의도한 맞교환으로 다뤄야 합니다.

Redis
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memoryFixed

Node.js: FATAL ERROR: Reached heap limit — JavaScript heap out of memory (종료 코드 134)

V8 힙에는 시스템 메모리와 Node 릴리스에서 유도되는 자기만의 천장이 있고, 가진 RAM 보다 훨씬 낮은 경우가 흔합니다. 빌드나 서버가 거기 닿으면 V8 은 FATAL ERROR: Reached heap limit 과 종료 코드 134 로 스스로 중단합니다. v8.getHeapStatistics().heap_size_limit 으로 실제 한도를 읽은 뒤, 큰 작업은 --max-old-space-size(MiB)나 NODE_OPTIONS 로 올리고, 컨테이너 안에서는 cgroup 한도보다 낮게 잡고, 오래 도는 프로세스의 누수는 --heapsnapshot-near-heap-limit 으로 잡으세요. FATAL ERROR 줄 없이 137 로 끝나면 컨테이너 kill 이지 이 오류가 아닙니다.

Node.js
exec /docker-entrypoint.sh: exec format errorFixed

Docker: 컨테이너 시작 직후 "exec format error" — 플랫폼이 다른 이미지, 에뮬레이터 없음, 셔뱅 없는 스크립트

컨테이너가 첫 명령에서 exec format error 로 끝납니다. 커널의 ENOEXEC, 즉 파일은 있지만 여기서는 실행할 수 없다는 뜻입니다. 실제로는 한 CPU 아키텍처에서 빌드한 이미지(Apple 실리콘 Mac은 linux/arm64 를 만듭니다)를 binfmt_misc 에 QEMU 핸들러가 없는 다른 아키텍처(x86_64 서버)에서 돌리거나, 엔트리포인트 스크립트의 첫 줄이 셔뱅이 아닌 경우입니다. uname -m, docker image inspect, ls /proc/sys/fs/binfmt_misc 로 원인을 가르고, docker buildx build --platform 을 명시(또는 두 플랫폼 매니페스트 목록 발행)하거나, 에뮬레이션이 목적이면 QEMU 등록과 --platform, 스크립트라면 #!/bin/sh 한 줄로 해결합니다.

Docker