본문 바로가기

useEffect와 데이터 요청

useEffect에서 Fetch API로 데이터를 요청해 상태에 반영하고 의존성 변경과 AbortController로 요청 생명주기를 관리합니다.

앞 글에서 익힌 비동기 처리를 써서 React 컴포넌트 안에서 데이터를 가져오는(data fetching) 방법을 알아봅니다. React에서는 컴포넌트가 마운트되거나 갱신될 때 외부의 데이터를 가져와야 하는 경우가 많고, 이런 부수 효과(side effect)를 처리하도록 useEffect 훅을 제공합니다. 이 글에서는 useEffect로 API를 호출하는 실전 패턴을 다룹니다.

3장의 컴포넌트 라이프사이클에서 본 Effect의 setup과 cleanup, 6장의 라우트 파라미터와 쿼리 문자열에서 본 경로 매개변수, 앞 글의 비동기 실행 순서를 알고 있으면 따라오기 쉽습니다. 이 글에서는 요청의 시작과 응답의 채택, 화면의 분기라는 세 지점을 따로 따라갑니다. 뒤에서 쓰는 useRef는 렌더 사이에 같은 보관함을 유지하므로 가장 최근 요청의 번호를 기억하는 데 쓰고, setup 안의 active는 그 setup 한 번이 아직 유효한지만 나타냅니다.

데이터 요청 UI는 네 상태와 유효한 전이로 관리한다 — 상태 추적

요청 시작/현재 이행/현재 실패/취소/오래된 완료에서 data/error/loading의 반영 여부가 달라진다. 취소를 Error로 표시하지 않고 latest 소유권을 추적한다.

데이터 요청 UI의 idle, loading, success, error 상태와 요청 시작, 최신 응답, 실패, 취소에 따른 상태 전이 계약

Idle → loading → success | error

최신 요청만 결과를 채택하는 UI 상태

화면은 현재 요청의 상태 하나를 렌더하고, 완료 순서가 아니라 최신 요청 여부로 결과를 채택합니다. 요청 시작, 성공, 실패, 취소를 분리하면 서로 모순되는 loading·error 조합과 오래된 응답의 덮어쓰기를 피할 수 있습니다.

배타적인 요청 UI 상태

배타적인 요청 UI 상태기존 data 유지 여부는 제품 정책입니다. 취소와 stale 완료는 새 결과 상태가 아니며 최신 gate 밖에서 차단합니다.시작resolverejectretryidle아직 요청 없음loading현재 요청 대기success최신dataerror최신실패
배타적인 요청 UI 상태기존 data 유지 여부는 제품 정책입니다. 취소와 stale 완료는 새 결과 상태가 아니며 최신 gate 밖에서 차단합니다.시작resolverejectretryidle아직 요청 없음loading현재 요청 대기success최신dataerror최신실패

기존 data 유지 여부는 제품 정책입니다. 취소와 stale 완료는 새 결과 상태가 아니며 최신 gate 밖에서 차단합니다.

응답 완료보다 최신 요청 소유권을 먼저 판정

응답 완료보다 최신 요청 소유권을 먼저 판정완료 응답의 ID를 현재 요청 ID와 비교합니다. 성공·실패·finally는 각각 state를 쓰기 직전에 최신성을 확인합니다. Abort/cleanup은 불필요한 작업을 중단하며, 취소와 오래된 완료는 일반 error 화면으로 보내지 않습니다.아니오예resolve실패현재요청 ID완료 도착결과 ID최신ID인가?이전 ID쓰기없음현재 ID반영허용resolvesuccessrejecterrorfinally도 최신 gateloading 종료
응답 완료보다 최신 요청 소유권을 먼저 판정완료 응답의 ID를 현재 요청 ID와 비교합니다. 성공·실패·finally는 각각 state를 쓰기 직전에 최신성을 확인합니다. Abort/cleanup은 불필요한 작업을 중단하며, 취소와 오래된 완료는 일반 error 화면으로 보내지 않습니다.아니오예resolve실패현재요청 ID완료도착결과 ID최신ID인가?이전 ID쓰기없음현재 ID반영허용resolvesuccessrejecterrorfinally도 최신gateloading 종료

완료 응답의 ID를 현재 요청 ID와 비교합니다. 성공·실패·finally는 각각 state를 쓰기 직전에 최신성을 확인합니다. Abort/cleanup은 불필요한 작업을 중단하며, 취소와 오래된 완료는 일반 error 화면으로 보내지 않습니다.

원래 설명과 상세 기준
Idle

아직 요청하지 않은 상태

초기 안내나 요청 행동을 보여줍니다. 시작 이벤트가 발생하면 이전 오류를 비우고 Loading으로 전이합니다.

Loading

현재 요청이 진행 중

loading을 켜고 진행 피드백을 렌더합니다. 기존 데이터를 유지할지 비울지는 제품 정책으로 정하되, 이번 요청의 정체성은 고정합니다.

Success

최신 요청이 데이터로 이행

현재 요청의 응답만 data에 반영하고 Loading을 끝냅니다. 입력 변경이나 재시도는 새 요청을 만들어 Loading으로 돌아갑니다.

Error

최신 요청이 실제 오류로 실패

AbortError가 아닌 현재 요청의 실패만 오류 UI로 보냅니다. 재시도하면 오류를 비우고 Loading으로 전이합니다.

취소와 stale completion은 결과 상태가 아니다

cleanup에서 이전 요청을 중단하고, 늦은 완료는 최신성 gate에서 버립니다. 취소를 Error로 표시하거나 오래된 결과로 Success를 만들지 않습니다.

렌더 분기는 상태 계약을 그대로 따른다

Loading은 진행 화면, Error는 실패와 재시도, Success는 데이터 화면을 선택합니다. 요청 코드보다 이 전이표를 먼저 정하면 UI가 흔들리지 않습니다.

요청 시작은 Loading으로, 최신 이행은 Success로, 최신 비취소 실패는 Error로 이동합니다. cleanup과 request identity gate는 상태 전이 바깥에서 오래된 요청의 쓰기를 차단합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
원도표 설명초기 안내나 요청 행동을 보여 줍니다. 시작 이벤트가 발생하면 이전 오류를 비우고 Loading으로 전이합니다.
원도표 설명loading을 켜고 진행 피드백을 렌더합니다. 기존 데이터를 유지할지 비울지는 제품 정책으로 정하되, 이번 요청의 정체성은 고정합니다.
원도표 설명현재 요청의 응답만 data에 반영하고 Loading을 끝냅니다. 입력 변경이나 재시도는 새 요청을 만들어 Loading으로 돌아갑니다.
원도표 설명AbortError가 아닌 현재 요청의 실패만 오류 UI로 보냅니다. 재시도하면 오류를 비우고 Loading으로 전이합니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
요청 중current: 요청 ID / UI: loading현재 요청의 loading을 켜고 이전 오류를 정리한다기존 데이터 유지는 제품 정책으로 명시한다
현재 완료성공: success / 실패: error현재 성공만 data를, 현재의 비취소 실패만 error를 쓴다취소는 보통 오류 배너의 경로가 아니다
이전 완료stale: 쓰기 없음 / UI: 새 요청 상태 유지교체된 요청의 완료는 current gate에서 버린다완료 순서보다 최신 소유권을 우선한다

데이터 요청 UI는 네 상태와 유효한 전이로 관리한다 — 상태 추적

요청 시작/현재 이행/현재 실패/취소/오래된 완료에서 data/error/loading의 반영 여부가 달라진다. 취소를 Error로 표시하지 않고 latest 소유권을 추적한다.

데이터 요청 UI의 idle, loading, success, error 상태와 요청 시작, 최신 응답, 실패, 취소에 따른 상태 전이 계약

Idle → loading → success | error

최신 요청만 결과를 채택하는 UI 상태

화면은 현재 요청의 상태 하나를 렌더하고, 완료 순서가 아니라 최신 요청 여부로 결과를 채택합니다. 요청 시작, 성공, 실패, 취소를 분리하면 서로 모순되는 loading·error 조합과 오래된 응답의 덮어쓰기를 피할 수 있습니다.

배타적인 요청 UI 상태

배타적인 요청 UI 상태기존 data 유지 여부는 제품 정책입니다. 취소와 stale 완료는 새 결과 상태가 아니며 최신 gate 밖에서 차단합니다.시작resolverejectretryidle아직 요청 없음loading현재 요청 대기success최신dataerror최신실패
배타적인 요청 UI 상태기존 data 유지 여부는 제품 정책입니다. 취소와 stale 완료는 새 결과 상태가 아니며 최신 gate 밖에서 차단합니다.시작resolverejectretryidle아직 요청 없음loading현재 요청 대기success최신dataerror최신실패

기존 data 유지 여부는 제품 정책입니다. 취소와 stale 완료는 새 결과 상태가 아니며 최신 gate 밖에서 차단합니다.

응답 완료보다 최신 요청 소유권을 먼저 판정

응답 완료보다 최신 요청 소유권을 먼저 판정완료 응답의 ID를 현재 요청 ID와 비교합니다. 성공·실패·finally는 각각 state를 쓰기 직전에 최신성을 확인합니다. Abort/cleanup은 불필요한 작업을 중단하며, 취소와 오래된 완료는 일반 error 화면으로 보내지 않습니다.아니오예resolve실패현재요청 ID완료 도착결과 ID최신ID인가?이전 ID쓰기없음현재 ID반영허용resolvesuccessrejecterrorfinally도 최신 gateloading 종료
응답 완료보다 최신 요청 소유권을 먼저 판정완료 응답의 ID를 현재 요청 ID와 비교합니다. 성공·실패·finally는 각각 state를 쓰기 직전에 최신성을 확인합니다. Abort/cleanup은 불필요한 작업을 중단하며, 취소와 오래된 완료는 일반 error 화면으로 보내지 않습니다.아니오예resolve실패현재요청 ID완료도착결과 ID최신ID인가?이전 ID쓰기없음현재 ID반영허용resolvesuccessrejecterrorfinally도 최신gateloading 종료

완료 응답의 ID를 현재 요청 ID와 비교합니다. 성공·실패·finally는 각각 state를 쓰기 직전에 최신성을 확인합니다. Abort/cleanup은 불필요한 작업을 중단하며, 취소와 오래된 완료는 일반 error 화면으로 보내지 않습니다.

원래 설명과 상세 기준
Idle

아직 요청하지 않은 상태

초기 안내나 요청 행동을 보여줍니다. 시작 이벤트가 발생하면 이전 오류를 비우고 Loading으로 전이합니다.

Loading

현재 요청이 진행 중

loading을 켜고 진행 피드백을 렌더합니다. 기존 데이터를 유지할지 비울지는 제품 정책으로 정하되, 이번 요청의 정체성은 고정합니다.

Success

최신 요청이 데이터로 이행

현재 요청의 응답만 data에 반영하고 Loading을 끝냅니다. 입력 변경이나 재시도는 새 요청을 만들어 Loading으로 돌아갑니다.

Error

최신 요청이 실제 오류로 실패

AbortError가 아닌 현재 요청의 실패만 오류 UI로 보냅니다. 재시도하면 오류를 비우고 Loading으로 전이합니다.

취소와 stale completion은 결과 상태가 아니다

cleanup에서 이전 요청을 중단하고, 늦은 완료는 최신성 gate에서 버립니다. 취소를 Error로 표시하거나 오래된 결과로 Success를 만들지 않습니다.

렌더 분기는 상태 계약을 그대로 따른다

Loading은 진행 화면, Error는 실패와 재시도, Success는 데이터 화면을 선택합니다. 요청 코드보다 이 전이표를 먼저 정하면 UI가 흔들리지 않습니다.

요청 시작은 Loading으로, 최신 이행은 Success로, 최신 비취소 실패는 Error로 이동합니다. cleanup과 request identity gate는 상태 전이 바깥에서 오래된 요청의 쓰기를 차단합니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
원도표 설명초기 안내나 요청 행동을 보여 줍니다. 시작 이벤트가 발생하면 이전 오류를 비우고 Loading으로 전이합니다.
원도표 설명loading을 켜고 진행 피드백을 렌더합니다. 기존 데이터를 유지할지 비울지는 제품 정책으로 정하되, 이번 요청의 정체성은 고정합니다.
원도표 설명현재 요청의 응답만 data에 반영하고 Loading을 끝냅니다. 입력 변경이나 재시도는 새 요청을 만들어 Loading으로 돌아갑니다.
원도표 설명AbortError가 아닌 현재 요청의 실패만 오류 UI로 보냅니다. 재시도하면 오류를 비우고 Loading으로 전이합니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
요청 중current: 요청 ID / UI: loading현재 요청의 loading을 켜고 이전 오류를 정리한다기존 데이터 유지는 제품 정책으로 명시한다
현재 완료성공: success / 실패: error현재 성공만 data를, 현재의 비취소 실패만 error를 쓴다취소는 보통 오류 배너의 경로가 아니다
이전 완료stale: 쓰기 없음 / UI: 새 요청 상태 유지교체된 요청의 완료는 current gate에서 버린다완료 순서보다 최신 소유권을 우선한다

위 그림은 데이터 요청 UI를 네 가지 상태와 그 사이의 올바른 전이로 관리하는 방식을 보여 줍니다. 아직 요청하지 않은 idle에서 요청을 시작하면 loading이 되고, 현재 요청이 이행되면 success, 실제로 실패하면 error가 되며, error에서 다시 시도하면 loading으로 돌아갑니다. 화면은 한 번에 하나의 상태만 렌더링하고, 응답이 도착한 순서가 아니라 그 응답이 최신 요청의 것인지로 결과를 받아들입니다. 기존 데이터를 남겨 둘지는 제품의 규칙이며, 취소와 오래된 응답의 완료는 새로운 결과 상태가 아닙니다.

그림 아래쪽의 흐름은 응답이 도착했을 때 최신 요청인지를 먼저 확인하는 과정입니다. 도착한 결과의 ID를 현재 요청의 ID와 비교해, 이전 ID라면 아무것도 쓰지 않고, 현재 ID일 때만 반영을 허용하며, finally의 로딩 종료도 같은 확인을 거칩니다. 그림의 세부 칸들이 덧붙이듯 Loading에서는 이번 요청을 고정한 채 진행 피드백을 보여 주고, AbortError가 아닌 현재 요청의 실패만 오류 화면으로 보냅니다. cleanup에서 이전 요청을 중단하고 늦은 완료는 최신성 확인에서 버리며, 취소를 Error로 보여 주거나 오래된 결과로 Success를 만들지 않습니다. 렌더 분기도 이 상태를 그대로 따라, 요청 코드보다 이 전이표를 먼저 정해 두면 UI가 흔들리지 않습니다.


useEffect 훅 복습

useEffect는 함수 컴포넌트 안에서 부수 효과를 실행하게 해 줍니다. 데이터 요청과 구독 설정, DOM의 직접 조작이 부수 효과에 해당합니다.

useEffect(() => {
  // 부수 효과 코드
  // 이 setup은 컴포넌트가 commit된 뒤 실행됩니다.

  return () => {
    // 클린업(Clean-up) 함수 (선택 사항)
    // 의존성이 바뀐 다음 setup 전이나 언마운트 때 실행됩니다.
    // 구독 해제, 타이머 클리어 등 정리 작업을 수행합니다.
  };
}, [dependencies]); // 의존성 배열 (선택 사항)

의존성 배열을 생략하면 컴포넌트가 커밋될 때마다 Effect가 다시 실행됩니다. 빈 배열([])은 setup이 읽는 반응형 값이 없다는 뜻입니다. 프로덕션에서는 보통 처음 마운트한 뒤의 setup과 언마운트할 때의 cleanup에 대응하지만, 개발 모드의 Strict Mode는 버그를 찾으려고 setup → cleanup → setup을 한 번 더 점검하므로 “정확히 한 번”만 실행된다는 뜻은 아닙니다. 값이 든 배열([dep1, dep2])이면 React가 각 값을 이전 값과 Object.is로 비교해, 하나라도 다르면 이전 cleanup을 실행한 뒤 새 값으로 setup을 실행합니다.

Effect 안에서 읽는 props와 state, 컴포넌트 본문의 변수와 함수 같은 반응형 값은 모두 의존성에 넣어야 합니다. 의존성 배열은 성능을 마음대로 조절하는 목록이 아니라, setup이 무엇과 동기화하는지를 밝히는 선언입니다.


데이터 페칭의 기본 원리

데이터 요청은 대표적인 비동기 부수 효과이며, useEffect로 데이터를 가져올 때는 보통 세 가지를 갖춥니다. 처음에 데이터를 불러올 때 setup이 반응형 값을 읽지 않는다면 빈 의존성 배열 []을 쓰되, Strict Mode의 추가 setup에서도 결과가 안전하도록 cleanup이나 오래된 결과를 거르는 장치를 함께 설계합니다. 데이터를 가져오는 중인지, 성공했는지, 실패했는지 사용자에게 알리도록 useState로 로딩과 오류, 데이터의 상태를 관리합니다.

마지막으로 정리와 최신성을 확인합니다. 요청이 더 이상 필요 없을 때는 cleanup에서 취소하고, 늦게 끝난 작업은 그것이 현재의 요청인지 확인한 뒤에 상태에 반영합니다. React 18부터 언마운트된 컴포넌트의 상태 갱신에 대한 경고는 없어졌지만, cleanup이 없으면 네트워크나 구독 같은 자원이 남을 수 있고, 아직 마운트된 컴포넌트에서도 오래된 응답이 최신 화면을 덮어쓰는 문제는 그대로 생길 수 있습니다.


실제 예제: 사용자 목록 가져오기

로컬 목(mock) API인 json-server를 http://localhost:4000에 띄우고, 사용자 목록을 가져오는 컴포넌트를 만들어 봅니다. 4000번은 목 API, 4100번은 별도의 백엔드나 실시간 서버처럼 포트를 나눠 두면, 여러 트랙을 함께 공부할 때 충돌을 줄일 수 있습니다.

이 글만 따라 한다면 React 프로젝트의 루트에 다음 db.json을 만듭니다. 뒤의 게시판 실습에서 쓰는 posts만 있는 데이터와 다르므로, /users 예제를 실행하려면 users도 있어야 합니다. 되풀이해 시험하기 전에 되돌릴 수 있도록 이 파일을 db.seed.json으로 복사해 두세요.

db.json · 사용자와 게시글 최소 입력
{
  "users": [
    { "id": "1", "firstName": "민", "lastName": "안", "username": "min", "email": "[email protected]", "phone": "010-0000-0001", "domain": "example.test", "company": { "name": "도서관" } },
    { "id": "5", "firstName": "서", "lastName": "이", "username": "seo", "email": "[email protected]", "phone": "010-0000-0005", "company": { "name": "자료실" } }
  ],
  "posts": [
    { "id": "1", "userId": 1, "title": "첫 게시물", "body": "요청 흐름 연습" }
  ]
}

json-server 1.x는 데이터 파일을 인수로 받으며 파일이 바뀌면 자동으로 다시 읽으므로, 예전 판의 --watch 옵션은 더 이상 필요 없습니다(JSON Server 저장소). 이 목 API는 독자의 실습용 4000번 서버이며 이 교재 사이트의 개발 서버와는 별개입니다.

Mock API · 현행 v1 명령 형태
npx json-server db.json --port 4000

먼저 브라우저에서 http://localhost:4000/users와 /posts/1이 JSON을 돌려주는지 확인한 뒤 React와 연결합니다. 목록에는 “민 안 (min)”과 “서 이 (seo)”가 나타나고, /users/5의 상세 화면에서 도메인은 데이터에 없으므로 -로 보입니다. Vite의 React 템플릿에서는 JSX가 들어 있는 UserList, UserProfileById, CancellableFetch를 모두 .jsx로 저장합니다.

기본 Fetch API 사용 예제

src/components/UserList.jsx
import React, { useState, useEffect } from 'react';

function UserList() {
  const [users, setUsers] = useState([]);        // 사용자 데이터를 저장할 상태
  const [loading, setLoading] = useState(true);  // 로딩 상태
  const [error, setError] = useState(null);      // 에러 상태

  useEffect(() => {
    let active = true; // 이 setup이 소유한 결과만 반영

    // 데이터를 가져오는 비동기 함수 정의 (async/await 사용)
    const fetchUsers = async () => {
      try {
        setLoading(true); // 요청 시작 시 로딩 상태를 true로 설정
        setError(null);
        const response = await fetch('http://localhost:4000/users');

        if (!response.ok) { // HTTP 상태 코드가 200번대가 아니면 에러 발생
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        const data = await response.json(); // 응답을 JSON 형태로 파싱
        if (active) {
          setUsers(Array.isArray(data) ? data : []);
        }
      } catch (err) {
        if (active) {
          setError(err); // 현재 setup의 에러만 반영
        }
      } finally {
        if (active) {
          setLoading(false); // 오래된 setup이 새 loading을 끄지 못하게 함
        }
      }
    };

    fetchUsers(); // 비동기 함수 호출

    return () => {
      active = false;
    };
  }, []); // 이 setup이 읽는 반응형 값이 없음

  if (loading) {
    return <div style={{ textAlign: 'center', padding: '20px' }}>사용자 정보를 불러오는 중...</div>;
  }

  if (error) {
    return <div style={{ textAlign: 'center', padding: '20px', color: 'red' }}>오류 발생: {error.message}</div>;
  }

  return (
    <div style={{ maxWidth: '800px', margin: '20px auto', padding: '20px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 5px rgba(0,0,0,0.05)' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50' }}>사용자 목록</h2>
      <ul style={{ listStyle: 'none', padding: 0 }}>
        {users.map(user => (
          <li
            key={user.id}
            style={{
              padding: '15px',
              marginBottom: '10px',
              border: '1px solid #eee',
              borderRadius: '5px',
              backgroundColor: '#fefefe',
              boxShadow: '0 1px 3px rgba(0,0,0,0.02)',
            }}
          >
            <strong style={{ color: '#3498db' }}>{user.firstName} {user.lastName}</strong> ({user.username})
            <br />
            <small>{user.email}</small>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default UserList;

Effect 안의 지역 변수 active는 이 setup이 시작한 요청의 결과만 화면에 반영하게 하는 표시입니다. 컴포넌트가 사라지거나 개발 모드의 Strict Mode가 setup을 다시 실행하면 정리 함수가 active를 false로 바꾸므로, 늦게 끝난 이전 요청이 state를 덮어쓰지 않습니다. 화면은 loading, error, 목록 순서로 검사해 그중 하나만 그립니다.

App.jsx에 추가

다음은 App.jsx의 일부입니다. 기존 파일에 UserList를 가져와 넣고, export default App;과 진입 파일의 import를 갖춘 전체 파일로 통합합니다.

src/App.jsx (일부)
import React from 'react';
import UserList from './components/UserList'; // UserList 임포트

function App() {
  return (
    <div className="App">
      {/* ... 기존 Navbar, Routes 등 */}
      <UserList /> {/* UserList 컴포넌트 추가 */}
    </div>
  );
}

UserList를 App.jsx에 넣고 실행하면, “사용자 정보를 불러오는 중...”이 잠시 보인 뒤 목 API에서 가져온 사용자 목록이 나타납니다. 개발자 도구의 Network 탭에서는 실제로 API 요청이 일어나는 것도 확인할 수 있습니다. 데이터를 가져오는 컴포넌트에서는 요청 코드보다 상태의 전환을 정확히 잡는 것이 중요하므로, loading과 error, users가 어떤 순서로 바뀌는지 먼저 정해 두면 렌더 분기가 흔들리지 않습니다.

응답의 모양과 HTTP 성공은 별개입니다

원래의 UserList는 200 응답이 배열이 아니어도 []로 바꾸는 너그러운 예제입니다. 깨진 응답을 정상적인 빈 목록과 구별하려면, const data = await response.json(); 바로 뒤, 상태를 저장하기 전에 아래 검사를 더합니다. JSON 문법의 파싱과 HTTP 상태의 확인, 사용자 목록의 모양 확인은 서로 다른 검증입니다.

if (!Array.isArray(data)) {
  throw new Error('GET /users 응답이 배열이 아닙니다.');
}

이 검사는 배열인지만 확인합니다. 실제 서비스에서 꼭 필요한 id나 이름 필드까지 보장하려면 항목마다 검증도 설계해야 합니다. 성공한 빈 배열은 오류가 아니며, 원래의 화면에는 목록 제목만 남습니다. 빈 결과의 안내를 더하고 싶다면 loading과 error를 검사한 뒤에 빈 배열의 분기를 둡니다.


async/await 사용 시 주의사항

useEffect에 넘기는 함수는 동기 함수여야 하므로, Effect의 콜백 자체를 async로 만들면 안 됩니다. 아래는 흔히 하는 잘못된 형태로, 겉보기에는 자연스럽지만 React가 정리 함수를 받아야 할 자리에서 Promise를 받게 됩니다. 개발 모드에서는 이때 Console에 경고가 나타날 수 있습니다.

useEffect(async () => { // 🚨 이렇게 직접 async로 만들지 마세요!
  const response = await fetch(...);
  const data = await response.json();
  // ...
}, []);

async 함수는 언제나 Promise를 돌려주지만, Effect의 setup은 cleanup 함수나 undefined만 돌려줘야 합니다. Promise는 cleanup 함수가 아니므로 setup 자체를 async로 선언하면 안 됩니다. 대신 useEffect 안에서 비동기 함수를 정의하고 바로 호출하는 패턴을 씁니다.

useEffect(() => {
  const fetchData = async () => { // 🌟 useEffect 내부에서 async 함수를 정의
    // ... 비동기 로직
  };

  fetchData(); // 🌟 정의된 async 함수를 즉시 호출
}, []);

앞의 UserList 예제가 바로 이 패턴을 썼습니다. 이 패턴에서도 비동기 작업의 완료는 앞에서 정한 Loading, Success, Error의 상태 전이와 최신 요청의 확인을 따라야 합니다.


의존성 배열에 따른 데이터 페칭

데이터를 언제나 마운트할 때만 가져오는 것은 아닙니다. 페이지를 넘기거나 필터를 바꾸는 것처럼, 특정한 값이 바뀔 때마다 데이터를 다시 가져와야 하는 경우도 있습니다. 이때 Effect가 읽는 userId나 검색어, 필터 같은 값은 모두 의존성에 둡니다. 반대로 렌더마다 새로 만드는 options 객체를 그대로 의존성에 넣으면 내용이 같아도 참조가 달라 Effect가 계속 다시 실행될 수 있으므로, 필요한 원시 값에만 의존하도록 구조를 단순하게 만듭니다. 사용자 ID에 따라 그 사용자의 정보를 가져오는 예를 봅니다.

src/components/UserProfileById.jsx
import React, { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router'; // React Router 사용 예시

function UserProfileById() {
  const { userId } = useParams(); // URL 파라미터에서 userId 가져옴
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const latestRequest = useRef(0);

  useEffect(() => {
    const requestId = ++latestRequest.current;
    let active = true;
    const isCurrent = () => active && requestId === latestRequest.current;

    if (!userId) { // userId가 없는 경우 (예: 초기 렌더링 시점에 파라미터가 아직 없을 때)
      setUser(null);
      setLoading(false);
      setError(new Error('사용자 ID가 제공되지 않았습니다.'));
      return () => {
        active = false;
      };
    }

    const controller = new AbortController();

    const fetchUser = async () => {
      try {
        setLoading(true);
        setError(null);
        // userId가 변경될 때마다 새로운 URL로 요청
        const response = await fetch(
          `http://localhost:4000/users/${userId}`,
          { signal: controller.signal },
        );

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        const data = await response.json();
        if (isCurrent()) {
          setUser(data);
        }
      } catch (err) {
        if (err.name !== 'AbortError' && isCurrent()) {
          setError(err);
        }
      } finally {
        // 오래된 요청의 finally가 새 요청의 loading을 끄지 못하게 함
        if (isCurrent()) {
          setLoading(false);
        }
      }
    };

    fetchUser();

    return () => {
      active = false;
      controller.abort();
    };
  }, [userId]); // userId가 바뀌면 이전 cleanup 뒤 새 요청 시작

  if (loading) {
    return <div style={{ textAlign: 'center', padding: '20px' }}>사용자 정보를 불러오는 중...</div>;
  }

  if (error) {
    return <div style={{ textAlign: 'center', padding: '20px', color: 'red' }}>오류 발생: {error.message}</div>;
  }

  if (!user) {
    return <div style={{ textAlign: 'center', padding: '20px' }}>사용자를 찾을 수 없습니다.</div>;
  }

  return (
    <div style={{ maxWidth: '600px', margin: '20px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 5px rgba(0,0,0,0.05)' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '20px' }}>{user.firstName} {user.lastName}님의 프로필</h2>
      <p><strong>아이디:</strong> {user.username}</p>
      <p><strong>이메일:</strong> {user.email}</p>
      <p><strong>전화번호:</strong> {user.phone}</p>
      <p><strong>도메인:</strong> {user.domain || '-'}</p>
      <p><strong>회사:</strong> {user.company?.name || '-'}</p>
    </div>
  );
}

export default UserProfileById;

라우트는 App.jsx나 관련된 라우트 파일에서 설정합니다. 이 예제도 6장과 같은 React Router 8의 react-router 패키지를 씁니다.

src/App.jsx (일부)
import React from 'react';
import { BrowserRouter, Routes, Route } from 'react-router';
import UserProfileById from './components/UserProfileById'; // 새 컴포넌트 임포트
// ... 다른 임포트

function App() {
  return (
    <BrowserRouter>
      {/* ... Navbar 등 */}
      <div className="main-content">
        <Routes>
          {/* ... 다른 라우트들 */}
          {/* 동적 라우트 설정 */}
          <Route path="/users/:userId" element={<UserProfileById />} />
          {/* ... 404 라우트 */}
        </Routes>
      </div>
    </BrowserRouter>
  );
}

개발 서버의 /users/1이나 /users/5처럼 userId를 바꿔 가며 들어가면, 그 ID의 사용자 정보를 다시 불러옵니다. userId가 의존성 배열에 있기 때문입니다. 각 setup은 자기가 시작될 때의 userId를 붙잡고 있으며, cleanup과 요청 ID가 그 가운데 가장 최근 요청만 상태를 갱신하게 합니다.


클린업 함수와 요청 취소 (AbortController)

Effect cleanup과 요청 ID가 최신 요청만 상태에 반영한다 — 상태 추적

active/request ID가 cleanup에서 무효화되며 성공/catch/finally 모두 같은 gate를 통과해야 한다. cleanup은 모든 성공의 필수 단계가 아니고 입력 변경/제거 사건임을 보강한다.

Effect setup, 의존성 비교, cleanup과 AbortController, AbortError 처리, 최신 request ID 확인, finally loading 확인으로 오래된 요청을 차단하는 수명주기

Setup → cleanup → latest-wins commit

Effect 정리와 최신 요청의 쓰기 권한

cleanup은 이전 요청을 비활성화하고 취소를 요청하며, 요청 ID gate는 이미 완료 중인 오래된 작업의 모든 상태 쓰기를 막습니다. 데이터·오류뿐 아니라 finally의 loading 종료까지 같은 최신성 조건을 통과해야 새 요청의 화면을 보존할 수 있습니다.

취소와 최신 요청의 쓰기 권한setup이 active와 요청 ID를 캡처합니다. 비동기 완료는 현재 권한 gate를 통과할 때만 data·error·loading을 쓰며 이전 완료는 버립니다. 의존성 변경이나 unmount가 일어날 때만 별도 cleanup 사건이 active를 닫고 abort와 ID 무효화를 수행합니다. 점선은 이 조건부 정리 사건과 gate 입력의 변경이며 모든 요청이 성공 전에 cleanup된다는 뜻이 아닙니다.setup · 입력 캡처active · request ID비동기 완료성공 · 실패 · finallyactive이고현재 요청 ID인가?현재 상태 쓰기data · error · loading이전 완료 버리기새 화면 상태 유지변경 또는 unmountactive=false · abortID 무효화예아니요정리 사건무효화점선: 조건부 정리와 쓰기 권한 무효화
취소와 최신 요청의 쓰기 권한setup이 active와 요청 ID를 캡처합니다. 비동기 완료는 현재 권한 gate를 통과할 때만 data·error·loading을 쓰며 이전 완료는 버립니다. 의존성 변경이나 unmount가 일어날 때만 별도 cleanup 사건이 active를 닫고 abort와 ID 무효화를 수행합니다. 점선은 이 조건부 정리 사건과 gate 입력의 변경이며 모든 요청이 성공 전에 cleanup된다는 뜻이 아닙니다.setup · 입력 캡처activerequest ID비동기 완료성공 · 실패finallyactive이고현재 요청ID인가?현재 상태 쓰기data · errorloading이전 완료 버리기새 화면 상태 유지변경 / unmountactive=falseabort · ID 무효화예아니요별도 정리 사건점선: 조건부 cleanup성공 단계와 구별정리 뒤 이전 완료는 버림
  1. commit 뒤 setup에서 요청 정체성을 고정

    Effect callback은 cleanup 함수나 undefined를 반환하는 동기 setup으로 두고 내부 async 함수를 호출합니다. 현재 dependency snapshot, 새 requestId, AbortController를 한 요청으로 묶습니다. []은 반응형 의존성이 없다는 뜻이며 개발 StrictMode에서는 추가 setup→cleanup→setup 점검이 있으므로 “정확히 한 번” 계약이 아닙니다.

  2. dependency가 바뀌면 이전 cleanup 뒤 새 setup

    React는 Object.is로 userId, query, filter 같은 반응형 값을 비교합니다. 누락한 값은 stale 데이터의 원인이 되고, 렌더마다 새로 만든 options 객체는 Effect를 불필요하게 반복시킬 수 있습니다.

  3. cleanup은 비활성화 후 abort

    다음 setup 전이나 unmount 때 이번 요청을 비활성으로 표시하고 abort()를 호출합니다. fetch가 AbortError로 거부되면 사용자 오류로 저장하지 않습니다. React 18에서 unmounted setState 경고는 제거됐지만, 네트워크·구독 자원과 stale write 문제는 그대로 남습니다.

  4. 완료 시 active와 최신 request ID를 함께 검사

    응답이 먼저 시작된 순서나 도착한 순서가 아니라 “아직 active이고 ID가 latest와 같은가”로 data와 error 쓰기를 결정합니다. abort보다 먼저 끝난 작업이나 signal을 따르지 않는 후속 처리도 이 gate에서 버립니다.

  5. finally도 같은 gate를 통과

    이전 요청의 finally가 무조건 setLoading(false)를 실행하면 새 요청이 아직 진행 중인데 로딩 UI를 끌 수 있습니다. 최신 요청일 때만 loading을 끝내야 latest-wins 계약이 완성됩니다.

R1 · stale

A 요청 뒤 입력이 B로 변경

A cleanup이 controller를 중단하고 active를 닫습니다. A의 성공·실패·finally가 나중에 이어져도 ID 불일치로 data, error, loading을 모두 쓰지 않습니다.

R2 · current

B 요청만 현재 화면을 소유

B의 ID가 latest와 일치할 때만 성공 또는 실제 오류를 반영하고 loading을 끝냅니다. 그 상태가 Loading, Success, Error 렌더 분기를 선택합니다.

AbortController는 불필요한 fetch 작업을 멈추는 자원 계약이고, active/request ID는 오래된 비동기 완료가 현재 React state를 바꾸지 못하게 하는 쓰기 계약입니다. 둘은 대체 관계가 아닙니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
원도표 설명cleanup은 이전 요청을 비활성화하고 취소를 요청하며, 요청 ID gate는 이미 완료 중인 오래된 작업의 모든 상태 쓰기를 막습니다. 데이터·오류뿐 아니라 finally의 loading 종료까지 같은 최신성 조건을 통과해야 새 요청의 화면을 보존할 수 있습니다.
원도표 설명Effect callback은 cleanup 함수나 undefined를 반환하는 동기 setup으로 두고 내부 async 함수를 호출합니다. 현재 dependency snapshot, 새 requestId, AbortController를 한 요청으로 묶습니다. []은 반응형 의존성이 없다는 뜻이며 개발 StrictMode에서는 추가 setup→cleanup→setup 점검이 있으므로 “정확히 한 번” 계약이 아닙니다.
원도표 조건·주석commit 뒤 setup에서 요청 정체성을 고정 Effect callback은 cleanup 함수나 undefined를 반환하는 동기 setup으로 두고 내부 async 함수를 호출합니다. 현재 dependency snapshot, 새 requestId, AbortController를 한 요청으로 묶습니다. []은 반응형 의존성이 없다는 뜻이며 개발 StrictMode에서는 추가 setup→cleanup→setup 점검이 있으므로 “정확히 한 번” 계약이 아닙니다.
원도표 설명React는 Object.is로 userId, query, filter 같은 반응형 값을 비교합니다. 누락한 값은 stale 데이터의 원인이 되고, 렌더마다 새로 만든 options 객체는 Effect를 불필요하게 반복시킬 수 있습니다.
원도표 조건·주석dependency가 바뀌면 이전 cleanup 뒤 새 setup React는 Object.is로 userId, query, filter 같은 반응형 값을 비교합니다. 누락한 값은 stale 데이터의 원인이 되고, 렌더마다 새로 만든 options 객체는 Effect를 불필요하게 반복시킬 수 있습니다.
원도표 설명다음 setup 전이나 unmount 때 이번 요청을 비활성으로 표시하고 abort()를 호출합니다. fetch가 AbortError로 거부되면 사용자 오류로 저장하지 않습니다. React 18에서 unmounted setState 경고는 제거됐지만, 네트워크·구독 자원과 stale write 문제는 그대로 남습니다.
원도표 조건·주석cleanup은 비활성화 후 abort 다음 setup 전이나 unmount 때 이번 요청을 비활성으로 표시하고 abort()를 호출합니다. fetch가 AbortError로 거부되면 사용자 오류로 저장하지 않습니다. React 18에서 unmounted setState 경고는 제거됐지만, 네트워크·구독 자원과 stale write 문제는 그대로 남습니다.
원도표 설명응답이 먼저 시작된 순서나 도착한 순서가 아니라 “아직 active이고 ID가 latest와 같은가”로 data와 error 쓰기를 결정합니다. abort보다 먼저 끝난 작업이나 signal을 따르지 않는 후속 처리도 이 gate에서 버립니다.
원도표 조건·주석완료 시 active와 최신 request ID를 함께 검사 응답이 먼저 시작된 순서나 도착한 순서가 아니라 “아직 active이고 ID가 latest와 같은가”로 data와 error 쓰기를 결정합니다. abort보다 먼저 끝난 작업이나 signal을 따르지 않는 후속 처리도 이 gate에서 버립니다.
원도표 설명finally도 같은 gate를 통과
원도표 설명이전 요청의 finally가 무조건 setLoading(false)를 실행하면 새 요청이 아직 진행 중인데 로딩 UI를 끌 수 있습니다. 최신 요청일 때만 loading을 끝내야 latest-wins 계약이 완성됩니다.
원도표 조건·주석finally도 같은 gate를 통과 이전 요청의 finally가 무조건 setLoading(false)를 실행하면 새 요청이 아직 진행 중인데 로딩 UI를 끌 수 있습니다. 최신 요청일 때만 loading을 끝내야 latest-wins 계약이 완성됩니다.
원도표 설명A cleanup이 controller를 중단하고 active를 닫습니다. A의 성공·실패·finally가 나중에 이어져도 ID 불일치로 data, error, loading을 모두 쓰지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
setup 권한active: true / ID: 현재 IDactive와 request ID를 캡처한다각 비동기 완료가 자기 권한을 확인해야 한다
조건부 cleanupoldactive: false / oldID: 현재와 다름변경이나 unmount 사건에서 active=false, abort, ID 무효화를 수행한다cleanup을 모든 성공 전에 필수 실행하는 것으로 그리지 않는다
모든 쓰기의 gateoldcompletion: 무시 / current: 허용success·catch·finally 모두 권한을 통과할 때만 상태를 쓴다이전 finally가 새로운 loading을 끄지 못하게 한다

Effect cleanup과 요청 ID가 최신 요청만 상태에 반영한다 — 상태 추적

active/request ID가 cleanup에서 무효화되며 성공/catch/finally 모두 같은 gate를 통과해야 한다. cleanup은 모든 성공의 필수 단계가 아니고 입력 변경/제거 사건임을 보강한다.

Effect setup, 의존성 비교, cleanup과 AbortController, AbortError 처리, 최신 request ID 확인, finally loading 확인으로 오래된 요청을 차단하는 수명주기

Setup → cleanup → latest-wins commit

Effect 정리와 최신 요청의 쓰기 권한

cleanup은 이전 요청을 비활성화하고 취소를 요청하며, 요청 ID gate는 이미 완료 중인 오래된 작업의 모든 상태 쓰기를 막습니다. 데이터·오류뿐 아니라 finally의 loading 종료까지 같은 최신성 조건을 통과해야 새 요청의 화면을 보존할 수 있습니다.

취소와 최신 요청의 쓰기 권한setup이 active와 요청 ID를 캡처합니다. 비동기 완료는 현재 권한 gate를 통과할 때만 data·error·loading을 쓰며 이전 완료는 버립니다. 의존성 변경이나 unmount가 일어날 때만 별도 cleanup 사건이 active를 닫고 abort와 ID 무효화를 수행합니다. 점선은 이 조건부 정리 사건과 gate 입력의 변경이며 모든 요청이 성공 전에 cleanup된다는 뜻이 아닙니다.setup · 입력 캡처active · request ID비동기 완료성공 · 실패 · finallyactive이고현재 요청 ID인가?현재 상태 쓰기data · error · loading이전 완료 버리기새 화면 상태 유지변경 또는 unmountactive=false · abortID 무효화예아니요정리 사건무효화점선: 조건부 정리와 쓰기 권한 무효화
취소와 최신 요청의 쓰기 권한setup이 active와 요청 ID를 캡처합니다. 비동기 완료는 현재 권한 gate를 통과할 때만 data·error·loading을 쓰며 이전 완료는 버립니다. 의존성 변경이나 unmount가 일어날 때만 별도 cleanup 사건이 active를 닫고 abort와 ID 무효화를 수행합니다. 점선은 이 조건부 정리 사건과 gate 입력의 변경이며 모든 요청이 성공 전에 cleanup된다는 뜻이 아닙니다.setup · 입력 캡처activerequest ID비동기 완료성공 · 실패finallyactive이고현재 요청ID인가?현재 상태 쓰기data · errorloading이전 완료 버리기새 화면 상태 유지변경 / unmountactive=falseabort · ID 무효화예아니요별도 정리 사건점선: 조건부 cleanup성공 단계와 구별정리 뒤 이전 완료는 버림
  1. commit 뒤 setup에서 요청 정체성을 고정

    Effect callback은 cleanup 함수나 undefined를 반환하는 동기 setup으로 두고 내부 async 함수를 호출합니다. 현재 dependency snapshot, 새 requestId, AbortController를 한 요청으로 묶습니다. []은 반응형 의존성이 없다는 뜻이며 개발 StrictMode에서는 추가 setup→cleanup→setup 점검이 있으므로 “정확히 한 번” 계약이 아닙니다.

  2. dependency가 바뀌면 이전 cleanup 뒤 새 setup

    React는 Object.is로 userId, query, filter 같은 반응형 값을 비교합니다. 누락한 값은 stale 데이터의 원인이 되고, 렌더마다 새로 만든 options 객체는 Effect를 불필요하게 반복시킬 수 있습니다.

  3. cleanup은 비활성화 후 abort

    다음 setup 전이나 unmount 때 이번 요청을 비활성으로 표시하고 abort()를 호출합니다. fetch가 AbortError로 거부되면 사용자 오류로 저장하지 않습니다. React 18에서 unmounted setState 경고는 제거됐지만, 네트워크·구독 자원과 stale write 문제는 그대로 남습니다.

  4. 완료 시 active와 최신 request ID를 함께 검사

    응답이 먼저 시작된 순서나 도착한 순서가 아니라 “아직 active이고 ID가 latest와 같은가”로 data와 error 쓰기를 결정합니다. abort보다 먼저 끝난 작업이나 signal을 따르지 않는 후속 처리도 이 gate에서 버립니다.

  5. finally도 같은 gate를 통과

    이전 요청의 finally가 무조건 setLoading(false)를 실행하면 새 요청이 아직 진행 중인데 로딩 UI를 끌 수 있습니다. 최신 요청일 때만 loading을 끝내야 latest-wins 계약이 완성됩니다.

R1 · stale

A 요청 뒤 입력이 B로 변경

A cleanup이 controller를 중단하고 active를 닫습니다. A의 성공·실패·finally가 나중에 이어져도 ID 불일치로 data, error, loading을 모두 쓰지 않습니다.

R2 · current

B 요청만 현재 화면을 소유

B의 ID가 latest와 일치할 때만 성공 또는 실제 오류를 반영하고 loading을 끝냅니다. 그 상태가 Loading, Success, Error 렌더 분기를 선택합니다.

AbortController는 불필요한 fetch 작업을 멈추는 자원 계약이고, active/request ID는 오래된 비동기 완료가 현재 React state를 바꾸지 못하게 하는 쓰기 계약입니다. 둘은 대체 관계가 아닙니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
원도표 설명cleanup은 이전 요청을 비활성화하고 취소를 요청하며, 요청 ID gate는 이미 완료 중인 오래된 작업의 모든 상태 쓰기를 막습니다. 데이터·오류뿐 아니라 finally의 loading 종료까지 같은 최신성 조건을 통과해야 새 요청의 화면을 보존할 수 있습니다.
원도표 설명Effect callback은 cleanup 함수나 undefined를 반환하는 동기 setup으로 두고 내부 async 함수를 호출합니다. 현재 dependency snapshot, 새 requestId, AbortController를 한 요청으로 묶습니다. []은 반응형 의존성이 없다는 뜻이며 개발 StrictMode에서는 추가 setup→cleanup→setup 점검이 있으므로 “정확히 한 번” 계약이 아닙니다.
원도표 조건·주석commit 뒤 setup에서 요청 정체성을 고정 Effect callback은 cleanup 함수나 undefined를 반환하는 동기 setup으로 두고 내부 async 함수를 호출합니다. 현재 dependency snapshot, 새 requestId, AbortController를 한 요청으로 묶습니다. []은 반응형 의존성이 없다는 뜻이며 개발 StrictMode에서는 추가 setup→cleanup→setup 점검이 있으므로 “정확히 한 번” 계약이 아닙니다.
원도표 설명React는 Object.is로 userId, query, filter 같은 반응형 값을 비교합니다. 누락한 값은 stale 데이터의 원인이 되고, 렌더마다 새로 만든 options 객체는 Effect를 불필요하게 반복시킬 수 있습니다.
원도표 조건·주석dependency가 바뀌면 이전 cleanup 뒤 새 setup React는 Object.is로 userId, query, filter 같은 반응형 값을 비교합니다. 누락한 값은 stale 데이터의 원인이 되고, 렌더마다 새로 만든 options 객체는 Effect를 불필요하게 반복시킬 수 있습니다.
원도표 설명다음 setup 전이나 unmount 때 이번 요청을 비활성으로 표시하고 abort()를 호출합니다. fetch가 AbortError로 거부되면 사용자 오류로 저장하지 않습니다. React 18에서 unmounted setState 경고는 제거됐지만, 네트워크·구독 자원과 stale write 문제는 그대로 남습니다.
원도표 조건·주석cleanup은 비활성화 후 abort 다음 setup 전이나 unmount 때 이번 요청을 비활성으로 표시하고 abort()를 호출합니다. fetch가 AbortError로 거부되면 사용자 오류로 저장하지 않습니다. React 18에서 unmounted setState 경고는 제거됐지만, 네트워크·구독 자원과 stale write 문제는 그대로 남습니다.
원도표 설명응답이 먼저 시작된 순서나 도착한 순서가 아니라 “아직 active이고 ID가 latest와 같은가”로 data와 error 쓰기를 결정합니다. abort보다 먼저 끝난 작업이나 signal을 따르지 않는 후속 처리도 이 gate에서 버립니다.
원도표 조건·주석완료 시 active와 최신 request ID를 함께 검사 응답이 먼저 시작된 순서나 도착한 순서가 아니라 “아직 active이고 ID가 latest와 같은가”로 data와 error 쓰기를 결정합니다. abort보다 먼저 끝난 작업이나 signal을 따르지 않는 후속 처리도 이 gate에서 버립니다.
원도표 설명finally도 같은 gate를 통과
원도표 설명이전 요청의 finally가 무조건 setLoading(false)를 실행하면 새 요청이 아직 진행 중인데 로딩 UI를 끌 수 있습니다. 최신 요청일 때만 loading을 끝내야 latest-wins 계약이 완성됩니다.
원도표 조건·주석finally도 같은 gate를 통과 이전 요청의 finally가 무조건 setLoading(false)를 실행하면 새 요청이 아직 진행 중인데 로딩 UI를 끌 수 있습니다. 최신 요청일 때만 loading을 끝내야 latest-wins 계약이 완성됩니다.
원도표 설명A cleanup이 controller를 중단하고 active를 닫습니다. A의 성공·실패·finally가 나중에 이어져도 ID 불일치로 data, error, loading을 모두 쓰지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
setup 권한active: true / ID: 현재 IDactive와 request ID를 캡처한다각 비동기 완료가 자기 권한을 확인해야 한다
조건부 cleanupoldactive: false / oldID: 현재와 다름변경이나 unmount 사건에서 active=false, abort, ID 무효화를 수행한다cleanup을 모든 성공 전에 필수 실행하는 것으로 그리지 않는다
모든 쓰기의 gateoldcompletion: 무시 / current: 허용success·catch·finally 모두 권한을 통과할 때만 상태를 쓴다이전 finally가 새로운 loading을 끄지 못하게 한다

위 그림은 Effect의 cleanup과 요청 ID가 최신 요청만 상태에 반영하게 하는 과정을 보여 줍니다. setup이 active와 요청 ID를 붙잡아 두면, 성공이든 실패든 finally든 비동기 작업이 끝났을 때 “아직 active이고 현재 요청 ID인가?”를 확인합니다. 그렇다면 data와 error, loading을 쓰고, 아니라면 이전의 완료를 버려 새 화면의 상태를 지킵니다. 의존성이 바뀌거나 언마운트되면 별도의 정리 사건이 일어나 active를 닫고 abort()를 호출하며 요청 ID를 무효로 만듭니다. 그림의 점선은 이 조건부 정리와 쓰기 권한의 무효화를 나타낼 뿐, 모든 요청이 성공하기 전에 정리된다는 뜻은 아닙니다.

그림 아래의 다섯 칸은 각 단계의 규칙을 정리합니다. Effect의 콜백은 cleanup 함수나 undefined를 돌려주는 동기 setup으로 두고, 그 안에서 현재 의존성의 스냅샷과 새 requestId, AbortController를 하나의 요청으로 묶습니다. 의존성이 바뀌면 React가 Object.is로 비교해 이전 cleanup 뒤에 새 setup을 실행하며, 의존성을 빠뜨리면 오래된 데이터가 남고 렌더마다 새로 만든 객체는 Effect를 불필요하게 되풀이합니다. cleanup은 이번 요청을 비활성으로 표시한 뒤 abort()를 호출하며, fetch가 AbortError로 거부되면 사용자에게 보여 줄 오류로 저장하지 않습니다. 완료할 때는 시작이나 도착의 순서가 아니라 활성 여부와 최신 ID로 쓸지를 정하고, finally도 같은 확인을 거쳐 이전 요청이 새 요청의 로딩 표시를 끄지 못하게 합니다.

그림의 맨 아래 두 칸은 실제 순서의 예입니다. A 요청 뒤에 입력이 B로 바뀌면 A의 cleanup이 컨트롤러를 중단하고 active를 닫으므로, A의 성공이나 실패, finally가 나중에 이어져도 ID가 맞지 않아 data와 error, loading을 모두 쓰지 않습니다. B는 자기 ID가 최신 ID와 같을 때만 성공이나 실제 오류를 반영하고 로딩을 끝냅니다. 그림의 마지막 설명처럼 AbortController는 불필요한 fetch 작업을 멈추는 자원의 정리이고, active와 요청 ID는 오래된 비동기 완료가 현재 React state를 바꾸지 못하게 막는 쓰기의 제한이라서 서로를 대신하지 않습니다.

React 18은 언마운트된 컴포넌트에 대한 상태 갱신 경고를 없앴습니다. 그 갱신이 곧바로 메모리 누수를 뜻하지는 않았기 때문입니다. 하지만 경고가 사라졌다고 작업을 정리할 필요까지 없어진 것은 아닙니다. AbortController로 불필요한 fetch를 멈추고, active나 요청 ID로 오래된 완료가 현재 상태를 쓰지 못하게 해야 하며, 특히 이전 요청의 finally가 새 요청의 로딩 상태를 끄지 않도록 같은 확인을 적용합니다.

src/components/CancellableFetch.jsx
import React, { useState, useEffect } from 'react';

function CancellableFetch() {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let active = true;
    const controller = new AbortController(); // AbortController 인스턴스 생성
    const signal = controller.signal; // 신호 가져오기

    const fetchData = async () => {
      try {
        setLoading(true);
        setError(null);
        const response = await fetch('http://localhost:4000/posts/1', { signal }); // fetch 옵션에 signal 전달

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        const json = await response.json();
        if (active) {
          setData(json);
        }
      } catch (err) {
        // AbortError는 사용자에게 보여줄 요청 실패가 아닙니다.
        if (err.name !== 'AbortError' && active) {
          setError(err);
        }
      } finally {
        if (active) {
          setLoading(false);
        }
      }
    };

    fetchData();

    // 클린업 함수: 컴포넌트 언마운트 또는 의존성 변경 시 실행
    return () => {
      active = false;     // 모든 후속 상태 쓰기 차단
      controller.abort(); // fetch 자원 중단 시도
    };
  }, []); // 이 setup이 읽는 반응형 값이 없음

  if (loading) return <div style={{ textAlign: 'center', padding: '20px' }}>데이터 로딩 중...</div>;
  if (error) return <div style={{ textAlign: 'center', padding: '20px', color: 'red' }}>에러: {error.message}</div>;

  return (
    <div style={{ maxWidth: '600px', margin: '20px auto', padding: '25px', border: '1px solid #ddd', borderRadius: '8px', boxShadow: '0 2px 5px rgba(0,0,0,0.05)' }}>
      <h2 style={{ textAlign: 'center', color: '#2c3e50', marginBottom: '20px' }}>게시글 상세</h2>
      <h3 style={{ color: '#3498db', marginBottom: '10px' }}>{data.title}</h3>
      <p>{data.body}</p>
      <p style={{ marginTop: '15px', fontSize: '0.9em', color: '#777' }}>
        (이 컴포넌트는 `AbortController`를 사용하여 마운트 해제 시 fetch 요청을 취소합니다.)
      </p>
    </div>
  );
}

export default CancellableFetch;

이 CancellableFetch를 시험하려면, 이 컴포넌트를 렌더링하는 부모를 만들어 잠시 뒤에 언마운트되게 하거나, 다른 화면으로 빠르게 이동하며 Network 탭을 확인해 볼 수 있습니다. 요청이 취소되면 브라우저 개발자 도구의 네트워크 상태에 취소된 요청으로 표시될 수 있습니다. 그 문구는 브라우저마다 다를 수 있으므로, 앱은 AbortError와 최신성의 확인을 기준으로 동작해야 합니다.


더 깊이: Effect에서 직접 요청하는 방식의 한계

React 문서는 Effect 안에서 fetch를 부르는 방식을, 완전히 클라이언트에서 도는 앱에서 흔히 쓰지만 손이 많이 가는 방법으로 설명하며 몇 가지 단점을 꼽습니다(React 문서의 Effect로 동기화하기). 먼저 Effect는 서버에서 실행되지 않으므로, 서버가 렌더링한 첫 HTML에는 데이터 없이 로딩 상태만 들어갑니다. 또 부모가 데이터를 받아 렌더링해야 자식이 자기 요청을 시작하는 네트워크 워터폴(network waterfall)이 생기기 쉬워, 필요한 데이터를 동시에 가져오는 것보다 느려집니다. 보통 미리 불러오거나 캐시하지도 않으므로 컴포넌트가 사라졌다 다시 나타나면 같은 데이터를 또 요청하고, 이 글에서 본 경쟁 상태(race condition)를 피하려면 준비 코드도 꽤 많이 필요합니다.

그래서 같은 문서는 프레임워크를 쓴다면 그 프레임워크에 들어 있는 데이터 요청 기능을, 아니라면 TanStack Query나 useSWR 같은 클라이언트 캐시, 또는 React Router의 데이터 기능을 먼저 검토하라고 권합니다. 이런 캐시를 직접 만든다면 안에서는 Effect를 쓰되, 중복 요청을 합치고 응답을 캐시하며 데이터를 미리 불러오거나 라우트로 끌어올려 워터폴을 줄이는 논리를 더해야 한다고 설명합니다. 이 글의 방식은 그 원리를 이해하는 데 알맞으며, 두 방법이 모두 맞지 않는 앱이라면 계속 Effect에서 직접 요청해도 된다고 문서도 덧붙입니다.


정리

  • useEffect의 setup은 커밋한 뒤에 실행되고, 의존성이 바뀌면 이전 cleanup 뒤에 새 setup이 실행되며, Strict Mode에서는 처음에 한 번 더 점검됩니다.
  • Effect의 콜백 자체를 async로 만들지 말고, 안에서 비동기 함수를 정의해 바로 호출합니다.
  • 요청 UI는 로딩과 오류, 데이터를 상태로 관리하고, response.ok와 응답의 모양을 따로 확인합니다.
  • Effect가 읽는 userId 같은 값은 의존성에 넣고, 렌더마다 새로 만드는 객체는 의존성에서 피합니다.
  • cleanup에서 abort()로 요청을 멈추고, active와 요청 ID로 오래된 완료가 데이터와 오류, finally의 로딩을 쓰지 못하게 합니다.
  • AbortError는 사용자에게 보여 줄 오류가 아니며, 취소와 오래된 완료는 새로운 결과 상태가 아닙니다.

연습 문제

1. 먼저 시작한 요청이 나중에 끝날 때

UserProfileById에서 /users/1의 요청 번호가 41이고, 입력을 바꾼 뒤 /users/5의 요청 번호가 42라고 가정합니다. 41의 cleanup이 끝났고 42는 아직 진행 중입니다. 41의 응답과 finally가 늦게 실행된 뒤 42의 응답이 정상적으로 끝난다면, 사용자 데이터와 loading, error는 어떻게 바뀌어야 할까요? abort()만 호출하고 요청 ID의 확인을 없앤 구현과 비교해 보세요.

해설 보기

41은 active가 false이고 최신 번호도 아니므로 사용자와 오류, 로딩 어느 상태도 쓰지 않습니다. 그래서 42가 진행 중일 때 loading은 true로 남습니다. 42가 끝나면 5번 사용자를 저장하고 자기 finally가 loading을 false로 바꾸며, 다른 실패가 없으므로 error는 null입니다.

취소 신호만으로는 이미 이어지고 있는 후속 처리까지 언제나 막지는 못합니다. 확인을 없앤 구현에서는 늦게 실행된 41의 finally가 무조건 로딩을 꺼, 42가 아직 진행 중인데 로딩 표시가 사라지는 순간이 생깁니다. 이것이 이 예제의 핵심 경쟁 조건입니다.

2. 없는 사용자 요청하기

앞의 db.json으로 json-server를 띄운 상태에서 /users/9로 들어가면 UserProfileById는 무엇을 보여 줄까요?

해설 보기

db.json에 ID 9인 사용자가 없으므로 json-server가 404를 돌려주고, response.ok가 false라서 HTTP error! status: 404라는 오류를 던집니다. 이 요청이 현재 요청이므로 error에 저장되어 “오류 발생: HTTP error! status: 404”가 보입니다.

“사용자를 찾을 수 없습니다.”라는 분기는 user가 null인 경우를 위한 것인데, 이 코드에서는 404가 오류 분기로 먼저 처리됩니다. 데이터가 없다는 것을 오류와 따로 보여 주고 싶다면 404 상태를 별도로 다뤄야 합니다.

다음 글 로딩 상태와 에러 처리에서는 로딩과 오류를 화면에 어떻게 나타낼지와 그 로직을 커스텀 훅으로 묶는 방법을 다루고, 이어서 axios의 응답과 오류 객체를 같은 요청 생명주기에 연결합니다.