Skip to content

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

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


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

개념한 줄 요약설명 위치
파생 상태 실전 (totalPages)totalCount와 pageSize로부터 자동 계산되는 전체 페이지 수4.1 🆕 박스 (기초는 2장 2.4)
onKeyDown (e.key === 'Enter')검색창에서 Enter 키를 눌렀을 때만 검색 실행하기4.1 🆕 박스
axios params로 다중 조건 요청page, size, category, keyword를 쿼리스트링으로 조립4.1 🆕 박스, 3장 3.5
SRP (단일 책임 원칙), DRY (중복 금지)하나의 컴포넌트가 너무 많은 변경 이유를 가질 때 벌어지는 문제4.2
UI 컴포넌트 분할 (value + onChange Props)마크업을 하위 컴포넌트로 분리하고 단방향 데이터 흐름 연결하기4.3 [기초 다지기] 박스
Custom Hook (useXxx)상태를 가진 비즈니스 로직을 함수로 도려내기5장에서 자세히

TIP

🏁 이 장의 시작 파일
frontend/src/BoardApp.tsx가 snapshots/BoardApp.ch03.tsx와 같은 상태에서 시작합니다(1.5장 실습 내용이 더 있어도 괜찮습니다). 이 장에서는 4.1의 코드 4-1로 BoardApp.tsx 파일 전체를 덮어쓰고 시작합니다. 기존 내용에 덧붙이는 것이 아니라 전체 선택 후 교체입니다. 3장 상태는 언제든 스냅샷에서 다시 가져올 수 있으므로 따로 백업하지 않아도 됩니다. 이 장에서 손대는 파일: frontend/src/BoardApp.tsx


4.1 관심사가 한데 몰린 BoardApp.tsx ​

1장에서 jQuery 레거시를 걷어내고 만든 BoardApp.tsx는 처음엔 150줄 남짓의 단일 파일이었습니다.
사내 사용자들의 요구사항이 이어지면서 3장의 상세 보기/실시간 검색에 이어 다음 기능들이 추가되었다고 가정해 봅시다:

  1. 페이지네이션: 현재 페이지, 페이지 크기, 전체 페이지 수 계산, [이전]/[다음] 버튼 제어
  2. 다중 검색 필터: 키워드 입력, 카테고리(DEV, NOTICE, FREE) 드롭다운, 검색 초기화
  3. 폼 유효성 검사: 제목 최소 2자, 본문 최소 10자 검증 및 에러 메시지 노출
  4. 서버 통신과 로딩 제어: 목록 조회, 등록, 삭제 시 파라미터 조합과 재조회

현업에서는 여기에 모달, 권한 분기, 첨부파일, 댓글까지 한 파일에 계속 더해지면서 수백~수천 줄짜리 **비대한 컴포넌트(Fat Component)**가 되기 쉽습니다. 에디터 세로 스크롤바가 손톱만 해지고, '이 변수가 아까 그 변수였나?' 휠을 미친 듯이 굴리다 퇴근 시간이 됩니다. 이때 옆 팀 동료가 다가와 *"그 게시판 소스 복붙해서 공지사항 화면 좀 만들게요~"*라고 속삭이는 순간, 사내에 똑같이 생긴 괴물 컴포넌트 10개가 복제되는 재앙이 시작됩니다. 문제를 또렷하게 보기 위해, 페이징·검색 필터·폼 검증이 한 파일에 섞인 버전부터 살펴봅시다.

NOTE

코드 4-1을 읽기 전에: 1.5장·3장 기능 일부를 의도적으로 뺐습니다
코드 4-1에는 3장의 **상세 보기(currentPost)**와 실시간 검색(디바운스), 1.5장의 번호 열·행 선택 같은 UI가 없고, 검색은 [검색] 버튼/Enter 방식입니다. 관심사 네 가지(목록·페이지·검색·폼)만 남겨야 5장에서 쪼갰을 때 무엇이 어디로 옮겨 가는지 비교하기 쉽기 때문입니다. 덮어쓴 뒤 기능이 줄고 화면 꾸밈이 수수해져도 정상이며, 이후 장은 이 코드를 기준으로 이어집니다.
또한 이제 페이지 번호를 서버에 보내기 시작합니다. 백엔드 PostController는 **1부터 시작하는 page**를 받아 내부에서 Spring Data의 0부터 시작하는 PageRequest.of(page - 1, size)로 변환하므로, 프론트엔드는 사람이 읽는 페이지 번호(1, 2, 3...)를 그대로 보내면 됩니다.

[코드 4-1] 관심사가 섞인 단일 컴포넌트 (frontend/src/BoardApp.tsx) ​

tsx
// frontend/src/BoardApp.tsx (코드 4-1: 4가지 관심사가 한 파일에 뒤엉킨 Fat Component)
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import './BoardApp.css';

// --- [관심사 1: 게시글 도메인 타입] ---
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 [isLoading, setIsLoading] = useState(false);
  const [isSubmitting, setIsSubmitting] = useState(false);

  // --- [관심사 2: 페이지네이션 상태 및 계산 로직] ---
  const [currentPage, setCurrentPage] = useState(1);
  const [pageSize] = useState(10);
  const [totalCount, setTotalCount] = useState(0);
  const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));

  // --- [관심사 3: 검색 및 카테고리 필터링 상태] ---
  const [selectedCategory, setSelectedCategory] = useState('ALL');
  const [searchKeyword, setSearchKeyword] = useState('');

  // --- [관심사 4: 등록 폼 상태 및 유효성 검증 로직] ---
  const [form, setForm] = useState({ title: '', author: '', content: '' });
  const [errors, setErrors] = useState({ title: '', content: '' });

  function validateForm(): boolean {
    const titleError =
      form.title.trim().length < 2 ? '제목은 2자 이상이어야 합니다.' : '';
    const contentError =
      form.content.trim().length < 10 ? '내용은 10자 이상이어야 합니다.' : '';
    setErrors({ title: titleError, content: contentError });
    return !titleError && !contentError;
  }

  // --- [통합 서버 로딩 함수: 모든 관심사의 변수를 한꺼번에 읽는다!] ---
  async function loadPosts(
    targetPage = currentPage,
    targetCategory = selectedCategory,
    targetKeyword = searchKeyword
  ) {
    setIsLoading(true);
    try {
      const res = await axios.get<{ items: Post[]; total: number }>('/api/posts', {
        params: {
          page: targetPage,
          size: pageSize,
          category: targetCategory,
          keyword: targetKeyword
        }
      });
      setPosts(res.data.items || []);
      setTotalCount(res.data.total ?? 0);
    } catch (err) {
      alert('게시글 목록을 불러오지 못했습니다.');
    } finally {
      setIsLoading(false);
    }
  }

  function changePage(page: number) {
    if (page < 1 || page > totalPages) return;
    setCurrentPage(page);
    loadPosts(page, selectedCategory, searchKeyword);
  }

  function handleSearch() {
    setCurrentPage(1);
    loadPosts(1, selectedCategory, searchKeyword);
  }

  function handleCategoryChange(nextCategory: string) {
    setSelectedCategory(nextCategory);
    setCurrentPage(1);
    loadPosts(1, nextCategory, searchKeyword);
  }

  function handleResetFilter() {
    setSelectedCategory('ALL');
    setSearchKeyword('');
    setCurrentPage(1);
    loadPosts(1, 'ALL', '');
  }

  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 (!validateForm()) return;
    setIsSubmitting(true);
    try {
      await axios.post('/api/posts', {
        ...form,
        category: 'DEV'
      });
      setForm({ title: '', author: '', content: '' });
      setErrors({ title: '', content: '' });
      await loadPosts();
    } catch (err) {
      alert('등록 실패');
    } finally {
      setIsSubmitting(false);
    }
  }

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

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

      {/* 1. 검색 및 필터 UI */}
      <div className="filter-bar">
        <select
          value={selectedCategory}
          onChange={(e) => handleCategoryChange(e.target.value)}
        >
          <option value="ALL">전체</option>
          <option value="DEV">개발</option>
          <option value="NOTICE">공지</option>
          <option value="FREE">자유</option>
        </select>
        <input
          value={searchKeyword}
          onChange={(e) => setSearchKeyword(e.target.value)}
          placeholder="검색어 입력"
          onKeyDown={(e) => {
            if (e.key === 'Enter') handleSearch();
          }}
        />
        <button type="button" onClick={handleSearch}>
          검색
        </button>
        <button type="button" onClick={handleResetFilter}>
          초기화
        </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={currentPage <= 1}
          onClick={() => changePage(currentPage - 1)}
        >
          이전
        </button>
        <span>
          {currentPage} / {totalPages}
        </span>
        <button
          disabled={currentPage >= totalPages}
          onClick={() => changePage(currentPage + 1)}
        >
          다음
        </button>
      </div>
    </div>
  );
}

🆕 처음 보는 문법: 코드 4-1에서 처음 나온 것들

  • const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));: 전체 건수와 페이지 크기로 전체 페이지 수를 계산하는 파생 상태입니다(2장 2.4). Math.ceil은 올림(25건 / 10 = 2.5 → 3페이지), Math.max(1, …)은 글이 0건이어도 최소 1페이지로 표시하기 위한 장치입니다.
  • 왜 loadPosts(1, nextCategory, searchKeyword)처럼 인자를 직접 넘겼나요?: 2장 2.3에서 배웠듯 setCurrentPage(1)을 호출한 바로 다음 줄에서 currentPage 상수를 읽으면 아직 이전 페이지 번호(2나 3)가 들어 있습니다! 그래서 한 파일 안에 상태가 다 섞여 있을 때는 방금 바꾼 값을 함수 인자로 일일이 전달해야 하는 번거로움이 생깁니다.
  • onKeyDown={(e) => { if (e.key === 'Enter') handleSearch(); &#125;&#125;: 검색창에서 Enter 키를 눌렀을 때만 handleSearch를 실행합니다.

💡 [확인하기]: 덮어쓴 뒤 npm run dev로 화면을 열면 목록과 검색, 등록, 삭제가 동작합니다. 샘플 글이 7건이라 페이지 표시가 1 / 1이고 [이전]/[다음]이 비활성인 것이 정상입니다.

💡 [연습]: 아래 이름들을 관심사 4개(목록·페이지·검색·폼) 중 어디에 속하는지 분류해 보십시오. 그리고 두 관심사 이상에 걸쳐 있는 것을 찾아보십시오.
posts, currentPage, totalPages, changePage, searchKeyword, handleSearch, handleResetFilter, form, errors, validateForm, isSubmitting, loadPosts, handleDelete, handleSubmit

연습 정답
  • 목록: posts, handleDelete, loadPosts(목록을 채움)
  • 페이지: currentPage, totalPages, changePage, JSX의 .pagination
  • 검색: searchKeyword, selectedCategory, handleSearch, handleCategoryChange, handleResetFilter, JSX의 .filter-bar
  • 폼: form, errors, validateForm, isSubmitting, JSX의 .form-box
  • 여러 관심사에 걸친 것: loadPosts는 페이지(currentPage, pageSize)와 검색(selectedCategory, searchKeyword)을 모두 읽습니다. handleSearch/handleResetFilter는 검색 함수인데 currentPage를 1로 바꾸고, handleSubmit과 handleDelete는 끝에 loadPosts()를 부릅니다. 이렇게 관심사끼리 서로의 변수를 직접 만지는 지점이 5장에서 **Custom Hook과 콜백(onPageChange, onFilterChange)**으로 깔끔하게 분리되는 곳입니다.

4.2 Fat Component가 만드는 두 가지 문제 ​

코드 4-1은 겉보기에는 아무 문제 없이 잘 돌아갑니다.
하지만 기능이 늘어날수록 아래 두 가지 문제가 커집니다:

1) 단일 책임 원칙(SRP)이 무너진다 ​

BoardApp.tsx는 지금 혼자서 너무 많은 책임을 지고 있습니다:

  • UI 화면을 그리고 이벤트를 바인딩하는 책임
  • 페이지 번호를 계산하고 범위를 제어하는 알고리즘 책임
  • 검색 쿼리를 조합하고 필터를 리셋하는 책임
  • 폼 입력값의 글자 수를 세고 검증하는 유효성 검사 책임
  • 서버와 HTTP 통신을 수행하는 네트워크 책임

이 컴포넌트는 "페이지네이션 버튼 스타일을 바꿀 때", "유효성 검사 글자 수 제한이 바뀔 때", "검색 필터에 날짜가 추가될 때", "서버 API 주소가 바뀔 때"마다 수정되어야 합니다.

2) 복사-붙여넣기(Copy & Paste)와 테스트 불가능의 늪 ​

옆 팀 동료가 **사내 공지사항 게시판(NoticeBoard.tsx)**을 만들어야 한다고 가정해 봅시다.
공지사항 게시판에도 똑같이 페이징, 검색, 폼 검증이 필요합니다.

동료는 어떻게 할까요?
BoardApp.tsx의 코드를 통째로 긁어서 NoticeBoard.tsx에 그대로 붙여넣습니다.
그 순간부터 페이징 계산 버그가 하나 발견되면, 두 파일, 세 파일을 일일이 찾아다니며 수정해야 하는 **코드 중복의 악순환(DRY 원칙 위반)**이 시작됩니다. 게다가 페이지 계산 로직 하나만 단위 테스트하고 싶어도 거대한 BoardApp 전체와 Axios까지 통째로 마운트해야만 테스트할 수 있습니다.


4.3 탈출구: UI 분할(Presentational Component) vs 상태 로직 분할(Custom Hook) ​

어떻게 해야 이 비대한 BoardApp.tsx를 구출할 수 있을까요?
프론트엔드 리팩터링에는 두 가지 축이 있습니다:

1) UI 분할 (Presentational Component + Controlled Props 패턴) ​

반복되거나 덩치가 큰 마크업(예: 검색 바, 등록 폼, 테이블)을 하위 컴포넌트로 분리합니다. (아래 SearchBar.tsx는 부모-자식 양방향 Props 연결 문법을 보여 주기 위한 예시이며, 실습 프로젝트에 만들지 않아도 됩니다. 실제 UI 분할은 8장에서 제네릭 공통 테이블 <BoardTable<T>>로 본격 실습합니다.)

tsx
// 자식 컴포넌트 (SearchBar.tsx) 예시: Controlled Props + Callback 패턴
interface SearchBarProps {
  keyword: string;
  category: string;
  onKeywordChange: (nextKeyword: string) => void;
  onCategoryChange: (nextCategory: string) => void;
  onSearch: () => void;
  onReset: () => void;
}

export function SearchBar({
  keyword,
  category,
  onKeywordChange,
  onCategoryChange,
  onSearch,
  onReset
}: SearchBarProps) {
  return (
    <div className="filter-bar">
      <select value={category} onChange={(e) => onCategoryChange(e.target.value)}>
        <option value="ALL">전체</option>
        <option value="DEV">개발</option>
        <option value="NOTICE">공지</option>
        <option value="FREE">자유</option>
      </select>
      <input
        value={keyword}
        onChange={(e) => onKeywordChange(e.target.value)}
        onKeyDown={(e) => e.key === 'Enter' && onSearch()}
      />
      <button type="button" onClick={onSearch}>검색</button>
      <button type="button" onClick={onReset}>초기화</button>
    </div>
  );
}

📘 [기초 다지기] React의 부모-자식 양방향 연결 표준: value + onChange Props

1.5장과 3장에서 봤듯 Props는 읽기 전용이므로, 자식이 부모의 상태를 바꾸려면 부모가 내려준 **콜백 함수(onKeywordChange, onCategoryChange)**를 호출해야 합니다.

tsx
// 부모 (BoardApp.tsx)에서의 사용 모습:
<SearchBar
  keyword={searchKeyword}
  category={selectedCategory}
  onKeywordChange={setSearchKeyword}
  onCategoryChange={handleCategoryChange}
  onSearch={handleSearch}
  onReset={handleResetFilter}
/>

네이티브 <input value={...} onChange={...} />와 똑같은 철학입니다. 실제 상태(searchKeyword)의 주인은 여전히 부모이고, 자식은 값을 받아 그리고 이벤트를 위로 올려보내기만 하므로 데이터 흐름이 한눈에 추적됩니다.

2) 상태를 가진 비즈니스 로직 분할 (Stateful Logic → Custom Hook) ​

UI 마크업만 쪼갠다고 해서 컴포넌트 내부의 복잡한 상태 계산이 사라지지는 않습니다. BoardApp.tsx에서 재사용 가능한 비즈니스 상태와 알고리즘 덩어리들을 독립된 TypeScript 커스텀 훅(Custom Hook) 함수로 도려내야(Extract) 합니다:

  • 페이지네이션 상태와 범위 계산 로직 → usePagination() 훅으로 분리
  • 검색 필터와 URL 쿼리 동기화 로직 → usePostFilter() 훅으로 분리
  • 폼 입력과 유효성 검증 로직 → useFormValidation() 훅으로 분리

5장에서 이 두 번째 축(Custom Hook)을 본격 실습합니다. 그 전에 usePostFilter가 쓸 URL 쿼리스트링과 React Router 기초를 4.5장에서 먼저 다룹니다.


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

미션: 코드 4-1의 validateForm에 '작성자(author) 필수 입력 검증' 추가하기 ​

현재 코드 4-1의 validateForm()은 제목(title) 2자 이상, 내용(content) 10자 이상만 검사하고 작성자(author)가 비어 있는지는 검사하지 않습니다.

  1. errors 상태 객체에 author: '' 필드를 추가하십시오.
  2. validateForm()에서 form.author.trim().length === 0이면 '작성자를 입력해 주세요.' 에러 메시지를 세팅하고 false를 반환하도록 수정하십시오.
  3. 작성자 <input> 바로 아래에 {errors.author && <span className="error">{errors.author}</span>}을 표시해 보십시오.
💡 미션 정답 코드 확인하기
tsx
// 1. errors 상태에 author 추가
const [errors, setErrors] = useState({ title: '', author: '', content: '' });

// 2. validateForm 수정
function validateForm(): boolean {
  const titleError =
    form.title.trim().length < 2 ? '제목은 2자 이상이어야 합니다.' : '';
  const authorError =
    form.author.trim().length === 0 ? '작성자를 입력해 주세요.' : '';
  const contentError =
    form.content.trim().length < 10 ? '내용은 10자 이상이어야 합니다.' : '';

  setErrors({ title: titleError, author: authorError, content: contentError });
  return !titleError && !authorError && !contentError;
}

// 3. JSX 작성자 input 아래에 에러 메시지 렌더링
<input
  value={form.author}
  onChange={(e) => setForm({ ...form, author: e.target.value })}
  placeholder="작성자"
/>
{errors.author && <span className="error">{errors.author}</span>}

이렇게 검증 규칙 하나를 추가할 때마다 BoardApp.tsx의 상태와 함수를 직접 뜯어고쳐야 한다는 사실이 바로 Fat Component의 아픔입니다. 5장에서 이 검증 로직을 useFormValidation.ts로 깔끔하게 독립시킵니다! (미션 확인 후에는 5장 실습을 위해 코드 4-1 원래 상태로 두셔도 됩니다.)


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

핵심 3줄 요약

  1. 한 컴포넌트에 목록·페이지·검색·폼 관심사가 섞이면, 어느 하나를 고칠 때마다 같은 파일을 건드려야 합니다(단일 책임 원칙 SRP 위반).
  2. 같은 로직이 필요한 다른 화면(NoticeBoard.tsx)이 생기면 복사-붙여넣기가 시작되고, 버그도 복사본 수만큼 찾아가며 고쳐야 합니다(중복 금지 DRY 위반).
  3. 탈출구는 두 축입니다: UI 마크업은 하위 컴포넌트(value + onChange Props)로, 상태를 가진 비즈니스 로직은 **Custom Hook(useXxx)**으로 도려냅니다.

바뀐 파일

파일무엇이 바뀌었나
frontend/src/BoardApp.tsx코드 4-1로 전체 교체(페이징·카테고리 필터·폼 검증 추가, 상세 보기·실시간 검색 제거)

자주 하는 실수 (Troubleshooting)

증상원인해결
Identifier 'useState' has already been declared 같은 컴파일 에러코드 4-1을 기존 파일 아래에 덧붙임파일 전체를 선택해 코드 4-1로 교체
검색어를 쳐도 목록이 바로 안 바뀜4-1은 실시간 검색이 아니라 [검색]/Enter 방식임Enter를 누르거나 [검색] 버튼 클릭
상세 보기·행 선택이 사라짐, 화면이 수수해짐코드 4-1이 관심사 분리 비교를 위해 기능을 덜어냄정상. 4.1 NOTE 참고

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

  1. 코드 4-1의 handleSearch는 왜 loadPosts를 부를 때 currentPage를 1로 되돌릴까요?
  2. 글이 0건이면 totalPages는 얼마로 표시되고, 그 이유는 어느 코드 때문인가요?
  3. 옆 팀이 코드 4-1을 복사해 NoticeBoard.tsx를 만든 뒤, 페이지 계산 버그를 BoardApp.tsx에서만 고치면 어떻게 될까요?
  4. 자식 컴포넌트 SearchBar에서 검색어 입력창에 타이핑이 일어났을 때, 실제 상태(searchKeyword)를 변경하는 주체는 자식인가요, 부모인가요?
셀프 체크 정답
  1. 3페이지를 보다가 검색하면 검색 결과는 1페이지 분량뿐일 수 있습니다. 페이지를 3으로 그대로 두면 서버에 결과가 없는 3페이지를 요청해 빈 목록이 나오므로 반드시 1페이지로 리셋해야 합니다.
  2. 1입니다. Math.ceil(0 / 10)은 0이지만 Math.max(1, …)이 최소 1페이지로 보정해 줍니다.
  3. NoticeBoard.tsx에는 버그가 그대로 남습니다. 복사본마다 따로 찾아 고쳐야 하는 것이 DRY 위반의 비용입니다.
  4. 부모(BoardApp.tsx)입니다. 자식은 onKeywordChange(e.target.value) 콜백을 호출해 알릴 뿐이고, 부모가 내려준 setSearchKeyword가 실행되어 부모의 상태를 바꿉니다.

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

코드 4-1에서 "React"로 검색한 뒤 브라우저 새로고침(F5)을 누르면 검색 조건이 허무하게 사라집니다. 동료에게 검색 결과를 링크로 보내 줄 수도 없습니다.

검색 조건을 **URL(?keyword=React&category=DEV)**에 담아 두면 새로고침·링크 공유·[뒤로 가기]에도 상태가 살아남습니다. 이어지는 Chapter 04.5에서는 React Router로 URL을 읽고 바꾸는 기초를 익히고, Chapter 05에서 그 지식으로 usePostFilter 커스텀 훅을 만듭니다.

Moon HomeLab Engineering Knowledge Hub