Untitled

배경

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<UserState>({
  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함수를 이용하면 상태를 분리할 수 있습니다.