본문으로 건너뛰기

백엔드

서버리스 환경에서 Prisma 커넥션이 고갈되는 이유와 해결법

2026. 07. 23. 목요일 오후 9시 0분

로컬이나 상시 서버(EC2)에서 멀쩡히 동작하던 Next.js 앱을 AWS Lambda로 옮기면, 트래픽이 몰릴 때 too many connections 오류로 API 요청이 실패하는 경우가 있습니다. DB와 Prisma 코드는 그대로이고 실행 환경만 서버리스로 바뀌었는데도 커넥션이 부족해집니다.

이 문제는 Prisma 버그나 DB 설정 실수가 아닙니다. 서버리스 실행 모델과 커넥션 풀의 동작 방식이 맞지 않아서 생기는 문제입니다. 이 글에서는 그 원인을 먼저 설명하고, connection_limit 조정 → 클라이언트 재사용 → 외부 풀러 도입으로 이어지는 해결 순서를 코드와 함께 정리합니다.


서버리스 환경에서 커넥션이 고갈되는 이유

상시 서버와 서버리스는 DB 커넥션을 다루는 방식이 다릅니다.

상시 서버(EC2, 컨테이너)는 프로세스 하나가 오래 떠 있습니다. 이 프로세스 안에 PrismaClient 인스턴스가 하나 있고, 그 밑에 커넥션 풀이 하나만 붙습니다. 요청이 아무리 많이 들어와도 이 풀 안의 커넥션을 돌려쓰기 때문에, DB가 받는 커넥션 수는 "서버 대수 × 풀 크기" 정도로 예측할 수 있습니다.

서버리스(Lambda)에서는 이 전제가 성립하지 않습니다. 서버리스 플랫폼은 동시에 들어온 요청을 처리하려고 함수 인스턴스를 여러 개 복제합니다. 인스턴스 하나하나가 자기만의 PrismaClient와 자기만의 커넥션 풀을 가지고 있어서, 인스턴스 수가 늘어나는 만큼 DB가 받는 커넥션 수도 함께 늘어납니다.

수치로 보면 이렇습니다.

  • Prisma의 기본 커넥션 풀 크기는 num_physical_cpus * 2 + 1입니다(driver adapter 없이 쓰는 클래식 PrismaClient 기준). Lambda처럼 vCPU가 1~2개인 작은 환경이면 인스턴스당 풀이 3~5개 정도가 됩니다.
  • 트래픽이 몰려 동시 실행 인스턴스가 200개가 됐다고 하겠습니다.
  • DB가 받는 커넥션은 200 인스턴스 × 5 = 1000개입니다.
  • PostgreSQL의 기본 max_connections는 보통 100 언저리입니다(그중 일부는 superuser 예약).

커넥션 상한이 100인데 1000개가 몰리므로 too many connections로 거절당합니다. 인스턴스 수 × 인스턴스당 풀 크기 값이 서버리스에서 트래픽에 따라 커지는 것이 원인입니다. 이어지는 절에서는 이 곱셈의 두 항을 순서대로 줄이고, 그 사이에 곱셈 모델(인스턴스 수 × 풀 크기)이 실제로 성립하도록 클라이언트를 재사용하는 단계를 추가합니다.


1단계: connection_limit 낮추기

가장 먼저 할 수 있는 것은 인스턴스당 풀 크기를 줄이는 것입니다. Prisma는 연결 문자열의 connection_limit 파라미터로 이 값을 조절합니다. 서버리스에서는 공식 문서도 connection_limit=1부터 시작하라고 권장합니다.

# .env
# 커넥션 문자열 뒤에 ?connection_limit=1 을 붙인다
DATABASE_URL="postgresql://user:password@db-host:5432/mydb?connection_limit=1"

스키마 쪽은 평소처럼 env로 URL만 읽어 오면 됩니다.

// prisma/schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

Prisma 7을 쓴다면 이 부분이 달라집니다. Prisma 7부터는 관계형 DB도 driver adapter가 기본이라, connection_limit URL 파라미터가 사라지고 풀 크기를 어댑터 쪽에서 설정합니다. PostgreSQL이면 @prisma/adapter-pgmax가 위 connection_limit에 해당하고, PrismaClient도 어댑터를 넘겨 만듭니다.

// Prisma 7 + driver adapter
import { PrismaClient } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
 
// connection_limit=1 대신 어댑터의 max로 풀 크기를 1로 잡는다.
const adapter = new PrismaPg({
  connectionString: process.env.DATABASE_URL,
  max: 1,
});
 
export const prisma = new PrismaClient({ adapter });

어댑터를 쓰면 기본 풀 크기도 앞서 본 num_physical_cpus * 2 + 1이 아니라 드라이버 기본값(pg는 10)을 따릅니다. 서버리스에서는 위처럼 max를 직접 낮추는 것이 중요합니다. 이어지는 예제는 클래식 PrismaClient(v6 계열) 기준이며, Prisma 7에서는 new PrismaClient()new PrismaClient({ adapter })로 바꿔 읽으면 됩니다.

풀을 크게 잡아야 성능이 좋다고 생각할 수 있지만, 그건 상시 서버에서의 이야기입니다. 서버리스에서는 실행 모델이 달라 방향이 반대입니다.

  • 상시 서버: 프로세스 하나가 여러 요청을 동시에 처리합니다. 그래서 풀을 넉넉히 잡아야 요청들이 커넥션을 나눠 쓰며 동시성을 확보합니다. 풀이 작으면 요청들이 커넥션을 기다리며 병목이 생깁니다.
  • 서버리스: 인스턴스 하나가 보통 한 번에 요청 하나만 처리합니다. 동시성은 인스턴스를 여러 개 띄우는 방식으로 이미 확보되어 있습니다. 인스턴스 안에서 커넥션을 여러 개 들고 있어도 대부분 놀고 있을 뿐이고, 곱셈의 값만 커집니다.

서버리스에서는 동시성을 인스턴스 개수로 조절하고, 인스턴스 하나는 커넥션을 최소한(1개)만 갖는 편이 커넥션 총량 관점에서 유리합니다. 앞의 예시에 적용하면 200 × 5 = 1000200 × 1 = 200으로 줄어듭니다. 아직 100을 넘지만 5배 줄었습니다.

다만 connection_limit=1은 곱셈의 한 항인 풀 크기만 1로 만든 것이라서, 인스턴스 수 자체가 계속 늘면 여전히 부족합니다. 다음 단계가 필요한 이유입니다.


2단계: 핸들러 바깥에서 클라이언트 재사용

두 번째로 챙길 것은 PrismaClient를 매 요청마다 새로 만들지 않는 것입니다. 이걸 이해하려면 서버리스의 cold start와 warm invocation을 먼저 알아야 합니다.

  • cold start: 요청을 처리할 인스턴스가 없어서 플랫폼이 실행 환경(컨테이너)을 새로 띄우는 경우입니다. 이때 핸들러 바깥에 있는 초기화 코드가 한 번만 실행됩니다(AWS Lambda의 INIT 단계).
  • warm invocation: 한 번 뜬 컨테이너는 요청이 끝나도 곧바로 사라지지 않고 freeze(동결)됩니다. 다음 요청이 오면 이 컨테이너를 thaw(해동)해서 재사용합니다. 이때 INIT 단계는 다시 돌지 않습니다.

핸들러 바깥에서 PrismaClient를 만들어 두면, cold start 때 한 번 생성된 클라이언트(와 그 커넥션)가 컨테이너에 얹혀 있다가 warm invocation에서 그대로 재사용됩니다. 반대로 핸들러 안에서 new PrismaClient()를 부르면, warm 상태여도 매 요청마다 새 클라이언트와 새 풀이 생겨 커넥션이 계속 늘어납니다.

// handler.ts
import { PrismaClient } from '@prisma/client';
import type { APIGatewayProxyHandler } from 'aws-lambda';
 
// 핸들러 '바깥' = INIT 단계.
// cold start 때 한 번만 실행되고, warm invocation에서는 이 인스턴스를 그대로 재사용한다.
const prisma = new PrismaClient();
 
export const handler: APIGatewayProxyHandler = async () => {
  // 여기서 new PrismaClient()를 부르지 않는다.
  const users = await prisma.user.findMany();
 
  return {
    statusCode: 200,
    body: JSON.stringify(users),
  };
};

handler.ts는 raw AWS Lambda 예시입니다. Next.js에는 이렇게 직접 작성하는 핸들러가 없는데, 여기서 말하는 '핸들러 바깥'은 Next.js에서는 모듈 최상단(예: lib/prisma.ts)에 해당합니다. 모듈 최상단도 컨테이너가 뜰 때 한 번만 평가되므로, 바로 아래에서 볼 globalThis 싱글턴은 개발 중 HMR 누수를 막는 것을 넘어 프로덕션에서도 '핸들러 바깥 재사용' 역할을 합니다.

핸들러 바깥에서 초기화한 클라이언트는 warm invocation에서만 재사용됩니다. cold start는 새 컨테이너이므로, cold start가 일어날 때마다 PrismaClient가 새로 생깁니다. 이 패턴은 커넥션이 늘어나는 속도를 늦추는 것이지, 인스턴스 수가 늘어나는 것 자체를 막지는 못합니다. 그 부분은 3단계에서 다룹니다.

개발 중 HMR로 인한 클라이언트 누수 막기 (globalThis 패턴)

Next.js를 로컬에서 개발할 때는 다른 문제도 하나 있습니다. 개발 서버는 코드를 저장할 때마다 HMR(Hot Module Replacement)로 모듈을 다시 불러오는데, 그때마다 new PrismaClient()가 새로 실행되어 커넥션이 계속 쌓입니다. 저장을 몇 번만 반복해도 개발 DB가 too many connections를 반환합니다.

Prisma가 권장하는 해법은 globalThis에 인스턴스를 한 번만 매달아 두고, 이미 있으면 그걸 재사용하는 싱글턴 패턴입니다.

// lib/prisma.ts
import { PrismaClient } from '@prisma/client';
 
// globalThis는 HMR로 모듈이 다시 로드돼도 초기화되지 않는다.
// any나 non-null 단언 없이 좁히기 위해 unknown을 한 번 거쳐 캐스팅한다.
const globalForPrisma = globalThis as unknown as {
  prisma: PrismaClient | undefined;
};
 
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
 
// 프로덕션에서는 globalThis에 매달지 않는다(불필요한 전역 오염 방지).
if (process.env.NODE_ENV !== 'production') {
  globalForPrisma.prisma = prisma;
}

globalForPrisma.prisma가 이미 있으면 그걸 쓰고, 없을 때만 새로 만듭니다. globalThis는 HMR로 모듈이 다시 로드돼도 살아남기 때문에, 몇 번을 저장해도 클라이언트는 하나로 유지됩니다. 그리고 이 파일 하나만 만들어 두고 다른 곳에서는 전부 여기서 prisma를 import 하면, 앱 어디에서도 PrismaClient가 중복 생성되지 않습니다.


3단계: 외부 커넥션 풀러

connection_limit=1로 풀 크기를 1까지 줄이고 클라이언트도 재사용하게 만들었는데도, 동시 실행 인스턴스 수 자체가 DB의 커넥션 상한을 넘길 만큼 많다면 여전히 커넥션이 부족합니다. 앞의 예시에서 200 × 1 = 200이 여전히 100을 넘던 경우가 이에 해당합니다. 곱셈의 남은 한 항인 인스턴스 수는 트래픽에 따라 정해지므로 직접 통제하기 어렵습니다.

이 문제는 앱과 DB 사이에 커넥션 풀러를 두면 해결됩니다. 수많은 인스턴스가 DB에 직접 붙는 대신 풀러에 붙고, 풀러는 소수의 커넥션만 DB와 유지하며 재활용합니다. 인스턴스가 몇백 개로 늘어나도 DB가 받는 커넥션은 풀러가 정한 상한 안에서만 움직입니다.

PgBouncer (transaction 모드)

가장 널리 쓰이는 선택지는 PgBouncer입니다. Prisma와 함께 쓸 때는 반드시 transaction 모드로 둬야 합니다. Prisma Client가 정상 동작하려면 트랜잭션마다 커넥션을 배정하는 이 모드가 필요합니다.

주의할 점은 prepared statement입니다. Prisma는 내부적으로 named prepared statement를 쓰는데, transaction 모드 풀러와 충돌할 수 있습니다. 과거에는 연결 문자열에 ?pgbouncer=true를 붙여 Prisma가 prepared statement를 쓰지 않도록 우회했습니다. PgBouncer 1.21.0부터는 prepared statement를 지원하므로(max_prepared_statements를 0보다 크게 설정), Prisma 공식 문서는 1.21.0 이상에서는 pgbouncer=true를 붙이지 말라고 안내합니다. 쓰는 PgBouncer 버전에 따라 설정이 다르므로 버전부터 확인해야 합니다.

Prisma Migrate 같은 CLI 작업은 스키마 엔진이 풀러를 거치면 안 되므로 DB에 직접 붙는 별도 URL이 필요합니다. 이 URL은 directUrl로 따로 지정합니다.

// prisma/schema.prisma
datasource db {
  provider  = "postgresql"
  // 앱(Prisma Client)은 PgBouncer를 통해 붙는다
  url       = env("DATABASE_URL")
  // Prisma Migrate/CLI는 풀러를 우회해 DB에 직접 붙는다
  directUrl = env("DIRECT_URL")
}
# .env
# 앱 런타임: PgBouncer(6432) 경유
DATABASE_URL="postgresql://user:password@pgbouncer-host:6432/mydb?connection_limit=1"
# 마이그레이션/CLI: DB(5432) 직접
DIRECT_URL="postgresql://user:password@db-host:5432/mydb"

매니지드 풀러 (Prisma Accelerate, Prisma Postgres)

PgBouncer를 직접 세우고 운영하는 것이 부담이라면, Prisma가 제공하는 매니지드 풀링을 쓸 수 있습니다.

매니지드 풀링 서비스는 시기에 따라 바뀌었습니다. 예전 자료에는 Prisma Data Proxy가 자주 등장하는데, 이 서비스는 2026년 5월에 종료되었습니다. 지금 새로 도입한다면 Data Proxy는 선택지에 없습니다. 현재 Prisma의 매니지드 커넥션 풀링은 Prisma Accelerate입니다. Data Proxy를 대체하며 커넥션 풀링에 글로벌 쿼리 캐시까지 더한 서비스입니다. DB까지 Prisma가 관리하길 원한다면 커넥션 풀링이 내장된 Prisma Postgres도 있습니다.

매니지드 방식의 장점은 인프라를 직접 운영하지 않아도 서버리스에서 커넥션 문제를 해결할 수 있다는 점이고, 트레이드오프는 외부 서비스에 대한 의존과 비용입니다.


하지 말 것: 매 요청마다 $disconnect

커넥션이 부족해지는 것을 보면 "요청이 끝날 때마다 $disconnect()로 닫아 주면 되지 않을까" 하는 생각이 들 수 있습니다. 하지만 이 방법은 서버리스에서 오히려 문제를 키웁니다. 이유는 두 가지입니다.

첫째, 커넥션을 다시 여는 비용이 큽니다. DB 커넥션 하나를 여는 데는 TCP 핸드셰이크, TLS 협상, 인증 같은 과정을 매번 거쳐야 합니다. 매 요청 끝에 $disconnect()로 닫아 버리면, 다음 요청이 warm invocation이라 컨테이너를 재사용할 수 있는데도 커넥션은 처음부터 다시 열어야 합니다. warm 재사용으로 얻는 이점이 사라집니다.

둘째, freeze/thaw 모델과 어긋납니다. 2단계에서 봤듯 서버리스 컨테이너는 요청이 끝나면 삭제되는 것이 아니라 freeze됩니다. $disconnect()를 부르지 않으면 freeze된 컨테이너 안에 살아 있는 커넥션이 다음 thaw 때 그대로 재사용됩니다. Prisma 공식 문서도 "장시간 실행되거나 서버리스인 앱에서는 매 요청마다 $disconnect()를 호출하지 말라"고 안내합니다.

컨테이너가 종료될 때 미처 닫지 못한 좀비 커넥션이 남는 것을 걱정할 수 있습니다. 플랫폼이 freeze된 컨테이너를 회수할 때, 앱이 $disconnect()를 부를 기회 없이 사라지면 DB 쪽에 잠깐 idle 커넥션이 남을 수 있습니다. 매 요청 $disconnect()를 부르면 이런 커넥션은 남지 않지만, 앞에서 본 재연결 비용 때문에 권하지 않습니다. 좀비 커넥션은 DB나 풀러의 idle timeout으로 처리합니다.

  • PostgreSQL 14 이상이라면 idle_session_timeout 설정으로 오래 놀고 있는 커넥션을 서버가 알아서 끊게 둘 수 있습니다. 이름이 비슷한 idle_in_transaction_session_timeout은 '열린 트랜잭션 안에서' idle인 세션만 끊으므로, 요청이 끝나 트랜잭션 밖에서 노는 이 좀비 커넥션에는 동작하지 않습니다.
  • PgBouncer 같은 풀러를 쓰면, 애초에 좀비 커넥션 관리 자체가 풀러의 몫이 됩니다. 앱은 풀러에만 붙으니 DB의 실제 커넥션은 풀러가 수명까지 책임집니다.

정리하면 매 요청 $disconnect()는 커넥션을 다시 여는 비용만 늘립니다. 좀비 커넥션은 timeout과 풀러로 처리합니다.


정리

서버리스에서 Prisma 커넥션이 부족해지는 것은 인스턴스 수 × 인스턴스당 풀 크기 값이 트래픽에 따라 커지기 때문입니다. 해결은 이 곱셈의 두 항을 순서대로 줄이는 과정입니다.

  • 1단계 (풀 크기 ↓): connection_limit=1로 인스턴스당 커넥션을 최소화한다. 서버 환경과 방향이 반대인 이유는 동시성을 인스턴스 개수로 확보하기 때문이다.
  • 2단계 (재사용): PrismaClient를 핸들러 바깥에서 만들어 warm invocation에서 재사용한다. 개발 중에는 globalThis 싱글턴으로 HMR 누수를 막는다. cold start마다 새 인스턴스가 생기는 것은 막을 수 없다.
  • 3단계 (인스턴스 수 ↓): 인스턴스가 아무리 늘어도 DB 커넥션은 외부 풀러가 상한 안에서 재활용하게 만든다.
  • 하지 말 것: 매 요청 $disconnect()는 재수립 비용만 늘리고 freeze/thaw 재사용을 깨뜨린다. 좀비 커넥션은 idle timeout과 풀러에 맡긴다.

어디까지 적용할지는 트래픽 규모와 DB 종류에 따라 다릅니다.

상황권장 구성
트래픽이 적고 간헐적connection_limit=1 + 클라이언트 재사용만으로 충분한 경우가 많다
동시성이 중간 이상, DB를 직접 운영PgBouncer(transaction 모드)를 앞에 두고 directUrl 분리
운영 부담을 줄이고 싶을 때매니지드 풀러(Prisma Accelerate) 또는 Prisma Postgres

표의 순서대로 1단계와 2단계를 먼저 적용하고, 동시 실행 인스턴스 수가 DB 커넥션 상한을 넘으면 3단계의 외부 풀러를 도입합니다.

긴 글 읽어주셔서 감사합니다.