Skip to content

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]과 같은 상태에서 시작합니다. 이 장에서 새로 만들거나 수정하는 파일:

  1. frontend/src/app/services/pagination.service.ts (신규 작성)
  2. frontend/src/app/services/post-filter.service.ts (신규 작성)
  3. frontend/src/app/services/form-validation.service.ts (신규 작성)
  4. frontend/src/app/board-app/board-app.component.ts & .html (슬림 버전으로 교체)
  5. 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건만 남은 상태에서 사용자가 그 글을 삭제했다고 가정해 봅시다:

  1. 삭제 직후 loadPosts()가 여전히 3페이지를 서버에 요청합니다.
  2. 서버는 빈 배열(items: [])과 줄어든 전체 건수(total: 20, 즉 총 2페이지)를 내려보냅니다.
  3. 이때 currentPage 숫자만 2로 바꾸고 끝내면, 하단 페이지 표시만 2 / 2로 바뀌고 테이블은 3페이지의 빈 화면("게시글이 없습니다") 그대로 남는 버그가 생깁니다!
  4. 따라서 페이지 보정이 일어난 순간 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) RxJS queryParams 구독을 **자동으로 해제(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.service
text
 ✓ 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. 제목이 1자('아')일 때 validate('아', '김개발', '충분히 긴 본문 내용입니다.')를 호출하면 false를 반환하고 errors().title에 에러 메시지가 담기는지 검증하십시오.
  2. 제목(2자 이상), 작성자(1자 이상), 본문(10자 이상)을 모두 올바르게 입력했을 때 validate(...)가 true를 반환하고 isValid() 파생 신호도 true가 되는지 검증하십시오.
  3. 에러가 발생한 상태에서 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줄

  1. 컴포넌트에 뒤엉켜 있던 페이징(PaginationService), URL 쿼리 동기화 검색(PostFilterService), 폼 검증(FormValidationService)을 @Injectable() 서비스로 분리하여 단일 책임 원칙(SRP)을 회복했습니다.
  2. 컴포넌트의 @Component({ providers: [...] })에 서비스를 등록하면 각 게시판 인스턴스마다 독립된 상태를 가지며, 컴포넌트가 파괴될 때 함께 정리됩니다.
  3. 분리된 서비스는 DOM이나 백엔드 없이 new PaginationService()로 즉시 생성해 Vitest로 0.1초 만에 단위 테스트할 수 있습니다.

다음 단계: 이제 BoardAppComponent가 아주 깔끔해졌습니다! 다음으로 사우들이 가장 간절히 원하던 **"무한 깊이(Depth) 계층형 대댓글 트리"**를 게시판에 붙일 차례입니다. 그런데 4단계 깊숙한 대대댓글에서 [삭제]나 [답글 등록]을 눌렀을 때, 중간 댓글 컴포넌트들이 이벤트를 일일이 중계(Props Drilling / Event Relay)하지 않고 단번에 처리하려면 어떻게 해야 할까요? Part 1의 대미를 장식할 Chapter 06에서 **재귀 컴포넌트와 Angular 고유의 계층적 컴포넌트 DI(providers: [CommentTreeService])**를 마스터해 봅시다!

Moon HomeLab Engineering Knowledge Hub