반복 작업은 신중해야합니다.

Z : “뭐? 반복 작업을 조심하라고?”
R : “네, ’setInterval()’은 위험합니다.”
금일 오전 수업에서 setInterval()을 이용해서 타이머를 만들어 보았습니다.
근데 한 가지 의문이 들었습니다.
setTimeout()을 반복해서 만드는거랑 무슨 차이가 있을까?
반복 작업에 필요한 메서드는 대표적으로 setInterval()과 재귀 setTimeout()을 사용하여 구현합니다.
두 작업의 대표적인 차이점은 실행 시점을 기준으로 하는데요,
- setInterval()은 지정한 ’시간’마다 ’태스크큐(Task Queue)’에 할 일을 추가하여 실행합니다.
- setTimeout()은 ’작업이 끝난 뒤’에 ’태스크큐(Task Queue)’에 할 일을 추가하여 실행합니다.
*태스크 큐에 대한 자세한 내용은 MDN: 태스크 큐
마이크로태스크는 자신을 생성한 함수 또는 프로그램이 종료됐고 JavaScript 실행 스택이 빈 후에, 그러나 사용자 에이전트가 스크립트 실행 환경을 운용하기 위해 사용하는 이벤트 루프로 통제권을 넘기기는 전에 실행되는 짧은 함수입니다.

즉, setInterval()은 선행 작업이 끝나지 않더라도(마무리 되지 않더라도) 시간만 지나면 다음 작업을 수행합니다.
하지만 이는 심각한 문제를 야기할 수 있습니다. 비동기 처리 로직에서 자칫하면 race condition(경쟁 상태)이나 stale closure 같은 문제를 야기할 수 있습니다.
Polling(폴링)을 예로 들어봅시다.
폴링은 클라이언트가 서버에게 주기적으로 신호를 보내는 서버 통신 방식인데요,
setInterval() 과 setTimeout()으로 구현해봅시다.
// setInterval 방식
setInterval(async function poll() {
const data = await fetch("/api");
setState(data);
}, 1000);
// setTimeout 재귀 방식
async function poll() {
const data = await fetch("/api");
setState(data);
setTimeout(poll, 1000);
}
poll();setInterval()동작 과정
- setInterval()을 호출합니다. -> setInterval()이 1초를 계속 반복적으로 셉니다.
- 바로 다음으로 #1 fetch(`/api`) 요청이 들어옵니다. -> #1 요청은 총 2초가 소요된다고 가정합니다.
- setInterval()이 처음 셌던 1초가 지나서 다음 #2 fetch(`/api`)가 들어옵니다. -> #2 요청은 3초가 소요됩니다. #1 요청이 1초 남았고, #2는 3초가 남았습니다.
- setInterval()이 다시 1초가 지나서 다음 #3 fetch(`/api`)가 들어옵니다. -> #3 요청은 1초가 소요됩니다. #1은 처리되었고, #2는 2초가 남았습니다.
- setInterval()이 다시 1초가 지나서 다음 #4 fetch(`/api`)가 들어옵니다. -> 이때 #3이 #2보다 먼저 처리됩니다. #2는 1초 남았습니다.
여기서 문제가 생깁니다. 앞서 말씀드렸던 race condition 문제가 발생합니다. 클라이언트는 분명 #2를 먼저 보냈지만, #3 응답이 먼저 도착해 상태를 덮어쓰는 race condition이 발생할 수 있습니다.
recursive setTimeout()(재귀 setTimeout()) 동작 과정
- setTImeout()을 호출합니다. -> setTimeout()이 1초를 새고 poll()을 호출합니다.
- 바로 다음으로 #1 fetch(`/api`) 요청이 들어옵니다. -> #1 요청은 총 2초가 소요된다고 가정합니다.
- 요청 시간 2초 동안 poll()을 호출하지 않습니다. -> #1이 응답까지 끝난 후에야 다음 태스크를 받을 준비를 합니다. 그동안 Task Queue는 대기합니다.
이처럼 재귀 setTimout()은 다음 요청이 오기 전까지 콜스택(새로운 호출을 스케줄링)을 비워두기 때문에 race condition을 예방할 수 있습니다.
즉, React에서는 재귀 setTimeout()과 뒷처리(cleanup)함수로 구현하는 것이 상대적으로 안전합니다.
useEffect(() => {
let cancelled = false;
async function loop() { ... }
loop();
return () => {
cancelled = true;
};
}, []);‘setInterval()은 그럼 아예 사용하면 안되는 걸까요?’
조건에 따라 사용해도 됩니다.
setInterval() 커스텀 훅을 만들어 참조 변수 의존성을 부여하고, ref.current를 사용해서 최신 state를 참조하도록 하면 됩니다. -> 이로써 stale closure 문제를 해결합니다.
이렇게 비동기 요청에서 setInterval()을 조심해야 하는 이유와 대처 방법에 대해 알아보았습니다. 결국 두 방식을 모두 사용할 수 있지만, 비동기 요청이나 폴링처럼 응답 순서가 중요한 로직에서는 setInterval()의 특성을 이해하고, 재귀 setTimeout()이나 커스텀 훅으로 예방하는 설계의 필요성을 느꼈습니다.
더 구체적인 내용은 참고한 블로그 내용을 읽어보시는 것을 권장합니다.
둘 다 주기적 작업에 쓰이지만 다른 스케줄링 방식, Task Queue에 콜백이 쌓이는 시점, 비동기 작업에서의 overlap 문제, React stale closure까지 이벤트 루프 레벨에서 살펴보기
- #회고