# 개요

리코일은 리액트의 전역 상태관리 라이브러리 중 하나입니다. 주로 쓰이는 리덕스에 비해 리액트 생태계에 나타난지 얼마 되지 않아 커뮤니티 형성이 크게 이루어지지 않고 있습니다.

사용자가 상대적으로 적다 보니 라이브러리 사용에 대해 이렇다 할 베스트 프랙티스가 고정되지 않고 있는 상황입니다. 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);
}

# 개선해야할 점

리코일 프로젝트 관리에 대한 개선점을 정리해보면 다음과 같습니다.

  1. 파일 네이밍이 헷갈린다.
  2. 작은 조각으로 관리할 아톰들을 자주 import해야하고 조각이 너무 많아져 생산성이 떨어진다. (폴더 구조 개선)
  3. 네이밍으로 인해 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'),
        ];
    },
});

위 코드를 다음 절차들에 따라 하나씩 수정해보겠습니다.

# 파일 네이밍 이슈

먼저 파일 네이밍에 관련되어서는 StateValue라는 접미사를 빼야합니다. 아톰을 표현했던 StateAtom으로, 셀렉터를 표현했던 Valuewith<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 구조 개선

위의 투두 관련 상태값을 관리할 때에 atomwithCompleted 아톰을 매번 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값 설정을 해야합니다. 이때 기억나지 않는 이름으로 아무거나 사용하게 되면 어쩌다가 키값 충돌 문제가 발생할 가능성이 존재하게 됩니다.

따라서 셀렉터 및 아톰 생성 시 키값과 객체명을 다음 값들을 모두 합쳐 카멜케이스로 작성합니다.

  1. 데이터 관리에 따른 폴더명 (todo, category)
  2. 아톰파일이라면 접미사로 Atom
  3. 셀렉터파일이라면 접미사로 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옵션 없이 익스포트 하였습니다.

# Reference

  1. Medium - Recoil Project Structure Best Practices (opens new window)
  2. Medium - 디렉토리 파일 export 하기, index.js의 사용 (opens new window)