[Next.js] Middleware로 토큰 만료 시 Access Token 자동 갱신 구현하기

문제 상황: 토큰 갱신 후 쿠키 저장 중 에러 발생

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의 미들웨어를 사용했다. 미들웨어는 서버 측에서 요청을 가로채어 처리할 수 있는 기능으로, 특정 경로 진입 전에 토큰을 검사하고 필요한 경우 자동으로 갱신할 수 있다.

 

미들웨어는 서버 컴포넌트의 렌더링 이전에 동작하므로, 이 레이어에서 토큰을 갱신하고 쿠키를 설정하면 이후 컴포넌트에서는 최신 인증 상태를 바탕으로 동작할 수 있다.

 

미들웨어 토큰 갱신 흐름 요약

  1. `/admin` 경로로 진입 시 middleware 실행 ( 관리자 페이지 접근 보호 )
  2. 요청 쿠키에서  accessToken, refreshToken 추출
  3. refreshToken이 없으면 `/login` 경로로 리다이렉트
  4. accessToken만 없거나 토큰이 만료되었다면 재발급 시도
  5. 토큰 재발급 성공 시 새로운 토큰을 쿠키에 저장

 

구현 방식: 미들웨어에서 토큰 유효성 검증 및 재발급

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로 리프레시 토큰을 전송해 새 토큰을 발급받고, 이를 쿠키에 저장한다.

 

참고 문서