치춘짱베리굿나이스
Suspense와 Error Boundary를 이용한 로딩과 예외처리 본문
Suspense와 Error Boundary를 이용한 로딩과 예외처리
기존의 구현 방식
export const DetailPage = ({ isMine = false }: Props) => {
const { id } = useParams();
const nav = useNavigate();
const [data, setData] = useState<ProfileType>({
username: '',
code: '',
language: '',
interest: '',
techStack: [],
worktime: '',
worktype: '',
email: '',
requirements: [],
liked: false, // useState 초기화
});
useEffect(() => {
fetchUserData(id)
.then((res) => {
setData(res);
})
.catch((err) => {
alert(err);
nav('/');
});
}, []);
return <ViewModeContainer
userId={id}
profileData={data}
/>;
};
- 기타 로직을 덜어낸 간략화된 코드이긴 하나, Suspense와 Error Boundary를 적용하기 전 코드는 위와 같다
- 초기화용 데이터 (
username
,code
,language
등이 빈 문자열로 초기화된 객체) 로 상태값을 초기화한다 - 컴포넌트가 Mount되었을 때 (
useEffect
) fetch를 진행한다 (비동기) - fetch 후 데이터를 받아왔을 때
then
을 통해 상태값을 제대로 데이터가 들어있는 객체로 set한다 - 에러가 발생할 경우,
catch
에서 잡아준다 - 이 방식은 fetch-on-render 라고도 불리며, 렌더링 직후에 데이터를 불러온다는 뜻이다
- 초기화용 데이터 (
문제점
- 존재하지 않는 ID를 입력하여 fetch가 실패하고, 예외가
catch
되었을 때에도 컴포넌트가 렌더링된다- 이때 데이터가 아무것도 들어있지 않는 컴포넌트가 기본적으로 렌더링되므로, 오히려 에러가 발생한 것 같지 않아 보이기도 한다
- 아무런 내용이 없는 빈 객체를 이용하여 컴포넌트가 렌더링되는 탓에, 사용자가 혼란을 겪을 가능성이 있다
- 불필요한 렌더링이 일어나므로, 에러 처리가 깔끔하게 되지 않았다는 느낌을 주며, 렌더링으로 인한 리소스 낭비가 존재한다
- 정상적으로 데이터를 불러왔을 때, (초기 상태값이 빈 내용물이 담긴 객체이므로) 처음에는 데이터가 없는 컴포넌트가 렌더링된 뒤, 상태값이 변경되면서 내부 내용물을 한번 더 렌더링한다
- 이 때에도 마찬가지로 불필요하게 컴포넌트가 2번 렌더링된다
- 데이터 fetch가 완료되지 않은 시점에 빈 객체를 이용한 컴포넌트 렌더링이 발생하며, 빈 컴포넌트가 그대로 노출된다
- 사용자의 컴포넌트 성능에 따라 fetch가 늦어질 경우, 그동안 빈 컴포넌트가 지속적으로 노출된다
총평
- 상세 프로필 페이지의 경우, 실재하는 사용자의 프로필을 노출시키는 페이지인 만큼 컴포넌트에 존재하는 공백이 어색하게 느껴진다
- 사용자의 프로필 fetch가 완료되기 전까지 컴포넌트 렌더링을 늦추고 싶다
- fetch 도중 예외가 발생하면, 아예 프로필 페이지 렌더링이 안 되게 하고 싶다
Suspense
Suspense for Data Fetching (Experimental) - React
- 리액트에서 공식적으로 지원하는 API로, 리액트 17에서는 시범적인 기능이었지만 18에서 정식 도입되었다
- 18버전 공식 문서에서는 Lazy loading 컴포넌트에 대해서만 지원한다고 하지만, 17버전 공식 문서에서는 데이터와 컴포넌트 등 대부분의 것을 ‘기다릴’ 때 사용할 수 있다고 한다
- Lazy Loading은 lazy() 메서드를 사용해서 구현할 수 있다
- Suspense를 통한 데이터 렌더링 방식을 Render-as-you-fetch (렌더링과 동시에 fetch) 라고 부른다
- fetch-on-render는 렌더링이 먼저 시작된 뒤 fetch되므로, 불러와야 하는 데이터가 많을 경우 컴포넌트가 순차적으로 그려지기 때문에 미관상 좋지 않다 (Waterfall 현상)
- fetch-then-render는 불러와야 하는 데이터가 여러 종류일 경우
Promise.all
을 이용하지 않는 이상 모든 불러오기를 차례로 진행하기 때문에 컴포넌트 렌더링이 매우 늦어진다
- Suspense는 컴포넌트 내부에서 사용하는 값이 전부 fetch되었는지, 아닌지 여부를 판단하고, 데이터 불러오기가 끝나지 않았다면
prop
으로 받아온 fallback 컴포넌트를 미리 렌더링한다- 이를 이용하여 로딩 컴포넌트를 쉽게 구현할 수 있다
- 결론적으로, Suspense는 데이터 등의 로딩 여부에 따라 컴포넌트 렌더링을 늦출 수 있고, 그 동안 다른 컴포넌트를 렌더링하는 등의 처리가 가능하게끔 해준다
Suspense 를 위한 Promise 생성하기
const PENDING = 0;
const SUCCESS = 1;
const ERROR = 2;
export function fetchUserData(userID: string | null) {
let status = PENDING;
let result: Error | ProfileType;
const suspender = fetch(`${process.env.REACT_APP_FETCH_URL}${API.DETAIL}${userID}`)
.then(checkStatusCode)
.then(checkCustomCode)
.then(
(res) => {
status = SUCCESS;
result = res;
},
(err) => {
status = ERROR;
result = err;
}
);
return {
read: () => {
if (status === PENDING) throw suspender;
else if (status === ERROR) throw result;
else return result as unknown as ProfileType; // Error 타입의 변수의 경우 위에서 반드시 throw됨
},
};
}
- 매우 복잡한 함수가 등장한다… 하나하나 분석해 보자
status
는PENDING
,SUCCESS
,ERROR
세 종류로 구성되며, 각각 fetch 중, fetch 완료, fetch 도중 에러 발생을 의미한다- 아래에 보면 알 수 있듯이,
status
는 처음에PENDING
으로 초기화되며, 모든 비동기 동작들이 완료된 후SUCCESS
또는ERROR
로 변경된다
- 아래에 보면 알 수 있듯이,
result
는 fetch 결과값을 의미한다- 응답이 정상적으로 처리되면
result
에는 처리된 응답이 저장된다 - 예외가 발생할 경우
result
는 예외가 저장되며, 마지막에throw
된다
- 응답이 정상적으로 처리되면
suspender
가 실제로 데이터를 fetch하는 영역이다- 상세 페이지의 유저 정보 를 가져오기 위해, fetch로
userID
에 대한요청을 보낸다 - 응답이 돌아오면, 응답의 상태 코드와 커스텀 상태 코드를 검사하기 위한 유틸 함수인
checkStatusCode
,checkCustomCode
가 호출된다 - 예외가 발생하지 않았을 경우
status
는SUCCESS
가 되며, 결과값은 응답받은 데이터가 저장된다 - 예외가 발생하였을 경우
status
는ERROR
가 되며, 결과값에는 발생한 예외가 저장된다
- 상세 페이지의 유저 정보 를 가져오기 위해, fetch로
fetchUserData
의 반환값은read
라는 함수로, 이 함수는fetchUserData
스코프 내에서status
와result
를 사용한다status
가PENDING
일 경우 (fetch 처리 중일 경우)suspender
Promise를throw
한다- 예외가 아니라 Promise 그 자체를
throw
하는 것이 요상해보일 수 있는데, Suspender에서는 자식 컴포넌트의 렌더링 도중 Promise가throw
될 경우 ‘Promise가 아직 처리되지 않았다’ 라고 판단하여 임시로 fallback 컴포넌트를 렌더링한다 throw
라는 것은 일종의 ‘중단’ 을 나타내기 때문에, (예외가throw
되면 해당 라인에서 로직이 멈추는 것과 같은 원리) Suspense에서도 ‘렌더링의 중단’ 으로 인식하는 것이다
- 예외가 아니라 Promise 그 자체를
status
가ERROR
일 경우 (fetch
도중 예외가 발생했을 경우) 예외를 상위 컴포넌트로throw
한다- 이 예외를 처리하는 것은 Suspense가 아닌 Error Boundary로, 하단에서 알아볼 것이다
status
가SUCCESS
일 경우,result
를 반환한다- 예외
throw
를 위해result
의 타입을Error | ProfileType
으로 설정했기 때문에, 정상적으로 데이터가 들어왔을 경우엔ProfileType
을 반환하기 위해 타입 변환을 한다 Error
와ProfileType
은 상호간 연관성이 없어 명시적 타입 변환이 어려우므로, 중간에 unknown으로 한번 지정해 준다
- 예외
Suspense 부착하기
import { Suspense } from 'react';
...
export const DetailInner = ({ userId, promise }: Props) => {
const profileData = promise.read(); // promise를 읽어온다
return
<ViewModeContainer
userId={userId}
profileData={profileData}
/>
};
export const DetailPage = ({ isMine = false }: Props) => {
const { id } = useParams();
const nav = useNavigate();
const data = fetchUserData(id); // data 는 read 함수를 갖고 있다
return (
<Suspense fallback={<div>로딩 중...</div>}>
<DetailInner
userId={id}
promise={data}
/>
</Suspense>
);
};
- Suspense 컴포넌트를 리액트에서 가져와 직접 부착해 보자
fallback
prop은 Promise가 완료되지 않았을 때 (하위 컴포넌트에서 promise가throw
되었을 때) 하위 컴포넌트 대신 렌더링되는 컴포넌트로, 이를 이용하여 로딩 화면을 만들 수 있다
DetailInner
에서read
함수를 호출하면,- Promise 처리가 완료되었을 경우 정상적으로 데이터가 반환된다
- Promise 처리가 완료되지 않았을 경우 Promise가
throw
되며, 이를 Suspense가catch
한다
- Suspense는 Promise의
throw
여부를 이용하여 처리가 완료되었는지 여부를 알게 된다
적용 결과
- fetch 속도가 조금 느려질 때, 빈 컴포넌트가 렌더링되는 것이 아니라 로딩 컴포넌트가 미리 보여짐으로써 데이터가 로딩중이라는 것이 조금 더 명확하게 표현된다
ErrorBoundary
Suspense까지 적용하고, 고의로 예외를 발생시켰을 때
- 존재하지 않는 유저 ID를 입력하여 고의로 예외를 발생시킨 모습이다
- 아예 모든 컴포넌트 렌더링을 하지 않고, 웹 사이트가 멈춰버렸다
- 이렇게 되면 사용자는 에러의 원인도 파악하지 못하고 흰 화면만 마주하게 되며, 이는 좋지 못한 사용자 경험을 낳는다
- 이 예외를 Error Boundary를 이용해
Catch
하고, 적절한 예외처리를 적용해 보자
ErrorBoundary란?
- React 16에서 도입된 API로, 하위 컴포넌트 (자식 컴포넌트) 트리 내에서 발생한 예외를
catch
하는 기능이다- 예외가 발생했을 경우 다른 컴포넌트 (fallback) 를 렌더링하거나, 어떠한 동작을 취하도록 설정할 수 있다
- 이벤트 핸들러의 예외, 비동기로 동작하는 코드의 예외 (이것은 별도로 catch해주어야 한다), 서버사이드 렌더링 시의 예외 등은 감지하지 못한다
- 예외
catch
는 클래스형 컴포넌트 내의componentDidCatch
메서드를 통해 이루어진다
- 이 기능의 최대 문제점으로, React 18버전에 와서도 함수형 컴포넌트를 지원하지 않으므로, 에러 바운더리 컴포넌트를 만들기 위해선 클래스형 컴포넌트를 사용해야 한다
Error Boundary 라이브러리
npm i react-error-boundary
- Error Boundary를 쉽게 사용하게 해주는 라이브러리로, 내부적으로 클래스형 컴포넌트로 Error Boundary 기능이 구현되어 있고, 기타 자잘한 에러 처리 관련 메서드들을 지원해준다
- 함수형 컴포넌트로 코드를 구성할 때, 클래스형 컴포넌트를 추가적으로 구현할 필요 없이 라이브러리로
import
하면 되므로 코드의 통일성을 지킬 수 있다 🫡
Error Boundary 적용하기
import { Suspense } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
...
export const ErrorFallback = () => <div>오류!</div>
export const DetailInner = ({ userId, promise }: Props) => {
const profileData = promise.read(); // promise를 읽어온다
return
<ViewModeContainer
userId={userId}
profileData={profileData}
/>
};
export const DetailPage = ({ isMine = false }: Props) => {
const { id } = useParams();
const nav = useNavigate();
const data = fetchUserData(id); // data 는 read 함수를 갖고 있다
return (
<ErrorBoundary fallbackComponent={<div>오류!</div>}>
<Suspense fallback={<div>로딩 중...</div>}>
<DetailInner
userId={id}
promise={data}
/>
</Suspense>
</ErrorBoundary>
);
};
- 크게 수정할 것은 없고, Error Boundary 기능만 사용하기 위해
ErrorBoundary
로 컴포넌트들을 감싸주면 된다fallbackComponent
는 예외가 발생하였을 때 렌더링할 컴포넌트를 지정한다- 하위 트리에 존재하는 모든 컴포넌트 (Suspense 이하) 에 대해, 예외가 발생할 경우 예외가 상위 컴포넌트 (상위 함수) 로 전달되므로 Error Boundary에 도달하여
catch
할 수 있게 된다 catch
한 예외의 내용을 인자로 받아오고 싶다면,fallbackComponent
대신fallbackRender
prop으로Error
를 인자로 받는 컴포넌트 또는 핸들러를 건네주면 된다
- 존재하지 않는 유저 ID를 입력해서 fetch 도중 예외가 발생했기 때문에, 기존의 프로필 페이지 컴포넌트 대신 ‘오류’ 라는 글자가 렌더링되는 것을 확인할 수 있다
- 실제 구현물에서는 컴포넌트 렌더링 대신,
alert
를 통한 에러 메시지 출력 및useNavigate
로 메인 화면 리디렉션을 시켜주었다 - 사용자가 오류를 인지할 수 있고, 메인 화면으로 리디렉션시켜 줌으로써 사용자에게 정상적인 동작을 유도할 수 있다
'ClientSide > React' 카테고리의 다른 글
마운트와 렌더링 (0) | 2023.07.24 |
---|---|
React의 useContext (0) | 2023.05.06 |
React를 클론코딩 #1 가상 돔 (0) | 2022.10.05 |
React (0) | 2022.10.01 |
useClickOutside 직접 구현하기 (0) | 2022.07.01 |
Comments