React 상태 관리 기술 소개 2021 ⚜️🌐

원문: https://itnext.io/an-overview-of-react-state-management-techniques-in-2021-️-1590242b1cbc- 저자: Amo Moloko

큰 규모의 프론트엔드 개발을 하다 보면, 사용자의 로그인 정보나 특정 UI의 메타데이터 혹은 서비스의 데이터 등 애플리케이션의 상태를 저장하고 관리할 전역 스토어가 필요할 것이다. 그리고 이런 전역 스토어를 직접 개발할 것인지도 결정해야 할 것이다.

이 포스트에서는 여러 상태 관리 도구 중, Apollo의 로컬 캐시, Recoil, Redux 3가지만 다룰 예정이다. 그리고 각 도구는 개요, 코드 설명, 후기 3가지로 나눠서 다룰 예정이다.

필자가 React로 큰 규모의 웹 애플리케이션을 개발하게 된다면, 프론트엔드 기술 스택에 추가하고 싶은 정도를 ⭐️로 환산해서 각 도구에 점수를 매길 예정이다.

모든 실험은 React Bleeding Edge kit으로 생성된 프로젝트로 진행되었고, 이 툴킷은 Create React App (CRA)로 생성된 프로젝트 위에 Apollo Client, TailwindCSS , Reach Router가 구동되는 고급 사용자를 위한 툴킷이다.

Apollo 로컬 캐시

Repository Link || Demo Link

개요 📡

만약 Apollo Client를 React나 Vue에서 사용 중이라면, Apollo의 로컬 캐시를 상태 관리 도구로 사용할 수 있다. GraphQL로 데이터를 가져오거나, 상태 변경을 할 수 있다는 장점이 있다. 코드 설명을 보면 알겠지만, 필자는 Apollo2를 사용했고, 로컬 resolver가 필요하다. 하지만 지금은 deprecate 되었다는 걸 명심하자.

아마 이 작자(역: clown of an author)가 왜 deprecate 된 API를 사용하라고 말하는지 의아할 것이다. 짧게 설명하자면 필자가 Apollo의 상태 관리 철학을 좋아하지 않는다는 것이고 그 다음은 필자의 편견을 반영한 것이다🥲. 하지만, Apollo 3의 최근 구현LaurBeatris의 Apollo 캐시 관리 이야기를 꼭 확인해보길 바란다. 🍦

코드 설명 🖥️

React 애플리케이션에 Apollo 캐시를 사용하도록 구성하기 위해서는, 계속 사용할 값에 대해 index.js파일에서 첫 상태 초기화를 해야 한다.

import React from "react";
import ReactDOM from "react-dom";
import "./styles/tailwind.css";
import App from "./App";
import * as serviceWorker from "./serviceWorker";
import ApolloClient from "apollo-boost";
import { ApolloProvider } from "@apollo/react-hooks";
import { resolvers } from "./resolvers";
import { InMemoryCache } from "apollo-cache-inmemory";

const cache = new InMemoryCache();

const client = new ApolloClient({
  cache,
  uri: process.env.REACT_APP_API_ENDPOINT || "http://localhost:4000/graphql",
  resolvers,
  onError: ({ networkError, graphQLErrors }) => {
    if (graphQLErrors) {
      console.log("graphQLErrors", graphQLErrors[0]);
    }
    console.log("networkError", networkError);
  },
}); //상태 초기화를 수행할 위치
cache.writeData({
  data: {
    formData: { date: "", email: "", customer: [], __typename: "formData" },
  },
});

전역 상태는 cache.writeData를 통해서 애플리케이션의 캐시에 저장된다. 이는 필수적으로 __typename필드를 추가해줘야 한다는 것을 의미한다. 이 값을 통해 Apollo는 객체의 어느 부분을 질의(query)하고 변형(mutation)할 것인지 알 수 있다. 별도의 상태 객체에 더 많은 데이터를 저장해야 하는 경우, 이 객체들을 생성하고 각 필드에 대해 __typename필드를 명시해야 한다. 이 부분이 아주 큰 단점이다.

다음과 같은 변형을 사용해서 로컬 resolver 파일을 만들어야 했다.

import { GET_FORM_DATA } from "./graphql/Queries";

export const resolvers = {
  Mutation: {
    updateFormData: (parent, args, context, info) => {
      const queryResult = context.cache.readQuery({ query: GET_FORM_DATA });
      const { formData } = queryResult;
      if (queryResult) {
        const data = {
          formData: {
            date: args.date,
            email: args.email,
            customer: args.customer,
            __typename: formData["__typename"],
          },
        };

        context.cache.writeQuery({ query: GET_FORM_DATA, data });
        return data.formData;
      }
      return [];
    },
  },
};

form 데이터의 최신 버전을 읽으려면, 캐시를 읽으면 된다. 만약, queryResultundefined라면, 변형에서 인수를 읽고 캐시에 기록해서 새로운 formData객체를 생성하여 읽을 수도 있다.

캐시에서 데이터를 읽으려면, 먼저 질의를 작성해야 한다.

import gql from "graphql-tag";

export const GET_FORM_DATA = gql`
  query GET_FORM_DATA {
    formData @client {
      date
      email
      customer
    }
  }
`;

이 스키마 문서는 기본적으로 formData 객체에서 email, customer, date를 질의한다. 애플리케이션의 다른 컴포넌트에서 이 질의를 다시 사용하게 될 것이다.

다음은, formData를 업데이트할 수 있는 다른 변형을 만들어보자.

import gql from "graphql-tag";

export const UPDATE_FORM_DATA = gql`
  mutation UPDATE_FORM_DATA(
    $date: String
    $email: String
    $customer: CustomerInput
  ) {
    updateFormData(date: $date, email: $email, customer: $customer) @client
  }
`;

이것은 email, date, customerInput의 수정을 받아들인다. 객체 형태로 누군가를 고객 배열에 추가하는 액션을 한번 확인해보자.

import React, { useState } from "react";
import { UPDATE_FORM_DATA } from "../../graphql/Mutations";
import { GET_FORM_DATA } from "../../graphql/Queries";
import { useMutation, useQuery } from "@apollo/react-hooks";

const Customers = (props) => {
  const [customers, setCustomers] = useState([]);
  const { data } = useQuery(GET_FORM_DATA);
  const [mutate] = useMutation(UPDATE_FORM_DATA);

  const addCustomer = () => {
    const o = [...customers];
    o.push({
      name: null,
      physioScore: null,
      surname: null,
      passportNumber: null,
      country: null,
    });

    setCustomers(o);
  };

  const updateCustomer = ({ index, field, value }) => {
    const o = [...customers];
    o[index][field] = value;
    mutate({
      variables: {
        email: data.formData.email,
        date: data.formData.date,
        customer: o,
      },
    });

    setCustomers(o);
  };

  const removeCustomer = (index) => {
    const o = [...customers];
    o.splice(index, 1);
    setCustomers(o);
  };

  return (
    <>
    //...
    </> 
  );
}

이 컴포넌트를 사용하면 여러 고객을 배열로 추가할 수 있다. 데이터를 로컬 상태로 저장하기 위해서는, 먼저 customers, email, date 질의를 해야 한다. 그다음 변형을 통해 고객을 업데이트한다. 이렇게 하면 고객 초기화의 여정은 끝이 난다. 이제, 사용자가 다음 컴포넌트로 이동할 때 가장 최신 버전의 스토어를 제공할 수 있다.

후기 🤔

로컬 resolver를 빼고는, 전체 설정을 예측하기 힘들다. 그리고 로컬 상태에 15개 이상의 필드가 있는 객체를 저장하는 경우, GraphQL의 스키마 유효성 검사가 잘 동작하지 않았다. Apollo3는 이 점을 개선하려고 한다. 하지만, 큰 애플리케이션에서 상태 관리 로직을 모듈 형태로 구성하는 경우를 생각해보면, 그 구현은 간단하지 않을 것으로 보인다. GraphQL 그룹의 구성원으로서 Apollo를 사용할 가치가 없다고 말하자니 슬플 따름이다.

별점:

Recoil

Repository Link|| Demo Link

개요 📡

Recoil은 (필자가 보기에는 🦧같은)React 내장 상태 관리 도구와 관련된 고통을 덜어주는 게 목표인, 2020년 중반에 릴리즈된 상태 관리 도구다.

기초 개념으로는 상태 조각을 구성하는 Atom과 상태를 변경할 수 있는 선택자가 있다. 선택자는 객체의 중첩된 함수를 사용해서 hook을 사용하는 컴포넌트의 상태를 읽거나 변경할 수 있게 해준다. 간결한 상태 관리와 보일러 플레이트 같지 않은 코드를 지향한다.

코드 설명🖥️

이 데모는 Formula One (F1) 테마의 코드다. 심지어 필자는 비동기 호출을 처리하기 위해 공개된 F1 API를 사용하기도 했다. 관리할 모든 상태가 들어있는 atoms.js 파일을 생성했다.

//atom.js
import {atom} from "recoil";

const driverState = atom({
  key: "driverState", 
  default: ["Max Verstappen", "Lando Norris"]
}); 
const circuitState = atom({  key: "circuitState",  default: [],}); 
const singleDriverState = atom(( key: "singleDriverState",  default: {},});

export { driverState, circuitState, singleDriverState };

상태를 초기화하는 방법은 아주 간단하다. 키를 가지고 있는 객체의 형태면 된다. 하지만, 이 상태가 애플리케이션의 다른 부분에서 이 상태를 참조하는 키기 때문에 반드시 알아둬야 한다. 그리고, default 키를 통해 기본 상태 값을 추가해서 그 값의 타입을 명시할 수도 있다. 정의한 상태 값을 보면, driveState는 배열이며 circuitState 또한 서킷의 배열이고, 드라이버의 모든 정보를 저장하는 singleDriverState 객체가 있다.

index 페이지를 작성하다 보니, 이 파일에 모든 드라이버를 명시하는 게 더 좋다고 느껴졌다.

import React from "react";
import { useRecoilValue } from "recoil";
import { driverState } from "../state/atoms";
import HeadingOne from "../components/HeadingOne";
const Index = (props) => {
  const drivers = useRecoilValue(driverState);
  return (
    <>
      <HeadingOne className="text-center text-blue-500">
        Recoil 상태 관리 데모
      </HeadingOne>
      {drivers.map((driver, index) => (
        <div
          className="cursor-pointer"
          key={index}
          onClick={() => props.navigate(`/driver/${driver.split(" ")[1]}`)}
        >
          {driver}
        </div>
      ))}
    </>
  );
};

export default Index;

useRecoilValue() hook을 사용하면 쉽게 상태 값을 참조할 수 있다. 앞서 atoms에 hook을 파라미터처럼 사용해서 초기화해두었으니, driverState를 그냥 사용만 하면 되는 것이다.

다음은, 대부분의 애플리케이션에서 필요로 할 비동기 예제를 준비했다. 사용자의 한 달간의 은행 거래를 가져와서 애플리케이션 전반에서 사용할 수 있게 한다고 상상해보자. selectorFamily()유틸리티를 다음과 같이 정의해서 사용할 수 있을 것이다.

import {selectorFamily } from "recoil";
import axios from "axios";
import { singleDriverState } from "./atoms";
const driverQuery = selectorFamily({
  key: "driver",
  get: (driverName) => async () => {
    const driverResponse = await axios.get(
      `http://ergast.com/api/f1/drivers/${driverName}.json`
    );
    return driverResponse.data.MRData.DriverTable.Drivers[0];
  },
  set: ({ set }, newValue) => set(singleDriverState, newValue),
});

export {driverQuery };

특정 드라이버의 정보를 받고 싶다면 해당 드라이버의 이름이 필요하다. key 속성이 이 선택자가 driver atom을 다루고 있다는 걸 알려준다. get() 키를 통해 실제로 드라이버의 정보를 받아오는 비동기 함수 호출을 할 수 있다. set 키를 통해 driver atom에 드라이버 정보를 저장 할 수 있다.

후기 🤔

전반적으로 Recoil은 깔끔하고 빨랐다. Hook 기반의 깔끔한 API와 직관적으로 구현이 잘 드러난 문서를 가진 모던 React 라이브러리다. 별 5개를 받지 못한 유일한 이유는, 선택자를 이해하기가 어려워서 필자의 머리가 뜨끈해졌기 때문이다. 🎭

별점: ⭐⭐⭐⭐

Redux

Repository Link || Demo Link

개요 📡

마지막 순서는 React 커뮤니티의 사랑을 듬뿍 받는 Redux 다! 처음에는 prop-drilling(역: 부모 요소로부터 자식 요소로 prop을 계속 내려주는 행위) 이슈를 해결하기 위해 등장했다. Redux 는 flux 패턴으로 동작한다. 최초 초기화한 상태와 그 상태를 reduce 하는데 도움을 주는 액션으로 구성되어있다. Redux 애플리케이션을 작성하는 방법에는 여러 가지가 있지만, 보일러플레이트화된 코드도 있다. 다행인 것은, 필자가 모듈 방식으로 파일 및 함수와 함께 Redux 을 사용하는 방법을 마스터한 뒤에 Redux Toolkit을 찾았다는 것이다.

코드 설명 🖥️

필자가 보통 구성하는 Redux 애플리케이션의 구조는 다음과 같다.

보다시피 약간 방대하고, 결과를 보려면 여러 파일을 수정해야 할 것이다. 이 점이 비생산적이고 불필요한 브레인 댄스(역:Cyberfunk 2077 개발진 사과 영상 meme) 라고 할 수 있다.

다행히도, Redux Toolkit은 Recoil의 구조적 원칙과 비슷한 부분을 충분히 차용했다. 따라서, 여러분은 슬라이스(Slice)를 만들어야 하는데, 이는 상태의 한 조각이라 말할 수 있다. 필자의 경우, 각 경로의 페이지 헤더를 변경할 수 있는 레이아웃 슬라이스를 만들었다.

import { createSlice } from "@reduxjs/toolkit";
import { setNewTitle } from "../reducers/layoutReducers";
export const layoutSlice = createSlice({
  name: "layout",
  initialState: {
    pageHeader: "Page",
  },
  reducers: {
    setPageHeader: (state, action) => setNewTitle(state, action),
  },
});

export const { setPageHeader } = layoutSlice.actions;

export default layoutSlice.reducer;

layout은 현재 필자가 추적하고 있는 pageHeader 문자열의 이름이다. 이 상태는 Page 라는 기본값을 가지고 있다. setPageHeader라는 리듀서도 있는데, 이 리듀서는 pageHeader의 값을 변경한다. 각 파일에서 리듀서를 내보내는(export) 방법도 드러나 있다.

이제 리듀서를 보자! layoutReducer파일 안에 있다.

export const setNewTitle = (state, action) => {
  state.pageHeader = action.payload;
  return state;
};

setNewTitle이라는 payload를 전역 상태의 pageHeader에 대입하는 간단한 함수를 내보내고 있다.

이제 useSelector라는 hook을 통해 전역 App 컴포넌트에서 상태를 가져와서 사용해보자.

import React, { useEffect } from "react";
import { useSelector } from "react-redux";
import Routes from "./routes";
const App = () => {
  const title = useSelector((state) => state.layout.pageHeader);
  return (
    <div className="text-center">
      {title}
      <Routes />
    </div>
  );
};
export default App;

pageHeader 타이틀을 바꾸는 유일한 방법은 useEffect hook 뿐이다.

import React, { useEffect } from "react";
import { useDispatch } from "react-redux";
import { setPageHeader } from "../slices/layoutSlice";
import HeadingOne from "../components/HeadingOne";

const Index = () => {
  const dispatch = useDispatch();
  useEffect(() => {
    dispatch(setPageHeader("index"));
  }, []);
  return (
    <>
      <HeadingOne className="text-center text-blue-500">
        이 앱의 메인 페이지입니다!
      </HeadingOne>
    </>
  );
};

export default Index;

useDispatch hook을 사용해서 가져온 dispatch 함수를 통해, 이전에 내보냈던 setPageHeader 액션을 사용했다. 이 방법은 mapDispatchToPropmapStateToProps 함수를 만들어서 사용하는 방법보다 훨씬 깔끔하다.

이와 비슷하게 상태의 mountains 배열을 초기화했고, 애플리케이션의 입력 폼의 값을 dispatch 했다.

import React, { useState, useEffect } from "react";
import { useDispatch, useSelector } from "react-redux";
import { addMountain } from "../slices/mountainsSlice";
import { setPageHeader } from "../slices/layoutSlice";

const Mountains = (props) => {
  const [mountainName, setMountainName] = useState("");

  const mountains = useSelector((state) => state.mountain.mountains);
  const dispatch = useDispatch();
  useEffect(() => {
    dispatch(setPageHeader("산 목록"));
  }, []);

  return (
    <div className="grid  grid-rows-2 place-items-center">
      <div>
        <input
          className="border border-pink-500 "
          onChange={(e) => setMountainName(e.currentTarget.value)}
        />
        <button
          className="rounded-lg bg-red-600 px-2 py-2 ml-3 text-red-200 "
          onClick={() => dispatch(addMountain(mountainName))}
        >
          산 추가
        </button>
      </div>

      <div className="flex flex-col">
        {mountains.map((mountain, idx) => (
          <p key={idx}>{mountain}</p>
        ))}
      </div>
    </div>
  );
};

export default Mountains;

아래와 같은 리듀서를 사용했다.

export const addNewMountain = (state, action) => {
  state.mountains.push(action.payload);
  return state;
};

후기 🤔

이전에 Redux를 사용하던 때와 비교했을 때, Redux Toolkit을 사용하는게 훨씬 좋고 깔끔했다. 애플리케이션이 커지더라도 훨씬 추상화 하기 쉬웠다.

별점: ⭐⭐⭐⭐

마지막으로, 필자는 정확하게 구성한 프론트엔드 구조를 배우는 것을 정말 좋아한다. 상태 관리가 엉성하게 구성되어있다면, 나중에 고통스럽기 때문이다. Recoil과 Redux Toolkit은 상태 관리 계층을 경쾌하고 모듈화 하기 쉽게 해준다고 할 수 있다.


아래는 다양한 Repository 링크들이다 🔌 .

Apollo

Repository Link || Demo Link

Recoil

Repository Link || Demo Link

Redux

RTK : Repository Link || Demo Link

PlainRedux: Repository Link

Credits 🦠

Apollo의 상태 관리 예제

이 글의 초안을 읽어준 Ben Janecke 에게 감사 인사를 전한다. 🧙‍♂️