
🚨 문제 상황
현재 Next.js + Express 백엔드로 구성된 블로그 애플리케이션에서 JWT 기반 인증 시스템을 사용하고 있습니다.
토큰 구성:
Access Token: 15분 (짧은 수명)
Refresh Token: 7일 (긴 수명)
기대 동작:
Access Token 만료 시 자동으로 Refresh Token을 사용해 갱신
사용자는 7일간 로그인 상태 유지
실제 문제:
15분 후 페이지 새로고침 시 강제 로그아웃
🔍 원인 분석
1. SSR 리다이렉트가 갱신 기회를 차단
현재 구현에서는 서버 사이드에서 Access Token이 없으면 즉시 로그인 페이지로 리다이렉트합니다.
// 현재 문제가 있는 로직
if (!accessToken && refreshToken) {
console.log("❌ accessToken이 없음");
return res.status(401).json({ message: "There's only refresh token" });
}
if (!accessToken && !refreshToken) {
return res.status(401).json({ message: "No access token provided" });
}
문제점:
서버가 Access Token만 확인하고 Refresh Token 존재 여부는 고려하지 않음
클라이언트가 토큰 갱신을 시도할 기회를 제공하지 않음
SSR에서 즉시 인증 실패로 판단하여 리다이렉트
2. 클라이언트 측 리프레시 로직의 무조건 실행
// 현재 문제가 있는 로직
const handleLogin = useCallback(async () => {
try {
await checkAuth(); // 로그인하지 않은 사용자도 항상 실행
router.replace("/");
} catch (error) {
console.error("로그인 처리 중 오류 발생:", error);
router.replace("/");
}
}, [router, checkAuth]);
문제점:
로그인하지 않은 사용자도
/auth/refresh
호출의미 없는 요청으로 서버 리소스 낭비
불필요한 네트워크 트래픽 발생
💡 해결 방안
1. 서버 미들웨어 수정 – SSR에서의 오판 줄이기
기존 문제는 SSR에서 Access Token이 없을 때 바로 로그인 페이지로 리다이렉트하는 로직이었습니다.이 방식의 문제는 “Refresh Token이 있는 사용자”도 토큰 갱신 기회를 얻지 못한다는 점입니다.즉, 서버가 너무 ‘단호하게’ 로그아웃 판정을 내려버린 거죠.
그래서 서버 미들웨어를 이렇게 단순화했습니다.
export const verifyToken = async (
req: Request,
res: Response,
next: NextFunction
) => {
const accessToken = req.cookies?.accessToken;
// 1️⃣ Access Token이 없으면 그냥 401을 던지고 끝.
// (로그아웃 판단 X, 리다이렉트 X)
if (!accessToken) {
res.status(401).json({ message: "No access token provided" });
return;
}
try {
// 2️⃣ 유효하면 디코딩해서 req.user에 저장
const decoded = jwt.verify(accessToken, JWT_SECRET) as CustomJwtPayload;
req.user = decoded;
next();
} catch (error) {
if (error instanceof jwt.TokenExpiredError) {
// 3️⃣ 만료됐어도 401로만 응답.
// → 클라이언트가 Refresh Token으로 갱신 시도
res.status(401).json({ message: "Token expired" });
return;
}
res.status(403).json({ message: "Invalid token" });
}
};
여기서 핵심은 서버는 더 이상 Refresh Token 여부를 신경 쓰지 않는다는 것입니다.서버의 역할은 “Access Token이 유효한지”만 판단하고, 나머지 처리는 클라이언트에 맡깁니다.이렇게 해야 SSR에서도 ‘갱신 기회’를 놓치지 않습니다.
2. 클라이언트 인증 요청 – 401만 보고 리프레시 시도
기존에는 로그인 여부와 상관없이 /auth/refresh
를 무조건 호출했습니다.그 결과 로그인 안 한 사람도 쓸데없이 서버에 요청을 날리는 비효율이 발생했죠.
이 문제를 해결하려면, 401 상태 코드가 왔을 때만 Refresh Token 로직을 실행하면 됩니다.
export const fetchWithAuth = async (url: string, options: RequestInit = {}) => {
const fetchOptions: RequestInit = {
...options,
credentials: "include", // 1️⃣ 쿠키 전송 필수
headers: {
"Content-Type": "application/json",
...(options.headers || {}),
},
};
let response = await fetch(url, fetchOptions);
// 2️⃣ 401이면 Access Token 만료 or 미존재 → refresh 시도
if (response.status === 401) {
const refreshSuccessful = await refreshToken();
if (!refreshSuccessful) {
// 3️⃣ 리프레시 실패 → 로그인 페이지로 강제 이동
window.location.href = "/login";
throw new Error("Authentication failed");
}
// 4️⃣ 토큰 갱신 후 원래 요청 재시도
response = await fetch(url, fetchOptions);
}
return response;
};
여기서 중요한 건 중앙 집중화입니다.모든 API 요청이 fetchWithAuth
를 거치도록 만들면, 토큰 갱신과 재요청 로직을 한 곳에서 관리할 수 있습니다.그 덕에 중복 코드도 사라지고, 유지보수가 훨씬 깔끔해졌습니다.
3. 인증 상태 초기화 – 앱 시작 시 1번만 실행
이제 남은 건 “앱이 처음 로드될 때” 인증 상태를 올바르게 세팅하는 겁니다.기존엔 페이지 이동 때마다 checkAuth
를 실행하는 식이었는데, 이러면 불필요한 요청이 너무 많습니다.
그래서 useRef
로 “초기화는 단 한 번”만 실행하도록 만들었습니다.
export default function Providers({ children }: { children: React.ReactNode }) {
const { checkAuth } = useAuthStore();
const authInitialized = useRef(false);
useEffect(() => {
const initializeAuth = async () => {
if (authInitialized.current) return;
try {
authInitialized.current = true;
await checkAuth(); // 1️⃣ 앱 로드 시 최초 1회
} catch (error) {
console.error("Auth initialization failed:", error);
실패 시 재시도 하지 않음.
authInitialized.current = true;
}
};
initializeAuth();
}, [checkAuth]);
return (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
}
이렇게 하면:
앱 진입 시 한 번만
checkAuth
호출이후에는
fetchWithAuth
를 통해 API 요청 과정에서만 상태 갱신불필요한 네트워크 요청 제거
🎯 정리
핵심은 역할 분리입니다.
서버 → “Access Token 유효 여부만 체크”
클라이언트 → “401 응답이면 Refresh Token 갱신 시도”
인증 흐름 →
fetchWithAuth
에서 중앙 관리