프론트엔드 개발을 하며 비동기 작업을 잘 처리하는 것은 사용자 경험 측면에서나 개발 경험 측면에서 매우 중요합니다. 특히, 서버와의 통신과 같이 시간이 오래 걸리는 작업을 수행할 때, 성공한 응답을 다루는 것 외에도 적절한 로딩 화면, 에러 피드백 등을 보여주는 것은 사용자 경험에 큰 영향을 주는데요. 이번 글에서는 이러한 서버 비동기 상태를 효율적으로 다루기 위해 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
의 동작 과정은 다음과 같습니다.
useEffect
로 컴포넌트가 마운트 될 때 fetch
함수를 실행한다.fetch
는 status
를 pending으로 설정하고, request
를 실행한다.request
가 성공하면 status
를 fulfilled로 설정하고, result
에 데이터를 저장한다.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
사용 예시를 보면, 비동기 작업이 성공
할 때와 실패
할 때, 그리고 로딩 중
일 때의 처리를 모두 한 컴포넌트에서 해주고 있습니다.
이런식으로 여러 비동기 상태가 섞여서 처리된다면 비즈니스 로직을 한눈에 파악하기 어렵습니다. 또한, 다뤄야할 비동기 작업이 여러개가 된다면 다뤄야 할 비동기 상태 분기가 기하급수적으로 늘어나게 되고, 이는 코드 유지보수를 어렵게합니다. 자세한 내용은 토스 박서진님의 발표 영상을 참고하신다면 더욱 이해하기 쉬워집니다.
이러한 문제를 해결하기 위해, 컴포넌트에는 성공
상태만 남기고, 로딩
상태와 실패
상태는 각각 Suspense
와 ErrorBoundary
를 통해 외부로 위임하도록 하여 선언적으로 처리할 수 있도록 하겠습니다.
그전에, Suspense
와 ErrorBoundary
는 무엇이고, 동작방식은 어떻게 될까요? 간단하게 요약해봤습니다.
Suspense
는 하위 컴포넌트에서 fetching 중인 데이터가 있다면 로딩 화면을 렌더링하는 리액트 기능이다.Promise
객체를 감지해서 동작한다.ErrorBoundary
는 하위 컴포넌트에서 에러가 발생할 경우 에러 화면을 렌더링 하는 기능이다.Error
객체를 감지해서 동작한다.// Suspense와 ErrorBoundary의 사용 방식
<ErrorBoundary fallback={<div>error caught</div>}>
<Suspense fallback={<div>loading...</div>}>
<Component/>
</Suspense>
</ErrorBoundary>
이 글에서 필요한 핵심 정보만 아주 간단하게 소개했으므로, 더 자세한 내용을 알고 싶다면 Suspense 공식문서와 ErrorBoundary 공식문서를 참고해주세요 :)
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
는 사실 별거 없고 기존에 에러 처리하는 방식 처럼 발생한 에러를 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
를 활용하는 방식이 무조건적으로 좋은건 아닙니다. 상황에 따라 isLoading
이나 isError
값으로 비동기를 다루는 방식이 더 효과적일 수 있죠. 이런 상황을 유연하게 대응할수 있도록 Suspense
와 ErrorBoundary
기능을 사용자 마음대로 부여할수 있는 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 인자 값으로 suspense
와 errorBoundary
기능 여부를 선택할 수 있고, 위에서 처리한 분기문에 해당 옵션 값을 추가해줬습니다. 이렇게 해서 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
라는 커스텀 훅을 만들어 비동기 처리를 모듈화하고 선언적으로 다루는 방법을 소개했습니다.
특히, Suspense
와 ErrorBoundary
를 활용하여 비동기 작업 중 로딩 상태와 에러 상태를 각각 선언적으로 처리하는 방법을 살펴보았는데요. 이러한 접근 방식은 코드의 가독성을 향상시키고 비동기 상태에 따른 컴포넌트 관심사를 명확하게 분리하여 유지보수를 용이하게 만들 수 있게 되었습니다.
useFetch
커스텀훅은 프로젝트에서 활발하게 사용되고 있고, 몇몇 요구사항에 따라 enabled
, refetchInterval
, onSuccess/onError
와 같은 기능들도 새롭게 추가되었습니다.
useFetch
의 전체 코드를 보고싶다면 아래 깃허브를 통해 볼수 있고, NPM에도 배포했으니 필요하신 분이 있다면 npm install react-async-fetch
명령어를 통해 사용해보세요 :)