목차
1. 서문
2. React에서 상태를 관리하는 방법
- 기본 상태 관리 방법
- 내부 기능 훅 사용하기
- 외부 라이브러리 사용하기
3. 상태 관리를 "잘"하는 방법
1. 서문
최근 공개된 Figma Config 2024에도 나왔고, IT업계 선두 쪽 회사에 다니는 지인들로부터도 Figma, Framer 등을 활용한 디자이너와 프론트엔드의 경계가 모호해진다는 등 여기저기서 소리가 들려온다. 변해가는 프론트엔드 역할을 볼 때 프론트엔드 개발자하면 더 기대되는 부분은 UI적 부분보다는 "데이터"를 다루는 "상태관리"를 얼마나 잘 하는지인 것 같다는 생각을 했다[글].
사실 상태 관리는 어렵다. 왜 어려울까. 무효화, 동기화, 파생 상태, 서버 재요청 등 예기치 못한 리렌더링의 상황이 많아서이다. 개인적으로 이 상태 관리가 너무 어려워서 에러로 쪽(?)도 많이 팔았다. 그래서 이런저런 이유로 상태 관리를 제대로 공부해보기로 했다. 상태관리란 무엇이며, 어떻게 해야하는 것일까. 어떻게 하는 것이 상태관리를 "잘"하는 것일까?
2. React 에서 상태를 관리하는 방법
"상태 관리"란 무엇인가요?
리액트에서 상태(state)란 컴포넌트 속 데이터를 의미합니다. 이러한 상태는 시간에 따라, 사용자와의 상호작용에 따라 업데이트되며, 이렇게 변화하는 상태를 앱의 다양한 부분이 서로 일관되게 공유하여 사용자의 상호작용에 올바르게 반응하게 해주는 관리를 상태 관리라고 합니다. 상태 관리에는 컴포넌트 내부의 데이터의 변화를 관리하는 지역적 상태 관리(local statement management)와 컴포넌트와 외부 컴포넌트끼리의 상태 공유를 관리하는 전역 상태 관리(global statement management)가 있습니다.
2-1. 기본 상태 관리 방법
- state 란...
- props 란...
- state를 업데이트 하는 방법, setState
- props를 업데이트 하는 방법, propsDrilling
상태를 관리하는 데에 있어 라이브러리가 반드시 필요할까요?
리액트에서 상태 관리를 하는 기본적인 방법은 컴포넌트의 state와 props를 사용하는 것입니다. props 는 부모 컴포넌트로 전달 받은 데이터를 의미합니다. 컴포넌트의 state는 setState 함수를 통해 업데이트할 수 있으며, 이 과정에서 리액트는 컴포넌트를 재렌더링하여 변경된 데이터를 사용자에게 보여줍니다. 그러나 이것은 컴포넌트 내부의 상태 관리일 뿐, 어플리케이션을 효과적으로 동작하게 하기 위해서는 여러 컴포넌트 간에 상태를 공유하고 관리할 수 있어야 합니다. 리액트는 단방향 흐름인 Flux 패턴을 바탕으로 하므로, 데이터 흐름이 단순하지만 단계별로 일일이 props를 넘겨주어야 하는 번거로움이 있습니다. 이는 규모가 어느 정도 큰 애플리케이션에서는 오히려 데이터 관리가 복잡해지는 결과를 낳기도 합니다. 따라서 트리 단계마다 명시적으로 props를 넘겨주지 않아도 컴포넌트 트리 전체에 데이터를 제공할 수 있게 만들어주는 상태 관리 라이브러리는, 복잡한 상태 관리가 필요한 경우 사용하는 것이 효율적인 면에서 필요하다고 생각합니다.
하지만 반드시 외부 라이브러리를 사용할 필요는 없습니다. 앱의 규모가 크지 않다면, 내부 상태 관리 라이브러리인 useContext와 useReducer 등을 사용하는 것을 고려해 볼 수 도 있습니다.
2-2. 내부 기능 훅 사용하기
- 훅(hook)이란 ...
- useState, useContext, useReducer
- useReducer의 동작 원리
훅(hook)이란 ...
리액트에서 상태를 관리하기 위해 상태관리 라이브러리가 반드시 필요하지는 않습니다. 하지만 앱의 규모가 커질 수록, 상태를 공유해야 하는 컴포넌트 사이의 거리가 멀수록, 컴포넌트끼리의 상태 공유를 보다 효율적하기 위해서 전역 상태 관리는 반드시 필요합니다. 예전 클래스형 리액트를 사용할 때는 이러한 전역 상태 관리를 위해 Redux 등 외부 라이브러리를 사용해야 했습니다. 그러나 2019년, React 16.8에서 훅(Hook) 기능을 소개하며, 여러 편리한 기능과 함께 상태 관리에 대한 대안을 제시했습니다. 특히 이러한 훅 중 useContext, useReducer 의 훅을 사용하면 반드시 외부 라이브러리를 사용할 필요 없으며, 이를 훅을 이용한 상태관리라고 하기도 합니다. 이러한 훅은 기본적으로 함수이며 use...로 시작하는 것이 관례입니다.
useContext, useReducer와 useState
useContext와 useReducer, 그리고 앞서 배운 useState는 사실 이러한 훅 중 상태 관리 기능에 특화된 훅입니다. useState가 상태 그 자체와 상태를 업데이트하는 함수를 동시에 정의한다면, useContext와 useReducer는 각각 상태와 상태 업데이트 방식을 그 기능을 확장하여 분리한 개념입니다. useContext에서 컨텍스트(Context)는 모든 컴포넌트에서 직접 접근 가능한 전역 데이터 객체로, 단순 지역 상태를 넘어 전역적으로 어느 컴포넌트에서든 접근하고 사용할 수 있는 상태 정보를 가진 거대한 컨테이너 같은 개념이빈다. 이러한 useContext를 사용하여 전역 상태 관리가 가능하게 해짐으로써, 외부 라이브러리 사용의 필요성이 낮아지게 되었습니다.
한편 useReducer는 상태 업데이트 방법으로, useState에서 제공하는 상태 업데이트 방식보다 더 복잡한 상태 업데이트를 보다 간단한 방식으로 처리해줍니다. 리액트는 이 두 훅에 의해서만 상태 변화를 인지합니다. 주의할 점은 이 두 훅이 아닌 다른 방식으로 상태를 업데이트하면 리액트는 상태 변화를 인지하지 못하기 때문에 리렌더링이 원하는대로 발생하지 않을 수 있습니다. 이는 뒤 3.상태관리를 잘하는법에서 더 자세히 다루겠습니다. 또한 useContext와 useState, useReducer의 구분은 전역, 지역 상태의 구분이라기 보다 상태와 상태 업데이트 방식의 구분입니다. 전역, 지역 상태는 상태의 범위를 컴포넌트 내부로 제한하느냐, 아니냐에 따라 나눈 구분이며, 상태과 상태 업데이트 방법은 지역성과 상관없이 역할에 대한 구분이므로 헷갈리지 않으시면 좋겠습니다.
useReducer의 동작 원리는 외부 상태 관리 라이브러리인 Redux와 유사합니다. 두 라이브리 모두 Flux 패턴을 사용합니다. Flux 패턴은 단방향으로 데이터 변경이 이루어지는 구조로, 다음 네 가지 개념으로 구성됩니다: Action, Dispatcher, Store(Model), View.
- Action: 변경 데이터와 변경 로직 타입을 정의합니다.
- Dispatcher: 구체적인 변경 로직을 담당합니다. useReducer의 reducer가 이 역할을 수행합니다.
- Store(Model): 상태를 저장합니다.
- View: 상태를 기반으로 UI를 렌더링합니다.
useReducer의 reducer는 state(기존 정보)와 action을 받아 기존 데이터를 업데이트하는 역할을 합니다. 결국 useReducer는 state와 state를 변경하는 동작(dispatch)을 하나로 합친 형태입니다. 따라서 state를 여러 번 언급할 필요가 없고, 여러 곳에서 반복되는 업데이트 로직을 reducer에 한 번만 정의한 후 여러 곳에서 사용할 수 있어 코드가 더 간결해집니다. 이는 코드의 직관성을 높여줍니다.
useReducer는 여러 상태를 효율적으로 관리하고, 업데이트 로직을 단순화하여 코드를 간결하게 만들어주는 훅입니다. (*reducer: a function that reduce one or more complex values to a simpler one.) 이는 Redux와 같은 상태 관리 라이브러리의 패턴을 따르며, 복잡한 상태 관리가 필요한 경우 유용합니다. useState는 단 하나의 상태만 관리할 수 있지만, useReducer를 이용하면 동시에 여러 상태들을 한 번에 관리할 수 있게 됩니다. 이러한 useReducer는 2024년 2월 기준 useState를 구현되어 있으며, 따라서 useState로 useReducer를 구현하는 것은 100%로 가능합니다. (향후 더 효율적으로 구현될 수 도 있습니다.) useReducer는 useState와 비교하여 순수함수이며, 외부에서 정의가 가능한 등 마이너한 차이점이 있지만, 특별한 사항이 아니면 중요치 않기 때문에, 기본적으로 useState와 useReducer는 기본적으로 동일며 상호 교환도 가능하다고 봅니다. 다만, useReducer 함수는 ‘순수함수’(어떤 상황이든 같은 input이면 같은 output을 내뱉는, 외부와 독립적이어 사이드이펙트가 없는 함수)라 동작을 테스트하기 더 쉽다는 이점으로 더 선호되는 부분도 있습니다.
2-3. 외부 라이브러리 사용하기
상태 관리 외부 라이브러리는 전역적인 상태 관리를 돕는 전역 상태 관리 라이브러리와 특정 상태의 특수성을 고려한 특수 목적형 상태 관리 라이브러리로 나뉩니다. 예를 들어, 전역 상태 관리 라이브러리에는 Redux, Recoil, 그리고 MobX 등이 있으며, 특정 상태 관리 라이브러리는 서버 상태 관리를 돕는 react-query, 폼의 상태 관리를 돕는 Formik 등이 있습니다. 이 중 실제로 사용해 본 몇 가지를 특징과 함께 사용 후기를 비교하여 설명해보고자 합니다.
왜 OO라이브러리를 사용하셨나요?
이런 질문은 항상 면접에서 빠지지 않고 듣는 것 같습니다. 그만큼 라이브러리를 무작위로 사용하지 않고 특징을 파악해 사용해 불필요한 리소스 사용을 줄이는 것이 엔지니어로서의 덕목이기 때문이겠죠. 우선 직접 사용해본 전역 상태 관리 라이브러리 두 가지를 공부하고 사용하며 느낀 그 특징을 설명해보겠습니다. 이를 바탕으로 질문에 답할 수 있을 것입니다.
1. Redux
Redux는 가장 오래된 상태 관리 라이브러리입니다. React Hooks(2019년 2월)보다 이전에 출시(2015년 6월)되어, 클래스형 컴포넌트로 상태를 관리하던 시절부터 전역 상태 관리를 위해 사용되던 상태 관리의 조상격 라이브러리입니다. 특징은 아래와 같습니다.
Redux의 기본 동작 원리와 특징: Flux 패턴
- 단방향 데이터 흐름: 상태 추적이 일관적이고 쉬워 팀원들 간에 공유하기 쉽습니다. Action, Dispatcher, Store(Model), View로 이루어진 Flux 패턴의 단방향 흐름은 앞서 useReducer를 설명하며 간단히 언급하였습니다. 더 자세한 동작 방식은 다음 글을 참조할 수 있습니다.
- 순수 함수: 역시 앞서 설명되었지만, 순수함수는 어떤 상황이든 같은 input이면 같은 output을 내뱉는, 외부와 독립적이어 사이드이펙트가 없는 함수를 의미합니다. 이러한 특성 덕분에 무한 되돌림, 무한 재생이 가능하며, Redux의 특징인 시간 여행도 가능합니다.
- 보일러플레이트 코드: 특정 패턴을 바탕으로 하기 때문에 설정 과정에서 많은 보일러플레이트 코드가 필요하여 진입 장벽이 높습니다.
그 외 특징: 커뮤니티 지원
- DevTools: 강력한 개발 도구를 제공합니다.
- 방대한 자료: 가장 오래된 상태 관리 라이브러리로 관련 자료가 많습니다.
2. Recoil
Recoil은 2020년 5월에 Facebook의 일부 React 개발자들이 출시하였으며, 현재 많은 개발자들에게 인정을 받고 있습니다.
Recoil의 기본 동작 원리
- 분산 상태: Recoil은 상태를 여러 작은 Atom(state)으로 나누어 관리할 수 있게 해줍니다. 이는 상태 관리의 복잡성을 줄이고, 특정 상태 변경이 애플리케이션 전체에 영향을 미치지 않도록 합니다.
- 증분 상태: Recoil의 Selectors는 파생된 상태(derived state)를 관리하기 위해 사용되며, 다른 Atom이나 Selector의 값을 기반으로 계산됩니다. 이는 상태의 재사용성을 높이고, 필요에 따라 상태를 동적으로 계산할 수 있게 해줍니다.
- useRecoilState: Recoil에서의 상태 업데이트 방법입니다.
그 외 Recoil의 특징
- 지엽적인 상태 관리: Atom식 상태 관리 특징에 따라 지엽적인 상태 관리가 더 많이 필요한 서비스의 경우 사용되면 좋을 것이라 판단됩니다. 전체적인 팀 공유가 어려워 질 수 있다는 단점이 있지만, 특정 상태 관리가 더 필요한 경우 유용합니다.
- 낮은 러닝 커브와 진입 장벽: React의 Context API와 거의 동일한 방식으로 상태를 사용하나, 1) Provider로 감싸줄 필요가 없고, 사용법이 더 직관적이고 쉽습니다. 3) React에 익숙한 사람이라면 이미 익숙한 개념이라 배우기 쉽습니다.
- 불완전한 개발 도구: Redux처럼 시간 여행 등의 기능이 있지만, 아직 완전하지는 않습니다. 문서 보기.
React Hooks가 나온 이후로 상태 관리 라이브러리의 사용 빈도는 점차 낮아지는 추세이지만, 여전히 다양한 외부 상태 관리 라이브러리가 존재하며 각각의 특성과 장단점을 잘 이해하고 활용하는 것이 중요합니다.
3. 상태 관리를 잘 하는 법
- 애초에 불필요한 상태를 만들지 않기
- 상태 변경시 불변성을 고려하기
- 리렌더링 성능을 최적화하기: memo(), useCallback()
- 컴포넌트 생명주기와 상태의 주기를 같이 하기: useEffect()
리액트의 "불변 철학"이란 무엇인가요? 리액트의 "상태 감지"에 대해 아는대로 말해주세요.
리액트는 상태 변경 시 기존 상태를 직접 수정하지 않고 새로운 상태 객체를 반환하는 불변성을 중요시합니다. 이는 상태 관리의 예측 가능성을 높이고, 상태 변경의 추적을 용이하게 합니다. 이는 리액트의 상태 감지 방식에서 유래합니다. 리액트는 상태 변화를 객체의 참조(주소)가 변경될 떄만 상태 변화로 인식하는 "얕은 참조"를 통해 변화를 감지합니다. 따라서 원시 값의 변경이나 같은 객체의 반환은 리렌더링을 일으키지 않습니다. 또한 앞서 언급되었듯 "useState와 useReducer"를 통해 관리되는 상태 변화만 감지합니다. 일반 변수 값의 변경은 감지하지 못해 리렌더링이 일어나지 않습니다. 따라서 같은 객체를 반환하거나 원시 값의 변경, useState 혹은 useReducer를 사용하지 않고 상태를 변경하는 경우 등은 베일 아웃을 야기할 수 있습니다. 이러한 리액트의 불변성을 고려하여 상태 변경을 진행해야 합니다.
React의 리렌더링 최적화
React는 렌더링은 두 번 일어납니다. 최초 앱을 실행할 때 한 번의 렌더링, 그리고 상태 변화가 감지되었을 때(리렌더링). 리렌더링이 발생하는 경우는 두 가지입니다. 첫째, 부모 컴포넌트의 리렌더링. 이 경우 하위 컴포넌트는 상태 변화와 상관 없이 전부 리렌더링됩니다. 둘째, 컨텍스트의 변경. 컨텍스트의 상태 변경 시, 해당 컨텍스트를 사용하는 모든 컴포넌트는 리렌더링 됩니다. 상태가 감지가 되면 리액트는 모든 부분이 아닌, 변화된 부분에 한해서 리렌더링합니다. 정확히는 상태가 변화하는 컴포넌트와 그의 모든 하위 컴포넌트를 리렌더링합니다. 변화한 부분만 리렌더링하는 자세한 내부 동작 원리는 아래와 같습니다.
- 가상 DOM 업데이트: 상태가 변경되면, React는 새로운 가상 DOM을 생성합니다. 가상 DOM은 실제 DOM의 가벼운 복사본입니다. 이 단계에서는 메모리 내에서만 작업이 이루어집니다.
- 비교 (Reconciliation): 새로운 가상 DOM과 이전 가상 DOM을 비교합니다. React는 이 비교 과정을 통해 변경된 부분을 찾아냅니다. 이를 "diffing algorithm"이라고 합니다.
- 실제 DOM 업데이트: 변경된 부분만 실제 DOM에 반영합니다. 이렇게 함으로써 성능을 최적화하고 불필요한 DOM 업데이트를 피할 수 있습니다.
리엑트에서의 리렌더링을 이해했다면, 상태가 업데이트되지 않은 컴포넌트의 불필요한 리렌더링 방지가 필요합니다. 이러한 불필요한 리렌더링을 방지하는 방법을 알고 있는대로 정리하면 아래와 같습니다.
- 애초에 잘 배치하기(Clever Structuring)
여러 컴포넌트에서 해당 상태 사용시, 하나의 컨테이너 안에서 상태 변경을 이루게 하기 위해 반드시 해당 컴포넌트들의 공통 상위 컴포넌트에 위치시켜야 합니다. 이를 상태 끌어올리기(lift state up)이라고 합니다. 만약 각각의 컴포넌트 안에서 useState를 사용해 상태를 다루게 된다면, 이는 사실상 각각 다른 컨테이너, 즉, 다른 문맥(ex. count container1, count container 2 등)을 사용하는 것이기 때문에, 추측과 다르게 렌더링이 일어나지 않는 리렌더링 오류가 발생할 수 있으니, 반드시 상태 끌어올리기를 사용해야 합니다. 공통된 상태를 공유하는 가장 낮은 컴포넌트에 적절히 상태를 잘 위치시키며, 컴포넌트 트리 구성과 리렌더링 상태를 염두에 두고 컴포넌트를 구성하는 것이 필요합니다. - memo: 부모 컴포넌트의 리렌더링으로 인해 발생하는 불필요한 하위 컴포넌트의 리렌더링을 막기 위해 memo를 사용합니다. 컨텍스트를 사용하지 않는 하위 컴포넌트도 부모의 리렌더링으로 불필요하게 리렌더링 되는 경우, 이 때 memo()를 활용한다. 즉, memo는 컨텍스트 변경이 없다면(부모 렌더링으로 인한 렌더링은 못막지만) 리렌더링을 막아줍니다.
그러나 성능 향상을 위해 memo를 무작위로 사용하는 것은 좋지 않습니다. memo의 동작 원리는, 상태 변경 시 memo로 감싼 컴포넌트의 prop의 변화를 확인하는 것입니다. prop의 변화가 있을 경우만 컴포넌트를 리렌더링합니다. 이러한 동작 원리를 이해했을 때, 불필요한 props-checking이 발생하여 성능이 저하되는 것을 막기 위해서는 꼭 필요한 상위 컴포넌트를 잘 선택해 memo를 감싸는 것이 중요합니다. 상위 컴포넌트가 리렌더링 되지 않는다면 하위 컴포넌트 역시 리렌더링 되지 않을 테니까요. - useRef와 useEffect: useRef과 useEffect는 모두 렌더링 사이클과 관계없이 값을 유지해야 할 때 사용하는, 상태 관리에 도움을 주는 훅입니다. 먼저 useRef는 렌더링 사이클과 관계없이 값을 유지하는 것 + DOM과의 연결이 필요할 때 사용합니다. useEffect는 컴포넌트의 생명 주기(마운트, 업데이트, 언마운트)를 보다 간결하고 효과적으로 관리하기 위해 탄생한 훅으로, 컴포넌트의 생명 주기를 따라 동작합니다. 부수 효과(데이터 페칭, 구독 설정, 타이머 설정, DOM 조작 등)를 렌더링 로직과 분리하는 데 사용하면 효과적입니다. 모든 함수가 그러하지만 특히 useEffect 의 경우 함수를 잘 사용하는 것이 중요합니다. 아니면 상태 관리 시 꼬인 상태 때문에 곤욕을 치룰 수 있기 때문입니다. 그 방법은 다음과 같습니다. 첫째, useEffect 함수 안에는 렌더링 로직과 상관 없는 동작만 넣어야 합니다. 즉, 상태 변화를 일으키는 setState 함수 등이 들어가면 안됩니다. 리액트는 컴포넌트가 리렌더링될 때마다 useEffect를 호출하므로, 불필요한 부수 효과를 방지하기 위해 상태 변경 로직은 useEffect 외부에서 처리해야 합니다. 둘째, cleanup 함수를 작성합니다. 개발 모드에서 컴포넌트가 리렌더링될 때 useEffect가 두 번 호출되는 경우를 대비해 cleanup 함수를 작성해야 합니다. useEffect 내부에서 상태 변경 훅(useState, useReducer)을 사용하지 않도록 주의합니다.
'Programming > 웹 프론트엔드' 카테고리의 다른 글
[React] 비동기함수 Hook으로 다루기 (0) | 2023.05.30 |
---|---|
[JavaScript] 문제로 이해하는 이벤트 루프(Event Loop)의 개념 (feat. JavaScript에서 비동기를 제어하는 4가지 방법) (0) | 2023.03.20 |
[React] 컴포넌트 분리, 어떻게 해야할까? (0) | 2023.03.19 |