[Spring] 댓글 쓰기, 삭제 - 블로그 제작 (4)

merci·2023년 2월 12일
0

블로그 제작 V1

목록 보기
4/9
post-thumbnail
post-custom-banner

완료 기능
✅ 1 - 회원가입, 로그인, 아이디 중복체크, 글 목록보기, 상세보기, 수정, 삭제, 썸네일 추가

1단계로 블로그의 핵심기능을 추가해왔다.

이번에는 댓글을 쓰고 댓글 목록을 보고 댓글 삭제를 해보자.

댓글 컨트롤러 작성

댓글 모델링

댓글 테이블과 댓글 더미 데이터를 생성하고 댓글의 기본 CRUD 를 만든다.
기본적으로 테이블을 생성하면 테이블과 매칭되는 모델 엔티티를 하나 만든다.

create table reply_tb (
    id int auto_increment primary key,
    comment varchar(100) not null, // 100자 제한
    user_id int not null,
    board_id int not null,
    created_at timestamp not null
);

댓글은 게시글에 여러개가 달릴수가 있다.

게시글과 댓글은 1:N의 관계가 성립하므로 게시글의 Primary Key를 댓글테이블의 Foreign Key로 하는 제약조건이 필요하다.
하지만 제약조건을 걸면 게시글을 삭제했을때 댓글테이블이 null 을 참조하는 문제가 발생하므로 잘 사용하지 않는다.

따라서 이번에 만드는 댓글 테이블에도 board_id를 컬럼으로 가져왔지만 제약조건을 따로 걸지는 않는다.

테이블과 매핑될 엔티티

@Getter
@Setter
public class Reply {
    private int id;
    private String comment;
    private int userId;
    private int boardId;
    private Timestamp createdAt;
}

CRUD 기본 쿼리를 만든다

@Mapper
public interface ReplyRepository {
    public List<Reply> findAll();
    public Reply findById(int id);
    public int insert(
        @Param("comment") String comment,
        @Param("boardId") int boardId,
        @Param("userId") int userId
    );
    public int deleteById(int id);
    public int updateById(
        @Param("comment") String comment
    );
}



댓글 쓰기 기능 추가 ( MyBatis INSERT 시 PK 값 반환 )

ajax로 댓글을 작성하자

먼저 버튼의 리스너에 함수를 등록하고 게시글 번호, 작성자 이름, 작성자 id 를 json 으로 변환해서 보내야 한다.

<button type="button" onclick="saveReply(
                               `${dto.id}`, 
                               `${principal.username}`,
                               `${principal.id}`
                               )">등록</button>

댓글 작성에 등록된 함수는 아래와 같다.

    function saveReply(id, user, principalId) {
        let comm = $('#reply-content').val();
        let username = user;
        let data = {
            comment: $('#reply-content').val(),
            boardId: id,
            userId: principalId
        }
        $.ajax({
            type: "post",
            url: "/reply/save",
            data: JSON.stringify(data),
            headers: {
                "content-type": "application/json; charset=utf-8"
            },
            dataType: "json"
        }).done((res) => {
            let replyId = res.data;
            render(replyId, comm, username);
        }).fail((err) => {
            alert(err.responseJSON.msg);
        });
        $('#reply-content').val("");
    }

    function render(replyId, comm, username){
            let str = `
             <li id="reply-`+ replyId +`" class="list-group-item d-flex 
											justify-content-between ">
             <div id="test">`+ comm +`</div>
             <div class="d-flex justify-content-left">
             <div class="font-italic">작성자 : `+ username +`&nbsp;</div>
             <div>
             <button class="badge bg-secondary" 
				onclick="updateComment(`+ replyId +`)">수정</button>
             <button class="badge bg-secondary" 
				onclick="deleteComment(`+ replyId +`)">삭제</button>
             </div>
             </div>
             </li>`;
        $('#reply-box').append(str);
    }
  }

댓글을 작성하여 insert 가 되는 동시에 pk를 받아서 렌더링을 해보려고 한다.

replyId 는 insert가 됐을때 h2 DB 에서 auto_increment 전략에 의해 자동 생성된 PK를 반환 받은 값이다.
댓글의 PK값을 바로 받아서 렌더링을 하므로 댓글을 작성하고 새로고침없이 수정과 삭제버튼을 사용 할 수 있다.


PK를 반환받는 MyBatis의 쿼리문은 아래처럼 작성한다.

    <insert id="insert"  useGeneratedKeys="true" keyProperty="rDto.id">
      insert into reply_tb ( comment, board_id, user_id, created_at) 
        values (#{rDto.comment}, #{rDto.boardId}, #{userId}, now())
    </insert>

useGeneratedKeys="true" + keyProperty='PK값을 반환받을 자바 오브젝트의 필드값' 을 이용해서 insert문을 작성하면 된다.

여기에 사용된 rDto는 댓글의 데이터를 받을 dto 객체이다 - ReplySaveReqDto

    @Setter
    @Getter
    public static class ReplySaveReqDto{
        private Integer id; // PK를 반환 받을 필드
        private String comment;
        private Integer userId; // 댓글 작성시의 userid
        private Integer boardId;
    }

MyBatis가 연결된 @Mapper 어노테이션이 있는 Repository 에서 파라미터로 자바오브젝트( ReplySaveReqDto )를 넣어주면 된다.

@Mapper
public interface ReplyRepository {
    public int insert(
        @Param("rDto") ReplySaveReqDto rDto,
        @Param("userId") int userId
    );
 }

만든 쿼리를 테스트를 해보자

@MybatisTest
public class ReplyRepositoryTest {
    @Autowired
    private ReplyRepository replyRepository;
        @Test
    public void insert_test() throws Exception{
        ReplySaveReqDto r = new ReplySaveReqDto();
        r.setComment("zzz");
        r.setBoardId(2);

        int result = replyRepository.insert(r, 1);
        System.out.println("테스트 : "+result);
    }
}

이제 이 쿼리를 사용하는 서비스는

    @Transactional
    public int 댓글쓰기(ReplySaveReqDto rDto, int principalId){
        if( rDto.getUserId() != principalId ){
            throw new CustomApiException("댓글을 작성할 권한이 없습니다.", HttpStatus.FORBIDDEN);
        }
        int result ;
        try {
            replyRepository.insert(rDto, principalId);
            result = rDto.getId();
        } catch (Exception e) {
            throw new CustomApiException("댓글 쓰기 실패", HttpStatus.INTERNAL_SERVER_ERROR);
        }
        return result;
    }

서비스에서 작성자와 로그인아이디가 같은지 한번 검사를 한다.

서비스가 사용되고 ajax로 비동기 요청을 받는 컨트롤러는

    @PostMapping("/reply/save")
    public ResponseEntity<?> save(@RequestBody ReplySaveReqDto rdto){
        // System.out.println("테스트 : "+rdto.getComment());
        User principal = (User) session.getAttribute("principal");
        if( principal ==null){
            throw new CustomApiException("인증이 되지 않았습니다.",HttpStatus.UNAUTHORIZED);
        }
        if( rdto.getComment()==null||rdto.getComment().isEmpty()){
            throw new CustomApiException("댓글을 작성해주세요");
        }
        
        if( rdto.getBoardId() == null){ // null 을 걸러야 함.. INTEGER 로 선언해
            throw new CustomApiException("게시글 번호가 필요합니다.");
        }
        
        int returnPK = replyService.댓글쓰기(rdto, principal.getId());
        return new ResponseEntity<>(
        		new ResponseDto<>(1, "댓글 쓰기 성공", returnPK), HttpStatus.OK);
    }

returnPK 를 이용해서 insert시 PK값을 json에 넣어서 반환해줬다.

이전과 마찬가지로 유효성이 통과되지 않으면 익셉션 핸들러를 이용해서 관리한다.


서비스를 테스트해보자

@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = WebEnvironment.MOCK)
public class ReplyControllerTest {

    @Autowired
    private MockMvc mvc;

    private MockHttpSession session;

    @BeforeEach  // MockHttpSession에 mockUser넣기
    public void setUp(){
        User mockUser = new User();
        mockUser.setId(3);
        mockUser.setUsername("ssar");
        mockUser.setPassword("1234");
        mockUser.setEmail("ssar@nate.com");

        session = new MockHttpSession();
        session.setAttribute("principal", mockUser);
    }

    @Test
    public void save_test() throws Exception{
        ObjectMapper om = new ObjectMapper();

        ReplySaveReqDto r = new ReplySaveReqDto();
        r.setBoardId(2);
        r.setComment("안녕");   
        r.setUserId(2);
        String test = om.writeValueAsString(r);

        ResultActions rs = mvc.perform(post("/reply/save")
                    .content(test)
                    .contentType(MediaType.APPLICATION_JSON_VALUE)
                    .session(session)
                    );

        rs.andExpect(status().isOk());
}
}

다양한 조건의 변화가 있을때 발생하는 익셉션들

댓글을 작성하면 비동기적으로 처리가 된다.



댓글 목록 기능 추가

댓글은 게시글 페이지 아래에 있으므로 게시글을 상세보기 했을때 댓글데이터도 모델에 담겨서 뷰에 전달되어야 한다.

댓글의 데이터를 담을 dto를 먼저 만든다. - ReplyListRespDto

public class ReplyResp {
    
    @Getter
    @Setter
    public static class ReplyListRespDto {
        private Integer id;
        private String comment;
        private String username;  // 서브쿼리 이용
        private Integer userId;	  // 삭제버튼 보일때 사용
        private Integer boardId;  // where 조건에 필요
        private Timestamp createdAt;
    }
}

게시글 상세보기를 했을때 연결되는 BoardControllerGetMapping 을 수정하자

    @GetMapping("/board/detail/{id}")
    public String boardDetail(@PathVariable int id, Model model){
        BoardDetailDto db =  boardRepository.findBoardforDetail(id);
        model.addAttribute("dto", db);
        // 아래 부분을 추가
        List<ReplyListRespDto> replyList = replyRepository.findByBoardIdWithUser(id);
        model.addAttribute("replyList", replyList);
        return "board/detail";
    }

기존의 게시글 데이터를 전달하는 모델에 댓글을 전달하는 모델을 추가한다.

조회하는데 사용되는 쿼리는

    <select id="findByBoardIdWithUser" resultType="shop.mtcoding.blog2.dto.reply.ReplyResp$ReplyListRespDto">
      select r.id, r.comment,
      ( select username from user_tb where id = r.user_id ) username,
      r.user_id,
      r.board_id,
      r.created_at
      from reply_tb r 
      where r.board_id = #{boardId}
    </select>

쿼리를 테스트하자

@MybatisTest
public class ReplyRepositoryTest {

    @Autowired
    private ReplyRepository replyRepository;
    @Test
    public void findAllforList_test() throws Exception{

        ObjectMapper om = new ObjectMapper();

        List<ReplyListRespDto> replyList = replyRepository.findByBoardIdWithUser(1);

        String responseBody = om.writeValueAsString(replyList);
        System.out.println("테스트 : "+ responseBody); 
    }
}

@MybatisTest를 이용해서 쿼리만 테스트 한다.
나온 결과를 http://jsonviewer.stack.hu/ 로 가서 구조를 바로 확인할 수 있다.

모델에 제대로 담기는지도 테스트하면 된다.

    @Test
    public void boardDetail_test()throws Exception{
    	int id = 1;
        ResultActions rs = mvc.perform(get("/board/detail/"+id).session(session));

        Map<String, Object> map = rs.andReturn().getModelAndView().getModel();
        List<ReplyListRespDto> rdo = (List<ReplyListRespDto>)map.get("replyList");
        assertThat(rdo.get(0).getUsername()).isEqualTo("love"); // 2번 유저는 love
    }



댓글 삭제 기능 추가

참고로 수정 삭제 버튼이 안보이는 코드는 아래 추가

<c:if test="${reply.userId != principal.id}">
     <button class="badge bg-secondary" style="visibility: hidden;">수정</button>
     <button class="badge bg-secondary" style="visibility: hidden;">삭제</button>
</c:if>

삭제를 하기 위해서 삭제버튼에 리스너를 추가하자

<button>삭제</button>

댓글 번호가 파라미터로 들어가면서 등록된 함수가 호출된다.

  function deleteComment(id) {
    $.ajax({
      type: "delete",
      url: "/reply/" + id,
      dataType: "json"
    }).done((res) => {
      alert(res.msg);
      $('#reply-' + id).remove();
    }).fail((err) => {
      alert(err.responseJSON.msg);
    });
  }

삭제는 ajax를 이용해서 비동기적으로 처리한다.
삭제 요청이 성공하면 제이쿼리의 remove() 메소드를 이용하여 댓글의 <li>를 제거 한다.

삭제 버튼의 요청을 받는 컨트롤러

    @DeleteMapping("/reply/{id}")
    public ResponseEntity<?> deleteReply(@PathVariable int id){
        System.out.println("테스트 : "+ id);
        User principal = (User) session.getAttribute("principal");
        if( principal == null){
            throw new CustomApiException("인증이 되지 않았습니다.",HttpStatus.UNAUTHORIZED);
        }
        replyService.댓글삭제(id, principal.getId());
        return new ResponseEntity<>(new ResponseDto<>(1, "삭제 성공", null), HttpStatus.OK);
    }

이전 포스팅에서도 줄곧 얘기 했으므로 간단히 적자면

  • ajax를 이용한 통신이므로 json을 리턴 하기 위해 ResponseEntity 타입으로 선언한다.
  • 리턴할 데이터는 ResponseDto<>의 생성자에 넣고 ResponseEntity가 자바오브젝트와 상태코드를 전달한다.
  • 자바오브젝트는 MappingJacksonHttpMessageConverterjson으로 변환해준다.
  • ResponseDto<>를 응답하는 CustomApiException을 사용한다.

컨트롤러가 유효성을 검사하고 호출한 서비스의 댓글삭제 메소드는

    @Transactional
    public void 댓글삭제(int id, int principalId) {
        Reply reply = replyRepository.findById(id);
        if ( reply == null ){
            throw new CustomApiException("댓글이 존재하지 않습니다.", HttpStatus.FORBIDDEN);
        }
        if ( reply.getUserId() != principalId){
            throw new CustomApiException("자신이 작성한 댓글만 삭제할 수 있습니다.", HttpStatus.FORBIDDEN);
        }
        try {
            replyRepository.deleteById(id);
        } catch (Exception e) {
            // INTERNAL_SERVER_ERROR 는 무조건 로그를 남겨야한다 !!!!!!!!!!!!!!!!
            //System.out.println("서버에러 : "+ e.getMessage());// 로그의 기능을 한다는거야, 일단 간단하게 남길게 더 정확하세는 아랫줄
            log.error("서버에러 : ", e.getMessage());
            // 로그 서버에도 로그를 보내야함.. 이러한 모든 로직을 AOP 로 구현하면 편하다
            throw new CustomApiException("댓글 삭제에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR);
        }
    }

댓글 삭제할때는 서버 오류가 나거나 쿼리에 문제가 발생하면 DB 에서 try-catch로 익셉션을 터트리기 때문에 -1을 리턴하지 않는다.
따라서 if( result != 1) 는 권장하지 않고 익셉션이 발생할 가능성이 있는 메소드는 try-catch를 사용하자.

에러가 발생하면 로그도 남겨야 하는데 이럴 때 사용하는 간단한 방법은 서비스에 어노테이션 @Slf4j을 추가하고 log.error() 를 이용하는 방법이 있다

@Slf4j  // 이녀석을 추가한다
@Transactional(readOnly = true)
@Service
public class ReplyService {
	// 서비스의 메소드들...
}

이제 간단한 테스트를 통해 검증을 해보자

@AutoConfigureMockMvc
@SpringBootTest(webEnvironment = WebEnvironment.MOCK)
public class ReplyControllerTest {
    
    @Autowired
    private MockMvc mvc;

    private MockHttpSession session;

    @BeforeEach
    public void setUp(){
        User mockUser = new User();
        mockUser.setId(1);
        mockUser.setUsername("ssar");
        mockUser.setPassword("1234");
        mockUser.setEmail("ssar@nate.com");

        session = new MockHttpSession();
        session.setAttribute("principal", mockUser);
    }
    @Test
    public void deleteReply_test() throws Exception{
        int id = 2 ;
        ResultActions rs = mvc.perform(delete("/reply/"+id).session(session));
        rs.andExpect(status().isOk());
        String result = rs.andReturn().getResponse().getContentAsString();
        System.out.println("테스트 : "+ result);
    }
}

조건의 변화를 주면 발생하는 익셉션들


삭제를 해보면



비동기적으로 처리된 것을 확인할 수 있다.

profile
작은것부터
post-custom-banner

0개의 댓글