Prisma ORM 실전 활용법: Node.js API 개발 가이드
Node.js 백엔드 개발에서 Prisma ORM을 활용하여 데이터베이스를 효율적으로 관리하고 강력한 API를 구축하는 실전 가이드입니다. 스키마 정의, CRUD, 아키텍처 통합까지 전반적인 과정을 다룹니다.
Prisma ORM 실전 활용법: Node.js API 개발 가이드
현대 Node.js 백엔드 개발에서 데이터베이스 상호작용은 핵심적인 부분입니다. 전통적인 SQL 쿼리 작성의 번거로움과 타입 안전성 문제를 해결하기 위해 많은 ORM(Object-Relational Mapping) 도구들이 등장했지만, 그중에서도 Prisma ORM은 탁월한 개발자 경험과 강력한 타입 안정성을 제공하며 빠르게 주목받고 있습니다. 이 글에서는 Prisma ORM의 기본 개념부터 실제 Node.js API 서버에 통합하여 활용하는 실전적인 방법까지 상세히 다루어 보겠습니다.
Prisma ORM이란? 왜 주목해야 하는가?
Prisma는 Node.js 및 TypeScript 환경을 위한 차세대 ORM입니다. 기존 ORM들이 복잡한 객체 매핑과 런타임 오류의 가능성을 내포했던 것과 달리, Prisma는 데이터베이스 스키마를 기반으로 완전히 타입 안전한 데이터베이스 클라이언트를 생성하여 개발 생산성과 코드 안정성을 크게 향상시킵니다.
Prisma가 제공하는 주요 기능은 다음과 같습니다.
- Prisma Schema Language (PSL): 직관적이고 강력한 스키마 정의 언어를 통해 데이터 모델을 쉽게 정의할 수 있습니다.
- Prisma Client: 정의된 스키마를 바탕으로 자동 생성되는 타입 안전한 데이터베이스 클라이언트로, 모든 쿼리 작업이 TypeScript의 강력한 타입 체크를 받습니다.
- Prisma Migrate: 데이터베이스 스키마 변경 사항을 관리하고 적용하는 강력한 마이그레이션 도구입니다.
- Prisma Studio: 데이터베이스 내용을 시각적으로 확인하고 편집할 수 있는 GUI 도구입니다.
이러한 기능들을 통해 Prisma는 개발자가 데이터베이스 상호작용에 드는 시간을 줄이고 비즈니스 로직에 더 집중할 수 있도록 돕습니다.
Prisma 시작하기: 프로젝트 설정
Prisma를 Node.js 프로젝트에 통합하는 과정은 매우 간단합니다. 먼저 기본적인 Node.js 및 TypeScript 프로젝트를 설정한 후 Prisma를 설치하고 초기화하는 단계부터 시작하겠습니다.
1. 프로젝트 초기화 및 TypeScript 설정
mkdir prisma-api-example
cd prisma-api-example
npm init -y
npm install typescript ts-node @types/node --save-dev
npx tsc --init
2. Prisma CLI 및 Prisma Client 설치
Prisma CLI는 개발 의존성으로, @prisma/client는 런타임 의존성으로 설치합니다.
npm install prisma --save-dev
npm install @prisma/client
3. Prisma 초기화
prisma init 명령어를 실행하면 .env 파일과 prisma/schema.prisma 파일이 생성됩니다.
npx prisma init
이 명령어를 실행하면 다음과 같은 파일이 생성됩니다.
-
.env: 데이터베이스 연결 URL 등 환경 변수를 관리합니다. -
prisma/schema.prisma: 데이터베이스 스키마를 정의하는 핵심 파일입니다.
.env 파일에는 DATABASE_URL이 기본으로 설정되어 있습니다. 사용할 데이터베이스(예: PostgreSQL, MySQL, SQLite)에 맞게 수정해주세요. 여기서는 PostgreSQL을 예시로 들겠습니다.
# .env
DATABASE_URL="postgresql://user:password@localhost:5432/mydb?schema=public"
스키마 정의와 데이터베이스 마이그레이션
prisma/schema.prisma 파일은 Prisma의 핵심입니다. 이 파일에 데이터베이스의 모델(테이블)과 그 관계를 정의합니다.
1. schema.prisma 파일 구조 이해
schema.prisma 파일은 크게 세 부분으로 구성됩니다.
-
datasource: 사용할 데이터베이스 종류와 연결 정보를 정의합니다. -
generator: Prisma Client를 어떤 언어(TypeScript/JavaScript)로 생성할지 정의합니다. -
model: 실제 데이터베이스 테이블에 매핑될 데이터 모델을 정의합니다.
// prisma/schema.prisma
datasource db {
provider = "postgresql" // 사용하는 데이터베이스 종류
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
// 여기에 모델을 정의합니다.
2. 모델 정의 예시: User와 Post
간단한 블로그 애플리케이션을 위해 User와 Post 두 가지 모델을 정의해 보겠습니다. User는 여러 개의 Post를 가질 수 있는 1:N 관계를 설정합니다.
// prisma/schema.prisma
// ... (datasource, generator 부분은 동일)
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
posts Post[] // User는 여러 개의 Post를 가질 수 있습니다.
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Post {
id Int @id @default(autoincrement())
title String
content String?
published Boolean @default(false)
author User @relation(fields: [authorId], references: [id]) // Post는 한 명의 User에 속합니다.
authorId Int // 외래 키
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
-
@id: 기본 키를 나타냅니다. -
@default(autoincrement()): 자동 증가하는 기본 키를 설정합니다. -
@unique: 고유한 값이어야 함을 나타냅니다. -
String?: null 값을 허용하는 필드입니다. -
Post[]:User모델이Post모델의 배열을 참조함을 나타내며, 이는 1:N 관계의 "N" 쪽입니다. -
@relation: 두 모델 간의 관계를 정의합니다.fields는 현재 모델의 외래 키,references는 참조하는 모델의 기본 키입니다. -
@default(now()): 생성 시 현재 시간을 기본값으로 설정합니다. -
@updatedAt: 업데이트 시 자동으로 현재 시간으로 갱신됩니다.
3. 데이터베이스 마이그레이션 적용
스키마를 정의했다면, prisma migrate dev 명령어를 사용하여 데이터베이스에 스키마를 적용하고 마이그레이션 파일을 생성합니다.
npx prisma migrate dev --name init_models
이 명령어를 실행하면 다음과 같은 작업이 수행됩니다.
-
prisma/migrations디렉토리에 마이그레이션 파일이 생성됩니다. - 데이터베이스에
User와Post테이블이 생성됩니다. - Prisma Client가 새로운 스키마에 맞춰 재생성됩니다.
4. Prisma Studio 활용 (선택 사항)
npx prisma studio 명령어를 실행하면 웹 기반의 GUI 도구인 Prisma Studio가 실행되어 데이터베이스 내용을 편리하게 확인하고 편집할 수 있습니다.
npx prisma studio
Prisma Client 활용: CRUD 작업
Prisma Client는 타입 안전한 방식으로 데이터베이스에 CRUD(Create, Read, Update, Delete) 작업을 수행할 수 있게 해줍니다.
1. Prisma Client 초기화
애플리케이션에서 Prisma Client를 사용하려면 인스턴스를 생성해야 합니다.
// src/prisma.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
export default prisma;
2. 데이터 생성 (Create)
새로운 User와 Post를 생성하는 예시입니다.
// src/create.ts
import prisma from './prisma';
async function main() {
// 1. 새로운 사용자 생성
const newUser = await prisma.user.create({
data: {
email: 'alice@example.com',
name: 'Alice',
posts: {
create: [ // 사용자 생성과 동시에 게시물 생성 (Nested Writes)
{ title: '첫 번째 글', content: '안녕하세요, Prisma!' },
{ title: 'Prisma 배우기', content: 'Prisma는 정말 편리합니다.', published: true },
],
},
},
});
console.log('생성된 사용자:', newUser);
// 2. 기존 사용자에게 게시물 추가
const bob = await prisma.user.create({
data: {
email: 'bob@example.com',
name: 'Bob',
},
});
const newPost = await prisma.post.create({
data: {
title: 'Bob의 첫 게시물',
content: '밥입니다.',
authorId: bob.id,
},
});
console.log('생성된 게시물:', newPost);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
3. 데이터 조회 (Read)
다양한 조건으로 데이터를 조회할 수 있습니다. where, include, select 등의 옵션을 활용합니다.
// src/read.ts
import prisma from './prisma';
async function main() {
// 1. 모든 사용자 조회
const allUsers = await prisma.user.findMany();
console.log('모든 사용자:', allUsers);
// 2. 특정 이메일을 가진 사용자 조회
const alice = await prisma.user.findUnique({
where: {
email: 'alice@example.com',
},
include: { // 관련 Post 데이터도 함께 가져오기
posts: true,
},
});
console.log('앨리스와 게시물:', alice);
// 3. 특정 키워드를 포함하는 게시물 조회 (필터링)
const prismaPosts = await prisma.post.findMany({
where: {
content: {
contains: 'Prisma', // content 필드에 'Prisma'가 포함된 게시물
},
},
include: {
author: true, // 게시물의 작성자 정보도 함께 가져오기
},
});
console.log('Prisma 관련 게시물:', prismaPosts);
// 4. 페이지네이션 (skip, take)
const paginatedPosts = await prisma.post.findMany({
skip: 0, // 첫 0개를 건너뛰고
take: 2, // 2개만 가져오기
orderBy: {
createdAt: 'desc',
},
});
console.log('페이지네이션된 게시물:', paginatedPosts);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
4. 데이터 업데이트 (Update)
특정 데이터를 찾아 업데이트합니다.
// src/update.ts
import prisma from './prisma';
async function main() {
// 1. 특정 사용자의 이름 업데이트
const updatedUser = await prisma.user.update({
where: {
email: 'alice@example.com',
},
data: {
name: 'Alice Smith',
},
});
console.log('업데이트된 사용자:', updatedUser);
// 2. 특정 게시물의 상태 업데이트
const updatedPost = await prisma.post.update({
where: {
id: 1, // 첫 번째 게시물 가정
},
data: {
published: true,
},
});
console.log('업데이트된 게시물:', updatedPost);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
5. 데이터 삭제 (Delete)
특정 데이터를 삭제합니다.
// src/delete.ts
import prisma from './prisma';
async function main() {
// 1. 특정 이메일을 가진 사용자 삭제 (관련 게시물도 함께 삭제)
// Cascade Delete가 설정되어 있지 않다면, 먼저 관련 게시물을 삭제해야 합니다.
// 스키마에 `onDelete: Cascade`를 설정하면 한 번에 삭제 가능합니다.
await prisma.post.deleteMany({
where: {
author: {
email: 'bob@example.com',
},
},
});
const deletedUser = await prisma.user.delete({
where: {
email: 'bob@example.com',
},
});
console.log('삭제된 사용자:', deletedUser);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
6. 트랜잭션 (Interactive Transactions)
여러 데이터베이스 작업을 하나의 원자적인 단위로 묶을 때 트랜잭션을 사용합니다. Prisma는 $transaction 메서드를 제공합니다.
// src/transaction.ts
import prisma from './prisma';
async function main() {
try {
const [user1, user2] = await prisma.$transaction([
prisma.user.create({ data: { email: 'charlie@example.com', name: 'Charlie' } }),
prisma.user.create({ data: { email: 'diana@example.com', name: 'Diana' } }),
]);
console.log('두 사용자 생성 완료:', user1, user2);
// 복잡한 비즈니스 로직을 포함하는 트랜잭션
const result = await prisma.$transaction(async (tx) => {
const post = await tx.post.create({
data: {
title: '트랜잭션 테스트',
content: '이 글은 트랜잭션 내에서 생성됩니다.',
authorId: user1.id,
},
});
// 특정 조건에 따라 추가 작업 수행
if (post.title.length > 10) {
await tx.user.update({
where: { id: user1.id },
data: { name: 'Charlie Long Post Writer' },
});
}
return post;
});
console.log('트랜잭션 결과:', result);
} catch (error) {
console.error('트랜잭션 실패:', error);
} finally {
await prisma.$disconnect();
}
}
main();
실전 아키텍처: Node.js API 서버에 Prisma 통합
실제 Node.js API 서버에 Prisma를 통합할 때는 아키텍처를 고려하여 효율적으로 사용하는 것이 중요합니다. 여기서는 Express.js를 예시로 간단한 통합 방안을 제시합니다.
1. 일반적인 백엔드 아키텍처 다이어그램 (텍스트)
[클라이언트 (웹/모바일)]
| (HTTP 요청)
v
[API Gateway / Load Balancer]
|
v
[Node.js API 서버 (Express/NestJS)]
|
v
[Controller] - 요청 처리, 응답 반환
|
v
[Service] - 비즈니스 로직 처리, 여러 Repository 조합
|
v
[Repository] - 데이터 접근 추상화 (Prisma Client 사용)
|
v
[Prisma Client] - 타입 안전한 DB 쿼리
|
v
[데이터베이스 (PostgreSQL, MySQL 등)]
2. Express.js와 Prisma 통합 예시
PrismaClient 인스턴스를 애플리케이션 전역에서 공유하고, 이를 Repository 또는 Service 계층에 주입하여 사용하는 것이 일반적입니다.
// src/app.ts (Express 앱 설정)
import express from 'express';
import prisma from './prisma'; // 위에서 생성한 prisma 인스턴스
import userRoutes from './routes/userRoutes';
import postRoutes from './routes/postRoutes';
const app = express();
app.use(express.json()); // JSON 요청 본문 파싱
// Prisma Client를 미들웨어로 주입하여 모든 라우터/컨트롤러에서 접근 가능하게 할 수 있습니다.
// 또는 DI 컨테이너를 사용하거나 직접 주입하는 방식도 가능합니다.
app.use((req, res, next) => {
(req as any).prisma = prisma; // 타입 안전성을 위해 Request 객체 확장 필요
next();
});
app.use('/users', userRoutes);
app.use('/posts', postRoutes);
app.get('/', (req, res) => {
res.send('Prisma API Server is running!');
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
// 서버 종료 시 Prisma Client 연결 해제
process.on('beforeExit', async () => {
await prisma.$disconnect();
});
// src/repositories/userRepository.ts
import { PrismaClient, User } from '@prisma/client';
export class UserRepository {
constructor(private prisma: PrismaClient) {}
async createUser(data: { email: string; name?: string }): Promise<User> {
return this.prisma.user.create({ data });
}
async findUserById(id: number): Promise<User | null> {
return this.prisma.user.findUnique({ where: { id } });
}
async findAllUsers(): Promise<User[]> {
return this.prisma.user.findMany({ include: { posts: true } });
}
async updateUser(id: number, data: { name?: string; email?: string }): Promise<User> {
return this.prisma.user.update({ where: { id }, data });
}
async deleteUser(id: number): Promise<User> {
return this.prisma.user.delete({ where: { id } });
}
}
// src/services/userService.ts
import { UserRepository } from '../repositories/userRepository';
import { User } from '@prisma/client';
export class UserService {
constructor(private userRepository: UserRepository) {}
async registerUser(email: string, name?: string): Promise<User> {
// 비즈니스 로직: 이메일 중복 확인 등
const existingUser = await this.userRepository.findUserByEmail(email);
if (existingUser) {
throw new Error('Email already registered.');
}
return this.userRepository.createUser({ email, name });
}
async getUserProfile(id: number): Promise<User | null> {
return this.userRepository.findUserById(id);
}
// ... 다른 비즈니스 로직
}
// src/routes/userRoutes.ts
import { Router } from 'express';
import { PrismaClient } from '@prisma/client';
import { UserRepository } from '../repositories/userRepository';
import { UserService } from '../services/userService';
const router = Router();
router.get('/', async (req, res) => {
const prisma: PrismaClient = (req as any).prisma;
const userRepository = new UserRepository(prisma);
const users = await userRepository.findAllUsers();
res.json(users);
});
router.post('/', async (req, res) => {
const prisma: PrismaClient = (req as any).prisma;
const userRepository = new UserRepository(prisma);
const userService = new UserService(userRepository); // 서비스 계층 사용
const { email, name } = req.body;
try {
const newUser = await userService.registerUser(email, name);
res.status(201).json(newUser);
} catch (error: any) {
res.status(400).json({ error: error.message });
}
});
// ... 기타 라우트 (GET /:id, PUT /:id, DELETE /:id)
export default router;
이러한 계층 분리를 통해 코드의 응집도를 높이고, 유지보수를 용이하게 하며, 테스트 가능한 구조를 만들 수 있습니다.
고급 Prisma 활용 팁
Prisma는 기본적인 CRUD 외에도 다양한 고급 기능을 제공하여 복잡한 요구사항을 충족시킬 수 있습니다.
1. Raw SQL 쿼리 사용
Prisma Client는 대부분의 경우 충분하지만, 특정 복잡한 쿼리나 데이터베이스 특정 기능을 활용해야 할 때는 Raw SQL 쿼리를 실행할 수 있습니다.
-
$queryRaw: SELECT 쿼리 실행 -
$executeRaw: INSERT, UPDATE, DELETE 등 DML 쿼리 실행
// src/raw_query.ts
import prisma from './prisma';
import { Prisma } from '@prisma/client';
async function main() {
// $queryRaw로 특정 조건의 사용자 수 조회
const userCount = await prisma.$queryRaw(
Prisma.sql`SELECT COUNT(*) FROM "User" WHERE name LIKE ${'%Alice%'}`
);
console.log('이름에 "Alice"가 포함된 사용자 수:', userCount);
// $executeRaw로 데이터 업데이트
const result = await prisma.$executeRaw(
Prisma.sql`UPDATE "Post" SET "published" = TRUE WHERE title = ${'첫 번째 글'}`
);
console.log('업데이트된 게시물 수:', result);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
Prisma.sql 태그 템플릿 리터럴을 사용하여 SQL 인젝션 공격을 방지하고 안전하게 파라미터를 전달하는 것이 중요합니다.
2. 미들웨어 (Middleware) 활용
Prisma Client 미들웨어를 사용하면 모든 쿼리 실행 전후에 특정 로직을 삽입할 수 있습니다. 이는 로깅, 쿼리 시간 측정, 소프트 딜리트 구현 등에 유용합니다.
// src/prisma.ts (기존 파일 수정)
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
// 쿼리 로깅 미들웨어
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;
});
// 소프트 딜리트 미들웨어 (예시)
// `deletedAt` 필드가 있는 모델에 대해 `find` 또는 `delete` 요청 시 작동
prisma.$use(async (params, next) => {
if (params.model === 'Post' && params.action === 'findUnique') {
// findUnique 쿼리에 deletedAt 필터 추가
params.args.where = { ...params.args.where, deletedAt: null };
}
if (params.model === 'Post' && params.action === 'findMany') {
// findMany 쿼리에 deletedAt 필터 추가
if (params.args.where) {
params.args.where = { ...params.args.where, deletedAt: null };
} else {
params.args.where = { deletedAt: null };
}
}
if (params.model === 'Post' && params.action === 'delete') {
// 실제 삭제 대신 deletedAt 업데이트로 변경
params.action = 'update';
params.args.data = { deletedAt: new Date() };
}
return next(params);
});
export default prisma;
소프트 딜리트를 구현하려면 Post 모델에 deletedAt 필드를 추가해야 합니다.
// prisma/schema.prisma (Post 모델 수정)
model Post {
// ... 기존 필드
deletedAt DateTime? // 삭제 시간 기록 (null이면 삭제되지 않음)
}
이후 npx prisma migrate dev를 다시 실행하여 스키마 변경 사항을 데이터베이스에 적용합니다.
마무리
지금까지 Prisma ORM을 Node.js 백엔드 개발에 통합하는 실전적인 방법을 살펴보았습니다. 프로젝트 설정부터 스키마 정의, CRUD 작업, 그리고 실제 API 서버 아키텍처에 통합하는 방법까지 다루었으며
관련 게시글
데이터베이스 Indexing 최적화 전략: Node.js API 성능 향상 가이드
Node.js API 백엔드 서버의 성능을 극대화하기 위한 데이터베이스 Indexing 최적화 전략을 심층적으로 다룹니다. B-tree, 복합 인덱스, Covering Index 등 다양한 기법과 실제 활용 예시를 통해 쿼리 속도를 향상시키는 방법을 알아보세요.
JWT Authentication System 구현 가이드: Node.js API 서버 구축
Node.js 백엔드 API 서버에서 JWT(JSON Web Token)를 활용한 안전하고 확장 가능한 인증 시스템을 구축하는 방법을 심층적으로 다룹니다. 아키텍처 설계부터 실제 코드 구현까지 자세히 설명합니다.
gRPC vs REST: Modern API Architecture Deep Dive
백엔드 API 아키텍처의 핵심인 gRPC와 REST를 비교 분석합니다. 성능, 개발 편의성, 사용 사례를 통해 각 기술의 장단점을 깊이 있게 탐구하고, Node.js 기반의 구현 예시를 제공하여 최적의 API 선택 가이드를 제시합니다.