ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Kent C. Dodds] 어플리케이션 상태 관리 (Application State Management with React 한글 번역)
    Frontend 2021. 5. 9. 21:25

    Photo by  Rene Böhmer

     

    올 해 1월부터 바닐라코딩 부트캠프 6기를 같이 수료한 몇몇 사람들과 시작한 스터디가 있는데,

    바로 영어로 된 개발 블로그 글을 번역하고 스터디 멤버들과 함께 공유하는 스터디이다.

     

    이런 스터디를 구상한 이유는 이리 저리 돌아다니면서 요즘 핫하다는 블로그 글을 수집은 많이 하는데 정작 잘 읽지는 않아서이다.

    좋은 글을 꾸준히 많이 읽는 것은 정말 개발자에게 특히 중요한 일이지만 혼자서 하면 작심 삼일이 되고 잘 안하게 되기 마련이다.

    그래서 시작한 스터디인데 생각보다 많은 글들을 읽게 되어 만족도가 높다.

     

    현재까지 번역해서 노션에 정리한 글이 7개 정도 된다.

    물론 1월 중순부터 시작한 것 치고 많지 않아보일 수도 있지만 어쨌든 중요한건 꾸준히 계속 하고 있다는 것이니까!

    오늘은 번역한 글들 중에서 정말 마음에 드는 글을 블로그에도 올려보려고 한다.


     

    원문 링크:

    kentcdodds.com/blog/application-state-management-with-react

     

    Application State Management with React

    How React is all you need to manage your application state

    kentcdodds.com

    오역이 있을 수 있습니다. 코멘트로 남겨주시거나 메일 주시면 감사하겠습니다~!

     

     

    어떻게 리액트가 여러분의 어플리케이션 상태를 관리하는데 필요한 전부인지 알아보자.

     

    어떤 어플리케이션이든 상태 관리는 틀림없이 가장 어려운 부분이다. 이 것이 바로 수많은 상태 관리 라이브러리들이 존재하고 매일 더 많은 라이브러리들이 생겨나는 이유이다. (그리고 심지어 일부는 다른 라이브러리들을 기반으로 생겨났다. npm에는 수백개의 추상화된 "더 쉬운 redux" 가 존재한다.) 상태 관리가 가장 어려운 문제라는 사실에도 불구하고, 나는 상태 관리를 어렵게 하는 이유 중에 하나는 종종 문제를 해결하기 위해 과한 엔지니어링(over-engineer)을 하기 때문이라고 생각한다.

     

    내가 개인적으로 리액트를 사용하면서 오랫동안 도입해보려고 시도했던 상태 관리 솔루션 하나가 있는데, 리액트 훅의 출시와 함께 (그리고 엄청난 리액트 context 향상과 함께) 이 상태 관리법은 대폭 단순화되었다.

     

    우리는 종종 리액트 컴포넌트를 어플리케이션을 구축하기 위한 레고 블록으로 비유한다. 그리고 사람들은 이 얘기를 들으면, 왜인지는 모르겠지만 이 비유가 상태 측면을 배제한다고 생각한다. 상태 관리 문제를 해결하기 위한 나의 개인적인 해결책에 숨어있는 "비밀"은 우리의 어플리케이션 상태가 어떻게 어플리케이션의 트리 구조를 그리는지를 생각하는 것이다.

     

    redux가 크게 성공할 수 있었던 이유 중에 하나는 react-redux가 prop drilling 문제를 해결했다는 사실에 있다. 단순히 컴포넌트를 마법같은 connect 함수에 넘기는 방법으로 트리의 다른 부분들을 넘어 데이터를 공유할 수 있다는 사실은 정말 멋진 것이었다. redux가 reducers/action creators/기타 등등을 사용하는 것 또한 멋지지만, 나는 redux가 여기 저기 널리 퍼질 수 있었던 이유가 개발자를 위해 prop drilling이라는 큰 불편함을 해소했다는 데 있다고 확신한다.

     

    다음에 적는 것이 바로 내가 지금껏 오직 하나의 프로젝트에서만 redux를 사용했던 이유이다: 나는 지금까지 지속적으로 개발자들이 그들이 사용하는 모든 상태를 redux에 넣는 것을 보았다. 전역 어플리케이션 상태뿐만 아니라 지역 상태까지 말이다. 이는 정말 많은 문제들을 야기했는데, 그 중 가장 중요한 문제는 다음과 같다. 여러분이 어떤 상태의 상호작용을 유지하고 있을 때, 이 상태 인터렉션은 reducer와 action creator, type, 그리고 dispatch 호출들과도 상호작용하며, 궁극적으로 무슨 일이 일어났는지, 그리고 이 일이 나머지 코드베이스에 어떤 영향을 미치는지를 파악하려면 머리속으로 여러 파일들을 열고 코드 속에서 흔적을 추적해야만 한다.

     

    확실히 하자면, 글로벌한 상태를 두는 것 자체는 정말이지 문제가 없다. 그러나 간단한 상태(예를 들면 모달이 열리는지 안열리는지를 결정하는 상태 또는 form의 input 값을 가지는 상태)를 글로벌하게 두는 것은 큰 문제다. 설상가상으로 이는 확장성에 별로 좋지 않다. 당신의 어플리케이션이 커질수록, 이 문제는 점점 더 커져버린다. 물론 여러분은 다른 reducer들을 연결해서 어플리케이션의 여러 다른 부분들을 관리할 수도 있지만, 모든 action creator와 reducer를 고려하면서 이런 간접적인 조치를 하는 것은 최적의 방법이 아니다.

     

    비록 여러분이 Redux를 사용하지 않더라도, 여러분의 모든 어플리케이션 상태를 하나의 객체로 관리하는 것 또한 다른 문제들을 야기할 수 있다. 리액트의 <Context.Provider>가 새로운 값을 가져오면 해당 값을 사용하는 모든 컴포넌트들은 설사 데이터의 작은 일부분만을 담당하는 함수형 컴포넌트라고 할지라도 다시 업데이트되고 render되어야 한다. 이 것은 잠재적인 성능 이슈를 만들 수 있다. (React-Redux v6는 훅(hook)과는 잘 작동하지 않을거라는 것을 인지하기 전까지는 이러한 접근법을 사용하려고 했었고, 이러한 이슈들을 해결하기 위해 v7에서는 다른 접근법을 사용해야만 했다.) 여기서 나의 포인트는 여러분이 만약 상태를 더 논리적으로 분리하고 리액트 트리 상에서 해당 상태를 사용하는 곳 가까이에 위치시킨다면 이러한 문제들을 겪지 않을 거라는 점이다.


    여기 더 놀라운 것이 있다. 만약 여러분이 리액트로 어플리케이션을 구축한다면, 이미 여러분의 어플리케이션에는 상태 관리 라이브러리가 설치되어 있다. 심지어 여러분은 npm install이나 yarn add를 실행할 필요도 없다. 이 것은 여러분이 만든 어플리케이션 사용자들로부터 어떠한 추가적인 바이트도 소모하지 않으며, npm에 존재하는 모든 리액트 패키지들과 잘 융합되고, 리액트 팀에 의해 이미 훌륭하게 문서화되어 있다. 바로 리액트 자체 말이다.

    리액트는 상태 관리 라이브러리이다

     

    여러분이 리액트 어플리케이션을 구축한다면, 여러분은 <App />에서 시작되어 <input />들이나 <div />, 그리고 <button />들에서 끝나는 컴포넌트 트리를 만들기 위해 다수의 컴포넌트들을 조립할 것이다. 여러분은 어플리케이션이 렌더링하는 낮은 단계의 모든 합성 컴포넌트들을 중앙의 한 장소에서 관리하지는 않을 것이다. 대신에, 각각의 개별적인 컴포넌트가 그것들을 관리하도록 둘 것이고, 이 것이 UI를 구축하는데 가장 효과적인 방법이 될 것이다. 그리고 이 방법을 여러분의 어플리케이션 상태에도 적용할 수 있는데, 바로 여러분이 오늘 한 것과 비슷한 것이다:

     

    function Counter() {
      const [count, setCount] = React.useState(0)
      const increment = () => setCount(c => c + 1)
      return <button onClick={increment}>{count}</button>
    }
    
    function App() {
      return <Counter />
    }

    Edit Application State Management with React

     

    내가 지금 여기서 말하는 모든 것들은 클래스 컴포넌트에도 적용된다는 사실을 기억하자. 훅(Hook)은 단지 모든 일들을 조금 더 쉽게 만들어줄 뿐이다. (잠시후에 살펴 볼 context는 특히나 그렇다.)

     

    class Counter extends React.Component {
      state = {count: 0}
      increment = () => this.setState(({count}) => ({count: count + 1}))
      
      render() {
        return <button onClick={this.increment}>{this.state.count}</button>
      }
    }

     

     

    "알겠어요, Kent, 물론 하나의 컴포넌트에서 관리되는 하나의 상태 요소만 가지는 일은 쉽겠죠. 그런데 상태를 여러 컴포넌트들에 공유해야할 때 당신은 어떻게 하나요? 예를 들어 내가 다음과 같은 일들을 하길 원한다면요?"

     

    function CountDisplay() {
      // `count`는 어디에서 오는가?
      return <div>The current counter count is {count}</div>
    }
    
    function App() {
      return (
        <div>
          <CountDisplay />
          <Counter />
        </div>
      )
    }

     

     

    "count는 <Counter />안에서 관리됩니다. 나는 이제 <CountDisplay />에서 count 값에 접근하고, 그 다음 이 값을 <Counter />안에서 업데이트하기 위해 상태 관리 라이브러리가 필요해요!"

     

     

    이 문제의 해답은 리액트 그 자체만큼이나 오래되었고 (혹은 더 오래되었을 수도?) 내가 기억하는 한 계속 공식 문서에 있어왔다: 상태 위로 올리기(Lifting State Up)

     

    "상태 위로 올리기"는 리액트에서 상태 관리 문제를 위한 적법한 답변이고 매우 단단한 방법이다. 이 것을 다음 상황에 어떻게 적용하는지 살펴보자.

     

    function Counter({count, onIncrementClick}) {
      return <button onClick={onIncrementClick}>{count}</button>
    }
    
    function CountDisplay({count}) {
      return <div>The current counter count is {count}</div>
    }
    
    function App() {
      const [count, setCount] = React.useState(0)
      const increment = () => setCount(c => c + 1)
      return (
        <div>
          <CountDisplay count={count} />
          <Counter count={count} onIncrementClick={increment} />
        </div>
      )
    }

    Edit Application State Management with React

     

    우리는 방금 전에 누가 상태에 책임이 있는지를 바꿔버렸고, 이 것은 매우 간단하다. 그리고 우리는 상태들을 계속해서 앱의 가장 위쪽으로 완전히 올려버릴 수 있다.

     

    "물론이죠 Kent, 좋아요, 그렇지만 prop drilling 문제는 어떻게할 건가요?"

     

    좋은 질문이다. 이 문제에 맞서는 여러분의 첫 방어진은 여러분의 컴포넌트를 구성하는 방법을 바꾸는 것이다. 컴포넌트 합성을 이용해보자.

     

     

    아마도 다음과 같은 것 대신에:

    function App() {
      const [someState, setSomeState] = React.useState('some state')
      return (
        <>
          <Header someState={someState} onStateChange={setSomeState} />
          <LeftNav someState={someState} onStateChange={setSomeState} />
          <MainContent someState={someState} onStateChange={setSomeState} />
        </>
      )
    }

     

    당신은 이런 방식을 사용할 수 있을 것이다:

    function App() {
      const [someState, setSomeState] = React.useState('some state')
      return (
        <>
          <Header
            logo={<Logo someState={someState} />}
            settings={<Settings onStateChange={setSomeState} />}
          />
          <LeftNav>
            <SomeLink someState={someState} />
            <SomeOtherLink someState={someState} />
            <Etc someState={someState} />
          </LeftNav>
          <MainContent>
            <SomeSensibleComponent someState={someState} />
            <AndSoOn someState={someState} />
          </MainContent>
        </>
      )
    }

     

    만약 위 예시가 명확히 이해되지 않는다면 (왜냐면 위 예시는 매우 억지로 꾸며낸 것이기 때문이다), 내가 말한 것이 어떤 의미인지 여러분이 분명하게 알 수 있도록 도와주는 Michael Jackson 멋진 비디오들을 봐도 좋다.

     

    그럼에도 불구하고 결국 합성조차도 당신이 원하는 일을 하지 못할 수 있고, 그랬을 때 다음 단계는 리액트의 Context API로 뛰어드는 것이다. 이는 실제로 오래도록 "해결책"이 되어주었지만, 오랫동안 "비공식적인" 해결책이기도 했다. 내가 말했듯이, 많은 사람들은 react-redux로 접근했다. 왜냐면 이 것이 사람들이 리액트 문서의 경고 문구를 걱정하지 않고도 적용할 수 있으면서, 내가 언급한 매커니즘을 이용하여 문제를 해결했기 때문이다. 그러나 이제 context는 리액트 API에서 공식적으로 지원되며, 우리는 별다른 문제 없이 바로 이 기능을 사용할 수 있다.

     

    // src/count/count-context.js
    import * as React from 'react'
    const CountContext = React.createContext()
    function useCount() {
      const context = React.useContext(CountContext)
      if (!context) {
        throw new Error(`useCount must be used within a CountProvider`)
      }
      return context
    }
    function CountProvider(props) {
      const [count, setCount] = React.useState(0)
      const value = React.useMemo(() => [count, setCount], [count])
      return <CountContext.Provider value={value} {...props} />
    }
    export {CountProvider, useCount}
    // src/count/page.js
    import * as React from 'react'
    import {CountProvider, useCount} from './count-context'
    function Counter() {
      const [count, setCount] = useCount()
      const increment = () => setCount(c => c + 1)
      return <button onClick={increment}>{count}</button>
    }
    function CountDisplay() {
      const [count] = useCount()
      return <div>The current counter count is {count}</div>
    }
    function CountPage() {
      return (
        <div>
          <CountProvider>
            <CountDisplay />
            <Counter />
          </CountProvider>
        </div>
      )
    }

    Edit Application State Management with React

     

     

    노트: 이 특정한 코드 예시는 매우 인위적이며 나는 여러분들이 이러한 특정한 시나리오를 해결하기 위해 context에 바로 도달하는 것을 추천하지 않는다. 왜 prop drilling이 반드시 문제인 것만은 아니고, 오히려 종종 바람직한 일인지 더 잘 이해하기 위해 Prop Drilling 글을 읽어보길 바란다. context에 너무 빨리 접근하지 말길!

     

    이 접근법의 멋진 점은 우리의 useCount hook에 상태를 업데이트하기 위한 모든 공통적인 로직들을 넣을 수 있다는 것이다.

     

    function useCount() {
      const context = React.useContext(CountContext)
    
      if (!context) {
        throw new Error(`useCount must be used within a CountProvider`)
      }
    
      const [count, setCount] = context
      const increment = () => setCount(c => c + 1)
    
      return {
        count,
        setCount,
        increment,
      }
    }

    Edit Application State Management with React

     

    그리고 당신은 또한 이 로직을 useState 대신 useReducer로 쉽게 변경할 수도 있다.

     

    function countReducer(state, action) {
      switch (action.type) {
        case 'INCREMENT': {
          return {count: state.count + 1}
        }
        default: {
          throw new Error(`Unsupported action type: ${action.type}`)
        }
      }
    }
    
    function CountProvider(props) {
      const [state, dispatch] = React.useReducer(countReducer, {count: 0})
      const value = React.useMemo(() => [state, dispatch], [state])
      return <CountContext.Provider value={value} {...props} />
    }
    
    function useCount() {
      const context = React.useContext(CountContext)
    
      if (!context) {
        throw new Error(`useCount must be used within a CountProvider`)
      }
    
      const [state, dispatch] = context
      const increment = () => dispatch({type: 'INCREMENT'})
    
      return {
        state,
        dispatch,
        increment,
      }
    }

    Edit Application State Management with React

     

    이 것은 우리에게 엄청나게 많은 유연성을 주고, 매우 복잡도를 매우 낮춰준다. 이 때 위와 같은 방법으로 일을 진행할 때 기억해야 할 몇 가지 중요한 것들이 있는데 다음과 같다:

     

    1. 어플리케이션의 모든 것들이 하나의 상태 객체를 필요로 하지는 않는다. 모든 것들을 논리적으로 분리하자. (유저 설정 값들이 반드시 알림과 같은 context 내에 위치해야만 하는 것은 아니다.) 여러분은 이러한 관점으로 여러 개의 provider를 사용해야할 것이다.
    2. 여러분의 모든 context를 전역에서 접근 가능하도록 만들 필요는 없다! 상태를 가능한 한 그 상태를 필요로 하는 곳 근처에 두자.

     

    2번째 관점을 조금 더 살펴보자. 당신의 앱 트리는 대충 다음과 같이 보여질 수 있다:

     

    function App() {
      return (
        <ThemeProvider>
          <AuthenticationProvider>
            <Router>
              <Home path="/" />
              <About path="/about" />
              <UserPage path="/:userId" />
              <UserSettings path="/settings" />
              <Notifications path="/notifications" />
            </Router>
          </AuthenticationProvider>
        </ThemeProvider>
      )
    }
    
    function Notifications() {
      return (
        <NotificationsProvider>
          <NotificationsTab />
          <NotificationsTypeList />
          <NotificationsList />
        </NotificationsProvider>
      )
    }
    
    function UserPage({username}) {
      return (
        <UserProvider username={username}>
          <UserInfo />
          <UserNav />
          <UserActivity />
        </UserProvider>
      )
    }
    
    function UserSettings() {
      // 이 것은 아마도 AuthenticationProvider와 연관된 hook일 것이다
      const {user} = useAuthenticatedUser()
    }

     

    각각의 페이지가 해당 페이지 내부 컴포넌트들이 필요로하는 데이터를 가진 고유한 provider를 가질 수 있다는 점을 주목하자. Code splitting은 이런 것에 그냥 딱 작동한다. 당신이 각 provider에 어떻게 데이터를 넣는지는 해당 provider가 사용하는 hook에 달려있고, 또한 당신이 어플리케이션에 데이터를 어떻게 불러오는지에 달려있다. 하지만 여러분은 그것들이 (provider 안에서) 어떻게 작동하는지 알아 볼 시작점을 알고 있다.

     

    왜 동일 장소 배치(colocation)가 유익한지에 대해 더 많이 알고 싶다면, "상태 동일 장소 배치는 당신의 리액트 앱을 더 빠르게 만듭니다"와 "동일 장소 배치(Colocation)"라는 블로그 글을 읽어보아라. 그리고 context에 더 많이 알고 싶다면 "리액트에서 효과적으로 context를 사용하는 법"이라는 글을 읽어보아라.

     

    Server Cache vs UI State (서버 캐시 vs UI 상태)

    마지막으로 덧붙이고 싶은 한 가지가 있다. 세상에는 다양한 종류의 상태가 있지만, 모든 상태의 타입은 (다음) 두 개 중 한 가지에 해당한다:

     

    1. 서버 캐시 - 사실상 서버에 저장되어 있고 빠른 접근을 위해 클라이언트에 저장하는 상태. (유저 데이터와 같은 것)
    2. UI 상태 - 오로지 우리 앱의 인터렉션을 제어하기 위한 UI에서만 유용한 상태. (모달의 isOpen 과 같은 것)

     

    우리는 두 가지를 하나로 합치면서 실수를 범한다. 서버 캐시는 본질적으로 UI 상태와는 다른 문제이고 따라서 다른 방법으로 관리되어야만 한다. 만약에 여러분이 가지고 있는 것이 사실은 상태값이 아니라 상태값의 캐시라는 사실을 받아들인다면, 여러분은 이제 그것들을 적절하게 생각하기 시작할 것이고, 따라서 적절하게 관리할 수 있을 것이다.

     

    여러분은 물론 올바른 useContext를 여기 저기에 사용하며 여러분만의 useState나 useReducer를 통해 스스로 이 것을 관리할 수 있다. 그렇지만 나는 여러분이 바로 본론에 들어갈 수 있도록 돕기 위해 다음과 같이 말하려고 한다. 캐싱은 정말 어려운 문제이고 (몇몇 사람들은 캐싱이 컴퓨터 공학 사상 가장 어려운 것이라고 말한다) 그렇기 때문에 이 문제에는 다른 사람이 이미 만들어둔 훌륭한 업적들을 활용하는 것이 현명할 수 있다고 말이다.

     

    이 것이 바로 내가 이런 종류의 상태를 다루기 위해 react-query를 사용하고 추천하는 이유이다. 물론 내가 (위에서) 상태 관리 라이브러리가 필요없다고 말했다는 것을 알고 있다. 하지만 나는 react-query가 상태 관리 라이브러리라고는 별로 생각하지 않는다. 나는 이 것이 캐시(Cache)를 다루는 라이브러리라고 생각한다. 그리고 이건 정말 끝내 주게 좋은 라이브러리다. 한 번 살펴보라! Tanner Linsley는 정말 똑똑한 친구다.

     

    What about performance? (성능 문제는 어떤가요?)

    만약 여러분이 위의 조언들을 따른다면, 성능은 거의 문제가 되지 않을 것이다. 특히 colocation에 대한 조언을 따른다면 말이다. 그러나 성능이 문제가 될 수 있는 사례들도 분명히 존재한다. 만약 상태와 관련해 성능 문제를 겪고 있다면, 가장 먼저 확인해야하는 것은 얼마나 많은 컴포넌트들이 상태 변화로 인해 re-render가 되는지와 해당 컴포넌트들에게 정말로 그 상태 변화로 인한 re-render가 필요한 것인지 여부를 알아내는 것이다. 만약 (re-render가) 꼭 필요한 것이라면, 성능 문제는 더 이상 상태 관리를 위한 메커니즘과는 상관이 없고, render 속도와 상관이 있어진다. 그리고 그런 경우에 여러분은 render 속도를 높일 필요가 있다.

     

    그러나 만약 DOM 업데이트가 없거나 사이드 이펙트를 필요로 하지 않는데도 렌더링되고 있는 컴포넌트가 많다는 것을 발견했다면, 이 컴포넌트들은 불필요하게 렌더링되고 있는 것이다. 이런 일들은 React에 항상 일어나는 일이고 이 자체로는 보통 문제가 되진 않는다 (그리고 당신은 심지어 이런 불필요한 re-render가 더 빨라지도록 만드는 데 먼저 집중해야 한다). 그러나 이 문제가 정말 장애 요소라면, 여기 React context에서의 상태 문제를 해결하는 몇 가지 소소한 접근 방법들이 있다:

     

    1. 당신의 상태를 하나의 큰 저장소 대신 몇 개의 다른 논리적인 조각들로 분리하라. 그러면 어떤 부분적인 상태의 변화가 앱의 모든 컴포넌트 업데이트를 유발시키진 않는다.
    2. 당신의 context provider를 최적화 시켜라.
    3. jotai를 사용하자.

     

    라이브러리 하나를 추천했다. 사실 리액트에 내장된 상태 관리 추상화가 잘 맞지 않는 유즈 케이스도 가끔은 존재한다. 이러한 유즈 케이스들에는 모든 사용 가능한 추상화 라이브러리 중에서 jotai가 가장 괜찮다. 이런 유즈 케이스가 어떤 경우를 말하는지 궁금하다면, jotai가 어떤 문제들을 해결했는지 아주 잘 설명된 글, 'Recoil: State Management for Today's React - Dave McCabe aka @mcc_abe at @ReactEurope 2020'을 읽어보자. Recoil과 jotai는 매우 비슷하다. (그리고 비슷한 타입의 문제를 해결한다.) 그러나 나의 (한정된) 사용 경험을 근거로 나는 jotai를 선호한다.

     

    어쨌든, 대부분의 앱들은 recoil이나 jotai같은 atomic한 상태 관리 툴을 필요로 하지 않는다.

     

    Conclusion (결론)

    다시 말하지만, 이것들은 여러분이 클래스 컴포넌트를 사용해도 해볼 수 있는 것들이다. (여러분이 꼭 hook을 사용할 필요는 없다.) Hook은 이 문제들을 훨씬 더 쉽게 만들지만, 이러한 철학을 React 15와도 아무 문제 없이 적용할 수 있다. 상태를 가능한 한 지역적으로 두고, prop drilling이 정말 문제가 된다면 오직 context만 사용하라. 이렇게 사용하는 것이 여러분의 상태 인터렉션 관리를 더욱 쉽게 만들어준다.

    반응형

    COMMENT