SQL 을 단일 진실 출처로
SQL 을 단일 진실 출처로
스키마는 어디에서 진실을 가질 것인가. ORM 모델·마이그레이션 파일·DDL SQL — 후보가 여럿입니다. 누적 마이그레이션 모델과 단일 SQL 파일 + CREATE IF NOT EXISTS 모델을 비교합니다.
1. 마이그레이션 도구의 자리
대표적인 마이그레이션 도구들입니다.
| 도구 | 첫 등장 | 언어/생태계 | 모델 |
|---|---|---|---|
| Liquibase | 2006 | JVM | XML/YAML/SQL 변경셋 누적 |
| Flyway | 2010 | JVM | 버전 SQL 파일 누적 (V1__init.sql) |
| Alembic | 2010s 초 | Python (Mike Bayer, SQLAlchemy) | Python 스크립트 누적 |
| Django migrations | 2014 (1.7) | Python/Django | 모델 → 자동 생성 + 누적 |
| Rails migrations | 2005 | Ruby | Ruby DSL 누적 |
| TypeORM migrations | 2016 | TS/JS | TS 클래스 누적 |
| Prisma Migrate | 2020 | TS/JS | 스키마 파일 + diff 기반 SQL 생성 |
| Drizzle Kit | 2023 | TS | 스키마 코드 + diff 기반 SQL 생성 |
이들은 공통적으로 "누적된 변경의 순서" 를 진실로 봅니다. DB 에 적용된 마이그레이션을 메타 테이블로 추적합니다 (예: Flyway 의 flyway_schema_history).
또 다른 모델은 선언적 SQL 단일 파일 입니다. 한 도메인의 모든 테이블을 하나의 SQL 에 CREATE TABLE IF NOT EXISTS · CREATE INDEX IF NOT EXISTS 로 적어 두고 변경이 필요하면 같은 파일을 수정합니다. 적용은 멱등합니다.
2. 누적 ALTER 모델
db/migrations/
├── V1__init.sql # CREATE TABLE users(...)
├── V2__add_email.sql # ALTER TABLE users ADD COLUMN email TEXT
└── V3__index_email.sql # CREATE INDEX idx_users_email ...
도구는 해시·버전을 기록해 같은 파일이 두 번 적용되지 않도록 합니다. 어떤 환경 (dev/staging/prod) 이든 같은 순서로 같은 결과에 도달한다는 약속이 강점입니다. 단점은 누적된 파일 수가 늘면 새 환경 부트스트랩이 길어지고, 한 테이블의 현재 모양을 알려면 여러 파일을 합쳐야 한다는 점입니다.
3. 선언적 단일 파일 + IF NOT EXISTS
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
장점:
- 한 파일을 보면 현재 스키마가 보입니다.
- 부트스트랩이 짧습니다 (파일 한 번 실행).
- 코드 리뷰에서 의도가 그대로 드러납니다.
한계:
- 컬럼 변경/삭제는 자동으로 따라오지 않습니다.
ALTER가 필요한 변경은 별도 절차가 필요합니다. - 환경 간 자동 동기화가 약합니다 — 운영 DB 에 이미 적용된 변경을 추적할 메타 테이블이 없습니다.
- 데이터 마이그레이션 (DML) 은 같은 파일에 두기 어렵습니다.
4. DDL 과 DML 의 분리
DDL (스키마) 과 DML (데이터) 은 흔히 분리됩니다.
- DDL 은 멱등하게 만들기 쉽습니다 (
IF NOT EXISTS). - DML 은 멱등성을 보장하기 어렵고 (같은 INSERT 두 번이 다른 결과), 트랜잭션·롤백 정책이 다릅니다.
- 데이터 시드는 보통 별도 스크립트 (
seed.sql) · 픽스처로 둡니다.
5. 마이그레이션 도구의 트레이드오프
- 재현 가능성 — 누적 모델이 강합니다. 어느 시점의 환경이든 같은 상태로 끌고 갈 수 있습니다.
- 롤백 — 누적 모델 대부분이 down 마이그레이션을 지원하지만, 운영에서는 down 을 쓰지 않고 새 forward 마이그레이션을 더하는 정책이 흔합니다. 데이터가 이미 변형된 뒤의 down 은 안전하지 않습니다.
- 다중 환경 동기화 — 메타 테이블 추적이 강점입니다.
- 운영 복잡도 — 도구·CLI·CI 통합이 필요합니다.
6. Diff 생성 모델
Prisma · Drizzle · atlas 같은 도구는 "원하는 상태(스키마 파일)" 와 "현재 DB" 의 차이를 보고 SQL 을 생성합니다. 선언적 모델의 편의에 누적 추적의 일부를 결합한 형태입니다. 자동 생성된 ALTER 는 검토가 필요합니다.
7. 어떤 모델이 어울리는 자리
선언적 단일 파일 + IF NOT EXISTS 가 어울리는 자리:
- 초기 단계의 작은 서비스.
- 스키마가 자주 바뀌지만 환경 수가 적은 프로젝트.
- 공공데이터·외부 API 의 캐시·집계 테이블처럼 "재생성 가능한" 데이터.
누적 마이그레이션이 어울리는 자리:
- 운영 환경이 여럿 (blue/green · region 분리).
- 데이터 손실이 회사 위험인 자리.
- 여러 팀이 같은 스키마를 동시에 수정.
큰 조직에서는 운영 스키마는 누적 마이그레이션으로 다루되 새로운 도메인의 초기 SQL 은 선언적으로 작성하다 안정화 시점에 마이그레이션 도구로 옮기는 절충도 종종 보입니다.
8. 자주 걸리는 자리
IF NOT EXISTS 의 침묵 — 컬럼 정의가 다르더라도 테이블이 이미 존재하면 새 정의가 무시됩니다. 정의 차이를 잡으려면 별도의 검증 (스키마 비교 도구) 이 필요합니다.
인덱스 이름 충돌 — PostgreSQL 은 인덱스 이름이 스키마 단위로 유일해야 합니다. IF NOT EXISTS 와 동일 이름 정책이 결합하면 의도와 다른 인덱스가 남을 수 있습니다.
마이그레이션 도구의 메타 테이블 손상 — 수동으로 flyway_schema_history 를 건드리는 시도는 위험합니다.
DML 을 DDL 마이그레이션과 섞기 — 락 보유 시간·트랜잭션 의미가 달라 운영 사고로 이어질 수 있습니다.
9. 한 사례 — 컬럼 단위 drift
선언적 단일 파일 모델에서 가장 무서운 건 "테이블 이름은 맞지만 컬럼이 어긋나는" 상태입니다. 한 프로젝트도 한 번 이 실수를 그대로 운영에 흘려보낼 뻔했습니다.
크롤러 코드는 INSERT INTO stores (store_id, name, address, tel, region, area, last_crawled_at, ...) 를 호출했고 같은 도메인의 SSOT 는 stores (store_id, store_nm, addr, tel_no, region_cd, region_nm, area_cd, area_nm, updated_at, ...) 였습니다. 테이블 이름조차 다르고 6 개 컬럼이 매핑이 어긋났습니다. 운영에선 이 라우트가 거의 호출되지 않아서 노출되지 않았을 뿐입니다.
이걸 잡는 방법은 두 갈래입니다.
① 테이블 단위 비교
SSOT 의 CREATE TABLE 이름과 pg_tables 이름의 차집합
→ 73:73 카운트만 맞아도 안심하면 안 됨 (정의 차이는 못 잡음)
② 컬럼 단위 비교
코드의 raw SQL 정규식 추출 → information_schema.columns 와 차집합
→ mismatch 하나하나가 잠재 BUG
두 번째 방법은 단순하지만 효과가 큽니다. 한 프로젝트 의 scripts/sql_column_audit.py 는 약 200 줄로 다음을 합니다.
- 정규식으로 INSERT/UPDATE/SELECT 문을 잡고 컬럼 토큰을 normalize.
information_schema.columns를 한 번 쿼리해서{table: {col1, col2, ...}}맵을 만듭니다.- 각 SQL 문을 돌며 사용 컬럼이 맵에 없으면 출력.
이 한 도구가 product_crawler 의 phantom 테이블 + order_tracking 의 미정의 테이블 두 개를 동시에 잡았습니다. 누적 ALTER 모델이라면 잡히지 않을 종류의 drift 입니다 — ALTER 가 적용된 환경과 안 된 환경이 공존하면 마이그레이션 메타 테이블도 진실을 보장하지 못합니다.
핵심 교훈은 이렇습니다. 선언적 SSOT 모델은 정의 자체로는 정합을 보장하지 않습니다. 코드와 SSOT 둘 다 진실이라 주장하는 두 갈래가 있고, 둘이 어긋난 순간 어느 쪽이 진실인지는 누가 검사하느냐에 달립니다. 컬럼 단위 자동 audit 가 있어야 의미가 있습니다.
10. 한 사례 — FK 제약과 TRUNCATE 의 충돌
선언적 SSOT 모델에서 새 FK 를 더하는 일은 흔합니다. 한 프로젝트 은 product_prices.store_id → stores.store_id FK 를 더한 며칠 뒤 data_crawler 의 정규화가 매시간 조용히 실패하기 시작한 걸 발견했습니다.
ERROR: cannot truncate a table referenced in a foreign key constraint
DETAIL: Table "product_prices" references "stores".
HINT: Truncate table "product_prices" at the same time, or use TRUNCATE ... CASCADE.
refresh_normalized_tables() 는 1.1M raw_items 로부터 stores 를 재계산하기 위해 TRUNCATE + INSERT 로 작성돼 있었습니다. FK 가 생긴 순간 이 패턴이 깨졌습니다. POST 응답은 200 으로 돌아왔지만 정규화 결과는 어제 날짜에서 멈췄습니다 — 에러 로그를 보지 않으면 BUG 가 보이지 않습니다.
세 가지 fix 안:
① TRUNCATE ... CASCADE
자식까지 wipe → product_prices 같은 누적 데이터엔 부적합
② FK 를 NULLABLE 로 + SET NULL
자식 행 보존 / 부모 참조 의미 상실
③ DELETE WHERE NOT IN(현재 키) + INSERT ON CONFLICT DO UPDATE
사라진 부모만 정리 (자식은 ON DELETE CASCADE 자동 정리)
살아있는 부모는 UPSERT — FK 따름
세 번째가 단일 SQL 두 줄로 끝나면서 누적 자식 데이터를 보존합니다.
교훈 — 마스터-자식 관계가 생긴 순간 마스터의 TRUNCATE 는 즉시 backfill 가능한 BUG 입니다. SSOT 에 FK 를 더할 때마다 "누가 마스터를 wipe 하는가" 를 코드 검색해 둘 일입니다.
하고픈 말
선언적 SSOT 모델은 작은 팀에 가장 단순한 시작점입니다. 다만 정의 자체로는 안전하지 않습니다. 컬럼 audit 도구 한 줄을 옆에 놓고 가면 운영 사고가 한참 줄어듭니다.
Next
- api-handler-pattern
- jobs-apscheduler
Flyway 공식 · Liquibase 공식 · Alembic 공식 · Prisma Migrate · Drizzle Kit · Atlas · PostgreSQL DDL 을 참고합니다.