React에서 비동기 작업을 할 때 Race Condition을 방지하는 효과적인 해결 방법들
Race Condition이란?
Race Condition이란 두 개 이상의 비동기 작업이 예상치 못한 순서로 실행되면서 의도하지 않은 결과를 만들어내는 상황을 의미한다. 보통 백엔드에서 멀티스레드 프로그래밍을 할 때 Race Condition을 주로 이야기하지만, React에서도 비동기 API 호출을 여러 번 할 때 이러한 상황이 발생할 수 있다. 그렇다면 구체적으로 어떤 상황에서 Race Condition이 발생하고, 이를 해결하기 위한 효과적인 해결 방법들을 알아보자.
React에서 발생할 수 있는 Race Condition
React에서 흔히 발생할 수 있는 Race Condition의 예로, 유저의 id를 props로 받아 해당 유저의 데이터를 fetch해 보여주는 컴포넌트를 생각해 볼 수 있다. 이러한 컴포넌트에서는 보통 useEffect 훅을 사용해 유저의 id를 의존성 배열에 넣고, id가 변경될 때마다 데이터를 fetch 해 온다.
이 과정에서 props로 넘겨 받은 유저의 id가 빠르게 바뀌면, 이전 요청이 완료되기 전에 새로운 요청이 실행되면서 Race Condition이 발생할 수 있다. 비동기 작업은 요청한 순서대로 완료되지 않을 수 있기 때문에, 가장 마지막으로 실행된 effect 보다 이전에 실행된 effect가 더 늦게 완료될 수 있다. 이 경우, 최신 props.id가 아닌 이전 props.id에 대한 잘못된 유저 데이터가 화면에 표시될 수 있다.
아래는 위 상황을 간단하게 재현을 한 예제이다. 네트워크 지연 상황을 재현하기 위해 의도적으로 비동기 작업 내에 지연 코드를 포함시켰다. 빠르게 유저들의 버튼을 클릭하다 보면 마지막으로 클릭한 유저의 정보가 보이지 않는 경우가 발생하는 것을 볼 수 있다.
const UserProfile = (props) => {
const [user, setUser] = useState(null);
useEffect(() => {
const fetchUser = async () => {
const res = await fetch(`https://dummyjson.com/users/${props.id}`);
await sleep(Math.random() * 3000);
const json = await res.json();
setUser(json);
}
fetchUser();
}, [props.id]);
if (!user) {
return <div>Loading...</div>;
}
return (
<div>
<p>{`props로 받은 유저 아이디: ${props.id}`}</p>
<h1>{`아이디: ${user.id}`}</h1>
<h2>{`이름: ${user.firstName} ${user.lastName}`}</h2>
</div>
)
}
See the Pen Race Condition이 발생할 수 있는 user fetch by Ohnim (@ohnim) on CodePen.
현재 effect가 가장 최신 effect인지 확인할 수 있는 플래그를 이용하는 방법
이러한 Race Condition을 해결하기 위한 한 가지 방법은, useEffect 내부에서 현재 실행 중인 effect가 최신 effect인지 확인할 수 있는 boolean 플래그를 사용하는 것이다. 먼저, effect가 실행될 때 isCurrent 변수를 true로 초기화한다. 그리고 useEffect의 clean-up 함수에서 isCurrent 변수를 false로 만들어준다. 이제 이 플래그를 사용하면 현재 effect가 최신 effect인지 추적할 수 있다. 이후 fetch 함수 내에서 isCurrent가 true일 때만 유저 데이터를 업데이트하면, 유저는 항상 최신 props.id에 해당하는 유저 데이터가 될 것이다.
이 방법은 useEffect가 실행될 때마다 매번 새로운 실행 컨텍스트가 생성되어, 완료 대기 중인 과거의 effect의 isCurrent는 false가 되고, 항상 최신의 effect에서만 isCurrent 변수가 true로 유지된다는 원리를 활용하는 방법이다.
const UserProfile = (props) => {
const [user, setUser] = useState(null);
useEffect(() => {
let isCurrent = true;
const fetchUser = async () => {
const res = await fetch(`https://dummyjson.com/users/${props.id}`);
await sleep(Math.random() * 3000);
const json = await res.json();
if (isCurrent) {
setUser(json);
}
};
fetchUser();
return () => {
isCurrent = false;
};
}, [props.id]);
if (!user) {
return <div>Loading...</div>;
}
return (
<div>
<p>{`props로 받은 유저 아이디: ${props.id}`}</p>
<h1>{`아이디: ${user.id}`}</h1>
<h2>{`이름: ${user.firstName} ${user.lastName}`}</h2>
</div>
);
};
boolean 플래그를 이용해 Race Condition을 해결하는 예제는 여기에서 확인할 수 있다.
state에 비동기 요청 시간을 추가하는 방법
Race Condition을 해결하는 또 다른 방법은, state에 비동기 요청의 시간을 추가하여 현재 state가 언제 요청된 데이터인지 확인하는 것이다. 구체적으로, 비동기 요청을 보낼 때 그 시점의 타임스탬프를 기록한다. 이후, 비동기 작업이 완료되면 state를 업데이트하기 전에, 해당 요청의 타임스탬프와 현재 state에 저장된 타임스탬프를 비교한다. 만약 state에 저장된 타임스탬프가 해당 요청의 타임스탬프보다 더 최신이라면, 해당 요청의 데이터는 state에 저장하지 않는다.
const UserProfile = (props) => {
const [{ user }, setData] = useState({
user: null,
requestedAt: 0
});
useEffect(() => {
const fetchUser = async () => {
const now = Date.now();
const res = await fetch(`https://dummyjson.com/users/${props.id}`);
await sleep(Math.random() * 3000);
const json = await res.json();
setData((prev) => {
if (prev.requestedAt > now) {
return prev;
}
return {
user: json,
requestedAt: now
};
});
};
fetchUser();
}, [props.id]);
if (!user) {
return <div>Loading...</div>;
}
return (
<div>
<p>{`props로 받은 유저 아이디: ${props.id}`}</p>
<h1>{`아이디: ${user.id}`}</h1>
<h2>{`이름: ${user.firstName} ${user.lastName}`}</h2>
</div>
);
};
state에 타임스탬프를 같이 저장해 Race Condition을 해결하는 예제는 여기에서 확인할 수 있다.
비동기 요청을 직접 중단하는 방법
비동기 요청을 직접 중단할 수 있다면, 이를 활용하는 것 또한 Race Condition을 해결하는 효과적인 방법이 될 수 있다. React 프로젝트에서 대부분의 비동기 호출은 fetch나 axios를 통해 이루어지는데, 이 경우 AbortController를 사용하여 비동기 요청을 중단할 수 있다.
이 방법을 구현하기 위해서는, useEffect 내부에서 AbortController를 생성한 뒤, 이를 fetch나 axios 호출에 취소 신호로 전달한다. 이후, useEffect의 clean-up 함수에서 AbortController를 실행해 진행 중인 비동기 요청을 중단시킬 수 있다. 이렇게 하면, 새로운 effect가 실행돼 새로운 비동기 요청이 발생할 때, 이전에 실행 중이던 비동기 요청은 자동으로 중단되어 항상 최신 데이터만 보여줄 수 있다.
다만, 사용하는 비동기 요청 라이브러리에 따라 요청을 중단시킬 경우 에러가 발생할 수 있으므로, then이나 catch를 이용해 비동기 요청이 AbortController에 의해 중단된 것인지 확인하는 추가적인 코드를 작성하는 것이 좋다.
const UserProfile = (props) => {
const [user, setUser] = useState(null);
useEffect(() => {
const controller = new AbortController();
const fetchUser = async () => {
const res = await fetch(`https://dummyjson.com/users/${props.id}`, {
signal: controller.signal
});
await sleep(Math.random() * 3000);
const json = await res.json();
setUser(json);
};
fetchUser();
return () => {
controller.abort();
};
}, [props.id]);
if (!user) {
return <div>Loading...</div>;
}
return (
<div>
<p>{`props로 받은 유저 아이디: ${props.id}`}</p>
<h1>{`아이디: ${user.id}`}</h1>
<h2>{`이름: ${user.firstName} ${user.lastName}`}</h2>
</div>
);
};
AbortController를 이용해 Race Condition을 해결하는 예제는 여기에서 확인할 수 있다.
상태 관리 라이브러리를 이용하는 방법
마지막으로, SWR이나 React Query와 같은 서버 상태 관리 라이브러리를 사용하는 방법이 있다. 이러한 라이브러리는 서버 상태를 효과적으로 관리할 수 있게 해주며, 특히 비동기 요청을 안정적으로 처리하도록 도와준다. 예를 들어, 이 라이브러리들은 쿼리 key마다 응답 상태를 독립적으로 관리하며, 항상 최신 요청의 응답만을 데이터에 저장한다. 따라서 props.id를 쿼리 key로 설정하면, Race Condition에 대한 우려를 크게 줄일 수 있다.
하지만, 이 글에서는 이러한 라이브러리를 사용하는 것 보다는 Race Condition이 발생하는 근본적인 이유와 이를 해결할 수 있는 기본 원리 및 방법을 이해하는 데 중점을 두고 있다. 따라서 이번 글에서는 SWR, React Query와 같은 라이브러리 사용 방법에 대한 자세한 설명은 생략하고, 추후 다른 글에서 다룰 예정이다.
결론
React 프로젝트에서 비동기 작업을 할 때 SWR이나 React Query와 같은 라이브러리를 사용하기 때문에, Race Condition은 자주 마주하는 문제가 아니다. 하지만 자주 발생하지 않는 문제이기 때문에, 한 번 마주치게 되면 원인 파악과 해결 방법에 많은 시간을 사용하게 될 수 있다. 이 글이 앞으로 React 프로젝트에서 Race Condition 문제를 해결하는 데 도움이 되길 바라며, 이만 글을 마친다.