다크 모드
Chapter 05. Injectable 서비스 패턴으로 로직 독립시키기 (+ 첫 자동 테스트)
"4장에서 관심사가 뒤엉켰던
BoardAppComponent를 해체하자. 페이지네이션, URL 동기화 검색 필터, 폼 유효성 검사 로직을 3개의@Injectable()서비스로 도려내면 컴포넌트는 다시 순수한 UI 조립자 본연의 모습으로 돌아오고, 비즈니스 로직은 화면 없이 0.1초 만에 자동 테스트할 수 있게 된다."
📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.
| 개념 | 한 줄 요약 | 설명 위치 |
|---|---|---|
@Injectable() 로컬 서비스 | 상태를 가진 비즈니스 로직을 캡슐화하여 컴포넌트마다 독립 인스턴스로 주입되는 클래스 | 5.1 |
컴포넌트 providers: [...] | 전역 싱글톤이 아닌 해당 컴포넌트와 그 자식들에게만 전용 서비스 인스턴스를 생성해 주는 설정 | 5.1, 5.5 |
PaginationService | 페이지 상태, 전체 페이지 수 계산, 삭제 후 빈 페이지 자동 보정을 담당하는 서비스 | 5.2 |
PostFilterService | 검색어·카테고리 상태와 Angular Router URL 쿼리스트링을 양방향 동기화하는 서비스 | 5.3 |
FormValidationService | 폼 필드별 에러 메시지와 유효성 검사 규칙을 전담하는 서비스 | 5.4 |
Vitest (describe, it, expect, vi.fn()) | 브라우저 없이 순수 TypeScript 서비스 로직을 0.1초 만에 검증하는 단위 테스트 프레임워크 | 5.7 |
TIP
🏁 이 장의 시작 파일frontend/src/app/board-app/board-app.component.ts가 4장의 [코드 4-1]과 같은 상태에서 시작합니다. 이 장에서 새로 만들거나 수정하는 파일:
frontend/src/app/services/pagination.service.ts(신규 작성)frontend/src/app/services/post-filter.service.ts(신규 작성)frontend/src/app/services/form-validation.service.ts(신규 작성)frontend/src/app/board-app/board-app.component.ts&.html(슬림 버전으로 교체)frontend/tests/unit/pagination.service.spec.ts(단위 테스트 작성 및 실행)
5.1 @Injectable() 상태 서비스(Stateful Service)란 무엇인가?
Angular에서 **서비스(Service)**는 화면 마크업(template)에 직접 관여하지 않고, 반응형 상태(signal, computed)와 비즈니스 알고리즘을 캡슐화하는 전문 클래스입니다.
⌚ 비유: 문방구 탁상 계산기(일반 유틸 함수) vs 손목의 스마트워치(
@Injectable서비스)
- 일반 유틸 함수 (
formatDate,debounce): 책상 위 탁상용 계산기입니다.add(1, 2)를 두드리면3을 뱉고 끝이며, 방금 무엇을 계산했는지 아무것도 기억하지 못합니다(Stateless).- Injectable 상태 서비스 (
PaginationService,PostFilterService): 내 손목에 찬 스마트워치입니다. 내 현재 페이지 번호와 검색어를 하루 종일 기억(signal)하고, 전체 글 수가 줄어들면 자동으로 페이지를 보정합니다(computed). 컴포넌트가@Component({ providers: [...] })로 손목에 찰 때마다 그 컴포넌트만을 위한 전용 스마트워치가 독립적으로 작동합니다!
왜 @Injectable({ providedIn: 'root' })를 쓰지 않고 @Injectable()만 쓸까요?
4장 심화 박스에서 살펴보았듯, providedIn: 'root'를 붙이면 앱 전체에서 단 1개의 인스턴스만 공유하는 전역 싱글톤이 됩니다(9장의 전역 스토어나 HttpClient에 적합).
반면 페이징(PaginationService), 검색 필터(PostFilterService), 폼 검증(FormValidationService)은 각 게시판 화면마다 독립된 상태를 가져야 하고, 화면을 떠나 컴포넌트가 파괴될 때 메모리에서 함께 정리되어야 합니다. 따라서 서비스 클래스에는 @Injectable()만 붙이고, 사용하는 컴포넌트의 providers: [PaginationService, ...] 배열에 등록합니다!
5.2 [도려내기 1] 페이지네이션 전담 PaginationService 구현
4장의 BoardAppComponent에 흩어져 있던 currentPage, pageSize, totalCount, totalPages, hasPrevPage, hasNextPage, 그리고 마지막 페이지의 유일한 글을 삭제했을 때 빈 페이지에 머물지 않도록 자동으로 이전 페이지로 보정하는 로직까지 완벽히 캡슐화합니다.
[코드 5-1] frontend/src/app/services/pagination.service.ts (전체 코드)
typescript
// frontend/src/app/services/pagination.service.ts
import { Injectable, signal, computed } from '@angular/core';
@Injectable()
export class PaginationService {
// 1. 내부 원천 상태 신호
readonly currentPage = signal<number>(1);
readonly pageSize = signal<number>(10);
readonly totalCount = signal<number>(0);
// 2. 파생 신호(computed): 전체 페이지 수 및 이전/다음 버튼 활성화 여부 자동 계산
readonly totalPages = computed(() => {
const count = Math.max(0, this.totalCount());
const size = Math.max(1, this.pageSize());
return Math.max(1, Math.ceil(count / size));
});
readonly hasPrevPage = computed(() => this.currentPage() > 1);
readonly hasNextPage = computed(() => this.currentPage() < this.totalPages());
/**
* 특정 페이지로 이동합니다.
* 유효한 범위의 새 페이지여서 실제로 페이지가 변경되었을 때만 onPageChange 콜백을 실행하고 true를 반환합니다.
*/
setPage(page: number, onPageChange?: (newPage: number) => void): boolean {
const maxPage = this.totalPages();
if (page < 1 || page > maxPage || page === this.currentPage()) {
return false;
}
this.currentPage.set(page);
onPageChange?.(page);
return true;
}
nextPage(onPageChange?: (newPage: number) => void): boolean {
if (!this.hasNextPage()) return false;
return this.setPage(this.currentPage() + 1, onPageChange);
}
prevPage(onPageChange?: (newPage: number) => void): boolean {
if (!this.hasPrevPage()) return false;
return this.setPage(this.currentPage() - 1, onPageChange);
}
resetFirstPage(): void {
this.currentPage.set(1);
}
/**
* 서버 응답의 전체 건수(total)를 반영하고,
* 만약 마지막 페이지의 유일한 글이 삭제되어 현재 페이지가 totalPages보다 커졌다면
* 자동으로 마지막 유효 페이지로 보정한 뒤 onPageAdjusted 콜백을 호출합니다!
*/
updateTotalCount(count: number, onPageAdjusted?: (adjustedPage: number) => void): void {
this.totalCount.set(Math.max(0, count));
const maxPage = this.totalPages();
if (this.currentPage() > maxPage) {
this.currentPage.set(maxPage);
onPageAdjusted?.(maxPage);
}
}
}🆕 처음 보는 문법: 왜 updateTotalCount에서 onPageAdjusted 콜백까지 호출할까요?
3페이지(마지막 페이지)에 글이 1건만 남은 상태에서 사용자가 그 글을 삭제했다고 가정해 봅시다:
- 삭제 직후
loadPosts()가 여전히 3페이지를 서버에 요청합니다. - 서버는 빈 배열(
items: [])과 줄어든 전체 건수(total: 20, 즉 총 2페이지)를 내려보냅니다. - 이때
currentPage숫자만2로 바꾸고 끝내면, 하단 페이지 표시만2 / 2로 바뀌고 테이블은 3페이지의 빈 화면("게시글이 없습니다") 그대로 남는 버그가 생깁니다! - 따라서 페이지 보정이 일어난 순간
onPageAdjusted콜백으로 2페이지 데이터를 다시 불러와야 화면까지 완벽히 채워집니다.
5.3 [도려내기 2] URL 쿼리와 양방향 동기화되는 PostFilterService 구현
4.5장에서 배운 ActivatedRoute와 Router를 내부에 주입받아, 검색어(keyword)와 카테고리(category)를 URL 쿼리스트링(?keyword=Angular&category=DEV)과 실시간으로 동기화하는 서비스를 만듭니다.
[코드 5-2] frontend/src/app/services/post-filter.service.ts (전체 코드)
typescript
// frontend/src/app/services/post-filter.service.ts
import { Injectable, signal, inject, DestroyRef } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
function normalizeQueryValue(val: unknown, fallback: string): string {
if (typeof val === 'string' && val.trim().length > 0) {
return val.trim();
}
if (Array.isArray(val) && typeof val[0] === 'string') {
return val[0].trim();
}
return fallback;
}
@Injectable()
export class PostFilterService {
// 라우터가 없는 단독 단위 테스트 환경에서도 안전하게 동작하도록 { optional: true } 옵션 사용!
private readonly route = inject(ActivatedRoute, { optional: true });
private readonly router = inject(Router, { optional: true });
private readonly destroyRef = inject(DestroyRef, { optional: true });
// 1. 현재 적용된 필터 신호 (초기값은 URL 쿼리스트링에서 즉시 복원!)
readonly keyword = signal<string>(
normalizeQueryValue(this.route?.snapshot?.queryParams?.['keyword'], '')
);
readonly category = signal<string>(
normalizeQueryValue(this.route?.snapshot?.queryParams?.['category'], 'ALL')
);
// 변경 알림 콜백 보관
private onFilterChangeCallback?: () => void;
constructor() {
// 2. 브라우저 [뒤로 가기 / 앞으로 가기] 시 URL 쿼리 변경을 감지해 신호 복원 및 재조회 트리거
if (this.route?.queryParams) {
const stream = this.destroyRef
? this.route.queryParams.pipe(takeUntilDestroyed(this.destroyRef))
: this.route.queryParams;
stream.subscribe((params) => {
const nextKeyword = normalizeQueryValue(params['keyword'], '');
const nextCategory = normalizeQueryValue(params['category'], 'ALL');
// 실제로 값이 달라졌을 때만 상태를 갱신하고 콜백을 실행해 중복 API 호출 방지!
if (this.keyword() !== nextKeyword || this.category() !== nextCategory) {
this.keyword.set(nextKeyword);
this.category.set(nextCategory);
this.onFilterChangeCallback?.();
}
});
}
}
/**
* 컴포넌트가 URL 복원(뒤로 가기 등) 시 실행할 콜백을 등록합니다.
*/
registerOnFilterChange(callback: () => void): void {
this.onFilterChangeCallback = callback;
}
/**
* 새 검색어와 카테고리를 적용하고 URL 쿼리스트링을 replaceUrl로 갱신합니다.
*/
applyFilter(newKeyword: string, newCategory: string): void {
const trimmedKeyword = newKeyword.trim();
const normalizedCategory = newCategory || 'ALL';
this.keyword.set(trimmedKeyword);
this.category.set(normalizedCategory);
if (this.router && this.route) {
this.router.navigate([], {
relativeTo: this.route,
queryParams: {
keyword: trimmedKeyword || null,
category: normalizedCategory === 'ALL' ? null : normalizedCategory,
page: '1'
},
queryParamsHandling: 'merge',
replaceUrl: true
});
}
this.onFilterChangeCallback?.();
}
resetFilter(): void {
this.applyFilter('', 'ALL');
}
}🆕 처음 보는 문법: inject(ActivatedRoute, { optional: true })와 takeUntilDestroyed
{ optional: true }: 만약 라우터 설정 없이 순수 단위 테스트(new PostFilterService())에서 이 서비스를 생성하더라도NullInjectorError를 던지지 않고null을 반환하게 해 주는 유연한 옵션입니다.takeUntilDestroyed(this.destroyRef): 컴포넌트와 서비스가 화면에서 사라질 때(Destroy) RxJSqueryParams구독을 **자동으로 해제(unsubscribe)**하여 메모리 누수를 원천 차단하는 Angular 표준 연산자입니다(13장에서 심층 분석).
5.4 [도려내기 3] 폼 유효성 검증 전담 FormValidationService 구현
글 등록 폼의 필드별 에러 메시지 상태와 글자 수 검증 규칙을 독립된 서비스로 분리합니다.
[코드 5-3] frontend/src/app/services/form-validation.service.ts (전체 코드)
typescript
// frontend/src/app/services/form-validation.service.ts
import { Injectable, signal, computed } from '@angular/core';
export interface PostFormErrors {
title: string;
author: string;
content: string;
}
@Injectable()
export class FormValidationService {
readonly errors = signal<PostFormErrors>({
title: '',
author: '',
content: ''
});
readonly isValid = computed(() => {
const current = this.errors();
return !current.title && !current.author && !current.content;
});
validate(title: string, author: string, content: string): boolean {
const nextErrors: PostFormErrors = {
title: title.trim().length < 2 ? '제목은 2자 이상 입력해야 합니다.' : '',
author: author.trim().length < 1 ? '작성자를 입력해야 합니다.' : '',
content: content.trim().length < 10 ? '본문은 10자 이상 입력해야 합니다.' : ''
};
this.errors.set(nextErrors);
return !nextErrors.title && !nextErrors.author && !nextErrors.content;
}
clearErrors(): void {
this.errors.set({ title: '', author: '', content: '' });
}
}5.5 다이어트에 성공한 클린 BoardAppComponent
이제 도려낸 3개의 서비스를 BoardAppComponent의 providers에 등록하고 inject()로 레고 블록처럼 조립합니다!
[코드 5-4] 리팩터링이 완료된 BoardAppComponent (board-app.component.ts & .html)
typescript
// frontend/src/app/board-app/board-app.component.ts
import { Component, ChangeDetectionStrategy, signal, inject, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { PaginationService } from '../services/pagination.service';
import { PostFilterService } from '../services/post-filter.service';
import { FormValidationService } from '../services/form-validation.service';
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],
// 🌟 3개의 비즈니스 상태 서비스를 이 컴포넌트 인스턴스 전용으로 제공!
providers: [PaginationService, PostFilterService, FormValidationService],
templateUrl: './board-app.component.html',
styleUrl: './board-app.component.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class BoardAppComponent implements OnInit {
private readonly http = inject(HttpClient);
// 🌟 1. 도려낸 3대 비즈니스 서비스를 단 세 줄로 장착!
readonly pager = inject(PaginationService);
readonly filter = inject(PostFilterService);
readonly validator = inject(FormValidationService);
// 2. 순수 도메인 및 UI 입력 바인딩 상태
posts = signal<Post[]>([]);
isLoading = signal<boolean>(false);
isSubmitting = signal<boolean>(false);
// 검색창 타이핑용 임시 입력 신호 (초기값은 URL에서 복원된 filter 신호 값!)
inputKeyword = signal<string>(this.filter.keyword());
selectedCategory = signal<string>(this.filter.category());
// 등록 폼 입력 신호
newTitle = signal<string>('');
newAuthor = signal<string>('');
newContent = signal<string>('');
constructor() {
// 검색 조건이 적용되거나 브라우저 [뒤로 가기]로 URL 쿼리가 복원될 때 실행할 동작 연결
this.filter.registerOnFilterChange(() => {
this.inputKeyword.set(this.filter.keyword());
this.selectedCategory.set(this.filter.category());
this.pager.resetFirstPage();
this.loadPosts();
});
}
ngOnInit(): void {
this.loadPosts();
}
handleSearch(): void {
this.filter.applyFilter(this.inputKeyword(), this.selectedCategory());
}
handleReset(): void {
this.inputKeyword.set('');
this.selectedCategory.set('ALL');
this.filter.resetFilter();
}
onPageChange(targetPage: number): void {
this.pager.setPage(targetPage, () => this.loadPosts());
}
loadPosts(): void {
this.isLoading.set(true);
this.http
.get<PageResponse>('/api/posts', {
params: {
page: this.pager.currentPage(),
size: this.pager.pageSize(),
category: this.filter.category(),
keyword: this.filter.keyword()
}
})
.subscribe({
next: (res) => {
this.posts.set(res.items ?? []);
this.isLoading.set(false);
// 🌟 마지막 페이지의 유일한 글 삭제 시 자동 페이지 보정 후 재조회!
this.pager.updateTotalCount(res.total ?? 0, () => {
this.loadPosts();
});
},
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.validator.validate(this.newTitle(), this.newAuthor(), this.newContent())) {
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.validator.clearErrors();
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 게시판 (v2.0 Clean Architecture)</h2>
<span class="badge">총 {{ pager.totalCount() }}건</span>
</div>
<!-- 1. 검색 및 필터 바 (filter 서비스와 연결) -->
<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]="inputKeyword()"
(ngModelChange)="inputKeyword.set($event)"
(keyup.enter)="handleSearch()"
placeholder="검색어 입력 후 Enter"
/>
<button type="button" (click)="handleSearch()">검색</button>
<button type="button" (click)="handleReset()">초기화</button>
</div>
<!-- 2. 게시글 등록 폼 (validator 서비스와 연결) -->
<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 (validator.errors().title) {
<p class="error-msg">⚠️ {{ validator.errors().title }}</p>
}
@if (validator.errors().author) {
<p class="error-msg">⚠️ {{ validator.errors().author }}</p>
}
<textarea
[ngModel]="newContent()"
(ngModelChange)="newContent.set($event)"
name="content"
placeholder="내용 (10자 이상)"
rows="3"
></textarea>
@if (validator.errors().content) {
<p class="error-msg">⚠️ {{ validator.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. 페이지네이션 (pager 서비스와 직결!) -->
<div class="pagination">
<button
type="button"
[disabled]="!pager.hasPrevPage()"
(click)="onPageChange(pager.currentPage() - 1)"
>
이전
</button>
<span>
{{ pager.currentPage() }} / {{ pager.totalPages() }} 페이지
(총 {{ pager.totalCount() }}건)
</span>
<button
type="button"
[disabled]="!pager.hasNextPage()"
(click)="onPageChange(pager.currentPage() + 1)"
>
다음
</button>
</div>
</div>5.6 첫 자동 단위 테스트: pagination.service.spec.ts (Vitest)
비즈니스 로직을 PaginationService로 독립시킨 가장 짜릿한 보상은 브라우저나 백엔드 서버를 전혀 띄우지 않고도 0.1초 만에 모든 엣지 케이스를 자동 검증할 수 있다는 점입니다!
frontend/tests/unit/pagination.service.spec.ts 파일을 열고 아래 전체 테스트 코드를 작성합니다.
[코드 5-5] frontend/tests/unit/pagination.service.spec.ts (전체 코드)
typescript
// frontend/tests/unit/pagination.service.spec.ts
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { PaginationService } from '../../src/app/services/pagination.service';
describe('PaginationService 단위 테스트', () => {
let service: PaginationService;
beforeEach(() => {
// 🌟 Angular TestBed나 DOM 렌더링 없이 순수 TypeScript 클래스로 0.001초 만에 인스턴스 생성!
service = new PaginationService();
service.pageSize.set(10);
});
it('1. 총 개수(25건)와 페이지 크기(10건)에 따라 전체 페이지 수(3페이지)를 정확히 계산해야 한다', () => {
service.updateTotalCount(25);
expect(service.totalPages()).toBe(3);
expect(service.currentPage()).toBe(1);
expect(service.hasPrevPage()).toBe(false);
expect(service.hasNextPage()).toBe(true);
});
it('2. 데이터가 0건일 때도 최소 전체 페이지 수는 1이어야 한다', () => {
service.updateTotalCount(0);
expect(service.totalPages()).toBe(1);
expect(service.hasPrevPage()).toBe(false);
expect(service.hasNextPage()).toBe(false);
});
it('3. nextPage 호출 시 페이지가 1 증가하고 콜백이 새 페이지 번호와 함께 실행되어야 한다', () => {
service.updateTotalCount(25);
const onPageChangeMock = vi.fn();
const moved = service.nextPage(onPageChangeMock);
expect(moved).toBe(true);
expect(service.currentPage()).toBe(2);
expect(onPageChangeMock).toHaveBeenCalledTimes(1);
expect(onPageChangeMock).toHaveBeenCalledWith(2);
});
it('4. 최대 범위를 벗어난 페이지 번호(999)는 무시되고 콜백도 호출되지 않아야 한다', () => {
service.updateTotalCount(25);
const onPageChangeMock = vi.fn();
const moved = service.setPage(999, onPageChangeMock);
expect(moved).toBe(false);
expect(service.currentPage()).toBe(1);
expect(onPageChangeMock).not.toHaveBeenCalled();
});
it('5. 마지막 페이지(3페이지)의 유일한 글이 삭제되어 총 개수가 20건으로 줄면, 자동으로 2페이지로 보정하고 재조회 콜백을 호출해야 한다', () => {
service.updateTotalCount(21); // 10개씩 3페이지
service.setPage(3);
expect(service.currentPage()).toBe(3);
const onPageAdjustedMock = vi.fn();
// 3페이지의 유일한 글 삭제 → 총 20건(2페이지)으로 감소!
service.updateTotalCount(20, onPageAdjustedMock);
expect(service.totalPages()).toBe(2);
expect(service.currentPage()).toBe(2);
expect(onPageAdjustedMock).toHaveBeenCalledWith(2);
});
});🆕 처음 보는 문법: Vitest 핵심 단언문 4가지
describe('묶음 이름', () => { ... }): 테스트 스위트(그룹)를 정의합니다.beforeEach(() => { ... }): 각it테스트가 실행되기 직전에 매번 깨끗한 새PaginationService인스턴스를 준비합니다.expect(실제값).toBe(기대값): 실제 결과와 기대하는 결과가 정확히 일치하는지 검증합니다.vi.fn(): 실제loadPosts()대신 넘기는 스파이(Mock) 함수입니다. 몇 번 호출되었는지(toHaveBeenCalledTimes), 어떤 인자로 호출되었는지(toHaveBeenCalledWith(2))를 기록해 검증합니다.
터미널에서 테스트 실행하기 (GREEN → RED → GREEN 체험)
bash
cd frontend
npx vitest run pagination.servicetext
✓ tests/unit/pagination.service.spec.ts (5 tests) 6ms
Test Files 1 passed (1)
Tests 5 passed (5)만약 pagination.service.ts의 updateTotalCount 메서드에서 onPageAdjusted?.(maxPage); 한 줄을 주석 처리하고 다시 실행하면, 5번 테스트가 즉시 빨간불(RED)을 내며 *"페이지는 2로 바뀌었지만 데이터를 다시 불러오지 않는 버그"*를 0.01초 만에 잡아냅니다. 확인 후 주석을 반드시 원래대로 되돌려 두십시오(GREEN).
5.7 [직접 해보기] 실습 미션 💪
방금 우리가 만든 FormValidationService(form-validation.service.ts)를 검증하는 **단위 테스트 파일(form-validation.service.spec.ts)**을 직접 작성해 보십시오!
📝 미션 요구사항
- 제목이 1자(
'아')일 때validate('아', '김개발', '충분히 긴 본문 내용입니다.')를 호출하면false를 반환하고errors().title에 에러 메시지가 담기는지 검증하십시오. - 제목(2자 이상), 작성자(1자 이상), 본문(10자 이상)을 모두 올바르게 입력했을 때
validate(...)가true를 반환하고isValid()파생 신호도true가 되는지 검증하십시오. - 에러가 발생한 상태에서
clearErrors()를 호출하면 모든 에러 문자열이 비워지고isValid()가true로 돌아오는지 검증하십시오.
💡 미션 정답 코드 확인하기
typescript
// frontend/tests/unit/form-validation.service.spec.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { FormValidationService } from '../../src/app/services/form-validation.service';
describe('FormValidationService 단위 테스트', () => {
let validator: FormValidationService;
beforeEach(() => {
validator = new FormValidationService();
});
it('1. 제목이 2자 미만이면 검증에 실패하고 제목 에러 메시지를 설정해야 한다', () => {
const ok = validator.validate('아', '김개발', '10자가 넘는 정상적인 본문 내용입니다.');
expect(ok).toBe(false);
expect(validator.isValid()).toBe(false);
expect(validator.errors().title).toContain('2자 이상');
expect(validator.errors().content).toBe('');
});
it('2. 모든 필드가 규칙을 만족하면 true를 반환하고 에러가 없어야 한다', () => {
const ok = validator.validate('정상 제목', '김개발', '10자가 넘는 정상적인 본문 내용입니다.');
expect(ok).toBe(true);
expect(validator.isValid()).toBe(true);
expect(validator.errors()).toEqual({ title: '', author: '', content: '' });
});
it('3. clearErrors() 호출 시 모든 에러 상태가 초기화되어야 한다', () => {
validator.validate('', '', '');
expect(validator.isValid()).toBe(false);
validator.clearErrors();
expect(validator.isValid()).toBe(true);
expect(validator.errors()).toEqual({ title: '', author: '', content: '' });
});
});5.8 이번 장 핵심 요약 및 다음 단계 🚀
핵심 요약 3줄
- 컴포넌트에 뒤엉켜 있던 페이징(
PaginationService), URL 쿼리 동기화 검색(PostFilterService), 폼 검증(FormValidationService)을@Injectable()서비스로 분리하여 단일 책임 원칙(SRP)을 회복했습니다. - 컴포넌트의
@Component({ providers: [...] })에 서비스를 등록하면 각 게시판 인스턴스마다 독립된 상태를 가지며, 컴포넌트가 파괴될 때 함께 정리됩니다. - 분리된 서비스는 DOM이나 백엔드 없이
new PaginationService()로 즉시 생성해 Vitest로 0.1초 만에 단위 테스트할 수 있습니다.
다음 단계: 이제 BoardAppComponent가 아주 깔끔해졌습니다! 다음으로 사우들이 가장 간절히 원하던 **"무한 깊이(Depth) 계층형 대댓글 트리"**를 게시판에 붙일 차례입니다. 그런데 4단계 깊숙한 대대댓글에서 [삭제]나 [답글 등록]을 눌렀을 때, 중간 댓글 컴포넌트들이 이벤트를 일일이 중계(Props Drilling / Event Relay)하지 않고 단번에 처리하려면 어떻게 해야 할까요? Part 1의 대미를 장식할 Chapter 06에서 **재귀 컴포넌트와 Angular 고유의 계층적 컴포넌트 DI(providers: [CommentTreeService])**를 마스터해 봅시다!