Skip to content

Chapter 01. 돌아가기만 하는 사내 레거시: jQuery 게시판의 종말 ​

"수년간 사내 인트라넷을 지탱해 온 저 jQuery 파일은 왜 지금 우리 팀의 가장 큰 기술 부채가 되었는가? 그리고 우리는 이 하나의 코드를 어디서부터, 어떻게 손으로 직접 작성하며 React 19.3 아키텍처로 진화시킬 수 있는가?"


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

꼭 잡을 핵심 3개 — 이 장을 마치면 이 셋은 손에 익어 있어야 합니다.

개념한 줄 요약설명 위치
useState와 불변성 업데이트값이 바뀌면 컴포넌트 함수를 다시 실행해 화면을 그리는 반응형 상태 훅Step 2 (사용법) → 2장, 2.5장에서 원리
JSX 보간 { }, map 리스트와 key, 조건부 렌더링, onClick상태를 화면에 선언적으로 그리고 이벤트를 연결하는 JSX 문법Step 2 [기초 다지기] 박스
제어 컴포넌트(value + onChange)와 폼 상태 객체폼 입력창과 React 상태를 양방향으로 동기화하는 패턴Step 3 박스 → 1.5장에서 useActionState와 비교
참고 문법 — 나올 때마다 그 자리의 🆕 박스가 풀어 줍니다
개념한 줄 요약설명 위치
npm, Vite, 개발 서버 프록시라이브러리 설치 도구 / 개발 서버 겸 번들러 / CORS 우회1.3 🆕 박스, 0장 0.2
.tsx 파일, 함수형 컴포넌트, CSS 파일 분리JSX와 TypeScript로 컴포넌트를 작성하는 표준 구조Step 1 🆕 박스
import, const, =>, async/await, try/catch/finally모던 자바스크립트(ES6+) 문법Step 2 🆕 박스, 0장 0.3
interface, id: number, useState<Post[]>, axiosTypeScript 타입 표시 / $.ajax 대신 쓰는 HTTP 라이브러리Step 2 🆕 박스, 0장 0.4~0.5
useEffect(..., [])컴포넌트가 화면에 마운트된 직후 실행되는 생명주기 훅Step 2 [기초 다지기] 박스 → 1.5장 1.5.7, 3장
e.preventDefault(), ...spread, `${}`, filter폼 새로고침 방지 / 객체·배열 불변 복사 / 문자열 조립 / 배열 거르기Step 3·4 박스, 0장 0.3
dangerouslySetInnerHTML, CSPReact에서도 XSS가 생기는 예외 지점Step 3 🔬 심화 박스

TIP

🏁 이 장의 시작 파일
frontend/src/BoardApp.tsx를 비우고 처음부터 작성합니다(지우기 전 내용은 snapshots/BoardApp.final.tsx에 사본이 있습니다). 이 장을 마치면 snapshots/BoardApp.ch01.tsx와 같은 상태가 됩니다(막혔다면 그 파일을 복사해 오십시오). 이 장에서 손대는 파일: frontend/src/BoardApp.tsx


1.1 사내 인트라넷에 살아 숨 쉬는 jQuery 게시판 ​

우리 회사 인트라넷에는 사내 Q&A와 공지사항을 위해 구축된 전형적인 **jQuery 기반 게시판(legacy-board.html)**이 지금도 평화롭게(?) 돌아가고 있습니다. 코드를 짠 선배는 이미 다른 회사로 이직해서 연락처도 모르고, 손대면 어디가 폭발할지 몰라 주석 하나도 경건한 마음으로 바라보는 바로 그 레거시 파일입니다.

기능 자체는 겉보기에 동작합니다:

  1. 브라우저가 열리면 $.ajax로 백엔드 Spring Boot(/api/posts)에서 목록을 가져옵니다.
  2. $('#board-tbody').empty()를 한 뒤 문자열 <tr><td>...</td></tr>을 루프로 돌려 .append()합니다.
  3. [등록] 버튼을 누르면 인풋 필드의 $('#input-title').val()을 읽어와 $.post를 날립니다.
  4. 각 행의 [삭제] 버튼을 누르면 동적 요소 이벤트 위임($(document).on('click', '.btn-delete'))으로 행을 지웁니다.

🖥️ [핸즈온 01] 레거시 jQuery 게시판 직접 실행해보기 ​

백엔드 서버를 먼저 띄워 jQuery 게시판이 어떻게 동작하는지 눈으로 확인해 봅시다.

  1. 터미널을 열고 백엔드(Spring Boot) 서버를 실행합니다:
bash
cd backend
./mvnw spring-boot:run
# (Windows PowerShell / CMD 환경의 경우):
# .\mvnw.cmd spring-boot:run

백엔드가 정상 기동되면 콘솔에 Started BoardApplication in ... seconds (process running for ...) 로그와 함께 8080 포트에서 대기합니다.

  1. 웹 브라우저를 열고 레거시 화면에 접속합니다:
    • 접속 주소: http://localhost:8080/legacy-board.html
    • 초기 데이터로 7개의 사내 게시글(공지 2건, 개발 3건, 자유 2건)이 테이블에 노출됩니다.
    • 제목과 작성자를 넣고 [게시글 등록] 버튼을 눌러보고, 행 우측의 [삭제] 버튼도 눌러보십시오. 일단 돌아는 갑니다!

1.2 [실습] 브라우저에서 직접 터뜨려보는 jQuery 3대 버그 ​

http://localhost:8080/legacy-board.html 화면 상단에 있는 안내 박스를 보며, 아래 3가지 액션을 이 책의 순서대로 직접 브라우저에서 수행해 보십시오. (XSS 실험에서 등록한 글은 DB에 남아 이후 레거시 화면을 열 때마다 경고창을 띄우므로 마지막에 합니다.)

[실험 1] 상태와 뷰의 이원화 버그 (동기화 실패) ​

  • 행동: 상단 건수 배지가 총 7건인 것을 확인한 뒤, 아무 행이나 우측의 [삭제] 버튼을 눌러 1~2건을 지워 봅니다.
  • 결과: 행은 테이블에서 사라졌는데 상단 건수 배지는 여전히 총 7건입니다. 코드를 보면 마지막 행까지 지워도 "등록된 게시글이 없습니다"라는 빈 상태(Empty State) 안내 행은 나타나지 않고 테이블 헤더만 덩그러니 남게 되어 있습니다.
  • 원인: 데이터(메모리 배열)와 화면(DOM)이 분리되어 있어, 개발자가 $targetRow.remove()를 호출할 때마다 건수 텍스트 갱신과 빈 안내 행 삽입($('#board-tbody').html(...))을 매번 수동으로 챙겨야 하는 명령형 코드의 구조적 한계 때문입니다. 실제로 legacy-board.html의 삭제 성공 콜백에는 $targetRow.remove() 한 줄만 있습니다.

NOTE

3번 글("React의 가상 DOM과 렌더링 엔진에 대해 질문이 있습니다.")에는 6장 댓글 실습에서 쓸 샘플 댓글이 달려 있습니다. 지우면 댓글까지 함께 삭제되므로 이 실험에는 다른 글을 사용하십시오. 실수로 지웠다면 백엔드를 재시작하면 초기 데이터로 돌아옵니다.

[실험 2] HTML 구조 변경 시 셀렉터 먹통 현상 ​

  • 행동: 브라우저 개발자 도구(F12 → Elements 탭)에서 아무 행의 [삭제] 버튼(<button class="btn-delete" data-id="...">)을 찾아, 클래스명을 더블클릭해 btn-delete → btn-remove로 바꿉니다. 그리고 그 버튼을 클릭해 봅니다.
  • 결과: 확인 창조차 뜨지 않고, 콘솔에도 에러가 한 줄도 찍히지 않은 채 아무 일도 일어나지 않습니다. 디자이너가 버튼 클래스명이나 table > tr > td 구조를 div.card-item 카드 뷰로 리뉴얼하는 순간 똑같은 일이 실제 서비스에서 벌어집니다. (구조만 바뀌어 closest('tr')이 빈 결과를 돌려주는 경우는 더 고약합니다. DELETE 요청은 성공해서 서버에서는 지워졌는데, 화면의 행은 그대로 남습니다.)
  • 원인: jQuery 코드의 비즈니스 로직이 HTML 마크업 계층 구조(.btn-delete 클래스, closest('tr'), $('#board-tbody'))에 강하게 결합되어 있기 때문입니다. 새로고침하면 바꾼 클래스는 원래대로 돌아옵니다.

[실험 3] 사내 세션 탈취 XSS 취약점 터뜨리기 ​

  • 행동: 게시글 제목 입력창에 아래 악의적인 자바스크립트 태그를 복사해서 붙여넣고, 작성자를 입력한 뒤 [게시글 등록] 버튼을 누릅니다:
html
<img src="invalid-image" onerror="alert('🚨 [보안 경고] 사내 세션 토큰이 탈취되었습니다!');">
  • 결과: 등록되자마자 브라우저에 alert 경고창이 뜹니다! 보안팀 담당자가 커피를 들고 우리 자리로 걸어오는 오싹한 환청이 들리기 시작합니다.
  • 원인: jQuery 코드가 post.title을 따옴표 문자열 결합('<td>' + p.title + '</td>')으로 HTML에 그대로 때려 박았기 때문에, 브라우저가 이미지 로드 실패 시 인라인 자바스크립트를 즉시 실행해 버린 것입니다.

TIP

백엔드는 H2 메모리 DB를 쓰므로, 백엔드 서버를 껐다 켜면 언제든 초기 7건 상태로 돌아갑니다. 실험으로 데이터가 어지러워졌다면 재시작하십시오.


1.3 [실습 환경 준비] React 19.3 + TypeScript 프론트엔드 프로젝트 구조 이해하기 ​

이제 이 불안정한 jQuery 코드를 모던 프론트엔드의 사실상 표준인 React 19.3 + TypeScript로 전환하겠습니다.
우선 프론트엔드 실습 환경이 어떻게 구성되어 있는지 살펴봅시다.

1) 실무에서 React 19 프로젝트를 새로 구성하는 방식 ​

만약 사내에서 신규 프로젝트를 처음부터 구축한다면 공식 Vite 스캐폴딩 도구를 사용합니다:

bash
npm create vite@latest my-board-app -- --template react-ts
  • 생성 후 react-router-dom, axios, zustand, vitest 등을 설치하면 표준 구성의 프로젝트가 만들어집니다. 이 책에서는 이 과정을 하지 않습니다.

2) 본 교재 실습 환경 (frontend/ 폴더) ​

번들러 설정에 시간을 쓰지 않도록, 프로젝트 루트의 frontend/ 디렉터리에 실습 환경이 미리 준비되어 있습니다.

text
react19-board-architecture/
├── backend/                  # Spring Boot 3 백엔드 (REST API: 8080)
└── frontend/                 # React 19.3 + TypeScript 프론트엔드 (3000)
    ├── package.json          # React 19.3, TypeScript, Axios, Zustand, Vitest 의존성
    ├── vite.config.ts        # Vite 번들러 설정 (8080 포트 프록시 내장)
    ├── index.html            # SPA 단일 HTML 엔트리포인트 (<div id="root"></div>)
    └── src/
        ├── main.tsx          # React createRoot 부트스트랩
        ├── App.tsx           # 최상위 레이아웃 컴포넌트 (라우터 & ErrorBoundary)
        ├── BoardApp.css      # 게시판 공통 스타일시트
        └── BoardApp.tsx      # 👈 우리가 직접 한 줄씩 코딩할 실습 파일!

🆕 처음 보는 문법: 실습 프로젝트의 파일들

파일역할
package.json사용하는 라이브러리(react, react-dom, axios 등) 목록과 npm run dev 같은 실행 명령 정의
vite.config.tsVite 개발 서버 설정: 포트 3000, /api → 8080 프록시, @ 경로 별칭
index.html브라우저가 처음 받는 유일한 HTML. 안에는 <div id="root"></div> 하나뿐이고, React가 이 자리에 화면을 그림
src/main.tsx앱의 시작점. createRoot(document.getElementById('root')!).render(<App />)로 App.tsx를 #root 자리에 마운트함 (jQuery의 $(document).ready() 역할)
src/App.tsx최상위 컴포넌트. 헤더·메뉴(Link) 아래의 <Routes> 자리에 주소 /에 연결된 BoardApp.tsx를 그려 줌

npm, Vite, 프록시가 낯설다면 0장 0.2를 먼저 읽고 오십시오.

💡 실습 환경의 src/main.tsx와 src/App.tsx에는 라우터(BrowserRouter, Routes, Route)와 <ErrorBoundary>가 이미 최종 형태로 들어 있습니다. 라우터 기초는 4.5장, 로그인 가드는 11장, ErrorBoundary는 12장에서 설명합니다. 지금은 "App.tsx가 우리가 만들 BoardApp.tsx를 화면에 보여 준다" 정도로만 이해하면 충분합니다. App.tsx는 1.5장 1.5.7의 생명주기 실험에서 잠깐 고쳤다가 되돌리는 것 말고는 손대지 않습니다.

NOTE

CORS 걱정 없는 Vite 프록시 설정:
frontend/vite.config.ts에는 /api로 시작하는 모든 HTTP 비동기 요청을 Spring Boot 백엔드(http://localhost:8080)로 자동 전달해주는 프록시(Proxy)가 내장되어 있어 별도의 CORS 설정 없이 로컬에서 원활하게 개발할 수 있습니다.

3) 프론트엔드 개발 서버 기동하기 ​

새로운 터미널 창을 하나 더 열고(백엔드 터미널은 켜둔 상태 유지), 프론트엔드 개발 서버를 실행합니다:

bash
cd frontend
npm install
npm run dev

터미널에 다음과 같은 녹색 메시지가 출력되면 성공입니다:

text
  VITE v6.0.0  ready in 280 ms

  ➜  Local:   http://localhost:3000/
  ➜  Network: use --host to expose

이제 웹 브라우저를 열고 http://localhost:3000에 접속해 두십시오.
frontend/src/App.tsx가 주소 /에 연결된 frontend/src/BoardApp.tsx를 화면에 그려 줄 준비를 마쳤습니다. 지금 화면에는 예제 패키지의 최종 완성본이 보이며, Step 1부터는 이 파일을 우리가 직접 처음부터 다시 작성합니다.


1.4 [따라하기 실습] BoardApp.tsx 5단계 점진적 코딩 ​

이제 본격적으로 코드를 작성해 봅시다.
한 번에 완성된 코드를 붙여넣지 마십시오! **조회(Read) → 등록(Create) → 삭제(Delete)**의 순서로 한 단계씩 코딩하며 화면의 변화를 직접 눈으로 확인해야 선언적 컴포넌트의 멘탈 모델이 뇌리에 각인됩니다.

TIP

🍳 비유로 이해하는 명령형(jQuery) vs 선언형(React)

  • 명령형 (jQuery - 간섭쟁이 손님): 레스토랑 주방으로 쳐들어가 요리사에게 일일이 간섭합니다. "냉장고 2번 칸 열어! 계란 꺼내! 프라이팬에 기름 둘러! 가스불 중불 켜! 30초 뒤에 뒤집어!" 지시 하나만 삐끗해도 계란이 타거나 주방에 불이 납니다(DOM 조작 누락 버그).
  • 선언형 (React - 우아한 미식가): 테이블에 앉아 "완숙 말고 노른자가 톡 터지는 반숙 계란프라이 하나요" 하고 주문서(데이터 상태 useState)만 깔끔하게 적어 냅니다. 불을 켜고 뒤집는 수고로운 과정은 전문 셰프(React Fiber 렌더링 엔진)가 상태를 보고 알아서 가장 효율적인 방식으로 조리해 화면에 서빙합니다.

[Step 1] 파일 준비 및 기본 골격 작성 ​

IMPORTANT

지우기 전에 확인하십시오. 예제 패키지의 frontend/src/BoardApp.tsx는 마지막 장까지 적용한 최종 완성본이며, 같은 사본이 frontend/src/snapshots/BoardApp.final.tsx에 있습니다. 장별 완성본도 같은 폴더에 BoardApp.ch01.tsx, BoardApp.ch01-5.tsx … 형태로 있어, 막히면 비교·복원용으로 씁니다.

frontend/src/BoardApp.tsx 파일을 에디터(VS Code 등)로 열고, 내용을 모두 지운 뒤 아래의 기본 골격부터 작성해 봅시다:

tsx
// frontend/src/BoardApp.tsx
import './BoardApp.css';

export default function BoardApp() {
  // 여기에 상태(State)와 비즈니스 로직을 하나씩 채워 넣을 것입니다.

  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>

      <p style={{ color: '#64748b', padding: '20px 0' }}>
        게시판 컴포넌트가 성공적으로 마운트되었습니다.
      </p>
    </div>
  );
}

🆕 처음 보는 문법: .tsx 파일의 구조

  • export default function BoardApp(): 컴포넌트는 대문자로 시작하는 자바스크립트 함수입니다(소문자로 시작하면 React가 <div> 같은 일반 HTML 태그로 오인합니다).
  • return ( ... ): 이 컴포넌트가 화면에 그릴 구조(JSX)를 반환합니다. 최상위 요소 하나(<div className="board-card">)로 감싸야 합니다.
  • className="board-card": JSX에서는 HTML의 class 대신 className을 씁니다.
  • style=&#123;&#123; color: '#64748b', padding: '20px 0' &#125;&#125;: 인라인 스타일은 문자열이 아니라 camelCase로 작성된 자바스크립트 객체로 전달합니다.
  • import './BoardApp.css': 카드와 테이블 스타일이 담긴 CSS 파일을 불러옵니다. (CSS 모듈이나 Tailwind CSS를 쓰는 방법은 실무에서 선택할 수 있으며, 본 교재는 코드 가독성을 위해 표준 클래스명을 사용합니다.)

💡 [확인하기]:
파일을 저장하고 브라우저 http://localhost:3000을 확인해 보십시오. 흰색 카드 상단에 제목과 뱃지가 깔끔하게 나타납니다.


[Step 2] 데이터 모델 정의 및 목록 선언적 조회 (Read) ​

이제 백엔드 Spring Boot의 /api/posts API를 호출하여 게시글 목록을 테이블에 뿌려보겠습니다.

  1. BoardApp.tsx 상단에 TypeScript 인터페이스와 useState, useEffect를 선언합니다:
tsx
// frontend/src/BoardApp.tsx
import { useState, useEffect } from 'react';
import axios from 'axios';
import './BoardApp.css';

// 1. 게시글 데이터 타입 정의 (백엔드 PostResponse DTO와 계약)
export interface Post {
  id: number;
  title: string;
  author: string;
  content: string;
  createdAt: string;
}

export default function BoardApp() {
  // 2. 상태(State) 선언: setPosts로 새 배열을 넣으면 React가 알아서 화면을 다시 그린다.
  const [posts, setPosts] = useState<Post[]>([]);
  const [isLoading, setIsLoading] = useState<boolean>(false);

  // 3. 서버 API 호출 함수
  async function loadPosts() {
    setIsLoading(true);
    try {
      const res = await axios.get<{ items: Post[] }>('/api/posts?size=20');
      setPosts(res.data.items || []);
    } catch (err) {
      alert('게시글 로딩에 실패했습니다.');
    } finally {
      setIsLoading(false);
    }
  }

  // 4. 컴포넌트가 마운트될 때 1회 즉시 실행
  useEffect(() => {
    loadPosts();
  }, []);

🆕 처음 보는 문법: Step 2 스크립트를 한 줄씩 읽기

코드읽는 법jQuery 시절에 비유하면
import { useState, useEffect } from 'react';react 라이브러리에서 두 훅(Hook) 함수를 가져온다<script src="jquery.js"> 로드
export interface Post { id: number; ... }게시글 데이터의 모양을 TypeScript에 알려 준다 (실행 시 사라짐)주석으로 "이런 필드가 온다"고 적어 두던 것
const [posts, setPosts] = useState<Post[]>([]);빈 배열을 초기값으로 갖는 상태를 만든다. posts는 현재 값, setPosts는 값을 바꾸고 리렌더링을 요청하는 함수다var posts = []; (단, 바뀌어도 화면이 자동 갱신되지 않던)
setIsLoading(true);상태를 true로 바꾸고 화면을 다시 그려 달라고 요청한다$('#spinner').show()
const res = await axios.get<{ items: Post[] }>(url);응답이 올 때까지 기다렸다가 res에 담는다. 꺾쇠는 응답 본문 모양의 타입 표시$.ajax({ url, success: function (res) {...} })
res.data.itemsaxios는 서버가 보낸 본문을 **res.data**에 넣어 준다success 콜백의 res.items
useEffect(() => { loadPosts(); }, []);"화면에 처음 붙은 직후 이 함수를 1번 실행해 달라"고 React에 등록. 두 번째 인자 [](빈 배열)가 "최초 1회만"이라는 뜻$(document).ready(function () {...})

useState가 정확히 어떻게 이전 값을 기억하고 화면을 다시 그리는지는 2장과 2.5장에서 밑바닥부터 구현하며 설명합니다. 지금은 "화면에 보여줄 값은 useState에 담고, 바꿀 때는 반드시 setXxx(새값)을 호출한다"만 기억하십시오.

  1. return ( ... ) 내부에 목록 테이블과 posts.map(...) 반복문을 작성합니다: <p>게시판 컴포넌트가...</p> 문장을 지우고, 상단 배지에 건수 표시(총 {posts.length}건)도 함께 연결해 봅시다:
tsx
  return (
    <div className="board-card">
      <div className="card-header">
        <h2>✅ [React 19.3 마이그레이션] 사내 기술 Q&A 게시판</h2>
        <span className="badge">총 {posts.length}건 · React 19.3</span>
      </div>

      {/* 로딩 인디케이터 선언적 조건부 렌더링 */}
      {isLoading ? (
        <div className="loading-state">
          데이터를 불러오는 중입니다...
        </div>
      ) : (
        /* 게시글 목록 테이블: jQuery의 for문 문자열 결합 완전 제거 */
        <table className="data-table">
          <thead>
            <tr>
              <th style={{ width: '60px' }}>ID</th>
              <th>제목</th>
              <th style={{ width: '100px' }}>작성자</th>
              <th style={{ width: '120px' }}>작성일</th>
              <th style={{ width: '70px' }}>동작</th>
            </tr>
          </thead>
          <tbody>
            {/* map: 상태 배열의 요소를 <tr> JSX 엘리먼트로 1:1 변환 */}
            {posts.map((post) => (
              <tr key={post.id}>
                <td>{post.id}</td>
                <td>
                  {/* 중괄호 { }는 HTML을 자동 이스케이프하므로 XSS 스크립트가 실행되지 않는다 */}
                  <span className="post-title">{post.title}</span>
                </td>
                <td>{post.author}</td>
                <td>{post.createdAt}</td>
                <td>
                  <button className="btn-delete">삭제</button>
                </td>
              </tr>
            ))}

            {/* 빈 상태 UI: 상태 배열 길이가 0이면 자동 노출 */}
            {posts.length === 0 && (
              <tr>
                <td colSpan={5} className="empty-state">
                  등록된 게시글이 없습니다.
                </td>
              </tr>
            )}
          </tbody>
        </table>
      )}
    </div>
  );
}
  1. frontend/src/BoardApp.css에 기본 스타일이 들어 있는지 확인합니다 (없다면 아래 스타일을 복사해 넣으십시오):
css
/* frontend/src/BoardApp.css */
.board-card { background: #ffffff; border-radius: 12px; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); padding: 24px; }
.card-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 2px solid #e2e8f0; padding-bottom: 16px; margin-bottom: 20px; }
.badge { background: #ecfdf5; color: #10b981; padding: 4px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; }
.data-table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; }
.data-table th, .data-table td { border-bottom: 1px solid #e2e8f0; padding: 12px; text-align: left; }
.data-table th { background: #f8fafc; color: #475569; font-weight: 600; }
.post-title { font-weight: 600; color: #0f172a; }
.loading-state, .empty-state { text-align: center; padding: 40px; color: #94a3b8; }
.btn-delete { background: #fee2e2; color: #ef4444; border: 1px solid #fca5a5; padding: 4px 8px; border-radius: 4px; cursor: pointer; }
.btn-delete:hover { background: #fecaca; }

💡 [확인하기]:
파일을 저장하고 브라우저 http://localhost:3000을 확인하십시오.
백엔드 H2 데이터베이스에 들어 있는 게시글 목록이 테이블에 출력되며, 상단 배지에 총 7건이 자동으로 계산되어 표시됩니다!

📘 [기초 다지기] Step 2 코드에 등장한 React 문법 한 줄씩 해부하기

1) 함수형 컴포넌트는 언제 실행되는가?
BoardApp() 함수는 처음 화면에 마운트될 때 1번 실행되고, 이후 **setPosts나 setIsLoading으로 상태가 바뀔 때마다 함수 전체가 다시 실행(리렌더링)**되어 새로운 JSX 설계도를 반환합니다.

2) JSX 템플릿 표현식 정리

문법의미
{post.title}자바스크립트 값을 텍스트 노드로 출력 (HTML 태그 자동 이스케이프)
{posts.map(post => <tr key={post.id}>...</tr>)}배열의 각 요소를 <tr> JSX 객체로 변환하여 리스트로 렌더링
{isLoading ? <A /> : <B />}삼항 연산자로 조건이 참일 때 <A />, 거짓일 때 <B />를 렌더링
{posts.length === 0 && <tr />}조건이 참일 때만 오른쪽 요소를 렌더링 (거짓이면 아무것도 그리지 않음)
onClick={() => handleDelete(post.id)}클릭 이벤트 리스너에 함수를 연결 (Step 4에서 사용)

3) key={post.id}는 왜 반드시 붙여야 하는가?
목록이 바뀌면 React는 이전 가상 DOM(Fiber) 트리와 새 트리를 비교(Diffing)해서 바뀐 부분만 실제 DOM에 반영합니다(2장 2.2 참고).
이때 key가 있으면 "id가 3인 행은 그대로 두고, id가 5인 행만 지운다"처럼 같은 데이터의 행을 정확히 추적하여 재사용합니다. key가 없거나 배열 인덱스(index)를 key로 쓰면 위치 번호를 기준으로 행을 재활용하므로, 맨 앞에 글을 추가하거나 중간 행을 삭제했을 때 입력 중이던 값·체크박스·포커스 같은 DOM 상태가 엉뚱한 행에 남는 심각한 버그가 생깁니다. 항상 서버가 준 고유 ID(post.id)를 key로 쓰십시오.

4) 조건부 삭제(? :, &&)와 CSS 숨김(display: none)의 차이
삼항 연산자나 &&로 요소를 숨기면 조건이 거짓일 때 요소를 **DOM에서 완전히 제거(언마운트)**하고, 참이 되면 새로 만듭니다.
반면 탭 화면처럼 내부 상태를 유지한 채 자주 켜고 끄는 UI는 CSS display: none을 쓰거나, React 19.2+에 도입된 <Activity mode={isVisible ? 'visible' : 'hidden'}> 컴포넌트를 사용합니다(13장에서 자세히 다룹니다).

5) useEffect(() => { loadPosts(); }, [])의 두 번째 인자 []
컴포넌트가 실제 DOM에 마운트된 직후 딱 1번 호출되도록 만드는 의존성 배열(Dependency Array)입니다. 만약 두 번째 인자 []를 아예 생략하면(useEffect(() => { loadPosts(); })), loadPosts가 setPosts를 부르고 → 리렌더링이 일어나고 → 다시 useEffect가 실행되는 무한 API 호출 루프에 빠집니다!

6) axios.get<{ items: Post[] }>의 응답 모양
백엔드 PostController의 GET /api/posts는 배열이 아니라 페이지 정보가 담긴 객체 { items, total, page, size, totalPages }를 반환합니다. 그래서 res.data가 아니라 res.data.items를 꺼냅니다.

🔬 심화(건너뛰어도 됨): Step 2의 엣지 케이스 세 가지
  • 개발 모드에서 loadPosts()가 왜 2번 호출되나요? (StrictMode): 개발자 도구 Network 탭을 보면 처음 화면을 열 때 /api/posts 요청이 2번 나가는 것을 볼 수 있습니다. 이는 src/main.tsx에 감싸진 <React.StrictMode>가 개발 모드에서만 컴포넌트를 일부러 마운트 → 언마운트 → 재마운트하여 정리(Cleanup) 누락 버그를 찾아내기 때문입니다. 운영 빌드(npm run build)에서는 정확히 1번만 실행됩니다.
  • 숫자 0과 && 연산자의 함정: JSX에서 {posts.length && <Table />}처럼 쓰면, posts.length가 0일 때 화면에 아무것도 안 나오는 것이 아니라 숫자 0이 그대로 텍스트로 출력됩니다! 자바스크립트에서 0 && <Table />의 평가 결과가 0이고, React는 숫자 0을 유효한 출력값으로 취급하기 때문입니다. 항상 {posts.length > 0 && <Table />}처럼 확실한 boolean 식으로 작성하십시오.
  • HTML 속성 이름 차이 (colSpan): HTML의 colspan="5"는 JSX에서 DOM 프로퍼티 표준에 따라 카멜케이스인 colSpan={5}로 작성합니다.

[Step 3] 제어 컴포넌트(value + onChange)로 등록 폼 구축 (Create) ​

이제 게시글을 등록하는 폼을 붙여봅시다.
과거 jQuery에서는 $('#input-title').val()로 일일이 DOM 값을 읽어야 했지만, React에서는 입력창의 value와 onChange를 상태 객체 form에 연결하여 실시간으로 동기화합니다.

  1. BoardApp.tsx 내부에 form 상태와 handleSubmit 함수를 추가합니다:
tsx
  // 등록 폼 상태 선언
  const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
  const [form, setForm] = useState({
    title: '',
    author: '',
    content: ''
  });

  // 등록 요청 함수
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault(); // 브라우저 기본 폼 제출(페이지 새로고침) 방지!

    // 백엔드 PostDto.CreateRequest는 title, author, content 모두 @NotBlank이므로
    // 하나라도 비어 있으면 서버가 400 Bad Request를 반환합니다. 화면에서 먼저 막아줍니다.
    if (!form.title.trim() || !form.author.trim() || !form.content.trim()) {
      alert('제목, 작성자, 내용을 모두 입력하세요.');
      return;
    }

    setIsSubmitting(true);
    try {
      const payload = {
        ...form,
        category: 'DEV'
      };
      const res = await axios.post<Post>('/api/posts', payload);

      // 🌟 핵심(불변성): 화면은 건드리지 않고 기존 배열 맨 앞에 새 글을 얹은 '새 배열'을 만든다!
      setPosts((prevPosts) => [res.data, ...prevPosts]);

      // 폼 입력창 초기화 (새 객체로 교체)
      setForm({ title: '', author: '', content: '' });
    } catch (err) {
      alert('게시글 등록에 실패했습니다.');
    } finally {
      setIsSubmitting(false);
    }
  }

🆕 처음 보는 문법: 폼 상태 객체 변경과 함수형 업데이트

  • setForm({ ...form, title: e.target.value }): 객체 상태 중 일부 속성만 바꿀 때도 불변성을 지켜야 합니다. ...form으로 기존 author, content를 그대로 복사한 뒤 title만 새 값으로 덮어쓴 새 객체를 만들어 넘깁니다.
  • setPosts((prevPosts) => [res.data, ...prevPosts]): setPosts([res.data, ...posts])라고 써도 동작하지만, prevPosts => ...처럼 함수형 업데이트를 쓰면 비동기 await가 끝난 시점의 가장 최신 배열 앞에 안전하게 새 글을 추가합니다(원리는 3장 3.2에서 자세히 다룹니다).
  • e: React.FormEvent<HTMLFormElement>: <form onSubmit>이 넘겨주는 이벤트 객체의 TypeScript 타입입니다. e.preventDefault()를 호출해 폼 제출 시 페이지가 새로고침되는 것을 막습니다.
  1. return ( ... ) 안의 <div className="card-header">...</div> 바로 아래, 로딩 분기({isLoading ? ...}) 위에 등록 폼 UI를 추가합니다:
tsx
      {/* 1. 제어 컴포넌트 등록 폼 (value + onChange) */}
      <form className="form-container" onSubmit={handleSubmit}>
        <div className="input-row">
          <input
            value={form.title}
            onChange={(e) => setForm({ ...form, title: e.target.value })}
            type="text"
            placeholder="제목 (XSS 공격 스크립트를 입력해 보세요)"
            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>
        <textarea
          value={form.content}
          onChange={(e) => setForm({ ...form, content: e.target.value })}
          placeholder="내용을 입력하세요"
          rows={3}
          required
        />
      </form>
  1. 폼 스타일이 frontend/src/BoardApp.css에 들어 있는지 확인합니다:
css
/* frontend/src/BoardApp.css 하단에 추가 */
.form-container { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin-bottom: 24px; }
.input-row { display: flex; gap: 10px; margin-bottom: 10px; }
.input-row input { flex: 1; padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px; }
.input-row .author-input { flex: 0 0 140px; }
.btn-submit { background: #10b981; color: white; border: none; padding: 8px 16px; border-radius: 6px; font-weight: 600; cursor: pointer; }
.btn-submit:disabled { background: #94a3b8; cursor: not-allowed; }
textarea { width: 100%; box-sizing: border-box; padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px; resize: vertical; }

📘 [기초 다지기] 제어 컴포넌트(Controlled Component)와 불변 배열 추가의 의미

1) value와 onChange의 양방향 호흡
<input value={form.title} onChange={(e) => setForm({ ...form, title: e.target.value })} />는 "상태 → 화면(value)"과 "화면 → 상태(onChange)" 두 방향을 연결합니다. 입력창의 값을 React 상태가 100% 통제하므로 제어 컴포넌트라고 부릅니다. jQuery의 $('#input-title').val()처럼 DOM을 뒤질 필요가 사라지고 언제든 form.title만 보면 됩니다. (React 19의 새로운 <form action>과 useActionState 방식은 1.5장에서 배웁니다.)

2) disabled={isSubmitting}: 중복 제출 방지
요청이 날아가는 동안 버튼을 비활성화하고 문구를 '등록 중...'으로 바꿔서, 사용자가 버튼을 연타해도 같은 글이 두 번 등록되지 않게 합니다. finally 블록에서 반드시 false로 되돌리는 것이 핵심입니다.

3) 목록 전체를 다시 조회하지 않고 [res.data, ...prevPosts]만 하는 이유
백엔드 POST /api/posts는 저장된 게시글(id, createdAt 포함)을 201 Created 응답 본문으로 돌려줍니다. 이미 완성된 객체를 받았으므로 목록 전체를 다시 조회할 필요 없이 기존 배열 맨 앞에 새 항목을 얹은 새 배열을 만들기만 하면 됩니다. (4장부터 페이지네이션이 붙으면 현재 페이지 건수를 맞추기 위해 등록 후 목록 재조회 방식으로 바뀝니다.)

💡 [확인하기 - XSS 공격 방어 검증!]:

  1. 브라우저 http://localhost:3000에서 제목에 아까 jQuery에서 성공했던 악의적인 스크립트를 그대로 입력해 보십시오:
    html
    <img src="x" onerror="alert('해킹');">
  2. 작성자와 내용(아무 문장이나)을 넣고 [게시글 등록] 버튼을 누릅니다.
  3. 결과: alert 경고창이 뜨지 않고, 문자열 그대로 테이블 최상단에 안전하게 텍스트로 출력되며 상단 건수 배지도 즉시 총 8건으로 올라갑니다!
    React가 {post.title}을 렌더링할 때 브라우저의 텍스트 노드로 안전하게 이스케이프하여 HTML 태그로 파싱되지 않도록 원천 차단하기 때문입니다.
🔬 심화(건너뛰어도 됨): React에서도 XSS가 뚫리는 3가지 예외 지점

JSX 중괄호 보간법({ })이 일반 텍스트 출력 시의 XSS를 막아 주지만, 다음 3가지 경우는 개발자가 직접 방어해야 합니다:

  1. dangerouslySetInnerHTML 남용: 리치 텍스트 에디터(위지윅) 본문을 <div dangerouslySetInnerHTML=&#123;&#123; __html: post.content &#125;&#125; />로 렌더링할 때는 이름 그대로 위험하므로 반드시 DOMPurify 같은 살균(Sanitization) 라이브러리를 거쳐야 합니다.
  2. 동적 URL 속성 바인딩: <a href={userProvidedUrl}>에 사용자가 javascript:alert(1)을 넣으면 클릭 시 스크립트가 실행됩니다. http:// 또는 https://로 시작하는지 검증해야 합니다.
  3. 서버 측 Stored XSS 방어 및 CSP: 방금 등록한 <img onerror=...> 문자열은 DB에 그대로 저장되어 있으므로, 레거시 화면(legacy-board.html)을 열면 여전히 스크립트가 실행됩니다. 백엔드 입출력 필터링과 Content-Security-Policy(CSP) 헤더 설정을 함께 해야 합니다.

[Step 4] 단 한 줄로 끝내는 삭제 기능 연결 (Delete) ​

과거 jQuery에서는 행을 지우기 위해 $(this).closest('tr').remove()로 DOM 요소를 수동 추적해서 뜯어내야 했고, 건수 배지 갱신을 깜빡해 버그가 났습니다.
React에서는 어떻게 할까요?

  1. BoardApp.tsx 내부에 handleDelete 함수를 작성합니다:
tsx
  async function handleDelete(id: number) {
    if (!window.confirm('정말 삭제하시겠습니까?')) return;
    try {
      await axios.delete(`/api/posts/${id}`);
      // 💡 배열에서 해당 ID만 제외한 '새 배열'을 만들면 화면의 <tr>도 사라지고 건수도 자동 갱신된다!
      setPosts((prevPosts) => prevPosts.filter((p) => p.id !== id));
    } catch (err) {
      alert('게시글 삭제에 실패했습니다.');
    }
  }

🆕 처음 보는 문법: 템플릿 리터럴과 filter

  • `/api/posts/${id}`: 백틱(`) 문자열 안의 ${ } 자리에 변수 값이 들어갑니다. '/api/posts/' + id와 같습니다.
  • prevPosts.filter((p) => p.id !== id): 배열을 돌면서 화살표 함수가 true를 반환한 요소만 모아 새 배열을 만듭니다. "id가 다른 글만 남긴다" = "id가 같은 글을 지운다"입니다. 새 배열(새 메모리 참조)을 setPosts에 넘기므로 React가 변경을 즉시 감지하고 화면을 다시 그립니다.
  1. JSX 테이블의 [삭제] 버튼에 클릭 이벤트를 연결합니다:
tsx
{/* 기존: <button className="btn-delete">삭제</button> */}
<button className="btn-delete" onClick={() => handleDelete(post.id)}>
  삭제
</button>

NOTE

왜 onClick={handleDelete(post.id)}라고 쓰면 안 되고 () =>를 앞에 붙이나요?
onClick={handleDelete(post.id)}처럼 함수 호출 결과를 바로 넣으면, 버튼을 클릭할 때가 아니라 화면이 그려지는 순간 모든 행의 handleDelete가 즉시 실행되어 버립니다! onClick={() => handleDelete(post.id)}처럼 화살표 함수로 한 번 감싸 넘겨야, 사용자가 실제로 버튼을 클릭했을 때 해당 행의 post.id를 인자로 넣어 호출됩니다.

💡 [확인하기 - 선언적 렌더링 체감]:
브라우저에서 방금 등록한 글의 [삭제] 버튼을 클릭해 보십시오. (댓글이 달린 3번 샘플 글은 6장에서 쓰므로 남겨 두십시오.)
setPosts(prev => prev.filter(...)) 한 줄로 배열 참조가 바뀌자마자:

  1. 해당 <tr>이 테이블에서 즉시 사라집니다.
  2. 상단 배지의 총 {posts.length}건 숫자가 자동으로 1 줄어듭니다(1.2 [실험 1] 버그 해결!).
  3. 만약 모든 행을 지우면 {posts.length === 0 && ...} 조건이 참이 되어 "등록된 게시글이 없습니다." 안내 행이 자동으로 나타납니다!

[Step 5] 1차 완성본 전체 소스 코드 확인 ​

우리가 단계별로 작성한 frontend/src/BoardApp.tsx의 전체 완성 코드입니다. 예제 패키지의 frontend/src/snapshots/BoardApp.ch01.tsx와 대조해 보십시오.

tsx
// frontend/src/BoardApp.tsx (Chapter 01 완성본)
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import './BoardApp.css';

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

export default function BoardApp() {
  const [posts, setPosts] = useState<Post[]>([]);
  const [isLoading, setIsLoading] = useState<boolean>(false);
  const [isSubmitting, setIsSubmitting] = useState<boolean>(false);

  const [form, setForm] = useState({
    title: '',
    author: '',
    content: ''
  });

  async function loadPosts() {
    setIsLoading(true);
    try {
      const res = await axios.get<{ items: Post[] }>('/api/posts?size=20');
      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((prevPosts) => prevPosts.filter((p) => p.id !== id));
    } 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 payload = { ...form, category: 'DEV' };
      const res = await axios.post<Post>('/api/posts', payload);
      setPosts((prevPosts) => [res.data, ...prevPosts]);
      setForm({ title: '', author: '', content: '' });
    } catch (err) {
      alert('게시글 등록에 실패했습니다.');
    } finally {
      setIsSubmitting(false);
    }
  }

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

  return (
    <div className="board-card">
      <div className="card-header">
        <h2>✅ [React 19.3 마이그레이션] 사내 기술 Q&A 게시판</h2>
        <span className="badge">총 {posts.length}건 · React 19.3</span>
      </div>

      {/* 1. 등록 폼 */}
      <form className="form-container" onSubmit={handleSubmit}>
        <div className="input-row">
          <input
            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>
        <textarea
          value={form.content}
          onChange={(e) => setForm({ ...form, content: e.target.value })}
          placeholder="내용을 입력하세요"
          rows={3}
          required
        />
      </form>

      {/* 2. 로딩 상태 분기 및 3. 목록 테이블 */}
      {isLoading ? (
        <div className="loading-state">데이터를 불러오는 중입니다...</div>
      ) : (
        <table className="data-table">
          <thead>
            <tr>
              <th style={{ width: '60px' }}>ID</th>
              <th>제목</th>
              <th style={{ width: '100px' }}>작성자</th>
              <th style={{ width: '120px' }}>작성일</th>
              <th style={{ width: '70px' }}>동작</th>
            </tr>
          </thead>
          <tbody>
            {posts.map((post) => (
              <tr key={post.id}>
                <td>{post.id}</td>
                <td>
                  <span className="post-title">{post.title}</span>
                </td>
                <td>{post.author}</td>
                <td>{post.createdAt}</td>
                <td>
                  <button
                    className="btn-delete"
                    onClick={() => handleDelete(post.id)}
                  >
                    삭제
                  </button>
                </td>
              </tr>
            ))}
            {posts.length === 0 && (
              <tr>
                <td colSpan={5} className="empty-state">
                  등록된 게시글이 없습니다.
                </td>
              </tr>
            )}
          </tbody>
        </table>
      )}
    </div>
  );
}

1.5 jQuery vs React 19.3 패러다임 비교 요약 ​

비교 항목jQuery 레거시 (legacy-board.html)React 19.3 선언적 컴포넌트 (BoardApp.tsx)
개발 방식명령형(Imperative): "어디를 찾아서 어떻게 바꿔라"선언형(Declarative): "이 상태일 때 화면은 이 모양이어야 한다"
화면 갱신수동 조작: $('tbody').append(...), $tr.remove()자동 반응: setPosts([res.data, ...prev]), setPosts(prev.filter(...))
보안(XSS)취약: 따옴표 문자열 결합(innerHTML) 시 XSS 즉시 감염안전: { } 보간 시 텍스트 노드로 자동 이스케이프 (dangerouslySetInnerHTML 제외)
DOM 결합도마크업에 종속: 태그·클래스가 바뀌면 JS 셀렉터가 깨짐관심사 분리: 비즈니스 로직은 메모리의 상태(useState)만 조작

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

방금 만든 BoardApp.tsx에 손으로 직접 작은 기능 두 가지를 추가하며 상태와 화면의 연결을 체득해 봅시다.

미션: [새로고침] 버튼과 본문 글자 수 실시간 카운터 추가하기 ​

  1. .card-header의 배지 옆에 <button onClick={loadPosts} disabled={isLoading}>🔄 새로고침</button> 버튼을 추가해 보십시오. 로딩 중에는 버튼이 비활성화되어야 합니다.
  2. 등록 폼의 <textarea> 바로 아래에 현재 입력한 본문 글자 수를 실시간으로 보여주는 <div style=&#123;&#123; textAlign: 'right', fontSize: '12px', color: '#64748b' &#125;&#125;>{form.content.length} / 500자</div> 표시줄을 추가해 보십시오. (만약 500자를 넘기면 글자 색상이 빨간색 #ef4444로 바뀌게 삼항 연산자를 써 보십시오!)
💡 미션 정답 코드 확인하기
tsx
// 1. .card-header 내부 수정
<div className="card-header">
  <h2>✅ [React 19.3 마이그레이션] 사내 기술 Q&A 게시판</h2>
  <div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
    <span className="badge">총 {posts.length}건 · React 19.3</span>
    <button
      type="button"
      onClick={loadPosts}
      disabled={isLoading}
      style={{ padding: '4px 10px', borderRadius: '6px', cursor: 'pointer' }}
    >
      {isLoading ? '로딩 중...' : '🔄 새로고침'}
    </button>
  </div>
</div>

// 2. <textarea> 바로 아래에 추가
<div
  style={{
    textAlign: 'right',
    fontSize: '12px',
    marginTop: '4px',
    color: form.content.length > 500 ? '#ef4444' : '#64748b'
  }}
>
  {form.content.length} / 500자
</div>

jQuery였다면 $('#textarea').on('input', function() { $('#counter').text($(this).val().length)... })처럼 별도의 이벤트 리스너와 DOM 조작 코드를 짜야 했겠지만, React에서는 이미 form.content 상태가 있으므로 {form.content.length} 한 줄로 끝납니다!


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

핵심 3줄 요약

  1. 화면에 보여 줄 값은 useState에 담고, JSX는 그 상태를 선언적으로 그리기만 합니다.
  2. 목록은 posts.map + key={post.id}, 조건 화면은 ? : 또는 &&, 입력창은 value + onChange, 이벤트는 onClick/onSubmit으로 연결합니다.
  3. 등록·삭제는 DOM이 아니라 배열 상태의 참조([res.data, ...prev], prev.filter(...))를 새로 만들어 교체하는 것으로 끝나며, { } 출력은 자동 이스케이프되어 XSS 스크립트가 실행되지 않습니다.

바뀐 파일

파일무엇이 바뀌었나
frontend/src/BoardApp.tsx처음부터 새로 작성: 목록 조회(Read), 등록 폼(Create), 삭제(Delete) (= snapshots/BoardApp.ch01.tsx)

자주 하는 실수 (Troubleshooting)

증상원인해결
'게시글 로딩에 실패했습니다.' 경고창, Vite 터미널에 http proxy error백엔드(8080)가 꺼져 있음백엔드를 먼저 실행 (부록 C)
posts.unshift(res.data); setPosts(posts);를 했는데 화면이 안 바뀜기존 배열을 직접 수정해 메모리 참조가 그대로임setPosts(prev => [res.data, ...prev])로 새 배열 전달
화면을 열자마자 모든 행의 삭제 확인 창이 뜸onClick={handleDelete(post.id)}처럼 함수를 즉시 실행함onClick={() => handleDelete(post.id)}로 화살표 함수 전달
Network 탭에 /api/posts 요청이 끝없이 수백 번 날아감useEffect에 두 번째 인자 빈 배열 []을 빼먹음useEffect(() => { loadPosts(); }, [])
등록 버튼을 누르면 페이지가 새로고침되고 입력이 사라짐handleSubmit 첫 줄에 e.preventDefault()가 빠짐e.preventDefault() 추가

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

  1. 1.2 [실험 1]의 "총 7건" 배지가 안 줄어드는 버그를 React에서는 JSX에 무엇을 쓰면 막을 수 있을까요? 삭제할 때 따로 갱신 코드가 필요할까요?
  2. setForm({ title: e.target.value })처럼 ...form을 빼먹고 쓰면 어떤 버그가 생길까요?
  3. 제목에 <img src="x" onerror="alert('해킹');">을 넣고 등록했을 때 jQuery 화면에서는 경고창이 뜨고 React 화면에서는 뜨지 않았습니다. 두 화면의 차이는 어디에 있을까요?
  4. key={post.id} 대신 배열 인덱스를 key로 쓰면 어떤 상황에서 문제가 될까요?
셀프 체크 정답
  1. JSX에 총 {posts.length}건을 쓰면 됩니다. setPosts로 배열이 바뀌면 컴포넌트 함수가 다시 실행되며 이 부분도 자동으로 다시 그려지므로, 삭제 코드에서 건수를 따로 갱신할 필요가 없습니다. 빈 안내 행도 {posts.length === 0 && ...}이라 같은 이유로 자동입니다.
  2. setForm은 객체를 합쳐 주지 않고 통째로 교체합니다. 따라서 ...form 없이 { title: e.target.value }만 넘기면 기존에 입력해 둔 author와 content 속성이 날아가 버립니다.
  3. jQuery 코드는 제목을 '<td>' + p.title + '</td>' 문자열로 이어 붙여 HTML 태그로 해석시켰고, React의 {post.title}은 텍스트 노드로 이스케이프해 넣어 태그로 해석되지 않습니다.
  4. 중간 행을 삭제하거나 맨 앞에 글을 끼워 넣을 때입니다. 인덱스 key는 위치 번호 기준으로 행을 재사용하므로, 행 안의 입력값·체크 상태·포커스 같은 DOM 상태가 엉뚱한 행에 남을 수 있습니다.

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

우리는 방금 140줄 남짓한 코드로 레거시 jQuery 화면을 React 19.3 컴포넌트 BoardApp.tsx로 직접 옮겼습니다.
그런데 막상 실무 기능을 조금 더 넣으려고 하면 손이 멈춥니다:

  • "글을 등록한 뒤 제목 입력창으로 포커스($('#title').focus())를 자동으로 옮기려면 어떻게 하지?"
  • "클릭한 행만 파란색으로 강조하고, 삭제 버튼을 눌렀을 때 행 클릭 이벤트가 같이 터지는 건 어떻게 막지?"
  • "라디오 버튼, 체크박스, 셀렉트 박스와 React 19의 새로운 <form action>은 어떻게 쓰지?"

이어지는 Chapter 01.5에서 게시판에 이 필수 기본기들을 하나씩 직접 붙여 보며 손에 익히고, Chapter 02에서 React 렌더링 엔진의 내부 원리로 들어가겠습니다.

Moon HomeLab Engineering Knowledge Hub