Skip to content

Chapter 05. Composable 패턴으로 로직 독립시키기 ​

"4장에서 800줄로 비대해졌던 BoardApp.vue를 해체하자. 변하지 않는 핵심 비즈니스 로직을 Composable 함수로 도려내면, 컴포넌트는 다시 순수한 UI 본연의 모습으로 돌아온다."


5.1 Composable이란 무엇인가? ​

Vue 3에서 **Composable(컴포저블)**이란 **"Vue의 반응형 API(ref, reactive, computed, 생명주기 훅 등)를 활용하여 상태를 가진 로직(Stateful Logic)을 캡슐화하고 재사용하는 순수 함수"**를 의미합니다.

컴포넌트가 UI 렌더링에만 집중할 수 있도록, 4장에서 BoardApp.vue를 무겁게 짓누르던 세 가지 관심사(페이징, 검색, 폼 검증)를 독립된 파일로 도려내겠습니다.


5.2 [도려내기 1] usePagination.ts 분리 ​

4장의 BoardApp.vue에 있던 currentPage, pageSize, totalCount, totalPages, changePage를 하나의 독립 함수로 추출합니다.

새로운 파일을 생성합니다: frontend/src/composables/usePagination.ts

[코드 5-1] frontend/src/composables/usePagination.ts ​

typescript
import { ref, computed, type Ref } from 'vue';

export interface UsePaginationOptions {
  initialPage?: number;
  initialSize?: number;
  totalCount: Ref<number> | number;
  onPageChange?: (newPage: number) => void; // 페이지 변경 시 실행할 콜백
}

export function usePagination(options: UsePaginationOptions) {
  const {
    initialPage = 1,
    initialSize = 10,
    totalCount,
    onPageChange
  } = options;

  const currentPage = ref(initialPage);
  const pageSize = ref(initialSize);

  const totalCountRef = computed(() => {
    return typeof totalCount === 'number' ? totalCount : totalCount.value;
  });

  const totalPages = computed(() => {
    return Math.max(1, Math.ceil(totalCountRef.value / pageSize.value));
  });

  const hasNextPage = computed(() => currentPage.value < totalPages.value);
  const hasPrevPage = computed(() => currentPage.value > 1);

  function setPage(page: number) {
    if (page < 1 || page > totalPages.value || page === currentPage.value) {
      return;
    }
    currentPage.value = page;
    onPageChange?.(page);
  }

  function nextPage() {
    if (hasNextPage.value) setPage(currentPage.value + 1);
  }

  function prevPage() {
    if (hasPrevPage.value) setPage(currentPage.value - 1);
  }

  return {
    currentPage,
    pageSize,
    totalPages,
    hasNextPage,
    hasPrevPage,
    nextPage,
    prevPage,
    setPage
  };
}

5.3 [도려내기 2] URL Query와 동기화되는 usePostFilter.ts 분리 ​

검색어와 카테고리를 브라우저 URL 쿼리 파라미터(?keyword=Vue&category=DEV)와 동기화하는 로직도 독립시킵니다.

새로운 파일을 생성합니다: frontend/src/composables/usePostFilter.ts

[코드 5-2] frontend/src/composables/usePostFilter.ts ​

typescript
import { ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';

export function usePostFilter(onFilterChange?: () => void) {
  const route = useRoute();
  const router = useRouter();

  const keyword = ref((route.query.keyword as string) || '');
  const category = ref((route.query.category as string) || 'ALL');

  function applyFilter(newKeyword: string, newCategory: string) {
    keyword.value = newKeyword;
    category.value = newCategory;

    // 브라우저 URL 쿼리 파라미터 교체
    router.replace({
      query: {
        ...route.query,
        keyword: newKeyword || undefined,
        category: newCategory === 'ALL' ? undefined : newCategory,
        page: 1
      }
    });

    onFilterChange?.();
  }

  function resetFilter() {
    applyFilter('', 'ALL');
  }

  return {
    keyword,
    category,
    applyFilter,
    resetFilter
  };
}

5.4 [도려내기 3] useFormValidation.ts 분리 ​

글 등록 폼의 유효성 검사 규칙을 선언적으로 추상화합니다.

새로운 파일을 생성합니다: frontend/src/composables/useFormValidation.ts

[코드 5-3] frontend/src/composables/useFormValidation.ts ​

typescript
import { reactive, computed } from 'vue';

export function useFormValidation() {
  const errors = reactive({
    title: '',
    content: ''
  });

  function validate(title: string, content: string): boolean {
    errors.title = title.trim().length < 2 ? '제목은 2자 이상 입력해야 합니다.' : '';
    errors.content = content.trim().length < 10 ? '본문은 10자 이상 입력해야 합니다.' : '';
    return !errors.title && !errors.content;
  }

  function clearErrors() {
    errors.title = '';
    errors.content = '';
  }

  const isValid = computed(() => !errors.title && !errors.content);

  return {
    errors,
    isValid,
    validate,
    clearErrors
  };
}

5.5 다이어트에 성공한 BoardApp.vue (800줄 → 150줄) ​

이제 추출한 세 개의 Composable을 BoardApp.vue에서 레고 블록처럼 조립(Compose)합니다!

[코드 5-4] 슬림해진 클린 컴포넌트 (frontend/src/BoardApp.vue) ​

html
<template>
  <div class="board-container">
    <h2>사내 기술 Q&A 게시판 (v2.0)</h2>

    <!-- 1. 검색 바 -->
    <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>

    <!-- 2. 등록 폼 -->
    <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>

    <!-- 3. 게시글 목록 테이블 -->
    <table class="board-table">
      <thead>
        <tr>
          <th>ID</th>
          <th>제목</th>
          <th>작성자</th>
          <th>작성일</th>
          <th>동작</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="post in posts" :key="post.id">
          <td>{{ post.id }}</td>
          <td>{{ post.title }}</td>
          <td>{{ post.author }}</td>
          <td>{{ post.createdAt }}</td>
          <td><button class="btn-delete" @click="handleDelete(post.id)">삭제</button></td>
        </tr>
        <tr v-if="posts.length === 0">
          <td colspan="5" class="empty-cell">게시글이 없습니다.</td>
        </tr>
      </tbody>
    </table>

    <!-- 4. 페이지네이션 -->
    <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 { usePagination } from '@/composables/usePagination';
import { usePostFilter } from '@/composables/usePostFilter';
import { useFormValidation } from '@/composables/useFormValidation';

// 1. 순수 도메인 상태
const posts = ref<any[]>([]);
const totalCount = ref(0);
const isSubmitting = ref(false);
const form = reactive({ title: '', author: '', content: '' });

// 2. 검색 필터 합성
const { keyword, category, applyFilter, resetFilter } = usePostFilter(() => {
  currentPage.value = 1;
  loadPosts();
});
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(); }

// 3. 페이지네이션 합성
const { currentPage, pageSize, totalPages, hasNextPage, hasPrevPage, nextPage, prevPage } = usePagination({
  totalCount,
  onPageChange: () => loadPosts()
});

// 4. 폼 유효성 검사 합성
const { errors, validate, clearErrors } = useFormValidation();

// 5. 서버 통신
async function loadPosts() {
  const res = await axios.get('/api/posts', {
    params: {
      page: currentPage.value,
      size: pageSize.value,
      keyword: keyword.value,
      category: category.value
    }
  });
  posts.value = res.data.items;
  totalCount.value = res.data.total;
}

async function handleDelete(id: number) {
  if (!confirm('정말 삭제하시겠습니까?')) return;
  await axios.delete(`/api/posts/${id}`);
  loadPosts();
}

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();
    loadPosts();
  } finally {
    isSubmitting.value = false;
  }
}

onMounted(() => { loadPosts(); });

5.6 무엇이 좋아졌는가? ​

  1. 컴포넌트의 다이어트:
    복잡한 알고리즘(페이징 수학, URL 쿼리 치환, 문자열 유효성 검사)이 밖으로 빠져나가고, BoardApp.vue는 오직 **"어떤 컴포저블을 엮어서 화면에 보여줄 것인가"**에만 집중하게 되었습니다.
  2. 사내 재사용성 확보:
    옆 팀 동료는 이제 코드를 복붙할 필요 없이, 우리가 만든 usePagination과 usePostFilter를 그대로 import해서 쓰면 됩니다.
  3. 독립적 단위 테스트 가능:
    화면을 띄우지 않고도 usePagination.ts의 페이징 수학 로직만을 단독으로 테스트할 수 있게 되었습니다.

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

이제 BoardApp.vue는 깔끔해졌습니다.
다음으로 사우들의 열렬한 요구사항인 **"댓글과 대댓글(답글의 답글) 기능"**을 BoardApp.vue 상세 화면에 추가해야 합니다.

그런데 댓글은 깊이가 3단계, 4단계까지 무한히 깊어질 수 있는 트리(Tree) 구조입니다.
저 4단계 깊숙한 대대댓글에서 [삭제] 버튼을 눌렀을 때, BoardApp.vue까지 이벤트를 올리려면 어떻게 해야 할까요?

이어지는 Chapter 06에서는 무한 재귀 컴포넌트와 **Provide / Inject (의존성 주입)**을 통해 이 문제를 풀어보겠습니다.

Moon HomeLab Engineering Knowledge Hub