앞선 챕터를 통해, 일반적인 핸들러 함수가 Race Condition과 관련된 잠재적인 문제점을 가지고 있는 것을 알게 되었다.
해당 글은 Race Condition을 제어할 솔루션을 집중적으로 다루기 보다는, Race Condition을 제어하기 위해 Dimmed컴포넌트를 사용하는 방법이 왜 의미가 없는지 집중적으로 설명한다.
useEffect(() => {
let active = true;
const fetchData = async () => {
setTimeout(async () => {
const response = await fetch(`https://swapi.dev/api/people/${props.id}/`);
const newData = await response.json();
if (active) {
setFetchedId(props.id);
setData(newData);
}
}, Math.round(Math.random() * 12000));
};
fetchData();
return () => {
active = false;
};
}, [props.id]);
컴포넌트가 마운트되면, 해당 이펙트가 실행되고 이는 fetchData()를 실행하게 되며, fetchData가 비동기적으로 응답값을 받아오는 도중, props.id에 대한 변조가 발생하는 경우, 해당 이펙트는 Clean-up 함수를 실행하여 active 변수값을 변조한다.
이때, Clean-up 함수가 변조한 active 변수는, 이전 fetchData()의 실행 콘텍스트에서 참조하는 같은 closure 내에 존재하는 active 변수이기 때문에, props.id값에 변화가 생겨 중복된 fetchData()호출이 발생해도, 실질적인 상태값 업데이트를 시도하지 않으므로 race condition을 제어할 수 있다.
다만 API 호출 자체를 방지하지 못하므로, 쓸데없는 서버 리소스 낭비는 방지할 수 없다고 볼 수 있다.
핸들러 함수를 통한 API 호출과 그에 따른 race condition 제어가 필요하다면 유용하게 사용할 수 있다. 2-1과 달리, 실제 중복된 요청 자체를 막아버리기 때문에 유용한 방법이다.
const ExampleAbortController = () => {
const [data,setData] =useState<any>()
const abortControllerRef = useRef<AbortController>()
const handleFetch = async () => {
if(abortControllerRef.current){
abortControllerRef.current.abort('already executed')
return;
}
abortControllerRef.current = new AbortController()
const res = await fetch('URL', {signal:abortControllerRef.current.signal})
setData(res)
}
return <button onClick={handleFetch}>{data}</button>
}
위 두 방법은, abort 컨트롤이 필요한 상황마다 매번 동일한 보일러 플레이트 코드를 작성해주어야 한다. 물론 API Client자체적인 configuration 설정과 제어를 통해 어느정도 공통 로직을 적용할 수는 있지만, 조금 더 간단한 방법이 없을까?
어떤 문제던 가장 훌륭한 솔루션은 가장 단순한 솔루션이다. 우리도 관점을 바꿔 단순하게 생각해보자. 핸들러 함수가 중복 호출되어 Race Condition 문제가 발생하는 거라면, 핸들러 함수의 중복 호출 자체를 막아버리면 되지 않을까?
실제로 많은 이들이 이런 사고의 흐름을 거치게 되고 이를 구현하기 위해 핸들러 함수가 실행될 때 Dimmed가 적용된 Spinner 컴포넌트를 노출 시키는 코드를 작성하는 경우가 많다.
다만 많은 이들이 의도한대로 동작하지 않은 Spinner를 구현해서 문제가 발생한다.
리액트를 사용한다면, 대게 Spinner 컴포넌트 또한 리액트를 통해 렌더링 여부를 결정한다. 예로 컴포넌트의 가장 상위 노드라고 불리는 Root레벨 정도에 주입하게 된다.
// 예제 코드
export const HomeTemplate= ({ children }) => {
// redux 전역 상택값 참조
const isSpinnerShow = useSelector(uiSelector.isSpinnerShow);
return (
<TemplateContainer>
{isSpinnerShow && <LoadingSpinner />}
{children}
</TemplateContainer>
);
};
isSpinnerShow라는 전역 상태값에 의존성을 가지고 있음을 알 수 있다. 또한 해당 상태값을 업데이트 하는 로직은 핸들러 함수의 API 호출 전,후 로 수행한다.
const handleClick = async () => {
dispatch(showSpinner());
// API 호출
await someAPIcall();
dispatch(hideSpinner());
};
개발자는 위와 같은 코드를 다음과 같은 순서의 작동흐름을 기대하며 작성했을 것이다.
위 방식의 문제점은 바로 의존성을 상태값에 둔다는 것이다. Redux 등의 전역 상태관리 솔루션이 굳이 아니더라도, '상태'값을 통해 Dimmed를 제어할 경우 절대 개발자가 의도한 대로 스피너가 동작하지 않는다.
이는 두 가지의 주요한 특성이 고려되어야 한다.
리덕스의 Action Dispatch는 리액트의 업데이트 방식과 달리, 동기적으로 상태값을 업데이트 할 여지가 있다. 즉 핸들러 함수 내부에서 상태값을 업데이트 하는 액션을 발행한다면, API 호출 전에 상태값이 업데이트 될 여지가 있다.
핸들러 함수의 실행 컨텍스트가 콜스택에 존재하는 한, 리액트는 리렌더링을 수행할 수 없다.
즉 showSpinner, hideSpinner등의 액션 발행을 통해, 리덕스가 핸들러 함수 내부의 실행 컨텍스트에서 동기적으로 상태값을 업데이트 한다고 해도, 리액트는 핸들러 함수가 종료되기 전까지 렌더링을 수행할 스레드 제어권을 가지지 못하므로, 의도한 동작이 이루어지지 않는다.
앞서 살펴본 내용에 의해, 실질적으로 스피너가 동기적으로 마운트되고, 언마운트 되려면, 핸들러 함수가 실행되는 중간에, 리액트에게 스레드 제어권이 넘어갈 수 있는 여지가 필요하다.
이를 위해서 상태관리 솔루션 마다 다양한 기법을 적용할 수 있지만, Redux에서는 Saga를 사용해 볼 수 있다.
import { put, call } from 'redux-saga/effects';
import { showSpinner, hideSpinner } from './actions';
function* handleApiCall() {
yield put(showSpinner());
try {
const response = yield call(someAPIcall);
// 필요한 경우 response를 바탕으로 다른 액션 디스패치
} catch (error) {
// 에러 핸들링
} finally {
yield put(hideSpinner());
}
}
import { takeLatest } from 'redux-saga/effects';
function* watchApiCall() {
yield takeLatest('TRIGGER_API_CALL', handleApiCall);
}
이제 핸들러 함수 내부에서 직접적으로 spinner 액션을 발행하는 것이 아니라, 우리가 작성한 사가 함수가 실행될 수 있도록 하는 액션을 발행합니다.
const handleClick = () => {
dispatch('TRIGGER_API_CALL');
};