4편. 조회 쿼리 고급 옵션(where)과 관계 데이터 생성(create) 패턴 이해
📚 목차
1. 조회 쿼리 고급 옵션 : where / select / include / pagination
2. 관계 데이터 생성 패턴 : connect / create / connectOrCreate

📂 [GitHub 예시 코드 보러가기] (https://github.com/cericube/nodejs-tutorials) /Prisma
1. 조회 쿼리 고급 옵션 : where / select / include / pagination
🔷 where 조건: 정밀한 필터링
1) 기본 스칼라 필터 (Boolean / Number / Date)
- 예시코드: /ch03/3-1-1.기본스칼라필터.ts
where: {
// published 컬럼이 true(발행됨)인 글만 조회
published: true,
// viewCount(조회수)가 100 "초과"인 글만 조회 (>)
viewCount: { gt: 100 },
// viewCount가 100 "이상"인 글만 조회 (>=)
viewCount: { gte: 100 },
// viewCount가 100 "미만"인 글만 조회 (<)
viewCount: { lt: 100 },
// viewCount가 100 "이하"인 글만 조회 (<=)
viewCount: { lte: 100 },
// createdAt(작성일)이 2025-01-01 00:00:00 "이후(포함)"인 글만 조회 (>=)
createdAt: { gte: new Date('2025-01-01') },
// createdAt이 "현재 시각 이전"인 글만 조회 (<)
// 일반적으로 미래 예약 데이터 제외 등에 사용
createdAt: { lt: new Date() },
}
2) 문자열 필터 (String)
- 예시코드: /ch03/3-1-2.문자열필터.ts
where: {
// title이 정확히 'Prisma' 와 "완전 일치"하는 글만 조회 (=)
title: { equals: 'Prisma' },
// title에 'Prisma' 문자열이 "포함"된 글만 조회 (LIKE %Prisma%)
title: { contains: 'Prisma' },
// contains + mode:'insensitive' => 대소문자 구분 없이 포함 검색
// 예: 'prisma', 'PRISMA' 모두 매칭
title: { contains: 'Prisma', mode: 'insensitive' },
// title이 'Pri' 로 "시작"하는 글만 조회 (LIKE Pri%)
title: { startsWith: 'Pri' },
// title이 'ORM' 으로 "끝"나는 글만 조회 (LIKE %ORM)
title: { endsWith: 'ORM' },
// title이 배열 안 값 중 "하나"와 일치하면 조회 (IN (...))
title: { in: ['Prisma', 'ORM'] },
// title이 배열 안 값들은 "제외"하고 조회 (NOT IN (...))
title: { notIn: ['Draft', 'Temp'] },
// title에 'deprecated'가 포함된 것은 "제외" (NOT LIKE %deprecated%)
title: { not: { contains: 'deprecated', mode: 'insensitive' } },
}
3) NULL 필터 (Nullable 필드)
- 예시코드: /ch03/3-1-3.Null필터.ts
where: {
// content가 NULL인 글만 조회
// (예: 내용이 없는 임시 데이터)
content: null,
// content가 NULL이 "아닌" 글만 조회
// (예: 정상적으로 내용이 있는 데이터만)
content: { not: null },
}
4) 논리 조합 (AND / OR / NOT)
- 예시코드: /ch03/3-1-4.논리조합.ts
where: {
// AND: 아래 조건들을 "모두 만족"해야 조회됨
AND: [
{ published: true }, // 발행된 글
{ viewCount: { gt: 100 } }, // 조회수 100 초과
],
// OR: 아래 조건들 중 "하나라도 만족"하면 조회됨
OR: [
{ title: { contains: 'Prisma', mode: 'insensitive' } }, // title에 Prisma 포함
{ title: { contains: 'ORM', mode: 'insensitive' } }, // 또는 title에 ORM 포함
],
// NOT: 아래 조건을 만족하는 데이터는 "제외"
NOT: [
{ title: { contains: 'deprecated', mode: 'insensitive' } }, // deprecated 포함 글 제외
],
}
5) 관계 필터 (Relation Filtering) : 1:1 / N:1 관계 (author 같은 단일 관계) - is, isNot
- 예시코드: /ch03/3-1-5.관계필터-1.ts
▸ is: "관계가 반드시 존재해야 함" + "그 대상이 조건을 만족해야 함"
▸ isNot: "관계가 존재하지 않거나" OR "관계는 있지만 조건을 만족하지 않음"
▸ is: null: 관계가 맺어지지 않은 상태 (Foreign Key가 null인 경우 포함).
▸ isNot: null: 관계가 반드시 맺어져 있는 상태.
where: {
author: {
// 1) 작성자(author)가 존재하는 글만
// author 관계가 "optional(Nullable)"인 스키마에서만 의미가 있음.
isNot: null,
// 2) author(User)에 대한 추가 조건을 걸고 싶을 때 사용
is: {
// 예: 작성자 role이 ADMIN인 글만
role: 'ADMIN',
// email: { endsWith: '@company.com' }, // 작성자 이메일 도메인 조건도 가능
// 3) author의 profile(1:1) 조건
// NOT( profile이 존재하고 AND (bio가 '01'을 포함) )
// 참고) "profile은 반드시 존재"하면서 "bio에 '01' 미포함"을 원한다면
// profile: { is: { bio: { not: { contains: '01' } } } }
profile: {
// profile이 아예 없는 작성자(profile = null) : 포함
// profile은 있지만 bio가 '01'을 포함하지 않는 작성자: 포함
isNot: {
bio: {
contains: '01',
},
},
},
},
},
}
6) 1:N 관계 (comments 같은 리스트 관계) - some / every / none
- 예시코드: /ch03/3-1-6.관계필터-2.ts, /ch03/3-1-7.관계필터-3.ts
where: {
comments: {
// some: 댓글 중 "하나라도" 조건을 만족하면 해당 Post를 조회
// 예: 댓글에 helpful 포함된 글만
some: {
content: { contains: 'helpful', mode: 'insensitive' },
},
// every: 댓글이 "전부" 조건을 만족해야 조회 (현실적으로는 사용이 까다로운 편)
// every: { content: { contains: 'helpful' } },
// none: 댓글 중 조건을 만족하는 것이 "하나도 없어야" 조회
// none: { content: { contains: 'spam' } },
},
}
7) 중첩 조건 (깊게 타고 들어가기)
where: {
author: {
is: {
profile: {
// profile이 nullable이라면 is / isNot로 존재 여부와 조건을 함께 줄 수 있음
is: {
// author.profile.bio에 'backend'가 포함된 작성자의 글만 조회
bio: { contains: 'backend', mode: 'insensitive' },
},
},
},
},
}
8) 배열 필드 필터 (String[] 등) - 스키마에 배열 필드가 있을 때
where: {
// tags 배열에 'prisma' 값이 "포함"된 글만 조회
tags: { has: 'prisma' },
// tags 배열에 ['prisma', 'node'] 중 "하나라도" 포함되면 조회
tags: { hasSome: ['prisma', 'node'] },
// tags 배열에 ['prisma', 'node']가 "모두" 포함되어야 조회
tags: { hasEvery: ['prisma', 'node'] },
// tags 배열이 비어있는지 여부
// isEmpty: false => 비어있지 않은 글만
tags: { isEmpty: false },
}
🔷 select vs include
| 기준 | select | include |
| 주요 목적 | 응답 데이터의 Shape(구조) 결정 | 관계된 전체 객체 로드 |
| 데이터 양 | 최소화 (필요한 필드만 조회 → 네트워크 비용 저렴) | 많음 (관계 모델의 전체 컬럼 로드) |
| 추천 상황 | 클라이언트 전달용 API, 대량 목록 조회 | 비즈니스 로직 연산, 복잡한 데이터 가공 |
| 관계 처리 | 하위 select를 통해 관계 필드도 선택적 추출 가능 | 관계 테이블의 모든 필드를 기본으로 가져옴 |
조회 쿼리를 설계할 때 가장 중요한 질문은 "이 API가 필요로 하는 최소한의 데이터는 무엇인가?"입니다
1) select : 필드 선택 (권장 기본값)
select는 SQL의 SELECT column1, column2와 정확히 대응됩니다.
Prisma에서 select를 사용한다는 것은 "내가 명시하지 않은 필드는 절대 가져오지 마라"고 선언하는 것과 같습니다.
▸ 보안: User 모델에 비밀번호나 내부 시스템용 필드가 있을 경우, select를 쓰지 않으면 실수로 외부에 노출될 수 있습니다.
▸ 성능: DB에서 애플리케이션 서버로 데이터를 전송할 때, 데이터의 크기가 작을수록 응답 속도가 빨라집니다.
▸ 타입 추론: TypeScript 환경에서 select로 지정한 필드만 타입으로 잡히기 때문에, 존재하지 않는 데이터에 접근하려는 런타임 에러를 방지합니다.
// 예시: 공개용 프로필 카드 데이터 조회
const userCard = await prisma.user.findUnique({
where: { id: 135 },
select: {
id: true,
displayName: true,
// Profile 전체가 아닌 bio만 콕 집어서 가져오기 (관계의 select)
profile: {
select: { bio: true }
}
}
});
//로그 sql 문 예시
SELECT
"study"."users"."id",
"study"."users"."display_name"
FROM "study"."users"
WHERE "study"."users"."id" = 135;
SELECT
"study"."profiles"."id",
"study"."profiles"."bio",
"study"."profiles"."user_id"
FROM "study"."profiles"
WHERE "study"."profiles"."user_id" = 135
Prisma는 기본적으로 User를 먼저 조회하고, 그 결과로 나온 user.id들을 모아 관계 테이블(Profile)을 별도 쿼리로 한 번 더 조회해 애플리케이션 레벨에서 합칩니다.
▸ 장점: 조인 결과가 “행이 늘어나는 문제(카테시안/중복)”를 피하고, 관계가 많거나 복잡해도 결과 Shape를 안정적으로 만들기 쉬움
▸ 단점: 단일 레코드여도 SQL이 2번 나갈 수 있음(지금 케이스)
2) include : 관계 데이터 포함 (모든 필드 + 관계)
include는 객체 지향적인 관점에서 접근합니다.
특정 모델을 조회할 때 그와 연결된 연관 관계(Relation)를 통째로 객체 그래프에 포함하고 싶을 때 사용합니다.
▸ 비즈니스 로직 처리: 게시글을 수정할 때 작성자 정보가 전부 필요하거나, 댓글의 모든 속성을 기반으로 서버 측 연산을 해야 할 때 유용합니다.
▸ 백오피스/어드민: 데이터의 전체 모습을 확인해야 하는 관리자 페이지에서 편리하게 사용됩니다.
async function exam1() {
const userCard = await prisma.user.findUnique({
// - 최상위에 include를 쓰면 User의 스칼라 컬럼을 "필드 제한 없이" 전부 로드하는 경향이 있다.
// (그래서 SQL에 created_at, updated_at, deleted_at, email 등도 함께 선택됨)
where: { id: 135 },
include: {
profile: {
select: { bio: true },
},
},
});
console.log(userCard);
}
// 로그 SQL 문 예시
SELECT
"study"."users"."id",
"study"."users"."created_at",
"study"."users"."updated_at",
"study"."users"."deleted_at",
"study"."users"."email",
"study"."users"."display_name"
FROM "study"."users"
WHERE "study"."users"."id" = 135
SELECT
"study"."profiles"."id",
"study"."profiles"."bio",
"study"."profiles"."user_id"
FROM "study"."profiles"
WHERE "study"."profiles"."user_id" = 135
3) select와 include는 동시에 사용 불가
⚠️ Prisma에서는 select와 include를 동시에 사용할 수 없습니다.
select
▸ “어떤 필드를 반환할 것인가”에 대한 선언
▸ 반환 데이터의 Shape를 필드 단위(field-level)로 정의
▸ API 응답 스키마를 명확히 통제하는 데 초점
include
▸ “어떤 관계를 함께 로드할 것인가”에 대한 선언
▸ 반환 데이터의 Shape를 엔티티 그래프(entity graph) 단위로 정의
▸ 관계형 데이터의 탐색과 로딩에 초점
즉, select와 include는 반환 Shape를 정의하는 서로 다른 두 철학을 기반으로 하며,
이로 인해 Prisma는 두 옵션의 동시 사용을 허용하지 않습니다.
4) 중첩 include의 이해와 Depth 관리
Prisma의 가장 큰 장점 중 하나는 중첩된 관계를 조회할 때 N+1 문제(연관된 데이터를 가져오기 위해 쿼리가 반복 실행되는 현상)를 내부적으로 최적화한다는 점입니다.
📌 N+1 문제와 Prisma의 최적화
전통적인 ORM은 10명의 유저를 조회할 때 각 유저의 게시글을 찾기 위해 쿼리를 10번 더 날리는 N+1 문제를 일으킵니다.
Prisma는 이를 내부적으로 IN 연산자를 사용하여 단 2번의 쿼리(유저 전체 조회 + 해당 유저들의 게시글 통합 조회)로 해결합니다.
(1) 중첩 include의 편리함 (생산성)
Prisma는 여러 테이블에 흩어진 데이터를 단 한 번의 호출로 계층 구조(JSON)로 만들어 반환합니다.
// 3단계 중첩: 유저 -> 게시글 -> 댓글 -> 댓글 작성자
const userFeed = await prisma.user.findUnique({
where: { id: 1 },
include: {
posts: {
include: {
comments: {
include: { author: true }
}
}
}
}
});
▸ 장점: 복잡한 JOIN 쿼리를 직접 작성할 필요가 없으며, 반환된 데이터의 타입이 자동으로 추론되어 개발 생산성이 극대화됩니다.
(2) 중첩 include의 위험성 (성능 및 보안)
▸ 데이터 폭발 (Over-fetching): 위의 예시에서 author: true를 호출하면, 댓글 작성자의 이메일, 가입일, 심지어 비밀번호 해시(모델에 포함된 경우)까지 모두 가져오게 됩니다. 이는 보안 취약점이 될 수 있습니다.
▸ 메모리 압박: DB에서 데이터를 효율적으로 가져오더라도(N+1 최적화), 서버(Node.js)는 이 거대한 JSON 덩어리를 메모리에 생성하고 조립해야 합니다. 중첩이 깊고 데이터가 많을수록 서버의 RAM 사용량이 급증합니다.
▸ 불필요한 부하: 사용자는 화면에서 게시글 제목만 보고 싶어 할 수도 있는데, 보이지도 않는 3~4단계 아래의 댓글 작성자 정보까지 가져오는 것은 명백한 자원 낭비입니다.
(3) 개선 방안: 중첩 include를 중첩 select로 전환
중첩 구조가 반드시 필요하다면, 하위 레벨에서도 select를 사용하여 필요한 필드만 필터링하는 것이 실무 표준입니다.
// 개선된 중첩 구조: 깊은 관계에서도 필요한 데이터만 최소화
const optimizedFeed = await prisma.user.findUnique({
where: { id: 1 },
select: {
displayName: true,
posts: {
take: 5, // 개수 제한
select: {
title: true,
comments: {
where: { deletedAt: null }, // 논리 삭제 필터링
select: {
content: true,
author: { select: { displayName: true } } // 필요한 필드만 콕 집어서!
}
}
}
}
}
});
🔷 페이지네이션(Pagination) : Offset-based Pagination / Cursor-based Pagination
✔️Offset 기반 페이지네이션 vs Cursor 기반 페이지네이션
실시간 데이터가 많이 추가/삭제되는 서비스라면 반드시 Cursor 기반 페이지네이션을 사용하세요.
성능과 데이터 일관성에서 압도적인 이점을 제공합니다.
| 구분 | Offset 기반 | Cursor 기반 |
| 적합한 UI | 전통적인 게시판 (페이지 번호 클릭) | 무한 스크롤, SNS 타임라인 |
| 성능 | 뒤로 갈수록 느려짐 (O(N)) | 항상 빠름 (O(1)) |
| 중복 / 누락 현상 | 발생 가능성 높음 | 거의 발생하지 않음 |
| 전체 개수 파악 | 쉬움 (count 쿼리) | 어려움 (별도 count 필요) |
| 임의 페이지 이동 | 가능 | 불가능 |
| 데이터 일관성 | 낮음 | 높음 |
| Prisma 권장도 | 소규모 데이터 | 대용량 / 실시간 서비스 (강력 권장) |
| 인덱스 요구사항 | 정렬 필드에 인덱스 | 커서 필드에 고유 인덱스 |
▸ 소규모 프로젝트 (< 10,000건): Offset 방식도 충분히 사용 가능
▸ 대규모 프로젝트 (> 100,000건): Cursor 방식 필수
▸ 관리자 도구: Offset 방식이 편리 (페이지 번호 필요)
▸ 사용자 피드: Cursor 방식 권장 (무한 스크롤)
▸ 하이브리드: 초반 N페이지는 Offset, 이후 Cursor 전환
✔️ Offset 기반 페이지네이션 (Offset-based Pagination)
데이터베이스에서 특정 개수만큼의 레코드를 건너뛰고(skip), 필요한 개수만큼 가져오는(take) 방식입니다.
// 3페이지를 조회 (페이지는 1부터 시작한다고 가정)
const page = 3;
// 한 페이지당 10개씩 가져오기
const limit = 10;
const posts = await prisma.post.findMany({
// 앞에서부터 (page-1)*limit 개를 건너뜀
// 3페이지면 (3-1)*10 = 20개 skip → 21번째부터 조회
skip: (page - 1) * limit,
// 이번 페이지에서 가져올 개수
take: limit,
// 최신 글이 먼저 오도록 생성일 내림차순 정렬
orderBy: {
createdAt: 'desc',
},
});
▸ 장점: 특정 페이지로 바로 이동하는(예: 1, 2, 3... 10 버튼) UI 구현이 매우 쉽습니다.
▸ 단점 (성능 저하): 데이터베이스는 skip한 만큼의 데이터를 모두 읽어서 버린 뒤 다음 데이터를 찾습니다.
만약 skip: 1000000이라면 성능이 급격히 떨어집니다.
▸ 단점 (데이터 중복/누락): 사용자가 1페이지를 보는 동안 새로운 글이 올라오면, 2페이지로 넘어갔을 때 1페이지에서 봤던 글이 중복되어 보일 수 있습니다.
✔️ Cursor 기반 페이지네이션 (Cursor-based Pagination)
마지막으로 조회한 데이터의 고유한 값(Cursor)을 기준으로 그 다음 데이터를 가져오는 방식입니다. Prisma에서는 이 방식을 강력히 권장합니다.
커서는 “다음 목록을 어디서부터 이어서 가져올지”를 알려주는 책갈피(bookmark)입니다.
ex) “마지막으로 내가 본 게시글이 id=100이었어” → “그다음 10개 가져와”
즉, 커서는 페이지 번호가 아니라 ‘마지막으로 본 기준점의 고유 값’입니다.
// 이전 페이지(또는 이전 요청)에서 "마지막으로 받은 게시글"의 id
// 다음 페이지는 이 id '이후'의 데이터를 가져오면 됨
const lastId = 100;
const posts = await prisma.post.findMany({
take: 10, // 다음 게시글 10개만 조회
// cursor: 기준점(시작 위치) 지정
// id=100 레코드를 기준으로 페이지를 이어서 조회
cursor: { id: lastId },
// cursor로 지정한 레코드(id=100) 자체도 결과에 포함될 수 있어서
// 그 1개를 제외하고 "그 다음부터" 가져오도록 skip: 1
skip: 1,
// cursor 기반 페이징은 "정렬 기준"이 매우 중요
// 여기서는 id 오름차순으로 정렬해서 id=100 다음(101, 102, ...)을 가져옴
orderBy: { id: 'asc' },
});
▸ 장점 (고성능): 인덱싱 된 커서 값을 기준으로 즉시 데이터 위치를 찾으므로, 데이터가 수억 건이라도 속도가 일정합니다.
▸ 장점 (안정성): 데이터가 실시간으로 추가되거나 삭제되어도 결과가 밀리거나 중복되지 않아 무한 스크롤이나 타임라인에 최적입니다.
▸ 단점: "500페이지로 바로 이동"과 같은 기능 구현이 어렵습니다. (이전 페이지의 마지막 커서 값을 알아야 하기 때문)
✔️ 복합 정렬 커서(Composite Cursor) 이해하기
실무 API에서는 단순히 id 순서가 아닌 createdAt(최신순), viewCount(조회수순) 등 다양한 기준으로 데이터를 정렬합니다.
하지만 정렬 기준 필드에 중복된 값(예: 동일한 작성 시간)이 존재할 경우, 단순 커서로는 데이터 누락이나 중복이 발생합니다.
이를 해결하기 위해 정렬 기준 필드와 고유 식별자(ID)를 결합한 것이 복합 커서입니다.
1) 필수 전제 조건: 스키마 설정
Prisma에서 복합 커서를 사용하려면 데이터베이스 레벨에서 해당 필드 조합이 고유하다는 것을 보장해야 합니다.
schema.prisma에 @@unique 설정을 반드시 추가해야 합니다.
model Post {
id Int @id @default(autoincrement())
createdAt DateTime @default(now())
title String
// 복합 커서 사용을 위한 필수 설정
// Prisma는 이를 통해 'createdAt_id'라는 커서 이름을 생성합니다.
@@unique([createdAt, id])
//
@@index([authorId, createdAt])
@@index([published, createdAt])
@@index([deletedAt])
@@map("posts")
}
2) 예시 코드 (Prisma 7)
const posts = await prisma.post.findMany({
take: 10,
cursor: {
// @@unique로 정의된 필드 조합을 키로 사용
createdAt_id: {
createdAt: new Date('2025-12-24T00:00:00Z'), // 마지막으로 본 데이터의 시간
id: 105, // 마지막으로 본 데이터의 ID
},
},
orderBy: [
{ createdAt: 'desc' }, // 1차 정렬 기준
{ id: 'desc' } // 2차 정렬 기준 (동일 시간 데이터 처리용)
],
skip: 1, // 커서 본인(105번)은 이미 봤으므로 제외하고 다음 데이터부터 조회
});
▸ createdAt: 전체적인 정렬 흐름(최신순)을 잡기 위해 필요합니다.
▸ id: 만약 createdAt이 1초의 오차도 없이 동일한 데이터가 여러 개 있다면, ID를 비교하여 정확히 어느 지점까지 읽었는지 식별하기 위해 필요합니다.
▸ 정렬 일치: cursor에 사용된 필드들과 orderBy에 사용된 필드/순서는 반드시 일치해야 최적의 성능을 냅니다.
▸ Skip 사용: 커서 기반 조회 시 현재 지점을 제외하려면 skip: 1이 거의 항상 동반됩니다.
3) 데이터 흐름 (Workflow)
복합 커서 페이지네이션은 클라이언트와 서버가 커서 데이터를 주고받으며 완성됩니다.
▸ 첫 요청: 클라이언트가 커서 없이 요청 → 서버는 최신 데이터 10개와 마지막 데이터의 커서 정보(createdAt, id)를 응답.
▸ 다음 요청 (무한 스크롤 등): 클라이언트는 받은 커서 정보를 쿼리 스트링에 담아 다시 요청.
▸ 서버 처리: 서버는 전달받은 커서를 prisma.findMany의 cursor 옵션에 넣어 정확히 그 지점 다음부터 데이터를 조회.

2. 관계 데이터 생성 패턴 : connect / create / connectOrCreate
Prisma에서 관계 데이터를 생성하는 것은 단순히 외래 키 값을 채우는 행위가 아니라, "데이터 간의 유효성을 검증하고 관계의 의도를 선언"하는 과정입니다.
관계 생성이란?
관계 데이터 생성은 단순히 "외래 키에 숫자 넣기"가 아닙니다.
"이 데이터가 실제로 존재하는지 확인하고, 안전하게 연결하는 과정"입니다.
✔️ 관계 생성 패턴 선택 가이드
| 상황 | 추천 패턴 | 이유 |
| 기존 데이터 확실 | connect | 안전하고 명확한 에러 처리 |
| 항상 함께 생성 | create | 트랜잭션으로 일관성 보장 |
| 존재 여부 불확실 | connectOrCreate | 존재 여부 자동 처리 |
| 성능이 매우 중요 | authorId:1 | DB 검증만 사용 (주의 필요) |
| N:M 관계 | 명시적 중간 테이블 | 추가 정보 저장 가능 |
🔷 connect - 기존 데이터와의 명시적 연결
실무에서 가장 많이 혼동하는 부분은 외래 키 ID를 직접 넣는 것과 connect를 사용하는 것의 차이입니다.
1) 외래 키 직접 할당 (authorId: 1)
Prisma는 별도의 검증 없이 DB에 INSERT 쿼리를 보냅니다.
제약조건 체크는 데이터베이스(DB)가 담당합니다. FK 제약 조건이 없다면 잘못된 ID도 저장될 위험이 있습니다.
미세하게 빠를 수 있으나, 에러 발생 시 DB 네이티브 에러가 발생하여 처리가 까다로울 수 있습니다.
await prisma.post.create({
data: {
title: '게시글',
authorId: 1 // 숫자만 넣음
}
})
2) 관계 연결(author: { connect: { id: 1 } }) : 권장
Prisma가 해당 레코드의 존재 여부를 확인하며 관계를 맺습니다.
대상이 없으면 Prisma 전용 에러(P2025)를 던져 애플리케이션 레벨에서 명확한 대응이 가능합니다.
데이터 정합성을 Prisma 수준에서 한 번 더 보장하며, '관계' 중심의 코드를 작성하게 해 줍니다.
반드시 @id 또는 @unique 필드로만 연결 가능합니다.
// 로그인한 사용자가 게시글 작성
const userId = session.user.id; // 세션을 통해 확보된 신뢰할 수 있는 사용자 ID
await prisma.post.create({
data: {
title: 'Prisma 완벽 가이드',
content: '관계 생성 패턴을...',
// [관계 생성 패턴: connect]
// 1. authorId: userId를 직접 할당하는 대신 author: { connect: ... }를 사용.
// 2. Prisma 엔진이 DB에 데이터를 넣기 전, 해당 User(ID: 1)가 실제 존재하는지 사전 검증함.
// 3. 만약 사용자가 없다면 DB 에러 대신 Prisma 유효성 에러(P2025)를 던져 안전한 핸들링이 가능함.
author: {
connect: { id: userId }
}
}
});
🔷 create - 관계 레코드 동시 생성
부모와 자식 레코드를 한 번에 생성합니다. Prisma는 이를 내부적으로 단일 트랜잭션으로 처리합니다.
하나라도 실패하면 전체 작업이 롤백되어 데이터 고아 현상(Orphan Record)을 방지합니다.
ex) 회원가입 시 프로필 생성, 게시글 생성 시 초기 설정 저장 등 "반드시 함께 존재해야 하는 데이터"에 적합합니다.
▸ User 생성 성공 → Profile 생성 성공 → 둘 다 저장
▸ User 생성 성공 → Profile 생성 실패 → 전체 롤백
▸ "User만 있고 Profile은 없는" 고아 데이터 방지!
예시) 회원가입 시 프로필 자동 생성(1:1 관계)
await prisma.user.create({
data: {
email: 'newuser@example.com',
displayName: '홍길동',
// 1:1 관계인 Profile 모델을 동시에 생성 (Nested Write)
profile: {
/**
* [create 패턴]
* - 부모(User)를 생성함과 동시에 자식(Profile) 레코드를 생성합니다.
* - Prisma가 내부적으로 단일 트랜잭션을 보장합니다.
* - User 생성이 성공해도 Profile 생성에서 에러가 나면 전체가 롤백됩니다.
* - 별도로 userId를 입력하지 않아도 Prisma가 생성된 User의 ID를 자동으로 연결합니다.
*/
create: {
bio: '안녕하세요!',
},
},
},
});
예시) 사용자 생성시 여러 개의 게시글을 동시에 작성(1:N 관계)
await prisma.user.create({
data: {
email: 'author@prisma.io',
displayName: '작성자A',
// 1:N 관계인 Post 모델들을 동시에 생성
posts: {
/**
* [1:N create 패턴]
* - User 레코드를 생성하면서 동시에 여러 개의 Post를 생성합니다.
* - posts: { create: [...] } 배열 형태로 전달하여 여러 레코드를 한 번에 넣을 수 있습니다.
*/
create: [
{
title: '첫 번째 게시글',
content: '반갑습니다.',
published: true
},
{
title: '두 번째 게시글',
content: 'Prisma 7 공부 중입니다.'
},
],
},
},
});
예시) Post 생성하면서 사용자 생성
await prisma.post.create({
data: {
title: '게시글',
author: {
create: { // ✅ 작성자를 새로 생성
email: 'newauthor@example.com',
displayName: 'New Author'
}
}
}
})
🔷 connectOrCreate - 조건부 생성 전략
대상이 있으면 연결(connect)하고, 없으면 생성(create)합니다.
이 기능은 동시성 환경에서 반드시 unique index를 전제로 합니다.
where 절에 유니크한 조건이 들어가야 하며, 중복 생성을 방지하기 위해 DB의 Unique 인덱스에 의존합니다.
ex) 외부 시스템 연동(소셜 로그인), 태그 시스템 등 데이터 존재 여부를 미리 알기 어려운 경우에 사용합니다.
await prisma.post.create({
data: {
// 1) 생성할 게시글의 제목
title: '연동 게시글',
// 2) 이 게시글의 작성자(author)를 설정하는 부분
author: {
// connectOrCreate:
// - User가 이미 존재하면 connect
// - 존재하지 않으면 create 후 connect
connectOrCreate: {
// 3) "기존 User를 찾기 위한 조건"
// 반드시 @unique 또는 @id 필드로만 찾을 수 있습니다.
// (여기서는 User.email이 @unique이므로 적합)
where: { email: 'guest@external.com' },
// 4) where 조건에 해당하는 User가 없을 때 생성할 데이터
// 이 데이터로 User 레코드를 만들고,
// 생성된 User를 Post.author로 연결합니다.
create: {
email: 'guest@external.com',
displayName: '게스트',
},
},
},
},
})
🔷 N:M 관계 - PostLike (명시적 중간 테이블)
Prisma에서는 N:M 관계를 명시적인 중간 테이블로 모델링할 수 있습니다.
model PostLike {
// Composite key (explicit M:N)
userId Int @map("user_id")
postId Int @map("post_id")
// Common-ish columns
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
post Post @relation(fields: [postId], references: [id], onDelete: Cascade)
@@id([userId, postId])
@@index([postId, createdAt])
@@index([deletedAt])
@@map("post_likes")
}
예제: 게시글 좋아요 추가
await prisma.postLike.create({
data: {
/**
* [관계 기반 connect 패턴]
* * 1. 안전성 (Validation):
* id: 1인 User와 id: 10인 Post가 실제로 DB에 존재하는지 Prisma가 먼저 확인합니다.
* 만약 어느 한쪽이라도 없다면 P2025 에러를 발생시켜 데이터 무결성을 지킵니다.
* * 2. 추상화:
* 단순히 숫자(외래키)를 넣는 것이 아니라 '사용자'와 '게시글'이라는
* 객체 간의 관계를 맺는다는 의도를 명확히 합니다.
* * 3. 확장성:
* 나중에 @unique 설정이 된 다른 필드(예: email)로 연결 대상을
* 변경하더라도 코드 구조를 동일하게 유지할 수 있습니다.
*/
user: {
connect: { id: 1 }
},
post: {
connect: { id: 10 }
},
},
})
예제: 좋아요 취소 (Soft Delete)
await prisma.postLike.update({
// 1. 복합 ID 찾기: @id([userId, postId])로 정의된 복합 키를 참조
where: {
userId_postId: {
userId,
postId
}
},
// 2. Soft Delete: 실제 레코드를 삭제하는 대신 삭제 시간만 기록
data: {
deletedAt: new Date()
}
})
※ 게시된 글 및 이미지 중 일부는 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] 3편. Prisma Client Query 구조 와 CRUD API 이해하기 (0) | 2026.01.14 |
| [Prisma7] 2편. schema.prisma 설계 규칙: 필드,제약,인덱스,Relation (0) | 2026.01.13 |
| [Prisma7] 1편. 개발환경 구축과 프로젝트 초기화 (Node.js + Prisma 7) (0) | 2026.01.12 |