본문 바로가기

Client/Front-end

<FE> Recoil로의 마이그레이션

배경

 HyupUp 프로젝트는 다양한 상태를 관리합니다. 그래서 저희는 처음 개발을 시작했을 때부터 상태관리에 관심을 가졌으며, 라이브러리의 유무에 따른 차이를 체감해보고자 하였습니다. HyupUp은 프로젝트 관리 및 협업 툴로써, 크게 유저 상태인 UserState 와 조직 내 프로젝트 상태인 EpicState, StoryState 를 가집니다. 이 글에서는 다음과 같은 UserState를 다룹니다.

 

UserState = {
  id?: number;
  name?: string;
  job?: string;
  email?: string;
  imageURL?: string;
  admin?: boolean;
  organization?: number;
  currentProjectName?: string;
  currentProjectId?: number;
  projects?: Array<ProjectType>;
  privateTasks?: Array<PrivateTask>;
  projectTasks?: Array<ProjectTask>;
};

 

 이와 같은 상태는 앱 내부에서 전체적으로 사용되어 전역에서 관리되어야 합니다. 따라서 컴포넌트 내부에서 상태를 관리하는 useState 훅이 아니라 Context API 와 useReducer 훅으로 전역에서 상태를 관리합니다.이 때 느꼈던 불편함은 다음과 같습니다.

 

 첫째, 초기설정이 번거롭습니다. 상태만 정의하는 것이 아니라 reducer에 필요한 액션을 정의해줘야 하고, ContextProvider를 만들어서 최상위 컴포넌트에 넣어주는 등 보일러플레이트 코드가 너무 많았습니다.

 

 둘째, 위의 내용과 더불어 많은 액션을 관리하기가 복잡합니다. 새로운 상태변화 로직이 필요할 때마다 액션을 새로 정의해주거나 기존 것을 수정해야했습니다. 게다가 이 상태를 사용하는 컴포넌트와 에디터 상에서 왔다갔다하며 코드를 작성하다보니 꽤나 불편했습니다.

 

 셋째, 상태 자체의 복잡성 때문에 분리가 필요합니다. 위에서 정의한 UserState에는 개인 할 일(privateTasks)배열과 프로젝트에서의 할 일(projectTasks)배열이 있습니다. 이 둘을 불변성을 지키면서 변경시키는 것은 쉬운 일이 아니었습니다. 게다가 이 둘을 사용하는 ListView 컴포넌트의 경우 두 프로퍼티 외 다른 프로퍼티들을 참조하지 않습니다. 그렇다고 위 둘을 분리하기에는 새로운 context를 만드는 것이 부담이기도 했고, 다른 프로퍼티와 연관이 아예 없는 것도 아니었습니다.

 

 이러한 상황에서 우리는 상태관리 라이브러리의 이점을 알아보고 활용하고자 Recoil을 도입했습니다. Redux나 MobX 같은 대체제가 있지만 Recoil을 선택한 가장 큰 이유는 러닝커브가 낮았기 때문입니다. 이미 어느정도 작업이 된 코드를 마이그레이션해야할 뿐만 아니라, 제한된 시간안에 개발을 마쳐야하므로 빠르게 적응할 수 있어야 했습니다. 게다가 Recoil은 리액트를 개발한 facebook(현 meta)에서 개발하여 Suspense와의 조합 등 리액트와도 궁합이 좋습니다.

도입

 

 코드내에 적용하기 전에 Recoil 공식 문서를 보면서 공부를 시작했습니다. 생각보다 너무 쉬워 놀라웠습니다. 문서의 한글화도 잘되어있을 뿐만 아니라 튜토리얼을 따라하는데 1시간이 채 걸리지 않았습니다. 사용방법이 기존 리액트의 useState 훅과 유사해 크게 어렵지 않았습니다. 결과적으로 UserState는 다음과 같이 간단하게 정의할 수 있었습니다.

 

// App.tsx
function App() {
  return (
    <RecoilRoot>
      <...>
    </RecoilRoot>  
  )
}
// recoil/user/atom.ts
const userAtom = atom({
  key: 'userAtom',
  default: {},
});

 

 atom이라는 키워드로 상태를 담고 컴포넌트 최상단을 RecoilRoot 로 감싸기만 하면 끝이었습니다. 복잡한 정의도 필요 없어서 기존의 Context API를 사용하던 것에 비해 훨씬 간단해졌습니다. 상태의 변경 또한 매우 간단했습니다.

 

// pages/LandingPage
import { useRecoilState } from 'recoil';
import userAtom from '@/recoil/user/atom';

const Test = () => {
const [userState, setUserState] = useRecoilState(userAtom);  
  const onClickLogin = async (email) => {
    const newUser = await getUser(email);
    setUserState(newUser);    
  }  
};
return (
  <div>이름 : {userState.name}</div>
)

 

 위 처럼 마치 useState 를 쓰듯이 useRecoilState 를 사용하여 atom을 불러오기만 하면 되었습니다. 이는 Recoil 공식 사이트에서 설명하는 ❝공유상태도 React의 내부상태처럼 간단한 get/set 인터페이스로 ...❞ 문구와 맞닿아 있다고 볼 수 있습니다. 다른 컴포넌트에서도 전역 상태를 위처럼 관리할 수 있습니다. 또한 놀랍게도 리렌더링은 필요한 컴포넌트에만 발생합니다. 이는 내부적으로 useRef 를 중간 과정에서 사용하여 다른 곳의 불필요한 리렌더링을 막는 방식입니다.

 

 기존에 privateTasks 배열과 projectTasks 배열을 user상태에서 분리하여 관리하고 싶다는 고민이 있었습니다. 위에서 언급했듯이 tasks 변경 액션을 정의할 때마다 불변성을 지켜가며 전체 UserState를 신경써야 하는 것이 너무 까다로웠습니다. 그러나 Recoil 에서 제공하는 selector함수를 이용하면 상태를 분리할 수 있습니다.

 

const privateTasksSelector = selector<PrivateTask[]>({
  key: 'privateTasksSelector',
  get: ({ get }) => {
    const user = get(userAtom);
    return user.privateTasks ? user.privateTasks : [];
  }
});

 

 위처럼 필요한 상태를 정의하여 atom 내부의 상태를 조회할 수 있습니다. 게다가 조회만 가능한 것이 아닙니다. setter 함수 또한 커스텀하여 atom의 상태를 변경할 수 있습니다.

 

const privateTasksSelector = selector<PrivateTask[]>({
  key: 'privateTasksSelector',
  get: ({ get }) => {
    const user = get(userAtom);
    return user.privateTasks ? user.privateTasks : [];
  },
  set: ({ set }, newValue) => {
    set(
      userAtom,
      newValue instanceof DefaultValue
        ? newValue
        : (prev) =>
            produce(prev, (draft) => {
              draft.privateTasks = [...newValue];
              return draft;
            }),
    );
  },
});

 

 위처럼 atom 내부의 privateTasks 를 변경시키는 로직만 setter에 작성하면 끝입니다. 이제 tasks는 다음과 같이 불러올 수 있게 되었습니다.

 

// getter / setter 모두 불러올 경우
const [privateTasks, setPrivateTasks] = useRecoilState(privateTasksSelector)
// getter 만 불러올 경우
const privateTasks = useRecoilValue(privateTasksSelector)
// setter 만 불러올 경우
const setPrivateTasks = useSetRecoilState(privateTasksSelector)

 

 이를 통해 tasks 만 사용하는 곳은 굳이 UserState 전체를 불러올 필요도 없고, 해당 task 배열을 get, set하듯이 사용할 수 있습니다. "새로운 context를 만들어야하는가"에 대해 고민할 필요없이 하나의 atom으로 여러 개의 context를 정의한 것처럼 된 것입니다. 이렇게 Recoil을 이용해 상태를 분할하여 코드의 책임을 명확히 할 수 있게 되었고, 유지보수가 용이한 코드를 작성할 수 있었습니다.

좋았던 점

 위와 같이 저희는 useContextuseReducer 을 사용하던 프로젝트에 Recoil을 도입함으로써 그 장점을 확실히 느낄 수 있었습니다. 이를 정리하면 다음과 같습니다.

 

 Recoil은 러닝커브가 낮고 리액트와의 조합이 좋았습니다. 공식문서가 잘 되어있을 뿐만 아니라 기존의 리액트 상태관리와 크게 다르지 않아 프로젝트에 빠르게 도입할 수 있었습니다. facebook에서 개발한 기술답게 리액트의 공식문서와도 매우 흡사했고, 한글화도 매우 잘 되어있었습니다. 게다가 Recoil은 내부적으로 리액트 트리를 이용하여 상태변화를 하고 있으며, useRef를 통해 불필요한 렌더링을 방지해주기까지 합니다.

 

 코드량을 눈에 띄게 줄일 수 있었습니다. atom 함수 하나로 간단하게 전역상태를 만들 수 있었고, 이는 기존의 context 에 비해 훨씬 코드가 간결했습니다. 게다가 useRecoilState, useRecoilValue , useSetRecoilState 를 통한 getter와 setter 함수는 기존 프로젝트의 reducer 에 비해 훨씬 간결하고 직관적이었습니다.

 

 책임을 명확히 할 수 있게 되었습니다. selector 키워드로 상태를 분리하여 관리할 수 있게 되어, 기존에 했던 상태 분리에 대한 고민을 해결할 수 있게 되었습니다. 이를 통해 전역상태에서 필요한 부분만 조회하고 수정하여 유지보수가 용이한 코드를 작성할 수 있게 되었습니다.

아쉬운 점

 Recoil은 상태 변화 시 상태가 변화한 컴포넌트 외 다른 컴포넌트의 렌더링에 영향을 끼치지 않는 것이 큰 장점 중 하나입니다. 그러나 현재 저희 프로젝트는 컴포넌트 구조의 깊이가 깊지 않으므로, 이에 대한 이점을 체감하기 힘들었습니다. React Devtools를 이용해 컴포넌트가 리렌더링되는 정도를 확인해보았으나 큰 차이는 없었습니다.

 

 레퍼런스를 많이 찾을 수 없었습니다. Recoil은 비교적 최신 기술이라 아직 사용하는 곳이 많지 않았습니다. 마이그레이션을하면서 이슈를 맞닥뜨렸지만, 이에 대한 자료를 찾을 수 없어서 3시간 가량 헤매기도 했습니다.

 

 devtools가 불안정합니다. 분석을 위해 크롬 익스텐션의 Recoil Devtools 를 설치하였으나, 아무것도 뜨지 않았습니다. 그래서 확인해보니 마지막 업데이트가 2020년이었습니다. 상태관리 라이브러리의 대명사 Redux의 devtools 마지막 업데이트가 2021년 중순인 것과 너무 비교되었습니다.

 

 Recoil의 캐싱 기능이 방해가 되는 경우가 있었습니다. Recoil의 selector은 읽기 전용 DB 쿼리를 모델링하는데에 특화되어 있습니다. get 내부에서 비동기 로직을 사용할 수 있으며 이를 캐싱할 수 있다는 점이 장점입니다. 이를 통해 Recoil은 주어진 상태에 대해 항상 일관적인 값을 제공하고자 합니다. 그러나 저희 프로젝트는 이 장점을 살릴 수 없었습니다. 데이터베이스에서 UserState를 받아오는 것은 최초 한 번만 일어나는 일이었고, selector또한 마찬가지었습니다. 따라서 캐싱에 따른 이점을 체감하기 어려웠습니다.

 

 또 프로젝트에서 사용되는 다른 상태들인 EpicState, StoryState 는 다른 사용자에 의해 자주 변경되는 상태입니다. 따라서 이들은 필수적으로 서버와 동기화되어야 하는데, 이때 캐싱이 방해요소가 될 수 있습니다. Recoil이 추구하는 바와 달리 저희는 항상 새로운 값을 필요로 하기 때문입니다. 하지만 이 부분은 현재 unstable 단계인 useRecoilRefresher 을 사용하여 해결할 수 있습니다.