이번 장에서는 리액트 애플리케이션을 개발할 때 빠지지 않고 언급되는 상태 관리 라이브러리에 대해 알아 본다. 흔히 리액트의 상태 관리라고 한다면 오래전부터 쓰인 리덕스 또는 최근에 페이스북에서 만들어진 Recoil을 떠올리곤 한다. 그리고 많은 개발자들이 리액트 애플리케이션에 자신이 익숙한 상태 관리 라이브러리를 설치하는 것을 익숙해하지만 정작 왜 상태 관리가 필요한지, 또 이 상태 관리가 어떻게 리액트와 함께 작동하는지는 간과하는 경우가 많다. 이번 장에서는 상태 관리 라이브러리의 필요성부터 최근 많이 주목받고 있는 상태 관리 라이브러리가 어떻게 작동하는지 살펴본다.
5.1 상태 관리는 왜 필요한가?
상태 관리에 대해 이야기하기에 앞서 이제 앞으로 계속해서 이야기할 '상태'가 무엇인지 정의할 필요가 있다. 흔히 엡 애플리케이션을 개발할 때 이야기하는 상태는 어떠한 의미를 지닌 값이며 애플리케이션의 시나리오에 따라 지속적으로 변경될 수 있는 값을 의미한다. 웹 애플리케이션에서 상태로 분류될 수 있는 것들은 대표적으로 다음과 같은 것이 있다.
UI : 기본적으로 웹 애플리케이션에서 상태라 함은 상호 작용이 가능한 모든 요소의 현재 값을 의미한다. 다크/라이트 모드, 라디오를 비롯한 각종 input, 알림창의 노출 여부 등 많은 종류의 상태가 존재한다.
URL : 브라우저에서 관리되고 있는 상태값으로, 여기에도 우리가 참고할 만한 상태가 존재할 수 있다. https://www.airbnb.co.kr/rooms/34113796?adults=2와 같은 주소가 있다고 가정해보자. 이 주소에는 roomId=34113796과 adults=2라고 하는 상태가 존재하며 이 상태는 사용자의 라우팅에 따라 변경된다.
폼(form) : 폼에도 상태가 존재한다. 로딩 중인지(loading), 현재 제출됐는지(submit), 접근이 불가능한지(disabled), 값이 유효한지(validation) 등 모두가 상태로 관리된다.
서버에서 가져온 값 : 클라이언트에서 서버로 요청을 통해 가져온 값도 상태로 볼 수 있다. 대표적으로 API 요청이 있다.
웹 서비스에서 점차 다양한 기능이 제공됨에 따라 웹 내부에서 관리해야 할 상태도 점차 비례해서 증가하고 있다. 단순히 서버에서 요청받은 내용을 보여주기만 하던 시대에는 상태라고 구분 지을 만한 요소들이 별로 없었지만 이제는 점차 증가하는 상태를 효과적으로 관리하는 방법을 계속해서 고민해야 하는 시대가 도래한 것이다.
하지만 단순히 '상태를 관리한다'라는 명제를 놓고 살펴본다면 상태 관리 자체는 크게 어려운 일이 아니며 단순히 손이 많이 가는 문제일 수도 있다. 폼에서의 상태 관리를 생각해 보자. 폼이 유효한지, 제출이 완료됐는지, 로딩 중인지는 폼을 둘러싼 요소들만 알고 그에 따른 대처가 돼 있다면 그것이 바로 폼 상태 관리다. 어떠한 알림 창(modal)이 있고, 이것이 뜰지 말지를 API 응답에 따라 관리한다면 그 또한 상태 관리일 것이다.
하지만 애플리케이션 전체적으로 관리해야 할 상태가 있다고 가정해 보자. 그리고 그 상태에 따라 다양한 요소들이 각 상태에 맞는 UI를 보여줘야 한다. 상태를 어디에 둘 것인가? 전역 변수에 둘 것인가? 별도의 클로저를 만들 것인가? 그렇다면 그 상태가 유효한 범위는 어떻게 제한할 수 있을까? 상태의 변화에 따라 변경돼야 하는 자식 요소들은 어떻게 이 상태의 변화를 감지할 것인가? 이러한 상태 변화가 일어남에 따라 즉각적으로 모든 요소들이 변경되어 애플리케이션이 찢어지는 현상(이를 tearing이라고 하며, 하나의 상태에 따라 서로 다른 결과물을 사용자에게 보여주는 현상을 말한다)을 어떻게 방지할 것인가?
이처럼 현대 웹 애플리케이션에서 상태 관리란 어렵다고 해서 외면할 수 없는 주제가 됐다. 이러한 상태를 효율적으로 관리하고, 상태가 필요한 쪽에서는 빠르게 반응할 수 있는 모델에 대한 고민이 본격적으로 시작된 것이다.
5.1.1 리액트 상태 관리의 역사
이제 리액트 이야기를 해 보자. 다른 웹 개발 환경과 마찬가지로 리액트도 상태 관리에 대한 필요성이 존재했다. 애플리케이션 개발에 모든 것을 제공하는, 이른바 프레임워크를 지향하는 Angular와는 다르게 리액트는 단순히 사용자 인터페이스를 만들기 위한 라이브러리일 뿐이고, 그 이상의 기능을 제공하지 않고 있다. 따라서 상태를 관리하는 방법도 개발자에 따라, 시간에 따라 많은 차이가 있다. 리액트 생태계에서 개발자들이 상태 관리를 하기 위해 어떠한 방법을 활용했는지 그 역사를 살펴보자.
Flux 패턴의 등장
리액트에서는 상태 관리, 특히 전역 상태 관리를 어떻게 했을까? 우리가 순수 리액트에서 할 수 있는 전역 상태 관리 수단이라고 한다면 Context API를 떠올릴 것이다.(엄밀히 이야기하면 리액트 Context API는 상태 관리가 아니라 상태 주입을 도와주는 역할이다. 이에 대해서는 이후에 다룬다.) 그러나 리액트에서 Context API가 선보인 것은 16.3 버전이었고, useContext를 선보인 것은 16.8 버전이었다. 그 전까지는, 리덕스가 나타나기 전까지 리액트 애플리케이션에서 딱히 이름을 널리 알린 상태 관리 라이브러리는 없었다.
그러던 중 2014년경, 리액트의 등장과 비슷한 시기에 Flux 패턴과 함께 이를 기반으로 한 라이브러리인 Flux를 소개하게 된다. Flux에 대해 소개하기에 앞서 먼저 이 당시 웹 개발 상황을 짚고 넘어가자. 웹 애플리케이션이 비대해지고 상태(데이터)도 많아짐에 따라 어디서 어떤 일이 일어나서 이 상태가 변했는지 등을 추적하고 이해하기가 매우 어려운 상황이었다.
기존 MVC 패턴은 모델과 뷰가 많아질수록 복잡도가 증가한다.
페이스북 팀은 이러한 문제의 원인을 양방향 데이터 바인딩으로 봤다. 뷰(HTML)가 모델(자바스크립트)을 변경할 수 있으며, 반대의 경우 모델도 뷰를 변경할 수 있다. 이는 코드를 작성하는 입장에서는 간단할 수 있지만 코드의 양이 많아지고 변경 시나리오가 복잡해질수록 관리가 어려워진다. 페이스북 팀은 양방향이 아닌 단방향으로 데이터 흐름을 변경하는 것을 제안하는데 이것이 바로 Flux 패턴이 시작이다.
Flux의 기본적인 단방향 데이터 흐름
먼저 각 용어의 정의를 살펴보자
액션(action) : 어떠한 작업을 처리할 액션과 그 액션 발생 시 함께 포함시킬 데이터를 의미한다. 액션 타입과 데이터를 각각 정의해 이를 디스패처로 보낸다.
디스패처(dispatcher) : 액션을 스토어에 보내는 역할을 한다. 콜백 함수 형태로 앞서 액션이 정의한 타일과 데이터를 모두 스토어에 보낸다.
스토어(store) : 여기에서 실제 상태에 따른 값과 상태를 변경할 수 있는 메서드를 가지고 있다. 액션의 타입에 따라 어떻게 이를 변경할지가 정의돼 있다.
뷰(view) : 리액트의 컴포넌트에 해당하는 부분으로, 스토어에서 만들어진 데이터를 가져와 화면을 렌더링하는 역할을 한다. 또한 뷰에서도 사용자의 입력이나 행위에 따라 상태를 업데이트하고자 할 수 있을 것이다. 이 경우에는 다음 그림처럼 뷰에서 액션을 호출하는 구조로 구성된다.
먼저 type action으로 액션이 어떤 종류가 있고 어떤 데이터를 필요로 하는지 정의해 뒀다. 그리고 스토어의 역할을 하는 것이 useReducer와 reducer인데, 각각 현재 상태와 상태에 따른 값이 어떻게 변경되는지를 정의했다. 그리고 dispatcher로 이 액션을 실행했고, 이를 뷰인 App에서 보여준다.
이러한 단방향 데이터 흐름 방식은 당연히 불편함도 존재한다. 사용자의 입력에 따라(여기에서는 사용자의 클릭에 따라) 데이터를 갱신하고 화면을 어떻게 업데이트 해야 하는지도 코드로 작성해야 하므로 코드의 양이 많아지고 개발자도 수고로워진다. 그러나 데이터의 흐름은 모두 액션이라는 한 방향(단방향)으로 줄어듦으로 데이터의 흐름을 추적하기 쉽고 코드를 이해하기가 한결 수월해진다.
리액트는 대표적인 단방향 데이터 바인딩을 기반으로 한 라이브러리였으므로 이러한 단방향 흐름을 정의하는 Flux 패턴과 매우 궁합이 잘 맞았다. 그리고 이와 동시에 이러한 Flux 패턴을 따르는 다양한 라이브러리들이 우후죽순 처럼 등장하기 시작했다. 대표적인 라이브러리로는 다음과 같은 것이 있다.
상태와 그 상태의 변경에 대한 흐름과 방식을 단방향으로 채택한 것이 바로 리액트 기반 Flux의 특징이라고 볼 수 있다.
시장 지배자 리덕스의 등장
리액트와 단방향 데이터 흐름이 점점 두각을 드러내던 와중에 등장한 것이 바로 리덕스(redux)다. 리덕스 또한 최초에는 이 Flux 구조를 구현하기 위해 만들어진 라이브러리 중 하나였다. 이에 한 가지 더 특별한 것은 여기에 Elm 아키텍처를 이해하려면 먼저 Elm이 무엇인지 알아야 한다. Elm은 웹페이지를 선언적으로 작성하기 위한 언어다. 다음 예제는 Elm으로 HTML을 작성한 예제다.
module Main exposing (..)
import Brower
import Html exposing (Html, button, div, text)
import Html.Events exposing (onClick)
--MAIN
main =
Brower.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 model =
case msg of
Increment ->
model + 1
Decrement ->
model - 1
-- VIEW
view: Model -> Html Msg
view model =
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 아키텍처의 핵심이다.
모델(model) : 애플리케이션의 상태를 의미한다. 여기서는 Model을 의미하며, 초기값으로는 0이 주어졌다.
뷰(view) : 모델을 표현하는 HTML을 말한다. 여기서는 Model을 인수로 받아서 HTML을 표현한다.
업데이트(update) : 모델을 수정하는 방식을 말한다. Increment, Decrement를 선언해 각각의 방식이 어떻게 모델을 수정하는지 나타났다.
즉, Elm은 Flux와 마찬가지로 데이터 흐름을 세 가지고 분류하고, 이를 단방향으로 강제해 웹 애플리케이션의 상태를 안정적으로 관리하고자 노력했다. 그리고 리덕스는 이 Elm 아키텍처의 영향을 받아 작성했다.
리덕스는 하나의 상태 객체를 스토어에 저장해 두고, 이 객체를 업데이트하는 작업을 디스패치해 업데이트를 수행한다. 이러한 작업은 reducer 함수로 발생시킬 수 있는데, 이 함수의 실행은 웹 애플리케이션 상태에 대한 완전히 새로운 복사본을 반환한 다음, 애플리케이션에 이 새롭게 만들어진 상태를 전파하게 된다.
이러한 리덕스의 등장은 리액트 생태계에 많은 영향을 미치게 됐다. 하나의 글로벌 상태 객체를 통해 이 상태를 하위 컴포넌트에 전파할 수 있기 때문에 props를 깊이 전파해야 하는 이른바 prop 내려주기 문제를 해결할 수 있었고, 스토어가 필요한 컴포넌트라면 단지 connect만 쓰면 스토어에 바로 접근할 수 있었다. Context API가 등장하기 전까지, 아니 지금까지도 리덕스는 리액트 상태 관리에서 빼놓고 이야기할 수 없는 중요한 축으로 자리 잡았다.
그렇다고 리덕스가 마냥 편하기만 한 것은 아니었다. 단순히 하나의 상태를 바꾸고 싶어도 해야 할 일이 너무 많았다. 먼저 어떠한 액션인지 타입을 선언해야 하고, 이 액션을 수행할 creator, 함수를 만들어야 한다. 그리고 dispatcher와 selector도 필요하고, 새로운 상태가 어떻게 기존의 리듀서 내부에서 어떤 식으로 변경돼야 할지, 혹은 새로 만들어야 할지도 새로 정의해야 했다. 한마디로, 하고자 하는 일에 비래 보일러플레이트가 너무 많다는 비판의 목소리가 있었다, 이는 리덕스가 처음 등장했을 때 받던 비판으로, 지금은 이러한 작업이 많이 간소화됐다.
그럼에도 리액트와 리덕스는 일종의 표준처럼 굳어졌다. 리덕스가 전역 상태 관리에 많은 편리함을 제공하기도 했고, 또 뚜렷한 대안이 존재하는 것 또한 아니었다. 지금까지도 리액트와 리덕스를 하나의 세트로 생각하는 개발자들이 있을 정도로 거의 표준으로 자리 잡히다시피 했다.
Context API와 useContext
리액트가 처음 세상에 나온 뒤에도 상태를 어떻게 적절하게 주입해야 하는지에 대한 고민은 계속 이어져왔다. 부모에 있는 상태를 자식 컴포넌트에서 쓰기 위해서는 이른바 prop 내려주기라 불리는 방식, 즉 props를 가지고 있는 부모에서 필요한 자식 컴포넌트까지 끊임없이 컴포넌트의 인수로 넘겨야 하는 불편함이 있었다. 단순한 레벨의 컴포넌트라면 한두 단계쯤 넘기는 것은 괜찮았지만 자식 컴포넌트의 깊이가 깊어질수록 말 그대로 props가 컴포넌트를 관통해 버리는 현상이 발생했다. 물론 이 당시에도 리덕스가 있었지만 단순히 상태를 참조하고 싶을 뿐인데 준비해야 하는 보일러플레이트도 부담스러웠다. 이는 번거로울 뿐만 아니라 컴포넌트를 설계할 때 커다란 제약으로 작용했다.
리액트 팀은 리액트 16.3에서 전역 상태를 하위 컴포넌트에 주입할 수 있는 새로운 Context API를 출시했다. props로 상태를 넘겨주지 않더라도 Context API를 사용하면 원하는 곳에서 Context Provider가 주입하는 상태를 사용할 수 있게 된 것이다.
리액트 16.3 버전 이전에도 context가 존재했으며, 이를 다루기 위한 getChildCotext()를 제공했었다. 다음 예제를 보자.
그러나 이 방식에는 몇 가지 문제점이 있었는데, 첫 번째로 상위 컴포넌트가 렌더링되면 getChildContext도 호출됨과 동시에 shoiuldComponentUpdate가 항상 true를 반환해 불필요하게 렌더링이 일어난다는 점, geChildContext를 사용하기 위해서는 context를 인수로 받아야 하는데 이 때문에 컴포넌트와 결합도가 높아지는 등의 단점이 있었다. 이러한 단점을 해결하기 위해 16.3 버전에서 새로운 context가 출시됐다.
이 코드를 보면 부모 컴포넌트인 MyApp에 상태가 선언돼 있고, 이를 Context로 주입하고 있는 것을 볼 수 있다. 그리고 Provider로 주입된 상태는 자식의 자식인(MyApp 밑 DummyParent 밑 CounterComponent) CounterComponent에서 사용하고 있음을 알 수 있다. 앞서 언급한 prop 내려주기, 즉 원하는 값을 props로 번거롭게 넘겨주지 않아도 사용 가능해진 것이다.
그러나 Context API는 상태 관리가 아닌 주입을 도와주는 기능이며, 렌더링을 막아주는 기능 또한 존재하지 않으니 사용할 때 주의가 필요하다.
훅의 탄생, 그리고 React Query와 SWR
Context API가 선보인 지 1년이 채 되지 않아 리액트는 16.8 버전에서 함수 컴포넌트에 사용할 수 있는 다양한 훅 API를 추가했다. 이 훅 API는 기존에 무상태 컴포넌트를 선언하기 이ㅜ해서만 제한적으로 사용됐던 함수 컴포넌트가 클래스 컴포넌트 이상의 인기를 구가할 수 있도록 많은 기능을 제공했다. 이 가운데 가장 큰 변경점 중 하나로 꼽을 수 있는 것은 state를 매우 손쉽게 재사용 가능하도록 만들 수 있다는 것이다. 다음 코드를 보자.
useCounter는 단순히 count state와 이를 1씩 올려주는 increase로만 구성돼 있지만 내부적으로 관리하고 있는 state도 있으며, 또 이를 필요한 곳에서 재사용할 수도 있게 됐다. 이는 클래스 컴포넌트보다 훨씬 간결하고 직관적인 방법이었으며, 리액트 개발자들은 앞다투어 자신만의 훅을 만들어내기 시작했다.
이러한 훅과 state의 등장으로 이전에는 볼 수 없던 방식의 상태 관리가 등장하는데 바로 React Query와 SWR이다.
두 라이브러리는 모두 외부에서 데이터를 불러오는 fetch를 관리하는 데 특화된 라이브러리지만, API 호출에 대한 상태를 관리하고 있기 때문에 HTTP 요청에 특화된 상태 관리 라이브러리라 볼 수 있다. SWR을 사용한 코드를 살펴보자.
import React from 'react'
import useSwR from 'swr'
const fetcher = (url) => fetch(url).then((res)=> res.json())
export default function App() {
const { data, error } = useSMR(
"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)}K/p>
</div>
)
}
useSWR의 첫 번재 인수로 조회할 API 주소를, 두 번째 인수로 조회에 사용되는 fetch를 넘겨준다. 첫 번째 인수인 API 주소는 키로도 사용되며, 이후에 다른 곳에서 동일한 키로 호출되면 재조회하는 것이 아니라 useSWR이 관리하고 있는 캐시의 값을 활용한다.기존에 우리가 알고 있는 상태 관리 라이브러리보다는 제한적인 목적으로, 일반적인 형태와는 다르다는 점만 제외하면 분명히 SWR이나 React Query도 상태 관리 라이브러리의 일종이라 볼 수 있다. 실제로 애플리케이션에서 이 두 라이브러리를 사용해 보면 생각보다 애플리케이션의 많은 부분에서 상태를 관리하는 코드가 사라진다는 것을 알 수 있다.
Recoil, Zustand, Jotai, Valtio에 이르기까지
SWR과 React Query가 HTTP 요청에 대해서만 쓸 수 있다면 좀 더 범용적으로 쓸 수 있는 상태 관리 라이브러리엔 어떤 변화가 있었을까?
훅이라는 새로운 패러다임의 등장에 따라, 훅을 활용해 상태를 가져오거나 관리할 수 있는 다양한 라이브러리가 등장하게 된다. 페이스북 팀에서 만든 Recoil을 필두로 Jotai, Zustand, Valito 등 다양한 라이브러리가 선보이게 된다.
요즘 새롭게 떠오르고 있는 많은 상태 관리 라이브러리는 기존의 리덕스 같은 라이브러리와는 차이점이 있는데, 바로 훅을 활용해 작은 크기의 상태를 효율적으로 관리한다는 것이다. Recoil, Jotai, Zustand, Valito의 저장소를 방문해 보면 모두 peerDependencies로 리액트 16.8 이상을 요구하고 있음을 확인할 수 있다. 물론 리덕스나 MobX도 react-redux나 mobx-react-lite 등을 설치하면 동일하게 훅으로 상태를 가져올 수 있지만 위 라이브러리는 애초에 리액트와의 연동을 전제로 작동해 별도로 다른 라이브러리를 설치하지 않아도 된다는 차이점이 있다.
이는 기존 상태 관리 라이브러리의 아쉬운 점으로 지적받던 전역 상태 관리 패러다임에서 벗어나 개발자가 원하는 만큼의 상탤를 지역적으로 관리하는 것을 가능하게 만들었고, 훅을 지원함으로써 함수 컴포넌트에서 손쉽게 사용할 수 있다는 장점 또한 가지고 있다.
5.1.2 정리
지금까지 상태 관리 라이브러리가 무엇이고, 리액트가 태동한 이래로 어떠한 상태 관리 라이브러리가 있는지 살펴봤다. 리액트를 쓴다면 당연히 리덕스를 쓰던 시절부터 다양한 상태 관리 라이브러리들이 등장한 지금까지, 리액트 생태계에서는 상태 관리에 대한 고민과 솔루션이 끊임없이 나오고 있따. 너무 많은 선택지로 인해 실제로 애플리케이션을 작성할 때 무엇을 선택해야 할지 고민스러울 수도 있지만 하나의 이슈에 대해 여러가지 해결책이 나온다는 것은 그만큼 이 분야가 건강하게 성장하고 있다는 증거이기도 하다. 일정이나 여러가지 제반 환경이 여의치 않다면 익숙한 것을 선택하는 것도 나쁘지 않지만 다양한 옵션을 살펴보고 비교하면서 어떤 식으로 구현하고 있는지 살펴보는 것도 많은 도움이 될 것이다. 다음 절에서는 상태 관리 라이브러리가 어떻게 만들어지는지 살펴보고, 과연 어떤 라이브러리를 선택하는 게 좋은 지, 올바른 선택의 기준은 무엇인지 등을 살펴보자.