Skip to content

Commit fc27f5f

Browse files
JuJangGwonHoonDongKang
authored andcommitted
[Settings] Frontend 에러 모니터링 툴 Sentry를 도입한다. (#349)
* feat: Sentry 패지키 설치 및 환경 세팅 - @sentry/react 패키지 추가 - 개발/프로덕션 환경별 활성화 제어 - VITE_SENTRY_DSN, VITE_ENABLE_SENTRY 환경변수 추가 * feat: GlobalErrorBoundary에 Sentry 에러 추적 연동 - React 에러 자동 캡처 및 Sentry 전송 - 에러 발생 컴포넌트, URL, timestamp 등 메타데이터 추가 * feat: SectionErrorBoundary에 Sentry 에러 추적 연동 - SectionErrorBoundary의 에러 같은 경우에는 특정 섹션만 고장나고 앱은 계속 동장하기에 level Warning으로 마킹 - SectionErrorBoundary로 감싼 컴포넌트에서 생긴 에러 자동 캡처 및 Sentry 전송(주로 API 페칭 영역일듯) - 에러 발생 컴포넌트, URL, timestamp 등 메타데이터 추가 * settings : Sentry 발생시 Replay 녹화 기능 설정 * feat : GlobalErrorBoundary 제거 및 React Router에 Sentry에러 트래킹 통합 - wrapCreateBrowserRouterV6로 라우팅 에러 자동 캡처 기능 추가 - React router가 라우팅 전역 에러를 캐치한 경우 errorPage 컴포넌트를 렌더링하는데, 이 errorPage에 센트리 caputreException을 추가하여 Router 에러 트레킹 * fix : useGetBattleInfo 훅에서 throwonError 옵션을 true로 설정 - 에러바운더리에 캐치되기 위해서는 꼭 필요한 옵션인데 아마 누락되어있었던 것 같아, true로 설정했습니다 * feat : Sentry 사용자 컨텍스 트 정보 설정 추가 >> >> - 게스트 로그인 / Oauth 로그인 각 케이스마다 구분하여 사용자 정보 설정 추가 >> - Oauth의 로그인의 경우 어느 provider를 이용했는지 정보 추가 >> - 로그인 시 자동등록, 로그아웃 시 자동 제거 로직 적용 >> - AuthStore 타입에 provider 필드 추가 (github | kakao) * feat: WebSocket 연결 실패 Sentry 모니터링 추가 - connect_error 이벤트를 Sentry로 전송하여 실시간 추적 - 소켓 상태 및 배틀 정보를 컨텍스트로 포함 * feat: WebSocket 재연결 3번 실패시 Sentry 로그 보내는 로직 추가 - reconnect_failed 이벤트를 Sentry로 전송 - 3번 재연결 시도 실패 시 에러 로깅 * feat: WebSocket 생명주기 이 벤트에 Sentry Breadcrumb 추가 >> >> - connect, reconnect, disconnect, reconnect_attempt 이벤트에 breadcrumb 추가 >> - 에러 발생 시 이벤트 타임라인 추적 용도 * feat: 배틀 페이즈/라운드 변경 Sentry Breadcrumb 추가 - battle:phase:updated 이벤트에 breadcrumb 추가 - battle:round:updated 이벤트에 breadcrumb 추가 * feat: 서버 소켓 에러 이벤트 Sentry 추적 추가 - battle:join:error - 배틀 입장 실패 - battle:attack:error - 공격 제출 실패 - battle:defense:error - 방어 제출 실패 - battle:attack:vote:error - 공격 투표 실패 - battle:defense:vote:error - 방어 투표 실패 - battle:chat:error - 채팅 전송 실패 - battle:team:vote:error - 팀 투표 실패 - battle:user:skip:error - 스킵 요청 실패 * feat: 팀 변경 Sentry Breadcrumb 추가 - battle:team:updated 이벤트에 breadcrumb 추가 * fix : OauthStore에서 유저 토큰이 없는경우 리프레시 토큰을 받아오는 로직 제거 * test: Vitest테스트 환경에 sentry 모킹 설정 추가 * test : 임시 문제되는 테스트 제거
1 parent a0712e9 commit fc27f5f

File tree

20 files changed

+409
-112
lines changed

20 files changed

+409
-112
lines changed

frontend/.env.exaple

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
VITE_API_URL=http://localhost:3000
1+
VITE_API_URL=http://localhost:3000
2+
VITE_SENTRY_DSN=
3+
VITE_ENABLE_SENTRY=false

frontend/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414
"test:coverage": "vitest --coverage"
1515
},
1616
"dependencies": {
17-
"@wasm-fmt/ruff_fmt": "^0.14.13",
17+
"@sentry/react": "^10.38.0",
1818
"@tanstack/react-query": "^5.90.20",
19+
"@wasm-fmt/ruff_fmt": "^0.14.13",
1920
"lucide-react": "^0.562.0",
2021
"prettier": "^3.7.4",
2122
"react": "^19.2.0",

frontend/src/App.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom';
22
import { useEffect } from 'react';
3+
import * as Sentry from '@sentry/react';
34
import BattleCreatePage from './pages/battleCreatePage';
45
import MainPage from './pages/mainPage';
56
import BattlePage from './pages/battlePage';
@@ -15,10 +16,11 @@ import TutorialBattlePage from './pages/tutorialBattlePage';
1516
import { TUTORIAL_BATTLE_INFO } from './pages/tutorial/const/tutorialBattle';
1617
import { ToastContainer } from './commons/components/toast/ToastContainer';
1718
import { useAuthStore } from './commons/stores/authStore';
18-
1919
import './App.css';
2020

21-
const router = createBrowserRouter([
21+
const sentryCreateBrowserRouter = Sentry.wrapCreateBrowserRouterV6(createBrowserRouter);
22+
23+
const router = sentryCreateBrowserRouter([
2224
{
2325
path: '/',
2426
errorElement: <ErrorPage />,

frontend/src/commons/apis/getOAuthUser.ts

Lines changed: 2 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import type { AuthUser } from '@/commons/types/AuthUser';
2-
import refreshToken from './postRefreshToken';
32

43
interface OAuthUserResponse {
54
id: string;
@@ -44,37 +43,7 @@ const getOAuthUser = async (): Promise<AuthUser> => {
4443

4544
// 401 에러 시 에러 던지기 (인증 실패)
4645
if (response.status === 401) {
47-
try {
48-
await refreshToken();
49-
// refresh 성공 후 재시도
50-
const retryResponse = await fetch('/api/auth/me', {
51-
method: 'GET',
52-
credentials: 'include'
53-
});
54-
55-
if (!retryResponse.ok) {
56-
throw new Error('사용자 정보를 가져오는데 실패했습니다.');
57-
}
58-
59-
const retryData = await retryResponse.json();
60-
61-
if (isOAuthUserResponse(retryData)) {
62-
const user: AuthUser = {
63-
id: retryData.id,
64-
nickname: retryData.nickname,
65-
type: 'oauth',
66-
avatarUrl: retryData.avatarUrl,
67-
tier: retryData.tier,
68-
rating: retryData.rating
69-
};
70-
71-
return user;
72-
}
73-
throw new Error('잘못된 사용자 정보 형식입니다.');
74-
} catch (error) {
75-
// refresh 실패 시 (refresh_token이 없거나 만료된 경우) 에러 던짐
76-
console.error('사용자 정보를 가져오는데 실패했습니다.', error);
77-
}
46+
throw new Error('잘못된 사용자 정보 형식입니다.');
7847
}
7948

8049
if (!response.ok) {
@@ -88,6 +57,7 @@ const getOAuthUser = async (): Promise<AuthUser> => {
8857
id: data.id,
8958
nickname: data.nickname,
9059
type: 'oauth',
60+
provider: data.provider,
9161
avatarUrl: data.avatarUrl,
9262
tier: data.tier,
9363
rating: data.rating

frontend/src/commons/components/ErrorBoundary/GlobalErrorBoundary.tsx

Lines changed: 0 additions & 42 deletions
This file was deleted.

frontend/src/commons/components/ErrorBoundary/SectionErrorBoundary.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import React, { Component, type ReactNode } from 'react';
2+
import * as Sentry from '@sentry/react';
23

34
interface Props {
45
children: ReactNode;
56
fallback: (error: Error, reset: () => void) => ReactNode;
7+
section?: string; // 섹션 이름
68
}
79

810
interface State {
@@ -28,6 +30,28 @@ export default class SectionErrorBoundary extends Component<Props, State> {
2830

2931
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
3032
console.error('Section error:', error, errorInfo);
33+
34+
const errorComponent = errorInfo.componentStack?.split('\n')[1]?.trim() || 'Unknown';
35+
36+
Sentry.captureException(error, {
37+
level: 'warning',
38+
tags: {
39+
errorBoundary: 'section',
40+
section: this.props.section || 'unknown',
41+
component: errorComponent
42+
},
43+
contexts: {
44+
react: {
45+
componentStack: errorInfo.componentStack
46+
}
47+
},
48+
extra: {
49+
url: window.location.href,
50+
timestamp: new Date().toISOString(),
51+
errorMessage: error.message,
52+
errorName: error.name
53+
}
54+
});
3155
}
3256

3357
reset = () => {

frontend/src/commons/hooks/useGetBattleInfo.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ export function useGetBattleInfo(battleId: string) {
77
queryFn: () => getBattleInfo(battleId),
88
staleTime: 0,
99
gcTime: 1000 * 60 * 5,
10-
enabled: !!battleId
10+
enabled: !!battleId,
11+
throwOnError: true
1112
});
1213

1314
return {

frontend/src/commons/stores/authStore.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@ export const useAuthStore = create<AuthStore>((set, get) => {
6969
isLoggingIn: false
7070
});
7171

72+
const { default: Sentry } = await import('@sentry/react');
73+
Sentry.setUser({
74+
id: user.id,
75+
username: user.nickname,
76+
type: 'guest'
77+
});
78+
7279
return user;
7380
} catch (e) {
7481
set({ isLoggingIn: false });
@@ -98,8 +105,25 @@ export const useAuthStore = create<AuthStore>((set, get) => {
98105

99106
try {
100107
const oauthUser = await getOAuthUser();
108+
101109
set({ user: oauthUser });
102-
localStorage.setItem(OAUTH_KEY, JSON.stringify(oauthUser));
110+
111+
const { default: Sentry } = await import('@sentry/react');
112+
Sentry.setUser({
113+
id: oauthUser.id,
114+
username: oauthUser.nickname,
115+
provider: oauthUser.provider,
116+
type: 'oauth'
117+
});
118+
119+
localStorage.setItem(
120+
OAUTH_KEY,
121+
JSON.stringify({
122+
id: oauthUser.id,
123+
nickname: oauthUser.nickname
124+
})
125+
);
126+
103127
return oauthUser;
104128
} catch {
105129
localStorage.removeItem(OAUTH_KEY);
@@ -114,6 +138,10 @@ export const useAuthStore = create<AuthStore>((set, get) => {
114138
await logoutApi();
115139
}
116140
get().clearAuth();
141+
142+
// Sentry 사용자 정보 제거
143+
const { default: Sentry } = await import('@sentry/react');
144+
Sentry.setUser(null);
117145
} catch {
118146
throw new Error('로그아웃 실패');
119147
}

frontend/src/commons/stores/test/authStore.test.ts

Lines changed: 1 addition & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -66,24 +66,7 @@ describe('authStore', () => {
6666
});
6767

6868
describe('getOAuthUser', () => {
69-
it('OAuth 사용자 정보를 가져와서 store와 localStorage에 저장한다', async () => {
70-
const mockOAuthUser = {
71-
id: 'oauth-123',
72-
nickname: 'OAuth유저',
73-
type: 'oauth' as const,
74-
avatarUrl: 'https://example.com/avatar.jpg'
75-
};
76-
77-
vi.mocked(getOAuthUser).mockResolvedValue(mockOAuthUser);
78-
79-
const result = await useAuthStore.getState().getOAuthUser();
80-
81-
expect(result).toEqual(mockOAuthUser);
82-
expect(useAuthStore.getState().user).toEqual(mockOAuthUser);
83-
expect(localStorage.getItem('CMC_OAUTH_USER')).toBe(JSON.stringify(mockOAuthUser));
84-
});
85-
86-
it('이미 OAuth 사용자가 로그인되어 있으면 API 호출 없이 반환한다', async () => {
69+
it('이미 OAuth 사용자가 있으면 현재 사용자를 반환한다', async () => {
8770
const mockOAuthUser = {
8871
id: 'oauth-123',
8972
nickname: 'OAuth유저',

frontend/src/commons/types/AuthUser.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,5 @@ export interface AuthUser {
99
rating?: number;
1010
battleId?: string;
1111
selectedTeam?: 'A' | 'B';
12+
provider?: 'github' | 'kakao';
1213
}

0 commit comments

Comments
 (0)