# 개요
리코일은 리액트의 전역 상태관리 라이브러리 중 하나입니다. 주로 쓰이는 리덕스에 비해 리액트 생태계에 나타난지 얼마 되지 않아 커뮤니티 형성이 크게 이루어지지 않고 있습니다.
사용자가 상대적으로 적다 보니 라이브러리 사용에 대해 이렇다 할 베스트 프랙티스가 고정되지 않고 있는 상황입니다. Medium - Recoil Project Structure Best Practices (opens new window) 이 문서에서는 기존 리코일 디렉토리 구조의 문제점을 짚어보고 개선 방향에 대해 제시하게 됩니다.
# 기존 폴더 구조
리코일을 잠시 복습해보자면, 전역 상태를 관리할 atom
파일을 생성하고 이들을 상속받아 derived된 셀렉터들이 있습니다. 셀렉터와 아톰에 대해 폴더 구조를 나타내면 다음과 같습니다.
.
├── App.tsx
├── index.tsx
└── src
├── components
│ └── Main.tsx
└── recoil
├── atoms
│ └── exampleAtom.ts
└── selectors
└── exampleSelector.ts
Main.tsx
에서 아톰으로부터 상태를 끌어와 컴포넌트를 렌더링하는 상황입니다.
리코일의 아톰은 기본적으로 좋은 성능을 위해 큰 데이터들을 작은 단위로 쪼개어 사용하는 것을 전제로 합니다.
각종 데이터를 중앙집중적으로 관리하는 아톰 특성상 그 구조가 커지면 커질수록 해당 아톰을 활용하는 컴포넌트가 필요없이 재 렌더링하는 문제가 존재할 수 있습니다.
따라서 아톰을 최대한 잘게 쪼개어 관리하게 되는데, 이때 한 어플리케이션에 너무나 많은 아톰들이 생길 수 있다는 것이 문제입니다.
추가적으로, 한 아톰에 대해 두 컴포넌트가 useRecoil
훅을 통해 접근하는 상황일 때에 아톰 값이 바뀌게 되면 다른 컴포넌트에 변화가 필요 없더라도 재 렌더링이 발생하는 문제점이 있습니다.
# 네이밍 문제
일반적으로 아톰에 대해서는 접미사로 State
가 붙고 셀렉터에 대해서는 Value
가 접미사로 붙습니다.
이러한 이유 때문에 컴포넌트에서 일시적으로 사용하는 상태값을 담을 변수명과 혼동이 있을수도 있습니다. (useState
훅으로 관리하는 상태값)
또한 다양한 이유로 한 아톰으로부터 셀렉터가 여러개 등장할때 source of truth
에 해당하는 아톰과 연관되도록 상태값을 관리해야하기 때문에 네이밍을 다음과 같이 해야할 수도 있습니다.
import {useRecoilValue} from "recoil";
import exampleState from "../recoil/atoms/exampleState";
import exampleValue from "../recoil/selectors/exampleValue";
import exampleValueTwo from "../recoil/selectors/exampleValueTwo";
const Component(){
const example = useRecoilValue(exampleState);
const exampleVal = useRecoilValue(exampleValue);
const exmapleValueTwo = useRecoilValue(exampleValueTwo);
}
# 개선해야할 점
리코일 프로젝트 관리에 대한 개선점을 정리해보면 다음과 같습니다.
- 파일 네이밍이 헷갈린다.
- 작은 조각으로 관리할 아톰들을 자주 import해야하고 조각이 너무 많아져 생산성이 떨어진다. (폴더 구조 개선)
- 네이밍으로 인해
useRecoilValue
훅이 어떤 상태값을 반환하는지 한번에 알아보기 어렵다. (export import 방식 개선)
투두리스트 작성과 관련된 아톰 코드입니다.
export const toDoState = atom<ITodo[]>({
key: 'todo',
default: [],
});
export const toDoSelector = selector({
key: 'toDoSelector',
get: ({ get }) => {
const toDos = get(toDoState);
return [
toDos.filter((toDo) => toDo.category === 'TO_DO'),
toDos.filter((toDo) => toDo.category === 'DOING'),
toDos.filter((toDo) => toDo.category === 'DONE'),
];
},
});
위 코드를 다음 절차들에 따라 하나씩 수정해보겠습니다.
# 파일 네이밍 이슈
먼저 파일 네이밍에 관련되어서는 State
와 Value
라는 접미사를 빼야합니다. 아톰을 표현했던 State
는 Atom
으로, 셀렉터를 표현했던 Value
는 with<Something>
으로 표현합니다.
// 1. State suffix -> Atom suffix
export const toDoAtom = atom<ITodo[]>({
key: 'todo',
default: [],
});
// 2. Selector or Value suffix -> with something
export const withCompleted = selector({
key: 'toDoWithCompleted',
get: ({ get }) => {
const toDos = get(toDoState);
return [
toDos.filter((toDo) => toDo.category === 'TO_DO'),
toDos.filter((toDo) => toDo.category === 'DOING'),
toDos.filter((toDo) => toDo.category === 'DONE'),
];
},
});
네이밍 컨벤션 변화에 따라 todoAtom
을 보고 투두 관련 데이터를 다루는 아톰임을 직관적으로 알 수 있고, WithCompleted
를 통해 해당 셀렉터가 투두 항목의 완료 여부를 관리하는 셀렉터임을 알 수 있습니다.
# 2. 폴더구조 개선
폴더구조를 /selector
와 /atoms
로 구분하는 것이 아니라 데이터 원천별로 구분합니다.
특정 아톰으로부터 derived
되는 셀렉터는 결국 해당 아톰과 동일한 데이터를 다루는 것이 중요합니다. 위의 투두리스트 관련 아톰을 폴더 구조로 표현하면 다음과 같게 됩니다.
.
├── App.tsx
├── index.tsx
└── src
├── components
│ └── Main.tsx
└── recoil
|── todo
└── atom.ts
└── withCompleted.ts
# 3. Export 구조 개선
위의 투두 관련 상태값을 관리할 때에 atom
및 withCompleted
아톰을 매번 Main.tsx
에서 상대경로 표기를 통해 임포트 하기에는 직관성이 많이 떨어지게 됩니다.
아톰 관리 시 투두항목과 더불어 투두의 카테고리 항목도 아톰으로 쪼개어 관리하는 상황이라고 가정하겠습니다.
폴더구조는 다음과 같게 됩니다.
recoil
├── category
│ ├── atom.ts
│ └── index.ts
└── todo
├── atom.ts
├── index.ts
└── withCompleted.ts
네이밍 컨벤션과 익스포트 구조 개선점을 반영한 코드는 다음과 같습니다.
// category/atom.ts
import { atom } from 'recoil';
export enum Categories {
'TO_DO' = 'TO_DO',
'DOING' = 'DOING',
'DONE' = 'DONE',
}
const categoryAtom = atom<Categories>({
key: 'category',
default: Categories.TO_DO,
});
// export default 사용, atom만 익스포트
export default categoryAtom;
// category/index.ts
import categoryAtom from './atom';
export default categoryAtom;
사실 위 코드는 필요없는 코드입니다. categoryAtom에서는 별다른 셀렉터가 존재하지 않기 때문에 index.ts파일을 거쳐갈 필요가 없습니다. index.ts파일의 사용은 바로 아래에 소개됩니다.
// todo/atom.ts
import { atom } from 'recoil';
import { Categories } from '../../atoms';
export interface ITodo {
text: string;
category: Categories;
id: number;
}
// Atom suffix
const todoAtom = atom<ITodo[]>({
key: 'todo',
default: [],
});
// export default -> index.ts에서 import
export default todoAtom;
// todo/withCompleted.ts
import { selector } from 'recoil';
import todoAtom from './atom';
import categoryAtom from '../category/atom';
// Selector suffix -> with<Something>
const todoWithCompleted = selector({
key: 'todoWithCompleted',
get: ({ get }) => {
const toDos = get(todoAtom);
const category = get(categoryAtom);
return toDos.filter((todo) => todo.category === category);
},
});
// export default
export default todoWithCompleted;
// todo/index.ts
import todoAtom from './atom';
import withCompleted from './withCompleted';
export { withCompleted };
export default todoAtom;
위 코드를 보면 셀렉터는 export
만 적용하여 여러 개의 셀렉터가 추가될 시 객체 안에 해당 셀렉터만 추가하도록 하고, 아톰의 경우 index.ts에서 export default
를 적용하여 원천데이터에 대한 표시를 해놓습니다.
이후 todo
데이터를 다루는 아톰을 임포트하는 컴포넌트의 코드를 보면 다음과 같게 됩니다.
import todoAtom, { withCategory } from 'recoil/todo';
참고로 index.ts
파일을 사용하면 암묵적으로 폴더 이름만 했을때 자동으로 index.js
파일을 임포트하게 됩니다.
# 4. 키값 충돌 방지
마지막으로 아톰에는 셀렉터를 포함하여 key
값 설정을 해야합니다. 이때 기억나지 않는 이름으로 아무거나 사용하게 되면 어쩌다가 키값 충돌 문제가 발생할 가능성이 존재하게 됩니다.
따라서 셀렉터 및 아톰 생성 시 키값과 객체명을 다음 값들을 모두 합쳐 카멜케이스로 작성합니다.
- 데이터 관리에 따른 폴더명 (todo, category)
- 아톰파일이라면 접미사로 Atom
- 셀렉터파일이라면 접미사로
With<Something>
이에 따라 마지막으로 리팩토링을 진행하면 다음과 같습니다. (todo 아톰에 대해서만 정리하겠습니다.)
// todo/withCompleted.ts
import { selector } from 'recoil';
import todoAtom from './atom';
import categoryAtom from '../category/atom';
// Selector suffix -> with<Something>
const todoWithCompleted = selector({
key: 'todoWithCompleted',
get: ({ get }) => {
const toDos = get(todoAtom);
const category = get(categoryAtom);
return toDos.filter((todo) => todo.category === category);
},
});
// export default
export default todoWithCompleted;
// todo/index.ts
import todoAtom from './atom';
import withCompleted from './withCompleted';
export { withCompleted };
export default todoAtom;
익스포트는 todoWithCompleted
로 했더라도 export default
를 적용하였으므로 다른 파일에서 임포트 할때에는 원하는 이름으로 임포트 해도 됩니다. withCompleted
로 임포트 후, 해당 데이터를 default
옵션 없이 익스포트 하였습니다.