- Authors

- Name
- Youngju Kim
- @fjvbn20031
- 들어가며 — 금요일 밤의 ALTER TABLE
- 마이그레이션 도구의 동작 원리
- PR에서의 검증 — 린트와 드라이런
- 마이그레이션 충돌 감지
- 환경별 자동 적용과 승인 게이트
- 쿠버네티스에서의 적용 — Job과 init container
- 드리프트 감지
- 시크릿과 자격증명
- 롤백 자동화
- 관측과 알림
- 전체 파이프라인 사례 — GitHub Actions
- 마이그레이션 테스트 — 코드처럼 테스트하기
- 레거시 DB에 자동화 도입하기 — 베이스라인
- 흔한 함정
- 체크리스트
- 마치며
- 참고 자료
들어가며 — 금요일 밤의 ALTER TABLE
많은 장애의 근원을 거슬러 올라가면 "누군가 운영 DB에 손으로 SQL을 날렸다"는 한 줄이 나옵니다. 애플리케이션 코드는 PR 리뷰를 거치고, 테스트를 통과하고, 자동 배포되는데, 정작 그 코드가 의존하는 데이터베이스 스키마는 누군가 SSH로 접속해 ALTER TABLE을 직접 실행하는 방식으로 바뀝니다. 금요일 밤, 졸린 눈으로 친 한 줄의 SQL이 락을 잡고 테이블을 멈추게 만들고, 다음 날 아침 장애 회의가 열립니다.
이 글의 주제는 단순합니다. 스키마 변경을 애플리케이션 코드와 똑같이 다루자는 것입니다. 버전 관리하고, PR에서 리뷰하고, CI에서 검증하고, CD에서 자동 적용하고, 문제가 생기면 롤백합니다. 이것이 "마이그레이션을 코드처럼" 다루는 GitOps 접근입니다.
이 글에서는 마이그레이션 도구의 동작 원리부터 파이프라인 통합, 환경별 승인 게이트, 쿠버네티스 적용, 드리프트 감지, 시크릿 관리, 롤백 자동화, 관측까지 실무 관점에서 정리합니다.
마이그레이션 도구의 동작 원리
마이그레이션 도구는 공통적으로 "어떤 변경이 어디까지 적용됐는가"를 추적합니다. 핵심은 메타데이터 테이블입니다.
- Flyway:
flyway_schema_history테이블에 적용된 마이그레이션의 버전, 체크섬, 실행 시각을 기록합니다. - Liquibase:
DATABASECHANGELOG테이블에 changeset 단위로 기록하고,DATABASECHANGELOGLOCK으로 동시 실행을 막습니다. - Atlas: 선언적 스키마(원하는 최종 상태)를 정의하면 현재 상태와 비교해 차이를 자동으로 SQL로 만들어 줍니다.
방식은 크게 두 갈래입니다.
+---------------------------+-------------------------------------------+
| 명령형 (versioned) | 선언적 (declarative) |
+---------------------------+-------------------------------------------+
| "이 단계를 순서대로 실행" | "최종 상태는 이것" |
| V1__init.sql | schema.hcl / schema.sql |
| V2__add_email.sql | 도구가 diff를 계산해 SQL 생성 |
| Flyway, golang-migrate | Atlas, (Liquibase 일부) |
| 변경 이력이 명확 | 의도가 명확, 드리프트 교정이 쉬움 |
+---------------------------+-------------------------------------------+
명령형은 변경의 순서와 이력이 명확해 디버깅이 쉽고, 선언적은 "원하는 상태"가 곧 코드라서 드리프트 교정이 쉽습니다. 실무에서는 명령형을 기본으로 쓰되, Atlas 같은 선언적 도구로 드리프트를 감지하는 하이브리드가 자주 쓰입니다.
Flyway 마이그레이션 예제
-- V2__add_user_status.sql
ALTER TABLE users
ADD COLUMN status VARCHAR(20) NOT NULL DEFAULT 'active';
CREATE INDEX CONCURRENTLY idx_users_status ON users (status);
여기서 핵심은 CREATE INDEX CONCURRENTLY입니다. 일반 CREATE INDEX는 테이블에 쓰기 락을 걸어 인덱스가 만들어지는 동안 INSERT/UPDATE를 막습니다. 대형 테이블에서는 수 분간 서비스가 멈출 수 있습니다. CONCURRENTLY는 락 없이 인덱스를 만들지만, 트랜잭션 안에서는 실행할 수 없다는 제약이 있어 마이그레이션 도구 설정에서 트랜잭션을 끄도록 지정해야 합니다.
PR에서의 검증 — 린트와 드라이런
마이그레이션을 코드처럼 다룬다는 것은, PR 단계에서 자동으로 검증한다는 뜻입니다. 사람이 리뷰하기 전에 기계가 먼저 위험을 잡습니다.
마이그레이션 린트
위험한 패턴을 정적으로 잡아내는 린터가 있습니다. 대표적으로 Atlas의 migrate lint, squawk(PostgreSQL용)가 있습니다. 이들이 잡는 대표적 위험은 다음과 같습니다.
NOT NULL컬럼을 기본값 없이 추가 (전체 테이블 재작성 유발)- 일반
CREATE INDEX(쓰기 락) - 컬럼 타입 변경 (테이블 재작성)
- 컬럼/테이블 삭제 (이전 버전 앱이 깨질 수 있음)
ALTER TABLE ... ADD COLUMN ... DEFAULT(구버전 DB에서 전체 재작성)
# .github/workflows/migration-lint.yml
name: migration-lint
on:
pull_request:
paths:
- 'migrations/**'
jobs:
lint:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:17
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: ariga/setup-atlas@v0
- name: Lint new migrations against main
run: |
atlas migrate lint \
--dir "file://migrations" \
--dev-url "postgres://postgres:postgres@localhost:5432/dev?sslmode=disable" \
--git-base "origin/main"
이 워크플로는 main 브랜치 대비 새로 추가된 마이그레이션만 검사합니다. 위험한 패턴이 발견되면 PR이 빨간색으로 막힙니다.
드라이런 — 실제로 적용해 보기
린트가 정적 분석이라면, 드라이런은 진짜로 적용해 보는 동적 검증입니다. CI에서 운영 DB의 스키마를 복제한 임시 DB를 띄우고, 마이그레이션을 실제로 실행해 봅니다.
# CI에서 임시 Postgres에 운영 스키마를 덤프해 적용한 뒤 마이그레이션 실행
pg_dump --schema-only "$PROD_RO_URL" > schema.sql
psql "$CI_DB_URL" -f schema.sql
flyway -url="$CI_DB_URL" -locations="filesystem:./migrations" migrate
드라이런에서 마이그레이션이 깨지거나, 예상보다 오래 걸리거나, 락 경합이 생기면 PR 단계에서 잡힙니다. 운영에 적용하기 전 마지막 안전망입니다.
마이그레이션 충돌 감지
여러 개발자가 동시에 마이그레이션을 추가하면 버전 번호가 충돌합니다. 두 PR이 각각 V5__...sql을 만들면, 둘 다 머지된 뒤 어느 쪽이 먼저 실행될지 모호해집니다.
main: V1 V2 V3 V4
|
PR-A ----------+--- V5__add_phone.sql
PR-B ----------+--- V5__add_avatar.sql <- 같은 버전! 충돌
해결 전략은 세 가지입니다.
- 타임스탬프 버전:
V20260616103000__...처럼 밀리초까지 쓰면 충돌 확률이 거의 없습니다. - CI 충돌 검사: 같은 버전 번호가 둘 이상이면 빌드를 실패시킵니다.
- 순서 무관 설계: Liquibase의 changeset처럼 ID로 추적하면 순서 의존성이 줄어듭니다.
# 중복 버전 번호 감지 (CI 스텝)
dupes=$(ls migrations/ | grep -oE '^V[0-9]+' | sort | uniq -d)
if [ -n "$dupes" ]; then
echo "중복 마이그레이션 버전 발견: $dupes"
exit 1
fi
환경별 자동 적용과 승인 게이트
마이그레이션은 환경을 단계적으로 통과해야 합니다. dev에는 자동, staging에는 자동, production에는 사람의 승인을 거쳐 적용하는 것이 일반적입니다.
[PR 머지]
|
v
+--------+ +-----------+ +--------------+
| dev | ---> | staging | ---> | production |
| 자동 | | 자동 | | 수동 승인 게이트 |
+--------+ +-----------+ +--------------+
|
(Slack 알림 + 승인자 2명)
GitHub Actions에서는 environment protection rule로 승인 게이트를 만듭니다.
# .github/workflows/migrate-deploy.yml
name: migrate-deploy
on:
push:
branches: [main]
paths:
- 'migrations/**'
jobs:
migrate-staging:
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- uses: ariga/setup-atlas@v0
- name: Apply to staging
env:
DB_URL: ${{ secrets.STAGING_DB_URL }}
run: atlas migrate apply --dir "file://migrations" --url "$DB_URL"
migrate-production:
needs: migrate-staging
runs-on: ubuntu-latest
environment: production # 승인 게이트가 걸린 환경
steps:
- uses: actions/checkout@v4
- uses: ariga/setup-atlas@v0
- name: Apply to production
env:
DB_URL: ${{ secrets.PROD_DB_URL }}
run: atlas migrate apply --dir "file://migrations" --url "$DB_URL"
environment: production으로 지정된 잡은, GitHub 설정에서 required reviewers를 걸어두면 승인 전까지 멈춰 있습니다. 승인자가 버튼을 눌러야 운영에 적용됩니다.
쿠버네티스에서의 적용 — Job과 init container
쿠버네티스 환경에서는 마이그레이션을 어떻게 실행할지가 별도의 문제가 됩니다. 두 가지 패턴이 있습니다.
패턴 1: 배포 전 Job
애플리케이션을 배포하기 전에, 마이그레이션만 실행하는 Job을 돌리고 성공하면 다음 단계로 넘어갑니다.
apiVersion: batch/v1
kind: Job
metadata:
name: db-migrate
spec:
backoffLimit: 0 # 실패 시 재시도 금지 (중복 실행 위험)
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: myorg/migrations:1.4.2
command: ["flyway", "migrate"]
env:
- name: FLYWAY_URL
valueFrom:
secretKeyRef:
name: db-credentials
key: url
Helm의 hook(helm.sh/hook: pre-install,pre-upgrade)이나 Argo CD의 PreSync hook으로 이 Job을 배포 직전에 실행하도록 묶습니다.
패턴 2: init container
각 파드가 뜰 때 init container로 마이그레이션을 실행하는 방식입니다. 단순하지만, 파드가 여러 개면 동시에 마이그레이션을 시도하는 문제가 있습니다. 마이그레이션 도구의 락(Flyway, Liquibase 모두 락 메커니즘이 있습니다)이 이를 막아 주지만, 권장은 Job 패턴입니다.
권장: Job(또는 hook)으로 단일 실행 -> 앱 배포
지양: 모든 파드 init container 동시 실행 -> 락 경합
드리프트 감지
드리프트는 "코드(마이그레이션)가 기대하는 스키마"와 "실제 운영 DB의 스키마"가 어긋난 상태입니다. 누군가 손으로 컬럼을 추가했거나, 핫픽스로 인덱스를 만들었는데 마이그레이션에 반영하지 않았을 때 발생합니다.
# Atlas로 운영 DB와 마이그레이션 디렉토리의 차이 검사 (정기 cron)
atlas migrate diff --dir "file://migrations" \
--to "postgres://...prod..." \
--dev-url "postgres://...dev..." \
--format '{{ sql . }}'
# 출력이 비어 있지 않으면 드리프트 존재 -> 알림
드리프트가 감지되면 두 가지 대응이 가능합니다. 운영을 코드에 맞추거나(추가 마이그레이션 작성), 코드를 운영에 맞춥니다(예상치 못한 변경을 마이그레이션으로 흡수). 어느 쪽이든, 드리프트를 방치하면 다음 마이그레이션이 예측 불가능하게 깨집니다. 정기적으로 cron으로 검사해 Slack에 알리는 것이 좋습니다.
시크릿과 자격증명
마이그레이션은 DB 자격증명을 다루므로, 시크릿 관리가 보안의 핵심입니다.
- CI/CD 시크릿 스토어: GitHub Actions Secrets, GitLab CI variables에 DB URL을 저장하고 환경별로 분리합니다.
- 동적 자격증명: HashiCorp Vault의 database secrets engine으로 마이그레이션 실행 시점에 짧은 수명의 자격증명을 발급받으면, 자격증명이 유출돼도 곧 만료됩니다.
- 최소 권한: 마이그레이션 계정에는 DDL 권한만 주고, 애플리케이션 계정과 분리합니다. 운영 마이그레이션 계정은 평소엔 비활성화하고 파이프라인에서만 활성화하는 패턴도 있습니다.
[안티패턴] 마이그레이션 SQL 파일에 비밀번호 하드코딩
[안티패턴] 모든 환경이 같은 DB 계정 공유
[권장] 환경별 시크릿 분리 + 최소 권한 + (가능하면) 동적 자격증명
롤백 자동화
마이그레이션이 잘못됐을 때 어떻게 되돌릴 것인가는 가장 어려운 주제입니다. 핵심 원칙은 "롤백 가능한 마이그레이션을 설계하라"입니다.
undo 스크립트
Flyway Teams나 Liquibase는 각 마이그레이션에 대응하는 undo/rollback 스크립트를 작성할 수 있습니다.
-- V5__add_status.sql (forward)
ALTER TABLE users ADD COLUMN status VARCHAR(20) DEFAULT 'active';
-- U5__add_status.sql (undo)
ALTER TABLE users DROP COLUMN status;
하지만 undo가 항상 안전한 것은 아닙니다. 컬럼을 DROP하면 그 사이에 쌓인 데이터가 사라집니다. 그래서 더 안전한 접근은 expand-contract(확장-축소) 패턴입니다.
Expand-Contract 패턴
스키마 변경을 가역적인 작은 단계로 쪼갭니다.
1. Expand: 새 컬럼/테이블 추가 (구버전 앱과 호환, 되돌리기 쉬움)
2. Migrate: 앱이 새 구조와 옛 구조 양쪽에 쓰기 (이중 쓰기)
3. Backfill: 기존 데이터를 새 구조로 채움
4. Switch: 앱이 새 구조만 읽도록 전환
5. Contract: 옛 컬럼/테이블 제거 (충분히 안정된 뒤)
각 단계가 작고 가역적이라, 문제가 생기면 그 단계만 되돌리면 됩니다. "컬럼 이름 변경"처럼 보이는 작업도 (새 컬럼 추가 → 이중 쓰기 → 백필 → 읽기 전환 → 옛 컬럼 삭제)로 쪼개면 무중단으로 안전하게 할 수 있습니다.
관측과 알림
마이그레이션은 보이지 않는 곳에서 일어나기 쉬우므로, 명시적으로 관측해야 합니다.
- 실행 로그: 어떤 마이그레이션이 언제, 얼마나 걸려 적용됐는지 구조화된 로그로 남깁니다.
- 소요 시간 메트릭: 마이그레이션 실행 시간을 메트릭으로 내보내, 평소보다 오래 걸리면 경고합니다.
- 알림: 마이그레이션 성공/실패를 Slack 등에 알립니다. 특히 운영 적용은 반드시 알립니다.
# 마이그레이션 결과를 Slack에 알리는 스텝
- name: Notify Slack
if: always()
run: |
status="${{ job.status }}"
curl -X POST "$SLACK_WEBHOOK" \
-H 'Content-Type: application/json' \
-d "{\"text\": \"DB 마이그레이션 [production]: $status\"}"
env:
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK }}
전체 파이프라인 사례 — GitHub Actions
지금까지의 조각을 하나로 합치면 다음 흐름이 됩니다.
개발자: migrations/ 에 새 SQL 추가 -> PR
|
v
[CI on PR] 린트(squawk/atlas) + 임시 DB 드라이런 + 중복 버전 검사
|
v (머지)
[CD] dev 자동 적용 -> staging 자동 적용 -> production 승인 게이트
|
v
쿠버네티스: pre-sync Job으로 마이그레이션 -> 앱 롤아웃
|
v
정기 cron: 드리프트 감지 -> 이상 시 Slack 알림
이 흐름의 핵심은, 사람이 운영 DB에 직접 손대는 경로가 없다는 것입니다. 모든 변경은 PR을 통과하고, 자동으로 검증되고, 추적됩니다.
마이그레이션 테스트 — 코드처럼 테스트하기
마이그레이션을 코드처럼 다룬다면, 코드처럼 테스트해야 합니다. 단위 테스트 수준에서 마이그레이션의 정확성을 검증하는 방법이 있습니다.
forward와 backward 왕복 테스트
마이그레이션을 적용하고(forward) 되돌린(backward) 뒤, 스키마가 원래대로 돌아오는지 확인합니다. 이 왕복 테스트는 undo 스크립트의 정확성을 보장합니다.
# 마이그레이션 왕복 테스트 (CI)
flyway -url="$CI_DB_URL" migrate # 최신까지 적용
flyway -url="$CI_DB_URL" undo # 마지막 마이그레이션 되돌림
flyway -url="$CI_DB_URL" migrate # 다시 적용
# 스키마 덤프를 비교해 idempotent한지 확인
pg_dump --schema-only "$CI_DB_URL" > after.sql
diff expected.sql after.sql
데이터 보존 테스트
스키마를 바꾸는 마이그레이션이 기존 데이터를 망가뜨리지 않는지 확인합니다. 시드 데이터를 넣은 뒤 마이그레이션을 적용하고, 데이터가 올바르게 변환됐는지 검증합니다.
-- 테스트 시나리오: 마이그레이션 전 시드 데이터
INSERT INTO users (id, name, email) VALUES
(1, 'alice', 'alice@example.com'),
(2, 'bob', 'bob@example.com');
-- 마이그레이션 적용 후, 변환 결과를 검증하는 단언
-- (예: status 컬럼이 모두 'active'로 채워졌는가)
SELECT count(*) FROM users WHERE status IS NULL; -- 0이어야 함
이런 테스트를 CI 파이프라인에 넣으면, 마이그레이션의 회귀(regression)를 자동으로 잡습니다. 특히 데이터 변환 로직이 들어간 마이그레이션은 반드시 테스트해야 합니다.
레거시 DB에 자동화 도입하기 — 베이스라인
이미 운영 중인, 마이그레이션 도구를 쓰지 않던 데이터베이스에 자동화를 도입하려면 베이스라인(baseline)이 필요합니다. 현재 운영 스키마를 "출발점 0"으로 선언하고, 그 이후의 변경만 마이그레이션으로 관리합니다.
1. 현재 운영 스키마를 덤프 -> V1__baseline.sql (기존 상태)
2. 마이그레이션 도구에 베이스라인 등록 (flyway baseline)
- 메타데이터 테이블이 "V1까지 이미 적용됨"으로 기록됨
3. 이후 모든 변경은 V2__... 부터 마이그레이션으로
4. 기존 데이터/스키마는 건드리지 않고, 점진적으로 자동화 영역 확대
베이스라인의 핵심은 "기존 상태를 재현하려 하지 않는다"는 것입니다. 운영에 이미 존재하는 스키마를 마이그레이션으로 다시 만들려 하면 충돌이 납니다. 대신 현재 상태를 인정하고, 그 위에서 앞으로의 변경만 관리합니다. 이렇게 하면 위험 없이 점진적으로 GitOps 워크플로로 전환할 수 있습니다.
여러 서비스가 공유하는 DB
마이크로서비스 환경에서 여러 서비스가 한 DB를 공유하면, 누가 스키마를 소유하는지가 모호해집니다. 권장은 "스키마 소유권을 한 서비스에 명확히 둔다"입니다. 마이그레이션 디렉토리도 그 서비스의 저장소에 두고, 다른 서비스는 읽기만 합니다. 장기적으로는 서비스별로 스키마(또는 DB)를 분리해 소유권을 명확히 하는 것이 좋습니다.
흔한 함정
- 큰 테이블에 락 거는 마이그레이션:
ALTER TABLE이 ACCESS EXCLUSIVE 락을 잡으면 서비스가 멈춥니다. CONCURRENTLY, 배치 백필, expand-contract로 회피합니다. - 마이그레이션과 앱 배포의 순서: 컬럼을 먼저 지우고 앱을 배포하면, 그 사이 구버전 앱이 없는 컬럼을 참조해 깨집니다. 항상 expand 먼저, contract 나중입니다.
- 트랜잭션 안의 DDL 가정: 일부 DDL(CONCURRENTLY 등)은 트랜잭션 밖에서 실행해야 합니다. 도구 설정을 확인합니다.
- 롤백을 데이터 복구로 착각: DROP된 컬럼의 데이터는 undo로 돌아오지 않습니다. 백업과 PITR(point-in-time recovery)이 별도로 필요합니다.
- 체크섬 변경: 이미 적용된 마이그레이션 파일을 나중에 수정하면 체크섬이 어긋나 도구가 거부합니다. 적용된 마이그레이션은 절대 수정하지 않습니다.
체크리스트
배포 전 다음을 확인하세요.
- 마이그레이션이 버전 관리되고 PR 리뷰를 거쳤는가
- CI에서 린트와 드라이런을 통과했는가
- 중복 버전 번호 충돌이 없는가
- 큰 테이블 변경에 CONCURRENTLY/배치 백필을 적용했는가
- expand-contract로 가역적으로 설계했는가
- 운영 적용에 승인 게이트가 걸려 있는가
- 시크릿이 환경별로 분리되고 최소 권한인가
- 드리프트 감지 cron이 동작하는가
- 마이그레이션 성공/실패가 알림으로 가는가
- 백업/PITR이 최신이고 복구 절차가 검증됐는가
마치며
마이그레이션 자동화의 본질은 도구가 아니라 원칙입니다. 스키마를 코드처럼 다루고, 변경을 작고 가역적으로 쪼개고, 사람의 직접 개입을 파이프라인으로 대체하는 것입니다. Flyway든 Liquibase든 Atlas든, 어떤 도구를 쓰든 이 원칙은 같습니다. 금요일 밤 손으로 친 ALTER TABLE이 사라지는 날, 여러분의 데이터베이스는 비로소 코드만큼 신뢰할 수 있는 대상이 됩니다.
참고 자료
- Flyway 공식 문서: https://flywaydb.org/documentation/
- Liquibase 공식 문서: https://docs.liquibase.com/
- Atlas 공식 문서: https://atlasgo.io/getting-started
- golang-migrate: https://github.com/golang-migrate/migrate
- squawk (PostgreSQL 마이그레이션 린터): https://squawkhq.com/
- PostgreSQL ALTER TABLE 문서: https://www.postgresql.org/docs/current/sql-altertable.html
- AWS DMS 문서: https://docs.aws.amazon.com/dms/
- Argo CD Sync Phases and Hooks: https://argo-cd.readthedocs.io/en/stable/user-guide/resource_hooks/