[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 스키마 정의 파일.env에DATABASE_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.json의 postinstall 스크립트로 자동화할 수 있다:
{
"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.prisma에 phone String?을 추가한 뒤 아래 명령을 실행한다.
npx prisma migrate dev --name add-phone
생성되는 SQL:
ALTER TABLE `member` ADD COLUMN `phone` VARCHAR(191) NULL;
컬럼 이름 변경은 Prisma가 삭제+추가로 처리하므로 데이터 손실 위험이 있다. 이 경우 마이그레이션 파일을 수동으로 수정해야 한다.
마이그레이션 워크플로우
- schema.prisma 변경 — 모델 추가, 컬럼 변경, 관계 수정
- migrate dev 실행 —
npm exec prisma migrate dev --name {변경_내용} - 마이그레이션 파일 생성 —
prisma/migrations/{timestamp}_{name}/migration.sql - DB에 자동 반영 — 개발 DB에 SQL이 즉시 적용됨
- 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,
},
})
cursor가 null이면 더 이상 데이터가 없다는 뜻이다.
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 작업을 하나의 단위로 묶어 모두 성공하거나 모두 실패(롤백)하도록 보장하는 메커니즘이다.