NestBook / Cloudflare + D1

이 챕터의 목표

지금까지 D1의 생성, 스키마, CRUD를 각각 배웠다. 이 챕터에서는 이것들을 Workers 코드 안에서 체계적으로 통합하는 방법을 다룬다.

  • env.DB 바인딩 패턴과 타입 설정
  • 트랜잭션으로 원자성 보장
  • 에러 처리 — D1 에러를 HTTP 응답으로 변환
  • 계층 분리 — Workers 핸들러와 DB 로직을 분리하는 패턴
  • 실전: 미니 REST API 전체 구현

env.DB 바인딩 — 타입 설정

worker-configuration.d.ts

wrangler types 명령어로 자동 생성하거나 수동으로 작성한다:

// worker-configuration.d.ts (프로젝트 루트)
interface Env {
  DB: D1Database;
  MY_KV: KVNamespace;          // KV도 함께 사용한다면
  ENVIRONMENT: string;         // 환경 변수
  JWT_SECRET: string;          // 시크릿 (다음 챕터)
}

Workers 핸들러에서 env.DB 사용

// src/index.ts
export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
    // env.DB가 D1Database 타입으로 자동완성된다
    const user = await env.DB.prepare("SELECT * FROM users WHERE id = ?")
      .bind(1)
      .first<User>();
    
    return Response.json(user);
  },
};

트랜잭션 — 원자성 보장

트랜잭션은 여러 쿼리를 하나의 단위로 묶는다. 중간에 하나라도 실패하면 전체를 되돌린다(롤백).

언제 트랜잭션이 필요한가?

예: 포인트 충전 + 결제 내역 저장을 동시에
  1. users 테이블에서 balance -= 5000       ← 이 쿼리는 성공
  2. payments 테이블에 결제 내역 INSERT     ← 이 쿼리가 실패
  
  트랜잭션 없으면: 돈은 빠졌는데 내역은 없는 상태가 된다 (데이터 불일치)
  트랜잭션 있으면: 2번이 실패하면 1번도 롤백 → 처음 상태로 복원

D1 트랜잭션 — exec() 사용

D1에서 트랜잭션은 exec() 메서드로 처리한다. 단, exec()는 바인딩(?)을 지원하지 않으므로 값을 직접 포함시켜야 한다.

// ⚠️ exec()는 SQL 인젝션에 주의 — 외부 입력값을 직접 사용하면 안 된다
// 숫자 값은 안전하게 사용 가능

async function transferPoints(env: Env, fromUserId: number, toUserId: number, amount: number) {
  // 정수로 강제 변환 (SQL 인젝션 방지)
  const safeFrom = Math.floor(fromUserId);
  const safeTo   = Math.floor(toUserId);
  const safeAmt  = Math.floor(amount);

  await env.DB.exec(`
    BEGIN TRANSACTION;
    
    -- 보내는 사람 포인트 차감
    UPDATE users SET points = points - ${safeAmt} WHERE id = ${safeFrom};
    
    -- 받는 사람 포인트 증가
    UPDATE users SET points = points + ${safeAmt} WHERE id = ${safeTo};
    
    -- 이체 내역 기록
    INSERT INTO point_transfers (from_user_id, to_user_id, amount, created_at)
    VALUES (${safeFrom}, ${safeTo}, ${safeAmt}, unixepoch());
    
    COMMIT;
  `);
}
⚠️ exec()에 문자열 값을 직접 넣으면 안 된다
exec()는 prepared statement의 바인딩을 지원하지 않는다. 정수나 고정된 값만 직접 삽입한다. 사용자 입력 문자열(이메일, 이름, 내용 등)이 포함된 트랜잭션은 아래의 대안 패턴을 사용한다.

문자열 값이 있는 트랜잭션 — batch() + 에러 처리

// 사용자 생성 + 기본 설정 추가 (문자열 포함)
async function createUserWithDefaults(env: Env, email: string, name: string, hashedPw: string) {
  // batch()는 prepared statement를 지원하므로 문자열 바인딩 가능
  // 단, 중간 실패 시 롤백이 안 된다 — 명시적으로 처리해야 함
  
  try {
    const results = await env.DB.batch([
      // 1. 사용자 생성
      env.DB.prepare("INSERT INTO users (email, name, password) VALUES (?, ?, ?)")
        .bind(email, name, hashedPw),
      // 2. 사용자 설정 테이블에 기본값 삽입 (user_id는 아직 모름 — 이 방식의 한계)
    ]);
    
    return results[0].meta.last_row_id;
  } catch (error) {
    // batch() 실패 시 이미 실행된 쿼리는 롤백되지 않는다
    // 보상 트랜잭션(이미 삽입된 데이터 삭제)이 필요하다
    throw error;
  }
}

실제로 원자성이 중요한 경우 exec() 트랜잭션이 더 안전하다. 문자열 값은 wrangler d1 execute로 사전에 삽입하거나, 정수 ID를 기반으로 후속 쿼리를 설계한다.


에러 처리 패턴

D1 쿼리 실패는 try-catch로 잡는다. 에러 유형에 따라 적절한 HTTP 응답을 반환한다.

// D1 에러 타입들
// - D1_ERROR: SQL 문법 오류, 제약 조건 위반 등
// - UNIQUE constraint failed: users.email  ← 이메일 중복
// - FOREIGN KEY constraint failed           ← 외래 키 위반

function isUniqueConstraintError(error: unknown, column?: string): boolean {
  if (!(error instanceof Error)) return false;
  const msg = error.message.toLowerCase();
  if (!msg.includes('unique constraint failed')) return false;
  if (column) return msg.includes(column.toLowerCase());
  return true;
}

async function createUser(env: Env, email: string, name: string, password: string) {
  try {
    const result = await env.DB.prepare(`
      INSERT INTO users (email, name, password) VALUES (?, ?, ?)
      RETURNING id, email, name
    `).bind(email, name, password).first<Pick<User, 'id' | 'email' | 'name'>>();

    return { ok: true, user: result };

  } catch (error) {
    if (isUniqueConstraintError(error, 'users.email')) {
      return { ok: false, status: 409, message: '이미 사용 중인 이메일입니다' };
    }
    // 예상하지 못한 에러는 다시 던진다
    throw error;
  }
}

HTTP 응답으로 변환하는 패턴

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    try {
      const url = new URL(request.url);

      if (url.pathname === '/api/users' && request.method === 'POST') {
        const body = await request.json<{ email: string; name: string; password: string }>();
        
        const result = await createUser(env, body.email, body.name, body.password);
        
        if (!result.ok) {
          return Response.json({ error: result.message }, { status: result.status });
        }
        return Response.json(result.user, { status: 201 });
      }

      return Response.json({ error: 'Not Found' }, { status: 404 });

    } catch (error) {
      // 예상하지 못한 에러 — 500으로 처리
      console.error('Unhandled error:', error);
      return Response.json(
        { error: '서버 오류가 발생했습니다' },
        { status: 500 }
      );
    }
  },
};

계층 분리 — DB 로직을 별도 파일로

Workers 핸들러 파일이 커지면 DB 로직을 별도 파일로 분리한다. 이것이 유지보수하기 좋은 구조다.

src/
  index.ts          ← 라우팅 핸들러만
  db/
    users.ts        ← users 테이블 관련 쿼리
    posts.ts        ← posts 테이블 관련 쿼리
  types.ts          ← 타입 정의
// src/db/users.ts
export async function getUserById(db: D1Database, id: number): Promise<User | null> {
  return db.prepare("SELECT id, email, name, created_at FROM users WHERE id = ?")
    .bind(id)
    .first<User>();
}

export async function getUserByEmail(db: D1Database, email: string): Promise<User | null> {
  return db.prepare("SELECT * FROM users WHERE email = ?")
    .bind(email)
    .first<User>();
}

export async function createUser(
  db: D1Database,
  data: { email: string; name: string; password: string }
): Promise<Pick<User, 'id' | 'email' | 'name'> | null> {
  return db.prepare(`
    INSERT INTO users (email, name, password)
    VALUES (?, ?, ?)
    RETURNING id, email, name
  `).bind(data.email, data.name, data.password).first();
}

export async function getUserCount(db: D1Database): Promise<number> {
  const result = await db.prepare("SELECT COUNT(*) as cnt FROM users").first<{ cnt: number }>();
  return result?.cnt ?? 0;
}
// src/db/posts.ts
export interface PostFilter {
  userId?: number;
  categoryId?: number;
  isPublic?: boolean;
  page?: number;
  pageSize?: number;
}

export async function getPosts(db: D1Database, filter: PostFilter = {}) {
  const { userId, categoryId, isPublic, page = 1, pageSize = 10 } = filter;
  const offset = (page - 1) * pageSize;

  // 동적 WHERE 절 구성
  const conditions: string[] = [];
  const bindings: unknown[] = [];

  if (userId !== undefined) {
    conditions.push("p.user_id = ?");
    bindings.push(userId);
  }
  if (categoryId !== undefined) {
    conditions.push("p.category_id = ?");
    bindings.push(categoryId);
  }
  if (isPublic !== undefined) {
    conditions.push("p.is_public = ?");
    bindings.push(isPublic ? 1 : 0);
  }

  const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';

  const sql = `
    SELECT p.id, p.title, p.slug, p.excerpt, p.view_count, p.created_at,
           u.name AS author_name, c.name AS category_name
    FROM posts p
    JOIN users u ON p.user_id = u.id
    LEFT JOIN categories c ON p.category_id = c.id
    ${whereClause}
    ORDER BY p.created_at DESC
    LIMIT ? OFFSET ?
  `;

  bindings.push(pageSize, offset);

  const result = await db.prepare(sql).bind(...bindings).all<PostWithAuthor>();
  return result.results;
}
// src/index.ts — 핸들러만 담당
import { getUserById, createUser } from './db/users';
import { getPosts } from './db/posts';

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const path = url.pathname;

    // GET /api/users/:id
    const userMatch = path.match(/^\/api\/users\/(\d+)$/);
    if (userMatch && request.method === 'GET') {
      const user = await getUserById(env.DB, Number(userMatch[1]));
      if (!user) return Response.json({ error: '사용자 없음' }, { status: 404 });
      return Response.json(user);
    }

    // GET /api/posts
    if (path === '/api/posts' && request.method === 'GET') {
      const posts = await getPosts(env.DB, {
        isPublic: true,
        page: Number(url.searchParams.get('page') ?? 1),
        pageSize: Number(url.searchParams.get('pageSize') ?? 10),
      });
      return Response.json(posts);
    }

    return Response.json({ error: 'Not Found' }, { status: 404 });
  },
};

KV 캐시 + D1 조합 패턴

D1 쿼리 결과를 KV에 캐시하면 DB 부하를 줄이고 응답 속도를 높일 수 있다.

// src/db/posts.ts
export async function getPostBySlug(
  env: Env,
  slug: string
): Promise<PostWithAuthor | null> {
  const cacheKey = `cache:post:${slug}`;

  // 1. KV 캐시 확인 (빠른 경로)
  const cached = await env.MY_KV.get<PostWithAuthor>(cacheKey, { type: 'json' });
  if (cached) return cached;

  // 2. 캐시 미스 — D1에서 조회
  const post = await env.DB.prepare(`
    SELECT p.*, u.name AS author_name, c.name AS category_name
    FROM posts p
    JOIN users u ON p.user_id = u.id
    LEFT JOIN categories c ON p.category_id = c.id
    WHERE p.slug = ? AND p.is_public = 1
  `).bind(slug).first<PostWithAuthor>();

  if (!post) return null;

  // 3. KV에 10분 캐시 (응답 보내면서 백그라운드에서 저장)
  // ctx.waitUntil이 없을 때는 그냥 await
  await env.MY_KV.put(cacheKey, JSON.stringify(post), { expirationTtl: 600 });

  return post;
}

// 게시글 수정 시 캐시 무효화
export async function invalidatePostCache(env: Env, slug: string) {
  await env.MY_KV.delete(`cache:post:${slug}`);
}

실전: 미니 블로그 API 완성본

지금까지 배운 것을 합쳐 실제로 동작하는 미니 블로그 REST API를 만든다.

// src/index.ts
import { getUserById, getUserByEmail, createUser } from './db/users';
import { getPosts, getPostBySlug, createPost, incrementViewCount } from './db/posts';

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    const method = request.method;
    const path = url.pathname;

    try {
      // ── 사용자 API ─────────────────────────────────────────
      
      // POST /api/users — 회원가입
      if (path === '/api/users' && method === 'POST') {
        const { email, name, password } = await request.json<{
          email: string; name: string; password: string;
        }>();
        
        if (!email || !name || !password) {
          return Response.json({ error: '필수 항목이 누락되었습니다' }, { status: 400 });
        }

        try {
          const user = await createUser(env.DB, { email, name, password });
          return Response.json(user, { status: 201 });
        } catch (e) {
          if (e instanceof Error && e.message.includes('UNIQUE constraint failed: users.email')) {
            return Response.json({ error: '이미 사용 중인 이메일입니다' }, { status: 409 });
          }
          throw e;
        }
      }

      // GET /api/users/:id — 사용자 조회
      const userMatch = path.match(/^\/api\/users\/(\d+)$/);
      if (userMatch && method === 'GET') {
        const user = await getUserById(env.DB, Number(userMatch[1]));
        if (!user) return Response.json({ error: '사용자를 찾을 수 없습니다' }, { status: 404 });
        return Response.json(user);
      }

      // ── 게시글 API ─────────────────────────────────────────

      // GET /api/posts — 목록 조회
      if (path === '/api/posts' && method === 'GET') {
        const page = Number(url.searchParams.get('page') ?? 1);
        const pageSize = Math.min(Number(url.searchParams.get('pageSize') ?? 10), 50);
        const posts = await getPosts(env.DB, { isPublic: true, page, pageSize });
        return Response.json(posts);
      }

      // POST /api/posts — 게시글 작성
      if (path === '/api/posts' && method === 'POST') {
        // 실제로는 JWT 인증 처리 필요 (다음 챕터)
        const userId = Number(request.headers.get('X-User-Id'));
        if (!userId) return Response.json({ error: '인증이 필요합니다' }, { status: 401 });
        
        const { title, content, isPublic } = await request.json<{
          title: string; content: string; isPublic?: boolean;
        }>();
        
        const post = await createPost(env.DB, userId, { title, content, isPublic });
        return Response.json(post, { status: 201 });
      }

      // GET /api/posts/:slug — 게시글 상세
      const postMatch = path.match(/^\/api\/posts\/([a-z0-9-]+)$/);
      if (postMatch && method === 'GET') {
        const slug = postMatch[1];
        const post = await getPostBySlug(env.DB, slug);
        if (!post) return Response.json({ error: '게시글을 찾을 수 없습니다' }, { status: 404 });
        
        // 조회수 증가 (응답 후 백그라운드 처리)
        // 실제로는 ctx.waitUntil 사용
        await incrementViewCount(env.DB, post.id);
        
        return Response.json(post);
      }

      return Response.json({ error: 'Not Found' }, { status: 404 });

    } catch (error) {
      console.error('[Worker] Unhandled error:', error);
      return Response.json({ error: '서버 오류' }, { status: 500 });
    }
  },
};

배포 — wrangler deploy

개발 완료 후 프로덕션에 배포:

# 1. 프로덕션 D1에 마이그레이션 적용 (먼저)
wrangler d1 migrations apply my-app-db

# 2. Workers 코드 배포
wrangler deploy

# 출력:
# ⛅️ wrangler 3.x.x
# -------------------
# Total Upload: 12.34 KiB / gzip: 4.56 KiB
# Uploaded my-worker (1.23 sec)
# Published my-worker (0.45 sec)
#   https://my-worker.my-account.workers.dev

초보자 흔한 오해와 주의사항

오해 1: “env.DB는 어디서든 접근할 수 있다” env는 Workers fetch 핸들러의 두 번째 인자로만 받을 수 있다. DB 로직 함수에 env.DB 또는 env.DB를 넘겨주는 방식으로 사용한다. 전역 변수로 접근하려 하면 오류가 난다.

오해 2: “트랜잭션 실패 시 자동으로 롤백된다” exec() 내에서 BEGIN TRANSACTIONCOMMIT을 명시해야 한다. exec() 자체가 트랜잭션을 자동으로 감싸주지 않는다. BEGIN 없이 여러 쿼리를 exec()에 넣으면 각각 자동 커밋된다.

오해 3: “배포하면 DB 스키마도 자동으로 업데이트된다” 아니다. wrangler deploy는 코드만 배포한다. 스키마 변경은 반드시 wrangler d1 migrations apply먼저 실행해야 한다. 순서를 바꾸면 새 코드가 없는 컬럼을 조회하다가 오류가 발생한다.

주의: D1은 로컬 개발과 프로덕션의 SQLite 버전이 다를 수 있다 wrangler dev의 로컬 D1과 실제 Cloudflare D1은 SQLite 버전이 다를 수 있다. 로컬에서 동작한 쿼리가 프로덕션에서 실패하는 경우는 드물지만 새로운 SQLite 기능을 사용할 때는 D1 호환성을 확인한다.


2단계 D1 데이터베이스 학습이 완료되었다. Workers KV(1단계 마지막)와 D1을 함께 사용하는 패턴, 마이그레이션으로 스키마를 버전 관리하는 방법, CRUD 쿼리와 트랜잭션, 그리고 계층 분리된 코드 구조까지 익혔다. 다음 3단계에서는 Hono 프레임워크를 도입해 지금까지 수동으로 처리하던 라우팅, 미들웨어, 유효성 검사를 훨씬 깔끔하게 처리한다.