이번 포스팅부터는 여러 기능들이 들어갈 웹 사이트를 만들어보려고 한다! 지금까지 포스팅했던 내용들은 자세하게 설명하지 않고, 링크만 걸어둘 계획이다.(이해가 안 된다면 걸어둔 링크에 접속해보자!)
SNS의 기능을 모두 만들게 되면 로그인, 이미지 업로드, 게시글 작성, 해시태그 검색, 팔로잉 등을 구현하게 된다.
책 Node.js 교과서(개정 2판) 책의 9장의 내용을 참고했다.
+모든 코드는 github주소에 있다.
개발 환경
이번 포스팅에서 구현할 것
- 프로젝트 기본 뼈대 잡기
- 프론트엔드 화면 구현하기
Github: https://github.com/delay-100/study-node/tree/main/ch9/sns
npm init
[설치할 모듈]
$ npm i sequelize mysql2 sequelize-cli
$ npx sequelize init
**npx sequelize init
명령어는 config, migrations, models, seeders 폴더가 생성됨, npx 명령어를 사용하는 이유는 전역 설치(npm i -g)를 피하기 위함
$ npm i express cookie-parser express-session morgan multer dotenv nunjucks
$ npm i -D nodemon
Git [sns/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": {
"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",
"sequelize": "^6.14.1",
"sequelize-cli": "^6.4.1"
},
"devDependencies": {
"nodemon": "^2.0.15"
}
}
Git [sns/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');
dotenv.config(); // .env 파일을 쓸 수 있게 함
const pageRouter = require('./routes/page');
const app = express();
app.set('port', process.env.PORT || 8001);
app.set('view engine', 'html');
nunjucks.configure('views', {
express: app,
watch: true,
});
app.use(morgan('dev'));
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());
app.use(express.urlencoded({extended:false}));
app.use(cookieParser(process.env.COOKIE_SECRET)); // .env 파일의 COOKIE_SECRET 변수 사용 - 보안 UP
//express-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로
},
}));
// 라우터 연결
app.use('/', pageRouter);
// 라우터가 없을 때 실행
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'), '번 포트에서 대기 중');
});
+.env파일 작성 (gitignore에 의해 Git repository에는 없음)
Git [sns/.env
]
COOKIE_SECRET=cookiesecret
Git [sns/routes/page.js
] **app.js에서 기본 router로 설정한 page.js
// app.js에서 기본 router로 설정한 page.js
const express = require('express');
const router = express.Router();
// 모든 요청마다 실행
router.use((req,res,next)=>{
res.locals.user = null; // res.locals는 변수를 모든 템플릿 엔진에서 공통으로 사용, 즉 user는 전역 변수로 이해하면 됨(아래도 동일)
res.locals.followerCount = 0;
res.locals.followingCount = 0;
res.locals.followerIdList = [];
next();
});
// http://127.0.0.1:8001/profile 에 get요청이 왔을 때
router.get('/profile', (req, res) => {
res.render('profile', { title: '내 정보 - sns'});
});
// http://127.0.0.1:8001/join 에 get요청이 왔을 때
router.get('/join', (req, res)=>{
res.render('join', {title: '회원가입 - sns'});
});
// http://127.0.0.1:8001/ 에 get요청이 왔을 때
router.get('/', (req, res, next) => {
const twits = [];
res.render('main', {
title: 'sns',
twits,
});
});
module.exports = router;
Github: https://github.com/delay-100/study-node/tree/main/ch9/sns
Git [sns/views/layout.html
] - 전체 HTML 틀(말그대로 layout)
<meta name="viewport" content="width=device-width, user-scalable=no">
란?<meta http-equiv="X-UA-Compatible" content="IE=edge">
란?{# 기본 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" type="/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>
Git [sns/views/main.html
]
1. user 변수가 존재할 때 게시글 업로드 폼을 보여줌
2. 모든 사람에게 twits 보여줌
{# 1. user 변수가 존재할 때 게시글 업로드 폼을 보여줌, 2. 모든 사람에게 twits 보여줌 #}
{% extends 'layout.html' %}
{% block content %}
<div class="timeline">
{% if user %} {# 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="미리보기">
<input id="img-url" type="hidden" name="url">
</div>
<div>
<label id="img-label" for="img">사진 업로드</label>
<input id="img" type="file" accept="image/*">
<button id="twit-btn" type="submit" class="btn">짹짹</button>
</div>
</form>
</div>
{% endif %}
<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>
{% if not followerIdList.includes(twit.User.id) and twit.User.id !== userid %} {# 작성자를 제외하고, 나의 팔로워 아이디 목록에 작성자의 아이디가 없으면 팔로우 버튼을 보여주기 위함 #}
<button class="twit-follow">팔로우하기</button>
{% 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 %}
{% block script %}
<script>
if(document.getElementById('img')){
document.getElementById('img').addEventListenr('change', function(e){
const formData = new FormData();
console.log(this, this.files);
formData.append('img', this.files[0]); // 서버에 있는 가장 첫 번째 multer 파일 업로드, 썸네일 추가
// formData.append(key, value)인데 key에 있는 변수를 서버에서도 사용해야 함, this는 e.target
axios.post('/post/img', formData) // 두 번째 인수에 데이터를 넣어서 보냄
.then((res)=>{
document.getElementById('img-url').value = res.data.url;
document.getElementById('img-preview').src = res.data.url;
document.getElementById('img-preview').style.display = 'inline';
})
.catch((err)=>{
console.error(err);
});
});
}
document.querySelectorAll('.twit-follow').forEach(function(tag) {
tag.addEventListener('click', function() {
const myId = document.querySelector('#my-id'); // myId는 <input id="my-id" type="hidden" value="{{user.id}}">
if(myId){
const userId = tag.parentNode.querySelector('.twit-user-id').value;
if(userId !== myId.value){
if(confirm('팔로잉하시겠습니까?')){
axios.post(`user/${userId}/follow`)
.then(() =>{
location.reload();
})
.catch((err)=>{
console.error(err);
});
}
}
}
});
});
</script>
{% endblock %}
Git [sns/views/profile.html
] - 팔로잉, 팔로워 목록 보여줌
{# 팔로잉, 팔로워 목록 보여줌 #}
{% extends 'layout.html' %}
{% block content %}
<div class="timeline">
<div class="followings half">
<h2>팔로잉 목록</h2>
{% if user.Followings %}
{% for following in user.Followings %}
<div>{{following.nick}}</div>
{% endfor %}
{% endif %}
</div>
<div class="followers half">
<h2>팔로워 목록</h2>
{% if user.Followers %}
{% for follower in user.Followers %}
<div>{{follower.nick}}</div>
{% endfor %}
{% endif %}
</div>
</div>
Git [sns/views/join.html
] - 팔로잉, 팔로워 목록 보여줌
{# 회원가입 폼 #}
{% extends 'layout.html' %}
{% block content %}
<div class="timeline">
<form id="join-form" action="/auto/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')){
alert('이미 존재하는 이메일입니다.');
}
};
</script>
{% endblock %}
Git [sns/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')){
alert('이미 존재하는 이메일입니다.');
}
};
</script>
{% endblock %}
Git [sns/views/error.html
] - 에러관련 html
{% extends 'layout.html' %}
{% block content %}
<h1>{{message}}</h1>
<h2>{{error.status}}</h2>
<pre>{{err.stack}}</pre>
{% endblock %}
+Git [sns/public/main.css
] - 위의 html들에 대한 css 파일
실행화면(console)
npm start
현재까지 디렉토리 구조
실행화면 1(웹 브라우저) - http://127.0.0.1:8001
실행화면 2(웹 브라우저) - http://127.0.0.1:8001/profile
실행화면 3(웹 브라우저) - http://127.0.0.1:8001/join