React Query와 함께하는 Next.js 무한 스크롤

hdpark·2022년 5월 18일
113
post-thumbnail

스크롤, 즐기시게 냅둬

탐색해야 할 정보가 세상에 너무나도 많다. 출근할 때 뮤직 앱에서 노래를 검색해야 하고,
쇼핑몰에서 맘에 드는 물건을 찾을 때까지 질리도록 스크롤을 밑으로 내려야 한다.

그러다 화면이 뷰포트 최하단에 닿는 순간, 혹은 닿기 전에 지체없이 다음에 봐야 할 정보들을 불러온다.
다른 조작을 할 필요도 없다. 그냥 우리는 내리기만 하면 정보가 바닥나기 전까지
끊임없는 정보를 계속 접하게 된다.

이런 방식의 정보 탐색은 이미 너무나 익숙해서 최근의 리스트 형식의 UI는 대부분 무한 스크롤을 채택하고 있다.
특정 페이지를 어느 정도 파악해야 하는 백오피스나 커뮤니티의 게시판을 제외하고는 이제 번호가 붙은 페이지네이션은 PC에 비해 좁아진 모바일 화면에선 끔찍할 뿐이다.

사용자를 무지성으로 스크롤만 내리게 만들기 위해서 프론트 개발자는 어떤 기능을 구현해야 할까?
Next.js와 React-query로 한번 즐기시게 만들어보자.

개발도 즐길 수 있나?

무한 스크롤의 실체

자, 이제 당신은 프론트 개발자로서 최초로 무한 스크롤을 적용하기로 했다.
어떤 과정들을 거쳐야 하는지 간단히 생각해 보기로 한다.

  1. 가장 최근 1페이지의 데이터를 불러온다.
  2. 정해진 갯수의 리스트가 화면에 표시되고 스크롤이 바닥에 닿는다
  3. 다음 페이지가 1페이지 아래로 다시 붙는다. 계속 되는 스크롤...

어 생각보다 단순한데...?
아마 서버사이드는 불가능할 것 같으니 클라이언트 사이드에서 해결해야 할 것이다.
단순하게 useEffectuseState를 섞어서 배열만 쭉쭉 뒤로 붙혀주면 될 것 같다.
바닥이야 그냥 대충 window안에 있는 파라미터랑 getBoundingClientRect()로 찾아서
추가로 데이터를 불러오면 될까?

가끔 개발자들의 직관과 통찰은 문제를 해결하는데 매우 탁월한 능력을 발휘한다.
이번도 자신의 기지가 잘 통했길 바라며 스크롤을 바닥까지 내린 순간
데이터를 성공적으로 불러오는 모습을 보고 '무한 스크롤 별 것도 아니네' 라고 생각했던 개발자는
상세 링크를 클릭한 순간, 자신이 구현하고자 하는 기능의 진짜 모습을 마주한다.

  1. 성공적으로 리스트를 잘 불러온다. 즐겁게 리스트의 상세 리스트로 들어간다.
  2. 뒤로가기를 누른다. 방금 전까진 10페이지에 있었는데 다시 1페이지부터 시작한다.
  3. 다시 밑으로 내려간다. 몇 번을 반복해도 허무하게 1페이지로 돌아올 뿐이다.

무한 스크롤의 진짜 모습은 그냥 배열 이어붙히기 같은 게 아니었다.

상세에서 다시 리스트로 돌아왔을 때 스크롤 위치를 유지해야 하며
스크롤을 유지하기 위해서는 이전에 불러왔었던 데이터를 기억하고 그 데이터를 다시 불러와야 한다.

하지만 1~n 페이지 전체를 매번 불러오게 되면 바닥에 닿는건 스크롤이 아니라
정수리가 될 수도 있기 때문에 기존에 불러왔던 네트워크 페이로드도 캐싱해서 리스트를 재구성해야 한다.

카드형태의 리스트라면 이미지가 포함되어 있을 가능성이 매우 높으므로 이미지 역시
Lazy loading 처리를 해줘야 낭비되는 트래픽을 없앨 수 있을 것이다.

또한 사용자가 원하는 정보를 언제 찾을지 알 수 없기 때문에
탐색을 계속하다 보면 화면 상단 바깥쪽으로 무수히 많은 리스트가 쌓이기 마련이고,
이는 사용자의 탐색 경험을 심각하게 저해한다. DOM의 최적화 역시 신경써야 한다.
근데 아이폰 쓰는 사람들은 성능 하락 못느끼더라

👊🏻 Infinity Scroll을 위한 스톤 모으기

사용하는 패키지 + API

Next.js로 새 프로젝트를 만들고 데이터를 불러오기 위해
fetch api 대신 axios를 사용할 것이다.
Mockup 데이터는 PokeApi를 사용해 가져올 예정.

axios로 데이터를 가져올 함수를 만들었다면 React Query를 사용해 무한 스크롤을 구현한다.
리스트를 추가로 가져올 때의 이미지는 React Lazy Load Image Component로 트래픽을 최소화 한다.

추가적으로 무한 스크롤의 밑바닥 감지와 화면 바깥으로 나가버린 DOM의 렌더를 막기 위해
intersectionObserver api를 사용할 것이다.

스크롤 감지 구현 후에는 useLocalStorage를 통해서 현재 스크롤 위치를 저장하고 다시 리스트로 돌아왔을 때 스크롤 위치를 복구한다.

사용하는 건 많아 보이지만 차분히 새 프로젝트를 만들고 필요한 것들을 설치하자.
( 💡 css에 관한 코드는 디자인마다 전부 다를 것이니 따로 작성하지 않겠다. )

  npx create-next-app infinity-scroll 
  cd infinity-scroll
  npm i axios react-query react-lazy-load-image-component use-local-storage 
  npm run dev

자 이제 눈에 익은 localhost:3000의 Next.js 페이지가 보였다면 _app.js 파일을 수정하자.

📄 _app.js

  import { useState } from "react"
  import { QueryClient, QueryClientProvider } from "react-query"
  import { ReactQueryDevtools } from "react-query/devtools"
  import "../styles/globals.css"

  const App = ({ Component, pageProps }) => {
      // useState lazy init을 사용해 QueryClient 인스턴스를 생성해
      // QueryClientProvider의 client 값으로 전달해준다
      const [queryClient] = useState(() => new QueryClient())

      return (
          // QueryClientProvider 로 인해 모든 페이지, 컴포넌트에서 
          // queryClient 에 접근이 가능해진다.
          <QueryClientProvider client={queryClient}>
              <Component {...pageProps} />

			  // devTool을 설치한다. 화면 좌측하단의 로고를 누르면 개발툴을 열어볼 수 있다.
		      // 개발환경에서만 활성화되기 때문에 따로 신경을 쓸 필요도 없다.
			  <ReactQueryDevtools initialIsOpen={false} />
          </QueryClientProvider>
      )
  }

  export default App

이제 각 페이지 및 컴포넌트에서 React Query를 사용할 준비가 끝났으니
index.js 파일로 이동해서 번들 페이지를 지우고 새 코드를 넣는다.

📄 index.js

  import { useEffect, useRef } from "react"
  import { useInfiniteQuery } from "react-query"
  import axios from "axios"
  import styles from "../styles/index.module.css"

  const Index = () => {
      return <></>
  }

  export default Index

Index에 필요한 패키지를 전부 불러왔으면 PoKeApi를 한 번 불러와보자

  const getPokemonList = () =>
      axios
      .get("https://pokeapi.co/api/v2/pokemon?limit=20&offset=20")
      .then(res => console.log(res))

  useEffect(() => {
      getPokemonList()
  }, [])

res.data를 들여다 보면 다음 페이지를 불러와야 할 단서들이 들어있다.
친절하게 다음 데이터를 불러와야 할 params를 알려주고 있으며
results에 화면에 시해야 하는 데이터들이 들어있음을 확인 할 수 있었다.
다음에 불러와야 할 데이터는 "https://pokeapi.co/api/v2/pokemon?limit=20&offset=40" 이라고 하니 limit만큼 offset에 숫자를 더하면 다음 페이지를 불러올 수 있을 것 같다.

그럼 이제 React Query의 useInfiniteQuery를 사용해볼 때다.

useInfiniteQuery( " name " , () => fetch() , { ... option } ) 를 살펴보자

React Query는 기본적으로 CSR과 SSR 양쪽에서 사용할 수 있는 data fetching과
CRUD를 가능하게 하는 mutation을 제공한다.
React Query의 강점은 기본적으로 데이터를 캐싱해서 최적화를 시도한다는 데에 있다.


좌측 하단 React Query로고를 눌러 개발툴을 열어보면 stale(1)로 표시되어 있는 부분이 보인다.
이 캐싱 데이터는 기본적으로 오래된 것으로 간주되고, 매 요청시마다 현재 캐싱된 데이터와
실제 fetching 데이터를 비교해서 자동으로 최신 데이터로 교체해 준다!

데이터를 보관할 staleTime과 얼마나 데이터를 자주 불러올지는 _app.js에서 선언된 QueryClient의 옵션으로 컨트롤이 가능하니 옵션에 관해서는 공식 문서를 참고하자.
( 💡 기본 캐싱 데이터는 5분 후 자동으로 가비지 컬렉팅된다. )

이번에 사용할 useInfiniteQuery는 무한 스크롤 구현을 위해 유용한 도구들을 리턴해주는 hook다.
아까 만들었던 getPokemonList함수를 callback으로 사용하기 위해서는 약간의 수정이 필요하다.

  const {
      data, // 💡 data.pages를 갖고 있는 배열
      error, // error 객체
      fetchNextPage, // 💡 다음 페이지를 불러오는 함수
      hasNextPage, // 다음 페이지가 있는지 여부, Boolean
      isFetching, // 첫 페이지 fetching 여부, Boolean, 잘 안쓰인다
      isFetchingNextPage, // 추가 페이지 fetching 여부, Boolean
      status, // 💡 loading, error, success 중 하나의 상태, string
  } = useInfiniteQuery(
        "pokemonList" // data의 이름
      , getPokemonList // fetch callback, 위 data를 불러올 함수
      , {
          // 💡 중요! getNextPageParams가 무한 스크롤의 핵심,
          // getNextPageParam 메서드가 falsy한 값을 반환하면 추가 fetch를 실행하지 않는다
          // falsy하지 않은 값을 return 할 경우 Number를 리턴해야 하며
          // 위의 fetch callback의 인자로 자동으로 pageParam을 전달.
          getNextPageParam: (lastPage, page) => 
              조건 ? Number : falsy한 모든  ( api에 따라 다를 것이다 ),
        }
      )

  const OFFSET = 30 // 나중에 편하게 바꿀 수 있도록 page offset을 상수로 설정

  // pageParam은 useInfiniteQuery의 getNextPageParam에서 자동으로 넘어온다. 
  // 1페이지는 undefined로 아무것도 넘어오지 않는다. 초기값을 반드시 설정해 주자. 
  const getPokemonList = ({ pageParam = OFFSET }) =>     
      axios
      .get("https://pokeapi.co/api/v2/pokemon", {
          // axios.get(url, config), 
          // url전체를 템플릿 리터럴로 넘기든 config의 params로 넘기든 취향에 맞게 넘기자.
          params: {
              limit: OFFSET,
              offset: pageParam,
          },
      })
      .then(res => res?.data)
   
  // render는 이렇게 하자
  return (
      <div>
          // status에 따라서 화면을 달리한다. (사실 이렇게 안하고 다짜고자 data를 보면 터진다)
          // 단순한 문구가 재미없다면 skeleton을 따로 만들어서 로딩으로 사용하는 것도 추천
          {status === "loading" && <p>불러오는 중</p>}

          {status === "error" && <p>{error.message}</p>}

          // 추가로 success일 경우에만 data를 들여다 보도록 하자.
          {status === "success" &&
              data.pages.map((group, index) => (
                  // pages들이 페이지 숫자에 맞춰서 들어있기 때문에
                  // group을 map으로 한번 더 돌리는 이중 배열 구조이다.
                  // PoKeApi는 특별한 고유 값이 없기에 key는 적당히 넣어준다.
                  <div key={index}>
                      {group.results.map(pokemon => (
                          <p key={pokemon.name}>{pokemon.name}</p>
                      ))}
                  </div>
          ))}

          // 스크롤 구현 전까지 테스트로 사용할 임시 버튼
          <button onClick={() => fetchNextPage()}>더 불러오기</button>

          // skeleton이나 화면 spinner로 로딩 만드는 것도 좋을 것 같다.
          {isFetchingNextPage && <p>계속 불러오는 중</p>}
      </div>
  )

이제 브라우저로 가서 데이터를 불러오면 첫 페이지도, 더 불러오기도 작동함을 알 수 있다.
하지만 계속 같은 데이터만 불러온다. 왜냐면 아직 getNextPageParam 메소드를 제대로 다루지 않았기 때문이다.

  {
      getNextPageParam: lastPage => {
          const { next } = lastPage // PoKeApi는 마지막 데이터가 없으면 next를 null로 준다

          // 마지막페이지 fetchNextPage가 더는 작동하지 않도록 false를 리턴하자
          if (!next) return false

          // next 값에서 URL주소를 주고 있기 때문에 필요한 offset만 빼와서
          // getPokemonList 함수에 pageParam으로 넘겨주자.
          return Number(new URL(next).searchParams.get("offset"))
      }
  }

axios로 api를 호출하는 callback을 만들고
fetchNextPage()를 호출하는 것만으로, useInfiniteQuery hook를 사용해 데이터를 불러오는 로직은 끝이 났다.
PoKeApi가 아니라 다른 REST api를 사용한다 해도 getNextPageParam의 리턴값과
api호출 callback만 주의한다면 방법은 동일할 것이다.

👁‍🗨 intersectionObserver

지금까지는 더 불러오기 버튼을 눌러서 다음 페이지를 가져오는 로직이었다.
유저가 스크롤을 맨 밑으로 내렸을때 브라우저가 이를 매번 감지할 수 있을까?

감지할 수 있다면 자동으로 fetchNextPage함수를 자동으로 작동시킬 수 있을 것이고
수 많은 검색 리스트가 브라우저 viewport 바깥으로 나갔을 경우에도
이를 감지하고 DOM에서 임시로 제거할 수 있을 것이다.
intersectionObserver라는 web api를 사용하면 쉽게 구현이 가능하다.

intersectionObserver용 hook 만들기

이 api는 무한 스크롤 data fetch와 포켓몬 리스트 DOM 렌더 여부를 위해 재사용성을 높인 hook로 구현할 예정이다.
web api라 기타 패키지를 사용하지 않아도 브라우저 자체에서 해석이 가능하기 때문에 따로 불러오지 않아도 된다.
( 물론 IE는 못알아먹는다. IE를 지원해야 한다면 조용히 새 탭을 열고 구글에서 다른 방법을 찾아보자... )

const oberserver = new IntersectionObserver(([entry]) => onIntersect() , { ...option } )

기본적인 사용법은 접촉이 감지되었을 때 실행할 callback 함수와
접촉을 감지할 조건을 option으로 받는다.

📄 useObserver.js

  import { useEffect } from "react"

  export const useObserver = ({
      target, // 감지할 대상, ref를 넘길 예정
      onIntersect, // 감지 시 실행할 callback 함수
      root = null, // 교차할 부모 요소, 아무것도 넘기지 않으면 document가 기본이다.
      rootMargin = "0px", // root와 target이 감지하는 여백의 거리
      threshold = 1.0, // 임계점. 1.0이면 root내에서 target이 100% 보여질 때 callback이 실행된다.
  }) => {
      useEffect(() => {
          let observer

          // 넘어오는 element가 있어야 observer를 생성할 수 있도록 한다.
          if (target && target.current) {
              // callback의 인자로 들어오는 entry는 기본적으로 순환자이기 때문에
              // 복잡한 로직을 필요로 할때가 많다. 
              // callback을 선언하는 곳에서 로직을 짜서 통째로 넘기도록 하겠다.
              observer = new IntersectionObserver(onIntersect, { root, rootMargin, threshold })
              // 실제 Element가 들어있는 current 관측을 시작한다.
              observer.observe(target.current)
          }

          // observer를 사용하는 컴포넌트가 해제되면 observer 역시 꺼 주자. 
          return () => observer && observer.disconnect()
      }, [target, rootMargin, threshold])
  }

주석을 제외하면 19줄 밖에 되지 않는다.
재빠르게 useObserver hook를 만들었으면 index.js에서 바로 사용해보자.
지금까지 작성한 index.js는 이런 모양이 된다.

📄 index.js

  import { useRef } from "react"
  import { useInfiniteQuery } from "react-query"
  import axios from "axios"
  import { useObserver } from "../lib/hooks/useObserver"
  import style from "../styles/index.module.scss"

  const OFFSET = 30

  const getPokemonList = ({ pageParam = OFFSET }) =>
      axios
          .get("https://pokeapi.co/api/v2/pokemon", {
              params: {
                  limit: OFFSET,
                  offset: pageParam,
              },
          })
          .then(res => res?.data)
                 
  const Index = () => {
      // 바닥 ref를 위한 useRef 선언      
      const bottom = useRef(null)

      const {
          data,
          error,
          fetchNextPage,
          hasNextPage,
          isFetching,
          isFetchingNextPage,
          status,
      } = useInfiniteQuery("pokemonList", getPokemonList, {
          getNextPageParam: lastPage => {
              const { next } = lastPage
               
              if (!next) return false
             
              return Number(new URL(next).searchParams.get("offset"))
          },
      })

      // useObserver로 넘겨줄 callback, entry로 넘어오는 HTMLElement가
      // isIntersecting이라면 무한 스크롤을 위한 fetchNextPage가 실행될 것이다.
      const onIntersect = ([entry]) => entry.isIntersecting && fetchNextPage()

      // useObserver로 bottom ref와 onIntersect를 넘겨 주자.
      useObserver({
          target: bottom,
          onIntersect,
      })

      return (
          <div className={style.pokemons_wrap}>
              {status === "loading" && <p>불러오는 중</p>}

              {status === "error" && <p>{error.message}</p>}

              {status === "success" && (
                  <div className={style.pokemon_list_box}>
                      {data.pages.map((group, index) => (
                          <div className={style.pokemon_list} key={index}>
                              {group.results.map(pokemon => 
                                  <p key={pokemon.name}>{pokemon.name}</p>
                              )}
                          </div>
                      ))}
                  </div>
              )}

              // 아까 만들었던 더 불러오기 버튼을 제거하고 
              // 바닥 ref를 위한 div를 하나 만들어준다.
              <div ref={bottom} />

              {isFetchingNextPage && <p>계속 불러오는 중</p>}
          </div>
      )
  }

  export default Index

무한 스크롤을 위한 onIntersect 함수가 선뜻 이해가 안될수도 있지만 일단 브라우저로 돌아가서 스크롤을 내려보자.

영단어같은 포켓몬 이름들만 지나가서 잘 식별되진 않지만
스크롤을 내릴때마다 데이터를 쭉쭉 잘 불러오는 것을 볼 수 있다👍🏻
..이건 날 미소짓게 하는군

무한 스크롤의 기능적인 부분들은 모두 끝이 났다.
이제 재미없는 영문자만 써있는 포켓몬 리스트를 좀 꾸며볼 시간이다.
불러온 각 포켓몬의 이미지 로딩과 DOM의 최적화,
상세 페이지로 이동 후의 스크롤 복구를 구현해보자.

📱 React Lazy Load Image Component

우리가 만드는 서비스들의 에셋은 cdn을 통해 관리될 가능성이 많고
디자인팀 쪽에서는 되도록 고화질, 고용량의 이미지를 사용해 달라는 요구가 잦을 것이다.
고화질의 이미지를 사용하면 서비스가 보다 매력적으로 보일 수 있겠지만
에셋을 위해 사용하는 높은 트래픽과 유저에게 무거운 페이로드를 요구하는 것은 비용적인 측면에서 바람직하지 않으므로 프론트엔드 개발자가 효율적인 타협점을 마련해 줘야 한다.

IntersectionObserver로 대상이 뷰포트에 들어올 때 이미지를 불러오도록 구현해도 좋지만
React Lazy Load Image Component는 이를 매우 간단하게 해결해준다.

https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png

이곳으로 요청하면 각 id에 맞는 포켓몬 스프라이트를 불러올 수 있다.
이제 PokemonCard 컴포넌트를 조금 꾸며주고 next/link를 사용해서 각 상세 페이지로 접근할 수 있게 만들어주자.

📄 PokemonCard.js

import Link from "next/link"
import { LazyLoadImage } from "react-lazy-load-image-component"
import style from "../styles/pokemon.module.scss"

const PokemonCard = ({ id, name }) => {
	return (
		<Link href={`/pokemon/${id}`} key={id}>
			<a className={style.pokemon_item}>
				// img 태그 대신에 LazyLoadImage 컴포넌트를 사용해 준다. 
				// 일반적인 사용법은 img태그와 같다.
				<LazyLoadImage
					src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png`}
					alt={name}
				/>
				<div className={style.item}>
					<div className={style.info_box}>
						<p className={style.label}>ID</p>
						<p className={style.info}>{id}</p>
					</div>
					<div className={style.info_box}>
						<p className={style.label}>name</p>
						<p className={style.info}>{name}</p>
					</div>
				</div>
			</a>
		</Link>
	)
}

export default PokemonCard

너무 심심해서 css로 조금 꾸며보았다.
컴포넌트로 만들었으면 Index.js에 넣고 id와 name을 props로 보내주자.

img태그 대신 사용된 LazyLoadImage컴포넌트를 적용했으면
개발자 도구의 네트워크 메뉴를 열고 스크롤을 내려보자.
뷰포트에 리스트가 들어올 때만 이미지를 불러옴을 알 수 있다.

이미지의 Lazy Load를 처리 했지만 아직 DOM에 대한 최적화가 남았다.
스크롤을 계속 내려서 수백개 이상의 리스트를 불러오면 Card컴포넌트 안의 중첩된 Element까지 포함해서 매우 무거운 HTML문서가 될 것이다.
대부분의 데스크탑에서는 문제가 되지 않겠지만, 유저의 관심사 바깥으로 깊게 중첩된 DOM을 방치하는 것은 저사양의 PC, 혹은 모바일에서 심각한 프레임 드랍을 유발할 수 있다.

아까 만든 useObserver hook를 사용해서 처리해보자.

  const PokemonCard = ({ id, name }) => {
	const target = useRef(null) // 대상 ref
	const [visible, setVisible] = useState(false) // DOM을 렌더할 조건

    // isIntersecting의 경우에 DOM을 마운트 한다.
    const onIntersect = ([entry]) =>
    	entry.isIntersecting ? setVisible(true) : setVisible(false)

	useObserver({
		target,
		onIntersect,
		threshold: 0.1, // 화면 양끝에서 10%만 보여져도 onIntersect를 실행한다.
	})

	return (
		<Link href={`/pokemon/${id}`} key={id}>
			// 관측 대상인 target ref. Link는 Next.js의 가상 요소로 실체가 없기 때문에
			// a태그에 스타일과 ref를 줘야 한다.
			<a className={style.pokemon_item} ref={target} >
				// 리스트 안쪽 전부를 조건부로 비워 준다.
				{visible && (
					<>
						<LazyLoadImage
							src={`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png`}
							alt={name}
						/>
						<div className={style.item}>
							<div className={style.info_box}>
								<p className={style.label}>ID</p>
								<p className={style.info}>{id}</p>
							</div>
							<div className={style.info_box}>
								<p className={style.label}>name</p>
								<p className={style.info}>{name}</p>
							</div>
						</div>
					</>
				)}
			</a>
		</Link>
	)
}

개발자 도구를 열어서 스크롤을 돌려보면 모든 데이터는 유지한 채로
a태그 안쪽의 중첩된 Element를 제거하는 것을 볼 수 있다👍🏻

❗ 내부 DOM을 제거하는 것이기 때문에 리스트가 높이를 잃어버리게 되고
문서 높이가 달라져 스크롤이 요동치면서 매우 불편한 경험을 하게 될 것이다.
css에서 리스트의 기본 높이를 min-height로 설정해줘야
문서의 높이가 비정상적으로 작동하는 것을 막을 수 있다.

링크를 만들었으니 각 포켓몬의 정보를 조회할 수 있는 상세페이지를 만든다.
api에 따라 제공하는 데이터는 입맛에 맞게 만들어 보자.
( 아무것도 넣지 않고 상세 페이지만 만들어도 괜찮다. )

📄 [pokeId].js

import axios from "axios"
import style from "../../styles/pokemon.module.scss"

const Pokemon = ({ data }) => {
	const { name, types, id, base_experience, abilities, order } = data

	return (
		<div className={style.pokemon_wrap}>
			//... data에서 맘에 드는 항목을 골라 자유롭게 편집한다.
		</div>
	)
}

// SSR로 데이터를 처리
export const getServerSideProps = async ({ params }) => {
	const { data } = await axios.get(`https://pokeapi.co/api/v2/pokemon/${params.pokeId}`)

	return { props: { data } }
}

export default Pokemon

간단히 상세페이지를 만들었으면 뒤로 가기 버튼을 눌러 리스트로 돌아가자.
React Query덕분에 데이터는 전부 유지하고 있지만
스크롤이 최상단으로 복구되어, 이전까지 탐색하던 위치를 유지하지 못하는 것을 볼 수 있을 것이다.

📑 useLocalStorage

대부분의 브라우저에서는 간편하게 사용할 임시 저장소를 몇가지 제공해주는데
가장 만만하고 쓰기 쉬운 localStorage로 스크롤 복구를 구현해 보겠다.
링크로 이동하기 전에 onClick 이벤트로 현재 window.scrollY 위치를 저장하고
다시 리스트로 돌아왔을때 useEffect로 스크롤 위치를 복구하면
길고 길었던 무한 스크롤의 한 사이클이 완성된다.

useLocalStorage hook는 사용법이 매우 간단하다.

	const [value, setValue] = useLocalStorage("name", initialValue)

useState와 완전 똑같이 사용하면 된다. 다른점은 데이터의 key이름과 처음으로 가질 값, 두 개를 인자로 받는다는 점이다.
일반적인 방법으로 react에서 localStorage에 접근해서 값을 쓰고 가져온다 해도
state가 아니기 때문에 화면을 다시 그리지 않는데
useLocalStorage는 localStorage에 값을 저장하면서도 값이 변경할 때마다
화면을 업데이트 한다는 게 큰 강점이다.

📄 PokemonCard.js

PokemonCard 컴포넌트로 가서 중간중간 코드를 집어넣어주자

import useLocalStorage from "use-local-storage"
.
.
const PokemonCard = ({ id, name }) => {
  	// scrollY를 useLocalStorage로 세팅한다.
  	const [scrollY, setScrollY] = useLocalStorage("poke_list_scroll", 0)
    .
    .
    return (
    <Link href={`/pokemon/${id}`} key={name}>
        <a
            className={style.pokemon_item}
            ref={target}
			// a태그를 클릭할때 window.scrollY를 저장	
            onClick={() => setScrollY(window.scrollY)}

카드를 눌러 상세로 들어갔을 때 성공적으로 값을 저장하는 것을 볼 수 있다.

이제 Index.js에서 useEffect를 추가로 작성해준다.

📄 index.js

  import useLocalStorage from "use-local-storage"
  .
  .
  const [scrollY] = useLocalStorage("poke_list_scroll", 0)
  .
  .
  useEffect(() => {
	  // 기본값이 "0"이기 때문에 스크롤 값이 저장됐을 때에만 window를 스크롤시킨다.
      if (scrollY !== "0") window.scrollTo(0, Number(scrollY))
  }, [])  

성공적으로 스크롤 위치를 복구하는 모습을 볼 수 있다😎
이미지 영역과 텍스트 영역이 깜빡이는 증상은 css로 공간을 미리 확보해 주면 수정된다.
Web Vital에 있어서 핵심적인 Cumulative Layout Shift를 방지할 수 있으니 실무에서는 필수적으로 적용해주자.

🔗 지금까지 작업물의 github repo
https://github.com/bselpin/infinity-scroll

마치며-

개발자로 전직하기 전에는 당연하게 여겼던 기능들이 사실은 결코 당연하지 않았다는 사실을 매번 체감하고 배운다.
기능 구현을 위한 패키지와 api의 최소한의 사용법만을 소개해서 컨텐츠의 깊이가 깊지는 못하지만,
누군가 새로운 기능 구현을 요구 받았을 때 소소한 레퍼런스라도 되었으면 하는 게
바람이라면 작은 바람일까🤭

여기까지 긴 시간을 할애해서 부족한 포스팅을 읽어 준 당신에게 감사드리며
오늘, 내일도 함께 해피 개발자 라이프를 무한으로 즐겨보도록 하자

profile
개발+디자인을 하는 적마도사

9개의 댓글

comment-user-thumbnail
2022년 5월 19일

잘 보고갑니다:) 내용도 내용이지만 썸네일 퀄리티가....대단하시네요!!!!

1개의 답글
comment-user-thumbnail
2022년 6월 20일

잘 읽었습니다. ㅎㅎ 좋은 글 감사해요!

답글 달기
comment-user-thumbnail
2022년 7월 11일

너무 잘봤습니다.
도움 많이 되었어요,,

답글 달기
comment-user-thumbnail
2023년 1월 19일

useObserver 부분 도움 많이 받았습니다!

답글 달기
comment-user-thumbnail
2023년 3월 23일

글 그자체 감동,,,

답글 달기
comment-user-thumbnail
2023년 4월 1일

멋진 네용이요!
감사합니다

답글 달기
comment-user-thumbnail
2023년 6월 20일

잘 읽었습니다 ..! 👍🏻 질문이 있는데, 혹시 useLocalStorage를 사용하게 되면 새로고침했을때 localStorage안에 있는 스크롤 저장 변수값이 const [scrollY, setScrollY] = useLocalStorage("poke_list_scroll", 0) 이 부분으로 인해서 초기화가 되나요?! 추가로, 저 부분이 없다면 새로고침을 해도 저장된 스크롤값으로 이동하나요?

1개의 답글