다크 모드
Chapter 01.5. React 19.3 기본기 실습: 게시판으로 필수 훅과 문법 익히기
"1장에서 jQuery 게시판을 React로 옮겼다. 그런데 막상 '입력창에 포커스를 주고 싶다', '선택한 행만 색칠하고 싶다', '삭제 버튼을 누르면 행 클릭까지 같이 된다'는 순간 손이 멈춘다. jQuery로 매일 하던 일을 React 19.3으로는 어떻게 하는가?"
📋 이 장에서 처음 만나는 개념 — 모르는 문법이 나오면 0장 준비운동에서 찾아볼 수 있습니다.
| 개념 | 한 줄 요약 | 설명 위치 |
|---|---|---|
useRef와 React 19 ref as a Prop | 실제 DOM 요소를 스크립트에서 직접 꺼내 쓰기 (forwardRef 불필요) | 1.5.1 |
비동기 배치와 flushSync | 상태 변경이 실제 DOM에 즉시 반영되도록 강제하기 (또는 useEffect 후처리) | 1.5.2 (원리는 2장 2.3) |
동적 className과 style 객체 | 조건에 따라 클래스를 조합하고 인라인 스타일 붙이기 | 1.5.3 |
합성 이벤트(SyntheticEvent)와 e.stopPropagation() | 이벤트 버블링 막기 / 키보드·마우스 보조키 처리 | 1.5.4 |
라디오, 체크박스, 셀렉트, 그리고 React 19 useActionState | 다양한 폼 입력 제어와 React 19 최신 폼 액션 훅 | 1.5.5 |
map의 인덱스, filter 후 map 체이닝 | 리스트 렌더링 규칙과 조건부 필터링 | 1.5.6 (useMemo 대안은 2장 2.4) |
컴포넌트 생명주기와 useEffect 클린업 | 마운트·업데이트·언마운트 타이밍과 리소스 정리 | 1.5.7 (실전은 3장, 13장) |
| 컴포넌트 나누기, Props와 콜백 전달 (미리 보기) | 부모→자식 데이터 전달, 자식→부모 이벤트 콜백 호출 | 1.5.10 (본격 실습은 4장, 8장) |
TIP
🏁 이 장의 시작 파일frontend/src/BoardApp.tsx가 snapshots/BoardApp.ch01.tsx와 같은 상태에서 시작합니다(막혔다면 그 파일을 복사해 오십시오). 끝 상태는 snapshots/BoardApp.ch01-5.tsx입니다. 이 장에서 손대는 파일: frontend/src/BoardApp.tsx, frontend/src/App.tsx(1.5.7 실험에서 잠깐 고쳤다가 되돌림)
NOTE
이 장은 React를 처음 쓰는 분을 위한 실습 장입니다. React 기본 문법이 이미 익숙하다면 가볍게 읽고 2장으로 가도 됩니다. 여기서 추가하는 기능은 3장 이후 실습과 충돌하지 않습니다.
1.5.1 useRef: $('#title').focus()는 React에서 어떻게 하나?
jQuery에서는 $('#title')로 언제든 DOM을 찾았습니다. React에서는 화면을 React가 그리므로 document.querySelector로 직접 찾는 대신, JSX 태그에 이름표(ref)를 달아 두고 스크립트에서 그 요소를 받아 씁니다.
요구사항: 게시판을 열면 제목 입력창에 커서가 바로 가 있어야 하고, 글을 등록한 뒤에도 다시 제목 입력창으로 돌아와야 한다.
🏷️ 비유: 옷걸이에 건 이름표(
useRef)
방 안의 수많은 옷 중에서 내 코트를 찾을 때 매번 옷장을 뒤지는 대신(document.querySelector), 코트에 빨간 이름표(ref={titleInputRef})를 달아두고 옷장 문을 열자마자 이름표를 쏙 잡아당기는 것과 같습니다.
[Step 1] 스크립트에서 useRef 상자 만들고 마운트 시 포커스 주기
tsx
// 기존 import 줄에 useRef를 추가합니다
import React, { useState, useEffect, useRef } from 'react';
// BoardApp 컴포넌트 안, 다른 상태 선언 아래에 추가
// JSX의 ref={titleInputRef} 요소가 화면에 붙은 뒤 .current 속성에 자동으로 담긴다
const titleInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
titleInputRef.current?.focus(); // = $('#title').focus()
loadPosts();
}, []);[Step 2] JSX <input>에 이름표(ref) 연결하기
1장 등록 폼의 제목 <input>에 ref={titleInputRef}를 추가합니다.
tsx
<input
ref={titleInputRef}
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
type="text"
placeholder="제목"
required
/>💡 [확인하기]: 브라우저를 새로고침하면 제목 입력창에 커서가 깜빡이고 있습니다.
왜 이렇게 동작하는가?
- 처음에는
null입니다:BoardApp()함수가 실행되는(렌더링되는) 시점에는 아직 브라우저 DOM이 만들어지기 전입니다(2장 2.2). 실제 DOM 요소는 커밋이 끝난 뒤useEffect시점에야.current에 들어오므로, 컴포넌트 본문 최상위에서titleInputRef.current.focus()를 부르면null이라 에러가 납니다.?.를 붙이는 이유입니다. - 조건부 렌더링(
? :,&&) 안의 요소는 다시null이 될 수 있습니다: 조건이 거짓이 되어 요소가 DOM에서 사라지면ref.current도 자동으로null로 돌아갑니다. .current를 바꿔도 리렌더링되지 않습니다:useState와 달리useRef의.current속성은 읽거나 바꿔도 컴포넌트 함수를 다시 실행하지 않습니다.
🆕 처음 보는 문법: React 19의 ref as a Prop (더 이상 forwardRef가 필요 없다!)
React 18까지는 내가 만든 자식 컴포넌트(<MyInput ref={titleInputRef} />)에 ref를 넘기려면 자식 컴포넌트를 React.forwardRef((props, ref) => ...)라는 복잡한 고차 함수로 감싸야 했습니다.
React 19부터는 ref가 일반 prop처럼 바로 전달됩니다!
tsx
// React 19.3 표준: 일반 prop으로 ref를 바로 구조 분해해서 받는다!
function CustomTitleInput({ ref, value, onChange }: {
ref?: React.Ref<HTMLInputElement>;
value: string;
onChange: (v: string) => void;
}) {
return <input ref={ref} value={value} onChange={(e) => onChange(e.target.value)} />;
}또한 React 19에서는 <input ref={(node) => { ...; return () => cleanup(); }} />처럼 ref 콜백에서 정리(Cleanup) 함수를 반환할 수도 있습니다.
WARNING
DOM 직접 조작은 "React 상태로 못 하는 일"에만 쓰십시오. 포커스, 스크롤 위치, 요소 크기 측정, 외부 라이브러리(차트, 에디터) 연결이 대표적입니다. 반대로 titleInputRef.current.value = ''로 입력값을 지우거나 classList.add()로 클래스를 붙이는 것은 상태(form.title, className)로 해야 합니다.
1.5.2 비동기 배치와 flushSync: 상태를 바꿨는데 DOM이 아직 그대로다?
요구사항: 글을 등록하면 새 글이 있는 첫 번째 행으로 화면이 부드럽게 스크롤되어야 하고, 제목 입력창으로 포커스가 돌아와야 한다.
jQuery 습관대로라면 "setPosts로 배열에 넣었으니 바로 다음 줄에서 첫 번째 <tr>을 찾아 스크롤"하면 될 것 같습니다. 직접 실험해 봅시다.
[실험] 상태 변경 직후에 DOM 세어 보기
1장 handleSubmit의 setPosts((prevPosts) => [res.data, ...prevPosts]); 바로 아래에 다음 두 줄을 잠시 넣고 글을 등록해 보십시오.
tsx
setPosts((prevPosts) => [res.data, ...prevPosts]);
console.log('이전 배열 길이 + 1:', posts.length + 1);
console.log('화면의 행 수:', document.querySelectorAll('.data-table tbody tr').length);콘솔에는 화면의 행 수가 새 배열 길이보다 1 작게 찍힙니다. setPosts를 불렀지만 화면(DOM)은 아직 바뀌지 않은 것입니다!
왜 이런 일이 생기는가?
2장 2.3에서 자세히 설명하듯, React는 setPosts나 setForm이 호출될 때마다 즉시 화면을 다시 그리지 않습니다. 한 이벤트 핸들러 안에서 상태를 여러 번 바꾸더라도 변경 요청을 장바구니에 모아 두었다가(Automatic Batching), 현재 함수 실행이 끝난 직후 한 번에 가상 DOM을 비교하고 실제 DOM을 갱신합니다. 그래서 setPosts 바로 다음 줄에서는 아직 새 <tr>이 DOM에 없습니다.
이때 새 DOM이 만들어진 직후 스크롤을 실행하는 방법은 두 가지가 있습니다:
react-dom의flushSync()사용: "지금 당장 이 상태 변경을 DOM에 즉시 반영(커밋)해 달라!"고 강제하는 함수입니다. 이벤트 핸들러 안에서 방금 추가한 요소로 즉시 스크롤할 때 가장 직관적입니다.useEffect로 상태 감시:lastCreatedId상태가 바뀔 때useEffect안에서 스크롤하는 선언적 방식입니다.
📦 비유: 택배 기사님과 엘리베이터 (
Automatic BatchingvsflushSync)
배열에 글을 하나 넣었다고 해서 React가 즉시 브라우저 화면을 다시 그리지 않습니다. 택배 기사님이 1층에서 상자 하나 실을 때마다 엘리베이터를 10층까지 오르락내리락하지 않고, 카트에 짐을 다 모아 실은 뒤 한 번에 10층으로 올리는 것(Automatic Batching)과 같습니다.
그런데 상태를 바꾸자마자 바로 다음 줄에서 10층 복도(DOM)를 뒤지면? 기사님이 아직 1층에서 짐 싣는 중이라 복도에는 새 상자가 없습니다!flushSync(() => setPosts(...))는 "기사님, 이 상자는 급한 물건이니 지금 당장 10층에 올려다 주세요!" 하고 즉시 배송을 완료시키는 특급 주문입니다.
[Step] 스크롤과 재포커스 구현 (flushSync + tableBodyRef)
tsx
import React, { useState, useEffect, useRef } from 'react';
import { flushSync } from 'react-dom'; // 🌟 즉시 DOM 반영이 필요할 때 사용
// BoardApp 컴포넌트 내부에 tbody 참조용 ref 추가
const tableBodyRef = useRef<HTMLTableSectionElement>(null);
// handleSubmit 안의 성공 구간 수정
flushSync(() => {
setPosts((prevPosts) => [res.data, ...prevPosts]);
setForm({ title: '', author: '', content: '' });
});
// 🌟 flushSync 블록이 끝난 직후에는 이미 새 <tr>이 실제 DOM에 반영되어 있다!
tableBodyRef.current?.querySelector('tr')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
titleInputRef.current?.focus(); // 다음 글을 바로 쓸 수 있게 제목으로 커서 복귀JSX의 <tbody>에는 ref={tableBodyRef}를 답니다.
tsx
<tbody ref={tableBodyRef}>💡 [확인하기]: 새 글을 등록하면 테이블 첫 행으로 부드럽게 스크롤되면서 제목 입력창으로 커서가 즉시 돌아옵니다!
TIP
flushSync가 필요한 때 / 필요 없는 때
- 필요한 때: 상태를 바꾼 바로 다음 줄에서 새로 생긴 DOM의 스크롤 위치나 크기를 조작해야 할 때만 제한적으로 씁니다.
- 필요 없는 때: 단순히 화면에 값을 보여주기만 할 때. 그건 React의 자동 배치가 알아서 가장 빠르게 처리합니다.
flushSync를 남발하면 React의 배치 최적화가 깨지므로 꼭 필요한 DOM 측정·스크롤 지점에만 쓰십시오.
1.5.3 동적 className과 style: addClass / css() 대신 상태로 꾸미기
요구사항: ① 클릭한 행은 파란 배경으로 표시 ② 방금 등록한 글은 노란색으로 잠깐 반짝임 ③ 제목 앞에 카테고리별 색상 배지 표시.
jQuery라면 $tr.addClass('row-selected'), $tr.siblings().removeClass(...)를 직접 관리했을 것입니다. React에서는 **"어떤 조건일 때 이 클래스 문자열이 만들어진다"**를 선언만 합니다.
[Step 1] 상태 두 개 추가 및 Post 인터페이스에 category 추가
1장의 Post 인터페이스에 category: string;을 추가합니다(백엔드는 이미 category를 내려주고 있습니다).
tsx
export interface Post {
id: number;
title: string;
author: string;
content: string;
category: string; // ➕ 추가
createdAt: string;
}
// BoardApp 내부 상태 추가
const [selectedId, setSelectedId] = useState<number | null>(null); // 선택된 행의 게시글 id
const [lastCreatedId, setLastCreatedId] = useState<number | null>(null); // 방금 등록한 게시글 id
// handleSubmit의 flushSync 블록 안에 한 줄 추가:
setLastCreatedId(res.data.id);[Step 2] 템플릿 리터럴과 배열 .filter(Boolean).join(' ')로 동적 클래스 조합
tsx
<tr
key={post.id}
className={[
'data-row',
post.id === selectedId && 'row-selected',
post.id === lastCreatedId && 'row-new'
].filter(Boolean).join(' ')}
onClick={() => setSelectedId(post.id)}
>- 조건이 거짓이면
false가 되고,.filter(Boolean)이false를 걸러낸 뒤.join(' ')이 공백으로 이어 붙입니다. 예를 들어 선택된 행이면"data-row row-selected"문자열이 됩니다. selectedId가 바뀌면 이전 행의row-selected는 자동으로 빠지고 새 행에 붙습니다.removeClass를 챙길 필요가 없습니다. (실무에서는 이 패턴을 도와주는clsx같은 작은 유틸리티도 자주 씁니다.)
[Step 3] 카테고리 배지 렌더링
제목 칸(<td>)을 다음과 같이 바꿉니다:
tsx
<td>
<span className={`category-badge category-${(post.category || 'DEV').toLowerCase()}`}>
{post.category || 'DEV'}
</span>
<span className="post-title">{post.title}</span>
</td>[Step 4] frontend/src/BoardApp.css에 스타일 추가
css
.data-row { cursor: pointer; }
.row-selected { background: #eff6ff; }
.row-new { animation: flash 1.5s ease-out; }
@keyframes flash { from { background: #fef9c3; } to { background: transparent; } }
.category-badge { display: inline-block; font-size: 11px; font-weight: 700; padding: 2px 6px; border-radius: 4px; margin-right: 6px; }
.category-dev { background: #dbeafe; color: #1d4ed8; }
.category-notice { background: #fee2e2; color: #b91c1c; }
.category-free { background: #dcfce7; color: #15803d; }💡 [확인하기]: 행을 클릭하면 그 행만 파랗게 바뀌고, 다른 행을 누르면 색이 옮겨 갑니다. 새 글을 등록하면 첫 행이 노랗게 반짝였다가 사라집니다.
NOTE
style={{ ... }} 인라인 스타일은 언제 쓰나? 색상표처럼 정해진 모양은 className으로, **실시간으로 계산되는 수치(진행률 퍼센트, 댓글 깊이 들여쓰기, 가상 스크롤 좌표)**는 style 객체로 씁니다.
tsx
<div className="progress-bar" style={{ width: `${percent}%`, backgroundColor: barColor }} />CSS 속성명은 backgroundColor처럼 camelCase로 쓰며, 숫자만 쓰면(marginLeft: 20) React가 자동으로 '20px'로 처리해 줍니다. 6장의 댓글 들여쓰기와 13장의 가상 스크롤이 style 객체를 씁니다.
1.5.4 이벤트 더 알기: 삭제 버튼을 눌렀는데 행까지 선택된다?
1.5.3까지 적용한 뒤 삭제 버튼을 눌러 보십시오. 확인 창에서 [취소]를 눌러도 그 행이 파랗게 선택됩니다! 버튼의 클릭 이벤트가 부모인 <tr>까지 **전파(버블링, Event Bubbling)**되어 onClick={() => setSelectedId(post.id)}도 함께 실행되었기 때문입니다. jQuery에서 e.stopPropagation()을 쓰던 바로 그 상황입니다.
[Step] e.stopPropagation()으로 이벤트 전파 막기
tsx
<td>
<button
className="btn-delete"
onClick={(e) => {
e.stopPropagation(); // 🌟 부모 <tr>로 클릭 이벤트가 번지는 것을 차단!
handleDelete(post.id);
}}
>
삭제
</button>
</td>그리고 handleDelete 안에서 선택된 글이 삭제되었다면 선택 상태도 비워 줍니다:
tsx
if (selectedId === id) setSelectedId(null);이제 삭제 버튼을 눌러도 행 선택은 바뀌지 않습니다.
React 합성 이벤트(SyntheticEvent) 자주 쓰는 패턴 정리
React는 브라우저마다 미묘하게 다른 네이티브 이벤트를 감싸 모든 브라우저에서 똑같이 동작하는 합성 이벤트(React.SyntheticEvent) 객체를 핸들러 첫 번째 인자로 넘겨줍니다.
| 하고 싶은 일 | jQuery | React (e는 이벤트 객체) |
|---|---|---|
| 부모 요소로 전파(버블링) 막기 | e.stopPropagation() | e.stopPropagation() |
| 브라우저 기본 동작 막기 (폼 새로고침 등) | e.preventDefault() / return false | e.preventDefault() |
| 자식이 아니라 그 요소 자체 클릭만 허용 (모달 배경 닫기) | if (e.target === e.currentTarget) | if (e.target === e.currentTarget) closeModal(); |
| Enter / Esc 키 감지 | if (e.which === 13) | onKeyDown={(e) => { if (e.key === 'Enter') ... }} |
| Shift / Ctrl + 클릭 감지 | if (e.shiftKey) | onClick={(e) => { if (e.shiftKey) ... }} |
1.5.5 폼 입력의 모든 종류와 React 19 useActionState
1장에서는 텍스트 입력과 textarea만 다뤘습니다. 실무 폼에는 라디오, 체크박스, 셀렉트가 빠지지 않습니다.
[Step 1] 라디오 버튼: 카테고리 하나 고르기 (checked + onChange)
지금까지는 등록할 때 category: 'DEV'로 고정했습니다. 사용자가 직접 고르게 바꿔 봅시다.
tsx
const categoryOptions = [
{ value: 'DEV', label: '개발' },
{ value: 'NOTICE', label: '공지' },
{ value: 'FREE', label: '자유' }
];
const [form, setForm] = useState({
title: '',
author: '',
content: '',
category: 'DEV' // 🌟 기본 선택값
});
// handleSubmit의 payload: 고정값 대신 form에 들어 있는 category를 그대로 보낸다
const res = await axios.post<Post>('/api/posts', { ...form });JSX의 .input-row 바로 아래에 라디오 버튼 그룹을 추가합니다:
tsx
<div className="option-row">
{categoryOptions.map((option) => (
<label key={option.value}>
<input
type="radio"
name="category"
value={option.value}
checked={form.category === option.value}
onChange={(e) => setForm({ ...form, category: e.target.value })}
/>
{' '}{option.label}
</label>
))}
</div>checked={form.category === option.value}로 현재 선택 여부를 연결하고, onChange에서 선택된 e.target.value를 form.category에 넣습니다. jQuery의 $('input[name=category]:checked').val()이 필요 없습니다.
[Step 2] 체크박스(단일): 켜고 끄기 (checked + e.target.checked)
요구사항: "등록 후 작성자 유지"를 체크하면 글을 등록해도 작성자 칸이 지워지지 않아야 한다.
tsx
const [keepAuthor, setKeepAuthor] = useState<boolean>(false);
// handleSubmit의 폼 초기화 부분 수정:
setForm((prev) => ({
title: '',
content: '',
category: 'DEV',
author: keepAuthor ? prev.author : '' // 체크되어 있으면 작성자 유지!
}));.option-row 안의 라디오 버튼들 옆에 체크박스를 추가합니다:
tsx
<label className="keep-author">
<input
type="checkbox"
checked={keepAuthor}
onChange={(e) => setKeepAuthor(e.target.checked)}
/>
{' '}등록 후 작성자 유지
</label>⚠️ 체크박스는 문자열 값인
e.target.value가 아니라 불리언 값인 **e.target.checked**를 읽어야 합니다!
[Step 3] 셀렉트 박스와 Number(): 목록 개수 바꾸기
tsx
const [pageSize, setPageSize] = useState<number>(20);
// loadPosts 수정: 인자로 받은 size(없으면 현재 pageSize)를 쿼리 파라미터로 보낸다
async function loadPosts(targetSize = pageSize) {
setIsLoading(true);
try {
const res = await axios.get<{ items: Post[] }>('/api/posts', {
params: { size: targetSize }
});
setPosts(res.data.items || []);
} catch (err) {
alert('게시글 로딩 실패');
} finally {
setIsLoading(false);
}
}등록 <form> 아래, 로딩/테이블 위에 목록 도구 막대를 추가합니다:
tsx
<div className="list-toolbar">
<span>총 {posts.length}건 표시 중</span>
<select
value={pageSize}
onChange={(e) => {
const nextSize = Number(e.target.value); // HTML 입력값은 항상 문자열이므로 숫자로 변환!
setPageSize(nextSize);
loadPosts(nextSize);
}}
>
<option value={5}>5개씩</option>
<option value={10}>10개씩</option>
<option value={20}>20개씩</option>
</select>
</div>| 입력 종류 | 연결하는 값 프로퍼티 | onChange에서 읽는 곳 |
|---|---|---|
<input type="text">, <textarea> | value={문자열} | e.target.value |
<input type="checkbox"> (단일) | checked={불리언} | e.target.checked |
<input type="radio"> | checked={상태 === 옵션값} | e.target.value |
<select> | value={선택값} | e.target.value (숫자면 Number(...)) |
🆕 처음 보는 문법: React 19의 신무기 useActionState와 <form action> 미리 보기
지금까지 우리는 isSubmitting 상태를 직접 만들고 e.preventDefault()와 try / catch / finally를 손으로 작성했습니다.
React 19에서는 <form action={함수}>에 비동기 함수를 바로 넘길 수 있고, useActionState 훅이 제출 중 상태(isPending)와 응답 결과/에러 상태를 자동으로 관리해 줍니다!
tsx
import { useActionState } from 'react';
// [상태, 폼액션함수, 제출중여부] = useActionState(비동기액션함수, 초기상태)
const [actionError, submitAction, isPending] = useActionState(
async (_prevState: string | null, formData: FormData) => {
const title = String(formData.get('title') || '').trim();
const author = String(formData.get('author') || '').trim();
if (!title || !author) return '제목과 작성자를 모두 입력하세요.';
const res = await axios.post<Post>('/api/posts', { title, author, content: '...', category: 'DEV' });
setPosts((prev) => [res.data, ...prev]);
return null; // 성공 시 에러 없음(null) 반환
},
null
);
// JSX: onSubmit 대신 action에 바로 연결! (e.preventDefault 불필요, 제출 완료 시 폼 자동 리셋)
// <form action={submitAction}>
// <input name="title" required />
// <button type="submit" disabled={isPending}>{isPending ? '등록 중...' : '등록'}</button>
// {actionError && <p className="error">{actionError}</p>}
// </form>이 패턴은 10장의 낙관적 업데이트(useOptimistic)와 결합될 때 진가를 발휘합니다. 지금은 "제어 컴포넌트 방식(value + onChange)과 React 19 Form Action 방식(useActionState) 두 가지 무기가 있다"는 것만 기억해 두십시오!
1.5.6 리스트 렌더링 규칙: 번호 붙이기와 filter + map
1) 인덱스 받기: 화면 표시용 번호 열 추가
posts.map((post, index) => ...)처럼 두 번째 매개변수로 0부터 시작하는 인덱스를 받습니다.
tsx
<th style={{ width: '50px' }}>번호</th>
...
{posts.map((post, index) => (
<tr key={post.id} ...>
<td>{index + 1}</td>주의: 화면에는 {index + 1}을 출력하더라도, key에는 인덱스(key={index})가 아니라 반드시 고유 식별자인 key={post.id}를 써야 합니다(1장 [기초 다지기] 3번).
2) 조건에 맞는 항목만 리스트로 그리고 싶을 때
"DEV 카테고리 글만 보여주고 싶다"고 할 때는 map 안에서 복잡하게 분기하기보다, filter로 먼저 걸러낸 뒤 map을 체이닝하는 것이 훨씬 깔끔합니다:
tsx
{posts
.filter((post) => post.category === 'DEV')
.map((post) => (
<tr key={post.id}>...</tr>
))}목록이 수천 건이어서 매 렌더링마다 filter를 도는 비용을 아끼고 싶다면 2장 2.4에서 배울 useMemo로 걸러낸 배열을 캐싱합니다.
1.5.7 컴포넌트의 일생(Lifecycle)과 useEffect 클린업
1장에서는 마운트 시 1회 실행하는 useEffect(..., [])만 썼습니다. 컴포넌트가 태어나고(Mount), 상태가 바뀌어 다시 그려지고(Update), 화면에서 사라질 때(Unmount)의 전체 흐름을 그림으로 확인해 봅시다.
[실험] 생명주기 순서를 눈으로 확인하기
BoardApp.tsx의 useEffect 아래에 잠시 다음 로그 코드를 넣어 봅시다:
tsx
console.log('1. 렌더 함수 실행 중 - titleInputRef:', titleInputRef.current); // 첫 렌더 때는 null
useEffect(() => {
console.log('2. 마운트 완료 (useEffect) - titleInputRef:', titleInputRef.current); // <input> 요소!
return () => {
console.log('3. 언마운트 완료 (클린업 실행) - 타이머·리스너 정리 시점');
};
}, []);컴포넌트가 사라지는 순간도 보려면 frontend/src/App.tsx에서 <Routes>를 감싼 부분 위에 임시 토글 버튼을 달아 보십시오:
tsx
// App.tsx 내부 임시 실험 코드
const [showBoard, setShowBoard] = useState(true);
// ...
<button onClick={() => setShowBoard(!showBoard)}>
게시판 {showBoard ? '숨기기' : '보이기'}
</button>
{showBoard && <Routes>...</Routes>}💡 [확인하기]: 새로고침하면 콘솔에
1. 렌더 함수 실행(null) → 2. 마운트 완료(<input>)순서로 찍힙니다. [숨기기] 버튼을 누르면 컴포넌트가 화면에서 사라지면서3. 언마운트 완료가 찍힙니다. 확인이 끝나면 실험용 로그와App.tsx의 토글 버튼을 원래대로 되돌리십시오.
IMPORTANT
"만들면 반드시 정리(Cleanup)한다": useEffect 안에서 window.addEventListener, setInterval, setTimeout, 웹소켓 연결을 만들었다면, 반드시 return () => { ... } 클린업 함수에서 짝이 되는 해제(removeEventListener, clearInterval)를 해야 합니다. 안 하면 화면을 떠나도 리스너와 타이머가 메모리에 유령처럼 남아 메모리 누수를 일으킵니다(3장 3.3과 13장에서 집중 실습).
1.5.8 이 장의 완성 코드
1.5.1 ~ 1.5.6을 모두 적용한 frontend/src/BoardApp.tsx 전체 코드입니다(1.5.7의 실험용 로그는 제외). 예제 패키지의 frontend/src/snapshots/BoardApp.ch01-5.tsx와 같습니다.
tsx
// frontend/src/BoardApp.tsx (Chapter 01.5 완성본)
import React, { useState, useEffect, useRef } from 'react';
import { flushSync } from 'react-dom';
import axios from 'axios';
import './BoardApp.css';
export interface Post {
id: number;
title: string;
author: string;
content: string;
category: string;
createdAt: string;
}
const categoryOptions = [
{ value: 'DEV', label: '개발' },
{ value: 'NOTICE', label: '공지' },
{ value: 'FREE', label: '자유' }
];
export default function BoardApp() {
const [posts, setPosts] = useState<Post[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [pageSize, setPageSize] = useState(20);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [lastCreatedId, setLastCreatedId] = useState<number | null>(null);
const [keepAuthor, setKeepAuthor] = useState(false);
const [form, setForm] = useState({
title: '',
author: '',
content: '',
category: 'DEV'
});
const titleInputRef = useRef<HTMLInputElement>(null);
const tableBodyRef = useRef<HTMLTableSectionElement>(null);
async function loadPosts(targetSize = pageSize) {
setIsLoading(true);
try {
const res = await axios.get<{ items: Post[] }>('/api/posts', {
params: { size: targetSize }
});
setPosts(res.data.items || []);
} catch (err) {
alert('게시글 로딩 실패');
} finally {
setIsLoading(false);
}
}
async function handleDelete(id: number) {
if (!window.confirm('정말 삭제하시겠습니까?')) return;
try {
await axios.delete(`/api/posts/${id}`);
setPosts((prev) => prev.filter((p) => p.id !== id));
if (selectedId === id) setSelectedId(null);
} catch (err) {
alert('게시글 삭제 실패');
}
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!form.title.trim() || !form.author.trim() || !form.content.trim()) {
alert('제목, 작성자, 내용을 모두 입력하세요.');
return;
}
setIsSubmitting(true);
try {
const res = await axios.post<Post>('/api/posts', { ...form });
flushSync(() => {
setPosts((prev) => [res.data, ...prev]);
setLastCreatedId(res.data.id);
setForm((prev) => ({
title: '',
content: '',
category: 'DEV',
author: keepAuthor ? prev.author : ''
}));
});
tableBodyRef.current
?.querySelector('tr')
?.scrollIntoView({ behavior: 'smooth', block: 'center' });
titleInputRef.current?.focus();
} catch (err) {
alert('게시글 등록 실패');
} finally {
setIsSubmitting(false);
}
}
useEffect(() => {
titleInputRef.current?.focus();
loadPosts();
}, []);
return (
<div className="board-card">
<div className="card-header">
<h2>✅ [React 19.3 마이그레이션] 사내 기술 Q&A 게시판</h2>
<span className="badge">React 19.3 + TypeScript</span>
</div>
{/* 1. 등록 폼 */}
<form className="form-container" onSubmit={handleSubmit}>
<div className="input-row">
<input
ref={titleInputRef}
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
type="text"
placeholder="제목"
required
/>
<input
value={form.author}
onChange={(e) => setForm({ ...form, author: e.target.value })}
type="text"
placeholder="작성자"
className="author-input"
required
/>
<button type="submit" disabled={isSubmitting} className="btn-submit">
{isSubmitting ? '등록 중...' : '게시글 등록'}
</button>
</div>
<div className="option-row">
{categoryOptions.map((option) => (
<label key={option.value}>
<input
type="radio"
name="category"
value={option.value}
checked={form.category === option.value}
onChange={(e) => setForm({ ...form, category: e.target.value })}
/>
{' '}{option.label}
</label>
))}
<label className="keep-author">
<input
type="checkbox"
checked={keepAuthor}
onChange={(e) => setKeepAuthor(e.target.checked)}
/>
{' '}등록 후 작성자 유지
</label>
</div>
<textarea
value={form.content}
onChange={(e) => setForm({ ...form, content: e.target.value })}
placeholder="내용을 입력하세요"
rows={3}
required
/>
</form>
{/* 2. 목록 도구 막대 */}
<div className="list-toolbar">
<span>총 {posts.length}건</span>
<select
value={pageSize}
onChange={(e) => {
const nextSize = Number(e.target.value);
setPageSize(nextSize);
loadPosts(nextSize);
}}
>
<option value={5}>5개씩</option>
<option value={10}>10개씩</option>
<option value={20}>20개씩</option>
</select>
</div>
{/* 3. 로딩 상태 분기 및 4. 목록 테이블 */}
{isLoading ? (
<div className="loading-state">데이터를 불러오는 중입니다...</div>
) : (
<table className="data-table">
<thead>
<tr>
<th style={{ width: '50px' }}>번호</th>
<th>제목</th>
<th style={{ width: '100px' }}>작성자</th>
<th style={{ width: '120px' }}>작성일</th>
<th style={{ width: '70px' }}>동작</th>
</tr>
</thead>
<tbody ref={tableBodyRef}>
{posts.map((post, index) => (
<tr
key={post.id}
className={[
'data-row',
post.id === selectedId && 'row-selected',
post.id === lastCreatedId && 'row-new'
]
.filter(Boolean)
.join(' ')}
onClick={() => setSelectedId(post.id)}
>
<td>{index + 1}</td>
<td>
<span
className={`category-badge category-${(
post.category || 'DEV'
).toLowerCase()}`}
>
{post.category || 'DEV'}
</span>
<span className="post-title">{post.title}</span>
</td>
<td>{post.author}</td>
<td>{post.createdAt}</td>
<td>
<button
className="btn-delete"
onClick={(e) => {
e.stopPropagation();
handleDelete(post.id);
}}
>
삭제
</button>
</td>
</tr>
))}
{posts.length === 0 && (
<tr>
<td colSpan={5} className="empty-state">
등록된 게시글이 없습니다.
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
);
}스타일은 1장의 BoardApp.css에 1.5.3 [Step 4]와 아래 세 줄을 더하면 됩니다:
css
.option-row { display: flex; gap: 16px; align-items: center; margin-bottom: 10px; font-size: 14px; color: #475569; }
.option-row .keep-author { margin-left: auto; }
.list-toolbar { display: flex; justify-content: space-between; align-items: center; font-size: 14px; color: #475569; }1.5.9 jQuery 개발자를 위한 한 장 요약
| 하고 싶은 일 | jQuery | React 19.3 |
|---|---|---|
| 요소에 포커스 | $('#title').focus() | ref={titleInputRef} + useRef<HTMLInputElement>(null) + titleInputRef.current?.focus() |
| 상태 바꾼 직후 새 DOM 다루기 | (바로 가능) | flushSync(() => setState(...)) 직후에 (또는 useEffect 안에서) |
| 클래스 토글 | addClass / removeClass / toggleClass | className={['base', isActive && 'active'].filter(Boolean).join(' ')} |
| 인라인 스타일 | .css({ width: '50%' }) | style={{ width: '50%' }} |
| 이벤트 전파 막기 | e.stopPropagation() | onClick={(e) => { e.stopPropagation(); ... }} |
| 라디오 값 읽기/쓰기 | $('input[name=x]:checked').val() | checked={form.category === opt} + onChange |
| 체크 여부 | $('#keep').is(':checked') | checked={keepAuthor} + onChange={(e) => setKeepAuthor(e.target.checked)} |
| 셀렉트 숫자 변환 | parseInt($('#size').val(), 10) | Number(e.target.value) |
| 반복 + 번호 | $.each(list, function (i, item) {...}) | list.map((item, i) => <tr key={item.id}>...</tr>) |
| 페이지 준비 완료 | $(document).ready() | useEffect(() => { ... }, []) |
| 화면을 떠날 때 정리 | (페이지 이동 시 브라우저가 초기화) | useEffect 안에서 return () => { ... } 반환 |
1.5.10 미리 보기: 화면을 컴포넌트로 나누면 데이터는 어떻게 주고받나
지금까지는 BoardApp.tsx 한 파일에 모든 것을 넣었습니다. 실무에서는 화면을 작은 컴포넌트로 나누는데, 그 순간 "부모의 데이터를 자식에게 어떻게 넘기고, 자식에서 일어난 일을 부모에게 어떻게 알리나?"라는 질문이 생깁니다. 4장과 8장에서 본격적으로 다루기 전에 모양만 먼저 봐 둡시다. 이 절은 읽기만 하고 실습 파일에는 넣지 않습니다.
tsx
// CategoryBadge.tsx (자식): 카테고리 배지 하나를 그리는 작은 부품
interface CategoryBadgeProps {
category: string; // 부모 → 자식 데이터 (읽기 전용)
onSelect: (category: string) => void; // 자식 → 부모 사건 알림용 콜백 함수
}
export function CategoryBadge({ category, onSelect }: CategoryBadgeProps) {
return (
<span
className={`category-badge category-${category.toLowerCase()}`}
onClick={() => onSelect(category)}
>
{category}
</span>
);
}tsx
// BoardApp.tsx (부모): import한 뒤 HTML 태그처럼 쓴다
import { CategoryBadge } from '@/components/CategoryBadge';
<CategoryBadge
category={post.category}
onSelect={(c) => console.log('선택한 카테고리:', c)}
/>| 방향 | 문법 | jQuery로 비유하면 |
|---|---|---|
| 부모 → 자식 (데이터) | 부모: category={값} / 자식: { category }: Props | 함수를 호출할 때 인자를 넘기는 것 |
| 자식 → 부모 (사건) | 부모: onSelect={핸들러} / 자식: onSelect(값) 호출 | 콜백 함수를 인자로 넘겨 자식이 부르게 하는 것 |
- 데이터는 위에서 아래로만 흐릅니다(Props Down, Events Up). 자식이 props를 직접 바꾸면 안 되고, 바꾸고 싶으면 부모가 내려준 콜백 함수(
onSelect)를 호출해 부모에게 부탁합니다.
1.5.11 [직접 해보기] 실습 미션 💪
미션: 제목 입력창에서 Escape 키를 누르면 입력 중이던 제목·내용 즉시 비우기
- 등록 폼의 제목
<input>에서 사용자가 타이핑하다가 키보드의Escape키(e.key === 'Escape')를 누르면,form.title과form.content가 빈 문자열('')로 초기화되도록onKeyDown핸들러를 추가해 보십시오. - 단, 이미 입력해 둔
form.author와form.category는 그대로 유지되어야 합니다!
💡 미션 정답 코드 확인하기
tsx
<input
ref={titleInputRef}
value={form.title}
onChange={(e) => setForm({ ...form, title: e.target.value })}
onKeyDown={(e) => {
if (e.key === 'Escape') {
// 기존 author와 category는 보존하고 title과 content만 초기화!
setForm((prev) => ({ ...prev, title: '', content: '' }));
}
}}
type="text"
placeholder="제목 (Esc를 누르면 초기화됩니다)"
required
/>1.5.12 이번 장 핵심 요약 및 다음 단계 🚀
핵심 3줄 요약
- 실제 DOM 접근(포커스, 스크롤, 측정)이 꼭 필요할 때만
useRef로 꺼내 쓰고, 상태 변경 직후 새 DOM을 즉시 다뤄야 할 때는flushSync를 활용합니다. - 클래스·스타일·입력값은 DOM을 직접 건드리지 않고 **상태(
className,style,value/checked+onChange)**로 표현하며, 이벤트 버블링은e.stopPropagation()으로 제어합니다. - 리스트(
map)에는 고유한key={post.id}를 부여하고,useEffect에서 만든 타이머나 이벤트 리스너는 반드시 반환 함수(return () => ...)에서 정리합니다.
바뀐 파일
| 파일 | 무엇이 바뀌었나 |
|---|---|
frontend/src/BoardApp.tsx | 제목 자동 포커스(useRef), 등록 후 스크롤(flushSync), 행 선택·새 글 강조, 카테고리 배지·라디오, 작성자 유지 체크박스, 목록 개수 셀렉트, 번호 열, 삭제 버블링 방지 (= snapshots/BoardApp.ch01-5.tsx) |
frontend/src/App.tsx | 1.5.7 실험에서 토글을 잠깐 넣었다가 원래대로 되돌림 (최종 변경 없음) |
자주 하는 실수 (Troubleshooting)
| 증상 | 원인 | 해결 |
|---|---|---|
TypeError: Cannot read properties of null (reading 'focus') | useEffect 밖(컴포넌트 본문 최상위)에서 titleInputRef.current.focus() 호출 | useEffect 안에서, ?.를 붙여 호출 |
| 삭제 버튼을 눌렀는데 행까지 파랗게 선택됨 | 클릭 이벤트가 부모 <tr>로 전파(버블링)됨 | 버튼 onClick 첫 줄에 e.stopPropagation() 추가 |
체크박스를 클릭해도 상태가 안 바뀌거나 문자열 'on'이 들어감 | e.target.value를 읽음 | 체크박스는 checked={keepAuthor}와 e.target.checked 사용 |
setPosts 바로 다음 줄에서 DOM 행 수를 세었더니 1 적음 | React 자동 배치로 인해 아직 DOM 커밋 전임 | flushSync로 감싸거나 useEffect에서 처리 |
셀프 체크 — 답을 먼저 떠올린 뒤 펼쳐 보십시오.
- 컴포넌트 함수 본문 최상위(
useEffect바깥)에titleInputRef.current.focus()를 쓰면 어떻게 될까요? useState의 값을 바꿀 때와useRef.current의 값을 바꿀 때 리렌더링 측면에서 어떤 결정적 차이가 있나요?<select value={pageSize}>의onChange에서e.target.value를Number()로 감싸지 않으면 어떤 문제가 생길까요?- React 19에서 자식 컴포넌트에
ref를 전달할 때 React 18 이하와 비교해 무엇이 간편해졌나요?
셀프 체크 정답
- 렌더 함수가 실행되는 시점에는 아직 DOM이 생성되기 전이라
titleInputRef.current가null이므로TypeError가 발생합니다. DOM 참조는 커밋 이후 실행되는useEffect나 이벤트 핸들러 안에서 사용해야 합니다. useState의 setter 함수에 새 참조를 넘기면 React가 컴포넌트를 다시 실행(리렌더링)하여 화면을 갱신하지만,useRef.current를 바꾸는 것은 일반 객체 속성을 고치는 것과 같아 리렌더링을 전혀 일으키지 않습니다.- HTML 입력 요소의
e.target.value는 항상 **문자열('5')**입니다. 숫자로 변환하지 않으면 숫자 타입 상태에 문자열이 들어가 이후 산술 연산(page + 1→'51')이나 타입 검사에서 오류가 납니다. - React 18 이하에서는 자식을
React.forwardRef로 감싸야만ref를 받을 수 있었지만, React 19부터는ref를 일반 prop처럼{ ref }로 바로 구조 분해해서 받을 수 있습니다.
다음 챕터로 이어지는 질문
포커스, 클래스, 폼 입력까지 모두 "상태만 바꾸면 화면이 따라오는" 방식으로 바꿨습니다. 그런데 React는 setPosts가 불렸을 때 **내부에서 어떻게 이전 화면과 새 화면을 비교(Diffing)**하고, 왜 여러 번 상태를 바꿔도 화면을 단 한 번만 갱신할까요? 다음 Chapter 02에서 가상 DOM(Fiber)과 렌더링 파이프라인의 비밀을 파헤칩니다.