Loading...
본문 바로가기
👥
총 방문자
📖
0개 이상
총 포스팅
🧑
오늘 방문자 수
📅
0일째
블로그 운영

여러분의 방문을 환영해요! 🎉

다양한 개발 지식을 쉽고 재미있게 알려드리는 블로그가 될게요. 함께 성장해요! 😊

코딩 정보/NextJs

서버 컴포넌트에서 쿼리 파라미터 프롭스 드릴링 해결하기

by 꽁이꽁설꽁돌 2025. 10. 13.
728x90
반응형

 

서버컴포넌트의 쿼리 파라미터 프롭스 드릴링 현상을 해결하면서

nuqs 라이브러리를 도입한 이유를 설명하고자 한다.

 

기존 방식의 문제

 

기존 방식의 문제점1 - 프롭스 드릴링

fsd 폴더 구조로 인해서 계속해서 searchParams를 넘겨주어야 하는 현상이 발생했고 

그 과정에서 프롭스 드릴링으로 각 파일마다 불필요한 타입 선언 임포트와 프롭스 드릴링이 발생했다.

import RecruitPage from '@/views/recruit/ui/recruit-page';
import { RecruitmentSearchParams } from '@/shared/model/recruit-type';

export const revalidate = 1800;

function Page({
  searchParams,
}: {
  searchParams: Promise<RecruitmentSearchParams>;
}) {
  return <RecruitPage searchParams={searchParams} />;
}

export default Page;

 

import RecruitHeader from '@/entities/recruit/ui/recruit-header';
import RecruitItemList from '@/widgets/recruit/ui/recruit-item-list';
import ScrollTopButton from '@/shared/ui/scroll-top-button';
import { Suspense } from 'react';
import ItemListSkeletonLoading from '@/shared/ui/item-list-skeleton-loading';
import { RecruitmentSearchParams } from '@/shared/model/recruit-type';

async function RecruitPage({
  searchParams,
}: {
  searchParams: Promise<RecruitmentSearchParams>;
}) {
  return (
    <>
      <div className="w-full sm:w-4xl lg:w-6xl">
        <Suspense
          fallback={
            <ItemListSkeletonLoading
              title="모집 공고"
              description={
                '관심 있는 동아리의 최신 모집 공고를\n한눈에 확인할 수 있어요.'
              }
              header
            />
          }
        >
          <RecruitHeader />
          <RecruitItemList searchParams={searchParams} />
        </Suspense>
      </div>
      <ScrollTopButton />
    </>
  );
}
export default RecruitPage;

 

import { ClubCategory } from '@/shared/model/type';
import ErrorBoundaryUi from '@/shared/ui/error-boundary-ui';
import { headers } from 'next/headers';
import { RecruitmentSearchParams } from '@/shared/model/recruit-type';
import RecruitItemClientList from './recruit-item-client-list';
import getClubRecruitList from '../api/getClubRecruitList';

function getInitialLayout(userAgent: string) {
  const isMobile = /iPhone|iPad|iPod|Android/i.test(userAgent);
  const isTablet =
    /iPad|Android/i.test(userAgent) && !/Mobile/i.test(userAgent);

  if (isMobile) return { columns: 1, cardHeight: 140 };
  if (isTablet) return { columns: 2, cardHeight: 140 };
  return { columns: 3, cardHeight: 198 };
}

async function RecruitItemList({
  searchParams,
}: {
  searchParams: Promise<RecruitmentSearchParams>;
}) {
  const params = await searchParams;

  const headersList = headers();
  const userAgent = (await headersList).get('user-agent') || '';
  const { columns, cardHeight } = getInitialLayout(userAgent);

  const res = await getClubRecruitList({
    page: Number(params.page || 1),
    size: Number(params.size || 1000),
    category: params.category?.toUpperCase() as ClubCategory,
  });

  if (!res.ok || !res.data) {
    return <ErrorBoundaryUi />;
  }
...생략

 

기존 방식의 문제점2 - 단언에 의존한 불명확한 타입 처리로 인한 코드의 불안정성과 가독성 저하

다음과 같이 타입이 옵셔널로 도배된것을 볼 수 있다.

또한 타입변환을 일일히 지정해주어야하고 초기값 설정으로 코드의 가독성이 현저하게 떨어지는 것을 볼 수 있다.

export interface RecruitItemListProps {
  searchParams: Promise<{
    page?: string;
    size?: string;
    keyword?: string;
    category?: string;
    affiliation?: string;
    recruitStatus?: string;
  }>;
}
  const res = await getClubRecruitList({
    page: Number(params.page || 1),
    size: Number(params.size || 1000),
    category: params.category?.toUpperCase() as ClubCategory,
  });

 

 

nuqs 라이브러리란?

Next.js 환경에서 URL 쿼리스트링과 애플리케이션 상태(state)를 간편하게 동기화해주는 라이브러리다.

 

엥 그런데 왜 이걸 뜬금없이 도입한거지?

공식문서에는 다음과 같은 사용방법이 존재한다.

 

페이지 컴포넌트가 아닌, 깊이 중첩된 서버 컴포넌트에서 searchParams에 접근하고 싶다면, createSearchParamsCache를 사용하면 타입 안전한 방식으로 접근할 수 있습니다.

// searchParams.ts
import {
  createSearchParamsCache,
  parseAsInteger,
  parseAsString
} from 'nuqs/server'
// Note: import from 'nuqs/server' to avoid the "use client" directive

export const searchParamsCache = createSearchParamsCache({
  // List your search param keys and associated parsers here:
  q: parseAsString.withDefault(''),
  maxResults: parseAsInteger.withDefault(10)
})

// page.tsx
import { searchParamsCache } from './searchParams'

export default function Page({
  searchParams
}: {
  searchParams: Record<string, string | string[] | undefined>
}) {
  // ⚠️ Don't forget to call `parse` here.
  // You can access type-safe values from the returned object:
  const { q: query } = searchParamsCache.parse(searchParams)
  return (
    <div>
      <h1>Search Results for {query}</h1>
      <Results />
    </div>
  )
}

function Results() {
  // Access type-safe search params in children server components:
  const maxResults = searchParamsCache.get('maxResults')
  return <span>Showing up to {maxResults} results</span>
}

 

 

그러면 사이드이펙트는 없을까?

이걸 도입해서 확실히 나아질 수 있나 한번 살펴보자

 

1. 컨텍스트에 대한 분리가 잘 이루어지는가?

내부적으로 nodejs의 AsyncLocalStorage을 활용하고 있다.

AsyncLocalStorage.run(store, fn) 

run() 함수는 새로운 비동기 context를 생성하고, 이 context는 현재 실행 중인 call stack에서 실행된 모든 비동기 작업에 전파된다.

 

따라서 nuqs는 내부적으로 Node.js의 AsyncLocalStorage를 사용해
React 서버 렌더링 요청 단위로 searchParams를 안전하게 캐싱하여

하위 컴포넌트에 전파하기 때문에 적절한 컨텍스트 분리가 이루어 진다.

 

 

검증

아래와 같이 보면 자식 서버 컴포넌트에서 사용하지 않을 경우 오류가 생기는 것을 볼 수 있다.

Error: [nuqs] Empty search params cache. Search params can't be accessed in Layouts.
  See https://nuqs.dev/NUQS-500
  in get(page)
    at ClubItemList (club-item-list.tsx:10:34)
    at resolveErrorDev (react-server-dom-turbopack-client.browser.development.js:1858:46)
    at processFullStringRow (react-server-dom-turbopack-client.browser.development.js:2238:17)
    at processFullBinaryRow (react-server-dom-turbopack-client.browser.development.js:2226:7)
    at progress (react-server-dom-turbopack-client.browser.development.js:2472:17)

The above error occurred in the <ClubItemList> component. It was handled by the <ErrorBoundaryHandler> error boundary.

 

 

2. 코드 가독성이 확실히 증대했는가?

import {
  createSearchParamsCache,
  parseAsString,
  parseAsInteger,
} from 'nuqs/server';

export const searchParamsCache = createSearchParamsCache({
  page: parseAsInteger.withDefault(1),
  size: parseAsInteger.withDefault(20),
  keyword: parseAsString.withDefault(''),
  category: parseAsString.withDefault(''),
  affiliation: parseAsString.withDefault(''),
  recruitStatus: parseAsString.withDefault(''),
});

export type RecruitSearchParams = ReturnType<typeof searchParamsCache.parse>;

 

타입 검증(type validation)타입 변환(type casting) 을 동시에 수행하는 타입 세이프 파서(typed parser)역할을 한것을 볼수 있다. 따라서 기존에는 옵셔널 체이닝으로 진행하고 초기값도 ||로 하던 것보다 훨씬 더 깔끔하고 안정된 것을 볼 수 있다.

import RecruitPage from '@/views/recruit/ui/recruit-page';
import { type SearchParams } from 'nuqs/server';
import { searchParamsCache } from './search-params';

export const revalidate = 1800;

type PageProps = {
  searchParams: Promise<SearchParams>;
};

async function Page({ searchParams }: PageProps) {
  await searchParamsCache.parse(searchParams);
  return <RecruitPage />;
}

export default Page;

 

처음 선언으로 캐싱만 잘해주면 다음과 같이 컨텍스트 단위별로 캐싱 값을 가져와 잘 쓰는 것을 볼 수 있다.

import { ClubCategory } from '@/shared/model/type';
import ErrorBoundaryUi from '@/shared/ui/error-boundary-ui';
import { headers } from 'next/headers';
import { searchParamsCache } from '@/app/(main)/recruit/search-params';
import RecruitItemClientList from './recruit-item-client-list';
import getClubRecruitList from '../api/getClubRecruitList';

function getInitialLayout(userAgent: string) {
  const isMobile = /iPhone|iPad|iPod|Android/i.test(userAgent);
  const isTablet =
    /iPad|Android/i.test(userAgent) && !/Mobile/i.test(userAgent);

  if (isMobile) return { columns: 1, cardHeight: 140 };
  if (isTablet) return { columns: 2, cardHeight: 140 };
  return { columns: 3, cardHeight: 198 };
}

async function RecruitItemList() {
  const page = searchParamsCache.get('page');
  const size = searchParamsCache.get('size');
  const category = searchParamsCache.get('category');

  const headersList = headers();
  const userAgent = (await headersList).get('user-agent') || '';
  const { columns, cardHeight } = getInitialLayout(userAgent);

  const res = await getClubRecruitList({
    page,
    size,
    category: category.toUpperCase() as ClubCategory,
  });

...생략

 

 

이를 통해 확실히 코드 가독성, 타입 검증 그리고 프롭스 드릴링을 해결한 것을 볼 수 있다.

 

 

3. 기타 고려

1. 번들 사이즈

https://www.npmjs.com/package/nuqs

427 kB로 그렇게까지 부담이 되는 사이즈는 아니다.

 

2. npm에 대한 후기들

reddit을 통해 계속 살펴본 결과 후기가 대체적으로 좋은 것을 볼 수 있다.

또한 npm 다운로드 수가 검증하고 있다.

 

3. 최신 업데이트가 이루어지고 있는가?

https://github.com/47ng/nuqs/pulls

 

GitHub - 47ng/nuqs: Type-safe search params state manager for React frameworks - Like useState, but stored in the URL query stri

Type-safe search params state manager for React frameworks - Like useState, but stored in the URL query string. - 47ng/nuqs

github.com

 

현재까지도 활발히 pr과 이슈가 올라오고 있다.

 

 

 

도입 후에도 의존성 문제와 버그 추적은 계속해서 해보아야 할 것 같다.

지금은 쓰는데 큰 문제가 없을 것 같다.

반응형