diff --git a/app/global.css b/app/global.css
index 74119091..bc9f1f89 100644
--- a/app/global.css
+++ b/app/global.css
@@ -225,6 +225,9 @@ https://velog.io/@oneook/tailwindcss-4.0-%EB%AC%B4%EC%97%87%EC%9D%B4-%EB%8B%AC%E
--color-background-accent-yellow-subtle: var(--color-yellow-50);
--color-background-accent-yellow-default: var(--color-yellow-100);
--color-background-accent-yellow-strong: var(--color-yellow-600);
+ --color-background-neutral-strong: var(--color-gray-900);
+ --color-background-success-default: var(--color-green-500);
+ --color-background-danger-default: var(--color-red-500);
--color-fill-brand-default-default: var(--color-rose-500);
--color-fill-brand-default-hover: var(--color-rose-600);
diff --git a/app/login/page.tsx b/app/login/page.tsx
index f8e28d58..fd388481 100644
--- a/app/login/page.tsx
+++ b/app/login/page.tsx
@@ -9,7 +9,5 @@ export const metadata: Metadata = {
// 랜딩페이지에서 로그인 모달이 뜨는 것으로 파악
export default function LoginPage() {
- console.log("NEXT_PUBLIC_API_BASE_URL", process.env.NEXT_PUBLIC_API_BASE_URL)
-
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..8ddb2820 100644
--- a/app/sign-up/page.tsx
+++ b/app/sign-up/page.tsx
@@ -4,7 +4,6 @@ import Landing from '@/features/auth/ui/landing';
// 랜딩페이지에서 회원가입 모달이 뜨는 것으로 파악
export default function SignupPage() {
// TODO : URL 에서 토큰 파싱후
- console.log('회원가입 페이지');
return ;
}
diff --git a/package.json b/package.json
index 76b2ac30..0f8dca0d 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
"react": "^19.0.0",
"react-day-picker": "9.4.3",
"react-dom": "^19.0.0",
+ "sonner": "^2.0.6",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.0.6",
"tailwindcss-animate": "^1.0.7",
diff --git a/src/app/provider/index.tsx b/src/app/provider/index.tsx
index bc0968dc..42a04378 100644
--- a/src/app/provider/index.tsx
+++ b/src/app/provider/index.tsx
@@ -1,4 +1,5 @@
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
+import { Toaster } from 'sonner';
import QueryProvider from '@/app/provider/query-provider';
interface ProviderProps {
@@ -8,6 +9,30 @@ interface ProviderProps {
function MainProvider({ children }: ProviderProps) {
return (
+
{children}
{process.env.NODE_ENV === 'development' && (
diff --git a/src/entities/user/api/get-user-profile.ts b/src/entities/user/api/get-user-profile.ts
index 67b9f322..7261fed1 100644
--- a/src/entities/user/api/get-user-profile.ts
+++ b/src/entities/user/api/get-user-profile.ts
@@ -2,12 +2,12 @@ import type {
GetUserProfileResponse,
PatchAutoMatchingParams,
} from '@/entities/user/api/types';
-import { axiosInstance } from '@/shared/tanstack-query/axios';
+import { axiosClientInstance } from '@/shared/tanstack-query/axios.client';
export const getUserProfile = async (
memberId: number,
): Promise => {
- const res = await axiosInstance.get(`/members/${memberId}/profile`);
+ const res = await axiosClientInstance.get(`/members/${memberId}/profile`);
return res.data.content;
};
@@ -16,7 +16,11 @@ export const patchAutoMatching = async ({
memberId,
autoMatching,
}: PatchAutoMatchingParams): Promise => {
- await axiosInstance.patch(`/members/${memberId}/auto-matching`, undefined, {
- params: { 'auto-matching': autoMatching },
- });
+ await axiosClientInstance.patch(
+ `/members/${memberId}/auto-matching`,
+ undefined,
+ {
+ params: { 'auto-matching': autoMatching },
+ },
+ );
};
diff --git a/src/features/auth/api/auth.ts b/src/features/auth/api/auth.ts
index 26828943..fc50873f 100644
--- a/src/features/auth/api/auth.ts
+++ b/src/features/auth/api/auth.ts
@@ -1,14 +1,13 @@
// API 통신만 담당하는 순수 함수들
import {
- axiosInstance,
- axiosInstanceForMultipart,
-} from '@/shared/tanstack-query/axios';
+ axiosClientInstance,
+ axiosClientInstanceForMultipart,
+} from '@/shared/tanstack-query/axios.client';
// 회원가입 요청 API
export async function signUp(data: any) {
- const res = await axiosInstance.post('/members', data);
- console.log('signUp res', res);
+ const res = await axiosClientInstance.post('/members', data);
return res.data;
}
@@ -19,7 +18,7 @@ export async function uploadProfileImage(
filename: string,
file: FormData,
) {
- const res = await axiosInstanceForMultipart.put(
+ const res = await axiosClientInstanceForMultipart.put(
`/files/members/${memberId}/profile/image/${filename}`,
file,
);
@@ -29,15 +28,15 @@ export async function uploadProfileImage(
// 멤버 ID 조회 API
export async function getMemberId() {
- const res = await axiosInstance.get(`/auth/me`);
- console.log('getMemberId res', res);
+ const res = await axiosClientInstance.get(`/auth/me`);
return res.data;
}
// 로그아웃 API
-export const logout = async (): Promise => {
- const res = await axiosInstance.post('/auth/logout');
+// 성공하면, content는 빈배열로 응답
+export const logout = async (): Promise> => {
+ const res = await axiosClientInstance.post('/auth/logout');
- return res.data.statusCode;
+ return res.data.content;
};
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..b63fe292 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 router = useRouter();
+ const { mutateAsync: logout } = useLogoutMutation();
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/my-page/api/update-user-profile.ts b/src/features/my-page/api/update-user-profile.ts
index 2d5b7877..e8b18d02 100644
--- a/src/features/my-page/api/update-user-profile.ts
+++ b/src/features/my-page/api/update-user-profile.ts
@@ -8,13 +8,16 @@ import type {
UpdateUserProfileRequest,
UpdateUserProfileResponse,
} from '@/features/my-page/api/types';
-import { axiosInstance } from '@/shared/tanstack-query/axios';
+import { axiosClientInstance } from '@/shared/tanstack-query/axios.client';
export const updateUserProfile = async (
memberId: number,
body: UpdateUserProfileRequest,
): Promise => {
- const res = await axiosInstance.patch(`/members/${memberId}/profile`, body);
+ const res = await axiosClientInstance.patch(
+ `/members/${memberId}/profile`,
+ body,
+ );
return res.data.content;
};
@@ -23,7 +26,7 @@ export const updateUserProfileInfo = async (
memberId: number,
body: UpdateUserProfileInfoRequest,
): Promise => {
- const res = await axiosInstance.patch(
+ const res = await axiosClientInstance.patch(
`/members/${memberId}/profile/info`,
body,
);
@@ -34,25 +37,25 @@ export const updateUserProfileInfo = async (
export const getAvailableStudyTimes = async (): Promise<
AvailableStudyTimeResponse[]
> => {
- const res = await axiosInstance.get('/available-study-times');
+ const res = await axiosClientInstance.get('/available-study-times');
return res.data.content;
};
export const getStudySubjects = async (): Promise => {
- const res = await axiosInstance.get('/study-subjects');
+ const res = await axiosClientInstance.get('/study-subjects');
return res.data.content;
};
export const getTechStacks = async (): Promise => {
- const res = await axiosInstance.get('/tech-stacks');
+ const res = await axiosClientInstance.get('/tech-stacks');
return res.data.content;
};
export const getStudyDashboard = async (): Promise => {
- const res = await axiosInstance.get('/study/dashboard');
+ const res = await axiosClientInstance.get('/study/dashboard');
return res.data.content;
};
diff --git a/src/features/study/api/get-study-data.ts b/src/features/study/api/get-study-data.ts
index 89f1c674..e7256963 100644
--- a/src/features/study/api/get-study-data.ts
+++ b/src/features/study/api/get-study-data.ts
@@ -10,13 +10,13 @@ import type {
PrepareStudyRequest,
WeeklyParticipationResponse,
} from '@/features/study/api/types';
-import { axiosInstance } from '@/shared/tanstack-query/axios';
+import { axiosClientInstance } from '@/shared/tanstack-query/axios.client';
// 스터디 상세 조회
export const getDailyStudyDetail = async (
params: string,
): Promise => {
- const res = await axiosInstance.get(`/study/daily/mine/${params}`);
+ const res = await axiosClientInstance.get(`/study/daily/mine/${params}`);
return res.data.content;
};
@@ -25,7 +25,7 @@ export const getDailyStudyDetail = async (
export const getDailyStudies = async (
params?: GetDailyStudiesParams,
): Promise => {
- const res = await axiosInstance.get('/study/daily', { params });
+ const res = await axiosClientInstance.get('/study/daily', { params });
return res.data.content;
};
@@ -34,13 +34,13 @@ export const getDailyStudies = async (
export const getMonthlyStudyCalendar = async (
params: GetMonthlyCalendarParams,
): Promise => {
- const res = await axiosInstance.get('/study/daily/month', { params });
+ const res = await axiosClientInstance.get('/study/daily/month', { params });
return res.data.content;
};
export const postDailyRetrospect = async (body: PostDailyRetrospectRequest) => {
- const res = await axiosInstance.post('/study/daily/retrospect', body);
+ const res = await axiosClientInstance.post('/study/daily/retrospect', body);
return res.data;
};
@@ -50,7 +50,10 @@ export const putStudyDaily = async (
dailyId: number,
body: PrepareStudyRequest,
) => {
- const res = await axiosInstance.put(`/study/daily/${dailyId}/prepare`, body);
+ const res = await axiosClientInstance.put(
+ `/study/daily/${dailyId}/prepare`,
+ body,
+ );
return res.data;
};
@@ -60,7 +63,7 @@ export const completeStudy = async (
dailyStudyId: number,
body: CompleteStudyRequest,
) => {
- const res = await axiosInstance.post(
+ const res = await axiosClientInstance.post(
`/study/daily/${dailyStudyId}/complete`,
body,
);
@@ -79,7 +82,7 @@ export const postJoinStudy = async (payload: JoinStudyRequest) => {
),
);
- const res = await axiosInstance.post('/matching/apply', cleanPayload);
+ const res = await axiosClientInstance.post('/matching/apply', cleanPayload);
return res.data;
};
@@ -93,4 +96,4 @@ export const getWeeklyParticipation = async (
});
return res.data.content;
-};
+};
\ No newline at end of file
diff --git a/src/features/study/model/use-study-query.ts b/src/features/study/model/use-study-query.ts
index ab782631..78eda77b 100644
--- a/src/features/study/model/use-study-query.ts
+++ b/src/features/study/model/use-study-query.ts
@@ -1,4 +1,5 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { useRouter } from 'next/navigation';
import {
completeStudy,
getDailyStudies,
@@ -8,6 +9,8 @@ import {
postJoinStudy,
putStudyDaily,
} from '@/features/study/api/get-study-data';
+import { isApiError } from '@/shared/tanstack-query/api-error';
+import { openToast } from '@/shared/ui/toast';
import {
CompleteStudyRequest,
GetDailyStudiesParams,
@@ -59,8 +62,22 @@ export const useMonthlyStudyCalendarQuery = (
// 스터디 신청 mutation
export const useJoinStudyMutation = () => {
+ const router = useRouter();
+
return useMutation({
mutationFn: (payload: JoinStudyRequest) => postJoinStudy(payload),
+ onSuccess: () => {
+ alert('스터디 신청이 완료되었습니다!');
+ router.refresh();
+ },
+ onError: (error) => {
+ if (isApiError(error)) {
+ openToast({
+ type: 'danger',
+ text: '스터디 신청에 실패했습니다.',
+ });
+ }
+ },
});
};
diff --git a/src/features/study/ui/start-study-modal.tsx b/src/features/study/ui/start-study-modal.tsx
index 4c3a190e..8a06cc20 100644
--- a/src/features/study/ui/start-study-modal.tsx
+++ b/src/features/study/ui/start-study-modal.tsx
@@ -2,7 +2,6 @@
import { XIcon } from 'lucide-react';
import Image from 'next/image';
-import { useRouter } from 'next/navigation';
import { useState } from 'react';
import {
useAvailableStudyTimesQuery,
@@ -151,7 +150,6 @@ function StartStudyForm({ memberId }: StartStudyModalProps) {
const { data: availableStudyTimes } = useAvailableStudyTimesQuery();
const { data: studySubjects } = useStudySubjectsQuery();
const { data: techStacks } = useTechStacksQuery();
- const router = useRouter();
const { mutate: joinStudy } = useJoinStudyMutation();
@@ -187,23 +185,12 @@ function StartStudyForm({ memberId }: StartStudyModalProps) {
return;
}
- joinStudy(
- {
- ...form,
- memberId,
- githubLink: githubLink.trim() || undefined,
- blogOrSnsLink: blogOrSnsLink.trim() || undefined,
- },
- {
- onSuccess: () => {
- alert('스터디 신청이 완료되었습니다!');
- router.refresh();
- },
- onError: () => {
- alert('스터디 신청 중 오류가 발생했습니다. 다시 시도해 주세요.');
- },
- },
- );
+ joinStudy({
+ ...form,
+ memberId,
+ githubLink: githubLink.trim() || undefined,
+ blogOrSnsLink: blogOrSnsLink.trim() || undefined,
+ });
};
return (
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/tanstack-query/api-error.ts b/src/shared/tanstack-query/api-error.ts
new file mode 100644
index 00000000..0fcd1f41
--- /dev/null
+++ b/src/shared/tanstack-query/api-error.ts
@@ -0,0 +1,40 @@
+class ApiError extends Error {
+ name = 'ApiError';
+ statusCode: number;
+ errorCode: string;
+ errorName: string;
+ 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.all.ts b/src/shared/tanstack-query/axios.all.ts
new file mode 100644
index 00000000..164cfc7a
--- /dev/null
+++ b/src/shared/tanstack-query/axios.all.ts
@@ -0,0 +1,26 @@
+import axios, { isAxiosError } from 'axios';
+import { ApiError, isApiError } from './api-error';
+
+// * 인증하지 않는 axios 인스턴스
+export const axiosAllInstance = axios.create({
+ baseURL: `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/`,
+ timeout: 10000,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+});
+
+axiosAllInstance.interceptors.response.use(
+ (response) => response,
+ (error) => {
+ if (
+ error.response &&
+ isAxiosError(error) &&
+ isApiError(error.response.data)
+ ) {
+ throw new ApiError(error.response.data);
+ }
+
+ return Promise.reject(error);
+ },
+);
diff --git a/src/shared/tanstack-query/axios.client.ts b/src/shared/tanstack-query/axios.client.ts
new file mode 100644
index 00000000..26a5f647
--- /dev/null
+++ b/src/shared/tanstack-query/axios.client.ts
@@ -0,0 +1,59 @@
+import axios, { InternalAxiosRequestConfig, isAxiosError } from 'axios';
+import { ApiError, isApiError } from './api-error';
+import { getCookie } from './cookie';
+
+// * 인증이 필요한 client-side axios 인스턴스
+
+// json 요청
+export const axiosClientInstance = axios.create({
+ baseURL: `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/`,
+ timeout: 10000,
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ withCredentials: true,
+});
+
+// multipart 요청용
+export const axiosClientInstanceForMultipart = axios.create({
+ baseURL: `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/`,
+ timeout: 10000,
+ headers: {
+ // JS에서 formData 를 넘길땐 Content-Type 생략해야 자동으로 multipart/form-data + boundary 설정됨
+ },
+});
+
+const onRequestClient = (config: InternalAxiosRequestConfig) => {
+ const accessToken = getCookie('accessToken');
+
+ if (accessToken) {
+ config.headers.Authorization = `Bearer ${accessToken}`;
+ }
+
+ return config;
+};
+
+axiosClientInstance.interceptors.request.use(onRequestClient);
+axiosClientInstanceForMultipart.interceptors.request.use(onRequestClient);
+
+const onResponseErrorClient = async (error: unknown) => {
+ if (isAxiosError(error) && error.response) {
+ const errorResponseBody = error.response.data;
+
+ if (isApiError(errorResponseBody)) {
+ const accessToken = getCookie('accessToken');
+
+ // 유효하지 않은 accessToken인 경우, 재발급
+ if (accessToken && errorResponseBody.errorCode === 'AUTH001') {
+ // refresh accessToken
+ }
+
+ throw new ApiError(errorResponseBody);
+ }
+ }
+};
+
+axiosClientInstance.interceptors.response.use(
+ (config) => config,
+ onResponseErrorClient,
+);
diff --git a/src/shared/tanstack-query/axios.server.ts b/src/shared/tanstack-query/axios.server.ts
new file mode 100644
index 00000000..5351413a
--- /dev/null
+++ b/src/shared/tanstack-query/axios.server.ts
@@ -0,0 +1,48 @@
+import axios, { InternalAxiosRequestConfig, isAxiosError } from 'axios';
+import { ApiError, 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',
+ },
+});
+
+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) {
+ const errorResponseBody = error.response.data;
+
+ if (isApiError(errorResponseBody)) {
+ const accessToken = await getServerCookie('accessToken');
+
+ // 유효하지 않은 accessToken인 경우, 재발급
+ if (accessToken && errorResponseBody.errorCode === 'AUTH001') {
+ // refresh accessToken
+ }
+
+ throw new ApiError(errorResponseBody);
+ }
+ }
+};
+
+axiosServerInstance.interceptors.response.use(
+ (config) => config,
+ onResponseErrorServer,
+);
diff --git a/src/shared/tanstack-query/axios.ts b/src/shared/tanstack-query/axios.ts
deleted file mode 100644
index d17b4a56..00000000
--- a/src/shared/tanstack-query/axios.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import axios from 'axios';
-import { getCookie, setCookie } from './cookie';
-
-// json 요청용
-export const axiosInstance = axios.create({
- baseURL: `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/`,
- timeout: 10000,
- headers: {
- 'Content-Type': 'application/json',
- },
- withCredentials: true,
-});
-
-// multipart 요청용
-export const axiosInstanceForMultipart = axios.create({
- baseURL: `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/`,
- timeout: 10000,
- headers: {
- // JS에서 formData 를 넘길땐 Content-Type 생략해야 자동으로 multipart/form-data + boundary 설정됨
- },
-});
-
-/*
- accessToken 은 쿠키에 저장
- refreshToken 은 HttpOnly 쿠키로 JS에서 접근 불가, 백엔드 서버와 쿠키로 통신
-*/
-
-// multipart 요청 로깅용
-axiosInstanceForMultipart.interceptors.request.use(
- (config) => {
- return config;
- },
- (error) => Promise.reject(error),
-);
-
-axiosInstance.interceptors.request.use(
- (config) => {
- const accessToken = getCookie('accessToken');
- if (accessToken) {
- config.headers.Authorization = `Bearer ${accessToken}`;
- }
- // 로컬 테스트에서 사용시 주석 제거
- // console.log("------------------------")
- // console.log("✅ 요청주소", config.url);
- // console.log("✅ 요청 Bearer", config.headers.Authorization);
- // console.log("✅ 요청내용", config);
-
- return config;
- },
- (error) => Promise.reject(error),
-);
-
-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;
- 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);
- }
- } catch (err) {
- // 로그인 페이지 리다이렉트 등 처리
- return Promise.reject(err);
- }
- }
-
- return Promise.reject(error);
- },
-);
diff --git a/src/shared/ui/toast/index.ts b/src/shared/ui/toast/index.ts
new file mode 100644
index 00000000..6b924b29
--- /dev/null
+++ b/src/shared/ui/toast/index.ts
@@ -0,0 +1,20 @@
+import { toast } from 'sonner';
+
+const openToast = ({
+ type = 'info',
+ text,
+}: {
+ type?: 'success' | 'danger' | 'info';
+ text: string;
+}) => {
+ if (type === 'success') {
+ return toast.success(text);
+ }
+ if (type === 'danger') {
+ return toast.warning(text);
+ }
+
+ return toast.info(text);
+};
+
+export { openToast };
diff --git a/src/widgets/home/sidebar.tsx b/src/widgets/home/sidebar.tsx
index 9709d142..572598cd 100644
--- a/src/widgets/home/sidebar.tsx
+++ b/src/widgets/home/sidebar.tsx
@@ -1,3 +1,4 @@
+import { redirect } from 'next/navigation';
import { getUserProfile } from '@/entities/user/api/get-user-profile';
import MyProfileCard from '@/features/study/ui/my-profile-card';
import StartStudyModal from '@/features/study/ui/start-study-modal';
@@ -8,6 +9,10 @@ import TodoList from '@/widgets/home/todo-list';
export default async function Sidebar() {
const memberId = await getLoginUserId();
+ if (!memberId) {
+ redirect('/login');
+ }
+
const userProfile = await getUserProfile(memberId);
return (
diff --git a/src/widgets/my-page/sidebar.tsx b/src/widgets/my-page/sidebar.tsx
index 2e9d9cb1..e3f5fbf7 100644
--- a/src/widgets/my-page/sidebar.tsx
+++ b/src/widgets/my-page/sidebar.tsx
@@ -1,38 +1,16 @@
'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 { mutateAsync: logout } = useLogoutMutation();
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),
- });
-
- deleteCookie('accessToken');
- deleteCookie('memberId');
-
- queryClient.clear();
- router.push('/');
- router.refresh();
- } catch (error) {
- console.error('로그아웃 실패:', error);
- }
+ await logout();
};
return (
diff --git a/yarn.lock b/yarn.lock
index 4a6df8da..ffe7cabc 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -6852,6 +6852,11 @@ snake-case@^3.0.4:
dot-case "^3.0.4"
tslib "^2.0.3"
+sonner@^2.0.6:
+ version "2.0.6"
+ resolved "https://registry.yarnpkg.com/sonner/-/sonner-2.0.6.tgz#623b73faec55229d63ec35226d42021f2119bfa7"
+ integrity sha512-yHFhk8T/DK3YxjFQXIrcHT1rGEeTLliVzWbO0xN8GberVun2RiBnxAjXAYpZrqwEVHBG9asI/Li8TAAhN9m59Q==
+
source-map-js@^1.0.1, source-map-js@^1.0.2, source-map-js@^1.2.0, source-map-js@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46"
@@ -6906,16 +6911,8 @@ strict-event-emitter@^0.5.1:
resolved "https://registry.yarnpkg.com/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz#1602ece81c51574ca39c6815e09f1a3e8550bd93"
integrity sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==
-"string-width-cjs@npm:string-width@^4.2.0":
- version "4.2.3"
- resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
- integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
- dependencies:
- emoji-regex "^8.0.0"
- is-fullwidth-code-point "^3.0.0"
- strip-ansi "^6.0.1"
-
-string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
+"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3:
+ name string-width-cjs
version "4.2.3"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
@@ -7017,14 +7014,8 @@ stringify-object@^5.0.0:
is-obj "^3.0.0"
is-regexp "^3.1.0"
-"strip-ansi-cjs@npm:strip-ansi@^6.0.1":
- version "6.0.1"
- resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
- integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
- dependencies:
- ansi-regex "^5.0.1"
-
-strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+ name strip-ansi-cjs
version "6.0.1"
resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
@@ -7644,7 +7635,8 @@ word-wrap@^1.2.5:
resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34"
integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==
-"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0":
+"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0:
+ name wrap-ansi-cjs
version "7.0.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
@@ -7662,15 +7654,6 @@ wrap-ansi@^6.2.0:
string-width "^4.1.0"
strip-ansi "^6.0.0"
-wrap-ansi@^7.0.0:
- version "7.0.0"
- resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
- integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
- dependencies:
- ansi-styles "^4.0.0"
- string-width "^4.1.0"
- strip-ansi "^6.0.0"
-
wrap-ansi@^8.1.0:
version "8.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214"