[Node.js / Express 5] ORM으로 Repository 리팩토링

1. ORM이란

5주차에서 작성한 Repository 코드를 다시 보면 이렇다:

export const getUserPreferencesByUserId = async (userId) => {
  const conn = await pool.getConnection()
  try {
    const [preferences] = await pool.query(
      "SELECT ufc.uf_category_id, ufc.f_category_id, ufc.user_id, fcl.f_category_name " +
        "FROM user_favor_category ufc JOIN food_category_list fcl on ufc.f_category_id = fcl.f_category_id " +
        "WHERE ufc.user_id = ? ORDER BY ufc.f_category_id ASC;",
      userId
    )
    return preferences
  } catch (err) {
    throw new Error(`오류가 발생했어요. (${err})`)
  } finally {
    conn.release()
  }
}

SQL 문자열을 직접 이어 붙이고, 오타 하나면 런타임 에러가 난다. 쿼리가 길어질수록 수정하기 어렵다.

ORM(Object-Relational Mapping)은 DB 테이블과 코드의 객체를 매핑해, SQL 대신 JavaScript 메서드로 DB를 조작하게 해주는 도구다.

구분 Raw SQL Prisma ORM
쿼리 작성 SQL 문자열 직접 작성 JavaScript 메서드로 DB 조작
오타 발견 런타임에서야 에러 발생 컴파일 타임에 잡힘 (타입 안전성)
복잡한 JOIN 강력하게 표현 가능 include로 조인 처리, 극단적 복잡도에서는 raw 쿼리 사용
스키마 변경 쿼리 하나하나 수정 schema.prisma 수정 후 generate 한 번으로 반영
개발 편의성 자동 완성 제한적 자동 완성 지원으로 개발 편의성 향상

ORM 장단점

구분 내용
장점 SQL을 직접 몰라도 사용 가능
장점 TypeScript 타입 안전성 (컴파일 시 오류 감지)
장점 DB 종류 변경 시 코드 수정 최소화
장점 반복적인 CRUD 코드 자동 생성
단점 복잡한 JOIN, 집계 쿼리 한계
단점 생성되는 SQL을 제어하기 어려워 성능 저하 가능
단점 학습 곡선 존재
단점 N+1 문제 주의 필요

2. Prisma 설정

설치

npm install @prisma/client
npm install --save-dev prisma
npm exec prisma init

prisma init 실행 시 생성되는 파일:

  • prisma/schema.prisma — DB 스키마 정의 파일
  • .envDATABASE_URL 추가

schema.prisma 작성

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

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

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique @db.VarChar(50)
  name      String   @db.VarChar(20)
  createdAt DateTime @default(now()) @map("created_at")

  reviews   Review[]

  @@map("users")
}

model Review {
  id        Int      @id @default(autoincrement())
  content   String
  rating    Int
  userId    Int      @map("user_id")
  storeId   Int      @map("store_id")
  createdAt DateTime @default(now()) @map("created_at")

  user  User  @relation(fields: [userId], references: [id])
  store Store @relation(fields: [storeId], references: [id])

  @@map("reviews")
}

주요 데코레이터

데코레이터 역할
@id 기본 키
@default(autoincrement()) 자동 증가
@unique 유니크 제약
@map("column_name") DB 컬럼명 매핑
@@map("table_name") DB 테이블명 매핑
@db.VarChar(50) DB 타입 지정
@relation(...) 외래 키 관계

Client 생성

npm exec prisma generate

schema.prisma를 수정할 때마다 prisma generate를 실행해야 한다. package.jsonpostinstall 스크립트로 자동화할 수 있다:

{
  "scripts": {
    "postinstall": "prisma generate"
  }
}

PrismaClient 싱글톤

import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient({ log: ['query'] })
export default prisma

Prisma 로그 설정

const prisma = new PrismaClient({
  log: ['query', 'info', 'warn', 'error'],
});

개발 중에는 query 로그를 켜두면 Prisma가 실행하는 실제 SQL을 콘솔에서 확인할 수 있다.

3. Prisma 쿼리로 Repository 리팩토링

raw SQL과 Prisma 코드를 나란히 놓으면 차이가 명확하다.

Before (Raw SQL)

const [rows] = await pool.query(
  'SELECT * FROM users WHERE email = ?',
  [email]
)
return rows[0] ?? null

After (Prisma)

return await prisma.user.findFirst({
  where: { email }
})

주요 쿼리 메서드

메서드 용도
findFirst({ where: {...} }) 조건에 맞는 첫 번째 레코드
findFirstOrThrow(...) 없으면 예외 발생
findMany({ where, orderBy, take, skip }) 여러 레코드
create({ data: {...} }) 레코드 생성
update({ where, data }) 레코드 수정
delete({ where }) 레코드 삭제

회원가입 Repository 전환

export const addUser = async (dto) => {
  const user = await prisma.user.create({
    data: {
      email: dto.email,
      password: dto.password,
      name: dto.name,
    },
  })
  return user.id
}

관계 쿼리 (include)

const userWithReviews = await prisma.user.findFirst({
  where: { id: userId },
  include: { reviews: true },
})

관계 쿼리 Before/After 상세 비교

Before (Raw SQL)

SELECT m.name, fc.name AS food_name
FROM member m
JOIN member_prefer mp ON m.id = mp.member_id
JOIN food_category fc ON mp.category_id = fc.id
WHERE m.id = 1;

After (Prisma)

const member = await prisma.member.findUnique({
  where: { id: 1 },
  include: {
    memberPrefer: {
      include: { foodCategory: true },
    },
  },
});

 

4. DB 마이그레이션

마이그레이션은 schema.prisma 변경사항을 실제 DB에 반영하는 과정이다.

명령어 용도 데이터 손실
prisma migrate dev 개발: 마이그레이션 파일 생성 + 적용 경고 후 진행
prisma migrate deploy 프로덕션: 기존 마이그레이션 파일만 적용 없음
prisma db push 실험적: 마이그레이션 파일 없이 스키마 반영 주의 필요
prisma migrate reset DB 초기화 후 전체 재적용 모든 데이터 삭제
npm exec prisma migrate dev --name init

실행 결과: prisma/migrations/{timestamp}_init/migration.sql 파일이 생성된다.

Migration 실전 예시

컬럼 추가 예시: schema.prismaphone String?을 추가한 뒤 아래 명령을 실행한다.

npx prisma migrate dev --name add-phone

생성되는 SQL:

ALTER TABLE `member` ADD COLUMN `phone` VARCHAR(191) NULL;

컬럼 이름 변경은 Prisma가 삭제+추가로 처리하므로 데이터 손실 위험이 있다. 이 경우 마이그레이션 파일을 수동으로 수정해야 한다.

마이그레이션 워크플로우

  1. schema.prisma 변경 — 모델 추가, 컬럼 변경, 관계 수정
  2. migrate dev 실행npm exec prisma migrate dev --name {변경_내용}
  3. 마이그레이션 파일 생성prisma/migrations/{timestamp}_{name}/migration.sql
  4. DB에 자동 반영 — 개발 DB에 SQL이 즉시 적용됨
  5. schema + migrations 함께 커밋prisma/schema.prisma + prisma/migrations/ 폴더를 함께 커밋

팀 협업 규칙

  • schema 파일과 migrations 폴더를 항상 같이 커밋한다.
  • 프로덕션에서 migrate reset은 절대 실행하지 않는다.
  • PR 리뷰 시 schema 변경 사항을 함께 리뷰한다.
  • main 브랜치의 마이그레이션 파일은 수정하지 않는다. 이미 배포된 마이그레이션을 수정하면 팀원의 DB와 충돌이 발생한다.
  • 새로운 변경은 항상 새 마이그레이션 파일로 추가한다.
  • 마이그레이션 충돌이 발생했을 때는 prisma migrate reset으로 초기화 후 재생성한다. 단, 로컬 데이터가 모두 삭제되므로 주의해야 한다.

5. 목록 API와 페이지네이션

모든 리뷰를 한 번에 조회하면 데이터가 많을 때 성능 문제가 생긴다. 페이지네이션으로 나눠서 가져와야 한다.

구분 Offset 기반 Cursor 기반
쿼리 방식 LIMIT 10 OFFSET 20 WHERE id > {lastId} LIMIT 10
구현 난이도 단순하고 직관적 커서 값 관리 필요
데이터 변동 추가/삭제 시 중복/누락 가능 변동에도 일관된 결과
성능 OFFSET이 클수록 성능 저하 인덱스 활용으로 성능 우수
임의 페이지 이동 가능 불가
적합한 UI 페이지 번호 무한 스크롤

Offset 페이지네이션 코드

const page = Number(req.query.page) || 1;
const take = 10;
const skip = (page - 1) * take;

const posts = await prisma.post.findMany({
  skip,
  take,
  orderBy: { createdAt: 'desc' },
});

단점: 데이터가 추가/삭제되면 같은 데이터가 중복 노출되거나 누락될 수 있다.

커서 기반 리뷰 목록 API 구현

Controller

export const handleListStoreReviews = async (req, res) => {
  const storeId = Number(req.params.storeId)
  const cursor = Number(req.query.cursor ?? 0)
  const result = await listStoreReviews(storeId, cursor)
  res.status(StatusCodes.OK).json(result)
}

Repository

export const getAllStoreReviews = async (storeId, cursor) => {
  return await prisma.review.findMany({
    select: {
      id: true,
      content: true,
      rating: true,
      createdAt: true,
      user: { select: { name: true } },
    },
    where: {
      storeId,
      ...(cursor > 0 && { id: { gt: cursor } }),
    },
    orderBy: { id: 'asc' },
    take: 5,
  })
}

응답 DTO

export const reviewsToResponse = (reviews) => ({
  data: reviews,
  pagination: {
    cursor: reviews.length === 5 ? reviews[reviews.length - 1].id : null,
  },
})

cursornull이면 더 이상 데이터가 없다는 뜻이다.

6. N+1 문제

N+1 문제란 목록을 1번 조회(1)하고, 각 항목의 연관 데이터를 N번 추가 조회하는 문제이다.

예시: 게시글 10개를 조회한 뒤, 각 게시글의 작성자를 조회하면 총 11번의 쿼리가 발생한다.

  • 1번: 게시글 목록 조회
  • 10번: 각 게시글의 작성자 조회

include를 사용하면 JOIN으로 한 번에 조회해서 쿼리 수를 줄인다.

const posts = await prisma.post.findMany({
  include: { author: true },
});

Prisma 로그로 실제로 몇 번의 쿼리가 발생하는지 확인할 수 있다.

7. Transaction

트랜잭션이란 여러 DB 작업을 하나의 단위로 묶는 것이다. 중간에 실패하면 모든 작업이 롤백된다.

예시: 주문 생성 시 재고 감소와 주문 내역 추가가 함께 성공해야 한다. 재고는 줄었는데 주문 내역이 저장되지 않으면 데이터 불일치가 발생한다.

await prisma.$transaction([
  prisma.inventory.update({
    where: { productId: 1 },
    data: { stock: { decrement: 1 } },
  }),
  prisma.order.create({
    data: { userId: 1, productId: 1, quantity: 1 },
  }),
]);

$transaction에 전달한 배열이 모두 성공해야 커밋된다. 하나라도 실패하면 전체가 롤백된다.

핵심 키워드

ORM (Object-Relational Mapping)

DB 테이블을 코드 객체와 매핑해 SQL 없이 DB를 조작하는 도구다.

Prisma

Node.js/TypeScript용 ORM이다. schema.prisma로 스키마를 관리하고 타입 안전한 쿼리를 제공한다.

prisma generate

schema.prisma를 기반으로 타입이 있는 PrismaClient 코드를 생성하는 명령이다.

DB Migration

schema 변경사항을 버전 관리하며 DB에 순서대로 반영하는 과정이다.

Cursor-based Pagination

마지막으로 조회한 항목의 ID를 커서로 사용해 다음 페이지를 가져오는 방식이다.

Prisma Relations

@relation으로 테이블 간 외래 키 관계를 정의하고 include로 조인 쿼리를 작성하는 방법이다.

N+1 문제

목록 1번 조회 후 각 항목의 연관 데이터를 N번 추가 조회해 총 N+1번의 쿼리가 발생하는 문제다. include로 해결한다.

Transaction

여러 DB 작업을 하나의 단위로 묶어 모두 성공하거나 모두 실패(롤백)하도록 보장하는 메커니즘이다.