Spring Boot + Mybatis + Mysql 을 활용한 계층형(=hierarchy) 게시판 구현

2020-05-31-hierarchy-board.md

Posted by 전봉근 on Sunday, May 31, 2020 Tags: java springboot mybaits mysql   7 minute read

Spring Boot + Mybatis + Mysql 을 활용한 계층형(=hierarchy) 게시판 구현


  • 테이블 설계
      CREATE TABLE `tb_board` (
          `BOARD_NO` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '게시판 고유 값',
          `GROUP_NO` int(11) unsigned NOT NULL COMMENT '게시판 그룹번호 (원글은 자신의 값)',
          `SORT_SEQ` int(2) unsigned NOT NULL COMMENT '게시글 정렬 순번',
          `BOARD_LVL` int(2) unsigned NOT NULL COMMENT '게시글 레벨(depth)',
          `BOARD_TITLE` varchar(45) NOT NULL COMMENT '게시글 제목',
          `BOARD_CONTENTS` text COMMENT '게시글 내용',
          `SYS_REGR_ID` varchar(20) NOT NULL COMMENT '시스템 등록일',
          `SYS_REG_DTIME` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '시스템 등록일자',
          `SYS_MODR_ID` varchar(20) NOT NULL COMMENT '시스템 수정자',
          `SYS_MOD_DTIME` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '시스템 수정일자',
      PRIMARY KEY (`BOARD_NO`)
      ) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_bin COMMENT='일반 게시판';
    
  • Entity Class 작성 [Board.java]
      package com.example.bkjeon.feature.board;
    
      import com.fasterxml.jackson.annotation.JsonFormat;
      import lombok.*;
    
      import java.time.LocalDateTime;
    
      @ToString
      @Builder
      @Getter
      @NoArgsConstructor
      @AllArgsConstructor
      public class Board {
    
          private Long boardNo;
          private Long groupNo;
          private Integer sortSeq;
          private Integer boardLvl;
          private String boardTitle;
          private String boardContents;
    
          private String sysRegrId;
    
          @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
          private LocalDateTime sysRegDtime;
    
          private String sysModrId;
    
          @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
          private LocalDateTime sysModDtime;
    
      }
    
  • DTO Class 작성 [BoardDTO.java]
      package com.example.bkjeon.feature.board;
    
      import lombok.AllArgsConstructor;
      import lombok.Getter;
      import lombok.NoArgsConstructor;
    
      import javax.validation.constraints.NotEmpty;
    
      @Getter
      @NoArgsConstructor
      @AllArgsConstructor
      public class BoardDTO {
    
          @NotEmpty(message = "제목을 입력하여 주시길 바랍니다.")
          private String boardTitle;
          private String boardContents;
    
          @NotEmpty(message = "userId 값을 입력하여 주시길 바랍니다.")
          private String userId;
    
      }
    
  • Controller 작성 [BoardController.java]
      package com.example.bkjeon.base.services.api.v1.board;
    
      import com.example.bkjeon.feature.board.BoardDTO;
      import io.swagger.annotations.ApiOperation;
      import io.swagger.annotations.ApiParam;
      import lombok.AllArgsConstructor;
      import org.springframework.validation.BindingResult;
      import org.springframework.web.bind.annotation.*;
    
      import javax.validation.Valid;
    
      @RestController
      @AllArgsConstructor
      @RequestMapping("boards")
      public class BoardController {
    
          private final BoardService boardService;
    
          @ApiOperation("게시글 리스트 조회")
          @GetMapping
          public List<Board> getBoardList(
              @ApiParam(
                  value = "page 번호를 설정할 수 있으며 설정 값은 1-N까지 입니다.",
                  name = "page",
                  defaultValue = "1",
                  required = true
              ) @RequestParam int page,
              @ApiParam(
                  value = "페이지 별 레코드 갯수를 설정 할 수 있습니다.",
                  name = "size",
                  defaultValue = "10",
                  required = true
              ) @RequestParam int size
          ) {
              return boardService.getBoardList(page, size);
          }
    
          @ApiOperation("게시글 상세 조회")
          @GetMapping("{boardNo}")
          public Board getBoard(
              @ApiParam(value = "boardNo", name = "boardNo", required = true) @PathVariable Long boardNo
          ) {
              return boardService.getBoard(boardNo);
          }
    
          @ApiOperation("메인 게시글 등록")
          @PostMapping
          public void setBoard(
              @RequestBody @Valid BoardDTO boardDTO,
              BindingResult bindingResult
          ) {
              boardService.setBoard(boardDTO, bindingResult);
          }
    
          @ApiOperation("서브 게시글 등록")
          @PostMapping("{boardNo}/replies")
          public void setBoardReply(
              @ApiParam(value = "boardNo", name = "boardNo", required = true) @PathVariable Long boardNo,
              @RequestBody @Valid BoardDTO boardDTO,
              BindingResult bindingResult
          ) {
              boardService.setBoardReply(boardNo, boardDTO, bindingResult);
          }
    
      }
    
  • Service 작성 [BoardService.java]
      package com.example.bkjeon.base.services.api.v1.board;
    
      import com.example.bkjeon.base.common.enums.ResponseResult;
      import com.example.bkjeon.base.common.model.ApiResponseMessage;
      import com.example.bkjeon.feature.board.Board;
      import com.example.bkjeon.feature.board.BoardDTO;
      import com.example.bkjeon.feature.board.BoardMapper;
      import lombok.AllArgsConstructor;
      import lombok.extern.slf4j.Slf4j;
      import org.springframework.stereotype.Service;
      import org.springframework.transaction.annotation.Transactional;
      import org.springframework.validation.BindingResult;
    
      import java.time.LocalDateTime;
      import java.util.List;
    
      @Slf4j
      @Service
      @AllArgsConstructor
      public class BoardService {
    
          private final BoardMapper boardMapper;
    
          // 게시글 리스트 조회
          @Transactional(readOnly = true)
          public List<Board> getBoardList(int page, int size) {
              List<Board> boardList = new ArrayList<>();
    
              try {
                  Integer offset = (page - 1) * size;
                  boardList = boardMapper.selectBoardList(size, offset);
              } catch (Exception e) {
                  if (log.isErrorEnabled()) {
                      log.error("getBoardList ERROR {}", e.getMessage());
                  }
              }
    
              return boardList;
          }
    
          // 게시글 상세 조회
          @Transactional(readOnly = true)
          public Board getBoard(Long boardNo) {
              Board board = null;
    
              try {
                  board = boardMapper.selectBoard(boardNo);
              } catch (Exception e) {
                  if (log.isErrorEnabled()) {
                      log.error("getBoard ERROR {}", e.getMessage());
                  }
              }
    
              return board;
          }
    
          // 메인 게시물 등록
          @Transactional
          public void setBoard(BoardDTO boardDTO, BindingResult bindingResult) {
              if (bindingResult.hasErrors()) {
                  log.error("Valid ERROR {}", bindingResult.getFieldError().getDefaultMessage());
              }
    
              try {
                  Board board = Board.builder()
                      .sortSeq(0)
                      .boardLvl(1)
                      .boardTitle(boardDTO.getBoardTitle())
                      .boardContents(boardDTO.getBoardContents())
                      .sysRegrId(boardDTO.getUserId())
                      .sysRegDtime(LocalDateTime.now())
                      .sysModrId(boardDTO.getUserId())
                      .sysModDtime(LocalDateTime.now())
                      .build();
                  boardMapper.insertBoard(board);
              } catch (Exception e) {
                  if (log.isErrorEnabled()) {
                      log.error("setBoard ERROR {}", e.getMessage());
                  }
              }
          }
    
          // 서브 게시물 등록
          @Transactional
          public void setBoardReply(Long boardNo, BoardDTO boardDTO, BindingResult bindingResult) {
              if (bindingResult.hasErrors()) {
                  log.error("Valid ERROR {}", bindingResult.getFieldError().getDefaultMessage());
              }
    
              try {
                  Integer sortSeq = null;
                  Integer boardLvl = null;
    
                  // CASE 1: 원글의 GROUP_NO, SORT_SEQ, BOARD_LVL 기준으로 답글의 저장될 데이터를 계산한다.
                  Board board = boardMapper.selectBoard(boardNo);
                  if (board == null) {
                      log.error("잘못된 접근입니다.");
                  }
    
                  if (board.getBoardLvl() == 1 && board.getSortSeq() == 0) {
                      // 원글 그룹에 기존에 답글이 있는지 유무 체크
                      Long boardGroupCnt = boardMapper.selectBoardGroupCnt(board.getGroupNo());
                      if (boardGroupCnt == 1) {
                          // CASE 1: 원글의 답글 등록 (원글 그룹으로 조회했을시 개수가 1개일 경우)
    
                          /**
                          * 원글의 GROUP_NO, SORT_SEQ, BOARD_LVL 기준으로 답글의 저장될 데이터를 계산한다.
                          * GROUP_NO: 원글의 GROUP_NO 값, SORT_SEQ: 원글 (SORT_SEQ + 1), BOARD_LVL: 원글 (BOARD_LVL + 1)
                          */
                          sortSeq = board.getSortSeq() + 1;
                          boardLvl = board.getBoardLvl() + 1;
                      } else if (boardGroupCnt > 1) {
                          // CASE 2: 원글에 답글을 한개 더 작성하는 경우 (원글 그룹으로 조회했을시 개수가 2개 이상일 경우)
    
                          /**
                          * GROUP_NO가 같으면서 새로운 답글이 상위로 위치하기 위해서
                          * 기존 원글의 답글들의 SORT_SEQ를 전부 +1 한다.
                          */
                          boardMapper.updateBoardGroupSort(board.getGroupNo());
    
                          /**
                          *  GROUP_NO: 원글의 GROUP_NO 값, SORT_SEQ: 원글 (SORT_SEQ + 1), BOARD_LVL: 원글 (BOARD_LVL + 1)
                          */
                          sortSeq = board.getSortSeq() + 1;
                          boardLvl = board.getBoardLvl() + 1;
                      }
                  } else if (board.getBoardLvl() > 1) {
                      // CASE 3: 답글에 답글을 작성하는 경우
    
                      /**
                      * GROUP_NO가 같으면서 새로운 답글이 상위로 위치하기 위해서
                      * 기존의 답글들의 SORT_SEQ를 전부 +1 한다.
                      */
                      boardMapper.updateBoardReplyGroupSort(board.getGroupNo(), board.getSortSeq());
    
                      /**
                      * GROUP_NO: 원글의 GROUP_NO 값, SORT_SEQ: 원글 (SORT_SEQ + 1), BOARD_LVL: 원글 (BOARD_LVL + 1)
                      */
                      sortSeq = board.getSortSeq() + 1;
                      boardLvl = board.getBoardLvl() + 1;
                  }
    
                  // 답글 저장
                  Board insertBoardReply = Board.builder()
                      .groupNo(board.getGroupNo())
                      .sortSeq(sortSeq)
                      .boardLvl(boardLvl)
                      .boardTitle(boardDTO.getBoardTitle())
                      .boardContents(boardDTO.getBoardContents())
                      .sysRegrId(boardDTO.getUserId())
                      .sysRegDtime(LocalDateTime.now())
                      .sysModrId(boardDTO.getUserId())
                      .sysModDtime(LocalDateTime.now())
                      .build();
                  boardMapper.insertBoardReply(insertBoardReply);
              } catch (Exception e) {
                  if (log.isErrorEnabled()) {
                      log.error("setBoardReply ERROR {}", e.getMessage());
                  }
              }
          }
    
      }
    
  • Mapper.java 작성 [BoardMapper.java]
      package com.example.bkjeon.feature.board;
    
      import java.util.List;
    
      public interface BoardMapper {
    
          void insertBoard(Board board);
          void insertBoardReply(Board board);
          List<Board> selectBoardList(Integer size, Integer offset);
          Board selectBoard(Long boardNo);
          Long selectBoardGroupCnt(Long groupNo);
          void updateBoardGroupSort(Long groupNo);
          void updateBoardReplyGroupSort(Long groupNo, Integer sortSeq);
    
      }
    
  • Mapper.xml 작성
      <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
      <mapper namespace="com.example.bkjeon.feature.board.BoardMapper">
    
          <resultMap id="boardResultMap" type="com.example.bkjeon.feature.board.Board">
              <result property="boardNo" column="BOARD_NO"/>
              <result property="groupNo" column="GROUP_NO"/>
              <result property="sortSeq" column="SORT_SEQ"/>
              <result property="boardLvl" column="BOARD_LVL"/>
              <result property="boardTitle" column="BOARD_TITLE"/>
              <result property="boardContents" column="BOARD_CONTENTS"/>
              <result property="sysRegrId" column="SYS_REGR_ID"/>
              <result property="sysRegDtime" column="SYS_REG_DTIME"/>
              <result property="sysModrId" column="SYS_MODR_ID"/>
              <result property="sysModDtime" column="SYS_MOD_DTIME"/>
              <result property="userNm" column="USER_NM"/>
          </resultMap>
    
          <sql id="boardSelectFromSql">
              SELECT
                  BOARD_NO,
                  GROUP_NO,
                  SORT_SEQ,
                  BOARD_LVL,
                  BOARD_TITLE,
                  BOARD_CONTENTS,
                  SYS_REGR_ID,
                  SYS_REG_DTIME,
                  SYS_MODR_ID,
                  SYS_MOD_DTIME
              FROM tb_board
          </sql>
    
          <sql id="boardInsertSql">
              INSERT INTO tb_board (
                  GROUP_NO,
                  SORT_SEQ,
                  BOARD_LVL,
                  BOARD_TITLE,
                  BOARD_CONTENTS,
                  SYS_REGR_ID,
                  SYS_MODR_ID
              ) VALUES (
                  ${groupNo},
                  ${sortSeq},
                  ${boardLvl},
                  #{boardTitle},
                  #{boardContents},
                  #{sysRegrId},
                  #{sysModrId}
              )
          </sql>
    
          <select id="selectBoardList" resultMap="boardResultMap" parameterType="map">
              <include refid="boardSelectFromSql" />
              ORDER BY GROUP_NO DESC, SORT_SEQ ASC
    
              <if test="size != null and offset != null">
                  LIMIT ${size} OFFSET ${offset}
              </if>
          </select>
    
          <select id="selectBoard" resultMap="boardResultMap" parameterType="map">
              <include refid="boardSelectFromSql" />
              WHERE BOARD_NO = ${boardNo}
          </select>
    
          <insert id="insertBoard" parameterType="com.example.bkjeon.feature.board.Board">
              <selectKey resultType="long" keyProperty="groupNo" order="BEFORE">
                  SELECT IFNULL(MAX(GROUP_NO), 0) + 1
                  FROM tb_board
              </selectKey>
    
              <include refid="boardInsertSql" />
          </insert>
    
          <insert id="insertBoardReply" parameterType="com.example.bkjeon.feature.board.Board">
              <include refid="boardInsertSql" />
          </insert>
    
          <select id="selectBoardGroupCnt" parameterType="map" resultType="long">
              SELECT COUNT(*) AS cnt FROM tb_board WHERE GROUP_NO = ${groupNo}
          </select>
    
          <update id="updateBoardGroupSort" parameterType="map">
              UPDATE tb_board
              SET SORT_SEQ = SORT_SEQ + 1
              WHERE GROUP_NO = ${groupNo}
              AND SORT_SEQ > 1
          </update>
    
          <update id="updateBoardReplyGroupSort" parameterType="map">
              UPDATE tb_board
              SET SORT_SEQ = SORT_SEQ + 1
              WHERE GROUP_NO = ${groupNo}
              AND SORT_SEQ > ${sortSeq}
          </update>
    
      </mapper>
    

이렇게 하면 계층형 게시판을 만들 수 있다.