프로젝트를 진행하면 초반에 어떤 상태 관리 라이브러리를 사용할지에 대해 얘기를 나눠보게 됩니다. 아직 배우고 있는 입장에서 새로운 것을 사용해보고 싶은 마음 반, 익숙한 것을 사용하고 싶은 마음이 반 드는 것 같습니다.
그러던 중 이런 식으로 새로운 라이브러리들을 어느 정도 찍먹해보고 나면 어떤 걸 사용하게 될까하는 생각이 들었습니다.
내 프로젝트의 성격과 라이브러리의 특징이 잘 맞아떨어지는 것이 최선의 선택일테니 이번 기회에 라이브러리들의 특징을 간단하게 살펴보면 좋을 것 같아 끄적이게 되었습니다...👀
사전에 상태(state)에 대해서 검색을 해보면 아래와 같이 나옵니다 :
일반적으로 CS(컴퓨터 공학)에서 말하는 상태(state)는 어떤 시스템이나 프로그램이 특정 시점에 가지고 있는 모든 정보나 조건을 말하며 시스템의 동작은 이 상태에 따라 결정됩니다.
🔎 wikipedia에서 더 자세한 내용 읽어보기
리액트에서의 상태(State)는 컴퓨터 과학의 기본 개념에서 유래되었으며 특정 시점에 컴포넌트가 가지고 있는 데이터나 정보를 의미합니다.
이 상태는 컴포넌트의 렌더링 결과를 결정하며, 사용자 입력, 이벤트에 따라 값이 변할 수 있습니다.
💡 기본적으로 웹 애플리케이션에서 상태는 상호 작용이 가능한 모든 요소의 현재 값을 의미
웹 애플리케이션에서 상태로 분류가 가능한 것들은 크게 아래 네 가지 항목으로 분류할 수 있습니다.
상태 관리는 리액트 애플리케이션에서 중요한 개념으로,
애플리케이션의 동작과 사용자 인터페이스를 동적으로 업데이트하는 데 필수적입니다.
또한 복잡한 상태 관리 요구 사항을 충족하기 위해 적절한 상태 관리 전략을 선택하는 것이 중요합니다.
상태 관리를 통해 애플리케이션의 컴포넌트들이 일관된/동일한 데이터를 공유할 수 있습니다. 이를 통해 사용자의 상호작용에 즉시 반응할 수 있으며, 전체 애플리케이션의 일관성을 유지할 수 있습니다.
상태는 애플리케이션의 동작을 결정하는 핵심 요소입니다. 컴포넌트의 상태가 변경될 때, 리액트는 해당 컴포넌트를 리렌더링하여 사용자에게 최신 정보를 제공하고, 애플리케이션의 동작을 최신 상태로 유지합니다.
효과적인 상태 관리는 애플리케이션의 성능과 유지보수성을 향상시킬 수 있습니다. 잘 설계된 상태 관리 시스템은 코드의 복잡성을 줄여주기 때문에 오류의 예방 및 상태의 쉬운 추적과 관리를 용이하게 해줍니다.
애플리케이션의 규모가 커지면서 여러 컴포넌트 간에 상태를 공유하고 관리해야 할 필요가 커집니다. 기본적인 상태 관리 방법만으로는 이러한 복잡한 상황을 처리하기 어려울 수 있으며, 이를 해결하기 위해 아래에 설명할 상태 관리 라이브러리와 패턴이 추가적으로 필요할 수 있습니다.
리액트에서 상태 관리는 컴포넌트의 state와 props를 통해 이루어집니다.
state는 컴포넌트 내부에서 관리되는 데이터이며, props는 부모 컴포넌트로부터 전달받은 데이터입니다.
state는 setState 함수를 통해 업데이트되며, 이 과정에서 리액트는 컴포넌트를 자동으로 리렌더링하여 변경된 데이터를 사용자에게 보여줍니다.
효율적인 상태 관리를 위해서는 다음과 같은 사항을 고려해야 합니다:
A1. 로컬 상태
상태가 특정 컴포넌트에만 필요한 경우, 해당 컴포넌트 내부에서 로컬 상태로 관리
A2. 전역 상태
여러 컴포넌트에서 접근하거나 수정해야 하는 상태는 전역 상태로 관리
A3. URL 상태
라우팅에 따라 변경되는 상태는 URL 쿼리 파라미터나 해시를 통해 관리 가능
사용자의 페이지 탐색 상태를 저장하고 복원하는 데 유용
A1. 컴포넌트의 책임 분리
각 컴포넌트는 자신의 상태와 관련된 데이터만 관리하도록 설계
컴포넌트 간의 의존성 ↓
상태가 필요한 컴포넌트에서만 상태를 관리
A2. 상태 분할
큰 상태 객체를 여러 개의 작은 상태로 나누어 관리
상태의 범위 ↓, 상태 변경의 영향을 최소화
A3. 상위 컴포넌트에서 상태 관리
상태가 여러 하위 컴포넌트에서 필요할 경우 상위 컴포넌트에서 상태를 관리
하위 컴포넌트에 필요한 데이터만 전달하는 방식으로 유효 범위 제한
A1. Props 전달
상위 컴포넌트에서 상태를 관리, 자식 컴포넌트에 props를 통해 상태 값을 전달
자식 컴포넌트는 전달받은 props가 변경될 때마다 리렌더링
A2. Context API
전역 상태를 Context API를 사용하여 제공
하위 컴포넌트는 Context를 구독하여 상태 변화에 반응
A3. 상태 관리 라이브러리
상태 변화가 발생할 때 자동으로 관련 컴포넌트를 업데이트
A1. 메모이제이션
React.memo, useMemo, useCallback 훅으로 컴포넌트와 함수의 결과를 메모이제이션
상태가 변경될 때만 렌더링 발생 → 불필요한 리렌더링 방지
A2. 상태 분리
컴포넌트의 상태를 세분화
상태가 변경될 때 관련된 컴포넌트만 리렌더링하도록 관리
A3. 성능 최적화
컴포넌트의 shouldComponentUpdate 메서드로 특정 조건에서만 리렌더링되도록 설정
useEffect 훅의 의존성 배열을 설정
효율적인 상태 관리는 애플리케이션의 다양한 레벨에서 상태를 쉽게 접근하고 업데이트할 수 있도록 하는 데 필수적입니다. 리액트 애플리케이션이 커지고 복잡해짐에 따라, 기본적인 state와 props만으로는 상태 관리가 어려울 수 있습니다.
이러한 문제를 해결하기 위해 리액트 생태계에서는 여러 가지 상태 관리 라이브러리와 패턴이 제안되었습니다. 이들 라이브러리는 복잡한 상태를 효율적으로 관리하고, 애플리케이션의 상태를 쉽게 유지보수할 수 있도록 도와줍니다.
✌🏻 상태 관리 라이브러리의 조건 2가지
① 어떠한 상태를 기반으로 다른 상태를 만들어낼 수 있는가
② 필요에 따라 이런 상태 변화를 최적화할 수 있는가
아래에는 리액트에서 널리 사용되는 상태 관리 라이브러리들을 소개합니다 :
리덕스(redux)는 flux 구조를 구현하기 위해 만들어진 라이브러리 중 하나로 Elm 아키텍처를 도입했다는 특징이 있습니다.
2014년 즈음 리액트의 등장과 비슷한 시기에 Flux 패턴과 함께 이를 기반으로 한 Flux 라이브러리가 등장하게 됩니다.
리액트는 단방향 데이터 바인딩을 기반으로 한 라이브러리입니다.
flux 패턴 역시 리액트와 마찬가지로 단방향 데이터 흐름을 정의하기 때문에 잘 맞았습니다.
단방향 데이터 바인딩
🟢 PROS - 데이터의 추적이 쉬움 - 코드의 가독성 용이 🔴 CONS - 코드의 양이 많아짐
Elm 아키텍처
Elm는 flux와 마찬가지로 데이터 흐름을 세 가지로 분류하고 단방향으로 강제해 웹 애플리케이션의 상태를 안정적으로 관리하고자 한 아키텍처입니다.
1️⃣ 하나의 상태 객체를 스토어에 저장
리덕스는 애플리케이션의 모든 상태를 하나의 중앙 저장소(Store)에 보관
2️⃣ 디스패치(dispatch)로 reducer 함수 호출
상태를 변경하려면, 액션(action)을 디스패치하여 리듀서(reducer) 함수를 호출
리듀서는 현재 상태와 액션을 받아 새로운 상태 객체를 반환
3️⃣ 새로운 상태를 전파
리듀서에서 반환된 새로운 상태 객체는 스토어에 저장
애플리케이션의 모든 구독된 컴포넌트에 전파
먼저 액션(action)은 상태를 변경하는 요청을 정의합니다.
setName이라는 액션을 사용하여 이름을 변경할 겁니다.
// actions.js
export const SET_NAME = "SET_NAME";
export const setName = (name) => ({
type: SET_NAME,
payload: name,
});
리듀서(reducer)는 액션을 처리하고 상태를 업데이트하는 함수입니다.
SET_NAME 액션이 호출되면 이름이 변경된 새로운 상태를 반환할 겁니다.
// reducer.js
import { SET_NAME } from "./actions";
const initialState = {
name: "Jane Doe",
age: 20,
};
export const userReducer = (state = initialState, action) => {
switch (action.type) {
case SET_NAME:
return {
...state,
name: action.payload,
};
default:
return state;
}
};
스토어(store)에서는 애플리케이션의 전역 상태를 관리합니다.
createStore 함수를 사용하여 리듀서를 기반으로 스토어를 생성합니다.
// store.js
import { createStore } from "redux";
import { userReducer } from "./reducer";
const store = createStore(userReducer);
export default store;
상위 컴포넌트에서 Provider를 통해 리덕스 스토어를 하위 컴포넌트에 제공합니다.
function App() {
return (
<div className="container">
<Provider store={store}> ✔
<UserProfile />
</Provider>
</div>
);
}
현재 사용자 이름과 나이를 화면에 표시해주고 아래 버튼 클릭 시 각각 "Jane Doe" 또는 "John Doe"로 이름을 변경되도록 합니다.
useSelector를 통해 전역 상태에서 현재 이름과 나이를 가져오고,
useDispatch를 사용해 이름을 변경하는 액션을 디스패치하고 있습니다.
const UserProfile = () => {
const user = useSelector((state) => state);
const dispatch = useDispatch();
return (
<div className="user-profile">
<h1>User Profile</h1>
<p>Name: {user.name}</p>
<p>Age: {user.age}</p>
<button onClick={() => dispatch(setName("Jane Doe"))}>
Set Female name
</button>
<button onClick={() => dispatch(setName("John Doe"))}>
Set Male name
</button>
</div>
);
};
export default UserProfile;
위 코드의 실행 결과는 아래에서 확인 가능합니다 :
🤔 provider가 여러 개라면?
가장 가까운 Provider의 값을 가져오게 됩니다.
Redux를 더욱 쉽게 사용하기 위한 RTK에 대한 게시글 보러 가기
props drilling 및 too much boilerplate 두 가지 단점을 잡기 위해 등장한 것이 바로 리액트의 context API 입니다.
props drilling을 해결하는데에는 리덕스(redux)도 있지만 리덕스의 투머치 보일러플레이트는 부담이 될 뿐만 아니라 컴포넌트 설계 시에도 큰 제약이 있기 때문입니다.
🤷🏻♀️ 어떤 제약?
① 작은 컴포넌트 및 간단한 상태 관리에도 과도한 구조적 복잡함
② 애플리케이션이 커질수록 유지보수가 어려움
③ 컴포넌트가 리덕스 스토어에 의존하게 되어 재사용성과 독립성이 저하
④ 상태를 전부 전역으로 관리하므로 특정 컴포넌트 테스트가 어려움
컨텍스트(context)는 props drilling을 극복하기 위해 등장한 개념입니다.
컨텍스트를 사용할 경우, props를 명시적으로 사용하지 않아도 선언한 모든 하위 컴포넌트에서 값을 사용할 수 있다는 특징이 있습니다.
useContext는 상위 컴포넌트에서 만들어진 컨텍스트를 함수 컴포넌트에서 사용할 수 있도록 만들어진 훅입니다.
// 01. create context
export const UserContext = createContext();
function App() {
const [sampleUser, setSampleUser] = useState({ name: "Jane Doe", age: 20 });
return (
// 02. provide state to child components by Provider
<div className="container">
<UserContext.Provider value={{ sampleUser, setSampleUser }}> ✔
<UserProfile />
<UpdateName />
</UserContext.Provider>
</div>
);
}
export default App;
UserContext의 Provider 안에 포함되는 자식 컴포넌트들은 value 안의 상태를 useContext 훅으로 가져다 쓸 수 있습니다.
// component 1
const UserProfile = () => {
const { sampleUser } = useContext(UserContext);
return (
<div>
<h1>User List</h1>
<p>Name : {sampleUser.name}</p>
<p>Age : {sampleUser.age}</p>
</div>
);
};
export default UserProfile;
// component 2
const UpdateName = () => {
const { sampleUser, setSampleUser } = useContext(UserContext);
const updateName = () => {
setSampleUser({ ...sampleUser, name: "John Doe" });
};
return (
<div>
<button onClick={updateName}>Change Name</button>
</div>
);
};
export default UpdateName;
위 코드의 실행 결과는 아래에서 확인 가능합니다 :
🤔 context 사용 시 주의해야할 점이 있다면?
① context API는 상태 관리가 아닌 주입을 도와주는 기능이란 점
② 렌더링을 막아주는 기능이 없다는 점
리코일(recoil)은 페이스북에서 만든 리액트를 위한 상태 관리 라이브러리입니다.
훅의 개념으로 상태 관리를 시작한 라이브러리 중 하나이기도 합니다.
2020년에 처음 만들어졌으며, github 주소로 가보면 아직 정식으로 출시가 되지 않은 것을 확인할 수 있습니다.
리코일 팀에서는 리액트 18에서 제공될 기능들이 지원되기 전까지는 1.0.0을 릴리즈(release)하지 않을 것이라고 밝혔습니다.
따라서 실제 프로젝트에 리코일을 채택해 사용하기엔 안정성, 성능, 사용성 등이 보장되지 않을 수 있습니다.
리코일을 사용하기 위해 RecoilRoot를 애플리케이션의 최상단에 선언해야 합니다.
RecoilRoot는 리코일에서 생성되는 상태값을 저장하기 위한 스토어를 생성하기 때문에 최상단에 선언을 해야 합니다.
function App() {
return <RecoilRoot>
{/*other components*/}
</RecoilRoot>
}
export default App;
리코일의 상태값은 RecoilRoot로 생성된 context의 스토어에 저장됩니다.
스토어의 상태값에 접근할 수 있는 함수들을 활용해 상태값을 변경할 수 있으며
값의 변경될 경우 해당 상태를 참조하고 있는 모든 하위 컴포넌트에게 알려줍니다.
atom은 리코일에서 최소 상태 단위를 나타내는 개념입니다.
key와 default 값을 필수로 가지며 이 key 값은 다른 atom과 구별되는 식별자가 됩니다.
selector(선택자)는 파생 상태(derived state)를 만들기 위한 도구입니다. 이는 여러 개의 atom을 조합하거나, 하나의 atom에서 특정 데이터를 가공하여 새로운 상태를 계산하는 데 사용됩니다.
즉, selector는 기존 상태를 기반으로 계산된 값을 반환하는 함수입니다.
이를 통해 컴포넌트에서 필요한 데이터만 선택적으로 사용할 수 있게 됩니다.
atom을 만들어 줍니다.
저는 파일을 분리했기 때문에 export를 했지만 한 파일에 전부 작성할 예정이라면 그냥 선언만 해주어도 무방합니다.
export const sampleNameState = atom({
key: "nameState",
default: "John",
});
리액트의 useState 대신 useRecoilState를 사용하면 됩니다.
안에는 atom을 생성했을 때 작성했던 식별자 key의 값을 넣어주면 됩니다.
const UserProfile = () => {
const [name, setName] = useRecoilState(sampleNameState);
const toggleName = () => {
setName(name === "John" ? "Jane" : "John");
};
return (
<div>
<h1>{name}</h1>
<button onClick={toggleName}>Toggle Name</button>
</div>
);
};
export default UserProfile;
최상단 컴포넌트에서 RecoilRoot로 recoil이 사용될 컴포넌트들을 감싸주면 됩니다.
function App() {
return (
<div className="container">
<RecoilRoot>
<UserProfile />
</RecoilRoot>
</div>
);
}
export default App;
위 코드의 실행 결과는 아래에서 확인 가능합니다 :
recoil 공식 홈페이지에서 더 자세한 내용 보기
조타이(jotai)는 리코일의 atom 모델에 영감을 받아 만들어진 라이브러리입니다.
공식 문서에도 언급되어 있지만,
Jotai는 리액트의 Context API에서 발생하는 불필요한 리렌더링 문제를 해결하기 위해 개발되었으며, 메모이제이션이나 추가적인 최적화 없이도 불필요한 리렌더링을 방지하도록 설계되었습니다.
조타이는 상향식(bottom-up) 접근법을 취하고 있기 때문에 작은 단위의 상태(atom)를 상위로 전파할 수 있는 구조를 갖고 있습니다.
리코일에서 atom 모델에 영감을 받았기 때문에 조타이에도 동일하게 atom이 존재합니다.
차이가 있다면 조타이에서는 atom 하나로 파생된 상태도 만들 수 있다는 것입니다.
또한 조타이에서는 atom을 생성할 때 고유한 key 값을 전달해주지 않아도 됩니다.
const counterAtom = atom(0)
console.log("atom", counterAtom);
/*
"atom",
{
init: 0,
read: (get) => get(config),
write: (get, set, update) =>
set(config, typeof update === "function" ? update(get(config)) : update
}
*/
조타이의 useAtomValue 함수에는 rerenderIfChanged라는 로직이 있습니다.
이 로직으로 인해 조타이에서는 atom의 값이 어디서 변경되든지 useAtomValue로 값을 사용하는 쪽에서는 항상 최신의 atom을 사용해 렌더링할 수 있습니다.
rerenderIfChanged가 일어나는 경우는 크게 두 가지가 있습니다 :
① 넘겨받은 atom이 reducer를 통해 스토어에 있는 atom과 달라졌을 때
② subscribe를 수행하고 있던 중 어디서 값이 변경되었을 때
atom으로 상태를 만들 수 있으며 리코일과 다르게 key 값을 넘겨주지 않아도 됩니다.
export const sampleNameAtom = atom("John");
생성된 상태는 useAtom으로 불러와 사용 가능합니다.
const UserProfile = () => {
const [name, setName] = useAtom(sampleNameAtom);
const toggleName = () => {
setName(name === "John" ? "Jane" : "John");
};
return (
<div>
<h1>{name}</h1>
<button onClick={toggleName}>Jotai Test</button>
</div>
);
};
export default UserProfile;
다른 라이브러리들과 달리 최상단을 provider라던가 context로 감쌀 필요가 없습니다.
function App() {
return (
<div className="container">
<UserProfile />
</div>
);
}
export default App;
위 코드가 실행된 결과는 아래와 같습니다 :
jotai 공식 홈페이지에서 더 자세한 내용 보기
zustand는 리덕스(redux)에 영감을 받아 만들어진 라이브러리입니다.
따라서 zustand는 하나의 스토어(store)를 중앙 집중형으로 활용해 스토어 내부에서 상태를 관리하는 방식입니다.
zustand의 깃허브를 살짝 훔쳐보면 관련된 코드를 볼 수 있습니다.
zustand의 store와 관련된 코드는 ./src/vanilla.ts
에서, store를 리액트에서 사용할 수 있도록 도와주는 함수들은 ./src/react.ts
에서 관리되고 있습니다.
바닐라 자바스크립트의 함수와 객체를 사용하여 상태를 관리하기 때문에 리덕스의 복잡한 구조를 갖추지 않아도 된다는 점에서 리덕스보다 유연하다고 볼 수 있습니다.
이러한 구조 덕분에 zustand는 리덕스에 비해 상태 관리 로직이 더욱 간단하고 직관적으로 작성됩니다.
또한 리액트의 useState와 유사한 setState 메커니즘을 사용하여 상태를 업데이트합니다. 이러한 구조는 상태 업데이트가 매우 직관적이며, 부분적인 상태 변경(partial state update)도 쉽게 수행할 수 있도록 도와줍니다.
// 64번째 줄 ~
const setState: StoreApi<TState>['setState'] = (partial, replace) => {
const nextState =
typeof partial === 'function'
? (partial as (state: TState) => TState)(state)
: partial
if (!Object.is(nextState, state)) {
const previousState = state
state =
(replace ?? (typeof nextState !== 'object' || nextState === null))
? (nextState as TState)
: Object.assign({}, state, nextState)
listeners.forEach((listener) => listener(state, previousState))
}
}
partial은 state의 일부를 변경할 때, replace는 state를 완전히 새로운 값으로 대체할 때 사용됩니다. 이를 통해 state가 객체일 때 필요에 따라 유연하게 사용할 수 있습니다.
zustand github 방문하기
Zustand의 create 함수를 사용해 스토어를 생성합니다.
export const sampleUserStore = create((set) => ({
name: "John",
toggleName: () =>
set((state) => ({
name: state.name === "John" ? "Jane" : "John",
})),
}));
생성한 스토어의 상태와 함수를 그대로 가져와 사용할 수 있습니다.
const UserProfile = () => {
const { name, toggleName } = sampleUserStore();
return (
<div>
<h1>{name}</h1>
<button onClick={toggleName}>zustand sample</button>
</div>
);
};
export default UserProfile;
마찬가지로 별도의 provider나 context로 감쌀 필요가 없습니다.
function App() {
return (
<div className="container">
<UserProfile />
</div>
);
}
위 코드의 실행 결과는 아래와 같습니다:
zustand 공식 홈페이지에서 더 자세한 내용 보기
라이브러리 | 복잡도 | 사용성 | 커뮤니티 크기 | 애플리케이션 규모 | 러닝 커브 | 타입스크립트 지원 | 미들웨어 지원 |
---|---|---|---|---|---|---|---|
Context API | 낮음 | 간편 | O | 작음 | 낮음 | O | X |
Redux | 높음 | 복잡 | O | 대규모 | 높음 | O | O |
Recoil | 중간 | 간편 | △ | 중간 | 중간 | O | X |
Zustand | 낮음 | 간편 | △ | 작음 | 낮음 | O | X |
Jotai | 중간 | 간편 | △ | 중간 | 중간 | O | X |
상태를 불변 객체로 관리하면 상태 업데이트의 복잡성을 줄이고 성능 최적화에 도움이 됩니다.
불필요한 상태는 애플리케이션의 복잡성을 증가시키고 버그의 원인이 될 수 있습니다.
필요한 상태만을 관리하여 코드의 간결함을 유지하는 것이 좋습니다.
상태 업데이트 로직을 컴포넌트 외부로 분리하면 코드의 재사용성을 높이고 테스트를 용이하게 할 수 있습니다. 이렇게 하면 컴포넌트는 UI를 표현하는 역할에 집중할 수 있습니다.
References.
[🌎 Officials]
[📚 Books]
[👩🏻💻 Blogs]
[🎥 Videos]