필자는 프론트엔드 어플리케이션의 전역 상태 관리를 위해서 badland라는 라이브러리를 활용하고 있다. 이 글은 후에 면접에서 "상태관리 어떻게 하셨어요?"라고 묻는 질문에 답하기 위해서 작성하는 글이다.
- badland· GitHub @baealex #
우선 널리 알려진 상태 관리 라이브러리를 사용하지 않은 이유는 Redux의 상태관리가 지나치게 복잡해 보였기 때문이었고, Recoil은 간단했으나 (그 당시) IE에서 비정상적인 동작을 일으켰다. 그래서 보다 단순하게 전역 상태를 관리하기 위해 만들었던 것이 badland다. 이 글에서는 badland가 상태를 관리하는 방법과 기본적인 사용방법을 작성한다.
설치 : NPM
npm i --save badland
스토어 생성
badland는 리액트 클래스 컴포넌트에서 영감을 얻었다. 전역으로 공유하는 상태(이하 전역 상태)를 만드는 방법은 badland 라이브러리에서 기본적으로 export하고 있는 Store를 상속받는 클래스를 생성하면 된다.
import Store from 'badland'
class AuthStore extends Store {
constructor() {
super()
this.state = {
isLogin: false,
username: '',
}
}
}
export const authStore = new AuthStore()
순수 함수로 관리되는 상태 관리는 안정적이지만 때때로 코드의 가독을 지나치게 어렵게 만든다고 생각했다. 인증과 관련된 상태는 오롯이 인증 클래스 안에서 관리하는 것이 간단할 것이라 생각했다. 혹은 문법을 간소화한 아래 함수를 사용하여 생성할 수도 있다.
import { createStore } from 'badland'
export const authStore = createStore({
isLogin: false,
username: '',
})
상태의 변경
badland에서는 전역 상태 객체(this.state)를 직접 수정할 수 없다. 위 코드에 존재하는 this.state는 getter와 setter로 추상화 된 것이다. 전역 상태 객체를 변경하려면 부모 클래스에 정의된 set 메서드를 호출해야 한다. this.state에 값을 할당할 경우 최초 할당이 아니라면 내부적으로 set를 거쳐 할당된다.
badland의 전역 상태 객체는 내부적으로 배열로 관리하고 있으며 set 메서드가 호출되면 배열에 존재하는 마지막 상태 객체에 인자로 넘어온 객체를 합쳐 freeze 시킨 후 배열에 추가한다. (디버그 모드에서만 배열이 누적되며 디버그 모드가 아니면 이전 상태는 즉시 소멸된다.)
let newState: unknown = nextState;
const prevState = this._states[this._states.length - 1];
if (typeof newState === 'function') {
newState = newState(prevState);
}
if (typeof newState !== 'object') {
reject(new TypeError('nextState is not object.'));
}
newState = Object.freeze({
...prevState,
...newState,
});
this._states.push(newState);
set을 다양한 방법(함수로 전달, 객체로 전달)으로 호출할 수 있도록 만들었는데 위에서도 언급했듯이 리액트 클래스 컴포넌트의 setState와 동일하게 사용할 수 있도록 만들기 위함이다. 기본적으로는 다음과 같이 호출한다.
authStore.set({
isLogin: true,
username: 'baealex',
})
인자를 함수로 넘기면 이전 상태를 가져올 수 있다.
authStore.set((prevState) => ({
...prevState,
isLogin: true,
}))
이전 상태와 병합하기 때문에 다음과 같이 전달해도 무관하다.
authStore.set({
isLogin: true,
})
원하는 곳에서 직접 상태를 변경할 수 있겠지만 상태를 보다 직관적으로 관리하기 위해 클래스 내부에 메서드로 선언하는 것을 권장한다.
const INIT_STATE = {
isLogin: false,
username: '',
}
class AuthStore extends Store {
constructor() {
super()
this.state = INIT_STATE
}
login(username) {
this.set({ isLogin: true, username })
}
logout() {
this.set(INIT_STATE)
}
}
상태의 공유
스토어 구독
badland는 내부적으로 set이 호출되고 객체의 수정이 완료된 이후 observer라고 명명된 내부 변수에 저장되어 있던 함수들을 실행시키는 동작을 수행한다. 따라서 상태 공유가 필요한 경우 observer에 함수를 등록해야 하는데 subscribe라는 메서드를 통해서 추가할 수 있다.
예를들어 자바스크립트에서는
authStore.subscribe((state) => {
document.getElementById('root').innerHTML = state.isLogin
? `<div>${state.username}님 환영합니다!</div>`
: `<div>로그인이 필요합니다.</div>`
})
리액트에서는
const [ state, setState ] = useState(authStore.state)
useEffect(() => {
authStore.subscribe(setState)
}, [])
위와같은 형태로 set가 호출된 시점에 적절한 동작을 수행하도록 정의할 수 있다.
스토어 구독 해제
공유가 불필요한 경우 unsubscribe 메서드를 호출하여 함수를 제거할 수 있다. unsubscribe 메서드가 요구하는 인자는 uuid 값이다. uuid 값은 subscribe로 함수를 등록할 때 전달받는다. 리액트의 경우 컴포넌트 언마운트 시점에 unsubscribe 메서드를 호출하도록 해야한다. 단, 실행시 에러가 발생하는 observer는 임의로 unsubscribe 시킨다.
const [ state, setState ] = useState(authStore.state)
useEffect(() => {
const key = authStore.subscribe(setState)
return () => authStore.unsubscribe(key)
}, [])
리액트에서 위처럼 불필요하게 코드가 반복되는 경우가 많아 전용 메서드를 추가하였다.
const [ state, setState ] = useState(authStore.state)
useEffect(authStore.syncState(setState), [])
또는 badland-react
를 추가로 설치해서 더 간단하게 선언할 수 있다.
import { useStore } from 'badland-react'
const [ state, setState ] = useStore(authStore)
단일 데이터만 필요한 경우
const [ username, setUsername ] = useState(authStore.state.username)
useEffect(authStore.syncValue('username', setUsername), [])
또는 badland-react
에서 다음과 같이 선언할 수 있다.
import { useValue } from 'badland-react'
const [ username, setUsername ] = useValue(authStore, 'username')
스토어 상태 변경 감지
상태 변경을 핸들링하기 위해 set의 동작 전후로 호출하는 메서드가 존재한다.
- beforeStateChange
- afterStateChange
정확히 beforeStateChange는 set 메서드가 호출된 직후에 호출되며, afterStateChange는 모든 observer의 실행이 완료된 후에 실행된다. 즉, 상태와 UI의 변경이 이뤄진 이후 호출된다. 따라서 해당 시점에 자동적으로 수행해야 할 로직이 필요한 경우 이 메서드들을 오버라이딩하면 된다.
class AuthStore extends Store {
constructor() {
super()
this.state = INIT_STATE
}
login(username) {
this.set({ isLogin: true, username })
}
logout() {
this.set(INIT_STATE)
}
beforeStateChange() {
console.log('상태가 변경되기 직전입니다.', this.state)
}
afterStateChange() {
console.log('상태가 변경된 직후입니다.', this.state)
}
}
타입 지원
badland는 타입스크립트 기반으로 만들어져 타입을 지원한다. Store 클래스의 첫번째 재내릭 타입은 상태 객체의 타입이다.
import Store from 'badland'
interface AuthStoreState {
isLogin: boolean;
username: string;
}
const INIT_STATE = {
isLogin: false,
username: '',
}
class AuthStore extends Store<AuthStoreState> {
constructor() {
super()
this.state = INIT_STATE
}
login(username: string) {
this.set({ isLogin: true, username })
}
logout() {
this.set(INIT_STATE)
}
beforeStateChange() {
console.log('상태 변경이 예정된 상태입니다.', this.state)
}
afterStateChange() {
console.log('상태 변경이 완료 및 UI 업데이트가 모두 완료된 상태입니다.', this.state)
}
}
export const authStore = new AuthStore()
결론
결론적으로 badland는 상태를 변경할 수 있는 유일한 메서드인 set를 통해 상태를 변경하고 set는 observer에 등록된 모든 함수를 실행하여 상태의 변경을 알림과 동시에 전달하여 공유한다.
Ghost