풀스택과 관련된 어떤 강의를 시청하면서 custom hook
을 생성하여 IntersectionObserver()
생성자와 함께 사용하여 50개 이상의 메세지 컨텐츠 데이터들을 특정 개수 단위로 잘라서 스크롤을 내릴 때마다 호출되도록 하는 일명 무한 스크롤(Infinite scroll) 기능을 구현해봤는데, 너무 신기해서 그 원리와 방법을 여기에 기록해보고자 한다.
custom hook 이란?
용어 그대로 자신만의 hook 함수를 말한다. 여기서 hook이란 React 16.8 부터 사용되는 그 hook을 뜻하며 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 함수 생성
getObserver()
을 생성한다.observerRef.current
가 유효하지 않을 경우, observerRef.current
값을 IntersectionObserver 생성자로 할당한다.intersecting
state를 true로 setState 한다.userEffect()
구문에서 useRef
hook 함수로 참조되고 있는 targetEl 가 DOM에 존재할 경우, getObserver()
함수의 observe()
메서드 파라미터로 할당하여 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 함수
cursor
라는 쿼리를 전달 받는다. (cursor: 현재 클라이언트에 존재하는 데이터들 중 마지막 데이터 id)msgs
) 를 기준으로 클라이언트에 보내줘야할 데이터들 중 가장 첫 번째 데이터 index를 찾는다. (msgs.findIndex(...) + 1
)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) });
}
},
{
메세지 리스트 컴포넌트
useEffect 구문에서 intersecting && hasNext
조건문이 true 일 때, 메세지 데이터 리스트를 조회하는 API 핸들러 함수를 호출한다.
intersectiong
: 무한 스크롤 감지 요소(fetchMoreEl) 가 observer에 의해 감지 되었는지 여부hasNext
: 무한 스크롤 이벤트로 인해 추가적으로 조회할 데이터가 남아있는지 여부메세지 컨텐츠 리스트 데이터를 조회하는 API의 파라미터에 cursor
를 추가하여, 스크롤 다음 호출될 데이터 리스트의 첫 번째 데이터 index 를 객체의 id
라는 키 값으로 전달한다.
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 추가 호출이 동작하면서 무한 스크롤 기능이 정상 작동한다.
customHook
으로 사용해보도록 하자.