Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 75 additions & 52 deletions src/api/apiClient.ts
Original file line number Diff line number Diff line change
@@ -1,51 +1,70 @@
import axios, { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios';
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import Router from 'next/router';

import { RetryRequestConfig } from '@/types/AuthTypes';
import { clearAuthCookiesWithCallback, getCookie, setCookie } from '@/lib/cookie';
import {
ApiClientContext,
RefreshTokenRequest,
RefreshTokenResponse,
RetryRequestConfig,
} from '@/types/AuthTypes';
import { CookieHeaderParams } from '@/types/CookieTypes';

import { updateAccessToken } from './auth';

// axios 인스턴스 생성
const apiClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_BASE_URL,
timeout: 10_000,
headers: { 'Content-Type': 'application/json' },
});

/* Axios 인터셉터 설정 */
// 토큰 추가 인터셉터
apiClient.interceptors.request.use(addAccessToken);

// 에러 처리 및 리프레쉬 토큰 추가 인터셉터
apiClient.interceptors.response.use(
(res) => res.data,
async (error) => {
const status = error.response?.status;
const refreshToken = localStorage.getItem('refreshToken');

if (status !== 401 || !refreshToken) return handleCommonError(error);
try {
const result = await handleRequestRefreshToken(error, refreshToken);
if (result) return result;
} catch (refreshTokenError) {
// 토큰 삭제 처리 후 리디렉트
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
Router.replace('/signin');
return handleCommonError(refreshTokenError as AxiosError);
}
},
);

export default apiClient;
export const createApiClient = (context?: ApiClientContext) => {
const cookieHeader = context?.req?.headers.cookie || '';

// axios 인스턴스 생성
const instance = axios.create({
baseURL: process.env.NEXT_PUBLIC_BASE_URL,
timeout: 10_000,
headers: { 'Content-Type': 'application/json' },
});

/* Axios 인터셉터 설정 */
// 토큰 추가 인터셉터
instance.interceptors.request.use(createAddAccessToken({ cookieHeader }));

// 에러 처리 및 리프레쉬 토큰 추가 인터셉터
instance.interceptors.response.use(
(res) => res.data,
async (error) => {
const status = error.response?.status;
const refreshToken = getCookie({ name: 'refreshToken', cookieHeader });

if (status !== 401 || !refreshToken) return handleCommonError(error);

try {
const result = await handleRequestRefreshToken({
instance,
error,
refreshToken,
response: context?.res,
});
if (result) return result;
} catch (refreshTokenError) {
clearAuthCookiesWithCallback(() => Router.replace('/signin'));

return handleCommonError(refreshTokenError as AxiosError);
}
},
);

return instance;
};

// 토큰 추가 메소드
function addAccessToken(config: InternalAxiosRequestConfig) {
const accessToken = localStorage.getItem('accessToken');
if (accessToken && config.headers) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
function createAddAccessToken({ cookieHeader }: CookieHeaderParams) {
return function addAccessToken(config: InternalAxiosRequestConfig) {
if ((config as RetryRequestConfig)._retry) return config;

const accessToken = getCookie({ name: 'accessToken', cookieHeader });
if (accessToken && config.headers) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
};
}

// 공통 에러 처리 메소드
Expand All @@ -64,24 +83,28 @@ function handleCommonError(error: AxiosError) {
}

// 리프레쉬 토큰 및 에러 처리 메소드
async function handleRequestRefreshToken(
error: AxiosError,
refreshToken: string,
): Promise<AxiosResponse | null> {
async function handleRequestRefreshToken({
instance,
error,
refreshToken,
response,
}: RefreshTokenRequest): RefreshTokenResponse {
const originalRequest = error.config as RetryRequestConfig;

if (originalRequest._retry) return null;
originalRequest._retry = true;

const data = await updateAccessToken({ refreshToken });

// 갱신받은 access 토큰 저장
localStorage.setItem('accessToken', data.accessToken);
setCookie({ response, name: 'accessToken', value: data.accessToken, maxAge: 1800 });

// 새 토큰으로 헤더 수정
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
}
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;

const retryRequestConfig = {
...originalRequest,
baseURL: instance.defaults.baseURL,
headers: originalRequest.headers,
};

return apiClient(originalRequest); // 토큰 갱신 후 재요청
return await instance.request(retryRequestConfig); // 토큰 갱신 후 재요청
}
11 changes: 6 additions & 5 deletions src/api/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import apiClient from '@/api/apiClient';
import {
AccessTokenRequest,
AccessTokenResponse,
Expand All @@ -10,18 +9,20 @@ import {
SignupResponse,
} from '@/types/AuthTypes';

import { createApiClient } from './apiClient';

export const createUser = (data: SignupRequest): Promise<SignupResponse> => {
return apiClient.post(`/${process.env.NEXT_PUBLIC_TEAM}/auth/signUp`, data);
return createApiClient().post(`/${process.env.NEXT_PUBLIC_TEAM}/auth/signUp`, data);
};

export const loginUser = (data: LoginRequest): Promise<LoginResponse> => {
return apiClient.post(`/${process.env.NEXT_PUBLIC_TEAM}/auth/signIn`, data);
return createApiClient().post(`/${process.env.NEXT_PUBLIC_TEAM}/auth/signIn`, data);
};

export const updateAccessToken = (data: AccessTokenRequest): Promise<AccessTokenResponse> => {
return apiClient.post(`/${process.env.NEXT_PUBLIC_TEAM}/auth/refresh-token`, data);
return createApiClient().post(`/${process.env.NEXT_PUBLIC_TEAM}/auth/refresh-token`, data);
};

export const signInKakao = (data: KakakoSignInRequest): Promise<KakakoSignInResponse> => {
return apiClient.post(`/${process.env.NEXT_PUBLIC_TEAM}/auth/signIn/KAKAO`, data);
return createApiClient().post(`/${process.env.NEXT_PUBLIC_TEAM}/auth/signIn/KAKAO`, data);
};
10 changes: 7 additions & 3 deletions src/api/user.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import apiClient from '@/api/apiClient';
import { GetServerSidePropsContext } from 'next';

import { GetUserResponse } from '@/types/UserTypes';

export const getUser = (): Promise<GetUserResponse> => {
return apiClient.get(`/${process.env.NEXT_PUBLIC_TEAM}/users/me`);
import { createApiClient } from './apiClient';

// getServerSideProps 확인을 위해 cookieHeader 부분 임시 추가
export const getUser = (context?: GetServerSidePropsContext): Promise<GetUserResponse> => {
return createApiClient(context).get(`/${process.env.NEXT_PUBLIC_TEAM}/users/me`);
};
2 changes: 1 addition & 1 deletion src/hooks/useAuthRedirect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const useAuthRedirect = () => {

const { data: userData, isLoading } = useQuery({
queryKey: ['getUser'],
queryFn: getUser,
queryFn: () => getUser,
enabled: hasToken,
retry: false,
});
Expand Down
6 changes: 4 additions & 2 deletions src/hooks/useTokenCheck.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { useEffect, useState } from 'react';

import { getCookie } from '@/lib/cookie';

const useTokenCheck = () => {
const [hasToken, setHasToken] = useState(false);

useEffect(() => {
const token = localStorage.getItem('accessToken');
setHasToken(!!token);
const accessToken = getCookie({ name: 'accessToken' });
setHasToken(!!accessToken);
}, []);

return hasToken;
Expand Down
88 changes: 88 additions & 0 deletions src/lib/cookie.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import {
ClearAuthCookiesParams,
GetClientCookieParams,
GetCookieParams,
GetServerCookieParams,
GetServerCookieReturn,
SetCookieCallbackParams,
SetCookieParams,
SetCookieType,
SetServerCookieParams,
} from '@/types/CookieTypes';

import { isClient } from './utils';

export function getCookie({ cookieHeader, name }: GetCookieParams) {
return isClient() ? getClientCookie({ name }) : getServerCookie({ cookieHeader, name });
}

export function setCookie({ response, name, value, maxAge }: SetCookieParams) {
if (isClient()) {
return setClientCookie({ name, value, maxAge });
}
if (response) {
return setServerCookie({ response, name, value, maxAge });
}
return undefined;
}

export function getClientCookie({ name }: GetClientCookieParams) {
const cookieArr = document.cookie.split('; ');
for (const cookie of cookieArr) {
const [key, value] = cookie.split('=');
if (key === name) return decodeURIComponent(value);
}
}

export function getServerCookie({
cookieHeader,
name,
}: GetServerCookieParams): GetServerCookieReturn {
if (!cookieHeader) return undefined;

const cookies = cookieHeader.split(';');
for (const cookie of cookies) {
const [key, ...val] = cookie.trim().split('=');
if (key === name) {
return decodeURIComponent(val.join('='));
}
}
}

export function setClientCookie({ name, value, maxAge }: SetCookieType) {
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAge}; SameSite=Lax; Secure`;
}

export function setServerCookie({ response, name, value, maxAge }: SetServerCookieParams) {
const cookie = `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${maxAge}; SameSite=Lax; Secure; HttpOnly`;

Copy link
Collaborator

@626-ju 626-ju Jul 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생하셨습니다!
테스트용 코드랑 전체적으로 큰 흐름은 비슷한 것 같습니다
함수를 어떻게 분리했는지 이런 세세한 것만 조금 다른 것 같아요

근데 여기서 httpOnly를 추가해서 보내버리면
겟 서버사이드 프롭스를 통해 이걸 사용한 페이지가 렌더 된 후
->
나중에 로그아웃할 때 로컬에서 강제로 쿠키에 접근해 삭제해야 할텐데 문제 없이 접근할 수 있나요???
저도 잘 모르겠어서 멘토님께 여쭤보고 피드백 받거나
직접 실험해봐야지 확실히 알 수 있을 거 같습니다.

그리고 SameSite =Lax
-> 이것도 다른 사이트에서 이미지를 가져올 때(get) 쿠키를 안보낸다고 하는데
-> 오늘 이미지 화면에 뿌려보니까 s3 아마존 어쩌구에서 받아서 오더라구요?
(sprint-fe-project.s3.ap-northeast-2.amazonaws.com)
이 경우에 안 걸리는지도 확인해봐야 할 것 같아요.
->만약 걸려서 요청이 제대로 안 보내진다면? none으로 설정해야 할텐데... 다른 방법이 있는지 여쭤보는 것도 좋을 것 같습니다

const prevCookies = response.getHeader('Set-Cookie');

if (!prevCookies) {
response.setHeader('Set-Cookie', cookie);
} else if (Array.isArray(prevCookies)) {
response.setHeader('Set-Cookie', [...prevCookies, cookie]);
} else if (typeof prevCookies === 'string') {
response.setHeader('Set-Cookie', [prevCookies, cookie]);
}
}

export function setAuthCookiesWithCallback({
accessToken,
refreshToken,
callback,
}: SetCookieCallbackParams) {
setCookie({ name: 'accessToken', value: accessToken, maxAge: 1800 }); // 만료 30분
setCookie({ name: 'refreshToken', value: refreshToken, maxAge: 604800 }); // 만료 7일
if (isClient()) {
callback();
}
}

export function clearAuthCookiesWithCallback(callback: ClearAuthCookiesParams) {
setCookie({ name: 'accessToken', value: '', maxAge: 0 });
setCookie({ name: 'refreshToken', value: '', maxAge: 0 });
if (isClient()) {
callback();
}
}
4 changes: 4 additions & 0 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

export function isClient() {
return typeof window !== 'undefined';
}
9 changes: 3 additions & 6 deletions src/pages/oauth/signup/kakao.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,12 +48,9 @@ const KakaoLoginCallbackPage = () => {

return (
/* 에러 모달에서 확인 버튼 클릭 시 로그인 화면으로 리디렉트 처리 */
<ErrorModal
open={open}
onOpenChange={setOpen}
onConfirm={() => router.replace('/signin')}
errorMessage={errorMessage}
></ErrorModal>
<ErrorModal open={open} onOpenChange={setOpen} onConfirm={() => router.replace('/signin')}>
{errorMessage}
</ErrorModal>
);
};

Expand Down
7 changes: 4 additions & 3 deletions src/pages/signin/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import ErrorModal from '@/components/common/Modal/ErrorModal';
import { Button } from '@/components/ui/button';
import useAuthRedirect from '@/hooks/useAuthRedirect';
import useErrorModal from '@/hooks/useErrorModal';
import { setAuthCookiesWithCallback } from '@/lib/cookie';
import { emailSchema, passwordSchema } from '@/lib/form/schemas';
import { LoginRequest, LoginResponse } from '@/types/AuthTypes';

Expand Down Expand Up @@ -48,9 +49,9 @@ const SignIn = () => {
const loginMutation = useMutation<LoginResponse, AxiosError, LoginRequest>({
mutationFn: loginUser,
onSuccess: (data) => {
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
router.push('/');
const { accessToken, refreshToken } = data;
/* 로그인 후 로컬스토리지 토큰 저장 */
setAuthCookiesWithCallback({ accessToken, refreshToken, callback: () => router.push('/') });
},
onError: (error) => {
if (error.response?.status === 400) {
Expand Down
10 changes: 6 additions & 4 deletions src/pages/signup/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import ErrorModal from '@/components/common/Modal/ErrorModal';
import { Button } from '@/components/ui/button';
import useAuthRedirect from '@/hooks/useAuthRedirect';
import useErrorModal from '@/hooks/useErrorModal';
import { setAuthCookiesWithCallback } from '@/lib/cookie';
import {
emailSchema,
nicknameSchema,
Expand Down Expand Up @@ -77,10 +78,9 @@ const Signup = () => {
const loginMutation = useMutation<LoginResponse, AxiosError, LoginRequest>({
mutationFn: loginUser,
onSuccess: (data) => {
const { accessToken, refreshToken } = data;
/* 로그인 후 로컬스토리지 토큰 저장 */
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
router.push('/');
setAuthCookiesWithCallback({ accessToken, refreshToken, callback: () => router.push('/') });
},
onError: (error) => {
// API 에러를 모달로 출력
Expand Down Expand Up @@ -110,7 +110,9 @@ const Signup = () => {

return (
<AuthLayout className='min-h-[43rem] md:min-h-[48rem] lg:min-h-[50rem]'>
<ErrorModal open={open} onOpenChange={setOpen} errorMessage={errorMessage} />
<ErrorModal open={open} onOpenChange={setOpen}>
{errorMessage}
</ErrorModal>

<AuthLogo />
{/* 폼 시작 */}
Expand Down
Loading
Loading