프론트엔드와 Race Condition

Park June Chul·2022년 3월 6일
8

잘 코딩하기

목록 보기
5/6

레이스 컨디션 (Race Condition)

https://ko.wikipedia.org/wiki/%EA%B2%BD%EC%9F%81_%EC%83%81%ED%83%9C

간단히 말해서 두 개 이상의 스레드가 하나의 공유 자원에 접근해서 일어나는 상태(혹은 그로 인해 발생하는 버그) 라고 생각하시면 됩니다.

레이스 컨디션의 좋은 예: 최근 검색어 여러개를 빠른 속도로 눌렀습니다. 결과적으론 검색어와 화면의 결과가 일치하지 않는 버그가 나타납니다.

여기서 두 개 이상의 스레드는 무엇이고, 하나의 공유 자원은 무었일까요?

  • 두 개 이상의 스레드는 검색에 대한 요청입니다:
    검색을 실행하면 fetch('/search?q=커피') 같은 코드가 실행되고, 만약 유저가 두 개의 검색을 실행하면 두 개의 fetch가 실행될 것입니다.

  • 하나의 공유 자원은 검색 결과 View(UI) 입니다:
    fetch 이후에 실행되는 코드는 검색 결과를 가지고 UI를 갱신하는 코드가 실행될 것이 분명합니다. 모든 fetch는 하나의 검색 결과 리스트(UI)에 쓰기 작업을 시도할 것이므로 공유 자원이 됩니다.

자바스크립트는 싱글 스레드가 아닌가요?

요기요 앱은 iOS 네이티브 언어로 작성되어있을 확률이 높지만, 이러한 Race Condition 버그는 웹으로 작성된 앱에서도 충분히 발생할 수 있습니다.

여러분의 코드는 싱글 스레드로 실행되지만, 브라우저는 멀티 스레드로 작동하기 때문이지요.

아래 코드는 Race Condition버그를 발생시키는 간단한 예시입니다.

const SearchResult = () => {
  const [result, setResult] = useState();
  
  const onClickMusic = async (name: string) => {
    setResult(
      await fetch(`/music/${name}`),
    );
  };
  
  return (
    <div>
      <div>
        <button onClick={() => onClickMusic('노래1')}>
          노래1
        </button>
        <button onClick={() => onClickMusic('노래2')}>
          노래2
        </button>
        <button onClick={() => onClickMusic('노래3')}>
          노래3
        </button>
      </div>
      <div>
        선택하신 노래의 정보는 {result} 입니다.
      </div>
    </div>
  );
};

특별한 코드가 아닙니다. 일반적으로 많이 작성되는 코드이고, 이 코드는 위의 요기요 버그를 그대로 가지고 있습니다.

어떤 경우에 버그가 발생할까요?

노래1 클릭 -> fetch(노래1) -> 노래2 클릭 -> fetch(노래2) -> setResult(노래2) -> setResult(노래1)

이런 경우를 생각해보세요.
유저가 마지막에 누른건 노래2 지만 화면에는 노래1 에 대한 정보가 표시됩니다.

이러한 문제의 원인은, 네트워크 요청이 얼마나 걸릴지는 아무도 알 수 없으며 심지어 늦게 출발한 요청이 먼저 도착할수도 있습니다.

이 글의 처음 영상만 봐도 알 수 있듯이 이것은 드물게 발생하는 현상이 아니며, 마음먹는다면 매우 쉽게 재현할 수 있다는 점에 있습니다.

단순히 UI 불일치 버그일까요?

그렇지 않습니다.
만약 네트워크 완료 이후에 실행되는 코드가 좀 더 복잡하게 체이닝 되어 있고 (여러개의 연쇄 useEffect 등) 더 많은 값들을 변경한다면 이제는 단순 UI 버그가 아닌 진짜 버그 를 만들어낼 수 있고, 발견하기 매우 어려워질 수 있습니다.

어떻게 고칠 수 있을까요?

Best Practice

만약 React를 사용한다면 Suspense 그리고 Render-as-you-fetch 패턴으로 교체합니다.
저의 다른 글에서 언급한 것 처럼 Suspense는 단순히 로딩 Spinner를 돌리기 위한 패턴이 아닙니다.
자세한 내용은 React 공식 문서에서의 Suspense와 경쟁 상태 부분을 참고하세요.

isLoading 상태 활용하기

가장 쉬운 방법입니다.
isLoading이라는 state를 하나 더 만들고 이미 진행중인 요청이 있으면 다음 요청을 무시합니다.

const Component = () => {
  const [isLoading, setIsLoading] = useState(false);
  const [result, setResult] = useState();
  
  const onClickMusic = (name: string) => {
    if (isLoading) return;
    
    try {
      setIsLoading(true);
      setResult(
        await fetch(`/music/${name}`),
      );
    } finally {
      setIsLoading(false);
    }
  };
  
  /* .... */
};

이 방법은 사소한 단점이 있습니다.

요청이 끝날 때 까지 컴포넌트가 블록상태가 됩니다.
네트워크 요청은 상태가 안좋을 경우 10초이상 걸릴수도 있으며, 인터넷이 아예 안 될 경우는 30~60초가 지난 후 결국 아무것도 하지 못한 채 실패 상태로 돌아오는 경우도 있습니다.

네트워크의 재밌는 점은 로딩이 너무 오래 걸려서 유저가 다른 액션을 취하면 그 액션에 대한 요청은 매우 빠르게 돌아올 수도 있다는 것입니다.
(페이지가 오래동안 표시되지 않아서 F5를 눌렀더니 바로 표시되는 경우)
isLoading 방법은 이러한 운 좋은 경우에 대한 가능성을 원천적으로 차단해버립니다.

(사소한 단점이라고 언급 한 만큼 꼭 해결해야 하는 문제는 아닐 수 있습니다.)

Fetch Counter 만들기

좀 더 좋은 접근방법은 마지막 요청 만을 유효한 요청으로 인식하는 것 입니다. 이것은 UX적으로 좀 더 좋은 경험을 제공합니다.

const Component = () => {
  const fetchCounter = useRef(0);
  const [result, setResult] = useState();
  
  const onClickMusic = (name: string) => {
    const prevFetchCounter = ++fetchCounter.current;
    const newResult = await fetch(`/music/${name}`);
    
    // 요청 사이에 다른 요청이 있었는지 검사합니다.
    if (prevFetchCounter === fetchCounter.current) {
      setResult(newResult);
    }
  };
  
  /* .... */
};
  • fetchCounter 라는 변수를 만들고 요청 시작 전에 지역변수로 복사합니다.
  • 작업 을 시작합니다. (여기서 작업은 fetch이며, 일반적으로 오래 걸리는 (0.1초도 오래 걸리는 작업임)을 의미합니다.)
  • 작업이 끝나고 돌아와서 지역 변수최신 값(ref)을 비교합니다.
  • 만약 두 값이 같으면 도중에 끼어든 요청이 없는 것을 의미하므로 결과를 덮어쓰기 합니다.
  • 만약 두 값이 다르면 fetch 도중에 새로운 fetch가 실행되었다는 뜻입니다. 내가 가지고 있는 newResult는 이제 오래된 값이므로 덮어씌우지 않고 종료합니다.

몇 줄 안되는 짧은 코드지만 생소할 수도 혹은 친숙한 코드일수도 있습니다.
만약 이 코드가 생소하시다면 이해할 때 까지 계속 읽어보시는게 좋은 경험이 될 수도 있습니다.

마무리

profile
다른 곳에서 볼 수 없는 이상한 주제를 다룹니다. https://pjc0247.github.io/new-home

0개의 댓글