Skip to content

Commit

Permalink
#267 feat: React-Query, react-error-boundary 도입 및 초기 설정
Browse files Browse the repository at this point in the history
  • Loading branch information
pillow12360 committed Jan 13, 2025
1 parent e7b5101 commit a145285
Show file tree
Hide file tree
Showing 11 changed files with 367 additions and 10 deletions.
4 changes: 4 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@
"@babel/core": "^7.25.2",
"@babel/eslint-parser": "^7.25.1",
"@react-google-maps/api": "^2.20.5",
"@tanstack/react-query": "^5.64.0",
"@tanstack/react-query-devtools": "^5.64.0",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@types/react-query": "^1.2.8",
"@types/react-window": "^1.8.8",
"@types/styled-components": "^5.1.34",
"axios": "^1.7.7",
Expand All @@ -23,6 +26,7 @@
"react": "^18.3.1",
"react-calendar": "^5.1.0",
"react-dom": "^18.3.1",
"react-error-boundary": "^5.0.0",
"react-markdown": "^9.0.1",
"react-quill": "^2.0.0",
"react-quill-new": "^3.3.3",
Expand Down
43 changes: 33 additions & 10 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,42 @@ import { GlobalStyle } from './styles/GlobalStyle';
import AppContent from './AppContent';
import { ModalProvider } from './components/Modal/context/ModalContext';
import { AuthProvider } from './context/AuthContext';
import { Providers as QueryProvider } from './lib/react-query/provider';
import { ErrorBoundary } from 'react-error-boundary';

function ErrorFallback({ error }: { error: Error }) {
return (
<div className="flex flex-col items-center justify-center min-h-screen p-4">
<h2 className="text-2xl font-bold text-red-600 mb-4">
오류가 발생했습니다
</h2>
<pre className="text-sm bg-gray-100 p-4 rounded-lg">{error.message}</pre>
<button
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
onClick={() => window.location.reload()}
>
새로고침
</button>
</div>
);
}

function App() {
return (
<ThemeProvider theme={theme}>
<GlobalStyle />
<BrowserRouter>
<ModalProvider>
<AuthProvider>
<AppContent />
</AuthProvider>
</ModalProvider>
</BrowserRouter>
</ThemeProvider>
<ErrorBoundary FallbackComponent={ErrorFallback}>
<QueryProvider>
<ThemeProvider theme={theme}>
<GlobalStyle />
<BrowserRouter>
<ModalProvider>
<AuthProvider>
<AppContent />
</AuthProvider>
</ModalProvider>
</BrowserRouter>
</ThemeProvider>
</QueryProvider>
</ErrorBoundary>
);
}

Expand Down
Empty file added frontend/src/api/index.ts
Empty file.
132 changes: 132 additions & 0 deletions frontend/src/api/news.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import axios from 'axios';
import {
useQuery,
useMutation,
UseQueryResult,
UseMutationResult,
} from '@tanstack/react-query';
import type {
NewsRequest,
NewsResponse,
ApiResponse,
PaginationParams,
PaginatedResponse,
} from '../types';

const API_URL = process.env.REACT_APP_API_URL;

export const newsKeys = {
all: ['news'] as const,
lists: () => [...newsKeys.all, 'list'] as const,
list: (params: PaginationParams) => [...newsKeys.lists(), params] as const,
details: () => [...newsKeys.all, 'detail'] as const,
detail: (id: number) => [...newsKeys.details(), id] as const,
};

// API 함수들
export const getNews = async (id: number): Promise<NewsResponse> => {
const { data } = await axios.get<ApiResponse<NewsResponse>>(
`${API_URL}/api/news/${id}`,
);
return data.data;
};

export const getNewsList = async ({
page,
size,
}: PaginationParams): Promise<PaginatedResponse<NewsResponse>> => {
const { data } = await axios.get<
ApiResponse<PaginatedResponse<NewsResponse>>
>(`${API_URL}/api/news`, {
params: { page, size },
});
return data.data;
};

export const createNews = async (newsData: NewsRequest): Promise<number> => {
const formData = new FormData();
formData.append(
'newsReqDto',
new Blob([JSON.stringify(newsData)], { type: 'application/json' }),
);

if (newsData.image) {
formData.append('news_image', newsData.image);
}

const { data } = await axios.post<ApiResponse<number>>(
`${API_URL}/api/news`,
formData,
{
headers: { 'Content-Type': 'multipart/form-data' },
},
);

return data.data;
};

export const updateNews = async ({
id,
...newsData
}: NewsRequest & { id: number }): Promise<void> => {
const formData = new FormData();
formData.append(
'newsReqDto',
new Blob([JSON.stringify(newsData)], { type: 'application/json' }),
);

if (newsData.image) {
formData.append('news_image', newsData.image);
}

await axios.post(`${API_URL}/api/news/${id}`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
};

export const deleteNews = async (id: number): Promise<void> => {
await axios.delete(`${API_URL}/api/news/${id}`);
};

// React Query Hooks
export const useGetNews = (id: number): UseQueryResult<NewsResponse> => {
return useQuery({
queryKey: newsKeys.detail(id),
queryFn: () => getNews(id),
});
};

export const useGetNewsList = (
params: PaginationParams,
): UseQueryResult<PaginatedResponse<NewsResponse>> => {
return useQuery({
queryKey: newsKeys.list(params),
queryFn: () => getNewsList(params),
});
};

export const useCreateNews = (): UseMutationResult<
number,
Error,
NewsRequest
> => {
return useMutation({
mutationFn: createNews,
});
};

export const useUpdateNews = (): UseMutationResult<
void,
Error,
NewsRequest & { id: number }
> => {
return useMutation({
mutationFn: updateNews,
});
};

export const useDeleteNews = (): UseMutationResult<void, Error, number> => {
return useMutation({
mutationFn: deleteNews,
});
};
110 changes: 110 additions & 0 deletions frontend/src/lib/axios/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// src/lib/axios/index.ts
import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios';

// API 에러 타입 정의
interface ApiErrorResponse {
message: string;
status: number;
data?: any;
}

// 인스턴스 생성 함수
const createAxiosInstance = (): AxiosInstance => {
const instance = axios.create({
baseURL: process.env.REACT_APP_API_URL,
timeout: 10000,
headers: {
'Content-Type': 'application/json',
},
});

// 요청 인터셉터
instance.interceptors.request.use(
(config) => {
// 토큰이 필요한 경우
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error: AxiosError) => {
return Promise.reject(error);
},
);

// 응답 인터셉터
instance.interceptors.response.use(
(response: AxiosResponse) => {
return response;
},
(error: AxiosError<ApiErrorResponse>) => {
if (error.response) {
// 서버 응답이 있는 경우
const { status, data } = error.response;

switch (status) {
case 401:
// 인증 에러 처리
localStorage.removeItem('token');
// 로그인 페이지로 리다이렉트 등의 처리
break;
case 403:
// 권한 에러 처리
break;
case 404:
// Not Found 처리
break;
case 500:
// 서버 에러 처리
break;
}

return Promise.reject({
message: data?.message || '에러가 발생했습니다.',
status: status,
data: data,
});
}

if (error.request) {
// 요청은 보냈지만 응답을 받지 못한 경우
return Promise.reject({
message: '서버에 연결할 수 없습니다.',
status: 503,
});
}

// 요청 자체를 보내지 못한 경우
return Promise.reject({
message: '요청을 보낼 수 없습니다.',
status: 0,
});
},
);

return instance;
};

export const axiosInstance = createAxiosInstance();

// API 에러 핸들러
export const handleApiError = (error: unknown): ApiErrorResponse => {
if (axios.isAxiosError(error)) {
return {
message:
error.response?.data?.message || '알 수 없는 에러가 발생했습니다.',
status: error.response?.status || 500,
data: error.response?.data,
};
}
return {
message: '알 수 없는 에러가 발생했습니다.',
status: 500,
};
};

// axios 타입 가드
export const isAxiosError = axios.isAxiosError;

export default axiosInstance;
16 changes: 16 additions & 0 deletions frontend/src/lib/react-query/provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { queryClient } from './queryClient';

interface ProvidersProps {
children: React.ReactNode;
}

export const Providers = ({ children }: ProvidersProps) => {
return (
<QueryClientProvider client={queryClient}>
{children}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
};
12 changes: 12 additions & 0 deletions frontend/src/lib/react-query/queryClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { QueryClient } from '@tanstack/react-query';

export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5분
gcTime: 30 * 60 * 1000, // 이전의 cacheTime
retry: 1,
refetchOnWindowFocus: false,
},
},
});
18 changes: 18 additions & 0 deletions frontend/src/types/api/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export interface ApiResponse<T> {
data: T;
message?: string;
status: number;
}

export interface PaginationParams {
page: number;
size: number;
}

export interface PaginatedResponse<T> {
content: T[];
totalPages: number;
totalElements: number;
size: number;
number: number;
}
2 changes: 2 additions & 0 deletions frontend/src/types/api/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './common';
export * from './news';
Loading

0 comments on commit a145285

Please sign in to comment.