이전 글에서 언급했듯이 이번 채팅 프로젝트에서는 Zustand를 이용하여 클라이언트 사이드의 전역상태를 관리하기로 했다. 그 중에서도 로그인 파트는 axios를 이용해 백엔드와의 통신을 처리하고 Zustand로 클라이언트에서 로그인 상태를 관리하는 구조를 채택했다.
Zustand는 리액트 상태 관리를 위한 라이브러리로, Redux보다 더 간단하고 직관적인 API를 제공한다. 이번에 직접 사용해본 결과 코드의 양이 압도적으로 줄어들고, 사용법도 상당히 직관적이고 가독성도 좋아서 상태관리 구현에 시간과 비용을 아낄 수 있었다. 이전에 리덕스를 사용해본 개발자라면 누구나 쉽게 적용할 수 있을 것이라고 예상된다.
앞서 언급된 바와 같이 로그인 파트에서는 axios와 zustand를 함께 사용하여 네트워크 요청과 상태관리를 분리하였다.
그 이유는 다음과 같다 :
이는 각 부분이 독립적으로 기능하도록 하여 코드의 모듈화를 증진시킨다. 무엇보다 zustand를 사용한 로직의 중앙 집중화를 통해 상태 관련 로직은 한 곳에서 관리할 수 있으므로, 코드 베이스가 깔끔해진다. 따라서 단일 책임의 원칙(Single Responsibility Principle)를 준수한 코드를 통해 프로젝트의 확장성을 높이고 유지보수성 또한 향상시킬 수 있다. 또한 네트워크 요청을 모의하거나 Zustand 스토어의 상태를 독립적으로 설정하여 개발 과정과 테스트 단계에서 버그를 더 쉽게 찾아내고 수정하기가 용이하다.
axios는 promise 기반의 HTTP 클라이언트로서, 비동기적인 HTTP 요청을 쉽게 처리할 수 있도록 지원한다. 이를 zustand와 결합하여, axios에서 받은 로그인 요청의 결과 (response)를 바로 zustand의 상태 업데이트 함수에 전달하여 상태를 갱신하고 스토어에 쉽게 반영할 수 있어, 비동기 통신의 상태 관리가 한결 간단해진다. 액션 생성자, 리듀서, 미들웨어 등 여러 단계를 거친 후 비로소 최종적으로 상태가 업데이트 되는 리덕스와는 달리, Zustand는 이러한 중간 단계가 간소화되어 간결한 비동기 처리가 가능해진다.
그렇다면 바로 코드 구현으로 넘어가보자.
useAuthStore.ts
)가장 먼저 타입스크립트를 사용하여 애플리케이션에서 필요한 타입들을 정의하였다.
// types.ts
export interface User {
id: string;
email: string;
username: string;
// 기타 필요한 사용자 정보 필드들
}
export interface AuthState {
user: User | null;
token: string | null;
setUser: (user: User, token: string) => void;
clearUser: () => void;
}
그 후 사용자 인증 상태를 관리하는 zustand 스토어를 설정했다.
우리 프로젝트에서는 유저명, 이메일, 엑세스 토큰을 여기에 저장했다.
import create from 'zustand';
import { AuthState } from "@/features/auth/types";
export const useAuthStore = create<AuthState>((set) => ({
user: null,
token: null,
setUser: (user, token) => set({ user, token }),
clearUser: () => set({ user: null, token: null }),
}));
또한 setUser
와 clearUser
함수를 이용해 상태를 업데이트하며, 로그인 및 로그아웃 기능에서 직접 호출하여 상태를 갱신할 수 있다.
axiosInstance.ts
)Axios를 사용하여 HTTP 요청을 관리하며, 인터셉터를 활용하여 요청과 응답을 가로채고, 필요 시 액세스 토큰을 갱신하거나 오류를 처리할 수 있다.
import axios from "axios";
import { BASE_URL } from "@/utils/config";
const axiosInstance = axios.create({
baseURL: BASE_URL,
timeout: 1000,
withCredentials: true,
});
axiosInstance.interceptors.request.use(
async (config) => {
const token = localStorage.getItem("accessToken");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
axiosInstance.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
const originalRequest = error.config;
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
const newAccessToken = await getNewAccessToken();
if (newAccessToken) {
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return axiosInstance(originalRequest);
}
} else if (error.response.status === 400) {
signout();
}
return Promise.reject(error);
}
);
export default axiosInstance;
LoginForm.tsx
) 및 useAuthActions 훅로그인 폼 컴포넌트를 구현하여 사용자로부터 이메일과 비밀번호를 입력받는다. 폼 제출 시 useAuthActions
훅을 사용하여 로그인 로직을 처리하고, Zustand 스토어에 상태를 저장할 수 있다.
import React, { useState } from 'react';
import { useAuthActions } from './useAuthActions';
import { useToast } from '@chakra-ui/react';
const LoginForm: React.FC = () => {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const { signin } = useAuthActions(); // signin 함수를 가져온다.
const toast = useToast();
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
try {
await signin(email, password); // signin 함수를 호출하여 로그인 시도
} catch (error) {
console.error('로그인 에러:', error);
toast({
title: "로그인 실패",
description: "로그인 중 오류가 발생했습니다.",
status: "error",
duration: 5000,
isClosable: true,
});
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="이메일"
required
/>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="비밀번호"
required
/>
<button type="submit">로그인</button>
</form>
);
};
export default LoginForm;
로그인이 성공적으로 처리되면 내부 로직에 의해 페이지가 리디렉션되고, 실패하면 Chakra ui의 에러 토스트 메시지가 사용자에게 표시되게 구현하였다. signin
함수는 비동기적으로 처리되며 예외가 발생할 경우 적절한 오류 처리를 수행한다.
useAuthActions
훅은 회원가입, 로그인, 로그아웃 기능을 제공하며, Axios를 사용하여 API 요청을 처리한다. 또한, Zustand 스토어를 사용하여 상태를 업데이트할 수 있다.
코드가 너무 길어지므로 여기에서는 로그인 관련 함수만 첨부하겠다.
import { useToast } from "@chakra-ui/react";
import { useNavigate } from "react-router-dom";
import { useAuthStore } from "@/stores/useAuthStore";
import axiosInstance from "@/api/axiosInstance";
import signupAxiosInstance from "@/api/signupAxiosInstance";
interface IUseAuth {
signin: (email: string, password: string) => Promise<void>;
signup: (email: string, password: string, username: string) => Promise<void>;
signout: () => void;
}
export const useAuthActions = (): IUseAuth => {
const toast = useToast();
const navigate = useNavigate();
const setUser = useAuthStore((state) => state.setUser);
const clearUser = useAuthStore((state) => state.clearUser);
const signin = async (email: string, password: string) => {
try {
const response = await axiosInstance.post(`/login`, {
email,
password,
});
if (response.status === 200) {
const accessToken = response.headers.authorization.split(" ")[1];
localStorage.setItem("accessToken", accessToken);
setUser(response.data.username, response.data.email, accessToken);
navigate("/chat/main");
}
} catch (error) {
console.error("Login error:", error);
toast({
title: "로그인 실패",
description: "닉네임과 비밀번호를 확인해주세요.",
status: "error",
duration: 5000,
isClosable: true,
});
}
};
const signout = async () => {
try {
const response = await axiosInstance.post(`/logout`, {});
if (response) {
localStorage.removeItem("accessToken");
clearUser();
navigate("/login");
toast({
title: "로그아웃 성공",
description: "로그아웃이 성공적으로 완료되었습니다.",
status: "success",
duration: 5000,
isClosable: true,
});
}
} catch (error) {
console.error("Logout error:", error);
toast({
title: "로그아웃 실패",
description: "로그아웃 중 오류가 발생했습니다.",
status: "error",
duration: 5000,
isClosable: true,
});
}
};
return { signin, signout };
};
두근거리는 마음으로 다른 컴포넌트에서도 로그인 시 받아온 사용자 정보가 잘 불러와지는지 테스트하는 과정에서 새로고침을 하면 사용자 정보가 null이 뜨며 유저박스가 '오프라인'으로 처리되는 현상을 발견했다.
소소한 좌절(?)을 머금고 구글링을 해본 결과 persist
미들웨어를 적용하지 않아서 발생한 현상임을 알게 되었다. zustand 뿐만 아니라 상태관리 라이브러리에서 상태는 기본적으로 메모리에 저장되어 페이지를 새로고침하거나 애플리케이션을 닫고 다시 열면 메모리 상태가 초기화되어 사라진다.
이를 방지하기 위해 persist
미들웨어를 사용하여 상태를 로컬 스토리지에 저장하고 복원하는 기능을 추가해야 한다. zustand/middleware
의 persist
를 사용하면 상태를 자동으로 로컬 스토리지에 저장하고 복원할 수 있다.
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { AuthState } from "@/features/auth/types";
export const useAuthStore = create(
persist<AuthState>(
(set) => ({
user: null,
token: null,
email: null,
setUser: (user, email, token) => {
set({ user, email, token });
},
clearUser: () => set({ user: null, email: null, token: null }),
}),
{
name: "auth-storage", // 로컬 스토리지에 저장될 키 이름
}
)
);
또한 로컬 스토리지는 브라우저가 종료되어도 데이터를 유지할 수 있기 때문에 상태를 지속적으로 저장하는 데 유용하다. 그러므로 persist
미들웨어를 사용하면 브라우저를 껐다가 다시 켜도 로그인 상태를 유지할 수 있다.
이렇게 하면 무한정 리로드를 해도 유저 정보가 잘 살아있는 것을 확인할 수 있다.
직접 Zustand로 로그인 기능을 구현해보았을때,
Context api나 리덕스와 비교했을때 느낀 가장 큰 장점은 다음과 같다 :
1. 간결하고 직관적인 로그인 전역 상태관리 코드.
2. 상태 변화를 추적하는 미들웨어 제공.
리덕스에 비해 가볍다는 소문은 익히 들었지만 persist
와 같이 강력한 미들웨어 기능까지 제공해주니 손쉽게 로그인 상태관리를 구현할 수 있었던 것 같다.
이전에 리덕스나 context api로 큰 시행착오를 겪으면서 구현했던 부분들이 더 간단하게 구현되는 것을 보니 새로운 기술에 대해 몸소 장점을 느끼면서 만들어볼 수 있는 재밌는 기회였던 것 같다.