-
Infinite Scroll로 리스트 처리하기JS Trick Dictionary 2021. 1. 31. 12:23
∞ 페이징과 무한 스크롤
데이터가 많은 리스트를 보여주는 방법에는 크게 두 가지가 있습니다. 첫 번째는 게시판에서 주로 볼 수 있는 페이징 방식이고 두 번째는 인스타그램이나 페이스북 등에서 볼 수 있는 무한 스크롤 방식입니다.
페이징은 위 사진과 같이 한 페이지당 나오는 데이터의 개수가 정해져 있고 주로 1부터 시작하는 페이지를 옮겨가며 여러 건의 데이터를 순차적으로 조회하는 방식입니다.
무한 스크롤은 페이지를 나누지 않고 특정 이벤트 (주로 스크롤 이벤트)가 발생하는 경우 기존에 나와있는 데이터 이후 건들을 조회해 노출하는 방식입니다.
🤔 왜 이 포스트를 작성했나요?
얼마 전 새로운 리스트 페이지를 구현할 일이 생겼습니다. 쌓인 로그를 단순히 보여주기만 하는 리스트 페이지이기 때문에 굳이 페이징 처리를 하지 않아도 될 것 같다는 판단 하에 개발을 했으나 쌓인 데이터가 많아 한 번에 5만 건이 넘는 데이터를 불러오는 경우 페이지를 그리는데 1분 가량의 긴 시간이 걸리는 문제가 발생했습니다.
백엔드 개발자와 논의를 해본 결과 이 페이지는 단순히 로그를 조회하고 조회한 로그를 하나하나 보기보다는 그 외 기능을 더 많이 사용하는 페이지이기 때문에 페이징보다는 무한 스크롤이 더 적합하다는 판단을 내렸습니다.
📁 AS-IS
기존에는 Spring에서 View로 내려주는 Model 데이터를 받아 Thymeleaf로 렌더링했습니다.
<tr th:each="item, index : ${items}"> <td th:text="${#lists.size(items) - index.index}"></td> <td th:text="${item.data1}"></td> <td th:text="${item.data2}"></td> <td th:text="${item.data3}"></td> <td th:text="${item.data4}"></td> <td th:text="${item.data5}"></td> <td th:text="${item.data6}"></td> <td th:text="${item.data7}"></td> </tr>
페이징 처리를 하지 않으니 5만여 건의 데이터를 조회할 경우
th:each
문을 돌며 전부 그린 다음에야 페이지 로딩이 끝났습니다. 너무 느리고 5만 건의 로그를 하나하나 확인할 용도로 만든 페이지가 아니기 때문에 비효율적인 방법이었습니다.🆕 TO-BE
무한 스크롤로 방식을 변경하며 한 번에 50개씩, 스크롤이 페이지 아래에 가면 그다음 50건을 불러오기로 결정했습니다.
프론트엔드 로직을 간략하게 정리하자면 다음과 같습니다.
- 리스트 정보를 리턴하는 API를 호출한다
- API Response 데이터로 리스트를 렌더링 한다
- 스크롤이 페이지의 bottom에 닿으면 1을 반복한다
- 리스트가 끝날 때까지 위를 반복한다
생각보다 간단하죠?
+) 백엔드(Spring Boot) 코드는 이 링크를 참고하세요! 무한 스크롤링을 이용하여 성능 개선하기
🤞 기본 기능 구현
리스트 불러오기
먼저, API를 호출하는 함수
getList
를 만들어봅시다.const getList = () => { fetch(URL, { method: "GET", body: { lastId } }) .then((res) => res.json()) .then((resJson) => { drawList(resJson); }); };
리스트 데이터를 response 객체에 담아 돌려주는 API를 호출하고 리턴된 JSON 데이터를
drawList
함수에 인자로 전달합니다. 이때body
에 전달되는lastId
는 백엔드에서 다음 50건을 찾아오기 위해 필요한 현재 리스트의 마지막 ID값입니다. (초기lastId
값은 따로 받아서 전달하고 있습니다.)그리고 이 함수를 페이지가 로드되자마자 호출합니다.
리스트 그리기
이번엔 데이터로 DOM에 리스트를 그리는 함수
drawList
를 만들어야겠죠?const drawList = (DATA) => { let listHtml = ""; DATA.forEach((item, index) => { const { id, data1, data2, data3, data4, data5, data6, data7 } = item; const TR_ELE = document.createElement('tr'); listHtml = `<td>${id}</td> <td>${name}</td> <td>${username}</td> <td>${email}</td> <td>${website}</td>`; if (index === DATA.length - 1) lastId = id; // 마지막건 ID 저장 TR_ELE.innerHtml = listHtml; TABLE_ELE.appendChild(TR_ELE); }); };
어렵지 않죠? 데이터들을
forEach
로 돌며 element를 그린 다음 원하는 곳에 append 시키면 됩니다! 다음 50건을 위한lastId
세팅도 잊지 않고 넣어줬습니다.스크롤 이벤트 처리하기
그럼, 다음으로 페이지 bottom에 닿았는지 여부를 체크하는 scroll event를 만들어볼게요. 스크롤이 바닥에 닿았는지 여부를 확인하기 위해서는
window.scrolY
와window.innerHeight
그리고document.body.offsetHeight
가 필요합니다.window.addEventListener("scroll", function () { const SCROLLED_HEIGHT = window.scrollY; const WINDOW_HEIGHT = window.innerHeight; const DOC_TOTAL_HEIGHT = document.body.offsetHeight; const IS_BOTTOM = WINDOW_HEIGHT + SCROLLED_HEIGHT === DOC_TOTAL_HEIGHT; if (IS_BOTTOM) { getList(); } });
변수명에서도 알 수 있듯
window.scrollY
는 top에서 현재까지의 간격 즉, 스크롤된 높이이고window.innerHeight
는 현재 클라이언트에게 보여지는 화면의 높이,document.body.offsetHeight
는 스크롤된 위치에 관계 없이document.body
의 전체 높이를 의미합니다.현재 보이는 높이
+이미 스크롤된 높이
가전체 높이
와 같으면 스크롤이 바닥까지 닿았다는 것을 알 수 있습니다. 만약 바닥에 닿았으면getList
함수로 API를 다시 호출하는 거죠.전체 코드
let lastId = 0; const TABLE_ELE = document.querySelector("table"); const drawList = (DATA) => { let listHtml = ""; DATA.forEach((item, index) => { const { id, data1, data2, data3, data4, data5, data6, data7 } = item; const TR_ELE = document.createElement('tr'); listHtml = `<td>${data1}</td> <td>${data2}</td> <td>${data3}</td> <td>${data4}</td> <td>${data5}</td> <td>${data6}</td> <td>${data7}</td>`; if (index === DATA.length - 1) lastId = id; // 마지막건 ID 저장 TR_ELE.innerHtml = listHtml; TABLE_ELE.appendChild(TR_ELE); }); }; const getList = () => { fetch(URL, { method: "GET", body: { lastId } }) .then((res) => res.json()) .then((resJson) => { drawList(resJson); }); }; window.addEventListener("scroll", function () { const SCROLLED_HEIGHT = window.scrollY; const WINDOW_HEIGHT = window.innerHeight; const DOC_TOTAL_HEIGHT = document.body.offsetHeight; const IS_BOTTOM = WINDOW_HEIGHT + SCROLLED_HEIGHT === DOC_TOTAL_HEIGHT; if (IS_BOTTOM) { getList(); } });
⌛️ bottom에 닿기 전 로드하기
페이스북에 접속해서 타임라인을 내려보면 스크롤이 bottom에 완전히 닿기 전 데이터가 로드되는 것을 볼 수 있습니다. 위에서
IS_BOTTOM
을 계산하는 수식을 조금 변경하면 이렇게 구현할 수 있습니다.const IS_END = (WINDOW_HEIGHT + SCROLLED_HEIGHT > DOC_TOTAL_HEIGHT - 500);
하지만 여기에서 문제가 발생할 수 있습니다.
===
이 아닌>
를 사용했기 때문에WINDOW_HEIGHT + SCROLLED_HEIGHT
가DOC_TOTAL_HEIGHT - 500
보다 크면 계속해서 API를 호출하죠.이것을 방지하기 위해
isFetching
라는 변수를 사용해 현재 호출한 API에 대한 callback 처리가 전부 완료되었는지를 체크해봅시다.let isFetching = false; const drawList = (DATA) => { let listHtml = ""; DATA.forEach((item, index) => { const { id, data1, data2, data3, data4, data5, data6, data7 } = item; const TR_ELE = document.createElement('tr'); listHtml = `<td>${data1}</td> <td>${data2}</td> <td>${data3}</td> <td>${data4}</td> <td>${data5}</td> <td>${data6}</td> <td>${data7}</td>`; if (index === DATA.length - 1) lastId = id; // 마지막건 ID 저장 TR_ELE.innerHtml = listHtml; TABLE_ELE.appendChild(TR_ELE); }); isFetching = false; // callback이 끝났으니 isFetching 리셋 }; const getList = () => { isFetching = true; // 아직 callback이 끝나지 않았어요! fetch(URL, { method: "GET", body: { lastId } }) .then((res) => res.json()) .then((resJson) => { drawList(resJson); }); }; window.addEventListener("scroll", function () { const SCROLLED_HEIGHT = window.scrollY; const WINDOW_HEIGHT = window.innerHeight; const DOC_TOTAL_HEIGHT = document.body.offsetHeight; const IS_END = (WINDOW_HEIGHT + SCROLLED_HEIGHT > DOC_TOTAL_HEIGHT - 500); if (IS_BOTTOM && !isFetching) { // isFetching이 false일 때 조건 추가 getList(); } });
👋 참고
stackoverflow.com/questions/9439725/javascript-how-to-detect-if-browser-window-is-scrolled-to-bottom
'JS Trick Dictionary' 카테고리의 다른 글
논리 연산자 || 와 Default Value 차이 (0) 2021.01.18 서로 다른 객체 배열 내 중복 객체 구하기 (0) 2021.01.05