As i wish

[React SNS] React SNS 만들기 - 5 (BackEnd server - Web server 만들기) 본문

React JS

[React SNS] React SNS 만들기 - 5 (BackEnd server - Web server 만들기)

어면태 2019. 8. 13. 18:43

안녕하세요. 엄티입니다.

계속해서 React 를 사용해서 SNS 를 만들어 보겠습니다.

 

프로젝트 소개와 프로젝트 구성 요소는 제 전 포스팅을 참고해주세요.

React SNS 만들기 - 1

 

[React SNS] React SNS 만들기 - 1

안녕하세요. 엄티입니다. 오늘은 React 를 사용하여 간단한 SNS 를 만들어 볼껀데요. 제 포스팅은 '제로초' 님의 강의를 바탕으로 복습용으로 작성됩니다. 자세한 설명을 원하면 밑에 강의를 참조해 주세요. '제로..

eomtttttt-develop.tistory.com

참고로 제 포스팅은 '제로초' 님의 SNS 만들기 강좌 복습 포스팅입니다.

'제로초' 님의 React NodeBird SNS

 

React로 NodeBird SNS 만들기 - 인프런

리액트&넥스트&리덕스&리덕스사가&익스프레스 스택으로 트위터와 유사한 SNS 서비스를 만들어봅니다. 끝으로 검색엔진 최적화 후 AWS에 배포합니다. 중급 웹 개발 프레임워크 및 라이브러리 서비스 개발 Front End Back End Javascript React 온라인 강의

www.inflearn.com

 

오늘은 백엔드 서버를 만들어 보겠습니다.

제로초 님은 NodeJS, ExpressJS, SQL DB 를 사용하셨는데, 저는 NodeJs, ExpressJS, MongoDB 로 구성해보겠습니다.

MongoDB 는 NoSQL 로 조금 테이블 구성이 다릅니다.

 

SQL vs No SQL

 

SQL? NoSQL?

무얼 써야 하나요 | cover 출처 및 참고 article: http://bigdatahadooppro.com/sql-vs-nosql-all-you-need-to-know/ 처음 웹 프로젝트를 접했을 때, 그리고 매 번 프로젝트를 시작할 때마다 고민되던 것이 바로 mySQL과 같은 SQL database를 쓸 것이냐, 아니면 mongoDB와 같은 NOSQL을 쓸것이냐였다.

brunch.co.kr

사실 저는 리액트에 조금 더 비중을 높이기 위해 웹서버에 시간을 많이 할애하지 않기 위해서 자주 사용하던 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 을 불러줘서 서버를 동작시킨다는 점이죠.

 

이번 프로젝트에서는 쿠키-세션 로그인을 사용하였는데, 이에 대한 설명 역시 깊게 하진 않고 첨부로 대신 하겠습니다.

 

인증 방법들

 

쉽게 알아보는 서버 인증 1편(세션/쿠키 , JWT)

앱 개발을 처음 배우게 됐을 때, 각종 화면을 디자인해보면서 프론트엔드 개발에 큰 흥미가 생겼습니다. 한때 프론트엔드 개발자를 꿈꾸기도 했었죠(현실은 ...) 그러나 서버와 통신을 처음 배웠을 때 마냥 쉬운..

tansfil.tistory.com

 

세션/쿠키 방식에서 처음 인증을 할 때에 서버가 세션아이디: 쿠키 같이 서버에 저장소에 저장을 해놓고,

다음 인증이 필요한 요청 (인증이 필요 없는 요청도 있죠. 예를 들면 게시물을 가져오거나 하는 요청)이 들어 올 때 마다 쿠키를 확인하여 세션아이디를 찾아내서 인증 여부를 확인합니다. 그 후 인증된 사용자에 인증된 요청이면 그에 맞는 응답을 주게 되죠.

이러한 일련의 작업들을 알아서 잘 해주는것이 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') 을 보내주고, 되어있다면 패스워드를 제외한 유저정보를 보내주면 됩니다.

 

이렇게 한 플로우를 간단하게 설명했는데요. 이 뿐만 아니라 이번 강좌에서는 게시글 가져오기, 게시글 작성, 회원가입 등 전반적인 큰틀에 대해여 다루었습니다.

 

저는 앞서 말했듯이 클라이언트 쪽에 조금 더 비중을 두고 있어서 서버 쪽은 최대한 간결하게 짜려고 노력했고, 그래도 동작은 해야하니까... 

동작은 합니다 ㅎㅎㅎ

 

그래서 설명은 이만 줄이려고 합니다.

저의 풀 코드는 깃허브를 확인해 주세요.

 

커밋메세지를 통하여 각각 어떤 코드를 추가하였는지 확인 할 수 있으니 쉽게 확인하시면 될 것 같네요.

 

깃허브 계정

 

eomttt/react-sns

React sns test by react-nodebird(zeroCho). Contribute to eomttt/react-sns development by creating an account on GitHub.

github.com

 

뭐 질문은 있으시면 적으면 좋고 잘못된 부분있으시면 말씀해주시면 언제나 수정가능합니다!

그럼 이만!!!

 

Comments