Frontend/Projects

[Next.js] useRouter 페이지 로딩 속도 개선

joycie416 2024. 11. 19. 15:55

따꼼 프로젝트를 진행하며 Next.js 14의 App Router를 사용했다. 검색어를 입력하고 검색 버튼을 누르면 쿼리스트링에 검색어를 저장해 검색결과를 보여주는 컴포넌트에서 쿼리스트링을 통해 검색 결과를 보여주도록 했다. 그런데 사용자 테스트를 받아보니 검색결과를 보여주기까지 로딩이 너무 오래 걸린다는 답변을 많이 받았고, 나도 잘 인지하고 있는 부분이었다.

 

Next.js App Router는 `next/navigation`에서 `useRouter`를 통해 검색 버튼을 눌렀을 때 페이지 이동을 하도록 했다. 그런데 `router.push()`는 페이지 전체를 처음부터 렌더링하는 것이므로 속도가 너무 느렸다. (Pages Router는 shallow routing을 지원하지만 App Router는 지원하지 않는다)

 

1. `window.history.pushState()`

페이지를 새로 렌더링하지 않기 위해 `window.history.pushState()`를 아래와 같이 사용했다.

//기존
import { useRouter } from "next/navigation";

const router = useRouter();

const onClick () => {
  router.push("/hospital?brtcCd=1100000000&sggCd=11680&pageNo=1")
}

//변경
const onClick () => {
  const newUrl = "/hospital?brtcCd=1100000000&sggCd=11680&pageNo=1"
  window.history.pushState({...window.history.state, as: newUrl, url: newUrl}, "", newUrl);
}

 

`window.history.pushState()`는 브라우저의 history 스택에 새로운 요소를 넣는 메서드이며 SPA(Single Page Application)에서 사용하는 방법중 하나이다.

 

첫번째 객체로 입력받은 인자는 기존 history에 다음 정보를 넣어준 것으로, 앞으로 가기, 뒤로 가기 할때 사용된다.

 

두번째 빈문자열은 title로 페이지의 제목을 설정하기 위해 사용되지만, 대부분의 브라우저에서 무시되어 빈문자열을 많이 사용한다고 한다.

 

세번째 인자는 실제 브라우저의 주소창에 표시될 url이다.

 

그런데 url만 바뀌고 내용은 변경되지 않았다.

 

2. 커스텀 훅 만들기

위 문제를 해결하기 위해 url이 바뀔 때마다 상태 변화를 인식할 수 있도록 커스텀 훅을 만들어야 했다.

 

개인적으로 React 훅을 사용한 커스텀 훅 만드는 것을 어려워한다. 아래 코드도 많이 고민해서 작성했고, 특히 훅의 parameter와 `useEffect`의 의존성 배열에 무엇을 넣어야할 지 고민을 많이 했다.

 

나는 page.tsx에 `SearchForm.tsx`와 `HospitalList.tsx`를 하위컴포넌트로 두고 있었는데, `SearchForm.tsx`는 '검색' 버튼 클릭 시 쿼리스트링 변경하면서 변경된 쿼리스트링 감지해서 검색어에 반영해야 했고, `HospitalList.tsx`는 변한 쿼리스트링에서 검색어를 객체로 가져와야했다.

 

따라서 아래와 같은 커스텀 훅을 만들었다.

 

// use-query-param.ts
'use client'

...

const useQueryParams = (currentQuery: string): [HospitalSearchParams, (params: HospitalSearchParams) => void] => {
  const [params, setParams] = useState<HospitalSearchParams>(Object.fromEntries(new URLSearchParams(currentQuery)));

  useEffect(() => {
    // 클라이언트 환경에서만 실행됨
    if (typeof window === "undefined") {
      return;
    }

    const handlePopState = () => {
      // 뒤로 가기 또는 앞으로 가기 시 (= url 변경 시 발생) 쿼리 파라미터 업데이트
      setParams(Object.fromEntries(new URLSearchParams(window.location.search)));
    };

    // 처음 마운트될 때 실행
    handlePopState();

    // popstate 이벤트 리스너 추가
    window.addEventListener("popstate", handlePopState);

    // 언마운트될 때 이벤트 리스너 제거
    return () => {
      window.removeEventListener("popstate", handlePopState);
    };
  }, [currentQuery]);

  const setQueryParams = (params: HospitalSearchParams) => {
    const newUrl = createQueryParams(params, "/hospital");
    window.history.pushState({ ...window.history.state, as: newUrl, url: newUrl }, "", newUrl);
    window.dispatchEvent(new PopStateEvent("popstate"));
  };

  return [params, setQueryParams];
};

export default useQueryParams;

 

`SearchForm.tsx`에서 '검색' 버튼을 눌렀을 때, `currentQuery`를 변경시켜 해당 커스텀 훅의 `useEffect`가 실행되도록 했고, `setQueryParams`를 통해 새로운 url로 변경되도록 했다. 이에 맞게 `window.location.search`를 통해 변경된 url의 쿼리스트링에서 검색어를 추출해 객체로 만들어 `HospitalList.tsx`에서 사용할 수 있도록 했다.

 

3. 각 컴포넌트에서 커스텀 훅을 사용한 부분

바로 위에서 말한 부분을 코드에서 어떻게 사용했는지 살펴보자.

// SearchForm.tsx

const SearchForm = ({ brtcObj, regionInfo, searchParams }: SearchFormProps) => {
  ...
  const [currentQuery, setCurrentQuery] = useState(new URLSearchParams(searchParams).toString());
  const [currentParams, setQueryParams] = useQueryParams(currentQuery);
  const [params, setParams] = useState<{ brtcCd: string; sggCd: string; addr: string; org: string }>({
    brtcCd: currentParams.brtcCd ?? BRTC,
    sggCd: currentParams.sggCd ?? SGG,
  });
  const { step, setStep } = useHospitalContext((state) => state);

  // searchParams, currentParams가 바뀔 때마다 재실행
  useEffect(() => {
    if (!currentParams.brtcCd || !currentParams.sggCd) {
      setStep(0);
    } else {
      setStep(1);
    }
    setParams({
      brtcCd: currentParams.brtcCd ?? BRTC,
      sggCd: currentParams.sggCd ?? SGG,
    });
  }, [searchParams, currentParams]);

  const onButtonClick = () => {
    const newURL = createQueryParams({ ...params, pageNo: "1" }, pathname);
    if (newURL !== pathname + "?" + currentQuery) {
      setCurrentQuery(new URLSearchParams({ ...params, pageNo: "1" }).toString());
      setQueryParams({ ...params, pageNo: "1" });
    }
    setStep(1);
  };

  return (
    ...
  )
};

 

`params`는 사람들이 입력한 검색어를 저장하는 변수이다. 처음에는 `page.tsx`에서 가져온 `searchParams`를 통해 초기화하고, 이후에는 검색 버튼을 눌러 `currentQuery`가 변경되면 방금 적용한 커스텀 훅이 반환하는 검색어 객체를 적용했다.

 

// HospitalList.tsx
const HospitalList = ({ searchParams, user }: HospitalListProps) => {
  const { step } = useHospitalContext((state) => state);
  const [params] = useQueryParams(new URLSearchParams(searchParams).toString());

  const [brtcCd, sggCd] = [
    params.brtcCd ?? "",
    params.sggCd ?? "",
  ];

  const {
    data: hospitalData,
    isLoading,
    isFetching,
    isError,
    error
  } = useHospitalQuery(brtcCd, sggCd);
  
  ...
  };

 

이처럼 커스텀 훅에서 가져온 값이 변하면 TanStack Query로 만든 커스텀 훅에도 다른 쿼리키를 입력하게 되므로 알아서 새로운 정보를 불러오게 된다.

 


참고한 블로그