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
에 대한 정보가 표시됩니다.
이러한 문제의 원인은, 네트워크 요청이 얼마나 걸릴지는 아무도 알 수 없으며 심지어 늦게 출발한 요청이 먼저 도착할수도 있습니다.
이 글의 처음 영상만 봐도 알 수 있듯이 이것은 드물게 발생하는 현상이 아니며, 마음먹는다면 매우 쉽게 재현할 수 있다는 점에 있습니다.
그렇지 않습니다.
만약 네트워크 완료 이후에 실행되는 코드가 좀 더 복잡하게 체이닝 되어 있고 (여러개의 연쇄 useEffect 등) 더 많은 값들을 변경한다면 이제는 단순 UI 버그가 아닌 진짜 버그 를 만들어낼 수 있고, 발견하기 매우 어려워질 수 있습니다.
만약 React를 사용한다면 Suspense 그리고 Render-as-you-fetch 패턴으로 교체합니다.
저의 다른 글에서 언급한 것 처럼 Suspense는 단순히 로딩 Spinner를 돌리기 위한 패턴이 아닙니다.
자세한 내용은 React 공식 문서에서의 Suspense와 경쟁 상태 부분을 참고하세요.
가장 쉬운 방법입니다.
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
방법은 이러한 운 좋은 경우에 대한 가능성을 원천적으로 차단해버립니다.
(사소한 단점이라고 언급 한 만큼 꼭 해결해야 하는 문제는 아닐 수 있습니다.)
좀 더 좋은 접근방법은 마지막 요청 만을 유효한 요청으로 인식하는 것 입니다. 이것은 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초도 오래 걸리는 작업임)을 의미합니다.)fetch
도중에 새로운 fetch
가 실행되었다는 뜻입니다. 내가 가지고 있는 newResult
는 이제 오래된 값이므로 덮어씌우지 않고 종료합니다.몇 줄 안되는 짧은 코드지만 생소할 수도 혹은 친숙한 코드일수도 있습니다.
만약 이 코드가 생소하시다면 이해할 때 까지 계속 읽어보시는게 좋은 경험이 될 수도 있습니다.
이미 실행된 fetch는 취소할 수 있습니다. 이전 요청을 취소하는 코드를 더하면 완벽한 코드가 되겠네요.
만약 이 주제에 대해 흥미가 생기셨다면, 여기에 더 읽어볼만한 글들이 있습니다: