로딩과 에러는 개별 메시지가 아니라 요청 상태와 데이터 유무에 따라 선택되는 렌더 분기표로 관리하면 더 안정적입니다.
idle, loading, error, success의 data·empty 분기와 retry 전이 위에 기존 데이터를 유지하는 background isRefreshing 축을 분리한 요청 UI 상태 모델
Primary status × background refresh
첫 요청의 화면 상태와 기존 데이터를 갱신하는 진행 상태는 같은 축이 아닙니다. 주 상태는 한 번에 하나를 선택하고, isRefreshing은 data 또는 empty 화면을 유지한 채 뒤에서 새 요청이 진행 중임을 덧붙입니다.
주 상태 · 배타적
idle → loading → success | error
idle: URL이나 시작 조건이 아직 없음
loading: 표시할 기존 데이터가 없는 첫 요청
success:data: 성공했고 표시할 항목이 있음
success:empty: 성공했지만 결과가 비어 있음
error: 첫 요청이 실패해 복구 행동이 필요함
직교 축 · 비차단
isRefreshing은 기존 UI를 보존
data 또는 empty 상태에서 같은 resource key를 refetch하면 기존 콘텐츠를 지우지 않고 진행 표시만 추가합니다. key에는 인증 주체·locale·query처럼 응답 정체성을 바꾸는 입력을 모두 포함합니다. 새 결과가 오면 교체하고, 갱신 실패는 기존 결과와 함께 비차단 오류·재시도 동선으로 보여줍니다.
AbortController는 불필요한 요청을 중단하고, active/latest request ID gate는 늦게 끝난 이전 요청이 현재 상태를 쓰지 못하게 합니다. finally도 같은 gate를 통과해야 합니다.
요청 이벤트가 주 상태와 화면 계약을 바꾸는 규칙
현재 상태
이벤트
다음 상태
사용자에게 보이는 화면
idle
유효한 URL로 시작
loading
스켈레톤·진행 안내, 중복 실행 제한
loading
항목과 함께 resolve
success:data
데이터와 다음 행동
loading
빈 결과로 resolve
success:empty
오류가 아닌 빈 상태 안내
loading
reject
error
원인 요약과 retry 행동
error
retry
loading
이전 오류를 정리하고 다시 요청
success
같은 key를 background refetch
success + 갱신 중
기존 data·empty UI를 그대로 유지
refresh
최신 요청 resolve
success
새 결과로 교체하고 갱신 표시 종료
refresh
최신 요청 reject
success + 갱신 오류
기존 결과를 유지하고 비차단 retry 제공
idle → loading
유효한 요청을 시작한다
표시할 결과가 없으므로 스켈레톤과 진행 안내를 보여주고 중복 실행을 제한합니다.
loading → success
resolve 결과는 data 또는 empty
항목이 있으면 데이터와 다음 행동을, 값이 비었으면 오류가 아닌 빈 상태 안내를 보여줍니다.
loading → error
reject 원인과 복구 행동을 제시한다
원인을 요약하고 사용자가 다시 요청할 수 있는 retry 동선을 제공합니다.
error → loading
retry는 새 요청 이벤트다
이전 오류를 정리하고 새 요청을 시작하며, 별도의 영구 화면 상태로 취급하지 않습니다.
success + refreshing
같은 key의 결과를 뒤에서 갱신한다
기존 data·empty 화면은 유지하고 진행 표시만 더합니다. 응답 정체성 key가 바뀌면 loading으로 돌아갑니다.
refresh completion
최신 완료만 현재 화면에 반영한다
resolve면 새 결과로 교체합니다. reject면 기존 결과를 유지하고 비차단 오류와 retry를 보여줍니다.
retry는 별도 화면 상태가 아니라 error에서 새 loading 요청을 시작하는 이벤트입니다. 응답 정체성 key가 바뀌면 이전 엔터티를 유지하지 않고 loading으로 돌아가며, empty는 요청 실패가 아니라 값이 비어 있는 성공 결과입니다.
데이터 페칭 시 useEffect의 의존성 배열을 올바르게 사용하는 것은 매우 중요합니다.
빈 배열 ([]): Effect가 읽는 반응형 값이 없을 때 사용합니다. 프로덕션 마운트에서는 한 번 setup되지만, 개발 Strict Mode는 cleanup 검증을 위해 추가 setup → cleanup → setup을 실행하므로 “정확히 한 번” 계약으로 사용하면 안 됩니다.
변수 포함 ([id, category]): 특정 props나 state 값이 변경될 때마다 데이터를 다시 가져옵니다. 예를 들어, 사용자 ID나 검색 카테고리가 변경될 때 유용합니다.
주의사항
함수나 객체 참조: Effect가 읽는 함수나 객체가 렌더마다 새로 만들어지면 Effect도 다시 실행될 수 있습니다. 불필요한 의존성을 먼저 Effect 안으로 옮기고, 참조 안정성이 실제로 필요할 때 useCallback이나 useMemo를 사용합니다.
// 매 렌더마다 fetchData 참조가 바뀌므로 Effect도 다시 실행됨const fetchDataEveryRender = async () => { /* ... */ };useEffect(() => { fetchDataEveryRender();}, [fetchDataEveryRender]);// 일반적인 해결: 요청 함수를 Effect 안에 정의useEffect(() => { const fetchData = async () => { /* ... */ }; fetchData();}, []);// 외부에서 같은 함수 참조가 필요할 때만 안정화const stableFetchData = useCallback(async () => { /* ... */ }, []);useEffect(() => { stableFetchData();}, [stableFetchData]);
데이터 페칭 함수의 경우, 대부분 useEffect 내부에 정의하는 것이 일반적이고 간결합니다.
이 구현에서 AbortController는 중단 가능한 네트워크 작업을 취소하고, active와 최신 request ID는 늦게 끝난 이전 작업의 상태 쓰기를 차단합니다. 같은 requestKey의 성공 결과가 있으면 값이 null인 empty 결과도 구분해 보존하고 isRefreshing만 켭니다. key가 달라지면 이전 엔터티를 노출하지 않고 새 loading 상태를 사용합니다.
requestKey는 URL뿐 아니라 인증 주체, locale, query, 응답을 바꾸는 header나 body처럼 응답의 정체성을 바꾸는 모든 입력을 포함해야 합니다. 이 예제는 공개 게시글 조회라서 post:${postId}면 충분하지만 사용자별 응답이라면 사용자 식별자도 key에 넣습니다. cache, 화면 state, commit guard가 모두 같은 key를 써야 이전 사용자의 데이터가 잠깐 노출되는 일을 막을 수 있습니다.
options는 Effect의 반응형 의존성입니다. 호출부에서 인라인 객체를 매 렌더마다 만들면 참조가 계속 바뀌어 요청이 반복되므로, 바뀌어야 하는 원시 값으로 options를 구성하거나 실제로 필요할 때 useMemo로 참조를 안정화합니다. 의존성 배열에서 options를 임의로 빼면 오래된 옵션을 읽게 됩니다.
Error Boundary가 하위 컴포넌트의 렌더·생성자·생명주기 오류를 fallback으로 격리하는 범위와 이벤트·일반 비동기 콜백·서버 렌더·경계 자체 오류를 처리할 별도 경로
Catch radius · recovery contract
Error Boundary는 자신이 감싼 하위 React 트리의 렌더 실패를 fallback UI로 격리합니다. 모든 JavaScript 오류를 잡는 전역 예외 처리기가 아니므로, 경계 안과 밖을 먼저 구분해야 복구 동선도 정확해집니다.
경계가 포착
하위 컴포넌트 트리
렌더 중 오류: 컴포넌트가 UI를 계산하다 throw
생성자 오류: 하위 class 컴포넌트의 constructor
생명주기 오류: 하위 class 컴포넌트의 lifecycle
getDerivedStateFromError로 fallback 상태를 만들고, componentDidCatch에서 오류와 component stack을 기록합니다.
별도 처리
경계 밖의 실행 문맥
이벤트 핸들러: 해당 동작의 try...catch
일반 비동기 콜백: Promise, setTimeout, requestAnimationFrame의 오류 경로
서버 렌더링: 서버·프레임워크의 오류 처리 경로
경계 자체의 오류: 더 바깥의 상위 Error Boundary
startTransition 콜백 안에서 throw된 오류는 일반 비동기 콜백과 달리 Error Boundary로 전달될 수 있습니다.
실패한 하위 트리만 fallback으로 교체
페이지·위젯처럼 사용자가 독립적으로 이해할 수 있는 단위에 경계를 두고, 오류 보고에는 component stack을 함께 남깁니다.
먼저 원인을 복구
잘못된 입력, 손상된 캐시, 실패한 데이터 요청처럼 다시 throw하게 만든 조건을 고칩니다. hasError만 false로 바꾸면 같은 하위 트리가 즉시 다시 실패할 수 있습니다.
복구 뒤 reset 또는 의도적인 remount
경계 상태를 reset하거나 key를 바꿔 경계·하위 트리를 새로 마운트합니다. remount는 내부 state도 초기화하므로 보존할 상태를 먼저 결정합니다.
데이터 요청의 reject는 보통 요청 코드나 데이터 라이브러리에서 처리합니다. 그 결과 때문에 이후 렌더가 throw될 때 비로소 가장 가까운 Error Boundary의 격리 범위가 적용됩니다.
데이터 페칭 에러는 try...catch로 처리할 수 있지만, 렌더링 중 예외가 발생하면 컴포넌트 트리 전체가 깨질 수 있습니다.
이때 Error Boundary를 두면 전체 페이지 다운 대신 문제 구역만 폴백 UI로 격리할 수 있습니다. 하위 트리의 렌더·생성자·생명주기 오류는 잡지만, 이벤트 핸들러, 일반 비동기 콜백, 서버 렌더링, Error Boundary 자체에서 발생한 오류는 잡지 않습니다.
import AppErrorBoundary from './components/AppErrorBoundary';import PostDetailWithHook from './components/PostDetailWithHook';export default function App() { return ( <AppErrorBoundary fallback={<p>게시글 화면을 불러오지 못했습니다.</p>}> <PostDetailWithHook /> </AppErrorBoundary> );}
배치 가이드
페이지 단위 경계: 라우트별 주요 화면을 감싸 전체 앱 장애를 방지합니다.
위젯 단위 경계: 외부 데이터 의존 위젯(차트, 에디터 등)을 개별 격리합니다.
재시도 정책: hasError만 초기화하면 같은 원인으로 즉시 다시 실패할 수 있습니다. 잘못된 입력·데이터·캐시를 먼저 복구하고, 하위 state까지 새로 시작해야 할 때는 복구 버전을 key로 사용해 subtree를 의도적으로 remount합니다. remount는 내부 state도 지우므로 보존 정책을 함께 정합니다.
로딩 상태와 에러 처리는 여기까지입니다.
이 장에서는 비동기 데이터 페칭 과정에서 발생하는 로딩 상태와 에러를 효과적으로 관리하고 사용자에게 피드백하는 중요성에 대해 배웠습니다.
useState와 try...catch를 이용한 기본적인 구현 방법부터, useEffect의 의존성 배열 사용 시 주의사항, 그리고 커스텀 훅을 통한 로직 재사용까지 심화된 내용을 다루었습니다.
이제는 리액트 애플리케이션에서 견고하고 사용자 친화적인 데이터 페칭 로직을 구현할 수 있는 기초를 마련했습니다.
다음 절에서는 axios와 같은 인기 있는 HTTP 클라이언트 라이브러리를 사용하여 데이터 페칭을 더욱 편리하게 만드는 방법을 알아보겠습니다.
로딩과 에러 처리는 데이터 요청 주변의 부가 UI가 아니라, 사용자가 다음 행동을 판단할 수 있게 만드는 상태 설계입니다.