Skip to content

Chapter 09. 상태는 언제 컴포넌트를 떠나야 하는가? (Pinia 철학) ​

"8장까지 작성된 BoardApp.vue는 뒤로 가기를 누를 때마다 화면이 하얗게 깜빡이며 목록을 처음부터 다시 불러온다. 상태를 컴포넌트 밖으로 꺼내어 전역 캐시로 승격시킬 때가 되었다."


9.1 BoardApp.vue 로컬 상태의 한계 ​

8장까지 완성한 BoardApp.vue 내부에는 다음과 같은 로컬 상태가 자리 잡고 있습니다:

typescript
// BoardApp.vue 내부의 로컬 상태
const posts = ref<Post[]>([]);

이 상태는 BoardApp.vue가 마운트될 때 생성되고, 언마운트되면 메모리에서 사라집니다.
따라서 사용자가 상세 페이지로 이동했다가 [뒤로 가기]를 누르면, 컴포넌트가 파괴되었다가 다시 마운트되면서 서버에 똑같은 목록 조회 요청을 또 날려 화면이 깜빡입니다.

또한 헤더 네비게이션 바에서 "현재 등록된 전체 게시글 수"를 띄워주고 싶어도, posts가 BoardApp.vue 내부에 갇혀있어 다른 컴포넌트가 이 데이터에 접근할 수 없습니다.


9.2 상태 분류 3단계 판별 매트릭스 ​

그렇다고 모든 변수를 전역 스토어로 보내면 코드가 엉망이 됩니다.
사내 개발팀의 엄격한 상태 분류 기준에 따라 판단해야 합니다:

상태 유형관리 장소BoardApp.vue 적용 예시
1. 로컬 UI 상태컴포넌트 내부 (ref)폼 입력 중인 임시 텍스트, 모달 토글 열림 여부
2. 라우트 상태URL Query (vue-router)검색 키워드, 현재 페이지 번호, 선택된 카테고리
3. 도메인/캐시 상태Pinia Store게시글 목록 캐시, 최근 조회 게시글, 로그인 세션

9.3 [실습] BoardApp.vue를 위한 usePostStore.ts 구축 ​

Pinia의 모던 표준인 **Setup Store 패턴(defineStore(() => { ... }))**을 사용하여, 목록 캐싱과 상세 데이터 동기화를 지원하는 스토어를 작성합니다.

[코드 9-1] src/stores/usePostStore.ts ​

typescript
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';
import axios from 'axios';
import type { Post } from '@/types/post';

export const usePostStore = defineStore('post', () => {
  // 1. State: 전역으로 승격된 게시글 상태
  const posts = ref<Post[]>([]);
  const currentPost = ref<Post | null>(null);
  const totalCount = ref(0);
  const isLoading = ref(false);
  const lastFetchedTime = ref<number | null>(null);

  // 2. Getters: 인기 게시글 상위 3개 자동 필터링
  const topPopularPosts = computed(() => {
    return [...posts.value].sort((a, b) => b.likeCount - a.likeCount).slice(0, 3);
  });

  // 3. Actions: 1분 유효시간 캐싱이 적용된 조회 함수
  async function fetchPosts(force = false) {
    const isCacheValid = lastFetchedTime.value && (Date.now() - lastFetchedTime.value < 60000);
    
    // 캐시가 유효하다면 네트워크 호출을 건너뛰고 메모리 캐시 즉시 반환!
    if (!force && isCacheValid && posts.value.length > 0) {
      console.log('⚡ [Cache Hit] 로컬 스토어 캐시된 게시글 목록을 즉시 반환합니다.');
      return;
    }

    isLoading.value = true;
    try {
      const res = await axios.get('/api/posts?size=20');
      posts.value = res.data.items || [];
      totalCount.value = res.data.total;
      lastFetchedTime.value = Date.now();
    } finally {
      isLoading.value = false;
    }
  }

  function removePost(id: number) {
    posts.value = posts.value.filter(p => p.id !== id);
    if (currentPost.value?.id === id) currentPost.value = null;
  }

  return {
    posts,
    currentPost,
    totalCount,
    isLoading,
    topPopularPosts,
    fetchPosts,
    removePost
  };
});

9.4 BoardApp.vue에 스토어 연결하기 ​

이제 BoardApp.vue 내부에서 직접 선언했던 const posts = ref([])를 제거하고, usePostStore를 연결합니다.
반응성을 지키기 위해 **storeToRefs**를 사용합니다.

[코드 9-2] BoardApp.vue에 스토어가 결합된 완전한 소스 코드 ​

html
<template>
  <div class="board-container">
    <h2>사내 기술 Q&A 게시판 (v4.0 - Pinia 캐싱 적용)</h2>

    <!-- 검색 바 -->
    <div class="filter-bar">
      <select v-model="selectedCategory">
        <option value="ALL">전체</option>
        <option value="DEV">개발</option>
        <option value="NOTICE">공지</option>
      </select>
      <input v-model="inputKeyword" placeholder="검색어" @keyup.enter="handleSearch" />
      <button @click="handleSearch">검색</button>
      <button @click="handleReset">초기화</button>
    </div>

    <!-- 등록 폼 생략 없이 완벽 포함 -->
    <form class="form-box" @submit.prevent="handleSubmit">
      <input v-model="form.title" placeholder="제목" />
      <span v-if="errors.title" class="error">{{ errors.title }}</span>
      <input v-model="form.author" placeholder="작성자" />
      <textarea v-model="form.content" placeholder="내용"></textarea>
      <span v-if="errors.content" class="error">{{ errors.content }}</span>
      <button type="submit" :disabled="isSubmitting">등록</button>
    </form>

    <!-- 공통 제네릭 테이블: 스토어의 캐시된 데이터를 직접 전달 -->
    <BoardTable
      :items="posts"
      :columns="columns"
      :loading="isLoading"
      rowKeyField="id"
    >
      <template #empty>
        <span>등록된 게시글이 없습니다.</span>
      </template>
      <template #cell-title="{ row }">
        <span class="post-title">{{ row.title }}</span>
        <span v-if="row.likeCount > 5" class="hot-badge">🔥 인기</span>
      </template>
      <template #cell-id="{ value }">
        <button class="btn-delete" @click="removePost(value)">삭제</button>
      </template>
    </BoardTable>

    <!-- 페이지네이션 -->
    <div class="pagination">
      <button :disabled="!hasPrevPage" @click="prevPage">이전</button>
      <span>{{ currentPage }} / {{ totalPages }} (총 {{ totalCount }}건)</span>
      <button :disabled="!hasNextPage" @click="nextPage">다음</button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import axios from 'axios';
import { storeToRefs } from 'pinia';
import { usePostStore } from '@/stores/usePostStore';
import BoardTable, { type TableColumn } from '@/components/common/BoardTable.vue';
import { usePagination } from '@/composables/usePagination';
import { usePostFilter } from '@/composables/usePostFilter';
import { useFormValidation } from '@/composables/useFormValidation';
import type { Post } from '@/types/post';

const columns: TableColumn<Post>[] = [
  { key: 'id', label: 'ID', width: '60px' },
  { key: 'title', label: '제목' },
  { key: 'author', label: '작성자', width: '100px' },
  { key: 'createdAt', label: '작성일', width: '120px' }
];

// 🌟 Pinia 스토어 연동: posts를 컴포넌트 로컬이 아닌 전역 캐시에서 구독!
const postStore = usePostStore();
const { posts, isLoading, totalCount } = storeToRefs(postStore);
const { fetchPosts, removePost } = postStore;

const isSubmitting = ref(false);
const form = reactive({ title: '', author: '', content: '' });

const { keyword, category, applyFilter, resetFilter } = usePostFilter(() => {
  currentPage.value = 1;
  fetchPosts({ page: currentPage.value, keyword: keyword.value, category: category.value }, true);
});
const inputKeyword = ref(keyword.value);
const selectedCategory = ref(category.value);

function handleSearch() { applyFilter(inputKeyword.value, selectedCategory.value); }
function handleReset() { inputKeyword.value = ''; selectedCategory.value = 'ALL'; resetFilter(); }

const { currentPage, pageSize, totalPages, hasNextPage, hasPrevPage, nextPage, prevPage } = usePagination({
  totalCount,
  onPageChange: (page) => fetchPosts({ page, keyword: keyword.value, category: category.value })
});

const { errors, validate, clearErrors } = useFormValidation();

async function handleSubmit() {
  if (!validate(form.title, form.content)) return;
  isSubmitting.value = true;
  try {
    await axios.post('/api/posts', { ...form, category: 'DEV' });
    form.title = ''; form.author = ''; form.content = '';
    clearErrors();
    fetchPosts({}, true); // 등록 후 강제 리프레시
  } finally {
    isSubmitting.value = false;
  }
}

onMounted(() => {
  fetchPosts(); // 최초 1회 호출 (이후 뒤로 가기 재진입 시 1분 이내 캐시 즉시 렌더링!)
});

9.5 다음 챕터로 이어지는 질문 ​

이제 BoardApp.vue는 뒤로 가기를 눌러도 깜빡임 없이 0초 만에 화면이 뜨는 캐싱 시스템을 갖추었습니다.

하지만 사용자 경험(UX) 측면에서 여전히 답답한 부분이 있습니다:
사우들이 게시글의 [좋아요(❤️)] 버튼을 누를 때, 서버 응답이 올 때까지 500ms 동안 하트가 칠해지지 않고 멍하니 기다려야 합니다.

"서버 성공을 미리 가정하고 화면의 하트를 0.001초 만에 즉시 칠해줄 수는 없을까? 만약 서버 에러가 나면 조용히 롤백하고?"

이어지는 Chapter 10에서는 현대 프론트엔드 UX의 정수인 **낙관적 업데이트(Optimistic Update)**를 BoardApp.vue와 Pinia 스토어에 적용하고, 작성 중인 글이 날아가지 않도록 보호하는 Pinia 영속화 플러그인을 장착해 보겠습니다.

Moon HomeLab Engineering Knowledge Hub