필요성 자바스크립트로 페이징 처리를해야 할 경우가 가끔있다. 여러가지 라이브러리들이 있지만 맘에 드는 것이 없어서(사실 별로 찾아보진 않았음 ^^) 간단하게 재활용할 수 있는 유틸 코드를 직접 만들어보려고한다.
TDD 막상 만드려고 생각하니 어디서부터 시작해야할 지 막막했다. 이럴땐 TDD도 하나의 좋은 방법이다. 개인적으로 Java를 주로 사용하고 Javascript에서는 복잡한 로직을 다룰 일이 별로 없어서 Javascript로는 TDD를 해본적이 없다. 연습도해볼 겸 그냥 시도해 보았다.
완성된 코드 는 맨 아래에 있지만 TDD 연습을 하고 싶은 분은 처음부터 따라해보는 걸 추천한다.
먼저 테스트를 검증하기 위한 코드를 작성한다. 익숙한 JUnit과 비슷하게 간단히 만들었다
1 2 3 4 5 6 7 8 9 10 const assertNotNull = actual => { if (actual !== null ) console .log('===== 성공 =====' ); else console .log(`===== 실패 =====\n actual is null` ); } const assertEquals = (expected, actual ) => { if (expected === actual) console .log('===== 성공 =====' ); else console .log(`===== 실패 =====\n expected [${expected} ], but [${actual} ]` ); } const assertTrue = actual => assertEquals(true , actual);const assertFalse = actual => assertEquals(false , actual);
시작
첫번째 테스트는 페이지 클래스 인스턴스를 생성하는 것이다. 생성만되면 성공하는 간단한 테스트다
1 2 3 4 (페이지클래스_만들기 =_ => { const pagination = new Pagination(); assertNotNull(pagination); })();
실행하면 ‘Pagination is not defined’ 라는 메시지의 에러가 발생한다. 에러를 잡기위해 클래스를 만들어주자.
1 2 const Pagination = class { }
다시 실행하면 테스트가 성공한다! 별거 아니지만 왠지 기분이 좋다
성공!!
전체 레코드 수
화면에 표시할 페이지 사이즈
하나의 페이지에 표시할 레코드(목록) 사이즈
위 값들을 생성자에서 초기화 해주면 총 페이지 사이즈가 계산되어야한다.
1 2 3 4 (총페이지수_계산 =_ => { const pagination = new Pagination(101 , 10 , 10 ); assertEquals(11 , pagination.totalPageSize); })();
총갯수 101개, 페이지크기 10, 레코드크기가 10일 때 총 페이지 갯수는 11개가 나와야한다.
실행하면 실패가 뜨면서 ‘expected [11], but [undefined]’ 메시지가 출력된다.
구현을 안했기 때문에 당연한 결과다. 이제 구현을해보자.
1 2 3 4 5 6 7 8 const Pagination = class { constructor (totalCount, pageSize, recordSize ) { this .pageSize = pageSize; this .recordSize = recordSize; this .totalCount = totalCount; this .totalPageSize = Math .ceil(totalCount / pageSize); } }
생성자에서 totalPageSize를 바로 계산해주었다. 다시 테스트해보면 성공한다.
테스트 케이스를 좀 더 추가해보자
1 2 3 4 5 6 7 8 9 10 (총페이지수_계산 =_ => { const p1 = new Pagination(101 , 10 , 10 ); assertEquals(11 , p1.totalPageSize); const p2 = new Pagination(100 , 10 , 10 ); assertEquals(10 , p2.totalPageSize); const p3 = new Pagination(101 , 10 , 5 ); assertEquals(21 , p3.totalPageSize); })();
마지막 검증에서 에러가난다. ‘expected [21], but [11]’
레코드 수가 5이면 페이지가 21개가 나와야 정상인데 왜 실패하는 걸까? 다시 생성자 코드를 자세히 보다보니 실수가 있었다.
(총갯수 / 레코드수) 를 해야하는데 페이지수로 나눈것이다. pageSize를 recordSize로 변경해준다.
this.totalPageSize = Math.ceil(totalCount / pageSize); //변경 전
this.totalPageSize = Math.ceil(totalCount / recordSize); //변경 후
수정 후 다시 테스트하면 성공한다. TDD로 개발을하면 이런 식으로 버그를 빠르게 발견할 수 있는 장점이있다. 위의 테스트 케이스만으론 충분하지 않지만 빠른 진행을위해 다음 테스트로 넘어가기로한다.
페이지 링크를 클릭했을 때 계산되어야 할 값들은 다음과 같다.
시작 페이지 번호
마지막 페이지 번호
이전 페이지 여부
다음 페이지 여부
먼저 시작페이지부터 하나씩 테스트 해보자
시작페이지 테스트1 2 3 4 5 6 7 (시작페이지_계산 =_ => { const p = new Pagination(101 , 10 , 10 ); assertEquals(1 , p.calcFirstPageNo(1 )); assertEquals(1 , p.calcFirstPageNo(10 )); assertEquals(2 , p.calcFirstPageNo(11 )); })();
마찬가지로 에러를 잡기위해 calcFirstPageNo 메소드를 구현한다.1 2 3 calcFirstPageNo (pageNo ) { return (Math .ceil(pageNo / this .pageSize) - 1 ) * this .pageSize + 1 ; }
마지막 페이지 테스트1 2 3 4 5 6 7 (마지막페이지_계산 =_ => { const p1 = new Pagination(101 , 10 , 10 ); assertEquals(1 , p1.calcLastPageNo(1 )); assertEquals(1 , p1.calcFirstPageNo(10 )); assertEquals(2 , p1.calcFirstPageNo(11 )); })();
1 2 3 calcLastPageNo (pageNo ) { return Math .min((this .calcFirstPageNo(pageNo) + this .pageSize -1 ), this .totalPageSize); }
이전 페이지 여부 테스트1 2 3 4 5 6 7 (이전페이지여부_계산 =_ => { const p = new Pagination(100 , 5 , 10 ); assertFalse(p.calcHasPrev(1 )); assertFalse(p.calcHasPrev(5 )); assertTrue(p.calcHasPrev(6 )); })();
1 2 3 calcHasPrev (pageNo ) { return Math .ceil(pageNo / this .pageSize) > 1 ; }
다음 페이지 여부 테스트1 2 3 4 5 6 7 8 9 (다음페이지여부_계산 =_ => { const p = new Pagination(101 , 5 , 10 ); assertTrue(p.calcHasNext(1 )); assertTrue(p.calcHasNext(5 )); assertTrue(p.calcHasNext(6 )); assertTrue(p.calcHasNext(10 )); assertFalse(p.calcHasNext(11 )); })();
1 2 3 calcHasNext (pageNo ) { return this .totalPageSize > this .calcLastPageNo(pageNo); }
여기까지 성공했으면 페이징을 위한 기본적인 로직은 끝이다. 위 메소드들을 한번에 계산하여 pagination 필드에 가지고 있도록 setUp(currentPageNo) 메소드를 만든다. 먼저 테스트코드를 만들어야하지만 귀찮으니까 건너뛰고 바로 구현했다 ㅋㅋ 꼭 먼저 테스트코드를 작성할 필요는 없다.1 2 3 4 5 6 setUp (currentPageNo ) { this .firstPageNo = this .calcFirstPageNo(currentPageNo); this .lastPageNo = this .calcLastPageNo(currentPageNo); this .hasPrev = this .calcHasPrev(currentPageNo); this .hasNext = this .calcHasNext(currentPageNo); }
이제 계산된 값들로 페이지 HTML을 만들어주면 된다. html(currentPageNo) 메소드를 호출하면 완성된 페이징 HTML을 리턴하게 만들어보자
작성된 코드는 다음과 같다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 html (currentPageNo ) { if (this .totalCount < 1 ) return '' ; this .setUp(currentPageNo); const html = []; if (this .hasPrev) { const prevPageNo = this .firstPageNo - 1 ; html.push(`<a href="?pageIndex=${prevPageNo} " onclick="goPage(${prevPageNo} );return false;" class="back" title="이전페이지로 이동"><</a>\n` ); } for (let i=this .firstPageNo; i<=this .lastPageNo; i++) { if (i === currentPageNo) { html.push(`<span>${i} </span>\n` ); } else { html.push(`<a href="?pageIndex=${i} " onclick="goPage(${i} );return false;">${i} </a>\n` ); } } if (this .hasNext) { const lastPageNo = this .lastPageNo + 1 ; html.push(`<a href="?pageIndex=${lastPageNo} " onclick="goPage(${lastPageNo} );return false;" class="forward" title="다음페이지로 이동">></a>\n` ); } return html.join('' ); }
여기서부터는 UI 이기 때문에 테스트코드를 만들지 않고 화면에 뿌려서 직접 눈으로 확인했다.
1 2 3 4 5 6 const pagination = new Pagination(101 , 10 , 10 );goPage(1 ); function goPage (pageNo ) { document .body.innerHTML = pagination.html(pageNo); }
브라우저에서 확인하면 다음과 같이 나온다.
버튼을 클릭해보면 잘 작동한다. 하지만 한가지 마음에 들지 않는다. html() 메소드를 보면 html과 로직이 섞여있고, html이 변경될 때마다 Pagination 클래스를 변경해야한다. 단일책임 원칙이 지켜지지 않았기 때문이다.
이러면 재사용하기도 어렵다. 로직과 html 템플릿을 분리해보자.
먼저 html 태그를 정의할 PaginationTemplate 클래스를 만든다.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 const PaginationTemplate = class { constructor ( ) { this .prevTag = '<a href="?pageIndex={pageNo}" onclick="goPage({pageNo});return false;" title="이전페이지로 이동"><</a>\n' ; this .currentPageTag = '<span>{pageNo}</span>\n' ; this .otherPageTag = '<a href="?pageIndex={pageNo}" onclick="goPage({pageNo});return false;">{pageNo}</a>\n' ; this .nextTag = '<a href="?pageIndex={pageNo}" onclick="goPage({pageNo});return false;" title="다음페이지로 이동">></a>\n' ; } prev (prevTag ) { this .prevTag = prevTag; } current (currentPageTag ) { this .currentPageTag = currentPageTag; } other (otherPageTag ) { this .otherPageTag = otherPageTag; } next (nextTag ) { this .nextTag = nextTag; } }
이전, 현재 번호, 다른 번호들, 다음 태그들을 정의하고
{pageNo} 부분을 현재 번호로 replace 할 것이다.
Pagination.html() 메소드를 수정하고 setTemplate() 수정자도 만들어주자
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 constructor (totalCount, pageSize=10 , recordSize=10 ) { this ._validate(totalCount, pageSize, recordSize); this .pageSize = pageSize; this .recordSize = recordSize; this .totalCount = totalCount; this .totalPageSize = Math .ceil(totalCount / recordSize); this .template = new PaginationTemplate(); } html (currentPageNo ) { if (typeof currentPageNo !== 'number' || currentPageNo < 1 || this .totalCount < 1 ) return '' ; this .setUp(currentPageNo); const html = []; const {prevTag, currentPageTag, otherPageTag, nextTag} = this .template; if (this .hasPrev) { const prevPageNo = this .firstPageNo - 1 ; html.push(this ._replace(prevTag, prevPageNo)); } for (let i=this .firstPageNo; i<=this .lastPageNo; i++) { if (i === currentPageNo) { html.push(this ._replace(currentPageTag, i)); } else { html.push(this ._replace(otherPageTag, i)); } } if (this .hasNext) { const lastPageNo = this .lastPageNo + 1 ; html.push(this ._replace(nextTag, lastPageNo)); } return html.join('' ); } _replace (originalFormat, replaceValue ) { return originalFormat.replaceAll(/\{pageNo\}/g , replaceValue); } setTemplate (template ) { if (!(template instanceof PaginationTemplate)) throw new TypeError ('only PaginationTemplate Type is available' ); this .template = template; }
사용코드는 다음과 같다.
1 2 3 4 5 6 7 8 const pagination = new Pagination(101 , 10 , 10 );const template = new PaginationTemplate();template.prev('<a href="?pageIndex={pageNo}" onclick="goPage({pageNo});return false;" class="back" title="이전페이지로 이동"><</a>\n' ); template.current('<span>{pageNo}</span>\n' ); template.other('<a href="?pageIndex={pageNo}" onclick="goPage({pageNo});return false;">{pageNo}</a>\n' ); template.next('<a href="?pageIndex={pageNo}" onclick="goPage({pageNo});return false;" class="forward" title="다음페이지로 이동">></a>\n' ); pagination.setTemplate(template); goPage(1 );
이렇게하면 client에서 PaginationTemplate을 Pagination에 주입시켜 재사용이 가능하다.
유효성 검증이나 테스트케이스 추가 등 해야할 것이 더 있지만 이정도에서 마무리한다.
최종 코드 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 <!DOCTYPE html > <html lang ="ko" > <body > <script > const Pagination = class { constructor (totalCount, pageSize=10 , recordSize=10 ) { this ._validate(totalCount, pageSize, recordSize); this .pageSize = pageSize; this .recordSize = recordSize; this .totalCount = totalCount; this .totalPageSize = Math .ceil(totalCount / recordSize); this .template = new PaginationTemplate(); } _validate (totalCount, pageSize, recordSize ) { if (typeof totalCount !== 'number' || totalCount < 0 ) { throw `totalCount must positive number: [${totalCount} ]` ; } if (typeof pageSize !== 'number' || pageSize < 0 ) { throw `pageSize must positive number: [${pageSize} ]` ; } if (typeof recordSize !== 'number' || recordSize < 0 ) { throw `recordSize must positive number: [${recordSize} ]` ; } } html (currentPageNo ) { if (typeof currentPageNo !== 'number' || currentPageNo < 1 || this .totalCount < 1 ) return '' ; this .setUp(currentPageNo); const html = []; const {prevTag, currentPageTag, otherPageTag, nextTag} = this .template; if (this .hasPrev) { const prevPageNo = this .firstPageNo - 1 ; html.push(this ._replace(prevTag, prevPageNo)); } for (let i=this .firstPageNo; i<=this .lastPageNo; i++) { if (i === currentPageNo) { html.push(this ._replace(currentPageTag, i)); } else { html.push(this ._replace(otherPageTag, i)); } } if (this .hasNext) { const lastPageNo = this .lastPageNo + 1 ; html.push(this ._replace(nextTag, lastPageNo)); } return html.join('' ); } setUp (currentPageNo ) { this .firstPageNo = this .calcFirstPageNo(currentPageNo); this .lastPageNo = this .calcLastPageNo(currentPageNo); this .hasPrev = this .calcHasPrev(currentPageNo); this .hasNext = this .calcHasNext(currentPageNo); } calcFirstPageNo (pageNo ) { return (Math .ceil(pageNo / this .pageSize) - 1 ) * this .pageSize + 1 ; } calcLastPageNo (pageNo ) { return Math .min((this .calcFirstPageNo(pageNo) + this .pageSize -1 ), this .totalPageSize); } calcHasPrev (pageNo ) { return pageNo > this .pageSize; } calcHasNext (pageNo ) { return this .totalPageSize > this .calcLastPageNo(pageNo); } setTemplate (template ) { if (!(template instanceof PaginationTemplate)) throw new TypeError ('only PaginationTemplate Type is available' ); this .template = template; } _replace (originalFormat, replaceValue ) { return originalFormat.replaceAll(/\{pageNo\}/g , replaceValue); } } const PaginationTemplate = class { constructor ( ) { this.prevTag = '<a href ="?pageIndex={pageNo}" onclick ="goPage({pageNo});return false;" title ="이전페이지로 이동" > < </a > \n'; this .currentPageTag = '<span>{pageNo}</span>\n' ; this.otherPageTag = '<a href ="?pageIndex={pageNo}" onclick ="goPage({pageNo});return false;" > {pageNo}</a > \n'; this.nextTag = '<a href ="?pageIndex={pageNo}" onclick ="goPage({pageNo});return false;" title ="다음페이지로 이동" > > </a > \n'; } prev (prevTag ) { this .prevTag = prevTag; } current (currentPageTag ) { this .currentPageTag = currentPageTag; } other (otherPageTag ) { this .otherPageTag = otherPageTag; } next (nextTag ) { this .nextTag = nextTag; } } const pagination = new Pagination(101 );// template.prev('<a href ="?pageIndex={pageNo}" onclick ="goPage({pageNo});return false;" class ="back" title ="이전페이지로 이동" > < </a > \n'); // template.current('<span > {pageNo}</span > \n'); // template.other('<a href ="?pageIndex={pageNo}" onclick ="goPage({pageNo});return false;" > {pageNo}</a > \n'); // template.next('<a href ="?pageIndex={pageNo}" onclick ="goPage({pageNo});return false;" class ="forward" title ="다음페이지로 이동" > > </a > \n'); goPage(1); function goPage (pageNo ) { document .body.innerHTML = pagination.html(pageNo); } /************** 테스트 ****************/ const assertNotNull = actual => { if (actual !== null ) console .log('===== 성공 =====' ); else console .log(`===== 실패 =====\n actual is null` ); } const assertEquals = (expected, actual ) => { if (expected === actual) console .log('===== 성공 =====' ); else console .log(`===== 실패 =====\n expected [${expected} ], but [${actual} ]` ); } const assertTrue = actual => assertEquals(true , actual);const assertFalse = actual => assertEquals(false , actual);(페이지클래스_만들기 =_ => { const pagination = new Pagination(1 , 2 , 3 ); assertNotNull(pagination); })(); (총페이지수_계산 =_ => { const p1 = new Pagination(101 , 10 , 10 ); assertEquals(11 , p1.totalPageSize); const p2 = new Pagination(100 , 10 , 10 ); assertEquals(10 , p2.totalPageSize); const p3 = new Pagination(101 , 10 , 5 ); assertEquals(21 , p3.totalPageSize); })(); (시작페이지_계산 =_ => { const p = new Pagination(101 , 10 , 10 ); assertEquals(1 , p.calcFirstPageNo(1)); assertEquals(1 , p.calcFirstPageNo(10)); assertEquals(11 , p.calcFirstPageNo(11)); })(); (마지막페이지_계산 =_ => { const p = new Pagination(201 , 10 , 10 ); assertEquals(10 , p.calcLastPageNo(1)); assertEquals(10 , p.calcLastPageNo(10)); assertEquals(20 , p.calcLastPageNo(11)); assertEquals(20 , p.calcLastPageNo(20)); assertEquals(21 , p.calcLastPageNo(21)); assertEquals(21 , p.calcLastPageNo(30)); })(); (이전페이지여부_계산 =_ => { const p = new Pagination(100 , 5 , 10 ); assertFalse(p.calcHasPrev(1)); assertFalse(p.calcHasPrev(5)); assertTrue(p.calcHasPrev(6)); })(); (다음페이지여부_계산 =_ => { const p = new Pagination(101 , 5 , 10 ); assertTrue(p.calcHasNext(1)); assertTrue(p.calcHasNext(5)); assertTrue(p.calcHasNext(6)); assertTrue(p.calcHasNext(10)); assertFalse(p.calcHasNext(11)); })(); </script > </body > </html >