Sentry와 함께 에러 핸들링하기

정다빈·2024년 5월 9일
post-thumbnail

패밀리노트는 돌봄이 필요한 어르신의 소식을 보호자님에게 편리하게 전달할 수 있는 서비스예요.
저는 패밀리노트의 ERP 서비스(패밀리케어)를 개발하고 있는데요, 최근에 에러 핸들링 작업을 하게 되면서 어떻게 진행했는지 이야기해 보려고 해요.

🙌🏻 에러 핸들링이 무엇인가요?

에러 핸들링은 소프트웨어에서 발생하는 에러를 적절히 처리하는 것을 의미해요. "에러는 애초에 발생하지 않는 게 가장 좋은 거 아니야? 🤔" 라고 생각할 수도 있지만, 에러가 없는 소프트웨어를 만드는 것은 거의 불가능에 가까워요. 그리고 에러를 의도적으로 발생시키는 경우도 있어요.

예를 들어 패밀리노트에 가입하려는 사용자가 중복 아이디를 입력했다고 가정해 볼게요.

사용자가 아이디를 입력하면 뒤에서는 아래와 같은 작업이 이루어져요.

  1. 클라이언트는 아이디 중복 여부를 확인하기 위해 서버에게 요청을 보내요.
  2. 서버는 아이디 중복 여부를 확인하고 에러를 응답해요. (일반적으로 409 에러)
    이때 "아이디" 값이 중복되었음을 알려주기 위해 별도의 에러 코드 (예를 들어 duplicated_id)를 함께 응답합니다.
  3. 클라이언트는 에러 코드를 확인해서 적절한 <input />에 에러를 표시해요.

"아이디 중복"처럼 의도적으로 에러가 발생하는 경우도 있기 때문에, 상황에 따라 에러를 적절히 처리하는 것이 중요해요.

👯 에러 핸들링과 에러 트래킹

중복 아이디 예시처럼, 의도적인 에러가 발생했을 때 적절한 UI를 보여주어야 해요. (에러 핸들링)
그런데, 의도하지 않은 에러가 발생했을 땐 어떻게 해야 할까요? 마찬가지로 적절한 UI를 보여주면서 (에러 핸들링), 에러를 추적(에러 트래킹)하고 수정해야 해요.

에러 페이지가 자주 노출되면 서비스에 대한 신뢰도가 떨어지고, 웹 사이트 이탈률에 영향을 미치게 돼요.

패밀리케어는 에러 트래킹을 위해 센트리를 도입했어요. 센트리를 어떻게 적용했는지 함께 알아보도록 할까요?

👮🏻 에러를 추적해 봅시다!

1. 센트리 설치하기

센트리는 다양한 SDK를 지원하고 있어요. 패밀리케어는 Next.js 기반으로 만들어졌기 때문에, Next.js SDK를 사용해 주었어요.

문서에 적혀있는 명령어를 입력하면 간단한 질문과 답변을 통해 설정 파일들을 만들어 줍니다.

npx @sentry/wizard@latest -i nextjs

2. 센트리 설정하기

명령어를 실행해서 설정 파일들을 만들어주고, 그중 몇 가지 파일은 프로젝트에 맞추어서 수정해 주었어요.

// next.config.js
const { withSentryConfig } = require('@sentry/nextjs');

const sentryWebpackPluginOptions = {
  // 센트리에 커밋을 추가합니다.
  setCommits: {
    repo: process.env.SENTRY_COMMIT_REPOSITORY,
    auto: true,
  },
};

const sentryOptions = {
  // 청크 파일 업로드 범위를 확장합니다.
  widenClientFileUpload: true,
};

module.exports = (phase) => {
  const nextConfig = { ... };

  return withSentryConfig(nextConfig, sentryWebpackPluginOptions, sentryOptions);
};

widenClientFileUpload 옵션은 무엇인가요?

센트리는 에러가 발생한 지점을 찾아서 알려주는 역할을 해요.
그런데 생각해 보면, 개발자가 작성한 원본 코드는 빌드 할 때 번들링 과정을 거치게 되고 → 이때 번들링 된 코드는 원본 코드와 형태가 크게 달라져요. 번들링 코드에서 에러가 발생했을 때, 센트리는 어떻게 원본 코드에서 에러가 발생한 지점을 짚어줄 수 있을까요?

센트리는 에러가 발생하면 원본 코드와 번들링 코드를 소스 매핑해서 → 원본 코드의 몇 번째 라인에서 에러가 발생했는지 알려줘요. 소스 매핑을 하기 위해 static/chunks/pages/ 디렉토리의 파일과 소스 맵 파일을 업로드하는데요, 간혹 static/chunks/ 디렉토리의 파일에서 에러가 발생하는 경우 소스 매핑이 되지 않을 수 있어요. static/chunks/ 디렉토리의 파일은 대부분 (원본 코드와 관계없는) Next.js, 서드 파티 관련 코드들이기 때문에 업로드하지 않는다고 해요.

모든 청크 파일을 업로드하려면widenClientFileUpload 옵션을 true로 설정해 주어야 합니다.

적용 전
소스 매핑이 되지 않아 원본 코드를 알 수 없어요.

적용 후
소스 매핑이 되어서 원본 코드를 알 수 있어요.

이제 센트리를 초기화하기 위해 sentry.client.config.jssentry.server.config.js 설정을 바꿔볼게요.

// sentry.client.config.js
import * as Sentry from '@sentry/nextjs';

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  release: process.env.GIT_COMMIT,
  environment: process.env.DEPLOY_ENV,
  tracesSampleRate: 0.01,
  denyUrls: [ ... ],
  ignoreErrors: [ ... ],
  normalizeDepth: 5,
});
// sentry.server.config.js
const Sentry = require('@sentry/nextjs');

Sentry.init({
  dsn: process.env.SENTRY_DSN,
  release: process.env.GIT_COMMIT,
  environment: process.env.DEPLOY_ENV,
  tracesSampleRate: 0.01,
  debug: true,
});
  • dsn : 이벤트를 전송하기 위한 식별 키
  • release : 어플리케이션 버전
  • environment : 어플리케이션이 배포된 환경 (prod, staging 등)
  • tracesSampleRate : 트랜잭션 전송 비율
  • denyUrls : "전송하지 않을 오류 URL"과 일치하는 정규식 목록
  • ignoreErrors : "전송하지 않을 오류 메세지"와 일치하는 정규식 목록
  • normalizeDepth : 컨텍스트 데이터를 주어진 깊이로 정규화
  • debug : 이벤트 전송에 문제가 발생할 경우, 디버깅 정보 출력 여부

익스텐션에서 발생하는 에러를 전송하지 않기 위해 denyUrls, ignoreErrors 목록에 관련 정규식을 추가했어요.

에러가 발생하면 아래와 같이 상세한 내용을 확인할 수 있습니다.

🛠️ 에러 핸들링을 해봅시다!

에러 핸들링은 아래 그림처럼 작업해 주었어요.

  1. axios의 interceptors를 이용해서 응답 status code를 확인해요.
  2. status code에 따라 커스텀 에러를 발생시켜요.
  3. 에러의 인스턴스에 따라 적절한 에러 페이지를 보여줘요.

1. status code 확인하기

서버로부터 응답을 받을 때, 이를 인터셉트해서 status code를 확인해 줍니다.

const instance = axios.create({ ... });

instance.interceptors.response.use(
  (response) => { ... },
  (error) => {
   	// GET 요청일 때만 status code를 확인합니다.
    if (error.config.method === 'get') {
      const { status } = error.response;

      if (status === 401) {
        // 401 에러 처리
      }

      if (status === 403) {
        // 403 에러 처리
      }

      if (status >= 400 && status < 500) {
        // 400대 에러 처리
      }

      // 500대 에러 처리
      return Promise.reject(error);
    }
  },
);

에러 케이스를 크게 4가지로 분리해서 처리해 주었어요.

  • 401 : 사용자의 인증이 필요해요. 이 에러가 발생한 경우 로그인 페이지로 이동시켜요. (Unauthorized Error)
  • 403 : 사용자가 인증되었지만 리소스에 대한 접근 권한이 필요해요. 이 에러가 발생한 경우 "접근 권한이 없습니다" 에러 페이지를 보여줘요. (Forbidden Error)
  • 400대 에러 : 요청한 페이지 또는 데이터를 찾을 수 없어요. 이 에러가 발생한 경우 "원하시는 페이지를 찾을 수가 없습니다" 에러 페이지를 보여줘요. (NotFound Error)
  • 500대 에러 : 서버가 요청을 처리하는 과정에서 예상하지 못한 오류가 발생했어요. 이 에러가 발생한 경우 "오류가 발생하였습니다" 에러 페이지를 보여줘요. (InternalServer Error)

2. 커스텀 에러 발생시키기

위 에러 케이스 중에서 403 에러를 위한 커스텀 에러 객체를 만들어 주었어요.

export class ForbiddenError extends Error {
  constructor(message) {
    super(message);

    // 번들러가 Minify 하는 과정에서 이름이 바뀔 수 있으므로 직접 주입합니다.
    this.name = 'ForbiddenError';
    this.statusCode = 403;

    // 에러 객체에 오류 정보를 저장합니다.
    if (Error.captureStackTrace) {
      Error.captureStackTrace(this, ForbiddenError);
    }
  }
}

export default ForbiddenError;

interceptors에서 status code가 403인 경우 ForbiddenError 에러를 발생시킵니다.

// 401 에러 처리
if (status === 401) {
  throw new UnauthorizedError('사용자 자격 증명이 유효하지 않습니다.');
}

// 403 에러 처리
if (status === 403) {
  throw new ForbiddenError('접근 권한이 없습니다.');
}

// 400대 에러 처리
if (status >= 400 && status < 500) {
  const errorMessage = error.response.data ? JSON.stringify(error.response.data) : '클라이언트 에러가 발생했습니다.';
  throw new NotFoundError(errorMessage);
}

3. fallback UI 만들기

센트리에서 제공하는 에러 바운더리를 이용해서 fallback UI를 보여줍니다.
401 에러는 로그인 페이지로 이동시켜야 하기 때문에 별도의 에러 페이지가 필요하지 않아요. 403 에러 / 400대 에러 / 500대 에러 페이지를 준비했어요.

403 에러

400대 에러

500대 에러

fallback UI로 사용할 컴포넌트는 에러 객체를 props로 받기 때문에, 에러 인스턴스를 확인해서 적절한 UI를 보여줄 수 있어요.

export default function ErrorFallbackView({ error, resetError }) {
  // 401 에러
  if (error instanceof UnauthorizedError) {
    window.location.replace('로그인 URL');
    return <></>;
  }

  // 403 에러
  if (error instanceof ForbiddenError) {
    // resetError는 에러 바운더리의 onReset을 트리거 합니다.
    // react query를 초기화하기 위해 사용했습니다.
    return <ForbiddenErrorView resetError={resetError} />;
  }

  // 400대 에러
  if (error instanceof NotFoundError) {
    return <NotFoundErrorView resetError={resetError} />;
  }

  // 500대 에러 및 기타 에러
  return <InternalServerErrorView resetError={resetError} />;
}

API 에러가 발생하지 않더라도, 클라이언트에서 에러가 발생할 수 있기 때문에 (문법 오류, undefined에 대한 접근 등) 이에 대한 핸들링도 필요해요. 500대 에러와 동일한 에러 페이지 ("오류가 발생하였습니다")를 사용하기 위해 500대 에러는 별도의 분기 처리를 하지 않았어요.

이제 fallback UI를 <ErrorBoundary />의 props로 넣어주면 됩니다.

// src/_app.tsx
export default function App() {
  return (
    <Sentry.ErrorBoundary
      fallback={ErrorFallbackView}
      onReset={() => resetReactQuery()} // react query를 초기화하기 위한 커스텀 함수
    >
      // ...
    </Sentry.ErrorBoundary>
  );
}

⛔️ 에러 테스트하기

이제 에러가 발생했을 때 적절한 에러 페이지를 보여주는지 테스트해야겠죠? 백엔드 개발자에게 찾아가서 API를 망가뜨려달라고 해줍니다.

사실 그렇게 하면 안 되고... API 응답을 mocking 하면 됩니다!

1. MSW 설치하기

현재 MSW의 최선 버전은 2.3.0인데요, 이 버전은 Node.js 18 버전 이상부터 사용할 수 있어요. 패밀리케어는 Node.js 16 버전을 사용하고 있기 때문에, 아쉽게도 1.3.3 버전을 사용해야 했어요. 물론 문서도 v1 버전을 확인해야 합니다.

아래 명령어를 이용해서 간단하게 설치할 수 있어요.

npm install msw --save-dev
# or
yarn add msw --dev

2. API 응답 mocking 하기

저는 500대 에러 페이지를 확인하기 위해 아래와 같이 핸들러를 구성했어요.

// src/mocks/handlers.ts
import { rest } from 'msw';

export const handlers = [
  rest.get('API URL', (req, res, ctx) => {
    return res(ctx.status(500));
  })
]

3. 서비스 워커 구성하기

서비스 워커는 브라우저가 백그라운드에서 실행하는 스크립트를 말해요. 네트워크 요청을 가로채서 수정하거나, 리소스를 캐싱 해서 네트워크 상태가 좋지 않을 때 리소스를 제공하기도 해요.

handlers.ts에서 정의한 핸들러를 사용해서 워커 인스턴스를 생성해 줍니다.

// src/mocks/browser.ts
import { setupWorker } from 'msw';
import { handlers } from './handlers';

export const worker = setupWorker(...handlers);

4. 서비스 워커 실행 함수 만들기

서비스 워커를 실행하려면 위에서 만든 워커 인스턴스를 사용해야 해요. 저는 브라우저 환경에서만 실행하고 싶기 때문에, 조건문을 추가해 주었어요.

// src/mocks/index.ts
async function startWorker() {
  if (typeof window !== 'undefined') {
    const { worker } = await import('./browser');
    worker.start();
  }
}

startWorker();

5. 서비스 워커 실행하기

_app.tsx에서 서비스 워커 실행 함수를 import 해줍니다. 환경 변수를 이용해서 moking이 필요할 때만 실행하도록 해주었어요.

// src/_app.tsx
if (process.env.NEXT_PUBLIC_API_MOCKING === 'enabled') {
  import('../mocks');
}

moking이 정상적으로 동작하는 것을 확인할 수 있습니다.

🤠 마무리

에러 핸들링 작업을 시작하기 전에는 "금방 끝나겠지? 😏"라는 안일한 생각을... 했었는데요, 생각보다 센트리 설정을 이해하는데 많은 시간이 필요했어요. 작업 중간중간 "이게 최선일까?"라는 고민도 많이 들었구요.

4개의 API 요청 중에서 1개의 에러가 발생했을 때, 에러 페이지를 노출하는 게 맞을까? 에러를 세분화해서 다른 데이터라도 보여줄 수는 없을까? 어디까지 세분화할 수 있을까? 등등 머릿속에서 많은 생각이 들더라구요! 🧐

프로젝트 일정상 세분화 작업까지 논의하고 진행할 수는 없었지만, 추후에 개선해 보면 좋을 것 같습니다.

📚 Reference

profile
Frontend Developer

0개의 댓글