들어가며



안녕하세요. Playce Dev team Frontend 개발자 노은지입니다. :blush:


현재 Playce Dev team에서는 React를 이용해 제품을 개발하고 있고, 그 중 클라우드 마이그레이션 자동화 툴인 Playce RoRo는 Redux와 Redux-saga를 이용하여 State 관리를 하고 있습니다. 그리고 Playce RoRo for Java app는 React Query + Recoil을 이용하여 개발하였습니다.


Playce Dev team은 Playce RoRo for Java app을 개발하기에 앞서, React 어플리케이션의 핵심이라고 할 수 있는 state를 저희가 원하는 방식대로 manage 해줄 수 있는 라이브러리를 선정하기 위해 많은 고민을 했습니다. 그 고민은 저희 팀 강동희 Front-end 개발자님께서 포스팅하신 “React-Query 도입을 위한 고민 (feat. Recoil)”에 잘 녹아 있습니다.


저는 Client state와 Server state를 함께 관리하던 Redux에서 왜 React Query와 Recoil로 Client state와 Server state를 따로 관리하는 방식으로 바꾸게 된 건지 찾아보던 중, Playce Dev 팀과 비슷한 고민을 했던 많은 국내외 사례들을 마주칠 수 있었습니다. 왜 많은 개발자 분들이 기존 코드를 다른 상태관리 라이브러리로 migration 하는 쉽지 않은 여정을 시작하게 되었는지, 그 궁금함을 해결하고자 공부하며 정리 해보려 합니다. 


그럼, 먼저 State의 개념에 대해 이해하고, state를 슬기롭게 관리 하기 위한 방법에 어떤 것들이 있는지, 왜 Client state와 Server state를 나누어 관리해야 하는지를 알아보겠습니다!


피드백은 언제나 환영입니다 🙂




상태(state)란 무엇인가요?

 

React에게 state란?

컴포넌트들은 상황에 따라서 스크린에 보이는 화면을 바꿔주어야 할 때가 있죠. 예를 들어 input에 입력되는 값을 업데이트시켜 화면에 반영해준다든지, 이미지 슬라이드에서 ‘다음’ 버튼을 눌렀을 때 바뀐 다음 이미지를 보여주어야 하는 것 말이죠. 또한, ‘장바구니에 담기’ 버튼을 눌렀을 때, 장바구니에 상품이 업데이트되는 모습을 보여주어야 할 때 등이 그렇습니다. 컴포넌트들은 이것들을 ‘기억’해야 하며, 이 컴포넌트들이 가지고 있는 기억을 ‘state’ 라고 React는 정의하고 있습니다.  




State의 종류


React 앱에서는 대표적으로 네가지 타입의 State 들이 있습니다.

      • Local(UI) state (지역 상태 또는 UI 상태)

      • Global state (전역 상태)

      • Server state (서버 상태)

      • URL state (URL 상태)

     

    1. Local (UI) state 

    Local state 또는 UI state는 우리가 하나 또는 여러 개의 컴포넌트에서 다루는 데이터입니다. 대부분 이것은 useState 훅으로 관리됩니다. modal(모달)을 숨겼다가 보여주는 것, form 양식을 제출하거나 input value를 관찰하고 input을 비활성화하는 등의 value를 추적하는 것이 흔히 볼수 있는 예입니다.


    2. Global state

    Global state는 우리가 앱 어디에서든 Get 요청을 해야 할 때, 또는 데이터를 업데이트해야 할때 등의 상황에 우리가 앱 안에서 어디에서나, 또는 다수의 컴포넌트를 넘나들며 그 state를 관리 해야하는 상황에서 필요합니다. 예를 들어 유저가 우리 앱에 로그인 한 뒤, 그들의 데이터(유저 정보)를 변경하는 경우 입니다. 


    3. Server state

    Server state는 말 그대로 서버에서 넘어오는 state입니다. 우리가 관리해야 하는 local, global state와 함께 server state를 관리하는 것은 너무 복잡하고 힘들 수 있습니다. 하지만 React Query 같은 상태 관리 라이브러리를 이용함으로써 우리는 좀 더 수월하게 server state를 관리 할 수 있습니다.


    4. URL state 

    URL state는 URL에 포함된 데이터를 말합니다. path 명 또는 쿼리 파라미터(query parameters)들 같은 것입니다. 

    이는 우리가 state의 종류를 정의할 때 종종 잊혀지곤 합니다. 하지만 매우 중요한 개념입니다. 블로그를 만들 때, URL에 있는 slug나 id 없이 게시글을 가져올 수 있는 상황, 상상이 되시나요?


    slug:  URL 주소의 마지막 부분으로 페이지의 식별자 역할을 합니다.


    이 외에도 더 많은 상태가 존재하겠지만, 위의 네 가지가 우리가 어플리케이션을 구축할 때 주요하게 다루어지는 state의 분류라고 할 수 있습니다.


    이 state들을 ownership으로 묶어 카테고리화 해 본다면 Client-side state,  Server-side state로 나누어 볼 수 있습니다. Client-side에서 조작 가능한 state들을 Client-side state라 보고, Server에서 넘겨주는 client에서 조작 할 수 없는 state를 Server-side state라 합니다.




    상태 관리 라이브러리를 쓰는 이유

     

    React 자체적으로 제공하고 있는 내장 Hook 중 useState를 이용하면 props로 하위 컴포넌트들에 state를 전달 해 줄 수 있습니다. 그럼 local state 관리가 가능해지죠. 더욱이 useState, useReducer 또는 useRef를 React Context와 함께 사용하면 전역적으로 state 공유도 가능합니다. 그런데 굳이 왜 상태 관리 라이브러리를 사용하는 걸까요?



    1. Prop drilling

    앱이 커지고 복잡해질수록, state를 컴포넌트 간에 props로 더 많이 전달 해 주어야 하는 상황이 발생하게 됩니다. 이렇게 data가 하나의 컴포넌트에서 React 컴포넌트 트리 내의 다른 컴포넌트들을 통해 전달되는 상황을 ‘Props drilling’이라고 부릅니다. 


    props를 전달하는 것 자체로는 문제가 되지 않지만, 해당 props를 사용하지 않는 컴포넌트들에 전달하게 되면서 불필요한 리소스 낭비와 컴포넌트 간의 의존성을 높이고, 컴포넌트 재활용 또한 어렵게 만듭니다. 

    다음 코드는 Geeksforgeeks 예시를 우리에게 친근한 한국어로 변경하였습니다.

    // Parent.jsx
    
    import React, { useState } from "react";
    
    function Parent() {
      const [fName, setfName] = useState("나의 이름");
      const [lName, setlName] = useState("내 성씨");
      return (
        <>
          <div>부모 컴포넌트 입니다.</div>
          <br />
          <FirstChild fName={fName} lName={lName} />
        </>
      );
    }
    
    function FirstChild({ fName, lName }) {
      return (
        <>
          첫번째 자식 컴포넌트 입니다.
          <br />
          <SecondChild fName={fName} lName={lName} />
        </>
      );
    }
    
    function SecondChild({ fName, lName }) {
      return (
        <>
          두번째 자식 컴포넌트 입니다.
          <br />
          <ThirdChild fName={fName} lName={lName} />
        </>
      );
    }
    
    function ThirdChild({ fName, lName }) {
      return (
        <>
          세번째 자식 컴포넌트 입니다.
          <h3> 부모로부터 props로 전달 받은 데이터:</h3>
          <h4>이름: {fName}</h4>
          <h4>성: {lName}</h4>
        </>
      );
    }
    
    export default Parent;

    // App.js
    
    import "./styles.css";
    import Parent from "./Parent";
    
    export default function App() {
      return (
        <div className="App">
          <Parent />
        </div>
      );
    }

    부모 컴포넌트에서 state를 받아 브라우저에 렌더링해 주는 컴포넌트는 오직 세 번째 자식 컴포넌트인데 첫 번째, 두 번째 컴포넌트도 props로 state를 받아 전달해 주고 있습니다. 불필요한 props 전달이 일어나면서 컴포넌트 간 의존성이 높아진 경우 입니다.




    Context API의 사용


    아래 코드대로라면 prop drilling 없이도 부모 컴포넌트에서 생성한 state를 Context API를 이용해 세 번째 자식 컴포넌트에 전달이 가능합니다.

    // Parent.jsx
    
    import React, { useState, useContext } from "react";
    
    let context = React.createContext(null);
    function Parent() {
      const [fName, setfName] = useState("은지");
      const [lName, setlName] = useState("노");
      return (
        <context.Provider value={{ fName, lName }}>
          <div>부모 컴포넌트 입니다.</div>
          <br />
          <FirstChild />
        </context.Provider>
      );
    }
    
    function FirstChild() {
      return (
        <>
          첫번째 자식 컴포넌트 입니다.
          <br />
          <SecondChild />
        </>
      );
    }
    
    function SecondChild() {
      return (
        <>
          두번째 자식 컴포넌트 입니다.
          <br />
          <Thirdchild />
        </>
      );
    }
    
    function Thirdchild() {
      const { fName, lName } = useContext(context);
      return (
        <>
          세번째 자식 컴포넌트 입니다.
          <br />
          <h3> 부모로부터 props로 전달받은 데이터:</h3>
          <h4>이름: {fName}</h4>
          <h4>성: {lName}</h4>
        </>
      );
    }
    
    export default Parent;

    하지만 Context API를 사용하는 것이 언제나 좋은 방법은 아닙니다.



    2. Context API 사용 시 유의 해야 할 점 

     

    불필요한 리렌더링

    Context를 사용할 때 Provider로 상태를 공유할 컴포넌트 중 최상단 컴포넌트에서 감싸 주어야 합니다. Context를 사용하는 컴포넌트들은 Context API가 전달하는 상태가 변경될 때마다 항상 리렌더링 됩니다.


    예를들어 Context가 제공하는 {firstName: 은지, lastName: 노} 중에서 ‘firstName: 은지’만 세 번째 자식 컴포넌트에서 읽힌다고 가정하고, lastName의 상태가 변경된다면 어떤 일이 일어날까요? 아무 이유 없이 세 번째 자식 컴포넌트는 리렌더링 됩니다. 이 context를 사용하는 다른 모든 컴포넌트들도 마찬가지입니다.


    단 몇 번의 불필요한 리렌더링이라면 성능에 큰 무리가 없을지도 모르지만, context를 사용하는 컴포넌트에 종속된 다른 자식 컴포넌트들이 있다면, 많게는 수백, 수천 개의 컴포넌트들이 리렌더링 되면서 성능에 큰 문제를 일으킬 수 있습니다.


    게다가 이런 불필요한 리렌더링은 데이터 추적을 힘들게 만들어서 props drilling이 심화되었을 경우와 같이 어디에서 어떤 데이터가 사용되는지 쉽게 찾아볼 수 없게 될 수도 있습니다.



    불필요한 Re-rendering을 막는 방법 

    context API를 사용시 생길수 있는 불필요한 리렌더링을 없애기 위해서 shouldComponentUpdate, React.memo나 useMemo hook을 사용하여 값을 Memoize 하거나, useContext hook 또는 커스텀 훅으로 context state를 기준으로 memoize 된 값을 리턴해 주도록 할 수 있습니다. 

    또한 context를 작은 단위로 만들어 필요한 컴포넌트에서만 구독하게 하고, context provider 컴포넌트를 최대한 상위 계층에 배치하면 불필요한 리렌더링을 줄일수 있습니다. 




    그럼, 상태 관리는 Context API로 충분하지 않나요?


    Context API를 사용하여 상태를 전달하고 관리하면 최적화를 위해 Memoization을 사용할 수 있지만, 이는 복잡한 애플리케이션에서 상태 관리와 최적화를 관리하기가 어려울 수 있기 때문에 전역 상태 관리 라이브러리를 사용하는 것이 좋을 수 있습니다.


    전역 상태 관리 라이브러리는 전체 애플리케이션에서 공유되는 상태를 중앙 집중적으로 관리하기 때문에 코드의 유지 보수성이 좋아집니다. 또한 Redux와 같은 라이브러리는 액션과 Reducer를 사용하여 상태를 업데이트하기 때문에 예측 가능한 방식으로 상태가 업데이트됩니다. 이를 통해 debugging이 용이해지며, 상태 변경에 대한 이해도가 높아집니다.


    또한 전역 상태 관리 라이브러리는 최적화 기능을 제공하여 상태 변경에 따른 불필요한 리렌더링을 방지할 수 있습니다. 예를 들어, Redux는 리렌더링이 필요한 컴포넌트만 다시 렌더링할 수 있도록 최적화된 방식으로 상태를 업데이트합니다. 이를 통해 애플리케이션의 성능을 향상시킬 수 있습니다.


    따라서 전역 상태 관리 라이브러리는 상태 관리 이외에도 코드의 유지 보수성을 높이고, 예측 가능한 방식으로 상태를 업데이트하며, 최적화된 방식으로 상태를 업데이트하여 애플리케이션의 성능을 향상시키는 등의 장점이 있기 때문에 사용됩니다.




    그럼 Context API는 언제 쓸 수 있나요?


    Software engineer Jannik Wempe는 우리가 Context API로 관리하기에 적합한 state인지 아닌지를 스스로 판단할 수 있도록 다음과 같은 chart를 공유했습니다. 

    이미지 출처: When to (not) use react context api for state

    여러분이 Context API 사용을 염두 하고 있는 하나의 state가 있다고 가정하고 다음 질문들을 읽고 스스로 올바른 선택지를 찾아 보세요!


    1. state가 backend로부터 독립적인가요? 

     

    Yes : 2번 문항으로

    No: Context API는 올바른 선택이 아닙니다. 이것은 server state입니다. 진실된 하나의 공급원으로부터 그 연결을 끊지 마세요. React-Query와 같은 상태관리 라이브러리 이용을 권장합니다.


    2. 여러 컴포넌트에서 사용 중인 state인가요?

     

    Yes: 3번 문항으로

    No: Context API는 올바른 선택이 아닙니다. 너무 복잡하게 만들 필요 없습니다. useState나 useReducer를 이용하세요.


    3. 주요 컴포넌트 흐름에서 동떨어져 있는 state인가요?

     

    Yes: 4번 문항으로

    No: Context API는 무난한 선택입니다. Context API는 고유한 context를 가질 수 있는 기능이지만 당신은 그 강점이 아닌 잠재적인 단점을 사용하고 있습니다.


    4. complex state (여러 개의 프로퍼티를 가진 객체와 같은 state)인가요?

     

    Yes: 5번 문항으로

    No: Context API는 적절한 선택입니다. 사용되지 않는 state의 변화로 인한 추가적인 리렌더링 가능성이 적습니다.


    5. consumers(state를 사용하는 컴포넌트)가 state의 일부만 사용하나요?

     

    Yes: 6번 문항으로

    No: Context API는 적절한 선택입니다. 사용되지 않는 state의 변화로 인한 추가적인 리렌더링 가능성이 적습니다.


    6. state가 자주 변하나요?

     

    Yes: Context API는 올바른 선택이 아닙니다. 추가적인 리렌더링이 자주 일어나 불필요한 연산이 수행됩니다. 

    No:  Context API는 무난한 선택입니다. 추가적 리렌더링은 발생하지만 state가 빈번하게 바뀌는 것은 아니기 때문에 무시해도 될 정도라고 할 수 있습니다.




    Client state(UI state)와 Server state를 함께 관리하기 어려운 이유

     

    Redux로 Server state와 UI state 를 함께 관리할 때 , 이런 문제들이 있습니다.


    1. 어플리케이션의 복잡성 증가


    사용자가 물품을 찾고, 장바구니에 담고 계산하는 e-commerce 어플리케이션을 만든다고 할 때, 물품의 상세정보나 재고량 등의 server state와 Redux로 구축한 어플리케이션은 서로 다른 부분에서 사용 하기위해 작성된 서로 다른 타입의 많은 액션과 리듀서를 각각 정의 해주어야 합니다. 많은 보일러플레이트는 Redux store를 크고 복잡하게 만듭니다.


    2. 성능상의 문제


    Server state와 UI state를 함께 관리하는 것은 성능상의 문제를 일으키기도 합니다. Server state가 server에서 fetch되어 Redux store에 저장 한다고 생각해 볼까요? 


    우리가 리스트를 렌더링하는 컴포넌트와 선택된 물품을 렌더링하는 컴포넌트가 각각 존재한다고 가정 했을 때, 하나의 store에 저장된 두 state 중 어느 한 쪽에만 변경이 일어난다 해도 두 컴포넌트 모두 리렌더링 됩니다.

    // 모든 state들이 모여있는 global state의 모습
    
    const state = {
    // Client state theme: "light", sidebar: "off",
    // Server state myPosts:[], myFavoritPosts: [], followers: [], userDetail: {}, messages:[] }

    이런 문제점을 피하기 위해서 class형 컴포넌트에서는 ‘shouldComponentUpdate’를 사용하거나 함수형 컴포넌트에서는 React.memo를 이용해 memoize하여 props가 바뀌었을 때만 리렌더링 해주는 방법이 있습니다. 


    또 다른 방법으로는 Server state와 UI state를 위해 Redux store를 따로 사용하는 방법이 있죠. 하지만 이것은 어플리케이션의 복잡성을 증가시키며, 작은 어플리케이션에 적합하지 않을 수 있습니다. 단일 store architecture 사용을 고집하는 Redux 의 3가지 원칙에도 벗어나죠.


    3. 동기화


    Server state가 변경될 때는 UI state를 업데이트해야 합니다. 하지만 서버에서 데이터를 가져오는데 시간이 걸리거나, 네트워크 상황에 따라 전달이 지연되는 경우 UI와 Server state가 동기화되지 않을 수 있습니다.


    4. 보안


    Server state 에 유저의 결제 정보 등 민감한 정보가 포함될 수 있습니다. 이런 경우 보안적 측면에서 문제가 발생할 수 있습니다. 예를 들어, 서버 상태에 저장된 토큰 정보를 브라우저에서 노출되는 UI state로 전달하면 보안 문제가 발생할 수 있습니다.


    또한 Server state는 Client state에서는 겪지 않는 난제인 데이터 캐싱, 데이터가 오래되었을 때를 알고 처리하는 것, 백그라운드 업데이트를 수행하는 동시에 페이지네이션 및 lazy-loading 같은 성능 최적화를 수행 등을 해결해야 합니다. 이것은 Redux 와 같은 전역 상태 관리 라이브러리에서 효과적으로 수행하기가 매우 어렵습니다.




    React Query의 등장

     

    이미지 출처: React Query

    하지만 Server state 관리 라이브러리인 React Query 의 등장으로 기존 Redux 와 Redux-saga 를 이용해 전역상태와 비동기 처리를 핸들링하던 프로젝트의 client state 와 server state 를 분리하게 되었고, 전역 상태 관리 툴은 오롯이 client-side state 만을 위한 역할에 충실할 수 있게 되었습니다. 무엇보다도 React Query 의 가장 큰 장점은 작성이 쉽고, 간단합니다.


    뿐만 아니라 위에서 언급한 데이터 캐싱, 백그라운드에서의 데이터 업데이트, optimistic update 등 유용한 기능들을 많이 제공하고 있습니다. 



    이러한 이점들 때문에 많은 분들이 전역 상태 관리 툴로 client state와 server state를 함께 관리하던 기존의 구조에서 서버 데이터 관리를 React Query 로 migration 하고 있고, 많은 사람들은 계속해서 이 같은 사례들이 나올 것이라 예측하고 있습니다.


    server state 를 client state 와 분리하게 되면서, 자연스럽게 Redux 보다 가볍고, 쉽게 client state 를 관리할 수 있는 새로운 전역 상태 관리 라이브러리들에게 개발자 분들이 관심을 가지게 되었을 거라고 생각합니다. 


    이어지는 글에서는 다양한 전역 상태 관리 라이브러리에 대해서 알아보는 시간을 가져보겠습니다!




    이어지는 글 바로 가기: State, 슬기롭게 관리하기 (2)








    참고

    React Docs : State: A Component’s Memory

    freeCodeCamp: How to manage state in your react apps

    Geeksforgeeks: what is prop drilling and how to avoid it?

    Jannik Wempe: When to not use react context api for state

    GitNation: React Query: It’s time to break up with your “Global State”!

    Nathan Sebhastian: How and Why you should use React Query

    Redux: Flux

    MobX: 10분만에 알아보는 MobX와 React

    Recoil Docs: Recoil official

    Reaktor: is Recoil what will kill Redux?

    Fernando Doglio: Let’s talk about Zustand (A converstation with Daishi Kato)

    다양한 경험을 하고 그 가치를 함께 나누는 것을 좋아합니다. 오픈소스컨설팅 프론트엔드 개발자 노은지입니다.

    Leave a Reply

    Your email address will not be published. Required fields are marked *