Next.js로 Access Token 만료 확인 및 재발급 받기

ClydeHan·2025년 1월 3일
18

nextjs jwt logo images

현재 적용중인 토큰 사용 보안 정책

인증/인가 방식

  • JWT Token 사용
    • Access Token & Refresh Token 사용
    • Refresh Token Rotation (RTR) 사용

Refresh Token Rotation (RTR)은 클라이언트가 리프레시 토큰을 사용할 때마다 서버가 새로운 리프레시 토큰을 발급하고, 이전 토큰을 무효화하는 방식이다. 이를 통해 탈취된 토큰의 재사용을 방지하고 보안을 강화한다.

JWT, Access Token & Refresh Token이란?: 프론트엔드 로그인 방식: 세션, 토큰, 그리고 JWT


토큰 저장 위치

  • Backend에서 Frontend로 Response Header에 Set-Cookie 포함하여 Cookie에 저장
    • Frontend Request Header에 Cookie 자동 포함
  • 속성
    • HTTP only
    • secure
    • SameSite=Strict

리스폰스 헤더에 Set-Cookie 포함하여 쿠키에 저장하게 되면, 브라우저는 서버에서 받은 쿠키를 자동으로 저장하고, 동일한 도메인으로 요청할 때 자동으로 전송한다. 이를 통해 클라이언트(프론트엔드)에서 별도로 토큰을 관리하거나 전송 로직을 구현할 필요가 없다. 또한, 토큰이 브라우저의 스크립트에 노출되지 않아 보안성도 향상된다.

쿠키는 속성을 통해 보안을 강화할 수 있다. HttpOnly는 JavaScript에서 쿠키 접근을 차단한다. secure는 쿠키가 HTTPS 연결에서만 전송되도록 제한한다. SameSite=Strict는 쿠키가 동일 사이트 내에서만 전송되도록 설정한다.


Token 유효 기간

  • Access Token 유효 기간: 30분
  • Refresh Token 유효 기간: 일주일

토큰의 유효 기간은 프로젝트의 성격에 따라 유연하게 조정될 수 있다. 일반적으로 Access Token은 보안을 위해 짧게 설정하고, Refresh Token은 사용자 경험을 고려해 비교적 길게 설정한다. 예를 들어, 민감한 데이터를 다루는 서비스에서는 더 짧은 유효 기간을 선택하고, 사용자 편의가 중요한 서비스에서는 유효 기간을 길게 설정할 수 있다.


Access Token 만료 시 재발급 방식

구분방식1: 백엔드 재발급방식2: 프론트엔드 재발급
보안성서버에서 중앙 통제, 민감 정보 보호클라이언트 복호화 시 민감 정보 노출 위험
서버 부하모든 요청 검증으로 부하 증가클라이언트에서 만료 확인으로 부하 감소
네트워크 비용모든 요청에 검증 포함만료된 경우에만 추가 요청 발생
구현 복잡성클라이언트 구현 단순클라이언트 측 로직 복잡성 증가

방식1: 백엔드에서 만료 확인 및 재발급

  1. 작동 방식
    • 클라이언트는 모든 요청에 토큰을 포함하여 서버로 전송한다.
    • 서버는 토큰의 유효성을 검증하고, 만료 시 Refresh Token을 사용하여 새 Access Token을 발급한다.
  2. 장점
    • 보안성 강화: 클라이언트에서 토큰을 복호화하지 않으므로 민감한 정보를 보호한다.
    • 중앙 관리: 토큰의 유효성과 만료를 서버가 통제하므로 정책 변경이나 관리가 용이하다.
    • 클라이언트 간 일관성: 모든 클라이언트가 동일한 인증 방식을 사용한다.
    • 단순한 클라이언트 구현: 클라이언트는 토큰 만료 여부를 확인하지 않아도 된다.
  3. 단점
    • 서버 부하 증가: 모든 요청마다 토큰 검증과 유효성 체크를 수행해야 하므로 서버 리소스 소모가 크다.
    • 네트워크 비용 증가: 만료되지 않은 요청에도 불필요한 검증 작업이 포함될 수 있다.

방식2: 프론트엔드에서 만료 확인 후 백엔드에 재발급 요청

  1. 작동 방식
    • 클라이언트가 JWT의 payload를 복호화하여 만료 시간(exp)을 확인한다.
    • 만료된 경우에만 서버에 Refresh Token을 사용한 재발급 요청한다.
  2. 장점
    • 서버 부하 감소: 클라이언트가 만료 여부를 자체적으로 확인하므로 불필요한 서버 요청 감소한다.
    • 네트워크 비용 절감: 만료되지 않은 경우, 추가 요청이 필요하지 않다.
    • 빠른 처리: 클라이언트 측에서 즉시 만료 여부를 확인 가능하다.
  3. 단점
    • 보안성 저하 가능성: 클라이언트에서 토큰을 복호화하여 처리하므로 민감 정보 노출 위험 증가한다.
    • 클라이언트 구현 복잡성 증가: 만료 확인 로직 및 재발급 요청 로직을 클라이언트에 구현해야 한다.

✅ 방식2 선택 이유

  1. Next.js middleware 사용으로 인한 보안 우려 해소
    • 클라이언트는 Response Header의 Cookie에 직접 접근하지 않고, Next.js 서버의 미들웨어에서 이를 처리한다.
    • 모든 요청이 Next.js 미들웨어를 거쳐 처리되므로 클라이언트는 Access Token의 만료 여부를 확인하지 않음. 즉, 민감 정보 노출 가능성을 대폭 줄인다.
  2. 서버 부하 감소
    • Next.js 미들웨어에서 Access Token의 유효성을 사전 검증함으로써 백엔드로 전달되는 요청량 감소한다.
    • 불필요한 재발급 요청을 방지하여 백엔드 부하 감소한다.
  3. 보안 정책과의 시너지
    • 토큰은 HTTP-Only, Secure, SameSite 옵션이 적용된 쿠키에 저장되어 탈취 위험이 매우 낮다.
    • 추가로 Refresh Token Rotation(RTR)을 적용하여, 탈취된 Refresh Token 재사용을 방지한다.

Next.js Middleware란?: Routing: Middleware


Next.js Middleware.ts Access Token 만료 확인 및 재발급 로직

참고: Next.js 14버전 App Router 사용중임

middleware.ts

import { ResponseCookies } from 'next/dist/compiled/@edge-runtime/cookies'
import { cookies } from 'next/headers'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'

import isValidToken from '@/lib/is-valid-token'

export async function middleware(request: NextRequest) {
  const API_URL = process.env.NEXT_PUBLIC_API_URL
  // API URL을 환경 변수에서 가져옴

  const cookieStore = cookies()
  // 요청에서 쿠키를 읽기 위해 cookieStore 생성

  const accesstoken = cookieStore.get('accesstoken')
  // 요청에서 'accesstoken' 쿠키를 가져옴

  const refreshtoken = cookieStore.get('refreshtoken')
  // 요청에서 'refreshtoken' 쿠키를 가져옴

  if (!accesstoken?.value || !refreshtoken?.value) {
    // 액세스 토큰 또는 리프레시 토큰이 없을 경우 로그인 페이지로 리다이렉트
    return NextResponse.redirect(new URL('/login', request.url))
  }

  const { isAccessTokenValid, isRefreshTokenValid } = isValidToken({
    accesstoken: accesstoken.value,
    refreshtoken: refreshtoken.value,
  })
  // 토큰의 유효성을 검사

  if (!isRefreshTokenValid) {
    // 리프레시 토큰이 유효하지 않을 경우 로그인 페이지로 리다이렉트
    return NextResponse.redirect(new URL('/login', request.url))
  }

  if (!isAccessTokenValid) {
    // 액세스 토큰이 유효하지 않을 경우 액세스 토큰을 재발급
    try {
      const response = await fetch(`${API_URL}/api/v1/auth/refresh`, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          cookie: `refreshtoken=${refreshtoken.value}`,
        },
        credentials: 'include',
      })
      // API를 호출하여 새 액세스 토큰과 리프레시 토큰을 요청

      if (!response.ok) {
        // 응답이 성공적이지 않으면 로그인 페이지로 리다이렉트
        return NextResponse.redirect(new URL('/login', request.url))
      }

      if (response.ok) {
        // 응답이 성공적이면 다음 요청을 처리
        const res = NextResponse.next()
        const responseCookies = new ResponseCookies(response.headers)
        // 응답 헤더에서 쿠키를 읽음

        const accessToken = responseCookies.get('accesstoken')
        // 응답에서 'accesstoken' 쿠키를 가져옴

        const refreshToken = responseCookies.get('refreshtoken')
        // 응답에서 'refreshtoken' 쿠키를 가져옴

        if (accessToken) {
          // 새 액세스 토큰을 설정
          res.cookies.set('accesstoken', accessToken.value, {
            httpOnly: accessToken.httpOnly,
            sameSite: accessToken.sameSite,
            path: accessToken.path,
            secure: accessToken.secure,
          })
        }

        if (refreshToken) {
          // 새 리프레시 토큰을 설정
          res.cookies.set('refreshtoken', refreshToken.value, {
            httpOnly: refreshToken.httpOnly,
            sameSite: refreshToken.sameSite,
            path: refreshToken.path,
            secure: refreshToken.secure,
          })
        }

        return res
      }
    } catch (error) {
      // 토큰 재발급 중 오류가 발생하면 콘솔에 출력하고 로그인 페이지로 리다이렉트
      console.error('엑세스 토큰 재발급 중 오류 발생:', error)
      return NextResponse.redirect(new URL('/login', request.url))
    }
  }
}

export const config = {
  matcher: ['/security'],
  // 이 미들웨어를 '/security' 경로에서만 실행
}

is-valid-token.ts


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
}

export default isValidToken

Next.js Middleware.ts로 JWT 관리 시 겪은 문제들

참고 문헌

2개의 댓글

comment-user-thumbnail
2025년 1월 3일

저도 토큰 갱신에 대해서 고민하고 있었는데 감사합니다

1개의 답글