[Nuxt.js-7] Nuxt.js 상태 관리 심화 (Pinia)

  • 카카오톡 공유하기
  • 네이버 블로그 공유하기
  • 네이버 밴드에 공유하기
  • 페이스북 공유하기
  • 트위터 공유하기
  • 링크 복사하기

안녕하세요, Nuxt.js 7번째 포스트입니다! 지난 번에는 Nuxt의 기본적인 상태 관리 방법인 useState에 대해 알아보았습니다. 이번 시간에는 대규모 애플리케이션에서 효과적으로 상태를 관리할 수 있는 Pinia에 대해 알아보겠습니다.

<출처: https://pinia.vuejs.kr/>

1. Pinia란 무엇인가?

Pinia는 Vue 생태계의 공식 상태 관리 라이브러리로, Vue 3와 함께 사용하도록 설계되었습니다. 기존의 Vuex를 대체하는 차세대 상태 관리 도구로, 더 간단한 API와 타입스크립트 지원, 더 나은 개발자 경험을 제공합니다.

Pinia의 주요 특징:

  • 타입스크립트와의 완벽한 호환성
  • 모듈식 설계로 코드 분할 지원
  • 가벼운 크기 (1KB 미만)
  • Vue DevTools와의 통합
  • 서버 사이드 렌더링 지원
  • 테스트 용이성

2. Nuxt.js에 Pinia 설치하기

Nuxt.js에서는 Pinia를 공식 모듈로 제공하므로 쉽게 통합할 수 있습니다.

설치 방법:

설치 후 nuxt.config.ts 파일에 모듈을 추가합니다:

// nuxt.config.ts
export default defineNuxtConfig({
  modules: [
  ...
    '@pinia/nuxt',
  ],
})

3. Pinia의 핵심 개념

Pinia는 몇 가지 핵심 개념을 중심으로 설계되었습니다:

3.1 Store

Store는 상태(state)를 포함하는 중앙 저장소입니다. 각 Store는 독립적이며, 특정 기능이나 도메인에 집중할 수 있습니다.
모든 컴포넌트에서 공유 가능한 상태를 관리하고, 로직을 처리할수 있습니다.

3.2 State

State는 애플리케이션의 상태 데이터를 저장하는 곳입니다. 반응형으로 작동하여 변경 시 관련 컴포넌트가 자동으로 업데이트됩니다.
모든 컴포넌트에서 접근하여 읽고 쓸 수 있습니다.

3.3 Getters

Getters는 상태를 기반으로 계산된 값을 반환하는 함수입니다. Vue의 computed 속성과 유사하게 작동합니다.
컴포넌트에서 직접 스테이트를 조작하지 않고, 게터를 통해 안전하게 값을 가져올 수 있습니다.

3.4 Actions

Actions는 상태를 변경하는 비즈니스 로직을 포함하는 메서드입니다. 비동기 작업으로 스테이트를 변경하는 처리할 수 있습니다.

4. Pinia Store 생성하기

Nuxt 프로젝트에서는 일반적으로 ‘stores’ 디렉토리에 Pinia 스토어를 생성합니다.

기본 스토어 구조:

// stores/counter.ts
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', {
  // 상태(state)
  state: () => ({
    count: 0,
    name: 'Counter'
  }),
  
  // 게터(getters)
  getters: {
    doubleCount: (state) => state.count * 2,
    // 다른 게터에 접근하기
    doubleCountPlusOne(): number {
      return this.doubleCount + 1
    }
  },
  
  // 액션(actions)
  actions: {
    increment() {
      this.count++
    },
    async fetchCountFromAPI() {
      try {
        const data = await fetch('/api/counter')
        const result = await data.json()
        this.count = result.count
      } catch (error) {
        console.error('Failed to fetch count:', error)
      }
    }
  }
})

Composition API 방식으로 스토어 정의하기:

// stores/counter.ts
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

export const useCounterStore = defineStore('counter', () => {
  // 상태(state)
  const count = ref(0)
  const name = ref('Counter')
  
  // 게터(getters)
  const doubleCount = computed(() => count.value * 2)
  
  // 액션(actions)
  function increment() {
    count.value++
  }
  
  async function fetchCountFromAPI() {
    try {
      const data = await fetch('/api/counter')
      const result = await data.json()
      count.value = result.count
    } catch (error) {
      console.error('Failed to fetch count:', error)
    }
  }
  
  return { count, name, doubleCount, increment, fetchCountFromAPI }
})

5. 컴포넌트에서 Pinia 스토어 사용하기

스토어를 정의한 후, 컴포넌트에서 쉽게 사용할 수 있습니다:

<template>
  <div>
    <h2>{{ counterStore.name }}</h2>
    <p>Count: {{ counterStore.count }}</p>
    <p>Double Count: {{ counterStore.doubleCount }}</p>
    <button @click="counterStore.increment()">Increment</button>
    <button @click="counterStore.fetchCountFromAPI()">Fetch from API</button>
  </div>
</template>

<script setup>
import { useCounterStore } from '~/stores/counter'

// 스토어 인스턴스 가져오기
const counterStore = useCounterStore()
</script>

6. 스토어 구조화 전략

대규모 애플리케이션에서는 스토어를 논리적으로 구조화하는 것이 중요합니다:

6.1 도메인별 스토어 분리

stores/
  ├── user.ts       // 사용자 관련 상태
  ├── products.ts   // 제품 관련 상태
  ├── cart.ts       // 장바구니 관련 상태
  └── ui.ts         // UI 상태 (테마, 사이드바 등)

6.2 스토어 간 상호작용

다른 스토어에 접근하여 상호작용할 수 있습니다:

// stores/cart.ts
import { defineStore } from 'pinia'
import { useProductsStore } from './products'

export const useCartStore = defineStore('cart', {
  state: () => ({
    items: []
  }),
  actions: {
    addItem(productId, quantity = 1) {
      const productsStore = useProductsStore()
      const product = productsStore.getProductById(productId)
      
      if (product && product.inStock) {
        this.items.push({
          productId,
          quantity,
          price: product.price
        })
      }
    }
  }
})

7. Pinia 고급 기능

7.1 스토어 상태 구독하기

스토어의 변경사항을 감지하고 반응할 수 있습니다:

const counterStore = useCounterStore()

// 스토어 전체 상태 변경 감지
const unsubscribe = counterStore.$subscribe((mutation, state) => {
  console.log('State changed:', mutation, state)
  
  // 변경사항을 로컬 스토리지에 저장
  localStorage.setItem('counter', JSON.stringify(state))
})

// 구독 취소
onUnmounted(() => {
  unsubscribe()
})

7.2 플러그인으로 기능 확장

Pinia는 플러그인을 통해 기능을 확장할 수 있습니다:

// plugins/pinia-persisted-state.ts
import { PiniaPluginContext } from 'pinia'

export default defineNuxtPlugin(({ $pinia }) => {
  // 로컬 스토리지에 상태 저장 플러그인
  $pinia.use(({ store }: PiniaPluginContext) => {
    // 초기 상태 복원
    const savedState = localStorage.getItem(`${store.$id}`)
    if (savedState) {
      store.$patch(JSON.parse(savedState))
    }
    
    // 변경사항 저장
    store.$subscribe((mutation, state) => {
      localStorage.setItem(store.$id, JSON.stringify(state))
    })
  })
})

8. Pinia와 서버 사이드 렌더링

Nuxt.js의 SSR 환경에서 Pinia를 사용할 때 몇 가지 고려해야 할 사항이 있습니다:

8.1 하이드레이션(Hydration)

Pinia는 서버에서 생성된 상태를 클라이언트로 자동 전달합니다. 이 과정을 ‘하이드레이션’이라고 합니다.

8.2 서버에서만 실행되는 코드 처리

// stores/user.ts
export const useUserStore = defineStore('user', {
  state: () => ({
    user: null
  }),
  actions: {
    async fetchUser() {
      // 서버와 클라이언트 모두에서 안전하게 실행
      const { data } = await useFetch('/api/user')
      this.user = data.value
      
      // 클라이언트에서만 실행되어야 하는 코드
      if (process.client) {
        localStorage.setItem('lastUserFetch', new Date().toISOString())
      }
    }
  }
})

9. 실전 예제: Todo 애플리케이션

이제 Pinia를 사용한 간단한 Todo 애플리케이션을 만들어 보겠습니다:

9.1 Todo 스토어 생성

// stores/todos.ts
import { defineStore } from 'pinia'

interface Todo {
  id: number
  text: string
  completed: boolean
}

export const useTodosStore = defineStore('todos', {
  state: () => ({
    todos: [] as Todo[],
    filter: 'all' // 'all', 'completed', 'active'
  }),
  
  getters: {
    filteredTodos(): Todo[] {
      if (this.filter === 'completed') {
        return this.todos.filter(todo => todo.completed)
      } else if (this.filter === 'active') {
        return this.todos.filter(todo => !todo.completed)
      }
      return this.todos
    },
    
    completedCount(): number {
      return this.todos.filter(todo => todo.completed).length
    },
    
    totalCount(): number {
      return this.todos.length
    }
  },
  
  actions: {
    addTodo(text: string) {
      if (!text.trim()) return
      
      const newTodo: Todo = {
        id: Date.now(),
        text: text.trim(),
        completed: false
      }
      
      this.todos.push(newTodo)
    },
    
    removeTodo(id: number) {
      const index = this.todos.findIndex(todo => todo.id === id)
      if (index !== -1) {
        this.todos.splice(index, 1)
      }
    },
    
    toggleTodo(id: number) {
      const todo = this.todos.find(todo => todo.id === id)
      if (todo) {
        todo.completed = !todo.completed
      }
    },
    
    setFilter(filter: string) {
      this.filter = filter
    },
    
    clearCompleted() {
      this.todos = this.todos.filter(todo => !todo.completed)
    }
  }
})

9.2 Todo 컴포넌트 생성

<template>
  <div class="todo-app">
    <h1>Todo 애플리케이션</h1>
    
    <div class="add-todo">
      <input 
        v-model="newTodo" 
        @keyup.enter="addTodo"
        placeholder="할 일을 입력하세요"
      />
      <button @click="addTodo">추가</button>
    </div>
    
    <div class="filters">
      <button 
        :class="{ active: todosStore.filter === 'all' }"
        @click="todosStore.setFilter('all')"
      >모두</button>
      <button 
        :class="{ active: todosStore.filter === 'active' }"
        @click="todosStore.setFilter('active')"
      >미완료</button>
      <button 
        :class="{ active: todosStore.filter === 'completed' }"
        @click="todosStore.setFilter('completed')"
      >완료</button>
    </div>
    
    <ul class="todo-list">
      <li v-for="todo in todosStore.filteredTodos" :key="todo.id">
        <input 
          type="checkbox" 
          :checked="todo.completed"
          @change="todosStore.toggleTodo(todo.id)"
        />
        <span :class="{ completed: todo.completed }">{{ todo.text }}</span>
        <button @click="todosStore.removeTodo(todo.id)">삭제</button>
      </li>
    </ul>
    
    <div class="todo-stats">
      <span>{{ todosStore.completedCount }} / {{ todosStore.totalCount }} 완료</span>
      <button v-if="todosStore.completedCount > 0" @click="todosStore.clearCompleted">
        완료된 항목 삭제
      </button>
    </div>
  </div>
</template>

<script setup>
import { ref } from 'vue'
import { useTodosStore } from '~/stores/todos'

const todosStore = useTodosStore()
const newTodo = ref('')

function addTodo() {
  todosStore.addTodo(newTodo.value)
  newTodo.value = ''
}
</script>

<style scoped>
.todo-app {
  max-width: 500px;
  margin: 0 auto;
  padding: 20px;
}

.completed {
  text-decoration: line-through;
  color: #888;
}

.todo-list li {
  display: flex;
  align-items: center;
  padding: 8px 0;
  border-bottom: 1px solid #eee;
}

.filters button {
  margin-right: 8px;
}

.filters button.active {
  font-weight: bold;
  color: #42b883;
}

.todo-stats {
  margin-top: 20px;
  display: flex;
  justify-content: space-between;
}
</style>
<실행 결과>

10. Pinia 사용 시 모범 사례

10.1 스토어 구성 가이드라인

  • 기능별로 스토어 분리하기
  • 스토어 간 의존성 최소화하기
  • 상태 변경은 항상 액션을 통해 수행하기
  • 복잡한 계산은 게터로 분리하기

10.2 성능 최적화

  • 불필요한 반응형 객체 생성 피하기
  • 큰 컬렉션은 Map이나 Set 사용 고려하기
  • 필요한 경우에만 깊은 복사 사용하기

10.3 디버깅 팁

  • Vue DevTools 활용하기
  • $subscribe를 이용한 로깅
  • 스토어 상태 스냅샷 저장하기

11. Pinia vs useState: 언제 무엇을 사용할까?

Nuxt의 useState와 Pinia는 각각 다른 사용 사례에 적합합니다:

useState 사용이 적합한 경우:

  • 간단한 상태 관리가 필요할 때
  • 페이지나 컴포넌트 간에 공유해야 하는 상태가 적을 때
  • 프로젝트 규모가 작을 때

Pinia 사용이 적합한 경우:

  • 복잡한 상태 관리가 필요할 때
  • 여러 모듈로 분리된 상태 관리가 필요할 때
  • 비동기 작업이 많을 때
  • 중간 이상 규모의 프로젝트
  • 팀 단위로 개발할 때 (명확한 구조 제공)

마무리

이번 포스트에서는 Nuxt.js에서 Pinia를 활용한 상태 관리 심화 내용을 살펴보았습니다. Pinia는 Vue 생태계의 공식 상태 관리 라이브러리로, 타입스크립트 지원과 모듈화된 구조를 통해 대규모 애플리케이션에서도 효과적인 상태 관리를 가능하게 합니다.

스토어, 상태, 게터, 액션의 개념을 이해하고 실제 Todo 애플리케이션 예제를 통해 Pinia의 활용법에 대해서 설명했습니다. 이제 복잡한 상태 관리가 필요한 대규모 Nuxt 애플리케이션을 구축할 수 있게 되었습니다!

다음 포스트 미리보기: 서버 디렉토리 (Server Directory) 활용

다음 포스트에서는 Nuxt.js의 강력한 기능 중 하나인 서버 디렉토리 활용에 대해 포스팅 할 예정입니다. Nuxt의 내장 서버 엔진인 Nitro를 활용하여 자체 API 엔드포인트를 구축하는 방법을 설명하고, server/api 라우트 생성과 server/middleware를 이용한 요청/응답 처리 방법을 자세히 살펴보겠습니다. 풀스택 개발의 세계로 한 걸음 더 나아가는 시간이 될 것입니다.


게시됨

카테고리

작성자

댓글

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다