Skip to content
Merged
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.33.0",
"jwt-decode": "^4.0.0",
"lucide-react": "^0.562.0",
"motion": "^12.29.2",
"radix-ui": "^1.4.3",
Expand Down
3 changes: 3 additions & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ import './App.css';

import { GlobalErrorModal } from '@/components/GlobalErrorModal';
import { RouterProvider } from 'react-router/dom';
import { useAutoLogout } from './hooks/useAutoLogout';
import { router } from './routes';

export default function App() {
useAutoLogout();

return (
<>
<RouterProvider router={router} />
Expand Down
36 changes: 33 additions & 3 deletions src/api/apiClient.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios, { AxiosError } from 'axios';
import { jwtDecode } from 'jwt-decode';
import useAuthStore from '../hooks/useAuthStore';
import { useErrorStore } from '../hooks/useErrorStore';

Expand All @@ -8,6 +9,9 @@ interface ApiErrorResponse {
message: string;
}

// 5๋ถ„ ๋ฒ„ํผ (๋ฐ€๋ฆฌ์ดˆ)
const BUFFER_TIME = 5 * 60 * 1000;

// ๊ณตํ†ต ์„ค์ •์„ ๊ฐ€์ง„ axios ์ธ์Šคํ„ด์Šค ์ƒ์„ฑ
const apiClient = axios.create({
baseURL: `${import.meta.env.VITE_API_BASE_URL}/api`,
Expand All @@ -17,9 +21,33 @@ const apiClient = axios.create({

// ์š”์ฒญ ์ธํ„ฐ์…‰ํ„ฐ: ๋ชจ๋“  ์š”์ฒญ ์ง์ „์— ์‹คํ–‰๋จ
apiClient.interceptors.request.use((config) => {
const token = useAuthStore.getState().token;
const { token, logout } = useAuthStore.getState();

if (token) {
try {
const decoded = jwtDecode(token);

if (decoded.exp) {
const expTimeMs = decoded.exp * 1000;
const now = Date.now();

// ๋งŒ๋ฃŒ์‹œ๊ฐ„ 5๋ถ„ ์ „(ํ˜น์€ ์ด๋ฏธ ์ง€๋‚จ)์ด๋ฉด ๊ฑฐ๋ถ€
if (expTimeMs - BUFFER_TIME - now <= 0) {
// ์ƒํƒœ ๊ด€๋ฆฌ ๋กœ๊ทธ์•„์›ƒ ์ฒ˜๋ฆฌ๋งŒ ์ˆ˜ํ–‰
// ์—๋Ÿฌ ๋ชจ๋‹ฌ(showError)๊ณผ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ(window.location.href)๋Š”
// ์ตœ์ƒ๋‹จ App.tsx์— ๋งˆ์šดํŠธ๋œ useAutoLogout ํ›…์—์„œ ์ผ๊ด„์ ์œผ๋กœ ์ฒ˜๋ฆฌํ•˜๋„๋ก ์œ„์ž„ํ•จ.
// ์—ฌ๊ธฐ์„œ ์ค‘๋ณต ํ˜ธ์ถœ ์‹œ ๋ชจ๋‹ฌ์ด 2๋ฒˆ ๋œจ๋Š” Race Condition ๋ฐœ์ƒ ๊ฐ€๋Šฅ์„ฑ ์ฐจ๋‹จ
logout();
return Promise.reject(new Error('TOKEN_EXPIRED_LOCAL'));
}
}
} catch {
// ๋””์ฝ”๋”ฉ ์‹คํŒจ ์‹œ ์ง„ํ–‰ํ•˜์ง€ ์•Š์Œ (์„œ๋ฒ„๊ฐ€ ๊ฑฐ์ ˆํ•˜๋„๋ก ๋‘๊ฑฐ๋‚˜ ์—ฌ๊ธฐ์„œ ๋ฐ”๋กœ ๋ง‰์Œ)
// ๋ณด์•ˆ์ƒ ์—ฌ๊ธฐ์„œ ๋ฐ”๋กœ ๋ง‰๋Š” ๊ฒƒ์ด ์•ˆ์ „
logout();
return Promise.reject(new Error('INVALID_TOKEN_FORMAT'));
}

config.headers.Authorization = `Bearer ${token}`;
}

Expand All @@ -36,14 +64,16 @@ apiClient.interceptors.response.use(

// 1. ํ† ํฐ ๋งŒ๋ฃŒ ๋“ฑ ํŠน์ˆ˜ํ•œ ๊ฒฝ์šฐ ํ™•์ธ ๋ฒ„ํŠผ ์•ก์…˜ ์ •์˜
let onConfirm = undefined;
let confirmText = undefined;
if (code === 'TOKEN_EXPIRED') {
onConfirm = () => {
window.location.href = '/login';
};
confirmText = '๋‹ค์‹œ ๋กœ๊ทธ์ธํ•˜๊ธฐ';
}

// 2. ์Šคํ† ์–ด๋ฅผ ํ†ตํ•ด ๋ชจ๋‹ฌ ๋„์šฐ๊ธฐ (์ˆœ์„œ ์ฃผ์˜: message, title, onConfirm)
showError(message, title || '์˜ค๋ฅ˜ ๋ฐœ์ƒ', onConfirm);
// 2. ์Šคํ† ์–ด๋ฅผ ํ†ตํ•ด ๋ชจ๋‹ฌ ๋„์šฐ๊ธฐ (์ˆœ์„œ ์ฃผ์˜: message, title, onConfirm, confirmText)
showError(message, title || '์˜ค๋ฅ˜ ๋ฐœ์ƒ', onConfirm, confirmText);
} else {
// ์„œ๋ฒ„ ์‘๋‹ต ์ž์ฒด๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ (๋„คํŠธ์›Œํฌ ์˜ค๋ฅ˜ ๋“ฑ)
showError(
Expand Down
23 changes: 19 additions & 4 deletions src/components/GlobalErrorModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,30 @@ import {
import { useErrorStore } from '@/hooks/useErrorStore';

export function GlobalErrorModal() {
const { isOpen, title, message, onConfirm, closeError } = useErrorStore();
const { isOpen, title, message, onConfirm, closeError, confirmText } =
useErrorStore();

const handleConfirm = () => {
if (onConfirm) {
onConfirm();
}
closeError();

// ํŽ˜์ด์ง€ ์ด๋™ ๋“ฑ ๋ฌด๊ฑฐ์šด ์ž‘์—…์ด ๋ฐœ์ƒํ•  ๋•Œ ๋ฆฌ์•กํŠธ ๋ Œ๋”๋ง ์‚ฌ์ดํด์ด ๊ผฌ์ด๋ฉด์„œ
// ๋นˆ ๋ชจ๋‹ฌ์ด ๊นœ๋นก์ด๋Š” ํ˜„์ƒ(Race condition)์„ ๋ง‰๊ธฐ ์œ„ํ•ด ์•ฝ๊ฐ„์˜ ๋”œ๋ ˆ์ด๋ฅผ ๋‘๊ณ  ๋‹ซ์Œ
setTimeout(() => {
closeError();
}, 100);
};

const handleOpenChange = (open: boolean) => {
if (!open) {
// ๋ฐ”๊นฅ ์˜์—ญ ํด๋ฆญ(์˜ค๋ฒ„๋ ˆ์ด) ๋“ฑ ์ˆ˜๋™์œผ๋กœ ๋‹ซ์„ ๋•Œ๋งŒ ์ฆ‰์‹œ ์ฒ˜๋ฆฌ
closeError();
}
};

return (
<AlertDialog open={isOpen} onOpenChange={closeError}>
<AlertDialog open={isOpen} onOpenChange={handleOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="text-destructive">
Expand All @@ -31,7 +44,9 @@ export function GlobalErrorModal() {
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogAction onClick={handleConfirm}>ํ™•์ธ</AlertDialogAction>
<AlertDialogAction onClick={handleConfirm}>
{confirmText}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
Expand Down
14 changes: 12 additions & 2 deletions src/hooks/useAuthStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,18 @@ const useAuthStore = create<AuthState>()(
isLoggedIn: false,
guestRegistrations: {},

login: (user, token) => set({ user, token, isLoggedIn: true }),
logout: () => set({ user: null, token: null, isLoggedIn: false }),
login: (user, token) =>
set({
user,
token,
isLoggedIn: true,
}),
logout: () =>
set({
user: null,
token: null,
isLoggedIn: false,
}),
updateUser: (user) => set({ user }),
setGuestRegistration: (eventId, registrationId) =>
set((state) => ({
Expand Down
86 changes: 86 additions & 0 deletions src/hooks/useAutoLogout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { jwtDecode } from 'jwt-decode';
import { useEffect, useRef } from 'react';
import useAuthStore from './useAuthStore';
import { useErrorStore } from './useErrorStore';

// 5๋ถ„ ๋ฒ„ํผ (๋ฐ€๋ฆฌ์ดˆ) -> ๋งŒ๋ฃŒ 5๋ถ„ ์ „์— ๋ฏธ๋ฆฌ ๋กœ๊ทธ์•„์›ƒ ์‹œํ‚ด
const BUFFER_TIME = 5 * 60 * 1000;

export function useAutoLogout() {
const { token, logout } = useAuthStore();
const showError = useErrorStore((state) => state.showError);

const isLoggingOut = useRef(false);

useEffect(() => {
if (!token) {
isLoggingOut.current = false;
return;
}

const checkAndLogout = () => {
if (isLoggingOut.current) return;

try {
const decoded = jwtDecode(token);
if (!decoded.exp) return;

// ํ† ํฐ exp๋Š” ์ดˆ ๋‹จ์œ„์ด๋ฏ€๋กœ ๋ฐ€๋ฆฌ์ดˆ๋กœ ๋ณ€ํ™˜
const expTimeMs = decoded.exp * 1000;
const now = Date.now();

// (๋งŒ๋ฃŒ์‹œ๊ฐ„ - ๋ฒ„ํผ) ์‹œ์ ๊นŒ์ง€ ๋‚จ์€ ์‹œ๊ฐ„
const timeLeftToLogout = expTimeMs - BUFFER_TIME - now;

if (timeLeftToLogout <= 0) {
// ์ด๋ฏธ ๋งŒ๋ฃŒ ์‹œ์ ์„(ํ˜น์€ ๋ฒ„ํผ ์‹œ์ ์„) ์ง€๋‚ฌ๋‹ค๋ฉด ์ฆ‰์‹œ ๋กœ๊ทธ์•„์›ƒ
handleLogout();
} else {
// ์•„์ง ์‹œ๊ฐ„์ด ๋‚จ์•˜๋‹ค๋ฉด ํƒ€์ด๋จธ ์„ค์ •
const timerId = setTimeout(() => {
handleLogout();
}, timeLeftToLogout);

return timerId;
}
} catch (error) {
// ํ† ํฐ ๋””์ฝ”๋”ฉ ์‹คํŒจ ์‹œ (์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ) ๊ฐ•์ œ ๋กœ๊ทธ์•„์›ƒ
console.error('Invalid token format', error);
handleLogout();
}
};

const handleLogout = () => {
if (isLoggingOut.current) return;
isLoggingOut.current = true;

logout();
showError(
'๋กœ๊ทธ์ธ ์œ ์ง€ ์‹œ๊ฐ„์ด ๋งŒ๋ฃŒ๋˜์–ด ์ž๋™์œผ๋กœ ๋กœ๊ทธ์•„์›ƒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.',
'๋กœ๊ทธ์•„์›ƒ ์•ˆ๋‚ด',
() => {
window.location.href = '/login';
},
'๋‹ค์‹œ ๋กœ๊ทธ์ธํ•˜๊ธฐ'
);
};

// 1. ๋งˆ์šดํŠธ ์‹œ์ ์— ์ฒดํฌ & ํƒ€์ด๋จธ ์„ค์ •
let timerId = checkAndLogout();

// 2. ๋ธŒ๋ผ์šฐ์ € ํƒญ ํ™œ์„ฑํ™”(ํฌ์ปค์Šค ๋ณต๊ท€) ์‹œ์ ์— ๋‹ค์‹œ ์ฒดํฌ
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') {
if (timerId) clearTimeout(timerId);
timerId = checkAndLogout();
}
};

document.addEventListener('visibilitychange', handleVisibilityChange);

return () => {
if (timerId) clearTimeout(timerId);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [token, logout, showError]);
}
21 changes: 17 additions & 4 deletions src/hooks/useErrorStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,30 @@ interface ErrorState {
isOpen: boolean;
title: string;
message: string;
confirmText: string;
onConfirm?: () => void; // ํ™•์ธ ๋ฒ„ํŠผ ํด๋ฆญ ์‹œ ์‹คํ–‰ํ•  ์ฝœ๋ฐฑ ํ•จ์ˆ˜
showError: (message: string, title?: string, onConfirm?: () => void) => void;
showError: (
message: string,
title?: string,
onConfirm?: () => void,
confirmText?: string
) => void;
closeError: () => void;
}

export const useErrorStore = create<ErrorState>((set) => ({
isOpen: false,
title: '์˜ค๋ฅ˜ ๋ฐœ์ƒ',
message: '',
confirmText: 'ํ™•์ธ',
onConfirm: undefined,
showError: (message, title = '์˜ค๋ฅ˜ ๋ฐœ์ƒ', onConfirm) =>
set({ isOpen: true, message, title, onConfirm }),
closeError: () => set({ isOpen: false, message: '', onConfirm: undefined }),
showError: (
message,
title = '์˜ค๋ฅ˜ ๋ฐœ์ƒ',
onConfirm,
confirmText = 'ํ™•์ธ'
) => {
set({ isOpen: true, message, title, onConfirm, confirmText });
},
closeError: () => set({ isOpen: false }), // ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ ์ค‘ ๊นœ๋นก์ž„ ๋ฐฉ์ง€๋ฅผ ์œ„ํ•ด ๋‚ด์šฉ ๋ณด์กด
}));
15 changes: 14 additions & 1 deletion src/mocks/handlers/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,21 @@ export const authHandlers = [

if (user) {
await delay(500); // ์‹ค์ œ ์„œ๋ฒ„์ฒ˜๋Ÿผ ์•ฝ๊ฐ„์˜ ์ง€์—ฐ ์‹œ๊ฐ„ ์ถ”๊ฐ€

// ํ…Œ์ŠคํŠธ๋ฅผ ์œ„ํ•ด ๋งŒ๋ฃŒ 5๋ถ„ 10์ดˆ(310์ดˆ)์งœ๋ฆฌ ์ง„์งœ JWT ๊ตฌ์กฐ ๋ฐœ๊ธ‰ (Base64 ์ธ์ฝ”๋”ฉ)
// Header: {"alg":"HS256","typ":"JWT"} -> eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
const header = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9';
const payloadObj = {
sub: user.id.toString(),
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 86400, // ํ˜„์žฌ ์‹œ๊ฐ„ + 24์‹œ๊ฐ„ ๋งŒ๋ฃŒ
};
const payload = btoa(JSON.stringify(payloadObj));
const signature = 'mock-signature';
const mockJwtToken = `${header}.${payload}.${signature}`;

return HttpResponse.json({
token: user.token,
token: mockJwtToken,
});
}

Expand Down
37 changes: 24 additions & 13 deletions src/mocks/handlers/user.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { GetMeResponse } from '@/types/users';
import { http, HttpResponse, delay } from 'msw';
import { http, HttpResponse } from 'msw';
import { userDB } from '../db/user.db';
import type { MockUser } from '../types';

Expand All @@ -22,24 +22,35 @@ export const userHandlers = [
);
}

// ํ—ค๋”์—์„œ ํ† ํฐ๊ฐ’๋งŒ ์ถ”์ถœ (์˜ˆ: "Bearer mock-token-1" -> "mock-token-1")
// ํ—ค๋”์—์„œ ํ† ํฐ๊ฐ’๋งŒ ์ถ”์ถœ
const requestToken = authHeader.split(' ')[1];

// ํ† ํฐ์„ ๊ธฐ๋ฐ˜์œผ๋กœ DB์—์„œ ์œ ์ € ์ฐพ๊ธฐ
const user = userDB.find((u: MockUser) => u.token === requestToken);
try {
// ํด๋ผ์ด์–ธํŠธ์—์„œ ๋ณด๋‚ธ JWT ๋””์ฝ”๋”ฉ (base64 ๋ถ€๋ถ„ ์ฐพ์•„์„œ ํŒŒ์‹ฑ)
const payloadBase64 = requestToken.split('.')[1];
const payloadJson = atob(payloadBase64);
const decoded = JSON.parse(payloadJson);
const userId = Number(decoded.sub);

if (!user) {
return new HttpResponse(
{ message: 'ํ•ด๋‹น ์œ ์ €๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.' },
{ status: 404 }
);
}
// ํ† ํฐ payload์˜ sub(์œ ์ € ID)๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ DB์—์„œ ์œ ์ € ์ฐพ๊ธฐ
const user = userDB.find((u: MockUser) => u.id === userId);

await delay(300);
if (!user) {
return new HttpResponse(
{ message: 'ํ•ด๋‹น ์œ ์ €๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.' },
{ status: 404 }
);
}

const { password, ...userResponse } = user as MockUser;
const { password, ...userResponse } = user as MockUser;

return HttpResponse.json(userResponse as GetMeResponse);
return HttpResponse.json(userResponse as GetMeResponse);
} catch (_error) {
return new HttpResponse(
{ message: '์œ ํšจํ•˜์ง€ ์•Š์€ ํ† ํฐ์ž…๋‹ˆ๋‹ค.' },
{ status: 401 }
);
}
}
),
];
8 changes: 8 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6775,6 +6775,13 @@ __metadata:
languageName: node
linkType: hard

"jwt-decode@npm:^4.0.0":
version: 4.0.0
resolution: "jwt-decode@npm:4.0.0"
checksum: 10c0/de75bbf89220746c388cf6a7b71e56080437b77d2edb29bae1c2155048b02c6b8c59a3e5e8d6ccdfd54f0b8bda25226e491a4f1b55ac5f8da04cfbadec4e546c
languageName: node
linkType: hard

"kleur@npm:^3.0.3":
version: 3.0.3
resolution: "kleur@npm:3.0.3"
Expand Down Expand Up @@ -7232,6 +7239,7 @@ __metadata:
class-variance-authority: "npm:^0.7.1"
clsx: "npm:^2.1.1"
framer-motion: "npm:^12.33.0"
jwt-decode: "npm:^4.0.0"
knip: "npm:^5.59.1"
lucide-react: "npm:^0.562.0"
motion: "npm:^12.29.2"
Expand Down