경합 조건이란 쉽게 말해 하나의 작업 처리를 위해 다수의 작업 (이벤트 등) 이 거의 동시에 발생하는 경우, 처리를 하는 입장에서 무엇이 먼저 완료될 지 예측하기 힘들다는 것입니다. API 호출 등 비동기의 상황에서 요청 순서와 응답 순서가 반드시 일치함을 보장할 수 없는 상태를 비동기 맥락에서의 경합 조건이라고 볼 수 있겠습니다.
const funcA = () => {
fetch(marketUrl).then(console.log);
};
const funcB = () => {
fetch(cartUrl).them(console.log);
}
funcA();
funcB();
위 코드에서 A 함수를 호출한 후 B 함수를 호출하고 있지만, 반드시 콘솔에 A 함수의 결과가 찍힌 이후 B 함수의 결과가 찍힌다고 보장할 수 없습니다.
왜냐하면 어떠한 이유에 의해서 marketUrl
의 응답이 cartUrl
의 응답보다 느려 늦게 도착할 수 있기 때문이죠. 이처럼 비동기 상황에서는 세심한 처리가 필요합니다.
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 링크에서 확인 가능합니다.)
위 예시는 보다 명확한 경합 조건 관찰을 위해 일부러 fetch 로직을 1~12초간 랜덤으로 수행하도록 하였는데요. fetch를 2회 이상 수행하게 되면 요청 순서와 응답 순서를 보장할 수 없는 경합 조건 상태에 빠지게됩니다. 요청 순서와 응답 순서가 정상이면 글자색이 초록색으로, 다르면 글자색이 빨간색으로 보이게 되는거죠.
만약에 비동기 요청을 순차적으로 처리해야하는 상황이었다면 경합 조건으로 인해 애플리케이션 동작이 망가지게 될 것입니다.
경합 조건을 어떻게 해결해볼 수 있을까요? 반복되는 이벤트 호출 처리를 제어하기 위해 쓰로틀링(Throttling)을 생각해볼 수 있겠습니다. 해당 상황에서 쓰로틀링이란 간략하게 말해 의도적으로 이벤트 호출 속도, 횟수를 제한하여 비용을 절감하는 행위라고 볼 수 있겠습니다.
하지만 쓰로틀링은 빈번하게 발생되는 동일한 이벤트의 호출량을 조절할 뿐, 비동기 상황에서 가져온 데이터들간 경합 조건을 해결해준다고 보기 어렵습니다.
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 값이 경합 조건에 의해 달리 보였을 수도 있었을 것입니다.
특정 시점에서 fetch 요청 자체를 끊어버리면 가장 깔끔한 방법이 아닐까요? Web API의 AbortController
를 사용하면 가능합니다. AbortController 는 하나 이상의 웹 요청을 취소할 수 있게 해주는 인터페이스이며 다음과 같이 주요 프로퍼티와 메서드를 가집니다.
사용 방법을 간략하게 요약하면 다음과 같습니다.
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)