우리fis아카데미에서 사이드 프로젝트를 진행하면서 랭킹기능을 구현하게 되었다. 페이지네이션이랑 무한스크롤 중에 고민하다가 일일이 클릭하며 페이지를 이동하는게 번거롭다고 느꼈고 InfiniteScroll을 구현해보고 싶었다!
왜 InfiniteScroll을 사용했는가?
- 사용자 경험 향상: 사용자가 스크롤을 내릴 때 자동으로 추가 데이터를 불러옴으로써, 페이지를 수동으로 전환할 필요 없이 매끄럽고 연속적인 데이터 로딩 경험을 제공
- 성능 최적화: 초기 데이터 로딩 시 모든 데이터를 한 번에 가져오는 것이 아니라, 필요한 만큼의 데이터만 순차적으로 불러와서 성능을 최적화
- 편리한 데이터 처리: 다음 페이지 데이터를 가져오는 로직이 명확하게 분리되어 있어, 유지보수와 확장성이 용이
1. react-infinite-scroller 설치하기
npm install react-infinite-scroller
2.import하기
import InfiniteScroll from 'react-infinite-scroller';
3.적용하기
3-1. 백엔드에서 반환되는 데이터 구조 확인
{
"member": {
"id": 1,
"name": "홍길동",
"course": "클라우드 서비스",
"rank": 2,
"image": "이미지 url 주소",
"studyTime": 1000,
"totalTime": 6000
},
"ranking": {
"hasNext": true,
"ranks": [
{
"id": 1,
"name": "홍길동",
"course": "클라우드 서비스",
"rank": 2,
"image": "이미지 url 주소",
"studyTime": 1000,
"totalTime": 6000
}
// 추가 학생 데이터...
]
}
}
hasNext는 값을 확인하여 더 이상 가져올 데이터가 있는지 여부를 확인하는 변수다.
3-2 구조 분해 할당 구현
API 호출을 통해 데이터를 받아오고, 필요한 정보를 구조 분해 할당을 통해 상태 변수에 저장.
const { member, ranking: { ranks: newRankings, hasNext } }: ApiResponse = await getMemberRanking({
tab: activeTab,
pageNumber,
size,
});
3-3. 상태관리
React의 useState 훅을 사용하여 상태 변수 설정:
- activeTab: 현재 활성화된 탭 (일간, 주간, 월간)
- rankings: 학생들의 랭킹 리스트
- hasMore: 추가 데이터 유무
- currentUser: 현재 사용자의 정보
- loading: 데이터 로딩 상태
const [activeTab, setActiveTab] = useState<'DAILY' | 'WEEKLY' | 'MONTHLY'>('DAILY');
const [rankings, setRankings] = useState<Student[]>([]);
const [hasMore, setHasMore] = useState(true);
const [page, setPage] = useState(0);
const [currentUser, setCurrentUser] = useState<Student | null>(null);
const [loading, setLoading] = useState(false);
const size = 10;
3-4. 데이터 페칭 함수 작성
API 호출을 처리하는 fetchData 함수 구현
const fetchData = async (pageNumber: number) => {
try {
setLoading(true);
const response = await getMemberRanking({ tab: activeTab, pageNumber, size });
// 데이터 처리 로직...
} catch (error) {
console.error('데이터 로드 에러:', error);
} finally {
setLoading(false);
}
};
fetchData 함수는 페이지 번호를 인자로 받아 API를 통해 랭킹 데이터를 요청, 요청은 getMemberRanking 함수를 통해 이루어지며, 현재 활성화된 탭(activeTab)과 페이지 번호(pageNumber), 가져올 데이터의 크기(size)를 매개변수로 전달
페이지 전환이나 데이터 갱신 시 사용자에게 최신 정보를 제공하는 역할 수행
3-5. 무한 스크롤 구현
사용자가 스크롤을 내리면 추가 데이터를 불러오는 loadMore 함수 작성:
const loadMore = () => {
if (hasMore && !loading) {
fetchData(page + 1);
}
};
3-6. 테이블 렌더링
사용자가 스크롤할 때 loadMore 함수를 호출하고, hasMore 값에 따라 더 로드할 수 있는지 확인. 데이터가 로드되는 동안 로딩 메시지가 표시.
<InfiniteScroll
pageStart={0}
loadMore={loadMore}
hasMore={hasMore}
loader={
<div className={styles.loader} key={0}>
Loading...
</div>
}
useWindow={false}
getScrollParent={() => containerRef.current}
>
<table>
...
</table>
</InfiniteScroll>
데이터 흐름:
- loadMore 함수: 스크롤이 끝에 도달할 때마다 호출되며, 다음 페이지의 데이터를 요청. 이 함수는 부모 컴포넌트에서 fetchData를 호출하여 새로운 데이터를 가져오는 역할을 함
- hasMore prop: 현재 더 불러올 데이터가 있는지를 판단. fetchData에서 새로운 데이터를 성공적으로 불러온 후, hasNext의 값에 따라 업데이트.
- loader: 데이터 로딩 중에 보여줄 로딩 UI를 정의. 새로운 데이터가 로드되는 동안 사용자에게 로딩 중임을 알려줌.
- getScrollParent: 스크롤 이벤트를 감지할 부모 요소를 지정. containerRef를 사용하여 InfiniteScroll 컴포넌트의 스크롤 부모를 지정.
이렇게 Infinite Scroll을 활용해 서버에서 필요한 데이터만 요청하여 네트워크 트래픽을 줄이고 성능 최적화할 수 있게 되었다!!
4.에러 회고 및 리팩토링 진행
처음 랭킹 페이지에 진입했을 때는 정상적인 데이터 반환했으나 주, 월랭킹으로 페이지를 전환했을 때, 스크롤 통해 추가 데이터를 로드 했을 때 동일한 구간의 랭킹이 중복으로 불러오는 문제가 발생
에러 수정 전 코드
export default function Ranking() {
// 상태 정의 생략
useEffect(() => {
const fetchData = async () => {
setRankings([]);
setPage(0);
setHasMore(true);
const {
member,
ranking: { ranks: initialRankings, hasNext },
}: ApiResponse = await getMemberRanking({
tab: activeTab,
pageNumber: 0,
size,
});
const currentUserData: Student = {
id: member.id,
name: member.name,
studyTime: member.studyTime,
totalTime: member.totalTime,
course: member.course,
rank: member.rank,
image: member.image || rankingImg,
};
setRankings(initialRankings);
setHasMore(hasNext);
setCurrentUser(currentUserData);
};
fetchData();
}, [activeTab]);
const loadMore = async () => {
if (hasMore) { // 로딩 상태 체크 없이 요청 발생
const nextPage = page + 1;
try {
const response: ApiResponse = await getMemberRanking({
tab: activeTab,
pageNumber: nextPage,
size,
});
const newRankings = response.ranking.ranks;
// 기존 상태값을 초기화하지 않고 데이터를 추가
setRankings((prevRankings) => [...prevRankings, ...newRankings]);
setHasMore(response.ranking.hasNext);
setPage(nextPage);
} catch (error) {
console.error('데이터 로드 에러:', error);
}
}
};
return (
<section className={styles.container}>
// 생략
</section>
);
}
에러 수정 및 리팩토링
export default function Ranking() {
// 기타 상태 정의 생략
const [loading, setLoading] = useState(false);
const fetchData = async (pageNumber: number) => {
try {
setLoading(true);
const {
member,
ranking: { ranks: newRankings, hasNext },
}: ApiResponse = await getMemberRanking({
tab: activeTab,
pageNumber,
size,
});
if (pageNumber === 0) {
// 첫 로드 시 기존 데이터를 덮어씀
setRankings(newRankings);
setCurrentUser({
id: member.id,
name: member.name,
studyTime: member.studyTime,
totalTime: member.totalTime,
course: member.course,
rank: member.rank,
image: member.image || rankingImg,
});
} else {
setRankings((prevRankings) => [...prevRankings, ...newRankings]);
}
setHasMore(hasNext);
setPage(pageNumber);
} catch (error) {
console.error('데이터 로드 에러:', error);
} finally {
setLoading(false); // 로딩 상태 초기화
}
};
useEffect(() => {
fetchData(0); // 새 탭으로 변경 시 첫 페이지부터 데이터 로드
}, [activeTab]);
const loadMore = () => {
// 로딩 중이 아닐 때만 데이터 요청
if (hasMore && !loading) {
fetchData(page + 1);
}
};
return (
<section className={styles.container}>
// 생략
</section>
);
}
문제 해결 핵심 이유
1. 로딩 상태 추가
원본 코드는 로딩 중임을 표시하는 상태가 없었음. 이로 인해 여러 요청이 동시에 발생할 수 있음
수정 후 아래 코드를 넣음으로써 loading 상태를 추가하여 데이터 로딩 중에는 추가 요청이 발생하지 않도록 함. 이로 인해 여러 요청이 동시에 이루어지는 문제 방지
const [loading, setLoading] = useState(false); // 새로운 로딩 상태
2. fetchData 함수 수정
원본코드에서 모든 모든 데이터 요청이 같은 함수에서 처리되므로, 페이지 번호에 따라 다른 방식으로 데이터를 처리할 수 없음
원본 코드
const fetchData = async () => {
// API 호출 및 데이터 처리
}
수정 후 코드
const fetchData = async (pageNumber: number) => {
try {
setLoading(true); // 로딩 시작
const {
member,
ranking: { ranks: newRankings, hasNext },
}: ApiResponse = await getMemberRanking({
tab: activeTab,
pageNumber,
size,
});
if (pageNumber === 0) {
setRankings(newRankings); // 초기 데이터 설정
setCurrentUser({
// 생략
});
} else {
setRankings((prevRankings) => [...prevRankings, ...newRankings]); // 추가 데이터 병합
}
setHasMore(hasNext);
setPage(pageNumber);
} catch (error) {
console.error('Error fetching data:', error);
} finally {
setLoading(false); // 로딩 종료
}
}
pageNumber가 0일 때 초기 데이터를 설정하고, 그 외의 경우에는 기존 데이터와 병합하는 방식으로 수정, setLoading(true)로 로딩 시작을 표시하고, 데이터 요청 후 setLoading(false)로 로딩 종료를 표시
3. loadMore 함수 수정
핵심으로 문제가 됐던 지점, 로딩 중에도 여러번 호출될 수 있어서 중복 데이터 요청이 발생할 수 있음.
React의 상태 업데이트는 비동기적으로 처리됨. setLoading(true)가 실행되어도 즉시 상태가 반영되지 않음. 상태 반영 이전에 사용자가 다시 스크롤하거나, 탭을 변경하면 fetchData 또는 loadMore가 추가 호출될 가능성이 존재, 이는 loading 상태가 아직 업데이트 되지 않아 중복 요청을 막지 못한 결과임.
원본 코드
const loadMore = async () => {
if (hasMore) {
const nextPage = page + 1;
try {
const response: ApiResponse = await getMemberRanking({
tab: activeTab,
pageNumber: nextPage,
size,
});
const newRankings = response.ranking.ranks;
setRankings((prevRankings) => [...prevRankings, ...newRankings]);
setHasMore(response.ranking.hasNext);
setPage(nextPage);
} catch (error) {
console.error('데이터 로드 에러:', error);
}
}
}
수정 후 코드
const loadMore = () => {
if (hasMore && !loading) { // 로딩 상태를 확인하여 중복 요청 방지
fetchData(page + 1);
}
};
react, next.js를 사용하다보면 관리해야할 상태가 많아지고 동작 순서가 헷갈리는 경우가 종종 생긴다. 어떤 코드가 다른 코드에 영향을 주고 또 그로 인해 사이드 이펙트가 생긴다. 특히 비동기 상태 업데이트가 이루어지는 React의 메커니즘은 그 자체로 주의가 필요가 한 것 같다.
5. 이외의 무한스크롤 구현하는 방법
다른 프로젝트를 진행하면서 똑같은 기능을 다른 방식으로 구현한 팀원이 있어서 이 방법도 살짝 정리하고 넘어가야겠다.
react-intersection-observer 라이브러리 활용한 무한스크롤
import React, { useState } from "react";
import { useInView } from "react-intersection-observer";
const InfiniteScrollWithLibrary = () => {
const [items, setItems] = useState<number[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const { ref, inView } = useInView({
threshold: 1.0, // 100% 보일 때 트리거
rootMargin: "100px", // 트리거를 앞당김
});
// 데이터를 가져오는 함수
const fetchData = async () => {
if (loading) return;
setLoading(true);
const newItems = Array.from({ length: 10 }, (_, i) => i + 1 + (page - 1) * 10);
await new Promise((resolve) => setTimeout(resolve, 1000));
setItems((prev) => [...prev, ...newItems]);
setPage((prev) => prev + 1);
setLoading(false);
};
// inView가 true일 때 데이터 로드
React.useEffect(() => {
if (inView) fetchData();
}, [inView]);
return (
<div>
{items.map((item, index) => (
<div key={index} style={{ padding: "20px", border: "1px solid black" }}>
Item {item}
</div>
))}
<div ref={ref} style={{ height: "50px", backgroundColor: "lightgray" }}>
{loading ? "Loading..." : "Load More"}
</div>
</div>
);
};
export default InfiniteScrollWithLibrary;
react-intersection-observer vs react-infinite-scroller
기능 | react-intersection-observer | react-infinite-scroller |
사용 방식 | useInView 훅 또는 InView 컴포넌트를 사용해 특정 DOM 요소가 뷰포트에 진입했는지 감지 | 기존 스크롤 이벤트를 감지하여 데이터 로드 (스크롤 위치에 의존) |
기반 기술 | 브라우저의 Intersection Observer API를 사용 | 스크롤 이벤트(onScroll)에 의존 |
성능 최적화 | 요소가 화면에 진입했을 때만 트리거, 성능에 매우 효율적 | 스크롤 이벤트가 과도하게 발생하면 성능 저하 가능 |
사용 용도 | 특정 요소가 화면에 보일 때(진입/퇴장 감지) | 무한스크롤 구현에 초점 |
데이터 로드 방식 | 관찰 대상이 화면에 보이면 새로운 데이터를 로드 | 스크롤 위치가 하단에 가까워지면 새로운 데이터를 로드 |
의존성 | 브라우저 기본 API 기반으로 가벼움 | 추가적인 로직 및 상태 관리를 포함, 상대적으로 무겁게 느껴질 수 있음 |
구현 난이도 | 단순하고 간결 (라이브러리와 훅으로 구현 가능) | 초기 설정이 간단하지만, 세부적인 커스터마이징이 필요한 경우 복잡할 수 있음 |
특징 | 뷰포트 진입 감지 외에도 다양한 threshold, rootMargin 등을 설정 가능 | 데이터 로드 외에 커스터마이징된 스크롤 이벤트 처리 기능 제공 |
장점 | 최신 API 기반으로 성능 우수, 요소별로 세밀한 관찰 가능 | 사용이 간단하고, 기존 스크롤 이벤트와 호환성이 뛰어남 |
단점 | 최신 브라우저에서만 완벽 지원, 오래된 브라우저(IE 등)에서는 폴리필 필요 | 스크롤 이벤트 과다 호출로 성능 저하 가능, 직접적인 Intersection Observer 기능 지원 부족 |
설치 및 크기 | npm install react-intersection-observer (경량 라이브러리, 약 2KB) | npm install react-infinite-scroller (약간 무거움, 약 8KB) |
유지보수 상태 | 활발하게 유지보수 중 | 유지보수는 되고 있지만 상대적으로 업데이트 빈도가 적음 |
예제 | 요소가 화면에 보일 때 추가 데이터 로드 (e.g., 이미지 Lazy Load, 무한스크롤) | 페이지 하단 근처에서 데이터 로드 (무한스크롤에 초점) |
react-intersection-observer 를 알았다면 이 라이브러리를 사용했을 것 같지만 직접 사용해봐야 좋은 라이브러리의 장점을 직접 체감할 수 있는 것이니,, 🤣특히 내가 맡은 랭킹 페이지 ui에서는 탭간 전환이 많아서 뷰포트에 들어오거나 나가는 순간을 정확히 감지 할 수 있는 react-intersection-observer가 훨씬 적합했을 것 같다. 이외에도 더 다양한 동작을 처리할 수 있다고 하니,, 이미 프로젝트는 끝났지만 다른 팀원 덕분에 좋은 라이브러리를 알게된 게 럭키비키다~~~ 🍀
참고자료
'Frontend > Next.js' 카테고리의 다른 글
Next.js(14버전)- SSE 실시간 알림 구현 (0) | 2025.01.31 |
---|---|
Next.js - Auth.js( NextAuth.js) 를 이용한 Keycloak과의 인증 처리 과정 (0) | 2024.09.24 |
Next.js - next.js에서 session 활용하기 (0) | 2024.09.23 |
Next.js - Data fetching (React vs Next.js) (0) | 2024.02.11 |
Next.js 14버전 - Layouts,Metadata,Dynamic Routes (0) | 2024.02.08 |