Web/React

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

이 글은 토스 개발자 컨퍼런스 SLASH 21 중 박서진 님의 프론트엔드 웹 서비스에서 우아하게 비동기 처리하기를 보고 내용을 정리한 글입니다. 정말 좋은 영상이니 여러분들도 꼭 보면 좋을 것 같습니다.

 

 

웹 서비스에서 다루기 어려운 "비동기"

비동기 프로그래밍은 프론트엔드 개발에 있어서 필수적인 존재다. 가장 큰 이유는 브라우저가 서버에 요청을 보낸 후에 아직 응답이 오지 않은 상황에서도 사용자와 인터랙션을 멈추면 안 되기 때문이다. 즉, 서버로 요청을 보내는 코드를 작성했을 때 해당 요청에 대한 응답이 담긴 변수의 값이 정해지기 전에도 다른 코드가 실행될 수 있어야 한다. 더 나은 사용자 경험을 위해서다. 그럼에도 불구하고, 비동기 개념은 초보 개발자들의 뒷목을 잡게 하고 어느 정도 개발을 해 본 개발자들도 다루기 어려워하고 있다. (물론 최근에는 비동기를 다루기 쉽게 하는 여러 라이브러리의 도움으로 인해 과거에 비해 개발자가 훨씬 편해지긴 했다.)

 

그렇다면 왜 개발자들은 비동기 개념과 친해지지 못하는 걸까? 이건 박서진 님의 의견은 아니고 내 생각이지만, 아마 우리가 동기적인 상황에서 훨씬 익숙하기 때문이라고 생각한다. 우리들은 코딩을 처음 배울 때부터 위에서부터 아래로 코드를 작성한 대로 코드가 동작할 것이라고 여기면서 이 순서가 보장된다고 무의식적으로 체화하고 있었다. 그래서 자바스크립트를 배울 때 초반에 제대로 개념을 잡지 못하고 어느 정도 시간이 지난 후 비동기 개념을 마주하면 더 헷갈려할 수도 있는 것이다. 가장 대표적인 예로 일반적인 코드와 Promise, setTimeout 등이 코드 작성 순서와 완전히 상관 없이 무조건 실행 순서가 이미 결정되어 있다는 것을 예로 들 수 있을 것 같다. 그렇다면 이렇게 다루기 어려운 개념을 어떻게 하면 더 쉽게 처리할 수 있을까?

 

좋은 코드가 무엇인지 생각해보자

좋은 코드란 무엇일까? 영상에서는 자신의 분명한 책임을 드러내는 함수와 변수, 응집도, 느슨한 결합, 의존성의 역전 등을 생각하는 개발자들도 있겠지만 가장 중요한 원칙은 여러 가지 상황에 대한 로직이 혼재되어 있지 않는 것이라고 말하고 있다. 쉬운 예시들부터 보면서 이 원칙을 정립해나가자.

 

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

 

위 코드는 좋은 코드라고 볼 수 있을까? 그렇지 않다. 하는 일은 단순하지만 코드가 너무 복잡하고, 각 프로퍼티에 접근하는 핵심 기능이 코드로 잘 드러나지 않기 때문이다. Optional Chaining을 사용하면 이 문제를 해결할 수 있다.

 

function getBazFromX(x) {
  return x?.foo?.bar?.baz;
}

 

이 코드는 코드가 간결하고 "성공한 경우"를 우선 생각하는 x.foo.bar.baz와 문법적 차이가 크지 않다. 그리고 함수의 역할을 한 눈에 파악할 수 있다. 그렇기 때문에 위에서 말한 나쁜 코드의 문제를 해결한 좋은 코드라고 볼 수 있을 것이다. 다음으로는 비동기 예제로 넘어가보자.

 

function fetchAccounts(callback) {
  fetchUserEntity((err, user) => {
    if (err != null) {
      callback(err, null);
      return;
    }

    fetchUserAccouts(user.no, (err, accounts) => {
      if (err != null) {
        callback(err, null);
        return;
      }

      callback(null, accounts);
    });
  });
}

 

이 함수는 fetchUserEntity를 호출해서 그 결과를 콜백으로 받고(이 과정에서 에러가 발생하면 에러를 emit한다.) 이 결과를 통해 사용자의 계좌 목록을 가져온(마찬가지로 이 과정에서도 에러가 발생하면 에러를 emit한다.) 뒤, 얻어온 값을 emit한다. 이 코드는 딱 봐도 잘 읽혀지지 않는데, 이 코드의 문제는 무엇일까?

 

  1. 성공하는 경우와 실패하는 경우가 섞여서 처리된다.
  2. 코드를 작성하는 입장에서 매번 에러 처리에 대한 코드가 작성되고 있다.

 

이제 위 코드를 async-await 문법을 통해 개선해보자.

 

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

 

이 코드는 위에서 언급한 문제를 전부 해결할 수 있다. 성공하는 경우만 다루고 있어서 실패하는 경우는 catch절 등을 토통해 따로 분리하여 처리할 수 있다. 그리고 이런 처리를 함수 외부에 위임할 수 있다는 큰 장점이 있다. 그리고 동기적인 함수와 생각의 흐름이 비슷하게 흘러가서 어떤 일을 할 지 예측이 잘 되고 이해하기 쉽다는 점도 좋다.

 

React의 비동기 처리 방식도 이렇게 바꿔보자

그래서 비동기를 우아하게 처리하는 방법에 대해 설명하다가 대신 좋은 코드의 특징에 대해서 설명한 이유는 뭘까? 이 "우아하게" 처리하는 방식이 좋은 코드를 작성하는 방식에서 기인하기 때문이다. 앞서 설명했듯 좋은 코드는 성공/실패의 경우를 분리해서 처리할 수 있고 비즈니스 로직을 한 눈에 파악할 수 있어야 한다. 이런 의미에서 현재의 React에서 비동기를 처리하는 방식은 좋은 코드라고 하기 힘들다는 단점이 있었다. 아래 React 코드를 보자.

 

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

 

현재 대부분의 프론트엔드 개발 환경에서 사용되는 query 형식의 비동기 처리 방식은 위와 같았다. (SWR, react-query 등이 사용하는, Promise를 반환하는 함수를 Hook의 인자로 넘기는 방식이다.) 위 코드는 성공하는 경우와 실패하는 경우가 혼재되어 있고 실패하는 경우에 대한 처리를 외부로 위임하기 힘들다는 단점을 그대로 답습하고 있다. 이 경우는 foo라는 값을 가져올 때 한 번 비동기 작업이 실행되지만, 만약 여러 값을 가져와야 하는 상황이라면 이 끔찍한 코드가 3의 (가져와야 하는 값의 개수)제곱만큼 더 복잡해진다.

 

 

보통 비동기 작업의 대한 결과가 pending, error, success 세 가지 경우로 나눠지기 때문이다. 각 값마다 이런 모든 경우를 예외 처리해야 한다면 정말 끔찍한 일이 아닐 수 없을 것이다.

 

이렇게 현재의 React 비동기 처리는 어렵다. 성공하는 경우에만 집중해서 컴포넌트를 구성하기 어렵고 여러 개의 비즈니스 로직이 개입할 때 비즈니스 로직을 파악하기 점점 어려워지기 때문이다. React 팀에서도 이를 인지하고 있었고, 이 문제를 해결하기 위한 방안을 내놓았다. 바로 Suspense다. (Suspense는 2022년 1월 현재 기준 아직 실험 단계에 있지만, 빠른 시일 내 정식 버전 업데이트가 이루어질 때 포함될 예정이다.)

 

Suspense는 async-await처럼 비동기 코드를 작성해서 간단하고 읽기 편한 코드를 만들기 위해 태어났다. 그리고 컴포넌트는 성공한 상태에만 관심을 두고, 로딩 상태와 에러 상태는 외부에 위임하게 된다. 위에서 좋은 코드라고 말한 코드의 기본 개념들을 따라가는 것이다. Suspense는 컴포넌트를 사용하는 쪽에서 로딩 처리와 에러 처리를 할 수 있도록 한다.

 

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

 

위는 Suspense를 활용하여 작성된 코드다. JS의 비동기 코드가 에러 처리를 catch문에서 하는 것처럼, 로딩 상태와 에러 상태도 컴포넌트를 사용하는 곳에서 한다. 로딩 상태는 FooBar 컴포넌트에서 가장 가까운 Suspense의 fallback으로 그려지며 에러 상태는 가장 가까운 ErrorBoundary가 componentDidCatch()로 처리한다.

 

우리가 사용하는 라이브러리에서도 Suspense를 사용하기 위한 옵션을 제공하고 있다. Recoil에서는 async selector라는 기능을 사용하고, SWR과 react-query에서는 { suspense: true } 라는 옵션을 사용하는 형태로 Suspense를 사용하겠다고 선언하게 된다. 이렇게 선언한 뒤로는 에러 처리와 로딩 상태 추적을 컴포넌트 내부에서 할 필요가 없다. 모든 에러 처리와 로딩 상태 추적은 외부로 위임하게 된다.

 

결론

비동기 처리를 "우아하게" 하는 방법이라는 것은 결국 어떻게 하면 좋은 코드를 쓸 수 있을까? 라는 물음에서 출발하게 되었다. 특정 비즈니스 로직에만 집중할 수 있는 코드가 좋다는 사실은 어떤 개발자든 간에 모두들이 알고 있는 사실이지만, 비동기 처리에서만큼은 이를 실현하고 있지 못했다. 이제 우리는 React Suspense를 통해 성공하는 경우에만 집중하는 컴포넌트를 작성함으로써 우리는 비동기 처리 코드를 동기 코드처럼 작성할 수 있게 되었다. 이런 방식으로 코드를 작성해서 프론트엔드 웹 서비스에서 "우아하게" 비동기 로직 처리를 한 번쯤 고려해보면 어떨까.

 

 

 

 

 

Reference

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