✓ 모달창의 class 속성값은 registerModal이라고 지정하고 <input> 태그들은 replyText와 replyWriter 속성값을 지정 ✓ 모달창의 버튼들도 구분하기 위해서 class 속성값을 registerBtn, closeRegisterBtn 등으로 지정해서 사용
read.html의 <script> 부분에는 자주 사용하는 DOM 객체들을 미리 변수로 처리
// 등록 작업 관련
const registerModal = new bootstrap.Modal(document.querySelector('.registerModal'));
const registerBtn = document.querySelector('.registerBtn');
const replyText = document.querySelector('.replyText');
const replyWriter = document.querySelector('.replyWriter');
const closeRegisterBtn = document.querySelector('.closeRegisterBtn');
ADD REPLY 버튼을 눌렀을 때 모달창을 보여주도록 이벤트 처리와 모달창의 Close 버튼에 대한 처리
document.querySelector('.addReplyBtn').addEventListener('click', function (e) {
registerModal.show();
});
closeRegisterBtn.addEventListener('click', function (e) {
registerModal.hide();
});
모달창 오른쪽 하단의 Register 버튼을 눌렀을 때 이벤트 처리를 추가
📍 reply.js의 addReply()를 호출하고 경고창을 통해서 추가된 댓글의 번호를 보여줌 📍 경고창이 닫히면 마지막 페이지를 다시 호출해서 등록된 댓글이 화면에 보일 수 있도록 구성
registerBtn.addEventListener('click', function (e) {
// 매개변수로 사용할 객체 생성
const replyObj = { bno: bno, replyText: replyText.value, replyWriter: replyWriter.value }
addReply(replyObj).then(result => { // 등록이 된 후 결과처리
alert(result.data.rno);
registerModal.hide();
replyText.value = '';
replyWriter.value = '';
printReplies(1, 10, true); // 댓글 목록 갱신
}).catch(e => {
alert('Exception');
});
});
2. 댓글 페이지 번호 클릭
새로운 댓글이 추가되면 자동으로 마지막 페이지로 이동하기는 하지만, 댓글의 페이지 번호를 누를 때도 이동할 수 있으므로 수정 / 삭제 전에 페이지 이동 처리는 먼저 진행 ⚡️ 화면에서 페이지 번호를 구성하는 부분은 <li> 태그 내에 존재하는 <a> 태그이고 페이지 번호가 'data-page' 속성값으로 지정되어 있음
⚡️ 페이지 번호는 매번 새로이 구성하므로 이벤트 처리할 때는 항상 고정되어 있는 <ul>을 대상으로 이벤트 리스너를 등록하는 방식을 이용
read.html에 코드 추가
// 3. 페이징 클릭
let page = 1;
let size = 10;
replyPaging.addEventListener('click', function (e) {
e.preventDefault();
e.stopPropagation();
const target = e.target;
if (!target || target.tagName !== 'A') {
return;
}
page = target.getAttribute('data-page');
printReplies(page, size);
});
✓ page와 size를 별도의 변수로 처리하는 것은 나중에 댓글 수정과 같은 작업에서 현재 페이지 번호를 유지 해야 할 가능성이 있기 때문 ✓ 이벤트 처리가 완료되면 댓글의 페이지 이동이 가능해 짐
3. 댓글 조회와 수정
댓글을 조회한다는 것은 댓글을 수정하거나 삭제하기 위함 댓글 조회는 등록과 유사하게 모달창을 이용해서 수정이나 삭제가 가능한 버튼들을 보여주는 형태로 구성
1) Axios 통신 부분
👾 reply.js에는 특정한 번호의 댓글을 조회하고 수정할 수 있는 기능을 구성 👾 댓글 조회는 GET 방식으로 처리되고, 댓글 수정은 PUT 방식으로 호출
⚡️ 흔하게 볼 수 있는 동기화된 코드인데 doA()를 실행해서 나온 결과로 result1을 이용해서 doB()를 호출하는 방식 ⚡️ 코드는 doA() -> doB() -> doC()의 순서대로 호출됨
📍 동기화된 방식의 단점은 doA()의 실행이 완료되어야만 doB()의 실행이 가능함. 즉 doA()의 결과를 반환할 때까지 다른 작업은 실행되지 않기 때문에 동시에 여러 작업을 처리할 수없음
📍 비동기 방식은 커피 가게에 여러 명의 점원이 있는 상황과 유사. 점원이 여러 명이면 한 명은 주문을 계속 받고, 다른 점원은 계속해서 커피를 제조할 수 있음. ➡️ 비동기 방식의 핵심은 '통보'. 비동기는 여러 작업을 처리하기 때문에 나중에 결과가 나오면 이를 '통보'해 주는 방식을 이용. 이러한 방식을 전문용어로는 콜백 callback이라고 함.
📍 비동기 방식은 'doA()'를 호출할 때 doB()를 해 줄 것을 같이 파라미터로 전달
function doA(callback) { ... result1 = .... callback(result1) }
⚡️ 파라미터로 전달되는 콜백을 내부에서 호출하는 코드
📍 자바 스크립트에서 함수는 '일급 객체 first-class object'로 일반 객체와 동일한 위상을 가지고 있으므로 파라미터가 되거나 리턴타입이 될 수 있음 📍 비동기 방식에서 콜백을 이용하는 것이 해결책이 되기는 하지만 동기화된 코드에 익숙한 개발자들에게는 조금만 단계가 많아져도 복잡한 코드를 만들어야 하는 불편함이 있음 ➡️ 자바 스크립트에서는 Promise하는 개념을 도입해서 '비동기 호출을 동기화된 방식'으로 작성할 수 있는 문법적인 장치가 있는데 Axios는 이를 활용하는 라이브러리 ➡️ Axios를 이용하면 Ajax를 호출하는 코드를 작성할 때 마치 동기화된 방식으로 작성할 수 있어서 자바 스크립트를 기반으로 하는 프레임워크 (Angula)나 라이브러리 (React, Vue)에서 많이 사용
2. Axios를 위한 준비
Axios를 활용해 Ajax를 이용하기 위해서는 댓글 처리가 필요한 화면에 Axios 라이브러리를 추가해주어야 함 자바 스크립트 코드의 경우 read.html에서는 주로 이벤트 관련된 부분을 처리하도록 하고 별도의 JS 파일을 작성해서 Axios를 이용하는 통신을 처리하도록 구성
static 폴더에 있는 js 폴더에 reply.js 파일을 추가
read.html의 <div layout:fragment="content">가 끝나기 전에 Axios 라이브러리를 추가 하고 reply.js 파일도 같이 추가
👾 reply.js에 간단하게 Axios를 이용하는 코드를 추가. Axios를 이용할 때 async / await를 같이 이용하면 비동기 처리를 동기화된 코드처럼 작성할 수 있음 ⚡️ async는 함수 선언 시에 사용하는 데 해당 함수가 비동기 처리를 위한 함수라는 것을 명시하기 위해서 사용하고, await는 async 함수 내에서 비동기 호츨하는 부분에 사용
async function get1(bno) {
const result = await axios.get(`/api/replies/list/${bno}`);
console.log(result);
}
reply.js에서 개발하려는 함수의 이름은 getList()라 하고, 파라미터는 다음과 같이 결정
bno : 현재 게시물 번호
page : 페이지 번호
size : 페이지당 사이즈
goLast : 마지막 페이지 호출 여부
⚡️ 이 중에서 goLast는 조금 특별한 용도로 사용. 댓글의 경우 한 페이지에서 모든 동작이 이루어지므로 새로운 댓글이 등록되어도 화면에는 아무런 변화가 없다는 문제가 생김 ⚡️ 또한 페이징 처리가 되면 새로 등록된 댓글이 마지막 페이지에 있기 때문에 댓글의 결과를 볼 수 있다는 문제가 생김
📍 goLast변수를 이용해서 강제적으로 마지막 댓글 페이지를 호출하도록 함
reply.js에 getList() 함수를 작성
async function getList({bno, page, size, goLast}) {
const result = await axios.get(`/api/replies/list/${bno}?page=${page}`, {params: {page, size}})
return result.data
}
read.html에는 getList()를 호출하는 함수와 현재 페이지가 로딩되면 해당 함수를 호출하도록 작성
function printReplies(page, size, goLast) {
getList({bno, page, size, goLast}).then(
data => {console.log(data);}
).catch(e => {
console.error();
});
}
printReplies(1, 10); // 무조건 호출
👾 결과 데이터는 dtoList로 화면에 목록(replyList)을 처리하고, 나머지 정보들로 페이지 번호를 출력
read.html에는 댓글 목록을 출력하는 printList()와 페이지 번호를 출력하는 printPages() 함수를 작성하고 Axios의 결과를 출력하도록 수정
const bno = [[${dto.bno}]];
// console.log(get1(bno));
function printReplies(page, size, goLast) {
getList({bno, page, size, goLast}).then(
data => {
console.log(data);
printList(data.list); // 목록 처리
printPages(data); // 페이지 처리
}
).catch(e => {
console.error();
});
}
printReplies(1, 10); // 무조건 호출
const replyList = document.querySelector('.replyList'); // 댓글 목록 DOM
const replyPaging = document.querySelector('.replyPaging'); // 페이지 목록 DOM
function printList(dtoList) { // 댓글 목록 출력
let str = '';
if(dtoList && dtoList.length > 0) {
for (const dto of dtoList) {
str += ` <li class="list-group-item d-flex replyItem">
<span class="col-2">${dto.rno}</span>
<span class="col-6" data-rno="${dto.rno}">${dto.replyText}</span>
<span class="col-2">${dto.replyWriter}</span>
<span class="col-2">${dto.regDate}</span>
</li>`;
}
}
replyList.innerHTML = str;
}
function printPages(data) { // 페이지 목록 출력
// pagination
page = data.page;
let pageStr = '';
if(data.prev) {
pageStr += `<li class="page-item">
<a class="page-link" data-page="${data.start - 1}">PREV</a></li>`;
}
for(let i = data.start; i <= data.end; i++) {
pageStr += `<li class="page-item ${i === data.page ? "active" : ""}">
<a class="page-link" data-page="${i}">${i}</a></li>`;
}
if(data.next) {
pageStr += `<li class="page-item">
<a class="page-link" data-page="${data.end + 1}">NEXT</a></li>`;
}
// console.log(pageStr);
replyPaging.innerHTML = pageStr;
}
A. @JsonFormat, @JsonIgnore
📍 출력된 댓글의 모양을 보면 등록 시간 regDate 부분이 배열로 처리되어서 지저분해 보임.
➡️ RelyDTO에 @JsonFormat을 이용해서 JSON 처리 시에 포맷팅을 지정 📍 댓글 수정 시간 modDate의 경우 화면에서 전혀 출력할 일이 없으므로 JSON 으로 변환될 때 제외하도록 @JsonIgnore를 적용
컨트롤러 영역에서는 Swagger UI를 이용해서 테스트와 함께 필요한 기능을 개발 ReplyController는 ReplyService를 주입 받도록 설계
@RestController
@RequestMapping("/api/replies")
@Log4j2
@RequiredArgsConstructor
public class ReplyController {
private final ReplyService replyService;
1) 등록 기능 확인
👩🏻🚀 ReplyController의 등록 기능은 이미 개발된 코드에 JSON 처리를 위해서 추가 코드가 필요
@Operation(summary = "Replies Post", description = "POST 방식으로 댓글 등록")
@PostMapping(value = "/", consumes = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Long> register(@Valid @RequestBody ReplyDTO replyDTO,
BindingResult bindingResult) throws BindException {
log.info(replyDTO);
if (bindingResult.hasErrors()) {
throw new BindException(bindingResult);
}
Map<String, Long> map = new HashMap<>();
Long rno = replyService.register(replyDTO);
map.put("rno", rno);
return map;
}
✓ 프로젝트를 실행하고 Swagger UI를 통해서 테스트를 진행. 등록 작업에서 주의할 점은 bno가 존재하는 게시물 번호여야 함
✓ 정상적으로 동작하면 {"rno": 6}과 같은 결과가 전송되는 것을 확인
에러에 대한 처리
📍 @Valid는 이미 처리를 했지만 연관 관계를 가진 엔티티를 처리할 때마다 항상 문제가 되는 것은 연관된 객체의 안전성을 확보하는 것
➡️ 예를 들어 앞선 테스트에서 bno 값을 사용할 수 없는 번호로 작성하면 다음과 같은 문제가 발생
[(conn=1337) Cannot add or update a child row: a foreign key constraint fails (`boot_ex_app_01_2405`.`reply`, CONSTRAINT `FKr1bmblqir7dalmh47ngwo7mcs` FOREIGN KEY (`board_bno`) REFERENCES `board` (`bno`))]
⚡️ 서버에 기록된 로그를 보면 SQL Exception이긴 하지만, org.springframework.dao.DataIntegrityViolationException 예외가 발생. 예외가 발생한다는 것은 분명 정상적인 결과이지만 서버의 상태 코드는 500으로 '서버 내부의 오류'로 처리
⚡️ 외부에서 Ajax로 댓글 등록 기능을 호출했을 때 500 에러가 발생한다면 호출한 측에서는 현재 서버의 문제라고 생각할 것
➡️ 클라이언트에 서버의 문제가 아니라 데이터의 문제가 있다고 전송하기 위해서는 @RestControllerAdvice를 이용하는 CustomRestAdvice에 DataIntegrityViolationException를 만들어서 사용자에게 예외 메시지를 전송하도록 구성
✓ 추가한 handlerFKException()는 DataIntegrityViolationException이 발생하면 "constraint fails" 메시지를 클라이언트로 전송
2) 특정 게시물의 댓글 목록
👩🏻🚀 특정한 게시물의 댓글 목록 처리는 '/api/replies/list/{bno}' 경로를 이용하도록 구성. 이때 bno는 게시물의 번호를 의미. 👩🏻🚀 스프링에서는 @PathVariable이라는 어노테이션을 이용해서 호출하는 경로의 값을 직접 파라미터의 변수로 처리할 수 있는 방법을 제공
ReplyController에 메서드 추가
@Operation(summary = "Replies of Board", description = "GET 방식으로 특정 게시물의 댓글 목록")
@GetMapping(value = "/list/{bno}")
public PageResponseDTO<ReplyDTO> getList(@PathVariable("bno") Long bno, PageRequestDTO pageRequestDTO) {
PageResponseDTO<ReplyDTO> responseDTO = replyService.getListOfBoard(bno, pageRequestDTO);
return responseDTO;
}
✓ getList()에서 bno 값은 경로에 있는 값을 취해서 사용할 것이므로 @PathVariable을 이용하고, 페이지와 관련된 정보는 일반 쿼리 스트링을 이용 ✓ Swagger UI로 ReplyController를 호출해 보면 PageResponseDTO가 JSON으로 처리된 결과를 볼 수 있음
3) 특정 댓글 조회
👩🏻🚀 특정한 댓글을 조회할 때는 Reply의 rno를 경로로 이용해서 GET 방식으로 처리
ReplyController에 getReplyDTO() 메서드를 추가
@Operation(summary = "Read Reply", description = "GET 방식으로 특정 댓글 조회")
@GetMapping(value = "/{rno}")
public ReplyDTO getReplyDTO(@PathVariable("rno") Long rno) {
ReplyDTO replyDTO = replyService.read(rno);
return replyDTO;
}
✓ 정상적인 rno 값이 전달되면 ReplyDTO가 JSON으로 처리
데이터가 존재하지 않는 경우의 처리
📍 getReplyDTO()와 같이 특정한 번호를 이용해서 조회할 때 문제가 되는 부분은 해당 데이터가 존재하지 않는 경우 📍 'NoSuchElementException' 예외 전송을 위해CustomRestAdvice에 기능을 추가
@ExceptionHandler(NoSuchElementException.class)
@ResponseStatus(HttpStatus.EXPECTATION_FAILED)
public ResponseEntity<Map<String, String>> handleNoSuchElementException(Exception ex) {
log.error(ex);
Map<String, String> errorsMap = new HashMap<>();
errorsMap.put("time", "" + System.currentTimeMillis());
errorsMap.put("msg", "No Such Element Exception");
return ResponseEntity.badRequest().body(errorsMap);
}
4) 특정 댓글 삭제
👩🏻🚀 일반적으로 REST 방식에서 삭제 작업은 GET / POST 가 아닌 DELETE 방식을 이용해서 처리
ReplyController에 remove() 메서드를 추가
@Operation(summary = "Delete Reply", description = "DELETE 방식으로 특정 댓글 삭제")
@DeleteMapping(value = "/{rno}")
public Map<String, Long> remove(@PathVariable("rno") Long rno) {
replyService.remove(rno);
Map<String, Long> map = new HashMap<>();
map.put("rno", rno);
return map;
}
존재하지 않는 번호의 삭제 예외
@ExceptionHandler({NoSuchElementException.class, EmptyResultDataAccessException.class})
@ResponseStatus(HttpStatus.EXPECTATION_FAILED)
public ResponseEntity<Map<String, String>> handlerNoSuchElementException(Exception e) {
log.error(e);
Map<String, String> errorMap = new HashMap<>();
errorMap.put("time", "" + System.currentTimeMillis());
errorMap.put("msg", "No Such Element Exception");
return ResponseEntity.badRequest().body(errorMap);
}
5) 특정 댓글 수정
👩🏻🚀 댓글 수정은 PUT방식으로 처리
ReplyController에 modify() 메서드를 추가
📍 주의할 점은 수정할 때도 등록과 마찬가지로 JSON 문자열이 전송되므로 이를 처리하도록 @RequestBody를 적용한다는 점
@Operation(summary = "Modify Reply", description = "PUT 방식으로 특정 댓글 수정")
@PutMapping(value = "/{rno}", consumes = MediaType.APPLICATION_JSON_VALUE)
public Map<String, Long> modify(@PathVariable("rno") Long rno, @RequestBody ReplyDTO replyDTO) {
replyDTO.setRno(rno);
replyService.modify(replyDTO);
Map<String, Long> map = new HashMap<>();
map.put("rno", rno);
return map;
}
👩🏻💻 ReplyServiceImpl에서는 PageRequestDTO를 이용해서 페이지 관련 정보를 처리하고 ReplyRepository를 통해서 특정 게시물에 속하는 Page<Reply>를 구함. 실제 반환되어야 하는 타입은 Reply가 아니라 ReplyDTO 타입이므로 ReplyServiceImpl에서는 이를 변환하는 작업이 필요
데이터베이스에서는 PK와 FK를 이용해서 엔티티 간의 관계를 표현. ⚡️ 데이터베이스의 테이블을 설계하는 경우 PK를 가진 테이블을 먼저 설계하고, 이를 FK로 사용하는 테이블을 설계하는 방식이 일반적
반면에 객체지향을 이용하는 JPA에서는key가 아니라 객체를 참조함 ⚡️ 예를 들어 '회원'이 여러 개의 '아이템'을 가지고 있다고 가정하면 '회원 객체가 아이템들을 참조할 것인지, 아이템이 회원을 참조하게 될 것인지' 판단을 해야 함.
⚡️ 만일 reply 테이블에서 board 테이블을 참조하는 bno키를 엔티티 객체에서 구현하는 경우 Long bno 라고 설정을 하면 JPA에서는 board의 프라이머리키를 참조하는 FK라고 인지 못하고, 일반 컬럼 bno 라고 판단
JPA의 연관 관계의 판단 기준을 결정할 때 기준
연관 관계의 기준은 항상 변화가 많은 쪽을 기준으로 결정
ERD의 FK를 기준으로 결정
1) 변화가 많은 쪽을 기준
🚀 판단 기준은 '조금 더 자주 변화가 있는 쪽'을 선택하는 것이 편리 ➡️ 예를 들어 '회원'과 '게시물'의 관계를 보면 회원들의 활동을 통해서 여러 개의 게시물이 만들어지므로 연관 관계의 핵심은 '게시물'로 판단하는 것이 편리
ERD 상에서 FK를 기준
✓ 간혹 연관 관계의 주어를 결정하기 어려울 때는 엔티티 관계 다이어그램 ERD를 그려서 확인하는 것이 좋음 ✓ ERD에서 JPA의 연관 관계를 읽을 때는 FK를 판단해서 읽는 것이 하나의 해결책이 될 수 있음
2) 단방향과 양방향
🚀 객체지향이 관계형 데이터베이스와 다른 점 중의 하나는 객체가 다른 객체를 참조하는 방식의 차이가 있음 🚀 데이터베이스에는 특정한 PK를 다른 테이블에서 FK로 참조해서 사용할 수 있지만, 객체지향에서는 'A가 B의 참조를 가질 수 있고, B가 A의 참조를 가질수'있다는 점이 다름 🚀 JPA는 참조를 결정할 때 다양한 방식이 존재할 수 있음 ➡️ A가 B를 참조하거나 B가 A를 참조라는 방식으로 한쪽만 참조를 유지하는 방식을 단방향 unidirection
➡️ 양쪽 모두를 참조하는 방식을 양방향 bidirection
⚡️ 양방향 : 양쪽 객체 모두 서로 참조를 유지하기 때문에 모든 관리를 양쪽 객체에 동일하게 적용해야만 하는 불편함이 있지만 JPA에서 필요한 데이터를 탐색하는 작업에서는 편리함을 제공 ⚡️ 단방향 : 구현이 단순하고 에러 발생의 여지를 많이 줄일 수 있지만, 데이터베이스 상에서 조인처리와 같이 다른 엔티티 객체의 내용을 사용하는 데 어렵다는 단점이 있음
📍 기본적으로 단방향으로 참조 관계를 유지하면서 다른 엔티티를 사용해야 할 때는 JPQL을 통한 조인처리를 이용하도록 작업
✓ Reply 클래스에는 Board 타입의 객체 참조를 하는 변수를 이용해서 참조하는데 이때 @ManyToOne을 이용해서 '다대일' 관계로 구성됨을 설명
📍 연관 관계를 구성할 때에는 다음과 같은 점들을 주의해서 작성
@ToString을 할 때 참조하는 객체를 사용하지 않도록 반드시 exclude 속성값을 지정
@ManyToOne과 같이 연관 관계를 나타낼 때는 반드시 fetch 속성을 LAZY로 지정
✓ 프로젝트를 실행하면 Reply 관련 테이블이 자동으로 생성되는 것을 확인 ✓ 생성된 테이블에서는 게시물 Board과 관련된 속성으로 'board_bno'라는 컬럼이 생성되고, 자동으로 FK가 생성
1) ReplyRepository 생성과 테스트
🚀 Reply는 Board와 별도로 CRUD가 일어날 수 있기 때문에 별도의 Repository를 작성해서 관리하도록 구성
repository 패키지에 ReplyRepository를 선언
public interface ReplyRepository extends JpaRepository<Reply, Long> {
}
A. 테스트를 통한 insert 확인
👾 test 폴더에는 ReplyRepositoryTest 클래스를 작성해서 작성된 ReplyRepository의 테스트 코드를 작성
👾 insertTest() 에서 주의할 부분은 Board 객체를 생성하는 부분. JPA에서는 @Id를 이용해서 엔티티 객체들을 구분하므로 실제 존재하는 @Id 값을 가지도록 구성하는 것이 중요
@SpringBootTest
@Log4j2
class ReplyRepositoryTest {
@Autowired
private ReplyRepository replyRepository;
@Test
public void testInsert() {
// 실제 DB에 있는 bno
Long bno = 50L;
Board board = Board.builder().bno(bno).build();
Reply reply = Reply.builder()
.board(board)
.replyText("댓글")
.replyWriter("작성자 1")
.build();
replyRepository.save(reply);
}
✓ InsertTest()를 실행하면 SQL 문이 실행되는 것과 데이터베이스의 reply 테이블도 확인
B. 특정 게시물의 댓글 조회와 인덱스
👾 댓글이 사용되는 방식은 주로 게시물 번호를 통해서 사용되는 경우가 많음 ➡️ 게시물의 댓글의 수나 해당 게시물의 댓글 목록 등 👾 쿼리 조건으로 자주 사용되는 컬럼에는 인덱스를 생성해 두는 것이 좋은데 @Table 어노테이션에 추가적인 설정을 이용해서 인덱스를 지정할 수 있음
특정한 게시글의 댓글들은 페이징 처리를 할 수 있도록 Pageable 기능을 ReplyRepository에 @Query를 이용해서 작성
public interface ReplyRepository extends JpaRepository<Reply, Long> {
@Query("SELECT r FROM Reply r WHERE r.board.bno = :bno")
Page<Reply> listByBoard(@Param("bno") Long bno, Pageable pageable);
}
C. 테스트 코드와 fetch 속성
👾 테스트 코드를 통해서 listOfBoard()의 동작을 확인
@Test
public void listOfBoardTest() {
// 실제 DB에 있는 bno
Long bno = 50L;
Pageable pageable = PageRequest.of(0, 10, Sort.by("rno").descending());
Page<Reply> replies = replyRepository.listByBoard(bno, pageable);
replies.getContent().forEach(log::info);
}
👾 Reply 객체를 출력할 때 조심해야 하는 부분은 @ToString(). JPA는 기본적으로 필요한 엔티티를 최소한의 자원으로 쓰는 방식을 선택.
➡️ 예를 들어 Reply를 출력했할 때 Board를 같이 출력하도록 한다면 reply 테이블에서 쿼리를 실행하고 board테이블에서 추가적인 쿼리를 실행하게 됨 👾 Reply 클래스의 @ToString()에서 exclude를 제거해 보면 이런 동작을 확인할 수 있음
@ToString
public class Reply extends BaseEntity {
✓ 이 상태에서 BoardRepliesTest() 실행하면 에러가 발생
💫 에러 메시지가 발생하는 상황을 생각해보면 reply 테이블에서 쿼리를 실행했지만, Board 객체를 같이 출력해야 하므로 다시 board 테이블에 쿼리를 추가로 실행해야만 하는 상황. 그래서 다시 한번 데이터베이스를 연결해야만 하는데 현재 테스트 코드는 한 번만 쿼리를 실행할 수 있기 때문에 발생하는 에러. no Session이라는 의미가 데이터베이스와 추가적인 연결이 필요해서 발생하는 문제
👾 강제로 이를 실행하고 싶으면 테스트 코드에 @Transactional을 추가하면 가능
👾 @Transactional을 추가한 후 테스트 코드를 실행하면 다음과 같이 reply 테이블에 쿼리가 실행되고 board 테이블에 추가 쿼리가 실행된 것을 볼 수 있음
👾 @ManyToOne에 fetch 속성 값이 FetchType.LAZY로 지정되어 있음. LAZY 속성값은 '지연 로딩' 이라고 표현하는데 지연 로딩은 기본적으로 필요한 순간까지 데이터베이스와 연결하지 않는 방식으로 동작 👾 FetchType.EAGER. EAGER는 '즉시 로딩'이라는 단어로 표현하는데 해당 엔티티를 로딩할 때 같이 로딩하는 방식. EAGER은 성능에 영향을 줄 수 있으므로 우선은 LAZY 값을 기본으로 사용하고 필요에 따라 EAGER를 고려하는 것이 좋음
D. 댓글 조회 / 수정 / 삭제
👾 JPA에서 엔티티 간의 관계를 한쪽에서만 참조하는 '단방향'방식으로 구현하는 경우 장점은 관리하기 편함 ➡️ 양방향의 경우 양쪽 객체 모두를 변경해 주어야 하기 때문에 구현할 때도 주의해야 하지만 트렌잭션을 신경 써야만 함 👾 현재 구현하는 코드처럼 단방향으로 구현되는 경우 Board 객체는 Reply에 대해서 모르는 상태. 즉, Reply의 CRUD와 무관하기 때문에 단순하게 구현할 수 있음
2) 게시물 목록과 Projection
🚀 게시물과 댓글의 관계처럼 엔티티가 조금씩 확장되면 가장 문제가 되는 부분은 목록 화면.
✓ 기존의 목록 화면에서는 Board 객체를 BoardDTO로 변환시켜서 내용을 출력하면 충분했지만 댓글이 추가되면 상황이 달라짐 ✓ 목록 화면에서는 특정한 게시물에 속한 댓글의 숫자를 같이 출력해 주어야 하기 때문에 기존의 코드를 사용할 수가 없고 다시 추가 개발이 필요
dto 패키지에 BoardListReplyCountDTO 클래스를 작성
@Data
public class BoardListReplyCountDTO {
private Long bno;
private String title;
private String writer;
private LocalDateTime regDate;
private Long replyCount;
}
목록 처리는 Querydsl을 이용하는 구조이므로 search의 BoardSearch에 추가
👾 BoardSearchImpl에서 searchWithReplyCount() 구현에는 단방향 참조가 가지는 단점이 보이는데, 바로 필요한 정보가 하나의 엔티티를 통해서 접근할 수 없다는 점 👾 이 문제를 해결하기 위해서 가장 쉽게 사용할 수 있는 방법은 JPQL을 이용해서 'left (outer) join'이나 'inner join'과 같은 조인 join 을 이용하는 것 👾 게시물과 댓글의 경우 한쪽에서만 데이터가 존재하는 상황이 발생할 수 있음. 예를 들어 특정 게시물은 댓글이 없는 경우가 발생하므로 outer join 을 통해서 처리
✓ JPQLQuery의 leftJoin()을 이용할 때는 on()을 이용해서 조인 조건을 지정 ✓ 조인 처리 후에 게시물당 처리가 필요하므로 groupBy()를 적용
B. Projections.bean()
👾 JPA에서는 Projection(프로젝션)이라고 해서 JPQL의 결과를 바로 DTO로 처리하는 기능을 제공. Querydsl도 마찬가지로 이러한 기능을 제공 👾 목록 화면에서 필요한 쿼리의 결과를 Projections.bean()이라는 것을 이용해서 한번에 DTO로 처리할 수 있는데, 이를 이용하려면 JPQLQuery 객체의 select()를 이용
BoardSearchImpl 클래스의 searchWithReplyCount() 내부는 다음과 같이 구현
· 최종적으로 검색 조건과 applyPagination() 까지 적용
@Override
public Page<BoardListReplyCountDTO> searchWithReplyCount(String[] types, String keyword, Pageable pageable) {
QBoard board = QBoard.board;
QReply reply = QReply.reply;
JPQLQuery<Board> query = from(board);
query.leftJoin(reply).on(reply.board.eq(board));
query.groupBy(board);
if ((types != null && types.length > 0) && keyword != null) { // 검색 조건과 키워드가 있다면
BooleanBuilder booleanBuilder = new BooleanBuilder(); // (
for (String type : types) {
switch (type) {
case "t":
booleanBuilder.or(board.title.contains(keyword));
break;
case "c":
booleanBuilder.or(board.content.contains(keyword));
break;
case "w":
booleanBuilder.or(board.writer.contains(keyword));
break;
}
} // end for
query.where(booleanBuilder);
}// end if
// bno > 0
query.where(board.bno.gt(0L));
JPQLQuery<BoardListReplyCountDTO> dtojpqlQuery = query.select(Projections.bean(BoardListReplyCountDTO.class,
board.bno, board.title, board.writer, board.regDate, reply.count().as("replyCount")));
this.getQuerydsl().applyPagination(pageable, dtojpqlQuery);
List<BoardListReplyCountDTO> dtoList = dtojpqlQuery.fetch();
long count = dtojpqlQuery.fetchCount();
return new PageImpl<>(dtoList, pageable, count);
}