Nuxt.js 여섯번째 포스팅입니다. 지난 번에는 데이터 가져오기(Data Fetching)에 대해 알아보았는데요, 이번 시간에는 Nuxt.js에서 상태 관리를 어떻게 할 수 있는지, 특히 useState
컴포저블을 활용한 방법에 대해 자세히 알아보겠습니다.
상태 관리(State Management)란?
웹 애플리케이션이 복잡해질수록 여러 컴포넌트 간에 데이터를 공유하고 관리하는 것이 중요해집니다. 이러한 데이터를 ‘상태(State)’라고 부르며, 이 상태를 효율적으로 관리하는 방법을 ‘상태 관리’라고 합니다. 프로젝트에서는 사용자가 웹페이지에 머무는 동안에 발생된 이벤트와 그로 인해 호출된 데이터의 변경에 대해 유지하거나, 변경된 정보를 반영해야 하는 경우들이 매우 흔합니다.
상태 관리가 필요한 상황은 다음과 같습니다:
- 여러 컴포넌트에서 동일한 데이터에 접근해야 할 때
- 사용자 인증 정보와 같이 애플리케이션 전반에 걸쳐 필요한 데이터가 있을 때
- 컴포넌트 간 데이터 전달이 복잡해질 때 (props drilling 문제)
- 페이지 새로고침 후에도 유지되어야 하는 데이터가 있을 때
Nuxt.js의 상태 관리 방식
Nuxt.js는 Vue.js를 기반으로 하기 때문에 Vue의 상태 관리 패턴을 따르지만, SSR(Server-Side Rendering)을 지원하는 프레임워크로서 몇 가지 특별한 고려사항이 있습니다.
Nuxt 3에서 부터는 다음과 같은 상태 관리 방법을 제공합니다:
- useState: Nuxt 3의 내장 컴포저블로, 서버와 클라이언트 간에 상태를 공유할 수 있습니다.
- Pinia: Vue.js 공식 상태 관리 라이브러리로, Nuxt 3와 완벽하게 통합됩니다.
- useStorage: 브라우저의 localStorage/sessionStorage와 연동하는 컴포저블
오늘은 이 중에서 useState
에 집중해 보겠습니다.
useState 컴포저블 소개
useState
는 Nuxt 3에서 제공하는 컴포저블로, SSR 환경에서도 안전하게 상태를 관리할 수 있도록 도와줍니다. Vue의 ref
와 유사하지만, 서버 사이드 렌더링 시 하이드레이션(hydration) 과정에서도 상태가 유지되는 특징이 있습니다.
useState의 기본 문법
const state = useState(key, init?)
여기서:
key
: 상태를 식별하는 고유한 키(문자열)init
: 초기 상태 값 또는 초기 상태를 반환하는 함수 (선택사항)
기본 사용법
// counter.vue
<script setup>
const counter = useState('counter', () => 0)
function increment() {
counter.value++
}
</script>
<template>
<div>
Counter: {{ counter }}
<button @click="increment">Increment</button>
</div>
</template>
위 예제에서 useState('counter', () => 0)
는 ‘counter’라는 키로 상태를 생성하고 초기값을 0으로 설정합니다. 이 상태는 반응형이므로 값이 변경되면 컴포넌트가 자동으로 업데이트됩니다.

useState의 특징과 장점
1. SSR 호환성
useState
는 서버 사이드 렌더링과 클라이언트 사이드 렌더링 모두에서 작동합니다. 서버에서 렌더링된 상태가 클라이언트로 전송되어 하이드레이션 과정에서 유지됩니다.
2. 컴포넌트 간 상태 공유
동일한 키를 사용하면 여러 컴포넌트에서 같은 상태에 접근할 수 있습니다.
// ComponentA.vue
<script setup>
const counter = useState('counter', () => 0)
</script>
// ComponentB.vue
<script setup>
const counter = useState('counter') // 같은 'counter' 상태에 접근
</script>
3. 타입 안전성
TypeScript와 함께 사용할 때 타입을 지정할 수 있습니다.
// TypeScript와 함께 사용
const counter = useState<number>('counter', () => 0)
실전 예제: 장바구니 기능 구현하기
실제 쇼핑몰 애플리케이션에서 장바구니 기능을 useState
로 구현해 보겠습니다.
// composables/useCart.ts
export const useCart = () => {
// 'cart' 키로 장바구니 상태 생성
const cart = useState('cart', () => [])
// 상품 추가 함수
const addToCart = (product) => {
const existingItem = cart.value.find(item => item.id === product.id)
if (existingItem) {
// 이미 있는 상품이면 수량만 증가
existingItem.quantity++
} else {
// 새 상품이면 장바구니에 추가
cart.value.push({
...product,
quantity: 1
})
}
}
// 상품 제거 함수
const removeFromCart = (productId) => {
cart.value = cart.value.filter(item => item.id !== productId)
}
// 장바구니 비우기
const clearCart = () => {
cart.value = []
}
// 장바구니 총액 계산
const cartTotal = computed(() => {
return cart.value.reduce((total, item) => {
return total + (item.price * item.quantity)
}, 0)
})
return {
cart,
addToCart,
removeFromCart,
clearCart,
cartTotal
}
}
이제 이 컴포저블을 어떤 컴포넌트에서든 사용할 수 있습니다:
// ProductCard.vue
<script setup>
const props = defineProps(['product'])
const { addToCart } = useCart()
</script>
<template>
<div class="product-card">
<h3>{{ product.name }}</h3>
<p>{{ product.price }}원</p>
<button @click="addToCart(product)">장바구니에 추가</button>
</div>
</template>
// CartPage.vue
<script setup>
const { cart, removeFromCart, cartTotal } = useCart()
</script>
<template>
<div>
<h2>장바구니</h2>
<div v-if="cart.length === 0">장바구니가 비어있습니다.</div>
<ul v-else>
<li v-for="item in cart" :key="item.id">
{{ item.name }} - {{ item.quantity }}개 ({{ item.price * item.quantity }}원)
<button @click="removeFromCart(item.id)">제거</button>
</li>
</ul>
<div v-if="cart.length > 0">
<h3>총액: {{ cartTotal }}원</h3>
</div>
</div>
</template>
useState의 한계와 주의사항
useState
는 간단한 상태 관리에 적합하지만, 다음과 같은 한계가 있습니다:
- 브라우저 새로고침 시 상태가 초기화됩니다 (영구 저장이 필요하면 localStorage나 서버 저장이 필요)
- 복잡한 상태 로직이나 대규모 애플리케이션에서는 관리가 어려울 수 있습니다
- 상태 변경 추적이나 디버깅 도구가 제한적입니다
상태 초기화 문제 해결하기
페이지 새로고침 시에도 상태를 유지하고 싶다면, useLocalStorage
컴포저블을 사용하거나 직접 구현할 수 있습니다:
// composables/usePersistentState.js
export const usePersistentState = (key, defaultValue) => {
// 서버 사이드에서는 기본값 사용
const initialValue = () => {
if (process.server) return defaultValue
// 클라이언트에서는 localStorage 확인
const storedValue = localStorage.getItem(key)
return storedValue ? JSON.parse(storedValue) : defaultValue
}
const state = useState(key, initialValue)
// 상태가 변경될 때마다 localStorage에 저장
if (process.client) {
watch(state, (newValue) => {
localStorage.setItem(key, JSON.stringify(newValue))
}, { deep: true })
}
return state
}
대규모 애플리케이션을 위한 상태 관리 전략
애플리케이션이 커지면 상태 관리도 복잡해집니다. 이때는 다음과 같은 전략을 고려해볼 수 있습니다:
- 상태 모듈화: 관련 상태와 로직을 컴포저블로 그룹화
- Pinia 도입: 더 체계적인 상태 관리를 위해 Pinia 사용 (다음 회차에서 다룰 예정)
- 상태 계층화: 로컬 상태, 페이지 상태, 전역 상태로 구분하여 관리
상태 계층화 예시
// 로컬 상태 (컴포넌트 내부에서만 사용)
const isOpen = ref(false)
// 페이지 상태 (페이지 내에서 공유)
const pageFilters = useState('pageFilters', () => ({ category: null, sort: 'newest' }))
// 전역 상태 (여러 페이지에서 공유)
const { user, isLoggedIn } = useAuth() // 사용자 인증 상태
const { cart } = useCart() // 장바구니 상태
실전 팁: 효율적인 상태 관리
- 상태 최소화: 필요한 상태만 저장하고, 계산 가능한 값은 computed로 처리
- 명확한 네이밍: 상태 키는 명확하고 구체적으로 작성 (예: ‘userPreferences’)
- 상태 초기화 주의: 서버와 클라이언트에서 동일한 초기값을 가지도록 설계
- 타입 정의: TypeScript를 사용한다면 상태 타입을 명확히 정의
- 상태 접근 추상화: 직접 상태에 접근하기보다 함수를 통해 접근하도록 설계
마무리
이번 포스트에서는 Nuxt.js의 useState
컴포저블을 활용한 상태 관리 방법에 대해 알아보았습니다. useState
는 간단하면서도 강력한 상태 관리 도구로, 특히 SSR 환경에서 상태를 안전하게 관리할 수 있게 해줍니다.
작은 규모의 애플리케이션이나 간단한 상태 관리에는 useState
만으로도 충분할 수 있지만, 애플리케이션이 복잡해질수록 더 체계적인 상태 관리 솔루션이 필요할 수 있습니다.
다음 회차 예고: 상태 관리 심화 (feat. Pinia)
다음 7회차에서는 Nuxt.js에서 공식적으로 지원하는 상태 관리 라이브러리인 Pinia에 대해 알아보겠습니다. Pinia는 Vue 3의 Composition API와 완벽하게 호환되며, TypeScript 지원, 개발자 도구 연동 등 다양한 장점을 제공합니다.
주요 내용으로는:
- Pinia 설치 및 Nuxt.js와의 통합 방법
- Store, State, Getters, Actions 개념 이해하기
- 모듈화된 상태 관리 구현하기
- Pinia와 useState의 비교 및 사용 시나리오
더 체계적이고 확장 가능한 상태 관리 방법에 대해 알아보고 싶다면, 다음 포스트를 기대해 주세요!
답글 남기기