HTTP 상태 코드 기반 UI 분기 처리 위한 커스텀 훅 설계 작업

junjeong·2025년 5월 26일

Wikied

목록 보기
4/5
post-thumbnail

🤔 에러 핸들링은 왜 필요한가?

이전 linkbrary 포스팅에서 ux를 저해시키는 요소에 대해서 소개 할 때, 페이지 이동이 3초 이상 걸리면 페이지는 이탈율이 90퍼센트의 육박한다는 이야기를 했었다.

오늘은 ux를 저해시키는 요소 중 속도가 아닌, 신뢰성에 대해서 이야기를 해볼려고 한다.

작업을 할려고 카페에 갔는데 갑자기 다른 손님이 강아지를 데리고 들어온다고 생각해보자. 강아지를 좋아하는 사람이라면 좋아하겠지만 반대의 고객이라면 당황스러운 순간이다. 에러이다.

만약 고객이 카페를 들어가기 이전에 "저희 카페는 애견 동반 가능한 카페입니다~"와 같은 안내 문구를 보았거나 또는 이후에 "죄송합니다 손님ㅎㅎ 저희 카페는 애견 동반 카페라 양해 부탁드립니다" 라는 말을 직원에게 들었다면 어땠을까?? 기분은 썩 좋지 않아도 수긍하거나 조금의 위안이 됐을 것이다. 이것이 에러핸들링이다.

100% 안전한 것은 없다. ‼️

그렇다. 에러 핸들링이란 가능한 사용자가 불편한 감정이 아닌 보다 긍정적인 감정을 느낄 수 있도록 피드백을 제공하는 행위를 의미한다.

어떠한 에러도 발생하지 않는 프로그램을 만드는 것은 불가능에 가까운 영역이라고 생각한다. 고로 에러는 100퍼센트 막을 수 없다. 최대한 많은 케이스를 고려하고 그에 따라 어떻게 피드백 할 것인가?? 고민하는 것이 중요하다.

⚠️ 에러가 발생하는 상황

그렇다면 에러가 발생하는 상황은 어떤 것들이 있을까?
에러는 크게 두가지로 나뉜다고 하는데 1)예측할 수 있는 에러와 2)예측할 수 없는 에러가 있다.

1. 예측할 수 있는 에러

  1. 서버 API로 전달받는 에러 중 상태코드로 예측할 수 있는 에러
  2. 사용자 입력 에러
  3. 인증 에러
  4. Router 에러 (ex.없는 페이지에 접근한 경우)

2. 예측할 수 없는 에러

  1. 서버 api로 받는 에러 중 500에러
  2. 일시적으로 네트워크가 불안정할 때 발생하는 에러
  3. 서비스 장애

🛠️ Wikied에 적용해보자.

사용자가 Wikied를 이용하면서 에러를 마주하게 되는 경우는 어떤 상황들이 있을까??
하나씩 카테고리별로 정리해보자.

1. 서버 api로부터 전달받는 에러

Wikied를 개발하면서 만든 API 라우트들이다.
API 라우트들이 이렇게 많은 이유는 인증이 필요한 요청마다 사용자의 액세스 토큰을 열람해야 하는데 클라이언트에서 바로 백엔드와 통신하는 것이 아닌
꽤나 많다. 하나씩 까보면서 아래 항목들을 점검해 보았다.

1) 엔드포인트에서 응답 형식을 일관되게 유지하고 있는가?
2) 에러가 발생했을 떄의 상태코드를 클라이언트에게 잘 전달해주는가??
3) 상태 코드 뿐만 아닌, 명확한 에러 메시지를 전달해주고 있는가?

API 함수의 개수가 많다보니 이번 포스팅에서는 게시판 페이지에서 특정 게시글의 데이터를 받아오는 getArticle 부분만 다루겠다.

//상세 게시판 페이지 컴포넌트
const BoardsDetailPage = () => {
  const [article, setArticle] = useState<Article | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const router = useRouter();
  const { id } = router.query;
  const articleId = Number(id);

  useEffect(() => {
    if (!router.isReady) return;

    const fetchData = async () => {
      try {
        const res = await getArticle(articleId);
        setArticle(res);
      } catch (error) {
        console.error("Failed to fetch article:", error);
      } finally {
        setIsLoading(false);
      }
    };

    fetchData();
  }, [articleId, router.isReady]);

  if (!article) {
    return (
      <div>
        <LoadingSpinner />
      </div>
    );
  }

  return (
    <>
      {isLoading && <LoadingSpinner />}

      <BoardsDetailLayout>
        <ArticleDetailContainer article={article} articleId={articleId} />
        <ArticleCommentContainer articleId={articleId} />
      </BoardsDetailLayout>
    </>
  );
};

export default BoardsDetailPage;

// 게시글 상세 조회 API함수, getArticle.ts
export const getArticle = async (articleId: number) => {
  const res = await proxy.get(`/api/articles/${articleId}`);
  if (res.status >= 200 && res.status < 300) return res.data;
  else return {};
};

//실제 백엔드 서버로 요청을 보내는 proxy 역할 api route
import { NextApiRequest, NextApiResponse } from "next";
import { parse } from "cookie";
import instance from "@/api/axios";

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  const cookies = parse(req.headers.cookie || "");
  const accessToken = cookies.accessToken;

  const { articleId } = req.query;
  if (!articleId) {
    return res
      .status(400)
      .json({ message: "쿼리 파라미터에 게시글 ID가 없습니다." });
  }

  switch (req.method) {
    case "GET":
      try {
        const response = await instance.get(`/articles/${articleId}`, {
          headers: { Authorization: `Bearer ${accessToken}` },
        });
        return res.status(200).json(response.data);
      } catch (err) {
        console.error(err);
        return res.status(500).json({ message: "게시글 조회에 실패했습니다." });
      }

    ....(post,patch,delete 요청)

    default:
      res.setHeader("Allow", ["GET", "POST", "PATCH", "DELETE"]);
      return res.status(405).end(`메서드 ${req.method}는 허용되지 않습니다.`);
  }
};

export default handler;

먼저, 상세 게시판 페이지에서는 getArticle이라는 함수를 호출하여 받아오는 데이터를 article 상태로 업데이트 해주고 있는 모습이다.

getArticle 함수는 무엇일까? 미리 만들어 놓은 API route 엔드포인트에 GET 요청을 보내는 util 함수이다.

마지막 API route 엔드포인트 hanlder 함수이다. 클라이언트의 요청 헤더에 쿠키를 확인하고 accessToken가 있다면 꺼내어 JWT 토큰으로 실제 백엔드 서버로 HTTP 요청을 보내는 함수이다.

문제가 있다.

getArticle은 NEXT 서버로, handler 함수는 백엔드 서버로 HTTP 요청을 비동기 함수임에도 불구하고 에러 핸들링이 제대로 이루어지고 있지 않다.

handler 함수는 실제 백엔드 서버가 주는 statusCode가 아닌 개발자가 임의로 지정한 500 에러만 반환하고 있으며, getArticle 함수는 handler 함수가 message가 담긴 객체를 반환하고 있음에도 이를 받아서 핸들링 하는 로직이 전혀 보이지 않고 단순히 빈 객체만을 반환하고 있다. 이러면 handler에서 굳이 message 객체를 전달해줄 필요가 없다.

앞서 강조한 3가지 점검 사항들을 모두 적용해 수정해보자.

// 상세 게시글 페이지 컴포넌트
const BoardsDetailPage = () => {
  const [article, setArticle] = useState<Article | null>(null);
  const [notFound, setNotFound] = useState(false);
  const [unknownErorr, setUnKnownError] = useState(false);
  const [isLoading, setIsLoading] = useState(false);
  const router = useRouter();
  const { id } = router.query;
  const articleId = Number(id);

  useEffect(() => {
    if (!router.isReady) return;

    const fetchData = async () => {
      setIsLoading(true);
      const res = await getArticle(articleId);

      if (res.ok) {
        setIsLoading(false);
        setArticle(res.data);
      } else {
        if (res.status === 404) {
          setIsLoading(false);
          setNotFound(true);
        } else {
          setIsLoading(false);
          setUnKnownError(true);
        }
      }
    };

    fetchData();
  }, [articleId, router.isReady]);

  if (isLoading) return <LoadingSpinner />;

  if (notFound)
    return (
      <>
        <h1>404 - 게시글을 찾을 수 없습니다.</h1>
        <p>요청하신 게시글이 존재하지 않습니다.</p>
      </>
    );

  if (unknownErorr)
    return (
      <>
        <h1>500 - 서버에 예기치 않은 오류가 발생했습니다.</h1>
        <p>네트워크 환경을 점검해 주세요.</p>
      </>
    );

  return (
    <>
      {article && (
        <BoardsDetailLayout>
          <ArticleDetailContainer article={article} articleId={articleId} />
          <ArticleCommentContainer articleId={articleId} />
        </BoardsDetailLayout>
      )}
    </>
  );
};

export default BoardsDetailPage;

// API 함수, getArticle.ts
export const getArticle = async (articleId: number) => {
  try {
    const res = await proxy.get(`/api/articles/${articleId}`);
    return res.data;
  } catch (err) {
    const axiosError = err as AxiosError;
    if (axiosError.response) {
      return {
        ok: false,
        status: axiosError.response.status,
        message: axiosError.response.statusText,
      };
    } else {
      return {
        ok: false,
        status: 500,
        message: "서버에 연결할 수 없습니다.",
      };
    }
  }
};

// API route
const handleError = (
  res: NextApiResponse,
  err: AxiosError,
  defaultMessage: string
) => {
  console.error(`Error occurred: ${err.message}`);
  return res.status(err.response?.status || 500).json({
    ok: false,
    message: err.response?.statusText || defaultMessage,
  });
};

const handler = async (req: NextApiRequest, res: NextApiResponse) => {
  const cookies = parse(req.headers.cookie || "");
  const accessToken = cookies.accessToken;

  const { articleId } = req.query;
  if (!articleId) {
    return res.status(400).json({
      ok: false,
      data: null,
      message: "게시글 ID를 찾지 못했습니다.",
    });
  }

  switch (req.method) {
    case "GET":
      try {
        const response = await instance.get(`/articles/${articleId}`, {
          headers: { Authorization: `Bearer ${accessToken}` },
        });
        return res.status(200).json({
          ok: true,
          data: response.data,
          message: "게시글 조회에 성공했습니다.",
        });
      } catch (err) {
        return handleError(
          res,
          err as AxiosError,
          "게시글 조회 중 오류가 발생했습니다."
        );
      }

...중략

    default:
      res.setHeader("Allow", ["GET", "POST", "PATCH", "DELETE"]);
      return res.status(405).end(`메서드 ${req.method}는 허용되지 않습니다.`);
  }
};

export default handler;

handler 함수에서는 반환값의 타입을 동일시 해주어 함수의 일관성을 높여주었고 에러 상태 또한 err.response.status를 그대로 클라이언트에게 전달해줄 수 있도록 수정하였다. 이전의 에러 로깅도 브라우저가 아닌 서버에서 이루어지도록 배치해주었다.

getArticle 함수는 error를 axios 에러인지 그 외의 에러인지 구분하여 axios 에러일 때는 handler가 주는 status와 message를 그대로 받아 getArticle를 호출하는 클라이언트까지 전달해주도록 중개 역할을 해주었다.(getArticle의 코드는 사실 index.ts에 써도 무방해 보인다. 현재는 페이지에서 한번만 쓰이고 있지만 해당 함수는 재사용성이 높은 성격의 함수라고 생각해 따로 빼주었다.)

이로써 페이지 컴포넌트는 api 요청 중 어떤 에러가 발생한 것인지 정확히 알 수 있게 되었고, 각 상태코드에 따른 안정적인 피드백을 제공할 수 있게 되었다.(ex 404는 notfound, 505는 unknown)

🚨🚨 긴급 경보 !! 긴급 경보 !! 🚨🚨

하지만 또 다른 문제가 발생한다.

데이터를 불러오는 도중에 404 또는 500 에러가 나는 상황은 대부분의 페이지에서 동일하게 겪는 문제이다. 그렇다고 해서 페이지별로 똑같은 예외처리를 해주기에는 뭔가 벅차다. 페이지 갯수만큼 중복 코드가 생기고 가독성을 해치기 때문이다.

뭔가 중앙 집중식으로 관리할 수 있는 방법이 없는지 찾아봐야 할 것 같다....

새롭게 알게 된 사실 😲
axios는 HTTP 요청을 보내고 받는 응답코드가 200이 아니면 모두 catch문에 error로 전달이 된다. 일반 fetch는 throw New Error로 직접 에러를 던져주어야 하는데 말이다. 어떻게 throw가 없는데 handler에 있는 await 문에서 에러가 발생했는데 바깥의 catch문에서 잡히지? 했는데 위의 원리 덕분이었다.

🤔 axios intercept에서 404, 500 에러가 났을 때 어떤 로직 수행을 해주면 되려나??

현재 Wikied에서는 모든 fetch 요청을 axios를 활용하고 있다. 해서 axios의 기능 중 하나인 intercept를 활용하면 어떻게든 중앙집중식으로 될 줄 알았다.

‼️ 하지만 중요한 것은 사용자들에게 에러 코드에 상응하는 대체 ui를 보여주는 것이었다. 때문에 페이지 컴포넌트는 각 ui가 리렌더링 될 수 있는 trigger 역할의 상태값이 반드시 필요했다는 사실을 인지했다.

💡 데이터를 fetch 하고 isLoading, 404error, 500error와 같은 서버상태를 반환 해주는 useFetch

각 페이지 컴포넌트에서 isLoading,notFound,unknownErorr와 같은 상태관리가 필연적인 요소라면 결국 가독성

중복 코드와 문맥을 최대한 줄이기 위해 article 상태를 관리하고 article을 업데이트할 때에 서버 상태까지(ex. isLoading, notFound, unknownError) 관리해주는 로직을 하나의 커스텀 훅으로 추상화 해주면 좋겟다는 생각이 들었다.

여기서 여러 선택지가 생긴다.

  1. 대부분의 페이지에서 필요한 로직이다 보니 useFetchData라는 추상화 레벨이 높은 커스텀 hook을 만들 것이냐? -> 함수의 결합도가 매우 높아 미래를 생각했을 때 확장성이 떨어져 다시 분리하는 상황이 생길 수 있음

  2. 코드가 중복된다 해도 각 페이지별로 useFetch" " 함수를 만들 것이냐 -> 중복되는 로직이지만 단일책임원칙을 따르기 때문에 유지보수성이나 추후에 페이지별로 커스텀이 가능해 확장성도 좋아보임.

  3. isLoading, error와 같은 비동기와 관련된 서버상태를 효과적으로 관리해주는 useQuery() 메소드가 있는 Tanstack-Query를 사용할 것이냐 -> 캐싱 기능도 있어 사용하는게 좋아보이지만 이번 포스팅에서는 다루지 않을 예정(지난 포스팅에서 다루었기 때문)

1번과 2번중에 고민이 정말 많았다. 하지만 1번이 아무리 생각해도 너무 위험한 선택지인 것 같앗, 중복 코드가 빈번히 발생한다 한들 각자의 fetch 커스텀 훅을 가짐으로써 단일책임원칙을 지키도록 하는 선택지를 골랐다.

아래는 최종적으로 개선된 코드이다.

const BoardsDetailPage = () => {
  const router = useRouter();
  const { id } = router.query;
  const articleId = Number(id);
  const { article, isLoading, notFound, unknownError } = useFetchArticle(
    Number(id)
  );

  if (isLoading) return <LoadingSpinner />;

  if (notFound) return <NotFound />;

  if (unknownError) return <Unknown />;

  return (
    <>
      {article && (
        <BoardsDetailLayout>
          <ArticleDetailContainer article={article} articleId={articleId} />
          <ArticleCommentContainer articleId={articleId} />
        </BoardsDetailLayout>
      )}
    </>
  );
};

export default BoardsDetailPage;

const useFetchArticle = (articleId: number) => {
  const [article, setArticle] = useState<Article | null>(null);
  const [notFound, setNotFound] = useState(false);
  const [unknownError, setUnKnownError] = useState(false);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    const fetchData = async () => {
      setIsLoading(true);
      const res = await getArticle(articleId);

      if (res.ok) {
        setIsLoading(false);
        setArticle(res.data);
      } else {
        if (res.status === 404) {
          setIsLoading(false);
          setNotFound(true);
        } else {
          setIsLoading(false);
          setUnKnownError(true);
        }
      }
    };

    fetchData();
  }, [articleId]);

  return { article, isLoading, notFound, unknownError };
};

export default useFetchArticle;

getArticle 함수와 handler 함수는 동일하다.

💡 Next에서 지원하는 404.tsx, 500.tsx, _error.tsx

포스팅을 거의 끝낸 시점에서 지금까지 한 노력들을 Next에서 자체적으로 지원해준다는 사실을 알아냈다 ㅎㅎ;

방법은 꽤나 간단했다...

/pages 폴더 아래 404.tsx 파일과 500.tsx파일을 만들면 각 에러코드에 해당하는 응답이 왔을 때에 해당하는 페이지를 렌더링 해주는 원리이다. _error.tsx는 그 외에 에러로 전역에러코드라고 생각하면 편하다.

이렇게 만들어두면 내가 처음에 원했던 각 페이지에서의 상태관리가 필요가 없어진다. 중앙집중식 관리가 되는 것이다 야호!🎉🎉

코드도 getArticle을 호출할 때 받는 res.status가 404냐 500에 따라서 router.push만 해주면 된다. 확실히 중앙집중식으로 관리하는 것이 코드도 깔끔하고 유지보수하기도 쉬워 보인다. NEXT 짱👍

const useFetchArticle = (articleId: number) => {
  const router = useRouter();
  const [article, setArticle] = useState<Article | null>(null);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    const fetchData = async () => {
      setIsLoading(true);
      const res = await getArticle(articleId);

      if (res.ok) {
        setArticle(res.data);
      } else {
        if (res.status === 404) {
          router.push("/404");
        } else {
          router.push("/500");
        }
      }
      setIsLoading(false);
    };

    fetchData();
  }, [articleId, router]);

  return { article, isLoading };
};

export default useFetchArticle;

👣... to be continued

이번 포스팅은 에러가 발생하는 케이스 중 첫번째인 API 통신할 때 발생하는 에러에 대한 케이스만 살펴보았다. 이외로는 사용자에게 받는 데이터 입력 에러, 사용자 인증 에러, 라우팅 에러가 존재한다. 마저 다루면 포스팅이 너무 길어지는 관계로 나머지는 part2에서 진행하겠다.

profile
Whether you're doing well or not, just keep going👨🏻‍💻🔥

0개의 댓글