Next.js URL 기반 테이블 상태 관리 구현하기

2025년 09월 20일

페이지네이션, 필터링, 정렬처럼 사용자 인터랙션이 많은 테이블은 상태 관리가 쉽게 복잡해집니다. 특히 Next.js 앱 라우터의 서버 사이드 환경에서는 이러한 클라이언트 상태를 어떻게 다룰지가 고민거리입니다.

클라이언트 사이드에서 필터를 변경했을 때 서버에서 데이터를 어떻게 가져올지, URL이나 브라우저 뒤로가기 같은 웹의 기본 기능들을 어떻게 지원할지 등 고려해야 할 점이 많습니다.

이 글에서는 @tanstack/react-tablenuqs를 조합하여 URL 기반의 테이블 상태 관리 시스템을 구축하는 방법을 다룹니다.

핵심 아이디어는 테이블의 모든 상태(페이지, 필터, 정렬 등)를 URL에 저장하여 상태를 공유 가능하게 만들고, 클라이언트의 상태 변경이 서버의 새로운 데이터 페칭으로 이어지는 구조입니다.


전체 아키텍처 개요

구현할 기능의 전체 동작 과정을 정리하면

사용자 인터랙션 (필터 변경, 페이지네이션 등)
    ↓
클라이언트 사이드에서 URL 업데이트 (shallow: false)
    ↓
서버사이드 렌더링 및 URL 파라미터 파싱
    ↓
새로운 파라미터로 API 호출
    ↓
클라이언트로 새 데이터 전달 및 UI 업데이트

URL 파라미터 스키마 정의하기

테이블에서 어떤 정보들을 URL에 담을지 먼저 정해야 합니다.

//search-params.ts
import { parseAsInteger, parseAsString, createSearchParamsCache } from 'nuqs/server';
 
export const searchParamsSchema = {
  page: parseAsInteger.withDefault(1),
  perPage: parseAsInteger.withDefault(10),
  email: parseAsString,
  role: parseAsString,
  createdAt: parseAsString,
};
 
export const searchParamsCache = createSearchParamsCache(searchParamsSchema);

nuqs에서 제공하는 searchParamsCache는 URL 쿼리 파라미터를 파싱하고 타입 변환하여 메모리에 저장하는 캐시 객체입니다.

// URL: /users?page=2&email=john&perPage=20
 
// 이렇게 변환됩니다:
{
  page: 2,
  perPage: 20,
  email: "john",
  role: undefined,
  createdAt: undefined
}

searchParamsCache는 서버 사이드에서만 사용가능합니다.

서버에서 URL 기반 데이터 가져오기

사용자가 URL을 변경하면 Next.js는 이를 새로운 페이지 요청으로 인식하고, 가장 먼저 page.tsx 컴포넌트를 실행합니다.

// page.tsx
export default async function Page(props: Props) {
  const searchParams = await props.searchParams;
  searchParamsCache.parse(searchParams); // 캐시에 URL 정보 저장
 
  return (
    <Suspense fallback={<DataTableSkeleton />}>
      <UserListingPage />
    </Suspense>
  );
}

/users?page=2&email=john 같은 URL에서 쿼리 파라미터를 추출하고, 미리 정의된 스키마에 따라 page는 숫자로, email은 문자열로 타입을 변환한 뒤 메모리의 캐시에 저장합니다.

캐시에 저장된 파라미터들은 이후 하위 컴포넌트들에서 타입 안전하게 접근할 수 있게 됩니다.

URL 파라미터를 API 요청으로 변환하는 컴포넌트

// UserListingPage.tsx
export default async function UserListingPage() {
  const page = searchParamsCache.get('page');
  const email = searchParamsCache.get('email');
  const limit = searchParamsCache.get('perPage');
  const role = searchParamsCache.get('role');
  const createdAt = searchParamsCache.get('createdAt');
 
  const filters = {
    page,
    limit,
    ...(email && { email }),
    ...(role && { role }),
    ...(createdAt && { createdAt }),
  };
 
  // 실제 API 호출
  const response = await getUserList(filters);
  const users = response.data ?? [];
  const pageCount = response.totalPages ?? 1;
 
  // 데이터를 클라이언트 컴포넌트로 전달
  return <UserTable data={users} pageCount={pageCount} columns={columns} />;
}

클라이언트에서 테이블 상태 관리

// UserTable.tsx
'use client'
 
export function UserTable({ data, pageCount, columns }) {
  const { table } = useDataTable({
    data,
    columns,
    pageCount,
    shallow: false, // 서버 리페칭을 위해 false
    debounceMs: 500,
  });
 
  return (
    <DataTable table={table}>
      <DataTableToolbar table={table} />
    </DataTable>
  );
}

shallow 옵션에 따라 URL 변경 시 동작이 결정됩니다.

shallow: true로 설정하면 URL만 업데이트되고 shallow routing이 적용되어 서버 데이터 요청이 다시 발생하지 않습니다. 따라서 클라이언트 전용 상태 관리에 적합합니다.
shallow: false로 설정하면 Next.js가 URL 변경을 새로운 네비게이션으로 인식해 서버 렌더링이 다시 수행되고, 필요한 경우 서버에서 새 데이터를 가져옵니다.

우리는 테이블 상태 변경이 서버의 데이터 페칭으로 이어져야 하므로 shallow: false를 사용합니다.

useDataTable 훅

nuqs 옵션 설정

// useDataTable.ts
export function useDataTable<TData>(props: UseDataTableProps<TData>) {
  const {
    debounceMs = 300,
    shallow = true,
    startTransition,
    // ...
  } = props;
 
  // nuqs에 전달할 옵션들
  const queryStateOptions = React.useMemo(() => ({
    history: 'replace',
    scroll: false,
    shallow,
    throttleMs: 50,
    debounceMs,
    clearOnDefault: false,
    startTransition,
  }), [shallow, debounceMs, startTransition]);
}

nuqs는 URL 상태 관리를 위한 다양한 옵션들을 제공하는데, queryStateOptions 객체에 이런 설정들을 모아서 정의해둡니다. 이렇게 하면 이후에 사용할 모든 useQueryState 훅들이 동일한 동작 방식을 가지게 됩니다. 브라우저 히스토리 관리부터 성능 최적화, 스크롤 위치 유지까지 URL 상태 변경과 관련된 모든 세부 동작을 여기서 통합 관리하는 셈입니다.

URL과 React 상태 연결

// 페이지네이션 상태를 URL과 연결
const [page, setPage] = useQueryState(
  'page',
  parseAsInteger.withOptions(queryStateOptions).withDefault(1)
);
 
const [perPage, setPerPage] = useQueryState(
  'perPage',
  parseAsInteger.withOptions(queryStateOptions).withDefault(10)
);
 
const pagination: PaginationState = React.useMemo(() => {
  return {
    pageIndex: page - 1,    // URL은 1부터, React Table은 0부터 시작
    pageSize: perPage,
  };
}, [page, perPage]);

useQueryState를 사용하면 테이블의 모든 상태가 URL에 저장되어서 브라우저의 기본 기능들(뒤로가기, 새로고침, URL 공유)이 자연스럽게 작동합니다. 무엇보다 사용자가 필터를 변경할 때마다 URL이 업데이트되고, 이것이 서버에서 새로운 데이터를 가져오는 트리거가 됩니다.


필터링 시스템 구현

동적 파서 생성
테이블에서 다양한 타입의 필터를 지원하려면 각 컬럼에 맞는 URL 처리 방식이 필요합니다. 이메일 검색은 단순 텍스트이지만, 역할 선택은 여러 값을 선택할 수 있고, 날짜는 범위로 처리해야합니다.

const columns = [
  {
    id: 'email',
  },
  {
    id: 'role',
    meta: {
      options: ['admin', 'user', 'guest']  // 다중 선택
    }
  },
  {
    id: 'createdAt',
    meta: {
      variant: 'dateRange'
    }
  }
];
 
const filterParsers = React.useMemo(() => {
  return filterableColumns.reduce((acc, column) => {
    const columnId = column.id ?? '';
    if (!columnId) return acc;
 
    if (column.meta?.variant === 'dateRange') {
       // URL: ?createdAt=2023-01-01,2023-12-31
       // 결과: ["2023-01-01", "2023-12-31"]
      acc[columnId] = parseAsArrayOf(parseAsString, ',').withOptions(queryStateOptions);
    } else if (column.meta?.options) {
      // URL: ?role=admin,user
      // 결과: ["admin", "user"]
      acc[columnId] = parseAsArrayOf(parseAsString, ',').withOptions(queryStateOptions);
    } else {
      acc[columnId] = parseAsString.withOptions(queryStateOptions);
    }
    return acc;
  }, {});
}, [filterableColumns, queryStateOptions]);
 
const [filterValues, setFilterValues] = useQueryStates(filterParsers);

결과적으로 URL이 /users?email=john&role=admin,user&createdAt=2023-01-01,2023-12-31이면:

filterValues = {
  email: "john",
  role: ["admin", "user"],
  createdAt: ["2023-01-01", "2023-12-31"]
}

디바운싱 적용
성능을 위해 디바운싱을 적용합니다. 사용자가 500ms 동안 입력을 멈춘 후에야 URL이 업데이트되고 서버에서 새 데이터를 가져옵니다.

const debouncedSetFilterValues = useDebouncedCallback((values) => {
  void setPage(1);
  void setFilterValues(values);
}, debounceMs);

필터 삭제와 URL 정리

사용자가 검색 필터를 지웠을 때 URL에서도 해당 파라미터를 제거해야 합니다.

const onColumnFiltersChange = React.useCallback(
  (updaterOrValue: Updater<ColumnFiltersState>) => {
    setColumnFilters((prev) => {
      const next = typeof updaterOrValue === 'function'
        ? updaterOrValue(prev)
        : updaterOrValue;
 
      // 1. 현재 활성화된 필터들을 URL 업데이트 객체에 수집
      const filterUpdates = next.reduce((acc, filter) => {
        if (filterableColumns.find(column => column.id === filter.id)) {
          acc[filter.id] = filter.value as string | string[];
        }
        return acc;
      }, {});
 
      // 2. 삭제된 필터들을 찾아서 null 값으로 설정 (URL에서 제거됨)
      for (const prevFilter of prev) {
        if (!next.some(filter => filter.id === prevFilter.id)) {
          filterUpdates[prevFilter.id] = null;
        }
      }
 
      debouncedSetFilterValues(filterUpdates);
      return next;
    });
  },
  [debouncedSetFilterValues, filterableColumns]
);

마무리

이 글에서 다룬 내용은 Next.js 서버 컴포넌트 환경에서 클라이언트의 테이블 인터랙션을 URL을 통해 서버의 데이터 페칭과 연결하는 구조입니다.

사용자가 필터를 변경하거나 페이지를 넘기면, useDataTable 훅이 URL을 업데이트하고, shallow: false 설정으로 인해 서버 컴포넌트가 새로운 searchParams와 함께 재실행됩니다.

이때 searchParamsCache가 URL 파라미터를 파싱해서 API 호출에 사용하고, 최종적으로 새로운 데이터가 클라이언트로 전달되는 구조입니다.

URL을 통한 상태 관리로 서버 렌더링의 성능 이점을 유지하면서도 클라이언트의 인터랙션을 자연스럽게 서버의 데이터 페칭과 연결할 수 있습니다. nuqs와 TanStack Table이 제공하는 타입 안전성과 함께 사용하면 복잡한 테이블 상태도 안정적으로 관리할 수 있습니다.