다크 모드
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줄 남짓의 아담한 컴포넌트였습니다.
그런데 사내 현업 부서의 요구사항이 매주 쏟아지면서 다음 기능들이 한 파일 안에 계속 추가되었다고 가정해 봅시다:
- 페이지네이션(Pagination): 현재 페이지(
currentPage), 페이지 크기(pageSize), 전체 건수(totalCount), 전체 페이지 수 계산(totalPages), 이전/다음 페이지 이동 및 삭제 시 빈 페이지 자동 보정 - 다중 검색 필터(Search & Filter): 키워드 입력, 카테고리(
ALL,DEV,NOTICE,FREE) 드롭다운, 검색 초기화 - 폼 유효성 검사(Form Validation): 제목 최소 2자, 본문 최소 10자 검증, 필드별 에러 메시지 상태(
errors) 관리 - 서버 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 컴포넌트 분리 연습을 해 봅시다.
📝 미션 요구사항
- [코드 4-1]에서 두 개 이상의 관심사에 동시에 발을 걸치고 있는(결합도를 높이는) 메서드 3개를 찾아 그 이유를 설명해 보십시오.
- 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줄
- 한 컴포넌트 안에 목록·페이징·검색·폼 검증 로직이 모두 모이면 **단일 책임 원칙(SRP)**이 무너지고, 다른 화면을 만들 때 복사-붙여넣기(DRY 위반)가 시작됩니다.
- 비즈니스 계산 로직이 UI 컴포넌트에 갇혀 있으면, 단순한 페이지 수 계산 공식 하나를 검증하려 해도 무거운 컴포넌트 렌더링 환경을 전부 띄워야 하므로 단위 테스트가 어려워집니다.
- 해결책은 두 축입니다: 반복되는 마크업은 하위 컴포넌트(
input/output/model)로 나누고, 상태를 가진 비즈니스 로직은@Injectable()서비스로 도려내어inject()로 조립합니다.
다음 단계: 그런데 5장에서 검색 필터 서비스(PostFilterService)를 도려낼 때 꼭 함께 해결해야 할 숙제가 하나 있습니다. 지금 [코드 4-1]에서 DEV 카테고리로 검색하거나 2페이지로 이동한 뒤 브라우저 **새로고침(F5)**을 누르면 어떻게 될까요? 모든 상태가 초기화되어 1페이지 전체 목록으로 튕겨 버립니다! 이어지는 Chapter 04.5에서 URL 쿼리스트링(?page=2&category=DEV)에 상태를 담아 새로고침과 링크 공유를 버텨내는 Angular Router 기초를 먼저 장착합시다!