클라이언트 - 서버 상태 분리: 둘이 친해지길 바라!

윤뿔소·2023년 6월 29일
8

현업 관련 경험

목록 보기
1/4

프론트엔드 개발을 하다 보면 수많은 상태 관리 라이브러리 중 뭘 선택해야 할지 혼란스럽다. 왜 React Query와 함께 Zustand나 Redux 같은 상태 관리 라이브러리를 추가로 사용해야 할까?
이 의문은 사실 '상태'라는 것에 두 가지 다른 종류가 있다는 사실을 이해하면 명확해진다.

하지만 이 두 상태의 차이를 제대로 이해하지 못하면, 코드는 점점 스파게티가 되어가고 상태 업데이트 로직은 복잡해지며 성능은 저하된다.

이 글에서는 서버 상태와 클라이언트 상태의 차이를 명확히 하고, 리액트 쿼리 + 상태관리 라이브러리(zustand? RTK?)를 고민하며 먼저 어떤 상태가 있는지 정의하고 관찰한다음, 예시도 보여주겠다.

서버 상태 vs 클라이언트 상태

서버 상태와 클라이언트 상태는 웹 애플리케이션에서 데이터를 관리하는 두 가지 다른 개념이다. 각각의 개념은 역할과 책임이 다르며, 서로 다른 용도와 특징을 가지고 있다.

서버 상태(Server State)

웹 애플리케이션의 백엔드에 저장돼 있는 데이터

특징

  • 우리가 직접 소유 X (제어권이 서버에 있음)
  • 비동기적으로 접근해야 함 (API 호출 필요)
  • 동기화 문제가 발생할 수 있음 (최신 상태 보장 필요)
  • 캐싱이 중요함 (불필요한 네트워크 요청 방지)

예시

  • 사용자 프로필 정보
  • 상품 목록 및 재고량
  • 게시글 및 댓글
  • 결제 내역

클라이언트 상태(Client State)

사용자의 브라우저나 애플리케이션 메모리에 로컬로 저장되는 데이터

특징

  • 우리가 직접 소유하고 제어 (100% 제어 가능)
  • 동기적으로 즉시 접근 가능
  • 공유되지 않음 (특정 사용자 세션에만 존재)
  • UI 상호작용과 관련된 경우가 많음

예시

  • 모달 창의 열림/닫힘 상태
  • 페이지 스크롤 위치
  • 폼 입력 값
  • UI 테마 선택
  • 로컬 필터링/정렬 설정

즉, 서버 상태는 직접 제어할 수 없고, 특별한 장비(API)가 있어야만 접근할 수 있다. 반면, 클라이언트 상태는 비교적 작고 제한적이지만, 우리가 완전히 통제할 수 있고 필요에 따라 즉시 변경할 수 있다.

왜 두 개의 라이브러리가 필요한가?

이 차이점 때문에 각 상태 유형에 최적화된 서로 다른 접근법이 필요하다. 이 차이를 구분해줄 Hook이나 컴포넌트를 만들어 각각 상태에 대해 처리해줘도 되지만, 그 접근법 중 라이브러리를 쓰는 것이 제일 편하다. 바로 React Query, Zustand(Redux)다.

React Query는 서버 상태 관리에 특화가 돼 있다.

  • 비동기 데이터 요청과 응답 처리
  • 캐싱 및 자동 리페칭
  • 로딩/에러 상태 관리
  • 데이터 동기화 처리

Zustand/Redux는 클라이언트 상태 관리에 적합하다.

  • 동기적 데이터 업데이트
  • 전역 UI 상태 관리
  • 단일 스토어에서 복잡한 상태 구조 처리
  • 로컬 상태 간의 상호 의존성 처리

즉, 서로 다른 문제를 해결하는 도구라고 생각하면 된다.

장점

이렇게 한다면 아래와 같은 이점이 있다.

  1. 책임 분리: 각 라이브러리가 자신이 잘하는 영역에만 집중 가능.
  2. 코드 가독성: 상태의 출처와 목적이 명확해져 코드 이해 용이.
  3. 성능 최적화: 각 라이브러리가 자신의 영역에 최적화된 기능 제공.
  4. 유지보수성: 서버/클라이언트 로직이 분리되어 유지보수 용이.

물론 단점도 있다. 규모가 작은 웹 앱이라면, 오버헤드가 커지고 학습해야 할 개념이 많아져 개발 효율이 떨어질 수 있다. 두 가지 상태 관리 라이브러리를 모두 알아야 하므로 러닝 커브도 높아진다.
만일 유명한 라이브러리가 아니라 지엽적인, 프로젝트 내의 유틸 Hook/함수라면? 커브가 더 높아진다.

하지만 애플리케이션이 조금이라도 성장하게 되면, 이런 구조가 빛을 발하게 된다. 특히 여러 팀원이 함께 개발할 때 코드의 일관성과 예측 가능성이 크게 향상된다.

예시

예시를 통해 서버 상태와 클라이언트 상태의 차이를 살펴보겠다.

1. 사용자 로그인

  • 서버 상태: 사용자의 인증 정보 (예: 사용자 이름, 암호화된 비밀번호)는 서버 상태로 저장. 서버는 인증을 처리하고 세션 또는 토큰을 생성하여 클라이언트에 제공.
  • 클라이언트 상태: 로그인 후 클라이언트는 세션 또는 토큰을 사용하여 사용자의 인증 상태를 유지. 이 상태는 클라이언트의 로그인 상태를 추적하고, 필요한 경우 서버에 추가 요청을 보내 인증된 요청을 처리.

2. 장바구니

  • 서버 상태: 장바구니에 있는 상품 목록과 수량은 서버 상태로 저장. 사용자의 장바구니 정보는 서버에서 관리되고 유지.
  • 클라이언트 상태: 클라이언트는 장바구니 상태를 유지. 사용자가 상품을 추가하거나 제거할 때 클라이언트 상태를 업데이트하고, 필요한 경우 서버에 변경 사항을 전송하여 장바구니를 동기화.

3. 사용자 환경 설정

  • 서버 상태: 사용자의 환경 설정 (예: 언어, 테마, 알림 설정)은 서버에 저장. 서버는 사용자의 환경 설정을 관리하고 필요한 경우 클라이언트에게 제공.
  • 클라이언트 상태: 클라이언트는 사용자의 환경 설정을 클라이언트 상태로 유지. 사용자가 환경 설정을 변경할 때 클라이언트 상태를 업데이트하고, 필요한 경우 서버에 변경 사항을 전송하여 환경 설정을 동기화.

서버 상태와 클라이언트 상태는 서로 다른 목적과 역할을 가지고 있으며, 애플리케이션의 특정 요구에 따라 적절하게 사용돼야 한다!

React Query + Zustand

사실 이 2가지 조합을 보려고 한다.

1. 로그인

Zustand (클라이언트 상태 관리)

import create from 'zustand';

interface AuthState {
  isAuthenticated: boolean;
  user: { username: string } | null;
  login: (username: string, password: string) => Promise<void>;
  logout: () => void;
}

const useAuthStore = create<AuthState>((set) => ({
  isAuthenticated: false,
  user: null,
  login: async (username, password) => {
    // 서버 요청을 통해 인증 처리
    try {
      // 인증 성공한 경우
      const response = await fetch('/api/login', {
        method: 'POST',
        body: JSON.stringify({ username, password }),
      });
      if (response.ok) {
        const user = await response.json();
        set({ isAuthenticated: true, user });
      } else {
        throw new Error('Login failed');
      }
    } catch (error) {
      console.error(error);
    }
  },
  logout: () => {
    set({ isAuthenticated: false, user: null });
  },
}));

React Query (서버 상태 관리)

// Presenter
import { useMutation } from 'react-query';

interface LoginResponse {
  token: string;
}

async function loginUser({ username, password }: { username: string, password: string }): Promise<LoginResponse> {
  const response = await fetch('/api/login', {
    method: 'POST',
    body: JSON.stringify({ username, password }),
  });

  if (!response.ok) {
    throw new Error('Login failed');
  }

  return response.json();
}

function useLoginMutation() {
  return useMutation<LoginResponse, Error, { username: string, password: string }>(loginUser);
}

// View: 컴포넌트 예시
import { useState } from 'react';

function LoginForm() {
  const [username, setUsername] = useState('');
  const [password, setPassword] = useState('');
  const auth = useAuthStore();
  const loginMutation = useLoginMutation();

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    loginMutation.mutate({ username, password });
  };

  if (auth.isAuthenticated) {
    return <p>Welcome, {auth.user?.username}!</p>;
  }

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} />
      <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
      <button type="submit" disabled={loginMutation.isLoading}>Login</button>
      {loginMutation.isError && <p>Login failed</p>}
    </form>
  );
}

위의 코드 예시에서 useAuthStore 함수는 Zustand를 사용해 클라이언트 상태를 관리한다. isAuthenticated는 사용자가 인증되었는지 여부를 나타내고, user는 현재 인증된 사용자 정보를 저장한다. login 함수는 사용자 로그인을 처리하고, logout 함수는 로그아웃을 처리한다.

useLoginMutation 함수는 React Query의 useMutation 훅을 사용하여 서버 요청을 처리하는데 사용된다. loginUser 함수는 실제 서버 요청을 보내는 로직을 담당하고, 해당 함수를 useLoginMutation 훅에 전달하여 사용자 로그인 요청을 처리한다.

위의 코드 예시에서 LoginForm 컴포넌트는 로그인 폼을 나타내며, Zustand와 React Query를 사용하여 클라이언트 상태와 서버 상태를 처리한다. 사용자가 로그인되어 있는 경우에는 환영 메시지를 표시하고, 그렇지 않은 경우에는 로그인 폼을 렌더링합니다. 로그인 폼에서는 입력된 사용자 이름과 비밀번호를 사용하여 로그인 요청을 보내고, 요청이 진행 중인 동안 버튼을 비활성화합니다. 로그인 요청이 실패한 경우에는 에러 메시지를 표시합니다.

이 코드 예시는 Zustand를 사용하여 클라이언트 상태를 관리하고, React Query를 사용하여 서버 상태를 처리하는 방법을 보여줍니다. 이를 기반으로 웹 애플리케이션에서 사용자 로그인 기능을 구현할 수 있습니다.

2. 장바구니

장바구니 예시를 들어 사용 코드를 보자.

Zustand: 장바구니 전역 상태 관리하기

import create from 'zustand';

const useCartStore = create((set) => ({
  cartItems: [],
  addToCart: (item) =>
    set((state) => ({ cartItems: [...state.cartItems, item] })),
  removeFromCart: (itemId) =>
    set((state) => ({
      cartItems: state.cartItems.filter((item) => item.id !== itemId),
    })),
}));
function Cart() {
  const cartItems = useCartStore((state) => state.cartItems);
  const addToCart = useCartStore((state) => state.addToCart);
  const removeFromCart = useCartStore((state) => state.removeFromCart);

  return (
    <div>
      <h2>장바구니</h2>
      <ul>
        {cartItems.map((item) => (
          <li key={item.id}>
            {item.name} - {item.price}{' '}
            <button onClick={() => removeFromCart(item.id)}>제거</button>
          </li>
        ))}
      </ul>
      <button onClick={() => addToCart({ id: 1, name: '상품', price: 1000 })}>
        장바구니에 추가
      </button>
    </div>
  );
}
  1. Zustand 라이브러리를 사용해 useCartStore를 생성하고 클라이언트의 장바구니 상태를 관리
  2. addToCart 함수는 상품을 장바구니에 추가
  3. removeFromCart 함수는 장바구니에서 상품을 제거

이렇게 Zustand를 사용하여 클라이언트 상태를 관리하면 장바구니 상태를 빠르고 간단하게 업데이트할 수 있다.

React Query: 물품 리스트 가져오기

// React Query (서버 상태 관리)
import { useQuery } from 'react-query';

function GetCartItem() {
  const { data: products, isLoading, isError } = useQuery('products', () =>
    fetch('/api/products').then((response) => response.json())
  );

  if (isLoading) {
    return <div>Loading...</div>;
  }

  if (isError) {
    return <div>Error fetching products</div>;
  }

  return (
    <div>
      <h2>상품 목록</h2>
      <ul>
        {products.map((product) => (
          <li key={product.id}>
            {product.name} - {product.price}</li>
        ))}
      </ul>
    </div>
  );
}
  1. React Query를 사용하여 useQuery 훅을 이용해 서버에서 상품 목록을 가져옴.
  2. useQuery의 첫 번째 매개변수는 쿼리 식별자로 사용되고, 두 번째 매개변수는 데이터를 가져오는 비동기 함수로 네트워크 호출.
  3. 가져온 상태에 따라 조건 분기 생성 및 product 리스트 Return.

이를 통해 서버 상태를 관리하면 데이터를 가져오고 업데이트할 수 있다.

상태 관리 선택의 가이드라인

어떤 상태를 어디서 관리해야 할지 헷갈린다면, 아래 질문을 해봐라.

  1. 이 데이터의 원천(source of truth)은 어디인가?
    • 서버에 있다면 → React Query
    • 클라이언트에만 있다면 → Zustand/Redux
  2. 이 데이터는 비동기적으로 가져와야 하는가?
    • 네 → React Query
    • 아니오 → Zustand/Redux
  3. 이 데이터는 다른 사용자와 공유되는가?
    • 네 → React Query
    • 아니오 → Zustand/Redux
  4. 이 데이터는 UI 상태와 관련되어 있는가?
    • 네 → Zustand/Redux
    • 아니오 → 판단 필요

이러한 이해를 바탕으로 상태 관리 전략을 세운다면, 당신의 React 애플리케이션은 더 예측 가능하고, 유지보수하기 쉬우며, 성능이 뛰어난 구조를 갖게 될 것이다!!

profile
코뿔소처럼 저돌적으로

2개의 댓글

comment-user-thumbnail
2024년 5월 29일

웹프론트 해본지 3년은 된 것 같은데 요새는 redux보다 zustand를 더 많이 쓰나요?

1개의 답글