toss / slash21 영상을 정리한 글입니다.
비동기 프로그래밍은 코드의 실행 순서가 보장되지 않는 경우를 말합니다.
웹 브라우저가 서버에 요청을 보냈을때 다른 작업을 하면서 사용자에게 좋은 경험을 보여주다가 서버 응답이 돌아오면 다시 할 일을 하는 것이 비동기 프로그래밍의 대표적 예시입니다.
따라서 비동기 프로그래밍은 끊기지 않는 60FPS의 좋은 사용자 경험을 위해서 필수적이며 JavaScript의 경우에는 Callback, Promise, Observable과 같은 도구를 사용하여 비동기적 상황을 다루고 있습니다.
하지만 UI 프로그래밍에서 비동기 프로그래밍은 아직 어려운 부분입니다.
다음 코드를 봅시다.
function getBarzFromX(x) {
if (x === undefined) {
return undefined;
}
if (x.foo === undefined) {
return undefined;
}
if (x.foo.bar === undefined) {
return undefined;
}
return x.foo.bar.baz;
}
함수가 하는 일(x.foo.bar.baz 프로퍼티에 안전하게 접근하는 역할)은 단순하지만
1. 코드가 너무 복잡합니다.
2. 각 프로퍼티에 접근하는 핵심 기능이 코드로 잘 드러나지 않습니다.
코드를 ECMAScript에 추가된 Optional Chaning 문법을 활용하여 다시 작성해 봅시다.
function getBarzFromX(x) {
return x?.foo?.bar?.baz;
}
바뀐 코드의 경우
다음 코드의 문제점은 무엇일까요?
function fetchAccounts(callback) {
fetchUserEntity((err, user) => {
if (err != nil) {
callback(err, null);
return;
}
fetchUserAccounts(user.no, (err, accounts) => {
if (err != nil) {
callback(err, null);
return;
}
callback(null, accounts);
})
})
}
코드를 다시 작성해 봅시다.
async function fetchAccounts(callback) {
const user = await fetchUserEntity();
const accounts = await fetchUserAccounts(user.no);
return accounts;
}
바뀐 코드의 경우
이는 좋은 코드임을 드러내는 부분입니다.
위에서 본 예시를 통해 좋은 코드는 다음과 같은 특징을 가진다는 것을 알 수 있습니다.
좋은 코드는
좋은 코드는 읽기 쉽고 함수의 책임이 명확히 드러납니다.
어려운 코드는
어려운 코드는 함수의 크기가 커지고 하는 역할이 명시적으로 드러나지 못합니다.
API 호출과 같은 상황을 처리할 때 React의 경우 보통 다음과 같이 비동기를 처리하는 부분을 정의했습니다.
const { data, error } = useAsyncValue(() => {
return fetchSomething();
});
SWR이나 react-query와 같은 라이브러리를 많이 활용하여 Promise를 반환하는 함수를 React Hook의 인자로 넘기고 Promise의 상태 변화에 따라 Hook이 반환하는 data, error의 값을 적절히 채워주는 방식으로 작성했습니다.
그리고 다음과 같이 컴포넌트를 작성했습니다.
function Profile() {
const foo = useAsyncValue(() => {
return fetchFoo();
})
if (foo.error) return <div>로딩에 실패했습니다.</div>
if (!foo.data) return <div>로딩 중입니다...</div>
return <div>{foo.data.name}님 안녕하세요!</div>
}
비동기인 foo를 가져오는데
여기서 우리는 이전의 문제점들이 거의 그대로 나타나고 있는 것을 볼 수 있습니다.
이러한 문제는 여러 개의 비동기 작업이 동시에 실행될 때 더 심각해집니다.
다음 코드는 foo와 bar 값을 비동기로 가져오는 상황입니다.
function Profile() {
const foo = useAsyncValue(() => {
return fetchFoo();
})
const bar = useAsyncValue(() => {
if (foo.error || !foo.data) {
return undefined;
}
return fetchBar(foo.data);
})
if (foo.error || bar.error) return <div>로딩에 실패했습니다.</div>
if (!foo.data || !bar.data) return <div>로딩 중입니다...</div>
return /* foo와 bar로 적합한 처리하기 */;
}
bar를 가져오기 위해서는 foo가 있어야 하는 상황에서
보통 하나의 비동기 작업은 [로딩 중, 에러, 완료됨]의 3가지의 상태를 가지는데
2개의 비동기 작업은 9가지의 상태를 가지며 이는 비동기 호출이 많아질수록 더욱 복잡해집니다.
일반적인 비동기 코드는 다음과 같이 async-await 스타일로 작성합니다.
async function fetchFooBar() {
const foo = await fetchFoo();
const bar = await fetchBar(foo);
return bar;
}
위 코드의 경우
React의 Hook이나 State를 사용하는 방식에서 비동기 처리는 어렵습니다.
이러한 문제를 React Suspense for Data Fetching으로 해결할 수 있습니다.
React Suspense for Data Fetching이 목표로 하는 코드는 다음과 같습니다.
function FooBar() {
const foo = useAsyncValue(() => fetchFoo());
const bar = useAsyncValue(() => fetchBar(foo));
return <div>{foo}{bar}</div>;
}
function FooBar() {
const foo = useMemo(() => fetchFoo());
const bar = useMemo(() => fetchBar(foo), [foo]);
return <div>{foo}{bar}</div>;
}
useAsyncValue를 동기적인 계산을 하는 useMemo로 치환하면 완벽히 똑같은 구조를 가지고 있는 것을 확인할 수 있습니다. React Suspense for Data Fetching은 이러한 useAsyncValue와 같은 Hook을 만들 수 있는 Low-level API를 제공합니다.
<ErrorBoundary fallback={<MyErrorPage />}>
<Suspense fallback={<Loader />}>
<FooBar />
</Suspense>
</ErrorBoundary>
이는 다음의 async-await의 try-catch문과 거의 유사한 구조를 가집니다.
try {
await fetchFooBar();
} catch {
//에러 처리를 하는 부분
}
비동기 콜을 하는 함수가 컴포넌트 가운데에 있고 실패하는 경우를 처리하는 부분이 그 부분을 감싸고 있습니다.
다음 코드는 App 전체에서 로딩 상태와 에러 상태를 처리해주는 핸들러입니다.
<ErrorBoundary fallback={<MyErrorPage />}>
<Suspense fallback={<Loader />}>
<App />
</Suspense>
</ErrorBoundary>
=> 어떻게 사용할 수 있는가?
사용하는 라이브러리에서 suspense를 사용한다고 선언해주면 사용이 가능합니다.
function getUserName(id) {
var user = JSON.parse(fetchTextSync('/users/' + id));
return user.name;
}
function getGreeting(name) {
if (name === 'Seb') {
return 'Hey';
}
return fetchTextSync('/greeting');
}
function getMessage() {
let name = getUserName(123);
return getGreeting(name) + ', ' + name + '!';
}
runPureTask(getMessage).then(message => console.log(message));
fetchTextSync함수는 API호출로 비동기 작업이지만 동기처럼 사용되는 것을 볼 수 있으며 이는 runPureTask라고 하는 런타임에 의해 가능합니다.
알림이나 푸시를 보낼 때 사용하는 TUBA 메신저의 메시지 상세 화면에서 상당히 복잡한 비동기 처리가 필요했으며 이를 Recoil의 비동기 셀렉터를 이용하여 해결했습니다.
export const templateSetSelector = selectorFamily({
key: '@messages/template-set',
get: (no:number) => async () => {
return fetchTemplateSet(no);
},
});
export const historiesOfTemplateSetSelector = selectorFamily({
key: '@pages/messanger/template-set/histories',
get: (templateSetNo: number) => async ({get}) => {
return fetchHistoriesOfTemplateSet(templateSetNo);
},
});
위 코드는 templateSetSelector는 no라는 번호를 인자로 받아 fetchTemplateSet이라고 하는 비동기 호출을 보냅니다.
위와 같이 정의된 비동기 리소스를 useRecoilValue를 이용해서 가져오려고 하면 Suspense가 발생합니다. useRecoilValue의 밑에서는 templateSet을 가져왔다는 것을 타입적으로 완전히 보장합니다.
function TemplateSetDetails({ templateSetNo }: Props) {
const templateSet = useRecoilValue(templateSetSelector(templateSetNo));
/* 이 아래에서는 templateSet이 존재하는 것이 보장됨 */
}
다음으로 비동기 호출을 하는 컴포넌트를 적절히 Suspense로 감싸주기만 하면 됩니다.
<Suspense fallback={<Skeleton />}>
<TemplateSetDetails />
</Suspense>
Redux나 다른 도구들을 이용해서 처리했다면 굉장히 복잡한 비동기 처리를 Recoil과 Suspense를 이용하여 굉장히 간단하게 바꿀 수 있습니다.
사용자 경험 측면에서도 데이터가 준비되는 대로 하나씩 자연스럽게 보여줄 수 있습니다.
React Hooks는 선언적인 API로 코드 복잡도를 줄였습니다.
Suspense의 경우
대수적 효과란 어떤 코드 조각을 감싸는 맥락으로 책임을 분리하는 방식을 말합니다.
이는 객체지향의 의존성 주입(DI), 의존성 역전(IoC)와도 유사하며 대수적 효과를 지원하는 언어에서 함수는 코드 조각을 선언적으로 사용합니다.
이 요소들을 사용하면 React에서 컴포넌트의 렌더 트리를 부분적으로 완성함으로써 사용자 경험을 크게 향상시킬 수 있습니다. 또한 비동기 작업뿐 아니라 기존에 Debounce 등으로 처리하던 무거운 동기적 작업에도 적용 가능합니다.