3편. Prisma Client Query 구조 와 CRUD API 이해하기
📚 목차
1. Prisma Client Query 구조 이해
2. CRUD 실전 API : create / read / update / delete / upsert

📂 [GitHub 예시 코드 보러가기] (https://github.com/cericube/nodejs-tutorials) /Prisma
1. Prisma Client Query 구조 이해
🔷 Prisma Client 호출의 핵심 구조
Prisma 7.x에서도 기본적인 호출 형태는 동일하지만, TypeScript와의 결합이 더욱 견고해졌습니다
const result = await prisma.[model].[action]({
[args]
});
▸ model: User, Profile처럼 스키마에 정의된 이름의 camelCase 형태입니다. (prisma.user, prisma.postLike)
▸ action: 데이터베이스에 수행할 연산입니다.
▸ args: 무엇을(where), 어떤 데이터를(data), 어떻게(orderBy, select) 처리할지 결정하는 객체입니다.
예시)
prisma.user.findMany({
where: { deletedAt: null },
})
🔷 action 종류 (CRUD + α)
Prisma Client에서 제공하는 action은 CRUD를 중심으로 구성되어 있지만, 실무 편의를 위한 확장 API도 함께 제공합니다.
참고 : Prisma Client API reference
Create Actions
| Action | 설명 | 반환 타입 |
| create | 단일 생성 | Model |
| createMany | 다건 생성(벌크) | BatchPayload(보통 { count }) |
| createManyAndReturn | 다건 생성(벌크) + 생성 레코드 반환 | Model[] |
Read Actions
| Action | 설명 | 반환 타입 |
| findUnique | unique로 1건 조회 | Model | null |
| findUniqueOrThrow | unique로 1건 조회(없으면 throw) | Model |
| findFirst | 조건 첫 1건 | Model | null |
| findFirstOrThrow | 조건 첫 1건(없으면 throw) | Model |
| findMany | 다건 조회 | Model[] |
Update Actions
| Action | 설명 | 반환 타입 |
| update | unique로 1건 수정 | Model |
| updateMany | 다건 수정(벌크) | BatchPayload(보통 { count }) |
| updateManyAndReturn | 다건 수정(벌크) + 수정 레코드 반환 | Model[] |
Delete Actions
| Action | 설명 | 반환 타입 |
| delete | unique로 1건 삭제 | Model |
| deleteMany | 다건 삭제(벌크) | BatchPayload(보통 { count }) |
Mixed
| Action | 설명 | 반환 타입 |
| upsert | 있으면 update, 없으면 create | Model |
집계 /그룹
| Action | 설명 | 반환 타입 |
| count | 개수(조건/필드 기반) | number(또는 select 형태) |
| aggregate | _count/_avg/_sum/_min/_max 집계 | 선택한 집계 shape |
| groupBy | 그룹 + 집계 | 그룹 결과배열 |
기타
| 분류 | API | 설명 |
| Transaction | $transaction | 배치/인터랙티브 트랜잭션 |
| Raw SQL | $queryRaw, $executeRaw (+ unsafe variants) | Raw SQL 실행 |
🔷 Args Object의 공통 구성 요소
Prisma Client의 args 객체는 다음과 같은 공통 필드들로 구성됩니다.
▸ 코드 예시
const result = await prisma.user.findMany({
// 1. where: 조건 필터링 (게시글이 있는 유저 중 이메일에 'prisma'가 포함된 경우)
where: {
email: { contains: 'prisma' },
posts: { some: { published: true } },
},
// 2. omit: 민감 정보나 불필요한 필드 제외 (Prisma 7/최신 기능)
// select와 함께 쓸 수 없으며, 전체 필드 중 특정 필드만 뺄 때 유용합니다.
omit: {
password: true,
phoneNumber: true,
},
// 3. include: 관계 데이터 로드 (게시글 목록 가져오기)
include: {
posts: {
where: { title: { contains: 'Guide' } },
orderBy: { createdAt: 'desc' },
},
},
// 4. relationLoadStrategy: 관계 로드 전략 설정 (Prisma 5.10+ 및 7 표준)
// 'join'은 DB 레벨에서 JOIN을 수행하고, 'query'는 별도 쿼리로 분할 실행합니다.
relationLoadStrategy: 'join',
// 5. orderBy: 정렬 (가입일 순, 이름 순)
orderBy: [{ createdAt: 'desc' }, { name: 'asc' }],
// 6. take & skip: 페이지네이션 (10개씩 가져오며 첫 5개 건너뜀)
take: 10,
skip: 5,
// 7. distinct: 특정 필드 기준으로 중복 제거
distinct: ['role'],
});
| 요소 | 설명 | 주로쓰는 Actions |
| where | 조건 필터링(필드/관계 필터 포함) | find*, update*, delete*, count, aggregate 등 |
| data | 삽입/수정할 실제 데이터 | create, createMany, update, updateMany, upsert |
| select | 특정 필드만 선택(전송량/성능 최적화) | 대부분의 find*, create, update, upsert |
| include | 관계 로드(Join/후속 쿼리 전략 포함) | 대부분의 find*, create, update, upsert |
| omit | 필드 제외(민감정보/불필요 필드 제거). 전역 설정/쿼리 단위 제외 모두 지원 |
Prisma Client 옵션 및(지원되는) 쿼리 옵션 |
| orderBy | 정렬 기준(단일/복수 정렬) | 주로 findMany |
| take | 결과 개수 제한(페이지 크기) | 주로 findMany |
| skip | 결과 건너뛰기(오프셋/커서 페이징에서 보조적으로 사용) | 주로 findMany |
| cursor | 커서 기반 페이지네이션의 기준점 (특정 레코드 위치를 기준으로 앞/뒤 페이지 조회) |
findMany |
| distinct | 지정 필드 기준 중복 제거(유니크한 조합/필드 단위 결과) | 주로 findMany |
| relationLoadStrategy | 관계 로드를 JOIN 기반 vs 쿼리 분할 같은 전략으로 최적화. (GA가 아닌 Preview 기능) |
관계 포함(include)이 있는 조회 |
| skipDuplicates | createMany에서 중복(유니크 충돌) 레코드 스킵 | createMany |
▸ API 응답 스펙이 엄격하고, 성능(전송량)을 강하게 최적화해야 하면 → select 중심
▸ 화면/리포트에서 관계 데이터까지 한 번에 로드하는 게 우선이면 → include 중심
🔷 undefined vs null
Prisma Client에서 undefined와 null은 완전히 다른 의미를 가집니다.
// undefined: Prisma가 해당 필드를 무시
await prisma.user.update({
where: { id: 1 },
data: {
displayName: undefined, // ❌ 업데이트되지 않음
},
});
// null: DB에 명시적으로 NULL 저장
await prisma.user.update({
where: { id: 1 },
data: {
displayName: null, // ✅ DB에 NULL 저장
},
});
2. CRUD 실전 API : create / read / update / delete / upsert
🔷 Create: 데이터 생성의 5가지 실전 패턴
1) 1:1 관계의 중첩 생성 (Nested Write)
User와 Profile이 1:1 관계일 때, 별도의 ID 조회 없이 한 번의 API 호출로 두 테이블에 데이터를 삽입합니다.
import type { User } from 'generated/client';
/**
* [패턴 1] User ↔ Profile (1:1) 동시 생성
* - 반환 타입: Promise<User> (기본 필드만 포함)
*/
async function runCreate(): Promise<User> {
return await prisma.user.create({
data: {
email: 'cericube1@naver.com',
displayName: 'cericube1',
profile: {
create: { bio: '반갑습니다!' }, // User 생성 시 Profile 자동 생성
},
},
});
}
2) 1:N 관계의 다중 생성 (Nested create)
User 한 명을 만들면서 여러 개의 Post를 함께 생성합니다. 데이터가 적을 때 직관적입니다.
/**
* [패턴 2] User ↔ Post (1:N) 리스트 생성
* - include를 사용하여 생성된 관계 데이터까지 즉시 확인 가능
*/
async function runCreateWithMultiCreate() {
return await prisma.user.create({
data: {
email: 'cericube2@naver.com',
posts: {
create: [
{ title: '제목1', content: '내용1' },
{ title: '제목2', content: '내용2' },
],
},
},
include: { posts: true }, // 반환값에 posts 배열 포함
});
}
3) 성능 최적화형 다중 생성 (Nested createMany)
create 배열 방식보다 성능상 유리한 createMany를 중첩 구조에서 사용합니다. 대량의 초기 데이터를 넣을 때 적합합니다.
/**
* [패턴 3] 성능 최적화 (Bulk Insert 방식)
* - skipDuplicates: Unique 제약 충돌 시 에러 대신 무시하고 진행
*/
async function runCreateWithCreateMany() {
return await prisma.user.create({
data: {
email: 'cericube3@naver.com',
posts: {
createMany: {
data: [
{ title: '공지사항1', content: '내용' },
{ title: '공지사항2', content: '내용' },
],
skipDuplicates: true,
},
},
},
include: { posts: true },
});
}
4) 생성 데이터 즉시 반환 (createManyAndReturn)
Prisma 7의 강력한 기능으로, 여러 건을 동시에 삽입하면서 DB가 생성한 ID나 기본값을 즉시 배열로 돌려받습니다.
/**
* [패턴 4] 대량 생성 후 생성된 객체들 가져오기
* - 이전에는 count만 반환했지만, 이제는 실제 데이터를 배열로 반환
*/
async function runCreateManyAndReturn(userId: number) {
return await prisma.post.createManyAndReturn({
data: [
{ title: '패턴4-1', authorId: userId },
{ title: '패턴4-2', authorId: userId },
],
select: { // 필요한 필드만 선택하여 페이로드 최적화
id: true,
title: true,
createdAt: true
},
});
}
5) 단순 벌크 삽입 (createMany)
관계 데이터 반환이 필요 없고, 오직 "성공 건수"만 중요할 때 가장 빠른 방식입니다.
/**
* [패턴 5] 단순 댓글 대량 생성
* - 반환값: { count: number }
*/
async function runCreateMany(userId: number, postId: number) {
return await prisma.comment.createMany({
data: [
{ content: '댓글1', postId, authorId: userId },
{ content: '댓글2', postId, authorId: userId },
],
});
}
🔷 Read: 조회 API의 전략적 선택
1) findUnique & findUniqueOrThrow: "정확한 타겟팅"
@id 또는 @unique가 선언된 컬럼으로만 조회가 가능합니다.
데이터베이스 수준에서 인덱스를 타기 때문에 가장 빠르고 안전합니다.
/**
* [1] findUnique: "있으면 주고, 없으면 null 줘"
* - 목적: 로그인, 마이페이지 등 선택적 존재 여부 확인
*/
async function runFindUnique(email: string) {
const user = await prisma.user.findUnique({
where: { email }, // email은 @unique 필드
// 필요한 데이터만 가져오는 select (성능 최적화)
select: { id: true, email: true, displayName: true }
});
return user; // 없으면 null
}
/**
* [2] findUniqueOrThrow: "없으면 에러야, 더 진행할 필요 없어"
* - 목적: 상세 페이지 조회 등, 대상이 없으면 404 에러를 던져야 할 때
*/
async function runFindUniqueOrThrow(postId: number) {
// Prisma가 자동으로 'NotFoundError'를 발생시킴
return await prisma.post.findUniqueOrThrow({
where: { id: postId },
include: {
author: { select: { displayName: true } } // 관계 데이터 로드
},
});
}
2) findFirst & findFirstOrThrow: "조건에 맞는 첫 번째"
Unique 필드가 아닌 일반 필드(예: authorId, published)로 검색할 때 사용합니다.
반드시 orderBy와 함께 사용하는 것이 원칙입니다.
/**
* [3] findFirst: "가장 최근의 ~하나만 가져와"
* - 목적: 최신 공지사항, 최근 로그인 이력 등
*/
async function runFindFirst(userId: number) {
return await prisma.post.findFirst({
where: {
authorId: userId,
deletedAt: null, // Soft Delete 필터링
},
orderBy: { createdAt: 'desc' }, // '최신순'이라는 의도를 명확히 함
include: {
_count: { select: { comments: true } } // 댓글 개수 등 집계 포함
}
});
}
3) findMany: "목록과 페이지네이션"
0개 이상의 레코드를 배열로 반환합니다. 실무에서는 대량 조회를 방지하기 위해 take와 skip(또는 Cursor)이 필수입니다.
/**
* [4] findMany: "검색 및 목록 보기"
*/
async function runFindMany(page: number = 1, pageSize: number = 10) {
return await prisma.post.findMany({
where: {
published: true,
deletedAt: null,
// 복합 조건 검색 (예: 제목에 '공지'가 포함됨)
title: { contains: '공지' }
},
take: pageSize,
skip: (page - 1) * pageSize, // 오프셋 기반 페이지네이션
orderBy: { createdAt: 'desc' },
});
}
🔷 Update & Upsert: 수정 API의 안정성과 무결성
Prisma의 수정 API는 "대상의 존재 여부"에 따라 에러를 던질지, 무시할지 결정합니다.
특히 Prisma 7에서는 다중 수정 후 결과값을 바로 받는 기능이 강화되었습니다.
1) update: 단건 수정 및 관계 업데이트
where 조건에 반드시 Unique 필드(ID, Email 등)가 필요하며, 레코드가 없으면 예외를 발생시켜 데이터 무결성을 보장합니다.
/**
* [1] User + Profile(1:1) 동시 업데이트
*/
async function runUpdate(userId: number) {
const updatedUser = await prisma.user.update({
where: { id: userId }, // Unique 필드 필수
data: {
displayName: '새로운 이름',
// Nested Update: 관계된 Profile이 존재해야 성공
profile: {
update: { bio: '프로필 바이오 업데이트' },
},
},
// select를 이용해 필요한 데이터만 반환 (Overfetching 방지)
select: {
id: true,
displayName: true,
profile: { select: { bio: true } },
},
});
return updatedUser;
}
▸ profile.update 실행 시 해당 User에게 연결된 Profile 레코드가 없으면 에러가 발생합니다. 안전하게 처리하려면 아래의 upsert나 별도의 존재 확인 로직이 필요합니다.
2) updateMany: 안전한 일괄 수정
조건에 맞는 모든 레코드를 수정합니다. 대상이 0건이어도 에러 없이 { count: 0 }을 반환하므로 배치 작업에 적합합니다.
/**
* [2] 특정 사용자의 미공개 글 일괄 공개 처리
*/
async function runUpdateMany(userId: number) {
const result = await prisma.post.updateMany({
where: {
authorId: userId,
published: false,
},
data: { published: true },
});
console.log(`수정된 행 수: ${result.count}`);
return result;
}
3) updateManyAndReturn: 수정과 동시에 데이터 반환
기존 updateMany는 개수만 알 수 있었으나, 이제는 수정된 실제 레코드 목록을 즉시 반환받을 수 있습니다.
/**
* [3] 다건 업데이트 후 변경된 목록 바로 확인
* 지원: PostgreSQL, CockroachDB 등 (MySQL 미지원)
*/
async function runUpdateManyAndReturn(userId: number) {
const updatedPosts = await prisma.post.updateManyAndReturn({
where: { authorId: userId },
data: { content: '일괄 업데이트된 내용' },
select: {
id: true,
title: true,
updatedAt: true,
author: { // 관계 데이터 조회도 가능
select: { displayName: true }
}
}
});
return updatedPosts; // Post[] 배열 반환
}
4) upsert: 존재하면 수정, 없으면 생성
"중복 생성 방지"와 "데이터 최신화"를 동시에 달성할 때 사용합니다.
/**
* [4] Upsert (Create or Update)
*/
async function runUpsert(email: string) {
const user = await prisma.user.upsert({
where: { email: email }, // 반드시 Unique 필드
create: {
email: email,
displayName: '신규 가입자',
profile: { create: { bio: '반갑습니다!' } }
},
update: {
displayName: '기존 사용자 갱신',
profile: {
// profile이 없을 경우를 대비해 upsert 중첩 사용 권장
upsert: {
create: { bio: '새로 생성된 바이오' },
update: { bio: '갱신된 바이오' }
}
}
},
include: { profile: true }
});
return user;
}
🔷 Delete: 물리 삭제와 데이터 무결성 관리
Prisma에서 삭제를 수행할 때는 "대상이 반드시 존재하는가?"와 "연관된 데이터(FK)가 있는가?"를 먼저 판단해야 합니다.
1) 단일 레코드 삭제 (delete)
delete는 where 조건에 Unique 필드가 필요하며, 삭제 대상이 없으면 에러를 던집니다.
/**
* 단일 댓글 삭제: 결과 반환 및 에러 핸들링 포함
*/
async function runDelete(commentId: number) {
try {
const deletedComment = await prisma.comment.delete({
where: { id: commentId },
// 삭제와 동시에 삭제된 데이터를 확인하거나 연관 정보를 select 할 수 있음
select: {
id: true,
content: true,
post: { select: { title: true } },
author: { select: { displayName: true } },
},
});
console.log('✅ 삭제 성공:', deletedComment);
} catch (error) {
// P2025: "An operation failed because it depends on one or more records that were required but not found."
if (error.code === 'P2025') {
console.error('❌ 삭제 실패: 해당 ID의 댓글을 찾을 수 없습니다.');
} else {
throw error;
}
}
}
2) 다중 레코드 삭제 (deleteMany)
조건에 맞는 모든 데이터를 삭제하며, 대상이 없어도 에러가 발생하지 않고 { count: 0 }을 반환합니다.
/**
* 특정 사용자의 모든 댓글 일괄 삭제
*/
async function runDeleteMany(userId: number) {
const result = await prisma.comment.deleteMany({
where: { authorId: userId },
});
// BatchPayload { count: number } 반환
console.log(`🧹 삭제 완료: 총 ${result.count}개의 댓글이 삭제되었습니다.`);
}
※ 게시된 글 및 이미지 중 일부는 AI 도구의 도움을 받아 생성되거나 다듬어졌습니다.
'4.Node.js > Prisma(ORM)' 카테고리의 다른 글
| [Prisma7] 6편. Prisma로 해결되지 않는 쿼리 다루기: Raw SQL 실전 활용 (0) | 2026.01.20 |
|---|---|
| [Prisma7] 5편. Prisma 관계 조회 심화: include / select와 중첩 관계 탐색 (0) | 2026.01.19 |
| [Prisma7] 4편. 조회 쿼리 고급 옵션(where)과 관계 데이터 생성(create) 패턴 이해 (0) | 2026.01.16 |
| [Prisma7] 2편. schema.prisma 설계 규칙: 필드,제약,인덱스,Relation (0) | 2026.01.13 |
| [Prisma7] 1편. 개발환경 구축과 프로젝트 초기화 (Node.js + Prisma 7) (0) | 2026.01.12 |