들어가면서
이 글은 Toss팀의 “프론트엔드 웹 서비스에서 우아하게 비동기 처리하기” 세션을 시청하고 실제로 적용해보면서 정리한 글입니다. 비동기 처리에 대한 전략을 제시해주는 좋은 영상인 것 같습니다. 꼭 시청해보시길 권장드립니다. 😀
비동기에서의 좋은 코드
비동기에서의 복잡도
만약 처리해야 할 비동기 함수가 M개이고, 각각의 비동기 함수는 N개의 상태를 가진다면, 확인해야 할 경우의 수가 많아집니다.
비동기에서의 좋은 코드란?
- 함수의 역할을 쉽게 파악할 수 있는 간결한 코드
- 비동기의 상태를 사용하는 입장에서 신경쓰지 않아도 되는 코드
Promsie
import { UserInfo } from '../types/user';
const fetchUserInfo = (userId: string, callback: (data: UserInfo | null) => void) => {
//회원 유무를 확인한다.
fetch('/auth', {
method: 'POST',
body: JSON.stringify({ userId }),
headers: { 'Content-Type': 'application/json' }
})
.then((res) => res.json())
.then((data) => {
if (data.success && data.token) {
//회원 정보를 요청한다.
fetch('/userInfo', {
method: 'POST',
body: JSON.stringify({ token: data.token }),
headers: { 'Content-Type': 'application/json' }
})
.then((res) => res.json())
.then((userInfo) => {
callback(userInfo);
})
.catch((error) => {
console.error(error);
});
} else {
callback({ data: null, errorMessage: '로그인에 실패하였습니다.', success: false });
}
})
.catch((error) => {
callback({ data: null, errorMessage: error, success: false });
});
};
export default fetchUserInfo;
보통 자바스크립트에서 데이터를 불러오기 위해 위와 같이 promise
를 많이 사용합니다. 이전에 등장한 callback 문법보다 복잡도 측면에서 상당한 개선이 이루어졌지만, 데이터를 두 번 불러와야 하는 상황에서는 여전히 promise 지옥이라 불리는 중첩 현상이 발생할 수 있습니다. 위의 코드에서는 결국 두 문제가 발생될 수 있습니다.
- 중첩 구조로 되어 있어 함수의 역할을 한 눈에 파악이 어렵다.
- 성공과 실패의 경우가 섞여있고, 요청할 때 마다 매번 오류 유무를 확인해야한다.
async-await
const fetchUserInfo = async (userId: string, callback: (data: UserInfo | null) => void) => {
try {
//회원 유무를 확인한다.
const userCheck = await fetch('/auth', {
method: 'POST',
body: JSON.stringify({ userId }),
headers: { 'Content-Type': 'application/json' }
}).then((res) => res.json());
//회원 정보를 조회한다.
const userInfo = await fetch('/userInfo', {
method: 'POST',
body: JSON.stringify({ token: userCheck.data.token }),
headers: { 'Content-Type': 'application/json' }
}).then((res) => res.json());
callback(userInfo);
} catch (e) {
callback({ data: null, errorMessage: '로그인에 실패하였습니다.', success: false });
}
};
export default fetchUserInfo;
async-await
을 사용하면 일반적인 동기식 코드로 표현이 가능해서 가독성이 높아지고, 실패하는 경우에만 집중할 수 있어 도메인 로직을 더욱 명확하게 작성할 수 있습니다. 이 fetching 함수를 사용하는 입장에서는 success
유무로 실패의 상황을 추론할 수 있으니까요.
React에서의 비동기 처리
이제 React에서 비동기 처리 방법을 살펴보겠습니다. 기본적으로 적용할 수 있는 간단한 방법은 데이터를 가져오는 함수를 컴포넌트 로직에서 분리하는 것입니다.
fetching 모듈화
//📂 services/fetchFollowers
const fetchFollowers = async (callback: (data: FollowerList | null) => void) => {
try {
const followerList: FollowerList = await fetch('/followers').then((res) => res.json());
callback(followerList);
} catch (e) {
callback({ data: [], errorMessage: 'Server Error!!', success: false });
}
};
export default fetchFollowers;
//📂 components/followers.tsx
export const Followers = () => {
const [followers, setFollowers] = useState<FollowerList | null>(null);
useEffect(() => {
fetchFollowers((res) => {
setFollowers(res);
});
}, []);
return (
<div css={flexChildren}>
<h2>Followers</h2>
{!followers && <strong>Loading....</strong>}
{!followers?.success && <strong>{followers?.errorMessage}</strong>}
{followers?.success &&
followers.data.map((follower) => {
return (
<div key={follower.id}>
<p>
<strong>{follower.name}</strong>
</p>
<img alt={follower.name} src={follower.avatar} />
</div>
);
})}
</div>
);
};
컴포넌트에서는 데이터를 요청하는 함수를 호출하고, 반환되는 결과 값만 도메인 로직에 사용하면 되기 때문에 fetching 함수와 UI 로직이 공존하는 경우보다 좋은 코드라고 볼 수 있습니다. 하지만 여기에도 몇 가지 문제가 존재합니다.
- 코드를 작성하는 입장에서 에러의 유무를 확인해야한다.
- 성공하는 경우와 실패하는 경우를 처리하는 로직이 공존한다.
- 비동기 호출 리소스가 많아질수록 컴포넌트 내 복잡도가 증가한다.
Fetching State
우선 “코드를 작성하는 입장에서 에러의 유무를 확인” 하는 부분을 서버 상태 라이브러리(swr, react-query 등)를 사용하여 개선해보겠습니다. 명확하게 책임을 생각해보면, 컴포넌트는 UI에만 신경써야하고 팔로워들의 목록을 가져오는 fetchFollowers
함수는 데이터를 서빙하고, 결과를 사용하는 컴포넌트에게 전달만 해주면 됩니다. 하지만 UI 컴포넌트에서는 해당 UI에서 사용할 데이터들이 로딩중인지, 에러가 발생했는지, 성공했으면 데이터가 잘 받아져왔는지 세 가지의 상황도 신경써야하는 문제가 존재합니다.
//📂 services/fetchFollowers
const fetchFollowers = async (): Promise<FollowerList> => {
try {
const res = await fetch('/followers');
return res.json();
} catch (e) {
console.error(e);
throw e;
}
};
export default fetchFollowers;
//📂 components/followers.tsx
export const Followers = () => {
const {
isLoading,
isError,
data: response
} = useQuery('followers', fetchFollowers, {
refetchOnWindowFocus: false
});
return (
<div css={flexChildren}>
<h2>Followers</h2>
{isLoading && <strong>Loading....</strong>}
{isError && <strong>{response?.errorMessage}</strong>}
{response?.data &&
response.data.map((follower) => {
return (
<div key={follower.id}>
<p>
<strong>{follower.name}</strong>
</p>
<img alt={follower.name} src={follower.avatar} />
</div>
);
})}
</div>
);
};
우선 fetch state api
사용하게 됨으로써, 비동기 요청 상태를 더욱 명확하게 확인할 수 있습니다. 이제 언급드렸던 문제에서 남은 문제를 살펴보도록 하겠습니다. (여전히 실패하는 경우, 로딩 상태에 대한 처리는 컴포넌트 계층에서 처리하고 있다.) React에서 공식적으로 제공하는 Suspense
와 ErrorBoundary
컴포넌트를 통해 이를 외부에 위임시킬 수 있습니다.
Suspense
React 18
버전에서 공식적으로 릴리즈된 기능으로, Suspense
컴포넌트를 활용하면 컴포넌트 로드 상태에 따라 fallback
컴포넌트를 우선적으로 렌더링해주는 기능입니다. 이를 사용하면 컴포넌트 내부에서 비동기 요청 후 로직에 필요한 성공 데이터만을 다루게 됩니다. react-query
등 여러 서드파티 라이브러리에서는 suspense 감지를 위한 옵션을 제공합니다.
예제에서 사용한 react-query
에서는 suspense 옵션을 통해 감지 옵션을 활성화시킬 수 있습니다.
const useFetchBoard = () => {
const { showBoundary } = useErrorBoundary();
try {
//react-query suspense
const { data: response } = useQuery('board', fetchPostsApi, {
refetchOnWindowFocus: false,
suspense: true
});
if (response && response.data) {
return response.data;
}
throw 'API 통신에 실패하였습니다.';
} catch (error) {
showBoundary(error);
}
};
export default useFetchBoard;
실제 Suspense
컴포넌트 사용법은 아래와 같습니다.
- children: 실제 렌더링하려는 UI 컴포넌트를 넘겨줍니다.
- fallback: children
의 로드가 완료되지 않을 때 대체로 렌더링 할 UI 컴포넌트를 전달합니다. (Spinner, Skeleton UI 등 가벼운 UI 컴포넌트를 권장하고 있습니다.)
<Suspense fallback={<Loading />}>
<SomeComponent />
</Suspense>
ErrorBoundary
ErrorBoundary
컴포넌트는 하위에 선언된 컴포넌트를 렌더링할 때 JavaScript 오류를 감지하고 fallback
에 전달한 대체 컴포넌트를 렌더링해줍니다. React 라이프사이클 계층과 하위 트리 생성자에서 발생하는 오류를 탐지할 수 있다고 합니다.
<ErrorBoundary fallback={<Error />}>
<Profile />
<Suspense fallback={<Loading />}>
<Menu />
<Board />
<Followers />
</Suspense>
</ErrorBoundary>
Suspense와 ErrorBoundary 컴포넌트를 활용하여 Follower 컴포넌트에서 더 이상 비동기 요청에 대해 직접 관리할 필요가 없습니다. 대신 부모 컴포넌트에서 에러와 fetching 상황 처리에 대한 책임을 위임할 수 있게 되었습니다.
import { flexChildren } from '@styles/createFlexStyle';
import fetchFollowers from '../services/useFetchFollowers';
export const Followers = () => {
const follwerList = fetchFollowers();
return (
<div css={flexChildren}>
<h2>Followers</h2>
{follwerList?.map((follower) => {
return (
<div key={follower.id}>
<p>
<strong>{follower.name}</strong>
</p>
<img
alt={follower.name}
src={follower.avatar}
css={{ width: '64px', height: '64px', border: '1px solid #ededed', borderRadius: '50%' }}
/>
</div>
);
})}
</div>
);
};
이점을 정리하자면 다음과 같습니다.
- 데이터 요청시에 발생하는 불규칙한 waterfall을 순차적으로 처리할 수 있게 되었습니다.
- 컴포넌트에서는 단순히 데이터를 요청하고, 도메인 로직에만 신경쓰면 됩니다.
- 실패하는 경우
throw
를 통해 예외 케이스를 발생시키고 예외 처리는 부모 컴포넌트에게 위임합니다. (대수적 효과)
마치며
Suspense와 ErrorBoundary 컴포넌트를 사용하여 컴포넌트 내부에서 비동기 처리를 외부로 위임함으로써, 코드 유지보수성이 향상될 것으로 기대됩니다. 이러한 설계를 통해, 많은 수의 컴포넌트 내부에서 발생하는 오류를 미리 예방하여 경계를 구축할 수 있습니다. 또한 프로그램이 더욱 견고하고 안정적이게 됩니다. 이러한 기술적인 개선은 전체적인 코드 품질과 유지보수성을 높일 뿐만 아니라, 사용자 경험에도 긍정적인 영향을 미칠 것입니다.
이 예제 코드는 github에 올려두었으니 참고해주시면 감사하겠습니다. 비동기 요청 상황을 가정하기 위해 msw
모킹 API를 사용하였습니다.