Skip to content

Chapter 04. 800줄로 비대해진 Fat Component의 위기 ​

"기능이 추가될 때마다 BoardApp.vue는 점점 비대해져 마침내 800줄을 넘어섰다. 그리고 옆 팀 동료는 이 코드를 그대로 복사해서 NoticeBoard.vue를 만들기 시작했다."


4.1 거대해진 BoardApp.vue의 현실 ​

1장에서 jQuery 레거시를 걷어내고 만든 BoardApp.vue는 처음엔 80줄짜리 깔끔한 파일이었습니다.
하지만 사내 사용자들의 요구사항이 쏟아지면서 다음과 같은 기능들이 추가되었습니다:

  1. 페이지네이션: 현재 페이지, 페이지 크기, 전체 페이지 수 계산, 이전/다음 버튼 제어
  2. 다중 검색 필터: 키워드 입력, 카테고리(DEV, NOTICE, FREE) 드롭다운, URL 쿼리 파라미터 동기화
  3. 폼 유효성 검사: 제목 최소 2자, 본문 최소 10자 검증 및 에러 메시지 노출
  4. 상세 조회 및 수정: 선택된 글의 상태 관리, 폼 리셋

현재 우리 손에 들려있는 BoardApp.vue의 실제 소스 코드를 봅시다.

[코드 4-1] 비대해진 단일 컴포넌트 (frontend/src/BoardApp.vue) ​

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

    <!-- 1. 검색 및 필터 UI -->
    <div class="filter-bar">
      <select v-model="selectedCategory" @change="handleSearch">
        <option value="ALL">전체</option>
        <option value="DEV">개발</option>
        <option value="NOTICE">공지</option>
      </select>
      <input v-model="searchKeyword" placeholder="검색어 입력" @keyup.enter="handleSearch" />
      <button @click="handleSearch">검색</button>
      <button @click="handleResetFilter">초기화</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="currentPage <= 1" @click="changePage(currentPage - 1)">이전</button>
      <span>{{ currentPage }} / {{ totalPages }}</span>
      <button :disabled="currentPage >= totalPages" @click="changePage(currentPage + 1)">다음</button>
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, reactive, computed, onMounted } from 'vue';
import axios from 'axios';

// --- [관심사 1: 게시글 도메인 및 서버 통신] ---
interface Post { id: number; title: string; author: string; content: string; createdAt: string; category: string; }
const posts = ref<Post[]>([]);
const isLoading = ref(false);
const isSubmitting = ref(false);

// --- [관심사 2: 페이지네이션 로직] ---
const currentPage = ref(1);
const pageSize = ref(10);
const totalCount = ref(0);
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / pageSize.value)));

function changePage(page: number) {
  currentPage.value = page;
  loadPosts();
}

// --- [관심사 3: 검색 및 필터링 로직] ---
const selectedCategory = ref('ALL');
const searchKeyword = ref('');

function handleSearch() {
  currentPage.value = 1;
  loadPosts();
}
function handleResetFilter() {
  selectedCategory.value = 'ALL';
  searchKeyword.value = '';
  currentPage.value = 1;
  loadPosts();
}

// --- [관심사 4: 폼 상태 및 유효성 검증 로직] ---
const form = reactive({ title: '', author: '', content: '' });
const errors = reactive({ title: '', content: '' });

function validateForm(): boolean {
  errors.title = form.title.trim().length < 2 ? '제목은 2자 이상이어야 합니다.' : '';
  errors.content = form.content.trim().length < 10 ? '내용은 10자 이상이어야 합니다.' : '';
  return !errors.title && !errors.content;
}

// --- [통합 서버 로딩 함수] ---
async function loadPosts() {
  isLoading.value = true;
  try {
    const res = await axios.get('/api/posts', {
      params: {
        page: currentPage.value,
        size: pageSize.value,
        category: selectedCategory.value,
        keyword: searchKeyword.value
      }
    });
    posts.value = res.data.items;
    totalCount.value = res.data.total;
  } finally {
    isLoading.value = false;
  }
}

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

async function handleSubmit() {
  if (!validateForm()) return;
  isSubmitting.value = true;
  try {
    await axios.post('/api/posts', {
      ...form,
      category: 'DEV'
    });
    form.title = '';
    form.author = '';
    form.content = '';
    loadPosts();
  } finally {
    isSubmitting.value = false;
  }
}

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

4.2 Fat Component가 초래하는 치명적인 악취 ​

코드 4-1은 겉보기에는 아무 문제 없이 잘 돌아갑니다.
하지만 이 컴포넌트는 소프트웨어 공학적으로 심각한 질병을 앓고 있습니다:

1) 단일 책임 원칙(SRP)의 완전한 붕괴 ​

BoardApp.vue는 지금 혼자서 너무 많은 책임을 지고 있습니다:

  • UI 화면을 그리고 이벤트를 바인딩하는 책임
  • 페이지 번호를 계산하고 범위를 제어하는 알고리즘 책임
  • 검색 쿼리를 조합하고 필터를 리셋하는 책임
  • 폼 입력값의 글자 수를 세고 검증하는 유효성 검사 책임
  • 서버와 HTTP 통신을 수행하는 네트워크 책임

이 컴포넌트는 "페이지네이션 버튼 스타일을 바꿀 때", "유효성 검사 글자 수 제한이 바뀔 때", "검색 필터에 날짜가 추가될 때", "서버 API 주소가 바뀔 때"마다 수정되어야 합니다.

2) 복사-붙여넣기(Copy & Paste)의 지옥 ​

옆 팀 동료가 **사내 공지사항 게시판(NoticeBoard.vue)**을 만들어야 한다고 가정해 봅시다.
공지사항 게시판에도 똑같이 페이징, 검색, 폼 검증이 필요합니다.

동료는 어떻게 할까요?
BoardApp.vue의 스크립트 코드 수백 줄을 긁어서 NoticeBoard.vue에 그대로 붙여넣습니다.
그 순간부터 페이징 계산 버그가 하나 발견되면, 두 파일, 세 파일을 일일이 찾아다니며 수정해야 하는 **코드 중복의 악순환(DRY 원칙 위반)**이 시작됩니다.


4.3 탈출구: 상태를 가진 로직(Stateful Logic)을 함수로 도려내자 ​

어떻게 해야 이 비대한 BoardApp.vue를 구출할 수 있을까요?

우리는 BoardApp.vue에서 순수한 비즈니스 로직 덩어리들을 독립된 자바스크립트/타입스크립트 함수로 도려내야(Extract) 합니다.

  • 페이지네이션 상태와 계산 로직 → usePagination() 함수로 분리
  • 검색 필터와 쿼리 동기화 로직 → usePostFilter() 함수로 분리
  • 폼 입력과 유효성 검증 로직 → useFormValidation() 함수로 분리

이어지는 Chapter 05에서는 Vue 3의 핵심 꽃인 Composable 패턴을 적용하여, 이 비대한 800줄짜리 BoardApp.vue를 불과 150줄의 아름다운 클린 컴포넌트로 다이어트시켜 보겠습니다.

Moon HomeLab Engineering Knowledge Hub