2023. 12. 12. 16:45ㆍJavaScript
React 18 버전에서 컴포넌트별 lazy loading 을 구현하다 rabbit hole 에 빠져 허우적 댄 경험을 까먹지라도 않으려고 포스팅을 남긴다.
그 전에 race condition 이 뭔지 알아보고 가자
간단하게 말하면 두 개 이상의 스레드가 하나의 공유 자원에 접근해서 일어나는 상태 ( 또는 그로 인해 발생하는 버그 ) 라고 생각하자
더 쉽게
두 개 이상의 스레드 = 유저가 두개의 검색을 실행하면 fetch('your_url?example=예시') 이런게 두개 실행된다.
하나의 공유 자원 = fetch 로 받아온 data 로 뿌려지는 ui
JS 가 두 개 이상의 스레드?
우리가 쓴 코드는 싱글 스레드로 실행되지만, 브라우저는 멀티 스테르도 작동한다.
버그가 발생하는 코드 예시
const SearchResult = () => {
const [result, setResult] = useState();
const onClickBlog = async (name: string) => {
setResult(
await fetch(`/blog/${name}`),
);
};
return (
<div>
<div>
<button onClick={() => onClickBlog('Blog1')}>
Blog1
</button>
<button onClick={() => onClickBlog('Blog2')}>
Blog2
</button>
<button onClick={() => onClickBlog('Blog3')}>
Blog3
</button>
</div>
<div>
선택하신 Blog의 정보는 {result} 입니다.
</div>
</div>
);
};
뭐 겉으로 보기에는 문제 없어보인다. 실제로 오류도 안뜬다.
하지만 여기서 Blog1 과 Blog2 , Blog3 의 데이터를 불러오는데 걸리는 시간은 아무도 알 수 없다.
그런데 어떤 성질급한 한국인이 Blog 1번을 누르고 빨리 안나와서 2번 눌렀다고 생각해보자.
Blog1 의 버튼을 클릭하고 Blog1의 데이터가 도착 하기 전
Blog2 버튼을 클릭하고 Blog2 의 데이터가 fetching 까지 완료되고 그 후에 Blog1 의 데이터가 fetching 된다면
유저가 마지막으로 누른 버튼은 Blog2 이지만 UI 에 보여지는 Data 는 Blog1 의 데이터 일 것이다.
뭐 사실 그냥 isLoading 상태 하나 만들어서 if 문 붙여줘서 fetch 하면 된다.
const SearchResult = () => {
const [result, setResult] = useState();
const [isLoading, setIsLoading] = useState(false);
const onClickBlog = async (name: string) => {
if(isLoading) return; // 이거 주면 된다
try{
setIsLoading(true);
setResult(
await fetch(`/blog/${name}`),
);
} finally {
setIsLoading(false);
}
};
return ...
};
짠 쉽게 해결된 것 처럼 보인다.
그런데 한가지 문제는 처음 보낸 요청이 끝나기 전 까지 컴포넌트가 Block 상태가 된다는 단점이 있다.
요청이 엄청 오래걸리면? 로딩이 오래걸려서 F5 를 눌렀을 때 갑자기 되는 경우도 있다. 그런 모든 가능성을 원천적으로 차단해버린다.
여기서 abortController 를 사용 해 볼 수 있다.( node js 에선 못씀 )
사실 프론트 작업 대부분이 비동기로 데이터 불러다가 화면에 띄우는게 대부분인데 데이터 호출이 오래걸리면 취소 할 수도 있어야 되지 않겠음?
비동기 호출 취소 하게 해주는 기능임
https://developer.mozilla.org/ko/docs/Web/API/AbortController
AbortController - Web API | MDN
AbortController 인터페이스는 하나 이상의 웹 요청을 취소할 수 있게 해준다.
developer.mozilla.org
공식문서 예시 코드를 확인해보자
var controller = new AbortController();
var signal = controller.signal;
var downloadBtn = document.querySelector('.download');
var abortBtn = document.querySelector('.abort');
downloadBtn.addEventListener('click', fetchVideo);
abortBtn.addEventListener('click', function() {
controller.abort();
console.log('Download aborted');
});
function fetchVideo() {
...
fetch(url, {signal}).then(function(response) {
...
}).catch(function(e) {
reports.textContent = 'Download error: ' + e.message;
})
}
그냥 뭐 fetch 에 옵션으로 signal 주면 abort 로 fetch 요청 중간에 signal 로 등록된 요청을 취소 시킬 수 있다.
추가로 abort 이벤트에도 접근 가능하다.
// 이벤트 접근 방법1
addEventListener('abort', (event) => { ... });
// 이벤트 접근 방법2
onabort = (event) => { ... };
자 이제 React 에 Loading 상태랑 AbortController 를 tsx 에서 같이 써보면 이런식으로 나온다
'use client';
import React, { useEffect, useState } from 'react';
interface BlogData {
title: string;
}
const Blog = () => {
const [result, setResult] = useState<undefined | BlogData>();
const [isLoading, setIsLoading] = useState(false);
// 얘는 따로 빼서 export 해주자
const fetchBlog = async ({ signal, url }: { signal: AbortSignal; url: string }): Promise<BlogData> => {
const response = await fetch(url, { signal });
const data: BlogData = await response.json();
return data;
};
// 첫 렌더링시 React 에서의 race condition 방지
useEffect(() => {
const abortController = new AbortController();
const { signal } = abortController;
fetchBlog({ signal, url: 'Blog1' }).then((blog: BlogData) => {
setIsLoading(false);
setResult(() => blog);
});
return () => {
abortController.abort();
};
}, []);
// click 이벤트에서 race condition 방지
const onClickBlog = async (url: string) => {
const abortController = new AbortController();
const { signal } = abortController;
if (isLoading) {
abortController.abort();
return;
}
try {
setIsLoading(true);
fetchBlog({ signal, url }).then((blog: BlogData) => {
setResult(() => blog);
});
} finally {
setIsLoading(false);
}
};
return (
<div>
<div>
<button onClick={() => onClickBlog('Blog1')}>Blog1</button>
<button onClick={() => onClickBlog('Blog2')}>Blog2</button>
<button onClick={() => onClickBlog('Blog3')}>Blog3</button>
</div>
<div>선택하신 Blog의 정보는 {result?.title} 입니다.</div>
</div>
);
};
export default Blog;
자 이제 완벽하게 동작하는 것 처럼 보인다.
하지만 더 해결할게 남았다 슬슬 스트레스가 받지만 꾹 참고 조금만 더 고쳐보자.
React 컴포넌트는 부모에서 자식으로 내려오며 순차적으로 렌더링된다.
useEffect 내부에서 데이터를 호출하게 된다면 dom mount 이후에 호출되어 렌더링 직후 데이터를 호출하기 시작한다.
한마디로 상위 컴포넌트의 렌더링이 끝나지 않으면 계층 구조 깊숙히 밖혀있는 하위 컴포넌트는 데이터 호출 시점이 뒤로 밀려나게 된다.

이에 대한 해결책도 당연히 존재한다.
Suspense
Suspense를 사용하여 컴포넌트 외부에서 데이터를 호출하게 되면 해당 코드가 선언된 모듈 파일이 실행되는시점에 맞춰 api 호출이 시작되고 loading state 관리가 필요 없어진다.
근데 피곤하니까 Suspense는 다음 포스팅에서 해볼 예정
'JavaScript' 카테고리의 다른 글
| Javascript temporal dead zone ( TDZ ) const 와 let 호이스팅 (1) | 2023.12.21 |
|---|---|
| Headless CMS 란? (0) | 2023.08.17 |
| 자바스크립트 카카오맵 API 활용방법 (0) | 2022.10.01 |
| Javascript 외부데이터 달력/차트 site (0) | 2022.09.22 |
| jQuery stop() 메서드 빠른클릭시 문제 (0) | 2022.09.16 |