diff --git a/src/_apis/auth/logout-apis.ts b/src/_apis/auth/logout-apis.ts
index e69de29b..cbc45f2d 100644
--- a/src/_apis/auth/logout-apis.ts
+++ b/src/_apis/auth/logout-apis.ts
@@ -0,0 +1,10 @@
+import { fetchApi } from '@/src/utils/api';
+
+export function logout(): Promise {
+ return fetchApi('/auths/logout', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+}
diff --git a/src/_apis/auth/reissue-apis.ts b/src/_apis/auth/reissue-apis.ts
new file mode 100644
index 00000000..48f393a0
--- /dev/null
+++ b/src/_apis/auth/reissue-apis.ts
@@ -0,0 +1,18 @@
+import { fetchApi } from '@/src/utils/api';
+
+export async function reissue(): Promise<{ token: string | null }> {
+ return fetchApi<{ headers: Headers }>(
+ '/auths/reissue',
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+ 5000,
+ true,
+ ).then((response) => {
+ const token = response.headers.get('Authorization');
+ return { token };
+ });
+}
diff --git a/src/_queries/auth/login-queries.ts b/src/_queries/auth/login-queries.ts
index 7b65170b..b19234fe 100644
--- a/src/_queries/auth/login-queries.ts
+++ b/src/_queries/auth/login-queries.ts
@@ -1,8 +1,8 @@
import { useMutation } from '@tanstack/react-query';
import { login } from '@/src/_apis/auth/login-apis';
+import { useHandleAuthSuccess } from '@/src/hooks/use-handle-auth-success';
import { ApiError } from '@/src/utils/api';
import { LoginRequest, LoginResponse } from '@/src/types/auth';
-import { useHandleAuthSuccess } from './use-handle-auth-success';
export function usePostLoginQuery() {
const handleAuthSuccess = useHandleAuthSuccess();
diff --git a/src/_queries/auth/logout-queries.ts b/src/_queries/auth/logout-queries.ts
index e69de29b..17a1a4bb 100644
--- a/src/_queries/auth/logout-queries.ts
+++ b/src/_queries/auth/logout-queries.ts
@@ -0,0 +1,9 @@
+import { useMutation } from '@tanstack/react-query';
+import { logout } from '@/src/_apis/auth/logout-apis';
+import { ApiError } from '@/src/utils/api';
+
+export function usePostLogoutQuery() {
+ return useMutation({
+ mutationFn: logout,
+ });
+}
diff --git a/src/_queries/auth/reissue-queries.ts b/src/_queries/auth/reissue-queries.ts
new file mode 100644
index 00000000..ac41cdf6
--- /dev/null
+++ b/src/_queries/auth/reissue-queries.ts
@@ -0,0 +1,15 @@
+import { useMutation } from '@tanstack/react-query';
+import { reissue } from '@/src/_apis/auth/reissue-apis';
+import { useHandleAuthSuccess } from '@/src/hooks/use-handle-auth-success';
+import { ApiError } from '@/src/utils/api';
+
+export function usePostReissueQuery() {
+ const handleAuthSuccess = useHandleAuthSuccess();
+
+ return useMutation<{ token: string | null }, ApiError>({
+ mutationFn: reissue,
+ onSuccess: async (response) => {
+ await handleAuthSuccess(response.token);
+ },
+ });
+}
diff --git a/src/_queries/auth/signup-queries.ts b/src/_queries/auth/signup-queries.ts
index eb39a5a1..c6c20217 100644
--- a/src/_queries/auth/signup-queries.ts
+++ b/src/_queries/auth/signup-queries.ts
@@ -1,8 +1,8 @@
import { useMutation } from '@tanstack/react-query';
import { signup } from '@/src/_apis/auth/signup-apis';
+import { useHandleAuthSuccess } from '@/src/hooks/use-handle-auth-success';
import { ApiError } from '@/src/utils/api';
import { SignupRequest, SignupResponse } from '@/src/types/auth';
-import { useHandleAuthSuccess } from './use-handle-auth-success';
export function usePostSignupQuery() {
const handleAuthSuccess = useHandleAuthSuccess();
diff --git a/src/_queries/auth/user-apis.ts b/src/_queries/auth/user-apis.ts
deleted file mode 100644
index cc339273..00000000
--- a/src/_queries/auth/user-apis.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { getUser } from '@/src/_apis/auth/user-apis';
-import { transformKeysToCamel } from '@/src/utils/transform-keys';
-import { User } from '@/src/types/auth';
-
-export function getUserQuery() {
- return {
- queryKey: ['user'],
- queryFn: getUser,
- select: (data: User) => transformKeysToCamel(data),
- };
-}
diff --git a/src/_queries/auth/user-queries.ts b/src/_queries/auth/user-queries.ts
new file mode 100644
index 00000000..687c1b06
--- /dev/null
+++ b/src/_queries/auth/user-queries.ts
@@ -0,0 +1,12 @@
+import { useQuery } from '@tanstack/react-query';
+import { getUser } from '@/src/_apis/auth/user-apis';
+import { authStore } from '@/src/store/use-auth-store';
+
+export function useUser() {
+ const { token } = authStore.getState();
+ return useQuery({
+ queryKey: ['user'],
+ queryFn: getUser,
+ enabled: !!token,
+ });
+}
diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx
index 3cef2e25..9865f6e0 100644
--- a/src/app/(auth)/layout.tsx
+++ b/src/app/(auth)/layout.tsx
@@ -1,3 +1,4 @@
+import { Suspense } from 'react';
import type { Metadata } from 'next';
import Image from 'next/image';
import '@mantine/core/styles.css';
@@ -30,7 +31,9 @@ export default function AuthLayout({
함께할 사람이없나요? 지금 크루에 참여해보세요
- {children}
+
+ {children}
+
diff --git a/src/app/(auth)/login/_component/login-form.tsx b/src/app/(auth)/login/_component/login-form.tsx
index 8fe169b5..e634f835 100644
--- a/src/app/(auth)/login/_component/login-form.tsx
+++ b/src/app/(auth)/login/_component/login-form.tsx
@@ -1,6 +1,7 @@
-import { UseFormReturn } from 'react-hook-form';
-import { Button } from '@mantine/core';
-import { useDebouncedCallback } from '@mantine/hooks';
+import { useEffect } from 'react';
+import { UseFormReturn, useWatch } from 'react-hook-form';
+import { useDebouncedValue } from '@mantine/hooks';
+import Button from '@/src/components/common/input/button';
import PasswordInput from '@/src/components/common/input/password-input';
import TextInput from '@/src/components/common/input/text-input';
@@ -18,15 +19,29 @@ export default function LoginForm({ onSubmit, formMethods }: LoginFormProps) {
register,
handleSubmit,
trigger,
+ control,
formState: { errors, isValid },
} = formMethods;
- const debouncedTrigger = useDebouncedCallback(async (field: keyof LoginFormValues) => {
- await trigger(field);
- }, 1000);
+ const email = useWatch({ control, name: 'email' });
+ const password = useWatch({ control, name: 'password' });
+
+ const [debouncedEmail, cancelDebouncedEmail] = useDebouncedValue(email, 1000);
+ const [debouncedPassword, cancelDebouncedPassword] = useDebouncedValue(password, 1000);
+
+ useEffect(() => {
+ if (debouncedEmail) trigger('email');
+ if (debouncedPassword) trigger('password');
+ }, [debouncedEmail, debouncedPassword, trigger]);
+
+ const handleFormSubmit = handleSubmit(async (data) => {
+ onSubmit(data);
+ cancelDebouncedEmail();
+ cancelDebouncedPassword();
+ });
return (
-
크루 만들기
diff --git a/src/app/(crew)/crew/detail/[id]/_components/create-gathering/index.tsx b/src/app/(crew)/crew/detail/[id]/_components/create-gathering/index.tsx
index 6446ed09..e6c6881c 100644
--- a/src/app/(crew)/crew/detail/[id]/_components/create-gathering/index.tsx
+++ b/src/app/(crew)/crew/detail/[id]/_components/create-gathering/index.tsx
@@ -2,13 +2,13 @@
import { useRouter } from 'next/navigation';
import { useDisclosure } from '@mantine/hooks';
-import { useAuthStore } from '@/src/store/use-auth-store';
+import { useAuth } from '@/src/hooks/use-auth';
import CreateGatheringModalContainer from '@/src/app/(crew)/crew/detail/[id]/_components/create-gathering/create-gathering-modal/container';
import Button from '@/src/components/common/input/button';
import { CreateGatheringFormTypes } from '@/src/types/gathering-data';
export default function CreateGathering({ crewId }: { crewId: number }) {
- const { isAuth } = useAuthStore();
+ const { isAuth } = useAuth();
const router = useRouter();
const [opened, { open, close }] = useDisclosure(false);
diff --git a/src/app/(crew)/crew/detail/[id]/_components/detail-crew-container.tsx b/src/app/(crew)/crew/detail/[id]/_components/detail-crew-container.tsx
index a73f9249..41cf87a9 100644
--- a/src/app/(crew)/crew/detail/[id]/_components/detail-crew-container.tsx
+++ b/src/app/(crew)/crew/detail/[id]/_components/detail-crew-container.tsx
@@ -5,8 +5,8 @@ import { toast } from 'react-toastify';
import { useRouter } from 'next/navigation';
import { useDisclosure } from '@mantine/hooks';
import { cancelCrew, joinCrew, leaveCrew } from '@/src/_apis/crew/crew-detail-apis';
+import { useUser } from '@/src/_queries/auth/user-queries';
import { useGetCrewDetailQuery } from '@/src/_queries/crew/crew-detail-queries';
-import { useAuthStore } from '@/src/store/use-auth-store';
import { ApiError } from '@/src/utils/api';
import ConfirmCancelModal from '@/src/components/common/modal/confirm-cancel-modal';
import { User } from '@/src/types/auth';
@@ -24,7 +24,7 @@ export default function DetailCrew({ id }: DetailCrewContainerProps) {
useDisclosure();
const router = useRouter();
- const { user } = useAuthStore();
+ const { data: user } = useUser();
const isDataWrappedUser = (value: unknown): value is { data: User } => {
return typeof value === 'object' && value !== null && 'data' in value;
diff --git a/src/app/(crew)/my-page/_components/profile-card/container.tsx b/src/app/(crew)/my-page/_components/profile-card/container.tsx
index e4747c23..8b65ece3 100644
--- a/src/app/(crew)/my-page/_components/profile-card/container.tsx
+++ b/src/app/(crew)/my-page/_components/profile-card/container.tsx
@@ -8,20 +8,23 @@ import {
resetUserProfileImage,
updateUserProfile,
} from '@/src/_apis/auth/user-apis';
-import { useAuthStore } from '@/src/store/use-auth-store';
-import { User } from '@/src/types/auth';
+import { useUser } from '@/src/_queries/auth/user-queries';
+import { useAuth } from '@/src/hooks/use-auth';
import ProfileCardPresenter from './presenter';
export default function ProfileCard() {
const router = useRouter();
- const { isAuth, rehydrated, setUser } = useAuthStore();
- const [user, setLocalUser] = useState(null);
+ const { isAuth } = useAuth();
+ const { data: user } = useUser();
+
+ // const { rehydrated, setUser } = useAuthStore();
+ // const [user, setLocalUser] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [profileImageUrl, setProfileImageUrl] = useState('');
useEffect(() => {
const checkAuthAndLoadUser = async () => {
- if (!rehydrated) return; // 상태 복원이 완료되지 않았으면 대기
+ // if (!rehydrated) return; // 상태 복원이 완료되지 않았으면 대기
if (!isAuth) {
router.push('/login'); // 인증되지 않은 경우 리디렉션
@@ -32,8 +35,8 @@ export default function ProfileCard() {
try {
const updatedUser = await fetchUpdatedUser();
- setLocalUser(updatedUser);
- setUser(updatedUser);
+ // setLocalUser(updatedUser);
+ // setUser(updatedUser);
setProfileImageUrl(updatedUser.profileImageUrl);
} catch {
toast.error('유저 정보를 가져오는 데 실패했습니다.');
@@ -43,9 +46,10 @@ export default function ProfileCard() {
};
checkAuthAndLoadUser();
- }, [isAuth, rehydrated, router, setUser]);
+ // }, [isAuth, rehydrated, router, setUser]);
+ }, [isAuth, router]);
- if (!rehydrated) return null;
+ // if (!rehydrated) return null;
if (!isAuth) return null;
if (isLoading) return 로딩 중...
;
if (!user) return 유저 정보를 불러오지 못했습니다.
;
@@ -71,7 +75,7 @@ export default function ProfileCard() {
const updatedUser = await fetchUpdatedUser();
const newProfileImageUrl = `${updatedUser.profileImageUrl}?timestamp=${new Date().getTime()}`;
setProfileImageUrl(newProfileImageUrl);
- setUser({ ...updatedUser, profileImageUrl: newProfileImageUrl });
+ // setUser({ ...updatedUser, profileImageUrl: newProfileImageUrl });
} catch (error) {
toast.error('파일 업로드에 실패했습니다.');
}
@@ -85,8 +89,8 @@ export default function ProfileCard() {
await resetUserProfileImage();
const updatedUser = await fetchUpdatedUser();
setProfileImageUrl(''); // 초기화된 이미지 반영
- setLocalUser(updatedUser);
- setUser(updatedUser);
+ // setLocalUser(updatedUser);
+ // setUser(updatedUser);
toast.success('프로필 이미지가 초기화되었습니다.');
} catch (error) {
toast.error('프로필 이미지 초기화에 실패했습니다.');
diff --git a/src/components/client-provider.tsx b/src/components/client-provider.tsx
index 91a397cc..80d3d59c 100644
--- a/src/components/client-provider.tsx
+++ b/src/components/client-provider.tsx
@@ -1,19 +1,45 @@
'use client';
-import { ReactNode } from 'react';
-import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ReactNode, useState } from 'react';
+import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental';
+import { reissue } from '../_apis/auth/reissue-apis';
+import { useAuthStore } from '../store/use-auth-store';
export default function ClientProvider({ children }: { children: ReactNode }) {
- const queryClient = new QueryClient({
- defaultOptions: {
- queries: {
- // 서버 렌더링 시 데이터를 새로 고침하지 않도록 설정
- staleTime: 60 * 1000, // 1분 동안 데이터 유지
- },
- },
- });
+ const setToken = useAuthStore((state) => state.setToken);
+
+ const [queryClient] = useState(
+ () =>
+ new QueryClient({
+ defaultOptions: {
+ queries: {
+ // 서버 렌더링 시 데이터를 새로 고침하지 않도록 설정
+ staleTime: 60 * 1000, // 1분 동안 데이터 유지
+ retry: (failureCount, error) => {
+ if (error.message === '토큰이 올바르지 않습니다.') {
+ return false;
+ }
+ return failureCount <= 1;
+ },
+ },
+ },
+ // accessToken 에러 -> reissue() -> refetch()
+ queryCache: new QueryCache({
+ onError: async (error, query) => {
+ if (error.message === '토큰이 올바르지 않습니다.') {
+ const { token } = await reissue();
+ if (token) setToken(token.replace(/^Bearer\s/, ''));
+ await Promise.all([
+ queryClient.refetchQueries({ queryKey: query.queryKey }),
+ queryClient.refetchQueries({ queryKey: ['user'] }),
+ ]);
+ }
+ },
+ }),
+ }),
+ );
return (
diff --git a/src/components/common/header/container.tsx b/src/components/common/header/container.tsx
index 00ca1dcd..7041776b 100644
--- a/src/components/common/header/container.tsx
+++ b/src/components/common/header/container.tsx
@@ -1,7 +1,8 @@
'use client';
-import { useRouter } from 'next/navigation';
-import { useAuthStore } from '@/src/store/use-auth-store';
+import { useUser } from '@/src/_queries/auth/user-queries';
+import { useAuth } from '@/src/hooks/use-auth';
+import { useLogout } from '@/src/hooks/use-logout';
import HeaderPresenter from '@/src/components/common/header/presenter';
/**
@@ -12,14 +13,9 @@ import HeaderPresenter from '@/src/components/common/header/presenter';
*/
export default function Header() {
- const { isAuth, user, logout } = useAuthStore(); // user 정보 가져오기
-
- const router = useRouter();
-
- const handleLogout = () => {
- logout();
- router.push('/');
- };
+ const { handleLogout } = useLogout();
+ const { isAuth } = useAuth();
+ const { data: user } = useUser();
return (
diff --git a/src/components/common/header/header.stories.tsx b/src/components/common/header/header.stories.tsx
index 143c1e5f..29e3ed69 100644
--- a/src/components/common/header/header.stories.tsx
+++ b/src/components/common/header/header.stories.tsx
@@ -1,5 +1,7 @@
+import { useState } from 'react';
import type { Meta, StoryFn } from '@storybook/react';
-import { useAuthStore } from '@/src/store/use-auth-store';
+import { QueryClient, QueryClientProvider, useQueryClient } from '@tanstack/react-query';
+import { useAuth } from '@/src/hooks/use-auth';
import Header from '@/src/components/common/header/container';
const meta: Meta = {
@@ -15,32 +17,10 @@ const meta: Meta = {
export default meta;
function Template() {
- const { isAuth, login, logout, setUser } = useAuthStore();
- const testToken = 'test token';
- const testUser = {
- id: 1,
- nickname: '크루크루',
- email: 'john@example.com',
- profileImageUrl: 'https://i.pinimg.com/736x/3f/e4/f4/3fe4f4f3aee36ec57aa072cce2e016b3.jpg',
- };
-
- const toggleAuth = () => {
- if (isAuth) {
- logout();
- } else {
- login(testToken);
- setUser(testUser);
- }
- };
-
return (
-
+
-
-
+
);
}
diff --git a/src/components/common/header/presenter.tsx b/src/components/common/header/presenter.tsx
index 4212fe81..37181812 100644
--- a/src/components/common/header/presenter.tsx
+++ b/src/components/common/header/presenter.tsx
@@ -43,8 +43,10 @@ export default function HeaderPresenter({
{links.map(({ href, label }) => (
{label}
@@ -76,7 +78,6 @@ export default function HeaderPresenter({
)}
- {/* */}
);
}
diff --git a/src/hooks/use-auth.ts b/src/hooks/use-auth.ts
new file mode 100644
index 00000000..7073f4e8
--- /dev/null
+++ b/src/hooks/use-auth.ts
@@ -0,0 +1,7 @@
+import { useUser } from '../_queries/auth/user-queries';
+
+export function useAuth() {
+ const { data: user } = useUser();
+ const isAuth = !!user;
+ return { isAuth };
+}
diff --git a/src/_queries/auth/use-handle-auth-success.ts b/src/hooks/use-handle-auth-success.ts
similarity index 54%
rename from src/_queries/auth/use-handle-auth-success.ts
rename to src/hooks/use-handle-auth-success.ts
index 1352948f..e2702e8c 100644
--- a/src/_queries/auth/use-handle-auth-success.ts
+++ b/src/hooks/use-handle-auth-success.ts
@@ -1,11 +1,7 @@
-import { useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '@/src/store/use-auth-store';
-import { User } from '@/src/types/auth';
-import { getUserQuery } from './user-apis';
export function useHandleAuthSuccess() {
- const queryClient = useQueryClient();
- const { login, setUser } = useAuthStore();
+ const setToken = useAuthStore((state) => state.setToken);
return async function handleAuthSuccess(token: string | null) {
if (!token) {
@@ -14,9 +10,7 @@ export function useHandleAuthSuccess() {
try {
const accessToken = token.replace(/^Bearer\s/, '');
- login(accessToken);
- const user: User = await queryClient.fetchQuery(getUserQuery());
- setUser(user);
+ setToken(accessToken);
} catch (error) {
throw new Error('사용자 상태 업데이트 실패');
}
diff --git a/src/hooks/use-logout.ts b/src/hooks/use-logout.ts
new file mode 100644
index 00000000..9a40d9a5
--- /dev/null
+++ b/src/hooks/use-logout.ts
@@ -0,0 +1,24 @@
+import { useRouter } from 'next/navigation';
+import { useQueryClient } from '@tanstack/react-query';
+import { usePostLogoutQuery } from '../_queries/auth/logout-queries';
+import { useAuthStore } from '../store/use-auth-store';
+
+export function useLogout() {
+ const { mutate: postLogout } = usePostLogoutQuery();
+ const clearToken = useAuthStore((state) => state.clearToken);
+ const queryClient = useQueryClient();
+
+ const router = useRouter();
+
+ return {
+ handleLogout: () => {
+ postLogout(undefined, {
+ onSuccess: () => {
+ router.push('/');
+ clearToken();
+ queryClient.removeQueries({ queryKey: ['user'] });
+ },
+ });
+ },
+ };
+}
diff --git a/src/store/use-auth-store.tsx b/src/store/use-auth-store.tsx
index 67a2881c..ff3feb50 100644
--- a/src/store/use-auth-store.tsx
+++ b/src/store/use-auth-store.tsx
@@ -1,47 +1,29 @@
-import { create } from 'zustand';
+import { createStore, useStore } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
-import { User } from '@/src/types/auth';
interface AuthState {
- isAuth: boolean;
- user: User | null;
token: string | null;
- rehydrated: boolean; // 상태 복원 여부
- login: (token: string) => void;
- logout: () => void;
- setUser: (user: User) => void;
+ setToken: (token: string) => void;
+ clearToken: () => void;
}
-export const useAuthStore = create()(
+export const authStore = createStore()(
persist(
(set) => ({
- isAuth: false,
- user: null,
token: null,
- rehydrated: false,
- login: (token) => set({ isAuth: true, token }),
- logout: () =>
- set({
- isAuth: false,
- user: null,
- token: null,
- }),
- setUser: (user: User) => set({ user, isAuth: true }),
+ setToken: (token) => set({ token }),
+ clearToken: () => set({ token: null }),
}),
{
name: 'auth-storage',
storage: createJSONStorage(() => sessionStorage),
- onRehydrateStorage: () => (state) => {
- if (state) {
- // eslint-disable-next-line no-param-reassign
- state.rehydrated = true; // 상태 복원 완료
- }
- },
partialize: (state) => ({
- isAuth: state.isAuth,
token: state.token,
- user: state.user,
}),
},
),
);
+
+export function useAuthStore(selector: (state: AuthState) => T) {
+ return useStore(authStore, selector);
+}
diff --git a/src/utils/api.ts b/src/utils/api.ts
index f47c8491..a76dfd2f 100644
--- a/src/utils/api.ts
+++ b/src/utils/api.ts
@@ -1,4 +1,4 @@
-import { useAuthStore } from '@/src/store/use-auth-store';
+import { authStore } from '@/src/store/use-auth-store';
export class ApiError extends Error {
detail: { validationErrors: Record } = { validationErrors: {} };
@@ -24,7 +24,7 @@ export async function fetchApi(
const controller = new AbortController();
const { signal } = controller;
const id = setTimeout(() => controller.abort(), timeout);
- const { token } = useAuthStore.getState();
+ const { token } = authStore.getState();
const fetchOptions: RequestInit = {
...options,
signal,