SNS 만들기 -1(with Node, MySQL, Nunjucks)

백지연·2022년 1월 27일
3

NodeJS

목록 보기
15/26
post-thumbnail

이번 포스팅부터는 여러 기능들이 들어갈 웹 사이트를 만들어보려고 한다! 지금까지 포스팅했던 내용들은 자세하게 설명하지 않고, 링크만 걸어둘 계획이다.(이해가 안 된다면 걸어둔 링크에 접속해보자!)

SNS의 기능을 모두 만들게 되면 로그인, 이미지 업로드, 게시글 작성, 해시태그 검색, 팔로잉 등을 구현하게 된다.

책 Node.js 교과서(개정 2판) 책의 9장의 내용을 참고했다.
+모든 코드는 github주소에 있다.

개발 환경

이번 포스팅에서 구현할 것

  1. 프로젝트 기본 뼈대 잡기
  2. 프론트엔드 화면 구현하기

1. 프로젝트 기본 뼈대 잡기

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

npm 설치

npm init

[설치할 모듈]

  • sequelize
  • sequelize-cli
  • mysql2
$ npm i sequelize mysql2 sequelize-cli
$ npx sequelize init

**npx sequelize init 명령어는 config, migrations, models, seeders 폴더가 생성됨, npx 명령어를 사용하는 이유는 전역 설치(npm i -g)를 피하기 위함

  • express
  • cookie-parser
  • express-session
  • morgan
  • multer
  • dotenv
$ 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;

2. 프론트엔드 화면 구현하기

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

Git [sns/views/layout.html] - 전체 HTML 틀(말그대로 layout)

{# 기본 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&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>

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


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

0개의 댓글