리액트 비동기 작업의 선언적 처리: Error boundary 및 Suspense 그리고 대수적 효과

JunSeok·2024년 8월 27일
0

지식 기록

목록 보기
10/14

전통적인 비동기 작업 처리 방법

Fetch-on-render

컴포넌트가 화면에 렌더링 된 후에, useEffect 혹은 ComponentDidMount 등의 라이프사이클 메서드를 활용해서 비동기 작업을 처리한다.

function ProfilePage() {
  const [user, setUser] = useState(null);

  useEffect(() => {
    fetchUser().then(u => setUser(u));
  }, []);

  if (user === null) {
    return <p>Loading profile...</p>;
  }
  return (
    <>
      <h1>{user.name}</h1>
      <ProfileTimeline />
    </>
  );
}

function ProfileTimeline() {
  const [posts, setPosts] = useState(null);

  useEffect(() => {
    fetchPosts().then(p => setPosts(p));
  }, []);

  if (posts === null) {
    return <h2>Loading posts...</h2>;
  }
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
}

실행 순서

  • ProfilePage 렌더링.
  • fetchUser 비동기 작업 시작.
  • fetchUser 비동기 작업 끝.
  • ProfileTimeline 렌더링.
  • fetchPost 비동기 작업 시작.
  • fetchPost 비동기 작업 끝.

문제점

warterfall 문제

위 방법은 매우 직관적인 방법이지만 Warterfall 문제를 야기한다.
Warterfall은 이전 fetch 요청에 대한 응답이 와야 다음 fetch 요청을 보낼 수 있는 구조를 말한다.
각 작업이 3초가 걸린다면, 총 6초가 지나야 모든 비동기 작업이 완료된 화면을 볼 수 있다.

상태 분기 처리 문제

현재 비동기 작업이 하나기 때문에 하나의 로딩 상태만을 다루지만,
비동기 작업이 2개 이상으로 늘어나고, 에러 상태도 처리하게 되는 순간 하나의 컴포넌트에서 너무 많은 일을 하게 되어 비즈니스 로직을 파악하기 힘들다.

Fetch-then-render

비동기 작업을 한 번에 처리하고, 모두 완료한 후 화면을 렌더링한다.
여러 비동기 작업을 한 번에 처리함으로써 Warterfall 문제를 방지할 수 있다.

// Kick off fetching as early as possible
const promise = fetchProfileData();

function ProfilePage() {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState(null);

  useEffect(() => {
    promise.then(data => {
      setUser(data.user);
      setPosts(data.posts);
    });
  }, []);

  if (user === null) {
    return <p>Loading profile...</p>;
  }
  return (
    <>
      <h1>{user.name}</h1>
      <ProfileTimeline posts={posts} />
    </>
  );
}

// The child doesn't trigger fetching anymore
function ProfileTimeline({ posts }) {
  if (posts === null) {
    return <h2>Loading posts...</h2>;
  }
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
}

실행 순서

  • ProfilePage 렌더링.
  • user data fetching 시작.
  • posts data fetching 시작.
  • user data fetching 끝.
  • posts data fetching 끝.
  • ProfilePage 리렌더링.

문제점

최종 렌더링 지연

Warterfall 문제는 해결했지만, 모든 비동기 작업이 완료될 때까지 ProfilePage는 렌더링하지 못한다.

컴포넌트 비대화

ProfilePage와 ProfileTimeline 컴포넌트에서 처리할 데이터와 로딩 상태를 모두 하나의 컴포넌트에서 처리하기 때문에 ProfilePage 컴포넌트가 비대화되는 문제가 발생한다.

코드의 가독성과 유지보수성이 떨어지며 비즈니스 로직을 파악하기 힘들어지는 문제가 발생한다.

비동기 작업의 선언적 처리

리액트의 선언적 프로그래밍

리액트의 특징 중 하나는 선언적 프로그래밍을 할 수 있다는 점이다.
선언적 프로그래밍이 가능하다는 것은 추상화 레벨이 높은 프로그래밍이 가능하다는 의미이기도 하다.

추상화에는 설계자와 사용자가 존재한다.
구체적인 기능 구현은 설계자의 책임이고, 기능 사용자는 구체적인 기능 구현을 알 필요 없이 원하는 기능을 사용하기만 하면 된다.

즉 리액트 사용자는 어떤 UI를 렌더링할 것인지에만 집중하고 이를 어떻게 렌더링할 것인지에 대한 책임은 설계자 역할인 리액트에게 넘긴다.

리액트 공식문서의 Design Principles에 다음과 같은 문구가 있다.

개발자는 함수로 컴포넌트를 작성할 때, 무엇을 렌더링할 것인지에 대한 설명에만 집중하고 해당 컴포넌트를 렌더하고 DOM에 적용하는 것은 리액트의 책임이므로 함수 호출에는 관여하지 않는다.

이러한 추상화로 인해 리액트 사용자인 개발자는 어떤 화면을 렌더링할 것인지를 고민하는 데에 더 많은 시간을 사용할 수 있으며, 이 화면을 어떻게 렌더링해야 할 지에 대한 고민은 리액트에게 온전히 맡길 수 있다.

그리고 우리는 Suspense와 ErrorBoundary를 사용하여 비동기 작업 또한 선언적으로 처리할 수 있다.
로딩을 처리하는 부분에 대한 책임과 데이터가 로딩되었는지를 판단하는 책임은 Suspense에 맡기고, 에러에 대한 책임을 ErrorBoundary에 맡김으로써, 컴포넌트는 정상적으로 데이터가 로딩되었을 때의 UI 선언에만 집중할 수 있다.

Render-as-you-fetch(with Suspense)

Render-as-you-fetch 방법은 비동기 작업과 렌더링을 동시에 시작한다.
Suspense는 모든 비동기 작업이 완료되기 전까지 fallback UI를 렌더링하고, 비동기 작업의 완료를 알아채면 중단 시점부터 컴포넌트를 렌더링한다.

즉 비동기 작업의 상태가 로딩상태인지 판단할 필요없이 Suspense에 fallback UI를 전달하여 로딩 상태에 따른 렌더링을 선언적으로 처리할 수 있다.

// 리액트 공식문서에서는 비동기 작업을 아래와 같이 promise로 wrapping 하여 상위 Suspense 컴포넌트와 소통한다.
// 컨셉 코드이기 때문에 상위 Suspense와 소통하는 개념에 집중하자.
function fetchUser() {
  console.log("fetch user...");
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log("fetched user");
    }, 1000);
  });
}

function fetchPosts() {
  console.log("fetch posts...");
  return new Promise((resolve) => {
    setTimeout(() => {
      console.log("fetched posts");
    }, 1100);
  });
}

export function fetchProfileData() {
  let userPromise = fetchUser();
  let postsPromise = fetchPosts();
  return {
    user: wrapPromise(userPromise),
    posts: wrapPromise(postsPromise)
  };
}

// Suspense를 사용하기 위해서는 promise 객체로 인자를 받아 상태에 따라 리턴값을 달리해주는 함수이다.
// 구현체가 복잡하지만 tanstack query와 같은 비동기 통신 라이브러리에서 Suspense 기능을 쉽게 사용할 수 있도록 도와준다.
function wrapPromise(promise) {
  let status = "pending";
  let result;
  let suspender = promise.then(
    (r) => {
      status = "success";
      result = r;
    },
    (e) => {
      status = "error";
      result = e;
    }
  );
  return {
    read() {
      if (status === "pending") {
        throw suspender;
      } else if (status === "error") {
        throw result;
      } else if (status === "success") {
        return result;
      }
    }
  };
}


const resource = fetchProfileData();

function ProfilePage() {
  return (
    <Suspense fallback={<h1>Loading profile...</h1>}>
      <ProfileDetails />
      <Suspense fallback={<h1>Loading posts...</h1>}>
        <ProfileTimeline />
      </Suspense>
    </Suspense>
  );
}

function ProfileDetails() {
  // Try to read user info, although it might not have loaded yet
  const user = resource.user.read();
  return <h1>{user.name}</h1>;
}

function ProfileTimeline() {
  // Try to read posts, although they might not have loaded yet
  const posts = resource.posts.read();
  return (
    <ul>
      {posts.map(post => (
        <li key={post.id}>{post.text}</li>
      ))}
    </ul>
  );
}

실행 순서

  • 비동기 작업과 렌더링 동시에 시작.
  • 비동기 작업 완료.
  • 리렌더링.

비동기 작업과 렌더링을 동시에 시작할 수 있는 이유

모든 비동기 작업이 완료되지 않아 Suspense가 fallback UI를 보여줄 때, 리액트는 hidden 모드로 자식 컴포넌트를 렌더하게 되며 이로 인해 부모 컴포넌트가 아직 로딩중이더라도 자식 컴포넌트는 자신의 비동기 작업을 Concurrent하게 수행할 수 있다.

때문에 부모 컴포넌트와 자식 컴포넌트의 비동기 작업과 렌더링을 동시에 할 수 있다.

Suspense 동작 원리

Suspense를 사용하면 비동기 작업의 로딩 상태를 판단할 필요 없이 로딩 상태에 따른 렌더링을 Suspense가 책임진다고 앞서 말했다.

그렇다면 Suspense는 어떻게 자식 컴포넌트의 비동기 작업에 대한 상태를 확인하는 것일까.
Suspense가 동작하는 방식은 다음과 같다.

  1. render method에서 캐시로부터 값을 읽기를 시도한다.
  2. value가 캐시되어 있으면 정상적으로 render 한다.
  3. value가 캐시되어 있지 않으면 캐시는 promise를 throw 한다.
  4. 해당 promise는 가까운 상위 Suspense 컴포넌트로 전파되고, Suspense는 이 promise를 받아 fallback UI를 렌더링한다.
  5. promise가 resolve되면 리액트는 promise를 throw 한 곳으로부터 재시작한다.

앞서 본 ProfilePage 예제에 적용하면 다음과 같다.
1. Suspense가 ProfilePage를 render 할 때 캐시로부터 데이터를 읽으려고 시도한다.
2. 데이터가 없으므로 ProfilePage의 캐시는 promise를 throw 한다.
3. Suspense는 이 promise를 받아 fallback UI를 render 하고, 정상적으로 resolve 되었다면 다시 ProfilePage를 render 한다.

비동기 작업 중일 때 컴포넌트는 가장 가까운 상위 Suspense에 promise를 throw 한다는 것.
비동기 작업이 완료되면(Promise가 resolve되면), Suspense는 fallback UI를 render 하는 것을 멈추고 다시 정상적으로 컴포넌트를 rener 한다.

Suspense의 핵심은 자식 컴포넌트의 비동기 작업에서 throw한 promise를 catch한다는 것이다.

개념적 구현

Prev React Core Team Sebastian Markbåge Example

_// Infrastructure.js_  
let cache = new Map();  
let pending = new Map();

// 실제 url로부터 데이터를 fetch하는 함수.
// fetch 요청의 완료 여부에 따라 리턴하는 값이 다르다.
// 로딩 중이면 resolve되지 않은 promise를 리턴하고, resolve되면 set한 캐시의 value를 리턴한다.

// 동일한 함수를 동일한 url을 가지고 여러 번 호출했을 때 promise의 resolve 여부에 따라서 리턴하는 값이 다르다.
// 즉 promise를 throw 했을 때 Suspense에서 이를 잡아서 resolve될 때까지 fallbackUI를 보여주다가 resolve가 되면 다시 throw한 곳으로부터 정상적인 컴포넌트 로딩을 처리할 수 있게 된다.
function fetchTextSync(url) {  
  if (cache.has(url)) {  
    return cache.get(url);  
  }  
  if (pending.has(url)) {  
    throw 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;  
      }  
    }  
  }  
}

Suspense와 대수적 효과(Algebraic Effects)

Suspense가 사용하는 개념적인 모델은 데이터의 상태를 확인하면서 pending 상태인 경우에는 원하는 비동기 작업에 대한 promise를 throw해서 suspense가 fallback UI를 render 하도록 하고 success 상태인 경우에는 컴포넌트를 render 하도록 하는 방식이다.

이 모델 자체는 대수적 효과는 아니지만, 대수적 효과의 영향을 받은 기술이라고 한다.
대수적 효과가 무엇일까.

Matija Pretnar의 “An Introduction to Algebraic Effects and Handlers Invited tutorial paper”에 의하면 대수적 효과란 exception throw 등을 포함하는 연산들의 집합으로부터 순수하지 못한 행위들이 발생할 수 있다는 전제를 바탕으로 computational effect에 대해 접근하는 방식을 의미한다.

위의 정의는 상당히 추상화되어 있어 쉽게 풀어보면 다음과 같다.
대수적 효과란 대수를 사용해서 연산을 수행할 때, 순수하지 못한 행위들이 일어날 수 있음을 전제로 하고 computational effect에 대해 접근하는 방식을 말한다.

여기서 computational effect는 자신의 환경을 변경하는 모든 연산을 포함하는 개념이다. 예를 들어 로컬 함수에서 부모 환경으로 작업을 넘겨 처리하게 하고 처리가 끝나면 로컬 함수의 실행이 멈췄던 곳으로 다시 돌아와 작업을 하는 경우, 이는 computational effect가 발생한 것이다.
작업을 넘긴다는 것은 로컬 함수에서 부모 컴포넌트로 promise를 throw하는 것으로 대치할 수 있으며, 이것이 바로 suspense가 promise의 pending 상태를 처리하는 방식과 동일하다.

다시 정리하면 대수적 효과는 서로 다른 환경(로컬 함수와 부모 환경)간의 상호작용에서 부수 효과가 발생할 수 있는데, 이를 적절한 handler를 통해 처리하는 접근 방식을 의미한다.
여기서 말하는 적절한 handler는 로컬함수에서 발생시킨 effect를 부모 환경에서 잡아 적절하게 처리한 뒤 다시 로컬 함수가 멈췄던 곳으로 실행 흐름을 되돌리는 역할을 한다. 이러한 handler가 바로 우리가 앞서 살펴본 Suspense 이다.

대수적 효과를 Suspense와 연관시켜 적용하면 다음과 같다.

대수적 효과는 부모 컴포넌트(Suspense)와 자식 컴포넌트(비동기 작업 처리 컴포넌트) 간의 상호작용에서 발생한 side effect(throw promise)를 적절한 handler(catch promise in suspense)를 사용해 해결하는 접근 방식을 의미하는 것이고, 이 handler는 자식 컴포넌트에서 발생시킨 effect(throw promise)를 부모 컴포넌트(suspense)에서 잡아 적절하게 처리한 뒤, 다시 자식 컴포넌트가 멈췄던 곳으로 흐름을 되돌려주는 것을 말한다.

대수적 효과는 어떤 코드 조각을 감싸는 맥락으로 side effect에 대한 책임을 분리하고, 분리된 책임에 대한 처리는 감싸는 맥락에서 처리하게 함으로써 "무엇"과 "어떻게"를 분리한다.
이렇게 책임을 분리하는 방식은 코드를 선언적으로 작성할 수 있는 관점을 제공하며 이는 앞서 말한 리액트의 설계 원칙과 동일하다.

즉 대수적 효과에서 영감을 받아 Suspense에게 비동기 작업의 로딩 상태에 대한 책임을 넘겼으며, 비동기 작업 처리 컴포넌트는 로딩 상태의 책임으로부터 벗어나 UI 선언에만 집중하도록 만들어줬다.

ErrorBoundary

ErrorBoundary는 리액트에서 에러를 선언적으로 처리하기 위해 제공하는 컴포넌트이다.
자식 컴포넌트에서 발생한 에러를 전달받아 핸들링하여 애플리케이션이 중단되는 것을 막는다.

ErrorBoundary 코드는 리액트 공식 문서에 제공된 기본 코드를 참고하여 사용한다.

ErrorBoundary에서 핵심은getDerivedStateFromError 메서드와 componentDidCatch 메서드이며, 어느 코드든 이 두 메서드만 가지고 있으면 에러 바운더리가 될 수 있다.

getDerivedStateFromError

  • render 단계에서 실행된다. => 순수함수여야 한다.
  • 자식 컴포넌트가 에러를 던질 때 호출된다.
  • fallback UI를 표시하기 위해 state를 업데이트해야 한다.

componentDidCatch

  • commit 단계에서 실행된다. => side effect 허용된다.
  • 자식 컴포넌트가 에러를 던질 때 호출된다.
  • 에러 로그를 기록할 때 사용된다.
class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props);
    this.state = { hasError: false };
  }

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

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

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

    return this.props.children;
  }
}

사용 방법

에러가 발생할 수 있는 컴포넌트를 ErrorBoundary 컴포넌트로 감싸서 사용한다.
필요에 따라 에러 발생시 사용자에게 보여줄 fallback UI를 props로 전달할 수 있다.

<ErrorBoundary fallback={<Error />}>
  <App />
</ErrorBoundary>

ErrorBoundary가 캐치하지 못하는 에러

ErrorBoundary가 모든 에러를 캐치할 수 있는 것은 아니다.
리액트 공식문서에서 아래와 같은 에러는 ErrorBoundary가 캐치할 수 없다고 한다.

  • 이벤트 핸들러
  • 비동기 작업(fetch API, setTimeout, requestAnimationFrame 콜백 등)
  • 서버 사이드 렌더링
  • 자식이 아닌 에러 바운더리 자체에서 발생하는 에러

이는 자바스크립트와 리액트를 잘 안다면 당연한 결과라 생각할 수 있다.

자바스크립트에서는 코드가 실행되면 실행 컨텍스트가 생성되고, 자바스크립트 엔진의 콜스택에 순차적으로 쌓여 실행이 끝나면 pop이 되어 없어진다.

이때 하나의 컨텍스트에서 에러가 발생하면 에러는 상위 컨텍스트로 전파된다. 상위 컨텍스트에서 에러가 처리되지 않으면 최상위 실행 컨텍스트로 전파되고 전체 애플리케이션이 중돤된다.

try {
	function throwErrorFn(a) {
		throw new Error('error')
	}
    setTimeout(throwErrorFn, 1000);
} catch(e) {
	console.log(e)	
}

위 예제를 보고 다시 정리하면 setTimeout 내의 throwErrorFn 콜백 함수는 1초 후에 실행 컨텍스트에서 실행된다. 하지만 그때는 이미 try-catch 문이 있는 실행 컨텍스트가 pop된 상황이기 때문에 catch할 수 없어 상단에 에러를 전파한다.

즉 try-catch 문의 컨텍스트 내에서 throwErrorFn 함수가 실행되지 않기 때문에 에러를 캐치할 수 없는 것이다.

ErrorBoundary 또한 try-catch와 동일한 원리로 동작한다.
ErrorBoundary 실행 컨텍스트안에서 에러가 발생한다면 캐치할 수 있고 그 안에 없다면 캐치할 수 없다. 매우 단순하다.

이벤트 핸들러

이벤트 핸들러 내에서 발생한 에러는 에러 바운더리가 캐치하지 못한다.

자바스크립트에서는 보통 addEventListenr를 이용하여 DOM node에 직접 이벤트를 등록한다.

document.getElementById("main_button").addEventListener("click", callbackFn)

하지만 리액트에서의 모든 이벤트는 root element에서 핸들링된다.

getEventListeners(document.getElementById('root'))

위 코드를 콘솔에 찍어보면 모든 이벤트가 root에 등록되어 있는 것을 알 수 있다.

root는 최상위 Html tag이고, 이곳에서 모든 이벤트 핸들링이 발생한다.
그렇기 때문에 이벤트 핸들링 과정에서 에러가 발생한다면, 이 코드는 ErrorBoundary 바깥에 있을 것이기 때문에 에러를 캐치하지 못한다.

이벤트 핸들러에서 에러를 핸들링하고 싶다면 try-catch 문을 사용하여 의도적으로 내부에서 에러를 캐치해야 한다.

비동기 작업

이는 위에서 본 setTimeout 예제와 같은 예시이다.
setTimeout의 콜백함수는 1초 후에 실행되며, 이 시점은 이미 ErrorBoundary의 실행 컨텍스트가 끝난 시점이다.
그래서 ErrorBoundary는 비동기 작업의 에러를 캐치하지 못한다.

ErrorBoundary를 이용하여 비동기 작업의 에러를 처리하기 위해서는 별도의 에러 상태를 선언하고, 비동기 작업 내에서 에러 상태를 업데이트시킨다.
이를 통해 리렌더링이 발생하고 에러 바운더리 컨텍스트 내에서 에러를 동기적으로 throw 한다.

const [error, setError] = useState(null)

const handler = async () => {
 try {
 	const response = await fetch('url')
 } catch(e) {
    setError(e)
 } 
}

if(error) throw error

tanstack query와 같은 비동기 작업 라이브러리에서 제공해주는 throwOnError 옵션을 true로 사용하면 비동기 작업 실패시 에러를 던지기 때문에 별도의 상태 처리 없이 비동기 작업에서 발생하는 에러를 에러 바운더리에서 캐치할 수 있다.
tanstack query throwOnError

서버 사이드 렌더링

ErrorBoundary는 상태값을 변경하는 getDerivedStateFromError 메서드를 기반으로 동작한다.

해당 메서드는 에러 인자를 받아, 해당 ErrorBoundary에서 처리할 에러인지 판단하고 처리할 에러라면 hasError와 같은 상태값을 true로 변경시켜 fallback UI를 렌더링한다.

이와 같이 상태값을 변경하는 메서드는 상태 변화가 존재하는 클라이언트 환경에서만 실행되고 서버 환경에서는 실행되지 않기 때문에 서버 사이드 렌더링에서 발생하는 에러는 당연히 캐치할 수 없다.

자식이 아닌 ErrorBoundary 자체에서 발생하는 에러

ErrorBoundary에서 에러를 받고 다시 에러를 throw 한다면 해당 에러는 상위 ErrorBoundary로 전파되기 때문에 ErrorBoundary 자체에서 발생하는 에러는 캐치할 수 없다.

출처 및 참고자료

Suspense for data fetching react docs
Error Boundary react docs
보아즈, 에러 바운더리가 캐치, 캐치 못하는 에러
토스, 프론트엔드에서 우아하게 비동기 처리하기
error와 error handling
리액트 앱이 에러를 처리하는 방법
테코톡, 아인노아 에러핸들링
테코톡, 밧드 에러핸들링
테코톡, 티케 에러핸들링
테코톡 클린 서스펜스와 에러바운더리
유인동, 비동기 프로그래밍 및 실전 에러 핸들링
sentry를 이용한 에러 추적
Learn React Error Boundary In 7 Minutes
React에서 선언적으로 비동기 다루기
프론트엔드 에러 핸들링에 대한 고민
React의 Error Boundary를 이용하여 효과적으로 에러 처리하기
React 18의 Suspense에 대해서 알아보자
React 대수적 효과
Conceptual Model of React Suspense
Algebraic Effects of React Suspense
Suspense Deep Dive (Code Implementation)
Suspense for Data Fetching의 작동 원리와 컨셉 (feat.대수적 효과)
추상화를 위한 대수적 효과(Algebraic Effect)와 React Suspense
선언적인 코드 작성하기
Declarative React, and Inversion of Control
Suspense의 동작 원리
ErrorBoundary가 포착할 수 없는 에러와 그 이론적 원리 분석

profile
최선을 다한다는 것은 할 수 있는 한 가장 핵심을 향한다는 것

0개의 댓글