상대 유저의 프로필 카드 내에 상대와 나의 성향을 기반으로 둘의 궁합을 점쳐주는 에픽을 진행하면서 작업하게 되었다.
해당 작업의 요구사항은 아래와 같았다.
이렇게 봤을 때, 구현이 필요한 주요 기능은
으로 볼 수 있었고, 이 기능들만 봤을 때 바로 리액트의 suspense, errorBoundary 컴포넌트, 그리고 react-query의 QueryErrorResetBoundary 기능을 조합한 컴포넌트를 실무에서 적용해볼 수 있는 절호의 찬스!! 라는 생각이 들어 해당 기능들을 활용해 개발을 진행했다.
suspense 컴포넌트는 자식 컴포넌트가 로딩 작업을 끝낼때까지 fallback 컴포넌트를 보여준다.
suspense의 props
리액트에서는 컴포넌트 렌더링 중 에러를 만나면 렌더링을 멈추고 빈 화면을 띄워준다. 이를 대비해 컴포넌트가 깨질 경우 대체 컴포넌트를 보여주기위해 Error Boundary를 활용할 수 있다.
Error Boundary는 클래스형 컴포넌트로만 구현이 가능하다.
기본적으로 생명주기 함수인 getDerivedStateFromError()와 componentDidCatch()를 사용해 에러를 핸들링한다.
아래는 공식문서에서 제공하는 error boundary 코드이다.
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false }; // 에러 상태 초기화
}
static getDerivedStateFromError(error) {
// state의 값을 업데이트하여 다음 렌더링 때 fallback UI를 노출한다.
return { hasError: true };
}
componentDidCatch(error, info) {
// 해당 메서드가 에러를 캐치하여 로깅 등의 작업을 수행할 수 있다.
logErrorToMyService(error, info.componentStack);
}
render() {
if (this.state.hasError) {
// hasError가 true일 경우 커스텀 fallback UI를 노출
return this.props.fallback;
}
return this.props.children;
}
}
react-query에서 제공하는 컴포넌트로, reset 함수를 매개변수로 받아서 JSX를 렌더링하도록 설계되었다. reset 함수 실행 시 해당 컴포넌트의 바운더리 내에서 발생한 모든 쿼리 에러를 리셋해주는 기능을 한다.
import { QueryErrorResetBoundary } from '@tanstack/react-query'
import { ErrorBoundary } from 'react-error-boundary'
const App: React.FC = () => (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
fallbackRender={({ resetErrorBoundary }) => (
<div>
There was an error!
<Button onClick={() => resetErrorBoundary()}>Try again</Button>
</div>
)}
>
<Page />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
)
react-intersection-observer
라이브러리의 useInview
를 활용한다.<Suspense/>
컴포넌트로 감싸고 1초가 지나도 아직 궁합 데이터가 패칭되지 않았다면 fallback prop으로 전달한 로딩 ui를 노출시킨다.<ErrorBoundary/>
컴포넌트에 fallback prop으로 전달한 에러 ui 컴포넌트를 노출시킨다.<ErrorBoundary/>
컴포넌트에 onReset prop으로 전달한 리셋 이벤트를 유저가 실행할 경우 궁합 정보 데이터 API 요청을 재시도 한다.export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
// error 에 대한 상태 초기화
this.state = {
hasError: false,
error: null,
};
this.resetErrorBoundary = this.resetErrorBoundary.bind(this);
}
// 에러가 발생했을 때 이 메서드가 호출된다.
static getDerivedStateFromError(
error: Error | AxiosError,
): ErrorBoundaryState {
// 에러가 발생했으므로 에러에 대한 새로운 상태값을 반환한다.
return {
hasError: true,
error,
};
}
// 에러 발생 시 보여지는 fallback 컴포넌트에서 리셋 시 실행되는 이벤트
resetErrorBoundary(): void {
const { props } = this;
props.onReset();
this.setState({
hasError: false,
error: null,
});
}
render() {
const { state, props, resetErrorBoundary } = this;
const { hasError, error } = state;
const { fallback, children } = props;
const fallbackProps: FallbackProps = {
error,
resetErrorBoundary,
};
const fallbackComponent = createElement(fallback, fallbackProps);
// 에러가 발생했을 경우 fallbackComponent를 렌더링하고,
// 에러가 발생하지 않았을 경우 children을 렌더링한다.
return hasError ? fallbackComponent : children;
}
}
const { ref, inView } = useInView({ threshold: inViewThreshold });
return (
<div className="chemistry-info-wrapper" ref={ref}>
// 의도적으로 추가한 loading 시간인 1초가 지날 경우
// isLoading값은 false로 변경됨
{isLoading ? (
<ChemistrySuspenseFallback />
) : (
<Suspense fallback={<ChemistrySuspenseFallback />}>
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary onReset={reset} fallback={ErrorFallback}>
<Chemistry targetProfileId={targetProfileId} />
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
</Suspense>
)}
</div>
)
위와 같이 구현 후 <Chemistry/>
컴포넌트 안에서 패칭하는 데이터의 쿼리에 suspense: true
옵션을 추가하여 suspense 기능을 활성화 하였다.
useInview 라이브러리 사용으로 스크롤이 궁합 컨텐츠 포지션에 도달했을 때 1초의 의도적인 지연시간 이후 정상적으로 궁합 컨텐츠가 노출되었고, 의도적으로 API에 에러를 발생시켜 테스트했을 때 1초의 의도적인 지연시간 이후 ErrorBoundary
의 fallback ui가 노출되었다.
그리고 ui가 노출되었을 때 QueryErrorResetBoundary
의 reset 기능을 활용해 reset을 실행하자 QueryErrorResetBoundary
컴포넌트 하위의 모든 API에 대해 재요청을 시도했고, 다시 정상적인 데이터를 응답받아 궁합 컨텐츠가 노출됨을 확인할 수 있었다.
에픽 작업 시 종종 로딩 ui 노출이나 실패 시 재시도 컴포넌트 노출 등의 기능이 요구사항에 등장하곤 했다.
그래서 suspense + errorBoundary + QueryErrorResetBoundary 를 이용한 컴포넌트를 개발한 김에 해당 기능들을 공통 컴포넌트로 만들어 쉽게 사용할 수 있도록 구현해두면 좋을 것 같아 공통 컴포넌트화 작업을 추가로 진행해보았다.
suspense의 무분별한 사용 시, 하나의 API에 suspense를 적용해 지연시키고 그 이후 또 나머지 API에 suspense를 적용해 지연시키고, 또 계속해서 이 과정이 반복될 수 있다는 부작용을 인지해 위 조건과 같이 해당 컴포넌트는 최소 단위의 기능에(가능하다면 1~2개의 API에) 사용하는 것을 목적으로 하였다.
// SuspenseWrapper.tsx
interface SuspenseWrapperProps {
loadingFallback: ComponentType;
errorFallback: ComponentType<FallbackProps>;
}
export const SuspenseWrapper = ({
loadingFallback: LoadingFallback,
errorFallback: ErrorFallback,
children,
}: PropsWithChildren<SuspenseWrapperProps>) => {
return (
<QueryErrorResetBoundary>
{({ reset }) => {
return (
<ErrorBoundary fallback={ErrorFallback} onReset={reset}>
<Suspense fallback={<LoadingFallback />}>{children}</Suspense>
</ErrorBoundary>
);
}}
</QueryErrorResetBoundary>
);
};
위와 같이 구현해 loadingFallback
, errorFallback
props에 각 컴포넌트를 전달받아 커스텀 UI가 적용될 수 있도록 하고,
errorFallback 컴포넌트의 경우 ErrorBoundary
컴포넌트 내부에서 props로 error와 resetErrorBoundary를 주입받아 렌더링된다.
// SuspenseWrapper 컴포넌트를 import 하여 사용하는 컴포넌트
<SuspenseWrapper
loadingFallback={LoadingFallback}
errorFallback={ErrorFallback}
>
<FeatureComponent />
</SuspenseWrapper>
그리고 Suspense와 Error Boundary를 사용할 수 있도록 공통 컴포넌트로 구현된 컴포넌트는 위와 같이 loading, error에 대한 fallback 컴포넌트만 props로 넘겨주면 하위 컴포넌트의 요청이 처리중일 때, 혹은 에러가 발생했을 때 상황별 컴포넌트를 자동으로 보여줄 수 있게 된다.
SuspenseWrapper
컴포넌트의 props인 loadingFallback, errorFallback을 어떤 형태로 넘겨야할지 고민했다.
<LoadingFallback/>
, <ErrorFallback/>
과 같은 element 형태로 넘겨야 할지 혹은 LoadingFallback
, ErrorFallback
과 같이 컴포넌트 형태로 넘겨야 할지 고민을 했다.
그리고 고민의 결과는 임포트한 컴포넌트 형태 그대로 넘기는 방법으로 결정하였고 그 이유는 아래와 같다.
SuspenseWrapper
컴포넌트 내부에서 loadingFallback, errorFallback을 어떻게 사용할지, 어떻게 렌더링할지 등을 정의하기 때문에 외부에서는 fallback 컴포넌트를 props로 전달하는 역할만 해야한다.<loadingFallback value="로딩"/>
등과 같이 새로운 props를 추가하고자 하는 욕구(?)가 생길수도 있고 해당 컴포넌트를 가공하고자 하는 마음이 생길 수 있으므로.. 이에 따라 의도치 않은 로직이 추가돼 사이드이펙트가 발생할 수 있다.선언형
으로 작성 가능하다는 의미였다. 막연하게 "편리함" 이라는 것만 생각했는데, 그 편리함이라는 개념을 개발적으로 구체화 하면 선언형
이라고 말할 수 있는 부분이었다. 추상적인 개념을 이해하기보단 보다 더 개발적인, 구체적인 표현으로 명확하게 이해할 필요가 있다.🔗 https://happysisyphe.tistory.com/54
🔗 https://yiyb-blog.vercel.app/posts/error-boundary-with-react-query
🔗 https://www.jbee.io/articles/react/React%EC%97%90%EC%84%9C%20%EC%84%A0%EC%96%B8%EC%A0%81%EC%9C%BC%EB%A1%9C%20%EB%B9%84%EB%8F%99%EA%B8%B0%20%EB%8B%A4%EB%A3%A8%EA%B8%B0