05. CRUD 조작 (CRUD Operations)
📋 개요
기본적인 조작을 넘어, 실무에서 마주치는 복잡한 데이터 처리 패턴을 다룹니다.
📋 목차
- 🔍 1. 필터 & 연산자 총정리 (Cheat Sheet)
- ➕ 2. Insert 심화 (Upsert & Returning)
- ✏️ 3. Update 심화 (Atomic Increments)
- 🚛 4. 대량 조작 (Batch Operations)
- 🔗 레퍼런스
기본적인 조작을 넘어, 실무에서 마주치는 복잡한 데이터 처리 패턴을 다룹니다.
🔍 1. 필터 & 연산자 총정리 (Cheat Sheet)
where 절에서 사용하는 연산자들입니다. import { eq, gt, ... } from 'drizzle-orm' 해서 씁니다.
| 연산자 | SQL 대응 | 사용 예시 | 비고 |
|---|---|---|---|
eq | = | eq(table.id, 1) | Equal |
ne | <> | ne(table.status, 'deleted') | Not Equal |
gt / gte | > / >= | gt(table.age, 18) | Greater Than |
lt / lte | < / <= | lt(table.price, 1000) | Less Than |
like | LIKE | like(table.name, '%Kim%') | 부분 일치 (대소문자 구분 O) |
ilike | ILIKE | ilike(table.name, '%kim%') | [PG전용] 대소문자 무시 매칭 |
inArray | IN | inArray(table.id, [1, 2, 3]) | 배열에 포함 여부 |
notInArray | NOT IN | notInArray(table.role, ['admin']) | |
isNull | IS NULL | isNull(table.deletedAt) | |
isNotNull | IS NOT NULL | isNotNull(table.email) | |
between | BETWEEN | between(table.age, 20, 30) |
논리 연산자 결합
import { and, or, not } from 'drizzle-orm';
// (age >= 20 AND status = 'active') OR role = 'admin'
await db.select().from(users).where(
or(
and(gte(users.age, 20), eq(users.status, 'active')),
eq(users.role, 'admin')
)
);➕ 2. Insert 심화 (Upsert & Returning)
Upsert (있으면 수정, 없으면 입력)
PostgreSQL의 ON CONFLICT 구문을 활용합니다.
// ID가 겹치면 이름을 업데이트하고, 아니면 새로 만듦
await db.insert(users).values({ id: 1, name: 'New Name' })
.onConflictDoUpdate({
target: users.id, // 충돌 감지할 컬럼 (Unique/PK)
set: { name: 'New Name', updatedAt: new Date() }, // 수정할 내용
});
// ID가 겹치면 그냥 무시 (아무것도 안 함)
await db.insert(users).values({ id: 1, ... })
.onConflictDoNothing();Returning (결과 바로 받기)
Insert 뿐만 아니라 Update, Delete에서도 됩니다!
// 삭제된 유저 정보를 반환받음 (로그 남길 때 유용)
const [deletedUser] = await db.delete(users)
.where(eq(users.id, 1))
.returning({
deletedId: users.id,
deletedEmail: users.email
});✏️ 3. Update 심화 (Atomic Increments)
"조회수 +1" 같은 기능 만들 때, 값을 읽어와서 +1 하고 다시 저장하면 동시성 문제가 생깁니다. DB에서 직접 연산하세요.
import { sql } from 'drizzle-orm';
// 조회수 = 현재 조회수 + 1 (Atomic)
await db.update(posts)
.set({
views: sql`${posts.views} + 1`,
updatedAt: new Date(),
})
.where(eq(posts.id, 5));🚛 4. 대량 조작 (Batch Operations)
수만 건의 데이터를 처리할 때는 네트워크 왕복(Round Trip)을 줄여야 합니다.
Bulk Insert
const manyUsers = Array(1000).fill({ ... });
await db.insert(users).values(manyUsers);
// Drizzle이 하나의 SQL로 묶어서 보냅니다. (Limit 있음, 보통 65535 파라미터)Transaction 활용
여러 작업을 한 번에 묶습니다.
await db.transaction(async (tx) => {
// 1. 유저 생성
const [user] = await tx.insert(users).values(...).returning();
// 2. 프로필 생성 (유저 ID 필요)
await tx.insert(profiles).values({ userId: user.id, ... });
// 3. 환영 이메일 로그
await tx.insert(logs).values(...);
});📝 마무리 퀴즈
Q1. Drizzle에서 returning()을 붙이는 실무적 장점은 무엇인가요?
✅ 정답: INSERT나 UPDATE 직후 DB가 실제로 저장한 값을 다시 받아 후속 로직에 안전하게 사용할 수 있습니다.
💡 상세 해설: 기본값, 자동 생성 id, DB에서 계산된 값은 애플리케이션이 미리 정확히 알 수 없습니다. returning()을 쓰면 생성된 유저 id로 프로필이나 로그를 이어서 만들 때 불필요한 재조회가 줄어듭니다.
Q2. 조회수나 포인트처럼 동시에 많이 수정되는 값을 current + 1 방식으로 애플리케이션에서 계산하면 왜 위험한가요?
✅ 정답: 동시 요청이 같은 값을 읽고 덮어써 업데이트가 사라지는 lost update가 생길 수 있습니다.
💡 상세 해설: 원자적 증가 연산은 DB가 현재 값을 기준으로 한 번에 계산하게 만들어 경쟁 상태를 줄입니다.
Q3. 영철이의 테스트 타임: 회원 가입 중 유저 생성은 성공했는데 프로필 생성에서 실패했습니다. 두 쿼리를 어떻게 묶어야 하나요?
✅ 정답: db.transaction(async (tx) => ...) 안에서 두 작업을 모두 tx로 실행해야 합니다.
💡 상세 해설: 가입은 유저, 프로필, 로그가 함께 성공하거나 함께 실패해야 하는 대표적인 작업입니다. 트랜잭션 안에서 실수로 바깥 db를 쓰면 같은 트랜잭션 흐름에 묶이지 않습니다.
🐣 영철이의 퇴근 일기
오늘은 CRUD가 네 글자 명령어가 아니라 데이터 일관성의 문제라는 걸 배웠다. insert, update, delete 자체보다 더 중요한 건 결과를 어떻게 확인하고, 여러 쿼리가 실패할 때 어디까지 되돌릴지였다.
영수네 커뮤니티 가입 흐름을 생각해보니 유저만 있고 프로필이 없는 상태는 운영팀에게 바로 문의 티켓이 된다. 그래서 트랜잭션은 어려운 고급 기능이 아니라, 사용자가 이상한 반쪽 상태를 만나지 않게 하는 기본 장치였다.
💡 "CRUD의 실무 기준은 쿼리가 성공했는지가 아니라, 실패했을 때 데이터가 설명 가능한 상태로 남는지다."
내일부터는 returning()을 안 쓰고 다시 조회하는 코드, 트랜잭션 안에서 db와 tx가 섞인 코드를 보면 바로 멈춰서 확인해야겠다.