들어가며
이번에는 상태 관리입니다.
Flux에서 시작해 Redux, Context API, 훅 기반 라이브러리까지의 흐름을 정리하고, 마지막 번외에서는 조금 다른 이야기를 합니다. “리렌더링을 막는 지금의 방식이 근본적인 해결인가” 라는 질문입니다.
상태란 무엇인가
상태는 애플리케이션의 시나리오에 따라 지속적으로 변경될 수 있는 값입니다.
웹 애플리케이션이 복잡해질수록 이 값을 어떻게 관리하고 공유할지가 중요한 문제가 됩니다. 상태로 분류될 수 있는 것들을 나눠보면 성격이 꽤 다릅니다.
| 종류 | 예시 |
|---|---|
| UI 상태 | 다크/라이트 모드, 알림창 노출 여부, 입력 값 |
| URL 상태 | 라우트 파라미터, 쿼리 스트링 등 브라우저가 관리하는 값 |
| 폼 상태 | 로딩, 제출, 비활성화, 유효성 |
| 서버 상태 | API 요청을 통해 서버에서 가져온 데이터 |
이걸 굳이 나눠본 이유가 있습니다. 네 가지를 같은 도구로 관리하려고 했던 것이 한동안 상태 관리가 어려웠던 원인 중 하나였기 때문입니다. 서버에서 받아온 데이터를 전역 스토어에 넣고 직접 캐싱과 갱신을 관리하던 시절을 떠올려보면 그렇습니다.
무엇이 어려운가
애플리케이션 전체적으로 관리해야 할 전역 상태가 있을 때, 상태 변화를 감지하고 UI가 일관성 있게 업데이트되도록 하는 것이 중요합니다.
여기서 나오는 개념이 테어링(Tearing) 입니다. 같은 상태를 바라보는 두 컴포넌트가 서로 다른 값을 화면에 보여주는, 말 그대로 화면이 찢어지는 현상입니다.
리액트 18의 동시성 렌더링에서 이 문제가 더 두드러졌습니다. 렌더링이 중간에 멈췄다 재개될 수 있게 되면서, 렌더링 도중에 외부 스토어의 값이 바뀌면 앞부분과 뒷부분이 다른 값으로 그려질 수 있게 된 것입니다. 그래서 리액트는 외부 스토어를 안전하게 구독하기 위한 useSyncExternalStore 를 따로 제공합니다. 지금 쓰는 상태 관리 라이브러리들은 대부분 내부적으로 이 훅을 씁니다.
상태 관리의 역사
큰 흐름은 하나입니다.
복잡도가 증가하는 양방향 데이터 바인딩의 문제를 해결하기 위해, 단방향 데이터 흐름을 도입하는 방향으로 발전했습니다.
Flux 패턴의 등장
기존의 MVC 패턴이 복잡해지자, 페이스북 팀은 단방향 데이터 흐름을 제안하는 Flux 패턴을 만들었습니다.
- Action(액션): 발생한 일과 데이터를 정의하여 Dispatcher로 보냅니다.
- Dispatcher(디스패처): 액션을 받아 Store로 전달합니다.
- Store(스토어): 실제 상태 값과 상태를 변경하는 로직을 가집니다.
- View(뷰): Store의 데이터를 가져와 화면을 렌더링하고, 사용자 인터랙션이 발생하면 Action을 호출합니다.
Action → Dispatcher → Store → View
↑ │
└─────────────────────────────┘장점은 데이터 흐름 추적이 쉬워 코드 이해도가 높아진다는 점입니다. 값이 바뀌었다면 반드시 액션이 있었고, 액션은 한 방향으로만 흐릅니다.
단점은 데이터 갱신과 화면 업데이트를 일일이 코드로 작성해야 해서 코드 양이 많다는 것입니다.
Redux
Flux 구조에 Elm 아키텍처를 도입하여 등장했습니다.
Elm은 선언적으로 HTML을 작성하기 위한 언어입니다. 그리고 리액트는 선언적 프로그래밍에 잘 어울리는 라이브러리입니다.
데이터가 단방향으로 흘러야 했던 것도 이런 의미였을까 싶었습니다.
정리하다 보니 납득이 됐습니다. 선언적이라는 건 “지금 상태가 이러면 화면은 이렇다” 를 쓴다는 뜻입니다. 그런데 화면이 다시 상태를 고칠 수 있으면 그 문장이 성립하지 않습니다. 어느 쪽이 원인인지 알 수 없으니까요. 단방향 흐름은 선언적으로 쓰기 위한 전제 조건에 가깝습니다.
장점은 Prop Drilling, 즉 상태를 여러 컴포넌트를 거쳐 전달하는 문제를 해결하고 어디서든 스토어에 접근 가능하다는 점입니다.
단점은 액션 타입, 액션 생성자, 리듀서 같은 보일러플레이트가 많아져 개발 피로도가 높았다는 것입니다. 값 하나를 바꾸려고 파일 세 개를 여는 경험은 대부분 해보셨을 겁니다.
Context API
리액트 16.3 버전 이후에 등장하여, Props Drilling을 해결하고 상태를 컴포넌트 트리에 주입하는 데 도움을 줍니다.
구 버전의 문제점은 Context를 사용하는 상위 컴포넌트가 렌더링되면 하위 컴포넌트의 불필요한 리렌더링이 자주 발생했다는 것입니다.
현재는 useContext 훅과 함께 사용되며, 상태 관리 자체보다는 상태를 ‘전달’하는 도구로 주로 활용됩니다.
이 구분이 중요합니다. Context는 값을 아래로 내려보내는 통로일 뿐, 값이 바뀌었을 때 누구를 다시 그릴지 골라내는 기능이 없습니다. 그래서 Context만으로 전역 상태를 관리하면 결국 리렌더링 범위 문제에 부딪힙니다.
리액트 훅 기반 상태 관리
리액트 16.8 버전에서 useState, useReducer 등의 훅이 추가되면서 상태 관리 생태계에 큰 변화가 생겼습니다.
기본 훅
가장 기본적인 지역(Local) 상태 관리 방법입니다.
- useState: 간단한 상태 관리에 적합합니다.
- useReducer: 상태가 복잡하거나 상태 변경 시나리오가 다양한 경우, 상태 로직을 훅으로 분리해 재사용성을 높입니다.
한계는 지역 상태라는 점입니다. 여러 컴포넌트에서 공유하려면 상위 컴포넌트로 상태를 끌어올린 후 props로 전달해야 합니다. 결국 Prop Drilling으로 돌아옵니다.
새로운 상태 관리 라이브러리들
훅의 등장으로, Redux와 달리 작은 크기의 상태를 효율적으로 관리하는 라이브러리들이 등장했습니다. 훅을 활용해 보일러플레이트를 줄이고 최적화에 집중합니다.
서버 상태 — React Query, SWR
데이터 페칭, 캐싱, 동기화, 에러 처리 등 서버에서 가져온 데이터를 효율적으로 관리하는 데 특화되어 있습니다.
앞에서 상태를 네 종류로 나눴던 게 여기서 의미를 갖습니다. 서버 상태는 애초에 내가 소유한 값이 아니라 원본의 사본입니다. 언제 낡는지, 언제 다시 받아올지가 핵심이라 클라이언트 상태와는 다뤄야 할 문제가 완전히 다릅니다. 이걸 전역 스토어에서 떼어낸 것만으로도 상태 관리의 부담이 크게 줄었습니다.
클라이언트 상태 — Recoil, Jotai, Zustand, Valtio
훅을 활용해 전역 상태를 더 리액트스럽고 간결하게 관리하며, 불필요한 리렌더링을 줄이는 데 초점을 맞춥니다.
번외: 그래서 리렌더링은 어떻게 되는가
“선언형 뷰는 코드를 예측 가능하고 디버깅하기 쉽게 만든다”
맞는 말입니다. 그런데 상태 관리를 하게 되면 리렌더링은 어떻게 될까요?
지금 우리가 쓰는 방법은 수동적입니다
우리는 불필요한 재연산과 리렌더링을 막기 위해 useMemo, useCallback, React.memo 등을 사용합니다.
하지만 이는 근본적인 해결이라기보단 수동적인 해결에 가깝습니다. 어디가 문제인지 개발자가 직접 찾아서, 직접 감싸주고, 의존성 배열까지 직접 관리해야 합니다. 하나라도 빠뜨리면 최적화는 조용히 무효가 됩니다.
이런 문제를 해결하기 위해 상태 관리 라이브러리를 쓰기도 합니다. 셀렉터로 필요한 조각만 구독하는 방식입니다.
다만 이 경우 불필요한 리렌더링을 막아도 불필요한 계산을 막을 수는 없습니다.
근본 원인은 참조 비교입니다
리액트 상태 변경 감지의 기본 원칙은 메모리 주소(참조) 비교입니다.
그리고 값이 변경되면 불변성 업데이트를 통해 새로운 객체를 할당합니다. 그러니 리액트 입장에서는 그 객체가 통째로 바뀌었다고 볼 수밖에 없습니다.
const next = { ...state, name: '연재' }
next === state // false
next.age === state.age // true — age는 그대로인데
// 리액트는 next !== state 만 보고 "바뀌었다"고 판단합니다age만 쓰는 컴포넌트도 다시 그려집니다. 바뀐 것은 필드 하나인데, 감지의 단위는 객체 전체이기 때문입니다.
여기에 하나 더 겹칩니다. 리액트의 갱신 단위는 컴포넌트입니다. 상태가 바뀌면 그 컴포넌트 함수가 처음부터 다시 실행되고, 그 안의 모든 계산이 다시 돌아갑니다. 실제로 화면에서 달라진 글자가 한 글자뿐이어도 그렇습니다.
useMemo는 이 “다시 실행됨”을 되돌리는 게 아니라, 다시 실행되는 함수 안에서 특정 계산만 건너뛰게 하는 땜질입니다. 그래서 수동적이라고 표현한 것입니다.
대안: Fine-Grained Reactivity
이 문제를 해결하는 방식으로 fine-grained reactivity(FGR) 가 있습니다. FEconf의 스벨트 발표에서 처음 접한 개념이었습니다.
발상이 다릅니다.
| 리액트의 기존 모델 | Fine-Grained Reactivity | |
|---|---|---|
| 감지 단위 | 객체 참조 | 개별 값 |
| 갱신 단위 | 컴포넌트 | 그 값이 실제로 쓰인 지점 |
| 방식 | 다시 실행 후 diff | 구독해둔 곳만 직접 갱신 |
FGR은 값을 읽는 그 순간 “누가 이 값을 읽었는지”를 기록해둡니다. 그래서 값이 바뀌면 diff를 뜰 필요 없이, 기록해둔 그 지점만 바로 갱신합니다. 컴포넌트 함수를 다시 실행하지 않으니 그 안의 계산도 다시 돌지 않습니다.
“와 좋다 스벨트!” 하고 있었는데, 많고 많은 서드파티를 가진 리액트에 없을 리가 없었습니다.
Legend State
그렇게 나온 것이 Legend State입니다.
FGR 방식으로 리렌더링을 압도적으로 줄여주고, 스스로를 가장 빠른 상태 관리 라이브러리라고 소개합니다.
그런데 이 방식, 어디서 보지 않으셨나요
바로 시그널(Signal) 입니다.
시그널도 대표적인 FGR 방식의 상태 관리 도구이고, 앵귤러에서 아주 유용하게 쓰고 있습니다.
// Angular — count 를 읽은 바인딩만 갱신됩니다
count = signal(0)
doubled = computed(() => this.count() * 2)
increase() {
this.count.update(v => v + 1)
}doubled는 count가 바뀔 때만 다시 계산되고, 화면에서는 doubled를 읽은 그 자리만 갱신됩니다. 컴포넌트 전체가 다시 실행되지 않습니다. useMemo를 붙일지 말지 고민할 일이 없다는 뜻이기도 합니다.
솔리드, 프리액트, 스벨트 5의 룬(rune)까지 전부 같은 뿌리입니다. 리액트가 참조 비교와 컴포넌트 단위 재실행이라는 길을 갔다면, 이쪽은 값 단위 구독이라는 다른 길을 간 셈입니다.
리액트의 대답
그렇다고 리액트가 손 놓고 있는 것은 아닙니다. 다만 방향이 다릅니다.
React Compiler는 빌드 시점에 코드를 분석해 필요한 메모이제이션을 자동으로 넣어줍니다. 개발자가 useMemo와 useCallback을 직접 붙이던 일을 컴파일러가 대신하는 것입니다.
정리하면 이렇게 갈립니다.
- 리액트: 컴포넌트 단위 재실행이라는 모델은 유지하되, 그걸 감당할 최적화를 자동화한다
- 시그널 계열: 애초에 재실행하지 않는 모델로 바꾼다
어느 쪽이 옳다기보다, 같은 문제를 서로 다른 층위에서 푸는 중이라고 보는 게 맞을 것 같습니다.
정리
- 상태는 UI / URL / 폼 / 서버로 나뉘고, 성격이 다르므로 다른 도구로 다루는 게 맞습니다. 서버 상태를 전역 스토어에서 떼어낸 것만으로도 상태 관리는 훨씬 단순해졌습니다.
- 상태 관리의 역사는 양방향 바인딩의 복잡도를 단방향 흐름으로 푸는 과정이었습니다. Flux가 흐름을 정했고, Redux가 그 위에 Elm 아키텍처를 얹었으며, 훅의 등장으로 가벼운 라이브러리들이 쏟아졌습니다.
- Context는 상태 관리 도구가 아니라 전달 도구입니다. 리렌더링 범위를 골라내는 기능이 없습니다.
useMemo,useCallback,React.memo는 근본적인 해결이 아니라 수동적인 대응입니다. 원인은 리액트의 변경 감지가 참조 비교이고 갱신 단위가 컴포넌트라는 데 있습니다.- 그 원인 자체를 바꾸는 방식이 fine-grained reactivity이고, Legend State와 시그널이 여기에 속합니다.
앵귤러에서 매일 쓰던 시그널이 사실은 리액트가 겪고 있는 리렌더링 문제에 대한 다른 층위의 답이었다는 게 이번에 정리하면서 가장 재미있었던 지점입니다. 도구를 옮겨 다니다 보면 각 프레임워크가 어떤 문제를 어디서 풀기로 선택했는지가 보이는 것 같습니다.
