Fixing race condition in React

2022.04.23
5 minutes read
198 views
thumbnail

useEffect 내에서 data fetch를 다룰 때 종종 발생하는 버그를 이해하고 Suspense를 이용해 어떻게 다루는지 확인해보려고 합니다.
https://17.reactjs.org/docs/concurrent-mode-suspense.html#suspense-and-race-conditions

입력 변화의 타이밍이나 순서가 예상과 다르게 작동하면 정상적인 결과가 나오지 않게 될 위험이 있는데 이를 Race condition(경쟁 상태)라고 합니다.

React useEffect 같은 react life-cycle 내에서 data fetch를 다룰 때 종종 이런 버그가 났던 것 같습니다.

마지막에 요청했던 결과가 아닌 이전 결과가 화면에 렌더링 된다던지... 그런 이슈 입니다.

이번 글에서는 Race condition을 어떻게 다루고 Suspense를 이용하면 간단하게 처리할 수 있는지 확인해보도록 하겠습니다.

Race condition in useEffect

이해를 위해 예시 프로젝트와 함께 살펴보겠습니다.

function Profile() {
  const [id, setId] = useState(1);

  return (
    <>
      <button onClick={() => setId(getNextId(id))}>Next</button>
      <ProfileComponent id={id} />
    </>
  );
}

export default Profile;
tsx

Next 버튼을 누르면 다음 id를 가진 user의 profile로 전환 해주는 page를 만들어보았습니다.
<ProfileComponent /> 는 id를 prop으로 받아 user 정보와 user에 해당하는 post를 렌더링하는 component입니다.

type Props = {
  id: number;
};

function ProfileTimeline({ id }: Props) {
  const [post, setPost] = useState<PostT | null>(null);

  useEffect(() => {
    fetchPost<PostT>(2000 * Math.random(), id).then(p => setPost(p));
  }, [id]);

  if (post === null) {
    return <h2>Loading post...</h2>;
  }
  return (
    <>
      <h3>{post.title}</h3>
      <p>{post.body}</p>
    </>
  );
}

function ProfileComponent({ id }: Props) {
  const [user, setUser] = useState<UserT | null>(null);

  useEffect(() => {
    fetchUser<UserT>(1000 * Math.random(), id).then(u => setUser(u));
  }, [id]);

  if (user === null) {
    return <p>Loading profile...</p>;
  }

  return (
    <>
      <h1 style={{ color: 'purple' }}>Name : {user.name}</h1>
      <ProfileTimeline id={id} />
    </>
  );
}
tsx

2개의 fetch가 존재하고 user정보를 useEffect를 통해서 fetch를 하고 post도 마찬가지로 useEffect를 통해 fetch합니다.

fetchUser와 fetchPost는 Math.random()을 통해 0~1 사이의 랜덤한 float값을 곱하여 delay후 실제 fetch를 수행합니다.

useEffect를 보면 dependency에 [id]를 넣어둔 것을 확인할 수 있습니다.
id가 변경되면 useEffect를 다시 실행하도록 만들어야 하기 때문입니다. 그렇지 않으면 새로운 데이터를 fetch하지 못합니다.

normal-case.png

처음에는 잘 작동하는 것처럼 보입니다. 하지만 "Next" 버튼을 아주 빠르게 클릭하게 되면 무언가 이상하게 동작하는 것을 콘솔 로그를 통해 볼 수 있습니다.

마지막에 보냈던 요청 뒤에 그 이전에 보냈던 요청이 돌아오는 경우가 발생할 때가 있는데 그로 인해서 setState시에 이전 요청의 결과값으로 덮어쓰게 되는 경우가 발생합니다.

race-condition

fetchPost를 보게되면

  1. id가 2로 바뀌면서 fetchPost(2) 시작
  2. id가 3로 바뀌면서 fetchPost(3) 시작
  3. fetchPost(3) 완료되어 setState와 함께 re-render
  4. fetchPost(2) 완료되어 setState와 함께 re-render

위 이미지에서 그 이전 fetchPost: 2 가 마지막 요청인 fetchPost: 3 요청을 덮어쓰면서 기대했던 결과와는 다른 결과가 나오게 되었습니다.

useEffect 내에서 data fetch를 주의깊게 사용하지 않는다면 이런 race condition을 종종 마주칩니다.

이 문제는 고칠 수 있습니다. useEffect 내에서 cleanup function을 이용해 오래된 요청을 무시하거나 취소할 수 있습니다. 하지만 이는 직관적이지 않고 디버깅하기도 어렵습니다.

useEffect(() => {
  ...
  // cleanup function
  return () => {
    ...
  }
}, [deps])
tsx

React component에는 고유한 "life-cycle"이 있습니다. 특정 시점마다 props를 받거나 state를 업데이트 합니다.

각각 비동기 요청 또한 자신만의 "life-cycle"을 가집니다. 요청이 출발했을 때 시작되며, 응답이 돌아오면 끝납니다.

어려운 점은 여러 프로세스가 서로에게 영향을 미치는 그 순간 여러 프로세스들을 "동기화"하는 작업입니다. 이 문제는 생각하기 어렵습니다.

Race condition in Suspense

위 예제를 Suspense를 이용해 다시 작성해보겠습니다.

const initialResource = suspenseProfileData<UserT, PostT>(1);

function Profile() {
  const [resource, setResource] = useState(initialResource);

  return (
    <>
      <button
        onClick={() => {
          const nextUserId = getNextId(resource.userId);
          setResource(suspenseProfileData(nextUserId));
        }}
      >
        Next
      </button>
      <ProfileComponent resource={resource} />
    </>
  );
}

export default Profile;
tsx

resource는 suspense를 위한 특별한 형태입니다.

// Suspense integrations like Relay implement
// a contract like this to integrate with React.
// Real implementations can be significantly more complex.
// Don't copy-paste this into your project!
export function wrapPromise<T>(promise: Promise<T>) {
  let status = 'pending';
  let result: T;
  let suspender = promise.then(
    r => {
      status = 'success';
      result = r;
    },
    e => {
      status = 'error';
      result = e;
    }
  );
  return {
    read() {
      if (status === 'pending') {
        throw suspender;
      } else if (status === 'error') {
        throw result;
      } else if (status === 'success') {
        return result;
      }
    },
  };
}
tsx

예시를 위해 제공된 코드 중에 있고 자세히 보면 특이한 것은 "pending"인 경우 promise 함수를 throw 하는 것을 볼 수 있습니다. error처럼 말이죠. React Suspense에서 promise를 다루는 방식이 이렇다라고만 이해하고 넘어가도록 하겠습니다. (주석처럼 이것을 그대로 프로젝트에 복사해서 쓰지 마세요.)

type Props = {
  resource: typeof initialResource;
};

function ProfileTimeline({ resource }: Props) {
  const post = resource.posts.read();
  return (
    <>
      <h3>{post!.title}</h3>
      <p>{post!.body}</p>
    </>
  );
}

function ProfileDetails({ resource }: Props) {
  const user = resource.user.read();

  return <h1 style={{ color: 'purple' }}>Name : {user!.name}</h1>;
}

function ProfileComponent({ resource }: Props) {
  return (
    <Suspense fallback={<h2>Loading profile...</h2>}>
      <ProfileDetails resource={resource} />
      <Suspense fallback={<h2>Loading posts...</h2>}>
        <ProfileTimeline resource={resource} />
      </Suspense>
    </Suspense>
  );
}
tsx

"Next"를 누르면 다음 profile정보에 대한 요청을 실행시키고, 그 resource를 ProfileComponent 컴포넌트 prop으로 전달합니다.

<>
  <button
    onClick={() => {
      const nextUserId = getNextId(resource.userId);
      setResource(suspenseProfileData(nextUserId));
    }}
  >
    Next
  </button>
  <ProfileComponent resource={resource} />
</>
tsx

setState할 때 응답이 오기전까지 기다리지 않는다는 점을 확인하세요.
오히려 전혀 반대 방법입니다. 즉, 요청을 시작시킨 뒤에 즉시 state를 설정합니다.(그 후 렌더링을 시작합니다.)
데이터를 얻게 되자마자, React는 <Suspense> 컴포넌트 내에 컨텐츠를 "fills in(주입)"합니다.

race-condition-suspense

실제 fetched 는 fetchUser(2)가 fetchUser(3) 보다 늦게 응답이 온 것으로 콘솔로그는 찍혔습니다.

그러나 위 코드에서 보았듯이 응답이 오기 전에 즉시 state를 설정했기 때문에 화면에 렌더링 된 것은 마지막에 요청한 fetchUser(3) 의 결과가 렌더링 됩니다.

image.png
image.png

fetchUser를 보게되면

  1. "Next" 클릭하여 id가 2인 suspense resource를 setState
  2. resource(id = 2)를 prop으로 받는 ProfileComponent re-render
  3. fetchUser(2) 시작
  4. "Next" 클릭하여 id가 3인 suspense resource를 setState
  5. resource(id = 3)를 prop으로 받는 ProfileComponent re-render
  6. fetchUser(3) 시작
  7. fetchUser(3) 완료
  8. resource(id = 3) 응답 결과 따라 ProfileComponent children 렌더링
  9. fetchUser(2) 완료 되지만 무시됨

이 코드는 매우 읽기 좋게 작성되어있지만. 기존 예제들과 달리 Suspense 버전에서는 race condition으로부터 고통받지 않습니다. 그 이유가 궁금하실 겁니다.
그 이유는 바로 Suspense 버전에서는 코드 상에서 time(시간) 에 대해 그다시 신경을 쓰지 않아도 되기 때문입니다.

race condition이 존재했던 기존의 코드에서는 이후의 적당한 시점에 state를 설정해야 했습니다. 그렇게 안하면 위에서 보듯이 기대하지 않은 결과가 나올 수 있습니다.
그러나 Suspense에서는 state를 즉시 설정합니다. 따라서 오작동하는 것이 더 어렵습니다.

위 예제코드는 공식 문서에서 codesandbox를 통해 소스를 제공하고 있고 제 github 코드에서도 확인할 수 있습니다.

마무리하며

useEffect내에서 data fetch를 주로 하고 state를 설정하는 방식을 많이 사용했습니다.
지금도 그런 프로젝트들이 많구요.

loading이나 error관리, 복잡해지는 구조 등에 매 번 좀 더 나은 구조를 위해서 고민했던 것 같습니다.

적당한 시점에 state를 설정해야한다는 것이 간단한 코드 내에서는 그래도 처리하겠는데 점점 더 코드가 복잡해지면 매우 힘든 경우도 있다는 것을 경험해보니 이런 Suspense와 같은 것이 너무 반갑습니다.

Suspense는 직관적이고 읽기 좋게 작성할 수 있습니다. 다음에 설명하겠지만 error-boundary를 통해 error 관리도 선언적으로 관리할 수 있게 되어 가독성 좋아집니다.

저 같은 경우 머리도 좋지 않고 "컨텍스트"를 잃어버리면 돌아오고 나서도 한참 헤매는 경우가 많습니다.(거기다 코드도 엄청 줄어듭니다)

이와 같이 직관적이라면 "컨텍스트"를 잃게 되더라도 빨리 돌아와 빠르게 회복될 것 같아 매우 좋을 것 같습니다.

적극적으로 사용해봐야겠다고 다짐 해봅니다.

reference