뭉균의 개발일지

[Next.js] 미들웨어로 refreshToken 요청 보내기 본문

Next.js

[Next.js] 미들웨어로 refreshToken 요청 보내기

박뭉균 2024. 12. 16. 16:52

🚪 들어가며

 

refreshToken을 사용하여, 서버에 토큰 재발급을 요청하는 상황에서는 인터셉터 또는 미들웨러를 사용할 수 있습니다. 저는 우선 아래 코드와 같이 인터셉터를 사용해보려고 했습니다.

 

axiosInstance.interceptors.response.use(
  (res) => res, // 성공적인 응답은 그대로 반환
  async (error) => {
    const originalRequest = error.config;

    // 401 Unauthorized 에러가 발생하고, 아직 리프레시 토큰을 시도하지 않은 경우
    if (error.response?.status === 401 && !originalRequest._retry) {
      originalRequest._retry = true;

    // 401 외 다른 에러는 그대로 반환
    return Promise.reject(error);
  }
);

 

저는 서버와 클라이언트 간에 토큰을 주고받는 상황에서 보안을 위해 토큰을 쿠키에 저장하고 있었습니다. 초기에는 Axios의 인터셉터를 사용해 refresh 요청을 통해 받은 토큰을 쿠키에 저장하려 했습니다. 하지만, App Router 환경에서는 인터셉터를 통해 쿠키를 설정하는 데 한계가 있다는 점을 알게 되었습니다.

 

 

Page Router

 

Page Router는 응답을 스트리밍하지 않기 때문에, 렌더링 중에 발견된 헤더를 응답 본문(body) 전송 전에 설정할 수 있습니다.

 

 

App Router

 

  • App Router는 React의 서버 사이드 렌더링(SSR)과 React 서버 컴포넌트(RSC)를 지원하기 위해 스트리밍을 사용합니다.
  • HTTP 응답에서 헤더는 항상 응답 본문보다 먼저 전송되어야 합니다.
  • 스트리밍 환경에서는 서버에서 렌더링된 본문이 클라이언트로 즉시 전송되므로, 렌더링 도중에 헤더를 설정하려 하면 이미 본문 일부가 전송된 후일 수 있습니다.
  • 이로 인해 쿠키 설정과 같은 작업이 어려워질 수 있습니다.

 

이 문제를 해결하기 위해 Next.js에서는 Middleware를 활용해 헤더를 설정하는 것을 권장하고 있습니다. Middleware는 응답 본문이 전송되기 전에 실행되므로, 안전하게 헤더나 쿠키를 설정할 수 있습니다.

 

 

 

🔖 Middleware 동작 과정

 

아래 코드들은 Next.js middleware토큰 갱신 로직을 구현한 예제입니다. 사용자가 요청할 때마다 액세스 토큰이 유효한지 확인하고, 만약 유효하지 않으면 리프레시 토큰을 통해 새로운 액세스 토큰을 발급받는 과정입니다. 차례대로 함수 코드를 보며 과정을 진행하겠습니다.

 

 

1. middleware 함수

Next.js middleware는 특정 요청에 대해 먼저 실행되는 코드입니다. 요청이 들어오면 middleware 함수가 실행되어, 요청이 정상적인지를 판단하고, 응답을 반환합니다. 이 코드에서는 middleware 함수가 실행될 때 request 객체를 인자로 받아옵니다. pathname을 확인하여 특정 경로(/로 시작하는)인 경우, 액세스 토큰이 유효한지 검사하고, 유효하지 않으면 리프레시 토큰을 이용해 새로운 액세스 토큰을 발급받는 작업을 진행합니다.

 

export const middleware = async (request: NextRequest): Promise<NextResponse> => {
  const { pathname } = request.nextUrl;

  if (pathname.startsWith("/")) {
    return await refreshToken(request);
  }

  return NextResponse.next();
};

 

 

2. refreshToken 함수

middleware에서 경로가 /로 시작하는 경우 refreshToken 함수가 호출됩니다. 이 함수는 요청에 담긴 accessTokenrefreshToken을 쿠키에서 읽어옵니다. decodeToken 함수로 액세스 토큰을 디코드하여 유효 기간이 지났는지 확인합니다. 액세스 토큰이 없거나 만료되었으면, 리프레시 토큰을 통해 새로운 액세스 토큰을 요청합니다. 리프레시 토큰을 사용해 새로운 액세스 토큰새로운 리프레시 토큰을 받아오면, 이 토큰들을 응답 쿠키에 설정하여 반환합니다. 이 토큰들은 각각 1시간(액세스 토큰)과 7일(리프레시 토큰) 동안 유효합니다.

 

const refreshToken = async (req: NextRequest) => {
  const AccessToken = req.cookies.get("accessToken")?.value;
  const RefreshToken = req.cookies.get("refreshToken")?.value;

  const res = NextResponse.next();

  const response = decodeToken(AccessToken);

  if (!response || isTokenExpired(response)) {
    // 리프레시 토큰을 이용해 새로운 액세스 토큰 발급
    const { accessToken, refreshToken } = await refreshAccessToken(RefreshToken || "");
    const access_token = {
      name: "accessToken",
      expires: Date.now() + 1 * 60 * 60 * 1000, // 1시간,
      httpOnly: true,
      value: accessToken,
    };
    const refresh_token = {
      name: "refreshToken",
      expires: Date.now() + 7 * 24 * 60 * 60 * 1000, // 7일,
      httpOnly: true,
      value: refreshToken,
    };
    res.cookies.set(access_token);
    res.cookies.set(refresh_token);
  }

  return res;
};

 

 

 

3. refreshAccessToken 함수

refreshAccessToken 함수는 리프레시 토큰을 이용해 서버에 요청을 보내 새로운 액세스 토큰을 받아옵니다. 요청 시 Authorization 헤더에 Bearer 방식으로 리프레시 토큰을 포함하여 "/auth/tokens"에 POST 요청을 보냅니다. 요청이 성공하면, 응답으로 액세스 토큰과 리프레시 토큰을 반환받아 이를 쿠키에 저장합니다.

 

const refreshAccessToken = async (refreshToken: string) => {
  if (!refreshToken) return null;
  try {
    const response = await axiosInstance.post(
      "/auth/tokens",
      {},
      {
        headers: {
          "Content-Type": "application/json",
          Authorization: `Bearer ${refreshToken}`,
        },
      }
    );

    return response.data;
  } catch (error) {
    if (isAxiosError(error)) {
      throw error;
    }
    throw error;
  }
};

 

 

4. decodeToken 함수

이 함수는 주어진 accessToken디코딩하여 페이로드(payload)를 추출하고, 해당 페이로드를 JSON 객체로 변환합니다. JWT 토큰은 header.payload.signature 형태로 이루어져 있으며, payload 부분에 사용자의 정보 및 토큰의 유효 시간(exp) 등의 정보가 들어 있습니다. Base64Url로 인코딩된 페이로드를 디코딩하여 JSON 객체로 변환합니다. 

 

const decodeToken = (accessToken: string | undefined) => {
  if (!accessToken) {
    return null; // 토큰이 비어있음
  }

  // JWT를 '.'로 분리하여 페이로드를 가져옵니다.
  const parts = accessToken.split(".");
  if (parts.length !== 3) {
    return null; // 잘못된 형식의 토큰
  }

  // Base64Url로 인코딩된 페이로드를 디코드합니다.
  const payload = parts[1];
  const decodedPayload = JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));

  return decodedPayload;
};

 


decodedPayload 반환 예시

decodedPayload: {
  id: 1307,
  iat: 1734333607,
  exp: 1734335407,
}

 

 

5. isTokenExpired 함수

 

이 함수는 디코딩된 토큰의 만료 시간(exp)을 확인하여 토큰이 만료되었는지 검사합니다. exp 값은 토큰 발급 시 포함된 만료 시간(초 단위)으로, 현재 시간이 이 값보다 크면 토큰이 만료된 것으로 간주합니다. 만약 exp 값이 없으면 토큰이 만료된 것으로 처리합니다.

 

const isTokenExpired = (decodedPayload: DecodedPayload) => {
  console.log("decodedPayload:", decodedPayload);
  const exp = decodedPayload?.exp; // exp는 토큰 만료 시간을 나타내는 값 (초 단위)

  if (!exp) {
    return true; // exp가 없다면 만료된 것으로 처리
  }

  const currentTime = Math.floor(Date.now() / 1000); // 현재 시간 (초 단위)

  // 만약 현재 시간이 exp보다 크면 만료된 것으로 간주
  return currentTime > exp;
};

 

 

전체흐름

 

전체흐름을 요약하면 다음과 같습니다. 

 

  1. 요청이 들어오면 middleware 함수가 호출되어, 요청 경로가 /로 시작하는지 확인합니다.
  2. 경로가 /인 경우, 쿠키에서 액세스 토큰과 리프레시 토큰을 읽어옵니다.
  3. 액세스 토큰을 디코딩하여 만료되었는지 확인하고, 만약 만료되었거나 토큰이 없으면 리프레시 토큰을 사용해 새로운 액세스 토큰을 발급받습니다.
  4. 새로운 액세스 토큰과 리프레시 토큰을 응답 쿠키에 저장하고 반환하여, 이후의 요청에서 유효한 토큰을 사용할 수 있도록 합니다.

 

 

활용 사례

  • JWT 인증이 필요한 웹 애플리케이션에서, 사용자가 로그인을 한 후, 일정 시간 동안 유효한 액세스 토큰을 사용합니다. 이 액세스 토큰이 만료되었을 때, 자동으로 리프레시 토큰을 사용해 새로운 액세스 토큰을 발급받아 사용자의 세션을 유지합니다.
  • 이 코드는 서버에서 클라이언트의 액세스 토큰 만료를 자동으로 처리하고, 클라이언트가 리프레시 토큰을 수동으로 갱신할 필요 없이 자동으로 갱신되도록 합니다.

 

 

📚 참고 자료

 

https://nextjs.org/docs/pages/building-your-application/routing/middleware

 

Routing: Middleware | Next.js

Learn how to use Middleware to run code before a request is completed.

nextjs.org

 

https://github.com/vercel/next.js/discussions/58110

 

App Router Custom Header Use Cases · vercel next.js · Discussion #58110

We’ve noticed questions and an interest in being able to set headers on the Response during an App Router render. We wanted to clarify why we don’t provide a generic solution for this at this time ...

github.com