codingstairs
노트에듀라이프연락
⌕검색⌘K
koen

Navigation

  • Intro
  • Blog
  • Life

연락하기

로그인 없이도 보낼 수 있어요. 답변이 필요하면 이메일을 함께 적어 주세요.

  • 익명 폼으로 의견 남기기 →
  • ✉ warragon112@gmail.com
  • 카카오톡 오픈채팅 ↗

© 2026 codingstairs

  • 노트
  • 에듀
  • 검색
  • 라이프
  • 연락
  • 약관
  • RSS
  • GitHub
노트›backend

SQL 을 단일 진실 출처로

2026-04-28 게시· 2026-05-18 갱신·0회 조회

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 을 참고합니다.

backend 카테고리의 다른 글

카테고리 전체 보기 →
  • 공공 OpenAPI 는 자체 BFF 로 한 번 감싼다
  • 이메일 발송과 OTP — SMTP
  • 감사로그 — logAdminAction 패턴
  • WebSocket · SSE — 실시간 통신
  • REST API 입문
  • OpenAPI 사양