일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
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 |
- JDBC
- 파이썬
- design-pattern
- Spring
- Eclipse
- Collections
- 프로그래머스
- DesignPattern
- tcp
- 디자인패턴
- exception
- 자바
- Python
- Java
- 로버트마틴
- lambda calculus
- Collection
- 큐
- solid
- functional programming
- 람다 칼큘러스
- Rails
- Network
- javscript
- JavaScript
- 겨울카카오인턴
- Pattern
- 백준
- 스택
- 함수형 프로그래밍
- Today
- Total
개발자 노트
dataTable 구현 본문
데이터 테이블을 만들어 보자
현업에서 서버개발 뿐만 아니라 종종 관리자 페이지도 개발합니다. front를 잘 모르더라도 개발할 수 있도록 jquery를 사용하지요. 그런데 성능을 개선해야하는 일이 생겼고... 이를 개선하는과정을 말씀드리겠습니다.
선 결과물: https://jsfiddle.net/who3fa7t/2/
문제
약 5000 row * 13 column을 페이징없이 한 화면에 출력해야 했습니다.
기존에 사용하고 있는 dataTable로는 속도가 느리더군요... 약 10초정도 걸렸습니다. 해당 라이브러리의 document를 3일동안 뒤져봤지만, 어떻게 개선할 방법을 못찾겠더라구요. 전체 데이터를 정렬할 때마다 10초씩 걸렸습니다. 기존에 엑셀을 사용하여 데이터를 처리하시던 분들이라 얼마나 답답하실까 싶더라구요.
혹시나 싶어서 HTML을 그대로 그려봤더니 3초로 개선되더라구요? 그래서 직접 구현해보기로 마음먹었습니다.
(지금 생각해보면, dataTable로도 좀더 간단하게 구현 가능한 방법이 있긴한데... 그때 당시에는 떠오르지 않았네요 ㅎㅎ;)
요구사항
요구사항은 다음과 같았습니다.
관리자측 요구사항
- 화면에 모든 데이터를 출력
- 정렬
- 검색(필터)
- 추가, 삭제, 업데이트...
나의 욕심
- 데이터 출력을 3초 이내로 줄이자.
- 유지보수 용이
유지보수
2번 유지보수는 2가지 측면에서 접근해야 했습니다.
1. 다른 개발자 분들이 봐도 이해하기 쉽게
아무래도 기존 방식대로 dataTable을 이용하는 것이 아닌, 제가 직접 개발하므로 다른 분들이 봤을 때 이해하기 어려울 수 있습니다. 따라서 최대한 이해하기 쉬운 구조로 만들고 만든 구조를 팀원들에게 공유하도록 하였습니다.
2. 상태 관리
이 부분이 가장 어려운 과제였습니다. 이전에 상태를 4~5개정도 관리해야하는 페이지를 개발한 적 있습니다. 간단하게 생각했는데, 상태 지옥에 빠졌습니다.
A의 상태를 변경시켰는데
- A의 렌더링은 그대로이거나 전의 상태를 렌더링하거나...
- A상태도 변하고 B의 상태도 변경되어 B가 렌더링되거나...
하는 지옥에 걸렸습니다. 원인은 다음과 같이 파악했습니다.
- view단에 상태를 기록하고 있다.
- 내부적으로도(변수선언) 상태를 기록하고 있다. (javascript실행시 memory에서...)
- 내부적으로 선언된 상태를 2가지 형태로 기록하고 있다. 성능 개선을 위해 하나는 리스트로, 하나는 해쉬(literal object)로 관리하고 있다. 두가지를 일치시켜야하는 문제 발생
- A상태 변경이라 함수명을 작성해놓고 B상태도 변경하고 있다.
- A상태 변경을 이곳저곳 함수에서 시도하고 있다.
- rendering 로직이 상태 변경 중에 작성되어 있다.
라는 것이였습니다. 와... 정말 죽겠더군요. 간단한거 하나 수정하는데 1시간이 걸리고, 버그가 발생하면 원인을 찾는데 도대체 어디서 변경되는 것인지... 파악하기도 힘들었습니다. 따라서 이를 해결하기 위해 대부분의 시간을 쏟았다 보시면 됩니다.
상태관리
이를 해결하기 위한 아이디어는 2가지가 있습니다.
1. 객체지향 패러다임
상태 A는 상태 A와 관련된 객체만 변경할 수 있도록 하면 어떨까요? 예를 들어서 User라는 클래스가 있고, phoneNumber상태가 있다고 봅시다.
class User {
private String phoneNumber;
}
이 phoneNumber는 user객체를 통해서만 수정할 수 있도록 한다면 좀 더 다음과 같은 상황을 통제할 수 있습니다.
- 어디서 phoneNumber가 수정됬는지,
- phoneNumber를 어떤 타입으로 외부에 전달해줄지
- 이것은 내부적으로는 '010-1234-5678'이라고 저장되어 있지만 다른 곳에서는 '-'를 없애고 전달하고 싶다면 public method로 phoneNumber를 전달시 '01012345678'로 반환하도록 하면 되죠. 해당 메서드를 call하고 있는 client들에게 일관성있게 값을 전달해줄 수 있습니다.
- 관심사 분리
- 만약에 phoneNumber를 수정해야할 일이 있다면 User클래스만 수정하면 되겠지요? decoupling이 잘 되어 있다면요.
따라서 전 객체지향을 적용하기로 마음먹었습니다.
Immutable
한편으로는 immutable한 상태는 strict하게 적용하진 않았습니다. 그 이유는 다음과 같죠.
- 프로그램 실행시 하나의 객체씩 존재한다.
- 객체 X의 상태가 변경됬다면, 별 다른 조작을 하지 않아도 참조하고 있는 다른 객체들도 변경된 X의 상태를 참조할 수 있어야 한다.
- 게임으로 예를 들면 player A, player B가 map을 참조하고 있다면, map의 한 부분이 변경됬다면 player A, player B도 map 객체를 새로 받지 않고도 알고 있어야 하죠.
- 나만이 통제하는 패키지이므로
- 이펙티브 자바에서 나온 말로 설명하자면, 악의적인 프로그래머가 상태 변경을 시도할 것도 아닌데, immutable하게 할 필요가 없다는 것이죠. 상태 변경들은 제가 잘~ 관리해줄것이구요.
다시 말해서, 제가 기대하는 대로 상태가 수정되었으리라 생각할 수 있으니까요. 하지만, immutable하게 해줘야하는 부분이 하나 있긴 합니다. 바로 아래에서 나올 내용이지만 간단히 설명드리겠습니다. requester라는 객체가 있고 requester로부터 얻은 DB의 데이터를 tbody와 cacheDB가 받게 됩니다. 이때, tbody와 cacheDB는 자신의 상태를 변경할 수 있는 메서드를 public으로 두는데, requester로 부터 받은 객체의 참조를 동일하게 가지면 cacheDB, tbody 서로의 상태변경이 각자에게 영향을 미치게 됩니다. 독립적인 것이 제 의도인데 불구하고 말이죠. 따라서 이 경우에 대해서는 requster의 데이터를 deep copy하여 Tbody, cacheDB에 넘겨줌으로써 의도치 않은 상태 변경을 피하였습니다.
2. 아키텍쳐
웹 화면을 개발하면서 떠오른게 바로 게임입니다. 화면에서 뭘 클릭하면 뭐가 변경되서 저렇게 화면을 그려주잖아요? 게임과 완전 똑같지 않나요? 게임도 어떤 액션을 주면 -> 어떤 변경사항을 화면에 그려주잖아요.
게임에서 관리해야할 상태가 엄청 많잖아요? 어떻게 구현하는가 궁금해지더라구요.
스타크래프트를 예를 들어보겠습니다.
프로버 생산 하나 눌렀다고 해봅시다.
- 인구 수를 1개 늘려야 합니다.
- 미네랄을 50차감시켜 주어야 합니다.
- 넥서스에서 프로버를 생성 중이라고 표시해주어야 합니다.
- 넥서스에서 프로버 생성까지 얼마나 완성되었는지 표시해주어야 합니다.
엄청나죠? 이를 단순히 절차지향적으로 작성했다면 상태 관리지옥에 빠져들것입니다... 그래서 게임에서 사용하는 아키텍쳐를 참고하기로 했습니다.
게임의 기본 프로세스
조사결과 다음 프로세스를 기본으로 동작하더라구요.
- input을 받는다.
- input을 특정 action과 연결 짓는다.
- action과 관련된 상태를 업데이트한다.
- 업데이트된 상태를 렌더링한다.
- 1 ~ 4를 반복한다.
헉~ 전 이과정을 다음처럼 이해했어요.
" rendering은 반드시 상태를 기반으로 한다! 그 상태는 action이 변경해주고! "
이렇게 생각하니 모든게 단순해지더라구요. 이런 관점에서 이전에 만들었던 문제점을 진단할 수 있었습니다.
- 액션 -> 상태 변경 -> 렌더링 순서를 고집하라! 그래야 변경된 상태를 렌더링한다.
- 상태(데이터)만을 가지고 그대로 랜더링하라! 이로써 상태 변경과 렌더링간 decoupling이 가능합니다.
마치 플라톤의 이데아론 같죠?
데이터는 이데아고, 그 형상은 여러 가지로 보여질 수 있으니까요. 렌더링이 어떻게 현상을 표현할 지 담당하네요.
구조의 단순화
위 과정은 다음 문제를 해결해야만 했습니다.
객체 X의 상태 변경시 rendering 해주는 객체(이하 Renderer)가
- 변경되었는지 어떻게 알아차리고
- 어떤 정보를 전달 해주어야 온전히 renderer가 객체 X를 그릴 수 있을까?
라는 것이였습니다. 좀 찾아보니 rendering queue라는 것을 구현하고 어찌고 저찌고 하던데... 이걸 구현하는게 보다 쉬운 코드, 유지보수를 달성할 수 있을까란 고민에 빠졌습니다. 따라서 다음처럼 단순화하기로 했죠.
"action은 상태를 변경 메서드를 call 한 후에 rendering method를 call한다."
"객체는 자신의 상태 변경 로직과 자신의 상태를 바탕으로 렌더링하는 로직을 가지고 있다."
action에서는 sequential correlation이 발생하고, 객체가 상태 변경과 렌더링을 분리하지 않아 책임이 많아진다는 단점이 존재합니다만, 더 쉬운 구조가 떠오르지 않았네요...
따라서 최종적으로 제 플로우는 다음과 같습니다.
- eventListener를 통해 input을 감지한다.
- eventListener의 callback function으로 action을 연관 짓는다.
- action에선 객체의 상태를 변경 method를 call한 후, 렌더링 method를 call한다.
layered architecture
그리고 listener, action, 객체(element라 명명)는 각각 위에서 부터 하나의 계층을 이룬다 보시면 됩니다. 위에서 아래로만 호출이 가능하지요.
그리고 같은 계층간 호출은 금지합니다! 의존성이 높아지기 때문이지요. element는 독립적이라 가정할 것인데, 코드 상 서로 연관되어 있으면 도메인과 상반될 뿐더러, 두 element간 dependency도 높아지겠죠. action에서 그 연관성을 결정지어줄 것입니다. (이 이유는 element를 다 그린 뒤에 다시 말씀드리겠습니다.)
따라서 구조를 그려보면 다음과 같습니다.
dataTable의 element들
dataTable을 그리기 위한 element들을 살펴보겠습니다.
그림먼저 보여드리면 다음과 같겠습니다.
4. cache DB
속도 향상을 위해 처음에 서버로부터 출력해야할 데이터를 모두 받아올 예정입니다. 그리고 이 cache DB에 저장할 것이지요. 이 작업은 최대 2초가 소요됩니다. 처음엔 느리지만, 한 번 불러오기 시작하면 통신비용이 없기 때문에 이후 작업은 빠르겠지요?
그리고 cache이기 때문에 서버와 동기화를 잘 해주어야 합니다.
따라서 상태와 상태변경 메서드는 다음과 같이 말할 수 있습니다.
상태
데이터
메서드
- save(item)
- delete(item)
- update(item)
5. requester
서버 측의 데이터를 변경해주는 작업을 합니다.
상태
상태는 뭘 가질 필요가 없을 것 같네요. 그나마 서버에 호출한다고 url을 상태로 가질 수도 있지만... 굳이~ 인 것 같네요.
메서드
- save(item)
- delete(item)
- update(item)
6. 서버의 DB
이건 외부에 존재하므로 javascript코드엔 존재하지 않습니다.
1. tbody, 3. tbody data
tbody는 데이터 테이블의 행을 보여주는 부분에 속하죠.
생각해보면 자신만의 데이터(tbody data)를 가지고 있어야 합니다. 다음 질문에 답해봅시다.
- tbody의 값을 정렬한다고 하면, cache DB의 값을 정렬해서 보여주어야 할까요?
- 검색(필터링)을 하여 값을 보여준다 하면, cache DB가 필터링되어 cache DB가 가지고 있는 데이터가 손실되지 않을까요?
따라서, tbody는 어떤 데이터를 보여줄 지 자신만의 상태를 가지고 있어야 합니다. 그것이 바로 tbody data가 되는 것이지요. 다시 말해 tbody는 tbody data를 상태로 관리하며 이것을 렌더링해야 합니다.
추가적으로 정렬 작업은 tbody가 tbody-data를 가지고 있으므로 tbody가 수행하도록 합니다. 따라서 정렬에 관련된 상태도 가지고 있어야겠지요?
상태
- tbody data
- 정렬 관련 상태
메서드
- rendering 관련...
2. searchBar
tbody data는 누구로부터 생성되는 것일까요? 다시 말해서, tbody에 있는 데이터는 무엇으로부터 결정되죠? 바로 searchBar를 통해서입니다. 무엇을 검색하느냐에 따라 보여주는 데이터가 달라지잖아요? 과정은 다음과 같습니다.
- searchBar에서 검색 조건을 통해 cache DB의 데이터를 필터링합니다.
- 그 필터링된 값을 tbody의 tbody data로 설정합니다.
그리고 tbody는 이 데이터를 바탕으로 화면에 출력하게 되겠죠?
상태
- 검색조건들
메서드
- presentData (데이터를 필터링해주는 메서드)
Action
어떤 액션을 정의할까요? 요구사항 2가지를 작성해보도록 하겠습니다.
1. 검색
검색이라는 액션은 element를 토대로 다음과 같이 말할 수 있습니다.
1) searchBar로부터 cache DB를 필터링한다.
2) 필터링한 데이터를 tbody의 tbody data로 넘겨준다.
3) tbody를 렌더링한다.
2. 정렬
1) tbody에서 key값을 넘겨주어 정렬한다.
2) tbody를 렌더링한다.
listeners
1. 검색버튼 클릭 및 엔터
검색 버튼이나 검색을 눌렀을 때 검색 action과 연결시켜줍니다.
2. 컬럼 클릭(정렬)
dataTable처럼 컬럼을 클릭할 때마다 해당 column 정렬 action을 수행합니다.
렌더링
렌더링은 자기 자신의 데이터로만! 해야 합니다. 그래야 엘레먼트간 의존성이 생기지 않겠죠.
지금 생각해보면 이런게 바로 stand-alone object가 아닐까 싶네요? 다른 객체와 의존성 없이 없잖아요?
구현
앞서 설명드린 것들을 간단히 구현해보도록 하겠습니다~~
요구사항
- user 정보를 테이블로 보여주세요.
- column은 아이디, 이름, 핸드폰번호, 성별, 부서 속성을 보여주세요.
- 아이디, 이름, 핸드폰번호는 검색어를 입력하여 찾을 수 있도록 해주세요
- 성별, 부서는 select box로 필터링할 수 있게 해주세요.
- 속성 헤더를 클릭할 때마다 속성의 값을 오름차순, 내림차순으로 변경해주세요. ex:) 성별 클릭시 성별 기준 오름차순 or 내림차순 정렬
테이블의 조회만 구현하도록 하겠습니다. element에 대한 설명은 위에 해두었습니다. 몇 개더 추가된 것이 있긴 한데... 이건 새로운 포스트에서 다루도록 하겠습니다.
참고로 정렬 상태를 저장하기 위해 thead element를 추가하였습니다.
디렉토리 구조
datatable이라는 폴더 아래 actions, elements, listeners 폴더를 두어 그 안에 알맞은 클래스들을 선언하였습니다. 그리고 통합하는 파일은 ju_table.js라고 보시면 됩니다.
ju_table.js
import Thead from './elements/thead'
import Tbody from './elements/tbody'
import CacheDB from './elements/cache_db'
import Requester from './elements/requester'
import SearchBar from './elements/search_bar'
import SearchAction from './actions/search_action'
import TbodyAction from './actions/tbody_action'
import TheadAction from './actions/thead_action'
import Listener from './listeners/listener'
window.juTable = {}
window.juTable.start = function ({
url,
tableElem,
columns,
items,
searchOptionElem,
qElem,
searchFormElem,
conditionInfos
}) {
let tbodyElem = tableElem.querySelector('tbody');
let theadElem = tableElem.querySelector('thead');
let thElemList = Array.from(tableElem.querySelectorAll('th'));
let cacheDB = new CacheDB(items);
let requester = new Requester(url);
let searchBar = new SearchBar({
searchOption: searchOptionElem,
q: qElem,
conditionInfos: conditionInfos
})
let tbody = new Tbody({
tbody: tbodyElem,
columns: columns
})
let thead = new Thead({
thead: theadElem,
thList: thElemList,
columns: columns,
})
let searchAction = new SearchAction({
searchBar: searchBar,
tbody: tbody,
cacheDB: cacheDB
})
let tbodyAction = new TbodyAction({
tbody: tbody,
requester: requester,
cacheDB: cacheDB
})
let theadAction = new TheadAction({
tbody: tbody,
thead: thead
})
let listener = new Listener({
searchForm: searchFormElem,
table: tableElem,
searchAction: searchAction,
tbodyAction: tbodyAction,
theadAction: theadAction
});
listener.init();
//초기에 검색하여 초기화
searchAction.search();
}
- 객체를 생성 + 의존성을 주입해주는 부분이라고 보시면 됩니다.
html
<form id="search-form">
<label for="search-option"></label>
<select name="" id="search-option">
<option value="id" selected>아이디</option>
<option value="name">이름</option>
<option value="phone_number">핸드폰번호</option>
</select>
<label for="q"></label>
<input type="text" id="q" placeholder="검색어를 입력해주세요">
<select name="" id="sex-type">
<option value="ALL" selected>전체</option>
<option value="M">남자</option>
<option value="W">여자</option>
</select>
<select name="" id="department-type">
<option value="ALL" selected>전체</option>
<option value="development">개발부서</option>
<option value="marketing">마케팅부서</option>
<option value="operation">운영부서</option>
</select>
</form>
<table id="user-table">
<thead>
<tr>
<th>아이디</th>
<th>이름</th>
<th>핸드폰번호</th>
<th>성별</th>
<th>부서</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<%= javascript_pack_tag 'users' %>
<%= javascript_pack_tag 'users' %>
은 Rails에서 웹팩을 불러오는 코드입니다 .여기선 현 페이지에 해당하는 users.js를 불러오도록 하였습니다.
client코드 (users.js)
function init() {
fetch('/users', {
headers: {Accept: 'application/json'}
})
.then(response => response.json())
.then(data => {
juTable.start({
url: '/users',
items: data.items,
tableElem: document.getElementById('user-table'),
searchOptionElem: document.getElementById('search-option'),
qElem: document.getElementById('q'),
searchFormElem: document.getElementById('search-form'),
columns: ['id', 'name', 'phone_number', 'sex', 'department'], // 서버측으로 받은 columnName, 테이블에 표시한 columns
conditionInfos: [
{condition: document.getElementById('sex-type'), column: 'sex'},
{condition: document.getElementById('department-type'), column: 'department'}
]
})
})
}
window.onload = init
- layout쪽에 ju_table.js 빌드한 웹팩을 불러오록 하여 users.js코드는 이상없이 작동합니다.(juTable 참조가능)
- conditionInfos를 통해 select box 성격의 필터링을 유연하게 정의할 수 있습니다. column은 서버측으로부터 받은 column 명을 입력해야 합니다.
- columns는 표시하고 싶은 header의 서버측으로부터 받은 column 명을 순서대로 입력해주시면 됩니다.
전체코드는 다음을 보시면 확인가능합니다. 레일즈로 작성했구요, localhost:3000/users 로 접속하시면 확인 가능합니다.
코드: https://github.com/jurogrammer/data-table-exam
귀찮으실까봐... jsfiddle.net으로 링크달아봅니당. 검색은 엔터누르시면 됩니다. 정렬은 헤더 누르구용
https://jsfiddle.net/who3fa7t/2/
돌아보기
- input은 액션이 참 이상합니다... element를 받는순간, input으로 인한 변경이 이미 반영되니까요.
- actions 이름을 어떻게 정해줘야할 지 모르겠네요? 뭐... theadAction이라고 하면 thead에 어떤 element의 상태를 변경시킬 수 있는 action이 있다는건지, Thead의 상태를 변경시키는 액션인지... 명확하지 않습니다.그래서 혼용하여 메서드가 작성되있는 것 같네요
- 각 역할에 맞는 메서드 명을 지어주지 못한 것 같네요?...
- Requster의 경우, url이 고정되고, method만 변경됩니다. ㅎㅎ; 수정할 필요가 있네요. 실전에서는 url을 하드코딩해주었습니다.
- javascript는 어떻게 작성해야 nice한지 잘 모르겠네요... 보기 불편하게 있다면 말씀해주세요 ㅎㅎ;
- 참조안하는 상태가 있네요...
- 미구현 메서드도 있습니다.
- element와 객체간 이름 구분이 불분명하네요...
Immutable
cacheDB에서 tbody로 데이터를 전달해줄 때 cacheDB는 defensive copy로 값을 반화해줍니다. 그 이유는 tbody에서 값을 수정했을 때 cacheDB에 저장 중인 값이 수정되지 않길 바래서이지요. 두 상태는 다르게 보도록 가정했기 때문입니다.
이게 최선? 더 빠르게 불가능해?
이 방식의 문제는 모든 데이터를 렌더링하고 있습니다. 따라서 데이터가 많아질수록 정렬, 검색할 때 시간이 느려질 수 밖에 없지요. 그래서 자료를 찾다가 이 글을 찾았습니다.
https://news.ycombinator.com/item?id=4225977
이 글엔 아래의 링크가 달려있었죠.
데이터가 엄청 많은데 곧바로 보여지다니! 충격이였습니다.... 잘 보니... 눈속임이더군요! 화면에 보이는 데이터만 보여줬던 겁니다. 와; 생각해보니 인피니티 스크롤이 딱 이거더군요. 이걸 구현하고자 마음먹었습니다!
정렬 - view와 data의 분리
처음 구상했을 적 view에 데이터가 있을 때,다음처럼 생각했습니다.
아니, 정렬하려면 모든 행을 어차피 로딩해야 하잖아? 예를 들어서 1~10 수가 있을 때 1,2,3만 가지고 정렬하면 32145678910이 되니까! 느릴 수 밖에 없넹 ㅠㅠ
했는데... data를 view와 분리하고 난 뒤엔 얼마나~ 멍청한 생각이였지 알겠더라구요. 분리한 덕분에 data만 정렬할 수 있잖아요? 이 작업은 얼마 안걸립니다. 페이지 로딩이 10초 나왔던 것도 대부분 렌더링때문이였거든요.
따라서 정렬작업을 수행한다면 data만을 정렬하고, 사용자가 보고있는 화면에 해당하는 부분의 data를 렌더링하는 방식으로 구현한다면 속도가 엄청 빨라지겠다 싶더라구요?
(ㅎㅎ; 이것만 잘 이해했다면... dataTable 그대로 사용했을 것 같네요)
그래서 Intersection Observer API를 이용하기로 했습니다. 이건 다음 포스팅에서 다루도록 하겠습니다.
'Web' 카테고리의 다른 글
JWT 형식에 대한 의문 WITH RFC 7519 (0) | 2022.06.21 |
---|---|
로컬용 TLS 통신 (1) | 2022.01.15 |
[Browser] 크롬에서 console.log가 안되는 경우 (0) | 2020.06.16 |
[Javascript]Transition 작동문제(infinite sliding window...) (0) | 2020.06.15 |
[Javascript] 함수에 bind()가 안 묶인 경우 (0) | 2020.06.09 |