기능 브랜치에서 UI를 고쳤는데 리뷰어가 그 변화를 확인하려고 브랜치를 체크아웃해 로컬에 띄워봐야 한다면 리뷰의 마찰이 크게 늘어난다. 코드 diff만 보고 “버튼 정렬이 실제로 어떻게 보이는지”, “이 API 변경이 프론트엔드에서 정말 동작하는지”를 판단하기는 어렵다. 그래서 성숙한 팀들은 PR마다 격리된 임시 배포 환경을 자동으로 만든다. 이것이 프리뷰 환경(preview environment) 패턴이다.
개념은 단순하지만 자동화를 잘못 설계하면 비용이 폭증하거나, 정리되지 않은 좀비 배포가 클러스터를 잠식하거나, 프리뷰용 시크릿이 새어 나가는 등 함정이 많다. 이 글에서는 프리뷰 환경의 생명주기(생성·갱신·정리), 상태를 가진 DB 처리, 고유 URL 라우팅, 비용·보안 통제까지 실제 GitHub Actions와 Kubernetes 설정으로 정리한다.
프리뷰 환경이 풀어야 하는 문제
프리뷰 환경의 목표는 하나다. 리뷰어가 클릭 한 번으로 PR의 실행 결과를 보게 하는 것이다. 이를 위해 자동화는 세 가지 이벤트를 다룬다. PR이 열리면(opened) 만들고, 새 커밋이 푸시되면(synchronize) 갱신하고, PR이 닫히면(closed) 지운다.
여기서 가장 많이 놓치는 것이 정리(cleanup)다. 생성 자동화는 눈에 보이니 다들 만들지만 삭제 자동화는 빠뜨리기 쉽다. 그 결과 머지된 지 몇 주 지난 PR의 배포가 여전히 살아 리소스를 잡아먹는다. 프리뷰 설계의 절반은 “확실히 지워지는가”다. 좋은 프리뷰는 네 가지를 만족한다. PR 간·프로덕션과의 격리, 예측 가능한 고유 URL, 닫히거나 시간이 지나면 회수되는 수명 제한, 프로덕션보다 가벼운 비용 통제다.
이벤트 기반 생명주기 설계
GitHub Actions에서 프리뷰 환경은 pull_request 이벤트로 제어한다. opened·synchronize·reopened에서는 배포하고 closed에서는 정리한다. 한 워크플로에서 잡을 나눠 조건을 걸면 흐름이 명확해진다.
name: preview
on:
pull_request:
types: [opened, synchronize, reopened, closed]
# 같은 PR에서 커밋이 연달아 올라오면 이전 배포 작업은 취소
concurrency:
group: preview-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
deploy:
# 닫힘 이벤트가 아닐 때만 배포
if: github.event.action != 'closed'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# PR 번호를 네임스페이스로 파생 (pr-1042) 후 빌드·배포 (아래 상세)
teardown:
# 닫힘(머지 포함) 이벤트일 때만 정리
if: github.event.action == 'closed'
runs-on: ubuntu-latest
steps:
- name: 프리뷰 네임스페이스 삭제
run: kubectl delete namespace "pr-${{ github.event.pull_request.number }}" --ignore-not-found
concurrency 블록이 중요하다. 이전 배포가 끝나기 전에 새 배포가 시작되면 둘이 경합해 상태가 어긋나는데, cancel-in-progress: true로 이전 실행을 취소하면 항상 최신 커밋 기준으로만 배포된다.
PR마다 격리된 네임스페이스
Kubernetes를 쓴다면 격리 단위로 네임스페이스가 가장 깔끔하다. PR 번호를 이름에 넣으면(pr-1042) PR 하나가 자기만의 클러스터 조각을 갖고, 네임스페이스 하나만 지우면 그 안의 모든 리소스가 함께 회수된다.
다만 네임스페이스만으로는 리소스 낭비를 막지 못한다. 프리뷰는 반드시 ResourceQuota로 상한을 걸어야 한다. 그렇지 않으면 한 PR의 배포가 노드 리소스를 독점해 다른 프리뷰까지 영향을 줄 수 있다.
# 프리뷰 네임스페이스에 적용할 쿼터 — 프리뷰는 작게 유지
apiVersion: v1
kind: ResourceQuota
metadata:
name: preview-quota
spec:
hard:
requests.cpu: "1" # 프리뷰 전체 CPU 요청 합 상한
requests.memory: 2Gi
limits.cpu: "2"
limits.memory: 4Gi
pods: "10" # 파드 수 폭주 방지
여기에 LimitRange를 함께 두면 limits 미지정 컨테이너에도 기본 상한이 적용돼 쿼터 소진을 막는다. 이미지는 PR별 고유 태그로 빌드한다. 커밋 SHA를 태그로 쓰면 정확히 그 커밋의 이미지가 배포된다. latest 같은 이동 태그는 어떤 커밋이 떠 있는지 추적이 안 돼 위험하다.
# PR 커밋 SHA를 이미지 태그로 사용
SHA="${GITHUB_SHA::12}"
IMAGE="registry.example.com/app:pr-${PR_NUMBER}-${SHA}"
docker build -t "$IMAGE" .
docker push "$IMAGE"
# Helm 으로 PR 전용 네임스페이스에 배포 (없으면 생성)
helm upgrade --install "app" ./chart
--namespace "pr-${PR_NUMBER}" --create-namespace
--set image.tag="pr-${PR_NUMBER}-${SHA}"
--set ingress.host="pr-${PR_NUMBER}.preview.example.com"
--wait --timeout 5m
고유 URL 라우팅과 와일드카드 인증서
각 PR에 pr-1042.preview.example.com 같은 고유 호스트를 부여하면 URL만으로 어느 PR인지 알 수 있다. DNS에 와일드카드 레코드(*.preview.example.com)를 한 번 걸어두고, Ingress가 호스트명으로 각 네임스페이스의 서비스로 라우팅하게 한다.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: preview
namespace: pr-1042
annotations:
cert-manager.io/cluster-issuer: letsencrypt # 인증서 자동 발급
spec:
rules:
- host: pr-1042.preview.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: app
port:
number: 80
tls:
- hosts: [pr-1042.preview.example.com]
secretName: preview-tls
TLS는 와일드카드 인증서(*.preview.example.com) 하나를 발급받아 공유하는 편이 실용적이다. PR마다 개별 인증서를 발급하면 발급 기관의 rate limit에 걸리기 쉽다. 와일드카드 인증서를 클러스터 시크릿으로 두고 모든 프리뷰 Ingress가 참조하게 한다.
상태를 가진 서비스: DB는 어떻게 할 것인가
프리뷰에서 가장 까다로운 부분이 데이터베이스다. 무상태 앱은 복제만 하면 되지만 DB는 스키마와 데이터가 필요하다. 접근 방식은 크게 세 가지다.
- PR마다 새 DB 인스턴스: 완전한 격리를 얻지만 프로비저닝이 느리고 비용이 크다.
- 공유 DB + PR별 스키마:
pr_1042같은 스키마로 격리한다. 빠르지만 정리를 잊으면 쌓인다. - 시드 데이터로 채운 임시 DB: 가벼운 DB를 네임스페이스 안에 함께 띄우고 시드로 최소 데이터만 넣는다. 프리뷰에 가장 잘 맞는다.
대부분의 프리뷰는 “동작 확인용 소량 데이터”면 충분하다. 그래서 네임스페이스 안에 임시 DB를 띄우고 마이그레이션 후 시드를 넣는 방식이 균형이 좋다. 네임스페이스를 지우면 DB도 함께 사라져 정리도 자동이다.
# 배포 후 마이그레이션·시드를 실행하는 Kubernetes Job
apiVersion: batch/v1
kind: Job
metadata:
name: db-migrate-seed
namespace: pr-1042
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: registry.example.com/app:pr-1042-abc123def456
command: ["/bin/sh", "-c"]
# 스키마 마이그레이션 후 프리뷰용 최소 시드 주입
args: ["app migrate up && app seed --profile preview"]
env:
# 같은 네임스페이스의 임시 DB 서비스를 가리킴
- name: DATABASE_URL
value: postgres://app:[email protected]:5432/app
절대 프로덕션 DB나 실데이터의 복사본을 프리뷰에 연결해서는 안 된다. 프리뷰는 접근 통제가 느슨하므로 실제 사용자 데이터가 들어오면 유출 위험이 크다. 실데이터가 필요하면 마스킹·익명화한 데이터셋을 별도로 준비해 시드로 쓴다.
시크릿과 접근 통제
프리뷰는 임시라는 이유로 보안이 느슨해지기 쉬운데, 이것이 흔한 사고 원인이다. 원칙은 프리뷰 전용의 낮은 권한 자격증명만 준다는 것이다. 프로덕션 결제 키를 프리뷰에 넣으면 실수로 진짜 결제·발송이 일어날 수 있다.
# 프로덕션과 완전히 분리된 프리뷰 전용 시크릿 주입
- name: 프리뷰 전용 시크릿 생성
run: |
kubectl create secret generic app-secrets
--namespace "pr-${PR_NUMBER}"
--from-literal=STRIPE_KEY="$PREVIEW_STRIPE_TEST_KEY"
--from-literal=SMTP_URL="smtp://mailhog.shared.svc:1025"
--dry-run=client -o yaml | kubectl apply -f -
env:
# 리포지토리 시크릿의 '테스트' 키만 노출
PREVIEW_STRIPE_TEST_KEY: ${{ secrets.PREVIEW_STRIPE_TEST_KEY }}
메일은 MailHog 같은 캐처를 공유로 띄워 프리뷰들이 그곳으로 보내게 하면, 실사용자에게 메일이 나갈 일 없이 발송 흐름을 확인할 수 있다. 외부로 나가는 부수효과(결제·발송·웹훅)는 프리뷰에서 샌드박스나 목(mock)으로 대체하는 것을 기본으로 삼는다. 배포가 끝나면 github-script 액션으로 PR에 프리뷰 URL을 코멘트하고 Deployments API의 environment_url을 채워둔다.
정리 자동화의 이중 안전망
앞서 closed 이벤트에서 네임스페이스를 지웠지만 이것만 믿으면 안 된다. 워크플로가 실패하거나 이벤트를 놓치면 정리가 누락된다. 그래서 시간 기반 정리(TTL)를 이중 안전망으로 둔다. 주기적으로 도는 잡이 생성 시각을 보고 오래된 것을 회수한다.
#!/usr/bin/env bash
# 예약 워크플로(cron)에서 실행 — 3일 넘은 프리뷰 네임스페이스 회수
set -euo pipefail
MAX_AGE_SECONDS=$((3 * 24 * 3600))
NOW=$(date +%s)
# preview=true 라벨이 붙은 네임스페이스만 대상
kubectl get ns -l preview=true -o json |
jq -r '.items[] | [.metadata.name, .metadata.creationTimestamp] | @tsv' |
while IFS=$'t' read -r ns created; do
created_epoch=$(date -d "$created" +%s)
age=$(( NOW - created_epoch ))
if (( age > MAX_AGE_SECONDS )); then
echo "만료된 프리뷰 삭제: $ns (age=${age}s)"
kubectl delete namespace "$ns" --ignore-not-found
fi
done
이 잡을 하루 한 번 돌리면 이벤트 기반 정리가 실패하더라도 좀비 프리뷰가 3일 이상 살아남지 못한다. 생성은 이벤트로, 정리는 이벤트 + TTL로 이중화하는 것이 핵심이다.
마무리
프리뷰 환경 자동화의 본질은 화려한 배포가 아니라 생명주기를 빈틈없이 닫는 것이다. PR 열림·갱신·닫힘 이벤트로 생성과 정리를 엮고, PR 번호를 격리 키로 삼아 네임스페이스·이미지 태그·URL을 파생시키며, ResourceQuota로 비용을 가두고 TTL로 정리 누락에 대비한다. DB는 시드된 임시 인스턴스로 대체하고, 시크릿은 프리뷰 전용 저권한 값만 준다.
자동화를 처음부터 완벽하게 만들려 하지 말고, 무상태 앱 하나를 PR별 URL로 띄우고 닫힐 때 지우는 최소 파이프라인부터 시작해 DB·시크릿·TTL을 점진적으로 붙이는 편이 안전하다. 다만 정리 자동화만큼은 첫날부터 포함해야 한다. 정리 없는 시스템은 며칠 안에 반드시 문제를 일으킨다.
자주 묻는 질문
Q. 프리뷰 환경마다 별도 클러스터를 써야 하나요?
A. 대부분은 필요 없습니다. 공유 클러스터 안에서 네임스페이스로 격리하고 ResourceQuota로 상한을 걸면 충분합니다. 보안 격리가 엄격해야 한다면 별도 노드 풀을 고려하되, 그 전에 NetworkPolicy로 충분한지 먼저 검토하세요.
Q. 프리뷰 빌드가 너무 오래 걸립니다. 어떻게 줄이나요?
A. 이미지 레이어·의존성 캐시를 활용하고 프리뷰 빌드는 프로덕션 최적화(소스맵 제거, 극한 압축)를 생략하는 것이 실용적입니다. 프리뷰의 목적은 성능이 아니라 동작 확인이므로 빠르게 뜨는 편이 낫습니다. 모노레포라면 영향받은 서비스만 배포합니다.
Q. 여러 마이크로서비스가 얽힌 앱도 프리뷰가 가능한가요?
A. 가능하지만 설계가 필요합니다. PR이 대상 서비스 하나만 프리뷰로 띄우고 나머지 의존 서비스는 공유 스테이징을 가리키게 하는 방식이 현실적입니다. 무엇을 격리하고 무엇을 공유할지가 마이크로서비스 프리뷰의 핵심입니다.