Stage 3: Backend Essentials

Database · Lesson 05

Prisma ORM

Schema modeling, migrations, relations and the Prisma Client.

Database3 min readFree lesson

Writing SQL by hand is fine until a schema change touches twenty queries. An ORM maps tables to typed objects, generates the SQL for you and keeps relations consistent. Prisma is the most type-safe option in the Node ecosystem.

Learning objectives

  • Describe models in schema.prisma.
  • Run migrations and generate the client.
  • Create, read, update and delete records with relations.
  • Choose include and select deliberately.

1. The three pieces

schema.prisma  →  migration  →  database
      │
      └────────→ Prisma Client (typed API in Node)
npm install -D prisma
npm install @prisma/client
npx prisma init --datasource-provider sqlite

That creates prisma/schema.prisma and adds DATABASE_URL="file:./dev.db" to .env.

2. Models and relations

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model User {
  id        Int       @id @default(autoincrement())
  email     String    @unique
  name      String
  bio       String?
  posts     Post[]
  comments  Comment[]
  createdAt DateTime  @default(now())

  @@map("users")
}

model Post {
  id        Int       @id @default(autoincrement())
  title     String
  content   String
  published Boolean   @default(false)
  author    User      @relation(fields: [authorId], references: [id], onDelete: Cascade)
  authorId  Int
  comments  Comment[]
  createdAt DateTime  @default(now())
  updatedAt DateTime  @updatedAt

  @@map("posts")
}

model Comment {
  id        Int      @id @default(autoincrement())
  content   String
  post      Post     @relation(fields: [postId], references: [id], onDelete: Cascade)
  postId    Int
  author    User     @relation(fields: [authorId], references: [id], onDelete: Cascade)
  authorId  Int
  createdAt DateTime @default(now())

  @@map("comments")
}

String? is nullable; String is required. @unique creates a unique index. @default(now()) and @updatedAt handle timestamps. onDelete: Cascade deletes a post's comments with the post.

3. Migrate

npx prisma migrate dev --name add-comments
npx prisma generate

migrate dev writes a SQL migration, applies it and regenerates the client. prisma studio opens a browser GUI for the data.

4. The client

const { PrismaClient } = require("@prisma/client");
const prisma = new PrismaClient();

Create one client per process, not per request.

// create with a relation
const post = await prisma.post.create({
  data: {
    title: "Hello",
    content: "First post",
    author: { connect: { id: 1 } },
  },
});

// list with relation data
const posts = await prisma.post.findMany({
  where: { published: true },
  include: { author: { select: { name: true } }, _count: { select: { comments: true } } },
  orderBy: { createdAt: "desc" },
  skip: 0,
  take: 10,
});

// update / delete
await prisma.post.update({ where: { id: post.id }, data: { published: true } });
await prisma.post.delete({ where: { id: post.id } });

include returns full related records; select returns only the fields you name. select is how you avoid leaking password hashes and oversized content.

5. Errors

Prisma throws typed errors for common cases:

const { Prisma } = require("@prisma/client");

try {
  await prisma.user.create({ data: { email: "[email protected]", name: "A" } });
} catch (error) {
  if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
    return res.status(409).json({ error: "Email already exists" });
  }
  throw error;
}

P2002 unique violation, P2025 record not found, P2003 foreign key failure.

6. When not to use an ORM

For bulk imports, complex analytics or hand-tuned queries, raw SQL through prisma.$queryRaw is faster and clearer. Use the ORM for CRUD and relations; drop to SQL for reports.

Common mistakes

  • Editing the database by hand and expecting Prisma to know — change the schema and migrate.
  • Forgetting await on a Prisma call (it returns a Promise).
  • Creating a new PrismaClient inside a request handler.
  • Using include everywhere and returning whole object graphs to the client.

Practice

Add comments to the blog schema and build the nested API in the assignment.

Example files

Complete, runnable files used in this lesson. Copy them into your project and experiment.

api.jsJavaScript
// ============================================
// 강의05 예제: Prisma + Express API
// User & Post CRUD
// ============================================
// 실행 순서:
//   1. npx prisma migrate dev --name init
//   2. node prisma/seed.js
//   3. node api.js
// ============================================

const express = require('express');
const { PrismaClient } = require('@prisma/client');

const app = express();
const prisma = new PrismaClient();
const PORT = 3000;

app.use(express.json());

// ─────────────────────────────────────────
// Users API
// ─────────────────────────────────────────

// GET /users — 전체 유저 + 포스트 수
app.get('/users', async (req, res) => {
    // findMany: 여러 행 조회
    const users = await prisma.user.findMany({
        include: {
            _count: { select: { posts: true } }, // 포스트 수만 포함
        },
        orderBy: { createdAt: 'desc' },
    });
    res.json({ success: true, data: users });
});

// GET /users/:id — 유저 + 공개 포스트 목록
app.get('/users/:id', async (req, res) => {
    const id = parseInt(req.params.id);

    // findUnique: 단일 행 조회 (없으면 null)
    const user = await prisma.user.findUnique({
        where: { id },
        include: {
            posts: {
                where: { published: true },     // 공개 포스트만
                orderBy: { createdAt: 'desc' },
            },
        },
    });

    if (!user) {
        return res.status(404).json({ success: false, message: '유저를 찾을 수 없습니다.' });
    }

    res.json({ success: true, data: user });
});

// POST /users — 유저 생성
// Body: { name, email, bio? }
app.post('/users', async (req, res) => {
    const { name, email, bio } = req.body;

    if (!name || !email) {
        return res.status(400).json({ success: false, message: 'name과 email은 필수입니다.' });
    }

    // 이메일 중복 확인
    const existing = await prisma.user.findUnique({ where: { email } });
    if (existing) {
        return res.status(409).json({ success: false, message: '이미 사용 중인 이메일입니다.' });
    }

    // create: 단일 행 생성
    const user = await prisma.user.create({
        data: { name, email, bio },
    });

    res.status(201).json({ success: true, data: user });
});

// ─────────────────────────────────────────
// Posts API
// ─────────────────────────────────────────

// GET /posts — 공개 포스트 목록 (작성자 포함)
app.get('/posts', async (req, res) => {
    const posts = await prisma.post.findMany({
        where: { published: true },     // 공개된 포스트만
        include: {
            author: {
                select: { id: true, name: true } // 작성자 이름만 포함 (이메일 제외)
            },
        },
        orderBy: { createdAt: 'desc' },
    });
    res.json({ success: true, count: posts.length, data: posts });
});

// GET /posts/:id — 포스트 단건 조회 (조회수 증가)
app.get('/posts/:id', async (req, res) => {
    const id = parseInt(req.params.id);

    // update로 조회수 증가 + 최신 데이터 반환을 한 번에 처리
    const post = await prisma.post.update({
        where: { id },
        data: { viewCount: { increment: 1 } }, // viewCount += 1
        include: { author: { select: { name: true, email: true } } },
    }).catch(() => null); // 없는 ID면 null 반환

    if (!post) {
        return res.status(404).json({ success: false, message: '포스트를 찾을 수 없습니다.' });
    }

    res.json({ success: true, data: post });
});

// POST /posts — 포스트 생성
// Body: { title, content, authorId, published? }
app.post('/posts', async (req, res) => {
    const { title, content, authorId, published = false } = req.body;

    if (!title || !content || !authorId) {
        return res.status(400).json({
            success: false,
            message: 'title, content, authorId는 필수입니다.',
        });
    }

    // 작성자 존재 확인
    const author = await prisma.user.findUnique({ where: { id: parseInt(authorId) } });
    if (!author) {
        return res.status(404).json({ success: false, message: '존재하지 않는 유저입니다.' });
    }

    const post = await prisma.post.create({
        data: {
            title,
            content,
            published,
            author: { connect: { id: parseInt(authorId) } }, // 기존 유저와 연결
        },
        include: { author: { select: { name: true } } },
    });

    res.status(201).json({ success: true, data: post });
});

// PUT /posts/:id — 포스트 수정
app.put('/posts/:id', async (req, res) => {
    const id = parseInt(req.params.id);
    const { title, content, published } = req.body;

    // updateMany 대신 update 사용 (단일 행)
    const post = await prisma.post.update({
        where: { id },
        data: {
            // undefined인 필드는 Prisma가 자동으로 무시 (부분 업데이트)
            ...(title     !== undefined && { title }),
            ...(content   !== undefined && { content }),
            ...(published !== undefined && { published }),
        },
    }).catch(() => null);

    if (!post) {
        return res.status(404).json({ success: false, message: '포스트를 찾을 수 없습니다.' });
    }

    res.json({ success: true, data: post });
});

// DELETE /posts/:id — 포스트 삭제
app.delete('/posts/:id', async (req, res) => {
    const id = parseInt(req.params.id);

    const post = await prisma.post.delete({
        where: { id },
    }).catch(() => null);

    if (!post) {
        return res.status(404).json({ success: false, message: '포스트를 찾을 수 없습니다.' });
    }

    res.json({ success: true, message: `"${post.title}" 삭제 완료` });
});

// ─────────────────────────────────────────
// 서버 시작
// ─────────────────────────────────────────
app.listen(PORT, () => {
    console.log(`Prisma API 서버: http://localhost:${PORT}`);
    console.log('');
    console.log('Users: GET/POST /users, GET /users/:id');
    console.log('Posts: GET/POST /posts, GET/PUT/DELETE /posts/:id');
});
prisma/seed.jsJavaScript
// ============================================
// 강의05 예제: 초기 데이터(Seed) 삽입 스크립트
// ============================================
// 실행: node prisma/seed.js
// (마이그레이션 후 한 번만 실행)
// ============================================

const { PrismaClient } = require('@prisma/client');
const prisma = new PrismaClient();

async function main() {
    console.log('시드 데이터 삽입 시작...');

    // ─────────────────────────────────────────
    // 기존 데이터 삭제 (재실행 시 중복 방지)
    // 외래키가 있으므로 posts 먼저 삭제 후 users 삭제
    // ─────────────────────────────────────────
    await prisma.post.deleteMany();
    await prisma.user.deleteMany();

    // ─────────────────────────────────────────
    // 유저 3명 생성 (각각 포스트 포함)
    // create 안에 posts를 중첩하면 연결도 자동 처리
    // ─────────────────────────────────────────
    const kim = await prisma.user.create({
        data: {
            name:  '김철수',
            email: '[email protected]',
            bio:   '풀스택 개발자입니다.',
            posts: {
                create: [
                    {
                        title:     'Prisma ORM 시작하기',
                        content:   'Prisma는 Node.js를 위한 차세대 ORM입니다...',
                        published: true,  // 공개 포스트
                    },
                    {
                        title:     'SQLite vs PostgreSQL 비교',
                        content:   '언제 SQLite를 쓰고 언제 PostgreSQL을 쓸까요?',
                        published: true,
                    },
                    {
                        title:     '작성 중인 드래프트',
                        content:   '아직 작성 중...',
                        published: false, // 비공개 포스트
                    },
                ],
            },
        },
    });

    const lee = await prisma.user.create({
        data: {
            name:  '이영희',
            email: '[email protected]',
            bio:   '백엔드 개발자, 커피를 사랑합니다.',
            posts: {
                create: [
                    {
                        title:     'Express.js 미들웨어 심화',
                        content:   '커스텀 미들웨어를 만드는 방법을 알아봅니다.',
                        published: true,
                    },
                    {
                        title:     'JWT vs 세션 인증 비교',
                        content:   '각 방식의 장단점을 분석합니다.',
                        published: true,
                    },
                ],
            },
        },
    });

    // 포스트 없는 유저도 생성 가능
    const park = await prisma.user.create({
        data: {
            name:  '박민준',
            email: '[email protected]',
        },
    });

    console.log(`유저 생성 완료: ${kim.name}, ${lee.name}, ${park.name}`);
    console.log('시드 데이터 삽입 완료!');
}

// main 함수 실행 후 연결 종료
main()
    .catch((e) => {
        console.error('시드 실패:', e);
        process.exit(1);
    })
    .finally(async () => {
        await prisma.$disconnect(); // DB 연결 종료
    });
schema.prismaPrisma
// ============================================
// 강의05 예제: Prisma 스키마 — User-Post 1:N 관계
// ============================================
// 이 파일을 수정한 뒤:
//   npx prisma migrate dev --name 변경내용
// 실행하면 DB 테이블이 자동으로 갱신됩니다.
// ============================================

// Prisma Client 생성 설정
generator client {
    provider = "prisma-client-js"
}

// 데이터베이스 연결 설정
// SQLite 사용: .env 파일에서 DATABASE_URL을 읽어옴
// .env 예시: DATABASE_URL="file:./dev.db"
datasource db {
    provider = "sqlite"
    url      = env("DATABASE_URL")
}

// ─────────────────────────────────────────
// User 모델 (users 테이블에 매핑)
// ─────────────────────────────────────────
model User {
    id        Int      @id @default(autoincrement()) // 기본키, 자동 증가
    email     String   @unique                       // 이메일, 중복 불가
    name      String                                 // 이름
    bio       String?                                // 자기소개 (선택, ?는 nullable)
    posts     Post[]                                 // 1:N 관계 — 한 유저는 여러 포스트
    createdAt DateTime @default(now())               // 가입일시 (자동)

    @@map("users") // 실제 테이블명을 "users"로 지정 (기본은 "User")
}

// ─────────────────────────────────────────
// Post 모델 (posts 테이블에 매핑)
// ─────────────────────────────────────────
model Post {
    id        Int      @id @default(autoincrement()) // 기본키, 자동 증가
    title     String                                 // 포스트 제목
    content   String                                 // 포스트 내용
    published Boolean  @default(false)              // 공개 여부 (기본: 비공개)
    viewCount Int      @default(0)                  // 조회수

    // 관계 정의: authorId가 User.id를 참조 (외래키)
    author    User     @relation(fields: [authorId], references: [id], onDelete: Cascade)
    authorId  Int      // 외래키 컬럼

    createdAt DateTime @default(now())               // 작성일시 (자동)
    updatedAt DateTime @updatedAt                    // 수정일시 (수정 시 자동 갱신)

    @@map("posts")
}

Assignment

Assignment 05 — Comments API with Prisma

Extend the blog schema with comments and expose a nested REST API.

Setup

npm install -D prisma
npm install @prisma/client express
npx prisma init --datasource-provider sqlite

Add the User, Post and Comment models from the lesson, then:

npx prisma migrate dev --name add-comments
npx prisma generate

Endpoints

Method Path Notes
GET /posts/:postId/comments include author name, newest first
POST /posts/:postId/comments require content and authorId; 201
PUT /comments/:id update content; 404 when missing
DELETE /comments/:id delete; 404 when missing

Requirements

  1. GET returns 404 when the post itself does not exist.
  2. POST validates that the post and the user exist before creating.
  3. DELETE removes only the comment; the post remains.
  4. Responses use { success, data } / { success, error }.
  5. Catch P2002 and P2025 and map them to 409 and 404.
  6. Seed one user, one post and two comments with prisma/seed.js.

Hints

const comments = await prisma.comment.findMany({
  where: { postId: Number(req.params.postId) },
  include: { author: { select: { id: true, name: true } } },
  orderBy: { createdAt: "desc" },
});
const comment = await prisma.comment.create({
  data: {
    content,
    post: { connect: { id: postId } },
    author: { connect: { id: authorId } },
  },
});

Done when

  • Deleting the post in Prisma Studio removes its comments (cascade).
  • Creating a comment for a missing post returns 404, not a database crash.
  • The response never includes the author's email or other unselected fields.
Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

routes.jsJavaScript
// ============================================
// 과제05 정답: 댓글 CRUD API 라우터
// Prisma Client 사용
// ============================================
// 사용법 (메인 app.js에서):
//   const commentRoutes = require('./routes');
//   app.use('/', commentRoutes);
// ============================================

const express = require('express');
const { PrismaClient } = require('@prisma/client');

const router = express.Router();
const prisma = new PrismaClient();

// ─────────────────────────────────────────
// GET /posts/:postId/comments
// 특정 포스트의 댓글 목록 조회 (최신 순)
// ─────────────────────────────────────────
router.get('/posts/:postId/comments', async (req, res) => {
    const postId = parseInt(req.params.postId);

    // 포스트 존재 여부 먼저 확인
    const post = await prisma.post.findUnique({ where: { id: postId } });
    if (!post) {
        return res.status(404).json({
            success: false,
            message: `ID ${postId}에 해당하는 포스트가 없습니다.`,
        });
    }

    // 해당 포스트의 댓글 목록 조회
    // include로 작성자 이름만 포함 (이메일 등 민감 정보 제외)
    const comments = await prisma.comment.findMany({
        where: { postId },                                // 해당 포스트의 댓글만
        include: {
            author: { select: { id: true, name: true } }, // 작성자 이름 포함
        },
        orderBy: { createdAt: 'desc' },                   // 최신 순
    });

    res.json({
        success: true,
        count: comments.length,
        data: comments,
    });
});

// ─────────────────────────────────────────
// POST /posts/:postId/comments
// 특정 포스트에 댓글 작성
// Body: { content, authorId }
// ─────────────────────────────────────────
router.post('/posts/:postId/comments', async (req, res) => {
    const postId = parseInt(req.params.postId);
    const { content, authorId } = req.body;

    // 필수 필드 검증
    if (!content || !authorId) {
        return res.status(400).json({
            success: false,
            message: 'content와 authorId는 필수입니다.',
        });
    }

    // 빈 내용 거부
    if (content.trim() === '') {
        return res.status(400).json({
            success: false,
            message: '댓글 내용이 비어있습니다.',
        });
    }

    // 포스트 존재 확인
    const post = await prisma.post.findUnique({ where: { id: postId } });
    if (!post) {
        return res.status(404).json({
            success: false,
            message: `ID ${postId}에 해당하는 포스트가 없습니다.`,
        });
    }

    // 작성자 존재 확인
    const author = await prisma.user.findUnique({ where: { id: parseInt(authorId) } });
    if (!author) {
        return res.status(404).json({
            success: false,
            message: '존재하지 않는 유저입니다.',
        });
    }

    // 댓글 생성
    // connect를 사용하여 기존 포스트/유저와 연결
    const comment = await prisma.comment.create({
        data: {
            content: content.trim(),
            post:   { connect: { id: postId } },            // 포스트 연결
            author: { connect: { id: parseInt(authorId) } }, // 작성자 연결
        },
        include: {
            author: { select: { name: true } }, // 응답에 작성자 이름 포함
        },
    });

    res.status(201).json({
        success: true,
        message: '댓글이 작성되었습니다.',
        data: comment,
    });
});

// ─────────────────────────────────────────
// PUT /comments/:id
// 댓글 내용 수정
// Body: { content }
// ─────────────────────────────────────────
router.put('/comments/:id', async (req, res) => {
    const id = parseInt(req.params.id);
    const { content } = req.body;

    // 내용 필수 검증
    if (!content || content.trim() === '') {
        return res.status(400).json({
            success: false,
            message: '수정할 내용을 입력해주세요.',
        });
    }

    // 댓글 존재 확인 + 수정을 한 번에 처리
    // .catch(() => null) 으로 없는 ID 에러를 null로 변환
    const comment = await prisma.comment.update({
        where: { id },
        data: { content: content.trim() },
        include: {
            author: { select: { name: true } }, // 응답에 작성자 포함
        },
    }).catch(() => null);

    if (!comment) {
        return res.status(404).json({
            success: false,
            message: `ID ${id}에 해당하는 댓글이 없습니다.`,
        });
    }

    res.json({
        success: true,
        message: '댓글이 수정되었습니다.',
        data: comment,
    });
});

// ─────────────────────────────────────────
// DELETE /comments/:id
// 댓글 삭제
// ─────────────────────────────────────────
router.delete('/comments/:id', async (req, res) => {
    const id = parseInt(req.params.id);

    // 삭제 실행 (없는 ID면 .catch에서 null 반환)
    const comment = await prisma.comment.delete({
        where: { id },
    }).catch(() => null);

    if (!comment) {
        return res.status(404).json({
            success: false,
            message: `ID ${id}에 해당하는 댓글이 없습니다.`,
        });
    }

    res.json({
        success: true,
        message: '댓글이 삭제되었습니다.',
    });
});

module.exports = router;
schema.prismaPrisma
// ============================================
// 과제05 정답: Comment 모델 추가된 스키마
// ============================================
// 마이그레이션 실행:
//   npx prisma migrate dev --name add-comment
// ============================================

generator client {
    provider = "prisma-client-js"
}

datasource db {
    provider = "sqlite"
    url      = env("DATABASE_URL")
}

// ─────────────────────────────────────────
// User 모델
// 한 유저는 여러 포스트와 여러 댓글을 가짐 (1:N 두 개)
// ─────────────────────────────────────────
model User {
    id        Int       @id @default(autoincrement())
    email     String    @unique
    name      String
    bio       String?
    posts     Post[]    // 유저 → 포스트 (1:N)
    comments  Comment[] // 유저 → 댓글 (1:N) ← 추가됨
    createdAt DateTime  @default(now())

    @@map("users")
}

// ─────────────────────────────────────────
// Post 모델
// 한 포스트는 여러 댓글을 가짐 (1:N)
// ─────────────────────────────────────────
model Post {
    id        Int       @id @default(autoincrement())
    title     String
    content   String
    published Boolean   @default(false)
    viewCount Int       @default(0)
    author    User      @relation(fields: [authorId], references: [id], onDelete: Cascade)
    authorId  Int
    comments  Comment[] // 포스트 → 댓글 (1:N) ← 추가됨
    createdAt DateTime  @default(now())
    updatedAt DateTime  @updatedAt

    @@map("posts")
}

// ─────────────────────────────────────────
// Comment 모델 (새로 추가)
// 한 댓글은 하나의 포스트 + 하나의 유저에 속함 (N:1 두 개)
// ─────────────────────────────────────────
model Comment {
    id        Int      @id @default(autoincrement())
    content   String                                   // 댓글 내용

    // 포스트와의 관계 (댓글 → 포스트, N:1)
    // onDelete: Cascade → 포스트 삭제 시 댓글도 자동 삭제
    post      Post     @relation(fields: [postId], references: [id], onDelete: Cascade)
    postId    Int

    // 작성자와의 관계 (댓글 → 유저, N:1)
    author    User     @relation(fields: [authorId], references: [id], onDelete: Cascade)
    authorId  Int

    createdAt DateTime @default(now())

    @@map("comments")
}

Learn this lesson with a mentor

Work through the Build Your Homepage series on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now