[Javascript] custom hook과 IntersectionObserver API 로 무한 스크롤 구현하기

BinaryWoo_dev·2023년 4월 16일
0

React

목록 보기
1/8
post-thumbnail

서론


풀스택과 관련된 어떤 강의를 시청하면서 custom hook을 생성하여 IntersectionObserver() 생성자와 함께 사용하여 50개 이상의 메세지 컨텐츠 데이터들을 특정 개수 단위로 잘라서 스크롤을 내릴 때마다 호출되도록 하는 일명 무한 스크롤(Infinite scroll) 기능을 구현해봤는데, 너무 신기해서 그 원리와 방법을 여기에 기록해보고자 한다.

custom hook 이란?

용어 그대로 자신만의 hook 함수를 말한다. 여기서 hook이란 React 16.8 부터 사용되는 그 hook을 뜻하며 React 공식 문서에 나와있는 개념적 의미는 다음과 같다.

"class를 작성하지 않고도 state와 다른 React 기능을 사용할 수 있게 해주는 함수"

이 포스트 주제는 무한 스크롤 기능 구현에 대한 방법이기 때문에 hook에 대한 자세한 설명은 React 공식 문서를 참고하도록 하자. (hook에 대해 자세히 알아보기)

Intersection Observer API

IntersectionObserver 인터페이스대상 요소상위 요소, 또는 대상 요소최상위 문서의 뷰포트가 서로 교차하는 영역이 달라지는 경우 이를 비동기적으로 감지할 수 있는 수단을 제공한다고 한다.
IntersectionObserver가 생성되면 루트 내에서 설정된 가시성 비율이 자신의 주시 대상 중에서 나타나는지 감지하기 시작다. 한 번 생성한 이후에는 설정을 변경할 수 없으므로, 이미 생성된 감지기 객체는 지정했던 가시성 비율의 감지에만 사용할 수 있다. 그래도 하나의 감지기가 다수의 대상을 한꺼번에 주시할 수 있다.

속성

Intersection Observer 의 속성은 크게 .root, .rootMargin, .thresholds 세 가지 종류가 있다. 그러나 이번 포스팅에서는 다루지 않기 때문에 각 속성들에 대한 상세 설명들은 공식 문서 링크를 참조하도록 한다.(속성에 대해 자세히 알아보기)

메서드

observe() : 주어진 대상 요소를 주시한다. (<--> disconnect())
disconnect() : 모든 대상의 주시를 해제한다. (<--> observe())
takeRecords() : 모든 주시 대상에 대한 IntersectionObserverEntry 배열을 반환한다.
unobserve() : 특정 대상 요소에 대한 주시를 해제한다.

예제 코드

var intersectionObserver = new IntersectionObserver(function(entries) {
  // intersectionRatio가 0이라는 것은 대상을 볼 수 없다는 것이므로
  // 아무것도 하지 않음
  if (entries[0].intersectionRatio <= 0) return;

  loadItems(10);
  console.log('새 항목 불러옴');
});
// 주시 시작
intersectionObserver.observe(document.querySelector('.scrollerFooter'));

본론


코드 작성

custom hook 함수 생성

로직

  1. 스크롤의 가장 마지막 위치에 있는 요소(targetEl)를 파라미터로 받는다.
  2. 무한스크롤을 실행할지 여부를 알려주는 로직을 갖고 있는 함수 getObserver() 을 생성한다.
    2-1. observerRef.current 가 유효하지 않을 경우, observerRef.current 값을 IntersectionObserver 생성자로 할당한다.
    2-2. IntersectionObserver 의 첫 번째 인자(Callback)에서 파라미터로 받은 entries(주시 대상들) 중, 실제로 감지된 인자가 1개라도 있을 경우, intersecting state를 true로 setState 한다.
    2-3. 이 함수는 observerRef.current 값이 변경될 때만 새로 정의되도록 한다.
  3. userEffect() 구문에서 useRef hook 함수로 참조되고 있는 targetEl 가 DOM에 존재할 경우, getObserver() 함수의 observe() 메서드 파라미터로 할당하여 targetEl 요소를 주시하도록 한다.
    (useEffect 구문은 targetEl 요소 참조값이 변경될 경우에만 새로 실행되도록 한다.)
+import { useCallback, useEffect, useRef, useState } from 'react';

+const useInfiniteScroll = (targetEl) => {
+	const observerRef = useRef(null);
+	const [intersecting, setIntersecting] = useState(false);
+
+	// MsgList 컴포넌트가 매번 재렌더링될 때마다 호출되는 것을 방지하기 위해 useCallback으로 선언함
+	const getObserver = useCallback(() => {
+		if (!observerRef.current) {
+			// entries : 감시되고 있는 모든 타겟들
+			observerRef.current = new IntersectionObserver((entries) =>
+				setIntersecting(entries.some((entry) => entry.isIntersecting))
+			);
+		}
+		return observerRef.current;
+	}, [observerRef.current]);
+
+	useEffect(() => {
+		if (targetEl.current) getObserver().observe(targetEl.current);
+
+		return () => {
+			getObserver().disconnect();
+		};
+	}, [targetEl.current]);
+
+	return intersecting;
+};
+
+export default useInfiniteScroll;

메세지 컨텐츠 API 함수

로직

  1. 클라이언트로부터 cursor 라는 쿼리를 전달 받는다. (cursor: 현재 클라이언트에 존재하는 데이터들 중 마지막 데이터 id)
  2. 전체 메세지 데이터(msgs) 를 기준으로 클라이언트에 보내줘야할 데이터들 중 가장 첫 번째 데이터 index를 찾는다. (msgs.findIndex(...) + 1)
  3. 2번에서 찾은 첫 번째 데이터 index를 가지고 전체 메세지 데이터(msgs)를 slice 메서드를 이용하여 자른 다음 res.send() 로 클라이언트에 전달해준다.
import { readDB, writeDB } from '../dbController.js';
 import { v4 } from 'uuid';
 const getMsgs = () => readDB('messages');
 const setMsgs = (newMsgs) => writeDB('messages', newMsgs);
 const messagesRoute = [
 	{
 		// 전체 Messages GET
 		method: 'get',
 		route: '/messages',
-		handler: (req, res) => {
+		handler: ({ query: { cursor = '' } }, res) => {
 			const msgs = getMsgs();
-			res.send({ payload: msgs });
+			const fromIndex = msgs.findIndex((msg) => msg.id === cursor) + 1;
+			res.send({ payload: msgs.slice(fromIndex, fromIndex + 15) });
 		}
 	},
 	{

메세지 리스트 컴포넌트

로직

  1. useEffect 구문에서 intersecting && hasNext 조건문이 true 일 때, 메세지 데이터 리스트를 조회하는 API 핸들러 함수를 호출한다.

    • intersectiong : 무한 스크롤 감지 요소(fetchMoreEl) 가 observer에 의해 감지 되었는지 여부
    • hasNext : 무한 스크롤 이벤트로 인해 추가적으로 조회할 데이터가 남아있는지 여부
  2. 메세지 컨텐츠 리스트 데이터를 조회하는 API의 파라미터에 cursor 를 추가하여, 스크롤 다음 호출될 데이터 리스트의 첫 번째 데이터 index 를 객체의 id 라는 키 값으로 전달한다.

  3. API return data로 받은 추가 데이터를 setMessages(...이전 메세지들, return 받은 메세지들) 코드를 통해 메세지 리스트 state를 업데이트한다.

-import React, { useState } from 'react';
+import React, { useRef, useState } from 'react';
 import MsgItem from './MsgItem';
 import MsgInput from './MsgInput';
 import { useEffect } from 'react';
 import fetcher from '../fetcher';
 import { useRouter } from 'next/router';
+import useInfiniteScroll from '../hooks/userInfiniteScroll';
 
 // 메세지 리스트 컴포넌트
 const MsgList = () => {
 	const [messages, setMessages] = useState([]);
 	const [editingId, setEditingId] = useState(null);
+	const [hasNext, setHasNext] = useState(true);
 	const { query } = useRouter();
+	const fetchMoreEl = useRef(null);
+	const intersecting = useInfiniteScroll(fetchMoreEl);
 
 	const userId = query?.userId || query?.userid || ''; // query의 userId 대/소문자 구분 예외처리
 
 	const onCreate = async (text) => {
 		const newMsg = await fetcher('post', '/messages', {
 			text,
 			userId
 		});
 		if (!newMsg) return; // 저장 API await 호출이 실패할 경우, 해당 스코프 구문 실행 종료.
 		setMessages([newMsg, ...messages]);
 	};

   (...)
 
 	// useEffect 내부에서는 async/await 을 직접 사용할 수 없기 때문에, 따로 비동기 함수를 생성해야한다.
 	const getInitMessages = async () => {
 		try {
-			const response = await fetcher('get', '/messages');
+			const response = await fetcher('get', '/messages', {
+				params: { cursor: messages[messages.length - 1]?.id || '' }
+			});
 			if (response.status >= 200 && response.status < 300) {
-				setMessages(response.data.payload);
+				if (response?.data?.payload.length === 0) {
+					setHasNext(false);
+					return;
+				}
+				setMessages((prevMessages) => [
+					...prevMessages,
+					...response.data.payload
+				]);
 			} else {
 				throw Error(response.status);
 			}
 		} catch (error) {
 			console.log('Error =>', error);
 		}
 	};
 
+
 	useEffect(() => {
-		getInitMessages();
-	}, []);
+		if (intersecting && hasNext) getInitMessages();
+	}, [intersecting]);
 
 	const isMessages = messages && messages.length > 0;
 	return (
 		<>
 			{userId && <MsgInput mutate={onCreate} />}
 			<ul className='messages'>
 				{isMessages &&
 					messages.map((x) => (
 						<MsgItem
 							{...x}
 							key={x.id}
 							startEdit={() => setEditingId(x.id)}
 							onUpdate={onUpdate}
 							onDelete={() => onDelete(x.id)}
 							isEditing={editingId === x.id}
 							myId={userId}
 						/>
 					))}
 			</ul>
+			{/* #무한 스크롤 구현 (API 호출 여부 감지용 Element) */}
+			<div ref={fetchMoreEl} />
 		</>
 	);
 };
 
 export default MsgList;

결과

스크롤 시, API 추가 호출이 동작하면서 무한 스크롤 기능이 정상 작동한다.

결론


  • Intersection Observer API의 기본적인 개념을 제대로 파악하면 충분히 무한 스크롤 구현이 가능하다.
  • 범용적으로 사용될 수 있는 함수같은 경우, customHook 으로 사용해보도록 하자.
profile
매일 0.1%씩 성장하는 Junior Web Front-end Developer 💻🔥

0개의 댓글