React
는 공식문서에서 사용자 인터페이스(UI
)를 만들기위한 자바스크립트 라이브러리라고 정의하고 있다. 공식문서의 정의로만 본다면 React
는 라이브러리 그 이상 그 이하도 아니지만, 오늘날 React
를 사용하여 웹 애플리케이션을 구축하는 모습에서 리액트는 거진 프레임워크와 같은 효용성을 보여주는 것 같기도 하다.
이전에 포스팅했던 프론트엔드 3대장 비교 글에서 리액트에 대한 간략한 소개를 한 적이 있다. 이때 또 다른 웹 프론트엔드 프레임워크인 Vue
와 비교했을때 상대적으로 리액트는 어렵다라는 평가가 많다는 언급을 한 적이 있다. 그러한 인식을 가지게 된 이유에는 여러가지 배경이 있겠지만, 가장 큰 지분을 가지고 있는 점은 상태관리와 관련된 부분이지 않을까 싶다. 물론 리액트뿐만 아니라 뷰와 앵귤러 모두 웹 애플리케이션을 구축하기 위해 사용할 수 있고, 따라서 내부적으로 관리해야할 상태는 어떤 식으로든 똑같이 존재한다. 직접 뷰와 앵귤러를 통한 개발환경 구축은 해보지 않았기 때문에 불확실한 정보일 수 있지만, 유독 리액트와 관련된 상태관리 라이브러리 결합이 어렵게 느껴지는 듯한 분위기가 있는 듯하다. 물론 리액트는 뷰와 앵귤러와는 달리 오직 단방향 데이터흐름만을 허용하며 진리의 원천(Source of Truth)
이라는 개념을 주장하기 때문에 다른 두 프레임워크와 접근 방법이 살짝 다르긴 하겠지만.
따라서 이번 포스팅에서는 리액트 상태관리의 필요성과 서드 파티 형태로 상태관리를 도와주는 여러 라이브러리들을 살펴보고자 한다. 글을 작성하는 본인 역시 모든 라이브러리들을 직접 사용해보지 못했고, 사용해 본 라이브러리가 있다 하더라도 아주 깊이 있게 사용해보지 않았다고 생각하기 때문에 각 라이브러리들의 특징과 소개 위주로 진행해볼 것이다.
현대 웹 개발의 흐름은 단순한 웹 페이지를 넘어 웹 애플리케이션을 구축하는 방향으로 전환한지 오래되었다. 이제 브라우저는 단순히 정적인 페이지를 렌더링하는 도구가 아니라, 하나의 애플리케이션의 운영을 돕는 아주 작은 규모의 OS
라고 보아도 무방할 지경이다.
웹 애플리케이션이라는 의미는 여러가지가 있을 수 있다. 사용자와의 활발한 인터랙션을 모두 처리할 수 있으며, 실시간 통신을 통해 데이터를 받아오고 이를 기반으로 렌더링 하는 등 다양한 기능을 포함하고 있다. 그렇지만 기반이 되는 핵심은 애플리케이션 수준에서는 자체적으로 상태를 관리해야하는 역할이 필수적으로 요구된다는 점이다. 때문에 오늘날 웹 애플리케이션 환경에서 상태를 다루는 것은 자연스러운 요구사항이 되었고, 이를 도와줄 수 있는 많은 기술이 라이브러리 형태로 등장하게 되었다.
아마 리액트를 처음 배우거나, 공식문서를 읽다 보면 어느 순간 상태 관리를 위한 라이브러리로 리덕스(Redux
)라는 기술이 소개되는 것을 접해보았을 것이다. 리덕스는 리액트와 가장 오랜기간을 함께하고 또 가장 유명한 상태관리 라이브러리 중에 하나이다. 그리고 리덕스를 기반으로 둔 수많은 추상화 된 라이브러리가 존재하고, 오늘날에는 리덕스와는 다른 철학을 가지고 있는 몹엑스(MobX
), 리코일(Recoil
) 등 또 다른 형태의 라이브러리가 등장했다. 이처럼 상태관리라는 것은 애플리케이션 규모에서는 외면할 수 없는 주제이고, 이를 지원하기 위한 노력은 항상 라이브러리 형태이든 또는 프레임워크 자체적으로 구현하든 항상 있어 왔다는 것을 알 수 있다.
일단은 상태 관리 라이브러리들을 하나 하나 살펴보기 전에 왜 상태관리가 필요하고, 라이브러리가 없는 상태에서 리액트 자체적으로 상태를 어떻게 관리하는지를 먼저 살펴본 후 서드 파티 라이브러리들을 살펴보는 서순으로 글을 진행해보려 한다.
상태관리가 왜 필요한지 알아보기 이전에 상태라는 것이 무엇인지 먼저 짚고 넘어갈 필요가 있다. 상태의 사전적 정의에 따르자면 무수히 많은 요소가 상태로 간주될 수 있겠지만, 오늘날 웹 프론트엔드 개발에 있어서 상태라는 것은 크게 보면 웹 애플리케이션을 렌더(render)하는데 있어 영향을 미칠 수 있는 값으로 말할 수 있겠다. 이는 지극히 개인적인 의견이 아니라, 리액트의 공식문서에서도 다음과 같이 상태를 정의하고 있다. (참고)
Plain Javascript Object hold information influences the output of render
물론 상태에 대한 절대적인 정의는 아니다. 대표적인 상태관리 중에 하나인 Redux
의 스토어에는 이러한 개념과 일치하지 않는 값들도 일부 관리하고 있을 수 있다. 하지만 본 포스트에서는 상태에 대한 개념을 위와 같이 정립한 채 글을 전개할 예정이다.
이처럼 상태가 렌더에 영향을 미치는 일련의 값이라는 것에 초점을 맞추면 리액트 자체에서 우리는 이미 상태를 관리하고 그에 따른 렌더링을 조절하고 있는 것을 파악할 수 있다. 16.8 버전부터 등장한 리액트 훅스의 useState
가 바로 그 단적인 예이다.
import { useState } from 'react';
function Example() {
const [count, setCount] = useState(0);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count+1)}>
click me
</button>
</div>
);
}
훅스를 이용한 카운터 예제로 지겹게 등장하는 위 코드 예시를 살펴보자. useState
를 이용해서 초기값 0
을 가지고 있는 변수 count
와 이를 조정할 수 있는 setCount
함수를 반환하고 있다. 여기서 반환된 count
변수는 상태라고 할 수 있다. Example
이라는 컴포넌트가 브라우저에 렌더될 때 count
값을 반영하기 때문이다. 즉 count
는 버튼 엘리먼트의 클릭 여부에 따라 동적으로 변할 수 있는 값이고, 동적으로 변화된 값은 결국 브라우저 문서에 반영되어야 한다. 따라서 애플리케이션 렌더링에 영향을 주고 있는 값이라고 볼 수 있다. 이처럼 상태가 렌더링에 영향을 주기 위한 값으로 존재하기 위해서는 한 가지 요소가 더 필요한 것을 알 수 있다. 바로 동적인 값, 즉 변하는 값이라는 조건이다. 변하지 않는 값은 컴포넌트 외부 상수로 참조하거나 또는 useRef
훅을 이용하여 참조할 수 있다.
useRef
로 참조하는 값을 렌더링 시 출력하더라도 이를 상태로 보기는 어렵다. 왜냐하면 useRef
로 관리하는 변수는 값이 바뀐다고 해서 리-렌더링이 일어나지 않기 때문이다. 즉 애플리케이션 렌더링 흐름에 영향을 주는 동적인 값으로 간주될 수 없다. (애초에 권장되는 방식이 아니기도 하다)
다시 본론으로 돌아오면, 리액트는 그 자체로 상태 관리 라이브러리라고 말할 수 있을 정도로 상태 관리에 대한 여러가지 방법을 제공하고 있다. 앞서 살펴본 useState
는 용어 자체에도 사실 상태(state
)를 내포하고 있음을 볼 수 있다. 관리(management
)라는 용어의 개념을 너무 거창하게 보지 말자. 별도의 서드 파티 라이브러리로서 상태에 대한 흐름을 관리하는 것은 리액트 자체에서는 해결하기 어려운 경우에 대한 해법을 제공하거나 효율 및 최적화를 반영하기 위해서이다. 위 예시에서 useState
훅을 통해 상태와 이에 대한 setter
함수를 선언하고, setter
함수를 통한 조작만을 위임하는 것으로 리액트는 상태 변화에 따른 리-렌더링을 성공적으로 수행한다.
물론 다음과 같은 의문이 들 수 있을 것 같다.
"하나의 컴포넌트에서 상태를 관리하는 것은 매우 간단하다. 그런데 이런 상태를 여러 컴포넌트가 공통적으로 접근하고 공유해야 하는 경우는 어떡하는가?"
보통 이러한 의문에 도달했을 때 바로 상태 관리 라이브러리를 떠올리는 것 같다. 하지만 이 역시 라이브러리의 도움 없이 리액트 자체만으로 해결할 수 있는 방법을 제공하고 있다. 이는 훅스 등장 이전에도 공식문서에서 상태 끌어올리기(Lifting State Up)
라는 기법으로 소개되어 왔다. (참고)
이는 매우 간단한 기법이다. 여러 컴포넌트에 공유되어야 할 상태가 있다면, 이 컴포넌트들이 공통으로 가지고 있는 부모 컴포넌트에 상태를 선언하는 것을 말한다. 즉 앞서 들었던 Exapmle
컴포넌트 예시의 경우 자체적으로 count
상태를 선언하여 관리하고 있지만, 만약 Example2
컴포넌트와 이를 공유해야 하는 경우 두 개 컴포넌트가 공통으로 공유하는 상단의 부모 컴포넌트에 상태를 선언하는 것을 말한다.
function Example({count, handleCount}) {
return <button onClick={handleCount}>{count}</button>
}
function Example2({count}) {
return <div>current count: {count}</div>
}
function App() {
const [count, setCount] = useState(0);
const handleCount = () => setCount(prev => prev+1);
return (
<div>
<Example count={count} handleCount={handleCount} />
<Example2 count={count} />
</div>
);
}
본래 Example
컴포넌트에서 가지고 있던 count
상태의 책임을 두 컴포넌트가 공유하고 있는 부모 컴포넌트 App
으로 가져왔다. 계층 구조 상 App
컴포넌트가 두 자식 컴포넌트 상위에 존재하기 때문에 이를 상태 끌어올리기
라는 기법이라고 말한다. 이 기법을 이용한다면 여러 컴포넌트가 다 같이 접근해야 하는 상태에 있어서, 모두 공유하는 부모 컴포넌트 요소를 찾고 해당 계층에서 상태를 선언 후 props
를 통해 전달하는 방식으로 상태를 관리할 수 있음을 알 수 있다. 즉 하나의 컴포넌트에서 선언된 상태를 여러 컴포넌트가 동일하게 접근할 수 있음을 말한다.
이때 이 같은 구조를 경험해 보았다면 또 다시 아래와 같은 의문이 들 수 있다.
"위와 같은 구조는 상태를 계속
props
로 전달해야 하는데 이 경우 직접적으로 해당 상태를 사용하지 않는 컴포넌트에도 일일이props
전달이 필요하고 그에 따른 전달 계층 구조가 깊어지는 문제는 어떻게 해결하는가?"
위의 의문을 한 단어로 일축하자면 props drilling
이라는 용어로 정리할 수 있다. 이는 props
로 전달되는 상태값이 계속 하위로 내려가는 모양새를 비유적으로 표현한 것이라고 볼 수 있다. 이 문제 역시 이미 리액트 자체적으로 해결할 수 있는 방법을 제공한다.
첫 번째는 컴포넌트 합성(Composite
)를 이용하는 방식이다. 합성이 무엇인지는 공식문서를 참고하도록하자. 일단 다음과 같은 계층 구조가 있다고 가정해보자.
App ---> Header ---> Logo, Setting
App ---> Navbar ---> ...
App
은 최상단 계층이고 하위에 Header
컴포넌트와 Navbar
컴포넌트가 있으며, Header
컴포넌트는 내부에 로고와 세팅과 관련된 하위 컴포넌트를 가지고 있다. 이때 Header
와 Navbar
컴포넌트는 모두 동일한 상태 값에 접근해야 된다고 가정해보자. 따라서 상태 끌어올리기를 통해 App
컴포넌트에서 상태가 선언되고 그에 따라 다음과 같이 props
를 전달할 것이다.
function App() {
const [state, setState] = useState('state');
return (
<>
<Header state={state} setState={setState} />
<Navbar state={state} setState={setState} />
</>
);
}
그리고 이 상태값은 Header
컴포넌트 하위에 있는 로고와 세팅 컴포넌트에도 전달되어야 하기 때문에 다음과 같이 다시 props
로 전달되어야 한다.
function Header({state, setState}) {
return (
<>
<Logo state={state} />
<Setting setState={setState} />
</>
);
}
props drilling
은 이러한 점을 지적한다. 지금이야 계층 구조가 깊지 않아서 비교적 간단해 보이지만, 만약 계층 구조가 깊어질 수록 이러한 과정은 번거롭게 느껴질 수 있다. 이는 아래와 같이 컴포넌트 합성으로 과정을 일축할 수 있다.
function App() {
const [state, setState] = useState('state');
return (
<>
<Header
logo={<Logo state={state} />}
setting={<Setting setState={setState} />}
/>
...
</>
);
}
컴포넌트 합성은 props
로 전달될 수 있는 값에 대해 제한이 없다는 것을 이용한 것이다. 기존에 방식에선 props
로 상태와 이에 대한 setter
를 직접 넘겨주었다면, 합성 방식에서는 props
로 아예 컴포넌트를 넘겨주는 방식이다. 따라서 Header
컴포넌트에서는 내부적으로 다시 props
를 동일하게 건네주어야 하는 과정을 건너뛸 수 있다.
그렇지만 합성 방식 역시 깊어지는 계층 간의 구조에서는 여전히 복잡할 수 있다. 리액트는 여기서 그치지 않고 Context API
라는 것을 제공한다. Context
에 대한 공식문서 설명을 참고하면 다음과 같이 말하고 있다.
context
를 이용하면 단계마다 일일이props
를 넘겨주지 않고도 텀포넌트 트리 전체에 데이터를 제공할 수 있다.
앞서 언급된 문제를 딱 겨냥하고 있음을 알 수 있다. 즉 Context API
를 사용하면 props drilling
문제를 해결할 수 있다. 위의 예시를 Context API
를 이용하여 다시 리팩토링 해보자.
// context.jsx
import {
createContext,
useState,
useMemo,
useContext }
from 'react';
const StateContext = createContext();
export function StateProvider(props) {
const [state, setState] = useState(0);
const value = useMemo(() => [state, setState], [state]);
return <StateContext.Provider value={value} {...props} />
}
export function useContextState() {
const context = useContext(StateContext);
if (!context) {
throw new Error('Error: useContextState는 StateProvider 내부에서 선언되어야 합니다!');
return context;
}
// App.jsx
import { StateProvider, useContextState } from './context';
function App() {
return (
<StateProvider>
<Header />
<Navbar />
</StateProvider>
);
}
function Header() {
return (
<>
<Logo />
<Setting />
</>
);
}
function Logo() {
const [state] = useContextState();
return <div>{state}</div>
}
function Setting() {
const [_, setState] = useContextState();
return
<button onClick={() => setState(prev => prev + 1)}>
click
</button>
}
위 예시를 보면 Logo
와 Setting
컴포넌트에서 직접 사용할 상태에 접근하고 있고, 그 밖에 다른 컴포넌트는 별도로 이를 props
로 전달하고 있지 않음을 볼 수 있다.
Context API
는 리액트 16.3 버전에서 지금과 같은 기능으로 도입이 되었다. (그 이전에도 존재했으나 지원이 지금과 동일하지는 않았다고 한다) Context API
의 도입으로 리액트에서 전역적인 글로벌 상태관리가 더욱 용이하게 되었다. 리덕스는 16.3 버전 이전부터 존재했기에 이러한 문제를 조금 더 용이하게 풀기 위해서 예전에는 이 시점에서 리덕스 도입이 장려되긴 했지만, 오늘날 이러한 기능이 리액트 자체만으로도 해결할 수 있다는 것은 굉장히 멋진 일이다.
지금까지의 내용을 정리해보자면,
useState
, useReducer
, Context API
등등)위와 같이 크게 정리해 볼 수 있겠다. 앞으로 이어질 상태관리를 주제로 한 포스팅은 3번에 대해 중점적으로 이야기 해 볼 것이다. 그 전초로 상태란 무엇이고, 별도의 라이브러리 없이 리액트 자체만으로 상태를 관리할 수 있는 기법을 살펴보았다. 다음 이야기를 본격적으로 풀기 전에, 왜 상태관리 라이브러리가 필요한지에 대해 가볍게 짚고 넘어가보고자 한다.
앞서 줄곧 살펴본 내용은 리액트 자체만으로도 별도 상태관리 라이브러리 도입 없이 상태를 관리할 수 있음을 피력하고 있다. 그럼에도 불구하고 상태관리 라이브러리가 필요한 시점이 항상 찾아오게 된다. 이는 전에 이야기 한 바와 같이 리액트 자체만으로는 해결하기 어려운 솔루션을 제공하기 때문이라고 볼 수 있다.
먼저 상태관리가 복잡해지기 시작한 시점을 떠올려보자. 이는 여러 컴포넌트가 공통으로 사용할 상태를 서로 공유해야 할 시점에서 복잡한 구조와 계층이 생성되기 시작함을 알 수 있다. 즉 전역으로 다루어야 할 상태가 생겼을 때가 라이브러리 도입에 대한 고민에 대한 전초가 될 수 있을 것이다.
이는 리액트에서 Context API
가 도입됨에 따라 어느정도 해소가 되긴 했다. 그렇지만 Context API
는 성능적인 이슈가 아직 존재한다. 값에 변화가 발생했을 때 Context
를 구독하고 있는 모든 컴포넌트들이 전체적으로 모두 리-렌더링이 발생한다. 따라서 반복적이고 복잡한 업데이트와 관련된 부분에서는 비효율적일 수 있다. 반면 리덕스의 경우에는 자체적으로 리-렌더링과 관련된 부분에 최적화가 적용되어 있기 때문에, 부분적인 리-렌더링이 발생한다. 또한 리덕스를 사용한다면 Context API
에선 제공할 수 없는 여러 다양한 기능들을 미들웨어를 사용해 관리할 수 있는 장점 또한 존재한다.
물론 이러한 성능적인 이유는 useReducer
를 통한 변경 흐름 조절이나 메모이제이션을 활용, 또는 논리적 관점에서 Provider
를 여러 개로 분리하고, 가능한 그 상태를 필요로 하는 곳 근처에 두는 등 자체적인 최적화가 가능하지만 이러한 과정이 다소 번거롭게 작용할 수 있다.
이러한 성능적인 이슈를 고려해 자체적으로 최적화를 적용한
useSelectedContext
훅을 리액트 팀에서 제공할 예정이라고 공표한 바 있지만 아직 릴리즈되지는 않았다.
리액트 자체만으로 상태를 관리하는 것은 굉장히 멋진 일이다. 외부 라이브러리에 의존하지 않고, 리액트 생명주기 내에서 그 흐름을 통제할 수 있기 때문이다. 그렇지만 실무 레벨에서는 상태 관리 흐름을 리액트만으로 모두 해결하기엔 한계가 존재하기 마련이다. 따라서 적절한 상태관리 라이브러리의 도입은 개발 생산성을 높여주고 동시에 상태 관리와 관련된 여러 편의성을 가져다 줄 수 있다.
다만 무조건적으로 전역 상태 관리의 필요성을 느낀 시점에서 외부 라이브러리에 의존하는 것을 지양할 필요가 있다. 어떤 상태 관리 라이브러리를 사용할 지에 대한 고민을 시작하기 전에, 과연 상태 관리 라이브러리가 어떠한 근거로 필요한 지에 대해 한 번 생각을 하고 도입하는 것이 좋지 않을까 생각한다.
마지막으로 코리 하우스(Cory House)의 트위터 글을 인용하고 1부 포스팅을 마무리 하고자 한다. 다음 내용은 상태를 다시 세분화하고, 세분화 된 상태에 따라 어떤 관점과 철학으로 상태 관리 라이브러리들이 있는지 소개해보고자 한다.
Realization: Putting Redux in our company framework by default was a mistake.
Result:
1. People connect *every* component.
2. People embed Redux in "reusable" components.
3. Everyone uses Redux. Even when they don't need it.
4. People don't know how to build an app with just React.
-- Cory House February 11.2018
상태 관련해서 애매하게 개념을 갖고 있었던 것 같습니다 정리된 글을 보니 제대로 개념이 확립된 기분이나 좋네요 :) 좋은 글 공유 감사합니다~