가장 첫 단추는 기존 팀원의 설계
가 아닐까 생각해본다.
이 글은 FE 상태관리 패턴
대한 이야기이다.
View가 state를 들고있다. 이에 대한 비즈니스 로직도 들고있다.
물론, 이에 대한 의존성을 최소화 하기 위해 커스텀 훅과 상태관리 도구(contextAPI, 전역 상태관리 도구 등)를 사용한다.
문제점은 왜?
이다.
이에 대한 본질적인 이유를 신입 개발자가 알기 어려우며, 아래의 팀원
들은 이에 대해 깊은 고민을 하지 않고 구현에만 집중한다고 가정한다.
가장 추상화가 강제되어, 팀 프로젝트에 안정적이라고 평가받는 redux를 생각해보자.
slice 내부에 state를 변경시키는 actions(setter)가 추상화되어있다.
해당 라이브러리를 도입하면 slice 내부에서 setter를 강제적으로 추상화해야 하기 때문에, 컴포넌트에서 setter를 추상화 할 확률이 현저히 줄어든다. atom 기반 상태관리 도구의 잠재적 위험 요소를 배재한 것이다.
즉, redux를 도입하면 최소한 팀원이 setter를 컴포넌트(View) 내부에서 추상화하여 View와 state의 비즈니스 로직의 의존도가 올라갈 상황
을 배제한다.
이로써 팀원들이 작성하는 코드가 회사의 자본
이 될 확률은 올라간다는 것이다.
그럼에도 불구하고, 발생하는 코드 부채는 다음과 같다.
1. selector에 대한 컨벤션 부재. (심지어 컴포넌트에서 호출하는 경우가 잦아 View와 Redux의 새로운 의존성이 발생)
2. setter를 사용하기 위해 사용처에서 dispatch 함수를 래핑이 강제됨.
3. 하나의 객체에서 모든 도메인의 state가 관리된다.
KakaoStyle나 Toss에서 찾아본 레퍼런스로, 1번에 대한 문제를 폴더
로 나누어 추상화 된 selector를 export하여 해결한다. 다만, 문서 수준에서 관리
되며, 구현 방식의 강제성이 없다
는 점에서 약간 아쉬움이 남는다.
- 아래는 상민님이 작성하신 KakaoStyle 기술블로그 글이다. FE의 상태관리에 대한 고민과 각 라이브러리에 대한 한계를 잘 살펴볼 수 있다.
> 프론트엔드 상태 관리에 대한 여정
> Jotai 레시피
필자 원하는 최소한의 컨벤션은 아래와 같았다.
하나의 state에 대한
getter
와setter
는 하나의 객체 내에서 추상화 될 것.
필자는 여러 상태관리 도구를 찾아보다 자유롭게 추상화할 수 있는 recoil과 jotai를 고려하게 되었고, jotai를 선택하기로 했다.
import { Atom, WritableAtom, atom } from 'jotai';
export abstract class AtomManager<T> {
protected initialState: T;
protected atom: WritableAtom<T, any, void>;
constructor(initialState: T) {
this.initialState = initialState;
this.atom = atom(this.initialState);
}
abstract selectors: {
[K in keyof Partial<T>]: Atom<T[K]>;
};
abstract actions: {
[key: string]: WritableAtom<T | null, any, void>;
};
}
/**
* initialState까지 캡슐화 하고 싶은 경우 도입.
* 다만, 타입이 굉장히 엄격해져, 보편적인 상황에선 권장되지 않음.
*/
export interface AtomManagerStatic<T> {
new (initialState: T): AtomManager<T>;
INITIAL_STATE: T;
}
문서에 작성했듯이. getter, setter에 대한 추상화가 강제된 상태에서 더욱 효율적으로 쓸 수 있는 flow는 아래와 같다.
추상 클래스 상속 및 구현 => 커스텀 훅 => 컴포넌트(View)
import { atom } from 'jotai';
import { AtomManager } from '@pengoose/jotai';
import { PlaylistStatus, Music } from '@/types';
// AtomManager 상속
export class Playlist extends AtomManager<PlaylistStatus> {
// selectors(getters), actions(setters) 추상화
public selectors = {
playlist: atom((get) => get(this.atom).playlist),
currentMusic: atom((get) => {
const { playlist, index } = get(this.atom);
return playlist[index];
}),
};
public actions = {
add: atom(null, (get, set, music: Music) => {
const { playlist } = get(this.atom);
if (playlist.some(({ id }) => id === music.id)) return;
set(this.atom, (prev: PlaylistStatus) => ({
...prev,
playlist: [...prev.playlist, music],
}));
}),
};
// 해당 도메인에 대한 유틸함수 추상화 및 캡슐화
private isEmpty(playlist: Music[]) {
return playlist.length === 0;
}
private isFirstMusic(index: number) {
return index === 0;
}
}
// 생성
const initialState: PlaylistStatus = {
playlist: [],
index: 0,
};
export const playlistManager = new Playlist(initialState);
// usePlaylist.ts
import { useAtomValue, useSetAtom } from 'jotai';
import { playlistManager } from '@/viewModel';
export const usePlaylist = () => {
const {
selectors: { playlist, currentMusic },
actions: { play, next, prev, add, remove },
} = playlistManager;
return {
// #FIXME: 반복되는 useAtomValue, useSetAtom은 atomManager에 래핑한 유틸함수로 개선 예정.
// Getters(Selectors)
playlist: useAtomValue(playlist),
currentMusic: useAtomValue(currentMusic),
// Setters(Actions)
play: useSetAtom(play),
next: useSetAtom(next),
prev: useSetAtom(prev),
add: useSetAtom(add),
remove: useSetAtom(remove),
};
};
// Playlist.tsx
import { usePlaylist } from '@/hooks';
import { Music } from '@/types';
export const Playlist = () => {
const { playlist, currentMusic, play, next, prev, add, remove } =
usePlaylist();
return (
<div>
<h1>Playlist</h1>
<ul>
{playlist?.map((music) => {
const { id, title, thumbnail } = music;
return (
<li key={id}>
<img src={thumbnail} alt={title} />
<p>{title}</p>
<button onClick={() => remove(music)}>Remove</button>
</li>
);
})}
</ul>
<button onClick={() => add(currentMusic)}>Add to Playlist</button>
<button onClick={() => play(currentMusic)}>Play</button>
<button onClick={prev}>Prev</button>
<button onClick={next}>Next</button>
</div>
);
};
새로 합류한 팀원에게 아래의 규칙만 각인시키면 코드 레벨에서 상태 관리에 대한 컨벤션을 통제할 수 있다.
- 반드시 AtomManager를 상속 받아 구현할 것.
- atom은 반드시 커스텀 훅 내부에서만 호출할 것.
하나의 도메인에 대한 추상화(getter, setter)가 하나의 객체 내부로 강제되어 View 내부에 비즈니스 로직이 사라진다.
반복되지 않는 특정 도메인에 대한 util 함수를 캡슐화 할 수 있다.
DI를 이용해, State를 포함한 도메인 간의 복잡한 비즈니스 로직 구현을 간단하게 구현할 수 있다.
극단적인 예시로 상태관리 도구가 바뀌더라도 컴포넌트에 손을 댈 필요가 없다. 즉, 이는 View와 state의 깔끔한 분리를 의미한다.
// usePlaylist.ts
import { useWrapAtomManager } from '@pengoose/jotai';
import { useAtomValue, useSetAtom } from 'jotai';
import { playlistManager } from '@/viewModel';
export const usePlaylist = () => {
const {
selectors: { playlist, currentMusic },
actions: { play, next, prev, add, remove },
} = useWrapAtomManager(playlistManager)();
return {
// Getters(Selectors)
playlist,
currentMusic,
// Setters(Actions)
play,
next,
prev,
add,
remove,
};
};