Gila - 메인 페이지 활동 리스트(app router searchParams)

박상준

2024년 10월 16일

1

Next.js
Gila

작업 분배

저번에 로그인과 회원가입 기능이 구현되었다. 다른 팀원분들이 만들어주신 로그인, 회원가입 폼과 연결하는 과정을 거쳤고 동작도 확인했다. 스타일까지 적용하지는 않았지만 기능구현자체는 끝이났다. 이제 메인 페이지를 작업하기로 결정했다.

페이지 컴포넌트 기획

포스트 이미지 기획은 이렇게 구성했다. 원래 시안에서 벗어나서 다시 컴포넌트를 배치하고 기능을 구상했다.

시안에서는 특정 부분에 하드코딩하라고 되어있는데 우리가 받아들이기 어려웠다. 그래서 그 부분을 바꿨다.

그래서 최종적으로 시안과 변동된 사항은 아래와 같다.

  • 상단 nav에 검색바 삽입
  • 하드코딩으로 정해진 부분을 캐러셀로 인기 액티비티 자동 슬라이드 적용

검색페이지 추가

그리고 원래 요구조건에서 없던 검색 페이지를 구분했다. 왜냐하면 두개의 페이지 목적이 다르기 때문이다. 기존의 메인페이지는 액티비티를 여러 카테고리와 필터에 따라 보여주게된다. 하지만 검색의 경우 해당 키워드로 얻은 결과만 보여주는게 맞다고 생각을 했다. 그리고 실제로 여러 서비스에서 검색했을때와 메인페이지의 레이아웃이 변동되는것도 확인을 했다. 그래서 /search?keyword=검색키워드의 경로를 추가하기로 결정했다.

내가 맡은 역할

나는 페이지네이션과 드롭다운 필터, 카테고리를 이용해 액티비티를 받아오는 부분을 맡았다. 이 부분을 선점한 이유는 페이지에서 핵심적인 기능이기도 하고 app router의 서버 렌더링을 체험해보고 searchparams를 이용해 데이터를 받아오도록 했기 때문에 부족한 경험을 체울수 있을것이라는 생각이였다.

액티비티 리스트

나는 다른 팀원과 같이 해당 부분을 개발하기로 했다. 나는 페이지네이션, 드롭다운, 카테고리버튼을 구현하기로 했고 팀원분은 서비스로직을 담당하셨다. 팀원분의 서비스 로직을 먼저 확인해보겠다.

fetcher

export const fetcher = async <T>(url: string): Promise<T> => {
  try {
    const response = await api.get<T>(url);
    const { data } = response;
    return data;
  } catch (error) {
    if (axios.isAxiosError(error)) {
      throw new Error(error.response?.data.message);
    }
    throw new Error('Internal Server Error');
  }
};

우선 앞으로 우리가 사용할 get요청에서 공통적으로 사용할 fetcher를 개발해주셨다. 그래서 fetcher를 사용하면 try...catch를 해주고 타입도 제네릭으로 처리해주는 역할을 한다.

const data = await fetcher<ActivityResponse>(`/activities${query}`);

실제로 이렇게 사용하면 된다. 제네릭타입으로 우리가 사용할 데이터의 타입을 넣어주면 모든 get요청에서 데이터에서 원하는 타입 추론이 가능하다.

data response

export const getActivities = async ({
  category,
  sort,
  page = 1,
  size,
}: Props): Promise<ActivityResponse> => {
  const query = `?method=offset${category ? `&category=${category}` : ''}${sort ? `&sort=${sort}` : ''}&page=${page}&size=${size}`;
  const data = await fetcher<ActivityResponse>(`/activities${query}`);
  return data;
};

데이터는 이렇게 받아온다. 카테고리, 정렬값, 페이지, 사이즈를 모두 props로 받아서 쿼리로 설정해준다.

interface Props {
  category?: string;
  sort?: string;
  page?: number;
  size: number;
}

쿼리는 필수값이 아니기때문에 size를 제외한 값들은 선택적으로 입력이 가능하다. 그러면 어떤 곳에서 사용하던지 필요한 값들만 넣어주면 된다.

app router searchParams

이제 팀원분이 만들어주신 코드에 내가 값들을 어떻게 입력할지 고민해봐야한다. 우선 app router를 사용하기로 결정한 이상 서버 렌더링을 고려해봐야한다. 서버 컴포넌트로 사용하게되면 우리가 전에 사용하던 useSearchParams같은 훅을 사용하지 못한다. 그래서 route에 있는 searchParmas를 가져오는 방법이 약간 다르다.

export default function Page({
  searchParams,
}: {
  searchParams?: { [key: string]: string | string[] | undefined };
}) {
  const { sort, category, page } = searchParams as { [key: string]: string };
...

페이지 컴포넌트에서 searchParams라는 값을 props로 입력하면 route에 있는 searchParams를 가져올 수 있다. searchParams는 key값과 string으로 구성되어 있는데 없는 경우도 있기 때문에 타입을 위와 같이 지정해준다. 그리고 우리가 사용할 sort, category, page값을 searchParams에서 구조분해해서 가져온다. 그러면 useSearchParams를 사용하지 않고 주소에 있는 searchParams값 사용이 가능하다.

간단하게 페이지 컴포넌트에서 데이터를 요청해 자식 컴포넌트로 보내주는 방법도 있지만 해당 페이지에서 다른 데이터를 요청받는 기능이 있어 경로를 받아오고 경로를 props로 컴포넌트에 내려줬다.

    <div>
      <ActicityContainer sort={sort} category={category} page={page} />
    </div>

이제 container컴포넌트에서 서버 렌더링을 해주면 된다.

export default async function ActicityContainer({ sort, category, page = '1' }: Props) {
  const { activities, totalCount } = await getActivities({
    sort,
    category,
    page: Number(page),
    size: 12,
  });

서버 컴포넌트에서 비동기로 데이터를 받아오기 위해서 컴포넌트앞에 async를 붙여준다. 그리고 데이터 요청하는 함수에 await을 넣어주면 컴포넌트가 비동기로 동작하게 된다.

액티비티를 보여주는 부분은 size를 12개로 지정해줬고 나머지는 모두 searchParams 값을 넣어줬다. page의 경우 처음에는 아무값도 없기 때문에 기본적으로 1을 넣어줬다.

이렇게 했을때 동작 순서는 주소로 접근했을때 서버에서 해당 데이터를 받아올때까지 빈 화면이 표시되다가 리스폰스가 도착하면 데이터를 이용해 페이지를 렌더링한다. 클라이언트 렌더링과 다른점은 사용자가 페이지에서 로딩을 기다릴 필요가 없다는 것이다. 물론 페이지 진입할때에는 빈화면이 표시되기는 하지만 대기하는 시간이 없어져 사용성이 좋아질 것이다.

searchParams 설정하기

searchParams를 설정하는 방법은 어렵지 않다. 기존의 주소에 새로운 쿼리를 넣어주고 해당 주소로 이동하면 적용된 주소로 이동해 쿼리의 정보를 사용하도록 하는 것이다.

카테고리 버튼

카테고리는 이상하게도 api자체적으로 강제해놨다. 커스텀되지 않고 지정된 카테고리만 쓰는것이 아쉽지만 어쩔수가 없다.

export const CATEGORIES: string[] = ['문화 · 예술', '식음료', '스포츠', '투어', '관광', '웰빙'];

우선 카테고리만 구분했다. 그리고 이 배열을 이용해서 버튼을 만들어줬다.

'use client';

export default function CategoryButton({ item }: { item: string }) {
  const searchParams = useSearchParams();
  const newSearchParams = new URLSearchParams(searchParams.toString());
  newSearchParams.set('category', item);

  return (
    <Link
      href={createUrl('/', newSearchParams)}
      className={`py-3 px-10 border text-center ${searchParams.get('category') === item ? 'bg-slate-200' : 'bg-white'}`}
    >
      {item}
    </Link>
  );
}

카테고리 버튼을 구현한 코드이다. 모든 버튼은 각자의 searchParams를 만든다. useSearchParams를 이용해 route의 모든 searchParams를 가져온다. 그리고 새로운 searchParams를 만들어 category라는 이름과 value를 삽입시켜주는 것이다. 만약 이미 category가 있다면 뒤에 또 추가되는 것이 아니라 category의 값만 수정된다. 그리고 추가적으로 createUrl이라는 함수를 만들었다. 왜냐하면 Link태그에 href로 주소, string값을 넣어줘야하는데 그걸 변환시켜주는 것이다.

export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyURLSearchParams) => {
  const paramsString = params.toString();
  const queryString = `${paramsString.length ? '?' : ''}${paramsString}`;

  return `${pathname}${queryString}`;
};

우선 쿼리를 제외한 경로를 첫번째로 받고 쿼리를 두번째로 받는다. 그러면 toString()을 사용해 문자열로 변환시켜준다. 그리고 변환시킨 쿼리를 원래 경로에 붙여서 리턴시켜준다.

    <Link
      href={createUrl('/', newSearchParams)}
      className={`py-3 px-10 border text-center ${searchParams.get('category') === item ? 'bg-slate-200' : 'bg-white'}`}
    >
      {item}
    </Link>

다시 카테고리 버튼을 보면 새롭게 만든 쿼리를 메인페이지 경로인 /에 추가한 경로로 이동하게 된다. 포스트 이미지 처음 페이지에 접근하면 이렇게 아무런 경로도 없지만 버튼을 누르면 포스트 이미지 원래 경로 뒤에 쿼리가 잘 삽입되는 것을 확인할 수 있다.

마무리

포스팅이 길어지는 관계로 드롭다운과 페이지네이션은 다음 포스트에 적어보겠다. 이 두개의 기능은 직접 구현하지 않고 shadcn에 있는 컴포넌트를 사용했다. 간편하게 만들어진 컴포넌트를 사용할 수 있다는 장점도 있고 쉽게 커스텀이 가능해서 사용하기로 했다.

전에는 searchParams라는 기능을 제대로 활용해보지 못했는데 이번에 사용할 수 있는 기회가 생겨서 다행이다. 앞으로 잘 사용할수 있지 않을까 싶다.

개의 댓글