Zod + Zustand + ReactQuery로 auth 구현하기 (feat. ErrorBoundary )

최원빈·2023년 5월 9일
10
post-thumbnail

새로운 앱을 개발한다면 기분상 가장 먼저 세팅하게 되는 부분은 auth. 인증 쪽 핸들링이라고 생각한다.

첫 작업인만큼 프로젝트에 선정한 기술스택을 적용해보고, 요청 예시를 만들며 최초의 구조를 만들게 된다.
처음 구성하는 부분인 만큼, 간단해 보이지만 신경쓸 점들이 많아 구현하는 게 쉽지만은 않다.

  • 로그인 페이지에 작성한 마크업 / 스타일링의 구조를 정의해야 한다.
  • 로그인에 사용될 Form의 상태를 관리해야 한다.
  • (보통은) 로컬 저장소에서 다룰 authentication의 결과인 token을 관리해야한다.
  • (보통은) 전역 상태로 다룰 authorization의 결과인 유저 객체를 관리해야한다.
  • 로그인 요청에서 에러가 발생할 수 있기에(401), 에러 핸들링 로직이 필요하다.
  • 로컬 저장소에 만료된 토큰으로 요청이 갈 수 있으므로, 갱신 로직이 필요하다.

이러한 과정을 거치며 작성된 코드는 앞으로의 코드에 많은 영향을 끼치기에, 개인적으로 프로젝트 최초 세팅은 auth 관리의 완성까지라고 생각한다.

이번 블로깅은 아래 라이브러리들을 활용해 auth를 관리한 방법에 대해 설명할 것이다.
전부 내가 짠 부분들은 아니지만, 전체적으로 정리해보면 좋을 것 같아 다른 부분들도 포함했다.
예시 코드는 CodeSandbox로 작성했으니, 코드만 먼저 확인하고 싶다면 예시를 참조하자.


Zod

Zod는 런타임 중 타입을 엄격하게 검사해주는 라이브러리이다.

TypeScript로 개발 과정에서 지정한 타입은 클라이언트에서 만든 변수의 경우 잘 관리될 수 있지만,
API Fetching의 결과로 실제 서버에서 주는 값은 내가 작성한 타입과 다를 수 있고,
as 와 같은 타입 단언등의 결과로 해당 변수의 타입은 실제 런타임에서는 다르게 정의된 상태일수도 있다.

간단하게 생각해보자.

interface User {
  id: number;
  name: string;
  student_number: number;
}

const authData = axois.get<User>('/user/me');

지금 User라는 타입은 내가 API를 분석해 임의로 정의한 것이므로 추후에 서버에서 내려주는 형태가 바뀐다거나, 잘못 정의했을 가능성도 무시할 수 없다.
실제로 API에서 반환하는 student_numberstring 형태였다면?

물론 자바스크립트는 자료형 간 변화가 자연스럽기에 별 일이 없을 수 있지만, 사용자에게 의도치 않은 결과를 보여줄 수 있다는 가능성은 확실하다.

특히 에 대해서는 더욱 신경 쓸 필요가 있다.

interface Menu {
  id: number;
  name: string;
  price: number; // 서버 측 실수로 string형태의 "10000"이 들어온다면?
}

let sidePrice = 1000;
let totalPrice = sidePrice + menu.price;
console.log(totalPrice);

menu.pricestring으로 들어온다면 11,000이 아닌, "100010000"이 출력되는 불상사가 발생할 것이다.

이번 프로젝트 기획은 확장성을 고려하면 실제 학교 주변 상점들의 배달비, 매출액 등을 고려해줘야 하기에 위와 같은 오류를 막아야 했고, 더욱 엄격한 타입의 적용이 필요했다.

이를 zod 가 가능하게 한다. zod 로 정의한 스키마인 ZodSchema들은, 정의한 타입과 런타임 중 현재 변수의 타입이 일치한지 검사할 수 있고, 이를 parse 라고 한다.

먼저 스키마를 정의하고..

import { z } from "zod";

// type의 정의를 z.infer<typeof ZodSchema> 로 간단하게 할 수 있다.
// 타입 변수는 컴파일 타임에 사라지기에 중복되는 이름으로 정의해도 괜찮다. 
export const LoginResponse = z.object({
  token: z.string(),
});
export type LoginResponse = z.infer<typeof LoginResponse>;

export const User = z.object({
  name: z.string(),
  age: z.number(),
  // ERROR!
  student_number: z.number(),
});
export type User = z.infer<typeof User>;

API Response를 불러올 때 이를 검증할 수 있다.

import { LoginResponse, User, LoginParams } from "model/auth";

export const login = async (param: LoginParams) => {
  const data = await axios.post<LoginResponse>('/login', param);
  return LoginResponse.parse(data);
};

export const getMe = async () => {
  const data = await axios.get<User>('/user/me');
  // 올바른 스키마가 아닐 경우 이 라인에서 Error가 발생한다.
  return User.parse(data);
};

이제 API 반환값은 클라이언트에서 정의한 타입을 지닌다고 확신할 수 있다.
물론 에러가 발생할 수 있어 핸들링을 해줘야하니, 이는 뒤에 함께 해결해보려 한다.


Zustand

로그인을 통해 토큰을 받고,
그 토큰을 담아 getMe() 요청을 보내 서버의 인가(authorization)를 완료하면 내 정보를 담은 객체를 받을 수 있다.

여기서 받은 내 정보는 전역 상태로 관리하면 여러모로 유용하게 쓸 수 있다.

로그인 여부에 따라 다르게 보여야 하는 UI가 군데군데 있을 수 있고,
UI에서 사용자 이름이나 프로필사진 등을 사용하는 일도 흔하다.

React환경에서 전역 상태 관리 라이브러리는 Redux(toolkit), Recoil, Zustand, Jotai 정도가 주로 사용되고 있으며, 각각의 장단점이 있기에 잘 비교해가며 선정해야한다.

우리 프로젝트의 기획을 봤을 때는 전역으로 관리될 상태가 다양하지 않고, 복잡한 동작을 요구하지 않기에 무거운 라이브러리를 사용할 필요가 없다고 판단.
다만 확장성을 고려하면 추후 배달 서비스를 만들 땐 알림, 주문상태 등 전역으로 관리할 상태들이 생길 것을 고려해 부분적으로 상태를 공유할 수 있는 라이브러리 중 가장 가벼운 zustand를 사용하기로 선정했다.

zustandcreate 함수를 통해 간단하게 스토어를 만들 수 있다.
Redux와 유사하게 스토어에 저장할 상태와, 상태를 수정할 수 있는 action을 같은 스토어 내에서 정의한다.

import { create } from "zustand";
import { User } from "../model/auth";

interface AuthStore {
  user: User | null;
  setUser: (user: User) => void;
}

export const useAuthStore = create<AuthStore>((set) => ({
  user: null,
  setUser: (user: User) => {
    set({ user: user });
  },
}));

이러한 특징 덕에 공식문서에서는 state와 action을 구분해서 타입을 정의하는 방식을 추천한다.

interface AuthState {
  user: User | null;
}

interface AuthAction {
  setUser: (user: User) => void;
}

export const useAuthStore = create<AuthState & AuthAction>(...)

이런 부분은 가독성에 맞게 적절해 통일해 사용하면 될 것 같다.

store를 정의했다면 다음은 store에 접근해야한다.
store를 hook형태(useAuthStore)로 정의했다면, 접근도 간편하다.

export default function LoginForm() {
  // 1. 1개만 가져오기
  const user = useAuthStore((state) => state.user);
 
  // 2. 전부 가져와 꺼내기
  const { user } = useAuthStore();
  const authStore = useAuthStore(); // authStore.user

1번의 경우 user의 변경만 감지하여 user가 변경될 때 이를 감지해 재렌더링이 되지만,
2번의 경우 user 가 아닌 스토어의 다른 상태/액션이 변경되어도 컴포넌트는 재렌더링을 발생시킨다.

그러므로 대부분의 경우 필요한 것만 꺼내 사용하는 1번의 방식을 택하게 된다.

아직은 getMe() 요청을 보내지 않았으니 usernull일테니, 이를 채워보자.


Login Process

로그인은 프로세스는 크게 인증(Authentication)인가(Authorization) 로 나뉜다.

인증: 사용자 또는 장치의 신원을 확인하는 과정
인가: 어떤 개체가 어떤 리소스에 접근할 수 있는지, 어떤 동작을 수행할 수 있는지를 검증하는 것

인증은 흔히 ID와 비밀번호로 이루어진다.
서버에 ID와 비밀번호를 보내고, 해당 페어에 맞는 유저가 DB에 있다면 인증 완료다.

그럼 인가는 무엇일까?

로그인이 완료된 상태로 게시판의 글을 작성한다고 하자.
서버에 POST요청을 보내면, 게시글의 내용과 제목, 그리고 글쓴이인 나의 정보를 넣어야 할 것이다.

그런데 누구의 글인지가 간단하게 유저의 ID만 담아서 정해진다면, 남의 ID를 담아 악의적인 글을 쓸 수 있을 것이다.
그럼 결국 내 ID와 비밀번호를 같이 보내서 인가를 거쳐야하는데, 글쓰기 뿐만 아니라 모든 요청 간 ID와 비밀번호를 담아야하는 뭔가 이상한 현상이 발생하게 된다.

이를 동시에 해결하기 위해 인증 완료 시, 서버에서는 토큰이라고 불리는 문자열을 발행한다.

export const login = async (param: LoginParams) => {
  const { data } = await axios.post<{ token: string }>('/login', param);
  return LoginResponse.parse(data);
};

토큰은 나의 정보를 담고 있고, 만료 기간이 정해져 있어 일정 시간이 지나면 이 토큰으로는 인가가 불가능해진다.

그렇기에 일정 시간동안 토큰을 저장할 수 있게 sessionStorage, cookie에 저장하는 것이 일반적이다.

export const loginRequest = async (param: LoginParam) => {
  const { token } = await login(param);
  sessionStoarge.setItem('token', token);
}

토큰을 로컬 공간에 저장했다면, fetch(axios) 요청의 헤더에 포함되게끔 만들어주어야한다.
그런데 일일히 axios 요청의 인자로 option: { header: ... } 를 적긴 귀찮다.

axios는 interceptors 를 사용해 request 이전 / response 직후 요청에 대한 정보를 인터셉트해 수정하거나 추가적인 액션을 취할 수 있다.

export const apiClient = axios.create({
  baseURL: `${API_PATH}`,
  timeout: 2000,
});

// 첫 번째 콜백은 "request 이전"을 의미한다.
apiClient.interceptors.request.use((config) => {
  const token = sessionStorage.getItem('token');
  config.headers.Authorization = `Bearer ${token}`;

  return config;
});

만들어둔 client를 사용하면, 헤더에 대한 설정이 없어도 알아서 있을 때 담아준다.
간단하게, 인가를 필요로 하는 모든 요청은 이를 거치면 된다.

export const getMe = async () => {
  const data = await apiClient.get<User>('/user/me');
  return User.parse(data);
};

인증

본격적으로 인증을 진행해보자.
사실 로그인은 ID, 패스워드만 담으면 되는 간단한 요청이지만, UX를 챙기다보면 요청은 별 것 없지만 클라이언트의 상태를 관리하는게 복잡해진다.

정말 기초적인 UX로, 로그인에 실패하면 로그인 폼 위치에 (보통은 빨간 글씨로)

  • 존재하지 않는 계정입니다.
  • 아이디 또는 비밀번호를 잘못 입력했습니다.

같은 경고 메시지를 띄워줄 수 있다.

React로 이러한 상태를 다루려면 에러 메시지, 에러 여부 등을 다뤄야 하는데, 매번 상태를 추가해 다루자니 관리가 복잡해진다.

export default function LoginForm(){
  const [isError, setIsError] = useState(false);
  const [error, setError] = useState<null | Error>(null);
  
  const submit = () => {
  	const id = loginFormRef.current.id.value;
    const pw = loginFormRef.current.pw.value;
    
    login({ id, pw }).then((token) => {
      sessionStorage.setItem(token);
      updateUser();
      setIsError(false);
    }).catch((e) => {
      setError(e);
      setIsError(true);
    })
  }
  
  return (
    //...
    {isError && error.message}

위 코드를 보고 눈에 띄는 문제들이 있었으니..

  1. 상태가 복잡하고, 관심사 분리가 전혀 안되었다.
  2. login 요청에서 catch 는 400~500대 에러뿐 아니라, zod의 parse에서도 발생한다.

복잡한 상태와 일관적이지 못한 구조부터 먼저 해결해보자.


React-Query

react-query 를 사용하면 아주 쉽게 복잡한 상태를 해결할 수 있다.
기본적으로 isLoading, isError 등의 추가적인 상태를 제공하고, 여러 옵션들로 하여금 API Response의 캐싱도 가능하다.

당장 만들 요청은 로그인이므로, 캐싱이 필요없어서 useMutation을 사용해 추가적인 상태를 얻었다. (캐싱이 필요한 보편적인 GET 요청엔 useQuery 를 쓸 것이다.)

import { login, getMe } from "../api/auth";

const useLogin = () => {
  const setUser = useAuthStore((state) => state.setUser);
  const { mutate, error, isError } = useMutation({
    mutationFn: login,
    onSuccess: async ({ token }) => {
      sessionStorage.setItem("accessToken", token);
      const user = await getMe();
      setUser(user);
    },
  });

  return { login: mutate, error, isError };
};

간단하게 useMutation 에 함수를 담기만 해도, 반환되는 mutate 를 사용해 나머지 추가 상태를 다룰 수 있다.
onSuccess 옵션을 활용해 성공을 마친 뒤 동작할 콜백을 정의할 수 있어서 사용하는 쪽의 코드도 훨씬 간결해진다.

export default function LoginForm() {
  const { login, error, isError } = useLogin();
  
  const submit = () => {
  	const id = loginFormRef.current.id.value;
    const pw = loginFormRef.current.pw.value;
    
    login({ id, pw })
  }
  
  return (
    //...
    {isError && error.message}

아직 문제는 zod의 parseError 또한 에러라서 로그인에 실패하지 않았음에도 로그인이 실패했다고 나오는... 올바르지 않은 결과가 나온다는 점이 해결되지 않았다.

parseError가 발생하면 어떤 작업을 해야할까?
나는 서버에서 잘못된 구조를 반환한 경우이므로, 클라이언트의 작업을 중지해야 한다고 생각했다.
잘못된 타입으로 이상한 요청이 가는 것은 분명히 큰 문제이다. 돈이 걸린다면? 더더욱 신경을 써줘야 한다.


ErrorBoundary

그보다 먼저, React에서 오류가 발생하면 어떤 일이 일어날까?

경험에 기반해 무의식적으로 알고있는 경우가 많겠지만, error가 throw되면, 앱 전체가 렌더링되지 않아 하얀 화면이 나오고, 콘솔엔 에러가 찍혀있다.

그런데 모든 에러에 대해서는 아니고, fetch 등에서 나는 에러는 그냥 콘솔에만 찍힌다.
하지만 해당 결과에 의해 state가 이상하게 바뀌거나 하면 또 흰 화면이 나온다.

React는 동기적으로 에러가 throw 되면, 전체 애플리케이션을 중단시키고, 이를 방지하기 위해 ErrorBoundary 라는 해결책을 두었다.

ErrorBoundary 로 특정 컴포넌트를 묶으면, 해당 컴포넌트에서 발생한 에러가 앱 전체를 멈추게 하지 않고, 그 부분만 다른 컴포넌트(fallback)으로 바뀌게 한다.

적용법은 해당 코드를 복사해 컴포넌트를 만들고, 컴포넌트를 감싸면 된다.

function App() {
  return (
    <div className="App">
      <ErrorBoundary fallback={<div>에러가 발생!!</div>}>
        <LoginForm />
      </ErrorBoundary>

문제는 "동기적으로" 에러가 throw 되면 이라는 조건이다.
API call은 전혀 동기적이지 못하다. React Query의 mutation도 비동기적인 코드이다.

이를 위해 React Query는 동기적인 에러를 발생시켜주는 useErrorBoundary라는 옵션을 제공한다.

const useLogin = () => {
  const { mutate, error, isError } = useMutation({
    mutationFn: login,
    useErrorBoundary: true,
    onSuccess: ...
  });

문제는 이렇게 쓰니 일반적인 에러(비밀번호가 틀린다던가)도 ErrorBoundary 에 에러를 throw한다는 점이었다.

이를 해결할 방법을 찾아보니, useErrorBoundary 가 boolean뿐 아닌, boolean을 반환하는 callback 또한 가능하다는 것을 발견했다.

그럼 해당 에러가 zod에서 발생한 에러인지, 서버에서 발생한 에러인지 알아야 했으므로, zod의 문서를 보니 zod는 ZodError 라는 클래스가 있어 이를 활용해 구분할 수 있었다.

const useLogin = () => {
  const { mutate, error, isError } = useMutation({
    mutationFn: login,
    useErrorBoundary: (err) => {
      if(err instanceof ZodError){
        return true;
      }
      return false;
    },
    onSuccess: ...
  });

매 훅마다 이를 적용하기엔 무리가 있고, 앱 전체적으로 zod 를 사용할 예정이었기에, React-Query의 defaultOption에서 정의하여 반복을 줄일 수 있었다.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      refetchOnWindowFocus: false,
      refetchOnMount: false,
      refetchOnReconnect: false,
      retry: 1,
      staleTime: 1000 * 60 * 5,
      // 짧게 줄여쓸 수 있다.
      useErrorBoundary: (err) => err instanceof ZodError,
    },
    mutations: {
      useErrorBoundary: (err) => err instanceof ZodError,
    },
  },
});

이렇게 React-QueryZod, Zustand 를 활용해 궁극적인 목표인 Auth의 구현과 기본적인 프로젝트 초기 세팅을 마쳤다.

아직 자동로그인이나 토큰 만료 등 관리할 부분들이 남았지만, 이는 다음 PR을 리뷰해보고 나서 정리가 되면 작성할 생각이다.

같이 작업하는 팀원들이 이걸 보고 로그인 도와주면서 한 고심의 흔적들을 알아줬으면 좋겠다.
여기까지 읽어주겠지? 🥹

profile
FrontEnd Developer

1개의 댓글

comment-user-thumbnail
2023년 6월 12일

auth를 제대로 구현하는건 정말 쉽지 않은 일이네요

답글 달기