PR이 열릴 때마다 격리된 Postgres 환경이 1초 안에 생긴다 — Neon 브랜칭과 GitHub Actions 연동 가이드
저도 한동안 PR 리뷰를 할 때마다 "이 마이그레이션이 프로덕션에서도 문제없을까?" 하는 불안이 따라다녔습니다. PR마다 테스트용 클러스터를 수동으로 프로비저닝하고, 공유 스테이징 DB에서 다른 PR의 마이그레이션과 충돌하고, "제 로컬에선 됐는데요"를 입버릇처럼 달고 살던 시절 이야기입니다.
Neon Serverless Postgres의 브랜칭은 이 문제를 다르게 접근합니다. PR이 열리면 GitHub Actions가 프로덕션 데이터를 고스란히 담은 독립 Postgres 인스턴스를 1초 안에 만들고, PR이 닫히면 자동으로 삭제합니다. 마이그레이션 검증, 통합 테스트, 스키마 리뷰가 전부 이 격리 환경에서 돌아갑니다.
이 글에서는 Neon 브랜칭이 내부적으로 어떻게 동작하는지 살펴본 뒤, GitHub Actions 워크플로우를 단계별로 구성하고, Prisma/Drizzle 마이그레이션 자동 검증까지 연결하는 과정을 정리합니다. 실제 보험 스타트업 Shepherd가 CI/CD에 적용한 사례도 함께 소개합니다.
Copy-on-Write: 1초 만에 브랜치가 생기는 이유
전통적인 방식으로 500GB DB를 복사하면 수십 분이 걸립니다. Neon이 그걸 1초 안에 해내는 건 실제로 복사를 하지 않기 때문입니다.
Neon은 PostgreSQL 컴퓨트 레이어와 Pageserver(스토리지)를 물리적으로 분리합니다. 브랜치를 생성할 때는 WAL(Write-Ahead Log) 히스토리의 특정 지점을 가리키는 메타데이터 포인터만 만듭니다. 실제 데이터 블록은 건드리지 않습니다. 브랜치에서 데이터가 변경될 때 비로소 해당 블록만 새로 기록됩니다. 이게 Copy-on-Write(CoW)의 핵심입니다.
실선은 쓰기, 점선은 읽기를 나타냅니다. 브랜치들은 공유 스토리지에서 읽고, 변경된 블록만 각자의 델타에 씁니다. 브랜치는 원본 데이터를 공유하므로 변경된 블록에 대해서만 추가 스토리지 비용이 발생합니다. 사용하지 않는 브랜치의 컴퓨트는 Scale to Zero로 자동 중단되어 비용이 0이 됩니다.
PR 브랜치 라이프사이클
전체 흐름을 먼저 머릿속에 그려두면 이후 코드를 이해하기 훨씬 쉽습니다.
synchronize 이벤트(새 커밋 push)에서는 브랜치를 새로 만들지 않습니다. opened 때 생성된 브랜치가 이미 존재하므로, 해당 브랜치에 마이그레이션을 재적용하고 테스트를 다시 돌립니다. 이 시점에 create-branch-action을 다시 호출하면 브랜치 이름 충돌로 오류가 납니다.
사전 준비
Neon 콘솔(neon.com)에서 프로젝트를 생성하고 API 키를 발급한 뒤, GitHub 저장소 Secrets에 두 가지를 추가합니다.
NEON_API_KEY: Neon 콘솔 → Account Settings → API KeysNEON_PROJECT_ID: 프로젝트 설정에서 확인
기본 워크플로우: PR 오픈/닫힘에 브랜치 연동
.github/workflows/neon-preview.yml
# 액션 최신 버전 확인: https://github.com/neondatabase/create-branch-action/releases
name: Neon Preview Branch
on:
pull_request:
types: [opened, reopened, synchronize, closed]
jobs:
setup-db:
if: github.event.action == 'opened' || github.event.action == 'reopened'
runs-on: ubuntu-latest
outputs:
db_url: ${{ steps.create-branch.outputs.db_url }}
steps:
- uses: actions/checkout@v4
- name: Neon 브랜치 생성
id: create-branch
uses: neondatabase/create-branch-action@v5
with:
project_id: ${{ secrets.NEON_PROJECT_ID }}
branch_name: preview/pr-${{ github.event.pull_request.number }}
api_key: ${{ secrets.NEON_API_KEY }}
teardown-db:
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- name: Neon 브랜치 삭제
uses: neondatabase/delete-branch-action@v3
with:
project_id: ${{ secrets.NEON_PROJECT_ID }}
branch: preview/pr-${{ github.event.pull_request.number }}
api_key: ${{ secrets.NEON_API_KEY }}create-branch-action은 브랜치 연결에 필요한 URL을 output으로 반환합니다. 액션은 PgBouncer(커넥션 풀러)를 경유하는 URL과 직접 연결 URL을 별도 output으로 제공합니다. 마이그레이션 실행 시에는 반드시 직접 연결 URL을 사용해야 합니다. 정확한 output 필드명은 create-branch-action README에서 확인하세요.
★ Insight ─────────────────────────────────────
branch_name에 PR 번호를 포함시키면 Neon 콘솔에서 어떤 PR의 브랜치인지 바로 파악할 수 있고, closed 이벤트에서 같은 이름으로 삭제할 수 있어 별도 저장소 없이도 라이프사이클 관리가 됩니다.- 브랜치 생성을
opened/reopened로만 제한하는 이유:create-branch-action은 같은 이름의 브랜치가 이미 있으면 오류를 냅니다.synchronize이벤트에서도 이 job을 실행하면 두 번째 커밋부터 실패합니다.synchronize지원이 필요하다면 기존 브랜치 URL을 Neon CLI로 조회하거나 delete 후 recreate하는 별도 처리가 필요합니다.─────────────────────────────────────────────────
Prisma 마이그레이션 자동 적용
migrate-and-test:
needs: setup-db
runs-on: ubuntu-latest
env:
DATABASE_URL: ${{ needs.setup-db.outputs.db_url }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
with:
version: 9
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install
- name: 마이그레이션 적용
run: pnpm prisma migrate deploy
- name: 통합 테스트 실행
run: pnpm test:integrationprisma migrate deploy는 dev와 달리 프롬프트 없이 pending 마이그레이션만 순서대로 적용합니다. CI에서는 반드시 deploy를 사용해야 합니다. migrate dev는 shadow 데이터베이스를 생성하고 인터랙티브 프롬프트를 요구하기 때문에 CI 환경에서 실행하면 멈춥니다.
마이그레이션 실행용 DATABASE_URL에는 PgBouncer를 경유하지 않는 직접 연결 URL을 써야 합니다. PgBouncer는 prepared statement를 지원하지 않는데 Prisma 마이그레이션이 내부적으로 이를 사용하기 때문입니다. 위 예시의 db_url 자리에 어떤 URL을 넣어야 하는지는 create-branch-action의 output 목록을 확인하세요.
Drizzle ORM 조합
- name: Drizzle 마이그레이션 적용
run: pnpm drizzle-kit migrate
env:
DATABASE_URL: ${{ needs.setup-db.outputs.db_url }}drizzle-kit push는 마이그레이션 히스토리 없이 스키마를 강제 동기화합니다. 프로덕션 데이터가 있는 브랜치에서는 의도치 않은 데이터 손실이 생길 수 있으므로, 마이그레이션 파일 기반으로 적용하는 migrate 명령을 사용하세요.
스키마 Diff를 PR 코멘트로 자동 게시
마이그레이션이 뭘 바꿨는지 리뷰어가 코드만 보고 파악하기 어려울 때가 많습니다. schema-diff-action을 추가하면 PR에 스키마 변경 내용이 자동으로 코멘트로 달립니다. 실제 출력은 pg_dump 기반 DDL diff 형식이며, 아래는 변경 내용을 보여주는 예시입니다.
--- main
+++ preview/pr-42
+ ALTER TABLE users ADD COLUMN verified_at TIMESTAMPTZ;
+ CREATE INDEX idx_users_verified_at ON users(verified_at); - name: 스키마 Diff 코멘트 게시
uses: neondatabase/schema-diff-action@v1
with:
project_id: ${{ secrets.NEON_PROJECT_ID }}
api_key: ${{ secrets.NEON_API_KEY }}
compare_branch: preview/pr-${{ github.event.pull_request.number }}
base_branch: main
github_token: ${{ secrets.GITHUB_TOKEN }}★ Insight ─────────────────────────────────────
- 스키마 diff는 마이그레이션이 적용된 이후 실행해야 의미가 있습니다. 마이그레이션 전에 실행하면 main과 diff가 없습니다. 스텝 순서에 주의하세요.
- 커스텀 diff 로직이 필요하다면
neonctlCLI의 브랜치 비교 명령으로 직접 제어할 수 있습니다. 정확한 서브커맨드는neonctl --help나 Neon CLI 문서를 참고하세요.─────────────────────────────────────────────────
마이그레이션이 있는 PR에만 브랜치 생성하기
모든 PR에 브랜치를 만들면 Free 플랜의 10개 제한에 금방 걸립니다. 마이그레이션 파일이 변경된 PR에서만 브랜치를 생성하도록 조건부로 구성할 수 있습니다.
check-migrations:
runs-on: ubuntu-latest
outputs:
has_migrations: ${{ steps.check.outputs.changed }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 마이그레이션 파일 변경 감지
id: check
run: |
CHANGED=$(git diff --name-only origin/main...HEAD -- 'prisma/migrations/' | wc -l)
echo "changed=$([ $CHANGED -gt 0 ] && echo true || echo false)" >> $GITHUB_OUTPUT
setup-db:
needs: check-migrations
if: |
needs.check-migrations.outputs.has_migrations == 'true' &&
(github.event.action == 'opened' || github.event.action == 'reopened')
# ... 이하 동일
teardown-db:
needs: check-migrations
if: |
github.event.action == 'closed' &&
needs.check-migrations.outputs.has_migrations == 'true'
# 마이그레이션이 없어 브랜치를 생성하지 않은 PR은 삭제도 건너뜁니다
# ... 이하 동일teardown-db에 동일한 has_migrations 조건을 넣지 않으면, 마이그레이션 파일 변경이 없어 브랜치가 생성되지 않은 PR이 닫힐 때 삭제 액션이 존재하지 않는 브랜치를 찾다가 오류를 냅니다.
Shepherd의 3단계 파이프라인 패턴
보험 스타트업 Shepherd는 단순한 PR 브랜치를 넘어 3단계 파이프라인을 구성했습니다.
이 구조의 핵심은 프로덕션 데이터를 기반으로 한 테스트 브랜치가 스테이징에 먼저 반영된다는 점입니다. 스테이징을 별도로 관리하는 팀이라면 이 패턴이 현실적입니다.
실무에서 자주 만나는 실수
prisma migrate dev를 CI에서 실행하는 경우
migrate dev는 인터랙티브 프롬프트를 요구하고 shadow 데이터베이스를 생성하려 합니다. CI 환경에서 실행하면 프롬프트에서 멈추거나 shadow DB 생성 실패로 오류가 납니다. CI에서는 반드시 prisma migrate deploy를 사용하세요.
pull_request 트리거에 closed 타입 누락
types: [opened, reopened, synchronize, closed]에서 closed를 빠뜨리면 PR이 닫혀도 브랜치가 자동 삭제되지 않습니다. Free 플랜의 10개 제한이 금방 차오릅니다.
마이그레이션에 풀러 URL을 사용하는 경우
create-branch-action은 PgBouncer를 경유하는 URL과 직접 연결 URL을 별도로 제공합니다. PgBouncer는 prepared statement를 지원하지 않아 Prisma/Drizzle 마이그레이션이 실패합니다. 마이그레이션 단계에는 반드시 직접 연결 URL을 사용하세요. 액션 README에서 각 output 필드의 용도를 확인한 뒤 워크플로우를 구성하는 걸 권장합니다.
Cold Start를 테스트 실패로 오해
브랜치를 새로 생성하고 바로 연결하면 Scale to Zero 상태에서 컴퓨트가 깨어나는 300~500ms 지연이 발생합니다. 연결 재시도 로직이 없는 테스트 러너에서 타임아웃처럼 보일 수 있으므로, 연결 확인 스텝을 미리 넣어두면 안정적입니다.
- name: DB 준비 확인
run: |
for i in {1..10}; do
psql "$DATABASE_URL" -c '\q' 2>/dev/null && break
sleep 2
done
env:
DATABASE_URL: ${{ env.DATABASE_URL }}장단점 정리
장점
| 항목 | 내용 |
|---|---|
| 즉각적인 브랜치 생성 | DB 크기 무관하게 약 1초 소요 |
| 비용 효율 | 변경된 블록에 대해서만 추가 스토리지 비용. Scale to Zero로 미사용 컴퓨트 비용 0 |
| 프로덕션 동등성 | 실제 프로덕션 데이터 포함 격리 환경으로 현실적인 테스트 가능 |
| 공식 GitHub Actions | create/delete/schema-diff 모두 Marketplace 제공 |
| 포인트-인-타임 복원 | 보존 기간 내 임의 시점에서 브랜치 생성 가능 |
| 스토리지 비용 인하 | 2025년 이후 $1.75 → $0.35/GB |
제한사항
| 항목 | 내용 |
|---|---|
| Cold Start 지연 | Scale to Zero 후 첫 커넥션 시 300~500ms 지연 |
| superuser 미지원 | neon_superuser 역할만 제공. PostgreSQL 표준 superuser 없음 |
| unlogged 테이블 초기화 | 컴퓨트 재시작 또는 Scale to Zero 시 unlogged 테이블 내용 소실 |
| tablespace 미지원 | PostgreSQL tablespace 기능 사용 불가 |
| Free 플랜 브랜치 수 제한 | 프로젝트당 기본 10개 |
| 고빈도 트랜잭션 비적합 | 간헐적 워크로드에 최적화. 일관된 고부하 처리에는 한계 |
고급 활용: 포인트-인-타임 브랜칭
Copy-on-Write 구조 덕분에 현재 상태뿐 아니라 보존 기간 내 임의의 과거 시점에서 브랜치를 생성할 수 있습니다. 별도 스냅샷을 미리 만들어 두지 않아도 WAL 보존 기간 안의 어느 시점이든 지정할 수 있다는 점이 일반 스냅샷 기능과의 차이입니다. 장애 분석 시 문제가 시작된 정확한 시각을 알고 있다면 그 직전 상태로 브랜치를 만들어 데이터를 확인할 수 있습니다.
neonctl branch create \
--name incident-2026-07-10 \
--parent main \
--point-in-time 2026-07-10T03:00:00Z프로덕션 데이터 복구나 특정 시점 버그 재현에 그대로 쓸 수 있습니다.
마치며
Neon 브랜칭은 Copy-on-Write 덕분에 DB 크기와 무관하게 1초 안에 격리 환경을 만듭니다. 여기에 GitHub Actions 공식 액션 세 가지(create-branch-action, delete-branch-action, schema-diff-action)를 연결하면, PR 단위로 프로덕션 동등 DB 환경을 자동으로 프로비저닝하고 정리하는 파이프라인이 완성됩니다. 마이그레이션 검증, 통합 테스트, 스키마 리뷰를 격리된 환경에서 진행할 수 있고, PR이 닫히면 깔끔하게 정리됩니다.
다음 세 단계로 시작할 수 있습니다.
- Neon 프로젝트 생성 + API 키 발급 — neon.com 콘솔에서 5분이면 됩니다. Free 플랜으로 시작해도 10개 브랜치까지 무료입니다.
- 기본 워크플로우 파일 추가 — 위 예시의
neon-preview.yml을 붙여넣고NEON_API_KEY,NEON_PROJECT_IDSecret 두 개만 설정합니다. - 마이그레이션 스텝 추가 — Prisma라면
prisma migrate deploy, Drizzle이라면drizzle-kit migrate를 연결하고,schema-diff-action으로 PR 코멘트 자동화를 붙입니다. 마이그레이션 단계에 직접 연결 URL을 쓰는 것, 잊지 마세요.
"이 마이그레이션이 프로덕션에서도 문제없을까?" — 이 질문에 확신을 가지고 답할 수 있는 환경을 만드는 것이 이 워크플로우의 진짜 가치입니다.
참고 자료
- Neon 공식 브랜칭 문서
- GitHub Actions로 브랜칭 자동화 — Neon 공식 가이드
- Schema Diff 공식 가이드
- Database Branching Workflow Primer — Neon Docs
- Shepherd 도입 사례
- Neon + Vercel 프리뷰 환경 구성
- schema-diff-action GitHub 저장소
- Drizzle + Neon + GitHub Actions 자동화 — Clerk 가이드
- Neon Schema Diff 블로그 포스트
- Neon 플랜 및 가격 문서
- Copy-on-Write 아키텍처 심층 설명