문제의 발생

image.png

팀 프로젝트를 진행 중 위 화면을 구현 하던 중 Hydration failed because the initial UI does not match what was rendered on the server 이라는 에러가 발생했다고 한다. 말 그대로 SSR로 생성된 HTML의 UI와 클라이언트에서 생성된 HTML의 UI가 서로 차이가 생겨 발생하는 문제였다.


문제에 접근하기

페이지를 작업하는 팀원분이 suppressHydrationWarning을 써서 당장 에러메시지는 제거할 수 있었지만, 근본적으로 문제를 해결했다고 보기 보다는 그냥 문제를 덮어 버리는 식의 방법이었고 다른 해결법이 필요하다고 말씀해주셨다.

<Image
  src={imageUrl}
  alt={"대표 이미지"}
  fill={true}
  className="object-cover"
  sizes="(max-width: 768px) 100vw, (min-width: 768px) and (max-width: 1024px) 50vw, 280px"
  suppressHydrationWarning={true}
/>;

우선 서버와 클라언트 간의 차이가 발생할 수 있는 부분을 분석해보자 맨위에 있는 사진을 보면 마감일 그리고 모임정보 관련 시간에서 데이터간의 차이가 발생할 수 있어 보인다.

{
    teamId: '11',
    id: 1466,
    type: 'MINDFULNESS',
    name: 'Heal with photo',
    dateTime: '2024-10-18T01:00:00.000Z',
    registrationEnd: '2024-10-18T01:00:00.000Z',
    location: '을지로3가',
    participantCount: 1,
    capacity: 6,
    image: '<https://sprint-fe-project.s3.ap-northeast-2.amazonaws.com/together-dallaem/1729210768960_000231480003.jpg>',
    createdBy: 821,
    canceledAt: null
  },

위 데이터를 기반으로 렌더링 되고 있는 컴포넌트인데 이 컴포넌트들의 UTC 기반의 시간을 클라이언트에서 +9 시간을 더해 로컬시간으로 변환한다. 서버의 시간은 01:00 분인데 클라이언트의 시간은 +9 시인 10시로 렌더링이 되므로 이 부분에서 시간을 기반으로 하는 컴포넌트들의 차이가 발생한다.


문제 해결

그렇다면 서버에 데이터를 넘겨줄 때 즉 prefetch를 진행할 때 데이터를 변환해주면, 시간 차이가 해소되지 않을까 하는 생각이 들었고 아래 작성한 코드를 팀원 분께 변경을 요청드렸다.

export async function prefetchGatherings({
  queryClient,
  type,
  date,
  ...
}: {
  queryClient: QueryClient;
  type?: GatheringType;
  date?: Date;
  ...
}) {
  await queryClient.prefetchInfiniteQuery<IGatherings[]>({
    queryKey: ["gatherings", type, location, date, sortBy, sortOrder],
    queryFn: ({ pageParam = 0 }) =>
      getGatheringList({
        pageParam: pageParam as number,
        type,
        date,
        ...
      }),
  });
}

위에서 작성된 코드에 우선 시간을 변환하는 함수들을 작성한다.

// 시간을 9시간 변환하는 코드
function convertTime(date: string | Date) {
  return new Date(
    new Date(date).toLocaleString("en-US", { timeZone: "Asia/Seoul" })
  );
}
// 마감일 관련 시간 보정 코드
function getDeadline(registrationTime: Date, now: Date) {
  if (registrationTime < now) return "마감된 모임";

  const hoursDiff = differenceInHours(registrationTime, now);

  if (hoursDiff < 24 && isToday(registrationTime))
    return `오늘 ${registrationTime.getHours()}시 마감`;

  if (hoursDiff < 24 && !isToday(registrationTime))
    return `내일 ${registrationTime.getHours()}시 마감`;

  const daysDiff = differenceInDays(registrationTime, now);

  return `${daysDiff}일 후 마감`;
}

그리고 데이터 변환 함수를 prefetch를 할 때 적용해준다.

export async function prefetchGatherings({
  queryClient,
  type,
  date,
  ...
}: {
  queryClient: QueryClient;
  type?: GatheringType;
  date?: Date;
  ...
}) {
  await queryClient.prefetchInfiniteQuery<IGatherings[]>({
    queryKey: ["gatherings", type, location, date, sortBy, sortOrder],
    queryFn: async ({ pageParam = 0 }) => {
      const gatherings = await getGatheringList({
        type,
        date,
        ...
      });

      const transformedData = gatherings.map((gathering: IGatherings) => {
        const currentTime = convertTime(new Date());
        const registrationEndTime = convertTime(gathering.registrationEnd);

        return {
          ...gathering,
          dateTime: format(
            convertTime(gathering.dateTime),
            "yyyy-MM-dd HH:mm:ss",
            { locale: ko }
          ),
          registrationEnd: format(registrationEndTime, "yyyy-MM-dd HH:mm:ss", {
            locale: ko,
          }),
          isClosed: registrationEndTime < currentTime,
          deadlineText: getDeadline(registrationEndTime, currentTime),
        };
      });
      return transformedData;
    },
  });
}

이렇게 하는 과정을 통해서 데이터 간의 불일치를 해결할 수 있었다. 쉬운설명을 위해 코드에는 생략된 부분이 많아 온전하지 못하지만 핵심은 이렇다. prefetch 과정에서 데이터를 보정해 불일치를 해소한다.