Neon 서버리스 Postgres: 스토리지·컴퓨트 분리, 브랜칭, 연결 풀링, Drizzle·Prisma·Vercel 연동
이 글의 핵심
서버리스 함수는 요청마다 새 연결을 열기 쉬워 일반 Postgres의 연결 한도를 금방 넘깁니다. 풀러 연결 문자열과 직접 연결을 언제 나눠 써야 하는지, 오토스케일과 슬립이 지연에 주는 영향, 브랜치 운영 시 주의점을 짚고, 예시 스키마와 인덱스 설계, 비밀 관리, 트러블슈팅까지 이어서 정리합니다.
이 글의 핵심
Neon은 클라우드에서 제공하는 PostgreSQL 호환 관리형 데이터베이스입니다. 한 대의 인스턴스를 오래 띄워 두는 전통적인 모델과 달리 스토리지 계층과 컴퓨트 계층을 분리하고, 브랜치로 개발·프리뷰 환경을 빠르게 만들 수 있게 하며, Vercel이나 AWS Lambda 같은 서버리스 런타임에 맞춘 연결 풀러와 전용 드라이버를 제공합니다.
아래에서는 이 구조가 왜 서버리스에 맞는지부터 시작해 브랜치 운영, 풀링과 @neondatabase/serverless 드라이버, 오토스케일과 슬립이 만드는 지연, Drizzle·Prisma·Vercel 연동, 멀티테넌트 스키마 설계 순서로 살펴봅니다.
과금, 한도, 리전, 제품 이름은 시기에 따라 바뀔 수 있으니 운영 전에 Neon 공식 문서와 콘솔을 확인하시기 바랍니다.
Neon의 핵심 개념
1-1. PostgreSQL 호환과 “관리형”의 의미
애플리케이션은 표준 PostgreSQL 프로토콜로 Neon에 접속합니다. 그래서 대부분의 SQL, 마이그레이션 도구, ORM을 기존 Postgres에서 쓰던 방식 그대로 가져올 수 있습니다. 다만 내부 구조는 VM 한 대에 Postgres를 설치한 형태와 다르고, 스토리지 서브시스템과 컴퓨트가 분리되어 있습니다.
관리형 서비스이므로 OS 패치, 디스크 증설, 스탠바이 구성 같은 일은 사용자가 하지 않습니다. 대신 연결 문자열, 브랜치, 역할, 백업 정책을 설계하는 데 집중하게 됩니다.
1-2. 스토리지와 컴퓨트 분리
Neon을 이해하는 열쇠는 스토리지와 컴퓨트(실제로 실행 중인 Postgres 프로세스)를 느슨하게 결합했다는 점입니다.
- 스토리지는 데이터가 지속적으로 보관되는 계층입니다. 내부적으로는 WAL을 받아 두는 Safekeeper와 페이지를 재구성해 제공하는 Pageserver로 나뉩니다.
- 컴퓨트는 쿼리를 실행하는 Postgres 인스턴스입니다. 트래픽에 따라 크기를 조정하거나 유휴 시 중단될 수 있습니다.
이렇게 분리되어 있기 때문에 브랜치를 만들 때 데이터를 물리적으로 복사하지 않고 특정 시점에서 copy-on-write로 분기할 수 있습니다. PR마다 프리뷰 DB를 붙이는 작업이 가벼워지는 이유가 여기에 있습니다.
1-3. 서버리스 워크로드와의 궁합
Vercel Functions 같은 서버리스 함수는 실행 시간이 짧고 인스턴스가 동시에 많이 뜰 수 있습니다. 그러면 DB 쪽 동시 연결 수가 급증하고, 각 함수 프로세스는 오래 유지되는 TCP 풀을 가질 수 없습니다. Neon은 이 상황을 전제로 풀러(PgBouncer 기반)와 HTTP/WebSocket 기반 서버리스 드라이버를 제공해, 애플리케이션이 연결 폭증에 덜 노출되도록 합니다.
아키텍처를 한 장으로 이해하기
아래는 개념적인 흐름입니다. 세부 구현은 제품 업데이트에 따라 달라질 수 있습니다.
flowchart LR
subgraph app [애플리케이션]
Next[Next.js / API]
Edge[Edge Runtime 선택 시]
end
subgraph neon [Neon]
Pooler[연결 풀러]
Compute[Postgres 컴퓨트]
Storage[분리 스토리지 / WAL]
end
Next --> Pooler
Edge --> Pooler
Pooler --> Compute
Compute --> Storage
애플리케이션은 가능하면 풀러 엔드포인트로 접속해 연결 수를 안정시킵니다. 컴퓨트는 부하에 따라 스케일되고 유휴 시 슬립할 수 있으며, 스토리지는 컴퓨트와 독립적으로 데이터의 지속성을 책임집니다.
RDS에서 하던 운영 습관을 그대로 옮기면 곤란한 지점이 바로 여기입니다. 연결 방식, 콜드 스타트, 풀러 URL과 직접 연결 URL의 구분을 처음부터 함께 설계해야 합니다.
브랜칭(Branching)과 프리뷰 환경
3-1. 브랜치가 해결하는 문제
코드를 PR 단위로 나누듯이 데이터베이스도 환경마다 스키마와 데이터가 따로 놀기 시작하면 통합 비용이 커집니다. Neon 브랜치는 프로덕션에 해당하는 메인 브랜치를 기준으로 개발·스테이징·프리뷰용 브랜치와 컴퓨트를 만들어 줍니다. 로컬에 덤프를 복원하거나 공용 스테이징 DB를 덮어쓰는 일에 덜 의존하게 됩니다.
3-2. 프리뷰 환경에 연결하는 일반적 패턴
Vercel이 PR마다 배포 URL을 만들듯이, PR마다 Neon 브랜치를 만들고 그 브랜치의 연결 문자열을 프리뷰 배포에 주입하는 패턴이 많이 쓰입니다. CI/CD에서 Neon API나 CLI로 브랜치를 만들고, 마이그레이션을 적용하고, 필요하면 시드를 넣은 뒤 연결 문자열을 환경 변수로 넘기는 흐름입니다.
프리뷰 앱이 주로 읽기만 하는지, 쓰기까지 필요한지에 따라 시드 규모와 브랜치 보존 기간을 정책으로 정해 두면 브랜치가 쌓이는 문제를 줄일 수 있습니다.
3-3. 운영 시 주의할 점
개인정보가 들어 있는 프로덕션 브랜치를 그대로 분기하면, 프리뷰 URL이 노출됐을 때 그 데이터가 함께 노출됩니다. 가능하면 익명화하거나 축소한 시드를 쓰고, 프리뷰 접근을 IP 제한이나 Basic Auth로 막아 두는 편이 안전합니다.
브랜치마다 마이그레이션 버전이 어긋나면 디버깅이 어려워집니다. Prisma Migrate나 drizzle-kit처럼 마이그레이션 이력을 하나의 소스로 관리하고, PR과 함께 버전을 고정해야 합니다.
연결 풀링(Connection Pooling)과 서버리스 드라이버
4-1. 왜 풀링이 필요한가
PostgreSQL은 연결마다 백엔드 프로세스를 하나씩 띄우기 때문에 동시 연결 수에 한계가 있고, 서버리스 환경에서는 연결을 새로 여는 비용이 매번 반복됩니다. 풀러는 많은 짧은 클라이언트 연결을 적은 수의 서버 측 연결로 묶어 줍니다. Neon 콘솔에서 연결 문자열을 복사할 때 풀링 옵션을 켜면 호스트 이름에 -pooler가 붙은 엔드포인트가 나오는데, 애플리케이션 트래픽은 보통 이쪽을 씁니다.
Neon 풀러는 PgBouncer를 트랜잭션 모드로 운영합니다. 그래서 세션 단위 상태에 기대는 기능, 예를 들어 세션 수준 SET, LISTEN/NOTIFY, 트랜잭션을 넘어 유지되는 prepared statement나 advisory lock은 풀러를 거치면 기대대로 동작하지 않을 수 있습니다.
4-2. @neondatabase/serverless 드라이버
TCP 소켓을 쓸 수 없는 엣지 런타임이나, 짧게 떴다 사라지는 서버리스 함수에서는 Neon 서버리스 드라이버를 씁니다. 이 드라이버는 단발 쿼리를 HTTP로 보내는 neon() 함수와, WebSocket 위에서 node-postgres 호환 인터페이스를 제공하는 Pool/Client를 함께 제공합니다.
아래는 개념 예시입니다. 실제로는 콘솔에서 발급한 연결 문자열을 환경 변수로 넣습니다.
// 개념 예시: @neondatabase/serverless로 쿼리 실행
import { neon } from '@neondatabase/serverless';
// DATABASE_URL은 풀러를 가리키는 것이 일반적입니다.
const sql = neon(process.env.DATABASE_URL!);
export async function getUserCount() {
const rows = await sql`SELECT count(*)::int AS c FROM users`;
return rows[0]?.c ?? 0;
}
태그드 템플릿 리터럴에 들어간 값은 문자열로 이어 붙여지지 않고 파라미터로 바인딩되므로 SQL 인젝션을 막을 수 있습니다. 쿼리를 문자열 연결로 조립하면 이 보호가 사라집니다.
HTTP 방식은 요청마다 연결을 맺고 끊는 부담이 없어서 단발 쿼리에 유리합니다. 반면 한 요청 안에서 여러 쿼리를 오가며 조건에 따라 커밋·롤백하는 대화형 트랜잭션은 HTTP 방식으로 처리할 수 없습니다. neon()의 transaction()은 미리 정해 둔 쿼리 묶음을 한 번에 보내는 비대화형 트랜잭션만 지원하므로, 대화형 트랜잭션이 필요하면 WebSocket 기반 Pool을 쓰거나 해당 작업을 상시 실행되는 워커로 분리합니다. 대량 배치 역시 서버리스 함수보다 별도 워커에서 돌리는 편이 안전합니다.
4-3. 일반 pg 클라이언트와의 선택
컨테이너 하나가 계속 떠 있는 장기 실행 서버라면 pg에 적절한 풀 크기를 설정하는 것만으로 충분한 경우가 많습니다. 반면 Vercel 함수처럼 인스턴스가 자주 바뀌는 환경에서는 풀러 URL과 서버리스 드라이버(또는 그 드라이버를 쓰는 ORM 어댑터) 조합을 먼저 검토합니다.
오토스케일(Autoscaling)과 슬립(Sleep)
5-1. 오토스케일
Neon 컴퓨트는 플랜과 설정에 따라 지정한 최소·최대 범위 안에서 부하에 맞춰 CPU와 메모리를 늘리거나 줄입니다. 그러니 고정 크기 인스턴스를 샀다고 생각하기보다, 성능이 단계적으로 달라질 수 있다고 가정하고 설계하는 편이 안전합니다.
5-2. 슬립과 콜드 스타트
유휴 상태가 일정 시간 이어지면 컴퓨트가 중단(scale to zero)될 수 있고, 이후 첫 요청이 들어올 때 컴퓨트를 다시 띄우는 지연이 생깁니다. 사용자에게는 “가끔 첫 로딩만 느리다”는 현상으로 보입니다.
완화 방법은 몇 가지가 있습니다. 스케줄된 워밍업 요청으로 컴퓨트를 깨워 둘 수 있지만, 그만큼 컴퓨트 사용 시간이 늘어 비용과 맞바꾸는 셈입니다. 중요한 조회 경로는 캐시나 읽기 복제본과 조합할 수 있습니다. 슬립이 치명적인 관리 도구나 배치라면 플랜이 허용하는 범위에서 자동 중단을 끄거나 최소 컴퓨트를 유지하는 설정을 검토합니다.
5-3. SLA와 운영 기대치
무료나 저가 플랜에서는 슬립과 연결 제한이 체감될 수 있습니다. 프로덕션이라면 요구사항에 맞게 플랜과 리전을 고르고, 헬스체크와 에러율을 모니터링해야 합니다.
Drizzle ORM 통합
Drizzle은 스키마를 TypeScript 코드로 정의하고 타입 안전한 쿼리를 지향하는 ORM입니다. Neon과 함께 쓸 때는 드라이버 계층만 Neon용으로 연결하면 됩니다.
6-1. 패키지와 연결
보통 @neondatabase/serverless와 drizzle-orm을 함께 설치하고, HTTP 방식이면 drizzle-orm/neon-http, WebSocket 방식이면 drizzle-orm/neon-serverless 어댑터를 가져옵니다. 버전에 따라 import 경로나 초기화 형태가 조금씩 다르니 공식 예제를 따르는 것이 좋습니다.
// 개념 예시: Drizzle + Neon serverless (import 경로는 사용 중인 drizzle 버전 기준)
import { drizzle } from 'drizzle-orm/neon-http';
import { neon } from '@neondatabase/serverless';
const sql = neon(process.env.DATABASE_URL!);
export const db = drizzle(sql);
이 구성에서 Drizzle은 SQL을 만들고 결과를 매핑하는 역할만 하고, 실제 전송은 Neon 서버리스 클라이언트가 맡습니다. 앞서 말한 HTTP 방식의 트랜잭션 제약도 그대로 따라옵니다. db.transaction()으로 대화형 트랜잭션을 쓰려면 neon-serverless 어댑터와 Pool을 써야 합니다.
6-2. 마이그레이션
스키마 변경은 drizzle-kit으로 관리합니다. 브랜치마다 CI에서 마이그레이션을 자동 적용할지, 로컬에서만 적용할지 팀 규칙을 정해 두어야 합니다.
프리뷰 브랜치에 프로덕션과 다른 순서로 마이그레이션이 적용되면 나중에 머지할 때 충돌이 납니다. 마이그레이션 이력은 반드시 한 곳에서 관리합니다.
Prisma 통합
Prisma는 schema.prisma, 마이그레이션, 클라이언트 생성이 한 도구에 묶여 있습니다. Neon과 함께 쓸 때 자주 부딪히는 지점은 다음과 같습니다.
7-1. 연결 문자열과 풀러
DATABASE_URL에는 풀러 연결 문자열을 쓰고, 마이그레이션용으로 directUrl에 직접 연결 문자열을 따로 지정하는 구성이 일반적입니다. 서버리스나 엣지 환경에서는 @prisma/adapter-neon 드라이버 어댑터로 Neon 서버리스 드라이버를 거쳐 쿼리를 보내는 방법도 있습니다. 설정 방식은 Prisma 버전에 따라 달라지므로 Prisma와 Neon 양쪽 문서를 함께 확인합니다.
7-2. 서버리스에서의 Prisma 클라이언트 생성
함수가 호출될 때마다 new PrismaClient()를 만들면 연결이 폭증할 수 있습니다. Next.js 개발 모드에서는 핫 리로드 때문에 모듈이 여러 번 로드되면서 클라이언트가 중복 생성되기도 해서, 아래처럼 전역 객체에 캐시하는 패턴이 널리 쓰입니다.
// Next.js App Router 등에서 흔히 쓰는 전역 Prisma 싱글톤 패턴(개념)
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
개발 환경에서는 모듈이 다시 로드돼도 같은 클라이언트를 재사용하고, 프로덕션에서는 같은 함수 인스턴스가 살아 있는 동안 모듈 스코프의 클라이언트가 재사용됩니다.
Edge Runtime에서 Prisma를 쓸지 여부에 따라 사용할 수 있는 드라이버가 달라지므로, 사용하는 Prisma 버전의 호환표를 확인해야 합니다.
Vercel·Next.js 통합
8-1. 환경 변수
Vercel 프로젝트 설정에는 보통 두 가지 연결 문자열을 둡니다. 애플리케이션이 쓰는 DATABASE_URL에는 풀러 URL을 넣고, 마이그레이션이나 일회성 관리 작업에 쓸 직접 연결 URL은 DATABASE_URL_UNPOOLED 같은 이름으로 따로 둡니다(이름은 팀 컨벤션에 따릅니다).
마이그레이션 도구는 여러 명령을 한 세션에서 연속으로 실행하거나 advisory lock 같은 세션 기능을 쓰는 경우가 많아서, 트랜잭션 모드 풀러를 거치면 실패할 수 있습니다. 그래서 마이그레이션에는 직접 연결 URL을 쓰는 것이 일반적입니다.
8-2. Next.js App Router에서의 위치
서버 컴포넌트나 Route Handler에서 DB에 접근할 때, 해당 코드가 Node 런타임에서 도는지 Edge 런타임에서 도는지에 따라 쓸 수 있는 드라이버가 달라집니다. Edge 런타임은 TCP 소켓을 열 수 없어서 pg 같은 TCP 기반 드라이버를 쓸 수 없고, Neon 서버리스 드라이버처럼 HTTP나 WebSocket을 쓰는 드라이버가 필요합니다.
8-3. 배포 파이프라인
- GitHub에 푸시하면 Vercel 빌드가 시작됩니다.
- 마이그레이션을 빌드 단계에서 실행할지, 런타임 전에 별도 잡으로 실행할지 정합니다.
- 프리뷰 환경에 별도 Neon 브랜치를 붙인다면 Vercel 환경 변수 스코프(Production / Preview)로 URL을 나눕니다.
실전 프로젝트 데이터베이스 설계 예시
아래는 SaaS 스타일의 멀티테넌트 앱을 가정한 최소 예시입니다. 실제 도메인에 맞게 테이블을 늘리되, 테넌트 격리 방식과 인덱스를 먼저 정해 두는 편이 안전합니다.
9-1. 요구사항 가정
- 조직(
tenant) 단위로 데이터가 분리됩니다. - 사용자는 여러 조직에 속할 수 있습니다(멤버십).
- 감사를 위해
created_at을 남깁니다.
9-2. 스키마 스케치(SQL)
-- 예시: 멀티테넌트 최소 모델
CREATE EXTENSION IF NOT EXISTS citext;
CREATE TABLE tenants (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE users (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email citext UNIQUE NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE memberships (
tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('owner', 'member')),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, user_id)
);
CREATE INDEX memberships_user_idx ON memberships (user_id);
기본 키를 uuid로 두면 여러 곳에서 ID를 생성해도 충돌 걱정이 적지만, 정수 키보다 인덱스가 커지고 무작위 값이라 삽입 위치가 흩어지는 단점이 있습니다. 멤버십 테이블은 (tenant_id, user_id) 복합 기본 키로 같은 조직에 중복 소속되는 것을 막고, 사용자 기준 조회를 위해 user_id 인덱스를 따로 둡니다. email에 쓴 citext는 확장이라 먼저 활성화해야 합니다.
ON DELETE CASCADE는 조직을 지우면 소속 데이터도 함께 정리하는 일을 DB에 맡기는 설정입니다. 비즈니스 요구가 소프트 삭제라면 이 부분은 조정해야 합니다.
9-3. 쿼리 패턴과 인덱스
자주 쓰는 조회가 WHERE tenant_id = ? AND ... 형태라면 인덱스의 첫 컬럼을 tenant_id로 둡니다. Neon도 결국 Postgres이므로 EXPLAIN (ANALYZE, BUFFERS)로 실제 실행 계획을 확인하는 습관이 그대로 통합니다.
9-4. 확장 시나리오
대량 로그나 이벤트 데이터는 메인 OLTP 테이블과 섞지 말고 아카이브 테이블이나 별도 저장소로 분리하는 것을 검토합니다. 검색 요구가 복잡해지면 PostgreSQL 전문 검색이나 전용 검색 엔진을 병행합니다.
보안·비밀 관리
연결 문자열은 Vercel 환경 변수에만 보관하고 저장소에 커밋하지 않습니다. 프리뷰 브랜치에 프로덕션 데이터가 들어간다면 최소 권한 역할과 네트워크 제한을 함께 검토합니다. 애플리케이션이 쓰는 DB 사용자에게는 DDL 권한을 주지 않고, 마이그레이션 전용 역할을 따로 두는 편이 안전합니다.
트러블슈팅
첫 쿼리만 수 초 걸리고 이후는 빠름. 컴퓨트가 유휴 상태에서 중단됐다가 다시 뜨는 콜드 스타트입니다. 프로덕션에서는 워밍업 크론이나 자동 중단 비활성화, 최소 컴퓨트 유지를 검토합니다. 개발·스테이징에서만 심하다면 브랜치별 부하와 무료 티어 한도도 함께 확인합니다.
“Too many connections” 또는 풀러 거절. 서버리스 동시 실행 수에 앱 쪽 풀 크기를 곱한 값이 한도를 넘은 상황입니다. 애플리케이션이 풀러 URL을 쓰고 있는지 확인하고, ORM이 요청마다 연결을 새로 여는지 로그로 확인합니다. 장기 실행 워커와 서버리스 함수를 같은 연결 문자열로 섞지 않는 편이 안전합니다.
마이그레이션은 성공했는데 앱만 실패. 프리뷰 브랜치와 프로덕션 브랜치의 스키마가 어긋났거나, Vercel 프리뷰 환경 변수가 다른 브랜치를 가리키는 경우가 많습니다. SELECT current_database(), inet_server_addr();이나 psql의 \conninfo, \d로 실제 접속 대상과 스키마를 확인합니다.
Edge Runtime에서만 쿼리 실패. Node 전용 드라이버를 쓰거나 TCP 연결을 가정한 코드가 섞인 경우가 많습니다. Edge에서 동작하는 HTTP/WebSocket 경로를 쓰고 있는지 Neon 서버리스 드라이버 문서와 대조합니다.
지연이 주기적으로 튐. 오토스케일로 컴퓨트 크기가 바뀌는 시점이나 동시 연결 급증과 겹치는지, 아니면 특정 쿼리 자체가 느린지를 pg_stat_statements로 구분합니다.