문제 상황: 토큰 갱신 후 쿠키 저장 중 에러 발생
Next.js에서 JWT 기반 인증을 구현하면서, 리프레시 토큰을 통해 액세스 토큰을 자동으로 재발급받는 기능을 구현하고자 했다.
처음에는 서버 액션에서 `getUserInfo()`를 호출하고, 액세스 토큰이 만료되어 401 에러가 발생하면 `refreshToken()` 함수를 호출해 쿠키에 새로운 토큰을 저장하는 방식으로 구성했다.
하지만 쿠키에 새로운 토큰을 저장하는 과정에서 다음과 같은 에러가 발생했다.
Cookies can only be modified in a Server Action or Route Handler
이는 `refreshToken()` 함수가 다른 서버 액션(`getUserInfo`) 안에서 일반 함수처럼 호출되면서, 원래 의도대도 "서버 액션"으로 기능하지 않았기 때문이다.
일반 함수처럼 사용된 함수 내부에서 `cookies().set()`을 호출하자, Next.js가 허용하지 않는 방식으로 쿠키를 수정하려 했다고 판단하고 에러를 발생시킨 것이다.
해결 방안: 미들웨어 활용
이 문제를 해결하기 위해 Next.js의 미들웨어를 사용했다. 미들웨어는 서버 측에서 요청을 가로채어 처리할 수 있는 기능으로, 특정 경로 진입 전에 토큰을 검사하고 필요한 경우 자동으로 갱신할 수 있다.
미들웨어는 서버 컴포넌트의 렌더링 이전에 동작하므로, 이 레이어에서 토큰을 갱신하고 쿠키를 설정하면 이후 컴포넌트에서는 최신 인증 상태를 바탕으로 동작할 수 있다.
미들웨어 토큰 갱신 흐름 요약
- `/admin` 경로로 진입 시 middleware 실행 ( 관리자 페이지 접근 보호 )
- 요청 쿠키에서 accessToken, refreshToken 추출
- refreshToken이 없으면 `/login` 경로로 리다이렉트
- accessToken만 없거나 토큰이 만료되었다면 재발급 시도
- 토큰 재발급 성공 시 새로운 토큰을 쿠키에 저장
구현 방식: 미들웨어에서 토큰 유효성 검증 및 재발급
1. 미들웨어 함수
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// 미들웨어 실행 페이지
export const config = {
matcher: ['/admin/:path*'],
};
const REDIRECT_PATH = '/login';
// 관리자 페이지 접근 시 토큰 유효성 검사 및 자동 갱신 처리
export async function middleware(request: NextRequest) {
// 요청 쿠키에서 토큰 추출
const accessToken = request.cookies.get('accessToken')?.value;
const refreshToken = request.cookies.get('refreshToken')?.value;
// 리프레시 토큰이 없으면 로그인 페이지로 이동
if (!refreshToken) {
console.warn('[Middleware] 리프레시 토큰 없음 → 로그인 페이지로 리다이렉트');
return redirectToLogin(request);
}
// 액세스 토큰이 없더라도 리프레시 토큰이 있다면 재발급 시도
if (!accessToken) {
console.warn('[Middleware] 액세스 토큰 없음 → 리프레시 토큰으로 재발급 시도');
const refreshed = await refreshTokens(refreshToken);
if (refreshed) {
return refreshed;
}
console.error('[Middleware] 액세스 토큰 재발급 실패 → 로그인 페이지로 리다이렉트');
return redirectToLogin(request);
}
// 토큰 만료 여부 확인
const { isAccessTokenValid, isRefreshTokenValid } = isValidToken({
accesstoken: accessToken,
refreshtoken: refreshToken,
});
// 리프레시 토큰이 만료된 경우 로그아웃 처리
if (!isRefreshTokenValid) {
console.warn('[Middleware] 리프레시 토큰 만료 → 로그인 페이지로 리다이렉트');
return redirectToLogin(request);
}
// 액세스 토큰만 만료된 경우 재발급 시도
if (!isAccessTokenValid) {
console.info('[Middleware] 액세스 토큰 만료 → 재발급 시도');
const refreshed = await refreshTokens(refreshToken);
if (refreshed) {
return refreshed;
}
// 재발급 실패 시 로그인 페이지로 이동
console.error('[Middleware] 액세스 토큰 재발급 실패 → 로그인 페이지로 리다이렉트',);
return redirectToLogin(request);
}
return NextResponse.next();
}
클라이언트의 요청이 들어오면 먼저 쿠키에서 토큰 값을 추출하고, 만료 여부를 검사한 뒤 조건에 따라 재발급 또는 로그인 페이지로 리다이렉트 한다.
2. JWT 유효성 확인 함수
// JWT 토큰 만료 여부 검사
function isValidToken({
accesstoken,
refreshtoken,
}: {
accesstoken?: string;
refreshtoken?: string;
}): {
isAccessTokenValid?: boolean;
isRefreshTokenValid?: boolean;
} {
// 현재 시간을 초 단위로 가져오기 (Unix Timestamp 형식)
const currentTime = Math.floor(Date.now() / 1000);
// 결과 객체를 초기화 (유효성 여부를 저장)
const result: {
isAccessTokenValid?: boolean;
isRefreshTokenValid?: boolean;
} = {};
try {
// 액세스 토큰이 존재하면 디코딩하여 만료 시간(`exp`) 확인
if (accesstoken) {
// JWT의 payload 부분(base64) 디코딩
const accessTokenPayload = JSON.parse(atob(accesstoken.split('.')[1]));
// 현재 시간과 만료 시간을 비교하여 유효성 판단
result.isAccessTokenValid = accessTokenPayload.exp > currentTime;
}
// 리프레쉬 토큰이 존재하면 디코딩하여 만료 시간(`exp`) 확인
if (refreshtoken) {
// JWT의 payload 부분(base64) 디코딩
const refreshTokenPayload = JSON.parse(atob(refreshtoken.split('.')[1]));
// 현재 시간과 만료 시간을 비교하여 유효성 판단
result.isRefreshTokenValid = refreshTokenPayload.exp > currentTime;
}
} catch (error) {
console.error('토큰 디코딩 실패:', error);
}
// 유효성 결과를 반환
return result;
}
// 로그인 페이지로 리다이렉트
function redirectToLogin(request: NextRequest) {
return NextResponse.redirect(new URL(REDIRECT_PATH, request.url));
}
JWT의 `payload`에서 `exp` 값을 디코딩하여 현재 시간과 비교함으로써 유효 여부를 판단한다.
3. 토큰 재발급 함수
async function refreshTokens(
refreshToken: string,
): Promise<NextResponse | null> {
try {
const apiBaseUrl = process.env.NEXT_PUBLIC_API_URL;
if (!apiBaseUrl) {
console.error('[Middleware] NEXT_PUBLIC_API_URL이 설정되어 있지 않습니다.');
return null;
}
// 백엔드 토큰 갱신 API 호출
const refreshResponse = await fetch(`${apiBaseUrl}/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken }),
});
if (!refreshResponse.ok) {
console.error('[Middleware] 토큰 갱신 요청 실패:', refreshResponse.status);
return null;
}
// API 응답에서 토큰 추출
const json = (await refreshResponse.json()) as {
success?: boolean;
data?: { accessToken?: string; refreshToken?: string };
};
if (!json.success || !json.data?.accessToken || !json.data.refreshToken) {
console.error('[Middleware] 토큰 갱신 응답 형식이 올바르지 않습니다.', json);
return null;
}
const response = NextResponse.next();
// 재발급된 토큰을 쿠키에 저장
response.cookies.set('accessToken', json.data.accessToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24, // 하루
path: '/',
});
response.cookies.set('refreshToken', json.data.refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30일
path: '/',
});
return response;
} catch (error) {
console.error('[Middleware] 토큰 갱신 중 오류 발생:', error);
return null;
}
}
백엔드 API로 리프레시 토큰을 전송해 새 토큰을 발급받고, 이를 쿠키에 저장한다.
참고 문서
- Next.js로 Access Token 만료 확인 및 재발급 받기
- [Nextjs] Next.js Middleware로 JWT Access Token 갱신하기
- Nextjs 앱라우터 서버컴포넌트에서 쿠키 세팅이 안되는 이유