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}