Hits

🌄 배경

게시글 페이지(/page/:postId)에서는 빌드타임에 HTML과 JSON을 만들어 놓았습니다. (블로그 게시글 페이지 SSR → SSG 전환기)

그리고 빌드타임에 생성해놓지 않은 path로 요청이 들어온 경우, fallback : true 옵션을 통해, getStaticProps의 동작을 아래와 같이 변경합니다.

  1. 생성해 놓지 않은 path에 대해서는 404를 반환하지 않고 페이지의 Fallback 버전을 보여준다.
  2. 백그라운드에서 NextJS가 요청된 path에 대해서 getStaticProps 함수를 이용해, HTML파일과 JSON파일을 만든다.
  3. 백그라운드 작업기 끝나면, 요청된 path에 해당하는 JSON파일을 받아서 새롭게 페이지를 렌더링한다.
  4. 사용자는 Fallback 버전 → 완성된 페이지 순서로 화면을 보게 된다.
pages/post.tsx
const Post = () => {
  const router = useRouter();
  const { data } = useGetOnePost(Number(router.query.id));
  //Prefetch한 Query를 반환하는 커스텀 훅
  if (router.isFallback) return <PostSkeleton />;
  // 빌드타임에 생성해놓지 않는 path로 요청이 들어올 경우 isFallback : true
  return <>...</>;
};
 
export const getStaticPaths = async () => {
  try {
    const data = await getAllPostsId();
    const paths = data.map(({ id }) => {
      return { params: { id: String(id) } };
    });
    return {
      paths,
      fallback: true, // fallback option setting
    };
  } catch (err) {
    return {
      paths: [],
      fallback: true,
    };
  }
};
 
export const getStaticProps: GetStaticProps = async (
  context: GetStaticPropsContext
) => {
  const queryClient = new QueryClient();
  await queryClient.fetchQuery(
    [QUERY_KEY.POST.ONE, Number(context.params?.id)],
    () => getOnePostAPI(Number(context.params?.id))
  );
  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
};
pages/post.tsx
const Post = () => {
  const router = useRouter();
  const { data } = useGetOnePost(Number(router.query.id));
  //Prefetch한 Query를 반환하는 커스텀 훅
  if (router.isFallback) return <PostSkeleton />;
  // 빌드타임에 생성해놓지 않는 path로 요청이 들어올 경우 isFallback : true
  return <>...</>;
};
 
export const getStaticPaths = async () => {
  try {
    const data = await getAllPostsId();
    const paths = data.map(({ id }) => {
      return { params: { id: String(id) } };
    });
    return {
      paths,
      fallback: true, // fallback option setting
    };
  } catch (err) {
    return {
      paths: [],
      fallback: true,
    };
  }
};
 
export const getStaticProps: GetStaticProps = async (
  context: GetStaticPropsContext
) => {
  const queryClient = new QueryClient();
  await queryClient.fetchQuery(
    [QUERY_KEY.POST.ONE, Number(context.params?.id)],
    () => getOnePostAPI(Number(context.params?.id))
  );
  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
};

❌ 문제 상황

문제는 사용자가 비정상적인 id를 입력해 접근하는 경우에 발생했습니다. (ex, /post/rke/post/12544 → 없는 게시글)

이런 상황에서는 사용자에게 어떤 상황인지에 대한 alert를 보여준 후, 메인페이지로 redirect 시켜야 했습니다.

이 과정에서 API를 호출하는 함수가 getStaticProps 함수에서 호출되어 에러 처리와 관련해 많은 삽질을 하게 되었습니다…


🤔 삽질 - 왜 useQuery의 onError가 호출되지 않을까?

분명, API 콜을 하는 함수에서 try catch를 통해, 에러를 핸들링하고 해당 함수를 사용하는 useQuery에서 onError 콜백을 지정해두었지만, 호출되지 않았습니다.

export const getOnePostAPI = async (id: number): Promise<PostType | null> => {
  if (isNaN(id)) throw new Error(MESSAGE.INVALIDE_ACCESS);
  if (!id) throw new Error();
  try {
    const { data } = await customAxios.get(`/post/load/${id}`);
    return data;
  } catch (err: any) {
    console.log("throw Error");
    throw Error(err?.response?.data);
  }
};
 
export const useGetOnePost = (id: number) => {
  const router = useRouter();
 
  return useQuery<PostType | null>(
    [QUERY_KEY.POST.ONE, id],
    () => getOnePostAPI(id),
    {
      ...CACHE_OPTION.ALL,
      enabled: isNaN(id) ? false : true,
      onError: (err) => {
        console.log("useGetOnePost onError Called");
        alert(err);
        router.replace("/");
      },
    }
  );
};
export const getOnePostAPI = async (id: number): Promise<PostType | null> => {
  if (isNaN(id)) throw new Error(MESSAGE.INVALIDE_ACCESS);
  if (!id) throw new Error();
  try {
    const { data } = await customAxios.get(`/post/load/${id}`);
    return data;
  } catch (err: any) {
    console.log("throw Error");
    throw Error(err?.response?.data);
  }
};
 
export const useGetOnePost = (id: number) => {
  const router = useRouter();
 
  return useQuery<PostType | null>(
    [QUERY_KEY.POST.ONE, id],
    () => getOnePostAPI(id),
    {
      ...CACHE_OPTION.ALL,
      enabled: isNaN(id) ? false : true,
      onError: (err) => {
        console.log("useGetOnePost onError Called");
        alert(err);
        router.replace("/");
      },
    }
  );
};

관련 레퍼런스를 찾아보니, useQuery 인자로 넣어주는 API 호출 함수에서 try catch를 통해 에러 핸들링을 할 경우, useQuery의 onError 콜백이 실행되는 것이 아닌, useQuery의 onSuccess 콜백이 실행된다는 내용을 확인했습니다.


이를 해결하기 위해, 총 3가지 방법을 사용했지만 모두 실패했습니다.

  1. API 호출 함수에서 try catch 지우기
  2. onSuccess를 이용한 에러 핸들링
  3. 커스텀훅 내부에서 쿼리 반환값과 useEffect를 통한 핸들링

그래서, onSuccess 콜백을 넣기도 해보고, try catch를 지워도 보았지만… 여전히 에러가 발생했습니다.

1. try-catch 지우기
export const getOnePostAPI = async (id: number): Promise<PostType | null> => {
  if (isNaN(id)) throw new Error(MESSAGE.INVALIDE_ACCESS);
  if (!id) throw new Error();
  const { data } = await customAxios.get(`/post/load/${id}`);
  return data;
};
1. try-catch 지우기
export const getOnePostAPI = async (id: number): Promise<PostType | null> => {
  if (isNaN(id)) throw new Error(MESSAGE.INVALIDE_ACCESS);
  if (!id) throw new Error();
  const { data } = await customAxios.get(`/post/load/${id}`);
  return data;
};
2. onSuccess로 핸들링 (try-catch는 그대로)
export const useGetOnePost = (
  id: number,
  queryOptions?: UseQueryOptions<any>
) => {
  const router = useRouter();
 
  return useQuery<PostType | null>(
    [QUERY_KEY.POST.ONE, id],
    () => getOnePostAPI(id),
    {
      ...CACHE_OPTION.ALL,
      onSuccess: (data) => {
        if (data.error) {
          console.log("useGetOnePost onError Called");
          alert(data.error);
          router.replace("/");
        }
      },
      ...queryOptions,
    }
  );
};
2. onSuccess로 핸들링 (try-catch는 그대로)
export const useGetOnePost = (
  id: number,
  queryOptions?: UseQueryOptions<any>
) => {
  const router = useRouter();
 
  return useQuery<PostType | null>(
    [QUERY_KEY.POST.ONE, id],
    () => getOnePostAPI(id),
    {
      ...CACHE_OPTION.ALL,
      onSuccess: (data) => {
        if (data.error) {
          console.log("useGetOnePost onError Called");
          alert(data.error);
          router.replace("/");
        }
      },
      ...queryOptions,
    }
  );
};
3. Query결과와 useEffect를 이용한 핸들링
export const useGetOnePost = (id: number) => {
  const router = useRouter();
 
  const queryResult = useQuery<PostType | null>(
    [QUERY_KEY.POST.ONE, id],
    () => getOnePostAPI(id),
    {
      ...CACHE_OPTION.ALL,
    }
  );
 
  useEffect(() => {
    if (queryResult.isError) {
      alert(queryResult.error);
      router.replace("/");
    }
  }, [queryResult]);
 
  return queryResult;
};
3. Query결과와 useEffect를 이용한 핸들링
export const useGetOnePost = (id: number) => {
  const router = useRouter();
 
  const queryResult = useQuery<PostType | null>(
    [QUERY_KEY.POST.ONE, id],
    () => getOnePostAPI(id),
    {
      ...CACHE_OPTION.ALL,
    }
  );
 
  useEffect(() => {
    if (queryResult.isError) {
      alert(queryResult.error);
      router.replace("/");
    }
  }, [queryResult]);
 
  return queryResult;
};

🛠 해결

이전까지 문제를 해결하지 못했던 이유는 빌드타임에 생성해놓지 않은 path로 요청이 들어온 경우, getStaticProps 내부 로직이 실행되지 않는다고 생각했기 때문이었습니다.

따라서, Hook과 API 호출 함수에서 어떤 방법을 써도 에러 메시지에 나온것처럼 static props를 load하는 과정에서 에러가 발생했던 것이었습니다.

최종적으로, getStaticProps 내부에서 API를 호출하는 부분을 try-catch로 감싸 해결했습니다.

export const getStaticProps: GetStaticProps = async (
  context: GetStaticPropsContext
) => {
  const queryClient = new QueryClient();
  try {
    await queryClient.fetchQuery(
      [QUERY_KEY.POST.ONE, Number(context.params?.id)],
      () => getOnePostAPI(Number(context.params?.id))
    );
    return {
      props: {
        dehydratedState: dehydrate(queryClient),
      },
    };
  } catch (err) {
    console.log("getStaticProps catch called");
    return { props: {} };
  }
};
export const getStaticProps: GetStaticProps = async (
  context: GetStaticPropsContext
) => {
  const queryClient = new QueryClient();
  try {
    await queryClient.fetchQuery(
      [QUERY_KEY.POST.ONE, Number(context.params?.id)],
      () => getOnePostAPI(Number(context.params?.id))
    );
    return {
      props: {
        dehydratedState: dehydrate(queryClient),
      },
    };
  } catch (err) {
    console.log("getStaticProps catch called");
    return { props: {} };
  }
};

getStaticProps 내부에서 try-catch를 통해 에러 핸들링 해주니, 서버에서 오류가 발생하지 않았고, 제가 의도한대로 클라이언트에서 렌더링하는 단계까지 넘어오게 되었고, onError콜백이 정상적으로 실행되었습니다.