경합 조건

se-een·2023년 8월 22일
0

React 탐구하기

목록 보기
7/7
post-thumbnail

경합 조건 (Race Condition) 이란?

경합 조건이란 쉽게 말해 하나의 작업 처리를 위해 다수의 작업 (이벤트 등) 이 거의 동시에 발생하는 경우, 처리를 하는 입장에서 무엇이 먼저 완료될 지 예측하기 힘들다는 것입니다. API 호출 등 비동기의 상황에서 요청 순서와 응답 순서가 반드시 일치함을 보장할 수 없는 상태를 비동기 맥락에서의 경합 조건이라고 볼 수 있겠습니다.

const funcA = () => {
  fetch(marketUrl).then(console.log);
};

const funcB = () => {
  fetch(cartUrl).them(console.log);
}

funcA();
funcB();

위 코드에서 A 함수를 호출한 후 B 함수를 호출하고 있지만, 반드시 콘솔에 A 함수의 결과가 찍힌 이후 B 함수의 결과가 찍힌다고 보장할 수 없습니다.

왜냐하면 어떠한 이유에 의해서 marketUrl 의 응답이 cartUrl 의 응답보다 느려 늦게 도착할 수 있기 때문이죠. 이처럼 비동기 상황에서는 세심한 처리가 필요합니다.

React에서 경합 조건

React 에서는 주로 useEffect 훅을 사용하거나 이벤트 핸들러에서 데이터를 fetch 해옵니다. 대표적인 예시는 다음과 같습니다.

const App = () => {
  const [data, setData] = useState(null);
  
  const onClickButton = async () => {
    const response = await fetch(URL);
    const responseData = await response.json();
    
    setData(responseData);
  };
  
  return (
    <>
      <button onClick={onClickButton}>fetch!</button>
      <div>{data.name}</div>
    </>
  )
}

버튼을 클릭하면 데이터를 fetch 해오고 data 라는 state에 set 하게 됩니다. 위 예제 역시 버튼을 굉장히 빠르게 누르면 경합 조건 상태에 놓이게 되는데요. Max Rozen의 Fixing Race Conditions in React with useEffect 글에서 언급된 예제를 살펴보면 더 직관적으로 이해가 가능하겠습니다. (아래 Code Sandbox 링크에서 확인 가능합니다.)

Code Sandbox (Beating Async Race Conditions in React)

위 예시는 보다 명확한 경합 조건 관찰을 위해 일부러 fetch 로직을 1~12초간 랜덤으로 수행하도록 하였는데요. fetch를 2회 이상 수행하게 되면 요청 순서와 응답 순서를 보장할 수 없는 경합 조건 상태에 빠지게됩니다. 요청 순서와 응답 순서가 정상이면 글자색이 초록색으로, 다르면 글자색이 빨간색으로 보이게 되는거죠.

만약에 비동기 요청을 순차적으로 처리해야하는 상황이었다면 경합 조건으로 인해 애플리케이션 동작이 망가지게 될 것입니다.

경합 조건 해결하기

경합 조건을 어떻게 해결해볼 수 있을까요? 반복되는 이벤트 호출 처리를 제어하기 위해 쓰로틀링(Throttling)을 생각해볼 수 있겠습니다. 해당 상황에서 쓰로틀링이란 간략하게 말해 의도적으로 이벤트 호출 속도, 횟수를 제한하여 비용을 절감하는 행위라고 볼 수 있겠습니다.

하지만 쓰로틀링은 빈번하게 발생되는 동일한 이벤트의 호출량을 조절할 뿐, 비동기 상황에서 가져온 데이터들간 경합 조건을 해결해준다고 보기 어렵습니다.

boolean 변수 사용

React 공식 문서에서 소개하는 방법 중 하나이기도 합니다.

import { useState, useEffect } from 'react';

const App = () => {
  const [name, setName] = useState('Lee');
  const [data, setData] = useState(null);

  useEffect(() => {
    let ignore = false;
    setData(null);

    fetch(name).then((result) => {
      if (!ignore) {
        setData(result);
      }
    });

    return () => {
      ignore = true;
    };
  }, [name]);

  return (
    <>
      <select
        value={name}
        onChange={(e) => {
          setName(e.target.value);
        }}
      >
        <option value="Alice">Lee</option>
        <option value="Bob">Kim</option>
        <option value="Taylor">Park</option>
      </select>
      <hr />
      <p>
        <i>{data ?? 'Loading...'}</i>
      </p>
    </>
  );
}

핵심은 ignore 변수가 false 일 때만 fetch 해온 데이터를 set 하는 것을 허용한다는 점입니다. 즉, 셀렉트 박스를 fetching 중간에 바꾸더라도 ignore 변수값이 true로 변경되어 데이터 set이 무시되므로 경합조건이 일어나지 않는 원리입니다.

만약 ignore 변수가 없었다면 fetching 도중 셀렉트 박스를 바꾸었을 때 선택한 셀렉트 박스 값과 fetching 해온 data 값이 경합 조건에 의해 달리 보였을 수도 있었을 것입니다.

AbortController

특정 시점에서 fetch 요청 자체를 끊어버리면 가장 깔끔한 방법이 아닐까요? Web API의 AbortController 를 사용하면 가능합니다. AbortController 는 하나 이상의 웹 요청을 취소할 수 있게 해주는 인터페이스이며 다음과 같이 주요 프로퍼티와 메서드를 가집니다.

  • 프로퍼티 - AbortController.signal : DOM 요청과 통신 또는 취소 시 사용되는 AbortSignal 객체 인터페이스를 반환 (읽기 전용)
  • 메서드 - AbortController.abort() : DOM 요청이 완료되기 전에 취소

사용 방법을 간략하게 요약하면 다음과 같습니다.

const abortController = new AbortController();

위와 같이 하나의 컨트롤러를 생성해준 후 fetch 요청 로직에 signal 프로퍼티를 생성하여 abortController.signal 값을 넣어주면 됩니다.

fetch(URL, {
  signal : controller.signal
});

마지막으로 요청을 취소할 시점에서 컨트롤러의 abort() 메서드를 호출해주면 되겠습니다.

if (data) {
  abortController.abort();
}

위에서 (React 에서 경합 조건) 작성한 코드에 AbortController를 적용하여 경합 조건을 해결해보겠습니다.

const App = () => {
  const App = () => {
  const [data, setData] = useState(null);
  const abortController = useRef(null);
  
  const onClickButton = async () => {  
    if (abortController) abortController.current.abort();
    
    abortController.current = new AbortController();
    
    try {  
      const response = await fetch(URL, {
        signal : abortController.current.signal,
      });
      const responseData = await response.json();

      setData(responseData);
    } catch (e) {
      if (error.name === "AbortError") {
      	// 이 곳에 abort() 가 실행되었을 때 실행할 로직을 작성
      }
    } finally {
      abortController.current = null;
  }
  
  return (
    <>
      <button onClick={onClickButton}>fetch!</button>
      <div>{data.name}</div>
    </>
  )
}

이제 버튼을 연속해서 누르더라도 마지막 요청만 fetch 해오게 됩니다. 버튼을 눌러 setData 를 진행하기 전까지 abortController 변수는 AbortController의 인스턴스 값을 갖고 있으므로 이전 요청을 취소할 수 있고, 마지막 요청이 정상적으로 fetch 되면 setData 를 진행하면서 리렌더링이 발생함으로 지속해서 사용할 수 있는 형태일 것입니다.

위에서 살펴본 Max Rozen의 글에서 AbortController를 적용한 코드 예시는 아래 링크를 통해 확인해볼 수 있습니다.

Code Sandbox (Beating Async Race Conditions in React, AbortController)

profile
woowacourse 5th FE

0개의 댓글