NestBook / Cloudflare + D1

D1 쿼리의 핵심 패턴

D1에서 쿼리를 실행하는 방법은 하나의 패턴으로 거의 모든 경우를 커버한다:

// 기본 패턴
const stmt = env.DB.prepare("SQL 문 (? 플레이스홀더 사용)");
const bound = stmt.bind(값1, 값2, ...);  // ? 에 값을 바인딩
const result = await bound.run();        // 또는 .first() / .all()

줄여서 체이닝으로 쓰는 경우가 많다:

const result = await env.DB.prepare("SELECT * FROM users WHERE id = ?")
  .bind(userId)
  .first();

Prepared Statements — SQL 인젝션 방어

절대 문자열 연결로 SQL을 만들지 않는다.

// ❌ 위험 — SQL 인젝션에 취약
const userId = request.headers.get('X-User-Id');
const query = `SELECT * FROM users WHERE id = ${userId}`;
// userId가 "1 OR 1=1"이면 모든 사용자 데이터가 유출된다

// ✅ 안전 — prepared statement 사용
const user = await env.DB.prepare("SELECT * FROM users WHERE id = ?")
  .bind(userId)
  .first();
// ? 자리에 userId 값이 리터럴로 바인딩된다. SQL로 해석되지 않는다.

? 플레이스홀더를 사용하면 D1이 값을 SQL이 아닌 데이터로 처리한다. 이것이 prepared statement의 역할이다.


SELECT — 데이터 조회

D1은 조회 결과를 가져오는 세 가지 메서드를 제공한다.

.first() — 첫 번째 행 하나

// 단일 행 조회 (없으면 null)
const user = await env.DB.prepare("SELECT * FROM users WHERE id = ?")
  .bind(userId)
  .first<User>();
// → { id: 1, email: "hong@example.com", name: "홍길동", ... } 또는 null

if (!user) {
  return Response.json({ error: "사용자를 찾을 수 없습니다" }, { status: 404 });
}

// 특정 컬럼 하나만 가져오기
const count = await env.DB.prepare("SELECT COUNT(*) as cnt FROM posts WHERE user_id = ?")
  .bind(userId)
  .first<{ cnt: number }>();
// → { cnt: 42 }

// 컬럼 이름으로 바로 꺼내기
const cnt = await env.DB.prepare("SELECT COUNT(*) as cnt FROM posts WHERE user_id = ?")
  .bind(userId)
  .first("cnt");  // 컬럼 이름 지정
// → 42 (숫자 그대로)

.all() — 여러 행

// 여러 행 조회
const result = await env.DB.prepare(
  "SELECT id, title, created_at FROM posts WHERE user_id = ? ORDER BY created_at DESC LIMIT ?"
).bind(userId, 20).all<Post>();

// result 구조:
// {
//   results: Post[],   ← 실제 데이터 배열
//   success: true,
//   meta: {
//     duration: 1.23,   // 쿼리 실행 시간 (ms)
//     rows_read: 20,    // 읽은 행 수 (요금 계산 기준)
//     rows_written: 0
//   }
// }

const posts = result.results;  // Post[] 배열

.raw() — 배열 형태로

// 객체 대신 배열로 반환 (컬럼 이름 없음, 파싱 오버헤드 최소화)
const rows = await env.DB.prepare("SELECT id, name FROM users LIMIT 5").raw<[number, string]>();
// → [[1, "홍길동"], [2, "김영희"], ...]

// 헤더 포함
const [headers, ...rows2] = await env.DB.prepare("SELECT id, name FROM users").raw({ columnNames: true });
// headers → ["id", "name"]
// rows2   → [[1, "홍길동"], ...]

SELECT — 실전 쿼리 패턴

조건 검색

// 여러 조건
const posts = await env.DB.prepare(`
  SELECT p.id, p.title, p.view_count, u.name AS author_name
  FROM posts p
  JOIN users u ON p.user_id = u.id
  WHERE p.is_public = 1
    AND p.published_at IS NOT NULL
    AND p.category_id = ?
  ORDER BY p.published_at DESC
  LIMIT ? OFFSET ?
`).bind(categoryId, pageSize, (page - 1) * pageSize).all<PostWithAuthor>();

페이지네이션

export async function getPaginatedPosts(env: Env, page: number, pageSize = 10) {
  const offset = (page - 1) * pageSize;

  const [postsResult, countResult] = await Promise.all([
    env.DB.prepare(`
      SELECT id, title, excerpt, created_at FROM posts
      WHERE is_public = 1
      ORDER BY created_at DESC
      LIMIT ? OFFSET ?
    `).bind(pageSize, offset).all<Post>(),
    
    env.DB.prepare("SELECT COUNT(*) as total FROM posts WHERE is_public = 1").first<{ total: number }>(),
  ]);

  const total = countResult?.total ?? 0;

  return {
    posts: postsResult.results,
    pagination: {
      page,
      pageSize,
      total,
      totalPages: Math.ceil(total / pageSize),
      hasNext: page * pageSize < total,
      hasPrev: page > 1,
    },
  };
}

LIKE 검색

// 제목에서 키워드 검색
const keyword = url.searchParams.get('q') ?? '';
const results = await env.DB.prepare(`
  SELECT id, title, excerpt FROM posts
  WHERE title LIKE ? AND is_public = 1
  ORDER BY created_at DESC
  LIMIT 20
`).bind(`%${keyword}%`).all<Post>();
// LIKE에도 prepared statement의 bind를 사용한다 — % 는 값 자체에 포함시킨다

INSERT — 데이터 삽입

기본 INSERT

// .run()은 실행 결과를 반환 (삽입된 행의 ID 포함)
const result = await env.DB.prepare(`
  INSERT INTO users (email, name, password)
  VALUES (?, ?, ?)
`).bind(email, name, hashedPassword).run();

// result 구조:
// {
//   success: true,
//   meta: {
//     last_row_id: 42,     // 삽입된 행의 id (AUTOINCREMENT)
//     rows_written: 1,
//     duration: 0.8
//   }
// }

const newUserId = result.meta.last_row_id;

INSERT 후 삽입된 행 바로 가져오기

// RETURNING 절 사용 (SQLite 3.35+, D1 지원)
const newUser = await env.DB.prepare(`
  INSERT INTO users (email, name, password, created_at)
  VALUES (?, ?, ?, unixepoch())
  RETURNING id, email, name, created_at
`).bind(email, name, hashedPassword).first<Omit<User, 'password'>>();

// → { id: 42, email: "hong@example.com", name: "홍길동", created_at: 1705312800 }

INSERT OR IGNORE / INSERT OR REPLACE

// 중복 시 무시
await env.DB.prepare(`
  INSERT OR IGNORE INTO categories (name, slug) VALUES (?, ?)
`).bind("JavaScript", "javascript").run();

// 중복 시 덮어쓰기 (기존 행 삭제 후 새로 삽입)
await env.DB.prepare(`
  INSERT OR REPLACE INTO user_settings (user_id, theme, lang)
  VALUES (?, ?, ?)
`).bind(userId, "dark", "ko").run();

UPDATE — 데이터 수정

// 기본 UPDATE
const result = await env.DB.prepare(`
  UPDATE posts
  SET title = ?, content = ?, updated_at = unixepoch()
  WHERE id = ? AND user_id = ?
`).bind(newTitle, newContent, postId, userId).run();

// 몇 행이 수정되었는지 확인
const updatedRows = result.meta.changes;  // 0이면 권한 없거나 없는 게시글
if (updatedRows === 0) {
  return Response.json({ error: "게시글을 찾을 수 없거나 권한이 없습니다" }, { status: 404 });
}

// 조회수 증가 (원자적 연산)
await env.DB.prepare(`
  UPDATE posts SET view_count = view_count + 1 WHERE id = ?
`).bind(postId).run();

DELETE — 데이터 삭제

// 기본 DELETE
const result = await env.DB.prepare(`
  DELETE FROM posts WHERE id = ? AND user_id = ?
`).bind(postId, userId).run();

if (result.meta.changes === 0) {
  return Response.json({ error: "삭제할 게시글이 없거나 권한이 없습니다" }, { status: 404 });
}

// 소프트 삭제 패턴 (실제 삭제 대신 플래그 처리)
await env.DB.prepare(`
  UPDATE posts SET deleted_at = unixepoch(), is_public = 0 WHERE id = ? AND user_id = ?
`).bind(postId, userId).run();

batch() — 여러 쿼리 한 번에 실행

batch()는 여러 쿼리를 하나의 HTTP 요청으로 묶어 D1에 전송한다. 개별 await보다 훨씬 빠르다.

// ❌ 느린 방법 — 쿼리마다 왕복 발생
const user = await env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(1).first();
const posts = await env.DB.prepare("SELECT * FROM posts WHERE user_id = ?").bind(1).all();
const comments = await env.DB.prepare("SELECT COUNT(*) as cnt FROM comments WHERE post_id = ?").bind(1).first();

// ✅ 빠른 방법 — 한 번에 전송
const [userResult, postsResult, commentResult] = await env.DB.batch([
  env.DB.prepare("SELECT * FROM users WHERE id = ?").bind(1),
  env.DB.prepare("SELECT * FROM posts WHERE user_id = ?").bind(1),
  env.DB.prepare("SELECT COUNT(*) as cnt FROM comments WHERE post_id = ?").bind(1),
]);

const user    = userResult.results[0];     // 또는 userResult.first()
const posts   = postsResult.results;
const comments = commentResult.results[0];

batch()로 여러 행 INSERT

// 여러 태그를 한 번에 게시글에 연결
const postId = 42;
const tagIds = [1, 3, 7, 12];

await env.DB.batch(
  tagIds.map(tagId =>
    env.DB.prepare("INSERT OR IGNORE INTO post_tags (post_id, tag_id) VALUES (?, ?)")
      .bind(postId, tagId)
  )
);
💡 batch()의 특성
  • 각 쿼리의 결과는 독립적 — 하나가 실패해도 다른 쿼리는 계속 실행된다
  • 트랜잭션이 아니다 — 모두 성공/실패가 보장되지 않는다. 트랜잭션이 필요하면 다음 챕터의 exec() 트랜잭션을 사용한다
  • 순서는 보장된다 — 배열 순서대로 실행되고 결과도 같은 순서로 반환된다

실전: 게시글 작성 API 전체 구현

INSERT + 관계 테이블까지 포함한 실전 예시:

interface CreatePostRequest {
  title: string;
  content: string;
  categoryId?: number;
  tagIds?: number[];
  isPublic?: boolean;
}

export async function createPost(
  env: Env,
  userId: number,
  data: CreatePostRequest
): Promise<{ id: number }> {
  // slug 생성 (한글 포함 시 실제로는 라이브러리 필요)
  const slug = data.title
    .toLowerCase()
    .replace(/[^a-z0-9가-힣]+/g, '-')
    .replace(/^-|-$/g, '') + '-' + Date.now();

  // 1. 게시글 삽입 + 생성된 ID 반환
  const post = await env.DB.prepare(`
    INSERT INTO posts (user_id, category_id, title, slug, content, is_public, published_at)
    VALUES (?, ?, ?, ?, ?, ?, CASE WHEN ? = 1 THEN unixepoch() ELSE NULL END)
    RETURNING id
  `).bind(
    userId,
    data.categoryId ?? null,
    data.title,
    slug,
    data.content,
    data.isPublic ? 1 : 0,
    data.isPublic ? 1 : 0,
  ).first<{ id: number }>();

  if (!post) throw new Error("게시글 생성 실패");

  // 2. 태그 연결 (여러 개 → batch)
  if (data.tagIds && data.tagIds.length > 0) {
    await env.DB.batch(
      data.tagIds.map(tagId =>
        env.DB.prepare("INSERT OR IGNORE INTO post_tags (post_id, tag_id) VALUES (?, ?)")
          .bind(post.id, tagId)
      )
    );
  }

  return { id: post.id };
}

타입 안전하게 사용하기

TypeScript와 함께 쓸 때 타입을 정의해두면 자동완성과 오류 감지가 된다.

// types.ts — DB 테이블 타입 정의
interface User {
  id: number;
  email: string;
  name: string;
  bio: string | null;
  avatar_url: string | null;
  created_at: number;
}

interface Post {
  id: number;
  user_id: number;
  category_id: number | null;
  title: string;
  slug: string;
  content: string;
  excerpt: string | null;
  is_public: 0 | 1;
  view_count: number;
  published_at: number | null;
  created_at: number;
  updated_at: number;
}

// JOIN 결과 타입 (여러 테이블 합침)
interface PostWithAuthor extends Omit<Post, 'content'> {
  author_name: string;
  author_avatar: string | null;
  category_name: string | null;
}
// 사용 예
const post = await env.DB.prepare("SELECT * FROM posts WHERE id = ?")
  .bind(id)
  .first<Post>();  // ← 타입 지정

if (post && post.is_public === 1) {
  // post.title, post.slug 자동완성 됨
}

성능 팁

meta로 쿼리 비용 모니터링

const result = await env.DB.prepare("SELECT * FROM posts WHERE is_public = 1")
  .all<Post>();

console.log({
  rowsRead: result.meta.rows_read,      // 요금 계산 기준
  rowsWritten: result.meta.rows_written,
  duration: result.meta.duration,       // ms
});

// rows_read가 매우 크면 인덱스가 없거나 쿼리 최적화가 필요한 신호

EXPLAIN QUERY PLAN — 쿼리 실행 계획 확인

# CLI에서 실행 계획 확인
wrangler d1 execute my-app-db --local --command="EXPLAIN QUERY PLAN SELECT * FROM posts WHERE user_id = 1"

# 출력:
# QUERY PLAN
# └── SEARCH posts USING INDEX idx_posts_user_id (user_id=?)
#                              ↑ 인덱스를 잘 타고 있음

# 인덱스를 안 타는 경우:
# └── SCAN posts  ← 전체 테이블 스캔 (느림, 인덱스 추가 필요)

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

오해 1: “D1은 비동기인지 몰랐다” 모든 D1 메서드는 Promise를 반환한다. await를 빠뜨리면 실제 데이터가 아닌 Promise 객체가 변수에 담긴다. 반드시 await를 붙인다.

오해 2: “result.results가 왜 이중으로 있지?” all()이 반환하는 객체는 { results: [], success: true, meta: {} }다. 실제 데이터는 .results 배열 안에 있다. .first().results[0]과 같지만, 없으면 null을 반환한다.

오해 3: “batch()는 트랜잭션이다” batch()는 여러 쿼리를 묶어 보내지만 트랜잭션이 아니다. 3번째 쿼리가 실패해도 1, 2번째는 이미 커밋된다. 원자성이 필요하면 다음 챕터에서 배우는 트랜잭션을 사용한다.

오해 4: “SQLite에도 boolean 타입이 있다” 없다. is_public INTEGER에 0 또는 1로 저장한다. JavaScript에서 읽을 때는 post.is_public === 1로 비교하거나 Boolean(post.is_public)으로 변환한다.


다음 챕터에서는 Workers + D1 통합의 완성형을 다룬다. env.DB 바인딩을 활용한 전체 API 구조, 트랜잭션으로 원자성 보장, 그리고 에러 처리 패턴까지 — 실제 프로덕션 코드에 가까운 수준으로 정리한다.