# 프로덕션의 악몽, Database Connection Pool 고갈: 디버깅부터 예방까지
Table of Contents
“갑자기 서버가 멈췄어요”
백엔드 개발자로 일하다 보면 한 번쯤은 이런 상황을 마주하게 됩니다. CPU도 여유롭고, 메모리도 충분하고, 네트워크도 정상인데 API 요청들이 줄줄이 타임아웃을 뱉으며 실패하는 상황. 로그를 까보니 범인은 바로 이 녀석입니다.
“Connection pool exhausted”
이 에러가 무서운 이유는 **‘재시작하면 잠깐 멀쩡해진다’**는 점 때문입니다. 서버를 재부팅하면 커넥션 풀이 초기화되니 당연히 잘 돌아가겠죠. 하지만 근본적인 원인을 찾지 못하면 몇 시간 뒤, 혹은 며칠 뒤 똑같은 악몽이 반복됩니다.
커넥션 풀 고갈은 분산 시스템에서 가장 까다로운 장애 중 하나입니다. DB 서버 자체는 멀쩡한데 애플리케이션만 죽어나는 경우가 많거든요. 보통 다음 세 가지 패턴 중 하나가 원인일 확률이 90% 이상입니다.
- 커넥션 누수 (Leak): 빌려간 커넥션을 반납하지 않음
- 슬로우 쿼리 (Slow Query): 쿼리가 너무 오래 걸려서 커넥션을 붙들고 있음
- 타임아웃 미설정: 문제가 생긴 커넥션을 무한정 기다림
이 글에서는 제가 실무에서 겪었던 경험을 바탕으로, 커넥션 풀 고갈 문제를 진단하고 해결하는 과정을 정리해보려 합니다.
Connection Pool, 왜 쓰는 걸까요?
본론으로 들어가기 전에, 왜 우리가 커넥션 풀을 쓰는지부터 잠깐 짚고 넘어갑시다.
데이터베이스 연결은 비싼 작업입니다. TCP 3-way handshake를 맺고, SSL 인증을 하고, 로그인 과정을 거쳐 세션을 맺는 과정은 생각보다 많은 리소스를 잡아먹습니다. 매 요청마다 이 과정을 반복하면 배보다 배꼽이 더 커지겠죠.
그래서 우리는 미리 일정 개수(예: 10개, 50개)의 커넥션을 맺어두고, 필요할 때 빌려 썼다가 다시 돌려주는 풀링(Pooling) 방식을 사용합니다.
풀링의 함정
문제는 이 ‘풀’의 크기가 유한하다는 데 있습니다. 만약 풀 크기가 100개인데 101번째 요청이 들어오면 어떻게 될까요? 101번째 요청은 누군가 커넥션을 반납할 때까지 **대기(Wait)**하게 됩니다.
이 대기 시간이 길어지면 클라이언트는 타임아웃을 겪게 되고, 서버 스레드들은 커넥션을 기다리느라 블로킹 상태에 빠집니다. 결국 눈덩이처럼 불어난 대기열 때문에 전체 서비스가 마비되는 것이죠.
주범 1: 커넥션 누수 (Connection Leak)
가장 흔하면서도, 막상 찾으려면 눈빠지는 원인입니다. 개발자가 코드를 짤 때 close()나 release()를 깜빡하는 경우죠.
범인은 ‘에러 처리’ 속에 있다
정상적인 로직에서는 대부분 커넥션을 잘 반납합니다. 문제는 예외가 발생했을 때입니다.
# 위험한 코드 예시
def get_user_data(user_id):
conn = pool.getconn()
cursor = conn.cursor()
# 여기서 에러가 나면?
# except 블록이 없거나 finally가 없다면 conn은 영원히 반납되지 않습니다.
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
user = cursor.fetchone()
if not user:
return None # 여기서도 반납을 안 하고 리턴해버릴 수 있죠.
pool.putconn(conn) # 여기까지 도달하지 못하면 누수 발생!
return user
위 코드에서 cursor.execute 중에 에러가 나거나, 중간에 return을 해버리면 pool.putconn(conn)이 실행되지 않습니다. 이렇게 샌 커넥션이 하나둘 쌓이다가, 결국 풀 전체가 좀비 커넥션으로 가득 차게 됩니다.
해결책: try-finally 혹은 Context Manager
언어 불문하고, 리소스 반납은 무조건 보장되어야 합니다.
# 안전한 코드 예시
def get_user_data_safe(user_id):
conn = pool.getconn()
try:
cursor = conn.cursor()
cursor.execute("SELECT * FROM users WHERE id = %s", (user_id,))
return cursor.fetchone()
except Exception as e:
logger.error(f"DB Error: {e}")
raise
finally:
# 무슨 일이 있어도 커넥션은 반납한다.
pool.putconn(conn)
대부분의 현대적인 프레임워크나 ORM은 이런 처리를 자동으로 해주지만, 직접 SQL을 실행하거나 레거시 코드를 다룰 땐 반드시 체크해야 합니다. HikariCP 같은 라이브러리는 leakDetectionThreshold 옵션을 제공해서, 오랫동안 반납되지 않는 커넥션을 감지하고 로그를 남겨주기도 하니 적극 활용하세요.
주범 2: 슬로우 쿼리 (Slow Query)
“커넥션 풀이 꽉 찼다”는 건 “모든 커넥션이 바쁘다”는 뜻입니다. 왜 바쁠까요? 쿼리가 느려서 그렇습니다.
만약 어떤 쿼리가 실행되는 데 10초가 걸린다고 칩시다. 초당 10번만 요청이 들어와도, 10초 뒤에는 커넥션 100개가 모두 이 쿼리를 처리하느라 묶여있게 됩니다. (10 req/s * 10 sec = 100 connections)
인덱스의 중요성
개발 환경에서는 데이터가 적으니 풀 스캔(Full Scan)을 해도 빠릅니다. 하지만 프로덕션 데이터가 쌓이면 이야기가 달라지죠.
-- created_at에 인덱스가 없다면?
SELECT * FROM orders
WHERE created_at > '2025-01-01'
ORDER BY id DESC;
이런 쿼리가 갑자기 트래픽을 받으면 순식간에 DB 커넥션을 다 잡아먹습니다.
해결책: 모니터링과 타임아웃
- Slow Query Log: DB 설정에서 1~2초 이상 걸리는 쿼리를 기록하도록 설정하고 주기적으로 확인하세요.
- Statement Timeout: 쿼리 자체에 타임아웃을 거세요. “이 쿼리는 3초 안에 안 끝나면 그냥 실패 처리해”라고 정해두는 겁니다. 무한정 기다리는 것보다 빨리 실패하는 게 전체 시스템을 살리는 길입니다.
# 예: PostgreSQL 설정
options="-c statement_timeout=3000" # 3초 타임아웃
주범 3: 무한 대기 (Missing Timeout)
네트워크 문제나 DB 장애로 연결이 안 될 때, 애플리케이션이 멍하니 기다리고 있는 경우입니다.
Fail Fast (빨리 실패하기)
커넥션을 얻으려고 줄을 섰는데, 30초가 지나도 못 얻었다면? 더 기다린다고 얻을 확률은 낮습니다. 차라리 빨리 에러를 뱉고 클라이언트에게 “잠시 후 다시 시도해주세요”라고 알리는 게 낫습니다.
많은 개발자들이 connectionTimeout (풀에서 커넥션을 얻기 위해 기다리는 시간) 설정을 간과합니다. 디폴트가 무한대이거나 아주 긴 시간으로 잡혀있는 경우가 많거든요.
권장 설정:
- Connection Timeout: 1초 ~ 5초 (짧게 잡으세요. 어차피 못 얻을 커넥션은 빨리 포기하는 게 낫습니다.)
- Socket Timeout: 네트워크 패킷을 기다리는 시간. 이것도 너무 길면 안 됩니다.
프로덕션을 위한 방어 전략
장애는 언제든 발생할 수 있습니다. 중요한 건 장애가 났을 때 피해를 최소화하는 것입니다.
1. Circuit Breaker (차단기)
DB가 힘들어할 때 계속 요청을 보내는 건 “확인 사살”이나 다름없습니다. 에러율이 치솟으면 잠시 DB로 가는 길을 막아버리는(Open) 서킷 브레이커 패턴을 적용하세요.
“DB가 아프니까 10초 동안은 요청 보내지 말고 바로 에러 리턴하자.”
이렇게 하면 DB도 숨 돌릴 틈을 얻어 복구될 가능성이 높아지고, 애플리케이션 스레드도 대기 상태에 빠지지 않습니다.
2. 적절한 풀 크기 설정
“풀 크기는 클수록 좋은 거 아니야?” 아닙니다. 커넥션이 너무 많으면 DB 서버의 CPU가 컨텍스트 스위칭하느라 시간을 다 씁니다.
PostgreSQL 팀에서는 대략적인 공식으로 (코어 수 * 2) + effective_spindle_count를 제안합니다. SSD를 쓰는 요즘 환경에서도 수천 개의 커넥션보다는 수십~수백 개 수준의 커넥션 풀이 처리량이 더 높은 경우가 많습니다.
무턱대고 max_connections를 늘리기보다, 애플리케이션의 스레드 수와 트래픽 패턴을 보고 벤치마킹을 통해 적정 값을 찾아야 합니다.
마치며
Connection Pool 고갈은 단순한 ‘설정 실수’가 아니라, 시스템의 건전성을 보여주는 중요한 지표입니다.
이 문제가 발생했을 때 단순히 풀 크기를 늘리는 것으로 해결하려 하지 마세요. 그건 진통제일 뿐입니다. 누수가 없는지, 쿼리가 느리지 않은지, 그리고 타임아웃 전략이 적절한지 코드를 꼼꼼히 뜯어보시기 바랍니다.
결국 안정적인 시스템은 “문제가 생겼을 때 빨리 실패하고 복구하는(Resilient)” 시스템이니까요.