Skip to content

Chapter 03. 훅의 규칙과 클로저 트랩(Stale Closure), 그리고 useEffectEvent의 정복 ​

"1장에서 만든 BoardApp.tsx에 상세 보기/수정과 실시간 검색을 추가해 보자. 그런데 비동기 함수나 타이머 안에서 상태를 읽는 순간, 왜 옛날 값에 갇혀 버리는 '오래된 클로저(Stale Closure)' 버그가 터지는가?"


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

개념한 줄 요약설명 위치
렌더링 스냅샷(Snapshot) 모델컴포넌트 함수가 실행될 때마다 그 순간의 독립된 지역 변수 스코프가 만들어진다3.0 (3.2 버그의 "왜?")
객체 불변 병합 { ...prev, ...next }상세 게시글 수정 시 기존 상태를 보존하며 속성을 덮어쓰기3.1 🆕 박스
오래된 클로저 (Stale Closure)과거 렌더링에서 생성된 함수가 옛날 상태 변수를 계속 붙잡고 있는 버그3.2 (직접 재현)
함수형 업데이트 setState(prev => ...)클로저에 의존하지 않고 React로부터 최신 상태를 안전하게 받아 변경하기3.3
React 19.2+ useEffectEventuseEffect를 재실행하지 않으면서도 항상 최신 Props/State를 읽는 이벤트 분리 훅3.4
useEffect 클린업과 디바운스(setTimeout)입력이 멈춘 뒤에만 API를 호출하고 이전 타이머를 취소하기3.5
AbortController와 axios.isCancel이미 출발한 이전 HTTP 검색 요청을 취소해 레이스 컨디션 방어하기3.5 🆕 박스

TIP

🏁 이 장의 시작 파일
frontend/src/BoardApp.tsx가 snapshots/BoardApp.ch01.tsx와 같은 상태에서 시작합니다(막혔다면 그 파일을 복사해 오십시오). 1.5장 실습을 했다면 BoardApp.ch01-5.tsx 상태 그대로 이어 가도 됩니다. 단, 이 장의 완성본 snapshots/BoardApp.ch03.tsx는 1장 기준이라 1.5장 기능(포커스, 라디오, 번호 열 등)이 들어 있지 않으니 대조할 때 그 차이는 무시하십시오. 이 장에서 손대는 파일: frontend/src/BoardApp.tsx


3.0 렌더링은 '스냅샷(사진)'이다: 컴포넌트 함수는 어떻게 실행되는가? ​

이 장에서는 상세 보기와 실시간 검색을 붙이다가 "화면에 있는 값은 분명히 바뀌었는데, 함수 안에서 찍어 본 값은 옛날 그대로 멈춰 있는" 기이한 버그를 직접 만나게 됩니다(3.2). 그 버그의 "왜?"에 대한 해답이 이 절에 있습니다.

2장과 2.5장에서 확인했듯, React 컴포넌트는 상태가 바뀔 때마다 함수 전체가 처음부터 다시 호출됩니다:

tsx
// 1번째 렌더링 (마운트 직후: posts는 빈 배열 [])
function BoardApp() {
  const posts = []; // <- 1번째 렌더링 스코프의 상수 posts
  // ...
}

// 2번째 렌더링 (API 응답 도착 후: posts는 7건 배열 [...])
function BoardApp() {
  const posts = [Post1, Post2, ...]; // <- 2번째 렌더링 스코프의 '완전히 새로운' 상수 posts!
  // ...
}

여기서 반드시 기억할 세 가지:

  1. 매 렌더링은 그 순간의 독립된 사진(Snapshot)입니다. 2번째 렌더링이 일어났다고 해서 1번째 렌더링 때 만들어진 const posts = [] 상수가 바뀌는 것이 아닙니다. 2번째 렌더링이라는 새로운 함수 호출 안에서 새로운 const posts 상수가 만들어질 뿐입니다.
  2. 함수(이벤트 핸들러, useEffect 콜백, setTimeout 콜백)는 자신이 태어난 렌더링의 변수들을 기억(Closure)합니다. 만약 1번째 렌더링 때 만든 타이머 함수를 계속 재사용하면, 그 타이머는 영원히 1번째 렌더링의 빈 배열 posts = []만 바라봅니다! 이것이 3.2에서 만날 **오래된 클로저(Stale Closure)**의 뿌리입니다.
  3. 모든 훅(useState, useEffect, useEffectEvent 등)은 반드시 컴포넌트 최상위에서만 호출해야 합니다. 조건문(if)이나 이벤트 핸들러(selectPost) 안에서는 절대 훅을 부를 수 없습니다(2.5장 2.5.4 참고).

3.1 BoardApp.tsx에 상세 보기 및 인라인 수정 기능 추가하기 ​

1장에서 만든 BoardApp.tsx는 목록 조회와 등록, 삭제만 가능했습니다.
이제 사용자가 목록에서 게시글 제목을 클릭하면 하단에 상세 내용이 펼쳐지고, 제목과 본문을 수정할 수 있는 기능을 추가해 봅시다.

frontend/src/BoardApp.tsx 파일을 열고, 아래 코드를 추가합니다:

1) 컴포넌트 내부에 상세 게시글 상태(currentPost) 및 조회 함수 추가 ​

tsx
// frontend/src/BoardApp.tsx 의 BoardApp 컴포넌트 내부

// 선택된 상세 게시글 상태 (null이면 선택되지 않은 상태)
const [currentPost, setCurrentPost] = useState<Post | null>(null);

// 제목 클릭 시 서버에서 최신 상세 데이터를 가져와 채워넣는 함수
async function selectPost(id: number) {
  try {
    const res = await axios.get<Post>(`/api/posts/${id}`);
    setCurrentPost(res.data);
  } catch (err) {
    alert('상세 정보를 불러오지 못했습니다.');
  }
}

🆕 처음 보는 문법: 유니온 상태 useState<Post | null>(null)과 불변 수정

  • useState<Post | null>(null): 처음에는 아무 글도 선택되지 않았으므로 초기값은 null이고, 글을 클릭하면 Post 객체가 들어옵니다. 그래서 타입을 Post | null로 지정합니다.
  • setCurrentPost({ ...currentPost, title: e.target.value }): 상세 카드에서 제목을 고칠 때는 기존 currentPost의 모든 속성(id, author, content, createdAt)을 ...currentPost로 펼쳐 복사한 뒤 title만 덮어쓴 새 객체를 넣어 리렌더링을 일으킵니다.

2) JSX 테이블 제목에 클릭 이벤트 바인딩 ​

테이블 내부의 게시글 제목 <span>에 클릭 이벤트와 clickable 클래스를 붙입니다:

tsx
{/* 변경 전: <span className="post-title">{post.title}</span> */}
<span
  className="post-title clickable"
  onClick={() => selectPost(post.id)}
>
  {post.title}
</span>

3) JSX 테이블 하단에 상세 보기 및 수정 뷰 추가 ​

테이블 </table> 태그(로딩 삼항 연산자가 끝나는 )} 바로 아래)에 다음 상세 카드 UI를 배치합니다:

tsx
      {/* 4. 상세 보기 및 인라인 수정 뷰 */}
      {currentPost && (
        <div className="detail-card">
          <div className="detail-header">
            <h3>🔍 게시글 상세 보기 (#{currentPost.id})</h3>
            <button
              type="button"
              className="btn-close"
              onClick={() => setCurrentPost(null)}
            >
              닫기
            </button>
          </div>

          <div className="edit-row">
            <label>제목:</label>
            <input
              type="text"
              value={currentPost.title}
              onChange={(e) =>
                setCurrentPost({ ...currentPost, title: e.target.value })
              }
            />
          </div>

          <div className="edit-row">
            <label>작성자:</label>
            <span>{currentPost.author}</span>
          </div>

          <div className="edit-row">
            <label>내용:</label>
            <textarea
              rows={4}
              value={currentPost.content}
              onChange={(e) =>
                setCurrentPost({ ...currentPost, content: e.target.value })
              }
            />
          </div>
        </div>
      )}

4) 상세 카드 스타일을 frontend/src/BoardApp.css 하단에 추가 ​

css
.clickable { cursor: pointer; }
.clickable:hover { color: #10b981; text-decoration: underline; }
.detail-card { margin-top: 24px; padding: 16px; border: 1px solid #e2e8f0; border-radius: 8px; background: #f8fafc; }
.detail-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
.detail-header h3 { margin: 0; font-size: 16px; }
.btn-close { background: #ffffff; border: 1px solid #cbd5e1; padding: 4px 10px; border-radius: 4px; cursor: pointer; }
.edit-row { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 10px; }
.edit-row label { flex: 0 0 60px; font-weight: 600; color: #475569; padding-top: 6px; }
.edit-row input { flex: 1; padding: 6px 10px; border: 1px solid #cbd5e1; border-radius: 6px; }

💡 [확인하기]:
파일을 저장하고 브라우저 http://localhost:3000에서 아무 게시글 제목을 클릭해 보십시오.
하단에 상세 카드가 펼쳐지며 제목과 내용이 인풋에 채워집니다.


3.2 [직접 터뜨려보는 실습] 가장 흔한 실무 버그: 오래된 클로저(Stale Closure)의 함정 ​

상세 화면에서 사용자가 글을 고치는 동안 3초마다 현재 작성 중인 제목을 콘솔에 자동 백업(Auto-save 로그)하는 타이머를 붙여 달라는 요구사항이 들어왔습니다.
많은 개발자가 "타이머는 한 번만 켜면 되니까 의존성 배열을 []로 두자"라며 다음과 같이 무심코 코드를 짭니다.

직접 BoardApp.tsx의 selectPost 함수 아래에 아래 버그 실험 코드를 작성하고 실행해 보십시오:

tsx
// 🚨 [실험 코드] Stale Closure 버그 체험: currentPost가 열려 있을 때 3초마다 제목 백업 로그 찍기
useEffect(() => {
  if (!currentPost) return;

  const timer = setInterval(() => {
    console.log(
      `[자동 백업 타이머] 글 #${currentPost.id}의 현재 제목: "${currentPost.title}"`
    );
  }, 3000);

  return () => clearInterval(timer);
}, [currentPost?.id]); // 👈 글을 클릭해 id가 바뀔 때만 타이머를 켜려고 id만 넣음!

그리고 상세 카드의 <div className="detail-header">...</div> 바로 아래에 화면 표시용 문장도 한 줄 추가합니다:

tsx
<p style={{ fontSize: '13px', color: '#475569' }}>
  화면의 실시간 제목: <strong>[{currentPost.title}]</strong> (콘솔 F12의 3초 백업 로그와 비교해 보세요!)
</p>

이제 브라우저 개발자 도구(F12 → Console 탭)를 열고, 아무 게시글 제목을 클릭한 뒤 상세 카드의 제목 입력창에 ' - 수정중!'이라고 타이핑해 보십시오:

  • 화면의 실시간 제목: 타이핑하는 즉시 [... - 수정중!]으로 잘 바뀝니다.
  • 콘솔의 [자동 백업 타이머] 로그: 3초마다 찍히는데, 아무리 제목을 고쳐도 처음 클릭했을 때의 옛날 제목이 영원히 계속 찍힙니다!

왜 이런 일이 벌어지는가? ​

3.0에서 본 **"렌더링은 스냅샷이다"**를 떠올려 보십시오.

  1. 사용자가 3번 글을 처음 클릭했을 때(예: 렌더링 #2), currentPost.id가 undefined -> 3으로 바뀌었으므로 useEffect가 실행되어 setInterval 타이머 함수를 등록합니다. 이때 타이머 함수는 **렌더링 #2의 currentPost 객체(초기 제목)**를 클로저로 붙잡습니다.
  2. 사용자가 제목 인풋에 ' - 수정중!'을 타이핑하면(렌더링 #3, #4...), 매번 새 currentPost 객체가 만들어져 화면은 새 제목을 그립니다.
  3. 하지만 currentPost?.id는 여전히 3으로 같기 때문에 React는 useEffect를 다시 실행하지 않습니다!
  4. 결국 3초마다 도는 setInterval 콜백은 렌더링 #2 당시에 캡처해 둔 옛날 currentPost 스냅샷에 갇혀서 수정 전 제목만 영원히 읽고 있는 것입니다. 이것이 바로 React 실무 버그 1위인 **오래된 클로저(Stale Closure)**입니다!

📸 비유: 10년 전 졸업앨범 사진(Stale Closure) vs 실시간 영상통화
지금 내 모습은 30대인데, 서랍 속에 넣어둔 10년 전 고등학교 졸업앨범 사진을 꺼내 보며 "왜 내 턱수염이 사진에 안 자라지?" 하고 의아해하는 것과 같습니다. 졸업앨범(과거 렌더링의 클로저)은 찍히던 그 순간의 모습만 영구 보존합니다!


3.3 해결책 1: 의존성 배열 정직하게 채우기 & 함수형 업데이트 (prev => ...) ​

이 오래된 클로저 문제를 푸는 가장 기본적인 방법은 두 가지입니다:

1) 상태를 변경할 때: 함수형 업데이트 (setState(prev => ...)) ​

타이머나 비동기 함수 안에서 **"이전 상태를 바탕으로 새 상태를 계산"**할 때는 클로저 변수를 읽지 말고 prev => ... 함수를 넘기십시오:

tsx
// ❌ 위험: 타이머가 캡처한 옛날 count(0)에 +1을 해서 영원히 1만 됨
// setCount(count + 1);

// ✅ 안전: React가 슬롯에 저장된 '진짜 최신 값'을 prev로 넘겨줌! (2.5장 Step 3에서 직접 구현한 기능)
setCount((prev) => prev + 1);

2) 값을 읽어야 할 때: 의존성 배열에 읽는 값을 정직하게 넣기 ​

만약 useEffect의 의존성 배열을 [currentPost]로 바꾸면, 제목을 고칠 때마다 이전 타이머가 해제되고 새 currentPost를 캡처한 새 타이머가 켜집니다.
그런데 여기서 딜레마가 생깁니다! 타자를 한 글자 칠 때마다 3초 타이머가 리셋(clearInterval 후 재시작)되어 버려서, 사용자가 타자를 계속 치는 동안에는 3초가 영원히 안 지나가 백업 로그가 한 번도 안 찍힙니다!

"타이머(Effect)는 리셋하지 않으면서, 타이머 안에서는 항상 최신 currentPost.title을 읽을 수는 없을까?"


3.4 해결책 2: React 19.2+ 최신 표준 useEffectEvent ​

과거(React 18 이하)에는 이 딜레마를 풀려고 useRef에 최신 값을 매번 복사해 넣는 꼼수를 쓰거나 ESLint 경고를 강제로 껐습니다.
**React 19.2+**에서는 이 문제를 정면으로 해결하는 공식 훅 **useEffectEvent**가 도입되었습니다!

useEffectEvent로 감싼 함수는 **"이펙트 내부에서 호출되지만, 의존성 배열에는 포함되지 않으면서 항상 가장 최신 렌더링의 Props와 State를 바라보는 비반응형(Non-reactive) 함수"**를 만듭니다.

  1. 먼저 BoardApp.tsx 맨 위의 import 줄에 useEffectEvent를 추가합니다:
tsx
import React, { useState, useEffect, useEffectEvent } from 'react';
  1. 3.2의 실험 코드를 아래 코드로 교체합니다:
tsx
  // ✅ React 19.2+ 표준: 타이머를 재시작하지 않고도 항상 최신 currentPost를 읽는다!
  const onAutoBackupLog = useEffectEvent(() => {
    if (!currentPost) return;
    console.log(
      `[자동 백업 타이머] 글 #${currentPost.id}의 최신 제목: "${currentPost.title}"`
    );
  });

  useEffect(() => {
    if (!currentPost?.id) return;

    const timer = setInterval(() => {
      onAutoBackupLog(); // 최신 스냅샷의 값을 안전하게 출력!
    }, 3000);

    return () => clearInterval(timer);
  }, [currentPost?.id]); // 글 ID가 바뀔 때만 타이머 재설정 (타이핑 중에는 타이머가 리셋되지 않음!)

💡 [확인하기]: 이제 게시글을 클릭하고 제목 인풋에 글자를 계속 타이핑해 보십시오. 타이머가 끊기지 않고 정확히 3초마다 돌아가면서, 콘솔에 방금 타이핑한 따끈따끈한 최신 제목이 찍힙니다!

🆕 처음 보는 문법: useEffectEvent의 사용 규칙 2가지

  1. useEffect(또는 useLayoutEffect) 내부에서만 호출해야 합니다. 일반 버튼의 onClick 핸들러로 넘기면 안 됩니다.
  2. useEffect의 의존성 배열([...])에 useEffectEvent 함수를 넣지 마십시오. React 19용 ESLint 플러그인(eslint-plugin-react-hooks)도 이 함수를 의존성 배열에서 자동으로 제외해 줍니다.

3.5 [실습] 비동기 검색어 감시와 네트워크 낭비 방어 (useEffect & AbortController) ​

이제 BoardApp.tsx 상단에 실시간 검색창을 두고, 사용자가 검색어를 입력할 때마다 서버 API를 호출하는 기능을 붙여 봅시다. 세 단계로 나눠 문제를 먼저 보고 → 하나씩 막습니다.

단계하는 일막는 문제
1단계가장 단순한 useEffect로 검색(문제 체험: 요청 폭주, 레이스 컨디션)
2단계디바운스(setTimeout) + 클린업(clearTimeout)글자마다 요청이 나가는 네트워크 낭비
3단계AbortController로 비행 중인 요청 취소늦게 도착한 옛 응답이 화면을 덮어쓰는 경쟁 상태

1단계: 가장 단순한 검색과 경쟁 상태 체험 ​

JSX의 <div className="card-header">...</div> 바로 아래(등록 폼 <form> 위)에 검색창을 배치합니다:

tsx
      <div className="search-bar">
        <input
          value={searchKeyword}
          onChange={(e) => setSearchKeyword(e.target.value)}
          placeholder="실시간 검색어 입력..."
        />
      </div>

스타일도 frontend/src/BoardApp.css에 추가합니다:

css
.search-bar { margin-bottom: 16px; }
.search-bar input { width: 100%; box-sizing: border-box; padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px; }

그리고 컴포넌트 내부에 searchKeyword 상태와 가장 단순한 useEffect를 넣습니다(기존 마운트용 useEffect(() => { loadPosts(); }, [])는 지웁니다. 이제 이 검색 useEffect가 마운트 시 초기 목록 조회까지 겸합니다):

tsx
  const [searchKeyword, setSearchKeyword] = useState('');

  // ❌ 1단계: 문제를 보기 위한 단순한 코드
  useEffect(() => {
    axios
      .get<{ items: Post[] }>('/api/posts', {
        params: { keyword: searchKeyword, size: 20 }
      })
      .then((res) => setPosts(res.data.items || []));
  }, [searchKeyword]);

💡 [확인하기: 요청 폭주]: 개발자 도구(F12) Network 탭을 열고 필터에 posts를 입력한 뒤, 검색창에 Spring을 쳐 보십시오. keyword=S, keyword=Sp, … keyword=Spring까지 글자마다 요청이 하나씩, 6건 나갑니다. 사용자는 마지막 결과만 필요한데 서버는 6번 일했습니다.

눈에 잘 보이지 않는 더 큰 문제도 숨어 있습니다. 응답은 보낸 순서대로 도착한다는 보장이 없습니다. 'Spring'을 치다가 지우고 'React'를 입력했는데, 먼저 보낸 'Spring' 요청이 서버 사정으로 'React' 요청보다 늦게 도착하면, 뒤늦은 'Spring' 결과가 setPosts를 덮어써 버립니다. 검색창에는 'React'가 있는데 목록은 'Spring' 결과인 상태, 이것이 **경쟁 상태(Race Condition)**입니다.

2단계: 디바운스로 요청 줄이기 (setTimeout + 클린업) ​

**디바운스(Debounce)**는 "타자를 치는 동안에는 기다렸다가, 입력이 250ms 동안 멈추면 그때 한 번만 요청"하는 기법입니다. useEffect가 반환하는 **클린업 함수(return () => clearTimeout(timerId))**는 searchKeyword가 바뀌어 다음 이펙트가 실행되기 직전에 호출됩니다(2.5장 Step 6에서 직접 구현했던 바로 그 기능!).

3단계: 이미 보낸 요청 취소하기 (AbortController)까지 한 번에 완성! ​

디바운스는 아직 보내지 않은 요청만 막습니다. 'Spring' 요청이 이미 출발해 응답을 기다리는 중에 사용자가 'React'를 입력하면 경쟁 상태는 여전히 가능하므로, 클린업 함수에서 AbortController.abort()로 이미 출발한 이전 HTTP 요청까지 취소합니다.

1단계의 useEffect를 지우고 아래 최종 코드로 교체하십시오:

tsx
  const [searchKeyword, setSearchKeyword] = useState('');

  useEffect(() => {
    const controller = new AbortController(); // 1. 이 요청 전용 취소 리모컨 생성

    // 2. 250ms 디바운스 타이머: 타자를 멈추고 0.25초가 지나야 실제 API 요청 발사
    const timerId = setTimeout(async () => {
      setIsLoading(true);
      try {
        const res = await axios.get<{ items: Post[] }>('/api/posts', {
          params: { keyword: searchKeyword, size: 20 },
          signal: controller.signal // 3. 요청을 취소 리모컨에 연결
        });
        setPosts(res.data.items || []);
      } catch (err) {
        if (axios.isCancel(err)) {
          // 4. 우리가 취소한 이전 요청이면 에러 알림 없이 조용히 무시
          console.log('이전 검색 요청이 정상적으로 취소(Abort)되었습니다.');
          return;
        }
        console.error('검색 중 오류 발생:', err);
      } finally {
        if (!controller.signal.aborted) {
          setIsLoading(false);
        }
      }
    }, 250);

    // 🌟 5. 클린업 함수: 새 검색어가 들어오면 이전 타이머와 이미 출발한 요청을 모두 취소!
    return () => {
      clearTimeout(timerId);
      controller.abort();
    };
  }, [searchKeyword]);

🆕 처음 보는 문법: AbortController와 axios.isCancel

  • new AbortController(): 브라우저 표준 내장 "요청 취소 리모컨"입니다. controller.signal을 axios 요청 옵션에 넘겨 두고, 나중에 controller.abort()를 부르면 진행 중이던 네트워크 요청이 즉시 취소됩니다. jQuery의 var xhr = $.ajax(...); xhr.abort();와 같은 역할입니다.
  • axios.isCancel(err): AbortController로 취소된 요청은 catch 블록으로 빠지므로, 일반 서버 장애와 구분해 조용히 return합니다.

💡 [개발자 도구(F12)에서 확인하기]:

  1. Network 탭 상단의 속도 설정을 Slow 3G로 바꿉니다.
  2. 검색창에 Spring을 치고 잠깐 멈춥니다. 0.25초 뒤 keyword=Spring 요청이 나갑니다.
  3. 그 요청이 아직 회색(pending)일 때 검색창을 지우고 React를 입력합니다. 앞의 Spring 요청이 빨간색 (canceled)로 즉시 취소되고, 마지막 요청의 결과만 화면에 반영됩니다! 확인이 끝나면 속도 설정을 No throttling으로 되돌리십시오.

3.6 3장 완성본 전체 소스 코드 확인 (BoardApp.tsx) ​

예제 패키지의 frontend/src/snapshots/BoardApp.ch03.tsx와 대조해 보십시오.

tsx
// frontend/src/BoardApp.tsx (Chapter 03 완성본)
import React, { useState, useEffect, useEffectEvent } 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 [searchKeyword, setSearchKeyword] = useState<string>('');
  const [currentPost, setCurrentPost] = useState<Post | null>(null);

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

  // 상세 게시글 조회
  async function selectPost(id: number) {
    try {
      const res = await axios.get<Post>(`/api/posts/${id}`);
      setCurrentPost(res.data);
    } catch (err) {
      alert('상세 정보를 불러오지 못했습니다.');
    }
  }

  // React 19.2+ useEffectEvent: 타이머 재시작 없이 최신 currentPost.title 참조
  const onAutoBackupLog = useEffectEvent(() => {
    if (!currentPost) return;
    console.log(
      `[자동 백업 타이머] 글 #${currentPost.id}의 최신 제목: "${currentPost.title}"`
    );
  });

  useEffect(() => {
    if (!currentPost?.id) return;
    const timer = setInterval(() => {
      onAutoBackupLog();
    }, 3000);
    return () => clearInterval(timer);
  }, [currentPost?.id]);

  // 실시간 검색 (250ms 디바운스 + AbortController 요청 취소)
  useEffect(() => {
    const controller = new AbortController();

    const timerId = setTimeout(async () => {
      setIsLoading(true);
      try {
        const res = await axios.get<{ items: Post[] }>('/api/posts', {
          params: { keyword: searchKeyword, size: 20 },
          signal: controller.signal
        });
        setPosts(res.data.items || []);
      } catch (err) {
        if (axios.isCancel(err)) {
          console.log('이전 검색 요청이 정상적으로 취소(Abort)되었습니다.');
          return;
        }
        console.error('검색 중 오류 발생:', err);
      } finally {
        if (!controller.signal.aborted) {
          setIsLoading(false);
        }
      }
    }, 250);

    return () => {
      clearTimeout(timerId);
      controller.abort();
    };
  }, [searchKeyword]);

  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 (currentPost?.id === id) setCurrentPost(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 payload = { ...form, category: 'DEV' };
      const res = await axios.post<Post>('/api/posts', payload);
      setPosts((prev) => [res.data, ...prev]);
      setForm({ title: '', author: '', content: '' });
    } catch (err) {
      alert('게시글 등록 실패');
    } finally {
      setIsSubmitting(false);
    }
  }

  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>

      {/* 실시간 검색창 */}
      <div className="search-bar">
        <input
          value={searchKeyword}
          onChange={(e) => setSearchKeyword(e.target.value)}
          placeholder="실시간 검색어 입력 (250ms 디바운스 + AbortController 적용)..."
        />
      </div>

      {/* 등록 폼 */}
      <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>

      {/* 목록 테이블 */}
      {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 clickable"
                    onClick={() => selectPost(post.id)}
                  >
                    {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>
      )}

      {/* 상세 보기 및 인라인 수정 뷰 */}
      {currentPost && (
        <div className="detail-card">
          <div className="detail-header">
            <h3>🔍 게시글 상세 보기 (#{currentPost.id})</h3>
            <button
              type="button"
              className="btn-close"
              onClick={() => setCurrentPost(null)}
            >
              닫기
            </button>
          </div>
          <p style={{ fontSize: '13px', color: '#475569' }}>
            화면의 실시간 제목: <strong>[{currentPost.title}]</strong>
          </p>
          <div className="edit-row">
            <label>제목:</label>
            <input
              type="text"
              value={currentPost.title}
              onChange={(e) =>
                setCurrentPost({ ...currentPost, title: e.target.value })
              }
            />
          </div>
          <div className="edit-row">
            <label>작성자:</label>
            <span>{currentPost.author}</span>
          </div>
          <div className="edit-row">
            <label>내용:</label>
            <textarea
              rows={4}
              value={currentPost.content}
              onChange={(e) =>
                setCurrentPost({ ...currentPost, content: e.target.value })
              }
            />
          </div>
        </div>
      )}
    </div>
  );
}

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

미션: 상세 카드의 수정 내용을위쪽 목록(posts) 배열에도 즉시 반영하는 [임시 반영] 버튼 만들기 ​

지금은 상세 카드에서 제목을 고쳐도 currentPost 상태만 바뀔 뿐, 위쪽 테이블 목록(posts)의 제목은 바뀌지 않습니다.
상세 카드 하단에 <button type="button" onClick={applyDetailToPosts}>목록에 반영</button> 버튼을 추가하고, 클릭 시 posts 배열에서 currentPost.id와 일치하는 항목을 currentPost로 불변 교체(map)하는 applyDetailToPosts 함수를 작성해 보십시오!

💡 미션 정답 코드 확인하기
tsx
function applyDetailToPosts() {
  if (!currentPost) return;
  // map으로 순회하며 id가 일치하는 게시글만 currentPost로 교체한 새 배열 생성!
  setPosts((prevPosts) =>
    prevPosts.map((p) => (p.id === currentPost.id ? { ...currentPost } : p))
  );
  alert('위쪽 목록 테이블에 수정된 제목이 반영되었습니다!');
}

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

핵심 3줄 요약

  1. 컴포넌트 함수는 렌더링마다 새로운 스냅샷을 만들므로, 오래 전에 생성된 타이머·비동기 콜백이 과거 렌더링의 변수를 붙잡고 있는 **오래된 클로저(Stale Closure)**를 항상 경계해야 합니다.
  2. 이전 상태를 기반으로 상태를 갱신할 때는 **함수형 업데이트(setState(prev => ...))**를 쓰고, useEffect를 재실행하지 않으면서 최신 값을 읽어야 할 때는 React 19.2+의 **useEffectEvent**를 사용합니다.
  3. 값이 바뀔 때마다 API를 부르는 useEffect에는 클린업 함수(return () => ...)를 두어 **디바운스(clearTimeout)**와 **이전 요청 취소(AbortController.abort())**를 함께 처리합니다.

바뀐 파일

파일무엇이 바뀌었나
frontend/src/BoardApp.tsxcurrentPost 상세 카드와 selectPost, useEffectEvent 기반 자동 백업 타이머, 실시간 검색창(searchKeyword + 디바운스 + AbortController) (= snapshots/BoardApp.ch03.tsx)

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

  1. useEffect(() => { setInterval(() => console.log(posts.length), 3000); }, [])를 둔 채 새 글을 3건 등록하면 콘솔에는 무엇이 찍히나요? 렌더링 스냅샷과 연결해 설명해 보십시오.
  2. 위 문제를 해결하려고 의존성 배열을 [currentPost]로 바꾸면 왜 3초 자동 백업 타이머가 제대로 동작하지 않을까요? 그리고 useEffectEvent는 이를 어떻게 해결하나요?
  3. 3.5의 3단계 코드에서 Spring을 치고 멈춰 요청이 나간 뒤, 응답이 오기 전에 검색창을 지우고 React를 빠르게 입력했습니다. Network 탭에는 어떤 요청이 어떤 상태로 남을까요?
  4. 디바운스(clearTimeout)만 있고 AbortController가 없으면 어떤 상황에서 경쟁 상태(Race Condition)가 발생할 수 있을까요?
셀프 체크 정답
  1. 초기 마운트 시점의 개수(0 또는 7)만 영원히 찍힙니다. 의존성 배열이 []이라 첫 번째 렌더링에서만 setInterval이 만들어졌고, 그 콜백 함수가 첫 번째 렌더링 스냅샷의 posts 변수를 클로저로 붙잡고 있기 때문입니다.
  2. 타이핑할 때마다 currentPost가 바뀌어 매 글자마다 clearInterval 후 타이머가 새로 시작되므로, 타자를 치는 동안에는 3초가 영원히 지나지 않습니다. useEffectEvent를 쓰면 타이머 이펙트는 글 ID가 바뀔 때만 재시작하면서 콜백 내부에서는 항상 최신 렌더링의 currentPost.title을 읽을 수 있습니다.
  3. 먼저 출발한 keyword=Spring 요청은 클린업의 controller.abort()에 의해 (canceled)가 되고, 입력이 멈춘 뒤 나간 keyword=React 요청 1건만 정상 응답되어 화면에 반영됩니다.
  4. 이전 요청이 이미 발사된 뒤(250ms가 지난 뒤) 새 검색어가 들어오는 경우입니다. clearTimeout은 아직 발사 전인 타이머만 취소할 수 있어서, 이미 출발한 옛 요청이 새 요청보다 늦게 도착하면 새 결과를 덮어쓸 수 있습니다.

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

useEffectEvent와 useEffect 클린업을 적용해, BoardApp.tsx는 이제 오래된 클로저나 경쟁 상태 없이 실시간 검색과 상세 보기/수정까지 처리합니다.

하지만 기능이 하나씩 추가될 때마다 코드 상태를 보십시오:

  • posts 목록 조회 로직
  • form 등록 로직
  • currentPost 상세 보기 및 수정 로직
  • searchKeyword 실시간 감시 및 비동기 취소 로직

이 모든 상태와 함수가 BoardApp.tsx 단 하나의 파일 안에 벌써 230줄 넘게 섞여 있습니다.
여기에 다음 장에서 페이지네이션, 다중 필터 드롭다운, 폼 글자 수 유효성 검사까지 붙는다면 어떻게 될까요? 이어지는 Chapter 04에서는 하나의 컴포넌트가 모든 책임을 떠안고 거대해지는 Fat Component의 위기를 직접 체험하고 진단해 보겠습니다.

Moon HomeLab Engineering Knowledge Hub