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