Next.js App Router에서 쿼리스트링을 통한 상태 변경시 주의할 점

2025-05-16

Next.js의 App Router에서 SSR(Server Side Rendering) 기반으로 페이지를 구성하다 보면, 검색어, 필터, 페이지 번호 등 쿼리스트링을 기반으로 데이터를 패칭해야 하는 상황이 자주 발생합니다.

예를 들어 ?keyword=nextjs&page=2 같은 URL을 통해 조건에 맞는 데이터를 다시 가져오고, 이에 따라 UI를 업데이트하게 됩니다.

하지만 이 과정에서 쿼리스트링만 변경했을 뿐인데도 UI가 멈춘 듯 보이는 현상이 자주 발생합니다. 이 글에서는 이러한 현상이 발생하는 이유와, Next.js App Router 환경에서 어떻게 더 나은 사용자 경험을 만들 수 있는지를 설명합니다.

현상

검색어나 카테고리, 페이지 번호 등을 바꿔가며 원하는 조건에 맞는 데이터를 조회하는 과정에서, 쿼리스트링을 변경하는 일이 자주 있었습니다. 예를 들어 ?keyword=nextjs&page=2처럼 URL을 수정해 필터링된 결과를 확인하는 경우입니다.

그런데 쿼리스트링을 바꾼 이후, 데이터를 새로 패칭하는 동안 loading.tsxSuspense의 fallback UI가 표시되지 않고, 화면이 갑자기 바뀌는 듯한 현상이 나타났습니다.

또한, 필터 버튼이나 페이지네이션 UI도 쿼리스트링이 실제 반영된 후에야 늦게 업데이트되어, 클릭했는데 아무런 반응이 없는 것처럼 느껴지는 경험을 하게 됐습니다.

테스트

이 문제를 확인하기 위해, Next.js App Router 기반의 간단한 페이지를 만들었습니다. 이 페이지는 쿼리스트링을 기반으로 데이터를 패칭하고, 4초후 필터링된 결과를 보여주는 기능을 가지고 있습니다. 첨부된 영상을 보시면, 일반적인 페이지 이동시에는 suspense가 작동을 하지만, 쿼리스트링만 변경했을경우에는 suspense가 작동하지 않고, 서버응답이 끝난후에야 UI가 업데이트되는 것을 확인할 수 있습니다.

https://github.com/cksrlcks/suspense_test 쿼리스트링변경_지연UI

원인

Next.js App Router에서는 router.push()로 쿼리스트링만 변경하는 경우, pathname이 동일하다면 기존의 layout과 page 컴포넌트를 언마운트하지 않고 유지한 채 React 내부에서 일부만 다시 렌더링합니다. 이 과정에서 loading.tsx는 트리거되지 않으며, Suspense fallback도 실행되지 않습니다.

즉, 서버에서 데이터를 다시 받아오고 있음에도 불구하고, 사용자는 그 사이의 로딩 과정을 전혀 인지할 수 없습니다. 이로 인해 UI는 분명히 업데이트되고 있지만, 로딩 중이라는 시각적 힌트가 없기 때문에 화면이 갑자기 바뀌는 듯한 부자연스러운 UX가 발생합니다.

또한, 쿼리스트링을 기반으로 렌더링되는 페이지 번호나 활성 필터 등의 상태는 router.push() 직후에는 반영되지 않고, 서버 컴포넌트가 새로운 데이터를 받아 다시 렌더링을 마친 뒤에야 클라이언트에 반영됩니다. (아래 테스트 영상 참고)

결과적으로 버튼을 클릭해도 즉각적인 반응 없이 지연된 화면 변경이 일어나기 때문에, "클릭했는데 아무 일도 안 일어나는 것 같은" 인상을 주는 문제가 발생합니다.

다음 영상은, 데이터패칭을 강제로 4초 지연시킨후 쿼리스트링을 변경했을 때의 모습입니다. (실제 서비스에서는 이렇게 느리게 동작하지 않지만, 문제를 이해하기 쉽게 하기 위해 의도적으로 지연시켰습니다.)

쿼리스트링변경_지연UI

해결방법

  1. Suspense의 리마운트를 유도하고 싶다면, key를 강제로 부여하는 방식으로 가능합니다.
  2. 실질적인 대응 전략
  • 서버 데이터를 기다리는 동안 로딩 UI를 보여주고 싶다면, useTransition()isPending 상태를 활용합니다.
  • 쿼리스트링 기반으로 즉각 반영되어야 하는 UI 상태는 useOptimistic()을 활용해 낙관적으로 처리합니다.

1차로 해결한 코드 (페이지네이션)

기존에는 쿼리스트링을 기반으로 활성 페이지를 표시했기 때문에, 페이지네이션 시 데이터가 모두 로드된 이후에야 UI가 반영되었습니다. 이제는 낙관적 상태를 활용해클릭 즉시 활성 페이지가 표시되도록 했습니다.

const [optimisticPage, setOptimisticPage] = useOptimistic(
  currentPage,
  (_, next: number) => next,
);

function handleClick(page: number) {
  const params = new URLSearchParams(searchParams);
  params.set("page", String(page));

  startTransition(() => {
    setOptimisticPage(page); // 👈 낙관적 업데이트
    router.push(`${pathname}?${params}`);
  });
}

{/* UI에서는 optimisticPage 사용 */}
{[...Array(totalPage)].map((_, i) => {
  const page = i + 1;
  return (
    <PaginationItem key={page}>
      <PaginationLink
        isActive={page === optimisticPage}
        onClick={() => handleClick(page)}
      >
        {page}
      </PaginationLink>
    </PaginationItem>
  );
})}

만약에 복합적인 여러 파라미터를 사용하는 경우에는? - 각각의 필터링 상태를 하나의 트랜지션으로 묶어야 하는 이유

여러 개의 쿼리 파라미터(예: keyword, category, page)가 함께 변경되는 경우에는 각 상태를 따로 처리하면 중간에 isPending이 끊기거나 UI가 깜빡이는 현상이 발생할 수 있습니다. 이를 하나의 useTransition 안에서 묶어 처리하면, 모든 상태 변화가 하나의 흐름으로 연결되어 일관된 로딩 상태와 부드러운 사용자 경험을 만들 수 있습니다.

복합적인필터링

  • 검색, 페이지네이션, 필터링이 동시에 동작할 수 있는 상황
  • 이 중 하나만 바뀌는 것이 아니라, 검색 → 필터 → 페이지 이동연쇄적으로 일어날 수 있음
  • 이걸 하나의 트랜지션으로 묶지 않으면 아래 문제가 생김:
    • isPending이 중간에 끊김
    • 로딩 UI가 flicker 현상을 일으킴
    • 낙관적 상태와 실제 데이터 간의 불일치 타이밍이 길어짐

복합적인 상황을 해결하기 위한 선택한 방법

여러 개의 필터 조건(검색어, 카테고리, 페이지 번호 등)이 동시에 바뀔 수 있기 때문에, 이들의 transition 상태를 전역에서 통합적으로 관리할 수 있도록 Context API를 사용했습니다.

하지만, 검색창이나 페이지네이션 버튼처럼 단일 동작만 처리하는 컴포넌트에서는 내부적으로도 별도로 useTransition을 사용해 자체적인 로딩 상태를 관리했습니다.

예를 들어 검색창의 경우, 사용자가 엔터를 누르면 검색창 자체의 로딩 UI가 즉시 반응하고, 동시에 전역 transition이 실행되어 리스트를 다시 불러오게 됩니다.

이렇게 하면, 검색창은 빠르게 반응하고, 리스트는 자연스럽게 스켈레톤으로 전환되어 여러 파라미터가 함께 동작하더라도 부자연스럽지 않은 UX를 만들 수 있습니다.

최종코드

FilterContext

"use client";

import { createContext, useContext, useOptimistic, useTransition } from "react";
import { useRouter, useSearchParams } from "next/navigation";

const FilterContext = createContext(/* ... */);

export function useFilters() {
  return useContext(FilterContext)!;
}

export default function FilterProvider({ basePath = '/' , children }) {
  const router = useRouter();
  const searchParams = useSearchParams();
  
  const initialFilters = Object.fromEntries(searchParams.entries());
  const [isPending, startTransition] = useTransition();
  const [optimisticFilters, setOptimisticFilters] = useOptimistic(
    initialFilters,
    (prev, next) => ({ ...prev, ...next }),
  );

  function updateFilters(nextFilters) {
    const newState = {
      ...optimisticFilters,
      ...nextFilters,
    };
    const newSearchParams = new URLSearchParams(newState);

    Object.entries(newState).forEach(([key, value]) => {
		  if (value !== undefined) {
		    newSearchParams.set(key, String(value));
		  }
		});

    startTransition(() => {
      setOptimisticFilters(updates || {});
      router.push(`${basePath}?${newSearchParams}`);
    });
  }

  return (
    <FilterContext.Provider
      value={{ filters: optimisticFilters, isPending, updateFilters }}
    >
      {children}
    </FilterContext.Provider>
  );
}

FilterContext의 isPending 사용 (리스트 컴포넌트)

export default function RoadmapList({ data, keyword }: RoadmapListProps) {
  const { isPending } = useFilters();

	// 카테고리, 페이지네이션, 검색이 복합적으로 반영된 isPending 상태
  if (isPending) {
    return (
      <GridList
        skeleton
        items={[...Array(6)]}
        renderItem={() => <RoadmapCardSkeleton />}
      />
    );
  }

  if (!data || data.length === 0) {
    return (
      ...
    );
  }

  return (
    <GridList
      items={data}
      renderItem={(item) => (
        <Link href={`/roadmap/${item.externalId}`}>
          <RoadmapCard roadmap={item} />
        </Link>
      )}
    />
  );
}

개별적인 transition이 필요할 경우 (검색 컴포넌트)

"use client";

export default function Search({ placeholder }: SearchProps) {
  const [isPending, startTransition] = useTransition();
  const { filters, updateFilters } = useFilters();
  const [value, setValue] = useState(filters?.keyword || "");

  const handleTagKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
    if (e.key !== "Enter") return;

    e.preventDefault();

    const keyword = (e.target as HTMLInputElement).value;
    const trimmedKeyword = keyword.trim();

    startTransition(() => {
      updateFilters({ keyword: trimmedKeyword });
    });
  };

  useEffect(() => {
    setValue(filters?.keyword || "");
  }, [filters?.keyword]);

  return (
    <div
      className={cn(
        "...",
        isPending && "opacity-80", // 개별적인 pending 상태 UI
      )}
    >
      <input
        type="text"
        placeholder={placeholder || "검색"}
        value={value}
        onChange={(e) => setValue(e.target.value)}
        onKeyDown={handleTagKeyDown}
      />
      {/* 개별적인 pending 상태 UI */}
      {isPending && <Spinner className="h-4 w-4" />}
    </div>
  );
}

해결된 모습

이제는 데이터 패칭이 지연되더라도, 선택한 카테고리는 낙관적 업데이트를 통해 즉시 UI에 반영되며, 더 자연스럽고 빠르게 반응하는 UX를 확인할 수 있습니다.

쿼리스트링변경_해결UI