🌄 배경
게시글 페이지(/page/:postId
)에서는 빌드타임에 HTML
과 JSON
을 만들어 놓았습니다. (블로그 게시글 페이지 SSR → SSG 전환기)
그리고 빌드타임에 생성해놓지 않은 path
로 요청이 들어온 경우, fallback : true
옵션을 통해, getStaticProps
의 동작을 아래와 같이 변경합니다.
- 생성해 놓지 않은
path
에 대해서는404
를 반환하지 않고 페이지의Fallback
버전을 보여준다. - 백그라운드에서
NextJS
가 요청된path
에 대해서getStaticProps
함수를 이용해,HTML
파일과JSON
파일을 만든다. - 백그라운드 작업기 끝나면, 요청된
path
에 해당하는JSON
파일을 받아서 새롭게 페이지를 렌더링한다. - 사용자는
Fallback
버전 → 완성된 페이지 순서로 화면을 보게 된다.
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),
},
};
};
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가지 방법을 사용했지만 모두 실패했습니다.
- API 호출 함수에서 try catch 지우기
- onSuccess를 이용한 에러 핸들링
- 커스텀훅 내부에서 쿼리 반환값과 useEffect를 통한 핸들링
그래서, onSuccess
콜백을 넣기도 해보고, 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;
};
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;
};
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,
}
);
};
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,
}
);
};
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;
};
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
콜백이 정상적으로 실행되었습니다.