Gila - 리뷰 기능 구현하기(shadcn modal, dayjs)

박상준

2024년 10월 18일

2

Next.js
Gila

이번에 맡은 기능

저번까지 메인 페이지의 기능 구현을 마치고 새로운 페이지를 다시 분담해 시작하기로 했다. 포스트 이미지 이번 페이지는 액티비티의 상세 내용을 확인하는 페이지이다. 그래서 액티비티의 사진을 보여주고 설명과 위치 정보, 리뷰, 일정 캘린더를 포함하고 있다. 그래서 총 3개의 영역으로 나눴다.

  1. 상세 정보
  1. 리뷰 목록
  2. 캘린더 모달

이렇게 나눴고 나는 리뷰 부분을 선택했다. 왜냐하면 리뷰를 페이지네이션으로 구현하지 않고 전체 리뷰 확인하기 버튼을 누르면 모달을 통해 리뷰를 보기로 했기 때문이다.

전까지는 모달을 useReducer로 조작했는데 html에 Dialog를 사용하면 직접 상태 관리를 할수 있다. 그래서 우리는 모달을 직접 구현하지 않고 shadcn에 있는 모달을 사용하기로 했고 구현된 모달은 dialog태그를 사용한다. 그래서 이걸 직접 체험해보고 싶어서 맡게 되었다.

리뷰 기능

타입 지정

기능 개발하기 이전에 타입을 먼저 지정해줬다.

export interface UserInfo {
  profileImageUrl: string;
  nickname: string;
  id: number;
}

export interface Review {
  id: number;
  user: UserInfo;
  activityId: number;
  rating: number;
  content: string;
  createdAt: string;
  updatedAt: string;
}

export interface ReviewResponse {
  averageRating: number;
  totalCount: number;
  reviews: Review[];
}

리뷰 타입과 리뷰안에 들어가는 유저정보, 서버에서 리스폰스로 받게되는 데이터의 타입까지 지정을 해줬다.

이후에 바로 기능 구현을 하면서 서버에 데이터를 만들면 좋지만 상당히 과정이 귀찮았다.

  • 액티비티 등록 -> 액티비티 신청 -> 신청 수락 -> 지정한 스케줄이후 리뷰 등록 가능

이런 순서로 되어 있다보니 리뷰를 작성하기에 상당히 귀찮다. 그래서 우선은 mockdata로 만들어 사용하고 이후에 모든 기능이 개발된 이후에 리뷰작성을 팀원들이 도와줄수 있는 시점에 진행하는 것이 맞는것 같다.

그래서 대충 mockData를 만들었다.

export const reviewsMock = {
  reviews: [
    {
      id: 1,
      user: testUser,
      activityId: 1659,
      rating: 4,
      content: '테스트',
      createdAt: '2024-07-15T07:11:02.293Z',
      updatedAt: '2024-07-15T07:11:02.293Z',
    },
    {
      id: 2,
      user: testUser,
      activityId: 1659,
      rating: 4,
      content: '테스트',
      createdAt: '2024-07-15T07:11:02.293Z',
      updatedAt: '2024-07-15T07:11:02.293Z',
    },
    ...

이렇게 실제 데이터와 같은 데이터를 만들어 테스트하기로 했다.

데이터 fetching

그래도 데이터를 받아오는 코드는 작성할 것이다. get method로 불러오는 것이기 때문에 전에 작성한 코드와 동일하게 fetcher를 사용해서 받아오도록 했다.

export const getReviews = async ({
  page = 1,
  size,
  activityId,
}: Props): Promise<ReviewResponse> => {
  const query = `?page=${page}&size=${size}`;
  const data = await fetcher<ReviewResponse>(`/activities/${activityId}/reviews${query}`);
  return data;
};

제네릭으로 리스폰스 타입을 지정해줬고 3가지의 인자를 받아서 쿼리를 설정해 데이터를 받아온다.

리뷰 컨테이너

페이지에서 바로 보여줄 리뷰 리스트를 클라이언트가 상호작용할 내용이 하나도 없다. 그래서 서버에서 렌더링해서 보여주도록 컴포넌트를 설계했다. 우선 페이지에서 params로 받아온 activityId만 받아온다. 여기에서 useParams를 사용하면 클라이언트 컴포넌트로 사용해야하기 때문이다.

export default async function ReviewContainer({ activityId }: { activityId: number }) {
  ...

그리고 이렇게 받아온 id를 사용해 위에서 만들어둔 리퀘스트 코드에서 쿼리로 설정해서 받아와야한다.

const result = await getReviews({ size: 4, activityId })

하지만 우리는 서버에 아무런 리뷰데이터가 없기 때문에 지금은 mockData로 넣어주겠다.

const reviewData = reviewsMock;

그리고 리뷰 리스폰스에 averageRating값이 있어서 rating값에 따라서 자체적인 평가를 넣기로 했다.

export const RATING = [
  { rating: 1, message: '별로에요' },
  { rating: 2, message: '그냥 그래요' },
  { rating: 3, message: '만족해요' },
  { rating: 4, message: '매우 만족해요' },
];

프로젝트의 constants파일에 점수에 따른 점수를 정의해뒀다.

const averageMessage = RATING.find(
  (item) => item.rating === Math.floor(reviewData.averageRating),
);

그리고 리뷰 리스폰스의 점수를 floor를 통해 내려서 점수가 같은 것을 찾는다. 이렇게 해주면

  • 1~2 : 별로에요
  • 2~3 : 그냥 그래요
  • 3~4 : 만족해요
  • 4~5 : 매우 만족해요

이렇게 점수를 평가할수 있다.

리뷰 모달

리뷰 모달은 이미 구현된 것을 커스텀하는 내용이였기 때문에 큰 어려움은 없었다. 하지만 모달은 컨테이너 컴포넌트와 달리 클라이언트 스크롤을 감지해 데이터를 추가적으로 불러와야하기 때문에 client컴포넌트로 구현해줘야한다. 우선 처음 보여지는 데이터는 이미 서버에서 불러온 내용이라서 더 불러올 필요가 없다고 생각한다. 그래서 처음 데이터는 props로 받아오도록 설계했다.

export default function ReviewModal({ totalCount, list, activityId, averageMessage }: Props) {
  const [reviewList, setReviewList] = useState(list);
  ...

이렇게 해주면 처음에 데이터를 로딩하는 시간이 없이 바로 모달에 렌더링될것이다. 하지만 여기에서도 서버에서 받아올 데이터가 없기 때문에 mockData로 대체한다.

const mock = mockData;

여기에서 모든 리뷰를 모두 렌더링해서 스크롤시키는 것이 아니라 intersectionObserver를 사용한 무한 스크롤로 구현할 것이다. 다행히 이 기능을 다른 팀원분이 검색 페이지에서 만들어주신 덕분에 간편하게 구현했다.

const [isPending, startTransition] = useTransition();

팀원분의 코드를 확인하던 중 처음보는 훅을 발견했다. 이 훅은 비동기로 처리되는 동작을 처리할 수 있는 훅이다.

  const loadMoreData = useCallback(async () => {
    if (isPending) return;
    
    startTransition(async () => {
      const { reviews } = await getReviews({ page, size: 4, activityId });
      if (reviews && reviews.length > 0) {
        setReviewList((prevData) => [...prevData, ...reviews]);
        setPage((prevPage) => prevPage + 1);
      } else {
        setHasMore(false);
      }
    });
  }, [activityId, isPending, page]);

데이터를 불러오는 함수에서 사용하고 있다. useTransitionstartTransition의 인자로 비동기 함수를 넣어주면 비동기 동작이 끝나기 전까지 isPending이 true가 된다. 그리고 내부 함수가 동작이 끝나면 다시 false로 설정된다. 이렇게 비동기 함수의 상태를 처리할 수 있게되었다.

옵저버 동작부터 리뷰가 추가되는 과정까지 한번에 보자면

  useEffect(() => {
    if (totalCount >= list.length) {
      return () => null;
    }
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && !isPending && hasMore) {
          loadMoreData();
        }
      },
      { threshold: 1 },
    );

    const currentLoaderRef = loaderRef.current;
    if (currentLoaderRef) {
      observer.observe(currentLoaderRef);
    }

    return () => {
      if (currentLoaderRef) {
        observer.unobserve(currentLoaderRef);
      }
    };
  }, [hasMore, loaderRef, isPending, loadMoreData, totalCount, list.length]);

우선 전체 리뷰 갯수와 컨테이너에서 받아온 데이터의 길이가 같거나 작으면 옵저버를 생성하지 않는다. 그 외에는 옵저버를 생성하는데 옵저버가 완전히 브라우저에 노출되었을때, 비동기 동작중이 아닐때, 다음 데이터가 있을때 옵저버가 동작되면서 데이터를 받아온다.

      const { reviews } = await getReviews({ page, size: 4, activityId });
      if (reviews && reviews.length > 0) {
        setReviewList((prevData) => [...prevData, ...reviews]);
        setPage((prevPage) => prevPage + 1);
      } else {
        setHasMore(false);
      }
    });

데이터 호출 부분을 다시보면 데이터를 받아오고 이전 데이터에 추가적으로 붙여서 렌더링시켜준다. 그리고 페이지 값도 증가시켜줘서 다음 요청에 사용할 수 있도록 해준다.

전에 다른 프로젝트를 진행할때는 다음 데이터 유무를 같이 보내줬는데 이번에는 없어서 아쉽다. 그래서 다음 데이터가 없어도 요청을 보내야하는 점이 아쉽다.

결과

이렇게 만들어진 컨테이너와 모달을 확인해보겠다. 포스트 이미지 처음에는 4개의 리뷰만 불러오고 점수에 따른 평가 코멘트를 넣어줬다. 포스트 이미지 그리고 모달도 잘된다. 아직 서버에서 받아왔을때에도 잘 되는지는 의문이지만 그건 나중에 확인해볼 문제이다

dayjs

이번에 추가적으로 날짜 변환 라이브러리인 dayjs를 설치해서 사용해봤다. 전에는 직접 날짜와 시간을 변환해서 사용했는데 너무 코드도 길어지고 유지보수성도 떨어져서 도입해봤다.

app router에서 사용하기

그냥 react나 nextjs page router에서는 바로 사용하면 될텐데 next14버전부터는 별도의 과정을 거쳐줘야한다.

import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import 'dayjs/locale/ko';
import 'dayjs/locale/en';

const dayjsExt = dayjs;
dayjs.extend(relativeTime);

export default dayjsExt;

우선 /lib/dayjs.ts파일을 만들어 위의 코드를 작성해줘야한다. 그러면 정상적으로 dayjs를 사용해서 시간을 변환할 수 있다.

정확한 이유는 모르겠으나 이렇게 해줬을때 컴포넌트에서 정상적으로 dayjs를 import하고 사용할 수 있게 되었다.

 const createdAt = dayjs(item.createdAt).format('YYYY-MM-DD');

실제로는 이렇게 사용해주면 된다. item.createdAt은 createdAt: '2024-07-15T07:11:02.293Z'이런식으로 문자열이 들어가 있다. 이런 시간 값을 YYYY-MM-DD의 포멧으로 변환시켜주는 것이다. 포스트 이미지 실제로 시간이 잘 변환되어서 들어가 있다.

마무리

이번에도 양 조절 실패다. 공유하기 기능 자체는 많은 내용이 있지는 않지만 그래도 다음으로 미뤄서 해보겠다. 큰 어려움은 없었지만 그래도 다른 컴포넌트에 의존성을 낮추고 props로 필요한 정보만 받아서 사용하도록 설계하는데 신경썼다. 이제 다른 팀원들과 작업사항을 공유할때 어떤 데이터가 필요하지만 얘기해주면 서로 편하게 코드를 작성하는 것이다.

새로운 진행 방식이 이제 익숙해질 참에 새로운 문제에 직면했다. 우리가 구현을 하다보니 주어진 백엔드 데이터와 api로는 불필요한 것들도 많고 필요한 것들이 없는 경우도 있다. 그래서 우리가 직접 api를 제작을 해서 해보는게 어떨지 하는 얘기들을 했다. 다행히 팀원중에 한분이 백엔드 구현이 가능하다고 하셔서 얘기가 나온것이다.

이런 방향으로 다시 가게되면 우리는 기획을 더 촘촘하게 할수밖에 없다. 왜냐하면 백엔드 개발이 끝난 이후에는 추가하거나 수정하기에 불편하고 복잡하기 때문이다. 물론 현재 개발진행방향과 엄청 어긋나진 않겠지만 그래도 어려운 길이기때문에 조금은 걱정이다. 하지만 괜찮다. 이렇게라도 해야 하나라도 남는 것이다. 남은 기간 더 열심히 해봅시다.

개의 댓글