[10분 테코톡] 서스펜스와 에러바운더리

흑우·2023년 12월 4일

10분 테코톡 - 5기

목록 보기
14/16

Data Fetching Approaches

Fetch-on-render

function ProfilePage(){
	const [user, setUser] = useState(null);
    const [loading, setLoading] = useState(false);
  
  	useEffect(() => {
    	setLoading(true);
      	fetchUser().then(u => setUser(u)).finally(setLoading(false));
    },[])
  
  	if (loading){
    	return <p>Loading profile</p>
    }
  
  	return (
    	<div>
        	<h1>{user.name}</h1>
        	<ProfileTimeline /> // post의 값을 fetching 해야함
        </div>
    )
}
  • 컴포넌트 렌더링을 먼저 시작하고, useEffect 혹은 ComponentDidMountemddm 생명주기 메서드를 활용해서 비동기 처리하는 방식

장점

  • 직관적이고, 간편하게 사용할 수 있다.

단점

  • 현재는 fetch가 한 개밖에 없지만, 여러 데이터를 받는 경우 데이터 상태에 따른 여러 분기 처리를 해줘야 함.
  • ProfileTimeLine 컴포넌트에 데이터 fetch 코드가 있다면, fetchUser 데이터 fetcher가 끝나기 전까지 렌더링 X => 동시성이 보장되지 않는다.
    • 부모 컴포넌트의 데이터 패칭이 끝나지 않는다면 자식 컴포넌트의 렌더링이 일어나지 않는다.
    • 부모 컴포넌트 데이터 패칭 => 부모 컴포넌트 렌더링 => 자식 컴포넌트 데이터 패칭 => 자식 컴포넌트 렌더링
    • Waterfall 현상 발생

Fetch-then-render

function ProfilePage(){
	const [user, setUser] = useState(null);
    const [posts, setPosts] = useState(null)
  
  	useEffect(() => {
    	Promise.all([fetchUser(), fetchPosts()])
      		.then([user, posts] => {
        		setUser(user)
          		setPosts(posts)
        	})
    },[])
  
  	if (user === null){
    	return <p>Loading profile</p>
    }
  
  	return (
    	<div>
        	<h1>{user.name}</h1>
        	<ProfileTimeline posts={posts} /> 
        </div>
    )
}
  • useEffect 혹은 ComponentDidMounteddm 생명주기 메서드를 활용해서 데이터 처리 후 렌더링 시작
  • 하위 컴포넌트의 데이터 Fetching을 상위 컴포넌트에서 한 번에 처리

장점

  • 동시에 비동기 처리를 함으로써 waterfall 문제 해결 가능

단점

  • ProfileTimeLine 컴포넌트에서 처리할 데이터를 부모 컴포넌트에서 처리 함으로써 관심사 분리가 제대로 이루어지지 않음

Render-as-you-fetch

function ProfilePage(){
	const user = useGetUser();
 
  	return (
    	<div>
        	<h1>{user.name}</h1>
        	<ErrorBoundary fallback={<ErrorPage />}>
              <Suspense fallback={<Skeleton />}>
   		     	<ProfileTimeline posts={posts} /> 
              </Suspense>
        	</ErrorBoundary>
        </div>
    )
}

function Page(){
	return (
    	<ErrorBoundary fallback={<ErrorPage />}>
          <Suspense fallback={<Skeleton />}>
            <ProfilePage />
          </Suspense>
        </ErrorBoundary>
    )
}
  • 비동기 작업과 렌더링을 동시에 시작, 초기에 fallback 컴포넌트를 렌더링하고 비동기 작업이 완료된다면 다시 한 번 렌더링.
  • 부모 컴포넌트로 Suspense를 감싸면서 loading 상태를 선언적으로 처리 가능
  • Render-as-you-fetch를 사용함으로써
    • waterfall 문제가 발생하지 않음. (동시에 처리)
    • 컴포넌트간 역할이 분리되고 의존성이 낮아짐
    • 상태에 따른 분기처리를 하지 않아도 돼서 복잡도가 낮아짐
    • 위 문제들은 laoding 상태 뿐만 아니라 error 처리 역시 같은 맥락. 에러 상태의 경우에는 ErrorBoundary를 통해 해결 가능

Suspense와 ErrorBoundary란?

Suspense

  • Suspense를 사용하면 자식이 로딩을 완료할 때까지 Fallback을 나타낼 수 있습니다.
function App(){
	return (
    	<Suspense fallback={<fallbackUI />}>
        	<ConponentThatThrowPromise />
        </Suspense>
    )
}

ErrorBoundary

function App(){
	return (
    	<ErrorBoundary fallback={<ErrorPage />}>
          <Suspense fallback={<fallbackUI />}>
            <ConponentThatThrowPromiseOrError />
          </Suspense>
        </ErrorBoundary>
    )
}
  • ErrorBoundary는 에러가 발생한 부분 대신 에러 메시지와 같은 Fallback UI를 표현할 수 있는 특수한 컴포넌트입니다.

공통점

  • Suspense와 ErrorBoundary 모두 자식 컴포넌트에서 발생한 예외를 부모 컴포넌트 위임을 통해 해결, 두 기능 모두 비슷한 컨셉에서 착안된 방식.

대수적 효과 (Algebraic Effects)

대수적 효과는 순수하지 않은 행동이 일련의 활동에서 발생된다는 전제 하에 computational effects에 대해 접근하는 방식이다. 물론 순수하지 않은 행동들을 적절하게 처리할 수 있는 handler들을 자연스럽게 주어진 상태로 말이다. - Matija Pretnar

프론트엔드 개발자로써 해석해보자!

순수하지 않은 행동

  • Side Effect를 유발하는 함수, 즉, 순수함수가 아닌 함수
  • 컴포넌트는 순수함수다. 하지만 useEffect를 통해 데이터를 fetching하게 된다면 순수하지 않게 된다.

computational effects

  • 서로 다른 환경에서 발생하는 상호 작용

대수적 효과

  • 서로 다른 환경에서 (부모 컴포넌트와 자식 컴포넌트)에서 발생하는 순수하지 않은 행동 (data fetching)에 대한 handler(throw & fallback)가 주어져 그 부수 효과들을 핸들링 하는 것을 의미합니다.

자식에서 throw한 예외를 받는 방법

Sebastion Markbage의 SybchronousAsync

// infrastructure.js
let cache = new Map();
let pending = new Map();

function fetchTextSync(url) {
	if (cache.has(url)) {
    	return cache.get(url);
    }
  
  	if (pending.has(url)) {
    	return pending.get(url);
    }
  
  	let promise = fetch(url).then(response => response.text()).then(text => {
    	pending.delete(url);
      	cache.set(url, text)
    })
    
    pending.set(url, promise);
  	throw promise;
}

async function runPureTask(task){
	for(;;){
    	try{
        	return task();
        } catch (x){
        	if(x instanceof Promise){
            	await x;
            } else {
            	throw x;
            }
        }
    }
}
  • fetchTextSync
    • Side Effect를 발생시키는 메서드
    • 저장된 값을 return 하거나, promise를 throw함
  • runPureTask
    • throw된 promise를 handling하는 메서드
    • task를 return 하기 전까지 계속 대기하고 있다.
    • for문을 돌면서 Promise면 계속 대기 상태이고 리턴이 되면 종료가된다.

React 18

Suspense Pipe Line (w render phase)

1. performConcurrentWarkOnRoot

  • 모든 Concurrent Mode task의 시작점, 해당 메서드를 시작으로 root로부터 fiber tree를 순회하면서 재조정 과정을 거치게 됨

2. renderRootConcurrent

  • Concurrent Mode에서 workLoopConcurrent를 호출하는 메서드, 이 메서드에서 do~while loop를 통해서 throw된 promise를 기다린다.

3. workLoopConcurrent

  • worklnProgress가 null일 때까지 모든 Fiber 노드를 순회하는 메서드. scheduler의 우선순위를 확인하는 shouldYield() 메서드와 함께 while문의 조건을 완성시킨다.

4. performUnitOfWokr

  • 인자로 넘겨받은 Fiber 노드의 alternate를 받고 beginWork()를 호출. 작업이 남았다면 worklnProgress에 다음 단위로 실행. null이라면 그대로 종료

5. beginWork

  • 인자로 전달 받은 worklnProgress의 tag에 따라 처리하고자 하는 component를 호출 tag에 따라 알맞은 case문 실행

6. case: xxComponent

  • Fiber를 return 해야 하는데 promise를 throw!
  • 이때 이것을 catch 해주는 부분이 2번 renderRootConcurrent

renderRootConcurrent

function renderRootConcurrent(root: FiberRoot, lanes: Lanes){
	// ...
  	outer: do {
    	try {
        	if(__DEV__ && ReactCurrentActQueue.current !== null) {
            	workLoopSync();
            }else {
            	workLoopConcurrent(); // throw Promise!
            }
        }catch (thrownValue) {
        	handleThrow(root, thrownValue); // catch!
        }
    }
}
  • 이러한 흐름 어디서 많이 보지 않으셨나요? 네! 맞습니다. 앞서 살펴보았던 SybchronousAsync의 runPureTask와 매우 유사한데요.
  • 그렇다면 이제 handleThrow에서 어떤 역할을 하고있는지 보겠습니다.
function handleThrow(root: FiberRoot, thrownValue: any): void {
	// ...
  	if(thrownValue === SuspeseException) {
    	thrownValue = getSuspendedThenable();
      	workInProgressSuspendedReason = 
        shouldRemainOnPreviousScreen() &&
        !isincludesNonIdleWork(workInProgressRootSkippedLanes) &&
        !isincludesNonIdleWork(workInProgressRootInterleavedUpdateLanes)
        ? SuspendedOnData: SuspendedOnImmediate;
    }
}
  • handleThrow에서 넘어온 thrownValue값이 SuspenseException과 일치한다면, SuspendedReason을 SuspendedOnData을 할당하게 된다.

ensureRootIsScheduled

  • 현재 root의 값(task)을 스케쥴러에 예약하도록 보장하는 메서드. resolve된 이후에 다시 렌더링을 재개할 수 있도록 함. 즉, throw된 ChildComponent부터 렌더링 될 수 있도록 보장해 줌.
  • 즉, fallback UI의 렌더링이 끝난 뒤에 해당 throw된 부분에서부터 렌더링을 재개할 수 있음

Suspense 컴포넌트가 어떻게 렌더링되는가?

  • beginWork에서 Suspense Component를 만나게된다면 updateSuspenseComponent라는 메서드가 실행됩니다.

  • updateSuspenseComponent의 작동 과정을 그림으로 표현하면 다음과 같습니다.

  • showFallback에서 맨 처음 렌더링할 때와 업데이트되었을 때 두 가지 경우가 다 있는데 다음 이미지는 맨 처음 렌더링되었을 때의 과정입니다.

true인 경우

  • true인 경우 mountSuspenseFallbackChildren에서 2가지 컴포넌트를 return하게 됩니다.
  • fallback fragment와 child component를 동시 렌더링합니다.
  • 이때 동시에 렌더링할 수는 없으니 child component의 모드를 hidden으로 해서 DOM에는 렌더링되지 않지만 동시성을 보장하는 코드입니다.

false인 경우

  • false인 경우 아까 있었던 child component의 모드가 visible로 바뀌게 대면서 렌더링이 되게됩니다.

ErrorBoundary

class ErrorBoundary extends Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

  static getDerivedStateFromError(error) {
    return { hasError: true };
  }

  componentDidCatch(error, errorInfo) {
    logErrorToMyService(error, errorInfo.componentStack);
  }

  render() {
    if (this.state.hasError) {
      return this.props.fallback;
    }

    return this.props.children;
  }
}
  • getDerivedStateFromError
    • render 단계에서 실행되는 메서드, 자식 컴포넌트가 에러를 던질 때 호출되는 메서드, 순수함수로 관리되어야 함. Fallback UI를 표시하도록 state를 업데이트 한다.
  • componentDidCatch
    • commit 단계에서 실행되는 메서드, 주로 에러 로그를 기록할 때 사용됨. sideEffect 허용
  • props 설정에 따라 fallback을 띄울수도 있고 고정된 UI를 띄울 수도 있다.
  • ErrorBoundary는 Suspense처럼 React에서 지원하는 내장 객체가 아니라 개발자가 직접 구현해야 합니다.
  • getDerivedStateFromError과 componentDidCatch 메서드를 필수적으로 사용해야하기 때문에 반드시 class를 사용해야 합니다.

ErrorBoundary Pipe Line (w render phase)

renderRootConcorrent

function renderRootConcurrent(root: FiberRoot, lanes: Lanes){
	// ...
  	outer: do {
    	try {
        	if(__DEV__ && ReactCurrentActQueue.current !== null) {
            	workLoopSync();
            }else {
            	workLoopConcurrent(); // throw Promise!
            }
        }catch (thrownValue) {
        	handleThrow(root, thrownValue); // catch!
        }
    }
}
  • 네 아까와 똑같습니다.

handleThrow

function handleThrow(root: FiberRoot, thrownValue: any): void {
	// ...
  	if(thrownValue === SuspeseException) {
    // ...
    } else {
		const isWakeable = 
  			thrownValue !== null &&
  			typeof thrownValue === 'object' &&
  			typeof thrownValue.then === 'function';
  			workInProgressSuspendedReason = isWakeable 
  			? SuspendedOnDeprecatedThrowPromise
  			: SuspendedOnError;
	}
}
  • 하지만 이번에는 SuspenseException이 아니라 else처리 되어서 workInProgressSuspendedReason에 SuspendedOnError가 할당되게 됩니다.

do while문으로 인해 다시 renderRootConcorrent

function renderRootConcurrent(root: FiberRoot, lanes: Lanes){
	// ...
  case SuspendedOnError: {
  	workInProgressSuspendedReason = NotSuspended;
    workInProgressThrownValue = null;
    throwAndUnwindWorkLoop(unitOfWork, thrownValue);
    break;
  }
}
  • 여기서 throwAndUnwindWorkLoop 메서드를 실행하게 됩니다.

throwAndUnwindWorkLoop

function throwAndUnwindWorkLoop(unitOfWork: Fiber, thrownValue: mixed){
	try{
    	throwException(
        	workInProgressRoot,
          	returnFiber,
          	untilOfWork,
          	thrownValue,
          	workInProgressRootRenderLanes,
        )
    } catch (error) {
    	workInProgress = returnFiber
      	throw error;
    }
}
  • throw된 값을 전달받아서 throwException 메서드에 전달해 줌 => 가장 근처에 있는 ErrorBoundary를 찾는 메서드

throwException

  • throwException에서 부모 노드를 탐색하면서 가장 가까운 ErrorBoundary를 찾는다.
  • 그리고, 찾은 ErrorBoudary에 전달 받은 error를 업데이트할 준비를 한다. => createClassErrorUpdate 실행

createClassErrorUpdate

  • 여기서 업데이트는 이전에 ErrorBoundary에서 error 설정을 해줬던 getDerivedStateFromError 메서드를 반환하면서 해준다.

finishClassComponent

  • ClassComponent를 업데이트 하고 난 다음에 렌더링을 마무리하는 과정에서 render()를 실행하게 된다.
  • 이를 통해기존에 에러를 throw한 지점에서 가장 가까운 ErrorBoundary를 찾고, 선언 했던 fallbackUI가 렌더링되는 것을 알 수 있다.

정리

  • 데이터를 fetching하고 로딩, 에러 상태를 관리하는 방법은 각각의 상태를 선언적으로 처리할 수 있는 'Render-as-you-fetch' 방법이 좋다.
  • 해당 방법은 Suspense와 ErrorBoundary를 통해서 구현할 수 있다.
  • Suspense와 ErrorBoundary는 대수적 효과에 영향을 받은 기술들이다.
  • 대수적 효과란, 서로 다른 환경에서 발생하는 순수하지 않은 상호작용을 주어진 handler를 통해 관리하는 방법이다.
  • 자식 컴포넌트에서 throw한 값을 받는 방법은 React 내부에 있는 renderRootConcorrent 메서드 속 do ~ while loop와 try ~ catch문을 통해서 처리할 수 있다.
  • Suspense의 경우에는 fallback Component가 렌더링 됨과 동시에 Child Component가 동시에 렌더링 되는 것을 알 수 있다.
  • ErrorBoundary는 ClassComponent로 작성해야 하고, getDerivedStateFromError 안에서 상태를 업데이트 해줘여 한다.

마무리

이번 글에서는 Suspense와 ErrorBoundary에 대해서 다뤘는데요. 기존에 알고 있던 것들이라 가벼운 마음으로 영상을 시청했었는데요.. 하하하 이렇게 깊게 들어갈줄은 몰랐습니다. 내부 동작을 코드 하나하나 따라가면서 설명해주셔서 이해는 됐으나 복잡하네요. 이 부분은 나중에 다시 한 번 볼 필요가 있을 거 같습니다. 영상 내용 너무 유익하니까 여러분도 꼭 시청하시면 좋겠습니다.

Reference

profile
흑우 모르는 흑우 없제~

0개의 댓글