NEMBV 15 게시판 만들기

11 분 소요

bootstrap-vue의 b-table을 이용하여 게시판을 제작해보겠다.

개요

웹프로그램에서 데이터베이스 기반으로 사이트를 만들때 아마 대부분 교육과정의 마무리로 게시판을 만들 것이다.

데이터베이스<>백<>프론트의 연동 과정을 응용하기 위함이다.

어떤 솔루션이던 게시판이 완성되었다면 사실상 해당 솔루션의 뼈대는 완성된 것이고 그것을 포장하기 위한 스토리와 몇가지 추가기능, 디자인만 남게 된다.

실제 커뮤니티 게시판을 만들어 본적은 없다(업무적으로 그다지 쓸모가 없다.)
생각나는 만큼만 설계해보도록 하겠다.

제작

페이지 요청 단한번으로 구조를 로드하고 페이징버튼등으로 안의 내용을 채워 넣는 테이블을 만들겠다.

datatables나 jquery table등을 다루어 봤다면 이해가 빠를 것이다.

서버사이드 데이터 프로바이더를 통해 동적으로 페이징이 되도록 제작할 것이다.

100개정도의 데이터는 서버사이드가 필요 없지만, 1000개를 다 불러 올수는 없기 때문

back-end

데이터베이스와 프론트간의 브릿지 역활을 하게 될 것이다.

models

게시판을 위한 모델

models/boards.js

const mongoose = require('mongoose');

const boardSchema = new mongoose.Schema({
  ut: { type: Date, default: Date.now }, // 변경 날짜 : timestamp
  ip: { type: Date, default: '' }, // ip address
  id: { type: String, default: '' }, // 작성자
  title: { type: String, default: '제목 없음', index: true }, // 제목
  content: { type: String, default: '' }, // 글
  cntView: { type: Number, default: 0 }, // 조회수
  cntLike: { type: Number, default: 0 }, // 좋아요수
  cmt_ids: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Comment' }], // 댓글
});

module.exports = mongoose.model('Board', boardSchema);

models/comments.js

const mongoose = require('mongoose');

const commentSchema = new mongoose.Schema({
  bd_id: { type: mongoose.Schema.Types.ObjectId, ref: 'Board', index: true },
  ut: { type: Date, default: Date.now }, // 변경 날짜 : timestamp
  ip: { type: Date, default: '' }, // ip address
  id: { type: String, default: '' }, // 작성자
  content: { type: String, default: '' }, // 글
  cntLike: { type: Number, default: 0 }, // 좋아요수
});

module.exports = mongoose.model('Comment', commentSchema);
  • board와 comment를 추가 했다.
  • board는 소속된 comments들의 _id값만 가지고 있다.
  • mongo의 row 자체가 json 문서이므로 사실 코멘트 내용 자체를 넣을 수도 있지만, comments들의 상호 연관이나 추후 빅데이터 처리에 적합하지 않기 때문에 그렇게 하지 않았다.

전반적으로 지난번 구현했던 company - groups의 관계와 유사함을 알 수 있다.

api

위의 모델로 api를 만든다.

routes/api/data/index.js

const router = require('express').Router();
const company = require('./company');
const group = require('./group');
const board = require('./board'); // add

router.use('/company', company);
router.use('/group', group);
router.use('/board', board); // add

router.all('*', (req, res) => {
    res.status(404).send({ success: false, msg: `unknown uri ${req.path}` });
});

module.exports = router;
  • /api/data/board 로 연결할 수 있도록 추가

routes/api/data/board/index.js

const router = require('express').Router();
const ctrl = require('./ctrls');

router.get('/', ctrl.list);
router.get('/:_id', ctrl.read);
router.post('/', ctrl.add);
router.put('/', ctrl.mod);
router.delete('/', ctrl.del);

router.all('*', (req, res) => {
    res.status(404).send({ success: false, msg: `unknown uri ${req.path}` });
});

module.exports = router;
  • get / : 게시물들 리스트
  • get /:_id : 특정 게시물 전체 가져오기
  • post: 게시물 등록
  • put: 게시물 수정
  • delete: 게시물 삭제

routes/api/data/board/ctrls.js

const Board = require('../../../../models/boards');
const Comment = require('../../../../models/comments');

exports.list = (req, res) => {
  let { draw, search, skip, limit, order, sort } = req.query;

  if(draw === undefined) return res.send({ success: false, msg: 'param err draw' });
  if(search === undefined) return res.send({ success: false, msg: 'param err search' });
  if(skip === undefined) return res.send({ success: false, msg: 'param err skip' });
  if(limit === undefined) return res.send({ success: false, msg: 'param err limit' });
  if(order === undefined) return res.send({ success: false, msg: 'param err order' });
  if(sort === undefined) return res.send({ success: false, msg: 'param err sort' });

  skip = parseInt(skip);
  limit = parseInt(limit);
  sort = parseInt(sort);

  const d = {
    draw: draw,
    cnt: 0,
    ds: [],
  };

  Board.count()
    .where('title').regex(search)
    .then((c) => {
      d.cnt = c;
      const s = {}
      s[order] = sort;
      return Board.find()
        .where('title').regex(search)
        .select('ut id title cntView cmt_ids')
        .sort(s)
        .skip(skip)
        .limit(limit);
    })
    .then((ds) => {
      d.ds = ds;
      res.send({success: true, d: d});
    })
    .catch((err) => {
      res.send({success: false, msg : err.message});
    });
};


exports.read = (req, res) => {
  const f = { _id: req.params._id };
  const s = { $inc: { cntView: 1 }};
  const o = { new: true };
  Board.findOneAndUpdate(f, s, o)
    // .where('_id').equals(_id)
    // .select('content')
    .populate('cmt_ids')
    .then((d) => {
      res.send({success: true, d: d});
    })
    .catch((err) => {
      res.send({success: false, msg : err.message});
    });
};

exports.add = (req, res) => {
  const { id, title, content } = req.body;
  console.log(req.body);

  if (!id) res.send({success: false, msg : 'id not exists'});
  if (!content) res.send({success: false, msg : 'content not exists'});

  const bd = new Board({
    id: id,
    title: title,
    content: content,
  });
  bd.save()
    .then(() => {
      res.send({success: true});
    })
    .catch((err) => {
      res.send({success: false, msg : err.message});
    });
};

exports.mod = (req, res) => {
  const set = req.body;

  if (!Object.keys(set).length) return res.send({ success: false, msg: 'body not set' });
  if (!set._id) return res.send({ success: false, msg: 'id not exitst' });
  set.ut = new Date();

  const f = { _id: set._id };
  const s = { $set: set };

  Board.findOneAndUpdate(f, s)
    .then(() => {
      res.send({ success: true });
    })
    .catch((err) => {
      res.send({ success: false, msg: err.message });
    });
};

exports.del = (req, res) => {
  const { _id } = req.query;

  if (!_id) return res.send({ success: false, msg: 'id not exists' });
  let cp;
  Board.findOne({ _id: _id })
    .then((r) => {
      cp = r;
      return Comment.remove({ _id: { $in: r.cmt_ids }});
    })
    .then(() => {
      return Board.remove({ _id: _id });
    })
    .then(() => { // { n: 1, ok: 1 }
      res.send({ success: true });
    })
    .catch((err) => {
      res.send({ success: false, msg: err.message });
    });
};
  • get
    • d.cnt, d.ds 의 데이터를 만드는 용도
    • select(‘ut id title cntView cmt_ids’) 리스트만 불러올 것이기 때문에 불러올 필드만 선택한다.
    • cmt_ids(댓글)의 경우 id들만 들어있다. 길이를 이용해 개수를 세는 정도로만 이용(데이터 절약)
  • get/:_id
    • content를 포함한 문서 전체를 읽어온다.
    • 읽을 때마다 cntView를 증가시킨다($inc).
    • cmt_ids 가 populate 되었기 때문에 안에 들어 있는 내용이 전부 나온다.
  • add
    • id, title, content를 받아서 추가한다.
  • mod
    • 글 수정, 수정한 시간을 갱신
  • del
    • 현재 보드를 찾아서 속해있는 댓글을 다 지우고 자신(보드)도 삭제

전반적으로 지난번 api/company와 비슷하다.

front-end

게시판 > 잡담이라는 페이지를 만들기 위해 메뉴에 추가한다.

fe/src/components/layout/top.vue

<b-nav-item-dropdown text="게시판">
    <topItem link="talk" icon="sss">
      잡담
    </topItem>
</b-nav-item-dropdown>

router

라우터 등록

fe/src/router/index.js

import talk from '@/components/page/board/talk'; 

{
  path: '/talk',
  name: 'talk',
  component: talk,
  meta: {
    title: '잡담',
    breadcrumb: [{
      text: '게시판 > 잡담',
      href: '/',
    }],
  },
},

page

구동할 페이지 작성

fe/src/components/page/board/talk.vue

<template>
  <div>
    <b-row class="mb-4">
      <b-col cols="6">
        <input type="text" v-model="filter" class="form-control" id="input-text" placeholder="검색 제목">
      </b-col>
      <b-col cols="6">
        <b-form-group horizontal label="Per" class="mb-0">
          <b-form-select :options="pageOptions" v-model="perPage" />
        </b-form-group>
      </b-col>
    </b-row>

    <b-table
      id="tt"
      ref="table"
      show-empty
      stacked="md"
      :items="list"
      :fields="fields"
      :current-page="currentPage"
      :per-page="perPage"
      :busy.sync="isBusy"
      :sort-by.sync="sortBy"
      :sort-desc.sync="sortDesc"
      :filter="filter"
      no-local-sorting
    >
      <!--@sort-changed="sortingChanged"-->
      <!--:no-local-sorting="true"-->
      <template slot="_id" slot-scope="row">
        <b-badge variant="info">
          {{ id2time(row.item._id) }}
        </b-badge>
      </template>
      <template slot="ut" slot-scope="row">
        <b-badge variant="info">
          {{ ago(row.item.ut) }}
        </b-badge>
      </template>
      <template slot="cmt_ids" slot-scope="row">
        <b-badge pill variant="success">
          {{ row.item.cmt_ids.length }}
        </b-badge>
      </template>
      <template slot="actions" slot-scope="row">
        <!-- We use @click.stop here to prevent a 'row-clicked' event from also happening -->
        <b-button size="sm" variant="primary" @click.stop="row.toggleDetails" @click="read(row)">
          {{ row.detailsShowing ? '숨기기' : '보기' }}
        </b-button>
      </template>
      <template slot="row-details" slot-scope="row">
        <b-card no-body
                :title="row.item.title"
        >
          <b-card-body>
            <p class="card-text" style="white-space: pre;">{{row.item.content}}</p>
          </b-card-body>
          <b-card-footer>
            <small class="text-muted">{{ ago(row.item.ut) }}</small>
            <b-button-group class="float-right">
              <b-btn variant="outline-warning" @click="mdModOpen(row.item)"><icon name="pencil"></icon></b-btn>
              <b-btn variant="outline-danger" @click="del(row.item)"><icon name="trash"></icon></b-btn>
            </b-button-group>
          </b-card-footer>
        </b-card>
      </template>
    </b-table>
    <!--<b-table id="my-table" show-empty :busy.sync="isBusy" :items="list"></b-table>-->


    <b-row>
      <b-col>
        <b-btn variant="info" @click="refresh">새로고침</b-btn>
        <b-btn variant="success" @click="mdAddOpen" >글쓰기</b-btn>
      </b-col>
      <b-col>
        <b-pagination
          align="right"
          size="md"
          :total-rows="totalRows"
          v-model="currentPage"
          :per-page="perPage"
          >
        </b-pagination>
      </b-col>
    </b-row>

    <b-modal ref="mdAdd" hide-footer title="새로운 글 작성">
      <b-form @submit="add">
        <b-form-group label="이름:"
                      label-for="fid">
          <b-form-input id="fid"
                        type="text"
                        v-model="form.id"
                        required
                        placeholder="홍길동">
          </b-form-input>
        </b-form-group>

        <b-form-group label="제목:"
                      label-for="ftitle">
          <b-form-input id="ftitle"
                        type="text"
                        v-model="form.title"
                        required
                        placeholder="아무거나">
          </b-form-input>
        </b-form-group>

        <b-form-group label="글"
                      label-for="fcontent">
          <b-form-textarea id="fcontent"
                           v-model="form.content"
                           placeholder="재미있는 글"
                           :rows="10"
                           :max-rows="20">
          </b-form-textarea>
        </b-form-group>

        <b-btn type="submit" variant="primary" class="float-right">글 쓰기</b-btn>
      </b-form>
    </b-modal>

    <b-modal ref="mdMod" hide-footer title="글 수정하기">
      <b-form @submit="mod">
        <b-form-group label="이름:"
                      label-for="fid">
          <b-form-input id="fid"
                        type="text"
                        v-model="form.id"
                        required
                        placeholder="홍길동">
          </b-form-input>
        </b-form-group>

        <b-form-group label="제목:"
                      label-for="ftitle">
          <b-form-input id="ftitle"
                        type="text"
                        v-model="form.title"
                        required
                        placeholder="아무거나">
          </b-form-input>
        </b-form-group>

        <b-form-group label="글"
                      label-for="fcontent">
          <b-form-textarea id="fcontent"
                           v-model="form.content"
                           placeholder="재미있는 글"
                           :rows="10"
                           :max-rows="20">
          </b-form-textarea>
        </b-form-group>

        <b-btn type="submit" variant="warning" class="float-right">글 수정</b-btn>
      </b-form>


      <!--<div slot="modal-footer">-->
        <!--<b-btn type="submit" class="float-right" variant="primary">저장</b-btn>-->
      <!--</div>-->
    </b-modal>
  </div>
</template>

<script>

  export default {
    name: 'talk',
    data() {
      return {
        fields: [
          {
            key: '_id',
            label: '등록일',
            sortable: true,
          },
          // {
          //   key: 'ut',
          //   label: '수정날짜',
          //   sortable: true,
          // },
          {
            key: 'id',
            label: '작성자',
            sortable: true,
          },
          {
            key: 'title',
            label: '제목',
            sortable: true,
          },
          {
            key: 'cntView',
            label: '조회',
            sortable: true,
          },
          {
            key: 'cmt_ids',
            label: '댓글',
            sortable: true,
          },
          {
            key: 'actions',
            label: '내용',
            sortable: true,
          },
        ],
        isBusy: false,
        items: [],
        currentPage: 1,
        perPage: 5,
        totalRows: 0,
        pageOptions: [5, 10, 15],
        sortBy: 'ut',
        sortDesc: false,
        filter: '',
        draw: 0,
        form: {
          _id: '',
          id: '',
          title: '',
          content: '',
        },
      };
    },
    // props: ['items'],
    mounted() {
      // this.list();
      // this.test();
    },
    computed: {
      setSkip() {
        if (this.currentPage <= 0) return 0;
        return (this.currentPage - 1) * this.perPage;
      },
      setSort() {
        return this.sortDesc ? -1 : 1;
      },
    },
    methods: {
      swalSuccess(msg) {
        return this.$swal({
          icon: 'success',
          title: '성공',
          text: msg,
          timer: 2000,
        });
      },
      swalWarning(msg) {
        return this.$swal({
          icon: 'warning',
          title: '실패',
          text: msg,
          timer: 2000,
        });
      },
      swalError(msg) {
        return this.$swal({
          icon: 'error',
          title: '에러',
          text: msg,
          timer: 2000,
        });
      },
      mdAddOpen() {
        this.form._id = '';
        this.form.id = '';
        this.form.title = '';
        this.form.content = '';
        this.$refs.mdAdd.show();
      },
      mdModOpen(v) {
        this.form._id = v._id;
        this.form.id = v.id;
        this.form.title = v.title;
        this.form.content = v.content;
        this.$refs.mdMod.show();
      },
      ago(t) {
        return this.$moment(t).fromNow();
      },
      id2time(_id) {
        return new Date(parseInt(_id.substring(0, 8), 16) * 1000).toLocaleString();
      },
      refresh() {
        this.$refs.table.refresh();
      },
      sortingChanged(ctx) {
        this.sortBy = ctx.sortBy;
        this.sortDesc = ctx.sortDesc;
        // if (ctx.sortDesc) this.s.order = -1;
        // else this.s.order = 1;
        this.list();
        // console.log(ctx);
      },
      list(ctx) {
        this.sortBy = ctx.sortBy;
        this.sortDesc = ctx.sortDesc;
        this.isBusy = true;
        const p = this.$axios.get(`${this.$cfg.path.api}data/board`, {
          params: {
            draw: (this.draw += 1),
            search: this.filter,
            skip: this.setSkip,
            limit: this.perPage,
            order: this.sortBy,
            sort: this.setSort,
          },
        });
        return p.then((res) => {
          if (!res.data.success) throw new Error(res.data.msg);
          this.totalRows = res.data.d.cnt;
          this.isBusy = false;
          // const items = res.data.d.ds;
          return res.data.d.ds;
        })
          .catch((err) => {
            this.isBusy = false;
            this.swalError(err.message);
            return [];
          });
      },
      read(r) {
        if (r.detailsShowing) return;
        const _id = r.item._id;
        this.isBusy = true;
        this.$axios.get(`${this.$cfg.path.api}data/board/${_id}`)
          .then((res) => {
            if (!res.data.success) throw new Error(res.data.msg);
            r.item.cntView = res.data.d.cntView;
            r.item.content = res.data.d.content;
            // console.log(res.data.d);
            // console.log(r.item);
            this.isBusy = false;
          })
          .catch((err) => {
            this.isBusy = false;
            this.swalError(err.message);
          });
      },
      add(evt) {
        evt.preventDefault();
        this.$axios.post(`${this.$cfg.path.api}data/board`, this.form)
          .then((res) => {
            if (!res.data.success) throw new Error(res.data.msg);
            return this.swalSuccess('글 작성 완료');
          })
          .then(() => {
            this.$refs.mdAdd.hide();
            this.refresh();
          })
          .catch((err) => {
            this.swalError(err.message);
          });
      },
      mod() {
        this.$swal({
          title: '작성한 글을 수정하시겠습니까?',
          dangerMode: true,
          buttons: {
            cancel: {
              text: '취소',
              visible: true,
            },
            confirm: {
              text: '수정',
            },
          },
        })
          .then((sv) => {
            if (!sv) throw new Error('');
            return this.$axios.put(`${this.$cfg.path.api}data/board`, this.form);
          })
          .then((res) => {
            if (!res.data.success) throw new Error(res.data.msg);
            return this.swalSuccess('글 수정 완료');
          })
          .then(() => {
            this.$refs.mdMod.hide();
            this.refresh();
            // this.list(); // todo: instead one article..
          })
          .catch((err) => {
            if (err.message) return this.swalError(err.message);
            this.swalWarning('글 수정 취소');
          });
      },
      del(v) {
        this.$swal({
          title: '글 삭제',
          dangerMode: true,
          buttons: {
            cancel: {
              text: '취소',
              visible: true,
            },
            confirm: {
              text: '삭제',
            },
          },
        })
          .then((sv) => {
            if (!sv) throw new Error('');
            return this.$axios.delete(`${this.$cfg.path.api}data/board`, {
              params: { _id: v._id },
            });
          })
          .then((res) => {
            if (!res.data.success) throw new Error(res.data.msg);
            return this.swalSuccess('글 삭제 완료');
          })
          .then(() => {
            this.refresh();
          })
          .catch((err) => {
            if (err.message) return this.swalError(err.message);
            this.swalWarning('글 삭제 취소');
          });
      },
    },
  };
</script>

<style scoped>
</style>

데이터들은 선언, 변경 즉시 테이블에 바로 반영되기 때문에 먼저 script의 data 구조를 판단해야한다.

  • script
    • data
      • fields: 화면에 표시할 헤더부분이다(th), sortTable이 true면 소트버튼이 나오며 자동 연동 소트가 된다.

        이때 소트는 html요소의 소트 방법에 따라 local로 할것인지 프로바이더를 통할것인지 정할 수 있다.

      • isBusy: 데이터를 불러오는 도중 또다른 요청을 막기 위함이다. true중일때는 테이블이 회색이 된다. 그러므로 완료 후 꼭 false가 되야한다
      • items: 실제 데이터, axios와 묶여 있는 데이터가 바뀌는 즉시 테이블도 변경된다.

        주의 할 것은 items만 바뀐다고 데이터가 변경되는 것은 아니고 fetch될 ajax 요청 promise 리턴으로 동작한다.(3시간 넘게 해맸음..)

      • currentPage: 현재 페이지
      • perPage: 한번에 볼 수 있는 로우 양
      • totalRows: 모든 데이터 양
      • pageOptions: perPage할 양 정의
      • sortBy: 소트할 필드명
      • sortDesc: 소트 내림차, 오름차 방향
      • filter: 검색어
      • form: 글 작성시 데이터

        원래 쓰던 변수가 있으나 가급적 https://bootstrap-vue.js.org/docs/components/table 대로 네이밍했다. 학습을 위해…

  • html
    • input filter: 검색어 1글자 입력이라도 바로 데이터 동기화가 일어난다. 추후 2글자 이상등 예외처리가 필요함
    • b-form-select: data.perPage와 연동 변경시 데이터 동기화
    • b-table
      • properties
        • id, ref: 나중에 참조로 이 테이블을 핸들링할때 쓰인다. ref만 있으면된다.
        • show-empty: 데이터가 없을 때 없다고 표시해줌
        • stacked: 모바일등에서 필드가 다 표시가 안될때 아래로 내려준다.
        • items: 데이터 혹은 프로바이더 데이터만 넣으면 일회성이지만 프로바이더(axios등으로 외부 api 정의)를 등록하면 데이터 동기화가 된다.
        • fields: 표시할 컬럼
        • current-page, per-page, sort-by.sync, sort-desc.sync, filter : 위에 선언된 변수로 데이터 동기화가 된다.
        • no-local-sorting: 로컬소팅을 안 쓰겠다는 것이다.
        • busy.sync: 데이터 동기화중에 재동기화 방지
      • template.slot: 표현될 데이터가 단순 text가 아닐 경우 해당 슬롯(cell) 내용을 원하는데로 변경할 수 있다.
      • row: 각 로의 데이터 객체
        • items: 실제 데이터가 들어있다. eg) row.items._id
        • row.toggleDetail: 해당 로를 클릭했을 때 아래로 자세한 내용 화면을 토글할 수 있다.
        • row-detail: 간단한 카드 등록
    • b-pagination: b-table과 변수를 같이 쓰기 때문에 데이터 동기화 또한 같이 된다.
  • script
    • methods
      • list(ctx): table 프로바이더로 등록되어 있을 경우 ctx에 현재 행위에 대한 값이 내려온다. 수신부 프라미스를 리턴하면 된다.
      • read: 해당로우 전체를 가져오고 데이터를 치환한다.
      • add: 데이터 추가
      • mod: 데이터 수정
      • del: 데이터 삭제
      • refresh: 해당 테이블 동기화한다.

그밖에 해석

  • methods.id2time: mongoDB는 자체적으로 _id라는 값을 가지고 있는데. _id중 8개의 스트링은 헥스스트링으로 시간정보를 담고 있다.
    그래서 등록시간을 굳이 넣지 않고 수정시간만 있는 것이다.
  • 콘텐츠를 표시할때 style=”white-space: pre;” 을 주면 \n등의 리턴이 화면에 표시된다.

구현된 화면

직접 눈으로 확인하는게 좋을 것 같아서 영상으로 만들어봤다.

mongoDB atlas 무료를 이용해서 매우 느리다. 하지만 매우 느리기 때문에 isBusy가 어떻게 동작하는지 볼 수 있다

전체 소스

https://github.com/fkkmemi/nembv

결론

https://bootstrap-vue.js.org/docs/components/table 에 프론트 쪽은 자세히 나와있다.

디비<>백엔드<>프론트가 어떻게 실제로 동작하는지 눈으로 확인해보았다.

댓글남기기