React에서 setInterval 사용하기
최근에 리액트 프로젝트를 진행하면서 setInterval을 사용할 일이 많아졌다.
채팅방 목록을 새로고침할 때 setInterval을 사용해서 주기적으로 HTTP 요청을 보낸다던가(polling),
채팅이 도착했을 때 setInterval을 사용해서 브라우저 탭 제목을 바꾼다던가 하는 식이다.
(페이스북 DM이 도착했을 때 브라우저 탭 제목이 '(4) facebook' 이런 식으로 바뀌는 것 처럼)
채팅방 목록을 보여주는 페이지에 처음 접속했을 때, 즉 페이지가 mount될 때
채팅방 목록을 일정 주기마다 불러오는 polling을 하기 위해서
empty dependency를 가진 useEffect 내부에 setInterval을 호출해줬는데, 생각지 못한 문제가 발생했다.
const example() => {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
// ...
};
empty dependency를 가진 useEffect는 처음 mount 시점에 한 번 실행된다.
때문에 위의 useEffect는 첫 렌더의 count를 이용해서 setInterval을 실행하는데,
이후로는 해당 useEffect가 다시 실행되지 않기 때문에
setInterval의 클로져는 계속해서 첫 렌더의 count를 기억하고 있다.
처음 interval이 세팅되는 시점의 count는 0이다.
그 후에 count가 1, 2, 3, ... 등으로 변경되어도 interval에서 count는 계속해서 0 + 1 = 1이 된다.
리액트 권위자(?) Dan Abramov에 의하면, 이 현상은 setInterval과 리액트의 근본적인 차이에 기반한다.
리액트 컴포넌트의 props와 state는 계속해서 변화할 수 있다.
이 때 리액트 컴포넌트는 저번 렌더링 상태를 모두 잊어버리고, 새로운 상태를 가지고 다시 렌더링한다.
반면 setInterval은 한 번 설정되면 clearInterval을 하는 것 말고는 아무런 변경을 할 수가 없다.
그래서 interval 타이머를 직접 갈아주지 않는 이상, 처음의 props와 state를 계속 참조하고 있는 것.
setCount(count => count + 1)처럼 updater function을 사용해서 setCount를 해주면 문제는 해결된다.
updater function을 사용하면 항상 신선한(fresh) 상태값을 가져올 수 있기 때문.
* setState는 비동기로 동작한다.
때문에, 한 번에 여러 개의 setState를 사용하면 그 update들을 모두 queue에 담았다가,
더 이상 setState가 없을 때 queue에 있는 모든 update들을 실행시키고, 이후에 리렌더링이 실행된다.
아마 쓸데없는 렌더링을 줄이고, 모든 변화를 반영한 한 번의 렌더링으로 효율성을 높이기 위한 것이리라.
그래서 여러 번의 setState를 사용할 때, 순서대로 변경이 일어나게 하기 위한 방법으로
'updater function' 형식이 사용된다. setState 내부에 콜백 함수를 사용해주는 것.
setState(count => count + 1);
이렇게 하면 setState가 실행되고 콜백 함수가 queue에 들어가서, 들어간 순서대로 updater function이 실행된다.
이 때 리액트는 이전 update로 인해 변경된 새로운 state의 복사본을 다음 update로 전달한다.
그래서 updater function을 사용하면 이전 setState의 결과물을 다음 setState에 바로바로 반영할 수 있게 된다.
setInterval에서도 아마 이전 interval의 setState가 update한 결과물을 리액트가 기억해뒀다가
다음 interval의 setState이게 전달해주기 때문에 매 interval 마다 최신 state를 반영할 수 있는 게 아닌가 싶다.
근데, 이는 state가 변경되는 경우에 문제점을 해결한 것이고,
props가 변경되는 경우는 여전히 해결하지 못한다.
이 문제의 근본적인 해결책으로 제시된 것은 바로 useRef이다.
Ref는 current 프로퍼티를 가지는 순수 객체이기 때문에,
setInterval에 들어가는 callback 함수를 이 current에 저장하고, 그 녀석을 계속 갈아주면 된다.
그러면 setInterval은 계속해서 변화하는 콜백을 interval에 반영할 수 있게 된다.
count가 변하면 리렌더링이 발생하는데,
그 때 새로운 상태를 반영한 새로운 callback이 savedCallback으로 들어가서 current에 저장된다.
그러면 객체의 프로퍼티이기 때문에 자유롭게 변경이 가능하고,
다음 interval에서는 이 새로운 callback을 반영한 interval이 실행된다.
const example = () => {
const [count, setCount] = useState(0);
const savedCallback = useRef();
const callback = () => {
setCount(count + 1);
}
useEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
const tick => () {
savedCallback.current();
}
const timer = setInterval(tick, 1000);
return () => clearInterval(timer);
}, []);
// ...
}
그리고, 이를 활용해 Dan Abramov가 useInterval이라는 훅을 만들었다.
const useInterval = (callback, delay) => {
const savedCallback = useRef();
useEffect(() => {
savedCallback.current = callback;
});
useEffect(() => {
const tick = () => {
savedCallback.current();
}
const timerId = setInterval(tick, delay);
return () => clearInterval(timerId);
}, [delay]);
}
이 훅을 사용해서 일단 채팅방 목록 polling 문제는 해결을 했다.
문제는 채팅 알림.
유저가 우리 페이지를 보고 있지 않을 때 채팅이 도착하면
채팅이 도착했다고 브라우저 탭에다가 표시를 해주는 형태로 구현을 하고 싶었다.
그리고 브라우저 탭에 표시해주는 내용을 주기적으로 바꿔서 사용자의 눈에 띄게 만들려고 했다.
useInterval이 선언적으로 interval을 줄 수 있는, 매우 잘 만든 훅이라고 생각하지만
우리의 경우에는 채팅이 수신될 때 알람이 발생하도록 해야했기 때문에 함수 내부에서 interval을 발생시켜야 했다.
훅은 함수나 조건문 내부에서 사용할 수가 없기 때문에, 고민 끝에 결국 다른 방법을 생각해야 했다.
일단 지금 당장 선택한 방법은 채팅이 수신될 때마다 setInterval을 걸어주는 것이다.
결국 원점으로 되돌아간 느낌이 없잖아 있지만, 이제는 setInterval의 사용법을 알았으니 문제를 해결할 수 있게 된 것.
(사용법이라 함은, 상태가 변할 때마다 interval을 새로 갈아주던가 updater function을 사용한다던가..)
물론, 그냥 setInterval만 계속 해주면 interval이 계속 중첩되기 때문에 대혼란파티가 벌어지므로
clearInterval로 이전의 interval을 먼저 제거해 준 후에 setInterval을 해주고 있다.
* setInterval을 하기 전에 clearInterval을 해주려면 timer의 id를 기억하고 있어야하므로 ref를 함께 사용해주었다.
(그냥 const나 let을 사용하면 리렌더링될 때 timer id가 사라지고, state 값으로 기억하기엔 타이머를 세팅할 때마다 불필요한 렌더링이 발생하기 때문에..)
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
// setInterval 사용부
timerRef.current = setInterval(() => {
// ...
}, 1000);
* 그냥 clearInterval만 해주면 interval은 멈추지만 timer id는 제거되지 않는다.
그래서 timer id까지 제거해주기 위해 timerRef.current = null 로 timerRef를 비워주는 것.
위에서 Dan Abramov가 구현한 훅처럼 ref에다가 콜백을 저장하고
그 콜백을 매번 갈아주는 함수를 만드는 방법보다 그냥 clear, set 해주는 게 단순하고 보기 편하다고 생각했다.
근데 막상 해보니 setInterval을 하는 모든 곳에다가 clear, set을 전부 해줘야돼서 더 귀찮아졌다..
updater function을 사용하면 setInterval이 정상적으로 최신 state를 반영할 수 있다는 건
글을 쓰면서 새로 알게 되었다. 저게 가능하다면, 굳이 매 번 귀찮게 clear, set 해줄 필요 없으니까 더 편할 듯.
세 가지 방법 중에 가장 괜찮다 싶은 걸로다가 갖다 써야지.
+ setInterval이 어차피 초기화되므로, setTimeout을 쓰는 것과 다름이 없다.
그래서 해외 블로그 포스팅들을 보면 setTimeout을 사용한 해결책들을 많이 볼 수 있다.
Dan Abramov의 'Making setInterval declarative with React Hooks' 번역본)