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

흑우·2023년 8월 10일

SLASH 21

목록 보기
1/2

서론

예전에 토스에서 진행했던 SLASH 21이라는 컨퍼런스에서 소개된 영상을 가볍게 정리한 게시글입니다. 프론트엔드에서 비동기 처리를 하는 방법을 설명해주셨는데요. React의 Suspense와 recoil의 selector에 대해서 다뤘습니다. 지금 진행 중인 개인 프로젝트에서 적용해 볼 예정입니다. 설명하시는 분이 설명을 잘하셔서 저같은 초보자도 쉽게 이해할 수 있습니다. 간단하게 정리한 게시글이기 때문에 되도록이면 영상을 보는 것을 추천드립니다. 영상 링크는 하단에 있습니다.

좋은 코드의 기준은 무엇일까?

이 코드는 왜 나쁜 코드일까?

funtion getBazFromX(x){
  if (x === undefined) {
   return undefined; 
  }
  
  if (x.foo === undefined) {
   return undefined; 
  }
  
  if (x.foo.bar === undefined) {
   return undefined; 
  }
  
  return x.foo.bar.baz;
}
  • 하는 일은 단순하지만 코드가 복잡하다.
  • 각 프로퍼티에 접근하는 핵심 기능이 코드로 잘 드러나지 않는다.

이 코드는 왜 좋은 코드일까?

function getBazFromX(){
 return x?.foo?.bar?.baz; 
}
  • Oprional Chaining 문법을 활용한 동일한 함수
  • 코드가 간결하다.
  • '성공한 경우'를 생각하는 x.foo.bar.baz와 문법적 차이가 크지 않다.
  • 함수의 역할을 한눈에 파악할 수 있다.

이 코드의 문제점은 무엇일까?

function fetchAccounts(callback){
  fetchUserEntity((err, user) => {
    if (err != null) {
      callback(err,null);
      return;
    }
    
    fetchUserAccounts(user.no, (err, accounts) => {
      if (err != null) {
        callback(err, null);
        return;
      }
      
      callback(null, accounts);
    })
  })
}
  • '성공하는 경우'와 '실패하는 경우'가 섞여서 처리된다
    • 코드를 작성하는 입장에서는 매번 에러 유무를 확인해야 한다.

이 코드는 왜 좋은 코드일까?

async function fetchAccounts(callback){
  const user = await fetchUserEntity();
  const accounts = await fetchUserAccounts(user.no)
  return accounts;
}
  • 비동기 요청이 성공하는 경우만 다루고 실패하는 경우는 catch 절에서 분리해 처리한다.
  • 실패하는 경우에 대한 처리를 외부에 위임할 수 있다.

좋은 코드의 특징

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

어려운 코드의 특징

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

프론트엔드에서의 비동기 처리

SWR / React Query 같은 데이터 패칭 라이브러리 사용

const { data, error } = useAsyncValue(() => {
  return fetchSomething();
})
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>
}
  • 성공하는 경우와 실패하는 경우가 섞여서 처리된다.
  • 실패하는 경우에 대한 처리를 외부에 위임하기 어렵다.

여러 개의 비동기 작업이 동시에 실행된다면?

function Profile(){
 const foo = useAsyncValue(() => {
   returb fetchFoo();
 })
 
 const bar = useAsyncValue(() => {
   if (foo.error || !foo.data){
     return undefined;
   }
   returb fetchBar(foo.data);
 });
  
  if (foo.error || bar.error) return <div>로딩에 실패했습니다.</div>
  if (foo.error || bar.error) return <div>로딩 중입니다...</div>
  return /* foo와 bar로 적합한 처리하기 */
}
  • 하나의 비동기 작업은 3개의 상태를 가집니다. 만약 2개의 비동기 작업이 있다면 3의 제곱으로 9가지의 상태를 가질 수 있습니다.

React의 비동기 처리는 어렵다

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

Suspense로 이러한 문제점을 해결하자!

Suspense가 목표로하는 코드

  • 성공한 경우에만 집중할 수 있는
  • 로딩 상태와 에러 상태가 분리된
  • 동기와 거의 같게 사용할 수 있는

비동기로 데이터를 패칭하는 컴포넌트

function FooBar() {
  const foo = useAsyncValue(() => fetchFoo());
  const bar = useAsyncValue(() => fetchBar());
  
  return <div>{foo}{bar}</div>
}

에러 상태와 로딩 상태는 어떻게 분리되는가?

<ErrorBoundary fallback={<MyErrorPage />}>
  <Suspense fallback={<Loader />}>
  	<FooBar />  
  </Suspense>
</ErrorBoundary>
  • 컴포넌트를 쓰는 쪽에서 로딩 처리와 에러 처리를 한다.
  • 로딩 상태는 가장 가까운 Suspense의 fallback으로 그려진다.
  • 에러 상태는 가장 가까운 ErrorBoundary가 componentDidCatch()로 처리한다.

일반적인 Suspense / ErrorBoundary의 용법

  • 모든 코드에 적용한다는 개념이 아니라 적절한 단위를 정해서 적용한다.

어떻게 사용할 수 있는가?

  • Recoil : Async Selector
  • SWR / React Query : {suspense: true}
    • 해당 옵션을 설정하면 자동으로 suspense에 의해 관리된다.

토스팀에서 적용한 사례

Recoil의 비동기 셀렉터

export const templateSetSelector = selectorFamily({
  key: '@messages/template-set',
  get: (no: number) => async () => {
    return fetchTemplateSet(no)
  }
})
export const historiesOfTemplateSetSelector = selectorFamily({
  key: '@pages/messenger/template-set/histories',
  get: (templateSetNo: number) => async ({ get }) => {
    return fetchHistoriesOfTemplateSet(templateSetNo)
  }
})
  • recoil 에서는 비동기 리소스를 selector 또는 selectorFamily로 정의할 수 있습니다.

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

funtion TemplateSetDetails({ templateSetNo }: Props){
 const templateSet = useRecoilValue(templateSetSelector(templateSetNo)) 
}

/* 이 아래에서는 templateSet이 존재하는 것이 보장됨 */
<Suspense fallback={<Skeleton />}>
	<TemplateSetDetails />  
</Suspense>
  • TemplateSetDetails 컴포넌트에서 useRecoilValue를 이용해 templateSetSelector를 가져오려고 하면 Suspense가 발생하게 됩니다.

웹 서비스의 코드 복잡도를 낮춘 방법 : Hooks

  • useState : 상태 사용을 선언
  • useMemo : 메모이제이션 사용을 선언
  • useCallback : 콜백 레퍼런스 보존을 선언
  • useEffect: 부수 효과 발생을 선언
  • 실제 상태 관리, 메모이제이션 등의 작업은 컴포넌트를 감싸는 React 프레임워크가 수행

웹 서비스의 코드 복잡도를 낮춘 방법 : Suspense

  • Suspense : 비동기적 리소스에 접근 선언
  • 실제 로딩 상태, 에러 상태 처리는 컴포넌트를 감싸는 부모 컴포넌트가 수행

에러 처리의 복잡도를 낮춘 방법: try-catch

  • trt-catch : 실패할 수 있는 함수는 에러를 throw 문으로 발생시킴
  • 실제 에러 처리는 컴포넌트를 감싸는 부모 함수가 수행

대수적 효과

  • 함수는 필요한 코드 조각을 선언적으로 사용
    • useMemo, 비동기 값 읽어오기, ...
  • 실제 관련된 처리는 컴포넌트를 감싸는 부모 함수에 위임

못 다한 이야기

  • React Concurrent Mode
  • useTransition, useDeferredValue
  • React에서 컴포넌트의 렌더 트리를 부분적으로만 완성함으로써 사용자 경험을 크게 향상 시킬 수 있습니다.
    • 비동기 작업 뿐만 아니라 Debounce 등으로 처리하던 무거운 동기적 작업에도 적용할 수 있다.

References

profile
흑우 모르는 흑우 없제~

0개의 댓글