📜 서론
내가 만든 서비스인 Re|view
(제작 도중 올평
에서 이름을 바꿨다)의 피드 페이지를 열면 위와 같은 리뷰카드들이 주루룩 나열된다. 유저들이 증가하면 리뷰도 덩달아 많아질 것이기 때문에 렌더링해야하는 리뷰카드의 개수도 엄청 많아질 것이다. 하지만 당연히 그 많은 컨텐츠를 한번에 서버에서 로드해서 렌더링까지 할 수는 없는 노릇이다. 무한 스크롤이 필요한 때다.
🔍 문제 정의
Tanstack Query(React Query) 쓰는 김에 같은 곳에서 제작한 Tanstack Virtual이랑 Intersection Observer API 조합하면 구현 끝 아님?
단순히 생각하자면 위처럼 생각할 수 있다. 하지만 막상 구현하려다 보니 이것 이외에도 생각해야 할 것이 생겼다.
다음과 같은 경우는 어떻게 해야할까?
- A 유저가 메인 화면에 들어와서 최신 게시물 10개(1페이지)를 로드함
- B 유저가 새 게시물을 업로드함
- A 유저가 스크롤을 해서 다음 게시물 10개(두번째 페이지)를 로드함. 이 다음 10개의 게시물 중 첫번째 게시물이 2번에 의해 첫번째 페이지의 마지막 게시물이 로드됨
아무 생각없이 그냥 구현한다면 이런 식으로 중복된 데이터가 로드되는 문제가 발생할 수 있다.
💡 해결 아이디어: 타임스탬프 기반 데이터 로드
이 이슈를 해결하는 방법으로 나는 게시물이 생성된 타임스탬프를 기준으로 다음 페이지를 로드하는 방식을 채택했다. 클라이언트에서 페이지 번호 대신, 첫 번째 페이지의 가장 마지막 게시물의 타임스탬프를 서버에 전달하고, 그보다 오래된 게시물만 가져오도록 요청하는 방법이다. 이렇게 하면 중복 문제가 발생하지 않을 것이다.
🌐 서버 측 구현
https://gist.github.com/Baejw0111/e7be57db7920ff11aa939a0c1666ccca
최신 리뷰 조회
최신 리뷰 조회. GitHub Gist: instantly share code, notes, and snippets.
gist.github.com
/**
* 최신 리뷰 목록 조회
* @returns {string[]} 리뷰 ID 리스트
*/
export const getLatestFeed = asyncHandler(async (req, res) => {
const { lastReviewId: lastReviewAliasId } = req.query;
// 클라이언트에서 마지막으로 받은 리뷰 ID의 업로드 시간을 통해 다음 리뷰 목록 조회
// 마지막 리뷰 ID가 없으면 처음 요청을 보내는 것이므로, 최근 업로드된 리뷰 20개 조회
const lastReviewUploadTime = lastReviewAliasId
? (await ReviewModel.findOne({ aliasId: lastReviewAliasId }))?.uploadTime ||
new Date()
: new Date();
const reviewList = await ReviewModel.find(
{ uploadTime: { $lt: lastReviewUploadTime } },
{ aliasId: 1, uploadTime: 1 } // 필요한 필드만 명시
)
.sort({ uploadTime: -1 })
.limit(20);
const reviewIdList = reviewList.map((review) => review.aliasId);
res.status(200).json(reviewIdList);
}, "최신 리뷰 조회");
위와 같은 식으로 클라이언트 측에서 마지막으로 받은 페이지의 마지막 리뷰의 ID를 쿼리 파라미터로 서버에서 받아 처리하도록 한다. 나머지는 위에서 말한 로직대로 처리하면 끝이다.
🖥️ 클라이언트 측 구현
📌 API 통신 함수 제작
https://gist.github.com/Baejw0111/73eb4c18794e0bb27704eaee3cee7953
서버로부터 최신 리뷰 목록 가져오기
서버로부터 최신 리뷰 목록 가져오기. GitHub Gist: instantly share code, notes, and snippets.
gist.github.com
/**
* 최신 리뷰 목록 조회 함수
* @param lastReviewId 마지막 리뷰 ID
* @returns 리뷰 목록
*/
export const fetchLatestFeed = async (
lastReviewId: string
): Promise<string[]> => {
const response = await generalApiClient.get(`/review/latest`, {
params: { lastReviewId },
});
return response.data;
};
이런 식으로 쿼리 파라미터로 lastReviewId
를 보내는 함수를 만들어 놓고 쓴다. 더 이상의 설명은 생략한다.
참고로 generalApiClient
는 이전 포스팅인 axios 인터셉터 글에서 제작했었던 axios 인스턴스다.
[Re|view] 5. 카카오 로그인 적용기 - Axios 인터셉터로 토큰 자동 갱신 구현하기
🧩 구현해야 할 로직저번 포스팅에서 말한 것처럼 인증이 필요한 api 요청(리뷰 작성, 댓글 달기 등등) 시 accessToken이 만료되었을 경우 토큰을 자동 갱신해서 응답을 받는 로직을 구현해야 한다.
wallbreaker.tistory.com
📌 useIntersectionObserver
훅 구현
우선 아래와 같이 Intersection Observer
API를 사용해 DOM 요소가 뷰포트 내에 들어올 때 콜백 함수를 실행할 수 있도록 하는 훅을 만들어 줬다. 이 훅의 역할에 대해 쉽게 설명하고 넘어가자면, 페이지 내의 특정 DOM 요소가 뷰포트(사용자가 보는 화면) 내에 들어왔는 지 확인할 수 있는 센서를 달았다고 생각하면 된다.
https://gist.github.com/Baejw0111/c46d8c98d596ec526a06edd4fca605ef
useIntersectionObserver
useIntersectionObserver. GitHub Gist: instantly share code, notes, and snippets.
gist.github.com
import { useEffect, useRef } from "react";
interface UseIntersectionObserverOptions<T> {
callback: () => void;
dependency?: T[];
threshold?: number;
}
/**
* 지정된 요소가 뷰포트에 들어올 때 콜백 함수를 호출하는 훅
* @param callback - 요소가 뷰포트에 들어올 때 호출될 콜백 함수
* @param dependency - 관찰할 요소가 속한 목록. 목록이 갱신될 때 관찰할 요소도 바뀐다.
* @param threshold - 콜백 실행을 트리거할 요소의 가시성 비율
* @returns 관찰될 요소에 대한 ref
*/
export default function useIntersectionObserver<T>({
callback,
dependency,
threshold = 0.1,
}: UseIntersectionObserverOptions<T>) {
const elementRef = useRef<HTMLDivElement | null>(null); // 관찰할 요소의 DOM 요소를 감시하기 위해 직접 접근해야 하므로 ref를 사용한다.
useEffect(() => {
const observer = new IntersectionObserver(
// entries는 관찰 대상 요소의 배열. 이 훅은 하나의 관찰 대상만 관찰하므로 배열의 길이는 항상 1이다.
(entries) => {
entries.forEach((entry) => {
// 관찰 대상이 뷰포트에 들어오면 콜백 함수를 호출한다.
if (entry.isIntersecting) {
callback();
}
});
},
{ threshold }
);
const observingElement = elementRef.current; // 관찰할 요소의 DOM
// 관찰할 요소의 DOM이 존재하면 관찰한다.
if (observingElement) {
observer.observe(observingElement);
}
// 컴포넌트가 언마운트되면 관찰을 중단한다.
return () => {
observer.disconnect();
};
}, [dependency, callback, threshold]);
return elementRef; // 관찰할 요소에 대한 ref를 반환한다.
}
이제 이 훅을 페이지에서 로드된 가장 마지막 페이지의 끝부분에 있는 리뷰 카드에 연결하면 된다. 당연히 훅에는 다음 페이지를 가져오는 함수를 콜백 함수로 등록해야 한다. 그러면 훅이 연결된 리뷰 카드가 뷰포트 내에 진입, 즉 화면에 노출됐을 때 다음 페이지를 로드하게 된다.
📌 CardList
컴포넌트 내에 useIntersectionObserver
적용하기
CardList
는 리뷰가 담긴 카드들을 렌더링하는 컴포넌트다. 여기서 아래와 같이 로드한 페이지의 마지막에서 두번째 행에 useIntersectionObserver
훅을 적용한다. 가장 마지막 행에 적용해도 되지만 다음 페이지를 좀 더 일찍 가져오게 하면 사용자가 기다리는 시간이 좀 더 줄을 것 같아서 이렇게 했다.
https://gist.github.com/Baejw0111/cec9ff78715997f44f918e51e6e9c6a2
리스트에 useIntersectionObserver 적용
리스트에 useIntersectionObserver 적용. GitHub Gist: instantly share code, notes, and snippets.
gist.github.com
// ...기존 코드 생략...
/**
* @description 카드 목록을 반환하는 컴포넌트
* @param idList 카드 ID 리스트
* @param callback 무한 스크롤 콜백 함수
* @param cardType 카드 타입(리뷰 카드 또는 유저 프로필 카드)
* @returns 카드 목록
*/
export default function CardList({
idList,
callback,
cardType,
}: {
idList: string[];
callback: () => void;
cardType: "review" | "userProfile";
}) {
const breakpoint = useTailwindBreakpoint(); // 현재 화면 너비
const listRef = useRef<HTMLDivElement>(null);
const gridColumnCount = {
xs: 1,
sm: 2,
md: 2,
lg: 3,
xl: 3,
"2xl": 4,
}; // 현재 화면 너비에 따른 그리드 칼럼 수
const totalWindowCount = Math.ceil(
infoList.length / gridColumnCount[breakpoint]
); // 가상화 윈도우의 총 개수
// 화면에 표시되는 리뷰 카드 그리드의 각 행을 가상화 윈도우로 관리
const virtualizer = useWindowVirtualizer({
// 가상화 윈도우의 총 개수는 (리뷰 개수 / 현재 화면 너비에 따른 그리드 칼럼 수)가 된다.
count: totalWindowCount,
estimateSize: cardType === "review" ? () => 240 : () => 192, // 각 행의 예상 높이
gap: 24, // 행 간의 간격
overscan: 2, // 미리 렌더링할 행 개수
});
const infiniteScrollRef = useIntersectionObserver<VirtualItem>({
callback,
dependency: virtualizer.getVirtualItems(),
});
return (
<>
// ...기존 코드 생략...
<div
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6"
key={item.index}
ref={
// 마지막 페이지의 뒤에서 두번째 윈도우가 뷰포트에 표시될 시 무한 스크롤 콜백 함수 트리거
item.index === totalWindowCount - 2
? infiniteScrollRef
: null
}
>
// ...기존 코드 생략...
</div>
// ...기존 코드 생략...
</>
);
}
여기서 virtualizer
라던지, gridColumnCount
같이 주제와 상관 없는 것들이 있는데, 이것들에 대해서는 다음 포스팅에서 설명할 예정이다. 리스트 가상화 기법과 관련된 것들이다. 일단 각 페이지의 끝부분에 다음 페이지를 가져오는 트리거를 설정했구나 라고 생각하면 되겠다.
📌 Feed
페이지에 useInfiniteQuery
훅 적용하기
Tanstack Query에는 흔히들 쓰는 useQuery
훅 뿐만 아니라 useInfiniteQuery
라는 훅도 있다. 이름에서 알 수 있는 것처럼 지금 구현하려 하는 무한 스크롤을 구현할 때 쓰라고 만든 훅이다. 공식 문서에서 사용 방법을 쉽게 배울 수 있다.
https://gist.github.com/Baejw0111/23406191a15ea3aa4aef4446a018ef4e
Feed 페이지에서 useInfiniteQuery 쓰기
Feed 페이지에서 useInfiniteQuery 쓰기. GitHub Gist: instantly share code, notes, and snippets.
gist.github.com
import CardList from "@/widgets/CardList";
import PageTemplate from "@/shared/original-ui/PageTemplate";
import { useLoaderData } from "react-router";
import { useInfiniteQuery } from "@tanstack/react-query";
import { fetchLatestFeed, fetchPopularFeed } from "@/api/review";
import { useLocation } from "react-router";
export default function Feed() {
const { pathname } = useLocation();
const initialData = useLoaderData() as string[];
const getQueryFn = (path: string) => {
switch (path) {
case "/":
case "/latest":
return ({ pageParam }: { pageParam: string }) =>
fetchLatestFeed(pageParam);
case "/popular":
return ({ pageParam }: { pageParam: string }) =>
fetchPopularFeed(pageParam);
}
};
const {
data: feedData,
isSuccess,
fetchNextPage,
} = useInfiniteQuery({
queryKey: ["feed", pathname === "/popular" ? "popular" : "latest"],
initialPageParam: "",
queryFn: getQueryFn(pathname),
getNextPageParam: (lastPage) => {
if (lastPage.length < 20) return undefined;
return lastPage[lastPage.length - 1];
},
initialData: initialData
? { pages: [initialData], pageParams: [""] }
: undefined,
enabled: !!pathname,
});
return (
<PageTemplate>
{isSuccess && (
<CardList
idList={feedData.pages.flatMap((page) => page)}
callback={fetchNextPage}
cardType="review"
/>
)}
</PageTemplate>
);
}
접속한 url의 pathname
에 따라 최신글, 또는 인기글을 가져오도록 구현했다.
여기서 getNextPageParam
은 다음 페이지를 가져올 때 사용할 파라미터를 넘겨주는 함수다. 가장 마지막으로 가져온 페이지인 lastPage
의 길이가 20보다 작은 경우는 가장 오래된 마지막 리뷰 데이터들을 가져온 것이기 때문에 undefined
를 반환해 데이터 로딩을 중지한다.
🎉 결과
문제 없이 무한 스크롤이 구현되었다.
⚠️ 아직 남은 이슈
무한 스크롤은 잘 구현되었지만, 구현 후 다른 이슈를 맞닥뜨리게 되었다. 무한 스크롤을 통해 가져온 모든 데이터를 렌더링해서 생기는 문제인데, 이에 대해서는 다음 포스팅에서 다루도록 하겠다.
'프로젝트 > Re|view' 카테고리의 다른 글
[Re|view] 10. 클라우드 서비스 연동 및 Github Actions로 CI/CD 구축하기 (0) | 2025.02.11 |
---|---|
[Re|view] 9. 리스트 가상화 기법으로 리뷰 리스트 페이지 최적화하기(feat. Tanstack Virtual) (0) | 2025.02.01 |
[Re|view] 7. FSD 구조 적용 실패기 (0) | 2025.01.28 |
[Re|view] 6. 카카오 로그인 적용기 - Redux Toolkit으로 사용자 정보 관리하기 & 로그아웃 (1) | 2024.09.04 |
[Re|view] 5. 카카오 로그인 적용기 - Axios 인터셉터로 토큰 자동 갱신 구현하기 (0) | 2024.09.03 |