서비스 운영 중 간헐적으로 FATAL: Max client connections reached 에러가 발생하며 서버가 중단되는 현상을 겪었습니다.
운영 환경은 Supabase Free Plan과 Spring Boot(HikariCP)를 사용 중이었습니다.
[실제 에러 로그]
{
"timestamp": "2025-07-29T10:01:45.637...",
"level": "ERROR",
"message": "[RuntimeException] Could not open JPA EntityManager for transaction",
"stack_trace": "org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction
...
Caused by: org.hibernate.exception.JDBCConnectionException: Unable to acquire JDBC Connection
...
Caused by: java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 20000ms.
...
Caused by: org.postgresql.util.PSQLException: FATAL: Max client connections reached"
}
Supabase Free Plan의 기본 Connection Limit은 매우 제한적인데 반해, Spring Boot의 HikariCP는 기본적으로 10개의 커넥션을 미리 점유(Pre-fill)하려고 시도합니다. 즉, 서버가 2대만 떠도 이미 한도 초과 위험이 있는 아슬아슬한 상황이었습니다.
처음에는 단순한 설정 문제로 판단하고 일반적인 대응을 했습니다.
단순한 코드 레벨의 누수(Leak)가 아님을 직감하고, 문제 상황을 정확히 파악하기 위해 로컬 모니터링 환경을 구축하여 데이터를 뜯어보기 시작했습니다.
로그의 스택 트레이스(Stack Trace)를 하나씩 분석해보면, 표면적인 타임아웃 뒤에 숨겨진 진짜 원인을 발견할 수 있습니다.
CannotCreateTransactionExceptionHikariPool-1 - Connection is not availableFATAL: Max client connections reached문제는 애플리케이션 내부의 풀이 꽉 찬 것(Resource Exhaustion)이 아니라, DB 서버의 물리적 연결 한계치(Max Connections)를 초과하여 발생한 것이었습니다.
하지만 여기서 이상한 점을 발견했습니다. 로그와 실제 데이터가 서로 다른 이야기를 하고 있었기 때문입니다.
Max client connections reached (연결이 꽉 찼음)pg_stat_activity): 실제 활성 연결 단 1개DB 연결은 텅텅 비어있는데, DB는 자리가 없다고 에러를 뱉는 명백한 모순이 발생한 것입니다.
이 모순을 통해 '원인은 애플리케이션(HikariCP)도, PostgreSQL DB 스펙 문제도 아니다'라는 결론에 도달했습니다.
애플리케이션과 DB 사이에서 연결을 중개하는 '무언가'가 범인임이 확실해졌습니다. 바로 Supabase의 PgBouncer(Connection Pooler)였습니다.
Supabase Free Plan 환경에서 Connection Pooler는 두 가지 모드로 동작하는데, 공식 문서를 확인해보니 결정적인 차이가 있었습니다.

| 구분 | Transaction Pooler (당시 설정) | Session Pooler (Direct) |
|---|---|---|
| Port | 6543 | 5432 |
| 연결 관리 단위 | 트랜잭션 (Transaction) | 세션 (Session) |
| Free Plan 한도 | 동시 연결 15~20개 (병목 지점) | 동시 연결 약 60개 |
문제의 핵심은 Supabase Free Plan 설정상 'Transaction Pooler' 모드의 동시 연결 한도가 Direct 연결보다 오히려 더 낮게 잡혀있었다는 점입니다.
아무리 DB의 max_connections 설정(Direct)을 여유롭게 설정해도, 그 앞단에 있는 Transaction Pooler가 15~20개의 문만 열어두고 있었던 것입니다.
특히 개발 중 잦은 재배포/재시작이 발생하면서 HikariCP가 맺었던 연결들이 Pooler에서 즉시 정리되지 않고 좀비처럼 누적되었고, 결국 실제 DB 부하는 없는데 Pooler의 한도(15개)가 꽉 차서 새로운 연결을 거부했던 것입니다.
원인을 파악한 후 해결책은 간단했습니다. 병목 지점이었던 Supabase의 설정을 변경했습니다.
Max client connections reached 에러 해결Connection Pooler가 중간에서 과도하게 연결을 제한하던 병목을 제거하고, HikariCP가 DB(Session)와 직접 통신하도록 변경함으로써 문제를 해결했습니다.
이번 트러블슈팅은 Supabase와 같은관리형 서비스(Managed Service)의 편리함 뒤에 숨겨진 내부 아키텍처와 플랜별 제약사항을 이해하는 것이 얼마나 중요한지 깨닫게 된 계기였습니다.
만약 Max client connections reached라는 에러 메시지만 보고 "커넥션 누수인가?", "HikariCP 설정 문제인가?"라며 코드만 붙잡고 있었다면, PgBouncer의 설정 한도라는 진짜 원인은 찾는 시간은 끝없이 늘어났을겁니다.
이 글을 읽는 분들께서는 저와 같은 실수를 하지 않길 바라며.. 글을 남깁니다.