Prisma ORM 가이드
혹시 이런 경험 있으신가요? 데이터베이스에서 사용자 정보를 가져오는 간단한 코드를 작성했는데, 런타임에 email이 아니라 e-mail이라는 오타 때문에 서버가 터진다든지… 아니면 SQL 쿼리에서 JOIN을 잘못 써서 한 시간 동안 디버깅한다든지…
저도 처음 Node.js로 백엔드를 만들 때 이런 일이 많았습니다. TypeScript를 쓰면서 타입 안전성의 혜택을 누리고 있었는데, 데이터베이스 코드만 작성하면 그 모든 보호막이 사라지는 기분이었죠. “왜 데이터베이스 쿼리는 타입 체크가 안 될까?”라고 답답해하던 찰나에 Prisma를 발견했습니다.
Prisma는 제가 겪던 고민을 정확히 이해하고 해결해주는 도구였습니다. 이제는 데이터베이스 작업이 다른 TypeScript 코드를 작성하는 것만큼 안전하고 편안해졌습니다.
왜 Prisma를 이해해야 할까요?
1. 런타임 에러에서 컴파일 타임 에러로
전통적인 데이터베이스 작업의 가장 큰 문제는 에러를 너무 늦게 발견한다는 것입니다.
// ❌ 전통적인 방식 - 런타임에 터짐
const user = await db.query('SELECT * FROM users WHERE emial = ?', [email]);
// ^^^^^ 오타!
// 서버를 실행하고 이 코드가 실행될 때까지 에러를 모름
이런 코드는 개발할 때는 아무 문제 없이 보입니다. 컴파일도 잘 되고, 에디터에서도 경고가 없습니다. 하지만 실제로 실행하면 column 'emial' does not exist 에러가 발생하죠. 더 끔찍한 건, 이 코드가 자주 실행되지 않는 기능이라면 프로덕션에 배포된 후에야 사용자가 발견할 수도 있다는 것입니다.
// ✅ Prisma - 타이핑하는 순간 에러 발견
const user = await prisma.user.findUnique({
where: { emial: email }
// ^^^^^ 빨간 줄! 'emial' 속성이 없다고 바로 알려줌
});
Prisma를 사용하면 코드를 작성하는 순간 에디터가 에러를 알려줍니다. 서버를 실행할 필요도 없고, 테스트를 돌릴 필요도 없습니다. 이것이 Prisma가 제공하는 가장 큰 가치입니다.
2. “어떻게”가 아니라 “무엇을” 원하는지만 말하세요
SQL을 직접 작성하다 보면, 원하는 데이터를 얻기 위해 복잡한 JOIN과 서브쿼리를 고민해야 합니다.
// ❌ "어떻게 가져올지"를 일일이 명령해야 함
const result = await db.query(`
SELECT
u.id, u.name, u.email,
p.id as post_id, p.title, p.content,
c.id as comment_id, c.content as comment_content,
ca.name as comment_author
FROM users u
LEFT JOIN posts p ON u.id = p.author_id
LEFT JOIN comments c ON p.id = c.post_id
LEFT JOIN users ca ON c.author_id = ca.id
WHERE u.email = ?
`);
// 그리고 결과를 객체로 변환하는 로직도 필요...
const user = {
id: result[0].id,
name: result[0].name,
email: result[0].email,
posts: [] // 어떻게 그룹핑하지?
};
이 코드의 문제는:
- SQL 문법을 정확히 알아야 함
JOIN순서와 타입을 고민해야 함- 결과를 객체로 변환하는 복잡한 로직 필요
- 각 테이블의 정확한 컬럼명을 기억해야 함
// ✅ "무엇을 원하는지"만 선언
const user = await prisma.user.findUnique({
where: { email },
include: {
posts: {
include: {
comments: {
include: {
author: true
}
}
}
}
}
});
// user.posts[0].comments[0].author.name
// ↑ 바로 사용 가능! 타입도 완벽!
Prisma는 “사용자와 그의 게시글, 그리고 각 게시글의 댓글과 댓글 작성자 정보를 가져와줘”라고 선언만 하면, 알아서 최적화된 쿼리를 생성하고 결과를 타입 안전한 객체로 변환해줍니다.
3. 데이터베이스 변경이 무섭지 않아집니다
프로젝트 중간에 데이터베이스 스키마를 변경해야 하는 상황을 상상해보세요. 예를 들어 User 테이블에 age 컬럼을 추가한다고 해봅시다.
// ❌ 전통적인 방식
// 1. SQL 마이그레이션 작성
// ALTER TABLE users ADD COLUMN age INTEGER;
// 2. 코드 전체를 검색해서 User 관련 쿼리 찾기
// 3. 타입 정의 수동으로 업데이트
interface User {
id: number;
email: string;
name: string;
age: number; // 이걸 일일이 추가
}
// 4. 모든 쿼리에서 age를 포함시켰는지 확인
// 5. 놓친 부분은 런타임에 발견... 😱
Prisma를 사용하면:
// ✅ Prisma 방식
// 1. schema.prisma 파일만 수정
model User {
id Int @id @default(autoincrement())
email String @unique
name String
age Int // 이것만 추가!
}
# 2. 마이그레이션 생성 및 적용
npx prisma migrate dev --name add_user_age
# 3. Prisma Client 자동 재생성
# → 모든 타입이 자동으로 업데이트됨!
// 4. 코드에서 자동완성과 타입 체크 제공
const user = await prisma.user.create({
data: {
email: 'john@example.com',
name: 'John',
age: // ← age를 안 넣으면 타입 에러!
}
});
// user.age ← 자동완성으로 바로 사용 가능!
스키마를 하나 수정하면, 타입부터 마이그레이션까지 모든 것이 자동으로 동기화됩니다. 이것이 Prisma가 제공하는 진정한 생산성입니다.
Prisma란?
Prisma는 Node.js와 TypeScript를 위한 차세대 ORM(Object-Relational Mapping)입니다. 하지만 단순한 ORM이 아니라, 데이터베이스 작업의 전체 워크플로우를 개선하는 완전한 도구 세트입니다.
ORM이란?
ORM(Object-Relational Mapping)은 객체 지향 프로그래밍(Object-Oriented Programming)에서 관계형 데이터베이스 관리 시스템(Relational Database Management System)을 사용하여 데이터를 관리하는 방법입니다. 쉽게 말하면, 데이터베이스의 테이블을 프로그래밍 언어의 객체처럼 다룰 수 있게 해주는 기술입니다.
Prisma의 작동 원리 (간단한 다이어그램)
┌─────────────────────────────────────────────────────────────┐
│ 개발 과정 │
└─────────────────────────────────────────────────────────────┘
1. 스키마 정의 (schema.prisma)
↓
model User {
id Int @id @default(autoincrement())
email String @unique
name String
}
2. Prisma CLI 명령어
↓
npx prisma migrate dev
npx prisma generate
3. 자동 생성된 결과물
↓
┌──────────────────┐ ┌──────────────────┐
│ 데이터베이스 │ │ Prisma Client │
│ │ │ │
│ CREATE TABLE │ │ - 타입 정의 │
│ users (...) │ │ - CRUD 메서드 │
│ │ │ - 자동완성 │
└──────────────────┘ └──────────────────┘
4. TypeScript 코드에서 사용
↓
const user = await prisma.user.findUnique({
where: { email: 'john@example.com' }
});
// ↑ 100% 타입 안전, 자동완성 지원
구성요소
Prisma는 4가지 핵심 도구로 구성됩니다.
1. Prisma Schema
데이터베이스 스키마를 직관적인 언어로 정의합니다.
// prisma/schema.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
name String
posts Post[]
createdAt DateTime @default(now())
}
model Post {
id Int @id @default(autoincrement())
title String
content String
author User @relation(fields: [authorId], references: [id])
authorId Int
}
특징:
- SQL보다 읽기 쉽고 직관적
- 관계를 코드로 명확히 표현
- 한 곳에서 전체 데이터베이스 구조 파악 가능
2. Prisma Client
스키마를 기반으로 자동 생성되는 타입 안전한 데이터베이스 클라이언트입니다.
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// 자동완성과 타입 체크를 제공하는 메서드들
await prisma.user.findMany();
await prisma.user.create({ data: {...} });
await prisma.user.update({ where: {...}, data: {...} });
await prisma.user.delete({ where: {...} });
3. Prisma Migrate
데이터베이스 스키마 변경을 안전하게 관리하는 마이그레이션 도구입니다.
# 마이그레이션 생성 및 적용
npx prisma migrate dev --name add_user_profile
# 프로덕션에 적용
npx prisma migrate deploy
# 마이그레이션 기록 확인
npx prisma migrate status
마이그레이션 작동 방식:
스키마 수정 → 마이그레이션 생성 → SQL 파일 자동 생성 → DB 적용
4. Prisma Studio
데이터베이스를 시각적으로 탐색하고 편집할 수 있는 GUI 도구입니다.
# Prisma Studio 실행
npx prisma studio
브라우저에서 http://localhost:5555로 접속하면:
- 테이블 데이터 조회/편집
- 관계 데이터 시각적으로 탐색
- 실시간으로 데이터베이스 변경사항 확인
- SQL 없이 데이터 CRUD 작업
또는 Prisma VSCode Extension을 사용하여 에디터 안에서 Prisma Studio를 실행할 수 있습니다.
전통적인 방식 vs Prisma 비교
// ❌ 전통적인 SQL 쿼리 - 타입 안전성 없음
const users = await db.query(`
SELECT u.*, p.title
FROM users u
LEFT JOIN posts p ON u.id = p.authorId
WHERE u.email = ?
`, [email]);
// users의 타입은? any[]? 알 수 없음
// 오타가 있어도 런타임에 발견
// ✅ Prisma Client - 타입 안전하고 직관적
const user = await prisma.user.findUnique({
where: { email },
include: { posts: true }
});
// user의 타입이 정확히 추론됨
// 오타는 코딩 중에 발견
// user.posts[0].title ← 자동완성 완벽
핵심 개념
1. 선언적 vs 명령적 접근법
Prisma의 가장 큰 철학적 차이는 “무엇을 원하는지”만 정의하면, “어떻게 할지”는 Prisma가 알아서 처리한다는 것입니다. 이게 왜 중요한지 실제 예제로 살펴보겠습니다.
명령적(Imperative) 방식 - 단계별 명령
기존 방식에서는 모든 단계를 직접 명령해야 합니다.
// ❌ "어떻게 할지"를 단계별로 명령 - 복잡하고 실수하기 쉬움
// 1단계: 테이블 생성
await db.query(`CREATE TABLE users (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL
)`);
await db.query(`CREATE TABLE posts (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT,
author_id INTEGER NOT NULL
)`);
// 2단계: 인덱스 수동 생성
await db.query(`CREATE INDEX idx_user_email ON users(email)`);
await db.query(`CREATE INDEX idx_post_author ON posts(author_id)`);
// 3단계: 외래키 제약조건 추가
await db.query(`
ALTER TABLE posts
ADD CONSTRAINT fk_author
FOREIGN KEY (author_id)
REFERENCES users(id)
ON DELETE CASCADE
`);
// 4단계: 데이터 조회 - 복잡한 JOIN 직접 작성
const result = await db.query(`
SELECT u.*, p.title
FROM users u
LEFT JOIN posts p ON u.id = p.author_id
WHERE u.email = ?
`, [email]);
// 5단계: 결과를 객체로 변환하는 로직도 필요
// ...
문제점:
- SQL 문법을 정확히 알아야 함
- 테이블 이름, 컬럼 이름 오타가 런타임에 발견됨
- 인덱스를 깜빡하면 성능 문제
- 외래키 설정을 잊으면 데이터 무결성 문제
- JOIN 로직이 복잡하고 실수하기 쉬움
선언적(Declarative) 방식 - 결과만 정의
Prisma에서는 원하는 최종 상태만 선언하면 됩니다.
// ✅ "무엇을 원하는지"만 선언 - 간단하고 명확
model User {
id Int @id @default(autoincrement())
email String @unique // ← @unique만 써도 인덱스 자동 생성
name String
posts Post[] // ← 관계만 선언하면 됨
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
author User @relation(fields: [authorId], references: [id])
authorId Int // ← 외래키 제약조건 자동 생성
}
// 복잡한 JOIN도 간단하게
const user = await prisma.user.findUnique({
where: { email },
include: { posts: true } // ← Prisma가 자동으로 최적화된 JOIN 처리
});
// 결과는 완벽하게 타입이 지정된 객체
// user.posts[0].title ← 자동완성 지원
Prisma가 자동으로 해주는 것:
선언적 스키마 정의
↓
┌───────────────────────────┐
│ Prisma가 자동으로 생성 │
├───────────────────────────┤
│ • SQL 테이블 생성문 │
│ • 인덱스 생성 │
│ • 외래키 제약조건 │
│ • 최적화된 쿼리 │
│ • TypeScript 타입 정의 │
│ • CRUD 메서드 │
└───────────────────────────┘
선언적 접근법의 장점:
- 추상화: 복잡한 SQL 작성 불필요 - “무엇을” 원하는지만 말하면 됨
- 자동화: 인덱스, 제약조건 자동 생성 - 잊어버릴 일이 없음
- 일관성: 항상 같은 방식으로 동작 - 개발자마다 다른 쿼리 작성 방지
- 안전성: 타입 체크와 관계 검증 자동화 - 런타임 에러 방지
2. 관계형 데이터 모델링
Prisma에서 테이블 간의 관계를 정의하는 방법을 살펴보겠습니다. 관계형 데이터베이스의 핵심은 “관계”인데, Prisma는 이를 매우 직관적으로 표현합니다.
일대다 관계 (1:N)
한 사용자가 여러 게시글을 작성할 수 있는 관계입니다.
model User {
id Int @id @default(autoincrement())
email String @unique
name String
posts Post[] // ← 배열로 표현 = "여러 개"
}
model Post {
id Int @id @default(autoincrement())
title String
content String
// 관계 정의: Post는 하나의 User에 속함
author User @relation(fields: [authorId], references: [id])
authorId Int // ← 실제 외래키 필드
}
시각적 표현:
User (1) ←───────────── (N) Post
↓ ↓
하나의 사용자 → 여러 게시글
홍길동 → 게시글1, 게시글2, 게시글3
실제 생성되는 SQL:
CREATE TABLE "User" (
id SERIAL PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL
);
CREATE TABLE "Post" (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
"authorId" INTEGER NOT NULL,
FOREIGN KEY ("authorId") REFERENCES "User"(id)
);
사용 예시:
// 사용자와 그의 모든 게시글 조회
const userWithPosts = await prisma.user.findUnique({
where: { id: 1 },
include: { posts: true }
});
console.log(userWithPosts.posts.length); // 게시글 개수
console.log(userWithPosts.posts[0].title); // 첫 번째 게시글 제목
// 게시글과 작성자 정보 조회
const postWithAuthor = await prisma.post.findUnique({
where: { id: 1 },
include: { author: true }
});
console.log(postWithAuthor.author.name); // 작성자 이름
일대일 관계 (1:1)
한 사용자가 하나의 프로필을 가지는 관계입니다.
model User {
id Int @id @default(autoincrement())
email String @unique
profile Profile? // ← ?는 선택적 관계 (nullable)
}
model Profile {
id Int @id @default(autoincrement())
bio String?
avatar String?
// 일대일 관계: Profile은 하나의 User에만 속함
user User @relation(fields: [userId], references: [id])
userId Int @unique // ← @unique가 중요! 일대일 보장
}
시각적 표현:
User (1) ←───────────── (1) Profile
↓ ↓
하나의 사용자 → 하나의 프로필
홍길동 → 프로필: "개발자입니다"
@unique의 중요성:
userId가 @unique가 아니면 → 일대다 관계
userId가 @unique면 → 일대일 관계
예시:
User 1 → Profile (userId: 1, @unique) ✅
User 1 → Profile (userId: 1) 또 생성? ❌ 에러!
사용 예시:
// 사용자 생성과 동시에 프로필 생성
const user = await prisma.user.create({
data: {
email: "john@example.com",
profile: {
create: {
bio: "개발자입니다",
avatar: "avatar.jpg"
}
}
},
include: { profile: true }
});
// 프로필 수정
await prisma.profile.update({
where: { userId: 1 },
data: {
bio: "시니어 개발자입니다"
}
});
다대다 관계 (N:M)
게시글이 여러 카테고리에 속하고, 카테고리도 여러 게시글을 가질 수 있는 관계입니다.
model Post {
id Int @id @default(autoincrement())
title String
categories Category[] // ← 여러 카테고리
}
model Category {
id Int @id @default(autoincrement())
name String @unique
posts Post[] // ← 여러 게시글
}
시각적 표현:
Post (N) ←───────────── (M) Category
↓ ↓
여러 게시글 ↔ 여러 카테고리
예시:
"Prisma 가이드" → "프로그래밍", "데이터베이스"
"React 튜토리얼" → "프로그래밍", "프론트엔드"
↑ 같은 카테고리에 여러 게시글
실제 생성되는 SQL (중간 테이블 자동 생성):
CREATE TABLE "Post" (
id SERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL
);
CREATE TABLE "Category" (
id SERIAL PRIMARY KEY,
name VARCHAR(255) UNIQUE NOT NULL
);
-- Prisma가 자동으로 생성하는 중간 테이블 (Junction Table)
CREATE TABLE "_CategoryToPost" (
"A" INTEGER NOT NULL REFERENCES "Category"(id),
"B" INTEGER NOT NULL REFERENCES "Post"(id)
);
-- 중간 테이블의 역할:
-- Post 1 - Category 1
-- Post 1 - Category 2
-- Post 2 - Category 1
-- 이렇게 다대다 관계를 저장
사용 예시:
// 게시글 생성과 동시에 카테고리 연결
const post = await prisma.post.create({
data: {
title: "Prisma 가이드",
categories: {
connect: [
{ id: 1 }, // 기존 카테고리 연결
{ id: 2 }
],
create: [
{ name: "새 카테고리" } // 새 카테고리 생성 후 연결
]
}
},
include: { categories: true }
});
// 특정 카테고리의 모든 게시글
const category = await prisma.category.findUnique({
where: { id: 1 },
include: { posts: true }
});
console.log(`${category.name}: ${category.posts.length}개의 게시글`);
명시적 다대다 관계 (중간 테이블 커스터마이징)
기본 다대다 관계는 중간 테이블을 Prisma가 자동으로 만들지만, 중간 테이블에 추가 필드가 필요한 경우 명시적으로 정의할 수 있습니다.
model User {
id Int @id @default(autoincrement())
email String @unique
postLikes PostLike[] // ← 중간 테이블을 통한 관계
}
model Post {
id Int @id @default(autoincrement())
title String
userLikes PostLike[] // ← 중간 테이블을 통한 관계
}
// 중간 테이블을 명시적으로 정의 - 추가 필드 가능!
model PostLike {
user User @relation(fields: [userId], references: [id])
userId Int
post Post @relation(fields: [postId], references: [id])
postId Int
likedAt DateTime @default(now()) // ← 추가 필드: 좋아요 누른 시간
@@id([userId, postId]) // ← 복합 기본키: 한 사용자는 한 게시글에 한 번만 좋아요
}
시각적 표현:
User ←─── PostLike ───→ Post
↓
likedAt (추가 정보)
예시:
홍길동 → [PostLike: 2024-01-15 10:30] → "Prisma 가이드"
홍길동 → [PostLike: 2024-01-15 14:20] → "React 튜토리얼"
사용 예시:
// 좋아요 추가 (시간 정보 자동 저장)
const like = await prisma.postLike.create({
data: {
userId: 1,
postId: 5
}
});
// 사용자가 좋아요한 게시글과 시간 조회
const userLikes = await prisma.user.findUnique({
where: { id: 1 },
include: {
postLikes: {
include: { post: true },
orderBy: { likedAt: 'desc' } // ← 최근 순으로 정렬
}
}
});
userLikes.postLikes.forEach(like => {
console.log(`${like.post.title} - ${like.likedAt}`);
// "React 튜토리얼 - 2024-01-15T14:20:00Z"
// "Prisma 가이드 - 2024-01-15T10:30:00Z"
});
자기 참조 관계 (Self-Relation)
댓글의 대댓글처럼 같은 테이블 내에서의 관계를 표현할 수 있습니다.
model Comment {
id Int @id @default(autoincrement())
content String
// 부모 댓글 (선택적)
parent Comment? @relation("CommentReplies", fields: [parentId], references: [id])
parentId Int?
// 자식 댓글들
replies Comment[] @relation("CommentReplies")
}
시각적 표현:
Comment
↓
"좋은 글이네요" (id: 1)
├─ "감사합니다!" (id: 2, parentId: 1)
│ └─ "저도요!" (id: 3, parentId: 2)
└─ "동의합니다" (id: 4, parentId: 1)
사용 예시:
// 댓글과 모든 대댓글 조회
const commentWithReplies = await prisma.comment.findUnique({
where: { id: 1 },
include: {
replies: {
include: {
replies: true // ← 대댓글의 대댓글까지
}
}
}
});
// 대댓글 작성
await prisma.comment.create({
data: {
content: "동의합니다!",
parentId: 1 // ← 부모 댓글 ID
}
});
설치 및 설정
실제로 Prisma를 프로젝트에 도입하는 과정을 단계별로 살펴보겠습니다.
1. 프로젝트 초기화
# 새 프로젝트 생성 (이미 프로젝트가 있다면 생략)
mkdir my-prisma-project
cd my-prisma-project
npm init -y
# TypeScript 설정 (선택사항이지만 강력 추천)
npm install -D typescript ts-node @types/node
npx tsc --init
# Prisma 설치
npm install prisma @prisma/client
# Prisma 초기화
npx prisma init
npx prisma init 명령어가 생성하는 파일:
프로젝트 루트/
├── prisma/
│ └── schema.prisma ← 스키마 정의 파일
└── .env ← 데이터베이스 연결 정보
2. 환경 설정
.env 파일에서 데이터베이스 연결 정보를 설정합니다.
# PostgreSQL
DATABASE_URL="postgresql://username:password@localhost:5432/mydb?schema=public"
# MySQL
DATABASE_URL="mysql://username:password@localhost:3306/mydb"
# SQLite (로컬 개발용)
DATABASE_URL="file:./dev.db"
# SQL Server
DATABASE_URL="sqlserver://localhost:1433;database=mydb;user=username;password=password"
주의사항:
.env파일은 절대 Git에 커밋하지 마세요!.gitignore에.env추가 필수
3. 스키마 파일 구조
prisma/schema.prisma 파일의 기본 구조:
// prisma/schema.prisma
// 1. 클라이언트 생성기 설정
generator client {
provider = "prisma-client-js"
}
// 2. 데이터베이스 연결 설정
datasource db {
provider = "postgresql" // "mysql", "sqlite", "sqlserver", "mongodb"
url = env("DATABASE_URL")
}
// 3. 데이터 모델 정의 (여기서부터 여러분의 스키마 작성)
model User {
id Int @id @default(autoincrement())
email String @unique
name String
}
4. 첫 마이그레이션 실행
# 마이그레이션 생성 및 데이터베이스 적용
npx prisma migrate dev --name init
# 이 명령어가 하는 일:
# 1. schema.prisma 읽기
# 2. SQL 마이그레이션 파일 생성 (prisma/migrations/ 폴더)
# 3. 데이터베이스에 테이블 생성
# 4. Prisma Client 자동 생성
5. Prisma Client 사용하기
// src/index.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
// 사용자 생성
const user = await prisma.user.create({
data: {
email: 'john@example.com',
name: 'John Doe'
}
});
console.log('Created user:', user);
}
main()
.catch(console.error)
.finally(() => prisma.$disconnect());
# 실행
npx ts-node src/index.ts
스키마 정의
Prisma 스키마는 데이터베이스 구조를 정의하는 핵심 파일입니다. 자세히 살펴보겠습니다.
1. 기본 데이터 타입
Prisma가 지원하는 주요 데이터 타입들입니다.
model User {
// 숫자 타입
id Int @id @default(autoincrement())
age Int
balance Float
price Decimal // 정확한 소수점 계산 (금융 데이터)
// 문자열 타입
email String @unique
name String? // ? = 선택적 필드 (NULL 가능)
bio String @db.Text // 긴 텍스트용
// 불리언 타입
isActive Boolean @default(true)
// 날짜/시간 타입
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt // 자동으로 업데이트됨
birthDate DateTime
// JSON 타입 (유연한 데이터 구조)
metadata Json?
// 열거형 (Enum)
role UserRole @default(USER)
}
enum UserRole {
USER
ADMIN
MODERATOR
}
각 타입의 실제 사용 예:
// 사용자 생성 with 다양한 타입
const user = await prisma.user.create({
data: {
email: 'john@example.com',
name: 'John Doe',
age: 30,
balance: 1000.50,
price: new Prisma.Decimal('99.99'), // 정확한 소수점
isActive: true,
birthDate: new Date('1994-01-15'),
metadata: {
preferences: { theme: 'dark' },
lastLogin: '2024-01-15'
},
role: 'ADMIN'
}
});
2. 필드 속성 (Field Attributes)
필드에 부가적인 제약조건과 설정을 추가할 수 있습니다.
model Product {
id Int @id @default(autoincrement())
// @unique: 중복 불가
sku String @unique @db.VarChar(50)
// @db.*: 데이터베이스 레벨 타입 명시
name String @db.VarChar(255)
price Decimal @db.Decimal(10, 2) // 총 10자리, 소수점 2자리
description String @db.Text
// @default: 기본값
stock Int @default(0)
isAvailable Boolean @default(true)
createdAt DateTime @default(now())
// @updatedAt: 자동 업데이트
updatedAt DateTime @updatedAt
// 복합 속성
@@index([name]) // 단일 인덱스
@@index([price, createdAt]) // 복합 인덱스
@@map("products") // 테이블명 매핑 (기본은 "Product")
}
인덱스가 왜 중요한가?
// ❌ 인덱스 없을 때
// WHERE name = 'iPhone' → 전체 테이블 스캔 (느림)
await prisma.product.findMany({
where: { name: { contains: 'iPhone' } }
});
// 100만 개 데이터 → 5초 걸림 😱
// ✅ 인덱스 있을 때
// @@index([name]) 덕분에 빠른 검색
await prisma.product.findMany({
where: { name: { contains: 'iPhone' } }
});
// 100만 개 데이터 → 0.1초 😊
3. 복합 관계 예제
실제 애플리케이션의 복잡한 관계를 표현한 예제입니다.
// 사용자
model User {
id Int @id @default(autoincrement())
email String @unique
name String
profile Profile? // 일대일: 프로필
posts Post[] // 일대다. 게시글들
comments Comment[] // 일대다. 댓글들
likes Like[] // 일대다. 좋아요들
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// 프로필 (일대일)
model Profile {
id Int @id @default(autoincrement())
bio String?
avatar String?
user User @relation(fields: [userId], references: [id])
userId Int @unique // ← @unique가 일대일 관계를 보장
}
// 게시글
model Post {
id Int @id @default(autoincrement())
title String
content String @db.Text
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id])
authorId Int
comments Comment[] // 일대다. 댓글들
likes Like[] // 일대다. 좋아요들
tags Tag[] // 다대다. 태그들
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([authorId])
@@index([published, createdAt])
}
// 댓글
model Comment {
id Int @id @default(autoincrement())
content String
post Post @relation(fields: [postId], references: [id])
postId Int
author User @relation(fields: [authorId], references: [id])
authorId Int
createdAt DateTime @default(now())
@@index([postId])
@@index([authorId])
}
// 좋아요 (복합 기본키 예제)
model Like {
user User @relation(fields: [userId], references: [id])
userId Int
post Post @relation(fields: [postId], references: [id])
postId Int
createdAt DateTime @default(now())
@@id([userId, postId]) // 복합 기본키: 한 사용자는 한 게시글에 한 번만 좋아요
}
// 태그 (다대다 관계)
model Tag {
id Int @id @default(autoincrement())
name String @unique
posts Post[] // 다대다. 여러 게시글
}
이 스키마의 실전 활용:
// 복잡한 쿼리 예제
const user = await prisma.user.findUnique({
where: { email: 'john@example.com' },
include: {
profile: true,
posts: {
where: { published: true },
include: {
comments: {
take: 3, // 최근 3개만
orderBy: { createdAt: 'desc' }
},
likes: true,
tags: true
}
}
}
});
// 결과 구조:
// user = {
// id: 1,
// email: 'john@example.com',
// profile: { bio: '...', avatar: '...' },
// posts: [
// {
// title: '...',
// comments: [...],
// likes: [...],
// tags: [...]
// }
// ]
// }
데이터베이스 마이그레이션
마이그레이션은 스키마 변경을 안전하게 데이터베이스에 적용하는 과정입니다. 저도 처음에는 “그냥 스키마 수정하면 자동으로 반영되는 거 아닌가?”라고 생각했는데, 실제 프로덕션 환경에서는 훨씬 신중해야 합니다.
1. 마이그레이션 생성 및 적용
개발 환경에서의 마이그레이션
# 스키마 변경 후 마이그레이션 생성
npx prisma migrate dev --name add_user_age
# 이 명령어가 하는 일:
# 1. 스키마 변경사항 감지
# 2. SQL 마이그레이션 파일 생성 (prisma/migrations/ 폴더)
# 3. 데이터베이스에 변경사항 적용
# 4. Prisma Client 자동 재생성
생성된 마이그레이션 파일 예시:
prisma/migrations/
└── 20240115_add_user_age/
└── migration.sql
-- migration.sql 내용:
ALTER TABLE "User" ADD COLUMN "age" INTEGER NOT NULL DEFAULT 0;
프로덕션 환경에서의 마이그레이션
# 프로덕션에는 migrate dev 사용 금지!
# 대신 migrate deploy 사용
npx prisma migrate deploy
# 이 명령어의 특징:
# - 생성된 마이그레이션만 적용 (새로 만들지 않음)
# - 롤백 없음 (안전)
# - CI/CD 파이프라인에서 사용
실제 배포 프로세스:
1. 로컬에서 개발
↓
npx prisma migrate dev --name feature_x
2. Git에 커밋
↓
git add prisma/
git commit -m "Add feature X schema"
3. CI/CD에서 자동 배포
↓
npx prisma migrate deploy
2. 스키마 동기화
Pull: 데이터베이스 → Prisma 스키마
이미 존재하는 데이터베이스를 Prisma 스키마로 가져올 때 사용합니다.
# 데이터베이스에서 스키마 가져오기
npx prisma db pull
# 사용 시나리오:
# - 기존 프로젝트에 Prisma 도입할 때
# - 다른 도구로 만든 DB를 Prisma로 관리하려 할 때
# - 팀원이 직접 DB를 수정했을 때 (권장하지 않음!)
Push: Prisma 스키마 → 데이터베이스 (프로토타입용)
마이그레이션 없이 빠르게 스키마를 데이터베이스에 반영합니다.
# 스키마를 데이터베이스에 직접 푸시
npx prisma db push
# ⚠️ 주의사항:
# - 마이그레이션 히스토리 생성 안 됨
# - 프로덕션에서 사용 금지
# - 빠른 프로토타이핑용으로만 사용
언제 db push를 쓸까?
✅ 좋은 사용 사례:
- 로컬 개발 초기 단계
- 빠른 프로토타이핑
- 스키마를 여러 번 빠르게 변경할 때
❌ 나쁜 사용 사례:
- 프로덕션 환경
- 팀 프로젝트
- 마이그레이션 히스토리가 필요한 경우
3. 마이그레이션 상태 관리
# 마이그레이션 상태 확인
npx prisma migrate status
# 출력 예시:
# Database schema is up to date!
#
# Applied migrations:
# 20240101_init
# 20240115_add_user_age
# 20240120_add_post_tags
# 마이그레이션 히스토리 초기화 (개발용)
npx prisma migrate reset
# ⚠️ 이 명령어는:
# - 모든 데이터 삭제
# - 모든 마이그레이션 재적용
# - 시드 데이터 실행 (있다면)
# - 프로덕션에서 절대 사용 금지!
4. Prisma Client 재생성
스키마가 변경되면 Prisma Client도 재생성해야 합니다.
# Prisma Client 생성/재생성
npx prisma generate
# 언제 실행해야 할까?
# 1. 마이그레이션 후 (migrate dev는 자동 실행)
# 2. Git에서 pull 받은 후 스키마가 변경됐을 때
# 3. node_modules 삭제 후 재설치할 때
자동화 팁:
// package.json
{
"scripts": {
"postinstall": "prisma generate",
"dev": "prisma generate && ts-node src/index.ts",
"migrate": "prisma migrate dev"
}
}
Prisma Client 사용법
이제 실제로 데이터를 조작하는 방법을 살펴보겠습니다. Prisma Client는 타입 안전하면서도 직관적인 API를 제공합니다.
1. 기본 CRUD 작업
CREATE - 생성
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// 단순 생성
const user = await prisma.user.create({
data: {
email: 'john@example.com',
name: 'John Doe'
}
});
// 관계와 함께 생성 (nested create)
const userWithPosts = await prisma.user.create({
data: {
email: 'jane@example.com',
name: 'Jane Doe',
posts: {
create: [
{
title: 'First Post',
content: 'Hello World!'
},
{
title: 'Second Post',
content: 'Learning Prisma'
}
]
}
},
include: { posts: true } // 생성된 게시글도 함께 반환
});
// 여러 개 생성 (bulk insert)
const users = await prisma.user.createMany({
data: [
{ email: 'user1@example.com', name: 'User 1' },
{ email: 'user2@example.com', name: 'User 2' },
{ email: 'user3@example.com', name: 'User 3' }
],
skipDuplicates: true // 중복 에러 무시
});
console.log(`${users.count}명의 사용자 생성됨`);
READ - 조회
// 단일 조회 - unique 필드로
const user = await prisma.user.findUnique({
where: { email: 'john@example.com' }
});
// 단일 조회 - 조건으로
const firstUser = await prisma.user.findFirst({
where: {
email: { contains: '@example.com' }
},
orderBy: { createdAt: 'desc' }
});
// 여러 개 조회
const users = await prisma.user.findMany({
where: {
email: { contains: '@example.com' }
},
include: {
posts: {
where: { published: true },
orderBy: { createdAt: 'desc' },
take: 5 // 최근 5개만
},
_count: { select: { posts: true } } // 게시글 개수
},
orderBy: { name: 'asc' },
take: 10, // 10개만
skip: 0 // 페이지네이션용
});
// 전체 조회
const allUsers = await prisma.user.findMany();
// 개수 세기
const userCount = await prisma.user.count({
where: {
posts: { some: { published: true } }
}
});
조회 결과 타입 자동 추론:
// user의 타입이 정확히 추론됨
const user = await prisma.user.findUnique({
where: { id: 1 },
include: {
posts: {
include: {
comments: true
}
}
}
});
// 타입스크립트가 자동으로 알고 있음:
user?.posts[0].comments[0].content; // ✅ 타입 안전
user?.posts[0].nonexistent; // ❌ 타입 에러!
UPDATE - 수정
// 단일 수정
const updatedUser = await prisma.user.update({
where: { id: 1 },
data: {
name: 'Jane Doe',
email: 'jane.doe@example.com'
}
});
// 관계 수정
const userWithUpdatedPosts = await prisma.user.update({
where: { id: 1 },
data: {
posts: {
// 미발행 게시글을 모두 발행으로 변경
updateMany: {
where: { published: false },
data: { published: true }
},
// 특정 게시글 삭제
delete: { id: 5 }
}
}
});
// 여러 개 수정
const result = await prisma.user.updateMany({
where: {
email: { contains: '@old-domain.com' }
},
data: {
email: { // ❌ 이건 안 됨 - 문자열 치환 불가
// updateMany는 복잡한 변환 미지원
}
}
});
// 숫자 증가/감소
await prisma.post.update({
where: { id: 1 },
data: {
viewCount: { increment: 1 }, // 조회수 +1
likeCount: { decrement: 1 } // 좋아요 -1
}
});
DELETE - 삭제
// 단일 삭제
await prisma.user.delete({
where: { id: 1 }
});
// 여러 개 삭제
const result = await prisma.user.deleteMany({
where: {
createdAt: {
lt: new Date('2023-01-01') // 2023년 이전 사용자 삭제
}
}
});
console.log(`${result.count}명의 사용자 삭제됨`);
// 전체 삭제 (⚠️ 위험!)
await prisma.user.deleteMany(); // 모든 사용자 삭제
2. 복잡한 쿼리 패턴
조건부 필터링
실제 애플리케이션에서는 사용자 입력에 따라 동적으로 쿼리를 구성해야 합니다.
interface SearchFilters {
email?: string;
minAge?: number;
maxAge?: number;
hasPublishedPosts?: boolean;
role?: string;
}
const searchUsers = async (filters: SearchFilters) => {
// 동적으로 where 조건 구성
return prisma.user.findMany({
where: {
// email이 있으면 포함
...(filters.email && {
email: { contains: filters.email, mode: 'insensitive' }
}),
// age 범위 조건
...(filters.minAge && { age: { gte: filters.minAge } }),
...(filters.maxAge && { age: { lte: filters.maxAge } }),
// 발행된 게시글이 있는 사용자만
...(filters.hasPublishedPosts && {
posts: { some: { published: true } }
}),
// role 필터
...(filters.role && { role: filters.role })
},
include: {
posts: {
where: { published: true },
take: 5
},
_count: {
select: { posts: true, comments: true }
}
}
});
};
// 사용 예시
const users = await searchUsers({
email: 'john',
minAge: 20,
hasPublishedPosts: true
});
집계 쿼리 (Aggregation)
// 평균, 합계, 최대, 최소 등
const userStats = await prisma.user.aggregate({
_count: { id: true }, // 사용자 수
_avg: { age: true }, // 평균 나이
_max: { createdAt: true }, // 최근 가입일
_min: { createdAt: true }, // 최초 가입일
where: {
posts: { some: { published: true } }
}
});
console.log(`
총 사용자: ${userStats._count.id}명
평균 나이: ${userStats._avg.age}세
최근 가입: ${userStats._max.createdAt}
`);
그룹화 (Group By)
// 작성자별 게시글 통계
const postsByUser = await prisma.post.groupBy({
by: ['authorId'],
_count: { id: true }, // 게시글 개수
_avg: { viewCount: true }, // 평균 조회수
_sum: { likeCount: true }, // 총 좋아요 수
having: {
id: { _count: { gt: 5 } } // 게시글 5개 이상인 작성자만
},
orderBy: {
_count: { id: 'desc' } // 게시글 많은 순
}
});
// 결과를 사용자 정보와 함께 표시
for (const stat of postsByUser) {
const user = await prisma.user.findUnique({
where: { id: stat.authorId }
});
console.log(`
${user?.name}:
- 게시글 ${stat._count.id}개
- 평균 조회수 ${stat._avg.viewCount}
- 총 좋아요 ${stat._sum.likeCount}
`);
}
페이지네이션
interface PaginationOptions {
page: number; // 1부터 시작
limit: number; // 페이지당 개수
}
const getPostsWithPagination = async (
{ page, limit }: PaginationOptions
) => {
const skip = (page - 1) * limit;
const [posts, total] = await Promise.all([
// 게시글 조회
prisma.post.findMany({
skip,
take: limit,
where: { published: true },
include: {
author: {
select: { id: true, name: true, email: true }
},
_count: {
select: { comments: true, likes: true }
}
},
orderBy: { createdAt: 'desc' }
}),
// 전체 개수
prisma.post.count({
where: { published: true }
})
]);
return {
posts,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit),
hasNext: page * limit < total,
hasPrev: page > 1
}
};
};
// 사용 예시
const result = await getPostsWithPagination({ page: 2, limit: 10 });
console.log(`페이지 ${result.pagination.page}/${result.pagination.totalPages}`);
고급 쿼리 패턴
1. 트랜잭션 처리
트랜잭션은 여러 데이터베이스 작업을 하나의 단위로 묶어서 처리하는 기능입니다. 모든 작업이 성공하거나, 하나라도 실패하면 전체를 취소(롤백)합니다.
왜 트랜잭션이 필요한가?
상상해보세요. 은행 앱에서 친구에게 10만원을 송금하는 상황을:
// ❌ 트랜잭션 없이 송금 처리 - 매우 위험!
async function transferMoney(senderId, receiverId, amount) {
// 1단계: 송금자 계좌에서 차감
await prisma.account.update({
where: { id: senderId },
data: { balance: { decrement: amount } }
});
// 💥 만약 여기서 서버가 다운되면?
// 또는 네트워크가 끊기면?
// 또는 데이터베이스 에러가 발생하면?
// → 송금자 돈은 차감됐는데 수신자는 받지 못함!
// → 10만원이 증발! 😱
// 2단계: 수신자 계좌에 추가
await prisma.account.update({
where: { id: receiverId },
data: { balance: { increment: amount } }
});
}
문제점:
- 두 작업 사이에 에러가 발생하면 데이터 불일치
- 돈이 사라지거나 복제될 수 있음
- 프로덕션에서 절대 사용 금지!
순차 트랜잭션 (Interactive Transaction)
모든 작업을 하나의 트랜잭션으로 묶어서 안전하게 처리합니다.
// ✅ 트랜잭션으로 안전한 송금 처리
const transferMoney = await prisma.$transaction(async (tx) => {
// tx는 트랜잭션 전용 Prisma 클라이언트
// 1단계: 송금자 계좌에서 차감
const sender = await tx.account.update({
where: { id: senderId },
data: { balance: { decrement: amount } }
});
// 잔액 검증
if (sender.balance < 0) {
throw new Error('잔액 부족'); // ← 에러 발생 시 전체 롤백!
}
// 2단계: 수신자 계좌에 추가
const receiver = await tx.account.update({
where: { id: receiverId },
data: { balance: { increment: amount } }
});
// 3단계: 거래 기록 생성
const transactionRecord = await tx.transaction.create({
data: {
senderId,
receiverId,
amount,
type: 'TRANSFER',
timestamp: new Date()
}
});
// 모든 작업이 성공하면 커밋!
return { sender, receiver, transactionRecord };
});
console.log('송금 완료:', transferMoney);
트랜잭션의 특징 (ACID 속성):
┌──────────────────────────────────────────────┐
│ 트랜잭션의 4가지 보장 (ACID) │
├──────────────────────────────────────────────┤
│ Atomicity (원자성) │
│ → 모든 작업이 성공하거나 모두 실패 │
│ 중간 상태는 없음 │
│ │
│ Consistency (일관성) │
│ → 데이터베이스 규칙 유지 │
│ 외래키, 제약조건 등이 항상 만족 │
│ │
│ Isolation (격리성) │
│ → 다른 트랜잭션과 독립적으로 실행 │
│ 동시 실행되어도 서로 영향 없음 │
│ │
│ Durability (지속성) │
│ → 성공한 변경사항은 영구 저장 │
│ 서버가 다운되어도 데이터 보존 │
└──────────────────────────────────────────────┘
배치 트랜잭션 (Batch Transaction)
여러 독립적인 작업을 한 번에 실행:
// 여러 작업을 배치로 처리
const batchOperations = await prisma.$transaction([
// 새 사용자 생성
prisma.user.create({
data: { email: 'user1@example.com', name: 'User 1' }
}),
prisma.user.create({
data: { email: 'user2@example.com', name: 'User 2' }
}),
// 미발행 게시글 삭제
prisma.post.deleteMany({
where: { published: false }
}),
// 카테고리 업데이트
prisma.category.update({
where: { id: 1 },
data: { name: '업데이트된 카테고리' }
})
]);
console.log('배치 작업 결과:', batchOperations);
// [User, User, { count: 5 }, Category]
트랜잭션 타임아웃 설정
트랜잭션이 너무 오래 실행되면 데이터베이스 리소스를 독점하고 다른 요청을 차단할 수 있습니다.
// ❌ 타임아웃 없는 위험한 트랜잭션
const result = await prisma.$transaction(async (tx) => {
// 수백만 개의 레코드 처리 - 매우 오래 걸림
const allUsers = await tx.user.findMany(); // 10만 개 사용자
for (const user of allUsers) {
await tx.post.create({
data: { title: `${user.name}의 게시글`, authorId: user.id }
}); // 각각 개별 쿼리 - 매우 비효율적
}
// 💥 이 트랜잭션이 30분 동안 실행되면?
// - 데이터베이스 연결 고갈
// - 다른 사용자 요청 차단
// - 메모리 부족
});
해결책: 타임아웃과 효율적인 쿼리
// ✅ 안전한 타임아웃 설정
const result = await prisma.$transaction(
async (tx) => {
// 효율적인 배치 처리
const users = await tx.user.findMany({ take: 1000 }); // 1000개씩 제한
const posts = await tx.post.createMany({
data: users.map(user => ({
title: `${user.name}의 게시글`,
authorId: user.id
}))
}); // 한 번에 배치 처리
return { users, posts };
},
{
maxWait: 5000, // 5초 내에 트랜잭션 시작 못하면 에러
timeout: 10000, // 10초 내에 완료 못하면 롤백
}
);
타임아웃 설정이 필요한 이유:
- 리소스 보호
- 데이터베이스 연결 풀 고갈 방지
- 메모리 사용량 제한
- 성능 보장
- 다른 사용자 요청 차단 방지
- 시스템 전체 응답성 유지
- 장애 방지
- 무한 대기 상황 방지
- 데드락 상황에서 자동 복구
실제 사용 사례들
1. 주문 처리 시스템:
const processOrder = await prisma.$transaction(async (tx) => {
// 재고 확인 및 차감
const product = await tx.product.update({
where: { id: productId },
data: { stock: { decrement: quantity } }
});
if (product.stock < 0) {
throw new Error('재고 부족');
}
// 주문 생성
const order = await tx.order.create({
data: {
userId,
productId,
quantity,
total: product.price * quantity
}
});
// 결제 처리
const payment = await tx.payment.create({
data: {
orderId: order.id,
amount: order.total,
status: 'COMPLETED'
}
});
return { order, payment, product };
});
2. 게시글 좋아요 토글:
const toggleLike = await prisma.$transaction(async (tx) => {
// 기존 좋아요 확인
const existingLike = await tx.like.findUnique({
where: {
userId_postId: { userId, postId }
}
});
if (existingLike) {
// 좋아요 취소
await tx.like.delete({
where: { id: existingLike.id }
});
await tx.post.update({
where: { id: postId },
data: { likeCount: { decrement: 1 } }
});
return { action: 'unliked' };
} else {
// 좋아요 추가
await tx.like.create({
data: { userId, postId }
});
await tx.post.update({
where: { id: postId },
data: { likeCount: { increment: 1 } }
});
return { action: 'liked' };
}
});
2. Raw 쿼리 사용
Prisma의 추상화로 해결하기 어려운 복잡한 쿼리는 Raw SQL을 사용할 수 있습니다.
// Raw SQL 쿼리
const result = await prisma.$queryRaw`
SELECT u.name, COUNT(p.id) as post_count
FROM "User" u
LEFT JOIN "Post" p ON u.id = p."authorId"
GROUP BY u.id, u.name
HAVING COUNT(p.id) > ${minPostCount}
`;
// 타입 안전한 Raw 쿼리
interface UserPostCount {
name: string;
post_count: bigint;
}
const typedResult = await prisma.$queryRaw<UserPostCount[]>`
SELECT u.name, COUNT(p.id) as post_count
FROM "User" u
LEFT JOIN "Post" p ON u.id = p."authorId"
GROUP BY u.id, u.name
`;
typedResult.forEach(row => {
console.log(`${row.name}: ${row.post_count}개의 게시글`);
});
3. 미들웨어 활용
Prisma 미들웨어를 사용하면 모든 쿼리에 공통 로직을 추가할 수 있습니다.
// 로깅 미들웨어
prisma.$use(async (params, next) => {
const before = Date.now();
const result = await next(params);
const after = Date.now();
console.log(`Query ${params.model}.${params.action} took ${after - before}ms`);
return result;
});
// 소프트 삭제 미들웨어
prisma.$use(async (params, next) => {
// delete를 update로 변환
if (params.action === 'delete') {
params.action = 'update';
params.args['data'] = { deletedAt: new Date() };
}
// deleteMany를 updateMany로 변환
if (params.action === 'deleteMany') {
params.action = 'updateMany';
if (params.args.data != undefined) {
params.args.data['deletedAt'] = new Date();
} else {
params.args['data'] = { deletedAt: new Date() };
}
}
return next(params);
});
// 이제 delete는 실제로 deletedAt만 설정
await prisma.user.delete({ where: { id: 1 } });
// → UPDATE User SET deletedAt = NOW() WHERE id = 1
TypeScript 통합
Prisma의 가장 큰 강점 중 하나는 완벽한 TypeScript 지원입니다.
1. 타입 안전성
import { User, Post, Prisma } from '@prisma/client';
// 생성된 타입 사용
type UserWithPosts = User & {
posts: Post[];
};
// Prisma 타입 유틸리티
type UserCreateInput = Prisma.UserCreateInput;
type PostWhereInput = Prisma.PostWhereInput;
// 선택적 필드 타입
type UserSelect = Prisma.UserSelect;
type PostInclude = Prisma.PostInclude;
2. 커스텀 타입 정의
// 복잡한 쿼리 결과 타입
type UserWithPostsAndComments = Prisma.UserGetPayload<{
include: {
posts: {
include: {
comments: true;
};
};
};
}>;
// 부분 선택 타입
type UserBasicInfo = Prisma.UserGetPayload<{
select: {
id: true;
email: true;
name: true;
};
}>;
// 제네릭 서비스 클래스
class UserService {
constructor(private prisma: PrismaClient) {}
async findUser<T extends Prisma.UserFindUniqueArgs>(
args: Prisma.SelectSubset<T, Prisma.UserFindUniqueArgs>
): Promise<Prisma.UserGetPayload<T> | null> {
return this.prisma.user.findUnique(args);
}
async createUser<T extends Prisma.UserCreateArgs>(
args: Prisma.SelectSubset<T, Prisma.UserCreateArgs>
): Promise<Prisma.UserGetPayload<T>> {
return this.prisma.user.create(args);
}
}
3. 타입 가드 및 검증
import { z } from 'zod';
// Zod 스키마로 런타임 검증
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
age: z.number().min(0).max(150)
});
type CreateUserInput = z.infer<typeof CreateUserSchema>;
const createUserSafely = async (input: unknown) => {
// 런타임 검증
const validatedInput = CreateUserSchema.parse(input);
// Prisma에서 컴파일 타임 검증
return prisma.user.create({
data: validatedInput
});
};
// 타입 가드 함수
function isUserWithPosts(user: any): user is UserWithPosts {
return user && Array.isArray(user.posts);
}
성능 최적화
1. N+1 쿼리 문제 해결
N+1 쿼리 문제는 ORM에서 가장 흔한 성능 문제입니다.
// ❌ N+1 문제 발생 - 매우 느림!
const users = await prisma.user.findMany(); // 1번 쿼리
for (const user of users) {
// 사용자마다 쿼리 실행 - N번 쿼리
const posts = await prisma.post.findMany({
where: { authorId: user.id }
});
console.log(`${user.name}: ${posts.length} posts`);
}
// 총 1 + N번의 쿼리 (100명이면 101번!)
// ✅ include로 해결 - 빠름!
const usersWithPosts = await prisma.user.findMany({
include: { posts: true }
}); // 단 1번의 쿼리 (JOIN 사용)
usersWithPosts.forEach(user => {
console.log(`${user.name}: ${user.posts.length} posts`);
});
// ✅ 또는 별도 쿼리로 해결
const users = await prisma.user.findMany();
const userIds = users.map(u => u.id);
const posts = await prisma.post.findMany({
where: { authorId: { in: userIds } }
}); // 총 2번의 쿼리 (매우 효율적)
// posts를 사용자별로 그룹핑
const postsByUser = posts.reduce((acc, post) => {
if (!acc[post.authorId]) acc[post.authorId] = [];
acc[post.authorId].push(post);
return acc;
}, {} as Record<number, Post[]>);
2. 선택적 로딩
필요한 필드만 선택하면 성능이 크게 향상됩니다.
// ❌ 모든 필드 로드 - 느림
const users = await prisma.user.findMany({
include: {
posts: {
include: {
comments: {
include: {
author: true
}
}
}
}
}
});
// 엄청난 양의 데이터!
// ✅ 필요한 필드만 선택 - 빠름
const users = await prisma.user.findMany({
select: {
id: true,
email: true,
name: true, // 필요한 필드만
posts: {
select: {
id: true,
title: true // content는 생략
},
take: 5, // 최근 5개만
orderBy: { createdAt: 'desc' }
}
}
});
// 조건부 include
const getUserWithOptionalPosts = (includePosts: boolean) => {
return prisma.user.findMany({
...(includePosts && {
include: { posts: true }
})
});
};
3. 연결 풀링 및 캐싱
// 연결 풀 설정
const prisma = new PrismaClient({
datasources: {
db: {
url: process.env.DATABASE_URL + '?connection_limit=20&pool_timeout=20'
}
}
});
// Redis 캐싱 레이어
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const getCachedUser = async (id: number) => {
const cacheKey = `user:${id}`;
// 캐시 확인
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// 캐시 미스 - DB에서 조회
const user = await prisma.user.findUnique({
where: { id },
include: { posts: true }
});
// 캐시 저장
if (user) {
await redis.setex(cacheKey, 300, JSON.stringify(user)); // 5분 캐시
}
return user;
};
실전 프로젝트 예제
실제 프로젝트에서 Prisma를 어떻게 활용하는지 살펴보겠습니다.
1. 블로그 API 서버
// src/services/blog.service.ts
export class BlogService {
constructor(private prisma: PrismaClient) {}
async createPost(authorId: number, data: CreatePostInput) {
return this.prisma.post.create({
data: {
...data,
author: { connect: { id: authorId } },
tags: {
connectOrCreate: data.tagNames?.map(name => ({
where: { name },
create: { name }
}))
}
},
include: {
author: { select: { id: true, name: true } },
tags: true,
_count: { select: { comments: true, likes: true } }
}
});
}
async getPostsWithPagination(
page: number,
limit: number,
filters?: PostFilters
) {
const skip = (page - 1) * limit;
const where: Prisma.PostWhereInput = {
published: true,
...(filters?.category && {
tags: { some: { name: filters.category } }
}),
...(filters?.search && {
OR: [
{ title: { contains: filters.search, mode: 'insensitive' } },
{ content: { contains: filters.search, mode: 'insensitive' } }
]
})
};
const [posts, total] = await Promise.all([
this.prisma.post.findMany({
where,
skip,
take: limit,
orderBy: { createdAt: 'desc' },
include: {
author: { select: { id: true, name: true, avatar: true } },
tags: true,
_count: { select: { comments: true, likes: true } }
}
}),
this.prisma.post.count({ where })
]);
return {
posts,
pagination: {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
}
};
}
}
2. 사용자 인증 시스템
// src/services/auth.service.ts
import bcrypt from 'bcrypt';
export class AuthService {
constructor(private prisma: PrismaClient) {}
async register(data: RegisterInput) {
// 중복 확인
const existingUser = await this.prisma.user.findUnique({
where: { email: data.email }
});
if (existingUser) {
throw new Error('이미 존재하는 이메일입니다.');
}
// 비밀번호 해싱
const hashedPassword = await bcrypt.hash(data.password, 12);
// 사용자 생성
return this.prisma.user.create({
data: {
email: data.email,
name: data.name,
password: hashedPassword,
profile: {
create: {
bio: data.bio || null
}
}
},
select: {
id: true,
email: true,
name: true,
profile: true
}
});
}
async login(email: string, password: string) {
const user = await this.prisma.user.findUnique({
where: { email },
include: { profile: true }
});
if (!user || !await bcrypt.compare(password, user.password)) {
throw new Error('잘못된 인증 정보입니다.');
}
// 로그인 기록 저장
await this.prisma.loginLog.create({
data: {
userId: user.id,
ipAddress: '127.0.0.1', // 실제로는 요청에서 가져옴
userAgent: 'Mozilla/5.0...' // 실제로는 요청에서 가져옴
}
});
return {
user: {
id: user.id,
email: user.email,
name: user.name,
profile: user.profile
},
token: this.generateJWT(user.id)
};
}
private generateJWT(userId: number): string {
// JWT 생성 로직
return 'jwt-token';
}
}
함정과 주의사항
제가 실제로 겪었던 실수들과 해결 방법을 공유합니다.
1. Prisma Client 인스턴스 관리
// ❌ 잘못된 방법 - 요청마다 새 인스턴스 생성
async function getUser(id: number) {
const prisma = new PrismaClient(); // 매번 생성!
const user = await prisma.user.findUnique({ where: { id } });
await prisma.$disconnect();
return user;
}
// 문제: 연결 풀 고갈, 성능 저하
// ✅ 올바른 방법 - 싱글톤 패턴
// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client';
const globalForPrisma = global as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error'] : ['error']
});
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
// 사용
import { prisma } from './lib/prisma';
async function getUser(id: number) {
return prisma.user.findUnique({ where: { id } });
}
2. select와 include 동시 사용 불가
// ❌ 에러 발생!
const user = await prisma.user.findUnique({
where: { id: 1 },
select: { id: true, email: true },
include: { posts: true } // select와 함께 사용 불가!
});
// ✅ 올바른 방법 1: select만 사용
const user = await prisma.user.findUnique({
where: { id: 1 },
select: {
id: true,
email: true,
posts: true // select 안에 포함
}
});
// ✅ 올바른 방법 2: include만 사용
const user = await prisma.user.findUnique({
where: { id: 1 },
include: {
posts: true
}
// 모든 User 필드 + posts
});
3. 관계 삭제 시 외래키 제약조건
// ❌ 에러 발생 - 외래키 제약조건 위반
await prisma.user.delete({
where: { id: 1 }
});
// 이 사용자의 게시글이 있으면 에러!
// ✅ 해결 방법 1: 관련 데이터 먼저 삭제
await prisma.$transaction([
prisma.post.deleteMany({ where: { authorId: 1 } }),
prisma.user.delete({ where: { id: 1 } })
]);
// ✅ 해결 방법 2: 스키마에서 onDelete 설정
model Post {
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
authorId Int
}
// Cascade: 사용자 삭제 시 게시글도 자동 삭제
// SetNull: 사용자 삭제 시 authorId를 NULL로
// Restrict: 게시글이 있으면 사용자 삭제 불가 (기본값)
4. 트랜잭션 내에서 await 빼먹기
// ❌ 잘못된 트랜잭션 - await 빼먹음
await prisma.$transaction(async (tx) => {
tx.user.create({ data: { email: 'test@example.com' } }); // await 없음!
tx.post.create({ data: { title: 'Test' } }); // await 없음!
});
// 트랜잭션이 제대로 작동하지 않음!
// ✅ 올바른 트랜잭션
await prisma.$transaction(async (tx) => {
await tx.user.create({ data: { email: 'test@example.com' } });
await tx.post.create({ data: { title: 'Test' } });
});
5. 개발 환경에서 migrate dev vs deploy
# ❌ 프로덕션에서 절대 사용 금지!
npx prisma migrate dev --name add_field
# 이유: 데이터베이스를 리셋할 수 있음
# ✅ 프로덕션에서는 deploy 사용
npx prisma migrate deploy
# 안전하게 마이그레이션만 적용
6. JSON 필드 쿼리 주의사항
// JSON 필드는 데이터베이스에 따라 쿼리 방법이 다름
// PostgreSQL
const users = await prisma.user.findMany({
where: {
metadata: {
path: ['preferences', 'theme'],
equals: 'dark'
}
}
});
// MySQL/SQLite는 JSON 쿼리 지원이 제한적
// 가능하면 JSON 대신 별도 테이블로 정규화 추천
좋은 예 vs 나쁜 예
스키마 설계
// ❌ 나쁜 스키마 설계
model User {
id Int @id @default(autoincrement())
data Json // 모든 데이터를 JSON에 때려넣기
}
// ✅ 좋은 스키마 설계
model User {
id Int @id @default(autoincrement())
email String @unique @db.VarChar(255)
name String @db.VarChar(100)
age Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// 관계는 명확하게 정의
posts Post[]
profile Profile?
// 인덱스 최적화
@@index([email])
@@index([createdAt])
@@map("users") // 테이블명 명시
}
// 열거형 사용
enum UserRole {
USER
ADMIN
MODERATOR
}
쿼리 작성
// ❌ 비효율적인 쿼리
const users = await prisma.user.findMany();
for (const user of users) {
const postCount = await prisma.post.count({
where: { authorId: user.id }
});
console.log(`${user.name}: ${postCount}`);
}
// N+1 쿼리 문제!
// ✅ 효율적인 쿼리
const users = await prisma.user.findMany({
include: {
_count: {
select: { posts: true }
}
}
});
users.forEach(user => {
console.log(`${user.name}: ${user._count.posts}`);
});
// 단 1번의 쿼리!
에러 처리
// ❌ 에러 처리 없음
async function createUser(data) {
const user = await prisma.user.create({ data });
return user;
}
// 중복 이메일 시 서버 크래시!
// ✅ 적절한 에러 처리
async function createUser(data) {
try {
const user = await prisma.user.create({ data });
return user;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === 'P2002') {
throw new Error('이미 존재하는 이메일입니다.');
}
}
throw error;
}
}
베스트 프랙티스
1. 에러 처리 패턴
// 커스텀 에러 클래스
export class DatabaseError extends Error {
constructor(message: string, public code?: string) {
super(message);
this.name = 'DatabaseError';
}
}
// 에러 처리 래퍼
export const handlePrismaError = (error: any): never => {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
switch (error.code) {
case 'P2002':
throw new DatabaseError('중복된 값입니다.', 'DUPLICATE_VALUE');
case 'P2025':
throw new DatabaseError('레코드를 찾을 수 없습니다.', 'NOT_FOUND');
default:
throw new DatabaseError('데이터베이스 오류가 발생했습니다.', error.code);
}
}
throw error;
};
// 서비스에서 사용
export class UserService {
async createUser(data: CreateUserInput) {
try {
return await this.prisma.user.create({ data });
} catch (error) {
handlePrismaError(error);
}
}
}
2. 테스팅 전략
// 테스트 데이터베이스 설정
// tests/setup.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient({
datasources: {
db: { url: process.env.TEST_DATABASE_URL }
}
});
export const resetDatabase = async () => {
await prisma.$transaction([
prisma.post.deleteMany(),
prisma.user.deleteMany(),
]);
};
// 통합 테스트 예제
// tests/user.service.test.ts
describe('UserService', () => {
beforeEach(async () => {
await resetDatabase();
});
it('should create user with profile', async () => {
const userData = {
email: 'test@example.com',
name: 'Test User',
profile: {
create: { bio: 'Test bio' }
}
};
const user = await userService.createUser(userData);
expect(user.email).toBe(userData.email);
expect(user.profile).toBeDefined();
expect(user.profile?.bio).toBe('Test bio');
});
it('should throw error for duplicate email', async () => {
await userService.createUser({
email: 'test@example.com',
name: 'User 1'
});
await expect(
userService.createUser({
email: 'test@example.com',
name: 'User 2'
})
).rejects.toThrow('중복된 값입니다.');
});
});
3. 프로덕션 고려사항
// 연결 관리
export const createPrismaClient = () => {
const prisma = new PrismaClient({
log: process.env.NODE_ENV === 'development'
? ['query', 'info', 'warn', 'error']
: ['error'],
datasources: {
db: {
url: process.env.DATABASE_URL + '?connection_limit=10&pool_timeout=20'
}
}
});
// Graceful shutdown
process.on('SIGINT', async () => {
await prisma.$disconnect();
process.exit(0);
});
return prisma;
};
// 헬스체크 엔드포인트
export const healthCheck = async (prisma: PrismaClient) => {
try {
await prisma.$queryRaw`SELECT 1`;
return { status: 'healthy', database: 'connected' };
} catch (error) {
return { status: 'unhealthy', database: 'disconnected', error };
}
};
참고 자료
공식 문서
- Prisma 공식 문서 - 가장 최신의 정보와 상세한 가이드
- Prisma Schema Reference - 스키마 문법 완전 가이드
- Prisma Client API Reference - 클라이언트 API 레퍼런스
- Prisma Migrate Reference - 마이그레이션 명령어 가이드
튜토리얼 및 가이드
- Prisma Getting Started - 빠른 시작 가이드
- Database Connectors - 데이터베이스별 연결 가이드
- Prisma with Next.js - Next.js 통합 가이드
- Prisma with TypeScript - TypeScript 활용법
커뮤니티 및 도구
- Prisma GitHub Repository - 소스코드 및 이슈 트래킹
- Prisma Community - 커뮤니티 리소스
- Prisma Examples - 다양한 프레임워크별 예제
- Prisma Studio - 데이터베이스 GUI 도구
관련 도구 및 라이브러리
- Zod - 런타임 타입 검증 라이브러리
- tRPC - 타입 안전한 API 구축
- Nexus - GraphQL 스키마 구축 도구
- Pothos GraphQL - GraphQL 스키마 빌더
성능 및 모니터링
- Prisma Accelerate - 연결 풀링 및 캐싱 서비스
- Prisma Pulse - 실시간 데이터베이스 이벤트
- DataDog Prisma Integration - 성능 모니터링
- New Relic Database Monitoring - 데이터베이스 성능 추적
블로그 및 아티클
- Prisma Blog - 최신 업데이트 및 기술 아티클
- Prisma Best Practices - 성능 최적화 가이드
- Database Schema Design Best Practices - 스키마 설계 가이드
마무리
Prisma를 처음 접했을 때, 저는 “이게 정말 가능한가?”라고 놀랐습니다. SQL 쿼리를 작성하면서 겪었던 모든 불편함이 사라지고, TypeScript의 타입 안전성이 데이터베이스 레이어까지 확장되는 경험은 정말 혁명적이었습니다.
핵심 포인트
제가 Prisma를 사용하면서 느낀 가장 큰 장점들입니다.
- 타입 안전성: 더 이상 런타임 에러를 두려워하지 않아도 됩니다. 코드를 작성하는 순간 에러를 발견할 수 있습니다.
- 개발자 경험: 자동완성, 인텔리센스 덕분에 코딩 속도가 눈에 띄게 빨라졌습니다. “이 테이블에 무슨 컬럼이 있었지?”라고 고민할 필요가 없습니다.
- 성능: N+1 쿼리 같은 함정에 빠질 염려가 줄었고, Prisma가 생성하는 쿼리는 대부분 최적화되어 있습니다.
- 유지보수성: 스키마 중심의 선언적 개발로 코드베이스가 깔끔해지고, 변경사항 관리가 쉬워졌습니다.
시작하기 전에 알아두면 좋은 점
Prisma는 강력하지만 만능은 아닙니다.
- 매우 복잡한 Raw SQL이 필요한 경우는 있을 수 있습니다
- 러닝 커브가 있습니다 (하지만 이 가이드가 도움이 되었기를 바랍니다!)
- 기존 레거시 데이터베이스와 통합할 때는 추가 작업이 필요할 수 있습니다
하지만 이런 단점들을 훨씬 상회하는 이점들이 있습니다. 특히 TypeScript를 사용하는 프로젝트라면, Prisma를 사용하지 않을 이유가 없습니다.
다음 단계
이 가이드를 읽으셨다면, 이제 직접 프로젝트를 만들어보세요! 작은 프로젝트부터 시작해서 다음 내용들을 연습해보시길 추천합니다.
- 간단한 스키마 정의하기
- CRUD 작업 구현하기
- 관계형 데이터 다루기
- 트랜잭션으로 복잡한 로직 처리하기
- 성능 최적화 적용하기
저도 처음에는 작은 토이 프로젝트로 시작했고, 지금은 프로덕션 서비스에서 자신 있게 Prisma를 사용하고 있습니다. 여러분도 충분히 할 수 있습니다!
Prisma를 활용하여 더 안전하고 효율적이며, 무엇보다 즐겁게 데이터베이스 애플리케이션을 개발해보세요!
댓글