앱 하나를 만드는 작은 팀을 떠올려 보세요. 개발자 둘일 수도, 다섯일 수도 있습니다. 전문 분야가 나뉜 사람들이 아니라, 각자 자기 브랜치에서 데이터베이스 마이그레이션부터 API를 거쳐 모바일 앱 화면까지 기능 하나를 처음부터 끝까지 맡는 사람들입니다. 팀을 굴리기에 좋은 방식이고, 두 번째 개발자가 합류하는 순간부터 반복되는 실패가 하나 있습니다. “지금 스테이징 쓰는 사람 있어요?“라는 메시지입니다.
그 메시지 뒤에는 기능을 다 만들고 테스터나 디자이너에게 보여주고 싶은데 그럴 수 없는 개발자가 있습니다. 스테이징에는 두 번째 개발자의 브랜치가 올라가 있고, dev에는 세 번째 개발자의 브랜치가 올라가 있는데 그건 이틀 전에 로그인을 망가뜨린 뒤로 다시 배포되지 않았습니다. 몇 사람, 공유 환경 둘, 그리고 일정 조율을 떠맡은 Slack 스레드.
이건 사람 문제가 아닙니다. 토폴로지 문제입니다. 모든 개발자가 환경을 “내 브랜치가 실제가 되는 곳"으로 다루니, 환경이 머지가 해야 할 일을 브랜치 하나씩 차례로 대신하고 있고, 나머지 전부는 줄을 서 있는 겁니다.
제 스택의 프로덕션 형태는 My Current Stack에 적었습니다. Fly.io의 가장 싼 머신 위에서 Granian으로 띄운 Django, PgBouncer 뒤의 Fly Postgres, 캐시와 Channels 레이어용 Redis, 별도의 WebSocket 앱, 백그라운드 작업용 db_worker, 파일용 Tigris, Firebase Auth, 앞단의 Cloudflare, 그리고 이 모든 것과 통신하는 Expo 앱입니다. 이 글은 그 스택을, 어떤 브랜치든 개발자가 PR에 라벨 하나를 붙이면 몇 분 안에 HTTPS가 붙은 공개 복사본 전체를 얻는 것으로 바꾼 이야기입니다. 다른 누구에게 부탁하지도, 누가 프로비저닝해 주지도 않고요. Postgres 클러스터를 하나 더 두지도, Redis를 하나 더 두지도, 버킷을 하나 더 두지도 않고요. 인프라는 공유된 채로 남습니다. 달라지는 건 모든 공유 자원이 이름으로 잘리는 법을 배운다는 것이고, 그 이름은 브랜치에서 나옵니다. 여기에 팀 규모를 전제하는 건 없습니다. 개발자 둘이든 스물이든 설계는 같고, 늘어나는 건 앱 목록뿐이며 그건 스위퍼가 처리합니다. 오후 한나절에 만들고, 브랜치 셋을 동시에 띄우고, 예상 못 한 두 군데에서 부러뜨리고, 다시 걷어냈습니다. 아래 출력은 그 실행에서 나온 것입니다.
공유 환경 하나가 개발자 한 명 이상으로 늘어나지 못하는 이유#
공유 dev 환경에는 전제가 하나 깔려 있습니다. 모두가 보고 싶어 하는 “현재 코드 버전"이 하나라는 전제입니다. 개발자가 한 명이거나, 팀이 변경 흐름 하나를 발맞춰 작업할 때는 맞는 말입니다. 두 번째 개발자가 다른 일정으로 기능을 시작하는 순간 틀린 말이 되고, 처음부터 끝까지 맡는 방식에서는 모든 기능이 그렇습니다. 그때부터 환경이 쥐고 있는 모든 것이 쟁탈 대상이 됩니다.
- 배포된 코드. 마지막에 배포한 사람이 이깁니다. 나머지 사람들의 작업은 다시 배포하기 전까지 보이지 않고, 다시 배포하면 앞사람 것이 지워집니다.
- 데이터베이스 스키마. 개발자마다 자기 기능의 데이터 모델을 소유할 때 가장 아프게 무는 부분입니다. 첫 브랜치가 컬럼을 추가합니다. 한 시간 뒤 배포된 두 번째 브랜치는 그 컬럼을 모르고, ORM이 insert에서 실패하기 시작합니다. 아니면 두 브랜치 모두
0042_*마이그레이션을 갖고 있어서, 공유 데이터베이스가 누구의 마이그레이션 이력과도 맞지 않는 상태가 되고, 누군가 초기화하면서 세 번째 개발자가 오후 내내 세팅한 테스트 데이터를 지웁니다. - 캐시. 한 브랜치의 serializer 변경이 다른 브랜치 코드가 읽는 것과 같은 Redis 키 아래 캐시됩니다. 그래서 나오는 버그 리포트는 어리둥절하고, 하나에 오전 한나절씩 잡아먹습니다.
- 서드파티 연동. OAuth 콜백 하나, 웹훅 URL 하나, 푸시 인증서 하나. 누구 브랜치가 배포돼 있든 그 브랜치가 이벤트를 받습니다.
- 테스터. 배포된 것만 테스트할 수 있으니, 기능이 준비된 순서가 아니라 환경이 우연히 점유된 순서대로 한 번에 하나씩 테스트합니다.
팀은 예약 시스템으로 대응합니다. Slack 스레드, 고정 메시지, /claim staging 봇. 그건 락입니다. 작업을 보여줄 수 있는 유일한 장소에 걸린 락은 전달 속도에 걸린 락입니다. 개발자 둘이면 성가신 정도입니다. 셋이면 좋은 주에는 견딜 만합니다. 네 번째가 합류하거나, 기능 하나가 디자이너와 사흘간 주고받느라 스테이징을 붙들고 있으면 견딜 수 없게 됩니다.
사람들이 먼저 손대는 해결책은 세 번째 공유 환경, “dev2"나 “uat"입니다. 몇 주는 벌어 줍니다. 줄이 두 갈래가 됐을 뿐입니다.
진짜 해결책은 dev와 스테이징이 애초에 환경이 아니었다는 걸 알아차리는 것입니다. 그것들은 누군가의 브랜치의 현재 상태에 URL이 붙은 것이었습니다. 그렇다면 알맞은 개수는 지금 팀이 신경 쓰는 브랜치의 개수이고, 알맞은 수명은 브랜치의 수명입니다.
모델: 브랜치가 곧 환경#
목표 상태는 이렇습니다.
| 환경 | 개수 | 만드는 주체 | 없애는 주체 | 수명 |
|---|---|---|---|---|
| 브랜치 환경 (예전의 “dev”) | 활성 브랜치당 하나 | 브랜치의 PR에 붙는 env 라벨, 그다음은 push마다. PR 없는 스파이크라면 같은 ./scripts/env up을 손으로 | 브랜치 삭제, 그리고 매일 밤 도는 스위퍼 | 며칠 |
| 스테이징 | 릴리스 후보당 하나 | main에서 태그가 잘릴 때 CI | 같은 이미지가 프로덕션으로 승격되면 CI | 몇 시간 |
| 프로덕션 | 하나 | 당신이, 한 번 | 아무도 | 영원히 |
표에서 누가 만들고 누가 없애는지를 다시 보세요. 전부 git 이벤트 아니면 시계입니다. git이 컨트롤 플레인입니다. 존재해야 할 환경의 집합은 존재하는 브랜치의 집합에서 그대로 따라 나오고, 자동화가 하는 일은 현실을 거기에 맞춰 두는 것뿐입니다. PR의 라벨이 만들고, 그 뒤의 push마다 갱신하고, PR을 닫거나 머지하면 없애고, 매일 밤 도는 잡이 앞의 셋이 놓친 것을 정리합니다. 모든 브랜치가 환경을 얻는 것도 아닙니다. 라벨이 게이트이고 개발자의 클릭 한 번이니, 백업 push나 봇의 의존성 업데이트는 비용이 없습니다. 아무도 환경을 프로비저닝하지 않습니다. 티켓을 받은 DevOps 엔지니어도, Slack에서 부르는 봇도, 매번 프롬프트를 넣어야 하는 AI 에이전트도 아닙니다. 열 번째 개발자의 첫 브랜치도 첫 번째 개발자의 브랜치와 똑같은 방법으로, PR에 라벨을 붙였다는 이유로 환경을 얻습니다. 사람에게 부탁해야 한다면 확장된 게 아니라 줄이 자리를 옮긴 것뿐입니다.
그리고 이걸 굴러가게 하는 규칙은 하나입니다. 환경이 건드리는 모든 자원은 브랜치 이름을 따서, 결정론적으로 이름 붙인다. 개발자 이름도 아니고, 티켓 번호도 아니고, 누군가 열기 전까지는 존재하지도 않는 PR 번호도 아닙니다. 브랜치입니다. 개발자가 실제로 다루는 것이 브랜치이고, 이름이 결정론적이면 같은 브랜치에서 ./scripts/env up을 두 번째 실행했을 때 새 환경을 또 만드는 대신 이미 만든 환경을 찾아내기 때문입니다.
이름은 사람을 위한 슬러그와 유일성을 위한 짧은 해시입니다.
branch=$(git rev-parse --abbrev-ref HEAD) # feature/payments-retry
slug=$(printf '%s' "$branch" | tr '[:upper:]/_ ' '[:lower:]---' | tr -cd 'a-z0-9-' | cut -c1-20)
hash=$(printf '%s' "$branch" | sha256sum | cut -c1-6)
ENV="${slug%-}-${hash}" # feature-payments-retr-3f9a1cfeature-payments-retr-3f9a1c가 환경입니다. 이 문자열이 Fly 앱 이름, Postgres 데이터베이스 이름(하이픈은 밑줄로), Redis 키 접두사, Tigris 객체 접두사, Sentry environment, 그리고 호스트명의 맨 왼쪽 라벨이 됩니다. 브랜치 이름을 아는 사람은 누구나 계산할 수 있습니다. 어디서도 찾아볼 필요가 없습니다.
마지막 문장이 곧 설계입니다. 환경 목록도 없고 조정자도 없습니다. env up, env down, 호스트명을 라우팅하는 Cloudflare Worker, 밤마다 도는 스위퍼가 각자 브랜치에서 이름을 독립적으로 다시 계산하고, 구조적으로 서로 일치합니다. 특히 Cloudflare는 아무것도 조정하지 않습니다. Worker는 호스트명에서 Fly 앱으로 가는 순수 함수이고, 환경이 만들어졌는지 없어졌는지 영영 알지 못합니다. 앱이 있으면 요청이 닿고, 없으면 Fly가 자기 오류로 답합니다.
여기서 세 가지가 따라 나옵니다.
첫째, “스테이징 누가 쓰고 있어요"가 더는 질문이 아닙니다. feature-payments-retr-3f9a1c-dev.marucommunity.com이 있고 fix-login-redirect-8b21e0-dev.marucommunity.com이 있으며, 각각 그 브랜치를 맡은 사람의 것입니다.
둘째, 스테이징이 쓰레기장이 아니라 프로덕션의 정직한 리허설이 됩니다. 프로덕션에 갈 바로 그 이미지로, 몇 분 전 프로덕션 모양의 스냅샷에서 복제한 데이터베이스를 상대로 만들어집니다. 거기서 되면 프로덕션에 남은 변수는 프로덕션 데이터뿐입니다.
셋째, 개발자마다 공유 자원의 root 권한 없이도 힘을 얻습니다. 브랜치 환경의 폭발 반경은 그 브랜치 환경입니다. 네 번째로 합류한 사람이 첫날에 자기 브랜치에 뭘 배포하든, 최악의 결과는 자기 브랜치가 망가지는 것입니다.

koreapost 행들이 프로덕션입니다.물리적으로는 공유, 논리적으로는 분리#
“브랜치마다 환경"의 솔깃한 버전은 통째로 복사하는 것입니다. Postgres 클러스터 하나씩, Redis 하나씩, 버킷 하나씩. 깔끔하지만 잘못된 거래입니다. Fly Postgres 클러스터는 머신 하나에 볼륨 하나에 복원 한 번이고, Redis는 머신 하나 더입니다. 환경마다 몇 분씩 기다리고 놀고 있는 데이터베이스 열두 개 값을 내게 됩니다. 스위퍼가 치울 것도 한 종류가 아니라 네 종류가 됩니다.
대신 각 공유 서비스는 프리뷰 조직 전체에 한 번만, 작은 프로덕션 크기로 마련하고, 모든 환경이 $ENV를 키로 한 조각씩 받습니다. 벽에 붙여 두고 싶은 표입니다.
| 자원 | 공유되는 것 | 환경별인 것 | 강제 수단 |
|---|---|---|---|
| 컴퓨트 | Fly 조직 maru-preview | Fly 앱 하나 maru-$ENV, 머신 하나 | Fly: 앱이 격리 단위 |
| 시크릿 | Fly의 앱별 시크릿 저장소 | 앱 자신의 시크릿. SECRET_KEY, FIELD_KEY, ENV_TOKEN은 복사가 아니라 환경별로 생성 | Fly: 시크릿은 앱에 속하고, 머신은 자기 것만 읽음 |
| Postgres | Fly Postgres 클러스터 하나 maru-preview-pg | 데이터베이스 하나 env_feature_payments_retr_3f9a1c, seed_template에서 복제 | Postgres: 데이터베이스는 단단한 경계, 데이터베이스 간 쿼리 불가 |
| Redis | Upstash/Fly Redis 하나 | 캐시와 Channels 레이어에 키 접두사 feature-payments-retr-3f9a1c: | Django KEY_PREFIX, channels_redis prefix |
| 파일 | Tigris 버킷 하나 maru-preview | 객체 접두사 feature-payments-retr-3f9a1c/ | django-storages location; presigned URL은 이미 키 단위로 범위가 잡힘 |
| 호스트명 | 존에 이미 있는 프록시된 *.marucommunity.com 레코드 | 맨 왼쪽 라벨 <env>-dev | *-dev.marucommunity.com/*에 걸린 Cloudflare Worker 라우트 |
| 인증 | Firebase 프로젝트 하나 maru-dev | 없음. 사용자 신원은 일부러 공유 (아래 참고) | 승인된 도메인 marucommunity.com |
| 오류와 로그 | Sentry 프로젝트 하나, Fly 로그 | 모든 것에 environment=$ENV 태그 | 설정 |
둘은 한마디씩 덧붙일 만합니다.
Postgres: 스키마가 아니라 데이터베이스. 클러스터 하나를 자르는 다른 방법은 환경마다 스키마를 두고 연결마다 search_path를 설정하는 것입니다. 연결 options로 -c search_path=...를 밀어 넣으면 Django도 해 주지만, 트랜잭션 풀링 모드의 PgBouncer는 따로 말해 주지 않는 한 시작 옵션을 버리고, 그러고 나면 어차피 풀의 모든 연결이 한 환경에 속해야 합니다. “왜 내 쿼리가 엉뚱한 테이블을 치지"류의 버그를 낳는 원천이고, 환경마다 데이터베이스를 두면 그냥 없는 문제입니다. 데이터베이스는 단단한 경계이고, CREATE DATABASE ... TEMPLATE은 몇 초 만에 하나를 만들며, DROP DATABASE는 아무것도 남기지 않고 지웁니다. 브랜치 환경은 PgBouncer를 거치지 않고 5433 포트로 Postgres에 직접 붙습니다. 프로덕션의 db_worker가 하는 것과 같은 방식입니다. 풀러는 프로덕션 동시성에서 제 값을 하는데, 브랜치 환경은 그런 동시성을 볼 일이 없습니다.
시크릿: 공유가 아니라 생성. dev 시크릿 한 벌을 만들어 모든 환경에 복사하면 편할 겁니다. 하지 마세요. env up 스크립트는 환경마다 새 SECRET_KEY(세션 서명), FIELD_KEY(저장 시 암호화되는 모든 것), ENV_TOKEN(모바일 앱이 제시하는 bearer 토큰, 아래에서 더 설명)을 생성해 그 Fly 앱의 시크릿 저장소에만 넣습니다. 한 브랜치의 세션 쿠키는 다른 브랜치에서 쓸모없고, 유출된 프리뷰 토큰은 프리뷰 하나만 열며, 개발자는 일부러 찾아보지 않는 한 값을 볼 일이 없습니다. Fly만이 아니라 1Password나 Doppler 같은 곳에 시크릿을 둔다면 같은 규칙이 적용됩니다. 경로는 maru/preview/$ENV/이고, 스위퍼가 환경과 함께 그 경로를 지웁니다. 환경 간에 정말로 공유되는 시크릿은 공유 인프라 자체의 자격 증명(Postgres 관리자 URL, Redis URL, Tigris 키)뿐이고, 그건 어느 환경도 아닌 CI에 삽니다.
Redis 행이 가장 의심하기 쉬우니, 테스트해 봤습니다. 한 환경 안에서 캐시 키를 넣고, 다른 환경에서 읽고, 공유 Redis의 모든 키를 나열합니다.
$ fly ssh console -a maru-feature-profile-tagl-fa8832 -C "python manage.py shell -c \"from django.core.cache import cache; cache.set('who-am-i','tagline-branch',600)\""
$ fly ssh console -a maru-feature-profile-webs-dcb2e7 -C "python manage.py shell -c \"from django.core.cache import cache; print(cache.get('who-am-i'))\""
None
$ # every key in the shared Redis, scanned from the website machine
feature-profile-tagl-fa8832:1:who-am-i
release:89112c88f7da008e:done
...같은 Redis, 같은 키 이름인데 다른 환경에서는 아무것도 보이지 않습니다. (저 목록의 release:* 키는 위에서 언급한 릴리스 게이트 버그로, 바로 이 스캔에서 잡혔습니다. 지금은 접두사 아래에 있습니다.)
이 글의 나머지는 저 여덟 행에 $ENV를 찍는 스크립트와, 그걸 정직하게 유지하는 규칙입니다.
./scripts/env up#
개발자가 자기 브랜치에서 실행하는 스크립트로서의 전부입니다. CI도 같은 스크립트를 실행합니다. 따로 놀다 어긋날 CI 전용 경로가 없습니다. 노트북에 필요한 건 fly와 git뿐입니다. SQL은 fly ssh로 Postgres 앱에 가고, 환경의 Redis 키와 Tigris 객체는 없애기 전에 환경 자신의 머신이 지우므로, 노트북은 gitignore된 .env.preview 말고는 공유 Redis나 버킷 자격 증명을 쥐고 있지 않습니다.
#!/usr/bin/env bash
# scripts/env up|down|name|url — one environment per branch on shared preview infrastructure
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
[[ -f .env.preview ]] && { set -a; source .env.preview; set +a; } # shared-infra credentials; CI secrets in Actions
ORG=${PREVIEW_ORG:-korea-post}
PREVIEW_PG_APP=${PREVIEW_PG_APP:-maru-preview-pg}
BASE_DOMAIN=${BASE_DOMAIN:-marucommunity.com}
branch=${BRANCH:-$(git rev-parse --abbrev-ref HEAD)}
[[ "$branch" == "main" ]] && { echo "main has no branch environment; it has staging" >&2; exit 1; }
slug=$(printf '%s' "$branch" | tr '[:upper:]/_ ' '[:lower:]---' | tr -cd 'a-z0-9-' | cut -c1-20); slug=${slug%-}
hash=$(printf '%s' "$branch" | shasum -a 256 | cut -c1-6)
ENV="${slug}-${hash}"
[[ -n "${ENV_OVERRIDE:-}" ]] && ENV="$ENV_OVERRIDE" # the sweeper only knows the name
APP="maru-${ENV}"
DB="env_${ENV//-/_}"
HOST="${ENV}-dev.${BASE_DOMAIN}"
pg() { fly ssh console -a "$PREVIEW_PG_APP" -q -C "psql postgres://postgres:${PREVIEW_PG_PASSWORD}@localhost:5433/postgres -At -c \"$1\""; }
up() {
echo "== $branch -> $ENV"
# 1. Compute. Idempotent: an app that exists is left alone.
fly apps list --org "$ORG" --json | grep -q "\"Name\": *\"$APP\"" || fly apps create "$APP" --org "$ORG"
# 2. Database: clone the seed template unless this branch already has one.
if [[ "$(pg "SELECT 1 FROM pg_database WHERE datname='$DB'")" != "1" ]]; then
pg "CREATE DATABASE $DB TEMPLATE seed_template"
fi
DATABASE_URL="postgres://postgres:${PREVIEW_PG_PASSWORD}@${PREVIEW_PG_APP}.flycast:5432/${DB}"
# 3. Secrets. Generated ones are generated once; a later `up` must not rotate them.
existing=$(fly secrets list -a "$APP" --json 2>/dev/null | grep -o '"Name": *"[^"]*"' | cut -d'"' -f4 || true)
gen() { grep -qx "$1" <<<"$existing" || printf '%s=%s\n' "$1" "$(openssl rand -hex 32)"; }
{
gen SECRET_KEY
gen ENV_TOKEN
cat <<S
ENV_NAME=$ENV
ALLOWED_HOSTS=$HOST,$APP.fly.dev
CSRF_TRUSTED_ORIGINS=https://$HOST,https://$APP.fly.dev
DATABASE_URL=$DATABASE_URL
REDIS_URL=$PREVIEW_REDIS_URL
BUCKET_NAME=$PREVIEW_BUCKET
AWS_ENDPOINT_URL_S3=https://fly.storage.tigris.dev
AWS_ACCESS_KEY_ID=$PREVIEW_AWS_ACCESS_KEY_ID
AWS_SECRET_ACCESS_KEY=$PREVIEW_AWS_SECRET_ACCESS_KEY
WS_URL=wss://$HOST
S
} | fly secrets import -a "$APP" --stage
# 4. Deploy the working tree, stamped with what it is.
fly deploy -a "$APP" --config fly.preview.toml --remote-only --ha=false \
--build-arg GIT_SHA="$(git rev-parse --short HEAD)" --build-arg GIT_BRANCH="$branch" \
--image-label "$(git rev-parse --short HEAD)"
echo "https://$HOST"
}
down() {
[[ "$APP" =~ ^maru-[a-z0-9-]+-[0-9a-f]{6}$ ]] || { echo "refusing to destroy $APP" >&2; exit 1; }
if fly apps list --org "$ORG" --json | grep -q "\"Name\": *\"$APP\""; then
fly machine start -a "$APP" >/dev/null 2>&1 || true
fly ssh console -a "$APP" -q -C "python manage.py env_teardown" || echo "warning: self-teardown failed; the sweeper will catch stragglers" >&2
fly apps destroy "$APP" --yes
fi
pg "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname='$DB'" >/dev/null || true
pg "DROP DATABASE IF EXISTS $DB" || true
}
case "${1:-}" in up) up ;; down) down ;; name) echo "$ENV" ;; url) echo "https://$HOST" ;; esac데모에서는 push하는 대신 제 노트북에서 직접 돌렸습니다. 브랜치 셋이 저장소 히스토리에 남기고 싶지 않은 일회용이었고, 출력이 흘러가는 걸 지켜보고 싶었기 때문입니다. 팀에서 쓸 때는 아무도 이걸 타이핑하지 않습니다. 아래에 나오는 라벨 워크플로가 정확히 이 스크립트를 돌리고, 지금부터 보이는 출력은 Actions 로그에 남을 그것입니다. 툴링 자체를 담은 브랜치에서 처음 실행했을 때 찍힌 출력입니다.
$ ./scripts/env up
== feature/branch-environments -> feature-branch-envir-c65cb4
New app created: maru-feature-branch-envir-c65cb4
creating database env_feature_branch_envir_c65cb4 from seed_template
CREATE DATABASE
Secrets have been staged, but not set on VMs. Deploy or update machines in this app for the secrets to take effect.
==> Building image with Depot
image: registry.fly.io/maru-feature-branch-envir-c65cb4:87c061b9
image size: 139 MB
> Machine 865529aee76918 [app] was created
✔ Machine 865529aee76918 [app] update finished: success
https://feature-branch-envir-c65cb4-dev.marucommunity.com
$ curl -s https://feature-branch-envir-c65cb4-dev.marucommunity.com/api/version/
{"environment": "feature-branch-envir-c65cb4", "branch": "feature/branch-environments", "commit": "87c061b9",
"image": "registry.fly.io/maru-feature-branch-envir-c65cb4:87c061b9", "app": "maru-feature-branch-envir-c65cb4", "region": "syd"}명령에서 URL까지 2분 30초쯤이고, 대부분은 이미지 빌드입니다. 커밋 하나 더 한 뒤 같은 브랜치에서 두 번째 up을 하니 “New app created"도 “creating database"도 찍히지 않았고, 같은 시크릿 이름을 스테이징하되 생성된 둘은 다시 만들지 않았으며, 다시 빌드했습니다. 같은 URL, 새 커밋.
안에 든 선택 몇 가지는 한 문장씩 값을 합니다.
fly.preview.toml은 fly.toml이 아닙니다. 프로덕션은 API, WebSocket 서버, db_worker를 별도 Fly 앱과 프로세스 그룹으로 굴려 각자 자기 지표로 스케일하게 하고, 그게 스택 글의 논지 전부입니다. 브랜치 환경은 스케일할 필요가 없습니다. 머신 하나여야 합니다. 그래서 프리뷰 설정은 프로세스가 하나이고, entrypoint.sh에 granian-preview 모드가 생겨 릴리스 게이트와 마이그레이션을 돌린 뒤 --ws를 붙인 ASGI Granian 하나를 띄웁니다. asgi.py의 ProtocolTypeRouter가 이미 http와 websocket을 모두 라우팅하니 프로세스 하나가 앱 전체를 서빙합니다. 그 모드에서 동기 뷰는 thread_sensitive로, 대략 한 번에 요청 하나씩 돕니다. 프로덕션에서 제가 벗어난 바로 그 거래인데, 여기서는 정확히 맞는 거래입니다. 브랜치를 테스트하는 사람은 아무도 눈치채지 못하니까요.
# fly.preview.toml (the app name is supplied by `fly deploy --app`)
app = 'branch-environment-placeholder'
primary_region = 'syd'
[processes]
app = "granian-preview"
[env]
DJANGO_SETTINGS_MODULE = 'koreapost_project.settings'
DEBUG = 'False'
WEB_BUNDLE_FROM_BUCKET = '0' # the image's own web bundle; the preview bucket never holds one
[http_service]
internal_port = 8000
force_https = true
auto_stop_machines = 'suspend'
auto_start_machines = true
min_machines_running = 0
[[http_service.checks]]
path = '/api/health/'
[http_service.checks.headers]
Host = '127.0.0.1' # base.py allows loopback; the app's own hostname isn't known to a static file
[[vm]]
size = 'shared-cpu-1x'
memory = '1gb'auto_stop_machines = "suspend", min_machines_running = 0. 점심 이후 아무도 열지 않은 브랜치 환경은 비용이 0이어야 합니다. stop 대신 suspend를 쓰면 30초 콜드 스타트 대신 504를 쫓다가 도달한 3초쯤의 재개를 얻는데, 여기서는 30초라도 견딜 만합니다. 데모 환경 셋 중 둘은 배포가 끝나고 1분 만에 fly apps list에서 이미 suspended였습니다.

DEBUG = False, 프로덕션과 같은 settings 모듈. 브랜치 환경은 공개 URL입니다. 테스트에 중요한 모든 면(진짜 HTTPS, 진짜 쿠키, 같은 헬스 체크)에서 프로덕션처럼 굴고, 누군가를 다치게 할 수 있는 모든 면에서는 프로덕션과 달라야 합니다. 별도의 preview.py 대신, 차이는 env up이 설정하는 시크릿이 실어 나릅니다. 샌드박스 결제 키, Firebase 관리자 자격 증명 없음, 그리고 아래의 모든 접두사를 켜는 ENV_NAME.
설정 하나가 모든 것을 자릅니다. ENV_NAME은 settings/base.py에 새로 들어간 유일한 손잡이이고, 네 곳에 적용됩니다.
ENV_NAME = os.getenv("ENV_NAME", "")
_ENV_PREFIX = f"{ENV_NAME}/" if ENV_NAME else ""
CACHES["default"]["KEY_PREFIX"] = ENV_NAME # every cache key, and the list-cache version counters
CHANNEL_LAYERS["default"]["CONFIG"]["prefix"] = f"{ENV_NAME}:asgi" # group and channel names
STORAGES["default"]["OPTIONS"]["location"] = f"{_ENV_PREFIX}media" # uploads
STORAGES["staticfiles"]["OPTIONS"]["location"] = f"{_ENV_PREFIX}static"channels 쪽을 빠뜨리면 두 브랜치의 WebSocket 서버가 그룹 이름을 공유해서, 한 환경에서 보낸 메시지가 다른 환경에 나타납니다. 하루를 잡아먹는 종류의 버그입니다.
다섯 번째 자리는 나중에 공유 Redis를 읽어 보고서야 찾았습니다. 504 글의 릴리스 게이트, 즉 이미지당 머신 하나가 마이그레이션을 돌리는 동안 나머지가 기다리게 하는 장치는 이미지 참조의 해시로 락 키를 만듭니다. 같은 커밋에서 빌드된 브랜치 환경 둘은 같은 이미지를 갖습니다. 두 번째 환경은 첫 번째의 “done” 마커를 발견하고, 마이그레이션되지 않은 자기 데이터베이스에 대한 자기 마이그레이션을 건너뛰었을 겁니다. 지금은 환경 이름이 해시되는 값에 들어가고 키는 접두사 아래에 삽니다. 공유 인프라 설계가 계속 만들어 내는 종류의 문제입니다. 암묵적으로 “배포당"이던 모든 것을 명시적으로 환경당으로 만들어야 하고, 뭐가 암묵적이었는지는 들여다보기 전까지 모릅니다.
데이터베이스는 빈 상태에서 마이그레이션하는 게 아니라 복제합니다. 다음 절입니다.
데이터베이스: 몇 초 만에 복제되는 템플릿#
브랜치 환경의 데이터베이스는 셋 중 하나일 수 있습니다. 모두와 공유. 우리를 여기까지 데려온 방식입니다. 빈 상태에서 마이그레이션하고 픽스처로 시드. 결정론적이지만, 픽스처는 실제 모양의 데이터만큼 좋았던 적이 없고 언제나 열여덟 달은 묵어 있습니다. 아니면 프로덕션 모양 스냅샷의 복제. 현실적인 행 수, 픽스처는 늘 채워져 있다고 가정했던 컬럼의 현실적인 null, 빈 테이블에서는 즉시 끝나지만 실제 테이블에서는 40분 걸리는 마이그레이션이 제 모습을 드러냅니다.
세 번째로 가되, 두 번째는 CI 전용 검사로 남깁니다. 그리고 세 번째를 브랜치마다 할 만큼 빠르게 만드는 건 대부분이 있는지도 잊은 Postgres 기능입니다.
-- Nightly, on maru-preview-pg. Restore last night's prod backup into seed_raw,
-- run the anonymiser, then freeze it as a template nobody can connect to.
ALTER DATABASE seed_raw RENAME TO seed_template;
UPDATE pg_database SET datistemplate = true, datallowconn = false WHERE datname = 'seed_template';
-- Per branch, in the time it takes to copy the files:
CREATE DATABASE env_feature_payments_retr_3f9a1c TEMPLATE seed_template;CREATE DATABASE ... TEMPLATE은 클러스터 안에서의 파일 수준 복사입니다. 몇 기가바이트면 pg_restore가 걸리는 몇 분이 아니라 몇 초입니다. 복제되는 동안 템플릿에는 열린 연결이 있으면 안 되고, datallowconn = false가 그걸 강제합니다. 밤 작업이 살아 있는 템플릿 위에 복원하는 대신 seed_raw에 빌드하고 마지막에 이름을 바꾸는 이유이기도 합니다.
데모에서는 프로덕션 덤프 대신 손으로 템플릿을 만들었습니다. fly proxy로 프리뷰 클러스터에 터널을 뚫고, manage.py migrate, 그다음 저장소에 있는 시드 명령으로 분류 체계, 대화가 있는 구매자와 판매자 한 쌍, 커뮤니티 글 스무 개. 19메가바이트. 브랜치 셋이 올라오면서 클러스터에 복제본 셋이 나타났고, 각각 19 MB, 각각 1초 미만이었습니다.
$ fly ssh console -a maru-preview-pg -C "psql ... -c '\l'"
datname | template | size
---------------------------------+----------+-------
env_feature_branch_envir_c65cb4 | f | 19 MB
env_feature_profile_tagl_fa8832 | f | 19 MB
env_feature_profile_webs_dcb2e7 | f | 19 MB
seed_template | t | 19 MB프리뷰 클러스터는 관리형이 아닌 Fly Postgres 노드 하나, 1 GB 볼륨을 단 shared-cpu-1x로, 한 달에 2달러쯤입니다. 브랜치 환경은 사설 네트워크의 .flycast로 붙습니다. 공개된 것은 아무것도 없습니다.
익명화기는 선택이 아닙니다. 프로덕션 데이터가 -dev 호스트명에서 닿는 순간, 그건 프로덕션 데이터가 아니어야 합니다. 이름, 이메일, 전화번호, 주소, 자유 텍스트, 결제 참조, 토큰을 이름을 바꾸기 전에 전부 덮어씁니다. 익명화기가 실패하면 전날 밤 템플릿이 남고 누군가 메시지를 받습니다. 작은 일이고, 이 접근 전체를 개인정보 설문에 답하는 사람이 받아들일 수 있게 만드는 바로 그 일입니다.
entrypoint.sh의 릴리스 게이트가 모든 이미지의 첫 부팅에서 migrate를 돌리므로, 브랜치 자신의 마이그레이션이 템플릿 위에 적용됩니다. “내 마이그레이션이 프로덕션 모양의 데이터에서 되나"의 첫 정직한 테스트이고, 누가 URL을 열어 보기 전에 일어납니다.
호스트명: 와일드카드 하나, Worker 하나, 그리고 문서가 알려주지 않은 두 가지#
모든 환경에는 공개 HTTPS 호스트명이 필요하고, “공개"가 핵심입니다. 폰을 든 테스터, 다른 네트워크의 디자이너, VPN을 깔지 않을 프로덕트 매니저. Fly는 앱마다 maru-$ENV.fly.dev를 공짜로 주고, 그것만으로도 됩니다. 하지만 *.fly.dev 호스트명에는 문제가 셋 있습니다. 보내기에 못생겼고, 앞에 Cloudflare가 없어서 프로덕션에 걸어 둔 보호가 하나도 적용되지 않으며, 자기 도메인에 두고 싶으면 환경마다 DNS 레코드 하나와 인증서 하나를 만들어야 하니 스위퍼가 치울 것이 하나 더 생깁니다.
그걸 전부 없애는 버전은 와일드카드 하나와 Cloudflare Worker 하나입니다. 계획은 *.dev.marucommunity.com이었습니다. 두 가지가 막았고, 같은 걸 계획하기 전에 둘 다 알아 둘 가치가 있습니다.
Universal SSL은 와일드카드 한 단계만 덮습니다. Cloudflare의 무료 인증서는 marucommunity.com과 *.marucommunity.com에 발급됩니다. *.dev.marucommunity.com은 덮지 않습니다. 두 단계 와일드카드에는 월 10달러의 Advanced Certificate Manager가 필요합니다. feature-branch-envir-c65cb4.dev.marucommunity.com으로의 첫 요청은 제 것이 무엇 하나 돌기도 전에 SSL 핸드셰이크 실패로 죽었습니다. 해결은 환경을 첫 라벨에 두는 것입니다. feature-branch-envir-c65cb4-dev.marucommunity.com. 이미 있는 인증서가 덮고, 이미 있는 프록시된 *.marucommunity.com 레코드가 덮습니다. 지어낸 서브도메인을 훑는 스캐너용 싱크홀로 제가 만들어 둔 그 레코드입니다. 그러니 환경에는 DNS가 아예 필요 없습니다.
Redirect Rules는 Workers보다 먼저 돕니다. 라우트를 걸어 두자 모든 -dev 호스트명이 Worker가 돌지도 않은 채 www 홈페이지로 301을 답했습니다. 싱크홀은 존 수준 리다이렉트 규칙 “그 밖의 모든 서브도메인 → www"이고, Cloudflare의 리다이렉트 단계는 Workers 단계보다 먼저 실행됩니다. 규칙에 절 하나가 필요했습니다. and not ends_with(http.host, "-dev.marucommunity.com"). 배포 스크립트가 up에서 넣고 down에서 빼는데, 누군가 손으로 고친 규칙이야말로 잊히는 것이기 때문입니다.

-dev.marucommunity.com으로 끝나는 호스트명만 Worker로 흘러갑니다.DNS 쪽은 이미 있던 그 레코드입니다.

그 둘을 치우고 나면 라우팅은 *-dev.marucommunity.com/*에 걸린 Worker 하나입니다.
// infra/dev-router.js — <env>-dev.marucommunity.com -> maru-<env>.fly.dev
const DEV_SUFFIX = "-dev.marucommunity.com";
const ENV_NAME = /^[a-z0-9-]+-[0-9a-f]{6}$/; // slug-hash, as scripts/env names them
export default {
async fetch(request) {
const url = new URL(request.url);
if (!url.hostname.endsWith(DEV_SUFFIX)) return new Response("not a dev hostname", { status: 404 });
const env = url.hostname.slice(0, -DEV_SUFFIX.length);
if (!ENV_NAME.test(env)) return new Response(`no such environment: ${env}`, { status: 404 });
url.hostname = `maru-${env}.fly.dev`;
const upstream = new Request(url, request);
upstream.headers.set("X-Forwarded-Host", request.headers.get("host") ?? "");
const res = await fetch(upstream);
const out = new Response(res.body, res);
out.headers.set("X-Robots-Tag", "noindex, nofollow"); // a half-finished branch is not for search engines
out.headers.set("X-Dev-Environment", env);
return out;
},
};Cloudflare가 와일드카드에서 TLS를 종단하고, Worker가 라벨에서 Fly 호스트명을 계산하며, 머신까지의 구간은 Fly 자체의 *.fly.dev 인증서가 덮습니다. WebSocket 업그레이드는 fetch를 그대로 통과합니다. 그리고 라우트가 존에 있으니 Cloudflare가 프로덕션에 해 주는 모든 것을 쓸 수 있습니다. WAF 규칙, 속도 제한, 봇 대응 모드, 그리고 Access.

브랜치 셋을 동시에 띄우고, Worker 하나, 각 호스트명이 답한 것.
| 호스트명 | HTTP | 라우팅 대상 | branch | commit |
|---|---|---|---|---|
feature-branch-envir-c65cb4-dev.marucommunity.com | 200 | maru-feature-branch-envir-c65cb4.fly.dev | feature/branch-environments | 6560ec9a |
feature-profile-tagl-fa8832-dev.marucommunity.com | 200 | maru-feature-profile-tagl-fa8832.fly.dev | feature/profile-tagline | e3baa936 |
feature-profile-webs-dcb2e7-dev.marucommunity.com | 200 | maru-feature-profile-webs-dcb2e7.fly.dev | feature/profile-website | beb08161 |
not-an-env-dev.marucommunity.com | 404 | 없음. Worker가 라벨을 거부 |
모든 200에는 Worker가 붙인 X-Robots-Tag: noindex, nofollow와 X-Dev-Environment: <env>가 함께 왔습니다. 404는 Fly에 묻기도 전에 Worker가 직접 낸 것입니다.
Cloudflare Access는 “공개"를 “닿아야 할 사람에게 닿음"으로 바꾸는 장치입니다. *-dev.marucommunity.com에 애플리케이션 하나, “@marucommunity.com 이메일이면 누구나” 정책 하나면 브라우저 방문자는 로그인 페이지를 한 번 보고 다시는 신경 쓰지 않습니다. 노트북의 디자이너, PM, 테스터는 그걸로 됩니다. 데모에는 넣지 않았습니다. 환경은 한 시간 동안 noindex를 달고 시드 데이터 말고는 아무것도 없이 떠 있었습니다.
모바일 앱은 덮지 못합니다. 네이티브 앱은 Access 로그인 리다이렉트를 완료할 수 없으니까요. 길은 둘입니다. Access는 로그인을 우회하는 서비스 토큰(CF-Access-Client-Id / CF-Access-Client-Secret 헤더 쌍)을 지원하고, Expo 앱의 dev 메뉴가 dev 호스트명용 한 쌍을 실을 수 있습니다. 아니면 더 단순하게, /api/에 Access 우회를 넣고 Django가 Firebase 사용자나 환경의 ENV_TOKEN, 즉 env up이 바로 이 환경을 위해 생성한 그 토큰이 없는 요청을 거부하게 합니다. 어느 쪽이든 Expo 쪽은 기본 URL과 토큰 두 필드가 있는 dev 메뉴 화면이고, 테스터가 이미 설치한 앱이 이제 그 브랜치와 통신합니다. 새 빌드는 없습니다. OTA 업데이트 채널은 앱이 빌드된 대로 그대로 두고, API 기본 URL은 빌드 시점이 아니라 런타임 설정이며, 원래 그랬어야 합니다.

Firebase Auth는 일부러 공유하는 유일한 공유 서비스입니다. 신원은 환경에 따라 다르지 않습니다. 테스터는 모든 브랜치에서 같은 계정으로 로그인하고 싶어 합니다. 승인된 도메인에 marucommunity.com을 넣은 dev Firebase 프로젝트 하나가 모든 -dev 호스트명을 덮고, 모든 환경이 그걸 상대로 ID 토큰을 검증합니다. 각 환경 데이터베이스의 사용자 테이블은 템플릿 사용자의 복사본이니, 테스터의 계정은 템플릿에 있던 모든 곳에 있습니다.
GitHub으로 자동화하기: 이벤트가 들어가고 환경이 나온다#
위의 모든 것을 손으로 하는 건 혼자 할 때는 괜찮습니다. 두 번째 개발자가 생기는 순간부터는 누가 기억해서가 아니라 git에서 뭔가 일어났기 때문에 일어나야 합니다. 자동화 전체는 워크플로 넷, 저장소 설정 하나, 룰셋 하나, GitHub environment 하나이고, 그 하나하나가 git 이벤트를 개발자가 손으로 돌리는 것과 같은 scripts/env 호출에 대응시킵니다. 따로 놀다 어긋날 CI 전용 경로는 없습니다.
| Git 이벤트 | 실행되는 것 | 하는 일 |
|---|---|---|
PR에 env 라벨이 붙음 | branch-env.yml → scripts/env up | 그 브랜치의 환경을 만들고, PR에 URL을 남김 |
| 라벨 붙은 PR의 브랜치에 push | branch-env.yml → scripts/env up | 제자리에서 갱신 |
| 라벨 제거, PR 닫힘 또는 머지 | branch-env.yml → scripts/env down | 환경이 자기 조각을 치우고 없어짐 |
| 브랜치 삭제 | branch-env.yml → scripts/env down | 손으로 띄웠고 PR이 없던 브랜치를 위한 뒷받침; 저장소 설정 delete branch on merge가 머지 때도 이걸 발생시킴 |
| 그 외 브랜치에 push | 아무것도 | 백업 push나 스파이크는 누군가 보고 싶다고 하기 전까지 비용이 없음 |
main으로의 pull request | migrations-check.yml | main을 머지한 뒤 마이그레이션 그래프의 리프가 하나인지; 빈 상태에서 전체 체인이 적용되는지 |
v* 태그 push | release.yml | 태그에서 스테이징 환경; 리뷰어의 승인이 같은 이미지를 프로덕션으로 승격하고 스테이징을 없앰 |
| 매일 밤 02:00 | sweep-envs.yml → scripts/sweep-envs | 브랜치가 사라졌거나 일주일 놀고 있는 것을 전부 없앰 |
라벨, push, delete 워크플로#
# .github/workflows/branch-env.yml
name: branch environment
on:
pull_request:
types: [labeled, unlabeled, synchronize, closed]
delete:
workflow_dispatch:
inputs:
branch:
description: branch to bring up without a PR
required: true
concurrency:
group: env-${{ github.head_ref || github.event.ref || inputs.branch }}
cancel-in-progress: true
env:
FLY_API_TOKEN: ${{ secrets.FLY_PREVIEW_TOKEN }} # org-scoped deploy token; a separate preview org keeps it away from prod
PREVIEW_PG_PASSWORD: ${{ secrets.PREVIEW_PG_PASSWORD }}
PREVIEW_REDIS_URL: ${{ secrets.PREVIEW_REDIS_URL }}
PREVIEW_BUCKET: ${{ vars.PREVIEW_BUCKET }}
PREVIEW_AWS_ACCESS_KEY_ID: ${{ secrets.PREVIEW_AWS_ACCESS_KEY_ID }}
PREVIEW_AWS_SECRET_ACCESS_KEY: ${{ secrets.PREVIEW_AWS_SECRET_ACCESS_KEY }}
jobs:
up:
# the gate: a PR carrying the `env` label, or someone asking for a branch by hand
if: >-
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'pull_request' && github.event.action != 'closed'
&& contains(github.event.pull_request.labels.*.name, 'env'))
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.head_ref || inputs.branch }}
- uses: superfly/flyctl-actions/setup-flyctl@master
- id: env
env:
BRANCH: ${{ github.head_ref || inputs.branch }}
run: |
scripts/env up
echo "url=$(scripts/env url)" >> "$GITHUB_OUTPUT"
- if: github.event_name == 'pull_request'
uses: marocchino/sticky-pull-request-comment@v2
with:
header: env
message: |
Branch environment: ${{ steps.env.outputs.url }}
Commit: `${{ github.event.pull_request.head.sha }}` · `/api/version/` says what it is running.
down:
# label removed, labelled PR closed or merged, or the branch itself deleted
if: >-
(github.event_name == 'pull_request' &&
((github.event.action == 'closed' && contains(github.event.pull_request.labels.*.name, 'env')) ||
(github.event.action == 'unlabeled' && github.event.label.name == 'env'))) ||
(github.event_name == 'delete' && github.event.ref_type == 'branch')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: BRANCH="${{ github.head_ref || github.event.ref }}" scripts/env down게이트는 env 라벨이고, 제가 가장 강하게 주장하고 싶은 부분입니다. 게이트가 없으면 모든 브랜치가 앱 하나와 데이터베이스 복제본 하나를 비용으로 치릅니다. 노트북 백업용으로 push한 브랜치, 내일 squash될 스파이크, 봇이 새벽 3시에 연 의존성 업데이트까지요. 그래서 push만으로는 아무 일도 일어나지 않고, PR을 여는 것만으로도 아무 일도 일어나지 않습니다. PR에 env를 붙이면 환경이 올라오고, 라벨이 붙어 있는 동안의 push마다 갱신되고, 라벨을 떼거나 PR을 닫거나 머지하면 내려갑니다. 옵트인은 여전히 GitHub 이벤트이지 사람에게 하는 부탁이 아니고, 누가 볼 건지 아는 유일한 사람이 하는 클릭 한 번입니다. 고정 댓글이 사용자 인터페이스입니다. PR에 라벨을 붙이면 누구에게든 보낼 수 있는 URL이 담긴 댓글이 나타나고, push마다 쌓이는 대신 제자리에서 갱신됩니다. PR이 생기기 전에 올리고 싶은 브랜치는 workflow_dispatch가, 나머지는 노트북에서 돌리는 ./scripts/env up이 맡습니다.
알아 둘 만한 게이트가 둘 더 있습니다. 브랜치 이름 필터(branches: ['feature/**', 'fix/**'])는 PR이 필요 없지만 모든 기능 브랜치가 비용을 치릅니다. “드래프트가 아닌 모든 PR"은 가장 자동적이고, 팀이 PR을 늦게, 준비됐을 때만 연다면 맞는 선택입니다. 저는 라벨을 골랐습니다. 비용 결정을 맥락을 아는 사람에게 두고, 아무것도 닫지 않고 되돌릴 수 있기 때문입니다. 마음에 드는 부작용 하나. 포크에서 온 pull_request 실행은 시크릿을 받지 못하니, 포크는 환경을 띄울 수 없습니다.
concurrency는 보기보다 중요합니다. 한 브랜치에 push가 연달아 오면 앞선 배포를 경주시키는 대신 취소합니다. 같은 앱에 fly deploy 둘이 동시에 돌면 하나가 지는 걸로 끝납니다.
PR이 있던 것은 머지됐든 아니든 closed가 철거를 맡습니다. delete 이벤트는 workflow_dispatch로 띄웠는데 끝내 PR이 생기지 않은 브랜치를 위한 뒷받침이고, 브랜치가 실제로 삭제될 때만 발생합니다. 그건 저장소 설정 Automatically delete head branches이고, 기본은 꺼져 있습니다. 켜 두면 Merge를 누르는 것이 PR을 닫고 브랜치를 삭제하니, 두 이벤트 중 어느 하나만으로도 env down이 돌았을 겁니다. 머지 버튼이 철거입니다.
gh api -X PATCH repos/jaredlynskey/koreapost -f delete_branch_on_merge=trueFLY_PREVIEW_TOKEN이 파일에서 위험한 줄입니다. 앱을 만들고 없앨 수 있으니, $APP이 브랜치 환경이 아닌 무언가가 되는 순간 프로덕션을 없앨 수도 있습니다. 가드는 둘이고 둘 다 두겠습니다. 토큰을 별도의 Fly 조직에 한정해 프로덕션 조직을 말 그대로 볼 수 없게 하는 것(데모에서는 새 조직에 결제 정보를 붙이는 걸 건너뛰려고 프로덕션 조직 korea-post를 썼고, 두 번째 가드 하나에 기댔습니다), 그리고 down()이 slug-hash 패턴에 맞지 않는 앱 이름을 전부 거부하는 것.
브랜치 환경이 스스로 할 수 없는 검사#
브랜치는 자기 마이그레이션만 봅니다. 그래서 중요한 검사는 pull request에서 main을 머지한 상태를 상대로 돌고, main이 요구하는 유일한 상태 검사입니다.
# .github/workflows/migrations-check.yml
name: migrations
on:
pull_request:
branches: [main]
jobs:
check:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:18
env: { POSTGRES_PASSWORD: ci, POSTGRES_DB: ci }
ports: ['5432:5432']
options: --health-cmd pg_isready --health-interval 5s --health-timeout 5s --health-retries 10
env:
DATABASE_URL: postgres://postgres:ci@localhost:5432/ci
DEBUG: 'False'
SECRET_KEY: ci-only
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: astral-sh/setup-uv@v4
- run: uv sync --frozen
- name: Merge main into the branch (a conflict here fails the check too)
run: |
git config user.email [email protected] && git config user.name ci
git merge --no-edit origin/main
- name: One leaf in the migration graph
run: |
uv run manage.py makemigrations --check --dry-run
uv run manage.py migrate --plan > /dev/null
- name: The full chain applies from nothing
run: uv run manage.py migrate --noinput30초. 0114 / 0114 충돌을 릴리스 당일의 놀람에서 두 번째 개발자 PR의 빨간 체크로 바꿔 주는 것이 이겁니다. 아래 마이그레이션 절에서 실제로 걸리는 모습을 보여줍니다.
main의 규칙#
브랜치가 한 달을 살거나 누군가 main에 직접 push하면 이 중 무엇도 버티지 못합니다. 규칙은 보호라기보다 환경을 작게, 마이그레이션을 최신으로 유지하기 위한 것입니다.
main은 보호되고 언제나 배포 가능합니다. 현재main을 기준으로migrations검사가 초록인 PR로만 머지합니다(strict 상태 검사이니, 지난주에 초록이던 PR은main이 움직이면 다시 돕니다). 선형 이력, force-push 금지, 삭제 금지.- 기능 브랜치는 짧습니다. 2주 열려 있는 브랜치는
main보다 2주 뒤처진 환경과, 남의 것과 충돌할 확률이 2주치 더 높은 마이그레이션을 갖습니다. 한 달짜리 기능은 플래그 뒤에서 조각조각main에 머지합니다. 오래 사는 건 플래그이지 브랜치가 아닙니다. - 브랜치 하나, 환경 하나, 데이터베이스 하나. 이미 이름이 강제합니다.
- 프로덕션에 가는 건 릴리스 태그뿐입니다. 이미지는 태그에서 한 번 빌드되고, 스테이징과 프로덕션은 그 이미지를 돌립니다. 다시 빌드하지 않습니다.
migrations/를 건드리는 PR은 나머지 둘 중 하나가 리뷰합니다. 아래에.
룰셋으로는, gh api -X POST repos/…/rulesets --input infra/github-ruleset-main.json으로 한 번 적용합니다.
{
"name": "main", "target": "branch", "enforcement": "active",
"conditions": { "ref_name": { "include": ["~DEFAULT_BRANCH"], "exclude": [] } },
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
{ "type": "required_linear_history" },
{ "type": "pull_request",
"parameters": { "required_approving_review_count": 1, "dismiss_stale_reviews_on_push": true,
"required_review_thread_resolution": true } },
{ "type": "required_status_checks",
"parameters": { "strict_required_status_checks_policy": true,
"required_status_checks": [ { "context": "check" } ] } }
]
}솔직한 주름 하나. 이 저장소는 GitHub Free 플랜의 비공개 저장소이고, Free에서 브랜치 보호와 룰셋은 공개 저장소에서만 쓸 수 있습니다. API는 403 Upgrade to GitHub Pro or make this repository public이라고 답합니다. delete_branch_on_merge 설정, environment, 워크플로는 Free에서도 전부 됩니다. 그러니 비공개 Free 저장소의 작은 팀에게 위 룰셋은 관례입니다. migrations 워크플로는 여전히 돌고 여전히 빨개지지만, 머지 버튼을 회색으로 만드는 것은 없습니다. 월 4달러짜리 결정이고, 저라면 두 번째 사람이 머지할 수 있게 되는 순간 하겠습니다.
develop 브랜치도 release/* 브랜치도 없습니다. Git-flow는 통합이 비싸서 묶어서 해야 한다는 전제로 설계됐습니다. 브랜치 환경이 통합을 싸게 만드니, 묶음이 사라집니다.
스테이징은 태그의 환경이고, 프로덕션은 승격이다#
스테이징은 장치 전부를 재사용합니다. main의 v* 태그가 브랜치 이름 staging/v1.42.0으로 scripts/env up을 돌리고, 이름 규칙이 그걸 staging-v1-42-0-<hash>로 바꿉니다. 같은 템플릿 복제 데이터베이스, 같은 놀면 suspend되는 머신, 같은 호스트명 체계. 차이는 그 뒤에 오는 것입니다.
# .github/workflows/release.yml (abridged)
on:
push:
tags: ['v*']
jobs:
staging:
steps:
- run: BRANCH="staging/${GITHUB_REF_NAME}" scripts/env up
- run: echo "image=$(fly image show -a "maru-$(BRANCH=staging/$GITHUB_REF_NAME scripts/env name)" --json | jq -r '…')" >> "$GITHUB_OUTPUT"
production:
needs: staging
environment: production # required reviewer on this environment = the promotion gate
steps:
- run: fly deploy --config fly.api.toml --image "${{ needs.staging.outputs.image }}"
- run: fly deploy --config fly.ws.toml --image "${{ needs.staging.outputs.image }}"
- run: BRANCH="staging/${GITHUB_REF_NAME}" scripts/env downproduction 잡은 필수 리뷰어가 있는 GitHub environment를 기다립니다. 누군가 스테이징을 보고 Approve를 누르면, 프로덕션은 스테이징이 돌린 같은 이미지를 참조로 받습니다. 다시 빌드하지 않습니다. 마지막 단계가 스테이징을 없애는 건 제 할 일을 다 했기 때문입니다. 아무도 승인하지 않으면 릴리스 후보는 거절된 것이고, 스위퍼가 그 “브랜치”(staging/v1.42.0은 origin에 존재한 적이 없습니다)가 생존 검사에 실패할 때 스테이징을 치웁니다. 스위퍼가 확인할 수 있는 이유 없이는 아무것도 존재할 수 없습니다.
밤마다 도는 스위퍼#
# .github/workflows/sweep-envs.yml
on:
schedule:
- cron: '0 14 * * *' # 02:00 NZST
workflow_dispatch:
jobs:
sweep:
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: scripts/sweep-envsfetch-depth: 0은 스위퍼가 Actions가 기본으로 체크아웃하는 커밋 하나가 아니라 모든 원격 브랜치와 마지막 커밋 날짜를 필요로 하기 때문입니다. workflow_dispatch는 사고 뒤에 손으로 돌릴 수 있게 하려는 것입니다.
개발자가 실제로 하는 일#
브랜치를 push하고, PR을 열고, 누군가 봐 줬으면 할 때 env 라벨을 붙입니다. 목록은 그게 전부입니다. 3분 안에 PR에 URL이 나타나고, push마다 갱신되고, PR이 머지되거나 닫히면 사라집니다. 팀원과 충돌했다면 마이그레이션 검사가 누구보다 먼저 알려줍니다. 차지할 스테이징이 없으니 아무도 스테이징을 차지하지 않고, 철거가 곧 머지이니 아무도 뭘 철거하지 않습니다.
그리고 이걸 세팅한 사람이 그 뒤로 브랜치마다 하는 일은, 없습니다. 공유 Postgres, Redis, 버킷은 한 번 프로비저닝하고, 시크릿은 Actions에 한 번 넣고, 그 뒤로 반복되는 일이라곤 월요일에 스위퍼 로그를 읽는 것뿐입니다. 티켓 큐도, 불러야 할 봇도, 프롬프트를 넣을 대상도 없습니다.
철거를 믿지 않는 철거#
env down이 보통의 경우이고, 흥미로운 부분은 누가 치우느냐입니다. Redis 키와 Tigris 객체는 공유 서비스의 것이고, down을 돌리는 노트북은 어느 쪽 자격 증명도 필요 없습니다. 환경 자신의 머신이 이미 그걸 갖고 있고 자기 접두사를 이미 알기 때문입니다. 그래서 down은 머신이 suspend돼 있으면 깨우고, 거기서 manage.py env_teardown을 돌린 다음에야 앱을 없애고 데이터베이스를 드롭합니다. 환경이 스스로 뒷정리를 합니다.
$ BRANCH=feature/profile-website ./scripts/env down
== feature/profile-website -> feature-profile-webs-dcb2e7 (destroying)
redis: deleted 0 keys under feature-profile-webs-dcb2e7:*
tigris: deleted 278 objects under maru-preview/feature-profile-webs-dcb2e7/
Destroyed app maru-feature-profile-webs-dcb2e7
DROP DATABASE
destroyed feature-profile-webs-dcb2e7
$ curl -s -o /dev/null -w '%{http_code}\n' https://feature-profile-webs-dcb2e7-dev.marucommunity.com/api/version/
530530은 조정에 관한 요점을 다시 보여줍니다. Worker는 여전히 호스트명을 라우팅하고, Fly에는 아무것도 없으며, 아무도 Cloudflare에 알릴 필요가 없었습니다.
env_teardown은 ENV_NAME 없이는 실행을 거부합니다. 접두사가 비어 있으면 공유 버킷을 통째로 지우는 일이 되니까요. 그 가드가 이 명령이 셸 스크립트 세 줄이 아니라 명령으로 존재하는 이유 전부입니다.
그래도 환경은 샙니다. GitHub Actions가 장애인 사이에 브랜치가 삭제됩니다. 개발자가 실험용으로 노트북에서 env up을 돌리고 push는 영영 안 합니다. fly apps destroy가 일시적으로 실패하고 || true가 삼킵니다. 석 달 뒤 앱이 마흔 개이고, 누군가 무슨 일이냐고 묻게 만드는 청구서가 옵니다.
그래서 첫 번째를 믿지 않는 두 번째 장치가 있습니다. 위 표의 밤마다 도는 스위퍼입니다. 모든 maru-* 앱을 나열하고, 각각이 어느 브랜치 것인지 알아내고, 브랜치가 사라졌거나 7일간 push가 없는 것을 전부 없앱니다. 일주일 논 브랜치에 살아 있는 URL은 필요 없고, 다음 push가 2분 만에 다시 만듭니다. 해시는 되돌릴 수 없으니, 스위퍼는 살아 있는 모든 브랜치의 환경 이름을 계산해 그 집합에 없는 앱을 전부 없앤 뒤, 뒤에 앱이 없는 env_* 데이터베이스를 드롭합니다.
#!/usr/bin/env bash
# scripts/sweep-envs — nightly
set -euo pipefail
cd "$(git rev-parse --show-toplevel)"
[[ -f .env.preview ]] && { set -a; source .env.preview; set +a; }
ORG=${PREVIEW_ORG:-korea-post}; TTL_DAYS=${TTL_DAYS:-7}
cutoff=$(( $(date +%s) - TTL_DAYS*86400 ))
git fetch --prune --quiet origin
live=""
while IFS=$'\t' read -r ts branch; do
[[ "$branch" == "main" ]] && continue
(( ts >= cutoff )) || continue
live+="$(BRANCH="$branch" scripts/env name)"$'\n'
done < <(git for-each-ref --format='%(committerdate:unix)%09%(refname:short)' refs/remotes/origin | sed 's#^\([0-9]*\)\torigin/#\1\t#')
apps=$(fly apps list --org "$ORG" --json | grep -o '"Name": *"maru-[^"]*"' | cut -d'"' -f4)
for app in $apps; do
env="${app#maru-}"
[[ "$env" =~ ^[a-z0-9-]+-[0-9a-f]{6}$ ]] || continue # not a branch environment (e.g. maru-preview-pg)
grep -qx "$env" <<<"$live" && continue
echo "sweeping $app: branch gone or idle > ${TTL_DAYS}d"
ENV_OVERRIDE="$env" scripts/env down || true
done
# ...then any env_* database with no app: DROP DATABASE.데모 브랜치는 어느 것도 push된 적이 없으니(위의 노트북 실행), 스위퍼 입장에서는 전부 사라진 것이었습니다. 누군가 노트북에서 띄우고 끝내 push하지 않은 스파이크가 일주일 뒤에 놓일 상태가 정확히 이것입니다. 남은 둘을, tagline 환경이 써 둔 Redis 키까지 포함해 해체했습니다.
$ ./scripts/sweep-envs
sweeping maru-feature-branch-envir-c65cb4: branch gone or idle > 7d
redis: deleted 0 keys under feature-branch-envir-c65cb4:*
tigris: deleted 322 objects under maru-preview/feature-branch-envir-c65cb4/
Destroyed app maru-feature-branch-envir-c65cb4
DROP DATABASE
sweeping maru-feature-profile-tagl-fa8832: branch gone or idle > 7d
redis: deleted 3 keys under feature-profile-tagl-fa8832:*
tigris: deleted 278 objects under maru-preview/feature-profile-tagl-fa8832/
Destroyed app maru-feature-profile-tagl-fa8832
DROP DATABASE
$ fly apps list --org korea-post | grep maru-
maru-preview-pg │ korea-post │ deployed(작게 문 것 하나. macOS에는 연관 배열이 없는 bash 3이 딸려 옵니다. 스위퍼 첫 버전은 그걸 썼다가 제 노트북에서 declare -A에서 죽었습니다. 위 버전은 줄바꿈으로 구분한 목록을 써서 어디서든 돕니다.)
비용은 즐거운 부분입니다. suspend된 머신은 rootfs 값만 내고, 한 달에 몇 센트입니다. 데이터베이스는 이미 값을 치른 볼륨 위의 19 MB입니다. Redis는 Upstash의 종량제 플랜으로, 명령 10만 개에 20센트인데 브랜치 테스트 규모에서는 0으로 반올림됩니다. Tigris 접두사는 저장한 만큼 냅니다. 공유 Postgres 노드가 유일한 고정 항목으로, 한 달에 2달러쯤입니다. 활성 브랜치 스무 개는 항상 켜진 shared-cpu-1x 하나 값쯤이고, 스위퍼가 그걸 이백이 아니라 스물로 유지합니다. 데모 전체, 즉 한 시간 동안의 환경 셋과 공유 부품은 그동안 마신 커피보다 쌌습니다.
브랜치를 가로지르는 마이그레이션, 경고해 줄 공유 데이터베이스 없이#
개발자마다 기능을 처음부터 끝까지 맡으면 모든 브랜치에 마이그레이션이 들어 있습니다. 그래서 이 절이 중요한 절입니다.
공유 환경 세계를 떠날 때 사람들이 놓치는 것이 있습니다. 모두가 dev 데이터베이스 하나에 배포할 때 마이그레이션 충돌은 즉시, 아프게 드러났지만, 어쨌든 드러났습니다. 마이그레이션 하나가 돌고, 다음 배포가 실패하고, 누군가 그날 오후에 고쳤습니다. 브랜치마다 데이터베이스가 있으면 각 마이그레이션은 자기 환경에서 완벽하게 돌고, 둘은 두 번째가 main에 머지될 때 처음 만납니다. 부주의하게 하면 충돌이 싸던 dev에서 싸지 않은 릴리스로 옮겨 갑니다.
규칙 넷, 전부 CI로 강제할 수 있어서 아무도 기억할 필요가 없습니다.
1. main을 머지한 상태에서 마이그레이션 그래프에 리프가 둘이면 CI가 실패합니다. makemigrations --check는 모델과 마이그레이션이 어긋난 걸 잡지만, 중요한 충돌은 두 브랜치가 둘 다 0042_*를 추가한 것입니다. 브랜치 단독이 아니라 main에 머지된 브랜치를 상대로 확인합니다.
git fetch origin main
git merge --no-commit --no-ff origin/main || { echo "merge conflict"; exit 1; }
python manage.py makemigrations --check --dry-run
python manage.py migrate --plan 2>&1 | grep -q "Conflicting migrations" && exit 1이건 제가 일부러 일으킨 것입니다. 데모 브랜치 셋 중 둘, feature/profile-tagline과 feature/profile-website가 각각 마이그레이션 하나로 UserProfile에 필드 하나를 추가했고, 각 환경은 올라오면서 자기 것을 적용했습니다. 컬럼은 있어야 할 곳에만 있었습니다.
env_feature_branch_envir_c65cb4 pending_email
env_feature_profile_tagl_fa8832 pending_email, tagline
env_feature_profile_webs_dcb2e7 pending_email, website둘 다 0114_*입니다. 어느 브랜치도 상대 것을 볼 수 없으니, 어느 환경도 문제가 있다고 말해 주지 못합니다. 하나를 다른 하나에 머지하면 검사가 말해 줍니다.
$ git merge --no-commit --no-ff feature/profile-tagline
$ python manage.py makemigrations --check --dry-run
CommandError: Conflicting migrations detected; multiple leaf nodes in the migration graph:
(0114_userprofile_tagline, 0114_userprofile_website in marketplace).
To fix them run 'python manage.py makemigrations --merge'
$ python manage.py makemigrations --merge --noinput
Created new merge migration marketplace/migrations/0115_merge_20260919_0324.py
dependencies = [
('marketplace', '0114_userprofile_tagline'),
('marketplace', '0114_userprofile_website'),
]두 번째 작성자가 자기 브랜치에서, 자기 환경에서, 아무도 귀찮게 하지 않고 그 --merge를 돌리면, 다음 env up이 자기 복제본 위에 0115를 적용합니다. (이 경우 git이 먼저 텍스트 충돌도 표시했습니다. 두 필드가 모델 파일의 같은 줄에 추가됐기 때문입니다. 현실적이고, 텍스트 충돌이 해소되기 전까지 마이그레이션 충돌을 가린다는 걸 알아 둘 가치가 있습니다.)
2. 마이그레이션은 이전 릴리스의 코드와 하위 호환됩니다. 배포 중에는 새 스키마와 옛 코드가 공존하는 창이 있고, 머신이 롤백되면 그 반대도 있습니다. 컬럼은 nullable이거나 기본값을 갖게 추가하고, 한 번에 이름을 바꾸지 않으며, 현재 릴리스가 여전히 읽는 것은 절대 드롭하지 않습니다. 추가하고, 배포하고, 백필하고, 코드를 바꾸고, 나중 릴리스에서 드롭합니다. expand and contract입니다. 여기서 더 중요한 이유는 일회용 환경이 배포를 잦게 만들어서 그 창 안에 더 자주 들어가기 때문입니다. 태그별 스테이징이 그걸 리허설하는 곳입니다.
3. CI는 빈 데이터베이스에 0부터 마이그레이션도 합니다. 템플릿 복제는 “내 마이그레이션이 현실적인 데이터에 적용되나"를 테스트합니다. 빈 실행은 “전체 체인이 여전히 무에서 되나"를 테스트하고, 이전 데이터 마이그레이션이 뭔가 채워 놨다고 가정한 마이그레이션을 잡습니다. 30초이고, 그러지 않으면 다음 새 노트북을 기다리는 부류의 버그를 잡습니다.
4. 스키마 변경에는 두 번째 눈이 붙습니다. 작은 팀에는 CODEOWNERS 파일이 필요 없지만, 규칙은 필요합니다. migrations/를 건드리는 PR은 머지 전에 팀원 하나가 리뷰하고, 리뷰어의 일은 SQL을 확인하는 게 아닙니다. 자기나 팀의 다른 누군가가 이번 2주 안에 같은 테이블을 건드리고 있는지 알고, 그렇다고 말하는 것입니다. 개발자가 열댓 명을 넘어가면 그 지식이 한 사람 머리에 다 들어가지 않으니, 그때는 migrations/에 CODEOWNERS 항목을 두는 게 제값을 합니다. 어떤 툴링도 이걸 대신하지 못합니다. 기계적인 건 툴링이 전부 잡아 주니 2분짜리 일이고, 옛 공유 환경 세계에서 간직할 가치가 있는 유일한 부분입니다. 두 사람이 곧 부딪힐 걸 알아차리는 순간.
버전 관리, URL이 무엇을 돌리는지 말해 주도록#
공유 환경 하나일 때 “스테이징에 뭐 올라가 있어요"는 Slack 질문이었습니다. 여럿이면 환경의 속성이어야 하고, 아니면 모든 버그 리포트가 고고학으로 시작합니다.
- 모든 이미지는 커밋 SHA를 지니고 앱이 그걸 노출합니다.
fly deploy --build-arg GIT_SHA=... --build-arg GIT_BRANCH=...가 이미지에 환경 변수로 찍고,/api/version/이 환경 이름, Fly 앱, 이미지 참조와 함께 돌려줍니다(위 스크린샷 참고). “feature-profile-tagl-fa8832에서 깨져요"에 누구나 제일 먼저 하는 일은 생각한 커밋을 돌리고 있는지 확인하는 것입니다. 첫 데모 환경은 툴링이 커밋되기 한 커밋 전의 작업 트리에서 배포됐고, 엔드포인트가 그렇게 말했습니다. 제가 예상한 SHA가 아니라87c061b9. ENVIRONMENT=$ENV가 모든 로그 줄과 Sentry 태그에 있습니다. 브랜치 오류가 프로덕션 오류 추적을 더럽히지 않고, 클릭 한 번으로 걸러집니다.- 브랜치는 SHA를, 릴리스는 태그를 갖습니다.
feature-payments-retr-3f9a1c에는 버전 번호가 없습니다. 커밋이 있습니다. 스테이징과 프로덕션에는v1.42.0이 있습니다. - 모바일 앱은 URL이 아니라 최소 API 버전을 지닙니다. dev 메뉴의 URL 필드가 테스터가 실제 폰을 브랜치에 향하게 하는 방법이고, API 스키마 버전을 실은
/version엔드포인트가 앱이 이해할 수 있는 상대와 통신하는지 아는 방법입니다.
이걸로 해결되지 않는 것#
같이 있어야만 말이 되는 기능 둘은 해결하지 못합니다. 브랜치 환경은 브랜치 하나를 보여줍니다. 답은 둘 다 플래그 뒤에 머지하고 main을 보는 것이고, main을 따라가는 상시 환경 하나(main-dev.marucommunity.com, 머지마다 재배포)로 할 수 있습니다. 제가 남겨 둘 유일한 장수 비프로덕션 환경이고, 아무도 거기에 배포하지 않습니다. 다음 릴리스가 무엇이 될지 보는 용도입니다.
등록된 콜백을 요구하면서 와일드카드를 받지 않는 제공자는 해결하지 못합니다. Firebase Auth는 상위 도메인을 받으니 marucommunity.com이 모든 -dev 브랜치 호스트명을 덮습니다. 일부 OAuth와 결제 제공자는 안 되고, 그런 경우 Worker가 마지막으로 차지한 환경으로 라우팅하는 공유 호스트명 하나를 둡니다. 작은 것에 걸린 작은 락이고, 모든 것에 걸린 락보다 훨씬 낫습니다.
마이그레이션의 사람 쪽은 해결하지 못합니다. 툴링은 기계적 충돌을 잡습니다. 같은 2주 안에 둘이 같은 개념을 양립 불가능하게 모델링하는 건 잡지 못하고, 그걸 고치는 유일한 방법은 한 명이 알아차리는 마이그레이션 리뷰입니다.
당신의 환경에서 확인해 볼 것#
공유 dev나 스테이징이 있고 그걸 예약하는 Slack 스레드가 있다면:
- 완성된 브랜치가 작성자 아닌 누군가가 도는 걸 볼 수 있기까지 얼마나 기다립니까? 한 시간 넘으면 그 락 값을 전달 시간으로 치르고 있는 것이고, 작은 팀이면 팀의 상당 부분이 기다리는 겁니다.
- 공유 데이터베이스는 얼마나 자주 초기화되고, 그때 누가 작업을 잃습니까?
- 새 개발자가 첫날에 누구에게 아무것도 묻지 않고 자기 브랜치가 도는 공개 URL을 얻을 수 있습니까? 물어야 하는 것이 무엇이든, 그게 제일 먼저 자동화할 것입니다.
- 새 환경은 현실적인 데이터에서 시작합니까? 빈 상태에서 시작하면, 처음으로 못 잡을 버그는 빈 테이블에서는 멀쩡하고 실제 테이블에서는 40분 걸리는 마이그레이션입니다.
- 비프로덕션 호스팅에 모두가 잊고 한 달 뒤에도 여전히 존재할 것이 있습니까? 있다면 다른 무엇보다 먼저 스위퍼를 쓰세요. 누수는 첫날부터 시작됩니다.
Slack 질문이 사라지는 건 사람들이 묻기를 그만둬서가 아니라, 답이 언제나 “네, 당신 것에서요"이기 때문입니다.
