[아이티센 부트캠프] React 3

이언덕·2026년 5월 21일

아이티센 부트캠프

목록 보기
105/115
post-thumbnail

리액트 렌더링 흐름 이해

React를 이해할 때 가장 먼저 잡아야 하는 기준은 화면을 직접 고치는 방식이 아니라는 점이다.
React는 현재 화면에 무엇이 보여야 하는지 먼저 계산하고, 계산 결과를 실제 브라우저 화면에 반영한다.


기존 방식에서는 개발자가 직접 DOM 요소를 찾아 글자나 내용을 바꿨다.
하지만 React에서는 state를 바꾸면 React가 컴포넌트를 다시 실행하고, 새 화면 구조를 계산한 뒤 필요한 부분만 실제 DOM에 반영한다.
즉, React의 화면 변경 기준은 직접 DOM을 고치는 것이 아니라 state 변경이다.


1-1. 리액트가 화면을 처음 만드는 흐름

처음 실행되는 위치

React 앱은 보통 main.jsx 또는 main.js에서 시작된다.
이 파일에서 실제 브라우저 화면에 붙을 위치를 찾고, 그 위치를 React가 관리할 수 있게 만든다.


기본 흐름은 아래 코드처럼 볼 수 있다.

// MainRenderEntry.jsx
import { StrictMode } from 'react'; // 개발 중 검사 기능을 사용한다.
import { createRoot } from 'react-dom/client'; // React 화면 시작점을 만든다.
import App from './App.jsx'; // 화면에 보여줄 최상위 컴포넌트다.

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <App />
  </StrictMode>
);

document.getElementById('root')는 index.html 안에 있는 root 영역을 찾는다.
createRoot()는 그 영역을 React가 관리할 수 있는 시작점으로 만든다.
render()는 그 시작점 안에 어떤 컴포넌트를 화면으로 만들지 알려준다.


여기서 <App />은 최상위 컴포넌트다.
React는 <App />부터 실행하면서 안쪽에 있는 자식 컴포넌트까지 차례대로 확인한다.


화면이 만들어지는 순서

React가 화면을 만들 때는 바로 DOM을 바꾸지 않는다.
먼저 화면에 무엇이 있어야 하는지 계산한다.


전체 흐름은 아래 순서로 이어진다.

  • App 컴포넌트를 호출한다.
  • 컴포넌트가 반환하는 JSX 결과를 확인한다.
  • 안쪽에 있는 자식 컴포넌트를 확인한다.
  • 자식 컴포넌트에 필요한 props를 전달한다.
  • useState의 초기값을 준비한다.
  • useEffect를 등록한다.
  • 최종적으로 만들 UI 구조를 계산한다.

이 단계는 실제 화면을 바꾸는 과정이 아니라, 화면에 어떤 구조가 필요할지 미리 계산하는 과정이다.


처음 렌더링에서는 이전에 만들어진 DOM과 비교할 것이 거의 없다.
그래서 React는 필요한 DOM 노드를 새로 만들고, #root 안에 넣는다.
이때 실제로 생성된 DOM 노드는 appendChild() 같은 방식으로 브라우저 화면에 붙게 된다.


appendChild()는 어떤 부모 요소 안에 새로운 자식 요소를 추가하는 동작이다.
초보자 기준에서는 React가 계산한 화면 조각을 실제 브라우저 영역 안에 끼워 넣는 과정으로 이해하면 된다.

React 앱은 index.html의 root 영역에서 시작된다.
main.jsx에서 root를 찾고, createRoot()로 React가 관리할 시작점을 만든 뒤, root.render()로 <App />을 렌더링한다.
그 다음 React Element Tree를 만들고, Render Phase에서 화면 구조를 계산한다.
계산이 끝나면 Commit Phase에서 실제 DOM을 만들거나 수정하고, 브라우저가 그 결과를 화면에 그린다.
마지막으로 화면에 반영된 뒤 useEffect()가 실행된다.


1-2. Render Phase와 Commit Phase의 차이

Render Phase

Render Phase는 화면에 무엇이 있어야 하는지 계산하는 단계다.
이 단계에서는 실제 브라우저 화면을 직접 바꾸지 않는다.


예를 들어 버튼을 눌러 숫자가 0에서 1로 바뀌어야 한다고 해도, Render Phase에서 바로 화면의 글자를 바꾸는 것이 아니다.
먼저 새 화면에서는 count가 1로 보여야 한다는 구조를 계산한다.


정리하면 Render Phase는 아래 역할을 한다.

  • 컴포넌트 함수를 실행한다.
  • JSX 결과를 확인한다.
  • 필요한 자식 컴포넌트를 확인한다.
  • state와 props를 기준으로 새 화면 구조를 계산한다.
  • 이전 결과와 비교할 준비를 한다.

Render Phase는 실제 DOM을 바꾸는 단계가 아니라 새 화면 구조를 계산하는 단계다.


Commit Phase

Commit Phase는 계산된 결과를 실제 DOM에 반영하는 단계다.
Render Phase에서 계산한 결과를 보고, 실제 브라우저 화면에 필요한 변경을 적용한다.


처음 렌더링에서는 화면에 기존 결과가 거의 없기 때문에 React가 필요한 DOM 노드를 만들고 #root 안에 삽입한다.
두 번째 렌더링부터는 이전 결과와 새 결과를 비교해서 바뀐 부분만 반영한다.


정리하면 Commit Phase는 아래 역할을 한다.

  • 새로 필요한 DOM 노드를 만든다.
  • 필요 없는 DOM 노드를 제거한다.
  • 바뀐 속성이나 텍스트를 실제 DOM에 반영한다.
  • 브라우저가 다시 그릴 수 있도록 변경 결과를 넘긴다.

React가 계산한 결과를 실제 화면에 적용하는 시점이 Commit Phase다.


useEffect가 실행되는 시점

useEffect()는 컴포넌트가 화면에 반영된 뒤 실행된다.
그래서 useEffect()는 화면을 만드는 핵심 계산이라기보다, 화면이 만들어진 뒤 실행되는 부가 작업에 가깝다.


예를 들어 서버에서 데이터를 가져오거나, 타이머를 등록하거나, 브라우저 이벤트를 연결하는 작업은 화면이 먼저 준비된 뒤 실행되는 편이 자연스럽다.
이런 작업을 useEffect() 안에 넣는다.


이 흐름을 순서로 정리하면 아래와 같다.

  • 컴포넌트 함수가 실행된다.
  • JSX가 만들어진다.
  • React가 화면 구조를 계산한다.
  • 실제 DOM에 반영한다.
  • 브라우저가 화면을 그린다.
  • 그 뒤 useEffect()가 실행된다.

이 순서를 알면 useEffect()가 왜 렌더링 중에 바로 실행되는 함수가 아닌지 이해할 수 있다.


1-3. StrictMode가 개발 모드에서 한 번 더 실행될 수 있는 이유

StrictMode의 역할

StrictMode는 개발 중에 문제를 더 빨리 찾기 위한 검사 도구다.
React 앱을 만들 때 <App />을 <StrictMode>로 감싸면 개발 모드에서 추가 검사가 실행될 수 있다.


이때 컴포넌트 렌더링이나 Effect가 한 번 더 실행되는 것처럼 보일 수 있다.
이 동작은 오류가 아니라 개발 중에 버그를 찾기 위한 의도적인 동작이다.


StrictMode의 추가 실행은 개발 환경에서 컴포넌트가 순수하게 작성되었는지 확인하기 위한 검사다.


개발 모드와 배포 모드의 차이

개발 모드에서는 React가 일부 동작을 한 번 더 실행하면서 코드에 문제가 없는지 확인할 수 있다.
하지만 배포용 production 빌드에서는 같은 방식으로 반복 실행되지 않는다.


초보자가 여기서 자주 헷갈리는 부분은 “내 코드가 두 번 실행되는 버그인가?”이다.
개발 모드에서만 보이는 검사 동작이라면 실제 배포 환경과는 다르게 보일 수 있다.


정리하면 아래처럼 이해하면 된다.

  • 개발 모드에서는 StrictMode 때문에 렌더링이나 Effect가 추가 실행될 수 있다.
  • 이 동작은 버그를 찾기 위한 검사다.
  • 배포용 production 빌드에서는 같은 방식으로 반복 실행되지 않는다.
  • 그래서 Effect 안의 코드가 중복 실행되어도 안전하도록 작성하는 습관이 필요하다.

특히 서버 요청, 타이머 등록, 이벤트 등록 같은 작업은 중복 실행되면 문제가 생길 수 있다.
그래서 뒤에서 배우는 cleanup 정리가 중요해진다.


1-4. state 변경이 화면 변경으로 이어지는 과정

기존 DOM 조작 방식

기존 방식에서는 개발자가 직접 DOM 요소를 찾아서 내용을 바꿨다.
예를 들어 특정 문단의 글자를 바꾸려면 해당 요소를 찾고, textContent나 innerHTML을 직접 변경했다.


이 방식은 화면 요소를 직접 건드리는 방식이다.
작은 예제에서는 단순하지만, 화면이 복잡해질수록 어떤 요소를 언제 바꿔야 하는지 관리하기 어려워진다.


React 방식

React에서는 화면을 직접 고치지 않는다.
대신 state를 바꾼다.


state가 바뀌면 React는 컴포넌트 함수를 다시 실행한다.
그리고 새로운 JSX 결과를 계산한 뒤, 이전 결과와 비교해서 실제로 달라진 부분만 DOM에 반영한다.


흐름은 아래처럼 이어진다.

  • 사용자가 버튼을 클릭한다.
  • 이벤트 핸들러가 실행된다.
  • setState 계열 함수가 호출된다.
  • state가 변경된다.
  • 컴포넌트 함수가 다시 실행된다.
  • 새로운 JSX가 만들어진다.
  • 이전 결과와 새 결과를 비교한다.
  • 필요한 부분만 실제 DOM에 반영한다.

React에서 중요한 것은 “화면을 어떻게 고칠까?”보다 “어떤 state가 되면 어떤 화면이 보여야 할까?”를 생각하는 것이다.

기존 방식은 개발자가 직접 DOM 요소를 찾아 화면을 수정하는 흐름에 가깝다.
React 방식은 사용자가 이벤트를 발생시키면 setState()로 값을 바꾸고, 그 값에 맞춰 컴포넌트가 다시 실행된다.
새로운 JSX가 계산되면 React가 이전 화면과 비교해서 실제로 바뀐 부분만 DOM에 반영한다.
그래서 React에서는 화면을 직접 수정하는 코드보다 state를 올바르게 바꾸는 코드가 더 중요하다.


1-5. 함수형 컴포넌트의 재실행

함수형 컴포넌트는 한 번만 실행되는 함수가 아니다

함수형 컴포넌트는 화면을 한 번 만들고 끝나는 함수가 아니다.
state나 props가 바뀌면 React는 해당 컴포넌트 함수를 다시 호출한다.


여기서 props는 부모 컴포넌트가 자식 컴포넌트에게 전달하는 값이다.
초보자 기준에서는 부모가 자식에게 넘겨주는 입력값으로 이해하면 된다.


state는 컴포넌트 안에서 관리하는 값이다.
버튼 클릭, 입력값 변경, 서버 응답 같은 상황에 따라 바뀔 수 있고, state가 바뀌면 화면도 다시 계산된다.


재실행이 필요한 이유

컴포넌트 함수가 다시 실행되어야 새로운 화면 구조를 만들 수 있다.
예를 들어 count가 0일 때 화면과 count가 1일 때 화면은 서로 다르다.


React는 count가 바뀌면 컴포넌트를 다시 호출해서 count가 1인 상태의 새 JSX를 만든다.
그리고 이전 JSX 결과와 새 결과를 비교해서 실제로 바뀐 부분만 DOM에 반영한다.


정리하면 아래 흐름이다.

  • 처음 렌더링 때 컴포넌트 함수가 실행된다.
  • state 초기값을 기준으로 JSX가 반환된다.
  • 사용자가 버튼을 누른다.
  • state가 바뀐다.
  • 컴포넌트 함수가 다시 실행된다.
  • 바뀐 state를 기준으로 새 JSX가 반환된다.
  • React가 필요한 부분만 화면에 반영한다.

함수형 컴포넌트의 재실행은 오류가 아니라 React가 최신 state를 화면에 반영하기 위한 정상 흐름이다.


1-6. 기본 예시: 렌더링 횟수 확인하기

예제의 목적

이 예제는 버튼을 눌러 state를 바꾸면 컴포넌트 함수가 다시 실행된다는 것을 확인하기 위한 코드다.
count는 화면에 보여줄 값이고, renderCount는 컴포넌트 함수가 몇 번 실행되었는지 확인하기 위한 값이다.

// RenderFlowPage.jsx
import { useState } from 'react'; // state를 사용하기 위해 가져온다.

let renderCount = 0; // 컴포넌트 실행 횟수를 확인하기 위한 변수다.

export default function RenderFlowPage() {
  const [count, setCount] = useState(0); // count의 초기값은 0이다.

  renderCount += 1; // 컴포넌트가 실행될 때마다 1씩 증가한다.

  return (
    <section>
      <h2>렌더링 흐름 확인</h2>
      <p>count: {count}</p>
      <p>렌더링 횟수: {renderCount}</p>
      <button onClick={() => setCount(count + 1)}>
        증가
      </button>
    </section>
  );
}

이 코드에서 처음 화면이 열리면 count는 0이다.
컴포넌트 함수가 한 번 실행되므로 renderCount도 증가한다.


사용자가 증가 버튼을 누르면 setCount(count + 1)이 실행된다.
count 상태가 바뀌었기 때문에 React는 RenderFlowPage 함수를 다시 호출한다.
함수가 다시 호출되면 renderCount += 1도 다시 실행된다.


버튼을 눌렀을 때 흐름

버튼 클릭 흐름은 아래처럼 이어진다.

  • 사용자가 증가 버튼을 클릭한다.
  • onClick 안의 함수가 실행된다.
  • setCount(count + 1)이 호출된다.
  • count 값이 변경된다.
  • React가 컴포넌트 함수를 다시 실행한다.
  • renderCount가 증가한다.
  • 새 count 값이 화면에 표시된다.

이 예제의 핵심은 setCount()가 단순히 숫자만 바꾸는 함수가 아니라는 점이다.
setCount()는 React에게 “상태가 바뀌었으니 화면을 다시 계산해야 한다”라고 알려주는 역할을 한다.


renderCount를 볼 때 주의할 점

renderCount는 컴포넌트 밖에 선언되어 있다.
그래서 컴포넌트 함수가 다시 실행되어도 값이 다시 0으로 초기화되지 않는다.


다만 개발 모드에서 <StrictMode>가 적용되어 있다면 렌더링 횟수가 예상보다 더 많이 증가한 것처럼 보일 수 있다.
이는 컴포넌트가 순수하게 작성되었는지 확인하기 위한 개발용 검사 동작이다.
실제 배포용 production 빌드에서 같은 방식으로 반복 실행된다는 뜻은 아니다.


또한 실제 코드에서 렌더링 횟수를 안정적으로 추적할 때는 보통 useRef()를 사용한다.
여기서는 아직 useRef()를 본격적으로 배우기 전이기 때문에, 함수형 컴포넌트가 다시 실행된다는 흐름을 확인하는 용도로만 이해하면 된다.


1-7. state를 직접 수정하면 안 되는 이유

React가 상태 변경을 알아차리는 기준

React는 setState() 계열 함수를 통해 상태 변경을 감지한다.
함수형 컴포넌트에서는 보통 useState()가 반환하는 변경 함수가 이 역할을 한다.


예를 들어 아래 코드에서 setPosts는 posts 상태를 바꾸는 함수다.
posts 배열 자체를 직접 수정하는 것이 아니라, 새 배열을 만들어 setPosts()에 전달해야 한다.

// AddPostStateExample.jsx
import { useState } from 'react'; // 배열 상태를 관리하기 위해 가져온다.

export default function AddPostStateExample() {
  const [posts, setPosts] = useState([]); // 게시글 목록을 빈 배열로 시작한다.

  function addPost(newPost) {
    setPosts([...posts, newPost]); // 기존 배열을 복사하고 새 게시글을 뒤에 추가한다.
  }

  return (
    <button onClick={() => addPost('새 게시글')}>
      게시글 추가
    </button>
  );
}

setPosts([...posts, newPost])는 기존 posts 배열을 직접 바꾸지 않는다.
...posts로 기존 배열 안의 값을 새 배열에 복사하고, 그 뒤에 newPost를 추가한다.


이렇게 하면 React는 이전 배열과 새로운 배열이 다르다고 판단할 수 있다.
여기서 다르다는 말은 단순히 배열 안의 값이 달라졌다는 뜻만이 아니다.
기존 배열을 그대로 다시 쓰는 것이 아니라, 새 배열을 만들어 전달했다는 뜻이다.


배열이나 객체는 안쪽 값도 중요하지만, React 입장에서는 이전 상태와 새 상태가 같은 대상을 가리키는지 다른 대상을 가리키는지도 중요하다.
그래서 상태를 바꿀 때는 기존 배열이나 객체를 직접 고치지 않고, 새 배열이나 새 객체를 만들어 전달해야 한다.


잘못된 방식

배열을 직접 수정하는 방식은 피해야 한다.

// WrongAddPostExample.jsx
import { useState } from 'react'; // state를 사용하기 위해 가져온다.

export default function WrongAddPostExample() {
  const [posts, setPosts] = useState([]); // 게시글 목록 상태다.

  function addPost(newPost) {
    posts.push(newPost); // 기존 배열을 직접 수정한다.
    setPosts(posts); // 같은 배열을 다시 전달한다.
  }

  return (
    <button onClick={() => addPost('새 게시글')}>
      게시글 추가
    </button>
  );
}

이 방식은 기존 배열 자체를 직접 바꾼다.
겉으로는 배열 안에 값이 추가된 것처럼 보이지만, React 입장에서는 같은 배열 객체가 다시 전달된 상황이 될 수 있다.


쉽게 말하면 상자 안에 물건을 하나 더 넣었지만, React에게는 같은 상자를 다시 건네준 것과 비슷하다.
그래서 변경을 안정적으로 감지하기 어렵다.
특히 배열이나 객체처럼 내부 값이 여러 개 들어 있는 자료는 직접 수정하지 않는 습관이 중요하다.


올바른 방식

배열에 항목을 추가할 때는 새 배열을 만들어야 한다.

// CorrectAddPostExample.jsx
import { useState } from 'react'; // state를 사용하기 위해 가져온다.

export default function CorrectAddPostExample() {
  const [posts, setPosts] = useState([]); // 게시글 목록 상태다.

  function addPost(newPost) {
    setPosts([...posts, newPost]); // 새 배열을 만들어 상태를 변경한다.
  }

  return (
    <button onClick={() => addPost('새 게시글')}>
      게시글 추가
    </button>
  );
}

이 방식은 기존 배열을 그대로 건드리지 않는다.
기존 배열의 값을 복사해서 새 배열을 만들고, 새 게시글을 추가한 뒤 그 새 배열을 setPosts()에 전달한다.


배열이나 객체 상태를 바꿀 때는 기존 값을 직접 수정하지 않고 새 배열이나 새 객체를 만들어 전달해야 한다.


객체도 같은 기준을 따른다

배열뿐 아니라 객체도 직접 수정하면 안 된다.
예를 들어 사용자 정보 객체가 있을 때 user.name = '둘리'처럼 직접 바꾸는 방식은 피해야 한다.


객체를 바꿀 때도 기존 객체를 복사하고, 바꿀 값만 덮어쓴 새 객체를 만들어야 한다.

// CorrectObjectStateExample.jsx
import { useState } from 'react'; // 객체 상태를 관리하기 위해 가져온다.

export default function CorrectObjectStateExample() {
  const [user, setUser] = useState({ name: '고길동', age: 30 }); // 사용자 객체 상태다.

  function changeName() {
    setUser({
      ...user, // 기존 객체의 값을 복사한다.
      name: '둘리', // name만 새 값으로 바꾼다.
    });
  }

  return (
    <button onClick={changeName}>
      이름 변경
    </button>
  );
}

...user는 기존 객체의 값을 새 객체에 복사한다.
그 다음 name: '둘리'를 적으면 기존 값 중 name만 새 값으로 바뀐다.


이 방식은 기존 객체를 직접 수정하지 않는다.
그래서 React가 새 객체를 받았다고 판단하고 화면을 다시 계산할 수 있다.


핵심 정리

React는 화면을 직접 수정하는 방식이 아니라 state를 기준으로 화면을 다시 계산하는 방식으로 동작한다.
처음 렌더링에서는 App 컴포넌트부터 실행해 필요한 화면 구조를 계산하고, 실제 DOM에 반영한다.


Render Phase는 화면 구조를 계산하는 단계이고, Commit Phase는 계산 결과를 실제 DOM에 반영하는 단계다.
useEffect()는 화면 반영이 끝난 뒤 실행되는 부가 작업이다.


개발 모드에서 StrictMode를 사용하면 렌더링이나 Effect가 한 번 더 실행되는 것처럼 보일 수 있다.
이 동작은 버그가 아니라 문제를 미리 찾기 위한 개발용 검사다.


함수형 컴포넌트는 state나 props가 바뀌면 다시 실행된다.
이 재실행을 통해 React는 새로운 JSX를 만들고, 이전 결과와 비교해서 필요한 부분만 화면에 반영한다.


마지막으로 배열이나 객체 상태는 직접 수정하면 안 된다.
React가 변경을 안정적으로 감지할 수 있도록 새 배열이나 새 객체를 만들어 setState 계열 함수에 전달해야 한다.




훅 전체 개념 잡기

Hook은 함수 컴포넌트에서 React 기능을 사용할 수 있게 해주는 함수다.
함수 컴포넌트는 화면을 반환하는 함수처럼 보이지만, 실제 개발에서는 화면에 보여줄 값도 관리해야 하고, 화면이 그려진 뒤 실행할 작업도 처리해야 한다.


예를 들어 버튼을 누르면 숫자가 바뀌어야 한다.
화면이 처음 열릴 때 서버에서 데이터를 가져와야 할 수도 있다.
특정 input에 자동으로 커서를 넣어야 할 수도 있다.
이런 기능을 함수 컴포넌트 안에서 사용할 수 있게 해주는 도구가 Hook이다.
즉, Hook은 함수 컴포넌트가 상태 관리, 렌더링 이후 작업, DOM 접근, 로직 재사용 같은 기능을 사용할 수 있게 해주는 함수다.


2-1. 훅이 필요한 이유

함수 컴포넌트에서 리액트 기능을 쓰기 위해 필요하다

Hook은 함수 컴포넌트에서 state, 생명주기 처리, ref, context, 성능 최적화 같은 React 기능을 사용할 수 있게 해준다.
여기서 state는 화면에 영향을 주는 값이다.
예를 들어 숫자 카운터의 현재 숫자, 입력창에 적은 글자, 서버에서 받아온 게시글 목록 같은 값이 state가 될 수 있다.


생명주기 처리는 컴포넌트가 화면에 나타나고, 바뀌고, 사라지는 흐름에 맞춰 특정 작업을 실행하는 것을 의미한다.
초보자 기준에서는 화면이 처음 열릴 때, 값이 바뀔 때, 화면에서 사라질 때 필요한 작업을 처리하는 흐름으로 이해하면 된다.


ref는 렌더링과 직접 관련 없는 값을 기억하거나 실제 DOM 요소를 가리킬 때 사용한다.
context는 여러 컴포넌트가 같은 값을 함께 사용할 수 있게 해주는 기능이다.
성능 최적화는 불필요한 계산이나 함수 생성을 줄여 화면이 더 효율적으로 동작하게 만드는 작업이다.


예전에는 이런 기능을 사용하려면 클래스 컴포넌트를 사용해야 하는 경우가 많았다.
하지만 Hook을 사용하면 클래스 컴포넌트 없이도 함수 컴포넌트 안에서 필요한 기능을 사용할 수 있다.


훅이 없으면 생기는 문제

함수 컴포넌트가 단순히 JSX만 반환한다면 화면을 그리는 일은 할 수 있다.
하지만 실제 화면은 대부분 값이 계속 바뀐다.


예를 들어 아래 같은 상황을 생각할 수 있다.

  • 버튼을 누르면 숫자가 증가해야 한다.
  • 사용자가 입력한 글자를 화면에 바로 보여줘야 한다.
  • 화면이 처음 열릴 때 서버에서 데이터를 가져와야 한다.
  • 타이머를 시작했다가 화면이 사라질 때 정리해야 한다.
  • 특정 input에 자동으로 포커스를 줘야 한다.

이런 작업은 단순히 JSX를 반환하는 것만으로는 처리하기 어렵다.
Hook은 이런 문제를 해결하기 위해 함수 컴포넌트 안에 React의 여러 기능을 연결해준다.


Hook을 사용하면 함수 컴포넌트에서도 데이터 관리, 렌더링 이후 작업, DOM 접근, 로직 재사용을 할 수 있다.

React에서 자주 사용하는 Hook은 각각 맡은 역할이 다르다.
useState는 화면에 보여줄 값을 관리하고, useEffect는 화면이 반영된 뒤 실행할 작업을 처리한다.
useRef는 렌더링과 직접 관련 없는 값을 기억하거나 실제 DOM 요소를 가리킬 때 사용한다.
useMemo와 useCallback은 불필요한 계산과 함수 재생성을 줄일 때 사용한다.
useContext는 여러 컴포넌트가 공통 값을 함께 사용할 때 사용하고, useReducer는 상태 변경 규칙이 복잡할 때 사용한다.


2-2. 주요 훅을 구분하는 기준

화면에 보여줄 값은 상태로 관리한다

useState는 화면에 보여줄 값을 관리할 때 사용하는 Hook이다.
값이 바뀌면 컴포넌트가 다시 렌더링되고, 바뀐 값이 화면에 반영된다.


예를 들어 버튼을 누를 때 숫자가 올라가는 화면을 만든다면 현재 숫자는 state로 관리하는 것이 자연스럽다.
사용자가 입력창에 적은 글자를 화면에 보여줘야 한다면 그 입력값도 state로 관리할 수 있다.


정리하면 useState는 아래 상황에 적합하다.

  • 화면에 직접 보여줘야 하는 값
  • 값이 바뀌면 화면도 같이 바뀌어야 하는 경우
  • 버튼 클릭, 입력값 변경처럼 사용자 동작에 따라 바뀌는 값

useState의 핵심은 값 변경이 화면 변경으로 이어진다는 점이다.
그래서 화면에 보여줄 값은 보통 useState로 관리한다.


화면 반영 후 실행할 작업은 이펙트로 처리한다

useEffect는 렌더링이 끝난 뒤 실행할 작업을 등록할 때 사용하는 Hook이다.
화면이 만들어진 뒤 서버에서 데이터를 가져오거나, 타이머를 등록하거나, 브라우저 이벤트를 연결할 때 사용한다.


예를 들어 게시글 목록 화면이 처음 열렸을 때 서버에서 게시글 데이터를 가져와야 한다면 useEffect() 안에서 API 요청을 보낼 수 있다.
또 1초마다 시간이 바뀌는 시계를 만든다면 useEffect() 안에서 타이머를 등록하고, 컴포넌트가 사라질 때 타이머를 정리할 수 있다.


정리하면 useEffect는 아래 상황에 적합하다.

  • 화면이 처음 나타난 뒤 실행해야 하는 작업
  • 특정 값이 바뀐 뒤 다시 실행해야 하는 작업
  • 컴포넌트가 사라질 때 정리해야 하는 작업
  • 서버 API, 타이머, 이벤트, localStorage, WebSocket처럼 React 바깥과 연결되는 작업

useEffect는 다음 세트에서 가장 자세히 다룬다.
여기서는 화면이 그려진 뒤 실행되는 작업을 맡는 Hook이라고 먼저 잡으면 된다.


렌더링과 무관한 값은 참조로 기억한다

useRef는 값은 기억해야 하지만, 그 값이 바뀐다고 화면을 다시 그릴 필요는 없을 때 사용하는 Hook이다.
또 실제 DOM 요소를 직접 가리킬 때도 사용한다.


예를 들어 타이머 ID는 화면에 보여줄 값이 아니다.
하지만 타이머를 멈출 때 필요하므로 어딘가에 기억해두어야 한다.
이런 값은 useState보다 useRef가 더 적합하다.


또 특정 input에 자동으로 포커스를 주고 싶다면 실제 input 요소를 가리킬 방법이 필요하다.
이때도 useRef를 사용할 수 있다.


정리하면 useRef는 아래 상황에 적합하다.

  • 값은 기억해야 하지만 화면에 보여줄 필요는 없는 경우
  • 값이 바뀌어도 리렌더링이 필요 없는 경우
  • 타이머 ID, 이전 값, 임시 플래그 같은 값을 저장하는 경우
  • input, video, div 같은 실제 DOM 요소에 접근해야 하는 경우

useState와 useRef는 둘 다 값을 저장할 수 있다.
하지만 useState는 값 변경이 화면 변경으로 이어지고, useRef는 값이 바뀌어도 화면을 다시 그리지 않는다는 차이가 있다.


복잡한 상태 변경 규칙은 리듀서로 관리한다

useReducer는 상태 변경 규칙이 복잡할 때 사용하는 Hook이다.
단순히 값 하나를 바꾸는 정도라면 useState로 충분하다.
하지만 여러 값이 함께 바뀌거나, 변경 종류가 많아지면 useState만으로는 코드가 흩어질 수 있다.


예를 들어 이름, 나이, 이메일처럼 입력값 여러 개를 한 번에 관리해야 하는 폼을 생각할 수 있다.
각 입력값마다 따로 useState를 만들 수도 있지만, 입력값이 많아질수록 상태 변경 코드가 길어진다.
이럴 때 useReducer를 사용하면 상태 변경 규칙을 reducer 함수 안에 모을 수 있다.


정리하면 useReducer는 아래 상황에 적합하다.

  • 상태 변경 종류가 많은 경우
  • 여러 값이 함께 바뀌는 경우
  • 상태 변경 규칙을 한곳에서 관리하고 싶은 경우
  • 장바구니, 게시판 상태, 폼 입력 상태, 로그인 상태처럼 변경 흐름이 많은 경우

useReducer는 상태를 직접 바꾸지 않는다.
dispatch()로 변경 요청을 보내고, reducer 함수가 그 요청을 보고 새로운 상태를 만들어 반환한다.


나머지 훅은 역할만 먼저 잡는다

useMemo, useCallback, useContext도 자주 등장하지만, 이 세트에서는 역할만 먼저 잡으면 된다.
아직 자세한 동작 방식까지 깊게 들어가면 Hook 전체 흐름이 복잡해질 수 있다.


간단히 구분하면 아래와 같다.

  • useMemo: 계산 결과를 기억한다.
  • useCallback: 함수 자체를 기억한다.
  • useContext: 여러 컴포넌트가 같은 값을 함께 사용할 때 사용한다.

초보 단계에서는 먼저 useState, useEffect, useRef, useReducer의 역할을 확실히 잡는 것이 중요하다.
useMemo, useCallback, useContext는 어떤 역할을 하는지만 먼저 기억하고, 필요할 때 자세히 확장하면 된다.


2-3. 상황별로 어떤 훅을 선택해야 하는지

기준을 먼저 잡아야 한다

Hook을 고를 때는 이름을 외우는 것보다 어떤 상황에서 필요한지 구분하는 것이 중요하다.
같은 값을 다루는 것처럼 보여도 목적에 따라 사용하는 Hook이 달라진다.


예를 들어 숫자 하나를 저장한다고 해도, 화면에 보여줄 숫자라면 useState가 적합하다.
하지만 화면에 보여줄 필요 없이 내부에서만 기억할 숫자라면 useRef가 더 적합할 수 있다.


상황별 기준은 아래처럼 잡을 수 있다.

  • 화면에 보여줄 값이면 useState
  • 화면 반영 후 외부 작업이면 useEffect
  • 렌더링과 무관하게 기억할 값이면 useRef
  • 상태 변경 규칙이 복잡하면 useReducer
  • 계산 결과를 재사용해야 하면 useMemo
  • 함수를 재사용해야 하면 useCallback
  • 여러 컴포넌트가 같은 값을 공유해야 하면 useContext

Hook은 이름으로 고르는 것이 아니라, 처리하려는 작업의 목적에 맞춰 선택해야 한다.


같은 값 저장이라도 상태와 참조는 다르다

초보자가 가장 많이 헷갈리는 부분은 useState와 useRef다.
둘 다 값을 저장할 수 있기 때문이다.
하지만 두 Hook의 목적은 다르다.


useState는 값이 바뀌었을 때 화면도 바뀌어야 하는 경우에 사용한다.
반면 useRef는 값은 기억해야 하지만 화면을 다시 그릴 필요가 없는 경우에 사용한다.


예를 들어 입력창에 적은 글자를 화면에 바로 보여줘야 한다면 useState가 적합하다.
하지만 타이머를 멈추기 위해 setInterval()의 반환값을 기억해야 하는 상황이라면 useRef가 적합하다.


정리하면 아래처럼 볼 수 있다.

  • useState: 값 변경이 화면 변경으로 이어져야 할 때
  • useRef: 값 변경이 화면 변경으로 이어질 필요가 없을 때

이 차이를 모르면 useRef로 화면 값을 관리하려고 하거나, 반대로 화면에 보이지 않는 값까지 useState로 관리해서 불필요한 렌더링을 만들 수 있다.


이펙트는 모든 계산을 넣는 곳이 아니다

useEffect는 화면이 반영된 뒤 실행할 작업을 넣는 곳이다.
그래서 서버 요청, 타이머, 이벤트 연결처럼 React 바깥과 연결되는 작업에 적합하다.


하지만 두 값을 곱해서 결과를 만드는 단순 계산까지 useEffect에 넣을 필요는 없다.
예를 들어 가격과 개수를 곱해 총액을 구하는 정도라면 렌더링 중에 바로 계산하면 된다.


잘못 이해하면 모든 값 변경 처리를 useEffect로 보내려고 할 수 있다.
그러면 코드가 길어지고, 값이 바뀌는 흐름을 따라가기 어려워진다.


useEffect는 모든 로직을 넣는 공간이 아니라, 렌더링 이후 외부 작업을 처리하는 공간이다.


핵심 정리

Hook은 함수 컴포넌트에서 React 기능을 사용할 수 있게 해주는 함수다.
클래스 컴포넌트 없이도 state 관리, 렌더링 이후 작업, DOM 접근, 로직 재사용, 성능 최적화, 공통 값 공유를 처리할 수 있다.


useState는 화면에 보여줄 값을 관리한다.
useEffect는 화면이 반영된 뒤 실행할 작업을 처리한다.
useRef는 렌더링과 무관하게 값을 기억하거나 실제 DOM 요소를 가리킨다.
useReducer는 복잡한 상태 변경 규칙을 한곳에 모아 관리한다.


useMemo는 계산 결과를 기억하고, useCallback은 함수를 기억한다.
useContext는 여러 컴포넌트가 같은 값을 함께 사용할 때 사용한다.


중요한 것은 Hook 이름을 외우는 것이 아니다.
처리하려는 작업의 목적을 먼저 판단하고, 그 목적에 맞는 Hook을 선택하는 것이 핵심이다.




useEffect 실행 시점 이해

useEffect()는 컴포넌트가 화면에 반영된 뒤 실행할 작업을 등록하는 Hook이다.
화면을 만드는 계산 자체를 담당한다기보다는, 화면이 만들어진 다음에 필요한 부가 작업을 처리한다.


예를 들어 화면이 처음 열렸을 때 서버에서 데이터를 가져오거나, 타이머를 시작하거나, 브라우저 이벤트를 연결해야 할 때 useEffect()를 사용할 수 있다.
즉, useEffect()는 렌더링 이후 React 바깥의 작업과 컴포넌트를 연결할 때 사용하는 Hook이다.


3-1. 이펙트의 기본 개념

실행 시점을 잡는 훅이다

useEffect()는 “언제 어떤 작업을 실행할지”를 정하는 Hook이다.
초보자 기준에서는 컴포넌트가 화면에 나타난 뒤, 특정 값이 바뀐 뒤, 또는 컴포넌트가 사라질 때 실행할 작업을 적어두는 공간으로 이해하면 된다.


여기서 화면에 나타나는 것을 Mount라고 한다.
Mount는 컴포넌트가 처음 화면에 붙는 시점이다.
반대로 화면에서 사라지는 것을 Unmount라고 한다.
Unmount는 컴포넌트가 더 이상 화면에 보이지 않게 제거되는 시점이다.


useEffect()는 이런 시점에 맞춰 작업을 실행할 수 있게 해준다.
예를 들어 “화면이 처음 열리면 서버에서 목록을 가져와라”, “숫자가 바뀔 때마다 특정 작업을 다시 실행해라”, “화면에서 사라질 때 타이머를 정리해라” 같은 흐름을 처리할 수 있다.


정리하면 useEffect()는 아래 상황에서 사용한다.

  • 컴포넌트가 화면에 처음 나타났을 때 실행할 작업
  • 특정 state나 props 값이 바뀌었을 때 실행할 작업
  • 컴포넌트가 화면에서 사라질 때 정리할 작업
  • 서버 API, 브라우저 이벤트, 타이머, localStorage, WebSocket 같은 외부 작업

useEffect()는 화면이 그려진 뒤 실행되므로, 화면에 필요한 기본 구조가 먼저 준비된 다음 외부 작업을 연결하는 흐름에 적합하다.


리액트 내부 계산까지 모두 넣는 곳은 아니다

useEffect()는 편리하지만 모든 로직을 넣는 공간은 아니다.
React 내부에서 바로 계산할 수 있는 값까지 useEffect()로 처리하면 코드가 불필요하게 복잡해질 수 있다.


예를 들어 가격과 개수를 곱해 총액을 구하는 작업은 서버 요청도 아니고, 브라우저 이벤트 연결도 아니다.
이런 단순 계산은 렌더링 중에 바로 계산하면 된다.
굳이 useEffect()에서 또 다른 state를 만들어 관리하면 값이 바뀌는 흐름을 따라가기 어려워진다.


useEffect()는 렌더링 이후 외부 시스템과 동기화해야 할 때 사용하는 것이 핵심이다.


여기서 외부 시스템은 React가 직접 계산하는 영역 밖에 있는 대상을 의미한다.
서버 API, 브라우저 이벤트, 타이머, 저장소, 외부 라이브러리 연결 같은 것들이 여기에 해당한다.


3-2. 실행 타이밍과 의존성 배열

의존성 배열이 실행 시점을 결정한다

useEffect()는 첫 번째 인자로 실행할 함수를 받고, 두 번째 인자로 의존성 배열을 받는다.
의존성 배열은 Effect가 언제 다시 실행될지 결정하는 기준이다.


의존성 배열은 이름이 어렵게 느껴질 수 있다.
초보자 기준에서는 “이 값이 바뀌면 다시 실행해라”라고 적어두는 목록으로 이해하면 된다.


기본 문법은 아래처럼 생겼다.

// UseEffectSyntaxExample.jsx
import { useEffect } from 'react'; // useEffect를 사용하기 위해 가져온다.

useEffect(() => {
  실행할_코드(); // 화면 반영 후 실행할 작업이다.

  return () => {
    정리할_코드(); // 다시 실행되기 전 또는 사라질 때 정리한다.
  };
}, [의존하는_state]); // 이 값이 바뀌면 effect가 다시 실행된다.

첫 번째 함수 안에는 실제로 실행할 코드를 작성한다.
두 번째 배열에는 이 Effect가 의존하는 값을 적는다.
배열 안의 값이 바뀌면 React는 해당 Effect를 다시 실행한다.


첫 번째 함수 안에서 return으로 함수를 반환하면 그 함수는 cleanup 함수가 된다.
cleanup은 정리 작업을 뜻한다.
타이머를 멈추거나, 이벤트 리스너를 제거하는 작업처럼 남겨두면 문제가 되는 것을 정리할 때 사용한다.

useEffect()는 렌더링 이후 실행할 작업을 등록한다.
첫 번째 인자에는 실행할 함수를 넣고, 두 번째 인자에는 의존성 배열을 넣는다.
의존성 배열에 어떤 값을 넣는지에 따라 Effect가 매번 실행될지, 처음 한 번만 실행될지, 특정 값이 바뀔 때만 다시 실행될지가 달라진다.
함수 안에서 반환하는 함수는 cleanup으로 사용되며, 다음 Effect가 실행되기 전이나 컴포넌트가 사라질 때 정리 작업을 맡는다.


형태별 실행 시점

useEffect()는 의존성 배열을 어떻게 쓰느냐에 따라 실행 시점이 달라진다.
이 차이를 정확히 알아야 불필요한 실행을 줄이고, 필요한 시점에만 작업을 실행할 수 있다.


대표적인 형태는 아래와 같다.

  • useEffect(fn): 렌더링이 끝날 때마다 실행한다.
  • useEffect(fn, []): 컴포넌트가 처음 화면에 나타난 뒤 한 번 실행한다.
  • useEffect(fn, [id]): 처음 실행 후 id 값이 바뀔 때마다 다시 실행한다.
  • return cleanup: Effect가 다시 실행되기 전 또는 컴포넌트가 사라질 때 실행한다.

useEffect(fn)처럼 두 번째 배열을 생략하면 렌더링이 끝날 때마다 실행된다.
특별한 이유 없이 이렇게 작성하면 너무 자주 실행될 수 있으므로 주의해야 한다.
만약 이 안에서 매번 state를 바꾸면 렌더링과 Effect 실행이 반복될 수 있다.


useEffect(fn, [])처럼 빈 배열을 넣으면 처음 화면에 나타난 뒤 한 번만 실행된다.
초기 데이터 로딩처럼 한 번만 실행하면 되는 작업에 자주 사용한다.
다만 개발 모드에서 <StrictMode>가 적용되어 있으면 검사 과정 때문에 한 번 더 실행되는 것처럼 보일 수 있다.


useEffect(fn, [id])처럼 배열 안에 값을 넣으면 처음 한 번 실행되고, 이후 id가 바뀔 때 다시 실행된다.
상세 페이지에서 글 번호가 바뀌면 새 글을 다시 가져오는 상황처럼 특정 값 변화에 맞춰 다시 실행해야 할 때 사용한다.

useEffect()의 형태에 따라 실행 시점과 목적이 달라진다.
의존성 배열을 생략하면 렌더링이 끝날 때마다 실행되고, 빈 배열을 넣으면 처음 화면에 나타난 뒤 한 번 실행된다.
배열 안에 특정 값을 넣으면 그 값이 바뀔 때 다시 실행된다.
cleanup은 타이머나 이벤트 리스너처럼 남겨두면 문제가 되는 작업을 정리할 때 사용한다.


3-3. 기본 예시로 의존성 배열 이해하기

카운트 값이 바뀔 때 메시지를 바꾸는 예시

아래 예제는 count1 값이 바뀔 때마다 message를 새 문장으로 바꾸는 코드다.
count1이 의존성 배열에 들어 있기 때문에, 처음 렌더링 후 한 번 실행되고 이후 count1이 바뀔 때마다 다시 실행된다.

// EduApp8.jsx
import { useEffect, useState } from 'react'; // state와 effect를 사용한다.

export default function UseEffectBasicPage1() {
  const [message, setMessage] = useState('아직 실행 전'); // 안내 문장 상태다.
  const [count1, setCount] = useState(0); // 숫자 상태다.

  useEffect(() => {
    setMessage(`count1 값이 ${count1}로 변경되었습니다.`); // count1이 바뀌면 문장을 바꾼다.
  }, [count1]); // count1이 바뀔 때마다 effect를 다시 실행한다.

  return (
    <section>
      <p>count1: {count1}</p>
      <p>{message}</p>
      <button onClick={() => setCount(count1 + 1)}>
        증가
      </button>
    </section>
  );
}

처음 화면이 열리면 count1은 0이다.
렌더링이 끝난 뒤 useEffect()가 실행되고, message가 count1 값이 0으로 변경되었습니다.로 바뀐다.


사용자가 증가 버튼을 누르면 setCount(count1 + 1)이 실행된다.
count1 값이 바뀌었기 때문에 컴포넌트가 다시 렌더링되고, 렌더링 이후 useEffect()도 다시 실행된다.
그 결과 message도 새 count1 값에 맞게 바뀐다.


이 예제는 실무에서 문장 하나를 만들기 위해 반드시 useEffect()를 써야 한다는 뜻이 아니다.
의존성 배열에 들어간 값이 바뀔 때 Effect가 다시 실행된다는 흐름을 확인하기 위한 기본 예시다.


처음 실행을 피하고 싶을 때 조건을 둘 수 있다

useEffect()는 의존성 배열에 값이 있더라도 처음 렌더링 후 한 번 실행된다.
그래서 “처음에는 문장을 바꾸지 않고, 버튼을 눌러 값이 바뀐 뒤부터만 문장을 바꾸고 싶다”면 함수 안에 조건을 둘 수 있다.

// EduApp9.jsx
import { useEffect, useState } from 'react'; // state와 effect를 사용한다.

export default function UseEffectBasicPage2() {
  const [message, setMessage] = useState('아직 실행 전'); // 처음 보여줄 문장이다.
  const [count2, setCount] = useState(0); // 숫자 상태다.

  useEffect(() => {
    if (count2 > 0) {
      setMessage(`count2 값이 ${count2}로 변경되었습니다.`); // 1 이상일 때만 문장을 바꾼다.
    }
  }, [count2]); // count2가 바뀔 때마다 effect를 다시 확인한다.

  return (
    <section>
      <p>count2: {count2}</p>
      <p>{message}</p>
      <button onClick={() => setCount(count2 + 1)}>
        증가
      </button>
    </section>
  );
}

이 예제는 count2가 0일 때는 message를 바꾸지 않는다.
그래서 처음 화면에는 아직 실행 전이 그대로 보인다.


버튼을 한 번 눌러 count2가 1이 되면 조건 count2 > 0이 참이 된다.
그때부터 setMessage()가 실행되어 변경 문장이 화면에 표시된다.


이 흐름을 보면 의존성 배열은 “언제 다시 확인할지”를 정하고, 실제 실행 여부는 if 조건으로 한 번 더 제어할 수 있다는 점을 알 수 있다.


카운트와 입력값이 있을 때 이펙트는 카운트만 본다

아래 예제는 count와 message 상태를 함께 사용한다.
하지만 useEffect()의 의존성 배열에는 count만 들어 있다.
그래서 count가 바뀔 때는 Effect가 다시 실행되지만, message만 바뀔 때는 해당 Effect가 다시 실행되지 않는다.


화면 꾸밈 코드를 제외하고 핵심 흐름만 보면 아래와 같다.

// EduApp10.jsx
import { useState, useEffect } from 'react'; // state와 effect를 사용한다.

function HookTest() {
  const [count, setCount] = useState(0); // count 상태다.
  const [message, setMessage] = useState(''); // input 입력값 상태다.

  console.log('HookTest 랜더링~~'); // 컴포넌트가 렌더링될 때마다 실행된다.

  useEffect(() => {
    console.log('🚀 [useEffect] count가 바뀌거나 컴포넌트가 나타남!'); // effect 실행 확인 로그다.

    return () => {
      console.log('🧹 [useEffect] 정리(Clean-up) 작업 수행'); // 다음 effect 실행 전 정리된다.
    };
  }, [count]); // count가 변경될 때만 effect를 다시 실행한다.

  return (
    <div>
      <h1>useEffect 테스트</h1>
      <p>콘솔창을 열어 로그를 확인하며 테스트하세요!</p>

      <section>
        <h3>useEffect()에 등록된 함수가 수행되나요?</h3>
        <h2>Count: {count}</h2>
        <button onClick={() => setCount(count + 1)}>Count 증가</button>
      </section>

      <section>
        <h3>useEffect()에 등록된 함수가 수행되나요?</h3>
        <input
          value={message}
          onChange={(event) => setMessage(event.target.value)}
          placeholder="여기에 타이핑해도 useEffect는 안 움직여요"
        />
        <p>입력 중: {message}</p>
      </section>
    </div>
  );
}

export default HookTest;

이 예제에서 중요한 부분은 console.log('HookTest 랜더링~~')의 위치다.
이 코드는 컴포넌트 함수 본문에 있으므로, 컴포넌트가 다시 렌더링될 때마다 실행된다.


반면 useEffect() 안의 로그는 아무 렌더링에서나 실행되지 않는다.
의존성 배열에 들어 있는 count가 바뀌었을 때만 다시 실행된다.

처음 화면이 렌더링되면 HookTest 컴포넌트가 실행되고, 콘솔에 HookTest 랜더링~~ 로그가 찍힌다.
개발 모드에서 StrictMode가 적용되어 있으면 컴포넌트 렌더링과 Effect가 한 번 더 실행되는 것처럼 보일 수 있다.
useEffect()는 화면이 반영된 뒤 실행되고, 정리 함수는 다음 Effect 실행 전 또는 컴포넌트가 사라질 때 실행된다.


Count 증가 버튼을 누르면 setCount(count + 1)이 실행된다.
count state가 바뀌면 컴포넌트가 다시 렌더링되고, count가 의존성 배열에 들어 있으므로 useEffect()도 다시 실행된다.


이때 기존 Effect의 정리 함수가 먼저 실행된다.
그 다음 새 count 값을 기준으로 useEffect()의 본문이 다시 실행된다.
즉, count 변경 흐름에서는 렌더링 로그와 cleanup 로그와 새 Effect 로그가 함께 나타난다.

Count 증가 버튼을 누르면 setCount(count + 1)이 실행되어 count state가 바뀐다.
count가 의존성 배열에 들어 있으므로 기존 Effect의 cleanup이 먼저 실행되고, 이후 새로운 count 값 기준으로 useEffect()가 다시 실행된다.
그래서 콘솔에는 렌더링 로그, 정리 로그, 새 useEffect 로그가 함께 나타난다.


반대로 input에 글자를 입력하면 message state가 바뀐다.
message state가 바뀌므로 HookTest 컴포넌트는 다시 렌더링된다.
그래서 컴포넌트 본문에 있는 console.log('HookTest 랜더링~~')는 입력할 때마다 다시 실행된다.


하지만 message는 useEffect()의 의존성 배열에 들어 있지 않다.
의존성 배열은 [count]이므로 message 변경만으로는 useEffect()가 다시 실행되지 않는다.


입력하면 컴포넌트는 다시 렌더링되지만, useEffect()는 count가 바뀔 때만 다시 실행된다.

input에 글자를 입력하면 message state가 바뀌므로 HookTest 컴포넌트는 다시 렌더링된다.
그래서 콘솔에는 HookTest 랜더링~~ 로그가 계속 추가된다.
하지만 useEffect()의 의존성 배열은 [count]이므로 message 변경만으로는 useEffect() 로그가 다시 찍히지 않는다.


3-4. 정리 함수가 필요한 이유

정리하지 않으면 보이지 않는 작업이 계속 남을 수 있다

타이머나 이벤트 리스너를 등록하면 컴포넌트가 사라질 때 정리해야 한다.
정리하지 않으면 화면에 보이지 않는 컴포넌트의 로직이 계속 실행될 수 있다.


예를 들어 시계 컴포넌트에서 setInterval()로 1초마다 시간을 바꾼다고 생각할 수 있다.
시계 컴포넌트가 화면에서 사라졌는데도 타이머가 계속 살아 있으면, 보이지 않는 곳에서 계속 시간이 업데이트된다.
이런 상황은 불필요한 작업을 만들고, 나중에는 오류나 성능 문제로 이어질 수 있다.


이벤트 리스너도 마찬가지다.
컴포넌트가 나타날 때마다 이벤트를 등록하고 정리하지 않으면 같은 이벤트가 여러 번 등록될 수 있다.
그 결과 버튼을 한 번 눌렀는데 같은 로직이 여러 번 실행되는 문제가 생길 수 있다.


cleanup은 컴포넌트가 사라지거나 Effect가 다시 실행되기 전에 남아 있는 작업을 정리하는 함수다.


타이머 정리 예시

아래 예제는 컴포넌트가 화면에 나타났을 때 타이머를 시작하고, 컴포넌트가 사라질 때 타이머를 정리한다.
useEffect()의 두 번째 인자로 빈 배열을 넣었기 때문에 처음 화면에 나타났을 때 한 번 실행된다.

// ClockCleanupExample.jsx
import { useEffect, useState } from 'react'; // state와 effect를 사용한다.

export default function Clock() {
  const [now, setNow] = useState(new Date()); // 현재 시간을 상태로 저장한다.

  useEffect(() => {
    const timerId = setInterval(() => {
      setNow(new Date()); // 1초마다 현재 시간으로 갱신한다.
    }, 1000);

    return () => {
      clearInterval(timerId)<; // 컴포넌트가 사라질 때 타이머를 정리한다.
    };
  }, []); // 처음 나타날 때 한 번만 타이머를 등록한다.

  return <p>{now.toLocaleTimeString()}</p>;
}

처음 컴포넌트가 화면에 나타나면 useEffect()가 실행된다.
그 안에서 setInterval()이 등록되고, 1초마다 setNow(new Date())가 실행된다.


setNow()가 실행되면 now 상태가 바뀌므로 화면의 시간이 다시 렌더링된다.
컴포넌트가 사라질 때는 return으로 반환한 함수가 실행되고, clearInterval(timerId)로 타이머를 멈춘다.


화면에서 마운트와 언마운트를 직접 확인하는 예시

아래 예제는 버튼으로 타이머 컴포넌트를 화면에 붙였다가 제거하면서 cleanup 실행을 확인하는 코드다.
Mount 버튼을 누르면 자식 컴포넌트가 화면에 나타나고, Unmount 버튼을 누르면 자식 컴포넌트가 사라진다.


화면 꾸밈 코드를 제외하고 핵심 흐름만 보면 아래와 같다.

// EduApp11.jsx
import React, { useState, useEffect } from 'react'; // state와 effect를 사용한다.

function TimerComponent() {
  const [time, setTime] = useState(new Date().toLocaleTimeString()); // 현재 시간 문자열이다.

  useEffect(() => {
    console.log('[Mount] 타이머 컴포넌트가 나타났다.'); // mount 확인 로그다.

    const timerId = setInterval(() => {
      console.log('시간이 흐르고 있다.'); // 타이머 실행 확인 로그다.
      setTime(new Date().toLocaleTimeString()); // 1초마다 시간을 갱신한다.
    }, 1000);

    return () => {
      console.log('[Unmount] 컴포넌트가 사라진다. 타이머를 정지한다.'); // cleanup 확인 로그다.
      clearInterval(timerId); // 타이머를 제거한다.
    };
  }, []); // 처음 나타날 때 한 번만 실행한다.

  return (
    <div>
      <h2>현재 시간: {time}</h2>
      <p>이 컴포넌트가 보일 때만 타이머가 작동한다.</p>
    </div>
  );
}

export default function ParentApp() {
  const [showTimer, setShowTimer] = useState(false); // 타이머 컴포넌트 표시 여부다.

  return (
    <div>
      <h1>useEffect Cleanup 테스트</h1>
      <button onClick={() => setShowTimer(true)} disabled={showTimer}>
        컴포넌트 Mount
      </button>
      <button onClick={() => setShowTimer(false)} disabled={!showTimer}>
        컴포넌트 Unmount
      </button>
      {showTimer ? <TimerComponent /> : <p>버튼을 눌러 타이머를 활성화하세요.</p>}
    </div>
  );
}

showTimer가 false이면 TimerComponent는 화면에 보이지 않는다.
컴포넌트 Mount 버튼을 누르면 showTimer가 true가 되고, TimerComponent가 화면에 나타난다.
이때 useEffect()가 실행되어 타이머가 시작된다.


컴포넌트 Unmount 버튼을 누르면 showTimer가 false가 되고, TimerComponent가 화면에서 사라진다.
이 순간 cleanup 함수가 실행되어 clearInterval(timerId)로 타이머를 정리한다.


이 예제의 핵심은 타이머를 시작하는 코드보다 타이머를 끝내는 코드가 더 중요하다는 점이다.
컴포넌트가 사라졌는데도 타이머가 계속 움직이면 보이지 않는 작업이 계속 남기 때문이다.


3-5. 이펙트를 쓰면 안 되는 대표 상황

단순 계산은 렌더링 중에 처리한다

useEffect()는 렌더링 이후 외부 작업을 처리하는 공간이다.
그래서 단순 계산이나 버튼 클릭 처리까지 모두 useEffect()로 보내면 코드가 복잡해진다.


예를 들어 가격과 개수를 곱해 총액을 구하는 상황을 생각할 수 있다.
이 값은 서버에서 가져오는 값이 아니고, 브라우저 이벤트를 따로 연결해야 하는 값도 아니다.
현재 price와 count만 알면 바로 계산할 수 있다.


이런 경우에는 아래처럼 렌더링 중에 계산하는 것이 더 단순하다.

// TotalPriceRenderCalculation.jsx
export default function TotalPriceRenderCalculation() {
  const price = 1000; // 상품 가격이다.
  const count = 3; // 상품 개수다.
  const total = price * count; // 렌더링 중에 바로 계산한다.

  return <p>총액: {total}원</p>;
}

이 예제에서는 useEffect()가 필요 없다.
price와 count가 있으면 total은 바로 계산할 수 있기 때문이다.


클릭 처리는 이벤트 핸들러에서 바로 처리한다

버튼 클릭 후 실행할 작업은 보통 onClick 핸들러에서 바로 처리하면 된다.
클릭 여부를 따로 state로 만들고, 그 값을 useEffect()에서 감지해서 처리하면 흐름이 불필요하게 돌아간다.


아래처럼 클릭했을 때 바로 처리하는 편이 더 직관적이다.

// ClickHandlerDirectExample.jsx
export default function ClickHandlerDirectExample() {
  function handleClick() {
    alert('버튼을 클릭했다.'); // 클릭 순간 바로 실행한다.
  }

  return <button onClick={handleClick}>확인</button>;
}

이 흐름은 사용자가 버튼을 누르면 바로 handleClick()이 실행된다.
별도의 clicked 상태를 만들고 useEffect()에서 다시 확인할 필요가 없다.


입력값 검증과 배열 필터링도 바로 계산할 수 있다

입력값 검증도 대부분 렌더링 중 조건식으로 계산할 수 있다.
예를 들어 입력값이 비어 있는지 확인하는 정도라면 useEffect()로 에러 상태를 따로 관리하지 않아도 된다.


배열 필터링도 마찬가지다.
검색어에 따라 목록을 걸러 보여주는 정도라면 posts.filter(...)처럼 렌더링 중에 계산하면 된다.

// RenderCalculationExamples.jsx
export default function RenderCalculationExamples() {
  const keyword = 'React'; // 검색어다.
  const posts = ['React 기초', 'JavaScript 문법', 'React Hook']; // 게시글 목록이다.

  const error = keyword.length === 0 ? '검색어를 입력하세요.' : ''; // 조건식으로 검증한다.
  const filteredPosts = posts.filter((post) => post.includes(keyword)); // 배열을 바로 필터링한다.

  return (
    <section>
      <p>{error}</p>
      <ul>
        {filteredPosts.map((post) => (
          <li key={post}>{post}</li>
        ))}
      </ul>
    </section>
  );
}

이 예제에서 error와 filteredPosts는 따로 state로 관리하지 않는다.
현재 값으로 바로 계산할 수 있기 때문이다.


이렇게 하면 값이 어디서 바뀌는지 추적하기 쉽고, 불필요한 Effect 실행도 줄일 수 있다.

useEffect()는 모든 값 변경 처리를 넣는 공간이 아니다.
두 state를 조합한 값은 렌더링 중에 계산하는 편이 좋고, 버튼 클릭 후 처리는 이벤트 핸들러에서 바로 처리하는 편이 자연스럽다.
입력값 검증이나 배열 필터링도 현재 값으로 바로 계산할 수 있다면 useEffect()보다 렌더링 중 계산이 더 단순하다.
useEffect()는 서버 요청, 타이머, 이벤트 연결처럼 렌더링 이후 외부 작업이 필요할 때 사용하는 기준으로 잡아야 한다.


핵심 정리

useEffect()는 컴포넌트가 화면에 반영된 뒤 실행할 작업을 등록하는 Hook이다.
서버 API, 브라우저 이벤트, 타이머, localStorage, WebSocket처럼 React 바깥의 시스템과 연결할 때 사용한다.


의존성 배열은 Effect가 언제 다시 실행될지 결정한다.
배열을 생략하면 렌더링이 끝날 때마다 실행되고, 빈 배열을 넣으면 처음 화면에 나타난 뒤 한 번 실행된다.
배열 안에 값을 넣으면 그 값이 바뀔 때 다시 실행된다.


cleanup은 컴포넌트가 사라지거나 Effect가 다시 실행되기 전에 남아 있는 작업을 정리하는 함수다.
타이머나 이벤트 리스너처럼 계속 남아 있으면 문제가 되는 작업은 반드시 정리해야 한다.


EduApp10.jsx 예제에서는 count와 message가 모두 state이므로 둘 다 바뀌면 컴포넌트는 다시 렌더링된다.
하지만 useEffect()의 의존성 배열에는 count만 들어 있기 때문에, count 변경은 Effect를 다시 실행하고 message 변경은 컴포넌트 렌더링만 다시 일으킨다.


마지막으로 useEffect()는 모든 로직을 넣는 공간이 아니다.
단순 계산, 클릭 처리, 입력값 검증, 배열 필터링처럼 현재 값으로 바로 처리할 수 있는 작업은 렌더링 중 계산이나 이벤트 핸들러에서 처리하는 것이 더 좋다.




useReducer로 복잡한 상태 변경 관리하기

useReducer()는 상태 변경 규칙이 복잡할 때 사용하는 Hook이다.
useState()는 값 하나를 간단히 바꿀 때 쓰기 좋지만, 상태 변경 종류가 많아지면 코드가 여러 곳으로 흩어질 수 있다.


예를 들어 증가, 감소, 입력값 변경, 초기화처럼 상태를 바꾸는 방식이 여러 개라면, 각각의 변경 코드를 컴포넌트 안에 계속 두는 것보다 한곳에 모아 관리하는 편이 이해하기 쉽다.
이때 상태 변경 규칙을 따로 분리해서 관리하게 해주는 도구가 useReducer()다.
즉, useReducer()는 복잡한 상태 변경 로직을 reducer 함수로 분리해서 관리하는 Hook이다.


4-1. 리듀서가 필요한 이유

상태 변경 규칙이 많아질 때 사용한다

useReducer()는 복잡한 state 변경 로직을 reducer 함수로 분리해서 관리한다.
여기서 state는 현재 상태를 의미한다.
예를 들어 카운터의 현재 숫자, 입력 폼의 이름과 나이와 이메일, 장바구니에 담긴 상품 목록 같은 값이 state가 될 수 있다.


useState()도 상태를 관리할 수 있다.
하지만 상태 변경 규칙이 단순할 때 더 적합하다.
예를 들어 버튼을 누르면 숫자 하나가 증가하는 정도라면 useState()만으로 충분하다.


반대로 상태 변경 규칙이 많아지면 useReducer()가 더 적합하다.
예를 들어 같은 상태를 증가시킬 수도 있고, 감소시킬 수도 있고, 초기화할 수도 있다면 변경 규칙이 여러 개가 된다.
이런 규칙을 컴포넌트 안에 흩어놓으면 코드를 읽기 어려워진다.


정리하면 useReducer()는 아래 상황에서 사용하기 좋다.

  • 상태 변경 종류가 여러 개인 경우
  • 여러 값이 함께 바뀌는 경우
  • 상태 변경 규칙을 한곳에서 관리하고 싶은 경우
  • 장바구니, 게시판 상태, 폼 입력 상태, 로그인 상태처럼 변경 흐름이 많은 경우

useReducer()를 사용하면 컴포넌트는 “어떤 변경을 요청할지”만 정하고, 실제 상태 변경 방식은 reducer 함수가 담당한다.


상태를 직접 바꾸지 않고 요청을 보낸다

useReducer()에서는 상태를 직접 바꾸지 않는다.
대신 dispatch()라는 함수를 호출해서 상태 변경을 요청한다.


dispatch()는 “이런 종류의 변경을 해줘”라고 요청을 보내는 함수다.
이 요청을 받은 reducer 함수가 현재 상태와 요청 내용을 보고 새로운 상태를 만들어 반환한다.


이때 요청 내용을 보통 action이라고 부른다.
action은 상태를 어떻게 바꿀지 알려주는 정보다.
대부분 객체 형태로 작성하고, 어떤 동작인지 구분하기 위해 type 값을 넣는다.


흐름은 아래처럼 이어진다.

  • 화면에서 사용자 입력이나 버튼 클릭이 발생한다.
  • 이벤트 핸들러에서 dispatch(action)을 호출한다.
  • React가 내부적으로 reducer(state, action) 함수를 실행한다.
  • reducer가 현재 state와 action을 보고 새 state를 만든다.
  • 새 state가 반환된다.
  • 컴포넌트가 다시 렌더링된다.

useReducer()의 핵심은 상태를 바로 바꾸는 것이 아니라, dispatch()로 변경 요청을 보내고 reducer가 새 상태를 만드는 흐름이다.


4-2. 리듀서의 기본 구성

useReducer의 기본 문법

useReducer()는 보통 아래 형태로 사용한다.
첫 번째 자리에는 상태 변경 규칙을 담은 reducer 함수를 넣고, 두 번째 자리에는 처음 상태를 넣는다.

// UseReducerSyntaxExample.jsx
import { useReducer } from 'react'; // reducer 방식으로 상태를 관리하기 위해 가져온다.

const initialState = { count: 0 }; // 처음 상태다.

function reducer(state, action) {
  switch (action.type) {
    case 'increase':
      return { count: state.count + 1 }; // count를 1 증가시킨 새 상태다.
    case 'decrease':
      return { count: state.count - 1 }; // count를 1 감소시킨 새 상태다.
    default:
      return state; // 모르는 요청이면 기존 상태를 그대로 반환한다.
  }
}

export default function Counter() {
  const [state, dispatch] = useReducer(reducer, initialState); // state와 dispatch를 준비한다.

  return (
    <>
      <p>{state.count}</p>
      <button onClick={() => dispatch({ type: 'increase' })}>+</button>
      <button onClick={() => dispatch({ type: 'decrease' })}>-</button>
    </>
  );
}

initialState는 처음 상태다.
이 예제에서는 처음 count 값이 0이다.


reducer 함수는 현재 state와 action을 받는다.
그리고 action.type을 보고 어떤 방식으로 상태를 바꿀지 결정한다.


dispatch()는 상태 변경 요청을 보낸다.
dispatch({ type: 'increase' })는 증가 요청이고, dispatch({ type: 'decrease' })는 감소 요청이다.


여기서 중요한 점은 dispatch()가 직접 상태를 바꾸는 것이 아니라는 점이다.
dispatch()는 요청을 보내고, 실제로 새 상태를 만드는 일은 reducer 함수가 한다.


구성 요소를 나누어 보기

useReducer() 코드는 처음 보면 useState()보다 복잡해 보인다.
하지만 구성 요소를 나누어 보면 역할이 분명하다.


핵심 구성은 아래와 같다.

  • state: 현재 상태다.
  • dispatch: 상태 변경 요청을 보내는 함수다.
  • reducer: 현재 상태와 요청을 받아 새 상태를 만드는 함수다.
  • initialState: 처음 상태다.
  • action: 어떤 변경을 할지 알려주는 요청 정보다.
  • action.type: 요청 종류를 구분하는 값이다.

이 구성을 알면 useReducer() 코드를 읽을 때 흐름이 훨씬 단순해진다.
컴포넌트는 dispatch()로 요청을 보내고, reducer는 요청에 따라 새 상태를 만들어 반환한다.

useReducer()는 상태 변경 규칙을 reducer 함수 안에 모아 관리한다.
버튼을 누르면 컴포넌트에서 dispatch()가 호출되고, dispatch()는 action을 reducer에게 전달한다.
reducer는 현재 state와 action.type을 보고 증가 요청인지 감소 요청인지 판단한 뒤 새 상태를 반환한다.
이 흐름 덕분에 컴포넌트 안에는 상태 변경 규칙이 길게 흩어지지 않고, 변경 규칙은 reducer 함수에 모인다.


4-3. 카운터 예제로 흐름 이해하기

증가 버튼을 눌렀을 때

카운터 예제에서 + 버튼을 누르면 아래 코드가 실행된다.
아래 코드는 전체 파일이 아니라, 증가 요청이 어떻게 전달되는지 보여주는 코드 조각이다.

// CounterIncreaseFlowSnippet.jsx
<button onClick={() => dispatch({ type: 'increase' })}>+</button>

이 코드는 count를 직접 증가시키지 않는다.
대신 dispatch()를 호출해서 { type: 'increase' }라는 요청을 보낸다.


초보자가 여기서 잡아야 할 점은 dispatch()가 reducer()를 직접 눈에 보이게 호출하지 않는다는 것이다.
개발자는 dispatch()만 호출하고, React가 내부적으로 reducer(state, action)을 실행한다.


그 요청은 reducer 함수로 전달된다.
reducer 함수는 action.type이 'increase'인지 확인하고, 현재 state.count보다 1 큰 값을 가진 새 상태를 반환한다.

// CounterReducerIncreaseCase.jsx
function reducer(state, action) {
  switch (action.type) {
    case 'increase':
      return { count: state.count + 1 }; // 기존 count보다 1 큰 새 상태를 반환한다.
    default:
      return state; // 처리할 요청이 없으면 기존 상태를 유지한다.
  }
}

return { count: state.count + 1 }은 기존 상태를 직접 수정하는 코드가 아니다.
새 객체를 만들어 반환하는 코드다.


이전 세트에서 배열이나 객체 상태를 직접 수정하면 안 된다고 정리했다.
useReducer()에서도 같은 기준이 적용된다.
reducer는 기존 상태를 직접 고치지 않고, 새로운 상태를 만들어 반환해야 한다.


감소 버튼을 눌렀을 때

- 버튼을 누르면 감소 요청이 전달된다.
아래 코드도 전체 파일이 아니라, 감소 요청을 보내는 부분만 보여주는 코드 조각이다.

// CounterDecreaseFlowSnippet.jsx
<button onClick={() => dispatch({ type: 'decrease' })}>-</button>

이 요청은 { type: 'decrease' } 형태다.
reducer 함수는 action.type이 'decrease'인지 확인하고, count를 1 줄인 새 상태를 반환한다.

// CounterReducerDecreaseCase.jsx
function reducer(state, action) {
  switch (action.type) {
    case 'decrease':
      return { count: state.count - 1 }; // 기존 count보다 1 작은 새 상태를 반환한다.
    default:
      return state; // 처리할 요청이 없으면 기존 상태를 유지한다.
  }
}

감소도 증가와 흐름은 같다.
컴포넌트는 요청만 보내고, reducer가 실제 변경 규칙을 처리한다.


이 구조를 사용하면 상태 변경 종류가 많아져도 각 규칙을 switch 문 안에 정리할 수 있다.
증가, 감소, 초기화, 값 변경 같은 동작을 한곳에서 확인할 수 있기 때문에 코드 흐름을 따라가기 쉬워진다.


default가 필요한 이유

switch 문에는 보통 default가 들어간다.
default는 처리할 수 없는 요청이 들어왔을 때 실행되는 부분이다.


예를 들어 dispatch({ type: 'unknown' })처럼 정해두지 않은 요청이 들어오면 increase도 아니고 decrease도 아니다.
이때 아무 상태도 반환하지 않으면 문제가 생길 수 있다.
그래서 default에서는 기존 state를 그대로 반환한다.

// CounterReducerDefaultCase.jsx
function reducer(state, action) {
  switch (action.type) {
    case 'increase':
      return { count: state.count + 1 }; // 증가 요청 처리다.
    case 'decrease':
      return { count: state.count - 1 }; // 감소 요청 처리다.
    default:
      return state; // 모르는 요청이면 기존 상태를 유지한다.
  }
}

default는 안전장치 역할을 한다.
예상하지 못한 요청이 들어와도 상태가 갑자기 사라지지 않고 기존 상태를 유지하게 만든다.


4-4. 상태 방식과 리듀서 방식 비교

상태를 각각 따로 관리하는 방식

입력값이 몇 개 안 되면 useState()를 여러 개 사용해도 이해하기 쉽다.
아래 예제는 이름, 나이, 이메일을 각각 따로 상태로 관리한다.

// UserFormState.jsx
import { useState } from 'react'; // state를 사용하기 위해 가져온다.

export default function UserFormState() {
  const [name, setName] = useState(''); // 이름 상태다.
  const [age, setAge] = useState(''); // 나이 상태다.
  const [email, setEmail] = useState(''); // 이메일 상태다.

  return (
    <div>
      <h2>useState 버전</h2>
      <input value={name} onChange={(event) => setName(event.target.value)} placeholder="이름" />
      <input value={age} onChange={(event) => setAge(event.target.value)} placeholder="나이" />
      <input value={email} onChange={(event) => setEmail(event.target.value)} placeholder="이메일" />
      <p>이름: {name}</p>
      <p>나이: {age}</p>
      <p>이메일: {email}</p>
    </div>
  );
}

이 방식은 직관적이다.
name은 setName()으로 바꾸고, age는 setAge()로 바꾸고, email은 setEmail()로 바꾼다.


하지만 입력값이 많아질수록 상태와 변경 함수가 계속 늘어난다.
이름, 나이, 이메일 정도는 괜찮지만, 입력 필드가 많아지면 코드가 길어질 수 있다.


리듀서로 입력값을 한 객체에서 관리하는 방식

useReducer()를 사용하면 여러 입력값을 하나의 객체 상태로 관리할 수 있다.
그리고 입력값 변경 규칙을 reducer 함수 안에 모을 수 있다.

// UserFormReducer.jsx
import { useReducer } from 'react'; // reducer 방식으로 상태를 관리하기 위해 가져온다.

const initialState = { name: '', age: '', email: '' }; // 입력 폼의 처음 상태다.

function reducer(state, action) {
  switch (action.type) {
    case 'change':
      return {
        ...state, // 기존 입력값을 복사한다.
        [action.name]: action.value, // 바뀐 input 이름에 해당하는 값만 변경한다.
      };
    default:
      return state; // 모르는 요청이면 기존 상태를 유지한다.
  }
}

export default function UserFormReducer() {
  const [state, dispatch] = useReducer(reducer, initialState); // 폼 상태와 요청 함수를 준비한다.

  function handleChange(event) {
    dispatch({
      type: 'change', // 입력값 변경 요청이다.
      name: event.target.name, // 어떤 input이 바뀌었는지 알려준다.
      value: event.target.value, // 새 입력값이다.
    });
  }

  return (
    <div>
      <h2>useReducer 버전</h2>
      <input name="name" value={state.name} onChange={handleChange} placeholder="이름" />
      <input name="age" value={state.age} onChange={handleChange} placeholder="나이" />
      <input name="email" value={state.email} onChange={handleChange} placeholder="이메일" />
      <p>이름: {state.name}</p>
      <p>나이: {state.age}</p>
      <p>이메일: {state.email}</p>
    </div>
  );
}

이 방식에서는 name, age, email을 각각 따로 상태로 만들지 않는다.
대신 하나의 객체 상태인 state 안에 세 값을 함께 저장한다.


입력값이 바뀌면 handleChange()가 실행되고, 그 안에서 dispatch()가 호출된다.
dispatch()는 어떤 입력칸이 바뀌었는지 name으로 전달하고, 새 값을 value로 전달한다.


예를 들어 이메일 입력칸에는 name="email"이 들어 있다.
사용자가 이메일 입력칸에 값을 입력하면 event.target.name은 "email"이 되고, event.target.value는 사용자가 입력한 새 값이 된다.
그래서 dispatch()는 { type: 'change', name: 'email', value: 새입력값 } 형태의 요청을 보낸다.


그 다음 reducer 함수가 실행된다.
reducer는 기존 상태를 복사한 뒤, [action.name]에 해당하는 값만 새 값으로 바꾼다.


여기서 [action.name]은 계산된 속성 이름이다.
초보자 기준에서는 “바뀐 입력칸의 이름을 보고 그 칸의 값만 바꾼다”라고 이해하면 된다.
예를 들어 name="email"인 입력칸이 바뀌면 state.email만 새 값으로 바뀐다.

useState 버전은 이름, 나이, 이메일마다 각각 상태와 변경 함수를 따로 둔다.
입력값이 적을 때는 직관적이지만, 입력 필드가 많아지면 상태 관리 코드가 늘어난다.
useReducer 버전은 여러 입력값을 하나의 객체 상태로 묶고, 입력값 변경 요청을 dispatch()로 보낸다.
reducer는 action.name을 보고 어떤 입력칸이 바뀌었는지 판단한 뒤, 해당 값만 새 값으로 바꾼다.


4-5. 리듀서 흐름 정리

입력값이 바뀔 때 내부 흐름

useReducer()의 흐름은 한 번에 보면 복잡해 보이지만, 순서대로 보면 단순하다.
입력값이 바뀌면 바로 상태를 직접 수정하는 것이 아니라, 먼저 변경 요청을 보낸다.


입력값 변경 흐름은 아래와 같다.

  • 사용자가 입력창에 값을 입력한다.
  • onChange 이벤트가 발생한다.
  • handleChange() 함수가 실행된다.
  • dispatch(action)이 호출된다.
  • React가 내부적으로 reducer(state, action)을 실행한다.
  • reducer가 새 state를 반환한다.
  • 컴포넌트가 다시 렌더링된다.
  • 화면에 변경된 값이 표시된다.

이 흐름에서 컴포넌트는 dispatch()로 요청을 보내는 역할을 한다.
상태를 어떻게 바꿀지는 reducer 함수가 결정한다.


상태와 리듀서를 선택하는 기준

useState()와 useReducer() 중 무엇을 써야 하는지는 상태 변경 규칙의 복잡도에 따라 판단하면 된다.


단순히 값 하나를 바꾸거나, 상태 변경 방식이 적으면 useState()가 더 편하다.
반대로 상태 변경 종류가 많고, 여러 값이 함께 움직이면 useReducer()가 더 이해하기 쉬울 수 있다.


정리하면 아래처럼 선택할 수 있다.

  • 값 하나를 단순히 바꾼다 → useState()
  • 상태 변경 규칙이 적다 → useState()
  • 여러 값이 함께 바뀐다 → useReducer()
  • 상태 변경 요청 종류가 많다 → useReducer()
  • 변경 규칙을 한곳에 모으고 싶다 → useReducer()

useState()와 useReducer()는 어느 하나가 무조건 더 좋은 것이 아니라, 상태 변경 흐름이 얼마나 복잡한지에 따라 선택하는 도구다.


4-6. EduApp17 장바구니 예제로 useReducer 흐름 확인하기

예제의 목적

EduApp17.jsx는 useReducer()로 장바구니 상태를 관리하는 예제다.
이 예제에서는 상품 추가, 수량 증가, 수량 감소, 상품 삭제, 쿠폰 적용, 장바구니 비우기처럼 여러 상태 변경 요청이 등장한다.


이런 기능을 전부 useState()로 나누어 관리할 수도 있다.
하지만 상태 변경 규칙이 여러 개로 늘어나면 코드가 흩어지고, 어떤 버튼이 어떤 상태를 어떻게 바꾸는지 따라가기 어려워진다.


그래서 이 예제에서는 장바구니 상태 변경 규칙을 cartReducer() 함수에 모아 관리한다.
컴포넌트는 dispatch(action)으로 “무엇을 할지” 요청하고, cartReducer()가 실제 상태 변경 방식을 결정한다.

EduApp17.jsx는 useReducer()로 장바구니 상태를 관리하는 예제다.
처음 상태는 cart: [], couponApplied: false, message: "상품을 선택하세요."로 시작한다.
컴포넌트는 dispatch()로 요청을 보내고, cartReducer()는 action.type을 보고 어떤 방식으로 장바구니 상태를 바꿀지 결정한다.


전체 코드

아래 코드는 EduApp17.jsx의 핵심 흐름을 정리한 코드다.
화면 꾸밈 코드는 제외하고, useReducer()로 장바구니 상태를 관리하는 데 필요한 부분만 정리했다.

// EduApp17.jsx
import { useReducer } from 'react'; // reducer 방식으로 상태를 관리하기 위해 가져온다.

const products = [
  { id: 1, name: '아메리카노', price: 4500 }, // 상품 목록 데이터다.
  { id: 2, name: '카페라떼', price: 5500 },
  { id: 3, name: '치즈케이크', price: 7000 },
];

const initialState = {
  cart: [], // 장바구니에 담긴 상품 목록이다.
  couponApplied: false, // 쿠폰 적용 여부다.
  message: '상품을 선택하세요.', // 화면에 보여줄 안내 메시지다.
};

function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_PRODUCT': {
      const exists = state.cart.find((item) => item.id === action.product.id); // 같은 상품이 있는지 확인한다.

      if (exists) {
        return {
          ...state, // 기존 상태를 복사한다.
          cart: state.cart.map((item) =>
            item.id === action.product.id
              ? { ...item, quantity: item.quantity + 1 } // 같은 상품이면 수량을 1 증가시킨다.
              : item
          ),
          message: `${action.product.name} 수량을 1개 늘렸습니다.`,
        };
      }

      return {
        ...state, // 기존 상태를 복사한다.
        cart: [...state.cart, { ...action.product, quantity: 1 }], // 새 상품을 장바구니에 추가한다.
        message: `${action.product.name} 상품을 추가했습니다.`,
      };
    }

    case 'INCREASE':
      return {
        ...state, // 기존 상태를 복사한다.
        cart: state.cart.map((item) =>
          item.id === action.id
            ? { ...item, quantity: item.quantity + 1 } // 선택한 상품 수량을 증가시킨다.
            : item
        ),
        message: '상품 수량을 늘렸습니다.',
      };

    case 'DECREASE':
      return {
        ...state, // 기존 상태를 복사한다.
        cart: state.cart
          .map((item) =>
            item.id === action.id
              ? { ...item, quantity: item.quantity - 1 } // 선택한 상품 수량을 감소시킨다.
              : item
          )
          .filter((item) => item.quantity > 0), // 수량이 0이면 장바구니에서 제거한다.
        message: '상품 수량을 줄였습니다.',
      };

    case 'REMOVE':
      return {
        ...state, // 기존 상태를 복사한다.
        cart: state.cart.filter((item) => item.id !== action.id), // 선택한 상품을 제거한다.
        message: '상품을 삭제했습니다.',
      };

    case 'TOGGLE_COUPON':
      return {
        ...state, // 기존 상태를 복사한다.
        couponApplied: !state.couponApplied, // 쿠폰 적용 여부를 반대로 바꾼다.
        message: !state.couponApplied
          ? '10% 할인 쿠폰을 적용했습니다.'
          : '쿠폰 적용을 취소했습니다.',
      };

    case 'CLEAR_CART':
      return {
        ...initialState, // 처음 상태로 되돌린다.
        message: '장바구니를 비웠습니다.',
      };

    default:
      return state; // 모르는 요청이면 기존 상태를 유지한다.
  }
}

export default function CartReducerExample() {
  const [state, dispatch] = useReducer(cartReducer, initialState); // 장바구니 상태와 요청 함수를 준비한다.

  const subtotal = state.cart.reduce(
    (sum, item) => sum + item.price * item.quantity,
    0
  ); // 상품 금액 합계다.

  const discount = state.couponApplied ? Math.floor(subtotal * 0.1) : 0; // 쿠폰 할인 금액이다.
  const deliveryFee = subtotal > 0 ? 3000 : 0; // 상품이 있을 때만 배송비를 붙인다.
  const total = subtotal - discount + deliveryFee; // 최종 결제 금액이다.

  return (
    <div>
      <h1>useReducer 장바구니 예제</h1>

      <section>
        <h2>상품 목록</h2>
        {products.map((product) => (
          <button
            key={product.id}
            onClick={() => dispatch({ type: 'ADD_PRODUCT', product })}

            {product.name}
            <br />
            {product.price.toLocaleString()}원
          </button>
        ))}
      </section>

      <section>
        <h2>장바구니</h2>
        {state.cart.length === 0 ? (
          <p>장바구니가 비어 있습니다.</p>
        ) : (
          state.cart.map((item) => (
            <div key={item.id}>
              <span>
                {item.name} / {item.price.toLocaleString()}원 / {item.quantity}개
              </span>
              <button onClick={() => dispatch({ type: 'DECREASE', id: item.id })}>-</button>
              <button onClick={() => dispatch({ type: 'INCREASE', id: item.id })}>+</button>
              <button onClick={() => dispatch({ type: 'REMOVE', id: item.id })}>삭제</button>
            </div>
          ))
        )}

        <button onClick={() => dispatch({ type: 'TOGGLE_COUPON' })}>
          {state.couponApplied ? '쿠폰 취소' : '10% 쿠폰 적용'}
        </button>
        <button onClick={() => dispatch({ type: 'CLEAR_CART' })}>
          장바구니 비우기
        </button>
      </section>

      <section>
        <h2>결제 정보</h2>
        <p>상품 금액: {subtotal.toLocaleString()}원</p>
        <p>할인 금액: -{discount.toLocaleString()}원</p>
        <p>배송비: {deliveryFee.toLocaleString()}원</p>
        <h3>최종 결제 금액: {total.toLocaleString()}원</h3>
        <p>{state.message}</p>
      </section>
    </div>
  );
}

이 코드는 장바구니 상태를 하나의 state 객체로 관리한다.
state.cart에는 장바구니 상품 배열이 들어가고, state.couponApplied에는 쿠폰 적용 여부가 들어가고, state.message에는 화면에 보여줄 안내 문구가 들어간다.


subtotal, discount, deliveryFee, total은 별도의 state로 저장하지 않는다.
현재 state.cart와 state.couponApplied를 기준으로 렌더링 중에 계산한다.
이 값들은 기존 상태에서 바로 계산할 수 있는 파생값이기 때문에 따로 state로 만들 필요가 없다.


처음 화면의 상태

처음 화면에서는 아직 장바구니에 담긴 상품이 없다.
그래서 initialState가 그대로 사용된다.

// EduApp17InitialStateExample.jsx
const initialState = {
  cart: [], // 장바구니가 비어 있다.
  couponApplied: false, // 쿠폰이 적용되지 않았다.
  message: '상품을 선택하세요.', // 처음 안내 문구다.
};

처음 state 값은 아래처럼 이해할 수 있다.

// [state 값 예시] 처음 렌더링 시점
// state.cart 값: []
// state.couponApplied 값: false
// state.message 값: "상품을 선택하세요."

계산값도 현재 상태를 기준으로 만들어진다.

// [계산값 예시] 처음 결제 정보
// subtotal: 0
// discount: 0
// deliveryFee: 0
// total: 0

장바구니가 비어 있기 때문에 상품 금액도 없고, 쿠폰 할인도 없고, 배송비도 붙지 않는다.

처음 화면에서는 아직 장바구니에 담긴 상품이 없다.
그래서 cart state는 빈 배열이고, 상품 금액, 할인 금액, 배송비, 최종 결제 금액이 모두 0원으로 표시된다.
message에는 처음값인 상품을 선택하세요.가 들어 있으므로 결제 정보 아래에도 같은 안내 문구가 보인다.


상품 추가 흐름

상품 목록에서 카페라떼 버튼을 누르면 아래 코드가 실행된다.

// EduApp17AddProductDispatchExample.jsx
<button onClick={() => dispatch({ type: 'ADD_PRODUCT', product })}>
  카페라떼
</button>

이 버튼은 상품을 직접 장바구니에 넣지 않는다.
dispatch()를 호출해서 ADD_PRODUCT 요청을 보낸다.


카페라떼 상품을 눌렀다면 action은 아래처럼 전달된다.

// [action 값 예시] 카페라떼 상품 추가 요청
// action.type: "ADD_PRODUCT"
// action.product:
// {
//   id: 2,
//   name: "카페라떼",
//   price: 5500
// }

이 요청을 받은 cartReducer()는 먼저 같은 상품이 이미 장바구니에 있는지 확인한다.

// EduApp17AddProductExistsExample.jsx
const exists = state.cart.find((item) => item.id === action.product.id);

처음 장바구니는 빈 배열이므로 같은 상품이 없다.
그래서 새 상품 객체에 quantity: 1을 붙여 장바구니 배열에 추가한다.

// EduApp17AddProductNewItemExample.jsx
return {
  ...state, // 기존 상태를 복사한다.
  cart: [...state.cart, { ...action.product, quantity: 1 }], // 새 상품을 추가한다.
  message: `${action.product.name} 상품을 추가했습니다.`, // 안내 메시지를 바꾼다.
};

상품 추가 후 state 값은 아래처럼 바뀐다.

// [state 값 예시] 카페라떼 추가 후
// state.cart 값:
// [
//   {
//     id: 2,
//     name: "카페라떼",
//     price: 5500,
//     quantity: 1
//   }
// ]
// state.couponApplied 값: false
// state.message 값: "카페라떼 상품을 추가했습니다."

결제 정보도 현재 state.cart를 기준으로 다시 계산된다.

// [계산값 예시] 카페라떼 1개 추가 후
// subtotal: 5500
// discount: 0
// deliveryFee: 3000
// total: 8500

상품 금액은 5,500원이다.
쿠폰이 적용되지 않았으므로 할인 금액은 0원이다.
장바구니에 상품이 있으므로 배송비 3,000원이 붙는다.
그래서 최종 결제 금액은 8,500원이 된다.

카페라떼 버튼을 누르면 dispatch({ type: "ADD_PRODUCT", product })가 실행된다.
cartReducer()는 기존 장바구니에 같은 상품이 있는지 확인하고, 없으면 quantity: 1을 붙인 새 상품 객체를 cart 배열에 추가한다.
상품 금액은 5,500원, 배송비는 3,000원, 최종 결제 금액은 8,500원으로 계산된다.


같은 상품을 다시 추가하는 경우

이미 장바구니에 있는 상품을 다시 누르면 새 항목을 하나 더 만드는 것이 아니라 기존 상품의 수량을 증가시킨다.
이 처리는 exists 값이 있을 때 실행된다.

// EduApp17AddSameProductExample.jsx
if (exists) {
  return {
    ...state, // 기존 상태를 복사한다.
    cart: state.cart.map((item) =>
      item.id === action.product.id
        ? { ...item, quantity: item.quantity + 1 } // 같은 상품이면 수량을 증가시킨다.
        : item
    ),
    message: `${action.product.name} 수량을 1개 늘렸습니다.`,
  };
}

이 코드에서 map()은 장바구니 배열을 하나씩 확인한다.
상품 id가 방금 누른 상품과 같으면 quantity를 1 증가시킨 새 객체를 반환한다.
다른 상품이면 기존 상품을 그대로 반환한다.


여기서도 기존 state.cart 배열을 직접 수정하지 않는다.
새 배열과 새 객체를 만들어 반환한다.
useReducer()에서도 기존 상태를 직접 바꾸지 않는 기준은 그대로 적용된다.


여러 상품을 추가한 상태

카페라떼와 치즈케이크를 장바구니에 추가하면 state.cart에는 상품 객체가 두 개 들어간다.

// [state 값 예시] 카페라떼와 치즈케이크 추가 후
// state.cart 값:
// [
//   {
//     id: 2,
//     name: "카페라떼",
//     price: 5500,
//     quantity: 1
//   },
//   {
//     id: 3,
//     name: "치즈케이크",
//     price: 7000,
//     quantity: 1
//   }
// ]
// state.couponApplied 값: false
// state.message 값: "치즈케이크 상품을 추가했습니다."

이때 상품 금액은 두 상품 가격을 더해서 계산된다.

// [계산값 예시] 카페라떼와 치즈케이크 추가 후
// subtotal: 5500 + 7000 = 12500
// discount: 0
// deliveryFee: 3000
// total: 12500 + 3000 = 15500

각 상품 옆에는 -, +, 삭제 버튼이 있다.
이 버튼들은 각각 다른 action을 dispatch()로 보낸다.

  • - 버튼 → DECREASE
  • + 버튼 → INCREASE
  • 삭제 버튼 → REMOVE

이렇게 장바구니 상태 변경 요청이 여러 종류로 늘어나기 때문에 useReducer()가 적합하다.

카페라떼와 치즈케이크가 장바구니에 들어가면 cart state에는 상품 객체가 두 개 저장된다.
각 상품 옆의 -, +, 삭제 버튼은 각각 DECREASE, INCREASE, REMOVE 요청을 dispatch()로 보낸다.
아직 쿠폰을 적용하지 않았으므로 할인 금액은 0원이고, 상품 금액 12,500원에 배송비 3,000원이 더해져 최종 결제 금액은 15,500원이 된다.


수량 증가, 수량 감소, 상품 삭제 흐름

+ 버튼을 누르면 INCREASE 요청이 전달된다.

// EduApp17IncreaseDispatchExample.jsx
<button onClick={() => dispatch({ type: 'INCREASE', id: item.id })}>
  +
</button>

cartReducer()는 action.id와 같은 상품을 찾아 수량을 1 증가시킨다.

// EduApp17IncreaseReducerExample.jsx
case 'INCREASE':
  return {
    ...state, // 기존 상태를 복사한다.
    cart: state.cart.map((item) =>
      item.id === action.id
        ? { ...item, quantity: item.quantity + 1 } // 선택한 상품 수량을 증가시킨다.
        : item
    ),
    message: '상품 수량을 늘렸습니다.',
  };

- 버튼을 누르면 DECREASE 요청이 전달된다.

// EduApp17DecreaseDispatchExample.jsx
<button onClick={() => dispatch({ type: 'DECREASE', id: item.id })}>
  -
</button>

DECREASE는 선택한 상품의 수량을 1 줄인다.
그리고 수량이 0이 된 상품은 장바구니에서 제거한다.

// EduApp17DecreaseReducerExample.jsx
case 'DECREASE':
  return {
    ...state, // 기존 상태를 복사한다.
    cart: state.cart
      .map((item) =>
        item.id === action.id
          ? { ...item, quantity: item.quantity - 1 } // 선택한 상품 수량을 감소시킨다.
          : item
      )
      .filter((item) => item.quantity > 0), // 수량이 0이면 제거한다.
    message: '상품 수량을 줄였습니다.',
  };

삭제 버튼을 누르면 REMOVE 요청이 전달된다.

// EduApp17RemoveDispatchExample.jsx
<button onClick={() => dispatch({ type: 'REMOVE', id: item.id })}>
  삭제
</button>

REMOVE는 선택한 상품을 장바구니 배열에서 제외한다.

// EduApp17RemoveReducerExample.jsx
case 'REMOVE':
  return {
    ...state, // 기존 상태를 복사한다.
    cart: state.cart.filter((item) => item.id !== action.id), // 선택한 상품을 제거한다.
    message: '상품을 삭제했습니다.',
  };

세 요청 모두 공통점이 있다.
기존 장바구니 배열을 직접 수정하지 않는다.
map()이나 filter()로 새 배열을 만들어 반환한다.


useReducer()에서도 기존 state를 직접 바꾸지 않고, 새 state를 만들어 반환해야 한다.


쿠폰 적용 흐름

10% 쿠폰 적용 버튼을 누르면 TOGGLE_COUPON 요청이 전달된다.

// EduApp17CouponDispatchExample.jsx
<button onClick={() => dispatch({ type: 'TOGGLE_COUPON' })}>
  10% 쿠폰 적용
</button>

cartReducer()는 couponApplied 값을 반대로 바꾼다.
처음에는 false였으므로 버튼을 누르면 true가 된다.

// EduApp17CouponReducerExample.jsx
case 'TOGGLE_COUPON':
  return {
    ...state, // 기존 상태를 복사한다.
    couponApplied: !state.couponApplied, // 쿠폰 적용 여부를 반대로 바꾼다.
    message: !state.couponApplied
      ? '10% 할인 쿠폰을 적용했습니다.'
      : '쿠폰 적용을 취소했습니다.',
  };

쿠폰 적용 후 state 값은 아래처럼 바뀐다.

// [state 값 예시] 쿠폰 적용 후
// state.cart 값:
// [
//   { id: 2, name: "카페라떼", price: 5500, quantity: 1 },
//   { id: 3, name: "치즈케이크", price: 7000, quantity: 1 }
// ]
// state.couponApplied 값: true
// state.message 값: "10% 할인 쿠폰을 적용했습니다."

이제 결제 정보가 다시 계산된다.

// [계산값 예시] 쿠폰 적용 후
// subtotal: 12500
// discount: Math.floor(12500 * 0.1) = 1250
// deliveryFee: 3000
// total: 12500 - 1250 + 3000 = 14250

discount는 couponApplied 값이 true일 때만 계산된다.
상품 금액 12,500원의 10%는 1,250원이다.
그래서 최종 결제 금액은 14,250원이 된다.

10% 쿠폰 적용 버튼을 누르면 dispatch({ type: "TOGGLE_COUPON" })이 실행된다.
cartReducer()는 couponApplied 값을 false에서 true로 바꾸고, 메시지를 10% 할인 쿠폰을 적용했습니다.로 변경한다.
상품 금액 12,500원의 10%인 1,250원이 할인되고, 배송비 3,000원이 더해져 최종 결제 금액은 14,250원이 된다.


장바구니 비우기 흐름

장바구니 비우기 버튼을 누르면 CLEAR_CART 요청이 전달된다.

// EduApp17ClearCartDispatchExample.jsx
<button onClick={() => dispatch({ type: 'CLEAR_CART' })}>
  장바구니 비우기
</button>

cartReducer()는 상태를 처음 상태로 되돌린다.

// EduApp17ClearCartReducerExample.jsx
case 'CLEAR_CART':
  return {
    ...initialState, // 처음 상태로 되돌린다.
    message: '장바구니를 비웠습니다.',
  };

initialState를 다시 펼쳐서 반환하므로 cart는 빈 배열이 되고, couponApplied는 false로 돌아간다.
다만 message는 장바구니를 비웠습니다.로 바꿔서 사용자가 방금 어떤 동작을 했는지 알 수 있게 한다.


장바구니 비우기 후 상태는 아래처럼 된다.

// [state 값 예시] 장바구니 비우기 후
// state.cart 값: []
// state.couponApplied 값: false
// state.message 값: "장바구니를 비웠습니다."

계산값은 다시 전부 0이 된다.

// [계산값 예시] 장바구니 비우기 후
// subtotal: 0
// discount: 0
// deliveryFee: 0
// total: 0



결제 금액은 state에서 바로 계산한다

EduApp17.jsx에서 결제 금액 관련 값은 따로 state로 저장하지 않는다.
현재 장바구니 상태를 기준으로 렌더링 중 계산한다.

// EduApp17PaymentCalculationExample.jsx
const subtotal = state.cart.reduce(
  (sum, item) => sum + item.price * item.quantity,
  0
); // 상품 금액 합계다.

const discount = state.couponApplied ? Math.floor(subtotal * 0.1) : 0; // 쿠폰 할인 금액이다.
const deliveryFee = subtotal > 0 ? 3000 : 0; // 상품이 있으면 배송비를 붙인다.
const total = subtotal - discount + deliveryFee; // 최종 결제 금액이다.

subtotal은 장바구니에 담긴 상품 가격과 수량을 곱해 모두 더한 값이다.


discount는 쿠폰이 적용되었을 때 상품 금액의 10%로 계산한다.
쿠폰이 적용되지 않았다면 0이다.


deliveryFee는 상품이 하나라도 있으면 3,000원이고, 장바구니가 비어 있으면 0원이다.


total은 상품 금액에서 할인 금액을 빼고, 배송비를 더한 최종 결제 금액이다.


이 값들은 state.cart와 state.couponApplied가 바뀌면 자동으로 다시 계산된다.
그래서 별도의 setSubtotal, setDiscount, setTotal 같은 상태 변경 함수가 필요하지 않다.


현재 state로 바로 계산할 수 있는 값은 별도의 state로 만들지 않고 렌더링 중 계산하는 것이 더 단순하다.


useReducer 전체 흐름 정리

EduApp17.jsx의 전체 흐름은 아래처럼 정리할 수 있다.

  • 사용자가 상품 버튼이나 장바구니 버튼을 클릭한다.
  • 버튼에 연결된 이벤트 핸들러가 dispatch(action)을 호출한다.
  • action.type에는 어떤 동작인지 들어 있다.
  • 필요한 경우 action.product나 action.id도 함께 전달된다.
  • cartReducer(state, action)이 실행된다.
  • cartReducer()는 action.type을 보고 새 state를 반환한다.
  • 새 state가 저장되면 컴포넌트가 다시 렌더링된다.
  • 렌더링 중 subtotal, discount, deliveryFee, total이 다시 계산된다.
  • 변경된 장바구니와 결제 정보가 화면에 표시된다.

useReducer() 흐름은 컴포넌트가 dispatch(action)으로 상태 변경을 요청하고, reducer가 현재 state와 action을 받아 새 state를 반환하는 구조다.
새 state가 저장되면 컴포넌트가 다시 렌더링되고, 변경된 장바구니와 결제 정보가 화면에 반영된다.


핵심 정리

useReducer()는 복잡한 상태 변경 로직을 reducer 함수로 분리해서 관리하는 Hook이다.
상태 변경 종류가 많거나 여러 값이 함께 바뀔 때 사용하면 변경 규칙을 한곳에 모을 수 있다.


dispatch()는 상태 변경 요청을 보내는 함수다.
action은 어떤 변경을 할지 알려주는 요청 정보다.
reducer는 현재 state와 action을 받아 새로운 state를 반환한다.


카운터 예제에서는 dispatch({ type: 'increase' })로 증가 요청을 보내고, dispatch({ type: 'decrease' })로 감소 요청을 보낸다.
실제 증가와 감소 규칙은 reducer 함수의 switch 문 안에서 처리된다.


입력 폼 예제에서는 여러 입력값을 하나의 객체 상태로 관리한다.
입력값이 바뀌면 dispatch()로 변경 요청을 보내고, reducer가 [action.name]을 기준으로 바뀐 입력칸만 새 값으로 변경한다.


EduApp17 장바구니 예제에서는 상품 추가, 수량 증가, 수량 감소, 상품 삭제, 쿠폰 적용, 장바구니 비우기처럼 여러 상태 변경 요청이 등장한다.
이런 요청을 각각 useState()로 흩어 관리하면 코드가 복잡해질 수 있다.
useReducer()를 사용하면 모든 상태 변경 규칙을 cartReducer() 안에 모아 관리할 수 있다.


또한 상품 금액, 할인 금액, 배송비, 최종 결제 금액처럼 현재 상태로 바로 계산할 수 있는 값은 별도의 state로 만들지 않는다.
현재 state.cart와 state.couponApplied를 기준으로 렌더링 중 계산하면 흐름이 더 단순해진다.


마지막으로 useState()와 useReducer()는 목적이 다르다.
단순한 상태는 useState(), 변경 규칙이 많은 상태는 useReducer()로 관리하면 흐름을 더 명확하게 정리할 수 있다.




참조로 렌더링과 무관한 값 기억하기 (useRef)

useRef()는 렌더링과 직접 관련이 없는 값을 기억하거나, 실제 DOM 요소를 직접 가리킬 때 사용하는 Hook이다.
useState()처럼 값을 저장할 수 있지만, 값이 바뀐다고 해서 화면이 다시 렌더링되지는 않는다.


예를 들어 타이머를 멈추기 위해 타이머 ID를 기억해야 할 수 있다.
또 특정 input에 자동으로 커서를 넣거나, 화면 아래쪽으로 스크롤을 이동시키거나, 동영상을 재생하고 멈춰야 할 수도 있다.
이런 경우에는 화면에 보여줄 값이 아니라 내부적으로 기억하거나 실제 요소를 가리키는 값이 필요하다.
즉, useRef()는 화면을 다시 그리지 않으면서 값을 기억하거나, 특정 DOM 요소에 접근하기 위한 Hook이다.


5-1. 참조의 기본 개념

렌더링과 직접 관련 없는 값을 기억한다

useRef()는 렌더링에 필요하지 않은 값을 참조할 때 사용하는 Hook이다.
여기서 참조한다는 말은 어떤 값을 계속 기억하고 있다가 필요할 때 꺼내 쓴다는 뜻이다.


useRef()를 호출하면 값 자체가 바로 반환되는 것이 아니다.
current라는 속성을 가진 객체가 반환된다.
그래서 실제 값은 ref.current에 저장된다.


기본 구조는 아래처럼 볼 수 있다.

// UseRefBasicSyntax.jsx
import { useRef } from 'react'; // ref를 사용하기 위해 가져온다.

export default function UseRefBasicSyntax() {
  const countRef = useRef(0); // current에 0을 저장한 ref 객체를 만든다.

  function increaseRefValue() {
    countRef.current += 1; // current 값만 바꾼다.
    console.log(countRef.current); // 화면 렌더링 없이 값만 확인한다.
  }

  return (
    <button onClick={increaseRefValue}>
      ref 값 증가
    </button>
  );
}

useRef(0)을 실행하면 { current: 0 } 형태의 객체가 만들어진다.
그리고 countRef.current를 통해 값을 읽거나 바꿀 수 있다.


중요한 점은 countRef.current += 1처럼 값을 바꿔도 컴포넌트가 다시 렌더링되지 않는다는 것이다.
그래서 화면에 바로 보여줄 값이라면 useRef()가 아니라 useState()를 사용하는 것이 맞다.


참조의 특징

useRef()는 렌더링 사이에서 값을 유지한다.
컴포넌트 함수가 다시 실행되어도 ref 객체 자체는 유지되기 때문에, current에 저장된 값을 계속 기억할 수 있다.


특징을 정리하면 아래와 같다.

  • useRef(초기값)은 { current: 초기값 } 형태의 객체를 반환한다.
  • ref.current를 읽고 쓸 수 있다.
  • ref.current를 바꿔도 화면이 다시 렌더링되지 않는다.
  • 컴포넌트가 다시 렌더링되어도 같은 ref 객체가 유지된다.
  • 실제 DOM 요소를 가리키는 데 사용할 수 있다.

화면에 보여줄 값이 아니라 내부적으로 기억해야 하는 값이라면 useRef()가 적합하다.
반대로 값이 바뀌었을 때 화면이 같이 바뀌어야 한다면 useState()가 적합하다.

useRef()는 값을 저장할 수 있지만, 값 변경만으로 화면을 다시 그리지는 않는다.
ref.current에 값을 넣어두면 렌더링 사이에서도 그 값이 유지된다.
그래서 타이머 ID, 이전 값, 임시 플래그처럼 화면에 바로 표시할 필요는 없지만 기억해야 하는 값을 저장할 때 적합하다.
또 실제 input, div, video 같은 DOM 요소를 직접 가리키는 용도로도 사용할 수 있다.


5-2. 상태와 참조의 차이

화면에 보여줄 값은 상태로 관리한다

useState()와 useRef()는 둘 다 값을 저장할 수 있다.
하지만 목적이 다르다.


useState()는 화면에 보여줄 값을 관리할 때 사용한다.
값이 바뀌면 컴포넌트가 다시 렌더링되고, 바뀐 값이 화면에 반영된다.


예를 들어 버튼을 누를 때 숫자가 화면에서 바로 바뀌어야 한다면 useState()가 적합하다.

// StateCounterExample.jsx
import { useState } from 'react'; // state를 사용하기 위해 가져온다.

export default function StateCounterExample() {
  const [count, setCount] = useState(0); // 화면에 보여줄 숫자 상태다.

  return (
    <div>
      <p>화면에 보이는 값: {count}</p>
      <button onClick={() => setCount(count + 1)}>
        증가
      </button>
    </div>
  );
}

이 코드에서 setCount(count + 1)이 실행되면 count 값이 바뀐다.
count는 state이므로 값이 바뀌면 컴포넌트가 다시 렌더링된다.
그래서 화면에도 새 숫자가 보인다.


화면과 무관하게 기억할 값은 참조로 관리한다

useRef()는 값이 바뀌어도 화면을 다시 그리지 않는다.
그래서 화면에 직접 보여줄 필요는 없지만, 내부적으로 기억해야 하는 값에 적합하다.


예를 들어 타이머 ID는 화면에 보여줄 값이 아니다.
하지만 타이머를 멈출 때 필요하므로 어딘가에 저장해두어야 한다.
이런 값은 useRef()로 관리하는 것이 자연스럽다.

// RefValueExample.jsx
import { useRef } from 'react'; // ref를 사용하기 위해 가져온다.

export default function RefValueExample() {
  const countRef = useRef(0); // 화면 렌더링과 무관한 값을 저장한다.

  function increaseRef() {
    countRef.current += 1; // 값은 바뀌지만 화면은 다시 렌더링되지 않는다.
    console.log(countRef.current); // 콘솔에서만 확인할 수 있다.
  }

  return (
    <button onClick={increaseRef}>
      ref 값 증가
    </button>
  );
}

이 코드에서 버튼을 눌러도 화면에는 바뀐 값이 바로 표시되지 않는다.
ref.current 값은 바뀌지만, useRef()는 렌더링을 발생시키지 않기 때문이다.


이 차이를 모르면 화면에 보여야 하는 값을 useRef()로 관리해서 “값은 바뀐 것 같은데 화면은 왜 안 바뀌지?”라는 문제가 생길 수 있다.

useState()는 값이 바뀌면 화면을 다시 렌더링한다.
그래서 화면에 표시할 값, 사용자 입력값, 서버에서 받아와 화면에 보여줄 데이터처럼 화면과 연결된 값에 적합하다.
반면 useRef()는 값이 바뀌어도 화면을 다시 렌더링하지 않는다.
그래서 타이머 ID, 이전 값, 임시 저장값, 실제 DOM 요소 참조처럼 화면 변경과 직접 연결되지 않는 값에 적합하다.


5-3. 인수와 반환값 구조 이해하기

참조 객체의 반환 구조

useRef(initialValue)는 초기값을 인수로 받는다.
여기서 인수는 함수에 전달하는 값을 의미한다.
initialValue는 처음에 current에 넣어둘 값이다.


useRef()의 반환값은 값 자체가 아니라 current 속성을 가진 객체다.
그래서 아래처럼 이해하면 된다.

// UseRefReturnShapeExample.jsx
import { useRef } from 'react'; // ref를 사용하기 위해 가져온다.

export default function UseRefReturnShapeExample() {
  const inputRef = useRef(null); // current의 처음 값은 null이다.

  function printRef() {
    console.log(inputRef.current); // current에 저장된 값을 확인한다.
  }

  return (
    <button onClick={printRef}>
      ref 확인
    </button>
  );
}

useRef(null)은 처음에 { current: null }과 비슷한 객체를 만든다.
그 뒤 inputRef.current에 실제 값이나 DOM 요소가 들어갈 수 있다.


current라는 이름은 중요하다.
useRef()로 만든 객체에서 실제 값을 담는 공간이 바로 current이기 때문이다.


초기값은 처음 렌더링 때 사용된다

useRef(initialValue)에서 전달한 초기값은 처음 렌더링 때 current의 시작값으로 사용된다.
컴포넌트가 다시 렌더링되어도 같은 ref 객체가 유지된다.


예를 들어 useRef(0)을 쓰면 처음에는 current가 0이다.
그 뒤 current를 5로 바꾸면, 다시 렌더링되어도 같은 ref 객체가 유지되므로 current 값은 계속 기억된다.


정리하면 아래처럼 볼 수 있다.

  • useRef(0) → 처음에는 { current: 0 }
  • ref.current = 5 → current 값이 5로 바뀜
  • 값이 바뀌어도 렌더링은 발생하지 않음
  • 다음 렌더링에서도 같은 ref 객체가 유지됨

이 구조 때문에 useRef()는 렌더링 사이에서 값을 잃지 않고 기억할 수 있다.

useRef()는 초기값을 받아 current 속성을 가진 객체를 반환한다.
처음에는 current에 초기값이 들어가고, 이후에는 ref.current를 통해 값을 읽거나 바꾼다.
current를 바꿔도 화면은 다시 렌더링되지 않는다.
따라서 useRef()는 화면 표시용 값이 아니라 렌더링과 무관하게 유지해야 하는 값이나 실제 DOM 참조를 저장하는 데 사용한다.


5-4. 실제 화면 요소에 접근하기

자동 포커스 예시

useRef()는 실제 DOM 요소를 직접 가리킬 때 사용할 수 있다.
기존 방식의 document.getElementById()와 비슷하게 특정 요소를 찾아 조작할 수 있지만, React에서는 보통 ref를 사용해 필요한 요소를 연결한다.


아래 예제는 컴포넌트가 화면에 나타난 뒤 input에 자동으로 포커스를 주는 코드다.

// AutoFocusExample.jsx
import { useEffect, useRef } from 'react'; // effect와 ref를 사용한다.

export default function AutoFocusExample() {
  const inputRef = useRef(null); // input 요소를 담을 ref다.

  useEffect(() => {
    inputRef.current.focus(); // 화면에 나타난 뒤 input에 포커스를 준다.
  }, []); // 처음 나타날 때 한 번만 실행한다.

  return <input ref={inputRef<} placeholder="자동으로 포커스" />;
}

input 태그에 ref={inputRef}를 넣으면 실제 input 요소가 inputRef.current에 연결된다.
그 뒤 useEffect() 안에서 inputRef.current.focus()를 실행하면 해당 입력창에 커서가 들어간다.


여기서 focus()는 입력창에 커서를 이동시키는 DOM 메서드다.
자동으로 검색창이나 로그인 입력창에 커서를 넣고 싶을 때 이런 흐름을 사용할 수 있다.


스크롤 이동 예시

아래 예제는 버튼을 누르면 화면의 맨 아래 위치로 이동하는 코드다.
bottomRef는 이동하고 싶은 위치의 div를 가리킨다.

// ScrollToBottomExample.jsx
import { useRef } from 'react'; // ref를 사용한다.

export default function ScrollToBottomExample() {
  const bottomRef = useRef(null); // 아래쪽 div를 가리킬 ref다.

  function scrollDown() {
    bottomRef.current.scrollIntoView({ behavior: 'smooth' }); // 해당 위치로 부드럽게 이동한다.
  }

  return (
    <div>
      <button onClick={scrollDown}>맨 아래로</button>
      <div style={{ height: 1000 }}>긴 내용...</div>
      <div ref={bottomRef}>맨 아래</div>
    </div>
  );
}

scrollIntoView()는 특정 요소가 화면에 보이도록 스크롤을 이동시키는 DOM 메서드다.
bottomRef.current가 맨 아래 div를 가리키고 있으므로, 버튼을 누르면 그 위치로 화면이 이동한다.


이 예제처럼 실제 화면 요소의 위치나 동작을 제어해야 할 때 useRef()를 사용할 수 있다.


동영상 재생 제어 예시

useRef()는 video 요소처럼 실제 DOM 메서드를 직접 호출해야 하는 경우에도 사용할 수 있다.
아래 예제는 버튼으로 동영상을 재생하거나 정지하는 흐름이다.

// VideoPlayerExample.jsx
import { useRef } from 'react'; // ref를 사용한다.

export default function VideoPlayerExample() {
  const videoRef = useRef(null); // video 요소를 가리킬 ref다.

  return (
    <div>
      <video ref={videoRef} src="/video.mp4" />
      <button onClick={() => videoRef.current.play()}>
        재생
      </button>
      <button onClick={() => videoRef.current.pause()}>
        정지
      </button>
    </div>
  );
}

videoRef.current에는 실제 video 요소가 연결된다.
그래서 videoRef.current.play()를 실행하면 동영상이 재생되고, videoRef.current.pause()를 실행하면 동영상이 멈춘다.


이처럼 useRef()는 화면에 표시할 값을 관리하기보다 실제 DOM 요소에 접근해 특정 동작을 실행할 때도 사용된다.


5-5. 렌더링 없이 값 저장하기

타이머 식별자 저장 예시

타이머를 사용할 때는 setInterval()이 반환하는 ID를 기억해야 한다.
그래야 나중에 clearInterval()로 정확히 해당 타이머를 멈출 수 있다.


타이머 ID는 화면에 보여줄 값이 아니다.
따라서 useState()로 관리해서 렌더링을 발생시킬 필요가 없다.
이런 값은 useRef()에 저장하는 것이 적합하다.

// TimerRefExample.jsx
import { useRef, useState } from 'react'; // ref와 state를 사용한다.

export default function TimerRefExample() {
  const [seconds, setSeconds] = useState(0); // 화면에 보여줄 초 상태다.
  const timerRef = useRef(null); // 타이머 ID를 저장할 ref다.

  function start() {
    timerRef.current = setInterval(() => {
      setSeconds((seconds) => seconds + 1); // 화면에 보여줄 초는 state로 변경한다.
    }, 1000);
  }

  function stop() {
    clearInterval(timerRef.current); // ref에 저장한 타이머 ID로 정확히 멈춘다.
  }

  return (
    <div>
      <p>{seconds}초</p>
      <button onClick={start}>시작</button>
      <button onClick={stop}>정지</button>
    </div>
  );
}

이 예제에서 seconds는 화면에 보여야 하므로 useState()로 관리한다.
반면 timerRef.current에 저장되는 타이머 ID는 화면에 보일 필요가 없으므로 useRef()로 관리한다.


이렇게 역할을 나누면 화면에 보여야 하는 값과 내부적으로만 필요한 값을 구분할 수 있다.


렌더링 횟수 추적 예시

useRef()는 렌더링 횟수처럼 화면을 다시 렌더링시키지 않고 기억하고 싶은 값에도 사용할 수 있다.
아래 예제는 입력값이 바뀔 때마다 컴포넌트가 다시 렌더링되고, 그 횟수를 ref로 저장한다.

// RenderCounterRefExample.jsx
import { useRef, useState } from 'react'; // ref와 state를 사용한다.

export default function RenderCounterRefExample() {
  const [value, setValue] = useState(''); // input 입력값 상태다.
  const renderCount = useRef(0); // 렌더링 횟수를 저장한다.

  renderCount.current += 1; // 렌더링될 때마다 값만 증가시킨다.

  return (
    <div>
      <input value={value} onChange={(event) => setValue(event.target.value)} />
      <p>렌더링 횟수: {renderCount.current}</p>
    </div>
  );
}

renderCount.current += 1은 ref 값을 바꾼다.
하지만 이 값 변경 자체가 렌더링을 새로 발생시키지는 않는다.


렌더링은 setValue() 때문에 발생한다.
input에 값을 입력하면 value 상태가 바뀌고, 상태 변경으로 컴포넌트가 다시 렌더링된다.
그때마다 renderCount.current가 증가한다.


다만 이 예제는 렌더링 횟수를 확인하기 위한 디버깅용 예시다.
렌더링 중에 ref.current 값을 바꾸는 형태이므로, 실제 화면 결과를 만드는 핵심 로직에서 같은 방식을 남용하면 안 된다.
뒤에서 정리하듯이 ref.current를 읽거나 쓰는 작업은 보통 이벤트 핸들러나 useEffect() 안에서 처리하는 것이 안전하다.


입력값은 저장되지만 화면은 바로 바뀌지 않는 예시

아래 예제는 EduApp12.jsx의 핵심 흐름이다.
input에 값을 입력하면 idRef.current에는 값이 저장된다.
하지만 ref 값이 바뀌는 것만으로는 화면이 다시 렌더링되지 않는다.

처음 화면에서는 renderCount state가 0이고, idRef.current에는 아직 표시할 값이 없다.
useRef()에 저장된 값은 화면을 다시 렌더링시키지 않으므로, 입력 전에는 현재 Ref 값도 비어 있는 상태로 보인다.

// EduApp12.jsx
import React, { useState, useRef } from 'react'; // state와 ref를 사용한다.

export default function RefNeedCheck() {
  const [renderCount, setRenderCount] = useState(0); // 화면 렌더링 횟수 표시용 상태다.
  const idRef = useRef(''); // 입력한 ID를 저장할 ref다.

  function handleRender() {
    setRenderCount((prev) => prev + 1); // 강제로 렌더링 횟수를 증가시킨다.
  }

  function handleIdChange(event) {
    idRef.current = event.target.value; // 값은 저장되지만 화면은 다시 그려지지 않는다.
    console.log('저장된 ID:', idRef.current); // 콘솔에서 저장 값을 확인한다.
  }

  return (
    <div>
      <h2>1. 성능 체커</h2>
      <p>전체 화면 렌더링 횟수: <b>{renderCount}</b></p>
      <input
        type="text"
        placeholder="아이디 입력"
        onChange={handleIdChange}
      />
      <button onClick={handleRender}>
        화면 강제 업데이트
      </button>
      <p>현재 Ref에 담긴 값: {idRef.current}</p>
      <small>입력을 마친 후 강제 업데이트를 눌러야 입력한 값이 화면에 보인다.</small>
    </div>
  );
}

input에 글자를 입력하면 handleIdChange()가 실행된다.
이때 idRef.current에는 입력값이 저장된다.
하지만 setState()를 호출한 것이 아니기 때문에 화면은 다시 렌더링되지 않는다.

input에 글자를 입력하면 handleIdChange()가 실행되고, 입력값이 idRef.current에 저장된다.
콘솔에는 저장된 ID 로그가 계속 찍히므로 ref 값이 바뀌고 있다는 것을 확인할 수 있다.
하지만 idRef.current를 바꾸는 것만으로는 React가 화면을 다시 렌더링하지 않기 때문에 화면의 현재 Ref 값은 바로 바뀌지 않는다.


여기서 초보자가 헷갈리기 쉬운 부분이 있다.
input 칸 안의 글자는 브라우저 입력 요소 자체에 보이는 값이다.
하지만 React가 렌더링한 현재 Ref에 담긴 값 문구는 state 변경이 일어나기 전까지 다시 계산되지 않는다.


그래서 input에 입력한 글자는 화면에 보이지만, 아래 문구는 바로 바뀌지 않는다.

// [화면 이해 예시]
// input 칸 안의 글자 → 브라우저 입력 요소가 보여주는 값
// 현재 Ref에 담긴 값 → React가 렌더링한 문구
// idRef.current 변경만으로는 React 렌더링이 다시 일어나지 않음

화면 강제 업데이트 버튼을 누르면 setRenderCount()가 실행된다.
renderCount state가 바뀌면 컴포넌트가 다시 렌더링된다.
그때 이전에 idRef.current에 저장해둔 값이 화면에 표시된다.

화면 강제 업데이트 버튼을 누르면 setRenderCount()가 실행된다.
renderCount state가 바뀌면서 컴포넌트가 다시 렌더링된다.
이때 이전에 idRef.current에 저장되어 있던 입력값이 화면에 표시된다.
즉, ref 값은 이미 바뀌어 있었지만, 화면에 보이려면 별도의 state 변경으로 렌더링이 다시 일어나야 한다.


이 예제의 핵심은 ref.current 값이 실제로 바뀌고 있어도, 그 변경만으로는 화면이 다시 렌더링되지 않는다는 점이다.
화면에 즉시 보여야 하는 값은 useState(), 화면과 무관하게 기억할 값은 useRef()로 관리해야 한다.


5-6. 이전 값 기억하기

이전 값을 저장하는 패턴

useRef()는 이전 값을 기억할 때도 사용할 수 있다.
현재 값은 state로 관리하고, 렌더링이 끝난 뒤 현재 값을 ref.current에 저장해두면 다음 렌더링에서 이전 값을 비교할 수 있다.


아래 예제는 현재 count와 이전 count를 함께 보여준다.

// PrevValueExample.jsx
import { useEffect, useRef, useState } from 'react'; // state, effect, ref를 사용한다.

export default function PrevValueExample() {
  const [count, setCount] = useState(0); // 현재 count 상태다.
  const prevCount = useRef(0); // 이전 count를 저장할 ref다.

  useEffect(() => {
    prevCount.current = count; // 렌더링 후 현재 값을 이전 값 저장소에 넣는다.
  });

  return (
    <div>
      <p>현재: {count} / 이전: {prevCount.current}</p>
      <button onClick={() => setCount((count) => count + 1)}>
        +1
      </button>
    </div>
  );
}

처음 화면에서는 count가 0이고, prevCount.current도 0이다.
버튼을 눌러 count가 바뀌면 화면이 다시 렌더링된다.
렌더링이 끝난 뒤 useEffect()가 실행되고, 현재 count를 prevCount.current에 저장한다.


이 흐름을 사용하면 이전 값과 현재 값을 비교하는 기능을 만들 수 있다.
예를 들어 점수가 올랐는지, 입력값이 이전과 달라졌는지 확인할 수 있다.


다만 이 예제는 이전 값 저장 흐름을 이해하기 위한 학습 예제다.
화면 결과를 만드는 핵심 데이터는 기본적으로 state로 관리하고, ref는 렌더링 사이에서 기억해야 하는 보조 값으로 사용하는 것이 안전하다.


점수의 이전 값을 기억하는 예시

아래 예제는 EduApp13.jsx의 핵심 흐름이다.
현재 점수는 score state로 관리하고, 이전 점수는 prevScoreRef.current에 저장한다.

// EduApp13.jsx
import React, { useState, useEffect, useRef } from 'react'; // state, effect, ref를 사용한다.

export default function PrevValueCheck() {
  const [score, setScore] = useState(0); // 현재 점수 상태다.
  const prevScoreRef = useRef(); // 이전 점수를 저장할 ref다.

  useEffect(() => {
    prevScoreRef.current = score; // 렌더링이 끝난 후 현재 점수를 이전 점수 저장소에 넣는다.
  }, [score]); // score가 바뀔 때마다 실행한다.

  const prevScore = prevScoreRef.current; // 화면에서 사용할 이전 점수다.

  return (
    <div>
      <h2>2. 기록 저장소</h2>
      <h3>현재 점수: {score}</h3>
      <h3>이전 점수: {prevScore !== undefined ? prevScore : '-'}</h3>
      <button onClick={() => setScore(score + Math.floor(Math.random() * 10))}>
        랜덤 점수 획득
      </button>
      <p>{score > prevScore ? '점수가 올랐네요!' : '그대로군요!'}</p>
    </div>
  );
}

처음 화면에서는 현재 점수가 0이고, 이전 점수는 아직 저장된 값이 없어서 -로 표시된다.
prevScoreRef는 이전 점수를 저장하기 위한 참조 값이고, 화면에 직접 보여줄 현재 점수는 score state로 관리된다.


랜덤 점수 획득 버튼을 누르면 setScore()가 실행된다.
score state가 바뀌므로 컴포넌트가 다시 렌더링된다.


렌더링 중에는 이전 렌더링에서 저장해둔 prevScoreRef.current 값을 읽을 수 있다.
그래서 현재 점수와 이전 점수를 비교할 수 있다.
렌더링이 끝난 뒤에는 useEffect()가 실행되고, 현재 score를 다시 prevScoreRef.current에 저장한다.

랜덤 점수 획득 버튼을 누르면 setScore()가 실행되어 현재 점수가 바뀐다.
score state가 바뀌면 컴포넌트가 다시 렌더링된다.
렌더링 중에는 이전 렌더링에서 prevScoreRef.current에 저장해둔 점수를 읽을 수 있다.
렌더링이 끝난 뒤 useEffect()가 실행되고, 현재 점수를 다시 prevScoreRef.current에 저장한다.
그래서 다음 렌더링에서는 방금 전 점수가 이전 점수로 보인다.


이전 점수는 버튼을 누르는 순간 바로 만들어지는 값이 아니다.
이전 렌더링이 끝난 뒤 useEffect()에서 ref에 저장해둔 값이다.
그래서 새 점수가 렌더링될 때 화면에는 현재 score와 이전 렌더링에서 저장된 prevScoreRef.current가 함께 보인다.


처음 렌더링에서는 이전 점수가 아직 없기 때문에 prevScore가 undefined일 수 있다.
그래서 화면의 이전 점수 자리에는 prevScore !== undefined ? prevScore : '-' 조건으로 -를 보여준다.


처음 렌더링에서 score > prevScore를 비교하면 prevScore가 아직 없기 때문에 점수가 올랐다고 판단하지 않는다.
그래서 처음에는 그대로군요!가 보일 수 있다.
이후 버튼을 눌러 새 점수가 만들어지면, 이전 렌더링에서 저장된 점수와 현재 점수를 비교할 수 있다.


이 예제는 useRef()가 단순히 DOM을 가리키는 용도만 있는 것이 아니라, 렌더링 사이에서 이전 값을 기억하는 저장소로도 사용할 수 있음을 보여준다.
다만 화면을 구성하는 핵심 값은 score처럼 state로 관리하고, 이전 값을 기억하는 보조 저장소로 ref를 사용하는 흐름으로 이해하는 것이 좋다.


5-7. 렌더링 중 참조값 사용 주의

렌더링 중에는 읽거나 쓰는 것을 피하는 것이 좋다

ref.current는 값을 읽고 쓸 수 있지만, 렌더링 중에 함부로 읽거나 쓰는 것은 피하는 것이 좋다.
렌더링 중이라는 말은 컴포넌트 함수가 실행되면서 JSX를 계산하는 시점을 의미한다.


React는 같은 state와 props가 들어오면 같은 화면 결과가 나오는 컴포넌트를 기대한다.
그런데 렌더링 중에 ref.current를 계속 바꾸면 화면 계산 과정이 예측하기 어려워질 수 있다.


그래서 ref.current를 읽거나 쓰는 작업은 보통 이벤트 핸들러나 useEffect() 안에서 처리하는 것이 안전하다.
예를 들어 버튼을 눌렀을 때 값을 바꾸거나, 화면이 반영된 뒤 input에 포커스를 주는 방식이 더 적절하다.


앞에서 본 렌더링 횟수 추적 예제는 컴포넌트가 몇 번 실행되는지 확인하기 위한 디버깅용 예시다.
이전 값 기억 예제도 ref가 렌더링 사이에서 값을 유지한다는 흐름을 보여주기 위한 학습 예제다.
실제 화면 결과를 만드는 핵심 데이터까지 렌더링 중에 ref.current로 바꾸는 방식으로 작성하면 흐름이 불안정해질 수 있다.


정리하면 아래처럼 기억하면 된다.

  • 이벤트가 발생했을 때 ref.current를 읽거나 쓴다.
  • 화면이 반영된 뒤 useEffect() 안에서 ref.current를 읽거나 쓴다.
  • 렌더링 중에는 초기화 목적이나 디버깅 목적을 제외하고 ref.current를 함부로 읽거나 쓰지 않는다.
  • 화면 결과에 직접 필요한 핵심 값은 state로 관리한다.

useRef()는 렌더링을 발생시키지 않는 저장소이므로, 화면 결과에 직접 필요한 값은 state로 관리하는 것이 안전하다.


참조를 사용해야 하는 상황을 정리하기

useRef()는 모든 값을 저장하는 만능 도구가 아니다.
값이 화면에 보여야 한다면 useState()를 사용해야 한다.
값이 바뀌어도 화면 변경이 필요 없거나, 실제 DOM 요소를 가리켜야 한다면 useRef()를 사용한다.


선택 기준은 아래처럼 정리할 수 있다.

  • 화면에 바로 보여야 하는 값 → useState()
  • 값이 바뀌면 화면도 바뀌어야 하는 값 → useState()
  • 화면에 보이지 않아도 내부적으로 기억해야 하는 값 → useRef()
  • 타이머 ID, 이전 값, 임시 플래그 → useRef()
  • 실제 input, div, video 요소 접근 → useRef()

이 기준을 잡아두면 useState()와 useRef()를 구분하기 쉬워진다.
둘 다 값을 저장할 수 있지만, 화면 렌더링을 발생시키는지 여부가 가장 큰 차이다.


핵심 정리

useRef()는 렌더링과 직접 관련이 없는 값을 기억하거나, 실제 DOM 요소를 직접 가리킬 때 사용하는 Hook이다.
반환값은 { current: 초기값 } 형태의 객체이고, 실제 값은 ref.current에 저장된다.


useState()와 useRef()는 모두 값을 저장할 수 있지만 목적이 다르다.
useState()는 값이 바뀌면 화면을 다시 렌더링하고, useRef()는 값이 바뀌어도 화면을 다시 렌더링하지 않는다.


useRef()는 자동 포커스, 스크롤 이동, 동영상 재생 제어처럼 실제 DOM 요소를 다룰 때 사용할 수 있다.
또 타이머 ID, 렌더링 횟수, 이전 값처럼 화면에 바로 보여줄 필요는 없지만 기억해야 하는 값에도 사용할 수 있다.


EduApp12.jsx 예제에서는 input에 입력한 값이 idRef.current에 저장되지만, ref 값 변경만으로 화면은 다시 렌더링되지 않는다.
그래서 화면에 저장 값을 표시하려면 setRenderCount()처럼 state 변경이 한 번 일어나야 한다.


EduApp13.jsx 예제에서는 현재 점수를 score state로 관리하고, 이전 점수를 prevScoreRef.current에 저장한다.
렌더링이 끝난 뒤 useEffect()가 현재 점수를 ref에 저장하기 때문에, 다음 렌더링에서는 이전 점수와 현재 점수를 비교할 수 있다.


마지막으로 ref.current는 이벤트 핸들러나 useEffect() 안에서 읽고 쓰는 방식이 안전하다.
화면에 보여줄 값은 useState(), 화면과 무관하게 기억할 값이나 DOM 참조는 useRef()로 관리하는 것이 핵심이다.




API 호출 기초

React와 Spring Boot를 분리해서 사용하면 화면을 만드는 역할과 데이터를 제공하는 역할이 나뉜다.
Spring Boot는 더 이상 완성된 HTML 화면을 보내는 것이 아니라, 화면에 필요한 데이터를 JSON 형태로 응답한다.


React는 이 JSON 데이터를 받아서 state에 저장하고, 저장된 state를 기준으로 화면을 다시 그린다.
그래서 React에서 서버 데이터를 화면에 보여주려면 API 요청 흐름, 응답 처리 흐름, state 저장 흐름을 함께 이해해야 한다.
즉, React 화면은 서버 응답을 직접 출력하는 것이 아니라, 서버 응답을 state에 저장한 뒤 그 state를 기반으로 출력한다.


6-1. React와 Spring Boot를 분리했을 때의 데이터 흐름

서버는 화면이 아니라 데이터를 보낸다

기존 방식에서는 서버가 HTML 화면을 만들어서 브라우저에 보낼 수 있었다.
하지만 React와 Spring Boot를 분리하면 역할이 달라진다.


Spring Boot Controller는 화면 파일을 직접 반환하기보다 JSON 데이터를 반환한다.
JSON은 데이터를 주고받기 위한 텍스트 형식이다.
초보자 기준에서는 객체나 배열 데이터를 문자열 형태로 표현한 약속된 형식으로 이해하면 된다.


예를 들어 게시글 목록을 서버에서 받아온다면 서버는 아래처럼 데이터를 보낼 수 있다.

[
  {
    "id": 1,
    "title": "React와 REST API 연동",
    "content": "JSON 응답을 state로 저장한다."
  },
  {
    "id": 2,
    "title": "useEffect 기본",
    "content": "API 호출과 외부 동기화에 사용한다."
  }
]

이 데이터는 화면 모양이 아니다.
제목, 내용, 번호 같은 순수한 데이터다.


React는 이 데이터를 받아서 posts 같은 state에 저장한다.
그 다음 posts.map()으로 배열을 반복하면서 화면에 보여줄 JSX를 만든다.


정리하면 역할은 아래처럼 나뉜다.

  • Spring Boot: 데이터 제공
  • Controller: 요청을 받고 JSON 응답 반환
  • React: JSON 응답을 받아 state에 저장
  • state: 화면에 보여줄 데이터 보관
  • JSX: state를 기준으로 화면 구조 생성

React와 Spring Boot를 분리하면 서버는 데이터를 제공하고, 화면 구성은 React가 담당한다.


API 호출은 보통 useEffect 안에서 시작한다

React 컴포넌트가 처음 화면에 나타났을 때 서버에서 데이터를 가져와야 하는 경우가 많다.
이때 보통 useEffect() 안에서 API 요청을 시작한다.


이유는 간단하다.
컴포넌트가 렌더링되기 전에 서버 요청을 바로 실행하는 것이 아니라, 화면이 준비된 뒤 필요한 외부 작업을 실행하는 흐름이기 때문이다.
앞에서 정리한 것처럼 useEffect()는 렌더링 이후 외부 작업을 처리하는 공간이다.


서버 데이터 요청은 React 내부 계산이 아니다.
서버라는 외부 시스템과 연결되는 작업이다.
그래서 useEffect() 안에서 처리하는 것이 자연스럽다.

React 컴포넌트가 처음 렌더링되면 화면 구조가 먼저 만들어진다.
그 뒤 useEffect()가 실행되면서 서버 API에 요청을 보낸다.
Spring Boot REST API는 요청에 맞는 JSON 데이터를 반환하고, React는 그 응답을 state에 저장한다.
state가 바뀌면 컴포넌트가 다시 렌더링되고, 서버에서 받은 데이터가 화면에 표시된다.


6-2. 응답 데이터를 state로 연결하는 기본 패턴

전체 흐름을 먼저 잡기

서버 데이터를 화면에 보여주는 과정은 한 번에 보면 복잡해 보일 수 있다.
하지만 순서대로 나누면 단순하다.


기본 흐름은 아래와 같다.

  • 컴포넌트가 처음 렌더링된다.
  • useEffect()가 실행된다.
  • fetch()로 서버에 요청을 보낸다.
  • 서버가 JSON 응답을 반환한다.
  • response.json()으로 응답을 JavaScript 데이터로 바꾼다.
  • setState()로 응답 데이터를 저장한다.
  • state가 바뀌므로 컴포넌트가 다시 렌더링된다.
  • 화면에 서버 데이터가 표시된다.

이 흐름에서 가장 중요한 지점은 setState()다.
서버에서 데이터를 받아오기만 해서는 화면이 자동으로 바뀌지 않는다.
받아온 데이터를 state에 저장해야 React가 화면을 다시 계산한다.


기본 예시로 목록 출력하기

아래 예제는 서버에서 게시글 목록을 받아와 화면에 출력하는 기본 패턴이다.
처음에는 posts가 빈 배열이고, 서버 응답을 받은 뒤 setPosts(data)로 목록을 저장한다.

// PostsPageApiBasicExample.jsx
import { useEffect, useState } from 'react'; // state와 effect를 사용한다.

export default function PostsPage() {
  const [posts, setPosts] = useState([]); // 서버에서 받아온 게시글 목록이다.

  useEffect(() => {
    async function loadPosts() {
      const response = await fetch('http://localhost:8080/api/posts'); // 서버에 목록을 요청한다.
      const data = await response.json(); // JSON 응답을 JavaScript 데이터로 바꾼다.
      setPosts(data); // 응답 데이터를 state에 저장한다.
    }

    loadPosts(); // 컴포넌트가 처음 나타난 뒤 목록을 불러온다.
  }, []); // 처음 렌더링 후 한 번만 실행한다.

  return (
    <section>
      {posts.map((post) => (
        <article key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.content}</p>
        </article>
      ))}
    </section>
  );
}

처음 화면이 열리면 posts는 빈 배열이다.
그래서 처음에는 출력할 게시글이 없다.


렌더링이 끝난 뒤 useEffect()가 실행된다.
그 안에서 loadPosts()가 호출되고, fetch()가 서버에 요청을 보낸다.


서버가 JSON 응답을 보내면 response.json()이 실행된다.
이 메서드는 응답 본문을 JavaScript에서 사용할 수 있는 배열이나 객체로 바꿔준다.


그 다음 setPosts(data)가 실행된다.
posts 상태가 바뀌었으므로 컴포넌트가 다시 렌더링된다.
다시 렌더링될 때는 posts 배열에 서버 데이터가 들어 있으므로 posts.map()으로 게시글 목록이 화면에 표시된다.


async와 await가 필요한 이유

fetch()는 서버 응답을 기다려야 하는 작업이다.
서버 요청은 즉시 끝나는 계산이 아니라, 네트워크를 통해 서버에 요청을 보내고 응답이 돌아올 때까지 기다리는 작업이다.


async는 함수 안에서 비동기 작업을 사용할 수 있게 표시하는 키워드다.
비동기 작업은 결과가 바로 나오지 않고 나중에 완료되는 작업을 의미한다.
서버 요청, 파일 읽기, 타이머 같은 작업이 대표적이다.


await는 비동기 작업이 끝날 때까지 기다린 뒤 다음 줄을 실행하게 한다.
아래 흐름을 보면 더 직관적이다.

// AsyncAwaitFlowExample.jsx
async function loadPosts() {
  const response = await fetch('http://localhost:8080/api/posts'); // 서버 응답을 기다린다.
  const data = await response.json(); // 응답 본문 변환이 끝날 때까지 기다린다.
  return data; // 변환된 데이터를 반환한다.
}

await fetch(...)가 없으면 응답이 도착하기 전에 다음 코드가 먼저 실행될 수 있다.
await response.json()이 없으면 JSON 변환이 끝나기 전에 데이터를 사용하려고 할 수 있다.


그래서 서버 응답을 순서대로 처리하려면 async/await 흐름을 사용하는 것이 이해하기 쉽다.


6-3. fetch에서 response.ok를 확인해야 하는 이유

fetch는 모든 HTTP 오류를 자동으로 catch로 보내지 않는다

fetch()를 사용할 때 주의할 점이 있다.
네트워크 자체가 실패하면 예외가 발생할 수 있다.
예를 들어 서버에 아예 연결할 수 없거나, 인터넷 연결이 끊긴 경우가 여기에 해당한다.


하지만 서버가 404나 500 같은 HTTP 오류 응답을 보낸다고 해서 fetch()가 자동으로 catch로 이동하는 것은 아니다.
404는 요청한 주소가 없다는 뜻이고, 500은 서버 내부 오류라는 뜻이다.
이런 응답도 서버가 응답을 보내긴 보낸 것이기 때문에 fetch() 입장에서는 요청 자체가 끝난 것으로 볼 수 있다.


그래서 응답이 진짜 성공인지 확인하려면 response.ok를 직접 검사해야 한다.
response.ok는 응답 상태 코드가 성공 범위일 때 true가 된다.
일반적으로 200번대 응답이면 성공으로 본다.


fetch()는 404, 500 같은 응답을 자동으로 에러 처리하지 않으므로, response.ok를 직접 확인해야 한다.


응답 실패를 직접 Error로 만들기

아래 예제는 서버에서 게시글 목록을 가져오면서 응답 성공 여부를 확인하는 코드다.
응답이 실패라면 직접 Error를 만들어 던진다.

// GetPostsWithOkCheckExample.jsx
async function getPosts() {
  const response = await fetch('http://localhost:8080/api/posts'); // 서버에 목록 요청을 보낸다.
  const data = await response.json(); // 응답 본문을 먼저 읽는다.

  if (!response.ok) {
    throw new Error(data.message ?? `HTTP ${response.status}`); // 실패 응답이면 에러를 만든다.
  }

  return data; // 성공이면 데이터를 반환한다.
}

response.ok가 false이면 성공 응답이 아니다.
이때 throw new Error(...)를 실행하면 에러가 발생하고, 이 함수를 호출한 쪽의 catch에서 처리할 수 있다.


data.message ?? \HTTP ${response.status}\는 에러 메시지를 정하는 부분이다.
서버 응답 데이터에 message가 있으면 그 메시지를 사용한다.
없으면 HTTP 404, HTTP 500처럼 상태 코드를 이용해 기본 메시지를 만든다.


여기서 ??는 왼쪽 값이 null 또는 undefined일 때 오른쪽 값을 사용하는 연산자다.
초보자 기준에서는 “앞의 메시지가 없으면 뒤의 기본 메시지를 써라”라고 이해하면 된다.


다만 이 예제는 실패 응답에도 서버가 JSON 형식의 본문을 내려준다는 전제를 가진다.
응답 본문이 비어 있거나 JSON 형식이 아니라면 response.json() 자체에서 오류가 날 수 있다.
이런 빈 응답이나 예외적인 응답 처리는 뒤에서 다룰 실제 실습 예제에서 더 구체적으로 확인한다.


에러 처리는 화면 상태와 연결된다

에러를 만들어 던지는 이유는 단순히 콘솔에 오류를 보기 위해서가 아니다.
사용자에게 현재 문제가 발생했다는 것을 화면에 알려주기 위해서다.


예를 들어 서버가 꺼져 있거나, 요청 주소가 틀렸거나, 서버 내부에서 오류가 발생하면 게시글 목록을 보여줄 수 없다.
이때 아무 화면도 바뀌지 않으면 사용자는 버튼이 눌렸는지, 서버가 느린지, 오류가 난 것인지 알 수 없다.


그래서 실제 화면에서는 보통 error 상태를 따로 둔다.
catch에서 에러 메시지를 setError(err.message)로 저장하고, 화면에서는 에러 메시지를 보여준다.
이 부분은 뒤의 loading, error, empty, success 상태 설계에서 더 자세히 다룬다.


6-4. Spring Boot REST API의 응답 타입에 따른 구현

배열 응답이면 map으로 반복 출력한다

React 쪽에서는 서버 응답 데이터의 타입을 알고 있어야 한다.
응답이 배열인지, 객체인지, 객체 안의 특정 필드인지에 따라 화면 코드가 달라지기 때문이다.


예를 들어 게시글 목록 API가 배열을 반환한다고 정하면 React에서는 posts.map()을 사용할 수 있다.
배열은 여러 데이터를 순서대로 담는 구조이기 때문이다.


서버 응답이 아래처럼 배열이라고 생각할 수 있다.

[
  {
    "id": 1,
    "title": "React와 REST API 연동",
    "content": "JSON 응답을 state로 저장한다.",
    "author": "kim"
  },
  {
    "id": 2,
    "title": "useEffect 기본",
    "content": "API 호출과 외부 동기화에 사용한다.",
    "author": "lee"
  }
]

이 경우 화면에서는 아래처럼 반복 출력할 수 있다.

// ArrayResponseRenderExample.jsx
export default function ArrayResponseRenderExample({ posts }) {
  return (
    <section>
      {posts.map((post) => (
        <article key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.content}</p>
          <span>{post.author}</span>
        </article>
      ))}
    </section>
  );
}

posts.map()은 배열 안의 게시글을 하나씩 꺼내서 article 태그로 바꾼다.
그래서 배열 응답은 목록 화면을 만들 때 자주 사용된다.


객체 응답이면 속성으로 접근한다

서버 응답이 배열이 아니라 객체 하나일 수도 있다.
예를 들어 게시글 상세 조회는 보통 게시글 하나를 가져온다.
이 경우 map()을 쓰는 것이 아니라 객체의 속성에 직접 접근한다.

{
  "id": 1,
  "title": "React와 REST API 연동",
  "content": "JSON 응답을 state로 저장한다.",
  "author": "kim"
}

이 응답은 게시글 하나이므로 아래처럼 출력할 수 있다.

// ObjectResponseRenderExample.jsx
export default function ObjectResponseRenderExample({ post }) {
  return (
    <article>
      <h3>{post.title}</h3>
      <p>{post.content}</p>
      <span>{post.author}</span>
    </article>
  );
}

배열에는 map()을 사용하지만, 객체 하나에는 map()을 사용하지 않는다.
객체 하나를 받았다면 post.title, post.content, post.author처럼 필요한 속성을 꺼내 사용한다.


객체 안에 목록이 들어 있는 경우도 있다

실제 API에서는 응답 객체 안에 목록이 들어 있는 경우도 많다.
예를 들어 페이지 처리된 응답은 아래처럼 content 안에 목록이 들어 있을 수 있다.

{
  "content": [
    {
      "id": 1,
      "title": "React와 REST API 연동"
    },
    {
      "id": 2,
      "title": "useEffect 기본"
    }
  ],
  "totalElements": 2
}

이 경우 전체 응답은 객체지만, 실제 목록은 content 배열 안에 있다.
그래서 화면에서는 data.content.map(...)처럼 접근해야 한다.

// ContentFieldResponseRenderExample.jsx
export default function ContentFieldResponseRenderExample({ data }) {
  return (
    <section>
      {data.content.map((post) => (
        <article key={post.id}>
          <h3>{post.title}</h3>
        </article>
      ))}
      <p>전체 게시글 수: {data.totalElements}</p>
    </section>
  );
}

이처럼 응답 구조를 모르면 화면 코드도 정확히 작성하기 어렵다.
배열인지, 객체인지, 객체 안의 특정 필드인지에 따라 접근 방식이 달라진다.


6-5. API 명세가 중요한 이유

프론트엔드와 백엔드는 같은 응답 구조를 보고 있어야 한다

프론트엔드와 백엔드가 함께 작업할 때는 API 명세가 중요하다.
API 명세는 어떤 주소로 요청을 보내고, 어떤 방식으로 요청하며, 어떤 형태의 응답이 돌아오는지를 정리한 약속이다.


예를 들어 게시글 목록을 요청할 때 아래 내용이 미리 정해져 있어야 한다.

  • 요청 주소는 무엇인지
  • 요청 방식은 GET인지 POST인지
  • 응답은 배열인지 객체인지
  • 게시글 번호 필드 이름은 id인지 boardNo인지
  • 제목 필드 이름은 title인지 다른 이름인지
  • 목록이 바로 배열로 오는지, content 안에 들어 있는지

이 약속이 맞지 않으면 서버는 정상 응답을 보내도 화면은 깨질 수 있다.
예를 들어 서버는 boardNo라는 이름으로 보냈는데 React가 post.id를 사용하면 값이 나오지 않을 수 있다.


응답 구조에 맞게 state도 설계해야 한다

응답 구조는 화면 코드뿐 아니라 state 초기값에도 영향을 준다.
배열 응답이면 state의 초기값도 보통 빈 배열로 둔다.
그래야 처음 렌더링에서 map()을 사용해도 오류가 나지 않는다.

// ArrayStateInitialValueExample.jsx
import { useState } from 'react'; // state를 사용한다.

export default function ArrayStateInitialValueExample() {
  const [posts, setPosts] = useState([]); // 배열 응답을 받을 예정이므로 빈 배열로 시작한다.

  return (
    <section>
      {posts.map((post) => (
        <article key={post.id}>{post.title}</article>
      ))}
    </section>
  );
}

반대로 객체 하나를 받을 예정이라면 처음에는 null로 둘 수도 있다.
이 경우 아직 데이터가 없을 때 바로 post.title에 접근하면 오류가 날 수 있으므로 조건부 렌더링이 필요하다.

// ObjectStateInitialValueExample.jsx
import { useState } from 'react'; // state를 사용한다.

export default function ObjectStateInitialValueExample() {
  const [post, setPost] = useState(null); // 아직 상세 데이터가 없으므로 null로 시작한다.

  if (post === null) {
    return <p>게시글을 불러오는 중이다.</p>; // 데이터가 없을 때 먼저 보여준다.
  }

  return (
    <article>
      <h3>{post.title}</h3>
      <p>{post.content}</p>
    </article>
  );
}

이처럼 서버 응답 타입은 state 초기값과 화면 분기 방식에도 영향을 준다.
배열 응답이면 빈 배열로 시작하고 반복 출력 흐름을 준비한다.
객체 응답이면 처음에는 데이터가 없을 수 있으므로 null이나 조건부 렌더링을 함께 고려한다.
객체 안에 목록이 들어 있다면 state에 전체 응답을 저장할지, 안쪽 목록만 꺼내 저장할지도 정해야 한다.


서버 응답 구조를 정확히 알아야 React에서 어떤 state를 만들고 어떻게 화면에 출력할지 결정할 수 있다.


핵심 정리

React와 Spring Boot를 분리하면 Spring Boot Controller는 완성된 HTML이 아니라 JSON 데이터를 반환한다.
React는 이 데이터를 받아 state에 저장하고, state를 기준으로 화면을 다시 그린다.


서버 데이터 요청은 보통 useEffect() 안에서 시작한다.
컴포넌트가 처음 렌더링된 뒤 fetch()로 서버에 요청을 보내고, response.json()으로 응답을 JavaScript 데이터로 바꾼 뒤 setState()로 저장한다.


fetch()는 404, 500 같은 HTTP 오류 응답을 자동으로 catch로 보내지 않는다.
그래서 response.ok를 직접 확인하고, 실패 응답이면 throw new Error()로 에러를 만들어 처리해야 한다.


마지막으로 React 코드는 서버 응답 타입에 따라 달라진다.
배열 응답이면 map()으로 반복 출력하고, 객체 응답이면 속성으로 접근한다.
객체 안에 목록이 들어 있다면 해당 필드까지 들어가서 배열을 꺼내야 한다.
프론트엔드와 백엔드는 같은 API 응답 구조를 기준으로 작업해야 화면과 데이터 흐름이 어긋나지 않는다.




EduApp14 도서 목록 불러오기 실습

EduApp14.jsx는 React에서 서버 API를 호출해 도서 목록 데이터를 가져오는 실습 예제다.
이 예제는 서버에 저장된 도서 데이터를 가져와서 state에 저장하고, 그 state를 기준으로 화면에 테이블을 출력하는 흐름을 보여준다.


핵심은 단순히 fetch()를 쓰는 것이 아니다.
사용자가 버튼을 누르기 전 상태, 요청 중 상태, 성공 응답 상태, 빈 응답 상태, 실패 상태를 모두 화면에 반영해야 한다.
즉, API 화면은 서버에서 데이터를 받아오는 코드뿐 아니라, 그 데이터가 어떤 state에 저장되고 어떤 조건으로 화면에 출력되는지까지 함께 이해해야 한다.


7-1. EduApp14 예제가 보여주는 핵심

서버의 도서 목록을 조회하는 예제다

EduApp14.jsx 파일에는 FetchExam1 컴포넌트가 작성되어 있다.
이 컴포넌트는 서버의 /boards 주소로 GET 요청을 보내 도서 목록을 가져온다.


GET은 서버에 저장된 데이터를 조회할 때 사용하는 요청 방식이다.
초보자 기준에서는 “서버야, 네가 가지고 있는 도서 목록을 보내줘”라고 요청하는 방식으로 이해하면 된다.


이 예제에서 사용자는 직접 주소창에 요청을 보내지 않는다.
화면의 목록 불러오기 버튼을 누르면 fetchBoards() 함수가 실행되고, 그 함수 안에서 fetch(API_URL) 요청이 나간다.


전체 흐름은 아래처럼 이어진다.

  • 서버에는 도서 목록 데이터가 준비되어 있다.
  • 처음 화면에서는 아직 서버 요청을 보내지 않았으므로 안내 문구만 보인다.
  • 사용자가 목록 불러오기 버튼을 누른다.
  • fetchBoards() 함수가 실행된다.
  • loading state 값이 true로 바뀌어 요청 중 화면이 보인다.
  • fetch(API_URL)로 서버에 도서 목록을 요청한다.
  • 서버는 도서 목록 배열을 바로 변수에 넣어주는 것이 아니라 response 객체를 먼저 보내준다.
  • response.status로 응답 본문이 없는지 확인한다.
  • response.ok로 성공 응답인지 확인한다.
  • 성공 응답이면 response.json()으로 실제 도서 목록 배열을 꺼낸다.
  • 꺼낸 배열을 setBoards(data)로 boards state에 저장한다.
  • boards state가 바뀌면 컴포넌트가 다시 렌더링된다.
  • boards.map()이 실행되면서 도서 목록이 테이블 행으로 출력된다.

서버 응답 데이터가 바로 화면에 찍히는 것이 아니라, response → data → boards state → map() → 화면 출력 순서로 이동한다.


화면 상태를 나누어 관리한다

API를 사용하는 화면에서는 데이터만 있으면 끝나는 것이 아니다.
요청을 보내기 전인지, 요청을 기다리는 중인지, 실패했는지, 성공했는지를 화면에서 구분해야 한다.


이 예제에서는 아래 세 가지 state를 사용한다.

  • boards state: 서버에서 받아온 도서 목록 배열이다.
  • loading state: 현재 서버 요청 중인지 나타낸다.
  • error state: 서버 요청이 실패했을 때 에러 메시지를 저장한다.

boards state는 실제 테이블에 출력할 데이터다.
처음에는 아직 서버에서 데이터를 받아오지 않았기 때문에 빈 배열로 시작한다.


loading state는 요청 중 화면을 보여줄 때 사용한다.
loading state 값이 true이면 버튼 문구가 불러오는 중...으로 바뀌고, 화면에는 서버에서 데이터를 가져오는 중이라는 안내가 보인다.


error state는 요청 실패 메시지를 저장한다.
서버가 꺼져 있거나, 요청 주소가 틀렸거나, CORS 문제가 있으면 이 값에 메시지가 들어가고 화면에 에러 문구가 출력된다.


도서 목록 화면은 boards state만으로 만들지 않는다. loading state와 error state까지 함께 관리해야 사용자가 현재 상태를 정확히 알 수 있다.


7-2. 기본 코드 흐름

먼저 큰 구조를 코드로 보기

아래 코드는 EduApp14.jsx의 큰 구조를 이해하기 위한 실제 구조 요약이다.
아직 세부 로직을 전부 보는 단계가 아니라, 코드가 어떤 역할로 나뉘는지 먼저 확인하는 단계다.

// [실제 코드 구조 요약] EduApp14.jsx
const API_URL = "http://localhost:9000/boards"; // 서버 요청 주소다.

export default function FetchExam1() {
  const [boards, setBoards] = useState([]); // boards state와 변경 함수다.
  const [loading, setLoading] = useState(false); // loading state와 변경 함수다.
  const [error, setError] = useState(null); // error state와 변경 함수다.

  async function fetchBoards() {
    // 서버 요청과 응답 처리를 담당한다.
  }

  return (
    // 현재 state에 따라 화면을 다르게 출력한다.
  );
}

이 구조는 크게 세 부분으로 나누어 볼 수 있다.

  • API_URL, boards state, loading state, error state: 서버 요청과 화면 출력에 필요한 값 준비
  • fetchBoards(): 서버에 요청을 보내고 응답을 처리하는 함수
  • return: 현재 state에 따라 화면을 다르게 보여주는 부분

이렇게 먼저 세 덩어리로 나누면 코드가 덜 복잡해진다.
이제 각 덩어리가 어떤 역할을 하는지 따로 보면 된다.


1단계: 요청 주소와 화면 상태 준비

첫 번째 덩어리는 서버 요청 주소와 화면 상태를 준비하는 부분이다.

// [실제 코드] EduApp14.jsx의 요청 주소와 state 준비 부분
const API_URL = "http://localhost:9000/boards"; // 도서 목록을 요청할 서버 주소다.

const [boards, setBoards] = useState([]); // boards state를 빈 배열로 시작한다.
const [loading, setLoading] = useState(false); // loading state를 false로 시작한다.
const [error, setError] = useState(null); // error state를 null로 시작한다.

API_URL은 도서 목록을 요청할 서버 주소다.
나중에 fetch(API_URL)이 실행되면 이 주소로 요청이 나간다.


boards는 서버에서 받은 도서 목록을 저장하는 state다.
서버 응답이 배열이므로 처음값도 빈 배열 []로 둔다.


loading은 요청 중인지 알려주는 state다.
처음에는 요청을 보내지 않았으므로 false다.


error는 실패 메시지를 저장하는 state다.
처음에는 실패한 요청이 없으므로 null이다.


처음 화면의 상태값은 아래처럼 시작한다.

// [state 값 예시] 실제 작성 코드가 아니라 현재 state 값을 펼쳐 쓴 것이다.
// boards state 값: []
// loading state 값: false
// error state 값: null

이 값 때문에 처음 화면에는 도서 테이블이 나오지 않고, 버튼을 눌러 목록을 불러오라는 안내 문구가 나온다.


2단계: 서버 요청과 응답 처리 함수 준비

두 번째 덩어리는 fetchBoards() 함수다.
이 함수는 버튼을 눌렀을 때 실행된다.

// [실제 코드] EduApp14.jsx의 서버 요청 함수
async function fetchBoards() {
  setLoading(true); // loading state를 true로 바꾼다.
  setError(null); // error state를 null로 초기화한다.
  setBoards([]); // boards state를 빈 배열로 초기화한다.

  try {
    const response = await fetch(API_URL); // 서버에 요청하고 response 객체를 기다린다.

    if (response.status === 204) {
      setBoards([]); // 응답 본문이 없으면 boards state를 빈 배열로 둔다.
      return; // JSON 변환 없이 종료한다.
    }

    if (!response.ok) {
      throw new Error(`서버 오류: HTTP ${response.status}`); // 실패 응답을 에러로 만든다.
    }

    const data = await response.json(); // 응답 본문을 JavaScript 데이터로 바꾼다.
    setBoards(data); // data 배열을 boards state에 저장한다.
  } catch (err) {
    setError(err.message); // 에러 메시지를 error state에 저장한다.
  } finally {
    setLoading(false); // loading state를 false로 되돌린다.
  }
}

이 함수에서 가장 중요한 흐름은 fetch()가 바로 도서 목록 배열을 주는 것이 아니라는 점이다.
fetch()는 먼저 response 객체를 준다.
그 다음 response.json()을 실행해야 실제 도서 목록 배열을 꺼낼 수 있다.


즉, 데이터 이동은 아래 순서다.

// [이해용 예시] 데이터 이동 순서
// fetch(API_URL)
// → response 객체
// → response.status 확인
// → response.ok 확인
// → response.json()
// → data 배열
// → setBoards(data)
// → boards state
// → boards.map()
// → table

이 순서를 놓치면 fetch() 결과가 바로 화면 데이터라고 착각하기 쉽다.


3단계: 현재 상태에 따라 화면 출력하기

세 번째 덩어리는 return 안의 화면 출력 부분이다.
화면은 항상 같은 모양으로 나오지 않는다.
loading state, error state, boards state 값에 따라 달라진다.

// [실제 코드] 버튼 클릭과 버튼 문구 처리
<button onClick={fetchBoards} disabled={loading}>
  {loading ? "불러오는 중..." : "목록 불러오기"}
</button>

이 버튼은 클릭하면 fetchBoards()를 실행한다.
loading state 값이 true이면 버튼 문구가 불러오는 중...으로 바뀌고, 버튼도 비활성화된다.

// [실제 코드] error state에 값이 있으면 에러 메시지를 출력한다.
{error && (
  <p>{error}</p>
)}

error state에 메시지가 있으면 에러 문구를 보여준다.

// [실제 코드] loading state가 true이면 로딩 문구를 출력한다.
{loading && (
  <p>서버에서 데이터를 가져오는 중...</p>
)}

loading state 값이 true이면 요청 중 안내 문구를 보여준다.

// [실제 코드] 요청 전 또는 빈 목록 안내 문구 출력 조건
{!loading && !error && boards.length === 0 && (
  <p>위 버튼을 눌러 목록을 불러오세요</p>
)}

loading state가 false이고, error state가 비어 있고, boards state 배열 길이가 0이면 안내 문구를 보여준다.
처음 화면과 빈 목록 응답 화면이 이 조건에 해당한다.

// [실제 코드] boards state에 데이터가 있으면 도서 목록 테이블을 출력한다.
{!loading && boards.length > 0 && (
  <table>
    <tbody>
      {boards.map((board) => (
        <tr key={board.boardNo}>
          <td>{board.boardNo}</td>
          <td>{board.title}</td>
          <td>{board.content}</td>
          <td>{board.writer}</td>
          <td>{formatDate(board.regDate)}</td>
        </tr>
      ))}
    </tbody>
  </table>
)}

boards state 배열에 데이터가 하나 이상 있으면 테이블을 출력한다.
boards.map()은 boards state 배열 안의 도서 객체를 하나씩 꺼내서 테이블 한 줄로 바꾼다.


7-3. 자세한 풀이

전체 코드

아래 코드는 EduApp14.jsx에 들어가는 핵심 실제 코드다.
화면 꾸밈 코드는 제외하고, API 요청과 상태 변화와 화면 분기 흐름을 이해하는 데 필요한 코드만 정리했다.

// [실제 코드] EduApp14.jsx에 들어가는 핵심 코드
import { useState } from "react"; // state를 사용하기 위해 가져온다.

const API_URL = "http://localhost:9000/boards"; // 도서 목록 요청 주소다.

function formatDate(isoString) {
  const d = new Date(isoString); // 서버 날짜 문자열을 날짜 객체로 바꾼다.
  const pad = (n) => String(n).padStart(2, "0"); // 숫자를 두 자리로 맞춘다.

  return (
    `${d.getFullYear()}년 ${pad(d.getMonth() + 1)}월 ` +
    `${pad(d.getDate())}일 ${pad(d.getHours())}시 ${pad(d.getMinutes())}분`
  );
}

export default function FetchExam1() {
  const [boards, setBoards] = useState([]); // boards state와 변경 함수다.
  const [loading, setLoading] = useState(false); // loading state와 변경 함수다.
  const [error, setError] = useState(null); // error state와 변경 함수다.

  async function fetchBoards() {
    setLoading(true); // loading state를 true로 바꾼다.
    setError(null); // error state를 null로 초기화한다.
    setBoards([]); // boards state를 빈 배열로 초기화한다.

    try {
      const response = await fetch(API_URL); // 서버에 GET 요청을 보내고 response 객체를 받는다.

      if (response.status === 204) {
        setBoards([]); // 응답 본문이 없으면 boards state를 빈 배열로 둔다.
        return; // JSON 변환 없이 종료한다.
      }

      if (!response.ok) {
        throw new Error(`서버 오류: HTTP ${response.status}`); // 실패 응답을 에러로 만든다.
      }

      const data = await response.json(); // JSON 응답을 JavaScript 데이터로 바꾼다.
      setBoards(data); // data 배열을 boards state에 저장한다.
    } catch (err) {
      setError(err.message); // 에러 메시지를 error state에 저장한다.
    } finally {
      setLoading(false); // loading state를 false로 되돌린다.
    }
  }

  return (
    <section>
      <h2>도서 목록</h2>

      <button onClick={fetchBoards} disabled={loading}>
        {loading ? "불러오는 중..." : "목록 불러오기"}
      </button>

      {error && (
        <p>{error}</p>
      )}

      {loading && (
        <p>서버에서 데이터를 가져오는 중...</p>
      )}

      {!loading && !error && boards.length === 0 && (
        <p>위 버튼을 눌러 목록을 불러오세요</p>
      )}

      {!loading && boards.length > 0 && (
        <table>
          <thead>
            <tr>
              <th>No</th>
              <th>제목</th>
              <th>내용</th>
              <th>작성자</th>
              <th>등록일</th>
            </tr>
          </thead>
          <tbody>
            {boards.map((board) => (
              <tr key={board.boardNo}>
                <td>{board.boardNo}</td>
                <td>{board.title}</td>
                <td>{board.content}</td>
                <td>{board.writer}</td>
                <td>{formatDate(board.regDate)}</td>
              </tr>
            ))}
          </tbody>
        </table>
      )}
    </section>
  );
}

이 코드는 실제 파일에 들어가는 코드다.
이제 전체 코드를 실행 흐름에 맞춰 하나씩 나누어 본다.
각 단계에서는 먼저 실제 코드를 확인하고, 바로 아래에서 그 코드가 실행될 때 실제 값이 어떻게 들어오고 바뀌는지 함께 본다.


1단계: 처음 화면의 state 확인하기

처음 화면이 열리면 아직 서버 요청을 보내지 않았다.
이때 실행 기준이 되는 state 준비 코드는 아래와 같다.

// [실제 코드] 처음 state 준비
const [boards, setBoards] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

useState([])는 boards state의 처음값을 빈 배열로 만든다.
boards state에는 나중에 서버에서 받아온 도서 목록 배열이 저장된다.


useState(false)는 loading state의 처음값을 false로 만든다.
아직 서버 요청을 보내지 않았으므로 요청 중이 아니다.


useState(null)은 error state의 처음값을 null로 만든다.
아직 실패한 요청이 없으므로 에러 메시지도 없다.

// [state 값 예시] 처음 렌더링 시점의 state 값
// boards state 값: []
// loading state 값: false
// error state 값: null

처음 화면에서는 아래 조건이 계산된다.

// [실제 코드] 요청 전 또는 빈 목록 안내 문구 출력 조건
!loading && !error && boards.length === 0

현재 state 값을 넣으면 아래처럼 계산된다.

// [조건 계산 예시] 처음 화면 조건 계산
// !loading → !false → true
// !error → !null → true
// boards.length === 0 → [].length === 0 → true
// 최종 결과: true

조건이 true이므로 화면에는 아래 문구가 출력된다.

// [화면 출력 결과] 처음 화면
// 위 버튼을 눌러 목록을 불러오세요

아직 boards state에 도서 데이터가 없으므로 테이블은 출력되지 않는다.


2단계: 버튼을 눌러 fetchBoards 실행하기

사용자가 목록 불러오기 버튼을 누르면 아래 코드가 실행된다.

// [실제 코드] 버튼 클릭 시 fetchBoards 함수 실행
<button onClick={fetchBoards} disabled={loading}>
  {loading ? "불러오는 중..." : "목록 불러오기"}
</button>

onClick={fetchBoards}는 버튼을 클릭했을 때 fetchBoards() 함수를 실행하라는 뜻이다.
disabled={loading}은 loading state 값이 true일 때 버튼을 비활성화한다는 뜻이다.


처음에는 loading state 값이 false이므로 버튼 문구는 아래처럼 계산된다.

// [조건 계산 예시] 처음 버튼 문구
// loading state 값: false
// false ? "불러오는 중..." : "목록 불러오기"
// 결과: "목록 불러오기"

그래서 처음 버튼에는 아래 문구가 보인다.

// [화면 출력 결과] 처음 버튼 문구
// 목록 불러오기

이 버튼을 누르면 fetchBoards() 함수 내부 코드가 위에서 아래로 실행되기 시작한다.


3단계: 요청 시작 상태로 바꾸기

fetchBoards() 함수가 실행되면 가장 먼저 아래 세 줄이 실행된다.

// [실제 코드] 요청 시작 전 state 초기화
setLoading(true);
setError(null);
setBoards([]);

setLoading(true)는 loading state 값을 true로 바꾼다.
이 값이 바뀌면 화면은 요청 중 상태로 다시 계산된다.


setError(null)은 이전에 남아 있던 에러 메시지를 지운다.
새 요청을 시작하는데 이전 에러가 계속 보이면 헷갈리기 때문이다.


setBoards([])는 이전 목록을 비운다.
새로 목록을 불러오는 동안 이전 결과가 그대로 남아 있지 않게 하기 위한 처리다.


버튼을 누르기 전 state 값은 아래와 같다.

// [state 값 예시] 버튼 클릭 전 state 값
// boards state 값: []
// loading state 값: false
// error state 값: null

세 줄이 실행된 뒤 state 값은 아래처럼 바뀐다.

// [state 값 예시] 요청 시작 직후 state 값
// boards state 값: []
// loading state 값: true
// error state 값: null

loading state 값이 true가 되었으므로 버튼 문구도 다시 계산된다.

// [실제 코드] loading state 값에 따른 버튼 문구
loading ? "불러오는 중..." : "목록 불러오기"

현재 값을 넣으면 아래처럼 된다.

// [조건 계산 예시] 요청 중 버튼 문구
// loading state 값: true
// true ? "불러오는 중..." : "목록 불러오기"
// 결과: "불러오는 중..."

그래서 요청 중 화면에는 아래 내용이 보인다.

// [화면 출력 결과] 요청 중 화면
// 불러오는 중...
// 서버에서 데이터를 가져오는 중...

여기까지는 아직 서버 응답이 도착한 상태가 아니다.
화면만 “요청을 시작했다”는 상태로 바뀐 것이다.


4단계: fetch로 서버에 요청 보내기

요청 시작 상태를 만든 뒤 try 안에서 아래 코드가 실행된다.

// [실제 코드] 서버에 GET 요청을 보내고 response 객체를 받는다.
const response = await fetch(API_URL);

fetch(API_URL)은 서버에 요청을 보내는 코드다.
await는 서버 응답이 도착할 때까지 기다린 뒤 다음 줄을 실행하게 한다.


여기서 API_URL의 실제 값은 아래와 같다.

// [실제 코드] 요청 주소
const API_URL = "http://localhost:9000/boards";

따라서 실제 요청은 아래 주소로 나간다.

// [이해용 예시] 실제 요청 주소
fetch("http://localhost:9000/boards");

서버에는 아래와 같은 도서 목록 데이터가 준비되어 있다고 생각할 수 있다.
아래 코드는 실제 React 코드가 아니라, 서버가 응답 본문으로 보내줄 수 있는 데이터 예시다.

// [이해용 예시] 서버가 응답 본문으로 보낼 수 있는 도서 목록 데이터
[
  {
    boardNo: 1,
    title: "리액트 입문",
    content: "fetch 실습",
    writer: "둘리",
    regDate: "2026-05-20T19:37:34.000000"
  },
  {
    boardNo: 2,
    title: "스프링 연동",
    content: "REST API 실습",
    writer: "또치",
    regDate: "2026-05-20T20:10:00.000000"
  }
]

하지만 이 배열이 바로 response 변수에 들어가는 것은 아니다.
fetch()의 결과로 먼저 들어오는 값은 response 객체다.

// [이해용 예시] 실제 작성 코드가 아니라 성공 응답 response 객체를 단순화해서 보여주는 값
response = {
  status: 200,
  ok: true,
  json: async function () {
    return [
      {
        boardNo: 1,
        title: "리액트 입문",
        content: "fetch 실습",
        writer: "둘리",
        regDate: "2026-05-20T19:37:34.000000"
      },
      {
        boardNo: 2,
        title: "스프링 연동",
        content: "REST API 실습",
        writer: "또치",
        regDate: "2026-05-20T20:10:00.000000"
      }
    ];
  }
};

response는 실제 도서 목록 배열이 아니다.
response는 응답 상태와 응답 본문을 꺼내는 기능을 가진 객체다.
실제 도서 목록 배열은 response.json()을 실행해야 data로 꺼낼 수 있다.


5단계: 204 응답이면 여기서 함수가 끝난다

서버가 응답을 보내면 먼저 204 응답인지 확인한다.

// [실제 코드] 응답 본문이 없는지 확인한다.
if (response.status === 204) {
  setBoards([]);
  return;
}

204 No Content는 요청은 성공했지만 응답 본문이 없다는 뜻이다.
즉, 서버가 “요청은 성공했지만 보내줄 데이터는 없다”라고 응답한 상태다.


서버가 204를 보냈다면 response는 아래처럼 이해할 수 있다.

// [이해용 예시] 204 응답 response 값
response = {
  status: 204,
  ok: true
};

조건을 실제 값으로 넣으면 아래처럼 된다.

// [조건 계산 예시] 204 응답일 때 조건 계산
// response.status === 204
// 204 === 204
// 결과: true

조건이 true이므로 아래 코드가 실행된다.

// [실제 코드] boards state를 빈 배열로 만들고 함수 종료
setBoards([]);
return;

setBoards([])는 boards state 값을 빈 배열로 만든다.
응답 본문이 없기 때문에 저장할 도서 목록도 없다.
그래서 빈 배열을 저장한다.


이때 state 값은 아래처럼 이해할 수 있다.

// [state 값 예시] 204 응답에서 setBoards([]) 실행 후
// boards state 값: []
// loading state 값: true
// error state 값: null

return은 fetchBoards() 함수를 여기서 끝낸다는 뜻이다.
그래서 아래 코드는 실행되지 않는다.

// [실행 안 됨] 204 응답에서는 여기까지 내려오지 않는다.
if (!response.ok) {
  throw new Error(`서버 오류: HTTP ${response.status}`);
}

const data = await response.json();
setBoards(data);

즉, 204 응답이면 response.ok 확인도 하지 않고, response.json()도 실행하지 않는다.
본문이 없는데 response.json()을 실행하면 꺼낼 데이터가 없어서 오류가 날 수 있기 때문이다.


다만 return을 만나도 finally는 실행된다.

// [실제 코드] 요청 종료 처리
setLoading(false);

최종 state 값은 아래와 같다.

// [state 값 예시] 204 응답 처리 후 최종 state 값
// boards state 값: []
// loading state 값: false
// error state 값: null

그래서 화면에는 테이블이 아니라 안내 문구가 출력된다.

// [화면 출력 결과] 204 응답 후 화면
// 위 버튼을 눌러 목록을 불러오세요

204 응답 흐름을 정리하면 아래와 같다.

// [데이터 흐름 정리] 204 응답 흐름
// fetch(API_URL)
// → response 객체 도착
// → response.status 값이 204
// → 204 조건 true
// → setBoards([])
// → boards state 값이 빈 배열로 유지됨
// → return으로 fetchBoards 함수 종료
// → response.ok 확인 안 함
// → response.json() 실행 안 함
// → finally에서 loading state 값 false
// → 빈 목록 안내 문구 출력



6단계: 실패 응답이면 catch로 이동한다

204가 아니라면 다음 코드로 내려온다.
그 다음 HTTP 실패 응답인지 확인한다.

// [실제 코드] HTTP 실패 응답인지 확인한다.
if (!response.ok) {
  throw new Error(`서버 오류: HTTP ${response.status}`);
}

response.ok는 응답이 성공 범위인지 알려준다.
서버가 200 OK를 보냈다면 response.ok 값은 true다.
서버가 404, 500 같은 실패 응답을 보냈다면 response.ok 값은 false다.


먼저 성공 응답인 경우를 보면 조건은 아래처럼 계산된다.

// [조건 계산 예시] 200 성공 응답일 때 ok 조건 계산
// !response.ok
// !true
// 결과: false

조건이 false이므로 throw new Error()는 실행되지 않는다.
이 경우에는 catch로 이동하지 않고 다음 줄로 내려간다.


반대로 서버가 500 오류를 응답했다고 가정하면 response는 아래처럼 이해할 수 있다.

// [이해용 예시] HTTP 500 실패 응답 response 값
response = {
  status: 500,
  ok: false
};

이때 조건을 실제 값으로 계산하면 아래와 같다.

// [조건 계산 예시] 500 응답일 때 ok 조건 계산
// !response.ok
// !false
// 결과: true

조건이 true이므로 아래 코드가 실행된다.

// [실제 코드] 실패 응답을 Error로 만들어 catch로 보낸다.
throw new Error(`서버 오류: HTTP ${response.status}`);

실제 에러 메시지는 아래처럼 만들어진다.

// [출력 결과] 생성되는 에러 메시지
// "서버 오류: HTTP 500"

throw가 실행되면 흐름은 바로 catch로 이동한다.
따라서 아래 성공 처리 코드는 실행되지 않는다.

// [실행 안 됨] HTTP 실패 응답에서는 여기까지 내려오지 않는다.
const data = await response.json();
setBoards(data);

catch에서는 에러 메시지를 error state에 저장한다.

// [실제 코드] 에러 메시지를 error state에 저장한다.
catch (err) {
  setError(err.message);
}

setError(err.message) 실행 전 state 값은 아래와 같다.

// [state 값 예시] 에러 저장 전 state 값
// boards state 값: []
// loading state 값: true
// error state 값: null

실행 후 error state에는 에러 메시지가 저장된다.

// [state 값 예시] 에러 저장 후 state 값
// boards state 값: []
// loading state 값: true
// error state 값: "서버 오류: HTTP 500"

그 다음 finally가 실행되어 loading state 값이 false가 된다.

// [state 값 예시] HTTP 실패 응답 이후 최종 state 값
// boards state 값: []
// loading state 값: false
// error state 값: "서버 오류: HTTP 500"

이제 화면은 아래 코드 때문에 에러 메시지를 출력한다.

// [실제 코드] error state 값이 있으면 에러 메시지를 출력한다.
{error && (
  <p>{error}</p>
)}

최종 화면에는 아래 문구가 보인다.

// [화면 출력 결과] HTTP 실패 응답 후 화면 문구
// 서버 오류: HTTP 500

HTTP 실패 응답 흐름을 정리하면 아래와 같다.

// [데이터 흐름 정리] HTTP 실패 응답 흐름
// fetch(API_URL)
// → response 객체 도착
// → response.status 값이 500
// → 204 조건 false
// → response.ok 값 false
// → 실패 조건 true
// → throw new Error("서버 오류: HTTP 500")
// → catch로 이동
// → setError(err.message)
// → error state에 "서버 오류: HTTP 500" 저장
// → finally에서 loading state 값 false
// → 에러 메시지 출력

fetch()는 500 같은 HTTP 실패 응답을 자동으로 catch로 보내지 않는다. 그래서 response.ok를 직접 확인하고 Error를 만들어야 한다.


7단계: 성공 응답이면 response.json으로 data를 꺼낸다

204도 아니고, 실패 응답도 아니라면 성공 응답이다.
이제 실제 도서 목록 데이터를 꺼낼 수 있다.

// [실제 코드] response 본문을 JavaScript 데이터로 변환한다.
const data = await response.json();

response.json()은 서버 응답 본문에 들어 있는 JSON 배열을 JavaScript 배열로 바꿔준다.


서버 응답 본문이 아래처럼 들어 있었다면,

// [이해용 예시] 서버가 response 본문으로 보낸 JSON 데이터
[
  {
    boardNo: 1,
    title: "리액트 입문",
    content: "fetch 실습",
    writer: "둘리",
    regDate: "2026-05-20T19:37:34.000000"
  },
  {
    boardNo: 2,
    title: "스프링 연동",
    content: "REST API 실습",
    writer: "또치",
    regDate: "2026-05-20T20:10:00.000000"
  }
]

response.json() 실행 후 data 값은 아래처럼 된다.

// [이해용 예시] response.json() 실행 후 data에 들어오는 값
data = [
  {
    boardNo: 1,
    title: "리액트 입문",
    content: "fetch 실습",
    writer: "둘리",
    regDate: "2026-05-20T19:37:34.000000"
  },
  {
    boardNo: 2,
    title: "스프링 연동",
    content: "REST API 실습",
    writer: "또치",
    regDate: "2026-05-20T20:10:00.000000"
  }
];

여기서 data는 아직 화면에 출력되는 값이 아니다.
data는 fetchBoards() 함수 안에서 잠깐 사용하는 변수다.
화면에 반영하려면 이 값을 boards state에 저장해야 한다.


response는 서버 응답 정보이고, data가 실제 도서 목록 배열이다.


8단계: data를 boards state에 저장하기

response.json()으로 꺼낸 data는 아래 코드로 boards state에 저장된다.

// [실제 코드] data 배열을 boards state에 저장한다.
setBoards(data);

setBoards(data) 실행 전 state 값은 아래와 같다.

// [state 값 예시] setBoards(data) 실행 전
// boards state 값: []
// loading state 값: true
// error state 값: null

setBoards(data) 실행 후 boards state 값은 아래처럼 바뀐다.

// [state 값 예시] setBoards(data) 실행 후
// boards state 값:
// [
//   {
//     boardNo: 1,
//     title: "리액트 입문",
//     content: "fetch 실습",
//     writer: "둘리",
//     regDate: "2026-05-20T19:37:34.000000"
//   },
//   {
//     boardNo: 2,
//     title: "스프링 연동",
//     content: "REST API 실습",
//     writer: "또치",
//     regDate: "2026-05-20T20:10:00.000000"
//   }
// ]
// loading state 값: true
// error state 값: null

setBoards(data)가 중요한 이유는 이 함수가 React에게 상태가 바뀌었다고 알려주기 때문이다.
boards state가 바뀌면 React는 컴포넌트를 다시 렌더링한다.


그 다음 finally에서 요청 중 상태를 끝낸다.

// [실제 코드] 요청이 끝났으므로 loading state 값을 false로 되돌린다.
setLoading(false);

성공 응답 이후 최종 state 값은 아래와 같다.

// [state 값 예시] 성공 응답 이후 최종 state 값
// boards state 값:
// [
//   {
//     boardNo: 1,
//     title: "리액트 입문",
//     content: "fetch 실습",
//     writer: "둘리",
//     regDate: "2026-05-20T19:37:34.000000"
//   },
//   {
//     boardNo: 2,
//     title: "스프링 연동",
//     content: "REST API 실습",
//     writer: "또치",
//     regDate: "2026-05-20T20:10:00.000000"
//   }
// ]
// loading state 값: false
// error state 값: null

이제 boards state 배열에 데이터가 2개 들어 있고, loading state 값도 false이므로 테이블 출력 조건을 확인할 수 있다.


9단계: boards.map으로 테이블 출력하기

성공 응답 이후 화면은 아래 조건을 확인한다.

// [실제 코드] 테이블 출력 조건
!loading && boards.length > 0

현재 state 값을 넣으면 아래처럼 계산된다.

// [조건 계산 예시] 테이블 출력 조건
// !loading → !false → true
// boards.length > 0 → 2 > 0 → true
// 최종 결과: true

조건이 true이므로 아래 코드가 실행된다.

// [실제 코드] boards state 배열을 테이블 행으로 변환한다.
{boards.map((board) => (
  <tr key={board.boardNo}>
    <td>{board.boardNo}</td>
    <td>{board.title}</td>
    <td>{board.content}</td>
    <td>{board.writer}</td>
    <td>{formatDate(board.regDate)}</td>
  </tr>
))}

boards.map()은 boards state 배열 안의 도서 객체를 하나씩 꺼낸다.
꺼낸 도서 객체 하나를 board라고 부르고, 그 board를 테이블 한 줄로 바꾼다.


첫 번째 반복에서는 boards state 배열의 첫 번째 객체가 board라는 이름으로 들어온다.

// [이해용 예시] boards.map() 첫 번째 반복에서 board에 들어오는 값
board = {
  boardNo: 1,
  title: "리액트 입문",
  content: "fetch 실습",
  writer: "둘리",
  regDate: "2026-05-20T19:37:34.000000"
};

첫 번째 테이블 행에 실제로 들어가는 값은 아래와 같다.

// [화면 출력값 예시] 첫 번째 테이블 행
// board.boardNo → 1
// board.title → "리액트 입문"
// board.content → "fetch 실습"
// board.writer → "둘리"
// formatDate(board.regDate) → "2026년 05월 20일 19시 37분"

두 번째 반복에서는 boards state 배열의 두 번째 객체가 board라는 이름으로 들어온다.

// [이해용 예시] boards.map() 두 번째 반복에서 board에 들어오는 값
board = {
  boardNo: 2,
  title: "스프링 연동",
  content: "REST API 실습",
  writer: "또치",
  regDate: "2026-05-20T20:10:00.000000"
};

두 번째 테이블 행에 실제로 들어가는 값은 아래와 같다.

// [화면 출력값 예시] 두 번째 테이블 행
// board.boardNo → 2
// board.title → "스프링 연동"
// board.content → "REST API 실습"
// board.writer → "또치"
// formatDate(board.regDate) → "2026년 05월 20일 20시 10분"

최종적으로 화면의 테이블은 아래처럼 출력된다.

// [화면 출력 결과] 최종 테이블 화면
// No | 제목        | 내용          | 작성자 | 등록일
// 1  | 리액트 입문 | fetch 실습    | 둘리   | 2026년 05월 20일 19시 37분
// 2  | 스프링 연동 | REST API 실습 | 또치   | 2026년 05월 20일 20시 10분

성공 흐름을 한 번에 정리하면 아래와 같다.

// [데이터 흐름 정리] 200 성공 응답 흐름
// 서버의 도서 목록 데이터
// → fetch(API_URL) 요청
// → response 객체 도착
// → response.status 값이 200
// → 204 조건 false
// → response.ok 값 true
// → 실패 조건 false
// → response.json() 실행
// → 서버 응답 본문이 data 배열로 변환됨
// → setBoards(data)
// → boards state에 도서 목록 배열 저장
// → finally에서 loading state 값 false
// → 컴포넌트 다시 렌더링
// → boards.map() 실행
// → 테이블 행 출력

이 흐름의 핵심은 서버 데이터가 바로 화면에 찍히는 것이 아니라, 반드시 boards state를 거쳐 화면에 출력된다는 점이다.


10단계: 200 빈 배열 응답이면 빈 목록으로 처리된다

서버가 200 성공 응답을 보냈지만 도서 목록이 비어 있을 수도 있다.
이 경우는 204와 다르다.
204는 본문 자체가 없는 응답이고, 200 빈 배열은 본문에 []가 들어 있는 응답이다.


아래 코드는 실제 작성 코드가 아니라, 이해를 돕기 위한 응답 예시다.

// [이해용 예시] 200 빈 배열 응답 response 값
response = {
  status: 200,
  ok: true,
  json: async function () {
    return [];
  }
};

이 경우 204 조건은 false다.
response.ok도 true이므로 실패 응답이 아니다.
그래서 response.json()을 실행한다.

// [실제 코드] 응답 본문을 JavaScript 데이터로 변환한다.
const data = await response.json();

response.json() 실행 결과는 아래와 같다.

// [이해용 예시] 200 빈 배열 응답의 data 값
// data 값: []

그 다음 빈 배열을 boards state에 저장한다.

// [실제 코드] 빈 배열을 boards state에 저장한다.
setBoards(data);

최종 state 값은 아래와 같다.

// [state 값 예시] 200 빈 배열 응답 처리 후 state 값
// boards state 값: []
// loading state 값: false
// error state 값: null

이 경우에도 화면에는 아래 문구가 출력된다.

// [화면 출력 결과] 200 빈 배열 응답 후 화면 문구
// 위 버튼을 눌러 목록을 불러오세요

200 빈 배열 응답 흐름을 정리하면 아래와 같다.

// [데이터 흐름 정리] 200 빈 배열 응답 흐름
// fetch(API_URL)
// → response 객체 도착
// → response.status 값이 200
// → 204 조건 false
// → response.ok 값 true
// → 실패 조건 false
// → response.json() 실행
// → data 값이 []
// → setBoards(data)
// → boards state 값이 []
// → finally에서 loading state 값 false
// → 빈 목록 안내 문구 출력

현재 핵심 코드에서는 204 빈 응답과 200 빈 배열 응답을 둘 다 같은 안내 문구로 처리한다.
빈 목록 전용 문구를 따로 보여주고 싶다면 “요청을 한 번이라도 완료했는지”를 저장하는 state를 추가로 둘 수 있다.


11단계: 네트워크 또는 CORS 오류가 나면 response 객체가 없다

서버가 아예 꺼져 있거나, 요청 주소에 접근할 수 없거나, CORS 정책에 막히면 response 객체 자체가 만들어지지 못할 수 있다.
이 경우에는 아래 코드에서 바로 에러가 발생한다.

// [실제 코드] 서버 연결 실패 또는 CORS 오류가 나면 여기서 catch로 이동할 수 있다.
const response = await fetch(API_URL);

즉, response.status나 response.ok를 확인하기 전에 catch로 이동한다.
response 객체가 없으므로 response.json()도 실행할 수 없다.


catch에서는 네트워크 오류 메시지를 error state에 저장한다.

// [실제 코드] 네트워크 오류 메시지를 error state에 저장한다.
catch (err) {
  setError(err.message);
}

브라우저 환경에서는 네트워크 오류나 CORS 오류가 발생했을 때 아래와 비슷한 메시지가 들어올 수 있다.

// [이해용 예시] 네트워크 또는 CORS 오류 시 error state 값
// error state 값: "Failed to fetch"

정확한 문구는 브라우저와 상황에 따라 달라질 수 있다.


이 경우에도 finally는 실행된다.

// [실제 코드] 실패해도 loading state 값을 false로 되돌린다.
setLoading(false);

최종 상태는 아래처럼 이해할 수 있다.

// [state 값 예시] 네트워크 또는 CORS 오류 이후 최종 state 값
// boards state 값: []
// loading state 값: false
// error state 값: "Failed to fetch"

화면에는 아래처럼 에러 메시지가 출력된다.

// [화면 출력 결과] 네트워크 또는 CORS 오류 후 화면 문구
// Failed to fetch

네트워크 또는 CORS 오류 흐름을 정리하면 아래와 같다.

// [데이터 흐름 정리] 네트워크 또는 CORS 오류 흐름
// fetch(API_URL)
// → 서버 연결 실패 또는 CORS 차단
// → response 객체가 만들어지지 못함
// → response.status 확인 못 함
// → response.ok 확인 못 함
// → response.json() 실행 못 함
// → catch로 이동
// → setError(err.message)
// → error state에 오류 메시지 저장
// → finally에서 loading state 값 false
// → 에러 메시지 출력

이 상황에서는 Spring Boot 서버가 실행 중인지, 요청 주소가 맞는지, @CrossOrigin(origins = "*") 같은 CORS 허용 설정이 되어 있는지 확인해야 한다.


7-4. 화면 상태 요약

처음 화면

처음 화면에서는 아직 서버 요청이 실행되지 않았다.
그래서 boards state 값은 빈 배열이고, loading state 값은 false, error state 값은 null이다.


화면에는 도서 목록 제목, 목록 불러오기 버튼, 안내 문구가 보인다.
테이블은 보이지 않는다.


처음 화면은 아래 상태로 이해하면 된다.

// [화면 출력 결과] 처음 화면 상태
// 서버 요청 전
// boards state 값: []
// loading state 값: false
// error state 값: null
// 안내 문구 출력
// 테이블 출력 안 됨



요청 중 화면

사용자가 목록 불러오기 버튼을 누르면 fetchBoards()가 실행된다.
함수 시작 부분에서 setLoading(true)가 실행되기 때문에 요청 중 화면으로 바뀐다.


요청 중 화면은 아래 상태로 이해하면 된다.

// [화면 출력 결과] 요청 중 화면 상태
// 서버 요청 중
// boards state 값: []
// loading state 값: true
// error state 값: null
// 버튼 문구: 불러오는 중...
// 로딩 문구 출력

이 화면이 필요한 이유는 서버 응답이 바로 오지 않을 수 있기 때문이다.
로딩 문구가 없으면 사용자는 버튼이 눌렸는지, 서버가 느린지, 오류가 난 것인지 알기 어렵다.


성공 화면

서버가 성공 응답을 보내면 response.json()으로 도서 목록 배열을 꺼낸다.
그 배열을 setBoards(data)로 boards state에 저장한다.


성공 화면은 아래 상태로 이해하면 된다.

// [화면 출력 결과] 성공 화면 상태
// 서버 요청 성공
// boards state 값:
// [
//   { boardNo: 1, title: "리액트 입문", ... },
//   { boardNo: 2, title: "스프링 연동", ... }
// ]
// loading state 값: false
// error state 값: null
// 테이블 출력

이때 boards.map()이 실행되면서 boards state 배열 안의 도서 객체가 테이블 행으로 바뀐다.


테이블에는 아래 정보가 출력된다.

  • No
  • 제목
  • 내용
  • 작성자
  • 등록일

도서 데이터 자체는 JSON 배열이지만, 화면에서는 사용자가 읽기 쉬운 테이블 형태로 바뀐다.


빈 응답 화면

서버가 204를 응답하거나, 200 성공 응답이지만 빈 배열 []을 응답할 수 있다.
두 경우 모두 현재 코드에서는 boards state 값이 빈 배열로 남는다.


빈 응답 화면은 아래 상태로 이해하면 된다.

// [화면 출력 결과] 빈 응답 화면 상태
// 서버 요청 성공
// boards state 값: []
// loading state 값: false
// error state 값: null
// 안내 문구 출력
// 테이블 출력 안 됨

현재 코드에서는 요청 전 상태와 빈 응답 상태를 같은 안내 문구로 처리한다.
그래서 빈 목록 전용 문구가 따로 필요하면 요청 완료 여부를 저장하는 state를 추가로 만들어야 한다.


실패 화면

HTTP 실패 응답이나 네트워크 오류가 발생하면 catch에서 setError(err.message)가 실행된다.


실패 화면은 아래 상태로 이해하면 된다.

// [화면 출력 결과] 실패 화면 상태
// 서버 요청 실패
// boards state 값: []
// loading state 값: false
// error state 값: "서버 오류: HTTP 500"

이때 화면에는 테이블 대신 에러 메시지가 출력된다.
오류가 발생했는데 아무 문구도 보여주지 않으면 사용자는 현재 상황을 알 수 없다.
그래서 API 화면에서는 실패 상태도 반드시 화면에 반영해야 한다.


7-5. 결과물

실행 결과 영상

목록 불러오기 버튼을 누르면 fetchBoards()가 실행된다.
요청이 시작되면 loading state 값이 true가 되어 버튼 문구가 불러오는 중...으로 바뀌고, 서버에서 데이터를 가져오는 중이라는 안내가 보인다.
서버 응답이 도착하면 먼저 response.status와 response.ok로 응답 상태를 확인한다.
성공 응답이면 response.json()으로 실제 도서 목록 배열을 꺼내고, setBoards(data)로 boards state에 저장한다.
boards state가 바뀌면 컴포넌트가 다시 렌더링되고, boards.map()을 통해 도서 목록이 테이블 행으로 출력된다.


핵심 정리

EduApp14.jsx는 서버의 /boards 주소로 GET 요청을 보내 도서 목록을 가져오는 실습 예제다.
사용자가 목록 불러오기 버튼을 누르면 fetchBoards()가 실행되고, 서버 요청이 시작된다.


처음에는 boards state 값이 빈 배열, loading state 값이 false, error state 값이 null인 상태다.
요청이 시작되면 loading state 값이 true가 되고, 화면은 요청 중 상태로 바뀐다.


fetch(API_URL)을 실행하면 서버에서 바로 도서 목록 배열이 오는 것이 아니다.
먼저 response 객체가 오고, 그 안의 status와 ok로 응답 상태를 확인한다.
그 다음 response.json()을 실행해야 실제 도서 목록 배열인 data를 꺼낼 수 있다.


꺼낸 data는 setBoards(data)로 boards state에 저장된다.
boards state가 바뀌면 React가 컴포넌트를 다시 렌더링하고, boards.map()이 배열 안의 도서 객체를 하나씩 테이블 행으로 바꾼다.


서버 응답이 204이면 본문이 없으므로 response.json()을 실행하지 않고 boards state를 빈 배열로 처리한다.
200 응답이지만 본문이 빈 배열이면 response.json()을 실행한 결과 data가 []가 되고, 그 빈 배열이 boards state에 저장된다.
HTTP 실패 응답이면 response.ok를 확인해 직접 Error를 만들어야 한다.
네트워크 오류나 CORS 오류는 response 객체가 만들어지기 전에 catch로 이동할 수 있다.


이 실습의 핵심은 fetch() 한 줄이 아니라, 서버 데이터가 response로 도착하고, data로 변환되고, boards state에 저장된 뒤, map()을 통해 화면 테이블로 바뀌는 전체 흐름을 이해하는 것이다.




EduApp15 따릉이 스테이션 순차 수집 실습

EduApp15.jsx는 React에서 서울시 공공자전거 따릉이 스테이션 데이터를 API로 가져와 화면에 카드 형태로 누적 출력하는 실습 예제다.
앞에서 본 EduApp14.jsx가 버튼을 눌러 한 번 목록을 불러오는 구조였다면, 이번 예제는 useEffect()와 setInterval()을 사용해 일정 시간마다 다음 요청을 자동으로 실행한다.


이 예제의 핵심은 데이터를 한 번 가져오는 것이 아니다.
여러 API 주소를 순서대로 호출하고, 응답 데이터를 기존 목록 뒤에 계속 누적한다.
즉, EduApp15.jsx는 API를 한 번 호출하는 예제가 아니라, 여러 요청을 순서대로 실행하면서 stations state에 데이터를 누적하는 예제다.


8-1. EduApp15 예제가 보여주는 핵심

따릉이 스테이션 데이터를 5개씩 나누어 가져온다

EduApp15.jsx 파일에는 FetchExam2 컴포넌트가 작성되어 있다.
이 컴포넌트는 서울시 공공자전거 API 주소를 사용해 따릉이 스테이션 정보를 가져온다.


데이터는 한 번에 전부 가져오는 방식이 아니다.
1~5, 6~10, 11~15처럼 범위를 나누어 여러 API 주소를 만든다.
그 다음 일정 시간마다 다음 주소를 호출하면서 스테이션 목록을 화면에 추가한다.


전체 흐름은 아래처럼 이어진다.

  • BASE에 서울시 따릉이 API 기본 주소를 저장한다.
  • TOTAL_PAGES에 요청 횟수를 저장한다.
  • buildEndpoints()로 요청 주소 목록을 만든다.
  • ENDPOINTS 배열에 실제 요청할 주소들이 저장된다.
  • useEffect()가 처음 렌더링 뒤 실행된다.
  • setInterval()이 일정 시간마다 fetchNext()를 실행한다.
  • fetchNext()는 indexRef.current가 가리키는 주소를 꺼낸다.
  • 현재 주소로 fetch(url) 요청을 보낸다.
  • 응답이 성공하면 res.json()으로 응답 본문을 꺼낸다.
  • 실제 스테이션 목록은 json.rentBikeStatus.row에서 꺼낸다.
  • 기존 stations state에 없는 stationId만 골라 새로 추가한다.
  • 모든 ENDPOINTS 요청이 끝나면 타이머를 멈춘다.

이번 예제의 핵심은 setInterval()로 반복 요청을 만들고, useRef()로 다음 요청 위치를 기억하며, setStations(prev => ...)로 이전 목록에 새 데이터를 안전하게 누적하는 것이다.


EduApp14와 다른 점

EduApp14.jsx는 사용자가 버튼을 누르면 한 번 요청을 보내고, 받은 목록을 화면에 출력했다.
그래서 핵심 흐름은 fetch() → response.json() → setBoards(data)였다.


EduApp15.jsx는 요청이 한 번으로 끝나지 않는다.
요청 주소가 여러 개 있고, setInterval()이 일정 시간마다 다음 주소를 호출한다.
그래서 이번에는 단순한 조회보다 요청 순서 관리와 데이터 누적 관리가 더 중요하다.


비교하면 아래와 같다.

  • EduApp14: 한 주소를 한 번 요청하고, 받은 배열을 boards state에 저장한다.
  • EduApp15: 여러 주소를 순서대로 요청하고, 받은 배열을 기존 stations state 뒤에 누적한다.

이 차이를 먼저 잡아야 indexRef.current, ENDPOINTS, prev, newItems가 왜 필요한지 이해할 수 있다.


8-2. 기본 코드 흐름

먼저 큰 구조를 코드로 보기

아래 코드는 EduApp15.jsx의 큰 구조를 이해하기 위한 실제 구조 요약이다.
아직 세부 실행 흐름을 보는 단계가 아니라, 코드가 어떤 역할로 나뉘는지 먼저 확인하는 단계다.

// EduApp15.jsx
// [실제 코드 구조 요약]
const BASE = "서울시 따릉이 API 기본 주소"; // 기본 요청 주소다.
const TOTAL_PAGES = 6; // 총 요청 횟수다.

function buildEndpoints(pages) {
  // 요청 주소 목록을 만든다.
}

const ENDPOINTS = buildEndpoints(TOTAL_PAGES); // 최종 요청 주소 배열이다.

function StationCard({ station, index }) {
  // 스테이션 객체 하나를 카드로 출력한다.
}

export default function FetchExam2() {
  const [stations, setStations] = useState([]); // 누적된 스테이션 목록이다.
  const [loading, setLoading] = useState(false); // 요청 중 여부다.
  const [lastTime, setLastTime] = useState(null); // 마지막 업데이트 시간이다.
  const [error, setError] = useState(null); // 에러 메시지다.
  const indexRef = useRef(0); // 다음 요청 위치다.

  useEffect(() => {
    // 일정 시간마다 fetchNext를 실행한다.
  }, []);

  return (
    // 현재 state에 따라 화면을 출력한다.
  );
}

이 구조는 크게 네 부분으로 나눌 수 있다.

  • BASE, TOTAL_PAGES, buildEndpoints(), ENDPOINTS: 요청 주소 목록 준비
  • StationCard: 스테이션 객체 하나를 카드로 출력
  • FetchExam2의 state, useRef, useEffect: 자동 요청과 데이터 누적 관리
  • return: 수집 개수, 로딩, 에러, 카드 목록, 완료 상태 출력

이제 큰 구조를 봤으니, 다음 단계에서는 실제 코드가 실행될 때 ENDPOINTS, indexRef.current, rows, stations state, newItems 값이 어떻게 바뀌는지 따라가면 된다.


8-3. 자세한 풀이

전체 코드

아래 코드는 EduApp15.jsx에 들어가는 핵심 실제 코드다.
화면 스타일 코드는 제외하고, 요청 주소 생성, 자동 요청, 응답 처리, 중복 제거, 상태 누적, 화면 출력 흐름을 이해하는 데 필요한 코드만 정리했다.

// EduApp15.jsx
// [실제 코드] 따릉이 스테이션 순차 수집 핵심 코드
import { useState, useEffect, useRef } from "react"; // state, effect, ref를 사용한다.

const BASE = "http://openapi.seoul.go.kr:8088/796143536a756e69313134667752417a/json/bikeList"; // 따릉이 API 기본 주소다.
const TOTAL_PAGES = 6; // 총 6번 요청한다.

function buildEndpoints(pages) {
  return Array.from({ length: pages }, (_, i) => {
    const start = i * 5 + 1; // 시작 번호를 계산한다.
    const end = start + 4; // 끝 번호를 계산한다.
    return `${BASE}/${start}/${end}/`; // 요청 주소를 만든다.
  });
}

const ENDPOINTS = buildEndpoints(TOTAL_PAGES); // 요청 주소 배열을 만든다.

function StationCard({ station, index }) {
  return (
    <div className="card" style={{ animationDelay: `${(index % 5) * 0.07}s` }}>
      <div className="card-top">
        <span className="card-icon">🚲</span>
        <span className="card-id">{station.stationId}</span>
      </div>
      <p className="card-name">{station.stationName}</p>
      <div className="card-coords">
        <div className="coord">
          <span className="coord-label">위도</span>
          <span className="coord-val">{station.stationLatitude}</span>
        </div>
        <div className="coord">
          <span className="coord-label">경도</span>
          <span className="coord-val">{station.stationLongitude}</span>
        </div>
      </div>
    </div>
  );
}

export default function FetchExam2() {
  const [stations, setStations] = useState([]); // 누적된 스테이션 목록이다.
  const [loading, setLoading] = useState(false); // 현재 요청 중인지 나타낸다.
  const [lastTime, setLastTime] = useState(null); // 마지막 업데이트 시각이다.
  const [error, setError] = useState(null); // 에러 메시지다.
  const indexRef = useRef(0); // 다음에 요청할 주소 위치다.

  useEffect(() => {
    const fetchNext = async () => {
      if (indexRef.current >= ENDPOINTS.length) return; // 모두 요청했으면 멈춘다.

      const url = ENDPOINTS[indexRef.current]; // 현재 요청할 주소를 꺼낸다.
      indexRef.current += 1; // 다음 요청 위치로 이동한다.
      setLoading(true); // 요청 중 상태로 바꾼다.
      setError(null); // 이전 에러를 지운다.

      try {
        const res = await fetch(url); // 서버에 요청한다.
        if (!res.ok) throw new Error(`HTTP ${res.status}`); // 실패 응답을 에러로 만든다.
        const json = await res.json(); // JSON 응답을 JavaScript 데이터로 바꾼다.

        const rows = json?.rentBikeStatus?.row ?? []; // 실제 스테이션 배열을 꺼낸다.

        setStations((prev) => {
          const existingIds = new Set(prev.map((s) => s.stationId)); // 기존 ID 목록이다.
          const newItems = rows.filter((r) => !existingIds.has(r.stationId)); // 새 항목만 고른다.
          return [...prev, ...newItems]; // 기존 목록 뒤에 새 항목을 붙인다.
        });

        setLastTime(new Date().toLocaleTimeString("ko-KR")); // 마지막 업데이트 시간을 저장한다.
      } catch (e) {
        setError(`데이터를 가져오지 못했습니다: ${e.message}`); // 에러 메시지를 저장한다.
      } finally {
        setLoading(false); // 요청 종료 상태로 바꾼다.
      }
    };

    const timer = setInterval(() => {
      if (indexRef.current >= ENDPOINTS.length) {
        clearInterval(timer); // 모든 요청이 끝나면 타이머를 멈춘다.
        return;
      }
      fetchNext(); // 다음 요청을 실행한다.
    }, 5000);

    return () => clearInterval(timer); // 컴포넌트가 사라질 때 타이머를 정리한다.
  }, []);

  const isDone = indexRef.current >= ENDPOINTS.length && !loading; // 모든 요청 완료 여부다.

  return (
    <main>
      <header>
        <span>{stations.length}개 수집됨</span>
        <span>
          {isDone
            ? `완료 · ${lastTime}`
            : loading
            ? "수집 중..."
            : `마지막: ${lastTime ?? "--"}`}
        </span>
      </header>

      {error && <div>{error}</div>}

      {stations.length === 0 && !error && (
        <div>🔄 스테이션 정보를 불러오는 중입니다...</div>
      )}

      <div className="grid">
        {stations.map((s, i) => (
          <StationCard key={s.stationId} station={s} index={i} />
        ))}
      </div>

      {isDone && stations.length > 0 && (
        <div>✅ 총 {stations.length}개 스테이션 수집 완료</div>
      )}
    </main>
  );
}

이 코드는 실제 파일에 들어가는 핵심 코드다.
이제 전체 코드를 실행 흐름에 맞춰 하나씩 나누어 본다.
각 단계에서는 먼저 실제 코드를 확인하고, 바로 아래에서 그 코드가 실행될 때 실제 값이 어떻게 들어오고 바뀌는지 함께 본다.


1단계: 요청 주소 목록 만들기

먼저 따릉이 API의 기본 주소와 요청 횟수를 준비한다.

// EduApp15.jsx
// [실제 코드] 기본 요청 주소와 요청 횟수
const BASE = "http://openapi.seoul.go.kr:8088/796143536a756e69313134667752417a/json/bikeList";
const TOTAL_PAGES = 6;

BASE는 서울시 따릉이 API의 기본 주소다.
이 주소만으로는 실제 요청이 완성되지 않는다.
뒤에 시작 번호와 끝 번호가 붙어야 실제 요청 주소가 된다.


TOTAL_PAGES는 요청 주소를 몇 개 만들지 정하는 값이다.
값이 6이므로 총 6개의 요청 주소를 만든다.


요청 주소 배열은 아래 함수로 만든다.

// EduApp15.jsx
// [실제 코드] 요청 주소 배열을 만드는 함수
function buildEndpoints(pages) {
  return Array.from({ length: pages }, (_, i) => {
    const start = i * 5 + 1;
    const end = start + 4;
    return `${BASE}/${start}/${end}/`;
  });
}

Array.from({ length: pages }, ...)는 pages 개수만큼 배열을 만든다.
여기서는 pages 값이 6이므로 i가 0부터 5까지 들어온다.


i 값에 따라 start와 end가 달라진다.

// [계산 예시] i 값에 따른 요청 범위
// i = 0 → start = 1,  end = 5
// i = 1 → start = 6,  end = 10
// i = 2 → start = 11, end = 15
// i = 3 → start = 16, end = 20
// i = 4 → start = 21, end = 25
// i = 5 → start = 26, end = 30

그 다음 아래 코드로 최종 요청 주소 배열을 만든다.

// EduApp15.jsx
// [실제 코드] 최종 요청 주소 배열 생성
const ENDPOINTS = buildEndpoints(TOTAL_PAGES);

실제 ENDPOINTS 값은 아래처럼 이해하면 된다.

// [이해용 예시] 실제 작성 코드가 아니라 ENDPOINTS에 들어가는 요청 주소 구조다.
// ENDPOINTS[0] = ".../bikeList/1/5/"
// ENDPOINTS[1] = ".../bikeList/6/10/"
// ENDPOINTS[2] = ".../bikeList/11/15/"
// ENDPOINTS[3] = ".../bikeList/16/20/"
// ENDPOINTS[4] = ".../bikeList/21/25/"
// ENDPOINTS[5] = ".../bikeList/26/30/"

따라서 이 예제는 따릉이 스테이션 데이터를 5개 단위로 나누어 총 6번 요청한다.


2단계: 처음 state와 ref 값 확인하기

FetchExam2 컴포넌트가 처음 실행되면 아래 값들이 준비된다.

// EduApp15.jsx
// [실제 코드] state와 ref 준비
const [stations, setStations] = useState([]);
const [loading, setLoading] = useState(false);
const [lastTime, setLastTime] = useState(null);
const [error, setError] = useState(null);
const indexRef = useRef(0);

stations state는 화면에 누적해서 보여줄 스테이션 목록이다.
처음에는 아직 받은 데이터가 없으므로 빈 배열 []이다.


loading state는 현재 요청 중인지 나타낸다.
처음에는 요청 중이 아니므로 false다.


lastTime state는 마지막으로 데이터를 받은 시간을 저장한다.
처음에는 아직 받은 데이터가 없으므로 null이다.


error state는 요청 실패 메시지를 저장한다.
처음에는 실패한 요청이 없으므로 null이다.


indexRef.current는 다음에 요청할 ENDPOINTS 배열의 위치를 기억한다.
처음에는 첫 번째 주소부터 요청해야 하므로 0이다.

// [state/ref 값 예시] 처음 렌더링 시점의 값
// stations state 값: []
// loading state 값: false
// lastTime state 값: null
// error state 값: null
// indexRef.current 값: 0

여기서 indexRef.current를 쓰는 이유가 중요하다.
다음 요청 위치는 화면에 직접 출력해야 하는 값이 아니다.
값이 바뀔 때마다 화면을 다시 렌더링할 필요도 없다.
그래서 useState()가 아니라 useRef()에 저장한다.


indexRef.current는 다음에 몇 번째 API 주소를 요청할지 기억하는 값이고, 이 값이 바뀌어도 화면을 다시 렌더링하지 않는다.


3단계: 처음 화면은 loading이 아니어도 안내 문구가 보인다

처음 화면에서는 아직 setInterval()이 fetchNext()를 실행하기 전이다.
그래서 loading state 값은 false다.
하지만 화면에는 안내 문구가 보인다.


그 이유는 안내 문구 조건이 loading state만 보고 판단하는 것이 아니기 때문이다.

// EduApp15.jsx
// [실제 코드] 초기 안내 문구 출력 조건
stations.length === 0 && !error

처음 값을 넣으면 아래처럼 계산된다.

// [조건 계산 예시] 처음 안내 문구 조건
// stations.length === 0 → [].length === 0 → true
// !error → !null → true
// 최종 결과: true

그래서 처음 화면에는 아래 문구가 보인다.

// [화면 출력 결과] 처음 화면
// 0개 수집됨
// 마지막: --
// 🔄 스테이션 정보를 불러오는 중입니다...

여기서 중요한 점은 이 문구가 실제 요청 중이라는 뜻만은 아니라는 점이다.
첫 번째 요청이 시작되기 전에도 stations state가 비어 있고 error state가 없기 때문에 이 안내 문구가 보인다.


4단계: useEffect에서 타이머 등록하기

컴포넌트가 처음 화면에 나타난 뒤 useEffect()가 실행된다.

// EduApp15.jsx
// [실제 코드] 처음 렌더링 뒤 타이머 등록
useEffect(() => {
  const fetchNext = async () => {
    // 다음 API 요청 실행
  };

  const timer = setInterval(() => {
    if (indexRef.current >= ENDPOINTS.length) {
      clearInterval(timer);
      return;
    }
    fetchNext();
  }, 5000);

  return () => clearInterval(timer);
}, []);

useEffect()는 렌더링이 끝난 뒤 실행된다.
의존성 배열이 빈 배열 []이므로 컴포넌트가 처음 화면에 나타난 뒤 한 번만 타이머를 등록한다.


setInterval()은 일정 시간마다 함수를 반복 실행하는 기능이다.
여기서는 5000을 사용했으므로 5초마다 내부 함수가 실행된다.


중요한 점은 setInterval()이 등록되자마자 바로 fetchNext()를 실행하는 것이 아니라는 점이다.
처음 렌더링 직후에는 타이머만 등록되고, 5초가 지나야 첫 번째 fetchNext()가 실행된다.

// [흐름 예시] 타이머 등록 후 실행 흐름
// 화면 처음 렌더링
// → useEffect 실행
// → setInterval 등록
// → 5초 대기
// → fetchNext() 첫 번째 실행
// → 다시 5초 대기
// → fetchNext() 두 번째 실행

마지막 줄의 return () => clearInterval(timer)는 정리 함수다.
컴포넌트가 화면에서 사라질 때 타이머를 정리한다.
타이머를 정리하지 않으면 화면이 사라진 뒤에도 요청이 계속 실행될 수 있다.


5단계: 첫 번째 요청 주소 꺼내기

5초가 지나면 setInterval() 안에서 fetchNext()가 실행된다.
먼저 요청할 주소가 남아 있는지 확인한다.

// EduApp15.jsx
// [실제 코드] 모든 요청이 끝났는지 확인
if (indexRef.current >= ENDPOINTS.length) return;

처음에는 indexRef.current 값이 0이고, ENDPOINTS.length는 6이다.

// [조건 계산 예시] 첫 번째 요청 전 완료 여부 확인
// indexRef.current >= ENDPOINTS.length
// 0 >= 6
// 결과: false

조건이 false이므로 아직 요청할 주소가 남아 있다.
그래서 다음 코드로 내려간다.


현재 요청할 주소를 꺼낸다.

// EduApp15.jsx
// [실제 코드] 현재 요청할 주소 꺼내기
const url = ENDPOINTS[indexRef.current];

현재 indexRef.current 값이 0이므로 ENDPOINTS[0]이 url에 들어간다.

// [값 예시] 첫 번째 요청에서 url에 들어가는 값
// indexRef.current 값: 0
// url = ENDPOINTS[0]
// url = ".../bikeList/1/5/"

그 다음 바로 indexRef.current 값을 증가시킨다.

// EduApp15.jsx
// [실제 코드] 다음 요청 위치로 이동
indexRef.current += 1;

증가 후 값은 아래처럼 바뀐다.

// [ref 값 예시] 첫 번째 요청 주소를 꺼낸 뒤
// 변경 전 indexRef.current 값: 0
// 변경 후 indexRef.current 값: 1

여기서 중요한 점은 indexRef.current를 먼저 증가시켜 둔다는 것이다.
그래야 다음 fetchNext()가 실행될 때 ENDPOINTS[1]을 요청할 수 있다.


즉 첫 번째 요청이 시작되기 전 값 이동은 아래처럼 정리된다.

// [데이터 흐름 정리] 첫 번째 요청 주소 선택
// indexRef.current 값: 0
// → ENDPOINTS[0] 선택
// → url = ".../bikeList/1/5/"
// → indexRef.current 값을 1로 증가
// → 다음 요청에서는 ENDPOINTS[1] 사용 가능



6단계: 요청 시작 상태로 바꾸기

요청 주소를 꺼낸 뒤 요청 시작 상태를 만든다.

// EduApp15.jsx
// [실제 코드] 요청 시작 상태로 변경
setLoading(true);
setError(null);

setLoading(true)는 loading state 값을 true로 바꾼다.
이 값이 바뀌면 화면은 요청 중 상태로 다시 계산된다.


setError(null)은 이전 요청에서 생긴 에러 메시지를 지운다.
새 요청을 시작할 때 이전 에러가 계속 남아 있으면 현재 요청의 상태를 헷갈릴 수 있기 때문이다.


요청 시작 전 값은 아래와 같다.

// [state/ref 값 예시] 요청 시작 전
// stations state 값: []
// loading state 값: false
// error state 값: null
// indexRef.current 값: 1

요청 시작 후 값은 아래처럼 바뀐다.

// [state/ref 값 예시] 요청 시작 직후
// stations state 값: []
// loading state 값: true
// error state 값: null
// indexRef.current 값: 1

이제 상태 배지는 loading state 값에 따라 수집 중...으로 바뀐다.

// [화면 출력 결과] 요청 중 상태
// 0개 수집됨
// 수집 중...



7단계: fetch로 현재 주소에 요청 보내기

이제 try 안에서 현재 url로 요청을 보낸다.

// EduApp15.jsx
// [실제 코드] 현재 url로 서버 요청
const res = await fetch(url);

첫 번째 요청에서는 url 값이 ENDPOINTS[0]이다.

// [이해용 예시] 첫 번째 요청 주소
// fetch(".../bikeList/1/5/")

서버가 정상 응답을 보내면 먼저 res 객체가 도착한다.
res는 실제 스테이션 배열이 아니라, 응답 상태와 응답 본문을 꺼내는 기능을 가진 객체다.

// [이해용 예시] 실제 작성 코드가 아니라 성공 응답 res 객체를 단순화해서 보여주는 값
res = {
  status: 200,
  ok: true,
  json: async function () {
    return {
      rentBikeStatus: {
        row: [
          {
            stationId: "ST-4",
            stationName: "102. 망원역 1번출구 앞",
            stationLatitude: "37.55564880",
            stationLongitude: "126.91062927"
          },
          {
            stationId: "ST-5",
            stationName: "103. 망원역 2번출구 앞",
            stationLatitude: "37.55495071",
            stationLongitude: "126.91083527"
          }
        ]
      }
    };
  }
};

여기서 res.ok는 응답이 성공 범위인지 알려준다.
실제 스테이션 목록은 아직 res에 바로 들어온 것이 아니다.
res.json()을 실행해야 응답 본문을 json 값으로 꺼낼 수 있다.


res는 서버 응답 정보이고, 실제 스테이션 배열은 res.json()으로 꺼낸 json.rentBikeStatus.row 안에 있다.


8단계: 실패 응답이면 catch로 이동하기

서버 응답을 받은 뒤 성공 응답인지 확인한다.

// EduApp15.jsx
// [실제 코드] HTTP 실패 응답 확인
if (!res.ok) throw new Error(`HTTP ${res.status}`);

성공 응답이면 res.ok 값이 true다.

// [조건 계산 예시] 200 성공 응답일 때 조건 계산
// !res.ok
// !true
// 결과: false

조건이 false이므로 throw new Error()는 실행되지 않는다.
이 경우에는 catch로 가지 않고 다음 줄로 내려간다.


반대로 서버가 500 오류를 응답했다고 가정하면 res는 아래처럼 볼 수 있다.

// [이해용 예시] 실제 작성 코드가 아니라 실패 응답 res 객체를 단순화한 값
res = {
  status: 500,
  ok: false
};

이때 조건을 계산하면 아래와 같다.

// [조건 계산 예시] 500 실패 응답일 때 조건 계산
// !res.ok
// !false
// 결과: true

조건이 true이므로 아래 코드가 실행된다.

// EduApp15.jsx
// [실제 코드] 실패 응답을 Error로 만들어 catch로 보낸다.
throw new Error(`HTTP ${res.status}`);

실제 에러 메시지는 아래처럼 만들어진다.

// [출력 결과] 생성되는 에러 메시지
"HTTP 500"

throw가 실행되면 흐름은 바로 catch로 이동한다.
따라서 아래 성공 처리 코드는 실행되지 않는다.

// [실행 안 됨] 실패 응답에서는 여기까지 내려오지 않는다.
const json = await res.json();
const rows = json?.rentBikeStatus?.row ?? [];
setStations((prev) => {
  const existingIds = new Set(prev.map((s) => s.stationId));
  const newItems = rows.filter((r) => !existingIds.has(r.stationId));
  return [...prev, ...newItems];
});

catch에서는 에러 메시지를 error state에 저장한다.

// EduApp15.jsx
// [실제 코드] 에러 메시지를 error state에 저장
catch (e) {
  setError(`데이터를 가져오지 못했습니다: ${e.message}`);
}

최종 에러 메시지는 아래처럼 만들어진다.

// [state 값 예시] 실패 응답 후 error state 값
// error state 값: "데이터를 가져오지 못했습니다: HTTP 500"

마지막에는 finally가 실행되어 loading state 값이 false가 된다.

// [state 값 예시] 실패 응답 이후 최종 state 값
// stations state 값: []
// loading state 값: false
// error state 값: "데이터를 가져오지 못했습니다: HTTP 500"

여기서 한 가지 더 봐야 할 점이 있다.
현재 코드는 indexRef.current += 1을 fetch(url)보다 먼저 실행한다.
그래서 특정 요청이 실패해도 이미 다음 요청 위치로 이동한 상태다.
다음 setInterval()이 실행되면 실패한 주소를 다시 요청하지 않고 다음 주소를 요청할 수 있다.


즉 실패 응답이 한 번 발생했다고 해서 전체 수집 과정이 바로 멈추는 구조는 아니다.
다음 타이머 실행 시점에 아직 요청할 주소가 남아 있다면 다음 주소로 요청이 이어질 수 있다.


실패 응답 흐름은 아래처럼 정리된다.

// [데이터 흐름 정리] HTTP 실패 응답 흐름
// url = ENDPOINTS[indexRef.current]
// → indexRef.current 먼저 1 증가
// → fetch(url)
// → res 객체 도착
// → res.ok 값 false
// → throw new Error("HTTP 500")
// → catch로 이동
// → error state에 에러 메시지 저장
// → finally에서 loading state 값 false
// → 다음 interval에서는 다음 ENDPOINTS 주소 요청 가능



9단계: 성공 응답이면 json으로 변환하기

실패 응답이 아니라면 성공 응답이다.
이제 응답 본문을 JavaScript 데이터로 바꾼다.

// EduApp15.jsx
// [실제 코드] JSON 응답을 JavaScript 데이터로 변환
const json = await res.json();

res.json()은 서버 응답 본문에 들어 있는 JSON 데이터를 JavaScript 객체로 바꿔준다.


서울시 따릉이 API 응답은 단순 배열이 바로 오는 구조가 아니다.
스테이션 목록 배열은 rentBikeStatus.row 안에 들어 있다.

// [이해용 예시] 실제 작성 코드가 아니라 res.json() 실행 후 json에 들어오는 값
json = {
  rentBikeStatus: {
    row: [
      {
        stationId: "ST-4",
        stationName: "102. 망원역 1번출구 앞",
        stationLatitude: "37.55564880",
        stationLongitude: "126.91062927"
      },
      {
        stationId: "ST-5",
        stationName: "103. 망원역 2번출구 앞",
        stationLatitude: "37.55495071",
        stationLongitude: "126.91083527"
      }
    ]
  }
};

여기서 실제 카드로 출력할 배열은 json 전체가 아니다.
json.rentBikeStatus.row가 실제 스테이션 배열이다.


10단계: rentBikeStatus.row에서 rows 꺼내기

응답 본문을 json으로 바꾼 뒤 실제 스테이션 배열을 꺼낸다.

// EduApp15.jsx
// [실제 코드] 실제 스테이션 배열 꺼내기
const rows = json?.rentBikeStatus?.row ?? [];

json?.rentBikeStatus?.row는 중간 값이 없을 때 오류가 나지 않도록 안전하게 접근하는 방식이다.
만약 json이 없거나, rentBikeStatus가 없거나, row가 없으면 오류를 내지 않고 undefined가 된다.


?? []는 앞의 값이 null 또는 undefined이면 빈 배열 []을 사용하라는 뜻이다.
즉 이 코드는 아래 의미다.

// [의미 정리]
// json 안에 rentBikeStatus가 있고,
// 그 안에 row가 있으면 row를 사용한다.
// row가 없으면 빈 배열 []을 사용한다.

정상 응답이라면 rows에는 아래처럼 스테이션 배열이 들어온다.

// [이해용 예시] rows에 들어오는 값
rows = [
  {
    stationId: "ST-4",
    stationName: "102. 망원역 1번출구 앞",
    stationLatitude: "37.55564880",
    stationLongitude: "126.91062927"
  },
  {
    stationId: "ST-5",
    stationName: "103. 망원역 2번출구 앞",
    stationLatitude: "37.55495071",
    stationLongitude: "126.91083527"
  }
];

만약 응답 구조가 예상과 다르다면 rows는 빈 배열이 된다.

// [이해용 예시] row가 없을 때
// rows 값: []

이렇게 처리하면 응답 구조가 비어 있어도 rows.filter() 같은 배열 메서드를 안전하게 사용할 수 있다.


11단계: 기존 stations state와 비교해서 중복 제거하기

가져온 rows를 바로 stations state 뒤에 붙이지 않는다.
기존 목록에 이미 같은 stationId가 있을 수 있기 때문이다.
그래서 기존 stations state에 없는 항목만 골라서 추가한다.

// EduApp15.jsx
// [실제 코드] 기존 목록과 비교해서 새 항목만 누적
setStations((prev) => {
  const existingIds = new Set(prev.map((s) => s.stationId));
  const newItems = rows.filter((r) => !existingIds.has(r.stationId));
  return [...prev, ...newItems];
});

여기서 prev는 이전 stations state 값이다.
즉 지금 화면에 이미 누적되어 있는 스테이션 목록이다.


첫 번째 요청 전에는 아직 누적된 데이터가 없다.

// [state 값 예시] 첫 번째 요청 전 stations state 값
// prev 값: []

기존 목록에서 stationId만 뽑아 Set으로 만든다.

// EduApp15.jsx
// [실제 코드] 기존 stationId 목록 만들기
const existingIds = new Set(prev.map((s) => s.stationId));

첫 번째 요청에서는 prev가 빈 배열이므로 existingIds도 비어 있다.

// [이해용 예시] 첫 번째 요청에서 existingIds 값
// prev 값: []
// existingIds 값: Set {}

그 다음 새로 받은 rows 중 기존에 없는 항목만 고른다.

// EduApp15.jsx
// [실제 코드] 기존 목록에 없는 새 항목만 고르기
const newItems = rows.filter((r) => !existingIds.has(r.stationId));

첫 번째 요청에서는 기존 목록이 비어 있으므로 rows의 모든 항목이 새 항목이다.

// [이해용 예시] 첫 번째 요청에서 newItems 값
newItems = [
  {
    stationId: "ST-4",
    stationName: "102. 망원역 1번출구 앞",
    stationLatitude: "37.55564880",
    stationLongitude: "126.91062927"
  },
  {
    stationId: "ST-5",
    stationName: "103. 망원역 2번출구 앞",
    stationLatitude: "37.55495071",
    stationLongitude: "126.91083527"
  }
];

마지막으로 기존 목록 뒤에 새 항목을 붙인 새 배열을 반환한다.

// EduApp15.jsx
// [실제 코드] 기존 목록 뒤에 새 항목 붙이기
return [...prev, ...newItems];

첫 번째 요청 후 stations state 값은 아래처럼 바뀐다.

// [state 값 예시] 첫 번째 요청 후 stations state 값
// stations state 값:
// [
//   { stationId: "ST-4", stationName: "102. 망원역 1번출구 앞", ... },
//   { stationId: "ST-5", stationName: "103. 망원역 2번출구 앞", ... }
// ]

setStations(prev => ...)를 사용하는 이유는 이전 stations state 값을 기준으로 새 응답 데이터를 안전하게 이어 붙이기 위해서다.


12단계: 마지막 업데이트 시간 저장하고 요청 종료하기

스테이션 데이터 누적이 끝나면 마지막 업데이트 시간을 저장한다.

// EduApp15.jsx
// [실제 코드] 마지막 업데이트 시간 저장
setLastTime(new Date().toLocaleTimeString("ko-KR"));

new Date()는 현재 시간을 만든다.
toLocaleTimeString("ko-KR")은 현재 시간을 한국어 시간 형식의 문자열로 바꾼다.


예를 들어 요청이 오후 3시 20분 10초에 성공했다면 lastTime state 값은 아래처럼 저장될 수 있다.

// [state 값 예시] 마지막 업데이트 시간
// lastTime state 값: "오후 3:20:10"

요청이 성공해도, 실패해도 마지막에는 finally가 실행된다.

// EduApp15.jsx
// [실제 코드] 요청 종료 상태로 변경
finally {
  setLoading(false);
}

finally는 try가 성공해도 실행되고, catch로 실패 처리를 해도 실행된다.
즉, 요청이 끝나면 항상 loading state 값을 false로 돌려놓는다.


성공 응답 이후 최종 값은 아래처럼 이해할 수 있다.

// [state/ref 값 예시] 요청 성공 이후 최종 값
// stations state 값: 기존 목록 + 새 스테이션 목록
// loading state 값: false
// error state 값: null
// lastTime state 값: "오후 3:20:10"

이제 화면은 요청 중 상태가 아니고, 누적된 stations state를 기준으로 카드 목록을 다시 출력한다.


13단계: 두 번째 요청에서 기존 목록 뒤에 새 데이터 누적하기

다음 5초가 지나면 fetchNext()가 다시 실행된다.
이때 indexRef.current 값은 이미 1이다.

// [ref 값 예시] 두 번째 요청 전
// indexRef.current 값: 1

그래서 이번에는 ENDPOINTS[1]이 url에 들어간다.

// [값 예시] 두 번째 요청에서 url에 들어가는 값
// url = ENDPOINTS[1]
// url = ".../bikeList/6/10/"

그리고 다시 indexRef.current 값을 증가시킨다.

// [ref 값 예시] 두 번째 요청 주소를 꺼낸 뒤
// 변경 전 indexRef.current 값: 1
// 변경 후 indexRef.current 값: 2

두 번째 응답으로 아래처럼 새 스테이션 목록이 왔다고 가정할 수 있다.

// [이해용 예시] 두 번째 요청의 rows 값
rows = [
  {
    stationId: "ST-6",
    stationName: "104. 합정역 1번출구 앞",
    stationLatitude: "37.55000000",
    stationLongitude: "126.91000000"
  },
  {
    stationId: "ST-7",
    stationName: "105. 합정역 5번출구 앞",
    stationLatitude: "37.54900000",
    stationLongitude: "126.91100000"
  }
];

이때 prev는 첫 번째 요청에서 저장된 기존 stations state 값이다.

// [state 값 예시] 두 번째 요청에서 prev 값
prev = [
  {
    stationId: "ST-4",
    stationName: "102. 망원역 1번출구 앞",
    stationLatitude: "37.55564880",
    stationLongitude: "126.91062927"
  },
  {
    stationId: "ST-5",
    stationName: "103. 망원역 2번출구 앞",
    stationLatitude: "37.55495071",
    stationLongitude: "126.91083527"
  }
];

기존 stationId 목록은 아래처럼 만들어진다.

// [이해용 예시] 두 번째 요청에서 existingIds 값
// existingIds 값: Set { "ST-4", "ST-5" }

rows 안의 ST-6, ST-7은 기존 목록에 없으므로 newItems에 들어간다.

// [이해용 예시] 두 번째 요청에서 newItems 값
newItems = [
  {
    stationId: "ST-6",
    stationName: "104. 합정역 1번출구 앞",
    stationLatitude: "37.55000000",
    stationLongitude: "126.91000000"
  },
  {
    stationId: "ST-7",
    stationName: "105. 합정역 5번출구 앞",
    stationLatitude: "37.54900000",
    stationLongitude: "126.91100000"
  }
];

최종 반환값은 기존 목록 뒤에 새 목록을 붙인 배열이다.

// [state 값 예시] 두 번째 요청 후 stations state 값
// stations state 값:
// [
//   {
//     stationId: "ST-4",
//     stationName: "102. 망원역 1번출구 앞",
//     stationLatitude: "37.55564880",
//     stationLongitude: "126.91062927"
//   },
//   {
//     stationId: "ST-5",
//     stationName: "103. 망원역 2번출구 앞",
//     stationLatitude: "37.55495071",
//     stationLongitude: "126.91083527"
//   },
//   {
//     stationId: "ST-6",
//     stationName: "104. 합정역 1번출구 앞",
//     stationLatitude: "37.55000000",
//     stationLongitude: "126.91000000"
//   },
//   {
//     stationId: "ST-7",
//     stationName: "105. 합정역 5번출구 앞",
//     stationLatitude: "37.54900000",
//     stationLongitude: "126.91100000"
//   }
// ]

즉 요청이 반복될수록 stations state는 새 배열로 바뀌고, 화면에는 카드가 뒤에 계속 추가된다.


14단계: 중복 stationId가 있으면 제외하기

만약 새로 받은 rows 안에 기존 stations state에 이미 있는 stationId가 들어 있다면 그 항목은 제외된다.


예를 들어 기존 목록에 이미 ST-4, ST-5가 있다고 가정한다.

// [state 값 예시] 기존 stations state의 stationId
// existingIds 값: Set { "ST-4", "ST-5" }

그런데 새 응답으로 아래 rows가 들어왔다고 볼 수 있다.

// [이해용 예시] 중복이 섞인 rows 값
rows = [
  {
    stationId: "ST-5",
    stationName: "103. 망원역 2번출구 앞",
    stationLatitude: "37.55495071",
    stationLongitude: "126.91083527"
  },
  {
    stationId: "ST-8",
    stationName: "106. 합정역 주변",
    stationLatitude: "37.54800000",
    stationLongitude: "126.91200000"
  }
];

필터 조건은 아래처럼 동작한다.

// EduApp15.jsx
// [실제 코드] 중복이 아닌 항목만 남기는 조건
rows.filter((r) => !existingIds.has(r.stationId));

각 항목을 실제 값으로 보면 아래와 같다.

// [조건 계산 예시] 중복 제거 계산
// r.stationId = "ST-5"
// existingIds.has("ST-5") → true
// !true → false
// 결과: ST-5는 제외

// r.stationId = "ST-8"
// existingIds.has("ST-8") → false
// !false → true
// 결과: ST-8은 추가

따라서 newItems에는 ST-8만 남는다.

// [이해용 예시] 중복 제거 후 newItems 값
newItems = [
  {
    stationId: "ST-8",
    stationName: "106. 합정역 주변",
    stationLatitude: "37.54800000",
    stationLongitude: "126.91200000"
  }
];

이렇게 하면 같은 스테이션 카드가 중복으로 화면에 출력되는 것을 막을 수 있다.


15단계: stations.map으로 카드 출력하기

stations state에 데이터가 들어오면 아래 코드가 실행된다.

// EduApp15.jsx
// [실제 코드] stations state 배열을 카드 목록으로 변환
{stations.map((s, i) => (
  <StationCard key={s.stationId} station={s} index={i} />
))}

stations.map()은 stations state 배열 안의 스테이션 객체를 하나씩 꺼낸다.
꺼낸 스테이션 객체 하나를 s라고 부르고, 그 값을 StationCard에게 station이라는 이름으로 전달한다.


첫 번째 반복에서 s와 i는 아래처럼 들어온다.

// [이해용 예시] stations.map() 첫 번째 반복 값
s = {
  stationId: "ST-4",
  stationName: "102. 망원역 1번출구 앞",
  stationLatitude: "37.55564880",
  stationLongitude: "126.91062927"
};
i = 0;

이 값은 아래처럼 StationCard로 전달된다.

// [이해용 예시] StationCard에 전달되는 값
<StationCard
  key="ST-4"
  station={{
    stationId: "ST-4",
    stationName: "102. 망원역 1번출구 앞",
    stationLatitude: "37.55564880",
    stationLongitude: "126.91062927"
  }}
  index={0}
/>

StationCard는 station 객체의 값을 꺼내 카드에 출력한다.

// EduApp15.jsx
// [실제 코드] StationCard 내부 출력 값
<span className="card-id">{station.stationId}</span>
<p className="card-name">{station.stationName}</p>
<span className="coord-val">{station.stationLatitude}</span>
<span className="coord-val">{station.stationLongitude}</span>

첫 번째 카드에 실제로 들어가는 값은 아래와 같다.

// [화면 출력값 예시] 첫 번째 카드
// station.stationId → "ST-4"
// station.stationName → "102. 망원역 1번출구 앞"
// station.stationLatitude → "37.55564880"
// station.stationLongitude → "126.91062927"

최종 화면에서는 카드 하나가 아래 정보 묶음으로 보인다.

// [화면 출력 결과] 카드 하나에 보이는 정보
// 🚲
// ST-4
// 102. 망원역 1번출구 앞
// 위도 37.55564880
// 경도 126.91062927



16단계: 모든 요청이 끝났는지 확인하기

화면을 렌더링할 때 완료 여부도 계산한다.

// EduApp15.jsx
// [실제 코드] 모든 요청 완료 여부 계산
const isDone = indexRef.current >= ENDPOINTS.length && !loading;

isDone은 모든 요청이 끝났는지 확인하는 값이다.
indexRef.current가 ENDPOINTS.length 이상이고, 현재 요청 중이 아니면 완료 상태로 본다.


처음에는 아래와 같다.

// [조건 계산 예시] 처음 완료 상태
// indexRef.current 값: 0
// ENDPOINTS.length 값: 6
// loading state 값: false
// indexRef.current >= ENDPOINTS.length → 0 >= 6 → false
// !loading → !false → true
// isDone 값: false

아직 요청할 주소가 남아 있으므로 완료가 아니다.


모든 요청이 끝나면 아래처럼 된다.

// [조건 계산 예시] 모든 요청 완료 상태
// indexRef.current 값: 6
// ENDPOINTS.length 값: 6
// loading state 값: false
// indexRef.current >= ENDPOINTS.length → 6 >= 6 → true
// !loading → !false → true
// isDone 값: true

이 값은 상태 배지와 완료 문구를 출력할 때 사용된다.

// EduApp15.jsx
// [실제 코드] 상태 배지 출력
{isDone
  ? `완료 · ${lastTime}`
  : loading
  ? "수집 중..."
  : `마지막: ${lastTime ?? "--"}`}

상태 배지는 아래 기준으로 바뀐다.

// [조건 결과 예시] 상태 배지 문구
// isDone이 true이면 → "완료 · 마지막 업데이트 시간"
// isDone이 false이고 loading이 true이면 → "수집 중..."
// 둘 다 아니면 → "마지막: 마지막 업데이트 시간 또는 --"

isDone이 true이고 stations.length > 0이면 하단 완료 문구도 출력된다.

// EduApp15.jsx
// [실제 코드] 완료 문구 출력
{isDone && stations.length > 0 && (
  <div>✅ 총 {stations.length}개 스테이션 수집 완료</div>
)}

중복이 없고 각 요청에서 5개씩 가져왔다면 최종 수집 개수는 최대 30개다.

// [화면 출력 결과] 완료 문구
// ✅ 총 30개 스테이션 수집 완료



17단계: 모든 요청이 끝나면 타이머를 멈추기

setInterval()은 일정 시간마다 계속 실행된다.
그래서 모든 주소 요청이 끝났는지 확인하고, 끝났다면 타이머를 멈춰야 한다.

// EduApp15.jsx
// [실제 코드] 모든 요청이 끝나면 타이머 정리
const timer = setInterval(() => {
  if (indexRef.current >= ENDPOINTS.length) {
    clearInterval(timer);
    return;
  }
  fetchNext();
}, 5000);

모든 요청 전에는 아래처럼 계산된다.

// [조건 계산 예시] 아직 요청할 주소가 남은 경우
// indexRef.current >= ENDPOINTS.length
// 3 >= 6
// 결과: false

조건이 false이면 fetchNext()를 실행한다.


마지막 요청이 시작되면 indexRef.current는 6까지 증가할 수 있다.
하지만 clearInterval(timer)가 마지막 요청이 끝나는 바로 그 순간 실행되는 것은 아니다.
타이머 함수가 다음 번에 다시 실행될 때 아래 조건이 true가 되면서 타이머가 멈춘다.

// [조건 계산 예시] 모든 요청이 끝난 뒤 다음 타이머 실행 시점
// indexRef.current >= ENDPOINTS.length
// 6 >= 6
// 결과: true

조건이 true이면 아래 코드가 실행된다.

// EduApp15.jsx
// [실제 코드] 타이머 중지
clearInterval(timer);
return;

이렇게 해야 더 이상 필요 없는 API 요청이 반복되지 않는다.


즉 완료와 타이머 중지는 아래처럼 구분해서 이해하면 된다.

// [흐름 정리] 완료 상태와 타이머 중지 시점
// 마지막 요청 시작 → indexRef.current 값이 6이 됨
// 마지막 요청 완료 → loading state 값이 false가 됨
// 화면에서는 isDone이 true가 되어 완료 상태 표시
// 다음 interval 실행 시점 → indexRef.current >= ENDPOINTS.length 조건 true
// clearInterval(timer) 실행
// 타이머 중지



18단계: 네트워크 또는 CORS 오류가 나면 res 객체가 없다

서버가 아예 응답하지 않거나, 요청 주소에 접근할 수 없거나, CORS 정책에 막히면 res 객체 자체가 만들어지지 못할 수 있다.
이 경우에는 아래 코드에서 바로 에러가 발생한다.

// EduApp15.jsx
// [실제 코드] 서버 연결 실패 또는 CORS 오류가 나면 여기서 catch로 이동할 수 있다.
const res = await fetch(url);

즉, res.ok를 확인하기 전에 catch로 이동한다.
res 객체가 없으므로 res.json()도 실행할 수 없다.


catch에서는 오류 메시지를 error state에 저장한다.

// EduApp15.jsx
// [실제 코드] 네트워크 오류 메시지를 error state에 저장
catch (e) {
  setError(`데이터를 가져오지 못했습니다: ${e.message}`);
}

브라우저 환경에서는 네트워크 오류나 CORS 오류가 발생했을 때 아래와 비슷한 메시지가 들어올 수 있다.

// [이해용 예시] 네트워크 또는 CORS 오류 시 error state 값
// error state 값: "데이터를 가져오지 못했습니다: Failed to fetch"

마지막에는 finally가 실행되어 loading state 값이 false가 된다.

// [state 값 예시] 네트워크 또는 CORS 오류 이후 최종 state 값
// stations state 값: 기존 값 유지
// loading state 값: false
// error state 값: "데이터를 가져오지 못했습니다: Failed to fetch"

네트워크 또는 CORS 오류 흐름을 정리하면 아래와 같다.

// [데이터 흐름 정리] 네트워크 또는 CORS 오류 흐름
// fetch(url)
// → 서버 연결 실패 또는 CORS 차단
// → res 객체가 만들어지지 못함
// → res.ok 확인 못 함
// → res.json() 실행 못 함
// → catch로 이동
// → setError(`데이터를 가져오지 못했습니다: ${e.message}`)
// → error state에 오류 메시지 저장
// → finally에서 loading state 값 false
// → 에러 메시지 출력

이 상황에서는 요청 주소가 맞는지, 서울시 API 접근이 가능한지, 브라우저에서 CORS 문제가 발생하는지 확인해야 한다.


8-4. 화면 상태 요약

처음 화면

처음 화면에서는 아직 스테이션 데이터가 수집되지 않았다.
그래서 stations state 값은 빈 배열이고, loading state 값은 false, lastTime state 값은 null, error state 값은 null이다.


처음 화면은 아래 상태로 이해하면 된다.

// [화면 출력 결과] 처음 화면 상태
// stations state 값: []
// loading state 값: false
// lastTime state 값: null
// error state 값: null
// indexRef.current 값: 0
// 수집 개수: 0개 수집됨
// 상태 배지: 마지막: --
// 안내 문구 출력
// 카드 출력 안 됨



수집 중 화면

setInterval()이 실행되면 fetchNext()가 일정 시간마다 다음 API 주소를 호출한다.
요청 중에는 loading state 값이 true가 된다.


요청 중 화면은 아래 상태로 이해하면 된다.

// [화면 출력 결과] 수집 중 상태
// stations state 값: 기존 누적 목록
// loading state 값: true
// error state 값: null
// 상태 배지: 수집 중...

응답이 도착하면 stations state에 새 항목이 누적되고, 카드가 화면에 나타난다.
영상에서는 카드가 한 번에 전부 생기는 것이 아니라 5개 단위로 늘어나는 흐름이 보인다.


누적 출력 화면

첫 번째 요청이 끝나면 5개 정도의 카드가 보인다.
다음 요청이 끝나면 기존 카드 뒤에 새 카드가 추가되면서 10개, 15개, 20개처럼 수집 개수가 증가한다.


카드는 그리드 형태로 배치된다.
각 카드에는 자전거 아이콘, 스테이션 번호, 스테이션 이름, 위도, 경도가 표시된다.


누적 출력 흐름은 아래처럼 이해하면 된다.

// [화면 출력 흐름] 카드 누적
// 첫 번째 요청 성공 → stations state에 5개 누적 → 카드 5개 출력
// 두 번째 요청 성공 → stations state에 새 5개 추가 → 카드 10개 출력
// 세 번째 요청 성공 → stations state에 새 5개 추가 → 카드 15개 출력



완료 화면

모든 ENDPOINTS 요청이 끝나면 isDone이 true가 된다.
상태 배지는 완료 · 마지막 시간 형태로 바뀐다.


stations.length > 0이고 isDone이 true이면 하단 완료 문구가 표시된다.

// [화면 출력 결과] 완료 문구
// ✅ 총 30개 스테이션 수집 완료

TOTAL_PAGES가 6이고 각 요청이 5개 범위를 가져오므로, 중복이 없다면 최대 30개 스테이션이 수집된다.


실패 화면

HTTP 실패 응답이나 네트워크 오류가 발생하면 catch에서 setError()가 실행된다.


실패 화면은 아래 상태로 이해하면 된다.

// [화면 출력 결과] 실패 화면 상태
// stations state 값: 기존 누적 목록 유지
// loading state 값: false
// error state 값: "데이터를 가져오지 못했습니다: HTTP 500"

이때 화면에는 에러 메시지가 출력된다.
오류가 발생했는데 아무 문구도 보여주지 않으면 사용자는 현재 상황을 알 수 없다.
그래서 자동 요청 화면에서도 실패 상태를 화면에 반영해야 한다.


8-5. 결과물

실행 결과 영상

처음에는 0개 수집됨, 마지막: --, 스테이션 정보를 불러오는 중입니다... 상태로 시작한다.
시간이 지나면 fetchNext()가 다음 API 주소를 호출하고, 응답으로 받은 스테이션 데이터가 stations state에 누적된다.
화면에서는 카드가 5개 단위로 추가되고, 헤더의 수집 개수도 함께 증가한다.
마지막 요청까지 끝나면 상태 배지가 완료 · 시간으로 바뀌고, 하단에는 총 수집 완료 문구가 표시된다.


핵심 정리

EduApp15.jsx는 서울시 따릉이 API를 여러 번 순서대로 호출하고, 응답 데이터를 stations state에 누적하는 예제다.
buildEndpoints()는 1~5, 6~10, 11~15처럼 범위를 나누어 요청 주소 배열을 만든다.


useEffect()는 컴포넌트가 처음 화면에 나타난 뒤 setInterval()을 등록한다.
setInterval()은 일정 시간마다 fetchNext()를 실행하고, fetchNext()는 indexRef.current가 가리키는 다음 주소를 요청한다.


indexRef.current는 다음 요청 위치를 기억하지만 화면에 직접 보여줄 값은 아니다.
그래서 값이 바뀌어도 리렌더링을 일으키지 않는 useRef()로 관리한다.


응답이 성공하면 res.json()으로 응답 본문을 json으로 바꾸고, json?.rentBikeStatus?.row ?? []로 실제 스테이션 배열을 꺼낸다.
그 다음 기존 stations state에 이미 있는 stationId를 제외하고 새 항목만 뒤에 붙인다.


중복 제거는 Set과 filter()를 사용한다.
prev.map((s) => s.stationId)로 기존 stationId 목록을 만들고, rows.filter((r) => !existingIds.has(r.stationId))로 새 항목만 골라낸다.


화면 출력은 stations.map()으로 처리한다.
stations state 배열 안의 스테이션 객체 하나가 StationCard 하나로 전달되고, 카드에는 스테이션 번호, 이름, 위도, 경도가 출력된다.


이번 예제의 핵심은 setInterval()로 반복 요청을 만들고, useRef()로 다음 요청 위치를 기억하며, setStations(prev => ...)로 이전 목록에 새 데이터를 안전하게 누적하는 것이다.




EduApp16 Board REST API 테스트 실습

EduApp16.jsx는 React 화면에서 Spring Boot의 Board REST API를 직접 테스트하는 실습 예제다.
앞에서 EduApp14.jsx는 목록 조회 중심으로 GET 요청을 다뤘고, EduApp15.jsx는 여러 API 요청을 순차적으로 누적하는 흐름을 다뤘다.


이번 예제는 게시글 전체 목록 조회, 상세 조회, 등록, 수정, 삭제, 기타 문자열 응답 조회까지 한 화면에서 테스트한다.
즉, EduApp16.jsx는 GET, POST, PUT, DELETE 요청이 실제 화면 동작과 어떻게 연결되는지 확인하는 예제다.


9-1. EduApp16 예제가 보여주는 핵심

Board REST API를 화면에서 직접 테스트한다

EduApp16.jsx 파일에는 FetchExam3 컴포넌트가 작성되어 있다.
이 컴포넌트는 http://localhost:9000/boards 주소를 기준으로 게시글 관련 REST API를 호출한다.


REST API는 서버의 자원을 주소와 요청 방식으로 구분해서 다루는 방식이다.
초보자 기준에서는 “주소는 어떤 데이터를 다룰지 정하고, 요청 방식은 어떤 작업을 할지 정한다”고 이해하면 된다.


이번 예제에서 사용하는 요청은 아래와 같다.

  • 전체 목록 조회: GET /boards
  • 상세 조회: GET /boards/{boardNo}
  • 등록: POST /boards
  • 수정: PUT /boards/{boardNo}
  • 삭제: DELETE /boards/{boardNo}
  • 오늘 요일 조회: GET /boards/day
  • 친구 목록 조회: GET /boards/friends

이 예제는 단순히 버튼을 눌러 보는 화면이 아니다.
각 버튼이 어떤 HTTP method 요청으로 이어지는지, 서버 응답이 어떤 state에 저장되는지, 그 state가 화면을 어떻게 바꾸는지 연결해서 봐야 한다.


요청마다 저장되는 state가 다르다

EduApp16.jsx는 여러 기능을 한 화면에서 테스트한다.
그래서 모든 응답을 하나의 state에 저장하지 않는다.


각 기능별 저장 위치는 아래처럼 나뉜다.

  • 전체 목록 조회 결과는 boards state에 저장한다.
  • 상세 조회 결과는 selectedBoard state에 저장한다.
  • 등록과 수정 입력값은 form state에 저장한다.
  • 등록, 수정, 삭제 응답 문자열은 message state에 저장한다.
  • 오늘 요일 조회 결과는 day state에 저장한다.
  • 친구 목록 조회 결과는 friends state에 저장한다.

이번 예제의 핵심은 서버 응답이 어떤 state에 저장되고, 그 state가 화면의 어느 부분을 바꾸는지 구분하는 것이다.


EduApp14, EduApp15와 다른 점

EduApp14.jsx는 GET /boards로 전체 목록을 한 번 조회하는 흐름이 중심이었다.
EduApp15.jsx는 여러 주소를 순차적으로 호출하고 받은 데이터를 누적하는 흐름이 중심이었다.


EduApp16.jsx는 한 화면에서 여러 종류의 요청을 모두 테스트한다.
그래서 이번에는 단순 조회보다 요청 방식의 차이와 응답 저장 위치의 차이가 중요하다.


비교하면 아래와 같다.

  • EduApp14: 목록 조회 중심이다.
  • EduApp15: 반복 요청과 누적 출력 중심이다.
  • EduApp16: CRUD 요청과 기타 문자열 응답 테스트 중심이다.

CRUD는 데이터를 다루는 기본 작업을 뜻한다.
Create는 등록, Read는 조회, Update는 수정, Delete는 삭제이다.
이 예제에서는 등록은 POST, 조회는 GET, 수정은 PUT, 삭제는 DELETE 요청으로 연결된다.


9-2. 기본 코드 흐름

코드를 역할 기준으로 먼저 나눈다

EduApp16.jsx는 한 화면에서 여러 API 요청을 테스트하므로 코드가 길다.
하지만 역할 기준으로 나누면 흐름이 훨씬 잘 보인다.


첫 번째는 서버 주소와 날짜 출력 함수를 준비하는 부분이다.
여기에는 API_BASE, formatDate(), getNowDateTime()이 들어간다.


두 번째는 화면에서 사용할 상태를 준비하는 부분이다.
여기에는 boards, message, readBoardNo, selectedBoard, day, friends, form이 들어간다.


세 번째는 사용자 입력을 관리하는 부분이다.
여기에는 handleChange()와 resetForm()이 들어간다.
입력칸 값이 바뀌면 form state를 변경하고, 초기화 버튼을 누르면 입력값을 비운다.


네 번째는 서버 요청 함수 부분이다.
여기에는 loadBoards(), registerBoard(), readBoard(), modifyBoard(), deleteBoard(), loadDay(), loadFriends()가 들어간다.
각 함수는 버튼 클릭과 연결되어 서버에 요청을 보낸다.


다섯 번째는 상태에 따라 화면을 출력하는 부분이다.
전체 목록 테이블, 상세 조회 결과, 등록/수정 입력 영역, 기타 API 결과, 응답 메시지를 화면에 보여준다.


큰 구조를 코드로 보면 아래와 같다.

// EduApp16.jsx
// [실제 코드 구조 요약]
const API_BASE = "http://localhost:9000/boards"; // 서버 기본 주소다.

function formatDate(isoString) {
  // 서버 날짜를 화면용 문자열로 바꾼다.
}

export default function FetchExam3() {
  const [boards, setBoards] = useState([]); // 전체 목록이다.
  const [message, setMessage] = useState(""); // 응답 메시지다.
  const [readBoardNo, setReadBoardNo] = useState(1); // 상세 조회 번호다.
  const [selectedBoard, setSelectedBoard] = useState(null); // 상세 조회 결과다.
  const [day, setDay] = useState(""); // 오늘 요일 결과다.
  const [friends, setFriends] = useState(""); // 친구 목록 결과다.

  const [form, setForm] = useState({
    boardNo: "",
    title: "",
    content: "",
    writer: "",
  }); // 등록/수정 입력값이다.

  // 입력 관리 함수
  // 서버 요청 함수
  // 화면 출력 코드
}

이 코드는 아직 세부 실행 흐름을 보는 단계가 아니다.
먼저 전체 화면이 어떤 상태와 어떤 함수로 구성되어 있는지 큰 덩어리로 보는 단계다.


정리하면 아래처럼 나눌 수 있다.

  • API_BASE, formatDate(), getNowDateTime(): 요청 주소와 날짜 처리 준비
  • boards, message, readBoardNo, selectedBoard, day, friends, form: 화면 상태
  • handleChange(), resetForm(): 입력값 관리
  • loadBoards(), registerBoard(), readBoard(), modifyBoard(), deleteBoard(), loadDay(), loadFriends(): 서버 요청 함수
  • return: 목록, 상세, 입력 폼, 기타 결과, 응답 메시지 출력

이제 큰 구조를 봤으니, 다음 단계에서는 각 버튼을 눌렀을 때 어떤 요청이 나가고 어떤 상태가 바뀌는지 따라가면 된다.


9-3. 자세한 풀이

전체 코드

아래 코드는 EduApp16.jsx에 들어가는 핵심 실제 코드다.
화면 스타일 코드는 길기 때문에 여기서는 API 요청과 상태 변경 흐름을 이해하는 데 필요한 코드 중심으로 정리했다.

// EduApp16.jsx
// [실제 코드] Board REST API 테스트 핵심 코드
import { useEffect, useState } from "react"; // 화면 상태와 초기 실행을 위해 가져온다.

const API_BASE = "http://localhost:9000/boards"; // 게시글 API 기본 주소다.

function formatDate(isoString) {
  const d = new Date(isoString); // 서버 날짜 문자열을 날짜 객체로 바꾼다.
  const pad = n => String(n).padStart(2, "0"); // 숫자를 두 자리로 맞춘다.
  return `${d.getFullYear()}년 ${pad(d.getMonth() + 1)}월 ${pad(d.getDate())}일 ${pad(d.getHours())}시 ${pad(d.getMinutes())}분`;
}

export default function FetchExam3() {
  const [boards, setBoards] = useState([]); // 전체 게시글 목록이다.
  const [message, setMessage] = useState(""); // 서버 응답 메시지다.
  const [readBoardNo, setReadBoardNo] = useState(1); // 상세 조회할 게시글 번호다.
  const [selectedBoard, setSelectedBoard] = useState(null); // 상세 조회 결과다.
  const [day, setDay] = useState(""); // 오늘 요일 결과다.
  const [friends, setFriends] = useState(""); // 친구 목록 결과다.

  const [form, setForm] = useState({
    boardNo: "",
    title: "",
    content: "",
    writer: "",
  }); // 등록과 수정에 사용할 입력값이다.

  const getNowDateTime = () => {
    const now = new Date(); // 현재 시간을 만든다.
    const offset = now.getTimezoneOffset() * 60000; // 시간대 차이를 계산한다.
    return new Date(now.getTime() - offset).toISOString().slice(0, 19); // 서버에 보낼 날짜 문자열이다.
  };

  const handleChange = (e) => {
    const { name, value } = e.target; // 입력칸 이름과 값을 꺼낸다.

    setForm({
      ...form, [name]: value,
    }); // 바뀐 입력칸만 form state에 반영한다.
  };

  const resetForm = () => {
    setForm({
      boardNo: "",
      title: "",
      content: "",
      writer: "",
    }); // 입력값을 모두 비운다.
  };

  const loadBoards = async () => {
    const response = await fetch(API_BASE); // 전체 목록을 요청한다.
    const data = await response.json(); // JSON 응답을 배열로 바꾼다.
    setBoards(data); // 전체 목록 state에 저장한다.
  };

  const registerBoard = async () => {
    const newBoard = {
      boardNo: Number(form.boardNo),
      title: form.title,
      content: form.content,
      writer: form.writer,
      regDate: getNowDateTime(),
    }; // 등록할 게시글 객체다.

    const response = await fetch(API_BASE, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(newBoard),
    }); // 등록 요청을 보낸다.

    const text = await response.text(); // 서버 응답 문자열을 꺼낸다.
    setMessage(text); // 응답 메시지를 저장한다.
    resetForm(); // 입력칸을 비운다.
    loadBoards(); // 전체 목록을 다시 불러온다.
  };

  const readBoard = async () => {
    const response = await fetch(`${API_BASE}/${readBoardNo}`); // 번호로 상세 조회한다.
    const data = await response.json(); // 조회 결과를 객체로 바꾼다.

    setSelectedBoard(data); // 상세 조회 결과를 저장한다.
    setForm({
      boardNo: data.boardNo ?? "",
      title: data.title ?? "",
      content: data.content ?? "",
      writer: data.writer ?? "",
    }); // 조회 결과를 수정 입력칸에도 채운다.
  };

  const modifyBoard = async () => {
    const updateBoard = {
      boardNo: Number(form.boardNo),
      title: form.title,
      content: form.content,
      writer: form.writer,
      regDate: getNowDateTime(),
    }; // 수정할 게시글 객체다.

    const response = await fetch(`${API_BASE}/${form.boardNo}`, {
      method: "PUT",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify(updateBoard),
    }); // 수정 요청을 보낸다.

    const text = await response.text(); // 서버 응답 문자열을 꺼낸다.
    setMessage(text); // 응답 메시지를 저장한다.
    resetForm(); // 입력칸을 비운다.
    setSelectedBoard(null); // 상세 조회 결과를 비운다.
    loadBoards(); // 전체 목록을 다시 불러온다.
  };

  const deleteBoard = async (boardNo) => {
    const response = await fetch(`${API_BASE}/${boardNo}`, {
      method: "DELETE",
    }); // 삭제 요청을 보낸다.

    const text = await response.text(); // 서버 응답 문자열을 꺼낸다.
    setMessage(text); // 응답 메시지를 저장한다.
    loadBoards(); // 전체 목록을 다시 불러온다.
  };

  const loadDay = async () => {
    const response = await fetch(`${API_BASE}/day`); // 오늘 요일 요청이다.
    const text = await response.text(); // 문자열 응답을 꺼낸다.
    setDay(text); // 오늘 요일 state에 저장한다.
  };

  const loadFriends = async () => {
    const response = await fetch(`${API_BASE}/friends`); // 친구 목록 요청이다.
    const text = await response.text(); // 문자열 응답을 꺼낸다.
    setFriends(text); // 친구 목록 state에 저장한다.
  };

  useEffect(() => {
    loadBoards(); // 처음 화면이 열리면 전체 목록을 불러온다.
  }, []);

  return (
    <>
      <div>
        <section>
          <h2>1. 전체 목록</h2>
          <button onClick={loadBoards}>전체 목록 다시 조회</button>

          <table>
            <tbody>
              {boards.map((b) => (
                <tr key={b.boardNo}>
                  <td>{b.boardNo}</td>
                  <td>{b.title}</td>
                  <td>{b.content}</td>
                  <td>{b.writer}</td>
                  <td>{formatDate(b.regDate)}</td>
                  <td>
                    <button onClick={() => deleteBoard(b.boardNo)}>삭제</button>
                  </td>
                </tr>
              ))}
            </tbody>
          </table>
        </section>

        <section>
          <h2>2. 상세 조회</h2>
          <input
            type="number"
            value={readBoardNo}
            onChange={(e) => setReadBoardNo(e.target.value)}
          />
          <button onClick={readBoard}>조회</button>

          {selectedBoard && (
            <div>
              <p>번호: {selectedBoard.boardNo}</p>
              <p>제목: {selectedBoard.title}</p>
              <p>내용: {selectedBoard.content}</p>
              <p>작성자: {selectedBoard.writer}</p>
              <p>등록일: {formatDate(selectedBoard.regDate)}</p>
            </div>
          )}
        </section>

        <section>
          <h2>3. 등록 / 수정</h2>

          <input name="boardNo" value={form.boardNo} onChange={handleChange} placeholder="글번호" />
          <input name="title" value={form.title} onChange={handleChange} placeholder="제목" />
          <input name="content" value={form.content} onChange={handleChange} placeholder="내용" />
          <input name="writer" value={form.writer} onChange={handleChange} placeholder="작성자" />

          <button onClick={registerBoard}>등록</button>
          <button onClick={modifyBoard}>수정</button>
          <button onClick={resetForm}>입력 초기화</button>
        </section>

        <section>
          <h2>4. 기타 API 테스트</h2>

          <button onClick={loadDay}>오늘 요일 조회</button>
          <button onClick={loadFriends}>친구 목록 조회</button>

          <p>오늘 요일: {day || "아직 조회 안 함"}</p>
          <p>친구 목록: {friends || "아직 조회 안 함"}</p>
        </section>

        <section>
          <h2>응답 메시지</h2>
          <p>{message || "아직 메시지 없음"}</p>
        </section>
      </div>
    </>
  );
}

이 코드는 실제 파일에 들어가는 핵심 코드다.
이제 전체 코드를 실행 흐름에 맞춰 기능별로 나누어 본다.
각 단계에서는 먼저 실제 코드를 확인하고, 바로 아래에서 그 코드가 실행될 때 실제 값이 어떻게 들어오고 바뀌는지 함께 본다.


1단계: 처음 state 값 확인하기

처음 화면이 열리면 FetchExam3 컴포넌트 안에서 여러 state가 준비된다.

// EduApp16.jsx
// [실제 코드] 처음 state 준비
const [boards, setBoards] = useState([]);
const [message, setMessage] = useState("");
const [readBoardNo, setReadBoardNo] = useState(1);
const [selectedBoard, setSelectedBoard] = useState(null);
const [day, setDay] = useState("");
const [friends, setFriends] = useState("");

const [form, setForm] = useState({
  boardNo: "",
  title: "",
  content: "",
  writer: "",
});

각 state는 화면의 서로 다른 영역을 담당한다.
boards state는 전체 목록 테이블을 담당한다.
selectedBoard state는 상세 조회 결과 영역을 담당한다.
form state는 등록과 수정 입력칸을 담당한다.
message state는 등록, 수정, 삭제 응답 메시지를 담당한다.
day state와 friends state는 기타 API 결과 영역을 담당한다.


처음 값은 아래처럼 시작한다.

// [state 값 예시] 처음 렌더링 시점의 state 값
// boards state 값: []
// message state 값: ""
// readBoardNo state 값: 1
// selectedBoard state 값: null
// day state 값: ""
// friends state 값: ""
// form state 값:
// {
//   boardNo: "",
//   title: "",
//   content: "",
//   writer: ""
// }

처음에는 아직 상세 조회를 하지 않았으므로 selectedBoard state는 null이다.
아직 요일과 친구 목록도 조회하지 않았으므로 day state, friends state는 빈 문자열이다.


화면에서는 아래 문구들이 기본값으로 보인다.

// [화면 출력 결과] 처음 화면 일부
// 오늘 요일: 아직 조회 안 함
// 친구 목록: 아직 조회 안 함
// 응답 메시지: 아직 메시지 없음



2단계: 처음 렌더링 후 전체 목록 조회하기

처음 화면이 나타나면 useEffect()가 실행된다.

// EduApp16.jsx
// [실제 코드] 처음 화면이 열렸을 때 전체 목록 조회
useEffect(() => {
  loadBoards();
}, []);

useEffect()는 화면이 처음 렌더링된 뒤 실행된다.
의존성 배열이 빈 배열 []이므로 처음 한 번만 loadBoards()를 호출한다.


loadBoards()는 전체 게시글 목록을 조회하는 함수다.

// EduApp16.jsx
// [실제 코드] 전체 목록 조회 함수
const loadBoards = async () => {
  const response = await fetch(API_BASE);
  const data = await response.json();
  setBoards(data);
};

fetch(API_BASE)는 서버의 전체 게시글 목록을 요청한다.
API_BASE의 실제 값은 아래와 같다.

// EduApp16.jsx
// [실제 코드] 게시글 API 기본 주소
const API_BASE = "http://localhost:9000/boards";

따라서 실제 요청은 아래처럼 나간다.

// [이해용 예시] 실제 요청 주소
fetch("http://localhost:9000/boards");

이 요청은 별도로 method를 적지 않았기 때문에 기본값인 GET 요청으로 실행된다.
즉 GET /boards 요청이다.


서버가 성공 응답을 보내면 먼저 response 객체가 도착한다.
이 값은 게시글 배열 자체가 아니라 응답 상태와 본문을 꺼내는 기능을 가진 객체다.

// [이해용 예시] 실제 작성 코드가 아니라 성공 응답 response 객체를 단순화한 값
response = {
  status: 200,
  ok: true,
  json: async function () {
    return [
      {
        boardNo: 1,
        title: "아기공룡 둘리 한자대탐험",
        content: "둘리 학습만화 시리즈",
        writer: "김수정",
        regDate: "2026-05-21T12:45:00"
      },
      {
        boardNo: 2,
        title: "고래 도서관",
        content: "바다 도서관 이야기",
        writer: "지도루",
        regDate: "2026-05-21T12:45:00"
      }
    ];
  }
};

실제 게시글 배열은 response.json()을 실행해야 꺼낼 수 있다.

// EduApp16.jsx
// [실제 코드] 응답 본문을 JavaScript 배열로 변환
const data = await response.json();

response.json() 실행 후 data 값은 아래처럼 된다.

// [이해용 예시] response.json() 실행 후 data 값
data = [
  {
    boardNo: 1,
    title: "아기공룡 둘리 한자대탐험",
    content: "둘리 학습만화 시리즈",
    writer: "김수정",
    regDate: "2026-05-21T12:45:00"
  },
  {
    boardNo: 2,
    title: "고래 도서관",
    content: "바다 도서관 이야기",
    writer: "지도루",
    regDate: "2026-05-21T12:45:00"
  }
];

이 data는 아직 화면에 직접 출력되는 값이 아니다.
화면에 반영하려면 boards state에 저장해야 한다.

// EduApp16.jsx
// [실제 코드] 전체 목록 배열을 boards state에 저장
setBoards(data);

실행 전 boards state 값은 아래와 같다.

// [state 값 예시] setBoards(data) 실행 전
// boards state 값: []

실행 후 boards state 값은 아래처럼 바뀐다.

// [state 값 예시] setBoards(data) 실행 후
// boards state 값:
// [
//   {
//     boardNo: 1,
//     title: "아기공룡 둘리 한자대탐험",
//     content: "둘리 학습만화 시리즈",
//     writer: "김수정",
//     regDate: "2026-05-21T12:45:00"
//   },
//   {
//     boardNo: 2,
//     title: "고래 도서관",
//     content: "바다 도서관 이야기",
//     writer: "지도루",
//     regDate: "2026-05-21T12:45:00"
//   }
// ]

boards state가 바뀌면 컴포넌트가 다시 렌더링되고, 전체 목록 테이블이 다시 계산된다.


3단계: boards.map으로 전체 목록 테이블 출력하기

전체 목록 화면은 boards state 배열을 기준으로 테이블을 만든다.

// EduApp16.jsx
// [실제 코드] boards state를 테이블 행으로 변환
{boards.map((b) => (
  <tr key={b.boardNo}>
    <td>{b.boardNo}</td>
    <td>{b.title}</td>
    <td>{b.content}</td>
    <td>{b.writer}</td>
    <td>{formatDate(b.regDate)}</td>
    <td>
      <button onClick={() => deleteBoard(b.boardNo)}>삭제</button>
    </td>
  </tr>
))}

boards.map()은 boards state 배열에서 게시글 객체를 하나씩 꺼낸다.
꺼낸 게시글 객체 하나를 b라고 부르고, 그 b를 테이블 한 줄로 바꾼다.


첫 번째 반복에서 b는 아래 값이다.

// [이해용 예시] boards.map() 첫 번째 반복에서 b에 들어오는 값
b = {
  boardNo: 1,
  title: "아기공룡 둘리 한자대탐험",
  content: "둘리 학습만화 시리즈",
  writer: "김수정",
  regDate: "2026-05-21T12:45:00"
};

첫 번째 테이블 행에 들어가는 실제 값은 아래와 같다.

// [화면 출력값 예시] 첫 번째 테이블 행
// b.boardNo → 1
// b.title → "아기공룡 둘리 한자대탐험"
// b.content → "둘리 학습만화 시리즈"
// b.writer → "김수정"
// formatDate(b.regDate) → "2026년 05월 21일 12시 45분"
// 삭제 버튼 → deleteBoard(1) 실행 준비

두 번째 반복에서 b는 아래 값이다.

// [이해용 예시] boards.map() 두 번째 반복에서 b에 들어오는 값
b = {
  boardNo: 2,
  title: "고래 도서관",
  content: "바다 도서관 이야기",
  writer: "지도루",
  regDate: "2026-05-21T12:45:00"
};

두 번째 테이블 행에 들어가는 실제 값은 아래와 같다.

// [화면 출력값 예시] 두 번째 테이블 행
// b.boardNo → 2
// b.title → "고래 도서관"
// b.content → "바다 도서관 이야기"
// b.writer → "지도루"
// formatDate(b.regDate) → "2026년 05월 21일 12시 45분"
// 삭제 버튼 → deleteBoard(2) 실행 준비

전체 목록 조회 흐름은 아래처럼 정리할 수 있다.

// [데이터 흐름 정리] 전체 목록 조회 흐름
// useEffect()
// → loadBoards()
// → fetch(API_BASE)
// → GET /boards 요청
// → response 객체 도착
// → response.json()
// → data 배열 생성
// → setBoards(data)
// → boards state에 전체 목록 저장
// → boards.map() 실행
// → 테이블 행 출력



4단계: 상세 조회 번호 입력하기

상세 조회 영역에는 조회할 게시글 번호를 입력하는 input이 있다.

// EduApp16.jsx
// [실제 코드] 상세 조회 번호 입력
<input
  type="number"
  value={readBoardNo}
  onChange={(e) => setReadBoardNo(e.target.value)}
/>

value={readBoardNo}는 입력칸에 readBoardNo state 값을 보여준다.
처음값은 1이다.


사용자가 입력칸에 2를 입력하면 onChange가 실행된다.

// EduApp16.jsx
// [실제 코드] 상세 조회 번호 state 변경
setReadBoardNo(e.target.value);

입력값은 문자열로 들어온다.
따라서 사용자가 2를 입력하면 readBoardNo state 값은 아래처럼 바뀐다.

// [state 값 예시] 상세 조회 번호 입력 후
// 변경 전 readBoardNo state 값: 1
// 변경 후 readBoardNo state 값: "2"

이 값은 나중에 상세 조회 요청 주소를 만들 때 사용된다.


5단계: 상세 조회 버튼을 눌러 특정 게시글 가져오기

상세 조회 영역에서 조회 버튼을 누르면 readBoard()가 실행된다.

// EduApp16.jsx
// [실제 코드] 상세 조회 버튼
<button onClick={readBoard}>조회</button>

readBoard()의 실제 코드는 아래와 같다.

// EduApp16.jsx
// [실제 코드] 상세 조회 함수
const readBoard = async () => {
  const response = await fetch(`${API_BASE}/${readBoardNo}`);
  const data = await response.json();

  setSelectedBoard(data);
  setForm({
    boardNo: data.boardNo ?? "",
    title: data.title ?? "",
    content: data.content ?? "",
    writer: data.writer ?? "",
  });
};

현재 readBoardNo state 값이 "2"라면 요청 주소는 아래처럼 만들어진다.

// [이해용 예시] 상세 조회 실제 요청 주소
fetch("http://localhost:9000/boards/2");

즉 GET /boards/2 요청이다.
서버가 2번 게시글을 응답하면 response.json()으로 객체를 꺼낸다.


예를 들어 data 값은 아래처럼 된다.

// [이해용 예시] 상세 조회 후 data 값
data = {
  boardNo: 2,
  title: "고래 도서관",
  content: "바다 도서관 이야기",
  writer: "지도루",
  regDate: "2026-05-21T12:45:00"
};

이 객체는 두 곳에 사용된다.
먼저 상세 조회 결과로 저장된다.

// EduApp16.jsx
// [실제 코드] 상세 조회 결과 저장
setSelectedBoard(data);

실행 후 selectedBoard state 값은 아래처럼 바뀐다.

// [state 값 예시] 상세 조회 후 selectedBoard state 값
// selectedBoard state 값:
// {
//   boardNo: 2,
//   title: "고래 도서관",
//   content: "바다 도서관 이야기",
//   writer: "지도루",
//   regDate: "2026-05-21T12:45:00"
// }

그 다음 같은 data를 수정 입력칸에도 채운다.

// EduApp16.jsx
// [실제 코드] 상세 조회 결과를 form state에 반영
setForm({
  boardNo: data.boardNo ?? "",
  title: data.title ?? "",
  content: data.content ?? "",
  writer: data.writer ?? "",
});

실행 후 form state 값은 아래처럼 바뀐다.

// [state 값 예시] 상세 조회 후 form state 값
// form state 값:
// {
//   boardNo: 2,
//   title: "고래 도서관",
//   content: "바다 도서관 이야기",
//   writer: "지도루"
// }

상세 조회 결과를 form state에도 넣는 이유는 수정할 때 기존 값을 입력칸에서 바로 고칠 수 있게 하기 위해서다.


상세 조회 결과는 아래 코드로 화면에 출력된다.

// EduApp16.jsx
// [실제 코드] 상세 조회 결과 출력
{selectedBoard && (
  <div>
    <p>번호: {selectedBoard.boardNo}</p>
    <p>제목: {selectedBoard.title}</p>
    <p>내용: {selectedBoard.content}</p>
    <p>작성자: {selectedBoard.writer}</p>
    <p>등록일: {formatDate(selectedBoard.regDate)}</p>
  </div>
)}

selectedBoard state가 null이면 상세 박스는 보이지 않는다.
상세 조회 후 객체가 들어오면 조건이 참이 되어 상세 박스가 출력된다.

// [화면 출력 결과] 상세 조회 후 화면
// 번호: 2
// 제목: 고래 도서관
// 내용: 바다 도서관 이야기
// 작성자: 지도루
// 등록일: 2026년 05월 21일 12시 45분

상세 조회 흐름은 아래처럼 정리된다.

// [데이터 흐름 정리] 상세 조회 흐름
// readBoardNo state 값 입력
// → 조회 버튼 클릭
// → readBoard()
// → fetch(`${API_BASE}/${readBoardNo}`)
// → GET /boards/2 요청
// → response 객체 도착
// → response.json()
// → data 객체 생성
// → setSelectedBoard(data)
// → selectedBoard state에 상세 결과 저장
// → setForm(data의 일부 값)
// → form state에 수정용 값 채우기
// → 상세 조회 박스 출력



6단계: 등록/수정 입력값을 form state로 관리하기

등록과 수정 영역에는 글번호, 제목, 내용, 작성자 입력칸이 있다.
이 입력값들은 각각 따로 state를 만들지 않고 하나의 form state 객체로 관리한다.

// EduApp16.jsx
// [실제 코드] 등록/수정 입력값 state
const [form, setForm] = useState({
  boardNo: "",
  title: "",
  content: "",
  writer: "",
});

입력칸이 여러 개이므로 객체 형태로 관리한다.
사용자가 입력칸에 값을 입력하면 handleChange()가 실행된다.

// EduApp16.jsx
// [실제 코드] 입력값 변경 함수
const handleChange = (e) => {
  const { name, value } = e.target;

  setForm({
    ...form, [name]: value,
  });
};

name은 어떤 입력칸인지 알려주는 이름이다.
value는 사용자가 입력한 값이다.
[name]: value는 바뀐 입력칸만 골라 form state에 반영한다.


예를 들어 사용자가 글번호 입력칸에 3을 입력했다고 보면 된다.

// [입력 이벤트 예시] 글번호 입력칸 변경
// e.target.name 값: "boardNo"
// e.target.value 값: "3"

이때 setForm()은 아래처럼 계산된다.

// [이해용 예시] form state 변경 계산
setForm({
  ...form,
  boardNo: "3",
});

그 다음 제목, 내용, 작성자를 입력하면 최종 form state는 아래처럼 된다.

// [state 값 예시] 등록 입력 완료 후 form state 값
// form state 값:
// {
//   boardNo: "3",
//   title: "바다 이야기",
//   content: "바다 속 내용",
//   writer: "이언덕"
// }

여기서 중요한 점은 입력값은 기본적으로 문자열이라는 점이다.
그래서 나중에 서버로 보낼 때 boardNo는 Number(form.boardNo)로 숫자 변환한다.


7단계: 등록 버튼을 눌러 POST 요청 보내기

등록 입력값이 준비된 상태에서 등록 버튼을 누르면 registerBoard()가 실행된다.

// EduApp16.jsx
// [실제 코드] 등록 버튼
<button onClick={registerBoard}>등록</button>

먼저 form state를 바탕으로 등록할 게시글 객체를 만든다.

// EduApp16.jsx
// [실제 코드] 등록할 게시글 객체 생성
const newBoard = {
  boardNo: Number(form.boardNo),
  title: form.title,
  content: form.content,
  writer: form.writer,
  regDate: getNowDateTime(),
};

현재 form state 값이 아래와 같다면,

// [state 값 예시] 등록 직전 form state 값
// form state 값:
// {
//   boardNo: "3",
//   title: "바다 이야기",
//   content: "바다 속 내용",
//   writer: "이언덕"
// }

newBoard는 아래처럼 만들어진다.

// [데이터 생성 예시] 등록할 newBoard 객체
// newBoard 값:
// {
//   boardNo: 3,
//   title: "바다 이야기",
//   content: "바다 속 내용",
//   writer: "이언덕",
//   regDate: "현재 날짜 시간"
// }

form.boardNo는 문자열 "3"이다.
서버에는 게시글 번호를 숫자로 보내야 하므로 Number(form.boardNo)로 3을 만든다.


regDate에는 getNowDateTime()의 실행 결과가 들어간다.
이 값은 등록 요청을 보내는 시점의 날짜와 시간을 서버에 전달하기 위해 사용한다.


그 다음 POST 요청을 보낸다.

// EduApp16.jsx
// [실제 코드] 등록 요청
const response = await fetch(API_BASE, {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify(newBoard),
});

method: "POST"는 새 데이터를 등록하겠다는 뜻이다.
Content-Type: application/json은 요청 본문이 JSON 형식이라는 뜻이다.
JSON.stringify(newBoard)는 JavaScript 객체를 서버가 받을 수 있는 JSON 문자열로 바꾼다.


실제 요청 흐름은 아래와 같다.

// [데이터 흐름 정리] 등록 요청 본문 생성
// form state
// → newBoard 객체 생성
// → JSON.stringify(newBoard)
// → JSON 문자열 생성
// → POST /boards 요청 본문으로 전송

요청이 끝나면 서버 응답 문자열을 꺼낸다.

// EduApp16.jsx
// [실제 코드] 등록 응답 처리
const text = await response.text();
setMessage(text);
resetForm();
loadBoards();

등록 응답은 게시글 배열이나 객체가 아니라 문자열이다.
그래서 response.json()이 아니라 response.text()를 사용한다.


서버가 "성공적으로 삽입했어요"를 응답했다고 보면 text 값은 아래처럼 된다.

// [응답 값 예시] 등록 응답 문자열
// text 값: "성공적으로 삽입했어요"

이 문자열은 message state에 저장된다.

// [state 값 예시] 등록 후 message state 값
// message state 값: "성공적으로 삽입했어요"

그 다음 resetForm()이 실행되어 입력칸이 비워진다.

// [state 값 예시] resetForm() 실행 후 form state 값
// form state 값:
// {
//   boardNo: "",
//   title: "",
//   content: "",
//   writer: ""
// }

마지막으로 loadBoards()를 다시 실행한다.
등록 후 전체 목록을 다시 불러와야 새 게시글이 화면 목록에 반영되기 때문이다.


등록 흐름은 아래처럼 정리된다.

// [데이터 흐름 정리] 등록 흐름
// form state에 입력값 저장
// → 등록 버튼 클릭
// → registerBoard()
// → newBoard 객체 생성
// → JSON.stringify(newBoard)
// → POST /boards 요청
// → response.text()
// → text 문자열 생성
// → setMessage(text)
// → message state에 성공 문구 저장
// → resetForm()
// → form state 초기화
// → loadBoards()
// → GET /boards 재요청
// → boards state 최신 목록으로 변경
// → 테이블에 새 게시글 반영



8단계: 수정 버튼을 눌러 PUT 요청 보내기

수정은 등록과 비슷하지만 요청 방식과 요청 주소가 다르다.
수정은 새 데이터를 만드는 것이 아니라, 이미 있는 게시글 번호에 해당하는 데이터를 바꾸는 흐름이다.


수정 버튼을 누르면 modifyBoard()가 실행된다.

// EduApp16.jsx
// [실제 코드] 수정 버튼
<button onClick={modifyBoard}>수정</button>

먼저 form state를 바탕으로 수정할 게시글 객체를 만든다.

// EduApp16.jsx
// [실제 코드] 수정할 게시글 객체 생성
const updateBoard = {
  boardNo: Number(form.boardNo),
  title: form.title,
  content: form.content,
  writer: form.writer,
  regDate: getNowDateTime(),
};

예를 들어 상세 조회로 3번 게시글을 불러온 뒤 제목과 내용을 바꿨다고 보면 된다.

// [state 값 예시] 수정 직전 form state 값
// form state 값:
// {
//   boardNo: "3",
//   title: "산 이야기",
//   content: "산 속 내용",
//   writer: "이언덕"
// }

그러면 updateBoard는 아래처럼 만들어진다.

// [데이터 생성 예시] 수정할 updateBoard 객체
// updateBoard 값:
// {
//   boardNo: 3,
//   title: "산 이야기",
//   content: "산 속 내용",
//   writer: "이언덕",
//   regDate: "현재 날짜 시간"
// }

수정할 때도 regDate에는 getNowDateTime()의 실행 결과가 들어간다.
현재 코드 기준에서는 수정 요청을 보내는 시점의 날짜와 시간이 함께 전달된다.


그 다음 PUT 요청을 보낸다.

// EduApp16.jsx
// [실제 코드] 수정 요청
const response = await fetch(`${API_BASE}/${form.boardNo}`, {
  method: "PUT",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify(updateBoard),
});

현재 form.boardNo 값이 "3"이면 실제 요청 주소는 아래와 같다.

// [이해용 예시] 수정 요청 주소
fetch("http://localhost:9000/boards/3", {
  method: "PUT",
  body: JSON.stringify(updateBoard),
});

즉 PUT /boards/3 요청이다.
요청 주소의 3은 어떤 게시글을 수정할지 알려주고, 요청 본문의 updateBoard는 어떤 값으로 수정할지 알려준다.


수정이 끝나면 서버 응답 문자열을 꺼낸다.

// EduApp16.jsx
// [실제 코드] 수정 응답 처리
const text = await response.text();
setMessage(text);
resetForm();
setSelectedBoard(null);
loadBoards();

서버가 "성공적으로 수정했어요"를 응답하면 text 값은 아래처럼 된다.

// [응답 값 예시] 수정 응답 문자열
// text 값: "성공적으로 수정했어요"

이 문자열은 message state에 저장된다.
resetForm()은 입력칸을 비운다.
setSelectedBoard(null)은 상세 조회 결과 박스를 지운다.
loadBoards()는 전체 목록을 다시 조회해서 수정된 내용을 테이블에 반영한다.


수정 흐름은 아래처럼 정리된다.

// [데이터 흐름 정리] 수정 흐름
// 상세 조회 또는 직접 입력으로 form state 준비
// → 수정 버튼 클릭
// → modifyBoard()
// → updateBoard 객체 생성
// → PUT /boards/{boardNo} 요청
// → response.text()
// → text 문자열 생성
// → setMessage(text)
// → message state에 수정 성공 문구 저장
// → resetForm()
// → form state 초기화
// → setSelectedBoard(null)
// → 상세 조회 결과 숨김
// → loadBoards()
// → GET /boards 재요청
// → boards state 최신 목록으로 변경
// → 테이블에 수정 결과 반영



9단계: 삭제 버튼을 눌러 DELETE 요청 보내기

전체 목록 테이블에는 각 게시글마다 삭제 버튼이 있다.

// EduApp16.jsx
// [실제 코드] 삭제 버튼
<button onClick={() => deleteBoard(b.boardNo)}>삭제</button>

예를 들어 3번 게시글 행의 삭제 버튼을 누르면 deleteBoard(3)이 실행된다.


삭제 함수는 아래와 같다.

// EduApp16.jsx
// [실제 코드] 삭제 함수
const deleteBoard = async (boardNo) => {
  const response = await fetch(`${API_BASE}/${boardNo}`, {
    method: "DELETE",
  });

  const text = await response.text();
  setMessage(text);
  loadBoards();
};

boardNo 값이 3이면 실제 요청 주소는 아래와 같다.

// [이해용 예시] 삭제 요청 주소
fetch("http://localhost:9000/boards/3", {
  method: "DELETE",
});

즉 DELETE /boards/3 요청이다.
요청 주소의 3은 삭제할 게시글 번호다.


삭제 응답도 문자열로 받는다.
서버가 "성공적으로 삭제했어요"를 응답하면 text 값은 아래처럼 된다.

// [응답 값 예시] 삭제 응답 문자열
// text 값: "성공적으로 삭제했어요"

이 문자열은 message state에 저장된다.
그 다음 loadBoards()가 다시 실행된다.
삭제 후 전체 목록을 다시 조회해야 삭제된 게시글이 테이블에서 사라진다.


삭제 전 boards state 값이 아래와 같았다고 보면 된다.

// [state 값 예시] 삭제 전 boards state 값
// boards state 값:
// [
//   { boardNo: 1, title: "아기공룡 둘리 한자대탐험", ... },
//   { boardNo: 2, title: "고래 도서관", ... },
//   { boardNo: 3, title: "산 이야기", ... }
// ]

삭제 후 loadBoards()가 다시 실행되면 서버에서 최신 목록을 받아온다.

// [state 값 예시] 삭제 후 boards state 값
// boards state 값:
// [
//   { boardNo: 1, title: "아기공룡 둘리 한자대탐험", ... },
//   { boardNo: 2, title: "고래 도서관", ... }
// ]

삭제 흐름은 아래처럼 정리된다.

// [데이터 흐름 정리] 삭제 흐름
// 전체 목록 테이블에서 삭제 버튼 클릭
// → deleteBoard(boardNo)
// → DELETE /boards/{boardNo} 요청
// → response.text()
// → text 문자열 생성
// → setMessage(text)
// → message state에 삭제 성공 문구 저장
// → loadBoards()
// → GET /boards 재요청
// → boards state 최신 목록으로 변경
// → 삭제된 게시글이 테이블에서 사라짐



10단계: 오늘 요일 조회하기

기타 API 테스트 영역에는 오늘 요일 조회 버튼이 있다.

// EduApp16.jsx
// [실제 코드] 오늘 요일 조회 버튼
<button onClick={loadDay}>오늘 요일 조회</button>

버튼을 누르면 loadDay()가 실행된다.

// EduApp16.jsx
// [실제 코드] 오늘 요일 조회 함수
const loadDay = async () => {
  const response = await fetch(`${API_BASE}/day`);
  const text = await response.text();
  setDay(text);
};

실제 요청 주소는 아래와 같다.

// [이해용 예시] 오늘 요일 요청 주소
fetch("http://localhost:9000/boards/day");

즉 GET /boards/day 요청이다.
이 응답은 객체나 배열이 아니라 문자열이다.
그래서 response.json()이 아니라 response.text()로 꺼낸다.


서버가 "목"을 응답했다고 보면 text 값은 아래처럼 된다.

// [응답 값 예시] 오늘 요일 응답 문자열
// text 값: "목"

이 값은 day state에 저장된다.

// [state 값 예시] 오늘 요일 조회 후 day state 값
// day state 값: "목"

화면 출력 코드는 아래와 같다.

// EduApp16.jsx
// [실제 코드] 오늘 요일 화면 출력
<p>오늘 요일: {day || "아직 조회 안 함"}</p>

처음에는 day state 값이 빈 문자열이므로 아직 조회 안 함이 보인다.
조회 후에는 day state 값이 "목"이므로 화면에는 아래처럼 보인다.

// [화면 출력 결과] 오늘 요일 조회 후
// 오늘 요일: 목

오늘 요일 조회 흐름은 아래처럼 정리된다.

// [데이터 흐름 정리] 오늘 요일 조회 흐름
// 오늘 요일 조회 버튼 클릭
// → loadDay()
// → GET /boards/day 요청
// → response.text()
// → text 문자열 생성
// → setDay(text)
// → day state에 "목" 저장
// → 오늘 요일 화면 값 변경



11단계: 친구 목록 조회하기

기타 API 테스트 영역에는 친구 목록 조회 버튼도 있다.

// EduApp16.jsx
// [실제 코드] 친구 목록 조회 버튼
<button onClick={loadFriends}>친구 목록 조회</button>

버튼을 누르면 loadFriends()가 실행된다.

// EduApp16.jsx
// [실제 코드] 친구 목록 조회 함수
const loadFriends = async () => {
  const response = await fetch(`${API_BASE}/friends`);
  const text = await response.text();
  setFriends(text);
};

실제 요청 주소는 아래와 같다.

// [이해용 예시] 친구 목록 요청 주소
fetch("http://localhost:9000/boards/friends");

즉 GET /boards/friends 요청이다.
이 응답도 문자열이므로 response.text()로 꺼낸다.


서버가 "둘리 또치 도우너"를 응답했다고 보면 text 값은 아래처럼 된다.

// [응답 값 예시] 친구 목록 응답 문자열
// text 값: "둘리 또치 도우너"

이 값은 friends state에 저장된다.

// [state 값 예시] 친구 목록 조회 후 friends state 값
// friends state 값: "둘리 또치 도우너"

화면 출력 코드는 아래와 같다.

// EduApp16.jsx
// [실제 코드] 친구 목록 화면 출력
<p>친구 목록: {friends || "아직 조회 안 함"}</p>

처음에는 friends state 값이 빈 문자열이므로 아직 조회 안 함이 보인다.
조회 후에는 friends state 값이 "둘리 또치 도우너"이므로 화면에는 아래처럼 보인다.

// [화면 출력 결과] 친구 목록 조회 후
// 친구 목록: 둘리 또치 도우너

친구 목록 조회 흐름은 아래처럼 정리된다.

// [데이터 흐름 정리] 친구 목록 조회 흐름
// 친구 목록 조회 버튼 클릭
// → loadFriends()
// → GET /boards/friends 요청
// → response.text()
// → text 문자열 생성
// → setFriends(text)
// → friends state에 "둘리 또치 도우너" 저장
// → 친구 목록 화면 값 변경



12단계: 응답 메시지와 기타 API 결과를 구분하기

등록, 수정, 삭제는 message state를 바꾼다.
하지만 오늘 요일 조회와 친구 목록 조회는 message state를 바꾸지 않는다.


등록, 수정, 삭제 응답 처리는 아래처럼 setMessage(text)를 사용한다.

// EduApp16.jsx
// [실제 코드] 등록/수정/삭제 응답 메시지 저장
setMessage(text);

반면 오늘 요일 조회는 setDay(text)를 사용한다.

// EduApp16.jsx
// [실제 코드] 오늘 요일 결과 저장
setDay(text);

친구 목록 조회는 setFriends(text)를 사용한다.

// EduApp16.jsx
// [실제 코드] 친구 목록 결과 저장
setFriends(text);

그래서 기타 API 버튼을 눌러도 message state는 바뀌지 않는다.
기존 message state 값이 없으면 화면에는 아직 메시지 없음이 보인다.
하지만 등록, 수정, 삭제 후 이미 메시지가 들어 있었다면 기타 API 버튼을 눌러도 그 메시지가 그대로 유지된다.
각 결과가 저장되는 state가 다르기 때문이다.


정리하면 아래와 같다.

// [state 저장 위치 정리]
// 등록 성공 문자열 → message state
// 수정 성공 문자열 → message state
// 삭제 성공 문자열 → message state
// 오늘 요일 문자열 → day state
// 친구 목록 문자열 → friends state

같은 문자열 응답이라도 화면의 어느 영역에 보여줄지에 따라 저장하는 state가 달라진다.


9-4. 화면 상태 요약

처음 화면

처음 화면에서는 useEffect()가 실행되면서 전체 목록 조회가 시작된다.
조회가 끝나면 boards state에 게시글 목록이 저장되고, 전체 목록 테이블이 출력된다.


처음 화면은 아래 상태로 이해하면 된다.

// [화면 출력 결과] 처음 화면 상태
// boards state 값: 서버에서 받은 전체 게시글 목록
// message state 값: ""
// readBoardNo state 값: 1
// selectedBoard state 값: null
// day state 값: ""
// friends state 값: ""
// form state 값:
// {
//   boardNo: "",
//   title: "",
//   content: "",
//   writer: ""
// }
// 전체 목록 테이블 출력
// 상세 조회 박스 출력 안 됨
// 오늘 요일: 아직 조회 안 함
// 친구 목록: 아직 조회 안 함
// 응답 메시지: 아직 메시지 없음



상세 조회 화면

상세 조회 번호를 입력하고 조회 버튼을 누르면 해당 번호의 게시글을 조회한다.
조회 결과는 selectedBoard state에 저장되고, 동시에 form state에도 들어간다.


상세 조회 화면은 아래 상태로 이해하면 된다.

// [화면 출력 결과] 상세 조회 후 상태
// readBoardNo state 값: "2"
// selectedBoard state 값: 2번 게시글 객체
// form state 값: 2번 게시글의 boardNo, title, content, writer
// 상세 조회 박스 출력
// 수정 입력칸에 기존 게시글 값 채워짐



등록 화면

등록/수정 영역에 글번호, 제목, 내용, 작성자를 입력하고 등록 버튼을 누르면 POST 요청이 나간다.
서버는 새 게시글을 저장하고 문자열 응답을 돌려준다.


등록이 끝나면 응답 메시지가 표시되고, 입력칸은 초기화된다.
그 다음 전체 목록을 다시 불러오므로 새 게시글이 테이블에 추가된다.

// [화면 출력 결과] 등록 후 상태
// message state 값: "성공적으로 삽입했어요"
// form state 값: 빈 입력값
// boards state 값: 새 게시글이 포함된 최신 목록
// 응답 메시지 출력
// 전체 목록 테이블 갱신



수정 화면

수정할 게시글 값을 입력하고 수정 버튼을 누르면 PUT 요청이 나간다.
요청 주소에는 수정할 게시글 번호가 들어간다.


수정이 끝나면 응답 메시지가 표시되고, 입력칸과 상세 조회 결과가 초기화된다.
그 다음 전체 목록을 다시 불러오므로 수정된 내용이 테이블에 반영된다.

// [화면 출력 결과] 수정 후 상태
// message state 값: "성공적으로 수정했어요"
// form state 값: 빈 입력값
// selectedBoard state 값: null
// boards state 값: 수정 결과가 반영된 최신 목록
// 상세 조회 박스 사라짐
// 전체 목록 테이블 갱신



삭제 화면

전체 목록에서 특정 행의 삭제 버튼을 누르면 DELETE 요청이 나간다.
요청 주소에는 삭제할 게시글 번호가 들어간다.


삭제가 끝나면 응답 메시지가 표시되고, 전체 목록을 다시 조회한다.
그래서 삭제된 게시글은 테이블에서 사라진다.

// [화면 출력 결과] 삭제 후 상태
// message state 값: "성공적으로 삭제했어요"
// boards state 값: 삭제된 게시글이 빠진 최신 목록
// 응답 메시지 출력
// 전체 목록 테이블 갱신



기타 API 화면

오늘 요일 조회 버튼을 누르면 GET /boards/day 요청이 나간다.
응답으로 받은 문자열이 day state에 저장되고, 화면의 오늘 요일 값이 바뀐다.


친구 목록 조회 버튼을 누르면 GET /boards/friends 요청이 나간다.
응답으로 받은 문자열이 friends state에 저장되고, 화면의 친구 목록 값이 바뀐다.

// [화면 출력 결과] 기타 API 조회 후 상태
// day state 값: "목"
// friends state 값: "둘리 또치 도우너"
// 오늘 요일: 목
// 친구 목록: 둘리 또치 도우너
// message state 값: 기존 값 그대로 유지

기타 API 조회는 message state를 변경하지 않는다.
따라서 기존 메시지가 없었다면 아직 메시지 없음이 그대로 보이고, 등록/수정/삭제 메시지가 이미 있었다면 그 메시지가 그대로 남는다.


9-5. 결과물

상세 조회 결과

상세 조회 입력칸에 2를 입력하고 조회 버튼을 누르면 GET /boards/2 요청이 실행된다.
서버에서 받은 2번 게시글 객체는 selectedBoard state에 저장되어 상세 조회 영역에 출력된다.
동시에 같은 값이 form state에도 들어가므로, 등록/수정 입력칸에는 2번 게시글의 글번호, 제목, 내용, 작성자가 채워진다.
영상에서는 제목 고래 도서관, 내용 바다 도서관 이야기, 작성자 지도루가 출력된다.


등록 결과

글번호 3, 제목 바다 이야기, 내용 바다 속 내용, 작성자 이언덕을 입력하고 등록 버튼을 누르면 POST /boards 요청이 실행된다.
입력값은 newBoard 객체로 만들어지고, JSON.stringify(newBoard)를 통해 서버로 전송된다.
등록이 성공하면 응답 메시지에 성공적으로 삽입했어요가 표시된다.
그 다음 loadBoards()가 다시 실행되어 전체 목록에 3번 게시글이 추가된다.


수정 결과

3번 게시글을 조회한 뒤 제목을 산 이야기, 내용을 산 속 내용으로 바꾸고 수정 버튼을 누르면 PUT /boards/3 요청이 실행된다.
입력값은 updateBoard 객체로 만들어지고, 요청 주소에는 수정할 게시글 번호 3이 들어간다.
수정이 성공하면 응답 메시지에 성공적으로 수정했어요가 표시된다.
그 다음 전체 목록을 다시 불러와 3번 게시글의 제목과 내용이 수정된 상태로 반영된다.


삭제 결과

전체 목록에서 3번 게시글의 삭제 버튼을 누르면 deleteBoard(3)이 실행된다.
그 다음 DELETE /boards/3 요청이 서버로 전송된다.
삭제가 성공하면 응답 메시지에 성공적으로 삭제했어요가 표시된다.
이후 loadBoards()가 다시 실행되어 전체 목록에서 3번 게시글이 사라지고 1, 2번 게시글만 남는다.


기타 API 조회 결과

오늘 요일 조회 버튼을 누르면 GET /boards/day 요청이 실행되고, 응답 문자열이 day state에 저장된다.
영상에서는 오늘 요일 값이 아직 조회 안 함에서 목으로 바뀐다.


친구 목록 조회 버튼을 누르면 GET /boards/friends 요청이 실행되고, 응답 문자열이 friends state에 저장된다.
영상에서는 친구 목록 값이 아직 조회 안 함에서 둘리 또치 도우너로 바뀐다.
이 기능은 message state를 바꾸지 않는다.
따라서 영상처럼 기존 메시지가 없으면 응답 메시지 영역은 아직 메시지 없음으로 보이고, 기존 메시지가 있었다면 그 메시지가 그대로 유지된다.


핵심 정리

EduApp16.jsx는 한 화면에서 Board REST API의 여러 요청을 테스트하는 예제다.
전체 목록 조회는 GET /boards, 상세 조회는 GET /boards/{boardNo}, 등록은 POST /boards, 수정은 PUT /boards/{boardNo}, 삭제는 DELETE /boards/{boardNo} 요청으로 처리한다.


전체 목록은 boards state에 저장되고, 상세 조회 결과는 selectedBoard state에 저장된다.
등록과 수정 입력값은 form state로 관리된다.


등록, 수정, 삭제 응답 문자열은 response.text()로 꺼낸 뒤 message state에 저장한다.
오늘 요일 응답 문자열은 response.text()로 꺼낸 뒤 day state에 저장한다.
친구 목록 응답 문자열은 response.text()로 꺼낸 뒤 friends state에 저장한다.


등록, 수정, 삭제가 끝난 뒤에는 loadBoards()를 다시 실행한다.
이렇게 해야 서버에서 바뀐 최신 목록을 다시 받아와 화면에 반영할 수 있다.


이번 예제의 핵심은 버튼 클릭이 어떤 HTTP method 요청으로 이어지는지, 그리고 서버 응답이 어떤 state에 저장되어 화면을 바꾸는지 연결해서 이해하는 것이다.




API 요청 화면 상태 설계

API를 사용하는 화면은 성공 결과만 생각하면 부족하다.
서버 응답을 기다리는 시간도 있고, 서버 오류가 날 수도 있고, 요청은 성공했지만 보여줄 데이터가 없을 수도 있다.


그래서 React 화면에서는 데이터를 받았을 때만 처리하는 것이 아니라, 요청 전, 요청 중, 실패, 빈 데이터, 성공 상태를 나누어 설계해야 한다.
API 화면의 핵심은 데이터를 가져오는 코드가 아니라, 현재 화면이 어떤 상태인지 사용자가 이해할 수 있게 보여주는 것이다.


10-1. API 화면은 성공만 처리하면 부족하다

API 요청에는 여러 상황이 생긴다

API 요청은 항상 성공해서 데이터가 바로 화면에 보이는 흐름으로만 진행되지 않는다.
실제 화면에서는 요청이 시작되기 전 상태, 서버 응답을 기다리는 상태, 서버 오류가 난 상태, 데이터가 비어 있는 상태, 데이터가 정상적으로 도착한 상태가 모두 생길 수 있다.


예를 들어 게시글 목록 화면을 생각하면 아래 상황이 가능하다.

  • 아직 사용자가 목록 조회 버튼을 누르지 않았다.
  • 목록을 불러오는 중이다.
  • 서버가 꺼져 있어서 요청이 실패했다.
  • 요청은 성공했지만 게시글이 하나도 없다.
  • 요청이 성공했고 게시글 목록이 있다.

이 상황들을 모두 같은 화면으로 처리하면 사용자가 현재 무슨 일이 일어났는지 알기 어렵다.
그래서 화면 상태를 나누어 설계해야 한다.


API 요청 화면에서 자주 나누는 상태는 아래와 같다.

  • idle: 아직 요청 전 상태
  • loading: 서버 응답을 기다리는 상태
  • error: 요청이 실패한 상태
  • empty: 요청은 성공했지만 보여줄 데이터가 없는 상태
  • success: 요청이 성공했고 보여줄 데이터가 있는 상태

idle은 버튼을 눌러 요청을 시작하는 화면에서 특히 유용하다.
예를 들어 목록 불러오기 버튼을 누르기 전에는 아직 요청을 보내지 않았으므로 idle 상태로 볼 수 있다.


반대로 화면이 열리자마자 자동으로 데이터를 불러오는 구조라면 처음부터 loading 상태로 시작할 수 있다.
즉, idle을 둘지 말지는 화면이 자동 요청인지, 사용자 버튼 요청인지에 따라 달라진다.


상태 흐름을 그림으로 보면 더 쉽다

처음 화면은 아직 요청 전인 idle 상태로 볼 수 있다.
요청이 시작되면 loading 상태가 되고, 서버 응답을 기다린다.
응답이 실패하면 error 상태로 이동하고 오류 메시지를 보여준다.
응답이 성공했는데 데이터 배열이 비어 있으면 empty 상태로 이동하고 빈 상태 안내 문구를 보여준다.
응답이 성공했고 데이터가 있으면 success 상태로 이동하고 데이터를 화면에 보여준다.


이 흐름을 알면 API 화면을 만들 때 “성공하면 목록 출력”만 생각하지 않게 된다.
화면이 현재 어떤 상태인지 먼저 나누고, 각 상태마다 무엇을 보여줄지 정하게 된다.


10-2. loading, error, empty, success 상태 준비하기

상태를 따로 나누는 이유

API 요청 화면에서는 데이터 자체와 요청 상태를 분리해서 관리하는 것이 좋다.
게시글 목록 데이터는 posts에 저장하고, 요청 중인지는 loading에 저장하며, 오류 메시지는 error에 저장한다.


이렇게 나누면 화면 조건을 명확하게 만들 수 있다.
posts만 있으면 “데이터가 비어 있는 것인지”, “아직 요청을 안 한 것인지”, “요청 중인지”, “오류가 난 것인지”를 구분하기 어렵다.


기본 상태 준비 코드는 아래처럼 볼 수 있다.

// ApiStateExample.jsx
const [posts, setPosts] = useState([]); // 게시글 목록 데이터다.
const [loading, setLoading] = useState(true); // 요청 중 여부다.
const [error, setError] = useState(null); // 오류 메시지다.

posts는 서버에서 받은 게시글 배열을 저장한다.
처음에는 아직 받은 데이터가 없으므로 빈 배열 []로 시작한다.


loading은 서버 응답을 기다리는 동안 true가 된다.
위 예시는 화면이 열리자마자 게시글 목록을 자동으로 불러오는 구조를 기준으로 한다.
그래서 처음부터 요청 중 상태로 보고 loading 초기값을 true로 둔다.


버튼을 눌러 요청하는 화면이라면 처음에는 아직 요청 전이므로 loading 초기값을 false로 둘 수 있다.
이 경우에는 idle 상태를 따로 두거나, “아직 조회 전” 안내 문구를 따로 보여줄 수 있다.


error는 요청 실패 메시지를 저장한다.
처음에는 오류가 없으므로 null로 시작한다.


요청 시작, 성공, 실패, 종료를 나누어 처리한다

API 요청은 보통 try, catch, finally 흐름으로 처리한다.
try에서는 실제 요청을 보내고, catch에서는 오류를 처리하며, finally에서는 성공과 실패와 관계없이 마지막 정리 작업을 한다.

// ApiStateExample.jsx
const [posts, setPosts] = useState([]); // 게시글 목록이다.
const [loading, setLoading] = useState(true); // 요청 중 상태다.
const [error, setError] = useState(null); // 오류 메시지다.

useEffect(() => {
  async function loadPosts() {
    try {
      setLoading(true); // 요청 시작이다.
      setError(null); // 이전 오류를 비운다.

      const data = await getPosts(); // 게시글 목록을 가져오는 함수라고 가정한다.
      setPosts(data); // 응답 데이터를 state에 저장한다.
    } catch (err) {
      setError(err.message); // 오류 메시지를 저장한다.
    } finally {
      setLoading(false); // 요청이 끝났음을 표시한다.
    }
  }

  loadPosts(); // 처음 렌더링 후 게시글을 불러온다.
}, []);

getPosts()는 서버에서 게시글 목록을 가져오는 함수라고 가정한 예시 이름이다.
실제 코드에서는 fetch()를 직접 사용하거나, 별도로 만든 API 함수를 넣을 수 있다.


이 코드에서 중요한 흐름은 상태가 언제 바뀌는지다.
요청을 시작하면 loading을 true로 바꾼다.
그러면 화면은 “불러오는 중” 상태를 보여줄 수 있다.


요청 전에 setError(null)을 실행하는 이유는 이전 오류 메시지를 지우기 위해서다.
이전 요청에서 오류가 났더라도 새 요청을 시작할 때는 다시 깨끗한 상태로 시작해야 한다.


요청이 성공하면 setPosts(data)로 서버 데이터를 저장한다.
state가 바뀌면 React는 화면을 다시 계산하고, 게시글 목록을 출력할 수 있다.


요청이 실패하면 catch에서 setError(err.message)를 실행한다.
그러면 화면은 게시글 목록 대신 오류 메시지를 보여줄 수 있다.


finally에서는 setLoading(false)를 실행한다.
성공하든 실패하든 요청은 끝났으므로 로딩 상태를 종료해야 한다.


10-3. 상태별 화면 분기하기

먼저 loading 상태를 확인한다

상태별 화면 분기는 순서가 중요하다.
가장 먼저 확인할 상태는 보통 loading이다.
요청 중에는 아직 성공인지 실패인지 판단할 수 없기 때문이다.

// ApiStateExample.jsx
if (loading) {
  return <LoadingView message="게시글 목록을 불러오는 중이다." />;
}

loading이 true이면 목록을 보여주기보다 로딩 화면을 먼저 보여준다.
사용자는 이 화면을 보고 “지금 서버 응답을 기다리는 중이구나”라고 이해할 수 있다.


로딩 화면이 없으면 서버 응답이 느릴 때 화면이 멈춘 것처럼 보일 수 있다.
그래서 loading 상태는 사용자의 불안을 줄이는 역할을 한다.


다음으로 error 상태를 확인한다

로딩이 끝났는데 오류가 있다면 error 상태를 보여준다.

// ApiStateExample.jsx
if (error) {
  return <ErrorView message={error} onRetry={refetch} />;
}

error가 있으면 서버 요청이 실패했다는 뜻이다.
이때는 목록을 보여주려고 하지 말고, 오류 메시지를 보여줘야 한다.


refetch는 같은 요청을 다시 실행하는 함수라고 가정한 예시 이름이다.
실제 코드에서는 loadPosts 같은 요청 함수를 다시 연결할 수 있다.


onRetry={refetch}는 다시 시도 버튼과 연결할 수 있는 함수다.
사용자가 오류를 보고 직접 다시 요청할 수 있게 만들면 화면 사용성이 좋아진다.


예를 들어 서버가 잠깐 꺼져 있었거나 네트워크가 불안정했다면, 다시 시도 버튼으로 같은 요청을 다시 보낼 수 있다.


데이터가 비어 있으면 empty 상태를 보여준다

로딩도 아니고 오류도 없는데 posts.length가 0이면 데이터가 비어 있는 상태다.

// ApiStateExample.jsx
if (posts.length === 0) {
  return <EmptyView message="게시글이 없습니다." />;
}

empty는 실패가 아니다.
서버 요청은 성공했지만 보여줄 데이터가 없는 상태다.


예를 들어 게시판 목록 요청은 성공했지만 아직 작성된 게시글이 없을 수 있다.
이때 오류 화면을 보여주면 안 된다.
사용자에게 “게시글이 없습니다”처럼 빈 상태 안내를 보여줘야 한다.


error와 empty는 완전히 다르다. error는 요청 실패이고, empty는 요청 성공 후 데이터가 없는 상태다.


마지막으로 success 상태를 보여준다

loading도 아니고, error도 없고, 데이터도 비어 있지 않다면 성공 상태다.

// ApiStateExample.jsx
return <PostList posts={posts} />;

이때는 서버에서 받아온 posts를 화면에 출력하면 된다.
PostList는 게시글 배열을 받아 목록 형태로 보여주는 컴포넌트라고 이해하면 된다.


상태별 화면 분기를 한 번에 정리하면 아래와 같다.

// ApiStateExample.jsx
if (loading) {
  return <LoadingView message="게시글 목록을 불러오는 중이다." />;
}

if (error) {
  return <ErrorView message={error} onRetry={refetch} />;
}

if (posts.length === 0) {
  return <EmptyView message="게시글이 없습니다." />;
}

return <PostList posts={posts} />;

이 코드에서 LoadingView, ErrorView, EmptyView, PostList는 상태별 화면을 담당하는 컴포넌트 이름이다.
실제 프로젝트에서는 직접 만든 컴포넌트 이름으로 바꿔 사용할 수 있다.


이 코드는 조건을 위에서부터 차례대로 검사한다.
로딩 중이면 로딩 화면에서 끝난다.
오류가 있으면 오류 화면에서 끝난다.
데이터가 비어 있으면 빈 화면에서 끝난다.
그 어떤 조건에도 걸리지 않으면 성공 화면을 보여준다.


10-4. 상태별 화면을 나누면 좋은 이유

사용자가 현재 상황을 바로 이해할 수 있다

상태별 화면을 나누면 사용자는 화면이 왜 그렇게 보이는지 이해할 수 있다.
loading 화면은 “기다리는 중”이라는 의미를 준다.
error 화면은 “문제가 생겼다”는 의미를 준다.
empty 화면은 “요청은 성공했지만 데이터가 없다”는 의미를 준다.
success 화면은 “데이터가 도착했다”는 의미를 준다.


반대로 상태를 나누지 않으면 사용자는 빈 화면을 보고 헷갈릴 수 있다.
서버가 느린 것인지, 오류가 난 것인지, 데이터가 없는 것인지 구분할 수 없기 때문이다.


코드도 읽기 쉬워진다

상태별 화면 분기는 사용자에게만 좋은 것이 아니다.
코드를 읽는 사람도 화면의 흐름을 쉽게 이해할 수 있다.


예를 들어 아래 순서로 읽으면 된다.

// 상태 분기 순서
// loading이면 로딩 화면
// error가 있으면 에러 화면
// posts가 비어 있으면 빈 화면
// 그 외에는 목록 화면

이렇게 흐름을 나누면 조건이 복잡하게 얽히지 않는다.
각 상태가 어떤 화면을 담당하는지 분명해진다.


좋은 API 화면은 성공 화면만 잘 만드는 화면이 아니라, 기다림, 실패, 빈 결과, 성공을 모두 설명해 주는 화면이다.


핵심 정리

API를 사용하는 화면은 성공 결과만 처리하면 부족하다.
서버 응답을 기다리는 loading 상태, 요청이 실패한 error 상태, 요청은 성공했지만 데이터가 없는 empty 상태, 데이터가 정상적으로 도착한 success 상태를 나누어 생각해야 한다.


버튼을 눌러 요청하는 화면이라면 요청 전인 idle 상태도 함께 생각할 수 있다.
반대로 화면이 열리자마자 자동으로 요청하는 화면이라면 처음부터 loading 상태로 시작할 수 있다.


loading, error, posts를 각각 다른 state로 관리하면 현재 화면 상태를 명확하게 판단할 수 있다.
요청을 시작할 때는 setLoading(true)와 setError(null)을 실행하고, 성공하면 setPosts(data)로 데이터를 저장한다.
실패하면 setError(err.message)로 오류 메시지를 저장하고, 마지막에는 setLoading(false)로 로딩을 종료한다.


화면 분기 순서는 보통 loading → error → empty → success 흐름으로 잡는다.
이 순서를 따르면 사용자는 현재 상황을 이해하기 쉽고, 코드를 읽는 사람도 화면 흐름을 명확하게 파악할 수 있다.


API 상태 설계의 핵심은 데이터가 도착한 순간만 보는 것이 아니라, 요청 전후에 화면이 어떤 상태를 지나가는지 함께 설계하는 것이다.




API 함수 분리와 프로젝트 구조

React 컴포넌트 안에 fetch() 코드를 계속 작성하면 처음에는 간단해 보인다.
하지만 요청이 많아질수록 화면 코드와 서버 통신 코드가 한 파일 안에 섞인다.


그래서 API 요청 코드는 따로 분리하는 것이 좋다.
API 함수 분리는 화면 컴포넌트가 “무엇을 보여줄지”에 집중하고, 통신 함수가 “서버와 어떻게 통신할지”를 담당하게 나누는 구조다.


11-1. API 함수 분리가 필요한 이유

컴포넌트 안에 fetch 코드가 많아지면 흐름이 복잡해진다

React 컴포넌트는 원래 화면을 구성하는 역할을 한다.
버튼, 입력창, 목록, 로딩 화면, 에러 화면처럼 사용자가 보는 UI를 만드는 것이 핵심이다.


그런데 컴포넌트 안에 fetch() 코드까지 계속 들어가면 한 파일 안에서 여러 일을 동시에 하게 된다.
서버 주소를 만들고, 요청 옵션을 작성하고, 응답을 확인하고, JSON을 변환하고, 에러까지 처리해야 한다.


처음에는 요청이 하나뿐이라 괜찮아 보일 수 있다.
하지만 게시글 조회, 상세 조회, 등록, 수정, 삭제처럼 요청이 늘어나면 컴포넌트가 점점 길어진다.


예를 들어 컴포넌트 안에 아래 코드가 계속 반복된다고 생각하면 된다.

// RepeatedFetchExample.jsx
const response = await fetch("http://localhost:8080/api/posts"); // 서버에 요청한다.
const text = await response.text(); // 응답 본문을 문자열로 먼저 받는다.
const data = text ? JSON.parse(text) : null; // 본문이 있으면 JSON으로 바꾼다.

if (!response.ok) {
  throw new Error(data?.message ?? `HTTP ${response.status}`); // 실패 응답을 에러로 만든다.
}

이 코드는 서버 요청 자체를 처리하는 코드다.
화면을 보여주는 코드가 아니라, 서버와 통신하는 코드에 가깝다.


이런 코드가 여러 컴포넌트에 반복되면 문제가 생긴다.

  • 서버 주소가 바뀌면 여러 파일을 전부 수정해야 한다.
  • Content-Type 같은 공통 헤더가 여러 곳에 반복된다.
  • 에러 처리 방식이 화면마다 달라질 수 있다.
  • 컴포넌트를 읽을 때 화면 흐름보다 통신 코드가 먼저 눈에 들어온다.

그래서 서버 통신 코드는 src/api 같은 별도 폴더로 분리하는 것이 좋다.


구조를 나누면 역할이 분명해진다

API 요청 코드를 분리하면 각 파일의 역할이 명확해진다.
화면 컴포넌트는 화면을 담당하고, API 함수는 서버 통신을 담당한다.


구조를 그림으로 보면 아래와 같다.

src/api에는 서버 통신 함수가 들어간다.
postApi.js처럼 게시글 요청 함수를 모아 두거나, apiClient.js처럼 공통 요청 함수를 둘 수 있다.
src/hooks에는 반복되는 상태 처리 로직을 분리할 수 있다.
src/components에는 재사용 가능한 화면 조각을 둔다.
src/pages에는 라우팅 단위 화면을 두고, src/App.jsx는 여러 페이지를 조합하며 전체 상태 흐름을 확인하는 역할을 한다.


이 구조의 핵심은 한 파일이 모든 일을 하지 않게 만드는 것이다.
서버 통신, 상태 처리, 화면 출력, 페이지 조합을 나누면 코드가 길어져도 어디를 수정해야 하는지 찾기 쉬워진다.


11-2. apiClient.js로 공통 요청 함수 만들기

apiClient.js의 역할

apiClient.js는 여러 API 함수가 공통으로 사용하는 요청 처리 파일이다.
서버 기본 주소, 공통 헤더, 응답 변환, 에러 처리를 한 곳에 모아 둔다.


이렇게 하면 각 기능별 API 함수에서는 fetch()를 직접 반복해서 쓰지 않아도 된다.
대신 request() 함수만 호출하면 된다.


핵심 코드는 아래와 같다.

// apiClient.js
const API_BASE_URL =
  import.meta.env.VITE_API_BASE_URL ?? "http://localhost:8080"; // 서버 기본 주소다.

export async function request(path, options = {}) {
  const response = await fetch(`${API_BASE_URL}${path}`, {
    ...options, // method, body 같은 요청 옵션이다.
    headers: {
      "Content-Type": "application/json", // JSON 요청 기본 헤더다.
      ...options.headers, // 추가 헤더가 있으면 합친다.
    },
  });

  const text = await response.text(); // 응답 본문을 문자열로 먼저 받는다.
  const data = text ? JSON.parse(text) : null; // 본문이 있으면 JSON으로 바꾼다.

  if (!response.ok) {
    const message = data?.message ?? `HTTP ${response.status}`; // 에러 메시지를 만든다.
    throw new Error(message); // 실패 응답을 에러로 보낸다.
  }

  return data; // 성공 응답 데이터를 반환한다.
}

API_BASE_URL은 서버의 기본 주소다.
import.meta.env.VITE_API_BASE_URL에 값이 있으면 그 값을 사용하고, 없으면 "http://localhost:8080"을 사용한다.


초보자 기준에서는 “환경 설정에 서버 주소가 있으면 그 주소를 쓰고, 없으면 기본 개발 서버 주소를 쓴다”고 이해하면 된다.
이 예시는 Vite 기반 React 프로젝트에서 자주 사용하는 환경 변수 방식이다.


여기서 request()는 기본적으로 JSON 응답을 처리하는 공통 함수다.
응답 본문이 있으면 JSON.parse(text)를 실행하기 때문이다.
따라서 서버가 순수 문자열만 응답하는 API라면 별도의 문자열 처리 함수를 만들거나, response.text()를 직접 사용하는 방식이 더 적합하다.


request 함수가 처리하는 흐름

request() 함수는 서버 요청에 필요한 공통 흐름을 담당한다.


먼저 실제 요청 주소를 만든다.

// apiClient.js
`${API_BASE_URL}${path}`;

예를 들어 API_BASE_URL이 "http://localhost:8080"이고 path가 "/api/posts"라면 실제 요청 주소는 아래처럼 된다.

// 출력 결과
// "http://localhost:8080/api/posts"

path만 바꾸면 같은 서버의 여러 주소로 요청을 보낼 수 있다.


그 다음 공통 헤더를 설정한다.

// apiClient.js
headers: {
  "Content-Type": "application/json",
  ...options.headers,
}

Content-Type은 서버에 보내는 데이터 형식을 알려준다.
여기서는 JSON 형식으로 데이터를 보낸다는 뜻이다.


...options.headers는 추가 헤더를 합치는 부분이다.
예를 들어 나중에 인증 토큰을 보내야 한다면 이 자리에 추가할 수 있다.


응답은 처음부터 response.json()으로 바로 처리하지 않고 response.text()로 먼저 받는다.

// apiClient.js
const text = await response.text();
const data = text ? JSON.parse(text) : null;

이렇게 하는 이유는 서버 응답 본문이 없을 수도 있기 때문이다.
본문이 없는데 바로 response.json()을 실행하면 오류가 날 수 있다.
그래서 문자열이 있을 때만 JSON.parse(text)로 바꾼다.


실패 응답이면 직접 에러를 만든다.

// apiClient.js
if (!response.ok) {
  const message = data?.message ?? `HTTP ${response.status}`;
  throw new Error(message);
}

fetch()는 404, 500 같은 HTTP 오류만으로 자동으로 catch로 이동하지 않는다.
그래서 response.ok를 직접 확인해야 한다.


서버가 에러 메시지를 data.message에 담아 주면 그 메시지를 사용한다.
그 값이 없으면 HTTP 404, HTTP 500처럼 상태 코드를 이용해 메시지를 만든다.


11-3. postApi.js로 게시글 API 함수 만들기

기능별 API 함수로 감싸기

postApi.js는 게시글 관련 요청 함수를 모아 두는 파일이다.
apiClient.js의 request()를 가져와서 게시글 조회, 상세 조회, 등록 함수를 만든다.


핵심 코드는 아래와 같다.

// postApi.js
import { request } from "./apiClient.js"; // 공통 요청 함수를 가져온다.

export function getPosts() {
  return request("/api/posts"); // 게시글 전체 목록 조회다.
}

export function getPost(id) {
  return request(`/api/posts/${id}`); // 게시글 상세 조회다.
}

export function createPost(post) {
  return request("/api/posts", {
    method: "POST", // 등록 요청이다.
    body: JSON.stringify(post), // 객체를 JSON 문자열로 바꾼다.
  });
}

getPosts()는 게시글 전체 목록을 가져오는 함수다.
컴포넌트에서는 이제 fetch("http://localhost:8080/api/posts")를 직접 쓰지 않고 getPosts()만 호출하면 된다.


getPost(id)는 특정 게시글 하나를 가져오는 함수다.
id 값이 3이면 요청 주소는 "/api/posts/3"이 된다.


createPost(post)는 새 게시글을 등록하는 함수다.
post 객체를 받아 JSON.stringify(post)로 바꾼 뒤 POST 요청으로 보낸다.


실제 값 기준으로 요청 주소 보기

postApi.js의 함수들은 내부적으로 request()를 호출한다.
흐름을 실제 값으로 보면 더 이해하기 쉽다.


전체 목록 조회는 아래처럼 실행된다.

// PostsPage.jsx
getPosts();

실제 의미는 아래와 같다.

// 실제 의미
request("/api/posts");

request() 내부에서는 최종 요청 주소가 이렇게 만들어진다.

// 출력 결과
// "http://localhost:8080/api/posts"

상세 조회는 아래처럼 실행된다.

// PostsPage.jsx
getPost(3);

실제 의미는 아래와 같다.

// 실제 의미
request("/api/posts/3");

최종 요청 주소는 아래처럼 된다.

// 출력 결과
// "http://localhost:8080/api/posts/3"

등록 요청은 아래처럼 실행된다.

// PostsPage.jsx
createPost({
  title: "React와 API 분리",
  content: "통신 함수를 따로 관리한다.",
  author: "kim",
});

실제 의미는 아래와 같다.

// 실제 의미
request("/api/posts", {
  method: "POST",
  body: JSON.stringify({
    title: "React와 API 분리",
    content: "통신 함수를 따로 관리한다.",
    author: "kim",
  }),
});

최종적으로 request()는 서버에 POST /api/posts 요청을 보내고, 성공하면 응답 데이터를 반환한다.


11-4. 컴포넌트에서는 API 함수만 호출하기

화면 코드는 화면 흐름에 집중한다

API 함수를 분리하면 컴포넌트는 서버 요청의 자세한 구현을 몰라도 된다.
컴포넌트는 getPosts(), getPost(), createPost() 같은 함수 이름을 보고 어떤 작업인지 이해할 수 있다.


예를 들어 게시글 목록 화면은 아래처럼 작성할 수 있다.

// PostsPage.jsx
import { useEffect, useState } from "react"; // state와 effect를 사용한다.
import { getPosts } from "../api/postApi.js"; // 게시글 목록 API 함수다.

export default function PostsPage() {
  const [posts, setPosts] = useState([]); // 게시글 목록 상태다.
  const [loading, setLoading] = useState(true); // 요청 중 상태다.
  const [error, setError] = useState(null); // 오류 메시지다.

  useEffect(() => {
    async function loadPosts() {
      try {
        setLoading(true); // 요청 시작이다.
        setError(null); // 이전 오류를 지운다.

        const data = await getPosts(); // 게시글 목록 API를 호출한다.
        setPosts(data); // 응답 데이터를 state에 저장한다.
      } catch (err) {
        setError(err.message); // 오류 메시지를 저장한다.
      } finally {
        setLoading(false); // 요청 종료다.
      }
    }

    loadPosts(); // 처음 화면이 열릴 때 실행한다.
  }, []);

  if (loading) {
    return <p>게시글 목록을 불러오는 중이다.</p>;
  }

  if (error) {
    return <p>{error}</p>;
  }

  if (posts.length === 0) {
    return <p>게시글이 없습니다.</p>;
  }

  return (
    <section>
      {posts.map((post) => (
        <article key={post.id}>
          <h3>{post.title}</h3>
          <p>{post.content}</p>
        </article>
      ))}
    </section>
  );
}

이 컴포넌트 안에는 fetch()의 자세한 설정이 없다.
서버 주소 조합, 공통 헤더, JSON 변환, 에러 처리는 apiClient.js에서 처리한다.


컴포넌트는 “게시글 목록을 가져온다”, “상태에 저장한다”, “상태에 맞게 화면을 보여준다”에 집중한다.


역할을 나누면 수정 위치가 명확해진다

API 주소가 바뀌면 apiClient.js의 API_BASE_URL만 확인하면 된다.
게시글 요청 주소가 바뀌면 postApi.js의 함수만 확인하면 된다.
화면 구성이 바뀌면 PostsPage.jsx만 확인하면 된다.


반복되는 데이터 요청 상태 관리가 여러 페이지에서 계속 필요해지면 src/hooks로 분리할 수 있다.
예를 들어 usePosts.js는 게시글 목록 요청 상태를 관리하고, useFetch.js는 공통 요청 상태를 관리하는 식으로 사용할 수 있다.


이렇게 나누면 파일별 책임이 분명해진다.

  • apiClient.js: 모든 요청에 공통으로 필요한 처리
  • postApi.js: 게시글 기능별 요청 함수
  • usePosts.js, useFetch.js: 반복되는 요청 상태 처리
  • PostsPage.jsx: 게시글 목록 화면 출력

즉, 문제가 생겼을 때 어디를 봐야 하는지 빨리 찾을 수 있다.


11-5. 핵심 구조 정리

폴더 역할을 기준으로 나눈다

API 함수 분리는 단순히 파일을 여러 개로 쪼개는 작업이 아니다.
각 파일이 맡을 역할을 정하는 작업이다.


기본 구조는 아래처럼 잡을 수 있다.

// 프로젝트 구조 예시
src
├─ api
│  ├─ apiClient.js
│  └─ postApi.js
├─ hooks
│  ├─ usePosts.js
│  └─ useFetch.js
├─ components
│  ├─ LoadingView.jsx
│  ├─ ErrorView.jsx
│  └─ EmptyView.jsx
├─ pages
│  ├─ PostsPage.jsx
│  └─ SearchPostsPage.jsx
└─ App.jsx

src/api는 서버와 통신하는 함수들을 모아 두는 곳이다.
apiClient.js는 공통 요청 처리, postApi.js는 게시글 요청 처리를 담당한다.


src/hooks는 여러 화면에서 반복되는 상태 처리 로직을 모아 두는 곳이다.
예를 들어 usePosts.js는 게시글 목록을 불러오고, loading, error, posts 상태를 함께 관리하는 역할을 할 수 있다.
useFetch.js는 특정 요청 하나에 묶이지 않고, 여러 요청에서 공통으로 쓰는 요청 상태 처리 흐름을 담을 수 있다.


src/components는 여러 화면에서 다시 쓸 수 있는 화면 조각을 모아 두는 곳이다.
예를 들어 로딩 화면, 에러 화면, 빈 데이터 화면은 여러 페이지에서 반복해서 사용할 수 있다.


src/pages는 하나의 화면 단위를 모아 두는 곳이다.
게시글 목록 페이지, 게시글 검색 페이지처럼 라우팅 단위 화면이 들어갈 수 있다.


App.jsx는 전체 페이지를 조합하는 파일로 볼 수 있다.
초보자 기준에서는 “여러 화면을 연결하고 최종적으로 보여줄 구조를 정하는 파일”이라고 이해하면 된다.


한 줄로 정리하면 역할 분리다

API 함수 분리의 목표는 코드를 멋있게 쪼개는 것이 아니다.
어떤 코드는 서버 통신을 담당하고, 어떤 코드는 상태 처리를 담당하며, 어떤 코드는 화면을 담당하는지 분명하게 만드는 것이다.


정리하면 아래 흐름이다.

// 역할 분리 흐름
apiClient.js
→ 공통 요청 처리

postApi.js
→ 게시글 기능별 API 함수

usePosts.js / useFetch.js
→ 반복되는 요청 상태 처리

PostsPage.jsx
→ API 함수 또는 hook을 사용해 화면에 출력

이 흐름을 지키면 API가 늘어나도 컴포넌트 안의 코드가 지나치게 복잡해지지 않는다.


핵심 정리

컴포넌트 안에 fetch() 코드를 계속 작성하면 화면 코드와 통신 코드가 섞인다.
요청이 적을 때는 괜찮아 보이지만, API가 늘어나면 유지보수가 어려워진다.


apiClient.js는 서버 기본 주소, 공통 헤더, 응답 변환, 에러 처리를 담당한다.
postApi.js는 게시글 전체 조회, 상세 조회, 등록처럼 기능별 요청 함수를 담당한다.
usePosts.js, useFetch.js 같은 hook 파일은 반복되는 요청 상태 처리를 분리할 때 사용할 수 있다.
컴포넌트는 getPosts() 같은 API 함수나 hook을 호출하고, 받은 데이터를 화면에 보여주는 역할에 집중한다.


API 함수 분리의 핵심은 컴포넌트가 서버 통신 세부 구현을 직접 떠안지 않게 하고, 화면 출력 역할에 집중하게 만드는 것이다.

0개의 댓글