본문 바로가기

ComputerScience/NodeJs

node - 9.1 익스프레스로 SNS 서비스 만들기(프로젝트 구조)

728x90
 

더북(TheBook): Node.js 교과서 개정 2판

 

thebook.io

* 위 내용을 정리하였음

1. package.json 만들기

npm init
{
  "name": "sns",
  "version": "0.0.1",
  "description": "simple sns serveice with express",
  "main": "app.js",
  "scripts": {
    "start": "nodemon app"
  },
  "author": "jsdysw",
  "license": "MIT"
}

- npm start로 nodmon을 실행할 수 있는 스크립트도 꼭 적어주자.

2. package 설치

- NoSQL 데이터 베이스를 사용할 것 이므로 relation을 자바스크립트 객체와 매핑해주는 시퀄라이즈를 설치한다.

npm i sequelize mysql2 sequelize-cli

- config, models, migrations, seeders 폴더 생성을 위해 다음 명령어를 실행 한다.

npx sequelize init

- 시퀄라이즈를 사용하려면 -g(전역) 설치를 해야 한다. 하지만 전역설치는 package.json에 기록되지 않기 때문에 npm i로 설치한 다음 npx로 실행해서 전역 설치한 효과를 얻도록 한다.

- npx는 패키지를 실행하는데 단 한번 실행을 위해 package.json에 있는 모듈을 실행한다. 만약 모듈이 없다면 설치하고 실행한 다음 바로 fallback한다.

npm i express cookie-parser express-session morgan multer dotenv nunjucks
npm i -D nodemon

- express : npm에서 제공하는 웹 서버 프레임워크

- cookie-parser : 요청에 동봉된 쿠키를 해석해 req.cookies 객체로 만들어준다.

- express-session : 세션관리

- morgan : 요청,응답 정보를 콘솔에 기록

- multer : 이미지, 동영상 등의 파일들을 멀티파트 형식으로 업로드할 때 사용하는 미들웨어

- dotenv : .env 파일 관리

- nunjucks : 템플릿 엔진

- nodemon : 소스가 바뀌었을때 자동으로 서버를 재실행, 개발용으로 설치

- public : 정적 파일들 위치

- routes : 라우터 위치

- views : 템플릿 파일들 위치

3. 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();
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));
app.use(session({
  resave: false,
  saveUninitialized: false,
  secret: process.env.COOKIE_SECRET,
  cookie: {
    httpOnly: true,
    secure: false,
  },
}));

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'), '번 포트에서 대기중');
});

- pageRouter, 404 응답 미들웨어, 에러처리 미들웨어가 있다.

4. .env

COOKIE_SECRET=cookiesecret

5. routes/page.js

const express = require('express');

const router = express.Router();

router.use((req, res, next) => {
  res.locals.user = null;
  res.locals.followerCount = 0;
  res.locals.followingCount = 0;
  res.locals.followerIdList = [];
  next();
});

router.get('/profile', (req, res) => {
  res.render('profile', { title: '내 정보 - NodeBird' });
});

router.get('/join', (req, res) => {
  res.render('join', { title: '회원가입 - NodeBird' });
});

router.get('/', (req, res, next) => {
  const twits = [];
  res.render('main', {
    title: 'NodeBird',
    twits,
  });
});

module.exports = router;

- 시작하자마자 router.use로 라우터용 미들웨어를 하나 만들었다.

- 템플릿 엔진에서 사용할 user, followingCount 등 변수를 res.locals에 저장한다. res.locals는 어디서든 사용할 수 있다.

- 위 값들을 모든 템플릿 엔진들이 공통으로 사용할 것이라 여기에 한번에 저장한것이다.

6. views

// view/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">
    <link rel="stylesheet" href="/main.css">
  </head>
  <body>
    <div class="container">
      <div class="profile-wrap">
        <div class="profile">
          {% if user and user.id %}
            <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 %}
          <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://www.zerocho.com" target="_blank">ZeroCho</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>

- 렌더링할때 user가 존재하면 사용자 정보, 팔로워 수를 보여주고 그렇지 않다면 로그인 메뉴를 보여준다.

// views/main.html

{% extends 'layout.html' %}

{% block content %}
    <div class="timeline">
      {% 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="미리보기">
              <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 %}
          <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 !== user.id %}
              <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').addEventListener('change', function(e) {
        const formData = new FormData();
        console.log(this, this.files);
        formData.append('img', this.files[0]);
        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');
        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 %}

- user가 존재하면 for문으로 twits 배열 안에 게시물 정보들을 화면에 그린다.

// 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>
{% endblock %}
// 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 %}

- 회원가입 폼이다.

// views/error.html

{% extends 'layout.html' %}

{% block content %}
  <h1>{{message}}</h1>
  <h2>{{error.status}}</h2>
  <pre>{{error.stack}}</pre>
{% endblock %}

- 서버에서 에러 발생시 에러 내용을 보여주는 화면이다.

7. public

- 디자인을 위한 css파일이 담겨있다.

// public/main.css

* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; height: 100%; }
.btn {
  display: inline-block;
  padding: 0 5px;
  text-decoration: none;
  cursor: pointer;
  border-radius: 4px;
  background: white;
  border: 1px solid silver;
  color: crimson;
  height: 37px;
  line-height: 37px;
  vertical-align: top;
  font-size: 12px;
}
input[type='text'], input[type='email'], input[type='password'], textarea {
  border-radius: 4px;
  height: 37px;
  padding: 10px;
  border: 1px solid silver;
}
.container { width: 100%; height: 100%; }
@media screen and (min-width: 800px) {
  .container { width: 800px; margin: 0 auto; }
}
.input-group { margin-bottom: 15px; }
.input-group label { width: 25%; display: inline-block; }
.input-group input { width: 70%; }
.half { float: left; width: 50%; margin: 10px 0; }
#join { float: right; }
.profile-wrap {
  width: 100%;
  display: inline-block;
  vertical-align: top;
  margin: 10px 0;
}
@media screen and (min-width: 800px) {
  .profile-wrap { width: 290px; margin-bottom: 0; }
}
.profile {
  text-align: left;
  padding: 10px;
  margin-right: 10px;
  border-radius: 4px;
  border: 1px solid silver;
  background: lightcoral;
}
.user-name { font-weight: bold; font-size: 18px; }
.count { font-weight: bold; color: crimson; font-size: 18px; }
.timeline {
  margin-top: 10px;
  width: 100%;
  display: inline-block;
  border-radius: 4px;
  vertical-align: top;
}
@media screen and (min-width: 800px) { .timeline { width: 500px; } }
#twit-form {
  border-bottom: 1px solid silver;
  padding: 10px;
  background: lightcoral;
  overflow: hidden;
}
#img-preview { max-width: 100%; }
#img-label {
  float: left;
  cursor: pointer;
  border-radius: 4px;
  border: 1px solid crimson;
  padding: 0 10px;
  color: white;
  font-size: 12px;
  height: 37px;
  line-height: 37px;
}
#img { display: none; }
#twit { width: 100%; min-height: 72px; }
#twit-btn {
  float: right;
  color: white;
  background: crimson;
  border: none;
}
.twit {
  border: 1px solid silver;
  border-radius: 4px;
  padding: 10px;
  position: relative;
  margin-bottom: 10px;
}
.twit-author { display: inline-block; font-weight: bold; margin-right: 10px; }
.twit-follow {
  padding: 1px 5px;
  background: #fff;
  border: 1px solid silver;
  border-radius: 5px;
  color: crimson;
  font-size: 12px;
  cursor: pointer;
}
.twit-img { text-align: center; }
.twit-img img { max-width: 75%; }
.error-message { color: red; font-weight: bold; }
#search-form { text-align: right; }
#join-form { padding: 10px; text-align: center; }
#hashtag-form { text-align: right; }
footer { text-align: center; }

8. 실행

- npm start로 서버 실행 후 http://localhost:8001에 접속하여 확인해보자.

 

728x90
반응형