Skip to content

Chapter 05. Custom Hook 패턴으로 로직 독립시키기 (+ 첫 자동 테스트) ​

"4장에서 관심사가 뒤엉켰던 BoardApp.tsx를 해체하자. 변하지 않는 핵심 비즈니스 로직을 Custom Hook 함수로 도려내면, 컴포넌트는 다시 순수한 UI 조립 본연의 모습으로 돌아온다."


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

개념한 줄 요약설명 위치
Custom Hook (useXxx)useState·useEffect 등 상태를 가진 로직을 캡슐화한 재사용 함수5.1, 규칙은 5.6 돌아보기
경로 별칭 @/src/ 폴더를 가리키는 줄임 표기5.1
옵션 인터페이스와 콜백 onPageChange?.()훅의 입력 계약 정의 및 페이지 변경·보정 시 외부 동작 트리거5.2 🆕 박스
총 페이지 감소 시 현재 페이지 자동 보정마지막 페이지의 유일한 글을 삭제했을 때 빈 화면이 남지 않게 보정5.2 NOTE
useSearchParams 양방향 동기화URL 쿼리스트링과 검색 필터 상태(keyword, category) 연결5.3 (기초는 4.5장)
useFormValidation 훅 분리폼 에러 상태와 검증 규칙을 컴포넌트 밖으로 독립5.4
Vitest describe / it / expect, vi.fn()자동 테스트의 묶음 / 한 가지 확인 / 기대값 비교 / 가짜 콜백 함수5.7
@testing-library/react의 renderHook, act화면(JSX) 없이 Custom Hook만 단독 실행하고 상태 변경을 반영하는 도구5.7

TIP

🏁 이 장의 시작 파일
frontend/src/BoardApp.tsx가 4장의 코드 4-1과 같은 상태(4.5장 임시 실습은 되돌린 상태)에서 시작합니다. 이 장에서 손대는 파일: src/hooks/usePagination.ts, src/hooks/usePostFilter.ts, src/hooks/useFormValidation.ts(새로 작성), src/BoardApp.tsx(코드 5-4로 교체), tests/unit/usePagination.test.ts(5.7, 코드 5-5).
예제 패키지에는 src/hooks/ 세 파일의 완성본이 이미 들어 있습니다. 직접 쳐 보려면 파일 내용을 비우고 따라 쓰고, 그렇지 않다면 코드를 읽으며 열린 파일과 비교만 해도 됩니다.


5.1 Custom Hook이란 무엇인가? ​

React에서 **Custom Hook(커스텀 훅)**이란 **"이름이 use로 시작하며, 내부에서 React의 기본 훅(useState, useEffect, useMemo, useRef 등)을 조합하여 상태를 가진 로직(Stateful Logic)을 캡슐화하고 여러 컴포넌트에서 재사용하는 함수"**를 의미합니다.

NOTE

순수 유틸리티 함수(Utility Function) vs 커스텀 훅(Custom Hook)

  • 유틸리티 함수 (formatDate, calculateTax): 내부 상태를 가지지 않으며, 같은 입력에 대해 항상 같은 출력을 반환하는 상태 비저장(Stateless) 함수입니다.
  • 커스텀 훅 (usePagination, usePostFilter): 호출될 때마다 각 컴포넌트 인스턴스의 Fiber 슬롯에 귀속되는 **독립적인 상태(useState)와 부수 효과(useEffect)**를 생성하고 관리하는 상태 저장(Stateful) 함수입니다. 반드시 함수 이름을 use로 시작해야 React와 ESLint가 훅의 규칙(조건문 호출 금지 등)을 검사할 수 있습니다.

⌚ 비유: 문방구 계산기(유틸) vs 손목의 스마트워치(Custom Hook)

  • 유틸리티 함수는 책상 위 탁상용 계산기입니다. add(1, 2) 두드리면 3을 뱉고 끝이며, 방금 무엇을 계산했는지 아무것도 기억하지 못합니다.
  • Custom Hook은 내 손목에 찬 스마트워치입니다. 내 심박수와 걸음수를 하루 종일 기억(useState)하고, 목표를 달성하면 진동을 울립니다(useEffect). 컴포넌트가 손목에 찰 때마다(usePagination()) 그 컴포넌트만을 위한 전용 스마트워치가 독립적으로 하나씩 작동합니다!

컴포넌트가 UI 조합에만 집중할 수 있도록, 4장에서 BoardApp.tsx를 무겁게 짓누르던 세 가지 관심사(페이징, 검색 필터, 폼 검증)를 독립된 파일로 도려내겠습니다.

import ... from '@/hooks/usePagination'의 @는 src/ 폴더를 가리키는 **경로 별칭(alias)**입니다. 실습 환경에는 이미 설정되어 있습니다.

🔬 심화(건너뛰어도 됨): @ 별칭은 어디서 정의되는가?
  • frontend/vite.config.ts의 resolve.alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) }: Vite 번들러가 실제로 파일을 찾을 때 사용
  • frontend/tsconfig.json의 "paths": { "@/*": ["src/*"] }: TypeScript와 VS Code가 타입을 찾을 때 사용

둘 중 하나만 설정하면 "브라우저에서는 되는데 에디터에 빨간 줄"이 뜨거나 그 반대 현상이 생기므로, 직접 프로젝트를 만들 때는 두 곳을 함께 맞추십시오.


5.2 [도려내기 1] usePagination.ts 분리 (페이지 계산과 자동 범위 보정) ​

4장의 BoardApp.tsx에 있던 currentPage, pageSize, totalCount, totalPages, changePage를 하나의 독립 훅으로 추출합니다.
특히 마지막 페이지의 유일한 글을 삭제해 전체 페이지 수(totalPages)가 줄어들었을 때, 현재 페이지가 유령 페이지에 머물지 않도록 자동으로 끌어내리고 onPageChange 콜백을 호출해 주는 안전장치까지 함께 캡슐화합니다.

새로운 파일을 생성(또는 확인)합니다: frontend/src/hooks/usePagination.ts

[코드 5-1] frontend/src/hooks/usePagination.ts ​

typescript
// frontend/src/hooks/usePagination.ts
import { useState, useMemo, useEffect } from 'react';

export interface UsePaginationOptions {
  initialPage?: number;
  initialSize?: number;
  totalCount: number;
  onPageChange?: (newPage: number) => void; // 페이지 변경 시 실행할 콜백
}

export function usePagination(options: UsePaginationOptions) {
  const {
    initialPage = 1,
    initialSize = 10,
    totalCount,
    onPageChange
  } = options;

  const [currentPage, setCurrentPage] = useState<number>(initialPage);
  const [pageSize, setPageSize] = useState<number>(initialSize);

  // 전체 페이지 수 계산 (글이 0건이어도 최소 1페이지 보장)
  const totalPages = useMemo(() => {
    const safeCount = Math.max(0, totalCount);
    return Math.max(1, Math.ceil(safeCount / pageSize));
  }, [totalCount, pageSize]);

  // 마지막 페이지의 유일한 글을 삭제해 totalPages가 줄어들었을 때 현재 페이지 자동 보정
  useEffect(() => {
    if (currentPage > totalPages) {
      setCurrentPage(totalPages);
      // 페이지 번호만 바꾸고 끝내면 화면에는 빈 페이지가 남습니다.
      // 보정된 페이지의 데이터를 다시 불러오도록 콜백까지 호출해야 합니다.
      onPageChange?.(totalPages);
    }
  }, [totalPages, currentPage]);

  const hasNextPage = currentPage < totalPages;
  const hasPrevPage = currentPage > 1;

  function setPage(page: number) {
    if (page < 1 || page > totalPages || page === currentPage) {
      return;
    }
    setCurrentPage(page);
    onPageChange?.(page);
  }

  function nextPage() {
    if (hasNextPage) {
      setPage(currentPage + 1);
    }
  }

  function prevPage() {
    if (hasPrevPage) {
      setPage(currentPage - 1);
    }
  }

  return {
    currentPage,
    pageSize,
    setPageSize,
    totalPages,
    hasNextPage,
    hasPrevPage,
    nextPage,
    prevPage,
    setPage
  };
}

🆕 처음 보는 문법: 코드 5-1의 문법

  • export interface UsePaginationOptions { ... }: 이 커스텀 훅이 받는 옵션 객체의 계약서입니다. initialPage?처럼 ?가 붙은 항목은 생략해도 됩니다.
  • const { initialPage = 1, initialSize = 10, totalCount, onPageChange } = options;: 옵션 객체를 구조 분해하면서 값이 생략되었을 때 = 1, = 10처럼 기본값을 넣습니다.
  • onPageChange?.(page): 옵셔널 호출 연산자입니다. 호출자가 onPageChange 콜백을 넘겼을 때만 실행하고, 넘기지 않았으면(undefined) 조용히 넘어갑니다. if (onPageChange) onPageChange(page);의 축약입니다.

NOTE

왜 보정 시에도 onPageChange(totalPages)를 호출해야 할까요?
3페이지(마지막 페이지)에 글이 1개만 남은 상태에서 그 글을 삭제하는 시나리오를 따라가 봅시다.

  1. 삭제 후 loadPosts()가 3페이지를 다시 조회 → 서버는 빈 items: []와 줄어든 total: 20을 반환합니다.
  2. totalCount가 20으로 줄어 totalPages가 3 → 2로 바뀌고, 위 useEffect가 currentPage를 2로 보정합니다.
  3. 이때 onPageChange?.(2)를 호출하지 않으면 페이지 번호 표시는 2 / 2로 바뀌었는데 테이블에는 3페이지의 빈 결과가 그대로 남게 됩니다!

5.3 [도려내기 2] URL Query와 양방향 동기화되는 usePostFilter.ts 분리 ​

검색어(keyword)와 카테고리(category)를 관리하고, 브라우저 URL 쿼리 파라미터(?keyword=React&category=DEV) 및 브라우저 [뒤로 가기/앞으로 가기]와 동기화하는 로직을 독립시킵니다. 4.5장에서 손으로 해 본 useSearchParams()로 쿼리 읽기/바꾸기를 그대로 커스텀 훅 안에 캡슐화합니다.

새로운 파일을 생성(또는 확인)합니다: frontend/src/hooks/usePostFilter.ts

[코드 5-2] frontend/src/hooks/usePostFilter.ts ​

typescript
// frontend/src/hooks/usePostFilter.ts
import { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';

export interface FilterState {
  keyword: string;
  category: string;
}

export function usePostFilter(onFilterChange?: (nextFilter: FilterState) => void) {
  const [searchParams, setSearchParams] = useSearchParams();

  // URL 쿼리스트링에서 초기 검색어와 카테고리를 읽어 상태를 초기화한다
  const [keyword, setKeyword] = useState<string>(
    () => searchParams.get('keyword') ?? ''
  );
  const [category, setCategory] = useState<string>(
    () => searchParams.get('category') ?? 'ALL'
  );

  // 콜백 참조를 최신으로 유지해 불필요한 이펙트 재실행을 방지한다
  const onFilterChangeRef = useRef(onFilterChange);
  onFilterChangeRef.current = onFilterChange;

  // 브라우저 [뒤로 가기 / 앞으로 가기] 시 URL 쿼리 변경을 감지해 상태를 복원한다
  useEffect(() => {
    const urlKeyword = searchParams.get('keyword') ?? '';
    const urlCategory = searchParams.get('category') ?? 'ALL';

    if (keyword !== urlKeyword || category !== urlCategory) {
      setKeyword(urlKeyword);
      setCategory(urlCategory);
      onFilterChangeRef.current?.({ keyword: urlKeyword, category: urlCategory });
    }
  }, [searchParams]);

  // 사용자가 [검색] 또는 [초기화]를 눌렀을 때 상태와 URL 쿼리를 동시에 갱신한다
  function applyFilter(newKeyword: string, newCategory: string) {
    const trimmedKeyword = newKeyword.trim();
    setKeyword(trimmedKeyword);
    setCategory(newCategory);

    setSearchParams(
      (prev) => {
        const next = new URLSearchParams(prev);
        if (trimmedKeyword) {
          next.set('keyword', trimmedKeyword);
        } else {
          next.delete('keyword');
        }

        if (newCategory && newCategory !== 'ALL') {
          next.set('category', newCategory);
        } else {
          next.delete('category');
        }

        next.set('page', '1');
        return next;
      },
      { replace: true } // 히스토리 오염 방지 (4.5장 4.5.5 참고)
    );

    onFilterChangeRef.current?.({
      keyword: trimmedKeyword,
      category: newCategory
    });
  }

  function resetFilter() {
    applyFilter('', 'ALL');
  }

  return {
    keyword,
    category,
    applyFilter,
    resetFilter
  };
}

🆕 처음 보는 문법: 지연 초기화 useState(() => ...)와 new URLSearchParams

  • useState(() => searchParams.get('keyword') ?? ''): useState에 값 대신 함수를 넘기면(Lazy Initialization) 최초 마운트 시 딱 1번만 실행해 초기값을 구합니다.
  • next.delete('keyword'): 검색어가 비었을 때 URL에서 ?keyword= 같은 빈 파라미터를 아예 제거하여 주소창을 깔끔하게 유지합니다(4.5.7 미션에서 연습한 코드입니다).
  • 왜 onFilterChange에 { keyword, category }를 인자로 넘기나요?: applyFilter 안에서 setKeyword(trimmedKeyword)를 호출한 직후에는 부모 컴포넌트의 keyword 변수가 아직 이전 렌더링 값입니다(2장 2.3). 그래서 방금 적용된 최신 필터 값을 콜백 인자로 직접 건네주어 부모가 즉시 정확한 파라미터로 API를 호출할 수 있게 합니다!

5.4 [도려내기 3] useFormValidation.ts 분리 ​

글 등록 폼의 유효성 검사 규칙과 에러 메시지 상태를 선언적으로 추상화합니다.

새로운 파일을 생성(또는 확인)합니다: frontend/src/hooks/useFormValidation.ts

[코드 5-3] frontend/src/hooks/useFormValidation.ts ​

typescript
// frontend/src/hooks/useFormValidation.ts
import { useState, useMemo } from 'react';

export interface FormErrors {
  title: string;
  content: string;
}

export function useFormValidation() {
  const [errors, setErrors] = useState<FormErrors>({
    title: '',
    content: ''
  });

  function validate(title: string, content: string): boolean {
    const nextErrors: FormErrors = {
      title:
        title.trim().length < 2 ? '제목은 2자 이상 입력해야 합니다.' : '',
      content:
        content.trim().length < 10 ? '본문은 10자 이상 입력해야 합니다.' : ''
    };
    setErrors(nextErrors);
    return !nextErrors.title && !nextErrors.content;
  }

  function clearErrors() {
    setErrors({ title: '', content: '' });
  }

  const isValid = useMemo(
    () => !errors.title && !errors.content,
    [errors.title, errors.content]
  );

  return {
    errors,
    isValid,
    validate,
    clearErrors
  };
}

5.5 다이어트에 성공한 BoardApp.tsx ​

이제 추출한 세 개의 Custom Hook(usePagination, usePostFilter, useFormValidation)을 BoardApp.tsx에서 레고 블록처럼 조립(Compose)합니다!
frontend/src/BoardApp.tsx 파일 전체를 아래 코드 5-4로 교체합니다. 완성본은 예제 패키지의 frontend/src/snapshots/BoardApp.ch05.tsx와 같습니다.

NOTE

줄 수가 비슷한데 왜 '다이어트'일까?
코드 4-1과 5-4를 나란히 놓으면 전체 줄 수는 비슷합니다(5-4에는 에러 처리와, URL에서 복원된 검색 조건을 입력창에 맞추는 useEffect가 추가되었습니다). 줄 수가 아니라 BoardApp.tsx가 직접 알아야 하는 내부 알고리즘이 사라졌다는 점을 보십시오.

관심사4장 (BoardApp.tsx 안)5장 (BoardApp.tsx 안)5장 (밖으로 독립)
페이징상태 3개 + 올림 계산 + 범위 검사usePagination({ ... }) 호출 1번hooks/usePagination.ts (+ 삭제 후 자동 보정)
검색 필터상태 2개 + 리셋 함수usePostFilter(...) 호출 + 입력창용 상태 2개hooks/usePostFilter.ts (+ URL 쿼리 동기화)
폼 검증에러 상태 + 글자 수 검증 규칙useFormValidation() 호출 1번hooks/useFormValidation.ts

실무의 수백 줄짜리 컴포넌트라면 이 차이가 훨씬 커지고, 무엇보다 옆 팀의 NoticeBoard.tsx가 같은 세 파일을 복사 없이 import해서 쓸 수 있게 됩니다!

[코드 5-4] 슬림해진 클린 컴포넌트 (frontend/src/BoardApp.tsx) ​

tsx
// frontend/src/BoardApp.tsx (코드 5-4: 3개의 Custom Hook을 조립한 클린 컴포넌트)
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { usePagination } from '@/hooks/usePagination';
import { usePostFilter } from '@/hooks/usePostFilter';
import { useFormValidation } from '@/hooks/useFormValidation';
import './BoardApp.css';

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

export default function BoardApp() {
  // 1. 도메인 상태
  const [posts, setPosts] = useState<Post[]>([]);
  const [totalCount, setTotalCount] = useState<number>(0);
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
  const [form, setForm] = useState({ title: '', author: '', content: '' });

  // 2. 검색 필터 Custom Hook 합성
  const { keyword, category, applyFilter, resetFilter } = usePostFilter(
    (nextFilter) => {
      setPage(1);
      loadPosts(1, nextFilter.keyword, nextFilter.category);
    }
  );

  // 입력창에 타이핑 중인 임시 상태 (Enter나 [검색]을 누를 때 applyFilter로 확정)
  const [inputKeyword, setInputKeyword] = useState<string>(keyword);
  const [selectedCategory, setSelectedCategory] = useState<string>(category);

  // URL 쿼리 변경(뒤로/앞으로 가기)으로 필터가 복원되면 입력창 값도 함께 맞춰준다
  useEffect(() => {
    setInputKeyword(keyword);
    setSelectedCategory(category);
  }, [keyword, category]);

  // 3. 페이지네이션 Custom Hook 합성
  const {
    currentPage,
    pageSize,
    totalPages,
    hasNextPage,
    hasPrevPage,
    nextPage,
    prevPage,
    setPage
  } = usePagination({
    totalCount,
    onPageChange: (newPage) => loadPosts(newPage, keyword, category)
  });

  // 4. 폼 유효성 검사 Custom Hook 합성
  const { errors, validate, clearErrors } = useFormValidation();

  // 5. 서버 통신 함수
  async function loadPosts(
    targetPage = currentPage,
    targetKeyword = keyword,
    targetCategory = category
  ) {
    setIsLoading(true);
    try {
      const res = await axios.get<{ items: Post[]; total: number }>('/api/posts', {
        params: {
          page: targetPage,
          size: pageSize,
          keyword: targetKeyword,
          category: targetCategory
        }
      });
      setPosts(res.data.items || []);
      setTotalCount(res.data.total ?? 0);
    } catch (err) {
      alert('게시글 목록을 불러오지 못했습니다.');
    } finally {
      setIsLoading(false);
    }
  }

  function handleSearch() {
    applyFilter(inputKeyword, selectedCategory);
  }

  function handleReset() {
    setInputKeyword('');
    setSelectedCategory('ALL');
    resetFilter();
  }

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

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    if (!validate(form.title, form.content)) return;
    setIsSubmitting(true);
    try {
      await axios.post('/api/posts', { ...form, category: 'DEV' });
      setForm({ title: '', author: '', content: '' });
      clearErrors();
      await loadPosts();
    } catch (err) {
      alert('게시글 등록에 실패했습니다.');
    } finally {
      setIsSubmitting(false);
    }
  }

  useEffect(() => {
    loadPosts();
  }, []);

  return (
    <div className="board-container">
      <h2>사내 기술 Q&A 게시판 (v2.0)</h2>

      {/* 1. 검색 바 */}
      <div className="filter-bar">
        <select
          value={selectedCategory}
          onChange={(e) => setSelectedCategory(e.target.value)}
        >
          <option value="ALL">전체</option>
          <option value="DEV">개발</option>
          <option value="NOTICE">공지</option>
          <option value="FREE">자유</option>
        </select>
        <input
          value={inputKeyword}
          onChange={(e) => setInputKeyword(e.target.value)}
          placeholder="검색어"
          onKeyDown={(e) => {
            if (e.key === 'Enter') handleSearch();
          }}
        />
        <button type="button" onClick={handleSearch}>
          검색
        </button>
        <button type="button" onClick={handleReset}>
          초기화
        </button>
      </div>

      {/* 2. 등록 폼 */}
      <form className="form-box" onSubmit={handleSubmit}>
        <input
          value={form.title}
          onChange={(e) => setForm({ ...form, title: e.target.value })}
          placeholder="제목"
        />
        {errors.title && <span className="error">{errors.title}</span>}
        <input
          value={form.author}
          onChange={(e) => setForm({ ...form, author: e.target.value })}
          placeholder="작성자"
        />
        <textarea
          value={form.content}
          onChange={(e) => setForm({ ...form, content: e.target.value })}
          placeholder="내용"
        />
        {errors.content && <span className="error">{errors.content}</span>}
        <button type="submit" disabled={isSubmitting}>
          등록
        </button>
      </form>

      {/* 3. 게시글 목록 테이블 */}
      <table className="board-table">
        <thead>
          <tr>
            <th>ID</th>
            <th>제목</th>
            <th>작성자</th>
            <th>작성일</th>
            <th>동작</th>
          </tr>
        </thead>
        <tbody>
          {posts.map((post) => (
            <tr key={post.id}>
              <td>{post.id}</td>
              <td>{post.title}</td>
              <td>{post.author}</td>
              <td>{post.createdAt}</td>
              <td>
                <button
                  className="btn-delete"
                  onClick={() => handleDelete(post.id)}
                >
                  삭제
                </button>
              </td>
            </tr>
          ))}
          {posts.length === 0 && !isLoading && (
            <tr>
              <td colSpan={5} className="empty-cell">
                게시글이 없습니다.
              </td>
            </tr>
          )}
        </tbody>
      </table>

      {/* 4. 페이지네이션 */}
      <div className="pagination">
        <button disabled={!hasPrevPage} onClick={prevPage}>
          이전
        </button>
        <span>
          {currentPage} / {totalPages} (총 {totalCount}건)
        </span>
        <button disabled={!hasNextPage} onClick={nextPage}>
          다음
        </button>
      </div>
    </div>
  );
}

NOTE

왜 inputKeyword를 keyword와 따로 두고, 다시 useEffect로 맞춰줄까요?
keyword는 "실제로 서버 조회에 적용된 검색 조건"이고, inputKeyword는 "사용자가 입력창에 타이핑 중인 값"입니다. 둘을 하나로 합치면 타자 한 글자마다 확정된 검색 조건이 바뀌어 버리므로 분리했습니다.
다만 useState(keyword)의 초기값은 마운트 시점에만 평가되므로, 브라우저 [뒤로 가기]로 URL 쿼리가 바뀌어 keyword가 복원되었을 때 입력창도 함께 바뀌도록 useEffect로 동기화해 줍니다.

그림으로 보는 검색 흐름 ​

검색 버튼을 눌렀을 때와 [뒤로 가기]를 눌렀을 때, 두 길 모두 결국 BoardApp.tsx가 넘긴 콜백(onFilterChange)으로 모입니다.

💡 [확인하기]: npm run dev로 화면을 열고 다음 세 가지를 확인하십시오.

  1. 검색 → URL: 검색창에 React를 넣고 Enter를 누르면 주소창이 /?keyword=React&page=1로 바뀌고 목록이 걸러집니다. 이 주소에서 새로고침(F5)을 해도 입력창에 React가 남아 있고 같은 결과가 나옵니다.
  2. [뒤로 가기] → 입력창 복원: 그 상태에서 상단 헤더의 [목록]을 누르면 주소가 /가 되고 입력창이 비면서 전체 목록이 나옵니다. 이제 브라우저 [뒤로 가기]를 누르면 주소가 /?keyword=React&page=1로 돌아오고 입력창에 React가 다시 채워지며 목록도 다시 걸러집니다!
  3. 마지막 페이지의 유일한 글 삭제 → 페이지 자동 보정: 샘플 글은 7건이라 한 페이지뿐입니다. 글을 4건(제목 2자 이상, 본문 10자 이상) 등록해 총 11건으로 만들고 [다음]을 누르면 2 / 2 (총 11건)에 글 1건이 보입니다. 이 글을 삭제하면 표시가 1 / 1 (총 10건)으로 바뀌고 자동으로 1페이지 목록이 나타납니다!

5.6 무엇이 좋아졌는가? (Custom Hook 설계 4대 원칙 돌아보기) ​

세 파일을 직접 만들어 봤으니, 실무에서 Custom Hook을 설계할 때 지킨 4가지 원칙을 코드와 맞춰 정리해 봅시다.

  1. 원칙 1. 이름은 반드시 use로 시작하고, 컴포넌트나 다른 훅의 최상위에서만 호출한다
    usePagination, usePostFilter처럼 use로 시작해야 React Linter가 조건문·반복문 내부 호출을 감지해 막아 줍니다(2.5장 2.5.4). 클릭 핸들러(handleSearch) 안에서는 절대 usePostFilter()를 호출할 수 없습니다.
  2. 원칙 2. Custom Hook은 "상태 자체"를 공유하는 것이 아니라 "상태 로직"을 공유한다
    BoardApp에서 usePagination()을 부르고 옆 팀의 NoticeBoard에서도 usePagination()을 부르면, 두 컴포넌트는 같은 페이지 번호를 공유할까요? 아닙니다! 완전히 독립된 별개의 currentPage 상태를 가집니다. 호출할 때마다 그 컴포넌트의 Fiber 슬롯에 새 상태가 만들어지기 때문입니다. (여러 컴포넌트가 같은 상태 값을 공유해야 할 때는 6장의 Context나 9장의 Zustand 스토어를 씁니다.)
  3. 원칙 3. 반환값은 구조 분해하기 편한 일반 객체({ currentPage, nextPage, ... })로 돌려준다
    인자가 2개뿐인 useState는 배열([val, setVal])을 반환하지만, 반환 항목이 3개 이상인 Custom Hook은 객체를 반환해야 호출자가 필요한 항목만 이름으로 꺼내 쓸 수 있습니다(const { currentPage, nextPage } = usePagination(...)).
  4. 원칙 4. 외부 동작과의 연결은 콜백(onPageChange, onFilterChange)으로 느슨하게 결합한다
    usePagination 내부에 axios.get('/api/posts')를 직접 하드코딩했다면 게시글 게시판에서밖에 못 썼을 것입니다. 대신 페이지가 바뀔 때 onPageChange?.(newPage) 콜백만 불러 주도록 설계했기 때문에, 게시글 목록이든 댓글 목록이든 사원 목록이든 어디서나 재사용할 수 있습니다!

5.7 첫 자동 테스트: Vitest와 renderHook으로 usePagination 검증하기 ​

5.5 [확인하기] 3번(마지막 페이지의 유일한 글 삭제)을 손으로 확인하려면 글을 4건 등록하고, 페이지를 넘기고, 삭제해야 했습니다. 이 확인을 자동화 테스트 코드로 적어 두면 0.1초 만에, 몇 번이든 반복할 수 있습니다! Custom Hook은 무거운 UI 컴포넌트나 백엔드 서버 없이 단독으로 호출할 수 있으므로 첫 단위 테스트 대상으로 최적입니다.

테스트 도구 Vitest와 **@testing-library/react**는 실습 환경에 이미 설치되어 있습니다. 테스트 파일을 열어 내용을 확인하고 실행해 봅시다.

[코드 5-5] frontend/tests/unit/usePagination.test.ts ​

typescript
// frontend/tests/unit/usePagination.test.ts
// [코드 5-5] 5장 5.7 첫 자동 테스트: 화면·서버 없이 usePagination의 계산과 보정만 검사한다
import { describe, it, expect, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { usePagination } from '@/hooks/usePagination';

describe('usePagination Custom Hook 단위 테스트', () => {
  it('총 개수와 페이지 크기에 따라 올바른 전체 페이지 수를 계산해야 한다', () => {
    const { result } = renderHook(() =>
      usePagination({
        totalCount: 25,
        initialSize: 10
      })
    );

    expect(result.current.totalPages).toBe(3);
    expect(result.current.hasNextPage).toBe(true);
    expect(result.current.hasPrevPage).toBe(false);
  });

  it('nextPage 호출 시 페이지가 1 증가하고 콜백이 실행되어야 한다', () => {
    const onPageChangeMock = vi.fn(); // 호출 여부와 인자를 기록하는 가짜 콜백 함수

    const { result } = renderHook(() =>
      usePagination({
        totalCount: 25,
        initialSize: 10,
        onPageChange: onPageChangeMock
      })
    );

    // 훅의 상태를 변경하는 함수를 호출할 때는 act()로 감싸 리렌더링을 반영한다
    act(() => {
      result.current.nextPage();
    });

    expect(result.current.currentPage).toBe(2);
    expect(onPageChangeMock).toHaveBeenCalledWith(2);
  });

  it('마지막 페이지의 글이 삭제되어 totalPages가 줄면 현재 페이지를 보정하고 데이터를 다시 불러와야 한다', () => {
    const onPageChangeMock = vi.fn();

    // 처음에는 totalCount = 21 (10개씩 3페이지)로 시작하며, 나중에 rerender로 totalCount를 바꾼다
    const { result, rerender } = renderHook(
      ({ count }) =>
        usePagination({
          totalCount: count,
          initialSize: 10,
          onPageChange: onPageChangeMock
        }),
      { initialProps: { count: 21 } }
    );

    // 3페이지로 이동
    act(() => {
      result.current.setPage(3);
    });
    onPageChangeMock.mockClear(); // setPage(3) 때 기록된 호출은 지우고 아래 삭제 상황만 본다

    // 3페이지의 유일한 글이 삭제되어 totalCount가 20(총 2페이지)으로 줄어든 상황 시뮬레이션!
    rerender({ count: 20 });

    expect(result.current.currentPage).toBe(2);
    expect(onPageChangeMock).toHaveBeenCalledWith(2);
  });
});

🆕 처음 보는 문법: Vitest와 renderHook / act 최소 문법

  • describe('이름', () => { ... }) / it('이름', () => { ... }): 관련 테스트를 묶는 상자와 개별 테스트 케이스입니다.
  • expect(실제값).toBe(기대값): 두 값이 같은지 비교합니다. 다르면 테스트가 빨간불(FAIL)을 냅니다.
  • renderHook(() => usePagination(...)): 훅은 원래 컴포넌트 밖에서 그냥 호출할 수 없습니다(훅의 규칙!). renderHook은 보이지 않는 초경량 테스트용 컴포넌트를 메모리에서 마운트해 그 안에서 훅을 실행하고, 반환값을 **result.current**에 담아 돌려줍니다.
  • act(() => { result.current.nextPage(); }): nextPage()처럼 내부에서 setState를 부르는 함수를 실행할 때 act로 감싸면, React의 상태 배치와 useEffect 실행이 모두 끝난 최신 상태가 result.current에 반영됩니다.
  • vi.fn(): 진짜 loadPosts 대신 넘기는 **가짜 스파이 함수(Mock Function)**입니다. 몇 번, 어떤 인자로 불렸는지 모두 기록하므로 expect(mock).toHaveBeenCalledWith(2)로 "인자 2를 들고 호출되었는가?"를 정확히 검증할 수 있습니다.

[실습 1] 실행하기: GREEN 확인 ​

터미널의 frontend 폴더에서 실행합니다:

bash
npx vitest run usePagination
text
 ✓ tests/unit/usePagination.test.ts (3 tests) 18ms

 Test Files  1 passed (1)
      Tests  3 passed (3)

[실습 2] 일부러 깨뜨리기: RED → 되돌려 GREEN ​

테스트가 정말 버그를 잡는지 확인하려면 **한 번은 실패하는 모습(RED)**을 직접 봐야 합니다. src/hooks/usePagination.ts의 useEffect 안에서 보정 후 콜백을 부르는 줄을 잠시 주석 처리해 보십시오:

typescript
    if (currentPage > totalPages) {
      setCurrentPage(totalPages);
      // onPageChange?.(totalPages);   // ← 잠시 주석 처리!
    }

다시 npx vitest run usePagination을 실행하면 세 번째 테스트가 즉시 실패합니다!

text
 ❯ tests/unit/usePagination.test.ts (3 tests | 1 failed)
   × usePagination Custom Hook 단위 테스트 > 마지막 페이지의 글이 삭제되어 totalPages가 줄면 현재 페이지를 보정하고 데이터를 다시 불러와야 한다
     → expected "spy" to be called with arguments: [ 2 ]

Number of calls: 0

 Test Files  1 failed (1)
      Tests  1 failed | 2 passed (3)

"가짜 함수(spy)가 인자 2로 불렸어야 하는데, 호출 횟수(Number of calls)가 0이다"라며 데이터를 다시 불러오지 않는 버그를 0.02초 만에 정확히 잡아냈습니다!
확인했으면 주석을 풀어 원래대로 되돌리고 다시 실행해 3 passed를 확인하십시오. 반드시 되돌리십시오.


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

미션: useFormValidation 훅을 검증하는 단위 테스트 2건 직접 작성해 보기 ​

방금 배운 renderHook과 act를 사용하여, useFormValidation이 제목 2자 미만일 때 false와 에러 메시지를 내는지 확인하는 간단한 테스트 코드를 떠올리거나 작성해 보십시오.

💡 미션 정답 코드 확인하기
typescript
import { describe, it, expect } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useFormValidation } from '@/hooks/useFormValidation';

describe('useFormValidation 단위 테스트', () => {
  it('제목이 2자 미만이면 validate가 false를 반환하고 title 에러가 설정되어야 한다', () => {
    const { result } = renderHook(() => useFormValidation());

    let isOk = true;
    act(() => {
      isOk = result.current.validate('짧', '충분히 긴 본문 내용입니다.');
    });

    expect(isOk).toBe(false);
    expect(result.current.errors.title).toContain('2자 이상');
    expect(result.current.errors.content).toBe('');
  });

  it('clearErrors 호출 시 모든 에러 메시지가 초기화되어야 한다', () => {
    const { result } = renderHook(() => useFormValidation());

    act(() => {
      result.current.validate('', '');
      result.current.clearErrors();
    });

    expect(result.current.errors.title).toBe('');
    expect(result.current.errors.content).toBe('');
  });
});

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

핵심 3줄 요약

  1. Custom Hook은 useState·useEffect·useMemo로 상태를 가진 비즈니스 로직을 useXxx 함수에 담아, 여러 컴포넌트가 복사 없이 import해 재사용하게 합니다.
  2. 훅과 컴포넌트는 콜백(onPageChange, onFilterChange)으로 느슨하게 연결하고, 검색 조건은 4.5장의 useSearchParams와 replace: true로 URL과 양방향 동기화합니다.
  3. Custom Hook은 화면 없이 renderHook과 act로 단독 호출할 수 있으므로 Vitest로 0.1초 만에 자동 테스트할 수 있으며, 일부러 깨뜨려 실패(RED)를 한 번 봐야 그 테스트를 신뢰할 수 있습니다.

바뀐 파일

파일무엇이 바뀌었나
src/hooks/usePagination.ts새로 작성(코드 5-1): 페이지 상태, 범위 검사, 삭제 후 페이지 자동 보정
src/hooks/usePostFilter.ts새로 작성(코드 5-2): 검색 조건 + useSearchParams URL 쿼리 양방향 동기화
src/hooks/useFormValidation.ts새로 작성(코드 5-3): 폼 검증 규칙과 에러 메시지 관리
src/BoardApp.tsx코드 5-4로 교체: 세 Custom Hook을 조립 (= snapshots/BoardApp.ch05.tsx)
tests/unit/usePagination.test.ts읽고 실행 및 RED/GREEN 검증 완료(코드 5-5)

자주 하는 실수 (Troubleshooting)

증상원인해결
Failed to resolve import "@/hooks/usePagination"파일 이름·경로 오타, 또는 src/hooks/가 아닌 곳에 만듦파일 위치와 대소문자 확인
useSearchParams() may be used only in the context of a <Router> component<BrowserRouter> 바깥에서 BoardApp을 렌더링함App.tsx에 <BrowserRouter>가 감싸져 있는지 확인
[뒤로 가기]로 주소는 바뀌는데 입력창은 예전 값코드 5-4의 useEffect([keyword, category]) 동기화를 빠뜨림입력창용 상태를 맞춰 주는 useEffect 추가
삭제 후 페이지 번호만 바뀌고 빈 테이블이 남음5.7 RED 실습 후 주석을 되돌리지 않음onPageChange?.(totalPages); 주석 해제

셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.

  1. BoardApp과 NoticeBoard 두 컴포넌트에서 각각 usePagination을 호출하면 두 컴포넌트는 currentPage 값을 공유하나요, 아니면 각자 따로 가지나요?
  2. 검색 버튼을 누르면 applyFilter가 URL을 바꾸고, 그 결과 usePostFilter 안의 useEffect([searchParams])도 깨어납니다. 그런데도 loadPosts()가 두 번 불리지 않는 이유는 무엇인가요?
  3. usePagination의 useEffect에서 onPageChange?.(totalPages)를 지우면 화면에서는 어떤 증상이 나타나나요?
  4. 단위 테스트에서 result.current.nextPage()를 호출할 때 왜 act(() => ...)로 감싸야 하나요?
셀프 체크 정답
  1. 각자 따로 가집니다. Custom Hook은 상태 값 자체를 공유하는 것이 아니라 상태 로직을 공유하므로, 호출할 때마다 각 컴포넌트 인스턴스의 독립된 Fiber 슬롯에 별개의 useState가 생성됩니다.
  2. applyFilter가 setKeyword와 setCategory를 먼저 새 값으로 바꿔 두므로, useEffect 안의 if (keyword !== urlKeyword || category !== urlCategory) 조건이 거짓이 되어 onFilterChange를 중복 호출하지 않습니다.
  3. 마지막 페이지의 유일한 글을 지우면 페이지 표시는 보정되지만(예: 2 / 2 → 1 / 1), 보정된 페이지의 데이터를 다시 불러오지 않아 테이블에는 빈 결과가 그대로 남습니다.
  4. nextPage()는 내부적으로 setCurrentPage를 호출해 리렌더링을 예약합니다. act()로 감싸야 React의 상태 배치와 useEffect 실행이 모두 완료된 후의 최신 값이 result.current에 반영됩니다.

다음 챕터로 이어지는 질문 ​

이제 BoardApp.tsx는 비즈니스 로직을 Custom Hook에 위임하고 날렵해졌습니다.
다음으로 사우들의 요구사항인 **"댓글과 대댓글(답글의 답글) 기능"**을 BoardApp.tsx에 추가해야 합니다.

그런데 댓글은 깊이가 3단계, 4단계까지 계속 깊어질 수 있는 트리(Tree) 구조입니다.
저 4단계 깊숙한 대대댓글에서 [삭제]나 [답글] 버튼을 눌렀을 때, 중간의 모든 댓글 컴포넌트를 거치지 않고 BoardApp.tsx에 바로 알리려면 어떻게 해야 할까요?

이어지는 Chapter 06에서는 재귀 컴포넌트와 **React 19 Context API (<Context> & use())**를 통한 의존성 주입(DI)으로 이 문제를 우아하게 풀어보겠습니다.

Moon HomeLab Engineering Knowledge Hub