Infinite Scroll, 인피니트 스크롤

김종현·2024년 5월 1일
0

구현 기능

목록 보기
1/2

코드출처 : https://tech.kakaoenterprise.com/149

// 무한 스크롤 코드
import { useEffect, useState, useCallback } from 'react'
import axios from 'axios'

const CARD_SIZE = 100
const PAGE_SIZE = 10 * Math.ceil(visualViewport.width / CARD_SIZE)

export default function UsersPage() {
    const [page, setPage] = useState(1)
    const [users, setUsers] = useState([])
    const [isFetching, setIsFetching] = useState(false)
    const [hasNextPage, setNextPage] = useState(true)

    const fetchUsers = useCallback(async () => {
        const { data } = await axios.get('/api/users', {
            params: { page, size: PAGE_SIZE },
        })
        setUsers( prevUsers => [...prevUsers, ...data.contents])
        setPage(data.pageNumber + 1)
        setNextPage(!data.isLastPage)
        setIsFetching(false)
    }, [page])

    useEffect(() => {
        const handleScroll = () => {
            const { scrollTop, offsetHeight } = document.documentElement
            if (window.innerHeight + scrollTop >= offsetHeight) {
                setIsFetching(true)
            }
        }
        setIsFetching(true)
        window.addEventListener('scroll', handleScroll)
        return () => window.removeEventListener('scroll', handleScroll)
    }, [])

    useEffect(() => {
        if (isFetching && hasNextPage) fetchUsers()
        else if (!hasNextPage) setIsFetching(false)
    }, [isFetching])

    return (
        <Card>
            {users.map((user, index) => (
                <div key={user.id} name={user.name} >카드</div>
            ))}
            {isFetching && <div> 로딩중</div>}
        </Card>
    )
}
// mocking api code
import { http, HttpResponse } from 'msw'
import { setupWorker } from 'msw/browser'

const users = Array.from(Array(1024).keys()).map(
    (id) => ({
        id,
        name: `denis${id}`,
    })
)

const handlers = [
    http.get('/api/users', async ({ request }) => {
        const { searchParams } = new URL(request.url)
        const size = Number(searchParams.get('size'))
        const page = Number(searchParams.get('page'))
        const totalCount = users.length
        const totalPages = Math.round(totalCount / size)

        return HttpResponse.json(
            {
                contents: users.slice(page * size, (page + 1) * size),
                pageNumber: page,
                pageSize: size,
                totalPages,
                totalCount,
                isLastPage: totalPages <= page,
                isFirstPage: page === 0,
            }
        )
    }),
]

export const worker = setupWorker(...handlers)

프론트 코드

1.

const PAGE_SIZE = 10 * Math.ceil(visualViewport.width / CARD_SIZE)const [page, setPage] = useState(0)
    const [users, setUsers] = useState([])
    const [isFetching, setIsFetching] = useState(false)
    const [hasNextPage, setNextPage] = useState(true)

    const fetchUsers = useCallback(async () => {
         const { data } = await axios.get('/api/users', {
            params: { page, size: PAGE_SIZE },
        })setUsers( prevUsers => [...prevUsers, ...data.contents])setPage(data.pageNumber + 1)setNextPage(!data.isLastPage)setIsFetching(false)
    }, [page])

① 페이지당 표시할 사용자 카드 수를 나타내는 값을 지정
② page 값이 0에서 시작하는 이유는 데이터를 가져올 때 slice 메서드를 통해 0~1에 해당하는 인덱스 값을 가져오기 때문
③ 데이터를 패칭, 쿼리 값으로 page와 PAGE_SIZE를 넘겨 요청. 결과 값을 data에 담음
④ 기존의 users 값과 가져온 data 값을 통합
⑤ page 값을 증가시켜 데이터 패칭 기준점을 새로 잡음
⑥ 마지막 페이지에 대한 boolean 값을 받아서 다음 페이지가 있는지 여부를 state로 저장
⑦ 패칭이 완료되었음을 state 값에 저장

※ visualViewport
-viewport : 현재 사용자의 디스플레이 장치에 표시되는 영역을 visualViewport라고 한다.
-사용자가 화면을 확대하거나 축소하는 등의 동작을 할 경우 viewport의 값은 유동적으로 변하게 된다.

Q. PAGE_SIZE 가 필요한 이유?

첫 페칭 데이터 사이즈가 충분하지 않으면 스크롤바가 노출되지 않아 스크롤 이벤트가 발생할 수 없게 된다.
이때 충분한 데이터 사이즈를 페칭하거나 스크롤바가 노출될 때까지 연속 페칭하는 방법이 있는데 화면 크기에 비례해서 충분한 데이터 사이즈를 페칭할 수 있도록 PAGE_SIZE를 만든 것이다.

Q. 무한 스크롤에서 page 값이 필요한 이유?

-page값을 기준으로 데이터 패칭을 하기 위해서 값이 필요.
-페이지 값을 기준점으로 데이터 패칭이 필요한 때를 감지할 수 있다.

2.

useEffect(() => {const handleScroll = () => {
            const { scrollTop, offsetHeight } = document.documentElement
            if (window.innerHeight + scrollTop >= offsetHeight) {
                setIsFetching(true)
            }
        }setIsFetching(true)
        ④ window.addEventListener('scroll', handleScroll)return () => window.removeEventListener('scroll', handleScroll)
    },[])

① 컴포넌트가 마운트 되면서 코드를 실행
② 마운트 된 순간 fetching 상태 값을 true로 변경
③ scrollTop 값과 offsetHeight 값을 가져와 변수에 등록, innerHeight 값과 scrollTop 값의 합이 offsetHeight 값보다 클 경우에 fetching 상태를 true로 지정
④ handleScroll을 스크롤 이벤트에 등록
⑤ 컴포넌트가 언마운트될 때 이벤트를 삭제

※ scrollTop
: 스크롤 가능한 요소의 상단에서 스크롤된 거리를 나타내는 속성. 최상단에서 0, 아래로 내릴 수록 값 증가.

※ offsetHeight
: 테두리, 패딩, 가로 스크롤바(렌더링된 경우), 테두리를 포함한 요소의 css 전체 높이를 픽셀 단위로 측정한 값. (참고 이미지 : mdn)

※ window.innerHeight
: 브라우저 창에 보여지는 내용의 높이. 존재한다면 수평 스크롤 막대 높이는 포함하되 수직 스크롤바는 포함되지 않음.(참고 이미지 : mdn)

Q. useEffect로 이벤트를 등록하는 이유?

-useEffect에서 빈 배열을 참조 값으로 넘겨 컴포넌트가 마운트 될 때 이벤트를 등록하고 컴포넌트가 언마운트(소멸) 될 때 관련 리소스를 해제하고 메모리 누수를 방지할 수 있다.
-만약 useEffect가 아닌 채로 등록을 하게 될 경우 위의 이점을 누릴 수 없다.

3.

 useEffect(() => {if (isFetching && hasNextPage) fetchUsers()else if (!hasNextPage) setIsFetching(false)
    },[isFetching])

① isFetching 상태 값의 변화에 따라 코드 실행
② isFetching과 hasNextPage 값이 모두 참일 경우 데이터 패칭
③ 다음 페이지가 없을 경우 fetching 상태 값을 false로 변경 따라서 데이터 패치 조건을 만족하는지 한 번 더 실행되고 해당되지 않으므로 데이터 패칭이 일어나지 않음.

백엔드 코드

const handlers = [
    http.get('/api/users', async ({ request }) => {const { searchParams } = new URL(request.url)const size = Number(searchParams.get('size'))const page = Number(searchParams.get('page'))const totalCount = users.length
        ⑤ const totalPages = Math.round(totalCount / size)

        return HttpResponse.json(
            {
               ⑥ contents: users.slice(page * size, (page + 1) * size),
                pageNumber: page,
                pageSize: size,
                totalPages,
                totalCount,
                isLastPage: totalPages <= page,
                isFirstPage: page === 0,
            }
        )
    }),
]

① 요청 url에서 query 값을 받아 size와 page에 지정
② size = 프론트의 PAGE_SIZE 값
③ page = 프론트의 page 값
④ totalCount = 저장된 데이터의 길이
⑤ totalPages = 저장된 데이터의 길이를 PAGE_SIZE 값으로 나눠 총 페이지 수 지정(page의 limit)
⑥ contents : 저장된 데이터를 (page x size) 인 값부터 (next page x size)인 값 까지를 잘라서 전송. 따라서 기존에 보냈던 배열 값만큼을 제외하고 다음 차례인 데이터 값들을 보냄.

쓰로틀(Throttle)

스크롤에 이벤트를 묶어두었으므로 매번 스크롤을 움직일 때 마다 스크롤 이벤트 핸들러가 호출되게 된다. 게다가 documentElement.scrollTop과 documentElement.offsetHeight는 리플로우(Reflow)가 발생하는 참조이므로 이벤트 호출량을 줄일 수 있는 방법이 필요하다.

이에 대해서는 디바운스와 쓰로틀이 있는데 내용은 아래의 링크에 정리해 두었다.

https://velog.io/@oknuh1501/%EB%94%94%EB%B0%94%EC%9A%B4%EC%8B%B1%EA%B3%BC-%EC%93%B0%EB%A1%9C%ED%8B%80%EB%A7%81-rygkwu47

둘 중 적절한 기술은 쓰로틀이므로 쓰로틀을 채택하였다.

코드출처 : https://tech.kakaoenterprise.com/149

profile
고양이 릴스 매니아

0개의 댓글