지금까지 구현한 sns를 동작들을 흐름을 위주로 정리하면서 전체 코드를 이해해보겠다!
Github: https://github.com/delay-100/study-node/tree/main/ch9/sns5
구현한 기능
- SNS 만들기 -1(with Node, MySQL, Nunjucks)
1. 프로젝트 기본 뼈대 잡기
2. 프론트엔드 화면 구현하기- SNS 만들기 -2(with Node, MySQL, Nunjucks)
3. DB 세팅하기- SNS 만들기 -3(with Node, MySQL, Nunjucks)
4. 로그인 구현하기(with Passport 모듈)- SNS 만들기 -4(with Node, MySQL, Nunjucks)
5. 이미지 업로드 구현하기(with multer 패키지)
6. 팔로우-팔로잉 기능 구현하기
7. 해시태그 검색 기능 구현하기
- 기본 module 세팅
- 전체 app.js 세팅
- 메인 페이지 이해하기 +layout.html 설명
- 회원가입 기능 이해하기
Model은 SNS 만들기 -2에서 다뤘으므로 흐름 정리 포스팅에서는 다루지 않겠다. (model git 주소)
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
실행된다.
sequelize, nunjucks 등 설치한 모듈 및 라우터 연결
해당 위치
- api 주소: sns5/
app.js
, sns5/.env
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_SECRET 에 SECRET KEY
작성
Git [.env
]
COOKIE_SECRET = 여기
해당 위치
- url 주소: http://localhost:8001/
- api 주소: sns5/routes/
page.js
- html: sns5/views/
main.html
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 변수
twits
: postsGit [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/post1.2 post에 이미지 추가
-> id가img
인input
추가
-> 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
실행
앞으로 여러 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
<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") 실행
해당 위치
- 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.js
의 get("/join")
의 실행 중인 곳으로 돌아감
4-1.2. res.render("join",~)에 의해 views/join.html
이 실행(렌더링)됨
+[render 변수]
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.js
의post("/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. 해시태그 검색 기능 이해하기를 다뤄보겠다.
잘못된 정보 수정 및 피드백 환영합니다!!