reference:
1) https://brunch.co.kr/@theopenproduct/58
(무한 스크롤에 대한 정의, 페이지네이션과의 비교를 중심으로)
2) https://tech.kakaoenterprise.com/149
(카카오 엔터프라이즈 포스팅)
3) https://www.bucketplace.com/post/2020-09-10-%EC%98%A4%EB%8A%98%EC%9D%98%EC%A7%91-%EB%82%B4-%EB%AC%B4%ED%95%9C%EC%8A%A4%ED%81%AC%EB%A1%A4-%EA%B0%9C%EB%B0%9C%EA%B8%B0/
(오늘의 집 무한 스크롤 개발기)
4) https://medium.com/myrealtrip-product/%EC%83%81%ED%99%A9%EC%97%90-%EB%A7%9E%EB%8A%94-%EB%A1%9C%EB%94%A9-%EC%95%A0%EB%8B%88%EB%A9%94%EC%9D%B4%EC%85%98-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0-2018af51c197
(미디엄, 상황에 맞는 로딩 인디케이터 적용하기)
5) https://blog.hyeyoonjung.com/2019/01/09/intersectionobserver-tutorial/
✅ 무한 스크롤(Infinite Scrolling) 톺아보기
무한 스크롤이란, 스크롤이 페이지의 끝에 도달했을 때 자동으로 다음 데이터를 요청하여 받아오는 UX 방식을 의미한다. 별도의 페이지 이동 없이 데이터를 지속적으로 불러오기 때문에 직관적이며 편리하다는 장점을 갖는다. 유튜브, 페이스북, 인스타그램 등 많은 서비스들이 모바일과 웹에서 콘텐츠를 제시하는 방식으로서 '무한 스크롤' 기법을 활용하고 있다.
무한 스크롤과 페이지네이션은, 결국 콘텐츠 데이터를 사용자에게 보여주는 UX 방식이라는 점에서는 동일하다. 다만, 페이지네이션은 전체 콘텐츠를 페이지를 기준으로 적절한 분량으로 나누어 사용자에게 콘텐츠를 제시한다.
페이지네이션의 장점은, 일단 전체 페이지의 수(=전체 볼륨)를 사용자가 확인할 수 있기 때문에, 사용자가 콘텐츠에 대한 통제감을 느끼며 탐색을 진행할 수 있다. 또한 특정한 규칙에 따라 콘텐츠가 정렬되기에 콘텐츠의 정확한 인덱스를 파악할 수 있으며, 페이지마다 제시되는 콘텐츠의 양이 적절히 정해져 있기 때문에 빠른 로딩 속도를 제공할 수 있다.
페이지네이션은 사용자가 처음부터 목적을 가지고 콘텐츠를 탐색할 때 유용하다. 특정한 기준에 따라서 콘텐츠가 정렬되어 있기 때문에 목적에 맞는 자료를 쉽게 찾을 수 있고, 언제든지 원하는 위치로 돌아갈 수 있기 때문이다.
하지만 이번에 진행할 MERN stack 프로젝트에서는, CRUD를 기반으로 콘텐츠를 제시하는 것이 주 목적이기 때문에 무한 스크롤 UX 기법을 연습해 보는 것이 더 적절할 것이라고 판단했다.
export interface PaginationResponse<T> {
contents: T[]; // 현재 페이지에 포함된 데이터 리스트
pageNumber: number; // 현재 페이지 번호
pageSize: number; // 페이지 크기
totalPages: number; // 전체 페이지 수
totalCount: number; // 전체 아이템 수
isLastPage: boolean; // 마지막 페이지 여부
isFirstPage: boolean; // 첫 페이지 여부
}
Typescript로 작성된 인터페이스 정의이다. 위 인터페이스를 통해, 페이지네이션 된 API 응답을 처리할 수 있게 될 것이다.
다음은 모킹 API이다.
// 0부터 1023까지의 숫자를 포함하는 배열을 생성하고 각 요소를 User 객체로 변환
const users = Array.from(Array(1024).keys()).map(
(id): User => ({
id,
name: `denis${id}`,
})
);
// 핸들러 배열에는 REST API의 엔드포인트와 그에 대한 응답을 정의하는 함수가 포함됨
const handlers = [
// '/users' 경로에 대한 GET 요청을 처리하는 핸들러
rest.get('/users', async (req, res, ctx) => {
// 요청 URL에서 searchParams를 추출
const { searchParams } = req.url;
// 쿼리 파라미터에서 size와 page를 추출하여 숫자로 변환
const size = Number(searchParams.get('size'));
const page = Number(searchParams.get('page'));
// 전체 유저 수
const totalCount = users.length;
// 전체 페이지 수를 계산 (전체 유저 수를 페이지 크기로 나눈 후 반올림)
const totalPages = Math.round(totalCount / size);
// 응답 생성 및 반환
return res(
// 응답 상태 코드를 200으로 설정 (성공)
ctx.status(200),
// JSON 형태로 응답을 생성
ctx.json<PaginationResponse<User>>({
// 요청된 페이지에 해당하는 유저 리스트를 slice 메서드를 사용해 가져옴
contents: users.slice(page * size, (page + 1) * size),
// 요청된 페이지 번호
pageNumber: page,
// 각 페이지의 크기 (유저 수)
pageSize: size,
// 전체 페이지 수
totalPages,
// 전체 유저 수
totalCount,
// 현재 페이지가 마지막 페이지인지 여부를 나타냄
isLastPage: totalPages <= page,
// 현재 페이지가 첫 페이지인지 여부를 나타냄
isFirstPage: page === 0,
}),
// 응답을 500ms 지연시킴
ctx.delay(500)
);
}),
];
다음은 프론트엔드 React 코드다.
// 페이지 크기를 계산, 카드 크기(CARD_SIZE)와 뷰포트 너비에 따라 동적으로 설정
const PAGE_SIZE = 10 * Math.ceil(visualViewport.width / CARD_SIZE);
function UsersPage() {
// 페이지 상태를 관리하기 위한 useState 훅
const [page, setPage] = useState(0);
// 유저 데이터를 저장할 상태
const [users, setUsers] = useState<User[]>([]);
// 데이터 로딩 상태를 관리하기 위한 상태
const [isFetching, setFetching] = useState(false);
// 다음 페이지가 있는지 여부를 관리하기 위한 상태
const [hasNextPage, setNextPage] = useState(true);
// 유저 데이터를 비동기적으로 가져오는 함수
const fetchUsers = useCallback(async () => {
// axios를 사용하여 '/users' 경로에 GET 요청을 보냄, 쿼리 파라미터로 페이지와 크기를 전달
const { data } = await axios.get<PaginationResponse<User>>('/users', {
params: { page, size: PAGE_SIZE },
});
// 현재 유저 리스트에 새로운 데이터를 추가
setUsers(users.concat(data.contents));
// 다음 페이지 번호를 설정
setPage(data.pageNumber + 1);
// 다음 페이지가 있는지 여부를 설정
setNextPage(!data.isLastPage);
// 로딩 상태를 false로 설정
setFetching(false);
}, [page]);
// 컴포넌트가 마운트될 때와 스크롤 이벤트를 처리하기 위한 useEffect 훅
useEffect(() => {
const handleScroll = () => {
const { scrollTop, offsetHeight } = document.documentElement;
// 스크롤이 페이지 하단에 도달했을 때 로딩 상태를 true로 설정
if (window.innerHeight + scrollTop >= offsetHeight) {
setFetching(true);
}
};
// 초기 로딩 상태를 true로 설정
setFetching(true);
// 스크롤 이벤트 리스너 추가
window.addEventListener('scroll', handleScroll);
// 컴포넌트가 언마운트될 때 스크롤 이벤트 리스너 제거
return () => window.removeEventListener('scroll', handleScroll);
}, []);
// 로딩 상태가 변경될 때와 다음 페이지가 있을 때 유저 데이터를 가져오는 useEffect 훅
useEffect(() => {
// 로딩 상태가 true이고 다음 페이지가 있을 때 유저 데이터를 가져옴
if (isFetching && hasNextPage) fetchUsers();
// 다음 페이지가 없을 때 로딩 상태를 false로 설정
else if (!hasNextPage) setFetching(false);
}, [isFetching]);
return (
<Container>
{/* 유저 데이터를 카드 형태로 렌더링 */}
{users.map((user) => (
<Card key={user.id} name={user.name} />
))}
{/* 로딩 상태일 때 로딩 컴포넌트를 렌더링 */}
{isFetching && <Loading />}
</Container>
);
}
코드를 처음부터 다 이해할 필요는 없다. 아니, 사실 처음부터 다 이해할 수는 없다. 중요한 것은, 무한 스크롤이라는 건 '특정 페이지 하단에 도달' 했을 때 'API 요청'이 실행된다는 점이다. 그렇다면 'Window 객체의 scroll event'를 통해 '특정 페이지 하단에 도달'을 어떻게 구현했는지를 정확히 이해하는 것이 핵심이다.
useEffect(() => {
const handleScroll = () => {
const { scrollTop, offsetHeight } = document.documentElement
if (window.innerHeight + scrollTop >= offsetHeight) {
setFetching(true)
}
}
setFetching(true)
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [])
위 코드가 알짜배기라는 것이다. 그러면 innerHeight, scrollTop, offsetHeight라는 재료가 무엇을 의미하는지, 그리고 그 재료들로 요리된 window.innerHeight + scrollTop >= offsetHeight
가 무엇인지만 알면 되겠네.
innerHeight: 브라우저 창의 내부 뷰포트 높이
scrollTop: 현재 페이지의 스크롤 위치(=사용자가 페이지를 스크롤 하여 위로 올린 정도)
offsetHeight: 페이지의 전체 높이
즉, 브라우저 창의 내부 뷰포트 높이 + 현재 페이지의 스크롤 위치 >= 페이지의 전체 높이
를 의미한다.
자, 그렇다면 Window 객체의 scroll을 쓰면 될 것이지, Intersection Observer API는 또 왜 쓰냐? 왜 나를 자꾸 힘들게 하는 것이냐?
기존 scroll 이벤트는 document에 스크롤 이벤트를 등록하고, 특정 지점을 관찰하여 엘리먼트가 위치에 도달했을 때 실행할 콜백 함수를 등록하는 방식으로 구현되어 있다. 하지만 scroll 이벤트는 단시간에 수백 번, 수천 번 호출될 수 있다. 동시에 스크롤 이벤트는 동기적
으로 실행되기 때문에 메인 스레드에 영향을 주게 된다. 게다가 특정 지점을 관찰하기 위해서는 getBoundingClientRect() 함수를 사용해야 하는데, 이 함수는 리플로우(reflow) 현상
이 발생한다는 단점이 있다. 리플로우(reflow)란 브라우저가 웹 페이지의 일부 또는 전체를 다시 그려야 하는 경우 발생한다.
Intersection Observer API를 사용하면 위와 같은 문제를 해결할 수 있다. 비동기적
으로 실행되기 때문에 메인 스레드에 영향을 주지 않으면서 변경 사항을 관찰할 수 있다. 또한 IntersectionObserverEntry
의 속성을 활용하면 getBoundingClientRect()를 호출한 것과 같은 결과를 알 수 있기 때문에 따로 getBoundingClientRect() 함수를 호출할 필요가 없어 리플로우 현상을 방지
할 수 있게 된다.
그리고 무한 스크롤과 관련하여 알고 있어야 할 개념에 두 가지가 있다. Debounce와 Throttle이다. Debounce와 Throttle은 둘 다 함수의 호출을 제어(=지연)하여 성능 최적화나 이벤트 처리를 관리하는 기술이다.
각각의 용어가 어떤 함의를 갖고 있는지 가볍게 살펴보겠다.
Debounce는 연이어 발생하는 이벤트에서 '마지막 이벤트'가 발생한 후 일정 시간이 지난 후에 해당 이벤트를 처리하는 기술이다. 주로 입력 필드에서 사용자의 입력을 처리하거나, 스크롤 이벤트 등에서 발생할 수 있는 연속적인 이벤트 처리를 제어하는 데 유용하다.
Throttle은 연속적인 이벤트의 발생을 제어하여 '일정 시간 간격'으로 이벤트 핸들러가 실행되도록 하는 기술이다. 주로 스크롤 이벤트나 DOM 요소의 드래그 이벤트와 같이 빈번하게 발생하는 이벤트를 제한하는 데 사용된다.
결국 Debounce와 Throttle은 모두 함수의 호출을 지연시키는 것이다. 준비되기 전까지 호들갑 떨지 말라는 거다. 그런데 이제 Debounce를 '간격'이라는 그릇에 담는 순간 Throttle이 되는 것이다.
무한 스크롤을 공부하는 김에, 로딩 인디케이터에 관한 내용도 가볍게 살펴봤다. 마이리얼트립에서 사용하는 로딩 인디케이터를 기준으로 학습했다. 간단하게 스크린 샷을 아카이빙 하겠다.
import React, { useState, useEffect, useRef, useCallback } from "react";
import axios from "axios";
import styled from "styled-components";
const AppContainer = styled.div`
text-align: center;
padding: 20px;
`;
const Title = styled.h1`
margin-bottom: 20px;
`;
const CardContainer = styled.div`
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 20px;
`;
const Card = styled.div`
border: 1px solid #ccc;
border-radius: 8px;
padding: 10px;
width: 150px;
text-align: left;
transition: transform 0.3s;
&:hover {
transform: translateY(-10px);
cursor: pointer;
}
`;
const CardImage = styled.img`
max-width: 100%;
border-radius: 4px;
`;
const CardText = styled.p`
margin: 10px 0 0;
font-size: 14px;
`;
const LoadingText = styled.p`
margin-top: 20px;
`;
const EndText = styled.p`
margin-top: 20px;
color: grey;
`;
const App = () => {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const observer = useRef();
const fetchItems = async (page) => {
setLoading(true);
try {
const response = await axios.get(
`https://jsonplaceholder.typicode.com/photos?_page=${page}&_limit=10`
);
setItems((prevItems) => [...prevItems, ...response.data]);
setHasMore(response.data.length > 0);
} catch (error) {
console.error("Error fetching data:", error);
}
setLoading(false);
};
useEffect(() => {
fetchItems(page);
}, [page]);
const lastItemRef = useCallback(
(node) => {
if (loading) return;
if (observer.current) observer.current.disconnect();
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore) {
setPage((prevPage) => prevPage + 1);
}
});
if (node) observer.current.observe(node);
},
[loading, hasMore]
);
return (
<AppContainer>
<Title>Infinite Scroll Cards</Title>
<CardContainer>
{items.map((item, index) => (
<Card
key={item.id}
ref={items.length === index + 1 ? lastItemRef : null}
>
<CardImage src={item.thumbnailUrl} alt={item.title} />
<CardText>{item.title}</CardText>
</Card>
))}
</CardContainer>
{loading && <LoadingText>Loading...</LoadingText>}
{!hasMore && <EndText>No more items to load</EndText>}
</AppContainer>
);
};
export default App;
✅ 회고
가장 경쟁력 있는 상품은
'서사(narrative)'입니다.
성장과 좌절이
진실하게 누적된 나의 기록은
유일무이한 나만의 서사입니다.
시대예보(송길영) 中