Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions services/one-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@
},
"dependencies": {
"@faker-js/faker": "^9.0.3",
"@sentry/react": "^8.38.0",
"@tanstack/react-query": "^5.59.16",
"axios": "^1.7.7",
"clsx": "^2.1.1",
"js-cookie": "^3.0.5",
"next": "14.2.16",
"react": "^18",
"react-dom": "^18",
"react-error-boundary": "^4.0.13",
"tailwind-merge": "^2.5.4",
"zod": "^3.23.8",
"zustand": "^5.0.0"
Expand Down
8 changes: 5 additions & 3 deletions services/one-app/src/__test__/1__StartPage.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { render, screen } from '@testing-library/react';
import Page from '@/app/(site)/page';
import Page from '@/app/page';

it.skip('App Router: Works with Server Components', () => {
render(<Page />);
expect(screen.getByRole('heading', { name: /App Router/i })).toHaveTextContent('App Router');
});
expect(
screen.getByRole('heading', { name: /App Router/i }),
).toHaveTextContent('App Router');
});
13 changes: 11 additions & 2 deletions services/one-app/src/app/(site)/_component/LoggedIn.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ export default function LoggedIn() {
}

return (
<p>Logged in: {isLoggedIn ? 'O' : 'X'}</p>
<div className="flex flex-col gap-3">
<p>Logged in: {isLoggedIn ? 'O' : 'X'}</p>
<button
onClick={() => {
window.location.reload();
}}
>
새로고침!
</button>
</div>
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client';

import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { apiClient } from '@/app/api';
import SpinnerIcon from '@/common/assets/icons/loading-spinner';

const UseQueryComponent = () => {
const errorQuery = useQuery({
queryKey: ['error', 'useQuery'],
queryFn: () => apiClient.get('/api/example-error'),
});

console.log({ error: errorQuery?.error });

if (errorQuery.isLoading)
return (
<div className="absolute left-1/2 -translate-x-1/2 top-1/2 -translate-y-1/2">
<SpinnerIcon />
</div>
);

return <div>UseQueryComponent</div>;
};

export default UseQueryComponent;
2 changes: 1 addition & 1 deletion services/one-app/src/app/(site)/login/callback/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { useShallow } from 'zustand/shallow';

import { requestLogin } from '../_lib/requestLogin';
import { useTemporaryAuthStore } from '@/store/auth';
import { useTemporaryAuthStore } from '@/store/authStore';
import { isValidSocialSignInType } from '@/model/Auth';
import { AuthService } from '@/common/service/AuthService';

Expand Down
2 changes: 1 addition & 1 deletion services/one-app/src/app/(site)/login/nickname/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { useShallow } from 'zustand/shallow';
import { updateUser } from '../../my/_lib/updateUser';
import { useCheckNickname } from '../_lib/useCheckNickname';
import { renderIndicatorIcon } from '../_lib/utilityFunctions';
import { useTemporaryAuthStore } from '@/store/auth';
import { useTemporaryAuthStore } from '@/store/authStore';
import { AuthService } from '@/common/service/AuthService';
import { cn } from '@/common/utils/cn';
import ArrowLeftIcon from '@/common/assets/icons/arrow-left';
Expand Down
2 changes: 1 addition & 1 deletion services/one-app/src/app/(site)/my/_lib/updateUser.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from 'zod';
import axios from 'axios';

import type { TemporaryUserAuthData } from '@/store/auth';
import type { TemporaryUserAuthData } from '@/store/authStore';
import { API_BASE_URL } from '@/common/constants/env';
import { APIResponseCode, RESPONSE_MESSAGES } from '@/common/constants/api';

Expand Down
41 changes: 41 additions & 0 deletions services/one-app/src/app/_components/ErrorCatcher.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
'use client';

import { useEffect } from 'react';

import { useAppErrorStore } from '@/store/appErrorStore';
import { captureError } from '@/common/utils/error/captureError';
import { isRequestFailedError } from '@/common/errors/RequestError';
import { RESPONSE_MESSAGES } from '@/common/constants/api';

/**
* @description
* 리액트 쿼리에서 의도적으로 throw한 에러를 처리합니다.
* 1. 센트리로 에러를 전송합니다.
* 2. 유저에게 토스트 UI를 통해 에러 상황을 전달합니다.
* 3. 예측하지 못한 런타임 에러 등은 error.tsx로 전파합니다.
*/
export const ErrorCatcher = ({ children }: React.PropsWithChildren) => {
const { appError: error } = useAppErrorStore();

useEffect(() => {
if (!error) return;

captureError(error); // 센트리로 에러 전송

if (!isRequestFailedError(error) || !isPredictableError(error)) throw error; // error.tsx로 전파

// TODO: 토스트 컴포넌트 만들어서 alert 대체하기
// toast.error(RESPONSE_MESSAGES[error.errorCode], {
// showingTime: 3000,
// position: 'bottom',
// bottom: '8rem',
// });
alert(RESPONSE_MESSAGES[error.errorCode]);
}, [error]);

return children;
};

const isPredictableError = (error: Error): boolean => {
return Object.values(RESPONSE_MESSAGES).includes(error.message);
};
18 changes: 18 additions & 0 deletions services/one-app/src/app/_components/PathTracker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
'use client';

import Cookies from 'js-cookie';
import { useEffect } from 'react';
import { useCurrentPath } from '@/common/hooks/useCurrentPath';

export const PathTracker = () => {
const currentPath = useCurrentPath();

useEffect(() => {
const previousPath = Cookies.get('currentPath') || 'null';

Cookies.set('currentPath', currentPath, { expires: 7 });
Cookies.set('previousPath', previousPath, { expires: 7 });
}, [currentPath]);

return null;
};
52 changes: 38 additions & 14 deletions services/one-app/src/app/_components/RQProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,54 @@
'use client';

import React, { useState } from 'react';
import { QueryClientProvider, QueryClient } from '@tanstack/react-query';
import React from 'react';
import {
QueryClientProvider,
QueryClient,
QueryCache,
MutationCache,
} from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

import { useAppErrorStore } from '@/store/appErrorStore';
import { RequestGetError } from '@/common/errors/RequestGetError';

type Props = {
children: React.ReactNode;
};

export const RQProvider = ({ children }: Props) => {
const [client] = useState(
new QueryClient({
defaultOptions: {
// react-query 전역 설정
queries: {
retry: false,
retryOnMount: true,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
},
const { updateAppError } = useAppErrorStore();

const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
throwOnError: true,
refetchOnWindowFocus: false,
},
},
queryCache: new QueryCache({
onError: (error: Error) => {
console.log('QueryCache Error:', error);
if (
error instanceof RequestGetError &&
// errorBoundary로 처리해야하는 에러인 경우 updateAppError를 하지 못하도록 얼리리턴
error.errorHandlingStrategy === 'fallback'
)
return;

updateAppError(error);
},
}),
);
mutationCache: new MutationCache({
onError: (error: Error) => {
updateAppError(error);
},
}),
});

return (
<QueryClientProvider client={client}>
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools
initialIsOpen={process.env.NODE_ENV === 'development'}
Expand Down
91 changes: 69 additions & 22 deletions services/one-app/src/app/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,104 @@
import axios, { AxiosError, AxiosInstance, isAxiosError } from 'axios';

import axios, { AxiosError, AxiosInstance } from 'axios';
import { AuthService } from '@/common/service/AuthService';
import { API_BASE_URL } from '@/common/constants/env';
import { APIErrorResponse } from '@/model/Error';
import {
APIResponseCode,
type ErrorInfo,
type ErrorCode,
} from '@/common/constants/api';
import {
RequestGetError,
WithErrorHandlingStrategy,
} from '@/common/errors/RequestGetError';
import { RequestFailedError } from '@/common/errors/RequestError';

// 에러 코드 상수 정의
const ERROR_CODES = {
ACCESS_TOKEN_EXPIRED: APIResponseCode.EXPIRED_ACCESS_TOKEN,
SESSION_EXPIRED: [
APIResponseCode.INVALID_ACCESS_TOKEN,
APIResponseCode.INVALID_REFRESH_TOKEN,
APIResponseCode.EXPIRED_REFRESH_TOKEN,
APIResponseCode.INVALID_PERMISSION_CODE,
],
} as const;

// TODO, access_token 선택적으로 보내는 방안 모색 (axios type 확장)
// public한 함수(인기 검색 순위 등)들의 경우 굳이 access_token이 없음을 처리할 필요가 없음
function isSessionExpiredCode(code: string): code is ErrorCode {
return (ERROR_CODES.SESSION_EXPIRED as readonly string[]).includes(code);
}

const setInterceptor = (instance: AxiosInstance) => {
instance.interceptors.request.use(
(config) => {
const requestConfig = config;

// Access Token 설정
const accessToken = AuthService.accessToken;
if (accessToken) {
requestConfig.headers.Authorization = `Bearer ${accessToken}`;
}

return requestConfig;
},
(err: AxiosError): Promise<AxiosError> => Promise.reject(err),
async (error: AxiosError) => {
const customError = await createError({ error });
return Promise.reject(customError);
},
);

// TODO, 응답 에러 처리 다듬기
instance.interceptors.response.use(
(response) => response,
async (error) => {
if (isAxiosError(error) && error.response?.data) {
const { code } = error.response.data as APIErrorResponse;

// console.log(code, error.response.data);
async (error: AxiosError) => {
if (error.response?.data) {
const { code } = error.response.data as ErrorInfo;

if (code === '202') {
// 액세스 토큰 만료 시 재요청
if (code === ERROR_CODES.ACCESS_TOKEN_EXPIRED) {
return AuthService.resetTokenAndRetryRequest(error);
}

if (['201', '203', '204', '205'].includes(code)) {
// 세션 만료 시 로그아웃 처리
if (isSessionExpiredCode(code)) {
AuthService.expireSession();
return Promise.reject(error);
return Promise.resolve({ sessionExpired: true });
}
}

console.error(error);
return Promise.reject(error);
const customError = await createError({ error: error as AxiosError });
return Promise.reject(customError);
},
);

return instance;
};

type CreateError = {
error: AxiosError;
};

const createError = async ({
error,
errorHandlingStrategy,
}: WithErrorHandlingStrategy<CreateError>): Promise<
RequestFailedError | RequestGetError
> => {
if (!error.response) {
throw error;
}

const { status, config, data } = error.response;
const { code, message } = data as ErrorInfo;

const errorParams = {
status,
requestBody: config.data,
endpoint: config.url || '',
method: config.method || '',
errorHandlingStrategy,
message,
errorCode: code,
};

return config.method?.toUpperCase() === 'GET'
? new RequestGetError(errorParams)
: new RequestFailedError(errorParams);
};

const apiClient = setInterceptor(
axios.create({
baseURL: API_BASE_URL,
Expand Down
22 changes: 22 additions & 0 deletions services/one-app/src/app/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use client';

import { useEffect } from 'react';

export default function ErrorPage({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error('error in ErrorPage', error);
}, []);

return (
<div>
<h2>Something went wrong!</h2>
<button onClick={() => reset()}>Try again</button>
</div>
);
}
Loading
Loading