iBetter Books
수정

Ch 03. 데이터베이스 모델 타이핑

지금까지 데이터를 메모리 배열에 저장했습니다. 서버를 재시작하면 모든 데이터가 사라집니다. 이제 데이터베이스에 영구 저장합니다. Prisma를 쓰면 데이터베이스 스키마에서 TypeScript 타입이 자동으로 생성됩니다. 타입을 직접 손으로 쓰지 않아도 됩니다.

Prisma란

Prisma는 Node.js/TypeScript 생태계의 ORM(Object-Relational Mapping)입니다. 기존 ORM과 다른 점이 하나 있습니다. 스키마 파일을 정의하면 데이터베이스 테이블과 TypeScript 타입이 동시에 생성됩니다. 스키마가 단일 진실 원천(Single Source of Truth)이 됩니다.

Prisma 설치

npm install @prisma/clientnpm install -D prismanpx prisma init --datasource-provider sqlite

--datasource-provider sqlite는 SQLite를 데이터베이스로 사용합니다. 별도 서버 없이 파일 하나로 동작하기 때문에 개발 환경에 적합합니다.

명령 실행 후 두 가지가 생성됩니다.

  • prisma/schema.prisma — 스키마 정의 파일
  • .envDATABASE_URL 환경 변수

스키마 정의

// 수정: prisma/schema.prisma
generator client {
  provider = "prisma-client-js"
}

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

model Todo {
  id        Int      @id @default(autoincrement())
  title     String
  completed Boolean  @default(false)
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("todos")
}

스키마 문법은 TypeScript와 비슷합니다. @id는 기본 키, @default(autoincrement())는 자동 증가 값, @updatedAt은 레코드가 업데이트될 때 자동으로 현재 시간을 씁니다.

@@map("todos")는 테이블 이름을 todos로 지정합니다. Prisma 모델 이름은 단수형 PascalCase(Todo)를, 테이블 이름은 복수형 소문자(todos)를 쓰는 것이 관례입니다.

마이그레이션 실행

스키마를 정의했으면 실제 데이터베이스에 테이블을 만들어야 합니다.

npx prisma migrate dev --name init

이 명령이 세 가지를 합니다.

  1. prisma/migrations/ 폴더에 SQL 파일을 생성합니다.
  2. 데이터베이스에 SQL을 실행해 테이블을 만듭니다.
  3. Prisma Client를 재생성해서 TypeScript 타입을 업데이트합니다.

마이그레이션 후 node_modules/.prisma/client/ 안에 생성된 타입을 확인할 수 있습니다.

Prisma Client 싱글톤

// 새 파일: src/db/prisma.tsimport { PrismaClient } from '@prisma/client';const globalForPrisma = globalThis as unknown as {  prisma: PrismaClient | undefined;};export const prisma =  globalForPrisma.prisma ??  new PrismaClient({    log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],  });if (process.env.NODE_ENV !== 'production') {  globalForPrisma.prisma = prisma;}

PrismaClient를 파일마다 new PrismaClient()로 만들면 데이터베이스 연결이 여러 개 생깁니다. 개발 환경에서 nodemon이 재시작할 때마다 연결이 누적됩니다. 싱글톤 패턴으로 전역에 하나만 유지합니다.

globalThis는 Node.js, 브라우저, 웹 워커 등 어떤 환경에서도 전역 객체에 접근하는 표준 방법입니다. as unknown as { prisma: PrismaClient | undefined }는 타입 단언입니다. globalThis에는 prisma 속성이 없으므로 TypeScript에게 "이 속성이 있다"고 알려주는 것입니다.

라우트에서 Prisma 사용

이제 메모리 배열을 Prisma 쿼리로 교체합니다.

// 수정: src/routes/todos.tsimport { Router, Request, Response } from 'express';import type { CreateTodoDto, UpdateTodoDto, TodoParams, ApiResponse } from '../types/todo.js';import { prisma } from '../db/prisma.js';import type { Todo } from '@prisma/client';import { validateBody, isCreateTodoDto } from '../middleware/validate.js';const router = Router();router.get('/', async (_req: Request, res: Response<ApiResponse<Todo[]>>) => {  const todos = await prisma.todo.findMany({    orderBy: { createdAt: 'desc' },  });  res.json({ data: todos });});router.get(  '/:id',  async (req: Request<TodoParams>, res: Response<ApiResponse<Todo> | { error: string }>) => {    const id = parseInt(req.params.id, 10);    const todo = await prisma.todo.findUnique({ where: { id } });    if (!todo) {      res.status(404).json({ error: `id ${id}인 할 일을 찾을 수 없습니다.` });      return;    }    res.json({ data: todo });  });router.post(  '/',  validateBody(isCreateTodoDto),  async (req: Request<{}, ApiResponse<Todo>, CreateTodoDto>, res: Response<ApiResponse<Todo>>) => {    const { title } = req.body;    const todo = await prisma.todo.create({      data: { title },    });    res.status(201).json({ data: todo, message: '할 일이 생성되었습니다.' });  });router.patch(  '/:id',  async (    req: Request<TodoParams, ApiResponse<Todo>, UpdateTodoDto>,    res: Response<ApiResponse<Todo> | { error: string }>  ) => {    const id = parseInt(req.params.id, 10);    try {      const todo = await prisma.todo.update({        where: { id },        data: req.body,      });      res.json({ data: todo });    } catch {      res.status(404).json({ error: `id ${id}인 할 일을 찾을 수 없습니다.` });    }  });router.delete('/:id', async (req: Request<TodoParams>, res: Response) => {  const id = parseInt(req.params.id, 10);  try {    await prisma.todo.delete({ where: { id } });    res.status(204).send();  } catch {    res.status(404).json({ error: `id ${id}인 할 일을 찾을 수 없습니다.` });  }});export default router;

import type { Todo } from '@prisma/client'에서 Todo 타입은 Prisma가 스키마에서 자동 생성한 것입니다. 우리가 직접 만들었던 src/types/todo.tsTodo 인터페이스와 동일한 구조를 가지지만, 이제는 스키마가 바뀌면 자동으로 따라옵니다.

prisma.todo.update()는 레코드가 없으면 예외를 던집니다. try-catch로 처리하면 됩니다. 예외 타입은 Prisma.PrismaClientKnownRequestError이지만, 간단한 예제에서는 범용적으로 처리해도 충분합니다.

Prisma Studio로 데이터 확인

npx prisma studio

브라우저에서 http://localhost:5555를 열면 데이터베이스 내용을 GUI로 볼 수 있습니다. API를 호출하고 데이터가 실제로 저장되는지 확인할 때 유용합니다.

타입 자동 생성의 가치

Prisma의 핵심 가치는 타입 동기화에 있습니다. 스키마에 필드를 추가하면 마이그레이션과 함께 TypeScript 타입이 자동으로 업데이트됩니다. 타입 파일을 수동으로 관리하지 않아도 됩니다.

예를 들어 priority 필드를 추가한다면 스키마 파일 하나만 수정하면 됩니다.

model Todo {
  id        Int      @id @default(autoincrement())
  title     String
  completed Boolean  @default(false)
  priority  Int      @default(0)   // 추가
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt

  @@map("todos")
}

npx prisma migrate dev --name add-priority를 실행하면 데이터베이스 컬럼과 TypeScript 타입이 동시에 추가됩니다. prisma.todo.create({ data: { title, priority } })처럼 바로 쓸 수 있고, priority 필드를 누락하면 TypeScript가 경고합니다.