Logo

Prolip's Blog

센트리로 에러 모니터링하기
  • #Etc
  • #Project
  • #ModuReview

센트리로 에러 모니터링하기

Prolip

2025-09-03

Next.js를 사용한 프로젝트에 센트리 연결하기, 센트리로 에러 로깅 및 모니터링하기, 에러 발생 시 슬랙으로 알림 받기

이번에 프로젝트를 진행하면서 에러 처리에 꽤나 신경을 많이 썼다고 생각합니다. 이전에 작성한 게시글에서 에러를 중앙화했던 경험을 작성했는데요.

사용자에게 원할하고 안정적인 경험을 제공하는건 모든 어플리케이션의 핵심 목표라고 생각합니다.

그런데 정말 많은 변수(브라우저 - 특히 사파리.., 네트워크 상태, 예측하기 힘든 사용자 행동 등)가 존재하는 프론트 환경에서 에러 발생은 솔직히 피할 수 없다고 생각합니다.

그래서 이런 환경에서 센트리를 통해 어떻게 에러를 보다 효과적으로 추적할 수 있었는지 기록해 보려고 합니다.

센트리?

우선 센트리는 에러 추적, 성능 모니터링 플랫폼인데요. 어플리케이션에서 발생하는 에러를 실시간으로 로깅하고 발생한 원인도 상세하게 분석할 수 있게 다양한 컨텍스트 정보를 제공합니다.

단순히 에러가 발생했다는 로그만 남기는게 아니라, 발생한 환경(브라우저 정보나 심지어 IP까지..), 사용자의 행동 순서, 어떤 네트워크 요청을 사용했는지 등 추적에 도움이 될만한 모든 정보를 제공합니다.

프론트에 에러 모니터링이 필요한가?

백엔드 쪽에서 발생한 에러들로 통계 내면 되는 거 아닌가? 싶을 수도 있는데요. 백엔드도 물론 에러 로깅과 모니터링이 필요하고 프론트도 동시에 필요하다고 생각합니다.

1. 에러 핸들링은 상처 생긴 곳에 붕대 감는 정도?

위에서 링크를 걸어놨듯, 우리 프로젝트는 에러가 발생하면 사용자 경험을 해치지 않고자 아래와 같이 실패한 영역에 대해 대체 UI를 보여주거나 토스트 메세지로 적절히 안내하고 있습니다.

image.png

image.png

그런데 이건 어디까지나 상처 난 부위를 급하게 붕대로 감은 응급처치일 뿐입니다. 우리는 이 요청이 왜 실패했는지에 대한 근본적인 질문이 남게 됩니다.

사용자에게 에러가 발생했다고 알려주는 것과 그 에러를 다시 발생하지 않도록 해결하는 것은 아예 다른 차원의 문제라고 생각합니다.

image.png

위와 같이 예측하지 못한 심각한 에러로 어플리케이션이 중단된 상태에서 개발자가 인지하지 못한 채 시간이 흐른다면 사용자는 서비스를 더는 이용하지 않고 이탈할 수 있습니다.

2. 에러 재현은 어렵다.

사실 가장 큰 문제는 에러가 발생하는 지점이 사용자의 브라우저라는 문제인데요. 만약에 우리 서비스를 이용하며 게시글을 작성하다 에러를 마주했다고 가정해 봅시다.

우리는 사용자에게 “혹시 어떤 버튼을 누르셨나요? 게시글 작성 전에 어떤 페이지를 방문하셨고, 입력창에 어떤 입력값을 넣으셨나요? 콘솔 창에 메세지는 어떻고 네트워크 탭에 페이로드는 잘 전달되고 있나요?” 이런 질문이 불가능합니다.

애초에 이런 질문 자체가 비현실적이고 사용자에게 나쁜 경험을 제공할 뿐입니다. 개발자는 사용자의 도움 없이도 문제가 발생한 상황을 정확하게 재현하고 원인을 파악할 수 있는 도구가 필요합니다.

3. 다양한 컨텍스트

센트리는 바로 위에서 짚어본 문제를 해결할 수 있는 여러가지 정보를 제공하는데요. 에러가 발생하면 단순히 발생한 에러 메세지와 스택만 보여주는게 아니라 정말 다양한 컨텍스트를 함께 기록합니다.

스택 트레이스

{1AAB5DCC-5CBB-4D7C-A533-68B55B4863DF}.png

에러가 발생하면 어떤 파일의 몇 번째 줄에서 에러가 발생했는지 정확하게 알려줍니다.

브레드 크럼

image.png

빵 부스러기라고 하더라구요. 에러가 발생하기 직전까지 사용자가 어떤 버튼을 클릭하고 어떤 페이지로 이동했는지 행동 순서를 빵 부스러기가 떨어진 것처럼 알려줍니다.

세션 리플레이

image.png

이게 가장 좋았는데요. 에러 발생 직전 10초 가량 사용자 화면을 녹화합니다. 아무래도 에러가 발생하면 에러를 재현하는게 가장 어려웠는데, 마치 옆에서 지켜보는 것처럼 재현이 가능했습니다. 물론 텍스트나 사진 등 민감한 정보는 마스킹 처리하고 있습니다.

커스텀 태그와 컨텍스트

image.png

에러 발생 시점에 어떤 API를 사용했는지, 요청 본문에 무엇을 넣었는지, 사용자 정보 등 필요한 데이터들을 함께 기록해 에러를 그룹화하거나 에러의 원인을 보다 쉽게 추적할 수 있습니다.

슬랙과 연동 가능

image.png

사실 심각한 에러가 발생해도 플랫폼에 직접 들어가지 않는 이상 알 수가 없는데요. 센트리는 슬랙, 디스코드, 이메일 등을 연결해 에러의 심각도 등에 따라 에러 정보를 포함해 알림을 생성할 수 있었습니다.

  • 아마 에러 발생 도메인에 따라 팀별로 에러를 보고할 수 있었겠으나, 프로젝트 규모가 그만큼 크지 않아 더 깊게 사용해보진 못했습니다.

영업은 여기까지만 하고 Next.js를 사용한 프로젝트에 어떻게 센트리를 적용했는지 기록해보겠습니다.

Manual Setup

먼저 프로젝트에 센트리를 설치하기 위해 Manual Setup | Sentry for Next.js 를 참고했습니다. 물론 이건 직접 모든 설정을 해줘야 하는 문서구요. Next.js | Sentry for Next.js 이 문서를 참고하면,

npx @sentry/wizard@latest -i nextjs

이 명령어를 사용해 자동으로 프로젝트에 센트리를 설치할 수 있다고 나옵니다. 그런데 저는 제가 모르는 무언가를 마구 설치하는걸 싫어해서 직접 설치했습니다.

1. 센트리 설치

먼저 우리 프로젝트는 Next.js 15 버전을 사용하고 있습니다.

pnpm add @sentry/nextjs --save

해당 명령어로 프로젝트에 Sentry SDK를 설치합니다. 그 전에 먼저 센트리 계정과 프로젝트를 만들어두는게 좋습니다.

2. 센트리 확장하기

import type {NextConfig} from 'next'; const nextConfig: NextConfig = { // ... }; export default nextConfig

먼저 센트리를 프로젝트에 적용하기 위해 루트 경로에 있는 next.config.ts 파일을 설정해줘야 하는데요. 기존에 정의되어있는 nextConfig 객체를 withSentryConfig를 사용해 감싸주면 됩니다.

import type {NextConfig} from 'next'; import {withSentryConfig} from '@sentry/nextjs'; const nextConfig: NextConfig = { // ... }; export default withSentryConfig(nextConfig, { org: 'modu-review', project: 'javascript-nextjs-5r', authToken: process.env.NEXT_SENTRY_AUTH_TOKEN, silent: !process.env.CI, disableLogger: true, widenClientFileUpload: true, sourcemaps: { disable: false, assets: ['.next/server/chunks/*.js', '.next/server/chunks/*.js.map', '.next/static/chunks/*.js'], ignore: ['**/node_modules/**'], deleteSourcemapsAfterUpload: true, }, });

저는 위와 같이 구성했는데요. 각 옵션들이 어떤 역할을 하는지 살펴볼게요.

  • org: 센트리 계정을 처음 만들면 조직을 만들게 되는데요. 생성한 조직 이름을 작성합니다. (내가 만든 organization 이름이 jimin이라면 org: ‘jimin’)
  • project: 조직 내에 여러 프로젝트가 만들어질 수 있는데, 사용할 프로젝트 이름을 작성합니다.

위의 두 속성을 제대로 입력해야 빌드 타임에 센트리 서버로 소스맵을 업로드할 수 있습니다.

  • authToken: 소스맵을 센트리 서버에 업로드하기 위해 사용하는 토큰입니다. 보안을 위해 환경 변수로 사용하고 있습니다. 토큰 발급은 Settings ⇒ Developer Settings ⇒ Organization Tokens 탭에서 발급합니다.

  • silent: Netlify, Vercel, Github Actions 등 CI/CD 환경에선 process.env.CI가 true로 출력되는데요. 즉, 로컬 환경에서 빌드할 때는 터미널에 로그가 출력되지 않게 설정하고, 실제 배포 환경에선 로그를 출력하게 만듭니다. 실제 배포 환경에선 아래와 같이 소스맵을 업로드하고 있다거나 혹은 이런 동작을 하는데 싫다면 이 옵션을 false로 설정해라 등 여러 로그가 출력됩니다.

    image.png

  • disableLogger: 공식 문서에 작성된 내용에 기반해 설명하자면, 번들 크기를 줄이기 위해 센트리 내부 디버깅 메세지를 트리 쉐이킹한다고 하는데요. 아마 가지 털기라는 거 보니 사용자에게는 필요 없는 코드들을 제거하는 옵션으로 보입니다.

  • widenClientFileUpload: 공식 문서상 Next.js에서만 사용하는 옵션으로 분류되어있는데요. 소스맵을 업로드할 때 기본 경로 외에 더 넓은 범위를 탐색하도록 설정하는 옵션입니다.

  • sourcemaps - disable: false로 설정해 소스맵 업로드 기능을 활성화합니다.

  • sourcemaps - assets: 소스맵과 원본 파일을 어디서 찾을지 경로를 지정하는 패턴 배열입니다.

    assets: ["**/*.js", "**/*.js.map"], // Specify which files to upload
    • 이게 공식 문서는 위와 같이 설정하라고 나와있었는데요. 저렇게 설정하고 빌드해보니 빌드 터미널에 아래와 같이 파일을 찾지 못했다는 로그가 출력됐습니다.

      image.png

    • Next.js는 빌드 시 서버 단에서 실행되는 코드는 .next/server/chunks 폴더에 들어가고, 클라이언트 단에서 실행되는 코드는 .next/static/chunks 폴더에 들어갑니다.

    • 에러는 클라이언트 단에서만 발생하지 않기 때문에 서버, 클라이언트 모두 추적하기 위해 두 경로를 모두 센트리에 업로드 하도록 경로를 설정해줬습니다.

  • sourcemaps - ignore: 센트리가 소스맵을 찾을 때 제외할 폴더를 지정해줍니다.

  • sourcemaps - deleteSourcemapsAfterUpload: 빌드 후에 생성된 소스맵 파일을 센트리 서버에 업로드하고 업로드한 파일들을 삭제하기 위해 설정해준 옵션입니다.

    • 우리가 작성한 원본 코드는 빌드 타임에 번들링, 난독화를 통해 알아보기 힘들게 변환됩니다. 아래처럼 변수명도 e, a와 같이 못생기게 변경되고 공백과 주석을 제거해 알아보기 힘들게 압축시킵니다.

    • 이렇게 만들어진 코드가 브라우저에서 실행되는데요. 물론 이렇게 만들면서 전송되는 파일의 크기를 줄여주긴 합니다.

      {34996:(e,a,s)=>{s....d(a,{A:()=>j});var t=s(87784),n=s(75396), ...}
    • 소스맵은 이렇게 알아보기 힘든 코드에서 특정 위치의 코드가 원본 코드의 어디에 해당하는지에 대한 매핑 정보를 담고 있는 코드로 유출된다면 우리 소스코드가 모두 노출된다는 의미이기도 합니다.

    • 물론 이 프로젝트의 모든 코드는 이 곳에 공개되어있지만, 비공개 레포지토리라던가 지적 재산으로 취급되고 있다면 공개하지 않도록 주의해야 합니다.

3. 센트리 SDK 초기화

이제 프로젝트에 센트리 SDK를 초기화하고 실행할 단계입니다. 공식문서에선 먼저 루트 경로에 3개의 파일을 만들어야 한다고 나와있는데요.

  1. sentry.server.config.(js | ts)
  2. sentry.edge.config.(js | ts)
  3. instrumentation-client.(js | ts)

이 파일들은 코드가 실행되는 환경에 따라 센트리를 어떻게 초기화하고 동작시킬지 작성하는 파일들입니다. 각 파일들의 실행 환경과 역할은 다음과 같아요.

1. sentry.server.config.ts

실행 환경이 Node.js 서버일 때의 센트리 초기화 코드를 작성합니다. API Routes, 서버 컴포넌트 등 Node.js 환경에서 코드가 실행 중일 때 발생하는 에러를 어떻게 처리할지 작성합니다.

2. sentry.edge.config.ts

실행 환경이 엣지 런타임일 때의 센트리 초기화 코드를 작성합니다. 우리 프로젝트는 엣지 환경에서 동작할 일이 없어서 작성하지 않았습니다.

3. instrumentation-client.ts

실행 환경이 브라우저일 때의 센트리 초기화 코드를 작성합니다. 실제 사용자들이 브라우저를 통해 서비스를 이용할 때 발생하는 에러를 어떻게 처리할지 작성합니다.

이렇게 3개의 파일 중 1번과 3번 파일만 작성했는데요. 먼저 클라이언트 측 초기화 코드를 확인해볼게요.

instrumentation-client.ts (클라이언트)

센트리 공식 문서에서 클라이언트 측 SDK 초기화를 위해 instrumentation-client.ts를 사용하라고 안내하고 있습니다. instrumentation-client.ts는 Next.js 공식 문서에서 이렇게 알려주고 있는데요.

The file allows you to add monitoring, analytics code, and other side-effects that run before your application becomes interactive.

이 파일을 쓰면 어플리케이션이 인터랙티브해지기 전에 모니터링, 분석 혹은 기타 부수 효과들을 실행시킬 수 있다고 하네요.

이 파일의 실행 시점은 다음과 같아요.

  1. After the HTML document is loaded - HTML 문서가 모두 로드된 직후
  2. Before React hydration begins - 하이드레이션 시작 직전
    • 센트리 초기화 코드가 리액트 컴포넌트에서 실행되면 하이드레이션 과정에서 발생하는 에러를 못 잡을 수도 있으니 이 파일을 사용하는 거 같네요.
  3. Before user interactions are possible - 상호작용이 가능해지기 직전

이렇게 페이지가 사용자 눈에 보이기 전에 센트리 같은 모니터링 툴을 미리 초기화해 초기 생명주기부터 사용자의 상호작용까지 발생하는 이벤트를 모두 포착할 수 있습니다.

공식 문서를 확인하면 성능 분석부터 에러 추적 등 여러 기능을 붙일 수 있더라구요. 하지만 코드가 무거워지면 성능에 문제가 생길 수 있는지 코드를 가볍게 유지하라고 나와있습니다. (아마 실행 시점 때문일 거 같습니다.)

import * as Sentry from '@sentry/nextjs'; import {isDevelopment, isProduction} from '@/shared/lib/utils/env'; const getEnvironmentConfig = () => { if (isProduction) { return { tracesSampleRate: 0.1, replaysSessionSampleRate: 0.01, sampleRate: 1.0, enableLogs: false, }; } if (isDevelopment) { return { tracesSampleRate: 1.0, replaysSessionSampleRate: 0.1, sampleRate: 1.0, enableLogs: true, }; } }; const config = getEnvironmentConfig(); Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, environment: process.env.NODE_ENV, ...config, replaysOnErrorSampleRate: 1.0, maxBreadcrumbs: 50, integrations: [ Sentry.replayIntegration({ maskAllText: true, maskAllInputs: true, blockAllMedia: true, }), ], tracePropagationTargets: [ /^https:\/\/api\.modu-review\.com/, /^https:\/\/dev\.modu-review\.com:3000/, /^https:\/\/.*\.modu-review\.com/, ], beforeSendTransaction(event) { const transactionName = event.transaction; const ignoredTransactions = ['/_next/static', '/favicon.ico', '/robots.txt', '/sitemap.xml']; if (ignoredTransactions.some(ignored => transactionName?.includes(ignored))) { return null; } const duration = event.timestamp! - event.start_timestamp!; if (duration < 100) { return Math.random() < 0.01 ? event : null; } return event; }, }); export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;

이건 제가 설정한 옵션들인데요. 개발 환경과 배포 환경에 따라 센트리의 샘플링 동작을 다르게 설정하고 있습니다.

1. getEnvironmentConfig

export const isProduction = process.env.NODE_ENV === 'production'; export const isDevelopment = process.env.NODE_ENV === 'development';

코드가 실행되는 환경을 감지해 샘플링 설정을 반환하는 함수입니다. 실행 환경 감지를 위한 isProductionisDevelopment는 서버 측 설정 파일에서도 재사용하기 위해 분리했습니다.

import {isDevelopment, isProduction} from '@/shared/lib/utils/env'; const getEnvironmentConfig = () => { if (isProduction) { return { tracesSampleRate: 0.1, replaysSessionSampleRate: 0.01, sampleRate: 1.0, enableLogs: false, }; } if (isDevelopment) { return { tracesSampleRate: 1.0, replaysSessionSampleRate: 0.1, sampleRate: 1.0, enableLogs: true, }; } };
  • tracesSampleRate: 트랜잭션 샘플링 비율을 설정합니다. 트랜잭션은 사용자의 행동 단위로 페이지 진입, 버튼 클릭, API 요청을 모두 포함합니다.
    • 개발 환경 (isDevelopment) - 1.0 (100%)
    • 배포 환경 (isProduction) - 0.1 (10%)
    • 왜 수집하느냐? 사실 이건 에러 모니터링과는 거리가 좀 멀고 오히려 성능 모니터링에 가까운데요. 에러가 없어도 정상적인 동작에 대한 성능 데이터를 수집하게 됩니다. 페이지 로딩에 시간이 얼마나 걸리는지, API 호출이 얼마나 빠른지 등을 수집할 수 있어 성능 개선에 활용할 수 있습니다.
  • replaySessionSampleRate: 정상 세션에 대한 녹화 비율을 설정합니다.
    • 개발 환경 (isDevelopment) - 0.1 (10%)
    • 배포 환경 (isProduction) - 0.01 (1%)
  • sampleRate: 에러 샘플링 비율을 설정하는데요. 아무래도 에러는 서비스 안정성에 크게 영향을 끼친다고 판단해 개발 환경과 배포 환경 모두 에러가 발생할 경우 100% 수집할 수 있게 설정했습니다.
    • 개발 환경 (isDevelopment) - 1.0 (100%)
    • 배포 환경 (isProduction) - 1.0 (10)%)
  • enableLogs: 센트리 내부 로그를 활성화할지 설정합니다. 배포 환경에서 불필요한 로그를 출력할 시 사용자에게 혼란을 줄 수 있다고 판단했습니다.
    • 개발 환경 (isDevelopment) - true
    • 배포 환경 (isProduction) - false

2. Sentry.init

센트리 SDK 초기화 영역이라고 생각하면 됩니다. 샘플링 비율은 개발 환경과 배포 환경이 달라 따로 getEnvironmentConfig 함수로 분리했는데 나머지 공통 값들을 이 곳에서 설정합니다.

Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, environment: process.env.NODE_ENV, ...config, replaysOnErrorSampleRate: 1.0, maxBreadcrumbs: 50, integrations: [ Sentry.replayIntegration({ maskAllText: true, maskAllInputs: true, blockAllMedia: true, }), ], tracePropagationTargets: [ /^https:\/\/api\.modu-review\.com/, /^https:\/\/dev\.modu-review\.com:3000/, /^https:\/\/.*\.modu-review\.com/, ], beforeSendTransaction(event) { const transactionName = event.transaction; const ignoredTransactions = ['/_next/static', '/favicon.ico', '/robots.txt', '/sitemap.xml']; if (ignoredTransactions.some(ignored => transactionName?.includes(ignored))) { return null; } const duration = event.timestamp! - event.start_timestamp!; if (duration < 100) { return Math.random() < 0.01 ? event : null; } return event; }, });
  • dsn: 센트리 프로젝트 고유 키로 이 주소로 모든 에러, 성능 데이터를 전송합니다. 이 고유 키는 센트리 페이지에서 조회할 수 있습니다. 환경 변수로 관리합니다.
  • environment: 현재 환경이 development인지 production인지 태그를 붙여줄 수 있습니다. 이 값은 센트리 대시보드에서 조회가 가능해 필터링할 때 유용합니다.
  • replaysOnErrorSampleRate: 에러 발생 시 세션 리플레이 샘플링 비율을 설정할 수 있는데요. 에러가 발생한 사용자 세션을 모두 녹화합니다.
    • 사실 버그가 발생했을 때 재현하는 과정이 제일 힘들다고 생각합니다. 브라우저의 특성으로 인해 발생할 수도 있고 그 순간의 네트워크 에러일 수도 있습니다. 그 외 여러 문제로 인해서도 버그가 발생할 수 있어 문제를 파악하고 재현하는 데 많은 시간이 소요됩니다.
    • 하지만 그 순간 자체를 녹화해버린다면 사용자가 어떤 행동을 했는지 어떤 순서로 진입했는지 등을 영상으로 확인할 수 있어 보다 쉽게 에러를 파악하고 해결할 수 있습니다.
  • maxBreadcrumbs: Breadcrumbs는 에러가 발생하기까지의 사용자 행동을 기록한 데이터인데요. HTML 요소 클릭, URL 이동 등을 포함합니다. 버그 재현에 세션 녹화를 사용하고 있어 기본값 100에서 50으로 줄여 전송 데이터의 크기를 줄였습니다.
  • integrations: 센트리 기능을 확장하는 영역입니다. 사용 가능한 모든 옵션은 이 곳에서 확인할 수 있습니다.
    • replayIntegration: 세션 리플레이 기능을 활성화하기 위해 배열에 등록합니다.
      • maskAllText: 화면의 모든 텍스트를 마스킹 처리할 수 있습니다. 녹화된 영상에 모든 텍스트가 ***로 표시됩니다.
      • maskAllInputs: 모든 입력 필드의 내용을 마스킹 처리합니다.
      • blockAllMedia: 이미지나 비디오 등 모든 미디어 요소를 녹화에서 제외해 버립니다.
  • tracePropagationTargets: 분산 추적을 위한 설정으로 배열에 등록된 주소로 API 요청을 보내면 센트리가 요청 헤더에 추적 정보(sentry-trace)를 심어서 보냅니다. 백엔드 측에 센트리가 설치되어있으면 하나의 트랜잭션으로 묶어 요청의 병목 지점이나 에러 발생을 쉽게 추적할 수 있습니다.
    • 근데 저희 백엔드에 센트리가 설치되어있지 않습니다.. 근데 나중에 도입될 가능성이 있어서 그냥 뒀습니다.
    • 설정하면 백엔드 측에서 Access-Control-Allow-Headers에 sentry-trace 헤더를 허용해줘야 합니다.
  • beforeSendTransaction: 센트리로 트랜잭션 데이터를 보내기 직전에 실행되는 메서드로 보낼 데이터를 필터링할 수 있습니다.
    • _next/static, favicon.ico 등 모니터링이 굳이 필요하지 않은 자원에 대해서는 수집하지 않게 설정했습니다.
    • 처리 시간이 100ms 미만이면 요청이 빠르게 처리된다고 생각하는데요. 이 경우 성능에 문제가 없을테니 굳이 수집하지 않게 설정했습니다. 빠른 요청은 수집해봐야 노이즈만 생기고 느린 요청만 수집해야 문제 지점을 정확하게 판단할 수 있다고 생각했습니다.
    • 그런데! 그 중에서 Math.random() < 0.01로 1%는 수집해 진짜로 문제가 없는지 가끔 확인할 용도로 수집합니다.

3. onRouterTransitionStart

performance.mark('app-init') export function onRouterTransitionStart( url: string, navigationType: 'push' | 'replace' | 'traverse' ) { console.log(`Navigation started: ${navigationType} to ${url}`) performance.mark(`nav-start-${Date.now()}`) }

Next.js에서 제공하는 함수로 클라이언트 사이드 네비게이션이 발생할 때 실행되는 함수입니다. 함수의 스펙은 위와 같은데요. 함수를 직접 구현하진 않고 Sentry.captureRouterTransitionStart를 등록해 센트리에서 제공하는 함수를 사용합니다.

image.png

우리가 직접 복잡한 기능을 구현하진 않고, 내부적으로 페이지 이동 간에 소요되는 시간을 센트리가 측정해 대시보드에 기록해줍니다.

sentry.server.config.ts (서버)

import {isDevelopment, isProduction} from '@/shared/lib/utils/env'; import * as Sentry from '@sentry/nextjs'; const getEnvironmentConfig = () => { if (isProduction) { return { tracesSampleRate: 0.1, sampleRate: 1.0, enableLogs: false, }; } if (isDevelopment) { return { tracesSampleRate: 1.0, sampleRate: 1.0, enableLogs: true, debug: true, }; } }; const config = getEnvironmentConfig(); Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN, environment: process.env.NODE_ENV, ...config, });

sentry.server.config.ts는 바로 다음 내용에서 다루겠지만 instrumentation.ts에서 register에 의해 호출되는 파일입니다. Next.js 서버 환경(node.js)에서 센트리가 어떻게 동작할지 정의하는 파일입니다.

dsn이나 샘플링 관련 옵션들은 이미 클라이언트 측 설정 파일에서 다뤘기 때문에 생략할게요. 클라이언트 설정 파일과 크게 다른 점이라면 세션 리플레이 기능을 제외했다는 점인데요.

세션 리플레이는 사용자의 화면과 마우스 움직임 등 시각적인 상호작용을 녹화하는 기능입니다. 이건 근본적으로 브라우저가 아니라면 의미가 없는 기능인데요. Next.js 서버는 UI가 없는 node.js 환경에서 실행되니 이 기능이 필요하지도 않고 적용도 불가능합니다.

instrumentation.ts

import {type Instrumentation} from 'next'; import {captureRequestError} from '@sentry/nextjs'; export const onRequestError: Instrumentation.onRequestError = captureRequestError; export async function register() { if (process.env.NEXT_RUNTIME === 'nodejs') { await import('./sentry.server.config'); } }

바로 위에서 설정한 sentry.servrer.config.ts는 알아서 실행되지 않습니다. intrumentation.ts 파일을 이용해야 하는데요.

이것도 공식 문서를 읽어보면 모니터링이나 로깅 도구를 프로젝트에 통합하기 위해 코드를 사용하는 과정이라고 하는데요. 그냥 instrumentation-client 은 브라우저 전용, instrumentation 은 서버 전용이라고 이해하시면 됩니다.

프로젝트의 루트 경로나 src 폴더에 두면 센트리, OpenTelemetry, Vercel의 otel 등의 외부 모니터링 툴을 연결할 수 있다고 합니다.

intrumentation-client.ts 처럼 내부에 센트리 초기화 코드를 직접 작성하지는 않고 별도의 함수를 사용해 미리 작성한 초기화 코드를 불러옵니다.

register

이 함수는 서버 인스턴스가 시작될 때 가장 먼저, 단 한 번만 실행된다고 합니다. 즉, 서버가 무언가 요청을 처리하기 전 모니터링 툴의 초기화 코드를 가장 먼저 실행하는 것을 보장해준다는 의미이기도 합니다.

공식 문서에서 부수효과를 일으킬 수 있는 코드를 register 함수 내에서 자바스크립트의 동적 import() 구문으로 불러오라고 권장하고 있습니다.

export async function register() { await import('./sentry.server.config'); }

센트리 설정 파일(sentry.server.config.ts)을 불러와 센트리 SDK를 초기화하는 작업은 부수효과를 일으킬 수 있는 작업에 해당하기 때문에 위에서 권장하는 방식으로 불러옵니다.

  • 센트리를 연결하면 에러가 발생했을 때 발생한 에러 정보를 센트리 서버로 보내기 위한 HTTP 요청을 만들어냅니다. 혹은 사용자의 행동이나 네트워크 요청 등 브레드크럼을 자동으로 수집하도록 기본 동작을 변경하기도 합니다. 이런 동작들을 부수효과라고 생각합니다.

한 가지 주의사항으로 register 함수는 node.js, edge를 포함한 모든 서버 환경에서 실행되는데요. edge 런타임의 경우 네이티브 node.js API를 사용하지 못하거나(I/O 작업에 사용되는 fs 모듈 사용 불가) require 구문을 사용해야 하는 등 사용 방식이 달라집니다.

때문에 node.js 환경에서 초기화할 sentry.server.config.ts, edge 환경에서 초기화할 sentry.edge.config.ts를 따로 분리해서 작성합니다.

export async function register() { if (process.env.NEXT_RUNTIME === 'nodejs') { await import('./sentry.server.config'); } if (process.env.NEXT_RUNTIME === 'nodejs') { await import('./sentry.edge.config'); } }

이렇게 분리한 파일들은 NEXT_RUNTIME 이라는 환경 변수를 사용해 분기처리할 수 있는데요. 이 값으로 현재 실행 중인 환경을 감지할 수 있습니다. 하지만 우리 프로젝트는 엣지 환경에서 실행할 일이 없어 node.js에 대한 케이스만 처리합니다.

onRequestError

register 말고 onRequestError라는 함수도 export 하고 있는데요. 이 함수는 서버에서 발생한 에러를 추적하는 함수로 센트리 같은 외부 모니터링 툴과 연결하기 위한 함수라고 공식문서에 나와있습니다.

서버가 에러를 감지할 때 트리거된다고 하는데요. 구체적으로 서버 컴포넌트를 렌더링하거나, 라우트 핸들러, 서버 액션, 미들웨어 등의 요청 처리 파이프라인 중 에러가 발생하고 서버가 이걸 감지하면 실행됩니다.

공식문서에선 try/catch로 직접 처리한 경우엔 호출되지 않는다고 합니다. 아마 그래서 그런지 센트리 대시보드에 서버 측에서 발생한 에러는 모두 unhandled 상태로 보고되는 거 같았습니다.

또, 에러 인스턴스가 서버 컴포넌트 렌더링 중에 리액트가 처리해버리면 throw된 시점의 에러 인스턴스가 아닐 수도 있다고 하는데요. 이건 digest를 사용해 확인해야 한다고 하네요. 어차피 센트리가 알아서 처리해줘서 여기까지만 알아도 될 거 같습니다.

export function onRequestError( error: { digest: string } & Error, request: { path: string method: string headers: { [key: string]: string } }, context: { routerKind: 'Pages Router' | 'App Router' routePath: string routeType: 'render' | 'route' | 'action' | 'middleware' renderSource: | 'react-server-components' | 'react-server-components-payload' | 'server-rendering' revalidateReason: 'on-demand' | 'stale' | undefined renderType: 'dynamic' | 'dynamic-resume' } ): void | Promise<void>

에러가 발생해 함수가 호출되면 3가지 파라미터를 전달하게 됩니다. 각 파라미터는 실제로 아래와 같이 출력되는데요.

# error 객체 Error: 이건 페이지 컴포넌트에서 발생하는 렌더링 에러일 거예요. at Page (app\rendering-error-test\page.tsx:2:8) 1 | export default function Page() { > 2 | throw new Error('이건 페이지 컴포넌트에서 발생하는 렌더링 에러일 거예요.'); | ^ 3 | 4 | return <section>테스트</section>; 5 | } { digest: '2401259980' } # request 객체 { path: '/rendering-error-test?_rsc=w37qh', method: 'GET', headers: { host: 'dev.modu-review.com:3000', connection: 'keep-alive', 'user-agent': 'node', ... } } # context 객체 { routerKind: 'App Router', routePath: '/rendering-error-test', routeType: 'render', renderSource: 'react-server-components', revalidateReason: undefined } # context 객체 - 라우트 핸들러에서 발생한 경우 context 객체: { routerKind: 'App Router', routePath: '/api/route-error-test', routeType: 'route', revalidateReason: undefined }

에러의 발생 지점, 경로, 요청 정보 등 여러 정보가 담겨오는걸 확인할 수 있습니다. 사실 이 정보들을 우리가 직접 처리하지는 않고 Sentry.captureRequestError함수를 연결하는게 끝입니다.

내부적으로 저 3개의 파라미터 값을 적절히 조합해서 센트리 서버로 전송하는 역할을 하는듯한데요. 굳이 내부적으로 어떻게 동작하는지까지는 열어보지 않았습니다.

실제로 이렇게 연결한 뒤로 센트리 대시보드에 한 가지 에러가 보고됐었는데요. 메인 페이지가 ISR로 동작하고 있어 주기적으로 백엔드 서버 측으로 HTTP 요청을 보내고 있었는데 백엔드 측 SSL 인증서가 만료됐는지, 아래와 같이 에러가 보고되어 있었습니다.

image.png

아마 메인 페이지를 다시 캐싱하기 위해 HTML 문서를 만드는 과정에서 백엔드 측으로 필요한 데이터를 요청했고, 해당 요청이 실패하면서 발생한 에러를 센트리에 보고한 것으로 보입니다.

메인 페이지 코드에는 센트리로 에러를 전송하는 로직을 작성하지 않았는데 intrumentation의 onRequestError가 처리되지 않은 에러를 감지해 센트리로 보고했다고 생각합니다.

4. 에러 보고하기

여기까지 설정했으면 이제 프로젝트에서 센트리가 동작하는데요. 여기까지 설정한대로라면 서버 측에서 발생한 처리되지 않은(unhandled) 에러는 자동으로 감지하게 됩니다.

하지만 브라우저에서 발생하는 에러를 감지할 수 있어야 하는데요. 단순히 에러를 수집하기보단, 어떤 에러가 중요한지 선별하고 에러에 대한 컨텍스트를 제대로 제공할 수 있어야 합니다.

reportErrorToSentry

Sentry.captureException(error)

센트리 서버로 발생한 에러를 보내기 위해 위와 같이 센트리에서 제공하는 captureException 함수를 사용할 수 있습니다. 하지만 대시보드에 단순히 에러 메세지만 출력하지 않고 에러가 어디서, 왜 발생했는지 나타내기 위해 별도의 함수를 만들었습니다.

import {RequestError} from '@/shared/apis/request-error'; import {captureException, SeverityLevel, withScope} from '@sentry/nextjs'; type ReportErrorToSentry = { error: RequestError | Error; level?: SeverityLevel; type: 'API' | 'Rendering'; }; export function reportErrorToSentry({level = 'error', error, type}: ReportErrorToSentry) { withScope(scope => { scope.setLevel(level); scope.setTag('error_type', `${type} - ${error.name}`); if (error instanceof RequestError) { const {name, message, endpoint, status, requestBody, method} = error; scope.setTags({ title: name, message, endpoint, status, requestBody: JSON.stringify(requestBody), method, }); } else { const {name, message} = error; scope.setTags({ title: name, message, }); } captureException(error); }); }

먼저 함수의 외부 인자로 3개를 받게 설정했는데요.

  • level: 발생한 에러의 심각도를 나타내기 위한 레벨로 'fatal', 'error', 'warning', 'log', 'info', 'debug' 이렇게 총 6개의 단계로 구분되어 있습니다.
  • error: 발생한 에러 그 자체로 우리 프로젝트에서 에러 객체를 확장한 RequestError 객체 혹은 기본 에러 객체가 전달됩니다.
  • type: API 호출 중 발생한 에러인지 렌더링 과정에서 발생한 에러인지를 나타내는 인자입니다.

이렇게 인자를 받아 센트리로 에러를 전송하기 위한 준비를 시작합니다.

  1. 먼저 withScope는 특정 에러 이벤트를 보내기 전에 해당 이벤트에만 적용되는 독립적인 공간을 만들어주는데요. 이 공간 안에서 추가한 정보(태그, 레벨 등)는 다른 에러에 영향을 주지 않습니다.
  2. 다음으로 scope.setLevel(level)로 인자로 전달된 레벨로 에러의 심각도를 설정합니다. 우리 프로젝트에서는 현재 어플리케이션이 다운될 수 있는 fatal 레벨만 센트리로 전송하고 있습니다.
  3. scope.setTag('error_type', 'API' | 'Rendering')로 에러에 검색 가능한 꼬리표를 설정합니다. 이렇게 설정된 에러는 대시보드에 'API - INTERNAL_SERVER_ERROR', 'Rendering - data is undefined'와 같이 표시되기 때문에 1차적으로 필터링하기 유용해집니다.
  4. 다음으로 if (error instanceof RequestError)를 통해 에러의 생성자를 확인합니다.
    • RequestError 타입은 프로젝트에서 별도로 사용하기 위해 에러 객체를 확장한 에러 타입으로 내부에 endpoint, status, requestBody 등 상세 정보를 담고 있습니다. 센트리 대시보드에서 필터링 가능하게 이 정보를 태그로 주입합니다.
    • 그 외 일반 에러 객체가 들어온 경우 최소한의 컨텍스트를 보장하기 위해 name, message를 태그로 주입합니다.
  5. 마지막으로 captureException(error)을 호출해 모든 정보가 추가된 에러를 센트리에 보고합니다.

reportError

사실 모든 에러를 센트리로 보내는 것은 비효율적이라고 생각합니다. 발생하는 모든 사소한 에러를 센트리로 보고하게 되면 정말로 급하게 처리해야 할 에러를 놓치게 될 수 있기 때문인데요. (그리고 센트리 플랜마다 수집 가능한 에러 상한이 정해져있습니다.)

그런 이유로 어플리케이션의 안정성에 영향을 크게 끼치는 문제만 보고하기 위해 함수를 하나 더 만들게 되었습니다.

export function reportError(error: RequestError | ClientError) { // 유동적으로 주석, 주석 해제 if (isDevelopment) return; if (error instanceof ClientError) return; if (error.status >= 500 && error.status < 600) { reportErrorToSentry({level: 'fatal', error, type: 'API'}); } }

이렇게 500번대의 상태 코드를 가진 API 에러만 보고하도록 코드를 작성했는데요.

이유는 404 Not Found, 401 Unauthorized 등의 에러는 클라이언트 측에서 잘못 요청했을 때 발생할 수 있는 그나마 예상 가능한 에러인 반면, 500번대 에러는 백엔드 서버가 다운됐거나 혹은 그 외 예측하지 못한 심각한 문제일 수 있기 때문입니다.

GlobalErrorDetector

export default function GlobalErrorDetector({children}: Props) { const globalError = useGlobalError(); const router = useRouter(); useEffect(() => { if (!globalError) return; reportError(globalError); // 토스트로 에러 표시 }, [globalError, router]); return children; }

이건 reportError를 사용하는 예시인데요. 제가 이전에 효율적인 에러 처리를 위한 몸부림이라는 게시글로 기록했듯, 우리 프로젝트는 리액트 쿼리를 통하는 요청 중 에러가 발생하면 GlobalErrorDetector 라는 컴포넌트로 모이는 구조입니다. 여기서 감지된 에러를 reportError로 전달하고 있습니다.

GlobalErrorBoundary

이제 API로 인한 에러 외에 어플리케이션을 중단시킬 수 있는 치명적인 에러도 로깅할 수 있어야 하는데요.

// Providers.tsx export default function Providers({children}: Props) { return ( <UnPredictableErrorBoundary> <GlobalErrorDetector> <ReactQueryProvider> <AuthProvider> <NotificationProvider>{children}</NotificationProvider> </AuthProvider> </ReactQueryProvider> </GlobalErrorDetector> </UnPredictableErrorBoundary> ); } // UnpredictableErrorBoundary.tsx export default function UnPredictableErrorBoundary({children}: Props) { return <ErrorBoundary FallbackComponent={GlobalErrorBoundary}>{children}</ErrorBoundary>; } // GlobalErrorBoundary.tsx export default function GlobalErrorBoundary({error}: Props) { useEffect(() => { reportErrorToSentry({level: 'fatal', error, type: 'Rendering'}); }, [error]); return ( <section> {/** UI **/} </section> ); }

우리 프로젝트는 처리되지 않은 에러로 인해 어플리케이션이 다운되지 않게 가장 상위 영역을 에러 바운더리로 감싸놓은 상태인데요.

렌더링 과정에서 에러가 발생해 이 컴포넌트가 화면에 표시된다면, 사용자가 정상적으로 서비스를 이용할 수 없는 상태임을 의미합니다.

따라서 useEffect을 사용해 컴포넌트가 마운트되는 시점에 fatal 레벨, Rendering 타입으로 센트리에 보고할 수 있게 구현했습니다.

5. 슬랙과 연동하기

여기까지 설정하면 센트리 대시보드에 발생한 에러가 보고되기 시작하는데요. 하지만 대시보드에 직접 접속하지 않고는 즉시 에러에 대한 알림을 받아볼 수는 없습니다.

image.png

알림 설정을 위해 Issues 탭 내에 Alerts 탭을 클릭하면 위처럼 Alerts 페이지가 나오는데요. 화면 상단의 Create Alert 버튼을 클릭하면,

image.png

이렇게 새로운 알림을 생성할 수 있습니다. 에러가 발생해 센트리 서버로 보고되면 하나의 이슈가 생성되는데, 이슈에 대한 알림을 생성하기 위해 Issues를 선택하고 Set Conditions를 클릭합니다.

image.png

이제 어떤 조건에서 알림을 생성할지 작성하게 되는데요. 먼저 1번 어떤 환경에서 알림을 생성할지 정합니다. 기본적으로 production 환경에서만 알림을 생성하게 설정했습니다.

  • When - 언제 알림을 생성할지 정하는데요. 새로운 에러가 발생해 이슈가 발행됐을 때 알림을 생성합니다.
  • If - 필터링 단계로 error 이상의 에러에 대해 알림을 생성합니다.
    • fatal 등급의 에러만 알림을 생성하려고 했으나, Next.js 서버 단에서 발생한 에러는 intrumentation의 onRequestError가 에러를 보고합니다. 이 때 error 레벨의 unhandled 에러가 센트리 서버에 도착하기 때문에 error 이상의 에러는 모두 알림을 생성하고 있습니다.
  • Then - Send a Slack notification를 누르면 슬랙 워크스페이스, 채널을 선택해 해당 채널에 알림을 전송할 수 있는데요. 채널명, 채널 ID를 입력하고 나타내고 싶은 태그를 포함할 수 있습니다. 마지막 notes 칸에 유저 ID를 @ID 형태로 입력하면 알림을 생성했을 때 멘션할 수 있습니다.

image.png

image.png

이후에 에러가 발생하면 이렇게 어떤 페이지에서 발생했는지, 어떤 API를 사용했는지, 어떤 HTTP 요청을 사용했는지에 대한 정보를 포함해 알림이 생성됩니다.

마치며

여기까지 Next.js로 이루어진 프로젝트에 센트리 연결하는 법을 기록해봤는데요.. 사실 센트리 문서를 확인해보면, 더 많은 기능이 있어보였지만 제대로 다루어보지는 못했다는 점이 아쉽게 남았습니다.

규모가 더 큰 팀일수록 더 많은 기능을 이용해볼 수 있을듯한데요. 이건 나중에 기회가 된다면 제대로 다루어보고 싶습니다.

오늘도 읽어주셔서 감사합니다….

..

뿅..

Next.js의 캐싱 기능 활용하기

Next.js의 캐싱 기능 활용하기