다크 모드
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 무엇이 좋아졌는가?
- 컴포넌트의 다이어트:
복잡한 알고리즘(페이징 수학, URL 쿼리 치환, 문자열 유효성 검사)이 밖으로 빠져나가고,BoardApp.vue는 오직 **"어떤 컴포저블을 엮어서 화면에 보여줄 것인가"**에만 집중하게 되었습니다. - 사내 재사용성 확보:
옆 팀 동료는 이제 코드를 복붙할 필요 없이, 우리가 만든usePagination과usePostFilter를 그대로import해서 쓰면 됩니다. - 독립적 단위 테스트 가능:
화면을 띄우지 않고도usePagination.ts의 페이징 수학 로직만을 단독으로 테스트할 수 있게 되었습니다.
5.7 다음 챕터로 이어지는 질문
이제 BoardApp.vue는 깔끔해졌습니다.
다음으로 사우들의 열렬한 요구사항인 **"댓글과 대댓글(답글의 답글) 기능"**을 BoardApp.vue 상세 화면에 추가해야 합니다.
그런데 댓글은 깊이가 3단계, 4단계까지 무한히 깊어질 수 있는 트리(Tree) 구조입니다.
저 4단계 깊숙한 대대댓글에서 [삭제] 버튼을 눌렀을 때, BoardApp.vue까지 이벤트를 올리려면 어떻게 해야 할까요?
이어지는 Chapter 06에서는 무한 재귀 컴포넌트와 **Provide / Inject (의존성 주입)**을 통해 이 문제를 풀어보겠습니다.