PlanetScale + Vercel: DATABASE_URL과 DIRECT_URL을 나눠야 하는 이유
접속 문자열을 정리한다며 시크릿 이름 하나를 통일했습니다. 그날 밤 운영 사이트 로그인이 죽었고, 백업은 아무 소리 없이 깨졌습니다.
두 사고의 원인은 같았습니다. 같은 데이터베이스라도 누가 접속하느냐에 따라 접속 문자열이 달라야 한다는 사실을 제가 잊었습니다.
로그인이 죽었는데 파일을 못 찾는다고 합니다
Vercel 런타임 로그에 이렇게 찍혔습니다.
[auth][error] CallbackRouteError
[auth][cause]: PrismaClientKnownRequestError:
Invalid `prisma.user.findUnique()` invocation:
ENOENT: no such file or directory, open 'system'open 'system'. 사용자 조회를 하다가 왜 system 이라는 파일을 열까요.
접속 문자열은 181자였고, 끝은 이렇게 생겼습니다.
?sslmode=verify-full&sslrootcert=systemsslrootcert=system 은 libpq만 아는 말입니다
TLS로 데이터베이스에 붙을 때 클라이언트는 서버 인증서를 검증해야 하고, 그러려면 믿을 CA 목록이 필요합니다. PostgreSQL 16부터 그 목록을 OS 신뢰 저장소로 지정하는 방법이 생겼습니다.
sslrootcert=/path/ca.pem 그 파일의 CA를 믿습니다
sslrootcert=system OS 신뢰 저장소를 믿습니다PlanetScale 같은 관리형 데이터베이스는 공인 CA 인증서를 씁니다. CA 파일을 따로 받아 둘 필요가 없어서 system 한 단어로 끝나고, 그래서 대시보드가 주는 접속 문자열에 이게 붙어 나옵니다.
문제는 이 말을 알아듣는 쪽이 libpq뿐이라는 것입니다.
| 드라이버 | sslrootcert=system |
|---|---|
libpq (psql, pg_dump) |
OS 저장소를 쓰라는 뜻으로 해석합니다 |
node-postgres (pg) |
system 이라는 이름의 파일을 엽니다 |
pg-connection-string 2.13.0 코드를 열어 봤는데 system 을 특별 취급하는 분기가 아예 없었습니다. 받은 문자열을 그대로 파일 경로로 씁니다. 없는 파일이니 ENOENT 로 죽습니다.
제가 오진한 지점이 여기였습니다. 같은 시간에 백업은 37MB로 멀쩡히 떨어지고 있었습니다. 저는 그걸 보고 "접속 문자열은 맞다"고 판단했습니다. 백업과 앱이 애초에 다른 드라이버를 쓰고 있다는 걸 생각하지 못했습니다.
sslmode를 require로 내리면 오히려 더 약해집니다
급하니까 verify-full 을 require 로 낮추고 싶어집니다. 저도 처음에 그렇게 하려고 했습니다. 확인해 보니 그게 더 위험했습니다.
// pg-connection-string
case 'require': {
if (config.sslrootcert) { /* verify-ca 처럼 동작 */ }
else { config.ssl.rejectUnauthorized = false } // 검증을 끕니다
}
case 'verify-full': { break } // ssl={} 그대로 둡니다verify-full 에 sslrootcert 가 없으면 ssl = {} 가 되고, Node는 내장 CA 저장소로 완전 검증을 합니다. 파라미터 하나만 빼면 검증 수준이 그대로 유지됩니다. 반대로 require 로 내리면 rejectUnauthorized 가 false 가 되어 중간자 공격에 열립니다.
그래서 고친 내용은 이게 전부였습니다.
sslmode=verify-full 남깁니다
&sslrootcert=system 지웁니다백업은 아무 소리 없이 깨졌습니다
로그인을 고치면서 앱 쪽을 PgBouncer 포트(6432)로 옮겼습니다. Vercel Functions는 요청마다 인스턴스가 뜨고 각자 커넥션을 잡기 때문에, 직결로 붙으면 max_connections 를 금방 소진합니다. PlanetScale 자신이 서버리스에는 풀러를 권합니다.
그런데 백업 워크플로가 같은 이름의 시크릿을 보고 있었습니다.
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }} # 이제 6432 풀러입니다
run: |
pg_dump ... "$DATABASE_URL" | gzip > "$DB_FILE"더 민망한 건, 바로 그 파일 23번째 줄에 제가 이렇게 적어 뒀다는 점입니다.
# 반드시 직결(5432)을 쓴다. PgBouncer 풀러로는 pg_dump 가 돌지 않는다.경고를 써 놓고 그 경고가 가리키는 값을 제 손으로 바꿨습니다.
pg_dump가 풀러로 못 도는 이유
pg_dump 는 트랜잭션을 열어 REPEATABLE READ 스냅샷을 고정하고, 그 스냅샷 위에서 수백 개 테이블을 차례로 COPY 합니다. 끝날 때까지 같은 세션이어야 합니다.
PgBouncer의 transaction pooling은 트랜잭션이 끝날 때마다 뒤쪽 실제 커넥션을 다른 클라이언트에게 넘깁니다. 스냅샷은 트랜잭션 밖으로 못 나가서 덤프 도중에 데이터가 바뀌고, SET 으로 걸어 둔 설정은 다음 문장에서 사라집니다. 병렬 덤프(-j)는 SET TRANSACTION SNAPSHOT 이 필수라 아예 불가능합니다.
세션 모드로 두면 되긴 합니다. 그러면 풀링 이득이 없고, 관리형 서비스에서는 모드를 고를 수도 없습니다. 덤프는 직결로 가는 게 맞습니다.
무서운 건 이게 조용히 깨진다는 점입니다. 배포는 성공하고 앱은 잘 돌고, 백업만 다음 새벽부터 안 됩니다. 크기 하한 검사를 넣어 둔 게 그나마 다행이었습니다.
SIZE_BYTES=$(wc -c < "$DB_FILE")
if [ "$SIZE_BYTES" -lt 10000 ]; then
echo "::error::덤프가 너무 작다(${SIZE_BYTES}바이트) — 연결 실패 가능"; exit 1
fi이름이 갈린 것은 혼란이 아니라 구분이었습니다
정리하면 이렇습니다.
| 변수 | 포트 | SSL 파라미터 | 소비자 |
|---|---|---|---|
DATABASE_URL |
6432 (PgBouncer) | sslmode=verify-full |
앱, 서버리스 함수 |
DIRECT_URL |
5432 (Direct) | sslmode=verify-full&sslrootcert=system |
pg_dump, psql, 마이그레이션 |
호스트도 사용자명도 비밀번호도 같습니다. 포트와 SSL 파라미터만 다릅니다.
Prisma가 DATABASE_URL 과 DIRECT_URL 두 칸을 두는 이유가 이것이었습니다. 저는 그걸 Supabase 시절의 관례로만 알고 있었습니다. 풀러를 쓰는 관리형 Postgres라면 어디서나 같은 구분이 필요합니다.
한 가지 함정이 더 있습니다. Prisma 7과 @prisma/adapter-pg 조합처럼 스키마가 url 을 들지 않고 코드에서 커넥션을 만드는 구성이면, DIRECT_URL 을 애플리케이션 코드가 읽지 않습니다. 저장소 전체를 grep해도 안 나옵니다. 그래서 죽은 변수처럼 보입니다. 저는 실제로 그렇게 판단해서 지웠다가 되돌렸습니다.
코드가 안 읽는 것과 아무도 안 쓰는 것은 다릅니다. 워크플로가 쓰고, 스크립트가 쓰고, 사람이 씁니다. 지우기 전에 "코드에 없다"가 아니라 "왜 있었나"를 물었어야 했습니다.
Preview는 브랜치 데이터베이스로 분리합니다
운영을 정리하고 나니 프리뷰가 남았습니다. 프리뷰가 운영 데이터베이스를 보게 두면 안 됩니다. PR마다 배포가 생기고 그 URL은 쉽게 공유됩니다.
PlanetScale은 데이터베이스 브랜치를 만들 수 있습니다. 스키마를 복제한 독립 데이터베이스가 생기고, 접속 자격증명도 브랜치별로 따로 발급됩니다.
Branches 에서 main 을 원본으로 새 브랜치를 만듭니다. 프리뷰용이면 preview 처럼 오래 쓸 이름 하나를 두는 편이 관리하기 쉽습니다. PR마다 브랜치를 만들면 정리가 일이 됩니다.
그다음 Connect 화면 오른쪽 위에서 Branch를 방금 만든 브랜치로 바꿉니다. 역할(role)을 고르면 그 브랜치용 호스트와 포트, 사용자명이 나옵니다. 여기서 PgBouncer / Direct 를 고르는 자리가 있는데, 앱이 쓸 값이니 PgBouncer를 고릅니다.
비밀번호는 역할을 만들 때 한 번만 보여줍니다. 놓치면 자격증명을 새로 발급해야 합니다. 받는 즉시 비밀 저장소에 넣어 두세요.
Vercel에는 이렇게 넣습니다.
vercel env add DATABASE_URL preview --sensitive특정 git 브랜치에만 걸고 싶으면 대시보드에서 브랜치를 지정할 수 있습니다. develop 만 프리뷰 데이터베이스를 쓰게 하는 식입니다. sslrootcert=system 은 여기서도 빼야 합니다. 앱은 어느 환경에서든 node-postgres를 씁니다.
프리뷰에 운영 비밀을 복사하지 않습니다
이름은 같고 값은 반드시 달라야 하는 것들이 있습니다.
| 변수 | 프리뷰에서 |
|---|---|
DATABASE_URL |
브랜치 데이터베이스. 운영은 금지입니다 |
AUTH_SECRET |
달라야 합니다. 같으면 프리뷰에서 발급된 세션이 운영에서도 유효해집니다 |
CRON_SECRET |
달라야 합니다 |
| 알림 채널 토큰 | 아예 넣지 않습니다. 프리뷰가 운영 알림 채널에 글을 씁니다 |
| 외부 API 키 | 공유해도 되지만 사용 한도를 같이 씁니다 |
제 프리뷰 환경에는 변수가 24개 있었습니다. 그중 16개는 예전에 쓰던 데이터베이스 제공자의 통합이 심어 둔 것이었고, 이미 안 쓰는 서비스를 가리키고 있었습니다. DATABASE_URL 은 두 벌이 겹쳐 있었습니다.
더 나쁜 건 그것들이 Non-sensitive로 저장돼 있었다는 점입니다. Vercel 대시보드에서 비밀번호와 전체 접속 문자열이 그대로 읽혔습니다. 통합을 붙였다 뗄 때는 남은 변수를 반드시 확인해야 합니다.
남은 것
프리뷰 정리는 아직 안 했습니다. 브랜치 데이터베이스를 만들고 비밀을 갈라 넣는 일이 남았고, 급하지 않아서 미뤘습니다. 어차피 이미 죽은 데이터베이스를 가리키고 있어서 하루 더 깨져 있어도 잃을 게 없습니다.
이번에 제일 오래 남을 교훈은 따로 있습니다. "동작한다"가 "맞다"는 아니라는 것입니다. 백업이 37MB로 떨어졌다는 사실은 접속 문자열이 옳다는 증거가 아니라, pg_dump 가 그 형식을 이해한다는 증거였을 뿐입니다. 같은 값을 여러 곳이 읽고 있다면, 그들이 같은 라이브러리를 쓰는지부터 확인해야 합니다.
함께 읽기
- Supabase에서 PlanetScale로: 이관이 드러낸 커넥션 풀 기본값운영 데이터베이스를 Supabase에서 PlanetScale Postgres로 옮겼습니다. 옮기고 나서 사이트가 조금 굼떠진 것 같았습니다. 클라우드를 건너뛰게 됐으니 그 대가겠거니 생각했는데, 재보니 아니었습니다. 원인은 이관과 상관없이 원래 있던 커넥션 풀 설정이었고, 그것도 제 첫 측정으로는 보이지 않았습니다.
- PostgreSQL autovacuum: 지운 행이 디스크에서 사라지지 않는 이유테이블에서 100만 행을 지웠습니다. DELETE는 성공했고 SELECT count()는 0을 돌려줍니다.
- SQLite WAL 모드: .db만 복사하면 커밋이 사라지는 이유디렉터리에 파일이 세 개 있습니다.
- PostgreSQL 데이터 이관: 비우는 순서와 같은 DB 가드로컬에 둔 영업 DB 가 원본보다 뒤처져 있었습니다. 리드가 254건인데 원본은 282건이었습니다. 활동 기록은 171건 대 281건으로 더 벌어져 있었습니다.
- Neon·Supabase 대체 서비스: 2026년 비교와 추천 순위Neon과 Supabase를 대신할 서비스를 정리해 달라는 질문을 받았습니다. 처음에는 후보를 한 표에 놓고 유명한 순서부터 매기려 했습니다. 공식 문서를 다시 읽어 보니 출발점부터 잘못되었습니다.