전역 상태를 사용하는 기준이 있으신가요? #5
Replies: 3 comments 4 replies
-
저의 경우에는 도입을 고민하는 포인트가 크게 3가지 있는데요.
위 세 가지가 모두 충족한다면 도입을 하는 편이에요. 장점으로는 아래와 같아요
코멘트를 달면서도 한번 더 생각을 해보게 되네요 |
Beta Was this translation helpful? Give feedback.
-
일단 상태의 종류가 다양하기 때문에, 프롭 내리기가 귀찮다는 모호한 기준만으로 모든 걸 전역 상태 vs 지역 상태로 나누면 위험하다고 생각합니다. 전역 상태와 지역 상태는 생명주기도 다르고 용도도 다르기 때문이죠. 무엇보다 전역 상태라고 프롭 내리기를 하지 않고 필요한 곳에 바로 꽂아 넣는 게 항상 좋은 것도 아닙니다. vue나 svelte에서도 비슷한 고민을 하겠지만 일단 react를 기준으로 이야기를 해보겠습니다. 간단하게만 분리해보자면 이런 식으로 나눌 수 있을 거에요.
1. 서버가 진실의 원천이고 동기화되는 상태대부분 전역 상태일 수 밖에 없습니다. 보통 서버 상태가 컴포넌트마다 다르지는 않고요. 컴포넌트마다 다른 상태를 구독하더라도, 어쨌든 데이터의 원천은 외부에 있으니까요. 탠스택 쿼리 문서에 잘 나와 있습니다 그래서 보통 tanstack query나 swr 등을 이용해서 on-demand로 가져오고 캐시를 최신화하거나, loader 등을 이용해서 페이지 단위로 페칭하거나, server component 등을 이용해서 주입할 수도 있을 거에요. 하지만 loader 같은 경우가 아니더라도, 특정 컴포넌트가 특정 서버 리소스에 바로 의존하게 하면, 다양한 용도로 사용하기 어려워집니다. 예를 들어 hook을 사용하면 client component가 되기 쉬운데, 서버에서 렌더해야 하는 경우에는 사용하기 어려워요. 스토리북이나 테스트 등의 환경에서 tanstack-query나 fetch를 모킹하는 건 그거 props로 데이터를 넘기는 것보다 훨씬 어렵습니다. relay 같은 솔루션으로 각 컴포넌트의 data 의존성을 명확하게 하고 보장해준다고 해도, 이건 또 (백엔드를 포함한) 팀이 GraphQL과 relay를 쓰고 이해해야한다는 의존성이 강하게 따라 붙습니다. 그런 이유로 저는 서버와 동기화되는 tanstack-query 등의 상태라고 해도 보통 props로 내리는 걸 두려워하지 않는 편입니다. 2. 브라우저에 저장되는 상태이 친구들도 그 특성상 전역 상태일 수 밖에 없습니다. 브라우저의 로컬스토리지나 쿠키 등도 앱 외부에 있기 때문입니다. (마치 백엔드 웹 애플리케이션 서버에게 os의 다른 프로세스나 파일 시스템, DB가 외부이듯이요) 하지만 위에서 말한 것처럼 스토리북과 테스트, 서버 사이드 렌더링 등의 환경을 생각하면... 이러한 데이터 소스도 컴포넌트 외부에서 주입해주는 게 나은 경우가 많았습니다. (서버와 로컬 스토리지를 공유할 수는 없잖아요?) 그리고 브라우저에 있는 상태는, 이런저런 요구사항을 따라서 서버로 옮겨가는 경우도 많으니까요. (설정을 여러 기기 간에 동기화한다거나요) 보통 자주 바뀌지 않으니 Context 로 내려주는 정도는 가능하다고 생각합니다. 보통 페이지 최상단에서 가져와서 컴포넌트로 내리게 됩니다. 3. 라우팅 상태history, path variable, search params, hash 같은 값들도 그 특성상 전역 상태인 경우가 많습니다. 다만 위의 1,2 와 달리 특정 페이지로 이동하기만 하면 쉽게 재현하고 복원할 수 있다는 장점이 있습니다. 앱 외부에서 우리 앱의 특정 부분으로 보내는데 사용하기도 해서... route에 상태를 집어넣고, 순수하게 페이지가 결정되게 하면 여러모로 유리합니다. 다만 지역 상태와 이런 routing 상태를 상호동기화하기는 보통 어렵기 때문에, 라우팅 상태를 진실의 원천으로 삼으면 좋습니다. 그래서 라우팅 상태는 보통 일시적이고, 생명주기가 짧습니다. 1번이나 2번처럼 영속되는 상태는 페이지를 이탈하거나 할 때 지워줘야 하는 등의 어려움이 있는데요. 라우팅 상태는 페이지만 이탈하면 사라지기 때문에 편하죠. 하지만 필터나 정렬 등이 다시 돌아왔을 때 유지되기를 바란다거나 하면... 결국 1번이나 2번으로 영속도 같이 해주고 동기화도 해줘야 해서 번거로워지기 쉽습니다. 하지만 여전히 라우팅 상태도 직접 꽂아넣는 게 보통 좋은 생각은 아닙니다. storybook이나 test 환경은 보통 iframe 등 안에서 history를 이미 사용하고 있는 경우가 많습니다. router에 의존하게 되면 이런 환경을 재현하기 어려워서, in memory router나 next-router-mock 같은 걸 직접 만들어야 하는 번거로움이 커집니다. 게다가 다양한 페이지에서 재사용 하는 컴포넌트에서는 특히 안 좋은 선택입니다. 페이지마다 같은 상태의 이름이 다를 수도 있고요. router는 서버에서는 보통 request나 전용 함수로, 클라이언트에서는 hook 을 이용해서 주입하는데. 당연하게도 hook을 사용하면 ssr/ssg 는 포기해야 하는 거죠. 1,2 에서도 반복되는 이야기지만 SSR에 더해 서버 컴포넌트가 나오면서 과거 presentation 컴포넌트 패턴을 다시 살려내라는 것인가 좀 불만스러운 부분이 있습니다. 4. 폼의 상태지역 상태의 표본입니다. 리덕스 베스트 프랙티스에서는 대부분의 경우 폼 상태를 전역 상태 라이브러리에 넣지 말라고 분명하게 이야기합니다. 폼 상태는 보통 그 페이지를 이탈하면 사라집니다. 임시 저장 등의 기능이 있더라도 이는 일시적으로 저장하고 복원하는 거지 보통 실시간으로 동기화되진 않습니다. 폼 상태의 초기값으로 123 등의 상태가 필요하다면 보통 initial 값으로 주입하기 마련입니다. 그럼에도 폼 상태를 전역 상태로 집어넣기를 바란다면, 보통 react의 특수한 성능 문제나, 필드가 많아지면서 props를 올리기 내리기가 부담스러워진 게 아닐까 싶습니다. 하지만 다시 반복하듯이, props 내리기와 전역 상태는 별개의 문제입니다. 이 문제는 ContextAPI로는 전혀 해결되지가 않습니다. 폼 전체의 상태가 상위에 있게 되면, 사용자가 한 글자를 칠 때마다 폼 전체가 리렌더 되는데요. 이는 상당 수의 리액트 앱이 느리고 버벅이게 만드는 성능 문제의 주범입니다. 보통 react-hook-form이나 final-form, formik 등의 폼 라이브러리를 사용하면 지역 상태로 폼 상태를 만들게 됩니다. 그러면 폼 단위에서 provider로 아래의 field 컴포넌트들에 폼의 store를 내려줄 수 있습니다. form의 store는 보통 내부는 가변이지만, 이 store 자체의 참조는 변하지 않기 때문에 폼 전체의 리렌더를 일으키지 않습니다. 각 Field 컴포넌트는 자신이 관심을 가지는 필드의 상태만 선택해서 구독합니다. 이러면 prop 내리기의 고통 없이 복잡한 폼도 쉽게 만들 수 있게 되는 경우가 많습니다. 그러면 각 필드는 독립되어 응집도를 가지고 처리할 수 있습니다. (예시 : https://github.com/twinstae/coaching-sospeso/blob/main/src/components/SospesoApplyingForm.tsx) 이러한 방식의 문제는 당연하게도 여러 필드의 상태가 엮인 경우는 어떻게 하냐는 것인데. 보통 여러 필드가 엮어져 있더라도 모든 폼의 상태를 필요로 하는 건 아니기 때문에 큰 문제는 없었습니다. 5. 그 외에 클라이언트의 상태여기에는 보통 특정한 컴포넌트의 캡슐화 가능한 상태들이 들어갑니다. 예를 들자면 모달이 열리고 닫힌다거나 (overlay-kit을 사용할 것 같은), collapsible한 아코디언 등이 열리고 닫힌다거나, 캐로셀이 알아서 넘어간다거나, hover나 focus, scroll 되는 상태 같은 것들 말이죠. 이런 상태들은 보통 생명주기가 매우 매우 짧고, 영속될 필요가 아마 없습니다. (스크롤 복원 등의 상황이 아니라면?) 메모리 안에서 태어나 메모리 안에서 사라지는 경우가 많습니다. 그래서 uncontrol 즉 통제하지 않고 그냥 컴포넌트 안에서 알아서 내버려 두게 하면 편합니다. 하지만 이런 상태를 1,2,3 등으로 외부에서 통제(control)하고 싶은 경우가 자주 생깁니다. 기억나는 사례로는 특정 url로 들어오면 특정 list item으로 가서 아코디언을 열어달라거나. 상세 페이지에 들어갔다 돌아오면 scroll을 복원해달라는 식의 요구사항이었습니다. 이런 경우에는 상태 영속이 필요하기 때문에 1,2,3 등으로 가야하는 거죠. 그런 경우에는 폼과 비슷하게, 이런 상태를 초기값으로 외부에서 설정할 수 있게 하고, 그 후로는 단방향으로 업데이트 되게하면 좋습니다. 아니면 한 상태에서 다른 상태가 파생되게 하고, CSS로 선언적으로 처리하거나, 외부 헤드리스 컴포넌트 라이브러리 등을 활용하면 쉽게 풀리는 경우가 매우 많았습니다. props 내리기에 대하여이러나 저러나 props을 내리는 건 여러모로 고민이 되는 문제입니다. 자식 컴포넌트에 필요한 의존성을 부모에서 알아야 한다는 건 좀 번거롭지만, 어떤 의존성이 있는지 명확히 숨겨지지 않고 보이는 건 장점입니다. 당연히 타입 체크도 되지요. 마치 Option이나 Result 같은 모나드 타입이 숨겨진 부수효과를 명확히 드러내듯이요. poor man's DI 라는 말이 있는데요. 생성자나 매개변수로 넘기는 건 직관적이고 단순한 의존성 주입 방법이기도 합니다. 서버와 클라이언트 모두에서 쉽게 사용할 수 있고요. storybook이나 test 환경도 쉽게 지원할 수 있습니다. 그럼에도 한 컴포넌트에 의존성 props를 5개 6개씩 넘기거나, 3단계 4단계 씩 내리거나, 복잡한 의존성 그래프를 만들게 되면... provider나 전역 상태를 직접 꽂고 싶은 유혹이 커질 것입니다. 저라면 의존성을 props로 주입 받는 컴포넌트를 만들고, 바로 윗 단계의 적절한 단위에서 주입하는 식으로 타협하기도 합니다만. 보통은 캡슐화만 잘해도 그런 고통은 많이 사라졌습니다. (애초에 하나의 개발자가 5, 6개 계층을 모두 가로질러 작업하는 게 문제는 아닐까요? 디자인 시스템이나 공용 컴포넌트로 처리할 문제를 애플리케이션 개발을 하면서 고민하진 않을까요?) 마지막으로 다시 정리하지만, props 내리기의 고통은 전역 상태보다는 의존성 주입 방식의 고민에 가깝다고 생각합니다. ContextAPI가 있으니 전역 상태 라이브러리가 필요가 없다는 식의 오개념이 자꾸만 퍼지는데요. ContextAPI 는 상태 관리 도구가 아니라 의존성 주입 도구로 보는 게 맞고, 의존성 주입 도구들의 네이밍 컨벤션을 따르고 있습니다. 리덕스나 jotai 등도 프로바이더를 쓴다고 하지만, 대부분 store를 동적으로 주입하기 위한 용도일 뿐입니다. Jotai는 그래서 global store를 가지고 providerless 모드로 쓸 수 있고요. 애초에 앱의 핵심 기능을 ContextAPI 처럼 특정 프레임워크의 한정된 기능에 종속시킨다면... Context 를 서버에서 쓸 수 있게 되기 전까지는 서버에서 주입할 방법이 없다거나 하는 이슈를 계속 겪게 될 겁니다. (Context가 만능인 줄 알고 살다가 server component를 만나고 엉엉 울었다) 어떤 경우에는 build 툴의 resolve alias를 사용하는 것도 좋을 것이고요. overlay-kit 처럼 기존에 번거롭게 만들던 isOpen 같은 전역 상태를 깔끔하게 캡슐화해주는 선언적인 라이브러리를 만드는 것도 도움이 될 것입니다. prefetch나 suspense, error boundary 등을 이용해서 loading 등의 상태를 제거하고, 앱 위로 올려버릴 수도 있고요. 다만 이는 상태를 어디에 둘 것인가의 고민과는 다르게 생각해봐야 할 것입니다. (의존성 주입에 대해 썻던 글을 첨부합니다 https://tech.wonderwall.kr/articles/functionaldependencymanagement/ ) |
Beta Was this translation helpful? Give feedback.
-
depth를 기준으로 전역 상태를 사용할지 여부를 결정하면 안 될 것 같습니다. 저는 react에서 전역 상태 관리 라이브러리 전성 시대가 열린 것이 과거 리액트의 행적과 큰 연관이 있다고 생각이 드는데요. redux 전성시대과거 훅도 없고 SPA라는 개념도 낯설던 시절, 리액트라는 작은 라이브러리로만 서비스의 모든 상태를 관리하는 것은 부담으로 다가왔어요. 심지어 state에만 저장해두면 그 컴포넌트를 벗어날 때 상태가 초기화돼서 다시 불러와야 해요. 그래서 그당시 많은 사용자들이 선택한 게 "저 값들을 redux의 단일 스토어에 저장하고 당장 필요할 때 필요한 부분만 작게 쪼개서 가져오자." 라고 생각해요. 당시에는 정말 다른 방법이 없었거든요. 그래서 서버에서 내려주는 데이터들도 리스트와 같은 경우 더 효율적으로 관리하기 위해 normalize해서 redux에 저장하고 그것을 클라이언트에서 사용하고 이런 형태로 사용이 됐어요. 그러나 그 때에도 많은 사용자들이 너무 보일러플레이트가 많다고 불만을 표했었죠. ContextAPI의 등장그리고 ContextAPI가 나왔어요. 이 때, 많은 사용자들이 "ContextAPI로 redux를 대체할 수 있느냐?" 하면서 많은 관심을 표했어요. "리액트 리렌더링이 자주 일어날 수록 페이지가 늘어나니까 모든 컴포넌트에 memo를 씌우자"와 같은 주장도 꽤 있었던 것으로 기억해요. 그래서 ContextAPI는 상태를 보다 더 멀리 전달할 수 있음에도 "redux를 대체할 수는 없음"이라고 많은 사용자들이 결정내리고 redux를 유지했죠. 물론, ContextAPI를 어떻게든 써볼 수 있을까? observedBits를 이용해서 계산식을 넣으면 실제 값이 변경된 컴포넌트만 다시 업데이트가 되는데? 이거 selector 불필요한 거 아님? 이런 의견도 꽤 있었어요. 이후 얘기지만, dai-shi 아저씨가 ContextAPI를 이용해서 꽤 많은 실험을 했고, 꽤 긴 시간이 있지만 그 이후에 탄생한 게 jotai, zustand, valtio 등이에요. 값을 전달해주는 통로 + 상태를 selector 기반(redux), atomic 기반(recoil), subscribe(mobx) 기반으로 다루는 개념을 접목시켜서 라이브러리를 만드셨죠. ReactQuery도 등장redux 사용이 많아지면서 사용자마다 모두 다른 보일러 플레이트를 작성하고, 사용하는 미들웨어도 모두 다르고 꽤 많은 혼란이 있었다고 생각해요. 그래서 "당신에게 redux는 꼭 필요하지 않을 수 있습니다" 등과 같은 글고 많이 나왔죠. 그러던 중, ReactQuery가 나오게 됐어요. redux 사용자들에게는 정말 단비같았죠. redux에 때려넣고 있던 데이터들 중 심지어 효율적으로 다루겠다고 normalizing까지 해서 서버 데이터를 저장했던 그 힘든 과정을 ReactQuery가 모두 대신 해주겠다고 하니까요. 한 번 호출한 API의 데이터를 알아서 캐싱하고 같은 API를 짧은 시간 내에 호출했을 때 캐싱 데이터를 이용하는 등, 귀찮은 작업들을 꽤 많이 가져가서 redux에 남아있는 데이터는 많이 남지 않게 되었어요. 지금 생각해보면?과거 시점이 아닌 지금 시점에서 생각해보면, 데이터를 전역에 저장하기 위해서 redux, jotai, zustand 등과 같은 전역 상태 도구를 꼭 써야할까? 생각하면 전 아닐 것 같아요. 물론, selector 패턴이 필요해서 redux나 zustand를, atomic store pattern이 필요해서 jotai를 선택할 수는 있다고 생각해요. 하지만, 이것이 주 목적이어야 하지, 전역으로 상태를 저장하는 것이 주 목적이면 안 될 것 같아요. 서버에서 내려주는 많은 데이터는 ReactQuery를 통해 캐싱하는 방향으로 발전해왔고, 남아있는 데이터를 봤을 때 별로 많지 않을 것 같아요. 또한, 각 영역을 지정해서 상태를 관리하고 ContextAPI와 useReducer를 적절히 활용해서 상태를 관리하도록 하면 전역 상태 관리 라이브러리들이 제공해주는 편리한 상태 관리 기능도 많이 대체할 수 있다고 생각해요. props drilling이 싫어요리액트 문서에서는 props drilling에 대한 해결 방법으로 전역 상태 관리 라이브러리 사용을 제시하는 게 아닌, ContextAPI 사용을 제시하고 있어요. 실제로 ContextAPI는 props drilling을 피하는 효과적인 수단이에요. 앞서 언급한 대로 리액트 리렌더링으로 인해서 꺼려질 수 있지만, 저는 리렌더링은 문제가 되지 않는다고 생각해요.
리렌더링은 문제가 되지 않는다는 의견이 많고, 리액트에서 렌더 단계에 진입했다고 돔이 다시 리렌더링되는 건 절대 아니거든요. 제가 선호하는 스택저는 대부분 상태를 관리할 때, useReducer와 ContextAPI를 사용해요. 또한, 정말 복잡한 상태를 관리해야 할 때는 useReducer를 대체하는 형태로 xstate를 사용하며, 필요하다면 ContextAPI를 사용하고 있어요. |
Beta Was this translation helpful? Give feedback.
-
현재 프로젝트는 props를 직접 주입하는 방식으로 상태를 전달하고 있으며, 전역 상태는 도입하지 않았어요. 컴포넌트 계층 구조는 다음과 같아요:
문제 상황:
고민 지점:
Beta Was this translation helpful? Give feedback.
All reactions