프론트엔드 웹 서비스에서 우아하게 비동기 처리하기

Yumin Jung·2023년 1월 20일
1
post-thumbnail

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;
}

바뀐 코드의 경우

  1. 코드가 간결해졌습니다.
  2. 성공한 경우를 생각하는 x.foo.bar.baz와 문법적 차이가 크지 않습니다.
  3. 함수의 역할을 한눈에 파악할 수 있습니다.

다음 코드의 문제점은 무엇일까요?

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);
    })
  })
}
  1. 성공하는 경우와 실패하는 경우가 나뉘어 있지 않아 함수가 하는 진짜 역할이 가려집니다.
  2. 코드를 작성하는 입장에서 매번 에러 유무를 확인해야 합니다.

코드를 다시 작성해 봅시다.

async function fetchAccounts(callback) {
  const user = await fetchUserEntity();
  const accounts = await fetchUserAccounts(user.no);
  return accounts;
}

바뀐 코드의 경우

  1. 성공하는 경우만 다루고 실패하는 경우는 catch절에서 분리해 처리하여 함수가 하는 역할이 명확히 드러납니다.
  2. 실패하는 경우에 대한 처리를 외부에 위임할 수 있습니다.

이는 좋은 코드임을 드러내는 부분입니다.

좋은 코드의 특징

위에서 본 예시를 통해 좋은 코드는 다음과 같은 특징을 가진다는 것을 알 수 있습니다.

좋은 코드

  1. 성공, 실패의 경우를 분리해 처리할 수 있습니다.
  2. 비즈니스 로직을 한눈에 파악할 수 있습니다.

좋은 코드는 읽기 쉽고 함수의 책임이 명확히 드러납니다.

어려운 코드

  1. 실패, 성공의 경우가 서로 섞여 처리됩니다.
  2. 비즈니스 로직을 파악하기 어렵습니다.

어려운 코드는 함수의 크기가 커지고 하는 역할이 명시적으로 드러나지 못합니다.

프론트엔드 웹 서비스에서 비동기처리는 지금까지 어땠나요?

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를 가져오는데

  1. foo가 에러이면 실패 메시지를 보여주고
  2. foo가 없으면 로딩 중이라고 보여주고
  3. foo가 있으면 안녕하세요라는 메시지를 보여주었습니다.

여기서 우리는 이전의 문제점들이 거의 그대로 나타나고 있는 것을 볼 수 있습니다.

  1. 성공하는 경우와 실패하는 경우가 섞여서 처리되고 있으며
  2. 실패하는 경우에 대한 처리를 외부에 위임하기가 어려워졌습니다.

이러한 문제는 여러 개의 비동기 작업이 동시에 실행될 때 더 심각해집니다.

다음 코드는 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가 있어야 하는 상황에서

  1. foo를 가져오고
  2. bar는 foo가 로드될 때까지 기다리며
  3. if문이 복잠해지는 것을 볼 수 있습니다.

보통 하나의 비동기 작업은 [로딩 중, 에러, 완료됨]의 3가지의 상태를 가지는데
2개의 비동기 작업은 9가지의 상태를 가지며 이는 비동기 호출이 많아질수록 더욱 복잡해집니다.

만약 React 컴포넌트가 아니라 일반적인 비동기 함수라면?

일반적인 비동기 코드는 다음과 같이 async-await 스타일로 작성합니다.

async function fetchFooBar() {
  const foo = await fetchFoo();
  const bar = await fetchBar(foo);
  return bar;
}

위 코드의 경우

  1. 성공하는 경우에만 집중하여 복잡도를 낮추었습니다.
  2. 일반적으로 작성하는 동기 로직과 큰 차이가 없습니다.

React의 Hook이나 State를 사용하는 방식에서 비동기 처리는 어렵습니다.

  1. 성공하는 경우에만 집중해 컴포넌트를 구성하기 어렵습니다.
  2. 2개 이상의 비동기 로직이 개입할 때, 비즈니스 로직을 파악하기 점점 어려워집니다.

이러한 문제를 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>;
}
  1. 성공한 경우에만 집중할 수 있는 코드
  2. 로딩 상태와 에러 상태는 외부에 위임하여 분리된 코드
  3. 동기(async-await)와 거의 같게 사용할 수 있는 코드
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>
  1. 컴포넌트를 쓰는 쪽에서 로딩 처리와 에러 처리를 합니다.
  2. 로딩 상태는 가장 가까운 Suspense의 Fallback으로 그려집니다.
  3. 에러 상태는 가장 가까운 ErrorBoundary가 componentDidCatch()로 처리합니다.

이는 다음의 async-await의 try-catch문과 거의 유사한 구조를 가집니다.

try {
  await fetchFooBar();
} catch {
  //에러 처리를 하는 부분
}

비동기 콜을 하는 함수가 컴포넌트 가운데에 있고 실패하는 경우를 처리하는 부분이 그 부분을 감싸고 있습니다.

일반적인 Suspense / ErrorBoundary의 용법

다음 코드는 App 전체에서 로딩 상태와 에러 상태를 처리해주는 핸들러입니다.

<ErrorBoundary fallback={<MyErrorPage />}>
  <Suspense fallback={<Loader />}>
    <App />
  </Suspense>
</ErrorBoundary>

=> 어떻게 사용할 수 있는가?

사용하는 라이브러리에서 suspense를 사용한다고 선언해주면 사용이 가능합니다.

  1. Recoil의 Async Selector 기능
  2. SWR, React Query의 suspense: true 옵션

React Team의 Sebastian Markbage의 Proof-of-concept

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이라고 하는 비동기 호출을 보냅니다.

비동기 셀렉터로 Suspense 일으키기

위와 같이 정의된 비동기 리소스를 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와의 유사도

React Hooks는 선언적인 API로 코드 복잡도를 줄였습니다.

  • 이전 Class 컴포넌트에서는 컴포넌트의 라이프사이클에 맞춰 다양한 작업을 명령형으로 해주어야 했지만 Hooks를 사용하면서 선언적으로 작업이 가능해졌습니다.

Suspense의 경우

  1. 컴포넌트에서는 비동기적인 리소스를 선언하고 그 값을 읽어온다고 선언합니다.
  2. 로딩 상태, 에러 상태 처리는 컴포넌트를 감싸는 부모 컴포넌트가 수행합니다.

대수적 효과(Algebric Effects)

대수적 효과란 어떤 코드 조각을 감싸는 맥락으로 책임을 분리하는 방식을 말합니다.

이는 객체지향의 의존성 주입(DI), 의존성 역전(IoC)와도 유사하며 대수적 효과를 지원하는 언어에서 함수는 코드 조각을 선언적으로 사용합니다.

React에서 사용자 경험을 더욱 향상시킬 수 있는 키워드

  • React Concurrent Mode
  • useTransition, useDeferredValue

이 요소들을 사용하면 React에서 컴포넌트의 렌더 트리를 부분적으로 완성함으로써 사용자 경험을 크게 향상시킬 수 있습니다. 또한 비동기 작업뿐 아니라 기존에 Debounce 등으로 처리하던 무거운 동기적 작업에도 적용 가능합니다.

0개의 댓글