Understand it step by step (한국어로 이해 → 영어로 말하기)
- 1
증상부터. 배포 직후 API가 전부 500을 뱉는다. 앱 로그에 찍힌 건 'connection refused'가 아니라 Postgres가 직접 보낸 'sorry, too many clients already'다. DB가 죽은 게 아니다 — 연결 자리 100개가 다 차서 새 손님을 거절하는 중이다.
✅ Fix: 에러 문구를 그대로 읽는다. 'too many clients'는 DB 장애가 아니라 연결 한도 초과라는 뜻이다. 앱을 재시작하면 잠깐 낫다가 트래픽이 돌아오면 다시 터진다.
🔧 도구:psqlapp logs
FATAL: sorry, too many clients already FATAL: remaining connection slots are reserved for non-replication superuser connections🗣 영어로 말해“Our API started failing because Postgres ran out of connection slots, not because it crashed.”
checking microphone…
- 2
왜 이런 일이 생기나. Postgres는 연결 1개마다 OS 프로세스를 하나씩 띄운다. 연결 하나가 메모리 5~10MB를 먹고, 새로 여는 데 TCP + 인증으로 수십 ms가 든다. 그래서 기본 한도가 100으로 낮다. 우리 상황: 서버 10대 × 풀 20개 = 200개 시도. 한도 100(superuser 예약 3개 빼면 97)을 두 배로 넘었다.
✅ Fix: 내 풀 설정을 전체 합산으로 계산한다. 공식은 하나다: 서버 수 × 풀 크기 ≤ max_connections − 예약분.
🔧 도구:psql
SHOW max_connections; -- 100 SHOW superuser_reserved_connections; -- 3 → 앱이 쓸 수 있는 건 97개 -- 서버 10대 × 풀 20 = 200개 시도 > 97개 한도
🗣 영어로 말해“Each Postgres connection is a whole OS process, so connections are expensive and limited.”
checking microphone…
- 3
감이 아니라 숫자로 본다. pg_stat_activity는 지금 붙어 있는 연결 전부를 보여준다. 결과: active 5개, idle 92개. 실제로 일하는 연결은 5개뿐인데 97자리 중 92자리를 노는 연결이 차지하고 있다. 가장 오래 논 연결은 41분째다. 이게 '연결은 꽉 찼는데 DB는 한가한' 상태다.
✅ Fix: active 대 idle 비율부터 본다. idle이 압도적이면 문제는 쿼리 속도가 아니라 연결 관리다. 여기서 EXPLAIN 들고 쿼리 튜닝부터 시작하면 헛다리다.
🔧 도구:psqlpg_stat_activity
SELECT state, count(*) FROM pg_stat_activity GROUP BY state; -- state | count -- active | 5 -- idle | 92 SELECT max(now() - state_change) FROM pg_stat_activity WHERE state = 'idle'; -- 00:41:07 → 41분째 노는 연결
🗣 영어로 말해“I checked pg_stat_activity and most connections were idle, not actually doing work.”
checking microphone…
- 4
지루하지만 맞는 첫 수: 각 앱의 풀 크기를 줄여서 합산이 한도 안에 들어오게 고정한다. 10대 × 8 = 80 < 97. 풀이 크다고 빨라지지 않는다 — DB 코어가 8개면 동시에 일할 수 있는 쿼리도 그 근처다. 풀 20은 자리만 차지하던 셈이다.
⚖️ Trade-off: 트래픽이 튀면 요청이 풀 앞에서 줄을 선다. 하지만 줄 서기는 복구되고, DB 연결 고갈은 전체 장애다. 기다림이 더 싼 비용이다.
✅ Fix: HikariCP면 maximumPoolSize=8, connectionTimeout=3000. 풀이 꽉 찼을 때 빨리 실패하게 해서 요청이 무한정 매달리지 않게 한다.
🔧 도구:HikariCP
# HikariCP (앱 서버 10대 기준) maximumPoolSize=8 # 10 × 8 = 80 ≤ 97 minimumIdle=8 connectionTimeout=3000 # 풀이 꽉 차면 3초 후 실패 (무한 대기 금지)
🗣 영어로 말해“The boring fix is capping each app's pool so the total math fits.”
checking microphone…
- 5
규모가 커지면 앱 쪽 계산이 깨진다. 서버 50대면 풀을 2개씩만 줘도 100개다. 이제 PgBouncer를 DB 앞에 세운다. transaction 모드에서는 트랜잭션이 끝나는 순간 연결을 회수해 다음 손님에게 준다. 클라이언트 2,000개 → 실제 Postgres 연결 25개로 압축된다.
⚖️ Trade-off: 홉이 하나 늘어 왕복마다 약간의 지연이 붙고, PgBouncer 자체가 새 단일 장애점이 된다. 보통 2대 이상 띄우고 앞에 로드밸런서를 둔다.
✅ Fix: 앱은 PgBouncer(6432 포트)를 DB인 줄 알고 붙는다. 코드는 거의 그대로 두고 연결 문자열만 바꾸면 된다.
🔧 도구:PgBouncer
; pgbouncer.ini [pgbouncer] pool_mode = transaction default_pool_size = 25 ; 실제 Postgres 연결 max_client_conn = 2000 ; 앱 쪽에서 받는 연결
🗣 영어로 말해“At scale, I'd put PgBouncer in front and share twenty-five real connections.”
checking microphone…
- 6
transaction 모드의 대가: '같은 세션이 계속 내 것'이라는 보장이 사라진다. SET으로 바꾼 세션 변수는 다음 트랜잭션에서 남의 연결에 가 있을 수 있고, LISTEN/NOTIFY나 advisory lock처럼 세션에 붙는 기능은 깨진다. 면접에서 이 한 줄이 '진짜 써 본 사람' 신호다.
⚖️ Trade-off: 우회 경로를 두면 연결 수 계산에 예외가 생긴다. 직결 연결도 한도에 포함되니 그 몫을 예약분으로 따로 잡아야 한다.
✅ Fix: 세션 기능이 꼭 필요한 연결(마이그레이션, LISTEN 용)은 풀러를 우회해 DB에 직결하고, 일반 트래픽만 PgBouncer로 보낸다.
🔧 도구:PgBouncerpsql
-- transaction pooling에서 깨지는 것들 SET work_mem = '256MB'; -- 다음 트랜잭션은 다른 연결일 수 있음 LISTEN order_events; -- 세션 고정이 필요 → 동작 안 함 SELECT pg_advisory_lock(42); -- 락을 쥔 연결이 남에게 넘어감
🗣 영어로 말해“The trade-off is losing session state, so I avoid session-level features in transaction mode.”
checking microphone…
6단계 영어를 다 말하면 → 이 메커니즘 전체를 영어로 설명할 수 있게 된다.