RicoCheesethe studio log · v2.0
Live · KRRead posts
목록으로
뉴스PUBLISHED · 2026년 8월 19일·10 MIN READ

프론트엔드 상태 관리, 뮤테이터 패턴 (State Management: Mutators)

Valtio와 Pinia가 채택한 뮤테이터 패턴을 액션·디스패치·리듀서 방식과 비교한다. 상태 일부만 바꿀 수 있어 직관적이지만, 그만큼 추적하기 어려운 버그가 생길 여지도 커진다.

#frontend#react#javascript#webdev#typescript
State Management in Front-end Web Development: Mutators

개요 #

React 생태계의 Valtio, Vue 생태계의 Pinia는 액션·디스패치·리듀서 대신 뮤테이터(mutator) 패턴으로 상태를 관리한다. 상태 객체 전체를 새로 만들어 갈아끼우는 대신, 바꾸고 싶은 부분만 직접 건드린다.

Abbey Perini는 상태 관리 시리즈의 이번 편에서 두 라이브러리의 예제를 나란히 놓고 뮤테이터 패턴의 장단점을 정리했다. 예제 코드는 react-state 저장소vue-state 저장소에 있다. 버전은 React 19.1.0, Valtio 2.3.2, Vue 13.5.40, Pinia 4.0.3 기준이다.

가변 상태라는 선택 #

뮤테이터는 가변(mutable) 상태를 변경한다. 전체를 교체하지 않고 일부만 바꾸니 배우기도 쉽고 계산량도 적다. 그렇다면 왜 항상 가변 상태를 쓰지 않는가.

뮤테이터 패턴은 상태 갱신 메서드 안에 부수 효과를 둘 수 있게 허용한다. 가변 상태는 비결정적이다. 경쟁 상태(race condition)처럼 재현이 어렵고 추적도 힘든 버그가 쉽게 생긴다. 메서드 호출 순서에 따라 결과가 크게 달라지고, 병렬 처리가 끼면 상황은 더 나빠진다.

액션·디스패치·리듀서 패턴은 상태가 가질 수 있는 모든 조합을 스토어 안에 정의하고 처리하도록 강제한다. 반면 뮤테이터는 그 처리를 원하는 시점, 원하는 위치에서 하게 해준다. 스토어 밖도 가능하다. 그 대가로 모든 경우를 컴포넌트마다 처리하는 장황한 코드가 나올 수 있다.

뮤테이터 패턴 자체가 버그를 만든다는 뜻은 아니다. 액션·디스패치·리듀서가 엄격한 규칙을 부과하는 자리에, 뮤테이터는 자유를 준다. 자유가 늘어난 만큼 버그를 만들 기회도 늘어난다.

상태 관리 패턴을 정리하는 작업 Photo by cottonbro studio on Pexels

뮤테이터는 옵저버 패턴 그 자체 #

시리즈 1편에서 저자는 반응형 상태란 결국 게터와 세터를 만드는 일이라고 했다. 같은 패턴을 부르는 다른 이름이 접근자(accessor)와 뮤테이터다.

즉 액션·디스패치·리듀서로 옵저버 패턴을 구성하는 것과 달리, 뮤테이터는 옵저버 패턴을 그냥 쓴다.

내부 구현은 보통 Proxy다. Proxy는 대상(target)이 되는 자바스크립트 객체의 사본과 핸들러로 이뤄진다. 핸들러가 접근자·뮤테이터 같은 연산을 정의한다. 상태를 읽는 메서드는 게터 또는 접근자, 상태를 바꾸는 메서드는 보통 액션이라고 부른다.

Pinia 예제 #

먼저 Pinia를 플러그인으로 등록한다.

untitled
// main.ts
import { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';

const pinia = createPinia();
const app = createApp(App);
app.use(pinia);
app.mount('#app');

저자는 Setup Store 문법을 골랐다. Composition API 문법이 더 익숙하고, 스토어 안에서 watcher컴포저블을 쓸 수 있기 때문이다. Options Stores 문법이 편하다면 공식 문서에 예제가 있다.

다음은 스토어다. 액션이라 부르는 갱신 메서드까지 전부 스토어 안에 넣을 수 있다.

untitled
// stores/shibas.ts
import { defineStore } from 'pinia';
import { ref, type Ref } from 'vue';

export const useShibaStore = defineStore('shibas', () => {
  // state
  const count = ref(0);
  const shibaList: Ref<string[]> = ref([]);
  const pending = ref(false);
  const errorMessage = ref("");

  // action
  async function increment(number: number) {
    errorMessage.value = "";
    count.value += number;
    pending.value = true;
    const response = await fetch(`https://dog.ceo/api/breed/shiba/images/random/${number}`);
    const shibas = await response.json();
    pending.value = false;
    if (shibas.status !== "success") {
      errorMessage.value = shibas.status!.toString();
      return;
    }
    return shibas.message.forEach((shiba: string) => shibaList.value.push(shiba))
  }

  return { count, shibaList, increment, pending, errorMessage }
})

마지막으로 컴포넌트에서 스토어를 가져다 시바견을 화면에 뿌린다.

untitled
// components/shibaCounter.vue
<script setup lang="ts">
import { useShibaStore } from '@/stores/shibas';
import { storeToRefs } from 'pinia';
const shibas = useShibaStore();
// Destructuring without storeToRefs will break reactivity
const { count, shibaList, pending, errorMessage } = storeToRefs(shibas)

function handleIncrement(e: SubmitEvent) {
  const form = e.target as HTMLFormElement;

  // Actions can be used as store properties
  shibas.increment(parseInt(form.number.value));
}

</script>
<template>
  <div>
    <span>Shibas: {{count}}</span>
    <form @submit.prevent="handleIncrement">
      <label for='number'>Number</label>
      <input id='number' type="number"/>
      <button type="submit">Add Shibas</button>
    </form>
    <p v-if="pending">Pending</p>
    <p v-if="errorMessage.length > 0">{{errorMessage}}</p>
    <img v-for="shiba in shibaList" :key="shiba" :src="shiba" alt="shiba" />
  </div>
</template>
<style lang="css">
img {
  width: 300px;
}
</style>

주의할 점은 구조 분해다. storeToRefs 없이 분해하면 반응성이 깨진다.

Shibas: 0. After 5 is typed into an input and the add shibas button is clicked, it shows Shibas: 5. 5 images of shibas are added to the page. "Pending" shows while the API call is pending.

Valtio 예제 #

React 쪽 예제로 Zustand가 아니라 Valtio를 고른 이유가 있다. Zustand는 일종의 하이브리드다. 갱신 문법은 액션·디스패치·리듀서가 아니라 접근자·뮤테이터에 가깝지만, 내부에서 Immer를 써서 상태를 불변으로 유지한다. 갱신 로직이 너무 복잡해져 Immer가 따라가지 못하면 불변성을 직접 챙겨야 한다.

먼저 프록시, 즉 스토어를 만든다.

untitled
// ShibaCounter-Valtio.js
import { proxy, useSnapshot } from 'valtio'

export const shibaStore = proxy({
  count: 0,
  shibaList: [],
  pending: false,
  error: false,
});

컴포넌트는 useSnapshot으로 프록시에 접근한다. 변경을 감시하는 읽기 전용 구독자가 만들어진다. Redux와 달리 부수 효과를 일으키려고 useEffect에 매달릴 필요가 없다.

untitled
import { useSnapshot } from 'valtio';
import { shibaState, increment } from '../ShibaCounter-Valtio';

export function ShibaCounterValtio() {
  const shibaSnap = useSnapshot(shibaState);

  function handleIncrement(event) {
    event.preventDefault();
    increment(parseInt(event.target.number.value));
  }

  let shibaImages = shibaSnap.shibaList.map(shiba => {
    return (
      <img src={shiba} alt="shiba" key={shiba}>
      </img>
    );
  })

  return (
    <div>
      <span>Shibas: {shibaSnap.count}</span>
      <form onSubmit={(event) => handleIncrement(event)}>
        <label htmlFor='number'>Number</label>
        <input id='number' type="number"/>
        <button type="submit">Add Shibas</button>
      </form>
      { shibaSnap.pending && <p>Pending</p>}
      { shibaSnap.error && <p>{error}</p> }
      { shibaImages }
    </div>
  )
}

마지막은 increment 액션이다. Valtio는 액션을 어디에 어떻게 두든 관여하지 않는다. 저자는 한곳에 모아두는 쪽을 좋아해서, 상태와 같은 파일에서 액션을 내보냈다.

untitled
// ShibaCounter-Valtio.js
export async function increment(number) {
    shibaState.error = "";
    shibaState.count += number;
    shibaState.pending = true;
    const response = await fetch(`https://dog.ceo/api/breed/shiba/images/random/${number}`);
    const shibas = await response.json();
    shibaState.pending = false;
    if (shibas.status !== "success") {
      shibaState.error = shibas.status.toString();
      return;
    }
    return shibas.message.forEach((shiba) => shibaState.shibaList.push(shiba))
  }

Shibas: 0. After 5 is typed into an input and the add shibas button is clicked, it shows Shibas: 5. 5 images of shibas are added to the page. "Pending" shows while the API call is pending.

정리 #

뮤테이터 패턴은 액션·디스패치·리듀서보다 훨씬 직관적이다. 대신 자유로운 만큼 추적하기 어려운 버그를 만들기 쉽다. 액션·디스패치·리듀서를 기본값으로 여기는 습관은 쉽게 사라지지 않는다. 이번 예제들조차 갱신 메서드를 액션이라 부른다. 저자는 두 라이브러리의 표면만 훑었다면서도, 둘 다 더 유연하고 시작하기가 훨씬 쉽다고 평가했다.


이 글은 위 출처를 바탕으로 한국 독자를 위해 재작성한 기사입니다. 원문의 사실과 수치에 근거하며, 별도의 견해를 포함하지 않습니다.