Preview 환경에서 공유 DB를 안전하게 쓰는 법 — PR별 스키마 격리와 시드 데이터 자동화
솔직히 고백하자면, 저도 Preview 환경에서 데이터베이스 문제로 하루를 통째로 날린 적이 있습니다. 동료가 올린 PR의 마이그레이션이 제 Preview 환경의 테이블을 날려버린 거죠. "누가 내 테이블 DROP 했어?"라는 슬랙 메시지를 보내본 경험, 다들 한 번쯤은 있지 않나요?
Preview 환경은 PR마다 자동으로 뜨는 격리된 배포 환경인데, 프론트엔드와 백엔드는 깔끔하게 분리되는 반면 데이터베이스는 여전히 "공유"라는 이름 아래 서로의 마이그레이션이 충돌하는 지뢰밭으로 남아 있는 경우가 많습니다. 이 글에서는 하나의 공유 데이터베이스에서 PR별로 완전히 격리된 환경을 운영하고, 시드 데이터까지 자동화하는 세 가지 전략을 실제 코드와 함께 다룹니다. 최근 Neon의 브랜칭 생태계 확대, DBLab 4.0의 출시 등 이 분야의 도구들이 급격히 성숙해지면서, 팀 규모와 데이터 크기에 따라 선택지가 명확해졌습니다.
핵심 개념
Preview 환경이 데이터베이스에서 막히는 이유
Preview 환경(Preview Environment): PR이 생성될 때마다 자동으로 프로비저닝되는 격리된 임시 배포 환경. 프론트엔드·백엔드·데이터베이스를 포함한 전체 스택을 고유 URL로 제공하여, 코드 변경사항을 프로덕션과 유사한 조건에서 검증할 수 있게 합니다.
Vercel이나 Netlify에서 PR을 올리면 고유 URL로 프론트엔드가 뜨죠. 문제는 백엔드, 특히 데이터베이스입니다. PR-A가 users 테이블에 컬럼을 추가하고, PR-B가 같은 테이블의 컬럼을 삭제하면? 둘 다 같은 DB를 바라보고 있다면 누가 먼저 머지되느냐에 따라 다른 쪽이 터집니다. 저도 이걸 몇 번 겪고 나서야 "DB 격리를 진지하게 고민해야겠구나" 싶었습니다.
우리 팀은 어떤 전략을 써야 할까?
실무에서 자주 맞닥뜨리는 질문인데, 아래 기준으로 빠르게 판단할 수 있습니다.
- 시드 데이터만으로 충분하고, 추가 비용을 쓰기 어렵다면 → PostgreSQL 스키마 격리
- 프로덕션 데이터로 테스트해야 하고, 관리형 서비스를 선호한다면 → Neon DB 브랜칭
- TB 규모 프로덕션 데이터가 필요하고, 셀프호스팅 인프라 역량이 있다면 → DBLab Thin Clone
| 전략 | 핵심 원리 | 프로비저닝 속도 | 프로덕션 데이터 포함 | 비용 |
|---|---|---|---|---|
| PostgreSQL 스키마 격리 | search_path 동적 전환 |
즉시 | ❌ (시드 데이터만) | 무료 |
| DB 브랜칭 (Neon) | Copy-on-Write 브랜치 | 수초 | ✅ | 종량제 (브랜치당 약 $0.5~2/월 수준, 컴퓨팅 사용량에 따라 변동) |
| Thin Clone (DBLab) | ZFS/LVM 스냅샷 | 수초 | ✅ (TB 규모도) | 인프라 비용만 |
이 글에서는 Prisma ORM을 예시로 사용하지만, 스키마 격리·브랜칭·시드 자동화 패턴 자체는 ORM에 무관하게 적용 가능합니다. Drizzle, TypeORM, 혹은 raw SQL을 쓰는 팀이라도 동일한 원리가 적용됩니다.
실전 적용
예시 1: PostgreSQL 스키마 기반 경량 격리
외부 서비스 비용을 쓰고 싶지 않거나, 이미 운영 중인 PostgreSQL에서 바로 적용하고 싶을 때 유용한 패턴입니다. 솔직히 소규모 팀이라면 이것만으로도 충분합니다.
PostgreSQL에서 스키마는 하나의 데이터베이스 안에서 네임스페이스 역할을 합니다. pr_123 스키마와 pr_456 스키마는 같은 DB에 살지만 서로의 테이블을 전혀 모릅니다. 20년 넘게 PostgreSQL이 지원해 온 내장 기능이라 별도 외부 서비스가 필요 없습니다.
# .github/workflows/preview-schema.yml
name: Schema Isolation
on:
pull_request:
types: [opened, synchronize]
jobs:
setup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Dependencies
run: pnpm install
- name: Create Schema & Migrate
run: |
SCHEMA_NAME="pr_${{ github.event.number }}"
# 스키마 생성
psql "$DATABASE_URL" -c "CREATE SCHEMA IF NOT EXISTS ${SCHEMA_NAME};"
# 스키마가 지정된 DATABASE_URL로 마이그레이션 및 시드 실행
DATABASE_URL="${DATABASE_URL}?schema=${SCHEMA_NAME}" \
npx prisma migrate deploy
DATABASE_URL="${DATABASE_URL}?schema=${SCHEMA_NAME}" \
npx prisma db seed
env:
DATABASE_URL: ${{ secrets.SHARED_DATABASE_URL }}# .github/workflows/preview-schema-cleanup.yml
name: Cleanup Preview Schema
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Drop Schema
run: |
SCHEMA_NAME="pr_${{ github.event.number }}"
psql "$DATABASE_URL" -c "DROP SCHEMA IF EXISTS ${SCHEMA_NAME} CASCADE;"
env:
DATABASE_URL: ${{ secrets.SHARED_DATABASE_URL }}Vercel 쪽에서는 환경 변수로 DATABASE_URL에 ?schema=pr_${VERCEL_GIT_PULL_REQUEST_ID}를 붙여 설정하면, Preview 배포가 자동으로 격리된 스키마를 바라보게 됩니다.
search_path주의점: PgBouncer 같은 커넥션 풀러를 사용하는 환경에서는search_path가 세션 간에 누수될 수 있습니다. 이 경우?schema=쿼리 파라미터 방식보다 각 쿼리 실행 전SET search_path TO pr_xxx;를 명시적으로 호출하거나, 풀러 설정에서 세션 초기화 쿼리를 지정하는 것이 안전합니다. 저도 이걸 모르고 PgBouncer 환경에서 삽질했던 적이 있습니다.
Prisma
?schema=파라미터: Prisma는 PostgreSQL 연결 문자열의?schema=파라미터를 통해search_path를 설정하는 것을 지원합니다.prisma migrate deploy실행 시에도 이 파라미터가 올바르게 적용됩니다. 다만 Prisma 버전에 따라 동작이 다를 수 있으니, 사용 중인 버전의 릴리스 노트를 확인해보시는 것을 권장합니다.
이 방식의 매력은 추가 비용이 전혀 없다는 점입니다. 다만 프로덕션 데이터를 복제할 수는 없으니, 시드 데이터의 품질이 테스트 신뢰도를 좌우하게 됩니다. 프론트엔드 개발자라면 초기 스키마 설정은 DBA나 DevOps 엔지니어가 한 번 잡아주고, 이후에는 DATABASE_URL 환경 변수만 바꿔서 사용하면 됩니다.
| 단계 | 동작 | 핵심 포인트 |
|---|---|---|
| 스키마 생성 | CREATE SCHEMA IF NOT EXISTS |
PR 번호 기반 네임스페이스 분리 |
| 마이그레이션 | prisma migrate deploy |
?schema= 파라미터로 대상 스키마 지정 |
| 시드 데이터 | prisma db seed |
멱등성 보장된 초기 데이터 주입 |
| 정리 | PR close 시 DROP SCHEMA CASCADE |
리소스 누수 방지 |
예시 2: Neon + GitHub Actions + Vercel — 풀 자동화 브랜칭
프로덕션 데이터를 포함한 환경이 필요하다면 이 조합이 가장 접근성이 좋습니다. 저도 최근 프로젝트에서 이 조합을 쓰고 있는데, PR이 열리면 Neon 브랜치가 자동 생성되고, Prisma 마이그레이션 적용 후 시드 데이터까지 주입됩니다.
Copy-on-Write(CoW): 원본 데이터를 실제로 복사하지 않고, 변경이 발생한 페이지만 새로 기록하는 기법. 10개의 브랜치가 1TB 원본을 공유해도, 각 브랜치에서 변경한 부분만 추가 저장 공간을 차지합니다.
# .github/workflows/preview-db.yml
name: Preview Database Branch
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
setup-db:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Dependencies
run: pnpm install
- uses: neondatabase/create-branch-action@v5
id: create-branch
with:
project_id: ${{ secrets.NEON_PROJECT_ID }}
branch_name: preview/pr-${{ github.event.number }}
api_key: ${{ secrets.NEON_API_KEY }}
parent: main
- name: Run Migrations
run: npx prisma migrate deploy
env:
DATABASE_URL: ${{ steps.create-branch.outputs.db_url }}
- name: Seed Data
run: npx prisma db seed
env:
DATABASE_URL: ${{ steps.create-branch.outputs.db_url }}# .github/workflows/preview-cleanup.yml
name: Cleanup Preview Branch
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- uses: neondatabase/delete-branch-action@v3
with:
project_id: ${{ secrets.NEON_PROJECT_ID }}
branch: preview/pr-${{ github.event.number }}
api_key: ${{ secrets.NEON_API_KEY }}| 단계 | 동작 | 핵심 포인트 |
|---|---|---|
| 브랜치 생성 | main에서 CoW 브랜치 분기 |
프로덕션 데이터 포함, 수초 내 완료 |
| 마이그레이션 | prisma migrate deploy |
PR의 새 마이그레이션만 순서대로 적용 |
| 시드 데이터 | prisma db seed |
테스트 계정, 샘플 데이터 주입 |
| 정리 | PR close 시 브랜치 삭제 | 리소스 누수 방지 |
Vercel 쪽에서는 환경 변수로 DATABASE_URL을 Neon 브랜치의 연결 문자열로 설정하면, Preview 배포가 자동으로 격리된 DB를 바라보게 됩니다. Neon 과금이 생각보다 빠르게 올라간 적이 있어서, 비활성 브랜치 자동 삭제 정책과 compute 자동 일시정지 설정은 꼭 켜두시는 것을 권장합니다.
예시 3: DBLab Thin Clone — 테라바이트도 수초 만에
DBLab Engine은 ZFS나 LVM의 Copy-on-Write 스냅샷을 활용합니다. 프로덕션 DB가 5TB여도 클론 생성에 걸리는 시간은 동일하게 수초입니다. 10개의 클론이 1TB 원본을 공유해도 총 디스크 사용량이 약 1TB에 불과한 것이 핵심이죠.
# .github/workflows/preview-dblab.yml
name: Preview with DBLab Clone
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
setup-db:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create Thin Clone
id: clone
run: |
RESPONSE=$(curl -s -X POST "$DBLAB_API_URL/clone" \
-H "Authorization: Bearer $DBLAB_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "pr-${{ github.event.number }}",
"protected": false,
"db": {
"username": "preview_user",
"password": "'"$CLONE_PASSWORD"'"
}
}')
echo "db_url=$(echo $RESPONSE | jq -r '.db.connStr')" >> $GITHUB_OUTPUT
env:
DBLAB_API_URL: ${{ secrets.DBLAB_API_URL }}
DBLAB_TOKEN: ${{ secrets.DBLAB_TOKEN }}
CLONE_PASSWORD: ${{ secrets.CLONE_PASSWORD }}
- name: Run Migrations
run: npx prisma migrate deploy
env:
DATABASE_URL: ${{ steps.clone.outputs.db_url }}
- name: Seed Data
run: npx prisma db seed
env:
DATABASE_URL: ${{ steps.clone.outputs.db_url }}# .github/workflows/preview-dblab-cleanup.yml
name: Cleanup DBLab Clone
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Delete Clone
run: |
curl -s -X DELETE "$DBLAB_API_URL/clone/pr-${{ github.event.number }}" \
-H "Authorization: Bearer $DBLAB_TOKEN"
env:
DBLAB_API_URL: ${{ secrets.DBLAB_API_URL }}
DBLAB_TOKEN: ${{ secrets.DBLAB_TOKEN }}이 방식은 ZFS/LVM 인프라를 직접 운영해야 하므로 초기 설정의 진입장벽이 있습니다. DBA나 DevOps 엔지니어가 DBLab Engine을 세팅해주면, 개발자는 위 워크플로에서 나오는 DATABASE_URL 환경 변수만 사용하면 되니 역할 분리가 자연스럽게 이루어집니다.
공통: 멱등성을 보장하는 시드 스크립트
어떤 격리 전략을 쓰든, 시드 데이터 자동화는 공통으로 필요합니다. 제가 여러 번 실수하고 나서 정착한 패턴을 공유합니다.
// prisma/seed.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const adminUser = await prisma.user.upsert({
where: { email: 'admin@example.com' },
update: {},
create: {
email: 'admin@example.com',
name: 'Test Admin',
role: 'ADMIN',
},
});
const existingOrg = await prisma.organization.findUnique({
where: { slug: 'test-org' },
});
if (!existingOrg) {
await prisma.organization.create({
data: {
name: 'Test Organization',
slug: 'test-org',
members: { connect: { id: adminUser.id } },
},
});
}
console.log('Seed completed successfully');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());멱등성(Idempotency): 동일한 연산을 여러 번 수행해도 결과가 달라지지 않는 성질. 시드 스크립트에서는
upsert패턴이나 존재 여부 확인 로직으로 구현합니다.
package.json에 아래 설정을 추가하면 npx prisma db seed로 실행됩니다. tsx가 devDependencies에 포함되어 있어야 하니 pnpm add -D tsx를 미리 실행해두시는 것을 권장합니다.
{
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"devDependencies": {
"tsx": "^4.0.0"
}
}| 패턴 | 용도 | 주의점 |
|---|---|---|
upsert |
단일 레코드의 멱등성 보장 | where 조건이 unique 필드여야 함 |
findFirst + 조건부 생성 |
복잡한 관계 데이터 | 트랜잭션으로 감싸는 것을 권장 |
createMany + skipDuplicates |
대량 시드 데이터 | PostgreSQL에서만 지원 |
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| PostgreSQL 스키마 격리 | 비용 제로, 외부 의존성 없음, 5분 안에 설정 가능, 모든 PostgreSQL 호스팅에서 동작 |
| Neon DB 브랜칭 | 프로덕션 데이터 포함 테스트 가능, GitHub/Vercel 공식 통합, 데이터 마스킹 내장 |
| DBLab Thin Clone | TB 규모도 수초 클론, 셀프호스팅으로 벤더 종속 없음, 인프라 비용만 발생 |
단점 및 주의사항
| 항목 | 단점 | 대응 방안 |
|---|---|---|
| PostgreSQL 스키마 | 프로덕션 데이터 복제 불가, 수천 개 스키마 시 관리 복잡, PgBouncer 환경에서 search_path 누수 가능 |
시드 데이터 품질 향상, cron으로 오래된 스키마 자동 정리, 커넥션 풀러 세션 초기화 설정 |
| Neon | 클라우드 벤더 종속, 브랜치 수·컴퓨팅 시간 기준 과금, 비활성 브랜치 방치 시 비용 누적 | 비활성 브랜치 자동 삭제 정책, compute 자동 일시정지 활용 |
| DBLab | ZFS/LVM 인프라 운영 필요, 초기 설정 진입장벽 높음, 전담 DevOps 인력 필요 | 공식 Docker 이미지 활용, 인프라 팀과 역할 분리 |
| 공통: 민감 데이터 | 프로덕션 데이터 복제 시 PII 노출 위험 | Neon 마스킹 브랜치 활용, 또는 별도 익명화 파이프라인 구축 |
PII(Personally Identifiable Information): 개인을 식별할 수 있는 정보(이름, 이메일, 전화번호 등). Preview 환경에 프로덕션 데이터를 복제할 때 반드시 마스킹 처리가 필요합니다.
실무에서 가장 흔한 실수
-
PR 클로즈 후 정리를 빠뜨리는 것 — 브랜치나 스키마가 계속 쌓이면서 리소스 비용이 눈덩이처럼 불어납니다.
pull_request: [closed]이벤트에 cleanup job을 반드시 연결해두는 것이 좋습니다. 저도 한 달 뒤에 수십 개의 고아 스키마를 발견하고 부랴부랴 정리 스크립트를 만든 적이 있습니다. -
시드 스크립트에
INSERT만 쓰는 것 — CI가 재실행될 때마다 중복 데이터가 쌓입니다.upsert나ON CONFLICT DO NOTHING패턴으로 멱등성을 확보하는 것이 중요합니다. -
개발 환경에서
prisma migrate dev를 Preview에도 쓰는 것 —migrate dev는 스키마 변경을 감지하고 새 마이그레이션 파일을 생성하는 용도이고, Preview·프로덕션에서는 기존 마이그레이션만 적용하는migrate deploy를 사용하는 것이 안전합니다. 이 차이를 모르고 Preview CI에서migrate dev를 돌렸다가 마이그레이션 히스토리가 꼬인 경험이 한 번 있으면 절대 안 잊게 됩니다.
마치며
PR마다 격리된 데이터베이스 환경을 갖추는 건 더 이상 대기업만의 사치가 아니라, 도구 선택만 잘하면 대부분의 팀이 빠르게 도입할 수 있는 실용적인 전략입니다. 다만 전략마다 필요한 역량과 인프라가 다르니, 팀 상황에 맞는 출발점을 고르는 것이 핵심입니다. 저는 소규모 프로젝트에서는 스키마 격리로 시작하고, 프로덕션 데이터가 필요해지면 Neon 브랜칭으로 전환하는 조합으로 정착했습니다.
지금 바로 시작해볼 수 있는 3단계:
-
PostgreSQL 스키마 격리를 선택한 경우 — CI 파이프라인에
CREATE SCHEMA IF NOT EXISTS pr_${PR_NUMBER};한 줄을 추가하고, cleanup 워크플로에DROP SCHEMA IF EXISTS ... CASCADE;를 연결하는 것으로 첫 발을 뗄 수 있습니다. Neon을 선택한 경우 —neondatabase/create-branch-action@v5와delete-branch-action@v3을 GitHub Actions에 추가하는 것만으로 시작할 수 있습니다. -
시드 스크립트를 멱등하게 리팩터링하기 — 기존 시드 파일에서
create를upsert로 바꾸고,package.json의prisma.seed설정을 추가합니다.npx prisma db seed를 3번 연속 실행해서 에러 없이 동일한 결과가 나오는지 확인해보시면 됩니다. -
정리 자동화 붙이기 — GitHub Actions에서
pull_request: [closed]이벤트를 감지하여 스키마 DROP 또는 브랜치 삭제를 수행하는 워크플로를 추가합니다. 이 한 단계가 리소스 누수를 막아주는 가장 중요한 안전장치입니다.
다음 글: Preview 환경의 데이터베이스에 프로덕션 데이터를 안전하게 마스킹하여 제공하는 파이프라인 구축기 — Neon 마스킹 브랜치와 커스텀 익명화 스크립트 비교
참고 자료
- A database for every preview environment using Neon, GitHub Actions, and Vercel | Neon Blog
- Automate branching with GitHub Actions | Neon Docs
- Create Environments with Masked Production Data Using Neon Branches | Neon Blog
- Practical Guide to Database Branching | Neon Blog
- Full-stack preview environments with DBLab 4.0 | PostgresAI
- DBLab Engine | GitHub
- Stop Using Database Branching for PR Previews: Postgres Schemas Are Enough | DEV Community
- Database branching: three-way merge for schema changes | PlanetScale
- Modern Database CI/CD with Atlas | AtlasGo
- Seeding | Prisma Documentation
- Preview Environments with PostgreSQL: Per-PR Database Isolation | Bunnyshell