일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- REACT
- 파이썬
- 클로즈가드
- 엄티로드
- graphQL
- 영화감상
- git
- 영화리뷰
- 자바스크립트
- 노드
- 주짓떼로
- 개발자
- 프로그래밍
- 개발
- JavaScript
- 리액트
- 주짓수
- 드릴
- 영화
- 웹개발
- nodejs
- Node
- 솔로드릴
- web
- Redux
- 주짓떼라
- 하프가드
- 디자인패턴
- Express
- development
- Today
- Total
As i wish
[React SNS] React SNS 만들기 - 5 (BackEnd server - Web server 만들기) 본문
안녕하세요. 엄티입니다.
계속해서 React 를 사용해서 SNS 를 만들어 보겠습니다.
프로젝트 소개와 프로젝트 구성 요소는 제 전 포스팅을 참고해주세요.
참고로 제 포스팅은 '제로초' 님의 SNS 만들기 강좌 복습 포스팅입니다.
오늘은 백엔드 서버를 만들어 보겠습니다.
제로초 님은 NodeJS, ExpressJS, SQL DB 를 사용하셨는데, 저는 NodeJs, ExpressJS, MongoDB 로 구성해보겠습니다.
MongoDB 는 NoSQL 로 조금 테이블 구성이 다릅니다.
사실 저는 리액트에 조금 더 비중을 높이기 위해 웹서버에 시간을 많이 할애하지 않기 위해서 자주 사용하던 MongoDB 를 사용했는데...
그냥 클론받을걸 그랬어요...
무튼 저는 MongoDB 로 구성했습니다!!
일단 서버는 NodeJS, ExpressJS 로 구성하기 때문에 몇가지 모듈들을 설치해보겠습니다.
$ npm i axios bcrypt cookie-parser cors dotenv express express-session mongoose morgan passport passport-local
$ npm i -D eslint eslint-config-airbnb-base eslint-plugin-import
cookie-parser, express-session 은 cookie, session 로그인을 사용하기 위해 설치하고요, bcrypt 는 패스워드 암호화, cors 는 access-control, morgan 은 로그, passport, passport-local 은 로그인 등 다양한 분야에 사용되는데 제 포스팅에서는 깊게 다루지 않겠습니다.
$ npm i -g nodeman
프론트에서는 next 로 인하여 제가 짠 코드가 변경되면 화면이 변경되는데 서버에서 그런 역할을 해주는 모듈입니다.
제일 먼저 기본이 되는 index.js 부터 보시겠습니다.
index.js
console.log('Process node version', process.version);
const MONGODB_URL = 'mongodb://localhost/reactSNS';
const express = require('express');
const morgan = require('morgan');
const cors = require('cors');
const cookieParser = require('cookie-parser');
const expressSession = require('express-session');
const dotenv = require('dotenv');
const passport = require('passport');
const mongoose = require('mongoose');
const passportConfig = require('./passport');
const userAPIRouter = require('./routes/user');
const postsApiRouter = require('./routes/posts');
const postApiRouter = require('./routes/post');
mongoose.connect(MONGODB_URL);
dotenv.config();
const app = express();
passportConfig();
app.use(morgan('dev'));
app.use(cors({
origin: true, // Allow all request
credentials: true, // For transition cookie (cors, axios)
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(cookieParser(process.env.COOKIE_SECRET));
app.use(expressSession({
resave: false, // 매번 세션 강제저장
saveUninitialized: false, // 빈값도 저장
secret: process.env.COOKIE_SECRET,
cookie: {
httpOnly: true,
secure: false, // https를 쓸 때 true
},
name: 'rnbck', // Express 는 쿠키 이름이 connet.sid 이기 때문에 이름 바꾸어주어야함
}));
app.use(passport.initialize());
app.use(passport.session());
app.use('/api/user', userAPIRouter);
app.use('/api/posts', postsApiRouter);
app.use('/api/post', postApiRouter);
app.listen(8080, () => {
console.log('server is running on http://localhost:8080');
});
app.use 를 사용하여 필요한 모듈들을 불러주고 있죠? 디비도 연결시켜주고 있고 쿠키 관련된것도 연결해주고 라우트도 연결해줍니다.
그리고 포트는 8080 으로 열어놓습니다.
pacakge.json
{
"name": "back",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "nodemon"
},
"author": "",
"license": "ISC",
"dependencies": {
"axios": "^0.19.0",
"bcrypt": "^3.0.6",
"cookie-parser": "^1.4.4",
"cors": "^2.8.5",
"dotenv": "^8.0.0",
"express": "^4.17.1",
"express-session": "^1.16.2",
"mongoose": "^5.6.9",
"morgan": "^1.9.1",
"passport": "^0.4.0",
"passport-local": "^1.0.0"
},
"devDependencies": {
"eslint": "^6.1.0",
"eslint-config-airbnb-base": "^14.0.0",
"eslint-plugin-import": "^2.18.2"
}
}
저의 pacakge.json 입니다. 주의하실 점은 scripts 부분에서 dev 에 nodeman 을 불러줘서 서버를 동작시킨다는 점이죠.
이번 프로젝트에서는 쿠키-세션 로그인을 사용하였는데, 이에 대한 설명 역시 깊게 하진 않고 첨부로 대신 하겠습니다.
세션/쿠키 방식에서 처음 인증을 할 때에 서버가 세션아이디: 쿠키 같이 서버에 저장소에 저장을 해놓고,
다음 인증이 필요한 요청 (인증이 필요 없는 요청도 있죠. 예를 들면 게시물을 가져오거나 하는 요청)이 들어 올 때 마다 쿠키를 확인하여 세션아이디를 찾아내서 인증 여부를 확인합니다. 그 후 인증된 사용자에 인증된 요청이면 그에 맞는 응답을 주게 되죠.
이러한 일련의 작업들을 알아서 잘 해주는것이 passport 라고 보시면 되겠습니다.
첫 인증을 완료하고 세션/쿠키를 저장하는 작업을 passport -> serializeUser
인증이 필요한 요청이 오면 인증을 확인해서 그에 맞게 응답을 주는 작업을 passport -> deserializeUser 라고 생각하시면 될 것 같네요.
* 인증이 필요한 요청 -> 글쓰기, 댓글쓰기 등등
* 인증이 필요없는 요청 -> 게시글 가져오기, 회원가입 등등
back/passport/index.js
const passport = require('passport');
const local = require('./local');
const UserModel = require('../models/user');
module.exports = () => {
// req.login 할 때 실행, 서버쪽에 [{id: 3, cookie: 'asdfgh'}]
passport.serializeUser((user, done) => { // Strategy 성공 시 호출됨
console.log('Serialize', user.userId);
return done(null, user.userId); // 여기의 user.userId가 req.session.passport.user에 저장
});
passport.deserializeUser(async (id, done) => { // 매개변수 id는 req.session.passport.user에 저장된 값
try {
console.log('Deserialize', id);
const user = await UserModel.aggregate([{
$match: {
userId: id,
},
}, {
$lookup: {
from: 'posts',
localField: 'userId', // Standard key from now db(User)
foreignField: 'userId', // Find key by from db(Posts)
as: 'user_posts',
},
}, {
$project: {
nickname: '$nickname',
userId: '$userId',
followings: '$followings',
followers: '$followers',
posts: '$user_posts._id',
},
}]);
return done(null, user[0]); // req.user
} catch (error) {
console.error(error);
return done(error);
}
});
local();
};
// 프론트에서 서버로는 cookie만 보내요(asdfgh)
// 서버가 쿠키파서, 익스프레스 세션으로 쿠키 검사 후 id: 3 발견
// id: 3이 deserializeUser에 들어감
// req.user로 사용자 정보가 들어감
// 요청 보낼때마다 deserializeUser가 실행됨(db 요청 1번씩 실행)
// 실무에서는 deserializeUser 결과물 캐싱
그럼 간단하게 로그인 사이클만 확인해보겠습니다. (사실 제일 복잡함...)
먼저 클라이언트 단에서 로그인을 요청합니다. -> 그럼 서버가 로그인 api 를 듣고 있다가 그에 맞는 api 들어오면 로그인 전략에 맞게 로그인을 진행합니다. -> 로그인이 완료 되면 쿠키를 클라이언트로 보내고 서버는 세션아이디와 쿠키를 저장합니다. -> 그 후 쿠키를 가지고 있는 요청이 들어오면 쿠키를 확인하여 이 요청이 유효한지 확인합니다.
클라이언트 요청은 front/sagas/user.js 쪽에서 지난번에 delay로 처리 하였던 것을 수정하면 됩니다.
import { all, fork, takeEvery, call, put, delay } from 'redux-saga/effects';
import axios from 'axios';
import * as actions from '../reducers/user';
function loginApi(loginData) {
return axios.post('/user/login', loginData, {
withCredentials: true, // For transition cookie
});
}
function* login({ payload }) {
try {
const { loginData } = payload;
console.log('Login data', loginData);
const { data } = yield call(loginApi, loginData);
yield put({
type: actions.LOG_IN_SUCCESS,
payload: {
data,
},
});
} catch (error) {
console.error('Login error. ', error);
yield put({
type: actions.LOG_IN_FAILURE,
error,
});
}
}
function logoutApi() {
return axios.post('/user/logout', {}, {
withCredentials: true,
});
}
function* logout() {
try {
yield call(logoutApi);
yield put({
type: actions.LOG_OUT_SUCCESS,
});
} catch (error) {
console.error('Logout error. ', error);
yield put({
type: actions.LOG_OUT_FAILURE,
error,
});
}
}
function loadUserApi() {
return axios.get('/user', {
withCredentials: true,
});
}
function* loadUser() {
try {
const { data } = yield call(loadUserApi);
yield put({
type: actions.LOAD_USER_SUCCESS,
payload: {
data,
},
});
} catch (error) {
console.error('Load user error. ', error);
yield put({
type: actions.LOAD_USER_FAILURE,
error,
});
}
}
function signUpApi(signUpData) {
return axios.post('/user', signUpData);
}
function* signUp({ payload }) {
try {
const { signUpData } = payload;
console.log('Sign up data', signUpData);
yield call(signUpApi, signUpData);
yield put({
type: actions.SIGN_UP_SUCCESS,
});
} catch (error) {
console.error(error);
yield put({
type: actions.SIGN_UP_FAILURE,
error,
});
}
}
function* watchLogin() {
yield takeEvery(actions.LOG_IN_REQUEST, login);
yield takeEvery(actions.LOG_OUT_REQUEST, logout);
}
function* watchLoadUser() {
yield takeEvery(actions.LOAD_USER_REQUEST, loadUser);
}
function* watchSignUp() {
yield takeEvery(actions.SIGN_UP_REQUEST, signUp);
}
export default function* userSaga() {
yield all([
fork(watchLogin),
fork(watchLoadUser),
fork(watchSignUp),
]);
}
위에서 loginApi 처럼 서버에 요청을 하는데 withCredentials 는 쿠키를 교환하겠다는 의미 입니다. 즉, 인증이 필요한 요청이다 라는 말이죠.
그럼 위 withCredentials 때문에 서버는 세션/쿠키를 확인하기 위해 passport 에서 deserializeUser 를 확인하죠.
(쿠키가 없으면 그냥 패스!!!)
거기서 서버에 저장했던 세션아이디를 가지고 디비에 접근하여 유저를 찾아냅니다. 만약 없으면 express router 에 req.user 가 undefined 로 나오는데 이에 따라 맞는 조치를 취해주면 됩니다. 인증이 없어도 무관하면 그냥 패스, 인증이 필요하면 에러 메세지를 클라이언트에 보내주어야겠죠?
로그인이기 때문에 쿠키가 없다고 가정합니다. 만약 있다면 다른 사람 쿠키이기 때문에 에러처리를 해주지 않으면 계속 진행 되겠죠.
그 다음 우리가 만들어준 로그인 전략으로 로그인을 시도 합니다.
로그인 전략은 간단합니다. 패스워드 체크 정도가 되겠죠. 완료가 되면 그에 맞는 세션/쿠키 를 만들어 줍니다. (serializeUser) 그리고 이를 클라이언트단으로 보내고 추후 클라이언트단에서 인증이 필요한 요청이 오면 쿠키와 함께 요청이 오게 되는거죠.
그럼 코드로 한번 따라가보겠습니다.
back/routes/user.js
const express = require('express');
const bcrypt = require('bcrypt');
const passport = require('passport');
const UserModel = require('../models/user');
const router = express.Router();
router.get('/', (req, res) => { // GET /api/user/
if (!req.user) {
return res.status(401).send('Please login');
}
const user = { ...req.user };
delete user.password;
return res.json(user);
});
router.post('/', async (req, res, next) => { // POST /api/user 회원가입
try {
const exUser = await UserModel.findOne({
userId: req.body.userId,
});
if (exUser) {
return res.status(403).send('이미 사용중인 아이디입니다.');
}
const hashedPassword = await bcrypt.hash(req.body.password, 12); // salt는 10~13 사이로
const newUser = await UserModel.create({
nickname: req.body.nickname,
userId: req.body.userId,
password: hashedPassword,
});
console.log(newUser);
return res.status(200).json(newUser);
} catch (e) {
console.error(e);
// 에러 처리를 여기서
return next(e);
}
});
router.get('/:id', (req, res) => { // 남의 정보 가져오는 것 ex) GET /api/user/123
});
router.post('/logout', (req, res) => { // POST /api/user/logout
req.logout();
req.session.destroy();
res.send('Logout');
});
router.post('/login', (req, res, next) => { // POST /api/user/login
passport.authenticate('local', (error, user, info) => {
if (error) {
return next(error);
}
if (info) {
return res.status(401).send(info.reason);
}
return req.login(user, async (loginError) => {
try {
if (loginError) {
return next(loginError);
}
const fullUser = await UserModel.aggregate([{
$match: {
userId: req.user.userId,
},
}, {
$lookup: {
from: 'posts',
localField: 'userId', // Standard key from now db(User)
foreignField: 'userId', // Find key by from db(Posts)
as: 'user_posts',
},
}, {
$project: {
nickname: '$nickname',
userId: '$userId',
followings: '$followings',
followers: '$followers',
posts: '$user_posts._id',
},
}]).exec();
console.log('Full user', fullUser[0]);
return res.json(fullUser[0]);
} catch (err) {
return next(err);
}
});
})(req, res, next);
});
router.get('/:id/follow', (req, res) => { // GET /api/user/:id/follow
});
router.post('/:id/follow', (req, res) => {
});
router.delete('/:id/follow', (req, res) => {
});
router.delete('/:id/follower', (req, res) => {
});
router.get('/:id/posts', (req, res) => {
});
module.exports = router;
여기서 router.post('/login', (req, res, next)) 부분을 보시면 되는데 앞서 클라이언트에서 withCredentials 가지고 왔기 때문에 passport 에 deserializeUser 통과 합니다. 그러나 쿠키가 없기 때문에 별일 없이 통과하는데 req.user 는 값이 없게 되죠.
그 다음 req.login 을 하게 되면 우리가 만들어 놓은 passport local 로그인 전략을 사용하게 됩니다.
back/passport/local.js
const passport = require('passport');
const { Strategy: LocalStrategy } = require('passport-local');
const bcrypt = require('bcrypt');
const UseModel = require('../models/user');
module.exports = () => {
passport.use(new LocalStrategy({
usernameField: 'userId',
passwordField: 'password',
}, async (userId, password, done) => {
try {
const user = await UseModel.findOne({
userId,
});
if (!user) {
return done(null, false, { reason: 'User does not singed up' });
}
const result = await bcrypt.compare(password, user.password);
if (result) {
return done(null, user);
}
return done(null, false, { reason: 'Wrong password' });
} catch (e) {
console.error(e);
return done(e);
}
}));
};
보시면 간단하게 디비확인하고 유저 정보 없으면 에러, 비밀번호 안맞으면 에러, 다 무사히 통과하면 user 를 넘겨주게 됩니다.
그렇게 로그인 에러 없이 로그인이 완료 되면 passport/index.js 에 serializeUser 에서 세션/쿠키를 만들어주고 알아서 쿠키를 클라이언트로 응답과 함께 보내게 됩니다. 클라이언트로 보내는 유저 정보에는 유저가 쓴 posts 정보가 필요하기 때문에 만들어주고, 유저 패스워드도 같이 보내주면 안되기 때문에 몇가지 작업을 한 뒤, res.json(fullUser[0]) 를 통하여 응답을 보냅니다.
그러면 클라이언트에서는 자동으로 쿠키가 생기고 이제 인증이 필요한 요청은 쿠키와 함께 서버에 요청을 하게 되는거죠.
이렇게 앞서 크게 로그인 플로우를 봤는데 이해하기는 쉽지 않을것 같아요.
그래서 한가지 더 새로고침을 하면 유저정보를 가져오는 로직을 설명해보겠습니다.
새로고침을 할 때에, 로그인이 되어있던 유저라면 자동로그인을 당연히 지원해야하는것이 요즘이죠.
그래서 이미 로그인이 된 유저/ 로그인 안한 유저 로 나누어서 플로우를 설명해보겠습니다.
로그인 안한 유저
새로고침 -> 클라이언트에서 loadUser요청 (쿠키를 가지고 - withCredentials) -> 서버에서 이를 받고 deserializeUser 에서 쿠키를 기준으로 세션아이디를 찾음 -> 쿠키가 없거나 세션아이디를 못찾으면 router에 req.user 에 정보가 없게됨 -> 로그인을 해달라고 클라이언트로 보냄
로그인 한 유저
새로고침 -> 클라이언트에서 loadUser요청 (쿠키를 가지고 - withCredentials) -> 서버에서 이를 받고 deserializeUser 에서 쿠키를 기준으로 세션아이디를 찾음 -> 찾음 -> router 에 req.user에 유저정보를 넣어줌 -> 유저 정보를 클라이언트로 보냄
간단하죠? 그렇게 어렵지 않습니다.
그럼 코드로 확인해 보겠습니다.
먼저 새로고침 후 클라이언트에 loadUser 요청은 위에 언급 했던 front/sagas/user.js 쪽을 보면 되겠는데요.
거기서 loadUserApi 부분을 보면 될 것 같습니다.
그 전에 새로고침 할 때마다 그 액션을 취해주어야하기 때문에 front/compontents/AppLayout.js 를 살짝 수정해 줍니다.
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux'
import Link from 'next/link';
import PropTypes from 'prop-types';
import { Menu, Input, Row, Col } from 'antd';
import LoginForm from './LoginForm';
import UserProfile from './UserProfile';
import * as userActions from '../reducers/user';
const AppLayout = ({ children }) => {
const dispatch = useDispatch();
// Get from store
const { me } = useSelector(state => state.user);
useEffect(() => {
dispatch(userActions.loadUserReuqestAction());
}, []); // 새로고침 할 때 마다 유저정보 가져오기
return (
<div>
<Menu mode="horizontal">
<Menu.Item key="home"><Link href="/">NodeBird</Link></Menu.Item>
<Menu.Item key="profile"><Link href="profile">Profile</Link></Menu.Item>
<Menu.Item key="mail"><Input.Search enterButton style={{ verticalAlign: 'middle' }} /></Menu.Item>
</Menu>
<Row gutter={8}>
<Col xs={24} md={6}>
{
me ? <UserProfile /> : <LoginForm />
}
</Col>
<Col xs={24} md={12}>{children}</Col>
<Col xs={24} md={6}><a href="https://www.zerocho.com" target="_blank" rel="noopener noreferrer">Made by ZeroCho</a></Col>
</Row>
</div>
);
};
AppLayout.propTypes = {
children: PropTypes.node.isRequired,
};
export default AppLayout;
그 후 서버에서 passport 가 일련의 작업을 처리해 줍니다. (back/passport/index.js deserializeUser 작업) 그래서 없으면 router 에 req.user 값이 비어있게 되고 있으면 채워지게 되는데 이는 처음 api 를 들었던 라우터에서 확인 할 수 있습니다.
이에 대한 코드는 back/routes/user.js 에서 확인 할 수 있는데 총 코드는 위에 첨부해서 이 부분만 가져왔습니다.
router.get('/', (req, res) => { // GET /api/user/
if (!req.user) {
return res.status(401).send('Please login');
}
const user = { ...req.user };
delete user.password;
return res.json(user);
})
그 후 로그인이 안되어있다면 클라이언트로 res.status(401).send('Please login') 을 보내주고, 되어있다면 패스워드를 제외한 유저정보를 보내주면 됩니다.
이렇게 한 플로우를 간단하게 설명했는데요. 이 뿐만 아니라 이번 강좌에서는 게시글 가져오기, 게시글 작성, 회원가입 등 전반적인 큰틀에 대해여 다루었습니다.
저는 앞서 말했듯이 클라이언트 쪽에 조금 더 비중을 두고 있어서 서버 쪽은 최대한 간결하게 짜려고 노력했고, 그래도 동작은 해야하니까...
동작은 합니다 ㅎㅎㅎ
그래서 설명은 이만 줄이려고 합니다.
저의 풀 코드는 깃허브를 확인해 주세요.
커밋메세지를 통하여 각각 어떤 코드를 추가하였는지 확인 할 수 있으니 쉽게 확인하시면 될 것 같네요.
뭐 질문은 있으시면 적으면 좋고 잘못된 부분있으시면 말씀해주시면 언제나 수정가능합니다!
그럼 이만!!!
'React JS' 카테고리의 다른 글
[React] children 컴포넌트에 props 전달하기 (0) | 2019.12.10 |
---|---|
[React] Redux-saga 적용하기 (0) | 2019.12.06 |
[React SNS] React SNS 만들기 - 4 - 2 (redux saga 적용) (0) | 2019.08.12 |
[React SNS] React SNS 만들기 - 4 - 1 (redux saga 란, airbnbStyle applied) (0) | 2019.08.12 |
[React SNS] React SNS 만들기 - 3 (0) | 2019.08.06 |