본 JPA 게시판 프로젝트는 단계별(step by step)로 진행됩니다.
이전 글에서는 비동기 방식으로 페이징과 검색 처리를 하는 방법에 대해 알아보았습니다.
이번 글에서는 페이지를 이동했을 때 또는 검색 조건이 포함되어 있는 상황에서
이전 페이지 정보를 유지하는 방법을 알아볼 건데요,
페이지 정보 유지가 필요한 케이스는 다음과 같습니다.
1. 게시글 상세 페이지에서 뒤로가기 버튼을 클릭했을 때
2. 게시글 수정 페이지에서 뒤로가기 버튼을 클릭했을 때
3. 기존에 등록된 게시글을 수정했을 때
4. 기존에 등록된 게시글을 삭제했을 때
5. 페이지 새로고침(Refresh)이 실행됐을 때
만약, 150페이지에 있는 게시글을 수정 또는 삭제했는데 1페이지로 리다이렉트 된다면...?
네, 사용자 입장에서는 환장 환장 대환장인 상황인 것이지요.
물론 저는 그런 말도 안 되는 실수를 해본 적이 단 한 번도 없지만요.
L I A R L I A R L I A R L I A R L I A R L I A R L I A R L I A R
네, 헛소리는 집어치우고, 이전 페이지 번호와 검색 조건 유지하기.
지금 바로 시작해 볼게요 :)
1. 비동기(Ajax) 처리의 문제점
이미 아시는 분들도 있겠지만, 제가 겪었었던 비동기 처리의 가장 큰 문제는 새로고침(Refresh)이었습니다.
페이징을 예시로 한번 들어보도록 하겠습니다.
예전에 많이 사용되던 폼 서브밋 방식은 전달받은 파라미터를 기준으로 페이지가 리로드(reload) 됩니다.
예를 들어, URI가 "/board/list"이고, 1페이지에서 7페이지로 이동한다면
"/board/list?page=1"에서 "/board/list?page=7"로 URI가 변경될 겁니다.
하지만, 비동기 처리를 지원해주는 jQuery Ajax나 Fetch API 등으로 데이터를 처리하는 경우에는
URL에 변동이 없기 때문에 페이지 정보를 유지하기가 굉장히 까다로웠는데요.
이러한 문제점을 JavaScript의 History API로 처리할 수 있습니다.
(History API에 대해 자세히 알고 싶으시다면, 링크를 참고해 주시면 됩니다!)
2. History.replaceState( )란?
게시글 리스트 페이지의 주소창(URL)을 보시면,
전체 URL이 Host("localhost:8080")와 Path("/board/list")로만 구성되어 있는 상태입니다.
(스크롤 압박 방지를 위해 recordPerPage를 잠시 5개로 변경했습니다!)
검색 조건을 포함하거나, 페이지를 이동해도 URI가 변경되지 않는 건 마찬가지입니다.
이럴 때 사용할 수 있는 게 History API의 replaceState( ) 함수인데요.
쉽게 말씀드리자면, 함수명 그대로 현재 history의 상태를 변경하는 것입니다.
해당 함수는 인자로 (stateObj, title, url) 총 세 개의 파라미터를 전달받습니다.
stateObj는 JS 객체로, 저장할 값이 없는 경우에는 null 또는 빈 리터럴 객체{ }를 전달해주면 되고,
title은 대부분의 브라우저에서 무시되기 때문에 빈 문자열(' ')을 전달해주면 되며,
마지막 파라미터인 url에는 변경할 URL 주소를 전달해주면 되는데요.
전달하는 URL은 쿼리 스트링(Query String) 파라미터를 제외한 모든 주소가 현재 URL과 동일해야 합니다.
즉, Host와 Path는 현재 페이지의 URL과 동일해야 한다는 의미인 것이지요 :)
3. replaceState( ) 함수로 URI 변경해보기
History API가 어떤 녀석인지 알아보았으니, 실제로 페이지에 적용해 보아야겠죠?
우선 list.html의 findAll( ) 함수를 다음과 같이 변경해 주세요.
params를 선언하는 코드와, getJson( ) 함수를 호출하는 코드 사이에 세 줄의 코드가 추가되었습니다.
< TIP. 여기에서 기존 코드와 새로운 코드의 텍스트를 비교해 보실 수 있어요! >
/**
* 게시글 리스트 조회
*/
function findAll(page) {
const form = document.getElementById('searchForm');
const params = {
page: page
, recordPerPage: 5
, pageSize: 10
, searchType: form.searchType.value
, keyword: form.keyword.value
}
const queryString = new URLSearchParams(params).toString();
const replaceUri = location.pathname + '?' + queryString;
history.replaceState({}, '', replaceUri);
getJson('/api/boards', params).then(response => {
if (!Object.keys(response).length) {
document.getElementById('list').innerHTML = '<td colspan="5">등록된 게시글이 없습니다.</td>';
drawPages();
return false;
}
let html = '';
let num = response.params.pagination.totalRecordCount - ((response.params.page - 1) * response.params.recordPerPage);
response.list.forEach((obj, idx) => {
html += `
<tr>
<td>${num--}</td>
<td class="text-left">
<a href="javascript: void(0);" onclick="goView(${obj.id})">${obj.title}</a>
</td>
<td>${obj.writer}</td>
<td>${moment(obj.createdDate).format('YYYY-MM-DD HH:mm:ss')}</td>
<td>${obj.hits}</td>
</tr>
`;
});
document.getElementById('list').innerHTML = html;
drawPages(response.params);
});
}
queryString
이전 글에서 간단히 설명드렸었던 new URLSearchParams( )를 기억하고 계시죠?!
해당 변수에는 params에 담긴 Key와 Value가 연결된 쿼리 스트링(Query String)이 담기게 됩니다.
replaceUri
replaceState( ) 함수를 이용해서 변경할 URI를 의미합니다.
location.pathname은 전체 URL 중 Path에 해당하는 "/board/list"를 리턴해주고,
location.pathname과 queryString을 연결해주면 다음과 같은 결괏값이 나오게 됩니다.
localhost:8080/board/list?page=15&recordPerPage=5&pageSize=10&searchType=&keyword=
history.replaceState( )
2번에서 말씀드렸던 replaceState 함수입니다.
전체 세 개의 인자 중 URL만 파라미터로 전달해주면,
페이지 번호를 클릭하거나 검색 버튼을 클릭했을 때,
주소창의 값이 변경되는 것을 확인하실 수 있습니다.
하지만, 여기에는 심각한 문제가 한 가지 있는데요.
페이지를 새로고침(Refresh) 했을 때 첫 페이지인 1페이지로 이동하게 되고,
검색 조건과 키워드마저 초기화되어버린다는 것입니다.
이유는 정말 간단합니다.
onload( ) 안에 선언된 함수는 페이지에 접근했을 때 무조건 실행되는 함수입니다.
즉, 새로고침이 실행되는 시점에 findAll( ) 함수가 실행되는데,
인자로 "1"을 전달하기 때문에 1페이지에 있는 데이터를 조회하게 되는 것입니다.
searchType과 keyword도 마찬가지입니다.
페이지가 새로고침 되면, 검색 유형과 키워드 또한 당연히 초기화가 되겠지요.
저는, 이전 페이지 정보 유지 기능을 연구할 때 이런 생각이 문득 스쳐 지나갔습니다.
"replaceState( )로 URL을 변경한 상태에서 페이지를 새로고침 했을 때,
onload( ) 안에서 쿼리 스트링(Query String)을 객체 형태로 전달받을 수 없을까?"
다음의 이미지는 50페이지에서 새로고침을 실행했을 때,
onload( )에서 호출하는 findAll(1) 함수를 디버깅하는 화면입니다.
함수의 파라미터인 페이지 번호(page)는 당연히 "1"이 들어오게 되는데요.
JavaScript의 location.search를 이용하면, 현재 URL에 포함된 쿼리 스트링을 리턴해 줍니다.
새로고침 시점에 쿼리 스트링을 콘솔에 출력해본 결과, 파라미터는 제대로 유지되는 상태입니다.
두 번째는 검색 조건과 키워드가 포함된 상태에서 쿼리 스트링을 출력해본 결과입니다.
마찬가지로 페이지 정보는 제대로 유지되는 상황이지요?
4. 이전 페이지 정보 유지용 함수 정의하기
앞에서 말씀드린 문제점에 대해서는 웬만큼은 이해하셨을 거라고 생각합니다 :)
이제, 새로고침(Refresh)을 실행했을 때, 이전 페이지 정보를 유지하는 방법을 알아보도록 할게요!
먼저, list.html의 JS 영역에 다음의 함수를 추가해 주세요.
/**
* 쿼리 스트링 파라미터 셋팅
*/
function setQueryStringParams() {
if ( !location.search ) {
return false;
}
const form = document.getElementById('searchForm');
new URLSearchParams(location.search).forEach((value, key) => {
if (form[key]) {
form[key].value = value;
}
});
}
다음으로 setQueryStringParams( ) 함수를 onload( ) 함수 안에 추가해 주세요.
반드시 findAll( ) 함수보다 우선 실행되어야 합니다!
/**
* 페이지 로딩 시점에 실행되는 함수
*/
window.onload = () => {
setQueryStringParams();
findAll(1);
addEnterSearchEvent();
}
setQueryStringParams( )
먼저, setQueryStringParams( )의 구조입니다.
106~108번 라인
쿼리 스트링이 없는, 즉 가장 처음 리스트 페이지로 접근한 경우를 의미합니다.
"localhost:8080/board/list"로 접근했을 때는 파라미터 세팅이 의미가 없기에 로직을 종료합니다.
form
검색 조건과 검색 키워드를 감싸고 있는 검색 폼입니다.
112~116번 라인
다음은 검색 조건을 "작성자"로 선택하고, "30번"이라는 키워드로 검색한 결과 페이지입니다.
두 번째로 setQueryStringParams( ) 함수를 디버깅하는 화면입니다.
1번 이미지와 동일한 상태에서 새로고침이 실행되면 setQueryStringParams( )가 실행되는데요.
new URLSearchParams( location.search )를 이용해서 쿼리 스트링을 객체화하면,
findAll( ) 함수의 params처럼 Key, Value 구조를 갖는 리터럴 객체로 변환됩니다.
해당 함수에서 핵심은 2번 이미지의 157~159번 라인입니다.
searchForm은 검색 조건(searchType)과 키워드(keyword)만 하위 엘리먼트로 가지고 있고,
page, recordPerPage, pageSize는 findAll( ) 함수에서 내부적으로 필요로 하는 파라미터입니다.
즉, 검색 조건이나 키워드와 같이 사용자에게 보여주지 않아도 되는 파라미터인 것이지요.
form[key]는 검색 폼 안에 있는 엘리먼트를 의미하는데요.
예를 들어 Key 값이 "page"라고 했을 때,
폼 안에는 "page"라는 id 또는 name을 가진 엘리먼트가 없기 때문에 value 값을 설정하지 않습니다.
반대로, Key 값이 "searchType" 또는 "keyword"인 경우에는 if 문 안의 로직을 실행하게 되며,
검색 조건(searchType)과 키워드(keyword)에, 새로고침 이전에 입력한 value 값이 세팅됩니다.
5. 새로고침 테스트해보기
다음은 "10"으로 시작하는 "작성자"를 기준으로 게시글을 검색한 결과인데요.
새로고침을 실행해보면 검색 조건과 키워드는 정상적으로 유지되는 것을 확인하실 수 있습니다.
이번엔 3페이지로 이동해 보았습니다. 새로고침을 실행하게 되면.....?
Holy... 1페이지로 돌아와 버렸습니다.
페이지가 유지되지 않는 문제 또한, 조금만 생각해보면 심플한 이유입니다.
계속해서 말씀드리듯이 findAll( ) 함수는 페이지 번호(page)를 파라미터로 전달받고,
새로고침이 실행됐을 때, 페이지 번호는 무조건 1을 전달받는 상황입니다.
즉, 페이지 번호도 검색조건, 키워드와 마찬가지로 값을 세팅해줄 필요가 있는 것이지요.
6. 페이지 번호 유지하기
페이지 번호 유지는 setQueryStringParams( )에서 검색 조건과 키워드를 유지하는 것과 유사합니다.
다만 몇 가지 조건이 붙게 되는데요, 코드를 작성한 후에 설명드리도록 할게요 :)
먼저, onload( ) 함수에서 호출하는 findAll(1) 함수의 인자인 "1"을 제거해 주세요.
/**
* 페이지 로딩 시점에 실행되는 함수
*/
window.onload = () => {
setQueryStringParams();
findAll();
addEnterSearchEvent();
}
다음으로 findAll( ) 함수의 form 변수 위에 네모 박스 안의 코드 두 줄을 추가해 주세요.
const pageParam = Number(new URLSearchParams(location.search).get('page'));
page = (page) ? page : ((pageParam) ? pageParam : 1);
pageParam
쿼리 스트링에 포함된 페이지 번호(page)를 의미합니다.
new URLSearchParams( )를 이용해서 location.search에 담긴 쿼리 스트링을 객체화한 후에,
"page" 값을 숫자 형태로 변환한 값입니다.
만약, onload( ) 함수의 findAll( )과 같이 빈 값을 전달받는 경우에는
Number( ) 함수에 의해 pageParam의 값은 "0"이 됩니다.
page = (page) ? page : ((pageParam) ? pageParam : 1)
해당 조건문에는 다중(중첩) 삼항 조건 연산자가 사용되는데요.
첫 번째, (page) ? page 조건은, 화면 하단의 페이지 번호를 클릭해서 페이지를 이동했거나,
검색 버튼을 클릭한 경우를 의미합니다.
이해가 쉽지 않으신 분들도 계실 수 있을 듯한데요.
화면 하단의 페이지 번호를 클릭하면 findAll( )의 파라미터로 선택한 페이지의 번호가 넘어올 테고,
검색 버튼의 findAll( )은 무조건 1페이지를 호출합니다.
즉, 페이지 번호를 클릭하거나, 검색 버튼을 클릭하는 경우에는 페이지 번호가 필수적으로 넘어오게 되고,
전달받은 페이지 번호(page)를 기준으로 조회된 게시글 데이터를 렌더링해 주기만 하면 되는 것입니다.
다음은, 첫 번째 조건이 false인 경우에 실행되는 ((pageParam) ? pageParam : 1) 조건입니다.
앞에서, 첫 번째 조건인 (page) ? page 조건이 true인 경우는 두 가지라고 말씀드렸습니다.
1. 화면 하단의 페이지 번호를 클릭했을 때 (페이지를 이동했을 때)
2. 검색 버튼을 클릭했을 때
그렇다면 정답은 이미 나왔지요?
앞의 두 가지 경우를 제외한 모든 경우에는 ((pageParam) ? pageParam : 1) 조건이 실행됩니다.
(pageParam) 조건이 true인 경우에는 쿼리 스트링에 포함된 페이지 번호를 page에 저장하고,
false인 경우에는 1을 page에 저장하게 되는데요.
( !pageParam ) 조건이 false인 경우, 즉 page에 1이 저장되는 경우를 우선적으로 생각해보면,
page에 1이 들어온다는 것은, 리스트 페이지로의 최초 접근을 의미합니다.
여기서 최초 접근이라 함은 "localhost:8080/board/list"의 호출을 의미합니다.
즉, 쿼리 스트링이 비어있는(' ') 상태의 리스트 페이지를 의미하는 것이지요.
이제 남은 조건은 단 한 가지, (pageParam) 조건이 true인 경우입니다.
1. 페이지에 최초로 접근했을 때
2. 화면 하단의 페이지 번호를 클릭했을 때 (페이지를 이동했을 때)
3. 검색 버튼을 클릭했을 때
앞의 세 가지를 제외한 모든 경우에는 (pageParam) 조건이 true가 되겠지요?
즉, 이전 페이지 정보를 유지해야 하는 상황에 해당 조건은 무조건 true가 됩니다.
대표적인 경우를 생각해보면 다음과 같습니다.
1. 페이지를 이동하고 새로고침을 실행했을 때
2. 검색 버튼을 클릭하고, 새로고침을 실행했을 때
3. 검색 버튼을 클릭하고, 페이지를 이동한 후에 새로고침을 실행했을 때
4. 게시글 상세 페이지 또는 수정 페이지에서 뒤로가기 버튼을 클릭했을 때
5. 게시글을 수정 또는 삭제했을 때
7. 2차 새로고침 테스트해보기
영상을 끝까지 시청하실 필요는 없고,
변경되는 페이지 번호와 주소창의 쿼리 스트링을 집중적으로 확인해 주세요 :)
다음의 네 가지 경우 모두 정상적으로 작동하는 모습을 보실 수 있습니다.
1. 페이지에 최초로 접근했을 때
2. 화면 하단의 페이지 번호를 클릭했을 때 (페이지를 이동했을 때)
3. 검색 버튼을 클릭했을 때
4. 페이지 번호 또는 검색 조건에 관계없이 페이지를 새로고침 했을 때
8. 게시글 상세 페이지로 이전 페이지 정보 전달하기
쿼리 스트링 파라미터가 정상적으로 유지되고 있는 것을 눈으로 확인해 보았습니다.
이제, 각 페이지로 리스트 페이지의 쿼리 스트링을 전달해 주기만 하면 됩니다.
먼저, findAll( ) 함수를 다음의 코드와 같이 변경해 주시면 되는데요.
response.list.forEach 부분만 변경해 주시면 됩니다.
끝이 보이기 시작했으니, 조금만 더 힘내 주세요 :)
/**
* 게시글 리스트 조회
*/
function findAll(page) {
const pageParam = Number(new URLSearchParams(location.search).get('page'));
page = (page) ? page : ((pageParam) ? pageParam : 1);
const form = document.getElementById('searchForm');
const params = {
page: page
, recordPerPage: 5
, pageSize: 10
, searchType: form.searchType.value
, keyword: form.keyword.value
}
const queryString = new URLSearchParams(params).toString();
const replaceUri = location.pathname + '?' + queryString;
history.replaceState({}, '', replaceUri);
getJson('/api/boards', params).then(response => {
if (!Object.keys(response).length) {
document.getElementById('list').innerHTML = '<td colspan="5">등록된 게시글이 없습니다.</td>';
drawPages();
return false;
}
let html = '';
let num = response.params.pagination.totalRecordCount - ((response.params.page - 1) * response.params.recordPerPage);
response.list.forEach((obj, idx) => {
const viewUri = `/board/view/${obj.id}` + '?' + queryString;
html += `
<tr>
<td>${num--}</td>
<td class="text-left"><a href="${viewUri}">${obj.title}</a></td>
<td>${obj.writer}</td>
<td>${moment(obj.createdDate).format('YYYY-MM-DD HH:mm:ss')}</td>
<td>${obj.hits}</td>
</tr>
`;
});
document.getElementById('list').innerHTML = html;
drawPages(response.params);
});
}
viewUri
다음의 이미지는 게시글 상세 페이지로 이동하는 goView( ) 함수입니다.
기존에는 리스트 페이지에서 게시글 제목을 클릭했을 때,
Path에 게시글 번호(id)를 포함시켜서 상세 페이지로 이동시켰는데요.
변경된 코드에서는 게시글 상세 페이지 URI와,
리스트 페이지의 쿼리 스트링을 함께 전달하는 형태로 변경되었습니다.
다음의 영상은, 검색 조건과 키워드가 포함된 상태로 게시글 상세 페이지로 이동한 결과입니다.
마찬가지로, 상세 페이지 상단의 주소 값을 눈여겨보아 주세요 :)
상세 페이지로 쿼리 스트링은 정상적으로 전달되지만,
상세 페이지 각 버튼의 클릭 이벤트에는 아무런 처리가 되어있지 않은 상황입니다.
뒤로가기와 삭제하기 버튼은 함수의 실행이 종료되는 시점에 리스트 1페이지로 이동하게 될 테고,
게시글 수정 페이지에는 쿼리 스트링은 무시된 채, 게시글 번호만 전달하게 될 겁니다.
9. 게시글 상세 페이지(view.html) 수정하기
먼저, 뒤로가기 버튼의 이벤트인 goList( ) 함수입니다.
/**
* 뒤로가기
*/
function goList() {
location.href = '/board/list' + location.search;
}
앞에서 말씀드렸듯이, location.search는 URL의 쿼리 스트링을 리턴해 줍니다.
즉, 해당 함수가 실행됐을 때 이동하는 페이지의 URI는 다음과 같습니다.
두 번째는 수정하기 버튼의 이벤트인 goWrite( ) 함수입니다.
/**
* 수정하기
*/
function goWrite() {
location.href = '/board/write' + location.search + `&id=[[ ${id} ]]`;
}
기존에는 게시글 수정 페이지의 파라미터로 게시글 번호(id)만 전달했지만,
리스트 페이지에서 상세 페이지로의 이동과 마찬가지로,
Path와 쿼리 스트링 파라미터를 연결해서 전달합니다.
마지막으로 삭제하기 버튼의 이벤트인 deleteBoard( ) 함수인입니다.
해당 함수는 API 호출이 완료된 시점, 즉 게시글이 삭제 처리된 후에 goList( ) 함수를 호출합니다.
네, deleteBoard( ) 함수는 손대지 않아도 되겠네요 :)
10. 게시글 상세 페이지 테스트해보기
다음의 영상은 게시글 상세 페이지의 뒤로가기, 삭제하기 버튼에 대한 테스팅 진행 영상입니다.
수정 페이지는 글쓰기 페이지(write.html)를 처리한 후에 다시 테스트해 보도록 할게요 :)
11. 게시글 수정 페이지(write.html) 수정하기
마지막으로 게시글 수정 페이지입니다.
먼저, 뒤로가기 버튼의 HTML을 다음과 같이 변경해 주세요.
<a href="javascript: void(0);" onclick="goList();" class="btn btn-default waves-effect waves-light">뒤로가기</a>
다음으로, 자바스크립트 영역에 goList( ) 함수를 선언해 주세요.
/**
* 뒤로가기
*/
function goList() {
const id = /*[[ ${id} ]]*/;
location.href = (id) ? '/board/list' + location.search : '/board/list';
}
해당 페이지의 goList( ) 함수는 view.html의 goList( ) 함수와는 조그마한 차이가 있습니다.
id는 파라미터로 전달받은 게시글 번호(PK)를 의미하는데요.
id가 없는 경우는 신규 게시글 등록을, id가 있는 경우는 기존 게시글의 수정을 뜻합니다.
즉, id가 있는 게시글 수정 페이지에서는 뒤로가기 버튼을 클릭했을 때 쿼리 스트링을 함께 전달하고,
신규 게시글 등록 페이지에서는 뒤로가기 버튼을 클릭했을 때 1페이지로 이동하게 됩니다.
두 번째는 게시글의 저장을 처리하는 save( ) 함수입니다.
상세 페이지의 삭제하기 이벤트와 마찬가지로,
API 호출이 완료된 시점에 goList( ) 함수를 호출하는 구조로 변경되었습니다.
12. 게시글 수정 페이지 테스트해보기
다음의 영상은 게시글 수정 페이지에서 뒤로가기, 수정하기 버튼에 대한 테스팅 영상입니다.
드디어 마지막 영상이네요 :)
마무리
여기까지, 비동기(Ajax) 방식의 페이징과 검색 처리,
그리고 페이지 정보 유지 기능의 구현이 모두 완료되었습니다 :)
솔직하게 말씀드리자면, 이번 글은 업로드하기 조금 두려웠습니다... (으어어어어어 ㅠㅠㅠ)
여러분이 최대한 쉽게 이해하시기를 바라며 글을 써 내려가긴 했지만,
지금까지 구현해 온 기능들에 비해, 나름대로 난이도가 있는(?) 기능이라는 생각이 들기도 하고,
혹시라도 잘못된 정보를 공유해 드리는 건 아닐까 싶기도 하거든요...
만약, 올바르지 않은 로직이 있거나 좀 더 효율적인 로직이 있다면 꼭 피드백 부탁드립니다!
마지막으로, 다음 글부터는 회원과 관련된 기능을 다루어 보려고 하는데요.
진행 절차는 다음과 같습니다.
1. 회원가입 기능 구현
2. 인터셉터(Interceptor)를 이용한 로그인 및 권한 처리
3. 아이디/비밀번호 찾기 기능 구현
오늘도 방문해 주신 여러분께 진심으로 감사드립니다.
새해 복 많이 많이 받으시고, 모두 다 같이 행복한 새해를 보내보아요!
그럼, 다음 글에서 뵙도록 하겠습니다 :)
진행에 어려움을 겪으시는 분들이 계실 수 있으니, 프로젝트를 첨부해 드리도록 하겠습니다.
application.properties의 데이터베이스 정보만 내 PC 환경과 일치하도록 변경해서 사용해 주세요 :)
댓글