리액트 애플리케이션에 자신이 익숙한 상태 관리 라이브러리를 설치하는 것이 익숙하지만, 왜 상태 관리가 필요한지와 어떻게 리액트에서 동작하는지 모르는 개발자 분들이 많다.(필자 포함)
이 글에서는 상태 관리 라이브러리의 필요성 부터 최근 많이 주목받고 있는 상태 관리 라이브러리가 어떻게 작동하는지 살펴보자.
상태 관리는 왜 필요한가
웹 애플리케이션을 개발할 때의 상태는 어떠한 의미를 지닌 값이며 애플리케이션의 시나리오에 따라 지속적으로 변경될 수 있는 값을 의미한다.
- UI : 기본적으로 웹 애플리케이션에서 상호작용이 가능한 모든 요소의 현재 값
- URL : 브라우저에서 관리되고 있는 상태값
- form : 폼에 로딩 여부, 제출 여부, 접근 불가능 여부, 유효 상태 등 모두 상태로 관리된다.
- 서버에서 가져온 값 : 클라이언트에서 서버로 요청을 통해 가져온 값(feat. API 요청 응답 값)
과거 웹은 단순히 서버의 응답 내용을 보여주기만 했지만, 현대 웹에서 다양한 기능이 제공됨에 따라 점차 증가하는 상태를 효과적으로 관리하는 방법을 계속해서 고민해야 하는 시대가 되었다...
상태 관리 자체는 크게 어려운 일이 아니지만 단순히 손이 많이 가는 문제일 수 있다. 하지만 애플리케이션 전반적으로 관리해야할 상태가 있다면, 상태의 위치, 전역 변수의 여부, 유효 범위의 제한 등 어려움이 있을 수 있다. 또한 상태 변화에 따라 모든 요소들이 변경되어 애플리케이션이 찢어지는 현상인 tearing도 발생할 수 있다.
이처럼 상태 관리란 어렵다고 해서 외면할 수 없는 주제가 됐다. 이러한 상태를 효율적으로 관리하고 빠르게 반응할 수 있는 모델에 대한 고민이 본격적으로 시작된 것이다.
리액트 상태 관리의 역사
애플리케이션 개발에 모든 것을 제공하는 Angular 프레임워크와는 달리 React는 단순히 UI를 만들기 위한 라이브러리이다. 따라서 상태 관리를 하는 방법도 개발자에 따라 시간에 따라 많은 차이가 있다.
Flus 패턴의 등장
순수 리액트에서 할 수 있는 전역 상태 관리 수단이라고 하면 Context API를 생각할 것이다.
하지만 Context API는 상태 관리가 아닌 상태 주입을 도와주는 것이다.
그러나 리액트가 16.3 버전에서 Context를 릴리스 했으며, 16.8 버전에서 useContext를 릴리스 했다. 그러니 그 전까지는 딱히 유명한 상태 관리 라이브러리는 없었다.
그러던 중 리액트와 비슷하게 등장한 Flux 패턴과 이를 기반으로 한 라이브러리인 Flux를 알아보자.
이 당시 웹 애플리케이션은 점점 더 비대해지고 상태도 많아짐에 따라 어디서 어떤 일이 일어나서 상태가 변하는지 등을 추적하고 이해하기가 매우 어려운 상황이였다.
위의 사진과 같은 MVC 패턴은 뷰(HTML)가 모델(JS)을 변경할 수 있으며, 반대로 모델이 뷰를 변경할 수도 있다. 이러한 양방향 데이터 흐름은 코드의 양이 많아질 수 도, 변경 시나리오가 복잡해 관리가 어려워질 수 있다.
페이스북 팀은 양방향이 아닌 단방향 데이터 흐름을 갖는 방식을 제안했으며, 이것이 Flux 패턴의 시작이다.
각가의 용어를 정리해보자.
- 액션 Action
어떠한 작업을 처리할 액션과 액션 발생시 함께 포함시킬 데이터 - 디스패처 Dispatcher
액션을 스토어에 보내는 역할을 한다. - 스토어 Store
실제 상태에 따른 값과 변경할 수 있는 메서드를 갖고 있다. 액션의 타입에 따라 어떻게 이를 변경할지가 정의돼 있다. - 뷰 View
리액트 컴포넌트에 해당하는 부분이며, 스토에서 만들어진 데이터를 가져와 화면을 렌더링하는 역할을 한다.
뷰에서 상태를 업데이트할 때는 액션을 호출하는 구조로 구성된다.
import { useReducer } from "react";
type StoreState = {
count: number;
};
type Action = { type: "add"; payload: number };
function reducer(prevState: StoreState, action: Action) {
const { type: ActionType } = action;
if (ActionType === "add") {
return {
count: prevState.count + action.payload,
};
}
throw new Error(`Unexpected Action [${ActionType}]`);
}
export default function MyComponent() {
const [state, dispatcher] = useReducer(reducer, { count: 0 });
function handleClick() {
dispatcher({ type: "add", payload: 1 });
}
return (
<div>
<h1>{state.count}</h1>
<button onClick={handleClick}>+</button>
</div>
);
}
- type Action으로 액션의 종류를 정의했다.
- 스토어의 역할을 하는 useReducer와 reducer
- dispatcher로 이 액션을 실행
- View인 App에서 보여준다
이런 단방햔 데이터 흐름 방식도 불편함은 있다. 사용자 입력에 따라 데이터를 갱신하고 코드의 양이 많아지고 개발자도 수고로워진다. 하지만 데이터의 흐름이 액션이라는 단방향으로 줄어드므로 데이터 흐름의 추적과 코드의 이해가 쉬워진다.
리액트는 대표적인 단방향 데이터 바인딩 라이브러리이므로 단방향 흐름을 정의하는 Flux 패턴과 매우 궁합이 좋다. 이데 따라 Flux 패턴을 따르는 다양한 라이브러리들이 많이 등장한다.
Flux, alt, RefluxJS, NuclearJS, Fluxible, Fluxxor
Redux의 등장
리덕스 또한 Flux 구조를 구현하기 위해 만들어진 라이브러리 중 하나이다. 다른 라이브러리와 다른 점은 Elm 아키텍처를 도입했다는 것이다.
Elm : 웹 페이지를 선언적으로 작성하기 위한 언어
module Main exposig(..)
import Browser
import Html exposing (Html,button,div,text)
import Html.Events exposing (onClick)
-- MAIN
main =
Browser.sandbox {init = init , update = update , view = view }
-- MODEL
type alias Model = Int
init : Model
init = 0
-- UPDATE
type Msg
= Increment
| Decrement
update : Msg -> Model -> Model
update msg mode =
cas msg of
Increment ->
model + 1
Decrement ->
model - 1
-- VIEW
view : Model -> Html Msg
view mode =
div[]
[ button [ onClick Decrement ] [ text "-" ]
, div[] [ text (String.fromInt model) ]
, button [ onClick Increment ] [ text " +" ]
<div>
<button>-<button>
<div>2</div>
<button>+<button>
</div>
Elm 코드 자체가 낮설게 느껴지지만, 주목해야 할 부분은 model, update, view가 있다는 것이며 이 세가지가 Elm 아키텍처의 핵심이다.
- 모델 : 애플리케이션의 상태
- 뷰 : 모델을 표현하는 HTML
- 업데이트 : 모델을 수정하는 방식
리덕스는 하나의 상태 객체를 소토어에 저장하고, 이 객체를 업데이트하는 작업을 디스패치해 업데이트를 수행한다. 이러한 작업은 reducer 함수로 발생시키며, 이 함수의 실행은 웹 애플리케이션 상태에 대한 완전히 새로운 복사본을 반환한 다음 애플리케이션에 상태를 전파한다.
이러한 리덕스의 등장은 리액트 생태계에 많은 영향을 미치게 됬으며, 하나의 글로벌 상태 객체를 통해 Prop Drilling을해결할 수 있으며, 스토어가 필요한 컴포넌트라면 connect만 사용해 스토어에 접근할 수 있다.
물론 단순히 상태를 바꾸는 작업에 액션 타입 설정, 액션 수행 함수, dispatch 등의 작업이 많이 있다. 그럼에도 불구하고 리덕스는 표준처럼 자리잡은 라이브러리이다.
Context API와 useContext
앞서 설명했듯이 상태를 어떻게 적절하게 주입해야 하는지에 대한 고민은 계속 이어져왔다. 부모 컴포넌트에서 자식 컴포넌트까지 끊임없이 컴포넌트 인수로 넘겨야 하는 불편함(Prop Drilling)이 있다.
리액트 팀은 리액트 16.3에서 전역 상태를 하위 컴포넌트에 주입할 수 있는 새로운 Context API를 출시했다. 16.3 이전에도 context는 존재했으며, 이를 다루기 위한 getChildContext()를 제공했었다.
import React from "react"
class MyComponent extends React.Component {
static childContextTypes = {
name : PropTypes.string,
age : PropTypes.number
}
getChildContext() {
return {
name : 'foo',
age : 30
}}
render() {
return <ChildComponent/>
}
}
function ChildComponent() {
return (
<div>
<p>Name : {context.name}</p>
<p>Age : {context.age</p>
</div>
)
}
ChildComponent.contextTypes = {
name : PropTypes.string,
age : PropTypes.number,
}
위의 방식에는 몇 가지 문제점이 있다. 첫 번째는 상위 컴포넌트가 렌더링되면 getChildContext도 호출됨과 동시에 shouldCoponentUpdate가 항상 true를 반환해 불필요한 렌더링이 일어난다. 두 번째는 context를 인수로 받아야 하는데, 이 때문에 컴포넌트와 결합도가 높아지는 단점이 있다. 두 가지 문제 외에도 다른 문제들이 있을 수 있다.
다음은 Context API를 사용해 하위 컴포넌트에 상태를 전달해보자.
import React, { Component, createContext } from "react";
type Counter = {
count: number;
};
const CounterContext = createContext<Counter | undefined>(undefined);
class CounterComponent extends Component {
render() {
return (
<CounterContext.Consumer>
{(state) => <p>{state?.count}</p>}
</CounterContext.Consumer>
);
}
}
class DummyParent extends Component {
render() {
return (
<>
<CounterComponent />
</>
);
}
}
export default class MyComponent extends Component<{}, Counter> {
state = { count: 0 };
componentDidMount() {
this.setState({ count: 1 });
}
handleClick = () => {
this.setState((state) => ({ count: state.count + 1 }));
};
render() {
return (
<CounterContext.Provider value={this.state}>
<button onClick={this.handleClick}>+</button>
<DummyParent />
</CounterContext.Provider>
);
}
}
부모 컴포넌트인 MyComponent에 상태가 선언되어 있지만 이를 Context로 주입한다. 그리고 Provider로 주입된 상태는 자식의 자식인 컴포넌트에서 사용하고 있음을 알 수 있다. 이는 원하는 값을 props로 번거롭게 넘겨주지 않아도 사용 가능해진 것이다.
하지만!!! Context API는 상태 관리가 아닌 주입을 도와주는 라이브러리이니 사용시 주의하자.
훅의 탄생, 그리고 React Query, SWR
Context API 릴리스가 얼마 되지 않아 리액트에서 함수형 컴포넌트에 사용할 수 있는 다양한 API가 추가되었다.
function useCounter (){
const [count,setCount] = useState(0)
function increase(){
setCount((prev) => prev + 1)
}
return {count,increase}
}
이러한 훅은 클래스형 컴포넌트보다 훨씬 간결하고 직관적인 방법이었다. 이런 Hook과 state의 등장으로 이전에는 볼 수 없던 방식의 상태 관리가 등장하는데, 바로 React Query와 SWR이다.
두 라이브러리는 모두 외부에서 데이터를 불러오는 fetch를 관리하는 데 특화된 라이브러리지만, HTTP 요청에 특화된 상태 관리 라이브러리라 볼 수 있다.
SWR을 사용한 코드를 살펴보자.
import React from "react";
const fetcher = (url) => fetch(url).then((res) => res.json());
const MyComponent = () => {
const { data, error } = useSWR(
"https://api.github.com/repos/vercel/swr",
fetcher
);
if (error) return "An error has occurred";
if (!data) return "Loading...";
return (
<div>
<p>{JSON.stringify(data)}</p>
</div>
);
};
export default MyComponent;
Recoil, Zustand, Jotai, Valtio...
SWR과 React Query가 HTTP 요청에 대해 중점적으로 사용한다면, 좀 더 범용적으로 사용할 수 있는 상태 관리 라이브러리엔 어떤 변화가 있었을까?
훅이라는 새로운 패러다임의 등장에 따라 페이스 북 팀에서 만든 Recoil을 필두로 다양한 라이브러리가 등장했다.
//Recoil
const counter = atom({key : 'count' , default : 0})
const todoList = useRecoilValue(counter)
//Jotai
const countAtom = atom(0)
const [count,setCount] = useAtom(countAtom)
//Zustand
const useCounterStore = create((set) => ({
count : 0,
increase : () => set((state) => ({count : state.count + 1})),
}))
const count = useCounterStore((state) => state.count)
//Valitio
const state = proxy({count : 0})
const snap = useSnapshot(state)
state.count++
'React > 모던 리액트 Deep Dive' 카테고리의 다른 글
[React Deep Dive] 리액트 훅으로 시작하는 상태 관리 (0) | 2025.06.12 |
---|---|
[React Deep Dive] Next.js (1) | 2025.06.06 |
리액트와 서버사이드 (0) | 2025.06.03 |
서버 사이드 렌더링 (1) | 2025.04.30 |
사용자 정의 훅과 고차 컴포넌트 (0) | 2025.04.28 |