환경 설정
예전에는 MySQL이 많이 사용됐는데 요새는 PostgreSQL이 많이 사용된다.
PostgreSQL이 기능이 다양하게 있기 때문이다.
1) Render에서 PostgreSQL 만들기
- 클라우드에서 DB를 무료로 호스팅해주는 서비스
1. https://render.com 접속
2. [Get Started for Free] 클릭
3. GitHub 또는 Google 계정으로 가입
4. 이메일 인증 완료
2) DBeaver은 데이터베이스에 접속해서 SQL을 실행할 수 있는 DB Client이다.
- 테스트를 편하게, 요청을 편하게 하기 위해서 사용한다.
- DBeaver 설치하고 Render DB에 연결해준다.
DBeaver 설치
1. https://dbeaver.io/download/ 접속
2. [Community Edition] 다운로드 (무료) - macOS: DMG 파일 - Windows: installer 파일
3. 설치 후 실행
RenderDB 연결경 설정



Render.com안에 있는 정보로 DBeaver을 세팅해준다
EXTERNAL DB URL: postgresql://postgres_test_urae_user:tFEJiLuTQnbbN0J9g8xIMF7YNSz8ExMA@dpg-d80n4omgvqtc73dmoj1g-a.singapore-postgres.render.com/postgres_test_urae
여기중에
HOST: dpg-d80n4omgvqtc73dmoj1g-a.singapore-postgres.render.com
관계형 DB란
1. 관계형 DB

테이블끼리 관계로 연결할 수 있기 때문에 관계형 DB이다.
2. SQL이란

SQL 명령어

DDL - 테이블 만들기

이때, SQL에서는 여러가지 데이터 타입을 사용한다.


보통은 date말고 timestamp를 관리한다.
제약조건 (Constraints)
데이터베이스 테이블에 불필요하거나 잘못된 데이터가 입력되는 것을 방지하여,
데이터의 정확성과 일관성(무결성)을 보장하기 위해 컬럼에 설정하는 제한 규칙

데이터 CRUD
이터를 넣고(Insert), 조회하고(Select), 수정하고(Update), 삭제하는(Delete) 4가지 핵심 SQL
1. INSERT

2. SELECT (콜럼들) FROM (테이블명) WHERE (조건)

3. UPDATE - 데이터 수정하기

4. DELETE - 데이터 삭제하가

조건과 정렬
- 비교 연산자, AND, OR 모두 사용 가능
- is null / is not null
- between 2 and 4
SELECT * FROM posts
WHERE user_id >= 2 AND user_id <= 4;
- in (1,3)
SELECT * FROM users
WHERE user_id = 1 OR user_id = 3
- like 패턴

- ORDER BY 기준 ASC/DESC

- LIMIT - 개수제한

Primary Key


Foreign Key


참조 무결성
- 데이터의 일관성을 DB 차원에서 보장하는 핵심 메커니즘
1. FK가 항상 유효한 PK를 기라켜야한다
2. FK가 가리키는 PK는 함부로 삭제되어서는 안된다.

ON DELETE - 삭제 시 동작 설정

CREATE TABLE posts (
id SERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
content TEXT,
user_id INTEGER REFERENCES users(id) ON DELETE SET NULL
-- NOT NULL 제거! (NULL이 들어갈 수 있어야 하므로)
);
SET NULL위치에 RESTRICT, CASCADE 등등 넣을 수 있다.
JOIN
두 테이블을 연결해서 한번에 조회하는 방법이다.
두 번 테이블을 조회하는것은 비효율적이니 JOIN으로 한번에 가져오는 것이다.
Inner Join
한 테이블의 primary key와 다른 테이블의 foreign key가 어떻게 연결됐는지 말해주며
Inner Join을 한다.
Inner Join은 양쪽 테이블에 모두 있는 데이터만 조회한다.
SELECT *
FROM posts
INNER JOIN users ON posts.user_id = users.id;
-- Alice의 게시물만 조회
SELECT p.title, p.content, u.name
FROM posts p
INNER JOIN users u ON p.user_id = u.id
WHERE u.name = 'Alice';
From문에 먼저 나온 테이블이 벤다이어그램의 왼쪽 테이블이고
Join문에 나중에 나온 테이블이 벤다이어그램의 오른쪽 테이블이다.

Left Join
왼쪽 (from)테이블의 모든 데이터이 나오되
오른쪽에 관련 데이터가 없으면 Null처리를 한다.

SELECT u.name, p.title
FROM users u
LEFT JOIN posts p ON u.id = p.user_id;
SELECT u.name, u.email
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
WHERE p.id IS NULL;
반대로 작동하는 Right Join도 존재하긴 하는데, 실무에서는 쓸일이 없다.
애초에 보고싶은 대상을 From문에 두기 때문에
Right Join을 할 이유가 없다
집계
데이터를 세고, 더하고, 평균내는 집계 함수들이 존재한다.
Group By로 그룹별 통계를 내고 Having으로 그룹을 필터링한다.
Select문에다가 넣어준다.
Count - 개수 세기
SELECT COUNT(*) FROM posts;
SELECT COUNT(*) FROM posts p
WHERE p.id >= 3;
Sum - 합계 내기
-- 전체 재고 합계
SELECT SUM(stock) FROM products;
-- 결과: 210
-- 전체 상품 가격 합계
SELECT SUM(price) FROM products;
-- 결과: 1739000
Avg - 평균 내기
-- 평균 상품 가격
SELECT AVG(price) FROM products;
-- 결과: 347800
-- 소수점 정리
SELECT ROUND(AVG(price)) FROM products;
-- 결과: 347800
Min / Max - 최솟값 / 최댓값
-- 가장 비싼 상품 가격
SELECT MAX(price) FROM products;
-- 결과: 1200000
-- 가장 싼 상품 가격
SELECT MIN(price) FROM products;
-- 결과: 35000
GROUP BY - 그룹별 집계
SELECT 그룹기준컬럼, 집계함수
FROM 테이블
GROUP BY 그룹기준컬럼;
SELECT user_id, COUNT(*) AS post_count
FROM posts
GROUP BY user_id;
-- 결과:
-- user_id | post_count
-- 1 | 3
-- 2 | 1
-- 3 | 1
-- 4 | 1
-- user_id 대신 이름으로 보기
SELECT u.name, COUNT(p.id) AS post_count
FROM users u
LEFT JOIN posts p ON u.id = p.user_id
GROUP BY u.name
ORDER BY post_count DESC;
-- 결과:
-- name | post_count
-- Alice | 3
-- Bob | 1
-- Carol | 1
-- Dave | 1
-- Eve | 0 ← LEFT JOIN이라 0으로 나옴
HAVING - 그룹 필터링
WHERE = 개별 행을 필터링 (GROUP BY 전에 적용)
HAVING = 그룹을 필터링 (GROUP BY 후에 적용)
-- 게시물을 2개 이상 쓴 사용자
SELECT u.name, COUNT(p.id) AS post_count
FROM users u
INNER JOIN posts p ON u.id = p.user_id
GROUP BY u.name
HAVING COUNT(p.id) >= 2;
-- 결과:
-- name | post_count
-- Alice | 3
-- id가 2 이상인 게시물 중에서 (WHERE)
-- 사용자별로 묶고 (GROUP BY)
-- 게시물 수가 1개 초과인 사용자만 (HAVING)
SELECT u.name, COUNT(p.id) AS post_count
FROM users u
INNER JOIN posts p ON u.id = p.user_id
WHERE p.id >= 2
GROUP BY u.name
HAVING COUNT(p.id) > 1;
-- 실행 순서:
-- 1. FROM/JOIN → 테이블 합치기
-- 2. WHERE → 행 필터링
-- 3. GROUP BY → 그룹 만들기
-- 4. HAVING → 그룹 필터링
-- 5. SELECT → 컬럼 선택
-- 6. ORDER BY → 정렬
RRISMA 사용하기
Prisma는 ORM (Object-Relational Mapping)
쉽게 말하면:DB의 테이블(Relational)을 코드의 객체(Object) 처럼 다루게 해주는 기술.
예를 들어 SQL 직접 쓰면:
SELECT * FROM users WHERE id = 1;
ORM 쓰면:
User.findById(1);
이렇게 객체/메서드 형태로 다룸.
ex) Prisma, Sequelize
프로젝트 초기화
# 프로젝트 폴더 생성
mkdir my-prisma-api
cd my-prisma-api
# package.json 생성
npm init -y
# Express와 Prisma 설치
npm install express
npm install -D prisma@6
npm install @prisma/client@6
# 개발 도구
npm install -D nodemon dotenv
# Prisma 초기화 (PostgreSQL) : 우린 PostgreSQL 사용!
# prisma 의 기본 proivder는 PostgreSQL 이므로 datasource-provider 생략가능
npx prisma init
# 또는
npx prisma init --datasource-provider postgresql
# 또는 SQLite
npx prisma init --datasource-provider sqlite
# 또는 MySQL
npx prisma init --datasource-provider mysql
1. schema.prisma안의 client에
output 지우기, provider은 "prisma-client-js"
2. prisma.config.ts 없애기
3. env 안의 database_url을 render.com의 external url을 붙이고 뒤에 ?schema=public을 붙여준다.
모델 세팅하기
schema.prisma에 보통 모델을 세팅한다.
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("users")
}
- primary key: @id로 설정
- unique: @unique로 설정
- ?: null 허용 (디폴트는 not null)
- default: @default(기본값)으로 설정
- now(): 여전히 역시 now()이다.
- @updateAt 사용시 자동 업데이트된다.
- 이름 컨벤션 바꾸기: Prisma 모델 이름과 실제 DB 테이블 이름을 다르게 매핑할때 사용한다.
createdAt과 updatedAt은 거의 템플릿이다.
모델을 DB에 반영하려면
npx prisma db push를 하면 된다.

db push는 빠르고 편하지만, "어떤 변경을 했는지" 이력이 안 남아요.
운영 환경에선 변경 이력 관리가 필수입니다.
그래서 다음 챕터에서 migrate dev를 배우고, 이후 모든 실습은 migrate dev로 진행합니다.
Index
검색 속도의 핵심이다.


필요한 부위만 index를 붙여주는 것이 중요하다.
조건으로 자주 등장하는 컬럼들에게 붙여주면 된다.
인덱스 설정은 다음과 같게 한다.
model User {
id Int @id @default(autoincrement())
age Int
city String
@@index([age]) // age 컬럼 하나에만 인덱스
}
model User {
id Int @id @default(autoincrement())
age Int
city String
@@index([age, city]) // age + city 묶어서 한 인덱스
}
복합 인덱스는 두 컬럼을 동시에 자주 검색할때 사용한다.
SELECT * FROM users WHERE age = 30 AND city = '서울';
다만, 뒤에있는 컬럼 만으로는 index가 사용 안되니, 복합 인덱스의 컬럼 순서가 중요하다.
@@index([age, city])로 만들면:
✅ WHERE age = 30 AND city = '서울' → 빠름
✅ WHERE age = 30 → 빠름 (앞 컬럼) ❌
WHERE city = '서울' → 인덱스 못 씀! (뒤 컬럼만으론 못 찾음)
→ 자주 같이 쓰는 조건의 컬럼 순서를 기준으로 정하세요!
- 필드레벨 속성은 @을 사용한다.
- 모델레벨 속성은 @@을 사용한다.
Enum
칼럼의 타입을 개발자가 지정해주는 방식이다.
// Enum 정의
enum Role {
USER
ADMIN
MODERATOR
}
enum Status {
ACTIVE
INACTIVE
SUSPENDED
}
model User {
id Int @id @default(autoincrement())
email String @unique
name String?
role Role @default(USER)
status Status @default(ACTIVE)
createdAt DateTime @default(now())
}
마이그레이션
이제부터 모든 실습은 migrate dev로 진행할 것이다.
db 변경 이력을 남기기 위해 사용한다.

npx prisma migrate dev --name init
- prisma -> SQL를 어떻게 바꿨는지
- 어떤 변경사항을 각 단계에 줬었는지
등등을 기록한다.

이제부터 테이블 수정할때마다
$ npx prisma migrate dev
을 하고 커밋 내역을 적어주면 된다.



Prisma Client 설정
src/lib/prisma.js에서
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
export default prisma;
을 해주면 된다.
Seeding
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
// 기존 데이터 삭제
await prisma.product.deleteMany();
await prisma.user.deleteMany();
console.log('기존 데이터 삭제 완료');
// 사용자 생성
const users = await prisma.user.createMany({
data: [
{
email: 'alice@example.com',
name: 'Alice'
},
{
email: 'bob@example.com',
name: 'Bob'
}
]
});
console.log(`${users.count}명 사용자 생성`);
// 상품 생성
const products = await prisma.product.createMany({
data: [
{
name: '노트북',
description: '고성능 노트북',
price: 1200000,
stock: 10,
category: 'electronics',
isPublished: true
},
{
name: '마우스',
description: '무선 마우스',
price: 30000,
stock: 50,
category: 'electronics',
isPublished: true
},
{
name: '키보드',
description: '기계식 키보드',
price: 80000,
stock: 30,
category: 'electronics',
isPublished: true
},
{
name: '모니터',
description: '27인치 모니터',
price: 350000,
stock: 15,
category: 'electronics',
isPublished: false
}
]
});
console.log(`${products.count}개 상품 생성`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
이러고 package.json에서
scripts에 seed를 추가해주고 (node prisma/seed.js)를 해주고
prisma에도 seed (node prisma/seed.js)를 추가해줘야한다
{
"scripts": {
"dev": "nodemon src/server.js",
"seed": "node prisma/seed.js"
},
"prisma": {
"seed": "node prisma/seed.js"
}
}
# 수동 실행
npm run seed
# 마이그레이션 후 자동 실행
npx prisma migrate reset
# 또는
npx prisma db seed
seed를 대량으로 많이 넣어주고 싶으면 faker 라이브러리를 사용하는 것이 좋다.
근데 요새는 AI한테 시드 데이터 만들어달라고 한다 한다. 좀 더 sementic한 테스트 데이터를 주기 떄문이다.
컨트롤러 만들고 Prisma Client로 CRUD 생성
왜 컨트롤러 파일을 따로 만드나요?
server.js 한 파일에 모든 로직을 넣으면 코드가 금방 200줄, 300줄로 늘어나서 관리가 힘들어집니다.
그래서 "라우팅은 server.js에서, 실제 동작은 controllers 폴더에서" 책임을 나누는 게 백엔드의 기본 관례예요.
Create
// 기본 사용법
const product = await prisma.product.create({
data: {
name: '에어팟',
description: '무선 이어폰',
price: 250000,
stock: 20,
category: 'electronics'
}
});
// 여러 개 한 번에 생성: createMany
const result = await prisma.product.createMany({
data: [
{ name: '상품A', price: 1000, category: 'electronics' },
{ name: '상품B', price: 2000, category: 'electronics' }
],
skipDuplicates: true // 중복 건너뛰기
});
// 반환: { count: 2 }
Read
// 모든 데이터 조회
const products = await prisma.product.findMany();
// 고유 식별자(PK, UNIQUE)로 1개 조회
const product = await prisma.product.findUnique({
where: { id: 1 }
});
// 조건에 맞는 첫 번째 1개 조회 (정렬 가능)
const product = await prisma.product.findFirst({
where: { isPublished: true },
orderBy: { createdAt: 'desc' }
});
Update
// 1개 수정
const product = await prisma.product.update({
where: { id: 1 },
data: { price: 12000 }
});
// 조건에 맞는 여러 개 수정
const result = await prisma.product.updateMany({
where: { category: 'electronics' },
data: {
price: { multiply: 0.9 } // 10% 할인
}
});
// 반환: { count: 5 }
// upsert: 있으면 수정, 없으면 생성
const product = await prisma.product.upsert({
where: { id: 1 },
update: { price: 15000 },
create: { name: '신상품', price: 15000, category: 'electronics' }
});
Delete
// 1개 삭제
await prisma.product.delete({
where: { id: 1 }
});
// 조건에 맞는 여러 개 삭제
const result = await prisma.product.deleteMany({
where: { category: 'discontinued' }
});
// 반환: { count: 3 }
삭제나 수정하려는 녀석이 없는 녀석이면 "P2025"에러 코드가 나타난다.
Prisma로 쿼리 파라미터 처리하기
//BETWEEN A AND B
const where = {};
if (minPrice || maxPrice) {
where.price = {};
if (minPrice) {
where.price.gte = parseFloat(minPrice);
}
if (maxPrice) {
where.price.lte = parseFloat(maxPrice);
}
}
const products = await prisma.product.findMany({
where
});
//ORDER BY (정렬)
const products = await prisma.product.findMany({
orderBy: {
[sortBy]: order
}
});
//LIKE (포함)
const { search } = req.query;
const where = {};
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } }
];
}
const products = await prisma.product.findMany({
where
});
//LIMIT(제한), OFFSET(스킵)
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 10;
const skip = (page - 1) * limit;
const [products, total] = await Promise.all([
prisma.product.findMany({
skip,
take: limit,
orderBy: { createdAt: 'desc' }
}),
prisma.product.count()
]);
보통 query를 어떻게 조건으로 변형하냐면
where이라는 빈 객체를 정의해두고
거기서 이제 query에 따라서 조건을 하나씩 추가해주는 형식이다.
export const getAllProducts = async (req, res) => {
try {
const {
category,
minPrice,
maxPrice,
search,
isPublished,
sortBy = 'createdAt',
order = 'desc',
page = 1,
limit = 10
} = req.query;
// where 조건
const where = {};
if (category) {
where.category = category;
}
if (isPublished !== undefined) {
where.isPublished = isPublished === 'true';
}
if (minPrice || maxPrice) {
where.price = {};
if (minPrice) where.price.gte = parseFloat(minPrice);
if (maxPrice) where.price.lte = parseFloat(maxPrice);
}
if (search) {
where.OR = [
{ name: { contains: search, mode: 'insensitive' } },
{ description: { contains: search, mode: 'insensitive' } }
];
}
// 페이지네이션
const skip = (parseInt(page) - 1) * parseInt(limit);
// 데이터 조회
const [products, total] = await Promise.all([
prisma.product.findMany({
where,
orderBy: { [sortBy]: order },
skip,
take: parseInt(limit)
}),
prisma.product.count({ where })
]);
res.json({
success: true,
page: parseInt(page),
limit: parseInt(limit),
total,
totalPages: Math.ceil(total / parseInt(limit)),
filters: { category, minPrice, maxPrice, search, isPublished },
data: products
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message
});
}
};
// URL: /api/products?category=electronics&minPrice=50000&search=노트북&sortBy=price&order=asc&page=1&limit=20
(+) 추가 기능으로 Select, Include, Distinct, Aggregate, GroupBy도 존재한다.
최적화를 조금 더 하고 싶으면 원시 쿼리를 시도해보면 좋다 (물론 아닐때도 많음)
const result = await prisma.$executeRaw`
UPDATE products
SET stock = stock + 10
WHERE category = 'electronics'
`;
유효성 검사
유효성 검사 여러개를 거쳐야지만 db에 데이터를 추가하는 형태이다.
export const createProduct = async (req, res) => {
try {
const { name, description, price, stock, category } = req.body;
// 검증
const errors = [];
if (!name || name.trim().length === 0) {
errors.push({ field: 'name', message: '상품명은 필수입니다' });
}
if (!price || price <= 0) {
errors.push({ field: 'price', message: '가격은 0보다 커야 합니다' });
}
{...}
if (errors.length > 0) {
return res.status(400).json({
success: false,
errors
});
}
// 데이터 생성
const product = await prisma.product.create({
data: { name, description, price, stock, category }
});
res.status(201).json({
success: true,
data: product
});
} catch (error) {
res.status(500).json({
success: false,
message: error.message
});
}
};
검사 코드가 한 함수에 너무 많아진다.
여러 API에서 같은 검사를 반복하는 느낌도 있고
그래서 ZOD같은 라이브러리로 선언적으로 쓰는게 좋다.
유효성 검사 schema를 만들어줘야하는데 이런 형식으로 작성된다.
import { z } from 'zod';
export const createProductSchema = z.object({
name: z.string().min(1, '상품명은 필수입니다').max(100, '상품명은 100자 이하여야 합니다'),
description: z.string().max(500, '설명은 500자 이하여야 합니다').optional(),
price: z.number().positive('가격은 양수여야 합니다'),
stock: z.number().int().nonnegative('재고는 0 이상이어야 합니다').default(0),
category: z.enum(['electronics', 'clothing', 'books', 'food'], {
errorMap: () => ({ message: '올바른 카테고리를 선택해주세요' })
}),
isPublished: z.boolean().default(false)
});
이러면 예전 예시를 이렇게 줄일 수 있다.
// 검증
const validatedData = createProductSchema.parse(req.body);
// 데이터 생성
const product = await prisma.product.create({
data: validatedData
});
에러 처리
지금까지는 모든 controller에 try-catch를 해서 redundant했다.
그래서 공통적인 에러처리를 해주는 asyncHandler을 만들어주는것이 통상적이다.
고차함수 (매개변수로 함수를 받아 실행 || 함수를 리턴) 형태로 만든다.
export const asyncHandler = (fn) => {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
};

error을 catch할때
1. 우리가 던진 HTTP 에러 (4xx~5xx 에러들)를 먼저 catch하고
2. Prisma가 던진 에러들을 catch한다
P2025 Record not found → 404 (수정/삭제할 대상이 없을 때)
P2002 Unique constraint violation → 409 (중복된 email, name 등)
P2003 Foreign key constraint → 400 (없는 userId 로 Todo 생성 시도 등)
P2014 Required relation violation → 400 (필수 관계 위반)
P2021 Table does not exist → 500 (마이그레이션 안 됐을 때 — 설정 문제)
📖 전체 코드: https://www.prisma.io/docs/orm/reference/error-reference
import { Prisma } from '@prisma/client';
import { HttpError } from './errors.js';
// asyncHandler() 의 실행결과는??
// const asyncHandler = (fn) => async () => {}
app.get("/todos", asyncHanlder(createTodo));
export const asyncHandler = (handler) => async (req, res, next) => {
try {
// 진짜 컨트롤러 실행
await handler(req, res, next);
} catch (error) {
// 1) 우리가 직접 던진 HTTP 에러 (NotFoundError 등)
if (error instanceof HttpError) {
return res.status(error.statusCode).json({
success: false,
message: error.message
});
}
// 2) Prisma 가 던진 알려진 에러 (코드별 매핑)
if (error instanceof Prisma.PrismaClientKnownRequestError) {
// P2025: 찾을 행이 없음
if (error.code === 'P2025') {
return res.status(404).json({
success: false,
message: '리소스를 찾을 수 없습니다'
});
}
// P2002: UNIQUE 제약 위반
if (error.code === 'P2002') {
return res.status(409).json({
success: false,
message: '이미 존재하는 데이터입니다',
field: error.meta?.target
});
}
// P2003: 외래 키 제약 위반
if (error.code === 'P2003') {
return res.status(400).json({
success: false,
message: '참조 무결성 제약 조건 위반'
});
}
}
// 3) Prisma validation 에러 (타입 불일치 등)
if (error instanceof Prisma.PrismaClientValidationError) {
return res.status(400).json({
success: false,
message: 'Prisma validation 에러',
detail: error.message.split('\n').slice(-2).join(' ')
});
}
// 4) 그 외 알 수 없는 에러 → 서버 로그에 전체 남기고, 사용자에겐 500
console.error(error);
res.status(500).json({
success: false,
message: '서버 에러가 발생했습니다'
});
}
};
커스텀 에러 클래스
커스텀 에러 클래스도 만들 수 있다.
Error로 모든 에러를 처리하기에는 나중에 디버깅할때 가시성이 떨어질 수 있다.
그래서 특정 타입의 에러들을 묶는, 하위 에러 타입들을 커스텀으로 만들 것이다.
// 모든 HTTP 에러의 부모 클래스
export class HttpError extends Error {
constructor(statusCode, message) {
super(message);
this.statusCode = statusCode;
}
}
// 자주 쓰는 상태코드는 미리 클래스로 만들어둠
export class NotFoundError extends HttpError {
constructor(message = '리소스를 찾을 수 없습니다') {
super(404, message);
}
}
export class BadRequestError extends HttpError {
constructor(message = '잘못된 요청입니다') {
super(400, message);
}
}
export class UnauthorizedError extends HttpError {
constructor(message = '인증이 필요합니다') {
super(401, message);
}
}
전체적 에러 헨들링 컨트롤러
import prisma from '../lib/prisma.js';
import { asyncHandler } from '../utils/asyncHandler.js';
import { NotFoundError } from '../utils/errors.js';
// CREATE
export const createTodo = asyncHandler(async (req, res) => {
const todo = await prisma.todo.create({ data: req.body });
res.status(201).json({ success: true, data: todo });
});
// READ - 단건
export const getTodo = asyncHandler(async (req, res) => {
const todo = await prisma.todo.findUnique({
where: { id: Number(req.params.id) }
});
if (!todo) throw new NotFoundError('Todo를 찾을 수 없습니다');
res.json({ success: true, data: todo });
});
// UPDATE
// → 없는 id 로 수정 시도하면 Prisma 가 P2025 던짐
// → asyncHandler 가 자동으로 404 로 변환해서 응답해줌
// → 우리는 P2025 분기 처리 안 적어도 됨!
export const updateTodo = asyncHandler(async (req, res) => {
const todo = await prisma.todo.update({
where: { id: Number(req.params.id) },
data: req.body
});
res.json({ success: true, data: todo });
});
// DELETE
export const deleteTodo = asyncHandler(async (req, res) => {
await prisma.todo.delete({
where: { id: Number(req.params.id) }
});
res.json({ success: true, message: 'Todo가 삭제되었습니다' });
});
추가 리팩토링
지금은 asyncHandler 안에 try-catch와 에러별 응답 매핑을 한꺼번에 모았다.
나중에 middleware을 배우면
- asyncHandler는 try-catch만 담당 (catch한 에러를 next(error)로 넘김)
- 별도의 errorHandler 미들웨어가 에러 종류별 응답을 담당
- app.use(errorHandler)로 등
Prisma로 데이터 모델간의 관계 정의하기
1. ERD 먼저 설계하기
관계형 DB는 사실 설계가 절반 이상이다.
명세서를 보고, 이 도메인은 어떤 엔티티가 있을지, 어떤 관계로 연결될지 먼저 그려보면 코드가 깔끔해진다.
ERD는 여러 스키마 중 개념적 스키마이다.
Schema = 데이터베이스의 설계 도면 (어떤 데이터를, 어떻게 저장하고, 어케 연결할지 표현)
- 개념적 스키마의 ERD: draw.io사용
- 논리적 스키마와 물리적 스키마: prisma 사용


❌코드부터 쓰면:
1. 필드 추가 → 나중에 관계 돌아보니 잘못 설계
2. 다시 스키마 수정 → 마이그레이션 엉켜서 꼬임
3. 팀원과 "너 왜 이렇게 설계했어?" 소통 어려움
✅ ERD 먼저 그리면:
1. 팀원에게 로직 설명하면서 문제점 발견
2. 관계가 명확해져서 필드 설계 쉬움
3. 스키마 작성 = ERD를 그대로 옮기기
1) 명세서에서 등장하는 명사중에 관리해야하는 엔티티 추출


메인 엔티티: User/ Todo / Tag
- 시스템 기능 대부분이 이 엔티티 중심으로 돌아감
- CRUD 직접 많이 함
- 서로 관계를 맺는 중심축 역할
- 독립적으로 존재 가능
비메인 엔티티: Profile
- Profile의 종속 객체이자 관계 설명(부가정보)용 객체
- 선택적으로만 존재 가능
- 단독으로 의미가 약함
2) 엔티티 간의 관계 종류 파악하기
[ User - Todo ]
- User은 Todo를 여러개 가짐
- Todo는 User 하나만 가질 수 있음
=> N : 1관계
[ Todo - Tag ]
- Todo는 Tag 여러개 가짐
- Tag는 여러 Todo 가짐
=> N : M 관계
[ User - Profile ]
- User은 Profile 하나만 가짐
- Profile은 User 하나만 가짐
=> 1 : 1 관계

2. ERD 그리기
ERD 툴은 draw.io를 권장한다.

3. Prisma로 테이블간 관계 정의하기

N:M은 한쪽이 지워진다 해도 다른 애가 지워지지 않는다.
각각 독립적이면서 그냥 서로 엮여 있다.
ex) Post - Tag (Post를 지워도 Tag는 지워지지 않는다)
1. 1:1 관계 정의하기
- 분리관계: 원래 한 테이블이었어도 됐는데 일부러 쪼갰다


select *과 select id, email, password하는것 비교하면 select *이 연산이 더 적어서 빠르다.
이래서 자주쓰는 칼럼과 가끔쓰는 칼람을 분리해서 사용하는 것이다.
model User {
id Int @id @default(autoincrement())
name String
profile Profile? // ← 1:1, 선택적 (Profile 없는 User도 가능)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users")
}
model Profile {
id Int @id @default(autoincrement())
bio String?
avatarUrl String?
userId Int @unique // ← 1:1의 핵심: @unique 가 붙음
user User @relation(fields: [userId], references: [id])
@@map("profiles")
}

⭐seed data 넣는 방법은 다음과 같다.
const alice = await prisma.user.create({
data: {
name: 'Alice',
profile: {
create: {
bio: '풀스택 개발 공부 중',
avatarUrl: 'https://example.com/alice.png'
}
}
},
include: { profile: true }
});
// 결과:
// { id: 1, name: 'Alice', profile: { id: 1, bio: '...', avatarUrl: '...', userId: 1 } }
⭐관계 데이터를 조회하는 방법은 다음과 같다.
const user = await prisma.user.findUnique({
where: { id: 1 },
include: { profile: true }
});
// 결과:
// { id: 1, name: 'Alice', profile: { bio: '...', avatarUrl: '...' } }
// Profile 이 없는 사용자는?
// { id: 2, name: 'Bob', profile: null }
2. 1:N 관계 정의하기
- 종속 관계: 한쪽이 한쪽을 표함하는 관게이다.
- 가장 흔한 관계이다.
- "한쪽이 사라지면 다른 쪽도 사라져야 하는가?" -> Yes: 1:N 종속이며 CASCADE DELETE 후


ex) 한 User이 여러 Todo를 가진다.
model User {
id Int @id @default(autoincrement())
name String
todos Todo[] // ← 새로 추가: "이 User는 여러 Todo를 가진다"
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("users")
}
model Todo {
id Int @id @default(autoincrement())
title String
content String?
isDone Boolean @default(false)
userId Int // ← 새로 추가: 외래 키
user User @relation(fields: [userId], references: [id]) // ← 새로 추가: 관계 정의
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("todos")
}
User의 todos Todo[]는
- 이 User는 여러 Todo(Todo[])를 소유한다를 표현함
- 실제 DB 테이블에서는 이 컬럼이 안 생긴다. (가상 필드)
- 코드에서 prisma.user.findUnique({ include: { todos: true } }) 쓸때 필요하다.
⭐ 타입에 기본타입/Enum 외의 테이블이 들어갈 시에는 둘 사이의 관계성만 표시하고 실제 컬럼은 안 생긴다.
Todo의 user User @relation(fields:[userId], references:[id])는
- 이 Todo는 하나의 User을 소유한다를 표현함
- 이 줄 자체는 컬럼을 만들지 않는다.
대신, 연결 고리가 될 외래 키를 정의하는 역할을 한다.
- Todo-User 사이의 연결고리인 외래키의 이름이 userId (fields: [userId]로 정의)
참조하는 원래의 primary key이름이 id (references: [id]로 정의)
- foreign key는 "힘이 약한 1쪽이 정의"
Todo의 userId Int는
⭐ 타입에 기본타입이 들어가니 새로운 컬럼이 생성됨 (얘가 바로 외래키!)
이렇게 하고 npx prisma migrate dev하고 생성되는 sql문을 확인해보면 다음과 같다.
-- AlterTable
ALTER TABLE "todos" ADD COLUMN "userId" INTEGER NOT NULL;
-- AddForeignKey
ALTER TABLE "todos"
ADD CONSTRAINT "todos_userId_fkey"
FOREIGN KEY ("userId") REFERENCES "users"("id")
ON DELETE RESTRICT ON UPDATE CASCADE;
⭐seed data 넣는 방법은 다음과 같다.
- 각 데이터 안에 foreign key 추
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
await prisma.todo.deleteMany();
await prisma.user.deleteMany();
// 사용자 먼저 생성 (하나씩, id를 알아내기 위해)
const alice = await prisma.user.create({ data: { name: 'Alice' } });
const bob = await prisma.user.create({ data: { name: 'Bob' } });
// Todo 생성 (userId 필수!)
await prisma.todo.createMany({
data: [
{ title: '우유 사오기', content: '저지방 1L', isDone: false, userId: alice.id },
{ title: 'Prisma 공부하기', content: '[3] 챕터까지 끝내기', isDone: false, userId: alice.id },
{ title: '운동하기', content: '30분 조깅', isDone: true, userId: alice.id },
{ title: '이메일 확인', isDone: true, userId: bob.id }
]
});
console.log('시드 완료');
}
main()
.catch(e => { console.error(e); process.exit(1); })
.finally(() => prisma.$disconnect());
⭐관계 데이터를 조회하는 방법은 다음과 같다.
- include : { relatedTableName: true }
- JOIN을 명시적으로 수행하는 것과 같다.
const todos = await prisma.todo.findMany({
include: { user: true } // 각 Todo 안에 user 정보도 포함
});
const users = await prisma.user.findMany({
include: { todos: true }
});
2. N:M 관계 정의하기
연결관계: 양쪽이 독립적으로 존재하면서 서로 엮여 있다.
⭐무조건 중간테이블(연결테이블)이 필요하다.
- 중간 테이블에 관계 엔티티 (추가 속성)이 붙으면 N:M이다
- 한쪽을 지워도 남은 애가 존재하면 N:M이다.


ex) 한 Todo에 여러 Tag가 붙고, 한 Tag도 여러 Todo에 공유된다.
model Todo {
id Int @id @default(autoincrement())
title String
content String?
isDone Boolean @default(false)
tags Tag[] // ← 새로 추가: "이 Todo는 여러 Tag를 가진다"
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("todos")
}
model Tag {
id Int @id @default(autoincrement())
name String @unique
todos Todo[] // ← "이 Tag는 여러 Todo에 붙는다"
@@map("tags")
}
- 보면 타입에 테이블배열에 들어가 있는 줄만 추가되어 있다.
기억하다시피, 이런 경우에는 딱히 column이 추가되는건 아니고
두 테이블이 연관되어 있다는 것을 알리고 어떤 연관인지 정의하는 줄이다.
- M:N은 딱히 foreign key를 생성하지 않는다.
Prisma가 자동으로 중간 테이블을 관리해주기 때문이다.
양쪽 배열 필드로 끝이다.

이렇게 하고 npx prisma migrate dev하고 생성되는 sql문을 확인해보면 다음과 같다.
-- CreateTable Tag
CREATE TABLE "tags" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
CONSTRAINT "tags_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "tags_name_key" ON "tags"("name");
-- CreateTable 중간 테이블 (Prisma가 자동 생성!)
CREATE TABLE "_TodoToTag" (
"A" INTEGER NOT NULL,
"B" INTEGER NOT NULL
);
ALTER TABLE "_TodoToTag"
ADD CONSTRAINT "_TodoToTag_A_fkey" FOREIGN KEY ("A") REFERENCES "todos"("id") ON DELETE CASCADE,
ADD CONSTRAINT "_TodoToTag_B_fkey" FOREIGN KEY ("B") REFERENCES "tags"("id") ON DELETE CASCADE;
⭐seed data 넣는 방법은 다음과 같다.
- tags: { connect: [ { id: tagHome.id } ] }
async function main() {
await prisma.todo.deleteMany();
await prisma.user.deleteMany();
await prisma.tag.deleteMany();
// 1) 사용자
const alice = await prisma.user.create({ data: { name: 'Alice' } });
const bob = await prisma.user.create({ data: { name: 'Bob' } });
// 2) 태그 먼저 생성
const tagHome = await prisma.tag.create({ data: { name: '집안일' } });
const tagStudy = await prisma.tag.create({ data: { name: '공부' } });
const tagHealth = await prisma.tag.create({ data: { name: '건강' } });
// 3) Todo 생성 + tags 연결 (connect 문법)
await prisma.todo.create({
data: {
title: '우유 사오기',
userId: alice.id,
tags: { connect: [{ id: tagHome.id }] }
}
});
await prisma.todo.create({
data: {
title: 'Prisma 공부하기',
userId: alice.id,
tags: { connect: [{ id: tagStudy.id }] }
}
});
await prisma.todo.create({
data: {
title: '운동하기',
isDone: true,
userId: alice.id,
tags: { connect: [{ id: tagHealth.id }, { id: tagHome.id }] } // 태그 2개 동시에
}
});
await prisma.todo.create({
data: { title: '이메일 확인', isDone: true, userId: bob.id } // 태그 없이 생성도 가능
});
console.log('시드 완료');
}
⭐관계 데이터를 조회하는 방법은 다음과 같다.
- include: { tags: true, user: true }
const tag = await prisma.tag.findUnique({
where: { name: '공부' },
include: { todos: true }
});
const todos = await prisma.todo.findMany({
include: { tags: true, user: true }
});
// 결과:
// [
// {
// id: 1, title: '우유 사오기', isDone: false,
// user: { id: 1, name: 'Alice' },
// tags: [ { id: 1, name: '집안일' } ]
// },
// {
// id: 3, title: '운동하기', isDone: true,
// user: { id: 1, name: 'Alice' },
// tags: [ { id: 3, name: '건강' }, { id: 1, name: '집안일' } ]
// },
// ...
// ]
실습 과제 - 동네 도서 대출 서비스
요구사항
- 회원은 이름, 이메일, 전화번호를 등록한다
- 도서는 제목, 저자, ISBN, 출판연도 정보를 가진다
- 회원이 도서를 빌리면 대출일과 반납 예정일이 기록된다
- 반납하면 실제 반납일이 기록된다
- 한 회원이 여러 책을 빌릴 수 있고, 한 책이 여러 회원에게 빌려질 수 있다 (시점이 다르면)
설계 조건
- PK, FK 명시
- NOT NULL이 필요한 곳에 제약조건 추가
제출자료 3가지
- 개념적 스키마 캡쳐이미지 (draw.io 사용)
- 논리적 스키마 이미지 (https://prisma-editor.bahumaish.com/ 사용)
- 물리적 스키마 이미지 (DBeaver 사용)
'코드잇 스터디' 카테고리의 다른 글
| [Express] JS로 백엔드 개발 시작하기 (0) | 2026.04.29 |
|---|---|
| [React] 리액트로 웹사이트 만들기 (0) | 2026.04.28 |
| [React] React로 데이터 다루기 (1) | 2026.04.22 |
| [React] 리엑트에 대하여 (0) | 2026.04.21 |
| [JS 기초문법] 리퀘스트 보내기 (0) | 2026.04.14 |