다크 모드
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장의 상세 보기/실시간 검색에 이어 다음 기능들이 추가되었다고 가정해 봅시다:
- 페이지네이션: 현재 페이지, 페이지 크기, 전체 페이지 수 계산, [이전]/[다음] 버튼 제어
- 다중 검색 필터: 키워드 입력, 카테고리(
DEV,NOTICE,FREE) 드롭다운, 검색 초기화 - 폼 유효성 검사: 제목 최소 2자, 본문 최소 10자 검증 및 에러 메시지 노출
- 서버 통신과 로딩 제어: 목록 조회, 등록, 삭제 시 파라미터 조합과 재조회
현업에서는 여기에 모달, 권한 분기, 첨부파일, 댓글까지 한 파일에 계속 더해지면서 수백~수천 줄짜리 **비대한 컴포넌트(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(); }}: 검색창에서 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)가 비어 있는지는 검사하지 않습니다.
errors상태 객체에author: ''필드를 추가하십시오.validateForm()에서form.author.trim().length === 0이면'작성자를 입력해 주세요.'에러 메시지를 세팅하고false를 반환하도록 수정하십시오.- 작성자
<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줄 요약
- 한 컴포넌트에 목록·페이지·검색·폼 관심사가 섞이면, 어느 하나를 고칠 때마다 같은 파일을 건드려야 합니다(단일 책임 원칙 SRP 위반).
- 같은 로직이 필요한 다른 화면(
NoticeBoard.tsx)이 생기면 복사-붙여넣기가 시작되고, 버그도 복사본 수만큼 찾아가며 고쳐야 합니다(중복 금지 DRY 위반). - 탈출구는 두 축입니다: UI 마크업은 하위 컴포넌트(
value+onChangeProps)로, 상태를 가진 비즈니스 로직은 **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 참고 |
셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.
- 코드 4-1의
handleSearch는 왜loadPosts를 부를 때currentPage를1로 되돌릴까요? - 글이 0건이면
totalPages는 얼마로 표시되고, 그 이유는 어느 코드 때문인가요? - 옆 팀이 코드 4-1을 복사해
NoticeBoard.tsx를 만든 뒤, 페이지 계산 버그를BoardApp.tsx에서만 고치면 어떻게 될까요? - 자식 컴포넌트
SearchBar에서 검색어 입력창에 타이핑이 일어났을 때, 실제 상태(searchKeyword)를 변경하는 주체는 자식인가요, 부모인가요?
셀프 체크 정답
- 3페이지를 보다가 검색하면 검색 결과는 1페이지 분량뿐일 수 있습니다. 페이지를 3으로 그대로 두면 서버에 결과가 없는 3페이지를 요청해 빈 목록이 나오므로 반드시 1페이지로 리셋해야 합니다.
1입니다.Math.ceil(0 / 10)은0이지만Math.max(1, …)이 최소 1페이지로 보정해 줍니다.NoticeBoard.tsx에는 버그가 그대로 남습니다. 복사본마다 따로 찾아 고쳐야 하는 것이 DRY 위반의 비용입니다.- 부모(
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 커스텀 훅을 만듭니다.