Prologue: 서버리스가 불러온 커넥션 지옥
사학을 공부하며 과거의 제도를 연구할 때 가장 흥미로운 점 중 하나는, 어떤 제도가 가진 본래의 훌륭한 취지가 전혀 예상치 못한 새로운 환경과 부딪힐 때 뜻밖의 부작용을 낳는 현상이었습니다. 개발을 배우고 서버리스(Serverless) 아키텍처를 접했을 때도 비슷한 전율을 느꼈습니다.
인프라 관리의 번거로움을 없애고 요청이 들어올 때만 함수가 실행되는 서버리스 아키텍처는 혁신적이었습니다. 하지만 이 매력적인 시스템은 전통적인 관계형 데이터베이스(RDBMS), 특히 PostgreSQL을 만나면서 심각한 병목 현상을 유발했습니다. 바로 '데이터베이스 커넥션 소진(Connection Exhaustion)' 문제였습니다.
처음 Next.js App Router와 Vercel Serverless Functions를 사용해 간단한 실시간 대시보드를 만들었을 때, 트래픽이 조금만 몰려도 다음과 같은 에러가 로그 창을 가득 채웠습니다.
FATAL: remaining connection slots are reserved for non-replication superuser connections
데이터베이스 커넥션이 부족해 요청이 전부 터져 나가고 있었던 것입니다. "이 좋은 서버리스 환경에서 왜 관계형 DB를 쓰는 게 이렇게 힘들까?"라는 질문을 시작으로, Supabase가 기존 PgBouncer의 한계를 극복하기 위해 내놓은 차세대 커넥션 풀러인 Supavisor를 깊이 연구하고 서비스에 적용해 본 삽질과 최적화의 기록을 공유하고자 합니다.
Concept: 서버리스와 PostgreSQL의 구조적 불일치
문제를 해결하기 위해서는 먼저 서버리스 환경과 PostgreSQL의 작동 메커니즘이 어떻게 충돌하는지 이해해야 합니다.
1. PostgreSQL의 프로세스 기반 아키텍처
PostgreSQL은 연결이 들어올 때마다 새로운 운영체제 프로세스(Process)를 포크(Fork)하여 핸들링하는 Process-per-connection 구조를 가지고 있습니다.
- 프로세스를 하나 띄울 때마다 수 메가바이트(MB)의 RAM이 소모됩니다.
- 클라이언트가 연결을 맺고 끊을 때마다 OS 커널 레벨에서 프로세스 생성 및 스레드 컨텍스트 스위칭 비용이 크게 발생합니다.
- 따라서 PostgreSQL은 감당할 수 있는 최대 커넥션 수(기본적으로 100~500개 안팎)가 매우 제한적입니다.
2. 서버리스의 무한 확장과 연결 주기
기존의 전통적인 롱 러닝(Long-running) 서버 환경(예: Express, NestJS)에서는 서버 인스턴스가 1~2개로 고정되어 있고, 서버 구동 시 커넥션 풀(Connection Pool)을 미리 생성해 두고 이를 재사용(Reuse)했습니다.
하지만 서버리스 환경은 다릅니다:
- 요청이 들어올 때마다 일시적인 컨테이너가 수십, 수백 개씩 생겨납니다.
- 각 컨테이너(Serverless Function)는 데이터베이스와 개별적으로 새로운 TCP 연결을 맺습니다.
- 함수 실행이 끝나면 컨테이너는 대기 상태로 들어가거나 사라지지만, 맺어놓은 커넥션은 데이터베이스 세션 타임아웃이 만료될 때까지 좀처럼 끊어지지 않고 남아있습니다.
- 결과적으로 동시 요청이 100개만 들어와도 데이터베이스의 최대 커넥션 제한인 100개를 순식간에 초과하게 되는 것입니다.
이 충돌을 완화하기 위해 중간에서 커넥션을 대리 관리해 주는 중간 미들웨어가 필요한데, Supabase는 엘릭서(Elixir) 언어로 개발된 고성능 풀러 Supavisor를 도입했습니다.
Deep Dive: Supavisor의 동작 방식과 두 가지 핵심 풀링 모드
Supavisor는 기존에 널리 쓰이던 PgBouncer를 대체하기 위해 Supabase가 자체 개발한 커넥션 풀러입니다. 수백만 개의 클라이언트 연결을 효율적으로 제어할 수 있는 엘릭서의 동시성 모델(OTP/Actor Model)을 채택하여 극도로 높은 성능과 안정성을 제공합니다.
Supavisor를 적용할 때 가장 중요한 결정은 **풀링 모드(Pooling Mode)**를 올바르게 설정하는 것입니다.
1. 세션 풀링(Session Pooling)
세션 풀링은 클라이언트가 연결을 맺는 순간부터 연결을 완전히 끊을 때까지 데이터베이스의 물리적 커넥션 하나를 통째로 할당해 주는 방식입니다.
- 특징: 기존 비-풀링 환경과 완벽하게 동일하게 작동합니다.
- 장점: 임시 테이블(Temporary Tables), prepared statements, Listen/Notify 기능 등 PostgreSQL의 모든 고급 기능을 제약 없이 사용할 수 있습니다.
- 단점: 서버리스 함수가 연결을 끊지 않고 쥐고 있으면 여전히 커넥션이 금방 소진됩니다. 서버리스 환경에서는 큰 효과를 보기 힘든 모드입니다.
2. 트랜잭션 풀링(Transaction Pooling)
트랜잭션 풀링은 클라이언트가 하나의 데이터베이스 트랜잭션(Transaction)을 실행하는 동안에만 실제 물리 커넥션을 할당하고, 트랜잭션이 끝나면 즉시 해당 커넥션을 다른 클라이언트에게 양보하는 방식입니다.
- 특징: 커넥션의 점유 시간이 극도로 짧아집니다.
- 장점: 수천 개의 서버리스 함수가 동시에 붙어도, 실제로 동시에 쿼리를 날리는 찰나의 순간에만 커넥션을 나누어 쓰기 때문에 물리 커넥션 수십 개만으로도 수천 개의 연결 요청을 거뜬히 소화합니다.
- 단점:
SET명령어를 통한 세션 매개변수 설정이나 Prepared Statements(일부 ORM 설정 필요), 임시 테이블 사용이 불가능하거나 제한됩니다.
Practical: Prisma와 Supavisor 완벽 설정 및 최적화
실무에서 Next.js 프로젝트에 Prisma ORM을 사용해 Supavisor를 적용하는 과정을 단계별로 정리해 보겠습니다.
1. 연결 문자열 설정 (Connection Strings)
Supabase 대시보드에서 제공하는 Supavisor 연결 정보를 확인해야 합니다. 포트 번호에 따라 동작 모드가 다릅니다.
- 포트 5432: 다이렉트 연결 (Direct Connection - 풀링 미사용)
- 포트 6543: Supavisor 풀러 연결 (트랜잭션/세션 모드 설정 필요)
트랜잭션 풀링을 사용해 Prisma를 최적화할 때는 DATABASE_URL과 DIRECT_URL 두 개를 나누어 지정해야 마이그레이션과 애플리케이션 쿼리를 안정적으로 수행할 수 있습니다.
# .env
# 트랜잭션 풀링 주소 (애플리케이션 쿼리용 - 포트 6543 및 pgbouncer=true 옵션 추가)
DATABASE_URL="postgres://postgres.yourproject:password@aws-0-ap-northeast-2.pooler.supabase.com:6543/postgres?pgbouncer=true&connection_limit=1"
# 다이렉트 연결 주소 (마이그레이션 및 DDL 쿼리용 - 포트 5432)
DIRECT_URL="postgres://postgres.yourproject:password@db.yourproject.supabase.co:5432/postgres"
[!WARNING] Prisma는 내부적으로 Prepared Statements를 사용하기 때문에, 트랜잭션 풀링 모드(
:6543)로 연결할 때는 연결 문자열 끝에 반드시pgbouncer=true옵션을 붙여주어야 쿼리가 터지지 않고 정상적으로 실행됩니다.
2. Prisma Schema 설정
schema.prisma 파일에서 두 URL을 매핑합니다.
// prisma/schema.prisma
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
directUrl = env("DIRECT_URL")
}
이렇게 하면 개발 단계나 서비스 런타임의 쿼리는 트랜잭션 풀링(DATABASE_URL)을 통해 효율적으로 처리하고, npx prisma db push 또는 npx prisma migrate dev 같은 마이그레이션 도구는 다이렉트 커넥션(DIRECT_URL)을 사용하여 세션 레벨 명령어 제약 없이 원활하게 데이터베이스 스키마를 업데이트할 수 있습니다.
3. Connection Limit 최적화 공식
서버리스 함수 환경에서 connection_limit를 너무 크게 잡으면 개별 컨테이너가 커넥션을 독점하여 풀러 자체가 고갈될 수 있습니다.
일반적인 서버리스 API 라우트 환경에서는 connection_limit=1 또는 최대 2로 설정하는 것이 가장 안전합니다.
// src/lib/db.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
Epilogue: 선언적 인프라가 주는 편안함
역사학에서 다양한 요인들이 얽혀 복잡하게 발생한 사회적 갈등을 해결하기 위해 훌륭한 중재자가 필요하듯, 모던 웹 아키텍처에서도 서버리스의 자유도와 RDBMS의 엄격함을 중재해 줄 고성능 커넥션 풀러는 필수적입니다.
과거에는 EC2 인프라를 직접 빌드하고 PgBouncer를 수동으로 설치하여 설정 파일을 튜닝하느라 며칠 밤을 새우곤 했습니다. 하지만 이제는 Supabase와 Supavisor 덕분에 연결 문자열의 포트와 쿼리 매개변수 몇 개를 조정하는 것만으로 수만 명의 동시 사용자를 감당할 수 있는 안정적인 서버리스 데이터베이스 레이어를 구축할 수 있게 되었습니다.
내가 만드는 서비스의 규모와 성격에 맞춰 풀링 설정을 정교하게 다듬는 것, 그것이 바로 서버리스 지옥에서 살아남는 모던 풀스택 개발자의 핵심 생존 전략입니다.