Skip to content

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(); &#125;&#125; />처럼 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이 만들어진 직후 스크롤을 실행하는 방법은 두 가지가 있습니다:

  1. react-dom의 flushSync() 사용: "지금 당장 이 상태 변경을 DOM에 즉시 반영(커밋)해 달라!"고 강제하는 함수입니다. 이벤트 핸들러 안에서 방금 추가한 요소로 즉시 스크롤할 때 가장 직관적입니다.
  2. useEffect로 상태 감시: lastCreatedId 상태가 바뀔 때 useEffect 안에서 스크롤하는 선언적 방식입니다.

📦 비유: 택배 기사님과 엘리베이터 (Automatic Batching vs flushSync)
배열에 글을 하나 넣었다고 해서 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=&#123;&#123; ... &#125;&#125; 인라인 스타일은 언제 쓰나? 색상표처럼 정해진 모양은 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) 객체를 핸들러 첫 번째 인자로 넘겨줍니다.

하고 싶은 일jQueryReact (e는 이벤트 객체)
부모 요소로 전파(버블링) 막기e.stopPropagation()e.stopPropagation()
브라우저 기본 동작 막기 (폼 새로고침 등)e.preventDefault() / return falsee.preventDefault()
자식이 아니라 그 요소 자체 클릭만 허용 (모달 배경 닫기)if (e.target === e.currentTarget)if (e.target === e.currentTarget) closeModal();
Enter / Esc 키 감지if (e.which === 13)onKeyDown={(e) => { if (e.key === 'Enter') ... &#125;&#125;
Shift / Ctrl + 클릭 감지if (e.shiftKey)onClick={(e) => { if (e.shiftKey) ... &#125;&#125;

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 개발자를 위한 한 장 요약 ​

하고 싶은 일jQueryReact 19.3
요소에 포커스$('#title').focus()ref={titleInputRef} + useRef<HTMLInputElement>(null) + titleInputRef.current?.focus()
상태 바꾼 직후 새 DOM 다루기(바로 가능)flushSync(() => setState(...)) 직후에 (또는 useEffect 안에서)
클래스 토글addClass / removeClass / toggleClassclassName={['base', isActive && 'active'].filter(Boolean).join(' ')}
인라인 스타일.css({ width: '50%' })style=&#123;&#123; width: '50%' &#125;&#125;
이벤트 전파 막기e.stopPropagation()onClick={(e) => { e.stopPropagation(); ... &#125;&#125;
라디오 값 읽기/쓰기$('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 키를 누르면 입력 중이던 제목·내용 즉시 비우기 ​

  1. 등록 폼의 제목 <input>에서 사용자가 타이핑하다가 키보드의 Escape 키(e.key === 'Escape')를 누르면, form.title과 form.content가 빈 문자열('')로 초기화되도록 onKeyDown 핸들러를 추가해 보십시오.
  2. 단, 이미 입력해 둔 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줄 요약

  1. 실제 DOM 접근(포커스, 스크롤, 측정)이 꼭 필요할 때만 useRef로 꺼내 쓰고, 상태 변경 직후 새 DOM을 즉시 다뤄야 할 때는 flushSync를 활용합니다.
  2. 클래스·스타일·입력값은 DOM을 직접 건드리지 않고 **상태(className, style, value/checked + onChange)**로 표현하며, 이벤트 버블링은 e.stopPropagation()으로 제어합니다.
  3. 리스트(map)에는 고유한 key={post.id}를 부여하고, useEffect에서 만든 타이머나 이벤트 리스너는 반드시 반환 함수(return () => ...)에서 정리합니다.

바뀐 파일

파일무엇이 바뀌었나
frontend/src/BoardApp.tsx제목 자동 포커스(useRef), 등록 후 스크롤(flushSync), 행 선택·새 글 강조, 카테고리 배지·라디오, 작성자 유지 체크박스, 목록 개수 셀렉트, 번호 열, 삭제 버블링 방지 (= snapshots/BoardApp.ch01-5.tsx)
frontend/src/App.tsx1.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에서 처리

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

  1. 컴포넌트 함수 본문 최상위(useEffect 바깥)에 titleInputRef.current.focus()를 쓰면 어떻게 될까요?
  2. useState의 값을 바꿀 때와 useRef.current의 값을 바꿀 때 리렌더링 측면에서 어떤 결정적 차이가 있나요?
  3. <select value={pageSize}>의 onChange에서 e.target.value를 Number()로 감싸지 않으면 어떤 문제가 생길까요?
  4. React 19에서 자식 컴포넌트에 ref를 전달할 때 React 18 이하와 비교해 무엇이 간편해졌나요?
셀프 체크 정답
  1. 렌더 함수가 실행되는 시점에는 아직 DOM이 생성되기 전이라 titleInputRef.current가 null이므로 TypeError가 발생합니다. DOM 참조는 커밋 이후 실행되는 useEffect나 이벤트 핸들러 안에서 사용해야 합니다.
  2. useState의 setter 함수에 새 참조를 넘기면 React가 컴포넌트를 다시 실행(리렌더링)하여 화면을 갱신하지만, useRef.current를 바꾸는 것은 일반 객체 속성을 고치는 것과 같아 리렌더링을 전혀 일으키지 않습니다.
  3. HTML 입력 요소의 e.target.value는 항상 **문자열('5')**입니다. 숫자로 변환하지 않으면 숫자 타입 상태에 문자열이 들어가 이후 산술 연산(page + 1 → '51')이나 타입 검사에서 오류가 납니다.
  4. React 18 이하에서는 자식을 React.forwardRef로 감싸야만 ref를 받을 수 있었지만, React 19부터는 ref를 일반 prop처럼 { ref }로 바로 구조 분해해서 받을 수 있습니다.

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

포커스, 클래스, 폼 입력까지 모두 "상태만 바꾸면 화면이 따라오는" 방식으로 바꿨습니다. 그런데 React는 setPosts가 불렸을 때 **내부에서 어떻게 이전 화면과 새 화면을 비교(Diffing)**하고, 왜 여러 번 상태를 바꿔도 화면을 단 한 번만 갱신할까요? 다음 Chapter 02에서 가상 DOM(Fiber)과 렌더링 파이프라인의 비밀을 파헤칩니다.

Moon HomeLab Engineering Knowledge Hub