React

전역상태는 항상 정답일까?

2024.05.27

이번 글에서는 회사에서 개발하면서 전역상태를 사용할 때 느끼는 불편함과 전역상태는 만능이 아니며, 상황에 맞게 상태관리를 하는것이 중요하다라는 것을 배운 경험을 공유하고자 합니다.

글을 쓰는 시점에 사내에서는 전역상태 라이브러리를 사용하고 있으며 Zustand를 사용하고 있습니다.

우선 문제가 발생한 상황에 대해 설명드리겠습니다. 서비스에는 A로 불리는 특정한 단위가 있으며,(ex: 계정 생성 등) A를 만들기 위해서는 4가지 상태가 존재합니다.

기본 정보 확인 → 새로운 정보 입력 → 최종 확인 → 생성 및 성공 확인

이러한 기능을 구현하기 위해 A를 생성하기 위한 최상단 부모 컴포넌트의 코드는 아래와 같습니다. (실제 코드를 각색하였습니다)

1const AModal = () => { 2 const [currentStep, onInit] = useAStore((state) => [ 3 state.currentStep, 4 state.onInit, 5 ]); 6 7 const decideStepComponent = () => { 8 switch (currentStep) { 9 case aEnum.DefaultInfoCheck: 10 return <DefaultInfoCheck />; 11 case aEnum.CreateInput: 12 return <CreateInput />; 13 case aEnum.Summary: 14 return <Summary />; 15 case aEnum.Success: 16 return <Success />; 17 } 18 }; 19 20 const closeModal = useModalStore((state) => state.closeModal); 21 22 const handleModalClose = () => { 23 if (currentStep !== aEnum.Success) { 24 closeModal(); 25 onInit(); 26 } 27 }; 28 29 return ( 30 <Modal> 31 <Modal.Header onClose={handleModalClose} /> 32 <CustomErrorBoundary 33 fallback={ 34 <DeferredComponent minHeight="min-h-[388px]"> 35 <Loading /> 36 </DeferredComponent> 37 } 38 > 39 <AFormProvider>{decideStepComponent()}</AFormProvider> 40 </CustomErrorBoundary> 41 </Modal> 42 ); 43}; 44 45export default AModal;

위의 코드에서는 4가지의 상태에 따라 다른 컴포넌트를 보여주기 위해 switch문으로 분기 처리하였으며, 이러한 상태를 AStore에서 정의해서 사용하고 있습니다.

추가로 A를 만들기 위해 여러 곳에서 자주 사용하는 상태들이 있습니다. aUuid, aName 값들입니다. aUuid부터 살펴보면 기본적으로 URL 쿼리를 통해 값을 가지고 옵니다. 코드에서도 useGetUrlQuery라는 공용 커스텀 훅을 통해 쿼리의 값인 aUuid을 받아서 사용하고 있기 때문에 이 또한 useGetUrlQuery만 사용하면 전역적으로(URL에 쿼리값이 있다는 전제하에) aUuid값을 얻을 수 있습니다.

1const { aUuid } = useGetUrlQuery(); 2 3const { data } = useGetAInfoData({ aUuid }); 4// data.payload.aName

또한 aName은 useGetAInfoData API 쿼리 데이터에 의존적입니다. 기본적으로 API 데이터를 받아서 data.payload.aName으로 값을 사용해야 하기 때문에 aName이 필요한 값은 항상 useGetAInfoData API 쿼리를 불러서 사용해야 하는 상황입니다.

이러한 상황에서 저는 "어떤 값은 커스텀훅을 통해서 URL 데이터로 가지고 와야 하고(aUuid), 어떤 값은 API 쿼리를 통해 데이터를 가지고 와야 하는데(aName) 그렇다면 전역상태를 이용해서 한 곳에 데이터를 모두 저장하고 동기화를 해서 사용하면 훨씬 편하지 않을까?" 라고 생각했었습니다. 그러곤 바로 초기에 받아올 수 있는 데이터를 받아와서 전역상태에 저장을 해주는 커스텀 훅을 따로 만들었습니다. 코드는 아래와 같습니다.

1function useCreateAInit() { 2 const { aUuid } = useGetUrlQuery(); 3 4 const [setAName, setAUuid] = useAStore((state) => [ 5 state.setAName, 6 state.setAUuid, 7 ]); 8 9 const { data } = useGetAInfoData({ 10 aUuid, 11 }); 12 13 useEffect(() => { 14 setAUuid(aUuid); 15 }, [aUuid]); 16 17 useEffect(() => { 18 if (!data.payload?.aName) return; 19 setAName(data.payload?.aName); 20 }, [data.payload]); 21 22 return { 23 aUuid, 24 }; 25} 26 27export default useCreateAInit;

useCreateAInit 덕분에 AStore 즉 전역상태를 적극적으로 활용할 수 있다고 생각하였고 기본 정보 확인 상태인 DefaultInfoCheck 컴포넌트에 전역상태를 활용했습니다.

1function DefaultInfoCheck() { 2 const [currentStep, aUuid, aName, aForm, setAForm] = 3 useAStore((state) => [ 4 state.currentStep, 5 state.aUuid, 6 state.aName, 7 state.aForm, 8 state.setAForm, 9 ]); 10 11 const { data } = useGetAInfoData({ 12 aUuid, 13 }); 14 const { data: dataForm } = useGetAForm({ 15 aName: aName ?? data?.payload?.aName, 16 staleTime: 60 * 1000, 17 }); 18 19 ...생략 20}

어떤가요? 최대한 AStore를 사용해서 분산되어 있는 데이터들을 한 곳에 모아서 사용하였습니다. 이렇게 하면 추후에 디버깅이 조금 더 쉽지 않을까요?

아쉽게도 아니었습니다.. useAStore 훅으로 AStore에서 관리하는건 좋다고 생각하지만 모두 이상적으로 잘 흘러갔을 때 이야기입니다. useAStore를 적극 활용하려고 했었지만 여러가지 문제점들이 발생을 하였는데요, 하나씩 살펴보면 아래와 같습니다.

1. 상태의 타입이 좁혀지지 않는다

전역상태를 사용할 때 느끼는 불편함 중에 흔하게 느끼는 불편함 중 하나인데요 바로 타입 문제입니다. 위의 DefaultInfoCheck 컴포넌트의 useFetAForm 훅 부분에 파라미터 부분이 보이시나요? aName: aNmae ?? data?.payload?.aName으로 null 병합 연산자를 사용하고 있습니다. 이렇게 해야하는 이유는 aName의 경우 아까 위에서 전역상태를 최대한 활용하기 위해 useCreateAInit 훅을 통해 useEffect로 데이터를 받아와서 전역상태에 값을 넣어주었습니다. 그러면 aName의 초기값은 null이므로 aName이 필요로 하며 필수로 값이 필요한 곳에서는 매번 null에 대한 처리가 필요하게 됩니다.

이러한 상태의 타입 문제로 인해 타입 가드, 단언 등의 직접 타입을 처리해야 하는 코드가 증가하게 됩니다.

2. Suspense를 사용하기에 어려움이 있다

A를 만들기 위해 여러 API 쿼리를 사용하기 때문에 Suspense로 로딩과 에러 처리를 진행하였는데요, Suspense를 사용하기 위해 API 쿼리에서 react-query의 useSuspenseQuery를 사용하게 된다면 enable 옵션이 없기 때문에 필요한 필수 파라미터는 무조건 존재해야 합니다. 만약 없다면 API에서 에러가 발생하게 되는데요,

DefaultInfoCheck 컴포넌트의 useGetAInfoData에서는 aUuid 값을 필수도 전달해주어야 하며 실제로도 값이 있어야 정상적으로 요청을 받아올 수 있습니다. 하지만 위의 코드에서는 에러가 발생하게 되는데요, 왜그럴까요?

거슬러올라가서 다시 한번 useCreateAInit 훅을 살펴보면 aUuid는 useGetUrlQuery 훅으로 부터 값을 받아서 useEffect를 통해 값을 저장하기 때문에 컴포넌트가 렌더링 되고 useSuspenseQuery가 가장 먼저 실행될 때는 aUuid는 초기값이 들어가게 되면서 원하는 동작을 하지 않게 됩니다.

useEffect의 동작 시점이 우리가 원하는 시점이 아니기 때문에 버그의 발생 위험 또한 증가하게 되는 코드입니다.

3. useEffect를 가급적 피하자

2번의 문제점에서 잠깐 이야기 했지만 useEffect는 외부 시스템과 동기화하는 목적이 아니라면 피하는게 좋습니다. 아래는 리액트 공식문서에 나와있는 리액트가 필요하지 않을 경우에 대해 소개하는 내용중 일부분입니다.

React 공식문서 Effect가 필요하지 않은 경우

Effect가 필요하지 않을 수도 있습니다 Effect는 React 패러다임에서 벗어날 수 있는 탈출구입니다. Effect를 사용하면 React를 “벗어나” 컴포넌트를 React가 아닌 위젯, 네트워크, 또는 브라우저 DOM과 같은 외부 시스템과 동기화할 수 있습니다. 외부 시스템이 관여하지 않는 경우 (예를 들어 일부 props 또는 state가 변경될 때 컴포넌트의 state를 업데이트하려는 경우), Effect가 필요하지 않습니다. 불필요한 Effect를 제거하면 코드를 더 쉽게 따라갈 수 있고, 실행 속도가 빨라지며, 에러 발생 가능성이 줄어듭니다.

공식문서의 내용처럼 useEffect는 리액트의 흐름을 거스르는 훅으로 외부 시스템 동기화가 아니면 사용하지 않는 것이 에러 발생을 낮추며 디버깅 또한 훨신 간편해집니다. 공식 문서에 지금까지 작성한 코드의 내용 처럼 일부 props 또는 state가 변경될 때 컴포넌트의 state를 업데이트하려는 경우에는 사용하지 말라고 나와있습니다.

이처럼 useEffect를 가급적 피하는 것이 좋지만 지금 코드는 전역상태를 최대한 활용하기 위해 useEffect를 사용하게 되면서 좋은 코드의 흐름이 아니게 되었습니다.

4. react-hook-form의 FormProvide의 존재

마지막 문제로는 FormProvider입니다. 글 초반에 보여드린 AModal 컴포넌트의 코드를 다시 한번 보면 AFormProvider를 불러와 각 상태 컴포넌트를 감싸주고 있습니다.

1const AModal = () => { 2 const [currentStep, onInit] = useAStore((state) => [ 3 state.currentStep, 4 state.onInit, 5 ]); 6 7 const decideStepComponent = () => { 8 switch (currentStep) { 9 case aEnum.DefaultInfoCheck: 10 return <DefaultInfoCheck />; 11 case aEnum.CreateInput: 12 return <CreateInput />; 13 case aEnum.Summary: 14 return <Summary />; 15 case aEnum.Success: 16 return <Success />; 17 } 18 }; 19 20 const closeModal = useModalStore((state) => state.closeModal); 21 22 const handleModalClose = () => { 23 if (currentStep !== aEnum.Success) { 24 closeModal(); 25 onInit(); 26 } 27 }; 28 29 return ( 30 <Modal> 31 <Modal.Header onClose={handleModalClose} /> 32 <CustomErrorBoundary 33 fallback={ 34 <DeferredComponent minHeight="min-h-[388px]"> 35 <Loading /> 36 </DeferredComponent> 37 } 38 > 39 <AFormProvider>{decideStepComponent()}</AFormProvider> 40 </CustomErrorBoundary> 41 </Modal> 42 ); 43}; 44 45export default AModal;

AFormProvider는 react-hook-form의 FormProvider로 여러 폼 컴포넌트를 하나의 폼으로 관리하고, 각 컴포넌트 간에 폼의 상태와 메소드를 공유할 수 있도록 도와주는 Provider입니다.

즉 DefaultInfoCheck, CreateInput 컴포넌트 등에서 FormProvider를 이용하여 하나의 useForm으로 입력값 검증을 수행할 수 있습니다. 이러한 역할과 동시에 CreateInput 컴포넌트에서는 이전 상태 컴포넌트인 DefaultInfoCheck 컴포넌트에서 입력한 값을 getValues, watch 함수 등을 이용해서 가져올 수 있습니다. 다른 컴포넌트의 값(상태)을 가지고 올 수 있다? 이러한 비슷한 역할은 AStore에서도 하고 있지 않나요?

결국 현재 AModal 컴포넌트에서는 2개의 전역상태를 사용한다고 할 수 있습니다. 성격과 동작하는 방식은 다르지만 벌써 컴포넌트 각 외부에서 여러개의 상태가 왔다갔다 하는게 너무 복잡하며, 데이터가 응집되어 있지 않고 분산되어 있어, 추후 유지보수 및 가독성에도 매우 좋지 않을 것 같다고 느꼈습니다.

이러한 문제들을 짊어지면서까지 전역상태를 활용하는게 좋을까? 라는 생각이 들었습니다. 전역상태를 사용하지 않는다면 부모에서 상태를 정의하고 props를 통해 상태를 전달해야 하고 이 부분에서 컴포넌트의 depth가 깊어질 수록 props drilling이 발생하는 문제가 발생할 수 있지만 A를 생성하는 컴포넌트를 고려해보면 depth가 깊지 않아 오히려 위에서 겪었던 어려운 문제들을 해결해줄 수 있을 것 같아 전역상태를 사용하지 않고 부모에서 상태를 정의해서 처리하여 아래의 코드로 수정했습니다.

1const AModal = () => { 2 const [Funnel, step, setStep] = useFunnel<aType>(aEnum.DefaultInfoCheck); 3 const { platformAccountUuid } = useGetUrlQuery(); 4 const { data } = useGetAInfoData({ 5 platformAccountUuid, 6 }); 7 const aName = data.payload?.aName; 8 9 const [aForm, setAForm] = useState<IAForm>({ 10 uuid: "", 11 nickname: "", 12 address: "", 13 }); 14 15 const closeModal = useModalStore((state) => state.closeModal); 16 17 const handleModalClose = () => { 18 if (step !== aEnum.Success) { 19 closeModal(); 20 } 21 }; 22 23 return ( 24 <Modal> 25 <Modal.Header onClose={handleModalClose} /> 26 <CustomErrorBoundary 27 fallback={ 28 <DeferredComponent minHeight="min-h-[388px]"> 29 <CreateTickSkeleton /> 30 </DeferredComponent> 31 } 32 > 33 <WithdrawalTicketFormProvider> 34 <Funnel> 35 <Funnel.Step name={aEnum.DefaultInfoCheck}> 36 <DefaultInfoCheck 37 currentStep={step} 38 anName={anName} 39 aForm={aForm} 40 setAForm={setAForm} 41 handleChangeStep={setStep} 42 /> 43 </Funnel.Step> 44 <Funnel.Step name={aEnum.CreateInput}> 45 <CreateInput 46 currentStep={step} 47 aUuid={aUuid} 48 anName={anName} 49 handleChangeStep={setStep} 50 /> 51 </Funnel.Step> 52 <Funnel.Step name={aEnum.Summary}> 53 <Summary 54 currentStep={step} 55 aUuid={aUuid} 56 anName={anName} 57 aForm={aForm} 58 handleChangeStep={setStep} 59 /> 60 </Funnel.Step> 61 <Funnel.Step name={aEnum.Success}> 62 <Success currentStep={step} /> 63 </Funnel.Step> 64 </Funnel> 65 </WithdrawalTicketFormProvider> 66 </CustomErrorBoundary> 67 </Modal> 68 ); 69}; 70 71export default AModal;

기존의 상태는 useFunnel이라는 커스텀 훅으로 step 상태에 따라 컴포넌트를 보여주고 step을 변경 관리만 담당하는 훅을 정의하였고, 그 외의 필요한 값은 부모에서 정의 또는 훅을 통해 가져와서 props로 전달하였습니다.

부모가 아닌 자식도 한번 확인해보면 DefaultInfoCheck 컴포넌트도 아래와 같이 수정되었습니다.

1function DefaultInfoCheck({ 2 currentStep, 3 aName, 4 aForm, 5 handleChangeStep, 6 setAForm, 7}: IDefaultInfoCheckProps) { 8 9 const { data } = useGetAInfoData({ 10 aUuid, 11 }); 12 const { data: dataForm } = useGetAForm({ 13 aName, 14 staleTime: 60 * 1000, 15 }); 16 17 ...생략 18}
  • 이전에 문제가 되었던 aName 상태의 타입 문제는 부모에서 props로 받아오기 떄문에 DefaultInfoCheck 컴포넌트에서는 null이 아닌 정확한 원하는 타입의 값이 들어왔다고 확신할 수 있으며,
  • Suspense의 초기값 문제 또한 말끔히 해결되었습니다.
  • 또한 자식 컴포넌트 내에서는 상태를 주입 받아서 사용하기 때문에 각 컴포넌트의 역할에 조금 더 충실할 수 있게 되었습니다.

기존에 사용했던 useEffect의 안티 패턴을 포함한 useCreateAInit 커스텀 훅을 제거할 수 있었으며, 추가로 다른 커스텀 훅 또한 필요없게 되며 코드의 양도 많이 줄일 수 있었습니다.

어떤가요? 저는 이번 경험을 통해 전역 상태가 만능이 아니라는 것을 느낄 수 있었으며, 상황에 맞게 가장 효과적인 방법을 사용하는 것이 좋다는 것을 배울 수 있었습니다. 이 글을 전역 상태를 사용하지 말자가 아닙니다 상황에 따라 가장 효율적인 방법을 사용하자를 공유드리고 싶었습니다.

다른 분들은 상태 관리를 어떻게 처리하시나요? 긴 글 읽어주셔서 감사합니다!


출처

사진: UnsplashMilad Fakurian

© 2024. sungkyu all rights reserved.