
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()는 컴포넌트가 화면에 반영된 뒤 실행할 작업을 등록하는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()는 상태 변경 규칙이 복잡할 때 사용하는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()로 바꾸고,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,
대신 하나의 객체 상태인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()는 렌더링과 직접 관련이 없는 값을 기억하거나, 실제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()로 관리하는 것이 핵심이다.
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.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.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.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를 사용하는 화면은 성공 결과만 생각하면 부족하다.
서버 응답을 기다리는 시간도 있고, 서버 오류가 날 수도 있고, 요청은 성공했지만 보여줄 데이터가 없을 수도 있다.
그래서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상태 설계의 핵심은 데이터가 도착한 순간만 보는 것이 아니라, 요청 전후에 화면이 어떤 상태를 지나가는지 함께 설계하는 것이다.
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함수 분리의 핵심은 컴포넌트가 서버 통신 세부 구현을 직접 떠안지 않게 하고, 화면 출력 역할에 집중하게 만드는 것이다.