분명 이때까지만 해도 자동 로그인 작동도 잘 됐고... 그런데... 문제가 여러 개 생겨버렸다.
그래서 트러블 슈팅 블로그 작성 가능하게 됐으니 럭키비키라고 생각하며 기쁜 마음으로 블로그를 적어보려 한다.
첫 번째
쿠키 관련 이슈
액세스 토큰 만료 시 쿠키 실시간 업데이트 X
원래 페이지 이동 시에 토큰을 확인하고, 자동 로그인 활성화일 땐 액세스 토큰을 재발급 받아서 로그인을 유지하도록 했는데,
페이지 이동 시에 다시 확인하고 발급받는 과정을 거치다 보니,
만료 후 로그인 전용 페이지 접근 시 리다이렉트 페이지가 노출되고 그 후 액세스 토큰이 업데이트 됐다.
이미 그 문제를 인지하고 있었고, 그래서 5분마다 확인하는 setInterval()을 넣어준 건데...
쿠키가 실시간 업데이트가 되지 않았다. 몇 번이나 확인해봤는데 안 됐음.
새로고침을 해야만 쿠키가 업데이트 됐음
5분마다 확인하는 건 소용이 없었던 것임.......
→ 사용자 경험 떡락 . . .
그래서 내가 택한 방법은,
토큰 유효기간 로컬 스토리지로 관리
- 로그인 시 로컬 스토리지에 액세스 토큰과 리프레시 토큰의 유효기간을 저장
- 현재 시간이 토큰의 유효기간을 넘어설 때, (만료) 로그아웃 또는 토큰 재발급 처리를 하도록 함.
// setCookieWithExpiry.ts
import { setCookie } from 'nookies';
// 쿠키 설정 함수
export const setCookieWithExpiry = (
name: string,
value: string,
maxAge: number, // 초 단위
) => {
const expiryDate = new Date(Date.now() + maxAge * 1000); // 현재 시간 + maxAge(유효기간)
setCookie(null, name, value, {
maxAge,
path: '/',
});
// 로컬 스토리지에 만료 시간 저장
localStorage.setItem(`${name}_expiry`, expiryDate.toISOString());
};
// sign-in/page.tsx
// 액세스 토큰을 쿠키에 저장
// 쿠키 설정 및 토큰 유효기간 로컬 스토리지에 저장
setCookieWithExpiry('access_token', result?.access_token, 60 * 60);
setCookieWithExpiry('refresh_token', result?.refresh_token, 7 * 24 * 60 * 60);
기존에 쿠키에만 액세스 토큰과 리프레시 토큰을 저장했는데,
유효기간까지 로컬 스토리지에 저장하도록 함수를 따로 생성해서 작성해 주었다.
Date.now()는 현재 시간을 밀리초로 변환해 주므로, maxAge(초 단위)에 1000을 곱하여 밀리초 단위로 바꾸고 더해주면
현재 시간에 쿠키의 만료 시간을 추가한 값이 된다.
그리고 이 값을 로컬 스토리지에 문자열 형식으로 저장해주면 끝.
→ ISOString으로 변환하는 것이 더 권장된다고 한다. (표준화된 형식 등..)
토큰 유효기간 만료에 따른 로직 처리
CheckLoginStatus.tsx의 로직을 완전히 뒤집었다.
증말... 스트레스
어제 내가 한 것은 뭐였지... ㅠ_ㅠ
// 로그인 상태 관리
const { isLoggedIn, setIsLoggedIn, refreshAccessToken, handleLogout } = useCheckLoginStatus();
const dispatch = useDispatch(); // Redux store에 액션 디스패치 위해
const autoSignin = localStorage.getItem('auto_signin'); // 자동 로그인 여부
const cookies = parseCookies();
const refreshToken = cookies['refresh_token']; // 리프레시 토큰 값
// 액세스, 리프레시 토큰 유효기간
const accessTokenExpireTime = localStorage.getItem('access_token_expiry');
const refreshTokenExpireTime = localStorage.getItem('refresh_token_expiry');
const now = new Date().getTime(); // 현재 시간
const accessTokenExpireDate = new Date(accessTokenExpireTime as string).getTime();
const refreshTokenExpireDate = new Date(refreshTokenExpireTime as string).getTime();
accessTokenExpireTime과 refreshTokenExpireTime을 string으로 타입 단언을 한 이유는,
어차피 둘 중 하나라도 없는 경우, (로그아웃) 이 로직을 건너 뛸 예정이라서...
let accessTimeout: NodeJS.Timeout | undefined;
let refreshTimeout: NodeJS.Timeout | undefined;
// 액세스 토큰 만료 타이머
if (accessTokenExpireDate > now) { // 유효기간 아직 안 지남 - 타이머 설정
accessTimeout = setTimeout(() => {
console.log('액세스 토큰 만료');
if (autoSignin === 'true') { // 자동 로그인 O
console.log('액세스 토큰 재발급');
if (refreshToken) {
refreshAccessToken(refreshToken); // 액세스 토큰 재발급
} else {
handleLogout('리프레시 토큰 만료로 인한 로그아웃'); // 로그아웃
}
} else {
handleLogout('액세스 토큰 만료로 인한 로그아웃'); // 로그아웃
}
}, accessTokenExpireDate - now); // 남은 시간
}
// 리프레시 토큰 만료 타이머
if (refreshTokenExpireDate > now) { // 유효기간 아직 안 지남 - 타이머 설정
refreshTimeout = setTimeout(() => {
console.log('리프레시 토큰 만료');
handleLogout('리프레시 토큰 만료로 인한 로그아웃'); // 무조건 로그아웃
}, refreshTokenExpireDate - now);
}
return () => {
if (accessTimeout) clearTimeout(accessTimeout);
if (refreshTimeout) clearTimeout(refreshTimeout);
};
그리고 accessTimeout, refreshTimeout을 만들어서 타이머를 생성해 주었다.
accessTokenExpire - now는 만료 시간 - 현재 시간이니까 만료까지 남은 시간을 의미하고,
그 시간이 지나면 타이머가 실행된다. (리프레시 토큰도 동일)
로그아웃 처리를 하는 handleLogout 함수와
리프레시 토큰을 이용하여 액세스 토큰을 재발급 받는 refreshAccessToken 함수는 auth.ts에 따로 저장해 두었다.
리프레시 토큰도 만료 가능성이 있기에, 타입 단언 말고 조건문으로 토큰 유무를 확인하고 진행하였다.
그리고 useEffect 안에 넣어줄 것이기 때문에 컴포넌트 언마운트 시 타이머 정리 코드까지 넣어주었다.
그리고 로그아웃 시에는 해당 로직이 실행되지 않도록, 로그아웃 처리를 모두 해주었다.
if (!accessTokenExpireDate || !refreshTokenExpireDate) {
console.log('이미 로그아웃 됨');
localStorage.removeItem('persist:user');
localStorage.removeItem('auto_signin');
localStorage.removeItem('access_token_expiry');
localStorage.removeItem('refresh_token_expiry');
dispatch(clearUser());
destroyCookie(null, 'access_token', { path: '/' });
destroyCookie(null, 'refresh_token', { path: '/' });
setIsLoggedIn(false); // 로그아웃 상태로 변경
return;
}
두 번째 이슈
타이머 설정 오류
새로고침을 하지 않으면 타이머 설정이 안됨
로그인 후 router.push('/')로 메인으로 이동하나,
새로고침이 되지 않아 컴포넌트가 다시 마운트되지 않고, 새로고침을 직접 해야만 타이머 설정 이 됐다.
→ 사용자 경험 떡락 . . . 맨 처음과 다를 게 없음 심지어.
로그인 후 메인 페이지로 이동할 때 새로고침
그냥 간단한 방식으로 해결했다...
로그인 후 메인 페이지로 이동 시 새로고침 실행
// 홈으로 이동 후 새로고침
window.location.href = '/'; // 새로고침
router.push('/') 사용 대신 window.location.href를 사용하여 페이지를 리로드하게 만들었다.
router.push('/')는 브라우저의 히스토리 API를 활용한 클라이언트 측 경로 변경으로 동작하며,
SPA 방식(Single Page Application)의 장점을 살리기 위해 새로고침 없이 경로를 변경!
세 번째 이슈
액세스 토큰 재발급 후 타이머 재설정 안됨
액세스 토큰이 한 번만 재발급이 가능하다.
accessTokenExpireDate를 useEffect의 의존성 배열에 넣어줌으로써,
액세스 토큰의 유효기간이 바뀔 때마다 (새로 발급 받을 때마다)
useEffect를 실행시켜 다시 타이머를 설정할 수 있게 해주었다.
의존성 배열에 accessTokenExpireDate 추가
useEffect(() => {
if (!accessTokenExpireDate || !refreshTokenExpireDate) {
console.log('이미 로그아웃 됨');
localStorage.removeItem('persist:user');
localStorage.removeItem('auto_signin');
localStorage.removeItem('access_token_expiry');
localStorage.removeItem('refresh_token_expiry');
dispatch(clearUser());
destroyCookie(null, 'access_token', { path: '/' });
destroyCookie(null, 'refresh_token', { path: '/' });
setIsLoggedIn(false); // 로그아웃 상태로 변경
return;
}
let accessTimeout: NodeJS.Timeout | undefined;
let refreshTimeout: NodeJS.Timeout | undefined;
// 액세스 토큰 만료 타이머
if (accessTokenExpireDate > now) {
accessTimeout = setTimeout(() => {
console.log('액세스 토큰 만료');
if (autoSignin === 'true') {
console.log('액세스 토큰 재발급');
if (refreshToken) {
refreshAccessToken(refreshToken); // 액세스 토큰 재발급
}
} else {
handleLogout('액세스 토큰 만료로 인한 로그아웃');
}
}, accessTokenExpireDate - now);
}
// 리프레시 토큰 만료 타이머
if (refreshTokenExpireDate > now) {
refreshTimeout = setTimeout(() => {
console.log('리프레시 토큰 만료');
handleLogout('리프레시 토큰 만료로 인한 로그아웃');
}, refreshTokenExpireDate - now);
}
return () => {
if (accessTimeout) clearTimeout(accessTimeout);
if (refreshTimeout) clearTimeout(refreshTimeout);
};
}, [
accessTokenExpireDate,
refreshTokenExpireDate,
autoSignin,
handleLogout,
now,
refreshAccessToken,
refreshToken,
dispatch,
setIsLoggedIn,
]);
네 번째 이슈
토스트 팝업이 여러 번 노출됨
로그아웃 시에만 노출되는 것이 아닌, 그 이후로 새로고침 할 때마다 계속 노출됨
isLoggedIn이라는 상태로 로그인 상태를 관리했는데,
당연한 것이 isLoggedIn == false일 때 토스트 팝업을 노출시키다 보니, 로그아웃 상태에서 계속 노출되는 게 당연했다.
결국 토스트 노출 여부도 로컬 스토리지로 관리하여 한 번만 노출될 수 있도록 했다.
1. 로그인 시 - toastShown = false
2. 로그인 만료 처리 후 - toastShown = true로 변경
3. 토스트 팝업은 toastShown = false일 때만 노출 (새 로그인 후)
로그인 시 toastShown 로컬 스토리지에 저장
localStorage.setItem('toast_shown', 'false');
로그아웃 처리 시 토스트 팝업 노출 후 toastShown를 false로 변경
const toastShown = localStorage.getItem('toast_shown');
useEffect(() => {
if (isLoggedIn == false && toastShown == 'false') {
// 로그인 만료 시 Toast 출력
toast({
message: ['로그인이 만료되었습니다.', '다시 로그인을 해주세요!'],
type: 'error',
});
localStorage.setItem('toast_shown', 'true');
}
}, [isLoggedIn, toastShown]);
해결 ~.~
근데 이렇게 로컬 스토리지에 뭘 많이 넣어놔도 되나 싶다... 8ㅅ8
auth.ts
로그아웃 처리 함수와 토큰 재발급 함수는 이렇게 생겼다.
import { useState } from 'react';
import { destroyCookie, setCookie } from 'nookies';
import { clearUser } from '@/store/userSlice';
import { useDispatch } from 'react-redux';
import { useAccessTokenRefreshMutation } from '@/api/userApi';
import { setCookieWithExpiry } from '@/app/sign-in/setCookieWithExpiry';
import { usePathname, useRouter } from 'next/navigation';
export const useCheckLoginStatus = () => {
const [accessTokenRefresh] = useAccessTokenRefreshMutation();
const dispatch = useDispatch();
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null); // 로그인 상태 관리
const router = useRouter();
const pathName = usePathname();
const refreshAccessToken = async (refreshToken: string) => {
try {
const result = await accessTokenRefresh(
JSON.stringify({ refresh_token: refreshToken }),
).unwrap();
setCookieWithExpiry('access_token', result?.access_token, 60 * 60);
setIsLoggedIn(true); // 로그인 상태로 변경
} catch (err) {
console.error('액세스 토큰 재발급 실패:', err);
handleLogout('로그인이 만료되었습니다.');
}
};
const handleLogout = (message: string) => {
console.log(message);
localStorage.removeItem('persist:user');
localStorage.removeItem('auto_signin');
localStorage.removeItem('access_token_expiry');
localStorage.removeItem('refresh_token_expiry');
dispatch(clearUser());
destroyCookie(null, 'access_token', { path: '/' });
destroyCookie(null, 'refresh_token', { path: '/' });
setIsLoggedIn(false); // 로그아웃 상태로 변경
if (
pathName === '/edit-profile' ||
pathName === '/confirm-password' ||
pathName === '/my-travel'
) {
setCookie(null, 'redirectMode', 'NOT_SIGNED_IN');
router.push('/redirect');
}
};
return { isLoggedIn, setIsLoggedIn, refreshAccessToken, handleLogout };
};
로그인 만료 시, 현재 경로가 로그인 전용 페이지일 경우, 리다이렉트 페이지로 이동되도록 해주었다.
트러블 슈팅 끝 ~.~