0503 - 0509

0503

게시물의 댓글 추가 기능

게시물에 대한 댓글을 추가하는 기능을 구현하였다.
api를 적용하는 방법은 이전과 비슷했고, 사실상 css를 조정하고 레이아웃을 수정하는 데에 시간을 많이 할애하였다.
Comment를 컴포넌트로 구분하여 PostContent 컴포넌트 안에 삽입하여 구현하는 방식을 사용하였다.

<template>
  <div class="content_comment">
    <div class="comment_header">
      <i class="far fa-comment-dots comment_img"></i>
      <span class="comment_title">댓글</span>
      <span class="comment_cnt"></span>
    </div>

    <div class="comment_input">
      <div class="comment_input_name"></div>
      <table class="content_mainTable02">
        <colgroup>
          <col />
          <col style="width: 10%;" />
        </colgroup>
        <tr>
          <td>
            <textarea
              placeholder="댓글을 남겨보세요"
              class="comment_input_text"
              v-model="content"
            />
          </td>
          <td>
            <button class="btn_basic" @click="addCommentMethod">등록</button>
          </td>
        </tr>
      </table>
    </div>

    <div class="content_commentAfter">
      <table class="content_mainTable02">
        <colgroup>
          <col style="width: 10%;" />
          <col />
          <col style="width: 10%;" />
        </colgroup>
        <tr>
          <td><!--이미지--></td>
          <td><!--입력된 내용--></td>
          <td><button class="btn_basic">삭제</button></td>
        </tr>
      </table>
    </div>
  </div>
</template>

<script>
import { mapActions, mapState } from "vuex";

export default {
  data() {
    return {
      boardNo: this.$route.params.boardNo,
      content: "",
    };
  },
  computed: {
    ...mapState({
      boardInfo: (state) => state.board.boardInfo,
      memberName: (state) => state.member.memberName,
    }),
  },
  methods: {
    ...mapActions(["addComment"]),
    async addCommentMethod() {
      try {
        const param = {
          boardNo: this.boardNo,
          content: this.content,
        };

        const result = await this.addComment(param);
        console.log(result);
      } catch (error) {
        console.log(error.response);
        alert(error.response.data.message);
      }
    },
  },
};
</script>

<style scoped src="../../assets/css/post/comment.css"></style>
  • 라우터 변수로 받은 boardNo와 글 내용을 변수로 하여 댓글을 입력하는 기능을 구현하였다.

댓글 목록, 삭제기능과 글 수정 삭제 기능만 구현하면 모든 메인 기능이 완성된다.
빠르게 마무리 하고, 세부 오류 사항들을 잡은 뒤 다음 프로젝트로 넘어가야겠다.

0504

댓글 목록, 삭제 기능

해당 게시글에 대한 댓글의 목록을 보여주고, 댓글을 입력하거나 삭제하였을 때 알맞게 상태가 변경되도록 기능을 구현하였다.
이번에는 좀 더 vuex를 논리적으로 잘 사용하기 위해 신경을 써가며 구현하였다.

<!-- PostComment.vue -->
<template>
  <div class="content_comment">
    <div class="comment_header">
      <i class="far fa-comment-dots comment_img"></i>
      <span class="comment_title">댓글</span>
      <span class="comment_cnt"></span>
    </div>

    <div
      class="comment_list"
      v-for="(list, index) in commentList"
      v-bind:key="index"
    >
      <table class="content_mainTable02">
        <colgroup>
          <col style="width: 2%;" />
          <col />
          <col style="width: 12%;" />
        </colgroup>
        <tr>
          <td></td>
          <td>
            <div class="comment_list_name">
              
            </div>
            <div class="comment_list_content">
              
            </div>
            <div class="comment_list_date">
              
            </div>
          </td>
          <td>
            <button
              class="btn_basic"
              @click="deleteCommentMethod(list.commentNo)"
            >
              삭제
            </button>
          </td>
        </tr>
      </table>

      <hr v-if="index != commentList.length - 1" />
    </div>

    <div class="comment_input">
      <div class="comment_input_name"></div>
      <table class="content_mainTable02">
        <colgroup>
          <col />
          <col style="width: 10%;" />
        </colgroup>
        <tr>
          <td>
            <textarea
              placeholder="댓글을 남겨보세요"
              class="comment_input_text"
              v-model="content"
            />
          </td>
          <td>
            <button class="btn_basic" @click="addCommentMethod">등록</button>
          </td>
        </tr>
      </table>
    </div>
  </div>
</template>

<script>
import { mapActions, mapState } from "vuex";

export default {
  data() {
    return {
      boardNo: this.$route.params.boardNo,
      content: "",
    };
  },
  computed: {
    ...mapState({
      boardInfo: (state) => state.board.boardInfo,
      commentList: (state) => state.board.commentList,
      memberName: (state) => state.member.memberName,
    }),
  },
  methods: {
    ...mapActions(["addComment", "getCommentList", "deleteComment"]),
    async addCommentMethod() {
      try {
        const param = {
          boardNo: this.boardNo,
          content: this.content,
        };

        await this.addComment(param);
        this.afterAddComment();
      } catch (error) {
        console.log(error.response);
        alert(error.response.data.message);
      }
    },
    async getCommentListMethod() {
      try {
        const param = {
          boardNo: this.boardNo,
          recordsPerPage: 100,
        };

        await this.getCommentList(param);
      } catch (error) {
        console.log(error.response);
      }
    },
    async deleteCommentMethod(commentNo) {
      try {
        if (!confirm("댓글을 삭제하시겠습니까?")) {
          return;
        }

        const param = {
          commentNo: commentNo,
          boardNo: this.boardNo,
        };

        await this.deleteComment(param);
      } catch (error) {
        console.log(error.response);
      }
    },
    afterAddComment() {
      this.content = "";
    },
  },
};
</script>

<style scoped src="../../assets/css/post/comment.css"></style>
/* eslint-disable no-unused-vars */
import boardService from "@/api/board";

export const board = {
  state: {
    boardInfo: {},
    commentList: [],
  },
  actions: {
    async getBoardList({ commit }, criteria) {
      const result = await boardService.getBoardList(criteria);
      return result.data.data;
    },
    async getBoardSearchList({ commit }, criteria) {
      const result = await boardService.getBoardSearchList(criteria);
      console.log(result.data.data);
      return result.data.data;
    },
    async getBoardDetail({ commit, dispatch }, boardNo) {
      const result = await boardService.getBoardDetail(boardNo);
      commit("setBoardInfo", result.data.data.info);
      dispatch("getCommentList", {
        boardNo: boardNo,
        recordsPerPage: 100,
      });
    },
    async addPost({ commit }, post) {
      const result = await boardService.addPost(post);
      return result.data;
    },
    async clickLike({ commit, dispatch }, boardNo) {
      await boardService.clickLike(boardNo);
      dispatch("getBoardDetail", boardNo.boardNo);
    },
    async addComment({ commit, dispatch }, data) {
      await boardService.addComment(data);
      dispatch("getBoardDetail", data.boardNo);
    },
    async deleteComment({ commit, dispatch }, data) {
      await boardService.deleteComment(data.commentNo);
      dispatch("getBoardDetail", data.boardNo);
    },
    async getCommentList({ commit }, data) {
      const result = await boardService.getCommentList(data);
      commit("setCommentList", result.data.data.list);
    },
  },
  mutations: {
    setBoardInfo(state, info) {
      state.boardInfo = info;
    },
    setCommentList(state, list) {
      state.commentList = list;
    },
  },
};
  • board의 state는 게시글의 세부 정보들을 포함하고 있는 boardInfo와 게시글의 댓글 목록인 commentList가 있다.
  • 댓글을 입력하거나 삭제했을 경우, 화면에 새로 생성되거나 삭제된 댓글 목록을 보여주기 위해 다시 getCommentList를 호출해야 했다.
  • 그리고 boardInfo의 속성인 댓글의 개수도 업데이트 해서 화면에 보여줘야 했는데, getCommentList를 호출하고 나서 getBoardDetail을 호출하기 보다는 애초에 게시글을 처음에 불러올 때 getBoardDetail이 호출되고, Comment는 그 안의 컴포넌트로 구성되어 있기 때문에 getBoardDetail에서 getCommentList를 호출하고 화면 업데이트가 필요하다면 getBoardDetail만 호출하는 것이 논리적으로 적합하다고 생각하여 그렇게 구성하였다.

0505

게시글 삭제 기능

<!-- PostDetailHeader.vue -->
<template>
  <div class="title_mainTop">
    <div class="title_leftNav">
      <button
        class="btn_basic"
        v-if="memberNo === boardInfo.memberNo"
        @click="routeEditPage"
      >
        수정
      </button>
      <button
        class="btn_basic"
        v-if="memberNo === boardInfo.memberNo"
        @click="deletePostMethod"
      >
        삭제
      </button>
    </div>
    <div class="title_center">
      <span @click="routeMainPage">Main</span>
    </div>
    <div class="title_rightNav">
      <ul>
        <li><img class="rightNav_profile" /></li>
        <li>
          <span title="사용자정보"></span>
        </li>
        <li><button @click="logoutMethod">로그아웃</button></li>
      </ul>
    </div>
  </div>
</template>

<script>
import { mapState, mapMutations, mapActions } from "vuex";

export default {
  data() {
    return {
      boardNo: this.$route.params.boardNo,
    };
  },
  computed: {
    ...mapState({
      memberName: (state) => state.member.memberName,
      memberNo: (state) => state.member.memberNo,
      boardInfo: (state) => state.board.boardInfo,
    }),
  },
  methods: {
    ...mapActions(["editPost", "deletePost"]),
    ...mapMutations(["logout"]),
    async editPostMethod() {
      try {
        await this.editPost();
      } catch (error) {
        console.log(error.response);
      }
    },
    async deletePostMethod() {
      try {
        if (!confirm("게시글을 삭제하시겠습니까?")) {
          return;
        }

        await this.deletePost(this.boardNo);
        this.$router.replace("/main");
      } catch (error) {
        console.log(error.response);
      }
    },
    routeEditPage() {
      this.$router.push(`/post/${this.boardNo}/edit`);
    },
    logoutMethod() {
      this.logout();
      this.$router.push("/login");
    },
    routeMainPage() {
      this.$router.push("/main");
    },
  },
};
</script>

<style scoped src="@/assets/css/post/header.css"></style>
  • 게시글 세부 정보에서 상단 버튼에 있는 수정, 삭제 버튼을 글 작성자에 한해서 활성화시켰다.
  • 삭제 버튼 클릭 시, confirm 창을 하나 띄우고 확인 버튼을 클릭하게 되면 boardNo로 조회하여 해당 게시글, 좋아요 정보, 댓글까지 모두 삭제시키는 기능을 구현하였다.
  • 수정 버튼 클릭 시, 해당 게시글을 수정할 수 있는 페이지로 이동시키는 메소드를 작성하였다.

0506

게시글 수정 기능

<!-- PostEditForm -->
<template>
  <div class="content_mainBox">
    <div class="content_tableArea02">
      <table class="content_mainTable02">
        <colgroup>
          <col style="width: 8%;" />
          <col />
        </colgroup>
        <tr>
          <th>제목</th>
          <td>
            <input
              type="text"
              class="board_write"
              placeholder="제목을 입력해 주세요."
              v-model="title"
            />
          </td>
        </tr>
      </table>
    </div>
    <div class="content_mainCont">
      <textarea placeholder="내용을 입력하세요." v-model="content"></textarea>
    </div>
  </div>
</template>

<script>
import { mapState, mapActions } from "vuex";

export default {
  data() {
    return {
      boardNo: this.$route.params.boardNo,
      title: "",
      content: "",
    };
  },
  computed: {
    ...mapState({
      boardInfo: (state) => state.board.boardInfo,
    }),
  },
  methods: {
    ...mapActions(["getBoardDetail", "editPost"]),
    async getBoardDetailMethod() {
      try {
        await this.getBoardDetail(this.boardNo);
        this.title = this.boardInfo.title;
        this.content = this.boardInfo.content;
      } catch (error) {
        console.log(error.response);
      }
    },
    async editPostMethod() {
      try {
        const post = {
          boardNo: this.boardNo,
          title: this.title,
          content: this.content,
        };

        await this.editPost(post);
        this.$router.push(`/post/${this.boardNo}`);
      } catch (error) {
        console.log(error.response);
        alert(error.response.data.message);
      }
    },
  },
  created() {
    this.getBoardDetailMethod();
  },
};
</script>

<style scoped src="@/assets/css/post/form.css"></style>
  • 게시글 수정 기능은 작성 기능과 비슷한 원리로 header에서 이벤트를 받아서 실행시켜 기능을 완성하였다.

0507

개행과 띄워쓰기

이제 메인 기능은 모두 완성 시켰다. 그러나 세부적으로 오류들을 수정할 것이 남아있어서 마무리 짓기로 하였다.
우선 가장 먼저 손봐야 할 부분은 게시글을 작성할 때, 엔터키를 입력하면 나중에 글을 불러올 때 한 줄이 넘어간 채로 받아와야 했는데 원하지 않는 결과를 얻게 되었다.
이를 해결하기 위해 db에 게시글을 저장할 때 \n 이스케이프 문자를 <br/>로 치환하여 저장하고, 여러 번 띄워쓰기를 입력하는 것 역시 마찬가지로 해결하기 위해 &nbsp;로 치환하여 저장하였다.

<!-- PostContent.vue -->
<template>
  <div class="content" v-html="boardInfo.content"></div>
</template>

<!-- PostForm.vue -->
<script>
import { mapActions } from "vuex";

export default {
  data() {
    return {
      title: "",
      content: "",
    };
  },
  methods: {
    ...mapActions(["addPost"]),
    async addPostMethod() {
      try {
        const post = {
          title: this.title,
          content: this.formatContent(this.content),
        };
        const result = await this.addPost(post);
        console.log(result);
        this.$router.push("/main");
      } catch (error) {
        console.log(error.response);
        alert(error.response.data.message);
      }
    },
    formatContent(str) {
      str = str.replace(/\n/g, "<br/>");
      return str.replace(/ /g, "&nbsp;&nbsp;");
    },
  },
};
</script>
  • 기능의 핵심만 보여주기 위해 필요한 부분만 작성하였다.
  • 우선 게시글을 작성하는 폼에서 api를 호출하기 전에 파라미터 content에 값을 원하는 값으로 치환하는 formatContent를 이용해서 값을 넣고 db에 저장한다.
  • db에는 개행문자 대신 <br/>태그가, 띄워쓰기 대신 &nbsp;가 저장되었기 때문에, 게시글을 보여주는 템플릿에서 v-html을 사용하여 정상적으로 띄워쓰기와 개행이 적용된 게시글을 나타낼 수 있다.

0508

날짜 포맷

현재는 백엔드 서버에서 날짜를 yyyy-mm-dd hh:mm:ss 형식으로 받아오고 있다.
게시글 목록을 보여줄 때 그날 작성한 글은 시간과 분만, 그 외의 목록은 연도와 달, 일까지만 출력하도록 필터를 사용해서 구현하였다.

<template>
  <div class="content_mainBox">
    <div class="content_search">
      <input
        type="text"
        class="login_id"
        placeholder="검색어를 입력해주세요"
        v-model="searchWord"
        @keyup.enter="getBoardListMethod"
      />
      <button class="searchBtn" title="search" @click="getBoardListMethod">
        검색
      </button>
    </div>

    <div class="result_total">검색결과 건</div>

    <div class="content_tableArea">
      <table class="content_mainTable">
        <colgroup>
          <col style="width: 10%;" />
          <col />
          <col style="width: 15%;" />
          <col style="width: 15%;" />
          <col style="width: 10%;" />
        </colgroup>
        <tr>
          <th>No</th>
          <th>제목</th>
          <th>작성자</th>
          <th>작성일</th>
          <th>좋아요</th>
        </tr>
        <tr v-for="(board, index) in boardList.list" v-bind:key="index">
          <td></td>
          <td>
            <a @click="routeDetailPage(board.boardNo)"></a>
            <span class="comment_cnt" v-if="board.commentCnt != 0">
              []
            </span>
          </td>
          <td></td>
          <td></td>
          <td></td>
        </tr>
      </table>
    </div>

    <div class="no_result" v-if="boardList.totalCnt == 0">
      검색 결과가 없습니다.
    </div>
    <pagination
      @update="changePage"
      v-bind:currentPageNo="boardList.currentPageNo"
      v-bind:totalCnt="boardList.totalCnt"
      v-bind:recordsPerPage="boardList.recordsPerPage"
      v-bind:pagePerLink="5"
    >
    </pagination>
  </div>
</template>

<script>
export default {
  filters: {
    formatDate(regDate) {
      const now = new Date();
      const year = now.getFullYear();
      const month = now.getMonth() + 1;
      const date = now.getDate();

      const regYear = regDate.slice(0, 4);
      let regMonth = "";
      if (regDate.charAt(5) == 0) {
        regMonth = regDate.charAt(6);
      } else {
        regMonth = regDate.slice(5, 7);
      }
      let regDay = "";
      if (regDate.charAt(8) == 0) {
        regDay = regDate.charAt(9);
      } else {
        regDay = regDate.slice(8, 10);
      }

      if (regYear == year && regMonth == month && regDay == date) {
        return regDate.slice(10, 16);
      } else {
        return regDate.slice(0, 10);
      }
    }
  }
}
<script />
  • 현재 시간과 받아온 시간을 비교하여, 연도 달 일이 일치하다면 시간과 분만, 아니라면 날짜를 출력하는 filter 함수를 작성하였다.
  • 필터의 사용법은 데이터 바인딩을 한 오른쪽에 | 연산자를 사용하여 필터 함수 이름을 작성하는 것이다.

0509

현재는 로그인을 하면 쿠키에 jwt 토큰과 회원이름 정보가 들어가도록 구현되어 있다.
하지만 굳이 쿠키에 회원이름을 노출시킬 필요 없이 jwt안에 넣으면 되겠다는 생각이 들어서 리팩토링 하기로 했다. 우선 백엔드 영역에서 /jwt api를 호출받으면 회원 번호와 추가로 회원 이름을 반환해주도록 구현하였다.

public class JwtTokenUtil {
    SecretKey key = Keys.secretKeyFor(SignatureAlgorithm.HS256);
    private long tokenValidMillisecond = 1000L * 60 * 60;

    // 토큰 생성
    public String createToken(String userPk, String memberName) {
        Map<String, Object> claims = new HashMap<>();
        claims.put("memberName", memberName);
        Date now = new Date();
        Date expiration = new Date(now.getTime() + tokenValidMillisecond);

        return Jwts.builder()
                .setClaims(claims)  // 토큰 정보 설정
                .setSubject(userPk) // 유저 번호 설정
                .setIssuedAt(now)   // 토큰 발급 시간 설정
                .setExpiration(expiration)    // 토큰 만료 시간 설정
                .signWith(key, SignatureAlgorithm.HS256)
                .compact();
    }

    // 토큰에서 회원 이름 추출
    public String getMemberName(String token) {
        return Jwts.parserBuilder()
                .setSigningKey(key)
                .build()
                .parseClaimsJws(token).getBody().get("memberName", String.class);
    }
  • 먼저 JwtToken을 생성하는 메소드에서 claims를 HashMap으로 구현하여 memberName을 파라미터로하여 토큰을 생성하도록 설정했다.
  • 그리고 회원 이름을 꺼낼 수 있는 메소드를 작성하였다.
@RequiredArgsConstructor
@Slf4j
public class BearerAuthInterceptor implements HandlerInterceptor {

    private final AuthorizationExtractor authorizationExtractor;
    private final JwtTokenUtil jwtTokenUtil;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        String memberPk = jwtTokenUtil.getMemberPk(token);
        String memberName = jwtTokenUtil.getMemberName(token);
        request.setAttribute("memberPk", memberPk);
        request.setAttribute("memberName", memberName);

        return true;
    }
}
  • 인터셉터에서 토큰을 넘겨받았을 때, 정보에서 memberName을 꺼내서 attribute에 설정하도록 했다.
  • 그리고 util에서 memberName을 꺼내서 반환하도록 하는 메소드를 작성하고 이를 이용해서 회원 이름을 반환해주는 api를 구현하였다.

태그:

카테고리:

업데이트: