NestBook / Cloudflare + 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/중복 불가
AUTOINCREMENTINSERT 시 id를 자동으로 1씩 증가. 삭제된 id는 재사용하지 않음
NOT NULLNULL 값을 허용하지 않음
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'!
⚠️ 프로덕션 마이그레이션은 신중하게
프로덕션 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);
💡 slug란?
URL에 사용하는 알아보기 쉬운 식별자다. /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()로 여러 쿼리를 한 번에 처리하는 방법을 배운다.