1.  댓글 등록

댓글 추가는 모달창을 이용해서 처리
  ⚡️  모달창을 이용해서 새로운 댓글이 필요한 replyText나 replyWriter를 입력하도록 구성하고, 자바스크립트의 객체로 POST 호출하도록 구성

reply.js에 새로운 댓글을 등록하는 기능을 추가


  📍 파라미터를 JS의 객체로 받아서 axios.post()를 이용해서 전달해 줌

async function addReply(replyObj) {
    const response = await axios.post(`/api/replies/`, replyObj);
    return response;
}

 

  ✓  addReply()가 정상적으로 처리되면 서버에서는 '{'rno':11}'과 같은 JSON 데이터를 전송하게 됨
  ✓  이를 이용해서 댓글이 추가되면 경고창을 보여주고 마지막 페이지로 이동해서 등록된 댓글을 볼 수 있게 구성

 

read.html에는 댓글 등록을 위한 모달창을 <div layout:fragment="content">가 끝나기 전에 추가
<!-- register modal -->
<div class="modal registerModal" tabindex="-1">
    <div class="modal-dialog">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title">Register Reply</h5>
                <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
            </div>
            
            <div class="modal-body">
                <div class="input-group mb-3">
                    <span class="input-group-text">Reply Text</span>
                    <input type="text" class="form-control replyText" >
                </div>
                <div class="input-group mb-3">
                    <span class="input-group-text">Reply Writer</span>
                    <input type="text" class="form-control replyWriter" >
                </div>
            </div>
        
            <div class="modal-footer">
                <button type="button" class="btn btn-primary registerBtn">Register</button>
                <button type="button" class="btn btn-outline-dark closeRegisterBtn" >Close</button>
            </div>
        </div>
    </div>
</div>
<!-- // register modal -->

 

  ✓  모달창의 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 방식으로 호출

async function getReply(rno) {
    const response = await axios.get(`/api/replies/${rno}`);
    return response.data;
}

async function modifyReply(replyObj) {
    const response = await axios.put(`/api/replies/${replyObj.rno}`, replyObj);
    return response.data;
}

 

2)  read.html의 모달창 처리

  👾  댓글 수정 후에는 다시 현재 페이지를 호출해서 최대한 목록에서 수정된 댓글을 확인할 수 있도록 함

 

read.html에 등록 모달창 아래 영역을 새롭게 추가
<!-- modify modal -->
<div class="modal modifyModal" tabindex="-1">
    <div class="modal-dialog">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title replyHeader"></h5>
                <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
            </div>
        
            <div class="modal-body">
                <div class="input-group mb-3">
                    <span class="input-group-text">Reply Text</span>
                    <input type="text" class="form-control modifyText" >
                </div>
            </div>

            <div class="modal-footer">
                <button type="button" class="btn btn-info modifyBtn">Modify</button>
                <button type="button" class="btn btn-danger removeBtn">Remove</button>
                <button type="button" class="btn btn-outline-dark closeModifyBtn">Close</button>
            </div>
        </div>
    </div>
</div>
<!-- // modify modal -->

 

  ✓  <div class="modifyModal">의 경우 class 속성값이 replyHeader 영역을 이용해서 선택한 댓글의 번호를 보여주도록 하고, modifyText 부분에는 댓글의 내용을 수정할 수 있도록 구성

 

read.html에 변수들을 추가해서 제어가 가능하도록 구성
const modifyModal = new bootstrap.Modal(document.querySelector('.modifyModal'));
const replyHeader = document.querySelector('.replyHeader');
const modifyText = document.querySelector('.modifyText');
const modifyBtn = document.querySelector('.modifyBtn');
const removeBtn = document.querySelector('.removeBtn');
const closeModifyBtn = document.querySelector('.closeModifyBtn');

 

특정한 댓글을 눌렀을 때 모달창을 보여주도록 replyList에 이벤트 처리를 추가

 

  📍  댓글 목록 역시 매번 브라우저에서 새로 만들어지기 때문에 이벤트는 항상 존재하는 바깥쪽 <ul>을 대상으로 처리

// 댓글 내용을 클릭했을 때 수정 모달창 띄우기
replyList.addEventListener('click', function (e) {
    e.preventDefault();
    e.stopPropagation();

    const target = e.target;
    if (!target || target.tagName !== 'SPAN') {
        return;
    }

    const rno = target.getAttribute('data-rno');
    if (!rno) {
        return;
    }
    
    getReply(rno).then(reply => { // 댓글의 내용을 모달창에 채워서 보여줌
        console.log(reply)
        replyHeader.innerHTML = reply.rno;
        modifyText.value = reply.replyText;
        modifyModal.show();
    }).catch(e => alert('error'));
});

 


3)  댓글 수정과 화면 갱신

  👾  댓글 수정은 화면에 있는 댓글의 내용과 번호, 게시물의 번호를 이용해서 처리할 수 있음
  👾  신경써야 할 부분은 댓글의 수정 후 처리.

         ➡️  수정된 댓글은 결국 목록에서 확인하게 되기 때문에 만일 사용자가 댓글을 수정하는 사이에 댓글이 많이 추가되면 확인할 방법이 없음

  👾  최대한 자신이 보고 있었던 페이지를 유지하는 수준으로 구현하도록 함

 

화면에서 Modify 버튼에 대한 이벤트 처리와 Close 버튼의 이벤트 처리를 작성
// 모달창의 수정 버튼 클릭
modifyBtn.addEventListener('click', function (e) {
    console.log('modifyBtn... click');
    const replyObj = {bno: bno, rno: replyHeader.innerHTML, replyText: modifyText.value};

    modifyReply(replyObj).then(result => {// 수정 처리 후
        alert(result.rno + '댓글이 수정되었습니다.');
        modifyText.value = '';
        modifyModal.hide();
        printReplies(page, size);
    }).catch(e => {
    console.log(e)
    });
});

// 모달창 close 버튼 클릭
closeModifyBtn.addEventListener('click', function () {
    modifyModal.hide();
});

 

  ✓  댓글이 수정된 후에는 다시 현재 페이지를 볼 수 있도록 printReplies()를 호출하므로 수정한 후에 현재 페이지를 다시 갱신


4)  댓글 삭제

reply.js에 삭제 처리 추가
async function removeReply(rno) {
    const response = await axios.delete(`/api/replies/${rno}`);
    return response.data;
}

 

read.html에는 모달 창의 Remove 버튼을 눌러서 댓글을 삭제

 

  📍 삭제 후에는 경고창을 통해서 댓글이 삭제되었음을 알려주고 댓글의 1페이지로 이동

/*
5. 삭제 관련
*/
removeBtn.addEventListener('click', function (e) {
    removeReply(replyHeader.innerHTML).then(result => {
        alert(result.rno + ' 댓글이 삭제되었습니다.');
        modifyText.value = '';
        modifyModal.hide();
        page = 1; // 해당 댓글이 삭제되었으니 1페이지로 이동
        printReplies(page, size);
    }).catch(e => {
    console.log(e)
    });
});

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  비동기 처리와 Axios

대부분 프로그래밍의 시작은 항상 동기화된 방식을 이용.

result1 = doA();
result2 = doB(result1);
result3 = doC(result2);


  ⚡️  흔하게 볼 수 있는 동기화된 코드인데 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)에서 많이 사용

 

시작하기 | Axios Docs

시작하기 브라우저와 node.js에서 사용할 수 있는 Promise 기반 HTTP 클라이언트 라이브러리 Axios란? Axios는 node.js와 브라우저를 위한 Promise 기반 HTTP 클라이언트 입니다. 그것은 동형 입니다(동일한 코

axios-http.com

 


2.  Axios를 위한 준비

Axios를 활용해 Ajax를 이용하기 위해서는 댓글 처리가 필요한 화면에 Axios 라이브러리를 추가해주어야 함
자바 스크립트 코드의 경우 read.html에서는 주로 이벤트 관련된 부분을 처리하도록 하고 별도의 JS 파일을 작성해서 Axios를 이용하는 통신을 처리하도록 구성

static 폴더에 있는 js 폴더에 reply.js 파일을 추가
  • read.html의 <div layout:fragment="content">가 끝나기 전에 Axios 라이브러리를 추가 하고 reply.js 파일도 같이 추가
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js">
</script>
<script src="/js/reply.js"></script>

 

read.html에 댓글과 관련된 화면 구성을 위한 코드 추가
  • 댓글 작성 버튼
  • 댓글 목록
  • 댓글 목록 페이징
<div class="row mt-3">
    <div class="col-md-12">
        <div class="my-4">
        <!-- 댓글 작성 버튼 -->
            <button class="btn btn-info addReplyBtn">ADD REPLY</button>
        </div>
        <!-- 댓글 목록 -->
            <ul class="list-group replyList">

            </ul>
        </div>
        <div class="row mt-3">
            <div class="col">
            <!-- 댓글 목록 페이징 -->
                <ul class="pagination replyPaging">

                </ul>
            </div>
    </div>
</div>

 


1)  Axios 호출해 보기

  👾  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);
}

 

read.html에 get1()을 호출하는 코드를 작성
<script layout:fragment="script" th:inline="javascript">
    const bno = [[${dto.bno}]];
    get1(bno);
</script>

 

브라우저에서 댓글이 많이 달린 조회 페이지로 이동

 

 

A.  비동기 함수의 반환

 

  ✓  화면에서 결과가 필요하다면 Axios의 호출 결과를 반환받아야 하기 때문에 reply.js에서 코드 수정

async function get1(bno) {
    const result = await axios.get(`/api/replies/list/${bno}`);
    //console.log(result);
    return result.data;
}
<script layout:fragment="script" th:inline="javascript">
    const bno = [[${dto.bno}]];
    console.log(get1(bno));
</script>

 


3.  댓글 처리와 자바스크립트

1)  댓글 목록 처리

  👾  가장 먼저 개발하려는 기능은 댓글 목록 기능
  👾  댓글도 페이징 처리가 가능하도록 구성

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를 적용

@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime regDate;

@JsonIgnore
private LocalDateTime modDate;

 


B. 마지막 페이지로 이동


  📍 댓글 페이징은 새로 글이 추가되는 상황이 발생하면 마지막으로 등록되기 때문에 확인이 어려운 문제가 있음
        ➡️  이를 처리하려면 댓글 목록 데이터의 total을 이용해서 다시 마지막 페이지를 호출해야 함

    ⚡️  현재 게시물의 댓글에 마지막 페이지를 알아낸 후, 마지막 페이지를 다시 호출하는 방식으로 동작
          ✓  마지막 페이지의 호출은 total 값과 size 값을 이용해서 마지막 페이지를 계산하고 다시 Axios로 호출하는방식

 

reply.js의 getList()는 마지막 페이지로 호출할 수 있는 goLast 변수를 추가해서 수정
async function getList({bno, page, size, goLast}){

    const result = await axios.get(`/api/replies/list/${bno}?page=${page}`, {params: {page, size}})
    if(goLast) {
        const total = result.data.total
        const lastPage = parseInt(Math.ceil(total/size))

        return getList({bno:bno, page:lastPage, size:size})
    }

    return result.data
}

 

  📍  read.html에서 처음부터 댓글의 마지막 페이지를 보고 싶다면 printReplies()를 호출할 때 true 값을 추가

    //printReplies(1, 10); // 무조건 호출
    printReplies(1, 10, true);

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  컨트롤러 계층 구현

컨트롤러 영역에서는 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를 만들어서 사용자에게 예외 메시지를 전송하도록 구성

 

@ExceptionHandler(DataIntegrityViolationException.class)
@ResponseStatus(HttpStatus.EXPECTATION_FAILED)
public ResponseEntity<Map<String, String>> handleFKException(Exception ex) {
    log.error(ex);

    Map<String, String> errorsMap = new HashMap<>();
    errorsMap.put("time", "" + System.currentTimeMillis());
    errorsMap.put("msg", "constraint fails");

    return ResponseEntity.badRequest().body(errorsMap);
}

 

  ✓  추가한 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;
}

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  댓글 서비스 계층의 구현

댓글의 엔티티 처리가 끝났다면 서비스 계층을 구현
Service 패키지에 ReplyService 인터페이스와 ReplyServiceImpl 클래스를 추가

 

1)  댓글 등록 처리

  👩🏻‍💻  댓글 등록은 게시물과 비슷하게 구현. ReplyService 인터페이스에 메서드를 정의

public interface ReplyService {
    Long register(ReplyDTO replyDTO);
}

 

  👩🏻‍💻  ReplyServiceImpl은 ReplyRepositoy와 ModelMapper를 주입받아서 구현

@Service
@RequiredArgsConstructor
@Log4j2
public class ReplyServiceImpl implements ReplyService {
    private final ReplyRepository replyRepository;
    private final ModelMapper modelMapper;

    @Override
    public Long register(ReplyDTO replyDTO) {
        Reply reply = modelMapper.map(replyDTO, Reply.class);
        Long rno = replyRepository.save(reply).getRno();
        return rno;
    }
}

 

 

댓글 등록 테스트


  📍  test 폴더의 service 패키지에 ReplyServiceTests 클래스를 추가해서 ReplyService 기능들의 동작에 문제가 없는지 확인

@SpringBootTest
@Log4j2
class ReplyServiceImplTest {

    @Autowired
    private ReplyService replyService;

    @Test
    public void registerReply() {
        log.info(replyService.getClass().getName());

        ReplyDTO replyDTO = ReplyDTO.builder()
                .bno(90L)
                .replyText("test...")
                .replyWriter("test...")
                .build();

       replyService.register(replyDTO);
    }
}

 

  ✓  실행 결과에 정상적으로 SQL이 동작하고 새로운 댓글이 insert되는지 확인. 실행 로그에는 새로운 댓글의 rno 값이 출력

 

 


2)  댓글 조회 / 수정 / 삭제 / 목록

  👩🏻‍💻  댓글을 수정하는 경우에는 Reply 객체에서 replyText만을 수정할 수 있으므로 Reply를 수정

public class Reply extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long rno;
    
    @ManyToOne(fetch = FetchType.LAZY)
    private Board board;

    private String replyText;
    private String replyWriter;

    public void changeText(String text) {
        this.replyText = text;
    }
}

 

  👩🏻‍💻  ReplyService 인터페이스에 CRUD 기능들을 선언

public interface ReplyService {

    Long register(ReplyDTO replyDTO);

    ReplyDTO read(Long rno);

    void modify(ReplyDTO replyDTO);

    void remove(Long rno);
}

 

  👩🏻‍💻  ReplyServiceImpl에서 register()를 제외한 기능들은 다음과 같이 구현

    @Override
    public ReplyDTO read(Long rno) {
        Optional<Reply> replyOptional = replyRepository.findById(rno);
        Reply reply = replyOptional.orElseThrow();
        return modelMapper.map(reply, ReplyDTO.class);
    }

    @Override
    public void modify(ReplyDTO replyDTO) {
        Optional<Reply> replyOptional = replyRepository.findById(replyDTO.getRno());
        Reply reply = replyOptional.orElseThrow();
        reply.changeText(replyDTO.getReplyText()); // 댓글의 내용만 수정 가능
        replyRepository.save(reply);
    }

    @Override
    public void remove(Long rno) {
        replyRepository.deleteById(rno);
    }

 


3)  특정 게시물의 댓글 목록 처리

  👩🏻‍💻  댓글 서비스의 가장 중요한 기능은 특정한 게시물의 댓글 목록을 페이징 처리한 것

ReplyService에 getListOfBoard()를 추가
PageResponseDTO<ReplyDTO> getListOfBoard(Long bno, PageRequestDTO pageRequestDTO);

 

  👩🏻‍💻  ReplyServiceImpl에서는 PageRequestDTO를 이용해서 페이지 관련 정보를 처리하고 ReplyRepository를 통해서 특정 게시물에 속하는 Page<Reply>를 구함. 실제 반환되어야 하는 타입은 Reply가 아니라 ReplyDTO 타입이므로 ReplyServiceImpl에서는 이를 변환하는 작업이 필요

@Override
public PageResponseDTO<ReplyDTO> getListOfBoard(Long bno, PageRequestDTO pageRequestDTO) {
    Pageable pageable = PageRequest.of(pageRequestDTO.getPage() <= 0 ? 0 : pageRequestDTO.getPage() - 1,
        pageRequestDTO.getSize(), Sort.by("rno").ascending());
    Page<Reply> replies = replyRepository.listOfBoard(bno, pageable);
    List<ReplyDTO> replyDTOList = replies.getContent().stream().map(reply ->
        modelMapper.map(reply, ReplyDTO.class)).collect(Collectors.toList());
        return PageResponseDTO.<ReplyDTO>withAll()
            .pageRequestDTO(pageRequestDTO)
            .dtoList(replyDTOList)
            .total((int)replies.getTotalElements())
            .build();
}

 

 

 

 

[ 내용 참고 : IT 학원 강의 ] 


1.  연관관계를 결정하는 방법

데이터베이스에서는 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을 통한 조인처리를 이용하도록 작업

 


2.  다대일 연관 관계의 구현

다대일 연관 관계는 필요한 엔티티 클래스에 @ManyToOne을 이용해서 연관관계를 작성

 

domain 패키지에 댓글의 엔티티 클래스를 추가
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "board")
public class Reply extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long rno;

    @ManyToOne(fetch = FetchType.LAZY)
    private Board board;

    private String replyText;
    private String replyWriter;
}

 

  ✓  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 어노테이션에 추가적인 설정을 이용해서 인덱스를 지정할 수 있음

 

Reply 클래스에는 @Table 어노테이션을 추가해서 다음과 같이 구성
@Entity
@Table(name = "Reply", indexes = {@Index(name = "idx_reply_board_bno", columnList = "board_bno")})
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
@ToString(exclude = "board")
public class Reply extends BaseEntity {

 

특정한 게시글의 댓글들은 페이징 처리를 할 수 있도록 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에 추가
public interface BoardSearch {
    Page<Board> search1(Pageable pageable);
    Page<Board> searchAll(String[] types, String keyword, Pageable pageable);
    Page<BoardListReplyCountDTO> searchWithReplyCount(String[] types, String keyword, Pageable pageable);
}

 


A.  LEFT (OUTER) JOIN 처리


  👾  BoardSearchImpl에서 searchWithReplyCount() 구현에는 단방향 참조가 가지는 단점이 보이는데, 바로 필요한 정보가 하나의 엔티티를 통해서 접근할 수 없다는 점
  👾  이 문제를 해결하기 위해서 가장 쉽게 사용할 수 있는 방법은 JPQL을 이용해서 'left (outer) join'이나 'inner join'과 같은 조인 join 을 이용하는 것
  👾  게시물과 댓글의 경우 한쪽에서만 데이터가 존재하는 상황이 발생할 수 있음. 예를 들어 특정 게시물은 댓글이 없는 경우가 발생하므로 outer join 을 통해서 처리

 

코드의 일부분으로 조인 처리만을 구성
@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);
    
    return null;
}

 

  ✓  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);
}

 

테스트 코드는 BoardRepositoryTests를 이용
@Test
public void searchReplyCountTest() {
    Pageable pageable = PageRequest.of(1, 10, Sort.by("bno").descending());
    Page<BoardListReplyCountDTO> boardPage = boardRepository.searchWithReplyCount(new String[]{"t", "c", "w"}, "1", pageable);

    // total pages
    log.info(boardPage.getTotalPages());

    // page size
    log.info(boardPage.getSize());

    // pageNumber
    log.info(boardPage.getNumber());

    // prev next
    log.info(boardPage.hasPrevious() + ": " + boardPage.hasNext());

    boardPage.getContent().forEach(board -> log.info(board));
}

 

 


3)  게시물 목록 화면 처리

  🚀  BoardRepository와 BoardSearch(Impl)을 이용해서 BoardListReplyCountDTO 처리가 완료되었다면 이를 반영해서 댓글의 수가 반영되는 화면을 구성

  🚀  우선 데이터를 가져오는 타입이 BoardDTO가 아닌 BoardListReplyCountDTO가 되었으므로 BoardService와 BoardServiceImpl에는 listWithReplyCount() 메서드를 추가하고 구현

 PageResponseDTO<BoardListReplyCountDTO> listWithReplyCount(PageRequestDTO pageRequestDTO);
@Override
public PageResponseDTO<BoardListReplyCountDTO> listWithReplyCount(PageRequestDTO pageRequestDTO) {
    String[] types = pageRequestDTO.getTypes();
    String keyword = pageRequestDTO.getKeyword();
    Pageable pageable = pageRequestDTO.getPageable("bno");

    Page<BoardListReplyCountDTO> result = boardRepository.searchWithReplyCount(types, keyword, pageable);

    return PageResponseDTO.<BoardListReplyCountDTO>withAll()
        .pageRequestDTO(pageRequestDTO)
        .dtoList(result.getContent())
        .total((int)result.getTotalElements())
        .build();
}

 

  🚀  BoardController에서는 호출하는 메서드를 변경. 기존에 BoardService의 list()를 호출하는 대신에 listWithReplyCount()를 호출하도록 수정

@GetMapping("/list")
public void list(PageRequestDTO pageRequestDTO, Model model) {
    //PageResponseDTO<BoardDTO> responseDTO = boardService.list(pageRequestDTO);
    PageResponseDTO<BoardListReplyCountDTO> responseDTO = boardService.listWithReplyCount(pageRequestDTO);
    log.info(responseDTO);

    model.addAttribute("responseDTO", responseDTO);
}

 

  🚀  마지막으로 화면을 처리하는 list.html에는 replyCount라는 속성을 출력하도록 수정

<tr th:each="dto, status:${responseDTO.dtoList}">
    <th scope="col">[[${no - status.index}]]</th>
    <td><a th:href="|@{/board/read(bno =${dto.bno})}&${link}|"> [[${dto.title}]] </a>
        <span class="badge progress-bar-success" style="background-color: #0a53be">[[${dto.replyCount}]]</span>
    </td>
    <td>[[${dto.writer}]]</td>
    <td>[[${#temporals.format(dto.regDate, 'yyyy-MM-dd')}]]</td>
</tr>

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]


1.  REST 방식의 댓글 처리 준비

📌  REST 방식의 댓글 처리는 다음과 같은 단계로 진행

  • URL 설계와 데이터 포맷 결정
  • 컨트롤러의 JSON / XML 처리
  • 동작 확인
  • 자바스크립트를 통한 화면 처리


1)  URL 설계와 DTO 설계


  ⚡️  REST 방식은 주로 XML이나 JSON 형태의 문자열을 전송하고 이를 컨트롤러에서 처리하는 방식을 이용
  ⚡️  JSON을 이용해서 DTO에 맞는 데이터를 전송하고 스프링을 이용해서 이를 DTO 처리하도록 구성

 

댓글의 URL 설계
Method URL 설명 반환 데이터
POST /replies 특정한 게시물의 댓글 추가 {'rno':11} - 생성된 댓글의 번호
GET /replies/list/:bno 특정 게시물(bno)의 댓글 목록 '?'뒤에 페이지 번호를 추가해서 댓글 페이징 처리 PageResponseDTO를 JSON으로 처리
PUT /replies/:rno 특정한 번호의 댓글 수정 {'rno':11} - 수정된 댓글의 번호
DELETE /replies/:rno 특정한 번호의 댓글 삭제 {'rno':11} - 삭제된 댓글의 번호
GET /replies/:rno 특정한 번호의 댓글 조회 댓글 객체를 JSON으로 변환한 문자열

 

프로젝트 dto 패키지에 ReplyDTO를 추가
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ReplyDTO {
    private Long rno; // 리플 고유번호
    private Long bno; // 게시판의 글 고유번호
    private String replyText; // 리플 내용
    private String replyWriter; // 리플 작성자
    private LocalDateTime regDate, modDate;
}

 

  ✓  ReplyDTO에는 고유한 rno외에도 특정한 게시물 번호 bno를 선언
  ✓  이를 통해서 현재 댓글이 특정한 게시물의 댓글임을 알 수 있음

 


 

2)  ReplyController 준비

 

  ⚡️  ReplyController는 기존과 달리 @RestController라는 어노테이션을 활용
  ⚡️ @RestController를 이용하게 되면 메서드의 모든 리턴 값은 JSP나 Thymeleaf로 전송되는 것이 아니라 바로 JSON이나 XML 등으로 처리
  ⚡️  본격적인 개발 전에 약간의 테스트를 진행 할 수 있도록 ReplyController에 POST 방식을 처리하는 메서드를 추가

@RestController
@RequestMapping("/api/replies")
@Log4j2
public class ReplyController {
    @Operation(summary = "Replies Post", description = "POST 방식으로 댓글 등록")
    @PostMapping(value = "/", consumes = MediaType.APPLICATION_JSON_VALUE)
    public Map<String, Long> register(@RequestBody ReplyDTO replyDTO) {
        log.info(replyDTO);
        Map<String, Long> map = new HashMap<>();
        map.put("rno", 111L);

        return map;
    }
}

 

  📍 register() 메서드의 파라미터에는 ReplyDTO를 이용해서 파라미터를 수집한다고 선언되어 있지만 앞에 @RequestBody라는 어노테이션이 존재. @RequestBody는 JSON 문자열을 ReplyDTO로 변환하기 위해 표시

  📍  @PostMapping에는 consumes 라는 속성을 사용. consumes는 해당 메서드를 받아서 소비(Consume)하는 데이터가 어떤 종류인지 명시할 수 있음. 이 경우 JSON 타입의 데이터를 처리하는 메서드임을 명시

  📍  Swagger UI에서는 필요한 JSON 데이터를 미리 구성해서 사용할 수 있도록 작성할 수 있음

  • Try it out 클릭 후 필요한 데이터만 입력해서 전송
  • register() 가 실행되고 리턴 값이 JSON으로 처리됨. @RestController는 리턴 값 자체가 JSON으로 처리
  • 서버에는 정상적으로 필요한 파라미터가 수집된 것을 확인

 

postman / Swagger 비교
  • boot 프로젝트의 경우 postman에 비해 Swagger가 작성한 애플리케이션의 특성에 맞게 테스트 할 수 있고, API 문서를 자동화 할 수 있는 장점이 존재
  • 프로젝트안에 Swagger가 들어가는 것을 기피하는 경우도 있음. 예를 들어 boot3 버전 부터는 Swagger 설정이 달라져서 프로젝트를 수정해야 함. 프로젝트와 독립된 방식으로 사용할 수 있는 postman을 선호하기도 함

 

3) @Valid와 @RestControllerAdvice

 

  ⚡️  REST 방식의 컨트롤러는 대부분 Ajax와 같이 눈에 보이지 않는 방식으로 서버를 호출하고 결과를 전송하므로 에러가 발생하면 어디에서 어떤 에러가 발생했는지 알아보기 힘든 경우가 많음 * Ajax 개발의 힘든 점 중 하나가 디버깅
  ⚡️ 이런 이유로 @Vaild 과정에서 문제가 발생하면 처리할 수 있도록 @RestControllerAdvice를 작성

 

controller 패키지에 advice 패키지를 추가하고 CustomRestAdvice 클래스를 추가
@Log4j2
@RestControllerAdvice
public class CustomRestAdvice {
    @ExceptionHandler(BindException.class)
    @ResponseStatus(HttpStatus.EXPECTATION_FAILED)
    public ResponseEntity<Map<String, String>> handleBindException(BindException e) {
        log.error(ex);

        Map<String, String> errorsMap = new HashMap<>();

        if (e.hasErrors()) {
            BindingResult bindingResult = e.getBindingResult();

            bindingResult.getFieldErrors().forEach(fieldError -> {
                errorsMap.put(fieldError.getField(), fieldError.getCode());
            });
        }
        return ResponseEntity.badRequest().body(errorsMap);
    }
}

 

  ✓  @RestControllerAdvice을 이용하면 컨트롤러에서 발생하는 예외에 대해 JSON과 같은 응답 메시지를 생성해서 보낼 수 있음
  ✓  작성한 handleBindException() 메서드는 컨트롤러에서 BindException이 던져지는 경우 이를 이용해서 JSON 메시지와 400에러 (Bad Request)를 전송

 


4)  댓글 등록 @Valid


  ⚡️  @RestControllerAdvice를 이용해서 ReplyDTO를 검증해서 register()를 처리하도록 변경

 

ReplyDTO에 검증과 관련된 어노테이션을 추가
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ReplyDTO {
    private Long rno; // 리플 고유번호

    @NotNull
    private Long bno; // 게시판의 글 고유번호
    
    @NotEmpty
    private String replyText; // 리플 내용

    @NotEmpty
    private String replyWriter; // 리플 작성자
    private LocalDateTime regDate, modDate;
}

 

실제 동작 여부를 확인하기 위해 ReplyController의 register()를 수정
public class ReplyController {

    @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<>();
            map.put("rno", 111L);

            return map;
    }
}

 

  📍 register()는 다음과 같은 점들이 수정

  • ReplyDTO를 수집할 때 @Valid를 적용
  • BindingResult를 파라미터로 추가하고 문제가 있을 때는 BindException을 throw하도록 수정
  • 메서드 선언부에 BindException을 throws하도록 수정
  • 메서드 리턴값에 문제가 있다면 @RestControllerAdvice가 처리할 것이므로 정상적인 결과만 리턴

 

프로젝트를 실행하고 ReplyDTO가 체크할 내용이 없는 데이터를 Swagger UI로 전송 테스트

 

  ⚡️  ReplyDTO는 bno나 replyText 등의 값이 반드시 있어야하므로 실행 결과는 다음과 같이 문제가 있는 부분만 에러 메시지를 전송받을 수 있음

 

 

✓  결과 메시지를 보면 @Valid 과정에 문제가 있는 필드들과 메시지를 JSON 문자열로 전송
✓Ajax를 이용하는 개발에는 에러의 발생 소지가 서버인 경우도 있고, 브라우저나 자바 스크립트일 때도 있기 때문에 서버에서 먼저 확실하게 문제가 없는 것을 확인하고 화면을 개발하는 것이 좋음

 

 

 

 

 

 

 

 

[ 내용 참고 : IT 학원 강의 ]

+ Recent posts