FrontEnd

상태(State)란 무엇일까?

뎁희 2024. 5. 18. 11:57

리액트 프로젝트를 시작하면서 리액트는 상태로 시작해 상태로 끝나는 것 같다는 생각이 들었다. 다양한 전역 상태 라이브러리를 경험해 보고 이제 정말 이해했다고 생각할 때쯤 서버 상태 관리가 나타나 멘붕이 왔던 기억이 난다. 그리고 이번 글은 이전 마이버추얼트립 프로젝트를 진행할 때 정리했던 상태 관리 라이브러리에 대한 정리와 모달을 만들며 다시 생각해 보게 된 Context API를 합쳐서 다시 한번 정리를 해보는 시간을 갖고자 작성한다.


State(상태)란 무엇일까?

State는 React 컴포넌트 내에서 동적인 데이터를 관리하는 객체이다. 사용자 인터랙션이나 서버 응답 등으로 인해 변경될 수 있으며, 변경될 때 컴포넌트의 리렌더링을 트리거한다.


바닐라 자바스크립트에서의 상태

리액트 하면 상태를 빼놓을 수 없는데 바닐라 자바스크립트 프로젝트를 할 때에는 상태라는 단어를 특별히 들어본 적이 없다. 상태의 개념이 없다는 것일까? 싶지만 그렇지 않다. 결국 상태도 하나의 데이터 개념이기 때문에 존재하지만 특성이나 관리하는 방법이 다르다.

  Vanilla JS React
상태 공유 전역 객체, 모듈 시스템 활용 props, Context API, 상태 관리 라이브러리
상태 업데이트 전역 변수 사용, 함수 호출 React Hook 활용
UI 동기화 직접 DOM 조작 상태 변경에 따라 리렌더링

 

왜 상태라고 표현했을까를 생각해 보면 변경 전/후처럼 어떠한 특정 시점의 데이터의 상태를 말하는 것이 아닐까 하는 생각이 든다. 상태는 언제든지 변할 수 있다. 그러므로 상태라는 것은 리액트여서가 아니라 웹 애플리케이션 전반에서 중요한 개념으로 볼 수 있다. 그만큼 이 상태를 어떻게 관리하느냐가 중요한 포인트가 된다. 간단히 생각해 봐도 이 상태 관리가 잘못되어 서비스 내에서 보이는 데이터가 다르다던지, 다른데 어디서부터 잘못된 것인지 추적이 안된다던지 다양한 문제가 생길 수 있을 것 같다.


리액트에서의 상태

useState의 한계

그럼 다시 리액트로 돌아와서 생각해 보자. 리액트는 Vanilla JS와 비교하여 상태 관리를 쉽게 하고, 상태 변화에 따라 UI 동기화를 쉽게 하고자 등장한 라이브러리이다. `useState`는 가장 기본이 되는 상태 관리 훅이라고 할 수 있는데 컴포넌트 로컬 상태를 선언하고 관리할 수 있도록 한다. 사용법도 간단하고, 로직이 복잡하지 않다면 기능을 구현하는데 충분하다.

 

하지만 기능을 구현하다 보면 결국 역할 또는 컨벤션에 따라 모듈화를 하게 되고, 함수를 분리하게 되면서 1개의 useState만으로는 부족해진다. 물론 각기 다른 상태라면 가능하지만, 같은 상태를 공유해야 한다면 관련 있는 모든 로직들이 useState가 선언된 파일 안에서 함께 오손도손 모여 있어야 하는 상황이 된다. 

 

Props로 1차 해결

간단히 부모 <-> 자식 구조라면 props로 상태를 전달하여 어느 정도 해결할 수 있다. 손에 손잡고 있는 근방의 컴포넌트에게는 전달해 줄 수 있다는 뜻이다. 하지만 1번 최상위에 있는 상태가 필요한 컴포넌트가 100번째 아래에 있다면 그 사이 99번의 손을 지나야만 하는, 문제의 `Props Drilling`이 발생한다. 


Pull 방식의 Context API

Context API는 `Props Drilling`문제를 해결하기 위한 방법 중 하나이다. Provider를 설정해 해당 Provider 하위에 포함되는 컴포넌트들은 구독 중인 상태가 변경되면 새로운 상태를 가져온다(pull). 1번 최상위에 Provider를 씌우면 하위 100번째 컴포넌트도 Provider의 상태에 접근할 수 있다는 말이다. 하지만 뭐든지 장점만 있는 것은 아니듯 대충 생각해 봐도 1번 컴포넌트에 Provider를 씌우면 `1~100`까지의 모든 컴포넌트가 구독 상태가 되는 것이고, 이로 인해 관련 없는 컴포넌트들의 불필요한 리렌더링이 일어나는 큰 단점이 발생한다. 

 

Pull 방식에 대한 좋은 예시 중 하나로 다음과 같이 설명할 수 있다.

서랍에서 필요한 물건을 꺼내는 것과 같다. 필요할 때마다 서랍에서 직접 물건을 가져오는 방식이다. 이 서랍이 갖는 물건은 어떤 사람이, 언제 열어도 동일해야 한다.

이렇게만 보면 "그럼 Context는 장점이 뭐야?"라고 생각할 수도 있다.(내가 그랬다.) depth가 깊은 구조에서도 상태를 공유하기 위해 쓰는데 불필요한 리렌더링이 생기고, 안 쓰자니 Props Drilling이 생기면 무조건 상태 관리 라이브러리가 나은 게 아닌가?라고 생각했다. 

장점

  1. React가 제공하는 내장 API이다.(= 라이브러리 의존성을 낮출 수 있다.)
  2. Provider를 이용해 원하는 부분만 국소적으로 로직을 재사용하면서 독립적인 상태를 공유할 수 있다.
    Context Provider에 감싸진 컴포넌트는 상위 가장 가까운 Provider의 상태를 공유한다. 그렇기때문에 부분 부분 감싸게 되면 컴포넌트마다 동일한 로직이지만 다른 상태를 갖는 컴포넌트가 될 수 있다.

전역 모달을 결국 Context로 만들면서도 많이 고민했던 부분인데 결국 위 2가지를 장점으로 뽑았다. 그리고 언제 쓸 것인가에 대한 나만의 선택 기준으로 부분적인 Provider의 필요성과 호환성을 고려해 볼 것 같다.

 

ContextAPI + useReducer 조합

ContextAPI 흐름

useReducer와 함께 쓴다는 가정하에 그려본 흐름이라 action과 dispatch를 함께 표시했다. `state`는 Provider의 value로 모이고, 이 Provider를 바로 상위로 갖는 컴포넌트들은 상태 공유 대상이 된다. 


Push 방식의 전역 상태 관리 라이브러리

Redux, Recoil, Jotai 등의 전역 상태 관리 라이브러리는 상태가 변경되면 구독중인 컴포넌트에 변경 사항을 push 한다. 이는 구독하는 컴포넌트만 리렌더링 되어 ContextAPI보다 성능 최적화에 유리하다. 

 

Push 방식에 대한 예시는 다음과 같이 설명할 수 있다.

뉴스레터를 구독하는 것과 같다. 새로운 뉴스가 있을 때마다 구독자(필요한 사람)에게만 자동으로 발송된다.

 

다양한 라이브러리 중 주로 Flux 패턴 vs Atom 패턴 구도로 나뉘게 되는데 이를 설명하기 위해 Flux(Redux-Toolkit), Atom(Recoil)을 기준으로 했다. 

 

Flux 패턴

간단히 `Action` > `Dispatcher` > `Store` > `View`로 표현되는 단방향 흐름의 패턴이다. View인 화면에서 Action에 의해 상태가 변경되면 다시 Dispatcher가 실행되는 구조이다. 

  • Action: 상태를 변경하기 위한 함수
  • Dispatcher: 액션이 실행되었을 때 스토어에 전달한다.
  • Store: 상태와 이를 관리할 수 있는 로직을 포함한다. 액션 타입에 따라 상태를 업데이트 하고, View가 이벤트를 받는다.
  • View: 사용자에게 표시되는 UI를 나타낸다. 스토어의 상태 변화에 따라 업데이트 된다.

Redux-Toolkit 흐름

Redux-Toolkit을 기준으로 보라색과 노란색 박스만 보면 된다. Store는 Slice 모음을 저장하고, 각 Slice는 관련된 State와 Action들의 모음이다. 앱 내에서는 Dispatcher를 통해 Action(노란색)을 호출하여 Slice에 전달하고, 새로운 new State를 반환하면 다시 컴포넌트의 View에 업데이트된다. 

 

장/단점

단방향 데이터 흐름의 특성으로 명확한 구조와 순서를 가지고 있어 상태 추적이 쉽고, 유지보수가 쉽다. 하지만 단점으로 항상 거론되는 어마어마한 보일러플레이트를 무시할 수 없다. 도식화된 이미지로만 봤을 때는 간단한 듯하면서도 Atom 패턴과 비교해 본다면 역할별 파일도 상대적으로 많게 느껴진다. 

 

직접 사용해 보았을 때도 학습 곡선이나 보일러플레이트와 같은 단점에 공감을 하면서도 처음 경험했던 라이브러리여서인지 그렇게 힘들게 느끼지 않았다. 도입했던 프로젝트가 꽤나 복잡했었는데 규모가 커질수록 보일러플레이트도 많아졌지만, 그만큼 보이는 정보가 많다고 느껴져 다시 돌아보았을 때 흐름을 이해하기가 좋다고 느꼈다.

 

Atom 패턴

말 그대로 상태를 Atom(원자)와 같은 작은 상태 단위로 관리하고, 이를 결합하여 더 큰 상태 구조를 형성하는 패턴이다. Recoil은 key가 필요하고, Jotai는 key가 필요하지 않다는 거 외에 사용법은 거의 유사하며 정말 간단하게 사용할 수 있다.

 

Atom은 상태의 최소 단위이자, 전역 상태이고 위에 말했던 것처럼 push 방식이라 상태가 변경되면 구독 중인 컴포넌트만 리렌더링 된다. 

`Selectors`를 이용해 파생 상태를 쉽게 관리할 수 있는데 Flux 패턴에서도 가능하지만 그 로직이 더 간단하고 가독성이 좋은 편이다.

 

Recoil 흐름

위에서부터 지금까지 총 3개의 도식화가 모두 같은 구조를 나타내고 있음에도 Atom 패턴인 Recoil이 정말 간단하다는 것을 느낄 수 있다. 관리하고자 하는 상태를 atom으로 저장하고, 원하는 컴포넌트에서 라이브러리가 제공하는 훅을 이용해 조회하거나 업데이트할 수 있다. 

 

장/단점

상태를 작은 단위로 나누어 관리하기 때문에 특정 상태 변경이 다른 상태에 미치는 영향을 최소화할 수 있다. 또, 필요한 상태만 구독하고 사용하므로 불필요한 리렌더링을 방지할 수 있어 성능 최적화면에서 좋다. 

 

Flux패턴과 비교했을 때 느낀 단점으로 오히려 간편해서 불편할 수 있겠다는 생각을 했다. 작은 단위의 상태가 너무 많아지게 된다면 관리가 어려울 것 같은데 그 상태가 전부 독립적으로 여기저기 분산되어 있으면 규모가 큰 프로젝트에서는 전체적인 구조를 확인하기에 어렵지 않을까? 싶다. 


마치며

`상태 관리`하면 떠오르는 3가지를 다시 한번 알아보았다. 처음 관련 포스팅을 했을 때에는 서버 상태 관리를 제대로 들어가기 전이었는데 함께 적용한 프로젝트를 진행해 본 지금 보니 어떤 것을 선택할 것이냐고 물어보면 더 어려울 것 같다는 생각도 든다. 실제로 마이버추얼트립 프로젝트에 TanStack-Query를 도입하면서 기존 클라이언트 상태 관리 라이브러리 로직이 모두 제거되었다. 그렇다고 해서 TanStack-Query가 모든 상태를 관리할 수 있는 것은 아니므로 적절한 상황을 파악할 수 있는 눈이 필요함을 느낀다. 또 한 가지는 라이브러리를 알게 된 이후에 ContextAPI는 항상 마지막 후보였는데 이 또한 불필요하게 라이브러리에 의존하는 상황을 만들어내고 있는 것은 아닌지 한 번 더 생각해 보게 된다.