본문으로 건너뛰기

4.2 PR마다 Branch: GitHub Actions

application 코드는 PR마다 preview 환경을 만들지만, database는 공용 staging 하나를 여러 PR이 나눠 쓰는 팀이 많습니다. 한 PR의 migration 때문에 다른 PR의 테스트가 실패하고, 데이터 변경 주체를 추적하기도 어렵습니다. branch가 수 초 안에 생성되고 diverge한 페이지만 storage를 차지한다면, database를 PR 단위로 격리하는 비용도 현실적인 수준으로 낮아집니다.

이 장에서는 GitHub Actions로 PR 생명주기에 branch를 연결하는 방법을 다룹니다. PR이 열리면 branch를 만들고 연결 문자열을 job에 주입합니다. PR이 닫히면 branch를 지웁니다.

흐름

branch 이름에 PR 번호를 넣으면 이름만으로 어느 PR에 속하는지 알 수 있습니다. 닫힘 이벤트에서도 같은 이름을 계산해 지울 수 있습니다.

공식 Action

Neon은 네 가지 GitHub Action을 제공합니다. 저장소는 neondatabase/ 조직 아래에 있습니다.

Action역할
neondatabase/create-branch-actionbranch 생성, 연결 정보 출력
neondatabase/delete-branch-actionbranch 삭제
neondatabase/reset-branch-actionbranch를 부모 상태로 reset
neondatabase/schema-diff-action두 branch의 schema diff를 PR 코멘트로 게시

버전 태그와 입력, 출력 이름은 각 Action 저장소의 정의가 기준입니다. 아래 예시는 create-branch-action v6과 delete-branch-action v3의 action.yml을 확인해 작성했습니다. major 버전이 올라가면 입력과 출력 이름이 바뀌므로 사용 전에 저장소의 정의 파일을 확인합니다.

PR 열림: branch 생성과 테스트

name: preview-db
on:
pull_request:
types: [opened, reopened, synchronize]

concurrency:
group: preview-db-${{ github.event.number }}
cancel-in-progress: true

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set branch expiration (7 days)
run: echo "EXPIRES_AT=$(date -u -d '+7 days' +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_ENV"

- name: Create Neon branch
id: create
uses: neondatabase/create-branch-action@v6
with:
project_id: ${{ vars.NEON_PROJECT_ID }}
branch_name: preview/pr-${{ github.event.number }}
parent_branch: main
database: app
role: app_owner
suspend_timeout: '60'
expires_at: ${{ env.EXPIRES_AT }}
api_key: ${{ secrets.NEON_API_KEY }}

- name: Run migrations
env:
DATABASE_URL: ${{ steps.create.outputs.db_url }}
run: npm run migrate

- name: Run tests
env:
DATABASE_URL: ${{ steps.create.outputs.db_url_pooled }}
run: npm test

create-branch-action은 같은 이름의 branch가 이미 있으면 새로 만들지 않고 기존 branch 정보를 반환합니다. 따라서 synchronize 이벤트로 커밋이 추가되어도 branch가 중복 생성되지 않습니다. 필수 입력은 api_keyproject_id 둘뿐입니다. 하지만 databaserole의 기본값은 neondbneondb_owner입니다. 프로젝트에서 사용하는 이름이 다르면 연결 문자열이 잘못된 database를 가리킵니다. 실제 이름을 명시하는 편이 안전합니다.

주요 입력과 출력은 v6 action.yml 기준으로 다음과 같습니다.

구분이름설명
입력parent_branch분기할 부모 branch
입력database, role연결 문자열에 쓸 database와 role. 기본값 neondb, neondb_owner
입력expires_at자동 삭제 시각 (RFC 3339)
입력suspend_timeoutscale to zero 대기 초. 0이면 기본 정책
입력branch_typedefault 또는 schema-only
입력masking_rules익명화 규칙 JSON
출력db_url, db_url_pooled직접 연결과 pooled 연결 문자열
출력db_host, db_host_pooled호스트만
출력branch_id, password, createdbranch ID, 비밀번호, 신규 생성 여부

migration은 직접 연결(db_url)로, 테스트는 pooled 연결(db_url_pooled)로 구분한 데에는 이유가 있습니다. migration 도구는 advisory lock이나 세션 상태에 의존하는 경우가 있습니다. 이 때문에 pooler를 거치면 실패하기도 합니다. 테스트는 연결이 많이 생기므로 pooler가 유리합니다.

PR 닫힘: branch 삭제

name: preview-db-cleanup
on:
pull_request:
types: [closed]

jobs:
delete:
runs-on: ubuntu-latest
steps:
- uses: neondatabase/delete-branch-action@v3
with:
project_id: ${{ vars.NEON_PROJECT_ID }}
branch: preview/pr-${{ github.event.number }}
api_key: ${{ secrets.NEON_API_KEY }}

delete-branch-action의 정의는 action.yaml 파일에 있습니다. 입력은 project_id, branch, branch_id, api_key, api_host입니다. 이름으로 지울 때는 branch, ID로 지울 때는 branch_id를 씁니다.

closed 이벤트는 머지와 단순 닫힘을 구분하지 않습니다. 두 경우 모두 branch를 지우는 것이 일반적입니다. 머지된 변경에 포함된 migration 파일은 이미 main에 반영할 파일로 코드 저장소에 남아 있기 때문입니다.

삭제 실패에 대비하는 expiration

cleanup workflow가 실패하거나 workflow 파일을 수정하는 동안 이벤트를 놓치면 branch가 남습니다. 그 결과는 플랜에 따라 다릅니다. Free 플랜은 생성 상한이 10개입니다. 잊힌 branch 몇 개가 다음 PR의 생성을 막습니다. Launch와 Scale은 포함량 10개와 25개를 넘어도 계속 생성됩니다. 초과분에는 branch 단위 요금이 붙습니다. 생성이 실패하지 않으므로 문제가 드러나지 않다가 청구서에서 발견됩니다.

생성 시점에 만료 시각을 설정하면 cleanup이 실패해도 배경 프로세스가 branch를 지웁니다.

- name: Compute expiry
id: expiry
run: echo "at=$(date -u -d '+7 days' +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"

- name: Set expiration
env:
NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
run: |
npm i -g neon
neon branches set-expiration preview/pr-${{ github.event.number }} \
--project-id ${{ vars.NEON_PROJECT_ID }} \
--expires-at ${{ steps.expiry.outputs.at }}

create-branch-action의 만료 입력 지원 여부는 버전에 따라 다릅니다. 지원하지 않으면 위와 같이 CLI로 별도 설정합니다. 만료 설정 기간은 최대 30일입니다. 보호 branch, default branch, 자식이 있는 branch에는 만료를 설정할 수 없습니다. 만료로 삭제한 branch는 복구되지 않습니다. 오래 유지해야 하는 branch에는 만료 대신 명시적 삭제를 씁니다.

비용

branch 자체는 diverge한 데이터만 storage를 차지하지만 compute는 다릅니다. create-branch-action은 기본으로 read-write compute를 하나 연결합니다. 테스트가 끝난 뒤 연결이 없으면 scale to zero 대기 시간인 기본 5분이 지난 후 멈춥니다. 활성 PR이 20개면 CI가 실행되는 동안 compute 20개가 동시에 동작하기도 합니다.

CI용 branch의 compute 크기는 테스트에 필요한 최소 수준으로 설정합니다. --cu 0.25나 작은 autoscaling 범위면 보통 충분합니다. 비용 구조는 6.1 비용 모델을 참고합니다.

Vercel 통합

Vercel의 preview deployment마다 branch를 만드는 Neon 관리형 통합이 있습니다. GitHub Actions 없이 Vercel이 배포를 만들면 Neon이 default branch에서 자식 branch를 만들고 환경 변수를 주입합니다. Vercel을 사용하지 않는 팀에는 위 Actions 패턴이 같은 결과를 제공합니다.

로컬 개발에서 같은 패턴: Neon Local

PR 단위 격리를 개발자 노트북에서도 사용하려면 Neon Local을 씁니다. neondatabase/neon_local 컨테이너는 로컬에서 PostgreSQL을 실행하지 않고 Neon 클라우드에 연결하는 프록시입니다. 컨테이너가 시작될 때 ephemeral branch를 만들고 종료할 때 지웁니다.

services:
db:
image: neondatabase/neon_local:latest
ports:
- "5432:5432"
environment:
NEON_API_KEY: ${NEON_API_KEY}
NEON_PROJECT_ID: ${NEON_PROJECT_ID}
PARENT_BRANCH_ID: ${PARENT_BRANCH_ID:-}
DELETE_BRANCH: "false"
volumes:
- ./.neon_local/:/tmp/.neon_local
- ./.git/HEAD:/tmp/.git/HEAD:ro,consistent

application은 postgres://neon:npg@localhost:5432/<db>?sslmode=require로 접속합니다. .neon_local/ 디렉터리와 .git/HEAD를 마운트하면 현재 git branch 이름에 대응하는 Neon branch를 기억합니다. git branch를 바꾸면 해당 branch의 database로 전환합니다. .neon_local/.gitignore에 넣습니다.

주의할 점이 셋 있습니다. 첫째, 인터넷 연결이 필요하며 오프라인에서는 동작하지 않습니다. 둘째, DELETE_BRANCH가 기본 true이므로 컨테이너를 내리면 branch가 사라집니다. 데이터를 남기려면 false로 둡니다. 셋째, 컨테이너가 self-signed 인증서를 사용하므로 클라이언트마다 TLS 설정이 필요합니다. Docker Desktop for Mac에서 git 연동을 사용하려면 파일 공유 방식을 VirtioFS 대신 gRPC FUSE로 바꿔야 합니다.

드라이버별 설정은 다음과 같습니다. psql이나 일반 PostgreSQL 클라이언트는 연결 문자열에 sslmode=no-verify를 씁니다. JavaScript의 pgpostgres 라이브러리는 ssl: { rejectUnauthorized: false }를 넘깁니다. serverless driver는 HTTP와 WebSocket 두 방식을 모두 지원합니다. WebSocket을 쓸 때는 webSocketConstructor를 지정합니다. useSecureWebSocketfalse, pipelineConnectfalse로 두고 wsProxy를 로컬 컨테이너로 향하게 설정합니다.

흔한 실패

synchronize 이벤트마다 migration을 다시 실행하면 이미 적용된 migration 때문에 실패하는 도구가 있습니다. 재실행이 안전한 도구를 사용합니다. 또는 커밋마다 reset-branch-action으로 부모 상태로 되돌린 뒤 migration을 처음부터 적용합니다.

fork에서 온 PR은 secrets에 접근하지 못합니다. 외부 기여자의 PR에서 branch 생성이 실패하면 pull_request_target 이벤트 사용을 검토합니다. 이때 신뢰하지 않는 코드가 secrets를 읽는 경로가 생기지 않도록 구성합니다.

연습 문제

  1. 위 두 workflow를 저장소에 넣고 PR을 열어 branch가 생기는지, 닫아서 지워지는지 확인합니다.
  2. schema-diff-action을 추가해 PR 코멘트로 schema 변경을 게시합니다.
  3. cleanup workflow를 의도적으로 실패시키고, expiration으로 branch가 지워지는지 확인합니다.

참고