다시 써본 Recoil 활용과 후기, 그리고 Zustand

11t518s·2022년 12월 18일
3
post-thumbnail

안녕하세요! 이번에 Recoil을 다시 사용해보면서 어떻게 사용했고 어땠는지 공유하려고 합니다!

우선 기존에 refresh.atoms 를 사용해서 강제로 업데이트하는 방식으로 하려고 했는데,
생각했던 대로 동작하지 않았던 듯 합니다 ㅠㅠ

원인은 캐싱인 듯 하고, 그래서 다른 방법을 고민했습니다.

정리하면
0. 기존의 문제점 파악하기
1. class처럼 사용하기 (private, public)
2. class처럼 사용하기 (getter, setter)
3. Recoil이 아니라 Zustand가 나에게 더 적합하지 않을까?
4. 앞으로 상황에서 Recoil을 쓰고 어떤 상황에서 zustand를 쓸 것인가

이 순서로 설명드리려고 합니다.

0. 기존의 문제점 파악하기

우선 기존에는

state
  -- index.ts
  -- atoms.ts
  -- selectors.ts
  -- refresh.atoms.ts
  -- types.ts

state라는 폴더 내부에 위와같이 정리했었습니다. 관심사에 따라 분리했다고생각하지만 2가지 문제가 있었습니다.

마음대로 되지 않았던 refresh.atoms.ts

docs의 설명대로라면 내부의 get으로 구독하고 있던 상태가 변하면 리프레쉬가 잘 일어날 것이라 생각했는데 그렇지 못했습니다. 원인은 찾지 못했지만 어쨌든 해당방법이 Recoil에서 사용하는 방법으로 이야기했긴 해도 상태가 자주변하는 코드를 사용하는 것은 Recoil을 바르게 사용하지 않는 것이라고 생각했습니다.

너무 많은 파일들

redux를 쓸때 복잡하다고 생각했던 것 중 하나가 쓸데 없이 관리 포인트가 늘어나는 것이었습니다. 물론 redux에 비해 보일러플레이트 코드는 적지만, 여러 파일에서 코드를 관리해야하기 때문에 관리포인트가 늘어난다는 부분이 있었고 불만족 스러웠습니다.

그래서 기존 구조를 바꿔서 사용해보기 위해 연구했고 상태를 받아오면 상태를 객체지향적 느낌으로 관리하는 방법을 고민했습니다.

1. class처럼 사용하기 (private, public)

그래서 class에서 좋았던 것들을 고민해봤고 먼저 하나의 객체에서 private, public, protect 등 메서드와 프로퍼티를 외부에 공개하고 숨길 수 있다는 것이 좋았습니다.

atom = private, selector = public

그래서 atom을 private하게 사용하고, selector를 public하게 사용하기로 했습니다.

예를들면 아래와 같이 atom을 기반으로 관리를 하지만, private하게 관리해서 해당값을 외부에서는 참조할 수 없게 하고, 읽기와 변경은 selector를 통해 관리하려고 했습니다.
(private하게 라고 말은 했지만 closure로 관리를 한다는 표현이 Javascript에서는 더 적절할 것 같긴 합니다!)

예를들어 아래와 같이 정리를 했습니다.

// state/[test].ts
	const TestId = atom<string>({
    	key: 'TestId/atom',
      	default: ''
    })
    
    export const testId = selector<string>({
    	key: 'testId/selector',
      	get: ({get}) => get(TestId),
      	set: ({set}, newValue) => set(TestId, newValue)
    })

TestId라는 값을 private하게 관리하고, 이 값을 읽고 수정하려면 testId라는 selector를 통해서만 수정하게 했습니다. (쓰다보니 2번의 내용 같네요..)

그리고 조금 더 확장해서 이런 느낌으로도 사용했습니다.

// 1번 방법
	const TestId = atom<string>({
    	key: 'TestId/atom',
      	default: ''
    })
    
    const TestData = atom<TestDataType>({
    	key: 'TestData/atom',
      	default: selector<TestDataType>({
        	key: 'TestId/atom/default',
          	get: async ({get})=> {
              	const testId = get(TestId)
                const result = await xxxxxx(testId)
                return result
        })
    })
    
    export const testId = selector<string>({
    	key: 'testId/selector',
      	get: ({get}) => get(TestId),
      	set: ({set}, newValue) => set(TestId, newValue)
    })
    
    export const testObject = selector<TestDataType>({
    	key: 'testObject/selector',
      	get: ({get}) => get(TestData),
      	set: ({set}, newValue) => set(TestData, newValue)
    })

그러면 이제 굳이 왜 TestData라는 변수를 만드냐? 라고 질문하실 분들이 있으실 수 있습니다. 왜냐하면

// 2번 방법
	const TestId = atom<string>({
    	key: 'TestId/atom',
      	default: ''
    })
    
  //   const TestData = atom<TestDataType>({
  //    	key: 'TestData/atom',
  //      	default: selector<TestDataType>({
  //        	key: 'TestId/atom/default',
  //          	get: async ({get})=> {
  //             	const testId = get(TestId)
  //              const result = await xxxxxx(testId)
  //              return result
  //      })
  //  })
    
    export const testId = selector<string>({
    	key: 'testId/selector',
      	get: ({get}) => get(TestId),
      	set: ({set}, newValue) => set(TestId, newValue)
    })
    
    // 여기가 변경됨
    export const testObject = selector<TestDataType>({
    	key: 'testObject/selector',
      	get: async ({get})=> {
              const testId = get(TestId)
              const result = await xxxxxx(testId)
              return result
        }
    })

위처럼 사용할 수도 있지 않을까 라고 생각할 수 있기 때문입니다.

저도 사실 처음에는 지금 나온 방법처럼 사용하려고 했었는데요.
이 방법은 set을 하기 어려워서 현실적으로 어려웠습니다.

    export const testObject = selector<TestDataType>({
    	key: 'testObject/selector',
      	get: async ({get})=> {
              const testId = get(TestId)
              const result = await xxxxxx(testId)
              return result
        }
     	set: ({set}, newValue) => {
    		set(testObject, newValue)
    	}
    })

이런식으로 자가 참조를 하면 자기자신을 참조하게 돼서 RangeError: Maximum call stack size exceeded 에러가 발생하게 됩니다.

그래서 서버에서 받아와서 set할 일이 전혀 없다면 2번 방법을 사용할 수 있었지만, 그게 불가능해서 1번 방법으로 사용해야 했습니다.

그래서 결론적으로 atom을 private하게, 그리고 selector를 public하게 사용하는 것으로(물론 원리상으로는 closure가 동작한 것 같지지만,,) 기존문제를 해결하려고 했습니다.

2. class처럼 사용하기 (getter, setter)

위의 코드에서 거의 나온 것 같지만 selector를 사용하여 getter와 setter를 사용했습니다.

그래서 사용할 수 있는 한


	const TestId = atom<string>({
    	key: 'TestId/atom',
      	default: ''
    })
    
    const TestArray = atom<TestDataType[]>({
    	key: 'TestArray/atom',
      	default: selector<TestDataType>({
        	key: 'TestArray/atom/default',
          	get: async ({get})=> {
              	const testId = get(TestId)
                const result = await xxxxxx(testId)
                return result
        })
    })
    
    export const testId = selector<string>({
    	key: 'testId/selector',
      	get: ({get}) => get(TestId),
      	set: ({set}, newValue) => set(TestId, newValue)
    })
   

    export const addTestArrayOnLast = selector<TestDataType[]>({
    	key: 'addTestArrayOnLast/selector',
      	get: ({get}) => {
        	const testArray = get(TestArray)
            return testArray[testArray.length - 1]
        },
      	set: ({set}, newValue) => set(TestArray, prev => [...prev, newValue])
    })
    
    export const updateTestArrayOnLast = selectorFamily<TestDataType, string>({
    	key: 'updateTestArrayOnLast/selector',
      	get: (willUpdatedTestId) => ({get}) => {
        	const testArray = get(TestArray)
            return testArray.find(test => test.id === willUpdatedTestId)
        },
      	set: (willUpdatedTestId) => ({set}, newValue) => 
            set(TestArray, prev => prev.map(test => 
                test.id === willUpdatedTestId
                ? newValue
                : test
            )
    })
      
    export const deleteTestArrayOnLast = selectorFamily<TestDataType, string>({
    	key: 'deleteTestArrayOnLast/selector',
      	get: (willDeletedTestId) => ({get}) => {
        	const testArray = get(TestArray)
            
            return testArray.find(test => test.id === willDeletedTestId)
        },
      	set: (willDeletedTestId) => ({set}, newValue) => 
            set(TestArray, prev => 
                prev.filter(test => test.id !== willDeletedTestId)
               )
    })

위처럼 selector에서 crud를 해주는 방식으로 사용했습니다.

getter와 setter를 사용하고 싶었던 이유가 상태에 관린 로직을 page나 component에서 다루지 않고 모두 recoil에서 다루고 싶었던게 컸었고, 그렇기 때문에 selector와 selectorFamily를 사용해 atom값을 변화시키는 식으로 코드를 구성했습니다.

이렇게하고 나서는 상태를 변경시키는 로직을 모두 리코일로 담을 수 있었지만 2가지 아쉬운 점이 있었습니다.

서버에서 다시 상태를 받아오고 싶을 때는?

서버 상태를 다시 받아오려면(비동기로 상태를 변경해야한다면) 어쩔수 없이 종속된 page나 component에서에서 function을 만들어서 update해줘야 합니다.

	// state/[test].ts
    export const testArray = selector<TestDataType[]>({
    	key: 'testObject/selector',
      	get: ({get}) => get(TestArray),
      	set: ({set}, newValue) => set(TestArray, newValue)
    })
    
    // testComponent.tsx
	const TestComponent = () => {
      const setTestArray = useSetRecoilState(testArray)
    
      
      return <button onClick={async () => {
        		const newTestArray = await ....
                setTestArray(newTestArray)
	        }}>
                이 버튼을 누르면 새로운 리스를 다시 받아옵니다.
            </button>
    }

이런식으로 결국엔 다시 컴포넌트에서 비동기 처리를 해줘야합니다.. 혹은 refresh를 할 수 있게끔 처음에 했던 refresh trigger를 만들거나 혹은 rodable을 사용하거나 하는 방법으로 우회하는 방법을 고려해봐야 합니다.

Recoil을 사용하면서 마음이 아팠던 부분중 하나는 지금처럼 서버에서 새로운 상태를 받아올 때의 처리가 안되는 부분인 것 같습니다 ㅠㅠ(서버에서 상태가 자주 바뀌면 react-query를 씁시다..!)

update, delete일 때 get의 의미는?

상태를 변경시키는 목적으로 selector를 사용할 때, get의 의미가 크게 없어집니다.
selector에서 get만 사용하는 건 가능하지만, set만 사용하는건 불가능하기 때문에 의미없는 코드가 생깁니다.

물론 삭제할, 업데이트할 대상의 이전 상태를 바라본다는 의미가 존재할 수 있지만 무의미한 느낌이 있습니다..

그래도 최종적으로

state
  test
    -- index.ts
    -- types.ts

파일구조도 이렇게 단순해지고 객체적으로 상태를 바라볼 수 있는 것이 좋았습니다.

3. Recoil이 아니라 zustand가 나에게 더 적합하지 않을까?

위와 같이 사용하면서 Recoil의 계속 아쉬움이 남았습니다.

1. 순수함수

그리고 기본적으로 Recoil, 거기에서 selector는 순수함수로 사용하는 속성이 있다.

Recoil공식문서에도

Recoil은 직교(orthogonal)하지만 본질적인 방향 그래프를 정의하고 React 트리에 붙인다.
상태 변화는 이 그래프의 뿌리(atoms)로부터 순수함수(selectors)를 거쳐 컴포넌트로 흐르며, 다음과 같은 접근 방식을 따른다.

와 같이 나오는데 Recoil은 기본적으로 selector라는 순수함수를 기반으로 함수형 프로그래밍 패러다임을 따라가고 있다.

물론 함수형 패러다임이 싫다는 것은 아니고, 상태를 받아오면 그 상태를 객체지향적으로 바라보는게 더 코드적으로 이해하기 쉽고 문제가 없다고 생각했다.
함수형 패러다임을 잘쓰면 좋겠지만 그렇기엔 너무 많은 selector가 생성되기도 하고 거기다가 selector는 util처럼 사용하기 보단 특정 atom이나 selector에 의존적으로 사용되기 때문에 함수형 프로그래밍 패러다임을 잘 못따라가지 않나 생각했다. 그렇기 때문에 상태관리에서는 상태만 명확히 다루는 객체지향이 더 적합하지 않을까 생각했다.

2. 비동기 처리

위에서도 문제인 부분이 비동기 처리에서의 아쉬움이 있었다. React-query를 사용한다 해도. 크게 변하지 않는 상태는 Recoil같은 클라이언트 상태 관리 라이브러리로 관리하다가 중간에 한번 한번씩만 서버와 연동해주는 것이 좋다고 생각했다.

그렇지만 아직 Recoil에서는 이러한 부분을 잘 해결하지 못하고 있어서 근본적으로 Recoil에 대해 아쉬움이 있었습니다.

Zustand

그렇게 Recoil도 벗어나서 다른 상태관리 방법을 찾으려고 XState, jotai, Zustand 등등 여러 라이브러리를 찾아보고 사용해봤습니다.

그러던 중 Zustand를 사용하면서 만족스러운 경험을 했습니다. 아직 자세하게 사용해보진 않아서 글을 작성할 정도는 아니지만 다음부터 상태관리가 필요하다면 Zustand로 사용하겠다 생각했습니다.

https://ui.toast.com/posts/ko_20210812
해당 링크를 통해서도 확인 가능하지만, 간단하게 제가 위에서 사용한 코드를 Zustand로 바꿔보면

	import create from "zustand";

    export const useTestStore = create<TestStoreType>((set, get) => ({
      	testArray = [],
		
		getTestArray: () => get().testArray,
        // getTestArray() { return get().testArray}
        // 위 주석처럼 메서드 축약형으로 쓸수도 있습니다! 
        // 그런데 공식문서에는 위처럼 나와있어서 위로 쓰겠습니다!
        refetchTestArray: async () => {
            const fetchedArray = await ......

        	set(() => ({
            	testArray: fetchedArray
            }))
        }

    	addTestArrayOnLast: (newTestItem) => {
          set((state) => ({
            testArray: [...state, newTestItem]
          }))
        },
          
        updateTestArray: (willUpdatedTestId, updatedTest) => {
          set((state) => ({
            testArray: state.testArray.map(test => 
					test.id === willUpdatedTestId
                      ? updatedTest
                      : test
			)
          }))
        },
          
        deleteTestArray: (willDeletedTestId) => {
          set((state) => ({
            testArray: state.testArray.filter(test => 
					test.id !== willDeletedTestId
			)
          }))
        },
    	
    
    }))

    (async function getTestArray() {
      	const testArray = await ......
      	useTestStore.setState({ testArray });
    })();

이렇게 바꿀 수 있습니다.

이런식으로 객체 지향적 관점에서 Test라는 객체를 관리할 수 있고, refetchTestArray와 같이 서버에서 상태를 다시 불러오는 코드도 넣을 수 있습니다!
물론 상속과 private같은 요소는 없어서 class와는 다르게 사용해야하고 그 부분에서 아쉬쉽지만, 객체적으로 사용할 수 있다는 점에서 만족스러웠고, 다음번 서비스를 만들 때는 해당 라이브러리를 사용하려고 하고, 그러면서 더 연구해보려고 합니다.

4. 앞으로 상황에서 Recoil을 쓰고 어떤 상황에서 Zustand를 쓸 것인가

확실하게 Redux는 사용하지 않을 것 같습니다!

그리고 서비스에 맞게 Recoil or Zustand를 사용하고 서버상태를 굉장히 의존해야하면 React-query를 추가하려 합니다.

Recoil을 쓰는 상황

Recoil에서 atom을 private하게 사용하는 식으로 코드를 작성했지만 필요에따라 export할 수 있습니다.

// state/test.ts
	//이렇게 export해주면
	export const TestId = atom<string>({
    	key: 'TestId/atom',
      	default: ''
    })
    
    export const testId = selector<string>({
    	key: 'testId/selector',
      	get: ({get}) => get(TestId),
      	set: ({set}, newValue) => set(TestId, newValue)
    })
    
// state/experiment.ts
    import {TestId} from './test'
    
    export const experiment = selector<ExperimentType>({
		key: 'experiment',
      	get: ({get}) => {
          const testId = get(TestId)
          const fetchedExperiment = await ............(testId)
          return fetchedExperiment
        }
    })

위의 상황처럼 상태간 연결이 용이합니다.
그렇기 때문에 상태간의 연결이 중요한 서비스라면 Recoil을 사용할 것 같습니다.

그리고 서비스에서 SSR에 대한 필요정도를 고려해보고 비동기 처리에 대한 논의를 해본다음 Suspense가 좋을 것 같으면 Recoil을 사용할 생각입니다!

Suspense는 추후에 Zustand나 다른 라이브러리에서도 지원하겠지만 아직까진 Recoil에서 처리하는 것 만큼 편하고 훌륭하게 해주는 라이브러리는 없는 것 같습니다!

Zustand를 쓰는 상황

위의 상황이 아니라면 Zustand를 사용할 듯 합니다.

상태간 연결이 크게 있지 않고 각 상태의 역할이 명확하고 다른 상태에 영향을 주지 않는 다면 Zustand를 사용하려 합니다.

물론 또 상황에 따라 다르게 판단하고 적용하겠지만, 지금은 이러한 원칙을 세웠습니다.

마무리

상태관리는 끝이 없는 것 같습니다!
다음에는 XState도 공부해보려고 합니다. Zustand와 Recoil의 강점을 합칠 수 있을 것 같긴한데, 둘 보다 조금 초기에 공부하기 어려워 보이는 느낌이 있습니다!

혹시 글을 보시는 분 중에 궁굼한게 있으시다면 혹은 지적해주실 부분이 있으시다면 언제든 편히 말씀해주시고, XState를 사용해본 경험 있으신 분도 어떠셨는지 말씀해주시면 같이 이야기 해보고 싶습니다!

감사합니다 ㅎ_ㅎ

profile
사람들에게 행복을 주는 서비스를 만들고 싶은 개발자입니다!

0개의 댓글