[트러블슈팅] DB 커넥션이 텅 비었는데 꽉 찼다고? (Supabase Max client connections 에러)

박상민·2025년 7월 30일

Infra

목록 보기
7/8

문제 상황

서비스 운영 중 간헐적으로 FATAL: Max client connections reached 에러가 발생하며 서버가 중단되는 현상을 겪었습니다.
운영 환경은 Supabase Free PlanSpring 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대만 떠도 이미 한도 초과 위험이 있는 아슬아슬한 상황이었습니다.

처음에는 단순한 설정 문제로 판단하고 일반적인 대응을 했습니다.

  • 1차 시도 (실패): Supabase 커넥션 풀 사이즈를 30, 50으로 늘려보았습니다. 하지만 늘어난 풀은 몇 시간 뒤 다시 가득 찼고 에러가 재발했습니다.
  • 2차 시도 (실패): DB를 재시작해 모든 커넥션을 강제로 끊었습니다. 일시적으로 해결됐지만, 얼마 지나지 않아 근본 원인은 그대로였습니다.

단순한 코드 레벨의 누수(Leak)가 아님을 직감하고, 문제 상황을 정확히 파악하기 위해 로컬 모니터링 환경을 구축하여 데이터를 뜯어보기 시작했습니다.


2. 로그 분석

로그의 스택 트레이스(Stack Trace)를 하나씩 분석해보면, 표면적인 타임아웃 뒤에 숨겨진 진짜 원인을 발견할 수 있습니다.

  1. Top Level: CannotCreateTransactionException
    • Spring 프레임워크가 트랜잭션을 시작하려 했으나 실패했습니다.
  2. Middle Level: HikariPool-1 - Connection is not available
    • 애플리케이션의 커넥션 풀(HikariCP)이 DB 연결을 확보하지 못하고 타임아웃(20s) 되었습니다. 보통 여기까지 보고 "풀 사이즈를 늘려야 하나?"라고 오판하기 쉽습니다.
  3. Root Cause (핵심 원인): FATAL: Max client connections reached
    • 결정적인 단서입니다. HikariCP가 풀을 채우기 위해(또는 새 연결을 맺기 위해) DB에 접근했으나, PostgreSQL(Supabase) 측에서 허용된 물리적 연결 한도를 초과했다며 거부한 것입니다.

문제는 애플리케이션 내부의 풀이 꽉 찬 것(Resource Exhaustion)이 아니라, DB 서버의 물리적 연결 한계치(Max Connections)를 초과하여 발생한 것이었습니다.


데이터의 모순: 로그와 지표가 서로 다른 말을 했다.

하지만 여기서 이상한 점을 발견했습니다. 로그와 실제 데이터가 서로 다른 이야기를 하고 있었기 때문입니다.

  • 로그(Error): Max client connections reached (연결이 꽉 찼음)
  • 모니터링(Grafana): 활성(Active) 커넥션 수 1~4개
  • DB 쿼리(pg_stat_activity): 실제 활성 연결 단 1개

DB 연결은 텅텅 비어있는데, DB는 자리가 없다고 에러를 뱉는 명백한 모순이 발생한 것입니다.
이 모순을 통해 '원인은 애플리케이션(HikariCP)도, PostgreSQL DB 스펙 문제도 아니다'라는 결론에 도달했습니다.


4. 근본 원인: Supabase Transaction Pooler의 한계

애플리케이션과 DB 사이에서 연결을 중개하는 '무언가'가 범인임이 확실해졌습니다. 바로 Supabase의 PgBouncer(Connection Pooler)였습니다.

Supabase Free Plan 환경에서 Connection Pooler는 두 가지 모드로 동작하는데, 공식 문서를 확인해보니 결정적인 차이가 있었습니다.

구분Transaction Pooler (당시 설정)Session Pooler (Direct)
Port65435432
연결 관리 단위트랜잭션 (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의 설정을 변경했습니다.

  • 조치 내용: Supabase 연결을 포트를 6543에서 5432로 변경하여 Session Pooler(Direct)로 변경
  • 결과: 연결 한도가 약 60개(Direct 기준)로 늘어나며, Max client connections reached 에러 해결

Connection Pooler가 중간에서 과도하게 연결을 제한하던 병목을 제거하고, HikariCP가 DB(Session)와 직접 통신하도록 변경함으로써 문제를 해결했습니다.


마치며

이번 트러블슈팅은 Supabase와 같은관리형 서비스(Managed Service)의 편리함 뒤에 숨겨진 내부 아키텍처와 플랜별 제약사항을 이해하는 것이 얼마나 중요한지 깨닫게 된 계기였습니다.

만약 Max client connections reached라는 에러 메시지만 보고 "커넥션 누수인가?", "HikariCP 설정 문제인가?"라며 코드만 붙잡고 있었다면, PgBouncer의 설정 한도라는 진짜 원인은 찾는 시간은 끝없이 늘어났을겁니다.

이 글을 읽는 분들께서는 저와 같은 실수를 하지 않길 바라며.. 글을 남깁니다.

0개의 댓글