NextJS 스크롤 포지션 유지/복구하기

NextJS에서는 라우팅을 할 때마다 페이지 컴포넌트가 페기되고 리빌딩이 되기 때문에 그 때마다 스크롤 포지션이 리셋되는 현상이 나타난다. 예를 들어 검색 결과 리스트가 나오고 상세 페이지로 라우팅 했다가 다시 뒤로 가기를 했을 경우, 리스트가 리빌딩 되면서 스크롤 포지션이 리셋되고 만다. UX적인 관점에서는 좋지 않은 경험이기 때문에 이를 수정 할 필요가 있다.

페이지 컴포넌트 자체를 캐시해서 재사용하는 방법도 있겠지만, 데이터 자체가 실시간으로 바뀌는 경우에는 캐시를 어떻게 컨트롤 할 건지에 대한 고민도 해봐야한다.

그렇기 때문에 이번 포스팅에서는 마지막 스크롤 포지션을 sessionStorage에 저장해뒀다가 리스트 페이지로 돌아갔을 때 다시 스크롤 포지션을 복구하는 방법을 알아본다.


# NextJS 공식 기능

알아보기 전에 NextJS에 자체적으로 스크롤 포지션을 복구하는 기능이 experimental 기능으로 현재 탑재되어있다. 하지만 데이터가 로딩되는 동안 로딩 인디케이터를 표시하고 로딩이 완료되면 리스트를 보여주는 형식의 기능을 구현하면, 스크롤 포지션 복구 기능이 제대로 기능하지 않았다.

공식 기능을 탑재하고 싶은 경우는 next.config.js 파일을 아래와 같이 수정해주면 된다.

const nextConfig = {
    reactStrictMode: true,
    experimental: {
        scrollRestoration: true
    };

module.exports = nextConfig;

# 스크롤 포지션 저장 & 복구

검색결과가 나타나는 페이지의 경로는 /search, 상세페이지의 경로는 /detail로 가정한다.

우선 검색결과를 나타내는 페이지 컴포넌트에 useEffectuseRouter를 이용하여 마지막 스크롤 포지션을 sessionStorage에 저장하는 코드를 넣어주자.

스크롤 포지션의 값은 scrollRestoration이라는 키에 저장한다.

스크롤 포지션을 복구 시킬 것인지에 관한 flag는 restoreScrollPosition이라는 키에 저장하는 것으로 한다. 이는 상세페이지(/detail)에서 뒤로가기를 해서 검색결과 페이지(/search)로 돌아 갔을 때만 스크롤 포지션이 복구 되게 하기 위함이다. 상세페이지에서 다른 페이지 (예를 들면 홈화면, 설정페이지 등)에 갔다가 다시 새롭게 검색을 실행하거나 검색 결과를 생성하는 페이지에 갔을 경우엔 스크롤 포지션을 복구 시키지 않기 위함이다.

/search

import { useEffect } from "react";
import { useRouter } from "next/router";

export default function Search() {
    const router = useRouter();
    
    // 브라우저의 history의 scrollRestoration을 manual로 변경. 기본값은 auto로 되어있는데 manual로 변경해줘야 수동으로 스크롤 포지션을 바꿔줄 수 있다.
    useEffect(() => {
        if ("scrollRestoration" in history && history.scrollRestoration !== "manual") {
            history.scrollRestoration = "manual";
        }
    }, []);
    
    // route가 변경됐을 때 마지막 스크롤 포지션 값을 저장
    useEffect(() => {
        const handleRouteChange = () => {
            sessionStorage.setItem("scrollPosition", window.scrollY.toString()); // sessionStorate에는 string값만 저장할 수 있다.
        };
        router.events.on("routeChangeStart", handleRouteChange);
        return () => {
            router.events.off("routeChangeStart", handleRouteChange);
        };
    }, [router.events]);
    
    useEffect(() => {
        // scrollPosition과 restoreScrollPosition 키가 존재 할 경우에만 스크롤 포지션을 복구
        if ("scrollPosition in sessionStorage && "restoreScrollPosition" in sessionStorage) {
            window.scrollTo(0, Number(sessionStorage.getItem("scrollPosition")));
            // 스크롤 포지션을 복구 한 후엔 키를 삭제
            sessionStorage.removeItem("scrollPosition");
            sessionStorage.removeItem("restoreScrollPosition");
        }
    }, []);
    
    return (생략);
}

/detail

import { useEffect } from "react";
import { useRouter } from "next/router";

export default function Detail() {
    const router = useRouter();
    
            // router.events의 url값은 새로운 route의 값을 리턴한다. 상세페이지에서 뒤로가기를 눌렀을 경우 url은 /search가 된다. /detail에서 /search 페이지로 돌아갈 때만 스크롤 포지션 복구를 하게 restoreScrollPosition flag를 세워준다.
    useEffect(() => {
        const handleRouteChange = (url) => {
            if (url.includes("search")) {
                sessionStorage.setItem("restoreScrollPosition", "true");
            }
        };
        router.events.on("routeChangeStart", handleRouteChange);
        return () => {
            router.events.off("routeChangeStart", handleRouteChange);
        };
    }, [router.events])
    
    return (생략);
}

이 외에 네비게이션 바에서 검색 할 수 있게 기능이 구현되어 있는 경우, 검색 버튼을 눌렀을 때 sessionStorage에서 scrollPosition 키를 삭제하는 코드를 추가해줘야만 새로운 검색결과 리스트에서 예전 스크롤 포지션으로 복구되는 걸 방지할 수 있다.


# 검색결과 데이터 캐시

이용자가 뒤로가기를 누를 때마다 새롭게 서버에서 데이터를 fetch하는 것은 서버에 부담도 되고 속도 저하의 원인이 되기도 한다. 그렇기 때문에 리스트 데이터를 불러 올 때는 왠만하면 캐시를 해주는 것이 좋다. SSR로 구현된 페이지 기준으로는 getServerSideProps에 다음과 같은 코드를 추가해주면 된다.

context.res.setHeader(
    "Cache-Control",
    "public, s-maxage=3600, stale-while-revalidate=60"
)

일반적인 response header에 캐시 컨트롤을 추가하는 법과 같으니 원하는 만큼의 시간을 지정하면 된다.