D1 CRUD 쿼리
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)
)
);
- 각 쿼리의 결과는 독립적 — 하나가 실패해도 다른 쿼리는 계속 실행된다
- 트랜잭션이 아니다 — 모두 성공/실패가 보장되지 않는다. 트랜잭션이 필요하면 다음 챕터의
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 구조, 트랜잭션으로 원자성 보장, 그리고 에러 처리 패턴까지 — 실제 프로덕션 코드에 가까운 수준으로 정리한다.