9/13 - 9/17, 9/27 - 9/30
React 프로젝트인 webucks가 필요로 하는 모든 기능을 수행하지는 않지만, CRUD를 연습할 수 있는 대표적인(?) 기능을 넣어 API를 만들었다. RESTful API까지 고려해 URI가 다르지만, 다음 프로젝트에서 백엔드를 수월하게 할 수 있도록 연습하는데 목적을 두고 열싐히... 했다! 에러처리가 아직 많이 미흡하다. 공부하며 보완해나가는걸로!
A부터 Z까지.. 모두 다 처음 배웠지만 다음 프로젝트시 기억하고 있어야 할 것들에 대해 정리해보자.
[ tree 사용 주요 옵션 ]
- -d : 디렉터리만
- -I : 특정 디렉토리 제외
- -L : level 지정 ex)-L 2는 depth2까지만
- -N : 한글이 포함될 때 사용하면 한글 깨지지 않습니다!
model Image {
id Int @id @default(autoincrement())
imageUrl String @map("image_url") @db.VarChar(2000)
productId Int @map("product_id")
product Product @relation(fields: [productId], references: [id])
@@map("images")
}
model User {
id Int @id @default(autoincrement())
email String @unique
password String
username String?
address String?
phone_number String?
policy_agreed Boolean @default(true)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
isDeleted Boolean @default(false) @map("is_deleted")
like Like[]
comment Comment[]
reComment ReComment[]
@@map("users")
}
model ReComment {
id Int @id @default(autoincrement())
contents String
userId Int @map("user_id")
commentId Int @map("comment_id")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime? @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
isDeleted Boolean @default(false) @map("is_deleted")
user User @relation(fields: [userId], references: [id])
comment Comment @relation(fields: [commentId], references: [id])
@@map("re_comments")
}
//.env에 작성한다.
DATABASE_URL="mysql://유저이름:비밀번호@localhost:3306/데이터베이스이름"
PORT = 포트번호
secret="원하는secret key"
//사용법
import dotenv from 'dotenv';
dotenv.config();
const { PORT } = process.env;
app.listen(PORT, () => console.log(`server on ${PORT}`));
댓글 작성이나 회원 정보 수정 등의 기능에는 지금 요청을 보낸 이 사용자가 우리의 user인지!를 인증하는 과정이 필요하다. 정말 많이 사용되는 함수이기 때문에 미들웨어로 만들어 여기저기 가져다 쓸 수 있도록 만들었다.
//valideToken.js
import jwt from 'jsonwebtoken';
import dotenv from 'dotenv';
import { userService } from '../services';
dotenv.config();
const { secret } = process.env;
const validateToken = async (req, res, next) => {
try {
const token = req.headers.cookie;
if (!token) {
return res.status(401).json({
message: 'NOT_LOGGED_IN',
});
}
const decoded = jwt.verify(token.split('=')[1], secret);
const id = decoded.id;
const foundUser = await userService.findUser(id);
if (foundUser === 0) {
return res.status(401).json({
message: 'USER_NOT_FOUND',
});
}
req.decoded = decoded;
//decoded안에 user정보가 담겨있기 때문에 다음 함수에서 꺼내 쓸 수 있다.
next();
} catch (err) {
next(err); //마지막에 app.js에 있는 에러 핸들링 함수가 실행되어 어떤 에러가 났는지 전송된다.
}
};
export default validateToken;
원래는 동사(delete, update 등)로 작성되어 있던 URI를 명사로 수정하고, 알맞은 메서드를 사용하도록 바꾸었다.
router.get('/user', validateToken, userController.getUsers);
router.post('/user/login', userController.loginUser);
router.post('/user', userController.createUser);
router.delete('/user', validateToken, userController.deleteUser);
//router
router.post('/user', userController.createUser);
//controller
const createUser = async (req, res, next) => {
try {
const userData = req.body;
const newUser = await userService.createUser(userData, next);
//마지막 데이터베이스 접근 함수에서 에러를 잡아내기 위해 next를 계속 전달
if (!newUser) {
next(); //app.js의 에러 핸들링 함수 실행
} else {
return res.status(201).json({
message: 'CREATED',
newUser,
});
}
} catch (err) {
next(err); //app.js의 에러 핸들링 함수 실행
}
};
//service
const createUser = async (userData, next) => {
const { password } = userData;
const hash = await bcrypt.hash(password, 10); //암호화
return await userDao.createUser(userData, hash, next);
};
//model
const createUser = async (userData, hash, next) => {
try {
const { email, username, address, phone_number } = userData;
await prisma.$queryRaw`
INSERT INTO
users(
email,
password,
username,
address,
phone_number
)
VALUES (
${email},
${hash},
${username},
${address},
${phone_number}
);
`;
const [newUser] = await prisma.$queryRaw`
SELECT email, username
FROM users
WHERE email=${email};
`;
return newUser;
} catch (err) {
next(err);
}
};
INSERT INTO문은 반환하는 데이터가 없다. 회원가입을 하고 나면 가입한 이메일을 보여주고 성공적으로 가입되었다고 하는 경우를 생각하고 이름과 이메일만 담아 데이터를 반환하도록 만들었다.
에러 핸들링
이메일은 unique key이기 때문에 이미 데이터베이스에 있는 이메일을 입력하게 되면 오류가 난다. 이 에러를 알려주기 위해 next를 model까지 전달해서 app.js에 있는 에러 핸들링 함수가 작동될 수 있도록 했다.app.use((err, req, res, next) => { const { status } = err; console.error(err); res.status(status || 500).json({ message: err.meta.message }); });
console.log를 찍어 보니 오류가 나면 err.meta.message에 그 내용이 담겨있었다. 요청을 보내 확인해보니 응답에는 아래와 같은 메세지가 담겨있다.
{ "message": "Duplicate entry 'dabin219@hohoho.com' for key 'users.users_email_key'" }
https://velog.io/@vraimentres/async-%ED%95%A8%EC%88%98%EC%99%80-try-catch
- 이 블로그 게시글에 따르면 에러가 발생한 뒤의 코드는 실행되지 않고, 발생한 에러는 Promise.reject 처리되어 상위 컨텍스트에서 비동기 에러로 처리된다. 비동기 코드의 결과에 상관 없이 클라이언트에게 응답을 해줘야 한다. 따라서 서비스 로직까지는 비동기에러를 계속 try-catch 없이 보내고, 컨트롤러에선 try-catch를 통해 에러내용을 정리해 응답을 해주면 된다.
https://jeonghwan-kim.github.io/node/2017/08/17/express-error-handling.html
- 이 게시글에 따르면 익스프레스로 에러를 알리는 방법은 세번째 인자로 next()를 사용하는 것이다. 비동기 오류를 보고하려면 반드시 next()를 통과해야 하고, 통과하면 마지막 오류 처리 미들웨어에서 에러를 클라이언트에게 보내줄 것이다.
- 컨트롤러에 try-catch를 통해 오류를 잡는 코드가 계속 반복된다. 이를 효율적으로 하기 위해 wrapper 함수를 사용해 무조건 next()를 통과할 수 있도록 전체 함수를 감싸줄 수 있다.
function wrapAsync(fn) { return function (req, res, next) { // 모든 오류를 .catch() 처리하고 체인의 next() 미들웨어에 전달하세요 // (이 경우에는 오류 처리기) fn(req, res, next).catch(next) } }
//utils.js
const wrapAsync = fn => {
return (req, res, next) => {
fn(req, res, next).catch(next);
};
};
export { wrapAsync };
//Controller
const createUser = wrapAsync(async (req, res) => {
const userData = req.body;
const newUser = await userService.createUser(userData);
res.status(201).json({
message: 'CREATED',
newUser,
});
});
//Service
const createUser = async userData => {
const { email, password } = userData;
const foundUser = await userDao.findUserByEmail(email);
if (foundUser) {
const err = new Error('ALREADY_EXISTED_USER');
err.status = 401;
throw err;
}
const hash = await bcrypt.hash(password, 10);
const newUser = await userDao.createUser(userData, hash);
return newUser;
};
//가입된 이메일이라면??
{
"message": "ALREADY_EXISTED_USER"
}
//router
router.post('user/login', userController.loginUser);
//controller
const loginUser = async (req, res) => {
try {
const { email, password } = req.body;
const token = await userService.loginUser(email, password);
if (!token) {
res.status(401).json('PLEASE_CHECK_EMAIL_OR_PASSWORD');
} else {
res.cookie('user', token, {
httpOnly: true,
});
//쿠키에 토큰을 저장해 나중에 인증을 진행할 때 사용할 수 있도록 했다.
//보안 측면에서 어떻게 저장해야 좋을까? 깊이 공부해보는 시간을 꼭 갖자!!!!
return res.status(200).json({
message: 'SUCCESS',
token,
});
}
} catch (err) {
console.log(err);
}
};
//service
const loginUser = async (email, password) => {
const user = await userDao.loginUser(email);
if (user) {
const validPassword = await bcrypt.compare(password, user.password);
if (validPassword) {
const token = jwt.sign({ id: user.id }, secret, { expiresIn: '7d' });
return token;
}
}
};
//model
const loginUser = async email => {
const [user] = await prisma.$queryRaw`
SELECT
password
FROM
users
WHERE
email=${email};
`;
return user;
};
//password를 반환해야 bcrypt.compare을 할 수 있다.
Update
//router
router.put(
'/product/:id/comment',
validateToken,
productController.updateCommentProduct
);
//내용 전부 업데이트는 PUT메서드를 사용하고, 일부 업데이트는 PATCH를 사용한다.
//controller
const updateCommentProduct = async (req, res) => {
try {
const productId = req.params.id;
const userId = req.decoded.id;
const { comment } = req.body;
await productService.updateCommentProduct(productId, userId, comment);
res.status(200).json({
message: 'UPDATED',
});
} catch (err) {
console.log(err);
}
};
//service
const updateCommentProduct = async (productId, userId, comment) => {
return await productDao.updateCommentProduct(productId, userId, comment);
};
//model
const updateCommentProduct = async (productId, userId, comment) => {
return await prisma.$queryRaw`
UPDATE comments
SET
contents=${comment},
updated_at=now()
WHERE
product_id=${productId}
AND
user_id=${userId};
`;
};
Delete
//router
router.delete(
'/product/:id/comment',
validateToken,
productController.deleteCommentProduct
);
//controller
const deleteCommentProduct = async (req, res) => {
try {
const productId = req.params.id;
const userId = req.decoded.id;
await productService.deleteCommentProduct(productId, userId);
res.status(200).json({
message: 'DELETED',
});
} catch (err) {
console.log(err);
}
};
//service
const deleteCommentProduct = async (productId, userId) => {
return await productDao.deleteCommentProduct(productId, userId);
};
//model
const deleteCommentProduct = async (productId, userId) => {
return await prisma.$queryRaw`
UPDATE comments
SET
deleted_at=now(),
is_deleted=true
WHERE
product_id=${productId}
AND
user_id=${userId}
;`;
};
참조 관계가 다대다 관계라면 새로운 테이블이 필요하다.
//prisma model
model Like {
id Int @id @default(autoincrement())
userId Int @map("user_id")
productId Int @map("product_id")
user User @relation(fields: [userId], references: [id])
product Product @relation(fields: [productId], references: [id])
@@map("likes")
}
//router
router.get('/:id/like', validateToken, productController.likeProduct);
//controller
const likeProduct = async (req, res) => {
try {
const productId = req.params.id;
const userId = req.decoded.id;
const isLiked = await productService.likeProduct(productId, userId);
res.status(200).json({
message: isLiked ? 'LIKED' : 'DISLIKED',
});
} catch (err) {
console.log(err);
}
};
//service
const likeProduct = async (productId, userId) => {
return await productDao.likeProduct(productId, userId);
};
//model
const likeProduct = async (productId, userId) => {
const [isLiked] = await prisma.$queryRaw`
SELECT * FROM likes WHERE product_id=${productId} and user_id=${userId}
`;
if (isLiked) {
await prisma.$queryRaw`
DELETE FROM
likes
WHERE
product_id=${productId} and user_id=${userId}`;
return false;
} else {
await prisma.$queryRaw`
INSERT INTO
likes (product_id, user_id)
VALUE
(${productId}, ${userId})
`;
return true;
}
};
model단에서 false, true를 반환해도 되는가에 대해서는 확신이 없다. 더 좋은 방법이 있을 것 같으니 좋아요 버튼에 대한 예제를 많이 보고 공부해보자. 사용자 중에는 라이크 버튼을 연속으로 우다다다다다 누르는 경우가 분명 있을 것이다. 이 경우 setTimeout등의 함수로 프론트에서 최종 데이터를 전송하는 등의 방식을 통해 비효율을 줄일 수 있다. 다음 프로젝트시 프론트/백엔드를 만지며 어디서 어떠한 처리를 해주는 것이 좋을지 고민해보자.
ZERO CHO님의 유튜브 영상 참고
링크 :router.post('/product/:id/like, userController.addLike); router.delete('/product/:id/like, userController.removeLike); //프론트에서 버튼에 따라 like/unlike 처리해서 post or delete 메서드로 호출
//router
router.get('/product', validateToken, productController.getProducts);
//GET http://localhost:8000/product?categoryId=숫자
//controller
const getProducts = async (req, res) => {
try {
const categoryId = req.query.categoryId;
const products = await productService.getProducts(Number(categoryId));
res.status(200).json({
message: 'SUCCESS',
products,
});
} catch (err) {
console.log(err);
}
};
//service
const getProducts = async categoryId => {
return await productDao.getProducts(categoryId);
};
//model
const getProducts = async categoryId => {
const products = await prisma.$queryRaw`
SELECT
p.id,
p.korean_name,
p.english_name,
c.name,
c.id,
i.image_url
FROM products p
JOIN categories c
ON c.id = p.category_id
JOIN images i
ON i.product_id = p.id
WHERE c.id=${categoryId}
`;
return products;
};
헐 다빈님 2주를 통으로 요약해두셨네요 진짜 정리 잘하십니다