Skip to content

Chapter 04. 한 파일에 몰린 관심사: Fat Component의 위기 ​

"기능이 하나 붙을 때마다 board-app.component.ts는 조금씩 무거워졌다. 그리고 옆 팀 동료는 이 코드를 그대로 복사해서 notice-board.component.ts를 만들기 시작했다."


📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.

개념한 줄 요약설명 위치
Fat Component (비대한 컴포넌트)UI 렌더링, 페이지 계산, 검색 필터링, 폼 검증, HTTP 통신이 한 클래스에 뒤엉킨 안티패턴4.1
단일 책임 원칙 (SRP) & DRY"컴포넌트는 하나의 변경 이유만 가져야 한다"는 원칙과 코드 중복 금지 원칙4.2
프레젠테이셔널 분할 vs 서비스 분할UI 조각은 하위 컴포넌트(input/output/model)로, 비즈니스 상태 로직은 @Injectable() 서비스로 도려내는 투트랙 전략4.3 → 5장에서 본격 구현

TIP

🏁 이 장의 시작 파일
이 장에서는 4.1절의 [코드 4-1]로 frontend/src/app/board-app/board-app.component.ts와 board-app.component.html 전체를 덮어쓰고 시작합니다. 페이징·카테고리 필터·폼 유효성 검사가 한 파일에 모두 섞여 있는 "비대한 컴포넌트" 상태를 먼저 만든 뒤, 어디가 어떻게 엉켜 있는지 해부하고 5장에서 이를 깔끔하게 3개의 서비스로 도려낼 것입니다. 이 장에서 손대는 파일: frontend/src/app/board-app/board-app.component.ts, frontend/src/app/board-app/board-app.component.html


4.1 관심사가 한데 몰린 BoardAppComponent ​

1장에서 jQuery 레거시를 걷어내고 만든 BoardAppComponent는 처음엔 120줄 남짓의 아담한 컴포넌트였습니다.
그런데 사내 현업 부서의 요구사항이 매주 쏟아지면서 다음 기능들이 한 파일 안에 계속 추가되었다고 가정해 봅시다:

  1. 페이지네이션(Pagination): 현재 페이지(currentPage), 페이지 크기(pageSize), 전체 건수(totalCount), 전체 페이지 수 계산(totalPages), 이전/다음 페이지 이동 및 삭제 시 빈 페이지 자동 보정
  2. 다중 검색 필터(Search & Filter): 키워드 입력, 카테고리(ALL, DEV, NOTICE, FREE) 드롭다운, 검색 초기화
  3. 폼 유효성 검사(Form Validation): 제목 최소 2자, 본문 최소 10자 검증, 필드별 에러 메시지 상태(errors) 관리
  4. 서버 HTTP 통신 및 도메인 상태 관리: 목록 조회, 등록, 삭제 API 호출 및 로딩 스피너 제어

현업 프로젝트에서는 여기에 권한 체크, 엑셀 다운로드, 첨부파일 드래그 앤 드롭, 댓글 트리까지 한 클래스에 계속 덧붙여지면서 **1,000줄짜리 괴물 컴포넌트(Fat Component)**가 탄생합니다. 에디터 스크롤바는 콩알만 해지고, 페이징 버그 하나 고치려다 등록 폼 변수를 건드려 장애가 터집니다.

문제를 선명하게 진단하고 5장의 리팩터링 전후를 정확히 비교하기 위해, 위 4가지 관심사가 한 파일에 뒤섞인 **[코드 4-1]**로 board-app.component.ts와 .html을 덮어써 봅시다.

[코드 4-1] 4가지 관심사가 한 파일에 뒤섞인 단일 컴포넌트 (board-app.component.ts & .html) ​

typescript
// frontend/src/app/board-app/board-app.component.ts
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';

export interface Post {
  id: number;
  title: string;
  author: string;
  content: string;
  category: string;
  createdAt: string;
}

interface PageResponse {
  items: Post[];
  total: number;
}

@Component({
  selector: 'app-board-app',
  standalone: true,
  imports: [FormsModule],
  templateUrl: './board-app.component.html',
  styleUrl: './board-app.component.css',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BoardAppComponent implements OnInit {
  private readonly http = inject(HttpClient);

  // =========================================================================
  // 🚨 [관심사 1: 게시글 도메인 및 서버 통신 상태]
  // =========================================================================
  posts = signal<Post[]>([]);
  isLoading = signal<boolean>(false);
  isSubmitting = signal<boolean>(false);

  // =========================================================================
  // 🚨 [관심사 2: 페이지네이션 계산 및 범위 제어 로직]
  // =========================================================================
  currentPage = signal<number>(1);
  pageSize = signal<number>(10);
  totalCount = signal<number>(0);

  totalPages = computed(() => {
    const count = Math.max(0, this.totalCount());
    return Math.max(1, Math.ceil(count / this.pageSize()));
  });
  hasPrevPage = computed(() => this.currentPage() > 1);
  hasNextPage = computed(() => this.currentPage() < this.totalPages());

  changePage(page: number): void {
    if (page < 1 || page > this.totalPages() || page === this.currentPage()) {
      return;
    }
    this.currentPage.set(page);
    this.loadPosts(); // 🔗 페이지 로직이 서버 통신 함수를 직접 호출하며 강하게 결합됨!
  }

  // =========================================================================
  // 🚨 [관심사 3: 검색 및 카테고리 필터링 로직]
  // =========================================================================
  selectedCategory = signal<string>('ALL');
  searchKeyword = signal<string>('');

  handleSearch(): void {
    this.currentPage.set(1); // 🔗 검색 로직이 페이지네이션 내부 상태를 직접 조작함!
    this.loadPosts();
  }

  handleResetFilter(): void {
    this.selectedCategory.set('ALL');
    this.searchKeyword.set('');
    this.currentPage.set(1);
    this.loadPosts();
  }

  // =========================================================================
  // 🚨 [관심사 4: 폼 입력 상태 및 글자 수 유효성 검증 로직]
  // =========================================================================
  newTitle = signal<string>('');
  newAuthor = signal<string>('');
  newContent = signal<string>('');
  errors = signal<{ title: string; content: string }>({ title: '', content: '' });

  validateForm(): boolean {
    const titleError =
      this.newTitle().trim().length < 2 ? '제목은 2자 이상 입력해야 합니다.' : '';
    const contentError =
      this.newContent().trim().length < 10 ? '본문은 10자 이상 입력해야 합니다.' : '';

    this.errors.set({ title: titleError, content: contentError });
    return !titleError && !contentError;
  }

  // =========================================================================
  // 🚨 [통합 서버 API 호출부: 모든 관심사의 신호를 전부 끌어다 씀]
  // =========================================================================
  ngOnInit(): void {
    this.loadPosts();
  }

  loadPosts(): void {
    this.isLoading.set(true);
    this.http
      .get<PageResponse>('/api/posts', {
        params: {
          page: this.currentPage(),
          size: this.pageSize(),
          category: this.selectedCategory(),
          keyword: this.searchKeyword().trim()
        }
      })
      .subscribe({
        next: (res) => {
          this.posts.set(res.items ?? []);
          this.totalCount.set(res.total ?? 0);

          // 마지막 페이지의 유일한 글을 삭제해 전체 페이지 수가 줄었을 때의 보정 로직까지 섞여 있음
          if (this.currentPage() > this.totalPages()) {
            this.currentPage.set(this.totalPages());
            this.loadPosts();
            return;
          }
          this.isLoading.set(false);
        },
        error: () => {
          alert('게시글 목록을 불러오지 못했습니다.');
          this.isLoading.set(false);
        }
      });
  }

  handleDelete(id: number): void {
    if (!confirm('정말 삭제하시겠습니까?')) return;

    this.http.delete<void>(`/api/posts/${id}`).subscribe({
      next: () => {
        this.loadPosts();
      },
      error: () => {
        alert('게시글 삭제에 실패했습니다.');
      }
    });
  }

  handleSubmit(): void {
    if (!this.validateForm()) return;
    if (!this.newAuthor().trim()) {
      alert('작성자를 입력해 주세요.');
      return;
    }

    this.isSubmitting.set(true);
    this.http
      .post<Post>('/api/posts', {
        title: this.newTitle().trim(),
        author: this.newAuthor().trim(),
        content: this.newContent().trim(),
        category: 'DEV'
      })
      .subscribe({
        next: () => {
          this.newTitle.set('');
          this.newAuthor.set('');
          this.newContent.set('');
          this.errors.set({ title: '', content: '' });
          this.isSubmitting.set(false);
          this.loadPosts();
        },
        error: () => {
          alert('게시글 등록에 실패했습니다.');
          this.isSubmitting.set(false);
        }
      });
  }
}
html
<!-- frontend/src/app/board-app/board-app.component.html -->
<div class="board-card">
  <div class="card-header">
    <h2>사내 기술 Q&A 게시판</h2>
    <span class="badge">총 {{ totalCount() }}건</span>
  </div>

  <!-- 1. 검색 및 카테고리 필터 바 -->
  <div class="filter-bar">
    <select
      [ngModel]="selectedCategory()"
      (ngModelChange)="selectedCategory.set($event); handleSearch()"
    >
      <option value="ALL">전체 카테고리</option>
      <option value="DEV">개발 (DEV)</option>
      <option value="NOTICE">공지 (NOTICE)</option>
      <option value="FREE">자유 (FREE)</option>
    </select>
    <input
      type="text"
      [ngModel]="searchKeyword()"
      (ngModelChange)="searchKeyword.set($event)"
      (keyup.enter)="handleSearch()"
      placeholder="검색어 입력 후 Enter"
    />
    <button type="button" (click)="handleSearch()">검색</button>
    <button type="button" (click)="handleResetFilter()">초기화</button>
  </div>

  <!-- 2. 게시글 등록 폼 및 유효성 검사 에러 노출 -->
  <form class="form-container" (ngSubmit)="handleSubmit()">
    <div class="input-row">
      <input
        type="text"
        [ngModel]="newTitle()"
        (ngModelChange)="newTitle.set($event)"
        name="title"
        placeholder="제목 (2자 이상)"
      />
      <input
        type="text"
        [ngModel]="newAuthor()"
        (ngModelChange)="newAuthor.set($event)"
        name="author"
        placeholder="작성자"
        class="author-input"
      />
      <button type="submit" [disabled]="isSubmitting()" class="btn-submit">
        {{ isSubmitting() ? '등록 중...' : '등록' }}
      </button>
    </div>
    @if (errors().title) {
      <p class="error-msg">⚠️ {{ errors().title }}</p>
    }

    <textarea
      [ngModel]="newContent()"
      (ngModelChange)="newContent.set($event)"
      name="content"
      placeholder="내용 (10자 이상)"
      rows="3"
    ></textarea>
    @if (errors().content) {
      <p class="error-msg">⚠️ {{ errors().content }}</p>
    }
  </form>

  <!-- 3. 게시글 목록 테이블 -->
  <table class="data-table">
    <thead>
      <tr>
        <th style="width: 60px;">ID</th>
        <th>제목</th>
        <th style="width: 100px;">작성자</th>
        <th style="width: 120px;">작성일</th>
        <th style="width: 80px;">동작</th>
      </tr>
    </thead>
    <tbody>
      @for (post of posts(); track post.id) {
        <tr>
          <td>{{ post.id }}</td>
          <td><span class="post-title">{{ post.title }}</span></td>
          <td>{{ post.author }}</td>
          <td>{{ post.createdAt }}</td>
          <td>
            <button type="button" class="btn-delete" (click)="handleDelete(post.id)">
              삭제
            </button>
          </td>
        </tr>
      } @empty {
        <tr>
          <td colspan="5" class="empty-state">게시글이 없습니다.</td>
        </tr>
      }
    </tbody>
  </table>

  <!-- 4. 페이지네이션 컨트롤러 -->
  <div class="pagination">
    <button
      type="button"
      [disabled]="!hasPrevPage()"
      (click)="changePage(currentPage() - 1)"
    >
      이전
    </button>
    <span>{{ currentPage() }} / {{ totalPages() }} 페이지</span>
    <button
      type="button"
      [disabled]="!hasNextPage()"
      (click)="changePage(currentPage() + 1)"
    >
      다음
    </button>
  </div>
</div>

추가 스타일(board-app.component.css 하단에 덧붙이기):

css
.filter-bar { display: flex; gap: 8px; margin-bottom: 16px; }
.filter-bar select, .filter-bar input { padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px; }
.filter-bar input { flex: 1; }
.filter-bar button { padding: 8px 14px; border: 1px solid #cbd5e1; border-radius: 6px; background: #f8fafc; cursor: pointer; }
.error-msg { color: #ef4444; font-size: 12px; margin: 4px 0 8px; font-weight: 600; }
.pagination { display: flex; justify-content: center; align-items: center; gap: 12px; margin-top: 20px; }
.pagination button { padding: 6px 12px; border: 1px solid #cbd5e1; border-radius: 6px; background: #ffffff; cursor: pointer; }
.pagination button:disabled { color: #cbd5e1; cursor: not-allowed; }

🆕 처음 보는 문법: (keyup.enter) 키보드 이벤트 필터링

Angular는 키보드 이벤트에 직관적인 키 이름 필터를 지원합니다. (keyup.enter)="handleSearch()"라고 적으면 사용자가 아무 키나 누를 때가 아니라 오직 Enter 키를 눌렀을 때만 handleSearch()를 실행합니다. (keydown.escape), (keydown.control.s)처럼 조합키도 그대로 쓸 수 있습니다.


4.2 Fat Component가 초래하는 3가지 아키텍처 위기 ​

[코드 4-1]은 당장 브라우저에서는 잘 돌아갑니다. 하지만 실무 팀 개발에서는 곧바로 세 가지 심각한 문제를 일으킵니다.

🎒 비유: 만능 맥가이버 칼의 비극 (Fat Component)
가위, 칼, 톱, 드라이버, 숟가락, 포크, 망치까지 50개 도구를 본체 하나에 다 접어 넣은 5kg짜리 거대 맥가이버 칼을 상상해 보십시오. 스테이크를 썰려고 칼을 쥐었는데 너무 무거워 손목이 나가고, 가위 날만 조금 갈려고 해도 50개 부품을 다 분해해야 합니다.

1) 단일 책임 원칙(SRP, Single Responsibility Principle) 위반 ​

로버트 C. 마틴(Uncle Bob)이 정의한 단일 책임 원칙은 **"하나의 모듈(클래스)은 오직 하나의 액터(변경의 이유)만을 가져야 한다"**는 뜻입니다. 지금 BoardAppComponent는 수정되어야 할 이유가 너무 많습니다:

  • 디자이너가 테이블 모양을 바꿀 때
  • 기획자가 "본문 최소 글자 수를 10자에서 20자로 늘려주세요"라고 할 때
  • 페이지네이션 계산식을 바꿀 때
  • 백엔드 API 스펙이 바뀔 때 모든 작업자가 board-app.component.ts 파일 하나를 동시에 수정하다가 Git 충돌(Merge Conflict) 지옥에 빠집니다.

2) 복사-붙여넣기(Copy & Paste)와 DRY 원칙 붕괴 ​

옆 팀 동료가 **사내 공지사항 게시판(NoticeBoardComponent)**과 **고객 문의 게시판(FaqBoardComponent)**을 만들어야 합니다. 거기에도 똑같이 페이지네이션과 검색 필터, 폼 검증이 필요합니다.
로직이 BoardAppComponent 안에 갇혀 있으니 동료는 board-app.component.ts의 코드를 복사해 붙여넣습니다. 일주일 뒤 "마지막 페이지 글을 지우면 빈 페이지가 남는 버그"를 발견하면, 사내에 복제된 10개의 게시판 컴포넌트를 일일이 찾아다니며 똑같이 고쳐야 합니다.

3) 단위 테스트(Unit Test)의 고통 ​

나는 단지 totalCount가 25이고 pageSize가 10일 때 totalPages()가 3이 나오는지 페이지 계산 공식만 0.01초 만에 테스트하고 싶습니다. 그런데 그 로직이 BoardAppComponent 안에 들어 있으면, 테스트를 돌리기 위해 무거운 HTML 템플릿, FormsModule, 가짜 HttpClient까지 전부 세팅(TestBed)해야만 합니다.


4.3 탈출구: UI 컴포넌트 분할 vs @Injectable() 서비스 분할 ​

이 비대한 컴포넌트를 해체하는 아키텍처 전략은 두 개의 축으로 나뉩니다.

리팩터링 축분리 대상사용하는 Angular 22 도구본 교재 실습 위치
1. UI 분할 (Presentational Component)눈에 보이는 마크업과 스타일 덩어리 (툴바, 검색바, 공통 테이블, 댓글 아이템)@Component, input(), output(), model()1.5장(BoardToolbar), 6장(CommentItem), 8장(BoardTable<T>)
2. 상태 로직 분할 (Stateful Service)화면 마크업과 무관한 반응형 상태(signal/computed) + 비즈니스 알고리즘@Injectable() 클래스 + inject()5장 (PaginationService, PostFilterService, FormValidationService)
🔬 심화(건너뛰어도 됨): 왜 전역 싱글톤(providedIn: 'root')이 아니라 컴포넌트 레벨 providers로 주입할까요?

Spring 백엔드 개발자에게 익숙한 @Service는 보통 애플리케이션 전체에서 인스턴스 하나만 공유하는 전역 싱글톤입니다. Angular에서도 @Injectable({ providedIn: 'root' })라고 쓰면 전역 싱글톤이 됩니다(9장에서 배울 전역 스토어가 이 방식을 씁니다).
하지만 PaginationService는 어떨까요? 만약 사용자가 Q&A 게시판에서 3페이지를 보다가 공지사항 게시판으로 이동했는데, 전역 싱글톤이라서 공지사항 게시판도 갑자기 3페이지부터 열린다면 큰일 납니다! 또한 한 화면에 두 개의 목록 위젯을 띄울 수도 있습니다.
따라서 컴포넌트의 생명주기와 함께 태어나고 사라져야 하는 로컬 비즈니스 로직은 @Injectable()에 providedIn: 'root'를 붙이지 않고, 컴포넌트의 @Component({ providers: [PaginationService] })에 등록하여 컴포넌트 인스턴스마다 전용 서비스가 1개씩 생성되게 만듭니다(5장과 6장의 핵심 패턴!).


4.4 [직접 해보기] 실습 미션 💪 ​

5장에서 본격적으로 3개의 서비스를 도려내기 전에, [코드 4-1]의 BoardAppComponent를 보고 관심사 분류 훈련과 작은UI 컴포넌트 분리 연습을 해 봅시다.

📝 미션 요구사항 ​

  1. [코드 4-1]에서 두 개 이상의 관심사에 동시에 발을 걸치고 있는(결합도를 높이는) 메서드 3개를 찾아 그 이유를 설명해 보십시오.
  2. 1.5장에서 배운 model()과 output()을 복습 삼아, [코드 4-1]의 <div class="filter-bar">...</div> 검색 바 영역을 독립된 자식 컴포넌트 SearchFilterBarComponent로 분리한다면 어떤 model()과 output()이 필요할지 인터페이스(클래스 선언부)를 설계해 보십시오.
💡 미션 정답 코드 확인하기

1. 여러 관심사에 걸친 결합 지점 3곳:

  • handleSearch() / handleResetFilter(): 검색 필터(관심사 3) 메서드인데, 페이지네이션 상태인 this.currentPage.set(1)(관심사 2)을 직접 건드리고 this.loadPosts()(관심사 1)를 직접 호출합니다.
  • changePage(): 페이지네이션(관심사 2) 메서드인데 this.loadPosts()(관심사 1)를 직접 호출합니다.
  • loadPosts(): 서버 통신(관심사 1) 메서드인데 페이지 신호(currentPage, pageSize)와 검색 필터 신호(selectedCategory, searchKeyword)를 모두 읽어 조립하며, 응답 직후 페이지 보정까지 직접 수행하고 있습니다.

2. SearchFilterBarComponent의 입출력 설계 예시:

typescript
import { Component, ChangeDetectionStrategy, model, output } from '@angular/core';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-search-filter-bar',
  standalone: true,
  imports: [FormsModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <div class="filter-bar">
      <select [ngModel]="category()" (ngModelChange)="category.set($event); search.emit()">
        <option value="ALL">전체 카테고리</option>
        <option value="DEV">개발 (DEV)</option>
        <option value="NOTICE">공지 (NOTICE)</option>
        <option value="FREE">자유 (FREE)</option>
      </select>
      <input
        type="text"
        [ngModel]="keyword()"
        (ngModelChange)="keyword.set($event)"
        (keyup.enter)="search.emit()"
        placeholder="검색어 입력 후 Enter"
      />
      <button type="button" (click)="search.emit()">검색</button>
      <button type="button" (click)="reset.emit()">초기화</button>
    </div>
  `
})
export class SearchFilterBarComponent {
  // 부모의 신호와 [(category)], [(keyword)]로 양방향 직결!
  category = model.required<string>();
  keyword = model.required<string>();

  // 검색 실행 및 초기화 요청 이벤트 발신!
  search = output<void>();
  reset = output<void>();
}

4.5 이번 장 핵심 요약 및 다음 단계 🚀 ​

핵심 요약 3줄

  1. 한 컴포넌트 안에 목록·페이징·검색·폼 검증 로직이 모두 모이면 **단일 책임 원칙(SRP)**이 무너지고, 다른 화면을 만들 때 복사-붙여넣기(DRY 위반)가 시작됩니다.
  2. 비즈니스 계산 로직이 UI 컴포넌트에 갇혀 있으면, 단순한 페이지 수 계산 공식 하나를 검증하려 해도 무거운 컴포넌트 렌더링 환경을 전부 띄워야 하므로 단위 테스트가 어려워집니다.
  3. 해결책은 두 축입니다: 반복되는 마크업은 하위 컴포넌트(input/output/model)로 나누고, 상태를 가진 비즈니스 로직은 @Injectable() 서비스로 도려내어 inject()로 조립합니다.

다음 단계: 그런데 5장에서 검색 필터 서비스(PostFilterService)를 도려낼 때 꼭 함께 해결해야 할 숙제가 하나 있습니다. 지금 [코드 4-1]에서 DEV 카테고리로 검색하거나 2페이지로 이동한 뒤 브라우저 **새로고침(F5)**을 누르면 어떻게 될까요? 모든 상태가 초기화되어 1페이지 전체 목록으로 튕겨 버립니다! 이어지는 Chapter 04.5에서 URL 쿼리스트링(?page=2&category=DEV)에 상태를 담아 새로고침과 링크 공유를 버텨내는 Angular Router 기초를 먼저 장착합시다!

Moon HomeLab Engineering Knowledge Hub