diff --git a/package.json b/package.json
index 028a9a3..632912a 100644
--- a/package.json
+++ b/package.json
@@ -24,6 +24,7 @@
"react-hook-form": "^7.54.1",
"react-redux": "^9.1.2",
"react-router": "^7.0.2",
+ "redux-persist": "^6.0.0",
"styled-components": "^6.1.13"
},
"devDependencies": {
@@ -39,6 +40,7 @@
"@types/jwt-decode": "^3.1.0",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
+ "@types/redux-persist": "^4.3.1",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.15.0",
"eslint-plugin-react-hooks": "^5.0.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 86b33c6..b288db6 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -41,6 +41,9 @@ importers:
react-router:
specifier: ^7.0.2
version: 7.0.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ redux-persist:
+ specifier: ^6.0.0
+ version: 6.0.0(react@18.3.1)(redux@5.0.1)
styled-components:
specifier: ^6.1.13
version: 6.1.13(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@@ -81,6 +84,9 @@ importers:
'@types/react-dom':
specifier: ^18.3.1
version: 18.3.2
+ '@types/redux-persist':
+ specifier: ^4.3.1
+ version: 4.3.1(react@18.3.1)(redux@5.0.1)
'@vitejs/plugin-react':
specifier: ^4.3.4
version: 4.3.4(vite@6.0.3(@types/node@22.10.2))
@@ -1076,6 +1082,10 @@ packages:
'@types/react@18.3.14':
resolution: {integrity: sha512-NzahNKvjNhVjuPBQ+2G7WlxstQ+47kXZNHlUvFakDViuIEfGY926GqhMueQFZ7woG+sPiQKlF36XfrIUVSUfFg==}
+ '@types/redux-persist@4.3.1':
+ resolution: {integrity: sha512-YkMnMUk+4//wPtiSTMfsxST/F9Gh9sPWX0LVxHuOidGjojHtMdpep2cYvQgfiDMnj34orXyZI+QJCQMZDlafKA==}
+ deprecated: This is a stub types definition for redux-persist (https://github.com/rt2zz/redux-persist). redux-persist provides its own type definitions, so you don't need @types/redux-persist installed!
+
'@types/resolve@1.20.6':
resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==}
@@ -2118,6 +2128,15 @@ packages:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
+ redux-persist@6.0.0:
+ resolution: {integrity: sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==}
+ peerDependencies:
+ react: '>=16'
+ redux: '>4.0.0'
+ peerDependenciesMeta:
+ react:
+ optional: true
+
redux-thunk@3.1.0:
resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==}
peerDependencies:
@@ -3476,6 +3495,13 @@ snapshots:
'@types/prop-types': 15.7.14
csstype: 3.1.3
+ '@types/redux-persist@4.3.1(react@18.3.1)(redux@5.0.1)':
+ dependencies:
+ redux-persist: 6.0.0(react@18.3.1)(redux@5.0.1)
+ transitivePeerDependencies:
+ - react
+ - redux
+
'@types/resolve@1.20.6': {}
'@types/statuses@2.0.5': {}
@@ -4528,6 +4554,12 @@ snapshots:
indent-string: 4.0.0
strip-indent: 3.0.0
+ redux-persist@6.0.0(react@18.3.1)(redux@5.0.1):
+ dependencies:
+ redux: 5.0.1
+ optionalDependencies:
+ react: 18.3.1
+
redux-thunk@3.1.0(redux@5.0.1):
dependencies:
redux: 5.0.1
diff --git a/src/App.tsx b/src/App.tsx
index 7697f7c..1a9921b 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -2,8 +2,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RouterProvider, createBrowserRouter } from 'react-router';
import router from '@/routes/router';
import { Provider } from 'react-redux'; // Redux Provider 임포트
-import { store } from '@/store/store'; // 설정된 Redux 스토어 임포트
import GlobalStyle from './styles/GlobalStyle';
+import { PersistGate } from 'redux-persist/integration/react';
+import { persistor, store } from './store/store';
const queryClient = new QueryClient({
defaultOptions: {
@@ -19,10 +20,12 @@ const App = () => {
const appRouter = createBrowserRouter(router);
return (
-
-
-
-
+
+
+
+
+
+
);
};
diff --git a/src/apis/http.api.ts b/src/apis/http.api.ts
index 30383c7..a532349 100644
--- a/src/apis/http.api.ts
+++ b/src/apis/http.api.ts
@@ -1,11 +1,26 @@
-import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
-import { getToken, removeToken } from '@/utils/token'; // 토큰 유틸리티 함수 import
-import { logout } from '@/store/slices/authSlice';
-import { store } from '@/store/store';
+import axios, {
+ AxiosInstance,
+ AxiosRequestConfig,
+ AxiosHeaders,
+ AxiosRequestHeaders,
+} from 'axios';
const BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3333';
const DEFAULT_TIMEOUT = 30000; // 요청 제한 시간
+// 토큰 관리 함수
+function getToken(): string | null {
+ return localStorage.getItem('token');
+}
+
+function setToken(token: string) {
+ localStorage.setItem('token', token);
+}
+
+function removeToken() {
+ localStorage.removeItem('token');
+}
+
// Axios 인스턴스 생성 함수
export const createClient = (config?: AxiosRequestConfig): AxiosInstance => {
const axiosInstance = axios.create({
@@ -21,17 +36,24 @@ export const createClient = (config?: AxiosRequestConfig): AxiosInstance => {
// 요청 인터셉터: Authorization 헤더 동적 설정
axiosInstance.interceptors.request.use(
(config) => {
- const accessToken = getToken();
+ const accessToken = getToken(); // 여기서 getToken()을 호출하여 실제로 사용
if (accessToken) {
- if (config.headers && typeof config.headers.set === 'function') {
- // AxiosHeaders 객체 처리
- config.headers.set('Authorization', `Bearer ${accessToken}`);
+ if (
+ config.headers &&
+ 'set' in config.headers &&
+ typeof (config.headers as AxiosHeaders).set === 'function'
+ ) {
+ // AxiosHeaders 타입으로 헤더를 다루는 경우
+ (config.headers as AxiosHeaders).set(
+ 'Authorization',
+ `Bearer ${accessToken}`
+ );
} else {
- // 일반 객체 초기화
+ // 일반 객체로 헤더를 다루는 경우
config.headers = {
- ...config.headers, // 기존 헤더 유지
+ ...config.headers,
Authorization: `Bearer ${accessToken}`,
- } as any;
+ } as AxiosRequestHeaders;
}
}
console.log('Request Headers:', config.headers); // 디버깅용 로그
@@ -43,26 +65,13 @@ export const createClient = (config?: AxiosRequestConfig): AxiosInstance => {
}
);
- // 응답 인터셉터: 401 상태 처리
- axiosInstance.interceptors.response.use(
- (response) => response,
- (error) => {
- if (error.response?.status === 401) {
- console.warn('401 Unauthorized: Logging out user...');
- removeToken(); // 토큰 삭제
- store.dispatch(logout()); // Redux 상태 초기화
- window.location.href = '/login'; // 로그인 페이지로 리다이렉트
- return;
- }
- return Promise.reject(error);
- }
- );
-
return axiosInstance;
};
// 기본 Axios 인스턴스 생성
export const httpClient = createClient();
-// 기본 Axios 인스턴스 내보내기
+// 토큰 관련 함수 export
+export { setToken, removeToken, getToken };
+
export default httpClient;
diff --git a/src/components/AuthButton.tsx b/src/components/AuthButton.tsx
new file mode 100644
index 0000000..305a041
--- /dev/null
+++ b/src/components/AuthButton.tsx
@@ -0,0 +1,95 @@
+// src/components/Header.tsx
+import { useNavigate } from 'react-router';
+import { useSelector, useDispatch } from 'react-redux';
+import { RootState } from '@/store/rootReducer';
+import { logout } from '@/hooks/userSlice';
+import { removeToken } from '@/apis/http.api';
+import styled from 'styled-components';
+
+function AuthButton() {
+ const navigate = useNavigate();
+ const dispatch = useDispatch();
+ const isLoggedIn = useSelector((state: RootState) => state.user.isLoggedIn);
+
+ const handleLogin = () => {
+ // 로그인 페이지로 이동
+ navigate('/login');
+ };
+
+ const handleLogout = () => {
+ // Redux 상태 초기화
+ dispatch(logout());
+ // 토큰 제거
+ removeToken();
+ // 로그아웃 후 메인 페이지 이동(필요하다면)
+ navigate('/');
+ };
+
+ return isLoggedIn ? (
+
+
+
+ ) : (
+
+
+
+ );
+}
+
+export default AuthButton;
+
+// Styled Components 동일
+const ButtonBox = styled.div`
+ height: 52px;
+ width: 114px;
+
+ .group {
+ height: 100%;
+ position: relative;
+ }
+
+ .overlap-group {
+ border-radius: 30px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 100%;
+ width: 100%;
+ cursor: pointer;
+ transition: background-color 0.2s ease-in-out;
+ }
+
+ .text-wrapper {
+ color: #ffffff;
+ font-family: 'Pretendard-ExtraBold', Helvetica;
+ font-size: 15px;
+ line-height: 12px;
+ white-space: nowrap;
+ }
+`;
+
+const LoginBox = styled(ButtonBox)`
+ .overlap-group {
+ background-color: #32c040;
+ }
+ .overlap-group:hover {
+ background-color: #28a034;
+ }
+`;
+
+const LogoutBox = styled(ButtonBox)`
+ .overlap-group {
+ background-color: #9a9a9a;
+ }
+ .overlap-group:hover {
+ background-color: #7f7f7f;
+ }
+`;
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 69b2b03..24ab86c 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -1,6 +1,7 @@
import styled from 'styled-components';
import logo from '@/assets/logo.svg';
import Avatar from './ui/atoms/Avator';
+import AuthButton from './AuthButton';
const Header = () => (
@@ -14,6 +15,7 @@ const Header = () => (
/>
+
);
diff --git a/src/components/ui/atoms/Input.tsx b/src/components/ui/atoms/Input.tsx
index 22af179..bc6de15 100644
--- a/src/components/ui/atoms/Input.tsx
+++ b/src/components/ui/atoms/Input.tsx
@@ -20,7 +20,7 @@ interface LabelProps {
const StyledLabel = styled.label`
position: absolute;
- left: 1rem;
+ left: 2rem;
top: 50%;
transform: translateY(-50%);
color: #727272;
diff --git a/src/hooks/userSlice.ts b/src/hooks/userSlice.ts
new file mode 100644
index 0000000..3ce5fd0
--- /dev/null
+++ b/src/hooks/userSlice.ts
@@ -0,0 +1,97 @@
+// userSlice.ts
+import { DecodedToken, UserInfo, UserState } from '@/types/auth';
+import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';
+import axios from 'axios';
+import { jwtDecode } from 'jwt-decode';
+
+const initialState: UserState = {
+ isLoggedIn: false,
+ token: null,
+ userInfo: null,
+ loading: false,
+ error: null,
+};
+
+// 실제 서버에 로그인 요청을 보내고 토큰을 받아오는 thunk
+export const loginAsync = createAsyncThunk(
+ 'user/loginAsync',
+ async (
+ { email, password }: { email: string; password: string },
+ { rejectWithValue }
+ ) => {
+ try {
+ const response = await axios.post(
+ 'http://localhost:3333/api/users/login',
+ {
+ email,
+ password,
+ }
+ );
+
+ if (response.data.success && response.data.token) {
+ return response.data.token;
+ } else {
+ return rejectWithValue('로그인 실패: 토큰 없음');
+ }
+ } catch (error: any) {
+ return rejectWithValue(
+ error.response?.data?.message || '로그인 요청 실패'
+ );
+ }
+ }
+);
+
+const userSlice = createSlice({
+ name: 'user',
+ initialState,
+ reducers: {
+ loginSuccess: (
+ state,
+ action: PayloadAction<{ token: string; userInfo: UserInfo }>
+ ) => {
+ state.isLoggedIn = true;
+ state.token = action.payload.token;
+ state.userInfo = action.payload.userInfo;
+ state.loading = false;
+ state.error = null;
+
+ localStorage.setItem('token', action.payload.token);
+ },
+ logout: (state) => {
+ state.isLoggedIn = false;
+ state.token = null;
+ state.userInfo = null;
+ state.loading = false;
+ state.error = null;
+ },
+ },
+ extraReducers: (builder) => {
+ builder
+ .addCase(loginAsync.pending, (state) => {
+ state.loading = true;
+ state.error = null;
+ })
+ .addCase(loginAsync.fulfilled, (state, action) => {
+ const decoded: DecodedToken = jwtDecode(action.payload);
+ const userInfo: UserInfo = {
+ id: decoded.id,
+ email: decoded.email,
+ nickname: decoded.nickname,
+ role: decoded.role,
+ };
+
+ state.isLoggedIn = true;
+ state.token = action.payload;
+ state.userInfo = userInfo;
+ state.loading = false;
+ state.error = null;
+ })
+ .addCase(loginAsync.rejected, (state, action) => {
+ state.loading = false;
+ state.error = action.payload as string;
+ });
+ },
+});
+
+export const { loginSuccess, logout } = userSlice.actions;
+export default userSlice.reducer;
diff --git a/src/pages/LoginPage.tsx b/src/pages/LoginPage.tsx
index 818312a..d4ff898 100644
--- a/src/pages/LoginPage.tsx
+++ b/src/pages/LoginPage.tsx
@@ -1,47 +1,43 @@
import styled from 'styled-components';
import { useNavigate } from 'react-router';
import qublogo from '@/assets/qublogo.svg';
-
import { useForm } from 'react-hook-form';
import { useDispatch, useSelector } from 'react-redux'; // Redux 추가
-import { AppDispatch, RootState } from '@/store/store'; // Redux 타입 가져오기
-import { loginThunk } from '@/store/slices/authSlice'; // Thunk 가져오기
import Input from '@/components/ui/atoms/Input';
+import { RootState } from '@/store/rootReducer';
+import { loginAsync } from '@/hooks/userSlice';
+import { AppDispatch } from '@/store/store';
-export interface LoginProps {
+type LoginFormData = {
email: string;
password: string;
-}
+};
function LoginPage() {
const navigate = useNavigate();
const dispatch = useDispatch();
-
- const { loading, error, isLoggedIn } = useSelector(
- (state: RootState) => state.auth
- );
+ const { loading, error } = useSelector((state: RootState) => state.user);
const {
register,
handleSubmit,
formState: { errors },
- } = useForm();
+ } = useForm();
- const onSubmit = async (data: LoginProps) => {
- const result = await dispatch(loginThunk(data));
+ const onSubmit = async (data: LoginFormData) => {
+ const result = await dispatch(loginAsync(data));
- if (loginThunk.fulfilled.match(result)) {
+ if (loginAsync.fulfilled.match(result)) {
alert('로그인에 성공했습니다!');
- navigate('/'); // 로그인 성공 시 메인 페이지로 이동
+ navigate('/');
} else {
- alert(result.payload || '로그인에 실패했습니다. 다시 시도해주세요.');
+ alert(
+ (result.payload as string) ||
+ '로그인에 실패했습니다. 다시 시도해주세요.'
+ );
}
};
- // 로그인 상태 확인 후 리다이렉트 처리
- if (isLoggedIn) {
- navigate('/');
- }
return (
diff --git a/src/store/rootReducer.ts b/src/store/rootReducer.ts
new file mode 100644
index 0000000..41d8039
--- /dev/null
+++ b/src/store/rootReducer.ts
@@ -0,0 +1,11 @@
+// src/app/rootReducer.ts
+import { combineReducers } from '@reduxjs/toolkit';
+import userReducer from '@/hooks/userSlice';
+
+const rootReducer = combineReducers({
+ user: userReducer,
+ // 다른 slice reducer가 있다면 여기에 추가
+});
+
+export type RootState = ReturnType;
+export default rootReducer;
diff --git a/src/store/slices/authSlice.ts b/src/store/slices/authSlice.ts
deleted file mode 100644
index 4535169..0000000
--- a/src/store/slices/authSlice.ts
+++ /dev/null
@@ -1,78 +0,0 @@
-import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
-import { login } from '@/apis/auth.api';
-import { LoginRequest, LoginResponse } from '@/types/auth';
-import { DecodedToken, LoginThunkResponse } from '@/types/auth'; // 디코딩된 토큰 타입 가져오기
-import { jwtDecode } from 'jwt-decode';
-
-// 초기 상태
-const initialState = {
- isLoggedIn: false,
- token: null as string | null,
- decodedToken: null as DecodedToken | null, // 디코딩된 토큰 정보
- loading: false,
- error: null as string | null,
-};
-
-export const loginThunk = createAsyncThunk(
- 'auth/login',
- async (credentials, thunkAPI) => {
- try {
- const response: LoginResponse = await login(credentials);
-
- // 토큰이 없는 경우 처리
- if (!response.token) {
- throw new Error('Token is missing from the server response.');
- }
-
- // JWT 디코딩
- const decodedToken = jwtDecode(response.token);
-
- return {
- token: response.token,
- decodedToken,
- };
- } catch (error: any) {
- return thunkAPI.rejectWithValue(
- error.response?.data?.message || '로그인 요청 실패'
- );
- }
- }
-);
-
-// Slice 생성
-const authSlice = createSlice({
- name: 'auth',
- initialState,
- reducers: {
- logout(state) {
- state.isLoggedIn = false;
- state.token = null;
- state.error = null;
-
- localStorage.removeItem('token');
- },
- },
- extraReducers: (builder) => {
- builder
- .addCase(loginThunk.pending, (state) => {
- state.loading = true;
- state.error = null;
- })
- .addCase(loginThunk.fulfilled, (state, action) => {
- state.loading = false;
- state.isLoggedIn = true;
- state.token = action.payload.token;
- state.decodedToken = action.payload.decodedToken; // 디코딩된 정보 저장
-
- // 로컬스토리지에 토큰 저장
- localStorage.setItem('token', action.payload.token);
- })
- .addCase(loginThunk.rejected, (state, action) => {
- state.loading = false;
- state.error = action.payload as string; // 에러 메시지 저장
- });
- },
-});
-
-export const { logout } = authSlice.actions;
-export default authSlice.reducer;
diff --git a/src/store/store.ts b/src/store/store.ts
index b1fe3eb..6d40096 100644
--- a/src/store/store.ts
+++ b/src/store/store.ts
@@ -1,13 +1,25 @@
import { configureStore } from '@reduxjs/toolkit';
-import authReducer from '@/store/slices/authSlice';
+import { persistStore, persistReducer } from 'redux-persist';
+import storage from 'redux-persist/lib/storage'; // 웹 환경에서 localStorage 사용
+import rootReducer from './rootReducer';
+
+const persistConfig = {
+ key: 'root',
+ storage,
+ whitelist: ['user'], // user slice만 localStorage에 저장
+};
+
+const persistedReducer = persistReducer(persistConfig, rootReducer);
export const store = configureStore({
- reducer: {
- auth: authReducer,
- },
+ reducer: persistedReducer,
+ middleware: (getDefaultMiddleware) =>
+ getDefaultMiddleware({
+ serializableCheck: false, // redux-persist 관련 non-serializable 경고 비활성화
+ }),
});
-export type RootState = ReturnType;
-export type AppDispatch = typeof store.dispatch;
+export const persistor = persistStore(store);
-export default store;
+// store의 타입 유추
+export type AppDispatch = typeof store.dispatch;
diff --git a/src/types/auth.ts b/src/types/auth.ts
index 8f76bf5..ba3e863 100644
--- a/src/types/auth.ts
+++ b/src/types/auth.ts
@@ -1,53 +1,23 @@
-export interface User {
+export interface UserInfo {
+ id: number;
email: string;
nickname: string;
- role: 'user' | 'admin';
+ role: string;
}
-// 토큰 인터페이스
-interface Tokens {
- accessToken: string;
-}
-
-export interface AuthState {
+export interface UserState {
isLoggedIn: boolean;
- user: User | null;
- tokens: Tokens | null;
+ token: string | null;
+ userInfo: UserInfo | null;
+ loading: boolean;
error: string | null;
}
-// 로그인 요청 데이터 타입
-export interface LoginRequest {
- email: string;
- password: string;
-}
-
-export interface AuthResponse {
- success: boolean;
- token?: string;
- user?: User;
-}
-
-export interface APIError {
- message: string;
-}
-
export interface DecodedToken {
+ id: number;
email: string;
nickname: string;
- role: string | string[]; // 단일 문자열 또는 배열
+ role: string;
iat: number;
exp: number;
}
-
-// Thunk 반환 타입
-export interface LoginThunkResponse {
- token: string; // JWT 토큰
- decodedToken: DecodedToken; // 디코딩된 데이터
-}
-
-export interface LoginResponse {
- message: any;
- success: boolean;
- token?: string;
-}