SNS 만들기 동작별 흐름 이해하기 -1(with Node, MySQL, Nunjucks) ★

백지연·2022년 2월 7일
1

NodeJS

목록 보기
19/26
post-thumbnail

지금까지 구현한 sns를 동작들을 흐름을 위주로 정리하면서 전체 코드를 이해해보겠다!

Github: https://github.com/delay-100/study-node/tree/main/ch9/sns5

구현한 기능

이번 포스팅에서 파헤쳐 볼 것

  1. 기본 module 세팅
  2. 전체 app.js 세팅
  3. 메인 페이지 이해하기 +layout.html 설명
  4. 회원가입 기능 이해하기

Model은 SNS 만들기 -2에서 다뤘으므로 흐름 정리 포스팅에서는 다루지 않겠다. (model git 주소)


1. 기본 module 세팅

npm module 설치 및 package.json 세팅("main", "start" 수정)

설치한 모듈

  • 기본 모듈
    bcrypt, cookie-parser, dotenv, express, express-session, morgan, multer, mysql2, nunjucks, passport, passport-kakao, passport-local, sequelize, sequelize-cli
  • 개발용 모듈
    nodemon

Git [package.json]

{
  "name": "delay100_sns",
  "version": "1.0.0",
  "description": "",
  "main": "app.js",
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "start": "nodemon app"
  },
  "author": "delay100",
  "license": "ISC",
  "dependencies": {
    "bcrypt": "^5.0.1",
    "cookie-parser": "^1.4.6",
    "dotenv": "^14.3.2",
    "express": "^4.17.2",
    "express-session": "^1.17.2",
    "morgan": "^1.10.0",
    "multer": "^1.4.4",
    "mysql2": "^2.3.3",
    "nunjucks": "^3.2.3",
    "passport": "^0.5.2",
    "passport-kakao": "^1.0.1",
    "passport-local": "^1.0.0",
    "sequelize": "^6.14.1",
    "sequelize-cli": "^6.4.1"
  },
  "devDependencies": {
    "nodemon": "^2.0.15"
  }
}

=1> "main"에 의해 app.js 실행된다.


2. 전체 app.js 세팅

sequelize, nunjucks 등 설치한 모듈 및 라우터 연결

해당 위치

  • api 주소: sns5/app.js, sns5/.env

+body-parser 설명

Git [app.js]

// 모듈 불러오기
const express = require('express');
const cookieParser = require('cookie-parser');
const morgan = require('morgan');
const path = require('path');
const session = require('express-session');
const nunjucks = require('nunjucks');
const dotenv = require('dotenv');
const passport = require('passport');

dotenv.config(); // .env 파일을 쓸 수 있게 함
// 라우터 연결
const pageRouter = require('./routes/page');
const authRouter = require('./routes/auth');
const postRouter = require('./routes/post');
const userRouter = require('./routes/user');

const { sequelize } = require('./models');  // require('./models/index.js')와 같음, 구조분해 할당으로 sequelize 가져옴
const passportConfig = require('./passport'); // require('./passport/index.js')와 같음

const app = express();
passportConfig(); // 패스포트 설정, 한 번 실행해두면 ()에 있는 deserializeUser 계속 실행 - passport/index.js
app.set('port', process.env.PORT || 8001);
app.set('view engine', 'html');
nunjucks.configure('views', { // 넌적스의 파일을 views 폴더에 저장
    express: app,
    watch: true,
});

// sequelize와 db 연결
sequelize.sync({ force: false })
    .then(() =>{
        console.log('데이터베이스 연결 성공');
    })
    .catch((err)=> {
        console.error(err);
    });

app.use(morgan('dev')); // morgan 연결 후 localhost:3000에 다시 접속하면 기존 로그 외 추가적인 로그를 볼 수 있음

// static 폴더 설정
app.use(express.static(path.join(__dirname, 'public')));
app.use('/img', express.static(path.join(__dirname, 'uploads')));

// body-parser
app.use(express.json());
app.use(express.urlencoded({extended:false})); // extended 옵션이 false면 노드의 querystring 모듈을 사용하여 쿼리스트링을 해석
                                                // extended 옵션이 true면 qs 모듈을 사용하여 쿼리스트링을 해석 - qs 모듈은 내장 모듈이 아닌 npm의 패키지(querystring 모듈의 기능을 좀 더 확장한 모듈임)
app.use(cookieParser(process.env.COOKIE_SECRET)); // .env 파일의 COOKIE_SECRET 변수 사용 - 보안 UP

//express-session, 인수: session에 대한 설정
app.use(session({
    resave:false, // resave : 요청이 올 때 세션에 수정 사항이 생기지 않더라도 세션을 다시 저장할지 설정
    saveUninitialized: false,  // saveUninitialized : 세션에 저장할 내역이 없더라도 처음부터 세션을 생성할지 설정
    secret: process.env.COOKIE_SECRET,
    cookie: {
        httpOnly: true, // httpOnly: 클라이언트에서 쿠키를 확인하지 못하게 함
        secure: false, // secure: false는 https가 아닌 환경에서도 사용 가능 - 배포할 때는 true로 
    },
}));

// passport 사용 - req.session 객체는 express-session에서 생성하므로 express-session 뒤에 작성해야함
app.use(passport.initialize()); // 요청(req 객체)에 passport 설정을 심음
app.use(passport.session()); // req.session 객체에 passport 정보를 저장(요청으로 들어온 세션 값을 서버에 저장한 후, passport 모듈과 연결)

// 라우터 연결
app.use('/', pageRouter);
app.use('/auth', authRouter);
app.use('/post', postRouter);
app.use('/user', userRouter);

// 라우터가 없을 때 실행 
app.use((req,res,next)=>{
    const error = new Error(`${req.method} ${req.url} 라우터가 없습니다.`);
    error.status = 404;
    next(error);
});

app.use((err, req, res, next) => {
    res.locals.message = err.message;
    res.locals.error = process.env.NODE_ENV !== 'production' ? err : {}; // 개발용
    res.status(err.status || 500);
    res.render('error');
});

app.listen(app.get('port'), () => {
    console.log(app.get('port'), '번 포트에서 대기 중');
});

=2> app.js 의해 여러 폴더 등에 접근 가능해짐

+process.env.COOKIE_SECRETSECRET KEY 작성
Git [.env]

COOKIE_SECRET = 여기

3. 메인 페이지 이해하기

  • 메인페이지 기본 실행 화면
  • 메인페이지 (로컬) 로그인 후 실행 화면

해당 위치

Git [routes/page.js] 中 일부

// app.js에서 기본 router로 설정한 page.js
const express = require('express');
const { isLoggedIn, isNotLoggedIn } = require('./middlewares'); // 구조분해할당으로 middlewares의 두 미들웨어를 가져옴
const { Post, User, Hashtag } = require('../models');

const router = express.Router();

// 모든 요청마다 실행
router.use((req, res, next) => {
  // res.locals.user = null;  // res.locals는 변수를 모든 템플릿 엔진에서 공통으로 사용, 즉 user는 전역 변수로 이해하면 됨(아래도 동일)
  res.locals.user = req.user; // 요청으로 온 유저를 넌적스에 연결
  res.locals.followerCount = req.user ? req.user.Followers.length : 0; // 유저가 있는 경우 팔로워 수를 저장
  res.locals.followingCount = req.user ? req.user.Followings.length : 0;
  res.locals.followerIdList = req.user ? req.user.Followings.map(f => f.id) : []; // 팔로워 아이디 리스트를 넣는 이유 -> 팔로워 아이디 리스트에 게시글 작성자의 아이디가 존재하지 않으면 팔로우 버튼을 보여주기 위함
  next();
});
...
// http://127.0.0.1:8001/ 에 get요청이 왔을 때
router.get('/', async (req, res, next) => {
  try {
    const posts = await Post.findAll({ // db에서 게시글을 조회 
      include: {
        model: User,
        attributes: ["id", "nick"], // id와 닉네임을 join해서 제공
      },
      order: [["createdAt", "DESC"]], // 게시글의 순서를 최신순으로 정렬
    });
    res.render("main", {
      title: "sns",
      twits: posts, // 조회 후 views/main.html 페이지를 렌더링할 때 전체 게시글을 twits 변수로 저장 
    });
  } catch (err) {
    console.error(err);
    next(err);
  }
});

module.exports = router;

=3-1> res.render("main",~)에 의해 views/main.html이 실행됨

+render 변수

  • title: "sns"
  • twits: posts
    이 twits가 아래의 main.html에서 사용됨

Git [views/main.html] 中 일부

{# 1. user 변수가 존재할 때 게시글 업로드 폼을 보여줌, 2. 모든 사람에게 twits 보여줌 #}
{% extends 'layout.html' %}

{% block content %}
    <div class="timeline">
     {# 1. user가 존재하는 경우 #}
        {% if user %}
        <div>
            <form id="twit-form" action="/post" method="post" enctype="multipart/form-data"> 
                <div class="input-group">
                    <textarea id="twit" name="content" maxlength="140"></textarea>
                </div>
                <div class="img-preview">
                    <img id="img-preview" src="" style="display: none;" width="250" alt="미리보기"> {# 아래의 js로 src 안에 미리보기 주소를 넣음#}
                    <input id="img-url" type="hidden" name="url">
                </div>
                <div>
                    <label id="img-label" for="img">사진 업로드</label> {# for: input의 id값 #}
                    <input id="img" type="file" accept="image/*">
                    <button id="twit-btn" type="submit" class="btn">짹짹</button> {# type이 submit이면 form의 action의 주소가 실행됨 #}
                </div>
            </form>
        </div>
        {% endif %}
        {# 2. 모든 사람에게 보여줌 #}
        <div class="twits">
            <form id="hashtag-form" action="/hashtag">
                <input type="text" name="hashtag" placeholder="태그 검색">
                <button class="btn">검색</button>
            </form>
        {% for twit in twits %} {# 렌더링 시 twits 배열 안의 요소들을 읽어서 게시글로 만듦 #}
            <div class="twit">
                <input type="hidden" value="{{twit.User.id}}" class="twit-user-id">
                <input type="hidden" value="{{twit.id}}" class="twit-id">
                <div class="twit-author">{{twit.User.nick}}</div>
                {# followerIdList: routes/page.js에서 res.locals에서 넣은 변수 #}
                {% if not followerIdList.includes(twit.User.id) and twit.User.id !== user.id %} {# 작성자를 제외하고, 나의 팔로워 아이디 목록에 작성자의 아이디가 없으면 팔로우 버튼을 보여주기 위함 #}
                <button class="twit-follow">팔로우하기</button> {# 아래의 js 코드가 class (twit-follow)으로 동작 실행 #}
                {% endif %}
                <div class="twit-content">{{twit.content}}</div>
                {% if twit.img %} 
                    <div class="twit-img"><img src="{{twit.img}}" alt="섬네일"></div>
                {% endif %}
            </div>
        {% endfor %}
        </div>
    </div>
{% endblock %} 

=3-2> 1. user가 존재하는 경우, 2. 모든 사람에게 보이는 것(해시태그 검색 버튼, 현재 등록된 twits)의 기능을 함

1. user가 존재하는 경우

1. post 작성 폼
-> 짹짹 버튼 클릭 시 type="submit"에 의해 form 태그의 action="/post" 실행 + method="post"
-> routes/post.js의 post("/") 실행
-> http://127.0.0.1:8001/post

1.2 post에 이미지 추가
-> id가 imginput 추가
-> scripts의 axios.post('/post/img', formData)가 실행될 때,
-> routes/post.js의 post('/img') 실행

2. 모든 사람에게 보이는 것

1. 해시태그 검색 버튼
-> 검색 버튼 클릭 시 class="btn"에 의해 action="/hastag" 실행될 때,
-> routes/page.js의 get('/hashtag') 실행

2. 현재 등록된 twit들을 보여줌
-> {% for twit in twits %} 이용

3. 팔로우하기 버튼
-> 팔로우하기 버튼 클릭 시 class="twit-follow"에 의해 <script>.twit-follow 실행

+ layout.html 설명

앞으로 여러 html 파일들에 상속을 할 수 있다.

상속할 html 파일에 넣는 구조

{% extends 'layout.html' %}
{% block content %}
코드
{% endblock %}

+href, src, url 차이
+href 추가 설명

Git [views/layout.html]

{# 기본 html 틀 #}
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
        <title>{{title}}</title>
        <meta name="viewport" content="width=device-width, user-scalable=no"> {# 모바일에서도 호환되게 해줌 #}
        <meta http-equiv="X-UA-Compatible" content="IE=edge"> {# IE 문서 모드, IE=edge는 IE브라우저에서 가장 최신 선택하는 것 #}
        <link rel="stylesheet" href="/main.css">
    </head>
    <body>
        <div class="container">
            <div class="profile-wrap">
                <div class="profile">
                    {% if user and user.id %} {# 렌더링 시 user가 존재하면 사용자 정보, 팔로잉, 팔로워 수를 보여줌 #}
                    <div class="user-name">{{'안녕하세요! ' + user.nick +'님'}}</div>
                    <div class="half">
                        <div>팔로잉</div>
                        <div class="count following-count">{{followingCount}}</div>
                    </div>
                    <div class="half">
                        <div>팔로워</div>
                        <div class="count follower-count">{{followerCount}}</div>
                    </div>
                    <input id="my-id" type="hidden" value="{{user.id}}">
                    <a id="my-profile" href="/profile" class="btn">내 프로필</a>
                    <a id="logout" href="/auth/logout" class="btn">로그아웃</a>
                    {% else %} {# 렌더링 시 user가 존재하지 않으면 로그인이 되어있지x -> 로그인 메뉴를 보여줌 #}
                    <form id="login-form" action="/auth/login" method="post">
                        <div class="input-group">
                            <label for="email">이메일</label>
                            <input id="email" type="email" name="email" required autofocus>
                        </div>
                        <div class="input-group">
                            <label for="password">비밀번호</label>
                            <input id="password" type="password" name="password"  required>
                        </div>
                        <a id="join" href="/join" class="btn">회원가입</a>
                        <button id="login" type="submit" class="btn">로그인</button>
                        <a id="kakao" href="/auth/kakao" class="btn">카카오톡</a>
                    </form>
                    {% endif %}
                </div>
                <footer>
                    Made by&nbsp;
                    <a href="https://velog.io/@delay100" target="_blank">delay100</a>
                </footer>
            </div>
            {% block content %}
            {% endblock %}
        </div>
        <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
        <script>
            window.onload = () =>{
                if (new URL(location.href).searchParams.get('loginError')){ 
                    alert(new URL(location.href).searchParams.get('loginError')); 
                }
            };
        </script>
        {% block script %}
        {% endblock %}
    </body>
</html>

=> 1. user가 존재하는 경우, 2. user가 존재하지 않는 경우로 나뉨

1. user가 존재하는 경우

1. 사용자 정보, 팔로잉, 팔로워 수를 보여줌
-> {% if user and user.id %} 이후

2. 내 프로필 버튼(팔로우, 팔로잉 목록 보여줌)
-> 내 프로필 버튼 클릭 시 href="/profile"에 의해 http://127.0.0.1:8001/profile 에 get 요청
-> routes/page.js의 get("/profile") 실행

3. 로그아웃 버튼
-> 로그아웃 버튼 클릭 시 href="/auth/logout"에 의해 http://127.0.0.1:8001/auth/logout 에 get 요청
-> routes/auth.js의 get("/logout") 실행

2. user가 존재하지 않는 경우

1. 회원가입 페이지로 이동하는 버튼
-> 회원가입 버튼 클릭 시 href="/join"에 의해 http://127.0.0.1:8001/join 에 get 요청
-> routes/page.js의 get("/join") 실행

2. 로컬 로그인 폼
-> 로그인 버튼 클릭 시 type="submit"에 의해 form 태그의 action="/auth/login" 실행 +method="post"
-> routes/auth.js의 post("/login") 실행
-> http://127.0.0.1:8001/auth/login

3. 카카오톡 버튼
-> 카카오톡 버튼 클릭 시 href="/auth/kakao"에 의해 http://127.0.0.1:8001/auth/kakao 에 get 요청
-> routes/auth.js의 get("/kakao") 실행


4. 회원가입 기능 이해하기

  • 회원가입 페이지 기본 실행 화면

해당 위치

  • url 주소: http://localhost:8001/join
  • api 주소: sns5/routes/page.js, sns5/routes/middlewares, sns5/routes/auth.js
  • html: sns5/views/join.html

위의 3. 메인 페이지 이해하기에서 layout.html 부분에서 회원가입 버튼을 다뤘다. 그 버튼을 누르면 어떻게 될까?

layout.html 설명 中 회원가입 버튼 내용

1. 회원가입 페이지로 이동하는 버튼
-> 회원가입 버튼 클릭 시 href="/join"에 의해 http://127.0.0.1:8001/join 에 get 요청
-> routes/page.js의 get("/join") 실행

Git [routes/page.js] 中 /join

const { isLoggedIn, isNotLoggedIn } = require('./middlewares');

// http://127.0.0.1:8001/join 에 get요청이 왔을 때
router.get("/join", isNotLoggedIn, (req, res) => {
  res.render("join", { title: "회원가입 - sns" });
});

=4-1> 1. isNotLoggedIn을 routes/middlewares에서 불러옴, 2. res.render("join",~)에 의해 views/join.html이 실행(렌더링)됨

4-1.1. isNotLoggedIn을 routes/middlewares에서 불러옴
Git [routes/middleware.js] 中 isNotLoggedIn 미들웨어

// 로그인 확인 관련 미들웨어 생성

// 로그인이 되지 않은 상태를 확인하는 미들웨어
exports.isNotLoggedIn = (req, res, next) => {
    // 로그인이 아니면 허용
    if(!req.isAuthenticated()){
        next(); // 다음 미들웨어로 넘겨줌
    } else{ // 로그인이면 허용
        const message = encodeURIComponent('로그인한 컴포넌트입니다.');
        res.redirect(`/?error=${message}`); // 에러 페이지로 바로 이동시킴
    }
};

=4-2> 미들웨어를 실행(로그인이 되지 않은 상태이면 허용)한 후 다시 routes/page.jsget("/join")의 실행 중인 곳으로 돌아감

4-1.2. res.render("join",~)에 의해 views/join.html이 실행(렌더링)됨

+[render 변수]

  • title: "회원가입 - sns"

Git [views/join.html]

{# 회원가입 폼 #}
{% extends 'layout.html' %} 

{% block content %} 

<div class="timeline">
    <form id="join-form" action="/auth/join" method="post">
        <div class="input-group">
            <label for="join-email">이메일</label>
            <input id="join-email" type="email" name="email">
        </div>
        <div class="input-group">
            <label for="join-nick">닉네임</label>
            <input id="join-nick" type="text" name="nick">
        </div>
        <div class="input-group">
            <label for="join-password">비밀번호</label>
            <input id="join-password" type="password" name="password">
        </div>
        <button id="join-btn" type="submit" class="btn">회원가입</button>
    </form>
</div>
{% endblock %} 

{% block script %} 
    <script>
        window.onload = () => {
            if(new URL(location.href).searchParams.get('error')){ // 현재 주소에서 error가 존재하면, 유저가 이미 존재하는 경우의 에러  
                alert('이미 존재하는 이메일입니다.');
            }
        };
    </script>
{% endblock %}

=4-3> 1. 회원가입(이메일, 닉네임, 비밀번호) 작성 폼을 보여줌

1. 회원가입(이메일, 닉네임, 비밀번호) 작성 폼
-> 회원가입 버튼 클릭 시 type="submit"에 의해 form 태그의 action="/auth/join" 실행 + method="post"
-> routes/auth.js의 post("/join") 실행
-> http://127.0.0.1:8001/auth/join

+ script 안의 error도 routes/auth.jspost("/join")에서 준 것임 -> 4-4.2.1에서 설명

Git [routes/auth.js] 中 post('/join')

// 회원가입, 로그인, 로그아웃 라우터
const express = require('express');
const bcrypt = require('bcrypt');
const { isLoggedIn, isNotLoggedIn } = require('./middlewares');
const User = require('../models/user');

const router = express.Router();

// 회원가입 라우터, /auth/join
router.post('/join', isNotLoggedIn, async (req, res, next) => {
    const { email, nick, password } = req.body; // body-parser 덕분에 views/join.html의 form에 있는 name="email, nick, password" 들을 req.body로 가져올 수 있음
    try {
        const exUser = await User.findOne({ where: {email}});  // User db에 같은 email이 있는지 확인  
        if(exUser){ // 이미 User가 존재하면
            return res.redirect('/join?error=exist'); // 주소 뒤에 에러를 쿼리스트링으로 표시
        }
        // User이 존재하지 않으면(회원가입 가능)
        const hash = await bcrypt.hash(password, 12); // bcrypt 모듈을 이용해 비밀번호 암호화 - crypto 모듈의 pbkdf2 메서드를 이용해 암호화도 가능
                                                     // 두번째 인수(추천- 12~31): pbkdf2의 반복횟수와 유사, 숫자가 커질수록 비밀번호를 알아내기 어렵지만 암호화 시간도 오래걸림 
        await User.create({
            email,
            nick,
            password: hash,
        });
        return res.redirect('/');
    } catch (error) {
        console.error(error);
        return next(error);
    }
});

module.exports = router;

=4-4> 1. isNotLoggedIn을 routes/middlewares에서 불러옴(4-1.1.에서 설명함), 2.1. 유저가 이미 존재하는 경우(에러), 2.2. 정상적으로 User가 생성된 경우(회원가입 완료), 2.3. 에러가 난 경우

4-4.2.1. 유저가 이미 존재하는 경우(에러)
-> res.redirect로 url에 /join?error=exist를 줌
-> routes/page.js의 get /join이 실행됨
-> 4-1.2.에 의해 views/join.html이 실행됨
-> script 태그에 의해 url에 error가 들어있는 것이 확인되어 alert 실행

4-4.2.2. 정상적으로 User가 생성된 경우(회원가입 완료)
-> res.redirect('/')에 의해 routes/page.js의 get /로 이동
-> http://127.0.0.1:8001 로 이동됨

4-4.2.3. 에러가 난 경우
-> console.error(error) 실행
+console.error(error) 설명


전체 파일 구조


다음 포스팅에서는 5. 로컬 로그인 기능 이해하기, 6. kakao 로그인 기능 이해하기, 7. 글쓰기/이미지 업로드 이해하기, 8. 팔로우-팔로잉 기능 이해하기, 9. 해시태그 검색 기능 이해하기를 다뤄보겠다.

잘못된 정보 수정 및 피드백 환영합니다!!

profile
TISTORY로 이사중! https://delay100.tistory.com

0개의 댓글