D1 스키마 & 마이그레이션
스키마란? 마이그레이션이란?
스키마(Schema): 데이터베이스의 구조 정의. 어떤 테이블이 있고, 각 테이블에 어떤 컬럼이 있으며, 어떤 타입과 제약조건을 가지는지.
마이그레이션(Migration): 스키마 변경을 코드처럼 버전 관리하는 방법. “v1에서 v2로 변경할 때 users 테이블에 phone 컬럼을 추가한다”를 SQL 파일로 기록해두는 것.
마이그레이션 없이 개발하면 이런 상황이 벌어진다:
개발 DB: users 테이블에 phone 컬럼이 있음
프로덕션 DB: phone 컬럼이 없음 (추가하는 걸 까먹음)
결과: 프로덕션에서 INSERT 쿼리 실패 → 서비스 장애
마이그레이션은 이런 불일치를 막는 안전 장치다.
D1 마이그레이션 시스템 초기화
Wrangler는 D1 마이그레이션을 위한 내장 도구를 제공한다.
1. 마이그레이션 폴더 생성
# wrangler.toml이 있는 프로젝트 루트에서 실행
wrangler d1 migrations create my-app-db initial_schema
# 출력:
# ✅ Successfully created Migration 'migrations/0001_initial_schema.sql'!
migrations/ 폴더가 생성되고 첫 번째 마이그레이션 파일이 만들어진다:
my-project/
wrangler.toml
src/
index.ts
migrations/
0001_initial_schema.sql ← 방금 생성됨
2. wrangler.toml에 마이그레이션 폴더 등록
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2024-09-23"
[[d1_databases]]
binding = "DB"
database_name = "my-app-db"
database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
migrations_dir = "migrations" # ← 이 줄 추가
CREATE TABLE — 테이블 정의
SQLite(D1)의 주요 타입과 제약조건을 보면서 첫 번째 마이그레이션 파일을 작성한다.
SQLite 데이터 타입
SQLite는 타입이 유연하다. D1에서 주로 사용하는 타입:
| SQLite 타입 | 의미 | 예시 |
|---|---|---|
INTEGER | 정수 | id, count, timestamp(unix) |
TEXT | 문자열 (길이 제한 없음) | name, email, content |
REAL | 부동소수점 | price, rating |
BLOB | 바이너리 데이터 | 이미지 (권장하지 않음, R2 사용) |
NUMERIC | 정수 또는 실수 | amount |
SQLite에는
BOOLEAN,DATE,DATETIME타입이 없다. 불리언은INTEGER(0/1), 날짜는TEXT(ISO 8601) 또는INTEGER(Unix 타임스탬프)로 저장한다.
첫 번째 마이그레이션 파일 작성
-- migrations/0001_initial_schema.sql
-- 사용자 테이블
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT, -- 자동 증가 PK
email TEXT NOT NULL UNIQUE, -- 유니크 이메일
name TEXT NOT NULL,
password TEXT NOT NULL, -- 실제로는 해시값 저장
created_at INTEGER NOT NULL DEFAULT (unixepoch()), -- Unix 타임스탬프
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- 게시글 테이블
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT NOT NULL,
is_public INTEGER NOT NULL DEFAULT 1, -- 0: 비공개, 1: 공개
view_count INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- 자주 조회하는 컬럼에 인덱스 추가
CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id);
CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at DESC);
주요 제약조건 설명
| 제약조건 | 의미 |
|---|---|
PRIMARY KEY | 테이블의 기본 키. 자동으로 인덱스가 생성되고 NULL/중복 불가 |
AUTOINCREMENT | INSERT 시 id를 자동으로 1씩 증가. 삭제된 id는 재사용하지 않음 |
NOT NULL | NULL 값을 허용하지 않음 |
UNIQUE | 중복 값을 허용하지 않음 |
DEFAULT | 값을 명시하지 않을 때 사용할 기본값 |
REFERENCES | 외래 키 — 다른 테이블의 컬럼을 참조 |
ON DELETE CASCADE | 참조하는 행이 삭제되면 이 행도 자동 삭제 |
마이그레이션 적용
로컬 개발 DB에 적용
wrangler d1 migrations apply my-app-db --local
# 출력:
# ✅ Successfully applied migration '0001_initial_schema.sql' to DB 'my-app-db' (local)!
#
# ┌─────────────────────────┬────────┐
# │ Name │ Status │
# ├─────────────────────────┼────────┤
# │ 0001_initial_schema.sql │ ✅ │
# └─────────────────────────┴────────┘
프로덕션 DB에 적용
wrangler d1 migrations apply my-app-db
# 출력:
# ✅ Successfully applied migration '0001_initial_schema.sql' to DB 'my-app-db'!
--local 플래그 없이 실행하면 실제 운영 DB에 적용된다. 롤백은 직접 SQL을 작성해야 한다.
마이그레이션 상태 확인
wrangler d1 migrations list my-app-db --local
# 출력:
# ┌─────────────────────────┬────────┐
# │ Name │ Status │
# ├─────────────────────────┼────────┤
# │ 0001_initial_schema.sql │ ✅ applied │
# └─────────────────────────┴────────┘
스키마 변경 — 두 번째 마이그레이션
서비스를 운영하다 보면 스키마를 변경해야 한다. 예를 들어 users 테이블에 phone 컬럼을 추가해야 한다고 하자.
절대로 하면 안 되는 방법 ❌
# 이미 적용된 마이그레이션 파일을 직접 수정하지 않는다!
# → 로컬에서는 동작할 수 있지만 프로덕션에는 이미 적용된 상태라
# 변경 내용이 적용되지 않는다. 두 환경이 다른 스키마를 갖게 된다.
올바른 방법 ✅ — 새 마이그레이션 파일 생성
wrangler d1 migrations create my-app-db add_phone_to_users
# migrations/0002_add_phone_to_users.sql 파일 생성됨
-- migrations/0002_add_phone_to_users.sql
ALTER TABLE users ADD COLUMN phone TEXT; -- NULL 허용 (기존 행에 값이 없으므로)
# 로컬에 적용
wrangler d1 migrations apply my-app-db --local
# 프로덕션에 적용
wrangler d1 migrations apply my-app-db
SQLite ALTER TABLE의 한계
SQLite는 ALTER TABLE에 제약이 있다. D1에서 할 수 있는 것과 할 수 없는 것:
| 작업 | 가능 여부 | 대안 |
|---|---|---|
컬럼 추가 (ADD COLUMN) | ✅ 가능 | - |
컬럼 삭제 (DROP COLUMN) | ✅ 가능 (SQLite 3.35+) | - |
컬럼 이름 변경 (RENAME COLUMN) | ✅ 가능 | - |
| 컬럼 타입 변경 | ❌ 불가능 | 새 컬럼 추가 → 데이터 복사 → 구 컬럼 삭제 |
| NOT NULL 제약 추가 (기존 컬럼에) | ❌ 불가능 | 위와 동일 |
실전: 블로그 서비스 스키마 설계
실제로 쓸만한 스키마를 처음부터 설계해보자. 미니 블로그 서비스를 예로 든다.
-- migrations/0001_initial_schema.sql
-- 사용자
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
bio TEXT, -- 자기소개 (선택사항)
avatar_url TEXT, -- 프로필 이미지 URL
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- 카테고리
CREATE TABLE IF NOT EXISTS categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE -- URL용 슬러그 (예: "javascript")
);
-- 게시글
CREATE TABLE IF NOT EXISTS posts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL,
title TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE, -- URL용 슬러그 (예: "my-first-post")
content TEXT NOT NULL,
excerpt TEXT, -- 미리보기 텍스트
is_public INTEGER NOT NULL DEFAULT 1,
view_count INTEGER NOT NULL DEFAULT 0,
published_at INTEGER, -- NULL이면 초안
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- 태그
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE
);
-- 게시글-태그 다대다 관계
CREATE TABLE IF NOT EXISTS post_tags (
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id) -- 복합 PK로 중복 방지
);
-- 댓글
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- 인덱스
CREATE INDEX IF NOT EXISTS idx_posts_user_id ON posts(user_id);
CREATE INDEX IF NOT EXISTS idx_posts_category_id ON posts(category_id);
CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(published_at DESC) WHERE published_at IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_comments_post_id ON comments(post_id);
/posts/123 대신 /posts/my-first-post처럼 쓴다. SEO에 유리하고 사용자가 URL만 보고도 내용을 예측할 수 있다. 한글 제목은 영문으로 변환하거나, 한글 인코딩된 슬러그를 사용한다.
마이그레이션 내에서 초기 데이터 삽입
스키마 생성과 함께 기본 데이터를 넣고 싶을 때:
-- migrations/0002_seed_categories.sql
-- 기본 카테고리 추가
INSERT OR IGNORE INTO categories (name, slug) VALUES
('JavaScript', 'javascript'),
('TypeScript', 'typescript'),
('Cloudflare Workers', 'cloudflare-workers'),
('기타', 'etc');
INSERT OR IGNORE는 중복 키가 있을 때 에러 없이 건너뛴다. 마이그레이션이 실수로 두 번 실행되어도 안전하다.
CLI로 스키마 직접 확인
# 로컬 DB의 테이블 목록 확인
wrangler d1 execute my-app-db --local --command=".tables"
# 또는
wrangler d1 execute my-app-db --local --command="SELECT name FROM sqlite_master WHERE type='table'"
# 특정 테이블의 컬럼 구조 확인
wrangler d1 execute my-app-db --local --command="PRAGMA table_info(users)"
# 출력:
# ┌─────┬────────────┬─────────┬─────────┬─────────────────────────┬────┐
# │ cid │ name │ type │ notnull │ dflt_value │ pk │
# ├─────┼────────────┼─────────┼─────────┼─────────────────────────┼────┤
# │ 0 │ id │ INTEGER │ 0 │ null │ 1 │
# │ 1 │ email │ TEXT │ 1 │ null │ 0 │
# │ 2 │ name │ TEXT │ 1 │ null │ 0 │
# │ 3 │ created_at │ INTEGER │ 1 │ (unixepoch()) │ 0 │
# └─────┴────────────┴─────────┴─────────┴─────────────────────────┴────┘
# 인덱스 목록 확인
wrangler d1 execute my-app-db --local --command="SELECT name, sql FROM sqlite_master WHERE type='index'"
초보자 흔한 오해와 주의사항
오해 1: “D1도 PostgreSQL처럼 날짜 타입이 있겠지”
SQLite(D1)에는 DATE, DATETIME, TIMESTAMP 타입이 없다. TEXT("2024-01-15T09:00:00Z") 또는 INTEGER(Unix 타임스탬프: 1705312800)로 저장한다. unixepoch() 함수는 현재 시간의 Unix 타임스탬프를 반환한다.
오해 2: “마이그레이션 파일을 한 번 더 실행하면 오류가 난다”
Wrangler의 마이그레이션 시스템은 이미 적용된 파일을 기록해두기 때문에 migrations apply를 반복 실행해도 이미 적용된 파일은 건너뛴다. 단, 파일을 수정하고 재실행해도 이미 적용된 것으로 처리되므로 변경 내용은 적용되지 않는다.
오해 3: “외래 키 제약이 자동으로 동작한다”
SQLite는 기본적으로 외래 키 제약이 비활성화 되어 있다. D1 Workers 바인딩에서는 자동으로 활성화되지만, CLI에서 직접 SQL을 실행할 때는 PRAGMA foreign_keys = ON;을 먼저 실행해야 한다.
주의: 마이그레이션 파일 이름 순서가 중요하다
Wrangler는 파일 이름의 숫자 접두사(0001_, 0002_, …)를 기준으로 순서대로 적용한다. 파일 이름을 바꾸거나 순서를 뒤집으면 의도치 않은 결과가 발생한다.
다음 챕터에서는 D1 CRUD 쿼리를 다룬다. prepare().bind().run() 패턴으로 SELECT, INSERT, UPDATE, DELETE를 실행하고, prepared statements로 SQL 인젝션을 방어하며, batch()로 여러 쿼리를 한 번에 처리하는 방법을 배운다.