diff --git a/app/(my)/my-page/page.tsx b/app/(my)/my-page/page.tsx index 13ec9bfa..b473e057 100644 --- a/app/(my)/my-page/page.tsx +++ b/app/(my)/my-page/page.tsx @@ -1,17 +1,13 @@ -import { redirect } from 'next/navigation'; -import { getUserProfile } from '@/entities/user/api/get-user-profile'; +import { getUserProfileInServer } from '@/entities/user/api/get-user-profile.server'; import Profile from '@/features/my-page/ui/profile'; import ProfileInfo from '@/features/my-page/ui/profile-info'; -import { getLoginUserId } from '@/shared/lib/get-login-user'; +import { getServerCookie } from '@/shared/lib/server-cookie'; export default async function MyPage() { - const memberId = await getLoginUserId(); + const memberIdStr = await getServerCookie('memberId'); + const memberId = Number(memberIdStr); - if (!memberId) { - redirect('/login'); - } - - const userProfile = await getUserProfile(memberId); + const userProfile = await getUserProfileInServer(memberId); return (
diff --git a/app/login/page.tsx b/app/login/page.tsx index f8e28d58..d58f679d 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -7,9 +7,6 @@ export const metadata: Metadata = { description: '로그인 페이지', }; -// 랜딩페이지에서 로그인 모달이 뜨는 것으로 파악 export default function LoginPage() { - console.log("NEXT_PUBLIC_API_BASE_URL", process.env.NEXT_PUBLIC_API_BASE_URL) - return ; } diff --git a/app/page.tsx b/app/page.tsx index 7c83339d..80303a27 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,7 +1,5 @@ import { Metadata } from 'next'; -import { redirect } from 'next/navigation'; import StudyCard from '@/features/study/ui/study-card'; -import { getLoginUserId } from '@/shared/lib/get-login-user'; import Banner from '@/widgets/home/banner'; import Sidebar from '@/widgets/home/sidebar'; @@ -11,12 +9,6 @@ export const metadata: Metadata = { }; export default async function Home() { - const memberId = await getLoginUserId(); - - if (!memberId) { - redirect('/login'); - } - return (
diff --git a/app/redirection/page.tsx b/app/redirection/page.tsx index 279307b3..97e271c1 100644 --- a/app/redirection/page.tsx +++ b/app/redirection/page.tsx @@ -49,13 +49,13 @@ function RedirectionContent() { handleRedirection().catch(console.error); }, [searchParams, router, queryClient]); // 의존성 추가 - return
처리중...
; + return <>; } // useSearchParams() should be wrapped in a suspense boundary export default function RedirectionPage() { return ( - 로딩중...
}> + }> ); diff --git a/app/sign-up/page.tsx b/app/sign-up/page.tsx index 42e39f7d..65380ff7 100644 --- a/app/sign-up/page.tsx +++ b/app/sign-up/page.tsx @@ -3,8 +3,5 @@ import Landing from '@/features/auth/ui/landing'; // 랜딩페이지에서 회원가입 모달이 뜨는 것으로 파악 export default function SignupPage() { - // TODO : URL 에서 토큰 파싱후 - console.log('회원가입 페이지'); - return ; } diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 00000000..2e47f1f0 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,114 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { getServerCookie } from '@/shared/lib/server-cookie'; +import { isNumeric } from '@/shared/lib/validation'; +import { isApiError } from '@/shared/tanstack-query/api-error'; + +const verifyAccessToken = async (accessToken: string) => { + try { + // Access token로 memberId만 반환하는 api + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/auth/me`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + ); + + if (!response.ok) { + const errorData = await response.json(); + + if (isApiError(errorData) && errorData.errorCode === 'AUTH001') { + return { state: 'invalid' }; + } + + return { state: 'unknownError' }; + } + + const data: { content: number } = await response.json(); + + return { state: 'valid', memberId: data.content }; + } catch (error) { + return { state: 'unknownError' }; + } +}; + +const refreshAccessToken = async () => { + try { + const refreshToken = await getServerCookie('refresh_token'); + + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/auth/access-token/refresh`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + cookie: `refresh_token=${refreshToken}`, // 쿠키를 수동으로 전송 + }, + }, + ); + + const data: { content: { accessToken: string } } = await response.json(); + + return data.content.accessToken; + } catch (error) { + return null; + } +}; + +export async function middleware(request: NextRequest) { + const accessToken = request.cookies.get('accessToken')?.value; + const memberId = request.cookies.get('memberId')?.value; + + const hasAccessToken = request.cookies.has('accessToken'); + const hasMemberId = request.cookies.has('memberId') && isNumeric(memberId); + + if ( + !hasAccessToken || + (request.nextUrl.pathname !== '/sign-up' && !hasMemberId) // 회원가입 페이지가 아닌 경우 memberId 체크 (회원가입 하지 않을 경우, memberId는 null) + ) { + const loginUrl = new URL('/login', request.url); + + return NextResponse.redirect(loginUrl); + } + + // access token 갱신 필요 여부 확인 + const verifyResponse = await verifyAccessToken(accessToken); + + const response = NextResponse.next(); + + // access token이 유효하지 않을 경우 -> 갱신 + if (verifyResponse.state === 'invalid') { + const newAccessToken = await refreshAccessToken(); + + if (newAccessToken) { + // 갱신 성공 + response.cookies.set('accessToken', newAccessToken, { + secure: true, + sameSite: 'strict', + path: '/', + }); + } else { + // 갱신 실패 + const loginUrl = new URL('/login', request.url); + + return NextResponse.redirect(loginUrl); + } + } + + // 이미 회원가입 완료 했는데, sign-up 페이지에 진입할 경우 메인 페이지로 리다이렉트 + if (request.nextUrl.pathname === '/sign-up' && hasMemberId) { + const mainUrl = new URL('/', request.url); + + return NextResponse.redirect(mainUrl); + } + + return response; +} + +// middleware가 적용될 경로 설정 +export const config = { + matcher: ['/', '/my-page', '/my-study', '/my-study-review', '/sign-up'], +}; diff --git a/src/entities/user/api/get-user-profile.server.ts b/src/entities/user/api/get-user-profile.server.ts new file mode 100644 index 00000000..3e826284 --- /dev/null +++ b/src/entities/user/api/get-user-profile.server.ts @@ -0,0 +1,10 @@ +import { axiosServerInstance } from '@/shared/tanstack-query/axios.server'; +import { GetUserProfileResponse } from './types'; + +export const getUserProfileInServer = async ( + memberId: number, +): Promise => { + const res = await axiosServerInstance.get(`/members/${memberId}/profile`); + + return res.data.content; +}; diff --git a/src/features/auth/api/auth.ts b/src/features/auth/api/auth.ts index 26828943..c61a3435 100644 --- a/src/features/auth/api/auth.ts +++ b/src/features/auth/api/auth.ts @@ -8,7 +8,6 @@ import { // 회원가입 요청 API export async function signUp(data: any) { const res = await axiosInstance.post('/members', data); - console.log('signUp res', res); return res.data; } @@ -30,7 +29,6 @@ export async function uploadProfileImage( // 멤버 ID 조회 API export async function getMemberId() { const res = await axiosInstance.get(`/auth/me`); - console.log('getMemberId res', res); return res.data; } diff --git a/src/features/auth/model/use-auth-mutation.ts b/src/features/auth/model/use-auth-mutation.ts index 4ec5aa3c..b81c0725 100644 --- a/src/features/auth/model/use-auth-mutation.ts +++ b/src/features/auth/model/use-auth-mutation.ts @@ -1,7 +1,11 @@ // 데이터 변경(Mutation) 을 담당하는 커스텀 훅 -import { useMutation } from '@tanstack/react-query'; +import { sendGTMEvent } from '@next/third-parties/google'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useRouter } from 'next/navigation'; import { logout, signUp, uploadProfileImage } from '@/features/auth/api/auth'; +import { hashValue } from '@/shared/lib/hash'; +import { deleteCookie, getCookie } from '@/shared/tanstack-query/cookie'; import { SignUpResponse } from './types'; // 회원가입 요청 커스텀 훅 @@ -27,7 +31,29 @@ export function useUploadProfileImageMutation() { } export const useLogoutMutation = () => { - return useMutation({ + const queryClient = useQueryClient(); + const router = useRouter(); + + return useMutation({ mutationFn: logout, + onSuccess: () => { + const memberId = getCookie('memberId'); + + if (memberId) + sendGTMEvent({ + event: 'custom_member_logout', + dl_timestamp: new Date().toISOString(), + dl_member_id: hashValue(memberId), + }); + + deleteCookie('accessToken'); + deleteCookie('memberId'); + deleteCookie('socialImageURL'); + + queryClient.clear(); + + router.push('/login'); + router.refresh(); + }, }); }; diff --git a/src/features/auth/ui/header-user-dropdown.tsx b/src/features/auth/ui/header-user-dropdown.tsx index 94292a47..9ccd7be5 100644 --- a/src/features/auth/ui/header-user-dropdown.tsx +++ b/src/features/auth/ui/header-user-dropdown.tsx @@ -1,48 +1,21 @@ 'use client'; -import { sendGTMEvent } from '@next/third-parties/google'; -import { useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; -import { hashValue } from '@/shared/lib/hash'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/shared/shadcn/ui/dropdown-menu'; -import { deleteCookie, getCookie } from '@/shared/tanstack-query/cookie'; import UserAvatar from '@/shared/ui/avatar'; -import { logout } from '../api/auth'; +import { useLogoutMutation } from '../model/use-auth-mutation'; export default function HeaderUserDropdown({ userImg }: { userImg: string }) { - const queryClient = useQueryClient(); + const { mutateAsync: logout } = useLogoutMutation(); const router = useRouter(); const handleLogout = async () => { - try { - // 1. 서버에 로그아웃 요청 (refresh token 삭제) - await logout(); - - const memberId = getCookie('memberId'); - sendGTMEvent({ - event: 'custom_member_logout', - dl_timestamp: new Date().toISOString(), - dl_member_id: hashValue(memberId), - }); - - // 2. 클라이언트의 access token 삭제 - deleteCookie('accessToken'); - deleteCookie('memberId'); - - // 3. React Query 캐시 초기화 - queryClient.clear(); - - // 4. 홈으로 리다이렉트 - router.push('/'); - router.refresh(); // 전체 페이지 리프레시 - } catch (error) { - console.error('로그아웃 실패:', error); - } + await logout(); }; return ( diff --git a/src/features/auth/ui/login-modal.tsx b/src/features/auth/ui/login-modal.tsx index 8abdf035..3f25d44b 100644 --- a/src/features/auth/ui/login-modal.tsx +++ b/src/features/auth/ui/login-modal.tsx @@ -21,11 +21,7 @@ export default function LoginModal({ return <>; } - const isLocal = origin.includes('localhost') || origin.includes('127.0.0.1'); // 로컬환경 테스트용 - - const API_BASE_URL = isLocal - ? 'https://test-api.zeroone.it.kr' - : process.env.NEXT_PUBLIC_API_BASE_URL; + const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL; const KAKAO_CLIENT_ID = process.env.NEXT_PUBLIC_KAKAO_CLIENT_ID; const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID; diff --git a/src/shared/lib/server-cookie.ts b/src/shared/lib/server-cookie.ts index 1f422428..233c8bf1 100644 --- a/src/shared/lib/server-cookie.ts +++ b/src/shared/lib/server-cookie.ts @@ -9,11 +9,11 @@ export const getServerCookie = async ( return value ?? undefined; }; -export const setServerCookie = async( +export const setServerCookie = async ( name: string, value: string, options: { path?: string } = {}, ): Promise => { const cookieStore = await cookies(); cookieStore.set(name, value, { path: '/', ...options }); -}; \ No newline at end of file +}; diff --git a/src/shared/lib/validation.ts b/src/shared/lib/validation.ts new file mode 100644 index 00000000..ee8b4ea9 --- /dev/null +++ b/src/shared/lib/validation.ts @@ -0,0 +1,3 @@ +export function isNumeric(str: string) { + return /^\d+$/.test(str); +} diff --git a/src/shared/tanstack-query/api-error.ts b/src/shared/tanstack-query/api-error.ts new file mode 100644 index 00000000..a87da2ed --- /dev/null +++ b/src/shared/tanstack-query/api-error.ts @@ -0,0 +1,40 @@ +class ApiError extends Error { + public name = 'ApiError'; + public statusCode: number; + public errorCode: string; + public errorName: string; + public message: string; + + constructor({ + statusCode, + errorCode, + errorName, + message, + }: { + statusCode: number; + errorCode: string; + errorName: string; + message: string; + }) { + super(message); + this.statusCode = statusCode; + this.errorCode = errorCode; + this.errorName = errorName; + this.message = message; + } +} + +// API 에러인지 확인하는 함수 +const isApiError = (error: unknown): error is ApiError => { + return ( + error instanceof ApiError || + (typeof error === 'object' && + error !== null && + 'statusCode' in error && + 'errorCode' in error && + 'errorName' in error && + 'message' in error) + ); +}; + +export { ApiError, isApiError }; diff --git a/src/shared/tanstack-query/axios.server.ts b/src/shared/tanstack-query/axios.server.ts new file mode 100644 index 00000000..575b3848 --- /dev/null +++ b/src/shared/tanstack-query/axios.server.ts @@ -0,0 +1,33 @@ +import axios, { InternalAxiosRequestConfig } from 'axios'; +import { getServerCookie } from '../lib/server-cookie'; + +// * server-side axios 인스턴스 + +// json 요청 +export const axiosServerInstance = axios.create({ + baseURL: `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/`, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, + withCredentials: true, +}); + +const onRequestServer = async (config: InternalAxiosRequestConfig) => { + const accessToken = await getServerCookie('accessToken'); + + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + + return config; +}; + +axiosServerInstance.interceptors.request.use(onRequestServer); + +axiosServerInstance.interceptors.response.use( + (config) => config, + async (error) => { + return Promise.reject(error); + }, +); diff --git a/src/shared/tanstack-query/axios.ts b/src/shared/tanstack-query/axios.ts index d17b4a56..5b9da700 100644 --- a/src/shared/tanstack-query/axios.ts +++ b/src/shared/tanstack-query/axios.ts @@ -1,7 +1,10 @@ -import axios from 'axios'; +import axios, { InternalAxiosRequestConfig, isAxiosError } from 'axios'; +import { ApiError, isApiError } from './api-error'; import { getCookie, setCookie } from './cookie'; -// json 요청용 +// * client-side axios 인스턴스 + +// json 요청 export const axiosInstance = axios.create({ baseURL: `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/`, timeout: 10000, @@ -20,70 +23,198 @@ export const axiosInstanceForMultipart = axios.create({ }, }); -/* - accessToken 은 쿠키에 저장 - refreshToken 은 HttpOnly 쿠키로 JS에서 접근 불가, 백엔드 서버와 쿠키로 통신 -*/ +const onRequestClient = (config: InternalAxiosRequestConfig) => { + const accessToken = getCookie('accessToken'); -// multipart 요청 로깅용 -axiosInstanceForMultipart.interceptors.request.use( - (config) => { - return config; - }, - (error) => Promise.reject(error), -); + if (accessToken) { + config.headers.Authorization = `Bearer ${accessToken}`; + } + + return config; +}; + +axiosInstance.interceptors.request.use(onRequestClient); +axiosInstanceForMultipart.interceptors.request.use(onRequestClient); + +// refresh token을 사용해서 access token을 재갱신하는 함수 +const refreshAccessToken = async (): Promise => { + try { + const response = await axios.get<{ content: { accessToken: string } }>( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/auth/access-token/refresh`, + { + withCredentials: true, + }, + ); -axiosInstance.interceptors.request.use( - (config) => { - const accessToken = getCookie('accessToken'); - if (accessToken) { - config.headers.Authorization = `Bearer ${accessToken}`; + const newAccessToken = response.data.content.accessToken; + + if (newAccessToken) { + setCookie('accessToken', newAccessToken); + + return newAccessToken; } - // 로컬 테스트에서 사용시 주석 제거 - // console.log("------------------------") - // console.log("✅ 요청주소", config.url); - // console.log("✅ 요청 Bearer", config.headers.Authorization); - // console.log("✅ 요청내용", config); - return config; - }, - (error) => Promise.reject(error), -); + return null; + } catch (error) { + alert('토큰 갱신에 실패했습니다. 다시 로그인해주세요'); + window.location.href = '/login'; + + return null; + } +}; + +// 토큰 갱신 중인지 확인하는 플래그 +let isRefreshing = false; +// 토큰 갱신을 기다리는 요청들 저장 +let failedQueue: Array<{ + resolve: (value: string) => void; + reject: (error: any) => void; +}> = []; + +// 대기 중인 요청들을 처리하는 함수 +const processFailedQueue = (error: unknown, token: string | null = null) => { + failedQueue.forEach(({ resolve, reject }) => { + if (error) { + reject(error); + } else { + resolve(token); + } + }); + + failedQueue = []; +}; axiosInstance.interceptors.response.use( - (response) => { - // 로컬 테스트에서 사용시 주석 제거 - // console.log('------------------------'); - // console.log('✅ 응답주소', response.request.responseURL); - // console.log('✅ 응답로그', response); + (config) => config, + async (error) => { + if ( + isAxiosError(error) && + error.response && + isApiError(error.response.data) + ) { + // 요청이 전송되었고, 서버는 2xx 외의 상태 코드로 응답 + const errorResponseBody = error.response.data; + const originalRequest = error.config; + + // 유효하지 않은 accessToken인 경우, 재발급 + if (errorResponseBody.errorCode === 'AUTH001') { + if (isRefreshing) { + // 이미 토큰 갱신 중이면 대기열에 추가 + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }) + .then((token) => { + if (originalRequest) { + originalRequest.headers.Authorization = `Bearer ${token}`; + + return axiosInstance(originalRequest); + } + }) + .catch((err) => { + return Promise.reject(err); + }); + } + + isRefreshing = true; + + try { + const newAccessToken = await refreshAccessToken(); + + if (newAccessToken) { + processFailedQueue(null, newAccessToken); - return response; + if (originalRequest) { + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + + return axiosInstance(originalRequest); + } + } else { + processFailedQueue(new Error('토큰 갱신 실패'), null); + window.location.href = '/login'; + + return Promise.reject(error); + } + } catch (refreshError) { + processFailedQueue(refreshError, null); + window.location.href = '/login'; + + return Promise.reject(refreshError); + } finally { + // eslint-disable-next-line require-atomic-updates + isRefreshing = false; + } + } + + return Promise.reject(new ApiError(errorResponseBody)); + } + + return Promise.reject(error); }, +); +// multipart 요청용 인터셉터도 동일하게 적용 +axiosInstanceForMultipart.interceptors.response.use( + (config) => config, async (error) => { - console.log('에러 확인', error); - console.log('에러 상태코드:', error.response?.status); - - const originalRequest = error.config; - if (error.response?.status === 401 && !originalRequest._retry) { - originalRequest._retry = true; - try { - const refreshApi = axios.create({ - baseURL: `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/`, - }); - const res = await refreshApi.get('/auth/access-token/refresh'); - const newAccessToken = res.data.accessToken; - - if (newAccessToken) { - setCookie('accessToken', newAccessToken); - originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; - - return axiosInstance(originalRequest); + if ( + isAxiosError(error) && + error.response && + isApiError(error.response.data) + ) { + // 요청이 전송되었고, 서버는 2xx 외의 상태 코드로 응답 + const errorResponseBody = error.response.data; + const originalRequest = error.config; + + // 유효하지 않은 accessToken인 경우, 재발급 + if (errorResponseBody.errorCode === 'AUTH001') { + if (isRefreshing) { + // 이미 토큰 갱신 중이면 대기열에 추가 + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }) + .then((token) => { + if (originalRequest) { + originalRequest.headers.Authorization = `Bearer ${token}`; + + return axiosInstance(originalRequest); + } + }) + .catch((err) => { + return Promise.reject(err); + }); + } + + isRefreshing = true; + + try { + const newAccessToken = await refreshAccessToken(); + + if (newAccessToken) { + processFailedQueue(null, newAccessToken); + + if (originalRequest) { + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + + return axiosInstance(originalRequest); + } + } else { + processFailedQueue(new Error('토큰 갱신 실패'), null); + window.location.href = '/login'; + + return Promise.reject(error); + } + } catch (refreshError) { + processFailedQueue(refreshError, null); + window.location.href = '/login'; + + return Promise.reject(refreshError); + } finally { + // eslint-disable-next-line require-atomic-updates + isRefreshing = false; } - } catch (err) { - // 로그인 페이지 리다이렉트 등 처리 - return Promise.reject(err); } + + return Promise.reject(new ApiError(errorResponseBody)); } return Promise.reject(error); diff --git a/src/shared/ui/dropdown/multi.tsx b/src/shared/ui/dropdown/multi.tsx index 63d47850..1750be98 100644 --- a/src/shared/ui/dropdown/multi.tsx +++ b/src/shared/ui/dropdown/multi.tsx @@ -107,8 +107,11 @@ export default function MultiDropdown({ {item.label}
*/} - {isLogin && } - {!isLogin && ( + {isLoggedIn ? ( + + ) : ( 로그인 / 회원가입} /> )}
diff --git a/src/widgets/home/sidebar.tsx b/src/widgets/home/sidebar.tsx index d6713d28..667e7bc0 100644 --- a/src/widgets/home/sidebar.tsx +++ b/src/widgets/home/sidebar.tsx @@ -1,16 +1,17 @@ import Image from 'next/image'; import Link from 'next/link'; -import { getUserProfile } from '@/entities/user/api/get-user-profile'; +import { getUserProfileInServer } from '@/entities/user/api/get-user-profile.server'; import MyProfileCard from '@/entities/user/ui/my-profile-card'; import StartStudyModal from '@/features/study/ui/start-study-modal'; -import { getLoginUserId } from '@/shared/lib/get-login-user'; +import { getServerCookie } from '@/shared/lib/server-cookie'; import Calendar from '@/widgets/home/calendar'; import TodoList from '@/widgets/home/todo-list'; export default async function Sidebar() { - const memberId = await getLoginUserId(); + const memberIdStr = await getServerCookie('memberId'); + const memberId = Number(memberIdStr); - const userProfile = await getUserProfile(memberId); + const userProfile = await getUserProfileInServer(memberId); return (