올해 상반기에 우리 회사는 가장 매출이 큰 프로젝트 중 하나를 리뉴얼을 하게 되었습니다.
왜 Sentry를 도입하게 되었는지
프로젝트에서도 가장 중요한 서비스 (서비스 A라고 하겠습니다.)는 주말에도 정상적으로 실행이 되어야하고, 새벽 시간대에도 정상적으로 실행이 되어야합니다.
하지만 지금까지 에러 모니터링은 기획팀에서 하루에 3번 씩 실행하는 방법이었습니다.
이 방법은 현실적으로 주말이나, 늦은 새벽에 실행하기에는 어려움이 있습니다.
따라서 저는 자동 모니터링을 해주는 시스템을 개발하기로 했습니다.
왜 굳이 Sentry냐, WebSocket을 연결해서 API 통신 중 이벤트가 발생하면 catch하면 안되는 것인지에 대해 저도 많은 생각을 해봤는데요.
이미 Sentry라는 좋은 툴이 있고, WebSocket 연결을 하게되면 DB를 한 번 거쳐 들어와야하는 번거로움과 DB를 잘 모르지만 설계해서 구현할 때의 보안 취약점 등 여러 이유 때문에 Sentry를 선택하게 되었습니다.
더 많은 이유를 말씀드리자면, Sentry에서는 어느 위치, 사용자 개인 정보, 특정 OS, Session Replay 등 어떤 사용자가 어느 시점에서 서비스를 사용하다 에러가 발생했는지 알 수 있습니다.
Sentry 설치하기
yarn add @sentry/react or npm install @sentry/react
# Next.js 프로젝트라면
yarn add @sentry/nextjs Sentry 초기 설정
import * as Sentry from '@sentry/react';
export const initSentry = () => {
const dsn = import.meta.env.VITE_SENTRY_DSN;
if (!dsn) {
console.warn('Sentry DSN이 설정되지 않았습니다.');
return;
}
Sentry.init({
dsn: dsn,
environment: import.meta.env.MODE || 'development',
// 성능 모니터링 샘플링 비율
tracesSampleRate: import.meta.env.MODE === 'production' ? 0.1 : 1.0,
// Session Replay 설정
replaysSessionSampleRate: 0.1, // 일반 세션 10%
replaysOnErrorSampleRate: 1.0, // 에러 발생 시 100%
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration(),
],
});
};
export { Sentry };기본적으로 세팅은 위와 같습니다. DSN은 프로젝트 Settings > Client Keys에서 확인할 수 있습니다. DSN 키는 반드시 .env 파일에 등록해야 합니다.
Sentry 초기화
import { initSentry, Sentry } from '@src/util/sentry';
// 앱 시작 전 초기화
initSentry();
function App() {
return (
<Sentry.ErrorBoundary
fallback={({ error }) => (
<div className="error-container">
<h2>오류가 발생했습니다</h2>
<p>{error.message}</p>
</div>
)}
showDialog
>
{/* 앱 컴포넌트 */}
</Sentry.ErrorBoundary>
);
}가장 상위 tsx 파일에 initSentry()를 넣어 초기화합니다. 앱 실행 시 초기화가 이루어지며 모니터링이 시작됩니다.
필요없는 에러 필터링
기본적으로 서비스 A는 에러가 나면 안되는 시스템이기 때문에 typeError, SyntexError 등 자질구레한 에러들은 처리를 하면 안됐습니다.
예를 들어, GoogleAnalyTics, 브라우저 확장 프로그램 에러 등 알림을 필터링했습니다.
Sentry.init({
dsn: dsn,
// ... 기타 설정
beforeSend(event, hint) {
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 1. 명시적으로 보낸 에러는 항상 전송
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
if (event.tags && (event.tags.form_type || event.tags.error_type)) {
return event; // 비즈니스 로직 에러는 무조건 전송
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 2. 외부 스크립트 에러 필터링
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const filename = event.exception?.values?.[0]?.stacktrace?.frames?.[0]?.filename || '';
const externalScripts = [
'groobee', // 광고 스크립트
'ex2cts', // 외부 서비스
'gelatto', // 외부 서비스
'googleadservices', // Google Ads
'google-analytics', // GA
'gtm', // Google Tag Manager
'facebook', // Facebook Pixel
'naver', // Naver 스크립트
];
if (externalScripts.some(script =>
filename.toLowerCase().includes(script.toLowerCase())
)) {
return null; // 에러 무시
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 3. 브라우저 확장 프로그램 에러 필터링
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
if (
filename.includes('extension://') ||
filename.includes('chrome-extension://')
) {
return null;
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 4. 일반적인 TypeError 필터링
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const errorType = event.exception?.values?.[0]?.type || '';
const errorMessage = event.exception?.values?.[0]?.value || '';
if (
errorType === 'TypeError' &&
(errorMessage.includes('Cannot read properties of null') ||
errorMessage.includes('Cannot read property') ||
errorMessage.includes('is not defined'))
) {
// 중요한 컨텍스트가 있으면 보내고, 없으면 필터링
if (!event.contexts?.test_drive_form && !event.contexts?.consulting_form) {
return null;
}
}
return event;
},
});결과적으로, 서비스 A와 중요도가 같은 서비스에서 에러가 발생 시에만 캡처가 되도록 수정했습니다.
그 결과, response가 200이 아니면서 서비스 A와 같이 중요한 서비스에서 발생하는 것이 아닌 자질구레한 에러가 수백 건 쌓이는 대신 특정 에러만 잡을 수 있게 되었습니다.
커스텀 대시보드 구축하기
Sentry 대시보드의 문제점
여기까지 정상적으로 필터링이 완성되었습니다. 하지만 문제는 그 다음이었습니다.
Sentry 대시보드와 공식문서는 기본적으로 한국어 지원이 되지않아, 영어 버전으로 살펴봐야했습니다.
개발자인 저조차도 Sentry 대시보드를 공부하고 익히는데 시간이 걸렸는데 기획팀이나 광고주 측에서 복잡한 Sentry 대시보드를 보며 에러를 보는 것은 너무 힘든 일 이었기 때문에 회사 내부에서 편하게 볼 대시보드를 직접 구축하기로 했습니다.
커스텀 대시보드 사양
대시보드 구현에는 아래의 프론트엔드 기술 스택을 사용했습니다.
"next": "16.1.1",
"react": "19.2.3",
"react-dom": "19.2.3"
"@sentry/nextjs": "^10.32.1",
"@tanstack/react-query": "^5.90.16",
"tailwindcss": "^4",
"typescript": "^5"Sentry API
Sentry에 등록되는 이슈들을 끌어오기 위해 다음과 같은 API들을 사용했습니다.
아래 API들은 모두 Next.js의 route.ts에 구현했습니다.
### 이슈 목록 조회 API
https://sentry.io/api/0/projects/${process.env.SENTRY_ORG}/${process.env.SENTRY_PROJECT}/issues/?query=is:${status}&statsPeriod=${period}
### 통계 조회 API
https://sentry.io/api/0/projects/${process.env.SENTRY_ORG}/${process.env.SENTRY_PROJECT}/stats/
### 단일 이벤트 조회 API
https://sentry.io/api/0/projects/${orgSlug}/${projectSlug}/events/${eventId}/
### 특정 이슈의 이벤트 목록을 가져오는 API
https://sentry.io/api/0/issues/${issueId}/events/위 API에 들어가있는 process.env.SENTRY_ORG, process.env.SENTRY_PROJECT, orgSlug, projectSlug는 모두 Sentry에서 발급받아야 합니다. orgSlug와 projectSlug는 url에서 확인할 수 있습니다.
예를 들어, url이 https://{orgSlug}.sentry.io/insights/projects/{projectSlug}/?project=0000000이라면, orgSlug는 조직 명, projectSlug는 Project Settings > Slug 등록을 통해 확인할 수 있습니다.
또, Setting > Developer Settings > Organization Tokens에서 토큰을 발급받아 .env 파일에 등록해야 합니다.
이렇게 Sentry에서 제공하는 API를 사용하여 커스텀 대시보드를 완성했습니다.

위 사진과 같이 JANDI 메시지를 통해 Sentry에 등록된 이벤트/이슈를 알림으로 받아오는데 성공했습니다 :)
JANDI 메신저 Webhook과 연동하기
마지막으로, 한 가지 더 문제가 있었습니다. 서비스 A와 같은 경우엔 알림이 적어야만 하는 즉, 에러가 절대 일어나면 안되는 서비스이기 때문에 Sentry developer 솔루션으로도 해결이 가능했습니다. 하지만 무료 버전의 경우 Slack의 Webhook만을 지원하여 커스텀 Webhook을 사용해야 했습니다.
전체적인 로직
1. 사용자가 서비스 A를 통해 신청합니다.
2. 에러가 발생합니다. (response 200 제외)
3. Sentry.io가 에러를 감지하여 캡처 후, Sentry 대시보드에 등록됩니다.
4. 커스텀 대시보드에도 등록되는 동시에, JANDI Webhook 서버를 통해 JANDI 메신저로 오류가 전달됩니다.1단계 : Sentry Webhook 설정
Sentry Dashboard에 접속합니다.
Settings → Integrations → Webhooks → Create New Webhook을 하면 아래와 같은 URL을 생성할 수 있습니다.https://your-domain.vercel.app/api/webhooks/sentry
위 URL은 Settings > Developer > Custom Integrations 내 Create New Integration 후 Webhook URL란에 입력해야 합니다.
또, Alert Rule Action을 On으로 설정해야 합니다.
Next.js Webhook 엔드포인트 구현
// app/api/webhooks/sentry/route.ts
import { NextResponse } from "next/server";
import { sendSentryWebhookToJandi } from "@/lib/jandi";
export async function POST(request: Request) {
try {
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// STEP 1: Sentry에서 보낸 Webhook 데이터 수신
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const rawPayload = await request.json();
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
console.log("[Sentry Webhook] Received at:", new Date().toISOString());
console.log("Action:", rawPayload.action);
console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
const action = rawPayload.action;
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// STEP 2: 액션 타입에 따라 분기 처리
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 2-1. 새 이슈 생성 이벤트
if (action === "created") {
const issue = rawPayload.data?.issue || rawPayload.data;
if (!issue) {
console.warn("[Sentry Webhook] No issue data found");
return NextResponse.json({ success: false }, { status: 200 });
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// STEP 3: Sentry 데이터를 우리 포맷으로 변환
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
const convertedPayload = {
action: "created",
data: {
// 기본 정보
id: issue.id || issue.shortId || "",
title: issue.title || "알 수 없는 에러",
culprit: issue.culprit || issue.title || "",
level: (issue.level || "error").toLowerCase(),
url: issue.permalink || issue.web_url || "",
type: issue.metadata?.type || "",
// 📍 핵심: setContext로 설정한 비즈니스 데이터
context: {
// 폼 데이터 1
formData1: issue.contexts?.form1,
// 폼 데이터 2
formData2: issue.contexts?.form2,
// 서버 응답 정보
response: issue.contexts?.response,
// 브라우저 정보
browser: issue.contexts?.browser,
os: issue.contexts?.os,
device: issue.contexts?.device,
},
// 태그 (필터링에 사용)
tags: issue.tags || [],
},
actor: rawPayload.actor,
};
console.log("[Sentry Webhook] Converted payload:",
JSON.stringify(convertedPayload, null, 2));
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// STEP 4: 잔디로 메시지 전송
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
await sendSentryWebhookToJandi(convertedPayload);
console.log("[Sentry Webhook] Successfully sent to Jandi");
}
// 2-2. 알림 트리거 이벤트
else if (action === "triggered") {
const alertData = rawPayload.data;
await sendSentryWebhookToJandi({
action: "triggered",
data: {
title: alertData.description_title || "Alert Triggered",
description: alertData.description_text || "",
url: alertData.web_url || "",
level: "warning",
}
});
}
// 2-3. 기타 이벤트는 무시
else {
console.log(`[Sentry Webhook] Ignored action: ${action}`);
}
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// STEP 5: Sentry에 200 응답 (중요!)
// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
// 200이 아니면 Sentry가 재시도를 계속함
return NextResponse.json({ success: true }, { status: 200 });
} catch (error) {
console.error("[Sentry Webhook] Error:", error);
// 에러가 발생해도 200 응답 (무한 재시도 방지)
return NextResponse.json(
{ success: false, error: "Webhook processing failed" },
{ status: 200 }
);
}
}위와 같은 엔드포인트를 구현하고, 잔디 메시지로 받고 싶은 데이터 형태로 포멧을 해야합니다.
2단계 : JANDI 연동
마지막으로, JANDI 내 알림을 받고 싶은 채팅방을 개설 후, Incoming Webhook을 발급받습니다.
이 Webhook URL은 Vercel과 같은 호스팅 플랫폼과 프로젝트 내 .env 파일에 등록해야 합니다.
# .env.local
JANDI_WEBHOOK_URL=https://wh.jandi.com/connect-api/webhook/12345678/abcdefghijklmnop

위 사진과 같이 에러 발생 시 JANDI로 메시지 알림이 오는 것을 확인할 수 있습니다.
추가 (NextJS를 사용한 이유)
처음엔 React로 구현을 시도했습니다. localhost 환경에서도 API 연동이 잘되어 로그 찍히는 것을 보고 배포를 했습니다. 하지만 배포된 사이트에서는 로그가 찍히지 않았습니다. React는 브라우저에서만 실행되기 때문입니다. 또한 토큰을 전송했어야 했는데 React에서는 이를 보호하지 못합니다. 때문에 NextJS를 채택했습니다.
결론
이렇게 서비스 - Sentry - JANDI Custom Webhook을 연동해봤습니다. 처음엔 영어 문서이기도 하고, Sentry 내에 정보가 너무 많아 정말 막막했습니다. react.js로 시도했다가 cors 에러가 발생해서 방법을 찾다가 NextJs를 사용해서 우회해야 한다는 사실도 알고, 간단한 키 값도 찾지 못해 헤매기도 하고, Sentry 내에는 아직 제가 모르는 기능도 너무 많다는 것을 깨달았습니다. 이번 기회에 NextJS route.ts도 직접 구현해보는 경험도 좋았습니다.
다음에 기회가 된다면, 잔디 및 Slack과 같이 개발자가 개발을 하지 않더라도 package 다운로드를 통해 손쉽게 Sentry와 메신저 간 Webhook 연동을 이용할 수 있게 구현하려고 합니다.