상태관리 라이브러리 mobx

일 년 정도 리액트, 리덕스 조합으로 사용해 봤다. 뷰(vue)나 모빅스(mobx)에 비해 러닝커브가 있다고 했지만 반복하고 시간이 지나니 익숙해 지더라.

그러던 중 신규 프로젝트에 상태관리 라이브러리로 모빅스를 사용해 보자는 의견이 팀에서 나왔고 비교적 단순해 보였다. 사용해 본 결과, 역시 단순하다.

문서 기준으로 기본 개념을 정리해 보자.

기본 개념

세 가지 용어가 등장한다.

  • 상태(state)
  • 데리베이션(derivation)
  • 액션(action)

상태

상태는 어플리케이션의 상태를 담고 있는 값이다. 예를 들어 타이머 경과 시간인 ts 정수나 동작 여부를 나타내는 불리언 값 따위가 될 수 있다.

// 상태
const state = observer({
  ts: 0, //
  isRunning: false,
})

데리베이션

데리베이션은 상태의 변화에 반응하는 것을 말한다. 데이베이션을 번역하기가 마땅치 않아서 영어 단어 그대로 사용했는데 사전에 보면 "유도", "파생" 이라는 의미를 가진다. 상태의 변화에 따라 반응하는 것은 UI나 데이터 그리고 백엔드와의 연동이 있을 수 있겠다.

데리베이션은 두 가지로 분류할 수 있다.

  • 계산된 값(computed values)
  • 리액션(reactions)

계산된 값은 순수 함수를 사용하는데 가령 타이머 경과 시간이 지날때 마다 화면에 출력하기 위한 값이 반응하는 것이 계산된 값이다.

// 계산된 값
const elapsedTime = computed(() => {
  const sec = Math.floor(state.ts / 1000)
  const ms = Math.floor((state.ts % 1000) / 10)
  return `${sec}:${ms}`
})

순수 함수를 사용하는 계산된 값에 비해 리액션은 순수하지 않는, 즉 사이드 이펙트(side effiect)를 만든다. 타이머 시간이 경과할 때마다 화면에 출력할 값이 변경되고 이를 그리기 위해 돔(DOM)을 조작하는 행위다.

// 리액션
autorun(() => {
  document.querySelector("#timer").innerHTML = elapsedTime
})

액션

데리베이션을 유도하기 위해서는 상태를 변경해야 하는데 이 역할을 하는 것을 액션이라고 한다. 상태를 변경하는 액션은 뭐 거창한 것이 아니라 단순히 상태에 값을 할당하는 행위다.

물론 엄격 모드(strict mode)에서는 action 에서만 상태를 변경하도록 강제하지만 실제 프로젝트할 때는 상태 변수에 값을 할당하는 것 만으로 잘 동작했다. 이것이 리덕스에 비해 모빅스의 장점이라고 생각한다.

function startTimer() {
  timerId = setInterval(() => {
    // 액션
    state.ts = state.ts + 10
  }, 10)
  // 액션
  state.isRunning = true
}

전체 코드를 죽 한 번 읽어 보자.

import { autorun, computed, observable } from "mobx"

// 상태
const state = observable({
  ts: 0,
  isRunning: false,
})

// 계산된 값
const elapsedTime = computed(() => {
  const sec = Math.floor(state.ts / 1000)
  const ms = Math.floor((state.ts % 1000) / 10)
  return `${sec}:${ms}`
})

let timerId = null

const $timer = document.querySelector("#timer")
const $button = document.querySelector("#button")

$button.addEventListener("click", () => {
  state.isRunning ? stopTimer() : startTimer()
})

function stopTimer() {
  if (timerId) clearInterval(timerId)
  // 액션
  state.isRunning = false
}

function startTimer() {
  timerId = setInterval(() => {
    // 액션
    state.ts = state.ts + 10
  }, 10)
  // 액션
  state.isRunning = true
}

// 리액션
autorun(() => {
  $timer.innerHTML = elapsedTime
})

// 리액션
autorun(() => {
  const btnTxt = state.isRunning ? "Stop" : "Start"
  $button.textContent = `${btnTxt}`
})

몇 가지 원칙

위에서 설명한 세가지 개념은 다음과 같은 순서로 동작한다.

액션 - 스테이트 - 뷰 (출처: https://mobx.js.org/the-gist-of-mobx.html)
액션 - 스테이트 - 뷰 (출처: https://mobx.js.org/the-gist-of-mobx.html)

  • 액션이 상태를 변경한다(타이머 값을 변경).
  • 변경된 상태에 따라 이를 감시하고 있는 계산된 값이 반응한다(elapsedTime을 다시 계산).
  • 경우에 따라서는 리액션이 발생하기도 한다(돔을 변경).

데리베이션은 몇 가지 특징이 있는데 그냥 참고 정도로 하면 좋겠다.

원자적 데리베이션

상태를 변경하는 채널은 여러개 일 수도 있다. 동시에 여러 채널에서 상태를 변경하려고 하면 데리베이션도 여러번 발생해서 렌더딩할 때 중간값이 노출 되지는 않을까? 하지만 그렇지 않다. Mobx는 값을 원자적으로 변경하기 때문에 여러번 요청이 오더라도 단 한 번만 반응하기 때문이다.

All Derivations are updated automatically and atomically when the state changes. As a result it is never possible to observe intermediate values.

리덕스 사가를 사용하면 takeLates() 같은 함수를 써서 여러번 요청이 올 때 마지막 한번만 잡는 방식을 썼는데 이걸 모빅스 기본으로 해 주는 것 같다.

동기적 데리베이션

액션에서 상태를 변경하고 나서 곧장 상태값을 조회할 때 변경 완료된 값인지 보장할 수 있을까? 모든 데리베이션은 기본적으로 동기적으로 갱신된다. 따라서 상태를 변경하는 액션은 안전하게 변경된 값에 접근 할 수 있다.

All Derivations are updated synchronously by default. This means that, for example, actions can safely inspect a computed value directly after altering the state.

계산된 값은 느리게

계산된 값은 느리게 갱신된다. 이게 무슨 의미 일까? 계산된 값은 모든 상태 변화에 반응하지는 않는다. 계산된 값이 사용되어야만 동작한다.

Computed values are updated lazily. Any computed value that is not actively in use will not be updated until it is needed for a side effect (I/O). If a view is no longer in use it will be garbage collected automatically.

리액트에서 사용하기

이전 코드에서는 mobx 라이브러만 사용했다. autorun() 함수로 리액션을 만들었는데 상태 변화에 따라 돔을 갱신하기 위해서다.

리액트 프로젝트에서 모빅스를 사용하려면 상태 변화에 따라 리액트 렌더 사이클을 돌려주어야 하는 역할잉 필요하다. 보통 리액트 컴포넌트는 프롭스 변화에 따라 렌더함수를 다시 실행한다. 리덕스를 사용할 때 connect() 함수의 역할이 이것이다.

모빅스도 리액트에서 사용하려면 이러한 역할이 필요한데 react-mox 패키지가 바로 그런 녀석이다. 리액트 컴포넌트가 모빅스 상태에 반응하도록 만들어 주는 HOC이다.

이전에 autorun() 으로 돔을 변경한 것 처럼 react-mox의 observer() 함수로 컴포넌트르 그렇게 만든다.

const App = () => <Timer />

// 리액션
export default observer(App)

상태와 계산된 값 그리고 액션은 리액트와 무관하게 동일하게 사용한다. 전체 코드를 죽 한 번 읽어 보자.

import * as React from "react"
import { computed, observable } from "mobx"
import { observer } from "mobx-react"

// 상태
const state = observable({
  ts: 0,
  isRunning: false,
})

// 계산된 값
const elapsedTime = computed(() => {
  const sec = Math.floor(state.ts / 1000)
  const ms = Math.floor((state.ts % 1000) / 10)
  return `${sec}:${ms}`
})

let timerId: number | null = null

const App = () => {
  const handleClick = () => {
    if (state.isRunning) stopTimer()
    else startTimer()
  }

  const stopTimer = () => {
    if (timerId) clearInterval(timerId)
    // 액션
    state.isRunning = false
  }

  const startTimer = () => {
    timerId = setInterval(() => {
      // 액션
      state.ts = state.ts + 10
    }, 10)
    // 액션
    state.isRunning = true
  }

  return (
    <>
      <div>{elapsedTime.get()}</div>
      <button onClick={handleClick}>
        {state.isRunning ? "Stop" : "Start"}
      </button>
    </>
  )
}

// 리액션
export default observer(App)

정리

비교적 리덕스 보다는 단순한 것 처럼 보인다. 특히 액션을 관리가 그러한데, 액션 타입을 정의하고, 액션 생성자를 만들고, 리듀서가 액션 타입에 따라 상태를 조작하는 것이 좀 번거로웠다. 반면 모빅스는 자바스크립트 값을 할당하듯이 상태 변수에 직접 값을 지정하면 상태를 변경할 수 있다.

어떤 이는 모빅스를 두고 흑마법을 부린다고 표현했는데 내게는 백바법(?)처럼 다가왔다. 상태관리를 위한 코드가 눈에 띄게 줄어들었기 때문이다. 뿐만 아니라 거대한 단일 스토어를 만들 필요없이 필요에 따라 경량 스토어를 만들어 사용할 수 있어서 부담이 적었다.

6주 전에 이 글을 쓰려고 마음먹었는데 너무 오랜시간 방치하고 있었다. 마침내 모빅스 6.0 업데이트를 보고 부랴부랴 글을 발행한다.

참고