댓글이 많은 커뮤니티 게시판에서 가장 중요한 건 무엇일까?
바로 부드럽고 끊김 없는 UX이다.
한 번에 모든 댓글을 가져오면 성능상 비효율적이고, 페이지 버튼을 두는 방식은 요즘 유저가 원하는 사용자 경험과 거리가 멀다.
그래서 나는 무한 스크롤 방식의 댓글 조회를 구현하기로 했다.
SQL및 API 설계와 SWR Infinite를 통한 클라이언트 요청, 그리고 Intersection Observer를 통한 자동 로딩까지의 과정을 단계별로 정리했다.
무한 스크롤 방식은 다음 3가지 핵심 구성 요소로 나뉜다:
무한 스크롤을 구현하려면 단순한 LIMIT, OFFSET 방식은 성능상 불리하다.
OFFSET은 데이터가 많을수록 느려지고, 중간에 데이터가 추가되면 페이지가 꼬일 수 있다.
따라서 나는 커서 기반 페이지네이션 방식을 사용했다.
커서 기반 페이지네이션은 "어디까지 봤는지 표시"해 두고, 그 다음부터 데이터를 가져오는 방식
예를 들어 댓글 목록을 불러올 때, 마지막 댓글의 시간이나 ID를 기억해두고 그 이후 댓글만 불러온다
이 방식은 데이터가 많아도 빠르고, 중간에 댓글이 추가돼도 순서가 꼬이지 않는다.
정렬 기준은 최신순과 좋아요(갯수)순 2가지이므로,
created_at(생성일), like_count(좋아요 개수), comment_id(댓글 ID)를 조합하여 데이터를 안정적으로 가져오도록 했다.
구현한 쿼리 조건은 아래와 같다
// 최신순일 때 기준값 설정
const pageConditionAt = lastAt
? moment.tz(lastAt, 'Asia/Seoul').tz('UTC').format('YYYY-MM-DD HH:mm:ss')
: "3000-01-01 00:00:00";
// 인기순일 때 기준값 설정
const pageConditionLikeCount = lastLikeCount ? Number(lastLikeCount) : 100000000;
const r = await debateQuery.getOpinionList(
Number(discussId),
sort,
pageConditionAt,
pageConditionLikeCount,
Number(lastCommentId)
);
const condition = sort === 'new' ?
/*sql */
`and c.created_at ${lastCommentId ? "<=" : "<"} '${pageConditionAt}' ${pageConditionAt === '3000-01-01 00:00:00' ? "" : lastCommentId ? `and c.comment_id < '${lastCommentId}'` : ""}
and c.parent_id IS NULL
ORDER BY c.created_at desc, c.comment_id desc ` :
/*sql */
`AND (SELECT COUNT(*) FROM SPRG.COMMENTS_LIKE WHERE comment_id = c.comment_id AND is_liked = 1)<=${pageConditionLikeCount}
and c.created_at<'${pageConditionAt}'
AND c.parent_id IS NULL
ORDER BY like_count DESC, created_at desc`;
클라이언트에서는 SWR의 useSWRInfinite 훅을 활용했다.
각 페이지의 마지막 댓글 정보를 이용해 다음 요청 URL을 구성하는 방식이다.
import useSWRInfinite from "swr/infinite";
const PAGE_SIZE = 15;
export const useInfiniteCommentList = ({ discussId, sort = "new" }) => {
const { data: session }: any = useSession();
const getKey = (pageIndex, previousPageData) => {
if (!session?.user?.email) return null;
if (previousPageData && previousPageData.data.length < PAGE_SIZE) return null;
if (pageIndex === 0) {
return `/api/comments/${discussId}?email=${session.user.email}&sort=${sort}`;
}
const lastItem = previousPageData.data[previousPageData.data.length - 1];
if (!lastItem) return null;
return `/api/comments/${discussId}?email=${session.user.email}&sort=${sort}&lastAt=${lastItem.created_at}&lastLikeCount=${lastItem.like_count}&lastCommentId=${lastItem.comment_id}`;
};
const { data, setSize, size, isValidating } = useSWRInfinite(getKey, fetcher, {
revalidateOnFocus: false,
});
const allComment = data?.map((d) => d.data).flat() || [];
const isLoadingMore = isValidating && data && typeof data[size - 1] === "undefined";
const isReachingEnd = data && data[data.length - 1]?.data?.length < PAGE_SIZE;
return {
commentList: allComment,
isLoadingMore,
isReachingEnd,
loadMore: () => setSize(size + 1),
};
};
getKey 함수에서 이전 페이지 마지막 댓글 정보를 커서로 활용한다
다음 데이터를 받아오려면 setSize(size + 1)로 호출하면 된다
데이터가 더 이상 없을 때는 자동으로 요청을 멈춰야 하며 너무 많은 연속 호출을 막기 위해 isReachingEnd 상태를 클라이언트 조건에 반드시 넣어야 한다.
마지막 댓글 요소가 뷰포트에 보이면 자동으로 다음 페이지를 요청해야 한다
IntersectionObserver를 사용했다.
IntersectionObserver는 브라우저에서 제공하는 생성자 함수이며,
이 함수로 만든 인스턴스는 특정 DOM 요소가 뷰포트 안에 들어오는지 감지할 수 있게 해주는 객체다
const observeLastItem = useCallback((node: HTMLDivElement | null) => {
if (isLoadingMore || isReachingEnd) return;
if (observerRef.current) observerRef.current.disconnect();
observerRef.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) {
loadMore(); // 마지막 요소가 보이면 loadMore 실행
}
});
if (node) observerRef.current.observe(node);
}, [isLoadingMore, isReachingEnd, loadMore]);
//댓글 리스트 컴포넌트...
<div ref={observeLastItem} />
무한 스크롤 기능을 구현하면서 느낀 건 단순히 UI를 부드럽게 만드는 것 이상으로 설계적인 고민이 많이 필요한 작업이라는 점이었다.
SQL 쿼리 자체를 커서 기반으로 설계하지 않으면 무한 스크롤처럼 페이지가 연속적으로 이어지는 구조에서는 중복이나 누락 문제가 쉽게 발생할 수 있다. 특히 created_at, comment_id, like_count 같은 필드를 적절히 조합해서 커서 조건을 만드는 것이 꽤 중요했다.
클라이언트에서 useSWRInfinite 훅을 사용해 데이터를 요청했는데, 이 방식은 페이지네이션 구조와 궁합이 잘 맞았고 무엇보다 코드가 간결하게 유지되는 점이 마음에 들었다. 조건에 따라 key를 다르게 만들어서 필요한 요청만 보낼 수 있으니, 불필요한 리렌더링이나 리패칭도 막을 수 있었고, 본문에는 담지 않았지만 Optimistic Update도 가능했다