TDD로 구현해보는 Javascript Paging

필요성

자바스크립트로 페이징 처리를해야 할 경우가 가끔있다. 여러가지 라이브러리들이 있지만 맘에 드는 것이 없어서(사실 별로 찾아보진 않았음 ^^) 간단하게 재활용할 수 있는 유틸 코드를 직접 만들어보려고한다.

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. 하나의 페이지에 표시할 레코드(목록) 사이즈

위 값들을 생성자에서 초기화 해주면 총 페이지 사이즈가 계산되어야한다.

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. 다음 페이지 여부

먼저 시작페이지부터 하나씩 테스트 해보자

  • 시작페이지 테스트
    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="이전페이지로 이동">&lt;</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="다음페이지로 이동">&gt;</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);
}

브라우저에서 확인하면 다음과 같이 나온다.

화면1

화면2

버튼을 클릭해보면 잘 작동한다.
하지만 한가지 마음에 들지 않는다. 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="이전페이지로 이동">&lt;</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="다음페이지로 이동">&gt;</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="이전페이지로 이동">&lt;</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="다음페이지로 이동">&gt;</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="이전페이지로 이동">&lt;</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="다음페이지로 이동">&gt;</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);
// 템플릿 커스텀
// const template = new PaginationTemplate();
// template.prev('<a href="?pageIndex={pageNo}" onclick="goPage({pageNo});return false;" class="back" title="이전페이지로 이동">&lt;</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="다음페이지로 이동">&gt;</a>\n');
// pagination.setTemplate(template);
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>