원문: 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 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 데이터의 최신 버전을 읽으려면, 캐시를 읽으면 된다. 만약, queryResult
가 undefined
라면, 변형에서 인수를 읽고 캐시에 기록해서 새로운 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은 (필자가 보기에는 🦧같은)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개를 받지 못한 유일한 이유는, 선택자를 이해하기가 어려워서 필자의 머리가 뜨끈해졌기 때문이다. 🎭
별점: ⭐⭐⭐⭐
개요 📡
마지막 순서는 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
액션을 사용했다. 이 방법은 mapDispatchToProp
나 mapStateToProps
함수를 만들어서 사용하는 방법보다 훨씬 깔끔하다.
이와 비슷하게 상태의 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
Recoil
Redux
RTK : Repository Link || Demo Link
PlainRedux: Repository Link
Credits 🦠
이 글의 초안을 읽어준 Ben Janecke 에게 감사 인사를 전한다. 🧙♂️