라이북러리 프로젝트에서 도서 검색에 대한 결과 값을 무한 스크롤로 보이도록 하였다. 이를 구현하는 과정에서 겪은 문제를 정리해보았다.
🔴 데이터를 불러오면 스크롤이 최상단으로 가는 이유는?
스크롤을 내려서 데이터를 더 불러올 때 해당 스크롤 위치에서 데이터만 더 불러오고 싶은데 자꾸 맨 위로 다시 올라가는 문제가 발생하였다.
위 문제에 대한 코드를 살펴보자.
type fetchStateType = 'idle' | 'loading' | 'fetched' | 'error'
function Search() {
const [fetchState, setFetchState] = useState<fetchStateType>('idle')
// 검색에 대한 결과 데이터 패치
const fetchNewData = useCallback(
(isSearchAgain: boolean) => {
setFetchState('loading')
fetchSearchBook(
{
search: word,
startIndex,
},
{
onSuccess: (res) => {
setBooks((prev) => (isSearchAgain ? res : [...prev, ...res]))
if (res.length > 0) setStartIndex((prev) => prev + 1)
setFetchState('fetched')
setSearchWord(word)
},
onError: (error) => {
console.error(error)
setFetchState('error')
},
}
)
},
[word, startIndex, searchWord]
)
// 검색어 또는 스크롤을 감지하여 검색 버튼 없이 지동으로 패치
useEffect(
function fetchSearchBookAPI() {
if (isOpenSearch) {
const needToSearchAgain = word.trim() !== searchWord.trim()
if (needToSearchAgain) {
const debounceFetch = debounce(() => fetchNewData(needToSearchAgain), 500)
debounceFetch()
return () => {
debounceFetch.cancel()
}
} else if (isIntersecting) {
fetchNewData(needToSearchAgain)
}
}
},
[isOpenSearch, word, isIntersecting]
)
return (
...
{fetchState === 'fetched' && ( // ✅
<div className='search-book'>
<BookCardList books={books} isAddRoute />
</div>
)}
...
{fetchState === 'loading' && <div>Loading</div>}
)
fetchNewData
함수에서 도서 데이터를 불러온다. 데이터가 패치되기 전과 성공/실패 여부에 따라 fetchState
가 변경되며, fetchState
가 fetched인 경우에 도서 목록을 화면에 띄운다.
fetchState
값이 바뀌게 되면 컴포넌트는 리렌더링되어 화면을 다시 그리게 된다. 스크롤을 내릴 때마다 fetchState
값이 다시 loading으로 바뀌고 결과에 따라 fetched 또는 error로 바뀌게 된다. 따라서 리렌더링 과정에서 스크롤이 최상단으로 올라가는 것이다.
이러한 문제는 아주 간단히 해결할 수 있다. 바로 반환하는 요소의 조건인 fetchState === 'fetched'
을 제거하면 된다. BookCardList
컴포넌트 안에서 books의 개수가 0보다 큰 경우에만 도서 리스트를 그리도록 했기 때문에 해당 조건은 필요하지 않다.
function BookCardList({ books, sort = 'wrap', isAddRoute = false }: BookCardListProps) {
return (
<ul className={`book-card-list ${sort}`}>
{books &&
books.map(({ isbn13, isbn, title, author, cover }: GettingBookType) => (
...
</ul>
)
}
코드를 수정하고 다시 확인해보자. 이제 스크롤의 위치가 유지되면서 데이터를 더 가져올 때 로딩 요소도 잘 보이게 된다.
return (
...
<div className='search-book'>
<BookCardList books={books} isAddRoute />
</div>
...
{fetchState === 'loading' && <div>Loading</div>}
)
🔴 observe 요소가 감지되지 않는 이유는?
스크롤을 내리는데 더이상 스크롤이 감지되지 않아 새로운 데이터가 불러와지지 않는 문제가 발생하였다.
이 문제를 해결하기 위해선 일단 스크롤이 어떻게 감지되는지 다시 살펴볼 필요가 있다.
무한스크롤을 구현하기 위해 useIntersectionObserver
커스텀 훅을 사용하였다. 훅을 자세히 살펴보면 다음과 같다.
import { RefObject, useEffect, useState } from 'react'
function useIntersectionObserver(ref: RefObject<Element | null>, options: IntersectionObserverInit = { threshold: 0 }) {
const [entries, setEntries] = useState<IntersectionObserverEntry[]>([])
useEffect(() => {
const observer = new IntersectionObserver(setEntries, options)
const currentRef = ref.current
if (currentRef) {
observer.observe(currentRef)
}
return () => {
if (currentRef) {
observer.unobserve(currentRef)
}
}
}, [ref, options])
return { isIntersecting: entries[0]?.isIntersecting }
}
export default useIntersectionObserver
스크롤이 ref
요소에 위치하면 데이터를 더 불러올 수 있도록 감지하고 threshold
는 ref의 임계값을 나타낸다. threshold
값이 0이라면 스크롤이 ref
의 상단 1px이라도 감지하면 observer
콜백을 실행하고, 1이라면 ref
의 최하단에 감지할 때 콜백을 실행한다. 0.5라면 ref
의 50%를 감지할 실행한다.
그렇다면 ref
요소가 어떻게 렌더링 되었는지 확인해보자.
function Search() {
...
const moreRef = useRef(null)
const { isIntersecting } = useIntersectionObserver(moreRef)
...
// 검색어 또는 스크롤을 감지하여 검색 버튼 없이 지동으로 패치
useEffect(
function fetchSearchBookAPI() {
if (isOpenSearch) {
const needToSearchAgain = word.trim() !== searchWord.trim()
if (needToSearchAgain) {
const debounceFetch = debounce(() => fetchNewData(needToSearchAgain), 500)
debounceFetch()
return () => {
debounceFetch.cancel()
}
} else if (isIntersecting) {
fetchNewData(needToSearchAgain)
}
}
},
[isOpenSearch, word, isIntersecting]
)
...
return isOpenSearch ? (
<div className='search'>
...
<div className='search-book'>
<BookCardList books={books} isAddRoute />
</div>
{books.length > 0 && <div ref={moreRef} />} // ✅
{fetchState === 'loading' && <div>Loading</div>}
</div>
) : null
}
{books.length > 0 && <div ref={moreRef} />}
에는 텍스트 요소나 스타일이 적용되어 있지 않기 때문에 width는 가로로 꽉 채워져있고 height는 0으로 적용되어 있다. 그러나 높이가 1px 이상이여야 threshold
가 0일 경우에도 감지가 되기 때문에 무한스크롤이 적용되지 않는 것이다.
따라서 moreRef
에 텍스트나 스타일을 추가해서 높이를 부여하면 문제를 해결할 수 있다.
{books.length > 0 && <div style={{ height: '1px' }} ref={moreRef} />}
🤔 그런데, height가 0이여도 처음 스크롤은 적용되는데?
이 문제의 원인이 브라우저 렌더링 과정에서 발생한다고 추측하였다.
브라우저 렌더링은 DOM 생성 ➡️ CSSOM 생성 ➡️ 렌더 트리 생성 ➡️ 레이아웃 ➡️ 페인트의 과정을 갖는다.
이 과정에서, 초기 렌더링 동안 요소의 스타일이 적용되기 전 기본 스타일이 잠깐 적용될 수 있다. 렌더 트리가 완전히 계산되기 전의 상태를 잠시 반영하여moreRef
에 높이가 주어지고 처음에는isIntersecting
이 true가 될 수 있다.
이후에는moreRef
높이가 0이 되므로isIntersecting
이 false가 된다.
🔴 원하는 검색 결과가 나오지 않는 이유?
예를 들어, 검색창에 "개발" 도서를 검색한 다음 해당 검색어를 지우고 "트로피컬 나이트" 도서를 검색하면 어떤 결과가 나올까? "개발"에 대한 결과는 사라지고 "트로피컬 나이트"에 대한 결과가 나타나길 기대하였다. 하지만 어떤 결과도 나오지 않았다.
개발자도구 Network를 통해 이런 이슈가 발생한 이유를 발견하였다.
"개발"을 검색하고 스크롤을 3번 내렸기 때문에 startIndex
의 값이 4가 되었다.
"트로피컬 나이트"를 검색하면 startIndex
가 초기화되지 않아 5가 되었고, startIndex
가 5인 API에 해당하는 결과가 존재하지 않기 때문에 원하는 도서 결과가 보여지지 않는 것이다.
만약 전체 결과가 무수히 많다 하더라도 startIndex
가 1부터 시작하지 않기 때문에 현재 startIndex
이전에 존재하는 결과는 볼 수 없다.
// 검색에 대한 결과 데이터 패치
const fetchNewData = useCallback(
(isSearchAgain: boolean) => {
setFetchState('loading')
fetchSearchBook(
{
search: word,
startIndex, // ✅
},
{
onSuccess: (res) => {
setBooks((prev) => (isSearchAgain ? res : [...prev, ...res]))
if (res.length > 0) setStartIndex((prev) => prev + 1) // ✅
setFetchState('fetched')
setSearchWord(word)
},
...
}
...
)
},
[word]
)
// 검색어 또는 스크롤을 감지하여 검색 버튼 없이 지동으로 패치
useEffect(
function fetchSearchBookAPI() {
if (isOpenSearch) {
const needToSearchAgain = word.trim() !== searchWord.trim()
if (needToSearchAgain) {
const debounceFetch = debounce(() => fetchNewData(needToSearchAgain), 500)
...
} else if (isIntersecting) {
fetchNewData(needToSearchAgain)
}
}
},
[isOpenSearch, word, isIntersecting]
)
위 코드를 보면 needToSearchAgain
이 true인 경우, 즉 새로운 도서 데이터를 가져와야 할 때에도 기존의 startIndex
를 사용하고 있다. 이 부분을 초기화하는 코드를 추가하면 해결할 수 있다.
const fetchNewData = useCallback(
(isSearchAgain: boolean, { index }: { index: number }) => {
setFetchState('loading')
fetchSearchBook(
{
search: word,
startIndex: index,
},
{
onSuccess: (res) => {
setBooks((prev) => (isSearchAgain ? res : [...prev, ...res]))
if (res.length > 0) setStartIndex(index + 1)
setFetchState('fetched')
setSearchWord(word)
},
...
}
...
)
},
[word]
)
useEffect(
function fetchSearchBookAPI() {
if (isOpenSearch) {
const needToSearchAgain = word.trim() !== searchWord.trim()
if (needToSearchAgain) {
const debounceFetch = debounce(() => fetchNewData(needToSearchAgain, { index: 1 }), 500) // ✅
...
} else if (isIntersecting) {
fetchNewData(needToSearchAgain, { index: startIndex }) // ✅
}
}
},
[isOpenSearch, word, isIntersecting]
)
이제 검색어를 바꿔도 올바른 결과를 받을 수 있다.
🔵 마치며
무한 스크롤 기능 자체를 구현하는 것에서도 어려움을 겪었는데 자잘한 UX와 버그를 수정하는 데 더 많은 시간이 걸렸다. 다양한 케이스를 테스트해서 서비스 사용자가 겪는 불편함을 최대한 없애도록 노력할 것이다.