GuestBook 프로젝트 (9) (QuerydslRepositorySupport, 검색 (search) 처리 및 적용)
GuestBook 프로젝트 (9) (QuerydslRepositorySupport, 검색 (search) 처리 및 적용)
GuestBook 프로젝트 (8) (조회 / 수정 / 삭제 처리, Controller 화면 처리) GuestBook 프로젝트 (8) (조회 / 수정 / 삭제 처리, Controller 화면 처리) GuestBook 프로젝트 (7) (Join 처리, JPQL 적용, DTO, Service, ServcieImpl) G
soohykeee.tistory.com
이번에는 Reply 댓글에 관련된 것들을 처리할 것이다. 댓글의 경우에는 게시물의 조회 화면에서만 처리가 되면 된다. 또한 Ajax를 이용해서 controller 와 JSON 포맷으로 데이터를 교환하는 방식으로 작성할 것이다. AJAX와 JSON은 블로그에 앞서 작성된 내용을 참고하면 좋다.
[개념] JSON은 무엇인가?
JSON (JavaScript Obejct Notation) JSON은 JavaScript Object Notation 의 약자로, 데이터를 저장하거나 전송할 때 많이 사용되는 경량의 DATA 교환 형식을 말한다. 해당 문장을 직역하면 '자바 스크립트 객체 표기
soohykeee.tistory.com
[개념] AJAX는 무엇인가?
AJAX (Asynchronous Javascript And XML) AJAX는 JavaScript 의 라이브러리 중 하나이며 비동기식 자바스크립트와 xml 의 약자이다. 브라우저가 갖고있는 XML HttpRequest 객체를 이용해서 전체 페이지를 새로 고치지
soohykeee.tistory.com
Reply 적용 요약
방식 | 호출 대상 | 파라미터 | 작업 | 반환 데이터 |
GET | /replies/board/{bno} (게시물번호) | 게시물 번호 | 해당 게시물의 댓글 조회 | JSON 배열 |
POST | /replies/ | JSON으로 구성된 댓글 데이터 | 댓글 추가 | 추가된 댓글 번호 |
DELETE | /replies/{rno} | 댓글 번호 | 댓글 삭제 | 삭제 결과 문자열 |
PUT | /replies/{rno} | 댓글 번호 + 수정할 내용 | 댓글 수정 | 수정 결과 문자열 |
.
특정 게시물의 댓글을 보여주는 작업은 게시물의 조회 화면에서 Ajax를 통해 JSON 포맷의 데이터 교환으로 처리한다. 게시물의 조회 화면에서는 다음과 같이 처리하면 된다.
- 게시물이 로딩된 이후 화면에서 댓글의 숫자를 클릭 시, 해당 게시물에 속한 댓글을 Ajax로 가져온 후 출력
- 특정한 버튼 클릭 시, 새로운 댓글을 작성할 수 있는 modal창을 출력하고, Ajax의 POST 방식으로 작성한 댓글을 전송, 이후에 댓글의 목록을 새로 가져온 후 방금 작성한 댓글 또한 보여질 수 있도록 출력
- 댓글 삭제와 수정은 댓글 등록의 방식과 동일하게 특정 댓글을 선택시 modal창이 출력되고 해당 modal창에서 처리, 원칙적으로는 자신이 작성한 댓글이 아니면 삭제, 수정이 되면 안되지만 현재 security 적용이 되지 않았으므로 이는 배제하고 개발
Reply 엔티티
앞서 생성해주었던 Reply entity에서 @ManyToOne 어노테이션에 지연로딩을 설정해줘야한다.
지연 로딩의 중요성과 왜 지연로딩을 하는지는 앞서 포스팅에서 다룬문제이므로 넘어간다.
GuestBook 프로젝트 (6) (Member / Board / Reply 생성, 연관 관계 추가, 지연(Lazy) / 즉시(Eager) 로딩, Fetch)
GuestBook 프로젝트 (6) (Member / Board / Reply 생성, 연관 관계 추가, 지연(Lazy) / 즉시(Eager) 로딩, Fetch)
GuestBook 프로젝트 (5) (검색 처리, 검색 조건 추가, 수정 및 삭제 후 redirect) GuestBook 프로젝트 (5) (검색 처리, 검색 조건 추가, 수정 및 삭제 후 redirect) GuestBook 프로젝트 (4) (guestbook 등록, 조회, 수정,
soohykeee.tistory.com
package com.example.guestbookdemo.entity;
import lombok.*;
import javax.persistence.*;
@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Getter
@ToString(exclude = "board")
public class Reply extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long rno;
private String text;
private String replyer;
@ManyToOne(fetch = FetchType.LAZY)
private Board board;
}
ReplyRepository
특정 게시물마다의 댓글을 가져와서 화면에 보여주기 위해서는, ReplyRepository에서 특정 게시글 번호에 의해서 댓글의 목록을 가져오는 기능을 추가해줘야한다. 해당 기능은 아래와 같이 쿼리메소드를 사용하여 구현한다.
package com.example.guestbookdemo.repository;
import com.example.guestbookdemo.entity.Board;
import com.example.guestbookdemo.entity.Reply;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface ReplyRepository extends JpaRepository<Reply, Long> {
// board 삭제시 댓글들 삭제해주는 메소드
@Modifying
@Query("delete from Reply r where r.board.bno=:bno")
void deleteByBno(Long bno);
// 게시물로 댓글 목록 가져오기
List<Reply> getRepliesByBoardOrderByRno(Board board);
}
ReplyDTO
dto 디렉토리에 ReplyDTO를 생성한 후 아래와 같이 작성한다. DTO에는 굳이 entity 처럼 board의 전체 정보를 가지고 있을 필요는 없으므로, board의 bno 값만 가지도록 작성한다.
package com.example.guestbookdemo.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
public class ReplyDTO {
private Long rno;
private String text;
private String replyer;
private Long bno;
private LocalDateTime regDate, modDate;
}
ReplyService / ReplyServiceImpl
replyService에 필요한 기능은 아래와 같다.
- 댓글을 등록하는 기능 (register)
- 특정 게시물의 댓글 리스트를 가져오는 기능 (getList)
- 댓글을 수정하고 삭제하는 기능 (modify, remove)
- Reply를 ReplyDTO로 변환 (entityToDTO())
- ReplyDTO를 Reply로 변환 (dtoToEntity())
package com.example.guestbookdemo.service;
import com.example.guestbookdemo.dto.ReplyDTO;
import com.example.guestbookdemo.entity.Board;
import com.example.guestbookdemo.entity.Reply;
import java.util.List;
public interface ReplyService {
Long register(ReplyDTO replyDTO);
List<ReplyDTO> getList(Long bno);
void modify(ReplyDTO replyDTO);
void remove(Long rno);
default Reply dtoToEntity(ReplyDTO replyDTO) {
Board board = Board.builder().bno(replyDTO.getBno()).build();
Reply reply = Reply.builder()
.rno(replyDTO.getRno())
.text(replyDTO.getText())
.replyer(replyDTO.getReplyer())
.board(board)
.build();
return reply;
}
default ReplyDTO entityToDTO(Reply reply) {
ReplyDTO dto = ReplyDTO.builder()
.rno(reply.getRno())
.text(reply.getText())
.replyer(reply.getReplyer())
.regDate(reply.getRegDate())
.modDate(reply.getModDate())
.build();
return dto;
}
}
package com.example.guestbookdemo.service;
import com.example.guestbookdemo.dto.ReplyDTO;
import com.example.guestbookdemo.entity.Board;
import com.example.guestbookdemo.entity.Reply;
import com.example.guestbookdemo.repository.ReplyRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class ReplyServiceImpl implements ReplyService{
private final ReplyRepository replyRepository;
@Override
public Long register(ReplyDTO replyDTO) {
Reply reply = dtoToEntity(replyDTO);
replyRepository.save(reply);
return reply.getRno();
}
@Override
public List<ReplyDTO> getList(Long bno) {
List<Reply> result = replyRepository.getRepliesByBoardOrderByRno(Board.builder().bno(bno).build());
return result.stream().map(reply -> entityToDTO(reply)).collect(Collectors.toList());
}
@Override
public void modify(ReplyDTO replyDTO) {
Reply reply = dtoToEntity(replyDTO);
replyRepository.save(reply);
}
@Override
public void remove(Long rno) {
replyRepository.deleteById(rno);
}
}
Test
Service단의 경우 적용해보기 전에 test 코드를 통해서 확인해보는것이 중요하다. 모든 servcie를 테스트 해야 하지만, 우선적으로 필요한 getList() 메소드만 테스트 해보겠다.
package com.example.guestbookdemo.service;
import com.example.guestbookdemo.dto.ReplyDTO;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
@SpringBootTest
public class ReplyServiceTests {
@Autowired
private ReplyService replyService;
@Test
public void testGetList() {
Long bno = 99L;
List<ReplyDTO> replyDTOList = replyService.getList(bno);
replyDTOList.forEach(replyDTO -> System.out.println(replyDTO));
}
}
테스트코드 실행 시 정상적으로 bno=99에 속하는 모든 댓글들이 출력된것을 확인할 수 있다.
ReplyController
이제 화면에 보여주기 위해서 controller를 만들어서 조회화면에 Ajax로 댓글을 표시해줘야 한다. 해당 예제에서는 댓글 데이터를 JSON으로 만들어서 처리할 것이므로, 별도의 화면이 필요하지 않고 오직 데이터만을 전송한다. 이러한 경우에는 @Controller 어노테이션이 아닌, @RestController 어노테이션을 이용하여 처리하는 것이 간편하다.
@RestController는 쉽게 말해서 controller에 responseBody가 추가된 것으로, 주로 JSON 형태로 반환하기 위해 사용한다.
package com.example.guestbookdemo.controller;
import com.example.guestbookdemo.dto.ReplyDTO;
import com.example.guestbookdemo.service.ReplyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/replies/")
@Log4j2
@RequiredArgsConstructor
public class ReplyController {
private final ReplyService replyService;
@GetMapping(value = "/board/{bno}", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<List<ReplyDTO>> getListByBoard(@PathVariable("bno") Long bno) {
return new ResponseEntity<>(replyService.getList(bno), HttpStatus.OK);
}
}
@RestController의 경우 모든 메서드의 return 타입은 기본으로 JSON을 사용한다. 메서드의 return 타입은 ResponseEntity라는 객체를 이용하는데, 이를 이용하면 HTTP의 상태 코드 등을 같이 전달할 수 있다. @GetMapping() 어노테이션에는 URL의 일부를 '{}' 로 묶은 변수를 이용하는데, 이는 메서드 내에서 @PathValue 어노테이션을 통해 처리한다. 해당 어노테이션을 활용하면 브라우저에서는 예를 들어, '/replies/board/100' 과 같이 특정 게시물 번호로 조회할 때 '100' 이라는 데이터를 변수로 처리하는 것이 가능해진다.
Mapping을 할 때 우리는 받고싶은 데이터를 강제 함으로써 오류 상황을 줄일 수 있다. 이를 위해 사용하는 것 중 하나가 위에 작성한 MediaType이다. 들어오는 데이터와 나가는 데이터를 정하여 처리할 수 있다.
- consumes : 들어오는 데이터 타입을 정의할 때 이용, 즉 클라이언트가 서버에게 보내는 데이터 타입을 명시
- produces : 반환하는 데이터 타입을 정의할 때 이용, 즉 서버가 클라이언트에게 반환하는 데이터 타입을 명시
HttpStatus의 코드들을 알기위해서 아래를 참조하였다.
https://developer.mozilla.org/ko/docs/Web/HTTP/Status
HTTP 상태 코드 - HTTP | MDN
HTTP 응답 상태 코드는 특정 HTTP 요청이 성공적으로 완료되었는지 알려줍니다. 응답은 5개의 그룹으로 나누어집니다: 정보를 제공하는 응답, 성공적인 응답, 리다이렉트, 클라이언트 에러, 그리고
developer.mozilla.org
위의 설명에 따라서 코드를 작성해주었다.
해당 url 로 접속 시, 아래와 같이 JSON 형태로 정상적으로 출력이 되는 것을 확인할 수 있다.
조회, 수정, 삭제 화면 처리
read.html의 일부를 아래와 같이 수정해준다.
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<th:block th:replace="~{/layout/basic :: setContent(~{this::content})}">
<th:block th:fragment="content">
<!-- 생략 -->
<a th:href="@{/board/modify(bno=${dto.bno}, page=${requestDTO.page}, type=${requestDTO.type},keyword=${requestDTO.keyword})}">
<button type="button" class="btn btn-primary">Modify</button>
</a>
<a th:href="@{/board/list(page=${requestDTO.page}, type=${requestDTO.type},keyword=${requestDTO.keyword})}">
<buton type="button" class="btn btn-info">List</buton>
</a>
<div>
<div class="mt-4">
<h5><span class="badge badge-info addReply">Add Reply</span></h5>
<h5><span class="badge badge-secondary replyCount">Reply Count [[${dto.replyCount}]]</span></h5>
</div>
<div class="list-group replyList">
</div>
</div>
<div class="modal" tabindex="-1" role="dialog">
<div class="modal-dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Modal title</h5>
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
<span aria-hidden="true">×</span>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<input class="form-control" type="text" name="replyText" placeholder="Reply Text...">
</div>
<div class="form-group">
<input class="form-control" type="text" name="replyer" placeholder="Replyer...">
<input type="hidden" name="rno">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-danger replyRemove">Remove</button>
<button type="button" class="btn btn-warning replyModify">Modify</button>
<button type="button" class="btn btn-primary replySave">Save</button>
<button type="button" class="btn btn-outline-secondary replyClose" data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<script th:inline="javascript">
$(document).ready(function () {
var bno = [[${dto.bno}]];
// 댓글이 추가될 영역
var listGroup = $(".replyList");
//날짜 처리를 위한 함수
function formatTime(str) {
var date = new Date(str);
return date.getFullYear() + '/' +
(date.getMonth() + 1) + '/' +
date.getDate() + ' ' +
date.getHours() + ':' +
date.getMinutes();
}
//특정한 게시글의 댓글을 처리하는 함수
function loadJSONDate() {
$.getJSON('/replies/board/' + bno, function (arr) {
console.log(arr);
var str = "";
$('.replyCount').html(" Reply Count " + arr.length);
$.each(arr, function (idx, reply) {
console.log(reply);
str += ' <div class="card-body" data-rno="' + reply.rno + '"><b>' + reply.rno + '</b>';
str += ' <h5 class="card-title">' + reply.text + '</h5>';
str += ' <h6 class="card-subtitle mb-2 text-muted">' + reply.replyer + '</h6>';
str += ' <p class="card-text">' + formatTime(reply.regDate) + '</p>';
str += ' </div>';
});
listGroup.html(str);
});
}
$(".replyCount").click(function () {
loadJSONDate();
});
var modal = $('.modal');
$(".addReply").click(function () {
modal.modal('show');
//댓글 입력하는 부분 초기화 시키기
$('input[name="replyText"]').val('');
$('input[name="replyer"]').val('');
//모달 내의 모든 버튼을 안보이도록
$(".modal-footer .btn").hide();
//필요한 버튼들만 보이도록
$(".replySave, .replyClose").show();
});
$(".replySave").click(function () {
var reply = {
bno: bno,
text: $('input[name="replyText"]').val(),
replyer: $('input[name="replyer"]').val()
};
console.log(reply);
$.ajax({
url: '/replies/',
method: 'post',
data: JSON.stringify(reply),
contentType: 'application/json; charset=utf-8',
dataType: 'json',
success: function (data) {
console.log(data);
var newRno = parseInt(data);
alert(newRno + "번 댓글이 등록되었습니다.");
modal.modal('hide');
loadJSONDate();
}
});
});
$('.replyList').on("click", ".card-body", function () {
var rno = $(this).data("rno");
$("input[name='replyText']").val($(this).find('.card-title').html());
$("input[name='replyer']").val($(this).find('.card-subtitle').html());
$("input[name='rno']").val(rno);
$(".modal-footer .btn").hide();
$(".replyRemove, .replyModify, .replyClose").show();
modal.modal('show');
});
$('.replyRemove').on("click", function () {
// modal 창에 보이는 댓글 번호 hidden 처리 되어있음
var rno = $("input[name='rno']").val();
$.ajax({
url: '/replies/' + rno,
method: 'delete',
success: function (result) {
if (result === 'success') {
alert("댓글이 삭제되었습니다.");
modal.modal('hide');
loadJSONDate();
}
}
});
});
$('.replyModify').click(function () {
var rno = $("input[name='rno']").val();
var reply = {
rno: rno,
bno: bno,
text: $('input[name="replyText"]').val(),
replyer: $('input[name="replyer"]').val()
};
$.ajax({
url: '/replies/' + rno,
method: 'put',
data: JSON.stringify(reply),
contentType: 'application/json; charset=utf-8',
success: function (result) {
if (result === 'success') {
alert("댓글이 수정되었습니다.");
modal.modal('hide');
loadJSONDate();
}
}
});
});
});
</script>
</th:block>
</th:block>
</html>
ReplyController 도 아래와 같이 수정해준다.
package com.example.guestbookdemo.controller;
import com.example.guestbookdemo.dto.ReplyDTO;
import com.example.guestbookdemo.service.ReplyService;
import lombok.RequiredArgsConstructor;
import lombok.extern.log4j.Log4j2;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/replies/")
@Log4j2
@RequiredArgsConstructor
public class ReplyController {
private final ReplyService replyService;
@GetMapping(value = "/board/{bno}", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<List<ReplyDTO>> getListByBoard(@PathVariable("bno") Long bno) {
return new ResponseEntity<>(replyService.getList(bno), HttpStatus.OK);
}
@PostMapping("")
public ResponseEntity<Long> register(@RequestBody ReplyDTO replyDTO) {
Long rno = replyService.register(replyDTO);
return new ResponseEntity<>(rno, HttpStatus.OK);
}
@DeleteMapping("/{rno}")
public ResponseEntity<String> remove(@PathVariable("rno") Long rno) {
replyService.remove(rno);
return new ResponseEntity<>("success", HttpStatus.OK);
}
@PutMapping("/{rno}")
public ResponseEntity<String> modify(@RequestBody ReplyDTO replyDTO) {
replyService.modify(replyDTO);
return new ResponseEntity<>("success", HttpStatus.OK);
}
}