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.tsx
나 Suspense
의 fallback UI가 표시되지 않고, 화면이 갑자기 바뀌는 듯한 현상이 나타났습니다.
또한, 필터 버튼이나 페이지네이션 UI도 쿼리스트링이 실제 반영된 후에야 늦게 업데이트되어, 클릭했는데 아무런 반응이 없는 것처럼 느껴지는 경험을 하게 됐습니다.
이 문제를 확인하기 위해, Next.js App Router 기반의 간단한 페이지를 만들었습니다. 이 페이지는 쿼리스트링을 기반으로 데이터를 패칭하고, 4초후 필터링된 결과를 보여주는 기능을 가지고 있습니다. 첨부된 영상을 보시면, 일반적인 페이지 이동시에는 suspense가 작동을 하지만, 쿼리스트링만 변경했을경우에는 suspense가 작동하지 않고, 서버응답이 끝난후에야 UI가 업데이트되는 것을 확인할 수 있습니다.
https://github.com/cksrlcks/suspense_test
Next.js App Router에서는 router.push()로 쿼리스트링만 변경하는 경우, pathname이 동일하다면 기존의 layout과 page 컴포넌트를 언마운트하지 않고 유지한 채 React 내부에서 일부만 다시 렌더링합니다. 이 과정에서 loading.tsx는 트리거되지 않으며, Suspense fallback도 실행되지 않습니다.
즉, 서버에서 데이터를 다시 받아오고 있음에도 불구하고, 사용자는 그 사이의 로딩 과정을 전혀 인지할 수 없습니다. 이로 인해 UI는 분명히 업데이트되고 있지만, 로딩 중이라는 시각적 힌트가 없기 때문에 화면이 갑자기 바뀌는 듯한 부자연스러운 UX가 발생합니다.
또한, 쿼리스트링을 기반으로 렌더링되는 페이지 번호나 활성 필터 등의 상태는 router.push() 직후에는 반영되지 않고, 서버 컴포넌트가 새로운 데이터를 받아 다시 렌더링을 마친 뒤에야 클라이언트에 반영됩니다. (아래 테스트 영상 참고)
결과적으로 버튼을 클릭해도 즉각적인 반응 없이 지연된 화면 변경이 일어나기 때문에, "클릭했는데 아무 일도 안 일어나는 것 같은" 인상을 주는 문제가 발생합니다.
다음 영상은, 데이터패칭을 강제로 4초 지연시킨후 쿼리스트링을 변경했을 때의 모습입니다. (실제 서비스에서는 이렇게 느리게 동작하지 않지만, 문제를 이해하기 쉽게 하기 위해 의도적으로 지연시켰습니다.)
Suspense
의 리마운트를 유도하고 싶다면, key
를 강제로 부여하는 방식으로 가능합니다.useTransition()
의 isPending
상태를 활용합니다.useOptimistic()
을 활용해 낙관적으로 처리합니다.기존에는 쿼리스트링을 기반으로 활성 페이지를 표시했기 때문에, 페이지네이션 시 데이터가 모두 로드된 이후에야 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
이 중간에 끊김여러 개의 필터 조건(검색어, 카테고리, 페이지 번호 등)이 동시에 바뀔 수 있기 때문에, 이들의 transition 상태를 전역에서 통합적으로 관리할 수 있도록 Context API를 사용했습니다.
하지만, 검색창이나 페이지네이션 버튼처럼 단일 동작만 처리하는 컴포넌트에서는 내부적으로도 별도로 useTransition을 사용해 자체적인 로딩 상태를 관리했습니다.
예를 들어 검색창의 경우, 사용자가 엔터를 누르면 검색창 자체의 로딩 UI가 즉시 반응하고, 동시에 전역 transition이 실행되어 리스트를 다시 불러오게 됩니다.
이렇게 하면, 검색창은 빠르게 반응하고, 리스트는 자연스럽게 스켈레톤으로 전환되어 여러 파라미터가 함께 동작하더라도 부자연스럽지 않은 UX를 만들 수 있습니다.
"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>
);
}
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>
)}
/>
);
}
"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를 확인할 수 있습니다.