[FE] 에러핸들러 패키지 만들어보기

정성엽·2025년 7월 5일
0

LG CNS AM Inspire 1기

목록 보기
70/70

INTRO

이번에 운이좋게도 새로운 프로젝트를 진행하게 되었다.

UI를 Next.js 환경에서 App Router를 사용하여 개발을 진행하는 중이다.

이전에 POPI 프로젝트를 진행하면서 고민했던 부분 중 하나는 에러를 어디서 어떻게 처리하는게 적절할까? 였다.

간단하게 Toast UI를 사용해서 사용자에게 에러가 발생했음을 알려주고 끝내는 것이상으로 코드 베이스에서 조금더 체계적으로 관리해보고 싶은 욕심이 항상 있었다.

해당 프로젝트에서 에러를 조금 더 체계적으로 관리해보려고 했던 고민과 그로인해 패키지를 만들어본 경험을 포스팅을 통해 공유해보고자 한다!


1. 초기 아이디어

우선 프로젝트에서 피그마 시안을 살펴봤을 때, 특정 에러가 발생하면 아주 비슷한 UI의 에러 페이지가 나타나도록 하는 요구사항이 있었다.

조금더 자세히 말하자면, 다음과 같다

When Error Occured

  • A 페이지 Error 발생 -> A` 문구 & A` 버튼 이벤트
  • B 페이지 Error 발생 -> B` 문구 & B` 버튼 이벤트

즉, 필자가 판단하기에는 동일한 에러 페이지를 재사용하는게 좋다고 판단했다

하지만 다음과 같은 문제가 있었다.

💡 callback을 전달할 수 없다!

프로젝트는 Next의 App Router 세팅으로 되어있었기 때문에, 페이지 라우팅을 위해서는 useRouter 를 사용하게 된다.

문제는 useRouter 를 사용해서 페이지로 데이터를 넘기기 위해서는 쿼리 파라미터를 사용해야하며, 버튼을 누를 때 발생하는 callback 함수를 넘기기가 애매하다는 것이다.

💡 util 함수 사용

그래서 처음에는 Util함수를 정의해서 이를 해결하려고 했다.

간단하게 아이디어를 공유해보면, 다음과 같이 코드를 작성할 수 있을 것 같다.

Sample Code

"use client";

import { ErrorCodeType } from "@/constants/Error";
import { useRouter } from "next/navigation";

export const useErrorRouter = () => {
  const router = useRouter();

  const errorRouter = (errorCode: ErrorCodeType) => {
    router.push(`/error?errorCode=${errorCode}`);
  };

  return { errorRouter };
};

위처럼 에러페이지에 상수로 정의되어있는 errorCode 를 넘겨준다

다음으로 에러 페이지에서는 이 에러 코드를 기반으로 미리 정의되어있는 에러 메세지를 불러올 수 있다

Sample Code

"use client";

export default function ErrorPage() {
  const searchParams = useSearchParams();
  const errorCode = searchParams.get("errorCode") as ErrorCodeType;

  return (
    <div className="flex flex-col justify-center items-center h-screen">
      <Image src={failed} width={300} alt="실패 아이콘" />
      <p className="font-large-title-b mt-1 mb-6">
        {getErrorMessage(errorCode)}
      </p>
      <GlobalBtn
        title="Retry"
        onClick={() => {}}
        className="mt-16 py-4 px-[115.5px]"
      />
    </div>
  );
}

사용하는 곳에서는 다음과 같이 사용할 수 있을 것이다

Sample Code

 const { errorRouter, router } = useErrorRouter();

 const handle = () => {
    errorRouter(ERROR_CODES.AUTHENTICATION.INVALID_CREDENTIALS);
 };

여기서 발생하는 문제는 이전에도 언급한 것처럼 버튼을 클릭할 때 발생하는 callback을 넘겨줄 수 없다는 것이다.

커스텀 훅을 별도로 생성하는 경우에는 사용하는 곳마다 서로 다른 인스턴스가 생성되기 때문에 서로 다른 상태를 공유하게 된다.

따라서, 당연히 useErrorRouter 훅 내부에서 callback을 저장하는 상태변수를 선언하더라도 이를 사용하기란 어렵다.

간단하게 생각해보면, 에러를 핸들링하는 지점에서 정의된 callback을 전역상태에 저장하고 Error Page에서 사용하는 방식이 적절해보인다.


2. 전역 상태로 관리하는게 맞을까?

본 프로젝트에서는 전역 상태 관리툴로 Zustand 를 사용하고 있다.

아주 간단하게 코드를 작성한다면 Zustand로 Store를 하나 생성해서 에러발생시 callback을 store에 정의하고, Error Page에서 꺼내 사용하면 된다.

하지만 Zustand를 사용하게 되면, 개발자가 너무 쉽게 접근할 수 있을 것 같다는 생각을 했다.

예를 들어, 다른 개발자가 실수로 에러와 관련 없는 곳에서 해당 store에 접근해서 callback을 변경하거나 초기화할 수 있다는 우려가 있었다.

또한 에러 처리라는 특정 도메인에 대한 로직을 전역 상태에 노출시키는 것이 과연 좋은 설계인지에 대한 의문도 들었다.

에러 핸들링은 애플리케이션 전반에서 사용되지만, 그렇다고 해서 모든 컴포넌트에서 자유롭게 접근할 수 있어야 하는 것은 아니라고 생각했다.

그래서 좀 더 엄격하고 체계적으로 에러를 관리할 수 있는 방법이 없을까 고민하게 되었다.

뭔가 에러 처리만을 위한 독립적인 시스템을 만들고 싶었고, 이것이 패키지를 만들어보자는 아이디어로 이어졌다.


3. Error Handler 구현하기

본 프로젝트에서는 React Query 를 사용해서 서버 상태를 관리하고 있다.

React Query 를 세팅하는 방식을 살펴보면 children 을 매개변수로 전달받는데, 루트를 전달받도록 세팅한다.

또한, 세팅하기 이전에 queryClient 를 생성해서 매개변수로 전달한다.

필자는 이처럼 React Query를 세팅하는 방식에서 아이디어를 얻어 React Query 세팅하는 방식과 유사하게, Context API 를 사용해서 패키지를 구성하기로 결정했다.

💡 에러핸들러 구성하기

에러 핸들러에서 사용하는 타입 정의를 살펴보면 다음과 같다.

Sample Code - Type 정의

export interface Configure<T extends Record<string, ConfigDetail>> {
  errorCode: T;
  router: any;
}

export type ConfigDetail = {
  key: string;
  mainMsg: string;
  subMsg?: string[];
  icon?: string;
  router: string;
};

type Props<T extends Record<string, ConfigDetail>> = {
  children: ReactNode;
  config: Configure<T>;
};

ReactQueryProvider를 세팅할때처럼 최초 Config를 정의하고 받는 부분이 Configure 이다.

Configure에는 ConfigDetail이라는 타입이 정의되어있어야만 하고, 이를 처음으로 받아들인다.

Sample Code - Provider 구현

export const ErrorHandlerProvider = <T extends Record<string, ConfigDetail>>({
  children,
  config,
}: Props<T>) => {
  const [pendingCallback, setPendingCallback] = useState<Function | null>(null);
  const [btnLabel, setBtnLabel] = useState<string>("");
  const [errorConfig, setErrorConfig] = useState<ConfigDetail | null>(null);

  const handleError = (
    errorKey: string,
    callback?: Function,
    btnLabel?: string
  ) => {
    const errorConfig = config.errorCode[errorKey];
    if (!errorConfig) {
      throw new Error(`Error Key '${errorKey}'를 찾지 못했습니다.`);
    }

    setErrorConfig(errorConfig);

    if (callback) {
      setPendingCallback(() => callback);
    }

    if (btnLabel) {
      setBtnLabel(btnLabel);
    }

    config.router.push(errorConfig.router);
  };

  const executeCallback = () => {
    if (pendingCallback) {
      pendingCallback();
      setPendingCallback(null);
    }
  };

  const getMainMsg = () => {
    return errorConfig?.mainMsg ?? "";
  };

  const getSubMsg = () => {
    return errorConfig?.subMsg ?? [];
  };

  return (
    <ErrorHandlerContext.Provider
      value={{ handleError, executeCallback, btnLabel, getMainMsg, getSubMsg }}

      {children}
    </ErrorHandlerContext.Provider>
  );
};

다음으로 구현된 내용을보면, 최초 세팅에서 받아온 데이터에서 다양한 유틸을 제공한다.

handlerError 는 에러를 발생시키는 지점에서, 그리고 executeCallback , btnLabel , getMainMsg , getSubMsg 등은 모두 Error Page에서 사용하려고 한다.

Sample Code - Router 구현

export const useErrorHandler = () => {
  const context = useContext(ErrorHandlerContext);
  if (!context) {
    throw new Error("useErrorHandler는 ErrorHandlerProvider안에서 사용하세요!");
  }
  return context;
};

이를 컨텍스트로 담아서 훅으로 사용할 수 있도록 제공한다.

Sample Code - Export

export { ErrorHandlerProvider } from "./ErrorHandlerProvider";
export { useErrorHandler } from "./useErrorHandler";
export type { Configure, ConfigDetail, ErrorHandlerContextType } from "./ErrorHandlerProvider";

다음으로 이를 export해준다.

사용하는 곳에서 여기서 정의한 타입을 사용할 수 있도록 타입도 같이 Export 해주도록 하자


4. Github Packages 사용하기

다음으로 개발한 패키지를 번들링하기 위해서 Rollup 을 사용해보기로 했다.

라이브러리나 패키지를 만들 때 특히 유용한데, Tree-shaking 이 기본으로 지원되어 불필요한 코드를 제거해주고, 번들 크기를 최소화할 수 있다는 장점이 있다.

이 부분은 다른분들이 진행했던 세팅을 열심히 참고해서 만들었다 😅

import resolve from '@rollup/plugin-node-resolve';
import commonjs from '@rollup/plugin-commonjs';
import typescript from '@rollup/plugin-typescript';
import peerDepsExternal from 'rollup-plugin-peer-deps-external';

export default {
  input: 'src/index.ts',
  output: [
    {
      file: 'dist/index.js',
      format: 'cjs',
      sourcemap: true,
    },
    {
      file: 'dist/index.esm.js',
      format: 'esm',
      sourcemap: true,
    },
  ],
  plugins: [
    peerDepsExternal(),
    resolve(),
    commonjs(),
    typescript({
      tsconfig: './tsconfig.json',
    }),
  ],
  external: ['react', 'react-dom'],
};

💡 Package.json 설정

패키지 배포를 위해서는 package.json도 적절히 설정해야 한다.

{
  "name": "@패키지명",
  "version": "1.0.0",
  "description": "Next.js App Router 환경에서 에러를 관리하는 패키지",
  "main": "dist/index.js",
  "module": "dist/index.esm.js",
  "types": "dist/index.d.ts",
  "files": [
    "dist"
  ],
  "scripts": {
    "build": "rollup -c",
    "prepublishOnly": "npm run build"
  },
  "peerDependencies": {
    "react": ">=18.0.0",
    "react-dom": ">=18.0.0"
  },
  "devDependencies": {
    "@rollup/plugin-commonjs": "^25.0.0",
    "@rollup/plugin-node-resolve": "^15.0.0",
    "@rollup/plugin-typescript": "^11.0.0",
    "rollup": "^4.0.0",
    "rollup-plugin-peer-deps-external": "^2.2.4",
    "typescript": "^5.0.0"
  }
}

특히 main, module, types 필드를 통해 각각 CommonJS, ES Module, TypeScript 타입 정의 파일의 진입점을 명시해주는 것이 중요하다.

💡 에러 핸들러를 사용하기

그래서 사용해보면 다음과 같다

Sample Code - 설정

import { ErrorHandlerProvider } from "@배포한 패키지";

export default function CustomErrorHandlerProvider({ children }: Props) {
  const router = useRouter();

  const config = useMemo(() => {
    return {
      router,
      errorCode: ERROR_HANDLER_CONFIG,
    };
  }, [router]);

  return (
    <ErrorHandlerProvider config={config}>{children}</ErrorHandlerProvider>
  );
}

이처럼 config를 설정한 이후 넘겨준다.

Sample Code - 에러 페이지

import { useErrorHandler } from "@배포한 패키지";

function ErrorContent() {
  const { getMainMsg, getSubMsg, btnLabel, executeCallback } =
    useErrorHandler();

  const mainMsg = getMainMsg();
  const subMsg = getSubMsg();

  return (
    <div className="flex flex-col justify-center items-center h-screen">
      <Image src={failed} width={180} alt="실패 아이콘" />
      <p className="font-large-title-b mt-1 mb-6">{mainMsg}</p>
      {subMsg.length > 0 &&
        subMsg.map((msg, index) => (
          <p key={index} className="font-title1-r">
            {msg}
          </p>
        ))}
      <div className="flex flex-col gap-[12px] mt-16">
        <GlobalBtn
          title={`${btnLabel}`}
          onClick={executeCallback}
          className="py-4 px-[115.5px]"
        />
      </div>
    </div>
  );
}

다음으로 패키지에서 정의했던 useErrorHandler 를 불러와서 에러페이지에서 사용하도록 수정해봤다.

실제로 에러를 발생시키는 지점에서는 다음과 같이 사용할 수 있다

Sample Code - 에러 발생

const { handleError } = useErrorHandler();

const handleLoginError = () => {
  handleError(
    'LOGIN_FAILED',
    () => router.push('/login'),
    'Retry'
  );
};

이렇게 구성하니 에러 코드와 콜백 함수, 버튼 라벨을 깔끔하게 전달할 수 있게 되었다.


OUTRO

이번 프로젝트를 통해 Next.js App Router 환경에서 에러를 관리하기 위한 패키지를 만들어봤다.

처음에는 단순히 콜백 함수를 페이지 간에 전달하는 문제에서 시작했지만, 결국 에러 핸들링 전용 패키지를 만들게 되었다 😅

Context API를 활용해서 에러 관련 상태를 캡슐화하고, 전역 상태 관리툴을 사용하지 않고도 에러 핸들링만을 위한 독립적인 시스템을 구축할 수 있다는 점이 개인적으로는 만족스러웠다.

Rollup을 사용한 번들링과 Github Packages를 통한 배포 과정도 새로운 경험이었고, 어쩌면 다른 프로젝트에서도 재사용할 수 있는 유용한 도구를 만든 것 같기도 하다.

현업에서는 모노레포 방식을 자주 사용한다고 하는데, 아무래도 하나의 팀에서 여러개의 어플리케이션을 만드는게 아니다보니까 접할기회가 없는게 조금 아쉽다.

스스로는 에러 핸들링 툴을 만들었다고 하지만, 물론 아직 개선할 점들이 많이 있다. (타입정의도 매끄럽게 되지 않았다)

더 나은 설계를 고민해봤다는데에 의의를 두자!

profile
코린이

6개의 댓글

comment-user-thumbnail
2025년 7월 8일

형님~ 잘 지내십니까? ㅎㅎ
여전히 성실하시네요 형.. 포스트 잘 읽고있습니다!

2개의 답글