본문 바로가기
Frontend/React

Next.js SSR accesstoken 개선(Localstorage에서 cookie로)

by joy_95 2024. 11. 9.

 개요

현재 제가 담당하고 있는 웹사이트의 accessToken은 Localstorage에 보관하여 사용자 인증을 처리하고 있습니다. 초기에 Server side rendering을 사용하지 않기도 했고, 사용자 인증에 따른 큰 기능이 없었기 때문에 큰 문제는 없었습니다. 그러나 페이지들을 SSR으로 바꾸고, 인증정보가 중요해지는 결제 서비스 출시를 준비하면서 Localstorage의 한계를 크게 느끼게 되었습니다.

 

next.js에서 token을 Localstorage에서 관리했을 때의 불편함은 이렇습니다.

- 첫 진입 or 새로고침시 로그인이 풀려있는 UI가 잠깐 보인다.

- 인증이 필요한 페이지는 항상 로딩이 필요하다. 

  - 클라이언트단에서 인증 정보를 체크하는 잠깐의 텀이 항상 필요하게 됨.

  - 항상 인증 정보 체크가 완료된 후 해당 페이지의 로직을 실행시켜야함. 

  - 이로인해 콘텐츠를 보여주기까지 시간이 더 걸리게 됨. LCP 지표 악화. 사용자 경험 악화

 

그래서 accessToken을 cookie로 관리하기 위해 여러 정보를 찾아봤지만 제가 궁금해하는 부분들을 속 시원하게 알려주는 포스팅이 없었습니다. 그래서 next.js SSR에서 cookie를 세팅하는 방법에 대한 방법을 공유하고자 합니다.

 

 

기존 클라이언트 단에서의 token 제어

1. sign in 후 token 저장

accessToken은 클라이언트에서 Localstorage에 저장하여 사용했고, refreshToken은 서버쪽 API response header를 통해 httponly, secure 등의 보안 옵션과 함께 cookie로 저장되었습니다.

 

2. 웹사이트 초기 진입시 Localstorage의 accessToken을 전역상태관리 라이브러리인 recoil에 저장

 

3. 인증이 필요한 API는 axios config를 통해 제어

axios interceptor를 사용해서 request시 header의 Authorization에 Localstorage의 accessToken을 추가하여 요청하였습니다. 

그리고 interceptor response에서 token 에러시 refreshToken으로 새로운 accessToken을 발급하여 재요청하고, Localstorage를 업데이트하였습니다.

 

 

Next.js에서 SSR에서 토큰을 제어하려면 어떻게 해야할까?

구글링 했을 때 생각보다 자료가 없어서 방향을 잡기 어려웠습니다. 저 같은 경우에는 기술스택이 Next.js 13 Page directory를 사용하고 있었기 때문에 제 상황과 딱 맞는 포스팅이 없었습니다. 그래서 여러 포스팅을 보면서 제 상황에 맞게 적용한 사례를 소개해드리려고 합니다.

 

요약하자면,

1. 로그인시 accessToken을 cookie에 저장한다.(refreshToken은 서버 API에서 httpOnly로 세팅)

2. 모든 페이지의 getServerSideProps를 감싸고 있는 withAuth 고차함수를 만들어서 토큰과 유저정보를 제어한다.

3. withAuth의 로직은 아래와 같다.

- cookie의 accessToken을 참조하여 user API를 호출하고 데이터를 pageProps로 넘겨준다.

- accessToken이 없으면 cookie의 refreshToken을 참조하여 새로운 accessToken을 요청한 후, response header에 accessToken을 쿠키로 세팅하고, user API를 호출하여 데이터를 pageProps로 넘겨준다.

- 둘다 없으면 비로그인 유저로 간주한다.

4. 서버단에서의 토큰 만료는 이렇게 처리했다.

- user API 호출시 token error가 발생하면 refreshToken으로 새로운 accessToken을 발급한다.

- 새로 발급받은 accessToken을 set-cookie로 업데이트하고, user API를 다시 호출한다.

- 새로운 accessToken을 발급받는데 실패하면 refreshToken도 만료된 것으로 파악하고 sign-in page로 redirect 한다.

5. 페이지를 이동할 때마다 user API를 호출하기 때문에 CDN 캐싱을 통해 한번 받아온 document를 재사용한다.

- 이때 cdn cache 조건에 cookie accessToken을 추가하여 토큰 갱신시 원본서버로부터 다시 요청할 수 있도록 제어했다.

 

 

그래서 user 정보를 가져오는 코드는 대략 아래 형식으로 작성하게 되었습니다. 

const getUser = async (
  res: ServerResponse,
  accessToken?: string,
  refreshToken?: string,
) => {
  try {
    if (accessToken) {
      const userInfo = await getUserInfo(accessToken, refreshToken, res);
      return userInfo;
    } else if (refreshToken) {
      const { accessToken: newAccessToken } = await updateAccessToken(
        refreshToken,
      );
      res.setHeader(
        'Set-Cookie',
        `accessToken=${newAccessToken}; Path=/; Secure;`,
      );

      const userInfo = await getUserInfo(newAccessToken, refreshToken, res);
      return userInfo;
    } else {
      return null;
    }
  } catch (error) {
    res.setHeader(
      'Set-Cookie',
      `accessToken=; Path=/; Secure; Expires=Thu, 01 Jan 1970 00:00:00 GMT; max-age=-1;`,
    );
    return null;
  }
};

 

Route guard도 옮겨보자

비인증 유저가 인증이 필요한 페이지로 진입할 때 리다이렉트 시키는 로직을 클라이언트단에서 구현하고 있었습니다. cookie를 통해 인증을 할 수 있기 때문에 middleware로 구현을 해보고자 했으나 이상하게 middleware를 추가하면 getServerSideProps의 pageProps가 빈 객체로 내려오는 이슈가 있었습니다.

 

getServerSideProps와 middleware가 충돌이 일어나는걸까요?

next 13버전 page directory에서 getServerSideProps와 middleware를 함께 사용하면 발생하는 이슈라고 합니다. 버전을 올리면 되는 문제이긴하나 withAuth에서 토큰 정보를 체크하고 있기 때문에 굳이 나눌 필요가 없다는 생각이 들어서 라우팅 가드도 withAuth에 함께 추가하였습니다.

https://github.com/vercel/next.js/issues/62631#issuecomment-1973490466

 

After using getServerSideProps, when the server is redeployed, json and their responses are empty {}. · Issue #62631 · vercel/

Link to the code that reproduces this issue https://github.com/rick-liruixin/body-scroll-lock-upgrade/tree/next/examples/next To Reproduce After using getServerSideProps, when the server is redeplo...

github.com

 

 

middleware란?

요청이 완료되기 전에 코드를 실행하여 리디렉션, 경로 재작성, 헤더 설정등을 수행할 수 있습니다. 즉 http 요청이 일어나면 그 요청을 가로채서 가장 먼저 실행되는 서버사이드에서 수행되는 코드인것이죠. 특히 vercel 환경에서 배포하게되면 엣지 네트워크에서 처리 되기 때문에 훨씬더 빠르게 사용자에게 응답이 가능해집니다.

 

그리고 현재 웹서버는 AWS ec2에 배포되어있기 때문에 middleware의 빠른 응답속도는 기대하기 어렵기 때문에 middleware를 사용했을 때의 이점을 얻기 어렵다고 생각했습니다.

 

그래서 withAuth의 경우 아래 형식으로 코드를 작성하였습니다. 

export function withAuth<P extends { [key: string]: any }>(
  gssp: GetServerSideProps<P>,
) {
  return async (context: GetServerSidePropsContext) => {
    const { req, res, resolvedUrl } = context;

    // withAuth로 전달된 getServerSideProps callback
    const result = await gssp(context);

    // user
    const accessToken = req.cookies.accessToken;
    const refreshToken = req.cookies.refreshToken;

    // Route guard
    if (isPrivateUrl(resolvedUrl)) {
      const isTokenValid =
        process.env.NODE_ENV === ENV.PRODUCTION
          ? !!refreshToken // 배포 환경에서는 refreshToken으로 식별
          : !!accessToken; // 개발 환경에서는 accessToken으로 식별
      if (!isTokenValid) {
        return {
          redirect: { destination: ROUTER.NOT_FOUND, permanent: false },
        };
      }
    }

    const { userInfo } = await getUser(
      res,
      accessToken,
      refreshToken,
    );

    if ('props' in result) {
      return {
        ...result,
        props: {
          ...result.props,
          userInfo,
        },
      };
    }
  };
}

 

 

저는 next.js에서 SSR을 더 잘 사용하기 위해 token을 이전하는 작업을 해보았는데요. 서버사이드에서 동작하는 코드이기때문에 성능적으로 부하가 일어나지 않을 수 있도록 신경을 쓰는 게 중요한 것 같습니다.

궁금하거나 더 좋은 방법이 있다면 댓글로 공유해주시면 감사하겠습니다 😊

반응형