본문 바로가기
Woowa Techcourse/Missions

React에서 setInterval 현명하게 사용하기(feat. useInterval)

by mingule 2021. 8. 26.

들어가기

Babble의 방 목록 페이지에 들어가면 유저가 생성한 방들이 쭉 나열되어 있는 것을 볼 수 있다. (안타깝게도 유저가 없으면 방도 없다ㅜㅜ)

그리고 이 방들은 5초마다 서버에 요청을 보내 새로이 갱신되는데, 어떤 유저가 새로 방을 만들면 다른 유저들에게도 그 방이 생겼다는 것을 보여줘야 하기 때문이다. 

현재는 유저가 많이 없기도 하고 또 실시간성을 보여주기 위해 이 방법을 사용하고는 있지만, 서버에 많은 요청이 가는 방법이기 때문에 추후에 새로고침 버튼 등을 이용해 개선하려고 생각중이다. 처음에는 개인적으로 '그래도 유저의 편리함이 우선시 되어야하지 않을까?' 라고 잠깐 생각하기도 했지만 유저가 많아지면 부하가 정말 심해질 것 같아서 이렇게 서버에 짧은 간격으로 요청을 계속 보내는 것은 좋지 못한 방법이라고 판단했다. 이에 관련한 내용은 추후에 차차 개선해 나갈 예정이다.

 

일단 위와 같은 이유로 Babble 프론트엔드 팀은 5초마다 서버에 요청을 보내 방을 그리도록 구현했고, 이번 글의 내용은 이를 구현하면서 생긴 몇가지 문제에 관한 내용이다.

🚨 문제 발생!

setInterval은 일정 시간마다 작업을 수행하는 메서드다. 5초라는 주기로 서버에 요청을 보내야하기 때문에, 당연히 setInterval을 써야 할 것으로 생각했고, 실제로도 그렇게 구현했다.

구현한 첫 번째 코드는 아래와 같다.

useEffect(() => {
  let timer = setInterval(() => {
    const selectedTagIdParam = selectedTagList.map(({ id }) => id).join(',');
    getRooms(selectedTagIdParam); // 서버에 요청해 방 목록을 불러오는 메서드
  }, 5000);
  
  return () => clearInterval(timer)
}, []);

방 목록에서 유저는 태그를 이용해 방을 검색할 수 있다. 그렇기 때문에 5초마다 불러오는 getRooms 메서드에도 태그 정보를 포함해 방 목록을 요청해야 한다. 방 목록은 5초마다 업데이트 되면서, 유저가 검색한 태그는 초기화되지 않도록 구현했다(고 생각했다).

 

그래서 실제로 코드를 돌려보니, 5초마다 새로운 방이 그려지는 것은 확인했지만, 태그 검색에 문제가 있었다. 

처음 태그를 검색했을 때에는 방을 검색하는 데 문제가 없었지만, 태그를 검색하고 몇 초 지나지 않아 방 목록이 다시 처음으로 돌아가는 문제가 발생했다. setInterval을 사용해 잘 불러오고 있다고 생각했는데, 왜 이렇게 동작했을까?

 

🍯 현상 분석

문제 1. - setInterval의 시간 보장

React에서 리렌더링이 일어나면, 함수가 새로 실행된다. 보통의 subscription API들은 이렇게 새로 함수가 실행되면, 이전의 subscription을 해제하고 새로운 subscription을 만드는데 setInterval은 그렇지 못하다. 

우리가 만든 interval을 해제하고 싶다면, clearInterval을 사용해 직접 timer를 해제해야한다. 

위에 작성된 코드도 마찬가지로 이러한 이유에서 페이지가 unmount될 때 clearInterval을 해주고 있다. 

 

그러면 이 함수는 완벽히 동작할까? 답은 그렇지 않다.

setInterval은 사실 우리가 원하는 delay 시간을 100% 보장하지 못하기 때문이다. 

setInterval은 함수를 실행하는 시간조차 delay에 포함시키기 때문에, 만약 함수를 실행하는 시간이 delay 시간보다 길다면 타이머가 제대로 작동하지 않는다. 이런 상황에서 setInterval은 함수의 실행이 종료될 때까지 기다렸다가 함수의 실행이 종료되면 다음 함수를 즉시 실행한다. 1초 마다 한 번씩 함수가 호출되기를 바랬는데, 함수의 실행이 1초보다 길어져버리면 함수가 실행이 끝난 후에 1초를 기다리지 않고 다음 함수를 바로 실행해버린다는 뜻이다. 

 

아래는 Javascript.info에서 가져온 관련 사진인데, 그림을 보면 더 이해하기 쉽다.

https://ko.javascript.info/settimeout-setinterval

그렇기 때문에 우리가 위에서 만든 useEffect 안의 setInterval 메서드도 이러한 문제점이 나타날 여지가 분명히 있다. 만약 리렌더링이 자주 일어나는데 render를 할 때마다 계속 timer를 설정해주거나, getRooms를 실행하는 데 5초 이상이 걸린다면 이러한 문제가 나타날 것이라고 예상할 수 있겠다. 

 

문제 2. 예기치 못한 Closure

하지만 여기까지만 봤을 때에도, 사실상 setInterval 함수가 동작하는 데 있어서는 문제가 없어야 한다. 하지만 우리가 만든 setInterval 함수는 태그를 검색했을 때 다시 초기화되는 문제가 있었다. 이 문제는 왜 나타났을까?

 

간단한 예시를 먼저 보자. 

아래는 멋진 Dan 형님의 Counter 예시이다. 이 예시 또한 마찬가지로 코드 상의 문제는 없어보이지만 제대로 동작하지 않는다.

이 Counter는 마치 1초마다 +1이 되어야 할 것 같은데, 그렇게 동작하지 못하고 count가 계속 1에서 머문다.

function Counter() {
  let [count, setCount] = useState(0);

  useEffect(() => {
    let id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  }, []);

  return <h1>{count}</h1>;
}

const rootElement = document.getElementById("root");
ReactDOM.render(<Counter />, rootElement);

우리가 겪었던 selectedTagIdParam이 계속 처음 State로 초기화되는 문제와 굉장히 유사하다. 조금 더 살펴보자.

이렇게 되는 이유는 useEffect가 첫 render에 count를 capture하기 때문이다. useEffect는 처음 mount되었을 때, setInterval 동작을 실행시킨다. 즉, count가 0일 때 setInterval을 실행시키는 것이다. 여기까지 아무 문제가 없다고 생각할 수 있지만, 여기를 잘 살펴보아야 한다. Closure 개념과 Event Loop를 통한 setInterval의 동작을 한 번 생각해보면 쉽게 이해할 수 있다. 

 

먼저, Closure의 개념에 대해 간단히 생각해보자. 말 그대로, 갇혀있다. 라는 뜻으로 생각해보면 이해하기 좋다. 

어떤 내부 함수를 감싸는 외부 함수가 실행되고나서 종료되었다 하더라도, 내부 함수에서 외부 함수의 값에 접근할 수 있는 현상을 Closure라고 한다. 나를 감싸는 외부 함수(setInterval)는 이미 종료되어 사라졌는데, 내(setCount)가 계속 그 값을 기억하고 있는 것이다. 

요 개념을 되뇌이며 아래 이벤트 루프로 넘어가보자.

 

브라우저에서 자바스크립트의 실행 과정을 한 번 간단히 떠올려보자.

자바스크립트는 single thread 언어이기 때문에 자바스크립트 엔진은 한 번에 하나의 작업만 가능하다. 그렇기 때문에 실행 시간이 오래 걸리는 함수를 호출하게 되면 화면이 멈추게 된다. 브라우저에서는 이러한 문제점을 Web API를 사용해 해결한다. Call Stack 에서 비동기 함수가 호출되면 Web API를 통해 Callback Queue에 쌓이게 되고, 이 Queue는 Call Stack이 비면 실행된다. 

 

setInterval 메소드도 브라우저에서 제공하는 Web API 중 하나이기 때문에 호출되면 바로 실행되지 않고 우리가 등록한 delay 시간을 기다렸다가 Callback Queue에 쌓인다. 그리고 Call Stack이 비면 그때 실행된다. 그리고 실행된 setInterval은 한 번 호출된 후에 바로 종료된다. 이제부터 setInterval이 주기적으로 실행하라고 남겨놓은 setCount 함수가 주기적으로 실행된다. 

 

위의 Closure에서 외부 함수가 종료되어도 내부 함수가 그 함수의 값을 기억한다고 했는데, 이게 바로 그 상황이다. setInterval은 종료되었지만, setInterval의 내부 함수인 setCount가 실행될 때마다 그 초기값이었던 0을 기억하고 계속 + 1을 하는 것이다. 다시 실행될 때에도 마찬가지다. setCount가 기억하는 count는 계속 0이기때문에, 값이 계속 1이 된다.

 

🍑 해결하기

setState에 callback 넘겨주기

count가 우리가 원하는 대로 동작하려면, count가 0에서 1이 되고, 1이 된 것을 기억했다가 다시 +1을 해서 2가 되어야 한다. 즉, 바뀌기 전의 state를 기억해야한다. 이를 해결하기 위한 다양한 방법들이 있지만, 가장 쉬운 방법은 setState에 callback 함수를 넘겨주는 것이다. setState에서는 이전의 state를 보장할 수 있는 방법을 제공하고 있다. setState에 callback 함수를 넘겨주면 해결된다. 즉, setCount((previousCount) => previousCount + 1)을 하게 되면 문제가 해결된다.

 

이 밖에도 useReducer를 사용하는 방법도 있지만, 이 게시글에서는 따로 다루지 않겠다. 더 궁금한 사람들은 이 링크의 예시를 보면 좋을 것 같다.

 

그런데 이런 방법을 통해 문제를 당장 해결하더라도 setInterval을 React에서 사용하는 것이 불편한 이유가 한가지 존재한다. react의 lifecycle과 다소 벗어난 행동을 한다는 것이다. state가 바뀌면 React는 리렌더링을 하게 되는데, setInterval은 렌더와 관계없이 계속 살아남아있는다. React는 리렌더링을 하면서 이전의 render된 내용들을 다 잊고 새로 그리게 되는데, setInterval은 그렇지 않다. Timer를 새로 설정하지 않는 이상 계속 이전의 내용(props나 state)들을 기억하고 있다. 

 

useInterval 사용하기

이런 문제점들을 해결하기 위해, useInterval이 나왔다. 

useInterval은 react에서 기본적으로 제공하는 hook이 아니라, Dan 형님께서 만든 멋진 custom Hook이다. (기본 제공에 좀 넣어주지)

import { useState, useEffect, useRef } from 'react';

function useInterval(callback, delay) {
  const savedCallback = useRef(); // 최근에 들어온 callback을 저장할 ref를 하나 만든다.

  useEffect(() => {
    savedCallback.current = callback; // callback이 바뀔 때마다 ref를 업데이트 해준다.
  }, [callback]);

  useEffect(() => {
    function tick() {
      savedCallback.current(); // tick이 실행되면 callback 함수를 실행시킨다.
    }
    if (delay !== null) { // 만약 delay가 null이 아니라면 
      let id = setInterval(tick, delay); // delay에 맞추어 interval을 새로 실행시킨다.
      return () => clearInterval(id); // unmount될 때 clearInterval을 해준다.
    }
  }, [delay]); // delay가 바뀔 때마다 새로 실행된다.
}

이 Hook은 interval을 set하고 unmount 되기 전에 clearInterval을 해준다. 즉, React의 Lifecycle에 맞게 새로 태어난 슈퍼빠월 setInterval이라고 볼 수 있다. 처음에 이 코드를 이해하려 할 때 조금 애먹었는데, 이해한 대로 주석에 적어봤다.

useRef를 사용해 setInterval이 React의 Lifecycle과 함께 동작하도록 만들어주었다. 

 

위의 예시 코드는 아래의 useInterval 예시 사용코드처럼 timer를 잠깐 멈출 수 있게 할 의도로 if (delay !== null) 옵션을 넣어놓았다.

  useInterval(() => {
    setCount(count + 1);
  }, isRunning ? delay : null);

우리는 timer를 멈추는 코드가 필요없다고 판단해 쿨하게 지우고 아래와 같이 사용했다.

import { useEffect, useRef } from 'react';

const useInterval = (callback, delay) => {
  const savedCallback = useRef(null);

  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  useEffect(() => {
    const executeCallback = () => {
      savedCallback.current();
    };

    const timerId = setInterval(executeCallback, delay);

    return () => clearInterval(timerId);
  }, []);
};

export default useInterval;

여기서 잠깐! 왜 savedCallback을 저장할 때에 state 대신 ref를 사용했을까?

useRef와 useState의 가장 큰 차이점은 리렌더링의 유무이다. 

setState로 State의 값을 바꾸어주면 함수가 새로 실행되면서 리렌더링이 일어난다.

반면 ref.current에 새로운 값을 넣어주더라도 리렌더링은 일어나지 않는다.

 

만약 위의 코드에서 아래의 예시 코드처럼 useState을 사용했다면,

useEffect 안에서 savedCallback을 새로 set할 때 리렌더링이 일어나면서 아래에 주석으로 남겨놓은 문제점이 일어나게된다.

import { useEffect, useState } from 'react';

const useInterval = (callback, delay) => {
  const [savedCallback, setSavedCallback] = useState(null) // useState사용

  // callback이 바뀔 때마다 실행
  // 첫 실행에 callback이 한 번 들어옴 -> 리렌더링 -> 다시 들어옴 -> 리렌더링 -> .. 무한 반복
  // 원래의 의도는 callback이 새로 들어오면 그 callback을 저장해두고 아래의 setInterval을 다시 실행해주려는 의도
  useEffect(() => {
    setSavedCallback(callback);
  }, [callback]);
  
  // mount가 끝나고 1번 일어남
  // 맨 처음 mount가 끝나고 savedCallback은 null이기 때문에 setInterval의 executeCallback이 제대로 실행되지 않음 (null이기 때문에)
  useEffect(() => {
    console.log(savedCallback());
    const executeCallback = () => {
      savedCallback();
    };

    const timerId = setInterval(executeCallback, delay);

    return () => clearInterval(timerId);
  }, []);
};

export default useInterval;

값이 바뀌어도 리렌더링이 일어나지 않는 Ref를 사용해야 제대로 동작한다.

만약 위와 같이 사용한다면 savedCallback을 console에 찍어봐도 null이 계속 찍힐 것이고, 

아래와 같은 오류를 계속해서 내뱉게 될 것이다.


그리고 최종 코드를 useInterval을 활용한 코드로 변경했다.

코드도 한결 간결해졌고, 문제 없이 잘 돌아가는 것을 확인했다. 

 useInterval(() => {
    const selectedTagIdParam = selectedTagList.map(({ id }) => id).join(',');
    getRooms(selectedTagIdParam);
  }, 5000);

마치며

setInterval을 React 내부에서 동작하게 만들면서, 레벨 초반에 Vanilla Javascript를 배우며 나왔던 것을이 하나씩 쏙쏙 등장하는 것을 보며 반갑기도 하고 신기했다. 피터와 함께 이런 저런 고민도 많이 해보고 또 직접 시도를 하면서 배운 점이 많아서 즐거웠다. 

 

 

아래는 참고했던 글과 동영상이다. 헤헤헹

- setInterval, setTimeout에 대한 이해

 

setTimeout과 setInterval을 이용한 호출 스케줄링

 

ko.javascript.info

- useInterval을 만든 Dan의 글 (영어긴 해서 읽기는 싫지만 막상 읽으면 또 이해는 잘 된다)

 

Making setInterval Declarative with React Hooks

How I learned to stop worrying and love refs.

overreacted.io

- 이벤트 루프에 대한 쉬운 이해를 할 수 있는 영상

- Closure에 대해 쉽게 이해할 수 있는 영상 

 

댓글