[React][Zustand] Zustand를 사용한 user 로그인 상태 관리

Joowon Jang·2024년 11월 8일
1

React

목록 보기
10/19

user 정보를 localStorage에 저장하면 안된다?

AWS, Azure 등의 서비스들은 브라우저의 localStorage에 인증 사용자 정보를 보관한다.
여러 서비스가 localStorage에 데이터를 저장함에도 불구하고, localStorage의 보안성 문제를 이야기하는 사람들도 있다. localStorage에 저장된 데이터는 브라우저 개발자 도구를 통해 쉽게 접근할 수 있으므로 크로스 사이트 스크립팅(XSS) 공격에 취약하다고 말한다. 웹사이트에 삽입된 악성 스크립트는 localStorage에 저장된 데이터에 쉽게 접근, 조작하여 민감한 사용자 정보를 손상시킬 수 있다는 것이다.

참고: https://www.reddit.com/r/webdev/comments/15g6spc/is_localstorage_secure/?rdt=60787

만약 보안 문제로 인해 localStorage 사용이 꺼려진다면 쿠키(Cookie)를 사용하거나, sessionStorage를 사용하는 것도 방법이 될 수 있다.

https://codewithpawan.medium.com/enhancing-security-and-efficiency-moving-away-from-localstorage-25bca9160074

하지만, sessionStorage를 사용하면 불편한 점도 많고, 민감한 정보를 localStorage에 저장하는 것이 아니라면 보안에 문제가 없다고 보기 때문에 localStorage를 사용해 user 정보를 관리하기로 했다.

Zustand와 middleware

useContext와 같은 React의 api나 Redux 등을 사용할 수 있겠지만, 비교적 사용법이 쉬운 Zustand를 사용해서 구현하였다.
Zustand는 여러 middleware라는 것을 제공하는데, 이 api들 중 devtools, persist 두 가지를 사용하였다.

devtools

Redux에서는 redux-devtools라는 개발자 도구를 사용해 편리하게 상태를 추적할 수 있는데, Zustanddevtools api를 사용하면 이 도구를 그대로 사용할 수 있다.

사용법

import create from 'zustand';
import { devtools } from 'zustand/middleware';

const useStore = create(
  devtools(
    (set) => ({
		// ...
  	}), { name: "CounterStore" }
  )
);

이렇게 create의 매개변수를devtools의 첫 번째 매개변수로 넣어주기만 하면 된다.
두 번째 매개변수는 개발자 도구에 표시될 store의 이름 등의 옵션을 설정해준다.

persist

이 포스트의 핵심인 persist는 상태를 브라우저의 저장소(예: localStorage, sessionStorage, IndexedDB 등)에 저장하여 페이지 새로고침이나 애플리케이션 재시작 시에도 상태를 유지할 수 있게 해준다.
이 api를 사용해 user 정보를 localStorage에 저장할 것이다.

사용법

import create from 'zustand';
import { persist } from 'zustand/middleware';

const useStore = create(
  persist(
    (set) => ({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 })),
    }),
    {
      name: 'count-storage', // 저장소에 저장될 키 이름
      storage: createJSONStorage(() => localStorage), // 사용할 저장소
      partialize: (store) => ({ count: store.count }), // 로컬스토리지에 저장할 상태만 선택
    }
  )
);

devtools와 마찬가지로, 첫 번째 매개변수로create의 매개변수를 넣어주고, 두 번째 매개변수에는 저장소(여기서는 localStorage)에 저장될 키 이름, 사용할 저장소 등을 선택해서 필요한 옵션을 넣어주면 된다.

useAuthStore 구현

서버에 유저 정보를 요청하는 함수와 토큰 만료 여부를 확인하는 함수는 미리 작성해 두었다.
store의 코드는 아래와 같다.

import { getUserData } from '@/api/users';
import { parseJwt } from '@/utils';
import { create } from 'zustand';
import { createJSONStorage, devtools, persist } from 'zustand/middleware';

// 토큰 만료 여부 확인
/** @type {(token: string) => boolean} */
function checkTokenExpiration(token) {
  if (!token) return true;

  const decodedToken = parseJwt(token);
  const expirationTime = decodedToken.exp * 1000; // exp는 초 단위이므로 밀리초로 변환
  const currentTime = Date.now();

  return currentTime > expirationTime;
}

// store
export const useAuthStore = create(
  devtools(
    persist(
      (set, get) => ({
        token: null,
        userInfo: null,
        // 유저 로그인
        loginUser: (token, userInfo) => set({ token, userInfo }),
        // 유저 로그아웃
        logoutUser: () => set({ token: null, userInfo: null }),
        // 유저 정보 업데이트
        updateUserInfo: (newUserInfo) => set({ userInfo: newUserInfo }),
        // 토큰 유효성 검사
        validateToken: () => {
          const token = get().token;
          if (checkTokenExpiration(token)) {
            get().logoutUser(); // 토큰이 만료되었다면 로그아웃
            return false;
          }
          return true;
        },
        // 유저 정보 요청
        fetchUserInfo: async () => {
          if (!get().validateToken()) return null;

          const token = get().token;
          try {
            const userInfo = await getUserData(token);
            set({ userInfo });
            return userInfo;
          } catch (error) {
            console.error('Failed to fetch user info:', error);
            return null;
          }
        },
        // 현재 인증 상태 확인 (토큰 유효성 검사 포함)
        checkSignIn: () => {
          return get().validateToken() && get().userInfo;
        },
      }),

      {
        name: 'authStore', // 로컬스토리지에 저장될 키 이름
        storage: createJSONStorage(() => localStorage), // 사용할 스토리지 선택
        partialize: (store) => ({
          token: store.token,
          userInfo: store.userInfo,
        }), // 로컬스토리지에 저장할 상태만 선택
      }
    ),

    { name: 'authStore' } // devtools에 표기될 저장소 이름
  )
);

우선, store에는 tokenuserInfo 두 가지 상태를 저장한다.
그리고 상태를 관리할 액션 함수는 '로그인', '로그아웃', '업데이트', '토큰 유효성 검사', '유저 정보 요청', '현재 인증 상태 확인'으로 총 6가지가 있다.

로그인은 아래와 같이 user의 로그인 인증 요청 함수에서 정상적으로 응답이 왔을 때, 그 안에 담긴 토큰과 유저 정보를 store에 넣어주면 된다.
업데이트도 같은 방식으로 유저 정보 업데이트 함수에 넣어주면 된다.

// @/api/user.js

/** @type {(username: string, password: string) => Promise<any>} */
export async function userSignIn(username, password) {
  const REQUEST_URL = {요청할 url};

  const body = JSON.stringify({ identity: username, password });

  const response = await fetch(REQUEST_URL, {
    method: 'POST',
    body,
    ...REQUEST_OPTIONS,
  });
  
  // 에러 핸들링
  if (!response.ok) {
    throw new Response(
      JSON.stringify({ message: '서버에서 요청에 응답하지 않습니다.' }),
      { status: 500 }
    );
  }

  const responseData = await response.json();

  // 로그인 성공 시 토큰과 유저 정보를 로컬 스토리지에 저장
  useAuthStore.getState().loginUser(responseData.token, responseData.record);

  return responseData;
}

'로그아웃'과 '유저 정보 요청'은 이벤트 핸들링 함수나 useEffect 등에서 필요한 상황에 사용할 수 있고,
'토큰 유효성 검사'는 '현재 인증 상태 확인' 함수에 포함되어 있기 때문에 쓸 일이 거의 없다.

'현재 인증 상태 확인' 함수는 중요하게 사용되는데,
아래와 같이 로그인하지 않은 유저는 로그인 화면으로 강제 redirect하는 방식으로 사용할 수 있다.

import { useAuthStore } from '@/stores/authStore';
import { getStorage } from '@/utils';
import { memo, useEffect } from 'react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import style from './RootLayout.module.css';

function RootLayout() {
  const navigate = useNavigate();
  const checkSignIn = useAuthStore((store) => store.checkSignIn);

  useEffect(() => {
    // 로그인 되어있지 않으면 (토큰 유효성 검사 포함) demo | auth 페이지로 이동
    if (!checkSignIn()) {
      if (!getStorage('completeDemo')) navigate('/demo/1');
      else navigate('/auth');
    }
  }, [navigate, checkSignIn]);
  
  // ...

  return (
    <div className={style.component}>
      <Outlet />
      // ...
    </div>
  );
}

export default memo(RootLayout);
profile
깊이 공부하는 웹개발자

0개의 댓글