선언적인 비동기 통신 커스텀 훅 만들기

정균·2023년 9월 17일
2

우아한테크코스

목록 보기
14/15
post-thumbnail

들어가며

프론트엔드 개발을 하며 비동기 작업을 잘 처리하는 것은 사용자 경험 측면에서나 개발 경험 측면에서 매우 중요합니다. 특히, 서버와의 통신과 같이 시간이 오래 걸리는 작업을 수행할 때, 성공한 응답을 다루는 것 외에도 적절한 로딩 화면, 에러 피드백 등을 보여주는 것은 사용자 경험에 큰 영향을 주는데요. 이번 글에서는 이러한 서버 비동기 상태를 효율적으로 다루기 위해 useFetch 커스텀 훅을 만든 과정을 소개하고, 더 나아가 선언적으로 비동기 처리 하는 방식과 구현 과정을 공유 드리려고 합니다.

useFetch 만들기

여느 서비스와 마찬가지로, 저희 팀에서 개발하고 있는 하루스터디 서비스에도 서버와 통신이 필요한 곳이 많았고, 이에 대한 비동기 로직을 작성해줘야 했는데요. 초반에는 마감 기한에 쫓겨 비동기 처리 코드들을 컴포넌트 내부에 직접 작성해줬습니다.

function Component() {
  const [data, setData] = useState();
  const [isLoading, setIsLoading] = useState(true);
  const [isError, setIsError] = useState(false);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const response = await fetch("/api");
        const data = await response.json();

        setData(data);
        setIsLoading(false);
      } catch (error) {
        setIsError(true);
        setIsLoading(false);
      }
    }

    fetchData();
  }, [])

  if(isLoading) {...}

  if(isError) {...}

  return ...

각 컴포넌트마다 비동기 처리 로직이 지저분하게 존재했고, 이로 인해 유지 보수하기가 매우 힘들었습니다. 또한, 팀원들마다 비동기 처리하는 방식이 다르다보니 예상하지 못한 버그도 많이 발생했습니다. 이러한 문제를 해결하기 위해 비동기 통신 로직을 모아 놓은 커스텀훅인 useFetch를 만들게 되었습니다.

type Status = "pending" | "fulfilled" | "rejected";

const useFetch = <T>(request: () => Promise<T>) => {
  const [status, setStatus] = useState<Status>("pending");
  const [result, setResult] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);

  const resolve = (newResult: T) => {
      setStatus("fulfilled");
      setResult(newResult);
  }

  const reject = (error: Error) => {
      setStatus("rejected");
      setError(error);
  }

  const fetch = () => {
    setStatus("pending");
    request().then(resolve, reject);
  }

  useEffect(() => {
    fetch();
  }, []);

  return {
    result,
    status,
    isLoading: status === "pending",
    isError: status === "rejected",
    error
  };
}

useFetch 코드에 대해 설명하자면 다음과 같습니다.

먼저, useFetch는 통신을 수행할 request 라는 프로미스 반환 함수를 인자로 받습니다. request의 비동기 상태를 갖는 status, request 가 성공 했을 때 데이터를 저장하는 result, request 가 실패 했을 때 에러를 저장하는 error를 커스텀훅의 상태로 갖습니다.

useFetch의 동작 과정은 다음과 같습니다.

  1. useEffect로 컴포넌트가 마운트 될 때 fetch 함수를 실행한다.
  2. fetchstatus를 pending으로 설정하고, request를 실행한다.
  3. request가 성공하면 status를 fulfilled로 설정하고, result에 데이터를 저장한다.
  4. request가 실패하면 status를 rejected로 설정하고, error에 에러를 저장한다.

useFetch는 비동기 상태(status)와 통신에 따른 결과 값(result, error)를 반환하고, 각 비동기 상태 여부를 확인할 수 있는 isLoading, isError 값도 같이 반환해줍니다.

이렇게 만들어진 useFetch는 다음과 같이 사용합니다.

function Component() {
  const {result, isLoading, isError} = useFetch(() => fetch("/api"));

  if(isLoading) {...}

  if(isError) {...}

  return ...
}

기존 컴포넌트 내부에 비동기 처리 로직을 두었을 때의 코드보다 훨씬 깔끔한 코드를 볼 수 있습니다.

선언적으로 비동기 처리할 수 있도록 만들기

만들어진 useFetch 에서 한 단계 더 나아가서, 비동기 상태를 선언적으로 처리해보려고 합니다.

여기서 잠깐, 비동기 상태를 선언적으로 처리한다는건 무슨 의미일까요? 위에 작성된 useFetch 사용 예시를 보면, 비동기 작업이 성공할 때와 실패할 때, 그리고 로딩 중일 때의 처리를 모두 한 컴포넌트에서 해주고 있습니다.

이런식으로 여러 비동기 상태가 섞여서 처리된다면 비즈니스 로직을 한눈에 파악하기 어렵습니다. 또한, 다뤄야할 비동기 작업이 여러개가 된다면 다뤄야 할 비동기 상태 분기가 기하급수적으로 늘어나게 되고, 이는 코드 유지보수를 어렵게합니다. 자세한 내용은 토스 박서진님의 발표 영상을 참고하신다면 더욱 이해하기 쉬워집니다.

이러한 문제를 해결하기 위해, 컴포넌트에는 성공 상태만 남기고, 로딩 상태와 실패 상태는 각각 SuspenseErrorBoundary를 통해 외부로 위임하도록 하여 선언적으로 처리할 수 있도록 하겠습니다.

Suspense와 ErrorBoundary의 동작방식

그전에, SuspenseErrorBoundary는 무엇이고, 동작방식은 어떻게 될까요? 간단하게 요약해봤습니다.

Suspense

  • Suspense는 하위 컴포넌트에서 fetching 중인 데이터가 있다면 로딩 화면을 렌더링하는 리액트 기능이다.
  • 하위 컴포넌트에서 throw 된 Promise 객체를 감지해서 동작한다.

ErrorBoundary

  • ErrorBoundary는 하위 컴포넌트에서 에러가 발생할 경우 에러 화면을 렌더링 하는 기능이다.
  • 하위 컴포넌트에서 throw 된 Error 객체를 감지해서 동작한다.
// Suspense와 ErrorBoundary의 사용 방식

<ErrorBoundary fallback={<div>error caught</div>}>
	<Suspense fallback={<div>loading...</div>}>
		<Component/>
	</Suspense>
</ErrorBoundary>

이 글에서 필요한 핵심 정보만 아주 간단하게 소개했으므로, 더 자세한 내용을 알고 싶다면 Suspense 공식문서ErrorBoundary 공식문서를 참고해주세요 :)

Suspense 적용하기

Suspense를 동작시키기 위해서는 pending 중인 promise를 throw 해주는 것이 핵심입니다. 다음과 같이 promise라는 상태를 하나 만들고, fetch 함수내에서 request를 실행함과 동시에 promise 상태에 넣어주겠습니다.

const [promise, setPromise] = useState<Promise<void> | null>(null);

...

const fetch = () => {
  setStatus("pending");
  setPromise(request().then(resolvePromise, rejectPromise));
}

그러고나서 status가 pending 상태이고, promise에 데이터가 있다면 해당 promise를 throw 해줍니다.

if (status === "pending" && promise) {
  throw promise;
}

Suspense가 잘 작동하는지 다음 코드를 통해 확인해보겠습니다.

function App() {
  return (
    <Suspense fallback={<h1>Loading..</h1>}>
      <Item />
    </Suspense>
  );
}

function Item() {
  const { result } = useFetch(fetchData);

  return <h1>{result}</h1>;
}

데이터를 불러오는 동안 Suspense에 걸어둔 fallback 화면이 보이는 모습을 볼 수 있습니다.

ErrorBoundary 적용하기

ErrorBoundary는 사실 별거 없고 기존에 에러 처리하는 방식 처럼 발생한 에러를 throw 해주면 됩니다. 마침 useFetch에는 error 상태가 존재하고, 이 error를 rejected 상태일 때 throw 해주기만 하면 됩니다.

if (status === "rejected") {
  throw error;
}

ErrorBoundary가 잘 작동하는지 다음 코드를 통해 확인해보겠습니다. 참고로 ErrorBoundary 는 v18 기준 리액트에서 제공하는 API가 없기 때문에 직접 만들어야 하는데, 저 같은 경우는 공식 문서에있는 ErrorBoundary 코드를 가져다가 썼습니다.

function App() {
  return (
    <ErrorBoundary fallback={<h1>Error!</h1>}>
		<Suspense fallback={<h1>Loading..</h1>}>
	      <Item />
	    </Suspense>
    </ErrorBoundary>
  );
}

function Item() {
  const { result } = useFetch(fetchData);

  return <h1>{result}</h1>;
}

fetch 결과가 에러라면 ErrorBoundary 에 걸어준 fallback 컴포넌트를 렌더링하는 모습을 볼 수 있습니다.

suspense, errorBoundary 옵션으로 설정하기

SuspenseErrorBoundary를 활용하는 방식이 무조건적으로 좋은건 아닙니다. 상황에 따라 isLoading이나 isError 값으로 비동기를 다루는 방식이 더 효과적일 수 있죠. 이런 상황을 유연하게 대응할수 있도록 SuspenseErrorBoundary 기능을 사용자 마음대로 부여할수 있는 option 값을 설정해주겠습니다.

type Option = {
	suspense: boolean;
	errorBoundary: boolean;
}

const useFetch = <T>(request: () => Promise<T>, { suspense = true, errorBoundary = true }: Options = {}) => {
	...
	
	if (suspense && status === "pending" && promise) {
	  throw promise;
	}

	if (errorBoundary && status === "rejected") {
	  throw error;
	}

	...
}

useFetch의 option 인자 값으로 suspenseerrorBoundary 기능 여부를 선택할 수 있고, 위에서 처리한 분기문에 해당 옵션 값을 추가해줬습니다. 이렇게 해서 useFetch의 사용자는 선언적인 비동기 처리를 할지 말지 선택할 수 있게되었습니다.

완성된 useFetch 소스코드

type Status = "pending" | "fulfilled" | "rejected";

type Option = {
	suspense: boolean;
	errorBoundary: boolean;
}

const useFetch = <T>(request: () => Promise<T>, { suspense = true, errorBoundary = true }: Options = {}) => {
  const [status, setStatus] = useState<Status>("pending");
  const [result, setResult] = useState<T | null>(null);
  const [error, setError] = useState<Error | null>(null);
  const [promise, setPromise] = useState<Promise<void> | null>(null);

  const resolve = (newResult: T) => {
      setStatus("fulfilled");
      setResult(newResult);
  }

  const reject = (error: Error) => {
      setStatus("rejected");
      setError(error);
  }

  const fetch = () => {
    setStatus("pending");
    setPromise(request().then(resolvePromise, rejectPromise));
  }

  useEffect(() => {
    fetch();
  }, []);
          
  if (suspense && status === "pending" && promise) {
	throw promise;
  }

  if (errorBoundary && status === "rejected") {
	throw error;
  }

  return {
    result,
    status,
    isLoading: status === "pending",
    isError: status === "rejected",
    error
  };
}

마무리

이번 글에서는 useFetch라는 커스텀 훅을 만들어 비동기 처리를 모듈화하고 선언적으로 다루는 방법을 소개했습니다.

특히, SuspenseErrorBoundary를 활용하여 비동기 작업 중 로딩 상태와 에러 상태를 각각 선언적으로 처리하는 방법을 살펴보았는데요. 이러한 접근 방식은 코드의 가독성을 향상시키고 비동기 상태에 따른 컴포넌트 관심사를 명확하게 분리하여 유지보수를 용이하게 만들 수 있게 되었습니다.

useFetch 커스텀훅은 프로젝트에서 활발하게 사용되고 있고, 몇몇 요구사항에 따라 enabled, refetchInterval, onSuccess/onError 와 같은 기능들도 새롭게 추가되었습니다.

useFetch의 전체 코드를 보고싶다면 아래 깃허브를 통해 볼수 있고, NPM에도 배포했으니 필요하신 분이 있다면 npm install react-async-fetch 명령어를 통해 사용해보세요 :)

Github
NPM

profile
💻✏️🌱

0개의 댓글