From 39c8b8b85a0d36b2006a98d3ab0596001c11daf4 Mon Sep 17 00:00:00 2001 From: aken-you Date: Thu, 28 Aug 2025 00:59:48 +0900 Subject: [PATCH 01/23] =?UTF-8?q?chore:=20=EA=B0=9C=EB=B0=9C=ED=99=98?= =?UTF-8?q?=EA=B2=BD=EC=97=90=EC=84=9C=20https=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - secure 옵션인 refresh token 쿠키로 인해 설정 --- .gitignore | 2 ++ package.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 295f80b4..9601bc7d 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,5 @@ yarn-error.log* next-env.d.ts *storybook.log + +certificates \ No newline at end of file diff --git a/package.json b/package.json index d0ae9f0f..5d366147 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --turbopack", + "dev": "next dev --turbopack --experimental-https", "build": "next build", "start": "next start", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", From 44ba882f19356d78ff9ea4a9742ec7b79f7c1daa Mon Sep 17 00:00:00 2001 From: aken-you Date: Fri, 29 Aug 2025 01:13:03 +0900 Subject: [PATCH 02/23] =?UTF-8?q?refactor:=20redirection=20url=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=A1=9C=EB=94=A9=20ui=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/redirection/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 ( - 로딩중...}> + }> ); From 6c161c578a07f5b26bd97dabccd861c52b885fe7 Mon Sep 17 00:00:00 2001 From: aken-you Date: Fri, 29 Aug 2025 01:21:02 +0900 Subject: [PATCH 03/23] =?UTF-8?q?style:=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/login/page.tsx | 3 --- app/sign-up/page.tsx | 3 --- src/features/auth/api/auth.ts | 2 -- src/shared/tanstack-query/axios.ts | 13 ------------- 4 files changed, 21 deletions(-) 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/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/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/shared/tanstack-query/axios.ts b/src/shared/tanstack-query/axios.ts index d17b4a56..6915172e 100644 --- a/src/shared/tanstack-query/axios.ts +++ b/src/shared/tanstack-query/axios.ts @@ -39,11 +39,6 @@ axiosInstance.interceptors.request.use( if (accessToken) { config.headers.Authorization = `Bearer ${accessToken}`; } - // 로컬 테스트에서 사용시 주석 제거 - // console.log("------------------------") - // console.log("✅ 요청주소", config.url); - // console.log("✅ 요청 Bearer", config.headers.Authorization); - // console.log("✅ 요청내용", config); return config; }, @@ -52,18 +47,10 @@ axiosInstance.interceptors.request.use( axiosInstance.interceptors.response.use( (response) => { - // 로컬 테스트에서 사용시 주석 제거 - // console.log('------------------------'); - // console.log('✅ 응답주소', response.request.responseURL); - // console.log('✅ 응답로그', response); - return response; }, async (error) => { - console.log('에러 확인', error); - console.log('에러 상태코드:', error.response?.status); - const originalRequest = error.config; if (error.response?.status === 401 && !originalRequest._retry) { originalRequest._retry = true; From 0b9066e29ff5903f23e8deefe5f358c4abfe85c9 Mon Sep 17 00:00:00 2001 From: aken-you Date: Fri, 29 Aug 2025 01:25:39 +0900 Subject: [PATCH 04/23] =?UTF-8?q?refactor:=20logout=20mutation=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/auth/model/use-auth-mutation.ts | 29 ++++++++++++++-- src/features/auth/ui/header-user-dropdown.tsx | 33 ++----------------- src/widgets/my-page/sidebar.tsx | 29 +++------------- 3 files changed, 34 insertions(+), 57 deletions(-) diff --git a/src/features/auth/model/use-auth-mutation.ts b/src/features/auth/model/use-auth-mutation.ts index 4ec5aa3c..6e327941 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,28 @@ 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'); + + 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/widgets/my-page/sidebar.tsx b/src/widgets/my-page/sidebar.tsx index af28bc7a..dd3eb0bf 100644 --- a/src/widgets/my-page/sidebar.tsx +++ b/src/widgets/my-page/sidebar.tsx @@ -1,38 +1,17 @@ 'use client'; -import { sendGTMEvent } from '@next/third-parties/google'; -import { useQueryClient } from '@tanstack/react-query'; import { usePathname, useRouter } from 'next/navigation'; -import { logout } from '@/features/auth/api/auth'; -import { hashValue } from '@/shared/lib/hash'; +import { useLogoutMutation } from '@/features/auth/model/use-auth-mutation'; import { cn } from '@/shared/shadcn/lib/utils'; -import { deleteCookie, getCookie } from '@/shared/tanstack-query/cookie'; export default function Sidebar() { const router = useRouter(); - const queryClient = useQueryClient(); const pathname = usePathname(); - const handleLogout = async () => { - try { - await logout(); - - const memberId = getCookie('memberId'); - sendGTMEvent({ - event: 'custom_member_logout', - dl_timestamp: new Date().toISOString(), - dl_member_id: hashValue(memberId), - }); + const { mutateAsync: logout } = useLogoutMutation(); - deleteCookie('accessToken'); - deleteCookie('memberId'); - - queryClient.clear(); - router.push('/'); - router.refresh(); - } catch (error) { - console.error('로그아웃 실패:', error); - } + const handleLogout = async () => { + await logout(); }; return ( From 769a12ff56332f60908adb99fbf0a0859053ecac Mon Sep 17 00:00:00 2001 From: aken-you Date: Fri, 29 Aug 2025 19:48:20 +0900 Subject: [PATCH 05/23] =?UTF-8?q?feat:=20ApiError=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/tanstack-query/api-error.ts | 40 ++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 src/shared/tanstack-query/api-error.ts 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 }; From fad80263b5f55a6895a4d0137ff6e09948f0e991 Mon Sep 17 00:00:00 2001 From: aken-you Date: Sat, 30 Aug 2025 05:30:21 +0900 Subject: [PATCH 06/23] =?UTF-8?q?refactor:=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EB=A9=94=EC=84=B8=EC=A7=80=20=EC=A0=9C=EA=B3=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 브라우저 alert 창으로 제공 --- src/shared/tanstack-query/axios.ts | 85 ++++++++++++++---------------- 1 file changed, 40 insertions(+), 45 deletions(-) diff --git a/src/shared/tanstack-query/axios.ts b/src/shared/tanstack-query/axios.ts index 6915172e..59c835bd 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 { 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,59 +23,51 @@ 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}`; + } -axiosInstance.interceptors.request.use( - (config) => { - const accessToken = getCookie('accessToken'); - if (accessToken) { - config.headers.Authorization = `Bearer ${accessToken}`; - } + return config; +}; - return config; - }, - (error) => Promise.reject(error), -); +axiosInstance.interceptors.request.use(onRequestClient); +axiosInstanceForMultipart.interceptors.request.use(onRequestClient); -axiosInstance.interceptors.response.use( - (response) => { - return response; - }, +const ERROR_MESSAGES = { + MEM001: '유효하지 않은 입력입니다.', + MEM002: '회원 정보가 존재하지 않습니다.', + MEM003: '이미 가입된 회원입니다.', + MEM004: '아직 스터디를 신청하지 않았습니다.', + MPR001: '관심사가 중복됐습니다.', + MPF001: '현재 프로젝트 에서 지원 하는 기능이 아닙니다.', +}; +axiosInstance.interceptors.response.use( + (config) => config, async (error) => { - 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 (isAxiosError(error) && error.response) { + const errorResponseBody = error.response.data; + + if (isApiError(errorResponseBody)) { + const accessToken = getCookie('accessToken'); - if (newAccessToken) { - setCookie('accessToken', newAccessToken); - originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + const originalRequest = error.config; - return axiosInstance(originalRequest); + // 유효하지 않은 accessToken인 경우, 재발급 + if (accessToken && errorResponseBody.errorCode === 'AUTH001') { + } + + if (errorResponseBody.errorCode in ERROR_MESSAGES) { + alert( + ERROR_MESSAGES[ + errorResponseBody.errorCode as keyof typeof ERROR_MESSAGES + ], + ); } - } catch (err) { - // 로그인 페이지 리다이렉트 등 처리 - return Promise.reject(err); } } - - return Promise.reject(error); }, ); From ed166148ccbd1284d7dbb120a74bceec9c57e38d Mon Sep 17 00:00:00 2001 From: aken-you Date: Sun, 31 Aug 2025 00:12:35 +0900 Subject: [PATCH 07/23] =?UTF-8?q?refactor:=20useLogoutMutation=20onSuccess?= =?UTF-8?q?=EC=97=90=EC=84=9C=20socialImageURL=20=EC=BF=A0=ED=82=A4=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/auth/model/use-auth-mutation.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/features/auth/model/use-auth-mutation.ts b/src/features/auth/model/use-auth-mutation.ts index 6e327941..b81c0725 100644 --- a/src/features/auth/model/use-auth-mutation.ts +++ b/src/features/auth/model/use-auth-mutation.ts @@ -48,6 +48,7 @@ export const useLogoutMutation = () => { deleteCookie('accessToken'); deleteCookie('memberId'); + deleteCookie('socialImageURL'); queryClient.clear(); From 1c2d10dbe647c88121e19b5a80dd6c1412b10e66 Mon Sep 17 00:00:00 2001 From: aken-you Date: Sun, 31 Aug 2025 17:10:01 +0900 Subject: [PATCH 08/23] =?UTF-8?q?refactor:=20access=20token=20=EC=9E=AC?= =?UTF-8?q?=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/tanstack-query/axios.ts | 109 +++++++++++++++++++++++++---- 1 file changed, 97 insertions(+), 12 deletions(-) diff --git a/src/shared/tanstack-query/axios.ts b/src/shared/tanstack-query/axios.ts index 59c835bd..20a8db8a 100644 --- a/src/shared/tanstack-query/axios.ts +++ b/src/shared/tanstack-query/axios.ts @@ -45,29 +45,114 @@ const ERROR_MESSAGES = { MPF001: '현재 프로젝트 에서 지원 하는 기능이 아닙니다.', }; +// 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, + }, + ); + + const newAccessToken = response.data.content.accessToken; + + if (newAccessToken) { + setCookie('accessToken', newAccessToken); + + return newAccessToken; + } + + return null; + } catch (error) { + alert('토큰 갱신에 실패했습니다. 다시 로그인해주세요'); + window.location.href = '/login'; + + return null; + } +}; + axiosInstance.interceptors.response.use( (config) => config, async (error) => { - if (isAxiosError(error) && error.response) { + if ( + isAxiosError(error) && + error.response && + isApiError(error.response.data) + ) { + // 요청이 전송되었고, 서버는 2xx 외의 상태 코드로 응답 const errorResponseBody = error.response.data; + const originalRequest = error.config; - if (isApiError(errorResponseBody)) { - const accessToken = getCookie('accessToken'); + // 유효하지 않은 accessToken인 경우, 재발급 + if (errorResponseBody.errorCode === 'AUTH001') { + const newAccessToken = await refreshAccessToken(); - const originalRequest = error.config; + if (newAccessToken && originalRequest) { + // 새로운 access token으로 원래 요청 재시도 + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; - // 유효하지 않은 accessToken인 경우, 재발급 - if (accessToken && errorResponseBody.errorCode === 'AUTH001') { + return axiosInstance(originalRequest); + } else { + // refresh token도 만료된 경우 로그인 페이지로 리다이렉트 + window.location.href = '/login'; + + return Promise.reject(error); } + } + + if (errorResponseBody.errorCode in ERROR_MESSAGES) { + alert( + ERROR_MESSAGES[ + errorResponseBody.errorCode as keyof typeof ERROR_MESSAGES + ], + ); + } + } - if (errorResponseBody.errorCode in ERROR_MESSAGES) { - alert( - ERROR_MESSAGES[ - errorResponseBody.errorCode as keyof typeof ERROR_MESSAGES - ], - ); + return Promise.reject(error); + }, +); + +// multipart 요청용 인터셉터도 동일하게 적용 +axiosInstanceForMultipart.interceptors.response.use( + (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') { + const newAccessToken = await refreshAccessToken(); + + if (newAccessToken && originalRequest) { + // 새로운 access token으로 원래 요청 재시도 + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + + return axiosInstanceForMultipart(originalRequest); + } else { + // refresh token도 만료된 경우 로그인 페이지로 리다이렉트 + window.location.href = '/login'; + + return Promise.reject(error); } } + + if (errorResponseBody.errorCode in ERROR_MESSAGES) { + alert( + ERROR_MESSAGES[ + errorResponseBody.errorCode as keyof typeof ERROR_MESSAGES + ], + ); + } } + + return Promise.reject(error); }, ); From f6f92e3bc63c50232fcb0072ac81db6295a81908 Mon Sep 17 00:00:00 2001 From: aken-you Date: Sun, 31 Aug 2025 17:12:00 +0900 Subject: [PATCH 09/23] =?UTF-8?q?style:=20prettier=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/shared/lib/server-cookie.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 +}; From 48a56f95a3e1b32dd08c3dd83813d89b7559e19a Mon Sep 17 00:00:00 2001 From: aken-you Date: Wed, 3 Sep 2025 22:25:06 +0900 Subject: [PATCH 10/23] =?UTF-8?q?refactor:=20https=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5d366147..d0ae9f0f 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev --turbopack --experimental-https", + "dev": "next dev --turbopack", "build": "next build", "start": "next start", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", From 235aaf7e59fdc00894944af51825e38497ee63f9 Mon Sep 17 00:00:00 2001 From: aken-you Date: Wed, 3 Sep 2025 23:04:38 +0900 Subject: [PATCH 11/23] =?UTF-8?q?refactor:=20API=5FBASE=5FURL=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/auth/ui/login-modal.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) 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; From 7c0a49668e488ca8d4a6dc95bf5323bb01ec3fd5 Mon Sep 17 00:00:00 2001 From: aken-you Date: Thu, 4 Sep 2025 00:50:12 +0900 Subject: [PATCH 12/23] =?UTF-8?q?refactor:=20middleware=EB=A5=BC=20?= =?UTF-8?q?=ED=86=B5=ED=95=B4=20=EB=B9=84=ED=9A=8C=EC=9B=90=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=A0=91=EA=B7=BC=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/(my)/my-page/page.tsx | 14 +++--- app/page.tsx | 8 ---- middleware.ts | 23 ++++++++++ .../user/api/get-user-profile.server.ts | 10 +++++ src/shared/lib/validation.ts | 3 ++ src/shared/tanstack-query/axios.server.ts | 45 +++++++++++++++++++ src/widgets/home/header.tsx | 29 +++++++----- src/widgets/home/sidebar.tsx | 9 ++-- 8 files changed, 109 insertions(+), 32 deletions(-) create mode 100644 middleware.ts create mode 100644 src/entities/user/api/get-user-profile.server.ts create mode 100644 src/shared/lib/validation.ts create mode 100644 src/shared/tanstack-query/axios.server.ts 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/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/middleware.ts b/middleware.ts new file mode 100644 index 00000000..acf436be --- /dev/null +++ b/middleware.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server'; +import type { NextRequest } from 'next/server'; +import { isNumeric } from '@/shared/lib/validation'; + +export function middleware(request: NextRequest) { + const hasAccessToken = request.cookies.has('accessToken'); + const hasMemberId = + request.cookies.has('memberId') && + isNumeric(request.cookies.get('memberId')?.value); + + if (!hasAccessToken || !hasMemberId) { + const loginUrl = new URL('/login', request.url); + + return NextResponse.redirect(loginUrl); + } + + return NextResponse.next(); +} + +// 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/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/axios.server.ts b/src/shared/tanstack-query/axios.server.ts new file mode 100644 index 00000000..d290c20b --- /dev/null +++ b/src/shared/tanstack-query/axios.server.ts @@ -0,0 +1,45 @@ +import axios, { InternalAxiosRequestConfig, isAxiosError } from 'axios'; +import { isApiError } from './api-error'; +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); + +const onResponseErrorServer = async (error: unknown) => { + if ( + isAxiosError(error) && + error.response && + isApiError(error.response.data) + ) { + const errorResponseBody = error.response.data; + const originalRequest = error.config; + + // 서버 에러 처리 + } +}; + +axiosServerInstance.interceptors.response.use( + (config) => config, + onResponseErrorServer, +); diff --git a/src/widgets/home/header.tsx b/src/widgets/home/header.tsx index bf25eca6..bf629d3c 100644 --- a/src/widgets/home/header.tsx +++ b/src/widgets/home/header.tsx @@ -1,20 +1,26 @@ 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 HeaderUserDropdown from '@/features/auth/ui/header-user-dropdown'; import LoginModal from '@/features/auth/ui/login-modal'; import { getServerCookie } from '@/shared/lib/server-cookie'; +import { isNumeric } from '@/shared/lib/validation'; import Button from '@/shared/ui/button'; -// import NotiIcon from 'public/icons/notifications_none.svg'; - export default async function Header() { - const memberId = await getServerCookie('memberId'); - const isLogin = /^\d+$/.test(memberId || ''); + const memberIdStr = await getServerCookie('memberId'); + const accessTokenStr = await getServerCookie('accessToken'); + + const hasMemberId = !!memberIdStr && isNumeric(memberIdStr); + const isLoggedIn = !!accessTokenStr && hasMemberId; + + const memberId = Number(memberIdStr); - const userInfo = isLogin ? await getUserProfile(Number(memberId)) : null; - const userImg = isLogin - ? userInfo.memberProfile.profileImage?.resizedImages[0].resizedImageUrl + const userProfile = isLoggedIn + ? await getUserProfileInServer(memberId) + : null; + const userInfo = userProfile?.memberProfile; + const userImg = userProfile + ? userInfo?.profileImage?.resizedImages[0].resizedImageUrl : 'profile-default.svg'; return ( @@ -36,8 +42,9 @@ export default async function Header() {
*/} - {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 (