Jotai로 Auth 구성하기

최원빈·2022년 12월 6일
5
post-thumbnail

동아리 프로젝트로 맛집 공유 플랫폼을 개발하고 있다.

프로젝트를 5개나 동시에 진행하고 있는게 걱정이 되기도 하지만 복학하고 나면 하고싶어도 못할테니 달려보기로 결정.

아무튼 이번 목표는 자동 로그인 기능을 포함한 인증 시스템을 구현하는 것이다.


🚀 목표

FE단에서 관리하는 유저의 인증은 몇 가지 핵심 기능이 존재한다.
백엔드에서 제공하는 토큰의 유효기간이나 종류에 따라 구현하는 방법은 조금씩 다르겠지만, (내가 생각하기에) 가장 보편적인 방법을 나열하면 아래와 같다.

  1. 로그인 시, accessToken을 제공받아 sessionStoarge에 저장하고, 인증을 필요로 하는 모든 요청의 header에 토큰을 담는다.

  2. 사용자가 자동로그인을 원할 시, refreshToken을 제공받아localStorage 또는 cookie에 저장한다.

  3. sessionStorage에 저장된 토큰은 탭을 닫을 때 사라지므로 refreshToken을 사용해 accessToken을 발급받아야 한다.(이 과정을 refresh 라고 한다.)

  4. 세션이 유지된 동안 accessToken이 만료되어 요청에 401에러가 발생한다면, refresh를 요청해 토큰을 재발급받고, 다시 이전 요청을 반복해야한다.

  5. 만약 refresh 과정 도중 확인한 refreshToken이 만료되었다면, localStorage에 남아있는 만료된 토큰을 지워줘야 한다.

위 조건들을 전부 만족하는 기본적인 인증 로직을 구성한 경험을 공유한다.


🛠️ 도구 선정

현재 개발중인 프로젝트는 번들 크기를 줄이고, Hook 패턴과 타입스크립트를 잘 사용하는 것을 목표로 하고있다.

불필요한 번들 크기 증가를 막고자 보편적인 스타일링 라이브러리도 쓰지 않고 scss를 사용해가면서 개발하고 있다.

그렇기에 전역 상태 관리에 대해 Context를 사용할 지.. 다른 전역 상태 관리 라이브러리를 사용할 지 고민하던 중, Jotai가 라이브러리 크기도 매우 작으면서(3kb), async 구문을 간단하게 작성할 수 있게 도와줄 수 있다는 것을 보고 바로 선정해서 공부했다.

또한 axios interceptor를 활용해 refresh를 구현하고자 fetcher로 axios도 채택했다.


🔑 axios를 사용한 인증 세팅

먼저 유저 관련 API를 모아두는 userApiClient를 구성했다.

const API_PATH = process.env.REACT_APP_API_PATH!;

const userApi = axios.create({
  baseURL: `${API_PATH}/user`,
  timeout: 2000,
});

userApi를 통해 api를 호출한다면, accessToken이 존재할 때 해당 토큰을 헤더에 담아 요청할 수 있도록 만들어주어야 한다.

axois interceptor는 요청 이전의 처리와, 응답 이후의 처리를 할 수 있게 도와준다.

요청 이전에 헤더를 세팅하도록 추가해주자.

userApi.interceptors.request.use(
  (config) => {
    const accessToken = sessionStorage.getItem('accessToken');
    // eslint-disable-next-line no-param-reassign
    if (config.headers && accessToken) config.headers.Authorization = `Bearer ${accessToken}`;
    return config;
  },
);

요청 이전에 헤더를 추가했으니, refresh동작도 추가하자.
만약 응답으로 401에러가 발생했다면, /refresh를 호출하고, 이전 요청을 반복할 수 있게 만들어주면 된다.
다만 /refresh 요청에서도 401에러가 날 수 있으므로, 무한반복을 방지해줄 필요가 있다.

userApi.interceptors.response.use(
  // 성공시
  (response) => response,

  // 실패시
  (error: AxiosError) => {
    try {
      const originalRequest = error.config;

      if (originalRequest.url !== '/refresh') {
        return refreshAccessToken().then(() => userApi(originalRequest));
      }
      return Promise.reject();
    } catch {
      makeToast('error', '네트워크 오류가 발생했습니다.');
      return Promise.reject();
    }
  },
);

여기서 사용되는 refreshAccessToken 함수는 refreshToken의 유효성에 따라 만료된 토큰을 없애는 역할도 동시에 진행한다.

export const refreshAccessToken = async () => {
  const refreshToken = localStorage.getItem('refreshToken');
  if (!refreshToken) {
    return Promise.reject();
  }
  try {
    const { data } = await userApi.get<RefreshResponse>('/refresh', {
      headers: { RefreshToken: `Bearer ${refreshToken}` },
    });
    sessionStorage.setItem('accessToken', data.accessToken);
    localStorage.setItem('refreshToken', data.refreshToken);
    return data;
  } catch {
    sessionStorage.removeItem('accessToken');
    localStorage.removeItem('refreshToken');
    return Promise.reject();
  }
};

이렇게 설정을 해 뒀으니, userApi를 사용해 개인정보 확인 등 요청을 보낸다면, 헤더에 인증이 알아서 추가되고 토큰의 상태에 따라 만료 처리를 해 줄 것이다.

import userApi from './userApiClient';
import { User } from './entity';

// 내정보 확인, 정보수정 등등 ...
export const getMe = () => userApi.get<User>('/me');
export const modify = (param: ModifyParams) => userApi.patch<User>('/modify', param);

🔐 Auth 상태 관리

/user/me 요청을 통해 받아오는 Auth 정보는 이곳저곳 사용할 일이 많다.
이름, 프로필 사진 등이 가장 많이 사용되는데, 이 정보를 여러 컴포넌트에서 사용하려면 전역으로 관리할 필요가 있었다.

처음엔 Context로 작성했다. (아래 코드를 사용하진 않았으니 나중 비교를 위해 느낌만 훑자)

import { getMe } from 'api/user';
import { User } from 'api/user/entity';
import { refreshAccessToken } from 'api/user/userApiClient';
import {
  ReactNode, useEffect, useMemo, useState,
} from 'react';
import AuthContext from './AuthContext';

export default function AuthProvider({ children }: { children: ReactNode }) {
  const [auth, setAuth] = useState<User | null>(null);
  const [accessToken, setAccessToken] = useState<string | null>(() => {
    const token = sessionStorage.getItem('accessToken');
    if (token) return token;
    return null;
  });

  const login = async (token: string) => {
    setAccessToken(token);
    const authResponse = await getMe();
    if (authResponse.data) setAuth(authResponse.data);
  };

  const logout = () => {
    setAuth(null);
    setAccessToken(null);
    sessionStorage.removeItem('accessToken');
    localStorage.removeItem('refreshToken');
  };

  const value = useMemo(() => ({
    auth,
    accessToken,
    login,
    logout,
  }), [auth, accessToken]);

  useEffect(() => {
    // 최초 접근 시 자동로그인
    const token = {
      access: sessionStorage.getItem('accessToken'),
      refresh: localStorage.getItem('refreshToken'),
    };
    if (token.access) {
      login(token.access);
    } else if (token.refresh) {
      refreshAccessToken().then((res) => {
        login(res.accessToken);
      });
    }
  }, []);

  return (
    <AuthContext.Provider value={value}>
      {children}
    </AuthContext.Provider>
  );
}

여러가지 문제가 있었는데, 다른 전역 상태를 추가하게 된다면 Provider를 하나 더 써야 한다는 점과, effectstate가 맞물려 상태를 관리하는 것도 복잡해보인다.

🤔 Jotai로 Auth 구현

Jotai는 전역 상태와, 상태를 수정하는 actions까지 전부 atom으로 관리하는 recoil과 유사한 방식의 전역 상태 관리 라이브러리이다. (recoil을 써보진 않았지만)

// auth 상태를 관리하는 atom
const authAtom = atom<User | null>(null);

// auth 상태를 갱신하는 atom (초기, 로그인, 로그아웃 시)
const initAuthAtom = atom(null, async (get, set) => {
  const token = {
    access: sessionStorage.getItem('accessToken'),
    refresh: localStorage.getItem('refreshToken'),
  };

  if (token.access) {
    const authResponse = await getMe();
    if (authResponse.data) return set(authAtom, authResponse.data);
  }

  if (token.refresh) {
    const refreshResponse = await refreshAccessToken();
    if (refreshResponse.accessToken) {
      sessionStorage.setItem('accessToken', refreshResponse.accessToken);

      const authResponse = await getMe();
      if (authResponse.data) return set(authAtom, authResponse.data);
    }
  }

  return set(authAtom, null);
});

// 사용하는 곳에서 쉽게 확인할 수 있게 hook으로 모았다.
export const useAuthAtom = () => {
  const [auth] = useAtom(authAtom);
  const [, initAuth] = useAtom(initAuthAtom);

  return { auth, initAuth };
};

Jotai를 처음 써보며 예제나 문서를 보고 이것저것 조합해 만들어 본 코드라 검증을 받을 필요가 있어 사용 경험이 있는 선배님께 코드리뷰를 부탁드렸고, 다양한 리뷰를 받을 수 있었다.

가장 큰 변경점은, async함수를 초기값으로 갖는 atom을 만들고, 이를 갱신하는 패턴으로 바꿨다는 점이다.

리뷰를 받기 전엔 async함수를 초기값으로 넣으니 갱신이 불가능해져서 안되는 줄로만 알았는데, 된다고 리뷰를 받아서 더 이것저것 찾아보게 되었다.

분명히 나와 같은 문제를 직면한 사람이 더 있을 것이라 생각해 이슈를 뒤지던 중, Maintainer dai-shi 선생님의 답변을 이슈에서 찾을 수 있었다.

atomWithDefault로 정의한 async atom은 값을 수정할 수 있다.

// 현재 토큰의 상태에 따라 auth를 갱신하는 함수
const getAuth = async () => {
  const token = {
    access: sessionStorage.getItem('accessToken'),
    refresh: localStorage.getItem('refreshToken'),
  };

  if (token.access) {
    const authResponse = await getMe();
    if (authResponse.data) return authResponse.data;
  }

  if (token.refresh) {
    const refreshResponse = await refreshAccessToken();
    if (refreshResponse.accessToken) {
      sessionStorage.setItem('accessToken', refreshResponse.accessToken);

      const authResponse = await getMe();
      if (authResponse.data) return authResponse.data;
    }
  }

  return null;
};

const authAtom = atomWithDefault(getAuth);

const updateAuthAtom = atom(null, async (get, set) => {
  set(authAtom, await getAuth());
});

// useSetAtom을 사용하면 2번째 반환값인 setter만 받을 수 있다.
export const useUpdateAuth = () => useSetAtom(updateAuthAtom);

// useAtomValue을 사용하면 1번째 반환값인 value만 받을 수 있다.
export const useAuth = () => useAtomValue(authAtom);

getAuth() 함수로 현재 토큰 상태에 따라 authAtom을 구성한다.
export 할 때는 필요한 값과 함수만 반환했기에, auth 객체를 사용하는 쪽에선 간편하게 사용할 수 있다.

// 이미 로그인 상태라면 접근할 수 없는 로그인, 회원가입 등 페이지를 리다이렉 시키는 라우팅 컴포넌트
export default function ProtectedRoute({ redirectPath = '/' }: Props) {
  const auth = useAuth();

  if (auth) {
    return <Navigate to={redirectPath} replace />;
  }

  return <Outlet />;
}

auth 객체를 전역으로 관리하는 이유가, 생각보다 활용할 일이 많다.

// 로그인 상태에 따라 로그인/마이페이지를 다르게 보여주는 상단바
function TopNavigation(): JSX.Element {
  const auth = useAuth();

  return (
    //...
        {auth
          ? <li><Link to="/profile" className={styles['top-navigation__link']}>마이페이지</Link></li>
          : <li><Link to="/login" className={styles['top-navigation__link']}>로그인</Link></li>}
      </ul>
    </nav>
  );
}

로그인 / 로그아웃 시에는 authAtom을 갱신해줘야 하므로 로직에 useUpdateAuth를 추가해 갱신할 수 있었다.

const useLoginRequest = () => {
  const navigate = useNavigate();
  const updateAuth = useUpdateAuth();

  const submitLogin = async ({ id, password, isAutoLoginChecked }: LoginFormInput) => {
    const { data } = await login({
      account: id,
      password: sha256(password),
    });

    sessionStorage.setItem('accessToken', data.accessToken);
    await updateAuth();
    // ...

정리

Jotai와 axios로 프론트에서 필요한 인증 부분들을 구현해보았다.

refreshToken의 유무에 따라 자동로그인을 하고,
토큰이 필요한 요청의 헤더에 accessToken이 추가되게끔 하고,
토큰 만료 시 토큰을 갱신하며 이전 요청을 반복하도록 만들었다.

앞으로 다른 전역 상태를 사용할 때도 Jotai를 사용할 테니 활용법을 더 잘 숙지해둬야겠다.

profile
FrontEnd Developer

0개의 댓글