Next.js 16 + React 19 프런트엔드 모니터링 실전기
안녕하세요. FlipB 개발팀 Luna 입니다.
“설치는 5분, 운영은 평생”이라는 말이 있죠. Sentry도 그렇습니다.
npx @sentry/wizard만 돌리면 설치는 끝나지만, 그 상태로 놔두면 한 달 뒤엔 “노이즈로 가득 찬 대시보드”와 “쓸 수 없는 스택 트레이스”만 남습니다.이 글은 brandvis 프런트엔드(@sentry/nextjs v10, Next.js 16.1, React 19.2)에 Sentry를 붙이면서 팀이 실제로 내렸던 결정들을 정리한 기록입니다. 공식 문서엔 안 나오지만 현실에선 꼭 필요했던 설정들, 그리고 “그냥 설치”를 넘어 “FE 모니터링”으로 가기 위해 바꿨던 것들을 담았습니다.
📌 TL;DR
| 영역 | 우리 선택 | 이유 |
|---|---|---|
| 로컬 개발 | NEXT_PUBLIC_ENVIRONMENT=local이면 SDK init 생략 | HMR·의도된 throw가 쿼터 소모 |
| 4xx 필터 | beforeSend에서 401/403 drop + API 레이어에선 5xx·네트워크 에러만 캡처 | 비즈니스 흐름은 버그가 아님 |
| Session Replay | replaysSessionSampleRate: 0, onErrorSampleRate: 1.0, maskAllText·blockAllMedia | 비용·개인정보 동시에 관리 |
| PII | sendDefaultPii: true로 전환 | IP·User-Agent 없이는 재현 불가 |
| 광고 차단기 대응 | tunnelRoute: '/monitoring' | Sentry 도메인 차단 사용자 커버 |
| Source Maps | Organization Token + Jenkins Credentials | Personal Token은 퇴사 시 빌드 중단 |
| 사용자 컨텍스트 | useAuth 훅에서 Sentry.setUser({ email, username }) | 에러가 “누구에게서” 났는지 식별 |
| Next.js 16 전환 추적 | onRouterTransitionStart export | App Router SPA 전환 성능 측정 |
왜 Sentry였나
운영 환경의 console.log는 쓸모가 없습니다. 고객이 “뭔가 이상해요”라고 알려왔을 때 우리에게 필요한 건 브라우저 버전, 직전 클릭 경로, 어느 API가 몇 번 실패했는지, 어느 배포 버전에서 재현되는지 — 즉 컨텍스트입니다.
Sentry는 그 컨텍스트를 스택 트레이스 · 브레드크럼 · Session Replay, 세 가지 무기로 묶어서 제공합니다. 다만 Next.js 16 / React 19 환경에서 이걸 제대로 활용하려면 몇 가지 최신 컨벤션과 현실 이슈를 함께 이해해야 합니다.
1. Next.js 16의 초기화 구조부터 이해하기
Sentry Next.js SDK는 최근 파일 구조가 바뀌었습니다. 과거의 sentry.client.config.ts는 사라지고, 이제 클라이언트 초기화는 instrumentation-client.ts 에서 일어납니다. 프로젝트 루트에 네 파일이 있어야 합니다.
brandvis-frontend/
├── instrumentation.ts # 서버/엣지 런타임 진입점
├── instrumentation-client.ts # 클라이언트 진입점 (Next.js 16 신규)
├── sentry.server.config.ts # Node 런타임용 init
└── sentry.edge.config.ts # Edge 런타임용 init (proxy.ts 등)
서버 진입점은 런타임에 따라 설정을 동적으로 불러오고, 서버 컴포넌트·API 라우트 에러는 onRequestError로 자동 캡처됩니다.
// instrumentation.ts
import * as Sentry from '@sentry/nextjs';
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
await import('./sentry.server.config');
}
if (process.env.NEXT_RUNTIME === 'edge') {
await import('./sentry.edge.config');
}
}
export const onRequestError = Sentry.captureRequestError;
클라이언트 쪽에서는 App Router의 라우트 전환을 추적하기 위해 onRouterTransitionStart를 반드시 export 해야 합니다. 이걸 빼먹으면 페이지 이동 전후의 성능 트레이스가 끊어집니다.
// instrumentation-client.ts
export const onRouterTransitionStart = Sentry.captureRouterTransitionStart;
2. 로컬 개발 서버에서는 Sentry를 꺼라
초기 설정에서 제일 먼저 후회하는 부분입니다. pnpm dev로 코드를 수정하다 보면 HMR·타입 에러·의도된 throw 같은 “개발 중 노이즈”가 대시보드를 가득 채웁니다. 무료 플랜 5,000 이벤트 쿼터가 금방 소진되고, 진짜 운영 이슈가 묻힙니다.
우리는 환경 변수 한 줄로 초기화 자체를 건너뜁니다.
// instrumentation-client.ts
const environment = process.env.NEXT_PUBLIC_ENVIRONMENT;
const isLocal = environment === 'local' || process.env.NODE_ENV === 'development';
const hasDsn = Boolean(process.env.NEXT_PUBLIC_SENTRY_DSN);
if (!isLocal && hasDsn) {
Sentry.init({ /* ... */ });
}
next dev는 NODE_ENV=development를 강제하지만, next build && next start는 그렇지 않기 때문에 dev/staging/prod 배포에는 영향이 없습니다. 로컬에서 Sentry 테스트가 필요하면 NEXT_PUBLIC_ENVIRONMENT=development로 바꾸면 됩니다.
3. 401/403은 에러가 아니다 — beforeSend로 걸러라
로그인 만료, 권한 없음 같은 4xx는 “비즈니스 흐름”이지 “버그”가 아닙니다. 방치하면 일주일 만에 401 Unauthorized 이슈가 수백 건 쌓이고, 진짜 5xx 버그는 그 아래에 묻힙니다.
// instrumentation-client.ts 에서 실제 사용 중인 설정
beforeSend(event, hint) {
const error = hint.originalException as { response?: { status?: number } } | undefined;
const status = error?.response?.status;
if (status === 401 || status === 403) {
return null; // Sentry로 보내지 않음
}
return event;
},
API 클라이언트 쪽에도 같은 원칙을 적용합니다. axios 인터셉터에서 5xx와 네트워크 에러만 캡처하고, 4xx 응답은 의도된 비즈니스 응답으로 취급합니다.
// src/lib/api/base.ts
private captureToSentry(error: AxiosError<ApiError>): void {
const status = error.response?.status;
const isServerError = typeof status === 'number' && status >= 500;
const isNetworkError = !error.response;
if (!(isServerError || isNetworkError)) return;
Sentry.captureException(error, {
tags: {
source: 'api-client',
status: status ? String(status) : 'network_error',
method: error.config?.method?.toUpperCase() ?? 'UNKNOWN',
},
extra: {
url: error.config?.url,
message: error.response?.data?.message ?? error.message,
},
});
}
💡 운영 철학: Sentry에 올라온 이슈는 “개발자가 고쳐야 할 것”이어야 합니다. 고칠 게 없는 이벤트는 전부 노이즈입니다.
4. Session Replay — 필요한 순간만, 마스킹은 기본
Session Replay는 에러 직전의 사용자 화면을 영상처럼 재생해주는 강력한 기능이지만, 두 가지 함정이 있습니다.
- 비용: 모든 세션을 녹화하면 쿼터가 폭발합니다.
- 개인정보: 아무 필터 없이 녹화하면 사용자 이메일·결제 정보·민감한 화면이 그대로 Sentry 서버로 올라갑니다.
우리 프로젝트는 아래와 같이 제한합니다.
// instrumentation-client.ts
Sentry.init({
replaysSessionSampleRate: 0, // 일반 세션은 녹화 안 함
replaysOnErrorSampleRate: 1.0, // 에러 발생 시에만 녹화 (직전 구간 포함)
integrations: [
Sentry.replayIntegration({
maskAllText: true, // 모든 텍스트 마스킹
blockAllMedia: true, // 이미지·비디오 차단
}),
],
});
maskAllText는 기본값이 아닙니다. 개인정보 보호를 위해 명시적으로 켜야 하며, 특정 요소만 원본으로 보여주고 싶다면 data-sentry-unmask 속성으로 선택적으로 해제할 수 있습니다.
5. sendDefaultPii: IP를 포기하면 디버깅도 포기해야 한다
처음엔 “개인정보는 아예 안 보내는 게 안전하다”는 논리로 sendDefaultPii: false로 시작했습니다. 몇 주 운영해보니 결과는 이랬습니다.
- IP 주소가 전부
unknown— 특정 지역/통신사/CDN 엣지에서만 발생하는 이슈를 구분할 수 없음 - User-Agent 누락 — Safari에서만 깨지는 렌더링 버그인지, 구형 Chrome 문제인지 판단 불가
- 지리적 분포 그래프가 비어 있음 — 한국 사용자 대상 서비스인데도 “어디에서 에러가 더 나는지” 알 수 없음
운영 버그를 재현하려면 “누가·어디서·어떤 환경에서” 셋 중 최소 둘은 확보해야 하는데, PII를 전부 끄니 사실상 에러 알림 서비스로만 쓰이고 있었습니다. 그래서 true로 돌렸고, 이게 지금 프로젝트의 기본값입니다.
// sentry.server.config.ts / instrumentation-client.ts
Sentry.init({
dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
environment,
tracesSampleRate: isProduction ? 0.1 : 1.0,
sendDefaultPii: true, // ⚠️ 기본 false → true로 변경
});
true로 켜면 추가되는 것
| 항목 | OFF (false) | ON (true) |
|---|---|---|
| 클라이언트 IP | 수집 안 함 | 수집 (지리 정보 파생) |
| 요청 헤더 (User-Agent, Referer 등) | 제외 | 포함 |
| 쿠키 | 제외 | 포함 (HttpOnly 제외) |
| 서버 요청 바디 | 제외 | 포함 (크기 제한) |
그래도 민감 데이터는 여전히 막혀 있다
흔한 오해지만, sendDefaultPii: true가 모든 개인정보를 무제한 수집한다는 뜻은 아닙니다. 우리 프로젝트는 세 겹으로 막아두고 있습니다.
- Session Replay 마스킹 — 녹화 영상에는 실제 텍스트·이미지가 들어가지 않음
- HttpOnly 쿠키 — 인증 토큰은 SDK가 접근 불가 → 자동 제외
beforeSend필터 — 비즈니스 에러(401/403) 자체를 보내지 않음
즉, sendDefaultPii: true로 얻는 건 디버깅에 필요한 환경 정보고, 잃지 않는 건 사용자의 실제 내용물입니다. 이 둘을 분리해서 생각해야 합니다.
국내 개인정보보호법 체크포인트
- IP는 국내법상 개인정보로 해석될 수 있음 → 개인정보처리방침에 “오류 분석 목적으로 Sentry에 IP·브라우저 정보가 전송된다”는 문구를 명시.
- Sentry는 Data Processing Addendum을 제공하므로 법무 검토 시 함께 제출.
- 필요하면
beforeSend에서 IP 마지막 옥텟을 마스킹해 지역 정보만 남기는 것도 가능합니다.
beforeSend(event) {
if (event.user?.ip_address) {
event.user.ip_address = event.user.ip_address.replace(/\.\d+$/, '.0');
}
return event;
}
💡 교훈: 프라이버시 기본값은 보수적으로 가되, “디버깅이 불가능한 수준”까지 끄면 오히려 사용자에게 해로운 서비스가 됩니다. 제품 신뢰성도 개인정보 보호의 일부라는 관점에서 균형을 잡아야 합니다.
6. 광고 차단기가 Sentry를 막는다면 tunnelRoute
uBlock Origin, AdBlock, 기업 방화벽은 *.ingest.sentry.io 도메인을 트래킹 서비스로 간주해 차단합니다. 그러면 그 사용자들의 에러는 영영 우리에게 도달하지 못합니다.
Next.js SDK는 앱 도메인을 경유해 Sentry로 포워딩하는 터널링을 지원합니다. 우리 next.config.mjs엔 이미 적용되어 있습니다.
// next.config.mjs
withSentryConfig(withNextIntl(nextConfig), {
org: process.env.SENTRY_ORG,
project: process.env.SENTRY_PROJECT,
authToken: process.env.SENTRY_AUTH_TOKEN,
silent: !process.env.CI,
widenClientFileUpload: true,
tunnelRoute: '/monitoring', // 우리 도메인의 /monitoring → Sentry로 포워딩
sourcemaps: { disable: false },
webpack: {
treeshake: { removeDebugLogging: true },
automaticVercelMonitors: false,
},
});
브라우저 개발자 도구 Network 탭에서 sentry.io 대신 /monitoring 요청만 보이면 성공입니다.
7. Source Maps — Personal Token 말고 Organization Token
Source Maps가 없으면 Sentry에 올라온 스택 트레이스는 chunk-a1b2c3.js:1:52431 같은 난독화 결과만 보여줍니다. 디버깅이 불가능합니다. 빌드 시점에 Source Maps를 업로드해야 하고, 이 과정에 Auth Token이 필요합니다.
여기서 실수하기 쉬운 부분: Sentry는 Personal Token과 Organization Token 두 가지를 제공하는데, CI에 Personal Token을 쓰면 해당 개발자가 퇴사/계정 정리되는 순간 빌드가 멈춥니다.
| Organization Token ✅ | Personal Token ❌ | |
|---|---|---|
| 소유권 | 조직 | 개인 |
| 개발자 이탈 시 | 유효 | 무효화 |
| 권한 | org:ci 고정 (최소 권한) | 수동 선택 |
| 용도 | CI/CD | 로컬 스크립트 |
Sentry 대시보드 → Settings → Auth Tokens 에서 Organization Token을 발급받아 Jenkins Credentials에 sentry-auth-token으로 등록합니다. 빌드 단계에서 SENTRY_AUTH_TOKEN, SENTRY_ORG, SENTRY_PROJECT 세 개만 주입하면 Source Maps는 자동으로 업로드됩니다.
8. 로그인한 사용자는 누구인가 — Sentry.setUser
에러가 “어느 사용자”에게서 발생했는지 알면 재현 난이도가 크게 줄어듭니다. 인증 훅에서 로그인/로그아웃 상태 변화에 맞춰 사용자 컨텍스트를 붙여둡니다.
// src/hooks/useAuth.ts
useEffect(() => {
if (isAuthenticated && user) {
Sentry.setUser({ email: user.email, username: user.name });
} else if (!isLoading) {
Sentry.setUser(null); // 로그아웃 시 컨텍스트 제거
}
}, [isAuthenticated, isLoading, user]);
그리고 React 루트 에러 바운더리(app/error.tsx, app/global-error.tsx)에서 마지막 안전망을 둡니다.
useEffect(() => {
Sentry.captureException(error);
}, [error]);
프런트엔드 모니터링은 백엔드와 다르게 봐야 한다
Sentry를 처음 접하는 팀이 가장 흔히 하는 실수는 프런트엔드를 백엔드처럼 모니터링하는 것입니다. 백엔드는 “요청 → 응답 → 성공/실패”가 명확하지만, 프런트엔드는 다릅니다.
사용자가 버튼을 눌렀는데 반응이 느린 것, 이미지 로딩이 밀려서 레이아웃이 튀는 것, Safari에서만 스크롤이 끊기는 것 — 이런 건 에러가 아니지만 명백한 품질 저하입니다. 프런트엔드 모니터링은 “안 터지는 것”이 아니라 “안 답답한 것”을 목표로 해야 합니다.
9. Core Web Vitals는 공짜로 따라온다
tracesSampleRate가 설정되어 있으면 Sentry는 별도 코드 없이 LCP, CLS, INP, FCP, TTFB를 자동 수집합니다. 대시보드의 Performance → Web Vitals 탭에서 페이지별로 바로 볼 수 있습니다.
우리 프로젝트는 프로덕션에서 0.1(10%), dev에서 1.0(100%)로 잡아둡니다.
// sentry.server.config.ts / instrumentation-client.ts
Sentry.init({
tracesSampleRate: isProduction ? 0.1 : 1.0,
});
실제로 가장 유용했던 시나리오는 다음과 같습니다.
- “대시보드 페이지 LCP가 4초를 넘는 사용자가 8% 있다” → 어떤 차트/리소스가 느린지 트레이스에서 확인
- “모바일 Safari에서만 CLS가 0.3 이상” → 폰트 로딩 시 레이아웃 튐 의심
- “배포 직후 INP가 갑자기 증가” → 최근 추가된 핸들러 코드 의심
p75(상위 25%) 기준으로 모니터링하세요. 평균값은 꼬리 분포를 숨깁니다. 업계 기준은 LCP < 2.5s, INP < 200ms, CLS < 0.1 입니다.
10. 라우트 전환 성능: Next.js App Router의 숨은 병목
SPA 라우트 전환은 브라우저의 내비게이션 타이밍 API가 잡지 못합니다. 그래서 Next.js SDK는 onRouterTransitionStart 라는 전용 훅을 제공합니다. 앞서 한 줄 export했던 바로 그 훅입니다.
이 한 줄로 /dashboard → /projects/:id 같은 전환 소요 시간, 데이터 페칭 waterfall, Suspense boundary 전환을 모두 트레이스로 볼 수 있습니다. React Server Components 환경에서는 이게 없으면 “어디서 느린지” 파악이 사실상 불가능합니다.
11. React 에러 바운더리 — 현재 2단, 필요하면 3단
프런트엔드 에러는 “한 컴포넌트만 죽을 때”와 “전체 앱이 죽을 때”의 처리가 완전히 달라야 합니다. 현재 우리 프로젝트는 Next.js App Router가 제공하는 2단 구조를 사용 중입니다.
1. 세그먼트 단위 — app/error.tsx (특정 페이지만 죽음)
↓ (루트 레이아웃 자체가 죽으면)
2. 전역 — app/global-error.tsx (앱 전체 크래시)
두 파일 모두 useEffect 안에서 Sentry.captureException(error)를 호출해 마지막 안전망 역할을 합니다. 그리고 fallback UI는 계층별로 완전히 다른 스타일링 전략이 필요합니다.
// app/global-error.tsx — 루트 레이아웃까지 죽었을 때
export default function GlobalError({ error, reset }) {
useEffect(() => {
Sentry.captureException(error);
}, [error]);
// ⚠️ 여기는 MUI 테마가 초기화되지 않은 상태.
// theme.palette 같은 걸 쓰면 에러 화면에서 또 에러가 난다.
return (
<html lang="ko">
<body style={{ /* 인라인 스타일만 */ }}>
{/* 최소한의 에러 화면 */}
</body>
</html>
);
}
우리 global-error.tsx는 모든 스타일을 인라인으로 처리하고, app/error.tsx에서만 MUI 컴포넌트를 사용합니다. 에러 화면에서 또 에러가 나는 재앙을 방지하는 기본기입니다.
📌 확장 포인트: 차트·에디터처럼 특정 위젯이 죽어도 페이지 전체를 깨뜨리고 싶지 않다면, 컴포넌트 단위로
<Sentry.ErrorBoundary fallback={...}>를 추가해 3단 구조로 확장할 수 있습니다. 현재 프로젝트엔 아직 적용하지 않았지만, ECharts·Quill 같은 외부 라이브러리 래퍼부터 도입할 계획입니다.
12. Unhandled Promise Rejection은 조용히 사라진다
React 19에서 async 컴포넌트·Suspense가 많아지면서, 에러 바운더리가 못 잡는 미처리 Promise rejection이 늘었습니다.
// 이런 패턴은 에러 바운더리에 안 잡힘
const handleClick = async () => {
await api.delete(id); // 실패하면 조용히 사라짐
};
Sentry는 window.onunhandledrejection을 기본으로 후킹하므로 SDK만 초기화돼 있으면 자동 캡처됩니다. 다만 axios 인터셉터에서 이미 5xx만 필터링하고 있다면, .catch() 없이 실패하는 Promise가 예상대로 추적되는지 한 번은 직접 확인해보세요.
13. 브라우저 · 디바이스 단위로 쪼개 보기
Sentry Issue 상세 화면 하단의 Tags 블록은 금광입니다. 에러 하나를 열었을 때 아래와 같은 분포가 보인다면:
browser: Safari 16.4 (95%) ← 여기만 문제
Chrome 120 (5%)
os: iOS 16 (88%)
device: iPhone (92%)
이건 Safari/iOS 한정 버그라는 뜻이고, 재현 환경을 즉시 특정할 수 있습니다. sendDefaultPii: true 없이는 이 태그들이 전부 unknown으로 찍힙니다. 바로 이게 앞서 PII를 켠 실용적인 이유입니다.
📌 확장 포인트: 프로젝트 도메인 특화 태그(
subscription_plan,has_project등)를Sentry.setTag로 붙이면 “Pro 플랜 사용자에게만 발생하는 결제 플로우 에러” 같은 필터링이 가능해집니다. 현재는 미적용이며, 필터링 수요가 쌓이면useAuth훅에서setUser와 나란히 추가할 예정입니다.
14. 릴리스 헬스 (도입 권장)
배포 직후 가장 궁금한 건 “지금 사용자가 앱을 쓰다가 터지고 있나?”입니다. Sentry의 Release Health는 세션 단위로 crash-free session rate를 계산해줍니다.
현재 우리 빌드는 generateBuildId로 GIT_COMMIT_SHA를 쓰고 있지만, Sentry Release 태깅은 아직 별도로 주입하지 않았습니다. 도입 시엔 이렇게 추가하면 됩니다.
# Docker 빌드 시 Git commit SHA를 Release로 태깅
SENTRY_RELEASE=$(git rev-parse HEAD) pnpm run build
배포 후 대시보드 → Releases 에서 확인할 수 있는 지표:
- crash-free users: 오늘 접속한 사용자 중 에러 없이 세션을 마친 비율 (목표: 99.5% 이상)
- adoption: 새 릴리스가 얼마나 퍼졌는지 (점진적 롤아웃 확인)
- 이전 릴리스 대비 에러 변화량
“99% → 96%로 떨어지면 무조건 롤백”이라는 기준선을 팀에서 미리 정해두는 게 중요합니다. 직감으로 판단하면 매번 “내가 본 건 괜찮던데?”로 끝납니다.
도입 후 바뀐 것들
- 배포 직후 30분 모니터링에 의미가 생겼다 — 새 이슈 그래프가 치솟으면 롤백 판단의 근거가 됩니다.
- “재현이 안 돼요” 대응이 빨라졌다 — Session Replay로 사용자의 실제 클릭 경로를 보고 시작합니다.
- 4xx 노이즈를 걸러낸 대시보드엔 진짜 버그만 남는다 — 이슈 1건 = 수정할 코드 1개, 라는 규칙이 지켜집니다.
sendDefaultPii: true로 켠 이후 브라우저별 이슈 분포가 보였다 — 이전엔 전부unknown으로 묶여 있어서 파악 불가능했던 패턴들입니다.- Core Web Vitals가 개발 우선순위에 들어왔다 — “왠지 느린 것 같아요”가 아니라 “대시보드 페이지 p75 LCP가 3.8초입니다”로 대화가 바뀌었습니다.
마무리 — 설치는 쉽고, 운영이 본론입니다
Sentry는 npx @sentry/wizard@latest 한 번이면 설치됩니다. 하지만 “설치한 Sentry”와 “일하는 Sentry”는 다릅니다. 팀이 투자해야 하는 지점은 설치가 아니라 네 가지 합의입니다.
- 어떤 에러를 보낼 것인가 —
beforeSend, API 클라이언트 필터 - 어떤 에러를 무시할 것인가 — 4xx, 로그인 플로우, 의도된 실패
- 어디까지 녹화·수집할 것인가 — 샘플 레이트, Replay 마스킹, PII의 트레이드오프
- 어떻게 도달시킬 것인가 — tunnelRoute, Source Maps, Release 태깅
이 네 가지가 팀 컨벤션으로 합의되면, Sentry 대시보드는 “노이즈 창고”가 아니라 “오늘 고쳐야 할 일 목록”이 됩니다. 그리고 프런트엔드 모니터링은 “앱이 터지나 안 터지나”를 넘어서 “사용자가 답답하지 않은가”를 추적하는 수준으로 올라갑니다. 그게 진짜 도입의 이유입니다.
참고 자료
- Sentry Next.js 공식 가이드
- Next.js 16 instrumentation 파일 컨벤션
- Sentry beforeSend & Filter
- Organization Tokens vs Personal Tokens
- Web Vitals 공식 문서
이 글은 brandvis 프런트엔드 팀의 실제 Sentry 도입·운영 경험을 정리한 것입니다. Next.js 16.1 / React 19.2 / @sentry/nextjs v10 기준으로 작성되었으며, 코드 스니펫은 모두 운영 중인 설정에서 발췌했습니다.