상세 컨텐츠

본문 제목

React Suspense 동작 원리 이해와 직접 구현해 보기

FrontEnd

by 뎁희 2024. 6. 2. 11:14

본문

Suspense는 비동기 작업이 실행되면 이를 감지하고, 서스펜드 상태로 전환하여 작업이 완료될 때까지 fallback UI를 보여줄 수 있도록 도와준다. 

 

Q. 비동기 작업을 어떻게 감지할까요?

A. 컴포넌트 안에 비동기 함수가 있으면 Suspense가 찰떡 같이 알아보고 실행되는 거 아닌가요? 🙄

 

그래서 어떻게 찰떡 같이 알아볼까?

  1. 비동기로 데이터를 받아오기 위해 `useSuspenseQuery` 사용
  2. 에러 처리를 위해 `ErrorBoundary` 적용
  3. `Suspense` 적용

에러가 나면 그 에러를 해결하기 위해 다음을 적용하면서 에러 돌려 막기가 되어버렸다. 마지막 Suspense까지 적용을 끝내고 새로운 에러를 만나 해결하다가 개념 정리를 위해 정리해 본다.


동작 원리 생각해 보기

Suspense는 비동기 함수로 인해 데이터가 로드되기 전까지 fallback UI를 보여준다. 따라서 Suspense는 비동기 함수의 실행 여부를 알 수 있어야 하며, 정확히는 비동기 함수가 반환한 Promise를 감지해야 한다. 컴포넌트 내부에서 Promise가 던져지면 서스펜드 상태로 전환되고, 해당 Promise가 resolve가 될 때까지(즉, 데이터가 로드될 때까지) 유지되며 fallback UI를 표시한다. 데이터가 모두 로드되면 서스펜드 상태가 해제되고 컴포넌트가 다시 렌더링 된다. 만일 Promise가 reject 되면 상위 ErrorBoundary가 이를 캐치하여 처리할 수 있다.

 

useEffect의 실행 시점

const TodoList = () => {
  const [todos, setTodos] = useState([]);

  useEffect(() => {
    const fetchData = async () => {
      const result = await fetchTodos();
      setTodos(result);
    };
    fetchData();
  }, []);
  
  ...
  }

보통 useEffect 내부에 비동기 함수를 호출하여 상태를 저장하는 방식을 사용하게 된다. 여기서 중요한 점은 useEffect는 사이드 이펙트를 다루기 위한 훅이며, 컴포넌트가 렌더링이 된 이후에 실행된다는 점이다.

 

  1. 컴포넌트 렌더링 시작: TodoList 컴포넌트 렌더링이 시작된다.
  2. useState 상태 초기화: useState를 사용하여 초기값으로 상태를 초기화한다.
  3. return부가 화면에 그려짐: 컴포넌트의 return 부분이 처음으로 화면에 그려진다. 이 단계에서는 아직 useEffect가 실행되지 않았기 때문에 초기 UI가 표시된다.
  4. useEffect 실행: 컴포넌트가 화면에 렌더링 된 후에 useEffect가 실행된다. 
  5. setTodos 상태 변화로 리렌더링: useEffect 내의 비동기 함수가 완료되고, setTodos 상태 업데이트 함수가 호출로 상태가 변경되면 컴포넌트는 다시 렌더링 된다.
  6. 상태가 변경된 후 UI 업데이트: 컴포넌트는 새로운 상태를 반영하여 UI를 다시 그린다.

이 과정을 통해 컴포넌트는 초기 렌더링 시 기본 UI를 표시하고, 이후 useEffect에서 데이터를 가져와 상태를 업데이트하여 UI를 업데이트한다.

 

function App() {
  return (
    <Suspense fallback={<div>Loading...</div>}>
      <TodoList />
    </Suspense>
  );
}

그렇기 때문에 TodoList를 Suspense로 감싸도 로딩 UI를 보여주지 않는다. 그 이유는 Suspense는 렌더링이 되는 시점(컴포넌트 함수 실행)에 Promise를 감지해야만 서스펜드 상태로 전환되기 때문이다.

 

기본 비동기 상태 처리

const TodoList = () => {
  const [todos, setTodos] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true)
    const fetchData = async () => {
      const result = await fetchTodos();
      setTodos(result);
      setLoading(false);
    };
    fetchData();
  }, []);

  if (loading) {
    return <div>Loading...</div>;
  }

  return (...)
};

다른 라이브러리 도움 없이 일반적으로 하던 useState를 이용한 로딩 상태 처리부터 다시 보자.

 

이 상태에서 Suspense를 적용하려면 어떻게 해야 할까?

TodoList가 처음 렌더링 되는(컴포넌트 호출) 시점에 Promise가 던져져야 한다. 단지 useEffect 내에 비동기 함수가 있다고 Suspense가 트리거 되지 않는다.

 

Suspense 트리거

const TodoList = () => {
  const [todos, setTodos] = useState([]);

  useEffect(() => {
    const fetchData = async () => {
      const result = await fetchTodos();
      setTodos(result);
    };
    fetchData();
  }, []);

  // Promise 던지기
  if (!todos) {
    throw fetchTodos();
  }

  return (...)
};

할 수 있는 방법은 조건부 렌더링을 적용해 todos라는 상태가 비어있으면(=비동기로 받아 온 데이터가 없으면) 비동기 함수를 throw 하여 Suspense가 Promise를 감지할 수 있도록 한다.


useSuspenseQuery

프로젝트에 리액트 쿼리(TanStack-Query)를 도입하고 비동기 통신에는 `useSuspenseQuery`를 사용했다. 리액트 쿼리는 데이터 페칭 레이어를 분리할 수 있게 해 주며, 그중 useSuspenseQuery는 suspense 옵션을 기본으로 사용하여 로딩 상태를 관리하고, 비동기 통신으로 얻은 데이터를 보장받는다. 그래서 프로젝트에 이 훅을 사용한 후 Suspense 설정이 필요했다.

 

데이터를 보장받는다는 것이 무엇을 뜻할까?

공식 문서 설명 - `data` is guaranteed to be defined

Suspense의 트리거 방식으로 예상해 보면 컴포넌트가 렌더링 되어 useSuspenseQuery가 호출되었을 때, 리액트 쿼리가 자체적으로 Promise를 던질 것이다. 이를 React Suspense가 catch 하여 fallback UI를 표시한다.

 

데이터가 로드되면 Promise가 해결되어 서스펜드 모드가 해제되기 때문에 useSuspenseQuery가 반환하는 데이터는 실제 데이터 통신의 결괏값이 된다. 그 결과, useSuspenseQuery는 Promise를 던져 Suspense 트리거하고, 데이터 페칭 상태를 처리하면서 반환하는 데이터를 보장할 수 있게 된다.


사용 주의사항

데이터 병목현상

컴포넌트 내에서 여러 비동기 요청이 동시에 발생하거나 순차적으로 처리될 때, 네트워크 병목 현상이 발생할 수 있다. 이는 서비스의 성능 저하로 이어질 수 있으며, 특히 로딩 상태 관리를 위해 Suspense를 사용할 때 주의가 필요하다. Suspense는 Promise를 사용해 대기 상태를 유발하며, 작업을 지연시킨다. 따라서 여러 개의 Suspense를 동일한 레벨에서 무분별하게 사용하면 빈번한 대기 상태가 발생하여 서비스가 느리게 동작하는 것처럼 보일 수 있다. 이는 사용자 경험을 저하시킬 수 있으므로 신중하게 사용해야 한다.

 

병목현상이 생기는 경우

병목현상을 방지하기 위해, 리액트 쿼리의 사용 여부에 따라 다음과 같이 생각해 볼 수 있다.

 

리액트 쿼리 미사용 시

비동기 함수를 직접 호출하여 상태를 관리할 때, `async/await`을 사용해 특정 작업이 완료될 때까지 대기하면 순차적인 실행으로 인해 병목 현상(의도하지 않았다면)이 발생할 수 있다. 

리액트 쿼리 사용 시

`useQuery` 훅은 여러 개의 쿼리를 병렬로 처리한다. 그러나 상태 관리는 직접 처리해야 한다. Suspense를 사용하려면 별도 옵션 설정을 해주어야 한다. `useSuspenseQuery` 도 동일하게 병렬 처리 하는데 Suspense가 기본으로 적용된 훅이다. 여러 개가 있으면 각 쿼리가 Promise를 던지면서 모든 쿼리가 완료될 때까지 렌더링을 지연시킨다. 이로 인해 특정 쿼리가 지연될 경우 병목 현상이 발생할 수 있다.

 

따라서 비동기 데이터를 페칭하는 방식에 따라 병목 현상 발생 여부가 달라지므로, 상황에 맞는 해결 방법을 적용하는 것이 중요하다.

 

데이터 페칭의 최적화

병목 현상을 줄이기 위해 다음과 같은 방법을 고려해 볼 수 있다.

  • 데이터 페칭 분산: 특정 쿼리의 데이터 페칭이 다른 쿼리에 영향을 주지 않도록, 쿼리를 작은 단위로 나누어 처리한다.
  • useSuspenseQueries 사용: 개별 API마다 useSuspenseQuery를 사용하는 대신, 여러 쿼리를 병렬로 페칭 하는 useSuspenseQueries를 활용한다. 이 경우 각 쿼리는 Promise를 반환하며, 리액트 쿼리가 이 Promise들을 모아 `Promise.all`처럼 처리한다. 이를 통해 한 번의 Suspense 트리거로 모든 Promise가 해결될 때까지 기다렸다가 한 번에 데이터를 받을 수 있다.

이렇듯 비동기 함수를 사용할 때 Suspense로 감싸야하는지 여부를 고민해보아야 한다. 무조건적인 사용은 오히려 병목 현상을 악화시킬 수 있으므로, 필요한 상황인지 확인한 후 사용하는 것이 좋겠다.


Suspense 직접 구현

이해해 본 원리로 Suspense 컴포넌트를 직접 구현해 보자.

<Suspense fallback={<p>Loading...</p>}>
  <Async />
</Suspense>

위 코드처럼 사용하기 위해 첫 번째로 고려해 보아야 할 부분으로 Suspense는 Promise를 catch 했을 때 보여줄 fallback UI와 Promise를 던질 children 컴포넌트를 props로 받아야 한다. 그리고 내부적으로는 Promise가 감지되었을 때, 처리 결과에 따라 fallback UI와 children을 화면에 렌더링 해줄 수 있도록 하면 된다.

 

01. 기본 구조

const Suspense = ({ fallback, children }: Props) => {
  if (loading) {
      return fallback;
  }

  return children;
};

가장 먼저 생각나는 구조는 이러했다. 현재 상태가 loading이면 fallback이 끝났을 때, children을 보여주는 것이다. 여기서 children에서 호출되는 Promise를 부모 컴포넌트인 Suspense에서 잡아내는 로직이 바로 생각나지 않아 고민했다.

 

로딩 상태 추가

const Suspense = ({ fallback, children }: Props) => {
  const [loading, setLoading] = useState(true);

  if (loading) {
    return fallback;
  }

  return children;
};

우선 loading을 useState로 관리하여 상태 처리해 보기로 한다. 그럼 이제 남은 로직은 children에서 호출되는 비동기 함수를 감지하고, resolve 되면 loading 상태를 false로 바꿔주는 것이다. 상태 기본값으로 true를 한 이유는 Suspense를 사용한다는 건 children이 호출하는 비동기 함수의 실행에 따라 fallback UI를 보여주겠다는 뜻이므로 첫 렌더링 시점에 기본으로 로딩 화면을 먼저 띄워주는 것으로 고려했다.

 

Promise 감지하기

이제 채워야 할 로직은 children이 던지는 Promise를 잡아내는 것🎣이다. 

 

짚고 넘어가야 할 것은 컴포넌트 렌더링 중에 Promise가 던져지면, React는 해당 컴포넌트의 렌더링을 중단하고 트리 구조 상의 상위 컴포넌트로 전파를 시작한다. 이때 Promise는 트리 구조를 따라 상위 컴포넌트로 계속 전파되며, 가장 먼저 만나는 Suspense 컴포넌트에 도달하면 멈춘다.

 

React는 트리 구조로 렌더링을 진행하기 때문에, 상위 컴포넌트들은 이미 렌더링 된 상태이므로 전파된 Promise는 상위 컴포넌트의 useEffect에서 catch 블록으로 처리될 수 있다. 이것이 React Suspense 주요 메커니즘이라고 볼 수도 있겠다. Suspense가 비동기 로직을 가진 컴포넌트를 감싸고 있을 때, 하위의 children 컴포넌트가 Promise를 던지면, 이 Promise는 상위로 전파되어 가장 먼저 만나는 Suspense 컴포넌트에서 처리된다.

 

이를 활용해 생각해 보면 현재 `Custom Suspense`로 `Async` 컴포넌트를 감싸고 있으므로 Async에서 throw Promise를 하게 되면 상위 컴포넌트인 Suspense 컴포넌트 내 useEffect의 catch에서 잡을 수 있다.

 

useEffect(() => {
  try {
    setLoading(false);
  } catch (promise) {
     if (promise instanceof Promise) {
       promise.then(() => setLoading(false));
     }
  }
}, [children]);

상위 Suspense에서 catch 한 Promise의 타입을 확인하고 그 Promise가 성공적으로 완료되었을 때 loading 상태를 변경하여, 리렌더링을 통해 fallback UI 대신 children을 렌더링 할 수 있도록 한다.

 

02. children 컴포넌트의 throw Promise

위에서 Suspense가 트리거 되려면 Promise가 던져져야 한다고 했다.

// Promise 던지기
if (!todos) {
  throw fetchTodos();
}

그래서 그 예시로서 이런 코드를 적어두었다. 하지만 fetchTodos 함수가 async인 경우 테스트를 해보면 Suspense가 작동되지 않는 것을 확인할 수 있다. React는 Promise가 pending 상태일 때만 트리거하기 때문이다.

 

따라서, React가 렌더링을 중단하고 Promise가 완료될 때까지 대기하게 하려면 pending 상태를 감지할 수 있는 Promise를 반환하는 비동기 함수를 호출해야 하며, 렌더링 중에 Promise를 throw 한 컴포넌트의 상태를 관찰할 수 있는, 중간 로직이 필요하다.

 

Promise catch 하기

던져진 Promise를 상위 컴포넌트가 catch 할 수 있는 중간 로직을 구현해 보기 위해 2가지 방법으로 시도해 보았다.

 

a. wrapPromise

첫 번째로 React Suspense에 대한 로직 예시로서 종종 볼 수 있는 `wrapPromise`를 활용해 본다.

const wrapPromise = <T>(promise: Promise<T>) => {
  let status = 'pending';
  let response: T;

  const suspender = promise.then(
    (res) => {
      status = 'success';
      response = res;
    },
    (err) => {
      status = 'error';
      response = err;
    }
  );

  const read = () => {
    switch (status) {
      case 'pending':
        throw suspender;
      case 'error':
        throw response;
      default:
        return response;
    }
  };

  return { read };
};

export default wrapPromise;

이 함수는 비동기 프로미스의 상태를 추적하며, 비동기 작업이 완료될 때까지 컴포넌트가 기다리도록 도와주는 유틸리티 함수이다. 비동기 함수가 반환하는 Promise를 매개변수로 받아 그 실행 결과를 suspender에 저장한다. 이 함수가 반환하는 read 메서드는, wrapPromise 함수가 종료된 이후에도 status, response, suspender 변수를 참조할 수 있다. 이는 자바스크립트의 클로저 덕분이며, 이로 인해 비동기 작업의 상태를 계속해서 확인하고 관리할 수 있게 된다.

 

사용
export const fetchData = (): Promise<string> =>
  new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve('data fetching completed');
    }, 2000);
  });
const resource = wrapPromise(fetchData());

const Async = () => {
  const data = resource.read();
  return <p>{data}</p>;
};

Async 컴포넌트가 렌더링 되면서 read 함수를 호출하면 환경변수로 저장된 status에 따라 case가 실행된다. status가 pending이라면 Promise가 아직 해결되지 않았다는 뜻으로 throw suspender로 Suspense의 트리거라고 했던 Promise가 던져지면서 fallback UI가 보이게 된다.

 

b. useSuspenseCache 훅 만들기

wrapPromise로 원하는 상황을 만들었지만, 사용법이 낯설다. 다른 방법을 찾아보고 싶어진다. 익숙한 `useSuspenseQuery`처럼 사용해보고 싶어 나만의 `useSuspenseCache` 훅을 만들어보았다.

const cache = new Map();

export const useSuspenseCache = (key: Array<string>, fn: () => Promise<unknown>) => {
  const keyString = JSON.stringify(key);

  const cachedValue = cache.get(keyString);

  if (cachedValue !== undefined) {
    if (cachedValue instanceof Error) {
        throw cachedValue;
    }
    return cachedValue;
  }

  throw fn().then(
    (data) => {
      cache.set(keyString, data);
    },
    (error) => {
      cache.set(keyString, error);
      throw error;
    }
  );
};

실제 useSuspenseQuery는 당연히 엄청나게 복잡하고 더 다양한 기능을 제공하겠지만, 당장의 구현에는 key로 페칭 된 데이터를 캐싱한다에 초점을 맞추어 구현했다.

  1. key는 string 배열로 받고, 동일한 key가 이미 존재하는지 확인할 수 있다.

배열의 값을 JSON string으로 비교한다.

  1. key를 이용해 캐싱된 데이터가 있는지 찾는다.
  2. 캐싱된 데이터가 있다면 그대로 반환한다.
  3. 캐싱된 데이터가 없다면 비동기 함수의 실행 결과로 반환된 Promise를 throw 한다.

 

사용
export type Post = {
	userId: number;
	title: string;
	id: number;
	body: string;
};

export const fetchPostData = async (): Promise => {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts/1');
  
  if (!response.ok) {
    throw new Error('Error!');
  }
  
  return await response.json();
};

 

const Async = () => {
  const data: Post = useSuspenseCache(['post'], fetchPostData);
  return <p>{data.title}</p>;
};

데이터 페칭 함수도 오픈 API를 이용해서 실제 환경처럼 테스트를 진행했다. 훅을 사용할 때에도 useSuspenseQuery와 동일하게 느껴진다.

 

Github Repo

 

GitHub - k-jeonghee/suspense

Contribute to k-jeonghee/suspense development by creating an account on GitHub.

github.com

 


Suspense 원리 활용해 보기

이제 Suspense가 어떤 로직에 의해 실행되는지 알게 되었다. 이번에는 디버스 프로젝트에서 Suspense의 원리를 다른 로직에 적용하여 활용한 사례를 소개해본다.

 

 

문제

디버스에서는 클라이언트 상태 관리로 `Jotai`를 사용하여 로그인 후 인증 정보를 atom으로 관리했다. 그리고 onMount를 이용해 파이어베이스 인증 리스너를 연결해서 사용했다.

const authAtom = atom<UserTypes | null>(null);
baseAuthAtom.onMount = (set) => onUserStateChange(set);

전역 상태라서 앱 내 어디에서든 인증 정보를 사용할 수 있었지만, 불편한 문제가 있었다.

 

  1. 사용자 인증이 되어야만 이용할 수 있는 페이지나 기능에서는 이미 인증이 완료되었기 때문에 인증 정보가 확실히 있지만, authAtom 타입이 null이 될 수 있어 매번 null 체크를 해주어야 했다.
  2. null 체크를 통과하기 전까지 디스트럭쳐링이 되지 않는다.
const { id: uid } = useAtomValue(authAtom); // X

const user = useAtomValue(authAtom); // O

데이터가 UserTypes라는 것이 확실한데 null 체크 이후에야 속성 추론이 되는 점이 불편했다. 게다가 null 체크를 미리 하지 않으면 값이 필요할 때마다 `uid && `와 같은 조건 처리를 해주어야 하는 것도 불편했다.

 

 

해결방법 01

타입스크립트 타입 단언으로 `!`를 붙여 이 값은 null이 아님을 알려준다.

const { id: uid } = useAtomValue(authAtom)!;

해결방법 02 - 응용

const baseAuthAtom = atom<UserTypes | null>(null);

const authAtom = atom<Promise<UserTypes>>((get) => {
  const x = get(baseAuthAtom);
  if (x === null) return new Promise(() => {});
  return Promise.resolve(x);
});

이 방법은 atom을 분리하여 인증이 되어 있어야 하는 컴포넌트에서는 Promise로 체크하는 atom을 사용하는 방법이다.

 

`baseAuthAtom`의 값을 가져와서 null 체크를 한 후 인증 정보가 없다면 빈 Promise를 던져 Suspense를 트리거한다. 이후 baseAuthAtom이 업데이트되면 이 atom 정보를 사용하는 authAtom도 다시 업데이트되어 resolve 되고, 실제 데이터로 리렌더링 된다. 

 

비교

해결방법 01 

  • 장점: 간단하고 불필요한 비동기 처리가 없다.
  • 단점: 값이 있다고 확신했지만 런타임 오류가 발생한다.

해결방법 02

  • 장점: Suspense와 함께 로딩 상태를 관리할 수 있다.
  • 단점: 값이 확실히 있다면 필요하지 않은, 불필요한 로직일 수 있다.

 

적용한 이유

1번 방법에 비해 대충 봐도 복잡해 보이고 불편해 보이는 이 방식을 사용한 이유가 있다. [해결방법 01]처럼 타입 단언을 하거나 assert util 함수로 null 체크를 하게 될 경우 렌더링 시점에 null값으로 초기화되면 에러로 처리되어 ErrorBoundary에 잡히게 된다.

 

 

하지만 내가 원했던 상황은 다음과 같다.

  1. 불필요한 null 체크는 하고 싶지 않다.
  2. 타입 추론이 가능하여 디스트럭쳐링 하고 싶다.
  3. 비동기 통신으로 데이터를 가지고 오기 전 상태를 에러로 잡지 않아야 한다.

 

[해결방법 02]는 이 세 가지 요구사항을 모두 해결한다.

 

컴포넌트가 렌더링 될 때, 인증 정보가 null이라면 Promise를 던져 Suspense를 트리거하여 fallback UI를 보여준다. 이후 baseAuthAtom에 인증 정보가 업데이트되면 Promise.resolve를 반환하면서 원했던 유저 정보를 얻는다. 이는 중간에 null로 초기화되더라도 에러로 처리되지 않는다는 뜻이다. 또한, atom을 정의할 때 반환값에 타입을 지정했기 때문에 타입 추론이 가능하며, 반환값이 null이 아니라는 것도 명확히 정의되어 별도의 null 체크를 하지 않아도 된다. 

 

프로젝트 전반적으로 Suspense를 이용하고 있었기 때문에 로딩 상태를 일관성 있게 처리할 수 있었고, 원하는 요구사항도 충족했기 때문에 단점일 수 있었던 오버헤드를 크게 문제라고 느끼지 않았다. 안정성도 챙기고 사용성도 챙길 수 있어 만족스럽게 사용했지만, 상황에 따라서 오히려 로딩 상태를 보여주고 싶지 않거나 의도한 사용자 경험에 맞지 않다면 적용을 고민해 볼 필요가 있다.


트러블 슈팅

좋은 점만 보면 아쉬우니 Suspense와 연관되어 겪었던 이슈 해결 과정도 함께 정리해 본다.

 

문제

사용자가 참여 중인 채팅방을 보여주는 Operation 페이지에 진입한 후 각 채팅방을 클릭할 때마다 화면이 깜빡이는 현상이 있었다. 이 정돈 이미 마주친 문제지! 하며 placeholderData 옵션을 주었지만 해결되지 않았다.

 

원인

Suspense를 사용하면서 데이터를 불러오는 동안 렌더링을 지연시키게 되고 그 사이 fallback UI를 보여주게 되는데 네트워크 상태에 따라 이 과정이 아주 빠르게 지나가면 로딩창을 온전히 확인하기도 전에 화면이 전환되어 깜빡깜빡하는 것처럼 보이는 것이었다.

 

시도

먼저 가장 상위 Operation 페이지를 Suspense가 감싸고 있기 때문에 전체적으로 깜빡이게 되는 현상을 줄이고자 Suspense의 적용 범위를 좁혀서 감싸주었다.

 

해결되지 않은 문제

채팅방을 클릭하면 해당 채팅방에서 주고받은 메시지가 보이게 되는데 첫 채팅방 클릭 시 깜빡임은 계속 됐다. 

당연하다. Suspense 범위만 줄이면 깜빡이는 부분만 줄어들 뿐이지 깜빡임이 없어지는 해결 방법은 아니었기 때문이다.

 

 

해결: useTransition

 

useTransition – React

The library for web and native user interfaces

ko.react.dev

 

UI를 차단하지 않고 state를 업데이트할 수 있도록 하는 훅을 이용해 해결했다. 리액트는 상태가 업데이트되면 해당 상태를 사용하는 모든 컴포넌트를 동기적으로 리렌더링 한다. useTransition을 사용하면 데이터 로드와 상태 업데이트가 비동기적으로 이루어지면서 특정 상태의 변경으로 일어나는 리렌더링을 변경된 값이 적용된 이후로 지연시킬 수 있다.

const [isPending, startTransition] = useTransition();

const handleChangeChatRoom = () => {
  startTransition(() => setCurChatRoomId(chatRoomId));
};

채팅방을 클릭할 때마다 현재 선택된 채팅방 id를 atom으로 관리하고 있었는데 atom이 변경될 때 비동기 통신으로 깜빡임이 있었기 때문에 이 atom을 변경하는 부분에 `startTransition`을 적용해 주었다.

 

이제 비동기적으로 채팅방 id 상태를 업데이트하면서 데이터(채팅방 메시지)가 로드되는 동안 기존 UI를 유지한다. 데이터 로드와 상태 업데이트가 완료되면, 새로운 채팅방 id에 맞는 데이터로 화면이 렌더링 되며 깜빡임 문제가 해결된다.

 

이 훅을 적절히 잘 사용하면 상태 변화에 따른 UI가 즉각적으로 반응하지 않기 때문에 부드럽게 업데이트되어 사용자 경험을 개선할 수 있다.

 

useTransition과 placeholderData 옵션

프로젝트에 리액트 쿼리를 사용하고 있기 때문에 데이터 페칭과 관련한 깜빡거림은 모두 리액트 쿼리로 해결할 수 있다고 생각했던 것 같다. 역할이 비슷한 것 같지만 필요한 상황이 다르고, 2가지를 함께 사용할 수도 있다.

 

useTransition

 

state가 업데이트될 때, 리렌더링을 지연시켜 부드러운 UI 전환을 보여줄 수 있도록 한다.

  • 우선순위가 높은 작업(사용자의 입력이나 액션)을 먼저 처리하고, 비동기 작업은 지연시킨다.
  • `isPending` 상태를 이용해 로딩 중임을 알릴 수 있다.

 

placeholderData

 

비동기 데이터를 가져올 때, 캐싱해 둔 이전 값으로 먼저 보여주고, 백그라운드에서 페칭 한 뒤 새로운 값으로 대체하도록 한다.

  • 즉시 데이터를 표시해 사용자 경험을 개선한다.

처음 채팅방 목록을 보여주고, 각 방의 메시지를 보여줄 때는 캐싱된 데이터가 없어 무조건 페칭이 일어난다. 그 부분을 useTransition을 이용해 사용성을 개선했고, 캐싱된 이후부터의 화면 전환에 개선이 필요하다면 placeholderData를 함께 사용할 수 있다.


마치며

이번 프로젝트에서 처음 Suspense와 ErrorBoundary를 적용했다. 이렇게 정리를 하고 나니 '에러 핸들링을 해야 한다'의 목적으로 얼렁뚱땅 적용했다는 반성을 또 하게 된다. 분명 쓸 때는 이해하고 썼는데 파고들다 보면 끝도 없다는 생각도 든다. 다음 프로젝트에서는 더 상세한 에러 핸들링에 대해 고민해 보아야겠다.

관련글 더보기