function Categories() {
const [_, setSearchParams] = useSearchParams();
const categories = ['카테고리01', '카테고리02', '카테고리03', '카테고리04'];
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearchParams({ category: e.target.value });
};
return (
<div>
{categories.map(category => (
<label key={category}>
<input type="radio" name="category" value={category} onChange={onChange} />
{category}
</label>
))}
</div>
);
}
function App() {
const { data, setData } = useState<Data | null>(null);
const [searchParams] = useSearchParams();
const category = searchParams.get('category') || '';
useEffect(() => {
axios
.get('url', { params: { category } })
.then((response: Data) => {
setData(response.data);
})
.catch((error: unknown) => {
console.error(error);
});
}, [category]);
return (
<div>
<Categories />
</form>
{state.data &&
state.data.map(data => (
<RenderDataItem key={data.id} data={data} />
))}
{state.error && <ErrorMessage />}
</div>
);
}
위의 코드는 n개의 카테고리 버튼이 있고, 각각의 버튼을 클릭할 때마다 해당 카테고리와 관련된 데이터를 새로 가져옵니다.
이 코드는 실제로 잘 동작하지만, 렌더링과 관련된 문제를 갖고 있는데요.
어떤 문제가 발생했을까요?
제 코드가 실행됐을 때 하나의 상황을 가정해보겠습니다.
사용자가 A 카테고리를 클릭했다면, A 카테고리에 대한 데이터를 가져오겠죠?
그런데 이 A 카테고리 데이터를 가져오기도 전에, 사용자가 B 카테고리를 클릭합니다.
또, B 카테고리 데이터를 가져오기도 전에, C 카테고리를 클릭해버립니다.
이제 화면에는 어떤 카테고리 데이터가 렌더링될까요?
C 카테고리 데이터가 렌더링됩니다. 그러나 그 이전에 A, B 카테고리 데이터도 같이 렌더링됩니다.
카테고리가 변경될 때마다 요청을 보내고, 받아온 데이터로 setState
를 실행하기 때문에 렌더링될 화면이 계속 바껴버립니다. 이것은 제가 의도한 동작이 아닙니다.
한가지 문제가 더 있습니다. 바로, 모든 비동기 데이터 요청이 발생한다는 점입니다.
요청을 보내고 네트워크 탭을 확인해보면, 보낸 모든 요청이 모두 성공적으로 이행됐음을 볼 수 있습니다.
마지막 카테고리 데이터만 렌더링하려고 하는데, n개의 요청이 모두 실행되고 이행된다는 점은 서버에도 무리를 주고, 요청을 낭비하기 때문에 좋지 않습니다.
아래 코드는 리액트 공식문서의 예제를 참고하여 작성했습니다.
useEffect(() => {
let shouldSetState = true;
fetchData(category)
.then(response => {
if (!shouldSetState) return;
setState();
})
.catch(error => {
if (!shouldSetState) return;
setState();
});
return () => {
shouldSetState = false;
};
}, [category]);
코드는 다음과 같이 동작합니다.
의존성 배열 값이 변경될 때마다 기존 이펙트 함수의 리턴문이 실행되고, 컴포넌트가 렌더링되면서 새로운 이펙트 함수가 호출됩니다.
그 과정에서 각각의 이펙트 함수 스코프가 렌더링마다 생성됩니다.
각 이펙트 함수 스코프마다 shouldSetState
라는 변수가 존재하고, 이 변수는 언마운트될 때 항상 false
로 변경되기 때문에, 이전 요청의 데이터로 setState를 실행하려고 하면 shouldSetState
변수가 true
가 아니기 때문에 setState가 실행되지 않습니다.
shouldSetState
변수가 함수 스코프마다 독립적으로 존재하기 때문에, 이펙트 함수 안에서 실행되는 비동기 함수의 실행 순서를 보장해주는 역할을 합니다.
이 방법을 사용하면, 컴포넌트가 언마운트된 후 setState가 실행되는 것을 방지할 수 있기 때문에 이전의 데이터 요청에 대한 setState
는 더이상 실행되지 않으므로, 불필요한 렌더링을 막을 수 있습니다.
하지만 모든 것이 해결된 것은 아닌데요. 화면을 렌더링하는 것은 개선했지만, 비동기 요청은 계속해서 실행되고 있기 때문입니다.
요청에 사용된 axios는 프로미스 객체를 리턴하는데, 자바스크립트에서는 이 프로미스를 중단할 수 있는 방법을 제공하지 않기 때문에, 자체적으로 요청을 취소할 수 없습니다.
하지만, DOM API에 존재하는 AbortController 객체
를 이용하면 비동기 요청을 취소할 수 있습니다.
이 객체는 하나 이상의 웹 요청을 중단할 수 있도록 하는 객체입니다.
이 객체를 이용해 abortController 객체
를 생성하면, 해당 인스턴스에 signal
프로퍼티가 존재합니다.
이 프로퍼티는 DOM 요청과 통신하는 AbortSignal 객체
를 리턴합니다.
AbortSignal 객체
는 DOM 요청과 통신하는 것은 물론, 보낸 요청을 취소할 수 있고, 요청의 취소 여부도 확인할 수 있습니다.
즉, AbortController 객체가 AbortSignal 객체를 리턴하면, 이 객체를 이용해서 DOM에 요청을 보낼 때 이를 취소할 수 있는 권한을 얻게 됩니다.
요청을 보낼 때, 요청 옵션 객체에 이를 전달하기만 하면 이 권한을 가질 수 있습니다.
abort
메서드를 통해 요청이 완료되기 전에 해당 요청을 취소할 수 있고,aborted
프로퍼티를 이용해 해당 요청이 취소됐는지도 확인할 수 있습니다.
그렇다면, 요청을 언제 취소해야 할까요?
useEffect는 렌더링마다 항상 새로운 이펙트 함수가 생성되어 호출된다고 했었죠?
즉, 각각의 렌더링마다 다른 이펙트 함수 스코프를 갖고 있습니다.
바로 이 점을 이용하면 됩니다. setState의 실행을 막았던 방법과 동일합니다.
이펙트 함수 안에서 abortController 객체
를 생성하고, 이 객체의 signal
프로퍼티를 요청시 옵션으로 전달합니다.
이제 이 객체는 각각의 이펙트 함수마다 독립적으로 존재하므로, 각 요청에 대한 취소 권한을 가질 수 있게 됩니다.
카테고리가 변경될 때, 이펙트 함수의 cleanup
함수에서 abort
메서드를 실행하게 되면 요청이 완료되기 전에 요청을 취소함으로써, 이전에 요청한 데이터를 더이상 가져오지 않습니다.
useEffect(() => {
const abortController = new AbortController();
const signal = abortControll.signal;
axios
.get('url', {
params: { category },
signal,
})
.then((response: Data) => {
if (signal.aborted) return;
setData(response.data);
})
.catch((error: unknown) => {
if (singal.aborted) return;
console.error(error);
});
return () => {
abortController.abort();
};
}, [category]);
이제 동작을 실행한 뒤, 네트워크 탭을 다시 확인해볼까요?
이전에 선택한 카테고리 데이터에 대한 요청이 모두 취소되고, 마지막으로 선택한 카테고리의 데이터만 가져오는 것을 확인할 수 있습니다.